From 9a9441899ac98d7804efd4516fde4fa4649b1b5d Mon Sep 17 00:00:00 2001 From: DramaticShape Date: Fri, 31 Jul 2026 22:54:16 -0400 Subject: [PATCH 001/116] first pass at water --- CHANGELOG.md | 154 ++++++++++++++ README.md | 17 ++ lib/BattleScene.lua | 47 ++++- lib/ChunkMesher.lua | 96 +++++++-- lib/Sky.lua | 74 ++++++- lib/Voxel3D.lua | 243 ++++++++++++++++++++-- lib/VoxelScene.lua | 204 +++++++++++++++---- main.lua | 24 ++- manifest.json | 4 +- mod.card | 4 + tests/dramatic_shape_test.lua | 367 +++++++++++++++++++++++++++++++++- 11 files changed, 1139 insertions(+), 95 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eed2d45..d03334c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,159 @@ # Changelog +## 1.4.0 + +### Added + +- **WATER, a new row on hotkey 9: water reflects the world, the sky, the sun + and the moon.** Every lake, sea and pond in Kanto was a flat animated + texture lying in a hole in the ground. It is now a surface, and it is + reflective. + + What it reflects, in the order the shader resolves them: + + - **The sky.** The reflected direction goes through the very matrix the + frame is drawn with, as a point at infinity, and the canvas row that + lands on is looked up on Sky's own band ramp -- the identical texture, + the identical checkerboard dither, the identical display-mode transform. + So the sky in the lake is the sky over it, and the two meet at the + waterline with no seam at any pitch, field of view, window shape or zoom. + Blue at noon, gold at dusk, navy under the moon; GRAY gets a grey lake + and CLASSIC a green one, for nothing. + + - **The sun and the moon**, hung by ANGLE rather than by screen position, + because a reflected body is usually off the top of the frame entirely + and a projected point stops meaning anything out there. The angular + radius is the painted disc's own radius run back through the camera's + field of view, so the two are the same size -- craters, dithered rim, + the sunset's loom and all, off one shared list. This is also the + specular: a low sun lays a broken gold path across the water on its own, + out of the reflection rather than out of a highlight term nailed on + beside it. + + - **The world, in screen space.** The reflected ray is walked forward in + world space, each step projected through the same matrix, looking for + where it passes behind what the depth buffer holds -- then binary-refined + onto the contact and read out of a copy of the frame as it stood before + the water went down. Shore trees, buildings, ledges and cliffs land in + the water because they are on screen; where the ray leaves the frame or + finds nothing, the sky above answers instead, which is what makes the far + half of a lake sky and the near half scenery with no seam between them. + + Fresnel decides how much of it shows: almost nothing looked straight down + at, almost everything looked along -- so the 15-degree rung is a pond and + the 75-degree rung is a mirror, off the same surface. + + Every rung gets one, though, which took a lean. A reflection off flat water + points as far above the horizon as the eye is above the water: 15 degrees + at the top rung -- grazing the sky's pale end, sweeping the sun's own path, + travelling far enough across the screen for the march to find the shoreline + -- and 75 degrees, straight up, at the steepest. Up there the bands are at + their darkest, the sun and moon sit at about 6 degrees of squashed + elevation and are nowhere near it, and the screen-space ray leaves the top + of the frame in two steps. All three are correct, and together they are a + lake with nothing in it. + + So the reflection now LEANS toward the elevation the top rung reflects at, + by however far the camera is from having a horizon in frame -- **zero** at + the rung where the horizon IS in frame, so the one place the join can be + seen, the waterline, is still the exact reflection it was. Toward an + elevation rather than by a weight, because the ray it starts from differs + at every rung and a fixed fraction lands them all somewhere different: the + middle rungs came out further from the sun than the steepest one. And it + leans the LEVEL reflection with each column's own deflection added back on + top -- leaning the perturbed ray sets its elevation outright, which at full + lean gave every column on the lake the same one, flattened the sky to a + single band and removed the moon entirely. + + Three rungs rather than a toggle. FULL is the whole thing; SKY drops the + ray march and keeps the sky, sun and moon, which is most of the look for a + handful of instructions; OFF is the flat water this mode always drew. The + FULL preset sets it to FULL. + +- **The water surface is a field of pixel-tall columns, and they are real.** + Not a normal map: a heightfield of one-world-pixel bars -- the same unit + every other voxel in this mode is built from, and exactly one texel of the + water tile -- each standing a WHOLE number of pixels high and rising and + falling on its own. + + Three travelling wave trains, and one of them dominates: a wave has a + DIRECTION, and its crest is a line running across it for as far as the + water goes. Three trains of equal weight cancel and reinforce in patches + instead, and the surface comes out as round islands of raised pixels with + no travel to them -- blobs rather than waves. The dominant train's + wavelength is about forty world pixels, five tiles, so a crest is a long + run of columns at one height with a step down either side. + + Drawn with no extra geometry at all: the mesh is still one flat quad per + tile, and the columns are found by walking the view ray down through the + slab in the pixel shader. That is what makes them read as solid -- a tall + bar hides the shorter ones behind it, you see the SIDE of the ones facing + you (wearing the mesh's own direction shading, so a crest is lit like every + other voxel in the world), and the whole field parallaxes against the plane + as the camera moves. The water's art is read at the column the ray landed + on rather than at the flat quad underneath, so the pixels travel with the + bars they are made of. + + The columns are what you SEE; the normal they reflect with is read off the + smooth surface they are a quantisation of. That distinction is the whole + difference between a moon on the water and confetti: whole-pixel heights + have whole-pixel differences, so a normal built from them can only point in + about five directions, and a sun or moon barely two degrees across falls + between them. Still one normal per column, so the surface stays + pixel-quantised in space while the value it reflects with is continuous. + + Crests stand up to five world pixels, well past the 2px recess water sits + in -- deliberately, because the columns are relief drawn inside the water + quad's own footprint, so a bar that reaches above the bank is clipped at + the water's edge rather than spilling over it. What it buys is a surface + with real swell in it instead of a two-rung terrace. + + And it moves in STEPS, at **15 a second** -- the cadence hand-drawn pixel + art is animated at. A surface built out of whole pixels that crawls + smoothly between them gives away that the quantisation is only skin deep. + Each step advances the dominant wave by exactly one world pixel, derived + from that train's own wavelength rather than tuned beside it, so nothing + ever lands half-way between two pixels and changing a wavelength moves the + speed with it. + + +### Changed + +- **The water surface is its own mesh, and its own pass.** A mirror cannot be + drawn until what it reflects exists, so water is lifted out of the terrain + mesh at build time and drawn between the world and the characters. The + shoreline faces around it are untouched -- they belong to the GROUND that + exposes them -- and the sun still sees the surface, so a tree at the water's + edge still throws its shadow onto the lake. + +- **The scene's depth buffer is a readable canvas.** It was an internal buffer + that could be written and tested and never sampled; it is now the same + buffer with a texture handle on it, at the same cost. Drivers that will not + make one fall straight back to the old buffer and lose the reflections and + nothing else. + +- **The cast is reflected too -- by being drawn twice.** Gen 1 draws people + over the world and water is world, so a surfing player has to composite + OVER the water they are sitting on, which puts them after it; and a + reflection can only hold what came before it. So the walkers, the NPCs and + the authored figures are painted into the reflection COPY alone, where they + are in the picture the water reflects and not yet in the picture the water + is drawn into. Both draws go through one function, so they cannot come out + different. The staged battle does the same with its two Pokemon. + + The ray march finds them the honest way round: a sprite is not in the depth + buffer at that point, so a ray aimed at one passes through to the terrain + standing behind it and reads the copy there -- where the sprite is already + painted. The reflection lands a hair off the sprite's own depth and exactly + on its colour, which at a lake's worth of wave is the same picture. + +### Known + +- Screen-space reflections can only reflect what is in the frame. A tree just + off the top edge is not in the water below it, and a reflection whose ray + runs off the side of the screen fades into the sky rather than ending on a + hard line. + ## 1.3.1 ### Fixed diff --git a/README.md b/README.md index 6886926..db18394 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,22 @@ as leaning sprite slabs, a shadow map throws real cast shadows across whatever they land on, and an optional tilt-shift pass sells the miniature-model look. +Water is a surface rather than a texture lying in a hole. It is a field of +one-pixel-wide voxel columns, each standing a whole number of pixels tall and +rising and falling as waves — found by walking the view ray through them in +the shader, so a crest hides what is behind it and shows you its lit side, +with no extra geometry anywhere. + +And it reflects. The sky it stands under, in the same bands, the same dither +and off the same clock, so the lake and the sky above it meet at the +waterline with no seam. The sun or moon hanging in it, at the size the +painted disc is drawn, craters and all. Whoever is standing beside it — +walkers, NPCs, the two Pokémon in a staged battle. And on **FULL**, a +screen-space ray march adds the rest of what is on screen: the shoreline, the +trees behind it, the buildings across the bay. How much of it shows is +Fresnel, so the top rung is a mirror and a looking-straight-down rung is a +pond, off the same water. + And battles fought on that world rather than on a white field. When something picks a fight the map's NPCs are culled, the engine's own wipe plays over the empty map, and the battle draws over the nearest patch of @@ -34,6 +50,7 @@ menu. | `6`, or the **T-SHIFT** options row | OFF → 1 → 2 → 3 → OFF (miniature blur) | | `7`, or the **V-CURVE** options row | OFF → 1 → 2 → 3 — bend the world over the horizon | | `8`, or the **3D-BTL** options row | ON / OFF — fight on the map instead of on a white field | +| `9`, or the **WATER** options row | FULL / SKY / OFF — waves and reflections on water. **SKY** gives the surface its pixel-tall wave columns and puts the sky, the sun, the moon and the cast in them; **FULL** adds a screen-space ray march that also reflects the shoreline, the trees and the buildings standing behind it | | the **BACK SPRITES** options row | OFF / ON — keep your own Pokémon on the battle menu, seen from behind in its classic slot, instead of standing it on the map; the foe is still out there. Only on the menu while **3D-BTL** is on, because it decides nothing without it | | the **DAYTIME** options row | SYNC / DAY / NIGHT / DUSK / DAWN / CYCLE — what time it is outdoors, on the diorama *and* on the flat 2D world; held at SYNC (and off the menu) while VOXEL is FULL | diff --git a/lib/BattleScene.lua b/lib/BattleScene.lua index d8d0170..d483ed5 100644 --- a/lib/BattleScene.lua +++ b/lib/BattleScene.lua @@ -141,9 +141,10 @@ local function prefetchArena(state, host) for _, nb in ipairs(state.neighbors or {}) do live[nb.map.id] = true end ChunkMesher.setLive(live) TerrainAtlas.setLive(live) - local terrain = ChunkMesher.request(host, false, nil, true) - or ChunkMesher.peek(host, true) - return terrain, {} + ChunkMesher.request(host, false, nil, true) + local terrain, water = ChunkMesher.pair(host, false) + if not terrain then terrain, water = ChunkMesher.pair(host, true) end + return terrain, {}, water, {} end -- ------- the sun @@ -227,7 +228,8 @@ local function shadowSignature(state, arena, terrain, nbMesh, token) end local function castShadows(state, arena, terrain, nbMesh, cx, cy, vw, vh, - atlasFor, cards, token, host, neighbors) + atlasFor, cards, token, host, neighbors, + water, nbWater) if not ShadowMap.available() then return end local sig = shadowSignature(state, arena, terrain, nbMesh, token) if not ShadowMap.stale(sig) then return end @@ -237,6 +239,14 @@ local function castShadows(state, arena, terrain, nbMesh, cx, cy, vw, vh, for i, nb in ipairs(neighbors) do ShadowMap.draw(nbMesh[i], atlasFor(nb.map), Mat4.translate(nb.ox, 0, nb.oy)) end + -- the water surface is its own reflective pass now (see Water) and so is + -- no longer inside the terrain mesh; the sun still has to see it, or the + -- light's map has a hole at every lake + ShadowMap.draw(water, atlasFor(host), nil) + for i, nb in ipairs(neighbors) do + ShadowMap.draw(nbWater and nbWater[i], atlasFor(nb.map), + Mat4.translate(nb.ox, 0, nb.oy)) + end -- thin cards are snugged toward the sun (ShadowMap.snug) so their shadows -- keep contact with their bases instead of starting a bias-width away ShadowMap.draw(ChunkMesher.flowers(host), atlasFor(host), @@ -326,7 +336,7 @@ function BattleScene.render(state, arena, textures, token) -- shares the free-roam mode's request/evict bookkeeping, so a battle warms -- exactly the meshes walking around would have and nothing extra - local terrain, nbMesh = prefetchArena(state, host) + local terrain, nbMesh, water, nbWater = prefetchArena(state, host) if not terrain then return nil end local lx, ly, s, pw, ph = BattleScene.letterbox() @@ -356,7 +366,7 @@ function BattleScene.render(state, arena, textures, token) local cards = monCards(arena, groundY, textures) Voxel3D.camera = nil castShadows(state, arena, terrain, nbMesh, cx, cy, vw, vh, atlasFor, - cards, token, host, neighbors) + cards, token, host, neighbors, water, nbWater) -- An opaque void either way. Outdoors the camera is low enough that the -- horizon is genuinely in frame, so it is sky; indoors it is the dark end @@ -393,6 +403,31 @@ function BattleScene.render(state, arena, textures, token) Voxel3D.draw(nbMesh[i], atlasFor(nb.map), Mat4.translate(nb.ox, 0, nb.oy)) end + -- and the water over it, reflecting the arena and the hour's sky (see + -- VoxelScene.drawWater -- the arena fights on the same lakes). The two + -- mons are this shot's cast: painted into the reflection copy alone, so + -- a fight staged at the water's edge has both of them in the water while + -- they still composite over it, exactly as the overworld's walkers do. + local waterDraws = {} + if water then waterDraws[#waterDraws + 1] = { water, atlasFor(host) } end + for i, nb in ipairs(neighbors) do + if nbWater and nbWater[i] then + waterDraws[#waterDraws + 1] = { nbWater[i], atlasFor(nb.map), + Mat4.translate(nb.ox, 0, nb.oy) } + end + end + if #waterDraws > 0 then + VoxelScene.drawWater(waterDraws, function() + Voxel3D.seams(false) + Voxel3D.glass(false) + for _, card in ipairs(monCards(arena, groundY, textures)) do + Voxel3D.draw(BattleBillboard.mesh(), card.tex, card.model, + BattleBillboard.PULL, ShadowMap.snug(card.model)) + end + Voxel3D.glass(true) + Voxel3D.seams(true) + end) + end -- The mons, standing on their tiles. Depth-tested like everything else, -- so a ledge or a tree between the camera and a Pokemon really is in -- front of it, and the alpha discard cuts the sprite's own outline out of diff --git a/lib/ChunkMesher.lua b/lib/ChunkMesher.lua index d8b2310..73ed890 100644 --- a/lib/ChunkMesher.lua +++ b/lib/ChunkMesher.lua @@ -221,8 +221,18 @@ end -- Kept free of any GPU call so it can be exercised headless -- the -- geometry is the part with the interesting invariants, and a suite that -- needed a real GL context to check them would never run in CI. -local function runGeometry(map, bodyOnly, masks, sink) +-- `waterSink`, when given, takes the WATER SURFACE quads instead of the +-- main sink -- the one class in this world that is drawn as its own pass +-- (see Water: a mirror cannot be drawn until what it reflects exists). +-- Nothing else moves: the quads are the same quads, emitted by the same +-- corner and uv arithmetic at the same recessed height, and the shoreline +-- faces around them still belong to the GROUND that exposes them. +-- +-- Omitted, water stays in the terrain mesh exactly as it always did, which +-- is what the headless geometry() below and the sun's own pass both want. +local function runGeometry(map, bodyOnly, masks, sink, waterSink) local push = sink.push + local waterPush = waterSink and waterSink.push or nil local tileset = map.tileset local S = Structures.forMap(map) local perRow = tileset.tilesPerRow or 16 @@ -358,12 +368,14 @@ local function runGeometry(map, bodyOnly, masks, sink) return aoSide end - local function topQuad(x0, z0, h, tile, shade) + -- `to` routes the quad somewhere other than the main sink -- the water + -- surface is the only caller that ever does (see runGeometry's header). + local function topQuad(x0, z0, h, tile, shade, to) local u0, u1, v0, v1 = uvRect(tile, 0, 8) - push({ { x0, h, z0 }, { x0 + 8, h, z0 }, - { x0 + 8, h, z0 + 8 }, { x0, h, z0 + 8 } }, - { { u0, v0 }, { u1, v0 }, { u1, v1 }, { u0, v1 } }, - aoShades(x0 / 8, z0 / 8, h, shade)) + ;(to or push)({ { x0, h, z0 }, { x0 + 8, h, z0 }, + { x0 + 8, h, z0 + 8 }, { x0, h, z0 + 8 } }, + { { u0, v0 }, { u1, v0 }, { u1, v1 }, { u0, v1 } }, + aoShades(x0 / 8, z0 / 8, h, shade)) end -- vertical quad for face direction `d` of the tile column at (x0, z0), @@ -558,8 +570,14 @@ local function runGeometry(map, bodyOnly, masks, sink) end topTile = S.tileAt[keyOf(tx, row)] end + -- water's surface, and only water's: the recessed sheet itself, + -- never the ground's shoreline bands around it. A cell an object + -- stands on took the branch above and paints synthesized GROUND, + -- which is right -- a sign at the waterline stands on a plot, not + -- on the pond. topQuad(x0, z0, h, topTile, - s.art == "upright" and VOLUME_TOP_SHADE or 1) + s.art == "upright" and VOLUME_TOP_SHADE or 1, + (s.class == "water") and waterPush or nil) end -- sides: 8px bands wherever the neighbour is lower. Band k spans @@ -764,18 +782,34 @@ end -- The raw geometry for `map`: (vertex list, triangle index list, quad -- count). Synchronous and GPU-free -- the headless suite and the probes -- exercise the invariants through this. -function ChunkMesher.geometry(map, bodyOnly, masks) +-- +-- `split` lifts the water surface out, as it is lifted out for the +-- reflective pass, and appends that sink's own three values -- so the suite +-- can check the same separation the GPU path relies on without a GPU. +-- Without it the water is in the first list, which is what every existing +-- caller reads. +function ChunkMesher.geometry(map, bodyOnly, masks, split) local sink = newTableSink() - runGeometry(map, bodyOnly, masks, sink) - return sink.results() + local waterSink = split and newTableSink() or nil + runGeometry(map, bodyOnly, masks, sink, waterSink) + if not waterSink then return sink.results() end + local v, i, n = sink.results() + local wv, wi, wn = waterSink.results() + return v, i, n, wv, wi, wn end -- Build the mesh for `map` synchronously. Returns nil when there is -- nothing to draw or meshes are unavailable (headless). -function ChunkMesher.build(map, bodyOnly, masks) +-- +-- `split` asks for the water surface as a SECOND mesh, returned after the +-- terrain one -- the shape the reflective pass needs (see Water). Without +-- it the water is inside the terrain mesh, which is the historical +-- contract and what every other caller still wants. +function ChunkMesher.build(map, bodyOnly, masks, split) local sink = newSink() - runGeometry(map, bodyOnly, masks, sink) - return sink.finish() + local waterSink = split and newSink() or nil + runGeometry(map, bodyOnly, masks, sink, waterSink) + return sink.finish(), waterSink and waterSink.finish() or nil end local function quadsMesh(quads) @@ -858,8 +892,17 @@ local function entry(id) return c end +-- The water surface that came out of a terrain slot's own build. Kept +-- beside it rather than in a slot of its own because the two are ONE +-- answer: a full mesh drawn beside a body build's water would draw the +-- ring's ponds twice and miss the body's own. +local function waterSlot(slot) + return slot .. "Water" +end + local function releaseEntry(c) - for _, slot in ipairs({ "full", "body", "grass", "flowers" }) do + for _, slot in ipairs({ "full", "body", "fullWater", "bodyWater", + "grass", "flowers" }) do local mesh = c[slot] if mesh and mesh.release then pcall(mesh.release, mesh) end c[slot] = nil @@ -924,13 +967,17 @@ local function runJob(job) if c.stale then c.stale.aux = nil end end local sink = newSink() - runGeometry(map, job.slot == "body", job.masks, sink) + local waterSink = newSink() + runGeometry(map, job.slot == "body", job.masks, sink, waterSink) local mesh = sink.finish() + local water = waterSink.finish() if (gen[job.id] or 0) ~= job.gen then if mesh and mesh.release then pcall(mesh.release, mesh) end + if water and water.release then pcall(water.release, water) end return end swapSlot(c, job.slot, mesh or false) + swapSlot(c, waterSlot(job.slot), water or false) if c.stale then c.stale[job.slot] = nil if not (c.stale.full or c.stale.body or c.stale.aux) then @@ -1032,12 +1079,14 @@ function ChunkMesher.get(map, bodyOnly, masks) if c.stale then c.stale.aux = nil end end if c[slot] == nil or (c.stale and c.stale[slot]) then - local ok, mesh = pcall(ChunkMesher.build, map, bodyOnly, masks) + local ok, mesh, water = pcall(ChunkMesher.build, map, bodyOnly, masks, + true) if not ok then print("[warn] voxel mesh build failed for " .. tostring(map.id) .. ": " .. tostring(mesh)) end swapSlot(c, slot, (ok and mesh) or false) + swapSlot(c, waterSlot(slot), (ok and water) or false) if c.stale then c.stale[slot] = nil if not (c.stale.full or c.stale.body or c.stale.aux) then @@ -1058,6 +1107,21 @@ function ChunkMesher.peek(map, bodyOnly) return mesh or nil end +-- A slot's terrain mesh AND the water surface lifted out of it, as one +-- answer. Never builds, like peek. +-- +-- Both or neither, always from the SAME slot: the water was cut out of that +-- exact geometry, so pairing a full mesh with a body build's water would +-- draw the border ring's ponds twice and leave the body's as holes. Callers +-- that fall back from one variant to the other fall back through this, so +-- there is nowhere for the two to be chosen separately. +function ChunkMesher.pair(map, bodyOnly) + local c = cache[map.id] + if not c then return nil, nil end + local slot = bodyOnly and "body" or "full" + return c[slot] or nil, c[waterSlot(slot)] or nil +end + function ChunkMesher.grass(map) local c = cache[map.id] return c and c.grass or nil diff --git a/lib/Sky.lua b/lib/Sky.lua index ec0ad2d..79431a4 100644 --- a/lib/Sky.lua +++ b/lib/Sky.lua @@ -263,6 +263,27 @@ end Sky._rampFor = rampFor -- named for the suite +-- The band ramp for the CURRENT bands, plus how many texels wide it is -- +-- for a pass that wants to read the same sky this one paints. The water's +-- reflection is the one caller: it looks the reflected direction up on this +-- very ramp, so the sky on the lake and the sky over it are one palette, +-- through one display-mode transform, off one clock. +-- +-- nil where the ramp could not be built, which is exactly when Sky.paint +-- falls back to flat bands -- so a driver that loses the gradient loses the +-- reflected gradient with it rather than showing two different skies. +function Sky.ramp() + local bands = Sky.bands() + if not (bands and bands[1]) then return nil end + local img = rampFor(bands) + if not img then return nil end + return img, #bands, bands +end + +-- How far the twilight glow reaches around the disc, in canvas pixels, for +-- a `w`-wide frame. The same number Sky.paint sends as `glowInvR`. +Sky.GLOW_REACH = 0.55 + local shader = nil -- nil = untried, false = unavailable local function getShader() @@ -323,20 +344,51 @@ end Sky.DISC_FRAC = 0.030 -- disc radius, as a fraction of the frame height Sky.DISC_MIN = 3 -- but never fewer cells than this across a radius --- crater centres as fractions of the radius, so they ride any disc size -local MOON_CRATERS = { { -0.4, -0.2 }, { 0.2, 0.45 }, { 0.5, -0.4 }, - { -0.15, 0.7 }, { 0.05, 0.05 } } +-- crater centres as fractions of the radius, so they ride any disc size. +-- Public because the water's reflection draws the same moon (see Water): +-- one list, so the disc on the lake cannot drift from the one in the sky. +Sky.MOON_CRATERS = { { -0.4, -0.2 }, { 0.2, 0.45 }, { 0.5, -0.4 }, + { -0.15, 0.7 }, { 0.05, 0.05 } } + +-- a crater's radius, as a fraction of the disc's -- the r/5 paintDisc uses +Sky.CRATER_FRAC = 0.2 + +local MOON_CRATERS = Sky.MOON_CRATERS + +-- The disc's four shades as the display mode has them, lightest first. +-- Shared with the reflection pass, so the sun on the water is the same sun +-- that is in the sky, in the same mode's palette. +function Sky.discShades(moon) + local src = moon and DayNight.MOON_COLORS or DayNight.SUN_COLORS + return PaletteFX.effectiveColors(src) or src +end + +-- Whether this body is the LOOMING low sun -- the sunset exaggeration. +local function looming(body) + return (body.glowAmt or 0) > 0.25 and not body.moon +end + +-- The disc's radius for a `h`-tall frame on a `cell`-pixel grid: in CANVAS +-- PIXELS, and in whole cells. Sized by the FRAME rather than by the world +-- (see DISC_FRAC), so a zoom does not swell the sun. +-- +-- Read by paintDisc below and by the reflection, which needs the same +-- number in radians -- a disc drawn one size and mirrored another would +-- read as two different suns. +function Sky.discRadius(h, cell, body) + cell = math.max(1, cell or 1) + local r = math.max(Sky.DISC_MIN, + math.floor(h * Sky.DISC_FRAC / cell + 0.5)) + if body and looming(body) then r = r + math.max(1, math.floor(r * 0.4)) end + return r * cell, r +end local function paintDisc(body, edge, cell, w, h) local g = love.graphics if not (body and body.y and g.setScissor) then return end - local src = body.moon and DayNight.MOON_COLORS or DayNight.SUN_COLORS - local shades = PaletteFX.effectiveColors(src) or src - local twilight = (body.glowAmt or 0) > 0.25 and not body.moon - local r = math.max(Sky.DISC_MIN, - math.floor(h * Sky.DISC_FRAC / cell + 0.5)) - -- the low sun looms: the classic sunset exaggeration, and it reads - if twilight then r = r + math.max(1, math.floor(r * 0.4)) end + local shades = Sky.discShades(body.moon) + local twilight = looming(body) + local _, r = Sky.discRadius(h, cell, body) -- snap the centre to the cell grid, like everything else in this sky local bx = math.floor(body.x / cell) * cell + cell / 2 local by = math.floor(body.y / cell) * cell + cell / 2 @@ -429,7 +481,7 @@ function Sky.paint(w, h, sky, horizonY, cell, body) if glowAmt > 0 then local gc = body.glowColor or { 248, 224, 168 } sh:send("glowPos", { body.x, body.y }) - sh:send("glowInvR", 1 / math.max(1, w * 0.55)) + sh:send("glowInvR", 1 / math.max(1, w * Sky.GLOW_REACH)) sh:send("glowColor", { gc[1] / 255, gc[2] / 255, gc[3] / 255 }) end end) diff --git a/lib/Voxel3D.lua b/lib/Voxel3D.lua index 62e98be..df2f311 100644 --- a/lib/Voxel3D.lua +++ b/lib/Voxel3D.lua @@ -283,8 +283,57 @@ local activeShader = nil -- the variant this pass bound -- resize, so the pair is stable for a session. local slots = {} local canvas, canvasW, canvasH = nil, 0, 0 -- the slot this pass bound +local held = nil -- and the whole record for it local active = false +-- A READABLE depth canvas, so a later pass in the same frame can ask the +-- buffer questions rather than only write to it -- which is the whole of +-- what makes screen-space reflections possible (see Water). +-- +-- `depth = true` in the target list, which is what this used to bind, +-- allocates an internal depth buffer that is written and tested and can +-- never be sampled. An explicit canvas is the same buffer with a texture +-- handle on it, and costs the same memory. +-- +-- nil where the driver will not make one -- the format is optional in GLES +-- and a canvas is the only honest test of it -- and beginScene falls +-- straight back to the internal buffer, which is exactly the old behaviour +-- minus the reflections. +local function newDepth(w, h) + if not (love.graphics and love.graphics.newCanvas) then return nil end + local ok, c = pcall(love.graphics.newCanvas, w, h, + { format = "depth24", readable = true }) + if not (ok and c) then return nil end + -- nearest: a depth is a distance, and a blend of two of them is a + -- distance to nothing. The march wants the texel it landed on. + pcall(c.setFilter, c, "nearest", "nearest") + pcall(c.setWrap, c, "clamp", "clamp") + -- and no compare mode: with one set, Texel returns a 0/1 shadow verdict + -- instead of the depth, which is not what any reader here wants + pcall(c.setDepthSampleMode, c) + return c +end + +-- The bound target for the slot this pass holds: the colour canvas plus +-- either the readable depth canvas or the internal buffer. +local function depthTarget() + if held and held.depth then + return { held.canvas, depthstencil = held.depth } + end + return { canvas, depth = true } +end + +-- Every GPU object one slot owns. The mirror is the copy of the frame the +-- water pass reads (see beginWater); it is only ever made if something asks +-- for one, so a session that never sees a lake never pays for it. +local function releaseSlot(slotHeld) + for _, key in ipairs({ "canvas", "depth", "mirror" }) do + local obj = slotHeld[key] + if obj and obj.release then pcall(obj.release, obj) end + slotHeld[key] = nil + end +end + local IDENTITY = Mat4.identity() -- Whether the driver admits to supporting derivatives. Only a hint -- @@ -379,6 +428,41 @@ end -- way either way. Voxel3D.camera = nil +-- ------- which way, and how steeply, this camera looks +-- +-- Two facts about the view direction, set alongside the eye and the focus +-- because they ARE the eye and the focus, and read by anything that has to +-- reason about the camera's ATTITUDE rather than about a point in front of +-- it: +-- +-- lookFlat the view direction flattened onto the ground plane and +-- normalized -- "the way the horizon lies from here", which is +-- what a reflection leans toward at the steeper rungs (Water). +-- descent how far below horizontal the view runs, as a sine: 0 looking +-- level, 1 looking straight down. It is the number that says +-- whether there is a horizon in frame at all, and it answers +-- the same way for the orbit and for a placed battle camera -- +-- which is why this is derived from the two vectors rather than +-- read off Voxel.angle, a rung the battle camera does not have. +-- +-- A camera looking exactly straight down has no horizontal direction at all, +-- and lookFlat then keeps whatever it last held rather than becoming a zero +-- vector nothing downstream could normalize. +Voxel3D.lookFlat = { 0, 0, -1 } +Voxel3D.descent = 0 + +local function setLook(eye, focus) + local dx = focus[1] - eye[1] + local dy = focus[2] - eye[2] + local dz = focus[3] - eye[3] + local len = math.sqrt(dx * dx + dy * dy + dz * dz) + if len < 1e-6 then return end + Voxel3D.descent = math.max(0, math.min(1, -dy / len)) + local flat = math.sqrt(dx * dx + dz * dz) + if flat < 1e-6 then return end + Voxel3D.lookFlat = { dx / flat, 0, dz / flat } +end + -- View and projection for a `vw` x `vh` world-pixel view centred on -- (cx, cy) in world pixels. Returns the combined matrix. function Voxel3D.viewProjection(cx, cy, vw, vh) @@ -389,10 +473,15 @@ function Voxel3D.viewProjection(cx, cy, vw, vh) -- kept beside the eye for horizonY: where the sky's pale end goes is a -- question about which way this camera looks, and only these two answer it Voxel3D.focus = focus + setLook(eye, focus) local dx = eye[1] - focus[1] local dy = eye[2] - focus[2] local dz = eye[3] - focus[3] local dist = math.max(1, math.sqrt(dx * dx + dy * dy + dz * dz)) + -- kept for the passes that measure an ANGLE against this camera rather + -- than a position: the water's reflected sun is sized in radians, and + -- radians per canvas pixel is exactly this over the frame height + Voxel3D.fovY = cam.fov local proj = Mat4.perspective(cam.fov, vw / vh, math.max(1, dist * 0.05), dist * 4 + 4096) -- the same clip-space Y flip the orbit needs, for the same reason: we @@ -409,12 +498,14 @@ function Voxel3D.viewProjection(cx, cy, vw, vh) -- the FOV that makes a straight-down camera at `dist` frame exactly `vh` -- world pixels, which is the framing the flat view already has local fov = 2 * math.atan(1 / (2 * focal)) + Voxel3D.fovY = fov local focus = { cx, 0, cy } local eye = { cx, dist * math.cos(a), cy + dist * math.sin(a) } -- exposed for camera-facing billboards (VoxelScene yaws sprites at it) Voxel3D.eye = eye Voxel3D.focus = focus + setLook(eye, focus) -- perpendicular to the view direction in the YZ plane: north is screen-up -- when looking straight down, +Y is screen-up when looking level. Never -- parallel to the view direction, so there is no degenerate a = 0 case. @@ -538,22 +629,29 @@ function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky, slot) end if not sh then return false end local name = slot or "world" - local held = slots[name] - if not (held and held.w == w and held.h == h) then + local slotHeld = slots[name] + if not (slotHeld and slotHeld.w == w and slotHeld.h == h) then local ok, c = pcall(love.graphics.newCanvas, w, h) if not ok then return false end c:setFilter("nearest", "nearest") - if held and held.canvas and held.canvas.release then - pcall(held.canvas.release, held.canvas) - end - held = { canvas = c, w = w, h = h } - slots[name] = held + if slotHeld then releaseSlot(slotHeld) end + -- the depth canvas is sized with its colour, so a window resize + -- reallocates the pair together and they can never disagree + slotHeld = { canvas = c, w = w, h = h, depth = newDepth(w, h) } + slots[name] = slotHeld end + held = slotHeld canvas, canvasW, canvasH = held.canvas, w, h -- a depth buffer is what makes occlusion real: walk behind a building and -- the building wins, with no y-sorting anywhere - local ok = pcall(love.graphics.setCanvas, - { canvas, depth = true }) + local ok = pcall(love.graphics.setCanvas, depthTarget()) + if not ok and held.depth then + -- the readable canvas would not bind; fall back to the internal buffer + -- for the rest of this session rather than losing the whole 3D pass + pcall(held.depth.release, held.depth) + held.depth = nil + ok = pcall(love.graphics.setCanvas, depthTarget()) + end if not ok then pcall(love.graphics.setCanvas) return false @@ -561,6 +659,14 @@ function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky, slot) -- Ahead of the clear, because the sky's bands are placed off the ground -- plane's vanishing line and that is a property of this matrix. Voxel3D.vp = Voxel3D.viewProjection(cx, cy, vw, vh) + -- This frame's pixels per WORLD pixel: the size a diorama pixel is on + -- screen. The sky's dither grid is cut to it, and so is the water's -- + -- one number, so the two break up on the same checkerboard. + Voxel3D.cell = w / math.max(1, vw or w) + -- and where the sky's bottom edge lands, which is what the reflection + -- reads its bands against (see Water). nil when nothing painted bands. + Voxel3D.skyEdge = (sky and sky.bands) + and Sky.region(h, Voxel3D.horizonY(h)) or nil if sky then love.graphics.clear(sky[1], sky[2], sky[3], sky[4] or 1, true, true) -- The sky goes down here, in the one window in this function where a @@ -573,7 +679,7 @@ function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky, slot) -- are the same size as the world's own and follow every resize and zoom. -- The banded sky also hangs the hour's sun or moon (skyBody projects it -- through this very camera); a flat sky has no bands and hangs nothing. - Sky.paint(w, h, sky, Voxel3D.horizonY(h), w / math.max(1, vw or w), + Sky.paint(w, h, sky, Voxel3D.horizonY(h), Voxel3D.cell, sky.bands and Voxel3D.skyBody(w, h) or nil) else love.graphics.clear(0, 0, 0, 0, true, true) @@ -720,6 +826,108 @@ function Voxel3D.flatten(color, amount) end end +-- ------------------------------------------------------- the water pass -- +-- +-- A reflective surface has to READ the frame it is being drawn into: the +-- colour of what is standing around it and the depth that says where. Both +-- are attachments of the target this pass is bound to, and a texture cannot +-- be sampled while it is one -- so for the length of the water draw the +-- frame is taken apart: +-- +-- the COLOUR is copied to a mirror canvas, which is a texture like any +-- other and is what the reflection samples. +-- +-- the DEPTH is simply detached. The water shader does the test itself +-- against the texture (see Water), which is the same comparison the +-- hardware would have made -- what it gives up is depth WRITES, and water +-- is flat, never overlaps itself, and has nothing drawn under it later. +-- +-- `paint`, when given, is called with the MIRROR bound and the scene shader +-- set, to add things that must be REFLECTED without being composited yet. +-- +-- The characters are the whole reason it exists. Gen 1 draws people over +-- the world and water is world, so the cast has to composite AFTER the +-- water -- but a reflection can only contain what was drawn BEFORE it, and +-- a lake with everyone standing beside it and nobody in it reads as glass. +-- Painting them into the mirror alone settles both: they are in the picture +-- the water reflects and not yet in the picture the water is drawn into. +-- +-- They go down depth-TESTED and depth-WRITE-FREE. Tested, so a figure behind +-- a building is behind it in the reflection too; write-free because the very +-- next thing to read that buffer is the water's own depth test, and a cast +-- that had written to it would punch itself out of the water it is standing +-- beside. +-- +-- Returns the two textures, or nil when there is nothing to hand over: no +-- readable depth canvas on this driver, or no pass open. A caller that gets +-- nil draws its water like ordinary terrain, which is what this mode always +-- did. +-- +-- MUST be paired with endWater, which puts the frame back together. +function Voxel3D.beginWater(paint) + if not (active and canvas and held and held.depth) then return nil end + if not held.mirror then + local ok, c = pcall(love.graphics.newCanvas, held.w, held.h) + if not (ok and c) then return nil end + pcall(c.setFilter, c, "nearest", "nearest") + pcall(c.setWrap, c, "clamp", "clamp") + held.mirror = c + end + love.graphics.setShader() + -- the frame's own depth rides along, so the paint below can test against + -- it; the copy underneath switches the test off rather than detaching it + local ok = pcall(love.graphics.setCanvas, + { held.mirror, depthstencil = held.depth }) + if not ok then + pcall(love.graphics.setCanvas, depthTarget()) + return nil + end + love.graphics.setDepthMode("always", false) + -- COLOUR only. The last two arguments are what keep the depth buffer the + -- frame's rather than this canvas's: cleared here, the water's own depth + -- test a few lines later would find nothing in front of anything and every + -- lake would draw straight through the buildings standing in it. + love.graphics.clear(0, 0, 0, 0, false, false) + -- premultiplied over a cleared target is a straight copy: every channel + -- lands exactly as it stood, including the alpha, so the mirror is the + -- frame rather than the frame composited against something + love.graphics.setBlendMode("alpha", "premultiplied") + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(canvas) + love.graphics.setBlendMode("alpha") + if paint and activeShader then + love.graphics.setDepthMode("lequal", false) + love.graphics.setShader(activeShader) + pcall(paint) + love.graphics.setShader() + end + love.graphics.setDepthMode() + -- and back to the scene canvas WITHOUT its depth: that texture is about + -- to be read + if not pcall(love.graphics.setCanvas, canvas) then + pcall(love.graphics.setCanvas, depthTarget()) + return nil + end + return held.mirror, held.depth +end + +-- Put the frame back: depth reattached, depth test and the scene shader as +-- the pass had them. Safe to call after a beginWater that returned nil. +function Voxel3D.endWater() + if not active then return end + pcall(love.graphics.setCanvas, depthTarget()) + pcall(love.graphics.setDepthMode, "lequal", true) + love.graphics.setColor(1, 1, 1, 1) + if activeShader then love.graphics.setShader(activeShader) end +end + +-- Whether a reflective water pass can run in this frame at all -- there is +-- a depth texture to read. Callers use it to choose between the water +-- shader and an ordinary terrain draw before they start moving canvases. +function Voxel3D.depthReadable() + return (active and held and held.depth) and true or false +end + -- Whether what is drawn next carries the voxel wireframe. false for the -- length of a draw, true to put it back. -- @@ -931,18 +1139,25 @@ function Voxel3D.canvas() return canvas end +-- The bound canvas's pixel size, for a pass that has to work in screen +-- coordinates (the water's reflection marches in them). +function Voxel3D.size() + return canvasW, canvasH +end + -- Drop the GPU objects (window resize, hot reload). function Voxel3D.invalidate() - for name, held in pairs(slots) do - if held.canvas and held.canvas.release then - pcall(held.canvas.release, held.canvas) - end + for name, slotHeld in pairs(slots) do + releaseSlot(slotHeld) slots[name] = nil end canvas, canvasW, canvasH = nil, 0, 0 + held = nil ShadowMap.invalidate() -- the sky is part of this pass and holds a shader of its own Sky.invalidate() + -- and so does the water, for the same reason + V.require("Water").invalidate() -- and the glass masks are textures of this context too GlassMask.invalidate() end diff --git a/lib/VoxelScene.lua b/lib/VoxelScene.lua index 01cfa0f..96bd4b8 100644 --- a/lib/VoxelScene.lua +++ b/lib/VoxelScene.lua @@ -21,6 +21,8 @@ local TileShape = V.require("TileShape") local TerrainAtlas = V.require("TerrainAtlas") local Voxel = V.require("VoxelState") local Sky = V.require("Sky") +local Water = V.require("Water") +local VoxelGrid = V.require("VoxelGrid") local DayNight = V.require("DayNight") local PaletteFX = require("src.render.PaletteFX") local Map = require("src.world.Map") @@ -404,17 +406,25 @@ function VoxelScene.prefetch(state) -- crossing demotes the map just left, and it must not vanish from -- behind the player while its body variant builds; its ring is -- already masked out under this map's body, so the stand-in is safe. - local terrain = ChunkMesher.request(state.map, false, masks, true) + -- The water surface rides along with whichever variant answers: it was + -- cut out of that build's own geometry (ChunkMesher.pair), so the two + -- always come from the same slot and a lake is never drawn twice or left + -- as a hole. + ChunkMesher.request(state.map, false, masks, true) + local terrain, water = ChunkMesher.pair(state.map, false) if not terrain then - terrain = ChunkMesher.peek(state.map, true) + terrain, water = ChunkMesher.pair(state.map, true) end - local nbMesh = {} + local nbMesh, nbWater = {}, {} for i, nb in ipairs(state.neighbors or {}) do - nbMesh[i] = ChunkMesher.request(nb.map, true) - or ChunkMesher.peek(nb.map, false) + ChunkMesher.request(nb.map, true) + nbMesh[i], nbWater[i] = ChunkMesher.pair(nb.map, true) + if not nbMesh[i] then + nbMesh[i], nbWater[i] = ChunkMesher.pair(nb.map, false) + end end Voxel.ready = terrain ~= nil - return terrain, nbMesh + return terrain, nbMesh, water, nbWater end -- Capture every entity's pose for this frame. pose() advances the hop / @@ -490,6 +500,123 @@ end local glint = {} +-- ------- the cast +-- +-- Everybody standing on the map: the walkers, and the authored FIGURES the +-- tileset draws into its own furniture (they ARE characters as far as the +-- artwork is concerned, just ones drawn by the tileset instead of by a +-- sprite sheet, so they get the same lean and the same camera-ward pull). +-- +-- One function because it is drawn TWICE and the two must be identical: once +-- into the frame, and once into the water's reflection copy (see drawWater -- +-- Gen 1 draws people over the world, and water is world, so the cast cannot +-- be composited before the water it has to appear in). +-- +-- Characters carry no wireframe out here, whatever the V-GRID row says. The +-- seams are what makes the WORLD read as built out of voxels, and the people +-- walking around in it are the one thing that should read as drawn instead -- +-- a grid over a 16x16 sprite lands a line every couple of display pixels and +-- turns a face into a mesh. (The battle pass makes the opposite call for its +-- own combatants, deliberately -- see BattleBillboard.) +-- +-- Sprite sheets until the figure pass: their texture coordinates mean +-- nothing to the tileset-shaped glass mask, so the glass is off or the +-- panes' atlas positions stripe the cast with lamplight at night. +local function drawCast(state, posed, atlasFor) + Voxel3D.glass(false) + Voxel3D.seams(false) + -- Characters, normally depth-tested: the camera-ward pull inside + -- drawEntity resolves the lean-over-the-wall-in-front case, and a + -- character genuinely behind a building is far deeper and loses the + -- test, so buildings and trees really occlude. + for _, p in ipairs(posed) do + drawEntity(p.sprite, p.px, p.py, p.facing, p.phase, p.flip, p.gh, + p.colors, p.lift) + end + -- back on for everything textured from the atlas again -- figures, grass + -- and flowers all sample it, where the mask's coordinates are honest + Voxel3D.glass(true) + -- Figures after the walkers, so a player standing in front of the couch + -- wins the overlap -- the order the flat game draws them in. + local figPull = billboardPull() + eachFigure(state.map, 0, 0, function(mesh, model, caster) + Voxel3D.draw(mesh, atlasFor(state.map), model, figPull, + ShadowMap.snug(caster)) + end) + for _, nb in ipairs(state.neighbors or {}) do + eachFigure(nb.map, nb.ox, nb.oy, function(mesh, model, caster) + Voxel3D.draw(mesh, atlasFor(nb.map), model, figPull, + ShadowMap.snug(caster)) + end) + end + -- and the seams are back on for the terrain art that follows: grass and + -- flowers are the world's own drawing, not people + Voxel3D.seams(true) +end + +-- ------- the water pass +-- +-- Between the terrain and everything that stands on it, because water is a +-- MIRROR and a mirror can only reflect what is already down: the ground, the +-- shoreline, the trees and buildings behind it, and the sky the frame opened +-- with. +-- +-- THE CAST IS THE AWKWARD ONE, and it is settled by drawing it twice. Gen 1 +-- draws people over the world and water is world, so a surfing player has to +-- composite OVER the water they are sitting on -- which puts them after it, +-- and a reflection can only hold what came before it. So `cast` is painted +-- into the reflection copy alone (Voxel3D.beginWater), where it is in the +-- picture the water reflects and not yet in the picture the water is drawn +-- into. Both draws go through drawCast, so they cannot come out different. +-- +-- The ray march finds them the honest way round: a sprite is not in the +-- DEPTH buffer at that point, so a ray aimed at one passes through to the +-- terrain standing behind it and reads the copy there -- where the sprite is +-- already painted. The reflection lands a hair off the sprite's own depth +-- and exactly on its colour, which at a lake's worth of ripple is the same +-- picture. +-- +-- `draws` is a list of { mesh, texture, model }. Nothing is a special case: +-- with the row OFF, no depth texture to read, or a shader that would not +-- build, the same meshes go through the ordinary scene shader and come out +-- as the flat animated water this mode always drew. +-- Shared with the overworld battle, which stages its fights on the same +-- lakes and wants the same water under them. +function VoxelScene.drawWater(draws, cast) + local plain = true + if Water.enabled() and Voxel3D.depthReadable() then + local mirror, depth = Voxel3D.beginWater(cast) + local w, h = Voxel3D.size() + local ok = mirror and depth and Water.begin({ + reflect = mirror, depth = depth, + vp = Voxel3D.vp, eye = Voxel3D.eye, curve = { Voxel3D.curveX or 0, + Voxel3D.curveZ or 0, + Voxel3D.curveK or 0 }, + screen = { w, h }, cell = Voxel3D.cell, fov = Voxel3D.fovY, + skyEdge = Voxel3D.skyEdge, grid = VoxelGrid.enabled(), + lookFlat = Voxel3D.lookFlat, descent = Voxel3D.descent, + }) + if ok then + for _, d in ipairs(draws) do + Water.draw(d[1], d[2], d[3]) + end + Water.finish() + plain = false + end + -- Unconditionally, and OUTSIDE the success branch: beginWater unbinds + -- the shader and the depth mode BEFORE it can discover it cannot go on, + -- so a frame that bails halfway through has to be put back together + -- exactly like one that succeeded -- otherwise the plain draw below (and + -- every pass after it) runs with no shader and no depth test. + Voxel3D.endWater() + end + if plain then + for _, d in ipairs(draws) do + Voxel3D.draw(d[1], d[2], d[3]) + end + end +end + -- A stamp of everything the sun pass depends on. Nothing in it moving -- means the shadow map it produced last frame is still exactly right, and -- redrawing the whole world from the sun would buy nothing -- which is @@ -540,7 +667,7 @@ end -- left out on purpose: thousands of tufts would cast a speckle no bigger -- than the pixels it lands on, at the cost of the mesh being drawn twice. local function castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh, - atlasFor) + atlasFor, water, nbWater) if not ShadowMap.available() then return end local sig = shadowSignature(terrain, nbMesh, posed, cx, cy, vw, vh) if not ShadowMap.stale(sig) then return end @@ -551,6 +678,15 @@ local function castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh, ShadowMap.draw(nbMesh[i], atlasFor(nb.map), Mat4.translate(nb.ox, 0, nb.oy)) end + -- The water surface, which the terrain mesh no longer carries (it is its + -- own reflective pass now -- see Water). The sun still has to see it, or + -- the map the light records has a hole at every lake and the frustum's + -- far plane answers for the surface a shoreline tree's shadow falls on. + ShadowMap.draw(water, atlasFor(state.map), nil) + for i, nb in ipairs(state.neighbors or {}) do + ShadowMap.draw(nbWater and nbWater[i], atlasFor(nb.map), + Mat4.translate(nb.ox, 0, nb.oy)) + end -- flower billboards live outside the terrain mesh (they draw after the -- characters, pulled -- see render), but the sun still sees them: a -- handful of cutouts per meadow, unlike the grass left out below. @@ -593,7 +729,7 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor) -- return nil: the engine keeps the 2D path for the frame and -- Voxel.ready holds the camera tween at flat, so the switch waits -- invisibly instead of freezing or tilting an empty stage. - local terrain, nbMesh = VoxelScene.prefetch(state) + local terrain, nbMesh, water, nbWater = VoxelScene.prefetch(state) if not terrain then return nil end local cam = state.camera @@ -630,7 +766,8 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor) end local posed, me = posesOf(state, spriteColors) - castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh, atlasFor) + castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh, atlasFor, + water, nbWater) if not Voxel3D.beginScene(w, h, cx, cy, vw, vh, skyFor(state.map)) then return nil @@ -642,6 +779,27 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor) Mat4.translate(nb.ox, 0, nb.oy)) end + -- and the water over the top of it, reflecting everything just drawn plus + -- the sky the frame opened with (see drawWater) + local waterDraws = {} + if water then + waterDraws[#waterDraws + 1] = { water, atlasFor(state.map), nil } + end + for i, nb in ipairs(state.neighbors or {}) do + if nbWater and nbWater[i] then + waterDraws[#waterDraws + 1] = { nbWater[i], atlasFor(nb.map), + Mat4.translate(nb.ox, 0, nb.oy) } + end + end + -- the cast goes into the reflection copy only -- see drawWater for why it + -- cannot be composited yet and why it is drawn through the same function + -- the real pass below uses + if #waterDraws > 0 then + VoxelScene.drawWater(waterDraws, function() + drawCast(state, posed, atlasFor) + end) + end + -- Without a shadow map (headless, or a driver that could not make the -- canvas) the old flat decals stand in: ground-only, characters only, -- but better than a world with nothing under anybody. They go down @@ -689,33 +847,7 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor) -- drawEntity resolves the lean-over-the-wall-in-front case, and a -- character genuinely behind a building is far deeper and loses the -- test, so buildings and trees really occlude. - Voxel3D.seams(false) - for _, p in ipairs(posed) do - drawEntity(p.sprite, p.px, p.py, p.facing, p.phase, p.flip, p.gh, - p.colors, p.lift) - end - -- back on for everything textured from the atlas again -- figures, grass - -- and flowers all sample it, where the mask's coordinates are honest - Voxel3D.glass(true) - -- Authored figures, alongside the characters and with the same lean and - -- the same camera-ward pull -- they ARE characters as far as the artwork - -- is concerned, just ones the tileset draws instead of a sprite sheet. - -- Drawn after the walkers so a player standing in front of the couch - -- wins the overlap, which is the order the flat game draws them in. - local figPull = billboardPull() - eachFigure(state.map, 0, 0, function(mesh, model, caster) - Voxel3D.draw(mesh, atlasFor(state.map), model, figPull, - ShadowMap.snug(caster)) - end) - for _, nb in ipairs(state.neighbors or {}) do - eachFigure(nb.map, nb.ox, nb.oy, function(mesh, model, caster) - Voxel3D.draw(mesh, atlasFor(nb.map), model, figPull, - ShadowMap.snug(caster)) - end) - end - -- and the seams are back on for the terrain art that follows: grass and - -- flowers are the world's own drawing, not people - Voxel3D.seams(true) + drawCast(state, posed, atlasFor) -- tall grass last, pulled camera-ward exactly as far as the characters -- were (same per-vertex shader bias, so grass never drifts either): -- relative depth between a walker and the tuft row south of their feet diff --git a/main.lua b/main.lua index ccef967..95ca933 100644 --- a/main.lua +++ b/main.lua @@ -81,6 +81,7 @@ local OverworldBattle = V.require("OverworldBattle") local BattleExit = V.require("BattleExit") local DayNight = V.require("DayNight") local DayTint = V.require("DayTint") +local Water = V.require("Water") -- Forward declaration: the voxel pipeline's update hook (registered below) -- calls this, and it is defined further down with the settings it drives. @@ -282,6 +283,10 @@ applyFull = function(level) -- the horizon flat. The curve bends the world away from a walking player, -- which fights a fixed diorama framing WorldCurve.setting:setIndex(1, Game) + -- and the water reflecting everything it can: FULL is the diorama at its + -- most photographed, and a lake with the sky and the shoreline in it is + -- most of what makes the model read as being outdoors + Water.setting:setIndex(1, Game) -- and the view fitted to the window opts.zoom = 0 Zoom.applyOptions(opts) @@ -332,6 +337,11 @@ local SETTINGS = { { VoxelGrid.setting, "One-pixel wireframe along every voxel edge." }, { WorldCurve.setting, "Bend the world down over the horizon, Animal Crossing style." }, + { Water.setting, + "Reflections on water. FULL adds screen-space reflections of the " + .. "shoreline, the trees and the buildings behind it; SKY is the sky, " + .. "the sun and the moon alone, which is most of the look for a " + .. "fraction of the cost." }, -- `full` marks a row FULL does not take away. FULL owns the diorama's own -- knobs; what a battle is drawn over, and how it is framed, are not that. { OverworldBattle.setting, @@ -366,6 +376,7 @@ mod.options:define(schema) -- 6 T-SHIFT cycle the blur ladder (was 9) -- 7 V-CURVE cycle the horizon bend (new) -- 8 3D-BTL toggle overworld battles (new) +-- 9 WATER cycle the water reflections (new; 9 was T-SHIFT's old key) -- -- Only 6 arrives by the documented route. Game:keypressed answers the -- engine's own display keys FIRST and returns -- 2 COLORS, 3 TILT, 4 ZOOM, @@ -398,6 +409,7 @@ local HOTKEYS = { ["5"] = VoxelGrid.setting, ["7"] = WorldCurve.setting, ["8"] = OverworldBattle.setting, + ["9"] = Water.setting, } do @@ -447,19 +459,19 @@ do return end elseif Pipelines.canToggle("voxel", top, self.overworld) then - -- All three answer to the voxel pass's own free-roam gate -- + -- All four answer to the voxel pass's own free-roam gate -- -- borrowed from the registry rather than restated, so a press -- mid-warp or mid-cutscene is refused for the wireframe exactly when - -- it would be for the mode itself. Two of them parameterise that - -- pass; the third (3D-BTL) decides what a battle is drawn over, and + -- it would be for the mode itself. Three of them parameterise that + -- pass; the fourth (3D-BTL) decides what a battle is drawn over, and -- wants the same gate for a different reason: the answer is read -- when the fight starts, so flipping it from inside one would be a -- switch that appeared to do nothing. claim:cycle(self) -- 8 is one of the two ways staged battles get switched on, and they - -- pin BATTLE LAYOUT to OG (see the rows hook). The other two keys + -- pin BATTLE LAYOUT to OG (see the rows hook). The other keys -- parameterise the pass and leave the layout alone; the guard answers - -- for all three, so nothing here has to know which key it was. + -- for all of them, so nothing here has to know which key it was. if stagedBattles() then OverworldBattle.forceOG(self) end return end @@ -848,7 +860,7 @@ mod.hooks:wrap("world.tod", function(next, tod, ctx) return DayNight.tod() end) -mod.exports.version = "1.3.1" +mod.exports.version = "1.4.0" -- exposed so a companion mod can pin its own tiles' shapes or read the -- camera without reaching into this mod's file layout mod.exports.lib = V diff --git a/manifest.json b/manifest.json index ef2c15c..92602fa 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "DRAMATIC_SHAPE", "name": "Dramatic Shape Voxel Mod", - "version": "1.3.1", + "version": "1.4.0", "api": 2, "entry": "main.lua", "profile": "content", @@ -15,5 +15,5 @@ "engine_internals" ], "affects_link": false, - "description": "A full 3D diorama overworld: extruded terrain, depth-buffered occlusion, voxel characters and a tilt-shift miniature pass -- and battles fought on the map itself, shot over the shoulder at the nearest clear ground with a slow parallax drift and a depth-of-field pass. Registers two render pipelines and claims hotkeys 3, 5, 6, 7 and 8 -- 3 and 5 displace the engine's TILT and GBC FX keys, both still reachable on the OPTIONS menu. Presentational only: it changes what a battle is drawn over, never where anybody stands." + "description": "A full 3D diorama overworld: extruded terrain, depth-buffered occlusion, voxel characters and a tilt-shift miniature pass -- and battles fought on the map itself, shot over the shoulder at the nearest clear ground with a slow parallax drift and a depth-of-field pass. Water reflects the sky, the sun, the moon and -- through a screen-space ray march -- the shoreline standing behind it. Registers two render pipelines and claims hotkeys 3, 5, 6, 7, 8 and 9 -- 3 and 5 displace the engine's TILT and GBC FX keys, both still reachable on the OPTIONS menu. Presentational only: it changes what a battle is drawn over, never where anybody stands." } diff --git a/mod.card b/mod.card index d7f9644..af77b03 100644 --- a/mod.card +++ b/mod.card @@ -21,6 +21,7 @@ return { "VOXEL options row and hotkey 3 (OFF / 15 / 35 / 50 / 75 degrees)", "T-SHIFT options row and hotkey 6 (OFF / 1 / 2 / 3), the miniature blur", "V-GRID on hotkey 5 and V-CURVE on hotkey 7", + "WATER on hotkey 9 (FULL / SKY / OFF, FULL by default): the water surface becomes a field of pixel-tall voxel columns rising and falling as waves, reflecting the sky, the sun, the moon and the cast standing beside it -- and, on FULL, the shoreline, trees and buildings behind it, by a screen-space ray march", "3D-BTL on hotkey 8 (ON / OFF, on by default), battles fought on the world map", "BACK SPRITES options row (OFF / ON, off by default), which keeps your own Pokemon on the battle menu in its classic slot while the foe stands out on the map", "a day/night clock that reaches the flat 2D overworld as well as the diorama -- outdoor maps only, and only when the hour is not midday", @@ -30,6 +31,9 @@ return { }, known = { "needs shader and depth-canvas support; without them the rows still cycle but the world stays 2D and battles draw plainly", + "water reflections additionally need a READABLE depth canvas; a driver without one draws the flat animated water this mode always drew", + "WATER on FULL ray-marches the depth buffer per water pixel, so a map that is mostly sea costs real fill rate on a weak GPU -- SKY is the same look minus the ray march, and OFF is the flat water", + "a screen-space reflection can only reflect what is in the frame: a tree just off the top edge is not in the water below it, and a ray that runs off the side fades into the sky rather than ending on a line", "a map with no 3x6 clearing falls back to a 1x4 one, and a map with neither draws the plain battle screen", "the arena is where the CAMERA goes -- nobody is moved, so a fight staged across the map is a shot of that ground, not a trip to it", "the battle backdrop renders at the GB's 160x144 to match the pics composited over it, so it is chunkier than the free-roam pass", diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua index 95b969d..c6db4ff 100644 --- a/tests/dramatic_shape_test.lua +++ b/tests/dramatic_shape_test.lua @@ -298,7 +298,7 @@ local order = {} for i, row in ipairs(grouped) do order[row.id] = i end T.check(order["pipeline:tiltshift"] < order["DRAMATIC_SHAPE:grid"], "the mode's settings follow its pipeline rows") -T.eq(order["DRAMATIC_SHAPE:battles"] - order["pipeline:tiltshift"], 3, +T.eq(order["DRAMATIC_SHAPE:battles"] - order["pipeline:tiltshift"], 4, "and sit in one unbroken block, not scattered to the end of the list") T.check(order["void_fill"] > order["DRAMATIC_SHAPE:battles"], "with the engine's own later rows still after them") @@ -379,9 +379,15 @@ end Pipelines.setLevel("voxel", 2) local hookedRows = Runtime.call("ui.options.rows", function(_, r) return r end, { data = Data }, { { id = "text_speed" } }) -T.eq(#hookedRows, 6, "the options hook added a row per setting") -local grid, curve, battles = hookedRows[2], hookedRows[3], hookedRows[4] -local backRow, daytime = hookedRows[5], hookedRows[6] +T.eq(#hookedRows, 7, "the options hook added a row per setting") +local grid, curve, water = hookedRows[2], hookedRows[3], hookedRows[4] +local battles, backRow, daytime = hookedRows[5], hookedRows[6], hookedRows[7] +T.eq(water.label, "WATER", "the water row carries its label") +T.eq(water.value(), "FULL", + "and defaults to FULL -- reflections are the point of having the row") +water.step({ save = { options = {} }, mods = { modOptions = {} } }, 1) +T.eq(water.value(), "SKY", + "stepping down drops the screen-space march and keeps the sky, sun and moon") T.eq(daytime.label, "DAYTIME", "the day/night row carries its label") T.eq(daytime.value(), "SYNC", "and defaults to SYNC -- no value set follows the clock on the wall") @@ -1482,6 +1488,359 @@ T.eq(Sky.paint(320, 0, skyGrad, 40, 7), false, "and a frame with no height paints nothing at all") end +-- ------- reflections on water +-- +-- Water is the one surface in this mode that cannot be drawn with the rest +-- of the world: it is a mirror, and a mirror needs what it reflects to +-- already be down. So it is lifted out of the terrain mesh at BUILD time and +-- drawn as its own pass. That lift is the load-bearing part -- get it wrong +-- and a lake is either a hole in the world or is drawn twice -- and it is +-- pure geometry, so it is driven here against a hand-drawn map. +do +local Water = run.loader.exports.DRAMATIC_SHAPE.lib.require("Water") +local Sky = run.loader.exports.DRAMATIC_SHAPE.lib.require("Sky") +local ChunkMesher = run.loader.exports.DRAMATIC_SHAPE.lib.require("ChunkMesher") +local Structures = run.loader.exports.DRAMATIC_SHAPE.lib.require("Structures") +local Shapes = run.loader.exports.DRAMATIC_SHAPE.lib.require("TileShape") +local TileShapeHeights = Shapes.heights() + +-- ------- the ladder +-- +-- Three rungs, not a toggle: the sky half of this costs a handful of +-- instructions and the screen-space half costs a ray march, so a machine +-- that wants the sunset on the lake but not the march has somewhere to sit. +T.eq(Water.setting.values[1], "full", + "FULL is the default -- reflections are the point of having the row") +Water.setting:sync("full") -- the row test above stepped it +T.eq(Water.level(), 2, "and it reads back as the full pass") +T.eq(Water.enabled(), true, "which is on") +Water.setting:sync("sky") +T.eq(Water.level(), 1, "SKY keeps the pass but drops the screen-space march") +T.eq(Water.enabled(), true, "and is still a reflection") +Water.setting:sync("off") +T.eq(Water.level(), 0, "OFF is no pass at all") +T.eq(Water.enabled(), false, + "which is what puts the water back in the ordinary scene shader") +Water.setting:sync("full") + +-- ------- the waves are geometry, not shading -- and they step at 15fps +-- +-- The surface is a heightfield of one-world-pixel columns, each standing a +-- WHOLE number of pixels tall -- a voxel like every other voxel in this +-- mode -- and it advances in STEPS rather than sliding: 15 a second, the +-- cadence hand-drawn pixel art is animated at. A surface built out of whole +-- pixels that crawls smoothly between them gives away that the quantisation +-- is only skin deep. +do +local TerrainAtlas = run.loader.exports.DRAMATIC_SHAPE.lib.require("TerrainAtlas") +local realClock = TerrainAtlas._animFrame +local frame = 0 +TerrainAtlas._animFrame = function() return frame end +local function at(f) + frame = f + return Water._waveTime() +end + +local period = 60 / Water.WAVE_FPS +T.eq(period, 4, "15 steps a second is one every four engine frames") +-- inside one step nothing moves; crossing one, it does +T.eq(at(0), at(period - 1), + "every frame inside one wave step gets the same phase -- the surface " + .. "steps rather than crawling between its own pixels") +T.neq(at(0), at(period), "and the step boundary is where it moves") + +local steps = {} +for f = 0, 59 do steps[at(f)] = true end +local n = 0 +for _ in pairs(steps) do n = n + 1 end +T.eq(n, Water.WAVE_FPS, "which is WAVE_FPS distinct positions in a second") +TerrainAtlas._animFrame = realClock + +-- and the step is worth taking: one world pixel of the dominant train per +-- step, DERIVED from that train rather than tuned beside it, so a change of +-- wavelength moves the speed with it. A step the surface cannot resolve is +-- a smooth crawl wearing a quantised clock. +local t = Water.WAVE_TRAINS[1] +local freq = math.sqrt(t[1] * t[1] + t[2] * t[2]) +local travel = (Water.waveRate() / Water.WAVE_FPS) * math.abs(t[3]) / freq +T.check(math.abs(travel - Water.WAVE_PIXELS_PER_STEP) < 1e-9, + "each step advances the dominant crest by exactly WAVE_PIXELS_PER_STEP " + .. "world pixels, so nothing ever lands half-way between two") + +-- the trains reach the shader as source, off the same table the rate above +-- is derived from -- one list, so the two cannot drift +local trains = Water._trainSource() +T.eq(select(2, trains:gsub("h %+= sin", "")), #Water.WAVE_TRAINS, + "every train in the table is summed by the shader") +T.check(trains:find(("%.4f"):format(t[1]), 1, true) ~= nil, + "at the frequency the table states") + +T.check(Water.WAVE_HEIGHT > -TileShapeHeights.water, + "the crests stand taller than the recess TileShape sinks water into -- " + .. "they are RELIEF inside the quad's own footprint, so a bar that reaches " + .. "above the bank is clipped at the water's edge rather than spilling") +end + +-- ------- the moon on the water is the moon in the sky +-- +-- The reflected disc is drawn by a shader and the painted one by rectangles, +-- so nothing but shared DATA can keep them the same moon. The crater list is +-- pasted into the shader source from Sky's own table, which is the seam that +-- makes "they cannot drift" true rather than merely intended. +local craters = Water._craterSource() +local craterLines = select(2, craters:gsub("crater%(", "")) +T.eq(craterLines, #Sky.MOON_CRATERS, + "the shader gets one crater per crater the painted moon has") +for _, c in ipairs(Sky.MOON_CRATERS) do + T.check(craters:find(("%.4f"):format(c[1]), 1, true) ~= nil, + "and each one at the offset the painted moon puts it at") +end +T.check(craters:find(("%.4f"):format(Sky.CRATER_FRAC), 1, true) ~= nil, + "at the same fraction of the disc's radius") + +-- and the disc is the same SIZE, which is the other half of being the same +-- moon: one function answers for the painted radius and for the angle the +-- reflection subtends it at +local px, cells = Sky.discRadius(288, 7, { moon = true }) +T.eq(cells, Sky.DISC_MIN, + "a small frame floors the disc at its minimum radius in cells") +T.eq(px, Sky.DISC_MIN * 7, "reported in canvas pixels on that cell grid") +T.eq(select(2, Sky.discRadius(288, 7, { glowAmt = 0.9 })), Sky.DISC_MIN + 1, + "and the low sun looms, exactly as the painted one does") +T.eq(select(2, Sky.discRadius(288, 7, { glowAmt = 0.9, moon = true })), + Sky.DISC_MIN, "which is a SUNSET exaggeration -- the moon never looms") + +-- the same band ramp, too: one texture, so the sky on the lake cannot be a +-- different palette from the sky over it +local rampImg, rampCount = Sky.ramp() +T.check(rampImg == nil or rampCount == #Sky.bands(), + "the reflection reads the sky off the very ramp the sky is painted from") + +-- ------- the horizon lean: the reflection has to have something IN it at +-- every rung, not just the one whose horizon is in frame +-- +-- The rungs are named for the camera's tilt off VERTICAL, so at 15 the eye +-- meets the water nearly head-on and the mirror ray points 75 degrees UP -- +-- where the sky's bands are darkest, the sun and moon (squashed to about 6 +-- degrees) are nowhere near, and a screen-space ray leaves the frame in two +-- steps. All three are correct and together they are an empty lake. The lean +-- tips the reflection toward the way the camera looks by however far that +-- camera is from having a horizon in frame. +do +local Voxel3D = run.loader.exports.DRAMATIC_SHAPE.lib.require("Voxel3D") +local VoxelState = run.loader.exports.DRAMATIC_SHAPE.lib.require("VoxelState") +local wasAngle, wasCam = VoxelState.angle, Voxel3D.camera +Voxel3D.camera = nil + +local lean = {} +for _, deg in ipairs({ 15, 35, 50, 75 }) do + VoxelState.angle = math.rad(deg) + Voxel3D.viewProjection(256, 256, 320, 288) + lean[deg] = { Water.lean(Voxel3D.descent), Voxel3D.descent } + -- the orbit looks NORTH, so the flattened view direction is -Z and level + T.check(math.abs(Voxel3D.lookFlat[3] + 1) < 1e-6, + ("the %d rung looks north along the ground plane"):format(deg)) + T.eq(Voxel3D.lookFlat[2], 0, + "flattened onto it, so the lean can never tip a reflection underground") +end + +-- descent is the SINE of how far below horizontal the view runs, and the +-- rungs are the camera's tilt off vertical -- so the two are complements +for _, deg in ipairs({ 15, 35, 50, 75 }) do + T.check(math.abs(lean[deg][2] - math.cos(math.rad(deg))) < 1e-6, + ("the %d rung descends by cos(%d)"):format(deg, deg)) +end + +T.eq(lean[75][1], 0, + "at the rung whose horizon is in frame there is NO lean -- the one place " + .. "the join can be seen (the waterline, where the lake meets the painted " + .. "sky) is still the exact reflection it always was") +T.check(lean[50][1] > 0, "and it comes in as the camera tips over") +T.check(lean[35][1] >= lean[50][1] and lean[15][1] >= lean[35][1], + "growing with every rung further from the horizon") +T.eq(lean[15][1], 1, + "and complete well before the steepest rung, so every rung under the top " + .. "one aims its reflection where the top one's already lands") + +-- a camera looking dead level has nothing to lean +T.eq(Water.lean(0), 0, "a level camera leans not at all") +T.eq(Water.lean(1), 1, "and one looking straight down leans all the way") +T.eq(Water.lean(Water.LEAN_FROM), 0, + "the ramp starts exactly where the top rung sits, so that rung is the one " + .. "the lean never touches") +T.check(math.abs(math.sin(Water.LEAN_ELEV) - Water.LEAN_FROM) < 1e-12, + "and the elevation it aims at IS that rung's own, stated as the same " + .. "number rather than beside it") + +VoxelState.angle, Voxel3D.camera = wasAngle, wasCam +end + +-- ------- the compiled variants +local plain = Water._source(false) +local gridded = Water._source(true) +T.check(plain:find("#define WAVE_STEPS " .. Water.WAVE_STEPS, 1, true) ~= nil, + "the relief march's step count is compiled in too") +-- the whole surface is answered per COLUMN: the ray picks one, and the art, +-- the shading, the reflection and the dither all read that one rather than +-- the fragment's own place on the flat quad. A smoothly-shaded reflection +-- over hard-edged 8-bit water is two pictures stacked. +T.check(plain:find("floor(waveRaw(q) * waveHeight + 0.5)", 1, true) ~= nil, + "column heights are floored to WHOLE world pixels -- a fractional step is " + .. "a smooth wave with extra arithmetic, not a bar") +-- and the normal is read off the SMOOTH field underneath, which is the +-- difference between a moon on the water and confetti: integer heights give +-- integer differences, so a normal built from them can only point in about +-- five directions and a two-degree disc falls between them +T.check(plain:find("float h = waveRaw(q);", 1, true) ~= nil, + "but the reflection's normal comes off the smooth surface the columns are " + .. "a quantisation of, so the ray sweeps instead of jumping") +T.check(plain:find("waveNormal(vec2 q, float tilt)", 1, true) ~= nil + and plain:find("waveNormal(col,", 1, true) ~= nil, + "still one answer per column, so the surface stays pixel-quantised in " + .. "space while the value it reflects with is continuous") +T.check(plain:find("relief(vBent, view, hit, col, face, axis)", 1, true) ~= nil, + "and the visible column is found by walking the view ray through the " + .. "slab, which is what makes a tall bar hide the short ones behind it") +-- the march's reach grows as one over the ray's descent, so a grazing camera +-- asks for hundreds of world pixels of it from a fixed number of samples -- +-- which stepped over whole crests and smeared the surface into streaks +T.check(plain:find("#define WAVE_STRIDE", 1, true) ~= nil + and plain:find("float maxSpan = float(WAVE_STEPS) * WAVE_STRIDE;", + 1, true) ~= nil, + "and its span is capped to a stride a sample can actually resolve") +T.check(Water.WAVE_STRIDE <= 1, + "which is at most ONE world pixel, because a column is one world pixel " + .. "wide -- a longer stride steps over columns, and which ones it misses " + .. "changes fragment to fragment, which is the peppery noise") +-- and the art is read off the COLUMN rather than by offsetting the +-- fragment's own uv by however far the march happened to travel: one world +-- pixel is one texel, so a column's texel follows from where it stands and +-- two fragments landing on the same column cannot disagree about it +T.check(plain:find("org + (mod(col, 8.0) + 0.5) * texel", 1, true) ~= nil, + "a column's art follows from its own world position, so it cannot swim " + .. "with the camera or speckle between neighbouring fragments") +T.check(plain:find("waveUV(tc, col)", 1, true) ~= nil, + "and the column is what is handed to it") + +-- the wireframe is ruled on the COLUMNS, not on the flat sheet they stand on +T.check(gridded:find("columnSeam(hit, vBent, axis)", 1, true) ~= nil, + "with V-GRID on, the seams outline the column the ray landed on -- every " + .. "voxel of water its own block -- rather than ruling a grid across the " + .. "flat quad underneath and ignoring the bars entirely") +T.check(gridded:find("vec3 w = fwidth(base);", 1, true) ~= nil, + "measured off the smooth plane, because the hit jumps a whole column " + .. "between neighbouring fragments and its own derivative is a step") +T.check(plain:find("march(surf, r)", 1, true) ~= nil, + "the reflection marches from that column, not from the raw fragment") +T.check(plain:find("mod(col.x + col.y, 2.0)", 1, true) ~= nil, + "and the dither's checkerboard is cut from the columns too, so a camera " + .. "pan slides the world through nothing") +T.check(plain:find("#define RAY_STEPS " .. Water.RAY_STEPS, 1, true) ~= nil, + "the march's step count is compiled in -- GLSL wants a constant bound") +T.check(plain:find("VOXEL_GRID", 1, true) ~= nil, + "the wireframe is guarded in the source") +T.check(plain:find("#define VOXEL_GRID", 1, true) == nil, + "and off in the plain variant") +T.check(gridded:find("#define VOXEL_GRID", 1, true) ~= nil, + "so a frame with the seams on gets its own compilation, like the scene " + .. "shader -- a driver that refuses derivatives loses the seams and not " + .. "the water") +T.check(plain:find("//@CRATERS", 1, true) == nil, + "and the crater placeholder is gone by the time a driver sees the source") + +-- ------- the lift itself +-- +-- A pond in a field: four water cells recessed below flat ground. The +-- shipped maps are the real thing but a picture states the invariant +-- exactly, and this one needs no atlas, no GPU and no fixture. +local WATER_TILE, GRASS_TILE = 20, 3 +local pond = { + { GRASS_TILE, GRASS_TILE, GRASS_TILE, GRASS_TILE }, + { GRASS_TILE, WATER_TILE, WATER_TILE, GRASS_TILE }, + { GRASS_TILE, WATER_TILE, WATER_TILE, GRASS_TILE }, + { GRASS_TILE, GRASS_TILE, GRASS_TILE, GRASS_TILE }, +} +local pondMap = { + id = "DS_TEST_POND", + tileset = { id = "DS_TEST_SET", image = "gfx/tilesets/ds_test.png", + tilesPerRow = 16, imageWidth = 128, imageHeight = 48, + blocks = {}, grassTile = -1 }, + def = { width = 1, height = 1, tileset = "DS_TEST_SET" }, + walkable = { [GRASS_TILE] = true }, + waterTiles = { [WATER_TILE] = true }, + doorTiles = {}, + tileAt = function(_, tx, ty) + return pond[(ty % 4) + 1][(tx % 4) + 1] + end, + cellTile = function(self, cx, cy) return self:tileAt(cx * 2, cy * 2 + 1) end, + isWaterCell = function(self, cx, cy) + return self:cellTile(cx, cy) == WATER_TILE + end, + isWalkableCell = function(self, cx, cy) + return self:cellTile(cx, cy) == GRASS_TILE + end, + inBounds = function(_, cx, cy) + return cx >= 0 and cy >= 0 and cx < 2 and cy < 2 + end, +} + +-- body-only, so the border ring is out of it and the count is the picture +local _, _, whole = ChunkMesher.geometry(pondMap, true, nil) +Structures.invalidate(pondMap.id) +local landVerts, _, land, waterVerts, _, wet = + ChunkMesher.geometry(pondMap, true, nil, true) + +T.check(wet > 0, "the pond's surface comes out as water quads") +T.eq(land + wet, whole, + "and the split is a MOVE, not a copy: every quad the one-sink build " + .. "emitted is in exactly one of the two") +T.eq(#waterVerts, wet * 4, "the water sink holds whole quads") + +-- every water vertex sits on the recessed plane, which is what says the +-- surface and only the surface was lifted -- the shoreline faces that drop +-- from the ground down to it belong to the GROUND that exposes them, and +-- must stay in the terrain mesh or a lake is ringed by a slit into the sky +local heights = Shapes.heights() +for _, v in ipairs(waterVerts) do + T.check(v[2] == heights.water, + "a water vertex stands on the water plane, not on a shoreline face") +end +local shore = 0 +for _, v in ipairs(landVerts) do + if v[2] < 0 then shore = shore + 1 end +end +T.check(shore > 0, + "and the shoreline bands below ground level stayed with the terrain") + +-- a map with no water at all splits into everything and nothing, rather +-- than into an empty terrain mesh +Structures.invalidate(pondMap.id) +local dry = {} +for y = 1, 4 do + dry[y] = {} + for x = 1, 4 do dry[y][x] = GRASS_TILE end +end +pond = dry +local _, _, dryLand, _, _, dryWet = ChunkMesher.geometry(pondMap, true, nil, + true) +T.check(dryLand > 0, "a map with no water still meshes its ground") +T.eq(dryWet, 0, "and hands back no water surface at all") + +-- ------- and the pairing +-- +-- The terrain mesh and the water lifted out of it are ONE answer: they came +-- from the same build, so a caller must never end up holding a full mesh +-- beside a body build's water (the ring's ponds twice, the body's as holes). +-- pair() is the only way to ask, which is what makes that unpairable. +local mesh, wetMesh = ChunkMesher.pair({ id = "DS_NOT_A_MAP" }, false) +T.eq(mesh, nil, "an unbuilt map pairs to nothing") +T.eq(wetMesh, nil, "on both halves, so a caller cannot half-draw one") + +Structures.invalidate(pondMap.id) +ChunkMesher.invalidate(pondMap.id) +Shapes.invalidate() +end + Voxel.angle = 0 -- ------- overworld battles: where the fight is staged From 8f38aeb36e9bbd7da585b01bd1af08123be45866 Mon Sep 17 00:00:00 2001 From: DramaticShape Date: Fri, 31 Jul 2026 23:05:54 -0400 Subject: [PATCH 002/116] water updates --- lib/BattleScene.lua | 32 + lib/ShadowMap.lua | 32 +- lib/VoxelScene.lua | 45 +- lib/Water.lua | 1201 +++++++++++++++++++++++++++++++++ tests/dramatic_shape_test.lua | 42 +- tests/mon_shots.lua | 71 ++ tests/pic_dump.lua | 63 ++ tests/water_reflect_probe.lua | 127 ++++ 8 files changed, 1589 insertions(+), 24 deletions(-) create mode 100644 lib/Water.lua create mode 100644 tests/mon_shots.lua create mode 100644 tests/pic_dump.lua create mode 100644 tests/water_reflect_probe.lua diff --git a/lib/BattleScene.lua b/lib/BattleScene.lua index d483ed5..2fc3cce 100644 --- a/lib/BattleScene.lua +++ b/lib/BattleScene.lua @@ -259,10 +259,15 @@ local function castShadows(state, arena, terrain, nbMesh, cx, cy, vw, vh, -- the mons themselves, as the same cards the camera will see. Their alpha -- is the silhouette, so what lands on the ground is the shape of the -- Pokemon rather than a blob standing in for one. + -- marked as the CAST, so a fight staged at the water's edge does not lay a + -- cut-out of a Pokemon across the lake (see ShadowMap.sprites); the arena's + -- own floor still takes them, which is the shadow that matters here + ShadowMap.sprites(true) for _, card in ipairs(cards or {}) do ShadowMap.draw(BattleBillboard.mesh(), card.tex, ShadowMap.snug(card.model)) end + ShadowMap.sprites(false) ShadowMap.finish(sig) end @@ -309,9 +314,36 @@ end BattleScene.FLASH_COLOR = { 1, 1, 1 } BattleScene.FLASH_STRENGTH = 0.5 +-- ------- the tile clock, while the overworld is not the one drawing +-- +-- Water and flowers animate off TileRenderer's 60Hz counter, and the ENGINE +-- only advances it from OverworldState:drawWorld -- which runs under dialogs +-- and menus, but not under a battle, because a battle draws instead of the +-- overworld rather than over it. So for the length of a staged fight the +-- counter stood still: the water tiles stopped rotating their pixels and the +-- wave field, which is driven off the same number so the two cannot drift +-- (see Water), stopped with them. A lake in the background of a battle was a +-- photograph. +-- +-- Ticked HERE rather than from the mod's update hook, because here is the +-- one place that means "a staged battle is drawing this frame, and the +-- overworld is not". From the update hook the condition would have to be +-- guessed at, and a frame where both ran would double the rate. +local function tickTiles() + local Game = require("src.core.Game") + local ow = Game and Game.overworld + local top = Game and Game.stack and Game.stack:top() + -- during the wipe INTO a battle the overworld can still be the one + -- drawing, and it is ticking the clock itself; two ticks in a frame would + -- run the water at double speed + if top and ow and top == ow then return end + pcall(require("src.render.TileRenderer").tick) +end + function BattleScene.render(state, arena, textures, token) if not (state and state.map and arena) then return nil end if not Voxel3D.available() then return nil end + tickTiles() -- the floor the fight is staged on: normally the player's own, sometimes -- another floor of the same cave or building (see BattleArena) diff --git a/lib/ShadowMap.lua b/lib/ShadowMap.lua index 9798462..18ceffd 100644 --- a/lib/ShadowMap.lua +++ b/lib/ShadowMap.lua @@ -130,17 +130,23 @@ local SHADER = [[ } #endif #ifdef PIXEL + uniform float sprite; // 1 while the CAST is being drawn; see ShadowMap.sprites vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) { // the same alpha discard the main pass uses: a sprite card casts its // silhouette, not its 16x16 bounding box if (Texel(tex, tc).a < 0.5) discard; - // pack into two channels: the high byte in red, the low in green + // pack into two channels: the high byte in red, the low in green. + // Blue says WHAT cast this, which costs a channel that was zero anyway + // and lets a surface decline one kind of caster -- water does, for the + // people (see Water's sunLit). float d = clamp(vDepth, 0.0, 1.0) * 255.0; - return vec4(floor(d) / 255.0, fract(d), 0.0, 1.0); + return vec4(floor(d) / 255.0, fract(d), sprite, 1.0); } #endif ]] +ShadowMap._source = function() return SHADER end -- named for the suite + local shader = nil -- nil = untried, false = unavailable local canvas = nil -- nil = untried, false = unavailable local canvasRes = 0 -- the edge `canvas` was made at @@ -440,6 +446,9 @@ function ShadowMap.begin(cx, cy, vw, vh) love.graphics.setShader(sh) love.graphics.setColor(1, 1, 1, 1) pcall(sh.send, sh, "lightVP", "row", ShadowMap.clipVP) + -- the world until a cast pass says otherwise, reset per pass so one that + -- forgot to put it back cannot leak into the next map's terrain + pcall(sh.send, sh, "sprite", 0) drawing = true ready = false return true @@ -448,6 +457,25 @@ end -- Draw one caster. Same signature as Voxel3D.draw minus the camera-ward -- pull, which is a trick for the VIEW's depth buffer and would drag a -- shadow off whatever throws it. +-- Whether what is drawn next is one of the CAST -- a walker, an authored +-- figure, a battle's Pokemon -- rather than part of the world. false for the +-- length of such a pass, true to put it back. +-- +-- The map records it per texel (the shader's blue channel) so a surface can +-- decline that kind of caster, and exactly one does: water. A character +-- standing at a lake's edge threw a hard cut-out of its own sprite across +-- the surface, which on something showing the sky and the shoreline reads as +-- a sticker rather than as a shadow in the water. Everything else -- ground, +-- roofs, ledges, the characters themselves -- still takes them. +-- +-- Sent rather than branched, so a caller that forgets to put it back only +-- mislabels casters rather than losing them; begin() resets it per pass. +function ShadowMap.sprites(on) + if not drawing then return end + local sh = getShader() + if sh then pcall(sh.send, sh, "sprite", on and 1 or 0) end +end + function ShadowMap.draw(mesh, texture, model) if not (drawing and mesh) then return end local sh = getShader() diff --git a/lib/VoxelScene.lua b/lib/VoxelScene.lua index 96bd4b8..b9244fb 100644 --- a/lib/VoxelScene.lua +++ b/lib/VoxelScene.lua @@ -699,6 +699,11 @@ local function castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh, ShadowMap.draw(ChunkMesher.flowers(nb.map), atlasFor(nb.map), ShadowMap.snug(Mat4.translate(nb.ox, 0, nb.oy))) end + -- From here down it is the CAST, marked as such in the map (see + -- ShadowMap.sprites) so water can decline them: everything the world casts + -- still shades a lake, a silhouette of somebody standing beside it does + -- not. Ground, roofs and the characters themselves take them as before. + ShadowMap.sprites(true) -- authored figures cast too, for the same reason the flowers do: a -- handful of cards per map, and a person with no shadow reads as pasted on eachFigure(state.map, 0, 0, function(mesh, _, caster) @@ -720,6 +725,7 @@ local function castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh, mirror))) end end + ShadowMap.sprites(false) ShadowMap.finish(sig) end @@ -779,8 +785,30 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor) Mat4.translate(nb.ox, 0, nb.oy)) end + -- Without a shadow map (headless, or a driver that could not make the + -- canvas) the old flat decals stand in: ground-only, characters only, + -- but better than a world with nothing under anybody. They go down + -- first, as decals the characters then stand over -- depth-tested + -- against the terrain just drawn (a shadow behind a building stays + -- hidden) but never depth-writing, so the grass pass at the end of the + -- frame still wins its feet-overdraw fights. + if not Voxel3D.shadowsActive() then + Voxel3D.beginShadows() + for _, p in ipairs(posed) do + drawShadow(p.sprite, p.px, p.py, p.facing, p.phase, p.flip, p.gh, + p.lift) + end + Voxel3D.endShadows() + end + -- and the water over the top of it, reflecting everything just drawn plus - -- the sky the frame opened with (see drawWater) + -- the sky the frame opened with (see drawWater). + -- + -- After the fallback decals deliberately: those are the stand-in drop + -- shadows for a frame with no shadow map, they write no depth, and a + -- lake would otherwise wear one as a black smear. Water covers them, + -- which is the same answer the shadow map's own pass gives (see + -- ShadowMap.sprites) -- people do not shadow water either way. local waterDraws = {} if water then waterDraws[#waterDraws + 1] = { water, atlasFor(state.map), nil } @@ -800,21 +828,6 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor) end) end - -- Without a shadow map (headless, or a driver that could not make the - -- canvas) the old flat decals stand in: ground-only, characters only, - -- but better than a world with nothing under anybody. They go down - -- first, as decals the characters then stand over -- depth-tested - -- against the terrain just drawn (a shadow behind a building stays - -- hidden) but never depth-writing, so the grass pass at the end of the - -- frame still wins its feet-overdraw fights. - if not Voxel3D.shadowsActive() then - Voxel3D.beginShadows() - for _, p in ipairs(posed) do - drawShadow(p.sprite, p.px, p.py, p.facing, p.phase, p.flip, p.gh, - p.lift) - end - Voxel3D.endShadows() - end -- Sprite sheets from here to the figure pass: their texture coordinates -- mean nothing to the tileset-shaped glass mask, so the glass is off or diff --git a/lib/Water.lua b/lib/Water.lua new file mode 100644 index 0000000..96f5368 --- /dev/null +++ b/lib/Water.lua @@ -0,0 +1,1201 @@ +-- Voxel world mode: water, and what it reflects. +-- +-- Every other surface in this mode is opaque and is drawn once, inside the +-- terrain mesh, by the scene shader. Water is neither: it is a MIRROR, and +-- a mirror cannot be drawn until the thing it reflects already exists. So +-- the water surface is lifted out of the terrain mesh at build time +-- (ChunkMesher's water sink) and drawn as its own pass, after the world and +-- before the characters, by the shader below. +-- +-- WHAT IT REFLECTS, in the order the shader resolves them: +-- +-- the sky the reflected direction is put through the SAME matrix the +-- frame is drawn with, as a point at infinity, and the canvas +-- row that lands on is looked up on Sky's own band ramp -- +-- the identical texture, dither and display-mode transform +-- the painted sky uses. So the sky in the lake is the sky +-- over it: blue at noon, gold at dusk, navy under the moon, +-- and it meets the painted sky at the waterline with no seam. +-- +-- the sun, hung by ANGLE rather than by screen position, because a +-- the moon reflected body is usually off the top of the frame and a +-- projected point is meaningless out there. The angular +-- radius is Sky.discRadius converted through the camera's own +-- field of view, so the disc on the water is exactly as big +-- as the disc in the sky -- craters, dithered rim, the +-- sunset's loom and all. This is also the specular: a low sun +-- lays a broken gold path across the water on its own, out of +-- the reflection rather than out of a highlight term. +-- +-- the world SCREEN SPACE. The reflected ray is walked forward in world +-- space, each step projected through the same matrix, looking +-- for where it passes behind what the depth buffer holds -- +-- then binary-refined onto the contact and read out of a copy +-- of the frame as it stood before the water went down. Shore +-- trees, buildings, ledges and cliffs land in the water +-- because they are on screen; where the ray leaves the frame +-- or finds nothing, the sky above answers instead, which is +-- what makes the far half of a lake sky and the near half +-- scenery without a seam between them. +-- +-- the cast the walkers, the NPCs, the authored figures and a staged +-- battle's two Pokemon. Awkward, and settled by drawing them +-- twice: Gen 1 draws people OVER the world and water is +-- world, so a surfing player has to composite after the +-- water, and a reflection can only hold what came before it. +-- So they are painted into the reflection copy alone +-- (Voxel3D.beginWater), in the picture the water reflects and +-- not yet in the picture it is drawn into. +-- +-- WHAT IT CANNOT REFLECT is what no screen-space reflection can: anything +-- that is not in the frame. A tree just off the top edge is not in the water +-- below it, and a ray that runs off the side of the screen fades into the +-- sky rather than ending on a line. +-- +-- THE SURFACE ITSELF is not flat. It is a heightfield of one-world-pixel +-- columns, each standing a whole number of pixels tall and rising and +-- falling as waves, walked by the view ray in the pixel shader -- so the +-- bars occlude each other and show their sides without a single extra +-- vertex. See WAVE_HEIGHT and relief(). +-- +-- THE PASS ITSELF, and why it is shaped this way. The scene canvas carries a +-- READABLE depth canvas (Voxel3D), and a texture cannot be sampled while it +-- is bound as a render target -- so for the length of this pass the depth +-- buffer is DETACHED and the shader does the depth test itself, comparing +-- its own fragment depth against the texture it just stopped writing to. +-- That is the same test the hardware would have run, so a tree in front of a +-- pond still hides it; what it costs is depth WRITES, which water has no use +-- for anyway (it is flat, it never overlaps itself, and everything drawn +-- after it stands on top of it by construction). +-- +-- Falls back all the way down. No readable depth canvas, a driver that will +-- not compile this, or the row set to OFF and the water mesh is simply drawn +-- by the ordinary scene shader -- flat animated water, exactly what the mode +-- drew before any of this existed. + +-- the mod namespace (see main.lua): V.require loads a sibling module +local V = ... + +local ModSetting = V.require("ModSetting") +local Sky = V.require("Sky") +local DayNight = V.require("DayNight") +local ShadowMap = V.require("ShadowMap") +local Mat4 = V.require("Mat4") + +local Water = {} + +-- ------- the row +-- +-- Three rungs rather than a toggle, because the two halves of this cost +-- very different things. SKY is a handful of instructions per water pixel +-- and no extra buffers read; FULL adds the screen-space march, which is the +-- part that samples a depth texture twenty-odd times. A phone that wants the +-- sunset on the lake but not the ray march has somewhere to stand. +Water.KEY = "water" +Water.LABEL = "WATER" + +Water.setting = ModSetting.new(Water.KEY, Water.LABEL, + { "full", "sky", "off" }, + { "FULL", "SKY", "OFF" }) + +function Water.level() + local v = Water.setting:get() + if v == "off" then return 0 end + if v == "sky" then return 1 end + return 2 +end + +-- Whether the reflective pass should run at all (either rung above OFF). +function Water.enabled() + return Water.level() > 0 +end + +-- ------- the look, in constants +-- +-- FRESNEL. Water reflects almost nothing looked straight down at and almost +-- everything looked along, which is Schlick's curve -- and taken literally +-- it hands the top rung a mirror and the other four nothing at all. This +-- mode's rungs are named for the camera's tilt off VERTICAL, so 15 is a +-- near-overhead camera meeting the water at 15 degrees off its normal: +-- honest Schlick gives that about 2%, and even a generous floor of 0.14 was +-- invisible. +-- +-- So the floor is lifted a long way above water's true 0.04 and the exponent +-- softened from 5 to 2: the SHAPE is still the honest one -- a low camera +-- still gets much more of it than a high one -- but the bottom of the curve +-- is a pond rather than a painted tile. +Water.FRESNEL_FLOOR = 0.34 +Water.FRESNEL_CEIL = 0.92 +Water.FRESNEL_POWER = 2.0 + +-- THE HORIZON LEAN, which is the other half of why the steeper rungs showed +-- nothing -- and the bigger half. +-- +-- A reflection off flat water points as far ABOVE the horizon as the eye is +-- above the water. At the top rung that is 15 degrees: the reflected ray +-- grazes the sky's pale end, sweeps the sun's own path and travels far +-- enough across the screen for the march to find the shoreline. At the 15 +-- rung it is 75 degrees -- straight up. Up there the sky's bands are at +-- their DARKEST (deep blue over blue water, which is no picture at all), the +-- sun and moon sit at about 6 degrees of squashed elevation and are nowhere +-- near it, and the screen-space ray leaves the top of the frame in two +-- steps. All three of those are correct, and together they are a lake with +-- nothing in it. +-- +-- So the reflected direction LEANS toward the way this camera is looking, by +-- however far the camera is from having a horizon in frame. That is a +-- deliberate stylisation and it is worth being exact about what it costs and +-- what it does not: +-- +-- at the rung where the horizon IS in frame the lean is ZERO, so the one +-- place the join can actually be seen -- the waterline, where the lake +-- meets the painted sky -- is still the exact reflection it was. +-- +-- at the rungs where the horizon is above the top edge there is no join to +-- break, and what the lean buys is the whole of the effect: the pale bands, +-- the sunset, the moon's path, and a screen-space ray that travels ACROSS +-- the diorama instead of straight out of it. +-- +-- It leans toward an ELEVATION rather than by a weight, and that matters. +-- Mixing the ray a fixed fraction of the way toward horizontal sounds like +-- the same thing and is not: the ray it starts from is different at every +-- rung, so a fixed fraction lands them all somewhere different, and the +-- middle rungs came out worst of all -- further from the sun than the +-- steepest one. Aimed at an elevation, every rung below the top one puts its +-- reflection where the TOP rung puts its own, which is the one place the +-- effect is known to work. +-- +-- Measured off Voxel3D.descent -- the sine of how far below horizontal the +-- view runs -- so it answers for the battle's placed camera too, which has +-- no rung to be asked about. +Water.LEAN_FROM = 0.30 -- descent where the lean starts: the top rung's +Water.LEAN_FULL = 0.55 -- and where it is complete +-- the elevation it aims at: the one the top rung's own reflection sits at, +-- stated as that same descent so the two cannot drift apart +Water.LEAN_ELEV = math.asin(Water.LEAN_FROM) + +function Water.lean(descent) + local span = Water.LEAN_FULL - Water.LEAN_FROM + local t = ((descent or 0) - Water.LEAN_FROM) / span + if t <= 0 then return 0 end + return t < 1 and t or 1 +end + +-- ------- the waves +-- +-- Not a normal map. The surface is a HEIGHTFIELD of one-world-pixel columns +-- -- the same unit every other voxel in this mode is built from, and exactly +-- one texel of the water tile (a tile is 8 texels across 8 world pixels) -- +-- and every column stands at a whole number of pixels. So the water is a +-- field of little square bars rising and falling on their own, which is what +-- water made of pixels should look like from a camera that can see it in 3D. +-- +-- It is drawn without any extra geometry. The mesh is still one flat quad +-- per tile; the columns are found by walking the view ray down through the +-- slab in the pixel shader (relief mapping) and taking the first one it +-- meets. That is what makes them read as SOLID rather than as shading: a +-- tall bar hides the shorter ones behind it, you see the SIDE of the ones +-- facing you, and the whole field parallaxes against the plane as the camera +-- moves. The side faces wear the mesh's own direction shading +-- (Voxel3D.FACE_SHADE, sent in rather than restated) so a wave crest is lit +-- like every other voxel in the world. +-- +-- HEIGHT is in world pixels: the tallest a column may stand above the plane +-- the quad is drawn on, and so both the amplitude and the number of rungs a +-- crest can climb through (five gives six). +-- +-- It is well past the 2px recess TileShape sinks water into, which is a +-- deliberate look rather than an oversight: the crests are RELIEF, drawn +-- inside the water quad's own screen footprint, so a bar that reaches above +-- the shoreline cannot actually spill over the bank -- it is clipped at the +-- water's edge like everything else this pass draws. What it buys is a +-- surface with real swell in it instead of a two-rung terrace. +Water.WAVE_HEIGHT = 5 + +-- ------- the trains +-- +-- Each is { fx, fz, speed, weight }. The vector is the train's DIRECTION and +-- its frequency in one -- the crest runs across it, and two pi over its +-- length is the wavelength in world pixels -- and `speed` is what walks it. +-- +-- The first one dominates, and that weighting is the whole difference +-- between water and soup: a wave has a direction, and its crest is a line +-- running across it for as far as the surface goes. Three trains of equal +-- weight cancel and reinforce in patches instead, and the field comes out as +-- round islands of raised pixels with no travel to them. +-- +-- Long, too: the dominant wavelength is about forty world pixels, five +-- tiles, so a crest is a run of hundreds of columns at one height with a +-- step down either side. Pitched anywhere near a pixel they stop being waves +-- and become static -- every column its own island. +-- +-- Read into the shader source rather than sent as uniforms, so the rate +-- below can be derived from the same numbers the field is built out of. +Water.WAVE_TRAINS = { + { 0.150, 0.062, 1.60, 0.60 }, + { 0.058, 0.132, -1.05, 0.29 }, + { -0.041, 0.033, 0.55, 0.11 }, +} + +-- ------- and the beat they move on +-- +-- The surface does not slide, it advances in STEPS -- 15 a second, off the +-- engine's own frame counter, which is the cadence hand-drawn pixel art is +-- animated at and the reason this reads as art rather than as a simulation +-- someone forgot to stylise. A surface built out of whole pixels that crawls +-- between them smoothly gives away that the quantisation is only skin deep. +Water.WAVE_FPS = 15 + +-- How far the dominant train advances each of those steps, in WORLD PIXELS. +-- One is the honest choice for a stepped surface: the whole field shifts by +-- exactly one pixel per frame, so nothing ever lands half-way between two. +-- The rate below is derived from it rather than tuned beside it, so changing +-- a wavelength moves the speed with it instead of quietly desynchronising. +Water.WAVE_PIXELS_PER_STEP = 1 + +-- Radians of wave phase per second. A train travels `speed / frequency` +-- world pixels per radian of phase, so the phase that moves the dominant one +-- a pixel is its frequency over its speed -- times the step rate. +function Water.waveRate() + local t = Water.WAVE_TRAINS[1] + local freq = math.sqrt(t[1] * t[1] + t[2] * t[2]) + local speed = math.abs(t[3]) + if not (freq > 0 and speed > 0) then return 0 end + return Water.WAVE_PIXELS_PER_STEP * (freq / speed) * Water.WAVE_FPS +end +-- Relief samples down through the slab. With the stride pinned at one world +-- pixel (see WAVE_STRIDE) this is also how FAR the march can see: sixteen +-- samples, sixteen pixels of parallax, which covers the slab at every rung +-- but the very lowest and leaves the rest to fade out honestly. +-- +-- The pass early-outs entirely (see relief) whenever the camera is steep +-- enough that the whole slab projects to under a pixel across, which is most +-- of the ladder -- so the cost of this only lands where it buys something. +Water.WAVE_STEPS = 16 + +-- The furthest one relief sample may travel ACROSS the surface, in world +-- pixels -- which is what bounds how far the march runs in total. +-- +-- The march's reach is the slab's depth over the ray's descent, so it grows +-- without limit as the camera flattens: at the top rung, fragments near the +-- horizon look along the water at a few degrees and the reach runs to +-- hundreds of world pixels. Spread over a fixed number of samples that steps +-- clean over whole crests, and the surface comes apart into streaks running +-- away from the eye. Capping the span is what keeps a sample worth taking; +-- what it costs is parallax on the far water, where the columns are under a +-- pixel across and there was nothing left to see anyway. +-- +-- This is the FLOOR on it. The stride the march actually takes is a SCREEN +-- pixel's worth of surface, which is the only rate that makes sense: +-- +-- up close, a screen pixel is a fraction of a world pixel, so the stride +-- sits on this floor of one world pixel and the march visits every column +-- on its path. It has to: a column is one world pixel wide, a longer +-- stride steps over columns, and which ones it misses changes from +-- fragment to fragment -- neighbouring pixels landing on different columns +-- at different heights wearing different faces. That is peppery noise. +-- +-- far away, a screen pixel already spans several world pixels, so a stride +-- that matches it skips columns the screen could not have resolved anyway. +-- Holding it at one world pixel out there does not buy detail, it just +-- runs out of samples -- and a march that runs out stops part-way down the +-- slab and reports the surface as flat, which is why the lowest rung lost +-- its waves entirely across the whole middle distance. +Water.WAVE_STRIDE = 1 + +-- How far the wave field's own gradient tilts the REFLECTION. A multiplier +-- on the SMOOTH surface's slope, not on the stepped one -- see waveNormal +-- for why that distinction is the whole difference between a moon on the +-- water and confetti. The field's gradient peaks around 0.06 per world +-- pixel, so this lands the steepest faces about twelve degrees off vertical: +-- enough to sweep a low sun or moon into a broken glitter path down the +-- lake, and not so much that the sky's own bands come apart. +Water.WAVE_SLOPE = 3.5 +-- and how far the horizon lean is allowed to open that up, since it squashes +-- the same tilt on its way past (see LEAN_FROM) +Water.WAVE_SLOPE_LEAN = 1.5 + +-- THE MARCH. Steps are in world pixels and lengthen as they go: near the +-- surface the reflection needs precision (a shoreline is a few pixels), far +-- from it reach matters more than accuracy, and a geometric ramp gets both +-- out of one loop. RAY_STEPS is compiled in -- GLSL wants a constant bound. +Water.RAY_STEPS = 24 +Water.RAY_REFINE = 5 -- halvings once a crossing is found +Water.RAY_STEP = 3.0 -- world pixels in the first step +-- and the ratio each step after it. 3 x (1.18^24 - 1) / 0.18 is about 930 +-- world pixels of reach -- three view-heights, past which a reflection is +-- faded out anyway (see the tail fade in march) and the sky is the honest +-- answer: distant water reflects haze, which is what the bands already are. +Water.RAY_GROW = 1.18 +-- How far behind the depth buffer a crossing may land and still count, as a +-- multiple of the depth the step itself covered. A ray that dives far past +-- what it crossed went BEHIND a thin thing rather than hitting it -- the +-- classic screen-space smear, where a tree between the camera and the pond +-- paints itself across the water -- and this is the test that drops it. +Water.RAY_THICK = 1.6 +Water.EDGE_FADE = 0.14 -- reflection eased off over this much of the frame + +-- ------- the shader +-- +-- The scene shader's own vertex path, plus the world position the geometry +-- was actually DRAWN at -- after the world curve, because that is the space +-- the depth buffer holds and therefore the space the march has to walk in. +-- (The curve only ever moves Y, so a fragment's world XZ is the same on both +-- sides of it and the ripple can be measured off this one too.) +local SHADER_SRC = [[ +varying float vShade; +varying vec3 vSun; +varying vec3 vBent; // world position, as drawn + +#ifdef VERTEX +uniform mat4 vp; +uniform mat4 model; +uniform mat4 sunVP; +uniform vec3 curve; // xy = the focus in world XZ, z = k; 0 = off +attribute float VertexShade; + +vec4 position(mat4 transform_projection, vec4 vertex_position) { + vShade = VertexShade; + vec4 w = model * vertex_position; + vSun = (sunVP * w).xyz; + if (curve.z > 0.0) { + vec2 cd = w.xz - curve.xy; + w.y -= dot(cd, cd) * curve.z; + } + vBent = w.xyz; + return vp * w; +} +#endif + +#ifdef PIXEL +uniform mat4 vp; +uniform vec3 eye; +uniform vec2 screen; // the canvas, in pixels +uniform float cell; // one diorama pixel, in canvas pixels +uniform float pxAngle; // radians of view one screen pixel subtends + +// the sun's own pass, exactly as the scene shader reads it +uniform Image sunMap; +uniform float sunDark; +uniform float sunBias; +uniform vec2 sunTexel; +uniform vec3 dayTint; + +// the frame as it stood before the water went down, and its depth +uniform Image reflectTex; +uniform Image depthTex; + +uniform float rays; // 0 = sky only, 1 = march the screen too +uniform vec3 lookFlat; // the way the horizon lies from this camera +uniform float lean; // and how far the reflection tilts toward it +uniform float leanElev; // the elevation it aims at, in radians +uniform float waveHeight; // the tallest column, in whole world pixels +uniform float waveSlope; // how far a column's neighbours tilt its normal +uniform float waveSlopeLean; // and how far the horizon lean may open that up +uniform float waveT; +uniform vec4 faceShade; // the mesh's own direction shading: E, W, S, N +uniform vec2 atlasSize; // the tileset atlas, in texels +uniform float fresnelFloor; +uniform float fresnelCeil; +uniform float fresnelPower; +uniform float rayStep; +uniform float rayGrow; +uniform float rayThick; +uniform float edgeFade; + +// the sky, as Sky paints it +uniform Image skyRamp; +uniform float skyCount; +uniform float skyEdge; // the sky's bottom, in canvas pixels +uniform float skyStart; // where the checker begins inside a band +uniform float skyOn; // 0 indoors: there is no sky to reflect + +// and what hangs in it +uniform vec3 bodyDir; +uniform float bodyOn; +uniform float bodyMoon; +uniform float bodyAng; // the disc's angular radius, in radians +uniform vec3 bodyCore; +uniform vec3 bodyMain; +uniform vec3 bodyDark; +uniform float glowAmt; +uniform float glowReach; // in radians, like bodyAng +uniform vec3 glowColor; + +#ifdef VOXEL_GRID +uniform float gridDark; +uniform float gridWidth; +#endif + +// ------- the sun's pass (the scene shader's, verbatim) + +// One shadow tap: 1 where the sun reaches, 0 where something blocks it -- +// EXCEPT that water declines one kind of blocker. +// +// The sun pass marks the cast in the blue channel (ShadowMap.sprites), and +// water ignores those. A character standing at a lake's edge laid a hard +// cut-out of its own sprite across the surface, and on something that is +// already showing the sky, the shoreline and the trees behind it, a +// silhouette of somebody reads as a sticker on the water rather than as a +// shadow in it. Everything the WORLD casts -- trees, buildings, cliffs, +// ledges -- still shades it, which is the half that was worth having. +float sunLit(vec2 uv, float z) { + vec4 c = Texel(sunMap, uv); + return max(step(z, c.r + c.g * (1.0 / 255.0)), c.b); +} + +float sunlight(vec3 p) { + if (sunDark <= 0.0) return 1.0; + if (p.x < 0.0 || p.x > 1.0 || p.y < 0.0 || p.y > 1.0 || p.z > 1.0) { + return 1.0; + } + vec2 e = min(p.xy, 1.0 - p.xy); + float edge = smoothstep(0.0, 0.06, min(e.x, e.y)); + if (edge <= 0.0) return 1.0; + float z = p.z - sunBias; + float lit = sunLit(p.xy + sunTexel * vec2(-0.5, -0.5), z) + + sunLit(p.xy + sunTexel * vec2( 0.5, -0.5), z) + + sunLit(p.xy + sunTexel * vec2(-0.5, 0.5), z) + + sunLit(p.xy + sunTexel * vec2( 0.5, 0.5), z); + return 1.0 - sunDark * edge * (1.0 - lit * 0.25); +} + +#ifdef VOXEL_GRID +// The wireframe, ruled on the COLUMNS rather than on the flat sheet they +// stand on. +// +// The scene shader reads a mesh's own model space, and for water that is the +// base plane -- so it would draw a grid across a flat sheet and ignore the +// bars entirely, which is the one thing that would give away that they are +// bars. What has to be outlined is what is actually SEEN: the column the ray +// landed on, at the height it landed at, so every voxel of water reads as +// its own block with its own edges. +// +// `p` is that hit; `base` is the smooth plane under it, and the derivative +// comes from THERE. `p` jumps a whole column between neighbouring fragments, +// so fwidth() of it reports a step rather than a scale and every column edge +// would blow out into a band. The plane underneath is smooth, and is the +// same scale in x and z that the columns are built on. +// +// `axis` is the direction the face does not vary along -- the top's own y, +// or a side's x or z. Its distance to the nearest plane is a constant zero, +// and taken at face value it floods the whole face solid; pushed out of +// reach it simply drops out, exactly as the scene shader's own seam handles +// the axis a face's normal points along. +float columnSeam(vec3 p, vec3 base, float axis) { + vec3 w = fwidth(base); + float wide = max(w.x, w.z); + // the plane has no vertical extent of its own to measure, so y borrows + // the horizontal scale -- it sets a line's THICKNESS and nothing else + w.y = wide; + vec3 d = abs(fract(p + 0.5) - 0.5); + vec3 px = d / max(w, vec3(1e-6)); + if (axis < 0.5) { px.y += 1e6; } + else if (axis < 1.5) { px.x += 1e6; } + else { px.z += 1e6; } + float near = min(min(px.x, px.y), px.z); + // Fade out where a column is too small on screen to hold a line at all, + // or the far water turns into a flat wash of seams rather than a grid. + // + // It holds on further than the scene shader's own does. That one is ruling + // seams across whole 16px walls and roofs; these are one world pixel + // apart, so the fade starts biting while the water is still perfectly + // readable -- and at the lowest rung, where the middle distance is most of + // the frame, it took the grid off nearly all of it. Full lines by a pixel + // and a half of screen space, gone under three quarters of one. + float span = 1.0 / max(wide, 1e-6); + float fade = clamp((span - 0.75) * 1.35, 0.0, 1.0); + return fade * clamp(gridWidth * 0.5 + 0.5 - near, 0.0, 1.0); +} +#endif + +// ------- the sky, by direction + +vec3 bandAt(float i) { + return Texel(skyRamp, + vec2((clamp(i, 0.0, skyCount - 1.0) + 0.5) / skyCount, 0.5)).rgb; +} + +// Where a DIRECTION lands on the sky's own gradient, as a band coordinate +// in [0, count]: 0 is straight overhead, count the horizon. +// +// Measured by putting the direction through the very matrix the frame is +// drawn with, as a point at infinity -- which is how Voxel3D finds both the +// vanishing line and the sun's place on the canvas. So the reflected sky and +// the painted sky are answering the same question with the same arithmetic, +// and they agree at the waterline for free at any pitch, fov or zoom. +// +// A direction whose w comes out negative is BEHIND the camera plane, which +// for an upward reflection means near-vertical: the top band, overhead. +float skyPos(vec3 d) { + vec4 c = vp * vec4(d, 0.0); + if (c.w <= 1e-6) return 0.0; + float py = (c.y / c.w * 0.5 + 0.5) * screen.y; + float row = floor(py / cell) * cell; + return clamp(row / max(skyEdge, 1.0), 0.0, 1.0) * skyCount; +} + +// `parity` is the diorama checkerboard this fragment sits on -- the same +// one Sky's own dither is cut from, so the reflected gradient breaks up in +// the same 8-bit way rather than being the one smooth thing in the frame. +vec3 skyAt(vec3 d, float parity) { + float pos = skyPos(d); + float base = min(floor(pos), skyCount - 1.0); + vec3 c = bandAt(base); + if (base < skyCount - 1.0 && (pos - base) > skyStart && parity < 0.5) { + c = bandAt(base + 1.0); + } + return c; +} + +float crater(vec2 p, vec2 c, float r) { + vec2 dd = p - c; + return step(dot(dd, dd), r * r); +} + +// The sun or moon, and the twilight warmth around it, laid over the bands. +// +// By ANGLE, not by screen position: the reflected direction usually +// projects off the top of the frame entirely, where screen distances stop +// meaning anything. bodyAng is Sky.discRadius run back through the camera's +// field of view, so this disc is the same size as the painted one. +vec3 bodyAt(vec3 d, vec3 c, float parity) { + if (bodyOn <= 0.0) return c; + float ang = acos(clamp(dot(d, bodyDir), -1.0, 1.0)); + if (glowAmt > 0.0) { + float g = glowAmt * pow(clamp(1.0 - ang / glowReach, 0.0, 1.0), 2.0); + float lvl = floor(g * 4.0); + if (g * 4.0 - lvl > 0.5 && parity < 0.5) { lvl += 1.0; } + c = mix(c, glowColor, min(lvl / 3.0, 1.0) * 0.65); + } + if (ang > bodyAng) return c; + float t = ang / bodyAng; + // the dithered rim, exactly as the painted disc keeps one parity of its + // outer ring of cells + if (t > 0.86 && parity < 0.5) return c; + vec3 disc = (t <= 0.5) ? bodyCore : bodyMain; + if (bodyMoon > 0.5) { + // disc-local coordinates: a frame built off world up, so the craters + // sit on the moon the same way round every night + vec3 t1 = normalize(cross(vec3(0.0, 1.0, 0.0), bodyDir)); + vec2 dc = vec2(dot(d, t1), dot(d, cross(bodyDir, t1))) / bodyAng; + float k = 0.0; +//@CRATERS + if (k > 0.0) { disc = bodyDark; } + } + return disc; +} + +// ------- the screen-space march + +// A point as (uv, depth, valid), through the very matrix the frame was drawn +// with. The uv and the depth are the same numbers the hardware wrote -- the +// clip-space Y flip is already baked into `vp`, and a canvas texture's v runs +// the same way its pixel rows do, so one 0.5x+0.5 answers for both. +// +// This is why the march walks in the world as DRAWN rather than as authored: +// the depth buffer holds the curved world, so a straight line in that space +// is the ray, and a straight line in the flat one would bend through it. +vec4 project(vec3 p) { + vec4 c = vp * vec4(p, 1.0); + if (c.w <= 1e-6) return vec4(0.0, 0.0, 0.0, 0.0); + return vec4(c.xy / c.w * 0.5 + 0.5, c.z / c.w * 0.5 + 0.5, 1.0); +} + +// Walk the reflected ray until it passes behind the depth buffer. Returns +// the colour found in .rgb and how much of it to believe in .a -- 0 for a +// ray that left the frame, ran out of steps, or crossed something it went +// straight through rather than landed on. +vec4 march(vec3 origin, vec3 dir) { + vec4 miss = vec4(0.0, 0.0, 0.0, 0.0); + vec3 a = origin; + vec4 pa = project(a); + if (pa.w < 0.5) return miss; + float len = rayStep; + for (int i = 0; i < RAY_STEPS; i++) { + vec3 b = a + dir * len; + vec4 pb = project(b); + if (pb.w < 0.5) return miss; + if (pb.x < 0.0 || pb.x > 1.0 || pb.y < 0.0 || pb.y > 1.0) return miss; + float scene = Texel(depthTex, pb.xy).r; + if (pb.z > scene) { + // how much depth this one step covered: the yardstick for whether + // the crossing is a surface or a thin thing the ray shot past + float span = max(abs(pb.z - pa.z), 1e-7); + if (pb.z - scene > span * rayThick) return miss; + // binary-refine onto the contact + vec3 lo = a; + vec3 hi = b; + for (int k = 0; k < RAY_REFINE; k++) { + vec3 m = (lo + hi) * 0.5; + vec4 pm = project(m); + if (pm.z > Texel(depthTex, pm.xy).r) { hi = m; } else { lo = m; } + } + vec4 hit = project(hi); + if (hit.w < 0.5) return miss; + // Ease out at the frame's rim, where the reflection is about to run + // off the only evidence there is -- and with distance travelled, so a + // long ray hands back to the sky instead of ending on a hard edge. + // + // The distance term is doing two jobs. It hides the march's own tail, + // where the steps are longest and a grazing crossing is least likely + // to be a real surface -- and it is also true: distant water reflects + // haze rather than detail, and the haze is what the bands underneath + // already are. The small floor keeps a genuine far hit as a trace + // rather than deleting it. + vec2 e = min(hit.xy, 1.0 - hit.xy); + float edge = smoothstep(0.0, edgeFade, min(e.x, e.y)); + float far = 1.0 - clamp(float(i) / float(RAY_STEPS), 0.0, 1.0); + return vec4(Texel(reflectTex, hit.xy).rgb, edge * (0.15 + 0.85 * far)); + } + a = b; + pa = pb; + len *= rayGrow; + } + return miss; +} + +// ------- the surface, as a field of pixel-tall columns + +// How high the column at world pixel `q` stands, in WHOLE world pixels. +// +// Whole, because that is what makes them BARS: a column is a voxel like +// every other voxel in this mode, one unit on a side, and a surface that +// stepped in fractions would just be a smooth wave with extra arithmetic. +// Three crossing wave trains, so the field has no readable repeat inside a +// lake's worth of pixels. +// +// The SMOOTH surface underneath, 0 to 1 -- the thing the columns are a +// quantisation of. Summed from Water.WAVE_TRAINS, which is where the trains +// and the reasoning behind their weights live; pasted in rather than sent, +// so the speed derived from those same numbers cannot drift from the field +// they describe. +float waveRaw(vec2 q) { + float h = 0.0; +//@TRAINS + return h * 0.5 + 0.5; +} + +// and the voxel surface: that field, in whole world pixels. +float waveAt(vec2 q) { + if (waveHeight <= 0.0) return 0.0; + return floor(waveRaw(q) * waveHeight + 0.5); +} + +// The tilt this column reflects with -- taken from the SMOOTH field, not +// from the stepped one, and this is the difference between a moon on the +// water and confetti. +// +// Floored heights are integers, so their differences are integers too: a +// column's neighbours are level with it or a whole pixel off, and nothing in +// between. Build the normal out of THOSE and the reflected ray can only ever +// point in about five directions -- straight up, or rotated by twice the +// arctangent of one step, or of two. A flat sky does not mind; the sun and +// the moon are discs barely two degrees across, and a ray that jumps in +// eighteen-degree increments simply steps over them. The lake goes dark and +// the odd column that happens to land dead on flares -- which is exactly +// what "the moon doesn't reflect right" looks like. +// +// The columns are an approximation of a real surface, and light reflects off +// the surface being approximated. So the SHAPE stays quantised -- it is what +// you see, and it is the whole point -- while the normal is read off the +// smooth field the shape is made from. Still one answer per column, because +// `q` is an integer: pixel-quantised in space, continuous in value, which +// puts the glitter path back without softening a single edge. +// +// Forward differences over one pixel: three samples, and the answer only has +// to say which way this piece of the surface leans. +vec3 waveNormal(vec2 q, float tilt) { + float h = waveRaw(q); + float e = waveRaw(q + vec2(1.0, 0.0)) - h; + float s = waveRaw(q + vec2(0.0, 1.0)) - h; + return normalize(vec3(-e * tilt, 1.0, -s * tilt)); +} + + +// Walk the view ray down through the wave slab and return the column it +// actually meets -- RELIEF MAPPING, and the whole reason the bars read as +// solid rather than as a pattern painted on a flat sheet. +// +// The mesh is still one flat quad per tile, so what gets rasterised is the +// point where the ray crosses the BASE plane. The visible surface is +// somewhere above that, and the two differ by more the lower the camera +// sits. So the ray is walked BACKWARD to the top of the slab and then +// stepped down: the first column whose top it falls below is what the eye is +// looking at, and everything shorter behind that column is hidden by it for +// free, because the march simply never reaches it. +// +// A step that lands below a column's top having just ARRIVED in that column +// is looking at its side; one that was already there and fell through is +// looking at its top. That is the whole of the face test, and it is what +// gives a crest a lit face and a shaded one. +// +// `axis` names which way the face it found points -- 0 top, 1 east/west, 2 +// north/south -- because the wireframe needs to know the one direction the +// face does not vary along (see columnSeam). +void relief(vec3 base, vec3 dir, out vec3 hit, out vec2 col, out float face, + out float axis) { + col = floor(base.xz); + hit = base; + face = 1.0; + axis = 0.0; + float dy = -dir.y; + // a ray running level along the surface has no slab to walk through, and + // dividing by its descent would send the start point to infinity + if (waveHeight <= 0.0 || dy < 0.02) return; + float across = length(dir.xz); + float reach = waveHeight / dy; + // How far across the surface the whole slab displaces the answer. Under + // half a pixel it cannot pick a different column than the one already + // under the fragment, so the march would spend its samples arriving where + // it started -- which is exactly the case at the steep rungs, where the + // camera looks nearly straight down the columns and there is no side of a + // bar to see anyway. + float span = reach * across; + if (span < 0.5) { + hit.y = base.y + waveAt(col); + return; + } + // and the other end: `reach` grows as one over the descent, so a grazing + // ray asks for hundreds of world pixels of march from a fixed number of + // samples. + // + // What one sample is worth is a SCREEN pixel of surface, so that is the + // stride (see WAVE_STRIDE). A screen pixel covers this much of the water: + // the distance to the eye times the angle one pixel subtends, opened out + // by the obliquity -- a surface seen edge-on runs away far faster per + // pixel than one seen face-on. Floored at a world pixel, because up close + // a finer stride than the columns themselves buys nothing and skipping + // them costs everything. + float dist = length(base - eye); + float stride = max(WAVE_STRIDE, dist * pxAngle / dy); + float maxSpan = float(WAVE_STEPS) * stride; + if (span > maxSpan) { reach = maxSpan / max(across, 1e-4); } + vec3 top = base - dir * reach; + vec2 wasCol = floor(top.xz); + for (int i = 1; i <= WAVE_STEPS; i++) { + vec3 p = mix(top, base, float(i) / float(WAVE_STEPS)); + vec2 q = floor(p.xz); + float y = base.y + waveAt(q); + if (p.y <= y) { + col = q; + hit = vec3(p.x, y, p.z); + vec2 d = q - wasCol; + if (abs(d.x) + abs(d.y) < 0.5) { + face = 1.0; // fell through the top + axis = 0.0; + } else if (abs(d.x) > abs(d.y)) { + face = (d.x > 0.0) ? faceShade.y : faceShade.x; // west : east + axis = 1.0; + } else { + face = (d.y > 0.0) ? faceShade.w : faceShade.z; // north : south + axis = 2.0; + } + return; + } + wasCol = q; + } +} + +// The water's own art, read at the column the ray landed on rather than at +// the fragment's own place on the flat quad -- otherwise the bars parallax +// away and the pixels they are made of stay behind on the plane. +// +// Read off the COLUMN, not off the fragment. +// +// One world pixel is one atlas texel exactly, and the mesher lays a tile's +// eight texels across its eight world pixels -- so the column at world +// (cx, cz) wears texel (cx mod 8, cz mod 8) and nothing else. That makes the +// lookup exact, and far more importantly STABLE: the art a column shows +// depends only on where that column stands in the world, so it cannot swim +// as the camera moves and two fragments that landed on the same column +// cannot disagree about it. +// +// Offsetting the fragment's own uv by the parallax instead makes the art +// depend on how far the march happened to travel -- and wherever the march +// skipped a column, neighbouring fragments picked texels several apart. That +// is what peppered the surface with noise, and why it cleared up in patches: +// the patches are where the march was not skipping. +// +// The tile origin is the FRAGMENT's, so the lookup can never leave the tile +// this quad was built to sample -- the same bleed the mesher's INSET stops. +vec2 waveUV(vec2 tc, vec2 col) { + vec2 texel = 1.0 / atlasSize; + vec2 tile = 8.0 * texel; + vec2 org = floor(tc / tile) * tile; + return org + (mod(col, 8.0) + 0.5) * texel; +} + +vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) { + // THE DEPTH TEST, done here because the buffer that would have done it is + // detached for the length of this pass so it can be READ (see the header). + // Same comparison, same buffer, same result: a building in front of a pond + // still hides it. + vec2 uv = sc / screen; + if (gl_FragCoord.z > Texel(depthTex, uv).r) discard; + + // THE COLUMN THIS FRAGMENT IS LOOKING AT. Every water pixel is a bar of + // its own standing a whole number of pixels tall, and the ray decides + // which one it meets -- so what follows is answered per COLUMN and not per + // screen pixel: one colour to a bar, at the resolution the water art is + // drawn at, with no smooth shading anywhere across it. (The depth test + // above is the one thing that stays per fragment: that is the hardware's + // own question and it is asked in screen space.) + vec3 view = normalize(vBent - eye); + vec3 hit; + vec2 col; + float face; + float axis; + relief(vBent, view, hit, col, face, axis); + // and the bar's centre, so a column is sampled and reflected from one + // place rather than from wherever inside it the fragment happened to land + vec3 surf = vec3(col.x + 0.5, hit.y, col.y + 0.5); + + vec4 p = Texel(tex, waveUV(tc, col)); + if (p.a < 0.5) discard; + // `face` is the column's own side shading, which is what makes a crest + // read as a solid thing with a lit flank rather than as a bright patch + vec3 base = p.rgb * vShade * face * sunlight(vSun) * dayTint; + + // the reflection follows the WAVES' own shape -- the tilt this column + // takes from the neighbours it stands beside -- rather than an invented + // wobble, so the sky and the sun break along the bars instead of across + // them. Opened up by the lean, which is about to squash it (see below). + vec3 n = waveNormal(col, waveSlope * (1.0 + lean * waveSlopeLean)); + vec3 r = reflect(view, n); + // the same reflection off a LEVEL surface, which is what the lean below + // moves: the difference between the two is this column's own contribution + vec3 rFlat = reflect(view, vec3(0.0, 1.0, 0.0)); + // The horizon lean (see LEAN_FROM in Water.lua): zero at the rung whose + // horizon is in frame, so the waterline join is untouched; taking the ray + // down to the elevation THAT rung reflects at as the camera tips over, + // where there is no join to break and a straight-up reflection has nothing + // in it. Applied to the sky, the body AND the march, because the same + // seventy-five-degree ray that misses the sun also leaves the frame. + // + // What is leaned is the LEVEL reflection, with this column's own deflection + // put back on top afterwards. Leaning the perturbed ray instead sets its + // elevation outright, which overwrites the very variation the waves are + // there to provide: at full lean every column on the lake reflects the + // same elevation, the sky comes out one flat band and the moon -- a disc + // two degrees wide that the ray now never sweeps past -- vanishes + // completely. Which is exactly what it did. + // + // The ray keeps its own BEARING and is only tipped in elevation, so a + // reflection still points where the water is pointing it -- and a ray so + // near vertical that it has no bearing left borrows the camera's. + if (lean > 0.0) { + float fl = length(rFlat.xz); + vec3 bearing = (fl > 1e-3) ? vec3(rFlat.x / fl, 0.0, rFlat.z / fl) + : lookFlat; + float e = mix(asin(clamp(rFlat.y, -1.0, 1.0)), leanElev, lean); + r = normalize(bearing * cos(e) + vec3(0.0, sin(e), 0.0) + (r - rFlat)); + } + + // The checkerboard the sky's bands and the sunset's glow are dithered on, + // cut from the WATER's own columns rather than from the screen. Same + // reasoning the window glint follows in the scene shader: a pattern + // anchored to the screen has the world sliding through it at zoom speed + // whenever the camera pans, which strobes. Anchored to the surface, + // panning moves nothing and only the waves do. + float parity = mod(col.x + col.y, 2.0); + vec3 refl = base; + if (skyOn > 0.5) { + refl = bodyAt(r, skyAt(r, parity), parity); + } + if (rays > 0.5) { + vec4 hit = march(surf, r); + refl = mix(refl, hit.rgb, hit.a); + } + + // Schlick, floored and softened (see FRESNEL_* in Water.lua): the angle + // still decides, a grazing camera still gets a mirror, and a steep one + // still gets a pond rather than a flat sticker. + float ct = clamp(dot(-view, n), 0.0, 1.0); + float f = fresnelFloor + + (fresnelCeil - fresnelFloor) * pow(1.0 - ct, fresnelPower); + vec3 rgb = mix(base, refl, clamp(f, 0.0, 1.0)); + +#ifdef VOXEL_GRID + rgb *= 1.0 - gridDark * columnSeam(hit, vBent, axis); +#endif + return vec4(rgb, 1.0) * color; +} +#endif +]] + +-- The crater list is Sky's (Sky.MOON_CRATERS), pasted in as source rather +-- than sent as a uniform array: GLSL ES has no array constructors worth +-- relying on, and the driver bug that cost the sky its bands is exactly +-- what a uniform array of vectors buys. Built from the one list, so the +-- moon on the water can never grow craters the moon in the sky has not. +local function craterSource() + local out = {} + for _, c in ipairs(Sky.MOON_CRATERS) do + out[#out + 1] = (" k += crater(dc, vec2(%.4f, %.4f), %.4f);") + :format(c[1], c[2], Sky.CRATER_FRAC) + end + return table.concat(out, "\n") +end + +Water._craterSource = craterSource -- named for the suite + +-- and the wave trains, pasted in for the same reason: the rate is derived +-- from this table (Water.waveRate), so the field the shader sums has to be +-- the one that table describes rather than a copy of it kept in step by hand +local function trainSource() + local out = {} + for _, t in ipairs(Water.WAVE_TRAINS) do + out[#out + 1] = (" h += sin(dot(q, vec2(%.4f, %.4f)) + waveT * %.4f)" + .. " * %.4f;"):format(t[1], t[2], t[3], t[4]) + end + return table.concat(out, "\n") +end + +Water._trainSource = trainSource -- named for the suite + +local function source(grid) + local src = SHADER_SRC:gsub("//@CRATERS", (craterSource():gsub("%%", "%%%%"))) + src = src:gsub("//@TRAINS", (trainSource():gsub("%%", "%%%%"))) + local head = ("#define RAY_STEPS %d\n#define RAY_REFINE %d\n" + .. "#define WAVE_STEPS %d\n#define WAVE_STRIDE %.1f\n") + :format(Water.RAY_STEPS, Water.RAY_REFINE, Water.WAVE_STEPS, + Water.WAVE_STRIDE) + if grid then head = head .. "#define VOXEL_GRID 1\n" end + return head .. src +end + +Water._source = source -- named for the suite + +-- Two compilations, exactly as Voxel3D keeps two of the scene shader: the +-- wireframe variant needs derivatives, the one thing a driver can refuse, +-- and a refusal must cost the seams on the water and nothing else. +-- nil = untried, false = unavailable. +local shaders = { [false] = nil, [true] = nil } + +function Water.shader(grid) + grid = grid and true or false + if shaders[grid] == nil then + if not (love.graphics and love.graphics.newShader) then + shaders[grid] = false + else + local ok, sh = pcall(love.graphics.newShader, source(grid)) + if not ok and V and V.mod and V.mod.log then + -- once, where it can be read: the fallback is flat water, which is + -- easy to look at and impossible to diagnose without this line + V.mod.log:warn("water shader did not compile: %s -- lakes draw flat", + tostring(sh)) + end + shaders[grid] = (ok and sh) or false + end + end + return shaders[grid] or nil +end + +-- ------- the pass + +local active = nil -- the shader this pass bound, or nil + +-- The ripple phase. Driven by the ENGINE's tile-animation clock, the same +-- 60Hz counter the water tiles rotate on, so the ripple and the art it +-- ripples move off one number rather than drifting against each other. +local function waveTime() + -- lazily, and through the mod namespace: TerrainAtlas reaches into the + -- engine's renderer at load time, and nothing about a settings row should + -- depend on that having happened yet + local ok, frame = pcall(function() + return V.require("TerrainAtlas")._animFrame() + end) + if not (ok and type(frame) == "number") then return 0 end + -- floored to the wave beat (see WAVE_FPS). The engine's counter runs at + -- 60, so this is the frame that step began on. + local period = 60 / math.max(1, Water.WAVE_FPS) + return (math.floor(frame / period) * period / 60) * Water.waveRate() +end + +Water._waveTime = waveTime + +-- Begin the reflective pass. +-- +-- `ctx` is everything the pass cannot work out for itself, all of it already +-- computed by whoever set the camera up this frame: +-- +-- reflect the frame so far, as a texture (Voxel3D.beginWater) +-- depth its depth, likewise +-- vp, eye, curve, screen, cell the camera, as beginScene sent it +-- skyEdge where the sky's bottom is, or nil indoors / with no bands +-- grid whether the voxel wireframe is compiled into this frame +-- +-- Returns false when the pass cannot run, in which case the caller draws the +-- water mesh through the ordinary scene shader instead. +function Water.begin(ctx) + if not (ctx and ctx.reflect and ctx.depth) then return false end + local level = Water.level() + if level <= 0 then return false end + local sh = ctx.grid and Water.shader(true) or nil + if not sh then sh = Water.shader(false) end + if not sh then return false end + + love.graphics.setShader(sh) + love.graphics.setColor(1, 1, 1, 1) + local function send(name, ...) + pcall(sh.send, sh, name, ...) + end + + send("vp", "row", ctx.vp) + send("eye", ctx.eye) + send("curve", ctx.curve) + send("screen", { ctx.screen[1], ctx.screen[2] }) + send("cell", math.max(1, ctx.cell or 1)) + -- how much of the view one screen pixel is worth: what sets the relief + -- march's stride, so a sample is always about a pixel of surface + send("pxAngle", (ctx.fov or 1) / math.max(1, ctx.screen[2])) + send("reflectTex", ctx.reflect) + send("depthTex", ctx.depth) + + -- the sun's pass, sent the same way and for the same reason the scene + -- shader sends it: the sampler is declared either way, and leaving one + -- unbound is a driver-dependent crash rather than a fallback + local map = ShadowMap.active() + send("sunVP", "row", map and ShadowMap.uvVP or Mat4.identity()) + local tex = ShadowMap.texture() + if tex then send("sunMap", tex) end + local Voxel3D = V.require("Voxel3D") + send("sunDark", map and Voxel3D.SHADOW_ALPHA or 0) + send("sunBias", ShadowMap.bias) + local texel = 1 / ShadowMap.res + send("sunTexel", { texel, texel }) + send("dayTint", Voxel3D.tint or { 1, 1, 1 }) + + send("rays", level >= 2 and 1 or 0) + -- the horizon lean, and the direction it leans toward (see Water.lean) + send("lookFlat", ctx.lookFlat or { 0, 0, -1 }) + send("lean", Water.lean(ctx.descent)) + send("leanElev", Water.LEAN_ELEV) + send("waveHeight", Water.WAVE_HEIGHT) + send("waveSlope", Water.WAVE_SLOPE) + send("waveSlopeLean", Water.WAVE_SLOPE_LEAN) + send("waveT", waveTime()) + -- the columns' side faces wear the MESH's own direction shading, sent in + -- rather than restated, so a wave crest is lit like every other voxel + local fs = Voxel3D.FACE_SHADE + send("faceShade", { fs[1], fs[2], fs[5], fs[6] }) -- east, west, south, north + send("fresnelFloor", Water.FRESNEL_FLOOR) + send("fresnelCeil", Water.FRESNEL_CEIL) + send("fresnelPower", Water.FRESNEL_POWER) + send("rayStep", Water.RAY_STEP) + send("rayGrow", Water.RAY_GROW) + send("rayThick", Water.RAY_THICK) + send("edgeFade", Water.EDGE_FADE) + if ctx.grid then + local VoxelGrid = V.require("VoxelGrid") + send("gridDark", VoxelGrid.DARK) + send("gridWidth", VoxelGrid.WIDTH) + end + + Water.sendSky(sh, ctx) + active = sh + return true +end + +-- The sky half of the uniforms: the band ramp, and whatever hangs in it. +-- +-- Split out because it is the part with a "there is none" answer -- indoors, +-- and on any frame whose ramp could not be built -- and that answer has to +-- leave every sampler bound anyway. skyOn 0 reflects the water's own colour +-- back at itself, which is what a pond in a cave does. +function Water.sendSky(sh, ctx) + local function send(name, ...) + pcall(sh.send, sh, name, ...) + end + local ramp, count = Sky.ramp() + local edge = ctx.skyEdge + if not (ramp and count and edge and edge > 0) then + -- the sampler still has to hold something; the ramp is the only image + -- this shader has for the job, so bind the frame copy and switch it off + send("skyRamp", ctx.reflect) + send("skyCount", 1) + send("skyEdge", 1) + send("skyStart", 2) + send("skyOn", 0) + send("bodyOn", 0) + send("glowAmt", 0) + return + end + send("skyRamp", ramp) + send("skyCount", count) + send("skyEdge", edge) + send("skyStart", Sky.DITHER and Sky.DITHER_START or 2) + send("skyOn", 1) + + local body = DayNight.body() + if not body then + send("bodyOn", 0) + send("glowAmt", 0) + return + end + local amt, glowColor = DayNight.glow() + local h = ctx.screen[2] + local cell = math.max(1, ctx.cell or 1) + -- Sky sizes the disc in canvas pixels; out here the reflected body is + -- usually off the top of the frame, where a pixel is not a unit any more + -- -- so it is converted to the ANGLE it subtends through this camera's + -- own field of view, which is the same number wherever it is looked at. + local perRadian = h / math.max(1e-4, ctx.fov or 1) + local rpx = Sky.discRadius(h, cell, + { moon = body.moon, glowAmt = amt }) + local shades = Sky.discShades(body.moon) + local function shade(i) + local c = shades[i] or shades[#shades] or { 255, 255, 255 } + return { c[1] / 255, c[2] / 255, c[3] / 255 } + end + local twilight = (amt or 0) > 0.25 and not body.moon + send("bodyOn", 1) + send("bodyMoon", body.moon and 1 or 0) + send("bodyDir", { body.dx, body.dy, body.dz }) + send("bodyAng", rpx / perRadian) + send("bodyCore", shade(1)) + send("bodyMain", shade(twilight and 3 or 2)) + send("bodyDark", shade(3)) + send("glowAmt", (not body.moon) and (amt or 0) or 0) + send("glowColor", glowColor + and { glowColor[1] / 255, glowColor[2] / 255, glowColor[3] / 255 } + or { 1, 0.88, 0.66 }) + send("glowReach", (ctx.screen[1] * Sky.GLOW_REACH) / perRadian) +end + +-- Draw one water mesh with `model` applied. Mirrors Voxel3D.draw, minus the +-- camera-ward pull (a flat sheet has nothing to lean over) and the separate +-- sun transform (water is terrain: the sun saw the same matrix). +function Water.draw(mesh, texture, model) + if not (active and mesh) then return end + if texture then mesh:setTexture(texture) end + pcall(active.send, active, "model", "row", model or Mat4.identity()) + -- Per draw rather than per pass, and read off the TEXTURE rather than + -- assumed: it is what converts a world pixel of the columns' parallax into + -- the texel of art standing on it, and two maps in one frame can be drawn + -- from atlases of different sizes. + if texture and texture.getDimensions then + local ok, w, h = pcall(texture.getDimensions, texture) + if ok and w and h then + pcall(active.send, active, "atlasSize", { w, h }) + end + end + love.graphics.draw(mesh) +end + +function Water.finish() + active = nil +end + +-- Drop the compiled shaders (window resize, hot reload): they are GPU +-- objects on a context that may not exist any more. +function Water.invalidate() + for k, sh in pairs(shaders) do + if sh and sh.release then pcall(sh.release, sh) end + shaders[k] = nil + end + active = nil +end + +return Water diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua index c6db4ff..c9f0d0e 100644 --- a/tests/dramatic_shape_test.lua +++ b/tests/dramatic_shape_test.lua @@ -1675,6 +1675,31 @@ T.check(math.abs(math.sin(Water.LEAN_ELEV) - Water.LEAN_FROM) < 1e-12, VoxelState.angle, Voxel3D.camera = wasAngle, wasCam end +-- ------- people do not shadow water +-- +-- The sun pass is ONE map, so a surface cannot ask what threw a shadow +-- unless the map says -- and it does, in the blue channel, which was zero +-- anyway. Water is the only surface that asks: a character standing at a +-- lake's edge laid a hard cut-out of its own sprite across a surface already +-- showing the sky and the shoreline, which reads as a sticker rather than as +-- a shadow. Everything the world casts still shades it. +do +local ShadowMap = run.loader.exports.DRAMATIC_SHAPE.lib.require("ShadowMap") +T.check(type(ShadowMap.sprites) == "function", + "the sun pass can be told it is drawing the cast rather than the world") +-- inert outside a pass, like every other toggle on it -- a caller that +-- brackets a draw it never made must not send to a shader that is not bound +T.check(pcall(ShadowMap.sprites, true) and pcall(ShadowMap.sprites, false), + "and saying so outside one is harmless") + +local shadowSrc = ShadowMap._source and ShadowMap._source() or nil +if shadowSrc then + T.check(shadowSrc:find("fract(d), sprite", 1, true) ~= nil, + "the marker rides the channel the depth pack left free, so it costs " + .. "nothing: the map is still two channels of depth") +end +end + -- ------- the compiled variants local plain = Water._source(false) local gridded = Water._source(true) @@ -1704,14 +1729,19 @@ T.check(plain:find("relief(vBent, view, hit, col, face, axis)", 1, true) ~= nil, -- the march's reach grows as one over the ray's descent, so a grazing camera -- asks for hundreds of world pixels of it from a fixed number of samples -- -- which stepped over whole crests and smeared the surface into streaks +-- a sample is worth a SCREEN pixel of surface, so that is the stride: held +-- at a world pixel up close (finer buys nothing and skipping costs the +-- pepper) and opened out with distance (holding it there just runs the march +-- out of samples part-way down the slab, which flattened the lowest rung's +-- whole middle distance) T.check(plain:find("#define WAVE_STRIDE", 1, true) ~= nil - and plain:find("float maxSpan = float(WAVE_STEPS) * WAVE_STRIDE;", - 1, true) ~= nil, - "and its span is capped to a stride a sample can actually resolve") + and plain:find("max(WAVE_STRIDE, dist * pxAngle / dy)", 1, true) ~= nil, + "the relief stride is a screen pixel's worth of surface, floored at a " + .. "world pixel") T.check(Water.WAVE_STRIDE <= 1, - "which is at most ONE world pixel, because a column is one world pixel " - .. "wide -- a longer stride steps over columns, and which ones it misses " - .. "changes fragment to fragment, which is the peppery noise") + "and that floor is at most ONE world pixel, because a column is one world " + .. "pixel wide -- a longer one steps over columns, and which ones it " + .. "misses changes fragment to fragment, which is the peppery noise") -- and the art is read off the COLUMN rather than by offsetting the -- fragment's own uv by however far the march happened to travel: one world -- pixel is one texel, so a column's texel follows from where it stands and diff --git a/tests/mon_shots.lua b/tests/mon_shots.lua new file mode 100644 index 0000000..73cfefb --- /dev/null +++ b/tests/mon_shots.lua @@ -0,0 +1,71 @@ +-- Driver: one overworld-battle screenshot per species, the mon fighting +-- ITSELF -- its back pic on the player's mark and its front pic on the +-- enemy's, so a single frame shows both sprites the 3D mode draws for it. +-- +-- The point is a visual sweep for pic glitches (holes the paper-fill missed, +-- a silhouette cut wrong, a pin that leaves the mon floating), so every shot +-- is staged identically: same map, same cells, same beat -- the battle menu, +-- both HUD panels up. Whatever differs between two shots is the mon. +-- +-- SHOT_DIR=.scratchpad/mon_shots \ +-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/mon_shots.lua love . +-- +-- Files land as NNN_species.png in dex order. +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or ".scratchpad/mon_shots" + local Pokemon = require("src.pokemon.Pokemon") + local BattleState = require("src.battle.BattleState") + + -- every real species the merged data carries, walked 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) + U.log(("%d species"):format(#species)) + + game.save.player.name = "RED" + + for _, s in ipairs(species) do + -- level 50 both sides: high enough that nothing about the staging is + -- species-specific, and a wild battle never awards exp off a menu shot + game.save.party = { Pokemon.new(game.data, s.id, 50) } + + U.teleport(game, "ROUTE_1", 5, 8, "down") + -- let the neighbourhood's meshes land so the first battle frame is the + -- real arena rather than the flat fallback + U.wait(60) + + local battle = BattleState.newWild(game, s.id, 50) + battle.onFinish = function() end + game.overworld:pushBattle(battle) + + -- the wipe, then tap through "Wild X appeared!" and the send-out until + -- the battle MENU is actually up -- a fixed tap count lands on whatever + -- beat the intro happened to be on, which is how a shot ends up with the + -- trainer still standing where the mon should be + U.wait(70) + for _ = 1, 200 do + if battle.phase == "menu" then break end + U.tap(game, "a") + U.wait(6) + end + if battle.phase ~= "menu" then + U.log(("STUCK before menu: %s (phase %s)"):format(s.id, tostring(battle.phase))) + end + -- let the send-out slide/ball beat finish so the mon is standing still + U.wait(40) + U.shot(game, ("%s/%03d_%s.png"):format(DIR, s.dex, s.id:lower())) + + while game.stack:top() and game.stack:top() ~= game.overworld do + game.stack:pop() + end + U.wait(10) + end + + U.log("done -- " .. DIR) +end diff --git a/tests/pic_dump.lua b/tests/pic_dump.lua new file mode 100644 index 0000000..c6a9714 --- /dev/null +++ b/tests/pic_dump.lua @@ -0,0 +1,63 @@ +-- Driver: dump the exact pic textures a live 3D battle draws, per stage -- +-- the sprite as loaded (raw) and what picImage hands the billboard after the +-- palette bake and BattlePics' paper fill (final). Diagnostic for pics that +-- render with holes: whichever stage the transparency first appears in is +-- the stage that made it. +-- +-- SHOT_DIR=.scratchpad/pic_dump \ +-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/pic_dump.lua love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or ".scratchpad/pic_dump" + local Pokemon = require("src.pokemon.Pokemon") + local BattleState = require("src.battle.BattleState") + + local function save(img, path) + if not img then U.log("NIL image for " .. path) return end + local w, h = img:getDimensions() + local g = love.graphics + local prev = g.getCanvas() + local canvas = g.newCanvas(w, h, { dpiscale = 1 }) + g.setCanvas(canvas) + g.clear(0, 0, 0, 0) + g.setBlendMode("replace", "premultiplied") + g.setColor(1, 1, 1, 1) + g.draw(img, 0, 0) + g.setCanvas(prev) + g.setBlendMode("alpha") + local f = assert(io.open(path, "wb")) + f:write(canvas:newImageData():encode("png"):getString()) + f:close() + end + + local SPECIES = os.getenv("PIC_SPECIES") + local list = {} + if SPECIES then + for id in SPECIES:gmatch("[^,%s]+") do list[#list + 1] = id:upper() end + else + list = { "PIKACHU", "SEEL", "BULBASAUR", "MEWTWO" } + end + + for _, id in ipairs(list) do + game.save.party = { Pokemon.new(game.data, id, 50) } + U.teleport(game, "ROUTE_1", 5, 8, "down") + U.wait(30) + local battle = BattleState.newWild(game, id, 50) + battle.onFinish = function() end + game.overworld:pushBattle(battle) + U.wait(80) + local lo = id:lower() + save(battle.enemy.sprite, ("%s/%s_front_raw.png"):format(DIR, lo)) + save(battle:picImage(battle.enemy.sprite), ("%s/%s_front_final.png"):format(DIR, lo)) + save(battle.player.sprite, ("%s/%s_back_raw.png"):format(DIR, lo)) + save(battle:picImage(battle.player.sprite), ("%s/%s_back_final.png"):format(DIR, lo)) + U.log("dumped " .. id) + while game.stack:top() and game.stack:top() ~= game.overworld do + game.stack:pop() + end + U.wait(5) + end + + U.log("done -- " .. DIR) + love.event.quit() +end diff --git a/tests/water_reflect_probe.lua b/tests/water_reflect_probe.lua new file mode 100644 index 0000000..f02040f --- /dev/null +++ b/tests/water_reflect_probe.lua @@ -0,0 +1,127 @@ +-- Driver: WHY is the water not reflecting anything? +-- +-- The reflective pass has several links and every one of them fails quietly +-- back to flat water, which looks exactly like the row being off. This walks +-- the chain in the LIVE game and prints where it stops. +-- +-- POKEPORT_DRIVER=mods/DRAMATIC_SHAPE/tests/water_reflect_probe.lua lovec . +-- +-- knobs (env): +-- REFL_MAP map id (default PALLET_TOWN) +-- REFL_SPOT "x,y[,facing]" (default 5,6,down) +-- REFL_LEVEL voxel rung (default 5, the 75-degree camera, +-- which is where a reflection is +-- most of what you can see) +-- REFL_TIME daytime pin (day/dusk/...) (default: leave as-is) +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pipelines = require("src.render.Pipelines") + + local mapId = os.getenv("REFL_MAP") or "PALLET_TOWN" + local level = math.floor(tonumber(os.getenv("REFL_LEVEL")) or 5) + local sx, sy, facing = (os.getenv("REFL_SPOT") or "5,6,down") + :match("^%s*(%d+)%s*,%s*(%d+)%s*,?%s*(%a*)") + facing = (facing ~= "" and facing) or "down" + + local function say(...) print("[water-ssr] " .. string.format(...)) end + + U.teleport(game, mapId, tonumber(sx), tonumber(sy), facing) + U.wait(20) + Pipelines.setLevel("voxel", level) + U.wait(40) -- outlast the camera tween and the build + + local V = game.mods.exports["DRAMATIC_SHAPE"] + V = V and V.lib + if not V then return say("mod exports unreachable -- is it enabled?") end + + local Water = V.require("Water") + local Voxel3D = V.require("Voxel3D") + local ChunkMesher = V.require("ChunkMesher") + local Sky = V.require("Sky") + local DayNight = V.require("DayNight") + + if os.getenv("REFL_TIME") then + DayNight.setting:sync(os.getenv("REFL_TIME")) + U.wait(5) + end + + local ow = game.overworld + local map = ow and ow.map + if not map then return say("no live map") end + + -- 1. is the row on at all? + say("row=%s level=%d", tostring(Water.setting:get()), Water.level()) + if not Water.enabled() then + return say("STOP: WATER is OFF -- press 9, or set the row") + end + + -- 2. is there any water on this map to reflect in? + local terrain, water = ChunkMesher.pair(map, false) + if not terrain then terrain, water = ChunkMesher.pair(map, true) end + say("terrain mesh=%s water mesh=%s", tostring(terrain ~= nil), + tostring(water ~= nil)) + if not terrain then + return say("STOP: no terrain mesh yet -- the build is still cooking") + end + if not water then + say("STOP: this map has no water surface. That is not a fault unless") + say(" you can see a lake: check the tileset's water tiles reach") + say(" TileShape (run voxel_survey.lua for the shape breakdown).") + return + end + + -- 3. did the driver give us a depth texture to read? This is the one + -- hardware requirement the rest of the mode does not already have. + say("depth canvas readable: %s", tostring(Voxel3D.depthReadable())) + if not Voxel3D.depthReadable() then + say("STOP: no readable depth canvas on this driver (depth24/readable).") + say(" The water falls back to the flat scene shader, which is") + say(" exactly what it looked like before this feature existed.") + return + end + + -- 4. did the shader build? Both variants -- the wireframe one needs + -- derivatives, which a driver may refuse on its own. + say("shader plain=%s grid=%s", + tostring(Water.shader(false) ~= nil), tostring(Water.shader(true) ~= nil)) + if not Water.shader(false) then + return say("STOP: the water shader did not compile -- the mod log has " + .. "the driver's own message") + end + + -- 5. is there a sky to reflect, and something hanging in it? + local ramp, count = Sky.ramp() + say("sky ramp=%s bands=%s edge=%s", tostring(ramp ~= nil), tostring(count), + tostring(Voxel3D.skyEdge)) + local body = DayNight.body() + if body then + local amt = DayNight.glow() + local w, h = Voxel3D.size() + local rpx = Sky.discRadius(h, Voxel3D.cell or 1, + { moon = body.moon, glowAmt = amt }) + local ang = rpx / ((h or 1) / math.max(1e-4, Voxel3D.fovY or 1)) + say("body=%s dir=(%.2f, %.2f, %.2f) disc=%.1fpx (%.2f deg)", + body.moon and "moon" or "sun", body.dx, body.dy, body.dz, rpx, + math.deg(ang)) + else + say("body: none in the sky right now (set REFL_TIME=day or =night)") + end + if not ramp then + say("NOTE: no band ramp -- indoors, or the ramp could not be built. The") + say(" sky half of the reflection is off; the ray march still runs.") + end + + -- 6. how much reflection this camera is actually asking for. Both numbers + -- fall out of the rung, and between them they explain every "it only + -- works at 75" report: Fresnel decides how much shows, and the lean + -- decides whether what shows has anything in it. + local f = Water.FRESNEL_FLOOR + (Water.FRESNEL_CEIL - Water.FRESNEL_FLOOR) + * (1 - Voxel3D.descent) ^ Water.FRESNEL_POWER + say("camera: descent %.3f -> fresnel about %.2f, horizon lean %.2f", + Voxel3D.descent, f, Water.lean(Voxel3D.descent)) + say("waves: %d px columns, phase %.2f", Water.WAVE_HEIGHT, Water._waveTime()) + + say("OK: every link is live. The effect is strongest at REFL_LEVEL=5 (the") + say(" 75-degree rung, where Fresnel is highest and the reflection is") + say(" exact) and with a low sun (REFL_TIME=dusk).") +end From 92fef2a37e6b6cc9913199d90a0a8865682af573 Mon Sep 17 00:00:00 2001 From: DramaticShape Date: Fri, 31 Jul 2026 23:29:39 -0400 Subject: [PATCH 003/116] update for modkit update --- manifest.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/manifest.json b/manifest.json index 92602fa..157448b 100644 --- a/manifest.json +++ b/manifest.json @@ -15,5 +15,6 @@ "engine_internals" ], "affects_link": false, - "description": "A full 3D diorama overworld: extruded terrain, depth-buffered occlusion, voxel characters and a tilt-shift miniature pass -- and battles fought on the map itself, shot over the shoulder at the nearest clear ground with a slow parallax drift and a depth-of-field pass. Water reflects the sky, the sun, the moon and -- through a screen-space ray march -- the shoreline standing behind it. Registers two render pipelines and claims hotkeys 3, 5, 6, 7, 8 and 9 -- 3 and 5 displace the engine's TILT and GBC FX keys, both still reachable on the OPTIONS menu. Presentational only: it changes what a battle is drawn over, never where anybody stands." + "description": "A full 3D diorama overworld: extruded terrain, depth-buffered occlusion, voxel characters and a tilt-shift miniature pass -- and battles fought on the map itself, shot over the shoulder at the nearest clear ground with a slow parallax drift and a depth-of-field pass. Water reflects the sky, the sun, the moon and -- through a screen-space ray march -- the shoreline standing behind it. Registers two render pipelines and claims hotkeys 3, 5, 6, 7, 8 and 9 -- 3 and 5 displace the engine's TILT and GBC FX keys, both still reachable on the OPTIONS menu. Presentational only: it changes what a battle is drawn over, never where anybody stands.", + "github": "DramaticShape/DramaticShapeVoxelMod" } From 98f7419b729466092912a4d0e5bb352648daeed5 Mon Sep 17 00:00:00 2001 From: DramaticShape Date: Fri, 31 Jul 2026 23:39:05 -0400 Subject: [PATCH 004/116] fix water for android glsl shaders --- .github/workflows/release.yml | 200 ++++++++++++++++++++++++++++++++++ CHANGELOG.md | 27 +++++ lib/Voxel3D.lua | 23 ++-- lib/Water.lua | 29 ++++- tests/dramatic_shape_test.lua | 19 ++++ tests/water_reflect_probe.lua | 3 +- 6 files changed, 290 insertions(+), 11 deletions(-) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..6b36f84 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,200 @@ +name: Release + +# Packs the mod into an installable .zip and publishes it as a GitHub Release, +# once per push to main. +# +# Archive layout: every mod file at the archive root, manifest.json included. +# That is one of the two shapes the game accepts on MODS > Import mod .zip +# (src/mods/LauncherMods.lua locateRoot: manifest at the root, or inside a +# single top-level folder). Nothing else is added, so the archive stays +# installable by hand too. +# +# Versioning, first rule that applies wins: +# 1. the "version" input of a manual run, +# 2. "[release X.Y.Z]" anywhere in the commit message, +# 3. manifest.json's own version, when it is ahead of every existing tag, +# so bumping the manifest is the normal way to cut a release, +# 4. otherwise the newest vX.Y.Z tag with its patch incremented +# (0.2.99 rolls over to 0.3.0). +# Whichever wins is written into the manifest.json inside the archive, so a +# shipped mod never reports a different version than the release it came from. +# +# Generated by: python3 tools/modkit.py add-release-workflow +# MOD_ID below is stamped to this mod's id when the file is copied. + +on: + push: + branches: [master] + paths-ignore: + - '.github/**' + - '**.md' + workflow_dispatch: + inputs: + version: + description: "Exact version to release (e.g. 0.3.0). Leave blank to auto-resolve." + required: false + default: "" + +permissions: + contents: write + +concurrency: + group: release + cancel-in-progress: false + +jobs: + release: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Determine version + id: ver + env: + DISPATCH_VERSION: ${{ github.event.inputs.version }} + run: | + set -euo pipefail + python3 - <<'PY' >> "$GITHUB_OUTPUT" + import json, os, re, subprocess, sys + + SEMVER = re.compile(r"^(\d+)\.(\d+)\.(\d+)$") + + def sh(*args): + return subprocess.run(args, capture_output=True, text=True).stdout.strip() + + def parse(text): + m = SEMVER.match(text) + return tuple(int(p) for p in m.groups()) if m else None + + def die(msg): + print(f"::error::{msg}", file=sys.stderr) + raise SystemExit(1) + + with open("manifest.json", encoding="utf-8") as fh: + manifest_version = str(json.load(fh).get("version", "")) + + released = sorted( + v for v in (parse(tag[1:]) for tag in sh("git", "tag", "-l", "v*").splitlines()) if v + ) + latest = released[-1] if released else None + + override = os.environ.get("DISPATCH_VERSION", "").strip() + if not override: + found = re.search(r"\[release\s+(\d+\.\d+\.\d+)\]", sh("git", "log", "-1", "--pretty=%B")) + override = found.group(1) if found else "" + + manifest_ver = parse(manifest_version) + if override: + version = parse(override) or die(f"invalid version override {override!r} (expected X.Y.Z)") + source = "the override" + elif manifest_ver and (latest is None or manifest_ver > latest): + version = manifest_ver + source = "manifest.json" + elif latest: + major, minor, patch = latest + patch += 1 + if patch > 99: + minor, patch = minor + 1, 0 + version = (major, minor, patch) + source = "a patch bump on v%d.%d.%d" % latest + else: + die(f"manifest.json version {manifest_version!r} is not X.Y.Z " + "and there is no vX.Y.Z tag to count from") + + text = "%d.%d.%d" % version + print(f"Releasing {text}, from {source}.", file=sys.stderr) + print(f"version={text}") + print(f"tag=v{text}") + PY + + - name: Refuse to clobber an existing release + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.ver.outputs.tag }} + run: | + set -euo pipefail + if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then + echo "::error::Tag $TAG already exists. Pick a different version." + exit 1 + fi + if gh release view "$TAG" >/dev/null 2>&1; then + echo "::error::Release $TAG already exists. Pick a different version." + exit 1 + fi + + - name: Build the mod .zip + env: + VERSION: ${{ steps.ver.outputs.version }} + MOD_ID: "DRAMATIC_SHAPE" + run: | + set -euo pipefail + staging="$RUNNER_TEMP/pkg" + out="$GITHUB_WORKSPACE/dist" + rm -rf "$staging" "$out" + mkdir -p "$staging" "$out" + + git archive HEAD | tar -x -C "$staging" + + rm -rf "$staging/.github" "$staging/.gitattributes" \ + "$staging/.gitignore" "$staging/.luarc.json" + + python3 - "$staging/manifest.json" "$VERSION" <<'PY' + import json, sys + path, version = sys.argv[1], sys.argv[2] + with open(path, encoding="utf-8") as fh: + manifest = json.load(fh) + manifest["version"] = version + with open(path, "w", encoding="utf-8") as fh: + json.dump(manifest, fh, indent=2, ensure_ascii=False) + fh.write("\n") + PY + + zip_path="$out/${MOD_ID}-${VERSION}.zip" + (cd "$staging" && zip -qr "$zip_path" .) + unzip -l "$zip_path" + + unzip -p "$zip_path" manifest.json > "$RUNNER_TEMP/packed-manifest.json" + python3 - "$RUNNER_TEMP/packed-manifest.json" "$VERSION" <<'PY' + import json, sys + path, expected = sys.argv[1], sys.argv[2] + with open(path, encoding="utf-8") as fh: + version = json.load(fh)["version"] + if version != expected: + raise SystemExit(f"::error::packed manifest says {version}, expected {expected}") + print(f"manifest.json is at the archive root and reports {version}") + PY + + (cd "$out" && sha256sum "${MOD_ID}"-*.zip > sha256sums.txt) + cat "$out/sha256sums.txt" + + - name: Publish GitHub Release + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ steps.ver.outputs.version }} + TAG: ${{ steps.ver.outputs.tag }} + MOD_ID: "DRAMATIC_SHAPE" + run: | + set -euo pipefail + + prev="$(git tag -l 'v*' --sort=-v:refname | grep -v "^${TAG}$" | head -1 || true)" + range="${prev:+${prev}..}$GITHUB_SHA" + changes="$(git log --no-merges --pretty='- %s' "$range" | head -50 || true)" + + notes=$'Download the .zip and install it from the game: MODS > Import mod .zip.' + if [ -n "$changes" ]; then + notes+=$'\n\n## Changes\n\n'"$changes" + fi + printf 'Release notes:\n%s\n' "$notes" + + gh release create "$TAG" \ + --target "$GITHUB_SHA" \ + --title "$VERSION" \ + --notes "$notes" \ + "dist/${MOD_ID}-${VERSION}.zip" \ + "dist/sha256sums.txt" + + echo "Published release $TAG" diff --git a/CHANGELOG.md b/CHANGELOG.md index d03334c..3511684 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -147,6 +147,33 @@ painted. The reflection lands a hair off the sprite's own depth and exactly on its colour, which at a lake's worth of wave is the same picture. +### Fixed + +- **On Android the water stayed flat, as if the row were off.** Two GLSL ES + defaults the desktop never exercises, both in the water shader: + + Fragment floats default to **mediump** on GLSL ES, and the vertex stage's + default is highp -- and the water shader is the mod's first to declare the + same uniform, the frame's `vp` matrix, in BOTH stages, one on each default. + GLSL ES refuses to link a uniform whose precision the stages disagree on, + so the whole shader failed to build and the pass fell back -- quietly, by + design -- to the flat water the mode always drew. The pixel stage now lifts + its float default to highp (guarded, so a GPU without fragment highp still + compiles and falls back flat), which settles the link and is also simply + needed: the march works in world coordinates that run to a few thousand, + where fp16 has no fraction left, and the world-position varying is + qualified highp for the same reason the wireframe's has been all along. + Samplers default to **lowp** no matter what floats are set to, so the depth + read is lifted too -- eight bits of depth is a march with nothing to land + on. + + And the readable depth canvas -- the one hardware requirement the rest of + the mode does not already have -- now tries four formats before giving up: + depth24, depth24 riding a stencil (a pairing some mobile drivers will + texture when they refuse the bare format), depth32f, and depth16 as the + floor every GLES3 device can read. Refused all four, the reflections are + lost and nothing else, exactly as before. + ### Known - Screen-space reflections can only reflect what is in the frame. A tree just diff --git a/lib/Voxel3D.lua b/lib/Voxel3D.lua index df2f311..471a688 100644 --- a/lib/Voxel3D.lua +++ b/lib/Voxel3D.lua @@ -295,15 +295,24 @@ local active = false -- never be sampled. An explicit canvas is the same buffer with a texture -- handle on it, and costs the same memory. -- --- nil where the driver will not make one -- the format is optional in GLES --- and a canvas is the only honest test of it -- and beginScene falls --- straight back to the internal buffer, which is exactly the old behaviour --- minus the reflections. +-- nil where the driver will not make one -- every depth format is optional +-- in GLES and a canvas is the only honest test of any of them, so this asks +-- for several in order of preference: 24 bits, the same 24 riding a stencil +-- (a pairing some mobile drivers will texture when the bare format they +-- refuse), 32-bit float, and 16 as the floor every GLES3 device can read. +-- Refused all four, beginScene falls straight back to the internal buffer, +-- which is exactly the old behaviour minus the reflections. +local DEPTH_FORMATS = { "depth24", "depth24stencil8", "depth32f", "depth16" } + local function newDepth(w, h) if not (love.graphics and love.graphics.newCanvas) then return nil end - local ok, c = pcall(love.graphics.newCanvas, w, h, - { format = "depth24", readable = true }) - if not (ok and c) then return nil end + local c = nil + for _, format in ipairs(DEPTH_FORMATS) do + local ok, made = pcall(love.graphics.newCanvas, w, h, + { format = format, readable = true }) + if ok and made then c = made break end + end + if not c then return nil end -- nearest: a depth is a distance, and a blend of two of them is a -- distance to nothing. The march wants the texel it landed on. pcall(c.setFilter, c, "nearest", "nearest") diff --git a/lib/Water.lua b/lib/Water.lua index 96f5368..77a32e6 100644 --- a/lib/Water.lua +++ b/lib/Water.lua @@ -345,7 +345,12 @@ Water.EDGE_FADE = 0.14 -- reflection eased off over this much of the fra local SHADER_SRC = [[ varying float vShade; varying vec3 vSun; -varying vec3 vBent; // world position, as drawn +// World position, as drawn -- and a varying that cannot ride GLSL ES's +// mediump fragment default: everything below floors it into columns and +// marches it through the frame's matrices, and a route's coordinates run +// to a few thousand, where fp16 has no fraction left at all. The same +// reasoning the scene shader's vGrid states at length. +varying LOVE_HIGHP_OR_MEDIUMP vec3 vBent; #ifdef VERTEX uniform mat4 vp; @@ -368,6 +373,21 @@ vec4 position(mat4 transform_projection, vec4 vertex_position) { #endif #ifdef PIXEL +// Everything below works in WORLD units through the frame's own matrices, +// and GLSL ES defaults fragment floats to mediump -- fp16, out of fraction +// by a coordinate of two thousand and quantising a depth into steps the +// march falls straight through. Worse than wrong pictures: `vp` is +// declared by BOTH stages, the vertex side's default is highp, and GLSL ES +// refuses to LINK a uniform whose precision the two stages disagree on -- +// which is not broken water but NO water shader at all, the flat fallback +// with nothing in the log. One statement lifts the whole stage; the guard +// keeps the odd GPU without fragment highp compiling, and such a driver +// falls back to flat water exactly as it did before this pass existed. +#ifdef GL_ES +#ifdef GL_FRAGMENT_PRECISION_HIGH +precision highp float; +#endif +#endif uniform mat4 vp; uniform vec3 eye; uniform vec2 screen; // the canvas, in pixels @@ -381,9 +401,12 @@ uniform float sunBias; uniform vec2 sunTexel; uniform vec3 dayTint; -// the frame as it stood before the water went down, and its depth +// the frame as it stood before the water went down, and its depth. The +// depth sampler is qualified because GLSL ES defaults samplers to LOWP no +// matter what floats are set to, and eight bits of depth is a march with +// nothing to land on. The frame copy is honest 8-bit colour and can stay. uniform Image reflectTex; -uniform Image depthTex; +uniform LOVE_HIGHP_OR_MEDIUMP Image depthTex; uniform float rays; // 0 = sky only, 1 = march the screen too uniform vec3 lookFlat; // the way the horizon lies from this camera diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua index c9f0d0e..2f8bd76 100644 --- a/tests/dramatic_shape_test.lua +++ b/tests/dramatic_shape_test.lua @@ -1778,6 +1778,25 @@ T.check(gridded:find("#define VOXEL_GRID", 1, true) ~= nil, T.check(plain:find("//@CRATERS", 1, true) == nil, "and the crater placeholder is gone by the time a driver sees the source") +-- ANDROID. GLSL ES defaults fragment floats to mediump and samplers to +-- lowp, and this shader is the one place in the mod where both defaults +-- are fatal: world coordinates run past fp16's fraction, the depth read +-- rounds to steps the march falls straight through, and -- the sharp edge +-- -- `vp` is declared by BOTH stages, whose defaults disagree, which GLSL +-- ES answers by refusing to LINK the shader at all. Flat lakes, empty log. +-- The sky's band ramp is this same lesson learned once already. +T.check(plain:find("precision highp float;", 1, true) ~= nil, + "the pixel stage lifts GLSL ES's mediump default to highp, so the march " + .. "keeps its fraction and the dual-declared vp links at one precision") +T.check(plain:find("GL_FRAGMENT_PRECISION_HIGH", 1, true) ~= nil, + "guarded, so the odd GPU without fragment highp still compiles and " + .. "falls back flat instead of failing loudly") +T.check(plain:find("LOVE_HIGHP_OR_MEDIUMP vec3 vBent", 1, true) ~= nil, + "the world-position varying is qualified like the scene shader's vGrid " + .. "rather than left to the fragment default") +T.check(plain:find("LOVE_HIGHP_OR_MEDIUMP Image depthTex", 1, true) ~= nil, + "and the depth sampler is lifted off lowp, which is eight bits of depth") + -- ------- the lift itself -- -- A pond in a field: four water cells recessed below flat ground. The diff --git a/tests/water_reflect_probe.lua b/tests/water_reflect_probe.lua index f02040f..4dcf36a 100644 --- a/tests/water_reflect_probe.lua +++ b/tests/water_reflect_probe.lua @@ -74,7 +74,8 @@ return function(game) -- hardware requirement the rest of the mode does not already have. say("depth canvas readable: %s", tostring(Voxel3D.depthReadable())) if not Voxel3D.depthReadable() then - say("STOP: no readable depth canvas on this driver (depth24/readable).") + say("STOP: no readable depth canvas on this driver (tried depth24, ") + say(" depth24stencil8, depth32f and depth16).") say(" The water falls back to the flat scene shader, which is") say(" exactly what it looked like before this feature existed.") return From 22b58e27a4d3f7cc24a67cfa4ee2a6739412cfd3 Mon Sep 17 00:00:00 2001 From: DramaticShape Date: Sat, 1 Aug 2026 00:13:01 -0400 Subject: [PATCH 005/116] fix android water shading --- CHANGELOG.md | 64 ++++++++++++++++++++++------------- lib/Water.lua | 23 +++++++++++-- tests/dramatic_shape_test.lua | 11 ++++++ 3 files changed, 72 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3511684..952ec1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -149,30 +149,46 @@ ### Fixed -- **On Android the water stayed flat, as if the row were off.** Two GLSL ES - defaults the desktop never exercises, both in the water shader: - - Fragment floats default to **mediump** on GLSL ES, and the vertex stage's - default is highp -- and the water shader is the mod's first to declare the - same uniform, the frame's `vp` matrix, in BOTH stages, one on each default. - GLSL ES refuses to link a uniform whose precision the stages disagree on, - so the whole shader failed to build and the pass fell back -- quietly, by - design -- to the flat water the mode always drew. The pixel stage now lifts - its float default to highp (guarded, so a GPU without fragment highp still - compiles and falls back flat), which settles the link and is also simply - needed: the march works in world coordinates that run to a few thousand, - where fp16 has no fraction left, and the world-position varying is - qualified highp for the same reason the wireframe's has been all along. - Samplers default to **lowp** no matter what floats are set to, so the depth - read is lifted too -- eight bits of depth is a march with nothing to land - on. - - And the readable depth canvas -- the one hardware requirement the rest of - the mode does not already have -- now tries four formats before giving up: - depth24, depth24 riding a stencil (a pairing some mobile drivers will - texture when they refuse the bare format), depth32f, and depth16 as the - floor every GLES3 device can read. Refused all four, the reflections are - lost and nothing else, exactly as before. +- **On Android the water stayed flat, as if the row were off -- and once it + did draw, it came up in blocks with the haze showing through the holes.** + Three separate faults, every one of them invisible on desktop GL, run down + on a Galaxy Z Fold 7 with the driver's own compiler errors in logcat: + + **The shader would not build.** Fragment floats default to **mediump** on + GLSL ES while the vertex stage's default is highp, and the water shader is + the mod's first to declare the same uniform -- the frame's `vp` matrix -- + in BOTH stages, one on each default; GLSL ES refuses to link that, and the + pass fell back, quietly and by design, to the flat water the mode always + drew. The pixel stage now lifts its float default to highp (guarded, so a + GPU without fragment highp still compiles and falls back flat), which + settles the link and is also simply needed: the march works in world + coordinates that run to a few thousand, where fp16 has no fraction left. + The world-position varying is qualified highp for the same reason the + wireframe's always was, and the depth sampler too -- samplers default to + **lowp** whatever the floats are set to, and eight bits of depth is a + march with nothing to land on. One wrinkle inside the fix: LOVE's header + forward-declares `effect()` under ITS default, and Samsung's Xclipse + compiler treats a definition whose parameter precisions have drifted from + the prototype's as an illegal overload -- so effect()'s own float + parameters stay pinned to mediump, matching the declaration, and the + maths above them runs highp regardless. + + **The depth test read the wrong texels.** The shader's own depth test + normalised LOVE's pixel coordinate by the `screen` uniform, which counts + canvas UNITS -- and on a highdpi phone (Android's density here is 2.625) + a canvas holds that many PIXELS per unit, so the lookup ran to 2.6, + clamped, and read edge texels across two thirds of the frame. Water + discarded itself in blocks wherever the mis-read depth landed in front, + and the haze backdrop showed through the holes. The coordinate is now + normalised by `love_ScreenSize.xy` -- the bound canvas's own pixel size, + measured in the same units on every display. + + **And the readable depth canvas** -- the one hardware requirement the + rest of the mode does not already have -- now tries four formats before + giving up: depth24, depth24 riding a stencil (a pairing some mobile + drivers will texture when they refuse the bare format), depth32f, and + depth16 as the floor every GLES3 device can read. Refused all four, the + reflections are lost and nothing else, exactly as before. ### Known diff --git a/lib/Water.lua b/lib/Water.lua index 77a32e6..b7ce4f8 100644 --- a/lib/Water.lua +++ b/lib/Water.lua @@ -849,12 +849,31 @@ vec2 waveUV(vec2 tc, vec2 col) { return org + (mod(col, 8.0) + 0.5) * texel; } -vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) { +// The float parameters are pinned to mediump BECAUSE the stage default is +// not: LOVE's own header forward-declares effect() under its default, and +// at least one mobile compiler (Samsung's Xclipse, in so many words) holds +// that a definition whose parameter precisions differ from its prototype's +// is a second function of the same name, and refuses the pair. The params +// can afford it -- the colour is a colour, and tc/sc arrived through +// LOVE's mediump plumbing whatever this signature says -- and the maths +// below runs on the stage default the moment the values touch a local. +vec4 effect(mediump vec4 color, Image tex, mediump vec2 tc, mediump vec2 sc) { // THE DEPTH TEST, done here because the buffer that would have done it is // detached for the length of this pass so it can be READ (see the header). // Same comparison, same buffer, same result: a building in front of a pond // still hides it. - vec2 uv = sc / screen; + // + // Normalised by LOVE's own screen size, not by the `screen` uniform: `sc` + // arrives in canvas PIXELS, and on a highdpi surface (Android's density + // is routinely 2.625) a canvas holds that many pixels per canvas UNIT, + // which is what `screen` counts. Divided by units, uv runs to 2.6 and + // clamps, and the test reads edge texels for two thirds of the frame -- + // discarding water in blocks and letting the haze backdrop through, which + // on a phone looked like lakes with pieces missing. love_ScreenSize.xy is + // the bound canvas's own pixel size, the same units sc is measured in, on + // every display. (`screen` stays in units: skyPos reads it against cell + // and skyEdge, which are unit-measured with it.) + vec2 uv = sc / love_ScreenSize.xy; if (gl_FragCoord.z > Texel(depthTex, uv).r) discard; // THE COLUMN THIS FRAGMENT IS LOOKING AT. Every water pixel is a bar of diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua index 2f8bd76..27930f3 100644 --- a/tests/dramatic_shape_test.lua +++ b/tests/dramatic_shape_test.lua @@ -1796,6 +1796,17 @@ T.check(plain:find("LOVE_HIGHP_OR_MEDIUMP vec3 vBent", 1, true) ~= nil, .. "rather than left to the fragment default") T.check(plain:find("LOVE_HIGHP_OR_MEDIUMP Image depthTex", 1, true) ~= nil, "and the depth sampler is lifted off lowp, which is eight bits of depth") +T.check(plain:find( + "effect(mediump vec4 color, Image tex, mediump vec2 tc, mediump vec2 sc)", + 1, true) ~= nil, + "effect()'s own floats stay pinned to LOVE's prototype precision -- the " + .. "Xclipse compiler reads a definition that drifted from the forward " + .. "declaration as an illegal overload and refuses the whole shader") +T.check(plain:find("sc / love_ScreenSize.xy", 1, true) ~= nil, + "the depth test normalises the pixel coord by the canvas's own pixel " + .. "size -- `screen` counts canvas UNITS, and on a highdpi phone the two " + .. "differ by the density, which clamped the lookup and cut the water " + .. "into blocks") -- ------- the lift itself -- From 980383bb92c42fa7410829344e7bebcb20f337fc Mon Sep 17 00:00:00 2001 From: DramaticShape Date: Sat, 1 Aug 2026 00:28:54 -0400 Subject: [PATCH 006/116] update battle water --- CHANGELOG.md | 26 ++++++++++++ lib/BattleScene.lua | 33 ++++++--------- lib/VoxelScene.lua | 5 ++- lib/Water.lua | 80 ++++++++++++++++++++++++++++++----- tests/dramatic_shape_test.lua | 23 +++++++++- 5 files changed, 133 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 952ec1a..172bfd4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -147,6 +147,32 @@ painted. The reflection lands a hair off the sprite's own depth and exactly on its colour, which at a lake's worth of wave is the same picture. +### Changed + +- **The waves arrive in sets now, and a little slower.** Three fixed trains + are an exactly periodic field -- every forty-odd pixels of sea wore the + same crest at the same height, which reads as wallpaper the moment a lake + is bigger than the repeat. Two long-wavelength fields now ride the + dominant train, four to five carrier wavelengths apiece so neither reads + as a wave itself: a SWELL that breathes its amplitude, so a few tall + crests march through and hand over to a lull that is itself moving, and a + BEND that bows its phase, so a crest line curves across the surface + instead of ruling itself over all of it. The two lesser trains stay + plain: they are texture rather than structure, and a third modulator is + the soup the train weights exist to avoid. The step beat comes down from + 15 to 12 a second -- the crests were hurrying, and a big wave is slower + than a walk cycle -- still a clean divisor of the engine's 60, and still + exactly one world pixel of dominant-crest travel per step. + +- **Staged battles draw their water plain, whatever the WATER row says.** + The reflective pass is tuned for the overworld's ladder of cameras; a + battle's camera is PLACED -- low, tilted, framed like a picture -- and + under it the pass read wrong: Fresnel opened all the way up, the leaned + sky landed on bands the framing never shows, and a lake-sized arena came + out as murk wearing the tile art. The battle is a stage set, and stage + water is painted: the flat animated tiles the mode always drew, with the + mons compositing over them like everything else on the set. + ### Fixed - **On Android the water stayed flat, as if the row were off -- and once it diff --git a/lib/BattleScene.lua b/lib/BattleScene.lua index 2fc3cce..170caac 100644 --- a/lib/BattleScene.lua +++ b/lib/BattleScene.lua @@ -435,31 +435,22 @@ function BattleScene.render(state, arena, textures, token) Voxel3D.draw(nbMesh[i], atlasFor(nb.map), Mat4.translate(nb.ox, 0, nb.oy)) end - -- and the water over it, reflecting the arena and the hour's sky (see - -- VoxelScene.drawWater -- the arena fights on the same lakes). The two - -- mons are this shot's cast: painted into the reflection copy alone, so - -- a fight staged at the water's edge has both of them in the water while - -- they still composite over it, exactly as the overworld's walkers do. - local waterDraws = {} - if water then waterDraws[#waterDraws + 1] = { water, atlasFor(host) } end + -- and the water over it -- PLAIN, always: the flat animated tiles, never + -- the reflective pass, whatever the WATER row says. The reflection is + -- tuned for the overworld's ladder of cameras; this shot's is PLACED -- + -- low, tilted and framed like a picture -- and under it the pass reads + -- wrong: Fresnel opens all the way up, the leaned sky lands on bands the + -- framing never shows, and a lake-sized arena comes out as murk wearing + -- the tile art. The battle is a stage set, and stage water is painted. + -- (No mirror also means the mons need no second draw into one -- they + -- just composite over the water below, like everything else on the set.) + if water then Voxel3D.draw(water, atlasFor(host)) end for i, nb in ipairs(neighbors) do if nbWater and nbWater[i] then - waterDraws[#waterDraws + 1] = { nbWater[i], atlasFor(nb.map), - Mat4.translate(nb.ox, 0, nb.oy) } + Voxel3D.draw(nbWater[i], atlasFor(nb.map), + Mat4.translate(nb.ox, 0, nb.oy)) end end - if #waterDraws > 0 then - VoxelScene.drawWater(waterDraws, function() - Voxel3D.seams(false) - Voxel3D.glass(false) - for _, card in ipairs(monCards(arena, groundY, textures)) do - Voxel3D.draw(BattleBillboard.mesh(), card.tex, card.model, - BattleBillboard.PULL, ShadowMap.snug(card.model)) - end - Voxel3D.glass(true) - Voxel3D.seams(true) - end) - end -- The mons, standing on their tiles. Depth-tested like everything else, -- so a ledge or a tree between the camera and a Pokemon really is in -- front of it, and the alpha discard cuts the sprite's own outline out of diff --git a/lib/VoxelScene.lua b/lib/VoxelScene.lua index b9244fb..daa4131 100644 --- a/lib/VoxelScene.lua +++ b/lib/VoxelScene.lua @@ -580,8 +580,9 @@ end -- with the row OFF, no depth texture to read, or a shader that would not -- build, the same meshes go through the ordinary scene shader and come out -- as the flat animated water this mode always drew. --- Shared with the overworld battle, which stages its fights on the same --- lakes and wants the same water under them. +-- The overworld's alone: the staged battle draws its water plain, always -- +-- its placed camera reads this pass wrong, and a stage set wants painted +-- water anyway (see BattleScene, where the choice is argued). function VoxelScene.drawWater(draws, cast) local plain = true if Water.enabled() and Voxel3D.depthReadable() then diff --git a/lib/Water.lua b/lib/Water.lua index b7ce4f8..9b7956c 100644 --- a/lib/Water.lua +++ b/lib/Water.lua @@ -237,14 +237,56 @@ Water.WAVE_TRAINS = { { -0.041, 0.033, 0.55, 0.11 }, } +-- ------- and what keeps them from reading as one pattern +-- +-- Three fixed trains are still an exactly periodic field: every forty-odd +-- pixels of sea wears the same crest at the same height, and a lake's worth +-- of that reads as wallpaper. Real swell varies two ways a sum of sines +-- cannot: waves arrive in SETS -- a few tall ones, then a lull -- and a +-- crest line curves as it runs rather than ruling itself across the whole +-- surface. Both are put back with one long-wavelength field each, riding +-- the DOMINANT train only; the two lesser trains stay plain, because they +-- are texture rather than structure and three modulators is soup again. +-- +-- Both wear the trains' own shape, { fx, fz, speed, x }: a direction whose +-- length is the spatial frequency, a phase rate, and what the field does. +-- Their wavelengths sit four to five times the carrier's, far enough apart +-- that neither reads as a wave itself -- the swell as slow weather over the +-- crests, the bend as the crests' own drift. +-- +-- THE SWELL scales the dominant train's amplitude; `x` is the DEPTH of the +-- deepest lull, as the fraction of the train it takes away. It runs roughly +-- along the carrier's own direction and slower than it, which is a wave +-- group's honest habit (deep-water groups travel at about half the phase +-- speed) -- so sets of crests swell up, march a while, and hand over to a +-- calm patch that is itself moving. +Water.WAVE_SWELL = { 0.0325, 0.0134, 0.55, 0.35 } + +-- THE BEND adds a slow wobble to the dominant train's phase; `x` is the +-- wobble's reach in RADIANS of carrier phase. 1.1 radians against a carrier +-- of about forty pixels bows a crest some seven pixels off its line over +-- the bend's own hundred-and-seventy-five -- a visible curve, not a +-- scribble -- and it runs ACROSS the carrier, which is the direction a +-- crest line actually wanders. What it costs is exactness in waveRate's +-- derivation: the carrier's local frequency now breathes around the number +-- the rate is derived from, so the one-pixel step is the average step +-- rather than every step's. The step CLOCK is untouched; only how far a +-- bowed stretch of crest moves on one tick varies, and by under a pixel. +Water.WAVE_BEND = { -0.0138, 0.0333, 0.35, 1.10 } + -- ------- and the beat they move on -- --- The surface does not slide, it advances in STEPS -- 15 a second, off the --- engine's own frame counter, which is the cadence hand-drawn pixel art is --- animated at and the reason this reads as art rather than as a simulation --- someone forgot to stylise. A surface built out of whole pixels that crawls --- between them smoothly gives away that the quantisation is only skin deep. -Water.WAVE_FPS = 15 +-- The surface does not slide, it advances in STEPS, off the engine's own +-- frame counter -- the move that makes this read as art rather than as a +-- simulation someone forgot to stylise. A surface built out of whole pixels +-- that crawls between them smoothly gives away that the quantisation is +-- only skin deep. +-- +-- 12 a second, a shade under the 15 hand-drawn pixel art is usually +-- animated at: the crests were hurrying, and a big wave is slower than a +-- sprite's walk cycle. Still a clean divisor of the engine's 60, so every +-- step spans the same whole number of frames. +Water.WAVE_FPS = 12 -- How far the dominant train advances each of those steps, in WORLD PIXELS. -- One is the honest choice for a stepped surface: the whole field shifts by @@ -984,12 +1026,30 @@ Water._craterSource = craterSource -- named for the suite -- and the wave trains, pasted in for the same reason: the rate is derived -- from this table (Water.waveRate), so the field the shader sums has to be --- the one that table describes rather than a copy of it kept in step by hand +-- the one that table describes rather than a copy of it kept in step by hand. +-- +-- The dominant train carries the swell and the bend (see WAVE_SWELL): its +-- phase wobbles by the bend field and its amplitude breathes with the swell +-- envelope, both off the same tables the constants above document. One +-- statement per train either way, which is what the suite counts. local function trainSource() local out = {} - for _, t in ipairs(Water.WAVE_TRAINS) do - out[#out + 1] = (" h += sin(dot(q, vec2(%.4f, %.4f)) + waveT * %.4f)" - .. " * %.4f;"):format(t[1], t[2], t[3], t[4]) + for i, t in ipairs(Water.WAVE_TRAINS) do + if i == 1 then + local s = Water.WAVE_SWELL + local b = Water.WAVE_BEND + out[#out + 1] = ( + " h += sin(dot(q, vec2(%.4f, %.4f)) + waveT * %.4f\n" + .. " + %.4f * sin(dot(q, vec2(%.4f, %.4f)) + waveT * %.4f))\n" + .. " * %.4f * (1.0 - %.4f * (0.5 + 0.5 *\n" + .. " sin(dot(q, vec2(%.4f, %.4f)) + waveT * %.4f)));") + :format(t[1], t[2], t[3], + b[4], b[1], b[2], b[3], + t[4], s[4], s[1], s[2], s[3]) + else + out[#out + 1] = (" h += sin(dot(q, vec2(%.4f, %.4f)) + waveT * %.4f)" + .. " * %.4f;"):format(t[1], t[2], t[3], t[4]) + end end return table.concat(out, "\n") end diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua index 27930f3..6bdfb73 100644 --- a/tests/dramatic_shape_test.lua +++ b/tests/dramatic_shape_test.lua @@ -1542,7 +1542,10 @@ local function at(f) end local period = 60 / Water.WAVE_FPS -T.eq(period, 4, "15 steps a second is one every four engine frames") +T.eq(period, 5, "12 steps a second is one every five engine frames") +T.eq(math.floor(period), period, + "and the beat divides the engine's 60 exactly, so every step spans the " + .. "same whole number of frames") -- inside one step nothing moves; crossing one, it does T.eq(at(0), at(period - 1), "every frame inside one wave step gets the same phase -- the surface " @@ -1575,6 +1578,24 @@ T.eq(select(2, trains:gsub("h %+= sin", "")), #Water.WAVE_TRAINS, T.check(trains:find(("%.4f"):format(t[1]), 1, true) ~= nil, "at the frequency the table states") +-- the variation that keeps three periodic trains from reading as wallpaper: +-- the dominant train's amplitude breathes with the swell and its crests bow +-- with the bend, both pasted from their own tables like the trains are +T.check(trains:find(("%.4f"):format(Water.WAVE_SWELL[1]), 1, true) ~= nil + and trains:find(("%.4f"):format(Water.WAVE_BEND[1]), 1, true) ~= nil, + "the swell and the bend reach the shader off the tables that document " + .. "them, not off copies kept in step by hand") +T.check(Water.WAVE_SWELL[4] > 0 and Water.WAVE_SWELL[4] < 1, + "the swell's deepest lull thins the dominant train without deleting or " + .. "inverting it -- a sea with sets in it, not a sea that turns off") +for _, mod in ipairs({ Water.WAVE_SWELL, Water.WAVE_BEND }) do + local mf = math.sqrt(mod[1] * mod[1] + mod[2] * mod[2]) + T.check(mf * 3.5 < freq, + "a modulator's wavelength sits several times the carrier's, far enough " + .. "apart that it reads as weather over the waves rather than as a " + .. "fourth wave -- which would be the soup the weights exist to avoid") +end + T.check(Water.WAVE_HEIGHT > -TileShapeHeights.water, "the crests stand taller than the recess TileShape sinks water into -- " .. "they are RELIEF inside the quad's own footprint, so a bar that reaches " From 462b0fc8901e3d8f8e0cf79049e3eb059eafa5dd Mon Sep 17 00:00:00 2001 From: absol89 Date: Sat, 1 Aug 2026 20:54:49 +0200 Subject: [PATCH 007/116] Add bring-your-own battle art pipeline --- .gitignore | 11 ++ CHANGELOG.md | 14 ++ README.md | 28 +++- assets/battle/back-animated/README.md | 5 + assets/battle/back-static/README.md | 8 + assets/battle/front-animated/README.md | 4 + assets/battle/front-static/README.md | 8 + lib/BattleArt.lua | 197 +++++++++++++++++++++++++ lib/BattleScene.lua | 9 +- lib/OverworldBattle.lua | 81 ++++++---- main.lua | 32 ++-- mod.card | 4 +- tools/package_mod.ps1 | 53 +++++++ 13 files changed, 400 insertions(+), 54 deletions(-) create mode 100644 assets/battle/back-animated/README.md create mode 100644 assets/battle/back-static/README.md create mode 100644 assets/battle/front-animated/README.md create mode 100644 assets/battle/front-static/README.md create mode 100644 lib/BattleArt.lua create mode 100644 tools/package_mod.ps1 diff --git a/.gitignore b/.gitignore index c10636b..102394d 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,14 @@ __pycache__/ # agent worktrees and local scratch .claude/ + +# Bring-your-own battle art. Keep the contracts/READMEs tracked, but never +# commit locally supplied Pokemon artwork. +assets/battle/front-static/* +!assets/battle/front-static/README.md +assets/battle/back-static/* +!assets/battle/back-static/README.md +assets/battle/front-animated/* +!assets/battle/front-animated/README.md +assets/battle/back-animated/* +!assets/battle/back-animated/README.md diff --git a/CHANGELOG.md b/CHANGELOG.md index eb6d286..fa7aca9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## Unreleased — Battle Art + +### Added + +- **Zero-configuration bring-your-own battle PNGs.** `BATTLE ART: STATIC` + looks in `front-static` and `back-static`, uses native image dimensions, + preserves alpha or keys a border-connected corner matte, and falls back per + species to ROM art. No Pokémon art is distributed or tracked. +- **World-placed player front/back choice.** `PLAYER VIEW` replaces the old + UI-pinned back-sprite toggle. Both views use the depth buffer, world and + day/night tint, display filtering, hit flash and alpha-shaped shadow. +- **Private test packaging.** The four battle-art folders ignore local art in + Git while `tools/package_mod.ps1` deliberately includes those PNGs in a ZIP. + ## 1.3.0 ### Added diff --git a/README.md b/README.md index 6886926..4772214 100644 --- a/README.md +++ b/README.md @@ -34,12 +34,36 @@ menu. | `6`, or the **T-SHIFT** options row | OFF → 1 → 2 → 3 → OFF (miniature blur) | | `7`, or the **V-CURVE** options row | OFF → 1 → 2 → 3 — bend the world over the horizon | | `8`, or the **3D-BTL** options row | ON / OFF — fight on the map instead of on a white field | -| the **BACK SPRITES** options row | OFF / ON — keep your own Pokémon on the battle menu, seen from behind in its classic slot, instead of standing it on the map; the foe is still out there. Only on the menu while **3D-BTL** is on, because it decides nothing without it | +| the **BATTLE ART** options row | STATIC / ANIMATED / ROM — use optional battle-only art, with a direct ROM fallback when a file or atlas is absent | +| the **PLAYER VIEW** options row | FRONT / BACK — choose the player's view while keeping it world-placed, lit, shadowed and depth-occluded | | the **DAYTIME** options row | SYNC / DAY / NIGHT / DUSK / DAWN / CYCLE — what time it is outdoors, on the diorama *and* on the flat 2D world; held at SYNC (and off the menu) while VOXEL is FULL | **3D-BTL** is on by default and is independent of **VOXEL**: battles draw on the world whether or not the free-roam camera is pitched over. +## Bring your own battle art + +`BATTLE ART: STATIC` is the default. Drop a PNG named for the species into +`assets/battle/front-static` or `assets/battle/back-static`; for example, +`caterpie.png`, `farfetchd.png`, or `mr-mime.png`. No Lua sidecar or fixed +resolution is required, and the image is not resized. Existing alpha is +preserved. For a fully opaque PNG, the corner-coloured background connected +to the image border is keyed transparent while enclosed matching pixels are +left intact. + +Enemy front art is used as authored, facing left. Player front art is mirrored +to face right; authored player back art already faces right and is not +mirrored. Every imported sprite remains a card in the 3D world, so the normal +world tint, day/night lighting, hit flash, depth occlusion and alpha-shaped +shadow apply. OG, inverted and CLASSIC display filters also apply to imported +art. Missing or unreadable files fall straight back to the ROM art. + +The four battle-art directories are intentionally ignored by Git except for +their README contracts. `tools/package_mod.ps1` nevertheless includes local +PNGs from them in a test ZIP, so artwork can stay private and uncommitted. +Animated atlas decoding is being added behind the stable `ANIMATED` option; +until an atlas is recognised, that selection safely uses ROM art. + Two of the engine's own rows are taken away while this mod is installed: **TILT**, which is the flat fake of what this mode does for real, and **GBC FX**, a full-screen present pass over the top of the diorama. Both are held at @@ -51,4 +75,4 @@ Everything the battle screen draws as a box — the two HUD blocks, the text box and the menus over it — sits on frosted glass rather than on the white field it used to have behind it: the world underneath, blurred and laid back down translucent, with the ink flipping white where the ground it lands on is -dark. Nothing the engine draws inside a box moves; only the paper is gone. \ No newline at end of file +dark. Nothing the engine draws inside a box moves; only the paper is gone. diff --git a/assets/battle/back-animated/README.md b/assets/battle/back-animated/README.md new file mode 100644 index 0000000..704987d --- /dev/null +++ b/assets/battle/back-animated/README.md @@ -0,0 +1,5 @@ +# Back animated battle art + +Reserved for optional animated player-back atlases. Local PNG files are +ignored by Git. Authored back art faces right and is never mirrored. Until an +atlas is recognized, BATTLE ART: ANIMATED falls back to the ROM. diff --git a/assets/battle/back-static/README.md b/assets/battle/back-static/README.md new file mode 100644 index 0000000..eb31512 --- /dev/null +++ b/assets/battle/back-static/README.md @@ -0,0 +1,8 @@ +# Back static battle art + +Drop `.png` player back sprites here. Art is used as authored and is +not mirrored; it should face right toward the opponent. Files may use any +pixel dimensions and require no Lua or metadata. + +These local PNGs are ignored by Git. Missing or invalid files fall back to the +ROM sprite. diff --git a/assets/battle/front-animated/README.md b/assets/battle/front-animated/README.md new file mode 100644 index 0000000..d664b9d --- /dev/null +++ b/assets/battle/front-animated/README.md @@ -0,0 +1,4 @@ +# Front animated battle art + +Reserved for optional animated battle atlases. Local PNG files are ignored by +Git. Until an atlas is recognized, BATTLE ART: ANIMATED falls back to the ROM. diff --git a/assets/battle/front-static/README.md b/assets/battle/front-static/README.md new file mode 100644 index 0000000..0996b93 --- /dev/null +++ b/assets/battle/front-static/README.md @@ -0,0 +1,8 @@ +# Front static battle art + +Drop `.png` files here, for example `caterpie.png` or `mr-mime.png`. +Files may use any pixel dimensions. Existing alpha is preserved; a fully +opaque image has its corner-coloured, border-connected background keyed out. + +These local PNGs are ignored by Git. Missing or invalid files fall back to the +ROM sprite. Enemy sprites are used as authored (facing left). diff --git a/lib/BattleArt.lua b/lib/BattleArt.lua new file mode 100644 index 0000000..57f74fc --- /dev/null +++ b/lib/BattleArt.lua @@ -0,0 +1,197 @@ +-- Optional, user-supplied battle art. Nothing in these folders is used by +-- the Pokedex, party menu or status screen. +local V = ... + +local ModSetting = V.require("ModSetting") +local BattleArt = {} + +BattleArt.setting = ModSetting.new("battleArt", "BATTLE ART", + { "static", "animated", "rom" }, { "STATIC", "ANIMATED", "ROM" }) +BattleArt.viewSetting = ModSetting.new("playerView", "PLAYER VIEW", + { "front", "back" }, { "FRONT", "BACK" }) + +local cache = {} +local external = setmetatable({}, { __mode = "k" }) +local metrics = setmetatable({}, { __mode = "k" }) +local original = setmetatable({}, { __mode = "k" }) + +local function slug(species) + local s = tostring(species or ""):lower() + s = s:gsub("♀", "-f"):gsub("♂", "-m") + s = s:gsub("['’%.]", "") + s = s:gsub("[^%w]+", "-"):gsub("^-+", ""):gsub("-+$", "") + return s +end +BattleArt.slug = slug + +function BattleArt.playerSide() + return BattleArt.viewSetting:get() == "back" and "back" or "front" +end + +local function pathFor(species, side) + local mode = BattleArt.setting:get() + if mode == "rom" then return nil end + -- Animated atlases need frame rectangles/timing, not just an image path. + -- Keep the folder and setting stable while that decoder is added; an + -- unrecognised atlas must never appear as one giant sprite sheet. + if mode == "animated" then return nil end + local rel = ("assets/battle/%s-%s/%s.png"):format( + side, "static", slug(species)) + local path = V.mod.assets:path(rel) + local fs = love and love.filesystem + if not (fs and fs.getInfo and fs.getInfo(path)) then return nil end + return path, rel +end + +local function rgbaKey(data, w, h) + local corners = { {0, 0}, {w - 1, 0}, {0, h - 1}, {w - 1, h - 1} } + local counts, values, order = {}, {}, {} + for _, p in ipairs(corners) do + local r, g, b = data:getPixel(p[1], p[2]) + local key = (math.floor(r * 255 + .5) * 65536) + + (math.floor(g * 255 + .5) * 256) + + math.floor(b * 255 + .5) + counts[key] = (counts[key] or 0) + 1 + values[key] = { r, g, b } + if counts[key] == 1 then order[#order + 1] = key end + end + local best, n = order[1], -1 + for _, k in ipairs(order) do + local count = counts[k] + if count > n then best, n = k, count end + end + return values[best] +end + +local function displayMode() + local ok, fx = pcall(require, "src.render.PaletteFX") + return ok and fx and fx.mode or "gbc" +end + +local function applyDisplayFilter(data, mode) + if mode == "gbc_inv" then + data:mapPixel(function(_, _, r, g, b, a) + if a <= 0 then return r, g, b, a end + return 1 - r, 1 - g, 1 - b, a + end) + return + end + if mode ~= "og" and mode ~= "og_inv" and mode ~= "classic" then return end + local PaletteFX = require("src.render.PaletteFX") + local colors = PaletteFX.effectiveColors(PaletteFX.GRAYS) + data:mapPixel(function(_, _, r, g, b, a) + if a <= 0 then return r, g, b, a end + local luma = r * 0.2126 + g * 0.7152 + b * 0.0722 + local i = luma > 0.83 and 1 or luma > 0.5 and 2 + or luma > 0.17 and 3 or 4 + local c = colors[i] + return c[1] / 255, c[2] / 255, c[3] / 255, a + end) +end + +local function prepare(path, mode) + local cacheKey = path .. "#" .. mode + local hit = cache[cacheKey] + if hit ~= nil then return hit or nil end + local made + local ok = pcall(function() + local data = love.image.newImageData(path) + local w, h = data:getDimensions() + if w < 1 or h < 1 then return end + + local opaque = true + for y = 0, h - 1 do + for x = 0, w - 1 do + local _, _, _, a = data:getPixel(x, y) + if a < 0.999 then opaque = false break end + end + if not opaque then break end + end + + -- Fully opaque art commonly carries a flat matte. Infer it from the + -- corners and remove only matching pixels connected to the border, so a + -- matching eye/highlight enclosed by the silhouette is preserved. + if opaque then + local key = rgbaKey(data, w, h) + local seen, stack, top = {}, {}, 0 + local function push(x, y) + if x < 0 or y < 0 or x >= w or y >= h then return end + local i = y * w + x + if seen[i] then return end + local r, g, b = data:getPixel(x, y) + if math.abs(r - key[1]) > 0.5 / 255 + or math.abs(g - key[2]) > 0.5 / 255 + or math.abs(b - key[3]) > 0.5 / 255 then return end + seen[i], top = true, top + 1 + stack[top] = i + end + for x = 0, w - 1 do push(x, 0); push(x, h - 1) end + for y = 0, h - 1 do push(0, y); push(w - 1, y) end + while top > 0 do + local i = stack[top]; stack[top], top = nil, top - 1 + local x, y = i % w, math.floor(i / w) + local r, g, b = data:getPixel(x, y) + data:setPixel(x, y, r, g, b, 0) + push(x - 1, y); push(x + 1, y); push(x, y - 1); push(x, y + 1) + end + end + + applyDisplayFilter(data, mode) + + local x0, x1, y0, y1 = w, -1, h, -1 + for y = 0, h - 1 do + for x = 0, w - 1 do + local _, _, _, a = data:getPixel(x, y) + if a > 0.001 then + if x < x0 then x0 = x end; if x > x1 then x1 = x end + if y < y0 then y0 = y end; if y > y1 then y1 = y end + end + end + end + if x1 < x0 then return end + made = love.graphics.newImage(data) + made:setFilter("nearest", "nearest") + external[made] = true + metrics[made] = { x0 = x0, x1 = x1, y0 = y0, y1 = y1, + w = w, h = h, padBottom = h - 1 - y1, + center = (x0 + x1 + 1) / 2 } + end) + cache[cacheKey] = (ok and made) or false + return made +end + +function BattleArt.image(species, side) + local path = pathFor(species, side) + return path and prepare(path, displayMode()) or nil +end + +function BattleArt.apply(battle) + if not battle then return end + local function applyOne(battler, side) + local species = battler and battler.mon and battler.mon.species + if not species then return end + local img = BattleArt.image(species, side) + if img then + if not BattleArt.isExternal(battler.sprite) then + original[battler] = battler.sprite + end + battler.sprite = img + elseif original[battler] then + battler.sprite, original[battler] = original[battler], nil + end -- otherwise retain the ROM image + end + applyOne(battle.enemy, "front") + applyOne(battle.player, BattleArt.playerSide()) +end + +function BattleArt.isExternal(img) return external[img] and true or false end +function BattleArt.metrics(img) return metrics[img] end + +function BattleArt.invalidate() + cache = {} + external = setmetatable({}, { __mode = "k" }) + metrics = setmetatable({}, { __mode = "k" }) + original = setmetatable({}, { __mode = "k" }) +end + +return BattleArt diff --git a/lib/BattleScene.lua b/lib/BattleScene.lua index d8d0170..fd818c5 100644 --- a/lib/BattleScene.lua +++ b/lib/BattleScene.lua @@ -178,10 +178,10 @@ end -- turn it around to face the camera it is standing in front of. local function monMatrix(tex, x, groundY, z, mirror) local k = BattleBillboard.FULL_W / BattleBillboard.FULL_PIC - local w = BattleScene.GB_W * k - local h = BattleScene.GB_H * k - local ox = -((tex.ax / BattleScene.GB_W) - 0.5) * w - local oy = -((BattleScene.GB_H - tex.ay) / BattleScene.GB_H) * h + local tw, th = tex.canvas:getWidth(), tex.canvas:getHeight() + local w, h = tw * k, th * k + local ox = (tw / 2 - tex.ax) * k + local oy = -(th - tex.ay) * k local yaw = BattleBillboard.yawToward(x, z, Voxel3D.eye) local card = Mat4.mul(Mat4.translate(ox, oy, 0), Mat4.scale(w, h, 1)) if mirror then card = Mat4.mul(Mat4.scale(-1, 1, 1), card) end @@ -198,6 +198,7 @@ local function monCards(arena, groundY, textures) local cell = (side == "player") and arena.player or arena.enemy if tex and tex.canvas and cell then local mirror = (side == "player") and not tex.trainer + and not tex.noMirror out[#out + 1] = { tex = tex.canvas, model = monMatrix(tex, cell[1], groundY, cell[2], mirror) } diff --git a/lib/OverworldBattle.lua b/lib/OverworldBattle.lua index dddf97d..e928e12 100644 --- a/lib/OverworldBattle.lua +++ b/lib/OverworldBattle.lua @@ -45,6 +45,7 @@ local BattleScene = V.require("BattleScene") local BattleDOF = V.require("BattleDOF") local BattleHud = V.require("BattleHud") local BattlePics = V.require("BattlePics") +local BattleArt = V.require("BattleArt") local Voxel3D = V.require("Voxel3D") local ChunkMesher = V.require("ChunkMesher") @@ -72,36 +73,21 @@ function OverworldBattle.enabled() return OverworldBattle.setting:get() and true or false end --- ------- BACK SPRITES: the player's own mon stays on the menu +-- ------- battle-art view -- -- The staged shot stands BOTH mons on the map, which is the mode's whole -- claim -- but it costs the one piece of framing Gen 1 is most recognisable -- by: your own Pokemon, seen from behind, sitting on top of the battle menu -- with its feet on the box. That silhouette is the series' shot. -- --- So BACK SPRITES is offered as a middle setting rather than a compromise --- imposed on everyone. With it on the foe is still geometry standing on its --- tile at the far end of the arena, and the player's side goes back to being --- the GB's own flat back pic in the GB's own slot: same art, same 2x, same --- feet on row 96. --- Nothing else about the shot moves -- the arena, the camera and the drift are --- solved exactly as they were, so the foe stands where it always stood and the --- player's cell is simply empty ground in the foreground. --- --- OFF by default: what the mode advertises is the pair of them out there. -OverworldBattle.BACK_KEY = "battleBack" -OverworldBattle.BACK_LABEL = "BACK SPRITES" - -OverworldBattle.backSetting = ModSetting.new(OverworldBattle.BACK_KEY, - OverworldBattle.BACK_LABEL, - { false, true }, { "OFF", "ON" }) +-- Kept as a compatibility query for older callers. Both FRONT and BACK are +-- now world cards; neither is pinned to the UI layer. -- Gated on 3D-BTL rather than read alone: with staged battles off there is no -- staged shot for a back pic to be pinned in FRONT of, and the engine's own -- battle screen already draws exactly this. function OverworldBattle.backPinned() - if not OverworldBattle.enabled() then return false end - return OverworldBattle.backSetting:get() and true or false + return false end -- ------- both mons face you @@ -113,9 +99,7 @@ end -- through the engine's own pokemon.sprite hook -- the seam that exists for -- exactly this, so no battle code has to be touched to get it. -- --- Unless BACK SPRITES is on, the setting that asks for the back pic back: --- that mon is drawn in its own slot on the menu, seen from behind, and the --- front art would be it turned round to face the player it belongs to. +-- PLAYER VIEW chooses whether the near card asks for front or back art. -- -- Answered BEFORE a battle exists, because the battler is built before the -- battle is pushed. So it cannot ask whether this fight is staged; it asks @@ -127,7 +111,7 @@ local staged = { mapId = nil, ok = false } function OverworldBattle.wantsFront() if not OverworldBattle.enabled() then return false end - if OverworldBattle.backPinned() then return false end + if BattleArt.playerSide() ~= "front" then return false end if not Voxel3D.available() then return false end -- required here rather than through the file's own helper: this runs -- while a battler is being built, which is before that helper is defined @@ -510,6 +494,7 @@ function OverworldBattle.invalidate() BattleDOF.invalidate() BattleHud.invalidate() BattlePics.invalidate() + BattleArt.invalidate() end -- ------- the battle screen's background @@ -664,16 +649,18 @@ OverworldBattle.TEX_AX, OverworldBattle.TEX_AY = TEX_AX, TEX_AY -- Which side is being rendered, or nil. The placement wrappers read it. local texturing = nil +local texturingMetric = nil +local texturingAx, texturingAy = TEX_AX, TEX_AY local texCanvas = {} local innerPics = nil -- captured by install() local innerHUDs = nil -- likewise, for the snapped HUD layer -local function texCanvasFor(side) +local function texCanvasFor(side, w, h) local c = texCanvas[side] - if c then return c end - local ok, made = pcall(love.graphics.newCanvas, BattleScene.GB_W, - BattleScene.GB_H, { dpiscale = 1 }) + w, h = w or BattleScene.GB_W, h or BattleScene.GB_H + if c and c:getWidth() == w and c:getHeight() == h then return c end + local ok, made = pcall(love.graphics.newCanvas, w, h, { dpiscale = 1 }) if not ok then return nil end made:setFilter("nearest", "nearest") texCanvas[side] = made @@ -706,8 +693,24 @@ local OFF = { -- feet ended up, in canvas coordinates. function OverworldBattle.sideTexture(battle, side) if not (innerPics and battle) then return nil end + BattleArt.apply(battle) if not sideVisible(battle, side) then return nil end - local canvas = texCanvasFor(side) + local battler = side == "enemy" and battle.enemy or battle.player + local showingTrainer = (side == "enemy" and battle.showEnemyTrainer + and battle.trainerPic) + or (side == "player" and battle.showPlayerBack + and battle.playerBackPic) + local metric = not showingTrainer and BattleArt.metrics(battler and battler.sprite) + or nil + local cw, ch = BattleScene.GB_W, BattleScene.GB_H + local ax, ay = TEX_AX, TEX_AY + if metric then + cw = math.max(cw, metric.w + 2) + ch = math.max(ch, metric.h + 2) + ax = cw / 2 + ay = math.max(TEX_AY, metric.h + 1) + end + local canvas = texCanvasFor(side, cw, ch) if not canvas then return nil end local g = love.graphics @@ -726,6 +729,8 @@ function OverworldBattle.sideTexture(battle, side) local saved = {} for k, v in pairs(OFF[side]) do saved[k] = battle[k]; battle[k] = v end texturing = side + texturingMetric = metric + texturingAx, texturingAy = ax, ay local ok, err = pcall(function() g.setCanvas(canvas) @@ -736,6 +741,8 @@ function OverworldBattle.sideTexture(battle, side) end) texturing = nil + texturingMetric = nil + texturingAx, texturingAy = TEX_AX, TEX_AY for k in pairs(OFF[side]) do battle[k] = saved[k] end g.setScissor, g.intersectScissor, g.getScissor = setScissor, intersectScissor, getScissor @@ -743,7 +750,6 @@ function OverworldBattle.sideTexture(battle, side) g.setBlendMode(prevBlend or "alpha", prevAlpha) if not ok then error(err, 0) end - local ax, ay = TEX_AX, TEX_AY local trainer = false -- The intro trainer pic draws itself straight into its own 7x7 slot rather -- than through the placement helpers, so it is hung from that slot instead. @@ -752,7 +758,8 @@ function OverworldBattle.sideTexture(battle, side) elseif side == "player" and battle.showPlayerBack and battle.playerBackPic then trainer = true end - return { canvas = canvas, ax = ax, ay = ay, trainer = trainer } + return { canvas = canvas, ax = ax, ay = ay, trainer = trainer, + noMirror = side == "player" and BattleArt.playerSide() == "back" } end -- Whether the hit flash is showing this frame. @@ -835,6 +842,7 @@ function OverworldBattle.install() function BattleState:picImage(img) local out = innerPic(self, img) if not OverworldBattle.shot() then return out end + if BattleArt.isExternal(out) then return out end return BattlePics.filled(out) end @@ -847,14 +855,23 @@ function OverworldBattle.install() function BattleState.backPlacement(w, h, pad, padL, scale) local x, y, s = innerBack(w, h, pad, padL, scale) if not texturing then return x, y, s end - return TEX_AX - w * scale / 2, TEX_AY - (h - pad) * scale, s + if texturingMetric then + return texturingAx - texturingMetric.center * scale, + texturingAy - (texturingMetric.y1 + 1) * scale, s + end + return texturingAx - w * scale / 2, + texturingAy - (h - pad) * scale, s end local innerFront = BattleState.frontPlacement function BattleState.frontPlacement(ex, ey, w, h, scale) local x, y, s = innerFront(ex, ey, w, h, scale) if not texturing then return x, y, s end - return TEX_AX - w * scale / 2, TEX_AY - h * scale, s + if texturingMetric then + return texturingAx - texturingMetric.center * scale, + texturingAy - (texturingMetric.y1 + 1) * scale, s + end + return texturingAx - w * scale / 2, texturingAy - h * scale, s end local innerDraw = BattleState.draw diff --git a/main.lua b/main.lua index 0ba9fb0..84dae27 100644 --- a/main.lua +++ b/main.lua @@ -78,6 +78,7 @@ local ChunkMesher = V.require("ChunkMesher") local VoxelGrid = V.require("VoxelGrid") local WorldCurve = V.require("WorldCurve") local OverworldBattle = V.require("OverworldBattle") +local BattleArt = V.require("BattleArt") local BattleExit = V.require("BattleExit") local DayNight = V.require("DayNight") local DayTint = V.require("DayTint") @@ -290,10 +291,8 @@ applyFull = function(level) -- battle rows stay on the menu under FULL (see the rows hook), so this is -- where the preset puts them and not where they are held. OverworldBattle.setting:setIndex(1, Game) - -- with both mons out there on it: BACK SPRITES keeps the player's own on the - -- menu, which is the one part of the old screen FULL is least about. Set the - -- same way, and changed back on the same row a keypress later. - OverworldBattle.backSetting:setIndex(1, Game) + -- default to the player's front view, with both battlers world-placed. + BattleArt.viewSetting:setIndex(1, Game) -- and the battle screen the staged fight is composed for. WIDE re-lays that -- screen out on a 304x144 surface, which moves every anchor the arena camera -- is solved against (OverworldBattle.forceOG); FULL has just switched staged @@ -338,13 +337,14 @@ local SETTINGS = { "Fight on the map: the battle draws over the nearest clear ground, " .. "shot over the shoulder with a slow parallax drift.", full = true }, - -- Only offered while a fight can actually be staged on the map: with 3D-BTL - -- off the engine draws the classic screen, which is this row's ON already, - -- and a row that no longer decides anything is worse than no row. - { OverworldBattle.backSetting, - "Keep your own Pokemon on the battle menu, seen from behind in its " - .. "original slot, instead of standing it on the map facing the foe. " - .. "The foe is still out there on its own tile.", + -- Only offered while a fight can actually be staged on the map. + { BattleArt.setting, + "Use optional PNGs from assets/battle in fights. Missing art falls " + .. "back to the ROM. STATIC is the zero-configuration default.", + when = function() return stagedBattles() end, full = true }, + { BattleArt.viewSetting, + "Show the player's Pokemon from the front or back. Both choices stay " + .. "world-placed, lit, shadowed and depth-occluded.", when = function() return stagedBattles() end, full = true }, { DayNight.setting, "What time it is outdoors: pin the sky to DAY, NIGHT, DUSK or DAWN, " @@ -575,13 +575,13 @@ mod.hooks:wrap("ui.options.rows", function(next, game, rows) -- -- FULL: a preset that owns the look, so the rows that describe the look go -- with it. The BATTLE rows are not that -- 3D-BTL decides what a fight is - -- drawn OVER and BACK SPRITES how it is framed, and neither is a knob on + -- drawn over and BATTLE ART how its world cards are sourced, neither a knob on -- the diorama FULL is a preset for. FULL still SETS them on arrival (see -- applyFull); it does not hold them, so leaving them on the menu is the -- difference between a preset and a lock. -- - -- And a row whose own switch is off the table this frame (BACK SPRITES, - -- which needs a staged fight to be about) is left off with it. The mod + -- And a row whose own switch is off the table this frame (battle-art rows + -- need a staged fight to be about) is left off with it. The mod -- manager's page carries every one of them either way. local offered = (entry.full or not full) and (not entry.when or entry.when()) @@ -773,7 +773,9 @@ mod.hooks:wrap("pokemon.sprite", function(next, path, ctx) if not (ctx and ctx.kind == "battle" and ctx.side == "back") then return out end - if not OverworldBattle.wantsFront() then return out end + if BattleArt.playerSide() ~= "front" or not OverworldBattle.wantsFront() then + return out + end local def = ctx.data and ctx.data.pokemon and ctx.data.pokemon[ctx.species] return (def and def.spriteFront) or out end) diff --git a/mod.card b/mod.card index d7f9644..7386219 100644 --- a/mod.card +++ b/mod.card @@ -22,7 +22,9 @@ return { "T-SHIFT options row and hotkey 6 (OFF / 1 / 2 / 3), the miniature blur", "V-GRID on hotkey 5 and V-CURVE on hotkey 7", "3D-BTL on hotkey 8 (ON / OFF, on by default), battles fought on the world map", - "BACK SPRITES options row (OFF / ON, off by default), which keeps your own Pokemon on the battle menu in its classic slot while the foe stands out on the map", + "BATTLE ART options row (STATIC / ANIMATED / ROM) with per-species ROM fallback", + "PLAYER VIEW options row (FRONT / BACK), with both views placed in the 3D world", + "zero-configuration battle-only PNG folders with automatic alpha keying, native dimensions, lighting, depth and alpha-shaped shadows", "a day/night clock that reaches the flat 2D overworld as well as the diorama -- outdoor maps only, and only when the hour is not midday", "an over-the-shoulder battle camera on a slow parallax orbit, with a depth-of-field pass that holds both mons sharp", "a sky behind the diorama at the 75-degree rung, outdoor maps only, coloured by the active palette mode", diff --git a/tools/package_mod.ps1 b/tools/package_mod.ps1 new file mode 100644 index 0000000..1142709 --- /dev/null +++ b/tools/package_mod.ps1 @@ -0,0 +1,53 @@ +param( + [string]$Output = "" +) + +$ErrorActionPreference = "Stop" +$repo = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path +function Relative-Path([string]$FullName) { + return $FullName.Substring($repo.Length + 1).Replace('\', '/') +} +if (-not $Output) { + $Output = Join-Path (Split-Path $repo -Parent) "DramaticShapeVoxelMod-battle-art.zip" +} +$Output = [System.IO.Path]::GetFullPath($Output) + +$source = @() +foreach ($dir in @('data', 'lib')) { + $source += Get-ChildItem -LiteralPath (Join-Path $repo $dir) -Recurse -File | + ForEach-Object { + Relative-Path $_.FullName + } +} +$source += @('CHANGELOG.md', 'main.lua', 'manifest.json', 'mod.card', 'README.md') +$contracts = @(Get-ChildItem -LiteralPath (Join-Path $repo 'assets\battle') ` + -Recurse -File -Filter 'README.md' | ForEach-Object { + Relative-Path $_.FullName + }) + +# These files are deliberately ignored by Git, but a local test build should +# include them. This bridges a clean public branch and private BYO artwork. +$localArt = @(Get-ChildItem -LiteralPath (Join-Path $repo 'assets\battle') ` + -Recurse -File -Filter '*.png' -ErrorAction SilentlyContinue | ForEach-Object { + Relative-Path $_.FullName + }) +$files = @($source + $contracts + $localArt | Sort-Object -Unique) +if (-not $files.Count) { throw "no package files found" } + +if (Test-Path -LiteralPath $Output) { Remove-Item -LiteralPath $Output -Force } +Push-Location $repo +try { + & tar -a -cf $Output @files + if ($LASTEXITCODE -ne 0) { throw "tar failed: $LASTEXITCODE" } +} finally { + Pop-Location +} + +$entries = @(tar -tf $Output) +[PSCustomObject]@{ + Path = $Output + Entries = $entries.Count + LocalPngs = $localArt.Count + Bytes = (Get-Item -LiteralPath $Output).Length + SHA256 = (Get-FileHash -LiteralPath $Output -Algorithm SHA256).Hash +} From a349a31eab3d2b1354df13e02a9fe1eaebf0d155 Mon Sep 17 00:00:00 2001 From: absol89 Date: Sat, 1 Aug 2026 21:01:10 +0200 Subject: [PATCH 008/116] Document exceptional battle art filenames --- assets/battle/back-animated/README.md | 14 ++++++++++++++ assets/battle/back-static/README.md | 14 ++++++++++++++ assets/battle/front-animated/README.md | 14 ++++++++++++++ assets/battle/front-static/README.md | 14 ++++++++++++++ 4 files changed, 56 insertions(+) diff --git a/assets/battle/back-animated/README.md b/assets/battle/back-animated/README.md index 704987d..a4e4a6b 100644 --- a/assets/battle/back-animated/README.md +++ b/assets/battle/back-animated/README.md @@ -3,3 +3,17 @@ Reserved for optional animated player-back atlases. Local PNG files are ignored by Git. Authored back art faces right and is never mirrored. Until an atlas is recognized, BATTLE ART: ANIMATED falls back to the ROM. + +## Gen 1 filename exceptions + +Most species use their ordinary lowercase name (`pikachu.png`). These four +engine names need the following exact filenames: + +| Species | Expected filename | Do not use | +| --- | --- | --- | +| Mr. Mime | `mr-mime.png` | `mrmime.png`, `mr.mime.png` | +| Farfetch’d | `farfetchd.png` | `farfetched.png`, `farfetch-d.png` | +| Nidoran♀ | `nidoran-f.png` | `nidoran.png`, `nidoran-female.png` | +| Nidoran♂ | `nidoran-m.png` | `nidoran.png`, `nidoran-male.png` | + +Filenames are lowercase. The same names apply in every battle-art folder. diff --git a/assets/battle/back-static/README.md b/assets/battle/back-static/README.md index eb31512..2d16933 100644 --- a/assets/battle/back-static/README.md +++ b/assets/battle/back-static/README.md @@ -6,3 +6,17 @@ pixel dimensions and require no Lua or metadata. These local PNGs are ignored by Git. Missing or invalid files fall back to the ROM sprite. + +## Gen 1 filename exceptions + +Most species use their ordinary lowercase name (`pikachu.png`). These four +engine names need the following exact filenames: + +| Species | Expected filename | Do not use | +| --- | --- | --- | +| Mr. Mime | `mr-mime.png` | `mrmime.png`, `mr.mime.png` | +| Farfetch’d | `farfetchd.png` | `farfetched.png`, `farfetch-d.png` | +| Nidoran♀ | `nidoran-f.png` | `nidoran.png`, `nidoran-female.png` | +| Nidoran♂ | `nidoran-m.png` | `nidoran.png`, `nidoran-male.png` | + +Filenames are lowercase. The same names apply in every battle-art folder. diff --git a/assets/battle/front-animated/README.md b/assets/battle/front-animated/README.md index d664b9d..c8eb8c6 100644 --- a/assets/battle/front-animated/README.md +++ b/assets/battle/front-animated/README.md @@ -2,3 +2,17 @@ Reserved for optional animated battle atlases. Local PNG files are ignored by Git. Until an atlas is recognized, BATTLE ART: ANIMATED falls back to the ROM. + +## Gen 1 filename exceptions + +Most species use their ordinary lowercase name (`pikachu.png`). These four +engine names need the following exact filenames: + +| Species | Expected filename | Do not use | +| --- | --- | --- | +| Mr. Mime | `mr-mime.png` | `mrmime.png`, `mr.mime.png` | +| Farfetch’d | `farfetchd.png` | `farfetched.png`, `farfetch-d.png` | +| Nidoran♀ | `nidoran-f.png` | `nidoran.png`, `nidoran-female.png` | +| Nidoran♂ | `nidoran-m.png` | `nidoran.png`, `nidoran-male.png` | + +Filenames are lowercase. The same names apply in every battle-art folder. diff --git a/assets/battle/front-static/README.md b/assets/battle/front-static/README.md index 0996b93..efa7f6d 100644 --- a/assets/battle/front-static/README.md +++ b/assets/battle/front-static/README.md @@ -6,3 +6,17 @@ opaque image has its corner-coloured, border-connected background keyed out. These local PNGs are ignored by Git. Missing or invalid files fall back to the ROM sprite. Enemy sprites are used as authored (facing left). + +## Gen 1 filename exceptions + +Most species use their ordinary lowercase name (`pikachu.png`). These four +engine names need the following exact filenames: + +| Species | Expected filename | Do not use | +| --- | --- | --- | +| Mr. Mime | `mr-mime.png` | `mrmime.png`, `mr.mime.png` | +| Farfetch’d | `farfetchd.png` | `farfetched.png`, `farfetch-d.png` | +| Nidoran♀ | `nidoran-f.png` | `nidoran.png`, `nidoran-female.png` | +| Nidoran♂ | `nidoran-m.png` | `nidoran.png`, `nidoran-male.png` | + +Filenames are lowercase. The same names apply in every battle-art folder. From 93ab245461f5828f567f1747eaf3aaff9962518c Mon Sep 17 00:00:00 2001 From: absol89 Date: Sat, 1 Aug 2026 21:07:30 +0200 Subject: [PATCH 009/116] Add static trainer battle art contracts --- CHANGELOG.md | 3 ++ README.md | 6 +++ assets/battle/back-animated/README.md | 3 ++ assets/battle/back-static/README.md | 14 ++++++ assets/battle/front-animated/README.md | 4 ++ assets/battle/front-static/README.md | 28 ++++++++++++ lib/BattleArt.lua | 59 ++++++++++++++++++++++++++ lib/OverworldBattle.lua | 26 ++++++++++-- 8 files changed, 139 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa7aca9..8702180 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,9 @@ day/night tint, display filtering, hit flash and alpha-shaped shadow. - **Private test packaging.** The four battle-art folders ignore local art in Git while `tools/package_mod.ps1` deliberately includes those PNGs in a ZIP. +- **Static trainer cards.** Opponent trainer classes resolve from + `front-static`; the player, Professor Oak, and Old Man backs resolve from + `back-static`. Trainer art never uses either animated folder. ## 1.3.0 diff --git a/README.md b/README.md index 4772214..0af1cf7 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,12 @@ PNGs from them in a test ZIP, so artwork can stay private and uncommitted. Animated atlas decoding is being added behind the stable `ANIMATED` option; until an atlas is recognised, that selection safely uses ROM art. +Trainer cards are always static. Opponent trainer fronts use class filenames +in `front-static` (for example `youngster.png`, `cooltrainer-f.png`, and +`jessie-james.png`). Player-side trainer backs use `back-static/player.png`, +`back-static/oak.png`, or `back-static/old-man.png`. The folder READMEs carry +the complete trainer-class list and the four exceptional Pokémon filenames. + Two of the engine's own rows are taken away while this mod is installed: **TILT**, which is the flat fake of what this mode does for real, and **GBC FX**, a full-screen present pass over the top of the diorama. Both are held at diff --git a/assets/battle/back-animated/README.md b/assets/battle/back-animated/README.md index a4e4a6b..4b7765a 100644 --- a/assets/battle/back-animated/README.md +++ b/assets/battle/back-animated/README.md @@ -17,3 +17,6 @@ engine names need the following exact filenames: | Nidoran♂ | `nidoran-m.png` | `nidoran.png`, `nidoran-male.png` | Filenames are lowercase. The same names apply in every battle-art folder. + +Trainer back pictures can never be animated and are not read from this +folder. Put `player.png`, `oak.png`, and `old-man.png` in `../back-static/`. diff --git a/assets/battle/back-static/README.md b/assets/battle/back-static/README.md index 2d16933..ac0dbd8 100644 --- a/assets/battle/back-static/README.md +++ b/assets/battle/back-static/README.md @@ -20,3 +20,17 @@ engine names need the following exact filenames: | Nidoran♂ | `nidoran-m.png` | `nidoran.png`, `nidoran-male.png` | Filenames are lowercase. The same names apply in every battle-art folder. + +## Static player-side trainer backs + +Trainer pictures are never animated. This is the only battle-art folder used +for trainer back pictures: + +| Battle role | Expected filename | +| --- | --- | +| Normal player battle intro | `player.png` | +| Professor Oak in Yellow's opening battle | `oak.png` | +| Old Man catching tutorial | `old-man.png` | + +These are intro trainer cards, not Pokémon species. Missing files retain the +ROM trainer backsprite. Opponent trainers never read from a back folder. diff --git a/assets/battle/front-animated/README.md b/assets/battle/front-animated/README.md index c8eb8c6..583829f 100644 --- a/assets/battle/front-animated/README.md +++ b/assets/battle/front-animated/README.md @@ -16,3 +16,7 @@ engine names need the following exact filenames: | Nidoran♂ | `nidoran-m.png` | `nidoran.png`, `nidoran-male.png` | Filenames are lowercase. The same names apply in every battle-art folder. + +Trainer front pictures can never be animated and are not read from this +folder. Put every opponent trainer PNG in `../front-static/`, including while +`BATTLE ART` is set to `ANIMATED`. diff --git a/assets/battle/front-static/README.md b/assets/battle/front-static/README.md index efa7f6d..78706e1 100644 --- a/assets/battle/front-static/README.md +++ b/assets/battle/front-static/README.md @@ -20,3 +20,31 @@ engine names need the following exact filenames: | Nidoran♂ | `nidoran-m.png` | `nidoran.png`, `nidoran-male.png` | Filenames are lowercase. The same names apply in every battle-art folder. + +## Static opponent trainer fronts + +Trainer pictures are never animated. Put opponent trainer PNGs in this folder +even when `BATTLE ART` is set to `ANIMATED`. The filename is the engine trainer +class in lowercase with underscores changed to hyphens: + +```text +youngster.png bug-catcher.png lass.png +sailor.png jr-trainer-m.png jr-trainer-f.png +pokemaniac.png super-nerd.png hiker.png +biker.png burglar.png engineer.png +unused-juggler.png fisher.png swimmer.png +cue-ball.png gambler.png beauty.png +psychic-tr.png rocker.png juggler.png +tamer.png bird-keeper.png blackbelt.png +rival1.png prof-oak.png chief.png +scientist.png giovanni.png rocket.png +cooltrainer-m.png cooltrainer-f.png bruno.png +brock.png misty.png lt-surge.png +erika.png koga.png blaine.png +sabrina.png gentleman.png rival2.png +rival3.png lorelei.png channeler.png +agatha.png lance.png +``` + +Yellow's special Rocket pair uses `jessie-james.png`; other Rocket trainers +use `rocket.png`. Missing trainer files retain the ROM trainer picture. diff --git a/lib/BattleArt.lua b/lib/BattleArt.lua index 57f74fc..4fa858f 100644 --- a/lib/BattleArt.lua +++ b/lib/BattleArt.lua @@ -14,6 +14,7 @@ local cache = {} local external = setmetatable({}, { __mode = "k" }) local metrics = setmetatable({}, { __mode = "k" }) local original = setmetatable({}, { __mode = "k" }) +local trainerOriginal = setmetatable({}, { __mode = "k" }) local function slug(species) local s = tostring(species or ""):lower() @@ -43,6 +44,14 @@ local function pathFor(species, side) return path, rel end +local function staticPathFor(name, side) + if BattleArt.setting:get() == "rom" then return nil end + local path = V.mod.assets:path( + ("assets/battle/%s-static/%s.png"):format(side, name)) + local fs = love and love.filesystem + return fs and fs.getInfo and fs.getInfo(path) and path or nil +end + local function rgbaKey(data, w, h) local corners = { {0, 0}, {w - 1, 0}, {0, h - 1}, {w - 1, h - 1} } local counts, values, order = {}, {}, {} @@ -165,6 +174,54 @@ function BattleArt.image(species, side) return path and prepare(path, displayMode()) or nil end +function BattleArt.namedImage(name, side) + local path = staticPathFor(name, side) + return path and prepare(path, displayMode()) or nil +end + +local function trainerKey(battle) + local id = battle and battle.oppClass + if type(id) ~= "string" then return nil end + if id == "OPP_ROCKET" and (battle.dramaticShapeTrainerParty or 1) >= 42 then + return "jessie-james" + end + return slug(id:gsub("^OPP_", "")) +end + +local function replaceTrainerField(battle, field, img) + local rec = trainerOriginal[battle] + if not rec then rec = {}; trainerOriginal[battle] = rec end + local saved = field .. "Saved" + if img then + if not rec[saved] then + rec[field], rec[saved] = battle[field] or false, true + end + battle[field] = img + elseif rec[saved] then + battle[field] = rec[field] or nil + rec[field], rec[saved] = nil, nil + end +end + +function BattleArt.applyTrainers(battle) + if not battle then return end + local enemy = battle.showEnemyTrainer and trainerKey(battle) or nil + replaceTrainerField(battle, "trainerPic", + enemy and BattleArt.namedImage(enemy, "front") or nil) + + local player + if battle.showPlayerBack then + if battle.demo then + player = tostring(battle.demoName or ""):find("OAK", 1, true) + and "oak" or "old-man" + else + player = "player" + end + end + replaceTrainerField(battle, "playerBackPic", + player and BattleArt.namedImage(player, "back") or nil) +end + function BattleArt.apply(battle) if not battle then return end local function applyOne(battler, side) @@ -182,6 +239,7 @@ function BattleArt.apply(battle) end applyOne(battle.enemy, "front") applyOne(battle.player, BattleArt.playerSide()) + BattleArt.applyTrainers(battle) end function BattleArt.isExternal(img) return external[img] and true or false end @@ -192,6 +250,7 @@ function BattleArt.invalidate() external = setmetatable({}, { __mode = "k" }) metrics = setmetatable({}, { __mode = "k" }) original = setmetatable({}, { __mode = "k" }) + trainerOriginal = setmetatable({}, { __mode = "k" }) end return BattleArt diff --git a/lib/OverworldBattle.lua b/lib/OverworldBattle.lua index e928e12..8cd2152 100644 --- a/lib/OverworldBattle.lua +++ b/lib/OverworldBattle.lua @@ -700,8 +700,11 @@ function OverworldBattle.sideTexture(battle, side) and battle.trainerPic) or (side == "player" and battle.showPlayerBack and battle.playerBackPic) - local metric = not showingTrainer and BattleArt.metrics(battler and battler.sprite) - or nil + local shownImage = showingTrainer + and (side == "enemy" and battle.trainerPic + or battle.playerBackPic) + or (battler and battler.sprite) + local metric = BattleArt.metrics(shownImage) local cw, ch = BattleScene.GB_W, BattleScene.GB_H local ax, ay = TEX_AX, TEX_AY if metric then @@ -737,7 +740,12 @@ function OverworldBattle.sideTexture(battle, side) g.clear(0, 0, 0, 0) g.setBlendMode("alpha") g.setColor(1, 1, 1, 1) - innerPics(battle, 0, 0, 0) + if side == "enemy" and showingTrainer and metric then + local img = battle:picImage(battle.trainerPic) + g.draw(img, ax - metric.center, ay - (metric.y1 + 1)) + else + innerPics(battle, 0, 0, 0) + end end) texturing = nil @@ -753,7 +761,8 @@ function OverworldBattle.sideTexture(battle, side) local trainer = false -- The intro trainer pic draws itself straight into its own 7x7 slot rather -- than through the placement helpers, so it is hung from that slot instead. - if side == "enemy" and battle.showEnemyTrainer and battle.trainerPic then + if side == "enemy" and battle.showEnemyTrainer and battle.trainerPic + and not metric then ax, ay, trainer = TRAINER_AX, TRAINER_AY, true elseif side == "player" and battle.showPlayerBack and battle.playerBackPic then trainer = true @@ -814,6 +823,15 @@ function OverworldBattle.install() end local BattleState = require("src.battle.BattleState") + if not BattleState.dramaticShapeTrainerPartyHook then + local newTrainer = BattleState.newTrainer + function BattleState.newTrainer(game_, oppClass, partyIndex) + local battle = newTrainer(game_, oppClass, partyIndex) + battle.dramaticShapeTrainerParty = partyIndex or 1 + return battle + end + BattleState.dramaticShapeTrainerPartyHook = true + end if BattleState.dramaticShapeBattleHook then return end -- Integer scales only. The camera is solved to make one overworld square From a140980b1d97c292dbbc5cb1bfe486329d7d255f Mon Sep 17 00:00:00 2001 From: DramaticShape Date: Sat, 1 Aug 2026 15:19:26 -0400 Subject: [PATCH 010/116] seal transparent back sprites --- CHANGELOG.md | 30 +++++++ lib/BattlePics.lua | 131 +++++++++++++++++++++++------ lib/OverworldBattle.lua | 33 +++++++- tests/dramatic_shape_test.lua | 152 +++++++++++++++++++++++++++++++--- 4 files changed, 310 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 172bfd4..21ab6e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -216,6 +216,36 @@ depth16 as the floor every GLES3 device can read. Refused all four, the reflections are lost and nothing else, exactly as before. +- **Under BACK SPRITES some of your own Pokemon were see-through -- Pikachu, + Seel, Dewgong, Chansey, Jigglypuff -- with the arena showing through the + middle of them.** Those back pics are drawn as OUTLINES: everything inside + the ink is the lightest shade, the decoder keys that shade to nothing, and + on hardware it did not matter because the field behind them was white too. + + BattlePics already put that paper back by flooding the background inward and + filling whatever it could not reach, and along the bottom of a figure it told + a narrow opening (a belly the drawing ran out of, sealed) from a wide one (a + stride, left open for the world to show through). Right for a mon standing + on the map -- but the pinned back pic is not on the map, it is on the text + box with its feet on row 96, and there is white box under its lowest row + rather than arena. Every one of those mons leaks out through an opening far + too wide to read as a drain, so the flood walked straight up inside them. + + A pic on the box is now told so, and its bottom edge seals: nothing reaches + it from below at any width, and the rule stops being a heuristic -- paper is + whatever the background cannot walk to from the left, the right or the top. + Twelve of the game's 151 back pics turn on this; the other 139 come back + byte-identical, and no front pic is touched at all. + + **And a hole is filled with the pic's own paper rather than with white.** + Shade 0 is only white while the pic is still grays, and pics arrive here + after the bake -- a species SGB colour, a BGP fade mid-animation, PAL_BLACK + across the whole screen while the blackout text is up. A hardcoded white + belly would have been the one lit thing on a blacked-out mon. The lightest + shade still standing in the pic is that colour, and every one of the game's + battler pics keeps at least one such pixel -- an eye, a highlight down a + cheek -- so what goes back is the baked shade itself. + ### Known - Screen-space reflections can only reflect what is in the frame. A tree just diff --git a/lib/BattlePics.lua b/lib/BattlePics.lua index d74ec24..bca2b53 100644 --- a/lib/BattlePics.lua +++ b/lib/BattlePics.lua @@ -57,6 +57,26 @@ -- and they come back untouched because that is what their own shape says, not -- because they were special-cased. -- +-- The drain/mouth cut is for a pic STANDING ON THE MAP, where a mouth is a +-- real hole with real ground behind it. A pic PINNED TO THE MENU has no such +-- hole to be: under BACK SPRITES the player's mon is drawn in the GB's own +-- slot with its feet flush on the text box (BattleState.backPlacement pins +-- row 96), so the only thing under its lowest row is white box. Nothing can +-- reach it from below, whatever the opening's width, and the caller says so +-- by asking for a SEALED BOTTOM -- for which the rule stops being a heuristic +-- and becomes exact: paper is whatever the background cannot walk to from the +-- left, the right or the top. +-- +-- That is the difference between a Pikachu that reads as a mon and one that +-- reads as wireframe. The pale-bodied back pics -- Pikachu, Seel, Dewgong, +-- Chansey, Jigglypuff -- are drawn as OUTLINES: everything inside the ink is +-- shade 0 and every one of them is keyed away, so the figure is a rim with the +-- arena showing through it. Each one also has a wide opening along its bottom, +-- which the drain cut correctly reads as a mouth and the sealed bottom +-- correctly does not. Twelve of this game's 151 back pics turn on it; the +-- other 139 come back byte-identical either way, because they had nothing +-- under them the flood was getting in through. +-- -- The silhouette is untouched, so the mon still cuts cleanly against the -- world; only its insides stop being see-through. -- @@ -71,14 +91,23 @@ local V = ... local BattlePics = {} --- Cached by the image the engine handed over. Weak keys, so a pic that goes --- out of scope takes its filled twin with it rather than pinning a texture --- for the session. -local cache = setmetatable({}, { __mode = "k" }) +-- Cached by the image the engine handed over, one table per bottom rule -- +-- the same pic answers differently sealed and unsealed, and a single table +-- would hand the wrong twin back to whichever caller asked second. Weak keys, +-- so a pic that goes out of scope takes its filled twin with it rather than +-- pinning a texture for the session. +local function newCache() + return { + [false] = setmetatable({}, { __mode = "k" }), + [true] = setmetatable({}, { __mode = "k" }), + } +end +local cache = newCache() --- What an enclosed hole is filled with. White, because white is what the --- battle field was: this restores the pixel the artist drew and the engine --- then keyed away, it does not invent a new one. +-- What an enclosed hole is filled with when the pic itself offers nothing +-- better. White, because white is what the battle field was: this restores the +-- pixel the artist drew and the engine then keyed away, it does not invent a +-- new one. BattlePics.FILL = { 1, 1, 1, 1 } -- Anything at or under this alpha counts as keyed-out rather than drawn. @@ -154,6 +183,42 @@ local function inkBounds(data, w, h) return x0, y0, x1, y1 end +-- The colour the keyed-away shade would have had: the LIGHTEST colour still +-- standing in the pic. +-- +-- Pure white is only the right answer while the pic is still grays, and by the +-- time it reaches here it usually is not. picImage hands a pic over AFTER the +-- bake -- a species SGB colour, a BGP fade mid-animation, PAL_BLACK for the +-- whole screen while the blackout text is up -- and shade 0 travels with the +-- rest. A white belly inside a blacked-out mon would be the one lit thing on a +-- dark screen; inside a warm-palette mon it would be a cold patch the artist +-- never drew. +-- +-- So the paper is read off the pic rather than assumed, which needs shade 0 to +-- have survived somewhere in it. It always has: every one of this game's 151 +-- back pics keeps at least one opaque shade-0 pixel -- a highlight down a +-- cheek, the white of an eye -- because only the shade-0 pixels the decoder +-- could reach were keyed. So what comes back is the baked shade 0 itself, not +-- an approximation of it, and it tracks every palette the engine picks without +-- being told which one that was. +-- +-- Ranked by channel sum, which orders four DMG shades exactly: a palette maps +-- all three channels monotonically, so lightest by sum is lightest full stop. +local function paperColor(data, x0, y0, x1, y1) + local best, pr, pg, pb = -1, nil, nil, nil + for y = y0, y1 do + for x = x0, x1 do + local r, g, b, a = data:getPixel(x, y) + if a > CUT then + local lum = r + g + b + if lum > best then best, pr, pg, pb = lum, r, g, b end + end + end + end + if best < 0 then return nil end + return pr, pg, pb +end + -- The widest opening along the bottom of a figure that still counts as a drain -- rather than a mouth. See the header for the measurements either side of it. BattlePics.DRAIN = 6 @@ -161,7 +226,9 @@ BattlePics.DRAIN = 6 -- Mark every transparent pixel the BACKGROUND can reach, flooding inward from -- the edges of the artwork's box: the left, the right and the top whole, and -- along the bottom only those openings wide enough to be background rather --- than the underside of a figure the drawing ran out of. +-- than the underside of a figure the drawing ran out of -- or none of them at +-- all, for a pic whose feet are on the text box and which therefore has +-- nothing behind its lowest row to let in. -- -- Confined to the box as well as seeded from it, so the empty frame under a -- short pic cannot walk around a sealed drain and come back up through it. @@ -169,7 +236,7 @@ BattlePics.DRAIN = 6 -- An explicit stack rather than recursion: a 56x56 pic is three thousand -- pixels and a keyed-out background is most of them, which is a deeper call -- chain than is worth risking for no gain. -local function markOutside(data, w, h, x0, y0, x1, y1) +local function markOutside(data, w, h, x0, y0, x1, y1, sealBottom) local outside = {} local stack, top = {}, 0 local function clear(x, y) @@ -191,17 +258,21 @@ local function markOutside(data, w, h, x0, y0, x1, y1) push(x1, y) end -- the bottom, run by run: a wide one is the gap between two legs and lets - -- the world through, a narrow one is where a belly ran out and is sealed - local x = x0 - while x <= x1 do - if clear(x, y1) then - local from = x - while x <= x1 and clear(x, y1) do x = x + 1 end - if (x - from) > BattlePics.DRAIN then - for k = from, x - 1 do push(k, y1) end + -- the world through, a narrow one is where a belly ran out and is sealed. + -- Skipped whole for a pic on the box, where even the widest of them has + -- white paper behind it rather than arena. + if not sealBottom then + local x = x0 + while x <= x1 do + if clear(x, y1) then + local from = x + while x <= x1 and clear(x, y1) do x = x + 1 end + if (x - from) > BattlePics.DRAIN then + for k = from, x - 1 do push(k, y1) end + end + else + x = x + 1 end - else - x = x + 1 end end while top > 0 do @@ -219,9 +290,15 @@ end -- The pic with its enclosed holes filled, or the pic itself when that could -- not be done (no pixel access, a driver that refused the readback). Never -- nil for a non-nil argument: a caller must always have something to draw. -function BattlePics.filled(img) +-- +-- sealBottom for a pic pinned to the text box rather than standing on the map: +-- see the header. A caller that does not say defaults to the map, which is +-- where all but one of this mod's pics are. +function BattlePics.filled(img, sealBottom) if not img then return img end - local hit = cache[img] + sealBottom = sealBottom and true or false + local slot = cache[sealBottom] + local hit = slot[img] if hit ~= nil then return hit or img end local made = nil @@ -231,8 +308,12 @@ function BattlePics.filled(img) local w, h = data:getDimensions() local x0, y0, x1, y1 = inkBounds(data, w, h) if not x0 then return end -- a pic with nothing drawn in it - local outside = markOutside(data, w, h, x0, y0, x1, y1) + local outside = markOutside(data, w, h, x0, y0, x1, y1, sealBottom) local fill = BattlePics.FILL + local pr, pg, pb = paperColor(data, x0, y0, x1, y1) + local fr = pr or fill[1] + local fg = pg or fill[2] + local fb = pb or fill[3] local changed = false -- only inside the box: everything beyond it is frame the artist never -- reached, and filling that would put the mon in a white rectangle @@ -242,7 +323,7 @@ function BattlePics.filled(img) if not outside[row + x] then local _, _, _, a = data:getPixel(x, y) if a <= CUT then - data:setPixel(x, y, fill[1], fill[2], fill[3], fill[4]) + data:setPixel(x, y, fr, fg, fb, fill[4]) changed = true end end @@ -255,12 +336,12 @@ function BattlePics.filled(img) made = out end) - cache[img] = (ok and made) or false + slot[img] = (ok and made) or false return made or img end function BattlePics.invalidate() - cache = setmetatable({}, { __mode = "k" }) + cache = newCache() end return BattlePics diff --git a/lib/OverworldBattle.lua b/lib/OverworldBattle.lua index dddf97d..78fdfff 100644 --- a/lib/OverworldBattle.lua +++ b/lib/OverworldBattle.lua @@ -104,6 +104,31 @@ function OverworldBattle.backPinned() return OverworldBattle.backSetting:get() and true or false end +-- Whether a pic is the one drawn in the GB's own slot with its feet on the +-- text box, rather than geometry standing out on the map. +-- +-- Exactly the player's side under BACK SPRITES -- its mon, or the trainer back +-- that holds the slot until "Go!" -- because that is the only pic this mod +-- ever leaves flat (see drawPicsLayer below). The foe is a billboard on its +-- tile whichever mode is on, and with the mode off the player's side is one +-- too, so both of those keep the open bottom that lets the arena through a +-- stride. What the answer buys is in BattlePics: a pic on the box has nothing +-- behind its lowest row, so its bottom edge seals. +-- Read by TRUTHINESS rather than against nil, because sideTexture blanks the +-- side it is not rendering by setting the field to FALSE (see OFF) and holds +-- it that way for the whole render -- during which the pic layer runs, and +-- picImage asks this. A nil test passes a `false` straight through to the +-- index below, and the error comes out of sideTexture into the pcall that +-- calls it: the foe's billboard is dropped for the frame and the Pokemon +-- simply is not there. +function OverworldBattle.pinnedPic(battle, img) + if not (battle and img) then return false end + if not OverworldBattle.backPinned() then return false end + if img == battle.playerBackPic then return true end + local player = battle.player + return (player and img == player.sprite) and true or false +end + -- ------- both mons face you -- -- Standing on a map, seen from in front, a Pokemon showing you its BACK is @@ -831,11 +856,17 @@ function OverworldBattle.install() -- behind it. There is a world back there now, so they are filled here -- instead -- see BattlePics, which puts the paper back without touching -- the silhouette. + -- + -- The pinned pic is told that its feet are on the box, which is what lets + -- the pale-bodied back sprites be filled at all: their bellies leak out + -- through an opening too wide to read as a drain, and only the box under + -- them settles that it is not a hole. Passed the pre-bake image, because + -- that is the one the battle holds a reference to. local innerPic = BattleState.picImage function BattleState:picImage(img) local out = innerPic(self, img) if not OverworldBattle.shot() then return out end - return BattlePics.filled(out) + return BattlePics.filled(out, OverworldBattle.pinnedPic(self, img)) end -- While a billboard texture is being rendered both pics are put in the same diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua index 6bdfb73..70b37fc 100644 --- a/tests/dramatic_shape_test.lua +++ b/tests/dramatic_shape_test.lua @@ -2476,7 +2476,35 @@ T.check(onAt["DRAMATIC_SHAPE:battleBack"], "switched back on, so is the row") T.eq(onAt["DRAMATIC_SHAPE:battleBack"] - onAt["DRAMATIC_SHAPE:battles"], 1, "directly under the row it belongs to") +-- ------- and which pic is the pinned one is asked with the other side BLANKED +-- +-- picImage asks this so BattlePics knows whether a pic's feet are on the text +-- box -- where its bottom edge seals, and its belly stops being see-through -- +-- and it is asked DURING the billboard render, inside which sideTexture has +-- switched the side it is not drawing off by setting the field to FALSE rather +-- than to nil (see OFF). +-- +-- So the read has to be by truthiness. A test against nil passes that `false` +-- through to the index below it, the error comes back out of sideTexture into +-- the pcall that calls it, textures() reports no card for the side -- and the +-- foe is simply not on the field. Which is the whole bug: fixing the player's +-- back pic took the enemy's billboard out. +local mine, theirs = {}, {} +local live = { player = { sprite = mine }, enemy = { sprite = theirs } } +T.eq(Battles.pinnedPic(live, mine), true, + "the player's own mon is the pic on the box") +T.eq(Battles.pinnedPic(live, theirs), false, + "and the foe is geometry out on the map, whatever the mode") +T.eq(Battles.pinnedPic({ player = false, enemy = { sprite = theirs } }, theirs), + false, "asking about the foe while the player is blanked answers, not throws") +T.eq(Battles.pinnedPic({ playerBackPic = mine }, mine), true, + "the trainer back holds the slot until Go!, on the box like the mon") +T.eq(Battles.pinnedPic({ player = false, playerBackPic = false }, mine), false, + "and with the side blanked outright nothing of it is pinned") + Battles.backSetting:setIndex(1, backGame) -- and off for the rows below +T.eq(Battles.pinnedPic(live, mine), false, + "with BACK SPRITES off the player's mon is out on the map with the foe") end -- ------- the hour reaches the FLAT world too @@ -2611,21 +2639,35 @@ local BattlePics = run.loader.exports.DRAMATIC_SHAPE.lib.require("BattlePics") -- reader over what came out. The pic is faked at the readback seam, which is -- the only thing between this and the pixels the engine would have blitted. local lastCanvas = nil -- what the readback asked newCanvas for -local function fill(rows) +-- '#' is ink and '.' the keyed-out nothing. 'W' is ink too, of the pic's +-- LIGHTEST shade -- a highlight the decoder happened not to key -- which is +-- what the paper a hole gets filled with is read off. +local SHADE = { ["#"] = 0.25, ["W"] = 0.75 } +-- reuse hands the SAME pic back through, which is how the two bottom rules +-- can be asked of one image the way a running battle would ask them +local function fill(rows, sealBottom, reuse) local W, H = #rows[1], #rows local built = nil local function fakeData() - local px = {} + local px, sh = {}, {} for y = 0, H - 1 do for x = 0, W - 1 do - px[y * W + x] = rows[y + 1]:sub(x + 1, x + 1) == "#" and 1 or 0 + local shade = SHADE[rows[y + 1]:sub(x + 1, x + 1)] + px[y * W + x] = shade and 1 or 0 + sh[y * W + x] = shade or 0 end end return { - px = px, + px = px, sh = sh, getDimensions = function() return W, H end, - getPixel = function(self, x, y) return 0, 0, 0, self.px[y * W + x] end, - setPixel = function(self, x, y, r, g, b, a) self.px[y * W + x] = a end, + getPixel = function(self, x, y) + local k = y * W + x + return self.sh[k], self.sh[k], self.sh[k], self.px[k] + end, + setPixel = function(self, x, y, r, g, b, a) + self.px[y * W + x] = a + self.sh[y * W + x] = r + end, } end @@ -2639,12 +2681,14 @@ local function fill(rows) built = data return { setFilter = function() end } end - local pic = { getDimensions = function() return W, H end } - local out = BattlePics.filled(pic) + local pic = reuse or { getDimensions = function() return W, H end } + local out = BattlePics.filled(pic, sealBottom) love.graphics.newCanvas, love.graphics.newImage = realNewCanvas, realNewImage -- deliberately NOT invalidated: each figure brings its own pic, and the -- cache check at the bottom needs one of them still in there - return out, pic, built and function(x, y) return built.px[y * W + x] > 0.5 end + return out, pic, + built and function(x, y) return built.px[y * W + x] > 0.5 end, + built and function(x, y) return built.sh[y * W + x] end end -- ------- the cut at the feet, which is what the closed bottom edge is for @@ -2727,6 +2771,75 @@ local drainOut, drainPic, drain = fill({ T.check(drainOut ~= drainPic and drain and drain(8, 4), "a narrow one is where the drawing ran out, and is paper") +-- ------- but a pic ON THE MENU has no mouth at all +-- +-- The drain/mouth cut is for a pic standing on the MAP, where a wide opening +-- along the bottom is a stride with real ground behind it. Under BACK SPRITES +-- the player's mon is drawn in the GB's own slot with its feet flush on the +-- text box, and the only thing under its lowest row is white box -- so nothing +-- reaches it from below, whatever the opening's width, and the rule stops +-- being a heuristic: paper is whatever the background cannot walk to from the +-- left, the right or the top. +-- +-- Which is the difference between a Pikachu and a wireframe. The pale-bodied +-- back pics -- Pikachu, Seel, Dewgong, Chansey, Jigglypuff -- are drawn as +-- OUTLINES, every shade-0 pixel inside the ink keyed away, and each of them +-- leaks out through a bottom opening far too wide to read as a drain. On the +-- map that reading is right; on the box it left the mon a rim with the arena +-- showing through it. +-- +-- The same stride figure the map rule leaves open, now standing on the box. +local boxOut, boxPic, boxOpaque = fill({ + "..##############..", + "..##############..", + "..##############..", + "..###........###..", + "..###........###..", + "..###........###..", +}, true) +T.check(boxOut ~= boxPic and boxOpaque, + "the gap a stride would have shown the world through is paper on the box") +T.check(boxOpaque(8, 3) and boxOpaque(8, 5), + "and it fills right down to the row the feet are on") + +-- the seal is the BOTTOM alone: the sides and the top still let the background +-- in, which is what keeps the silhouette cutting against the arena instead of +-- standing the mon in a white block +local boxGapOut, boxGapPic = fill({ + "..#####.", + "..#...#.", + "..#...#.", + "....###.", -- opens at the left, and drains out that way + "..#####.", + "..#####.", +}, true) +T.eq(boxGapOut, boxGapPic, + "a pocket that drains out to the side is background on the box too") + +-- ------- and a hole is filled with the pic's OWN paper, not with white +-- +-- Shade 0 is white only while the pic is still grays, and by the time one +-- reaches here it usually is not: picImage hands it over after the bake -- a +-- species SGB colour, a BGP fade mid-animation, PAL_BLACK across the whole +-- screen while the blackout text is up -- and shade 0 travels with the rest. +-- A hardcoded white belly would be the one lit thing on a blacked-out mon. +-- +-- So the paper is read off the pic: the lightest shade still standing in it, +-- which is shade 0 wherever the decoder could not reach one. It never has to +-- guess -- all 151 of this game's back pics keep at least one, an eye or a +-- highlight down a cheek. +local _, _, _, paperShade = fill({ + "..####..", + "..#WW#..", -- a highlight the decoder did not key: this is the paper + "..#..#..", + "..#..#..", + "..####..", +}) +T.eq(paperShade(3, 2), paperShade(3, 1), + "the hole takes the lightest shade the pic still has") +T.check(paperShade(3, 2) ~= paperShade(2, 2), + "which is not the ink beside it") + -- ------- and the readback is measured in PIXELS, which is what kept the mons -- the size of the squares they stand on -- @@ -2743,12 +2856,31 @@ T.check(drainOut ~= drainPic and drain and drain(8, 4), -- scale that was wrong everywhere. T.check(lastCanvas and lastCanvas.opts and lastCanvas.opts.dpiscale == 1, "the readback canvas is one texel per pic pixel, on a highdpi phone too") -T.eq(lastCanvas.w, 18, "and it is the size of the pic, in those pixels") +T.eq(lastCanvas.w, 8, "and it is the size of the pic, in those pixels") -- the answer is cached on the image, so a pic costs one readback a session -- rather than one a frame -- checked on the first figure, which is still in -- there because fill() does not clear it T.eq(BattlePics.filled(pic), out, "the rebuilt pic is cached on the original") +-- and cached PER BOTTOM RULE, because one image answers differently on the map +-- and on the box. A single table for both would hand whichever caller asked +-- second the other one's answer -- the map's stencil to the menu, or a menu +-- fill to a mon standing on grass -- which is this section's bug arriving +-- through the cache rather than through the flood. +-- +-- The stride figure again, on the SAME pic the map rule already answered for. +local reOut = fill({ + "..##############..", + "..##############..", + "..##############..", + "..###........###..", + "..###........###..", + "..###........###..", +}, true, stridePic) +T.check(reOut ~= stridePic, + "the pic the map left open still fills when the box asks for it") +T.eq(BattlePics.filled(stridePic), stridePic, + "and the map's own answer for it is still the pic itself") BattlePics.invalidate() end From cdafcf76d67132e16da0334d776d29bc8677cf0d Mon Sep 17 00:00:00 2001 From: absol89 Date: Sat, 1 Aug 2026 21:29:13 +0200 Subject: [PATCH 011/116] Port native-resolution animated atlas decoder --- CHANGELOG.md | 5 + README.md | 6 +- assets/battle/back-animated/README.md | 14 +- assets/battle/front-animated/README.md | 13 +- data/animated_battle_sprites.lua | 608 +++++++++++++++++++++++++ lib/AnimatedBattleArt.lua | 147 ++++++ lib/BattleArt.lua | 29 +- lib/OverworldBattle.lua | 8 + tools/import_animated_back_sprite.py | 263 +++++++++++ 9 files changed, 1081 insertions(+), 12 deletions(-) create mode 100644 data/animated_battle_sprites.lua create mode 100644 lib/AnimatedBattleArt.lua create mode 100644 tools/import_animated_back_sprite.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8702180..895ca41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,11 @@ - **Static trainer cards.** Opponent trainer classes resolve from `front-static`; the player, Professor Oak, and Old Man backs resolve from `back-static`. Trainer art never uses either animated folder. +- **Native-resolution animated atlas playback.** `BATTLE ART: ANIMATED` + extracts timed PNG-atlas cells through `ImageData`, avoiding canvas DPI + scaling, and sends every frame through the same transparency, display + filtering, world lighting, depth and shadow path as static art. Missing or + malformed atlases fall back per Pokemon to ROM art. ## 1.3.0 diff --git a/README.md b/README.md index 0af1cf7..7175c7e 100644 --- a/README.md +++ b/README.md @@ -61,8 +61,10 @@ art. Missing or unreadable files fall straight back to the ROM art. The four battle-art directories are intentionally ignored by Git except for their README contracts. `tools/package_mod.ps1` nevertheless includes local PNGs from them in a test ZIP, so artwork can stay private and uncommitted. -Animated atlas decoding is being added behind the stable `ANIMATED` option; -until an atlas is recognised, that selection safely uses ROM art. +`BATTLE ART: ANIMATED` reads the PNG atlases described by +`data/animated_battle_sprites.lua`. The authoring importer generates both; +the runtime extracts native-resolution cells without a DPI-scaled canvas and +falls back per missing or malformed atlas to ROM art. Trainer cards are always static. Opponent trainer fronts use class filenames in `front-static` (for example `youngster.png`, `cooltrainer-f.png`, and diff --git a/assets/battle/back-animated/README.md b/assets/battle/back-animated/README.md index 4b7765a..17fe272 100644 --- a/assets/battle/back-animated/README.md +++ b/assets/battle/back-animated/README.md @@ -1,8 +1,16 @@ # Back animated battle art -Reserved for optional animated player-back atlases. Local PNG files are -ignored by Git. Authored back art faces right and is never mirrored. Until an -atlas is recognized, BATTLE ART: ANIMATED falls back to the ROM. +Optional animated player-back battle atlases go here. Local PNG files are +ignored by Git. Authored back art faces right and is never mirrored. The +decoder expects the atlas cell dimensions and frame timings recorded in +`data/animated_battle_sprites.lua`; the supplied metadata recognizes the +atlases produced by `tools/import_animated_back_sprite.py`. Missing or +malformed art falls back to the ROM sprite for that Pokemon. + +GIF decoding is authoring-only. The game reads PNG atlases, extracts every +cell at its native logical resolution, and uses nearest-neighbour filtering. +The importer creates both the atlases and their single shared metadata file; +users do not write one Lua file per Pokemon. ## Gen 1 filename exceptions diff --git a/assets/battle/front-animated/README.md b/assets/battle/front-animated/README.md index 583829f..06d0a9b 100644 --- a/assets/battle/front-animated/README.md +++ b/assets/battle/front-animated/README.md @@ -1,7 +1,16 @@ # Front animated battle art -Reserved for optional animated battle atlases. Local PNG files are ignored by -Git. Until an atlas is recognized, BATTLE ART: ANIMATED falls back to the ROM. +Optional animated opponent/player-front battle atlases go here. Local PNG +files are ignored by Git. The decoder expects the atlas cell dimensions and +frame timings recorded in `data/animated_battle_sprites.lua`; the supplied +metadata recognizes the atlases produced by +`tools/import_animated_back_sprite.py`. Missing or malformed art falls back to +the ROM sprite for that Pokemon. + +GIF decoding is authoring-only. The game reads PNG atlases, extracts every +cell at its native logical resolution, and uses nearest-neighbour filtering. +The importer creates both the atlases and their single shared metadata file; +users do not write one Lua file per Pokemon. ## Gen 1 filename exceptions diff --git a/data/animated_battle_sprites.lua b/data/animated_battle_sprites.lua new file mode 100644 index 0000000..02434c5 --- /dev/null +++ b/data/animated_battle_sprites.lua @@ -0,0 +1,608 @@ +-- Generated by tools/import_animated_back_sprite.py. +-- Black/White animated front and back sprites, original 151. +return { + BULBASAUR = { + front = { image = "assets/battle/front-animated/bulbasaur.png", width = 37, height = 38, columns = 16, frames = 99, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/bulbasaur.png", width = 33, height = 38, columns = 16, frames = 99, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + IVYSAUR = { + front = { image = "assets/battle/front-animated/ivysaur.png", width = 58, height = 51, columns = 16, frames = 111, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/ivysaur.png", width = 57, height = 45, columns = 16, frames = 111, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + VENUSAUR = { + front = { image = "assets/battle/front-animated/venusaur.png", width = 86, height = 71, columns = 16, frames = 167, durations = {100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,100} }, + back = { image = "assets/battle/back-animated/venusaur.png", width = 89, height = 63, columns = 16, frames = 167, durations = {100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,100} }, + }, + CHARMANDER = { + front = { image = "assets/battle/front-animated/charmander.png", width = 42, height = 42, columns = 16, frames = 107, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/charmander.png", width = 43, height = 44, columns = 16, frames = 107, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + CHARMELEON = { + front = { image = "assets/battle/front-animated/charmeleon.png", width = 69, height = 56, columns = 16, frames = 89, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/charmeleon.png", width = 67, height = 60, columns = 16, frames = 89, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + CHARIZARD = { + front = { image = "assets/battle/front-animated/charizard.png", width = 89, height = 91, columns = 16, frames = 143, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/charizard.png", width = 98, height = 83, columns = 16, frames = 143, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + SQUIRTLE = { + front = { image = "assets/battle/front-animated/squirtle.png", width = 39, height = 43, columns = 16, frames = 51, durations = {50,50,50,50,50,50,50,100,50,50,50,50,50,50,150,50,50,50,50,50,50,100,50,50,50,50,50,50,350,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,800} }, + back = { image = "assets/battle/back-animated/squirtle.png", width = 40, height = 45, columns = 16, frames = 51, durations = {50,50,50,50,50,50,50,100,50,50,50,50,50,50,150,50,50,50,50,50,50,100,50,50,50,50,50,50,350,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,800} }, + }, + WARTORTLE = { + front = { image = "assets/battle/front-animated/wartortle.png", width = 61, height = 57, columns = 16, frames = 87, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/wartortle.png", width = 57, height = 62, columns = 16, frames = 87, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + BLASTOISE = { + front = { image = "assets/battle/front-animated/blastoise.png", width = 69, height = 65, columns = 16, frames = 244, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + back = { image = "assets/battle/back-animated/blastoise.png", width = 66, height = 66, columns = 16, frames = 244, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + }, + CATERPIE = { + front = { image = "assets/battle/front-animated/caterpie.png", width = 36, height = 37, columns = 16, frames = 81, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/caterpie.png", width = 38, height = 35, columns = 16, frames = 81, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + METAPOD = { + front = { image = "assets/battle/front-animated/metapod.png", width = 36, height = 38, columns = 16, frames = 111, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/metapod.png", width = 42, height = 43, columns = 16, frames = 111, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + BUTTERFREE = { + front = { image = "assets/battle/front-animated/butterfree.png", width = 62, height = 57, columns = 16, frames = 89, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/butterfree.png", width = 64, height = 57, columns = 16, frames = 29, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + WEEDLE = { + front = { image = "assets/battle/front-animated/weedle.png", width = 37, height = 46, columns = 16, frames = 123, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/weedle.png", width = 43, height = 46, columns = 16, frames = 123, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + KAKUNA = { + front = { image = "assets/battle/front-animated/kakuna.png", width = 32, height = 40, columns = 16, frames = 79, durations = {100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,150} }, + back = { image = "assets/battle/back-animated/kakuna.png", width = 32, height = 40, columns = 16, frames = 79, durations = {100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,150} }, + }, + BEEDRILL = { + front = { image = "assets/battle/front-animated/beedrill.png", width = 74, height = 69, columns = 16, frames = 60, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + back = { image = "assets/battle/back-animated/beedrill.png", width = 76, height = 67, columns = 16, frames = 20, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + }, + PIDGEY = { + front = { image = "assets/battle/front-animated/pidgey.png", width = 43, height = 48, columns = 16, frames = 41, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,1950,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,450} }, + back = { image = "assets/battle/back-animated/pidgey.png", width = 45, height = 47, columns = 16, frames = 41, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,1950,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,450} }, + }, + PIDGEOTTO = { + front = { image = "assets/battle/front-animated/pidgeotto.png", width = 82, height = 116, columns = 16, frames = 35, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/pidgeotto.png", width = 95, height = 96, columns = 16, frames = 35, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + PIDGEOT = { + front = { image = "assets/battle/front-animated/pidgeot.png", width = 58, height = 68, columns = 16, frames = 71, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/pidgeot.png", width = 68, height = 66, columns = 16, frames = 68, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + RATTATA = { + front = { image = "assets/battle/front-animated/rattata.png", width = 43, height = 43, columns = 16, frames = 101, durations = {50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/rattata.png", width = 58, height = 42, columns = 16, frames = 99, durations = {50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,100} }, + }, + RATICATE = { + front = { image = "assets/battle/front-animated/raticate.png", width = 65, height = 51, columns = 16, frames = 119, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/raticate.png", width = 66, height = 52, columns = 16, frames = 119, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + SPEAROW = { + front = { image = "assets/battle/front-animated/spearow.png", width = 33, height = 39, columns = 16, frames = 61, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/spearow.png", width = 35, height = 40, columns = 16, frames = 61, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + FEAROW = { + front = { image = "assets/battle/front-animated/fearow.png", width = 82, height = 105, columns = 16, frames = 21, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/fearow.png", width = 79, height = 112, columns = 16, frames = 21, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + EKANS = { + front = { image = "assets/battle/front-animated/ekans.png", width = 59, height = 46, columns = 16, frames = 71, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/ekans.png", width = 57, height = 49, columns = 16, frames = 71, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + ARBOK = { + front = { image = "assets/battle/front-animated/arbok.png", width = 81, height = 74, columns = 16, frames = 74, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/arbok.png", width = 86, height = 72, columns = 16, frames = 73, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + PIKACHU = { + front = { image = "assets/battle/front-animated/pikachu.png", width = 50, height = 46, columns = 16, frames = 112, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,100,100} }, + back = { image = "assets/battle/back-animated/pikachu.png", width = 41, height = 47, columns = 16, frames = 113, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,200} }, + }, + RAICHU = { + front = { image = "assets/battle/front-animated/raichu.png", width = 74, height = 73, columns = 16, frames = 167, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/raichu.png", width = 76, height = 72, columns = 16, frames = 167, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + SANDSHREW = { + front = { image = "assets/battle/front-animated/sandshrew.png", width = 35, height = 39, columns = 16, frames = 123, durations = {50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50} }, + back = { image = "assets/battle/back-animated/sandshrew.png", width = 41, height = 38, columns = 16, frames = 123, durations = {50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50} }, + }, + SANDSLASH = { + front = { image = "assets/battle/front-animated/sandslash.png", width = 59, height = 53, columns = 16, frames = 111, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,300} }, + back = { image = "assets/battle/back-animated/sandslash.png", width = 57, height = 52, columns = 16, frames = 111, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,300} }, + }, + NIDORAN_F = { + front = { image = "assets/battle/front-animated/nidoran-f.png", width = 36, height = 34, columns = 16, frames = 89, durations = {150,50,100,50,100,50,150,50,250,50,150,50,100,50,100,50,300,50,100,50,150,50,100,50,300,50,100,50,100,50,150,50,250,50,150,50,100,50,100,50,300,50,100,50,150,50,100,50,300,50,100,50,100,50,150,50,250,50,150,50,100,50,100,50,300,50,100,50,50,50,50,50,50,50,50,100,50,100,50,50,50,50,50,100,50,100,50,50,150} }, + back = { image = "assets/battle/back-animated/nidoran-f.png", width = 33, height = 36, columns = 16, frames = 82, durations = {150,50,100,50,100,50,150,50,250,50,150,50,100,50,100,50,300,50,100,50,150,50,100,50,300,50,100,50,100,50,150,50,250,50,150,50,100,50,100,50,300,50,100,50,150,50,100,50,300,50,100,50,100,50,150,50,250,50,150,50,100,50,100,50,300,50,100,50,150,50,100,50,150,150,50,100,50,100,50,150,50,150} }, + }, + NIDORINA = { + front = { image = "assets/battle/front-animated/nidorina.png", width = 52, height = 47, columns = 16, frames = 49, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/nidorina.png", width = 52, height = 44, columns = 16, frames = 49, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + NIDOQUEEN = { + front = { image = "assets/battle/front-animated/nidoqueen.png", width = 76, height = 69, columns = 16, frames = 123, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/nidoqueen.png", width = 85, height = 72, columns = 16, frames = 121, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + NIDORAN_M = { + front = { image = "assets/battle/front-animated/nidoran-m.png", width = 37, height = 37, columns = 16, frames = 121, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/nidoran-m.png", width = 37, height = 43, columns = 16, frames = 121, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + NIDORINO = { + front = { image = "assets/battle/front-animated/nidorino.png", width = 52, height = 56, columns = 16, frames = 99, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/nidorino.png", width = 55, height = 55, columns = 16, frames = 99, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + NIDOKING = { + front = { image = "assets/battle/front-animated/nidoking.png", width = 86, height = 71, columns = 16, frames = 97, durations = {100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,200,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,200,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,200,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/nidoking.png", width = 89, height = 69, columns = 16, frames = 105, durations = {100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + CLEFAIRY = { + front = { image = "assets/battle/front-animated/clefairy.png", width = 47, height = 42, columns = 16, frames = 133, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50} }, + back = { image = "assets/battle/back-animated/clefairy.png", width = 54, height = 43, columns = 16, frames = 132, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50} }, + }, + CLEFABLE = { + front = { image = "assets/battle/front-animated/clefable.png", width = 55, height = 52, columns = 16, frames = 102, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/clefable.png", width = 55, height = 51, columns = 16, frames = 102, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + VULPIX = { + front = { image = "assets/battle/front-animated/vulpix.png", width = 59, height = 49, columns = 16, frames = 71, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/vulpix.png", width = 61, height = 52, columns = 16, frames = 71, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + NINETALES = { + front = { image = "assets/battle/front-animated/ninetales.png", width = 76, height = 73, columns = 16, frames = 53, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/ninetales.png", width = 76, height = 64, columns = 16, frames = 107, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + JIGGLYPUFF = { + front = { image = "assets/battle/front-animated/jigglypuff.png", width = 49, height = 45, columns = 16, frames = 111, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/jigglypuff.png", width = 46, height = 48, columns = 16, frames = 109, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + WIGGLYTUFF = { + front = { image = "assets/battle/front-animated/wigglytuff.png", width = 49, height = 82, columns = 16, frames = 109, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,200,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,100,50,100,50,100,50,100,50,100,50,50,50,50,50,50,50,50,50,100,100} }, + back = { image = "assets/battle/back-animated/wigglytuff.png", width = 47, height = 83, columns = 16, frames = 109, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,200,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,100,50,100,50,100,50,100,50,50,50,50,50,50,50,50,50,50,50,50,200} }, + }, + ZUBAT = { + front = { image = "assets/battle/front-animated/zubat.png", width = 53, height = 46, columns = 16, frames = 54, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + back = { image = "assets/battle/back-animated/zubat.png", width = 49, height = 59, columns = 16, frames = 54, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + }, + GOLBAT = { + front = { image = "assets/battle/front-animated/golbat.png", width = 111, height = 83, columns = 16, frames = 16, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + back = { image = "assets/battle/back-animated/golbat.png", width = 95, height = 84, columns = 16, frames = 16, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + }, + ODDISH = { + front = { image = "assets/battle/front-animated/oddish.png", width = 38, height = 50, columns = 16, frames = 109, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/oddish.png", width = 41, height = 51, columns = 16, frames = 109, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + GLOOM = { + front = { image = "assets/battle/front-animated/gloom.png", width = 51, height = 45, columns = 16, frames = 95, durations = {100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gloom.png", width = 49, height = 46, columns = 16, frames = 95, durations = {100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,100} }, + }, + VILEPLUME = { + front = { image = "assets/battle/front-animated/vileplume.png", width = 56, height = 50, columns = 16, frames = 71, durations = {50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/vileplume.png", width = 56, height = 48, columns = 16, frames = 71, durations = {50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,100} }, + }, + PARAS = { + front = { image = "assets/battle/front-animated/paras.png", width = 48, height = 38, columns = 16, frames = 73, durations = {50,50,50,50,50,50,150,50,50,50,50,50,150,50,50,50,50,50,150,50,50,50,50,50,150,50,50,50,50,50,150,50,50,50,50,50,150,50,50,50,50,50,150,50,50,50,50,50,150,50,50,50,50,50,150,50,50,50,50,50,150,50,50,50,50,50,150,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/paras.png", width = 48, height = 32, columns = 16, frames = 74, durations = {50,50,50,50,50,50,150,50,50,50,50,50,150,50,50,50,50,50,150,50,50,50,50,50,150,50,50,50,50,50,150,50,50,50,50,50,150,50,50,50,50,50,150,50,50,50,50,50,100,50,50,50,50,50,50,150,50,50,50,50,50,150,50,50,50,50,50,150,50,50,50,50,50,100} }, + }, + PARASECT = { + front = { image = "assets/battle/front-animated/parasect.png", width = 62, height = 63, columns = 16, frames = 71, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/parasect.png", width = 70, height = 52, columns = 16, frames = 65, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + VENONAT = { + front = { image = "assets/battle/front-animated/venonat.png", width = 42, height = 53, columns = 16, frames = 95, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/venonat.png", width = 42, height = 52, columns = 16, frames = 95, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + VENOMOTH = { + front = { image = "assets/battle/front-animated/venomoth.png", width = 75, height = 74, columns = 16, frames = 21, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + back = { image = "assets/battle/back-animated/venomoth.png", width = 83, height = 73, columns = 16, frames = 21, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + }, + DIGLETT = { + front = { image = "assets/battle/front-animated/diglett.png", width = 38, height = 31, columns = 16, frames = 108, durations = {50,50,50,50,50,50,150,50,700,100,100,100,100,50,50,50,50,50,50,50,50,150,50,700,200,100,100,50,50,50,50,50,50,150,50,700,100,100,100,100,50,50,50,50,50,50,50,50,150,50,700,200,100,100,100,100,100,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,100,100,100,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/diglett.png", width = 38, height = 32, columns = 16, frames = 108, durations = {50,50,50,50,50,50,150,50,700,100,100,100,100,50,50,50,50,50,50,50,50,150,50,600,200,100,200,50,50,50,50,50,50,150,50,700,100,100,100,100,50,50,50,50,50,50,50,50,150,50,600,200,100,200,100,100,100,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,100,100,100,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + DUGTRIO = { + front = { image = "assets/battle/front-animated/dugtrio.png", width = 52, height = 41, columns = 16, frames = 83, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/dugtrio.png", width = 52, height = 42, columns = 16, frames = 83, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + MEOWTH = { + front = { image = "assets/battle/front-animated/meowth.png", width = 48, height = 65, columns = 16, frames = 139, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/meowth.png", width = 58, height = 64, columns = 16, frames = 139, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + PERSIAN = { + front = { image = "assets/battle/front-animated/persian.png", width = 73, height = 62, columns = 16, frames = 107, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/persian.png", width = 78, height = 56, columns = 16, frames = 107, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + PSYDUCK = { + front = { image = "assets/battle/front-animated/psyduck.png", width = 38, height = 46, columns = 16, frames = 44, durations = {150,50,100,50,100,50,150,50,100,50,150,450,50,150,50,100,50,150,450,50,150,50,100,50,100,50,150,50,100,50,100,50,150,50,100,50,150,450,50,150,50,100,50,150} }, + back = { image = "assets/battle/back-animated/psyduck.png", width = 37, height = 44, columns = 16, frames = 46, durations = {50,50,100,50,150,50,50,50,50,50,50,50,100,100,50,450,50,150,50,150,100,50,400,50,50,50,50,50,50,50,100,50,50,50,50,50,350,50,150,50,150,100,50,450,50,200} }, + }, + GOLDUCK = { + front = { image = "assets/battle/front-animated/golduck.png", width = 60, height = 59, columns = 16, frames = 76, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/golduck.png", width = 55, height = 57, columns = 16, frames = 81, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + MANKEY = { + front = { image = "assets/battle/front-animated/mankey.png", width = 66, height = 61, columns = 16, frames = 120, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + back = { image = "assets/battle/back-animated/mankey.png", width = 62, height = 59, columns = 16, frames = 120, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + }, + PRIMEAPE = { + front = { image = "assets/battle/front-animated/primeape.png", width = 66, height = 60, columns = 16, frames = 56, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + back = { image = "assets/battle/back-animated/primeape.png", width = 72, height = 58, columns = 16, frames = 51, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,200} }, + }, + GROWLITHE = { + front = { image = "assets/battle/front-animated/growlithe.png", width = 55, height = 52, columns = 16, frames = 141, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/growlithe.png", width = 57, height = 57, columns = 16, frames = 135, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + ARCANINE = { + front = { image = "assets/battle/front-animated/arcanine.png", width = 77, height = 69, columns = 16, frames = 71, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/arcanine.png", width = 86, height = 69, columns = 16, frames = 71, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + POLIWAG = { + front = { image = "assets/battle/front-animated/poliwag.png", width = 54, height = 34, columns = 16, frames = 79, durations = {100,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + back = { image = "assets/battle/back-animated/poliwag.png", width = 55, height = 35, columns = 16, frames = 79, durations = {100,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + }, + POLIWHIRL = { + front = { image = "assets/battle/front-animated/poliwhirl.png", width = 74, height = 55, columns = 16, frames = 107, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/poliwhirl.png", width = 80, height = 57, columns = 16, frames = 107, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + POLIWRATH = { + front = { image = "assets/battle/front-animated/poliwrath.png", width = 81, height = 58, columns = 16, frames = 89, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/poliwrath.png", width = 74, height = 57, columns = 16, frames = 89, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + ABRA = { + front = { image = "assets/battle/front-animated/abra.png", width = 63, height = 53, columns = 16, frames = 113, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/abra.png", width = 71, height = 56, columns = 16, frames = 111, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + KADABRA = { + front = { image = "assets/battle/front-animated/kadabra.png", width = 79, height = 60, columns = 16, frames = 119, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/kadabra.png", width = 80, height = 66, columns = 16, frames = 121, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,50,50,50,50,50,50,100} }, + }, + ALAKAZAM = { + front = { image = "assets/battle/front-animated/alakazam.png", width = 93, height = 70, columns = 16, frames = 105, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,350,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/alakazam.png", width = 82, height = 67, columns = 16, frames = 105, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,350,50,50,50,50,50,50,50,50,50,100} }, + }, + MACHOP = { + front = { image = "assets/battle/front-animated/machop.png", width = 51, height = 50, columns = 16, frames = 149, durations = {50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/machop.png", width = 53, height = 51, columns = 16, frames = 149, durations = {50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + MACHOKE = { + front = { image = "assets/battle/front-animated/machoke.png", width = 60, height = 71, columns = 16, frames = 98, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,450,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,450,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,450,50,100,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/machoke.png", width = 61, height = 71, columns = 16, frames = 95, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,550,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,550,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,550,50,50,50,50,50,100} }, + }, + MACHAMP = { + front = { image = "assets/battle/front-animated/machamp.png", width = 64, height = 76, columns = 16, frames = 59, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,750,50,50,50,50,50,50,50,50,50,50,50,50,50,350,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/machamp.png", width = 67, height = 76, columns = 16, frames = 59, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,750,50,50,50,50,50,50,50,50,50,50,50,50,50,350,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + BELLSPROUT = { + front = { image = "assets/battle/front-animated/bellsprout.png", width = 41, height = 44, columns = 16, frames = 95, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,150,50,50,50,50,50,50,100,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + back = { image = "assets/battle/back-animated/bellsprout.png", width = 45, height = 51, columns = 16, frames = 95, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,150,50,50,50,50,50,50,100,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + }, + WEEPINBELL = { + front = { image = "assets/battle/front-animated/weepinbell.png", width = 69, height = 51, columns = 16, frames = 97, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/weepinbell.png", width = 72, height = 54, columns = 16, frames = 97, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + VICTREEBEL = { + front = { image = "assets/battle/front-animated/victreebel.png", width = 65, height = 71, columns = 16, frames = 71, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/victreebel.png", width = 69, height = 70, columns = 16, frames = 71, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + TENTACOOL = { + front = { image = "assets/battle/front-animated/tentacool.png", width = 60, height = 71, columns = 16, frames = 95, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/tentacool.png", width = 62, height = 76, columns = 16, frames = 95, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + TENTACRUEL = { + front = { image = "assets/battle/front-animated/tentacruel.png", width = 84, height = 84, columns = 16, frames = 80, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + back = { image = "assets/battle/back-animated/tentacruel.png", width = 84, height = 85, columns = 16, frames = 80, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + }, + GEODUDE = { + front = { image = "assets/battle/front-animated/geodude.png", width = 61, height = 45, columns = 16, frames = 103, durations = {50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/geodude.png", width = 58, height = 51, columns = 16, frames = 106, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + GRAVELER = { + front = { image = "assets/battle/front-animated/graveler.png", width = 71, height = 44, columns = 16, frames = 47, durations = {50,50,50,50,50,50,50,50,300,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,300,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,250,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/graveler.png", width = 65, height = 43, columns = 16, frames = 47, durations = {50,50,50,50,50,50,50,50,300,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,300,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,250,50,50,50,50,50,100} }, + }, + GOLEM = { + front = { image = "assets/battle/front-animated/golem.png", width = 72, height = 57, columns = 16, frames = 91, durations = {50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/golem.png", width = 69, height = 56, columns = 16, frames = 91, durations = {50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + PONYTA = { + front = { image = "assets/battle/front-animated/ponyta.png", width = 57, height = 57, columns = 16, frames = 91, durations = {100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/ponyta.png", width = 59, height = 59, columns = 16, frames = 91, durations = {100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + RAPIDASH = { + front = { image = "assets/battle/front-animated/rapidash.png", width = 74, height = 73, columns = 16, frames = 47, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/rapidash.png", width = 76, height = 76, columns = 16, frames = 48, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + }, + SLOWPOKE = { + front = { image = "assets/battle/front-animated/slowpoke.png", width = 64, height = 53, columns = 16, frames = 89, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/slowpoke.png", width = 68, height = 50, columns = 16, frames = 89, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + SLOWBRO = { + front = { image = "assets/battle/front-animated/slowbro.png", width = 63, height = 60, columns = 16, frames = 113, durations = {100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,250,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,100,50,200,50,100,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,100} }, + back = { image = "assets/battle/back-animated/slowbro.png", width = 76, height = 58, columns = 16, frames = 113, durations = {100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,250,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,100,50,200,50,100,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,100} }, + }, + MAGNEMITE = { + front = { image = "assets/battle/front-animated/magnemite.png", width = 38, height = 32, columns = 16, frames = 105, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/magnemite.png", width = 36, height = 31, columns = 16, frames = 105, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + MAGNETON = { + front = { image = "assets/battle/front-animated/magneton.png", width = 68, height = 59, columns = 16, frames = 143, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/magneton.png", width = 73, height = 58, columns = 16, frames = 143, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + FARFETCHD = { + front = { image = "assets/battle/front-animated/farfetchd.png", width = 45, height = 48, columns = 16, frames = 65, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,400,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,350} }, + back = { image = "assets/battle/back-animated/farfetchd.png", width = 46, height = 50, columns = 16, frames = 65, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,400,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,350} }, + }, + DODUO = { + front = { image = "assets/battle/front-animated/doduo.png", width = 60, height = 53, columns = 16, frames = 189, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + back = { image = "assets/battle/back-animated/doduo.png", width = 61, height = 56, columns = 16, frames = 189, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + }, + DODRIO = { + front = { image = "assets/battle/front-animated/dodrio.png", width = 70, height = 76, columns = 16, frames = 155, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/dodrio.png", width = 72, height = 76, columns = 16, frames = 155, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + SEEL = { + front = { image = "assets/battle/front-animated/seel.png", width = 62, height = 47, columns = 16, frames = 49, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/seel.png", width = 60, height = 43, columns = 16, frames = 49, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + DEWGONG = { + front = { image = "assets/battle/front-animated/dewgong.png", width = 75, height = 74, columns = 16, frames = 121, durations = {200,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,200,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,150,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,300,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,100} }, + back = { image = "assets/battle/back-animated/dewgong.png", width = 77, height = 88, columns = 16, frames = 121, durations = {200,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,200,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,150,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,300,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,100} }, + }, + GRIMER = { + front = { image = "assets/battle/front-animated/grimer.png", width = 73, height = 42, columns = 16, frames = 116, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/grimer.png", width = 70, height = 46, columns = 16, frames = 113, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + MUK = { + front = { image = "assets/battle/front-animated/muk.png", width = 94, height = 68, columns = 16, frames = 103, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/muk.png", width = 100, height = 81, columns = 16, frames = 103, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + SHELLDER = { + front = { image = "assets/battle/front-animated/shellder.png", width = 42, height = 38, columns = 16, frames = 87, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/shellder.png", width = 48, height = 37, columns = 16, frames = 87, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + CLOYSTER = { + front = { image = "assets/battle/front-animated/cloyster.png", width = 74, height = 65, columns = 16, frames = 101, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/cloyster.png", width = 69, height = 68, columns = 16, frames = 101, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + GASTLY = { + front = { image = "assets/battle/front-animated/gastly.png", width = 67, height = 75, columns = 16, frames = 110, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + back = { image = "assets/battle/back-animated/gastly.png", width = 63, height = 78, columns = 16, frames = 110, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + }, + HAUNTER = { + front = { image = "assets/battle/front-animated/haunter.png", width = 85, height = 70, columns = 16, frames = 25, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/haunter.png", width = 90, height = 71, columns = 16, frames = 25, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + GENGAR = { + front = { image = "assets/battle/front-animated/gengar.png", width = 79, height = 65, columns = 16, frames = 88, durations = {50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + back = { image = "assets/battle/back-animated/gengar.png", width = 73, height = 64, columns = 16, frames = 88, durations = {50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + }, + ONIX = { + front = { image = "assets/battle/front-animated/onix.png", width = 74, height = 74, columns = 16, frames = 99, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/onix.png", width = 98, height = 70, columns = 16, frames = 143, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + DROWZEE = { + front = { image = "assets/battle/front-animated/drowzee.png", width = 53, height = 58, columns = 16, frames = 153, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/drowzee.png", width = 56, height = 56, columns = 16, frames = 153, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + HYPNO = { + front = { image = "assets/battle/front-animated/hypno.png", width = 66, height = 62, columns = 16, frames = 125, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/hypno.png", width = 65, height = 60, columns = 16, frames = 123, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + KRABBY = { + front = { image = "assets/battle/front-animated/krabby.png", width = 55, height = 39, columns = 16, frames = 73, durations = {50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/krabby.png", width = 55, height = 43, columns = 16, frames = 73, durations = {50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + KINGLER = { + front = { image = "assets/battle/front-animated/kingler.png", width = 82, height = 59, columns = 16, frames = 97, durations = {50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/kingler.png", width = 83, height = 62, columns = 16, frames = 97, durations = {50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + VOLTORB = { + front = { image = "assets/battle/front-animated/voltorb.png", width = 46, height = 31, columns = 16, frames = 71, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,450,150,500} }, + back = { image = "assets/battle/back-animated/voltorb.png", width = 47, height = 31, columns = 16, frames = 70, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,450,650} }, + }, + ELECTRODE = { + front = { image = "assets/battle/front-animated/electrode.png", width = 49, height = 52, columns = 16, frames = 49, durations = {50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,100,100,50,50,50,50,50,50,500,400,100,100,100,50,50,50,50,50,50,100,700} }, + back = { image = "assets/battle/back-animated/electrode.png", width = 49, height = 52, columns = 16, frames = 45, durations = {50,100,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,250,50,50,50,50,50,550,100,100,100,400,50,50,50,50,50,800} }, + }, + EXEGGCUTE = { + front = { image = "assets/battle/front-animated/exeggcute.png", width = 55, height = 44, columns = 16, frames = 53, durations = {450,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,450,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,450,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/exeggcute.png", width = 54, height = 40, columns = 16, frames = 53, durations = {450,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,450,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,450,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + EXEGGUTOR = { + front = { image = "assets/battle/front-animated/exeggutor.png", width = 79, height = 73, columns = 16, frames = 89, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/exeggutor.png", width = 76, height = 70, columns = 16, frames = 81, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + CUBONE = { + front = { image = "assets/battle/front-animated/cubone.png", width = 49, height = 43, columns = 16, frames = 157, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/cubone.png", width = 52, height = 41, columns = 16, frames = 157, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + MAROWAK = { + front = { image = "assets/battle/front-animated/marowak.png", width = 68, height = 59, columns = 16, frames = 136, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/marowak.png", width = 65, height = 53, columns = 16, frames = 133, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + HITMONLEE = { + front = { image = "assets/battle/front-animated/hitmonlee.png", width = 68, height = 78, columns = 16, frames = 112, durations = {50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50} }, + back = { image = "assets/battle/back-animated/hitmonlee.png", width = 62, height = 75, columns = 16, frames = 108, durations = {150,50,50,50,50,50,50,100,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,100,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,150,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50} }, + }, + HITMONCHAN = { + front = { image = "assets/battle/front-animated/hitmonchan.png", width = 34, height = 59, columns = 16, frames = 81, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/hitmonchan.png", width = 46, height = 56, columns = 16, frames = 81, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + LICKITUNG = { + front = { image = "assets/battle/front-animated/lickitung.png", width = 69, height = 52, columns = 16, frames = 70, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + back = { image = "assets/battle/back-animated/lickitung.png", width = 77, height = 53, columns = 16, frames = 72, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + }, + KOFFING = { + front = { image = "assets/battle/front-animated/koffing.png", width = 91, height = 59, columns = 16, frames = 87, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/koffing.png", width = 79, height = 64, columns = 16, frames = 87, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + WEEZING = { + front = { image = "assets/battle/front-animated/weezing.png", width = 86, height = 67, columns = 16, frames = 99, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/weezing.png", width = 87, height = 68, columns = 16, frames = 99, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + RHYHORN = { + front = { image = "assets/battle/front-animated/rhyhorn.png", width = 68, height = 54, columns = 16, frames = 157, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/rhyhorn.png", width = 73, height = 54, columns = 16, frames = 167, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + RHYDON = { + front = { image = "assets/battle/front-animated/rhydon.png", width = 78, height = 67, columns = 16, frames = 193, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/rhydon.png", width = 85, height = 68, columns = 16, frames = 193, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + CHANSEY = { + front = { image = "assets/battle/front-animated/chansey.png", width = 61, height = 47, columns = 16, frames = 116, durations = {50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + back = { image = "assets/battle/back-animated/chansey.png", width = 67, height = 47, columns = 16, frames = 116, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + }, + TANGELA = { + front = { image = "assets/battle/front-animated/tangela.png", width = 49, height = 42, columns = 16, frames = 63, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/tangela.png", width = 49, height = 42, columns = 16, frames = 63, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + KANGASKHAN = { + front = { image = "assets/battle/front-animated/kangaskhan.png", width = 79, height = 61, columns = 16, frames = 253, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/kangaskhan.png", width = 73, height = 66, columns = 16, frames = 253, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + HORSEA = { + front = { image = "assets/battle/front-animated/horsea.png", width = 34, height = 43, columns = 16, frames = 80, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + back = { image = "assets/battle/back-animated/horsea.png", width = 39, height = 40, columns = 16, frames = 80, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + }, + SEADRA = { + front = { image = "assets/battle/front-animated/seadra.png", width = 58, height = 56, columns = 16, frames = 64, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + back = { image = "assets/battle/back-animated/seadra.png", width = 64, height = 59, columns = 16, frames = 64, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + }, + GOLDEEN = { + front = { image = "assets/battle/front-animated/goldeen.png", width = 75, height = 57, columns = 16, frames = 114, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + back = { image = "assets/battle/back-animated/goldeen.png", width = 75, height = 58, columns = 16, frames = 114, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + }, + SEAKING = { + front = { image = "assets/battle/front-animated/seaking.png", width = 74, height = 66, columns = 16, frames = 96, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + back = { image = "assets/battle/back-animated/seaking.png", width = 96, height = 72, columns = 16, frames = 96, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + }, + STARYU = { + front = { image = "assets/battle/front-animated/staryu.png", width = 57, height = 49, columns = 16, frames = 79, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/staryu.png", width = 55, height = 52, columns = 16, frames = 79, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + STARMIE = { + front = { image = "assets/battle/front-animated/starmie.png", width = 65, height = 59, columns = 16, frames = 159, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/starmie.png", width = 56, height = 56, columns = 16, frames = 159, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + MR_MIME = { + front = { image = "assets/battle/front-animated/mr-mime.png", width = 57, height = 53, columns = 16, frames = 127, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/mr-mime.png", width = 55, height = 51, columns = 16, frames = 127, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + SCYTHER = { + front = { image = "assets/battle/front-animated/scyther.png", width = 55, height = 61, columns = 16, frames = 137, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/scyther.png", width = 69, height = 65, columns = 16, frames = 137, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + JYNX = { + front = { image = "assets/battle/front-animated/jynx.png", width = 63, height = 60, columns = 16, frames = 107, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/jynx.png", width = 62, height = 60, columns = 16, frames = 107, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + ELECTABUZZ = { + front = { image = "assets/battle/front-animated/electabuzz.png", width = 71, height = 65, columns = 16, frames = 95, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/electabuzz.png", width = 71, height = 63, columns = 16, frames = 95, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + MAGMAR = { + front = { image = "assets/battle/front-animated/magmar.png", width = 73, height = 61, columns = 16, frames = 65, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/magmar.png", width = 70, height = 59, columns = 16, frames = 65, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + PINSIR = { + front = { image = "assets/battle/front-animated/pinsir.png", width = 72, height = 62, columns = 16, frames = 143, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/pinsir.png", width = 70, height = 61, columns = 16, frames = 143, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + TAUROS = { + front = { image = "assets/battle/front-animated/tauros.png", width = 76, height = 59, columns = 16, frames = 79, durations = {100,50,100,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,100} }, + back = { image = "assets/battle/back-animated/tauros.png", width = 72, height = 54, columns = 16, frames = 81, durations = {100,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,100,50,100,50,50,50,50,50,50,50,50,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,100,50,100,50,50,50,50,50,50,50,50,50,50,50,100,50,100,50,100} }, + }, + MAGIKARP = { + front = { image = "assets/battle/front-animated/magikarp.png", width = 47, height = 63, columns = 16, frames = 120, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + back = { image = "assets/battle/back-animated/magikarp.png", width = 66, height = 58, columns = 16, frames = 117, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + }, + GYARADOS = { + front = { image = "assets/battle/front-animated/gyarados.png", width = 102, height = 84, columns = 16, frames = 87, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gyarados.png", width = 108, height = 82, columns = 16, frames = 87, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + LAPRAS = { + front = { image = "assets/battle/front-animated/lapras.png", width = 68, height = 71, columns = 16, frames = 65, durations = {100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,200,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,200,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,200,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,100} }, + back = { image = "assets/battle/back-animated/lapras.png", width = 76, height = 71, columns = 16, frames = 65, durations = {100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,200,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,200,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,200,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,100} }, + }, + DITTO = { + front = { image = "assets/battle/front-animated/ditto.png", width = 47, height = 32, columns = 16, frames = 80, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + back = { image = "assets/battle/back-animated/ditto.png", width = 47, height = 32, columns = 16, frames = 80, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + }, + EEVEE = { + front = { image = "assets/battle/front-animated/eevee.png", width = 49, height = 47, columns = 16, frames = 125, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/eevee.png", width = 49, height = 50, columns = 16, frames = 133, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + VAPOREON = { + front = { image = "assets/battle/front-animated/vaporeon.png", width = 50, height = 59, columns = 16, frames = 101, durations = {100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,250,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,250,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,250,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,150} }, + back = { image = "assets/battle/back-animated/vaporeon.png", width = 50, height = 56, columns = 16, frames = 107, durations = {100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,150} }, + }, + JOLTEON = { + front = { image = "assets/battle/front-animated/jolteon.png", width = 48, height = 49, columns = 16, frames = 99, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/jolteon.png", width = 49, height = 48, columns = 16, frames = 99, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + FLAREON = { + front = { image = "assets/battle/front-animated/flareon.png", width = 66, height = 60, columns = 16, frames = 119, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/flareon.png", width = 70, height = 59, columns = 16, frames = 119, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + PORYGON = { + front = { image = "assets/battle/front-animated/porygon.png", width = 56, height = 52, columns = 16, frames = 60, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + back = { image = "assets/battle/back-animated/porygon.png", width = 62, height = 47, columns = 16, frames = 53, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,150,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,300,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + }, + OMANYTE = { + front = { image = "assets/battle/front-animated/omanyte.png", width = 43, height = 45, columns = 16, frames = 68, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + back = { image = "assets/battle/back-animated/omanyte.png", width = 48, height = 48, columns = 16, frames = 68, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + }, + OMASTAR = { + front = { image = "assets/battle/front-animated/omastar.png", width = 63, height = 60, columns = 16, frames = 155, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/omastar.png", width = 65, height = 63, columns = 16, frames = 155, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + KABUTO = { + front = { image = "assets/battle/front-animated/kabuto.png", width = 39, height = 41, columns = 16, frames = 67, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/kabuto.png", width = 38, height = 42, columns = 16, frames = 67, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + KABUTOPS = { + front = { image = "assets/battle/front-animated/kabutops.png", width = 65, height = 61, columns = 16, frames = 47, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + back = { image = "assets/battle/back-animated/kabutops.png", width = 67, height = 55, columns = 16, frames = 47, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + }, + AERODACTYL = { + front = { image = "assets/battle/front-animated/aerodactyl.png", width = 93, height = 81, columns = 16, frames = 17, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + back = { image = "assets/battle/back-animated/aerodactyl.png", width = 91, height = 91, columns = 16, frames = 17, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + }, + SNORLAX = { + front = { image = "assets/battle/front-animated/snorlax.png", width = 75, height = 74, columns = 16, frames = 173, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/snorlax.png", width = 75, height = 71, columns = 16, frames = 173, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + ARTICUNO = { + front = { image = "assets/battle/front-animated/articuno.png", width = 72, height = 81, columns = 16, frames = 51, durations = {150,50,50,50,50,50,50,50,50,50,50,50,50,50,450,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,950,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/articuno.png", width = 78, height = 87, columns = 16, frames = 51, durations = {150,50,50,50,50,50,50,50,50,50,50,50,50,50,450,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,950,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + ZAPDOS = { + front = { image = "assets/battle/front-animated/zapdos.png", width = 97, height = 71, columns = 16, frames = 17, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/zapdos.png", width = 85, height = 73, columns = 16, frames = 17, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + MOLTRES = { + front = { image = "assets/battle/front-animated/moltres.png", width = 129, height = 98, columns = 15, frames = 53, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/moltres.png", width = 117, height = 79, columns = 16, frames = 53, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + DRATINI = { + front = { image = "assets/battle/front-animated/dratini.png", width = 44, height = 51, columns = 16, frames = 49, durations = {150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,250,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/dratini.png", width = 47, height = 57, columns = 16, frames = 49, durations = {150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,250,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,100} }, + }, + DRAGONAIR = { + front = { image = "assets/battle/front-animated/dragonair.png", width = 65, height = 74, columns = 16, frames = 127, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/dragonair.png", width = 64, height = 74, columns = 16, frames = 127, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + DRAGONITE = { + front = { image = "assets/battle/front-animated/dragonite.png", width = 80, height = 82, columns = 16, frames = 102, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,600,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,400} }, + back = { image = "assets/battle/back-animated/dragonite.png", width = 86, height = 81, columns = 16, frames = 102, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,600,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,400} }, + }, + MEWTWO = { + front = { image = "assets/battle/front-animated/mewtwo.png", width = 90, height = 69, columns = 16, frames = 125, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/mewtwo.png", width = 95, height = 70, columns = 16, frames = 125, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + MEW = { + front = { image = "assets/battle/front-animated/mew.png", width = 78, height = 54, columns = 16, frames = 119, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/mew.png", width = 60, height = 60, columns = 16, frames = 119, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, +} diff --git a/lib/AnimatedBattleArt.lua b/lib/AnimatedBattleArt.lua new file mode 100644 index 0000000..7d8989d --- /dev/null +++ b/lib/AnimatedBattleArt.lua @@ -0,0 +1,147 @@ +-- Runtime playback for authoring-time GIF conversions. +-- +-- LÖVE does not decode animated GIFs. The importer flattens each GIF into a +-- PNG atlas and data/animated_battle_sprites.lua records the logical cell and +-- timing data. This module extracts exact ImageData rectangles: no Canvas DPI +-- participates, so one source pixel remains one logical battle-art pixel. +local V = ... + +local BattleArt = V.require("BattleArt") +local DEFINITIONS = V.data("animated_battle_sprites") +local AnimatedBattleArt = {} + +local loaded, loadOrder = {}, {} +local LOAD_LIMIT = 6 +local states = setmetatable({}, { __mode = "k" }) -- battler -> playback + +local function forgetFromOrder(def) + for i = #loadOrder, 1, -1 do + if loadOrder[i] == def then table.remove(loadOrder, i) end + end +end + +local function remember(def, mode, frames) + forgetFromOrder(def) + loaded[def] = { mode = mode, frames = frames } + loadOrder[#loadOrder + 1] = def + if #loadOrder > LOAD_LIMIT then + local old = table.remove(loadOrder, 1) + loaded[old] = nil + end +end + +local function atlasPath(def) + local path = def and def.image and V.mod.assets:path(def.image) + local fs = love and love.filesystem + if not (path and fs and fs.getInfo and fs.getInfo(path)) then return nil end + return path +end + +local function loadFrames(def, mode) + local hit = loaded[def] + if hit and hit.mode == mode then return hit.frames end + + local path = atlasPath(def) + if not path then return nil end + local result + local ok = pcall(function() + local width = assert(tonumber(def.width)) + local height = assert(tonumber(def.height)) + local columns = assert(tonumber(def.columns)) + local count = assert(tonumber(def.frames)) + if width < 1 or height < 1 or columns < 1 or count < 1 then return end + + local sheet = love.image.newImageData(path) + local sheetW, sheetH = sheet:getDimensions() + local rows = math.ceil(count / columns) + if sheetW < columns * width or sheetH < rows * height then return end + + local frames = {} + for index = 0, count - 1 do + local cell = love.image.newImageData(width, height) + cell:paste(sheet, 0, 0, + (index % columns) * width, + math.floor(index / columns) * height, + width, height) + local image = BattleArt.prepareData(cell, mode) + if not image then return end + frames[#frames + 1] = image + end + if #frames == count then result = frames end + end) + if not ok or not result then return nil end + remember(def, mode, result) + return result +end + +local function restore(battler) + local state = battler and states[battler] + if not state then return end + if battler.sprite == state.frames[state.frame] then + battler.sprite = state.original + end + states[battler] = nil +end + +local function definition(battler, side) + local species = battler and battler.mon and battler.mon.species + local bySide = species and DEFINITIONS[tostring(species):upper()] + return bySide and bySide[side] or nil +end + +local function updateBattler(battler, side, dt, mode) + if not battler then return end + local def = definition(battler, side) + if not (def and battler.sprite) then restore(battler); return end + + local state = states[battler] + if state and (state.def ~= def or state.mode ~= mode) then + restore(battler) + state = nil + end + if not state then + local frames = loadFrames(def, mode) + if not frames then return end -- missing/malformed atlas: retain ROM art + state = { def = def, mode = mode, original = battler.sprite, + frames = frames, frame = 1, elapsed = 0 } + states[battler] = state + elseif battler.sprite ~= state.frames[state.frame] + and battler.sprite ~= state.original then + -- Transform or another battle effect owns the sprite now. + states[battler] = nil + return + end + + state.elapsed = state.elapsed + (tonumber(dt) or 0) + local durations = def.durations or {} + local duration = math.max(1, tonumber(durations[state.frame]) or 100) / 1000 + while state.elapsed >= duration do + state.elapsed = state.elapsed - duration + state.frame = state.frame % #state.frames + 1 + duration = math.max(1, tonumber(durations[state.frame]) or 100) / 1000 + end + battler.sprite = state.frames[state.frame] +end + +function AnimatedBattleArt.update(battle, dt) + if not battle then return end + if BattleArt.setting:get() ~= "animated" then + AnimatedBattleArt.finish(battle) + return + end + local mode = BattleArt.displayMode() + updateBattler(battle.enemy, "front", dt, mode) + updateBattler(battle.player, BattleArt.playerSide(), dt, mode) +end + +function AnimatedBattleArt.finish(battle) + if not battle then return end + restore(battle.enemy) + restore(battle.player) +end + +function AnimatedBattleArt.invalidate() + loaded, loadOrder = {}, {} +end + +return AnimatedBattleArt diff --git a/lib/BattleArt.lua b/lib/BattleArt.lua index 4fa858f..02e6456 100644 --- a/lib/BattleArt.lua +++ b/lib/BattleArt.lua @@ -76,6 +76,7 @@ local function displayMode() local ok, fx = pcall(require, "src.render.PaletteFX") return ok and fx and fx.mode or "gbc" end +BattleArt.displayMode = displayMode local function applyDisplayFilter(data, mode) if mode == "gbc_inv" then @@ -98,13 +99,12 @@ local function applyDisplayFilter(data, mode) end) end -local function prepare(path, mode) - local cacheKey = path .. "#" .. mode - local hit = cache[cacheKey] - if hit ~= nil then return hit or nil end +-- Turn one logical sprite image into battle-ready art. Animated atlases use +-- this same path after extracting a cell, so static and animated art receive +-- identical transparency keying, display-palette filtering and anchoring. +function BattleArt.prepareData(data, mode) local made local ok = pcall(function() - local data = love.image.newImageData(path) local w, h = data:getDimensions() if w < 1 or h < 1 then return end @@ -165,6 +165,17 @@ local function prepare(path, mode) w = w, h = h, padBottom = h - 1 - y1, center = (x0 + x1 + 1) / 2 } end) + return (ok and made) or nil +end + +local function prepare(path, mode) + local cacheKey = path .. "#" .. mode + local hit = cache[cacheKey] + if hit ~= nil then return hit or nil end + local made + local ok = pcall(function() + made = BattleArt.prepareData(love.image.newImageData(path), mode) + end) cache[cacheKey] = (ok and made) or false return made end @@ -227,6 +238,14 @@ function BattleArt.apply(battle) local function applyOne(battler, side) local species = battler and battler.mon and battler.mon.species if not species then return end + -- AnimatedBattleArt owns Pokemon sprites in this mode. Trainers still + -- pass through applyTrainers below because trainer art is always static. + if BattleArt.setting:get() == "animated" then + if original[battler] then + battler.sprite, original[battler] = original[battler], nil + end + return + end local img = BattleArt.image(species, side) if img then if not BattleArt.isExternal(battler.sprite) then diff --git a/lib/OverworldBattle.lua b/lib/OverworldBattle.lua index 8cd2152..89a1924 100644 --- a/lib/OverworldBattle.lua +++ b/lib/OverworldBattle.lua @@ -46,6 +46,7 @@ local BattleDOF = V.require("BattleDOF") local BattleHud = V.require("BattleHud") local BattlePics = V.require("BattlePics") local BattleArt = V.require("BattleArt") +local AnimatedBattleArt = V.require("AnimatedBattleArt") local Voxel3D = V.require("Voxel3D") local ChunkMesher = V.require("ChunkMesher") @@ -386,6 +387,7 @@ end function OverworldBattle.finish() if not session then return end + AnimatedBattleArt.finish(session.battle) restoreCast() session = nil Voxel3D.camera = nil @@ -423,6 +425,7 @@ function OverworldBattle.update(dt) -- the battle only exists once it has been pushed; a session opened at -- pushBattle time has it, one opened from battle.started was handed it session.battle = session.battle or (top ~= ow and top or nil) + AnimatedBattleArt.update(session.battle, dt) -- the world pass is hidden behind the battle, so mesh builds get the wide -- slice: nothing visible can hitch on them ChunkMesher.pump(true) @@ -494,7 +497,12 @@ function OverworldBattle.invalidate() BattleDOF.invalidate() BattleHud.invalidate() BattlePics.invalidate() + -- Put the ROM image back before BattleArt drops the frame registry. A map + -- or palette rebuild during a battle can then recreate filtered frames + -- without mistaking an old animation frame for the battler's original. + AnimatedBattleArt.finish(session and session.battle) BattleArt.invalidate() + AnimatedBattleArt.invalidate() end -- ------- the battle screen's background diff --git a/tools/import_animated_back_sprite.py b/tools/import_animated_back_sprite.py new file mode 100644 index 0000000..7af2ec5 --- /dev/null +++ b/tools/import_animated_back_sprite.py @@ -0,0 +1,263 @@ +"""Download and convert the original 151 Black/White battle animations. + +The resulting PNG sheets are runtime assets; GIF decoding and network access +remain authoring-only. Both normal front and back-normal collections are +imported, and a Lua metadata table is generated alongside them. +""" + +from __future__ import annotations + +import argparse +import math +import time +import urllib.request +from io import BytesIO +from pathlib import Path + +from PIL import Image, ImageSequence + + +KANTO = """ +BULBASAUR bulbasaur +IVYSAUR ivysaur +VENUSAUR venusaur +CHARMANDER charmander +CHARMELEON charmeleon +CHARIZARD charizard +SQUIRTLE squirtle +WARTORTLE wartortle +BLASTOISE blastoise +CATERPIE caterpie +METAPOD metapod +BUTTERFREE butterfree +WEEDLE weedle +KAKUNA kakuna +BEEDRILL beedrill +PIDGEY pidgey +PIDGEOTTO pidgeotto +PIDGEOT pidgeot +RATTATA rattata +RATICATE raticate +SPEAROW spearow +FEAROW fearow +EKANS ekans +ARBOK arbok +PIKACHU pikachu +RAICHU raichu +SANDSHREW sandshrew +SANDSLASH sandslash +NIDORAN_F nidoran-f +NIDORINA nidorina +NIDOQUEEN nidoqueen +NIDORAN_M nidoran-m +NIDORINO nidorino +NIDOKING nidoking +CLEFAIRY clefairy +CLEFABLE clefable +VULPIX vulpix +NINETALES ninetales +JIGGLYPUFF jigglypuff +WIGGLYTUFF wigglytuff +ZUBAT zubat +GOLBAT golbat +ODDISH oddish +GLOOM gloom +VILEPLUME vileplume +PARAS paras +PARASECT parasect +VENONAT venonat +VENOMOTH venomoth +DIGLETT diglett +DUGTRIO dugtrio +MEOWTH meowth +PERSIAN persian +PSYDUCK psyduck +GOLDUCK golduck +MANKEY mankey +PRIMEAPE primeape +GROWLITHE growlithe +ARCANINE arcanine +POLIWAG poliwag +POLIWHIRL poliwhirl +POLIWRATH poliwrath +ABRA abra +KADABRA kadabra +ALAKAZAM alakazam +MACHOP machop +MACHOKE machoke +MACHAMP machamp +BELLSPROUT bellsprout +WEEPINBELL weepinbell +VICTREEBEL victreebel +TENTACOOL tentacool +TENTACRUEL tentacruel +GEODUDE geodude +GRAVELER graveler +GOLEM golem +PONYTA ponyta +RAPIDASH rapidash +SLOWPOKE slowpoke +SLOWBRO slowbro +MAGNEMITE magnemite +MAGNETON magneton +FARFETCHD farfetchd +DODUO doduo +DODRIO dodrio +SEEL seel +DEWGONG dewgong +GRIMER grimer +MUK muk +SHELLDER shellder +CLOYSTER cloyster +GASTLY gastly +HAUNTER haunter +GENGAR gengar +ONIX onix +DROWZEE drowzee +HYPNO hypno +KRABBY krabby +KINGLER kingler +VOLTORB voltorb +ELECTRODE electrode +EXEGGCUTE exeggcute +EXEGGUTOR exeggutor +CUBONE cubone +MAROWAK marowak +HITMONLEE hitmonlee +HITMONCHAN hitmonchan +LICKITUNG lickitung +KOFFING koffing +WEEZING weezing +RHYHORN rhyhorn +RHYDON rhydon +CHANSEY chansey +TANGELA tangela +KANGASKHAN kangaskhan +HORSEA horsea +SEADRA seadra +GOLDEEN goldeen +SEAKING seaking +STARYU staryu +STARMIE starmie +MR_MIME mr-mime +SCYTHER scyther +JYNX jynx +ELECTABUZZ electabuzz +MAGMAR magmar +PINSIR pinsir +TAUROS tauros +MAGIKARP magikarp +GYARADOS gyarados +LAPRAS lapras +DITTO ditto +EEVEE eevee +VAPOREON vaporeon +JOLTEON jolteon +FLAREON flareon +PORYGON porygon +OMANYTE omanyte +OMASTAR omastar +KABUTO kabuto +KABUTOPS kabutops +AERODACTYL aerodactyl +SNORLAX snorlax +ARTICUNO articuno +ZAPDOS zapdos +MOLTRES moltres +DRATINI dratini +DRAGONAIR dragonair +DRAGONITE dragonite +MEWTWO mewtwo +MEW mew +""" + +SPECIES = [tuple(line.split()) for line in KANTO.strip().splitlines()] +BASE = "https://img.pokemondb.net/sprites/black-white/anim" + + +def download(url: str, retries: int = 4) -> bytes: + request = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"}) + for attempt in range(retries): + try: + with urllib.request.urlopen(request, timeout=30) as response: + return response.read() + except Exception: + if attempt + 1 == retries: + raise + time.sleep(1.5 * (attempt + 1)) + raise AssertionError("unreachable") + + +def convert(raw: bytes, destination: Path, columns: int = 16, + max_dimension: int = 2048) -> dict: + with Image.open(BytesIO(raw)) as gif: + frames, durations = [], [] + for frame in ImageSequence.Iterator(gif): + durations.append(int(frame.info.get("duration", gif.info.get("duration", 100)))) + frames.append(frame.convert("RGBA")) + if not frames: + raise ValueError("GIF contains no frames") + width, height = frames[0].size + if any(frame.size != (width, height) for frame in frames): + raise ValueError("GIF frames do not share one logical canvas") + max_columns = max_dimension // width + if max_columns < 1: + raise ValueError(f"frame width {width} exceeds {max_dimension}") + columns = max(1, min(columns, max_columns, len(frames))) + while math.ceil(len(frames) / columns) * height > max_dimension: + columns += 1 + if columns > max_columns or columns > len(frames): + raise ValueError( + f"{len(frames)} {width}x{height} frames do not fit " + f"within a {max_dimension}px sheet" + ) + rows = math.ceil(len(frames) / columns) + sheet = Image.new("RGBA", (columns * width, rows * height)) + for index, frame in enumerate(frames): + sheet.alpha_composite(frame, ((index % columns) * width, + (index // columns) * height)) + destination.parent.mkdir(parents=True, exist_ok=True) + sheet.save(destination, optimize=True) + return {"width": width, "height": height, "columns": columns, + "frames": len(frames), "durations": durations} + + +def lua_definition(path: str, info: dict) -> str: + durations = ",".join(map(str, info["durations"])) + return (f'{{ image = "{path}", width = {info["width"]}, ' + f'height = {info["height"]}, columns = {info["columns"]}, ' + f'frames = {info["frames"]}, durations = {{{durations}}} }}') + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--root", type=Path, default=Path.cwd()) + args = parser.parse_args() + root = args.root.resolve() + records = {} + for number, (species, slug) in enumerate(SPECIES, 1): + records[species] = {} + for side, remote in (("front", "normal"), ("back", "back-normal")): + url = f"{BASE}/{remote}/{slug}.gif" + relative = f"assets/battle/{side}-animated/{slug}.png" + print(f"[{number:03d}/151] {side:5s} {slug}", flush=True) + records[species][side] = convert(download(url), root / relative) + records[species][side]["path"] = relative + + lines = ["-- Generated by tools/import_animated_back_sprite.py.", + "-- Black/White animated front and back sprites, original 151.", + "return {"] + for species, _ in SPECIES: + lines.append(f" {species} = {{") + for side in ("front", "back"): + info = records[species][side] + lines.append(f" {side} = {lua_definition(info['path'], info)},") + lines.append(" },") + lines.append("}") + destination = root / "data" / "animated_battle_sprites.lua" + destination.write_text("\n".join(lines) + "\n", encoding="utf-8") + print(f"wrote {destination} with {len(SPECIES) * 2} animations") + + +if __name__ == "__main__": + main() From 5f4db7ab6febca4ebd55bc04d9e2bdfa81d71e7a Mon Sep 17 00:00:00 2001 From: absol89 Date: Sat, 1 Aug 2026 21:39:37 +0200 Subject: [PATCH 012/116] Add optional Crystal animated fronts --- CHANGELOG.md | 5 + assets/battle/front-animated/README.md | 7 + data/animated_battle_sprites_crystal.lua | 457 +++++++++++++++++++++++ lib/AnimatedBattleArt.lua | 7 +- tools/import_crystal_front_sprites.py | 123 ++++++ 5 files changed, 598 insertions(+), 1 deletion(-) create mode 100644 data/animated_battle_sprites_crystal.lua create mode 100644 tools/import_crystal_front_sprites.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 895ca41..3bce462 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,11 @@ scaling, and sends every frame through the same transparency, display filtering, world lighting, depth and shadow path as static art. Missing or malformed atlases fall back per Pokemon to ROM art. +- **Optional Crystal front collection.** Local atlases in + `front-animated/crystal` are preferred per species, with the ordinary + animated atlas and ROM art as successive fallbacks. The authoring importer + downloads #001-#151, preserves their 56x56 logical canvases and original + frame delays, and commits only generated timing metadata—not artwork. ## 1.3.0 diff --git a/assets/battle/front-animated/README.md b/assets/battle/front-animated/README.md index 06d0a9b..27a8eb1 100644 --- a/assets/battle/front-animated/README.md +++ b/assets/battle/front-animated/README.md @@ -12,6 +12,13 @@ cell at its native logical resolution, and uses nearest-neighbour filtering. The importer creates both the atlases and their single shared metadata file; users do not write one Lua file per Pokemon. +`crystal/` is the preferred front collection when a matching atlas exists. +Run `python tools/import_crystal_front_sprites.py --root .` from the repository +root to download National Dex #001-#151 from the configured source, convert +their GIF frames without resizing, and regenerate the shared Crystal timing +metadata. A missing Crystal species falls through to the root animated atlas +and then to ROM art. + ## Gen 1 filename exceptions Most species use their ordinary lowercase name (`pikachu.png`). These four diff --git a/data/animated_battle_sprites_crystal.lua b/data/animated_battle_sprites_crystal.lua new file mode 100644 index 0000000..bf4e978 --- /dev/null +++ b/data/animated_battle_sprites_crystal.lua @@ -0,0 +1,457 @@ +-- Generated by tools/import_crystal_front_sprites.py. +-- Pokemon Crystal animated fronts, National Dex #001-#151. +return { + BULBASAUR = { + front = { image = "assets/battle/front-animated/crystal/bulbasaur.png", width = 56, height = 56, columns = 14, frames = 14, durations = {10,180,180,140,80,360,80,60,60,380,60,60,60,990} }, + }, + IVYSAUR = { + front = { image = "assets/battle/front-animated/crystal/ivysaur.png", width = 56, height = 56, columns = 10, frames = 10, durations = {400,250,250,300,150,150,150,150,250,1000} }, + }, + VENUSAUR = { + front = { image = "assets/battle/front-animated/crystal/venusaur.png", width = 56, height = 56, columns = 14, frames = 14, durations = {180,680,140,120,180,160,180,180,400,660,240,240,220,1000} }, + }, + CHARMANDER = { + front = { image = "assets/battle/front-animated/crystal/charmander.png", width = 56, height = 56, columns = 12, frames = 12, durations = {50,200,350,150,100,200,150,400,100,100,100,2400} }, + }, + CHARMELEON = { + front = { image = "assets/battle/front-animated/crystal/charmeleon.png", width = 56, height = 56, columns = 16, frames = 16, durations = {10,150,100,150,150,250,150,450,350,100,200,150,200,150,100,1790} }, + }, + CHARIZARD = { + front = { image = "assets/battle/front-animated/crystal/charizard.png", width = 56, height = 56, columns = 11, frames = 11, durations = {100,300,150,150,150,150,350,150,100,100,1000} }, + }, + SQUIRTLE = { + front = { image = "assets/battle/front-animated/crystal/squirtle.png", width = 56, height = 56, columns = 14, frames = 14, durations = {10,200,150,100,150,100,150,200,100,400,100,50,100,1990} }, + }, + WARTORTLE = { + front = { image = "assets/battle/front-animated/crystal/wartortle.png", width = 56, height = 56, columns = 13, frames = 13, durations = {60,640,160,140,160,140,160,160,520,80,100,100,1500} }, + }, + BLASTOISE = { + front = { image = "assets/battle/front-animated/crystal/blastoise.png", width = 56, height = 56, columns = 10, frames = 10, durations = {10,100,700,100,100,500,200,150,200,990} }, + }, + CATERPIE = { + front = { image = "assets/battle/front-animated/crystal/caterpie.png", width = 56, height = 56, columns = 13, frames = 13, durations = {200,150,200,200,50,200,400,50,150,50,100,100,1000} }, + }, + METAPOD = { + front = { image = "assets/battle/front-animated/crystal/metapod.png", width = 56, height = 56, columns = 11, frames = 11, durations = {10,200,350,200,400,100,100,50,100,100,990} }, + }, + BUTTERFREE = { + front = { image = "assets/battle/front-animated/crystal/butterfree.png", width = 56, height = 56, columns = 13, frames = 13, durations = {100,350,100,100,100,100,100,100,450,100,150,200,1000} }, + }, + WEEDLE = { + front = { image = "assets/battle/front-animated/crystal/weedle.png", width = 56, height = 56, columns = 16, frames = 23, durations = {100,180,120,120,120,120,120,120,120,120,80,80,80,80,100,100,100,100,100,100,100,100,1500} }, + }, + KAKUNA = { + front = { image = "assets/battle/front-animated/crystal/kakuna.png", width = 56, height = 56, columns = 7, frames = 7, durations = {100,240,140,140,140,140,2000} }, + }, + BEEDRILL = { + front = { image = "assets/battle/front-animated/crystal/beedrill.png", width = 56, height = 56, columns = 16, frames = 55, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,150,100,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,150,1500} }, + }, + PIDGEY = { + front = { image = "assets/battle/front-animated/crystal/pidgey.png", width = 56, height = 56, columns = 16, frames = 23, durations = {80,80,280,80,80,40,120,40,80,80,80,40,80,80,480,40,80,80,180,80,80,120,1000} }, + }, + PIDGEOTTO = { + front = { image = "assets/battle/front-animated/crystal/pidgeotto.png", width = 56, height = 56, columns = 14, frames = 14, durations = {100,140,140,140,300,120,120,120,120,100,100,100,100,1500} }, + }, + PIDGEOT = { + front = { image = "assets/battle/front-animated/crystal/pidgeot.png", width = 56, height = 56, columns = 16, frames = 16, durations = {100,320,120,120,120,120,120,120,120,120,120,120,120,120,120,1500} }, + }, + RATTATA = { + front = { image = "assets/battle/front-animated/crystal/rattata.png", width = 56, height = 56, columns = 16, frames = 16, durations = {100,40,240,200,100,100,100,100,100,100,100,100,100,100,100,1000} }, + }, + RATICATE = { + front = { image = "assets/battle/front-animated/crystal/raticate.png", width = 56, height = 56, columns = 15, frames = 15, durations = {100,230,100,100,100,100,100,100,100,100,100,100,100,100,1000} }, + }, + SPEAROW = { + front = { image = "assets/battle/front-animated/crystal/spearow.png", width = 56, height = 56, columns = 16, frames = 21, durations = {100,100,100,250,250,200,100,100,100,100,100,100,100,100,100,100,100,100,100,100,1000} }, + }, + FEAROW = { + front = { image = "assets/battle/front-animated/crystal/fearow.png", width = 56, height = 56, columns = 16, frames = 16, durations = {250,150,150,150,150,150,150,150,150,150,150,200,200,200,200,1700} }, + }, + EKANS = { + front = { image = "assets/battle/front-animated/crystal/ekans.png", width = 56, height = 56, columns = 16, frames = 38, durations = {100,80,50,30,30,30,30,30,30,30,30,30,30,30,30,30,30,200,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,1020} }, + }, + ARBOK = { + front = { image = "assets/battle/front-animated/crystal/arbok.png", width = 56, height = 56, columns = 16, frames = 26, durations = {140,80,80,80,80,80,80,70,70,50,50,70,70,50,50,70,70,50,50,80,60,40,40,40,60,1000} }, + }, + PIKACHU = { + front = { image = "assets/battle/front-animated/crystal/pikachu.png", width = 56, height = 56, columns = 16, frames = 22, durations = {10,150,100,150,150,50,450,100,50,50,150,400,50,100,50,50,100,100,50,100,50,990} }, + }, + RAICHU = { + front = { image = "assets/battle/front-animated/crystal/raichu.png", width = 56, height = 56, columns = 11, frames = 11, durations = {100,100,100,450,200,100,450,100,50,50,800} }, + }, + SANDSHREW = { + front = { image = "assets/battle/front-animated/crystal/sandshrew.png", width = 56, height = 56, columns = 16, frames = 19, durations = {200,100,100,100,200,100,100,100,100,100,100,100,100,100,100,100,100,120,1000} }, + }, + SANDSLASH = { + front = { image = "assets/battle/front-animated/crystal/sandslash.png", width = 56, height = 56, columns = 13, frames = 13, durations = {160,140,120,140,160,240,140,160,160,120,80,80,1500} }, + }, + NIDORAN_F = { + front = { image = "assets/battle/front-animated/crystal/nidoran-f.png", width = 56, height = 56, columns = 15, frames = 15, durations = {100,100,180,100,100,100,100,100,200,100,100,100,100,100,1000} }, + }, + NIDORINA = { + front = { image = "assets/battle/front-animated/crystal/nidorina.png", width = 56, height = 56, columns = 15, frames = 15, durations = {100,200,100,110,100,100,100,110,100,100,100,100,100,100,1000} }, + }, + NIDOQUEEN = { + front = { image = "assets/battle/front-animated/crystal/nidoqueen.png", width = 56, height = 56, columns = 15, frames = 15, durations = {100,200,200,200,200,200,200,200,200,500,200,200,200,200,1500} }, + }, + NIDORAN_M = { + front = { image = "assets/battle/front-animated/crystal/nidoran-m.png", width = 56, height = 56, columns = 15, frames = 15, durations = {210,210,100,100,100,100,100,100,400,100,100,100,100,100,1000} }, + }, + NIDORINO = { + front = { image = "assets/battle/front-animated/crystal/nidorino.png", width = 56, height = 56, columns = 16, frames = 21, durations = {100,180,500,100,120,280,120,100,120,120,120,370,120,120,120,120,120,120,120,100,1000} }, + }, + NIDOKING = { + front = { image = "assets/battle/front-animated/crystal/nidoking.png", width = 56, height = 56, columns = 11, frames = 11, durations = {100,220,620,200,470,100,120,100,120,100,1000} }, + }, + CLEFAIRY = { + front = { image = "assets/battle/front-animated/crystal/clefairy.png", width = 56, height = 56, columns = 16, frames = 18, durations = {100,120,120,100,80,80,80,80,80,100,370,200,220,200,220,80,80,1000} }, + }, + CLEFABLE = { + front = { image = "assets/battle/front-animated/crystal/clefable.png", width = 56, height = 56, columns = 11, frames = 11, durations = {100,180,180,170,480,120,130,120,130,130,1000} }, + }, + VULPIX = { + front = { image = "assets/battle/front-animated/crystal/vulpix.png", width = 56, height = 56, columns = 16, frames = 17, durations = {100,100,80,180,100,100,80,80,80,100,370,180,180,170,100,80,1000} }, + }, + NINETALES = { + front = { image = "assets/battle/front-animated/crystal/ninetales.png", width = 56, height = 56, columns = 15, frames = 15, durations = {100,150,170,170,180,220,180,130,470,100,120,100,120,120,1000} }, + }, + JIGGLYPUFF = { + front = { image = "assets/battle/front-animated/crystal/jigglypuff.png", width = 56, height = 56, columns = 16, frames = 17, durations = {100,250,170,170,120,350,100,100,100,100,470,120,120,120,120,130,1000} }, + }, + WIGGLYTUFF = { + front = { image = "assets/battle/front-animated/crystal/wigglytuff.png", width = 56, height = 56, columns = 14, frames = 14, durations = {100,350,130,150,100,120,70,470,100,120,100,120,120,1000} }, + }, + ZUBAT = { + front = { image = "assets/battle/front-animated/crystal/zubat.png", width = 56, height = 56, columns = 16, frames = 17, durations = {100,120,130,130,130,120,130,120,150,120,470,580,220,200,220,220,1000} }, + }, + GOLBAT = { + front = { image = "assets/battle/front-animated/crystal/golbat.png", width = 56, height = 56, columns = 16, frames = 17, durations = {100,170,170,170,300,150,150,150,150,150,470,100,120,100,120,100,1000} }, + }, + ODDISH = { + front = { image = "assets/battle/front-animated/crystal/oddish.png", width = 56, height = 56, columns = 14, frames = 14, durations = {100,180,350,120,80,370,120,120,130,430,80,80,100,1000} }, + }, + GLOOM = { + front = { image = "assets/battle/front-animated/crystal/gloom.png", width = 56, height = 56, columns = 15, frames = 15, durations = {100,180,250,350,180,150,130,120,120,100,530,120,180,180,1000} }, + }, + VILEPLUME = { + front = { image = "assets/battle/front-animated/crystal/vileplume.png", width = 56, height = 56, columns = 16, frames = 16, durations = {100,100,250,100,100,100,100,100,100,100,100,100,100,110,100,1000} }, + }, + PARAS = { + front = { image = "assets/battle/front-animated/crystal/paras.png", width = 56, height = 56, columns = 11, frames = 11, durations = {100,280,320,200,200,100,100,100,100,100,1500} }, + }, + PARASECT = { + front = { image = "assets/battle/front-animated/crystal/parasect.png", width = 56, height = 56, columns = 16, frames = 20, durations = {100,100,100,100,100,100,100,100,100,200,100,100,100,100,100,100,300,100,200,1500} }, + }, + VENONAT = { + front = { image = "assets/battle/front-animated/crystal/venonat.png", width = 56, height = 56, columns = 16, frames = 21, durations = {100,100,100,100,100,100,100,100,100,100,100,100,100,100,100,100,100,150,110,150,1000} }, + }, + VENOMOTH = { + front = { image = "assets/battle/front-animated/crystal/venomoth.png", width = 56, height = 56, columns = 16, frames = 19, durations = {100,100,100,100,100,100,100,100,100,100,100,100,100,100,100,100,100,100,1000} }, + }, + DIGLETT = { + front = { image = "assets/battle/front-animated/crystal/diglett.png", width = 56, height = 56, columns = 11, frames = 11, durations = {100,80,160,180,160,180,100,160,100,160,1500} }, + }, + DUGTRIO = { + front = { image = "assets/battle/front-animated/crystal/dugtrio.png", width = 56, height = 56, columns = 16, frames = 26, durations = {100,160,160,240,120,100,120,200,100,100,100,100,100,100,400,120,120,400,200,320,80,80,80,80,400,1500} }, + }, + MEOWTH = { + front = { image = "assets/battle/front-animated/crystal/meowth.png", width = 56, height = 56, columns = 11, frames = 11, durations = {100,480,280,280,280,280,160,160,160,160,1500} }, + }, + PERSIAN = { + front = { image = "assets/battle/front-animated/crystal/persian.png", width = 56, height = 56, columns = 14, frames = 14, durations = {100,240,640,240,200,160,200,160,120,100,100,100,100,1500} }, + }, + PSYDUCK = { + front = { image = "assets/battle/front-animated/crystal/psyduck.png", width = 56, height = 56, columns = 11, frames = 11, durations = {90,280,140,140,140,140,100,100,100,100,1000} }, + }, + GOLDUCK = { + front = { image = "assets/battle/front-animated/crystal/golduck.png", width = 56, height = 56, columns = 16, frames = 18, durations = {100,140,300,160,100,160,100,460,200,200,200,200,200,200,300,200,300,1500} }, + }, + MANKEY = { + front = { image = "assets/battle/front-animated/crystal/mankey.png", width = 56, height = 56, columns = 15, frames = 15, durations = {100,240,160,160,160,160,160,160,120,120,120,120,120,120,1500} }, + }, + PRIMEAPE = { + front = { image = "assets/battle/front-animated/crystal/primeape.png", width = 56, height = 56, columns = 16, frames = 22, durations = {100,160,160,160,160,160,160,160,160,160,160,100,500,200,500,120,120,120,120,120,120,1500} }, + }, + GROWLITHE = { + front = { image = "assets/battle/front-animated/crystal/growlithe.png", width = 56, height = 56, columns = 14, frames = 14, durations = {260,200,360,160,160,160,160,160,320,160,160,160,320,1500} }, + }, + ARCANINE = { + front = { image = "assets/battle/front-animated/crystal/arcanine.png", width = 56, height = 56, columns = 16, frames = 16, durations = {10,80,480,180,180,460,400,160,160,160,160,160,160,160,160,990} }, + }, + POLIWAG = { + front = { image = "assets/battle/front-animated/crystal/poliwag.png", width = 56, height = 56, columns = 16, frames = 17, durations = {130,480,160,160,160,160,120,120,120,120,120,120,200,200,200,200,1500} }, + }, + POLIWHIRL = { + front = { image = "assets/battle/front-animated/crystal/poliwhirl.png", width = 56, height = 56, columns = 13, frames = 13, durations = {100,140,100,200,200,200,200,320,100,100,100,100,1500} }, + }, + POLIWRATH = { + front = { image = "assets/battle/front-animated/crystal/poliwrath.png", width = 56, height = 56, columns = 12, frames = 12, durations = {100,480,240,160,160,160,160,160,120,120,120,1500} }, + }, + ABRA = { + front = { image = "assets/battle/front-animated/crystal/abra.png", width = 56, height = 56, columns = 16, frames = 16, durations = {100,200,180,180,200,180,180,200,180,140,140,140,140,140,140,1500} }, + }, + KADABRA = { + front = { image = "assets/battle/front-animated/crystal/kadabra.png", width = 56, height = 56, columns = 16, frames = 18, durations = {100,80,60,60,60,60,60,60,60,60,120,70,70,70,70,70,70,1000} }, + }, + ALAKAZAM = { + front = { image = "assets/battle/front-animated/crystal/alakazam.png", width = 56, height = 56, columns = 12, frames = 12, durations = {100,100,120,120,120,120,120,190,150,450,100,1000} }, + }, + MACHOP = { + front = { image = "assets/battle/front-animated/crystal/machop.png", width = 56, height = 56, columns = 11, frames = 11, durations = {130,440,240,240,240,240,120,120,120,120,1500} }, + }, + MACHOKE = { + front = { image = "assets/battle/front-animated/crystal/machoke.png", width = 56, height = 56, columns = 13, frames = 13, durations = {100,120,400,100,60,250,50,200,80,80,80,80,1000} }, + }, + MACHAMP = { + front = { image = "assets/battle/front-animated/crystal/machamp.png", width = 56, height = 56, columns = 16, frames = 20, durations = {50,600,100,60,100,60,100,60,100,60,100,60,100,60,160,140,140,140,140,1500} }, + }, + BELLSPROUT = { + front = { image = "assets/battle/front-animated/crystal/bellsprout.png", width = 56, height = 56, columns = 16, frames = 21, durations = {90,160,160,160,160,160,240,160,160,160,240,160,200,200,100,100,100,100,100,100,1500} }, + }, + WEEPINBELL = { + front = { image = "assets/battle/front-animated/crystal/weepinbell.png", width = 56, height = 56, columns = 12, frames = 12, durations = {60,100,100,100,100,100,240,140,140,140,140,1500} }, + }, + VICTREEBEL = { + front = { image = "assets/battle/front-animated/crystal/victreebel.png", width = 56, height = 56, columns = 13, frames = 13, durations = {100,200,500,200,200,200,120,120,100,120,120,100,1500} }, + }, + TENTACOOL = { + front = { image = "assets/battle/front-animated/crystal/tentacool.png", width = 56, height = 56, columns = 10, frames = 10, durations = {10,150,150,150,150,250,100,100,100,990} }, + }, + TENTACRUEL = { + front = { image = "assets/battle/front-animated/crystal/tentacruel.png", width = 56, height = 56, columns = 16, frames = 20, durations = {100,150,230,150,230,310,30,30,30,30,30,30,30,30,30,30,30,30,30,1000} }, + }, + GEODUDE = { + front = { image = "assets/battle/front-animated/crystal/geodude.png", width = 56, height = 56, columns = 15, frames = 15, durations = {90,320,140,140,140,140,140,140,160,160,160,160,160,160,1500} }, + }, + GRAVELER = { + front = { image = "assets/battle/front-animated/crystal/graveler.png", width = 56, height = 56, columns = 14, frames = 14, durations = {100,120,120,170,240,90,90,90,350,200,200,200,200,1000} }, + }, + GOLEM = { + front = { image = "assets/battle/front-animated/crystal/golem.png", width = 56, height = 56, columns = 10, frames = 10, durations = {90,120,180,120,120,80,80,80,80,1000} }, + }, + PONYTA = { + front = { image = "assets/battle/front-animated/crystal/ponyta.png", width = 56, height = 56, columns = 14, frames = 14, durations = {10,100,150,150,50,350,50,100,50,400,50,150,100,580} }, + }, + RAPIDASH = { + front = { image = "assets/battle/front-animated/crystal/rapidash.png", width = 56, height = 56, columns = 16, frames = 21, durations = {110,720,100,100,140,120,100,140,240,240,240,240,240,240,240,240,120,120,120,120,1500} }, + }, + SLOWPOKE = { + front = { image = "assets/battle/front-animated/crystal/slowpoke.png", width = 56, height = 56, columns = 6, frames = 6, durations = {100,50,280,100,200,1000} }, + }, + SLOWBRO = { + front = { image = "assets/battle/front-animated/crystal/slowbro.png", width = 56, height = 56, columns = 16, frames = 16, durations = {50,160,560,240,200,200,200,200,480,200,200,160,160,160,160,1500} }, + }, + MAGNEMITE = { + front = { image = "assets/battle/front-animated/crystal/magnemite.png", width = 56, height = 56, columns = 13, frames = 13, durations = {410,120,120,120,120,120,240,150,120,120,120,120,240} }, + }, + MAGNETON = { + front = { image = "assets/battle/front-animated/crystal/magneton.png", width = 56, height = 56, columns = 16, frames = 18, durations = {380,120,120,120,120,120,120,120,60,60,60,240,150,150,300,360,360,360} }, + }, + FARFETCHD = { + front = { image = "assets/battle/front-animated/crystal/farfetchd.png", width = 56, height = 56, columns = 9, frames = 9, durations = {100,60,60,140,60,100,70,70,1000} }, + }, + DODUO = { + front = { image = "assets/battle/front-animated/crystal/doduo.png", width = 56, height = 56, columns = 13, frames = 13, durations = {100,120,320,240,80,80,80,200,60,60,60,360,1000} }, + }, + DODRIO = { + front = { image = "assets/battle/front-animated/crystal/dodrio.png", width = 56, height = 56, columns = 11, frames = 11, durations = {100,200,200,200,180,200,160,200,160,200,1500} }, + }, + SEEL = { + front = { image = "assets/battle/front-animated/crystal/seel.png", width = 56, height = 56, columns = 12, frames = 12, durations = {70,320,200,200,200,200,240,120,120,120,120,1500} }, + }, + DEWGONG = { + front = { image = "assets/battle/front-animated/crystal/dewgong.png", width = 56, height = 56, columns = 15, frames = 15, durations = {100,90,90,90,90,140,60,60,60,60,120,60,60,60,1060} }, + }, + GRIMER = { + front = { image = "assets/battle/front-animated/crystal/grimer.png", width = 56, height = 56, columns = 11, frames = 11, durations = {100,200,150,450,300,100,100,150,150,150,750} }, + }, + MUK = { + front = { image = "assets/battle/front-animated/crystal/muk.png", width = 56, height = 56, columns = 12, frames = 12, durations = {100,120,120,120,120,120,120,120,120,120,120,1000} }, + }, + SHELLDER = { + front = { image = "assets/battle/front-animated/crystal/shellder.png", width = 56, height = 56, columns = 14, frames = 14, durations = {100,100,100,150,100,100,350,150,150,150,100,150,150,1500} }, + }, + CLOYSTER = { + front = { image = "assets/battle/front-animated/crystal/cloyster.png", width = 56, height = 56, columns = 8, frames = 8, durations = {100,180,180,180,180,180,520,1500} }, + }, + GASTLY = { + front = { image = "assets/battle/front-animated/crystal/gastly.png", width = 56, height = 56, columns = 11, frames = 11, durations = {50,180,240,180,120,120,60,60,60,60,1000} }, + }, + HAUNTER = { + front = { image = "assets/battle/front-animated/crystal/haunter.png", width = 56, height = 56, columns = 9, frames = 9, durations = {10,250,250,500,150,150,500,250,1990} }, + }, + GENGAR = { + front = { image = "assets/battle/front-animated/crystal/gengar.png", width = 56, height = 56, columns = 16, frames = 18, durations = {100,60,70,500,90,90,90,90,90,90,20,20,20,20,90,90,90,1000} }, + }, + ONIX = { + front = { image = "assets/battle/front-animated/crystal/onix.png", width = 56, height = 56, columns = 16, frames = 17, durations = {100,120,120,120,120,120,120,120,120,120,100,100,100,100,100,100,1000} }, + }, + DROWZEE = { + front = { image = "assets/battle/front-animated/crystal/drowzee.png", width = 56, height = 56, columns = 16, frames = 17, durations = {100,80,80,80,80,50,50,50,50,50,60,60,60,60,80,80,1000} }, + }, + HYPNO = { + front = { image = "assets/battle/front-animated/crystal/hypno.png", width = 56, height = 56, columns = 14, frames = 14, durations = {100,220,220,220,220,220,220,220,40,120,120,120,120,740} }, + }, + KRABBY = { + front = { image = "assets/battle/front-animated/crystal/krabby.png", width = 56, height = 56, columns = 15, frames = 15, durations = {100,180,120,40,30,40,30,60,40,30,40,30,60,60,1000} }, + }, + KINGLER = { + front = { image = "assets/battle/front-animated/crystal/kingler.png", width = 56, height = 56, columns = 16, frames = 18, durations = {100,220,220,220,220,220,220,300,300,150,150,150,150,150,150,150,150,1500} }, + }, + VOLTORB = { + front = { image = "assets/battle/front-animated/crystal/voltorb.png", width = 56, height = 56, columns = 10, frames = 10, durations = {180,80,60,40,60,80,80,80,80,1080} }, + }, + ELECTRODE = { + front = { image = "assets/battle/front-animated/crystal/electrode.png", width = 56, height = 56, columns = 5, frames = 5, durations = {100,50,400,450,1000} }, + }, + EXEGGCUTE = { + front = { image = "assets/battle/front-animated/crystal/exeggcute.png", width = 56, height = 56, columns = 16, frames = 22, durations = {90,40,40,40,40,100,30,30,30,30,30,30,100,40,40,80,80,60,60,60,60,1000} }, + }, + EXEGGUTOR = { + front = { image = "assets/battle/front-animated/crystal/exeggutor.png", width = 56, height = 56, columns = 16, frames = 16, durations = {100,100,100,100,90,90,90,90,90,180,100,100,100,100,100,1000} }, + }, + CUBONE = { + front = { image = "assets/battle/front-animated/crystal/cubone.png", width = 56, height = 56, columns = 15, frames = 15, durations = {50,190,80,60,30,20,400,20,20,80,80,80,80,80,1000} }, + }, + MAROWAK = { + front = { image = "assets/battle/front-animated/crystal/marowak.png", width = 56, height = 56, columns = 16, frames = 18, durations = {100,70,70,70,70,70,70,70,70,70,70,150,150,150,150,150,150,1500} }, + }, + HITMONLEE = { + front = { image = "assets/battle/front-animated/crystal/hitmonlee.png", width = 56, height = 56, columns = 16, frames = 16, durations = {50,240,120,60,100,120,100,100,100,100,80,80,80,80,80,1500} }, + }, + HITMONCHAN = { + front = { image = "assets/battle/front-animated/crystal/hitmonchan.png", width = 56, height = 56, columns = 16, frames = 21, durations = {100,260,120,120,120,120,120,150,120,400,100,400,180,180,180,180,180,180,180,290,1500} }, + }, + LICKITUNG = { + front = { image = "assets/battle/front-animated/crystal/lickitung.png", width = 56, height = 56, columns = 11, frames = 11, durations = {100,160,60,60,60,60,60,60,60,60,1000} }, + }, + KOFFING = { + front = { image = "assets/battle/front-animated/crystal/koffing.png", width = 56, height = 56, columns = 9, frames = 9, durations = {10,300,150,50,150,250,350,750,2390} }, + }, + WEEZING = { + front = { image = "assets/battle/front-animated/crystal/weezing.png", width = 56, height = 56, columns = 16, frames = 17, durations = {100,150,200,200,200,200,200,200,120,160,160,160,160,160,160,120,1500} }, + }, + RHYHORN = { + front = { image = "assets/battle/front-animated/crystal/rhyhorn.png", width = 56, height = 56, columns = 11, frames = 11, durations = {100,200,260,400,260,260,260,260,260,260,1500} }, + }, + RHYDON = { + front = { image = "assets/battle/front-animated/crystal/rhydon.png", width = 56, height = 56, columns = 16, frames = 29, durations = {150,450,150,200,50,50,300,50,50,50,50,50,50,50,50,100,50,50,50,50,50,150,50,100,450,100,100,100,800} }, + }, + CHANSEY = { + front = { image = "assets/battle/front-animated/crystal/chansey.png", width = 56, height = 56, columns = 14, frames = 14, durations = {10,100,150,100,100,100,50,150,100,400,50,100,100,990} }, + }, + TANGELA = { + front = { image = "assets/battle/front-animated/crystal/tangela.png", width = 56, height = 56, columns = 9, frames = 9, durations = {10,500,160,160,350,100,100,100,990} }, + }, + KANGASKHAN = { + front = { image = "assets/battle/front-animated/crystal/kangaskhan.png", width = 56, height = 56, columns = 15, frames = 15, durations = {10,450,250,200,150,150,150,250,400,100,100,150,200,400,1490} }, + }, + HORSEA = { + front = { image = "assets/battle/front-animated/crystal/horsea.png", width = 56, height = 56, columns = 14, frames = 14, durations = {10,200,200,100,50,50,50,50,100,100,100,100,100,1490} }, + }, + SEADRA = { + front = { image = "assets/battle/front-animated/crystal/seadra.png", width = 56, height = 56, columns = 13, frames = 13, durations = {200,160,160,160,160,160,500,120,120,120,120,120,1500} }, + }, + GOLDEEN = { + front = { image = "assets/battle/front-animated/crystal/goldeen.png", width = 56, height = 56, columns = 15, frames = 15, durations = {100,450,160,240,240,240,240,240,240,240,190,140,140,140,1500} }, + }, + SEAKING = { + front = { image = "assets/battle/front-animated/crystal/seaking.png", width = 56, height = 56, columns = 16, frames = 16, durations = {150,250,200,200,150,200,150,200,200,600,250,200,200,300,250,1100} }, + }, + STARYU = { + front = { image = "assets/battle/front-animated/crystal/staryu.png", width = 56, height = 56, columns = 15, frames = 15, durations = {380,360,120,180,60,60,60,60,30,60,60,60,60,120,1000} }, + }, + STARMIE = { + front = { image = "assets/battle/front-animated/crystal/starmie.png", width = 56, height = 56, columns = 15, frames = 15, durations = {100,100,100,100,100,50,150,100,100,150,100,100,500,100,600} }, + }, + MR_MIME = { + front = { image = "assets/battle/front-animated/crystal/mr-mime.png", width = 56, height = 56, columns = 14, frames = 14, durations = {150,150,100,150,150,150,100,500,100,400,100,100,100,2250} }, + }, + SCYTHER = { + front = { image = "assets/battle/front-animated/crystal/scyther.png", width = 56, height = 56, columns = 16, frames = 34, durations = {10,150,300,150,100,50,100,50,100,100,50,100,50,100,50,100,50,350,100,100,50,50,100,50,100,50,50,100,50,100,50,50,50,1590} }, + }, + JYNX = { + front = { image = "assets/battle/front-animated/crystal/jynx.png", width = 56, height = 56, columns = 15, frames = 15, durations = {170,160,160,160,160,160,160,160,60,60,60,60,60,60,1000} }, + }, + ELECTABUZZ = { + front = { image = "assets/battle/front-animated/crystal/electabuzz.png", width = 56, height = 56, columns = 16, frames = 16, durations = {10,200,50,50,150,200,50,50,50,400,50,100,50,50,250,480} }, + }, + MAGMAR = { + front = { image = "assets/battle/front-animated/crystal/magmar.png", width = 56, height = 56, columns = 15, frames = 15, durations = {100,250,100,100,200,100,50,200,450,50,150,100,150,100,1000} }, + }, + PINSIR = { + front = { image = "assets/battle/front-animated/crystal/pinsir.png", width = 56, height = 56, columns = 14, frames = 14, durations = {10,200,150,150,250,200,200,450,100,150,150,100,150,2340} }, + }, + TAUROS = { + front = { image = "assets/battle/front-animated/crystal/tauros.png", width = 56, height = 56, columns = 16, frames = 18, durations = {120,120,250,120,120,120,120,120,120,120,120,120,100,100,100,100,100,2500} }, + }, + MAGIKARP = { + front = { image = "assets/battle/front-animated/crystal/magikarp.png", width = 56, height = 56, columns = 11, frames = 11, durations = {600,140,100,140,100,280,330,150,150,150,300} }, + }, + GYARADOS = { + front = { image = "assets/battle/front-animated/crystal/gyarados.png", width = 56, height = 56, columns = 9, frames = 9, durations = {120,120,120,120,500,100,100,100,2500} }, + }, + LAPRAS = { + front = { image = "assets/battle/front-animated/crystal/lapras.png", width = 56, height = 56, columns = 12, frames = 12, durations = {100,150,150,300,150,150,250,100,150,150,100,1000} }, + }, + DITTO = { + front = { image = "assets/battle/front-animated/crystal/ditto.png", width = 56, height = 56, columns = 15, frames = 15, durations = {10,150,150,100,150,50,200,200,150,250,400,150,50,150,230} }, + }, + EEVEE = { + front = { image = "assets/battle/front-animated/crystal/eevee.png", width = 56, height = 56, columns = 16, frames = 17, durations = {10,100,100,150,100,150,100,150,450,50,100,50,100,100,150,200,1490} }, + }, + VAPOREON = { + front = { image = "assets/battle/front-animated/crystal/vaporeon.png", width = 56, height = 56, columns = 14, frames = 14, durations = {250,150,200,150,150,200,100,500,50,100,100,100,100,1000} }, + }, + JOLTEON = { + front = { image = "assets/battle/front-animated/crystal/jolteon.png", width = 56, height = 56, columns = 15, frames = 15, durations = {10,100,600,100,450,100,100,100,450,100,100,200,100,150,1790} }, + }, + FLAREON = { + front = { image = "assets/battle/front-animated/crystal/flareon.png", width = 56, height = 56, columns = 13, frames = 13, durations = {10,250,100,100,100,100,100,100,100,100,100,100,990} }, + }, + PORYGON = { + front = { image = "assets/battle/front-animated/crystal/porygon.png", width = 56, height = 56, columns = 15, frames = 15, durations = {50,50,50,50,50,50,50,750,50,250,250,50,50,50,1000} }, + }, + OMANYTE = { + front = { image = "assets/battle/front-animated/crystal/omanyte.png", width = 56, height = 56, columns = 9, frames = 9, durations = {100,100,250,100,500,100,100,100,1000} }, + }, + OMASTAR = { + front = { image = "assets/battle/front-animated/crystal/omastar.png", width = 56, height = 56, columns = 9, frames = 9, durations = {100,150,600,90,280,160,160,320,800} }, + }, + KABUTO = { + front = { image = "assets/battle/front-animated/crystal/kabuto.png", width = 56, height = 56, columns = 11, frames = 11, durations = {100,200,250,250,400,100,100,100,250,100,180} }, + }, + KABUTOPS = { + front = { image = "assets/battle/front-animated/crystal/kabutops.png", width = 56, height = 56, columns = 13, frames = 13, durations = {10,750,100,50,100,400,450,150,100,100,100,100,990} }, + }, + AERODACTYL = { + front = { image = "assets/battle/front-animated/crystal/aerodactyl.png", width = 56, height = 56, columns = 13, frames = 13, durations = {250,200,400,100,100,100,100,100,500,50,50,50,1000} }, + }, + SNORLAX = { + front = { image = "assets/battle/front-animated/crystal/snorlax.png", width = 56, height = 56, columns = 13, frames = 13, durations = {10,100,300,200,500,100,160,100,500,100,160,100,2490} }, + }, + ARTICUNO = { + front = { image = "assets/battle/front-animated/crystal/articuno.png", width = 56, height = 56, columns = 16, frames = 16, durations = {150,150,200,150,150,200,200,150,350,150,100,100,100,100,200,40} }, + }, + ZAPDOS = { + front = { image = "assets/battle/front-animated/crystal/zapdos.png", width = 56, height = 56, columns = 13, frames = 13, durations = {10,150,150,150,200,150,200,150,350,150,750,100,1340} }, + }, + MOLTRES = { + front = { image = "assets/battle/front-animated/crystal/moltres.png", width = 56, height = 56, columns = 16, frames = 21, durations = {10,160,160,160,160,160,160,160,160,160,160,160,160,100,50,100,160,160,160,160,2490} }, + }, + DRATINI = { + front = { image = "assets/battle/front-animated/crystal/dratini.png", width = 56, height = 56, columns = 11, frames = 11, durations = {100,100,200,100,150,150,400,50,100,100,1000} }, + }, + DRAGONAIR = { + front = { image = "assets/battle/front-animated/crystal/dragonair.png", width = 56, height = 56, columns = 16, frames = 18, durations = {120,120,120,120,120,120,120,120,120,200,100,100,100,200,100,100,100,1200} }, + }, + DRAGONITE = { + front = { image = "assets/battle/front-animated/crystal/dragonite.png", width = 56, height = 56, columns = 16, frames = 18, durations = {200,200,200,200,150,250,200,200,200,200,150,200,200,650,50,100,50,1000} }, + }, + MEWTWO = { + front = { image = "assets/battle/front-animated/crystal/mewtwo.png", width = 56, height = 56, columns = 16, frames = 25, durations = {10,150,100,50,50,50,150,50,50,100,50,100,50,50,50,50,50,50,200,100,400,100,100,150,1740} }, + }, + MEW = { + front = { image = "assets/battle/front-animated/crystal/mew.png", width = 56, height = 56, columns = 16, frames = 23, durations = {100,150,100,100,50,150,50,50,50,200,400,350,400,300,250,150,450,50,100,100,150,300,1000} }, + }, +} diff --git a/lib/AnimatedBattleArt.lua b/lib/AnimatedBattleArt.lua index 7d8989d..ed08d6d 100644 --- a/lib/AnimatedBattleArt.lua +++ b/lib/AnimatedBattleArt.lua @@ -8,6 +8,7 @@ local V = ... local BattleArt = V.require("BattleArt") local DEFINITIONS = V.data("animated_battle_sprites") +local CRYSTAL = V.data("animated_battle_sprites_crystal") local AnimatedBattleArt = {} local loaded, loadOrder = {}, {} @@ -85,7 +86,11 @@ end local function definition(battler, side) local species = battler and battler.mon and battler.mon.species - local bySide = species and DEFINITIONS[tostring(species):upper()] + local key = species and tostring(species):upper() + local crystal = key and CRYSTAL[key] + local preferred = crystal and crystal[side] + if preferred and atlasPath(preferred) then return preferred end + local bySide = key and DEFINITIONS[key] return bySide and bySide[side] or nil end diff --git a/tools/import_crystal_front_sprites.py b/tools/import_crystal_front_sprites.py new file mode 100644 index 0000000..c3306c5 --- /dev/null +++ b/tools/import_crystal_front_sprites.py @@ -0,0 +1,123 @@ +"""Download Crystal #001-#151 GIFs and build native-resolution PNG atlases. + +Artwork is written below assets/battle/front-animated/crystal, which Git +ignores. Only the generated cell/timing metadata belongs in source control. + +Default source: https://bluemoonfalls.com/images/sprites/crystal/normal/ +""" + +from __future__ import annotations + +import argparse +import math +import re +import time +import urllib.request +from io import BytesIO +from pathlib import Path + +from PIL import Image, ImageSequence + + +DEFAULT_BASE = "https://bluemoonfalls.com/images/sprites/crystal/normal" +ENTRY = re.compile( + r'^ ([A-Z0-9_]+) = \{\n' + r' front = \{ image = "assets/battle/front-animated/([^/]+)\.png"', + re.MULTILINE, +) + + +def species(root: Path) -> list[tuple[str, str]]: + source = (root / "data" / "animated_battle_sprites.lua").read_text( + encoding="utf-8" + ) + entries = ENTRY.findall(source) + if len(entries) != 151: + raise ValueError(f"expected 151 Kanto species definitions, got {len(entries)}") + return entries + + +def download(url: str, retries: int = 4) -> bytes: + request = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"}) + for attempt in range(retries): + try: + with urllib.request.urlopen(request, timeout=30) as response: + return response.read() + except Exception: + if attempt + 1 == retries: + raise + time.sleep(1.5 * (attempt + 1)) + raise AssertionError("unreachable") + + +def convert(raw: bytes, destination: Path, max_columns: int = 16) -> dict: + with Image.open(BytesIO(raw)) as gif: + frames, durations = [], [] + for frame in ImageSequence.Iterator(gif): + durations.append( + int(frame.info.get("duration", gif.info.get("duration", 100))) + ) + frames.append(frame.convert("RGBA")) + if not frames: + raise ValueError("GIF contains no frames") + width, height = frames[0].size + if any(frame.size != (width, height) for frame in frames): + raise ValueError("GIF frames do not share one logical canvas") + columns = min(max_columns, len(frames)) + rows = math.ceil(len(frames) / columns) + sheet = Image.new("RGBA", (columns * width, rows * height)) + for index, frame in enumerate(frames): + sheet.alpha_composite( + frame, ((index % columns) * width, (index // columns) * height) + ) + destination.parent.mkdir(parents=True, exist_ok=True) + sheet.save(destination, optimize=True) + return { + "width": width, + "height": height, + "columns": columns, + "frames": len(frames), + "durations": durations, + } + + +def lua_definition(path: str, info: dict) -> str: + durations = ",".join(map(str, info["durations"])) + return ( + f'{{ image = "{path}", width = {info["width"]}, ' + f'height = {info["height"]}, columns = {info["columns"]}, ' + f'frames = {info["frames"]}, durations = {{{durations}}} }}' + ) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--root", type=Path, default=Path.cwd()) + parser.add_argument("--base-url", default=DEFAULT_BASE) + args = parser.parse_args() + root = args.root.resolve() + records = [] + for number, (engine_name, slug) in enumerate(species(root), 1): + url = f"{args.base_url.rstrip('/')}/{number}.gif" + relative = f"assets/battle/front-animated/crystal/{slug}.png" + print(f"[{number:03d}/151] {slug}", flush=True) + info = convert(download(url), root / relative) + records.append((engine_name, relative, info)) + + lines = [ + "-- Generated by tools/import_crystal_front_sprites.py.", + "-- Pokemon Crystal animated fronts, National Dex #001-#151.", + "return {", + ] + for engine_name, path, info in records: + lines.append(f" {engine_name} = {{") + lines.append(f" front = {lua_definition(path, info)},") + lines.append(" },") + lines.append("}") + destination = root / "data" / "animated_battle_sprites_crystal.lua" + destination.write_text("\n".join(lines) + "\n", encoding="utf-8") + print(f"wrote {destination} with {len(records)} animations") + + +if __name__ == "__main__": + main() From 23bedfa5f841156497389996a6d8f7a7a00cb8b8 Mon Sep 17 00:00:00 2001 From: absol89 Date: Sat, 1 Aug 2026 21:50:47 +0200 Subject: [PATCH 013/116] Organize animated art by generation --- CHANGELOG.md | 12 +- README.md | 8 +- assets/battle/back-animated/README.md | 17 +- assets/battle/front-animated/README.md | 24 +- data/animated_battle_sprites.lua | 608 ----------------------- data/animated_battle_sprites_crystal.lua | 457 ----------------- data/animated_battle_sprites_gen2.lua | 457 +++++++++++++++++ data/animated_battle_sprites_gen3.lua | 457 +++++++++++++++++ data/animated_battle_sprites_gen5.lua | 608 +++++++++++++++++++++++ lib/AnimatedBattleArt.lua | 15 +- lib/BattleArt.lua | 2 + main.lua | 4 + tools/import_animated_back_sprite.py | 263 ---------- tools/import_animated_sprites.py | 158 ++++++ tools/import_crystal_front_sprites.py | 123 ----- 15 files changed, 1724 insertions(+), 1489 deletions(-) delete mode 100644 data/animated_battle_sprites.lua delete mode 100644 data/animated_battle_sprites_crystal.lua create mode 100644 data/animated_battle_sprites_gen2.lua create mode 100644 data/animated_battle_sprites_gen3.lua create mode 100644 data/animated_battle_sprites_gen5.lua delete mode 100644 tools/import_animated_back_sprite.py create mode 100644 tools/import_animated_sprites.py delete mode 100644 tools/import_crystal_front_sprites.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bce462..12cb1bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,11 +21,13 @@ scaling, and sends every frame through the same transparency, display filtering, world lighting, depth and shadow path as static art. Missing or malformed atlases fall back per Pokemon to ROM art. -- **Optional Crystal front collection.** Local atlases in - `front-animated/crystal` are preferred per species, with the ordinary - animated atlas and ROM art as successive fallbacks. The authoring importer - downloads #001-#151, preserves their 56x56 logical canvases and original - frame delays, and commits only generated timing metadata—not artwork. +- **Generation-scoped animation sets.** `ANIM SET` selects GEN 2, GEN 3, or + GEN 5 from matching generation subfolders. Missing art falls directly back + to ROM instead of mixing visual styles. One authoring importer builds all + three formats; only generated dimensions/timings are committed, never art. +- **Crystal and Emerald front collections.** The local imports cover #001-#151 + while preserving every source frame's native canvas and delay. Gen 5 keeps + its existing animated fronts and backs in the same folder convention. ## 1.3.0 diff --git a/README.md b/README.md index 7175c7e..e2875af 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ menu. | `7`, or the **V-CURVE** options row | OFF → 1 → 2 → 3 — bend the world over the horizon | | `8`, or the **3D-BTL** options row | ON / OFF — fight on the map instead of on a white field | | the **BATTLE ART** options row | STATIC / ANIMATED / ROM — use optional battle-only art, with a direct ROM fallback when a file or atlas is absent | +| the **ANIM SET** options row | GEN 2 / GEN 3 / GEN 5 — choose one animated-art generation without mixing sets | | the **PLAYER VIEW** options row | FRONT / BACK — choose the player's view while keeping it world-placed, lit, shadowed and depth-occluded | | the **DAYTIME** options row | SYNC / DAY / NIGHT / DUSK / DAWN / CYCLE — what time it is outdoors, on the diorama *and* on the flat 2D world; held at SYNC (and off the menu) while VOXEL is FULL | @@ -61,10 +62,11 @@ art. Missing or unreadable files fall straight back to the ROM art. The four battle-art directories are intentionally ignored by Git except for their README contracts. `tools/package_mod.ps1` nevertheless includes local PNGs from them in a test ZIP, so artwork can stay private and uncommitted. -`BATTLE ART: ANIMATED` reads the PNG atlases described by -`data/animated_battle_sprites.lua`. The authoring importer generates both; +`BATTLE ART: ANIMATED` reads the generation selected by `ANIM SET` from +`front-animated/gen2`, `gen3`, or `gen5` (and `back-animated/gen5`). The +authoring importer generates the PNG atlases and one metadata table per set; the runtime extracts native-resolution cells without a DPI-scaled canvas and -falls back per missing or malformed atlas to ROM art. +falls back per missing or malformed atlas directly to ROM art. Trainer cards are always static. Opponent trainer fronts use class filenames in `front-static` (for example `youngster.png`, `cooltrainer-f.png`, and diff --git a/assets/battle/back-animated/README.md b/assets/battle/back-animated/README.md index 17fe272..e1db8bc 100644 --- a/assets/battle/back-animated/README.md +++ b/assets/battle/back-animated/README.md @@ -1,16 +1,17 @@ # Back animated battle art -Optional animated player-back battle atlases go here. Local PNG files are -ignored by Git. Authored back art faces right and is never mirrored. The -decoder expects the atlas cell dimensions and frame timings recorded in -`data/animated_battle_sprites.lua`; the supplied metadata recognizes the -atlases produced by `tools/import_animated_back_sprite.py`. Missing or -malformed art falls back to the ROM sprite for that Pokemon. +Optional animated player-back battle atlases are grouped here by generation. +Local PNG files are ignored by Git. Authored back art faces right and is never +mirrored. Gen 5 currently supplies `gen5/` back atlases; Gen 2 and Gen 3 have +front metadata only, so `PLAYER VIEW: BACK` falls back to the ROM back sprite +when either is selected. GIF decoding is authoring-only. The game reads PNG atlases, extracts every cell at its native logical resolution, and uses nearest-neighbour filtering. -The importer creates both the atlases and their single shared metadata file; -users do not write one Lua file per Pokemon. +The importer creates both the atlases and the selected set's shared metadata; +users do not write one Lua file per Pokemon. Run +`python tools/import_animated_sprites.py --set gen5` from the repository root +to generate both Gen 5 fronts and backs. ## Gen 1 filename exceptions diff --git a/assets/battle/front-animated/README.md b/assets/battle/front-animated/README.md index 27a8eb1..e70d834 100644 --- a/assets/battle/front-animated/README.md +++ b/assets/battle/front-animated/README.md @@ -1,23 +1,17 @@ # Front animated battle art -Optional animated opponent/player-front battle atlases go here. Local PNG -files are ignored by Git. The decoder expects the atlas cell dimensions and -frame timings recorded in `data/animated_battle_sprites.lua`; the supplied -metadata recognizes the atlases produced by -`tools/import_animated_back_sprite.py`. Missing or malformed art falls back to -the ROM sprite for that Pokemon. +Optional animated opponent/player-front battle atlases are grouped here as +`gen2/`, `gen3/`, and `gen5/`. Local PNG files are ignored by Git. `ANIM SET` +chooses exactly one folder; missing or malformed art falls back to the ROM +sprite rather than silently mixing generations. GIF decoding is authoring-only. The game reads PNG atlases, extracts every cell at its native logical resolution, and uses nearest-neighbour filtering. -The importer creates both the atlases and their single shared metadata file; -users do not write one Lua file per Pokemon. - -`crystal/` is the preferred front collection when a matching atlas exists. -Run `python tools/import_crystal_front_sprites.py --root .` from the repository -root to download National Dex #001-#151 from the configured source, convert -their GIF frames without resizing, and regenerate the shared Crystal timing -metadata. A missing Crystal species falls through to the root animated atlas -and then to ROM art. +The importer creates both the atlases and the selected set's shared metadata; +users do not write one Lua file per Pokemon. From the repository root, run one +of `python tools/import_animated_sprites.py --set gen2`, `--set gen3`, or +`--set gen5`. Gen 2 uses Crystal fronts, Gen 3 uses Emerald fronts, and Gen 5 +uses Black/White fronts. ## Gen 1 filename exceptions diff --git a/data/animated_battle_sprites.lua b/data/animated_battle_sprites.lua deleted file mode 100644 index 02434c5..0000000 --- a/data/animated_battle_sprites.lua +++ /dev/null @@ -1,608 +0,0 @@ --- Generated by tools/import_animated_back_sprite.py. --- Black/White animated front and back sprites, original 151. -return { - BULBASAUR = { - front = { image = "assets/battle/front-animated/bulbasaur.png", width = 37, height = 38, columns = 16, frames = 99, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/bulbasaur.png", width = 33, height = 38, columns = 16, frames = 99, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - IVYSAUR = { - front = { image = "assets/battle/front-animated/ivysaur.png", width = 58, height = 51, columns = 16, frames = 111, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/ivysaur.png", width = 57, height = 45, columns = 16, frames = 111, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - VENUSAUR = { - front = { image = "assets/battle/front-animated/venusaur.png", width = 86, height = 71, columns = 16, frames = 167, durations = {100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,100} }, - back = { image = "assets/battle/back-animated/venusaur.png", width = 89, height = 63, columns = 16, frames = 167, durations = {100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,100} }, - }, - CHARMANDER = { - front = { image = "assets/battle/front-animated/charmander.png", width = 42, height = 42, columns = 16, frames = 107, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/charmander.png", width = 43, height = 44, columns = 16, frames = 107, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - CHARMELEON = { - front = { image = "assets/battle/front-animated/charmeleon.png", width = 69, height = 56, columns = 16, frames = 89, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/charmeleon.png", width = 67, height = 60, columns = 16, frames = 89, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - CHARIZARD = { - front = { image = "assets/battle/front-animated/charizard.png", width = 89, height = 91, columns = 16, frames = 143, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/charizard.png", width = 98, height = 83, columns = 16, frames = 143, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - SQUIRTLE = { - front = { image = "assets/battle/front-animated/squirtle.png", width = 39, height = 43, columns = 16, frames = 51, durations = {50,50,50,50,50,50,50,100,50,50,50,50,50,50,150,50,50,50,50,50,50,100,50,50,50,50,50,50,350,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,800} }, - back = { image = "assets/battle/back-animated/squirtle.png", width = 40, height = 45, columns = 16, frames = 51, durations = {50,50,50,50,50,50,50,100,50,50,50,50,50,50,150,50,50,50,50,50,50,100,50,50,50,50,50,50,350,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,800} }, - }, - WARTORTLE = { - front = { image = "assets/battle/front-animated/wartortle.png", width = 61, height = 57, columns = 16, frames = 87, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/wartortle.png", width = 57, height = 62, columns = 16, frames = 87, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - BLASTOISE = { - front = { image = "assets/battle/front-animated/blastoise.png", width = 69, height = 65, columns = 16, frames = 244, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, - back = { image = "assets/battle/back-animated/blastoise.png", width = 66, height = 66, columns = 16, frames = 244, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, - }, - CATERPIE = { - front = { image = "assets/battle/front-animated/caterpie.png", width = 36, height = 37, columns = 16, frames = 81, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/caterpie.png", width = 38, height = 35, columns = 16, frames = 81, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - METAPOD = { - front = { image = "assets/battle/front-animated/metapod.png", width = 36, height = 38, columns = 16, frames = 111, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/metapod.png", width = 42, height = 43, columns = 16, frames = 111, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - BUTTERFREE = { - front = { image = "assets/battle/front-animated/butterfree.png", width = 62, height = 57, columns = 16, frames = 89, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/butterfree.png", width = 64, height = 57, columns = 16, frames = 29, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - WEEDLE = { - front = { image = "assets/battle/front-animated/weedle.png", width = 37, height = 46, columns = 16, frames = 123, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/weedle.png", width = 43, height = 46, columns = 16, frames = 123, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - KAKUNA = { - front = { image = "assets/battle/front-animated/kakuna.png", width = 32, height = 40, columns = 16, frames = 79, durations = {100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,150} }, - back = { image = "assets/battle/back-animated/kakuna.png", width = 32, height = 40, columns = 16, frames = 79, durations = {100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,150} }, - }, - BEEDRILL = { - front = { image = "assets/battle/front-animated/beedrill.png", width = 74, height = 69, columns = 16, frames = 60, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, - back = { image = "assets/battle/back-animated/beedrill.png", width = 76, height = 67, columns = 16, frames = 20, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, - }, - PIDGEY = { - front = { image = "assets/battle/front-animated/pidgey.png", width = 43, height = 48, columns = 16, frames = 41, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,1950,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,450} }, - back = { image = "assets/battle/back-animated/pidgey.png", width = 45, height = 47, columns = 16, frames = 41, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,1950,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,450} }, - }, - PIDGEOTTO = { - front = { image = "assets/battle/front-animated/pidgeotto.png", width = 82, height = 116, columns = 16, frames = 35, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/pidgeotto.png", width = 95, height = 96, columns = 16, frames = 35, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - PIDGEOT = { - front = { image = "assets/battle/front-animated/pidgeot.png", width = 58, height = 68, columns = 16, frames = 71, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/pidgeot.png", width = 68, height = 66, columns = 16, frames = 68, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - RATTATA = { - front = { image = "assets/battle/front-animated/rattata.png", width = 43, height = 43, columns = 16, frames = 101, durations = {50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/rattata.png", width = 58, height = 42, columns = 16, frames = 99, durations = {50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,100} }, - }, - RATICATE = { - front = { image = "assets/battle/front-animated/raticate.png", width = 65, height = 51, columns = 16, frames = 119, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/raticate.png", width = 66, height = 52, columns = 16, frames = 119, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - SPEAROW = { - front = { image = "assets/battle/front-animated/spearow.png", width = 33, height = 39, columns = 16, frames = 61, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/spearow.png", width = 35, height = 40, columns = 16, frames = 61, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - FEAROW = { - front = { image = "assets/battle/front-animated/fearow.png", width = 82, height = 105, columns = 16, frames = 21, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/fearow.png", width = 79, height = 112, columns = 16, frames = 21, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - EKANS = { - front = { image = "assets/battle/front-animated/ekans.png", width = 59, height = 46, columns = 16, frames = 71, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/ekans.png", width = 57, height = 49, columns = 16, frames = 71, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - ARBOK = { - front = { image = "assets/battle/front-animated/arbok.png", width = 81, height = 74, columns = 16, frames = 74, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/arbok.png", width = 86, height = 72, columns = 16, frames = 73, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - PIKACHU = { - front = { image = "assets/battle/front-animated/pikachu.png", width = 50, height = 46, columns = 16, frames = 112, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,100,100} }, - back = { image = "assets/battle/back-animated/pikachu.png", width = 41, height = 47, columns = 16, frames = 113, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,200} }, - }, - RAICHU = { - front = { image = "assets/battle/front-animated/raichu.png", width = 74, height = 73, columns = 16, frames = 167, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/raichu.png", width = 76, height = 72, columns = 16, frames = 167, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - SANDSHREW = { - front = { image = "assets/battle/front-animated/sandshrew.png", width = 35, height = 39, columns = 16, frames = 123, durations = {50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50} }, - back = { image = "assets/battle/back-animated/sandshrew.png", width = 41, height = 38, columns = 16, frames = 123, durations = {50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50} }, - }, - SANDSLASH = { - front = { image = "assets/battle/front-animated/sandslash.png", width = 59, height = 53, columns = 16, frames = 111, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,300} }, - back = { image = "assets/battle/back-animated/sandslash.png", width = 57, height = 52, columns = 16, frames = 111, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,300} }, - }, - NIDORAN_F = { - front = { image = "assets/battle/front-animated/nidoran-f.png", width = 36, height = 34, columns = 16, frames = 89, durations = {150,50,100,50,100,50,150,50,250,50,150,50,100,50,100,50,300,50,100,50,150,50,100,50,300,50,100,50,100,50,150,50,250,50,150,50,100,50,100,50,300,50,100,50,150,50,100,50,300,50,100,50,100,50,150,50,250,50,150,50,100,50,100,50,300,50,100,50,50,50,50,50,50,50,50,100,50,100,50,50,50,50,50,100,50,100,50,50,150} }, - back = { image = "assets/battle/back-animated/nidoran-f.png", width = 33, height = 36, columns = 16, frames = 82, durations = {150,50,100,50,100,50,150,50,250,50,150,50,100,50,100,50,300,50,100,50,150,50,100,50,300,50,100,50,100,50,150,50,250,50,150,50,100,50,100,50,300,50,100,50,150,50,100,50,300,50,100,50,100,50,150,50,250,50,150,50,100,50,100,50,300,50,100,50,150,50,100,50,150,150,50,100,50,100,50,150,50,150} }, - }, - NIDORINA = { - front = { image = "assets/battle/front-animated/nidorina.png", width = 52, height = 47, columns = 16, frames = 49, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/nidorina.png", width = 52, height = 44, columns = 16, frames = 49, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - NIDOQUEEN = { - front = { image = "assets/battle/front-animated/nidoqueen.png", width = 76, height = 69, columns = 16, frames = 123, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/nidoqueen.png", width = 85, height = 72, columns = 16, frames = 121, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - NIDORAN_M = { - front = { image = "assets/battle/front-animated/nidoran-m.png", width = 37, height = 37, columns = 16, frames = 121, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/nidoran-m.png", width = 37, height = 43, columns = 16, frames = 121, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - NIDORINO = { - front = { image = "assets/battle/front-animated/nidorino.png", width = 52, height = 56, columns = 16, frames = 99, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/nidorino.png", width = 55, height = 55, columns = 16, frames = 99, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - NIDOKING = { - front = { image = "assets/battle/front-animated/nidoking.png", width = 86, height = 71, columns = 16, frames = 97, durations = {100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,200,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,200,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,200,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/nidoking.png", width = 89, height = 69, columns = 16, frames = 105, durations = {100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - CLEFAIRY = { - front = { image = "assets/battle/front-animated/clefairy.png", width = 47, height = 42, columns = 16, frames = 133, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50} }, - back = { image = "assets/battle/back-animated/clefairy.png", width = 54, height = 43, columns = 16, frames = 132, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50} }, - }, - CLEFABLE = { - front = { image = "assets/battle/front-animated/clefable.png", width = 55, height = 52, columns = 16, frames = 102, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/clefable.png", width = 55, height = 51, columns = 16, frames = 102, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - VULPIX = { - front = { image = "assets/battle/front-animated/vulpix.png", width = 59, height = 49, columns = 16, frames = 71, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/vulpix.png", width = 61, height = 52, columns = 16, frames = 71, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - NINETALES = { - front = { image = "assets/battle/front-animated/ninetales.png", width = 76, height = 73, columns = 16, frames = 53, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/ninetales.png", width = 76, height = 64, columns = 16, frames = 107, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - JIGGLYPUFF = { - front = { image = "assets/battle/front-animated/jigglypuff.png", width = 49, height = 45, columns = 16, frames = 111, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/jigglypuff.png", width = 46, height = 48, columns = 16, frames = 109, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - WIGGLYTUFF = { - front = { image = "assets/battle/front-animated/wigglytuff.png", width = 49, height = 82, columns = 16, frames = 109, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,200,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,100,50,100,50,100,50,100,50,100,50,50,50,50,50,50,50,50,50,100,100} }, - back = { image = "assets/battle/back-animated/wigglytuff.png", width = 47, height = 83, columns = 16, frames = 109, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,200,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,100,50,100,50,100,50,100,50,50,50,50,50,50,50,50,50,50,50,50,200} }, - }, - ZUBAT = { - front = { image = "assets/battle/front-animated/zubat.png", width = 53, height = 46, columns = 16, frames = 54, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, - back = { image = "assets/battle/back-animated/zubat.png", width = 49, height = 59, columns = 16, frames = 54, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, - }, - GOLBAT = { - front = { image = "assets/battle/front-animated/golbat.png", width = 111, height = 83, columns = 16, frames = 16, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, - back = { image = "assets/battle/back-animated/golbat.png", width = 95, height = 84, columns = 16, frames = 16, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, - }, - ODDISH = { - front = { image = "assets/battle/front-animated/oddish.png", width = 38, height = 50, columns = 16, frames = 109, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/oddish.png", width = 41, height = 51, columns = 16, frames = 109, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - GLOOM = { - front = { image = "assets/battle/front-animated/gloom.png", width = 51, height = 45, columns = 16, frames = 95, durations = {100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,100} }, - back = { image = "assets/battle/back-animated/gloom.png", width = 49, height = 46, columns = 16, frames = 95, durations = {100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,100} }, - }, - VILEPLUME = { - front = { image = "assets/battle/front-animated/vileplume.png", width = 56, height = 50, columns = 16, frames = 71, durations = {50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/vileplume.png", width = 56, height = 48, columns = 16, frames = 71, durations = {50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,100} }, - }, - PARAS = { - front = { image = "assets/battle/front-animated/paras.png", width = 48, height = 38, columns = 16, frames = 73, durations = {50,50,50,50,50,50,150,50,50,50,50,50,150,50,50,50,50,50,150,50,50,50,50,50,150,50,50,50,50,50,150,50,50,50,50,50,150,50,50,50,50,50,150,50,50,50,50,50,150,50,50,50,50,50,150,50,50,50,50,50,150,50,50,50,50,50,150,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/paras.png", width = 48, height = 32, columns = 16, frames = 74, durations = {50,50,50,50,50,50,150,50,50,50,50,50,150,50,50,50,50,50,150,50,50,50,50,50,150,50,50,50,50,50,150,50,50,50,50,50,150,50,50,50,50,50,150,50,50,50,50,50,100,50,50,50,50,50,50,150,50,50,50,50,50,150,50,50,50,50,50,150,50,50,50,50,50,100} }, - }, - PARASECT = { - front = { image = "assets/battle/front-animated/parasect.png", width = 62, height = 63, columns = 16, frames = 71, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/parasect.png", width = 70, height = 52, columns = 16, frames = 65, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - VENONAT = { - front = { image = "assets/battle/front-animated/venonat.png", width = 42, height = 53, columns = 16, frames = 95, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/venonat.png", width = 42, height = 52, columns = 16, frames = 95, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - VENOMOTH = { - front = { image = "assets/battle/front-animated/venomoth.png", width = 75, height = 74, columns = 16, frames = 21, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, - back = { image = "assets/battle/back-animated/venomoth.png", width = 83, height = 73, columns = 16, frames = 21, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, - }, - DIGLETT = { - front = { image = "assets/battle/front-animated/diglett.png", width = 38, height = 31, columns = 16, frames = 108, durations = {50,50,50,50,50,50,150,50,700,100,100,100,100,50,50,50,50,50,50,50,50,150,50,700,200,100,100,50,50,50,50,50,50,150,50,700,100,100,100,100,50,50,50,50,50,50,50,50,150,50,700,200,100,100,100,100,100,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,100,100,100,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/diglett.png", width = 38, height = 32, columns = 16, frames = 108, durations = {50,50,50,50,50,50,150,50,700,100,100,100,100,50,50,50,50,50,50,50,50,150,50,600,200,100,200,50,50,50,50,50,50,150,50,700,100,100,100,100,50,50,50,50,50,50,50,50,150,50,600,200,100,200,100,100,100,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,100,100,100,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - DUGTRIO = { - front = { image = "assets/battle/front-animated/dugtrio.png", width = 52, height = 41, columns = 16, frames = 83, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/dugtrio.png", width = 52, height = 42, columns = 16, frames = 83, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - MEOWTH = { - front = { image = "assets/battle/front-animated/meowth.png", width = 48, height = 65, columns = 16, frames = 139, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/meowth.png", width = 58, height = 64, columns = 16, frames = 139, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - PERSIAN = { - front = { image = "assets/battle/front-animated/persian.png", width = 73, height = 62, columns = 16, frames = 107, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/persian.png", width = 78, height = 56, columns = 16, frames = 107, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - PSYDUCK = { - front = { image = "assets/battle/front-animated/psyduck.png", width = 38, height = 46, columns = 16, frames = 44, durations = {150,50,100,50,100,50,150,50,100,50,150,450,50,150,50,100,50,150,450,50,150,50,100,50,100,50,150,50,100,50,100,50,150,50,100,50,150,450,50,150,50,100,50,150} }, - back = { image = "assets/battle/back-animated/psyduck.png", width = 37, height = 44, columns = 16, frames = 46, durations = {50,50,100,50,150,50,50,50,50,50,50,50,100,100,50,450,50,150,50,150,100,50,400,50,50,50,50,50,50,50,100,50,50,50,50,50,350,50,150,50,150,100,50,450,50,200} }, - }, - GOLDUCK = { - front = { image = "assets/battle/front-animated/golduck.png", width = 60, height = 59, columns = 16, frames = 76, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/golduck.png", width = 55, height = 57, columns = 16, frames = 81, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - MANKEY = { - front = { image = "assets/battle/front-animated/mankey.png", width = 66, height = 61, columns = 16, frames = 120, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, - back = { image = "assets/battle/back-animated/mankey.png", width = 62, height = 59, columns = 16, frames = 120, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, - }, - PRIMEAPE = { - front = { image = "assets/battle/front-animated/primeape.png", width = 66, height = 60, columns = 16, frames = 56, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, - back = { image = "assets/battle/back-animated/primeape.png", width = 72, height = 58, columns = 16, frames = 51, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,200} }, - }, - GROWLITHE = { - front = { image = "assets/battle/front-animated/growlithe.png", width = 55, height = 52, columns = 16, frames = 141, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/growlithe.png", width = 57, height = 57, columns = 16, frames = 135, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - ARCANINE = { - front = { image = "assets/battle/front-animated/arcanine.png", width = 77, height = 69, columns = 16, frames = 71, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/arcanine.png", width = 86, height = 69, columns = 16, frames = 71, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - POLIWAG = { - front = { image = "assets/battle/front-animated/poliwag.png", width = 54, height = 34, columns = 16, frames = 79, durations = {100,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, - back = { image = "assets/battle/back-animated/poliwag.png", width = 55, height = 35, columns = 16, frames = 79, durations = {100,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, - }, - POLIWHIRL = { - front = { image = "assets/battle/front-animated/poliwhirl.png", width = 74, height = 55, columns = 16, frames = 107, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/poliwhirl.png", width = 80, height = 57, columns = 16, frames = 107, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - POLIWRATH = { - front = { image = "assets/battle/front-animated/poliwrath.png", width = 81, height = 58, columns = 16, frames = 89, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/poliwrath.png", width = 74, height = 57, columns = 16, frames = 89, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - ABRA = { - front = { image = "assets/battle/front-animated/abra.png", width = 63, height = 53, columns = 16, frames = 113, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/abra.png", width = 71, height = 56, columns = 16, frames = 111, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - KADABRA = { - front = { image = "assets/battle/front-animated/kadabra.png", width = 79, height = 60, columns = 16, frames = 119, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/kadabra.png", width = 80, height = 66, columns = 16, frames = 121, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,50,50,50,50,50,50,100} }, - }, - ALAKAZAM = { - front = { image = "assets/battle/front-animated/alakazam.png", width = 93, height = 70, columns = 16, frames = 105, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,350,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/alakazam.png", width = 82, height = 67, columns = 16, frames = 105, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,350,50,50,50,50,50,50,50,50,50,100} }, - }, - MACHOP = { - front = { image = "assets/battle/front-animated/machop.png", width = 51, height = 50, columns = 16, frames = 149, durations = {50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/machop.png", width = 53, height = 51, columns = 16, frames = 149, durations = {50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - MACHOKE = { - front = { image = "assets/battle/front-animated/machoke.png", width = 60, height = 71, columns = 16, frames = 98, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,450,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,450,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,450,50,100,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/machoke.png", width = 61, height = 71, columns = 16, frames = 95, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,550,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,550,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,550,50,50,50,50,50,100} }, - }, - MACHAMP = { - front = { image = "assets/battle/front-animated/machamp.png", width = 64, height = 76, columns = 16, frames = 59, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,750,50,50,50,50,50,50,50,50,50,50,50,50,50,350,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/machamp.png", width = 67, height = 76, columns = 16, frames = 59, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,750,50,50,50,50,50,50,50,50,50,50,50,50,50,350,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - BELLSPROUT = { - front = { image = "assets/battle/front-animated/bellsprout.png", width = 41, height = 44, columns = 16, frames = 95, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,150,50,50,50,50,50,50,100,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, - back = { image = "assets/battle/back-animated/bellsprout.png", width = 45, height = 51, columns = 16, frames = 95, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,150,50,50,50,50,50,50,100,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, - }, - WEEPINBELL = { - front = { image = "assets/battle/front-animated/weepinbell.png", width = 69, height = 51, columns = 16, frames = 97, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/weepinbell.png", width = 72, height = 54, columns = 16, frames = 97, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - VICTREEBEL = { - front = { image = "assets/battle/front-animated/victreebel.png", width = 65, height = 71, columns = 16, frames = 71, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/victreebel.png", width = 69, height = 70, columns = 16, frames = 71, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - TENTACOOL = { - front = { image = "assets/battle/front-animated/tentacool.png", width = 60, height = 71, columns = 16, frames = 95, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/tentacool.png", width = 62, height = 76, columns = 16, frames = 95, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - TENTACRUEL = { - front = { image = "assets/battle/front-animated/tentacruel.png", width = 84, height = 84, columns = 16, frames = 80, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, - back = { image = "assets/battle/back-animated/tentacruel.png", width = 84, height = 85, columns = 16, frames = 80, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, - }, - GEODUDE = { - front = { image = "assets/battle/front-animated/geodude.png", width = 61, height = 45, columns = 16, frames = 103, durations = {50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/geodude.png", width = 58, height = 51, columns = 16, frames = 106, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - GRAVELER = { - front = { image = "assets/battle/front-animated/graveler.png", width = 71, height = 44, columns = 16, frames = 47, durations = {50,50,50,50,50,50,50,50,300,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,300,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,250,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/graveler.png", width = 65, height = 43, columns = 16, frames = 47, durations = {50,50,50,50,50,50,50,50,300,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,300,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,250,50,50,50,50,50,100} }, - }, - GOLEM = { - front = { image = "assets/battle/front-animated/golem.png", width = 72, height = 57, columns = 16, frames = 91, durations = {50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/golem.png", width = 69, height = 56, columns = 16, frames = 91, durations = {50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - PONYTA = { - front = { image = "assets/battle/front-animated/ponyta.png", width = 57, height = 57, columns = 16, frames = 91, durations = {100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/ponyta.png", width = 59, height = 59, columns = 16, frames = 91, durations = {100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - RAPIDASH = { - front = { image = "assets/battle/front-animated/rapidash.png", width = 74, height = 73, columns = 16, frames = 47, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/rapidash.png", width = 76, height = 76, columns = 16, frames = 48, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, - }, - SLOWPOKE = { - front = { image = "assets/battle/front-animated/slowpoke.png", width = 64, height = 53, columns = 16, frames = 89, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/slowpoke.png", width = 68, height = 50, columns = 16, frames = 89, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - SLOWBRO = { - front = { image = "assets/battle/front-animated/slowbro.png", width = 63, height = 60, columns = 16, frames = 113, durations = {100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,250,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,100,50,200,50,100,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,100} }, - back = { image = "assets/battle/back-animated/slowbro.png", width = 76, height = 58, columns = 16, frames = 113, durations = {100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,250,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,100,50,200,50,100,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,100} }, - }, - MAGNEMITE = { - front = { image = "assets/battle/front-animated/magnemite.png", width = 38, height = 32, columns = 16, frames = 105, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/magnemite.png", width = 36, height = 31, columns = 16, frames = 105, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - MAGNETON = { - front = { image = "assets/battle/front-animated/magneton.png", width = 68, height = 59, columns = 16, frames = 143, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/magneton.png", width = 73, height = 58, columns = 16, frames = 143, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - FARFETCHD = { - front = { image = "assets/battle/front-animated/farfetchd.png", width = 45, height = 48, columns = 16, frames = 65, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,400,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,350} }, - back = { image = "assets/battle/back-animated/farfetchd.png", width = 46, height = 50, columns = 16, frames = 65, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,400,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,350} }, - }, - DODUO = { - front = { image = "assets/battle/front-animated/doduo.png", width = 60, height = 53, columns = 16, frames = 189, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, - back = { image = "assets/battle/back-animated/doduo.png", width = 61, height = 56, columns = 16, frames = 189, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, - }, - DODRIO = { - front = { image = "assets/battle/front-animated/dodrio.png", width = 70, height = 76, columns = 16, frames = 155, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/dodrio.png", width = 72, height = 76, columns = 16, frames = 155, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - SEEL = { - front = { image = "assets/battle/front-animated/seel.png", width = 62, height = 47, columns = 16, frames = 49, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/seel.png", width = 60, height = 43, columns = 16, frames = 49, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - DEWGONG = { - front = { image = "assets/battle/front-animated/dewgong.png", width = 75, height = 74, columns = 16, frames = 121, durations = {200,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,200,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,150,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,300,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,100} }, - back = { image = "assets/battle/back-animated/dewgong.png", width = 77, height = 88, columns = 16, frames = 121, durations = {200,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,200,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,150,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,300,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,100} }, - }, - GRIMER = { - front = { image = "assets/battle/front-animated/grimer.png", width = 73, height = 42, columns = 16, frames = 116, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/grimer.png", width = 70, height = 46, columns = 16, frames = 113, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - MUK = { - front = { image = "assets/battle/front-animated/muk.png", width = 94, height = 68, columns = 16, frames = 103, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/muk.png", width = 100, height = 81, columns = 16, frames = 103, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - SHELLDER = { - front = { image = "assets/battle/front-animated/shellder.png", width = 42, height = 38, columns = 16, frames = 87, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/shellder.png", width = 48, height = 37, columns = 16, frames = 87, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - CLOYSTER = { - front = { image = "assets/battle/front-animated/cloyster.png", width = 74, height = 65, columns = 16, frames = 101, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/cloyster.png", width = 69, height = 68, columns = 16, frames = 101, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - GASTLY = { - front = { image = "assets/battle/front-animated/gastly.png", width = 67, height = 75, columns = 16, frames = 110, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, - back = { image = "assets/battle/back-animated/gastly.png", width = 63, height = 78, columns = 16, frames = 110, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, - }, - HAUNTER = { - front = { image = "assets/battle/front-animated/haunter.png", width = 85, height = 70, columns = 16, frames = 25, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/haunter.png", width = 90, height = 71, columns = 16, frames = 25, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - GENGAR = { - front = { image = "assets/battle/front-animated/gengar.png", width = 79, height = 65, columns = 16, frames = 88, durations = {50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, - back = { image = "assets/battle/back-animated/gengar.png", width = 73, height = 64, columns = 16, frames = 88, durations = {50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, - }, - ONIX = { - front = { image = "assets/battle/front-animated/onix.png", width = 74, height = 74, columns = 16, frames = 99, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/onix.png", width = 98, height = 70, columns = 16, frames = 143, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - DROWZEE = { - front = { image = "assets/battle/front-animated/drowzee.png", width = 53, height = 58, columns = 16, frames = 153, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/drowzee.png", width = 56, height = 56, columns = 16, frames = 153, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - HYPNO = { - front = { image = "assets/battle/front-animated/hypno.png", width = 66, height = 62, columns = 16, frames = 125, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/hypno.png", width = 65, height = 60, columns = 16, frames = 123, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - KRABBY = { - front = { image = "assets/battle/front-animated/krabby.png", width = 55, height = 39, columns = 16, frames = 73, durations = {50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/krabby.png", width = 55, height = 43, columns = 16, frames = 73, durations = {50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - KINGLER = { - front = { image = "assets/battle/front-animated/kingler.png", width = 82, height = 59, columns = 16, frames = 97, durations = {50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/kingler.png", width = 83, height = 62, columns = 16, frames = 97, durations = {50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - VOLTORB = { - front = { image = "assets/battle/front-animated/voltorb.png", width = 46, height = 31, columns = 16, frames = 71, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,450,150,500} }, - back = { image = "assets/battle/back-animated/voltorb.png", width = 47, height = 31, columns = 16, frames = 70, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,450,650} }, - }, - ELECTRODE = { - front = { image = "assets/battle/front-animated/electrode.png", width = 49, height = 52, columns = 16, frames = 49, durations = {50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,100,100,50,50,50,50,50,50,500,400,100,100,100,50,50,50,50,50,50,100,700} }, - back = { image = "assets/battle/back-animated/electrode.png", width = 49, height = 52, columns = 16, frames = 45, durations = {50,100,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,250,50,50,50,50,50,550,100,100,100,400,50,50,50,50,50,800} }, - }, - EXEGGCUTE = { - front = { image = "assets/battle/front-animated/exeggcute.png", width = 55, height = 44, columns = 16, frames = 53, durations = {450,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,450,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,450,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/exeggcute.png", width = 54, height = 40, columns = 16, frames = 53, durations = {450,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,450,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,450,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - EXEGGUTOR = { - front = { image = "assets/battle/front-animated/exeggutor.png", width = 79, height = 73, columns = 16, frames = 89, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/exeggutor.png", width = 76, height = 70, columns = 16, frames = 81, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - CUBONE = { - front = { image = "assets/battle/front-animated/cubone.png", width = 49, height = 43, columns = 16, frames = 157, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/cubone.png", width = 52, height = 41, columns = 16, frames = 157, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - MAROWAK = { - front = { image = "assets/battle/front-animated/marowak.png", width = 68, height = 59, columns = 16, frames = 136, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/marowak.png", width = 65, height = 53, columns = 16, frames = 133, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - HITMONLEE = { - front = { image = "assets/battle/front-animated/hitmonlee.png", width = 68, height = 78, columns = 16, frames = 112, durations = {50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50} }, - back = { image = "assets/battle/back-animated/hitmonlee.png", width = 62, height = 75, columns = 16, frames = 108, durations = {150,50,50,50,50,50,50,100,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,100,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,150,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50} }, - }, - HITMONCHAN = { - front = { image = "assets/battle/front-animated/hitmonchan.png", width = 34, height = 59, columns = 16, frames = 81, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/hitmonchan.png", width = 46, height = 56, columns = 16, frames = 81, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - LICKITUNG = { - front = { image = "assets/battle/front-animated/lickitung.png", width = 69, height = 52, columns = 16, frames = 70, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, - back = { image = "assets/battle/back-animated/lickitung.png", width = 77, height = 53, columns = 16, frames = 72, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, - }, - KOFFING = { - front = { image = "assets/battle/front-animated/koffing.png", width = 91, height = 59, columns = 16, frames = 87, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/koffing.png", width = 79, height = 64, columns = 16, frames = 87, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - WEEZING = { - front = { image = "assets/battle/front-animated/weezing.png", width = 86, height = 67, columns = 16, frames = 99, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/weezing.png", width = 87, height = 68, columns = 16, frames = 99, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - RHYHORN = { - front = { image = "assets/battle/front-animated/rhyhorn.png", width = 68, height = 54, columns = 16, frames = 157, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/rhyhorn.png", width = 73, height = 54, columns = 16, frames = 167, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - RHYDON = { - front = { image = "assets/battle/front-animated/rhydon.png", width = 78, height = 67, columns = 16, frames = 193, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/rhydon.png", width = 85, height = 68, columns = 16, frames = 193, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - CHANSEY = { - front = { image = "assets/battle/front-animated/chansey.png", width = 61, height = 47, columns = 16, frames = 116, durations = {50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, - back = { image = "assets/battle/back-animated/chansey.png", width = 67, height = 47, columns = 16, frames = 116, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, - }, - TANGELA = { - front = { image = "assets/battle/front-animated/tangela.png", width = 49, height = 42, columns = 16, frames = 63, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/tangela.png", width = 49, height = 42, columns = 16, frames = 63, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - KANGASKHAN = { - front = { image = "assets/battle/front-animated/kangaskhan.png", width = 79, height = 61, columns = 16, frames = 253, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/kangaskhan.png", width = 73, height = 66, columns = 16, frames = 253, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - HORSEA = { - front = { image = "assets/battle/front-animated/horsea.png", width = 34, height = 43, columns = 16, frames = 80, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, - back = { image = "assets/battle/back-animated/horsea.png", width = 39, height = 40, columns = 16, frames = 80, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, - }, - SEADRA = { - front = { image = "assets/battle/front-animated/seadra.png", width = 58, height = 56, columns = 16, frames = 64, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, - back = { image = "assets/battle/back-animated/seadra.png", width = 64, height = 59, columns = 16, frames = 64, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, - }, - GOLDEEN = { - front = { image = "assets/battle/front-animated/goldeen.png", width = 75, height = 57, columns = 16, frames = 114, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, - back = { image = "assets/battle/back-animated/goldeen.png", width = 75, height = 58, columns = 16, frames = 114, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, - }, - SEAKING = { - front = { image = "assets/battle/front-animated/seaking.png", width = 74, height = 66, columns = 16, frames = 96, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, - back = { image = "assets/battle/back-animated/seaking.png", width = 96, height = 72, columns = 16, frames = 96, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, - }, - STARYU = { - front = { image = "assets/battle/front-animated/staryu.png", width = 57, height = 49, columns = 16, frames = 79, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/staryu.png", width = 55, height = 52, columns = 16, frames = 79, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - STARMIE = { - front = { image = "assets/battle/front-animated/starmie.png", width = 65, height = 59, columns = 16, frames = 159, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/starmie.png", width = 56, height = 56, columns = 16, frames = 159, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - MR_MIME = { - front = { image = "assets/battle/front-animated/mr-mime.png", width = 57, height = 53, columns = 16, frames = 127, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/mr-mime.png", width = 55, height = 51, columns = 16, frames = 127, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - SCYTHER = { - front = { image = "assets/battle/front-animated/scyther.png", width = 55, height = 61, columns = 16, frames = 137, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/scyther.png", width = 69, height = 65, columns = 16, frames = 137, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - JYNX = { - front = { image = "assets/battle/front-animated/jynx.png", width = 63, height = 60, columns = 16, frames = 107, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/jynx.png", width = 62, height = 60, columns = 16, frames = 107, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - ELECTABUZZ = { - front = { image = "assets/battle/front-animated/electabuzz.png", width = 71, height = 65, columns = 16, frames = 95, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/electabuzz.png", width = 71, height = 63, columns = 16, frames = 95, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - MAGMAR = { - front = { image = "assets/battle/front-animated/magmar.png", width = 73, height = 61, columns = 16, frames = 65, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/magmar.png", width = 70, height = 59, columns = 16, frames = 65, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - PINSIR = { - front = { image = "assets/battle/front-animated/pinsir.png", width = 72, height = 62, columns = 16, frames = 143, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/pinsir.png", width = 70, height = 61, columns = 16, frames = 143, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - TAUROS = { - front = { image = "assets/battle/front-animated/tauros.png", width = 76, height = 59, columns = 16, frames = 79, durations = {100,50,100,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,100} }, - back = { image = "assets/battle/back-animated/tauros.png", width = 72, height = 54, columns = 16, frames = 81, durations = {100,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,100,50,100,50,50,50,50,50,50,50,50,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,100,50,100,50,50,50,50,50,50,50,50,50,50,50,100,50,100,50,100} }, - }, - MAGIKARP = { - front = { image = "assets/battle/front-animated/magikarp.png", width = 47, height = 63, columns = 16, frames = 120, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, - back = { image = "assets/battle/back-animated/magikarp.png", width = 66, height = 58, columns = 16, frames = 117, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, - }, - GYARADOS = { - front = { image = "assets/battle/front-animated/gyarados.png", width = 102, height = 84, columns = 16, frames = 87, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/gyarados.png", width = 108, height = 82, columns = 16, frames = 87, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - LAPRAS = { - front = { image = "assets/battle/front-animated/lapras.png", width = 68, height = 71, columns = 16, frames = 65, durations = {100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,200,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,200,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,200,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,100} }, - back = { image = "assets/battle/back-animated/lapras.png", width = 76, height = 71, columns = 16, frames = 65, durations = {100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,200,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,200,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,200,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,100} }, - }, - DITTO = { - front = { image = "assets/battle/front-animated/ditto.png", width = 47, height = 32, columns = 16, frames = 80, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, - back = { image = "assets/battle/back-animated/ditto.png", width = 47, height = 32, columns = 16, frames = 80, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, - }, - EEVEE = { - front = { image = "assets/battle/front-animated/eevee.png", width = 49, height = 47, columns = 16, frames = 125, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/eevee.png", width = 49, height = 50, columns = 16, frames = 133, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - VAPOREON = { - front = { image = "assets/battle/front-animated/vaporeon.png", width = 50, height = 59, columns = 16, frames = 101, durations = {100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,250,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,250,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,250,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,150} }, - back = { image = "assets/battle/back-animated/vaporeon.png", width = 50, height = 56, columns = 16, frames = 107, durations = {100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,150} }, - }, - JOLTEON = { - front = { image = "assets/battle/front-animated/jolteon.png", width = 48, height = 49, columns = 16, frames = 99, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/jolteon.png", width = 49, height = 48, columns = 16, frames = 99, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - FLAREON = { - front = { image = "assets/battle/front-animated/flareon.png", width = 66, height = 60, columns = 16, frames = 119, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/flareon.png", width = 70, height = 59, columns = 16, frames = 119, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - PORYGON = { - front = { image = "assets/battle/front-animated/porygon.png", width = 56, height = 52, columns = 16, frames = 60, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, - back = { image = "assets/battle/back-animated/porygon.png", width = 62, height = 47, columns = 16, frames = 53, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,150,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,300,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, - }, - OMANYTE = { - front = { image = "assets/battle/front-animated/omanyte.png", width = 43, height = 45, columns = 16, frames = 68, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, - back = { image = "assets/battle/back-animated/omanyte.png", width = 48, height = 48, columns = 16, frames = 68, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, - }, - OMASTAR = { - front = { image = "assets/battle/front-animated/omastar.png", width = 63, height = 60, columns = 16, frames = 155, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/omastar.png", width = 65, height = 63, columns = 16, frames = 155, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - KABUTO = { - front = { image = "assets/battle/front-animated/kabuto.png", width = 39, height = 41, columns = 16, frames = 67, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/kabuto.png", width = 38, height = 42, columns = 16, frames = 67, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - KABUTOPS = { - front = { image = "assets/battle/front-animated/kabutops.png", width = 65, height = 61, columns = 16, frames = 47, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, - back = { image = "assets/battle/back-animated/kabutops.png", width = 67, height = 55, columns = 16, frames = 47, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, - }, - AERODACTYL = { - front = { image = "assets/battle/front-animated/aerodactyl.png", width = 93, height = 81, columns = 16, frames = 17, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, - back = { image = "assets/battle/back-animated/aerodactyl.png", width = 91, height = 91, columns = 16, frames = 17, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, - }, - SNORLAX = { - front = { image = "assets/battle/front-animated/snorlax.png", width = 75, height = 74, columns = 16, frames = 173, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/snorlax.png", width = 75, height = 71, columns = 16, frames = 173, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - ARTICUNO = { - front = { image = "assets/battle/front-animated/articuno.png", width = 72, height = 81, columns = 16, frames = 51, durations = {150,50,50,50,50,50,50,50,50,50,50,50,50,50,450,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,950,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/articuno.png", width = 78, height = 87, columns = 16, frames = 51, durations = {150,50,50,50,50,50,50,50,50,50,50,50,50,50,450,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,950,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - ZAPDOS = { - front = { image = "assets/battle/front-animated/zapdos.png", width = 97, height = 71, columns = 16, frames = 17, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/zapdos.png", width = 85, height = 73, columns = 16, frames = 17, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - MOLTRES = { - front = { image = "assets/battle/front-animated/moltres.png", width = 129, height = 98, columns = 15, frames = 53, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/moltres.png", width = 117, height = 79, columns = 16, frames = 53, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - DRATINI = { - front = { image = "assets/battle/front-animated/dratini.png", width = 44, height = 51, columns = 16, frames = 49, durations = {150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,250,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/dratini.png", width = 47, height = 57, columns = 16, frames = 49, durations = {150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,250,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,100} }, - }, - DRAGONAIR = { - front = { image = "assets/battle/front-animated/dragonair.png", width = 65, height = 74, columns = 16, frames = 127, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/dragonair.png", width = 64, height = 74, columns = 16, frames = 127, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - DRAGONITE = { - front = { image = "assets/battle/front-animated/dragonite.png", width = 80, height = 82, columns = 16, frames = 102, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,600,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,400} }, - back = { image = "assets/battle/back-animated/dragonite.png", width = 86, height = 81, columns = 16, frames = 102, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,600,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,400} }, - }, - MEWTWO = { - front = { image = "assets/battle/front-animated/mewtwo.png", width = 90, height = 69, columns = 16, frames = 125, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/mewtwo.png", width = 95, height = 70, columns = 16, frames = 125, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, - MEW = { - front = { image = "assets/battle/front-animated/mew.png", width = 78, height = 54, columns = 16, frames = 119, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - back = { image = "assets/battle/back-animated/mew.png", width = 60, height = 60, columns = 16, frames = 119, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, - }, -} diff --git a/data/animated_battle_sprites_crystal.lua b/data/animated_battle_sprites_crystal.lua deleted file mode 100644 index bf4e978..0000000 --- a/data/animated_battle_sprites_crystal.lua +++ /dev/null @@ -1,457 +0,0 @@ --- Generated by tools/import_crystal_front_sprites.py. --- Pokemon Crystal animated fronts, National Dex #001-#151. -return { - BULBASAUR = { - front = { image = "assets/battle/front-animated/crystal/bulbasaur.png", width = 56, height = 56, columns = 14, frames = 14, durations = {10,180,180,140,80,360,80,60,60,380,60,60,60,990} }, - }, - IVYSAUR = { - front = { image = "assets/battle/front-animated/crystal/ivysaur.png", width = 56, height = 56, columns = 10, frames = 10, durations = {400,250,250,300,150,150,150,150,250,1000} }, - }, - VENUSAUR = { - front = { image = "assets/battle/front-animated/crystal/venusaur.png", width = 56, height = 56, columns = 14, frames = 14, durations = {180,680,140,120,180,160,180,180,400,660,240,240,220,1000} }, - }, - CHARMANDER = { - front = { image = "assets/battle/front-animated/crystal/charmander.png", width = 56, height = 56, columns = 12, frames = 12, durations = {50,200,350,150,100,200,150,400,100,100,100,2400} }, - }, - CHARMELEON = { - front = { image = "assets/battle/front-animated/crystal/charmeleon.png", width = 56, height = 56, columns = 16, frames = 16, durations = {10,150,100,150,150,250,150,450,350,100,200,150,200,150,100,1790} }, - }, - CHARIZARD = { - front = { image = "assets/battle/front-animated/crystal/charizard.png", width = 56, height = 56, columns = 11, frames = 11, durations = {100,300,150,150,150,150,350,150,100,100,1000} }, - }, - SQUIRTLE = { - front = { image = "assets/battle/front-animated/crystal/squirtle.png", width = 56, height = 56, columns = 14, frames = 14, durations = {10,200,150,100,150,100,150,200,100,400,100,50,100,1990} }, - }, - WARTORTLE = { - front = { image = "assets/battle/front-animated/crystal/wartortle.png", width = 56, height = 56, columns = 13, frames = 13, durations = {60,640,160,140,160,140,160,160,520,80,100,100,1500} }, - }, - BLASTOISE = { - front = { image = "assets/battle/front-animated/crystal/blastoise.png", width = 56, height = 56, columns = 10, frames = 10, durations = {10,100,700,100,100,500,200,150,200,990} }, - }, - CATERPIE = { - front = { image = "assets/battle/front-animated/crystal/caterpie.png", width = 56, height = 56, columns = 13, frames = 13, durations = {200,150,200,200,50,200,400,50,150,50,100,100,1000} }, - }, - METAPOD = { - front = { image = "assets/battle/front-animated/crystal/metapod.png", width = 56, height = 56, columns = 11, frames = 11, durations = {10,200,350,200,400,100,100,50,100,100,990} }, - }, - BUTTERFREE = { - front = { image = "assets/battle/front-animated/crystal/butterfree.png", width = 56, height = 56, columns = 13, frames = 13, durations = {100,350,100,100,100,100,100,100,450,100,150,200,1000} }, - }, - WEEDLE = { - front = { image = "assets/battle/front-animated/crystal/weedle.png", width = 56, height = 56, columns = 16, frames = 23, durations = {100,180,120,120,120,120,120,120,120,120,80,80,80,80,100,100,100,100,100,100,100,100,1500} }, - }, - KAKUNA = { - front = { image = "assets/battle/front-animated/crystal/kakuna.png", width = 56, height = 56, columns = 7, frames = 7, durations = {100,240,140,140,140,140,2000} }, - }, - BEEDRILL = { - front = { image = "assets/battle/front-animated/crystal/beedrill.png", width = 56, height = 56, columns = 16, frames = 55, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,150,100,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,150,1500} }, - }, - PIDGEY = { - front = { image = "assets/battle/front-animated/crystal/pidgey.png", width = 56, height = 56, columns = 16, frames = 23, durations = {80,80,280,80,80,40,120,40,80,80,80,40,80,80,480,40,80,80,180,80,80,120,1000} }, - }, - PIDGEOTTO = { - front = { image = "assets/battle/front-animated/crystal/pidgeotto.png", width = 56, height = 56, columns = 14, frames = 14, durations = {100,140,140,140,300,120,120,120,120,100,100,100,100,1500} }, - }, - PIDGEOT = { - front = { image = "assets/battle/front-animated/crystal/pidgeot.png", width = 56, height = 56, columns = 16, frames = 16, durations = {100,320,120,120,120,120,120,120,120,120,120,120,120,120,120,1500} }, - }, - RATTATA = { - front = { image = "assets/battle/front-animated/crystal/rattata.png", width = 56, height = 56, columns = 16, frames = 16, durations = {100,40,240,200,100,100,100,100,100,100,100,100,100,100,100,1000} }, - }, - RATICATE = { - front = { image = "assets/battle/front-animated/crystal/raticate.png", width = 56, height = 56, columns = 15, frames = 15, durations = {100,230,100,100,100,100,100,100,100,100,100,100,100,100,1000} }, - }, - SPEAROW = { - front = { image = "assets/battle/front-animated/crystal/spearow.png", width = 56, height = 56, columns = 16, frames = 21, durations = {100,100,100,250,250,200,100,100,100,100,100,100,100,100,100,100,100,100,100,100,1000} }, - }, - FEAROW = { - front = { image = "assets/battle/front-animated/crystal/fearow.png", width = 56, height = 56, columns = 16, frames = 16, durations = {250,150,150,150,150,150,150,150,150,150,150,200,200,200,200,1700} }, - }, - EKANS = { - front = { image = "assets/battle/front-animated/crystal/ekans.png", width = 56, height = 56, columns = 16, frames = 38, durations = {100,80,50,30,30,30,30,30,30,30,30,30,30,30,30,30,30,200,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,1020} }, - }, - ARBOK = { - front = { image = "assets/battle/front-animated/crystal/arbok.png", width = 56, height = 56, columns = 16, frames = 26, durations = {140,80,80,80,80,80,80,70,70,50,50,70,70,50,50,70,70,50,50,80,60,40,40,40,60,1000} }, - }, - PIKACHU = { - front = { image = "assets/battle/front-animated/crystal/pikachu.png", width = 56, height = 56, columns = 16, frames = 22, durations = {10,150,100,150,150,50,450,100,50,50,150,400,50,100,50,50,100,100,50,100,50,990} }, - }, - RAICHU = { - front = { image = "assets/battle/front-animated/crystal/raichu.png", width = 56, height = 56, columns = 11, frames = 11, durations = {100,100,100,450,200,100,450,100,50,50,800} }, - }, - SANDSHREW = { - front = { image = "assets/battle/front-animated/crystal/sandshrew.png", width = 56, height = 56, columns = 16, frames = 19, durations = {200,100,100,100,200,100,100,100,100,100,100,100,100,100,100,100,100,120,1000} }, - }, - SANDSLASH = { - front = { image = "assets/battle/front-animated/crystal/sandslash.png", width = 56, height = 56, columns = 13, frames = 13, durations = {160,140,120,140,160,240,140,160,160,120,80,80,1500} }, - }, - NIDORAN_F = { - front = { image = "assets/battle/front-animated/crystal/nidoran-f.png", width = 56, height = 56, columns = 15, frames = 15, durations = {100,100,180,100,100,100,100,100,200,100,100,100,100,100,1000} }, - }, - NIDORINA = { - front = { image = "assets/battle/front-animated/crystal/nidorina.png", width = 56, height = 56, columns = 15, frames = 15, durations = {100,200,100,110,100,100,100,110,100,100,100,100,100,100,1000} }, - }, - NIDOQUEEN = { - front = { image = "assets/battle/front-animated/crystal/nidoqueen.png", width = 56, height = 56, columns = 15, frames = 15, durations = {100,200,200,200,200,200,200,200,200,500,200,200,200,200,1500} }, - }, - NIDORAN_M = { - front = { image = "assets/battle/front-animated/crystal/nidoran-m.png", width = 56, height = 56, columns = 15, frames = 15, durations = {210,210,100,100,100,100,100,100,400,100,100,100,100,100,1000} }, - }, - NIDORINO = { - front = { image = "assets/battle/front-animated/crystal/nidorino.png", width = 56, height = 56, columns = 16, frames = 21, durations = {100,180,500,100,120,280,120,100,120,120,120,370,120,120,120,120,120,120,120,100,1000} }, - }, - NIDOKING = { - front = { image = "assets/battle/front-animated/crystal/nidoking.png", width = 56, height = 56, columns = 11, frames = 11, durations = {100,220,620,200,470,100,120,100,120,100,1000} }, - }, - CLEFAIRY = { - front = { image = "assets/battle/front-animated/crystal/clefairy.png", width = 56, height = 56, columns = 16, frames = 18, durations = {100,120,120,100,80,80,80,80,80,100,370,200,220,200,220,80,80,1000} }, - }, - CLEFABLE = { - front = { image = "assets/battle/front-animated/crystal/clefable.png", width = 56, height = 56, columns = 11, frames = 11, durations = {100,180,180,170,480,120,130,120,130,130,1000} }, - }, - VULPIX = { - front = { image = "assets/battle/front-animated/crystal/vulpix.png", width = 56, height = 56, columns = 16, frames = 17, durations = {100,100,80,180,100,100,80,80,80,100,370,180,180,170,100,80,1000} }, - }, - NINETALES = { - front = { image = "assets/battle/front-animated/crystal/ninetales.png", width = 56, height = 56, columns = 15, frames = 15, durations = {100,150,170,170,180,220,180,130,470,100,120,100,120,120,1000} }, - }, - JIGGLYPUFF = { - front = { image = "assets/battle/front-animated/crystal/jigglypuff.png", width = 56, height = 56, columns = 16, frames = 17, durations = {100,250,170,170,120,350,100,100,100,100,470,120,120,120,120,130,1000} }, - }, - WIGGLYTUFF = { - front = { image = "assets/battle/front-animated/crystal/wigglytuff.png", width = 56, height = 56, columns = 14, frames = 14, durations = {100,350,130,150,100,120,70,470,100,120,100,120,120,1000} }, - }, - ZUBAT = { - front = { image = "assets/battle/front-animated/crystal/zubat.png", width = 56, height = 56, columns = 16, frames = 17, durations = {100,120,130,130,130,120,130,120,150,120,470,580,220,200,220,220,1000} }, - }, - GOLBAT = { - front = { image = "assets/battle/front-animated/crystal/golbat.png", width = 56, height = 56, columns = 16, frames = 17, durations = {100,170,170,170,300,150,150,150,150,150,470,100,120,100,120,100,1000} }, - }, - ODDISH = { - front = { image = "assets/battle/front-animated/crystal/oddish.png", width = 56, height = 56, columns = 14, frames = 14, durations = {100,180,350,120,80,370,120,120,130,430,80,80,100,1000} }, - }, - GLOOM = { - front = { image = "assets/battle/front-animated/crystal/gloom.png", width = 56, height = 56, columns = 15, frames = 15, durations = {100,180,250,350,180,150,130,120,120,100,530,120,180,180,1000} }, - }, - VILEPLUME = { - front = { image = "assets/battle/front-animated/crystal/vileplume.png", width = 56, height = 56, columns = 16, frames = 16, durations = {100,100,250,100,100,100,100,100,100,100,100,100,100,110,100,1000} }, - }, - PARAS = { - front = { image = "assets/battle/front-animated/crystal/paras.png", width = 56, height = 56, columns = 11, frames = 11, durations = {100,280,320,200,200,100,100,100,100,100,1500} }, - }, - PARASECT = { - front = { image = "assets/battle/front-animated/crystal/parasect.png", width = 56, height = 56, columns = 16, frames = 20, durations = {100,100,100,100,100,100,100,100,100,200,100,100,100,100,100,100,300,100,200,1500} }, - }, - VENONAT = { - front = { image = "assets/battle/front-animated/crystal/venonat.png", width = 56, height = 56, columns = 16, frames = 21, durations = {100,100,100,100,100,100,100,100,100,100,100,100,100,100,100,100,100,150,110,150,1000} }, - }, - VENOMOTH = { - front = { image = "assets/battle/front-animated/crystal/venomoth.png", width = 56, height = 56, columns = 16, frames = 19, durations = {100,100,100,100,100,100,100,100,100,100,100,100,100,100,100,100,100,100,1000} }, - }, - DIGLETT = { - front = { image = "assets/battle/front-animated/crystal/diglett.png", width = 56, height = 56, columns = 11, frames = 11, durations = {100,80,160,180,160,180,100,160,100,160,1500} }, - }, - DUGTRIO = { - front = { image = "assets/battle/front-animated/crystal/dugtrio.png", width = 56, height = 56, columns = 16, frames = 26, durations = {100,160,160,240,120,100,120,200,100,100,100,100,100,100,400,120,120,400,200,320,80,80,80,80,400,1500} }, - }, - MEOWTH = { - front = { image = "assets/battle/front-animated/crystal/meowth.png", width = 56, height = 56, columns = 11, frames = 11, durations = {100,480,280,280,280,280,160,160,160,160,1500} }, - }, - PERSIAN = { - front = { image = "assets/battle/front-animated/crystal/persian.png", width = 56, height = 56, columns = 14, frames = 14, durations = {100,240,640,240,200,160,200,160,120,100,100,100,100,1500} }, - }, - PSYDUCK = { - front = { image = "assets/battle/front-animated/crystal/psyduck.png", width = 56, height = 56, columns = 11, frames = 11, durations = {90,280,140,140,140,140,100,100,100,100,1000} }, - }, - GOLDUCK = { - front = { image = "assets/battle/front-animated/crystal/golduck.png", width = 56, height = 56, columns = 16, frames = 18, durations = {100,140,300,160,100,160,100,460,200,200,200,200,200,200,300,200,300,1500} }, - }, - MANKEY = { - front = { image = "assets/battle/front-animated/crystal/mankey.png", width = 56, height = 56, columns = 15, frames = 15, durations = {100,240,160,160,160,160,160,160,120,120,120,120,120,120,1500} }, - }, - PRIMEAPE = { - front = { image = "assets/battle/front-animated/crystal/primeape.png", width = 56, height = 56, columns = 16, frames = 22, durations = {100,160,160,160,160,160,160,160,160,160,160,100,500,200,500,120,120,120,120,120,120,1500} }, - }, - GROWLITHE = { - front = { image = "assets/battle/front-animated/crystal/growlithe.png", width = 56, height = 56, columns = 14, frames = 14, durations = {260,200,360,160,160,160,160,160,320,160,160,160,320,1500} }, - }, - ARCANINE = { - front = { image = "assets/battle/front-animated/crystal/arcanine.png", width = 56, height = 56, columns = 16, frames = 16, durations = {10,80,480,180,180,460,400,160,160,160,160,160,160,160,160,990} }, - }, - POLIWAG = { - front = { image = "assets/battle/front-animated/crystal/poliwag.png", width = 56, height = 56, columns = 16, frames = 17, durations = {130,480,160,160,160,160,120,120,120,120,120,120,200,200,200,200,1500} }, - }, - POLIWHIRL = { - front = { image = "assets/battle/front-animated/crystal/poliwhirl.png", width = 56, height = 56, columns = 13, frames = 13, durations = {100,140,100,200,200,200,200,320,100,100,100,100,1500} }, - }, - POLIWRATH = { - front = { image = "assets/battle/front-animated/crystal/poliwrath.png", width = 56, height = 56, columns = 12, frames = 12, durations = {100,480,240,160,160,160,160,160,120,120,120,1500} }, - }, - ABRA = { - front = { image = "assets/battle/front-animated/crystal/abra.png", width = 56, height = 56, columns = 16, frames = 16, durations = {100,200,180,180,200,180,180,200,180,140,140,140,140,140,140,1500} }, - }, - KADABRA = { - front = { image = "assets/battle/front-animated/crystal/kadabra.png", width = 56, height = 56, columns = 16, frames = 18, durations = {100,80,60,60,60,60,60,60,60,60,120,70,70,70,70,70,70,1000} }, - }, - ALAKAZAM = { - front = { image = "assets/battle/front-animated/crystal/alakazam.png", width = 56, height = 56, columns = 12, frames = 12, durations = {100,100,120,120,120,120,120,190,150,450,100,1000} }, - }, - MACHOP = { - front = { image = "assets/battle/front-animated/crystal/machop.png", width = 56, height = 56, columns = 11, frames = 11, durations = {130,440,240,240,240,240,120,120,120,120,1500} }, - }, - MACHOKE = { - front = { image = "assets/battle/front-animated/crystal/machoke.png", width = 56, height = 56, columns = 13, frames = 13, durations = {100,120,400,100,60,250,50,200,80,80,80,80,1000} }, - }, - MACHAMP = { - front = { image = "assets/battle/front-animated/crystal/machamp.png", width = 56, height = 56, columns = 16, frames = 20, durations = {50,600,100,60,100,60,100,60,100,60,100,60,100,60,160,140,140,140,140,1500} }, - }, - BELLSPROUT = { - front = { image = "assets/battle/front-animated/crystal/bellsprout.png", width = 56, height = 56, columns = 16, frames = 21, durations = {90,160,160,160,160,160,240,160,160,160,240,160,200,200,100,100,100,100,100,100,1500} }, - }, - WEEPINBELL = { - front = { image = "assets/battle/front-animated/crystal/weepinbell.png", width = 56, height = 56, columns = 12, frames = 12, durations = {60,100,100,100,100,100,240,140,140,140,140,1500} }, - }, - VICTREEBEL = { - front = { image = "assets/battle/front-animated/crystal/victreebel.png", width = 56, height = 56, columns = 13, frames = 13, durations = {100,200,500,200,200,200,120,120,100,120,120,100,1500} }, - }, - TENTACOOL = { - front = { image = "assets/battle/front-animated/crystal/tentacool.png", width = 56, height = 56, columns = 10, frames = 10, durations = {10,150,150,150,150,250,100,100,100,990} }, - }, - TENTACRUEL = { - front = { image = "assets/battle/front-animated/crystal/tentacruel.png", width = 56, height = 56, columns = 16, frames = 20, durations = {100,150,230,150,230,310,30,30,30,30,30,30,30,30,30,30,30,30,30,1000} }, - }, - GEODUDE = { - front = { image = "assets/battle/front-animated/crystal/geodude.png", width = 56, height = 56, columns = 15, frames = 15, durations = {90,320,140,140,140,140,140,140,160,160,160,160,160,160,1500} }, - }, - GRAVELER = { - front = { image = "assets/battle/front-animated/crystal/graveler.png", width = 56, height = 56, columns = 14, frames = 14, durations = {100,120,120,170,240,90,90,90,350,200,200,200,200,1000} }, - }, - GOLEM = { - front = { image = "assets/battle/front-animated/crystal/golem.png", width = 56, height = 56, columns = 10, frames = 10, durations = {90,120,180,120,120,80,80,80,80,1000} }, - }, - PONYTA = { - front = { image = "assets/battle/front-animated/crystal/ponyta.png", width = 56, height = 56, columns = 14, frames = 14, durations = {10,100,150,150,50,350,50,100,50,400,50,150,100,580} }, - }, - RAPIDASH = { - front = { image = "assets/battle/front-animated/crystal/rapidash.png", width = 56, height = 56, columns = 16, frames = 21, durations = {110,720,100,100,140,120,100,140,240,240,240,240,240,240,240,240,120,120,120,120,1500} }, - }, - SLOWPOKE = { - front = { image = "assets/battle/front-animated/crystal/slowpoke.png", width = 56, height = 56, columns = 6, frames = 6, durations = {100,50,280,100,200,1000} }, - }, - SLOWBRO = { - front = { image = "assets/battle/front-animated/crystal/slowbro.png", width = 56, height = 56, columns = 16, frames = 16, durations = {50,160,560,240,200,200,200,200,480,200,200,160,160,160,160,1500} }, - }, - MAGNEMITE = { - front = { image = "assets/battle/front-animated/crystal/magnemite.png", width = 56, height = 56, columns = 13, frames = 13, durations = {410,120,120,120,120,120,240,150,120,120,120,120,240} }, - }, - MAGNETON = { - front = { image = "assets/battle/front-animated/crystal/magneton.png", width = 56, height = 56, columns = 16, frames = 18, durations = {380,120,120,120,120,120,120,120,60,60,60,240,150,150,300,360,360,360} }, - }, - FARFETCHD = { - front = { image = "assets/battle/front-animated/crystal/farfetchd.png", width = 56, height = 56, columns = 9, frames = 9, durations = {100,60,60,140,60,100,70,70,1000} }, - }, - DODUO = { - front = { image = "assets/battle/front-animated/crystal/doduo.png", width = 56, height = 56, columns = 13, frames = 13, durations = {100,120,320,240,80,80,80,200,60,60,60,360,1000} }, - }, - DODRIO = { - front = { image = "assets/battle/front-animated/crystal/dodrio.png", width = 56, height = 56, columns = 11, frames = 11, durations = {100,200,200,200,180,200,160,200,160,200,1500} }, - }, - SEEL = { - front = { image = "assets/battle/front-animated/crystal/seel.png", width = 56, height = 56, columns = 12, frames = 12, durations = {70,320,200,200,200,200,240,120,120,120,120,1500} }, - }, - DEWGONG = { - front = { image = "assets/battle/front-animated/crystal/dewgong.png", width = 56, height = 56, columns = 15, frames = 15, durations = {100,90,90,90,90,140,60,60,60,60,120,60,60,60,1060} }, - }, - GRIMER = { - front = { image = "assets/battle/front-animated/crystal/grimer.png", width = 56, height = 56, columns = 11, frames = 11, durations = {100,200,150,450,300,100,100,150,150,150,750} }, - }, - MUK = { - front = { image = "assets/battle/front-animated/crystal/muk.png", width = 56, height = 56, columns = 12, frames = 12, durations = {100,120,120,120,120,120,120,120,120,120,120,1000} }, - }, - SHELLDER = { - front = { image = "assets/battle/front-animated/crystal/shellder.png", width = 56, height = 56, columns = 14, frames = 14, durations = {100,100,100,150,100,100,350,150,150,150,100,150,150,1500} }, - }, - CLOYSTER = { - front = { image = "assets/battle/front-animated/crystal/cloyster.png", width = 56, height = 56, columns = 8, frames = 8, durations = {100,180,180,180,180,180,520,1500} }, - }, - GASTLY = { - front = { image = "assets/battle/front-animated/crystal/gastly.png", width = 56, height = 56, columns = 11, frames = 11, durations = {50,180,240,180,120,120,60,60,60,60,1000} }, - }, - HAUNTER = { - front = { image = "assets/battle/front-animated/crystal/haunter.png", width = 56, height = 56, columns = 9, frames = 9, durations = {10,250,250,500,150,150,500,250,1990} }, - }, - GENGAR = { - front = { image = "assets/battle/front-animated/crystal/gengar.png", width = 56, height = 56, columns = 16, frames = 18, durations = {100,60,70,500,90,90,90,90,90,90,20,20,20,20,90,90,90,1000} }, - }, - ONIX = { - front = { image = "assets/battle/front-animated/crystal/onix.png", width = 56, height = 56, columns = 16, frames = 17, durations = {100,120,120,120,120,120,120,120,120,120,100,100,100,100,100,100,1000} }, - }, - DROWZEE = { - front = { image = "assets/battle/front-animated/crystal/drowzee.png", width = 56, height = 56, columns = 16, frames = 17, durations = {100,80,80,80,80,50,50,50,50,50,60,60,60,60,80,80,1000} }, - }, - HYPNO = { - front = { image = "assets/battle/front-animated/crystal/hypno.png", width = 56, height = 56, columns = 14, frames = 14, durations = {100,220,220,220,220,220,220,220,40,120,120,120,120,740} }, - }, - KRABBY = { - front = { image = "assets/battle/front-animated/crystal/krabby.png", width = 56, height = 56, columns = 15, frames = 15, durations = {100,180,120,40,30,40,30,60,40,30,40,30,60,60,1000} }, - }, - KINGLER = { - front = { image = "assets/battle/front-animated/crystal/kingler.png", width = 56, height = 56, columns = 16, frames = 18, durations = {100,220,220,220,220,220,220,300,300,150,150,150,150,150,150,150,150,1500} }, - }, - VOLTORB = { - front = { image = "assets/battle/front-animated/crystal/voltorb.png", width = 56, height = 56, columns = 10, frames = 10, durations = {180,80,60,40,60,80,80,80,80,1080} }, - }, - ELECTRODE = { - front = { image = "assets/battle/front-animated/crystal/electrode.png", width = 56, height = 56, columns = 5, frames = 5, durations = {100,50,400,450,1000} }, - }, - EXEGGCUTE = { - front = { image = "assets/battle/front-animated/crystal/exeggcute.png", width = 56, height = 56, columns = 16, frames = 22, durations = {90,40,40,40,40,100,30,30,30,30,30,30,100,40,40,80,80,60,60,60,60,1000} }, - }, - EXEGGUTOR = { - front = { image = "assets/battle/front-animated/crystal/exeggutor.png", width = 56, height = 56, columns = 16, frames = 16, durations = {100,100,100,100,90,90,90,90,90,180,100,100,100,100,100,1000} }, - }, - CUBONE = { - front = { image = "assets/battle/front-animated/crystal/cubone.png", width = 56, height = 56, columns = 15, frames = 15, durations = {50,190,80,60,30,20,400,20,20,80,80,80,80,80,1000} }, - }, - MAROWAK = { - front = { image = "assets/battle/front-animated/crystal/marowak.png", width = 56, height = 56, columns = 16, frames = 18, durations = {100,70,70,70,70,70,70,70,70,70,70,150,150,150,150,150,150,1500} }, - }, - HITMONLEE = { - front = { image = "assets/battle/front-animated/crystal/hitmonlee.png", width = 56, height = 56, columns = 16, frames = 16, durations = {50,240,120,60,100,120,100,100,100,100,80,80,80,80,80,1500} }, - }, - HITMONCHAN = { - front = { image = "assets/battle/front-animated/crystal/hitmonchan.png", width = 56, height = 56, columns = 16, frames = 21, durations = {100,260,120,120,120,120,120,150,120,400,100,400,180,180,180,180,180,180,180,290,1500} }, - }, - LICKITUNG = { - front = { image = "assets/battle/front-animated/crystal/lickitung.png", width = 56, height = 56, columns = 11, frames = 11, durations = {100,160,60,60,60,60,60,60,60,60,1000} }, - }, - KOFFING = { - front = { image = "assets/battle/front-animated/crystal/koffing.png", width = 56, height = 56, columns = 9, frames = 9, durations = {10,300,150,50,150,250,350,750,2390} }, - }, - WEEZING = { - front = { image = "assets/battle/front-animated/crystal/weezing.png", width = 56, height = 56, columns = 16, frames = 17, durations = {100,150,200,200,200,200,200,200,120,160,160,160,160,160,160,120,1500} }, - }, - RHYHORN = { - front = { image = "assets/battle/front-animated/crystal/rhyhorn.png", width = 56, height = 56, columns = 11, frames = 11, durations = {100,200,260,400,260,260,260,260,260,260,1500} }, - }, - RHYDON = { - front = { image = "assets/battle/front-animated/crystal/rhydon.png", width = 56, height = 56, columns = 16, frames = 29, durations = {150,450,150,200,50,50,300,50,50,50,50,50,50,50,50,100,50,50,50,50,50,150,50,100,450,100,100,100,800} }, - }, - CHANSEY = { - front = { image = "assets/battle/front-animated/crystal/chansey.png", width = 56, height = 56, columns = 14, frames = 14, durations = {10,100,150,100,100,100,50,150,100,400,50,100,100,990} }, - }, - TANGELA = { - front = { image = "assets/battle/front-animated/crystal/tangela.png", width = 56, height = 56, columns = 9, frames = 9, durations = {10,500,160,160,350,100,100,100,990} }, - }, - KANGASKHAN = { - front = { image = "assets/battle/front-animated/crystal/kangaskhan.png", width = 56, height = 56, columns = 15, frames = 15, durations = {10,450,250,200,150,150,150,250,400,100,100,150,200,400,1490} }, - }, - HORSEA = { - front = { image = "assets/battle/front-animated/crystal/horsea.png", width = 56, height = 56, columns = 14, frames = 14, durations = {10,200,200,100,50,50,50,50,100,100,100,100,100,1490} }, - }, - SEADRA = { - front = { image = "assets/battle/front-animated/crystal/seadra.png", width = 56, height = 56, columns = 13, frames = 13, durations = {200,160,160,160,160,160,500,120,120,120,120,120,1500} }, - }, - GOLDEEN = { - front = { image = "assets/battle/front-animated/crystal/goldeen.png", width = 56, height = 56, columns = 15, frames = 15, durations = {100,450,160,240,240,240,240,240,240,240,190,140,140,140,1500} }, - }, - SEAKING = { - front = { image = "assets/battle/front-animated/crystal/seaking.png", width = 56, height = 56, columns = 16, frames = 16, durations = {150,250,200,200,150,200,150,200,200,600,250,200,200,300,250,1100} }, - }, - STARYU = { - front = { image = "assets/battle/front-animated/crystal/staryu.png", width = 56, height = 56, columns = 15, frames = 15, durations = {380,360,120,180,60,60,60,60,30,60,60,60,60,120,1000} }, - }, - STARMIE = { - front = { image = "assets/battle/front-animated/crystal/starmie.png", width = 56, height = 56, columns = 15, frames = 15, durations = {100,100,100,100,100,50,150,100,100,150,100,100,500,100,600} }, - }, - MR_MIME = { - front = { image = "assets/battle/front-animated/crystal/mr-mime.png", width = 56, height = 56, columns = 14, frames = 14, durations = {150,150,100,150,150,150,100,500,100,400,100,100,100,2250} }, - }, - SCYTHER = { - front = { image = "assets/battle/front-animated/crystal/scyther.png", width = 56, height = 56, columns = 16, frames = 34, durations = {10,150,300,150,100,50,100,50,100,100,50,100,50,100,50,100,50,350,100,100,50,50,100,50,100,50,50,100,50,100,50,50,50,1590} }, - }, - JYNX = { - front = { image = "assets/battle/front-animated/crystal/jynx.png", width = 56, height = 56, columns = 15, frames = 15, durations = {170,160,160,160,160,160,160,160,60,60,60,60,60,60,1000} }, - }, - ELECTABUZZ = { - front = { image = "assets/battle/front-animated/crystal/electabuzz.png", width = 56, height = 56, columns = 16, frames = 16, durations = {10,200,50,50,150,200,50,50,50,400,50,100,50,50,250,480} }, - }, - MAGMAR = { - front = { image = "assets/battle/front-animated/crystal/magmar.png", width = 56, height = 56, columns = 15, frames = 15, durations = {100,250,100,100,200,100,50,200,450,50,150,100,150,100,1000} }, - }, - PINSIR = { - front = { image = "assets/battle/front-animated/crystal/pinsir.png", width = 56, height = 56, columns = 14, frames = 14, durations = {10,200,150,150,250,200,200,450,100,150,150,100,150,2340} }, - }, - TAUROS = { - front = { image = "assets/battle/front-animated/crystal/tauros.png", width = 56, height = 56, columns = 16, frames = 18, durations = {120,120,250,120,120,120,120,120,120,120,120,120,100,100,100,100,100,2500} }, - }, - MAGIKARP = { - front = { image = "assets/battle/front-animated/crystal/magikarp.png", width = 56, height = 56, columns = 11, frames = 11, durations = {600,140,100,140,100,280,330,150,150,150,300} }, - }, - GYARADOS = { - front = { image = "assets/battle/front-animated/crystal/gyarados.png", width = 56, height = 56, columns = 9, frames = 9, durations = {120,120,120,120,500,100,100,100,2500} }, - }, - LAPRAS = { - front = { image = "assets/battle/front-animated/crystal/lapras.png", width = 56, height = 56, columns = 12, frames = 12, durations = {100,150,150,300,150,150,250,100,150,150,100,1000} }, - }, - DITTO = { - front = { image = "assets/battle/front-animated/crystal/ditto.png", width = 56, height = 56, columns = 15, frames = 15, durations = {10,150,150,100,150,50,200,200,150,250,400,150,50,150,230} }, - }, - EEVEE = { - front = { image = "assets/battle/front-animated/crystal/eevee.png", width = 56, height = 56, columns = 16, frames = 17, durations = {10,100,100,150,100,150,100,150,450,50,100,50,100,100,150,200,1490} }, - }, - VAPOREON = { - front = { image = "assets/battle/front-animated/crystal/vaporeon.png", width = 56, height = 56, columns = 14, frames = 14, durations = {250,150,200,150,150,200,100,500,50,100,100,100,100,1000} }, - }, - JOLTEON = { - front = { image = "assets/battle/front-animated/crystal/jolteon.png", width = 56, height = 56, columns = 15, frames = 15, durations = {10,100,600,100,450,100,100,100,450,100,100,200,100,150,1790} }, - }, - FLAREON = { - front = { image = "assets/battle/front-animated/crystal/flareon.png", width = 56, height = 56, columns = 13, frames = 13, durations = {10,250,100,100,100,100,100,100,100,100,100,100,990} }, - }, - PORYGON = { - front = { image = "assets/battle/front-animated/crystal/porygon.png", width = 56, height = 56, columns = 15, frames = 15, durations = {50,50,50,50,50,50,50,750,50,250,250,50,50,50,1000} }, - }, - OMANYTE = { - front = { image = "assets/battle/front-animated/crystal/omanyte.png", width = 56, height = 56, columns = 9, frames = 9, durations = {100,100,250,100,500,100,100,100,1000} }, - }, - OMASTAR = { - front = { image = "assets/battle/front-animated/crystal/omastar.png", width = 56, height = 56, columns = 9, frames = 9, durations = {100,150,600,90,280,160,160,320,800} }, - }, - KABUTO = { - front = { image = "assets/battle/front-animated/crystal/kabuto.png", width = 56, height = 56, columns = 11, frames = 11, durations = {100,200,250,250,400,100,100,100,250,100,180} }, - }, - KABUTOPS = { - front = { image = "assets/battle/front-animated/crystal/kabutops.png", width = 56, height = 56, columns = 13, frames = 13, durations = {10,750,100,50,100,400,450,150,100,100,100,100,990} }, - }, - AERODACTYL = { - front = { image = "assets/battle/front-animated/crystal/aerodactyl.png", width = 56, height = 56, columns = 13, frames = 13, durations = {250,200,400,100,100,100,100,100,500,50,50,50,1000} }, - }, - SNORLAX = { - front = { image = "assets/battle/front-animated/crystal/snorlax.png", width = 56, height = 56, columns = 13, frames = 13, durations = {10,100,300,200,500,100,160,100,500,100,160,100,2490} }, - }, - ARTICUNO = { - front = { image = "assets/battle/front-animated/crystal/articuno.png", width = 56, height = 56, columns = 16, frames = 16, durations = {150,150,200,150,150,200,200,150,350,150,100,100,100,100,200,40} }, - }, - ZAPDOS = { - front = { image = "assets/battle/front-animated/crystal/zapdos.png", width = 56, height = 56, columns = 13, frames = 13, durations = {10,150,150,150,200,150,200,150,350,150,750,100,1340} }, - }, - MOLTRES = { - front = { image = "assets/battle/front-animated/crystal/moltres.png", width = 56, height = 56, columns = 16, frames = 21, durations = {10,160,160,160,160,160,160,160,160,160,160,160,160,100,50,100,160,160,160,160,2490} }, - }, - DRATINI = { - front = { image = "assets/battle/front-animated/crystal/dratini.png", width = 56, height = 56, columns = 11, frames = 11, durations = {100,100,200,100,150,150,400,50,100,100,1000} }, - }, - DRAGONAIR = { - front = { image = "assets/battle/front-animated/crystal/dragonair.png", width = 56, height = 56, columns = 16, frames = 18, durations = {120,120,120,120,120,120,120,120,120,200,100,100,100,200,100,100,100,1200} }, - }, - DRAGONITE = { - front = { image = "assets/battle/front-animated/crystal/dragonite.png", width = 56, height = 56, columns = 16, frames = 18, durations = {200,200,200,200,150,250,200,200,200,200,150,200,200,650,50,100,50,1000} }, - }, - MEWTWO = { - front = { image = "assets/battle/front-animated/crystal/mewtwo.png", width = 56, height = 56, columns = 16, frames = 25, durations = {10,150,100,50,50,50,150,50,50,100,50,100,50,50,50,50,50,50,200,100,400,100,100,150,1740} }, - }, - MEW = { - front = { image = "assets/battle/front-animated/crystal/mew.png", width = 56, height = 56, columns = 16, frames = 23, durations = {100,150,100,100,50,150,50,50,50,200,400,350,400,300,250,150,450,50,100,100,150,300,1000} }, - }, -} diff --git a/data/animated_battle_sprites_gen2.lua b/data/animated_battle_sprites_gen2.lua new file mode 100644 index 0000000..3eb1e69 --- /dev/null +++ b/data/animated_battle_sprites_gen2.lua @@ -0,0 +1,457 @@ +-- Generated by tools/import_animated_sprites.py --set gen2. +-- Pokemon Crystal animated fronts, National Dex #001-#151. +return { + BULBASAUR = { + front = { image = "assets/battle/front-animated/gen2/bulbasaur.png", width = 56, height = 56, columns = 14, frames = 14, durations = {10,180,180,140,80,360,80,60,60,380,60,60,60,990} }, + }, + IVYSAUR = { + front = { image = "assets/battle/front-animated/gen2/ivysaur.png", width = 56, height = 56, columns = 10, frames = 10, durations = {400,250,250,300,150,150,150,150,250,1000} }, + }, + VENUSAUR = { + front = { image = "assets/battle/front-animated/gen2/venusaur.png", width = 56, height = 56, columns = 14, frames = 14, durations = {180,680,140,120,180,160,180,180,400,660,240,240,220,1000} }, + }, + CHARMANDER = { + front = { image = "assets/battle/front-animated/gen2/charmander.png", width = 56, height = 56, columns = 12, frames = 12, durations = {50,200,350,150,100,200,150,400,100,100,100,2400} }, + }, + CHARMELEON = { + front = { image = "assets/battle/front-animated/gen2/charmeleon.png", width = 56, height = 56, columns = 16, frames = 16, durations = {10,150,100,150,150,250,150,450,350,100,200,150,200,150,100,1790} }, + }, + CHARIZARD = { + front = { image = "assets/battle/front-animated/gen2/charizard.png", width = 56, height = 56, columns = 11, frames = 11, durations = {100,300,150,150,150,150,350,150,100,100,1000} }, + }, + SQUIRTLE = { + front = { image = "assets/battle/front-animated/gen2/squirtle.png", width = 56, height = 56, columns = 14, frames = 14, durations = {10,200,150,100,150,100,150,200,100,400,100,50,100,1990} }, + }, + WARTORTLE = { + front = { image = "assets/battle/front-animated/gen2/wartortle.png", width = 56, height = 56, columns = 13, frames = 13, durations = {60,640,160,140,160,140,160,160,520,80,100,100,1500} }, + }, + BLASTOISE = { + front = { image = "assets/battle/front-animated/gen2/blastoise.png", width = 56, height = 56, columns = 10, frames = 10, durations = {10,100,700,100,100,500,200,150,200,990} }, + }, + CATERPIE = { + front = { image = "assets/battle/front-animated/gen2/caterpie.png", width = 56, height = 56, columns = 13, frames = 13, durations = {200,150,200,200,50,200,400,50,150,50,100,100,1000} }, + }, + METAPOD = { + front = { image = "assets/battle/front-animated/gen2/metapod.png", width = 56, height = 56, columns = 11, frames = 11, durations = {10,200,350,200,400,100,100,50,100,100,990} }, + }, + BUTTERFREE = { + front = { image = "assets/battle/front-animated/gen2/butterfree.png", width = 56, height = 56, columns = 13, frames = 13, durations = {100,350,100,100,100,100,100,100,450,100,150,200,1000} }, + }, + WEEDLE = { + front = { image = "assets/battle/front-animated/gen2/weedle.png", width = 56, height = 56, columns = 16, frames = 23, durations = {100,180,120,120,120,120,120,120,120,120,80,80,80,80,100,100,100,100,100,100,100,100,1500} }, + }, + KAKUNA = { + front = { image = "assets/battle/front-animated/gen2/kakuna.png", width = 56, height = 56, columns = 7, frames = 7, durations = {100,240,140,140,140,140,2000} }, + }, + BEEDRILL = { + front = { image = "assets/battle/front-animated/gen2/beedrill.png", width = 56, height = 56, columns = 16, frames = 55, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,150,100,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,150,1500} }, + }, + PIDGEY = { + front = { image = "assets/battle/front-animated/gen2/pidgey.png", width = 56, height = 56, columns = 16, frames = 23, durations = {80,80,280,80,80,40,120,40,80,80,80,40,80,80,480,40,80,80,180,80,80,120,1000} }, + }, + PIDGEOTTO = { + front = { image = "assets/battle/front-animated/gen2/pidgeotto.png", width = 56, height = 56, columns = 14, frames = 14, durations = {100,140,140,140,300,120,120,120,120,100,100,100,100,1500} }, + }, + PIDGEOT = { + front = { image = "assets/battle/front-animated/gen2/pidgeot.png", width = 56, height = 56, columns = 16, frames = 16, durations = {100,320,120,120,120,120,120,120,120,120,120,120,120,120,120,1500} }, + }, + RATTATA = { + front = { image = "assets/battle/front-animated/gen2/rattata.png", width = 56, height = 56, columns = 16, frames = 16, durations = {100,40,240,200,100,100,100,100,100,100,100,100,100,100,100,1000} }, + }, + RATICATE = { + front = { image = "assets/battle/front-animated/gen2/raticate.png", width = 56, height = 56, columns = 15, frames = 15, durations = {100,230,100,100,100,100,100,100,100,100,100,100,100,100,1000} }, + }, + SPEAROW = { + front = { image = "assets/battle/front-animated/gen2/spearow.png", width = 56, height = 56, columns = 16, frames = 21, durations = {100,100,100,250,250,200,100,100,100,100,100,100,100,100,100,100,100,100,100,100,1000} }, + }, + FEAROW = { + front = { image = "assets/battle/front-animated/gen2/fearow.png", width = 56, height = 56, columns = 16, frames = 16, durations = {250,150,150,150,150,150,150,150,150,150,150,200,200,200,200,1700} }, + }, + EKANS = { + front = { image = "assets/battle/front-animated/gen2/ekans.png", width = 56, height = 56, columns = 16, frames = 38, durations = {100,80,50,30,30,30,30,30,30,30,30,30,30,30,30,30,30,200,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,1020} }, + }, + ARBOK = { + front = { image = "assets/battle/front-animated/gen2/arbok.png", width = 56, height = 56, columns = 16, frames = 26, durations = {140,80,80,80,80,80,80,70,70,50,50,70,70,50,50,70,70,50,50,80,60,40,40,40,60,1000} }, + }, + PIKACHU = { + front = { image = "assets/battle/front-animated/gen2/pikachu.png", width = 56, height = 56, columns = 16, frames = 22, durations = {10,150,100,150,150,50,450,100,50,50,150,400,50,100,50,50,100,100,50,100,50,990} }, + }, + RAICHU = { + front = { image = "assets/battle/front-animated/gen2/raichu.png", width = 56, height = 56, columns = 11, frames = 11, durations = {100,100,100,450,200,100,450,100,50,50,800} }, + }, + SANDSHREW = { + front = { image = "assets/battle/front-animated/gen2/sandshrew.png", width = 56, height = 56, columns = 16, frames = 19, durations = {200,100,100,100,200,100,100,100,100,100,100,100,100,100,100,100,100,120,1000} }, + }, + SANDSLASH = { + front = { image = "assets/battle/front-animated/gen2/sandslash.png", width = 56, height = 56, columns = 13, frames = 13, durations = {160,140,120,140,160,240,140,160,160,120,80,80,1500} }, + }, + NIDORAN_F = { + front = { image = "assets/battle/front-animated/gen2/nidoran-f.png", width = 56, height = 56, columns = 15, frames = 15, durations = {100,100,180,100,100,100,100,100,200,100,100,100,100,100,1000} }, + }, + NIDORINA = { + front = { image = "assets/battle/front-animated/gen2/nidorina.png", width = 56, height = 56, columns = 15, frames = 15, durations = {100,200,100,110,100,100,100,110,100,100,100,100,100,100,1000} }, + }, + NIDOQUEEN = { + front = { image = "assets/battle/front-animated/gen2/nidoqueen.png", width = 56, height = 56, columns = 15, frames = 15, durations = {100,200,200,200,200,200,200,200,200,500,200,200,200,200,1500} }, + }, + NIDORAN_M = { + front = { image = "assets/battle/front-animated/gen2/nidoran-m.png", width = 56, height = 56, columns = 15, frames = 15, durations = {210,210,100,100,100,100,100,100,400,100,100,100,100,100,1000} }, + }, + NIDORINO = { + front = { image = "assets/battle/front-animated/gen2/nidorino.png", width = 56, height = 56, columns = 16, frames = 21, durations = {100,180,500,100,120,280,120,100,120,120,120,370,120,120,120,120,120,120,120,100,1000} }, + }, + NIDOKING = { + front = { image = "assets/battle/front-animated/gen2/nidoking.png", width = 56, height = 56, columns = 11, frames = 11, durations = {100,220,620,200,470,100,120,100,120,100,1000} }, + }, + CLEFAIRY = { + front = { image = "assets/battle/front-animated/gen2/clefairy.png", width = 56, height = 56, columns = 16, frames = 18, durations = {100,120,120,100,80,80,80,80,80,100,370,200,220,200,220,80,80,1000} }, + }, + CLEFABLE = { + front = { image = "assets/battle/front-animated/gen2/clefable.png", width = 56, height = 56, columns = 11, frames = 11, durations = {100,180,180,170,480,120,130,120,130,130,1000} }, + }, + VULPIX = { + front = { image = "assets/battle/front-animated/gen2/vulpix.png", width = 56, height = 56, columns = 16, frames = 17, durations = {100,100,80,180,100,100,80,80,80,100,370,180,180,170,100,80,1000} }, + }, + NINETALES = { + front = { image = "assets/battle/front-animated/gen2/ninetales.png", width = 56, height = 56, columns = 15, frames = 15, durations = {100,150,170,170,180,220,180,130,470,100,120,100,120,120,1000} }, + }, + JIGGLYPUFF = { + front = { image = "assets/battle/front-animated/gen2/jigglypuff.png", width = 56, height = 56, columns = 16, frames = 17, durations = {100,250,170,170,120,350,100,100,100,100,470,120,120,120,120,130,1000} }, + }, + WIGGLYTUFF = { + front = { image = "assets/battle/front-animated/gen2/wigglytuff.png", width = 56, height = 56, columns = 14, frames = 14, durations = {100,350,130,150,100,120,70,470,100,120,100,120,120,1000} }, + }, + ZUBAT = { + front = { image = "assets/battle/front-animated/gen2/zubat.png", width = 56, height = 56, columns = 16, frames = 17, durations = {100,120,130,130,130,120,130,120,150,120,470,580,220,200,220,220,1000} }, + }, + GOLBAT = { + front = { image = "assets/battle/front-animated/gen2/golbat.png", width = 56, height = 56, columns = 16, frames = 17, durations = {100,170,170,170,300,150,150,150,150,150,470,100,120,100,120,100,1000} }, + }, + ODDISH = { + front = { image = "assets/battle/front-animated/gen2/oddish.png", width = 56, height = 56, columns = 14, frames = 14, durations = {100,180,350,120,80,370,120,120,130,430,80,80,100,1000} }, + }, + GLOOM = { + front = { image = "assets/battle/front-animated/gen2/gloom.png", width = 56, height = 56, columns = 15, frames = 15, durations = {100,180,250,350,180,150,130,120,120,100,530,120,180,180,1000} }, + }, + VILEPLUME = { + front = { image = "assets/battle/front-animated/gen2/vileplume.png", width = 56, height = 56, columns = 16, frames = 16, durations = {100,100,250,100,100,100,100,100,100,100,100,100,100,110,100,1000} }, + }, + PARAS = { + front = { image = "assets/battle/front-animated/gen2/paras.png", width = 56, height = 56, columns = 11, frames = 11, durations = {100,280,320,200,200,100,100,100,100,100,1500} }, + }, + PARASECT = { + front = { image = "assets/battle/front-animated/gen2/parasect.png", width = 56, height = 56, columns = 16, frames = 20, durations = {100,100,100,100,100,100,100,100,100,200,100,100,100,100,100,100,300,100,200,1500} }, + }, + VENONAT = { + front = { image = "assets/battle/front-animated/gen2/venonat.png", width = 56, height = 56, columns = 16, frames = 21, durations = {100,100,100,100,100,100,100,100,100,100,100,100,100,100,100,100,100,150,110,150,1000} }, + }, + VENOMOTH = { + front = { image = "assets/battle/front-animated/gen2/venomoth.png", width = 56, height = 56, columns = 16, frames = 19, durations = {100,100,100,100,100,100,100,100,100,100,100,100,100,100,100,100,100,100,1000} }, + }, + DIGLETT = { + front = { image = "assets/battle/front-animated/gen2/diglett.png", width = 56, height = 56, columns = 11, frames = 11, durations = {100,80,160,180,160,180,100,160,100,160,1500} }, + }, + DUGTRIO = { + front = { image = "assets/battle/front-animated/gen2/dugtrio.png", width = 56, height = 56, columns = 16, frames = 26, durations = {100,160,160,240,120,100,120,200,100,100,100,100,100,100,400,120,120,400,200,320,80,80,80,80,400,1500} }, + }, + MEOWTH = { + front = { image = "assets/battle/front-animated/gen2/meowth.png", width = 56, height = 56, columns = 11, frames = 11, durations = {100,480,280,280,280,280,160,160,160,160,1500} }, + }, + PERSIAN = { + front = { image = "assets/battle/front-animated/gen2/persian.png", width = 56, height = 56, columns = 14, frames = 14, durations = {100,240,640,240,200,160,200,160,120,100,100,100,100,1500} }, + }, + PSYDUCK = { + front = { image = "assets/battle/front-animated/gen2/psyduck.png", width = 56, height = 56, columns = 11, frames = 11, durations = {90,280,140,140,140,140,100,100,100,100,1000} }, + }, + GOLDUCK = { + front = { image = "assets/battle/front-animated/gen2/golduck.png", width = 56, height = 56, columns = 16, frames = 18, durations = {100,140,300,160,100,160,100,460,200,200,200,200,200,200,300,200,300,1500} }, + }, + MANKEY = { + front = { image = "assets/battle/front-animated/gen2/mankey.png", width = 56, height = 56, columns = 15, frames = 15, durations = {100,240,160,160,160,160,160,160,120,120,120,120,120,120,1500} }, + }, + PRIMEAPE = { + front = { image = "assets/battle/front-animated/gen2/primeape.png", width = 56, height = 56, columns = 16, frames = 22, durations = {100,160,160,160,160,160,160,160,160,160,160,100,500,200,500,120,120,120,120,120,120,1500} }, + }, + GROWLITHE = { + front = { image = "assets/battle/front-animated/gen2/growlithe.png", width = 56, height = 56, columns = 14, frames = 14, durations = {260,200,360,160,160,160,160,160,320,160,160,160,320,1500} }, + }, + ARCANINE = { + front = { image = "assets/battle/front-animated/gen2/arcanine.png", width = 56, height = 56, columns = 16, frames = 16, durations = {10,80,480,180,180,460,400,160,160,160,160,160,160,160,160,990} }, + }, + POLIWAG = { + front = { image = "assets/battle/front-animated/gen2/poliwag.png", width = 56, height = 56, columns = 16, frames = 17, durations = {130,480,160,160,160,160,120,120,120,120,120,120,200,200,200,200,1500} }, + }, + POLIWHIRL = { + front = { image = "assets/battle/front-animated/gen2/poliwhirl.png", width = 56, height = 56, columns = 13, frames = 13, durations = {100,140,100,200,200,200,200,320,100,100,100,100,1500} }, + }, + POLIWRATH = { + front = { image = "assets/battle/front-animated/gen2/poliwrath.png", width = 56, height = 56, columns = 12, frames = 12, durations = {100,480,240,160,160,160,160,160,120,120,120,1500} }, + }, + ABRA = { + front = { image = "assets/battle/front-animated/gen2/abra.png", width = 56, height = 56, columns = 16, frames = 16, durations = {100,200,180,180,200,180,180,200,180,140,140,140,140,140,140,1500} }, + }, + KADABRA = { + front = { image = "assets/battle/front-animated/gen2/kadabra.png", width = 56, height = 56, columns = 16, frames = 18, durations = {100,80,60,60,60,60,60,60,60,60,120,70,70,70,70,70,70,1000} }, + }, + ALAKAZAM = { + front = { image = "assets/battle/front-animated/gen2/alakazam.png", width = 56, height = 56, columns = 12, frames = 12, durations = {100,100,120,120,120,120,120,190,150,450,100,1000} }, + }, + MACHOP = { + front = { image = "assets/battle/front-animated/gen2/machop.png", width = 56, height = 56, columns = 11, frames = 11, durations = {130,440,240,240,240,240,120,120,120,120,1500} }, + }, + MACHOKE = { + front = { image = "assets/battle/front-animated/gen2/machoke.png", width = 56, height = 56, columns = 13, frames = 13, durations = {100,120,400,100,60,250,50,200,80,80,80,80,1000} }, + }, + MACHAMP = { + front = { image = "assets/battle/front-animated/gen2/machamp.png", width = 56, height = 56, columns = 16, frames = 20, durations = {50,600,100,60,100,60,100,60,100,60,100,60,100,60,160,140,140,140,140,1500} }, + }, + BELLSPROUT = { + front = { image = "assets/battle/front-animated/gen2/bellsprout.png", width = 56, height = 56, columns = 16, frames = 21, durations = {90,160,160,160,160,160,240,160,160,160,240,160,200,200,100,100,100,100,100,100,1500} }, + }, + WEEPINBELL = { + front = { image = "assets/battle/front-animated/gen2/weepinbell.png", width = 56, height = 56, columns = 12, frames = 12, durations = {60,100,100,100,100,100,240,140,140,140,140,1500} }, + }, + VICTREEBEL = { + front = { image = "assets/battle/front-animated/gen2/victreebel.png", width = 56, height = 56, columns = 13, frames = 13, durations = {100,200,500,200,200,200,120,120,100,120,120,100,1500} }, + }, + TENTACOOL = { + front = { image = "assets/battle/front-animated/gen2/tentacool.png", width = 56, height = 56, columns = 10, frames = 10, durations = {10,150,150,150,150,250,100,100,100,990} }, + }, + TENTACRUEL = { + front = { image = "assets/battle/front-animated/gen2/tentacruel.png", width = 56, height = 56, columns = 16, frames = 20, durations = {100,150,230,150,230,310,30,30,30,30,30,30,30,30,30,30,30,30,30,1000} }, + }, + GEODUDE = { + front = { image = "assets/battle/front-animated/gen2/geodude.png", width = 56, height = 56, columns = 15, frames = 15, durations = {90,320,140,140,140,140,140,140,160,160,160,160,160,160,1500} }, + }, + GRAVELER = { + front = { image = "assets/battle/front-animated/gen2/graveler.png", width = 56, height = 56, columns = 14, frames = 14, durations = {100,120,120,170,240,90,90,90,350,200,200,200,200,1000} }, + }, + GOLEM = { + front = { image = "assets/battle/front-animated/gen2/golem.png", width = 56, height = 56, columns = 10, frames = 10, durations = {90,120,180,120,120,80,80,80,80,1000} }, + }, + PONYTA = { + front = { image = "assets/battle/front-animated/gen2/ponyta.png", width = 56, height = 56, columns = 14, frames = 14, durations = {10,100,150,150,50,350,50,100,50,400,50,150,100,580} }, + }, + RAPIDASH = { + front = { image = "assets/battle/front-animated/gen2/rapidash.png", width = 56, height = 56, columns = 16, frames = 21, durations = {110,720,100,100,140,120,100,140,240,240,240,240,240,240,240,240,120,120,120,120,1500} }, + }, + SLOWPOKE = { + front = { image = "assets/battle/front-animated/gen2/slowpoke.png", width = 56, height = 56, columns = 6, frames = 6, durations = {100,50,280,100,200,1000} }, + }, + SLOWBRO = { + front = { image = "assets/battle/front-animated/gen2/slowbro.png", width = 56, height = 56, columns = 16, frames = 16, durations = {50,160,560,240,200,200,200,200,480,200,200,160,160,160,160,1500} }, + }, + MAGNEMITE = { + front = { image = "assets/battle/front-animated/gen2/magnemite.png", width = 56, height = 56, columns = 13, frames = 13, durations = {410,120,120,120,120,120,240,150,120,120,120,120,240} }, + }, + MAGNETON = { + front = { image = "assets/battle/front-animated/gen2/magneton.png", width = 56, height = 56, columns = 16, frames = 18, durations = {380,120,120,120,120,120,120,120,60,60,60,240,150,150,300,360,360,360} }, + }, + FARFETCHD = { + front = { image = "assets/battle/front-animated/gen2/farfetchd.png", width = 56, height = 56, columns = 9, frames = 9, durations = {100,60,60,140,60,100,70,70,1000} }, + }, + DODUO = { + front = { image = "assets/battle/front-animated/gen2/doduo.png", width = 56, height = 56, columns = 13, frames = 13, durations = {100,120,320,240,80,80,80,200,60,60,60,360,1000} }, + }, + DODRIO = { + front = { image = "assets/battle/front-animated/gen2/dodrio.png", width = 56, height = 56, columns = 11, frames = 11, durations = {100,200,200,200,180,200,160,200,160,200,1500} }, + }, + SEEL = { + front = { image = "assets/battle/front-animated/gen2/seel.png", width = 56, height = 56, columns = 12, frames = 12, durations = {70,320,200,200,200,200,240,120,120,120,120,1500} }, + }, + DEWGONG = { + front = { image = "assets/battle/front-animated/gen2/dewgong.png", width = 56, height = 56, columns = 15, frames = 15, durations = {100,90,90,90,90,140,60,60,60,60,120,60,60,60,1060} }, + }, + GRIMER = { + front = { image = "assets/battle/front-animated/gen2/grimer.png", width = 56, height = 56, columns = 11, frames = 11, durations = {100,200,150,450,300,100,100,150,150,150,750} }, + }, + MUK = { + front = { image = "assets/battle/front-animated/gen2/muk.png", width = 56, height = 56, columns = 12, frames = 12, durations = {100,120,120,120,120,120,120,120,120,120,120,1000} }, + }, + SHELLDER = { + front = { image = "assets/battle/front-animated/gen2/shellder.png", width = 56, height = 56, columns = 14, frames = 14, durations = {100,100,100,150,100,100,350,150,150,150,100,150,150,1500} }, + }, + CLOYSTER = { + front = { image = "assets/battle/front-animated/gen2/cloyster.png", width = 56, height = 56, columns = 8, frames = 8, durations = {100,180,180,180,180,180,520,1500} }, + }, + GASTLY = { + front = { image = "assets/battle/front-animated/gen2/gastly.png", width = 56, height = 56, columns = 11, frames = 11, durations = {50,180,240,180,120,120,60,60,60,60,1000} }, + }, + HAUNTER = { + front = { image = "assets/battle/front-animated/gen2/haunter.png", width = 56, height = 56, columns = 9, frames = 9, durations = {10,250,250,500,150,150,500,250,1990} }, + }, + GENGAR = { + front = { image = "assets/battle/front-animated/gen2/gengar.png", width = 56, height = 56, columns = 16, frames = 18, durations = {100,60,70,500,90,90,90,90,90,90,20,20,20,20,90,90,90,1000} }, + }, + ONIX = { + front = { image = "assets/battle/front-animated/gen2/onix.png", width = 56, height = 56, columns = 16, frames = 17, durations = {100,120,120,120,120,120,120,120,120,120,100,100,100,100,100,100,1000} }, + }, + DROWZEE = { + front = { image = "assets/battle/front-animated/gen2/drowzee.png", width = 56, height = 56, columns = 16, frames = 17, durations = {100,80,80,80,80,50,50,50,50,50,60,60,60,60,80,80,1000} }, + }, + HYPNO = { + front = { image = "assets/battle/front-animated/gen2/hypno.png", width = 56, height = 56, columns = 14, frames = 14, durations = {100,220,220,220,220,220,220,220,40,120,120,120,120,740} }, + }, + KRABBY = { + front = { image = "assets/battle/front-animated/gen2/krabby.png", width = 56, height = 56, columns = 15, frames = 15, durations = {100,180,120,40,30,40,30,60,40,30,40,30,60,60,1000} }, + }, + KINGLER = { + front = { image = "assets/battle/front-animated/gen2/kingler.png", width = 56, height = 56, columns = 16, frames = 18, durations = {100,220,220,220,220,220,220,300,300,150,150,150,150,150,150,150,150,1500} }, + }, + VOLTORB = { + front = { image = "assets/battle/front-animated/gen2/voltorb.png", width = 56, height = 56, columns = 10, frames = 10, durations = {180,80,60,40,60,80,80,80,80,1080} }, + }, + ELECTRODE = { + front = { image = "assets/battle/front-animated/gen2/electrode.png", width = 56, height = 56, columns = 5, frames = 5, durations = {100,50,400,450,1000} }, + }, + EXEGGCUTE = { + front = { image = "assets/battle/front-animated/gen2/exeggcute.png", width = 56, height = 56, columns = 16, frames = 22, durations = {90,40,40,40,40,100,30,30,30,30,30,30,100,40,40,80,80,60,60,60,60,1000} }, + }, + EXEGGUTOR = { + front = { image = "assets/battle/front-animated/gen2/exeggutor.png", width = 56, height = 56, columns = 16, frames = 16, durations = {100,100,100,100,90,90,90,90,90,180,100,100,100,100,100,1000} }, + }, + CUBONE = { + front = { image = "assets/battle/front-animated/gen2/cubone.png", width = 56, height = 56, columns = 15, frames = 15, durations = {50,190,80,60,30,20,400,20,20,80,80,80,80,80,1000} }, + }, + MAROWAK = { + front = { image = "assets/battle/front-animated/gen2/marowak.png", width = 56, height = 56, columns = 16, frames = 18, durations = {100,70,70,70,70,70,70,70,70,70,70,150,150,150,150,150,150,1500} }, + }, + HITMONLEE = { + front = { image = "assets/battle/front-animated/gen2/hitmonlee.png", width = 56, height = 56, columns = 16, frames = 16, durations = {50,240,120,60,100,120,100,100,100,100,80,80,80,80,80,1500} }, + }, + HITMONCHAN = { + front = { image = "assets/battle/front-animated/gen2/hitmonchan.png", width = 56, height = 56, columns = 16, frames = 21, durations = {100,260,120,120,120,120,120,150,120,400,100,400,180,180,180,180,180,180,180,290,1500} }, + }, + LICKITUNG = { + front = { image = "assets/battle/front-animated/gen2/lickitung.png", width = 56, height = 56, columns = 11, frames = 11, durations = {100,160,60,60,60,60,60,60,60,60,1000} }, + }, + KOFFING = { + front = { image = "assets/battle/front-animated/gen2/koffing.png", width = 56, height = 56, columns = 9, frames = 9, durations = {10,300,150,50,150,250,350,750,2390} }, + }, + WEEZING = { + front = { image = "assets/battle/front-animated/gen2/weezing.png", width = 56, height = 56, columns = 16, frames = 17, durations = {100,150,200,200,200,200,200,200,120,160,160,160,160,160,160,120,1500} }, + }, + RHYHORN = { + front = { image = "assets/battle/front-animated/gen2/rhyhorn.png", width = 56, height = 56, columns = 11, frames = 11, durations = {100,200,260,400,260,260,260,260,260,260,1500} }, + }, + RHYDON = { + front = { image = "assets/battle/front-animated/gen2/rhydon.png", width = 56, height = 56, columns = 16, frames = 29, durations = {150,450,150,200,50,50,300,50,50,50,50,50,50,50,50,100,50,50,50,50,50,150,50,100,450,100,100,100,800} }, + }, + CHANSEY = { + front = { image = "assets/battle/front-animated/gen2/chansey.png", width = 56, height = 56, columns = 14, frames = 14, durations = {10,100,150,100,100,100,50,150,100,400,50,100,100,990} }, + }, + TANGELA = { + front = { image = "assets/battle/front-animated/gen2/tangela.png", width = 56, height = 56, columns = 9, frames = 9, durations = {10,500,160,160,350,100,100,100,990} }, + }, + KANGASKHAN = { + front = { image = "assets/battle/front-animated/gen2/kangaskhan.png", width = 56, height = 56, columns = 15, frames = 15, durations = {10,450,250,200,150,150,150,250,400,100,100,150,200,400,1490} }, + }, + HORSEA = { + front = { image = "assets/battle/front-animated/gen2/horsea.png", width = 56, height = 56, columns = 14, frames = 14, durations = {10,200,200,100,50,50,50,50,100,100,100,100,100,1490} }, + }, + SEADRA = { + front = { image = "assets/battle/front-animated/gen2/seadra.png", width = 56, height = 56, columns = 13, frames = 13, durations = {200,160,160,160,160,160,500,120,120,120,120,120,1500} }, + }, + GOLDEEN = { + front = { image = "assets/battle/front-animated/gen2/goldeen.png", width = 56, height = 56, columns = 15, frames = 15, durations = {100,450,160,240,240,240,240,240,240,240,190,140,140,140,1500} }, + }, + SEAKING = { + front = { image = "assets/battle/front-animated/gen2/seaking.png", width = 56, height = 56, columns = 16, frames = 16, durations = {150,250,200,200,150,200,150,200,200,600,250,200,200,300,250,1100} }, + }, + STARYU = { + front = { image = "assets/battle/front-animated/gen2/staryu.png", width = 56, height = 56, columns = 15, frames = 15, durations = {380,360,120,180,60,60,60,60,30,60,60,60,60,120,1000} }, + }, + STARMIE = { + front = { image = "assets/battle/front-animated/gen2/starmie.png", width = 56, height = 56, columns = 15, frames = 15, durations = {100,100,100,100,100,50,150,100,100,150,100,100,500,100,600} }, + }, + MR_MIME = { + front = { image = "assets/battle/front-animated/gen2/mr-mime.png", width = 56, height = 56, columns = 14, frames = 14, durations = {150,150,100,150,150,150,100,500,100,400,100,100,100,2250} }, + }, + SCYTHER = { + front = { image = "assets/battle/front-animated/gen2/scyther.png", width = 56, height = 56, columns = 16, frames = 34, durations = {10,150,300,150,100,50,100,50,100,100,50,100,50,100,50,100,50,350,100,100,50,50,100,50,100,50,50,100,50,100,50,50,50,1590} }, + }, + JYNX = { + front = { image = "assets/battle/front-animated/gen2/jynx.png", width = 56, height = 56, columns = 15, frames = 15, durations = {170,160,160,160,160,160,160,160,60,60,60,60,60,60,1000} }, + }, + ELECTABUZZ = { + front = { image = "assets/battle/front-animated/gen2/electabuzz.png", width = 56, height = 56, columns = 16, frames = 16, durations = {10,200,50,50,150,200,50,50,50,400,50,100,50,50,250,480} }, + }, + MAGMAR = { + front = { image = "assets/battle/front-animated/gen2/magmar.png", width = 56, height = 56, columns = 15, frames = 15, durations = {100,250,100,100,200,100,50,200,450,50,150,100,150,100,1000} }, + }, + PINSIR = { + front = { image = "assets/battle/front-animated/gen2/pinsir.png", width = 56, height = 56, columns = 14, frames = 14, durations = {10,200,150,150,250,200,200,450,100,150,150,100,150,2340} }, + }, + TAUROS = { + front = { image = "assets/battle/front-animated/gen2/tauros.png", width = 56, height = 56, columns = 16, frames = 18, durations = {120,120,250,120,120,120,120,120,120,120,120,120,100,100,100,100,100,2500} }, + }, + MAGIKARP = { + front = { image = "assets/battle/front-animated/gen2/magikarp.png", width = 56, height = 56, columns = 11, frames = 11, durations = {600,140,100,140,100,280,330,150,150,150,300} }, + }, + GYARADOS = { + front = { image = "assets/battle/front-animated/gen2/gyarados.png", width = 56, height = 56, columns = 9, frames = 9, durations = {120,120,120,120,500,100,100,100,2500} }, + }, + LAPRAS = { + front = { image = "assets/battle/front-animated/gen2/lapras.png", width = 56, height = 56, columns = 12, frames = 12, durations = {100,150,150,300,150,150,250,100,150,150,100,1000} }, + }, + DITTO = { + front = { image = "assets/battle/front-animated/gen2/ditto.png", width = 56, height = 56, columns = 15, frames = 15, durations = {10,150,150,100,150,50,200,200,150,250,400,150,50,150,230} }, + }, + EEVEE = { + front = { image = "assets/battle/front-animated/gen2/eevee.png", width = 56, height = 56, columns = 16, frames = 17, durations = {10,100,100,150,100,150,100,150,450,50,100,50,100,100,150,200,1490} }, + }, + VAPOREON = { + front = { image = "assets/battle/front-animated/gen2/vaporeon.png", width = 56, height = 56, columns = 14, frames = 14, durations = {250,150,200,150,150,200,100,500,50,100,100,100,100,1000} }, + }, + JOLTEON = { + front = { image = "assets/battle/front-animated/gen2/jolteon.png", width = 56, height = 56, columns = 15, frames = 15, durations = {10,100,600,100,450,100,100,100,450,100,100,200,100,150,1790} }, + }, + FLAREON = { + front = { image = "assets/battle/front-animated/gen2/flareon.png", width = 56, height = 56, columns = 13, frames = 13, durations = {10,250,100,100,100,100,100,100,100,100,100,100,990} }, + }, + PORYGON = { + front = { image = "assets/battle/front-animated/gen2/porygon.png", width = 56, height = 56, columns = 15, frames = 15, durations = {50,50,50,50,50,50,50,750,50,250,250,50,50,50,1000} }, + }, + OMANYTE = { + front = { image = "assets/battle/front-animated/gen2/omanyte.png", width = 56, height = 56, columns = 9, frames = 9, durations = {100,100,250,100,500,100,100,100,1000} }, + }, + OMASTAR = { + front = { image = "assets/battle/front-animated/gen2/omastar.png", width = 56, height = 56, columns = 9, frames = 9, durations = {100,150,600,90,280,160,160,320,800} }, + }, + KABUTO = { + front = { image = "assets/battle/front-animated/gen2/kabuto.png", width = 56, height = 56, columns = 11, frames = 11, durations = {100,200,250,250,400,100,100,100,250,100,180} }, + }, + KABUTOPS = { + front = { image = "assets/battle/front-animated/gen2/kabutops.png", width = 56, height = 56, columns = 13, frames = 13, durations = {10,750,100,50,100,400,450,150,100,100,100,100,990} }, + }, + AERODACTYL = { + front = { image = "assets/battle/front-animated/gen2/aerodactyl.png", width = 56, height = 56, columns = 13, frames = 13, durations = {250,200,400,100,100,100,100,100,500,50,50,50,1000} }, + }, + SNORLAX = { + front = { image = "assets/battle/front-animated/gen2/snorlax.png", width = 56, height = 56, columns = 13, frames = 13, durations = {10,100,300,200,500,100,160,100,500,100,160,100,2490} }, + }, + ARTICUNO = { + front = { image = "assets/battle/front-animated/gen2/articuno.png", width = 56, height = 56, columns = 16, frames = 16, durations = {150,150,200,150,150,200,200,150,350,150,100,100,100,100,200,40} }, + }, + ZAPDOS = { + front = { image = "assets/battle/front-animated/gen2/zapdos.png", width = 56, height = 56, columns = 13, frames = 13, durations = {10,150,150,150,200,150,200,150,350,150,750,100,1340} }, + }, + MOLTRES = { + front = { image = "assets/battle/front-animated/gen2/moltres.png", width = 56, height = 56, columns = 16, frames = 21, durations = {10,160,160,160,160,160,160,160,160,160,160,160,160,100,50,100,160,160,160,160,2490} }, + }, + DRATINI = { + front = { image = "assets/battle/front-animated/gen2/dratini.png", width = 56, height = 56, columns = 11, frames = 11, durations = {100,100,200,100,150,150,400,50,100,100,1000} }, + }, + DRAGONAIR = { + front = { image = "assets/battle/front-animated/gen2/dragonair.png", width = 56, height = 56, columns = 16, frames = 18, durations = {120,120,120,120,120,120,120,120,120,200,100,100,100,200,100,100,100,1200} }, + }, + DRAGONITE = { + front = { image = "assets/battle/front-animated/gen2/dragonite.png", width = 56, height = 56, columns = 16, frames = 18, durations = {200,200,200,200,150,250,200,200,200,200,150,200,200,650,50,100,50,1000} }, + }, + MEWTWO = { + front = { image = "assets/battle/front-animated/gen2/mewtwo.png", width = 56, height = 56, columns = 16, frames = 25, durations = {10,150,100,50,50,50,150,50,50,100,50,100,50,50,50,50,50,50,200,100,400,100,100,150,1740} }, + }, + MEW = { + front = { image = "assets/battle/front-animated/gen2/mew.png", width = 56, height = 56, columns = 16, frames = 23, durations = {100,150,100,100,50,150,50,50,50,200,400,350,400,300,250,150,450,50,100,100,150,300,1000} }, + }, +} diff --git a/data/animated_battle_sprites_gen3.lua b/data/animated_battle_sprites_gen3.lua new file mode 100644 index 0000000..064129f --- /dev/null +++ b/data/animated_battle_sprites_gen3.lua @@ -0,0 +1,457 @@ +-- Generated by tools/import_animated_sprites.py --set gen3. +-- Pokemon Emerald animated fronts, National Dex #001-#151. +return { + BULBASAUR = { + front = { image = "assets/battle/front-animated/gen3/bulbasaur.png", width = 49, height = 45, columns = 16, frames = 29, durations = {80,20,40,20,20,20,20,20,20,20,80,40,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,2250} }, + }, + IVYSAUR = { + front = { image = "assets/battle/front-animated/gen3/ivysaur.png", width = 40, height = 54, columns = 16, frames = 21, durations = {80,20,20,20,20,20,20,20,20,20,20,20,20,20,40,20,20,20,20,20,2250} }, + }, + VENUSAUR = { + front = { image = "assets/battle/front-animated/gen3/venusaur.png", width = 61, height = 72, columns = 16, frames = 27, durations = {80,20,20,20,20,220,20,20,20,20,20,20,20,20,20,40,20,20,20,20,20,20,20,20,20,20,2250} }, + }, + CHARMANDER = { + front = { image = "assets/battle/front-animated/gen3/charmander.png", width = 37, height = 47, columns = 16, frames = 17, durations = {80,20,20,40,20,20,20,40,20,20,20,20,20,20,20,140,2250} }, + }, + CHARMELEON = { + front = { image = "assets/battle/front-animated/gen3/charmeleon.png", width = 74, height = 57, columns = 13, frames = 13, durations = {80,20,20,20,20,20,20,140,20,20,20,20,2250} }, + }, + CHARIZARD = { + front = { image = "assets/battle/front-animated/gen3/charizard.png", width = 64, height = 68, columns = 16, frames = 21, durations = {80,20,20,20,20,20,20,20,20,40,20,20,20,20,20,20,20,20,20,20,2250} }, + }, + SQUIRTLE = { + front = { image = "assets/battle/front-animated/gen3/squirtle.png", width = 55, height = 40, columns = 16, frames = 42, durations = {80,20,20,20,20,20,20,20,20,20,20,120,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,120,20,20,20,20,20,20,20,20,20,2250} }, + }, + WARTORTLE = { + front = { image = "assets/battle/front-animated/gen3/wartortle.png", width = 64, height = 53, columns = 16, frames = 25, durations = {80,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,2250} }, + }, + BLASTOISE = { + front = { image = "assets/battle/front-animated/gen3/blastoise.png", width = 64, height = 60, columns = 16, frames = 27, durations = {80,20,20,20,20,20,20,20,20,20,20,20,40,200,20,20,20,20,20,20,20,20,20,20,40,20,2250} }, + }, + CATERPIE = { + front = { image = "assets/battle/front-animated/gen3/caterpie.png", width = 48, height = 35, columns = 16, frames = 49, durations = {80,40,20,20,20,20,20,20,20,20,20,40,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,40,60,20,20,20,20,20,20,20,20,20,20,40,2250} }, + }, + METAPOD = { + front = { image = "assets/battle/front-animated/gen3/metapod.png", width = 63, height = 32, columns = 16, frames = 46, durations = {80,40,20,20,20,20,20,20,20,20,20,40,20,40,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,100,20,20,20,20,20,20,20,20,20,20,2250} }, + }, + BUTTERFREE = { + front = { image = "assets/battle/front-animated/gen3/butterfree.png", width = 76, height = 50, columns = 16, frames = 48, durations = {80,20,20,20,20,20,40,20,20,20,20,20,20,20,20,20,20,20,40,20,20,20,20,20,20,20,20,20,20,40,20,20,20,20,20,20,20,20,20,20,20,40,20,20,20,20,20,2250} }, + }, + WEEDLE = { + front = { image = "assets/battle/front-animated/gen3/weedle.png", width = 45, height = 29, columns = 16, frames = 31, durations = {80,80,20,20,20,20,80,20,20,20,20,20,20,20,40,40,40,40,20,20,20,20,20,20,20,80,20,20,20,20,2250} }, + }, + KAKUNA = { + front = { image = "assets/battle/front-animated/gen3/kakuna.png", width = 27, height = 38, columns = 16, frames = 27, durations = {80,60,40,60,20,20,20,20,20,20,20,40,20,40,60,40,60,40,20,40,20,20,20,20,20,20,2250} }, + }, + BEEDRILL = { + front = { image = "assets/battle/front-animated/gen3/beedrill.png", width = 62, height = 48, columns = 14, frames = 14, durations = {80,320,20,20,40,20,160,20,20,20,20,20,40,2250} }, + }, + PIDGEY = { + front = { image = "assets/battle/front-animated/gen3/pidgey.png", width = 43, height = 51, columns = 16, frames = 32, durations = {80,40,20,20,20,60,40,20,20,20,20,20,20,20,20,20,20,60,40,20,20,20,20,20,20,20,20,80,20,20,20,2250} }, + }, + PIDGEOTTO = { + front = { image = "assets/battle/front-animated/gen3/pidgeotto.png", width = 49, height = 60, columns = 16, frames = 24, durations = {80,100,100,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,2250} }, + }, + PIDGEOT = { + front = { image = "assets/battle/front-animated/gen3/pidgeot.png", width = 88, height = 80, columns = 16, frames = 27, durations = {80,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,2250} }, + }, + RATTATA = { + front = { image = "assets/battle/front-animated/gen3/rattata.png", width = 61, height = 34, columns = 16, frames = 44, durations = {80,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,2250} }, + }, + RATICATE = { + front = { image = "assets/battle/front-animated/gen3/raticate.png", width = 82, height = 65, columns = 16, frames = 35, durations = {80,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,2250} }, + }, + SPEAROW = { + front = { image = "assets/battle/front-animated/gen3/spearow.png", width = 38, height = 45, columns = 16, frames = 48, durations = {80,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,40,20,20,20,20,20,20,20,20,20,20,60,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,2250} }, + }, + FEAROW = { + front = { image = "assets/battle/front-animated/gen3/fearow.png", width = 90, height = 74, columns = 16, frames = 35, durations = {80,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,2250} }, + }, + EKANS = { + front = { image = "assets/battle/front-animated/gen3/ekans.png", width = 68, height = 42, columns = 16, frames = 26, durations = {80,80,80,80,80,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,2250} }, + }, + ARBOK = { + front = { image = "assets/battle/front-animated/gen3/arbok.png", width = 57, height = 75, columns = 16, frames = 22, durations = {80,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,2250} }, + }, + PIKACHU = { + front = { image = "assets/battle/front-animated/gen3/pikachu.png", width = 45, height = 52, columns = 16, frames = 16, durations = {80,20,60,100,60,20,20,20,20,40,20,40,20,40,160,2250} }, + }, + RAICHU = { + front = { image = "assets/battle/front-animated/gen3/raichu.png", width = 52, height = 71, columns = 16, frames = 24, durations = {80,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,80,200,2250} }, + }, + SANDSHREW = { + front = { image = "assets/battle/front-animated/gen3/sandshrew.png", width = 56, height = 39, columns = 16, frames = 23, durations = {80,20,20,20,20,60,20,20,20,20,20,20,20,20,20,20,40,20,20,20,20,20,2250} }, + }, + SANDSLASH = { + front = { image = "assets/battle/front-animated/gen3/sandslash.png", width = 50, height = 57, columns = 16, frames = 22, durations = {80,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,2250} }, + }, + NIDORAN_F = { + front = { image = "assets/battle/front-animated/gen3/nidoran-f.png", width = 33, height = 47, columns = 16, frames = 29, durations = {80,60,40,60,80,20,20,20,40,20,20,20,20,20,20,40,20,20,20,20,20,20,40,20,20,20,40,40,2250} }, + }, + NIDORINA = { + front = { image = "assets/battle/front-animated/gen3/nidorina.png", width = 51, height = 49, columns = 16, frames = 23, durations = {80,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,2250} }, + }, + NIDOQUEEN = { + front = { image = "assets/battle/front-animated/gen3/nidoqueen.png", width = 67, height = 58, columns = 16, frames = 21, durations = {80,40,20,20,20,20,20,20,20,20,20,20,20,20,20,40,20,20,20,40,2250} }, + }, + NIDORAN_M = { + front = { image = "assets/battle/front-animated/gen3/nidoran-m.png", width = 44, height = 57, columns = 16, frames = 21, durations = {80,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,2250} }, + }, + NIDORINO = { + front = { image = "assets/battle/front-animated/gen3/nidorino.png", width = 61, height = 57, columns = 16, frames = 25, durations = {80,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,2250} }, + }, + NIDOKING = { + front = { image = "assets/battle/front-animated/gen3/nidoking.png", width = 62, height = 60, columns = 16, frames = 22, durations = {80,100,140,40,20,20,20,20,20,20,20,20,20,20,20,20,20,40,20,20,20,2250} }, + }, + CLEFAIRY = { + front = { image = "assets/battle/front-animated/gen3/clefairy.png", width = 41, height = 50, columns = 16, frames = 26, durations = {80,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,2250} }, + }, + CLEFABLE = { + front = { image = "assets/battle/front-animated/gen3/clefable.png", width = 63, height = 59, columns = 16, frames = 85, durations = {80,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,40,20,20,20,20,20,20,2250} }, + }, + VULPIX = { + front = { image = "assets/battle/front-animated/gen3/vulpix.png", width = 56, height = 50, columns = 16, frames = 23, durations = {80,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,100,200,2250} }, + }, + NINETALES = { + front = { image = "assets/battle/front-animated/gen3/ninetales.png", width = 61, height = 68, columns = 16, frames = 22, durations = {80,20,20,20,20,20,20,20,40,20,20,20,20,20,20,20,20,20,20,100,200,2250} }, + }, + JIGGLYPUFF = { + front = { image = "assets/battle/front-animated/gen3/jigglypuff.png", width = 51, height = 45, columns = 16, frames = 45, durations = {80,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,2250} }, + }, + WIGGLYTUFF = { + front = { image = "assets/battle/front-animated/gen3/wigglytuff.png", width = 68, height = 61, columns = 16, frames = 25, durations = {80,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,60,200,2250} }, + }, + ZUBAT = { + front = { image = "assets/battle/front-animated/gen3/zubat.png", width = 52, height = 70, columns = 16, frames = 24, durations = {80,20,20,20,20,20,20,20,20,20,20,20,40,20,20,20,20,20,20,40,20,20,20,2250} }, + }, + GOLBAT = { + front = { image = "assets/battle/front-animated/gen3/golbat.png", width = 83, height = 66, columns = 16, frames = 50, durations = {80,20,20,20,20,20,40,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,40,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,2250} }, + }, + ODDISH = { + front = { image = "assets/battle/front-animated/gen3/oddish.png", width = 35, height = 48, columns = 16, frames = 28, durations = {80,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,40,120,2250} }, + }, + GLOOM = { + front = { image = "assets/battle/front-animated/gen3/gloom.png", width = 51, height = 58, columns = 16, frames = 49, durations = {80,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,2250} }, + }, + VILEPLUME = { + front = { image = "assets/battle/front-animated/gen3/vileplume.png", width = 74, height = 65, columns = 16, frames = 84, durations = {80,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,40,20,20,20,20,20,20,2250} }, + }, + PARAS = { + front = { image = "assets/battle/front-animated/gen3/paras.png", width = 52, height = 37, columns = 16, frames = 36, durations = {80,60,40,20,20,20,20,20,100,20,20,20,20,20,20,20,20,100,20,20,20,20,20,20,20,20,40,60,20,20,20,20,60,40,60,2250} }, + }, + PARASECT = { + front = { image = "assets/battle/front-animated/gen3/parasect.png", width = 67, height = 53, columns = 16, frames = 24, durations = {80,100,100,100,140,40,20,20,20,20,20,20,20,20,20,20,20,20,20,40,20,20,20,2250} }, + }, + VENONAT = { + front = { image = "assets/battle/front-animated/gen3/venonat.png", width = 54, height = 60, columns = 16, frames = 32, durations = {80,40,60,40,60,20,20,40,20,20,20,40,20,100,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,2250} }, + }, + VENOMOTH = { + front = { image = "assets/battle/front-animated/gen3/venomoth.png", width = 72, height = 70, columns = 16, frames = 55, durations = {80,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,2250} }, + }, + DIGLETT = { + front = { image = "assets/battle/front-animated/gen3/diglett.png", width = 42, height = 43, columns = 16, frames = 20, durations = {80,20,20,20,20,20,20,20,40,20,20,20,20,20,20,20,20,20,20,2250} }, + }, + DUGTRIO = { + front = { image = "assets/battle/front-animated/gen3/dugtrio.png", width = 54, height = 40, columns = 16, frames = 42, durations = {80,340,20,20,20,20,20,20,20,40,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,2250} }, + }, + MEOWTH = { + front = { image = "assets/battle/front-animated/gen3/meowth.png", width = 39, height = 47, columns = 16, frames = 20, durations = {80,80,80,80,160,20,20,40,20,20,20,40,20,20,20,20,20,20,20,2250} }, + }, + PERSIAN = { + front = { image = "assets/battle/front-animated/gen3/persian.png", width = 57, height = 61, columns = 16, frames = 22, durations = {80,220,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,2250} }, + }, + PSYDUCK = { + front = { image = "assets/battle/front-animated/gen3/psyduck.png", width = 53, height = 56, columns = 16, frames = 28, durations = {80,20,20,40,20,20,20,40,20,120,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,40,2250} }, + }, + GOLDUCK = { + front = { image = "assets/battle/front-animated/gen3/golduck.png", width = 62, height = 61, columns = 16, frames = 36, durations = {80,40,20,20,20,20,20,20,20,20,20,20,20,20,20,40,20,20,20,20,20,20,20,20,20,20,20,20,20,40,40,20,20,20,20,2250} }, + }, + MANKEY = { + front = { image = "assets/battle/front-animated/gen3/mankey.png", width = 67, height = 75, columns = 16, frames = 42, durations = {80,60,40,60,60,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,2250} }, + }, + PRIMEAPE = { + front = { image = "assets/battle/front-animated/gen3/primeape.png", width = 67, height = 63, columns = 16, frames = 45, durations = {80,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,2250} }, + }, + GROWLITHE = { + front = { image = "assets/battle/front-animated/gen3/growlithe.png", width = 60, height = 43, columns = 16, frames = 16, durations = {80,80,80,80,60,20,20,20,20,20,20,140,20,20,20,2250} }, + }, + ARCANINE = { + front = { image = "assets/battle/front-animated/gen3/arcanine.png", width = 69, height = 59, columns = 16, frames = 16, durations = {80,80,80,80,140,40,20,20,40,20,160,20,40,20,20,2250} }, + }, + POLIWAG = { + front = { image = "assets/battle/front-animated/gen3/poliwag.png", width = 56, height = 48, columns = 16, frames = 26, durations = {80,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,2250} }, + }, + POLIWHIRL = { + front = { image = "assets/battle/front-animated/gen3/poliwhirl.png", width = 89, height = 73, columns = 16, frames = 38, durations = {80,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,2250} }, + }, + POLIWRATH = { + front = { image = "assets/battle/front-animated/gen3/poliwrath.png", width = 57, height = 56, columns = 16, frames = 26, durations = {80,20,20,40,20,20,40,20,20,40,20,20,180,20,20,20,20,40,20,20,20,20,20,20,40,2250} }, + }, + ABRA = { + front = { image = "assets/battle/front-animated/gen3/abra.png", width = 58, height = 47, columns = 16, frames = 24, durations = {80,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,180,2250} }, + }, + KADABRA = { + front = { image = "assets/battle/front-animated/gen3/kadabra.png", width = 54, height = 65, columns = 16, frames = 22, durations = {80,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,160,2250} }, + }, + ALAKAZAM = { + front = { image = "assets/battle/front-animated/gen3/alakazam.png", width = 59, height = 66, columns = 16, frames = 23, durations = {80,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,220,2250} }, + }, + MACHOP = { + front = { image = "assets/battle/front-animated/gen3/machop.png", width = 39, height = 56, columns = 16, frames = 26, durations = {80,20,20,20,40,20,20,20,20,20,20,40,20,20,20,20,20,20,40,20,20,20,100,160,140,2250} }, + }, + MACHOKE = { + front = { image = "assets/battle/front-animated/gen3/machoke.png", width = 46, height = 57, columns = 16, frames = 21, durations = {80,20,20,20,20,20,20,20,40,20,20,20,20,20,20,20,20,20,20,240,2250} }, + }, + MACHAMP = { + front = { image = "assets/battle/front-animated/gen3/machamp.png", width = 78, height = 68, columns = 16, frames = 25, durations = {80,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,120,100,2250} }, + }, + BELLSPROUT = { + front = { image = "assets/battle/front-animated/gen3/bellsprout.png", width = 46, height = 55, columns = 16, frames = 22, durations = {80,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,2250} }, + }, + WEEPINBELL = { + front = { image = "assets/battle/front-animated/gen3/weepinbell.png", width = 77, height = 47, columns = 16, frames = 43, durations = {80,20,20,20,20,20,20,20,20,20,80,40,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,100,20,20,20,20,20,20,20,20,20,2250} }, + }, + VICTREEBEL = { + front = { image = "assets/battle/front-animated/gen3/victreebel.png", width = 84, height = 75, columns = 16, frames = 38, durations = {80,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,2250} }, + }, + TENTACOOL = { + front = { image = "assets/battle/front-animated/gen3/tentacool.png", width = 37, height = 64, columns = 16, frames = 26, durations = {80,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,2250} }, + }, + TENTACRUEL = { + front = { image = "assets/battle/front-animated/gen3/tentacruel.png", width = 71, height = 75, columns = 16, frames = 29, durations = {80,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,40,180,200,2250} }, + }, + GEODUDE = { + front = { image = "assets/battle/front-animated/gen3/geodude.png", width = 56, height = 51, columns = 16, frames = 44, durations = {80,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,2250} }, + }, + GRAVELER = { + front = { image = "assets/battle/front-animated/gen3/graveler.png", width = 86, height = 72, columns = 16, frames = 45, durations = {80,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,2250} }, + }, + GOLEM = { + front = { image = "assets/battle/front-animated/gen3/golem.png", width = 60, height = 71, columns = 16, frames = 26, durations = {80,20,20,20,220,20,20,40,20,20,20,20,20,20,20,20,20,20,20,20,20,40,20,20,20,2250} }, + }, + PONYTA = { + front = { image = "assets/battle/front-animated/gen3/ponyta.png", width = 54, height = 50, columns = 16, frames = 26, durations = {80,20,20,20,20,20,20,20,20,20,40,20,80,20,80,20,40,20,20,20,20,20,20,20,20,2250} }, + }, + RAPIDASH = { + front = { image = "assets/battle/front-animated/gen3/rapidash.png", width = 76, height = 72, columns = 16, frames = 27, durations = {80,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,2250} }, + }, + SLOWPOKE = { + front = { image = "assets/battle/front-animated/gen3/slowpoke.png", width = 54, height = 48, columns = 16, frames = 47, durations = {80,20,20,20,20,20,20,20,40,20,20,20,20,20,20,20,20,20,20,20,20,20,20,40,20,20,20,20,20,20,20,20,20,20,20,20,20,20,40,20,20,20,20,20,20,20,2250} }, + }, + SLOWBRO = { + front = { image = "assets/battle/front-animated/gen3/slowbro.png", width = 78, height = 56, columns = 16, frames = 42, durations = {80,20,20,20,20,20,20,20,20,20,20,120,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,120,20,20,20,20,20,20,20,20,20,2250} }, + }, + MAGNEMITE = { + front = { image = "assets/battle/front-animated/gen3/magnemite.png", width = 51, height = 56, columns = 16, frames = 67, durations = {80,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,100,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,2250} }, + }, + MAGNETON = { + front = { image = "assets/battle/front-animated/gen3/magneton.png", width = 49, height = 48, columns = 16, frames = 18, durations = {80,20,60,100,40,20,20,40,20,40,20,40,20,40,20,140,140,2250} }, + }, + FARFETCHD = { + front = { image = "assets/battle/front-animated/gen3/farfetchd.png", width = 70, height = 59, columns = 16, frames = 46, durations = {80,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,2250} }, + }, + DODUO = { + front = { image = "assets/battle/front-animated/gen3/doduo.png", width = 51, height = 53, columns = 16, frames = 38, durations = {80,20,20,20,20,20,20,40,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,40,20,20,20,20,20,20,20,20,20,20,20,20,20,2250} }, + }, + DODRIO = { + front = { image = "assets/battle/front-animated/gen3/dodrio.png", width = 89, height = 83, columns = 16, frames = 37, durations = {80,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,60,120,120,2250} }, + }, + SEEL = { + front = { image = "assets/battle/front-animated/gen3/seel.png", width = 75, height = 51, columns = 16, frames = 45, durations = {80,20,20,20,20,20,20,20,20,20,20,100,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,40,80,20,20,20,20,20,20,20,20,20,40,2250} }, + }, + DEWGONG = { + front = { image = "assets/battle/front-animated/gen3/dewgong.png", width = 73, height = 76, columns = 16, frames = 49, durations = {80,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,2250} }, + }, + GRIMER = { + front = { image = "assets/battle/front-animated/gen3/grimer.png", width = 61, height = 43, columns = 16, frames = 30, durations = {80,20,20,20,20,100,20,20,20,20,20,20,20,20,40,60,20,20,20,20,20,20,20,20,100,20,20,20,20,2250} }, + }, + MUK = { + front = { image = "assets/battle/front-animated/gen3/muk.png", width = 71, height = 67, columns = 16, frames = 37, durations = {80,100,100,100,60,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,2250} }, + }, + SHELLDER = { + front = { image = "assets/battle/front-animated/gen3/shellder.png", width = 35, height = 35, columns = 16, frames = 21, durations = {80,60,40,120,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,40,2250} }, + }, + CLOYSTER = { + front = { image = "assets/battle/front-animated/gen3/cloyster.png", width = 77, height = 58, columns = 16, frames = 50, durations = {80,20,20,20,20,20,20,40,20,20,20,20,20,20,20,20,20,20,20,40,20,20,20,20,20,20,20,20,20,20,40,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,2250} }, + }, + GASTLY = { + front = { image = "assets/battle/front-animated/gen3/gastly.png", width = 54, height = 55, columns = 16, frames = 32, durations = {80,40,40,20,20,20,40,20,40,20,40,20,40,40,60,60,280,60,60,40,40,20,40,20,40,20,40,20,20,20,40,2250} }, + }, + HAUNTER = { + front = { image = "assets/battle/front-animated/gen3/haunter.png", width = 64, height = 56, columns = 13, frames = 13, durations = {80,80,160,40,20,20,20,160,20,60,20,280,2250} }, + }, + GENGAR = { + front = { image = "assets/battle/front-animated/gen3/gengar.png", width = 77, height = 69, columns = 16, frames = 46, durations = {80,20,20,20,20,20,40,20,20,20,40,20,40,20,20,20,20,20,20,40,20,20,20,40,60,20,20,20,20,40,100,40,20,20,20,20,20,20,20,20,20,20,20,20,40,2250} }, + }, + ONIX = { + front = { image = "assets/battle/front-animated/gen3/onix.png", width = 81, height = 63, columns = 16, frames = 45, durations = {80,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,2250} }, + }, + DROWZEE = { + front = { image = "assets/battle/front-animated/gen3/drowzee.png", width = 67, height = 55, columns = 16, frames = 28, durations = {80,120,120,120,120,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,2250} }, + }, + HYPNO = { + front = { image = "assets/battle/front-animated/gen3/hypno.png", width = 55, height = 57, columns = 9, frames = 9, durations = {80,160,100,40,160,140,60,100,2250} }, + }, + KRABBY = { + front = { image = "assets/battle/front-animated/gen3/krabby.png", width = 60, height = 42, columns = 16, frames = 19, durations = {80,20,20,20,20,40,20,20,20,20,40,20,20,20,20,60,20,20,2250} }, + }, + KINGLER = { + front = { image = "assets/battle/front-animated/gen3/kingler.png", width = 76, height = 74, columns = 16, frames = 55, durations = {80,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,2250} }, + }, + VOLTORB = { + front = { image = "assets/battle/front-animated/gen3/voltorb.png", width = 29, height = 40, columns = 16, frames = 27, durations = {80,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,80,2250} }, + }, + ELECTRODE = { + front = { image = "assets/battle/front-animated/gen3/electrode.png", width = 41, height = 50, columns = 16, frames = 27, durations = {80,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,260,2250} }, + }, + EXEGGCUTE = { + front = { image = "assets/battle/front-animated/gen3/exeggcute.png", width = 70, height = 52, columns = 16, frames = 31, durations = {80,20,20,20,20,100,20,20,20,20,20,20,20,20,40,60,20,20,20,20,20,20,20,20,80,20,20,20,20,20,2250} }, + }, + EXEGGUTOR = { + front = { image = "assets/battle/front-animated/gen3/exeggutor.png", width = 80, height = 74, columns = 16, frames = 39, durations = {80,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,2250} }, + }, + CUBONE = { + front = { image = "assets/battle/front-animated/gen3/cubone.png", width = 55, height = 54, columns = 16, frames = 46, durations = {80,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,2250} }, + }, + MAROWAK = { + front = { image = "assets/battle/front-animated/gen3/marowak.png", width = 67, height = 71, columns = 16, frames = 46, durations = {80,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,2250} }, + }, + HITMONLEE = { + front = { image = "assets/battle/front-animated/gen3/hitmonlee.png", width = 75, height = 59, columns = 16, frames = 22, durations = {80,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,2250} }, + }, + HITMONCHAN = { + front = { image = "assets/battle/front-animated/gen3/hitmonchan.png", width = 45, height = 57, columns = 10, frames = 10, durations = {80,60,40,60,40,60,140,60,200,2250} }, + }, + LICKITUNG = { + front = { image = "assets/battle/front-animated/gen3/lickitung.png", width = 65, height = 70, columns = 16, frames = 47, durations = {80,20,20,20,20,20,20,20,40,20,20,20,20,20,20,20,20,20,20,20,20,20,20,40,20,20,20,20,20,20,20,20,20,20,20,20,20,20,40,20,20,20,20,20,20,20,2250} }, + }, + KOFFING = { + front = { image = "assets/battle/front-animated/gen3/koffing.png", width = 62, height = 55, columns = 16, frames = 24, durations = {80,20,20,20,20,20,20,20,20,20,20,20,40,20,20,20,20,20,20,40,20,20,20,2250} }, + }, + WEEZING = { + front = { image = "assets/battle/front-animated/gen3/weezing.png", width = 64, height = 72, columns = 16, frames = 18, durations = {80,20,20,40,20,20,20,20,20,40,20,20,20,20,60,20,20,2250} }, + }, + RHYHORN = { + front = { image = "assets/battle/front-animated/gen3/rhyhorn.png", width = 55, height = 51, columns = 16, frames = 19, durations = {80,20,20,20,20,20,20,20,20,20,20,20,20,20,40,20,20,20,2250} }, + }, + RHYDON = { + front = { image = "assets/battle/front-animated/gen3/rhydon.png", width = 73, height = 69, columns = 16, frames = 24, durations = {80,20,20,20,40,20,20,20,20,20,20,40,20,20,20,20,20,20,40,20,20,20,300,2250} }, + }, + CHANSEY = { + front = { image = "assets/battle/front-animated/gen3/chansey.png", width = 60, height = 63, columns = 16, frames = 47, durations = {80,20,20,20,20,20,20,20,40,20,20,20,20,20,20,20,20,20,20,20,20,20,20,40,20,20,20,20,20,20,20,20,20,20,20,20,20,20,40,20,20,20,20,20,20,20,2250} }, + }, + TANGELA = { + front = { image = "assets/battle/front-animated/gen3/tangela.png", width = 65, height = 64, columns = 16, frames = 46, durations = {80,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,2250} }, + }, + KANGASKHAN = { + front = { image = "assets/battle/front-animated/gen3/kangaskhan.png", width = 63, height = 75, columns = 16, frames = 22, durations = {80,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,2250} }, + }, + HORSEA = { + front = { image = "assets/battle/front-animated/gen3/horsea.png", width = 37, height = 39, columns = 16, frames = 16, durations = {80,20,40,20,20,40,20,20,20,20,20,20,40,20,180,2250} }, + }, + SEADRA = { + front = { image = "assets/battle/front-animated/gen3/seadra.png", width = 55, height = 63, columns = 16, frames = 19, durations = {80,20,20,60,20,20,20,20,20,20,20,20,20,20,60,20,20,100,2250} }, + }, + GOLDEEN = { + front = { image = "assets/battle/front-animated/gen3/goldeen.png", width = 72, height = 49, columns = 16, frames = 49, durations = {80,20,20,20,20,20,20,20,20,20,20,40,20,40,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,80,20,20,20,20,20,20,20,20,20,20,20,100,160,2250} }, + }, + SEAKING = { + front = { image = "assets/battle/front-animated/gen3/seaking.png", width = 58, height = 77, columns = 16, frames = 51, durations = {80,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,2250} }, + }, + STARYU = { + front = { image = "assets/battle/front-animated/gen3/staryu.png", width = 47, height = 47, columns = 16, frames = 58, durations = {80,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,120,20,40,20,20,40,20,20,40,20,20,40,20,20,40,20,20,40,20,20,40,20,20,40,20,2250} }, + }, + STARMIE = { + front = { image = "assets/battle/front-animated/gen3/starmie.png", width = 56, height = 55, columns = 16, frames = 16, durations = {80,20,40,20,20,40,20,20,20,20,20,20,40,20,180,2250} }, + }, + MR_MIME = { + front = { image = "assets/battle/front-animated/gen3/mr-mime.png", width = 59, height = 48, columns = 16, frames = 29, durations = {80,40,20,20,20,60,40,20,20,20,20,20,20,20,40,80,40,20,20,20,20,20,20,20,100,20,20,20,2250} }, + }, + SCYTHER = { + front = { image = "assets/battle/front-animated/gen3/scyther.png", width = 65, height = 64, columns = 16, frames = 19, durations = {80,60,40,40,20,20,20,40,60,20,40,20,20,20,20,20,40,60,2250} }, + }, + JYNX = { + front = { image = "assets/battle/front-animated/gen3/jynx.png", width = 56, height = 70, columns = 16, frames = 24, durations = {80,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,100,700,2250} }, + }, + ELECTABUZZ = { + front = { image = "assets/battle/front-animated/gen3/electabuzz.png", width = 61, height = 58, columns = 16, frames = 16, durations = {80,80,20,100,60,40,40,20,40,20,40,20,40,20,60,2250} }, + }, + MAGMAR = { + front = { image = "assets/battle/front-animated/gen3/magmar.png", width = 63, height = 63, columns = 16, frames = 20, durations = {80,40,20,20,20,20,20,20,20,20,20,20,20,20,20,40,20,20,20,2250} }, + }, + PINSIR = { + front = { image = "assets/battle/front-animated/gen3/pinsir.png", width = 68, height = 67, columns = 16, frames = 21, durations = {80,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,2250} }, + }, + TAUROS = { + front = { image = "assets/battle/front-animated/gen3/tauros.png", width = 58, height = 73, columns = 16, frames = 27, durations = {80,100,20,40,20,20,40,20,20,20,20,20,20,180,20,20,20,20,40,20,20,20,20,20,20,40,2250} }, + }, + MAGIKARP = { + front = { image = "assets/battle/front-animated/gen3/magikarp.png", width = 67, height = 62, columns = 16, frames = 44, durations = {80,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,2250} }, + }, + GYARADOS = { + front = { image = "assets/battle/front-animated/gen3/gyarados.png", width = 82, height = 69, columns = 16, frames = 44, durations = {80,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,2250} }, + }, + LAPRAS = { + front = { image = "assets/battle/front-animated/gen3/lapras.png", width = 64, height = 55, columns = 16, frames = 23, durations = {80,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,2250} }, + }, + DITTO = { + front = { image = "assets/battle/front-animated/gen3/ditto.png", width = 40, height = 35, columns = 16, frames = 23, durations = {80,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,2250} }, + }, + EEVEE = { + front = { image = "assets/battle/front-animated/gen3/eevee.png", width = 38, height = 51, columns = 16, frames = 23, durations = {80,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,2250} }, + }, + VAPOREON = { + front = { image = "assets/battle/front-animated/gen3/vaporeon.png", width = 46, height = 59, columns = 16, frames = 23, durations = {80,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,2250} }, + }, + JOLTEON = { + front = { image = "assets/battle/front-animated/gen3/jolteon.png", width = 60, height = 62, columns = 16, frames = 24, durations = {80,20,20,20,20,40,20,20,20,20,20,20,40,20,20,20,20,20,20,40,20,20,20,2250} }, + }, + FLAREON = { + front = { image = "assets/battle/front-animated/gen3/flareon.png", width = 43, height = 58, columns = 16, frames = 22, durations = {80,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,2250} }, + }, + PORYGON = { + front = { image = "assets/battle/front-animated/gen3/porygon.png", width = 47, height = 46, columns = 16, frames = 16, durations = {80,20,20,40,20,20,20,20,20,20,20,20,20,40,20,2250} }, + }, + OMANYTE = { + front = { image = "assets/battle/front-animated/gen3/omanyte.png", width = 36, height = 47, columns = 16, frames = 49, durations = {80,20,20,20,20,20,20,40,20,20,20,20,20,20,20,20,20,20,20,40,20,20,20,20,20,20,20,20,20,20,40,20,20,20,20,20,20,20,20,20,20,20,40,20,20,20,20,20,2250} }, + }, + OMASTAR = { + front = { image = "assets/battle/front-animated/gen3/omastar.png", width = 63, height = 58, columns = 16, frames = 22, durations = {80,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,2250} }, + }, + KABUTO = { + front = { image = "assets/battle/front-animated/gen3/kabuto.png", width = 51, height = 35, columns = 16, frames = 49, durations = {80,20,20,20,20,20,20,40,20,20,20,20,20,20,20,20,20,20,20,40,20,20,20,20,20,20,20,20,20,20,40,20,20,20,20,20,20,20,20,20,20,20,40,20,20,20,20,20,2250} }, + }, + KABUTOPS = { + front = { image = "assets/battle/front-animated/gen3/kabutops.png", width = 66, height = 57, columns = 16, frames = 20, durations = {80,40,20,20,20,20,20,20,20,20,20,20,20,20,20,40,20,20,20,2250} }, + }, + AERODACTYL = { + front = { image = "assets/battle/front-animated/gen3/aerodactyl.png", width = 64, height = 72, columns = 16, frames = 31, durations = {80,20,20,20,20,20,100,20,20,20,20,20,20,20,20,80,20,20,20,20,20,20,20,20,20,100,20,20,20,20,2250} }, + }, + SNORLAX = { + front = { image = "assets/battle/front-animated/gen3/snorlax.png", width = 80, height = 58, columns = 16, frames = 42, durations = {80,20,20,20,20,20,20,20,20,20,20,120,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,120,20,20,20,20,20,20,20,20,20,2250} }, + }, + ARTICUNO = { + front = { image = "assets/battle/front-animated/gen3/articuno.png", width = 73, height = 68, columns = 16, frames = 23, durations = {80,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,200,2250} }, + }, + ZAPDOS = { + front = { image = "assets/battle/front-animated/gen3/zapdos.png", width = 64, height = 59, columns = 16, frames = 16, durations = {80,80,20,100,60,40,40,20,40,20,40,20,20,20,20,2250} }, + }, + MOLTRES = { + front = { image = "assets/battle/front-animated/gen3/moltres.png", width = 72, height = 68, columns = 16, frames = 50, durations = {80,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,40,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,2250} }, + }, + DRATINI = { + front = { image = "assets/battle/front-animated/gen3/dratini.png", width = 60, height = 54, columns = 16, frames = 23, durations = {80,20,20,20,40,20,20,20,20,20,20,40,20,20,20,20,20,20,40,20,20,20,2250} }, + }, + DRAGONAIR = { + front = { image = "assets/battle/front-animated/gen3/dragonair.png", width = 62, height = 60, columns = 16, frames = 21, durations = {80,20,20,20,20,20,20,20,20,40,20,20,20,20,20,20,20,20,20,20,2250} }, + }, + DRAGONITE = { + front = { image = "assets/battle/front-animated/gen3/dragonite.png", width = 64, height = 72, columns = 16, frames = 28, durations = {80,40,20,20,20,100,20,20,20,20,20,20,20,40,80,40,20,20,20,20,20,20,20,100,20,20,20,2250} }, + }, + MEWTWO = { + front = { image = "assets/battle/front-animated/gen3/mewtwo.png", width = 65, height = 72, columns = 16, frames = 22, durations = {80,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,2250} }, + }, + MEW = { + front = { image = "assets/battle/front-animated/gen3/mew.png", width = 66, height = 41, columns = 16, frames = 47, durations = {80,20,20,20,20,20,20,20,20,20,20,20,40,20,40,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,100,20,20,20,20,20,20,20,20,20,20,2250} }, + }, +} diff --git a/data/animated_battle_sprites_gen5.lua b/data/animated_battle_sprites_gen5.lua new file mode 100644 index 0000000..5384a10 --- /dev/null +++ b/data/animated_battle_sprites_gen5.lua @@ -0,0 +1,608 @@ +-- Generated by tools/import_animated_sprites.py --set gen5. +-- Black/White animated front and back sprites, original 151. +return { + BULBASAUR = { + front = { image = "assets/battle/front-animated/gen5/bulbasaur.png", width = 37, height = 38, columns = 16, frames = 99, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/bulbasaur.png", width = 33, height = 38, columns = 16, frames = 99, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + IVYSAUR = { + front = { image = "assets/battle/front-animated/gen5/ivysaur.png", width = 58, height = 51, columns = 16, frames = 111, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/ivysaur.png", width = 57, height = 45, columns = 16, frames = 111, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + VENUSAUR = { + front = { image = "assets/battle/front-animated/gen5/venusaur.png", width = 86, height = 71, columns = 16, frames = 167, durations = {100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/venusaur.png", width = 89, height = 63, columns = 16, frames = 167, durations = {100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,100} }, + }, + CHARMANDER = { + front = { image = "assets/battle/front-animated/gen5/charmander.png", width = 42, height = 42, columns = 16, frames = 107, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/charmander.png", width = 43, height = 44, columns = 16, frames = 107, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + CHARMELEON = { + front = { image = "assets/battle/front-animated/gen5/charmeleon.png", width = 69, height = 56, columns = 16, frames = 89, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/charmeleon.png", width = 67, height = 60, columns = 16, frames = 89, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + CHARIZARD = { + front = { image = "assets/battle/front-animated/gen5/charizard.png", width = 89, height = 91, columns = 16, frames = 143, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/charizard.png", width = 98, height = 83, columns = 16, frames = 143, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + SQUIRTLE = { + front = { image = "assets/battle/front-animated/gen5/squirtle.png", width = 39, height = 43, columns = 16, frames = 51, durations = {50,50,50,50,50,50,50,100,50,50,50,50,50,50,150,50,50,50,50,50,50,100,50,50,50,50,50,50,350,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,800} }, + back = { image = "assets/battle/back-animated/gen5/squirtle.png", width = 40, height = 45, columns = 16, frames = 51, durations = {50,50,50,50,50,50,50,100,50,50,50,50,50,50,150,50,50,50,50,50,50,100,50,50,50,50,50,50,350,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,800} }, + }, + WARTORTLE = { + front = { image = "assets/battle/front-animated/gen5/wartortle.png", width = 61, height = 57, columns = 16, frames = 87, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/wartortle.png", width = 57, height = 62, columns = 16, frames = 87, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + BLASTOISE = { + front = { image = "assets/battle/front-animated/gen5/blastoise.png", width = 69, height = 65, columns = 16, frames = 244, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + back = { image = "assets/battle/back-animated/gen5/blastoise.png", width = 66, height = 66, columns = 16, frames = 244, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + }, + CATERPIE = { + front = { image = "assets/battle/front-animated/gen5/caterpie.png", width = 36, height = 37, columns = 16, frames = 81, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/caterpie.png", width = 38, height = 35, columns = 16, frames = 81, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + METAPOD = { + front = { image = "assets/battle/front-animated/gen5/metapod.png", width = 36, height = 38, columns = 16, frames = 111, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/metapod.png", width = 42, height = 43, columns = 16, frames = 111, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + BUTTERFREE = { + front = { image = "assets/battle/front-animated/gen5/butterfree.png", width = 62, height = 57, columns = 16, frames = 89, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/butterfree.png", width = 64, height = 57, columns = 16, frames = 29, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + WEEDLE = { + front = { image = "assets/battle/front-animated/gen5/weedle.png", width = 37, height = 46, columns = 16, frames = 123, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/weedle.png", width = 43, height = 46, columns = 16, frames = 123, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + KAKUNA = { + front = { image = "assets/battle/front-animated/gen5/kakuna.png", width = 32, height = 40, columns = 16, frames = 79, durations = {100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,150} }, + back = { image = "assets/battle/back-animated/gen5/kakuna.png", width = 32, height = 40, columns = 16, frames = 79, durations = {100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,150} }, + }, + BEEDRILL = { + front = { image = "assets/battle/front-animated/gen5/beedrill.png", width = 74, height = 69, columns = 16, frames = 60, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + back = { image = "assets/battle/back-animated/gen5/beedrill.png", width = 76, height = 67, columns = 16, frames = 20, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + }, + PIDGEY = { + front = { image = "assets/battle/front-animated/gen5/pidgey.png", width = 43, height = 48, columns = 16, frames = 41, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,1950,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,450} }, + back = { image = "assets/battle/back-animated/gen5/pidgey.png", width = 45, height = 47, columns = 16, frames = 41, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,1950,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,450} }, + }, + PIDGEOTTO = { + front = { image = "assets/battle/front-animated/gen5/pidgeotto.png", width = 82, height = 116, columns = 16, frames = 35, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/pidgeotto.png", width = 95, height = 96, columns = 16, frames = 35, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + PIDGEOT = { + front = { image = "assets/battle/front-animated/gen5/pidgeot.png", width = 58, height = 68, columns = 16, frames = 71, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/pidgeot.png", width = 68, height = 66, columns = 16, frames = 68, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + RATTATA = { + front = { image = "assets/battle/front-animated/gen5/rattata.png", width = 43, height = 43, columns = 16, frames = 101, durations = {50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/rattata.png", width = 58, height = 42, columns = 16, frames = 99, durations = {50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,100} }, + }, + RATICATE = { + front = { image = "assets/battle/front-animated/gen5/raticate.png", width = 65, height = 51, columns = 16, frames = 119, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/raticate.png", width = 66, height = 52, columns = 16, frames = 119, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + SPEAROW = { + front = { image = "assets/battle/front-animated/gen5/spearow.png", width = 33, height = 39, columns = 16, frames = 61, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/spearow.png", width = 35, height = 40, columns = 16, frames = 61, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + FEAROW = { + front = { image = "assets/battle/front-animated/gen5/fearow.png", width = 82, height = 105, columns = 16, frames = 21, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/fearow.png", width = 79, height = 112, columns = 16, frames = 21, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + EKANS = { + front = { image = "assets/battle/front-animated/gen5/ekans.png", width = 59, height = 46, columns = 16, frames = 71, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/ekans.png", width = 57, height = 49, columns = 16, frames = 71, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + ARBOK = { + front = { image = "assets/battle/front-animated/gen5/arbok.png", width = 81, height = 74, columns = 16, frames = 74, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/arbok.png", width = 86, height = 72, columns = 16, frames = 73, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + PIKACHU = { + front = { image = "assets/battle/front-animated/gen5/pikachu.png", width = 50, height = 46, columns = 16, frames = 112, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,100,100} }, + back = { image = "assets/battle/back-animated/gen5/pikachu.png", width = 41, height = 47, columns = 16, frames = 113, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,200} }, + }, + RAICHU = { + front = { image = "assets/battle/front-animated/gen5/raichu.png", width = 74, height = 73, columns = 16, frames = 167, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/raichu.png", width = 76, height = 72, columns = 16, frames = 167, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + SANDSHREW = { + front = { image = "assets/battle/front-animated/gen5/sandshrew.png", width = 35, height = 39, columns = 16, frames = 123, durations = {50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50} }, + back = { image = "assets/battle/back-animated/gen5/sandshrew.png", width = 41, height = 38, columns = 16, frames = 123, durations = {50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50} }, + }, + SANDSLASH = { + front = { image = "assets/battle/front-animated/gen5/sandslash.png", width = 59, height = 53, columns = 16, frames = 111, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,300} }, + back = { image = "assets/battle/back-animated/gen5/sandslash.png", width = 57, height = 52, columns = 16, frames = 111, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,300} }, + }, + NIDORAN_F = { + front = { image = "assets/battle/front-animated/gen5/nidoran-f.png", width = 36, height = 34, columns = 16, frames = 89, durations = {150,50,100,50,100,50,150,50,250,50,150,50,100,50,100,50,300,50,100,50,150,50,100,50,300,50,100,50,100,50,150,50,250,50,150,50,100,50,100,50,300,50,100,50,150,50,100,50,300,50,100,50,100,50,150,50,250,50,150,50,100,50,100,50,300,50,100,50,50,50,50,50,50,50,50,100,50,100,50,50,50,50,50,100,50,100,50,50,150} }, + back = { image = "assets/battle/back-animated/gen5/nidoran-f.png", width = 33, height = 36, columns = 16, frames = 82, durations = {150,50,100,50,100,50,150,50,250,50,150,50,100,50,100,50,300,50,100,50,150,50,100,50,300,50,100,50,100,50,150,50,250,50,150,50,100,50,100,50,300,50,100,50,150,50,100,50,300,50,100,50,100,50,150,50,250,50,150,50,100,50,100,50,300,50,100,50,150,50,100,50,150,150,50,100,50,100,50,150,50,150} }, + }, + NIDORINA = { + front = { image = "assets/battle/front-animated/gen5/nidorina.png", width = 52, height = 47, columns = 16, frames = 49, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/nidorina.png", width = 52, height = 44, columns = 16, frames = 49, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + NIDOQUEEN = { + front = { image = "assets/battle/front-animated/gen5/nidoqueen.png", width = 76, height = 69, columns = 16, frames = 123, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/nidoqueen.png", width = 85, height = 72, columns = 16, frames = 121, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + NIDORAN_M = { + front = { image = "assets/battle/front-animated/gen5/nidoran-m.png", width = 37, height = 37, columns = 16, frames = 121, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/nidoran-m.png", width = 37, height = 43, columns = 16, frames = 121, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + NIDORINO = { + front = { image = "assets/battle/front-animated/gen5/nidorino.png", width = 52, height = 56, columns = 16, frames = 99, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/nidorino.png", width = 55, height = 55, columns = 16, frames = 99, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + NIDOKING = { + front = { image = "assets/battle/front-animated/gen5/nidoking.png", width = 86, height = 71, columns = 16, frames = 97, durations = {100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,200,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,200,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,200,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/nidoking.png", width = 89, height = 69, columns = 16, frames = 105, durations = {100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + CLEFAIRY = { + front = { image = "assets/battle/front-animated/gen5/clefairy.png", width = 47, height = 42, columns = 16, frames = 133, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50} }, + back = { image = "assets/battle/back-animated/gen5/clefairy.png", width = 54, height = 43, columns = 16, frames = 132, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50} }, + }, + CLEFABLE = { + front = { image = "assets/battle/front-animated/gen5/clefable.png", width = 55, height = 52, columns = 16, frames = 102, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/clefable.png", width = 55, height = 51, columns = 16, frames = 102, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + VULPIX = { + front = { image = "assets/battle/front-animated/gen5/vulpix.png", width = 59, height = 49, columns = 16, frames = 71, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/vulpix.png", width = 61, height = 52, columns = 16, frames = 71, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + NINETALES = { + front = { image = "assets/battle/front-animated/gen5/ninetales.png", width = 76, height = 73, columns = 16, frames = 53, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/ninetales.png", width = 76, height = 64, columns = 16, frames = 107, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + JIGGLYPUFF = { + front = { image = "assets/battle/front-animated/gen5/jigglypuff.png", width = 49, height = 45, columns = 16, frames = 111, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/jigglypuff.png", width = 46, height = 48, columns = 16, frames = 109, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + WIGGLYTUFF = { + front = { image = "assets/battle/front-animated/gen5/wigglytuff.png", width = 49, height = 82, columns = 16, frames = 109, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,200,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,100,50,100,50,100,50,100,50,100,50,50,50,50,50,50,50,50,50,100,100} }, + back = { image = "assets/battle/back-animated/gen5/wigglytuff.png", width = 47, height = 83, columns = 16, frames = 109, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,200,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,100,50,100,50,100,50,100,50,50,50,50,50,50,50,50,50,50,50,50,200} }, + }, + ZUBAT = { + front = { image = "assets/battle/front-animated/gen5/zubat.png", width = 53, height = 46, columns = 16, frames = 54, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + back = { image = "assets/battle/back-animated/gen5/zubat.png", width = 49, height = 59, columns = 16, frames = 54, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + }, + GOLBAT = { + front = { image = "assets/battle/front-animated/gen5/golbat.png", width = 111, height = 83, columns = 16, frames = 16, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + back = { image = "assets/battle/back-animated/gen5/golbat.png", width = 95, height = 84, columns = 16, frames = 16, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + }, + ODDISH = { + front = { image = "assets/battle/front-animated/gen5/oddish.png", width = 38, height = 50, columns = 16, frames = 109, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/oddish.png", width = 41, height = 51, columns = 16, frames = 109, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + GLOOM = { + front = { image = "assets/battle/front-animated/gen5/gloom.png", width = 51, height = 45, columns = 16, frames = 95, durations = {100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/gloom.png", width = 49, height = 46, columns = 16, frames = 95, durations = {100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,100} }, + }, + VILEPLUME = { + front = { image = "assets/battle/front-animated/gen5/vileplume.png", width = 56, height = 50, columns = 16, frames = 71, durations = {50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/vileplume.png", width = 56, height = 48, columns = 16, frames = 71, durations = {50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,100} }, + }, + PARAS = { + front = { image = "assets/battle/front-animated/gen5/paras.png", width = 48, height = 38, columns = 16, frames = 73, durations = {50,50,50,50,50,50,150,50,50,50,50,50,150,50,50,50,50,50,150,50,50,50,50,50,150,50,50,50,50,50,150,50,50,50,50,50,150,50,50,50,50,50,150,50,50,50,50,50,150,50,50,50,50,50,150,50,50,50,50,50,150,50,50,50,50,50,150,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/paras.png", width = 48, height = 32, columns = 16, frames = 74, durations = {50,50,50,50,50,50,150,50,50,50,50,50,150,50,50,50,50,50,150,50,50,50,50,50,150,50,50,50,50,50,150,50,50,50,50,50,150,50,50,50,50,50,150,50,50,50,50,50,100,50,50,50,50,50,50,150,50,50,50,50,50,150,50,50,50,50,50,150,50,50,50,50,50,100} }, + }, + PARASECT = { + front = { image = "assets/battle/front-animated/gen5/parasect.png", width = 62, height = 63, columns = 16, frames = 71, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/parasect.png", width = 70, height = 52, columns = 16, frames = 65, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + VENONAT = { + front = { image = "assets/battle/front-animated/gen5/venonat.png", width = 42, height = 53, columns = 16, frames = 95, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/venonat.png", width = 42, height = 52, columns = 16, frames = 95, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + VENOMOTH = { + front = { image = "assets/battle/front-animated/gen5/venomoth.png", width = 75, height = 74, columns = 16, frames = 21, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + back = { image = "assets/battle/back-animated/gen5/venomoth.png", width = 83, height = 73, columns = 16, frames = 21, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + }, + DIGLETT = { + front = { image = "assets/battle/front-animated/gen5/diglett.png", width = 38, height = 31, columns = 16, frames = 108, durations = {50,50,50,50,50,50,150,50,700,100,100,100,100,50,50,50,50,50,50,50,50,150,50,700,200,100,100,50,50,50,50,50,50,150,50,700,100,100,100,100,50,50,50,50,50,50,50,50,150,50,700,200,100,100,100,100,100,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,100,100,100,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/diglett.png", width = 38, height = 32, columns = 16, frames = 108, durations = {50,50,50,50,50,50,150,50,700,100,100,100,100,50,50,50,50,50,50,50,50,150,50,600,200,100,200,50,50,50,50,50,50,150,50,700,100,100,100,100,50,50,50,50,50,50,50,50,150,50,600,200,100,200,100,100,100,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,100,100,100,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + DUGTRIO = { + front = { image = "assets/battle/front-animated/gen5/dugtrio.png", width = 52, height = 41, columns = 16, frames = 83, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/dugtrio.png", width = 52, height = 42, columns = 16, frames = 83, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + MEOWTH = { + front = { image = "assets/battle/front-animated/gen5/meowth.png", width = 48, height = 65, columns = 16, frames = 139, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/meowth.png", width = 58, height = 64, columns = 16, frames = 139, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + PERSIAN = { + front = { image = "assets/battle/front-animated/gen5/persian.png", width = 73, height = 62, columns = 16, frames = 107, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/persian.png", width = 78, height = 56, columns = 16, frames = 107, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + PSYDUCK = { + front = { image = "assets/battle/front-animated/gen5/psyduck.png", width = 38, height = 46, columns = 16, frames = 44, durations = {150,50,100,50,100,50,150,50,100,50,150,450,50,150,50,100,50,150,450,50,150,50,100,50,100,50,150,50,100,50,100,50,150,50,100,50,150,450,50,150,50,100,50,150} }, + back = { image = "assets/battle/back-animated/gen5/psyduck.png", width = 37, height = 44, columns = 16, frames = 46, durations = {50,50,100,50,150,50,50,50,50,50,50,50,100,100,50,450,50,150,50,150,100,50,400,50,50,50,50,50,50,50,100,50,50,50,50,50,350,50,150,50,150,100,50,450,50,200} }, + }, + GOLDUCK = { + front = { image = "assets/battle/front-animated/gen5/golduck.png", width = 60, height = 59, columns = 16, frames = 76, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/golduck.png", width = 55, height = 57, columns = 16, frames = 81, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + MANKEY = { + front = { image = "assets/battle/front-animated/gen5/mankey.png", width = 66, height = 61, columns = 16, frames = 120, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + back = { image = "assets/battle/back-animated/gen5/mankey.png", width = 62, height = 59, columns = 16, frames = 120, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + }, + PRIMEAPE = { + front = { image = "assets/battle/front-animated/gen5/primeape.png", width = 66, height = 60, columns = 16, frames = 56, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + back = { image = "assets/battle/back-animated/gen5/primeape.png", width = 72, height = 58, columns = 16, frames = 51, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,200} }, + }, + GROWLITHE = { + front = { image = "assets/battle/front-animated/gen5/growlithe.png", width = 55, height = 52, columns = 16, frames = 141, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/growlithe.png", width = 57, height = 57, columns = 16, frames = 135, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + ARCANINE = { + front = { image = "assets/battle/front-animated/gen5/arcanine.png", width = 77, height = 69, columns = 16, frames = 71, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/arcanine.png", width = 86, height = 69, columns = 16, frames = 71, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + POLIWAG = { + front = { image = "assets/battle/front-animated/gen5/poliwag.png", width = 54, height = 34, columns = 16, frames = 79, durations = {100,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + back = { image = "assets/battle/back-animated/gen5/poliwag.png", width = 55, height = 35, columns = 16, frames = 79, durations = {100,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + }, + POLIWHIRL = { + front = { image = "assets/battle/front-animated/gen5/poliwhirl.png", width = 74, height = 55, columns = 16, frames = 107, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/poliwhirl.png", width = 80, height = 57, columns = 16, frames = 107, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + POLIWRATH = { + front = { image = "assets/battle/front-animated/gen5/poliwrath.png", width = 81, height = 58, columns = 16, frames = 89, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/poliwrath.png", width = 74, height = 57, columns = 16, frames = 89, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + ABRA = { + front = { image = "assets/battle/front-animated/gen5/abra.png", width = 63, height = 53, columns = 16, frames = 113, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/abra.png", width = 71, height = 56, columns = 16, frames = 111, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + KADABRA = { + front = { image = "assets/battle/front-animated/gen5/kadabra.png", width = 79, height = 60, columns = 16, frames = 119, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/kadabra.png", width = 80, height = 66, columns = 16, frames = 121, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,50,50,50,50,50,50,100} }, + }, + ALAKAZAM = { + front = { image = "assets/battle/front-animated/gen5/alakazam.png", width = 93, height = 70, columns = 16, frames = 105, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,350,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/alakazam.png", width = 82, height = 67, columns = 16, frames = 105, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,350,50,50,50,50,50,50,50,50,50,100} }, + }, + MACHOP = { + front = { image = "assets/battle/front-animated/gen5/machop.png", width = 51, height = 50, columns = 16, frames = 149, durations = {50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/machop.png", width = 53, height = 51, columns = 16, frames = 149, durations = {50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + MACHOKE = { + front = { image = "assets/battle/front-animated/gen5/machoke.png", width = 60, height = 71, columns = 16, frames = 98, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,450,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,450,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,450,50,100,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/machoke.png", width = 61, height = 71, columns = 16, frames = 95, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,550,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,550,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,550,50,50,50,50,50,100} }, + }, + MACHAMP = { + front = { image = "assets/battle/front-animated/gen5/machamp.png", width = 64, height = 76, columns = 16, frames = 59, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,750,50,50,50,50,50,50,50,50,50,50,50,50,50,350,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/machamp.png", width = 67, height = 76, columns = 16, frames = 59, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,750,50,50,50,50,50,50,50,50,50,50,50,50,50,350,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + BELLSPROUT = { + front = { image = "assets/battle/front-animated/gen5/bellsprout.png", width = 41, height = 44, columns = 16, frames = 95, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,150,50,50,50,50,50,50,100,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + back = { image = "assets/battle/back-animated/gen5/bellsprout.png", width = 45, height = 51, columns = 16, frames = 95, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,150,50,50,50,50,50,50,100,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + }, + WEEPINBELL = { + front = { image = "assets/battle/front-animated/gen5/weepinbell.png", width = 69, height = 51, columns = 16, frames = 97, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/weepinbell.png", width = 72, height = 54, columns = 16, frames = 97, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + VICTREEBEL = { + front = { image = "assets/battle/front-animated/gen5/victreebel.png", width = 65, height = 71, columns = 16, frames = 71, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/victreebel.png", width = 69, height = 70, columns = 16, frames = 71, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + TENTACOOL = { + front = { image = "assets/battle/front-animated/gen5/tentacool.png", width = 60, height = 71, columns = 16, frames = 95, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/tentacool.png", width = 62, height = 76, columns = 16, frames = 95, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + TENTACRUEL = { + front = { image = "assets/battle/front-animated/gen5/tentacruel.png", width = 84, height = 84, columns = 16, frames = 80, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + back = { image = "assets/battle/back-animated/gen5/tentacruel.png", width = 84, height = 85, columns = 16, frames = 80, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + }, + GEODUDE = { + front = { image = "assets/battle/front-animated/gen5/geodude.png", width = 61, height = 45, columns = 16, frames = 103, durations = {50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/geodude.png", width = 58, height = 51, columns = 16, frames = 106, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + GRAVELER = { + front = { image = "assets/battle/front-animated/gen5/graveler.png", width = 71, height = 44, columns = 16, frames = 47, durations = {50,50,50,50,50,50,50,50,300,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,300,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,250,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/graveler.png", width = 65, height = 43, columns = 16, frames = 47, durations = {50,50,50,50,50,50,50,50,300,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,300,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,250,50,50,50,50,50,100} }, + }, + GOLEM = { + front = { image = "assets/battle/front-animated/gen5/golem.png", width = 72, height = 57, columns = 16, frames = 91, durations = {50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/golem.png", width = 69, height = 56, columns = 16, frames = 91, durations = {50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + PONYTA = { + front = { image = "assets/battle/front-animated/gen5/ponyta.png", width = 57, height = 57, columns = 16, frames = 91, durations = {100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/ponyta.png", width = 59, height = 59, columns = 16, frames = 91, durations = {100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + RAPIDASH = { + front = { image = "assets/battle/front-animated/gen5/rapidash.png", width = 74, height = 73, columns = 16, frames = 47, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/rapidash.png", width = 76, height = 76, columns = 16, frames = 48, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + }, + SLOWPOKE = { + front = { image = "assets/battle/front-animated/gen5/slowpoke.png", width = 64, height = 53, columns = 16, frames = 89, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/slowpoke.png", width = 68, height = 50, columns = 16, frames = 89, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + SLOWBRO = { + front = { image = "assets/battle/front-animated/gen5/slowbro.png", width = 63, height = 60, columns = 16, frames = 113, durations = {100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,250,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,100,50,200,50,100,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,100} }, + back = { image = "assets/battle/back-animated/gen5/slowbro.png", width = 76, height = 58, columns = 16, frames = 113, durations = {100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,250,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,100,50,200,50,100,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,100} }, + }, + MAGNEMITE = { + front = { image = "assets/battle/front-animated/gen5/magnemite.png", width = 38, height = 32, columns = 16, frames = 105, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/magnemite.png", width = 36, height = 31, columns = 16, frames = 105, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + MAGNETON = { + front = { image = "assets/battle/front-animated/gen5/magneton.png", width = 68, height = 59, columns = 16, frames = 143, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/magneton.png", width = 73, height = 58, columns = 16, frames = 143, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + FARFETCHD = { + front = { image = "assets/battle/front-animated/gen5/farfetchd.png", width = 45, height = 48, columns = 16, frames = 65, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,400,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,350} }, + back = { image = "assets/battle/back-animated/gen5/farfetchd.png", width = 46, height = 50, columns = 16, frames = 65, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,400,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,350} }, + }, + DODUO = { + front = { image = "assets/battle/front-animated/gen5/doduo.png", width = 60, height = 53, columns = 16, frames = 189, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + back = { image = "assets/battle/back-animated/gen5/doduo.png", width = 61, height = 56, columns = 16, frames = 189, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + }, + DODRIO = { + front = { image = "assets/battle/front-animated/gen5/dodrio.png", width = 70, height = 76, columns = 16, frames = 155, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/dodrio.png", width = 72, height = 76, columns = 16, frames = 155, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + SEEL = { + front = { image = "assets/battle/front-animated/gen5/seel.png", width = 62, height = 47, columns = 16, frames = 49, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/seel.png", width = 60, height = 43, columns = 16, frames = 49, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + DEWGONG = { + front = { image = "assets/battle/front-animated/gen5/dewgong.png", width = 75, height = 74, columns = 16, frames = 121, durations = {200,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,200,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,150,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,300,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/dewgong.png", width = 77, height = 88, columns = 16, frames = 121, durations = {200,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,200,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,150,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,300,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,100} }, + }, + GRIMER = { + front = { image = "assets/battle/front-animated/gen5/grimer.png", width = 73, height = 42, columns = 16, frames = 116, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/grimer.png", width = 70, height = 46, columns = 16, frames = 113, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + MUK = { + front = { image = "assets/battle/front-animated/gen5/muk.png", width = 94, height = 68, columns = 16, frames = 103, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/muk.png", width = 100, height = 81, columns = 16, frames = 103, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + SHELLDER = { + front = { image = "assets/battle/front-animated/gen5/shellder.png", width = 42, height = 38, columns = 16, frames = 87, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/shellder.png", width = 48, height = 37, columns = 16, frames = 87, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + CLOYSTER = { + front = { image = "assets/battle/front-animated/gen5/cloyster.png", width = 74, height = 65, columns = 16, frames = 101, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/cloyster.png", width = 69, height = 68, columns = 16, frames = 101, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + GASTLY = { + front = { image = "assets/battle/front-animated/gen5/gastly.png", width = 67, height = 75, columns = 16, frames = 110, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + back = { image = "assets/battle/back-animated/gen5/gastly.png", width = 63, height = 78, columns = 16, frames = 110, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + }, + HAUNTER = { + front = { image = "assets/battle/front-animated/gen5/haunter.png", width = 85, height = 70, columns = 16, frames = 25, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/haunter.png", width = 90, height = 71, columns = 16, frames = 25, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + GENGAR = { + front = { image = "assets/battle/front-animated/gen5/gengar.png", width = 79, height = 65, columns = 16, frames = 88, durations = {50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + back = { image = "assets/battle/back-animated/gen5/gengar.png", width = 73, height = 64, columns = 16, frames = 88, durations = {50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + }, + ONIX = { + front = { image = "assets/battle/front-animated/gen5/onix.png", width = 74, height = 74, columns = 16, frames = 99, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/onix.png", width = 98, height = 70, columns = 16, frames = 143, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + DROWZEE = { + front = { image = "assets/battle/front-animated/gen5/drowzee.png", width = 53, height = 58, columns = 16, frames = 153, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/drowzee.png", width = 56, height = 56, columns = 16, frames = 153, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + HYPNO = { + front = { image = "assets/battle/front-animated/gen5/hypno.png", width = 66, height = 62, columns = 16, frames = 125, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/hypno.png", width = 65, height = 60, columns = 16, frames = 123, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + KRABBY = { + front = { image = "assets/battle/front-animated/gen5/krabby.png", width = 55, height = 39, columns = 16, frames = 73, durations = {50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/krabby.png", width = 55, height = 43, columns = 16, frames = 73, durations = {50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + KINGLER = { + front = { image = "assets/battle/front-animated/gen5/kingler.png", width = 82, height = 59, columns = 16, frames = 97, durations = {50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/kingler.png", width = 83, height = 62, columns = 16, frames = 97, durations = {50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + VOLTORB = { + front = { image = "assets/battle/front-animated/gen5/voltorb.png", width = 46, height = 31, columns = 16, frames = 71, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,450,150,500} }, + back = { image = "assets/battle/back-animated/gen5/voltorb.png", width = 47, height = 31, columns = 16, frames = 70, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,450,650} }, + }, + ELECTRODE = { + front = { image = "assets/battle/front-animated/gen5/electrode.png", width = 49, height = 52, columns = 16, frames = 49, durations = {50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,100,100,50,50,50,50,50,50,500,400,100,100,100,50,50,50,50,50,50,100,700} }, + back = { image = "assets/battle/back-animated/gen5/electrode.png", width = 49, height = 52, columns = 16, frames = 45, durations = {50,100,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,250,50,50,50,50,50,550,100,100,100,400,50,50,50,50,50,800} }, + }, + EXEGGCUTE = { + front = { image = "assets/battle/front-animated/gen5/exeggcute.png", width = 55, height = 44, columns = 16, frames = 53, durations = {450,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,450,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,450,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/exeggcute.png", width = 54, height = 40, columns = 16, frames = 53, durations = {450,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,450,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,450,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + EXEGGUTOR = { + front = { image = "assets/battle/front-animated/gen5/exeggutor.png", width = 79, height = 73, columns = 16, frames = 89, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/exeggutor.png", width = 76, height = 70, columns = 16, frames = 81, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + CUBONE = { + front = { image = "assets/battle/front-animated/gen5/cubone.png", width = 49, height = 43, columns = 16, frames = 157, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/cubone.png", width = 52, height = 41, columns = 16, frames = 157, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + MAROWAK = { + front = { image = "assets/battle/front-animated/gen5/marowak.png", width = 68, height = 59, columns = 16, frames = 136, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/marowak.png", width = 65, height = 53, columns = 16, frames = 133, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + HITMONLEE = { + front = { image = "assets/battle/front-animated/gen5/hitmonlee.png", width = 68, height = 78, columns = 16, frames = 112, durations = {50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50} }, + back = { image = "assets/battle/back-animated/gen5/hitmonlee.png", width = 62, height = 75, columns = 16, frames = 108, durations = {150,50,50,50,50,50,50,100,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,100,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,150,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50} }, + }, + HITMONCHAN = { + front = { image = "assets/battle/front-animated/gen5/hitmonchan.png", width = 34, height = 59, columns = 16, frames = 81, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/hitmonchan.png", width = 46, height = 56, columns = 16, frames = 81, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + LICKITUNG = { + front = { image = "assets/battle/front-animated/gen5/lickitung.png", width = 69, height = 52, columns = 16, frames = 70, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + back = { image = "assets/battle/back-animated/gen5/lickitung.png", width = 77, height = 53, columns = 16, frames = 72, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + }, + KOFFING = { + front = { image = "assets/battle/front-animated/gen5/koffing.png", width = 91, height = 59, columns = 16, frames = 87, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/koffing.png", width = 79, height = 64, columns = 16, frames = 87, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + WEEZING = { + front = { image = "assets/battle/front-animated/gen5/weezing.png", width = 86, height = 67, columns = 16, frames = 99, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/weezing.png", width = 87, height = 68, columns = 16, frames = 99, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + RHYHORN = { + front = { image = "assets/battle/front-animated/gen5/rhyhorn.png", width = 68, height = 54, columns = 16, frames = 157, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/rhyhorn.png", width = 73, height = 54, columns = 16, frames = 167, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + RHYDON = { + front = { image = "assets/battle/front-animated/gen5/rhydon.png", width = 78, height = 67, columns = 16, frames = 193, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/rhydon.png", width = 85, height = 68, columns = 16, frames = 193, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + CHANSEY = { + front = { image = "assets/battle/front-animated/gen5/chansey.png", width = 61, height = 47, columns = 16, frames = 116, durations = {50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + back = { image = "assets/battle/back-animated/gen5/chansey.png", width = 67, height = 47, columns = 16, frames = 116, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + }, + TANGELA = { + front = { image = "assets/battle/front-animated/gen5/tangela.png", width = 49, height = 42, columns = 16, frames = 63, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/tangela.png", width = 49, height = 42, columns = 16, frames = 63, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + KANGASKHAN = { + front = { image = "assets/battle/front-animated/gen5/kangaskhan.png", width = 79, height = 61, columns = 16, frames = 253, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/kangaskhan.png", width = 73, height = 66, columns = 16, frames = 253, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + HORSEA = { + front = { image = "assets/battle/front-animated/gen5/horsea.png", width = 34, height = 43, columns = 16, frames = 80, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + back = { image = "assets/battle/back-animated/gen5/horsea.png", width = 39, height = 40, columns = 16, frames = 80, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + }, + SEADRA = { + front = { image = "assets/battle/front-animated/gen5/seadra.png", width = 58, height = 56, columns = 16, frames = 64, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + back = { image = "assets/battle/back-animated/gen5/seadra.png", width = 64, height = 59, columns = 16, frames = 64, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + }, + GOLDEEN = { + front = { image = "assets/battle/front-animated/gen5/goldeen.png", width = 75, height = 57, columns = 16, frames = 114, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + back = { image = "assets/battle/back-animated/gen5/goldeen.png", width = 75, height = 58, columns = 16, frames = 114, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + }, + SEAKING = { + front = { image = "assets/battle/front-animated/gen5/seaking.png", width = 74, height = 66, columns = 16, frames = 96, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + back = { image = "assets/battle/back-animated/gen5/seaking.png", width = 96, height = 72, columns = 16, frames = 96, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + }, + STARYU = { + front = { image = "assets/battle/front-animated/gen5/staryu.png", width = 57, height = 49, columns = 16, frames = 79, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/staryu.png", width = 55, height = 52, columns = 16, frames = 79, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + STARMIE = { + front = { image = "assets/battle/front-animated/gen5/starmie.png", width = 65, height = 59, columns = 16, frames = 159, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/starmie.png", width = 56, height = 56, columns = 16, frames = 159, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + MR_MIME = { + front = { image = "assets/battle/front-animated/gen5/mr-mime.png", width = 57, height = 53, columns = 16, frames = 127, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/mr-mime.png", width = 55, height = 51, columns = 16, frames = 127, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + SCYTHER = { + front = { image = "assets/battle/front-animated/gen5/scyther.png", width = 55, height = 61, columns = 16, frames = 137, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/scyther.png", width = 69, height = 65, columns = 16, frames = 137, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + JYNX = { + front = { image = "assets/battle/front-animated/gen5/jynx.png", width = 63, height = 60, columns = 16, frames = 107, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/jynx.png", width = 62, height = 60, columns = 16, frames = 107, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + ELECTABUZZ = { + front = { image = "assets/battle/front-animated/gen5/electabuzz.png", width = 71, height = 65, columns = 16, frames = 95, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/electabuzz.png", width = 71, height = 63, columns = 16, frames = 95, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + MAGMAR = { + front = { image = "assets/battle/front-animated/gen5/magmar.png", width = 73, height = 61, columns = 16, frames = 65, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/magmar.png", width = 70, height = 59, columns = 16, frames = 65, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + PINSIR = { + front = { image = "assets/battle/front-animated/gen5/pinsir.png", width = 72, height = 62, columns = 16, frames = 143, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/pinsir.png", width = 70, height = 61, columns = 16, frames = 143, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + TAUROS = { + front = { image = "assets/battle/front-animated/gen5/tauros.png", width = 76, height = 59, columns = 16, frames = 79, durations = {100,50,100,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,100,50,100,50,50,50,100,50,100,50,100} }, + back = { image = "assets/battle/back-animated/gen5/tauros.png", width = 72, height = 54, columns = 16, frames = 81, durations = {100,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,100,50,100,50,50,50,50,50,50,50,50,50,50,50,100,50,100,50,50,50,100,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,100,50,100,50,50,50,50,50,50,50,50,50,50,50,100,50,100,50,100} }, + }, + MAGIKARP = { + front = { image = "assets/battle/front-animated/gen5/magikarp.png", width = 47, height = 63, columns = 16, frames = 120, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + back = { image = "assets/battle/back-animated/gen5/magikarp.png", width = 66, height = 58, columns = 16, frames = 117, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + }, + GYARADOS = { + front = { image = "assets/battle/front-animated/gen5/gyarados.png", width = 102, height = 84, columns = 16, frames = 87, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/gyarados.png", width = 108, height = 82, columns = 16, frames = 87, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + LAPRAS = { + front = { image = "assets/battle/front-animated/gen5/lapras.png", width = 68, height = 71, columns = 16, frames = 65, durations = {100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,200,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,200,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,200,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/lapras.png", width = 76, height = 71, columns = 16, frames = 65, durations = {100,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,200,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,200,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,200,50,50,50,50,50,100,50,50,50,50,50,100,50,50,50,100} }, + }, + DITTO = { + front = { image = "assets/battle/front-animated/gen5/ditto.png", width = 47, height = 32, columns = 16, frames = 80, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + back = { image = "assets/battle/back-animated/gen5/ditto.png", width = 47, height = 32, columns = 16, frames = 80, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + }, + EEVEE = { + front = { image = "assets/battle/front-animated/gen5/eevee.png", width = 49, height = 47, columns = 16, frames = 125, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/eevee.png", width = 49, height = 50, columns = 16, frames = 133, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + VAPOREON = { + front = { image = "assets/battle/front-animated/gen5/vaporeon.png", width = 50, height = 59, columns = 16, frames = 101, durations = {100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,250,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,250,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,250,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,150} }, + back = { image = "assets/battle/back-animated/gen5/vaporeon.png", width = 50, height = 56, columns = 16, frames = 107, durations = {100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,100,50,150} }, + }, + JOLTEON = { + front = { image = "assets/battle/front-animated/gen5/jolteon.png", width = 48, height = 49, columns = 16, frames = 99, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/jolteon.png", width = 49, height = 48, columns = 16, frames = 99, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + FLAREON = { + front = { image = "assets/battle/front-animated/gen5/flareon.png", width = 66, height = 60, columns = 16, frames = 119, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/flareon.png", width = 70, height = 59, columns = 16, frames = 119, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + PORYGON = { + front = { image = "assets/battle/front-animated/gen5/porygon.png", width = 56, height = 52, columns = 16, frames = 60, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + back = { image = "assets/battle/back-animated/gen5/porygon.png", width = 62, height = 47, columns = 16, frames = 53, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,150,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,300,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + }, + OMANYTE = { + front = { image = "assets/battle/front-animated/gen5/omanyte.png", width = 43, height = 45, columns = 16, frames = 68, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + back = { image = "assets/battle/back-animated/gen5/omanyte.png", width = 48, height = 48, columns = 16, frames = 68, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + }, + OMASTAR = { + front = { image = "assets/battle/front-animated/gen5/omastar.png", width = 63, height = 60, columns = 16, frames = 155, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/omastar.png", width = 65, height = 63, columns = 16, frames = 155, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + KABUTO = { + front = { image = "assets/battle/front-animated/gen5/kabuto.png", width = 39, height = 41, columns = 16, frames = 67, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/kabuto.png", width = 38, height = 42, columns = 16, frames = 67, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + KABUTOPS = { + front = { image = "assets/battle/front-animated/gen5/kabutops.png", width = 65, height = 61, columns = 16, frames = 47, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + back = { image = "assets/battle/back-animated/gen5/kabutops.png", width = 67, height = 55, columns = 16, frames = 47, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + }, + AERODACTYL = { + front = { image = "assets/battle/front-animated/gen5/aerodactyl.png", width = 93, height = 81, columns = 16, frames = 17, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + back = { image = "assets/battle/back-animated/gen5/aerodactyl.png", width = 91, height = 91, columns = 16, frames = 17, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50} }, + }, + SNORLAX = { + front = { image = "assets/battle/front-animated/gen5/snorlax.png", width = 75, height = 74, columns = 16, frames = 173, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/snorlax.png", width = 75, height = 71, columns = 16, frames = 173, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + ARTICUNO = { + front = { image = "assets/battle/front-animated/gen5/articuno.png", width = 72, height = 81, columns = 16, frames = 51, durations = {150,50,50,50,50,50,50,50,50,50,50,50,50,50,450,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,950,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/articuno.png", width = 78, height = 87, columns = 16, frames = 51, durations = {150,50,50,50,50,50,50,50,50,50,50,50,50,50,450,50,100,50,50,50,50,50,50,50,50,50,50,50,50,50,950,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + ZAPDOS = { + front = { image = "assets/battle/front-animated/gen5/zapdos.png", width = 97, height = 71, columns = 16, frames = 17, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/zapdos.png", width = 85, height = 73, columns = 16, frames = 17, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + MOLTRES = { + front = { image = "assets/battle/front-animated/gen5/moltres.png", width = 129, height = 98, columns = 15, frames = 53, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/moltres.png", width = 117, height = 79, columns = 16, frames = 53, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + DRATINI = { + front = { image = "assets/battle/front-animated/gen5/dratini.png", width = 44, height = 51, columns = 16, frames = 49, durations = {150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,250,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/dratini.png", width = 47, height = 57, columns = 16, frames = 49, durations = {150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,250,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,100} }, + }, + DRAGONAIR = { + front = { image = "assets/battle/front-animated/gen5/dragonair.png", width = 65, height = 74, columns = 16, frames = 127, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/dragonair.png", width = 64, height = 74, columns = 16, frames = 127, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + DRAGONITE = { + front = { image = "assets/battle/front-animated/gen5/dragonite.png", width = 80, height = 82, columns = 16, frames = 102, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,600,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,400} }, + back = { image = "assets/battle/back-animated/gen5/dragonite.png", width = 86, height = 81, columns = 16, frames = 102, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,600,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,400} }, + }, + MEWTWO = { + front = { image = "assets/battle/front-animated/gen5/mewtwo.png", width = 90, height = 69, columns = 16, frames = 125, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/mewtwo.png", width = 95, height = 70, columns = 16, frames = 125, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,150,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, + MEW = { + front = { image = "assets/battle/front-animated/gen5/mew.png", width = 78, height = 54, columns = 16, frames = 119, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + back = { image = "assets/battle/back-animated/gen5/mew.png", width = 60, height = 60, columns = 16, frames = 119, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,100} }, + }, +} diff --git a/lib/AnimatedBattleArt.lua b/lib/AnimatedBattleArt.lua index ed08d6d..7d31139 100644 --- a/lib/AnimatedBattleArt.lua +++ b/lib/AnimatedBattleArt.lua @@ -1,14 +1,17 @@ -- Runtime playback for authoring-time GIF conversions. -- -- LÖVE does not decode animated GIFs. The importer flattens each GIF into a --- PNG atlas and data/animated_battle_sprites.lua records the logical cell and +-- PNG atlas and one generated data table per set records the logical cell and -- timing data. This module extracts exact ImageData rectangles: no Canvas DPI -- participates, so one source pixel remains one logical battle-art pixel. local V = ... local BattleArt = V.require("BattleArt") -local DEFINITIONS = V.data("animated_battle_sprites") -local CRYSTAL = V.data("animated_battle_sprites_crystal") +local SETS = { + gen2 = V.data("animated_battle_sprites_gen2"), + gen3 = V.data("animated_battle_sprites_gen3"), + gen5 = V.data("animated_battle_sprites_gen5"), +} local AnimatedBattleArt = {} local loaded, loadOrder = {}, {} @@ -87,10 +90,8 @@ end local function definition(battler, side) local species = battler and battler.mon and battler.mon.species local key = species and tostring(species):upper() - local crystal = key and CRYSTAL[key] - local preferred = crystal and crystal[side] - if preferred and atlasPath(preferred) then return preferred end - local bySide = key and DEFINITIONS[key] + local selected = SETS[BattleArt.animationSetting:get()] + local bySide = selected and key and selected[key] return bySide and bySide[side] or nil end diff --git a/lib/BattleArt.lua b/lib/BattleArt.lua index 02e6456..01e411a 100644 --- a/lib/BattleArt.lua +++ b/lib/BattleArt.lua @@ -7,6 +7,8 @@ local BattleArt = {} BattleArt.setting = ModSetting.new("battleArt", "BATTLE ART", { "static", "animated", "rom" }, { "STATIC", "ANIMATED", "ROM" }) +BattleArt.animationSetting = ModSetting.new("animatedSet", "ANIM SET", + { "gen2", "gen3", "gen5" }, { "GEN 2", "GEN 3", "GEN 5" }) BattleArt.viewSetting = ModSetting.new("playerView", "PLAYER VIEW", { "front", "back" }, { "FRONT", "BACK" }) diff --git a/main.lua b/main.lua index 84dae27..2195500 100644 --- a/main.lua +++ b/main.lua @@ -342,6 +342,10 @@ local SETTINGS = { "Use optional PNGs from assets/battle in fights. Missing art falls " .. "back to the ROM. STATIC is the zero-configuration default.", when = function() return stagedBattles() end, full = true }, + { BattleArt.animationSetting, + "Choose the generation folder used by BATTLE ART: ANIMATED. Missing " + .. "art falls directly back to ROM rather than mixing generations.", + when = function() return stagedBattles() end, full = true }, { BattleArt.viewSetting, "Show the player's Pokemon from the front or back. Both choices stay " .. "world-placed, lit, shadowed and depth-occluded.", diff --git a/tools/import_animated_back_sprite.py b/tools/import_animated_back_sprite.py deleted file mode 100644 index 7af2ec5..0000000 --- a/tools/import_animated_back_sprite.py +++ /dev/null @@ -1,263 +0,0 @@ -"""Download and convert the original 151 Black/White battle animations. - -The resulting PNG sheets are runtime assets; GIF decoding and network access -remain authoring-only. Both normal front and back-normal collections are -imported, and a Lua metadata table is generated alongside them. -""" - -from __future__ import annotations - -import argparse -import math -import time -import urllib.request -from io import BytesIO -from pathlib import Path - -from PIL import Image, ImageSequence - - -KANTO = """ -BULBASAUR bulbasaur -IVYSAUR ivysaur -VENUSAUR venusaur -CHARMANDER charmander -CHARMELEON charmeleon -CHARIZARD charizard -SQUIRTLE squirtle -WARTORTLE wartortle -BLASTOISE blastoise -CATERPIE caterpie -METAPOD metapod -BUTTERFREE butterfree -WEEDLE weedle -KAKUNA kakuna -BEEDRILL beedrill -PIDGEY pidgey -PIDGEOTTO pidgeotto -PIDGEOT pidgeot -RATTATA rattata -RATICATE raticate -SPEAROW spearow -FEAROW fearow -EKANS ekans -ARBOK arbok -PIKACHU pikachu -RAICHU raichu -SANDSHREW sandshrew -SANDSLASH sandslash -NIDORAN_F nidoran-f -NIDORINA nidorina -NIDOQUEEN nidoqueen -NIDORAN_M nidoran-m -NIDORINO nidorino -NIDOKING nidoking -CLEFAIRY clefairy -CLEFABLE clefable -VULPIX vulpix -NINETALES ninetales -JIGGLYPUFF jigglypuff -WIGGLYTUFF wigglytuff -ZUBAT zubat -GOLBAT golbat -ODDISH oddish -GLOOM gloom -VILEPLUME vileplume -PARAS paras -PARASECT parasect -VENONAT venonat -VENOMOTH venomoth -DIGLETT diglett -DUGTRIO dugtrio -MEOWTH meowth -PERSIAN persian -PSYDUCK psyduck -GOLDUCK golduck -MANKEY mankey -PRIMEAPE primeape -GROWLITHE growlithe -ARCANINE arcanine -POLIWAG poliwag -POLIWHIRL poliwhirl -POLIWRATH poliwrath -ABRA abra -KADABRA kadabra -ALAKAZAM alakazam -MACHOP machop -MACHOKE machoke -MACHAMP machamp -BELLSPROUT bellsprout -WEEPINBELL weepinbell -VICTREEBEL victreebel -TENTACOOL tentacool -TENTACRUEL tentacruel -GEODUDE geodude -GRAVELER graveler -GOLEM golem -PONYTA ponyta -RAPIDASH rapidash -SLOWPOKE slowpoke -SLOWBRO slowbro -MAGNEMITE magnemite -MAGNETON magneton -FARFETCHD farfetchd -DODUO doduo -DODRIO dodrio -SEEL seel -DEWGONG dewgong -GRIMER grimer -MUK muk -SHELLDER shellder -CLOYSTER cloyster -GASTLY gastly -HAUNTER haunter -GENGAR gengar -ONIX onix -DROWZEE drowzee -HYPNO hypno -KRABBY krabby -KINGLER kingler -VOLTORB voltorb -ELECTRODE electrode -EXEGGCUTE exeggcute -EXEGGUTOR exeggutor -CUBONE cubone -MAROWAK marowak -HITMONLEE hitmonlee -HITMONCHAN hitmonchan -LICKITUNG lickitung -KOFFING koffing -WEEZING weezing -RHYHORN rhyhorn -RHYDON rhydon -CHANSEY chansey -TANGELA tangela -KANGASKHAN kangaskhan -HORSEA horsea -SEADRA seadra -GOLDEEN goldeen -SEAKING seaking -STARYU staryu -STARMIE starmie -MR_MIME mr-mime -SCYTHER scyther -JYNX jynx -ELECTABUZZ electabuzz -MAGMAR magmar -PINSIR pinsir -TAUROS tauros -MAGIKARP magikarp -GYARADOS gyarados -LAPRAS lapras -DITTO ditto -EEVEE eevee -VAPOREON vaporeon -JOLTEON jolteon -FLAREON flareon -PORYGON porygon -OMANYTE omanyte -OMASTAR omastar -KABUTO kabuto -KABUTOPS kabutops -AERODACTYL aerodactyl -SNORLAX snorlax -ARTICUNO articuno -ZAPDOS zapdos -MOLTRES moltres -DRATINI dratini -DRAGONAIR dragonair -DRAGONITE dragonite -MEWTWO mewtwo -MEW mew -""" - -SPECIES = [tuple(line.split()) for line in KANTO.strip().splitlines()] -BASE = "https://img.pokemondb.net/sprites/black-white/anim" - - -def download(url: str, retries: int = 4) -> bytes: - request = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"}) - for attempt in range(retries): - try: - with urllib.request.urlopen(request, timeout=30) as response: - return response.read() - except Exception: - if attempt + 1 == retries: - raise - time.sleep(1.5 * (attempt + 1)) - raise AssertionError("unreachable") - - -def convert(raw: bytes, destination: Path, columns: int = 16, - max_dimension: int = 2048) -> dict: - with Image.open(BytesIO(raw)) as gif: - frames, durations = [], [] - for frame in ImageSequence.Iterator(gif): - durations.append(int(frame.info.get("duration", gif.info.get("duration", 100)))) - frames.append(frame.convert("RGBA")) - if not frames: - raise ValueError("GIF contains no frames") - width, height = frames[0].size - if any(frame.size != (width, height) for frame in frames): - raise ValueError("GIF frames do not share one logical canvas") - max_columns = max_dimension // width - if max_columns < 1: - raise ValueError(f"frame width {width} exceeds {max_dimension}") - columns = max(1, min(columns, max_columns, len(frames))) - while math.ceil(len(frames) / columns) * height > max_dimension: - columns += 1 - if columns > max_columns or columns > len(frames): - raise ValueError( - f"{len(frames)} {width}x{height} frames do not fit " - f"within a {max_dimension}px sheet" - ) - rows = math.ceil(len(frames) / columns) - sheet = Image.new("RGBA", (columns * width, rows * height)) - for index, frame in enumerate(frames): - sheet.alpha_composite(frame, ((index % columns) * width, - (index // columns) * height)) - destination.parent.mkdir(parents=True, exist_ok=True) - sheet.save(destination, optimize=True) - return {"width": width, "height": height, "columns": columns, - "frames": len(frames), "durations": durations} - - -def lua_definition(path: str, info: dict) -> str: - durations = ",".join(map(str, info["durations"])) - return (f'{{ image = "{path}", width = {info["width"]}, ' - f'height = {info["height"]}, columns = {info["columns"]}, ' - f'frames = {info["frames"]}, durations = {{{durations}}} }}') - - -def main() -> None: - parser = argparse.ArgumentParser() - parser.add_argument("--root", type=Path, default=Path.cwd()) - args = parser.parse_args() - root = args.root.resolve() - records = {} - for number, (species, slug) in enumerate(SPECIES, 1): - records[species] = {} - for side, remote in (("front", "normal"), ("back", "back-normal")): - url = f"{BASE}/{remote}/{slug}.gif" - relative = f"assets/battle/{side}-animated/{slug}.png" - print(f"[{number:03d}/151] {side:5s} {slug}", flush=True) - records[species][side] = convert(download(url), root / relative) - records[species][side]["path"] = relative - - lines = ["-- Generated by tools/import_animated_back_sprite.py.", - "-- Black/White animated front and back sprites, original 151.", - "return {"] - for species, _ in SPECIES: - lines.append(f" {species} = {{") - for side in ("front", "back"): - info = records[species][side] - lines.append(f" {side} = {lua_definition(info['path'], info)},") - lines.append(" },") - lines.append("}") - destination = root / "data" / "animated_battle_sprites.lua" - destination.write_text("\n".join(lines) + "\n", encoding="utf-8") - print(f"wrote {destination} with {len(SPECIES) * 2} animations") - - -if __name__ == "__main__": - main() diff --git a/tools/import_animated_sprites.py b/tools/import_animated_sprites.py new file mode 100644 index 0000000..8bc3cc6 --- /dev/null +++ b/tools/import_animated_sprites.py @@ -0,0 +1,158 @@ +"""Build one bring-your-own animated battle-art collection. + +Generated PNG atlases live in ignored generation folders. Generated Lua +contains only the atlas geometry and frame timing needed by the runtime. + +Default sources: + gen2 https://bluemoonfalls.com/images/sprites/crystal/normal/ + gen3 https://pkmn.net/sprites/emerald/ + gen5 https://img.pokemondb.net/sprites/black-white/anim/ +""" + +from __future__ import annotations + +import argparse +import math +import re +import time +import urllib.request +from io import BytesIO +from pathlib import Path + +from PIL import Image, ImageSequence + + +COLLECTIONS = { + "gen2": { + "base": "https://bluemoonfalls.com/images/sprites/crystal/normal", + "title": "Pokemon Crystal animated fronts, National Dex #001-#151.", + "numbered": True, + "sides": (("front", None),), + }, + "gen3": { + "base": "https://pkmn.net/sprites/emerald", + "title": "Pokemon Emerald animated fronts, National Dex #001-#151.", + "numbered": True, + "sides": (("front", None),), + }, + "gen5": { + "base": "https://img.pokemondb.net/sprites/black-white/anim", + "title": "Pokemon Black/White animated fronts and backs, #001-#151.", + "numbered": False, + "sides": (("front", "normal"), ("back", "back-normal")), + }, +} +ENTRY = re.compile( + r'^ ([A-Z0-9_]+) = \{\n' + r' front = \{ image = "assets/battle/front-animated/gen5/([^/]+)\.png"', + re.MULTILINE, +) + + +def species(root: Path) -> list[tuple[str, str]]: + source = (root / "data" / "animated_battle_sprites_gen5.lua").read_text( + encoding="utf-8" + ) + entries = ENTRY.findall(source) + if len(entries) != 151: + raise ValueError(f"expected 151 Kanto species definitions, got {len(entries)}") + return entries + + +def download(url: str, retries: int = 4) -> bytes: + request = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"}) + for attempt in range(retries): + try: + with urllib.request.urlopen(request, timeout=30) as response: + return response.read() + except Exception: + if attempt + 1 == retries: + raise + time.sleep(1.5 * (attempt + 1)) + raise AssertionError("unreachable") + + +def convert(raw: bytes, destination: Path, columns: int = 16, + max_dimension: int = 2048) -> dict: + with Image.open(BytesIO(raw)) as gif: + frames, durations = [], [] + for frame in ImageSequence.Iterator(gif): + durations.append( + int(frame.info.get("duration", gif.info.get("duration", 100))) + ) + frames.append(frame.convert("RGBA")) + if not frames: + raise ValueError("GIF contains no frames") + width, height = frames[0].size + if any(frame.size != (width, height) for frame in frames): + raise ValueError("GIF frames do not share one logical canvas") + max_columns = max_dimension // width + if max_columns < 1: + raise ValueError(f"frame width {width} exceeds {max_dimension}") + columns = max(1, min(columns, max_columns, len(frames))) + while math.ceil(len(frames) / columns) * height > max_dimension: + columns += 1 + if columns > max_columns or columns > len(frames): + raise ValueError("animation does not fit within one 2048px atlas") + rows = math.ceil(len(frames) / columns) + sheet = Image.new("RGBA", (columns * width, rows * height)) + for index, frame in enumerate(frames): + sheet.alpha_composite( + frame, ((index % columns) * width, (index // columns) * height) + ) + destination.parent.mkdir(parents=True, exist_ok=True) + sheet.save(destination, optimize=True) + return { + "width": width, "height": height, "columns": columns, + "frames": len(frames), "durations": durations, + } + + +def lua_definition(path: str, info: dict) -> str: + durations = ",".join(map(str, info["durations"])) + return ( + f'{{ image = "{path}", width = {info["width"]}, ' + f'height = {info["height"]}, columns = {info["columns"]}, ' + f'frames = {info["frames"]}, durations = {{{durations}}} }}' + ) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--set", required=True, choices=COLLECTIONS) + parser.add_argument("--root", type=Path, default=Path.cwd()) + parser.add_argument("--base-url") + args = parser.parse_args() + root = args.root.resolve() + config = COLLECTIONS[args.set] + base = (args.base_url or config["base"]).rstrip("/") + records = [] + for number, (engine_name, slug) in enumerate(species(root), 1): + sides = {} + for side, remote in config["sides"]: + url = (f"{base}/{number}.gif" if config["numbered"] + else f"{base}/{remote}/{slug}.gif") + relative = f"assets/battle/{side}-animated/{args.set}/{slug}.png" + print(f"[{number:03d}/151] {side:5s} {slug}", flush=True) + sides[side] = (relative, convert(download(url), root / relative)) + records.append((engine_name, sides)) + + lines = [ + f"-- Generated by tools/import_animated_sprites.py --set {args.set}.", + f"-- {config['title']}", + "return {", + ] + for engine_name, sides in records: + lines.append(f" {engine_name} = {{") + for side, _ in config["sides"]: + path, info = sides[side] + lines.append(f" {side} = {lua_definition(path, info)},") + lines.append(" },") + lines.append("}") + destination = root / "data" / f"animated_battle_sprites_{args.set}.lua" + destination.write_text("\n".join(lines) + "\n", encoding="utf-8") + print(f"wrote {destination} with {len(records)} species") + + +if __name__ == "__main__": + main() diff --git a/tools/import_crystal_front_sprites.py b/tools/import_crystal_front_sprites.py deleted file mode 100644 index c3306c5..0000000 --- a/tools/import_crystal_front_sprites.py +++ /dev/null @@ -1,123 +0,0 @@ -"""Download Crystal #001-#151 GIFs and build native-resolution PNG atlases. - -Artwork is written below assets/battle/front-animated/crystal, which Git -ignores. Only the generated cell/timing metadata belongs in source control. - -Default source: https://bluemoonfalls.com/images/sprites/crystal/normal/ -""" - -from __future__ import annotations - -import argparse -import math -import re -import time -import urllib.request -from io import BytesIO -from pathlib import Path - -from PIL import Image, ImageSequence - - -DEFAULT_BASE = "https://bluemoonfalls.com/images/sprites/crystal/normal" -ENTRY = re.compile( - r'^ ([A-Z0-9_]+) = \{\n' - r' front = \{ image = "assets/battle/front-animated/([^/]+)\.png"', - re.MULTILINE, -) - - -def species(root: Path) -> list[tuple[str, str]]: - source = (root / "data" / "animated_battle_sprites.lua").read_text( - encoding="utf-8" - ) - entries = ENTRY.findall(source) - if len(entries) != 151: - raise ValueError(f"expected 151 Kanto species definitions, got {len(entries)}") - return entries - - -def download(url: str, retries: int = 4) -> bytes: - request = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"}) - for attempt in range(retries): - try: - with urllib.request.urlopen(request, timeout=30) as response: - return response.read() - except Exception: - if attempt + 1 == retries: - raise - time.sleep(1.5 * (attempt + 1)) - raise AssertionError("unreachable") - - -def convert(raw: bytes, destination: Path, max_columns: int = 16) -> dict: - with Image.open(BytesIO(raw)) as gif: - frames, durations = [], [] - for frame in ImageSequence.Iterator(gif): - durations.append( - int(frame.info.get("duration", gif.info.get("duration", 100))) - ) - frames.append(frame.convert("RGBA")) - if not frames: - raise ValueError("GIF contains no frames") - width, height = frames[0].size - if any(frame.size != (width, height) for frame in frames): - raise ValueError("GIF frames do not share one logical canvas") - columns = min(max_columns, len(frames)) - rows = math.ceil(len(frames) / columns) - sheet = Image.new("RGBA", (columns * width, rows * height)) - for index, frame in enumerate(frames): - sheet.alpha_composite( - frame, ((index % columns) * width, (index // columns) * height) - ) - destination.parent.mkdir(parents=True, exist_ok=True) - sheet.save(destination, optimize=True) - return { - "width": width, - "height": height, - "columns": columns, - "frames": len(frames), - "durations": durations, - } - - -def lua_definition(path: str, info: dict) -> str: - durations = ",".join(map(str, info["durations"])) - return ( - f'{{ image = "{path}", width = {info["width"]}, ' - f'height = {info["height"]}, columns = {info["columns"]}, ' - f'frames = {info["frames"]}, durations = {{{durations}}} }}' - ) - - -def main() -> None: - parser = argparse.ArgumentParser() - parser.add_argument("--root", type=Path, default=Path.cwd()) - parser.add_argument("--base-url", default=DEFAULT_BASE) - args = parser.parse_args() - root = args.root.resolve() - records = [] - for number, (engine_name, slug) in enumerate(species(root), 1): - url = f"{args.base_url.rstrip('/')}/{number}.gif" - relative = f"assets/battle/front-animated/crystal/{slug}.png" - print(f"[{number:03d}/151] {slug}", flush=True) - info = convert(download(url), root / relative) - records.append((engine_name, relative, info)) - - lines = [ - "-- Generated by tools/import_crystal_front_sprites.py.", - "-- Pokemon Crystal animated fronts, National Dex #001-#151.", - "return {", - ] - for engine_name, path, info in records: - lines.append(f" {engine_name} = {{") - lines.append(f" front = {lua_definition(path, info)},") - lines.append(" },") - lines.append("}") - destination = root / "data" / "animated_battle_sprites_crystal.lua" - destination.write_text("\n".join(lines) + "\n", encoding="utf-8") - print(f"wrote {destination} with {len(records)} animations") - - -if __name__ == "__main__": - main() From b546df7c1603bc7c192f52f085946e926663d520 Mon Sep 17 00:00:00 2001 From: absol89 Date: Sat, 1 Aug 2026 21:56:13 +0200 Subject: [PATCH 014/116] Split animated front and back generation settings --- CHANGELOG.md | 9 +++++---- README.md | 15 +++++++++------ assets/battle/back-animated/README.md | 3 ++- assets/battle/front-animated/README.md | 6 +++--- lib/AnimatedBattleArt.lua | 4 +++- lib/BattleArt.lua | 6 +++++- lib/ModSetting.lua | 12 +++++++----- main.lua | 24 ++++++++++++++++++------ 8 files changed, 52 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 12cb1bc..e7f6d44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,10 +21,11 @@ scaling, and sends every frame through the same transparency, display filtering, world lighting, depth and shadow path as static art. Missing or malformed atlases fall back per Pokemon to ROM art. -- **Generation-scoped animation sets.** `ANIM SET` selects GEN 2, GEN 3, or - GEN 5 from matching generation subfolders. Missing art falls directly back - to ROM instead of mixing visual styles. One authoring importer builds all - three formats; only generated dimensions/timings are committed, never art. +- **Independent animated front/back sets.** `FRONT GEN` and `BACK GEN` each + select GEN 2, GEN 3, or GEN 5 from matching generation subfolders, and are + shown in-game only under ANIMATED; STATIC and ROM ignore them. Missing art + falls directly back to ROM instead of mixing visual styles. One importer builds all three + formats; only generated dimensions/timings are committed, never art. - **Crystal and Emerald front collections.** The local imports cover #001-#151 while preserving every source frame's native canvas and delay. Gen 5 keeps its existing animated fronts and backs in the same folder convention. diff --git a/README.md b/README.md index e2875af..e31b7a9 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,8 @@ menu. | `7`, or the **V-CURVE** options row | OFF → 1 → 2 → 3 — bend the world over the horizon | | `8`, or the **3D-BTL** options row | ON / OFF — fight on the map instead of on a white field | | the **BATTLE ART** options row | STATIC / ANIMATED / ROM — use optional battle-only art, with a direct ROM fallback when a file or atlas is absent | -| the **ANIM SET** options row | GEN 2 / GEN 3 / GEN 5 — choose one animated-art generation without mixing sets | +| the **FRONT GEN** options row | GEN 2 / GEN 3 / GEN 5 — choose the animated front collection | +| the **BACK GEN** options row | GEN 2 / GEN 3 / GEN 5 — independently choose the animated back collection | | the **PLAYER VIEW** options row | FRONT / BACK — choose the player's view while keeping it world-placed, lit, shadowed and depth-occluded | | the **DAYTIME** options row | SYNC / DAY / NIGHT / DUSK / DAWN / CYCLE — what time it is outdoors, on the diorama *and* on the flat 2D world; held at SYNC (and off the menu) while VOXEL is FULL | @@ -62,11 +63,13 @@ art. Missing or unreadable files fall straight back to the ROM art. The four battle-art directories are intentionally ignored by Git except for their README contracts. `tools/package_mod.ps1` nevertheless includes local PNGs from them in a test ZIP, so artwork can stay private and uncommitted. -`BATTLE ART: ANIMATED` reads the generation selected by `ANIM SET` from -`front-animated/gen2`, `gen3`, or `gen5` (and `back-animated/gen5`). The -authoring importer generates the PNG atlases and one metadata table per set; -the runtime extracts native-resolution cells without a DPI-scaled canvas and -falls back per missing or malformed atlas directly to ROM art. +`BATTLE ART: ANIMATED` reads independent `FRONT GEN` and `BACK GEN` choices +from the matching generation folders. These selectors are ignored by STATIC +and ROM modes and only appear on the in-game menu while ANIMATED is selected. +The authoring importer generates the PNG atlases and one +metadata table per set; the runtime extracts native-resolution cells without +a DPI-scaled canvas and falls back per missing or malformed atlas directly to +ROM art. Trainer cards are always static. Opponent trainer fronts use class filenames in `front-static` (for example `youngster.png`, `cooltrainer-f.png`, and diff --git a/assets/battle/back-animated/README.md b/assets/battle/back-animated/README.md index e1db8bc..f64eeba 100644 --- a/assets/battle/back-animated/README.md +++ b/assets/battle/back-animated/README.md @@ -4,7 +4,8 @@ Optional animated player-back battle atlases are grouped here by generation. Local PNG files are ignored by Git. Authored back art faces right and is never mirrored. Gen 5 currently supplies `gen5/` back atlases; Gen 2 and Gen 3 have front metadata only, so `PLAYER VIEW: BACK` falls back to the ROM back sprite -when either is selected. +when either is selected in `BACK GEN`. This selector only affects ANIMATED +mode; static back PNGs remain freely replaceable and mix-and-match. GIF decoding is authoring-only. The game reads PNG atlases, extracts every cell at its native logical resolution, and uses nearest-neighbour filtering. diff --git a/assets/battle/front-animated/README.md b/assets/battle/front-animated/README.md index e70d834..92bbd82 100644 --- a/assets/battle/front-animated/README.md +++ b/assets/battle/front-animated/README.md @@ -1,9 +1,9 @@ # Front animated battle art Optional animated opponent/player-front battle atlases are grouped here as -`gen2/`, `gen3/`, and `gen5/`. Local PNG files are ignored by Git. `ANIM SET` -chooses exactly one folder; missing or malformed art falls back to the ROM -sprite rather than silently mixing generations. +`gen2/`, `gen3/`, and `gen5/`. Local PNG files are ignored by Git. `FRONT GEN` +chooses exactly one folder in ANIMATED mode; missing or malformed art falls +back to the ROM sprite rather than silently mixing generations. GIF decoding is authoring-only. The game reads PNG atlases, extracts every cell at its native logical resolution, and uses nearest-neighbour filtering. diff --git a/lib/AnimatedBattleArt.lua b/lib/AnimatedBattleArt.lua index 7d31139..73f4f93 100644 --- a/lib/AnimatedBattleArt.lua +++ b/lib/AnimatedBattleArt.lua @@ -90,7 +90,9 @@ end local function definition(battler, side) local species = battler and battler.mon and battler.mon.species local key = species and tostring(species):upper() - local selected = SETS[BattleArt.animationSetting:get()] + local setting = side == "back" and BattleArt.backAnimationSetting + or BattleArt.frontAnimationSetting + local selected = SETS[setting:get()] local bySide = selected and key and selected[key] return bySide and bySide[side] or nil end diff --git a/lib/BattleArt.lua b/lib/BattleArt.lua index 01e411a..ca8c7ea 100644 --- a/lib/BattleArt.lua +++ b/lib/BattleArt.lua @@ -7,8 +7,12 @@ local BattleArt = {} BattleArt.setting = ModSetting.new("battleArt", "BATTLE ART", { "static", "animated", "rom" }, { "STATIC", "ANIMATED", "ROM" }) -BattleArt.animationSetting = ModSetting.new("animatedSet", "ANIM SET", +BattleArt.frontAnimationSetting = ModSetting.new("frontAnimatedSet", "FRONT GEN", { "gen2", "gen3", "gen5" }, { "GEN 2", "GEN 3", "GEN 5" }) +-- Gen 5 is currently the supplied animated-back collection, so it is the +-- sensible fallback for a save with no explicit back-generation choice. +BattleArt.backAnimationSetting = ModSetting.new("backAnimatedSet", "BACK GEN", + { "gen2", "gen3", "gen5" }, { "GEN 2", "GEN 3", "GEN 5" }, 3) BattleArt.viewSetting = ModSetting.new("playerView", "PLAYER VIEW", { "front", "back" }, { "FRONT", "BACK" }) diff --git a/lib/ModSetting.lua b/lib/ModSetting.lua index 2bed605..6580645 100644 --- a/lib/ModSetting.lua +++ b/lib/ModSetting.lua @@ -31,11 +31,12 @@ local function modId() end -- `values` are the stored values in ladder order and `labels` what the row --- shows for each; values[1] is the default, and the one an unreadable or --- unrecognised stored value falls back to. -function ModSetting.new(key, label, values, labels) +-- shows for each. The optional defaultIndex is also the fallback for an +-- unreadable or unrecognised stored value; otherwise values[1] is used. +function ModSetting.new(key, label, values, labels, defaultIndex) return setmetatable({ key = key, label = label, values = values, labels = labels, + defaultIndex = defaultIndex or 1, index = nil, -- nil = not yet read back from the persisted options }, ModSetting) end @@ -44,7 +45,7 @@ local function indexOf(self, value) for i, v in ipairs(self.values) do if v == value then return i end end - return 1 + return self.defaultIndex or 1 end -- What the player left it at last session. Read lazily rather than at load @@ -126,7 +127,8 @@ function ModSetting:schema(help) default = self.values[1], help = help } end return { key = self.key, type = "choice", label = self.label, - choices = choices, default = self.values[1], help = help } + choices = choices, default = self.values[self.defaultIndex or 1], + help = help } end return ModSetting diff --git a/main.lua b/main.lua index 2195500..5b215bb 100644 --- a/main.lua +++ b/main.lua @@ -342,10 +342,18 @@ local SETTINGS = { "Use optional PNGs from assets/battle in fights. Missing art falls " .. "back to the ROM. STATIC is the zero-configuration default.", when = function() return stagedBattles() end, full = true }, - { BattleArt.animationSetting, - "Choose the generation folder used by BATTLE ART: ANIMATED. Missing " - .. "art falls directly back to ROM rather than mixing generations.", - when = function() return stagedBattles() end, full = true }, + { BattleArt.frontAnimationSetting, + "Choose the front generation used by BATTLE ART: ANIMATED. STATIC " + .. "ignores this row. Missing art falls directly back to ROM.", + when = function() + return stagedBattles() and BattleArt.setting:get() == "animated" + end, full = true }, + { BattleArt.backAnimationSetting, + "Choose the back generation used by BATTLE ART: ANIMATED. STATIC " + .. "ignores this row. Missing art falls directly back to ROM.", + when = function() + return stagedBattles() and BattleArt.setting:get() == "animated" + end, full = true }, { BattleArt.viewSetting, "Show the player's Pokemon from the front or back. Both choices stay " .. "world-placed, lit, shadowed and depth-occluded.", @@ -701,7 +709,8 @@ end) -- and a player who stepped off FULL could not see the rows come back. -- -- Rebuilt in place, and only on a step that changes the LIST: crossing FULL, --- or toggling 3D-BTL, which is the other row that owns one (BATTLE LAYOUT). +-- toggling 3D-BTL, which owns BATTLE LAYOUT, or stepping BATTLE ART into or +-- out of ANIMATED, which owns the FRONT GEN and BACK GEN rows. -- Every other rung returns the same list, and rebuilding on all of them would -- rerun every mod's ui.options.rows hook once per keypress. The cursor is -- clamped rather than reset, so it stays on the row it was just used on @@ -720,12 +729,15 @@ do function OptionsMenu:update(dt) local before = Pipelines.level("voxel") local hadBattles = OverworldBattle.enabled() + local hadAnimated = BattleArt.setting:get() == "animated" local wasOn = idAt(self, self.index) inner(self, dt) local after = Pipelines.level("voxel") local crossedFull = after ~= before and (Voxel.isFull(before) or Voxel.isFull(after)) - if crossedFull or OverworldBattle.enabled() ~= hadBattles then + local hasAnimated = BattleArt.setting:get() == "animated" + if crossedFull or OverworldBattle.enabled() ~= hadBattles + or hasAnimated ~= hadAnimated then local rebuilt = OptionsMenu.new(self.game) self.rows = rebuilt.rows -- Follow the row the cursor was ON rather than the slot it was in: From 6887f5d951e1404f8fad8761889a91075e076a1b Mon Sep 17 00:00:00 2001 From: DramaticShape Date: Sat, 1 Aug 2026 16:03:32 -0400 Subject: [PATCH 015/116] updates to oak's lab --- CHANGELOG.md | 66 +++++++++++++++++++ README.md | 1 + data/voxel_heights.lua | 90 ++++++++++++++++++++++++++ lib/BattleScene.lua | 14 ++++- lib/Buildings.lua | 36 +++++++++-- lib/TileShape.lua | 18 ++++++ lib/Voxel3D.lua | 2 +- lib/VoxelGrid.lua | 13 ++++ lib/Water.lua | 2 +- main.lua | 36 ++++++++++- tests/dramatic_shape_test.lua | 58 ++++++++++++++++- tools/building_voxels.py | 115 ++++++++++++++++++++++++++++++---- 12 files changed, 426 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 21ab6e1..f9ea089 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -116,6 +116,72 @@ ever lands half-way between two pixels and changing a wavelength moves the speed with it. +- **AA, a new options row: OFF / 2X / 4X.** Everything else in this game is + flat art blitted at whole pixels. This mode's world is real geometry seen + through a perspective camera, and a polygon edge that lands at an angle + across the pixel grid is the one place where a hard stair-step is not a + stylistic choice -- a roof ridge, a ledge lip, a tree's silhouette against + the sky, the leaning card of a character. At the shallow rungs, where the + diorama reads most like a photograph of a model, they crawl as the camera + drifts. + + The row is SUPERSAMPLING: the whole pass renders into a canvas larger than + the window and is folded back down at the end. The ladder is samples per + display pixel, so 2X is a canvas root-two wider and taller and 4X one + exactly twice the size -- an honest 2x2 box. + + Two alternatives were tried against what this pass already is, and both + lost: + + - **MSAA** would have taken the water with it. The reflections read the + frame's own depth buffer as a texture, and a multisampled depth + attachment is not something a fragment shader in this dialect can sample. + The row would have quietly switched the WATER row off. + + - **An edge filter** (FXAA and its relatives) works from the finished + colour alone, so it would be guessing where the edges are out of one + sample per pixel -- inventing detail it never rendered, and unable to + tell a geometry edge from the boundary between two texels of a tileset. + + Rendering larger has neither problem, and nothing in the frame had to be + taught about it: every pass already measures itself in the canvas it was + handed, so the sky's dither, the water's ray march, the shadow lookups and + the camera itself come out the same picture at a higher sample rate. It + antialiases the geometry, the alpha-cut outline of a sprite card, the + wireframe and the reflections at once, because none of them know it is + happening. + + And it softens the ARTWORK with them, which is worth saying plainly. A + tileset texel out here is not a screen pixel, it is a quad in a perspective + view, and its boundary crosses the pixel grid at the same arbitrary angle a + roof ridge does -- so the fold averages across it exactly as it averages + across the ridge. That is what an honest extra sample says about that + pixel, and it is also the trade the row is: the diorama comes out smoother, + not sharper. Which is why it is a row and not something that is simply on. + + Two things are quoted in DISPLAY pixels rather than canvas ones and are + multiplied up to match: the voxel wireframe's line width -- left alone it + would fold down to half a line, so turning the smoothing up would appear to + fade the grid out -- and the scale the overworld's FX closures draw at. + + The fold is a shader rather than a scaled draw, because the void this pass + renders into is a transparent BLACK: averaging a straight-alpha edge against + it drags the colour toward black as well as toward transparent, and the + engine's composite then multiplies by that alpha a second time. Every + silhouette against the sky would have come out ringed with a dark fringe -- + the exact artefact the row exists to remove. So the taps are premultiplied + before they are averaged and divided back out after. + + The staged battle gets it too, on its own canvas: the arena is folded back + to the window's pixel size before the depth-of-field pass and the HUDs go + on, so the world is smoothed and the pics, panels and text box stay the + chunky GB art they are. + + OFF by default, and **FULL neither sets it nor takes the row away** -- it + is the one row that is not a knob on the look but on what the look COSTS, + and only the player knows what their machine can carry. No hotkey, for the + same reason: it is set once, not flicked while walking. + ### Changed diff --git a/README.md b/README.md index db18394..14e2cb8 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,7 @@ menu. | `8`, or the **3D-BTL** options row | ON / OFF — fight on the map instead of on a white field | | `9`, or the **WATER** options row | FULL / SKY / OFF — waves and reflections on water. **SKY** gives the surface its pixel-tall wave columns and puts the sky, the sun, the moon and the cast in them; **FULL** adds a screen-space ray march that also reflects the shoreline, the trees and the buildings standing behind it | | the **BACK SPRITES** options row | OFF / ON — keep your own Pokémon on the battle menu, seen from behind in its classic slot, instead of standing it on the map; the foe is still out there. Only on the menu while **3D-BTL** is on, because it decides nothing without it | +| the **AA** options row | OFF / 2X / 4X — smooth the stair-stepped edges of the 3D world by rendering the diorama larger than the window and folding it back down. The ladder is samples per display pixel: 2X is a canvas root-two wider and taller, 4X one exactly twice the size. Every edge in the projected picture softens with the silhouettes — the tileset's own texels are quads in a perspective view and cross the pixel grid at the same arbitrary angles — so the diorama reads smoother rather than sharper. The most expensive row in the mod, so it is OFF by default and **FULL** leaves it alone | | the **DAYTIME** options row | SYNC / DAY / NIGHT / DUSK / DAWN / CYCLE — what time it is outdoors, on the diorama *and* on the flat 2D world; held at SYNC (and off the menu) while VOXEL is FULL | **3D-BTL** is on by default and is independent of **VOXEL**: battles draw diff --git a/data/voxel_heights.lua b/data/voxel_heights.lua index 4079a53..7ee4004 100644 --- a/data/voxel_heights.lua +++ b/data/voxel_heights.lua @@ -579,6 +579,12 @@ return { -- auto-extracting into standing prisms, and the ball/Pokedex -- sprites ride the table's authored height. table = { 41, 42, 57, 59, 78, 79 }, + -- These tables are drawn 6px tall (3px slab edge over 3px base -- + -- see the lab_table entry under `buildings`), not the default + -- table's 12: the override keeps the volume-built north tables + -- and the band-built starter table one height, and stands the + -- ball/Pokedex sprites exactly on the top face of both. + heights = { table = 6 }, }, -- Red's room and the Copycat's room (one tileset). The detector reads @@ -3231,5 +3237,89 @@ return { slab = 4, frontEave = 4, ledge = nil, }, }, + + DOJO = { + -- F01: the starter-ball table in Oak's lab (one placement in the + -- game: OAKS_LAB cell 6,3) -- the first FURNITURE through the + -- band pipeline, and the first drawing whose plot is smaller than + -- its grid. Its 24 rows read: 0-15 the tabletop seen from above + -- (black rim, white highlight course, grey field); 16-18 the top + -- slab's own front edge, black/#555/black -- which is exactly + -- what the rim treatment paints, so slab = 3 and those rows fold + -- into the roof band instead of extruding under it; 19-21 the + -- base band, corner feet and the inset dark panel between them. + -- + -- The legs stand on open FLOOR: the measured ground line lands + -- two rows short of the grid (see Buildings measure), and `depth` + -- keeps the plot to the blocked cell row -- the grid's third row + -- is the walkable cell the player faces the table from, matched + -- so the flat leg art is claimed off the floor, not so the model + -- stands on it. 16 top rows onto a 16px plot map 1:1: roofBack + -- covers the whole depth, nothing cycles, and roofCycle is + -- unreachable behind it. The Poke Ball sprites ride the `table` + -- pin's height (VoxelScene.groundAt reads the collision tile, not + -- this model), so the tileset entry above overrides that height + -- to the 6px this drawing actually stands. + { + id = "lab_table", + tiles = { + { 41, 59, 59, 59, 59, 42 }, + { 78, 57, 57, 57, 57, 79 }, + { 88, 89, 89, 89, 89, 90 }, + }, + roofRows = 19, roofBack = 16, roofFront = 0, roofCycle = { 2, 13 }, + slab = 3, frontEave = 0, ledge = nil, depth = 2, + }, + -- F02: the computer bank on the lab's west table (OAKS_LAB cell + -- 0,1) -- the one FACADE-ONLY template: rows 0-14 are machines + -- drawn face-on (monitor and keys, the centre console, the right + -- cabinet) over the lab-table fascia and base bands, and nothing + -- is drawn from above. roofRows = 0, so the whole drawing + -- extrudes -- the house facade treatment with no roof band; the + -- roof fields are inert and the recess pass sinks every framed + -- pane (screens, keys, the cabinet's inset face) into relief on + -- its own. Same drawing, same grid, stands in the Hall of Fame + -- on the GYM atlas -- registered there below. + { + id = "lab_computers", + tiles = { + { 91, 92, 93, 94 }, + { 54, 55, 85, 95 }, + { 88, 89, 89, 90 }, + }, + roofRows = 0, roofBack = 0, roofFront = 0, roofCycle = { 0, 0 }, + slab = 0, frontEave = 0, ledge = nil, depth = 2, + }, + -- F03: the empty north table beside it (OAKS_LAB cell 2,1): the + -- starter table's band table verbatim on a grid two tiles + -- narrower. + { + id = "lab_table_small", + tiles = { + { 41, 59, 59, 42 }, + { 78, 57, 57, 79 }, + { 88, 89, 89, 90 }, + }, + roofRows = 19, roofBack = 16, roofFront = 0, roofCycle = { 2, 13 }, + slab = 3, frontEave = 0, ledge = nil, depth = 2, + }, + }, + + GYM = { + -- F02 again: the Hall of Fame's recording machine is the lab's + -- computer bank drawing, tile for tile, on the GYM atlas (one + -- placement: HALL_OF_FAME cell 4,1). Same band table as the DOJO + -- entry above. + { + id = "lab_computers", + tiles = { + { 91, 92, 93, 94 }, + { 54, 55, 85, 95 }, + { 88, 89, 89, 90 }, + }, + roofRows = 0, roofBack = 0, roofFront = 0, roofCycle = { 0, 0 }, + slab = 0, frontEave = 0, ledge = nil, depth = 2, + }, + }, }, } diff --git a/lib/BattleScene.lua b/lib/BattleScene.lua index 170caac..3938f89 100644 --- a/lib/BattleScene.lua +++ b/lib/BattleScene.lua @@ -42,6 +42,7 @@ local BattleCam = V.require("BattleCam") local BattleBillboard = V.require("BattleBillboard") local VoxelGrid = V.require("VoxelGrid") local DayNight = V.require("DayNight") +local AntiAlias = V.require("AntiAlias") local PaletteFX = require("src.render.PaletteFX") local Map = require("src.world.Map") @@ -427,7 +428,16 @@ function BattleScene.render(state, arena, textures, token) -- its own canvas slot: this renders at the window's pixel size and the -- free-roam pass does too, but the two are alive at different moments -- and a shared slot would reallocate on every battle entry and exit - if not Voxel3D.beginScene(pw, ph, cx, cy, vw, vh, sky, "battle") then + -- + -- AA, if the row asks for it, renders it larger still and folds it back + -- to pw x ph below (see AntiAlias). The framing is untouched by that: + -- the lens was widened by the window's RATIO to the letterbox and the + -- rig solved in the GB's own frame, so a bigger canvas is more samples + -- of the identical shot -- which is why the pins below still measure in + -- pw and ph, and why the HUDs and the depth of field, drawn onto the + -- folded canvas afterwards, stay the chunky GB art they are. + local rw, rh = AntiAlias.expand(pw, ph) + if not Voxel3D.beginScene(rw, rh, cx, cy, vw, vh, sky, "battle") then return end Voxel3D.draw(terrain, atlasFor(host), nil) @@ -500,7 +510,7 @@ function BattleScene.render(state, arena, textures, token) Mat4.translate(nb.ox, 0, nb.oy), fpull, ShadowMap.snug(Mat4.translate(nb.ox, 0, nb.oy))) end - local canvas = Voxel3D.endScene() + local canvas = AntiAlias.resolve(Voxel3D.endScene(), pw, ph, "battle") if not canvas then return end local vp = Voxel3D.vp diff --git a/lib/Buildings.lua b/lib/Buildings.lua index 7bfc6e0..644cb09 100644 --- a/lib/Buildings.lua +++ b/lib/Buildings.lua @@ -190,7 +190,24 @@ local function measure(sp, t) top[x] = r end - local wallH = H - roofRows + -- The drawing's own ground line: the row after the last drawn one. A + -- building ends on the black threshold row it stands on (ground == H), + -- but furniture is drawn standing on open floor -- the lab table's + -- legs stop two rows short of its grid -- and extruding against H + -- would float it that far above its own plot. + local ground = roofRows + for sy = H - 1, roofRows, -1 do + local drawn = false + for sx = 0, W - 1 do + if sp.inside[sy * W + sx] then drawn = true break end + end + if drawn then + ground = sy + 1 + break + end + end + + local wallH = ground - roofRows local ytop = wallH - 1 + t.slab -- Side faces must not come out as slabs of outline black: where the @@ -279,7 +296,12 @@ local function measure(sp, t) -- sprite taller than its footprint -- the tower's 16-row drawing -- stands on the 8 rows of it that are actually on the map, and D = H -- would have pushed its body 64px south into the town plaza. - return { top = top, ytop = ytop, D = #t.tiles * 8, + -- `depth` (in tile rows) names the plot when the grid runs PAST it + -- onto ground the drawing merely stands its legs on: the lab table's + -- third row is the walkable cell the player faces it from, and the + -- full-grid depth would stand the model in their path. + return { top = top, ytop = ytop, D = (t.depth or #t.tiles) * 8, + ground = ground, recess = recess, interior = interior, shadeTexel = shadeTexel } end @@ -292,7 +314,7 @@ end local function model(sp, pr, t) local W, H, D = sp.W, sp.H, pr.D local slab, roofRows = t.slab, t.roofRows - local top, ytop = pr.top, pr.ytop + local top, ytop, ground = pr.top, pr.ytop, pr.ground -- The roof's drawn span. A sprite inset from its box (B03) leaves outer -- columns undrawn in the roof band; they carry no roof at all, and the @@ -367,16 +389,18 @@ local function model(sp, pr, t) -- the awning: the band juts two voxels past the walls, front and back if ledge0 and (z == -2 or z == -1 or z == D or z == D + 1) then - local sy = H - 1 - y + local sy = ground - 1 - y if sy >= ledge0 and sy <= ledge1 and sp.inside[sy * W + x] then return sy * W + x end return nil end - -- the facade, extruded straight back over the footprint + -- the facade, extruded straight back over the footprint. Rows map + -- against the measured ground line, not the grid's last row: the two + -- differ only for furniture standing on open floor (see measure). if z < 0 or z >= D then return nil end - local sy = H - 1 - y + local sy = ground - 1 - y local i = sy * W + x if y == 0 and not sp.inside[i] and sy > 0 and sp.inside[i - W] then -- the drawing's last row is the ground the building stands on, so diff --git a/lib/TileShape.lua b/lib/TileShape.lua index 6cb65ce..f36f87d 100644 --- a/lib/TileShape.lua +++ b/lib/TileShape.lua @@ -295,6 +295,24 @@ function TileShape.forMap(map) if cache[id] then return cache[id] end local heights = TileShape.heights() + -- Per-tileset height overrides (a tileset entry's `heights`): the class + -- vocabulary is global but the drawings are not -- the DOJO lab tables + -- are drawn 6px tall where the default `table` is 12 -- and the height + -- a sprite RIDES at (VoxelScene.groundAt) must be the height the art + -- actually stands, or the starter balls float over their own table. + -- Same gate as the global list: known classes, numbers only. + do + local s = load() + local entry = s and s.tilesets and s.tilesets[id] + local over = entry and entry.heights + if type(over) == "table" then + for class, h in pairs(over) do + if type(h) == "number" and FALLBACK_HEIGHTS[class] then + heights[class] = h + end + end + end + end local authored = authoredGroups(id, heights) local count = math.floor((tileset.imageWidth or 128) / 8) * math.floor((tileset.imageHeight or 48) / 8) diff --git a/lib/Voxel3D.lua b/lib/Voxel3D.lua index 471a688..54ef847 100644 --- a/lib/Voxel3D.lua +++ b/lib/Voxel3D.lua @@ -715,7 +715,7 @@ function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky, slot) pcall(sh.send, sh, "sunTexel", { texel, texel }) if grid then pcall(sh.send, sh, "gridDark", VoxelGrid.DARK) - pcall(sh.send, sh, "gridWidth", VoxelGrid.WIDTH) + pcall(sh.send, sh, "gridWidth", VoxelGrid.width()) end -- ordinary shading until the silhouette pass asks for otherwise. Sent -- every frame rather than once, because a scene that opened mid-ghost -- diff --git a/lib/VoxelGrid.lua b/lib/VoxelGrid.lua index 1fb7ce7..b585072 100644 --- a/lib/VoxelGrid.lua +++ b/lib/VoxelGrid.lua @@ -44,6 +44,19 @@ VoxelGrid.DARK = 0.45 -- 1.0 here is the one-pixel wireframe. VoxelGrid.WIDTH = 1.0 +-- The same width in the CANVAS pixels the shader measures in, which is what +-- every sender of it actually wants. +-- +-- The two are the same number until AA renders the pass larger than the +-- window (see AntiAlias): there a canvas pixel is a fraction of a display +-- one, and a width left at 1.0 would come out a half or a quarter of a line +-- after the fold -- the wireframe fading as the smoothing goes up, which +-- reads as one row breaking the other. Scaled, it stays a one-pixel seam and +-- simply gains the antialiasing everything else in the frame just gained. +function VoxelGrid.width() + return VoxelGrid.WIDTH * V.require("AntiAlias").factor() +end + -- where it persists and the rows that cycle it (see ModSetting) VoxelGrid.setting = ModSetting.new(VoxelGrid.KEY, VoxelGrid.LABEL, { false, true }, { "OFF", "ON" }) diff --git a/lib/Water.lua b/lib/Water.lua index 9b7956c..db1db8e 100644 --- a/lib/Water.lua +++ b/lib/Water.lua @@ -1192,7 +1192,7 @@ function Water.begin(ctx) if ctx.grid then local VoxelGrid = V.require("VoxelGrid") send("gridDark", VoxelGrid.DARK) - send("gridWidth", VoxelGrid.WIDTH) + send("gridWidth", VoxelGrid.width()) end Water.sendSky(sh, ctx) diff --git a/main.lua b/main.lua index 95ca933..6ca5f8a 100644 --- a/main.lua +++ b/main.lua @@ -82,6 +82,7 @@ local BattleExit = V.require("BattleExit") local DayNight = V.require("DayNight") local DayTint = V.require("DayTint") local Water = V.require("Water") +local AntiAlias = V.require("AntiAlias") -- Forward declaration: the voxel pipeline's update hook (registered below) -- calls this, and it is defined further down with the settings it drives. @@ -203,20 +204,34 @@ mod.content.render_pipelines:register("voxel", { -- a magnified low-res image, while the FX closures keep drawing in -- world-pixel units. local sw, sh = sceneSize(ctx) - local canvas = VoxelScene.render(ctx.state, sw, sh, + -- With AA on, the whole pass runs into a canvas BIGGER than the window + -- and is folded back down at the end (see AntiAlias). Nothing between + -- these two lines knows: every pass in the frame measures itself in the + -- canvas it was handed, so the sky's dither, the water's march and the + -- camera itself all come out the same picture at a higher sample rate. + local rw, rh = AntiAlias.expand(sw, sh) + local canvas = VoxelScene.render(ctx.state, rw, rh, ctx.vw, ctx.vh, ctx.paletteFor) if not canvas then return nil end -- fall back to the 2D path if Voxel3D.beginOverlay() then + -- the FX closures are ordinary 2D draws sized in DISPLAY pixels, and + -- they are drawing into the supersampled canvas alongside everything + -- else -- so the scale goes up with it, or the "!" bubble lands the + -- right place at half the size. project() already answers in canvas + -- pixels, so only the scale needs saying. ctx.drawFx(function(wx, wy) return Voxel3D.project(wx, 0, wy) end, - ctx.scale) + ctx.scale * AntiAlias.factor()) Voxel3D.endOverlay() end - return canvas + -- and back to the window's own size, which is what the engine composites + -- one canvas pixel to one display pixel. A pass-through when AA is off. + return AntiAlias.resolve(canvas, sw, sh, "world") end, invalidate = function() Voxel3D.invalidate() OverworldBattle.invalidate() + AntiAlias.invalidate() ChunkMesher.invalidate() -- no map id = every cached mesh end, }) @@ -361,6 +376,21 @@ local SETTINGS = { .. "let CYCLE run it -- ten minutes of sun, ten of moon, with the " .. "shadows, the sky and the light following -- or SYNC it to the " .. "clock on the wall, so Kanto's evening falls when yours does." }, + -- Marked `full` for the opposite reason the battle rows are: this is not a + -- knob on the look at all, it is what the look COSTS. FULL is a preset for + -- the diorama, not a licence to spend four times the fill rate on the + -- machine it happens to be running on, so it neither sets this nor takes + -- the row away -- the player decides what their hardware can carry, from + -- inside FULL like anywhere else. + { AntiAlias.setting, + "Smooth the stair-stepped edges of the 3D world -- roof ridges, ledge " + .. "lips, a tree against the sky -- by rendering the diorama larger than " + .. "the window and folding it back down. Every edge in the picture " + .. "softens with them, the tileset's own texels included, so the diorama " + .. "reads smoother rather than sharper. 2X costs half again as many " + .. "pixels in each direction and 4X twice, which makes this the most " + .. "expensive row in the mod.", + full = true }, } local schema = {} diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua index 70b37fc..0ca9283 100644 --- a/tests/dramatic_shape_test.lua +++ b/tests/dramatic_shape_test.lua @@ -145,6 +145,9 @@ T.check(not fullIds["DRAMATIC_SHAPE:daytime"], "and DAYTIME") -- back sprite (or no staged fights at all) can still say so from inside FULL T.check(fullIds["DRAMATIC_SHAPE:battles"], "3D-BTL is still on the menu under FULL") T.check(fullIds["DRAMATIC_SHAPE:battleBack"], "and BACK SPRITES with it") +-- and AA, for the opposite reason: it is not a knob on the look at all, it is +-- what the look COSTS, and only the player knows what their machine can carry +T.check(fullIds["DRAMATIC_SHAPE:aa"], "and AA, which FULL neither sets nor owns") -- DAYTIME is not only hidden under FULL, it is HELD at SYNC: the row cannot -- be reached while FULL owns it, so a value changed underneath (the mod @@ -379,9 +382,11 @@ end Pipelines.setLevel("voxel", 2) local hookedRows = Runtime.call("ui.options.rows", function(_, r) return r end, { data = Data }, { { id = "text_speed" } }) -T.eq(#hookedRows, 7, "the options hook added a row per setting") +T.eq(#hookedRows, 8, "the options hook added a row per setting") local grid, curve, water = hookedRows[2], hookedRows[3], hookedRows[4] local battles, backRow, daytime = hookedRows[5], hookedRows[6], hookedRows[7] +-- the AA row is hookedRows[8]; it is read in its own block below, because +-- this chunk is one main function and has 200 local slots to spend T.eq(water.label, "WATER", "the water row carries its label") T.eq(water.value(), "FULL", "and defaults to FULL -- reflections are the point of having the row") @@ -459,6 +464,57 @@ curve.step(settingGame, 1) curve.step(settingGame, 1) T.eq(curve.value(), "OFF", "the curve is left off for the rows below") +-- ------- AA renders the pass larger and folds it back down +-- +-- The ladder is SAMPLES per display pixel, so the canvas scale each rung asks +-- for is its square root -- and the scale in force has to be readable after +-- the fact, because two things are quoted in DISPLAY pixels and have to be +-- multiplied up into the canvas the pass actually opened: the wireframe's +-- line width and the FX overlay's sprite scale. +do +local AntiAlias = run.loader.exports.DRAMATIC_SHAPE.lib.require("AntiAlias") +local VoxelGrid = run.loader.exports.DRAMATIC_SHAPE.lib.require("VoxelGrid") +local aaGame = { save = { options = {} }, mods = { modOptions = {} } } +local aa = hookedRows[8] +T.eq(aa.label, "AA", "the anti-aliasing row carries its label") +T.eq(aa.value(), "OFF", + "and starts off -- supersampling is a cost knob, and a mod must not spend " + .. "four times the fill rate of the machine it lands on unasked") + +T.eq(AntiAlias.samples(), 0, "AA is off by default") +local w, h = AntiAlias.expand(320, 200) +T.eq(w, 320, "an OFF row renders at the window's own width") +T.eq(h, 200, "and its height") +T.eq(AntiAlias.factor(), 1, "with nothing to multiply display pixels by") +T.eq(VoxelGrid.width(), VoxelGrid.WIDTH, + "so the wireframe is the one-display-pixel line it has always been") + +aa.step(aaGame, 1) +T.eq(aa.value(), "2X", "stepping the row climbs to two samples a pixel") +T.eq(aaGame.save.options.modOptions.DRAMATIC_SHAPE.aa, 2, + "the sample count persists beside the other settings, not over them") +w, h = AntiAlias.expand(320, 200) +T.eq(w, 453, "two samples a pixel is a canvas root-two wider") +T.eq(h, 283, "and root-two taller") +T.check(math.abs(AntiAlias.factor() - 453 / 320) < 1e-9, + "and the factor is what it MEASURED, not what the row asked for") + +aa.step(aaGame, 1) +T.eq(aa.value(), "4X", "and again to four") +w, h = AntiAlias.expand(320, 200) +T.eq(w, 640, "four samples a pixel is a canvas exactly twice the size") +T.eq(h, 400, "in each direction, which is the 2x2 box the fold reads") +T.eq(AntiAlias.factor(), 2, "with everything in display pixels doubled") +T.eq(VoxelGrid.width(), VoxelGrid.WIDTH * 2, + "the wireframe among them -- a seam left at 1.0 would fold down to half a " + .. "line, so the smoothing row would appear to fade the grid row out") + +aa.step(aaGame, 1) +T.eq(aa.value(), "OFF", "and the ladder wraps back to off") +AntiAlias.expand(320, 200) +T.eq(AntiAlias.factor(), 1, "leaving nothing behind for the next pass") +end + -- ------- the animated terrain atlas survives an engine without its seams -- -- Regression: cycling palette modes with voxel mode on eventually killed diff --git a/tools/building_voxels.py b/tools/building_voxels.py index 0fb1b12..a38f8c5 100644 --- a/tools/building_voxels.py +++ b/tools/building_voxels.py @@ -547,6 +547,57 @@ roof_rows=17, roof_back=5, roof_front=3, roof_cycle=(5, 9), slab=4, front_eave=4, ledge=None, tileset="forest", ), + # F01: the starter-ball table in Oak's lab -- the first FURNITURE + # through the pipeline, and the first drawing whose plot is smaller + # than its grid. Rows 0-15 are the tabletop seen from above; 16-18 + # are the top slab's own front edge (black/#555/black -- exactly + # what the rim treatment paints, so slab=3 and those rows fold into + # the roof band instead of extruding); 19-21 are the base: corner + # feet and the inset dark panel between them. The legs stand on + # open FLOOR, so the measured ground line lands two rows short of + # the grid; `depth` keeps the plot to the blocked cell row -- the + # legs row of the grid is the walkable cell the player faces the + # table from, and D = len(tiles) would stand the model in their + # path. 16 top rows onto a 16px plot map 1:1: no cycling, and + # roof_cycle is unreachable behind roof_back=16. + "lab_table": dict( + tiles=[ + [41, 59, 59, 59, 59, 42], + [78, 57, 57, 57, 57, 79], + [88, 89, 89, 89, 89, 90], + ], + roof_rows=19, roof_back=16, roof_front=0, roof_cycle=(2, 13), + slab=3, front_eave=0, ledge=None, tileset="gym", depth=2, + ), + # F02: the computer bank on Oak's lab's west table -- and the Hall + # of Fame's recording machine, the same drawing on the GYM atlas + # (one placement each; both registered in voxel_heights). The one + # FACADE-ONLY template: rows 0-14 are machines drawn face-on over + # the lab-table fascia and base bands, nothing is drawn from above, + # so roof_rows=0 and the whole drawing extrudes -- the house facade + # treatment with no roof band. The recess pass turns every framed + # pane (the monitor, its keys, the right cabinet's inset face) into + # relief on its own. + "lab_computers": dict( + tiles=[ + [91, 92, 93, 94], + [54, 55, 85, 95], + [88, 89, 89, 90], + ], + roof_rows=0, roof_back=0, roof_front=0, roof_cycle=(0, 0), + slab=0, front_eave=0, ledge=None, tileset="gym", depth=2, + ), + # F03: the empty north table beside it -- the starter table's band + # table verbatim on a grid two tiles narrower (one placement). + "lab_table_small": dict( + tiles=[ + [41, 59, 59, 42], + [78, 57, 57, 79], + [88, 89, 89, 90], + ], + roof_rows=19, roof_back=16, roof_front=0, roof_cycle=(2, 13), + slab=3, front_eave=0, ledge=None, tileset="gym", depth=2, + ), # B23: the Victory Road entrance on Route 23: a rock face with two # barred doors. The roof band is the pale cliff top seen from above. "victory_road_gate": dict( @@ -642,7 +693,14 @@ def profile(sp, t): r = next((y for y in range(H) if inside(x, y)), t["roof_rows"]) top.append(min(r, t["roof_rows"])) - wall_h = H - t["roof_rows"] + # The drawing's own ground line: the row after the last drawn one. A + # building ends on the black threshold row it stands on (ground == H), + # but furniture is drawn standing on open floor -- the lab table's + # legs stop two rows short of its grid -- and extruding against H + # would float it that far above its own plot. + ground = max((y for y in range(H) for x in range(W) if inside(x, y)), + default=H - 1) + 1 + wall_h = ground - t["roof_rows"] ytop = wall_h - 1 + t["slab"] # Recesses: the panes the art seals behind a black frame. Non-black @@ -674,8 +732,12 @@ def profile(sp, t): if x1 - x0 + 1 <= RECESS_MAX and y1 - y0 + 1 <= RECESS_MAX: recess.update(cells) + # Depth is the PLOT. For a whole-drawing building that is the grid + # itself; `depth` (in tile rows) names it when the grid runs past the + # plot onto ground the drawing merely stands its legs on. return dict(top=top, wall_h=wall_h, ytop=ytop, recess=recess, - inside=inside, D=H, W=W, H=H) + inside=inside, D=t.get("depth", len(t["tiles"])) * 8, + ground=ground, W=W, H=H) # --------------------------------------------------------------- stage 3 -- @@ -720,7 +782,9 @@ def put(x, y, z, sx, sy): # ---- walls: the facade rows extruded straight back, trimmed under the roof for sy in range(t["roof_rows"], H): - y = H - 1 - sy + y = pr["ground"] - 1 - sy # rows below the ground line are floor + if y < 0: + continue for sx in range(W): if not inside(sx, sy) or trimmed(sx, y): continue @@ -742,7 +806,7 @@ def put(x, y, z, sx, sy): if t["ledge"]: l0, l1 = t["ledge"] for sy in range(l0, l1 + 1): - y = H - 1 - sy + y = pr["ground"] - 1 - sy for sx in range(W): if inside(sx, sy) and not trimmed(sx, y): for z in (-2, -1, D, D + 1): @@ -750,7 +814,7 @@ def put(x, y, z, sx, sy): # ---- recesses: the front voxel of every pane sinks, its frame stays proud for sx, sy in pr["recess"]: - vox.pop((sx, H - 1 - sy, D - 1), None) + vox.pop((sx, pr["ground"] - 1 - sy, D - 1), None) # ---- roof: flat top over the plateau, stepped diagonal ends z0, z1 = 0, D - 1 + t["front_eave"] @@ -805,6 +869,41 @@ def verify(vox, sp, pr, t): for (x, y, z), _ in vox.items(): assert y <= T(x), f"voxel pokes through the roof at {x},{y},{z}" + # A facade-only template (roof_rows == 0: the lab computer bank) has + # no roof band, so there is no surface to hold level or slope and no + # coverage to demand of it; the poke-through assert above and the + # facade asserts below are what it answers to. Everything with a roof + # band answers the full set. + if t["roof_rows"] == 0: + # its one geometric intent: a straight extrusion, so each column + # of the drawing is solid from its lowest voxel to its top. NOT + # from the ground -- the drawing's base band is inset like every + # lab table's, and the body's outer columns legitimately + # overhang it -- and not on the recess layer, which sinks panes. + lo, hi = {}, {} + for (x, y, z) in vox: + lo[(x, z)] = min(lo.get((x, z), y), y) + hi[(x, z)] = max(hi.get((x, z), y), y) + for (x, z), h in hi.items(): + if z == D - 1: + continue + assert all((x, y, z) in vox for y in range(lo[(x, z)], h + 1)), \ + f"facade column has a hole at {x},{z}" + else: + verify_roof(vox, pr, t) + + shell = [k for k in vox if not all( + (k[0] + d[0], k[1] + d[1], k[2] + d[2]) in vox + for d in ((1, 0, 0), (-1, 0, 0), (0, 1, 0), + (0, -1, 0), (0, 0, 1), (0, 0, -1)))] + return shell + + +def verify_roof(vox, pr, t): + W, H, D = pr["W"], pr["H"], pr["D"] + ytop, top, slab = pr["ytop"], pr["top"], t["slab"] + T = lambda x: ytop - top[max(0, min(W - 1, x))] + # The roof surface reads over the columns the drawing actually paints: # a sprite inset from its box says nothing about the rest. roofed = [x for x in range(W) if top[x] < t["roof_rows"]] @@ -845,12 +944,6 @@ def verify(vox, sp, pr, t): elif any((x, y, z) in vox for y in range(0, T(x) - slab + 1)): assert (x, T(x), z) in vox, f"wall uncovered at {x},{z}" - shell = [k for k in vox if not all( - (k[0] + d[0], k[1] + d[1], k[2] + d[2]) in vox - for d in ((1, 0, 0), (-1, 0, 0), (0, 1, 0), - (0, -1, 0), (0, 0, 1), (0, 0, -1)))] - return shell - def preview(shell, vox, sp, name, out, flip, canvas=(1700, 900), pad=20): # Mirroring depth swings the camera round to the other side. Mirror From 752653e243a05e32b5c005ff836d628076daaa18 Mon Sep 17 00:00:00 2001 From: DramaticShape Date: Sat, 1 Aug 2026 16:20:04 -0400 Subject: [PATCH 016/116] update oak's pc --- data/voxel_heights.lua | 49 +++++++--- lib/Buildings.lua | 143 ++++++++++++++++++++++++++++ tools/building_voxels.py | 199 ++++++++++++++++++++++++++++++++++++--- 3 files changed, 364 insertions(+), 27 deletions(-) diff --git a/data/voxel_heights.lua b/data/voxel_heights.lua index 7ee4004..cbf4fda 100644 --- a/data/voxel_heights.lua +++ b/data/voxel_heights.lua @@ -3270,16 +3270,21 @@ return { roofRows = 19, roofBack = 16, roofFront = 0, roofCycle = { 2, 13 }, slab = 3, frontEave = 0, ledge = nil, depth = 2, }, - -- F02: the computer bank on the lab's west table (OAKS_LAB cell - -- 0,1) -- the one FACADE-ONLY template: rows 0-14 are machines - -- drawn face-on (monitor and keys, the centre console, the right - -- cabinet) over the lab-table fascia and base bands, and nothing - -- is drawn from above. roofRows = 0, so the whole drawing - -- extrudes -- the house facade treatment with no roof band; the - -- roof fields are inert and the recess pass sinks every framed - -- pane (screens, keys, the cabinet's inset face) into relief on - -- its own. Same drawing, same grid, stands in the Hall of Fame - -- on the GYM atlas -- registered there below. + -- F02: the computer desk on the lab's west side (OAKS_LAB cell + -- 0,1) -- the one DESK-SET template: the pipeline's region + -- classification at PART granularity (see Buildings + -- deskSetModel). The desk is the sibling lab table (fascia rows + -- 16-18, base 19-21); on it stand a monitor over its keyboard + -- (left), a computer tower over a keyboard and mouse (middle), + -- and a sheet of paper LYING FLAT (right). Upright parts anchor + -- their drawn bottom row to the desk's top plane and wear their + -- own drawn tops as lids; flat parts lie one voxel proud at + -- drawn row = depth row -- the same 1:1 the tabletop itself is + -- drawn with. The roof fields are inert (roofRows = 0 keeps the + -- recess scan over the whole drawing, which is what sinks the + -- monitor's screen and the tower's slots). Same drawing, same + -- grid, stands in the Hall of Fame on the GYM atlas -- + -- registered there below. { id = "lab_computers", tiles = { @@ -3289,6 +3294,16 @@ return { }, roofRows = 0, roofBack = 0, roofFront = 0, roofCycle = { 0, 0 }, slab = 0, frontEave = 0, ledge = nil, depth = 2, + desk = { fascia = { 16, 18 }, base = { 19, 21 } }, + parts = { + { kind = "upright", x = { 2, 13 }, top = { 0, 2 }, + facade = { 3, 10 }, depth = 4 }, -- the monitor + { kind = "flat", x = { 1, 13 }, rows = { 11, 14 } }, -- keyboard + { kind = "upright", x = { 14, 21 }, top = { 0, 3 }, + facade = { 4, 10 }, depth = 6 }, -- the tower + { kind = "flat", x = { 14, 21 }, rows = { 11, 14 } }, -- keys+mouse + { kind = "flat", x = { 22, 30 }, rows = { 1, 14 } }, -- the paper + }, }, -- F03: the empty north table beside it (OAKS_LAB cell 2,1): the -- starter table's band table verbatim on a grid two tiles @@ -3307,8 +3322,8 @@ return { GYM = { -- F02 again: the Hall of Fame's recording machine is the lab's - -- computer bank drawing, tile for tile, on the GYM atlas (one - -- placement: HALL_OF_FAME cell 4,1). Same band table as the DOJO + -- computer desk drawing, tile for tile, on the GYM atlas (one + -- placement: HALL_OF_FAME cell 4,1). Same part table as the DOJO -- entry above. { id = "lab_computers", @@ -3319,6 +3334,16 @@ return { }, roofRows = 0, roofBack = 0, roofFront = 0, roofCycle = { 0, 0 }, slab = 0, frontEave = 0, ledge = nil, depth = 2, + desk = { fascia = { 16, 18 }, base = { 19, 21 } }, + parts = { + { kind = "upright", x = { 2, 13 }, top = { 0, 2 }, + facade = { 3, 10 }, depth = 4 }, -- the monitor + { kind = "flat", x = { 1, 13 }, rows = { 11, 14 } }, -- keyboard + { kind = "upright", x = { 14, 21 }, top = { 0, 3 }, + facade = { 4, 10 }, depth = 6 }, -- the tower + { kind = "flat", x = { 14, 21 }, rows = { 11, 14 } }, -- keys+mouse + { kind = "flat", x = { 22, 30 }, rows = { 1, 14 } }, -- the paper + }, }, }, }, diff --git a/lib/Buildings.lua b/lib/Buildings.lua index 644cb09..d378ec7 100644 --- a/lib/Buildings.lua +++ b/lib/Buildings.lua @@ -307,11 +307,154 @@ end -- ----------------------------------------------------------------- build -- +-- A desk with separately-classified objects on it (a template's `parts` +-- list): the methodology's region classification at part granularity. +-- Upright parts anchor their drawn bottom row to the desk's top plane +-- and wear their own drawn tops as lids; flat parts (a keyboard, a +-- sheet of paper) lie one voxel proud at drawn row = depth row -- the +-- same 1:1 the tabletop itself is drawn with, so an object's height ON +-- the drawing is its position ON the desk. The desk is the lab-table +-- slab + base; its lid is the one synthesized surface in the model +-- (the objects cover every drawn pixel of the tabletop), continued +-- from the sibling tables' pattern in the drawing's own shades. +-- tools/building_voxels.py `build_desk_set` is the reference twin. +local function deskSetModel(sp, pr, t) + local W, H, D = sp.W, sp.H, pr.D + local ground = pr.ground + local col, inside = sp.col, sp.inside + local vox = {} + local function key(x, y, z) return (y * D + z) * W + x end + local function put(x, y, z, i) vox[key(x, y, z)] = i end + + -- de-outline walk bounded to the part, so a part's side faces show + -- its own material and never the neighbour's (the sprite-wide walk + -- the facade path uses would cross the black seam between units) + local function interiorAt(sx, sy, lo, hi) + local i = sy * W + sx + if col[i] ~= BLACK then return sx end + local step = sx < math.floor((lo + hi) / 2) and 1 or -1 + for d = 1, 3 do + local nx = sx + step * d + if nx >= lo and nx <= hi then + local ni = sy * W + nx + if inside[ni] and col[ni] ~= BLACK then return nx end + end + end + return sx + end + + local f0, f1 = t.desk.fascia[1], t.desk.fascia[2] + local b0, b1 = t.desk.base[1], t.desk.base[2] + local plane = (b1 - b0 + 1) + (f1 - f0 + 1) + + -- the base band, extruded exactly like every lab table's + for sy = b0, b1 do + Budget.tick() + local y = ground - 1 - sy + for sx = 0, W - 1 do + if inside[sy * W + sx] then + local ix = interiorAt(sx, sy, 0, W - 1) + for z = 0, D - 1 do + local px = (z == 0 or z == D - 1) and sx or ix + put(sx, y, z, sy * W + px) + end + end + end + end + for i in pairs(pr.recess) do + local sy = math.floor(i / W) + if sy >= b0 and sy <= b1 then + vox[key(i % W, ground - 1 - sy, D - 1)] = nil + end + end + + -- the slab: fascia rows wrap every side; the lid continues the + -- sibling tables' top -- black rim, white highlight courses along + -- the north and west, grey field + for sy = f0, f1 do + Budget.tick() + local y = plane - 1 - (sy - f0) + for sx = 0, W - 1 do + for z = 0, D - 1 do put(sx, y, z, sy * W + sx) end + end + end + for sx = 0, W - 1 do + for z = 0, D - 1 do + local shade = GREY + if sx == 0 or sx == W - 1 or z == 0 or z == D - 1 then + shade = BLACK + elseif sx == 1 or z == 1 then + shade = WHITE + end + put(sx, plane - 1, z, pr.shadeTexel[shade]) + end + end + + local ytop = plane + for _, p in ipairs(t.parts) do + Budget.tick() + local x0, x1 = p.x[1], p.x[2] + if p.kind == "flat" then + for sy = p.rows[1], p.rows[2] do + if sy >= 0 and sy < D then + for sx = x0, x1 do + if inside[sy * W + sx] then put(sx, plane, sy, sy * W + sx) end + end + end + end + else + local tr0, tr1 = p.top[1], p.top[2] + local fr0, fr1 = p.facade[1], p.facade[2] + local pd = p.depth + local ytp = plane + (fr1 - fr0) + if ytp > ytop then ytop = ytp end + for sx = x0, x1 do + -- the lid: the part's drawn top laid across its depth from the + -- back, last row continuing forward; the front lid row is the + -- facade's own top row -- the drawn front-top edge + for z = 0, pd - 1 do + local front = z == pd - 1 + local sy = front and fr0 or math.min(tr0 + z, tr1) + while sy <= tr1 and not inside[sy * W + sx] do sy = sy + 1 end + local ok = sy <= tr1 or (front and inside[fr0 * W + sx]) + if ok then + put(sx, ytp, z, (front and fr0 or sy) * W + sx) + end + end + -- the body: facade rows anchored to the desk's top plane + for sy = fr0 + 1, fr1 do + local y = plane + (fr1 - sy) + local i = sy * W + sx + if inside[i] then + local ix = interiorAt(sx, sy, x0, x1) + for z = 0, pd - 1 do + if z == pd - 1 then + if not pr.recess[i] then put(sx, y, z, i) end + elseif z == 0 then + put(sx, y, z, i) + else + put(sx, y, z, sy * W + ix) + end + end + end + end + end + end + end + + return { at = function(x, y, z) + if x < 0 or x >= W or y < 0 or z < 0 or z >= D then return nil end + return vox[key(x, y, z)] + end, + W = W, ytop = ytop, zmin = 0, zmax = D - 1 } +end + -- The voxel model as a lookup: `at(x, y, z)` is the index of the sprite -- pixel that voxel wears, or nil. Build ORDER is expressed as lookup -- order -- roof first, so it overwrites the walls it intersects, and walls -- are trimmed to its underside so nothing pokes through the surface. local function model(sp, pr, t) + if t.parts then return deskSetModel(sp, pr, t) end local W, H, D = sp.W, sp.H, pr.D local slab, roofRows = t.slab, t.roofRows local top, ytop, ground = pr.top, pr.ytop, pr.ground diff --git a/tools/building_voxels.py b/tools/building_voxels.py index a38f8c5..6cb00b7 100644 --- a/tools/building_voxels.py +++ b/tools/building_voxels.py @@ -569,15 +569,21 @@ roof_rows=19, roof_back=16, roof_front=0, roof_cycle=(2, 13), slab=3, front_eave=0, ledge=None, tileset="gym", depth=2, ), - # F02: the computer bank on Oak's lab's west table -- and the Hall - # of Fame's recording machine, the same drawing on the GYM atlas - # (one placement each; both registered in voxel_heights). The one - # FACADE-ONLY template: rows 0-14 are machines drawn face-on over - # the lab-table fascia and base bands, nothing is drawn from above, - # so roof_rows=0 and the whole drawing extrudes -- the house facade - # treatment with no roof band. The recess pass turns every framed - # pane (the monitor, its keys, the right cabinet's inset face) into - # relief on its own. + # F02: the computer desk in Oak's lab -- and the Hall of Fame's + # recording machine, the same drawing on the GYM atlas (one + # placement each; both registered in voxel_heights). The one + # DESK-SET template: the methodology's region classification at + # part granularity. The desk is the sibling lab table (fascia rows + # 16-18, base 19-21); on it stand a monitor over its keyboard + # (left), a computer tower over a keyboard and mouse (middle), and + # a sheet of paper LYING FLAT (right). Upright parts anchor their + # drawn bottom row to the desk's top plane and wear their own drawn + # tops as lids; flat parts lie one voxel proud with drawn row = + # depth row -- the same 1:1 the tabletop itself is drawn with, so + # an object's height ON the drawing is its position ON the desk. + # The desk's own top is the one synthesized surface (the objects + # cover every pixel of it), continued from the sibling tables' + # pattern in the drawing's own shades. "lab_computers": dict( tiles=[ [91, 92, 93, 94], @@ -586,6 +592,16 @@ ], roof_rows=0, roof_back=0, roof_front=0, roof_cycle=(0, 0), slab=0, front_eave=0, ledge=None, tileset="gym", depth=2, + desk=dict(fascia=(16, 18), base=(19, 21)), + parts=[ + dict(kind="upright", x=(2, 13), top=(0, 2), facade=(3, 10), + depth=4), # the monitor + dict(kind="flat", x=(1, 13), rows=(11, 14)), # its keyboard + dict(kind="upright", x=(14, 21), top=(0, 3), facade=(4, 10), + depth=6), # the tower + dict(kind="flat", x=(14, 21), rows=(11, 14)), # keys + mouse + dict(kind="flat", x=(22, 30), rows=(1, 14)), # the paper + ], ), # F03: the empty north table beside it -- the starter table's band # table verbatim on a grid two tiles narrower (one placement). @@ -741,10 +757,122 @@ def profile(sp, t): # --------------------------------------------------------------- stage 3 -- +def build_desk_set(sp, pr, t): + """A desk with separately-classified objects on it (the `parts` list): + upright parts stand on the desk's top plane wearing their own drawn + tops as lids, flat parts lie one voxel proud at drawn row = depth row, + and the desk itself is the lab-table slab + base with a synthesized + lid (the objects cover every drawn pixel of the tabletop).""" + W, H, D = pr["W"], pr["H"], pr["D"] + inside = pr["inside"] + col, src = sp["col"], sp["src"] + ground = pr["ground"] + vox = {} + + def put(x, y, z, sx, sy): + vox[(x, y, z)] = (col[sy][sx], src[sy][sx]) + + shade_px = {} + for sy in range(H): + for sx in range(W): + if inside(sx, sy): + shade_px.setdefault(col[sy][sx], (sx, sy)) + + def interior(sx, sy, lo, hi): + if col[sy][sx] != BLACK: + return sx + step = 1 if sx < (lo + hi) // 2 else -1 + for d in range(1, 4): + nx = sx + step * d + if lo <= nx <= hi and inside(nx, sy) and col[sy][nx] != BLACK: + return nx + return sx + + f0, f1 = t["desk"]["fascia"] + b0, b1 = t["desk"]["base"] + plane = (b1 - b0 + 1) + (f1 - f0 + 1) # the desk's top plane + + # the base band, extruded exactly like every lab table's + for sy in range(b0, b1 + 1): + y = ground - 1 - sy + for sx in range(W): + if not inside(sx, sy): + continue + ix = interior(sx, sy, 0, W - 1) + for z in range(D): + put(sx, y, z, sx if z in (0, D - 1) else ix, sy) + for sx, sy in pr["recess"]: + if b0 <= sy <= b1: + vox.pop((sx, ground - 1 - sy, D - 1), None) + + # the slab: the fascia rows wrap every side; the lid is the one + # synthesized surface in the model -- the drawing never paints the + # tabletop (its objects cover it), so the lid continues the sibling + # tables' pattern in the drawing's own shades: black rim, white + # highlight courses along the north and west, grey field + for i, sy in enumerate(range(f0, f1 + 1)): + y = plane - 1 - i + for sx in range(W): + for z in range(D): + put(sx, y, z, sx, sy) + for sx in range(W): + for z in range(D): + if sx in (0, W - 1) or z in (0, D - 1): + shade = BLACK + elif sx == 1 or z == 1: + shade = WHITE + else: + shade = GREY + px = shade_px.get(shade) or shade_px[BLACK] + put(sx, plane - 1, z, px[0], px[1]) + + for p in t["parts"]: + x0, x1 = p["x"] + if p["kind"] == "flat": + r0, r1 = p["rows"] + for sy in range(r0, r1 + 1): + if not 0 <= sy < D: + continue + for sx in range(x0, x1 + 1): + if inside(sx, sy): + put(sx, plane, sy, sx, sy) + continue + tr0, tr1 = p["top"] + fr0, fr1 = p["facade"] + pd = p["depth"] + ytp = plane + (fr1 - fr0) + for sx in range(x0, x1 + 1): + # the lid: the part's drawn top laid across its depth from + # the back, last row continuing forward; the front lid row + # is the facade's own top row -- the drawn front-top edge + for z in range(pd): + sy = fr0 if z == pd - 1 else min(tr0 + z, tr1) + while sy <= tr1 and not inside(sx, sy): + sy += 1 + if sy > tr1 and not (z == pd - 1 and inside(sx, fr0)): + continue + put(sx, ytp, z, sx, fr0 if z == pd - 1 else sy) + # the body: facade rows anchored to the desk's top plane + for sy in range(fr0 + 1, fr1 + 1): + y = plane + (fr1 - sy) + if not inside(sx, sy): + continue + ix = interior(sx, sy, x0, x1) + for z in range(pd): + if z == pd - 1: + if (sx, sy) not in pr["recess"]: + put(sx, y, z, sx, sy) + else: + put(sx, y, z, sx if z == 0 else ix, sy) + return vox + + def build(sp, pr, t): """The voxel model. Order is load-bearing: walls, ledge, recesses, then the roof solid overwrites what it intersects and the walls are trimmed to the roof's underside.""" + if t.get("parts"): + return build_desk_set(sp, pr, t) W, H, D = pr["W"], pr["H"], pr["D"] inside, top, ytop = pr["inside"], pr["top"], pr["ytop"] col, src = sp["col"], sp["src"] @@ -869,12 +997,13 @@ def verify(vox, sp, pr, t): for (x, y, z), _ in vox.items(): assert y <= T(x), f"voxel pokes through the roof at {x},{y},{z}" - # A facade-only template (roof_rows == 0: the lab computer bank) has - # no roof band, so there is no surface to hold level or slope and no - # coverage to demand of it; the poke-through assert above and the - # facade asserts below are what it answers to. Everything with a roof - # band answers the full set. - if t["roof_rows"] == 0: + # A desk set answers its own asserts; a facade-only template (roof_rows + # == 0) has no roof band, so there is no surface to hold level or slope + # and no coverage to demand of it; everything with a roof band answers + # the full set. + if t.get("parts"): + verify_desk_set(vox, pr, t) + elif t["roof_rows"] == 0: # its one geometric intent: a straight extrusion, so each column # of the drawing is solid from its lowest voxel to its top. NOT # from the ground -- the drawing's base band is inset like every @@ -899,6 +1028,46 @@ def verify(vox, sp, pr, t): return shell +def verify_desk_set(vox, pr, t): + W, D = pr["W"], pr["D"] + f0, f1 = t["desk"]["fascia"] + b0, b1 = t["desk"]["base"] + plane = (b1 - b0 + 1) + (f1 - f0 + 1) + + # the slab is a full solid under everything on it + for x in range(W): + for z in range(D): + for y in range(b1 - b0 + 1, plane): + assert (x, y, z) in vox, f"slab hole at {x},{y},{z}" + + # each part stands where authored -- upright bodies solid from the + # desk to their own drawn top (bar the front layer, whose panes + # sink) -- and nothing stands anywhere else + tops = {} + for p in t["parts"]: + x0, x1 = p["x"] + if p["kind"] == "flat": + r0, r1 = p["rows"] + for x in range(x0, x1 + 1): + for z in range(max(r0, 0), min(r1, D - 1) + 1): + tops[(x, z)] = max(tops.get((x, z), 0), plane) + else: + fr0, fr1 = p["facade"] + ytp = plane + (fr1 - fr0) + for x in range(x0, x1 + 1): + for z in range(p["depth"]): + tops[(x, z)] = max(tops.get((x, z), 0), ytp) + if z == p["depth"] - 1: + continue + ys = [y for y in range(plane, ytp + 1) + if (x, y, z) in vox] + assert ys == list(range(ys[0], ys[0] + len(ys))) \ + if ys else True, f"part column hole at {x},{z}" + for (x, y, z) in vox: + assert y <= tops.get((x, z), plane - 1), \ + f"voxel above its part at {x},{y},{z}" + + def verify_roof(vox, pr, t): W, H, D = pr["W"], pr["H"], pr["D"] ytop, top, slab = pr["ytop"], pr["top"], t["slab"] From 47363b8d230046f8155efe0d04eeed66b80ecb3d Mon Sep 17 00:00:00 2001 From: DramaticShape Date: Sat, 1 Aug 2026 16:41:03 -0400 Subject: [PATCH 017/116] iterate version --- CHANGELOG.md | 61 ++++++++++ data/voxel_heights.lua | 82 ++++++++++++- lib/AntiAlias.lua | 244 +++++++++++++++++++++++++++++++++++++++ lib/Buildings.lua | 15 ++- lib/Structures.lua | 18 ++- lib/TileShape.lua | 7 ++ main.lua | 2 +- manifest.json | 2 +- tests/aa_shots.lua | 195 +++++++++++++++++++++++++++++++ tools/building_voxels.py | 39 ++++++- 10 files changed, 644 insertions(+), 21 deletions(-) create mode 100644 lib/AntiAlias.lua create mode 100644 tests/aa_shots.lua diff --git a/CHANGELOG.md b/CHANGELOG.md index f9ea089..5bb313c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,66 @@ # Changelog +## 1.4.1 + +### Added + +- **Furniture through the building pipeline.** The band-table voxelizer + that models whole buildings from their own drawings (lib/Buildings.lua) + now reads interior furniture too, and the first four drawings are in: + + - **F01, the starter-ball table in Oak's lab** -- the tabletop's 16 + drawn rows lay flat over a 16px plot (1:1, the first template that + never cycles), the black/#555/black edge band folds into the slab's + own rim, and the base extrudes with its corner feet. Six voxels + tall, exactly the drawn elevation. + - **F03, the empty north table beside it** -- the same band table on a + grid two tiles narrower. + - **F02, the lab's computer desk** -- the first DESK-SET template: the + drawing segments into PARTS, each classified by the surface it + depicts. The monitor and the computer tower stand upright on the + desk wearing their own drawn tops as lids; the keyboards and the + mouse lie flat in front of them; the sheet of paper on the right + lies flat across the desk. Flat parts keep the drawing's own rule -- + drawn row IS depth row, the same 1:1 the tabletop is drawn with -- + so an object's height on the drawing is its position on the desk. + The Hall of Fame's recording machine is this drawing tile for tile + on the GYM atlas, and models identically for free. + - **F04, the Center PC** -- the desk-set read again: a Mac-style unit + with its screen and drive slot in relief, standing at the back of a + low white-topped desk with its keyboard lying at the front edge. + Eleven Pokemon Centers, plus the Indigo Plateau lobby, whose MART + tileset shares the atlas. + + Two measurements had to stop being assumptions for furniture to fit + the pipeline: the GROUND LINE is now read off the drawing (a building + ends on the black threshold row it stands on; a table's legs stop two + rows short of theirs, and extruding against the grid floated them in + the air), and a template may name its PLOT (`depth`) when the matched + grid runs past it onto the walkable floor the legs merely stand on. + Both are identities for every existing building. + +- **The Center couch has a backrest.** The couch is drawn from above -- + back-and-arm strip down the west side, cushions and seams on the east + -- and rendered as one seat-high box. The new `backrest` class raises + the drawn back strip to 12px over the 8px seat, in every Center and + the Celadon Hotel. The man sitting on it keeps his seat: the figure + anchor now scans under his card for the tallest authored upright (his + cushion) instead of reading the corner tile, which is the backrest + now. + +### Changed + +- **Sprites ride at the height the art actually stands.** Class heights + can now be overridden per tileset (a tileset entry's `heights`), and + DOJO's lab tables use it: they are drawn 6px tall, not the default + table's 12, so the starter balls sit exactly on the modelled tabletop + -- and the volume-built north tables drop to the same height, keeping + every table in the room level. +- The Center PC's old rendering -- a 12px table box with the unit as a + flat standee on it -- retires wherever the F04 template stamps; the + pins stay only as the degradation path when the shape profile is + absent. + ## 1.4.0 ### Added diff --git a/data/voxel_heights.lua b/data/voxel_heights.lua index cbf4fda..97f52f6 100644 --- a/data/voxel_heights.lua +++ b/data/voxel_heights.lua @@ -110,6 +110,9 @@ return { bed = 7, stool = 8, counter = 8, + -- the raised back band of low seating (the Center couch's back and + -- arm strip): half again the 8px seat it rises over + backrest = 12, table = 12, desk = 24, prop = 16, @@ -705,14 +708,25 @@ return { -- one clean band, so the drawn front panel stands up and the -- counter top stays on top; at 12 they read as wall stubs counter = { 8, 10, 24, 25, 56, 90, 91, - -- and the lounge couch with the man sitting on it. - -- Same half-cell box: its bottom row (42/43, the front - -- base) stands up as the couch's front, and the three - -- rows above it -- cushion (38/39) and the man - -- (36/37 head, 52/53 face) -- ride the top face in + -- and the lounge couch's SEAT column with the man + -- sitting on it. Same half-cell box: its bottom row + -- (43, the front base) stands up as the couch's + -- front, and the rows above it -- cushion (39) and + -- the man (37 head, 53 face) -- ride the top face in -- drawn order, each exactly once. See the note below -- on why he cannot be stood upright. - 36, 37, 38, 39, 42, 43, 52, 53 }, + 37, 39, 43, 53 }, + -- The couch's WEST tile column: the drawing's left strip is the + -- couch's back and arm running north-south (the seat's cushions + -- and seams fill the east column), so it rises over the 8px seat + -- the way a couch back does instead of lying flush in the same + -- box. Per-tile granularity puts the drawn ~6px strip plus a + -- 2px sliver of cushion on the raised band -- invisible at tile + -- scale, and the alternative is no backrest at all. The figure + -- anchor scans for the tallest authored UPRIGHT under the man + -- (see Structures.buildFigure), so he keeps sitting at seat + -- height beside it. + backrest = { 36, 38, 42, 52 }, -- standing per-pixel props, black-outline segmented: the healing -- machines' screen tops (58/59/74/75, drawn above the pinned -- bodies so they stand ON them) and the PC (66/70/82/86), which @@ -3320,6 +3334,62 @@ return { }, }, + POKECENTER = { + -- F04: the PC in every Center's northeast corner (11 + -- placements; the Indigo Plateau lobby's twin is registered + -- under MART below). The lab desk-set read again: a Mac-style + -- unit drawn face-on -- white top band (rows 0-3), bezel, screen + -- and drive slot (4-14) -- standing at the back of a low desk + -- whose front face is rows 20-23; the drawn top around the unit + -- is WHITE, which is what `lid` carries. The keyboard rows 17-19 + -- are drawn below the desk's 16px top span, so the flat part's + -- `z` puts it at the desk's front edge. The old billboard pins + -- for these tiles (66/70/82/86, desk 9/88) stay as the + -- degradation path -- the claim neutralizes them wherever this + -- template stamps. + { + id = "center_pc", + tiles = { + { 66, 70 }, + { 82, 86 }, + { 9, 88 }, + }, + roofRows = 0, roofBack = 0, roofFront = 0, roofCycle = { 0, 0 }, + slab = 0, frontEave = 0, ledge = nil, depth = 2, + desk = { fascia = { 20, 21 }, base = { 22, 23 }, lid = "white" }, + parts = { + { kind = "upright", x = { 2, 13 }, top = { 0, 3 }, + facade = { 4, 14 }, depth = 6 }, -- the unit + { kind = "flat", x = { 2, 13 }, rows = { 17, 19 }, + z = 13 }, -- keyboard + }, + }, + }, + + MART = { + -- F04 again: the Indigo Plateau lobby's PC (cell 15,7) -- the + -- MART tileset shares the POKECENTER atlas, so this is the same + -- drawing tile for tile. Same part table as the POKECENTER entry + -- above. + { + id = "center_pc", + tiles = { + { 66, 70 }, + { 82, 86 }, + { 9, 88 }, + }, + roofRows = 0, roofBack = 0, roofFront = 0, roofCycle = { 0, 0 }, + slab = 0, frontEave = 0, ledge = nil, depth = 2, + desk = { fascia = { 20, 21 }, base = { 22, 23 }, lid = "white" }, + parts = { + { kind = "upright", x = { 2, 13 }, top = { 0, 3 }, + facade = { 4, 14 }, depth = 6 }, -- the unit + { kind = "flat", x = { 2, 13 }, rows = { 17, 19 }, + z = 13 }, -- keyboard + }, + }, + }, + GYM = { -- F02 again: the Hall of Fame's recording machine is the lab's -- computer desk drawing, tile for tile, on the GYM atlas (one diff --git a/lib/AntiAlias.lua b/lib/AntiAlias.lua new file mode 100644 index 0000000..30f8761 --- /dev/null +++ b/lib/AntiAlias.lua @@ -0,0 +1,244 @@ +-- Voxel world mode: anti-aliasing, by supersampling. +-- +-- Everything else in this mod is flat art blitted at whole pixels; this one +-- pass is real geometry seen through a perspective camera, and a polygon +-- edge that lands at an angle across the pixel grid is the one place in the +-- game where a hard stair-step is not a stylistic choice. A roof ridge, a +-- ledge lip, a tree's silhouette against the sky and the leaning card of a +-- character are all cut by an edge that has no reason to line up with +-- anything, and at the shallow rungs -- where the diorama reads most like a +-- photograph of a model -- they crawl as the camera drifts. +-- +-- SUPERSAMPLING, not MSAA and not a filter over the finished frame, for two +-- reasons that both come out of what the pass already is: +-- +-- MSAA would take the water with it. The reflections read the frame's own +-- DEPTH buffer as a texture (Voxel3D.beginWater), and a multisampled depth +-- attachment is not a thing a fragment shader in this dialect can sample. +-- The row would have quietly switched the other row off. +-- +-- An edge filter (FXAA and its relatives) works from the finished colour +-- alone, and would be GUESSING where the edges are out of one sample per +-- pixel -- inventing detail it never rendered, and unable to tell a +-- geometry edge from the boundary between two texels of a tileset. +-- +-- Rendering the pass larger and folding it back down has neither problem: +-- the depth buffer stays an ordinary texture, every pass in the frame keeps +-- working in the canvas it was handed, and the fold is an average of samples +-- that were each rendered honestly. It antialiases everything at once -- +-- geometry, the alpha-cut outline of a sprite card, the wireframe, the +-- water's ray march -- because none of them know it is happening. +-- +-- Be clear about what "everything" means: the artwork softens too. A tileset +-- texel out here is not a screen pixel, it is a quad in a perspective view, +-- and its boundary crosses the pixel grid at the same arbitrary angle a roof +-- ridge does -- so the fold averages across it exactly as it averages across +-- the ridge. That is what an honest extra sample says about that pixel, and +-- it is also the trade the row IS: the diorama comes out smoother, not +-- sharper. Which is why this is a row and not something that is simply on. +-- +-- What it costs is pixels, which is the whole of why this is a row and not +-- something that is simply on: 2X is half again as many in each direction, +-- 4X is twice, and the scene pass is the most expensive thing in the frame. + +-- the mod namespace (see main.lua): V.require loads a sibling module +local V = ... + +local ModSetting = V.require("ModSetting") + +local AntiAlias = {} + +-- the key under options.modOptions.DRAMATIC_SHAPE, shared by the row in +-- OPTIONS and the mod manager's own settings page for this mod +AntiAlias.KEY = "aa" +AntiAlias.LABEL = "AA" + +-- The ladder is SAMPLES PER DISPLAY PIXEL, which is how an AA setting reads +-- everywhere else, and the canvas scale each rung costs is its square root: +-- 2 samples is a canvas 1.41x wider and taller, 4 is one exactly twice the +-- size. OFF is the default -- this is a cost knob, and a mod should not +-- quietly spend four times the fill rate of the machine it lands on. +AntiAlias.setting = ModSetting.new(AntiAlias.KEY, AntiAlias.LABEL, + { 0, 2, 4 }, { "OFF", "2X", "4X" }) + +-- The scale the pass currently open was actually expanded by (see expand). +-- 1 while there is no supersampling in force, which is also what every +-- reader gets on a frame that never opened a pass at all. +local live = 1 + +function AntiAlias.samples() + return tonumber(AntiAlias.setting:get()) or 0 +end + +-- What the row ASKS for. The scale in force is `factor()`, which is this +-- clamped to what the driver will actually allocate. +local function wanted() + local n = AntiAlias.samples() + if n <= 1 then return 1 end + return math.sqrt(n) +end + +-- The biggest canvas this driver admits to, or nil where it will not say. +-- A 4K window at 4X asks for 7680 across, which is past the limit on plenty +-- of hardware and every phone -- and a refused canvas is not a softer +-- diorama, it is beginScene returning false and the whole mode falling back +-- to the flat 2D path. +local function textureLimit() + if not (love.graphics and love.graphics.getSystemLimits) then return nil end + local ok, limits = pcall(love.graphics.getSystemLimits) + return (ok and limits and limits.texturesize) or nil +end + +-- The size to render `w` x `h` display pixels at, and the size everything +-- inside the pass then measures itself in. +-- +-- Also where `live` is set, which is why this must be called once per pass +-- immediately before beginScene: the wireframe's line width and the FX +-- overlay's sprite scale are both quoted in DISPLAY pixels and have to be +-- multiplied up into canvas ones, and the honest multiplier is the one this +-- returned rather than the one the row asked for. +function AntiAlias.expand(w, h) + local s = wanted() + local max = textureLimit() + if max and max > 0 then + -- clamped rather than abandoned: a window too big for 4X can usually + -- still carry some of it, and half a rung of smoothing is worth more + -- than a row that silently does nothing at that size + s = math.min(s, max / math.max(1, w), max / math.max(1, h)) + end + if not (s > 1.01) then + live = 1 + return w, h + end + local ew, eh = math.floor(w * s + 0.5), math.floor(h * s + 0.5) + live = ew / math.max(1, w) + return ew, eh +end + +-- The scale the open pass was expanded by; 1 when it was not. +function AntiAlias.factor() + return live +end + +-- ------- the fold +-- +-- One target per pass (the free-roam world and the battle's arena are alive +-- at different moments but reallocating on every battle entry and exit is +-- what the scene canvas's own slots exist to avoid), reallocated only when +-- that pass's DISPLAY size changes -- a window resize, or the row itself +-- moving, which changes the source and not this. + +local targets = {} + +local function targetFor(slot, w, h) + local t = targets[slot] + if not (t and t.w == w and t.h == h) then + local ok, c = pcall(love.graphics.newCanvas, w, h) + if not (ok and c) then return nil end + -- nearest, like the canvas it stands in for: this one is composited a + -- canvas pixel to a display pixel, and the smoothing has already happened + pcall(c.setFilter, c, "nearest", "nearest") + if t and t.canvas and t.canvas.release then pcall(t.canvas.release, t.canvas) end + t = { canvas = c, w = w, h = h } + targets[slot] = t + end + return t.canvas +end + +-- The box filter, and the whole of why it is a shader rather than a scaled +-- draw with linear filtering on. +-- +-- The void this pass renders into is cleared to a TRANSPARENT BLACK, and at +-- the rungs below FULL a good deal of the frame is still that. Averaging a +-- straight-alpha edge against it drags the result toward black as well as +-- toward transparent, and then the engine's own composite multiplies by that +-- alpha a second time -- so every silhouette against the void would come out +-- ringed with a dark fringe, which is exactly the artefact the row is here to +-- remove. +-- +-- So the taps are premultiplied before they are averaged and divided back out +-- after, which is the arithmetic that makes an edge pixel mean "half covered +-- by this colour" instead of "covered by half of this colour". +-- +-- Four taps, half a source texel from the destination centre. At 4X those +-- land dead on the four texel centres the destination pixel covers, so it is +-- an exact 2x2 box; at 2X the source grid does not divide, and the bilinear +-- fetch under each tap widens the box a little rather than missing samples. +local SHADER = [[ + uniform vec2 tap; // half a SOURCE texel, in uv + vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) { + vec4 a = Texel(tex, tc + vec2(-tap.x, -tap.y)); + vec4 b = Texel(tex, tc + vec2( tap.x, -tap.y)); + vec4 c = Texel(tex, tc + vec2(-tap.x, tap.y)); + vec4 d = Texel(tex, tc + vec2( tap.x, tap.y)); + float al = (a.a + b.a + c.a + d.a) * 0.25; + if (al <= 0.0) return vec4(0.0); + vec3 sum = a.rgb * a.a + b.rgb * b.a + c.rgb * c.a + d.rgb * d.a; + return vec4(sum * 0.25 / al, al) * color; + } +]] + +local shader = nil -- nil = untried, false = unavailable + +local function getShader() + if shader == nil then + local ok, sh = pcall(love.graphics.newShader, SHADER) + shader = (ok and sh) or false + end + return shader or nil +end + +-- Fold `canvas` down to `w` x `h` and hand back the result. +-- +-- Returns the input untouched when there is nothing to fold -- the row is +-- off, or the canvas already IS that size -- so a caller can run it +-- unconditionally, and so can a headless test run. A target that would not +-- allocate is the same answer: the pass is lost either way if this hands back +-- something the wrong size, so it hands back the input and the frame draws at +-- the size it was rendered. +function AntiAlias.resolve(canvas, w, h, slot) + if not canvas then return canvas end + local ok, cw, ch = pcall(canvas.getDimensions, canvas) + if not ok or (cw == w and ch == h) then return canvas end + local target = targetFor(slot or "world", w, h) + if not target then return canvas end + + local sh = getShader() + local prevBlend, prevAlpha = love.graphics.getBlendMode() + -- the scene canvas filters nearest for its usual 1:1 blit; the taps want + -- linear, put back below so every other pass finds what it expects + pcall(canvas.setFilter, canvas, "linear", "linear") + love.graphics.setColor(1, 1, 1, 1) + -- replace, not alpha-blend: this is an image-processing copy, and the alpha + -- the shader worked out has to land as itself rather than be composited + -- against whatever the target held + love.graphics.setBlendMode("replace", "premultiplied") + if sh then + love.graphics.setShader(sh) + pcall(sh.send, sh, "tap", { 0.5 / cw, 0.5 / ch }) + end + local drew = pcall(function() + love.graphics.setCanvas(target) + love.graphics.clear(0, 0, 0, 0) + love.graphics.draw(canvas, 0, 0, 0, w / cw, h / ch) + end) + love.graphics.setCanvas() + love.graphics.setShader() + love.graphics.setBlendMode(prevBlend or "alpha", prevAlpha) + pcall(canvas.setFilter, canvas, "nearest", "nearest") + return drew and target or canvas +end + +-- Drop the GPU objects (window resize, hot reload). +function AntiAlias.invalidate() + for slot, t in pairs(targets) do + if t.canvas and t.canvas.release then pcall(t.canvas.release, t.canvas) end + targets[slot] = nil + end +end + +function AntiAlias.row() + return AntiAlias.setting:row() +end + +return AntiAlias diff --git a/lib/Buildings.lua b/lib/Buildings.lua index d378ec7..b07c913 100644 --- a/lib/Buildings.lua +++ b/lib/Buildings.lua @@ -378,9 +378,10 @@ local function deskSetModel(sp, pr, t) for z = 0, D - 1 do put(sx, y, z, sy * W + sx) end end end + local field = t.desk.lid == "white" and WHITE or GREY for sx = 0, W - 1 do for z = 0, D - 1 do - local shade = GREY + local shade = field if sx == 0 or sx == W - 1 or z == 0 or z == D - 1 then shade = BLACK elseif sx == 1 or z == 1 then @@ -395,10 +396,16 @@ local function deskSetModel(sp, pr, t) Budget.tick() local x0, x1 = p.x[1], p.x[2] if p.kind == "flat" then - for sy = p.rows[1], p.rows[2] do - if sy >= 0 and sy < D then + -- drawn row = depth row by default; `z` renames the origin when + -- the flat sits below the desk's own drawn top span (the Center + -- PC's keyboard) + local r0 = p.rows[1] + local z0 = p.z or r0 + for sy = r0, p.rows[2] do + local z = z0 + (sy - r0) + if z >= 0 and z < D then for sx = x0, x1 do - if inside[sy * W + sx] then put(sx, plane, sy, sy * W + sx) end + if inside[sy * W + sx] then put(sx, plane, z, sy * W + sx) end end end end diff --git a/lib/Structures.lua b/lib/Structures.lua index 0b5262a..2e3e70c 100644 --- a/lib/Structures.lua +++ b/lib/Structures.lua @@ -2253,14 +2253,22 @@ local function buildFigure(S, map, fig, tx, ty, perRow) -- He stands ON the furniture he was drawn into -- the same lift a pinned -- prop above a pinned box takes (see buildObject), and gated the same -- way: a thing set down on furniture occupies a BLOCKED cell, while a - -- seat you merely walk up to is in a walkable one. + -- seat you merely walk up to is in a walkable one. The row under his + -- card is SCANNED for the tallest authored upright rather than read at + -- its west corner: the corner tile can be furniture that is not his + -- seat (the couch's raised backrest column stands there, `top` art and + -- taller than the cushion he actually sits on). local baseY = 0 - local bs = S.shapeAt[keyOf(tx, ty + fig.h)] local blocked = not map:isWalkableCell(math.floor(tx / 2), math.floor((ty + fig.h - 1) / 2)) - if blocked and bs and bs.authored and bs.art == "upright" - and (bs.h or 0) > 0 then - baseY = bs.h + if blocked then + for dx = 0, fig.w - 1 do + local bs = S.shapeAt[keyOf(tx + dx, ty + fig.h)] + if bs and bs.authored and bs.art == "upright" + and (bs.h or 0) > baseY then + baseY = bs.h + end + end end local atlasW = map.tileset.imageWidth or 128 diff --git a/lib/TileShape.lua b/lib/TileShape.lua index f36f87d..31e5916 100644 --- a/lib/TileShape.lua +++ b/lib/TileShape.lua @@ -83,6 +83,10 @@ local FALLBACK_HEIGHTS = { bed = 7, stool = 8, counter = 8, + -- the raised back band of low seating: the Center couch's west strip + -- is drawn from above like the rest of the couch, but depicts the + -- back and arm rising over the 8px seat + backrest = 12, table = 12, desk = 24, prop = 16, @@ -145,6 +149,9 @@ local ART = { -- profile archetype Structures builds real steps for -- rising flights -- for stairs leading up, sunken stairwells for stairs leading down bed = "top", + -- a backrest's art is the couch seen from above, so like the bed it + -- rides the top face of its taller box + backrest = "top", stool = "billboard", -- half-cell furniture: a service counter, a low couch. One 8px band, -- so exactly the drawing's bottom row stands up as the front and diff --git a/main.lua b/main.lua index 6ca5f8a..01aaf0f 100644 --- a/main.lua +++ b/main.lua @@ -890,7 +890,7 @@ mod.hooks:wrap("world.tod", function(next, tod, ctx) return DayNight.tod() end) -mod.exports.version = "1.4.0" +mod.exports.version = "1.4.1" -- exposed so a companion mod can pin its own tiles' shapes or read the -- camera without reaching into this mod's file layout mod.exports.lib = V diff --git a/manifest.json b/manifest.json index 157448b..b2382d4 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "DRAMATIC_SHAPE", "name": "Dramatic Shape Voxel Mod", - "version": "1.4.0", + "version": "1.4.1", "api": 2, "entry": "main.lua", "profile": "content", diff --git a/tests/aa_shots.lua b/tests/aa_shots.lua new file mode 100644 index 0000000..dcd484a --- /dev/null +++ b/tests/aa_shots.lua @@ -0,0 +1,195 @@ +-- Driver: one scene, once per rung of the AA row. +-- +-- The AA row is the one setting in this mod whose whole effect is a pixel +-- wide, so it is also the one that cannot be judged from a description. This +-- renders the SAME frame at each rung and writes one PNG per rung; put two of +-- them side by side, magnified, and the row is either doing something or it +-- is not. +-- +-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/aa_shots.lua \ +-- SHOT_DIR= lovec.exe . +-- +-- knobs (env): +-- SHOT_DIR output directory (created if missing) (default "shots/aa") +-- AA_MAP map id (default VIRIDIAN_CITY) +-- AA_SPOT "x,y[,facing]" (default 20,26,up) +-- AA_RUNG the voxel camera rung (default 5, the 75 one) +-- +-- The scene defaults to a town at the LOW camera on purpose: roof ridges, the +-- diagonal of a fence and a tree's silhouette against the sky are the edges +-- that stair-step, and 75 degrees is the rung that puts the most of them at an +-- angle to the pixel grid. +-- +-- Determinism matters here for the same reason it does in voxel_shots_ab: the +-- three shots differ ONLY by the row under test, or comparing them means +-- nothing. The clock is pinned, the animated tile slots are frozen, the +-- townsfolk are stopped where they stand, and the tilt-shift is held at zero +-- (a gaussian over the frame would smear away the very edges being looked at). +-- +-- Nothing here writes the player's options: the row is moved with +-- ModSetting:sync, which moves the cached index and persists nothing. +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pipelines = require("src.render.Pipelines") + local OverworldState = require("src.world.OverworldController") + + local ROOT = os.getenv("SHOT_DIR") or "shots/aa" + + local handle = game.mods.exports["DRAMATIC_SHAPE"] + if not (handle and handle.lib) then + print("[aa] DRAMATIC_SHAPE mod not loaded -- nothing to shoot") + return + end + local V = handle.lib + local DayNight = V.require("DayNight") + local AntiAlias = V.require("AntiAlias") + local ChunkMesher = V.require("ChunkMesher") + local Voxel = V.require("VoxelState") + local ShadowMap = V.require("ShadowMap") + + local MAP = os.getenv("AA_MAP") or "VIRIDIAN_CITY" + local SPOT = os.getenv("AA_SPOT") or "20,26,up" + local RUNG = math.floor(tonumber(os.getenv("AA_RUNG")) or 5) + local sx, sy, sf = SPOT:match("^(%-?%d+),%s*(%-?%d+),?%s*(%a*)$") + sx, sy = tonumber(sx) or 20, tonumber(sy) or 26 + if sf == "" then sf = "up" end + + OverworldState.rollEncounter = function() return nil end + + local NPC = require("src.world.NPC") + if not NPC.dramaticShapeAaFreeze then + local inner = NPC.update + function NPC:update(...) + self.frozen = true + return inner(self, ...) + end + NPC.dramaticShapeAaFreeze = true + end + pcall(love.math.setRandomSeed, 20260801) + + local TileRenderer = require("src.render.TileRenderer") + TileRenderer.tick = function() end + TileRenderer.animFrame = function() return 0 end + + pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null') + pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul') + + local Zoom = require("src.render.Zoom") + pcall(function() + game.save.options.zoom = 1 + Zoom.applyOptions(game.save.options) + end) + + local function cameraStill() + local o = game.overworld + local c = o and o.camera + if not c then return true end + local lx, ly, held = nil, nil, 0 + for _ = 1, 300 do + if c.x == lx and c.y == ly then + held = held + 1 + if held >= 10 then return true end + else + held = 0 + lx, ly = c.x, c.y + end + U.wait(1) + end + return false + end + + -- The camera PITCH, which is the one that caught this driver out. The tween + -- runs on wall-clock dt and Voxel.t reaching 1 is not the same instant the + -- angle stops moving, so the first shot of a run came out at 67 degrees + -- while the two after it were at 75 -- three frames that differ by the + -- camera, in a comparison whose entire subject is a pixel. + local function angleStill() + local last, held = nil, 0 + for _ = 1, 600 do + if Voxel.angle == last then + held = held + 1 + if held >= 10 then return true end + else + held = 0 + last = Voxel.angle + end + U.wait(1) + end + return false + end + + local function settle() + for _ = 1, 900 do + if ChunkMesher.pending() == 0 then break end + U.wait(1) + end + for _ = 1, 300 do + if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end + U.wait(1) + end + angleStill() + cameraStill() + -- the sun map is only redrawn when its inputs move, and the AA row is not + -- one of them -- so force one pass at the settled camera rather than + -- comparing a frame against a map fitted a few hundredths of a pixel ago + if ShadowMap.forget then ShadowMap.forget() end + U.wait(20) + end + + DayNight.setting:sync("day") + U.teleport(game, MAP, sx, sy, sf) + Pipelines.setLevel("voxel", RUNG) + Pipelines.setLevel("tiltshift", 0) + + -- Warm up before the FIRST shot, not just between them. + -- + -- Neighbour maps are requested from inside the render itself + -- (VoxelScene.prefetch), so an empty build queue right after a teleport + -- means "nothing has been asked for yet", not "everything is here". The + -- first capture of a run came out with the map beyond Viridian missing -- + -- a whole tree line absent from one frame of a three-way comparison, which + -- looks exactly like the row under test doing something enormous. Settling + -- twice lets the first render request the neighbourhood and the second + -- drain it. + settle() + settle() + + local shots, missed = 0, 0 + for _, samples in ipairs({ 0, 2, 4 }) do + AntiAlias.setting:sync(samples) + settle() + -- AA_TRACE=1 prints the state each shot was taken in. When two shots of a + -- run disagree by more than the row could account for, this is what says + -- which input moved -- it is how the camera-tween and the neighbour-mesh + -- settles above were both found. + if os.getenv("AA_TRACE") == "1" then + local Voxel3D = V.require("Voxel3D") + local o = game.overworld + local cw, chh = Voxel3D.size() + print(("[aa] trace samples=%d angle=%.6f fov=%.6f cell=%.4f canvas=%dx%d cam=(%.3f,%.3f) eye=(%.2f,%.2f,%.2f) factor=%.4f") + :format(samples, Voxel.angle or -1, Voxel3D.fovY or -1, + Voxel3D.cell or -1, cw or 0, chh or 0, + o and o.camera and o.camera.x or -1, + o and o.camera and o.camera.y or -1, + (Voxel3D.eye or {})[1] or 0, (Voxel3D.eye or {})[2] or 0, + (Voxel3D.eye or {})[3] or 0, AntiAlias.factor())) + end + local path = ("%s/aa_%d.png"):format(ROOT, samples) + game.capturePath = path + U.wait(6) + local f = io.open(path, "rb") + if f then + f:close() + shots = shots + 1 + print(("[aa] %s samples=%d"):format(path, samples)) + else + missed = missed + 1 + print("[aa] capture did not reach disk: " .. path) + end + end + -- left where it was found, so a run cannot leak a rung into the next one + AntiAlias.setting:sync(0) + + print(("[aa] %d shots into %s (%d failed to reach disk)") + :format(shots, ROOT, missed)) +end diff --git a/tools/building_voxels.py b/tools/building_voxels.py index 6cb00b7..1ca5fd2 100644 --- a/tools/building_voxels.py +++ b/tools/building_voxels.py @@ -614,6 +614,31 @@ roof_rows=19, roof_back=16, roof_front=0, roof_cycle=(2, 13), slab=3, front_eave=0, ledge=None, tileset="gym", depth=2, ), + # F04: the Pokemon Center's PC -- every Center's northeast corner + # (11 placements) plus the Indigo Plateau lobby, whose MART tileset + # shares this atlas. The lab desk-set read again: a + # Mac-style unit drawn face-on -- white top band (rows 0-3), bezel, + # screen and drive slot (4-14) -- standing at the back of a low + # desk whose front face is rows 20-23 and whose drawn top (the + # white sliver of row 15 and the margins beside the unit) names the + # lid shade. The keyboard rows 17-19 lie BELOW the desk's 16px top + # span, so the flat part carries an authored z origin: it lies at + # the desk's front edge, in front of the unit. + "center_pc": dict( + tiles=[ + [66, 70], + [82, 86], + [9, 88], + ], + roof_rows=0, roof_back=0, roof_front=0, roof_cycle=(0, 0), + slab=0, front_eave=0, ledge=None, tileset="pokecenter", depth=2, + desk=dict(fascia=(20, 21), base=(22, 23), lid="white"), + parts=[ + dict(kind="upright", x=(2, 13), top=(0, 3), facade=(4, 14), + depth=6), # the unit + dict(kind="flat", x=(2, 13), rows=(17, 19), z=13), # keyboard + ], + ), # B23: the Victory Road entrance on Route 23: a rock face with two # barred doors. The roof band is the pale cliff top seen from above. "victory_road_gate": dict( @@ -815,6 +840,7 @@ def interior(sx, sy, lo, hi): for sx in range(W): for z in range(D): put(sx, y, z, sx, sy) + field = WHITE if t["desk"].get("lid") == "white" else GREY for sx in range(W): for z in range(D): if sx in (0, W - 1) or z in (0, D - 1): @@ -822,7 +848,7 @@ def interior(sx, sy, lo, hi): elif sx == 1 or z == 1: shade = WHITE else: - shade = GREY + shade = field px = shade_px.get(shade) or shade_px[BLACK] put(sx, plane - 1, z, px[0], px[1]) @@ -831,11 +857,15 @@ def interior(sx, sy, lo, hi): if p["kind"] == "flat": r0, r1 = p["rows"] for sy in range(r0, r1 + 1): - if not 0 <= sy < D: + # drawn row = depth row by default; `z` renames the + # origin when the flat sits below the desk's own drawn + # top span (the Center PC's keyboard) + z = p.get("z", r0) + (sy - r0) + if not 0 <= z < D: continue for sx in range(x0, x1 + 1): if inside(sx, sy): - put(sx, plane, sy, sx, sy) + put(sx, plane, z, sx, sy) continue tr0, tr1 = p["top"] fr0, fr1 = p["facade"] @@ -1048,8 +1078,9 @@ def verify_desk_set(vox, pr, t): x0, x1 = p["x"] if p["kind"] == "flat": r0, r1 = p["rows"] + z0 = p.get("z", r0) for x in range(x0, x1 + 1): - for z in range(max(r0, 0), min(r1, D - 1) + 1): + for z in range(max(z0, 0), min(z0 + r1 - r0, D - 1) + 1): tops[(x, z)] = max(tops.get((x, z), 0), plane) else: fr0, fr1 = p["facade"] From 91cc2d6f51bc50ba5f8bf5ab4c6092dd1c4ad13b Mon Sep 17 00:00:00 2001 From: DramaticShape Date: Sat, 1 Aug 2026 23:48:23 -0400 Subject: [PATCH 018/116] bills, pewter gym, register, celadon mansion, tables, garbage cans --- data/voxel_heights.lua | 979 ++++++++++++++++++++++--- lib/Buildings.lua | 544 +++++++++++--- lib/Structures.lua | 1255 +++++++++++++++++++++++++++++---- lib/TileShape.lua | 213 +++++- lib/Water.lua | 70 +- tests/dramatic_shape_test.lua | 35 +- tests/voxel_figure_test.lua | 166 +++++ tools/building_voxels.py | 1005 +++++++++++++++++++++++--- 8 files changed, 3811 insertions(+), 456 deletions(-) diff --git a/data/voxel_heights.lua b/data/voxel_heights.lua index 97f52f6..2c5ae61 100644 --- a/data/voxel_heights.lua +++ b/data/voxel_heights.lua @@ -58,12 +58,43 @@ -- collapses onto a one-cell-deep box at its -- full drawn height; back rows become hidden -- floor, and an unpinnable trim row above --- becomes the cap +-- becomes the cap. Its FRONT carries the +-- measured pane relief -- every non-black +-- region the drawing seals behind its own +-- black frame sinks one voxel, the same rule +-- lib/Buildings.lua recesses a facade with, +-- so the books stand in the shelf instead of +-- being painted on it. A tileset that +-- borrows the collapse for something that is +-- not a shelf says `bookcase_relief = false` +-- cylinder / canopy round scenery (tree canopies): a voxel hull +-- / stump cut from the art's own darkest-pixel outline, +-- round in depth -- one 16px cell, a 2x2-cell +-- group, or with the drawn top read as a cut face +-- can that hull cut at both ends, hollowed and +-- tapered: an OPEN bin standing on a floor +-- (Vermilion Gym's trash cans). The drawn mouth +-- ellipse projects across the round top and down +-- the well, the drawn base ellipse is ground +-- contact rather than body, the top rows keep a +-- wall at each end of their chord and lose the +-- middle, and the plan narrows toward the floor +-- (can_cap / can_base in art rows; can_height, +-- can_well, can_taper in voxels) +-- planter the same hull for a round drawing stacked TWO +-- cells high on ONE cell of plot (the Centers' +-- potted plants): it stands in the lower cell and +-- the upper cell is the height it overhangs -- billboard / prop a prop drawn face-on: a standing per-pixel -- cutout, black-outline segmented; drawn above -- a pinned box it stands ON that box (a -- monitor on its desk). prop is a second pool -- so two touching cutouts stay separate +-- bike the same cutout at TWO voxels: a vehicle drawn +-- side-on is a LINE drawing, and the air inside +-- its frame is what makes it read as one -- any +-- thicker and neighbouring strokes close those +-- gaps with their own side faces -- -- A tileset entry may also carry `prop_ground` (not a class): a map of -- prop tile id -> ground tile id naming the tile painted under that @@ -83,7 +114,20 @@ -- and nothing automatic can either when the figure has no background -- margin to flood from and wears the same shades as what it sits on. -- The Pokemon Center's seated man is the case; see POKECENTER below for --- the format. +-- the format. A figure entry that states a `depth` is not a person and +-- stands as a per-pixel SOLID instead of a flat card -- the Marts' cash +-- register, a machine set down on a counter (`thin` gives its top rows a +-- thickness of their own, for the part of a drawing that is paper). +-- +-- And `mounted` (not a class either): the same authored-mask escape for a +-- thing painted INTO a WALL band, stood proud of the panel as a thin +-- per-pixel slab instead of standing on its feet as a sprite card. Where +-- a figure is a person and reads face-on from any angle, a mounted object +-- is an object drawn side-on and holds the wall's own plane; and it keeps +-- its DRAWN elevation, so a thing hung clear of the floor stays hung. +-- The Bike Shop's two wall bicycles are the case -- and the mask there is +-- measured rather than drawn, by flooding the panel tile's own stripe out +-- from behind them; see CLUB below. -- -- Whole BUILDINGS are not tile pins -- one drawing packs a roof seen from -- above, a facade seen face-on and sloped ends as diagonal silhouettes, @@ -117,6 +161,13 @@ return { desk = 24, prop = 16, cutout = 16, + -- a vehicle drawn side-on (the Bike Shop's bicycles): a standee like + -- the pools above, two voxels thin so the air inside its frame stays + -- air (see Structures' PINNED_DEPTH) + bike = 16, + -- a round drawing stacked two cells high on one cell of plot (the + -- Centers' potted plants): 32px of hull standing in its lower cell + planter = 32, relief = 3, bookcase = 32, stair_e = 16, @@ -195,12 +246,8 @@ return { -- black-outline segmented -- backgrounds flood away, the pixels -- the outline encloses stay. Everything stands on the gyms' main -- floor tile ($11), which is also Bruno's floor. - -- (The round boulder drawn beside some statues, $07/$08/$17/$18, - -- is NOT pinned: it also tiles wall-to-wall as Pewter's rock rows, - -- which are scenery for the detector, not a lone prop. Probed: - -- the repeat-aware scenery path already gives every one of them a - -- single 16px course in Pewter's and Bruno's rock walls alike, so - -- a pin would only restate the derived answer.) + -- (The boulders, $07/$08/$17/$18, are pinned round -- see the + -- `cylinder` pool below.) -- -- The rest of the tileset is the ten rooms' own furniture: the six -- badge gyms it serves (Pewter, Cerulean, Vermilion, Celadon, @@ -270,13 +317,100 @@ return { -- from the darkest-pixel outline -- the same treatment the -- overworld's tree canopies take, and per-CELL, so a hedge row -- becomes a row of bushes instead of one boxed monolith. - cylinder = { 44, 45, 46, 47 }, - -- The statues, the cans, and Celadon's three little trees - -- ($40/$41 canopy over $50/$51 trunk). A trunk is not round, so - -- the tree cannot be a ball like the shrubs beside it -- it takes - -- the thin standee pool every interior plant takes, which is also - -- a pool apart from the cylinders it touches. - prop = { 2, 56, 18, 19, 11, 12, 27, 28, + -- + -- The rock gyms' boulder ($07/$08 over $17/$18) is the same + -- reading: one cell is one rock, drawn as a lit dome -- white + -- highlight up the north-west, mid-grey body, black-and-grey + -- rubble dither filling the rest -- with the FLOOR showing + -- through all four corners, which is what makes a run of them + -- read as a pile of separate stones rather than a wall (tile + -- them and the gaps between four rocks draw a grey diamond). + -- Left derived it fell to the repeat-aware scenery path, which + -- extruded the whole drawing as one 16px course: Pewter's and + -- Bruno's rock rows came out as square bars wearing a boulder + -- texture in relief -- the extruded picture. Round-pinned, + -- each cell is a hull whose plan view is its own drawn width + -- profile turned in depth: a dome 16 wide and 16 tall, full + -- width from the drawn shoulder down and tapering over the top + -- five rows exactly where the art tapers, and the corner + -- diamonds open up between them the way the drawing has them. + -- It stays 16px, so nothing that stands on or beside a rock + -- moves. The perimeter rows take the same model as the maze + -- clusters -- one drawing, one rock, everywhere. + -- Scanned: across all 222 maps these four tiles occur 87 times + -- on this atlas and every one of them anchors a whole boulder + -- cell (87 tile-$07 hits, 87 grid matches), in exactly two maps + -- -- PEWTER_GYM's walls and rock maze, and BRUNOS_ROOM's + -- clusters. DOJO shares gym.png but places none of them, so + -- the pin is not copied there. + cylinder = { 44, 45, 46, 47, + 7, 8, 23, 24 }, + -- Vermilion Gym's trash cans ($0B/$0C over $1B/$1C), the switch + -- puzzle's fifteen cans plus the sixteenth beside the leader's + -- platform. An open galvanised bin in the 3/4 view, and its plan is + -- measured: the silhouette runs straight down both flanks for art + -- rows 4-10 and is 11px wide there, so the can is 11 across -- and + -- being round in plan, 11 DEEP. Above row 4 is the MOUTH seen from + -- above (the drawn ring is 9x5, a circle flattened to 55%, which is + -- what fixes it as a top view rather than a face-on dome); below row + -- 10 is the base circle's front arc, ground contact, with the + -- drawing's own $555 halo one pixel outside it as the grounding + -- shadow. + -- + -- Left in the thin standee pool the can was a flat disc on edge -- + -- fifteen coins standing in a row. Pinned a plain `cylinder` it + -- revolves every drawn row, bottom arc included, and comes out a + -- barrel balanced on a three-voxel stem. `can` is the hull cut at + -- both ends and hollowed: the mouth projects across the round top, + -- the base rows are ground rather than body, and the top can_well + -- voxel rows keep a wall at each end of every chord and lose their + -- middle, so the bin is open and you look down into it. + -- + -- can_cap and can_base are the two ellipses in art rows and come off + -- the pixels. The other three do NOT, and are the numbers taste + -- moves: + -- + -- can_height 9 Un-projected strictly the drawing states a squat + -- drum barely two rows of straight side tall, + -- because the GB artist spent most of a 16px cell + -- on the opening -- but a bin is taller than it is + -- wide and the flat game reads as one, the drawing + -- being 14px tall beside a 16px player. The lowest + -- surviving body row (art row 10: black rim, shaded + -- flank, lit face) is repeated up to this, the + -- plainest continuation of the material drawn. + -- `heights` must follow it so anything riding a can + -- lands on the rim. + -- can_well 5 How far down the mouth is hollowed. The drawing + -- paints an opening and cannot say how deep. + -- can_taper 4 Voxels of DIAMETER the base loses -- 11 at the + -- rim to 7 on the floor, stepped twice over the + -- height. The drawing's base arc pulls in to 9px + -- on its own, so the direction is drawn; the amount + -- is not. 2 is one barely-visible step, 4 reads as + -- a cone. + -- + -- Scanned: the four ids occur as this grid 16 times on the GYM atlas + -- and only in VERMILION_GYM -- cells (1/3/5/7/9, 7), the same five + -- on rows 9 and 11, and (6,1). DOJO shares gym.png and places none, + -- so the pin is not copied there. The same four ids form the same + -- grid on eight OTHER atlases (the Bike Shop's crates, the overworld + -- roofs, the Centers' counters, ...) for 142 more hits; those are id + -- collisions between tilesets, and a pin is per tileset id, so they + -- are none of this entry's business. + can = { 11, 12, 27, 28 }, + can_cap = 9, + can_base = 4, + can_height = 9, + can_well = 5, + can_taper = 4, + heights = { can = 9 }, + -- The statues and Celadon's three little trees ($40/$41 canopy over + -- $50/$51 trunk). A trunk is not round, so the tree cannot be a + -- ball like the shrubs beside it -- it takes the thin standee pool + -- every interior plant takes, which is also a pool apart from the + -- cylinders it touches. + prop = { 2, 56, 18, 19, 64, 65, 80, 81 }, -- The Hall of Fame's recording machine, the one piece of real -- furniture in the tileset. It is drawn 32px wide and THREE tile @@ -629,6 +763,11 @@ return { -- the floor above). Bookcases, dining table with its flower pot, -- stools, the TV on the floor, and the staircase up. REDS_HOUSE_1 = { + -- the `house_stool` template below stands 5 voxels and the + -- `reds_house_table` model 6 (both the drawn elevation): whoever + -- sits on a stool cell and whatever object sprite stands on the + -- table rides these heights, not the 8/12px class defaults + heights = { stool = 5, table = 6 }, wall = { 0, 36, 37, 52, 53 }, stool = { 2, 3, 18, 19 }, -- the dining table (38-44/58-60); its top row also caps the @@ -656,6 +795,12 @@ return { -- wall-height volumes and towers the table, so every object is -- pinned to the shape it depicts. HOUSE = { + -- the `house_stool` template below stands 5 voxels and the + -- `house_table` model 6 (both the drawn elevation): whoever sits + -- on a stool cell -- Daisy at her table -- and whatever object + -- sprite stands on a table cell rides these heights, not the + -- 8/12px class defaults + heights = { stool = 5, table = 6 }, -- the wall band stays one 16px face: blank courses, the window, -- the framed picture, and the schoolhouse blackboard (72-75/88-91) wall = { 0, 36, 45, 46, 52, 61, 62, 72, 73, 75, 88, 89, 90, 91 }, @@ -694,14 +839,17 @@ return { -- look-only), the pokeball poster (2/3/18/19), and the pillars -- (16/41) with their bases (4/5/20/21; 20 is the $14 water-fallback -- trap and would recess into a pond lip). The healing machines' - -- bodies (72/76/77 console face, 6/22 button panel, 73 light edge, - -- 7/13 shadowed edge) are ALSO wall: drawn 16px tall against the - -- band, so wall height is their drawn height and they read as - -- consoles jutting from it; the near-black screen tiles must be - -- pinned or the void rule flattens them, and 72 is the $48 - -- water-fallback trap - wall = { 2, 3, 4, 5, 6, 7, 13, 16, 18, 19, 20, 21, 22, 40, 41, - 72, 73, 76, 77, 92, 93, 94, 95 }, + -- console face (76/77) and button panel (6/22) are ALSO wall: + -- drawn 16px tall against the band, so wall height is their drawn + -- height and they read as equipment jutting from it. (The + -- `center_heal_machine` template below claims those four tiles at + -- every placement and models the console properly; these pins are + -- the degradation path when the profile is absent.) The + -- near-black screen tiles must be pinned or the void rule flattens + -- them. What is NOT wall is the machines' two flanks -- see + -- `prop` below. + wall = { 2, 3, 4, 5, 6, 16, 18, 19, 20, 21, 22, 40, 41, + 76, 77, 92, 93, 94, 95 }, -- the counters, half a cell high: top band (8) with the nurse's -- tray (10), front face (24/25, the game's counterTiles), left end -- cap (56) and the Cable Club's light sections (90/91). 8px is @@ -732,6 +880,29 @@ return { -- bodies so they stand ON them) and the PC (66/70/82/86), which -- stands on its pinned desk billboard = { 58, 59, 66, 70, 74, 75, 82, 86 }, + -- The healing machines' two FLANKS, which are not the machine and + -- not the wall: thin equipment standing on the floor beside the + -- console, drawn against a plain light-grey ground with no floor + -- checker in it. `wall` boxed each of them into a solid 16px + -- half-cell wearing its drawing in relief -- the billboard failure, + -- one tile wide. + -- 72 the west flank of both machines, and 73 its mirror on the + -- east machine: a pair of PIPES leaving the console and + -- bending down into the floor in an L, the 8px motif drawn + -- once per pipe. Round and thin, so the THIN pool's 5px + -- slab, per-pixel, is the whole of them; a box is a wall + -- stub with pipework printed on it. + -- 7/13 the west machine's east flank: a KEYBOARD, one 16px panel + -- over two tiles (keys, and the wide bar down its right). + -- Same thin standee; the drawn panel IS its thickness. + -- All four ids are used at these flanks and nowhere else in the + -- game -- 12 maps, the same two cells in each -- so this pin cannot + -- reach anything but the machines. 72 is still the $48 + -- water-fallback trap and still has to carry SOME pin; it just + -- wanted the thin one, not the wall. + prop = { 7, 13, 72, 73, + -- ...and the potted plants -- see THE POTTED PLANTS below + 32, 33, 34, 35, 48, 49, 50, 51 }, -- The man on the couch, cut out by hand and stood up (see `figures` -- below). Nothing automatic reaches him. A class pin resolves a -- whole 8x8 tile and he SHARES his tiles with the couch, so pinning @@ -745,34 +916,35 @@ return { -- sofa he is painted into, so that is what `figures` carries. -- the PC's desk body, which the PC stands on table = { 9, 88 }, - -- the potted plants: bush (32/33/48/49) over pot (34/35/50/51, - -- 50 is the $32 water-fallback trap). Standing per-pixel - -- billboards in the THIN pool, like every other interior plant -- - -- and a pool apart from the sofa, which touches the corner pair - -- and must stay a separate object. The bush's light checker - -- highlights share the floor's shades, so the mask drains some of - -- them; the standee reads darker than the flat art, which is the - -- accepted trade for a real plant silhouette - prop = { 32, 33, 34, 35, 48, 49, 50, 51 }, - -- ...but the POTS were draining outright. The plant pair is drawn - -- as one 4x4-tile block and the pot's olive base is flush on that - -- block's bottom row, so the background vote -- which reads the - -- shades touching the drawing's own bounding box -- came back with - -- "dark" in it, and every dark pixel in the plant left with the - -- floor. The pots rendered as hollow black frames around one or - -- two surviving pixels while the flat art has solid olive bodies. - -- - -- So name the background outright for these eight tiles: the floor - -- IS light and white here, and nothing else is. That restores 152 - -- pixels of the block (55% -> 69% of it kept) and cannot move any - -- other prop, which matters -- the same call tileset-wide would pull - -- the dark wall band into the healing consoles' screens and strip - -- three pixels off the PC, because those two want the opposite - -- answer on the very same shades. - prop_bg = { - { tiles = { 32, 33, 34, 35, 48, 49, 50, 51 }, - shades = { "light", "white" } }, - }, + -- THE POTTED PLANTS: a leaf crown (32/33/48/49) over an urn + -- (34/35/50/51; 50 is the $32 water-fallback trap), the most + -- repeated interior prop in the game -- 78 placements over 13 maps + -- across this id and MART (scan: six per Center, two in the + -- Celadon hotel's lounge rows, four in the Plateau lobby). + -- + -- A plant is the THIN pool's textbook case: the drawing is ONE + -- organic silhouette -- crown, 3px stem, urn, all 8-connected, + -- 350 of the block's 512 pixels -- so it stands as a per-pixel + -- cutout 5 voxels deep at its real drawn height, 32px over the + -- two stacked cells. Its lowest drawn row is the block's bottom + -- row, so it stands in the pot cell's south band -- drawn low, + -- near the front of its blocked plot -- and the crown overhangs + -- the stem the way the drawing says, which no box or hull cut to + -- cell granularity can do. Both cells' tiles carry the pin (in + -- `prop` above); the pool clusters them into one drawing, and the + -- two side-by-side placements (every map pairs or quads them) + -- share one cluster whose per-pixel geometry is identical to two + -- separate ones. + -- The plant drawing has no floor margin to vote from: the urn's + -- #555 foot lies flush on the block's bottom edge, so the rim + -- vote reads "dark" as background and drains the urn's own body. + -- Name the background outright instead: the floor showing + -- through the crown's gaps is white+light and nothing else, + -- while the crown's own white/light highlights are sealed inside + -- its black/#555 outline -- a light+white flood from the ring + -- reproduces the extract mask exactly, leaves intact. + prop_bg = { { tiles = { 32, 33, 34, 35, 48, 49, 50, 51 }, + shades = { "light", "white" } } }, -- THE MAN ON THE COUCH. He is drawn INTO the lounge furniture and -- spills out of it in BOTH directions, which is why he needs three -- tile columns: @@ -908,6 +1080,21 @@ return { -- part of that same work surface now that the machine has been cut -- off them as a sprite -- see `figures` below. counter = { 8, 14, 15, 16, 24, 25, 30, 31, 41, 56, 89 }, + -- the same potted plants as POKECENTER above -- MART shares that + -- atlas, so this is the identical drawing tile for tile, and + -- INDIGO_PLATEAU_LOBBY (the only map on this id that places it) + -- puts four of them along the hall. Across POKECENTER and MART + -- all eight of these ids appear exactly 78 times each, which is + -- exactly how often the 2x4 plant grid appears: they are never + -- anything but this plant, so pinning them by tile cannot catch + -- anything else. Same reading as the POKECENTER entry: one + -- organic silhouette in the THIN standee pool, per-pixel, 5 + -- voxels deep at its drawn 32px height, with the background + -- shades named because the urn's foot lies flush on the block's + -- bottom edge and votes the drawing's own darks away. + prop = { 32, 33, 34, 35, 48, 49, 50, 51 }, + prop_bg = { { tiles = { 32, 33, 34, 35, 48, 49, 50, 51 }, + shades = { "light", "white" } } }, -- Left to the derived default on purpose: the floor checker and -- its shadowed variants (1/11/17/26/27/54) and the exit mat -- (12/28) all sit in cells the ROM marks walkable, so the cell @@ -915,13 +1102,14 @@ return { -- interior, used to be left to the volume path here; it is now the -- back wall's third band, because a run of four rows has to be -- contiguous for the wall to collapse into one box at all.) - -- THE CASH REGISTER: a keypad and a curl of receipt paper drawn - -- face-on across two tile rows in the middle of the counter's east - -- arm. Like the Center's seated man it is a FIGURE drawn into the - -- furniture, so it gets the same treatment -- a flat sprite card - -- standing on the counter -- and for the same reason: no class pin - -- can separate it from the counter, because the counter draws its - -- own edging down columns 0 and 15 of the very same tiles. + -- THE CASH REGISTER: a till drawn across two tile rows in the middle + -- of the counter's east arm, and the same drawing in all nine maps + -- on this id (eight Marts plus the Indigo Plateau lobby), always at + -- cell (1,5). Like the Center's seated man it is drawn INTO the + -- furniture, so it is cut out by the same authored mask, and for the + -- same reason: no class pin can separate it from the counter, + -- because the counter draws its own edging down columns 0 and 15 of + -- the very same tiles. -- -- That edging is what defeated every automatic reading. Black -- always survives the shade flood, so the standee pools extruded @@ -933,13 +1121,70 @@ return { -- to the top right, and the counter keeps its edging and its light -- work surface. -- + -- But it is a MACHINE, not a person, so unlike the seated man it + -- carries a `depth` and stands as a solid rather than a sprite + -- card. A flat card of a till is exactly the billboard the standee + -- pools exist to avoid -- it read as a decal on the counter, and + -- turned edge-on with the camera. + -- + -- AND IT IS NOT A BOX. The 16x16 packs two facings, and the black + -- linework says which is which: the keypad has its OWN complete + -- border (cols 2-8, rows 4-11) sealed inside the outer silhouette, + -- and what surrounds it is an L -- + -- + -- cols 2-----8 9--12 + -- +-----------+-----+ row 4 + -- | keypad | arm | the L's upright arm: the printer and + -- +-----------+-----+ row 11 display the paper feeds out of + -- | base / drawer | rows 12-15: the L's foot, drawn face-on + -- +------------------+ + -- + -- So the keypad is NOT a face. It is the machine's DECK seen from + -- ABOVE -- the keys lie on it -- and `flat` lays it horizontal in + -- the notch of the L, at the height the base band leaves it and + -- with drawn row = depth row 1:1. Extruding it instead stood that + -- surface on end and painted the keys up the front of a plain box: + -- the extruded-picture failure, and what the first pass shipped. + -- + -- Everything else falls out of the drawing: the base band is 4 rows, + -- so the deck stands 4 above the counter; the arm is 8, so it rises + -- 8 above the deck; the paper reaches 4 more. + -- + -- TWO AUTHORED NUMBERS. + -- + -- `depth` 12, three quarters of the cell. The keypad's own eight + -- drawn rows measure 8, and at 8 the machine read thin on the + -- counter, so the body runs half again deeper and the deck STRETCHES + -- over it -- 8 rows into 12 voxels, centre-sampled, so every second + -- row doubles and the keypad still covers the whole surface. This + -- is the one place in the model where a texel is not 1:1 with a + -- drawn pixel; laying the panel 1:1 instead leaves a strip of the + -- base band's top showing behind the keys. The model is anchored at + -- the counter cell's FRONT and grows north into the bare top behind + -- it, so deepening it can never push the till toward the aisle: at + -- 12 it still stops 4 voxels short of the cell's north edge. This + -- is the number to move if it wants more or less presence. + -- + -- `thin`: 4 rows at 2. Row 4 is the machine's + -- own drawn top edge -- a black rule from column 3 to column 9 -- so + -- everything above it is the receipt strip, and paper is 2 voxels + -- like every other `cutout` here. Centred in the body's band, so + -- the strip leaves the arm's top face by a slot in the middle of it + -- rather than flush with its front. At the body's 8 the curl came + -- out as a chunky wedge on the corner and stopped reading as paper. + -- -- `under` is 16/41 twice -- the plain work surface, which already -- carries the west edging (black over white) and the east (dark -- over black), so the counter closes up behind the machine with no - -- synthesis at all. + -- synthesis at all. The `counter` pin on 14/15/30/31 stays: the + -- box under the till is still the counter, and the machine stands + -- on its 8px top plane. figures = { { w = 2, + depth = 12, + thin = { rows = 4, depth = 2 }, + flat = { x = { 2, 8 }, rows = { 4, 11 } }, tiles = { 14, 15, 30, 31 }, under = { 16, 41, @@ -1648,6 +1893,11 @@ return { -- footing. bookcase = { 7, 8, 9, 10, 23, 24, 25, 26, 39, 40, 41, 42, 55, 56, 57, 58 }, + -- ...and the drums are the reason this tileset turns the shelf + -- relief OFF: the collapse here is borrowed for a MACHINE, whose + -- light regions are the barrel's own lit face and not panes + -- behind a frame. Sinking them would dent the drum. + bookcase_relief = false, -- The big OCTAGONAL boardroom table -- the Fan Club's and Silph -- 11F's, the same drawing in both -- half a cell high, and half a -- cell on purpose. A `counter` is ONE band: exactly the drawing's @@ -1712,6 +1962,13 @@ return { -- drawn into the WALKABLE cell in front; 43/44 belong to the stool -- there, not to the desk. table = { 11, 12, 13, 14, 27, 28, 29, 30 }, + -- and those eight ids are the ONLY `table` in this tileset, so the + -- height override is this desk's alone: 8px, the height its apron + -- is drawn and the plane the `bills_desk` template stands its top + -- on (see `buildings` below). The pin is only the degradation + -- path here -- the template claims both placements -- but the two + -- must not disagree about where the desk's top is. + heights = { table = 8 }, -- the seats: Bill's desk stool (43/44 over 59/60) and the Fan -- Club's four members' chairs (61/62 over 59/60), a whole cell -- each. All of them sit in WALKABLE cells, so they were flat @@ -1765,11 +2022,13 @@ return { -- rounded foot, 53, stands one row lower on the floor and is -- walkable, so it stays flat ground in front of the band). -- Drawn INTO the band and therefore wall as well: the two - -- bicycles hung on the Bike Shop's wall (1/2/3 over 17/18/19, + -- bicycles against the Bike Shop's wall (1/2/3 over 17/18/19, -- exactly two tile rows = exactly the band's height), the Cable -- Club's link poster (48/49/50/51 -- 50 is $32, the shore trap -- that had it recessing to -2), and the Trade Center's pair of -- lit notice boards (63/68/68/69 over 66/67/67/70). + -- The bicycles keep their `wall` pin as the degradation path, but + -- `mounted` below lifts them off the panel -- see there. wall = { 1, 2, 3, 6, 17, 18, 19, 48, 49, 50, 51, 52, 63, 66, 67, 68, 69, 70 }, -- Counters, half a cell high -- the house rule for anything you @@ -1797,28 +2056,114 @@ return { -- character standing on one sits on it. stool = { 38, 39, 42, 43 }, -- The showroom bicycles -- six of them standing on the Bike Shop - -- floor, in two columns, each drawn 3 tiles by 2 side-on. The - -- THIN standee pool, not `bookcase`: a bike is nearly all air - -- (two wheel rings and a frame), and collapsing a rack of them - -- onto a one-cell-deep box at full height would trade six - -- bicycles for one blank 32px panel wearing a bicycle print. As - -- cutouts they segment cleanly -- the drawing has floor margin on - -- three sides and its own black outline seals the wheels -- and - -- the standee builder's connected-component split gives each bike - -- its own feet in its own cell even where a lower bike's - -- handlebar stem touches the wheel of the one drawn above it. + -- floor, in two columns, each drawn 3 tiles by 2 side-on. A THIN + -- standee pool, not `bookcase`: a bike is nearly all air (two + -- wheel rings and a frame), and collapsing a rack of them onto a + -- one-cell-deep box at full height would trade six bicycles for + -- one blank 32px panel wearing a bicycle print. As cutouts they + -- segment cleanly -- the drawing has floor margin on three sides + -- and its own black outline seals the wheels -- and the standee + -- builder's connected-component split gives each bike its own + -- feet in its own cell even where a lower bike's handlebar stem + -- touches the wheel of the one drawn above it. -- 11/12/14 the saddle and bar row, 27/28/9 the frame and wheels. - prop = { 9, 11, 12, 14, 27, 28 }, - -- The crate and the standing pump beside the shop's south wall + -- + -- Their own pool at TWO voxels rather than `prop`'s five, which is + -- the whole reason `bike` exists. The segmentation was never the + -- problem -- the mask the detector cuts is a clean bicycle, wheels, + -- forks, handlebars and all. Depth was: every stroke of a line + -- drawing extruded five voxels closes the gap to its neighbour with + -- its own side faces, so off-axis (which is every camera rung but + -- flat) the air inside the frame filled in and the showroom's six + -- bicycles came out as one dark lump against the wall. At two the + -- negative space survives and they read as bicycles again. + bike = { 9, 11, 12, 14, 27, 28 }, + -- The toolbox and the standing pump beside the shop's south wall -- (29/13 over 21/22), twice. A separate pool from the bicycles - -- on purpose, and the thicker one: a crate is a deliberate object - -- with a body, where a bike is a silhouette. + -- on purpose, and the thicker one: a toolbox is a deliberate + -- object with a body, where a bike is a silhouette. + -- + -- Kept as the DEGRADATION PATH only. The `bike_shop_toolbox` + -- building template (see `buildings.CLUB`) claims these cells and + -- models the drawing properly -- a per-pixel standee is the wrong + -- primitive for a box, and 10 voxels of it turned every black + -- outline pixel into a 10-deep bar. Buildings runs before the + -- standee scan, so where the template stamps, this pin never + -- fires; without a profile it is still better than the volume + -- path. billboard = { 13, 21, 22, 29 }, -- The Bike Shop floor's second checker phase. It is NOT in the -- tileset's walkable list, so the cells it floors resolve by rule -- 4 and it rose as a wall stub -- or, worse, got swept into the -- bicycle beside it and dragged to 8px. It is floor; it is flat. ground = { 30 }, + -- THE TWO BICYCLES AGAINST THE SHOWROOM'S NORTH WALL. Same object + -- as the six on the floor -- 24x16 of bicycle seen side-on, wheels + -- on the floor line -- but drawn INTO the wall band instead of onto + -- the floor, which is a different problem entirely: + -- + -- a class pin resolves a whole 8x8 tile, so `wall` (what these + -- carried) paints the bicycle flat on the panel: a green wall + -- with a bicycle printed on it, which is what the shop looked + -- like; + -- and nothing automatic can cut it out either. The panel behind + -- it is tile 6's stripe -- a #aaa field ruled with #555 every + -- four rows -- and #555 is a flood BOUNDARY, so a silhouette + -- flood comes back with three full-width wall stripes welded + -- across the bike (rows 3, 7 and 15 of the drawing). Naming the + -- background shades instead (prop_bg) cannot work either: the + -- bicycle's own basket is drawn in the very #aaa and white the + -- wall field uses. + -- + -- So the silhouette is authored -- but MEASURED, not drawn by hand. + -- 6 is the plain panel these three tile columns are painted over, + -- and it is perfectly regular, so compositing 6 across the same 3x2 + -- grid and flooding the background in through the pixels that still + -- MATCH it separates bicycle from wall exactly: 247 pixels, one + -- connected component, no stripe left on it and no hole in it. + -- That is also why `under` is six 6s -- the wall wears the panel + -- the artist drew everywhere else along the same run. + -- + -- `depth` 2 for the same reason the `bike` pool above is 2, and + -- because a family shares a silhouette: these are the showroom's + -- bicycles, leaning on the wall rather than standing in the room. + -- The slab juts 2 voxels SOUTH of the band, so they stand in front + -- of the wall and overhang the walkable cell a little -- which is + -- what a bicycle leaning on a wall does. Collision is untouched. + -- + -- Only BIKE_SHOP places this grid on the club atlas, at tile (1,0) + -- and (6,0). The same six tile ids DO pair up in gate.png (both + -- Route gate upper floors and all four Safari rest houses), but + -- that is a different image and `mounted` is keyed per tileset, so + -- the pattern cannot reach them. + mounted = { + { + w = 3, + depth = 2, + tiles = { 1, 2, 3, + 17, 18, 19 }, + under = { 6, 6, 6, + 6, 6, 6 }, + pixels = { + ".........XXX............", + "..XXXXX.XXXX............", + "..XXXXXXXXXX............", + "..XXXXXXXXXX............", + "..XXXXXXXXXXXXX.........", + "..XXXXXXXXXXXXXX........", + "..XXXXXXXXXXXXXX........", + "..XXXXXXXXXXXXXX..XXX...", + ".XXXXXXXXXXXXXXXXXXXXXX.", + "XXXXXXXXX..XXXXXXXXXXXXX", + "XXXXXXXXX.XXXXXXXXXXXXXX", + "XXXXXXXXX.XXXXXXXXXXXXXX", + "XXXXXXXXX.XXXXXXXXXXXXXX", + "XXXXXXXXX..XXX..XXXXXXXX", + ".XXXXXXX....X...XXXXXXX.", + "...XXX.....XXX....XXX...", + }, + }, + }, -- Left to the derived default on purpose: -- $0F $1F the main floor checker -> walkable, ground -- $0A $1A the dark slab under the Bike Shop's exit warp -> a @@ -1920,11 +2265,34 @@ return { -- towered to 24. bookcase = { 43, 54 }, -- The galley barrels -- three down the kitchen's east wall, one in - -- the captain's room, one in each house. A per-pixel cutout in - -- the THIN pool, the treatment Lt. Surge's trash cans get: 72/73 - -- the lid, 88/89 the banded body. 72 is $48, the shore-set trap - -- that had each barrel resolving to water at -2. - prop = { 72, 73, 88, 89 }, + -- the captain's room, one in each house. 72/73 the mouth, 88/89 + -- the banded body. 72 is $48, the shore-set trap that had each + -- barrel resolving to water at -2. + -- + -- This is Lt. Surge's trash can REDRAWN PIXEL FOR PIXEL on the ship + -- atlas: lay the two masks side by side and the only difference is + -- that the gym's floor stripes cross the gym copy's background, + -- which the flood keeps and this one has no need of. So it takes + -- the same treatment and the same numbers as GYM's can -- see there + -- for why the mouth and base ellipses are measured and the height, + -- well and taper are authored. + -- + -- Scanned: six placements on the SHIP atlas and no others -- + -- SS_ANNE_KITCHEN (13,5), (13,7) and (13,9) down the east wall, + -- SS_ANNE_CAPTAINS_ROOM (4,1), and one each in CERULEAN_BADGE_HOUSE + -- and FUCHSIA_GOOD_ROD_HOUSE at (7,7), both of which are ship + -- interiors reusing the tileset. Twenty-one more hits are id + -- collisions on other atlases and no business of this entry; the one + -- worth naming is VERMILION_DOCK, because SHIP_PORT is a near-twin + -- of this image -- but its $48/$49/$58/$59 are a hull panel, not a + -- barrel (diffed pixel for pixel), so the pin is NOT copied there. + can = { 72, 73, 88, 89 }, + can_cap = 9, + can_base = 4, + can_height = 9, + can_well = 5, + can_taper = 4, + heights = { can = 9 }, -- The stairs, both flights, in every map that has them (1F, 2F, -- 3F, B1F and the captain's room). Same two drawings the whole -- game uses, and the warp table says which is which: 41/42/57/58 @@ -2429,6 +2797,11 @@ return { -- PLATEAU, so the wall reads as set INTO the terrace instead of -- standing in front of a trench of synthesized ground. bookcase_backfill = "above", + -- and no shelf relief: what the collapse carries here is MASONRY, + -- whose courses are the wall itself rather than panes set behind + -- a frame. Sinking them cut a stepped bevel into the League's + -- walls and the pilasters (shot before it was turned off). + bookcase_relief = false, -- ONE course, and it must stay one: $15/$16 + $05/$06 + $30/$31 is -- the pilaster -- cap, shaft, plaque base -- and $15/$16 over $30/$31 -- IS the statue's plinth, the artist having drawn the same stone @@ -3364,6 +3737,84 @@ return { z = 13 }, -- keyboard }, }, + -- F05: the healing machine behind the counter -- the pair in every + -- Center (cells (1,0):(2,1) and (6,0):(7,1)), 22 placements, plus + -- the Indigo Plateau lobby's pair under MART below (scan + -- "58,59;74,75;76,77;6,22"). + -- + -- The drawing is 4x4 tiles but only its middle COLUMN is this + -- template's. The corners are the striped wall band (40), and + -- the outer tile columns are the flanking EQUIPMENT -- the hoses + -- (72 west, 73 east) and the control keyboard (7/13) -- which are + -- separate thin objects, not part of this console: they are + -- pinned into the THIN standee pool above and stay out of this + -- grid. What no class can split is the middle -- a wall-height + -- cabinet with a monitor perched on its front top edge, drawn + -- across two map rows because it towers over the 16px band + -- behind it, which the volume path can only read as more wall. + -- + -- Read off the pixels (local x, the whole drawing's x8..x23): + -- rows 15-31 the CABINET's front face, 16 wide, 17 tall: + -- black front-top edge with vent notches (15), + -- white top rail (16), black panel in a white + -- frame (17-28), white rail / light plinth / black + -- ground line (29-31). + -- rows 6-14 the cabinet's TOP FACE seen from above -- not a + -- second-story facade: a white expanse with a lit + -- west strip (x1 white) and a shaded east strip + -- (x14 #555) wrapping the monitor, full-width in + -- front of it (13-14), strips beside it (7-12). + -- Its 9 rows plus the front edge row 15 ARE the + -- cabinet's depth: 10. `desk.top` lays the band + -- flat as the lid; where the monitor's drawing + -- occludes it, the lid continues the nearest strip + -- (the drawing's own pixels). + -- rows 1-13 the MONITOR, x2..x13: black back rim (1) and + -- white cap (2-3) seen from above -- 3 rows plus + -- the front edge, depth 4 -- then bezel, screen + -- and control strip (4-13) face-on, 10 tall, + -- standing on the cabinet top at its front: the + -- drawn base edge (13) is one band row up from the + -- front strip (14), so its front plane is one + -- voxel back from the cabinet's -- z 20..23. The + -- screen interior (x5..x10, rows 6-8) is sealed + -- behind its own black frame and sinks one voxel + -- via `inset` -- the pane rule applied by hand, + -- because `panes = false` blocks the global pass. + -- rows 0-5 at the flanks: wall stripes and the machine's + -- cast shadow on the wall east of it -- BACKGROUND, + -- the same standing as the potted plants' floor. + -- The `wall` element keeps the band solid over the + -- back map row: a 16-tall, 16-deep block cycling + -- the drawing's own stripe unit (x0, rows 0-3), + -- what the neighbouring cells' wall pins render. + -- + -- `depth = 4` is BOTH map rows: the back row is the wall's plot, + -- the front row the machine's own, and the cabinet leans against + -- the wall at z 16..25. `panes = false`: the front panel seals + -- DARK behind LIGHT (a black panel in a white frame), the + -- opposite polarity to the pane rule, so the global recess pass + -- would sink the frame and leave the panel proud. + { + id = "center_heal_machine", + tiles = { + { 58, 59 }, + { 74, 75 }, + { 76, 77 }, + { 6, 22 }, + }, + roofRows = 0, roofBack = 0, roofFront = 0, roofCycle = { 0, 0 }, + slab = 0, frontEave = 0, ledge = nil, depth = 4, + panes = false, + wall = { h = 16, depthPx = 16, x = 0, cycle = { 0, 3 } }, + desk = { fascia = { 15, 16 }, base = { 17, 31 }, z = 16, + depthPx = 10, top = { 6, 14 } }, + parts = { + { kind = "upright", x = { 2, 13 }, top = { 1, 3 }, + facade = { 4, 13 }, z = 20, depth = 4, + inset = { x = { 5, 10 }, rows = { 6, 8 } } }, -- the monitor + }, + }, }, MART = { @@ -3388,6 +3839,31 @@ return { z = 13 }, -- keyboard }, }, + -- F05 again: the Indigo Plateau lobby has the Centers' healing + -- machine pair too (cells (5,4):(6,5) and (10,4):(11,5)), and its + -- MART tileset shares the POKECENTER atlas, so this is the same + -- drawing tile for tile. Same part table as the POKECENTER entry + -- above. + { + id = "center_heal_machine", + tiles = { + { 58, 59 }, + { 74, 75 }, + { 76, 77 }, + { 6, 22 }, + }, + roofRows = 0, roofBack = 0, roofFront = 0, roofCycle = { 0, 0 }, + slab = 0, frontEave = 0, ledge = nil, depth = 4, + panes = false, + wall = { h = 16, depthPx = 16, x = 0, cycle = { 0, 3 } }, + desk = { fascia = { 15, 16 }, base = { 17, 31 }, z = 16, + depthPx = 10, top = { 6, 14 } }, + parts = { + { kind = "upright", x = { 2, 13 }, top = { 1, 3 }, + facade = { 4, 13 }, z = 20, depth = 4, + inset = { x = { 5, 10 }, rows = { 6, 8 } } }, -- the monitor + }, + }, }, GYM = { @@ -3416,5 +3892,350 @@ return { }, }, }, + + CLUB = { + -- F06: the Bike Shop's OPEN TOOLBOX -- BIKE_SHOP cells (6,6) and + -- (7,7), and nowhere else in the game (2 placements). + -- + -- The drawing looks down INTO the box: what fills its middle is not + -- a face but the inside of the tray, with a wrench lying in it, and + -- the lid standing open on the right. That is why every solid + -- treatment fails it. As a `billboard` (what these tiles carried) + -- the whole 16x16 went up as one 10-voxel per-pixel slab -- the + -- extruded picture in its pure form, a black monolith wearing the + -- drawing as a decal. Reading the middle as a cabinet FRONT and + -- extruding that is the same mistake one level down: it puts a wall + -- where the opening is. An open box has to be hollow, so `tray` + -- builds four walls, a floor slab and AIR between them. + -- + -- The bands, and they measure out exactly (8 depth rows for a + -- one-tile-row plot): + -- row 4 the FAR rim, black across the back wall -> z 0 + -- rows 5-10 the tray's inside, seen from above, with the + -- wrench lying across it -- drawn row = depth row, + -- six rows for six rows -> z 1-6 + -- row 11 the near wall's INNER face, in shadow + -- row 12 the near rim, black right across the box -> z 7 + -- rows 13-15 the near wall's outer face, seen face-on: a light + -- panel between its black rim and its black foot, + -- so the wall stands 4 voxels and the rim is its top + -- + -- `x` is the box's outer span and `inner` the opening's, so the + -- one-column difference on each side IS the wall. x12 is left out + -- of both on purpose: the #555 at x11-x12 on rows 13-15 is the 3/4 + -- shear of the right wall, which un-projecting simply removes -- + -- the wall is where the plan says it is, not where the projection + -- slides it to. `floor = 0` puts the tray's bottom one voxel thick, + -- leaving three voxels of open box above it. + -- + -- The LID is an upright part riding the rim, hinged on the right + -- and standing up: 12 tall as drawn, spanning the box's whole depth + -- (8) and 4 thick, which is the black outline plus the two columns + -- of white body the drawing gives it. It overhangs east of the + -- hinge, which is what a hinged lid standing open does. + { + id = "bike_shop_toolbox", + tiles = { + { 29, 13 }, + { 21, 22 }, + }, + roofRows = 0, roofBack = 0, roofFront = 0, roofCycle = { 0, 0 }, + slab = 0, frontEave = 0, ledge = nil, depthPx = 14, + tray = { top = { 4, 11 }, front = { 12, 15 }, x = { 2, 11 }, + inner = { 3, 9 }, floor = 0 }, + parts = { + { kind = "upright", x = { 11, 14 }, top = { 0, 0 }, + facade = { 0, 11 }, z = 0, depth = 14 }, -- the open lid + }, + }, + }, + + MANSION = { + -- F05: the TALL display cabinet, the one with the trophy behind + -- its glass -- CELADON_CHIEF_HOUSE cells 3,0 and 4,0 and + -- CELADON_MANSION_1F cell 2,2 (3 placements). Its 32 rows read + -- like every band table's: 0-8 the cabinet top seen from above + -- (black rim, white highlight courses along the north and west, + -- grey field, the front corner shaded at row 8); row 9 the top's + -- own black front edge -- which is what the rim treatment paints, + -- so slab = 1 and that row folds into the roof band instead of + -- extruding under it; 10-31 the front face: the trophy in its + -- dark display opening, the nameplate under it, then the two + -- panelled doors of the base. The contents come off the pixels, + -- not the band table: the trophy, the plate and both door panels + -- are non-black regions the drawing seals behind its own black + -- frame, so the measured recess pass sinks each of them a voxel + -- and the frames stay proud. Nine drawn top rows over a 32px + -- plot, so the rims map 1:1 -- the drawn front-corner shading + -- lands one voxel behind the front edge -- and the uniform field + -- cycles between. Both cell rows of the plot are BLOCKED and + -- both are cabinet drawing, so D is the whole grid: the rank is + -- built into the room's north wall, and its front stands flush + -- with the short case's beside it. 23 voxels tall. + { + id = "mansion_trophy_case", + tiles = { + { 38, 41 }, + { 40, 21 }, + { 56, 87 }, + { 50, 51 }, + }, + roofRows = 10, roofBack = 8, roofFront = 2, roofCycle = { 2, 7 }, + slab = 1, frontEave = 0, ledge = nil, + }, + -- F06: the SHORT book case standing beside it -- the same + -- cabinet on a grid one tile row shorter (CELADON_CHIEF_HOUSE + -- cells 2,0 and 5,0, CELADON_MANSION_1F tiles 2,5 and 6,5; 4 + -- placements). Identical top band, identical panelled base; + -- only the display opening is shorter, a shelf of books where + -- the tall one has the trophy. Same band numbers as F05 -- + -- which is what makes the pair read as one line of furniture -- + -- and 15 voxels tall against the tall one's 23, exactly the 8 + -- rows of drawing between them. The grid must carry the 38/41 + -- cap row: {34,35} over {50,51} alone also stands on + -- CELADON_MANSION_2F, where it is the bottom of a taller + -- two-shelf bank and not this drawing at all. The 8px of wall + -- behind the cap is not part of the cabinet and keeps its own + -- `wall` pin. + { + id = "mansion_bookcase", + tiles = { + { 38, 41 }, + { 34, 35 }, + { 50, 51 }, + }, + roofRows = 10, roofBack = 8, roofFront = 2, roofCycle = { 2, 7 }, + slab = 1, frontEave = 0, ledge = nil, + }, + -- F07: the long table in the middle of the chief's house + -- (CELADON_CHIEF_HOUSE cell 2,3), the lab table's read at four + -- cells wide and two deep. Rows 0-23 are the tabletop seen from + -- above; 24-26 are the top slab's own front edge, + -- black/#555/black, and row 27 the #555 shadow that closes it -- + -- exactly what the rim treatment paints, so slab = 3 and rows + -- 24-27 fold into the roof band. That leaves 28-30 as the base, + -- the same three rows the lab table's is, and the two tables + -- stand the same 6 voxels: row 28 the apron running the whole + -- width, 29-30 the end legs under it. The legs stop one row + -- short of the grid, so the measured ground line lands there and + -- the model does not float. Both cell rows of the plot are + -- blocked, so D is the whole grid: the 24 drawn top rows map 1:1 + -- from the north rim and the field's own rows cycle for the last + -- 8. The `table` pin these tiles keep stays 12px -- it is + -- shared with the 2F/3F writing desks and the rooftop shed, and + -- nothing rides this table -- so it is only the degradation + -- path, neutralized wherever this template stamps. + { + id = "mansion_long_table", + tiles = { + { 38, 39, 39, 39, 39, 39, 39, 41 }, + { 54, 55, 55, 55, 55, 55, 55, 57 }, + { 54, 55, 55, 55, 55, 55, 55, 57 }, + { 60, 58, 58, 58, 58, 58, 58, 59 }, + }, + roofRows = 28, roofBack = 24, roofFront = 0, roofCycle = { 2, 23 }, + slab = 3, frontEave = 0, ledge = nil, + }, + }, + + HOUSE = { + -- F08: the dining table of the generic town house -- 18 + -- placements, every home's cells (3,3):(4,4), Blue's and the + -- Daycare among them -- the chief's long table (F07 above) at two + -- cells wide, the same read to the row. Rows 0-23 are the rounded + -- tabletop seen from above (black rim, white highlight course, + -- grey field, #555 east rim); 24-26 the slab's own front edge, + -- black/#555/black, and 27 the #555 apron shadow that closes it, + -- folded into the band; 28-30 the base -- the apron's black + -- underrun and the corner legs -- stopping one row short of the + -- grid, so the measured ground line keeps the model on its plot. + -- The same 6 voxels the whole table family stands. The `table` + -- pins stay as the degradation path, neutralized where this + -- stamps; the schoolhouse's half-width table with its book and the + -- trashed house's ransacked corner are different grids and keep + -- theirs. + { + id = "house_table", + tiles = { + { 38, 39, 39, 41 }, + { 54, 47, 47, 57 }, + { 54, 47, 47, 57 }, + { 60, 58, 58, 59 }, + }, + roofRows = 28, roofBack = 24, roofFront = 0, roofCycle = { 2, 23 }, + slab = 3, frontEave = 0, ledge = nil, + }, + -- F09: the stool at every one of those tables (94 placements on + -- this tileset) -- the first template with NO base piece. The + -- drawing is one object, a round seat on legs, drawn MID-CELL over + -- its own floor (rows 0-4 are the room behind it), and no band + -- split fits that: a roof band starts at the drawing's top row. + -- So it is a desk-set of exactly one upright part anchored to the + -- floor: rows 5-10 the seat seen from above (its lid), row 11 the + -- seat's own front edge, 12-15 the legs with the floor showing + -- between them, which the per-pixel facade cut keeps open. The + -- drawing measures 7 deep (six seat rows plus the front edge at + -- drawn row = depth row); the shipped stool is grown two voxels + -- past that north AND south (z 3..13, developer-tuned against the + -- in-game read), with `stretch` mapping the seat band over the + -- deeper lid instead of smearing its last row. `panes = false`: + -- a stool has no windows, and the pane rule would sink the legs' + -- lit faces behind their own outlines. The 5-voxel stand is the + -- drawn elevation, and the tileset's `heights` above pins the + -- `stool` ride height to it, so whoever sits here sits ON the + -- seat; the old stool standee pins stay as the degradation path. + { + id = "house_stool", + tiles = { + { 2, 3 }, + { 18, 19 }, + }, + roofRows = 0, roofBack = 0, roofFront = 0, roofCycle = { 0, 0 }, + slab = 0, frontEave = 0, ledge = nil, + panes = false, + parts = { + { kind = "upright", x = { 2, 13 }, top = { 5, 10 }, + facade = { 11, 15 }, z = 3, depth = 11, + stretch = true }, -- the stool + }, + }, + }, + + REDS_HOUSE_1 = { + -- F08 again: Red's and the Copycat's ground-floor dining table + -- (cells (3,4):(4,5) of both maps) -- the house table on the + -- reds_house atlas, with two differences the fields absorb. + -- + -- Its tabletop field stops at row 22 and row 23 is the top's own + -- #555 south rim, which roofBack = 24 would lay MID-TABLE as a + -- dark stripe: back stops at 23 and the field cycles from there + -- (the drawn rim's place at the south edge is under the rim + -- treatment's own black, like every sibling's). + -- + -- And the POTTED PLANT is drawn standing on the tabletop (rows + -- 5-15, x10..x21, tiles 40/55/56 with the cutout pool's 54/57 + -- beside them). It stays the cutout standee it has always been: + -- `keep` leaves its tiles unclaimed so the pin and its standee + -- survive, `scrub` replaces its pixels with the field shade so the + -- model tops out as the plain surface it sits on, and `support` + -- states the model's 6-voxel top plane so the standee stands ON + -- the modelled tabletop (Structures reads it off the claim; a + -- plain claim's h = 0 supports nothing). + { + id = "reds_house_table", + tiles = { + { 38, 39, 40, 41 }, + { 54, 55, 56, 57 }, + { 44, 42, 42, 43 }, + { 60, 58, 58, 59 }, + }, + roofRows = 28, roofBack = 23, roofFront = 1, roofCycle = { 2, 22 }, + slab = 3, frontEave = 0, ledge = nil, + scrub = { { 10, 5, 21, 15 } }, + keep = { 40, 54, 55, 56, 57 }, + support = 6, + }, + -- F09 again: the stools around it (10 placements across Red's and + -- the Copycat's ground floors), pixel for pixel the house + -- tileset's drawing. Same part table; see HOUSE above. + { + id = "house_stool", + tiles = { + { 2, 3 }, + { 18, 19 }, + }, + roofRows = 0, roofBack = 0, roofFront = 0, roofCycle = { 0, 0 }, + slab = 0, frontEave = 0, ledge = nil, + panes = false, + parts = { + { kind = "upright", x = { 2, 13 }, top = { 5, 10 }, + facade = { 11, 15 }, z = 3, depth = 11, + stretch = true }, -- the stool + }, + }, + }, + + INTERIOR = { + -- F0x: Bill's desk, and the Silph president's -- one drawing, two + -- placements (BILLS_HOUSE cell 1,4 and SILPH_CO_11F cell 10,12, + -- both found by the scan and nothing else). The whole 4x4-tile + -- block is one designed unit, but this template deliberately takes + -- only the desk's own two cells. + -- + -- What the 16 drawn rows are: the TABLETOP seen from above, the + -- lab table's pattern tile for tile -- black rim, a white + -- highlight course inside it, grey field -- over a 16px plot, so + -- they map 1:1 and nothing cycles. Standing on it, and drawn INTO + -- that top view the way lab_computers' objects are: a terminal + -- (screen head over a keypad panel, rows 3-13) and the ball + -- (rows 1-13). Drawn row = depth row on a top view, so each part's + -- `z` is where the drawing puts it: the head's front row is 8, the + -- keypad picks up at 9, and the ball's front row is its drawn + -- bottom. Only the two DEPTHS and how much of each blob is lid + -- rather than face are authored -- the projection cannot state + -- either. + -- + -- Why the grid stops at two rows. This drawing's front face is its + -- APRON, and the artist drew the apron into the WALKABLE cell in + -- front (43/44 + 91/92) -- exactly where the lab table's fascia and + -- base are. But 43/44 is also the upper half of the CHAIR pushed + -- up to the desk (43/44 over 59/60, pinned `stool` in the tileset + -- section above), and a template claims whole TILES: reading the + -- apron would take the chair's back with it and leave a backless + -- stub in both rooms. So the apron stays with the chair, `plane` + -- authors the desk's height instead, and the body under the lid is + -- synthesized in the drawing's own shades (the band table's rim + -- treatment -- a shaded box closed by the outline at the floor). + -- 8px is not a taste number: it is the apron's own drawn row count + -- and the `table` height these tiles are already pinned to, so the + -- desk stands where the degradation path stood it. + { + id = "bills_desk", + tiles = { + { 11, 12, 13, 14 }, + { 27, 28, 29, 30 }, + { 43, 44, 91, 92 }, + { 59, 60, 31, 31 }, + }, + roofRows = 0, roofBack = 0, roofFront = 0, roofCycle = { 0, 0 }, + slab = 0, frontEave = 0, ledge = nil, depth = 4, + -- the desk's own plot is its two cells; the grid runs on because + -- its apron and the CHAIR share tiles 43/44 + desk = { fascia = { 16, 18 }, base = { 19, 23 }, depth = 2 }, + parts = { + -- the notes: two sheets lying on the desk, so PAPER -- one + -- voxel proud at drawn row = depth row, and nothing raised + { kind = "flat", x = { 2, 15 }, rows = { 3, 7 } }, + -- the keyboard: a real slab, not a print on the desk. Its + -- keys are drawn from ABOVE so they ride the top face across + -- its depth, and its drawn bottom frame row stands as the + -- front edge + { kind = "upright", x = { 4, 15 }, top = { 8, 12 }, + facade = { 12, 13 }, z = 8, depth = 6 }, + -- the cord: the kinked dark run the drawing puts between the + -- keyboard's top-right corner and the computer's left corner. + -- Raised to the keyboard's own height so it reads as a cable + -- spanning them rather than a scuff on the desk + { kind = "upright", x = { 16, 19 }, top = { 8, 8 }, + facade = { 8, 9 }, z = 8, depth = 2 }, + -- the computer: drawn in 2:1 ISOMETRIC, turned 45 degrees to + -- the map -- see the `iso` branch in buildParts. `plan` = rx + -- makes its footprint a SQUARE turned 45, so from directly + -- above it is the cube the drawing depicts and not the slab + -- the 2:1 would otherwise build + { kind = "iso", x = { 19, 30 }, rows = { 1, 13 }, + plan = 6, z = 9 }, + -- the chair pushed up to the desk, drawn into the walkable + -- cell in front. It stands on the FLOOR, not on the desk, so + -- its rise is the whole plane back down; two voxels deep + -- because it is a chair, and `inside` cuts it per pixel, so + -- this is the thin slab the `stool` standee was -- the desk + -- claiming tiles 43/44 for its apron is what makes the + -- template owe it + { kind = "upright", x = { 2, 13 }, top = { 20, 21 }, + facade = { 22, 31 }, rise = -8, z = 22, depth = 10 }, + }, + }, + }, }, } diff --git a/lib/Buildings.lua b/lib/Buildings.lua index b07c913..04a55df 100644 --- a/lib/Buildings.lua +++ b/lib/Buildings.lua @@ -68,6 +68,47 @@ local RECESS_MAX = 24 local SHADE = { top = 0.95, south = 1.0, north = 0.68, side = 0.78, bottom = 0.5 } +-- ------- how far a merged run may reach: the tile lattice +-- +-- Merging is what keeps a 90k-voxel house down to ~2k quads, and under a +-- straight projection a run may be as long as it likes -- a straight line +-- is a straight line however finely it is cut. THE WORLD CURVE IS NOT +-- STRAIGHT. It drops every vertex by the square of its distance from the +-- focus (see WorldCurve), so a quad's interior is the CHORD of a parabola +-- its neighbours draw the arc of: a run of length L hangs k*L^2/4 below +-- the short quads butted against it, and the join tears open. +-- +-- Nothing bounded a run's length before, and the runs that ran away were +-- the ones wearing a CONSTANT texel -- the roof's black eave outline, its +-- fascia, the shaded underside -- because a flat run has no art to break +-- it. Those reached 102px across a gym, which at V-CURVE 3 hangs some +-- three world pixels under the roof surface beside it: the eave tore off +-- the roof and the drop showed the building's dark interior through the +-- slot. (Strip runs, the drawing marching along the atlas, break at the +-- tileset's own boundaries and were never the problem.) +-- +-- So a run stops at the next 8px lattice line. Buildings are stamped at +-- tx*8 (see stamp), so the model's lattice IS the map's: every quad in the +-- scene -- terrain, props, this -- now ends on the same lines, every join +-- is vertex-for-vertex, and the bend carries them together. What is left +-- is the sag WITHIN one cell, k*64/4, which is under a twentieth of a +-- world pixel at any rung. +-- +-- It costs quads on a dense city map (Cerulean's object stream goes from +-- 35.7k to 41.6k, and its longest edge from 102px to 8px) and it costs them +-- whether the curve is on or not, which is the deliberate trade: the mesh +-- is cached per map and built asynchronously over seconds, so meshing for +-- the curve's sake only when the curve is on would mean rebuilding every +-- live map on a keypress. +local CELL = 8 + +-- How far a run starting at `a` may go before it crosses the next lattice +-- line. Floor-mod, so the awning's negative z lands on the same lines the +-- positive side does. +local function runCap(a) + return CELL - a % CELL +end + local function keyOf(tx, ty) return (ty + 64) * 4096 + (tx + 64) end @@ -170,6 +211,39 @@ local function read(t, data, perRow) local inside = {} for i = 0, W * H - 1 do inside[i] = not outside[i] end + + -- `scrub` names pixel rects where the drawing paints an object standing + -- ON the surface (Red's potted plant on the dining tabletop). The object + -- keeps its own standee -- the template's `keep` leaves its tiles + -- unclaimed -- so the band beneath it is the one surface the drawing + -- implies but never paints clear: every rect pixel takes the field + -- shade, sourced from the first field texel outside the rects, and the + -- model's top comes out as the plain surface the object sat on. + if t.scrub then + local function inRect(x, y) + for _, r in ipairs(t.scrub) do + if x >= r[1] and x <= r[3] and y >= r[2] and y <= r[4] then + return true + end + end + return false + end + local donor = nil + for i = 0, W * H - 1 do + if col[i] == GREY and inside[i] + and not inRect(i % W, math.floor(i / W)) then + donor = i + break + end + end + for i = 0, W * H - 1 do + if inRect(i % W, math.floor(i / W)) then + col[i] = GREY + ax[i], ay[i] = ax[donor], ay[donor] + inside[i] = true + end + end + end return { W = W, H = H, col = col, ax = ax, ay = ay, inside = inside } end @@ -276,6 +350,14 @@ local function measure(sp, t) end end + -- The pane rule reads a LIGHT region the drawing seals behind a BLACK + -- frame. A drawing built the other way round -- the healing machine's + -- dark screens sealed behind their own white bezels -- inverts under + -- it: every lit edge sinks and the black panes stand proud, a black + -- lattice a voxel off the face. `panes = false` says the drawing does + -- not carry the rule's polarity, so the facade stays flush. + if t.panes == false then recess = {} end + -- One representative texel per shade, taken from the building's own art: -- the roof's fascia and its undersides are geometry the drawing implies -- but never paints, and they must still wear its palette (and pick up @@ -300,7 +382,12 @@ local function measure(sp, t) -- onto ground the drawing merely stands its legs on: the lab table's -- third row is the walkable cell the player faces it from, and the -- full-grid depth would stand the model in their path. - return { top = top, ytop = ytop, D = (t.depth or #t.tiles) * 8, + -- `depth` names the plot in TILE ROWS, which is the right grain for a + -- building. `depthPx` names it in voxels, for an object whose real + -- depth is not a whole tile row -- the Bike Shop toolbox is a box + -- standing in the middle of its own cell, not a thing that fills a plot. + return { top = top, ytop = ytop, + D = t.depthPx or ((t.depth or #t.tiles) * 8), ground = ground, recess = recess, interior = interior, shadeTexel = shadeTexel } end @@ -343,19 +430,308 @@ local function deskSetModel(sp, pr, t) return sx end + -- The parts list, shared by every base piece: a desk plane or an + -- open tray rim alike, `plane` is simply the height they ride. + local ytop = 0 + local function buildParts(plane) + for _, p in ipairs(t.parts) do + Budget.tick() + local x0, x1 = p.x[1], p.x[2] + if p.kind == "flat" then + -- drawn row = depth row by default; `z` renames the origin when + -- the flat sits below the desk's own drawn top span (the Center + -- PC's keyboard) + local r0 = p.rows[1] + local z0 = p.z or r0 + for sy = r0, p.rows[2] do + local z = z0 + (sy - r0) + if z >= 0 and z < D then + for sx = x0, x1 do + if inside[sy * W + sx] then put(sx, plane, z, sy * W + sx) end + end + end + end + elseif p.kind == "iso" then + -- An ISO part is drawn in 2:1 isometric -- a box TURNED 45 + -- degrees to the map, so one rhombus carries its top, its front + -- and its side at once and no band or facade split can reach + -- them. Un-projecting it is that projection run backwards: the + -- box stands as a real diamond in plan and every voxel wears the + -- texel the drawing paints where that voxel projects TO. The + -- drawn top lands on the top, the screen on the screen-facing + -- side and the flank on the flank, and nothing is segmented by + -- hand -- which is the only way to get this right, because the + -- three faces meet on a diagonal no rectangle can name. + -- + -- Everything but the depth centre falls out of the drawn rect, + -- because the projection fixes it: the half-width is the drawn + -- rhombus's x radius, HALF that again its z radius (2:1 is what + -- makes it isometric), the near corner's drawn row is the base + -- rhombus's front tip, and whatever drawn height is left once + -- that rhombus is accounted for is the box's own height. Bill's + -- computer: rx 6, rz 3, base centre row 10, and 6 voxels tall -- + -- which puts its left corner's vertical edge at drawn rows + -- 4..10, exactly where the drawing paints one. + -- + -- `plan` is the one thing the drawing CANNOT state: 2:1 is the + -- projection, not the object, so reading rz as the plan radius + -- too builds a box half as deep as it is wide -- a slab, not the + -- cube the drawing depicts. `plan` names the real z radius and + -- the drawn row is scaled into it, so a cube is `plan = rx` and + -- the drawing still lands on it pixel for pixel. + local pr0, pr1 = p.rows[1], p.rows[2] + local rx = math.floor((x1 - x0 + 1) / 2) + local rz = math.floor(rx / 2) + local plan = p.plan or rz + local oy = pr1 - rz + local h = oy - rz - pr0 + local ytp = plane + h + if ytp > ytop then ytop = ytp end + for sx = x0, x1 do + -- doubled, so a rect of even width keeps its centre between + -- two columns instead of limping one to the left + local dx2 = 2 * sx - (x0 + x1) + for dz = -plan, plan do + local z = p.z + dz + local d2 = math.abs(dx2) * plan + 2 * math.abs(dz) * rx + if z >= 0 and z < D and d2 <= (2 * rx + 1) * plan then + -- the plan row scaled back into the drawn rhombus + local dzs = math.floor((2 * dz * rz + plan) / (2 * plan)) + for y = 0, h do + local sy = oy + dzs - y + local i = sy * W + sx + if sy >= pr0 and sy <= pr1 and inside[i] then + put(sx, plane + y, z, i) + end + end + end + end + end + else + local tr0, tr1 = p.top[1], p.top[2] + local fr0, fr1 = p.facade[1], p.facade[2] + local pd = p.depth + -- `rise` lifts a part off the desk's top plane and `z` names its + -- back-most depth row (the field a flat part already carries). An + -- object STANDING on a desk needs neither: it starts on the plane + -- at the plot's back. The healing machine's console needs both -- + -- it stands in the FRONT map row of a grid whose back row is the + -- wall band it leans against, and its screen head is MOUNTED on + -- the console's front two voxels above the body's top. Both come + -- off the drawing, not off taste. + local base = plane + (p.rise or 0) + local pz = p.z or 0 + local ytp = base + (fr1 - fr0) + if ytp > ytop then ytop = ytp end + -- `inset` sinks an authored pane one voxel: the pane rule + -- applied by hand, for a part whose screen IS sealed behind its + -- own black frame while the template's `panes = false` (set for + -- the polarity-inverted panel elsewhere in the same drawing) + -- blocks the global pass. Same mechanism as a recess: the front + -- voxel is simply not placed. + local ins = p.inset + for sx = x0, x1 do + -- the lid: the part's drawn top laid across its depth from the + -- back, last row continuing forward; the front lid row is the + -- facade's own top row -- the drawn front-top edge. `stretch` + -- maps the drawn band over the whole depth instead, the tray's + -- rule: for a part authored DEEPER than its drawing (the house + -- stool grown past its drawn seat), clamping would print the + -- last row as a long smear off the back band's edge. + for z = pz, pz + pd - 1 do + local front = z == pz + pd - 1 + local sy + if front then + sy = fr0 + elseif p.stretch then + sy = math.min(tr0 + math.floor((z - pz) * (tr1 - tr0 + 1) + / (pd - 1)), tr1) + else + sy = math.min(tr0 + z - pz, tr1) + end + while sy <= tr1 and not inside[sy * W + sx] do sy = sy + 1 end + local ok = sy <= tr1 or (front and inside[fr0 * W + sx]) + if ok and z >= 0 and z < D then + put(sx, ytp, z, (front and fr0 or sy) * W + sx) + end + end + -- the body: facade rows anchored to the part's own base + for sy = fr0 + 1, fr1 do + local y = base + (fr1 - sy) + local i = sy * W + sx + if inside[i] then + local ix = interiorAt(sx, sy, x0, x1) + for z = pz, pz + pd - 1 do + if z >= 0 and z < D then + if z == pz + pd - 1 then + local sunk = ins and sx >= ins.x[1] and sx <= ins.x[2] + and sy >= ins.rows[1] and sy <= ins.rows[2] + if not sunk and not pr.recess[i] then put(sx, y, z, i) end + elseif z == pz then + put(sx, y, z, i) + else + put(sx, y, z, sy * W + ix) + end + end + end + end + end + end + end + end + end + + -- A TRAY is an open container -- the drawing looks down INTO it, so its + -- top-view band is not a lid but the inside of the box, and the model + -- has to be hollow. Bands, all measured 1:1 like any other band table: + -- `top` is the opening (drawn row -> depth row), `front` the near wall + -- seen face-on (drawn row -> elevation), `x` the box's outer span and + -- `inner` the opening's, so the difference between them is the wall. + -- Four walls stand to the rim, the floor slab lies `floor` voxels thick + -- under the opening, and the cavity between them is left as AIR -- which + -- is the whole point, and what an extruded facade can never be. Parts (a + -- standing lid) then ride the rim like any object on a desk's plane. + if t.tray then + local tr = t.tray + local top0 = tr.top[1] + local fr0, fr1 = tr.front[1], tr.front[2] + local bx0, bx1 = tr.x[1], tr.x[2] + local ix0, ix1 = tr.inner[1], tr.inner[2] + local floor = tr.floor or 0 + local plane = fr1 - fr0 + 1 -- the rim: the wall's height + -- Which drawn row lies at depth z. The far rim is the band's first + -- row and the near rim the front wall's own, and the drawn inside + -- STRETCHES over whatever depth is between them: a box deeper than + -- its drawing has rows to spare is the ordinary case once the plot + -- stops being the grid, and the alternative -- running out of rows + -- and repeating the last one -- would print the wrench twice. + local lo, hi = top0 + 1, tr.top[2] - 1 -- the drawn inside + local span = math.max(1, D - 3) -- interior depth rows - 1 + local function trayRow(z) + if z == 0 then return top0 end + if z == D - 1 then return fr0 end + return lo + math.floor((z - 1) * (hi - lo) / span) + end + for sx = bx0, bx1 do + Budget.tick() + for z = 0, D - 1 do + local hollow = sx >= ix0 and sx <= ix1 and z > 0 and z < D - 1 + for y = 0, (hollow and floor or plane - 1) do + if hollow or y == plane - 1 then + -- the opening seen from above: the tray's own floor and + -- whatever lies in it -- and the rim is the same band where + -- the wall meets it + local i = trayRow(z) * W + sx + if inside[i] then put(sx, y, z, i) end + else + -- the wall below the rim: the front band folded up it, the + -- drawn face on the front and back layers and the de-outlined + -- interior between, exactly as a facade extrudes. + -- + -- NO recess pass here, and it must stay that way: a pane sinks + -- by DELETING its front voxel so the one behind becomes the + -- pane, and a container's wall is one voxel thick -- there is + -- nothing behind it, so the front panel simply opened a hole + -- straight into the box and you could see the wrench through it. + local sy = fr1 - y + local i = sy * W + sx + if inside[i] then + local px = (z == 0 or z == D - 1) and sx + or interiorAt(sx, sy, bx0, bx1) + put(sx, y, z, sy * W + px) + end + end + end + end + end + if plane > ytop then ytop = plane end + buildParts(plane) + return { at = function(x, y, z) + if x < 0 or x >= W or y < 0 or z < 0 or z >= D then + return nil + end + return vox[key(x, y, z)] + end, + W = W, ytop = ytop, zmin = 0, zmax = D - 1 } + end + + -- No base piece at all: the drawing IS its parts (the house stool -- a + -- seat and its legs, nothing under them but floor). The plane the parts + -- anchor to is the ground itself. + if not t.desk then + buildParts(0) + return { at = function(x, y, z) + if x < 0 or x >= W or y < 0 or z < 0 or z >= D then + return nil + end + return vox[key(x, y, z)] + end, + W = W, ytop = ytop, zmin = 0, zmax = D - 1 } + end + + -- The desk's top plane. Usually the drawing states it: the fascia and + -- base rows it paints below the objects ARE the front face, and their + -- row count is the height. Bill's desk paints neither inside its grid + -- -- its apron is drawn into the WALKABLE cell in front, and that cell + -- is left out on purpose so the chair standing there keeps its own + -- tiles -- so `plane` names the height directly and the body below the + -- lid is synthesized: the band table's own rim treatment, a shaded box + -- closed by the outline where it meets the floor, in the drawing's + -- shades via shadeTexel. local f0, f1 = t.desk.fascia[1], t.desk.fascia[2] local b0, b1 = t.desk.base[1], t.desk.base[2] local plane = (b1 - b0 + 1) + (f1 - f0 + 1) + -- The desk's own PLOT, when the grid holds more than the desk. Bill's + -- grid runs on into the walkable cell, because the drawing puts the + -- desk's apron AND the chair pushed up to it in the same tiles -- so + -- the desk box has to stop at its own cell (`depth`) and stand on its + -- own ground line rather than the grid's, which the chair's feet set + -- eight rows lower. The base band's last row IS that ground line by + -- definition, and for every desk drawn inside its own grid it is the + -- measured one to the row (lab table, lab computers, Center PC, the + -- Bike Shop toolbox), so this changes nothing for them. + -- ...and in voxels (`depthPx`) plus a back origin (`z`) when the desk + -- is shallower than a tile row and leans against something: the + -- healing machine's cabinet is 10 deep -- its drawn top band's 9 rows + -- plus the front edge -- standing against the wall band, so its box + -- runs z 16..25 of a 32-deep plot. + local deskD = t.desk.depthPx or (t.desk.depth and t.desk.depth * 8) or D + local dz0 = t.desk.z or 0 + local dz1 = dz0 + deskD - 1 + local deskG = b1 + 1 + + -- The WALL element: the band the machine backs onto, whose tiles this + -- grid claims. The drawing shows it only as the stripe background + -- around the tower (the same standing as the potted plants' floor), + -- so the block cycles the drawing's own stripe unit -- real pixels of + -- column `x`, rows `cycle` -- at wall-band height over the back plot, + -- exactly what the neighbouring cells' `wall` pins render. + if t.wall then + local wl = t.wall + local c0, c1 = wl.cycle[1], wl.cycle[2] + local cn = c1 - c0 + 1 + local wx = wl.x or 0 + for y = 0, wl.h - 1 do + Budget.tick() + local sy = c0 + (wl.h - 1 - y) % cn + for sx = 0, W - 1 do + for z = 0, wl.depthPx - 1 do + put(sx, y, z, sy * W + wx) + end + end + end + end + -- the base band, extruded exactly like every lab table's for sy = b0, b1 do Budget.tick() - local y = ground - 1 - sy + local y = deskG - 1 - sy for sx = 0, W - 1 do if inside[sy * W + sx] then local ix = interiorAt(sx, sy, 0, W - 1) - for z = 0, D - 1 do - local px = (z == 0 or z == D - 1) and sx or ix + for z = dz0, dz1 do + local px = (z == dz0 or z == dz1) and sx or ix put(sx, y, z, sy * W + px) end end @@ -364,91 +740,72 @@ local function deskSetModel(sp, pr, t) for i in pairs(pr.recess) do local sy = math.floor(i / W) if sy >= b0 and sy <= b1 then - vox[key(i % W, ground - 1 - sy, D - 1)] = nil + vox[key(i % W, deskG - 1 - sy, dz1)] = nil end end - -- the slab: fascia rows wrap every side; the lid continues the - -- sibling tables' top -- black rim, white highlight courses along - -- the north and west, grey field + -- the slab: fascia rows wrap every side for sy = f0, f1 do Budget.tick() local y = plane - 1 - (sy - f0) for sx = 0, W - 1 do - for z = 0, D - 1 do put(sx, y, z, sy * W + sx) end - end - end - local field = t.desk.lid == "white" and WHITE or GREY - for sx = 0, W - 1 do - for z = 0, D - 1 do - local shade = field - if sx == 0 or sx == W - 1 or z == 0 or z == D - 1 then - shade = BLACK - elseif sx == 1 or z == 1 then - shade = WHITE - end - put(sx, plane - 1, z, pr.shadeTexel[shade]) + for z = dz0, dz1 do put(sx, y, z, sy * W + sx) end end end - local ytop = plane - for _, p in ipairs(t.parts) do - Budget.tick() - local x0, x1 = p.x[1], p.x[2] - if p.kind == "flat" then - -- drawn row = depth row by default; `z` renames the origin when - -- the flat sits below the desk's own drawn top span (the Center - -- PC's keyboard) - local r0 = p.rows[1] - local z0 = p.z or r0 - for sy = r0, p.rows[2] do - local z = z0 + (sy - r0) - if z >= 0 and z < D then - for sx = x0, x1 do - if inside[sy * W + sx] then put(sx, plane, z, sy * W + sx) end + if t.desk.top then + -- The lid wears the desk's own drawn top band -- the drawing DOES + -- paint this tabletop (the healing machine's white top face with + -- its lit west and shaded east strips), so nothing is synthesized + -- where it is visible: band rows map back-to-front, the first + -- fascia row is the drawn front-top edge, same rule as an upright + -- part's lid. Where a part's drawing occludes the band (the monitor + -- standing on it), the lid continues the nearest strip BESIDE the + -- part -- still the drawing's own pixels, the same sibling-pattern + -- rule every synthesized lid follows. + local tr0, tr1 = t.desk.top[1], t.desk.top[2] + for z = dz0, dz1 do + Budget.tick() + local sy = z == dz1 and f0 or math.min(tr0 + (z - dz0), tr1) + for sx = 0, W - 1 do + local px = sx + for _, p in ipairs(t.parts) do + local px0, px1 = p.x[1], p.x[2] + local r0, r1 + if p.kind == "flat" or p.kind == "iso" then + r0, r1 = p.rows[1], p.rows[2] + else + r0, r1 = p.top[1], p.facade[2] end - end - end - else - local tr0, tr1 = p.top[1], p.top[2] - local fr0, fr1 = p.facade[1], p.facade[2] - local pd = p.depth - local ytp = plane + (fr1 - fr0) - if ytp > ytop then ytop = ytp end - for sx = x0, x1 do - -- the lid: the part's drawn top laid across its depth from the - -- back, last row continuing forward; the front lid row is the - -- facade's own top row -- the drawn front-top edge - for z = 0, pd - 1 do - local front = z == pd - 1 - local sy = front and fr0 or math.min(tr0 + z, tr1) - while sy <= tr1 and not inside[sy * W + sx] do sy = sy + 1 end - local ok = sy <= tr1 or (front and inside[fr0 * W + sx]) - if ok then - put(sx, ytp, z, (front and fr0 or sy) * W + sx) + if sx >= px0 and sx <= px1 and sy >= r0 and sy <= r1 then + px = (sx - px0 < px1 - sx) and (px0 - 1) or (px1 + 1) + px = math.max(0, math.min(W - 1, px)) + break end end - -- the body: facade rows anchored to the desk's top plane - for sy = fr0 + 1, fr1 do - local y = plane + (fr1 - sy) - local i = sy * W + sx - if inside[i] then - local ix = interiorAt(sx, sy, x0, x1) - for z = 0, pd - 1 do - if z == pd - 1 then - if not pr.recess[i] then put(sx, y, z, i) end - elseif z == 0 then - put(sx, y, z, i) - else - put(sx, y, z, sy * W + ix) - end - end - end + put(sx, plane - 1, z, sy * W + px) + end + end + else + -- the lid continues the sibling tables' top -- black rim, white + -- highlight courses along the north and west, grey field + local field = t.desk.lid == "white" and WHITE or GREY + for sx = 0, W - 1 do + for z = dz0, dz1 do + local shade = field + if sx == 0 or sx == W - 1 or z == dz0 or z == dz1 then + shade = BLACK + elseif sx == 1 or z == dz0 + 1 then + shade = WHITE end + put(sx, plane - 1, z, pr.shadeTexel[shade]) end end end + if plane > ytop then ytop = plane end + buildParts(plane) + return { at = function(x, y, z) if x < 0 or x >= W or y < 0 or z < 0 or z >= D then return nil end return vox[key(x, y, z)] @@ -638,7 +995,8 @@ local function emit(m, sp, atlasW, atlasH) local function runX(y, z, dx, dy, dz, x) local i0 = ci(x, y, z) local strip, n = nil, 1 - while true do + local cap = runCap(x) + while n < cap do local nx = x + n local i = ci(nx, y, z) if not i or ci(nx + dx, y + dy, z + dz) then break end @@ -730,8 +1088,8 @@ local function emit(m, sp, atlasW, atlasH) while z <= zmax do local i = ci(x, y, z) if i and not ci(x + d, y, z) then - local n = 1 - while z + n <= zmax do + local n, cap = 1, runCap(z) + while n < cap and z + n <= zmax do local j = ci(x, y, z + n) if j ~= i or ci(x + d, y, z + n) then break end n = n + 1 @@ -840,7 +1198,7 @@ function Buildings.build(S, map, data, perRow) end built = models[key] end - Buildings.stamp(S, map, built, tx, ty, bw, bh) + Buildings.stamp(S, map, built, tx, ty, bw, bh, t) end end end @@ -850,9 +1208,24 @@ end -- One placement: claim its tiles (so the detector leaves them alone and -- the mesher paints ground under them) and copy the model into place. -function Buildings.stamp(S, map, quads, tx, ty, bw, bh) - local shape = { class = "building", h = 0, art = "building", - flat = false, authored = true } +-- +-- Two template fields alter what a claim means, for a drawing that +-- carries a STANDEE on its surface (Red's potted plant on the dining +-- table). `keep` names tile ids the stamp must NOT claim: their authored +-- pins stay live, so the standee scan still stands the object exactly as +-- it always did. `support` is the model's top plane in voxels: the claim +-- shape carries it as its height, which is what tells that scan the +-- standee's shelf -- a plain claim stays at h = 0, and Structures treats +-- a building claim with height as a full model (skip, never a second +-- box; see its support branches). +function Buildings.stamp(S, map, quads, tx, ty, bw, bh, t) + local shape = { class = "building", h = (t and t.support) or 0, + art = "building", flat = false, authored = true } + local keep = nil + if t and t.keep then + keep = {} + for _, id in ipairs(t.keep) do keep[id] = true end + end -- the ground the building stands on: the commonest flat tile around its -- feet, so a house on a path keeps its path @@ -878,9 +1251,18 @@ function Buildings.stamp(S, map, quads, tx, ty, bw, bh) for r = 0, bh - 1 do for c = 0, bw - 1 do local k = keyOf(tx + c, ty + r) - S.shapeAt[k] = shape - S.skip[k] = true - S.ground[k] = best or false + if keep and keep[S.tileAt[k]] then + -- unclaimed by request: the tile keeps its pin (the plant's + -- cutout pool) and the standee scan finds it there. Only the + -- ground is set now, so the scan's own claim of these tiles has + -- the building's floor to paint when no flat tile touches a + -- cluster ringed by its own furniture. + S.ground[k] = best or false + else + S.shapeAt[k] = shape + S.skip[k] = true + S.ground[k] = best or false + end end end diff --git a/lib/Structures.lua b/lib/Structures.lua index 2e3e70c..ad3d5ec 100644 --- a/lib/Structures.lua +++ b/lib/Structures.lua @@ -75,9 +75,12 @@ local OBJECT_DEPTH = 6 -- voxel thickness of a detected prop -- pure profile; `post` matches the 6px the detector gives the fence -- rows it finds on its own, so pinned and detected fences look alike; -- `signpost` is a plate on a stick -- 2 voxels, the thinnest that still --- shows an edge +-- shows an edge; `bike` is the same 2 for the same reason from the other +-- direction -- a bicycle drawn side-on is a LINE drawing whose negative +-- space is the drawing, and at the 5 voxels `prop` gives, the side faces +-- of neighbouring strokes close every gap in it off-axis local PINNED_DEPTH = { billboard = 10, prop = 5, stool = 10, cutout = 1, - console = 10, post = 6, signpost = 2 } + console = 10, post = 6, signpost = 2, bike = 2 } local MAX_ROWS = 6 -- volume height cap: 48px @@ -297,7 +300,9 @@ function Structures.forMap(map) Structures.buildStairs(S, map, x0, x1, y0, y1) -- ---- bookcases: pinned shelves collapsed to one cell of depth ---- - Structures.buildBookcases(S, map, x0, x1, y0, y1) + -- The atlas comes along so the shelf front can carry its own measured + -- relief: the panes it seals behind its black frames sink a voxel. + Structures.buildBookcases(S, map, x0, x1, y0, y1, pixels(tileset), perRow) -- ---- figures: a person drawn INTO furniture, lifted off it ---- -- Before the region flood and the volume pass, so everything after this @@ -307,6 +312,14 @@ function Structures.forMap(map) -- passes should see, and this needs no pixel access to do it.) Structures.buildFigures(S, map, x0, x1, y0, y1) + -- ---- mounted: a thing drawn INTO a wall band, stood proud of it ---- + -- Here for the same reason and with the same guarantee as the figures + -- above: the repaint hands every pass below the plain panel the profile + -- says is behind the object, so the wall band it was painted into keeps + -- resolving as the wall it is -- without a second copy of the drawing + -- flat on its face. + Structures.buildMounted(S, map, x0, x1, y0, y1) + -- ---- flood-fill regions of structural tiles ---- local seen = {} local regions = {} @@ -558,14 +571,51 @@ end local ROUND_SHADE = { front = 1.0, back = 0.68, side = 0.78, top = 1.0, bottom = 0.55 } -local function roundTemplate(S, map, data, cx, cy, groundTiles, N, capRows) - N = N or 16 -- art canvas: 16 = one cell, 32 = 2x2 cells - local N2 = N / 2 +-- The potted plant's ORGANIC HALF: the leaf crown (16 rows), then the +-- trunk, its root flare and the strands draping over the pot's rim (8 +-- more) -- all of it stands as a slab this many voxels deep instead of +-- revolving. `depth` 5 is the thin standee pool's depth, what every other +-- interior plant already uses. +-- +-- `rows` = 24 puts the slab/revolve boundary AT THE VESSEL'S RIM ROW, and +-- that placement is what makes the pot read as a pot. The first cut put +-- it at the cell seam (16), which let the root and drape rows revolve: +-- their drawn spans are 8-12 wide, so they stacked 8-12-deep discs on top +-- of the rim and the whole base read as one bulbous onion instead of a +-- flat-mouthed planter with a trunk standing out of it. Only rows 24-31 +-- -- black rim edge, gold band, body, foot, the drawn flowerpot profile +-- -- are the vessel, and only they revolve. +local PLANTER_SPRAY = { rows = 24, depth = 5 } + +-- `spray`, when given, caps the chord over the canvas's top `rows` rows to +-- `depth` voxels instead of revolving them. +-- +-- Revolving a row turns its DRAWN WIDTH into depth, which only means +-- something when the drawing states a width to turn -- the pot's rows do +-- (a 3px stem opening to a 12px belly and closing to a 6px foot, an urn's +-- profile), and a tree canopy's do (the ball's outline is drawn). A leaf +-- crown's do NOT: the leaves are a spray that runs off all four sides of +-- its tile, so every row measures the full canvas and the revolve can only +-- produce a solid cylinder -- the "hedge column" a plant must never become, +-- with one row of texels smeared down its whole top face. Where the drawing +-- states no profile, the honest reading is the one the thin standee pools +-- exist for: the foliage stands as a per-pixel slab and keeps the airy +-- silhouette that makes it read as leaves. +local function roundTemplate(S, map, data, cx, cy, groundTiles, N, capRows, + NYin, spray, baseRows, bodyRows, wellRows, + taperVox) + -- The canvas is NX wide and NX DEEP (a hull is round in plan, so its + -- depth is its width) by NY tall. NX = 16 is one cell, 32 a 2x2-cell + -- group; NY defaults to NX -- a ball -- and NY = 2 * NX is a drawing + -- STACKED two cells high on one cell of plot (the potted plant). + local NX = N or 16 + local NY = NYin or NX + local N2 = NX / 2 local perRow = map.tileset.tilesPerRow or 16 local atlasW = map.tileset.imageWidth or 128 local atlasH = map.tileset.imageHeight or 48 - -- cell-space art access (NxN, row 0 = top), anchored at cell (cx, cy) + -- cell-space art access (NX x NY, row 0 = top), anchored at cell (cx, cy) local function tileOf(px, py) return S.tileAt[keyOf(cx * 2 + math.floor(px / 8), cy * 2 + math.floor(py / 8))] @@ -576,19 +626,19 @@ local function roundTemplate(S, map, data, cx, cy, groundTiles, N, capRows) math.floor(tile / perRow) * 8 + py % 8 end - -- shade class of every canvas pixel, indexed py * N + px + -- shade class of every canvas pixel, indexed py * NX + px local cls = {} - for py = 0, N - 1 do - for px = 0, N - 1 do + for py = 0, NY - 1 do + for px = 0, NX - 1 do local ax, ay = texel(px, py) local r, g, b, a = data:getPixel(ax, ay) - cls[py * N + px] = a == 0 and "off" - or Structures.shadeClass(math.min(r, g, b)) + cls[py * NX + px] = a == 0 and "off" + or Structures.shadeClass(math.min(r, g, b)) end end - -- 4-connected flood from the canvas border through `passable` classes - local function floodOutside(passable) + -- 4-connected flood from a row band's border through `passable` classes + local function floodOutside(passable, y0, y1) local out, stack = {}, {} local function seed(i) if not out[i] and passable[cls[i]] then @@ -596,39 +646,56 @@ local function roundTemplate(S, map, data, cx, cy, groundTiles, N, capRows) stack[#stack + 1] = i end end - for i = 0, N - 1 do - seed(i); seed(N * (N - 1) + i); seed(i * N); seed(i * N + N - 1) + for px = 0, NX - 1 do + seed(y0 * NX + px); seed(y1 * NX + px) + end + for py = y0, y1 do + seed(py * NX); seed(py * NX + NX - 1) end while #stack > 0 do local i = table.remove(stack) - local px = i % N + local px, py = i % NX, math.floor(i / NX) if px > 0 then seed(i - 1) end - if px < N - 1 then seed(i + 1) end - if i >= N then seed(i - N) end - if i < N * (N - 1) then seed(i + N) end + if px < NX - 1 then seed(i + 1) end + if py > y0 then seed(i - NX) end + if py < y1 then seed(i + NX) end end return out end - -- the mask: darkest-pixel outline plus its enclosure; dither fallback - local out = floodOutside({ off = true, dark = true, - light = true, white = true }) - local mask, enclosed = {}, 0 - for i = 0, N * N - 1 do - if not out[i] then - mask[i] = true - if cls[i] ~= "black" then enclosed = enclosed + 1 end - end - end - if enclosed < N * N / 8 then - out = floodOutside({ off = true, light = true, white = true }) - mask = {} - for i = 0, N * N - 1 do - if not out[i] and cls[i] ~= "off" then mask[i] = true end + -- The mask -- darkest-pixel outline plus its enclosure, with the dither + -- rule as fallback -- computed per CELL BAND of NX rows. + -- + -- A square canvas is ONE band, so this is exactly the whole-canvas rule + -- it replaces. A STACKED canvas needs it per band because its two halves + -- want opposite answers: the potted plant's leaf crown is a black-outlined + -- dither drawn over floor (outline enclosure keeps it), while its pot is a + -- solid DARK body whose base runs flush to the band's bottom edge (the + -- enclosure flood walks in through dark and guts it, and the fallback -- + -- which the band's own `enclosed` count asks for -- keeps it). Measured on + -- the Center plant: one flood over both bands keeps 53% of the drawing and + -- leaves the pot a hollow black frame; per band keeps 68% and both read. + local mask = {} + for band = 0, NY / NX - 1 do + local y0, y1 = band * NX, band * NX + NX - 1 + local out = floodOutside({ off = true, dark = true, + light = true, white = true }, y0, y1) + local enclosed = 0 + for i = y0 * NX, (y1 + 1) * NX - 1 do + if not out[i] then + mask[i] = true + if cls[i] ~= "black" then enclosed = enclosed + 1 end + end + end + if enclosed < NX * NX / 8 then + out = floodOutside({ off = true, light = true, white = true }, y0, y1) + for i = y0 * NX, (y1 + 1) * NX - 1 do + mask[i] = (not out[i] and cls[i] ~= "off") or nil + end end end local any = nil - for i = 0, N * N - 1 do any = any or mask[i] end + for i = 0, NX * NY - 1 do any = any or mask[i] end if not any then return {} end -- a CAPPED hull (the stump): the top capRows rows of the mask are the @@ -638,24 +705,96 @@ local function roundTemplate(S, map, data, cx, cy, groundTiles, N, capRows) local capY0, capY1 = nil, nil if capRows and capRows > 0 then local top = nil - for iy = 0, N - 1 do - for ix = 0, N - 1 do - if mask[iy * N + ix] then top = iy break end + for iy = 0, NY - 1 do + for ix = 0, NX - 1 do + if mask[iy * NX + ix] then top = iy break end end if top then break end end if top then capY0 = top - capY1 = math.min(top + capRows - 1, N - 2) + capY1 = math.min(top + capRows - 1, NY - 2) for iy = capY0, capY1 do - for ix = 0, N - 1 do mask[iy * N + ix] = nil end + for ix = 0, NX - 1 do mask[iy * NX + ix] = nil end + end + any = nil + for i = 0, NX * NY - 1 do any = any or mask[i] end + if not any then return {} end + end + end + + -- a FLAT-BASED hull (the can): the bottom baseRows rows of the mask are + -- the BASE circle's front arc -- the drawing's mirror of the cut face + -- above, ground contact seen from above rather than body. A can is only + -- round in the horizontal plane, so the drop those rows make toward the + -- middle is DEPTH, not a narrowing of the plan: left as body they revolve + -- into ever smaller discs and the can ends up balanced on a stem three + -- voxels wide (which is exactly what the first build did). Strip them and + -- the foot rule below runs the last body row's full disc straight to the + -- floor; the rows keep their own texels there, so the front view is still + -- the drawing, base rim and all. + local baseArt = nil + if baseRows and baseRows > 0 then + local bot = nil + for iy = NY - 1, 0, -1 do + for ix = 0, NX - 1 do + if mask[iy * NX + ix] then bot = iy break end + end + if bot then break end + end + if bot then + baseArt = {} + for iy = math.max(bot - baseRows + 1, (capY1 or -1) + 2), bot do + for ix = 0, NX - 1 do + local i = iy * NX + ix + if mask[i] then baseArt[i] = true end + mask[i] = nil + end end any = nil - for i = 0, N * N - 1 do any = any or mask[i] end + for i = 0, NX * NY - 1 do any = any or mask[i] end if not any then return {} end end end + -- The can's HEIGHT, and the one place this file departs from the drawing + -- on purpose. Strictly un-projected, the drawing states a squat drum: cut + -- the mouth ellipse off the top and the base circle off the bottom and + -- barely two rows of straight side are left between them, because the GB + -- artist spent most of a 16px cell on the opening. A real bin is TALLER + -- than it is wide, and the flat game reads as one because the drawing is + -- 14px tall next to a 16px player -- so the height is authored (can_height + -- voxels) rather than measured, and the surviving body band is repeated + -- upward to fill it, bottom row first, which continues the drawn rib + -- rhythm instead of inventing a texel. Everything else still comes off + -- the pixels. + local artRow = {} + if bodyRows and bodyRows > 0 then + local body = {} + for iy = 0, NY - 1 do + for ix = 0, NX - 1 do + if mask[iy * NX + ix] then body[#body + 1] = iy break end + end + end + local nb = #body + if nb > 0 then + local top = body[1] + for iy = top - 1, math.max(NY - bodyRows, 0), -1 do + -- the LOWEST surviving body row, repeated: it is the widest and + -- plainest reading of the material (outline, shaded flank, lit + -- face) and stacks into a clean metal cylinder. Cycling the whole + -- surviving band instead stacks the drawn rim arcs into a barcode + -- of hoops, which is detail the drawing never states about the + -- side of the can. + local from = body[nb] + artRow[iy] = from + for ix = 0, NX - 1 do + mask[iy * NX + ix] = mask[from * NX + ix] + end + end + end + end + -- the ground the ball stands on: the drawing's own background names -- it. Score every flat ground tile the map places against the cell's -- unmasked light pixels and keep the closest -- mid-forest trees have @@ -670,11 +809,15 @@ local function roundTemplate(S, map, data, cx, cy, groundTiles, N, capRows) local ox = (t % perRow) * 8 local oy = math.floor(t / perRow) * 8 local score, n = 0, 0 - for py = 0, N - 1 do - for px = 0, N - 1 do - local i = py * N + px + for py = 0, NY - 1 do + for px = 0, NX - 1 do + local i = py * NX + px local c = cls[i] - if not mask[i] and (c == "light" or c == "white") then + -- a stripped base row is the OBJECT's own rim, not background: + -- scoring its whites against the floor tiles matches paper-white + -- ground under a can whose art stands on the gym's grey + if not mask[i] and not (baseArt and baseArt[i]) + and (c == "light" or c == "white") then local ax, ay = texel(px, py) local r1, g1, b1 = data:getPixel(ax, ay) local r2, g2, b2 = data:getPixel(ox + px % 8, oy + py % 8) @@ -691,14 +834,17 @@ local function roundTemplate(S, map, data, cx, cy, groundTiles, N, capRows) end end - -- discs: per mask pixel a z chord [z0, z1), from its row's span circle - local z0, z1, src = {}, {}, {} + -- discs: per mask pixel a z chord [z0, z1), from its row's span circle. + -- z2/z3 is an optional SECOND chord for the same pixel, which only the + -- can's hollow mouth uses: a ring in plan needs a front wall and a back + -- wall at the same column, and one interval cannot say that. + local z0, z1, z2, z3, src, srcX = {}, {}, {}, {}, {}, {} local loRow, hiRow = {}, {} local yBot = nil - for iy = 0, N - 1 do + for iy = 0, NY - 1 do local lo, hi = nil, nil - for ix = 0, N - 1 do - if mask[iy * N + ix] then + for ix = 0, NX - 1 do + if mask[iy * NX + ix] then lo = lo or ix hi = ix end @@ -709,7 +855,7 @@ local function roundTemplate(S, map, data, cx, cy, groundTiles, N, capRows) local c = (lo + hi + 1) / 2 local hw = (hi - lo + 1) / 2 for ix = lo, hi do - local i = iy * N + ix + local i = iy * NX + ix if mask[i] then local dx = ix + 0.5 - c local n = 1 @@ -717,23 +863,148 @@ local function roundTemplate(S, map, data, cx, cy, groundTiles, N, capRows) n = math.max(1, math.floor(2 * math.sqrt(hw * hw - dx * dx) + 0.5)) end + if spray and iy < spray.rows then n = math.min(n, spray.depth) end z0[i] = math.floor(N2 - n / 2 + 0.5) z1[i] = z0[i] + n - src[i] = iy + -- a row the can's body band was repeated into wears the row it + -- was copied from, never a texel of its own + src[i] = artRow[iy] or iy + end + end + end + end + + -- Spray-gap BACKING: the drawing's own gap pixels, one voxel deep at + -- the slab's mid-plane. The flat crown is full of floor showing + -- between leaves; carved as an open slab those gaps became TUNNELS -- + -- the Center couch, the man sitting on it and the void wall all read + -- as pink/orange/black confetti INSIDE the foliage, and the sparse + -- bottom rows (lone drawn leaf tips) floated as disconnected specks + -- against them. The drawing itself backs every gap with its own + -- pixels, so the hull does the same: each in-span gap below drawn + -- foliage takes ITS OWN texel as a plate recessed behind the leaf + -- relief. Coverage is monotone down a column, so the first backed + -- cell always sits directly under a leaf chord -- and every chord + -- spans the mid-plane, so no plate ever caps the crown's top: columns + -- open to the sky stay open and the silhouette keeps its notches. + if spray then + for iy = 1, math.min(spray.rows, NY) - 1 do + if loRow[iy] then + for ix = loRow[iy], hiRow[iy] do + local i = iy * NX + ix + if not z0[i] then + local covered = false + for iy2 = 0, iy - 1 do + if mask[iy2 * NX + ix] then covered = true break end + end + if covered then + z0[i], z1[i], src[i] = N2, N2 + 1, iy + end + end end end end end -- foot: rows under the mask repeat the bottom row's discs, wearing the - -- bottom row's (outline-dark) pixels - for iy = yBot + 1, N - 1 do + -- bottom row's (outline-dark) pixels -- except where a stripped base row + -- DREW something at that pixel, which keeps its own texel, so a can's + -- drawn base rim lands on the model's base instead of being painted over + -- by the body band above it + for iy = yBot + 1, NY - 1 do loRow[iy], hiRow[iy] = loRow[yBot], hiRow[yBot] for ix = loRow[yBot], hiRow[yBot] do - local b = yBot * N + ix + local b = yBot * NX + ix if z0[b] then - local i = iy * N + ix - z0[i], z1[i], src[i] = z0[b], z1[b], yBot + local i = iy * NX + ix + z0[i], z1[i] = z0[b], z1[b] + src[i] = (baseArt and baseArt[i]) and iy or yBot + end + end + end + + -- the TAPER: a bin is a truncated cone, not a tube -- wide at the rim, + -- drawn in a couple of voxels toward the base. The drawing agrees as far + -- as it can (its own base arc pulls in to 9px from the 11px flanks), but + -- it cannot state the whole run, so taperVox is the diameter the base + -- loses and the rows in between interpolate. Every row keeps its plan + -- ROUND: narrow the span, then re-cut the chords from the narrowed span, + -- or the model comes out a cylinder with its corners shaved. + local stepped = {} + if taperVox and taperVox > 0 then + local yTopRow = nil + for iy = 0, NY - 1 do + if loRow[iy] then yTopRow = iy break end + end + local span = NY - 1 - (yTopRow or 0) + if yTopRow and span > 0 then + for iy = yTopRow, NY - 1 do + local inset = math.floor(taperVox / 2 * (iy - yTopRow) / span + 0.5) + if inset > 0 and loRow[iy] then + local lo = loRow[iy] + inset + local hi = hiRow[iy] - inset + if hi - lo < 1 then + lo = math.floor((loRow[iy] + hiRow[iy]) / 2) + hi = lo + 1 + end + for ix = loRow[iy], hiRow[iy] do + if ix < lo or ix > hi then + local i = iy * NX + ix + z0[i], z1[i], z2[i], z3[i] = nil, nil, nil, nil + end + end + -- squeeze the row's ART into the narrowed span rather than + -- clipping its ends off: the drawn outline is the last column + -- either side, and dropping it leaves the taper's new edge + -- wearing an interior texel -- a white chip down the rim + for ix = lo, hi do + srcX[iy * NX + ix] = loRow[iy] + + math.floor((ix - lo) * (hiRow[iy] - loRow[iy]) + / (hi - lo) + 0.5) + end + loRow[iy], hiRow[iy] = lo, hi + stepped[iy] = true + local c = (lo + hi + 1) / 2 + local hw = (hi - lo + 1) / 2 + for ix = lo, hi do + local i = iy * NX + ix + if z0[i] then + local dx = ix + 0.5 - c + local n = 1 + if hw * hw > dx * dx then + n = math.max(1, math.floor(2 * math.sqrt(hw * hw - dx * dx) + + 0.5)) + end + z0[i] = math.floor(N2 - n / 2 + 0.5) + z1[i] = z0[i] + n + end + end + end + end + end + end + + -- the MOUTH: a bin is open, and a solid top wearing the drawn opening + -- only paints one. Hollow the top wellRows voxel rows -- every chord + -- long enough to hold two walls plus a gap keeps a wall at each end and + -- loses its middle, which is a ring in plan, so the model has a real rim + -- to look into. The short chords at the left and right of the row ARE + -- the ring's sides and stay solid on their own. + local wellTop = nil + if wellRows and wellRows > 0 then + for iy = 0, NY - 1 do + if loRow[iy] then wellTop = iy break end + end + local wall = 2 + for iy = wellTop or 0, math.min((wellTop or 0) + wellRows - 1, NY - 1) do + if loRow[iy] then + for ix = loRow[iy], hiRow[iy] do + local i = iy * NX + ix + if z0[i] and z1[i] - z0[i] > wall * 2 then + z2[i], z3[i] = z1[i] - wall, z1[i] + z1[i] = z0[i] + wall + end + end end end end @@ -742,31 +1013,47 @@ local function roundTemplate(S, map, data, cx, cy, groundTiles, N, capRows) -- projection below local capTopRow, capZ0, capZ1 = nil, nil, nil if capY0 then - for iy = 0, N - 1 do + for iy = 0, NY - 1 do if loRow[iy] then capTopRow = iy break end end if capTopRow then for ix = loRow[capTopRow], hiRow[capTopRow] do - local i = capTopRow * N + ix + local i = capTopRow * NX + ix if z0[i] then + -- the OUTER extent, so a hollowed row still projects the mouth + -- across the whole opening and not just its front wall + local back = z3[i] or z1[i] capZ0 = math.min(capZ0 or z0[i], z0[i]) - capZ1 = math.max(capZ1 or z1[i], z1[i]) + capZ1 = math.max(capZ1 or back, back) end end end end + -- the art row the mouth projection puts at depth iz -- the drawn + -- opening's north arc at the far side of the hull, its south arc at the + -- near one. The top-face pass below reads the same mapping; this is the + -- vertical faces inside the well asking it the same question. + local function mouthRow(iz) + if not (capY0 and capZ0 and capZ1) then return 0 end + local t = capZ1 - 1 > capZ0 and (iz - capZ0) / (capZ1 - 1 - capZ0) or 0 + t = math.max(0, math.min(1, t)) + return capY0 + math.floor(t * (capY1 - capY0) + 0.5) + end + local function solidAt(ix, iy, iz) - if ix < 0 or ix > N - 1 or iy < 0 or iy > N - 1 then return false end - local i = iy * N + ix - return z0[i] ~= nil and iz >= z0[i] and iz < z1[i] + if ix < 0 or ix > NX - 1 or iy < 0 or iy > NY - 1 then return false end + local i = iy * NX + ix + if z0[i] == nil then return false end + if iz >= z0[i] and iz < z1[i] then return true end + return z2[i] ~= nil and iz >= z2[i] and iz < z3[i] end -- cap interiors sample the canopy a couple of rows below the rim, -- skipping outline-dark pixels local function deepTexel(ix, iy) - for iy2 = iy + 2, math.min(N - 1, iy + 4) do - local i = iy2 * N + ix + for iy2 = iy + 2, math.min(NY - 1, iy + 4) do + local i = iy2 * NX + ix if mask[i] and cls[i] ~= "black" then return texel(ix, iy2) end end return texel(ix, iy) @@ -778,56 +1065,95 @@ local function roundTemplate(S, map, data, cx, cy, groundTiles, N, capRows) -- ball paints solid black the moment the camera turns. The foot rows -- stay dark on purpose: their whole source row is outline-black. local function sideTexel(ix, iy) - local r = src[iy * N + ix] + -- A foot row's SIDE keeps the last body row's material even where its + -- FRONT wears a stripped base row (the can). The drawn base rim is + -- front-face art; walking the de-outline inside a row that is no longer + -- in the mask breaks at once and hands back the silhouette's own + -- outline, which painted every flank of the can solid black. + local r = (yBot and iy > yBot) and yBot or src[iy * NX + ix] + -- the walk runs in ART columns, so a tapered row starts from the drawn + -- pixel its squeezed span put here rather than from the model column + local a = srcX[iy * NX + ix] or ix local dir = ix + ix < loRow[iy] + hiRow[iy] and 1 or -1 for step = 0, 3 do - local x2 = ix + dir * step - local i2 = r * N + x2 - if x2 < 0 or x2 > N - 1 or not mask[i2] then break end + local x2 = a + dir * step + local i2 = r * NX + x2 + if x2 < 0 or x2 > NX - 1 or not mask[i2] then break end if cls[i2] ~= "black" then return texel(x2, r) end end - return texel(ix, r) + return texel(a, r) end local quads = {} - for iy = 0, N - 1 do + for iy = 0, NY - 1 do if loRow[iy] then - local yB, yT = N - 1 - iy, N - iy + local yB, yT = NY - 1 - iy, NY - iy -- front and back: the drawing per-pixel, columns merged where they -- share a chord plane; a run never crosses the 8px atlas tile seam -- (its u range must interpolate inside one tile) local ix = loRow[iy] while ix <= hiRow[iy] do - local i = iy * N + ix + local i = iy * NX + ix if z0[i] then local ix2 = ix while ix2 + 1 <= hiRow[iy] do - local j = iy * N + ix2 + 1 - if z0[j] == z0[i] and z1[j] == z1[i] + local j = iy * NX + ix2 + 1 + -- src too: a can's foot row draws part of its span from the + -- stripped base rim and the rest from the body band above it, + -- so a run must not straddle two source rows (the u range is + -- interpolated from one row's texels) + if z0[j] == z0[i] and z1[j] == z1[i] and src[j] == src[i] + and z2[j] == z2[i] and z3[j] == z3[i] and math.floor((ix2 + 1) / 8) == math.floor(ix / 8) then ix2 = ix2 + 1 else break end end - local ax0, ay = texel(ix, src[i]) - local ax1 = (texel(ix2, src[i])) - local u0, u1 = (ax0 + 0.05) / atlasW, (ax1 + 0.95) / atlasW - local v0, v1 = (ay + 0.05) / atlasH, (ay + 0.95) / atlasH local x0, x1 = ix - N2, ix2 - N2 + 1 - local zF, zB = z1[i] - N2, z0[i] - N2 - quads[#quads + 1] = { - { x0, yB, zF }, { x1, yB, zF }, { x1, yT, zF }, { x0, yT, zF }, - uv = { { u0, v1 }, { u1, v1 }, { u1, v0 }, { u0, v0 } }, - shade = ROUND_SHADE.front, - } - quads[#quads + 1] = { - { x1, yB, zB }, { x0, yB, zB }, { x0, yT, zB }, { x1, yT, zB }, - uv = { { u1, v1 }, { u0, v1 }, { u0, v0 }, { u1, v0 } }, - shade = ROUND_SHADE.back, - } + -- one facing pair per chord, each face given the art row it + -- should wear. A hollowed mouth row has two chords, and the two + -- faces that look into the well take the drawn OPENING (via the + -- same projection the rim does) rather than the body band: the + -- drawing paints its mouth dark, and an inside-out white wall + -- across the opening is the one thing that stops a bin reading + -- as a bin. + local function facing(za, zb, rowF, rowB) + local zF, zB = zb - N2, za - N2 + local function pair(z, row, shade, back) + local ax0, ay = texel(srcX[i] or ix, row) + local ax1 = (texel(srcX[iy * NX + ix2] or ix2, row)) + local u0, u1 = (ax0 + 0.05) / atlasW, (ax1 + 0.95) / atlasW + local v0, v1 = (ay + 0.05) / atlasH, (ay + 0.95) / atlasH + if back then + quads[#quads + 1] = { + { x1, yB, z }, { x0, yB, z }, { x0, yT, z }, { x1, yT, z }, + uv = { { u1, v1 }, { u0, v1 }, { u0, v0 }, { u1, v0 } }, + shade = shade, + } + else + quads[#quads + 1] = { + { x0, yB, z }, { x1, yB, z }, { x1, yT, z }, { x0, yT, z }, + uv = { { u0, v1 }, { u1, v1 }, { u1, v0 }, { u0, v0 } }, + shade = shade, + } + end + end + pair(zF, rowF, ROUND_SHADE.front, false) + pair(zB, rowB, ROUND_SHADE.back, true) + end + local body = src[i] + if z2[i] then + -- z grows toward the viewer: the low chord is the can's FAR + -- wall, so its +z face is the inside you look across, and the + -- near chord's -z face is the inside of the wall facing you + facing(z0[i], z1[i], mouthRow(z1[i]), body) + facing(z2[i], z3[i], body, mouthRow(z2[i] - 1)) + else + facing(z0[i], z1[i], body, body) + end ix = ix2 + 1 else ix = ix + 1 @@ -837,19 +1163,20 @@ local function roundTemplate(S, map, data, cx, cy, groundTiles, N, capRows) -- sides, steps, undersides: constant-texel quads over the z runs a -- neighbour doesn't cover for ix = loRow[iy], hiRow[iy] do - local i = iy * N + ix + local i = iy * NX + ix if z0[i] then - local ax, ay = texel(ix, src[i]) + local ax, ay = texel(srcX[i] or ix, src[i]) local u, v = (ax + 0.5) / atlasW, (ay + 0.5) / atlasH local x0, x1 = ix - N2, ix - N2 + 1 - -- exposed z pieces against one neighbouring column - local function pieces(nx, ny, emit) - local iz = z0[i] - while iz < z1[i] do + -- exposed z pieces against one neighbouring column, over each of + -- the pixel's chords (a hollowed mouth row has two) + local function chordPieces(nx, ny, emit, zLo, zHi) + local iz = zLo + while iz < zHi do if not solidAt(nx, ny, iz) then local iz2 = iz - while iz2 + 1 < z1[i] and not solidAt(nx, ny, iz2 + 1) do + while iz2 + 1 < zHi and not solidAt(nx, ny, iz2 + 1) do iz2 = iz2 + 1 end emit(iz - N2, iz2 - N2 + 1, iz, iz2) @@ -859,6 +1186,10 @@ local function roundTemplate(S, map, data, cx, cy, groundTiles, N, capRows) end end end + local function pieces(nx, ny, emit) + chordPieces(nx, ny, emit, z0[i], z1[i]) + if z2[i] then chordPieces(nx, ny, emit, z2[i], z3[i]) end + end local sax, say = sideTexel(ix, iy) local su, sv = (sax + 0.5) / atlasW, (say + 0.5) / atlasH @@ -881,7 +1212,12 @@ local function roundTemplate(S, map, data, cx, cy, groundTiles, N, capRows) u = tu, v = tv, shade = ROUND_SHADE.top, } end - if capTopRow and iy == capTopRow and capZ1 then + -- the whole hollowed band takes the projection, not just its + -- top row: the rim ring gets the mouth's outer arcs and the + -- floor of the well gets its middle, so looking in reads as + -- one opening rather than a lid with a hole punched in it + if capTopRow and capZ1 + and iy >= capTopRow and iy <= capTopRow + (wellRows or 0) then -- the CUT FACE (a capped hull's top): project the drawn -- ellipse across the round cap voxel row by voxel row -- -- its top arc at the cap's north rim, its bottom arc at @@ -890,7 +1226,7 @@ local function roundTemplate(S, map, data, cx, cy, groundTiles, N, capRows) local t = capZ1 - 1 > capZ0 and (iz - capZ0) / (capZ1 - 1 - capZ0) or 0 local ry = capY0 + math.floor(t * (capY1 - capY0) + 0.5) - local cax, cay = texel(ix, ry) + local cax, cay = texel(srcX[i] or ix, ry) top(iz - N2, iz - N2 + 1, (cax + 0.5) / atlasW, (cay + 0.5) / atlasH) end @@ -900,11 +1236,21 @@ local function roundTemplate(S, map, data, cx, cy, groundTiles, N, capRows) top(zA, zA + 1, u, v) top(zA + 1, zB - 1, (du + 0.5) / atlasW, (dv + 0.5) / atlasH) top(zB - 1, zB, u, v) + elseif stepped[iy] then + -- a taper STEP: the chord narrowing leaves a ring facing up + -- at the front of the can, and wearing the lit body band it + -- reads as a bright chip taken out of the wall. The drawing's + -- own rim column is black, so the step wears that and the + -- taper reads as a hoop line -- which is how the reference + -- object is banded anyway. + local rx = srcX[iy * NX + loRow[iy]] or loRow[iy] + local rax, ray = texel(rx, src[i]) + top(zA, zB, (rax + 0.5) / atlasW, (ray + 0.5) / atlasH) else top(zA, zB, u, v) end end) - if iy < N - 1 then + if iy < NY - 1 then pieces(ix, iy + 1, function(zA, zB) quads[#quads + 1] = { { x0, yB, zB }, { x1, yB, zB }, { x1, yB, zA }, { x0, yB, zA }, @@ -946,8 +1292,12 @@ function Structures.buildCylinders(S, map, x0, x1, y0, y1, groundTiles) local tsid = tostring(map.tileset.id or map.tileset.image or "?") -- the stump class's drawn-ellipse height, hand-authored per tileset - -- (the profile's stump_cap, in art rows) - local stumpCap = 6 + -- (the profile's stump_cap, in art rows), and the can class's three: the + -- mouth ellipse over the top (can_cap) and the base ellipse under the + -- bottom (can_base), both in art rows, plus the authored can_height in + -- voxels the body band is repeated up to + local stumpCap, canCap, canBase, canHeight, canWell, canTaper + = 6, 9, 4, 9, 5, 4 do local okP, prof = pcall(V.data, "voxel_heights") local entry = okP and type(prof) == "table" and prof.tilesets @@ -955,6 +1305,21 @@ function Structures.buildCylinders(S, map, x0, x1, y0, y1, groundTiles) if entry and type(entry.stump_cap) == "number" then stumpCap = entry.stump_cap end + if entry and type(entry.can_cap) == "number" then + canCap = entry.can_cap + end + if entry and type(entry.can_base) == "number" then + canBase = entry.can_base + end + if entry and type(entry.can_height) == "number" then + canHeight = entry.can_height + end + if entry and type(entry.can_well) == "number" then + canWell = entry.can_well + end + if entry and type(entry.can_taper) == "number" then + canTaper = entry.can_taper + end end -- cells consumed by a 2x2 `canopy` group; the scan runs north to @@ -1015,13 +1380,72 @@ function Structures.buildCylinders(S, map, x0, x1, y0, y1, groundTiles) grouped[ckey + 8192] = true grouped[ckey + 8193] = true end + elseif s and s.art == "planter" and near then + -- ONE 16x32x16 hull over a drawing stacked TWO CELLS HIGH on one + -- cell of plot: the Pokemon Centers' potted plants (a leaf crown + -- over a flared pot, 78 placements across 13 maps). + -- + -- The anchor is the NORTH cell -- the crown, where the canvas + -- starts -- but the hull stands in the SOUTH cell, because that is + -- where the pot is drawn and an object's ground contact is its + -- plot. The crown is therefore HEIGHT, not depth: the north cell + -- is claimed and left as floor for the crown to overhang, which is + -- what un-projecting the 3/4 view means here. Pinning only one of + -- the two cells leaves the drawing partial (a map edit, a mod's + -- stray tile), so the anchor is left alone rather than carved into + -- half a plant. + local below = S.shapeAt[keyOf(cx * 2, (cy + 1) * 2)] + if below and below.art == "planter" then + local ground = false + if data then + local ids = {} + for dy = 0, 3 do + for dx = 0, 1 do + ids[#ids + 1] = S.tileAt[keyOf(cx * 2 + dx, cy * 2 + dy)] + end + end + local sig = tsid .. "|p32|" .. gsig .. "|" + .. table.concat(ids, ":") + local tpl = roundCache[sig] + if not tpl then + local tq, tbg = roundTemplate(S, map, data, cx, cy, + groundTiles, 16, nil, 32, + PLANTER_SPRAY) + tpl = { quads = tq, bg = tbg } + roundCache[sig] = tpl + end + ground = tpl.bg or false + S.roundStamps[#S.roundStamps + 1] = + { quads = tpl.quads, mx = cx * 16 + 8, + mz = (cy + 1) * 16 + 8 } + end + for dy = 0, 3 do + for dx = 0, 1 do + local tk = keyOf(cx * 2 + dx, cy * 2 + dy) + S.skip[tk] = true + S.ground[tk] = ground + end + end + grouped[ckey + 8192] = true + end elseif s and s.art == "cylinder" and near then -- a `stump`-class cell is the same hull with a cut face: its - -- top capRows of drawing project onto the round top - local cap = s.class == "stump" and stumpCap or nil + -- top capRows of drawing project onto the round top. A `can`-class + -- cell is that hull cut at BOTH ends -- lid on top, base circle on + -- the floor -- which is what a drum standing on a floor is. + local cap = (s.class == "stump" and stumpCap) + or (s.class == "can" and canCap) or nil + local base = s.class == "can" and canBase or nil + local tall = s.class == "can" and canHeight or nil + local well = s.class == "can" and canWell or nil + local taper = s.class == "can" and canTaper or nil local ground = false if data then - local sig = tsid .. (cap and ("|c" .. cap) or "") .. "|" + local sig = tsid .. (cap and ("|c" .. cap) or "") + .. (base and ("|b" .. base) or "") + .. (tall and ("|h" .. tall) or "") + .. (well and ("|w" .. well) or "") + .. (taper and ("|t" .. taper) or "") .. "|" .. gsig .. "|" .. table.concat({ S.tileAt[k], S.tileAt[keyOf(cx * 2 + 1, cy * 2)], S.tileAt[keyOf(cx * 2, cy * 2 + 1)], @@ -1029,7 +1453,8 @@ function Structures.buildCylinders(S, map, x0, x1, y0, y1, groundTiles) local tpl = roundCache[sig] if not tpl then local tq, tbg = roundTemplate(S, map, data, cx, cy, - groundTiles, 16, cap) + groundTiles, 16, cap, nil, nil, + base, tall, well, taper) tpl = { quads = tq, bg = tbg } roundCache[sig] = tpl end @@ -1179,11 +1604,107 @@ end -- When the row just above a rank is undetected structure (a shared trim -- tile the profile cannot pin), the rank adopts it as its CAP: one more -- band of height and the art its top face wears. -local BOOK_SHADE = { south = 1.0, north = 0.68, flank = 0.8, top = 0.85 } +local BOOK_SHADE = { south = 1.0, north = 0.68, flank = 0.8, top = 0.85, + -- a pane's reveal: the one-voxel side of the frame + -- standing proud of it. The sill catches the light + -- the top face does; the lintel is in shadow. + sill = 0.85, lintel = 0.5 } + +-- A pane is a shelf opening, a glass door or an inset panel: a non-black +-- region the drawing SEALS OFF behind its own black frame. Anything +-- wider or taller than this is a band of the front itself -- a trim +-- course, a plinth -- and stays flush. The same number and the same +-- rule lib/Buildings.lua measures a facade's panes with, so a shelf the +-- band pipeline models and a shelf this class collapses carry the same +-- relief. +local BOOK_RECESS_MAX = 24 + +-- The panes of a BANK of ranks -- every rank of the same height standing +-- side by side -- as a mask over the bank's south face, plus the atlas +-- pixel each face texel comes from. Measured over the whole bank rather +-- than per column, because a door panel drawn across two tiles is one +-- region and not two halves, and because the size test that keeps a +-- broad course flush has to see the course's real width. +-- +-- `fx` runs across the bank and `fy` DOWN from its top, so the grid +-- reads like the drawing: the rank folds its tiles up band by band, the +-- southmost row lowest, and fy = 0 is the topmost drawn row. +local function bookcasePanes(map, data, perRow, run, i, j) + if not data then return nil end + local bands = run[i].bands + local size = run[i].front - run[i].top + 1 + local W, H = (j - i + 1) * 8, bands * 8 + local light, srcU, srcV = {}, {}, {} + for fy = 0, H - 1 do + local band = bands - 1 - math.floor(fy / 8) + local row = fy % 8 + for fx = 0, W - 1 do + local col = run[i + math.floor(fx / 8)] + local tile = band < size and map:tileAt(col.tx, col.front - band) + or col.cap + if tile then + local k = fy * W + fx + local ax = (tile % perRow) * 8 + fx % 8 + local ay = math.floor(tile / perRow) * 8 + row + srcU[k], srcV[k] = ax, ay + local r, g, b, a = data:getPixel(ax, ay) + light[k] = a ~= 0 + and Structures.shadeClass(math.min(r, g, b)) ~= "black" + end + end + end + + -- The drawing's non-black regions, split across its black frames. A + -- region that reaches the face's own border is not sealed by anything + -- -- it is a course of the front running edge to edge, the way a + -- masonry band or a wall of siding does -- and it stays flush. That + -- test is what keeps this rule to shelves: `bookcase` also collapses + -- the League's gate walls and the terraces, and their courses run off + -- the drawing, so nothing there sinks. + local pane, seen = {}, {} + for k0 = 0, W * H - 1 do + if light[k0] and not seen[k0] then + local cells, stack = {}, { k0 } + seen[k0] = true + local ax0, ax1 = k0 % W, k0 % W + local ay0, ay1 = math.floor(k0 / W), math.floor(k0 / W) + local edge = false + while #stack > 0 do + local k = table.remove(stack) + cells[#cells + 1] = k + local cx, cy = k % W, math.floor(k / W) + if cx < ax0 then ax0 = cx end + if cx > ax1 then ax1 = cx end + if cy < ay0 then ay0 = cy end + if cy > ay1 then ay1 = cy end + if cx == 0 or cx == W - 1 or cy == 0 or cy == H - 1 then + edge = true + end + for _, d in ipairs(DIRS4) do + local nx, ny = cx + d[1], cy + d[2] + if nx >= 0 and nx < W and ny >= 0 and ny < H then + local nk = ny * W + nx + if light[nk] and not seen[nk] then + seen[nk] = true + stack[#stack + 1] = nk + end + end + end + end + if not edge and ax1 - ax0 < BOOK_RECESS_MAX + and ay1 - ay0 < BOOK_RECESS_MAX then + for _, k in ipairs(cells) do pane[k] = true end + end + end + end + return pane, srcU, srcV, W, H +end -local function bookcaseRank(S, map, tx, northTy, frontTy, capTile) +local function bookcaseRank(S, map, perRow, run, i, j, k, pane, srcU, srcV, + bankW, bankH) + local r = run[k] + local tx, northTy, frontTy, capTile = r.tx, r.top, r.front, r.cap local quads = S.objectQuads - local perRow = map.tileset.tilesPerRow or 16 local atlasW = map.tileset.imageWidth or 128 local atlasH = map.tileset.imageHeight or 48 local function uvRect(tile) @@ -1194,12 +1715,13 @@ local function bookcaseRank(S, map, tx, northTy, frontTy, capTile) end local size = frontTy - northTy + 1 - local bands = size + (capTile and 1 or 0) + local bands = r.bands local h = bands * 8 local depth = math.min(2, size) * 8 local x0, x1 = tx * 8, tx * 8 + 8 local z1 = frontTy * 8 + 8 local z0 = z1 - depth + local fx0 = (k - i) * 8 -- this rank's columns within the bank -- does the neighbouring column continue this shelf? (flanks only cap -- the ends of a run of bookcases standing side by side) @@ -1208,14 +1730,101 @@ local function bookcaseRank(S, map, tx, northTy, frontTy, capTile) return ns ~= nil and ns.art == "bookcase" end + local function sunk(fx, fy) + if not pane or fx < 0 or fx >= bankW or fy < 0 or fy >= bankH then + return false + end + return pane[fy * bankW + fx] == true + end + for band = 0, bands - 1 do local tile = band < size and map:tileAt(tx, frontTy - band) or capTile local u0, u1, v0, v1 = uvRect(tile) local y0, y1 = band * 8, band * 8 + 8 - quads[#quads + 1] = { { x0, y0, z1 }, { x1, y0, z1 }, - { x1, y1, z1 }, { x0, y1, z1 }, - uv = { { u0, v1 }, { u1, v1 }, { u1, v0 }, { u0, v0 } }, - shade = BOOK_SHADE.south } + local fyTop = (bands - 1 - band) * 8 + + -- The south face: the drawing folded upright. A band with no pane + -- in it is the single quad it has always been; a band that seals + -- one splits into per-row runs of texels, and the pane's run sinks + -- a voxel behind the frame that stays proud around it. + local relief = false + if pane then + for row = 0, 7 do + for c = 0, 7 do + if sunk(fx0 + c, fyTop + row) then relief = true break end + end + if relief then break end + end + end + if not relief then + quads[#quads + 1] = { { x0, y0, z1 }, { x1, y0, z1 }, + { x1, y1, z1 }, { x0, y1, z1 }, + uv = { { u0, v1 }, { u1, v1 }, { u1, v0 }, { u0, v0 } }, + shade = BOOK_SHADE.south } + else + local ax = (tile % perRow) * 8 + local ay = math.floor(tile / perRow) * 8 + for row = 0, 7 do + local fy = fyTop + row + local wy = y0 + 7 - row -- the drawing's row 0 is the top + local c = 0 + while c < 8 do + local s = sunk(fx0 + c, fy) + local n = 1 + while c + n < 8 and sunk(fx0 + c + n, fy) == s do n = n + 1 end + local pz = s and z1 - 1 or z1 + local qu0 = (ax + c + 0.05) / atlasW + local qu1 = (ax + c + n - 0.05) / atlasW + local qv0 = (ay + row + 0.05) / atlasH + local qv1 = (ay + row + 1 - 0.05) / atlasH + quads[#quads + 1] = { { x0 + c, wy, pz }, { x0 + c + n, wy, pz }, + { x0 + c + n, wy + 1, pz }, { x0 + c, wy + 1, pz }, + uv = { { qu0, qv1 }, { qu1, qv1 }, { qu1, qv0 }, { qu0, qv0 } }, + shade = BOOK_SHADE.south } + c = c + n + end + end + -- the reveals: where a sunk texel meets a proud one, the frame's + -- own one-voxel side shows. It wears the PROUD neighbour's texel, + -- because that is the block it belongs to. A pane running off the + -- bank, or off the top or bottom of the rank, needs none: the + -- flank and top faces already close it. + for row = 0, 7 do + local fy = fyTop + row + local wy = y0 + 7 - row + for c = 0, 7 do + if sunk(fx0 + c, fy) then + local X = x0 + c + local function reveal(nfx, nfy, verts, shade) + if nfx < 0 or nfx >= bankW or nfy < 0 or nfy >= bankH then + return + end + if sunk(nfx, nfy) then return end + local nk = nfy * bankW + nfx + if not srcU[nk] then return end + quads[#quads + 1] = { verts[1], verts[2], verts[3], verts[4], + u = (srcU[nk] + 0.5) / atlasW, v = (srcV[nk] + 0.5) / atlasH, + shade = shade } + end + reveal(fx0 + c - 1, fy, { + { X, wy, z1 }, { X, wy, z1 - 1 }, + { X, wy + 1, z1 - 1 }, { X, wy + 1, z1 } }, BOOK_SHADE.flank) + reveal(fx0 + c + 1, fy, { + { X + 1, wy, z1 - 1 }, { X + 1, wy, z1 }, + { X + 1, wy + 1, z1 }, { X + 1, wy + 1, z1 - 1 } }, + BOOK_SHADE.flank) + reveal(fx0 + c, fy + 1, { + { X, wy, z1 - 1 }, { X + 1, wy, z1 - 1 }, + { X + 1, wy, z1 }, { X, wy, z1 } }, BOOK_SHADE.sill) + reveal(fx0 + c, fy - 1, { + { X, wy + 1, z1 }, { X + 1, wy + 1, z1 }, + { X + 1, wy + 1, z1 - 1 }, { X, wy + 1, z1 - 1 } }, + BOOK_SHADE.lintel) + end + end + end + end + quads[#quads + 1] = { { x1, y0, z0 }, { x0, y0, z0 }, { x0, y1, z0 }, { x1, y1, z0 }, uv = { { u0, v1 }, { u1, v1 }, { u1, v0 }, { u0, v0 } }, @@ -1245,10 +1854,19 @@ local function bookcaseRank(S, map, tx, northTy, frontTy, capTile) end end -function Structures.buildBookcases(S, map, x0, x1, y0, y1) +function Structures.buildBookcases(S, map, x0, x1, y0, y1, data, perRow) + perRow = perRow or map.tileset.tilesPerRow or 16 -- What to do with the rows a rank VACATES (see TileShape.bookcaseBackfill). -- Read once: it is a property of the tileset, not of the column. local backfill = TileShape.bookcaseBackfill(map.tileset.id) + -- the front's measured relief: on for a shelf, off for the tilesets + -- that borrow the collapse for masonry or machinery + if not TileShape.bookcaseRelief(map.tileset.id) then data = nil end + -- Ranks are collected here and emitted after the sweep: a rank's panes + -- are measured over the whole BANK it stands in (see bookcasePanes), + -- and the bank is only known once every column has been read. Nothing + -- below this loop mutates what the sweep reads, so deferring is free. + local order, banks = {}, {} for tx = x0, x1 do local ty = y1 while ty >= y0 do @@ -1295,7 +1913,17 @@ function Structures.buildBookcases(S, map, x0, x1, y0, y1) S.ground[tk] = false end end - bookcaseRank(S, map, tx, top, front, capTile) + -- ranks of the same height standing side by side are one bank + local bands = (front - top + 1) + (capTile and 1 or 0) + local key = top .. ":" .. front .. ":" .. bands + local bank = banks[key] + if not bank then + bank = {} + banks[key] = bank + order[#order + 1] = key + end + bank[#bank + 1] = { tx = tx, top = top, front = front, + cap = capTile, bands = bands } front = top - 1 end ty = north - 1 @@ -1304,6 +1932,24 @@ function Structures.buildBookcases(S, map, x0, x1, y0, y1) end end end + + -- tx ascends in the sweep above, so each bank's columns are already in + -- order; split them into the contiguous runs that actually touch + for _, key in ipairs(order) do + local run = banks[key] + local i = 1 + while i <= #run do + local j = i + while j < #run and run[j + 1].tx == run[j].tx + 1 do j = j + 1 end + local pane, srcU, srcV, bankW, bankH = + bookcasePanes(map, data, perRow, run, i, j) + for k = i, j do + bookcaseRank(S, map, perRow, run, i, j, k, + pane, srcU, srcV, bankW, bankH) + end + i = j + 1 + end + end end -- ---- stairs: pinned cells that render as real steps ---- @@ -2023,8 +2669,14 @@ function Structures.buildObject(S, map, region, cluster, -- stacked box is still a box: the Plateau's gate pilasters carry a -- statue on 48 of their tops, and collapsing the pilaster to a stacked -- run made every one of them fail this test and drop to ground level. + -- A `building` claim supports too, when it carries a height: a + -- Buildings template that names `support` is furniture modelled in + -- full with a standee left standing on it (Red's dining table under + -- its potted plant), and the height it states is the model's top + -- plane. A plain claim stays at h = 0 and supports nothing. if blocked and bs and bs.authored and (bs.h or 0) > 0 - and (bs.art == "upright" or bs.art == "bookcase") then + and (bs.art == "upright" or bs.art == "bookcase" + or bs.class == "building") then baseY, support = bs.h, bs end end @@ -2159,7 +2811,8 @@ function Structures.buildObject(S, map, region, cluster, for _, c in ipairs(cluster.tiles) do local k = keyOf(c[1], c[2]) if support and (support.class == "wall" or support.class == "cliff" - or support.art == "bookcase") then + or support.art == "bookcase" + or support.class == "building") then -- a figure drawn above a FULL-HEIGHT block (the gym statue on its -- plinth) is a statue on a pillar with ONE cell of footprint: the -- block below already carries the whole base, so the drawn cell @@ -2174,8 +2827,14 @@ function Structures.buildObject(S, map, region, cluster, -- pilasters found this -- taking the furniture branch turned each -- statue's own two rows into a 32px box wearing the pilaster's art, -- so every one of them stood inside a slab of its own plinth. + -- A `building` support belongs here too: the template's stamped + -- model already carries every surface under the standee (that is + -- what its `support` height asserts), so a box here would stand + -- INSIDE the modelled tabletop. Its stamp pre-painted the floor + -- under these tiles, which the `or` keeps when no flat tile + -- touches a cluster ringed by its own furniture. S.skip[k] = true - S.ground[k] = best + S.ground[k] = best or S.ground[k] elseif support then -- the claimed tile keeps rendering as the box the prop stands on, -- wearing the art its own ROW would have without the drawing (the @@ -2204,7 +2863,167 @@ function Structures.buildObject(S, map, region, cluster, return true end --- ---- figures: a person drawn INTO furniture, cut out and stood up ---- +-- ---- authored masks with a body ---- + +-- One authored mask emitted as a per-pixel voxel slab in WORLD space -- +-- the treatment every solid standee in this file gets, driven by a hand +-- drawn silhouette instead of a flood. +-- +-- The caller owns placement entirely, because placement is the whole +-- difference between the two things that use this: `x0` is the world x of +-- the mask's west edge, `yOf(ly)` the world y a drawn row lands at, and +-- `bandOf(ly)` its z span. A bicycle hung on a wall keeps its drawn +-- elevation and juts south of the band; a cash register stands on the +-- counter's top plane and sits inside its own cell. +-- +-- `bandOf` is per ROW rather than per object so one drawing can hold parts +-- of different thickness (the register's receipt curl over its body). +-- Where the band CHANGES between two stacked rows the lower row still gets +-- its top face: without that the body would be open along the strip the +-- thinner part does not cover, and you would see into the machine. +-- +-- `omit` is a rect of the mask this pass does NOT extrude, because it is +-- not a face at all -- maskPlate lays it flat instead. It leaves the mask +-- for good here, neighbours included, so the extrusion closes up around +-- the notch exactly as if the drawing had never filled it. +local function maskSlab(quads, m, perRow, atlasW, atlasH, x0, yOf, bandOf, + yFloor, omit) + local bw, bh = m.w * 8, m.h * 8 + + local function at(lx, ly) + if lx < 0 or lx >= bw or ly < 0 or ly >= bh then return false end + if omit and lx >= omit.x0 and lx <= omit.x1 + and ly >= omit.r0 and ly <= omit.r1 then return false end + return m.mask[ly * bw + lx] or false + end + + for ly = 0, bh - 1 do + Budget.tick() + local z0, z1 = bandOf(ly) + local pz0, pz1 = bandOf(ly - 1) + local capped = (pz0 ~= z0 or pz1 ~= z1) + for lx = 0, bw - 1 do + if at(lx, ly) then + local tile = m.tiles[math.floor(ly / 8) * m.w + + math.floor(lx / 8) + 1] + local u = ((tile % perRow) * 8 + lx % 8 + 0.5) / atlasW + local v = (math.floor(tile / perRow) * 8 + ly % 8 + 0.5) / atlasH + local x, y = x0 + lx, yOf(ly) + local function quad(c1, c2, c3, c4, shade) + quads[#quads + 1] = { c1, c2, c3, c4, u = u, v = v, shade = shade } + end + quad({ x, y, z1 }, { x + 1, y, z1 }, { x + 1, y + 1, z1 }, + { x, y + 1, z1 }, OBJ_SHADE.front) + quad({ x + 1, y, z0 }, { x, y, z0 }, { x, y + 1, z0 }, + { x + 1, y + 1, z0 }, OBJ_SHADE.back) + if capped or not at(lx, ly - 1) then + quad({ x, y + 1, z0 }, { x + 1, y + 1, z0 }, { x + 1, y + 1, z1 }, + { x, y + 1, z1 }, OBJ_SHADE.top) + end + if y > yFloor and not at(lx, ly + 1) then + quad({ x, y, z1 }, { x + 1, y, z1 }, { x + 1, y, z0 }, + { x, y, z0 }, OBJ_SHADE.bottom) + end + if not at(lx - 1, ly) then + quad({ x, y, z0 }, { x, y, z1 }, { x, y + 1, z1 }, + { x, y + 1, z0 }, OBJ_SHADE.side) + end + if not at(lx + 1, ly) then + quad({ x + 1, y, z1 }, { x + 1, y, z0 }, { x + 1, y + 1, z0 }, + { x + 1, y + 1, z1 }, OBJ_SHADE.side) + end + end + end + end +end + +-- The other half of the same drawing: a rect of the mask that is a +-- TOP-VIEW surface, laid HORIZONTAL instead of extruded. +-- +-- This is the methodology's band classification at rect granularity, and +-- the reason the register is not a box. A GB cell packs several facings, +-- and the register's keypad is drawn from ABOVE -- its keys lie on the +-- machine's deck, sealed behind their own black border inside the outer +-- silhouette. Extruding it stands that surface on end and paints the keys +-- up the machine's face, which is the extruded-picture failure exactly. +-- +-- So the rect lands one voxel proud of what maskSlab left below it, at `y`, +-- one voxel thick, filling the body's whole depth band (`z0`, `D`). +-- +-- The rect STRETCHES over that band rather than laying its rows 1:1: it is +-- the machine's whole deck, so it has to reach the machine's whole depth, +-- and the alternative -- panel at the front, bare deck behind -- leaves a +-- strip of the base band's top showing through where the keys should be. +-- Sampled at the voxel's CENTRE, the same rule Stage 1 samples the atlas +-- with, so a band scales by whole voxels and nothing blurs: at 8 rows over +-- 12 voxels every second drawn row doubles. The one place in the model +-- where a texel is not 1:1 with a drawn pixel, and the reason `depth` is an +-- authored number again. No bottom faces: it rests on the box. +local function maskPlate(quads, m, perRow, atlasW, atlasH, x0, r, y, z0, D) + local bw, bh = m.w * 8, m.h * 8 + local rows = r.r1 - r.r0 + 1 + + -- depth voxel -> the drawn row it wears + local function rowAt(k) + if k < 0 or k >= D then return nil end + return r.r0 + math.min(rows - 1, math.floor((k + 0.5) * rows / D)) + end + + local function at(lx, k) + local ly = rowAt(k) + if not ly or lx < r.x0 or lx > r.x1 then return false end + return m.mask[ly * bw + lx] or false + end + + -- The plate's rim, in the two directions the drawing treats differently. + -- ACROSS the rows the neighbour is the extrusion standing BESIDE the + -- notch (the register's display unit), which is tall and covers the + -- plate's edge, so that face must not be drawn twice. ALONG them the + -- neighbour is the extrusion BELOW it (the base band, whose own front + -- face stops one voxel short), so the plate's front lip is exposed and + -- is the deck's own front edge. + local function beside(lx, ly) + if lx < 0 or lx >= bw or ly < 0 or ly >= bh then return false end + return m.mask[ly * bw + lx] or false + end + + for k = 0, D - 1 do + Budget.tick() + local ly, z = rowAt(k), z0 + k + for lx = r.x0, r.x1 do + if at(lx, k) then + local tile = m.tiles[math.floor(ly / 8) * m.w + + math.floor(lx / 8) + 1] + local u = ((tile % perRow) * 8 + lx % 8 + 0.5) / atlasW + local v = (math.floor(tile / perRow) * 8 + ly % 8 + 0.5) / atlasH + local x = x0 + lx + local function quad(c1, c2, c3, c4, shade) + quads[#quads + 1] = { c1, c2, c3, c4, u = u, v = v, shade = shade } + end + quad({ x, y + 1, z }, { x + 1, y + 1, z }, { x + 1, y + 1, z + 1 }, + { x, y + 1, z + 1 }, OBJ_SHADE.top) + if not at(lx, k + 1) then + quad({ x, y, z + 1 }, { x + 1, y, z + 1 }, { x + 1, y + 1, z + 1 }, + { x, y + 1, z + 1 }, OBJ_SHADE.front) + end + if not at(lx, k - 1) then + quad({ x + 1, y, z }, { x, y, z }, { x, y + 1, z }, + { x + 1, y + 1, z }, OBJ_SHADE.back) + end + if not beside(lx - 1, ly) then + quad({ x, y, z }, { x, y, z + 1 }, { x, y + 1, z + 1 }, + { x, y + 1, z }, OBJ_SHADE.side) + end + if not beside(lx + 1, ly) then + quad({ x + 1, y, z + 1 }, { x + 1, y, z }, { x + 1, y + 1, z }, + { x + 1, y + 1, z + 1 }, OBJ_SHADE.side) + end + end + end + end +end + +-- ---- figures: a thing drawn INTO furniture, cut out and stood up ---- -- One authored figure at one matched position. -- @@ -2215,22 +3034,34 @@ end -- believe it. Which also means figures build HEADLESS: unlike every -- other standee here, nothing below reads a pixel. -- --- A figure is a SPRITE, not a prop. It gets exactly the treatment --- SpriteBillboards gives a character: one flat plane of the drawing's own --- pixels, no thickness, standing at its feet and leaned back by the --- camera's pitch at draw time so it always reads face-on -- because that --- is what the artwork is. A seated man drawn face-on is a 2D icon like --- every other Gen 1 figure; extruding him into a slab reconstructs a body --- nobody drew (the ten-voxel version read as a wedge of furniture, and --- even one voxel showed an edge the sprites never show). +-- A PERSON is a SPRITE, not a prop, and an entry that states no `depth` +-- gets exactly the treatment SpriteBillboards gives a character: one flat +-- plane of the drawing's own pixels, no thickness, standing at its feet +-- and leaned back by the camera's pitch at draw time so it always reads +-- face-on -- because that is what the artwork is. A seated man drawn +-- face-on is a 2D icon like every other Gen 1 figure; extruding him into +-- a slab reconstructs a body nobody drew (the ten-voxel version read as a +-- wedge of furniture, and even one voxel showed an edge the sprites never +-- show). -- --- So the quads are emitted in the card's OWN LOCAL SPACE -- x from the +-- So the card's quads are emitted in its OWN LOCAL SPACE -- x from the -- mask's west edge, y from his feet, all at z = 0 -- and the placement -- (`wx`, `wz`, `y`) rides along for VoxelScene to build the lean matrix -- from. One quad per pixel rather than one alpha-keyed texture: the -- tileset atlas has no alpha to key on, and per-pixel quads cut the exact -- same silhouette straight out of the live atlas, so every palette bake -- (SGB, RED++ per-tile groups, a mod's own art) textures him for free. +-- +-- An entry that DOES state a `depth` is not a person, and takes the other +-- branch: a per-pixel voxel slab in world space (maskSlab above), standing +-- on the same furniture the card would have stood on. The Marts' cash +-- register is why -- a machine set down on a counter is a box seen from +-- the front, and a card of it is the billboard failure the standee pools +-- exist to avoid. It keeps the card's anchoring exactly: its feet on the +-- support's top plane, and its body in the 8px depth band of the tile row +-- its lowest pixel is drawn in, which is where a character card would +-- have pivoted. So the machine sits at the FRONT of the counter cell it +-- is drawn low in, and never leans into the aisle behind it. local function buildFigure(S, map, fig, tx, ty, perRow) local bw, bh = fig.w * 8, fig.h * 8 @@ -2273,34 +3104,76 @@ local function buildFigure(S, map, fig, tx, ty, perRow) local atlasW = map.tileset.imageWidth or 128 local atlasH = map.tileset.imageHeight or 48 - local quads = {} - for ly = 0, bh - 1 do - Budget.tick() - for lx = 0, bw - 1 do - if at(lx, ly) then - local tile = fig.tiles[math.floor(ly / 8) * fig.w - + math.floor(lx / 8) + 1] - local u = ((tile % perRow) * 8 + lx % 8 + 0.5) / atlasW - local v = (math.floor(tile / perRow) * 8 + ly % 8 + 0.5) / atlasH - local x, y = lx - minX, lowY - ly - quads[#quads + 1] = { { x, y, 0 }, { x + 1, y, 0 }, - { x + 1, y + 1, 0 }, { x, y + 1, 0 }, - u = u, v = v, shade = 1 } + + if fig.depth then + -- An OBJECT: the standee slab, standing on the FRONT edge of the tile + -- row its feet are drawn in -- the south face of the 8px band a + -- character card would have pivoted in. It is anchored there and + -- grows NORTH rather than being centred, so that `depth` is free to + -- exceed the 8px band without the machine ever creeping toward the + -- aisle: a till drawn low on a counter is at the counter's front, and + -- a deeper one just eats more of the bare top behind it. (At the + -- 8 the band itself is, the two rules agree.) + -- + -- `thin` caps the top rows to their own thickness, centred in the + -- body's depth -- the register's receipt curl leaves the arm's top + -- face by a slot in the middle of it, not flush with its front. + local south = ty * 8 + math.floor(lowY / 8) * 8 + 8 + local function bandOf(ly) + local z0 = south - fig.depth + if fig.thin and ly < fig.thin.rows then + local m = math.floor((fig.depth - fig.thin.depth) / 2) + return z0 + m, z0 + m + fig.thin.depth + end + return z0, south + end + local function yOf(ly) return baseY + lowY - ly end + maskSlab(S.objectQuads, fig, perRow, atlasW, atlasH, tx * 8, + yOf, bandOf, baseY, fig.flat) + if fig.flat then + -- The top-view rect lands on the plane its own BOTTOM row would + -- have stood at -- which is the top of whatever the extrusion left + -- under it (the register's base band), so the keys lie on the deck + -- and never float. + -- + -- In depth it fills the body's whole band, STRETCHED to it: the rect + -- is the machine's deck, so it reaches as deep as the machine does, + -- and its last drawn row stays the deck's front edge directly over + -- the fascia below it -- an object drawn LOW on a surface is drawn + -- NEAR its front. + maskPlate(S.objectQuads, fig, perRow, atlasW, atlasH, tx * 8, + fig.flat, yOf(fig.flat.r1), south - fig.depth, fig.depth) + end + else + local quads = {} + for ly = 0, bh - 1 do + Budget.tick() + for lx = 0, bw - 1 do + if at(lx, ly) then + local tile = fig.tiles[math.floor(ly / 8) * fig.w + + math.floor(lx / 8) + 1] + local u = ((tile % perRow) * 8 + lx % 8 + 0.5) / atlasW + local v = (math.floor(tile / perRow) * 8 + ly % 8 + 0.5) / atlasH + local x, y = lx - minX, lowY - ly + quads[#quads + 1] = { { x, y, 0 }, { x + 1, y, 0 }, + { x + 1, y + 1, 0 }, { x, y + 1, 0 }, + u = u, v = v, shade = 1 } + end end end - end - -- Where the card stands. `wz` is the MIDDLE of the tile row his feet are - -- drawn in, which is the same convention a character card uses (its feet - -- plane sits at its cell's middle) -- so he sorts against the couch and - -- against a player walking past exactly the way an NPC standing there - -- would. - S.figures[#S.figures + 1] = { - quads = quads, - wx = tx * 8 + minX, - wz = ty * 8 + math.floor(lowY / 8) * 8 + 4, - y = baseY, - } + -- Where the card stands. `wz` is the MIDDLE of the tile row his feet + -- are drawn in, which is the same convention a character card uses + -- (its feet plane sits at its cell's middle) -- so he sorts against + -- the couch and against a player walking past exactly the way an NPC + -- standing there would. + S.figures[#S.figures + 1] = { + quads = quads, + wx = tx * 8 + minX, + wz = ty * 8 + math.floor(lowY / 8) * 8 + 4, + y = baseY, + } + end -- What each covered tile wears now that he is off it. Only the ART -- changes: the couch tiles keep their `counter` box (they ARE the @@ -2342,6 +3215,78 @@ function Structures.buildFigures(S, map, x0, x1, y0, y1) end end +-- ---- mounted: a thing drawn INTO a wall band, stood proud of it ---- + +-- One authored mounted object at one matched position. +-- +-- Same authoring premise as a figure -- the mask IS the classification, +-- because a drawing painted onto the wall it hangs on has no background +-- margin for a flood to enter by, and here the wall's own #555 stripes +-- are a flood boundary as well, so a silhouette comes back striped. +-- Like a figure it therefore builds HEADLESS: nothing below reads a +-- pixel. +-- +-- But a mounted object is an OBJECT, so it is built the way every other +-- standee here is -- a per-pixel voxel slab wearing the drawing's own +-- texels, quads emitted in world space -- and not as a sprite card: +-- +-- ELEVATION is the drawn one. A figure stands on its own feet; this +-- keeps the row it is painted in, because the band it is painted into +-- is a measured 16px face rising off the floor. So drawn row `ly` +-- becomes world y = (band height - 1) - ly, and a bicycle whose wheels +-- are drawn on the band's bottom row lands on the floor while one hung +-- clear of it stays hung. +-- DEPTH juts SOUTH of the band's own face (z0 at the drawing's south +-- edge), so the object stands in front of the wall rather than inside +-- it. It overhangs the walkable cell in front, which is what a bicycle +-- leaning on a wall does; nothing about collision changes. +local function buildMountedAt(S, map, m, tx, ty, perRow) + local bh = m.h * 8 + local z0 = (ty + m.h) * 8 + local z1 = z0 + (m.depth or 2) + + maskSlab(S.objectQuads, m, perRow, map.tileset.imageWidth or 128, + map.tileset.imageHeight or 48, tx * 8, + function(ly) return (bh - 1) - ly end, + function() return z0, z1 end, 0) + + -- What the band wears now that the object is off it: the plain panel + -- the artist drew everywhere else along the same wall. Only the ART + -- changes -- these tiles keep the `wall` box they always resolved to, + -- because they ARE the wall. + for i = 1, #m.tiles do + local dx, dy = (i - 1) % m.w, math.floor((i - 1) / m.w) + S.tileAt[keyOf(tx + dx, ty + dy)] = m.under[i] + end +end + +-- Every authored mounted object, wherever the map draws it. Matched by +-- TILE PATTERN like a figure, and for the same reason -- one blockset +-- entry can place the same drawing in several rooms -- and the repaint +-- above replaces the pattern's own tiles, so a match never fires twice +-- on one drawing. +function Structures.buildMounted(S, map, x0, x1, y0, y1) + local list = TileShape.mounted(map.tileset.id) + if not list then return end + local perRow = map.tileset.tilesPerRow or 16 + for _, m in ipairs(list) do + for ty = y0, y1 - m.h + 1 do + for tx = x0, x1 - m.w + 1 do + Budget.tick() + local hit = true + for i = 1, #m.tiles do + local dx, dy = (i - 1) % m.w, math.floor((i - 1) / m.w) + if S.tileAt[keyOf(tx + dx, ty + dy)] ~= m.tiles[i] then + hit = false + break + end + end + if hit then buildMountedAt(S, map, m, tx, ty, perRow) end + end + end + end +end + -- ---- tall grass ---- -- A tall-grass CELL is four tufts: 2x2 tiles, and each 8x8 tile is one diff --git a/lib/TileShape.lua b/lib/TileShape.lua index 31e5916..0ea0ada 100644 --- a/lib/TileShape.lua +++ b/lib/TileShape.lua @@ -71,6 +71,21 @@ local FALLBACK_HEIGHTS = { -- body builds from the bark rows and the drawn ellipse projects onto -- the hull's round top stump = 16, + -- the same hull cut at both ends, hollowed and tapered: an OPEN bin + -- standing on a floor (the Vermilion Gym trash cans). The drawn mouth + -- ellipse projects onto the round top and down the well, the drawn base + -- ellipse is ground contact rather than body, and the plan narrows toward + -- the floor. Height is AUTHORED (the profile's can_height, which this + -- pin must be kept equal to so anything riding a can lands on its rim) -- + -- the drawing's own straight run is only a couple of rows, because a GB + -- cell spends most of itself on the opening + can = 9, + -- round scenery drawn ONE cell wide and TWO cells TALL, standing on one + -- cell of plot: the Pokemon Centers' potted plants. Carved as one + -- 16x32x16 hull in the SOUTH (pot) cell -- the drawing's upper cell is + -- the object's height, not its depth. BOTH cells take the class; the + -- group build anchors on the north one (Structures.buildCylinders) + planter = 32, billboard = 16, signpost = 16, post = 16, @@ -91,6 +106,10 @@ local FALLBACK_HEIGHTS = { desk = 24, prop = 16, cutout = 16, + -- a vehicle drawn SIDE-ON: the showroom bicycles. Standee height like + -- every other cutout pool -- what differs is the thickness (see + -- Structures' PINNED_DEPTH) + bike = 16, console = 16, relief = 3, bookcase = 32, @@ -127,6 +146,8 @@ local ART = { cylinder = "cylinder", canopy = "canopy", stump = "cylinder", + can = "cylinder", + planter = "planter", billboard = "billboard", -- signposts share the billboard treatment but as their own pool at a -- 2-voxel depth: a sign is a thin plate on a stick, and the standard @@ -165,6 +186,13 @@ local ART = { desk = "upright", prop = "billboard", cutout = "billboard", + -- a bicycle is a LINE drawing seen side-on, and its negative space -- + -- the air inside the frame, between the wheel and the fork -- is what + -- makes it read as a bicycle at all. Its own pool at two voxels: any + -- thicker and the side faces of neighbouring strokes close those gaps + -- from every angle but dead-on, and six of them in a showroom come out + -- as one dark lump (which is what the 5px `prop` pool gave) + bike = "billboard", -- a machine standing on furniture: the billboard treatment with -- body, plus the one-object contract `cutout` has -- the drawing is -- ringed by the furniture it sits on, and those edges must not be @@ -183,6 +211,7 @@ local ART = { local spec = nil -- the loaded data file, or false when absent local cache = {} -- tileset id -> resolved shape list local figCache = {} -- tileset id -> parsed figure masks, or false +local mntCache = {} -- tileset id -> parsed mounted masks, or false local bgCache = {} -- tileset id -> prop background shades, or false -- The shape profile ships with the mod (data/voxel_heights.lua) and is read @@ -430,68 +459,158 @@ end -- pixel by pixel (see data/voxel_heights.lua): -- -- figures = { { w = , +-- depth = , +-- thin = { rows = , depth = }, +-- flat = { x = { , }, rows = { , } }, -- tiles = { ...w*h tile ids, row-major... }, -- under = { ...w*h ids: what each tile wears once the -- figure is lifted off it... }, -- pixels = { ...h*8 strings of w*8 chars, "." = not the -- figure... } } } -- --- No class: a figure is always a flat sprite card, drawn the way --- SpriteBillboards draws a character (see Structures.buildFigures). +-- No class -- what the entry carries instead is a `depth`, or does not: +-- +-- WITHOUT one it is a flat sprite card, drawn the way SpriteBillboards +-- draws a character. That is the right reading for a PERSON: a Gen 1 +-- figure is a face-on 2D icon, and extruding one reconstructs a body +-- nobody drew (see Structures.buildFigures). +-- WITH one it is an OBJECT and gets the standee treatment every other +-- solid here gets -- a per-pixel slab in world space, standing on the +-- same furniture the card would have stood on. The Marts' cash +-- register is the case: a machine on a counter is a box, not an icon. +-- +-- Two fields say which parts of such a drawing are NOT the extrusion, +-- because a solid drawn in one 16x16 GB cell still packs more than one +-- facing: +-- +-- `thin` caps the thickness over the mask's top rows, for the part of +-- the drawing that is not the machine (the register's receipt curl). +-- `flat` names a rect of the mask that is a TOP-VIEW surface rather +-- than a face -- the register's keypad, whose keys lie ON its deck. +-- The rect lays horizontal one voxel proud of whatever the extrusion +-- leaves below it, at the elevation its BOTTOM row would have had, +-- with drawn row = depth row 1:1 (the mapping the lab tabletop is +-- drawn with). So a drawing whose front elevation is an L reads as +-- one: body up the side and along the base, keys lying in the notch. -- -- Returned normalized: `mask` as a set keyed by ly * (w * 8) + lx, so -- Structures can read it as a bitmap without re-parsing per position. -- A malformed entry is dropped rather than half-applied -- a typo in a -- mask should leave the couch alone, not carve a hole in it. -function TileShape.figures(tilesetId) - local hit = figCache[tilesetId] - if hit ~= nil then return hit or nil end - - local s = load() - local entry = s and s.tilesets and s.tilesets[tilesetId] - local list = entry and entry.figures +-- +-- `mounted` (below) carries the same four fields, so the parse is shared, +-- and so are the optional ones that give an authored mask a BODY: `depth`, +-- `thin` and `flat` above. `depth` is left nil when unstated, because +-- absence is meaningful on a figure: no depth means the flat sprite card a +-- person is drawn as. +local function authoredMasks(list) local out = {} - if type(list) == "table" then - for _, f in ipairs(list) do - local ok = type(f) == "table" and type(f.w) == "number" - and type(f.tiles) == "table" and type(f.under) == "table" - and type(f.pixels) == "table" - local w = ok and math.floor(f.w) or 0 - local h = (w >= 1) and (#f.tiles / w) or 0 - ok = ok and w >= 1 and h >= 1 and h == math.floor(h) - and #f.under == #f.tiles and #f.pixels == h * 8 - if ok then - for i = 1, h * 8 do - local row = f.pixels[i] - if type(row) ~= "string" or #row ~= w * 8 then - ok = false - break - end + if type(list) ~= "table" then return out end + for _, f in ipairs(list) do + local ok = type(f) == "table" and type(f.w) == "number" + and type(f.tiles) == "table" and type(f.under) == "table" + and type(f.pixels) == "table" + local w = ok and math.floor(f.w) or 0 + local h = (w >= 1) and (#f.tiles / w) or 0 + ok = ok and w >= 1 and h >= 1 and h == math.floor(h) + and #f.under == #f.tiles and #f.pixels == h * 8 + if ok then + for i = 1, h * 8 do + local row = f.pixels[i] + if type(row) ~= "string" or #row ~= w * 8 then + ok = false + break end end - if ok then - local mask, n = {}, 0 - for ly = 0, h * 8 - 1 do - local row = f.pixels[ly + 1] - for lx = 0, w * 8 - 1 do - if row:sub(lx + 1, lx + 1) ~= "." then - mask[ly * (w * 8) + lx] = true - n = n + 1 - end + end + if ok then + local mask, n = {}, 0 + for ly = 0, h * 8 - 1 do + local row = f.pixels[ly + 1] + for lx = 0, w * 8 - 1 do + if row:sub(lx + 1, lx + 1) ~= "." then + mask[ly * (w * 8) + lx] = true + n = n + 1 end end - if n > 0 then - out[#out + 1] = { w = w, h = h, n = n, mask = mask, - tiles = f.tiles, under = f.under } - end + end + local depth = tonumber(f.depth) + local thin = nil + if type(f.thin) == "table" and tonumber(f.thin.rows) + and tonumber(f.thin.depth) then + thin = { rows = math.floor(tonumber(f.thin.rows)), + depth = math.floor(tonumber(f.thin.depth)) } + end + local flat = nil + if type(f.flat) == "table" and type(f.flat.x) == "table" + and type(f.flat.rows) == "table" then + flat = { x0 = math.floor(f.flat.x[1]), x1 = math.floor(f.flat.x[2]), + r0 = math.floor(f.flat.rows[1]), + r1 = math.floor(f.flat.rows[2]) } + end + if n > 0 then + out[#out + 1] = { w = w, h = h, n = n, mask = mask, + tiles = f.tiles, under = f.under, + depth = depth and math.floor(depth) or nil, + thin = thin, flat = flat } end end end + return out +end + +function TileShape.figures(tilesetId) + local hit = figCache[tilesetId] + if hit ~= nil then return hit or nil end + + local s = load() + local entry = s and s.tilesets and s.tilesets[tilesetId] + local out = authoredMasks(entry and entry.figures) figCache[tilesetId] = (#out > 0) and out or false return figCache[tilesetId] or nil end +-- Hand-authored MOUNTED objects for one tileset: a thing drawn INTO the +-- wall band it hangs on, cut out by an explicit pixel mask and stood +-- proud of the wall's face. +-- +-- Same authoring problem as `figures` and the same answer -- a class pin +-- resolves a whole 8x8 tile, and the detector cannot segment a drawing +-- that has no background margin to flood from. The Bike Shop's two wall +-- bicycles are the case: the shop's striped wall panel runs BEHIND them, +-- and its #555 stripes are a flood boundary, so a silhouette flood comes +-- back with the stripes attached to the bike. +-- +-- Two things differ from a figure, and both follow from the object being +-- an object rather than a character: +-- +-- it keeps its DRAWN ELEVATION. A figure stands on its own feet; a +-- mounted thing sits where the wall band draws it, so a bicycle hung +-- clear of the floor stays hung. +-- it has THICKNESS (`depth`, default 2), and it is built in world +-- space as a per-pixel slab jutting south of the band -- not as a +-- camera-facing sprite card. A bicycle drawn side-on is a plane +-- parallel to the wall, not a face-on icon. +-- +-- mounted = { { w = , +-- depth = , +-- tiles = { ...w*h tile ids, row-major... }, +-- under = { ...w*h ids: what each tile wears once the +-- object is lifted off it (the plain panel)... }, +-- pixels = { ...h*8 strings of w*8 chars, "." = wall... } } } +function TileShape.mounted(tilesetId) + local hit = mntCache[tilesetId] + if hit ~= nil then return hit or nil end + + local s = load() + local entry = s and s.tilesets and s.tilesets[tilesetId] + local out = authoredMasks(entry and entry.mounted) + + mntCache[tilesetId] = (#out > 0) and out or false + return mntCache[tilesetId] or nil +end + -- Which GB shades count as BACKGROUND for a pinned per-pixel prop, per tile -- (a tileset entry's prop_bg). Returns tile id -> set of shade names, or nil. -- @@ -564,12 +683,30 @@ function TileShape.bookcaseBackfill(tilesetId) return mode == "above" and mode or nil end +--- Does this tileset's `bookcase` run carry the measured pane RELIEF on +--- its front (a tileset entry's bookcase_relief)? Default yes: the class +--- almost always collapses a shelf, a rack or a display case, and every +--- one of those seals its contents behind a frame that should stand proud +--- of them. +--- +--- A tileset says `bookcase_relief = false` when it borrows the collapse +--- for something that is NOT a shelf -- the League's gate walls and +--- pilasters, Bill's transporter drums -- where the drawing's light +--- regions are the masonry and the barrel, not panes, and sinking them +--- carves the surface instead of describing it. +function TileShape.bookcaseRelief(tilesetId) + local s = load() + local entry = s and s.tilesets and s.tilesets[tilesetId] + return not (entry and entry.bookcase_relief == false) +end + -- Drop the cache: a mod that shadows data/voxel_heights.lua or a tileset -- record needs the next lookup to re-resolve (hot reload, mod toggle). function TileShape.invalidate() spec = nil cache = {} figCache = {} + mntCache = {} bgCache = {} end diff --git a/lib/Water.lua b/lib/Water.lua index db1db8e..4ab4022 100644 --- a/lib/Water.lua +++ b/lib/Water.lua @@ -381,9 +381,32 @@ Water.EDGE_FADE = 0.14 -- reflection eased off over this much of the fra -- -- The scene shader's own vertex path, plus the world position the geometry -- was actually DRAWN at -- after the world curve, because that is the space --- the depth buffer holds and therefore the space the march has to walk in. --- (The curve only ever moves Y, so a fragment's world XZ is the same on both --- sides of it and the ripple can be measured off this one too.) +-- the surface the eye MEETS lives in: which wave column a screen pixel is +-- looking at is a question about the geometry as drawn, and relief() answers +-- it there. (The curve only ever moves Y, so a fragment's world XZ is the +-- same on both sides of it and the ripple can be measured off this one too.) +-- +-- WHAT IT REFLECTS is worked out on the other side of the bend, in the FLAT +-- world, and this is the same rule the rest of the mode keeps: the curve +-- tips the world away and the things standing on it do not lean with it (see +-- WorldCurve -- buildings stay upright, shadows are resolved before the bend +-- and ride along). A lake is one of those things. Reflect off the bowl the +-- bend has made instead and the far half of a pond is a mirror tilted twenty +-- degrees: it throws the ray past the vertical, where the sky ramp's own +-- measure -- a screen row, through the frame's matrix -- swings from one end +-- of the ramp to the other across a single column, and the pond comes out +-- with hard-edged patches of the wrong sky stamped into it -- the overhead +-- band and the horizon band abutting in the middle of a lake, which reads as +-- something other than water showing through. The same tilt sends the +-- screen-space march grazing along the bank instead of over it, which is the +-- other half: the dock and the roofs smeared across the harbour. +-- +-- So the reflection is taken with the flat view ray about the flat normal, +-- exactly as it would be with the curve off -- and the MARCH still has to +-- walk the world as drawn, because that is what the depth buffer holds. Both +-- at once: the ray is straight in the flat world, and project() bends each +-- sample on its way to the screen, which is the same displacement the vertex +-- stage applies and therefore lands in the same place the geometry did. local SHADER_SRC = [[ varying float vShade; varying vec3 vSun; @@ -435,6 +458,20 @@ uniform vec3 eye; uniform vec2 screen; // the canvas, in pixels uniform float cell; // one diorama pixel, in canvas pixels uniform float pxAngle; // radians of view one screen pixel subtends +// The same bend the vertex stage applied. This stage has to undo it to get +// back to the flat world it reflects in, and re-apply it on every marched +// sample to get back to the screen. Declared in both stages, like `vp`, and +// both are highp here. +uniform vec3 curve; // xy = the focus in world XZ, z = k; 0 = off + +// How far the bend has pushed the world down at world XZ `q` -- the vertex +// stage's own displacement, as a number this stage can add and subtract. +// Zero when the curve is off, which is the shader's "skip it" everywhere. +float bendDrop(vec2 q) { + if (curve.z <= 0.0) return 0.0; + vec2 d = q - curve.xy; + return dot(d, d) * curve.z; +} // the sun's own pass, exactly as the scene shader reads it uniform Image sunMap; @@ -658,10 +695,14 @@ vec3 bodyAt(vec3 d, vec3 c, float parity) { // clip-space Y flip is already baked into `vp`, and a canvas texture's v runs // the same way its pixel rows do, so one 0.5x+0.5 answers for both. // -// This is why the march walks in the world as DRAWN rather than as authored: -// the depth buffer holds the curved world, so a straight line in that space -// is the ray, and a straight line in the flat one would bend through it. +// The point arrives in the FLAT world -- the space the ray is straight in -- +// and is bent here, by the same displacement the vertex stage applied, so it +// lands exactly where the geometry it is being compared against landed. That +// split is the whole trick: the reflection is worked out in a world that has +// not been tipped, and every sample of it is tipped on the way to the screen, +// so the march reads the depth buffer it actually has. vec4 project(vec3 p) { + p.y -= bendDrop(p.xz); vec4 c = vp * vec4(p, 1.0); if (c.w <= 1e-6) return vec4(0.0, 0.0, 0.0, 0.0); return vec4(c.xy / c.w * 0.5 + 0.5, c.z / c.w * 0.5 + 0.5, 1.0); @@ -925,12 +966,23 @@ vec4 effect(mediump vec4 color, Image tex, mediump vec2 tc, mediump vec2 sc) { // drawn at, with no smooth shading anywhere across it. (The depth test // above is the one thing that stays per fragment: that is the hardware's // own question and it is asked in screen space.) - vec3 view = normalize(vBent - eye); + // + // Answered on the FLAT sheet, which is where the bars are a slab of even + // thickness over a level plane -- the one thing relief() is built on. The + // bend translates every bar straight down by its own column's drop, so the + // field keeps its shape and only its height moves; undo that here and the + // walk is the walk it was written for. Try it in the world as DRAWN + // instead and the slab is a bowl: the backward step up the ray climbs the + // bowl's near side as fast as it climbs out of the water, the walk starts + // inside the sheet, and it hands back a column a pixel or three off -- per + // fragment, differently, which is a patch of noise rather than parallax. + vec3 sheet = vec3(vBent.x, vBent.y + bendDrop(vBent.xz), vBent.z); + vec3 view = normalize(sheet - eye); vec3 hit; vec2 col; float face; float axis; - relief(vBent, view, hit, col, face, axis); + relief(sheet, view, hit, col, face, axis); // and the bar's centre, so a column is sampled and reflected from one // place rather than from wherever inside it the fragment happened to land vec3 surf = vec3(col.x + 0.5, hit.y, col.y + 0.5); @@ -1001,7 +1053,7 @@ vec4 effect(mediump vec4 color, Image tex, mediump vec2 tc, mediump vec2 sc) { vec3 rgb = mix(base, refl, clamp(f, 0.0, 1.0)); #ifdef VOXEL_GRID - rgb *= 1.0 - gridDark * columnSeam(hit, vBent, axis); + rgb *= 1.0 - gridDark * columnSeam(hit, sheet, axis); #endif return vec4(rgb, 1.0) * color; } diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua index 0ca9283..2fb9094 100644 --- a/tests/dramatic_shape_test.lua +++ b/tests/dramatic_shape_test.lua @@ -1800,9 +1800,21 @@ T.check(plain:find("waveNormal(vec2 q, float tilt)", 1, true) ~= nil and plain:find("waveNormal(col,", 1, true) ~= nil, "still one answer per column, so the surface stays pixel-quantised in " .. "space while the value it reflects with is continuous") -T.check(plain:find("relief(vBent, view, hit, col, face, axis)", 1, true) ~= nil, +T.check(plain:find("relief(sheet, view, hit, col, face, axis)", 1, true) ~= nil, "and the visible column is found by walking the view ray through the " .. "slab, which is what makes a tall bar hide the short ones behind it") +-- ...over the FLAT sheet, which is the one thing that walk is built on: an +-- even slab over a level plane. The world curve drops each bar straight down +-- by its own column's drop, so undoing that drop hands relief() the field it +-- was written for. Walked in the world as DRAWN instead, the slab is a bowl: +-- the backward step up the ray climbs the bowl's near side as fast as it +-- climbs out of the water, the walk starts inside the sheet, and it returns a +-- column a pixel or three off -- differently per fragment, which is a +-- hard-edged patch of noise in the middle of a pond. +T.check(plain:find("vec3 sheet = vec3(vBent.x, vBent.y + bendDrop(vBent.xz), vBent.z)", + 1, true) ~= nil, + "and it walks the sheet the mesh was AUTHORED as, the bend taken back off, " + .. "because a slab walk over a bowl starts inside the water") -- the march's reach grows as one over the ray's descent, so a grazing camera -- asks for hundreds of world pixels of it from a fixed number of samples -- -- which stepped over whole crests and smeared the surface into streaks @@ -1830,7 +1842,7 @@ T.check(plain:find("waveUV(tc, col)", 1, true) ~= nil, "and the column is what is handed to it") -- the wireframe is ruled on the COLUMNS, not on the flat sheet they stand on -T.check(gridded:find("columnSeam(hit, vBent, axis)", 1, true) ~= nil, +T.check(gridded:find("columnSeam(hit, sheet, axis)", 1, true) ~= nil, "with V-GRID on, the seams outline the column the ray landed on -- every " .. "voxel of water its own block -- rather than ruling a grid across the " .. "flat quad underneath and ignoring the bars entirely") @@ -1839,6 +1851,25 @@ T.check(gridded:find("vec3 w = fwidth(base);", 1, true) ~= nil, .. "between neighbouring fragments and its own derivative is a step") T.check(plain:find("march(surf, r)", 1, true) ~= nil, "the reflection marches from that column, not from the raw fragment") +-- The two halves of the world curve, and they pull opposite ways. WHAT the +-- lake reflects is worked out FLAT -- the same rule the rest of the mode +-- keeps, that the world tips away and the things standing on it do not lean +-- with it. Reflect off the bowl the bend has made instead and the far half +-- of a pond is a mirror tilted twenty degrees, throwing the ray past the +-- vertical, where the sky ramp's own measure (a screen row, through the +-- frame's matrix) swings from one end of the ramp to the other across a +-- single column and stamps hard-edged patches of the wrong sky into the +-- water. But WHERE it lands has to be found in the world as DRAWN, because +-- that is what the depth buffer holds -- so the ray stays straight in the +-- flat world and every sample of it is bent on the way to the screen, by the +-- vertex stage's own displacement. +T.check(plain:find("p.y -= bendDrop(p.xz);", 1, true) ~= nil, + "and every marched sample is bent into the world as DRAWN before it is " + .. "projected, because that is the world the depth buffer holds") +T.check(plain:find("vec3 r = reflect(view, n);", 1, true) ~= nil + and plain:find("reflect(view, vec3(0.0, 1.0, 0.0))", 1, true) ~= nil, + "while the reflection itself is taken about the FLAT normal, so a curved " + .. "world does not tip the lake the way it does not lean the buildings") T.check(plain:find("mod(col.x + col.y, 2.0)", 1, true) ~= nil, "and the dither's checkerboard is cut from the columns too, so a camera " .. "pan slides the world through nothing") diff --git a/tests/voxel_figure_test.lua b/tests/voxel_figure_test.lua index 6f8e529..66e193e 100644 --- a/tests/voxel_figure_test.lua +++ b/tests/voxel_figure_test.lua @@ -214,6 +214,172 @@ Structures.buildFigures(twice, map, 0, 3, 8, 11) T.eq(#twice.figures, 1, "the repaint replaces the pattern, so a rescan cannot match it again") +-- ------- a figure with a DEPTH is an object, not a card +-- +-- The Marts' cash register: the same authored-mask escape, but a machine +-- set down on a counter is a box seen from the front rather than a +-- face-on icon, so it builds as a per-pixel solid. Driven over a +-- synthetic copy of the counter's east arm, as all nine maps on the MART +-- id draw it at cell (1,5): +-- +-- y=9 16 41 the work surface north of it +-- y=10 14 15 the register: keypad and receipt curl +-- y=11 30 31 +-- y=12 16 41 the work surface it stands on + +T.check(TileShape.figures("POKECENTER")[1].depth == nil, + "the seated man states no depth -- he stays a flat sprite card") + +local regs = TileShape.figures("MART") +T.check(type(regs) == "table" and #regs == 1, + "MART carries exactly one figure") +local reg = regs[1] +T.eq(reg.w, 2, "the register is two tiles across") +T.eq(reg.h, 2, "and two tall") +T.eq(reg.n, 150, "the mask claims 150 pixels of the 256 it spans") +T.eq(reg.depth, 12, "its body is 12 voxels deep -- three quarters of the cell") +T.check(reg.thin and reg.thin.rows == 4 and reg.thin.depth == 2, + "the four rows above its drawn top edge are 2-voxel paper") +T.check(reg.flat and reg.flat.x0 == 2 and reg.flat.x1 == 8 + and reg.flat.r0 == 4 and reg.flat.r1 == 11, + "and the keypad is a TOP-VIEW rect, not a face") + +local MART_ROWS = { [9] = { 16, 41 }, [10] = { 14, 15 }, + [11] = { 30, 31 }, [12] = { 16, 41 } } +local martS = { shapeAt = {}, tileAt = {}, figures = {}, skip = {}, + ground = {}, runs = {}, objectQuads = {} } +for ty, row in pairs(MART_ROWS) do + for i, tile in ipairs(row) do + martS.tileAt[keyOf(1 + i, ty)] = tile + martS.shapeAt[keyOf(1 + i, ty)] = COUNTER + end +end +local martMap = { + tileset = { id = "MART", tilesPerRow = 16, + imageWidth = 128, imageHeight = 48 }, + isWalkableCell = function() return false end, +} +Structures.buildFigures(martS, martMap, 2, 3, 9, 12) + +T.eq(#martS.figures, 0, "no card was built -- it is a solid") +T.eq(#martS.objectQuads, 351, + "and it landed in the standee channel as 351 quads") +T.eq(martS.tileAt[keyOf(2, 10)], 16, + "its tiles wear the plain work surface now") +T.eq(martS.tileAt[keyOf(3, 11)], 41, "all four of them") +T.eq(martS.shapeAt[keyOf(2, 10)].class, "counter", + "and keep the counter box the machine stands on") + +local rx0, rx1, ry0, ry1, rz0, rz1 +for _, q in ipairs(martS.objectQuads) do + for c = 1, 4 do + local p = q[c] + rx0 = math.min(rx0 or p[1], p[1]); rx1 = math.max(rx1 or p[1], p[1]) + ry0 = math.min(ry0 or p[2], p[2]); ry1 = math.max(ry1 or p[2], p[2]) + rz0 = math.min(rz0 or p[3], p[3]); rz1 = math.max(rz1 or p[3], p[3]) + end +end +T.eq(ry0, 8, "it stands ON the counter's 8px top plane, not the floor") +T.eq(ry1, 24, "and is its drawn 16px tall") +T.eq(rx0, 18, "west edge at the mask's column 2") +T.eq(rx1, 30, "east edge at column 13, inside its own cell (16..32)") +T.eq(rz1, 96, "its FRONT is the cell's own front edge, where it is drawn") +T.eq(rz0, 84, "and it grows north from there, 4 short of the cell's back") + +-- the two thicknesses: the body at 8, the receipt curl at 2, the curl +-- centred in the body's own band rather than flush with its front +local bands = {} +for _, q in ipairs(martS.objectQuads) do + for c = 1, 4 do bands[q[c][3]] = true end +end +for _, z in ipairs({ 84, 89, 91, 96 }) do + T.check(bands[z], "the model has a face at z = " .. z) +end +local curl = {} +for _, q in ipairs(martS.objectQuads) do + local lo = math.min(q[1][2], q[2][2], q[3][2], q[4][2]) + if lo >= 21 then for c = 1, 4 do curl[q[c][3]] = true end end +end +T.check(curl[89] and curl[91] and not curl[84] and not curl[96], + "clear of the arm's top only the 2-voxel paper band exists") + +-- THE L. The base band (drawn rows 12-15) stands 4 above the counter and +-- the keypad lies on it as a horizontal plate, so the whole machine is +-- exactly three surfaces: a foot, an arm, and a deck in the notch. +local plate, deckTop = {}, 0 +for _, q in ipairs(martS.objectQuads) do + local flatQuad = q[1][2] == q[2][2] and q[2][2] == q[3][2] + and q[3][2] == q[4][2] + if flatQuad and q[1][2] == 13 then + plate[#plate + 1] = q + elseif flatQuad and q[1][2] == 12 then + deckTop = deckTop + 1 + end +end +T.eq(#plate, 83, + "the keypad lies FLAT: one top quad per masked voxel of the deck") +T.eq(deckTop, 7, + "on the base band's own top, which is 4 voxels up (drawn rows 12-15)") +local dz0, dz1 +for _, q in ipairs(martS.objectQuads) do + if q[1][2] == 12 and q[3][2] == 12 then + for c = 1, 4 do + dz0 = math.min(dz0 or q[c][3], q[c][3]) + dz1 = math.max(dz1 or q[c][3], q[c][3]) + end + end +end +T.eq(dz0, 84, "and that deck runs the body's whole depth") +T.eq(dz1, 96, "-- plain behind the panel, covered by it in front") + +local px0, px1, pz0, pz1 +for _, q in ipairs(plate) do + for c = 1, 4 do + px0 = math.min(px0 or q[c][1], q[c][1]); px1 = math.max(px1 or q[c][1], q[c][1]) + pz0 = math.min(pz0 or q[c][3], q[c][3]); pz1 = math.max(pz1 or q[c][3], q[c][3]) + end +end +T.eq(px0, 18, "the deck spans the mask's columns 2..8") +T.eq(px1, 25, "-- the keypad panel and its own black rim") +T.eq(pz1, 96, "the deck reaches the body's front edge") +T.eq(pz0, 84, "and its back -- 8 drawn rows STRETCHED over 12 voxels") + +-- the stretch is by whole voxels, centre-sampled: 8 drawn rows over 12 +-- voxels of deck doubles every second one and blurs nothing +local perRow16, atlasH16 = 16, 48 +local depthRow = {} +for _, q in ipairs(plate) do + local z = math.min(q[1][3], q[2][3], q[3][3], q[4][3]) + depthRow[z] = math.floor(q.v * atlasH16) +end +local seen = {} +for z = 84, 95 do + T.check(depthRow[z] ~= nil, "deck voxel at z = " .. z .. " wears a texel") + seen[depthRow[z]] = (seen[depthRow[z]] or 0) + 1 +end +T.eq(depthRow[95], 11, "the front voxel wears the keypad's own bottom rim") +T.eq(depthRow[84], 4, "the back one wears its top rim") +local doubled = 0 +for _, n in pairs(seen) do + T.check(n == 1 or n == 2, "no drawn row spreads over more than two voxels") + if n == 2 then doubled = doubled + 1 end +end +T.eq(doubled, 4, "exactly four of the eight rows double -- 8 into 12") + + +-- and the arm still stands its drawn 8 rows above that deck, carrying +-- the paper: nothing in the notch reaches higher than the plate +local armTop, notchTop = 0, 0 +for _, q in ipairs(martS.objectQuads) do + for c = 1, 4 do + if q[c][1] >= 25 then armTop = math.max(armTop, q[c][2]) + elseif q[c][1] <= 24 then notchTop = math.max(notchTop, q[c][2]) end + end +end +T.eq(armTop, 24, "the arm and its receipt curl reach the drawn 16px") +T.eq(notchTop, 23, + "and west of it only the keys (13) and the paper overhanging them") + -- ------- prop_bg: the shades a pinned prop treats as background -- -- The potted plants needed this: their pot's olive base is drawn flush on diff --git a/tools/building_voxels.py b/tools/building_voxels.py index 1ca5fd2..a73fa9d 100644 --- a/tools/building_voxels.py +++ b/tools/building_voxels.py @@ -25,6 +25,7 @@ PER_ROW = 16 WHITE, GREY, DARK, BLACK = 0, 1, 2, 3 # by luminance, light first +SHADE_NAMES = dict(white=WHITE, grey=GREY, dark=DARK, black=BLACK) # --- the shape profile, mirroring data/voxel_heights.lua's `buildings` ------ TEMPLATES = { @@ -614,6 +615,156 @@ roof_rows=19, roof_back=16, roof_front=0, roof_cycle=(2, 13), slab=3, front_eave=0, ledge=None, tileset="gym", depth=2, ), + # F05: the tall display cabinet, the one with the trophy behind its + # glass -- CELADON_CHIEF_HOUSE cells 3,0 and 4,0 and + # CELADON_MANSION_1F cell 2,2. Its 32 rows read like every band + # table's: 0-8 the cabinet top seen from above (black rim, white + # highlight along the north and west, grey field, the front corner + # shaded at row 8), row 9 the top's own black front edge -- so + # slab = 1 and that row folds into the rim treatment -- and 10-31 + # the front face: the trophy in its dark display recess, the + # nameplate under it, then the two panelled doors of the base. + # Nine drawn top rows over a 32px plot, so the rims map 1:1 (the + # drawn front-corner shading lands one voxel behind the front edge) + # and the uniform field cycles between. Both cells of the plot are + # blocked and both are cabinet drawing, so D is the whole grid -- + # the rank is built into the room's north wall. + "mansion_trophy_case": dict( + tiles=[ + [38, 41], + [40, 21], + [56, 87], + [50, 51], + ], + roof_rows=10, roof_back=8, roof_front=2, roof_cycle=(2, 7), + slab=1, front_eave=0, ledge=None, tileset="mansion", + ), + # F06: the short book case standing beside it -- the same cabinet on + # a grid one tile row shorter (CELADON_CHIEF_HOUSE cells 2,0 and + # 5,0, CELADON_MANSION_1F tiles 2,5 and 6,5). Identical top band and + # identical base; only the display opening is shorter, a shelf of + # books instead of the trophy. Same band numbers as F05, which is + # what makes the two read as one line of furniture: 15 voxels tall + # against the tall one's 23. The 8px of wall behind it is not part + # of the drawing and keeps its own `wall` pin. + "mansion_bookcase": dict( + tiles=[ + [38, 41], + [34, 35], + [50, 51], + ], + roof_rows=10, roof_back=8, roof_front=2, roof_cycle=(2, 7), + slab=1, front_eave=0, ledge=None, tileset="mansion", + ), + # F07: the long table in the middle of the chief's house + # (CELADON_CHIEF_HOUSE cell 2,3), the lab table's read at four + # cells wide and two deep. Rows 0-23 are the tabletop seen from + # above; 24-26 are the top slab's own front edge, black/#555/black + # -- exactly what the rim treatment paints -- and row 27 is the + # #555 shadow that closes it, so slab = 3 and rows 24-27 fold into + # the roof band instead of extruding under it. That leaves rows + # 28-30 as the base, the same three the lab table's is, and the two + # tables stand the same 6 voxels: row 28 the apron running the whole + # width, 29-30 the end legs under it. The legs stop one row short of + # the grid, so the measured ground line lands there and the model + # does not float. Both cell rows of the plot are blocked, so D is + # the whole grid: 24 drawn top rows map 1:1 from the north rim and + # the field's own rows cycle for the last 8. + "mansion_long_table": dict( + tiles=[ + [38, 39, 39, 39, 39, 39, 39, 41], + [54, 55, 55, 55, 55, 55, 55, 57], + [54, 55, 55, 55, 55, 55, 55, 57], + [60, 58, 58, 58, 58, 58, 58, 59], + ], + roof_rows=28, roof_back=24, roof_front=0, roof_cycle=(2, 23), + slab=3, front_eave=0, ledge=None, tileset="mansion", + ), + # F08: the dining table of the generic town house -- 18 placements, + # every home's cells (3,3):(4,4) -- the chief's long table (F07) at + # two cells wide. The same read to the row: 0-23 the rounded + # tabletop seen from above (black rim, white highlight course, grey + # field, #555 east rim), 24-26 the slab's own black/#555/black + # front edge and 27 the #555 apron shadow that closes it, folded + # into the band, then 28-30 the base -- the apron's black underrun + # and the corner legs, stopping one row short of the grid so the + # measured ground line keeps the model on its plot. The same 6 + # voxels the whole table family stands. + "house_table": dict( + tiles=[ + [38, 39, 39, 41], + [54, 47, 47, 57], + [54, 47, 47, 57], + [60, 58, 58, 59], + ], + roof_rows=28, roof_back=24, roof_front=0, roof_cycle=(2, 23), + slab=3, front_eave=0, ledge=None, tileset="house", + ), + # F08 again: Red's and the Copycat's ground-floor dining table -- + # the same drawing on the reds_house atlas, with two differences + # the numbers absorb. Its tabletop field stops at row 22 and row 23 + # is the top's own #555 south rim, which back = 24 would lay + # MID-TABLE as a dark stripe: back stops at 23 and the field cycles + # from there (the drawn rim's place at the south edge is under the + # rim treatment's black, like every sibling's). And the POTTED + # PLANT is drawn standing on the tabletop (rows 5-15, x10..x21): it + # stays the cutout standee it has always been -- the template + # scrubs its pixels to the field shade and leaves its tiles + # unclaimed (the Lua entry's `keep`), so the model tops out as the + # plain surface the plant sits on. + "reds_house_table": dict( + tiles=[ + [38, 39, 40, 41], + [54, 55, 56, 57], + [44, 42, 42, 43], + [60, 58, 58, 59], + ], + roof_rows=28, roof_back=23, roof_front=1, roof_cycle=(2, 22), + slab=3, front_eave=0, ledge=None, tileset="reds_house", + scrub=[(10, 5, 21, 15)], + ), + # F09: the stool at every one of those tables -- the first template + # with NO base piece. The drawing is one object, a round seat on + # legs, drawn MID-CELL over its own floor (rows 0-4 are the room + # behind it), and no band split fits that: a roof band starts at + # the drawing's top row. So it is a desk-set of exactly one upright + # part anchored to the floor: rows 5-10 the seat seen from above + # (its lid), row 11 the seat's own front edge, 12-15 the legs with + # the floor showing between them, which the per-pixel facade cut + # keeps open. The drawing measures 7 deep (six seat rows plus the + # front edge at drawn row = depth row); the shipped stool is grown + # two voxels past that north AND south (z 3..13, developer-tuned + # against the in-game read) with the seat band STRETCHED over the + # deeper lid. `panes = False`: a stool has no windows, and the pane + # rule would sink the legs' lit faces behind their own outlines. + "house_stool": dict( + tiles=[ + [2, 3], + [18, 19], + ], + roof_rows=0, roof_back=0, roof_front=0, roof_cycle=(0, 0), + slab=0, front_eave=0, ledge=None, tileset="house", + panes=False, + parts=[ + dict(kind="upright", x=(2, 13), top=(5, 10), facade=(11, 15), + z=3, depth=11, stretch=True), # the stool + ], + ), + # F09 again on the reds_house atlas (Red's and the Copycat's ground + # floors), pixel for pixel the same drawing. + "reds_house_stool": dict( + tiles=[ + [2, 3], + [18, 19], + ], + roof_rows=0, roof_back=0, roof_front=0, roof_cycle=(0, 0), + slab=0, front_eave=0, ledge=None, tileset="reds_house", + panes=False, + parts=[ + dict(kind="upright", x=(2, 13), top=(5, 10), facade=(11, 15), + z=3, depth=11, stretch=True), # the stool + ], + ), # F04: the Pokemon Center's PC -- every Center's northeast corner # (11 placements) plus the Indigo Plateau lobby, whose MART tileset # shares this atlas. The lab desk-set read again: a @@ -639,6 +790,247 @@ dict(kind="flat", x=(2, 13), rows=(17, 19), z=13), # keyboard ], ), + # F05: the healing machine behind every Pokemon Center counter -- + # two 4x4-tile variants, 24 placements between them (a pair in all + # eleven Centers plus the Indigo Plateau lobby's pair, whose MART + # tileset shares this atlas). The variants differ only in the east + # flank: the west machine has the control keyboard there (7/13, + # scan "40,58,59,40;40,74,75,40;72,76,77,7;72,6,22,13" -- 12 + # placements), the east machine mirrored hoses (73, scan + # "...;72,76,77,73;72,6,22,73" -- 12). + # + # The full drawing is claimed: corners are the striped wall band + # (40), flanks the machine's own equipment. What no class can split + # is a wall-height cabinet with a monitor perched on its front top + # edge, drawn over two map rows because it towers over the 16px + # band behind it -- and hoses and a keyboard that are ATTACHED to + # it, not furniture standing on a desk plane. + # + # Read off the pixels (absolute grid x0..x31): + # rows 15-31, x8..x23 the CABINET's front face, 16 wide, 17 + # tall: black front-top edge with vent notches (15), + # white top rail (16), black panel in a white frame + # (17-28), white rail / light plinth / black ground + # (29-31). `desk.x` bounds it to the middle columns. + # rows 6-14, x8..x23 the cabinet's TOP FACE seen from above -- + # not a second-story facade: white expanse, lit west + # strip (x9), shaded east strip (x22), wrapping the + # monitor. Its 9 rows + the front edge row 15 ARE the + # cabinet's depth: 10 (z 16..25 against the wall). + # `desk.top` lays the band flat as the lid; where the + # monitor's drawing occludes it, the lid continues + # the nearest strip (the drawing's own pixels). + # rows 1-13, x10..x21 the MONITOR: black back rim (1) + white + # cap (2-3) seen from above -- depth 4 -- then bezel, + # screen and control strip (4-13) face-on, 10 tall, + # at the cabinet top's front (drawn base edge 13 is + # one band row up from the front strip): z 20..23. + # The screen interior (x13..x18, rows 6-8) sinks one + # voxel via `inset` -- the pane rule by hand, since + # `panes=False` blocks the global pass. + # rows 16-31, x0..x7 the HOSES (tile 72 twice): one 8-row + # motif per map row -- a horizontal run high on the + # cabinet's side (16-18, x3..x7 -> heights 13..15, + # measured off the drawn rows) and a shaded drop + # (19-23, x2..x7) that bends down INTO THE FLOOR. + # The two stacked motifs are two hoses in DEPTH (the + # drawn row band is the depth band): `box` parts at + # z 18 and z 24, 3 deep, the drop cycling its two + # shading rows (19-20) down to the floor with the + # drawn foot rows (21-23) landing at the bottom -- + # the roof-rim rule turned vertical. The second + # hose re-wears the first motif's rows: the atlas + # tile is the same, so the pixels are identical. + # rows 16-31, x24..x31 west variant: the KEYBOARD (7/13), a + # key grid with its cable at the north end and a bar + # down the east edge -- TOP-VIEW art, 16 rows = 16 + # depth rows. A `flat` part mounted on the cabinet's + # side at working height (`at` 11, authored -- the + # one number top-view art cannot state), 3 thick, + # top face wearing the drawn art. The stray dark + # dashes east of it (x30-31) are its cast shadow on + # the floor: background. East variant: mirrored + # hoses (runs 16-18 x24..x28, drops 19-23 x24..x29), + # same z slots. + # rows 0-15 elsewhere wall stripes and cast shadow: BACKGROUND. + # The `wall` element keeps the band solid over the + # back map row, full grid width -- a 16-tall, + # 16-deep block cycling the drawing's own stripe + # unit (x0, rows 0-3), exactly what the flanking + # cells' wall pins render. + # depth 4 is both map rows: the back row is the wall's plot, the + # front row the machine's own. `panes=False`: the front panel + # seals DARK behind LIGHT, the opposite polarity to the pane rule. + "center_heal_machine_w": dict( + tiles=[ + [40, 58, 59, 40], + [40, 74, 75, 40], + [72, 76, 77, 7], + [72, 6, 22, 13], + ], + roof_rows=0, roof_back=0, roof_front=0, roof_cycle=(0, 0), + slab=0, front_eave=0, ledge=None, tileset="pokecenter", depth=4, + panes=False, + wall=dict(h=16, depth_px=16, x=0, cycle=(0, 3)), + desk=dict(x=(8, 23), fascia=(15, 16), base=(17, 31), z=16, + depth_px=10, top=(6, 14)), + parts=[ + dict(kind="upright", x=(10, 21), top=(1, 3), facade=(4, 13), + z=20, depth=4, + inset=dict(x=(13, 18), rows=(6, 8))), # the monitor + dict(kind="box", x=(3, 7), rows=(16, 18), + z=18, depth=3), # north hose run + dict(kind="box", x=(2, 7), rows=(19, 23), cycle=(19, 20), + base=0, z=18, depth=3), # north hose drop + dict(kind="box", x=(3, 7), rows=(16, 18), + z=24, depth=3), # south hose run + dict(kind="box", x=(2, 7), rows=(19, 23), cycle=(19, 20), + base=0, z=24, depth=3), # south hose drop + dict(kind="flat", x=(24, 29), rows=(16, 31), + z=16, at=11, thick=3), # keyboard shelf + ], + ), + "center_heal_machine_e": dict( + tiles=[ + [40, 58, 59, 40], + [40, 74, 75, 40], + [72, 76, 77, 73], + [72, 6, 22, 73], + ], + roof_rows=0, roof_back=0, roof_front=0, roof_cycle=(0, 0), + slab=0, front_eave=0, ledge=None, tileset="pokecenter", depth=4, + panes=False, + wall=dict(h=16, depth_px=16, x=0, cycle=(0, 3)), + desk=dict(x=(8, 23), fascia=(15, 16), base=(17, 31), z=16, + depth_px=10, top=(6, 14)), + parts=[ + dict(kind="upright", x=(10, 21), top=(1, 3), facade=(4, 13), + z=20, depth=4, + inset=dict(x=(13, 18), rows=(6, 8))), # the monitor + dict(kind="box", x=(3, 7), rows=(16, 18), + z=18, depth=3), # NW hose run + dict(kind="box", x=(2, 7), rows=(19, 23), cycle=(19, 20), + base=0, z=18, depth=3), # NW hose drop + dict(kind="box", x=(3, 7), rows=(16, 18), + z=24, depth=3), # SW hose run + dict(kind="box", x=(2, 7), rows=(19, 23), cycle=(19, 20), + base=0, z=24, depth=3), # SW hose drop + dict(kind="box", x=(24, 28), rows=(16, 18), + z=18, depth=3), # NE hose run + dict(kind="box", x=(24, 29), rows=(19, 23), cycle=(19, 20), + base=0, z=18, depth=3), # NE hose drop + dict(kind="box", x=(24, 28), rows=(16, 18), + z=24, depth=3), # SE hose run + dict(kind="box", x=(24, 29), rows=(19, 23), cycle=(19, 20), + base=0, z=24, depth=3), # SE hose drop + ], + ), + # F06: the Bike Shop's toolbox -- 2 placements, cells (6,6) and (7,7) + # of BIKE_SHOP and nowhere else in the game. + # + # A 2x2-tile drawing that packs two objects standing side by side on + # the floor, which is why no class pin reaches it: a pin resolves a + # whole 8x8 tile and the pump's columns run through both right-hand + # tiles of the toolbox's own grid. Pinned `billboard` the whole + # 16x16 went up as one 10-voxel per-pixel slab -- every black outline + # pixel a 10-deep bar, so it read as a black monolith with the + # drawing decalled on its front, and the pump came out as a wing off + # its corner. + # + # Read off the pixels: + # rows 12-15 x2..x12 the plinth: a black rim course over a light + # drawer front with its own #555 side. The + # `desk` -- 4 tall, and the first one here + # drawn NARROWER than its grid (the pump + # stands on open floor past its right end) + # rows 4-11 x2..x11 the cabinet: a #555 tool tray behind a black + # frame, its right side face (#555) drawn one + # column wide. An upright part on the plinth, + # 8 tall, wearing its drawn top edge as a lid + # rows 0-11 x10..x14 the pump: a white body in a black outline + # with a grey foot. Its own upright part, 4 + # deep and set 2 back so it stands BEHIND the + # cabinet, and `foot` because its drawn bottom + # is where the plinth crosses it, not where it + # meets the floor + # The pump is listed FIRST so the cabinet wins the two columns they + # share (x10/x11 are the cabinet's side face, drawn through it). + # depth 1 is the plot: the toolbox blocks its cell but is drawn a + # half-cell deep, so 8 leaves the walk-up row in front of it clear. + "bike_shop_toolbox": dict( + tiles=[ + [29, 13], + [21, 22], + ], + roof_rows=0, roof_back=0, roof_front=0, roof_cycle=(0, 0), + slab=0, front_eave=0, ledge=None, tileset="club", depth_px=14, + tray=dict(top=(4, 11), front=(12, 15), x=(2, 11), inner=(3, 9), + floor=0), + parts=[ + dict(kind="upright", x=(11, 14), top=(0, 0), facade=(0, 11), + z=0, depth=14), # the open lid + ], + ), + # F0x: Bill's desk, and the Silph president's -- the same drawing in + # both (BILLS_HOUSE cell 1,4 and SILPH_CO_11F cell 10,12). 16 rows of + # TABLETOP seen from above over a 16px plot, 1:1, with a terminal and + # a ball standing on it: the lab table's top surface (black rim, white + # highlight course, grey field) carrying lab-computers' objects. + # + # The grid stops at the desk's own two cells ON PURPOSE. The block + # draws the desk's apron into the WALKABLE cell in front, and the + # left half of that apron shares its tiles with the CHAIR pushed up + # to the desk (43/44 over 59/60, pinned `stool`) -- claiming those + # tiles to read the apron would take the chair's back with them. So + # the front face is the one surface here the drawing does not hand + # us inside the grid, and `plane` authors it at the 8px this desk is + # pinned to stand: the apron's own drawn row count, and table height. + "bills_desk": dict( + tiles=[ + [11, 12, 13, 14], + [27, 28, 29, 30], + [43, 44, 91, 92], + [59, 60, 31, 31], + ], + roof_rows=0, roof_back=0, roof_front=0, roof_cycle=(0, 0), + slab=0, front_eave=0, ledge=None, tileset="interior", depth=4, + # the desk's own plot is its two cells; the grid runs on because + # its apron and the CHAIR share tiles 43/44 + desk=dict(fascia=(16, 18), base=(19, 23), depth=2), + parts=[ + # the notes: two sheets lying on the desk (a plain one and a + # crumpled one), so PAPER -- one voxel proud at drawn row = + # depth row, and nothing raised + dict(kind="flat", x=(2, 15), rows=(3, 7)), # the notes + # the keyboard: a real slab, not a print on the desk. Its + # keys are drawn from ABOVE, so they ride the top face across + # its depth, and its drawn bottom frame row stands as the + # front edge + dict(kind="upright", x=(4, 15), top=(8, 12), facade=(12, 13), + z=8, depth=6), # the keyboard + # the cord: the kinked dark run the drawing puts between the + # keyboard's top-right corner and the computer's left corner. + # Raised to the keyboard's own height so it reads as a cable + # spanning them rather than a scuff on the desk + dict(kind="upright", x=(16, 19), top=(8, 8), facade=(8, 9), + z=8, depth=2), # the cord + # the computer: drawn in 2:1 isometric, turned 45 degrees to + # the map -- see the iso branch in build_parts. `plan` = rx + # makes its footprint a SQUARE turned 45, so from directly + # above it is the cube the drawing depicts and not the slab + # the 2:1 would otherwise build + dict(kind="iso", x=(19, 30), rows=(1, 13), plan=6, z=9), + # the chair pushed up to the desk, drawn into the walkable + # cell in front. It stands on the FLOOR, not on the desk, so + # its rise is the whole plane back down; two voxels deep + # because it is a chair, and `inside` cuts it per pixel, so + # this is the thin slab the `stool` standee was -- the desk + # claiming tiles 43/44 for its apron is what makes the + # template owe it + dict(kind="upright", x=(2, 13), top=(20, 21), facade=(22, 31), + rise=-8, z=22, depth=10), # the chair + ], + ), # B23: the Victory Road entrance on Route 23: a rock face with two # barred doors. The roof band is the pale cliff top seen from above. "victory_road_gate": dict( @@ -662,7 +1054,7 @@ # --------------------------------------------------------------- stage 1 -- -def sprite(tiles, seal="", tileset="overworld"): +def sprite(tiles, seal="", tileset="overworld", scrub=None): """Composite the building and read it the way Structures reads the map: palette index per pixel plus the light-only silhouette flood. @@ -719,6 +1111,26 @@ def seed(x, y): src = [[((tiles[y // 8][x // 8] % PER_ROW) * 8 + x % 8, (tiles[y // 8][x // 8] // PER_ROW) * 8 + y % 8) for x in range(W)] for y in range(H)] + + # `scrub` names pixel rects where the drawing paints an object standing + # ON the surface (Red's potted plant on the dining tabletop). The object + # keeps its own standee -- the template leaves its tiles unclaimed -- so + # the band beneath it is the one surface the drawing implies but never + # paints clear: every rect pixel takes the field shade, sourced from the + # first field texel outside the rects, and the model's top comes out as + # the plain surface the object sat on. + if scrub: + in_rect = lambda x, y: any(x0 <= x <= x1 and y0 <= y <= y1 + for x0, y0, x1, y1 in scrub) + donor = next((x, y) for y in range(H) for x in range(W) + if col[y][x] == GREY and not out[y][x] + and not in_rect(x, y)) + for y in range(H): + for x in range(W): + if in_rect(x, y): + col[y][x] = GREY + src[y][x] = src[donor[1]][donor[0]] + out[y][x] = False return dict(W=W, H=H, col=col, out=out, src=src, pal=pal) @@ -773,11 +1185,27 @@ def profile(sp, t): if x1 - x0 + 1 <= RECESS_MAX and y1 - y0 + 1 <= RECESS_MAX: recess.update(cells) + # The pane rule reads a LIGHT region sealed behind a BLACK frame. A + # drawing built the other way round -- the healing machine's dark + # screens sealed behind their own white bezels -- inverts under it: + # every lit edge sinks and the black panes stand proud, a black + # lattice one voxel off the face. `panes = False` says the drawing + # does not carry the rule's polarity, so the facade stays flush. + if not t.get("panes", True): + recess = set() + # Depth is the PLOT. For a whole-drawing building that is the grid # itself; `depth` (in tile rows) names it when the grid runs past the # plot onto ground the drawing merely stands its legs on. return dict(top=top, wall_h=wall_h, ytop=ytop, recess=recess, - inside=inside, D=t.get("depth", len(t["tiles"])) * 8, + inside=inside, + # `depth` names the plot in TILE ROWS, which is the right + # grain for a building. `depth_px` names it in voxels, for + # an object whose real depth is not a whole tile row -- the + # Bike Shop toolbox is a box in the middle of its own cell, + # not a thing that fills a plot. + D=t.get("depth_px") + or t.get("depth", len(t["tiles"])) * 8, ground=ground, W=W, H=H) @@ -813,87 +1241,369 @@ def interior(sx, sy, lo, hi): return nx return sx + def build_parts(plane): + for p in t["parts"]: + x0, x1 = p["x"] + if p["kind"] == "flat": + r0, r1 = p["rows"] + # `at` names the sheet's own height when it does not lie + # on the desk plane (the healing machine's keyboard is a + # shelf mounted on the cabinet's side); `thick` gives it + # a body -- layers below the sheet repeating each + # column's own texel, the same continuation rule every + # synthesized surface follows. + at_y = p.get("at", plane) + thick = p.get("thick", 1) + for sy in range(r0, r1 + 1): + # drawn row = depth row by default; `z` renames the + # origin when the flat sits below the desk's own drawn + # top span (the Center PC's keyboard) + z = p.get("z", r0) + (sy - r0) + if not 0 <= z < D: + continue + for sx in range(x0, x1 + 1): + if inside(sx, sy): + for y in range(max(0, at_y - thick + 1), at_y + 1): + put(sx, y, z, sx, sy) + continue + if p["kind"] == "box": + # A BOX part is a drawn rect standing at its own drawn + # elevation -- equipment attached to the machine rather + # than an object on the desk plane. The rows are face-on + # art: the top row's drawn height IS the box's top + # (ground - 1 - r0, measured), and the box runs down to + # `base` (default the drawn extent; 0 continues it to + # the floor, the legs-continue rule). Height beyond the + # drawn rows fills the way a roof band does: rows before + # `cycle` map 1:1 from the top, rows after it 1:1 from + # the bottom -- the healing machine hoses' foot lands ON + # the floor -- and the cycle window repeats between. + r0, r1 = p["rows"] + c0, c1 = p.get("cycle", (r1, r1)) + pz = p.get("z", 0) + pd = p["depth"] + top = pr["ground"] - 1 - r0 + bot = p.get("base", pr["ground"] - 1 - r1) + n_top, n_bot = c0 - r0, r1 - c1 + for y in range(bot, top + 1): + k, j = top - y, y - bot + if k < n_top: + sy = r0 + k + elif j < n_bot: + sy = r1 - j + else: + sy = c0 + (k - n_top) % (c1 - c0 + 1) + for sx in range(x0, x1 + 1): + if not inside(sx, sy): + continue + ix = interior(sx, sy, x0, x1) + for z in range(pz, pz + pd): + if 0 <= z < D: + put(sx, y, z, + sx if z in (pz, pz + pd - 1) else ix, sy) + continue + if p["kind"] == "iso": + # An ISO part is drawn in 2:1 isometric -- a box TURNED 45 + # degrees to the map, so one rhombus carries its top, its + # front and its side at once and no band or facade split + # can reach them. Un-projecting it is that projection run + # backwards: the box stands as a real diamond in plan and + # every voxel wears the texel the drawing paints where + # that voxel projects TO, so the drawn top lands on the + # top, the screen on the screen-facing side and the flank + # on the flank -- nothing segmented by hand, which is the + # only way to get this right, because the three faces + # meet on a diagonal no rectangle can name. + # + # Everything but the depth centre falls out of the drawn + # rect, because the projection fixes it: the half-width + # is the drawn rhombus's x radius, HALF that again its z + # radius (2:1 is what makes it isometric), the near + # corner's drawn row is the base rhombus's front tip, and + # whatever drawn height is left once that rhombus is + # accounted for is the box's own height. Bill's computer: + # rx 6, rz 3, base centre row 10, 6 voxels tall -- which + # puts its left corner's vertical edge at drawn rows + # 4..10, exactly where the drawing paints one. + # + # `plan` is the one thing the drawing CANNOT state: 2:1 + # is the projection, not the object, so reading rz as the + # plan radius too builds a box half as deep as it is wide + # -- a slab, not the cube the drawing depicts. `plan` + # names the real z radius and the drawn row is scaled + # into it, so a cube is `plan = rx` and the drawing still + # lands on it pixel for pixel. + pr0, pr1 = p["rows"] + rx = (x1 - x0 + 1) // 2 + rz = rx // 2 + plan = p.get("plan", rz) + oy = pr1 - rz + h = oy - rz - pr0 + for sx in range(x0, x1 + 1): + # doubled, so a rect of even width keeps its centre + # between two columns instead of limping one left + dx2 = 2 * sx - (x0 + x1) + for dz in range(-plan, plan + 1): + z = p["z"] + dz + d2 = abs(dx2) * plan + 2 * abs(dz) * rx + if not (0 <= z < D and d2 <= (2 * rx + 1) * plan): + continue + # the plan row scaled back into the drawn rhombus + dzs = (2 * dz * rz + plan) // (2 * plan) + for y in range(h + 1): + sy = oy + dzs - y + if pr0 <= sy <= pr1 and inside(sx, sy): + put(sx, plane + y, z, sx, sy) + continue + tr0, tr1 = p["top"] + fr0, fr1 = p["facade"] + pd = p["depth"] + # `rise` lifts a part off the desk's top plane. An object STANDING + # on the desk starts at the plane (rise 0, every table-top part); + # the healing machine's screen head is MOUNTED on the console's + # front instead, and the drawing states where -- its bottom row is + # two above the body's top, so rise is measured, not tuned. + base = plane + p.get("rise", 0) + # ...and `z` names its back-most depth row, the same field a flat + # part carries. A part on a desk starts at the plot's back (0); + # the healing machine's console stands in the FRONT map row of a + # grid whose back row is the wall band it leans against, so it + # starts where that wall ends. + pz = p.get("z", 0) + ytp = base + (fr1 - fr0) + # `inset` sinks an authored pane one voxel: the pane rule + # applied by hand, for a part whose screen IS sealed behind + # its own black frame while the template's `panes=False` + # (set for the polarity-inverted panel elsewhere in the same + # drawing) blocks the global pass. Same mechanism as a + # recess: the front voxel is simply not placed. + ins = p.get("inset") + for sx in range(x0, x1 + 1): + # the lid: the part's drawn top laid across its depth from + # the back, last row continuing forward; the front lid row + # is the facade's own top row -- the drawn front-top edge. + # `stretch` maps the drawn band over the whole depth + # instead, the tray's rule: for a part authored DEEPER + # than its drawing (the house stool grown past its drawn + # seat), clamping would print the last row as a long + # smear off the back band's edge. + for z in range(pz, pz + pd): + if z == pz + pd - 1: + sy = fr0 + elif p.get("stretch"): + sy = min(tr0 + ((z - pz) * (tr1 - tr0 + 1)) + // (pd - 1), tr1) + else: + sy = min(tr0 + z - pz, tr1) + while sy <= tr1 and not inside(sx, sy): + sy += 1 + if sy > tr1 and not (z == pz + pd - 1 and inside(sx, fr0)): + continue + if not 0 <= z < D: # a part lives inside the plot + continue + put(sx, ytp, z, sx, fr0 if z == pz + pd - 1 else sy) + # the body: facade rows anchored to the part's own base + for sy in range(fr0 + 1, fr1 + 1): + y = base + (fr1 - sy) + if not inside(sx, sy): + continue + ix = interior(sx, sy, x0, x1) + for z in range(pz, pz + pd): + if not 0 <= z < D: + continue + if z == pz + pd - 1: + if ins and ins["x"][0] <= sx <= ins["x"][1] \ + and ins["rows"][0] <= sy <= ins["rows"][1]: + continue + if (sx, sy) not in pr["recess"]: + put(sx, y, z, sx, sy) + else: + put(sx, y, z, sx if z == pz else ix, sy) + + # A TRAY is an open container -- the drawing looks down INTO it, so + # its top-view band is not a lid but the inside of the box, and the + # model has to be hollow. Bands, all measured 1:1 like any other band + # table: `top` is the opening (drawn row -> depth row), `front` the + # near wall seen face-on (drawn row -> elevation), `x` the box's outer + # span and `inner` the opening's, so the difference between them is + # the wall. Four walls stand to the rim, the floor slab lies `floor` + # voxels thick under the opening, and the cavity between them is left + # as air -- which is the whole point, and what an extruded facade can + # never be. Parts (a standing lid) then ride the rim like any object + # on a desk's plane. + if t.get("tray"): + tr = t["tray"] + top0, top1 = tr["top"] + fr0, fr1 = tr["front"] + bx0, bx1 = tr["x"] + ix0, ix1 = tr["inner"] + floor = tr.get("floor", 0) + plane = fr1 - fr0 + 1 # the rim: the wall's height + # Which drawn row lies at depth z. The far rim is the band's first + # row and the near rim the front wall's own, and the drawn inside + # STRETCHES over whatever depth is between them: a box deeper than + # its drawing has rows to spare is the ordinary case once the plot + # stops being the grid, and the alternative -- running out of rows + # and repeating the last one -- would print the wrench twice. + lo, hi = top0 + 1, top1 - 1 # the drawn inside + span = max(1, D - 3) # interior depth rows - 1 + def tray_row(z): + if z == 0: + return top0 + if z == D - 1: + return fr0 + return lo + ((z - 1) * (hi - lo)) // span + + for sx in range(bx0, bx1 + 1): + for z in range(D): + hollow = ix0 <= sx <= ix1 and 0 < z < D - 1 + for y in range(0, (floor if hollow else plane - 1) + 1): + if hollow or y == plane - 1: + # the opening seen from above: the tray's own + # floor and whatever lies in it -- and the rim is + # the same band where the wall meets it + sy = tray_row(z) + if not inside(sx, sy): + continue + put(sx, y, z, sx, sy) + else: + # the wall below the rim: the front band folded up + # it, the drawn face on the front and back layers + # and the de-outlined interior between, exactly as + # a facade extrudes. + # + # NO recess pass here, and it must stay that way: a + # pane sinks by DELETING its front voxel so the one + # behind becomes the pane, and a container's wall is + # one voxel thick -- there is nothing behind it, so + # the front panel simply opened a hole straight into + # the box and you could see the wrench through it. + sy = fr1 - y + if not inside(sx, sy): + continue + px = sx if z in (0, D - 1) else interior(sx, sy, + bx0, bx1) + put(sx, y, z, px, sy) + build_parts(plane) + return vox + + # No base piece at all: the drawing IS its parts (the house stool -- a + # seat and its legs, nothing under them but floor). The plane the parts + # anchor to is the ground itself. + if not t.get("desk"): + build_parts(0) + return vox + f0, f1 = t["desk"]["fascia"] b0, b1 = t["desk"]["base"] plane = (b1 - b0 + 1) + (f1 - f0 + 1) # the desk's top plane + # The desk's own PLOT, when the grid holds more than the desk. Bill's + # grid runs on into the walkable cell, because the drawing puts the + # desk's apron AND the chair pushed up to it in the same tiles -- so + # the desk box has to stop at its own cell (`depth`) and stand on its + # own ground line rather than the grid's, which the chair's feet set + # eight rows lower. The base band's last row IS that ground line by + # definition, and for every desk drawn inside its own grid it is the + # measured one to the row (lab table, lab computers, Center PC, the + # Bike Shop toolbox), so this changes nothing for them. + # ...and in voxels (`depth_px`) plus a back origin (`z`) when the + # desk is shallower than a tile row and leans against something: the + # healing machine's cabinet is 10 deep -- its drawn top band's 9 + # rows plus the front edge -- standing against the wall band, so its + # box runs z 16..25 of a 32-deep plot. + desk_d = t["desk"].get("depth_px") or t["desk"].get("depth", 0) * 8 or D + dz0 = t["desk"].get("z", 0) + dz1 = dz0 + desk_d - 1 + desk_g = b1 + 1 + # ...and the desk's COLUMNS (`x`), when the grid is wider than the + # desk: the healing machine's grid carries its flanking hoses and + # keyboard, and the cabinet is only the middle 16 columns. + dx0, dx1 = t["desk"].get("x", (0, W - 1)) + + # The WALL element: the band the machine backs onto, whose tiles this + # grid claims. The drawing shows it only as the stripe background + # around the tower (the same standing as the potted plants' floor), + # so the block cycles the drawing's own stripe unit -- real pixels of + # column `x`, rows `cycle` -- at wall-band height over the back plot, + # exactly what the neighbouring cells' `wall` pins render. + if t.get("wall"): + wl = t["wall"] + c0, c1 = wl["cycle"] + cn = c1 - c0 + 1 + wx = wl.get("x", 0) + for y in range(wl["h"]): + sy = c0 + (wl["h"] - 1 - y) % cn + for sx in range(W): + for z in range(wl["depth_px"]): + put(sx, y, z, wx, sy) + # the base band, extruded exactly like every lab table's for sy in range(b0, b1 + 1): - y = ground - 1 - sy - for sx in range(W): + y = desk_g - 1 - sy + for sx in range(dx0, dx1 + 1): if not inside(sx, sy): continue - ix = interior(sx, sy, 0, W - 1) - for z in range(D): - put(sx, y, z, sx if z in (0, D - 1) else ix, sy) + ix = interior(sx, sy, dx0, dx1) + for z in range(dz0, dz1 + 1): + put(sx, y, z, sx if z in (dz0, dz1) else ix, sy) for sx, sy in pr["recess"]: - if b0 <= sy <= b1: - vox.pop((sx, ground - 1 - sy, D - 1), None) - - # the slab: the fascia rows wrap every side; the lid is the one - # synthesized surface in the model -- the drawing never paints the - # tabletop (its objects cover it), so the lid continues the sibling - # tables' pattern in the drawing's own shades: black rim, white - # highlight courses along the north and west, grey field + if b0 <= sy <= b1 and dx0 <= sx <= dx1: + vox.pop((sx, desk_g - 1 - sy, dz1), None) + + # the slab: the fascia rows wrap every side for i, sy in enumerate(range(f0, f1 + 1)): y = plane - 1 - i - for sx in range(W): - for z in range(D): + for sx in range(dx0, dx1 + 1): + for z in range(dz0, dz1 + 1): put(sx, y, z, sx, sy) - field = WHITE if t["desk"].get("lid") == "white" else GREY - for sx in range(W): - for z in range(D): - if sx in (0, W - 1) or z in (0, D - 1): - shade = BLACK - elif sx == 1 or z == 1: - shade = WHITE - else: - shade = field - px = shade_px.get(shade) or shade_px[BLACK] - put(sx, plane - 1, z, px[0], px[1]) - for p in t["parts"]: - x0, x1 = p["x"] - if p["kind"] == "flat": - r0, r1 = p["rows"] - for sy in range(r0, r1 + 1): - # drawn row = depth row by default; `z` renames the - # origin when the flat sits below the desk's own drawn - # top span (the Center PC's keyboard) - z = p.get("z", r0) + (sy - r0) - if not 0 <= z < D: - continue - for sx in range(x0, x1 + 1): - if inside(sx, sy): - put(sx, plane, z, sx, sy) - continue - tr0, tr1 = p["top"] - fr0, fr1 = p["facade"] - pd = p["depth"] - ytp = plane + (fr1 - fr0) - for sx in range(x0, x1 + 1): - # the lid: the part's drawn top laid across its depth from - # the back, last row continuing forward; the front lid row - # is the facade's own top row -- the drawn front-top edge - for z in range(pd): - sy = fr0 if z == pd - 1 else min(tr0 + z, tr1) - while sy <= tr1 and not inside(sx, sy): - sy += 1 - if sy > tr1 and not (z == pd - 1 and inside(sx, fr0)): - continue - put(sx, ytp, z, sx, fr0 if z == pd - 1 else sy) - # the body: facade rows anchored to the desk's top plane - for sy in range(fr0 + 1, fr1 + 1): - y = plane + (fr1 - sy) - if not inside(sx, sy): - continue - ix = interior(sx, sy, x0, x1) - for z in range(pd): - if z == pd - 1: - if (sx, sy) not in pr["recess"]: - put(sx, y, z, sx, sy) + top_band = t["desk"].get("top") + if top_band: + # The lid wears the desk's own drawn top band -- the drawing DOES + # paint this tabletop (the healing machine's white top face with + # its lit west and shaded east strips), so nothing is synthesized + # where it is visible: band rows map back-to-front, the first + # fascia row is the drawn front-top edge, same rule as an upright + # part's lid. Where a part's drawing occludes the band (the + # monitor standing on it), the lid continues the nearest strip + # BESIDE the part -- still the drawing's own pixels, the same + # sibling-pattern rule every synthesized lid follows. + tr0, tr1 = top_band + for z in range(dz0, dz1 + 1): + sy = f0 if z == dz1 else min(tr0 + (z - dz0), tr1) + for sx in range(dx0, dx1 + 1): + px = sx + for p in t["parts"]: + px0, px1 = p["x"] + if p["kind"] in ("flat", "iso", "box"): + r0, r1 = p["rows"] else: - put(sx, y, z, sx if z == 0 else ix, sy) + r0, r1 = p["top"][0], p["facade"][1] + if px0 <= sx <= px1 and r0 <= sy <= r1: + px = px0 - 1 if sx - px0 < px1 - sx else px1 + 1 + px = max(dx0, min(dx1, px)) + break + put(sx, plane - 1, z, px, sy) + else: + # the lid is the one synthesized surface in the model -- the + # drawing never paints the tabletop clear of its objects, so the + # lid continues the sibling tables' pattern in the drawing's own + # shades: black rim, white highlight courses along the north and + # west, grey field + field = WHITE if t["desk"].get("lid") == "white" else GREY + for sx in range(dx0, dx1 + 1): + for z in range(dz0, dz1 + 1): + if sx in (dx0, dx1) or z in (dz0, dz1): + shade = BLACK + elif sx == dx0 + 1 or z == dz0 + 1: + shade = WHITE + else: + shade = field + px = shade_px.get(shade) or shade_px[BLACK] + put(sx, plane - 1, z, px[0], px[1]) + + build_parts(plane) return vox @@ -1024,16 +1734,22 @@ def verify(vox, sp, pr, t): ytop, top, slab = pr["ytop"], pr["top"], t["slab"] T = lambda x: ytop - top[max(0, min(W - 1, x))] + # A desk set answers its own asserts, and answers this first one + # BETTER -- per part rather than per drawing (nothing may stand above + # the part it belongs to). It also has to: a desk with an authored + # plane legitimately stands taller than its drawing's row count, + # because its height is the one thing the grid does not draw. + # A facade-only template (roof_rows == 0) has no roof band, so there + # is no surface to hold level or slope and no coverage to demand of + # it; everything with a roof band answers the full set. + if t.get("parts"): + verify_desk_set(vox, pr, t) + return shell_of(vox) + for (x, y, z), _ in vox.items(): assert y <= T(x), f"voxel pokes through the roof at {x},{y},{z}" - # A desk set answers its own asserts; a facade-only template (roof_rows - # == 0) has no roof band, so there is no surface to hold level or slope - # and no coverage to demand of it; everything with a roof band answers - # the full set. - if t.get("parts"): - verify_desk_set(vox, pr, t) - elif t["roof_rows"] == 0: + if t["roof_rows"] == 0: # its one geometric intent: a straight extrusion, so each column # of the drawing is solid from its lowest voxel to its top. NOT # from the ground -- the drawing's base band is inset like every @@ -1051,24 +1767,76 @@ def verify(vox, sp, pr, t): else: verify_roof(vox, pr, t) - shell = [k for k in vox if not all( + return shell_of(vox) + + +def shell_of(vox): + return [k for k in vox if not all( (k[0] + d[0], k[1] + d[1], k[2] + d[2]) in vox for d in ((1, 0, 0), (-1, 0, 0), (0, 1, 0), (0, -1, 0), (0, 0, 1), (0, 0, -1)))] - return shell + + +def verify_tray(vox, pr, t): + """An open container: the walls stand unbroken to the rim, the floor + slab is solid under the whole opening, and -- the assert the whole + thing exists for -- the cavity between them is EMPTY. A tray that + fills in is just the extruded box again.""" + D = pr["D"] + tr = t["tray"] + fr0, fr1 = tr["front"] + bx0, bx1 = tr["x"] + ix0, ix1 = tr["inner"] + floor = tr.get("floor", 0) + plane = fr1 - fr0 + 1 + + for sx in range(bx0, bx1 + 1): + for z in range(D): + hollow = ix0 <= sx <= ix1 and 0 < z < D - 1 + if hollow: + for y in range(floor + 1): + assert (sx, y, z) in vox, f"tray floor hole {sx},{y},{z}" + for y in range(floor + 1, plane): + assert (sx, y, z) not in vox, \ + f"tray is not hollow at {sx},{y},{z}" + else: + for y in range(plane): + if (sx, y, z) not in vox: # a pane sank, or undrawn + continue + assert y < plane, f"wall above the rim at {sx},{y},{z}" + # and the walls actually enclose it: every opening column has a wall + # on all four sides + for sx in range(ix0, ix1 + 1): + assert (sx, plane - 1, 0) in vox and (sx, plane - 1, D - 1) in vox, \ + f"opening column {sx} is not walled front and back" + for z in range(1, D - 1): + assert (bx0, plane - 1, z) in vox and (bx1, plane - 1, z) in vox, \ + f"opening row {z} is not walled left and right" def verify_desk_set(vox, pr, t): W, D = pr["W"], pr["D"] - f0, f1 = t["desk"]["fascia"] - b0, b1 = t["desk"]["base"] - plane = (b1 - b0 + 1) + (f1 - f0 + 1) - - # the slab is a full solid under everything on it - for x in range(W): - for z in range(D): - for y in range(b1 - b0 + 1, plane): - assert (x, y, z) in vox, f"slab hole at {x},{y},{z}" + inside = pr["inside"] + if t.get("tray"): + return verify_tray(vox, pr, t) + # the slab is a full solid under everything on it, over the desk's + # own plot (which is the whole grid unless the grid holds something + # standing in front of the desk as well). A desk-less template (the + # house stool) has no slab to demand: its parts anchor to the floor, + # so the plane is 0 and only the per-part asserts below apply. + plane = 0 + dz0, desk_d = 0, 0 + if t.get("desk"): + f0, f1 = t["desk"]["fascia"] + b0, b1 = t["desk"]["base"] + plane = (b1 - b0 + 1) + (f1 - f0 + 1) + desk_d = t["desk"].get("depth_px") or t["desk"].get("depth", 0) * 8 or D + dz0 = t["desk"].get("z", 0) + dx0, dx1 = t["desk"].get("x", (0, W - 1)) + for x in range(dx0, dx1 + 1): + for z in range(dz0, dz0 + desk_d): + for y in range(b1 - b0 + 1, plane): + assert (x, y, z) in vox, f"slab hole at {x},{y},{z}" # each part stands where authored -- upright bodies solid from the # desk to their own drawn top (bar the front layer, whose panes @@ -1079,21 +1847,73 @@ def verify_desk_set(vox, pr, t): if p["kind"] == "flat": r0, r1 = p["rows"] z0 = p.get("z", r0) + at_y = p.get("at", plane) for x in range(x0, x1 + 1): for z in range(max(z0, 0), min(z0 + r1 - r0, D - 1) + 1): - tops[(x, z)] = max(tops.get((x, z), 0), plane) + tops[(x, z)] = max(tops.get((x, z), 0), at_y) + elif p["kind"] == "box": + # its one geometric intent: a solid column from `base` to the + # drawn top wherever the silhouette is drawn, at its own + # elevation -- never touching the desk plane machinery + r0, r1 = p["rows"] + pz = p.get("z", 0) + top = pr["ground"] - 1 - r0 + bot = p.get("base", pr["ground"] - 1 - r1) + for x in range(x0, x1 + 1): + for z in range(pz, pz + p["depth"]): + if not 0 <= z < D: + continue + tops[(x, z)] = max(tops.get((x, z), 0), top) + if z == pz + p["depth"] - 1: + continue + ys = [y for y in range(bot, top + 1) if (x, y, z) in vox] + assert ys == list(range(ys[0], ys[0] + len(ys))) \ + if ys else True, f"box column hole at {x},{z}" + elif p["kind"] == "iso": + # its one geometric intent: a diamond in plan, solid from the + # plane to a FLAT top -- an isometric box that came out + # stepped would mean the un-projection had drifted + pr0, pr1 = p["rows"] + rx = (x1 - x0 + 1) // 2 + rz = rx // 2 + plan = p.get("plan", rz) + h = (pr1 - rz) - rz - pr0 + for x in range(x0, x1 + 1): + dx2 = 2 * x - (x0 + x1) + for dz in range(-plan, plan + 1): + z = p["z"] + dz + d2 = abs(dx2) * plan + 2 * abs(dz) * rx + if not (0 <= z < D and d2 <= (2 * rx + 1) * plan): + continue + tops[(x, z)] = max(tops.get((x, z), 0), plane + h) + for y in range(plane, plane + h + 1): + assert (x, y, z) in vox, \ + f"iso box hole at {x},{y},{z}" else: fr0, fr1 = p["facade"] - ytp = plane + (fr1 - fr0) + ytp = plane + p.get("rise", 0) + (fr1 - fr0) + pz = p.get("z", 0) for x in range(x0, x1 + 1): - for z in range(p["depth"]): + for z in range(pz, pz + p["depth"]): tops[(x, z)] = max(tops.get((x, z), 0), ytp) - if z == p["depth"] - 1: + if z == pz + p["depth"] - 1: continue ys = [y for y in range(plane, ytp + 1) if (x, y, z) in vox] assert ys == list(range(ys[0], ys[0] + len(ys))) \ if ys else True, f"part column hole at {x},{z}" + # the wall element is a full solid at exactly its own height -- a + # hole is a gap in the room's back wall, and anything above it is + # the tower failure the element exists to prevent + if t.get("wall"): + wl = t["wall"] + for x in range(W): + for z in range(wl["depth_px"]): + for y in range(wl["h"]): + assert (x, y, z) in vox, f"wall hole at {x},{y},{z}" + floor_top = plane - 1 if dz0 <= z < dz0 + desk_d else 0 + tops[(x, z)] = max(tops.get((x, z), 0), wl["h"] - 1, + floor_top) for (x, y, z) in vox: assert y <= tops.get((x, z), plane - 1), \ f"voxel above its part at {x},{y},{z}" @@ -1189,7 +2009,8 @@ def main(): out = sys.argv[1] if len(sys.argv) > 1 else "." os.makedirs(out, exist_ok=True) for name, t in TEMPLATES.items(): - sp = sprite(t["tiles"], t.get("seal", ""), t.get("tileset", "overworld")) + sp = sprite(t["tiles"], t.get("seal", ""), t.get("tileset", "overworld"), + t.get("scrub")) pr = profile(sp, t) vox = build(sp, pr, t) shell = verify(vox, sp, pr, t) From a7c9541ac49fc8d5567a23fe29ee70c0b8846d98 Mon Sep 17 00:00:00 2001 From: DramaticShape Date: Sun, 2 Aug 2026 00:01:39 -0400 Subject: [PATCH 019/116] pokecenter machines, water occlusion --- data/voxel_heights.lua | 337 +++++++++++++++++++++++++--------- lib/Buildings.lua | 86 +++++++-- lib/VoxelScene.lua | 45 ++++- lib/Water.lua | 9 +- tests/dramatic_shape_test.lua | 13 ++ tools/building_voxels.py | 107 +++++++---- 6 files changed, 454 insertions(+), 143 deletions(-) diff --git a/data/voxel_heights.lua b/data/voxel_heights.lua index 2c5ae61..a98c059 100644 --- a/data/voxel_heights.lua +++ b/data/voxel_heights.lua @@ -842,12 +842,12 @@ return { -- console face (76/77) and button panel (6/22) are ALSO wall: -- drawn 16px tall against the band, so wall height is their drawn -- height and they read as equipment jutting from it. (The - -- `center_heal_machine` template below claims those four tiles at - -- every placement and models the console properly; these pins are - -- the degradation path when the profile is absent.) The - -- near-black screen tiles must be pinned or the void rule flattens - -- them. What is NOT wall is the machines' two flanks -- see - -- `prop` below. + -- `center_heal_machine_w`/`_e` templates below claim the full + -- 4x4 machine grids at every placement and model the console + -- properly; these pins are the degradation path when the profile + -- is absent.) The near-black screen tiles must be pinned or the + -- void rule flattens them. What is NOT wall is the machines' + -- two flanks -- see `prop` below. wall = { 2, 3, 4, 5, 6, 16, 18, 19, 20, 21, 22, 40, 41, 76, 77, 92, 93, 94, 95 }, -- the counters, half a cell high: top band (8) with the nurse's @@ -899,7 +899,10 @@ return { -- game -- 12 maps, the same two cells in each -- so this pin cannot -- reach anything but the machines. 72 is still the $48 -- water-fallback trap and still has to carry SOME pin; it just - -- wanted the thin one, not the wall. + -- wanted the thin one, not the wall. (The machine templates now + -- claim the flank tiles too and model the hoses and keyboard as + -- attached 3D equipment; these standee pins are the degradation + -- path when the profile is absent.) prop = { 7, 13, 72, 73, -- ...and the potted plants -- see THE POTTED PLANTS below 32, 33, 34, 35, 48, 49, 50, 51 }, @@ -1968,13 +1971,24 @@ return { -- on (see `buildings` below). The pin is only the degradation -- path here -- the template claims both placements -- but the two -- must not disagree about where the desk's top is. - heights = { table = 8 }, + -- + -- `stool` is 5 for the same reason: that is the drawn elevation of + -- BOTH seats in this tileset -- rows 11-15, the seat's front edge + -- over its legs -- and the height the `club_stool` template stands + -- them. Bill's chair and the Fan Club's are different drawings + -- above the seat but share tiles 59/60 below it, so they are drawn + -- at the same height pixel for pixel; the class default of 8 was + -- three voxels over both. Every one of these cells is WALKABLE, + -- so this is also where a character sitting on one ends up. + heights = { table = 8, stool = 5 }, -- the seats: Bill's desk stool (43/44 over 59/60) and the Fan -- Club's four members' chairs (61/62 over 59/60), a whole cell -- each. All of them sit in WALKABLE cells, so they were flat - -- floor until pinned; the 8px standee pool puts them at seat - -- height, which is also where a character standing on the cell - -- ends up. + -- floor until pinned; the standee pool puts them at seat height, + -- which is also where a character standing on the cell ends up. + -- The Fan Club's four are modelled in full by `club_stool` below + -- and these pins are their degradation path; Bill's chair stays a + -- standee, stood up as a part of `bills_desk`. stool = { 43, 44, 59, 60, 61, 62 }, -- flat, and pinned flat on purpose. 31 is the rooms' floor tile -- and needs no pin for its 980 walkable placements -- but the @@ -3737,82 +3751,154 @@ return { z = 13 }, -- keyboard }, }, - -- F05: the healing machine behind the counter -- the pair in every - -- Center (cells (1,0):(2,1) and (6,0):(7,1)), 22 placements, plus - -- the Indigo Plateau lobby's pair under MART below (scan - -- "58,59;74,75;76,77;6,22"). + -- F05: the healing machine behind the counter -- two 4x4-tile + -- variants, 24 placements between them (a pair in every Center, + -- cells (1,0):(2,1) and (6,0):(7,1), plus the Indigo Plateau + -- lobby's pair under MART below). The variants differ only in + -- the east flank: the west machine has the control keyboard + -- there (7/13, scan "40,58,59,40;40,74,75,40;72,76,77,7; + -- 72,6,22,13" -- 12 placements), the east machine mirrored + -- hoses (73, scan "...;72,76,77,73;72,6,22,73" -- 12). -- - -- The drawing is 4x4 tiles but only its middle COLUMN is this - -- template's. The corners are the striped wall band (40), and - -- the outer tile columns are the flanking EQUIPMENT -- the hoses - -- (72 west, 73 east) and the control keyboard (7/13) -- which are - -- separate thin objects, not part of this console: they are - -- pinned into the THIN standee pool above and stay out of this - -- grid. What no class can split is the middle -- a wall-height + -- The FULL drawing is claimed now: the corners are the striped + -- wall band (40), the flanks the machine's own equipment -- + -- hoses and a keyboard ATTACHED to the cabinet, not furniture + -- standing free (their standee pins above stay as the + -- degradation path). What no class can split is a wall-height -- cabinet with a monitor perched on its front top edge, drawn -- across two map rows because it towers over the 16px band -- behind it, which the volume path can only read as more wall. -- - -- Read off the pixels (local x, the whole drawing's x8..x23): - -- rows 15-31 the CABINET's front face, 16 wide, 17 tall: - -- black front-top edge with vent notches (15), - -- white top rail (16), black panel in a white - -- frame (17-28), white rail / light plinth / black - -- ground line (29-31). - -- rows 6-14 the cabinet's TOP FACE seen from above -- not a - -- second-story facade: a white expanse with a lit - -- west strip (x1 white) and a shaded east strip - -- (x14 #555) wrapping the monitor, full-width in - -- front of it (13-14), strips beside it (7-12). - -- Its 9 rows plus the front edge row 15 ARE the - -- cabinet's depth: 10. `desk.top` lays the band - -- flat as the lid; where the monitor's drawing - -- occludes it, the lid continues the nearest strip - -- (the drawing's own pixels). - -- rows 1-13 the MONITOR, x2..x13: black back rim (1) and - -- white cap (2-3) seen from above -- 3 rows plus - -- the front edge, depth 4 -- then bezel, screen - -- and control strip (4-13) face-on, 10 tall, - -- standing on the cabinet top at its front: the - -- drawn base edge (13) is one band row up from the - -- front strip (14), so its front plane is one - -- voxel back from the cabinet's -- z 20..23. The - -- screen interior (x5..x10, rows 6-8) is sealed - -- behind its own black frame and sinks one voxel + -- Read off the pixels (absolute grid x0..x31): + -- rows 15-31, x8..x23 the CABINET's front face, 16 wide, 17 + -- tall: black front-top edge with vent notches + -- (15), white top rail (16), black panel in a + -- white frame (17-28), white rail / light plinth / + -- black ground line (29-31). `desk.x` bounds the + -- desk to the middle columns. + -- rows 6-14, x8..x23 the cabinet's TOP FACE seen from above + -- -- not a second-story facade: a white expanse + -- with a lit west strip (x9) and a shaded east + -- strip (x22) wrapping the monitor. Its 9 rows + -- plus the front edge row 15 ARE the cabinet's + -- depth: 10, z 16..25 against the wall. + -- `desk.top` lays the band flat as the lid; where + -- the monitor's drawing occludes it, the lid + -- continues the nearest strip (the drawing's own + -- pixels). + -- rows 1-13, x10..x21 the MONITOR: black back rim (1) and + -- white cap (2-3) seen from above -- depth 4 -- + -- then bezel, screen and control strip (4-13) + -- face-on, 10 tall, at the cabinet top's front + -- (the drawn base edge 13 is one band row up from + -- the front strip 14): z 20..23. The screen + -- interior (x13..x18, rows 6-8) sinks one voxel -- via `inset` -- the pane rule applied by hand, -- because `panes = false` blocks the global pass. - -- rows 0-5 at the flanks: wall stripes and the machine's - -- cast shadow on the wall east of it -- BACKGROUND, - -- the same standing as the potted plants' floor. - -- The `wall` element keeps the band solid over the - -- back map row: a 16-tall, 16-deep block cycling - -- the drawing's own stripe unit (x0, rows 0-3), - -- what the neighbouring cells' wall pins render. + -- rows 16-31, x0..x7 the HOSES (tile 72 stacked): one 8-row + -- motif per map row, and the two stacked motifs + -- are two hoses in DEPTH -- the drawn row band is + -- the depth band, and each motif's own rows are + -- its elevation WITHIN that band, exactly the + -- potted plants' stacked-cell rule. So both + -- hoses hug the floor: a horizontal run off the + -- cabinet's side (rows 24-26, x3..x7 -> heights + -- 5..7) over a shaded drop landing ON the floor + -- (rows 27-31, x2..x7 -> heights 0..4) -- all + -- measured, the drawn extent maps 1:1 with no + -- continuation. `box` parts at z 18 and z 24, 3 + -- deep; both wear the LOWER motif's rows (the one + -- drawn at its true elevation; the upper motif is + -- the same atlas tile, pixel-identical). + -- rows 16-31, x24..x31 west variant: the KEYBOARD (7/13), a + -- key grid with its cable at the north end and a + -- bar down the east edge -- TOP-VIEW art, 16 rows + -- = 16 depth rows. A `flat` part mounted on the + -- cabinet's side at COUNTER level (`at` 8, the + -- counters' own 8px band -- the one number + -- top-view art cannot state, pinned to the room's + -- work surface), 3 thick, top face wearing the + -- drawn art. The dark dashes east of it (x30-31) + -- are its cast shadow on the floor: background. + -- East variant: mirrored hoses (runs x24..x28, + -- drops x24..x29), same rows and z slots. + -- rows 0-15 elsewhere wall stripes and the machine's cast + -- shadow -- BACKGROUND, the same standing as the + -- potted plants' floor. The `wall` element keeps + -- the band solid over the back map row, full grid + -- width: a 16-tall, 16-deep block cycling the + -- drawing's own stripe unit (x0, rows 0-3), what + -- the neighbouring cells' wall pins render. -- -- `depth = 4` is BOTH map rows: the back row is the wall's plot, - -- the front row the machine's own, and the cabinet leans against - -- the wall at z 16..25. `panes = false`: the front panel seals - -- DARK behind LIGHT (a black panel in a white frame), the - -- opposite polarity to the pane rule, so the global recess pass - -- would sink the frame and leave the panel proud. + -- the front row the machine's own. `panes = false`: the front + -- panel seals DARK behind LIGHT (a black panel in a white + -- frame), the opposite polarity to the pane rule, so the global + -- recess pass would sink the frame and leave the panel proud. { - id = "center_heal_machine", + id = "center_heal_machine_w", tiles = { - { 58, 59 }, - { 74, 75 }, - { 76, 77 }, - { 6, 22 }, + { 40, 58, 59, 40 }, + { 40, 74, 75, 40 }, + { 72, 76, 77, 7 }, + { 72, 6, 22, 13 }, }, roofRows = 0, roofBack = 0, roofFront = 0, roofCycle = { 0, 0 }, slab = 0, frontEave = 0, ledge = nil, depth = 4, panes = false, wall = { h = 16, depthPx = 16, x = 0, cycle = { 0, 3 } }, - desk = { fascia = { 15, 16 }, base = { 17, 31 }, z = 16, - depthPx = 10, top = { 6, 14 } }, + desk = { x = { 8, 23 }, fascia = { 15, 16 }, base = { 17, 31 }, + z = 16, depthPx = 10, top = { 6, 14 } }, parts = { - { kind = "upright", x = { 2, 13 }, top = { 1, 3 }, + { kind = "upright", x = { 10, 21 }, top = { 1, 3 }, facade = { 4, 13 }, z = 20, depth = 4, - inset = { x = { 5, 10 }, rows = { 6, 8 } } }, -- the monitor + inset = { x = { 13, 18 }, rows = { 6, 8 } } }, -- the monitor + { kind = "box", x = { 3, 7 }, rows = { 24, 26 }, + z = 18, depth = 3 }, -- N hose run + { kind = "box", x = { 2, 7 }, rows = { 27, 31 }, + z = 18, depth = 3 }, -- N hose drop + { kind = "box", x = { 3, 7 }, rows = { 24, 26 }, + z = 24, depth = 3 }, -- S hose run + { kind = "box", x = { 2, 7 }, rows = { 27, 31 }, + z = 24, depth = 3 }, -- S hose drop + { kind = "flat", x = { 24, 29 }, rows = { 16, 31 }, + z = 16, at = 8, thick = 3 }, -- keyboard shelf + }, + }, + { + id = "center_heal_machine_e", + tiles = { + { 40, 58, 59, 40 }, + { 40, 74, 75, 40 }, + { 72, 76, 77, 73 }, + { 72, 6, 22, 73 }, + }, + roofRows = 0, roofBack = 0, roofFront = 0, roofCycle = { 0, 0 }, + slab = 0, frontEave = 0, ledge = nil, depth = 4, + panes = false, + wall = { h = 16, depthPx = 16, x = 0, cycle = { 0, 3 } }, + desk = { x = { 8, 23 }, fascia = { 15, 16 }, base = { 17, 31 }, + z = 16, depthPx = 10, top = { 6, 14 } }, + parts = { + { kind = "upright", x = { 10, 21 }, top = { 1, 3 }, + facade = { 4, 13 }, z = 20, depth = 4, + inset = { x = { 13, 18 }, rows = { 6, 8 } } }, -- the monitor + { kind = "box", x = { 3, 7 }, rows = { 24, 26 }, + z = 18, depth = 3 }, -- NW hose run + { kind = "box", x = { 2, 7 }, rows = { 27, 31 }, + z = 18, depth = 3 }, -- NW hose drop + { kind = "box", x = { 3, 7 }, rows = { 24, 26 }, + z = 24, depth = 3 }, -- SW hose run + { kind = "box", x = { 2, 7 }, rows = { 27, 31 }, + z = 24, depth = 3 }, -- SW hose drop + { kind = "box", x = { 24, 28 }, rows = { 24, 26 }, + z = 18, depth = 3 }, -- NE hose run + { kind = "box", x = { 24, 29 }, rows = { 27, 31 }, + z = 18, depth = 3 }, -- NE hose drop + { kind = "box", x = { 24, 28 }, rows = { 24, 26 }, + z = 24, depth = 3 }, -- SE hose run + { kind = "box", x = { 24, 29 }, rows = { 27, 31 }, + z = 24, depth = 3 }, -- SE hose drop }, }, }, @@ -3840,28 +3926,75 @@ return { }, }, -- F05 again: the Indigo Plateau lobby has the Centers' healing - -- machine pair too (cells (5,4):(6,5) and (10,4):(11,5)), and its - -- MART tileset shares the POKECENTER atlas, so this is the same - -- drawing tile for tile. Same part table as the POKECENTER entry - -- above. + -- machine pair too -- the west-variant grid at (5,4):(6,5) and + -- the east at (10,4):(11,5), verified by region dump -- and its + -- MART tileset shares the POKECENTER atlas, so these are the + -- same drawings tile for tile. Same part tables as the + -- POKECENTER entries above. { - id = "center_heal_machine", + id = "center_heal_machine_w", tiles = { - { 58, 59 }, - { 74, 75 }, - { 76, 77 }, - { 6, 22 }, + { 40, 58, 59, 40 }, + { 40, 74, 75, 40 }, + { 72, 76, 77, 7 }, + { 72, 6, 22, 13 }, }, roofRows = 0, roofBack = 0, roofFront = 0, roofCycle = { 0, 0 }, slab = 0, frontEave = 0, ledge = nil, depth = 4, panes = false, wall = { h = 16, depthPx = 16, x = 0, cycle = { 0, 3 } }, - desk = { fascia = { 15, 16 }, base = { 17, 31 }, z = 16, - depthPx = 10, top = { 6, 14 } }, + desk = { x = { 8, 23 }, fascia = { 15, 16 }, base = { 17, 31 }, + z = 16, depthPx = 10, top = { 6, 14 } }, parts = { - { kind = "upright", x = { 2, 13 }, top = { 1, 3 }, + { kind = "upright", x = { 10, 21 }, top = { 1, 3 }, facade = { 4, 13 }, z = 20, depth = 4, - inset = { x = { 5, 10 }, rows = { 6, 8 } } }, -- the monitor + inset = { x = { 13, 18 }, rows = { 6, 8 } } }, -- the monitor + { kind = "box", x = { 3, 7 }, rows = { 24, 26 }, + z = 18, depth = 3 }, -- N hose run + { kind = "box", x = { 2, 7 }, rows = { 27, 31 }, + z = 18, depth = 3 }, -- N hose drop + { kind = "box", x = { 3, 7 }, rows = { 24, 26 }, + z = 24, depth = 3 }, -- S hose run + { kind = "box", x = { 2, 7 }, rows = { 27, 31 }, + z = 24, depth = 3 }, -- S hose drop + { kind = "flat", x = { 24, 29 }, rows = { 16, 31 }, + z = 16, at = 8, thick = 3 }, -- keyboard shelf + }, + }, + { + id = "center_heal_machine_e", + tiles = { + { 40, 58, 59, 40 }, + { 40, 74, 75, 40 }, + { 72, 76, 77, 73 }, + { 72, 6, 22, 73 }, + }, + roofRows = 0, roofBack = 0, roofFront = 0, roofCycle = { 0, 0 }, + slab = 0, frontEave = 0, ledge = nil, depth = 4, + panes = false, + wall = { h = 16, depthPx = 16, x = 0, cycle = { 0, 3 } }, + desk = { x = { 8, 23 }, fascia = { 15, 16 }, base = { 17, 31 }, + z = 16, depthPx = 10, top = { 6, 14 } }, + parts = { + { kind = "upright", x = { 10, 21 }, top = { 1, 3 }, + facade = { 4, 13 }, z = 20, depth = 4, + inset = { x = { 13, 18 }, rows = { 6, 8 } } }, -- the monitor + { kind = "box", x = { 3, 7 }, rows = { 24, 26 }, + z = 18, depth = 3 }, -- NW hose run + { kind = "box", x = { 2, 7 }, rows = { 27, 31 }, + z = 18, depth = 3 }, -- NW hose drop + { kind = "box", x = { 3, 7 }, rows = { 24, 26 }, + z = 24, depth = 3 }, -- SW hose run + { kind = "box", x = { 2, 7 }, rows = { 27, 31 }, + z = 24, depth = 3 }, -- SW hose drop + { kind = "box", x = { 24, 28 }, rows = { 24, 26 }, + z = 18, depth = 3 }, -- NE hose run + { kind = "box", x = { 24, 29 }, rows = { 27, 31 }, + z = 18, depth = 3 }, -- NE hose drop + { kind = "box", x = { 24, 28 }, rows = { 24, 26 }, + z = 24, depth = 3 }, -- SE hose run + { kind = "box", x = { 24, 29 }, rows = { 27, 31 }, + z = 24, depth = 3 }, -- SE hose drop }, }, }, @@ -4236,6 +4369,46 @@ return { facade = { 22, 31 }, rise = -8, z = 22, depth = 10 }, }, }, + -- F09 once more: the Pokemon Fan Club's four members' chairs, + -- drawn round the boardroom table -- POKEMON_FAN_CLUB cells (1,3), + -- (1,4), (6,3) and (6,4), and nowhere else in the game. + -- + -- A DIFFERENT drawing from the house stool -- its seat field + -- carries a one-pixel white margin where the house's carries two + -- -- but the same object band for band: rows 5-10 the seat seen + -- from above (its lid), row 11 the seat's own front edge, 12-15 + -- the legs with the floor showing between them. Its elevation + -- rows are PIXEL-IDENTICAL to the house drawing's, so it takes + -- that part table unchanged, growth and all: z 3..13, `stretch` + -- mapping the seat band over the deeper lid, `panes = false` so + -- the pane rule does not sink the legs' lit faces. + -- + -- Rows 11-15 come from tiles 59/60, which this drawing SHARES + -- with Bill's chair (43/44 over 59/60, modelled as a part of + -- `bills_desk` above). That is why the tileset's `stool = 5` + -- height override is right for both seats rather than a special + -- case for this one: they are drawn at the same height because + -- they are drawn with the same pixels. + -- + -- Listed after `bills_desk` although neither grid is a subgrid of + -- the other (that grid has 43/44 over 59/60, this one 61/62), so + -- the order is free and the bigger, more specific grid keeps + -- first claim. + { + id = "club_stool", + tiles = { + { 61, 62 }, + { 59, 60 }, + }, + roofRows = 0, roofBack = 0, roofFront = 0, roofCycle = { 0, 0 }, + slab = 0, frontEave = 0, ledge = nil, + panes = false, + parts = { + { kind = "upright", x = { 2, 13 }, top = { 5, 10 }, + facade = { 11, 15 }, z = 3, depth = 11, + stretch = true }, -- the stool + }, + }, }, }, } diff --git a/lib/Buildings.lua b/lib/Buildings.lua index 04a55df..def6c81 100644 --- a/lib/Buildings.lua +++ b/lib/Buildings.lua @@ -440,14 +440,68 @@ local function deskSetModel(sp, pr, t) if p.kind == "flat" then -- drawn row = depth row by default; `z` renames the origin when -- the flat sits below the desk's own drawn top span (the Center - -- PC's keyboard) + -- PC's keyboard). `at` names the sheet's own height when it does + -- not lie on the desk plane (the healing machine's keyboard is a + -- shelf mounted on the cabinet's side); `thick` gives it a body + -- -- layers below the sheet repeating each column's own texel, + -- the same continuation rule every synthesized surface follows. local r0 = p.rows[1] local z0 = p.z or r0 + local atY = p.at or plane + local thick = p.thick or 1 + if atY > ytop then ytop = atY end for sy = r0, p.rows[2] do local z = z0 + (sy - r0) if z >= 0 and z < D then for sx = x0, x1 do - if inside[sy * W + sx] then put(sx, plane, z, sy * W + sx) end + if inside[sy * W + sx] then + for y = math.max(0, atY - thick + 1), atY do + put(sx, y, z, sy * W + sx) + end + end + end + end + end + elseif p.kind == "box" then + -- A BOX part is a drawn rect standing at its own drawn + -- elevation -- equipment attached to the machine rather than an + -- object on the desk plane. The rows are face-on art: the top + -- row's drawn height IS the box's top (ground - 1 - r0, + -- measured), and the box runs down to `base` (default the drawn + -- extent; 0 continues it to the floor, the legs-continue rule). + -- Height beyond the drawn rows fills the way a roof band does: + -- rows before `cycle` map 1:1 from the top, rows after it 1:1 + -- from the bottom -- the healing machine hoses' foot lands ON + -- the floor -- and the cycle window repeats between. + local r0, r1 = p.rows[1], p.rows[2] + local c0 = p.cycle and p.cycle[1] or r1 + local c1 = p.cycle and p.cycle[2] or r1 + local pz = p.z or 0 + local pd = p.depth + local top = pr.ground - 1 - r0 + local bot = p.base or (pr.ground - 1 - r1) + local nTop, nBot = c0 - r0, r1 - c1 + if top > ytop then ytop = top end + for y = bot, top do + local k, j = top - y, y - bot + local sy + if k < nTop then + sy = r0 + k + elseif j < nBot then + sy = r1 - j + else + sy = c0 + (k - nTop) % (c1 - c0 + 1) + end + for sx = x0, x1 do + local i = sy * W + sx + if inside[i] then + local ix = interiorAt(sx, sy, x0, x1) + for z = pz, pz + pd - 1 do + if z >= 0 and z < D then + local px = (z == pz or z == pz + pd - 1) and sx or ix + put(sx, y, z, sy * W + px) + end + end end end end @@ -700,6 +754,11 @@ local function deskSetModel(sp, pr, t) local dz0 = t.desk.z or 0 local dz1 = dz0 + deskD - 1 local deskG = b1 + 1 + -- ...and the desk's COLUMNS (`x`), when the grid is wider than the + -- desk: the healing machine's grid carries its flanking hoses and + -- keyboard, and the cabinet is only the middle 16 columns. + local dx0 = t.desk.x and t.desk.x[1] or 0 + local dx1 = t.desk.x and t.desk.x[2] or W - 1 -- The WALL element: the band the machine backs onto, whose tiles this -- grid claims. The drawing shows it only as the stripe background @@ -727,9 +786,9 @@ local function deskSetModel(sp, pr, t) for sy = b0, b1 do Budget.tick() local y = deskG - 1 - sy - for sx = 0, W - 1 do + for sx = dx0, dx1 do if inside[sy * W + sx] then - local ix = interiorAt(sx, sy, 0, W - 1) + local ix = interiorAt(sx, sy, dx0, dx1) for z = dz0, dz1 do local px = (z == dz0 or z == dz1) and sx or ix put(sx, y, z, sy * W + px) @@ -739,8 +798,9 @@ local function deskSetModel(sp, pr, t) end for i in pairs(pr.recess) do local sy = math.floor(i / W) - if sy >= b0 and sy <= b1 then - vox[key(i % W, deskG - 1 - sy, dz1)] = nil + local sx = i % W + if sy >= b0 and sy <= b1 and sx >= dx0 and sx <= dx1 then + vox[key(sx, deskG - 1 - sy, dz1)] = nil end end @@ -748,7 +808,7 @@ local function deskSetModel(sp, pr, t) for sy = f0, f1 do Budget.tick() local y = plane - 1 - (sy - f0) - for sx = 0, W - 1 do + for sx = dx0, dx1 do for z = dz0, dz1 do put(sx, y, z, sy * W + sx) end end end @@ -767,19 +827,19 @@ local function deskSetModel(sp, pr, t) for z = dz0, dz1 do Budget.tick() local sy = z == dz1 and f0 or math.min(tr0 + (z - dz0), tr1) - for sx = 0, W - 1 do + for sx = dx0, dx1 do local px = sx for _, p in ipairs(t.parts) do local px0, px1 = p.x[1], p.x[2] local r0, r1 - if p.kind == "flat" or p.kind == "iso" then + if p.kind == "flat" or p.kind == "iso" or p.kind == "box" then r0, r1 = p.rows[1], p.rows[2] else r0, r1 = p.top[1], p.facade[2] end if sx >= px0 and sx <= px1 and sy >= r0 and sy <= r1 then px = (sx - px0 < px1 - sx) and (px0 - 1) or (px1 + 1) - px = math.max(0, math.min(W - 1, px)) + px = math.max(dx0, math.min(dx1, px)) break end end @@ -790,12 +850,12 @@ local function deskSetModel(sp, pr, t) -- the lid continues the sibling tables' top -- black rim, white -- highlight courses along the north and west, grey field local field = t.desk.lid == "white" and WHITE or GREY - for sx = 0, W - 1 do + for sx = dx0, dx1 do for z = dz0, dz1 do local shade = field - if sx == 0 or sx == W - 1 or z == dz0 or z == dz1 then + if sx == dx0 or sx == dx1 or z == dz0 or z == dz1 then shade = BLACK - elseif sx == 1 or z == dz0 + 1 then + elseif sx == dx0 + 1 or z == dz0 + 1 then shade = WHITE end put(sx, plane - 1, z, pr.shadeTexel[shade]) diff --git a/lib/VoxelScene.lua b/lib/VoxelScene.lua index daa4131..e06036e 100644 --- a/lib/VoxelScene.lua +++ b/lib/VoxelScene.lua @@ -583,8 +583,40 @@ end -- The overworld's alone: the staged battle draws its water plain, always -- -- its placed camera reads this pass wrong, and a stage set wants painted -- water anyway (see BattleScene, where the choice is argued). +-- ------- and why the flat draw happens FIRST, every time +-- +-- The reflective pass writes no depth -- it cannot, the depth canvas is +-- detached for the length of it so the shader can READ it -- and it does its +-- own depth test against that texture instead. That test asks whether +-- something opaque is in front, and it answers correctly for every case but +-- one: WATER IN FRONT OF WATER. Nothing puts water in the depth buffer, so +-- no lake can hide another, and the pass simply paints them in mesh order. +-- +-- On flat water that never mattered, because every surface lies in the one +-- plane at its own recessed height and a farther sheet always lands farther +-- down the screen. THE WORLD CURVE ENDS THAT. The bend drops the world by +-- the square of its distance, so the far side of the map swings down and +-- back up into the near field of view -- and a sheet of sea a hundred and +-- fifty tiles away, drawn later in the same mesh, paints straight over the +-- pond at the player's feet. That is the tall grass and the water "from the +-- other side of the map": not a reflection of them, THEM, rasterised on top +-- of the water in front of you. +-- +-- So the meshes go down flat first, through the ordinary scene shader with +-- depth writes on, and the reflective pass draws over the top of what +-- survived. Three things fall out of it and all three are wanted: the depth +-- buffer now holds the water surface, so the pass's own test throws the far +-- sheet away; the reflection COPY holds it too, so a ray grazing another +-- part of the lake reads water rather than the void behind it; and the flat +-- draw is the same fallback this function already ended with, so a frame +-- that cannot run the reflective pass is unchanged. +-- +-- It costs one more rasterisation of the water meshes. Water is a small +-- share of a frame's triangles and this is the cheapest shader in the mode. function VoxelScene.drawWater(draws, cast) - local plain = true + for _, d in ipairs(draws) do + Voxel3D.draw(d[1], d[2], d[3]) + end if Water.enabled() and Voxel3D.depthReadable() then local mirror, depth = Voxel3D.beginWater(cast) local w, h = Voxel3D.size() @@ -602,20 +634,15 @@ function VoxelScene.drawWater(draws, cast) Water.draw(d[1], d[2], d[3]) end Water.finish() - plain = false end -- Unconditionally, and OUTSIDE the success branch: beginWater unbinds -- the shader and the depth mode BEFORE it can discover it cannot go on, -- so a frame that bails halfway through has to be put back together - -- exactly like one that succeeded -- otherwise the plain draw below (and - -- every pass after it) runs with no shader and no depth test. + -- exactly like one that succeeded -- otherwise every pass after it runs + -- with no shader and no depth test. (What a bail leaves on screen is the + -- flat draw above, which is where this function used to end anyway.) Voxel3D.endWater() end - if plain then - for _, d in ipairs(draws) do - Voxel3D.draw(d[1], d[2], d[3]) - end - end end -- A stamp of everything the sun pass depends on. Nothing in it moving diff --git a/lib/Water.lua b/lib/Water.lua index 4ab4022..3bfcf4e 100644 --- a/lib/Water.lua +++ b/lib/Water.lua @@ -956,8 +956,15 @@ vec4 effect(mediump vec4 color, Image tex, mediump vec2 tc, mediump vec2 sc) { // the bound canvas's own pixel size, the same units sc is measured in, on // every display. (`screen` stays in units: skyPos reads it against cell // and skyEdge, which are unit-measured with it.) + // + // The buffer now holds THIS SURFACE too (VoxelScene draws the water flat + // before the pass that reflects it), which is what makes one lake able to + // hide another -- and it means every fragment here is testing against its + // own depth. The two draws reach it through different shader programs, so + // ask for a hair of slack rather than bit-equality: far less than the gap + // to anything genuinely in front, far more than two compilers disagree by. vec2 uv = sc / love_ScreenSize.xy; - if (gl_FragCoord.z > Texel(depthTex, uv).r) discard; + if (gl_FragCoord.z > Texel(depthTex, uv).r + 1e-5) discard; // THE COLUMN THIS FRAGMENT IS LOOKING AT. Every water pixel is a bar of // its own standing a whole number of pixels tall, and the ray decides diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua index 2fb9094..a832e15 100644 --- a/tests/dramatic_shape_test.lua +++ b/tests/dramatic_shape_test.lua @@ -1915,6 +1915,19 @@ T.check(plain:find("sc / love_ScreenSize.xy", 1, true) ~= nil, .. "size -- `screen` counts canvas UNITS, and on a highdpi phone the two " .. "differ by the density, which clamped the lookup and cut the water " .. "into blocks") +-- ...and it tests against a buffer that now holds the water surface too, +-- because VoxelScene lays the meshes down flat before the reflective pass +-- runs over them. Nothing else can make one lake hide another: the pass +-- writes no depth (the buffer is detached so it can be READ), so before this +-- the sheets were simply painted in mesh order. Flat water never showed it -- +-- one plane, and a farther sheet always lands farther down the screen -- but +-- the world curve drops the far side of the map into the near field of view, +-- and a sea a hundred and fifty tiles away came out rasterised on top of the +-- pond at the player's feet, tall grass and all. +T.check(plain:find("Texel(depthTex, uv).r + 1e-5", 1, true) ~= nil, + "with a hair of slack, because every water fragment is now testing " + .. "against its own depth through a second shader program") +T.check(VoxelScene.drawWater ~= nil, "and the flat draw that puts it there") -- ------- the lift itself -- diff --git a/tools/building_voxels.py b/tools/building_voxels.py index a73fa9d..bcc9078 100644 --- a/tools/building_voxels.py +++ b/tools/building_voxels.py @@ -765,6 +765,35 @@ z=3, depth=11, stretch=True), # the stool ], ), + # F09 once more, on the interior atlas: the Pokemon Fan Club's four + # members' chairs, drawn round the boardroom table -- 4 placements + # (cells (1,3), (1,4), (6,3), (6,4)) and nowhere else in the game. + # A DIFFERENT drawing from the house stool -- its seat field carries + # a one-pixel white margin where the house's carries two -- but the + # same object band for band, and its elevation rows (11-15: the + # seat's front edge, then the legs) are pixel-identical to the house + # drawing's. So it takes the same part table unchanged, including + # the two-voxel growth north and south. + # + # Those elevation rows come from tiles 59/60, which this drawing + # SHARES with Bill's chair (43/44 over 59/60, modelled as a part of + # `bills_desk`) -- which is why one `stool = 5` height override in + # the tileset entry is right for both seats rather than a special + # case: they are drawn at the same height because they are drawn + # with the same pixels. + "club_stool": dict( + tiles=[ + [61, 62], + [59, 60], + ], + roof_rows=0, roof_back=0, roof_front=0, roof_cycle=(0, 0), + slab=0, front_eave=0, ledge=None, tileset="interior", + panes=False, + parts=[ + dict(kind="upright", x=(2, 13), top=(5, 10), facade=(11, 15), + z=3, depth=11, stretch=True), # the stool + ], + ), # F04: the Pokemon Center's PC -- every Center's northeast corner # (11 placements) plus the Indigo Plateau lobby, whose MART tileset # shares this atlas. The lab desk-set read again: a @@ -829,29 +858,31 @@ # voxel via `inset` -- the pane rule by hand, since # `panes=False` blocks the global pass. # rows 16-31, x0..x7 the HOSES (tile 72 twice): one 8-row - # motif per map row -- a horizontal run high on the - # cabinet's side (16-18, x3..x7 -> heights 13..15, - # measured off the drawn rows) and a shaded drop - # (19-23, x2..x7) that bends down INTO THE FLOOR. - # The two stacked motifs are two hoses in DEPTH (the - # drawn row band is the depth band): `box` parts at - # z 18 and z 24, 3 deep, the drop cycling its two - # shading rows (19-20) down to the floor with the - # drawn foot rows (21-23) landing at the bottom -- - # the roof-rim rule turned vertical. The second - # hose re-wears the first motif's rows: the atlas - # tile is the same, so the pixels are identical. + # motif per map row, and the two stacked motifs are + # two hoses in DEPTH -- the drawn row band is the + # depth band, and each motif's own rows are its + # elevation WITHIN that band, exactly the potted + # plants' stacked-cell rule. So both hoses hug the + # floor: a horizontal run off the cabinet's side + # (rows 24-26, x3..x7 -> heights 5..7) over a shaded + # drop landing ON the floor (rows 27-31, x2..x7 -> + # heights 0..4) -- all measured, the drawn extent + # maps 1:1 with no continuation. `box` parts at z 18 + # and z 24, 3 deep; both wear the LOWER motif's rows + # (the one drawn at its true elevation; the upper + # motif is the same atlas tile, pixel-identical). # rows 16-31, x24..x31 west variant: the KEYBOARD (7/13), a # key grid with its cable at the north end and a bar # down the east edge -- TOP-VIEW art, 16 rows = 16 # depth rows. A `flat` part mounted on the cabinet's - # side at working height (`at` 11, authored -- the - # one number top-view art cannot state), 3 thick, - # top face wearing the drawn art. The stray dark - # dashes east of it (x30-31) are its cast shadow on - # the floor: background. East variant: mirrored - # hoses (runs 16-18 x24..x28, drops 19-23 x24..x29), - # same z slots. + # side at COUNTER level (`at` 8, the counters' own + # 8px band -- the one number top-view art cannot + # state, pinned to the room's work surface), 3 + # thick, top face wearing the drawn art. The stray + # dark dashes east of it (x30-31) are its cast + # shadow on the floor: background. East variant: + # mirrored hoses (runs x24..x28, drops x24..x29), + # same rows and z slots. # rows 0-15 elsewhere wall stripes and cast shadow: BACKGROUND. # The `wall` element keeps the band solid over the # back map row, full grid width -- a 16-tall, @@ -878,16 +909,16 @@ dict(kind="upright", x=(10, 21), top=(1, 3), facade=(4, 13), z=20, depth=4, inset=dict(x=(13, 18), rows=(6, 8))), # the monitor - dict(kind="box", x=(3, 7), rows=(16, 18), + dict(kind="box", x=(3, 7), rows=(24, 26), z=18, depth=3), # north hose run - dict(kind="box", x=(2, 7), rows=(19, 23), cycle=(19, 20), - base=0, z=18, depth=3), # north hose drop - dict(kind="box", x=(3, 7), rows=(16, 18), + dict(kind="box", x=(2, 7), rows=(27, 31), + z=18, depth=3), # north hose drop + dict(kind="box", x=(3, 7), rows=(24, 26), z=24, depth=3), # south hose run - dict(kind="box", x=(2, 7), rows=(19, 23), cycle=(19, 20), - base=0, z=24, depth=3), # south hose drop + dict(kind="box", x=(2, 7), rows=(27, 31), + z=24, depth=3), # south hose drop dict(kind="flat", x=(24, 29), rows=(16, 31), - z=16, at=11, thick=3), # keyboard shelf + z=16, at=8, thick=3), # keyboard shelf ], ), "center_heal_machine_e": dict( @@ -907,22 +938,22 @@ dict(kind="upright", x=(10, 21), top=(1, 3), facade=(4, 13), z=20, depth=4, inset=dict(x=(13, 18), rows=(6, 8))), # the monitor - dict(kind="box", x=(3, 7), rows=(16, 18), + dict(kind="box", x=(3, 7), rows=(24, 26), z=18, depth=3), # NW hose run - dict(kind="box", x=(2, 7), rows=(19, 23), cycle=(19, 20), - base=0, z=18, depth=3), # NW hose drop - dict(kind="box", x=(3, 7), rows=(16, 18), + dict(kind="box", x=(2, 7), rows=(27, 31), + z=18, depth=3), # NW hose drop + dict(kind="box", x=(3, 7), rows=(24, 26), z=24, depth=3), # SW hose run - dict(kind="box", x=(2, 7), rows=(19, 23), cycle=(19, 20), - base=0, z=24, depth=3), # SW hose drop - dict(kind="box", x=(24, 28), rows=(16, 18), + dict(kind="box", x=(2, 7), rows=(27, 31), + z=24, depth=3), # SW hose drop + dict(kind="box", x=(24, 28), rows=(24, 26), z=18, depth=3), # NE hose run - dict(kind="box", x=(24, 29), rows=(19, 23), cycle=(19, 20), - base=0, z=18, depth=3), # NE hose drop - dict(kind="box", x=(24, 28), rows=(16, 18), + dict(kind="box", x=(24, 29), rows=(27, 31), + z=18, depth=3), # NE hose drop + dict(kind="box", x=(24, 28), rows=(24, 26), z=24, depth=3), # SE hose run - dict(kind="box", x=(24, 29), rows=(19, 23), cycle=(19, 20), - base=0, z=24, depth=3), # SE hose drop + dict(kind="box", x=(24, 29), rows=(27, 31), + z=24, depth=3), # SE hose drop ], ), # F06: the Bike Shop's toolbox -- 2 placements, cells (6,6) and (7,7) From 1a283d67711d32a0f8492a7b3abc77a52c449d46 Mon Sep 17 00:00:00 2001 From: DramaticShape Date: Sun, 2 Aug 2026 00:14:13 -0400 Subject: [PATCH 020/116] bump version --- CHANGELOG.md | 202 ++++++++++++++++++++++++++++++++ data/voxel_heights.lua | 70 +++++++++++ main.lua | 2 +- manifest.json | 2 +- tests/bike_shop_shots.lua | 79 +++++++++++++ tests/bills_desk_shots.lua | 77 ++++++++++++ tests/chief_house_shots.lua | 81 +++++++++++++ tests/heal_machine_shots.lua | 78 ++++++++++++ tests/house_furniture_shots.lua | 98 ++++++++++++++++ tests/mart_shots.lua | 76 ++++++++++++ tests/potted_plant_shots.lua | 91 ++++++++++++++ tests/roof_curve_shots.lua | 80 +++++++++++++ tests/roof_span_probe.lua | 47 ++++++++ tests/round_regress_shots.lua | 75 ++++++++++++ tests/shelf_relief_shots.lua | 92 +++++++++++++++ tests/ship_can_shots.lua | 78 ++++++++++++ tests/trash_can_shots.lua | 80 +++++++++++++ tests/water_curve_shots.lua | 121 +++++++++++++++++++ tools/building_voxels.py | 59 ++++++++++ 19 files changed, 1486 insertions(+), 2 deletions(-) create mode 100644 tests/bike_shop_shots.lua create mode 100644 tests/bills_desk_shots.lua create mode 100644 tests/chief_house_shots.lua create mode 100644 tests/heal_machine_shots.lua create mode 100644 tests/house_furniture_shots.lua create mode 100644 tests/mart_shots.lua create mode 100644 tests/potted_plant_shots.lua create mode 100644 tests/roof_curve_shots.lua create mode 100644 tests/roof_span_probe.lua create mode 100644 tests/round_regress_shots.lua create mode 100644 tests/shelf_relief_shots.lua create mode 100644 tests/ship_can_shots.lua create mode 100644 tests/trash_can_shots.lua create mode 100644 tests/water_curve_shots.lua diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bb313c..e6f0ad3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,207 @@ # Changelog +## 1.4.3 + +### Added + +- **The furniture of the whole game goes through the building + pipeline.** 1.4.1 put four drawings through it; this is the rest of + the rooms. Every one of them is the same read -- the drawing's own + bands say what is a top seen from above, what is a face seen head-on, + and where the thing ends on the floor -- and every one of them + replaces a pinned box that wore its drawing as a decal. The pins all + stay as the degradation path, neutralized wherever a template stamps. + + - **The bookcase, the commonest piece of furniture in the game** -- + 58 placements across two drawings on the town-house atlas (books + and a bowl on each shelf at the west end of eighteen homes, books + on both at the east), plus Red's and the Copycat's pair. Pinned + `desk` it was a 24px box with the books painted on its flat front. + Modelled it is 23 voxels of cabinet with its top seen from above, + and every book, bowl and door panel sunk a voxel behind the frame + the drawing seals it in. + - **Celadon's display cabinets** -- the tall one with the trophy + behind its glass and the short one beside it, band for band the + same object as the town house's on another atlas, which is what + makes the pair read as one line of furniture: 23 voxels and 15, + exactly the 8 rows of drawing between them. + - **The dining table, everywhere it is drawn** -- the generic town + house's at 18 placements, Red's and the Copycat's, and the chief's + long table at four cells wide. All of them the lab table's read at + a different width, all of them 6 voxels, all of them standing on + the ground line their legs are drawn stopping at rather than on + the grid's floor. + - **The stool at every one of those tables** -- 94 placements on the + house atlas alone, ten more in Red's and the Copycat's, and the Fan + Club's four members' chairs, a different drawing that is + pixel-identical from the seat down. The first template with no base + piece at all: a stool is drawn mid-cell over its own floor, so it + is a desk-set of exactly one part, seat lid over legs with the + floor showing between them. + - **The Pokemon Center's healing machine** -- two variants, 24 + placements, plus the Indigo Plateau lobby's pair. A wall-height + cabinet with its monitor perched on the front of its top face, + drawn across two map rows because it towers over the 16px band + behind it, which the volume path could only read as more wall. The + hoses leaving its side are modelled as hoses, at the elevation and + the depth the two stacked motifs put them; the west machine's + keyboard is a shelf at counter height wearing its own top-view art. + - **Bill's desk, and the Silph president's** -- the same drawing in + both rooms. Its terminal is drawn in 2:1 isometric, turned 45 + degrees to the map, and builds as a cube rather than the slab a 2:1 + reading gives; the kinked dark run between keyboard and computer is + raised to the keyboard's height and reads as the cable it is. The + desk stops at its own two cells because the artist drew its apron + into the walkable cell in front, sharing tiles with the chair + pushed up to it -- so the chair is modelled as a part of the desk. + - **The Bike Shop's open toolbox.** The drawing looks down INTO the + tray, which is why every solid treatment failed it -- as a + `billboard` the whole cell went up as one 10-voxel slab wearing the + drawing as a decal. `tray` builds four walls, a floor and air + between them, with the lid standing open on its hinge. + + What the template language grew to carry them: `tray`; a `desk` band + that lays its top face flat as a lid; the `box`, `flat` and `iso` + part kinds; `stretch` for a band mapped over a deeper plot than it + was drawn on; `inset` for a pane sunk by hand; `panes = false` where + the global recess pass has the polarity backwards; a `wall` element + so a template can keep the band behind it solid; `plane` for a height + the drawing states elsewhere; and `scrub`/`keep`/`support`, which let + a template model a surface while leaving an object standing on it to + its own standee -- Red's potted plant on the dining table. + +- **Round bins: the `can` class.** Vermilion Gym's switch puzzle stands + fifteen galvanised trash cans in a row, and the S.S. Anne redraws the + same object pixel for pixel as its galley barrels. Left to the thin + standee pool they were flat discs on edge -- fifteen coins standing + in a row; pinned a plain `cylinder` the drawing's base arc revolves + too and they came out as barrels balanced on a three-voxel stem. + `can` is the round hull cut at both ends, hollowed and tapered: the + drawn mouth ellipse projects across the top and down the well so you + look into the bin, the drawn base ellipse is ground contact rather + than body, and the plan narrows toward the floor. The two ellipses + are measured off the pixels; the height, the well and the taper are + authored, and the entry says why. + +- **The rock gyms' boulders are round.** 87 placements over Pewter's + walls and maze and Bruno's clusters, and every one of them was a + square bar wearing a boulder texture in relief -- the repeat-aware + scenery path extruding the whole drawing as one course. Each cell is + now a hull whose plan is its own drawn width profile turned in depth: + a dome full-width from the drawn shoulder down, tapering over the top + five rows exactly where the art tapers, with the floor's corner + diamonds opening between them the way the drawing has them. Still + 16px, so nothing standing on or beside a rock moves. + +- **The potted plant stands as a plant.** The most repeated interior + prop in the game -- 78 placements over 13 maps, six per Pokemon + Center -- and its urn was rendering as a hollow black frame, because + the drawing's foot lies flush on the block's bottom edge and the + background vote took the plant's own darks away with the floor. Named + outright as light and white instead, it stands as one organic + silhouette 32px tall over its two stacked cells, crown overhanging + the stem. `planter` carries the same reading for a round drawing + stacked two cells high on one cell of plot. + +- **Bicycles, in both places the Bike Shop draws them.** The six on the + showroom floor get their own pool at two voxels rather than the thin + pool's five: a bike is a line drawing, and at five voxels every + stroke closes the gap to its neighbour with its own side faces, so + from any angle but dead-on the air inside the frames filled in and + the six came out as one dark lump. And the two against the north wall + get `mounted`, a new authored-mask escape for a thing drawn INTO a + wall band: it holds the wall's plane as a thin per-pixel slab instead + of standing up as a sprite card, and it keeps its drawn elevation, so + a bicycle hung clear of the floor stays hung. Its mask is measured + rather than hand-drawn -- the plain panel tile composited across the + same grid and the background flooded in through the pixels that still + match it, which separates bicycle from stripe exactly. + +- **The Marts' cash register is a machine, not a decal.** An authored + figure may now state a `depth`, which makes it an object rather than + a person: a per-pixel solid standing on the counter instead of the + flat card that turned edge-on with the camera. And the drawing is not + a box -- its black linework packs two facings, an L of base and arm + around a keypad that is the machine's deck seen from above. `flat` + lays that rect horizontal in the notch of the L, and `thin` gives the + receipt curl a paper's thickness where the body's would have made it + a wedge. + +- **Shelf fronts have relief.** Everything the `bookcase` collapse is + used for is a shelf, a rack or a display case, and all of them seal + their contents behind the drawing's own black frame -- so those + regions now sink a voxel, the same rule a facade's window panes are + recessed by, and the books stand in the shelf instead of being + painted on it. A tileset that borrows the collapse for something that + is not a shelf says `bookcase_relief = false`: the League's masonry + and pilasters, whose courses are the wall itself, and Bill's + transporter drums, whose light regions are a lit barrel. + +### Changed + +- **Class heights now follow the models under them.** A tileset's + `heights` gets stools at 5 and tables at 6 in the houses, Bill's desk + at 8, and cans at 9 -- each of them the drawn elevation the new + template or hull stands at, so whoever sits on a stool sits on the + seat, and whatever object sprite stands on a table lands on the + modelled top rather than three voxels over it or under it. +- The healing machines' two flanks leave the `wall` pin for the thin + standee pool. They are equipment standing beside the console -- a + pair of pipes and a keyboard -- and as wall each was boxed into a + solid 16px half-cell wearing its drawing in relief. + +### Fixed + +- **Water no longer hides behind water.** The reflective pass writes no + depth -- the depth canvas is detached for the length of it so the + shader can read it -- so nothing put a lake in the buffer and no lake + could occlude another; the sheets were simply painted in mesh order. + Flat water never showed it, one plane, a farther sheet always landing + farther down the screen. The world curve ends that: it drops the far + side of the map into the near field of view, and a sea a hundred and + fifty tiles away came out rasterised on top of the pond at the + player's feet, tall grass and all -- water and terrain "from the + other side of the map", not reflected but there. The water meshes now + go down flat first, through the ordinary scene shader with depth + writes on, and the reflective pass draws over what survived. The + buffer holds the surface, so the pass's own test throws the far sheet + away; the reflection copy holds it too, so a ray grazing another part + of the lake reads water rather than the void behind it; and a frame + that cannot run the pass at all is unchanged, because the flat draw + is the fallback that was already there. +- **Reflections under the world curve.** The bend tips the world away + and the things standing on it do not lean with it -- and a lake is + one of those things. Reflected off the bowl the bend makes, the far + half of a pond was a mirror tilted twenty degrees: it threw the ray + past the vertical, where the sky ramp's own measure swings from one + end to the other across a single column, and hard-edged patches of + the wrong sky stamped into the water; the same tilt sent the + screen-space march grazing along the bank rather than over it, which + is what smeared the dock and the roofs across the harbour. What the + water reflects is now worked out in the flat world, exactly as it + would be with the curve off, and every marched sample is bent on its + way to the screen by the vertex stage's own displacement -- so the + ray is straight where it should be and lands where the geometry did. + The wave columns are read on the flat sheet too: the relief walk is + built on an even slab over a level plane, and in the curved world + that slab is a bowl, which handed back a column a pixel or three off + per fragment -- a patch of noise in the middle of a pond. +- **Merged runs tore open under the curve.** A quad's interior is the + chord of a parabola its neighbours draw the arc of, so a long run + hangs below the short quads butted against it. Nothing bounded a + run's length, and the ones that ran away were those wearing a + constant texel -- a roof's black eave outline, its fascia, its shaded + underside -- because a flat run has no art to break it. At 102px + across a gym the eave tore off the roof and the slot showed the + building's dark interior through it. Runs now stop at the next 8px + lattice line, which is the lattice buildings are stamped on and the + one every other quad in the scene already ends on, so every join is + vertex-for-vertex and the bend carries them together. It costs quads + whether the curve is on or not -- Cerulean's object stream goes from + 35.7k to 41.6k -- and that is deliberate: the mesh is cached per map + and built over seconds, so meshing for the curve's sake only when the + curve is on would mean rebuilding every live map on a keypress. + ## 1.4.1 ### Added diff --git a/data/voxel_heights.lua b/data/voxel_heights.lua index a98c059..55c075c 100644 --- a/data/voxel_heights.lua +++ b/data/voxel_heights.lua @@ -4198,6 +4198,60 @@ return { roofRows = 28, roofBack = 24, roofFront = 0, roofCycle = { 2, 23 }, slab = 3, frontEave = 0, ledge = nil, }, + -- F10: the town house's BOOKCASE -- the commonest piece of + -- furniture in the game at 58 placements across three drawings, + -- and the piece this family was really for: pinned `desk` it was a + -- 24px box with a flat front and the books PAINTED on it. Band for + -- band it is the Celadon display cabinet (F05/F06 under MANSION) + -- on a different atlas, and it takes those numbers unchanged: rows + -- 0-8 the top seen from above (black rim, white highlight courses + -- along the north and west, grey field, the front corner shaded at + -- row 8); row 9 the top's own black front edge -- which is what the + -- rim treatment paints, so slab = 1 and that row folds into the + -- roof band instead of extruding under it; 10-31 the front: two + -- shelves in their dark openings over the cupboard's two panelled + -- doors. Every book, bowl and door panel is a non-black region the + -- drawing seals behind its own black frame, so the measured pane + -- pass sinks each one a voxel and the frames stand proud of them -- + -- the relief the `bookcase` class now carries too, arrived at the + -- same way. + -- + -- Nine drawn top rows over a 32px plot: the rims map 1:1 (the drawn + -- front-corner shading lands one voxel behind the front edge) and + -- the uniform field cycles between. BOTH cell rows of the plot are + -- blocked and both are cabinet drawing, so D is the whole grid -- + -- the case is built into the room's north wall. 23 voxels tall, + -- one under the `desk` pin it replaces and exactly the Celadon + -- trophy case's stand; the `desk` pins stay as the degradation + -- path, neutralized wherever this stamps. + -- + -- This drawing carries books and a bowl on each shelf: 36 + -- placements, the west end of eighteen homes. + { + id = "house_bookcase_bowls", + tiles = { + { 38, 41 }, + { 14, 15 }, + { 14, 15 }, + { 30, 31 }, + }, + roofRows = 10, roofBack = 8, roofFront = 2, roofCycle = { 2, 7 }, + slab = 1, frontEave = 0, ledge = nil, + }, + -- F10 again: its twin at the east end, books on both shelves -- 22 + -- placements, and the only one of the two that Cerulean's trashed + -- house also puts at the west end. + { + id = "house_bookcase_books", + tiles = { + { 38, 41 }, + { 48, 49 }, + { 48, 49 }, + { 30, 31 }, + }, + roofRows = 10, roofBack = 8, roofFront = 2, roofCycle = { 2, 7 }, + slab = 1, frontEave = 0, ledge = nil, + }, -- F09: the stool at every one of those tables (94 placements on -- this tileset) -- the first template with NO base piece. The -- drawing is one object, a round seat on legs, drawn MID-CELL over @@ -4268,6 +4322,22 @@ return { keep = { 40, 54, 55, 56, 57 }, support = 6, }, + -- F10 again: Red's and the Copycat's bookcase pair, cells (0,0) + -- and (1,0) of both maps (4 placements). The same object as the + -- town house's, one shelf of each of its two drawings -- 48/49 + -- books above, 34/35 below -- and the same band numbers; see HOUSE + -- above for the read. + { + id = "reds_bookcase", + tiles = { + { 38, 41 }, + { 48, 49 }, + { 34, 35 }, + { 50, 51 }, + }, + roofRows = 10, roofBack = 8, roofFront = 2, roofCycle = { 2, 7 }, + slab = 1, frontEave = 0, ledge = nil, + }, -- F09 again: the stools around it (10 placements across Red's and -- the Copycat's ground floors), pixel for pixel the house -- tileset's drawing. Same part table; see HOUSE above. diff --git a/main.lua b/main.lua index 01aaf0f..f0b4883 100644 --- a/main.lua +++ b/main.lua @@ -890,7 +890,7 @@ mod.hooks:wrap("world.tod", function(next, tod, ctx) return DayNight.tod() end) -mod.exports.version = "1.4.1" +mod.exports.version = "1.4.3" -- exposed so a companion mod can pin its own tiles' shapes or read the -- camera without reaching into this mod's file layout mod.exports.lib = V diff --git a/manifest.json b/manifest.json index b2382d4..1f6184c 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "DRAMATIC_SHAPE", "name": "Dramatic Shape Voxel Mod", - "version": "1.4.1", + "version": "1.4.3", "api": 2, "entry": "main.lua", "profile": "content", diff --git a/tests/bike_shop_shots.lua b/tests/bike_shop_shots.lua new file mode 100644 index 0000000..7a896ed --- /dev/null +++ b/tests/bike_shop_shots.lua @@ -0,0 +1,79 @@ +-- Scratch driver: shots of the Bike Shop showroom, for the bicycle +-- voxelization. Two viewpoints -- the north wall (the two bikes drawn +-- INTO the wall band) and the showroom floor (the six standing bikes). +-- +-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/bike_shop_shots.lua \ +-- SHOT_DIR=.scratchpad/bikes AB_TAG=before lovec.exe . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pipelines = require("src.render.Pipelines") + + local ROOT = (os.getenv("SHOT_DIR") or "shots/bikes") + .. "/" .. (os.getenv("AB_TAG") or "before") + + local handle = game.mods.exports["DRAMATIC_SHAPE"] + if not (handle and handle.lib) then + print("[bike] DRAMATIC_SHAPE mod not loaded") + return + end + local V = handle.lib + local DayNight = V.require("DayNight") + local ChunkMesher = V.require("ChunkMesher") + local Voxel = V.require("VoxelState") + + require("src.world.OverworldController").rollEncounter = function() return nil end + local TileRenderer = require("src.render.TileRenderer") + TileRenderer.tick = function() end + TileRenderer.animFrame = function() return 0 end + DayNight.setting:sync("day") + + pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null') + pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul') + + local Zoom = require("src.render.Zoom") + pcall(function() + game.save.options.zoom = 1 + Zoom.applyOptions(game.save.options) + end) + + local function settle() + for _ = 1, 900 do + if ChunkMesher.pending() == 0 then break end + U.wait(1) + end + for _ = 1, 300 do + if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end + U.wait(1) + end + U.wait(40) + end + + -- cells: wall bikes ride cell row 0 (tile cols 1-3 and 6-8); the six + -- floor bikes stand in cell columns 0 and 2, rows 1-2 and 4-5 + local SCENES = { + { x = 2, y = 2, face = "up", label = "wall" }, + { x = 3, y = 3, face = "up", label = "room" }, + { x = 2, y = 4, face = "left", label = "floor" }, + { x = 3, y = 6, face = "up", label = "wide" }, + -- the two toolboxes, cells (6,6) and (7,7) + { x = 5, y = 6, face = "right", label = "tools" }, + { x = 6, y = 4, face = "down", label = "tools2" }, + } + + local shots = 0 + for _, s in ipairs(SCENES) do + for _, rung in ipairs({ 3, 5 }) do + U.teleport(game, "BIKE_SHOP", s.x, s.y, s.face) + Pipelines.setLevel("voxel", rung) + Pipelines.setLevel("tiltshift", 0) + settle() + local path = ("%s/%s_v%d.png"):format(ROOT, s.label, rung) + game.capturePath = path + U.wait(6) + local f = io.open(path, "rb") + if f then f:close() shots = shots + 1 + else print("[bike] capture missed: " .. path) end + end + end + print(("[bike] %d shots into %s"):format(shots, ROOT)) +end diff --git a/tests/bills_desk_shots.lua b/tests/bills_desk_shots.lua new file mode 100644 index 0000000..f6c7898 --- /dev/null +++ b/tests/bills_desk_shots.lua @@ -0,0 +1,77 @@ +-- Scratch driver: shots of Bill's desk, for the `bills_desk` +-- voxelization. The desk fills cells (1,4) and (2,4) of Bill's house +-- with its chair in the walkable cell (1,5) below it, so these are the +-- angles you can actually stand at: head-on from the floor two cells +-- south, and from either flank. +-- +-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/bills_desk_shots.lua \ +-- SHOT_DIR=.scratchpad/billsdesk AB_TAG=after lovec.exe . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pipelines = require("src.render.Pipelines") + + local ROOT = (os.getenv("SHOT_DIR") or "shots/billsdesk") + .. "/" .. (os.getenv("AB_TAG") or "after") + + local handle = game.mods.exports["DRAMATIC_SHAPE"] + if not (handle and handle.lib) then + print("[bills] DRAMATIC_SHAPE mod not loaded") + return + end + local V = handle.lib + local DayNight = V.require("DayNight") + local ChunkMesher = V.require("ChunkMesher") + local Voxel = V.require("VoxelState") + + require("src.world.OverworldController").rollEncounter = function() return nil end + local TileRenderer = require("src.render.TileRenderer") + TileRenderer.tick = function() end + TileRenderer.animFrame = function() return 0 end + DayNight.setting:sync("day") + + pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null') + pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul') + + local Zoom = require("src.render.Zoom") + pcall(function() + game.save.options.zoom = 1 + Zoom.applyOptions(game.save.options) + end) + + local function settle() + for _ = 1, 900 do + if ChunkMesher.pending() == 0 then break end + U.wait(1) + end + for _ = 1, 300 do + if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end + U.wait(1) + end + U.wait(40) + end + + local SCENES = { + { x = 1, y = 6, face = "up", label = "headon" }, + { x = 2, y = 6, face = "up", label = "headon_e" }, + { x = 4, y = 5, face = "left", label = "east" }, + { x = 0, y = 5, face = "right", label = "west" }, + } + + local shots = 0 + for _, s in ipairs(SCENES) do + for _, rung in ipairs({ 3, 5 }) do + U.teleport(game, "BILLS_HOUSE", s.x, s.y, s.face) + Pipelines.setLevel("voxel", rung) + Pipelines.setLevel("tiltshift", 0) + settle() + local path = ("%s/%s_v%d.png"):format(ROOT, s.label, rung) + game.capturePath = path + U.wait(6) + local f = io.open(path, "rb") + if f then f:close() shots = shots + 1 + else print("[bills] capture missed: " .. path) end + end + end + print(("[bills] %d shots into %s"):format(shots, ROOT)) + love.event.quit() +end diff --git a/tests/chief_house_shots.lua b/tests/chief_house_shots.lua new file mode 100644 index 0000000..2fec3a8 --- /dev/null +++ b/tests/chief_house_shots.lua @@ -0,0 +1,81 @@ +-- Scratch driver: shots of the Celadon chief's house, for the display +-- cabinet and long table voxelizations. Three viewpoints -- the +-- cabinet rank along the north wall, the long table in the middle of +-- the room, and a wide shot with both in frame. +-- +-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/chief_house_shots.lua \ +-- SHOT_DIR=.scratchpad/chief AB_TAG=after lovec.exe . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pipelines = require("src.render.Pipelines") + + local ROOT = (os.getenv("SHOT_DIR") or "shots/chief") + .. "/" .. (os.getenv("AB_TAG") or "after") + + local handle = game.mods.exports["DRAMATIC_SHAPE"] + if not (handle and handle.lib) then + print("[chief] DRAMATIC_SHAPE mod not loaded") + return + end + local V = handle.lib + local DayNight = V.require("DayNight") + local ChunkMesher = V.require("ChunkMesher") + local Voxel = V.require("VoxelState") + + require("src.world.OverworldController").rollEncounter = function() return nil end + local TileRenderer = require("src.render.TileRenderer") + TileRenderer.tick = function() end + TileRenderer.animFrame = function() return 0 end + DayNight.setting:sync("day") + + pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null') + pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul') + + local Zoom = require("src.render.Zoom") + pcall(function() + game.save.options.zoom = 1 + Zoom.applyOptions(game.save.options) + end) + + local function settle() + for _ = 1, 900 do + if ChunkMesher.pending() == 0 then break end + U.wait(1) + end + for _ = 1, 300 do + if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end + U.wait(1) + end + U.wait(40) + end + + -- the cabinets occupy cells 2..5 of rows 0-1; the long table cells + -- 2..5 of rows 3-4; the player walks rows 2 and 5 + local SCENES = { + { x = 3, y = 2, face = "up", label = "cabinets" }, + { x = 5, y = 2, face = "up", label = "bookcase" }, + { x = 3, y = 5, face = "up", label = "table" }, + { x = 1, y = 5, face = "right", label = "wide" }, + -- the same rank on CELADON_MANSION_1F, where it stands against the + -- interior partition and the grids start on an ODD tile row + { map = "CELADON_MANSION_1F", x = 2, y = 4, face = "up", + label = "mansion1f" }, + } + + local shots = 0 + for _, s in ipairs(SCENES) do + for _, rung in ipairs({ 3, 5 }) do + U.teleport(game, s.map or "CELADON_CHIEF_HOUSE", s.x, s.y, s.face) + Pipelines.setLevel("voxel", rung) + Pipelines.setLevel("tiltshift", 0) + settle() + local path = ("%s/%s_v%d.png"):format(ROOT, s.label, rung) + game.capturePath = path + U.wait(6) + local f = io.open(path, "rb") + if f then f:close() shots = shots + 1 + else print("[chief] capture missed: " .. path) end + end + end + print(("[chief] %d shots into %s"):format(shots, ROOT)) +end diff --git a/tests/heal_machine_shots.lua b/tests/heal_machine_shots.lua new file mode 100644 index 0000000..7f148cd --- /dev/null +++ b/tests/heal_machine_shots.lua @@ -0,0 +1,78 @@ +-- Scratch driver: shots of the Pokemon Center healing machines behind +-- the counter, for the center_heal_machine voxelization. The pair +-- stands at cells (1,0):(2,1) and (6,0):(7,1) of every Center; the +-- nurse aisle (row 2) is the row you can actually face them from, and +-- the public floor south of the counter gives the wide view. +-- +-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/heal_machine_shots.lua \ +-- SHOT_DIR=.scratchpad/healshots AB_TAG=after lovec.exe . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pipelines = require("src.render.Pipelines") + + local ROOT = (os.getenv("SHOT_DIR") or "shots/healmachine") + .. "/" .. (os.getenv("AB_TAG") or "after") + + local handle = game.mods.exports["DRAMATIC_SHAPE"] + if not (handle and handle.lib) then + print("[heal] DRAMATIC_SHAPE mod not loaded") + return + end + local V = handle.lib + local DayNight = V.require("DayNight") + local ChunkMesher = V.require("ChunkMesher") + local Voxel = V.require("VoxelState") + + require("src.world.OverworldController").rollEncounter = function() return nil end + local TileRenderer = require("src.render.TileRenderer") + TileRenderer.tick = function() end + TileRenderer.animFrame = function() return 0 end + DayNight.setting:sync("day") + + pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null') + pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul') + + local Zoom = require("src.render.Zoom") + pcall(function() + game.save.options.zoom = 1 + Zoom.applyOptions(game.save.options) + end) + + local function settle() + for _ = 1, 900 do + if ChunkMesher.pending() == 0 then break end + U.wait(1) + end + for _ = 1, 300 do + if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end + U.wait(1) + end + U.wait(40) + end + + local SCENES = { + { x = 1, y = 2, face = "up", label = "west_head" }, + { x = 2, y = 2, face = "up", label = "west_keyboard" }, + { x = 3, y = 2, face = "left", label = "west_side" }, + { x = 6, y = 2, face = "up", label = "east_head" }, + { x = 3, y = 4, face = "up", label = "wide" }, + } + + local shots = 0 + for _, s in ipairs(SCENES) do + for _, rung in ipairs({ 3, 5 }) do + U.teleport(game, "VIRIDIAN_POKECENTER", s.x, s.y, s.face) + Pipelines.setLevel("voxel", rung) + Pipelines.setLevel("tiltshift", 0) + settle() + local path = ("%s/%s_v%d.png"):format(ROOT, s.label, rung) + game.capturePath = path + U.wait(6) + local f = io.open(path, "rb") + if f then f:close() shots = shots + 1 + else print("[heal] capture missed: " .. path) end + end + end + print(("[heal] %d shots into %s"):format(shots, ROOT)) + love.event.quit() +end diff --git a/tests/house_furniture_shots.lua b/tests/house_furniture_shots.lua new file mode 100644 index 0000000..4a3a136 --- /dev/null +++ b/tests/house_furniture_shots.lua @@ -0,0 +1,98 @@ +-- Scratch driver: shots of the house dining tables and stools, for the +-- band-table + no-desk-part voxelization. Every generic home places the +-- table at cells (3,3):(4,4) with four stools around it (Blue's house has +-- Daisy seated at hers); Red's and the Copycat's ground floors place the +-- same furniture one cell lower, with the potted plant CUTOUT standing on +-- the tabletop -- the standee the table template must support, not +-- swallow. +-- +-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/house_furniture_shots.lua \ +-- SHOT_DIR=.scratchpad/housefurn AB_TAG=after lovec.exe . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pipelines = require("src.render.Pipelines") + + local ROOT = (os.getenv("SHOT_DIR") or "shots/housefurn") + .. "/" .. (os.getenv("AB_TAG") or "after") + + local handle = game.mods.exports["DRAMATIC_SHAPE"] + if not (handle and handle.lib) then + print("[housefurn] DRAMATIC_SHAPE mod not loaded") + return + end + local V = handle.lib + local DayNight = V.require("DayNight") + local ChunkMesher = V.require("ChunkMesher") + local Voxel = V.require("VoxelState") + + require("src.world.OverworldController").rollEncounter = function() return nil end + local TileRenderer = require("src.render.TileRenderer") + TileRenderer.tick = function() end + TileRenderer.animFrame = function() return 0 end + DayNight.setting:sync("day") + + pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null') + pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul') + + local Zoom = require("src.render.Zoom") + pcall(function() + game.save.options.zoom = 1 + Zoom.applyOptions(game.save.options) + end) + + local function settle() + for _ = 1, 900 do + if ChunkMesher.pending() == 0 then break end + U.wait(1) + end + for _ = 1, 300 do + if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end + U.wait(1) + end + U.wait(40) + end + + local SCENES = { + -- head on: below Blue's table looking north over a stool at it, + -- Daisy seated at the left one + { map = "BLUES_HOUSE", x = 3, y = 5, face = "up", label = "blues_front" }, + -- from the east, table and both east stools in profile + { map = "BLUES_HOUSE", x = 6, y = 3, face = "left", label = "blues_side" }, + -- from the north wall looking south down over the tabletop + { map = "BLUES_HOUSE", x = 4, y = 2, face = "down", label = "blues_over" }, + -- close beside a stool: seat top, legs and the gap between them + { map = "BLUES_HOUSE", x = 2, y = 5, face = "up", label = "stool_close" }, + -- Red's table head on from the south: the plant cutout standing on + -- the modelled tabletop + { map = "REDS_HOUSE_1F", x = 4, y = 6, face = "up", label = "reds_front" }, + -- and from the east along the stool row, plant in profile + { map = "REDS_HOUSE_1F", x = 6, y = 4, face = "left", label = "reds_side" }, + -- the Fan Club's four members' chairs round the boardroom table: + -- from the south of the west pair, both stools stacked in profile + { map = "POKEMON_FAN_CLUB", x = 1, y = 5, face = "up", + label = "club_west_pair" }, + -- across the table from the west, both pairs and the octagon between + { map = "POKEMON_FAN_CLUB", x = 0, y = 3, face = "right", + label = "club_across" }, + -- close on the east pair from the north, looking down over the seats + { map = "POKEMON_FAN_CLUB", x = 6, y = 2, face = "down", + label = "club_over" }, + } + + local shots = 0 + for _, s in ipairs(SCENES) do + for _, rung in ipairs({ 3, 5 }) do + U.teleport(game, s.map, s.x, s.y, s.face) + Pipelines.setLevel("voxel", rung) + Pipelines.setLevel("tiltshift", 0) + settle() + local path = ("%s/%s_v%d.png"):format(ROOT, s.label, rung) + game.capturePath = path + U.wait(6) + local f = io.open(path, "rb") + if f then f:close() shots = shots + 1 + else print("[housefurn] capture missed: " .. path) end + end + end + print(("[housefurn] %d shots into %s"):format(shots, ROOT)) +end diff --git a/tests/mart_shots.lua b/tests/mart_shots.lua new file mode 100644 index 0000000..428c4dc --- /dev/null +++ b/tests/mart_shots.lua @@ -0,0 +1,76 @@ +-- Scratch driver: shots of a Poke Mart's clerk counter, for the cash +-- register voxelization. The register is drawn at cell (1,5) of the 4x4 +-- shop layout every Mart shares, in the middle of the counter's east arm, +-- so these are the three angles you can actually stand at: head-on from +-- the aisle, side-on from the east, and over the counter's south arm. +-- +-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/mart_shots.lua \ +-- SHOT_DIR=.scratchpad/register AB_TAG=after lovec.exe . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pipelines = require("src.render.Pipelines") + + local ROOT = (os.getenv("SHOT_DIR") or "shots/register") + .. "/" .. (os.getenv("AB_TAG") or "after") + + local handle = game.mods.exports["DRAMATIC_SHAPE"] + if not (handle and handle.lib) then + print("[mart] DRAMATIC_SHAPE mod not loaded") + return + end + local V = handle.lib + local DayNight = V.require("DayNight") + local ChunkMesher = V.require("ChunkMesher") + local Voxel = V.require("VoxelState") + + require("src.world.OverworldController").rollEncounter = function() return nil end + local TileRenderer = require("src.render.TileRenderer") + TileRenderer.tick = function() end + TileRenderer.animFrame = function() return 0 end + DayNight.setting:sync("day") + + pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null') + pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul') + + local Zoom = require("src.render.Zoom") + pcall(function() + game.save.options.zoom = 1 + Zoom.applyOptions(game.save.options) + end) + + local function settle() + for _ = 1, 900 do + if ChunkMesher.pending() == 0 then break end + U.wait(1) + end + for _ = 1, 300 do + if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end + U.wait(1) + end + U.wait(40) + end + + local SCENES = { + { x = 1, y = 7, face = "up", label = "aisle" }, + { x = 2, y = 5, face = "left", label = "side" }, + { x = 2, y = 6, face = "left", label = "over" }, + } + + local shots = 0 + for _, s in ipairs(SCENES) do + for _, rung in ipairs({ 3, 5 }) do + U.teleport(game, "VIRIDIAN_MART", s.x, s.y, s.face) + Pipelines.setLevel("voxel", rung) + Pipelines.setLevel("tiltshift", 0) + settle() + local path = ("%s/%s_v%d.png"):format(ROOT, s.label, rung) + game.capturePath = path + U.wait(6) + local f = io.open(path, "rb") + if f then f:close() shots = shots + 1 + else print("[mart] capture missed: " .. path) end + end + end + print(("[mart] %d shots into %s"):format(shots, ROOT)) + love.event.quit() +end diff --git a/tests/potted_plant_shots.lua b/tests/potted_plant_shots.lua new file mode 100644 index 0000000..533bdba --- /dev/null +++ b/tests/potted_plant_shots.lua @@ -0,0 +1,91 @@ +-- Scratch driver: shots of the potted plants, for the plant standee +-- voxelization. Every Center places three side-by-side pairs on its +-- bottom row -- crowns at cells (0,6)/(1,6), (6,6)/(7,6), (12,6)/(13,6), +-- pots below at y=7 -- and INDIGO_PLATEAU_LOBBY (the MART tileset id, +-- same atlas) lines four of them along its hall at cells (12,10)..(15,10). +-- +-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/potted_plant_shots.lua \ +-- SHOT_DIR=.scratchpad/plants AB_TAG=after lovec.exe . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pipelines = require("src.render.Pipelines") + + local ROOT = (os.getenv("SHOT_DIR") or "shots/plants") + .. "/" .. (os.getenv("AB_TAG") or "after") + + local handle = game.mods.exports["DRAMATIC_SHAPE"] + if not (handle and handle.lib) then + print("[plant] DRAMATIC_SHAPE mod not loaded") + return + end + local V = handle.lib + local DayNight = V.require("DayNight") + local ChunkMesher = V.require("ChunkMesher") + local Voxel = V.require("VoxelState") + + require("src.world.OverworldController").rollEncounter = function() return nil end + local TileRenderer = require("src.render.TileRenderer") + TileRenderer.tick = function() end + TileRenderer.animFrame = function() return 0 end + DayNight.setting:sync("day") + + pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null') + pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul') + + local Zoom = require("src.render.Zoom") + pcall(function() + game.save.options.zoom = 1 + Zoom.applyOptions(game.save.options) + end) + + local function settle() + for _ = 1, 900 do + if ChunkMesher.pending() == 0 then break end + U.wait(1) + end + for _ = 1, 300 do + if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end + U.wait(1) + end + U.wait(40) + end + + local SCENES = { + -- east of the left pair, looking west along the bottom row: both + -- plants in profile, crown overhang and pot silhouette side-on + { map = "VIRIDIAN_POKECENTER", x = 3, y = 6, face = "left", + label = "pair_side" }, + -- north of the left pair, looking south down over the crowns + { map = "VIRIDIAN_POKECENTER", x = 1, y = 5, face = "down", + label = "pair_over" }, + -- head on: standing below the middle pair looking north at it + { map = "VIRIDIAN_POKECENTER", x = 6, y = 7, face = "up", + label = "pair_front" }, + -- close up beside the east pair's pot + { map = "VIRIDIAN_POKECENTER", x = 11, y = 7, face = "right", + label = "close" }, + -- the Plateau lobby's row of four (MART tileset id), along the row + { map = "INDIGO_PLATEAU_LOBBY", x = 11, y = 10, face = "right", + label = "lobby_row" }, + -- and head on from the hall below + { map = "INDIGO_PLATEAU_LOBBY", x = 13, y = 12, face = "up", + label = "lobby_front" }, + } + + local shots = 0 + for _, s in ipairs(SCENES) do + for _, rung in ipairs({ 3, 5 }) do + U.teleport(game, s.map, s.x, s.y, s.face) + Pipelines.setLevel("voxel", rung) + Pipelines.setLevel("tiltshift", 0) + settle() + local path = ("%s/%s_v%d.png"):format(ROOT, s.label, rung) + game.capturePath = path + U.wait(6) + local f = io.open(path, "rb") + if f then f:close() shots = shots + 1 + else print("[plant] capture missed: " .. path) end + end + end + print(("[plant] %d shots into %s"):format(shots, ROOT)) +end diff --git a/tests/roof_curve_shots.lua b/tests/roof_curve_shots.lua new file mode 100644 index 0000000..71c06b0 --- /dev/null +++ b/tests/roof_curve_shots.lua @@ -0,0 +1,80 @@ +-- Scratch driver: the Cerulean gym and the houses beside it, shot at +-- several camera rungs with V-CURVE walked OFF..3, to see what the world +-- bend does to a building's roof. +-- +-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/roof_curve_shots.lua \ +-- SHOT_DIR=.scratchpad/roofcurve AB_TAG=before lovec.exe . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pipelines = require("src.render.Pipelines") + + local ROOT = (os.getenv("SHOT_DIR") or "shots/roofcurve") + .. "/" .. (os.getenv("AB_TAG") or "after") + + local handle = game.mods.exports["DRAMATIC_SHAPE"] + if not (handle and handle.lib) then + print("[roof] DRAMATIC_SHAPE mod not loaded") + return + end + local V = handle.lib + local DayNight = V.require("DayNight") + local ChunkMesher = V.require("ChunkMesher") + local Voxel = V.require("VoxelState") + local WorldCurve = V.require("WorldCurve") + + require("src.world.OverworldController").rollEncounter = function() return nil end + local TileRenderer = require("src.render.TileRenderer") + TileRenderer.tick = function() end + TileRenderer.animFrame = function() return 0 end + DayNight.setting:sync("day") + + pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null') + pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul') + + local Zoom = require("src.render.Zoom") + pcall(function() + game.save.options.zoom = 1 + Zoom.applyOptions(game.save.options) + end) + + local function settle() + for _ = 1, 900 do + if ChunkMesher.pending() == 0 then break end + U.wait(1) + end + for _ = 1, 300 do + if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end + U.wait(1) + end + U.wait(30) + end + + -- the gym door is CERULEAN_CITY (30,19); the bike shop and the row of + -- houses along the west side give a second, smaller roof in frame + local SCENES = { + { map = "CERULEAN_CITY", x = 30, y = 20, face = "up", label = "gym" }, + { map = "CERULEAN_CITY", x = 27, y = 21, face = "up", label = "gymwide" }, + { map = "PALLET_TOWN", x = 5, y = 6, face = "up", label = "house" }, + } + + local shots = 0 + for _, s in ipairs(SCENES) do + for _, rung in ipairs({ 3, 5 }) do + for _, curve in ipairs({ 0, 3 }) do + U.teleport(game, s.map, s.x, s.y, s.face) + Pipelines.setLevel("voxel", rung) + Pipelines.setLevel("tiltshift", 0) + WorldCurve.setting:setIndex(curve + 1, game) + settle() + local path = ("%s/%s_v%d_c%d.png"):format(ROOT, s.label, rung, curve) + game.capturePath = path + U.wait(8) + local f = io.open(path, "rb") + if f then f:close() shots = shots + 1 + else print("[roof] capture missed: " .. path) end + end + end + end + print(("[roof] %d shots into %s"):format(shots, ROOT)) + love.event.quit() +end diff --git a/tests/roof_span_probe.lua b/tests/roof_span_probe.lua new file mode 100644 index 0000000..99e9563 --- /dev/null +++ b/tests/roof_span_probe.lua @@ -0,0 +1,47 @@ +-- Scratch probe: how long do a building model's merged quads get? +-- +-- A quad's longest world-space edge is what decides how far its CHORD +-- falls below the world curve's parabola, so this is the number that says +-- whether the bend can crack the mesh open. +-- +-- BUILD_MAP=CERULEAN_CITY POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/roof_span_probe.lua lovec . +return function(game) + local U = dofile("tests/drivers/util.lua") + local mapId = os.getenv("BUILD_MAP") or "CERULEAN_CITY" + U.teleport(game, mapId, tonumber(os.getenv("BUILD_X") or "30"), + tonumber(os.getenv("BUILD_Y") or "20"), "up") + U.wait(30) + + local V = game.mods.exports["DRAMATIC_SHAPE"] + V = V and V.lib + local Structures = V and V.require("Structures") + local ow = game.overworld + if not (Structures and ow and ow.map) then + print("[span] mod or map unavailable") + love.event.quit() + return + end + local S = Structures.forMap(ow.map) + local hist, worst = {}, 0 + for _, q in ipairs(S.objectQuads) do + local dx = math.max(q[1][1], q[2][1], q[3][1], q[4][1]) + - math.min(q[1][1], q[2][1], q[3][1], q[4][1]) + local dz = math.max(q[1][3], q[2][3], q[3][3], q[4][3]) + - math.min(q[1][3], q[2][3], q[3][3], q[4][3]) + local dy = math.max(q[1][2], q[2][2], q[3][2], q[4][2]) + - math.min(q[1][2], q[2][2], q[3][2], q[4][2]) + local span = math.max(dx, dz, dy) + local bucket = span <= 8 and "<=8" or (span <= 16 and "<=16" + or (span <= 32 and "<=32" or (span <= 64 and "<=64" or ">64"))) + bucket = bucket .. (q.own and " bld" or " prop") + hist[bucket] = (hist[bucket] or 0) + 1 + if span > worst then worst = span end + end + print(("[span] %d object quads, longest edge %d px"):format(#S.objectQuads, worst)) + for _, b in ipairs({ "<=8", "<=16", "<=32", "<=64", ">64" }) do + for _, kind in ipairs({ " bld", " prop" }) do + print(("[span] %-10s %d"):format(b .. kind, hist[b .. kind] or 0)) + end + end + love.event.quit() +end diff --git a/tests/round_regress_shots.lua b/tests/round_regress_shots.lua new file mode 100644 index 0000000..246b302 --- /dev/null +++ b/tests/round_regress_shots.lua @@ -0,0 +1,75 @@ +-- Scratch driver: one shot of every OTHER user of the round-hull builder +-- (tree canopies, boulders, hedges, stumps, the Center planter), to check +-- that the `can` class's base cut is the identity it is supposed to be for +-- everything that does not ask for it. +-- +-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/round_regress_shots.lua \ +-- SHOT_DIR=.scratchpad/round AB_TAG=after lovec.exe . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pipelines = require("src.render.Pipelines") + + local ROOT = (os.getenv("SHOT_DIR") or "shots/round") + .. "/" .. (os.getenv("AB_TAG") or "after") + + local handle = game.mods.exports["DRAMATIC_SHAPE"] + if not (handle and handle.lib) then return end + local V = handle.lib + local DayNight = V.require("DayNight") + local ChunkMesher = V.require("ChunkMesher") + local Voxel = V.require("VoxelState") + + require("src.world.OverworldController").rollEncounter = function() return nil end + local TileRenderer = require("src.render.TileRenderer") + TileRenderer.tick = function() end + TileRenderer.animFrame = function() return 0 end + DayNight.setting:sync("day") + + pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null') + pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul') + + local Zoom = require("src.render.Zoom") + pcall(function() + game.save.options.zoom = 1 + Zoom.applyOptions(game.save.options) + end) + + local function settle() + for _ = 1, 900 do + if ChunkMesher.pending() == 0 then break end + U.wait(1) + end + for _ = 1, 300 do + if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end + U.wait(1) + end + U.wait(40) + end + + local SCENES = { + { map = "VIRIDIAN_FOREST", x = 16, y = 20, face = "up", label = "forest" }, + { map = "PEWTER_GYM", x = 4, y = 10, face = "up", label = "boulders" }, + { map = "CELADON_GYM", x = 4, y = 8, face = "up", label = "hedges" }, + { map = "VIRIDIAN_POKECENTER", x = 6, y = 5, face = "up", label = "planter" }, + { map = "PALLET_TOWN", x = 5, y = 8, face = "up", label = "trees" }, + } + + local shots = 0 + for _, s in ipairs(SCENES) do + local ok = pcall(U.teleport, game, s.map, s.x, s.y, s.face) + if ok then + Pipelines.setLevel("voxel", 5) + Pipelines.setLevel("tiltshift", 0) + settle() + local path = ("%s/%s.png"):format(ROOT, s.label) + game.capturePath = path + U.wait(6) + local f = io.open(path, "rb") + if f then f:close() shots = shots + 1 + else print("[round] capture missed: " .. path) end + else + print("[round] teleport failed: " .. s.map) + end + end + print(("[round] %d shots into %s"):format(shots, ROOT)) +end diff --git a/tests/shelf_relief_shots.lua b/tests/shelf_relief_shots.lua new file mode 100644 index 0000000..633fe37 --- /dev/null +++ b/tests/shelf_relief_shots.lua @@ -0,0 +1,92 @@ +-- Scratch driver: shots of the `bookcase` class across the tilesets that +-- pin it, for the shelf-front relief. Two of them are NOT shelves -- +-- the League's gate walls and the terraces on PLATEAU -- and are here as +-- the control: their courses run edge to edge, so nothing should sink. +-- +-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/shelf_relief_shots.lua \ +-- SHOT_DIR=.scratchpad/shelves AB_TAG=before lovec.exe . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pipelines = require("src.render.Pipelines") + + local ROOT = (os.getenv("SHOT_DIR") or "shots/shelves") + .. "/" .. (os.getenv("AB_TAG") or "before") + + local handle = game.mods.exports["DRAMATIC_SHAPE"] + if not (handle and handle.lib) then + print("[shelf] DRAMATIC_SHAPE mod not loaded") + return + end + local V = handle.lib + local DayNight = V.require("DayNight") + local ChunkMesher = V.require("ChunkMesher") + local Voxel = V.require("VoxelState") + + require("src.world.OverworldController").rollEncounter = function() return nil end + local TileRenderer = require("src.render.TileRenderer") + TileRenderer.tick = function() end + TileRenderer.animFrame = function() return 0 end + DayNight.setting:sync("day") + + pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null') + pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul') + + local Zoom = require("src.render.Zoom") + pcall(function() + game.save.options.zoom = 1 + Zoom.applyOptions(game.save.options) + end) + + local function settle() + for _ = 1, 900 do + if ChunkMesher.pending() == 0 then break end + U.wait(1) + end + for _ = 1, 300 do + if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end + U.wait(1) + end + U.wait(40) + end + + local SCENES = { + { map = "OAKS_LAB", x = 7, y = 2, face = "up", label = "dojo_lab" }, + { map = "CELADON_MANSION_2F", x = 2, y = 4, face = "up", label = "mansion_2f" }, + { map = "CELADON_MART_2F", x = 5, y = 5, face = "up", label = "lobby_mart" }, + { map = "MUSEUM_1F", x = 2, y = 4, face = "up", label = "museum" }, + { map = "VIRIDIAN_MART", x = 3, y = 5, face = "up", label = "mart" }, + { map = "SS_ANNE_CAPTAINS_ROOM", x = 5, y = 2, face = "up", label = "ship" }, + -- the controls, both of them tilesets that borrow the collapse for + -- something that is NOT a shelf and say so with `bookcase_relief = + -- false`: the League's masonry gate walls, and Bill's transporter + -- drums. Nothing in either may move. + { map = "INDIGO_PLATEAU", x = 2, y = 5, face = "up", label = "plateau" }, + { map = "BILLS_HOUSE", x = 2, y = 3, face = "up", label = "bills" }, + -- the house shelves: pinned `desk`, NOT `bookcase`, so they go + -- through the world mesher's box fold and this relief never reaches + -- them. Here to show the gap. + { map = "REDS_HOUSE_1F", x = 1, y = 2, face = "up", label = "reds" }, + { map = "BLUES_HOUSE", x = 1, y = 2, face = "up", label = "blues" }, + } + + local shots = 0 + for _, s in ipairs(SCENES) do + local ok = pcall(U.teleport, game, s.map, s.x, s.y, s.face) + -- twice: the first load of the session has no mesh to settle against + pcall(U.teleport, game, s.map, s.x, s.y, s.face) + if ok then + Pipelines.setLevel("voxel", 5) + Pipelines.setLevel("tiltshift", 0) + settle() + local path = ("%s/%s.png"):format(ROOT, s.label) + game.capturePath = path + U.wait(6) + local f = io.open(path, "rb") + if f then f:close() shots = shots + 1 + else print("[shelf] capture missed: " .. path) end + else + print("[shelf] teleport failed: " .. s.map) + end + end + print(("[shelf] %d shots into %s"):format(shots, ROOT)) +end diff --git a/tests/ship_can_shots.lua b/tests/ship_can_shots.lua new file mode 100644 index 0000000..6eb535f --- /dev/null +++ b/tests/ship_can_shots.lua @@ -0,0 +1,78 @@ +-- Scratch driver: shots of the SS Anne's galley barrels, which are Lt. +-- Surge's trash can redrawn on the ship atlas. Three down the kitchen's +-- east wall at cells (13,5)/(13,7)/(13,9), one in the captain's room at +-- (4,1), and one each in the two ship-interior houses at (7,7). +-- +-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/ship_can_shots.lua \ +-- SHOT_DIR=.scratchpad/ssanne AB_TAG=after lovec.exe . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pipelines = require("src.render.Pipelines") + + local ROOT = (os.getenv("SHOT_DIR") or "shots/ssanne") + .. "/" .. (os.getenv("AB_TAG") or "after") + + local handle = game.mods.exports["DRAMATIC_SHAPE"] + if not (handle and handle.lib) then + print("[ship] DRAMATIC_SHAPE mod not loaded") + return + end + local V = handle.lib + local DayNight = V.require("DayNight") + local ChunkMesher = V.require("ChunkMesher") + local Voxel = V.require("VoxelState") + + require("src.world.OverworldController").rollEncounter = function() return nil end + local TileRenderer = require("src.render.TileRenderer") + TileRenderer.tick = function() end + TileRenderer.animFrame = function() return 0 end + DayNight.setting:sync("day") + + pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null') + pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul') + + local Zoom = require("src.render.Zoom") + pcall(function() + game.save.options.zoom = 1 + Zoom.applyOptions(game.save.options) + end) + + local function settle() + for _ = 1, 900 do + if ChunkMesher.pending() == 0 then break end + U.wait(1) + end + for _ = 1, 300 do + if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end + U.wait(1) + end + U.wait(40) + end + + local SCENES = { + { map = "SS_ANNE_KITCHEN", x = 12, y = 11, face = "up", label = "galley_up" }, + { map = "SS_ANNE_KITCHEN", x = 12, y = 3, face = "down", label = "galley_down" }, + { map = "SS_ANNE_KITCHEN", x = 11, y = 7, face = "right", label = "galley_side" }, + { map = "SS_ANNE_CAPTAINS_ROOM", x = 4, y = 4, face = "up", label = "captain" }, + { map = "CERULEAN_BADGE_HOUSE", x = 6, y = 7, face = "right", label = "house" }, + } + + local shots = 0 + for _, s in ipairs(SCENES) do + local ok = pcall(U.teleport, game, s.map, s.x, s.y, s.face) + if ok then + Pipelines.setLevel("voxel", 5) + Pipelines.setLevel("tiltshift", 0) + settle() + local path = ("%s/%s.png"):format(ROOT, s.label) + game.capturePath = path + U.wait(6) + local f = io.open(path, "rb") + if f then f:close() shots = shots + 1 + else print("[ship] capture missed: " .. path) end + else + print("[ship] teleport failed: " .. s.map) + end + end + print(("[ship] %d shots into %s"):format(shots, ROOT)) +end diff --git a/tests/trash_can_shots.lua b/tests/trash_can_shots.lua new file mode 100644 index 0000000..408165e --- /dev/null +++ b/tests/trash_can_shots.lua @@ -0,0 +1,80 @@ +-- Scratch driver: shots of Vermilion Gym's trash cans, for the trash can +-- voxelization. The fifteen cans stand on odd cell columns 1..9 in cell +-- rows 7, 9 and 11; the sixteenth is up at cell (6,1) beside the leader's +-- platform. Even columns are open floor, so the player can be parked +-- between two cans and look along a row. +-- +-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/trash_can_shots.lua \ +-- SHOT_DIR=.scratchpad/cans AB_TAG=before lovec.exe . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pipelines = require("src.render.Pipelines") + + local ROOT = (os.getenv("SHOT_DIR") or "shots/cans") + .. "/" .. (os.getenv("AB_TAG") or "before") + + local handle = game.mods.exports["DRAMATIC_SHAPE"] + if not (handle and handle.lib) then + print("[can] DRAMATIC_SHAPE mod not loaded") + return + end + local V = handle.lib + local DayNight = V.require("DayNight") + local ChunkMesher = V.require("ChunkMesher") + local Voxel = V.require("VoxelState") + + require("src.world.OverworldController").rollEncounter = function() return nil end + local TileRenderer = require("src.render.TileRenderer") + TileRenderer.tick = function() end + TileRenderer.animFrame = function() return 0 end + DayNight.setting:sync("day") + + pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null') + pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul') + + local Zoom = require("src.render.Zoom") + pcall(function() + game.save.options.zoom = 1 + Zoom.applyOptions(game.save.options) + end) + + local function settle() + for _ = 1, 900 do + if ChunkMesher.pending() == 0 then break end + U.wait(1) + end + for _ = 1, 300 do + if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end + U.wait(1) + end + U.wait(40) + end + + local SCENES = { + -- head on into the middle of the field, cans left, right and ahead + { x = 4, y = 12, face = "up", label = "field" }, + -- close up: standing between two cans of the bottom row + { x = 2, y = 11, face = "left", label = "close" }, + -- along the row, so the cans line up in depth + { x = 4, y = 13, face = "up", label = "row" }, + -- from the north, looking back down over all three rows + { x = 4, y = 6, face = "down", label = "over" }, + } + + local shots = 0 + for _, s in ipairs(SCENES) do + for _, rung in ipairs({ 3, 5 }) do + U.teleport(game, "VERMILION_GYM", s.x, s.y, s.face) + Pipelines.setLevel("voxel", rung) + Pipelines.setLevel("tiltshift", 0) + settle() + local path = ("%s/%s_v%d.png"):format(ROOT, s.label, rung) + game.capturePath = path + U.wait(6) + local f = io.open(path, "rb") + if f then f:close() shots = shots + 1 + else print("[can] capture missed: " .. path) end + end + end + print(("[can] %d shots into %s"):format(shots, ROOT)) +end diff --git a/tests/water_curve_shots.lua b/tests/water_curve_shots.lua new file mode 100644 index 0000000..26d8f1f --- /dev/null +++ b/tests/water_curve_shots.lua @@ -0,0 +1,121 @@ +-- Scratch driver: water shot with V-CURVE walked OFF..3, to see what the +-- world bend does to the reflective pass. +-- +-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/water_curve_shots.lua \ +-- SHOT_DIR=.scratchpad/watercurve AB_TAG=before lovec.exe . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pipelines = require("src.render.Pipelines") + + local ROOT = (os.getenv("SHOT_DIR") or "shots/watercurve") + .. "/" .. (os.getenv("AB_TAG") or "after") + + local handle = game.mods.exports["DRAMATIC_SHAPE"] + if not (handle and handle.lib) then + print("[water] DRAMATIC_SHAPE mod not loaded") + return + end + local V = handle.lib + local DayNight = V.require("DayNight") + local ChunkMesher = V.require("ChunkMesher") + local Voxel = V.require("VoxelState") + local WorldCurve = V.require("WorldCurve") + local Water = V.require("Water") + -- WATER_RUNG=sky drops the screen-space march and leaves the sky path, to + -- tell an artefact of the one from an artefact of the other + if os.getenv("WATER_RUNG") then Water.setting:sync(os.getenv("WATER_RUNG")) end + + require("src.world.OverworldController").rollEncounter = function() return nil end + local TileRenderer = require("src.render.TileRenderer") + TileRenderer.tick = function() end + TileRenderer.animFrame = function() return 0 end + DayNight.setting:sync(os.getenv("WATER_TIME") or "night") + + pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null') + pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul') + + local Zoom = require("src.render.Zoom") + pcall(function() + game.save.options.zoom = 1 + Zoom.applyOptions(game.save.options) + end) + + local function settle() + for _ = 1, 900 do + if ChunkMesher.pending() == 0 then break end + U.wait(1) + end + for _ = 1, 300 do + if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end + U.wait(1) + end + U.wait(40) + end + + -- Stand on the walkable cell just north of the widest run of water on the + -- map, so a scene is picked by where the water actually is rather than by + -- a coordinate guessed off the block list. + local TileShape = V.require("TileShape") + local function shore(map) + local def = map.def + local shapes = TileShape.forMap(map) + local function classAt(cx, cy) + local tx, ty = cx * 2, cy * 2 + local s = TileShape.at(map, shapes, map:tileAt(tx, ty), tx, ty) + return s and s.class + end + local best, bestRun = nil, 0 + for cy = 1, def.height * 2 - 1 do + local run, start = 0, nil + for cx = 0, def.width * 2 - 1 do + if classAt(cx, cy) == "water" then + start = start or cx + run = run + 1 + if run > bestRun and classAt(cx, cy - 1) ~= "water" then + bestRun, best = run, { x = start + math.floor(run / 2), y = cy - 1 } + end + else + run, start = 0, nil + end + end + end + return best + end + + local SCENES = { + { map = "PALLET_TOWN", label = "pallet" }, + { map = "CERULEAN_CITY", label = "cerulean" }, + { map = "VERMILION_CITY", x = 18, y = 27, label = "vermilion" }, + { map = "ROUTE_24", label = "route24" }, + { map = "VIRIDIAN_CITY", label = "viridian" }, + } + + local shots = 0 + for _, s in ipairs(SCENES) do + if not s.x then + -- teleport once so the map is loaded, then let the scan place us + U.teleport(game, s.map, 1, 1, "down") + U.wait(4) + local spot = game.overworld and game.overworld.map and shore(game.overworld.map) + if spot then s.x, s.y = spot.x, spot.y else s.x, s.y = 1, 1 end + print(("[water] %s shore at (%d,%d)"):format(s.label, s.x, s.y)) + end + for _, rung in ipairs({ 3, 4 }) do + for _, curve in ipairs({ 0, 3 }) do + U.teleport(game, s.map, s.x, s.y, s.face or "down") + Pipelines.setLevel("voxel", rung) + Pipelines.setLevel("tiltshift", 0) + WorldCurve.setting:setIndex(curve + 1, game) + settle() + local path = ("%s/%s_v%d_c%d.png"):format(ROOT, s.label, rung, curve) + game.capturePath = path + U.wait(8) + local f = io.open(path, "rb") + if f then f:close() shots = shots + 1 + else print("[water] capture missed: " .. path) end + end + end + end + print(("[water] %d shots into %s"):format(shots, ROOT)) + love.event.quit() +end diff --git a/tools/building_voxels.py b/tools/building_voxels.py index bcc9078..c8718c0 100644 --- a/tools/building_voxels.py +++ b/tools/building_voxels.py @@ -723,6 +723,65 @@ slab=3, front_eave=0, ledge=None, tileset="reds_house", scrub=[(10, 5, 21, 15)], ), + # F10: the town house's bookcase -- the commonest piece of furniture + # in the game at 58 placements across three drawings, and the reason + # this family exists: the `desk` box it used to be had a flat front + # with the books PAINTED on it. Band for band it is the Celadon + # cabinet (F05/F06) on a different atlas, and it takes those numbers + # unchanged: 0-8 the top seen from above (black rim, white highlight + # courses along the north and west, grey field, the front corner + # shaded at row 8), row 9 the top's own black front edge -- so + # slab = 1 and that row folds into the roof band -- and 10-31 the + # front: two shelves in their dark openings over the cupboard's two + # panelled doors. Every one of those is a non-black region the + # drawing seals behind its own black frame, so the measured pane + # pass sinks each a voxel and the frames stand proud of them. + # Nine drawn top rows over a 32px plot: the rims map 1:1 and the + # uniform field cycles between. BOTH cell rows of the plot are + # blocked and both are cabinet drawing, so D is the whole grid -- + # the case is built into the room's north wall. 23 voxels tall, + # one under the `desk` pin it replaces, and the same 23 the Celadon + # trophy case stands. + # + # This one carries books and a bowl on each shelf; 36 placements, + # the west end of eighteen homes. + "house_bookcase_bowls": dict( + tiles=[ + [38, 41], + [14, 15], + [14, 15], + [30, 31], + ], + roof_rows=10, roof_back=8, roof_front=2, roof_cycle=(2, 7), + slab=1, front_eave=0, ledge=None, tileset="house", + ), + # F10 again: its twin at the east end, books on both shelves -- + # 22 placements, and the only drawing of the two that the trashed + # house in Cerulean puts at the west end as well. + "house_bookcase_books": dict( + tiles=[ + [38, 41], + [48, 49], + [48, 49], + [30, 31], + ], + roof_rows=10, roof_back=8, roof_front=2, roof_cycle=(2, 7), + slab=1, front_eave=0, ledge=None, tileset="house", + ), + # F10 once more, on the reds_house atlas: Red's and the Copycat's + # ground-floor pair (4 placements). The same object, one shelf of + # each drawing -- 48/49 books above, 34/35 below -- and the same + # band numbers. + "reds_bookcase": dict( + tiles=[ + [38, 41], + [48, 49], + [34, 35], + [50, 51], + ], + roof_rows=10, roof_back=8, roof_front=2, roof_cycle=(2, 7), + slab=1, front_eave=0, ledge=None, tileset="reds_house", + ), # F09: the stool at every one of those tables -- the first template # with NO base piece. The drawing is one object, a round seat on # legs, drawn MID-CELL over its own floor (rows 0-4 are the room From e6d4059c383f8dc8534bb26856b7cb27bbb1e3a5 Mon Sep 17 00:00:00 2001 From: DramaticShape Date: Sun, 2 Aug 2026 00:53:38 -0400 Subject: [PATCH 021/116] fix water again on android --- lib/VoxelScene.lua | 63 ++++++++++++++++++++++------------- lib/VoxelState.lua | 27 +++++++++++++-- lib/Water.lua | 26 ++++++++++++--- tests/dramatic_shape_test.lua | 8 +++-- 4 files changed, 90 insertions(+), 34 deletions(-) diff --git a/lib/VoxelScene.lua b/lib/VoxelScene.lua index e06036e..529f367 100644 --- a/lib/VoxelScene.lua +++ b/lib/VoxelScene.lua @@ -583,7 +583,7 @@ end -- The overworld's alone: the staged battle draws its water plain, always -- -- its placed camera reads this pass wrong, and a stage set wants painted -- water anyway (see BattleScene, where the choice is argued). --- ------- and why the flat draw happens FIRST, every time +-- ------- and why the flat draw happens FIRST while the world is curved -- -- The reflective pass writes no depth -- it cannot, the depth canvas is -- detached for the length of it so the shader can READ it -- and it does its @@ -592,31 +592,39 @@ end -- one: WATER IN FRONT OF WATER. Nothing puts water in the depth buffer, so -- no lake can hide another, and the pass simply paints them in mesh order. -- --- On flat water that never mattered, because every surface lies in the one --- plane at its own recessed height and a farther sheet always lands farther --- down the screen. THE WORLD CURVE ENDS THAT. The bend drops the world by --- the square of its distance, so the far side of the map swings down and --- back up into the near field of view -- and a sheet of sea a hundred and --- fifty tiles away, drawn later in the same mesh, paints straight over the --- pond at the player's feet. That is the tall grass and the water "from the --- other side of the map": not a reflection of them, THEM, rasterised on top --- of the water in front of you. +-- On a flat world that never matters: every surface lies in the one plane +-- at its own recessed height, and a farther sheet always lands farther down +-- the screen. THE WORLD CURVE ENDS THAT. The bend drops the world by the +-- square of its distance, so the far side of the map swings down and back +-- up into the near field of view -- and a sheet of sea a hundred and fifty +-- tiles away, drawn later in the same mesh, paints straight over the pond +-- at the player's feet. Not a reflection of the far shore: the far shore +-- itself, rasterised on top of the water in front of you. -- --- So the meshes go down flat first, through the ordinary scene shader with --- depth writes on, and the reflective pass draws over the top of what --- survived. Three things fall out of it and all three are wanted: the depth --- buffer now holds the water surface, so the pass's own test throws the far --- sheet away; the reflection COPY holds it too, so a ray grazing another --- part of the lake reads water rather than the void behind it; and the flat --- draw is the same fallback this function already ended with, so a frame --- that cannot run the reflective pass is unchanged. +-- So WHILE THE CURVE IS ON, the meshes go down flat first, through the +-- ordinary scene shader with depth writes on, and the reflective pass draws +-- over the top of what survived: the depth buffer now holds the water +-- surface, so the pass's own test throws the far sheet away, and the +-- reflection COPY holds it too, so a ray grazing another part of the lake +-- reads water rather than the void behind it. -- --- It costs one more rasterisation of the water meshes. Water is a small --- share of a frame's triangles and this is the cheapest shader in the mode. +-- With the curve OFF the prepass is not just unnecessary, it is a LIABILITY, +-- and it stays off -- the reflective pass tests only against terrain, as it +-- always did. Painting the surface into the depth texture turns the pass's +-- test into a comparison of the surface against ITSELF, which asks the two +-- rasterisations to agree to within interpolation error -- and on mobile +-- GPUs they don't reliably (that fight is what put the Android port back on +-- flat water). Confined to the curve there is no regression to reach: the +-- flat world never had the far-shore bug in the first place. function VoxelScene.drawWater(draws, cast) - for _, d in ipairs(draws) do - Voxel3D.draw(d[1], d[2], d[3]) + -- prepass only under the bend; see the header + local curved = (Voxel3D.curveK or 0) > 0 + if curved then + for _, d in ipairs(draws) do + Voxel3D.draw(d[1], d[2], d[3]) + end end + local plain = not curved if Water.enabled() and Voxel3D.depthReadable() then local mirror, depth = Voxel3D.beginWater(cast) local w, h = Voxel3D.size() @@ -634,15 +642,22 @@ function VoxelScene.drawWater(draws, cast) Water.draw(d[1], d[2], d[3]) end Water.finish() + plain = false end -- Unconditionally, and OUTSIDE the success branch: beginWater unbinds -- the shader and the depth mode BEFORE it can discover it cannot go on, -- so a frame that bails halfway through has to be put back together -- exactly like one that succeeded -- otherwise every pass after it runs - -- with no shader and no depth test. (What a bail leaves on screen is the - -- flat draw above, which is where this function used to end anyway.) + -- with no shader and no depth test. Voxel3D.endWater() end + -- the fallback flat draw -- unless the curve's prepass already put the + -- same meshes down, in which case a bailed frame is already whole + if plain then + for _, d in ipairs(draws) do + Voxel3D.draw(d[1], d[2], d[3]) + end + end end -- A stamp of everything the sun pass depends on. Nothing in it moving diff --git a/lib/VoxelState.lua b/lib/VoxelState.lua index efad2ec..e19b314 100644 --- a/lib/VoxelState.lua +++ b/lib/VoxelState.lua @@ -32,8 +32,17 @@ local Voxel = {} -- Its ANGLE is 35 degrees, the same as the rung of that name. The duplicate -- in the table is deliberate: the ladder is a list of what each rung LOOKS -- like, and two rungs may look the same while meaning different things. -Voxel.ANGLES_DEG = { 0, 35, 15, 35, 50, 75 } -Voxel.ANGLE_LABELS = { "OFF", "FULL", "15", "35", "50", "75" } +-- +-- 1ST is the other rung that is more than an angle: the camera steps off its +-- orbit entirely and stands in the player's own eyes (lib/FirstPerson.lua), +-- with free look and free movement. Its ANGLE entry is 75 -- the orbit rung +-- it hands over from -- because the tween in and out of first person starts +-- from whatever the orbit shows, and the lowest rung is the one a dive into +-- a head should start from. Everything angle-derived (the sky's fade, the +-- billboard lean the blend eases away) reads that 75 while the first-person +-- rig owns the actual camera. +Voxel.ANGLES_DEG = { 0, 35, 15, 35, 50, 75, 75 } +Voxel.ANGLE_LABELS = { "OFF", "FULL", "15", "35", "50", "75", "1ST" } Voxel.MAX_LEVEL = #Voxel.ANGLES_DEG - 1 -- the rung FULL sits on, so nothing has to hunt for it by label @@ -43,6 +52,13 @@ function Voxel.isFull(level) return (level or Voxel.level) == Voxel.FULL_LEVEL end +-- the rung the first-person camera sits on, likewise +Voxel.FP_LEVEL = 6 + +function Voxel.isFirstPerson(level) + return (level or Voxel.level) == Voxel.FP_LEVEL +end + -- ------- what the hotkey walks -- -- The ANGLE rungs only, with FULL left out. The key is a display-mode @@ -51,7 +67,12 @@ end -- mid-walk, would silently turn the blur to maximum and flatten the horizon -- with no indication that a keypress had done so. FULL stays on the OPTIONS -- row, which is where a preset that changes other rows belongs. -Voxel.HOTKEY_ORDER = { 0, 2, 3, 4, 5 } -- OFF, 15, 35, 50, 75 +-- +-- 1ST is on the path: it changes the camera and only the camera, which is +-- exactly what the key promises -- and the key is also the way back OUT of +-- first person on a keyboard, where the mouse is captured and the OPTIONS +-- menu is a trip. +Voxel.HOTKEY_ORDER = { 0, 2, 3, 4, 5, 6 } -- OFF, 15, 35, 50, 75, 1ST -- The rung a press moves to from `level`. -- diff --git a/lib/Water.lua b/lib/Water.lua index 3bfcf4e..45ecd26 100644 --- a/lib/Water.lua +++ b/lib/Water.lua @@ -960,11 +960,29 @@ vec4 effect(mediump vec4 color, Image tex, mediump vec2 tc, mediump vec2 sc) { // The buffer now holds THIS SURFACE too (VoxelScene draws the water flat // before the pass that reflects it), which is what makes one lake able to // hide another -- and it means every fragment here is testing against its - // own depth. The two draws reach it through different shader programs, so - // ask for a hair of slack rather than bit-equality: far less than the gap - // to anything genuinely in front, far more than two compilers disagree by. + // own depth. That raises the bar on the fragment's own z: gl_FragCoord is + // allowed to be MEDIUMP on GLES (and is, on Adreno), and fp16 near the far + // end of the range steps by about half a thousandth -- which the old test + // against the terrain far behind the surface never felt, and a comparison + // of the surface against itself loses outright. Every fragment failed, the + // pass discarded the whole lake, and Android showed the flat draw + // underneath. So the depth is recomputed HERE, in highp, from the same + // vBent and vp the vertex stage used -- full precision on every driver. + // + // The slack is sized to what remains after that, which is not rounding: + // the buffer holds depth interpolated LINEARLY IN SCREEN SPACE, while the + // recomputation projects the perspective-interpolated vBent -- the exact + // answer. The two agree at the vertices and drift apart across a quad's + // interior, by more the bigger the quad stands on screen; on a phone + // (fit scale 6, water quads hundreds of pixels tall) the drift crosses + // 1e-5 mid-quad, which discarded the middle of every tile row and looked + // like flat water with reflective seams. Anything GENUINELY in front of a + // water pixel is whole world units nearer -- upward of 1e-3 in depth -- + // so 2e-4 clears the drift with room while still catching every occluder. vec2 uv = sc / love_ScreenSize.xy; - if (gl_FragCoord.z > Texel(depthTex, uv).r + 1e-5) discard; + vec4 selfC = vp * vec4(vBent, 1.0); + float selfZ = selfC.z / selfC.w * 0.5 + 0.5; + if (selfZ > Texel(depthTex, uv).r + 2e-4) discard; // THE COLUMN THIS FRAGMENT IS LOOKING AT. Every water pixel is a bar of // its own standing a whole number of pixels tall, and the ray decides diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua index a832e15..703763a 100644 --- a/tests/dramatic_shape_test.lua +++ b/tests/dramatic_shape_test.lua @@ -1924,9 +1924,11 @@ T.check(plain:find("sc / love_ScreenSize.xy", 1, true) ~= nil, -- the world curve drops the far side of the map into the near field of view, -- and a sea a hundred and fifty tiles away came out rasterised on top of the -- pond at the player's feet, tall grass and all. -T.check(plain:find("Texel(depthTex, uv).r + 1e-5", 1, true) ~= nil, - "with a hair of slack, because every water fragment is now testing " - .. "against its own depth through a second shader program") +T.check(plain:find("vec4 selfC = vp * vec4(vBent, 1.0)", 1, true) ~= nil + and plain:find("Texel(depthTex, uv).r + 2e-4", 1, true) ~= nil, + "testing a HIGHP recomputed depth (gl_FragCoord.z is mediump on mobile " + .. "GLES -- fp16 loses a self-comparison outright) with slack covering " + .. "the buffer's screen-linear interpolation drift across big quads") T.check(VoxelScene.drawWater ~= nil, "and the flat draw that puts it there") -- ------- the lift itself From 9ef8644bff1c7480643a3f940f61d97c7ce5c849 Mon Sep 17 00:00:00 2001 From: DramaticShape Date: Sun, 2 Aug 2026 01:16:42 -0400 Subject: [PATCH 022/116] add first person mode --- CHANGELOG.md | 52 +++ README.md | 25 +- lib/ChunkMesher.lua | 20 +- lib/FirstPerson.lua | 680 ++++++++++++++++++++++++++++++++++ lib/FreeMove.lua | 345 +++++++++++++++++ lib/Voxel3D.lua | 12 +- lib/VoxelScene.lua | 111 +++++- lib/VoxelState.lua | 3 +- main.lua | 48 ++- manifest.json | 2 +- mod.card | 7 +- tests/dramatic_shape_test.lua | 199 +++++++++- tests/fp_shots.lua | 159 ++++++++ 13 files changed, 1621 insertions(+), 42 deletions(-) create mode 100644 lib/FirstPerson.lua create mode 100644 lib/FreeMove.lua create mode 100644 tests/fp_shots.lua diff --git a/CHANGELOG.md b/CHANGELOG.md index e6f0ad3..3823961 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,57 @@ # Changelog +## 1.5.0 + +### Added + +- **1ST: a first-person camera, played like a modern one.** A seventh + rung on the VOXEL ladder (hotkey 3 walks it; the OPTIONS row carries + it). Stepping onto it dives the camera from wherever the orbit was + into the player's own head over half a second, and stepping off flies + it back out. The rig rides the same placed-camera seam the staged + battle proved out, so the sky's bands meet the horizon, the sun and + moon hang where their shadows say, and the water reflects at eye + level -- all through math that was already there. + + - **Free look.** Relative mouse motion (the cursor is captured while + the rung is on; left click is A, right click is B), the right + stick at a rate with a squared response curve, or a touch dragged + across any open screen -- the overlay's d-pad and buttons still + work, and a second finger can drag the view while the first + walks. Pitch clamps short of straight up and straight down. + + - **Free movement.** While 1ST drives, the grid walk is replaced by + a continuous, camera-relative one: push forward and you go where + you look, at any angle, sliding along whatever you graze. The left + stick's raw deflection, the touch d-pad's true vector, or the held + keys (forward / backpedal / strafe) all steer it. The grid is + still the game: the walk asks the engine's own collision the same + per-cell questions a grid step asks, the logical cell tracks the + body, and every cell crossed runs the engine's own landing + pipeline -- warps, encounters, spinners, gates, poison, repel, the + step counters. Walking off the map edge, into a ledge or into a + boulder hands the push to the engine's own handlers, so + connections cross, ledges hop and boulders shove exactly as + themselves. Speed is the grid walker's own (bike included), so + distance per second and encounters per tile are unchanged. + + - **Billboards seen from inside the world.** Character cards stop + leaning and start turning: upright, yawed about their feet to face + the eye, wearing the frame their pose shows *this* viewer -- walk + behind an NPC and you see their back, circle to a flank and you + get the profile, exactly the four frames Gen 1 drew. The authored + figures (the couch sitters) turn the same way, about their own + middle. The sun pass swaps frames in step, so a card never reads + its own shadow through a mirror-flipped record of itself. The + player's own card is left out of the camera draw -- the eye stands + in it -- but still casts its shadow on the ground ahead. + + - The shadow map's box follows the look (the orbit's fit reaches far + north and barely south, which is wrong for a head facing south); + the world curve is declined outright while the head owns the + camera; and the whole rung falls back to the 75-degree orbit on + hardware without the 3D pass. + ## 1.4.3 ### Added diff --git a/README.md b/README.md index 14e2cb8..2ec0b45 100644 --- a/README.md +++ b/README.md @@ -32,11 +32,26 @@ clear ground — shot over the shoulder, the player's mon low and left and the enemy high and right, with a slow parallax drift behind them and a depth-of-field pass that keeps both of them sharp. -Purely presentational. Nothing here reaches collision, movement, triggers -or scripts — it changes what the world *looks* like and nothing about what -it *is*. The battle arena is where the **camera** goes, not where anybody -goes: no cell, facing, flag or warp is written, so the player is standing -exactly where the fight found them when it ends. +And the whole thing from inside. The ladder's top rung, **1ST**, dives the +camera into the player's own head: free look on the mouse (captured while +the rung is on — left click is A, right click is B), the right stick, or a +touch dragged across open screen; free movement that goes where you look, +at any angle, sliding along walls — the left stick's raw deflection, the +touch d-pad's true vector, or WASD as forward/backpedal/strafe. NPCs turn +to face the eye wearing the frame their pose shows *this* viewer — walk +behind someone and you see their back — and the sky, the shadows and the +water reflections all carry over, because the head rides the same placed +camera the battle shot proved out. + +Presentational, with one deliberate exception. Every rung but 1ST changes +what the world *looks* like and nothing about what it *is*; the battle +arena is where the **camera** goes, not where anybody goes. 1ST replaces +the grid walk with a free one while it is selected — but even there the +game is untouched: the walk asks the engine's own collision the same +questions a grid step asks, keeps the player's cell synced, and runs the +engine's own landing pipeline per cell crossed, so warps, encounters, +ledges, gates and scripts all fire exactly as themselves. Step off the +rung and the grid walk is back. ## Controls diff --git a/lib/ChunkMesher.lua b/lib/ChunkMesher.lua index 73ed890..eb2dbb9 100644 --- a/lib/ChunkMesher.lua +++ b/lib/ChunkMesher.lua @@ -853,14 +853,24 @@ end -- character card (VoxelScene). A figure baked into the terrain mesh could -- not lean, and a shared mesh could not carry per-figure placement. -- --- A list, not a mesh: `{ mesh, wx, wz, y }` per figure. Maps have one or --- none, so the loop that draws them is shorter than the terrain's. +-- A list, not a mesh: `{ mesh, wx, wz, y, w }` per figure. Maps have one +-- or none, so the loop that draws them is shorter than the terrain's. +-- `w` is the card's own width in its local space (its quads start at +-- x = 0), measured here because the first-person pass yaws a card about +-- its middle -- a card yawed about its left edge swings off its seat. local function buildFigureMeshes(map) local out = {} for _, f in ipairs(Structures.forMap(map).figures or {}) do local mesh = quadsMesh(f.quads) if mesh then - out[#out + 1] = { mesh = mesh, wx = f.wx, wz = f.wz, y = f.y } + local w = 0 + for _, q in ipairs(f.quads) do + for c = 1, 4 do + local x = q[c] and q[c][1] + if x and x > w then w = x end + end + end + out[#out + 1] = { mesh = mesh, wx = f.wx, wz = f.wz, y = f.y, w = w } end end return out @@ -1132,8 +1142,8 @@ function ChunkMesher.flowers(map) return c and c.flowers or nil end --- Authored figures as `{ mesh, wx, wz, y }` records -- each placed by its --- own leaning matrix at draw time, so they cannot share one mesh. +-- Authored figures as `{ mesh, wx, wz, y, w }` records -- each placed by +-- its own leaning matrix at draw time, so they cannot share one mesh. function ChunkMesher.figures(map) local c = cache[map.id] local list = c and c.figures diff --git a/lib/FirstPerson.lua b/lib/FirstPerson.lua new file mode 100644 index 0000000..7722adb --- /dev/null +++ b/lib/FirstPerson.lua @@ -0,0 +1,680 @@ +-- Voxel world mode: the first-person camera -- the 1ST rung. +-- +-- Every other rung is the same camera at a different pitch: an orbit over +-- the view centre, described by one number. 1ST is a different rig +-- entirely: the eye stands in the player's own head, the view direction is +-- the player's to steer -- mouse, right stick or a touch drag -- and the +-- rig rides the placed-camera seam (Voxel3D.camera) that the staged battle +-- already proved out. Everything downstream of that seam -- the shader +-- uniforms, project(), the sky's vanishing line, the water's lean -- reads +-- eye and focus the same way it always has. +-- +-- What this module owns: +-- +-- the ATTITUDE yaw and pitch, fed by whichever look input speaks: +-- relative mouse motion, the right stick's rate, or a +-- touch dragged across open screen. All three drive the +-- same two numbers, so they compose instead of fighting. +-- +-- the BLEND easing between the orbit and the head. Stepping onto +-- the rung dives the camera from wherever the orbit was +-- into the player's eyes over half a second; stepping off +-- flies it back out. Mid-blend the rig is a straight lerp +-- of the two cameras -- eye, focus, fov, up -- through +-- the same placed-camera record. +-- +-- the MOVE INTENT the analog vector FreeMove walks the player by, +-- gathered here because it is made of the same devices: +-- the left stick's raw axes, the touch d-pad's true +-- deflection, or the held keys, rotated by this camera's +-- yaw so "forward" means "where I am looking". +-- +-- Deliberately NOT here: movement itself (lib/FreeMove.lua, which owns the +-- collision walk and the grid the game logic still lives on), and the +-- billboard math that faces cards at this eye (VoxelScene, which owns +-- every other card matrix too). +-- +-- Everything the module reaches -- the mouse's relative mode, the wrapped +-- love handlers, the touch overlay's hit test -- is pcall-guarded the same +-- way the 3D pass is: headless runs and drivers without a mouse simply +-- never see the input, and the rung falls back to holding the 75-degree +-- orbit. + +-- the mod namespace (see main.lua): V.require loads a sibling module +local V = ... + +local Mat4 = V.require("Mat4") +local Voxel = V.require("VoxelState") +local Voxel3D = V.require("Voxel3D") +local WorldCurve = V.require("WorldCurve") + +local FirstPerson = {} + +-- ------- the rig's numbers +-- +-- EYE_HEIGHT stands the eye near the top of the 16px sprite -- the head, +-- not the hat tip -- above the same ground-plus-lift the character card +-- stands on, so surfing bobs and ledge hops carry the view with them. +-- +-- FOV is wider than the diorama's ~53 degrees: inside the world, the +-- diorama's lens reads as a keyhole. 65 vertical is the modern-shooter +-- middle ground. +-- +-- FOCUS_DIST is short on purpose: the placed-camera branch derives its +-- near plane from |eye - focus| (dist * 0.05), and the eye walks within +-- 2-3 world pixels of a wall face when sliding along it -- a far focus +-- would push the near plane through the wall and clip a hole in it. +FirstPerson.EYE_HEIGHT = 13 +FirstPerson.FOV = math.rad(65) +FirstPerson.FOCUS_DIST = 24 + +-- Pitch limits, in radians below horizontal (positive looks DOWN). The +-- world has no ceiling and the sky's bands sit low, so looking far up +-- shows the void above the gradient; the up-range is clamped tighter than +-- the down-range for that reason, not a technical one. +FirstPerson.PITCH_DOWN = math.rad(70) +FirstPerson.PITCH_UP = -math.rad(50) +FirstPerson.PITCH_DEFAULT = math.rad(10) + +-- how long the dive into (and out of) the head takes, in seconds +FirstPerson.BLEND_TIME = 0.45 + +-- ------- look input tuning +-- +-- MOUSE_SENS is radians per relative-mode count -- about 0.18 degrees per +-- count, the conventional shooter default. STICK rates are radians per +-- second at full deflection, with a squared response curve so small +-- deflections aim and full ones turn. TOUCH_TURN is what one full screen +-- width of drag turns, mobile-shooter convention. +FirstPerson.MOUSE_SENS = 0.0032 +FirstPerson.STICK_YAW = 3.5 +FirstPerson.STICK_PITCH = 2.4 +FirstPerson.STICK_DEAD = 0.18 +FirstPerson.TOUCH_TURN = 2.2 * math.pi +FirstPerson.MOVE_DEAD = 0.25 + +-- ------- state +-- +-- Yaw is a world bearing: 0 faces south (+Z, the way a resting sprite +-- faces), pi/2 east -- the same convention VoxelScene.YAW uses, so a +-- facing converts to a yaw by table lookup. +FirstPerson.yaw = 0 +FirstPerson.pitch = FirstPerson.PITCH_DEFAULT +FirstPerson.blend = 0 + +local wasEngaged = false +local stick = { x = 0, y = 0 } -- right stick, latest event values +local mouseDX, mouseDY = 0, 0 -- relative counts since last update +local lookTouch = nil -- { id, x, y } of the claimed finger +local touchMove = nil -- the touch d-pad's analog deflection +local captured = false -- mouse relative mode engaged by us + +-- the placed-camera record this module last handed to Voxel3D, so passes +-- that key behaviour off "is the first-person rig the one drawing" (the +-- billboard yaw, the frame remap) can ask by identity rather than by mode +-- -- the battle's own placed camera must never read as first person +local rig = nil + +local FACING_ANGLE = { + down = 0, + right = math.pi / 2, + up = math.pi, + left = -math.pi / 2, +} +local FACING_ORDER = { "down", "right", "up", "left" } + +local function wrapPi(a) + return (a + math.pi) % (2 * math.pi) - math.pi +end + +local function ease(t) + return t * t * (3 - 2 * t) +end + +-- ------- gates + +-- Whether the 1ST rung is selected and the 3D pass can carry it. +function FirstPerson.engaged() + return Voxel.isFirstPerson(Voxel.level) and Voxel3D.available() +end + +-- Whether first person should be READING the player's inputs right now: +-- engaged, with the overworld on top of the stack (a menu, a dialog or a +-- battle above it owns the buttons, exactly as it does for grid walking). +function FirstPerson.driving() + if not FirstPerson.engaged() then return false end + local ok, top, ow = pcall(function() + local Game = require("src.core.Game") + return Game.stack and Game.stack:top(), Game.overworld + end) + return ok and top ~= nil and top == ow +end + +-- The eased blend, 0 at the orbit and 1 in the head. +function FirstPerson.blendEased() + return ease(FirstPerson.blend) +end + +-- The blend, but only while the free-roam pass's own rig is the placed +-- camera. The battle scene places a camera of its own through the same +-- seam, and its cards must keep their stage lean rather than yawing at a +-- first-person eye that is not looking at them. +function FirstPerson.cardBlend() + if not rig or Voxel3D.camera ~= rig then return 0 end + return ease(FirstPerson.blend) +end + +-- Whether the player's own card should be left out of the camera draw: +-- deep enough into the blend that the card would fill the lens from +-- inside. The sun pass keeps drawing it either way -- a first-person +-- player still throws a shadow on the ground ahead. +function FirstPerson.hidePlayer() + return FirstPerson.cardBlend() > 0.9 +end + +-- ------- attitude + +-- Apply a look delta, in radians. Everything that turns the head funnels +-- through here, so the clamps live once. +function FirstPerson.lookBy(dyaw, dpitch) + FirstPerson.yaw = wrapPi(FirstPerson.yaw + dyaw) + FirstPerson.pitch = math.max(FirstPerson.PITCH_UP, + math.min(FirstPerson.PITCH_DOWN, + FirstPerson.pitch + dpitch)) +end + +-- The view direction's flat compass facing, for everything that still +-- thinks in the grid's four directions: the cell A interacts with, the +-- sprite the sun sees, the direction a blocked slide bonks in. +function FirstPerson.compassFacing() + local s, c = math.sin(FirstPerson.yaw), math.cos(FirstPerson.yaw) + if math.abs(s) > math.abs(c) then + return s > 0 and "right" or "left" + end + return c > 0 and "down" or "up" +end + +-- The unit look direction, and its flat (ground-plane) part. +local function lookDir() + local cp = math.cos(FirstPerson.pitch) + return math.sin(FirstPerson.yaw) * cp, + -math.sin(FirstPerson.pitch), + math.cos(FirstPerson.yaw) * cp +end + +function FirstPerson.lookFlat() + return math.sin(FirstPerson.yaw), math.cos(FirstPerson.yaw) +end + +-- ------- billboards seen from inside the world +-- +-- The diorama's cards face south and lean back by the camera's pitch -- +-- correct for a camera that always stands south. An eye that can stand +-- ANYWHERE sees a south-facing card edge-on from the east, so in first +-- person every card yaws about its feet to face the eye (cylindrical +-- billboarding: upright, never tipping). VoxelScene blends its matrices +-- between the two by cardBlend. + +-- The yaw that turns a card's south-facing normal toward the eye. +function FirstPerson.cardYaw(wx, wz) + local eye = rig and rig.eye + if not eye then return 0 end + local dx, dz = eye[1] - wx, eye[3] - wz + if dx * dx + dz * dz < 1e-9 then return 0 end + return math.atan2(dx, dz) +end + +-- Which of the four sprite frames an entity shows THIS eye: its facing +-- rotated into the viewer's own frame, quantised. The flat game's frames +-- are "how this pose looks from the south", so the apparent facing is the +-- pose rotated by where the viewer actually stands -- walk behind an NPC +-- and you see their back, circle to their flank and you see the profile, +-- exactly as the four frames Gen 1 drew intend. +function FirstPerson.apparentFacing(facing, wx, wz) + local eye = rig and rig.eye + local phi = FACING_ANGLE[facing] + if not (eye and phi) then return facing end + local dx, dz = eye[1] - wx, eye[3] - wz + if dx * dx + dz * dz < 1e-9 then return facing end + local rel = wrapPi(phi - math.atan2(dx, dz)) + local idx = math.floor((rel + math.pi / 4) / (math.pi / 2)) % 4 + return FACING_ORDER[idx + 1] +end + +-- ------- the move intent +-- +-- The analog vector FreeMove walks by, in CAMERA space: mx strafes (+ +-- right), mz advances (+ forward). Whichever device is actually deflected +-- answers -- the left stick's raw axes first (the engine quantises them to +-- a d-pad; the raw pair is the analog truth), then a touch d-pad finger, +-- then the held keys. Magnitude caps at 1. +function FirstPerson.moveVector() + local ok, Game = pcall(require, "src.core.Game") + local input = ok and Game.input or nil + + local ax = input and input.stickAxis or nil + if ax then + local mag = math.sqrt(ax.x * ax.x + ax.y * ax.y) + if mag > FirstPerson.MOVE_DEAD then + local t = math.min(1, (mag - FirstPerson.MOVE_DEAD) + / (1 - FirstPerson.MOVE_DEAD)) + return ax.x / mag * t, -ax.y / mag * t + end + end + + if touchMove then + local mag = math.sqrt(touchMove.x * touchMove.x + + touchMove.y * touchMove.y) + if mag > FirstPerson.MOVE_DEAD then + local t = math.min(1, mag) + return touchMove.x / mag * t, -touchMove.y / mag * t + end + end + + if input then + local mx = (input:isDown("right") and 1 or 0) + - (input:isDown("left") and 1 or 0) + local mz = (input:isDown("up") and 1 or 0) + - (input:isDown("down") and 1 or 0) + if mx ~= 0 or mz ~= 0 then + local mag = math.sqrt(mx * mx + mz * mz) + return mx / mag, mz / mag + end + end + return 0, 0 +end + +-- Rotate a camera-space move into world space: forward is the flat look +-- direction, strafe-right is its right hand. (cross(forward, up) with +-- forward = (sin y, 0, cos y) and up = +Y lands right on (-cos y, 0, +-- sin y): face south and your right hand points west.) +function FirstPerson.moveWorld(mx, mz) + local s, c = math.sin(FirstPerson.yaw), math.cos(FirstPerson.yaw) + return -c * mx + s * mz, s * mx + c * mz +end + +-- ------- the tick + +-- Runs from the pipeline's update hook, every frame whatever the level -- +-- the same tick VoxelState eases the orbit on. Owns the blend, the mouse +-- capture lifecycle, and the frame's stick-rate look. +function FirstPerson.update(dt) + local engagedNow = FirstPerson.engaged() + + -- entering the rung: the head starts looking the way the sprite faces, + -- pitched gently down -- the reading pose of the flat game + if engagedNow and not wasEngaged then + local ok, facing = pcall(function() + local Game = require("src.core.Game") + return Game.overworld and Game.overworld.player + and Game.overworld.player.facing + end) + FirstPerson.yaw = (ok and FACING_ANGLE[facing]) or 0 + FirstPerson.pitch = FirstPerson.PITCH_DEFAULT + end + wasEngaged = engagedNow + + -- the blend, held at flat until there is terrain to dive into -- the + -- same wait Voxel.update keeps for the orbit tween, for the same reason + local target = engagedNow and 1 or 0 + if target > FirstPerson.blend and FirstPerson.blend == 0 + and not Voxel.ready then + target = 0 + end + local step = dt / FirstPerson.BLEND_TIME + if FirstPerson.blend < target then + FirstPerson.blend = math.min(target, FirstPerson.blend + step) + elseif FirstPerson.blend > target then + FirstPerson.blend = math.max(target, FirstPerson.blend - step) + end + if FirstPerson.blend <= 0 and rig then + -- fully out: let go of the placed camera (unless a battle already + -- swapped its own in, which is not ours to clear) + if Voxel3D.camera == rig then Voxel3D.camera = nil end + rig = nil + end + + -- mouse capture follows engagement: captured whenever the rung is on and + -- the window has focus, released the moment either ends. Checked against + -- the live mode rather than toggled on edges, so a capture lost to the + -- OS (alt-tab) re-arms itself on the next focused frame. + local wantCapture = engagedNow + if wantCapture and love.window and love.window.hasFocus then + local okF, focus = pcall(love.window.hasFocus) + wantCapture = okF and focus or false + end + if love.mouse and love.mouse.setRelativeMode then + local okM, isRel = pcall(love.mouse.getRelativeMode) + if okM and isRel ~= wantCapture then + pcall(love.mouse.setRelativeMode, wantCapture) + end + captured = wantCapture + end + + local driving = FirstPerson.driving() + + -- The mouse's counts, accumulated by the wrapped handler since the last + -- tick; dropped unread while something else owns the screen. + -- + -- The yaw sign is NEGATED, here and in every look input below: yaw grows + -- south -> east -> north (the world runs +X east, +Z south, and the + -- direction is (sin yaw, cos yaw)), which seen from behind the eye is a + -- LEFT turn -- so "move the mouse right, look right" means subtracting. + local dx, dy = mouseDX, mouseDY + mouseDX, mouseDY = 0, 0 + if driving and (dx ~= 0 or dy ~= 0) then + FirstPerson.lookBy(-dx * FirstPerson.MOUSE_SENS, + dy * FirstPerson.MOUSE_SENS) + end + + -- the right stick is a rate: radians per second, squared response so + -- the first half of the throw aims and the rest turns + if driving then + local rx, ry = stick.x, stick.y + local function curve(v) + local a = math.abs(v) + if a < FirstPerson.STICK_DEAD then return 0 end + a = (a - FirstPerson.STICK_DEAD) / (1 - FirstPerson.STICK_DEAD) + return (v < 0 and -1 or 1) * a * a + end + local cy, cp = curve(rx), curve(ry) + if cy ~= 0 or cp ~= 0 then + -- negated yaw for the same reason as the mouse above + FirstPerson.lookBy(-cy * FirstPerson.STICK_YAW * dt, + cp * FirstPerson.STICK_PITCH * dt) + end + end +end + +-- ------- the rig itself + +-- The orbit camera's eye/focus/fov/up for the frame's centre -- the same +-- arithmetic Voxel3D.viewProjection runs, restated here because the blend +-- needs both ends as DATA. Kept textually tiny so the two cannot drift: +-- focus on the centre, eye FOCAL*vh away at the pitch, up perpendicular +-- in the YZ plane. +local function orbitRig(cx, cy, vh) + local a = Voxel.angle + local dist = Voxel.FOCAL * vh + return { cx, dist * math.cos(a), cy + dist * math.sin(a) }, + { cx, 0, cy }, + 2 * math.atan(1 / (2 * Voxel.FOCAL)), + { 0, math.sin(a), -math.cos(a) } +end + +local lastEye = nil -- frozen head pose for player-less frames + +-- Build this frame's placed camera and hand it to Voxel3D, plus the scene +-- centre the curve and the depth reference should use. `me` is the +-- player's posed entry (px, py, gh, lift) or nil (a Fly animation), and +-- (cx, cy) the orbit's own view centre. +-- +-- Returns nil with the blend fully out, which is the caller's signal to +-- leave the orbit in charge. +function FirstPerson.frame(me, cx, cy, vw, vh) + local b = FirstPerson.blend + if b <= 0 then + if rig and Voxel3D.camera == rig then Voxel3D.camera = nil end + rig = nil + return nil + end + local e = ease(b) + + local head + if me then + head = { me.px + 8, + (me.gh or 0) + (me.lift or 0) + FirstPerson.EYE_HEIGHT, + me.py + 8 } + lastEye = head + else + head = lastEye or { cx, FirstPerson.EYE_HEIGHT, cy } + end + local lx, ly, lz = lookDir() + local fpFocus = { head[1] + lx * FirstPerson.FOCUS_DIST, + head[2] + ly * FirstPerson.FOCUS_DIST, + head[3] + lz * FirstPerson.FOCUS_DIST } + + local oEye, oFocus, oFov, oUp = orbitRig(cx, cy, vh) + local function mix(p, q) + return { p[1] + (q[1] - p[1]) * e, + p[2] + (q[2] - p[2]) * e, + p[3] + (q[3] - p[3]) * e } + end + local up = mix(oUp, { 0, 1, 0 }) + local ul = math.sqrt(up[1] * up[1] + up[2] * up[2] + up[3] * up[3]) + if ul > 1e-6 then up[1], up[2], up[3] = up[1] / ul, up[2] / ul, up[3] / ul + else up = { 0, 1, 0 } end + + -- the world curve eases out with the blend: standing inside the world, + -- the bend that sells the diorama reads as the ground falling away. A + -- true zero (curve declined) needs the field present -- nil would let + -- Voxel3D fall back to the setting + local k = WorldCurve.k(vh) * (1 - e) + + rig = { + eye = mix(oEye, head), + focus = mix(oFocus, fpFocus), + fov = oFov + (FirstPerson.FOV - oFov) * e, + up = up, + curve = k, + } + Voxel3D.camera = rig + + -- the scene centre walks from the orbit's view centre to the head, so + -- the curve's focus, the depth reference and the glint's travel follow + -- the camera that is actually in charge + local sx = cx + (head[1] - cx) * e + local sy = cy + (head[3] - cy) * e + return rig, sx, sy +end + +-- Where the shadow pass should centre its box: pushed along the flat look +-- so the fitted frustum -- built for an orbit that always looks north -- +-- covers the ground THIS camera sees. The push is strongest looking +-- south (the direction the orbit's box barely reaches) and scales with +-- the blend. +function FirstPerson.shadowCenter(sx, sy, vh) + local e = FirstPerson.cardBlend() + if e <= 0 then return sx, sy end + local fx, fz = FirstPerson.lookFlat() + local ShadowMap = V.require("ShadowMap") + local cap = (ShadowMap.FAR_CAP or 2.5) * vh + return sx + fx * 0.6 * vh * e, + sy + fz * (fz > 0 and (cap - vh * 0.5) or vh * 0.4) * e +end + +-- The first-person facts a shadow signature has to include: the sun's +-- box is fitted around this camera, so turning the head or walking the +-- blend has to re-fit it even standing still. +function FirstPerson.signature() + local b = FirstPerson.blend + if b <= 0 then return "" end + return table.concat({ + math.floor(b * 64), + math.floor(FirstPerson.yaw * 64), + math.floor(FirstPerson.pitch * 64), + }, ",") +end + +-- ------- input capture +-- +-- The seams: relative mouse motion has no Game handler at all (the +-- engine's love.mousemoved only feeds the mouse-as-touch debug path), the +-- right stick's axes are explicitly ignored by Input, and a touch +-- anywhere off the overlay's controls dies in TouchControls. Each wrap +-- forwards everything it does not claim, and claims only while first +-- person is actually driving -- so with the rung off, every byte flows +-- exactly where it always did. + +local installed = false + +function FirstPerson.install() + if installed then return end + installed = true + + local Game = require("src.core.Game") + + -- ------- right stick + do + local inner = Game.gamepadaxis + function Game:gamepadaxis(joystick, axis, value) + if axis == "rightx" then stick.x = value + elseif axis == "righty" then stick.y = value end + return inner(self, joystick, axis, value) + end + end + -- generic (non-gamepad) sticks: axes 1/2 are the left stick by SDL + -- convention and Input already claims them; 3/4 are the usual right + -- pair on the same class of device. Real gamepads are excluded -- they + -- already spoke through the mapped rightx/righty above, and their RAW + -- axis 3 is as likely a trigger as a stick. + do + local inner = Game.joystickaxis + function Game:joystickaxis(joystick, axis, value) + local mapped = joystick and joystick.isGamepad and joystick:isGamepad() + if not mapped then + if axis == 3 then stick.x = value + elseif axis == 4 then stick.y = value end + end + return inner(self, joystick, axis, value) + end + end + + -- ------- mouse + -- + -- love.mousemoved rather than a Game method, because the engine has no + -- Game:mousemoved to wrap -- the callback in the project's main.lua is + -- the one place relative counts arrive. Claimed only while captured; + -- pass-through otherwise, including the mouse-as-touch path. + do + local inner = love.mousemoved + love.mousemoved = function(x, y, dx, dy, istouch) + if captured and not istouch then + mouseDX = mouseDX + (dx or 0) + mouseDY = mouseDY + (dy or 0) + return + end + if inner then return inner(x, y, dx, dy, istouch) end + end + end + -- While the mouse is captured there is no cursor to click UI with, so + -- the buttons become GB buttons: left is A, right is B -- through the + -- overlay's own press path, which a rebind can never detach. What WE + -- pressed is remembered per button, so the release always reaches the + -- overlay even if the capture ended while the button was down -- + -- otherwise a click that outlives the rung strands A held forever. + local mouseHeld = {} + local MOUSE_BTN = { [1] = "a", [2] = "b" } + do + local inner = love.mousepressed + love.mousepressed = function(x, y, button, istouch, presses) + if captured and not istouch and MOUSE_BTN[button] then + local Input = require("src.core.Input") + mouseHeld[button] = true + Input:overlayPressed(MOUSE_BTN[button]) + return + end + if inner then return inner(x, y, button, istouch, presses) end + end + end + do + local inner = love.mousereleased + love.mousereleased = function(x, y, button, istouch, presses) + if mouseHeld[button] then + local Input = require("src.core.Input") + mouseHeld[button] = nil + Input:overlayReleased(MOUSE_BTN[button]) + return + end + if inner then return inner(x, y, button, istouch, presses) end + end + end + + -- ------- touch + -- + -- A finger on open screen -- not on the overlay's d-pad or buttons -- + -- becomes the look drag. One finger owns the look at a time; every + -- other touch flows to TouchControls untouched, so a thumb can drag the + -- view while the other walks the d-pad. That d-pad finger is also read + -- back ANALOG here: TouchControls quantises it to four directions for + -- the grid game, but the deflection it quantised is exactly the move + -- vector a free walk wants. + local TouchControls = require("src.core.TouchControls") + + local function dpadVector(x, y) + local ok, v = pcall(function() + local L = TouchControls:layout() + local dz = L.dpad + local half = dz.w * 0.65 + return { x = math.max(-1, math.min(1, (x - dz.cx) / half)), + y = math.max(-1, math.min(1, (y - dz.cy) / half)) } + end) + return ok and v or nil + end + + do + local inner = Game.touchpressed + function Game:touchpressed(id, x, y) + if FirstPerson.driving() then + local onControl = nil + pcall(function() onControl = TouchControls:hitTest(x, y) end) + if not onControl and not lookTouch then + lookTouch = { id = id, x = x, y = y } + return + end + inner(self, id, x, y) + if onControl == "dpad" and TouchControls.dpadTouch == id then + touchMove = dpadVector(x, y) + end + return + end + return inner(self, id, x, y) + end + end + do + local inner = Game.touchmoved + function Game:touchmoved(id, x, y) + if lookTouch and lookTouch.id == id then + local w = 1280 + pcall(function() w = love.graphics.getWidth() end) + local per = FirstPerson.TOUCH_TURN / math.max(320, w) + if FirstPerson.driving() then + -- negated yaw for the same reason as the mouse (see update): + -- drag right, look right, the mobile-shooter convention + FirstPerson.lookBy(-(x - lookTouch.x) * per, + (y - lookTouch.y) * per) + end + lookTouch.x, lookTouch.y = x, y + return + end + if touchMove and TouchControls.dpadTouch == id then + touchMove = dpadVector(x, y) or touchMove + end + return inner(self, id, x, y) + end + end + do + local inner = Game.touchreleased + function Game:touchreleased(id, x, y) + if lookTouch and lookTouch.id == id then + lookTouch = nil + return + end + if TouchControls.dpadTouch == id then touchMove = nil end + return inner(self, id, x, y) + end + end + + -- a reset that drops held input state drops ours with it + do + local inner = Game.focus + function Game:focus(f) + lookTouch, touchMove = nil, nil + stick.x, stick.y = 0, 0 + mouseDX, mouseDY = 0, 0 + return inner(self, f) + end + end +end + +return FirstPerson diff --git a/lib/FreeMove.lua b/lib/FreeMove.lua new file mode 100644 index 0000000..eacd26a --- /dev/null +++ b/lib/FreeMove.lua @@ -0,0 +1,345 @@ +-- Voxel world mode: free movement for the first-person rung. +-- +-- The engine walks a grid: sixteen frames per cell, four directions, +-- input locked mid-step. Inside a first-person camera that gait reads as +-- riding a rail, so while 1ST drives, this module replaces the WALK and +-- nothing else: the player's position becomes continuous, steered by the +-- camera's own yaw -- push forward and you go where you look, at any +-- angle, sliding along whatever you graze. +-- +-- THE GRID IS STILL THE GAME. Every fact the world cares about is a fact +-- about cells -- what blocks, what warps, what rustles, what bites -- and +-- this module keeps the player's logical cell synced to wherever the free +-- walk stands, then reuses the engine's own machinery for every one of +-- those questions: +-- +-- passability the same isWalkableCell / water-while-surfing / +-- tile-pair / entity-occupancy verdicts Collision +-- hands the grid walker, asked per cell the player's +-- body overlaps. +-- +-- cell arrival OverworldState:onStepComplete, the same landing +-- pipeline a grid step runs -- warps, spinners, gates, +-- forced currents, poison, repel, encounters, the +-- step counters -- fired once per cell crossed, which +-- is exactly the rate a grid walk fires it. +-- +-- the special pushes walking off the map edge, into a ledge, or into +-- a boulder hands the quantised direction straight to +-- checkEdgeExit / checkLedgeHop / checkBoulderPush, +-- the engine's own handlers, which validate and stage +-- everything themselves (connections, the hop arc, +-- the two-push arm). While any of those animates a +-- scripted grid move, this module stands aside and +-- adopts the result. +-- +-- Nothing here writes save state, rolls encounters, or decides what a +-- warp does -- it moves a point, keeps the cell honest, and lets the +-- engine be the engine. Stepping off the rung snaps the point to its +-- cell and hands the walk back to the grid, and with the rung off this +-- module costs one gate check per frame. + +-- the mod namespace (see main.lua): V.require loads a sibling module +local V = ... + +local FirstPerson = V.require("FirstPerson") + +local FreeMove = {} + +-- The body: a circle in the ground plane. Small enough to walk every +-- one-cell corridor the grid game has (half a cell is 8), big enough to +-- keep the eye's near plane out of wall faces when sliding along them. +FreeMove.RADIUS = 5.5 + +-- World pixels per fixed 60Hz frame -- the grid walker's own speeds (16 +-- frames per 16px cell on foot, 8 on the bike), so distance covered per +-- second is unchanged and the encounter rate per tile crossed stays the +-- game's own. +FreeMove.WALK = 1.0 +FreeMove.BIKE = 2.0 + +local EPS = 0.01 + +-- the free position (player centre, world px) and the px/py we last wrote +-- -- if they differ from the player's, something else (a warp, a script) +-- moved them, and the free walk adopts rather than fights +local pos = nil +local lastPx, lastPy = nil, nil + +local function adopt(p) + pos = { x = p.px + 8, z = p.py + 8 } + lastPx, lastPy = p.px, p.py +end + +function FreeMove.drop() + pos = nil +end + +-- named for the suite: the module's live position, nil while dropped +function FreeMove._pos() + return pos +end + +-- ------- the per-cell verdict +-- +-- The same questions Collision.canMove asks for a grid step, asked of one +-- cell from the player's current standing. The player's OWN cell never +-- blocks -- the body must always be free to leave wherever it stands +-- (a warp mat, the water it is surfing, a cell an NPC just stepped +-- against). + +local function pairBlocked(map, surfing, sx, sy, tx, ty) + local Game = require("src.core.Game") + local tp = Game.data and Game.data.field and Game.data.field.tilePairs + if not tp then return false end + local list = surfing and tp.water or tp.land + if not list or #list == 0 then return false end + local tileset = map.def.tileset + local a = map:cellTile(sx, sy) + local b = map:cellTile(tx, ty) + for _, p in ipairs(list) do + if p.tileset == tileset + and ((p.a == a and p.b == b) or (p.a == b and p.b == a)) then + return true + end + end + return false +end + +-- Why (cx, cy) refuses the player's body, or nil when it may enter: +-- "bounds" | "tile" | "entity", the grid verdict's own names. +local function blockedCell(state, p, cx, cy) + if cx == p.cellX and cy == p.cellY then return nil end + local map = state.map + if not map:inBounds(cx, cy) then return "bounds" end + if not map:isWalkableCell(cx, cy) then + if not (p.surfing and map:isWaterCell(cx, cy)) then return "tile" end + end + if pairBlocked(map, p.surfing, p.cellX, p.cellY, cx, cy) then + return "tile" + end + local Collision = require("src.world.Collision") + if Collision.occupied(state.entities, cx, cy, p) then return "entity" end + return nil +end + +FreeMove._blockedCell = blockedCell -- named for the suite + +-- ------- the slide +-- +-- One axis at a time, clamped at the first refusing cell's face: the +-- classic axis-separated walk, which is where wall-sliding comes from -- +-- the blocked axis stops and the free one keeps going. Returns the +-- refusal ("bounds"/"tile"/"entity") when this axis was clamped. + +local function slideX(state, p, dx) + if dx == 0 then return nil end + local r = FreeMove.RADIUS + local nx = pos.x + dx + local z0 = math.floor((pos.z - r + EPS) / 16) + local z1 = math.floor((pos.z + r - EPS) / 16) + local hit = nil + local edge = dx > 0 and math.floor((nx + r) / 16) + or math.floor((nx - r) / 16) + for zc = z0, z1 do + hit = blockedCell(state, p, edge, zc) + if hit then break end + end + if hit then + if dx > 0 then nx = math.min(nx, edge * 16 - r - EPS) + else nx = math.max(nx, (edge + 1) * 16 + r + EPS) end + end + pos.x = nx + return hit +end + +local function slideZ(state, p, dz) + if dz == 0 then return nil end + local r = FreeMove.RADIUS + local nz = pos.z + dz + local x0 = math.floor((pos.x - r + EPS) / 16) + local x1 = math.floor((pos.x + r - EPS) / 16) + local hit = nil + local edge = dz > 0 and math.floor((nz + r) / 16) + or math.floor((nz - r) / 16) + for xc = x0, x1 do + hit = blockedCell(state, p, xc, edge) + if hit then break end + end + if hit then + if dz > 0 then nz = math.min(nz, edge * 16 - r - EPS) + else nz = math.max(nz, (edge + 1) * 16 + r + EPS) end + end + pos.z = nz + return hit +end + +-- ------- the blocked push +-- +-- The grid game's blocked step is where half its verbs live: the map-edge +-- crossing, the ledge hop, the boulder shove, the route-gate warp fired +-- by collision, and the honest bonk. Hand the engine the quantised +-- direction and let its own handlers decide -- each one validates itself +-- (checkLedgeHop matches the tile pair, checkEdgeExit checks the bounds), +-- so calling them on every firm push is safe. Returns true when one of +-- them took the frame over. +local function pushSpecials(state, dir, why) + local p = state.player + p.facing = dir -- the handlers read the push off the facing + if why == "bounds" and state:checkEdgeExit(dir) then return true end + if state:checkLedgeHop(dir) then return true end + if state:checkBoulderPush(dir) then return true end + if why ~= "entity" and state:canCollisionWarp() then + local Game = require("src.core.Game") + local Warp = require("src.world.Warp") + local w = Warp.onCollision(state.map, Game.data.field.warpCarpets, + p.cellX, p.cellY, dir) + if w then + state:takeWarp(w.def) + return true + end + end + if why ~= "entity" then + if (state.bumpCooldown or 0) <= 0 then + local Game = require("src.core.Game") + require("src.core.Sound").play(Game.data, "Collision") + state.bumpCooldown = 16 + end + end + return false +end + +-- ------- the tick +-- +-- Runs in place of OverworldState:handleInput while first person drives +-- (see install below), which means it inherits every gate the grid walk +-- has: never during scripted moves, transitions, or with anything above +-- the overworld on the stack. + +function FreeMove.tick(state) + local p = state.player + + -- a grid move is animating -- a ledge hop, a spinner slide, a scripted + -- walk -- or a cutscene owns the player: stand aside, adopt the result + if p.moving or p.inputLocked then + FreeMove.drop() + return + end + if not pos or p.px ~= lastPx or p.py ~= lastPy then adopt(p) end + + local Game = require("src.core.Game") + local input = Game.input + + -- the head is the facing: what A talks to, what the sun's card shows, + -- which way a bonk points + p.facing = FirstPerson.compassFacing() + + if input:wasPressed("a") then + state:interact() + return + end + if input:wasPressed("start") then + require("src.core.Sound").play(Game.data, "Start_Menu") + require("src.ui.Screens").push(Game, "StartMenu") + return + end + + local mx, mz = FirstPerson.moveVector() + local wx, wz = FirstPerson.moveWorld(mx, mz) + + -- Cycling Road's downhill pull, the free-walk restatement of the grid + -- path's simulated PAD_DOWN: south drift with nothing held, braked by + -- holding A or B exactly as the Route 17 sign promises + local moving = (mx ~= 0 or mz ~= 0) + if not moving and Game.save and Game.save.onBike then + local fm = Game.data.field.forcedMovement + local braking = input:isDown("a") or input:isDown("b") + if fm and not braking then + for _, m in ipairs(fm.slopeMaps or {}) do + if m == state.map.id then + wx, wz, moving = 0, 1, true + break + end + end + end + end + + if not moving then return end + + state.bumpCooldown = math.max(0, (state.bumpCooldown or 0) - 1) + + local speed = (Game.save and Game.save.onBike) and FreeMove.BIKE + or FreeMove.WALK + local dx, dz = wx * speed, wz * speed + + local hitX = slideX(state, p, dx) + local hitZ = slideZ(state, p, dz) + + -- the walk cycle: the wall-bonk clock animates the legs of a player the + -- grid thinks is standing still, refreshed while the free walk covers + -- ground (Player:update ticks animClock off it; walkPhase reads it) + p.bumpFrames = 2 + + p.px, p.py = pos.x - 8, pos.z - 8 + lastPx, lastPy = p.px, p.py + + -- the cell the body stands in; crossing into a new one IS a step + local ncx = math.floor(pos.x / 16) + local ncy = math.floor(pos.z / 16) + if ncx ~= p.cellX or ncy ~= p.cellY then + p.cellX, p.cellY = ncx, ncy + state:onStepComplete() + -- a warp or a battle may have moved the world out from under the + -- walk; the adopt check on the next tick picks the pieces up + return + end + + -- a firm push into something that refused: the engine's own blocked-step + -- verbs, aimed the way the push leans + local hit, dir + if hitX and (not hitZ or math.abs(dx) >= math.abs(dz)) then + hit, dir = hitX, (dx > 0 and "right" or "left") + elseif hitZ then + hit, dir = hitZ, (dz > 0 and "down" or "up") + end + if hit and math.max(math.abs(dx), math.abs(dz)) > 0.4 * speed then + if pushSpecials(state, dir, hit) then + FreeMove.drop() + return + end + -- the push handlers may have turned the facing; the head still rules + p.facing = FirstPerson.compassFacing() + end +end + +-- ------- the seam +-- +-- OverworldState:handleInput is the one choke point where the grid walk +-- reads the pad -- the same seam the engine's own Cycling Road pull and +-- collision warps live behind -- so replacing the walk means wrapping it +-- and nothing else. Every gate ABOVE the call (scripted moves, trainer +-- engagement, transitions, anything on the stack) still applies to the +-- free walk, because the wrap sits below them all. +function FreeMove.install() + local OverworldState = require("src.world.OverworldController") + if OverworldState.dramaticShapeFreeMoveHook then return end + local inner = OverworldState.handleInput + + function OverworldState:handleInput() + if not FirstPerson.driving() then + if pos then + -- stepping off the rung: back onto the grid, on the cell the + -- free walk stood in + local p = self.player + p.px, p.py = p.cellX * 16, p.cellY * 16 + FreeMove.drop() + end + return inner(self) + end + return FreeMove.tick(self) + end + + OverworldState.dramaticShapeFreeMoveHook = true +end + +return FreeMove diff --git a/lib/Voxel3D.lua b/lib/Voxel3D.lua index 54ef847..1749402 100644 --- a/lib/Voxel3D.lua +++ b/lib/Voxel3D.lua @@ -421,7 +421,8 @@ end -- ---------------------------------------------------------------- camera -- -- An explicit camera, replacing the orbit below for as long as it is set: --- { eye = {x,y,z}, focus = {x,y,z}, fov = radians, curve = k or nil }. +-- { eye = {x,y,z}, focus = {x,y,z}, fov = radians, curve = k or nil, +-- up = {x,y,z} or nil }. -- -- The orbit is the free-roam camera and it is described entirely by ONE -- number, the pitch, because that is all a camera following the player over @@ -496,9 +497,12 @@ function Voxel3D.viewProjection(cx, cy, vw, vh) -- the same clip-space Y flip the orbit needs, for the same reason: we -- bypass LOVE's transform_projection and canvas coordinates run Y down proj = Mat4.mul(Mat4.scale(1, -1, 1), proj) - -- world up, so the horizon stays level -- a placed camera that rolled - -- with its own pitch would tip the whole arena - return Mat4.mul(proj, Mat4.lookAt(eye, focus, { 0, 1, 0 })) + -- world up by default, so the horizon stays level -- a placed camera + -- that rolled with its own pitch would tip the whole arena. A caller + -- may hand its own up: the first-person BLEND does, because its far + -- end is the orbit, whose up leans with the pitch -- world up at the + -- orbit's steep end degenerates against a straight-down view. + return Mat4.mul(proj, Mat4.lookAt(eye, focus, cam.up or { 0, 1, 0 })) end local a = Voxel.angle diff --git a/lib/VoxelScene.lua b/lib/VoxelScene.lua index 529f367..48c719d 100644 --- a/lib/VoxelScene.lua +++ b/lib/VoxelScene.lua @@ -24,6 +24,7 @@ local Sky = V.require("Sky") local Water = V.require("Water") local VoxelGrid = V.require("VoxelGrid") local DayNight = V.require("DayNight") +local FirstPerson = V.require("FirstPerson") local PaletteFX = require("src.render.PaletteFX") local Map = require("src.world.Map") @@ -214,6 +215,22 @@ local function frameFor(def, facing, phase, flip) return frame, mirror end +-- The facing a pose SHOWS this camera. The flat frames are "how this pose +-- looks from the south", which is where the orbit always stands; a +-- first-person eye stands anywhere, so deep enough into the blend the +-- facing is remapped to how the pose looks from THERE -- walk behind an +-- NPC and their card wears the back sprite. Used by the camera draw and +-- the sun pass BOTH: the card the sun stored and the transform a lit card +-- reads its own shadowing with must describe the same frame, or the +-- mirror-flip half of the pair asks the map about texels the sun filed +-- under the other cheek. +local function viewFacing(p) + if FirstPerson.cardBlend() > 0.5 then + return FirstPerson.apparentFacing(p.facing, p.px + 8, p.py + 8) + end + return p.facing +end + -- FALLBACK ONLY (see castShadows below). Draw one entity's drop shadow as -- a decal: its current sprite frame as a single quad, flattened onto the -- ground along the sun line (Voxel3D.shadowMatrix). Runs inside @@ -236,10 +253,22 @@ end -- Shared by the solid draw and the silhouette below, so the two can never -- drift apart -- a silhouette standing anywhere but exactly behind the -- figure would read as a second character. +-- +-- IN FIRST PERSON the card stops leaning and starts TURNING: upright, yawed +-- about its feet to face the eye (cylindrical billboarding). A south-facing +-- card is invisible edge-on to an eye standing east of it, which no orbit +-- camera could ever do and a first-person one does constantly. The blend +-- carries one pose into the other -- the lean eases out as the yaw eases in +-- -- and cardBlend is zero for every camera that is not the first-person +-- rig, the battle's placed shot included, so nothing else moves. local function billboardMatrix(px, py, y, mirror) local Voxel = V.require("VoxelState") - local m = Mat4.mul(Mat4.translate(px + 8, y, py + 8), - Mat4.rotateX(Voxel.angle - math.pi / 2)) + local b = FirstPerson.cardBlend() + local m = Mat4.translate(px + 8, y, py + 8) + if b > 0 then + m = Mat4.mul(m, Mat4.rotateY(FirstPerson.cardYaw(px + 8, py + 8) * b)) + end + m = Mat4.mul(m, Mat4.rotateX((Voxel.angle - math.pi / 2) * (1 - b))) if mirror then m = Mat4.mul(m, Mat4.scale(-1, 1, 1)) end return Mat4.mul(m, Mat4.translate(-8, 0, 0)) end @@ -258,10 +287,24 @@ end -- the Pokemon Center couch reads face-on at every tilt like the NPCs -- around him. No cell centring: unlike a character he is not standing on a -- cell, he is standing where he was drawn, which may straddle two. +-- +-- First person turns him at the eye like the walkers (see billboardMatrix) +-- -- about his own middle, because unlike a character card his local space +-- starts at x = 0 rather than being anchored by a -8 shift, and a yaw about +-- his edge would swing him off his seat. The width rode in on the record +-- for exactly this (ChunkMesher.buildFigureMeshes). local function figureMatrix(f, offX, offZ) local Voxel = V.require("VoxelState") - return Mat4.mul(Mat4.translate(f.wx + (offX or 0), f.y, f.wz + (offZ or 0)), - Mat4.rotateX(Voxel.angle - math.pi / 2)) + local b = FirstPerson.cardBlend() + local wx, wz = f.wx + (offX or 0), f.wz + (offZ or 0) + local m = Mat4.translate(wx, f.y, wz) + if b > 0 and f.w and f.w > 0 then + local half = f.w / 2 + m = Mat4.mul(m, Mat4.translate(half, 0, 0)) + m = Mat4.mul(m, Mat4.rotateY(FirstPerson.cardYaw(wx + half, wz) * b)) + m = Mat4.mul(m, Mat4.translate(-half, 0, 0)) + end + return Mat4.mul(m, Mat4.rotateX((Voxel.angle - math.pi / 2) * (1 - b))) end -- What the sun sees: the same card UNLEANED and flattened, exactly as @@ -333,7 +376,7 @@ VoxelScene.drawEntity = drawEntity -- mesh for it. local function drawGhost(p) local def = p.sprite.def - local frame, mirror = frameFor(def, p.facing, p.phase, p.flip) + local frame, mirror = frameFor(def, viewFacing(p), p.phase, p.flip) local mesh = SpriteBillboards.shadowQuad(def, frame) if not mesh then return end local tex = p.sprite:resolveImage() @@ -462,7 +505,13 @@ local function posesOf(state, spriteColors) gh = groundAt(state.map, e.cellX, e.cellY), lift = e.py - vy, colors = colors, } - if e == state.player then me = posed[#posed] end + if e == state.player then + me = posed[#posed] + -- marked so the camera draw can leave the card out in first + -- person, where it would fill the lens from inside; the SUN pass + -- reads the same list and deliberately does not check the mark + me.isPlayer = true + end end end return posed, me @@ -529,9 +578,18 @@ local function drawCast(state, posed, atlasFor) -- drawEntity resolves the lean-over-the-wall-in-front case, and a -- character genuinely behind a building is far deeper and loses the -- test, so buildings and trees really occlude. + -- + -- In first person two of them change: the player's own card is left out + -- (the eye is standing in it), and every other card wears the frame its + -- pose SHOWS this eye (viewFacing) rather than the one it shows the + -- south. Both run through here, so the water's reflection copy -- drawn + -- by this same function -- agrees with the frame to the pixel. + local hideMe = FirstPerson.hidePlayer() for _, p in ipairs(posed) do - drawEntity(p.sprite, p.px, p.py, p.facing, p.phase, p.flip, p.gh, - p.colors, p.lift) + if not (p.isPlayer and hideMe) then + drawEntity(p.sprite, p.px, p.py, viewFacing(p), p.phase, p.flip, p.gh, + p.colors, p.lift) + end end -- back on for everything textured from the atlas again -- figures, grass -- and flowers all sample it, where the mask's coordinates are honest @@ -687,6 +745,10 @@ local function shadowSignature(terrain, nbMesh, posed, cx, cy, vw, vh) -- few times a minute rather than every frame. put(math.floor(ShadowMap.KX * 128)) put(math.floor(ShadowMap.KZ * 128)) + -- and the first-person head: the box is fitted around wherever it looks + -- and the sprite cards swap frames as it circles them, so a turn on the + -- spot re-fits and redraws exactly like a camera move ("" outside 1ST) + put(FirstPerson.signature()) put(tostring(terrain)) for i = 1, #nbMesh do put(tostring(nbMesh[i])) end for _, p in ipairs(posed) do @@ -759,7 +821,12 @@ local function castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh, end for _, p in ipairs(posed) do local def = p.sprite.def - local frame, mirror = frameFor(def, p.facing, p.phase, p.flip) + -- viewFacing, exactly as the camera draw picks it (see viewFacing for + -- why the two passes must agree): in first person the sun's card + -- swaps frame as the eye circles, which costs a redraw the signature + -- already charges for (FirstPerson.signature) and keeps a card from + -- fringing against a mirror-flipped record of itself + local frame, mirror = frameFor(def, viewFacing(p), p.phase, p.flip) local mesh = SpriteBillboards.shadowQuad(def, frame) if mesh then ShadowMap.draw(mesh, p.sprite:resolveImage(), @@ -815,7 +882,23 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor) end local posed, me = posesOf(state, spriteColors) - castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh, atlasFor, + + -- The first-person rig, built (or blended) for this frame and handed to + -- Voxel3D BEFORE either pass runs: the sun's box is fitted around this + -- camera, and every card matrix asks it which way to turn. With the + -- blend fully out the call clears the placed camera and the orbit is + -- exactly what it always was. The scene centre it returns walks from + -- the orbit's view centre into the head, so the curve's focus and the + -- depth reference follow the camera actually in charge. + local fpRig, fpCx, fpCy = FirstPerson.frame(me, cx, cy, vw, vh) + if fpRig then cx, cy = fpCx, fpCy end + + -- The sun's box, pushed along the first-person look so it covers the + -- ground THIS camera sees (a no-op at blend zero): the orbit's fit + -- reaches far north and barely south, which is right for every rung + -- but a head free to face south. + local shCx, shCy = FirstPerson.shadowCenter(cx, cy, vh) + castShadows(state, terrain, nbMesh, posed, shCx, shCy, vw, vh, atlasFor, water, nbWater) if not Voxel3D.beginScene(w, h, cx, cy, vw, vh, skyFor(state.map)) then @@ -838,7 +921,7 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor) if not Voxel3D.shadowsActive() then Voxel3D.beginShadows() for _, p in ipairs(posed) do - drawShadow(p.sprite, p.px, p.py, p.facing, p.phase, p.flip, p.gh, + drawShadow(p.sprite, p.px, p.py, viewFacing(p), p.phase, p.flip, p.gh, p.lift) end Voxel3D.endShadows() @@ -884,7 +967,11 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor) -- wrote it, so the silhouette would paint over the player at all times. -- Every character then draws on top as usual, which leaves the silhouette -- showing in exactly one situation: where the world hides them. - if me then + -- + -- Not in first person: the card it silhouettes is the one the camera is + -- standing inside, and "the world is in front of the player" is every + -- wall the player faces. + if me and not FirstPerson.hidePlayer() then Voxel3D.beginGhost() drawGhost(me) Voxel3D.endGhost() diff --git a/lib/VoxelState.lua b/lib/VoxelState.lua index e19b314..3e8bb45 100644 --- a/lib/VoxelState.lua +++ b/lib/VoxelState.lua @@ -42,7 +42,8 @@ local Voxel = {} -- billboard lean the blend eases away) reads that 75 while the first-person -- rig owns the actual camera. Voxel.ANGLES_DEG = { 0, 35, 15, 35, 50, 75, 75 } -Voxel.ANGLE_LABELS = { "OFF", "FULL", "15", "35", "50", "75", "1ST" } +Voxel.ANGLE_LABELS = { "OFF", "FULL", "15", "35", "50", "75", + "1ST (EXPERIMENTAL)" } Voxel.MAX_LEVEL = #Voxel.ANGLES_DEG - 1 -- the rung FULL sits on, so nothing has to hunt for it by label diff --git a/main.lua b/main.lua index f0b4883..6f50f7c 100644 --- a/main.lua +++ b/main.lua @@ -23,9 +23,16 @@ -- the engine's TILT mode -- is engine plumbing driven by the records -- below. This file declares; lib/ draws. -- --- Nothing here reaches collision, movement, triggers or scripts. Voxel --- mode is purely presentational: it changes what the world LOOKS like and --- nothing about what it IS. +-- Voxel mode is presentational: it changes what the world LOOKS like and +-- nothing about what it IS. ONE rung is the deliberate exception. 1ST -- +-- the first-person camera -- replaces the grid WALK with a free, +-- camera-relative one while it is selected (lib/FreeMove.lua), because a +-- head you can steer with a mouse demands feet that go where it looks. +-- Even there the game is untouched: the walk asks the engine's own +-- collision the same questions a grid step asks, keeps the player's +-- logical cell synced, and fires the engine's own landing pipeline per +-- cell crossed -- warps, encounters, ledges, gates and scripts all run +-- exactly as themselves. Step off the rung and the grid walk is back. local mod = ... @@ -83,6 +90,8 @@ local DayNight = V.require("DayNight") local DayTint = V.require("DayTint") local Water = V.require("Water") local AntiAlias = V.require("AntiAlias") +local FirstPerson = V.require("FirstPerson") +local FreeMove = V.require("FreeMove") -- Forward declaration: the voxel pipeline's update hook (registered below) -- calls this, and it is defined further down with the settings it drives. @@ -162,6 +171,11 @@ mod.content.render_pipelines:register("voxel", { -- would fight anyone who changed one deliberately. applyFull(level) Voxel.update(dt, level) + -- the first-person head, on the same tick: its blend in and out of the + -- orbit, the mouse capture lifecycle, and the frame's stick-rate look. + -- Unconditional like Voxel.update, because the blend has to keep easing + -- OUT after the rung is left + FirstPerson.update(dt) -- the day/night clock, on the same always-running tick: Pipelines.update -- runs whatever the level, so time passes with the mode off, through -- battles and menus, and a CYCLE evening falls mid-fight exactly as it @@ -789,6 +803,32 @@ end -- so this file keeps naming every engine seam the mod touches. OverworldBattle.install() +-- ------- the first-person rung's inputs and its walk +-- +-- 1ST needs two things no other rung does, and each is a named seam: +-- +-- FirstPerson.install claims the LOOK inputs the engine ignores: the right +-- stick's axes (Game:gamepadaxis passes them to Input, which returns early +-- on anything but the left pair), relative mouse motion (love.mousemoved -- +-- there is no Game handler to wrap; the engine's own callback only feeds +-- the mouse-as-touch debug path, which stays untouched), the mouse buttons +-- while the cursor is captured (A and B -- there is no cursor to click UI +-- with), and any touch that lands off the overlay's controls (a drag on +-- open screen is the look; the d-pad and buttons still go to +-- TouchControls, whose own d-pad finger is also read back analog as the +-- move vector). Every wrap forwards whatever it does not claim, and claims +-- only while 1ST is actually driving. +-- +-- FreeMove.install wraps OverworldState:handleInput -- the one choke point +-- where the grid walk reads the pad, and the same seam the engine's own +-- Cycling Road pull lives behind. While 1ST drives, the walk is continuous +-- and camera-relative; the player's logical cell stays synced and every +-- per-cell consequence still runs through the engine's own machinery +-- (onStepComplete, checkEdgeExit, checkLedgeHop, checkBoulderPush). The +-- file argues the whole arrangement. +FirstPerson.install() +FreeMove.install() + -- The overworld's own pushBattle is the choke point for a wild encounter or -- a trainer, and it is wrapped. A battle that arrives some other way -- a -- link battle, a script pushing a BattleState directly -- reaches this @@ -890,7 +930,7 @@ mod.hooks:wrap("world.tod", function(next, tod, ctx) return DayNight.tod() end) -mod.exports.version = "1.4.3" +mod.exports.version = "1.5.0" -- exposed so a companion mod can pin its own tiles' shapes or read the -- camera without reaching into this mod's file layout mod.exports.lib = V diff --git a/manifest.json b/manifest.json index 1f6184c..8ea928d 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "DRAMATIC_SHAPE", "name": "Dramatic Shape Voxel Mod", - "version": "1.4.3", + "version": "1.5.0", "api": 2, "entry": "main.lua", "profile": "content", diff --git a/mod.card b/mod.card index af77b03..08ec65a 100644 --- a/mod.card +++ b/mod.card @@ -16,9 +16,11 @@ return { "a battle's letterbox voids go black rather than white, because the battle canvas is no longer white", "the engine's TILT and GBC FX rows are taken OFF the OPTIONS menu and held at off for as long as this mod is installed -- TILT is the flat fake of what this mode does for real, GBC FX is a full-screen pass over the top of it; uninstalling puts both rows back", "hotkeys 3 and 5 are taken over from those two, which have no key and no row while this is loaded", + "on the 1ST rung ONLY, the grid walk is replaced by free camera-relative movement: collision, warps, ledges, encounters and scripts still run through the engine's own machinery, and every other rung leaves movement untouched", + "on the 1ST rung the mouse cursor is captured for free look; left click is A, right click is B, and any touch off the overlay's controls drags the view", }, added = { - "VOXEL options row and hotkey 3 (OFF / 15 / 35 / 50 / 75 degrees)", + "VOXEL options row and hotkey 3 (OFF / 15 / 35 / 50 / 75 degrees / 1ST, a first-person camera with free look and free movement)", "T-SHIFT options row and hotkey 6 (OFF / 1 / 2 / 3), the miniature blur", "V-GRID on hotkey 5 and V-CURVE on hotkey 7", "WATER on hotkey 9 (FULL / SKY / OFF, FULL by default): the water surface becomes a field of pixel-tall voxel columns rising and falling as waves, reflecting the sky, the sun, the moon and the cast standing beside it -- and, on FULL, the shoreline, trees and buildings behind it, by a screen-space ray march", @@ -39,6 +41,9 @@ return { "the battle backdrop renders at the GB's 160x144 to match the pics composited over it, so it is chunkier than the free-roam pass", "menus and cutscenes are unaffected -- outside a battle the mode only draws the free-roam overworld", "terrain meshes are cached per map, so the first frame after entering a large map costs a build", + "1ST needs the 3D pass like every rung; without it the level still persists but the world stays 2D and the grid walk stays in charge", + "in 1ST, scripted walks, ledge hops and spinner slides play out as the grid moves they are, with the camera riding along; free control resumes when they land", + "rooms have no ceilings, so a first-person look over an interior wall shows the void the diorama always had behind it", }, }, credits = { diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua index 703763a..032b297 100644 --- a/tests/dramatic_shape_test.lua +++ b/tests/dramatic_shape_test.lua @@ -49,12 +49,14 @@ T.eq(defs._owners and defs._owners.voxel, "DRAMATIC_SHAPE", -- ------- the ladders the engine drives -T.eq(#defs.voxel.levels, 6, "voxel exposes a six-rung ladder") +T.eq(#defs.voxel.levels, 7, "voxel exposes a seven-rung ladder") T.eq(defs.voxel.levels[1], "OFF", "rung 0 is OFF") T.eq(defs.voxel.levels[2], "FULL", "FULL is the first rung after OFF -- the order those two get used in") -T.eq(defs.voxel.levels[6], "75", "the top rung is the 75-degree camera") -T.eq(Pipelines.maxLevel("voxel"), 5, "the engine reads the ladder height") +T.eq(defs.voxel.levels[6], "75", "rung 5 is the 75-degree camera") +T.eq(defs.voxel.levels[7], "1ST (EXPERIMENTAL)", + "the top rung is the first-person camera, labelled as the experiment it is") +T.eq(Pipelines.maxLevel("voxel"), 6, "the engine reads the ladder height") T.eq(Pipelines.levelLabel("voxel", 3), "35", "the engine reads the rung labels") -- ------- gating: inert until switched on, and inert without a GPU @@ -1079,12 +1081,12 @@ local Curve = run.loader.exports.DRAMATIC_SHAPE.lib.require("WorldCurve") -- with nothing on screen saying a keypress had done it. Pipelines.setLevel("voxel", 0) local walk = {} -for _ = 1, 6 do +for _ = 1, 7 do Game.keypressed(keyGame, "3") walk[#walk + 1] = Pipelines.levelLabel("voxel") end -T.eq(table.concat(walk, ","), "15,35,50,75,OFF,15", - "3 walks OFF -> 15 -> 35 -> 50 -> 75 and wraps, never touching FULL") +T.eq(table.concat(walk, ","), "15,35,50,75,1ST (EXPERIMENTAL),OFF,15", + "3 walks OFF -> 15 -> 35 -> 50 -> 75 -> 1ST and wraps, never touching FULL") -- FULL is 35 degrees, so a press from it goes ON to 50 rather than back to -- the rung that shows the same camera -- the key never appears to do nothing. @@ -1143,8 +1145,8 @@ T.eq(GBCFX.level, 0, "and on the live renderer") -- TILT with or without us. Park the ladder on its top rung and turn both -- back on, so the single press under test is the one that wraps to OFF -- -- where nothing else is going to clear them. --- 5 is the "75" rung, the last one the key walks before it wraps to OFF -Pipelines.setLevel("voxel", 5) +-- 6 is the "1ST" rung, the last one the key walks before it wraps to OFF +Pipelines.setLevel("voxel", 6) Tilt.setLevel(3) GBCFX.setLevel(4) keyGame.save.options.tilt = 3 @@ -3336,7 +3338,10 @@ Commands.start_battle(ctx, "trainer", "RIVAL1", 1) T.check(viaOverworld ~= nil and pushed == nil, "a scripted trainer goes through pushBattle: the flash, the wipe and the " .. "theme, like any fight walked into") -T.eq(viaOverworld.kind, "trainer", "with the battle it was asked to start") +-- guarded index: when the seam above regresses, this must FAIL like any +-- assertion rather than crash the suite half-run +T.eq(viaOverworld and viaOverworld.kind, "trainer", + "with the battle it was asked to start") ctx.overworld = nil Commands.start_battle(ctx, "wild", "PIDGEY", 5) T.check(pushed ~= nil, @@ -3462,6 +3467,182 @@ T.eq(g.amp, 0, "standing still fades it back out within a beat") T.eq(g.phase, held, "and the phase does not move while the camera does not") end +-- ------- the first-person rung +-- +-- 1ST rides the same placed-camera seam the battle proved out, so most of +-- what it adds is arithmetic this suite can hold still: the rig built from +-- a pose, the compass the grid game still thinks in, the camera-relative +-- walk vector, and the frame an NPC shows an eye that can stand anywhere. + +do +local FirstPerson = + run.loader.exports.DRAMATIC_SHAPE.lib.require("FirstPerson") +local VoxelState = run.loader.exports.DRAMATIC_SHAPE.lib.require("VoxelState") +local FreeMove = run.loader.exports.DRAMATIC_SHAPE.lib.require("FreeMove") +local Voxel3D = run.loader.exports.DRAMATIC_SHAPE.lib.require("Voxel3D") + +T.eq(VoxelState.FP_LEVEL, 6, "1ST is the seventh rung") +T.check(VoxelState.isFirstPerson(6), "and isFirstPerson answers for it") +T.check(not VoxelState.isFirstPerson(5), "but not for the 75 orbit") +T.eq(VoxelState.ANGLE_LABELS[VoxelState.FP_LEVEL + 1], "1ST (EXPERIMENTAL)", + "the rung wears the experimental label") +T.eq(VoxelState.ANGLES_DEG[VoxelState.FP_LEVEL + 1], 75, + "and hands the blend the 75-degree orbit as its far end") + +-- ------- the compass and the walk vector +-- +-- Yaw 0 faces south (+Z), the way a resting sprite faces; the compass is +-- the dominant axis and the walk rotates camera space into world space. +FirstPerson.yaw, FirstPerson.pitch = 0, 0 +T.eq(FirstPerson.compassFacing(), "down", "yaw 0 looks south") +FirstPerson.yaw = math.pi / 2 +T.eq(FirstPerson.compassFacing(), "right", "a quarter turn looks east") +FirstPerson.yaw = math.pi +T.eq(FirstPerson.compassFacing(), "up", "a half turn looks north") +FirstPerson.yaw = -math.pi / 2 +T.eq(FirstPerson.compassFacing(), "left", "and three quarters looks west") + +local function near(a, b) return math.abs(a - b) < 1e-9 end + +FirstPerson.yaw = 0 +local wx, wz = FirstPerson.moveWorld(0, 1) +T.check(near(wx, 0) and near(wz, 1), "facing south, forward walks south") +wx, wz = FirstPerson.moveWorld(1, 0) +T.check(near(wx, -1) and near(wz, 0), + "facing south, the right hand points west") +FirstPerson.yaw = math.pi +wx, wz = FirstPerson.moveWorld(0, 1) +T.check(near(wx, 0) and near(wz, -1), "facing north, forward walks north") +wx, wz = FirstPerson.moveWorld(1, 0) +T.check(near(wx, 1) and near(wz, 0), + "facing north, the right hand points east") + +-- the look clamps: pitch stops at the limits, yaw wraps +FirstPerson.pitch = 0 +FirstPerson.lookBy(0, 100) +T.eq(FirstPerson.pitch, FirstPerson.PITCH_DOWN, "pitch clamps looking down") +FirstPerson.lookBy(0, -100) +T.eq(FirstPerson.pitch, FirstPerson.PITCH_UP, "and looking up") +FirstPerson.yaw = 0 +FirstPerson.lookBy(2 * math.pi, 0) +T.check(math.abs(FirstPerson.yaw) < 1e-9, "a full turn of yaw wraps to zero") + +-- ------- the rig +-- +-- frame() is pure arithmetic over the pose and the orbit, so the suite can +-- stand the blend anywhere and read the record it hands Voxel3D. +FirstPerson.yaw, FirstPerson.pitch = 0, 0 +FirstPerson.blend = 1 +local me = { px = 100, py = 200, gh = 0, lift = 0 } +local rig, sx, sy = FirstPerson.frame(me, 500, 600, 320, 288) +T.check(rig ~= nil, "with the blend in, frame() builds a rig") +T.eq(Voxel3D.camera, rig, "and hands it to Voxel3D") +T.check(near(rig.eye[1], 108) and near(rig.eye[3], 208), + "the eye stands on the player's centre") +T.eq(rig.eye[2], FirstPerson.EYE_HEIGHT, "at head height") +T.check(near(rig.fov, FirstPerson.FOV), "wearing the first-person lens") +T.check(near(sx, 108) and near(sy, 208), + "and the scene centre stands with it") +T.check(rig.curve == 0, + "the world curve is declined outright -- a zero, not a nil the setting " + .. "could override") +T.check(rig.focus[3] > rig.eye[3], + "yaw 0 focuses south of the eye") + +-- surf bob and ledge lift carry the eye with them +local bobbed = FirstPerson.frame({ px = 100, py = 200, gh = 4, lift = 6 }, + 500, 600, 320, 288) +T.eq(bobbed.eye[2], 10 + FirstPerson.EYE_HEIGHT, + "ground height and lift both raise the eye") + +-- mid-blend, the rig is a lerp of the orbit and the head: its eye sits +-- between the two ends, and the curve is only half declined +VoxelState.angle = math.rad(75) +FirstPerson.blend = 0.5 +local mid = FirstPerson.frame(me, 500, 600, 320, 288) +T.check(mid.eye[2] > FirstPerson.EYE_HEIGHT, + "half way out, the eye is higher than the head") +T.check(mid.eye[2] < 288 * VoxelState.FOCAL, + "and lower than the orbit") + +-- ------- the cards ask the rig, and only the rig +FirstPerson.blend = 1 +FirstPerson.frame(me, 500, 600, 320, 288) +T.check(FirstPerson.cardBlend() == 1, "the free-roam rig turns the cards") +T.check(FirstPerson.hidePlayer(), "and hides the player's own card") + +-- an NPC south of the eye: the card yaws to face north, back at the eye +local yaw = FirstPerson.cardYaw(108, 300) +T.check(near(math.sin(yaw), 0) and near(math.cos(yaw), -1), + "a card south of the eye turns its face north") + +-- the frame an entity SHOWS this eye: stand north of someone facing away +-- and you see their back; face to face you see their front; flanks show +-- profiles, named by which flank is toward you +T.eq(FirstPerson.apparentFacing("down", 108, 300), "up", + "an NPC facing south, seen from the north, shows their back") +T.eq(FirstPerson.apparentFacing("up", 108, 300), "down", + "an NPC facing north, seen from the north, shows their face") +T.eq(FirstPerson.apparentFacing("right", 108, 300), "left", + "an NPC facing east, seen from the north, shows their left flank") +T.eq(FirstPerson.apparentFacing("left", 108, 300), "right", + "an NPC facing west, seen from the north, shows their right flank") + +-- another camera on the same seam -- the battle's placed shot -- and the +-- cards stand down: blend still 1, but it is not our rig drawing +local battleCam = { eye = { 0, 40, 120 }, focus = { 0, 8, 0 }, + fov = math.rad(30) } +Voxel3D.camera = battleCam +T.eq(FirstPerson.cardBlend(), 0, + "a battle camera on the seam turns no cards") +T.check(not FirstPerson.hidePlayer(), + "and hides nobody") + +-- blend fully out: frame() clears only a camera that is still ours +FirstPerson.blend = 0 +T.eq(FirstPerson.frame(me, 500, 600, 320, 288), nil, + "with the blend out, frame() answers nil and the orbit rules") +T.eq(Voxel3D.camera, battleCam, + "without touching a camera some other pass placed") +Voxel3D.camera = nil + +-- ------- the free walk's per-cell verdict +-- +-- The same questions Collision asks a grid step, asked per cell the body +-- overlaps -- through a map stub shaped like the engine's own. +local blocked = FreeMove._blockedCell +local stubMap = { + def = { tileset = "OVERWORLD" }, + inBounds = function(self, x, y) + return x >= 0 and y >= 0 and x < 10 and y < 10 + end, + isWalkableCell = function(self, x, y) return x ~= 3 end, + isWaterCell = function(self, x, y) return x == 3 and y == 3 end, + cellTile = function() return 0 end, +} +local p = { cellX = 5, cellY = 5, surfing = false } +local state = { map = stubMap, entities = {} } + +T.eq(blocked(state, p, 5, 5), nil, "the body's own cell never refuses it") +T.eq(blocked(state, p, 6, 5), nil, "an open neighbour admits it") +T.eq(blocked(state, p, 3, 5), "tile", "an unwalkable cell refuses it") +T.eq(blocked(state, p, -1, 5), "bounds", "off the map is the edge's answer") +T.eq(blocked(state, p, 3, 3), "tile", "water refuses a walker") +p.surfing = true +T.eq(blocked(state, p, 3, 3), nil, "and admits a surfer") +p.surfing = false + +state.entities = { { cellX = 6, cellY = 5 } } +T.eq(blocked(state, p, 6, 5), "entity", "an occupied cell refuses the body") +state.entities = { { cellX = 7, cellY = 5, targetX = 6, targetY = 5 } } +T.eq(blocked(state, p, 6, 5), "entity", + "and so does one an NPC is mid-step into") + +-- the ladder's own state, put back the way the suite found it +FirstPerson.blend = 0 +VoxelState.reset() +end + Pipelines.reset() run.release() diff --git a/tests/fp_shots.lua b/tests/fp_shots.lua new file mode 100644 index 0000000..e972bfe --- /dev/null +++ b/tests/fp_shots.lua @@ -0,0 +1,159 @@ +-- Scratch driver: shots of the 1ST (first-person) rung -- the rig standing +-- in the player's head, billboards yawing to face it, the sky meeting the +-- horizon, the shadow box following the look, water seen from eye level, +-- and an interior with its figures. +-- +-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/fp_shots.lua \ +-- SHOT_DIR=.scratchpad/fpshots lovec.exe . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pipelines = require("src.render.Pipelines") + + local ROOT = (os.getenv("SHOT_DIR") or "shots/fp") + + local handle = game.mods.exports["DRAMATIC_SHAPE"] + if not (handle and handle.lib) then + print("[fp] DRAMATIC_SHAPE mod not loaded") + return love.event.quit() + end + local V = handle.lib + local FirstPerson = V.require("FirstPerson") + local ChunkMesher = V.require("ChunkMesher") + local Voxel = V.require("VoxelState") + local DayNight = V.require("DayNight") + + pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null') + pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul') + + require("src.world.OverworldController").rollEncounter = function() return nil end + local TileRenderer = require("src.render.TileRenderer") + TileRenderer.tick = function() end + TileRenderer.animFrame = function() return 0 end + DayNight.setting:sync("day") + + local Zoom = require("src.render.Zoom") + pcall(function() + game.save.options.zoom = 0 + Zoom.applyOptions(game.save.options) + end) + + local function settle() + for _ = 1, 900 do + if ChunkMesher.pending() == 0 then break end + U.wait(1) + end + for _ = 1, 300 do + if FirstPerson.blend >= 1 and Voxel.ready + and ChunkMesher.pending() == 0 then break end + U.wait(1) + end + U.wait(40) + end + + -- Teleport to the nearest WALKABLE cell: a guessed coordinate inside a + -- building footprint buries the eye in the geometry, which is what the + -- first cut of every Pallet shot did. + local function place(mapId, x, y) + U.teleport(game, mapId, x, y, "down") + local ow = game.stack:top() + local map = ow and ow.map + if not map or map:isWalkableCell(x, y) then return end + for r = 1, 8 do + for dy = -r, r do + for dx = -r, r do + if math.max(math.abs(dx), math.abs(dy)) == r then + local cx, cy = x + dx, y + dy + if map:inBounds(cx, cy) and map:isWalkableCell(cx, cy) then + U.teleport(game, mapId, cx, cy, "down") + print(("[fp] (%d,%d) not walkable; standing at (%d,%d)") + :format(x, y, cx, cy)) + return + end + end + end + end + end + end + + -- yaw is a world bearing: 0 south, pi/2 east, pi north, -pi/2 west + local SCENES = { + -- Pallet Town, mid-street: houses, the lab, NPCs -- the town seen + -- from inside it, in each compass direction plus a diagonal + { map = "PALLET_TOWN", x = 13, y = 14, yaw = math.pi, label = "pallet_north" }, + { map = "PALLET_TOWN", x = 13, y = 14, yaw = 0, label = "pallet_south" }, + { map = "PALLET_TOWN", x = 9, y = 7, yaw = math.pi / 2, label = "pallet_east" }, + { map = "PALLET_TOWN", x = 9, y = 7, yaw = 3 * math.pi / 4, + label = "pallet_diag" }, + -- the shoreline: water at eye level, which is where the battle pass + -- says a low placed camera reads the reflection wrong -- the shot + -- decides whether 1ST keeps it + { map = "PALLET_TOWN", x = 9, y = 12, yaw = 0, label = "pallet_water" }, + -- looking up: the sky's bands and the horizon line + { map = "PALLET_TOWN", x = 13, y = 14, yaw = math.pi, + pitch = -math.rad(25), label = "pallet_skyward" }, + -- and down: the ground, the feet-level shadow + { map = "PALLET_TOWN", x = 13, y = 14, yaw = math.pi, + pitch = math.rad(45), label = "pallet_down" }, + -- Route 1: grass rows and ledges from inside them + { map = "ROUTE_1", x = 10, y = 28, yaw = math.pi, label = "route1_north" }, + -- an interior: the Center's counter, machines and couch figures + { map = "VIRIDIAN_POKECENTER", x = 3, y = 5, yaw = math.pi, + label = "center_north" }, + { map = "VIRIDIAN_POKECENTER", x = 6, y = 4, yaw = -math.pi / 2, + label = "center_west" }, + } + + local shots = 0 + for _, s in ipairs(SCENES) do + place(s.map, s.x, s.y) + Pipelines.setLevel("voxel", Voxel.FP_LEVEL) + Pipelines.setLevel("tiltshift", 0) + settle() + FirstPerson.yaw = s.yaw + FirstPerson.pitch = s.pitch or FirstPerson.PITCH_DEFAULT + U.wait(20) + if U.shot(game, ("%s/%s.png"):format(ROOT, s.label)) then + shots = shots + 1 + end + end + + -- one mid-blend shot: step the ladder onto 1ST from 75 and catch the + -- dive halfway + place("PALLET_TOWN", 13, 14) + Pipelines.setLevel("voxel", 5) + settle() + Pipelines.setLevel("voxel", Voxel.FP_LEVEL) + for _ = 1, 300 do + if FirstPerson.blend >= 0.5 then break end + U.wait(1) + end + if U.shot(game, ROOT .. "/blend_mid.png") then shots = shots + 1 end + + -- ------- the free walk, exercised + -- + -- Hold forward with the head yawed off-grid and confirm the player + -- GLIDES: the position moves along the look direction, lands off the + -- 16px grid (which no grid step can do), and the logical cell follows. + place("PALLET_TOWN", 13, 14) + Pipelines.setLevel("voxel", Voxel.FP_LEVEL) + settle() + local ow = game.stack:top() + local p = ow.player + FirstPerson.yaw = 3 * math.pi / 4 -- northeast, deliberately off-grid + FirstPerson.pitch = FirstPerson.PITCH_DEFAULT + local x0, y0, c0x, c0y = p.px, p.py, p.cellX, p.cellY + U.hold(game, "up", 90) + U.wait(5) + local moved = math.abs(p.px - x0) + math.abs(p.py - y0) + print(("[fp] walk: (%.1f,%.1f) cell(%d,%d) -> (%.1f,%.1f) cell(%d,%d)") + :format(x0, y0, c0x, c0y, p.px, p.py, p.cellX, p.cellY)) + print(("[fp] walk moved %.1f px; off-grid: %s; diagonal: %s") + :format(moved, + tostring(p.px % 16 ~= 0 or p.py % 16 ~= 0), + tostring(math.abs(p.px - x0) > 8 + and math.abs(p.py - y0) > 8))) + if U.shot(game, ROOT .. "/walked.png") then shots = shots + 1 end + + print(("[fp] %d shots into %s"):format(shots, ROOT)) + love.event.quit() +end From 3f7210bfcd9f01ce6a5fc74a7479a7021157e15a Mon Sep 17 00:00:00 2001 From: DramaticShape Date: Sun, 2 Aug 2026 01:20:39 -0400 Subject: [PATCH 023/116] mobile spin fix --- lib/FirstPerson.lua | 39 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/lib/FirstPerson.lua b/lib/FirstPerson.lua index 7722adb..d1019b4 100644 --- a/lib/FirstPerson.lua +++ b/lib/FirstPerson.lua @@ -528,13 +528,35 @@ function FirstPerson.install() -- pair on the same class of device. Real gamepads are excluded -- they -- already spoke through the mapped rightx/righty above, and their RAW -- axis 3 is as likely a trigger as a stick. + -- + -- Two more exclusions, both learned the hard way on Android, where this + -- wrap runs BEFORE the engine's own generic-joystick guards: + -- + -- the accelerometer arrives as a joystick named for what it is, with + -- gravity pinning an axis well past any deadzone -- the same device + -- Game:joystickaxis refuses for movement (#459), refused here by the + -- same name test, or the view spins on its own the moment 1ST opens. + -- + -- and a raw axis is only BELIEVED after it has been seen near centre + -- once. A stick at rest sits at zero, so a real one earns trust with + -- its first touch; a gravity-pinned sensor axis or a trigger resting + -- at an extreme never centres and so never steers the look. + local function isAccelerometer(joystick) + local ok, name = pcall(function() return joystick:getName() end) + return ok and type(name) == "string" + and name:lower():find("accelerometer", 1, true) ~= nil + end + local rawCentred = {} do local inner = Game.joystickaxis function Game:joystickaxis(joystick, axis, value) local mapped = joystick and joystick.isGamepad and joystick:isGamepad() - if not mapped then - if axis == 3 then stick.x = value - elseif axis == 4 then stick.y = value end + if not mapped and (axis == 3 or axis == 4) + and not isAccelerometer(joystick) then + if math.abs(value) < 0.3 then rawCentred[axis] = true end + if rawCentred[axis] then + if axis == 3 then stick.x = value else stick.y = value end + end end return inner(self, joystick, axis, value) end @@ -675,6 +697,17 @@ function FirstPerson.install() return inner(self, f) end end + -- a disconnected controller cannot send the centering event for whatever + -- its stick last held -- the engine drops all input state here, and the + -- look rate (plus the raw axes' earned trust) goes with it + do + local inner = Game.joystickremoved + function Game:joystickremoved(joystick) + stick.x, stick.y = 0, 0 + rawCentred[3], rawCentred[4] = nil, nil + return inner(self, joystick) + end + end end return FirstPerson From 238cf608928b5f6caf2e70d55e1325e71eba4ec5 Mon Sep 17 00:00:00 2001 From: absol89 Date: Sun, 2 Aug 2026 09:50:07 +0200 Subject: [PATCH 024/116] Add Battle Art modes and staged battle rendering --- CHANGELOG.md | 58 ++- README.md | 81 +++- assets/battle/back-animated/README.md | 44 +- assets/battle/back-static/README.md | 51 ++- assets/battle/front-animated/README.md | 11 +- assets/battle/front-static/README.md | 15 +- data/animated_battle_sprites_gen4.lua | 457 +++++++++++++++++++++ data/animated_player_trainers.lua | 21 + lib/AnimatedBattleArt.lua | 171 +++++++- lib/BattleArt.lua | 148 ++++++- lib/BattleCam.lua | 7 +- lib/BattleExit.lua | 13 +- lib/BattleHud.lua | 49 ++- lib/BattleScene.lua | 12 + lib/OverworldBattle.lua | 145 +++++-- lib/TiltShift.lua | 8 +- lib/Voxel3D.lua | 8 + main.lua | 52 ++- manifest.json | 2 +- tests/dramatic_shape_test.lua | 25 +- tools/extract_purchased_static_art.py | 228 ++++++++++ tools/import_animated_sprites.py | 45 +- tools/import_sugimori_static.py | 149 +++++++ tools/package_mod.ps1 | 18 +- tools/player-animation-template-400x80.png | Bin 0 -> 1129 bytes 25 files changed, 1643 insertions(+), 175 deletions(-) create mode 100644 data/animated_battle_sprites_gen4.lua create mode 100644 data/animated_player_trainers.lua create mode 100644 tools/extract_purchased_static_art.py create mode 100644 tools/import_sugimori_static.py create mode 100644 tools/player-animation-template-400x80.png diff --git a/CHANGELOG.md b/CHANGELOG.md index e7f6d44..6c1850a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,27 +5,61 @@ ### Added - **Zero-configuration bring-your-own battle PNGs.** `BATTLE ART: STATIC` - looks in `front-static` and `back-static`, uses native image dimensions, + looks in `front-static` and the selected `back-static/gen1` through `gen5`, + uses native image dimensions, preserves alpha or keys a border-connected corner matte, and falls back per species to ROM art. No Pokémon art is distributed or tracked. -- **World-placed player front/back choice.** `PLAYER VIEW` replaces the old - UI-pinned back-sprite toggle. Both views use the depth buffer, world and - day/night tint, display filtering, hit flash and alpha-shaped shadow. +- **World-placed player front/back choice.** `PLAYER: FRONT SPRITES` and + `PLAYER: BACK SPRITES` replace the old global UI-pinned back-sprite toggle. + Supplied views use the depth buffer, world and day/night tint, display + filtering, hit flash and alpha-shaped shadow; a missing selected generated + back deliberately retains the ROM sprite in the UI layer. - **Private test packaging.** The four battle-art folders ignore local art in Git while `tools/package_mod.ps1` deliberately includes those PNGs in a ZIP. -- **Static trainer cards.** Opponent trainer classes resolve from - `front-static`; the player, Professor Oak, and Old Man backs resolve from - `back-static`. Trainer art never uses either animated folder. +- **Selectable static opponent trainer cards.** `TRAINER ART: GEN 1 / GEN 2 / GEN 3` + resolves opponent classes from matching `front-static` subfolders, with a + direct per-class ROM fallback and no cross-generation mixing. The player, + Professor Oak, and Old Man backs still resolve from `back-static`. +- **Selectable player trainer portrait.** `PLAYER ART` defaults to generic + PNG, and also chooses GEN 1–5, ASH, GARY, or ROM for the normal player + battle intro. Missing named art tries `player.png` before ROM. It is + independent of species `BATTLE ART`; Oak and Old Man retain their scripted + filenames. +- **One-shot animated player intros.** `PLAYER ANIM` selects GEN 1–5, ASH, + GARY, or ROM five-pose player strips in ANIMATED mode. Frame one holds until + the engine begins its leftward intro slide; the remaining poses advance + with that slide once and never loop. AUTO uses OG UI, explicit back + placement still overrides it, and a missing or malformed strip retains the + ROM portrait. Five equal cells are decoded at native resolution, supporting + both existing 320-pixel and normalized 400-pixel strips. Custom frames stay + 1x on OG UI; only the ROM's half-resolution player back keeps its normal 2x. - **Native-resolution animated atlas playback.** `BATTLE ART: ANIMATED` extracts timed PNG-atlas cells through `ImageData`, avoiding canvas DPI scaling, and sends every frame through the same transparency, display filtering, world lighting, depth and shadow path as static art. Missing or malformed atlases fall back per Pokemon to ROM art. -- **Independent animated front/back sets.** `FRONT GEN` and `BACK GEN` each - select GEN 2, GEN 3, or GEN 5 from matching generation subfolders, and are - shown in-game only under ANIMATED; STATIC and ROM ignore them. Missing art - falls directly back to ROM instead of mixing visual styles. One importer builds all three - formats; only generated dimensions/timings are committed, never art. +- **Independent front/back sets.** `ANIM FRONT GEN` selects animated GEN 2, + GEN 3, GEN 4, or GEN 5 fronts. `BACK ART SET` selects the static GEN 1–5 folder in + STATIC mode; ANIMATED uses static GEN 1–4 PNGs or animated GEN 5 atlases. + The mode always decides whether atlas decoding is allowed. Supplied backs + are world geometry; missing or malformed art falls back to ROM. +- **Independent back placement.** `BACK PLACEMENT: AUTO / WORLD / OG UI` + separates an art set from its presentation. AUTO keeps STATIC fallbacks in + the world, supplied ANIMATED backs in the world, and ROM/ANIMATED fallbacks + on gen1recomp's OG UI anchor; the other choices force either layer. +- **Transparent-HUD contrast.** White battle HUD ink now receives a crisp + one-logical-pixel dark shadow, improving readability without restoring the + translucent backplates. Ink remains white through trainer intros, send-out, + active battle, and post-battle frames instead of switching through gray. + The obsolete zero-alpha frost composite is skipped entirely, preventing a + driver-dependent dim veil over the arena. +- **Untinted static front illustrations.** Supplied species PNGs from + `front-static` retain their authored brightness and colour instead of being + multiplied by the day/night tint. They remain world geometry with display + filtering, hit effects, depth occlusion, and alpha-shaped shadows. +- **Strict ROM player fallback.** `BATTLE ART: ROM` now restores the engine's + player portrait even when a named `PLAYER ART` choice was previously saved. + The PLAYER ART row remains visible in ROM mode and is pinned to ROM. - **Crystal and Emerald front collections.** The local imports cover #001-#151 while preserving every source frame's native canvas and delay. Gen 5 keeps its existing animated fronts and backs in the same folder convention. diff --git a/README.md b/README.md index e31b7a9..8b992e2 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Dramatic Shape Voxel Mod +# DRAMATIC SHAPE VOXEL MOD BATTLE ART A mod for the [Pokémon Gen 1 Recompilation Project](https://github.com/bryanthaboi/pokemon-gen1-recomp-project). @@ -35,9 +35,14 @@ menu. | `7`, or the **V-CURVE** options row | OFF → 1 → 2 → 3 — bend the world over the horizon | | `8`, or the **3D-BTL** options row | ON / OFF — fight on the map instead of on a white field | | the **BATTLE ART** options row | STATIC / ANIMATED / ROM — use optional battle-only art, with a direct ROM fallback when a file or atlas is absent | -| the **FRONT GEN** options row | GEN 2 / GEN 3 / GEN 5 — choose the animated front collection | -| the **BACK GEN** options row | GEN 2 / GEN 3 / GEN 5 — independently choose the animated back collection | -| the **PLAYER VIEW** options row | FRONT / BACK — choose the player's view while keeping it world-placed, lit, shadowed and depth-occluded | +| the **TRAINER ART** options row | GEN 1 / GEN 2 / GEN 3 — choose a static opponent-trainer collection | +| the **PLAYER ART** options row | PNG / GEN 1 / GEN 2 / GEN 3 / GEN 4 / GEN 5 / ASH / GARY / ROM — choose the static player trainer battle-intro portrait; BATTLE ART: ROM pins it to ROM | +| the **PLAYER ANIM** options row | GEN 1 / GEN 2 / GEN 3 / GEN 4 / GEN 5 / ASH / GARY / ROM — choose a five-pose player intro while ANIMATED is selected | +| the **ANIM FRONT GEN** options row | GEN 2 / GEN 3 / GEN 4 / GEN 5 — choose the animated front collection | +| the **BACK ART SET** options row | GEN 1 / GEN 2 / GEN 3 / GEN 4 / GEN 5 — STATIC always uses generation PNGs; ANIMATED uses Gen 1–4 PNGs or Gen 5 atlases | +| the **PLAYER** options row | FRONT SPRITES / BACK SPRITES — supplied art is world-placed; a missing selected back uses the ROM's UI-attached pic | +| the **BACK PLACEMENT** options row | AUTO / WORLD / OG UI — use the mode-aware default or force every player back onto one layer | +| battle HUD ink | HUD and dialogue glyphs stay white through every battle phase and receive a one-pixel dark shadow over transparent panels | | the **DAYTIME** options row | SYNC / DAY / NIGHT / DUSK / DAWN / CYCLE — what time it is outdoors, on the diorama *and* on the flat 2D world; held at SYNC (and off the menu) while VOXEL is FULL | **3D-BTL** is on by default and is independent of **VOXEL**: battles draw @@ -45,8 +50,9 @@ on the world whether or not the free-roam camera is pitched over. ## Bring your own battle art -`BATTLE ART: STATIC` is the default. Drop a PNG named for the species into -`assets/battle/front-static` or `assets/battle/back-static`; for example, +`BATTLE ART: STATIC` is the default. Drop a front PNG named for the species +into `assets/battle/front-static`, or a back PNG into the selected +`assets/battle/back-static/gen1` through `gen5` folder; for example, `caterpie.png`, `farfetchd.png`, or `mr-mime.png`. No Lua sidecar or fixed resolution is required, and the image is not resized. Existing alpha is preserved. For a fully opaque PNG, the corner-coloured background connected @@ -55,26 +61,57 @@ left intact. Enemy front art is used as authored, facing left. Player front art is mirrored to face right; authored player back art already faces right and is not -mirrored. Every imported sprite remains a card in the 3D world, so the normal -world tint, day/night lighting, hit flash, depth occlusion and alpha-shaped -shadow apply. OG, inverted and CLASSIC display filters also apply to imported -art. Missing or unreadable files fall straight back to the ROM art. +mirrored. Every imported sprite remains a card in the 3D world, so hit flash, +depth occlusion and alpha-shaped shadows apply. Static species front +illustrations preserve their authored brightness instead of receiving the +day/night colour tint; other art continues to follow the hour. OG, inverted +and CLASSIC display filters still apply to imported art. Missing or unreadable +files fall straight back to the ROM art. The four battle-art directories are intentionally ignored by Git except for their README contracts. `tools/package_mod.ps1` nevertheless includes local PNGs from them in a test ZIP, so artwork can stay private and uncommitted. -`BATTLE ART: ANIMATED` reads independent `FRONT GEN` and `BACK GEN` choices -from the matching generation folders. These selectors are ignored by STATIC -and ROM modes and only appear on the in-game menu while ANIMATED is selected. -The authoring importer generates the PNG atlases and one -metadata table per set; the runtime extracts native-resolution cells without -a DPI-scaled canvas and falls back per missing or malformed atlas directly to -ROM art. - -Trainer cards are always static. Opponent trainer fronts use class filenames -in `front-static` (for example `youngster.png`, `cooltrainer-f.png`, and -`jessie-james.png`). Player-side trainer backs use `back-static/player.png`, -`back-static/oak.png`, or `back-static/old-man.png`. The folder READMEs carry +`BATTLE ART: ANIMATED` reads independent `ANIM FRONT GEN` and `BACK ART SET` +choices. Front choices remain animated atlases, including converted +Diamond/Pearl APNGs for GEN 4. Back GEN 1 through GEN 4 read +ordinary species PNGs from `back-static/gen1` through `back-static/gen4`; they +need no atlas or metadata. Back GEN 5 reads `back-animated/gen5` atlases. A +loaded back is world-placed, lit, depth-occluded and shadowed. If its selected +PNG or atlas is missing or malformed, the unmodified ROM backsprite remains in +the original UI layer, including its normal battle motion and filtering. +`BACK ART SET` also appears under STATIC. In that mode every choice, including +GEN 5, reads only `back-static//.png`; it never inspects +an animated atlas. GEN 1 remains selectable when its directory is absent so +users can create it for ROM-hack art. `ANIM FRONT GEN` remains exclusive to +ANIMATED, and ROM ignores both selectors. + +`BACK PLACEMENT: AUTO` keeps STATIC-mode player backs in the world, including +ROM fallbacks. Under ANIMATED, supplied Gen 1–4 PNGs and Gen 5 atlases stay in +the world while a missing selection leaves the ROM backsprite on its OG UI +anchor. ROM mode also uses OG UI. `WORLD` and `OG UI` override that decision +for testing any art set; large supplied art may crop when forced onto OG UI. + +Opponent trainer cards are always static. `TRAINER ART` chooses fronts from +`front-static/gen1`, `front-static/gen2`, or `front-static/gen3`, using class +filenames such as `youngster.png`, `cooltrainer-f.png`, and +`jessie-james.png`. A missing class falls directly back to its ROM portrait; +generations are never silently mixed. In STATIC mode, `PLAYER ART` selects +player trainer backs +such as `back-static/gen1player.png`, `back-static/ashplayer.png`, or ROM. +PNG is the default and reads `back-static/player.png`; every missing named +choice tries that same generic PNG before falling back to ROM. +In ANIMATED mode, `PLAYER ANIM` selects the corresponding five-frame strip +from `back-animated`, or the engine portrait under ROM. Frame one holds during +the stationary entrance pose. Frames two through five advance once with the +engine's leftward intro slide and then stop; they do not loop. AUTO placement +keeps this intro on OG UI, while WORLD and OG UI remain explicit overrides. +Both the original five-by-64 strips and normalized five-by-80 strips are read +at native resolution. Unlike the ROM's half-resolution player back, a custom +animated trainer remains 1x when attached to OG UI. Use +`tools/player-animation-template-400x80.png` as the authoring guide, then +remove its coloured dividers from the finished atlas. +`back-static/oak.png` and `back-static/old-man.png` remain the scripted demo +portraits. The folder READMEs carry the complete trainer-class list and the four exceptional Pokémon filenames. Two of the engine's own rows are taken away while this mod is installed: diff --git a/assets/battle/back-animated/README.md b/assets/battle/back-animated/README.md index f64eeba..87545f2 100644 --- a/assets/battle/back-animated/README.md +++ b/assets/battle/back-animated/README.md @@ -2,10 +2,41 @@ Optional animated player-back battle atlases are grouped here by generation. Local PNG files are ignored by Git. Authored back art faces right and is never -mirrored. Gen 5 currently supplies `gen5/` back atlases; Gen 2 and Gen 3 have -front metadata only, so `PLAYER VIEW: BACK` falls back to the ROM back sprite -when either is selected in `BACK GEN`. This selector only affects ANIMATED -mode; static back PNGs remain freely replaceable and mix-and-match. +mirrored. Only GEN 5 uses animated back atlases here. `BACK ART SET` choices +GEN 1 through GEN 4 instead read single-frame PNGs from sibling folders +`../back-static/gen1` through `../back-static/gen4`, with no atlas processing. +Missing selected art retains the ROM back sprite in its original UI layer. +This selector only affects ANIMATED mode. + +`BACK PLACEMENT` offers AUTO, WORLD, and OG UI. AUTO keeps a valid Gen 5 atlas +in the world and leaves a missing atlas's ROM fallback on OG UI. Forcing OG UI +is supported for comparison, but large atlas frames may crop there. + +This folder also accepts five-pose player-trainer strips selected by the +`PLAYER ANIM` row in ANIMATED mode: + +| Option | Filename | +| --- | --- | +| GEN 1 | `gen1player.png` | +| GEN 2 | `gen2player.png` | +| GEN 3 | `gen3player.png` | +| GEN 4 | `gen4player.png` | +| GEN 5 | `gen5player.png` | +| ASH | `ashplayer.png` | +| GARY | `garyplayer.png` | +| ROM | no file; use the engine portrait | + +Each PNG is one horizontal row of exactly five equal-width frames. Existing +320-pixel strips are read as five 64-pixel cells; the recommended 400x80 +format is five 80x80 cells. Copy poses into +`tools/player-animation-template-400x80.png`, but remove the coloured guide +dividers in the finished PNG. The first frame holds while the portrait is +stationary; frames two through five play once as the trainer slides left and +then stop. The animation never loops and is never resampled. Missing or +malformed selected art falls back to the ROM portrait. Under `BACK PLACEMENT: +AUTO`, player-trainer animation uses OG UI; WORLD and OG UI can override it. +Custom player frames remain 1x on OG UI, while its ROM fallback retains the +engine's intended 2x scale. GIF decoding is authoring-only. The game reads PNG atlases, extracts every cell at its native logical resolution, and uses nearest-neighbour filtering. @@ -28,5 +59,6 @@ engine names need the following exact filenames: Filenames are lowercase. The same names apply in every battle-art folder. -Trainer back pictures can never be animated and are not read from this -folder. Put `player.png`, `oak.png`, and `old-man.png` in `../back-static/`. +Opponent trainer pictures, Professor Oak, and Old Man are never animated. +Put `oak.png` and `old-man.png` in `../back-static/`; `player.png` is the +generic fallback used by STATIC-mode `PLAYER ART`. diff --git a/assets/battle/back-static/README.md b/assets/battle/back-static/README.md index ac0dbd8..dcd7000 100644 --- a/assets/battle/back-static/README.md +++ b/assets/battle/back-static/README.md @@ -1,12 +1,35 @@ # Back static battle art -Drop `.png` player back sprites here. Art is used as authored and is -not mirrored; it should face right toward the opponent. Files may use any -pixel dimensions and require no Lua or metadata. +Drop `.png` player back sprites into a generation subfolder. Art is +used as authored and is not mirrored; it should face right toward the +opponent. Files may use any pixel dimensions and require no Lua or metadata. These local PNGs are ignored by Git. Missing or invalid files fall back to the ROM sprite. +## Generation sets + +`BACK ART SET` selects one of these generation subfolders: + +- `back-static/gen1/.png` +- `back-static/gen2/.png` +- `back-static/gen3/.png` +- `back-static/gen4/.png` +- `back-static/gen5/.png` + +Under `BATTLE ART: STATIC`, all five choices read only these ordinary PNGs. +Static GEN 5 never loads or decodes the similarly named animated atlas. + +Under `BATTLE ART: ANIMATED`, GEN 1 through GEN 4 use the same single-frame +PNGs, while GEN 5 uses `back-animated/gen5`. If the selected file is absent or +invalid, the ROM backsprite is used instead. An absent `gen1` directory does +not remove GEN 1 from the menu; it is an intentional empty slot for ROM-hack +or other user-supplied artwork. + +`BACK PLACEMENT` can override the layer for comparison. AUTO uses supplied +generation PNGs in the world and keeps a missing ANIMATED fallback on OG UI; +WORLD and OG UI force either presentation. + ## Gen 1 filename exceptions Most species use their ordinary lowercase name (`pikachu.png`). These four @@ -23,14 +46,26 @@ Filenames are lowercase. The same names apply in every battle-art folder. ## Static player-side trainer backs -Trainer pictures are never animated. This is the only battle-art folder used -for trainer back pictures: +This folder supplies the static `PLAYER ART` portraits. ANIMATED mode instead +reads the five-pose player strips documented in `../back-animated/`. Professor +Oak and Old Man remain static and always resolve here: | Battle role | Expected filename | | --- | --- | -| Normal player battle intro | `player.png` | +| `PLAYER ART: PNG` (default and named-set fallback) | `player.png` | +| `PLAYER ART: GEN 1` | `gen1player.png` | +| `PLAYER ART: GEN 2` | `gen2player.png` | +| `PLAYER ART: GEN 3` | `gen3player.png` | +| `PLAYER ART: GEN 4` | `gen4player.png` | +| `PLAYER ART: GEN 5` | `gen5player.png` | +| `PLAYER ART: ASH` | `ashplayer.png` | +| `PLAYER ART: GARY` | `garyplayer.png` | +| `PLAYER ART: ROM` | no file; retain the ROM portrait | | Professor Oak in Yellow's opening battle | `oak.png` | | Old Man catching tutorial | `old-man.png` | -These are intro trainer cards, not Pokémon species. Missing files retain the -ROM trainer backsprite. Opponent trainers never read from a back folder. +These are intro trainer cards, not Pokémon species. A missing GEN/ASH/GARY +selection tries `player.png`, then retains the ROM trainer backsprite. PNG +tries `player.png` directly; ROM deliberately bypasses it. `PLAYER ART` is independent of species +`BATTLE ART`, so its ROM choice does not disable custom Pokémon or opponent +trainer art. Opponent trainers never read from a back folder. diff --git a/assets/battle/front-animated/README.md b/assets/battle/front-animated/README.md index 92bbd82..53ce596 100644 --- a/assets/battle/front-animated/README.md +++ b/assets/battle/front-animated/README.md @@ -1,7 +1,7 @@ # Front animated battle art Optional animated opponent/player-front battle atlases are grouped here as -`gen2/`, `gen3/`, and `gen5/`. Local PNG files are ignored by Git. `FRONT GEN` +`gen2/`, `gen3/`, `gen4/`, and `gen5/`. Local PNG files are ignored by Git. `ANIM FRONT GEN` chooses exactly one folder in ANIMATED mode; missing or malformed art falls back to the ROM sprite rather than silently mixing generations. @@ -9,9 +9,10 @@ GIF decoding is authoring-only. The game reads PNG atlases, extracts every cell at its native logical resolution, and uses nearest-neighbour filtering. The importer creates both the atlases and the selected set's shared metadata; users do not write one Lua file per Pokemon. From the repository root, run one -of `python tools/import_animated_sprites.py --set gen2`, `--set gen3`, or -`--set gen5`. Gen 2 uses Crystal fronts, Gen 3 uses Emerald fronts, and Gen 5 -uses Black/White fronts. +of `python tools/import_animated_sprites.py --set gen2`, `--set gen3`, +`--set gen4`, or `--set gen5`. Gen 2 uses Crystal fronts, Gen 3 uses Emerald +fronts, Gen 4 uses Diamond/Pearl animated PNGs, and Gen 5 uses Black/White +fronts. ## Gen 1 filename exceptions @@ -27,6 +28,6 @@ engine names need the following exact filenames: Filenames are lowercase. The same names apply in every battle-art folder. -Trainer front pictures can never be animated and are not read from this +Opponent trainer front pictures can never be animated and are not read from this folder. Put every opponent trainer PNG in `../front-static/`, including while `BATTLE ART` is set to `ANIMATED`. diff --git a/assets/battle/front-static/README.md b/assets/battle/front-static/README.md index 78706e1..80e9f1b 100644 --- a/assets/battle/front-static/README.md +++ b/assets/battle/front-static/README.md @@ -6,6 +6,9 @@ opaque image has its corner-coloured, border-connected background keyed out. These local PNGs are ignored by Git. Missing or invalid files fall back to the ROM sprite. Enemy sprites are used as authored (facing left). +Static species fronts preserve their authored brightness and colour in staged +battles: day/night tint is omitted for these PNGs, while display filters, +hit effects, depth occlusion, lighting and alpha-shaped shadows still apply. ## Gen 1 filename exceptions @@ -23,11 +26,11 @@ Filenames are lowercase. The same names apply in every battle-art folder. ## Static opponent trainer fronts -Trainer pictures are never animated. Put opponent trainer PNGs in this folder -even when `BATTLE ART` is set to `ANIMATED`. The filename is the engine trainer -class in lowercase with underscores changed to hyphens: +Opponent trainer pictures are never animated. Put complete opponent sets in +`gen1`, `gen2`, and `gen3`; the `TRAINER ART` option selects one of those +folders even when `BATTLE ART` is set to `ANIMATED`. The filename is the +engine trainer class in lowercase with underscores changed to hyphens: -```text youngster.png bug-catcher.png lass.png sailor.png jr-trainer-m.png jr-trainer-f.png pokemaniac.png super-nerd.png hiker.png @@ -44,7 +47,7 @@ erika.png koga.png blaine.png sabrina.png gentleman.png rival2.png rival3.png lorelei.png channeler.png agatha.png lance.png -``` Yellow's special Rocket pair uses `jessie-james.png`; other Rocket trainers -use `rocket.png`. Missing trainer files retain the ROM trainer picture. +use `rocket.png`. A missing file in the selected generation retains the ROM +trainer picture. The runtime does not borrow it from either of the other sets. diff --git a/data/animated_battle_sprites_gen4.lua b/data/animated_battle_sprites_gen4.lua new file mode 100644 index 0000000..fb9e729 --- /dev/null +++ b/data/animated_battle_sprites_gen4.lua @@ -0,0 +1,457 @@ +-- Generated by tools/import_animated_sprites.py --set gen4. +-- Pokemon Diamond/Pearl animated fronts, National Dex #001-#151. +return { + BULBASAUR = { + front = { image = "assets/battle/front-animated/gen4/bulbasaur.png", width = 86, height = 86, columns = 16, frames = 37, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + IVYSAUR = { + front = { image = "assets/battle/front-animated/gen4/ivysaur.png", width = 80, height = 80, columns = 16, frames = 34, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + VENUSAUR = { + front = { image = "assets/battle/front-animated/gen4/venusaur.png", width = 80, height = 80, columns = 16, frames = 24, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + CHARMANDER = { + front = { image = "assets/battle/front-animated/gen4/charmander.png", width = 80, height = 80, columns = 16, frames = 27, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + CHARMELEON = { + front = { image = "assets/battle/front-animated/gen4/charmeleon.png", width = 80, height = 80, columns = 16, frames = 22, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + CHARIZARD = { + front = { image = "assets/battle/front-animated/gen4/charizard.png", width = 84, height = 84, columns = 16, frames = 36, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + SQUIRTLE = { + front = { image = "assets/battle/front-animated/gen4/squirtle.png", width = 80, height = 80, columns = 16, frames = 58, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + WARTORTLE = { + front = { image = "assets/battle/front-animated/gen4/wartortle.png", width = 110, height = 110, columns = 16, frames = 36, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + BLASTOISE = { + front = { image = "assets/battle/front-animated/gen4/blastoise.png", width = 80, height = 80, columns = 16, frames = 37, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + CATERPIE = { + front = { image = "assets/battle/front-animated/gen4/caterpie.png", width = 80, height = 80, columns = 16, frames = 23, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + METAPOD = { + front = { image = "assets/battle/front-animated/gen4/metapod.png", width = 80, height = 80, columns = 16, frames = 23, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + BUTTERFREE = { + front = { image = "assets/battle/front-animated/gen4/butterfree.png", width = 80, height = 80, columns = 16, frames = 23, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + WEEDLE = { + front = { image = "assets/battle/front-animated/gen4/weedle.png", width = 80, height = 80, columns = 16, frames = 26, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + KAKUNA = { + front = { image = "assets/battle/front-animated/gen4/kakuna.png", width = 80, height = 80, columns = 16, frames = 37, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + BEEDRILL = { + front = { image = "assets/battle/front-animated/gen4/beedrill.png", width = 86, height = 86, columns = 16, frames = 29, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + PIDGEY = { + front = { image = "assets/battle/front-animated/gen4/pidgey.png", width = 80, height = 80, columns = 16, frames = 43, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + PIDGEOTTO = { + front = { image = "assets/battle/front-animated/gen4/pidgeotto.png", width = 92, height = 92, columns = 16, frames = 23, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + PIDGEOT = { + front = { image = "assets/battle/front-animated/gen4/pidgeot.png", width = 96, height = 96, columns = 16, frames = 22, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + RATTATA = { + front = { image = "assets/battle/front-animated/gen4/rattata.png", width = 80, height = 80, columns = 16, frames = 27, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + RATICATE = { + front = { image = "assets/battle/front-animated/gen4/raticate.png", width = 80, height = 80, columns = 16, frames = 24, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + SPEAROW = { + front = { image = "assets/battle/front-animated/gen4/spearow.png", width = 80, height = 80, columns = 16, frames = 26, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + FEAROW = { + front = { image = "assets/battle/front-animated/gen4/fearow.png", width = 90, height = 90, columns = 16, frames = 43, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + EKANS = { + front = { image = "assets/battle/front-animated/gen4/ekans.png", width = 100, height = 100, columns = 16, frames = 26, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + ARBOK = { + front = { image = "assets/battle/front-animated/gen4/arbok.png", width = 98, height = 98, columns = 16, frames = 26, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + PIKACHU = { + front = { image = "assets/battle/front-animated/gen4/pikachu.png", width = 80, height = 80, columns = 16, frames = 49, durations = {50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,2000} }, + }, + RAICHU = { + front = { image = "assets/battle/front-animated/gen4/raichu.png", width = 80, height = 80, columns = 16, frames = 48, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,2550} }, + }, + SANDSHREW = { + front = { image = "assets/battle/front-animated/gen4/sandshrew.png", width = 124, height = 124, columns = 16, frames = 37, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + SANDSLASH = { + front = { image = "assets/battle/front-animated/gen4/sandslash.png", width = 80, height = 80, columns = 16, frames = 40, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + NIDORAN_F = { + front = { image = "assets/battle/front-animated/gen4/nidoran-f.png", width = 80, height = 80, columns = 16, frames = 22, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + NIDORINA = { + front = { image = "assets/battle/front-animated/gen4/nidorina.png", width = 80, height = 80, columns = 16, frames = 23, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + NIDOQUEEN = { + front = { image = "assets/battle/front-animated/gen4/nidoqueen.png", width = 80, height = 80, columns = 16, frames = 23, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + NIDORAN_M = { + front = { image = "assets/battle/front-animated/gen4/nidoran-m.png", width = 80, height = 80, columns = 16, frames = 43, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + NIDORINO = { + front = { image = "assets/battle/front-animated/gen4/nidorino.png", width = 80, height = 80, columns = 16, frames = 38, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + NIDOKING = { + front = { image = "assets/battle/front-animated/gen4/nidoking.png", width = 80, height = 80, columns = 16, frames = 38, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + CLEFAIRY = { + front = { image = "assets/battle/front-animated/gen4/clefairy.png", width = 80, height = 80, columns = 16, frames = 51, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + CLEFABLE = { + front = { image = "assets/battle/front-animated/gen4/clefable.png", width = 104, height = 104, columns = 16, frames = 35, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + VULPIX = { + front = { image = "assets/battle/front-animated/gen4/vulpix.png", width = 80, height = 80, columns = 16, frames = 28, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + NINETALES = { + front = { image = "assets/battle/front-animated/gen4/ninetales.png", width = 84, height = 84, columns = 16, frames = 26, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + JIGGLYPUFF = { + front = { image = "assets/battle/front-animated/gen4/jigglypuff.png", width = 80, height = 80, columns = 16, frames = 53, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + WIGGLYTUFF = { + front = { image = "assets/battle/front-animated/gen4/wigglytuff.png", width = 106, height = 106, columns = 16, frames = 35, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + ZUBAT = { + front = { image = "assets/battle/front-animated/gen4/zubat.png", width = 80, height = 80, columns = 16, frames = 21, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + GOLBAT = { + front = { image = "assets/battle/front-animated/gen4/golbat.png", width = 86, height = 86, columns = 16, frames = 49, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + ODDISH = { + front = { image = "assets/battle/front-animated/gen4/oddish.png", width = 80, height = 80, columns = 16, frames = 26, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + GLOOM = { + front = { image = "assets/battle/front-animated/gen4/gloom.png", width = 80, height = 80, columns = 16, frames = 24, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + VILEPLUME = { + front = { image = "assets/battle/front-animated/gen4/vileplume.png", width = 90, height = 90, columns = 16, frames = 42, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + PARAS = { + front = { image = "assets/battle/front-animated/gen4/paras.png", width = 80, height = 80, columns = 16, frames = 33, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + PARASECT = { + front = { image = "assets/battle/front-animated/gen4/parasect.png", width = 90, height = 90, columns = 16, frames = 40, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + VENONAT = { + front = { image = "assets/battle/front-animated/gen4/venonat.png", width = 80, height = 80, columns = 16, frames = 77, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + VENOMOTH = { + front = { image = "assets/battle/front-animated/gen4/venomoth.png", width = 80, height = 80, columns = 16, frames = 24, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + DIGLETT = { + front = { image = "assets/battle/front-animated/gen4/diglett.png", width = 80, height = 80, columns = 16, frames = 34, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + DUGTRIO = { + front = { image = "assets/battle/front-animated/gen4/dugtrio.png", width = 80, height = 80, columns = 16, frames = 32, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + MEOWTH = { + front = { image = "assets/battle/front-animated/gen4/meowth.png", width = 80, height = 80, columns = 16, frames = 41, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + PERSIAN = { + front = { image = "assets/battle/front-animated/gen4/persian.png", width = 80, height = 80, columns = 16, frames = 23, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + PSYDUCK = { + front = { image = "assets/battle/front-animated/gen4/psyduck.png", width = 80, height = 80, columns = 16, frames = 51, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + GOLDUCK = { + front = { image = "assets/battle/front-animated/gen4/golduck.png", width = 90, height = 90, columns = 16, frames = 26, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + MANKEY = { + front = { image = "assets/battle/front-animated/gen4/mankey.png", width = 84, height = 84, columns = 16, frames = 31, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + PRIMEAPE = { + front = { image = "assets/battle/front-animated/gen4/primeape.png", width = 84, height = 84, columns = 16, frames = 25, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + GROWLITHE = { + front = { image = "assets/battle/front-animated/gen4/growlithe.png", width = 80, height = 80, columns = 16, frames = 58, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + ARCANINE = { + front = { image = "assets/battle/front-animated/gen4/arcanine.png", width = 80, height = 80, columns = 16, frames = 43, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + POLIWAG = { + front = { image = "assets/battle/front-animated/gen4/poliwag.png", width = 80, height = 80, columns = 16, frames = 43, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + POLIWHIRL = { + front = { image = "assets/battle/front-animated/gen4/poliwhirl.png", width = 80, height = 80, columns = 16, frames = 37, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + POLIWRATH = { + front = { image = "assets/battle/front-animated/gen4/poliwrath.png", width = 80, height = 80, columns = 16, frames = 23, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + ABRA = { + front = { image = "assets/battle/front-animated/gen4/abra.png", width = 80, height = 80, columns = 16, frames = 32, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + KADABRA = { + front = { image = "assets/battle/front-animated/gen4/kadabra.png", width = 94, height = 94, columns = 16, frames = 44, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + ALAKAZAM = { + front = { image = "assets/battle/front-animated/gen4/alakazam.png", width = 90, height = 90, columns = 16, frames = 25, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + MACHOP = { + front = { image = "assets/battle/front-animated/gen4/machop.png", width = 80, height = 80, columns = 16, frames = 32, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + MACHOKE = { + front = { image = "assets/battle/front-animated/gen4/machoke.png", width = 84, height = 84, columns = 16, frames = 39, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + MACHAMP = { + front = { image = "assets/battle/front-animated/gen4/machamp.png", width = 88, height = 88, columns = 16, frames = 61, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + BELLSPROUT = { + front = { image = "assets/battle/front-animated/gen4/bellsprout.png", width = 80, height = 80, columns = 16, frames = 31, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + WEEPINBELL = { + front = { image = "assets/battle/front-animated/gen4/weepinbell.png", width = 80, height = 80, columns = 16, frames = 52, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + VICTREEBEL = { + front = { image = "assets/battle/front-animated/gen4/victreebel.png", width = 80, height = 80, columns = 16, frames = 24, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + TENTACOOL = { + front = { image = "assets/battle/front-animated/gen4/tentacool.png", width = 90, height = 90, columns = 16, frames = 55, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + TENTACRUEL = { + front = { image = "assets/battle/front-animated/gen4/tentacruel.png", width = 84, height = 84, columns = 16, frames = 41, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + GEODUDE = { + front = { image = "assets/battle/front-animated/gen4/geodude.png", width = 80, height = 80, columns = 16, frames = 49, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + GRAVELER = { + front = { image = "assets/battle/front-animated/gen4/graveler.png", width = 80, height = 80, columns = 16, frames = 33, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + GOLEM = { + front = { image = "assets/battle/front-animated/gen4/golem.png", width = 80, height = 80, columns = 16, frames = 45, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + PONYTA = { + front = { image = "assets/battle/front-animated/gen4/ponyta.png", width = 80, height = 80, columns = 16, frames = 39, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + RAPIDASH = { + front = { image = "assets/battle/front-animated/gen4/rapidash.png", width = 104, height = 104, columns = 16, frames = 22, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + SLOWPOKE = { + front = { image = "assets/battle/front-animated/gen4/slowpoke.png", width = 90, height = 90, columns = 16, frames = 37, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + SLOWBRO = { + front = { image = "assets/battle/front-animated/gen4/slowbro.png", width = 90, height = 90, columns = 16, frames = 52, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + MAGNEMITE = { + front = { image = "assets/battle/front-animated/gen4/magnemite.png", width = 80, height = 80, columns = 16, frames = 21, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + MAGNETON = { + front = { image = "assets/battle/front-animated/gen4/magneton.png", width = 80, height = 80, columns = 16, frames = 64, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + FARFETCHD = { + front = { image = "assets/battle/front-animated/gen4/farfetchd.png", width = 80, height = 80, columns = 16, frames = 26, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + DODUO = { + front = { image = "assets/battle/front-animated/gen4/doduo.png", width = 80, height = 80, columns = 16, frames = 24, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + DODRIO = { + front = { image = "assets/battle/front-animated/gen4/dodrio.png", width = 80, height = 80, columns = 16, frames = 21, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + SEEL = { + front = { image = "assets/battle/front-animated/gen4/seel.png", width = 112, height = 112, columns = 16, frames = 36, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + DEWGONG = { + front = { image = "assets/battle/front-animated/gen4/dewgong.png", width = 102, height = 102, columns = 16, frames = 31, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + GRIMER = { + front = { image = "assets/battle/front-animated/gen4/grimer.png", width = 84, height = 84, columns = 16, frames = 43, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + MUK = { + front = { image = "assets/battle/front-animated/gen4/muk.png", width = 104, height = 104, columns = 16, frames = 23, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + SHELLDER = { + front = { image = "assets/battle/front-animated/gen4/shellder.png", width = 80, height = 80, columns = 16, frames = 52, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + CLOYSTER = { + front = { image = "assets/battle/front-animated/gen4/cloyster.png", width = 98, height = 98, columns = 16, frames = 55, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + GASTLY = { + front = { image = "assets/battle/front-animated/gen4/gastly.png", width = 80, height = 80, columns = 16, frames = 25, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + HAUNTER = { + front = { image = "assets/battle/front-animated/gen4/haunter.png", width = 86, height = 86, columns = 16, frames = 30, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + GENGAR = { + front = { image = "assets/battle/front-animated/gen4/gengar.png", width = 80, height = 80, columns = 16, frames = 29, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + ONIX = { + front = { image = "assets/battle/front-animated/gen4/onix.png", width = 94, height = 94, columns = 16, frames = 28, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + DROWZEE = { + front = { image = "assets/battle/front-animated/gen4/drowzee.png", width = 80, height = 80, columns = 16, frames = 43, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + HYPNO = { + front = { image = "assets/battle/front-animated/gen4/hypno.png", width = 80, height = 80, columns = 16, frames = 48, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + KRABBY = { + front = { image = "assets/battle/front-animated/gen4/krabby.png", width = 80, height = 80, columns = 16, frames = 41, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + KINGLER = { + front = { image = "assets/battle/front-animated/gen4/kingler.png", width = 84, height = 84, columns = 16, frames = 23, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + VOLTORB = { + front = { image = "assets/battle/front-animated/gen4/voltorb.png", width = 80, height = 80, columns = 16, frames = 27, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + ELECTRODE = { + front = { image = "assets/battle/front-animated/gen4/electrode.png", width = 80, height = 80, columns = 16, frames = 42, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + EXEGGCUTE = { + front = { image = "assets/battle/front-animated/gen4/exeggcute.png", width = 80, height = 80, columns = 16, frames = 48, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + EXEGGUTOR = { + front = { image = "assets/battle/front-animated/gen4/exeggutor.png", width = 126, height = 126, columns = 16, frames = 36, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + CUBONE = { + front = { image = "assets/battle/front-animated/gen4/cubone.png", width = 80, height = 80, columns = 16, frames = 25, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + MAROWAK = { + front = { image = "assets/battle/front-animated/gen4/marowak.png", width = 80, height = 80, columns = 16, frames = 26, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + HITMONLEE = { + front = { image = "assets/battle/front-animated/gen4/hitmonlee.png", width = 98, height = 98, columns = 16, frames = 36, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + HITMONCHAN = { + front = { image = "assets/battle/front-animated/gen4/hitmonchan.png", width = 80, height = 80, columns = 16, frames = 27, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + LICKITUNG = { + front = { image = "assets/battle/front-animated/gen4/lickitung.png", width = 88, height = 88, columns = 16, frames = 27, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + KOFFING = { + front = { image = "assets/battle/front-animated/gen4/koffing.png", width = 84, height = 84, columns = 16, frames = 27, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + WEEZING = { + front = { image = "assets/battle/front-animated/gen4/weezing.png", width = 88, height = 88, columns = 16, frames = 25, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + RHYHORN = { + front = { image = "assets/battle/front-animated/gen4/rhyhorn.png", width = 80, height = 80, columns = 16, frames = 31, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + RHYDON = { + front = { image = "assets/battle/front-animated/gen4/rhydon.png", width = 86, height = 86, columns = 16, frames = 36, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + CHANSEY = { + front = { image = "assets/battle/front-animated/gen4/chansey.png", width = 80, height = 80, columns = 16, frames = 93, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + TANGELA = { + front = { image = "assets/battle/front-animated/gen4/tangela.png", width = 80, height = 80, columns = 16, frames = 25, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + KANGASKHAN = { + front = { image = "assets/battle/front-animated/gen4/kangaskhan.png", width = 100, height = 100, columns = 16, frames = 26, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + HORSEA = { + front = { image = "assets/battle/front-animated/gen4/horsea.png", width = 80, height = 80, columns = 16, frames = 25, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + SEADRA = { + front = { image = "assets/battle/front-animated/gen4/seadra.png", width = 80, height = 80, columns = 16, frames = 53, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + GOLDEEN = { + front = { image = "assets/battle/front-animated/gen4/goldeen.png", width = 80, height = 80, columns = 16, frames = 51, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + SEAKING = { + front = { image = "assets/battle/front-animated/gen4/seaking.png", width = 80, height = 80, columns = 16, frames = 51, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + STARYU = { + front = { image = "assets/battle/front-animated/gen4/staryu.png", width = 80, height = 80, columns = 16, frames = 52, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + STARMIE = { + front = { image = "assets/battle/front-animated/gen4/starmie.png", width = 80, height = 80, columns = 16, frames = 52, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + MR_MIME = { + front = { image = "assets/battle/front-animated/gen4/mr-mime.png", width = 80, height = 80, columns = 16, frames = 41, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + SCYTHER = { + front = { image = "assets/battle/front-animated/gen4/scyther.png", width = 86, height = 86, columns = 16, frames = 40, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + JYNX = { + front = { image = "assets/battle/front-animated/gen4/jynx.png", width = 80, height = 80, columns = 16, frames = 35, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + ELECTABUZZ = { + front = { image = "assets/battle/front-animated/gen4/electabuzz.png", width = 80, height = 80, columns = 16, frames = 55, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + MAGMAR = { + front = { image = "assets/battle/front-animated/gen4/magmar.png", width = 80, height = 80, columns = 16, frames = 41, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + PINSIR = { + front = { image = "assets/battle/front-animated/gen4/pinsir.png", width = 80, height = 80, columns = 16, frames = 25, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + TAUROS = { + front = { image = "assets/battle/front-animated/gen4/tauros.png", width = 80, height = 80, columns = 16, frames = 45, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + MAGIKARP = { + front = { image = "assets/battle/front-animated/gen4/magikarp.png", width = 82, height = 82, columns = 16, frames = 42, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + GYARADOS = { + front = { image = "assets/battle/front-animated/gen4/gyarados.png", width = 96, height = 96, columns = 16, frames = 22, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + LAPRAS = { + front = { image = "assets/battle/front-animated/gen4/lapras.png", width = 92, height = 92, columns = 16, frames = 23, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + DITTO = { + front = { image = "assets/battle/front-animated/gen4/ditto.png", width = 80, height = 80, columns = 16, frames = 24, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + EEVEE = { + front = { image = "assets/battle/front-animated/gen4/eevee.png", width = 80, height = 80, columns = 16, frames = 31, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + VAPOREON = { + front = { image = "assets/battle/front-animated/gen4/vaporeon.png", width = 106, height = 106, columns = 16, frames = 42, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + JOLTEON = { + front = { image = "assets/battle/front-animated/gen4/jolteon.png", width = 80, height = 80, columns = 16, frames = 41, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + FLAREON = { + front = { image = "assets/battle/front-animated/gen4/flareon.png", width = 80, height = 80, columns = 16, frames = 30, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + PORYGON = { + front = { image = "assets/battle/front-animated/gen4/porygon.png", width = 80, height = 80, columns = 16, frames = 28, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + OMANYTE = { + front = { image = "assets/battle/front-animated/gen4/omanyte.png", width = 80, height = 80, columns = 16, frames = 53, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + OMASTAR = { + front = { image = "assets/battle/front-animated/gen4/omastar.png", width = 80, height = 80, columns = 16, frames = 33, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + KABUTO = { + front = { image = "assets/battle/front-animated/gen4/kabuto.png", width = 80, height = 80, columns = 16, frames = 52, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + KABUTOPS = { + front = { image = "assets/battle/front-animated/gen4/kabutops.png", width = 80, height = 80, columns = 16, frames = 28, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + AERODACTYL = { + front = { image = "assets/battle/front-animated/gen4/aerodactyl.png", width = 88, height = 88, columns = 16, frames = 22, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + SNORLAX = { + front = { image = "assets/battle/front-animated/gen4/snorlax.png", width = 80, height = 80, columns = 16, frames = 27, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + ARTICUNO = { + front = { image = "assets/battle/front-animated/gen4/articuno.png", width = 80, height = 80, columns = 16, frames = 47, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + ZAPDOS = { + front = { image = "assets/battle/front-animated/gen4/zapdos.png", width = 84, height = 84, columns = 16, frames = 41, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + MOLTRES = { + front = { image = "assets/battle/front-animated/gen4/moltres.png", width = 102, height = 102, columns = 16, frames = 53, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + DRATINI = { + front = { image = "assets/battle/front-animated/gen4/dratini.png", width = 80, height = 80, columns = 16, frames = 24, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + DRAGONAIR = { + front = { image = "assets/battle/front-animated/gen4/dragonair.png", width = 86, height = 86, columns = 16, frames = 24, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + DRAGONITE = { + front = { image = "assets/battle/front-animated/gen4/dragonite.png", width = 86, height = 86, columns = 16, frames = 41, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + MEWTWO = { + front = { image = "assets/battle/front-animated/gen4/mewtwo.png", width = 90, height = 90, columns = 16, frames = 41, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, + MEW = { + front = { image = "assets/battle/front-animated/gen4/mew.png", width = 102, height = 102, columns = 16, frames = 54, durations = {30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,3000} }, + }, +} diff --git a/data/animated_player_trainers.lua b/data/animated_player_trainers.lua new file mode 100644 index 0000000..e2d68a2 --- /dev/null +++ b/data/animated_player_trainers.lua @@ -0,0 +1,21 @@ +-- Player-trainer back animations. Each source is a five-pose horizontal +-- strip. Runtime division by the actual sheet width supports both the current +-- 320-pixel strips (5 x 64) and the normalized template (5 x 80) without +-- resampling any authored pixels. A set can still use explicit `cells` later +-- if it genuinely needs uneven frame boundaries. +local function five(image) + return { + image = "assets/battle/back-animated/" .. image, + autoColumns = 5, + } +end + +return { + gen1 = five("gen1player.png"), + gen2 = five("gen2player.png"), + gen3 = five("gen3player.png"), + gen4 = five("gen4player.png"), + gen5 = five("gen5player.png"), + ash = five("ashplayer.png"), + gary = five("garyplayer.png"), +} diff --git a/lib/AnimatedBattleArt.lua b/lib/AnimatedBattleArt.lua index 73f4f93..16195db 100644 --- a/lib/AnimatedBattleArt.lua +++ b/lib/AnimatedBattleArt.lua @@ -10,13 +10,21 @@ local BattleArt = V.require("BattleArt") local SETS = { gen2 = V.data("animated_battle_sprites_gen2"), gen3 = V.data("animated_battle_sprites_gen3"), + gen4 = V.data("animated_battle_sprites_gen4"), gen5 = V.data("animated_battle_sprites_gen5"), } +local PLAYER_SETS = V.data("animated_player_trainers") local AnimatedBattleArt = {} local loaded, loadOrder = {}, {} local LOAD_LIMIT = 6 local states = setmetatable({}, { __mode = "k" }) -- battler -> playback +local trainerStates = setmetatable({}, { __mode = "k" }) -- battle -> playback + +local function currentImage(state) + if not state then return nil end + return state.frames and state.frames[state.frame] or state.image +end local function forgetFromOrder(def) for i = #loadOrder, 1, -1 do @@ -49,24 +57,38 @@ local function loadFrames(def, mode) if not path then return nil end local result local ok = pcall(function() - local width = assert(tonumber(def.width)) - local height = assert(tonumber(def.height)) - local columns = assert(tonumber(def.columns)) - local count = assert(tonumber(def.frames)) - if width < 1 or height < 1 or columns < 1 or count < 1 then return end - local sheet = love.image.newImageData(path) local sheetW, sheetH = sheet:getDimensions() - local rows = math.ceil(count / columns) - if sheetW < columns * width or sheetH < rows * height then return end - local frames = {} + local cells = def.cells + local autoColumns = tonumber(def.autoColumns) + local count = cells and #cells or autoColumns or assert(tonumber(def.frames)) + if count < 1 then return end + local autoWidth + if autoColumns then + if autoColumns % 1 ~= 0 or sheetW % autoColumns ~= 0 then return end + autoWidth = sheetW / autoColumns + end for index = 0, count - 1 do + local x, y, width, height + if cells then + local c = cells[index + 1] + x, y = tonumber(c.x) or 0, tonumber(c.y) or 0 + width, height = assert(tonumber(c.width)), assert(tonumber(c.height)) + elseif autoColumns then + x, y = index * autoWidth, 0 + width, height = autoWidth, sheetH + else + width = assert(tonumber(def.width)) + height = assert(tonumber(def.height)) + local columns = assert(tonumber(def.columns)) + x = (index % columns) * width + y = math.floor(index / columns) * height + end + if width < 1 or height < 1 or x < 0 or y < 0 + or x + width > sheetW or y + height > sheetH then return end local cell = love.image.newImageData(width, height) - cell:paste(sheet, 0, 0, - (index % columns) * width, - math.floor(index / columns) * height, - width, height) + cell:paste(sheet, 0, 0, x, y, width, height) local image = BattleArt.prepareData(cell, mode) if not image then return end frames[#frames + 1] = image @@ -78,10 +100,66 @@ local function loadFrames(def, mode) return result end +local function restoreTrainer(battle) + local state = battle and trainerStates[battle] + if not state then return end + if battle.playerBackPic == state.frames[state.frame] then + battle.playerBackPic = state.original + end + trainerStates[battle] = nil +end + +-- Five authored poses are tied to SlideTrainerPicOffScreen rather than to a +-- free-running clock. Frame one waits with the stationary intro portrait; +-- frames two through five divide the 72-pixel leftward walk, then clamp. +local function updatePlayerTrainer(battle, mode) + if not (battle and battle.showPlayerBack and not battle.demo) then + restoreTrainer(battle) + return + end + local selected = BattleArt.playerAnimationSetting:get() + local def = selected ~= "rom" and PLAYER_SETS[selected] or nil + if not def or not battle.playerBackPic then + restoreTrainer(battle) + return + end + local state = trainerStates[battle] + if state and (state.def ~= def or state.mode ~= mode) then + restoreTrainer(battle) + state = nil + end + if not state then + local frames = loadFrames(def, mode) + if not frames then return end + state = { def = def, mode = mode, original = battle.playerBackPic, + frames = frames, frame = 1 } + trainerStates[battle] = state + elseif battle.playerBackPic ~= state.frames[state.frame] + and battle.playerBackPic ~= state.original then + trainerStates[battle] = nil + return + end + + local offset = 0 + if type(battle.picOffset) == "function" then + local ok, got = pcall(battle.picOffset, battle, "back") + if ok then offset = tonumber(got) or 0 end + end + local progress = math.max(0, math.min(72, -offset)) + if progress <= 0 then + state.frame = 1 + else + local movingFrames = math.max(1, #state.frames - 1) + state.frame = math.min(#state.frames, + 2 + math.floor(math.max(0, progress - 1) * movingFrames / 72)) + end + battle.playerBackPic = state.frames[state.frame] +end + local function restore(battler) local state = battler and states[battler] if not state then return end - if battler.sprite == state.frames[state.frame] then + if battler.sprite == currentImage(state) then battler.sprite = state.original end states[battler] = nil @@ -103,14 +181,16 @@ local function updateBattler(battler, side, dt, mode) if not (def and battler.sprite) then restore(battler); return end local state = states[battler] - if state and (state.def ~= def or state.mode ~= mode) then + if state and (state.kind ~= "animated" or state.def ~= def + or state.mode ~= mode or state.side ~= side) then restore(battler) state = nil end if not state then local frames = loadFrames(def, mode) if not frames then return end -- missing/malformed atlas: retain ROM art - state = { def = def, mode = mode, original = battler.sprite, + state = { kind = "animated", side = side, def = def, mode = mode, + original = battler.sprite, frames = frames, frame = 1, elapsed = 0 } states[battler] = state elseif battler.sprite ~= state.frames[state.frame] @@ -131,6 +211,29 @@ local function updateBattler(battler, side, dt, mode) battler.sprite = state.frames[state.frame] end +local function updateStaticBack(battler, generation, mode) + if not (battler and battler.sprite) then return end + local species = battler.mon and battler.mon.species + local image = species and BattleArt.generationBackImage(species, generation) + local state = states[battler] + if state and (state.kind ~= "static" or state.generation ~= generation + or state.mode ~= mode or state.image ~= image) then + restore(battler) + state = nil + end + if not image then restore(battler); return end + if not state then + state = { kind = "static", side = "back", generation = generation, + mode = mode, original = battler.sprite, image = image } + states[battler] = state + elseif battler.sprite ~= state.image and battler.sprite ~= state.original then + -- A transform or battle effect owns the sprite for this frame. + states[battler] = nil + return + end + battler.sprite = state.image +end + function AnimatedBattleArt.update(battle, dt) if not battle then return end if BattleArt.setting:get() ~= "animated" then @@ -138,18 +241,52 @@ function AnimatedBattleArt.update(battle, dt) return end local mode = BattleArt.displayMode() + -- Restore a static PLAYER ART replacement before the animation manager + -- captures the engine portrait, then leave managed animation frames alone. + BattleArt.applyTrainers(battle) + updatePlayerTrainer(battle, mode) updateBattler(battle.enemy, "front", dt, mode) - updateBattler(battle.player, BattleArt.playerSide(), dt, mode) + local playerSide = BattleArt.playerSide() + if playerSide == "back" then + local generation = BattleArt.backAnimationSetting:get() + if generation == "gen5" then + updateBattler(battle.player, "back", dt, mode) + else + updateStaticBack(battle.player, generation, mode) + end + else + updateBattler(battle.player, "front", dt, mode) + end +end + +-- The staged renderer asks this before deciding whether to suppress the +-- engine's original player pics layer. A managed back image belongs in the +-- world; no managed image means the selected file/atlas was absent or bad, +-- so the untouched ROM backsprite remains attached to the UI. +function AnimatedBattleArt.hasWorldBack(battler) + local state = battler and states[battler] + return state and state.side == "back" + and battler.sprite == currentImage(state) or false +end + +-- UI scaling needs to distinguish our native-resolution trainer atlas from +-- the ROM's deliberately half-resolution back picture. Both occupy the same +-- engine field, but only the ROM image should receive the Game Boy 2x scale. +function AnimatedBattleArt.hasPlayerTrainerFrame(battle) + local state = battle and trainerStates[battle] + return state and battle.playerBackPic == currentImage(state) or false end function AnimatedBattleArt.finish(battle) if not battle then return end + restoreTrainer(battle) restore(battle.enemy) restore(battle.player) end function AnimatedBattleArt.invalidate() loaded, loadOrder = {}, {} + trainerStates = setmetatable({}, { __mode = "k" }) end return AnimatedBattleArt diff --git a/lib/BattleArt.lua b/lib/BattleArt.lua index ca8c7ea..11d1f06 100644 --- a/lib/BattleArt.lua +++ b/lib/BattleArt.lua @@ -7,14 +7,47 @@ local BattleArt = {} BattleArt.setting = ModSetting.new("battleArt", "BATTLE ART", { "static", "animated", "rom" }, { "STATIC", "ANIMATED", "ROM" }) -BattleArt.frontAnimationSetting = ModSetting.new("frontAnimatedSet", "FRONT GEN", - { "gen2", "gen3", "gen5" }, { "GEN 2", "GEN 3", "GEN 5" }) --- Gen 5 is currently the supplied animated-back collection, so it is the --- sensible fallback for a save with no explicit back-generation choice. -BattleArt.backAnimationSetting = ModSetting.new("backAnimatedSet", "BACK GEN", - { "gen2", "gen3", "gen5" }, { "GEN 2", "GEN 3", "GEN 5" }, 3) -BattleArt.viewSetting = ModSetting.new("playerView", "PLAYER VIEW", - { "front", "back" }, { "FRONT", "BACK" }) +BattleArt.frontAnimationSetting = ModSetting.new("frontAnimatedSet", "ANIM FRONT GEN", + { "gen2", "gen3", "gen4", "gen5" }, + { "GEN 2", "GEN 3", "GEN 4", "GEN 5" }) +-- The selected generation names the static back folder in STATIC mode. In +-- ANIMATED mode Gen 1-4 still use those single images, while Gen 5 selects +-- the animated atlas collection. The mode, not the generation, decides the +-- decoder. +BattleArt.backAnimationSetting = ModSetting.new("backAnimatedSet", "BACK ART SET", + { "gen1", "gen2", "gen3", "gen4", "gen5" }, + { "GEN 1", "GEN 2", "GEN 3", "GEN 4", "GEN 5" }, 5) +BattleArt.viewSetting = ModSetting.new("playerView", "PLAYER", + { "front", "back" }, { "FRONT SPRITES", "BACK SPRITES" }) +BattleArt.backPlacementSetting = ModSetting.new( + "backPlacement", "BACK PLACEMENT", + { "auto", "world", "ui" }, { "AUTO", "WORLD", "OG UI" }) +BattleArt.trainerSetting = ModSetting.new( + "trainerArtSet", "TRAINER ART", + { "gen1", "gen2", "gen3" }, { "GEN 1", "GEN 2", "GEN 3" }) +BattleArt.playerArtSetting = ModSetting.new( + "playerArtSet", "PLAYER ART", + { "png", "gen1", "gen2", "gen3", "gen4", "gen5", "ash", "gary", "rom" }, + { "PNG", "GEN 1", "GEN 2", "GEN 3", "GEN 4", "GEN 5", "ASH", "GARY", "ROM" }) +BattleArt.playerAnimationSetting = ModSetting.new( + "playerAnimatedSet", "PLAYER ANIM", + { "gen1", "gen2", "gen3", "gen4", "gen5", "ash", "gary", "rom" }, + { "GEN 1", "GEN 2", "GEN 3", "GEN 4", "GEN 5", "ASH", "GARY", "ROM" }) + +-- BATTLE ART: ROM owns the normal player portrait as completely as it owns +-- species art. Keep the visible PLAYER ART row honest instead of leaving a +-- stale named PNG selected while the renderer silently ignores it. +function BattleArt.forceRomPlayer(game) + if BattleArt.setting:get() ~= "rom" + or BattleArt.playerArtSetting:get() == "rom" then return false end + for i, value in ipairs(BattleArt.playerArtSetting.values) do + if value == "rom" then + BattleArt.playerArtSetting:setIndex(i, game) + return true + end + end + return false +end local cache = {} local external = setmetatable({}, { __mode = "k" }) @@ -42,8 +75,16 @@ local function pathFor(species, side) -- Keep the folder and setting stable while that decoder is added; an -- unrecognised atlas must never appear as one giant sprite sheet. if mode == "animated" then return nil end - local rel = ("assets/battle/%s-%s/%s.png"):format( - side, "static", slug(species)) + local rel + if side == "back" then + -- STATIC never consults an atlas. In particular, GEN 5 means the ordinary + -- PNG at back-static/gen5 here; only AnimatedBattleArt may resolve the + -- similarly named animated generation. + rel = ("assets/battle/back-static/%s/%s.png"):format( + BattleArt.backAnimationSetting:get(), slug(species)) + else + rel = ("assets/battle/front-static/%s.png"):format(slug(species)) + end local path = V.mod.assets:path(rel) local fs = love and love.filesystem if not (fs and fs.getInfo and fs.getInfo(path)) then return nil end @@ -188,7 +229,28 @@ end function BattleArt.image(species, side) local path = pathFor(species, side) - return path and prepare(path, displayMode()) or nil + local image = path and prepare(path, displayMode()) or nil + -- Authored static species fronts preserve their illustration brightness in + -- the battle scene. BattleScene reads this tag to omit only the clock tint; + -- ordinary world lighting, shadows, depth and display filtering remain. + if image and side == "front" and metrics[image] then + metrics[image].staticFront = true + end + return image +end + +-- Back-generation choices Gen 1-5 are ordinary, independently replaceable +-- PNGs. They use the same transparency keying, palette filtering and native +-- pixel metrics as BATTLE ART: STATIC, but live in generation subfolders so +-- switching the selector does not require renaming or replacing files. +function BattleArt.generationBackImage(species, generation) + if not tostring(generation or ""):match("^gen[1-5]$") then return nil end + local rel = ("assets/battle/back-static/%s/%s.png"):format( + generation, slug(species)) + local path = V.mod.assets:path(rel) + local fs = love and love.filesystem + if not (fs and fs.getInfo and fs.getInfo(path)) then return nil end + return prepare(path, displayMode()) end function BattleArt.namedImage(name, side) @@ -196,6 +258,42 @@ function BattleArt.namedImage(name, side) return path and prepare(path, displayMode()) or nil end +-- Opponent trainer pictures are always static, but can be switched as a +-- complete generation set without renaming files. Deliberately do not fall +-- through to another generation (or to the old flat folder): a missing class +-- is useful and predictable as a per-trainer ROM fallback. +function BattleArt.trainerImage(name) + if BattleArt.setting:get() == "rom" then return nil end + local generation = BattleArt.trainerSetting:get() + local rel = ("assets/battle/front-static/%s/%s.png"):format( + generation, name) + local path = V.mod.assets:path(rel) + local fs = love and love.filesystem + if not (fs and fs.getInfo and fs.getInfo(path)) then return nil end + return prepare(path, displayMode()) +end + +-- The normal player trainer intro has its own collection, independent of +-- Pokemon BATTLE ART. This is why ROM is an explicit choice here: users can +-- keep custom species and opponent trainers while retaining the original +-- player portrait. Oak and Old Man remain separately named scripted roles. +function BattleArt.playerTrainerImage() + local set = BattleArt.playerArtSetting:get() + if set == "rom" then return nil end + local fs = love and love.filesystem + local function load(name) + local rel = "assets/battle/back-static/" .. name + local path = V.mod.assets:path(rel) + if not (fs and fs.getInfo and fs.getInfo(path)) then return nil end + return prepare(path, displayMode()) + end + if set == "png" then return load("player.png") end + -- A named collection may be incomplete without making every battle fall + -- all the way back to ROM. player.png is the collection-independent BYO + -- fallback; only its own absence reaches the engine portrait. + return load(set .. "player.png") or load("player.png") +end + local function trainerKey(battle) local id = battle and battle.oppClass if type(id) ~= "string" then return nil end @@ -224,19 +322,30 @@ function BattleArt.applyTrainers(battle) if not battle then return end local enemy = battle.showEnemyTrainer and trainerKey(battle) or nil replaceTrainerField(battle, "trainerPic", - enemy and BattleArt.namedImage(enemy, "front") or nil) + enemy and BattleArt.trainerImage(enemy) or nil) - local player + local player, playerImage if battle.showPlayerBack then - if battle.demo then + local artMode = BattleArt.setting:get() + if artMode == "rom" then + -- A stale PLAYER ART selection must never leak a custom trainer back + -- into ROM mode. nil restores the engine-owned portrait. + playerImage = nil + elseif battle.demo then player = tostring(battle.demoName or ""):find("OAK", 1, true) and "oak" or "old-man" + playerImage = BattleArt.namedImage(player, "back") + elseif artMode == "animated" then + -- AnimatedBattleArt owns this field in animated mode. Passing nil here + -- first restores any static PLAYER ART image from a live mode switch; + -- on subsequent frames it leaves the animation manager's image alone. + playerImage = nil else - player = "player" + playerImage = BattleArt.playerTrainerImage() end end replaceTrainerField(battle, "playerBackPic", - player and BattleArt.namedImage(player, "back") or nil) + playerImage) end function BattleArt.apply(battle) @@ -245,7 +354,8 @@ function BattleArt.apply(battle) local species = battler and battler.mon and battler.mon.species if not species then return end -- AnimatedBattleArt owns Pokemon sprites in this mode. Trainers still - -- pass through applyTrainers below because trainer art is always static. + -- pass through applyTrainers below for opponent and scripted trainer art; + -- AnimatedBattleArt separately owns the normal player's animated intro. if BattleArt.setting:get() == "animated" then if original[battler] then battler.sprite, original[battler] = original[battler], nil @@ -269,6 +379,10 @@ end function BattleArt.isExternal(img) return external[img] and true or false end function BattleArt.metrics(img) return metrics[img] end +function BattleArt.isStaticFront(img) + local m = img and metrics[img] + return m and m.staticFront == true or false +end function BattleArt.invalidate() cache = {} diff --git a/lib/BattleCam.lua b/lib/BattleCam.lua index 07bc78e..dc812f0 100644 --- a/lib/BattleCam.lua +++ b/lib/BattleCam.lua @@ -66,6 +66,11 @@ local V = ... local BattleCam = {} +-- Leave enough headroom for full-height trainer cards. This opens the solved +-- battle lens by 70% without resampling any sprite: Pokemon and trainers stay +-- at their authored world size while more of the arena enters the frame. +BattleCam.ZOOM_OUT = 1.70 + -- ------- the rig, in world pixels (a map cell is 16, a block 32) -- -- Solved against the four anchors and two spans above, with the two mons 48 @@ -181,7 +186,7 @@ function BattleCam.rig(arena, groundY) return { eye = eye, focus = focus, - fov = 2 * math.atan((R.frameH / 2) / dist), + fov = 2 * math.atan((R.frameH * BattleCam.ZOOM_OUT / 2) / dist), -- the world curve is a free-roam flourish that bends the horizon away -- from the player; a fixed camera on a staged shot has no player to bend -- around, and the bend would tip the arena floor out from under the mons diff --git a/lib/BattleExit.lua b/lib/BattleExit.lua index c60004f..7546fff 100644 --- a/lib/BattleExit.lua +++ b/lib/BattleExit.lua @@ -66,11 +66,16 @@ function BattleExit.veil() -- answer is used. The walk only happens while a fade is live. local stack = live.game and live.game.stack local states = stack and stack.states - local onStack = false - for i = #(states or {}), 1, -1 do - if states[i] == live then onStack = true break end + local top = states and states[#states] + -- The veil owns the finished frame only while its state owns the top of + -- the stack. Merely finding an old instance somewhere underneath a newer + -- battle is not enough: painting from there uniformly multiplies the new + -- arena (and its baked HUD) while leaving the later UI canvas bright. + -- A legitimate exit is topmost on both halves of the transition. + if top ~= live then + live = nil + return nil end - if not onStack then live = nil; return nil end local a = live.t / live.frames if live.phase == "in" then a = 1 - a end return math.max(0, math.min(1, a)) diff --git a/lib/BattleHud.lua b/lib/BattleHud.lua index 34432e7..223073f 100644 --- a/lib/BattleHud.lua +++ b/lib/BattleHud.lua @@ -30,12 +30,11 @@ local BattleHud = {} -- How solid the frost is over the world behind it, and how far the tint -- pushes it toward the far end from the text. -- --- Both deliberately light. The panel is there to make glyphs legible, not to --- put a slab in the corner of the frame: at these values the sharp world --- still reads through it and the blur registers as a pane of glass rather --- than as a second background. -BattleHud.FROST = 0.55 -BattleHud.TINT = 0.26 +-- Keep the backplates fully transparent. Contrast still comes from the +-- luminance verdict below, which switches the engine's glyphs between black +-- and white without placing a translucent slab over the arena. +BattleHud.FROST = 0.0 +BattleHud.TINT = 0.0 -- The luminance the glyphs flip at, with a dead band so a drift across it -- settles rather than strobes. @@ -256,6 +255,10 @@ end -- used, so the contrast is guaranteed rather than hoped for: a dark panel -- gets darker under white text, a bright one brighter under black text. function BattleHud.panel(rect, box, dark, world) + -- Fully transparent means absent, not a zero-alpha draw. Avoid touching the + -- destination canvas or blend state at all; premultiplied driver paths can + -- otherwise retain RGB from a transparent sample as a dim veil. + if BattleHud.FROST <= 0 and BattleHud.TINT <= 0 then return true end if not (frost and box and box.scale and box.scale > 0) then return false end local fx, fy, fw, fh = mapper(world)(rect, box) local ok = pcall(function() @@ -300,7 +303,25 @@ local FLIP = [[ } ]] +-- A one-logical-pixel shadow belongs to the flipped (white) ink, not to the +-- transparent panel behind it. Build it from the same black source pixels the +-- flip shader recognises, then put the white result over it. Because this is +-- done before the HUD texture is enlarged, the offset remains aligned to the +-- Game Boy pixel grid at every window scale. +local SHADOW_ALPHA = 0.72 +local SHADOW = [[ + uniform float ink; + uniform float opacity; + vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) { + vec4 p = Texel(tex, tc); + float luma = dot(p.rgb, vec3(0.299, 0.587, 0.114)); + float a = (p.a > 0.0 && luma <= ink * p.a) ? p.a * opacity : 0.0; + return vec4(0.0, 0.0, 0.0, a) * color; + } +]] + local flipShader = nil +local shadowShader = nil local layer = nil local function getFlip() @@ -311,6 +332,14 @@ local function getFlip() return flipShader or nil end +local function getShadow() + if shadowShader == nil then + local ok, sh = pcall(love.graphics.newShader, SHADOW) + shadowShader = (ok and sh) or false + end + return shadowShader or nil +end + -- Whether the flip pass can run at all, for the shot driver's log. function BattleHud.flipReady() return getFlip() ~= nil @@ -343,6 +372,14 @@ function BattleHud.flipGlyphs(w, h, fn) love.graphics.setBlendMode(prevBlend or "alpha", prevAlpha) if not ok then error(err, 0) end + local shadow = getShadow() + if shadow then + love.graphics.setShader(shadow) + pcall(shadow.send, shadow, "ink", INK) + pcall(shadow.send, shadow, "opacity", SHADOW_ALPHA) + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(layer, 1, 1) + end love.graphics.setShader(sh) pcall(sh.send, sh, "ink", INK) love.graphics.setColor(1, 1, 1, 1) diff --git a/lib/BattleScene.lua b/lib/BattleScene.lua index fd818c5..da3686c 100644 --- a/lib/BattleScene.lua +++ b/lib/BattleScene.lua @@ -185,6 +185,12 @@ local function monMatrix(tex, x, groundY, z, mirror) local yaw = BattleBillboard.yawToward(x, z, Voxel3D.eye) local card = Mat4.mul(Mat4.translate(ox, oy, 0), Mat4.scale(w, h, 1)) if mirror then card = Mat4.mul(Mat4.scale(-1, 1, 1), card) end + local presentationScale = tonumber(tex.presentationScale) or 1 + if presentationScale ~= 1 then + -- Scale about the reported foot anchor. This is deliberately metadata on + -- trainer cards, not a texture resize and not a Pokemon-wide multiplier. + card = Mat4.mul(Mat4.scale(presentationScale, presentationScale, 1), card) + end return Mat4.mul(Mat4.mul(Mat4.translate(x, groundY, z), Mat4.rotateY(yaw)), card) end @@ -200,6 +206,7 @@ local function monCards(arena, groundY, textures) local mirror = (side == "player") and not tex.trainer and not tex.noMirror out[#out + 1] = { tex = tex.canvas, + noDayTint = tex.noDayTint, model = monMatrix(tex, cell[1], groundY, cell[2], mirror) } end @@ -417,10 +424,15 @@ function BattleScene.render(state, arena, textures, token) -- tileset atlas, so the mask's coordinates mean nothing on them Voxel3D.glass(false) for _, card in ipairs(monCards(arena, groundY, textures)) do + -- Static front illustrations retain their authored brightness instead + -- of being dimmed or colour-cast by the clock. Only the hour tint is + -- neutral here; depth and alpha-shaped lighting/shadows stay active. + if card.noDayTint then Voxel3D.dayTint({ 1, 1, 1 }) end -- the sun stored this card snugged (castShadows), so its own shadow -- lookup must read the same snugged transform -- see ShadowMap.snug Voxel3D.draw(BattleBillboard.mesh(), card.tex, card.model, BattleBillboard.PULL, ShadowMap.snug(card.model)) + if card.noDayTint then Voxel3D.dayTint() end end Voxel3D.glass(true) Voxel3D.seams(true) diff --git a/lib/OverworldBattle.lua b/lib/OverworldBattle.lua index 89a1924..f561156 100644 --- a/lib/OverworldBattle.lua +++ b/lib/OverworldBattle.lua @@ -51,6 +51,7 @@ local Voxel3D = V.require("Voxel3D") local ChunkMesher = V.require("ChunkMesher") local OverworldBattle = {} +local session = nil -- DS_BATTLE_DEBUG=1 logs what the HUD's brightness probe is reading, once a -- second, which is how the glyph flip is checked from a shot run. Read @@ -81,14 +82,37 @@ end -- by: your own Pokemon, seen from behind, sitting on top of the battle menu -- with its feet on the box. That silhouette is the series' shot. -- --- Kept as a compatibility query for older callers. Both FRONT and BACK are --- now world cards; neither is pinned to the UI layer. - --- Gated on 3D-BTL rather than read alone: with staged battles off there is no --- staged shot for a back pic to be pinned in FRONT of, and the engine's own --- battle screen already draws exactly this. +-- Placement is deliberately independent from the selected art set. WORLD and +-- OG UI are useful authoring overrides; AUTO follows the intended presentation: +-- STATIC puts supplied art and ROM fallback in the world, ANIMATED puts a +-- supplied PNG/atlas in the world but leaves a missing ROM fallback on the +-- original UI anchor, and ROM uses that OG anchor outright. function OverworldBattle.backPinned() - return false + if not OverworldBattle.enabled() then return false end + local battle = session and session.battle + local trainerBack = battle and battle.showPlayerBack + and battle.playerBackPic + -- PLAYER controls Pokemon views, not the trainer intro. A trainer back can + -- still use OG UI while FRONT SPRITES keeps the later Pokemon card in-world. + if not trainerBack and BattleArt.playerSide() ~= "back" then return false end + local placement = BattleArt.backPlacementSetting:get() + if placement == "world" then return false end + if placement == "ui" then return true end + + local artMode = BattleArt.setting:get() + if artMode == "static" then return false end + if artMode == "rom" then return true end + + if not battle then return false end + -- Trainer backs never use the Pokemon atlas manager. A supplied player, + -- Oak or Old Man PNG is marked external by BattleArt; a ROM trainer pic is + -- not. Animated player-trainer art deliberately stays on OG UI in AUTO: + -- its one-shot frames are synchronised to that layer's leftward slide. + if trainerBack then + return true + end + return battle.player and not AnimatedBattleArt.hasWorldBack(battle.player) + or false end -- ------- both mons face you @@ -100,7 +124,7 @@ end -- through the engine's own pokemon.sprite hook -- the seam that exists for -- exactly this, so no battle code has to be touched to get it. -- --- PLAYER VIEW chooses whether the near card asks for front or back art. +-- PLAYER chooses whether the near card asks for front or back sprites. -- -- Answered BEFORE a battle exists, because the battler is built before the -- battle is pushed. So it cannot ask whether this fight is staged; it asks @@ -246,7 +270,10 @@ OverworldBattle.HUD_BAND = { function OverworldBattle.snapRects(shot) local s = shot.scale local e, p = OverworldBattle.HUD_RECT.enemy, OverworldBattle.HUD_RECT.player - local ex = -e[1] * s -- foe: panel's left edge to 0 + -- Leave two logical pixels between the foe's name and the window edge. + -- The whole enemy band moves with the panel, so long names and HUD shakes + -- keep their original internal alignment instead of being clipped. + local ex = (2 - e[1]) * s local px = shot.pw - (p[1] + p[3]) * s -- player: right edge to the far side local rects = { enemy = { ex + e[1] * s, shot.ly + e[2] * s, e[3] * s, e[4] * s }, @@ -270,7 +297,7 @@ end -- -- nil when no overworld battle is running. Never more than one: battles do -- not nest. -local session = nil +session = nil local function game() return require("src.core.Game") @@ -462,15 +489,12 @@ function OverworldBattle.update(dt) local okDof, blurred = pcall(BattleDOF.apply, shot.canvas, focusY, band, range) if okDof and blurred then shot.canvas = blurred end - -- the frosted glass the HUDs sit on is built from the FINISHED backdrop, - -- so a panel over a blurred far field is frosted from what is actually - -- behind it - pcall(BattleHud.build, shot.canvas) - -- and then the HUDs go ON that backdrop, snapped out to the window's own + -- Backplates are absent, so no frost/downsample or zero-alpha composite + -- touches the finished arena. The HUDs go directly ON that backdrop, + -- snapped out to the window's own -- edges (snapHUDs). Here rather than in the battle's draw for the same - -- reason the scene is: it binds a canvas of its own. After the frost, so - -- the glass is frosted from the world alone and never from the glyphs - -- about to sit on it. + -- reason the scene is: it binds a canvas of its own. Their only contrast + -- treatment is pure white ink plus a dark one-pixel shadow. local okHud, up = pcall(OverworldBattle.snapHUDs, session.battle, shot) session.snapped = (okHud and up) and true or false -- once per battle, not once per frame: a driver that cannot do this cannot @@ -652,6 +676,7 @@ end -- known row. That known point is what the quad is then hung from. local TEX_AX, TEX_AY = 80, 96 -- forced pic centre and baseline local TRAINER_AX, TRAINER_AY = 124, 56 -- the intro trainer pic's own slot +local OPPONENT_TRAINER_SCALE = 2 -- trainer cards only; Pokemon stay 1x OverworldBattle.TEX_AX, OverworldBattle.TEX_AY = TEX_AX, TEX_AY @@ -766,16 +791,20 @@ function OverworldBattle.sideTexture(battle, side) g.setBlendMode(prevBlend or "alpha", prevAlpha) if not ok then error(err, 0) end - local trainer = false + local trainer = showingTrainer and true or false -- The intro trainer pic draws itself straight into its own 7x7 slot rather -- than through the placement helpers, so it is hung from that slot instead. if side == "enemy" and battle.showEnemyTrainer and battle.trainerPic and not metric then - ax, ay, trainer = TRAINER_AX, TRAINER_AY, true - elseif side == "player" and battle.showPlayerBack and battle.playerBackPic then - trainer = true + ax, ay = TRAINER_AX, TRAINER_AY end return { canvas = canvas, ax = ax, ay = ay, trainer = trainer, + noDayTint = BattleArt.isStaticFront(shownImage), + -- Opponent trainer cards are authored for a compact intro slot. + -- Enlarge only those cards in world space, whichever generation + -- supplied them. Pokemon remain native 1x and untouched. + presentationScale = side == "enemy" and trainer + and OPPONENT_TRAINER_SCALE or 1, noMirror = side == "player" and BattleArt.playerSide() == "back" } end @@ -840,6 +869,33 @@ function OverworldBattle.install() end BattleState.dramaticShapeTrainerPartyHook = true end + + -- A staged battle is already a finished window-sized world image. The + -- engine's generic warp fade is composited over that image in endFrame, + -- after the snapped HUD has been baked into it but before the live dialog + -- canvas is drawn. If a Transition leaves worldFadeAlpha armed, that makes + -- the arena and its name/HP blocks uniformly gray while the dialog below + -- stays bright white. Battle entry has BattleTransition's own wipe and + -- battle exit has BattleExit's finished-frame veil, so the overworld warp + -- fade has no job on this path. Suppress only that overlay while this + -- module's world override is actually active; ordinary map fades and flat + -- fallback battles remain untouched. + local Renderer = require("src.render.Renderer") + if not Renderer.dramaticShapeBattleFadeHook then + local innerEndFrame = Renderer.endFrame + function Renderer:endFrame(zones, worldZones) + if OverworldBattle.shot() and self.worldOverride then + self.worldFadeAlpha = nil + end + return innerEndFrame(self, zones, worldZones) + end + Renderer.dramaticShapeBattleFadeHook = true + end + + -- The renderer guard above is intentionally installed before this return. + -- Mod replacement can preserve the engine class and its existing battle + -- wrappers for the running process; a newly added renderer seam must still + -- be allowed to install in that session. if BattleState.dramaticShapeBattleHook then return end -- Integer scales only. The camera is solved to make one overworld square @@ -856,6 +912,14 @@ function OverworldBattle.install() -- resample the sprite twice -- once into the texture and again on the way -- to the screen -- and a twice-resampled Gen 1 pic is mush. if texturing then return 1 end + -- The engine's trainer/back slot defaults to 2x because ROM back pictures + -- are authored at half display resolution. Our animated player-trainer + -- frames are already native 80x80 art. Keep those at 1x on OG UI without + -- changing ROM backs, static pictures, Pokemon, or world billboards. + local battle = session and session.battle + if side == "back" and AnimatedBattleArt.hasPlayerTrainerFrame(battle) then + return 1 + end if not OverworldBattle.shot() then return base end return math.max(1, math.floor((tonumber(base) or 1) + 0.5)) end @@ -939,10 +1003,11 @@ function OverworldBattle.install() -- nothing left to do here. Skipped rather than left to draw underneath, or -- every Pokemon would appear twice: once on its tile and once in its slot. -- - -- Except under BACK SPRITES, where the player's side never became geometry and this - -- layer is the only thing that draws it. The engine's own onlySide argument - -- does the whole job: one call, the player's branches alone, in the slot and - -- at the scale the GB always put them -- feet on the box, 2x, back view. + -- Except when a selected generated back is missing or invalid. That fallback + -- deliberately never becomes geometry, and this layer is the only thing + -- that draws it. The engine's own onlySide argument does the whole job: one + -- call, the player's branches alone, in the slot and at the scale the GB + -- always put them -- feet on the box, 2x, back view. innerPics = BattleState.drawPicsLayer function BattleState:drawPicsLayer(slide, sx, sy, onlySide, skipMenuClip) local shot = self.dramaticShapeShot @@ -1144,16 +1209,13 @@ function OverworldBattle.snapHUDs(battle, shot) for key, rect in pairs(OverworldBattle.textRects(battle)) do live[key] = toWorld(rect, shot) end - -- measured under the SNAPPED rects: the panels are over whatever the world - -- shows at the window's edges now, which is not what was behind them in the - -- middle of the frame. ONE verdict over all of them, HUDs and box together, - -- for the reason BattleHud.verdict gives: a frame with white glyphs in the - -- corner and black ones on the menu reads as a bug rather than as adaptation. - local dark = BattleHud.verdict(live, shot, true) - -- the box's own ink is flipped where the engine draws it, in the GB frame, - -- so the answer has to outlive this function (see drawHudPanels) - if session then session.dark = dark end - local layer = OverworldBattle.hudTexture(battle, slide, dark) + -- Transparent backplates need one stable ink treatment through every phase + -- of battle. Always whiten engine ink and add BattleHud's dark one-pixel + -- shadow; luminance-driven switching made trainer/send-out frames gray and + -- changed colour as the same fight moved between phases. + local whiteInk = true + if session then session.dark = whiteInk end + local layer = OverworldBattle.hudTexture(battle, slide, whiteInk) if not layer then return false end local g = love.graphics @@ -1162,7 +1224,9 @@ function OverworldBattle.snapHUDs(battle, shot) local ok, err = pcall(function() g.setCanvas(shot.canvas) g.setBlendMode("alpha") - for _, rect in pairs(live) do BattleHud.panel(rect, shot, dark, true) end + for _, rect in pairs(live) do + BattleHud.panel(rect, shot, whiteInk, true) + end g.setColor(1, 1, 1, 1) for side, band in pairs(OverworldBattle.HUD_BAND) do local quad = g.newQuad(band[1], band[2], band[3], band[4], @@ -1190,7 +1254,7 @@ function OverworldBattle.drawHudPanels(battle) battle.dramaticShapeDark = nil if not shot then return end if snapped() then - battle.dramaticShapeDark = session and session.dark or nil + battle.dramaticShapeDark = true return end local slide = (battle.introSlide or 0) * 4 @@ -1201,9 +1265,8 @@ function OverworldBattle.drawHudPanels(battle) if player then live.player = rect.player end for key, r in pairs(OverworldBattle.textRects(battle)) do live[key] = r end if not next(live) then return end - local dark = BattleHud.verdict(live, shot) - battle.dramaticShapeDark = dark - for _, r in pairs(live) do BattleHud.panel(r, shot, dark) end + battle.dramaticShapeDark = true + for _, r in pairs(live) do BattleHud.panel(r, shot, true) end end return OverworldBattle diff --git a/lib/TiltShift.lua b/lib/TiltShift.lua index d95c10e..6929101 100644 --- a/lib/TiltShift.lua +++ b/lib/TiltShift.lua @@ -39,9 +39,11 @@ TiltShift.LABELS = { "OFF", "1", "2", "3" } -- because that is where the voxel camera parks the player. TiltShift.FOCUS_Y = 0.5 TiltShift.PRESETS = { - [1] = { spacing = 0.0016, band = 0.14, range = 0.42, saturation = 1.10 }, - [2] = { spacing = 0.0028, band = 0.10, range = 0.36, saturation = 1.18 }, - [3] = { spacing = 0.0042, band = 0.07, range = 0.30, saturation = 1.28 }, + -- Keep the middle half fully sharp. Blur ramps only through the outer 25% + -- at the near and far edges; the levels change strength, not coverage. + [1] = { spacing = 0.0016, band = 0.25, range = 0.25, saturation = 1.06 }, + [2] = { spacing = 0.0028, band = 0.25, range = 0.25, saturation = 1.10 }, + [3] = { spacing = 0.0042, band = 0.25, range = 0.25, saturation = 1.14 }, } local SHADER = [[ diff --git a/lib/Voxel3D.lua b/lib/Voxel3D.lua index 62e98be..432ffe0 100644 --- a/lib/Voxel3D.lua +++ b/lib/Voxel3D.lua @@ -869,6 +869,14 @@ function Voxel3D.draw(mesh, texture, model, pull, sunModel) love.graphics.draw(mesh) end +-- Override the hour tint for one tightly scoped draw, or restore the scene +-- tint when called without an argument. Other lighting uniforms are untouched. +function Voxel3D.dayTint(tint) + if not (active and activeShader) then return end + pcall(activeShader.send, activeShader, "dayTint", + tint or Voxel3D.tint or { 1, 1, 1 }) +end + -- Project a world point to canvas pixels: returns (x, y, scale), or nil -- when the point is behind the camera. `scale` is how much bigger a thing -- at that depth appears than one at the focus point, so a caller can size diff --git a/main.lua b/main.lua index 5b215bb..0433626 100644 --- a/main.lua +++ b/main.lua @@ -1,4 +1,4 @@ --- Dramatic Shape Voxel Mod: a full 3D diorama overworld, shipped as a +-- DRAMATIC SHAPE VOXEL MOD BATTLE ART: a full 3D diorama overworld, shipped as a -- rendering pipeline mod. -- -- The engine's render_pipelines registry (src/mods/Schemas.lua) lets a mod @@ -342,6 +342,27 @@ local SETTINGS = { "Use optional PNGs from assets/battle in fights. Missing art falls " .. "back to the ROM. STATIC is the zero-configuration default.", when = function() return stagedBattles() end, full = true }, + { BattleArt.trainerSetting, + "Choose the static opponent trainer collection. A class missing from " + .. "the selected generation falls back directly to its ROM portrait.", + when = function() + return stagedBattles() and BattleArt.setting:get() ~= "rom" + end, full = true }, + { BattleArt.playerArtSetting, + "Choose the player trainer's static battle-intro portrait. A missing " + .. "named choice tries player.png, then ROM. PNG uses player.png " + .. "directly. BATTLE ART: ROM pins this row to ROM.", + when = function() + local mode = BattleArt.setting:get() + return stagedBattles() and (mode == "static" or mode == "rom") + end, full = true }, + { BattleArt.playerAnimationSetting, + "Choose the five-frame player trainer atlas under ANIMATED. Playback " + .. "starts with the leftward intro slide, runs once, and never loops. " + .. "Missing art and ROM retain the engine portrait.", + when = function() + return stagedBattles() and BattleArt.setting:get() == "animated" + end, full = true }, { BattleArt.frontAnimationSetting, "Choose the front generation used by BATTLE ART: ANIMATED. STATIC " .. "ignores this row. Missing art falls directly back to ROM.", @@ -349,14 +370,20 @@ local SETTINGS = { return stagedBattles() and BattleArt.setting:get() == "animated" end, full = true }, { BattleArt.backAnimationSetting, - "Choose the back generation used by BATTLE ART: ANIMATED. STATIC " - .. "ignores this row. Missing art falls directly back to ROM.", + "Choose the player back-art generation. STATIC reads only a PNG from " + .. "back-static/GEN for every choice. ANIMATED reads static GEN 1-4 " + .. "PNGs or GEN 5 atlases. Missing art falls back to the ROM.", when = function() - return stagedBattles() and BattleArt.setting:get() == "animated" + return stagedBattles() and BattleArt.setting:get() ~= "rom" end, full = true }, { BattleArt.viewSetting, - "Show the player's Pokemon from the front or back. Both choices stay " - .. "world-placed, lit, shadowed and depth-occluded.", + "Show the player's Pokemon from the front or back. Supplied art stays " + .. "world-placed; a missing selected back falls back to the ROM UI pic.", + when = function() return stagedBattles() end, full = true }, + { BattleArt.backPlacementSetting, + "Place player back art automatically, force it into the 3D world, or " + .. "use gen1recomp's OG UI anchor. AUTO keeps STATIC fallbacks in the " + .. "world and ANIMATED/ROM fallbacks on the UI.", when = function() return stagedBattles() end, full = true }, { DayNight.setting, "What time it is outdoors: pin the sky to DAY, NIGHT, DUSK or DAWN, " @@ -571,6 +598,7 @@ mod.hooks:wrap("ui.options.rows", function(next, game, rows) -- keypress. if stagedBattles() then OverworldBattle.forceOG(game) + BattleArt.forceRomPlayer(game) dropRow(out, "battleLayout") end local full = Voxel.isFull(Pipelines.level("voxel")) @@ -613,6 +641,7 @@ mod.events:on("mod.options_changed", function(payload) -- the OPTIONS row does. The manager persists its own value; this is the one -- that has to follow it. if stagedBattles() then OverworldBattle.forceOG() end + BattleArt.forceRomPlayer() -- and DAYTIME changed from the manager's page while FULL owns it snaps -- straight back to SYNC -- the OPTIONS row is hidden, but the manager's is -- not, and FULL's pin must hold against both @@ -709,8 +738,8 @@ end) -- and a player who stepped off FULL could not see the rows come back. -- -- Rebuilt in place, and only on a step that changes the LIST: crossing FULL, --- toggling 3D-BTL, which owns BATTLE LAYOUT, or stepping BATTLE ART into or --- out of ANIMATED, which owns the FRONT GEN and BACK GEN rows. +-- toggling 3D-BTL, which owns BATTLE LAYOUT, or changing BATTLE ART, which +-- owns the TRAINER ART row and, under ANIMATED, the two generation rows. -- Every other rung returns the same list, and rebuilding on all of them would -- rerun every mod's ui.options.rows hook once per keypress. The cursor is -- clamped rather than reset, so it stays on the row it was just used on @@ -729,15 +758,16 @@ do function OptionsMenu:update(dt) local before = Pipelines.level("voxel") local hadBattles = OverworldBattle.enabled() - local hadAnimated = BattleArt.setting:get() == "animated" + local hadBattleArt = BattleArt.setting:get() local wasOn = idAt(self, self.index) inner(self, dt) + BattleArt.forceRomPlayer(self.game) local after = Pipelines.level("voxel") local crossedFull = after ~= before and (Voxel.isFull(before) or Voxel.isFull(after)) - local hasAnimated = BattleArt.setting:get() == "animated" + local hasBattleArt = BattleArt.setting:get() if crossedFull or OverworldBattle.enabled() ~= hadBattles - or hasAnimated ~= hadAnimated then + or hasBattleArt ~= hadBattleArt then local rebuilt = OptionsMenu.new(self.game) self.rows = rebuilt.rows -- Follow the row the cursor was ON rather than the slot it was in: diff --git a/manifest.json b/manifest.json index be71b75..3a2bdbb 100644 --- a/manifest.json +++ b/manifest.json @@ -1,6 +1,6 @@ { "id": "DRAMATIC_SHAPE", - "name": "Dramatic Shape Voxel Mod", + "name": "DRAMATIC SHAPE VOXEL MOD BATTLE ART", "version": "1.3.0", "api": 2, "entry": "main.lua", diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua index 95b969d..8476185 100644 --- a/tests/dramatic_shape_test.lua +++ b/tests/dramatic_shape_test.lua @@ -1699,12 +1699,17 @@ local rig, pitch = BattleCam.rig(shot, 0) local px, py = project(rig, shot.player) local ex, ey = project(rig, shot.enemy) +local function widenedAnchor(v, centre) + return centre + (v - centre) / BattleCam.ZOOM_OUT +end +local wantPX, wantPY = widenedAnchor(26, 80), widenedAnchor(96, 72) +local wantEX, wantEY = widenedAnchor(124, 80), widenedAnchor(56, 72) T.check(px ~= nil and ex ~= nil, "both marks are in front of the camera") -T.check(math.abs(px - 26) < 1 and math.abs(py - 96) < 1, - ("the player's cell projects onto its pic's feet at (26, 96): got " +T.check(math.abs(px - wantPX) < 1 and math.abs(py - wantPY) < 1, + ("the player's cell follows the widened battle framing: got " .. "(%.2f, %.2f)"):format(px, py)) -T.check(math.abs(ex - 124) < 1 and math.abs(ey - 56) < 1, - ("the enemy's cell projects onto its pic's feet at (124, 56): got " +T.check(math.abs(ex - wantEX) < 1 and math.abs(ey - wantEY) < 1, + ("the enemy's cell follows the widened battle framing: got " .. "(%.2f, %.2f)"):format(ex, ey)) T.check(px < ex, "which puts the player's mon LEFT of the enemy's") T.check(py > ey, "and lower in the frame -- nearer the camera") @@ -1727,11 +1732,11 @@ local function span(cam, point) return math.abs(b - a) end -T.check(math.abs(span(rig, shot.player) - 64) < 4, - ("the player's square is a back pic wide (64px at 2x): got %.2f") +T.check(math.abs(span(rig, shot.player) - 64 / BattleCam.ZOOM_OUT) < 4, + ("the player's square follows the widened framing: got %.2f") :format(span(rig, shot.player))) -T.check(math.abs(span(rig, shot.enemy) - 56) < 4, - ("the enemy's square is a front pic wide (56px at 1x): got %.2f") +T.check(math.abs(span(rig, shot.enemy) - 56 / BattleCam.ZOOM_OUT) < 4, + ("the enemy's square follows the widened framing: got %.2f") :format(span(rig, shot.enemy))) -- ------- the close rig, for rooms the default cannot stand back from @@ -1763,10 +1768,10 @@ T.check(cd < eyeDistance(rig), "and is nearer than the default") local cpx, cpy = project(closeRig, shot.player) local cex, cey = project(closeRig, shot.enemy) -T.check(math.abs(cpx - 26) < 1 and math.abs(cpy - 96) < 1, +T.check(math.abs(cpx - wantPX) < 1 and math.abs(cpy - wantPY) < 1, ("the wide lens lands the player's mark on the same anchor: (%.2f, %.2f)") :format(cpx, cpy)) -T.check(math.abs(cex - 124) < 1 and math.abs(cey - 56) < 1, +T.check(math.abs(cex - wantEX) < 1 and math.abs(cey - wantEY) < 1, ("and the enemy's too: (%.2f, %.2f)"):format(cex, cey)) T.check(span(closeRig, shot.player) < span(rig, shot.player), "the mons render smaller on it, which is what it trades for fitting") diff --git a/tools/extract_purchased_static_art.py b/tools/extract_purchased_static_art.py new file mode 100644 index 0000000..8a7ab69 --- /dev/null +++ b/tools/extract_purchased_static_art.py @@ -0,0 +1,228 @@ +"""Extract a purchased 10-column Kanto art sheet into static battle PNGs. + +The source artwork is expected to use the regular 150-entry grid found in +Pokemon_Sugimori_151_18x24.jpg, followed by Mew centered on the final row. +All drawings share one scale factor so their relative sizes are preserved. +""" + +from __future__ import annotations + +import argparse +import re +from pathlib import Path + +import numpy as np +from PIL import Image, ImageFilter + + +REFERENCE_WIDTH = 5400 +REFERENCE_HEIGHT = 7200 +REFERENCE_ROW_BOUNDS = ( + 0, 513, 957, 1404, 1930, 2492, 2923, 3381, + 3822, 4231, 4677, 5174, 5633, 6083, 6485, 6852, +) +ENTRY = re.compile( + r'^ ([A-Z0-9_]+) = \{\n' + r' front = \{ image = "assets/battle/front-animated/gen5/([^/]+)\.png"', + re.MULTILINE, +) + +# Populate after reviewing the labelled contact sheet. Entries here are +# mirrored only when the purchased drawing clearly faces right. +FLIP_DEX: set[int] = { + # 001-030, reviewed against the purchased sheet. + 1, 4, 8, 10, 12, 13, 14, 18, 19, 23, 26, 27, 28, + # 031-060, reviewed against the purchased sheet. + 31, 37, 41, 42, 43, 44, 45, 46, 47, 48, 49, 51, 52, 53, 54, 56, 59, + # 061-090, reviewed against the purchased sheet. + 63, 70, 71, 72, 73, 74, 78, 81, 83, 88, + # 091-120, reviewed against the purchased sheet. + 91, 93, 95, 96, 99, 104, 109, 119, + # 121-151, reviewed against the purchased sheet. + 123, 126, 129, 130, 132, 133, 136, 137, 140, 144, 145, 150, 151, +} + +# Species with a detached artifact introduced by the collage layout. This is +# intentionally opt-in because detached particles are legitimate artwork for +# species such as Gastly and Koffing. +RETAIN_LARGEST_COMPONENT_DEX: set[int] = {149} + + +def species(root: Path) -> list[str]: + source = (root / "data" / "animated_battle_sprites_gen5.lua").read_text( + encoding="utf-8" + ) + entries = ENTRY.findall(source) + if len(entries) != 151: + raise ValueError(f"expected 151 Kanto species definitions, got {len(entries)}") + return [slug for _, slug in entries] + + +def smoothstep(values: np.ndarray) -> np.ndarray: + values = np.clip(values, 0.0, 1.0) + return values * values * (3.0 - 2.0 * values) + + +def matte(rgb: np.ndarray, key: np.ndarray) -> Image.Image: + distance = np.max(np.abs(rgb.astype(np.float32) - key), axis=2) + alpha_f = smoothstep((distance - 7.0) / (44.0 - 7.0)) + alpha = np.rint(alpha_f * 255.0).astype(np.uint8) + + # Remove the lavender composite color from partially transparent edge + # pixels. This avoids a purple JPEG fringe after downsampling. + safe_alpha = np.maximum(alpha_f[..., None], 1.0 / 255.0) + foreground = ( + rgb.astype(np.float32) - (1.0 - alpha_f[..., None]) * key + ) / safe_alpha + foreground = np.clip(np.rint(foreground), 0, 255).astype(np.uint8) + foreground[alpha == 0] = 0 + rgba = np.dstack((foreground, alpha)) + return Image.fromarray(rgba, "RGBA") + + +def horizontal_groups(mask: np.ndarray, gap: int) -> list[tuple[int, int]]: + projection = mask.sum(axis=0) + active = np.where(projection > 1)[0] + if not len(active): + return [] + groups: list[tuple[int, int]] = [] + start = previous = int(active[0]) + for position_raw in active[1:]: + position = int(position_raw) + if position - previous > gap: + if projection[start:previous + 1].sum() > 250: + groups.append((start, previous + 1)) + start = position + previous = position + if projection[start:previous + 1].sum() > 250: + groups.append((start, previous + 1)) + return groups + + +def retain_largest_component(image: Image.Image) -> Image.Image: + alpha = np.asarray(image.getchannel("A")) + mask = alpha > 8 + height, width = mask.shape + visited = np.zeros_like(mask, dtype=bool) + components: list[list[tuple[int, int]]] = [] + for y in range(height): + for x in range(width): + if not mask[y, x] or visited[y, x]: + continue + stack = [(x, y)] + visited[y, x] = True + component: list[tuple[int, int]] = [] + while stack: + px, py = stack.pop() + component.append((px, py)) + for nx in range(max(0, px - 1), min(width, px + 2)): + for ny in range(max(0, py - 1), min(height, py + 2)): + if mask[ny, nx] and not visited[ny, nx]: + visited[ny, nx] = True + stack.append((nx, ny)) + components.append(component) + if len(components) <= 1: + return image + largest = max(components, key=len) + keep = Image.new("L", image.size) + keep_pixels = keep.load() + for x, y in largest: + keep_pixels[x, y] = 255 + keep = keep.filter(ImageFilter.MaxFilter(5)) + rgba = np.asarray(image).copy() + rgba[np.asarray(keep) == 0] = 0 + return Image.fromarray(rgba, "RGBA") + + +def extract(source: Path) -> tuple[list[Image.Image], tuple[int, int, int]]: + with Image.open(source) as opened: + rgb = np.asarray(opened.convert("RGB")) + height, width = rgb.shape[:2] + if (width, height) != (REFERENCE_WIDTH, REFERENCE_HEIGHT): + raise ValueError( + f"expected {REFERENCE_WIDTH}x{REFERENCE_HEIGHT}, got {width}x{height}" + ) + + border = np.concatenate(( + rgb[:32].reshape(-1, 3), rgb[-32:].reshape(-1, 3), + rgb[:, :32].reshape(-1, 3), rgb[:, -32:].reshape(-1, 3), + )) + key = np.median(border, axis=0).astype(np.float32) + distance = np.max(np.abs(rgb.astype(np.float32) - key), axis=2) + foreground_mask = distance > 18.0 + + drawings: list[Image.Image] = [] + for row, (top, bottom) in enumerate( + zip(REFERENCE_ROW_BOUNDS, REFERENCE_ROW_BOUNDS[1:]), 1 + ): + groups = horizontal_groups(foreground_mask[top:bottom], gap=40) + if len(groups) != 10: + raise ValueError(f"row {row} contains {len(groups)} drawings, expected 10") + for left, right in groups: + section = rgb[top:bottom, left:right] + image = matte(section, key) + box = image.getchannel("A").getbbox() + if not box: + raise ValueError(f"row {row} contains an empty drawing") + drawings.append(image.crop(box)) + + # The final band contains centered Mew and a seller signature at right. + top = REFERENCE_ROW_BOUNDS[-1] + groups = horizontal_groups(foreground_mask[top:], gap=40) + center = width / 2.0 + mew_left, mew_right = min( + groups, key=lambda group: abs(((group[0] + group[1]) / 2.0) - center) + ) + mew = matte(rgb[top:, mew_left:mew_right], key) + box = mew.getchannel("A").getbbox() + if not box: + raise ValueError("Mew extraction is empty") + drawings.append(mew.crop(box)) + + if len(drawings) != 151: + raise ValueError(f"extracted {len(drawings)} drawings, expected 151") + return drawings, tuple(int(round(value)) for value in key) + + +def save( + drawings: list[Image.Image], names: list[str], output: Path, max_width: int +) -> float: + widest = max(image.width for image in drawings) + scale = min(1.0, max_width / widest) + output.mkdir(parents=True, exist_ok=True) + for number, (image, name) in enumerate(zip(drawings, names), 1): + if number in FLIP_DEX: + image = image.transpose(Image.Transpose.FLIP_LEFT_RIGHT) + size = ( + max(1, round(image.width * scale)), + max(1, round(image.height * scale)), + ) + image = image.resize(size, Image.Resampling.LANCZOS) + if number in RETAIN_LARGEST_COMPONENT_DEX: + image = retain_largest_component(image) + image.save(output / f"{name}.png", optimize=True) + return scale + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("source", type=Path) + parser.add_argument("--root", type=Path, default=Path.cwd()) + parser.add_argument("--output", type=Path) + parser.add_argument("--max-width", type=int, default=96) + args = parser.parse_args() + root = args.root.resolve() + output = (args.output or root / "assets" / "battle" / "front-static").resolve() + drawings, key = extract(args.source.resolve()) + scale = save(drawings, species(root), output, args.max_width) + widths = [round(image.width * scale) for image in drawings] + heights = [round(image.height * scale) for image in drawings] + print(f"Extracted 151 PNGs into {output}") + print(f"Background key: #{key[0]:02x}{key[1]:02x}{key[2]:02x}") + print(f"Shared scale: {scale:.6f}") + print(f"Output widths: {min(widths)}-{max(widths)} px") + print(f"Output heights: {min(heights)}-{max(heights)} px") + + +if __name__ == "__main__": + main() diff --git a/tools/import_animated_sprites.py b/tools/import_animated_sprites.py index 8bc3cc6..f40bc91 100644 --- a/tools/import_animated_sprites.py +++ b/tools/import_animated_sprites.py @@ -6,6 +6,7 @@ Default sources: gen2 https://bluemoonfalls.com/images/sprites/crystal/normal/ gen3 https://pkmn.net/sprites/emerald/ + gen4 https://archives.bulbagarden.net/wiki/Category:Diamond_and_Pearl_sprites gen5 https://img.pokemondb.net/sprites/black-white/anim/ """ @@ -16,6 +17,8 @@ import re import time import urllib.request +import urllib.error +import urllib.parse from io import BytesIO from pathlib import Path @@ -35,6 +38,12 @@ "numbered": True, "sides": (("front", None),), }, + "gen4": { + "base": "https://archives.bulbagarden.net/wiki/Special:Redirect/file", + "title": "Pokemon Diamond/Pearl animated fronts, National Dex #001-#151.", + "numbered": "bulbagarden-gen4", + "sides": (("front", None),), + }, "gen5": { "base": "https://img.pokemondb.net/sprites/black-white/anim", "title": "Pokemon Black/White animated fronts and backs, #001-#151.", @@ -72,6 +81,32 @@ def download(url: str, retries: int = 4) -> bytes: raise AssertionError("unreachable") +def download_gen4(base: str, number: int) -> bytes: + """Fetch the ordinary Gen 4 front APNG, preferring the male variant. + + Diamond/Pearl stores sexually dimorphic species as `NNN m` and `NNN f` + while species without a visual variant use bare `NNN`. The engine has one + default front per species, so male is the predictable default; bare and + female are fallbacks for archive records that provide no male title. + """ + errors = [] + # Most species use the bare title. Archive redirects for a dimorphic + # species may already resolve that to its default; otherwise prefer male, + # then female, without making 151 guaranteed-failing male probes first. + for suffix in ("", " m", " f"): + filename = f"Spr 4d {number:03d}{suffix}.png" + url = f"{base}/{urllib.parse.quote(filename)}" + try: + raw = download(url, retries=1) + # A missing Special:Redirect title can return an HTML error page. + if not raw.startswith(b"\x89PNG\r\n\x1a\n"): + raise ValueError("response is not a PNG") + return raw + except (urllib.error.URLError, ValueError) as exc: + errors.append(f"{filename}: {exc}") + raise RuntimeError("; ".join(errors)) + + def convert(raw: bytes, destination: Path, columns: int = 16, max_dimension: int = 2048) -> dict: with Image.open(BytesIO(raw)) as gif: @@ -130,11 +165,15 @@ def main() -> None: for number, (engine_name, slug) in enumerate(species(root), 1): sides = {} for side, remote in config["sides"]: - url = (f"{base}/{number}.gif" if config["numbered"] - else f"{base}/{remote}/{slug}.gif") + if config["numbered"] == "bulbagarden-gen4": + raw = download_gen4(base, number) + else: + url = (f"{base}/{number}.gif" if config["numbered"] + else f"{base}/{remote}/{slug}.gif") + raw = download(url) relative = f"assets/battle/{side}-animated/{args.set}/{slug}.png" print(f"[{number:03d}/151] {side:5s} {slug}", flush=True) - sides[side] = (relative, convert(download(url), root / relative)) + sides[side] = (relative, convert(raw, root / relative)) records.append((engine_name, sides)) lines = [ diff --git a/tools/import_sugimori_static.py b/tools/import_sugimori_static.py new file mode 100644 index 0000000..d8842ce --- /dev/null +++ b/tools/import_sugimori_static.py @@ -0,0 +1,149 @@ +"""Build normalized 96x96 static fronts from archived Red/Green artwork. + +The high-resolution inputs and generated battle PNGs are local authoring +assets ignored by Git. This tool does not use or alter watermarked artwork. +""" + +from __future__ import annotations + +import argparse +import json +import re +import urllib.parse +import urllib.request +from pathlib import Path + +from PIL import Image, ImageEnhance + + +API = "https://archives.bulbagarden.net/w/api.php" +CATEGORY = "Category:Art_from_Pokémon_Red_and_Green" +CANVAS = 96 +ART_LIMIT = 88 +BOTTOM = 94 +ENTRY = re.compile( + r'^ ([A-Z0-9_]+) = \{\n' + r' front = \{ image = "assets/battle/front-animated/gen5/([^/]+)\.png"', + re.MULTILINE, +) + +# Filled after reviewing labelled contact sheets. Symmetric/front-on poses do +# not need an entry; only art whose subject actually faces right is mirrored. +FLIP_DEX: set[int] = set() + + +def species(root: Path) -> list[tuple[str, str]]: + source = (root / "data" / "animated_battle_sprites_gen5.lua").read_text( + encoding="utf-8" + ) + entries = ENTRY.findall(source) + if len(entries) != 151: + raise ValueError(f"expected 151 Kanto species definitions, got {len(entries)}") + return entries + + +def get_json(url: str) -> dict: + request = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"}) + with urllib.request.urlopen(request, timeout=30) as response: + return json.load(response) + + +def archive_manifest(cache: Path) -> dict[int, dict]: + saved = cache / "manifest.json" + if saved.exists(): + records = json.loads(saved.read_text(encoding="utf-8")) + return {int(key): value for key, value in records.items()} + query = urllib.parse.urlencode({ + "action": "query", + "generator": "categorymembers", + "gcmtitle": CATEGORY, + "gcmtype": "file", + "gcmlimit": "500", + "prop": "imageinfo", + "iiprop": "url|size", + "format": "json", + }) + data = get_json(f"{API}?{query}") + records = {} + pattern = re.compile(r"^File:(\d{3}).+ RG(?: 2)?\.png$") + for page in data["query"]["pages"].values(): + match = pattern.match(page["title"]) + if not match: + continue + number = int(match.group(1)) + info = page["imageinfo"][0] + records[number] = { + "title": page["title"], "url": info["url"], + "width": info["width"], "height": info["height"], + } + if set(records) != set(range(1, 152)): + missing = sorted(set(range(1, 152)) - set(records)) + raise ValueError(f"archive set is incomplete; missing {missing}") + cache.mkdir(parents=True, exist_ok=True) + saved.write_text(json.dumps(records, indent=2), encoding="utf-8") + return records + + +def download(url: str, destination: Path) -> None: + if destination.exists(): + return + request = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"}) + with urllib.request.urlopen(request, timeout=30) as response: + content = response.read() + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes(content) + + +def normalize(source: Path, destination: Path, flip: bool) -> None: + with Image.open(source) as opened: + image = opened.convert("RGBA") + alpha = image.getchannel("A") + box = alpha.getbbox() + if not box: + raise ValueError(f"{source} contains no visible artwork") + image = image.crop(box) + if flip: + image = image.transpose(Image.Transpose.FLIP_LEFT_RIGHT) + + # One restrained recipe across the entire set. It restores a little of + # the line/color separation lost at 96px without changing palettes. + r, g, b, a = image.split() + rgb = Image.merge("RGB", (r, g, b)) + rgb = ImageEnhance.Contrast(rgb).enhance(1.08) + rgb = ImageEnhance.Color(rgb).enhance(1.04) + rgb = ImageEnhance.Sharpness(rgb).enhance(1.05) + image = rgb.convert("RGBA") + image.putalpha(a) + + width, height = image.size + scale = min(ART_LIMIT / width, ART_LIMIT / height) + size = (max(1, round(width * scale)), max(1, round(height * scale))) + image = image.resize(size, Image.Resampling.LANCZOS) + canvas = Image.new("RGBA", (CANVAS, CANVAS)) + x = (CANVAS - size[0]) // 2 + y = BOTTOM - size[1] + canvas.alpha_composite(image, (x, y)) + destination.parent.mkdir(parents=True, exist_ok=True) + canvas.save(destination, optimize=True) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--root", type=Path, default=Path.cwd()) + parser.add_argument("--cache", type=Path) + args = parser.parse_args() + root = args.root.resolve() + cache = (args.cache or root.parent.parent / ".codex-temp" / "sugimori-rg") + cache = cache.resolve() + manifest = archive_manifest(cache) + for number, (_, slug) in enumerate(species(root), 1): + record = manifest[number] + source = cache / f"{number:03d}-{slug}.png" + print(f"[{number:03d}/151] {slug}", flush=True) + download(record["url"], source) + normalize(source, root / "assets" / "battle" / "front-static" + / f"{slug}.png", number in FLIP_DEX) + + +if __name__ == "__main__": + main() diff --git a/tools/package_mod.ps1 b/tools/package_mod.ps1 index 1142709..b1b0ef7 100644 --- a/tools/package_mod.ps1 +++ b/tools/package_mod.ps1 @@ -29,18 +29,32 @@ $contracts = @(Get-ChildItem -LiteralPath (Join-Path $repo 'assets\battle') ` # include them. This bridges a clean public branch and private BYO artwork. $localArt = @(Get-ChildItem -LiteralPath (Join-Path $repo 'assets\battle') ` -Recurse -File -Filter '*.png' -ErrorAction SilentlyContinue | ForEach-Object { - Relative-Path $_.FullName + $relative = Relative-Path $_.FullName + # Authoring drafts may live beside a collection in a folder literally + # named "backup". They are never runtime candidates and must not inflate + # or leak into the private test package. + if ($relative -notmatch '(?i)(^|/)backup(/|$)') { $relative } }) $files = @($source + $contracts + $localArt | Sort-Object -Unique) if (-not $files.Count) { throw "no package files found" } if (Test-Path -LiteralPath $Output) { Remove-Item -LiteralPath $Output -Force } +$fileList = [System.IO.Path]::GetTempFileName() +[System.IO.File]::WriteAllLines( + $fileList, + [string[]]$files, + [System.Text.UTF8Encoding]::new($false) +) Push-Location $repo try { - & tar -a -cf $Output @files + # Passing hundreds of asset paths as individual arguments exceeds the + # Windows command-line limit. tar's list-file option keeps the invocation + # short while preserving repository-relative paths inside the archive. + & tar -a -cf $Output -T $fileList if ($LASTEXITCODE -ne 0) { throw "tar failed: $LASTEXITCODE" } } finally { Pop-Location + Remove-Item -LiteralPath $fileList -Force -ErrorAction SilentlyContinue } $entries = @(tar -tf $Output) diff --git a/tools/player-animation-template-400x80.png b/tools/player-animation-template-400x80.png new file mode 100644 index 0000000000000000000000000000000000000000..3a81687c50959d3279c7db1ff851a6a603d72e1d GIT binary patch literal 1129 zcmeAS@N?(olHy`uVBq!ia0y~yV4MJC2XL?fNpl6|GeC;5ILO_JVcj{Imp~3nx}&cn z1H;CC?mvmFKt5-IM`SSr1K(i~W;~w1B87p0`JbnYV@O5Z+pC6o(u@Kuj{pCQ{?gQy zn!@bKXt*~~bi?t(t<0Q%pMR{V*->!*dH(&o(cf!o_U?W<&;I`Yo#pSp^8b0fyZZe< z)+hX%k3Vl(A1*e(zG1yN+x*uz*KfG*h|@z! zVdCF3rXmLiB}Pvcl})3Dj|R(VYQdX{ZvJn;lWCvJ$=5SR{J5$*OQU5kuy|nbboFyt I=akR{0LuM% Date: Sun, 2 Aug 2026 09:57:25 +0200 Subject: [PATCH 025/116] Add Yellow SGB static back-sprite importer --- assets/battle/back-static/README.md | 12 ++++ tools/import_yellow_sgb_back_sprites.py | 96 +++++++++++++++++++++++++ 2 files changed, 108 insertions(+) create mode 100644 tools/import_yellow_sgb_back_sprites.py diff --git a/assets/battle/back-static/README.md b/assets/battle/back-static/README.md index dcd7000..08ecba6 100644 --- a/assets/battle/back-static/README.md +++ b/assets/battle/back-static/README.md @@ -26,6 +26,18 @@ invalid, the ROM backsprite is used instead. An absent `gen1` directory does not remove GEN 1 from the menu; it is an intentional empty slot for ROM-hack or other user-supplied artwork. +For a complete Gen 1 set with prepared transparency, the optional importer +downloads the 151 Pokemon Yellow Super Game Boy back sprites from +[Bulbagarden Archives](https://archives.bulbagarden.net/wiki/Category:Yellow_back_sprites_(Super_Game_Boy)): + +```powershell +python tools/import_yellow_sgb_back_sprites.py --root . +``` + +It writes the source PNG bytes unchanged into `back-static/gen1`. The artwork +remains ignored by Git but is included by `tools/package_mod.ps1` in local test +ZIPs. + `BACK PLACEMENT` can override the layer for comparison. AUTO uses supplied generation PNGs in the world and keeps a missing ANIMATED fallback on OG UI; WORLD and OG UI force either presentation. diff --git a/tools/import_yellow_sgb_back_sprites.py b/tools/import_yellow_sgb_back_sprites.py new file mode 100644 index 0000000..285ed30 --- /dev/null +++ b/tools/import_yellow_sgb_back_sprites.py @@ -0,0 +1,96 @@ +"""Import the 151 Pokemon Yellow Super Game Boy back sprites. + +The downloaded PNGs are deliberately kept byte-for-byte as supplied by +Bulbagarden Archives. In particular, this tool does not resample the indexed +pixels or rewrite the source transparency. +""" + +from __future__ import annotations + +import argparse +import re +import time +import urllib.parse +import urllib.request +from io import BytesIO +from pathlib import Path + +from PIL import Image + + +SOURCE = "https://archives.bulbagarden.net/wiki/Special:Redirect/file" +ENTRY = re.compile( + r'^ ([A-Z0-9_]+) = \{\n' + r' front = \{ image = "assets/battle/front-animated/gen5/([^/]+)\.png"', + re.MULTILINE, +) + + +def species(root: Path) -> list[tuple[str, str]]: + source = (root / "data" / "animated_battle_sprites_gen5.lua").read_text( + encoding="utf-8" + ) + entries = ENTRY.findall(source) + if len(entries) != 151: + raise ValueError(f"expected 151 Kanto species definitions, got {len(entries)}") + return entries + + +def download(url: str, retries: int = 4) -> bytes: + request = urllib.request.Request( + url, + headers={"User-Agent": "DramaticShapeVoxelMod battle-art importer"}, + ) + for attempt in range(retries): + try: + with urllib.request.urlopen(request, timeout=30) as response: + return response.read() + except Exception: + if attempt + 1 == retries: + raise + time.sleep(1.5 * (attempt + 1)) + raise AssertionError("unreachable") + + +def validate_png(raw: bytes, number: int) -> tuple[int, int, str]: + if not raw.startswith(b"\x89PNG\r\n\x1a\n"): + raise ValueError(f"#{number:03d} response is not a PNG") + with Image.open(BytesIO(raw)) as image: + image.verify() + with Image.open(BytesIO(raw)) as image: + return image.width, image.height, image.mode + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--root", type=Path, default=Path.cwd()) + parser.add_argument("--base-url", default=SOURCE) + args = parser.parse_args() + + root = args.root.resolve() + destination = root / "assets" / "battle" / "back-static" / "gen1" + destination.mkdir(parents=True, exist_ok=True) + seen = set() + + roster = species(root) + for number, (_, slug) in enumerate(roster, 1): + archive_name = f"Spr b 1y {number:03d} SGB.png" + url = f"{args.base_url.rstrip('/')}/{urllib.parse.quote(archive_name)}" + raw = download(url) + width, height, mode = validate_png(raw, number) + output = destination / f"{slug}.png" + output.write_bytes(raw) + seen.add(output.name) + print( + f"[{number:03d}/151] {slug:<12s} {width}x{height} {mode}", + flush=True, + ) + + expected = {f"{slug}.png" for _, slug in roster} + if seen != expected: + raise RuntimeError("downloaded filename set does not match the Kanto roster") + print(f"wrote {len(seen)} unmodified PNGs to {destination}") + + +if __name__ == "__main__": + main() From 899a687c18d6ae043a7b1b4f5f4431b04a5cda30 Mon Sep 17 00:00:00 2001 From: absol89 Date: Sun, 2 Aug 2026 10:10:36 +0200 Subject: [PATCH 026/116] Add Emerald animated and static back art --- CHANGELOG.md | 2 +- README.md | 13 +- assets/battle/back-animated/README.md | 11 +- assets/battle/back-static/README.md | 11 +- data/animated_battle_backs_gen3.lua | 457 ++++++++++++++++++++++++++ lib/AnimatedBattleArt.lua | 9 +- lib/BattleArt.lua | 5 +- tools/import_emerald_back_sprites.py | 90 +++++ 8 files changed, 582 insertions(+), 16 deletions(-) create mode 100644 data/animated_battle_backs_gen3.lua create mode 100644 tools/import_emerald_back_sprites.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c1850a..04bc6b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,7 +40,7 @@ malformed atlases fall back per Pokemon to ROM art. - **Independent front/back sets.** `ANIM FRONT GEN` selects animated GEN 2, GEN 3, GEN 4, or GEN 5 fronts. `BACK ART SET` selects the static GEN 1–5 folder in - STATIC mode; ANIMATED uses static GEN 1–4 PNGs or animated GEN 5 atlases. + STATIC mode; ANIMATED uses animated GEN 3/5 atlases or static GEN 1/2/4 PNGs. The mode always decides whether atlas decoding is allowed. Supplied backs are world geometry; missing or malformed art falls back to ROM. - **Independent back placement.** `BACK PLACEMENT: AUTO / WORLD / OG UI` diff --git a/README.md b/README.md index 8b992e2..8414bb6 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ menu. | the **PLAYER ART** options row | PNG / GEN 1 / GEN 2 / GEN 3 / GEN 4 / GEN 5 / ASH / GARY / ROM — choose the static player trainer battle-intro portrait; BATTLE ART: ROM pins it to ROM | | the **PLAYER ANIM** options row | GEN 1 / GEN 2 / GEN 3 / GEN 4 / GEN 5 / ASH / GARY / ROM — choose a five-pose player intro while ANIMATED is selected | | the **ANIM FRONT GEN** options row | GEN 2 / GEN 3 / GEN 4 / GEN 5 — choose the animated front collection | -| the **BACK ART SET** options row | GEN 1 / GEN 2 / GEN 3 / GEN 4 / GEN 5 — STATIC always uses generation PNGs; ANIMATED uses Gen 1–4 PNGs or Gen 5 atlases | +| the **BACK ART SET** options row | GEN 1 / GEN 2 / GEN 3 / GEN 4 / GEN 5 — STATIC always uses generation PNGs; ANIMATED uses Gen 3/5 atlases and Gen 1/2/4 PNGs | | the **PLAYER** options row | FRONT SPRITES / BACK SPRITES — supplied art is world-placed; a missing selected back uses the ROM's UI-attached pic | | the **BACK PLACEMENT** options row | AUTO / WORLD / OG UI — use the mode-aware default or force every player back onto one layer | | battle HUD ink | HUD and dialogue glyphs stay white through every battle phase and receive a one-pixel dark shadow over transparent panels | @@ -73,9 +73,9 @@ their README contracts. `tools/package_mod.ps1` nevertheless includes local PNGs from them in a test ZIP, so artwork can stay private and uncommitted. `BATTLE ART: ANIMATED` reads independent `ANIM FRONT GEN` and `BACK ART SET` choices. Front choices remain animated atlases, including converted -Diamond/Pearl APNGs for GEN 4. Back GEN 1 through GEN 4 read -ordinary species PNGs from `back-static/gen1` through `back-static/gen4`; they -need no atlas or metadata. Back GEN 5 reads `back-animated/gen5` atlases. A +Diamond/Pearl APNGs for GEN 4. Animated back GEN 3 and GEN 5 read atlases from +`back-animated/gen3` and `back-animated/gen5`. Back GEN 1, GEN 2 and GEN 4 read +ordinary species PNGs from their `back-static` generation folders. A loaded back is world-placed, lit, depth-occluded and shadowed. If its selected PNG or atlas is missing or malformed, the unmodified ROM backsprite remains in the original UI layer, including its normal battle motion and filtering. @@ -85,6 +85,11 @@ an animated atlas. GEN 1 remains selectable when its directory is absent so users can create it for ROM-hack art. `ANIM FRONT GEN` remains exclusive to ANIMATED, and ROM ignores both selectors. +Run `python tools/import_emerald_back_sprites.py --root .` to import both the +animated Emerald `Spr b 3e` backs and the static `Spr b 3r` backs for the +first 151 species. The generated atlases and ordinary PNGs remain local, +ignored artwork; only their shared atlas metadata is committed. + `BACK PLACEMENT: AUTO` keeps STATIC-mode player backs in the world, including ROM fallbacks. Under ANIMATED, supplied Gen 1–4 PNGs and Gen 5 atlases stay in the world while a missing selection leaves the ROM backsprite on its OG UI diff --git a/assets/battle/back-animated/README.md b/assets/battle/back-animated/README.md index 87545f2..f9f27c5 100644 --- a/assets/battle/back-animated/README.md +++ b/assets/battle/back-animated/README.md @@ -2,9 +2,9 @@ Optional animated player-back battle atlases are grouped here by generation. Local PNG files are ignored by Git. Authored back art faces right and is never -mirrored. Only GEN 5 uses animated back atlases here. `BACK ART SET` choices -GEN 1 through GEN 4 instead read single-frame PNGs from sibling folders -`../back-static/gen1` through `../back-static/gen4`, with no atlas processing. +mirrored. GEN 3 and GEN 5 use animated back atlases here. `BACK ART SET` +choices GEN 1, GEN 2 and GEN 4 instead read single-frame PNGs from their +sibling `../back-static` generation folders, with no atlas processing. Missing selected art retains the ROM back sprite in its original UI layer. This selector only affects ANIMATED mode. @@ -45,6 +45,11 @@ users do not write one Lua file per Pokemon. Run `python tools/import_animated_sprites.py --set gen5` from the repository root to generate both Gen 5 fronts and backs. +For Emerald, run `python tools/import_emerald_back_sprites.py --root .`. It +converts all 151 `Spr b 3e` APNGs into `gen3` atlases, generates their shared +Lua metadata, and also copies the corresponding `Spr b 3r` single-frame PNGs +unchanged into `../back-static/gen3`. + ## Gen 1 filename exceptions Most species use their ordinary lowercase name (`pikachu.png`). These four diff --git a/assets/battle/back-static/README.md b/assets/battle/back-static/README.md index 08ecba6..c1fb690 100644 --- a/assets/battle/back-static/README.md +++ b/assets/battle/back-static/README.md @@ -20,9 +20,10 @@ ROM sprite. Under `BATTLE ART: STATIC`, all five choices read only these ordinary PNGs. Static GEN 5 never loads or decodes the similarly named animated atlas. -Under `BATTLE ART: ANIMATED`, GEN 1 through GEN 4 use the same single-frame -PNGs, while GEN 5 uses `back-animated/gen5`. If the selected file is absent or -invalid, the ROM backsprite is used instead. An absent `gen1` directory does +Under `BATTLE ART: ANIMATED`, GEN 3 and GEN 5 use atlases from their +`back-animated` folders. GEN 1, GEN 2 and GEN 4 use these same single-frame +PNGs. If the selected file is absent or invalid, the ROM backsprite is used +instead. An absent `gen1` directory does not remove GEN 1 from the menu; it is an intentional empty slot for ROM-hack or other user-supplied artwork. @@ -38,6 +39,10 @@ It writes the source PNG bytes unchanged into `back-static/gen1`. The artwork remains ignored by Git but is included by `tools/package_mod.ps1` in local test ZIPs. +For Gen 3, `python tools/import_emerald_back_sprites.py --root .` copies the +151 Emerald `Spr b 3r` source PNGs byte-for-byte into `back-static/gen3` while +also preparing the separate animated `Spr b 3e` collection. + `BACK PLACEMENT` can override the layer for comparison. AUTO uses supplied generation PNGs in the world and keeps a missing ANIMATED fallback on OG UI; WORLD and OG UI force either presentation. diff --git a/data/animated_battle_backs_gen3.lua b/data/animated_battle_backs_gen3.lua new file mode 100644 index 0000000..926d70d --- /dev/null +++ b/data/animated_battle_backs_gen3.lua @@ -0,0 +1,457 @@ +-- Generated by tools/import_emerald_back_sprites.py. +-- Pokemon Emerald animated backs, National Dex #001-#151. +return { + BULBASAUR = { + back = { image = "assets/battle/back-animated/gen3/bulbasaur.png", width = 64, height = 64, columns = 16, frames = 17, durations = {50,50,33,50,50,50,66,150,16,150,83,50,50,33,50,33,1083} }, + }, + IVYSAUR = { + back = { image = "assets/battle/back-animated/gen3/ivysaur.png", width = 64, height = 64, columns = 16, frames = 29, durations = {16,16,16,16,16,83,16,16,16,16,16,16,16,16,83,16,16,16,16,16,16,16,16,83,16,16,16,16,1050} }, + }, + VENUSAUR = { + back = { image = "assets/battle/back-animated/gen3/venusaur.png", width = 69, height = 69, columns = 16, frames = 38, durations = {16,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,16,16,1033} }, + }, + CHARMANDER = { + back = { image = "assets/battle/back-animated/gen3/charmander.png", width = 64, height = 64, columns = 16, frames = 31, durations = {33,33,33,33,16,16,16,16,16,33,16,33,33,33,33,16,16,33,16,16,16,33,16,16,16,16,33,16,16,33,1033} }, + }, + CHARMELEON = { + back = { image = "assets/battle/back-animated/gen3/charmeleon.png", width = 66, height = 66, columns = 16, frames = 30, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,33,16,16,16,1033} }, + }, + CHARIZARD = { + back = { image = "assets/battle/back-animated/gen3/charizard.png", width = 75, height = 75, columns = 16, frames = 40, durations = {66,66,66,33,66,66,66,66,100,100,100,117,16,16,33,16,16,16,16,16,33,16,16,16,16,33,16,16,133,100,100,100,66,66,66,66,33,66,66,1066} }, + }, + SQUIRTLE = { + back = { image = "assets/battle/back-animated/gen3/squirtle.png", width = 64, height = 64, columns = 16, frames = 31, durations = {33,33,33,33,16,16,16,16,16,33,16,33,33,33,33,16,16,33,16,16,16,33,16,16,16,16,33,16,16,33,1033} }, + }, + WARTORTLE = { + back = { image = "assets/battle/back-animated/gen3/wartortle.png", width = 64, height = 64, columns = 16, frames = 31, durations = {33,33,33,33,16,16,16,16,16,33,16,33,33,33,33,16,16,33,16,16,16,33,16,16,16,16,33,16,16,33,1033} }, + }, + BLASTOISE = { + back = { image = "assets/battle/back-animated/gen3/blastoise.png", width = 75, height = 75, columns = 16, frames = 40, durations = {66,66,66,33,66,66,66,66,100,100,100,117,16,16,33,16,16,16,16,16,33,16,16,16,16,33,16,16,133,100,100,100,66,66,66,66,33,66,66,1066} }, + }, + CATERPIE = { + back = { image = "assets/battle/back-animated/gen3/caterpie.png", width = 64, height = 64, columns = 16, frames = 29, durations = {16,16,16,16,16,83,16,16,16,16,16,16,16,16,83,16,16,16,16,16,16,16,16,83,16,16,16,16,1050} }, + }, + METAPOD = { + back = { image = "assets/battle/back-animated/gen3/metapod.png", width = 64, height = 64, columns = 16, frames = 17, durations = {50,50,33,50,50,50,66,150,16,150,83,50,50,33,50,33,1083} }, + }, + BUTTERFREE = { + back = { image = "assets/battle/back-animated/gen3/butterfree.png", width = 89, height = 89, columns = 16, frames = 73, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,33,33,16,16,83,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,16,33,33,16,16,83,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,1033} }, + }, + WEEDLE = { + back = { image = "assets/battle/back-animated/gen3/weedle.png", width = 64, height = 64, columns = 16, frames = 29, durations = {16,16,16,16,16,83,16,16,16,16,16,16,16,16,83,16,16,16,16,16,16,16,16,83,16,16,16,16,1050} }, + }, + KAKUNA = { + back = { image = "assets/battle/back-animated/gen3/kakuna.png", width = 64, height = 64, columns = 16, frames = 17, durations = {50,50,33,50,50,50,66,150,16,150,83,50,50,33,50,33,1083} }, + }, + BEEDRILL = { + back = { image = "assets/battle/back-animated/gen3/beedrill.png", width = 80, height = 80, columns = 16, frames = 39, durations = {33,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,1066} }, + }, + PIDGEY = { + back = { image = "assets/battle/back-animated/gen3/pidgey.png", width = 72, height = 72, columns = 16, frames = 18, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,1050} }, + }, + PIDGEOTTO = { + back = { image = "assets/battle/back-animated/gen3/pidgeotto.png", width = 74, height = 74, columns = 16, frames = 30, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,33,16,16,16,1033} }, + }, + PIDGEOT = { + back = { image = "assets/battle/back-animated/gen3/pidgeot.png", width = 76, height = 76, columns = 16, frames = 18, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,1050} }, + }, + RATTATA = { + back = { image = "assets/battle/back-animated/gen3/rattata.png", width = 79, height = 79, columns = 16, frames = 43, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,1050} }, + }, + RATICATE = { + back = { image = "assets/battle/back-animated/gen3/raticate.png", width = 86, height = 86, columns = 16, frames = 43, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,1050} }, + }, + SPEAROW = { + back = { image = "assets/battle/back-animated/gen3/spearow.png", width = 67, height = 67, columns = 16, frames = 18, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,1050} }, + }, + FEAROW = { + back = { image = "assets/battle/back-animated/gen3/fearow.png", width = 82, height = 82, columns = 16, frames = 30, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,33,16,16,16,1033} }, + }, + EKANS = { + back = { image = "assets/battle/back-animated/gen3/ekans.png", width = 71, height = 71, columns = 16, frames = 18, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,1050} }, + }, + ARBOK = { + back = { image = "assets/battle/back-animated/gen3/arbok.png", width = 66, height = 66, columns = 16, frames = 37, durations = {16,16,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,16,16,1050} }, + }, + PIKACHU = { + back = { image = "assets/battle/back-animated/gen3/pikachu.png", width = 64, height = 64, columns = 16, frames = 30, durations = {50,50,33,16,50,50,50,50,50,50,33,16,33,16,33,16,33,16,33,16,33,16,33,16,33,16,33,16,33,1033} }, + }, + RAICHU = { + back = { image = "assets/battle/back-animated/gen3/raichu.png", width = 64, height = 64, columns = 16, frames = 30, durations = {50,50,33,16,50,50,50,50,50,50,33,16,33,16,33,16,33,16,33,16,33,16,33,16,33,16,33,16,33,1033} }, + }, + SANDSHREW = { + back = { image = "assets/battle/back-animated/gen3/sandshrew.png", width = 64, height = 64, columns = 16, frames = 31, durations = {33,33,33,33,16,16,16,16,16,33,16,33,33,33,33,16,16,33,16,16,16,33,16,16,16,16,33,16,16,33,1033} }, + }, + SANDSLASH = { + back = { image = "assets/battle/back-animated/gen3/sandslash.png", width = 81, height = 81, columns = 16, frames = 44, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,1033} }, + }, + NIDORAN_F = { + back = { image = "assets/battle/back-animated/gen3/nidoran-f.png", width = 64, height = 64, columns = 16, frames = 31, durations = {33,33,33,33,16,16,16,16,16,33,16,33,33,33,33,16,16,33,16,16,16,33,16,16,16,16,33,16,16,33,1033} }, + }, + NIDORINA = { + back = { image = "assets/battle/back-animated/gen3/nidorina.png", width = 74, height = 74, columns = 16, frames = 30, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,33,16,16,16,1033} }, + }, + NIDOQUEEN = { + back = { image = "assets/battle/back-animated/gen3/nidoqueen.png", width = 64, height = 64, columns = 16, frames = 37, durations = {16,16,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,16,16,1050} }, + }, + NIDORAN_M = { + back = { image = "assets/battle/back-animated/gen3/nidoran-m.png", width = 64, height = 64, columns = 16, frames = 31, durations = {33,33,33,33,16,16,16,16,16,33,16,33,33,33,33,16,16,33,16,16,16,33,16,16,16,16,33,16,16,33,1033} }, + }, + NIDORINO = { + back = { image = "assets/battle/back-animated/gen3/nidorino.png", width = 78, height = 78, columns = 16, frames = 30, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,33,16,16,16,1033} }, + }, + NIDOKING = { + back = { image = "assets/battle/back-animated/gen3/nidoking.png", width = 67, height = 67, columns = 16, frames = 37, durations = {16,16,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,16,16,1050} }, + }, + CLEFAIRY = { + back = { image = "assets/battle/back-animated/gen3/clefairy.png", width = 64, height = 64, columns = 16, frames = 17, durations = {50,50,33,50,50,50,66,150,16,150,83,50,50,33,50,33,1083} }, + }, + CLEFABLE = { + back = { image = "assets/battle/back-animated/gen3/clefable.png", width = 64, height = 64, columns = 16, frames = 17, durations = {50,50,33,50,50,50,66,150,16,150,83,50,50,33,50,33,1083} }, + }, + VULPIX = { + back = { image = "assets/battle/back-animated/gen3/vulpix.png", width = 74, height = 74, columns = 16, frames = 44, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,1033} }, + }, + NINETALES = { + back = { image = "assets/battle/back-animated/gen3/ninetales.png", width = 66, height = 66, columns = 16, frames = 39, durations = {33,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,1066} }, + }, + JIGGLYPUFF = { + back = { image = "assets/battle/back-animated/gen3/jigglypuff.png", width = 64, height = 64, columns = 16, frames = 17, durations = {50,50,33,50,50,50,66,150,16,150,83,50,50,33,50,33,1083} }, + }, + WIGGLYTUFF = { + back = { image = "assets/battle/back-animated/gen3/wigglytuff.png", width = 64, height = 64, columns = 16, frames = 53, durations = {16,33,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,83,16,83,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,50,1050} }, + }, + ZUBAT = { + back = { image = "assets/battle/back-animated/gen3/zubat.png", width = 84, height = 84, columns = 16, frames = 73, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,33,33,16,16,83,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,16,33,33,16,16,83,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,1033} }, + }, + GOLBAT = { + back = { image = "assets/battle/back-animated/gen3/golbat.png", width = 64, height = 64, columns = 16, frames = 37, durations = {16,16,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,16,16,1050} }, + }, + ODDISH = { + back = { image = "assets/battle/back-animated/gen3/oddish.png", width = 64, height = 64, columns = 16, frames = 29, durations = {16,16,16,16,16,83,16,16,16,16,16,16,16,16,83,16,16,16,16,16,16,16,16,83,16,16,16,16,1050} }, + }, + GLOOM = { + back = { image = "assets/battle/back-animated/gen3/gloom.png", width = 64, height = 64, columns = 16, frames = 29, durations = {16,16,16,16,16,83,16,16,16,16,16,16,16,16,83,16,16,16,16,16,16,16,16,83,16,16,16,16,1050} }, + }, + VILEPLUME = { + back = { image = "assets/battle/back-animated/gen3/vileplume.png", width = 69, height = 69, columns = 16, frames = 41, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,1050} }, + }, + PARAS = { + back = { image = "assets/battle/back-animated/gen3/paras.png", width = 64, height = 64, columns = 16, frames = 29, durations = {16,16,16,16,16,83,16,16,16,16,16,16,16,16,83,16,16,16,16,16,16,16,16,83,16,16,16,16,1050} }, + }, + PARASECT = { + back = { image = "assets/battle/back-animated/gen3/parasect.png", width = 64, height = 64, columns = 16, frames = 38, durations = {16,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,16,16,1033} }, + }, + VENONAT = { + back = { image = "assets/battle/back-animated/gen3/venonat.png", width = 83, height = 83, columns = 16, frames = 43, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,1050} }, + }, + VENOMOTH = { + back = { image = "assets/battle/back-animated/gen3/venomoth.png", width = 86, height = 86, columns = 16, frames = 73, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,33,33,16,16,83,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,16,33,33,16,16,83,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,1033} }, + }, + DIGLETT = { + back = { image = "assets/battle/back-animated/gen3/diglett.png", width = 64, height = 64, columns = 16, frames = 37, durations = {16,16,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,16,16,1050} }, + }, + DUGTRIO = { + back = { image = "assets/battle/back-animated/gen3/dugtrio.png", width = 64, height = 64, columns = 16, frames = 37, durations = {16,16,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,16,16,1050} }, + }, + MEOWTH = { + back = { image = "assets/battle/back-animated/gen3/meowth.png", width = 70, height = 70, columns = 16, frames = 44, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,1033} }, + }, + PERSIAN = { + back = { image = "assets/battle/back-animated/gen3/persian.png", width = 85, height = 85, columns = 16, frames = 18, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,1050} }, + }, + PSYDUCK = { + back = { image = "assets/battle/back-animated/gen3/psyduck.png", width = 64, height = 64, columns = 16, frames = 29, durations = {16,16,16,16,16,83,16,16,16,16,16,16,16,16,83,16,16,16,16,16,16,16,16,83,16,16,16,16,1050} }, + }, + GOLDUCK = { + back = { image = "assets/battle/back-animated/gen3/golduck.png", width = 66, height = 66, columns = 16, frames = 41, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,1050} }, + }, + MANKEY = { + back = { image = "assets/battle/back-animated/gen3/mankey.png", width = 72, height = 72, columns = 16, frames = 44, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,1033} }, + }, + PRIMEAPE = { + back = { image = "assets/battle/back-animated/gen3/primeape.png", width = 75, height = 75, columns = 16, frames = 44, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,1033} }, + }, + GROWLITHE = { + back = { image = "assets/battle/back-animated/gen3/growlithe.png", width = 65, height = 65, columns = 16, frames = 30, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,33,16,16,16,1033} }, + }, + ARCANINE = { + back = { image = "assets/battle/back-animated/gen3/arcanine.png", width = 81, height = 81, columns = 16, frames = 30, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,33,16,16,16,1033} }, + }, + POLIWAG = { + back = { image = "assets/battle/back-animated/gen3/poliwag.png", width = 64, height = 64, columns = 16, frames = 31, durations = {33,33,33,33,16,16,16,16,16,33,16,33,33,33,33,16,16,33,16,16,16,33,16,16,16,16,33,16,16,33,1033} }, + }, + POLIWHIRL = { + back = { image = "assets/battle/back-animated/gen3/poliwhirl.png", width = 64, height = 64, columns = 16, frames = 37, durations = {16,16,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,16,16,1050} }, + }, + POLIWRATH = { + back = { image = "assets/battle/back-animated/gen3/poliwrath.png", width = 64, height = 64, columns = 16, frames = 36, durations = {16,16,16,16,16,16,33,16,33,16,33,16,33,16,16,33,66,385,16,16,16,16,16,16,16,16,33,16,33,16,16,16,16,50,50,1150} }, + }, + ABRA = { + back = { image = "assets/battle/back-animated/gen3/abra.png", width = 64, height = 64, columns = 16, frames = 41, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,1050} }, + }, + KADABRA = { + back = { image = "assets/battle/back-animated/gen3/kadabra.png", width = 64, height = 64, columns = 16, frames = 41, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,1050} }, + }, + ALAKAZAM = { + back = { image = "assets/battle/back-animated/gen3/alakazam.png", width = 73, height = 73, columns = 16, frames = 31, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,1050} }, + }, + MACHOP = { + back = { image = "assets/battle/back-animated/gen3/machop.png", width = 65, height = 65, columns = 16, frames = 30, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,33,16,16,16,1033} }, + }, + MACHOKE = { + back = { image = "assets/battle/back-animated/gen3/machoke.png", width = 64, height = 64, columns = 16, frames = 37, durations = {16,16,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,16,16,1050} }, + }, + MACHAMP = { + back = { image = "assets/battle/back-animated/gen3/machamp.png", width = 66, height = 66, columns = 16, frames = 37, durations = {16,16,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,16,16,1050} }, + }, + BELLSPROUT = { + back = { image = "assets/battle/back-animated/gen3/bellsprout.png", width = 64, height = 64, columns = 16, frames = 29, durations = {16,33,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,1050} }, + }, + WEEPINBELL = { + back = { image = "assets/battle/back-animated/gen3/weepinbell.png", width = 64, height = 64, columns = 16, frames = 30, durations = {16,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,1050} }, + }, + VICTREEBEL = { + back = { image = "assets/battle/back-animated/gen3/victreebel.png", width = 71, height = 71, columns = 16, frames = 31, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,1050} }, + }, + TENTACOOL = { + back = { image = "assets/battle/back-animated/gen3/tentacool.png", width = 64, height = 64, columns = 16, frames = 29, durations = {16,16,16,16,16,83,16,16,16,16,16,16,16,16,83,16,16,16,16,16,16,16,16,83,16,16,16,16,1050} }, + }, + TENTACRUEL = { + back = { image = "assets/battle/back-animated/gen3/tentacruel.png", width = 71, height = 71, columns = 16, frames = 29, durations = {16,16,16,16,16,83,16,16,16,16,16,16,16,16,83,16,16,16,16,16,16,16,16,83,16,16,16,16,1050} }, + }, + GEODUDE = { + back = { image = "assets/battle/back-animated/gen3/geodude.png", width = 64, height = 64, columns = 16, frames = 36, durations = {16,16,16,16,16,16,33,16,33,16,33,16,33,16,16,33,66,385,16,16,16,16,16,16,16,16,33,16,33,16,16,16,16,50,50,1150} }, + }, + GRAVELER = { + back = { image = "assets/battle/back-animated/gen3/graveler.png", width = 64, height = 64, columns = 16, frames = 38, durations = {16,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,16,16,1033} }, + }, + GOLEM = { + back = { image = "assets/battle/back-animated/gen3/golem.png", width = 67, height = 67, columns = 16, frames = 38, durations = {16,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,16,16,1033} }, + }, + PONYTA = { + back = { image = "assets/battle/back-animated/gen3/ponyta.png", width = 64, height = 64, columns = 16, frames = 40, durations = {66,66,66,33,66,66,66,66,100,100,100,117,16,16,33,16,16,16,16,16,33,16,16,16,16,33,16,16,133,100,100,100,66,66,66,66,33,66,66,1066} }, + }, + RAPIDASH = { + back = { image = "assets/battle/back-animated/gen3/rapidash.png", width = 80, height = 80, columns = 16, frames = 30, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,33,16,16,16,1033} }, + }, + SLOWPOKE = { + back = { image = "assets/battle/back-animated/gen3/slowpoke.png", width = 68, height = 68, columns = 16, frames = 29, durations = {16,16,16,16,16,83,16,16,16,16,16,16,16,16,83,16,16,16,16,16,16,16,16,83,16,16,16,16,1050} }, + }, + SLOWBRO = { + back = { image = "assets/battle/back-animated/gen3/slowbro.png", width = 67, height = 67, columns = 16, frames = 17, durations = {50,50,33,50,50,50,66,150,16,150,83,50,50,33,50,33,1083} }, + }, + MAGNEMITE = { + back = { image = "assets/battle/back-animated/gen3/magnemite.png", width = 64, height = 64, columns = 16, frames = 18, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,1050} }, + }, + MAGNETON = { + back = { image = "assets/battle/back-animated/gen3/magneton.png", width = 72, height = 72, columns = 16, frames = 18, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,1050} }, + }, + FARFETCHD = { + back = { image = "assets/battle/back-animated/gen3/farfetchd.png", width = 64, height = 64, columns = 16, frames = 29, durations = {16,16,16,16,16,83,16,16,16,16,16,16,16,16,83,16,16,16,16,16,16,16,16,83,16,16,16,16,1050} }, + }, + DODUO = { + back = { image = "assets/battle/back-animated/gen3/doduo.png", width = 70, height = 70, columns = 16, frames = 18, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,1050} }, + }, + DODRIO = { + back = { image = "assets/battle/back-animated/gen3/dodrio.png", width = 81, height = 81, columns = 16, frames = 30, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,33,16,16,16,1033} }, + }, + SEEL = { + back = { image = "assets/battle/back-animated/gen3/seel.png", width = 65, height = 65, columns = 16, frames = 17, durations = {50,50,33,50,50,50,66,150,16,150,83,50,50,33,50,33,1083} }, + }, + DEWGONG = { + back = { image = "assets/battle/back-animated/gen3/dewgong.png", width = 64, height = 64, columns = 16, frames = 29, durations = {16,16,16,16,16,83,16,16,16,16,16,16,16,16,83,16,16,16,16,16,16,16,16,83,16,16,16,16,1050} }, + }, + GRIMER = { + back = { image = "assets/battle/back-animated/gen3/grimer.png", width = 64, height = 64, columns = 16, frames = 30, durations = {16,33,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,1050} }, + }, + MUK = { + back = { image = "assets/battle/back-animated/gen3/muk.png", width = 92, height = 92, columns = 16, frames = 31, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,1050} }, + }, + SHELLDER = { + back = { image = "assets/battle/back-animated/gen3/shellder.png", width = 64, height = 64, columns = 16, frames = 17, durations = {50,50,33,50,50,50,66,150,16,150,83,50,50,33,50,33,1083} }, + }, + CLOYSTER = { + back = { image = "assets/battle/back-animated/gen3/cloyster.png", width = 81, height = 81, columns = 16, frames = 18, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,1050} }, + }, + GASTLY = { + back = { image = "assets/battle/back-animated/gen3/gastly.png", width = 81, height = 81, columns = 16, frames = 39, durations = {33,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,1066} }, + }, + HAUNTER = { + back = { image = "assets/battle/back-animated/gen3/haunter.png", width = 68, height = 68, columns = 16, frames = 39, durations = {33,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,1066} }, + }, + GENGAR = { + back = { image = "assets/battle/back-animated/gen3/gengar.png", width = 64, height = 64, columns = 16, frames = 41, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,1050} }, + }, + ONIX = { + back = { image = "assets/battle/back-animated/gen3/onix.png", width = 70, height = 70, columns = 16, frames = 37, durations = {16,16,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,16,16,1050} }, + }, + DROWZEE = { + back = { image = "assets/battle/back-animated/gen3/drowzee.png", width = 64, height = 64, columns = 16, frames = 17, durations = {50,50,33,50,50,50,66,150,16,150,83,50,50,33,50,33,1083} }, + }, + HYPNO = { + back = { image = "assets/battle/back-animated/gen3/hypno.png", width = 64, height = 64, columns = 16, frames = 41, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,1050} }, + }, + KRABBY = { + back = { image = "assets/battle/back-animated/gen3/krabby.png", width = 80, height = 80, columns = 16, frames = 43, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,1050} }, + }, + KINGLER = { + back = { image = "assets/battle/back-animated/gen3/kingler.png", width = 66, height = 66, columns = 16, frames = 37, durations = {16,16,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,16,16,1050} }, + }, + VOLTORB = { + back = { image = "assets/battle/back-animated/gen3/voltorb.png", width = 64, height = 64, columns = 16, frames = 30, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,33,16,16,16,1033} }, + }, + ELECTRODE = { + back = { image = "assets/battle/back-animated/gen3/electrode.png", width = 64, height = 64, columns = 16, frames = 30, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,33,16,16,16,1033} }, + }, + EXEGGCUTE = { + back = { image = "assets/battle/back-animated/gen3/exeggcute.png", width = 64, height = 64, columns = 16, frames = 29, durations = {16,16,16,16,16,83,16,16,16,16,16,16,16,16,83,16,16,16,16,16,16,16,16,83,16,16,16,16,1050} }, + }, + EXEGGUTOR = { + back = { image = "assets/battle/back-animated/gen3/exeggutor.png", width = 87, height = 87, columns = 16, frames = 44, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,1033} }, + }, + CUBONE = { + back = { image = "assets/battle/back-animated/gen3/cubone.png", width = 66, height = 66, columns = 16, frames = 30, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,33,16,16,16,1033} }, + }, + MAROWAK = { + back = { image = "assets/battle/back-animated/gen3/marowak.png", width = 68, height = 68, columns = 16, frames = 44, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,1033} }, + }, + HITMONLEE = { + back = { image = "assets/battle/back-animated/gen3/hitmonlee.png", width = 64, height = 64, columns = 16, frames = 29, durations = {16,16,16,16,16,83,16,16,16,16,16,16,16,16,83,16,16,16,16,16,16,16,16,83,16,16,16,16,1050} }, + }, + HITMONCHAN = { + back = { image = "assets/battle/back-animated/gen3/hitmonchan.png", width = 70, height = 70, columns = 16, frames = 18, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,1050} }, + }, + LICKITUNG = { + back = { image = "assets/battle/back-animated/gen3/lickitung.png", width = 64, height = 64, columns = 16, frames = 29, durations = {16,16,16,16,16,83,16,16,16,16,16,16,16,16,83,16,16,16,16,16,16,16,16,83,16,16,16,16,1050} }, + }, + KOFFING = { + back = { image = "assets/battle/back-animated/gen3/koffing.png", width = 64, height = 64, columns = 16, frames = 53, durations = {16,33,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,83,16,83,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,50,1050} }, + }, + WEEZING = { + back = { image = "assets/battle/back-animated/gen3/weezing.png", width = 71, height = 71, columns = 16, frames = 55, durations = {16,33,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,33,50,16,50,33,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,50,1050} }, + }, + RHYHORN = { + back = { image = "assets/battle/back-animated/gen3/rhyhorn.png", width = 64, height = 64, columns = 16, frames = 36, durations = {16,16,16,16,16,16,33,16,33,16,33,16,33,16,16,33,66,385,16,16,16,16,16,16,16,16,33,16,33,16,16,16,16,50,50,1150} }, + }, + RHYDON = { + back = { image = "assets/battle/back-animated/gen3/rhydon.png", width = 71, height = 71, columns = 16, frames = 36, durations = {16,16,16,16,16,16,33,16,33,16,33,16,33,16,16,33,66,385,16,16,16,16,16,16,16,16,33,16,33,16,16,16,16,50,50,1150} }, + }, + CHANSEY = { + back = { image = "assets/battle/back-animated/gen3/chansey.png", width = 66, height = 66, columns = 16, frames = 31, durations = {33,33,33,33,16,16,16,16,16,33,16,33,33,33,33,16,16,33,16,16,16,33,16,16,16,16,33,16,16,33,1033} }, + }, + TANGELA = { + back = { image = "assets/battle/back-animated/gen3/tangela.png", width = 64, height = 64, columns = 16, frames = 29, durations = {16,33,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,33,1050} }, + }, + KANGASKHAN = { + back = { image = "assets/battle/back-animated/gen3/kangaskhan.png", width = 65, height = 65, columns = 16, frames = 31, durations = {33,33,33,33,16,16,16,16,16,33,16,33,33,33,33,16,16,33,16,16,16,33,16,16,16,16,33,16,16,33,1033} }, + }, + HORSEA = { + back = { image = "assets/battle/back-animated/gen3/horsea.png", width = 64, height = 64, columns = 16, frames = 17, durations = {50,50,33,50,50,50,66,150,16,150,83,50,50,33,50,33,1083} }, + }, + SEADRA = { + back = { image = "assets/battle/back-animated/gen3/seadra.png", width = 78, height = 78, columns = 16, frames = 73, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,33,33,16,16,83,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,16,33,33,16,16,83,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,1033} }, + }, + GOLDEEN = { + back = { image = "assets/battle/back-animated/gen3/goldeen.png", width = 78, height = 78, columns = 16, frames = 73, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,33,33,16,16,83,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,16,33,33,16,16,83,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,1033} }, + }, + SEAKING = { + back = { image = "assets/battle/back-animated/gen3/seaking.png", width = 83, height = 83, columns = 16, frames = 73, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,33,33,16,16,83,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,16,33,33,16,16,83,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,1033} }, + }, + STARYU = { + back = { image = "assets/battle/back-animated/gen3/staryu.png", width = 64, height = 64, columns = 16, frames = 17, durations = {50,50,33,50,50,50,66,150,16,150,83,50,50,33,50,33,1083} }, + }, + STARMIE = { + back = { image = "assets/battle/back-animated/gen3/starmie.png", width = 70, height = 70, columns = 16, frames = 40, durations = {66,66,66,33,66,66,66,66,100,100,100,117,16,16,33,16,16,16,16,16,33,16,16,16,16,33,16,16,133,100,100,100,66,66,66,66,33,66,66,1066} }, + }, + MR_MIME = { + back = { image = "assets/battle/back-animated/gen3/mr-mime.png", width = 73, height = 73, columns = 16, frames = 41, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,1050} }, + }, + SCYTHER = { + back = { image = "assets/battle/back-animated/gen3/scyther.png", width = 76, height = 76, columns = 16, frames = 18, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,1050} }, + }, + JYNX = { + back = { image = "assets/battle/back-animated/gen3/jynx.png", width = 64, height = 64, columns = 16, frames = 17, durations = {50,50,33,50,50,50,66,150,16,150,83,50,50,33,50,33,1083} }, + }, + ELECTABUZZ = { + back = { image = "assets/battle/back-animated/gen3/electabuzz.png", width = 64, height = 64, columns = 16, frames = 30, durations = {50,50,33,16,50,50,50,50,50,50,33,16,33,16,33,16,33,16,33,16,33,16,33,16,33,16,33,16,33,1033} }, + }, + MAGMAR = { + back = { image = "assets/battle/back-animated/gen3/magmar.png", width = 64, height = 64, columns = 16, frames = 40, durations = {66,66,66,33,66,66,66,66,100,100,100,117,16,16,33,16,16,16,16,16,33,16,16,16,16,33,16,16,133,100,100,100,66,66,66,66,33,66,66,1066} }, + }, + PINSIR = { + back = { image = "assets/battle/back-animated/gen3/pinsir.png", width = 65, height = 65, columns = 16, frames = 36, durations = {16,16,16,16,16,16,33,16,33,16,33,16,33,16,16,33,66,385,16,16,16,16,16,16,16,16,33,16,33,16,16,16,16,50,50,1150} }, + }, + TAUROS = { + back = { image = "assets/battle/back-animated/gen3/tauros.png", width = 64, height = 64, columns = 16, frames = 36, durations = {16,16,16,16,16,16,33,16,33,16,33,16,33,16,16,33,66,385,16,16,16,16,16,16,16,16,33,16,33,16,16,16,16,50,50,1150} }, + }, + MAGIKARP = { + back = { image = "assets/battle/back-animated/gen3/magikarp.png", width = 75, height = 75, columns = 16, frames = 44, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,1033} }, + }, + GYARADOS = { + back = { image = "assets/battle/back-animated/gen3/gyarados.png", width = 70, height = 70, columns = 16, frames = 37, durations = {16,16,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,16,16,1050} }, + }, + LAPRAS = { + back = { image = "assets/battle/back-animated/gen3/lapras.png", width = 65, height = 65, columns = 16, frames = 40, durations = {66,66,66,33,66,66,66,66,100,100,100,117,16,16,33,16,16,16,16,16,33,16,16,16,16,33,16,16,133,100,100,100,66,66,66,66,33,66,66,1066} }, + }, + DITTO = { + back = { image = "assets/battle/back-animated/gen3/ditto.png", width = 64, height = 64, columns = 16, frames = 47, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,50,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,1050} }, + }, + EEVEE = { + back = { image = "assets/battle/back-animated/gen3/eevee.png", width = 64, height = 64, columns = 16, frames = 31, durations = {33,33,33,33,16,16,16,16,16,33,16,33,33,33,33,16,16,33,16,16,16,33,16,16,16,16,33,16,16,33,1033} }, + }, + VAPOREON = { + back = { image = "assets/battle/back-animated/gen3/vaporeon.png", width = 64, height = 64, columns = 16, frames = 40, durations = {66,66,66,33,66,66,66,66,100,100,100,117,16,16,33,16,16,16,16,16,33,16,16,16,16,33,16,16,133,100,100,100,66,66,66,66,33,66,66,1066} }, + }, + JOLTEON = { + back = { image = "assets/battle/back-animated/gen3/jolteon.png", width = 64, height = 64, columns = 16, frames = 30, durations = {50,50,33,16,50,50,50,50,50,50,33,16,33,16,33,16,33,16,33,16,33,16,33,16,33,16,33,16,33,1033} }, + }, + FLAREON = { + back = { image = "assets/battle/back-animated/gen3/flareon.png", width = 64, height = 64, columns = 16, frames = 40, durations = {66,66,66,33,66,66,66,66,100,100,100,117,16,16,33,16,16,16,16,16,33,16,16,16,16,33,16,16,133,100,100,100,66,66,66,66,33,66,66,1066} }, + }, + PORYGON = { + back = { image = "assets/battle/back-animated/gen3/porygon.png", width = 64, height = 64, columns = 16, frames = 39, durations = {33,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,1066} }, + }, + OMANYTE = { + back = { image = "assets/battle/back-animated/gen3/omanyte.png", width = 64, height = 64, columns = 16, frames = 17, durations = {50,50,33,50,50,50,66,150,16,150,83,50,50,33,50,33,1083} }, + }, + OMASTAR = { + back = { image = "assets/battle/back-animated/gen3/omastar.png", width = 66, height = 66, columns = 16, frames = 17, durations = {50,50,33,50,50,50,66,150,16,150,83,50,50,33,50,33,1083} }, + }, + KABUTO = { + back = { image = "assets/battle/back-animated/gen3/kabuto.png", width = 64, height = 64, columns = 16, frames = 17, durations = {50,50,33,50,50,50,66,150,16,150,83,50,50,33,50,33,1083} }, + }, + KABUTOPS = { + back = { image = "assets/battle/back-animated/gen3/kabutops.png", width = 69, height = 69, columns = 16, frames = 30, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,33,16,16,16,1033} }, + }, + AERODACTYL = { + back = { image = "assets/battle/back-animated/gen3/aerodactyl.png", width = 79, height = 79, columns = 16, frames = 30, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,33,16,16,16,1033} }, + }, + SNORLAX = { + back = { image = "assets/battle/back-animated/gen3/snorlax.png", width = 67, height = 67, columns = 16, frames = 17, durations = {50,50,33,50,50,50,66,150,16,150,83,50,50,33,50,33,1083} }, + }, + ARTICUNO = { + back = { image = "assets/battle/back-animated/gen3/articuno.png", width = 64, height = 64, columns = 16, frames = 40, durations = {66,66,66,33,66,66,66,66,100,100,100,117,16,16,33,16,16,16,16,16,33,16,16,16,16,33,16,16,133,100,100,100,66,66,66,66,33,66,66,1066} }, + }, + ZAPDOS = { + back = { image = "assets/battle/back-animated/gen3/zapdos.png", width = 64, height = 64, columns = 16, frames = 30, durations = {50,50,33,16,50,50,50,50,50,50,33,16,33,16,33,16,33,16,33,16,33,16,33,16,33,16,33,16,33,1033} }, + }, + MOLTRES = { + back = { image = "assets/battle/back-animated/gen3/moltres.png", width = 67, height = 67, columns = 16, frames = 40, durations = {66,66,66,33,66,66,66,66,100,100,100,117,16,16,33,16,16,16,16,16,33,16,16,16,16,33,16,16,133,100,100,100,66,66,66,66,33,66,66,1066} }, + }, + DRATINI = { + back = { image = "assets/battle/back-animated/gen3/dratini.png", width = 64, height = 64, columns = 16, frames = 29, durations = {16,16,16,16,16,83,16,16,16,16,16,16,16,16,83,16,16,16,16,16,16,16,16,83,16,16,16,16,1050} }, + }, + DRAGONAIR = { + back = { image = "assets/battle/back-animated/gen3/dragonair.png", width = 76, height = 76, columns = 16, frames = 18, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,1050} }, + }, + DRAGONITE = { + back = { image = "assets/battle/back-animated/gen3/dragonite.png", width = 64, height = 64, columns = 16, frames = 37, durations = {16,16,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,16,16,1050} }, + }, + MEWTWO = { + back = { image = "assets/battle/back-animated/gen3/mewtwo.png", width = 84, height = 84, columns = 16, frames = 31, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,1050} }, + }, + MEW = { + back = { image = "assets/battle/back-animated/gen3/mew.png", width = 64, height = 64, columns = 16, frames = 31, durations = {33,33,33,33,16,16,16,16,16,33,16,33,33,33,33,16,16,33,16,16,16,33,16,16,16,16,33,16,16,33,1033} }, + }, +} diff --git a/lib/AnimatedBattleArt.lua b/lib/AnimatedBattleArt.lua index 16195db..3e3ba6c 100644 --- a/lib/AnimatedBattleArt.lua +++ b/lib/AnimatedBattleArt.lua @@ -13,6 +13,10 @@ local SETS = { gen4 = V.data("animated_battle_sprites_gen4"), gen5 = V.data("animated_battle_sprites_gen5"), } +local BACK_SETS = { + gen3 = V.data("animated_battle_backs_gen3"), + gen5 = SETS.gen5, +} local PLAYER_SETS = V.data("animated_player_trainers") local AnimatedBattleArt = {} @@ -170,7 +174,8 @@ local function definition(battler, side) local key = species and tostring(species):upper() local setting = side == "back" and BattleArt.backAnimationSetting or BattleArt.frontAnimationSetting - local selected = SETS[setting:get()] + local collections = side == "back" and BACK_SETS or SETS + local selected = collections[setting:get()] local bySide = selected and key and selected[key] return bySide and bySide[side] or nil end @@ -249,7 +254,7 @@ function AnimatedBattleArt.update(battle, dt) local playerSide = BattleArt.playerSide() if playerSide == "back" then local generation = BattleArt.backAnimationSetting:get() - if generation == "gen5" then + if generation == "gen3" or generation == "gen5" then updateBattler(battle.player, "back", dt, mode) else updateStaticBack(battle.player, generation, mode) diff --git a/lib/BattleArt.lua b/lib/BattleArt.lua index 11d1f06..e8152bd 100644 --- a/lib/BattleArt.lua +++ b/lib/BattleArt.lua @@ -11,9 +11,8 @@ BattleArt.frontAnimationSetting = ModSetting.new("frontAnimatedSet", "ANIM FRONT { "gen2", "gen3", "gen4", "gen5" }, { "GEN 2", "GEN 3", "GEN 4", "GEN 5" }) -- The selected generation names the static back folder in STATIC mode. In --- ANIMATED mode Gen 1-4 still use those single images, while Gen 5 selects --- the animated atlas collection. The mode, not the generation, decides the --- decoder. +-- ANIMATED mode uses atlases for Gen 3 and Gen 5; Gen 1, 2 and 4 use their +-- single images. The mode, not just the generation, decides the decoder. BattleArt.backAnimationSetting = ModSetting.new("backAnimatedSet", "BACK ART SET", { "gen1", "gen2", "gen3", "gen4", "gen5" }, { "GEN 1", "GEN 2", "GEN 3", "GEN 4", "GEN 5" }, 5) diff --git a/tools/import_emerald_back_sprites.py b/tools/import_emerald_back_sprites.py new file mode 100644 index 0000000..cac6efd --- /dev/null +++ b/tools/import_emerald_back_sprites.py @@ -0,0 +1,90 @@ +"""Import Kanto Emerald animated and static back-sprite collections. + +`Spr b 3e` APNGs are flattened into native-resolution atlases for LÖVE. +`Spr b 3r` single-frame PNGs are copied byte-for-byte so their indexed pixels +and source transparency remain untouched. +""" + +from __future__ import annotations + +import argparse +import urllib.parse +from io import BytesIO +from pathlib import Path + +from PIL import Image + +from import_animated_sprites import convert, download, lua_definition, species + + +SOURCE = "https://archives.bulbagarden.net/wiki/Special:Redirect/file" + + +def archive_png(base: str, filename: str) -> bytes: + url = f"{base.rstrip('/')}/{urllib.parse.quote(filename)}" + raw = download(url) + if not raw.startswith(b"\x89PNG\r\n\x1a\n"): + raise ValueError(f"{filename}: response is not a PNG") + return raw + + +def validate_static(raw: bytes, filename: str) -> tuple[int, int, str]: + with Image.open(BytesIO(raw)) as image: + if getattr(image, "n_frames", 1) != 1: + raise ValueError(f"{filename}: expected one static frame") + width, height, mode = image.width, image.height, image.mode + image.verify() + return width, height, mode + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--root", type=Path, default=Path.cwd()) + parser.add_argument("--base-url", default=SOURCE) + args = parser.parse_args() + + root = args.root.resolve() + records = [] + roster = species(root) + for number, (engine_name, slug) in enumerate(roster, 1): + animated_name = f"Spr b 3e {number:03d}.png" + animated = archive_png(args.base_url, animated_name) + animated_rel = f"assets/battle/back-animated/gen3/{slug}.png" + info = convert(animated, root / animated_rel) + + static_name = f"Spr b 3r {number:03d}.png" + static_path = root / "assets" / "battle" / "back-static" / "gen3" / f"{slug}.png" + # A complete import needs animated timing metadata, so APNGs are read + # on every run. Static files carry no sidecar state and can be resumed + # safely after a slow archive or an interrupted command. + static = (static_path.read_bytes() if static_path.exists() + else archive_png(args.base_url, static_name)) + width, height, mode = validate_static(static, static_name) + static_path.parent.mkdir(parents=True, exist_ok=True) + static_path.write_bytes(static) + + records.append((engine_name, animated_rel, info)) + print( + f"[{number:03d}/151] {slug:<12s} " + f"animated={info['frames']}x{info['width']}x{info['height']} " + f"static={width}x{height} {mode}", + flush=True, + ) + + lines = [ + "-- Generated by tools/import_emerald_back_sprites.py.", + "-- Pokemon Emerald animated backs, National Dex #001-#151.", + "return {", + ] + for engine_name, path, info in records: + lines.append(f" {engine_name} = {{") + lines.append(f" back = {lua_definition(path, info)},") + lines.append(" },") + lines.append("}") + metadata = root / "data" / "animated_battle_backs_gen3.lua" + metadata.write_text("\n".join(lines) + "\n", encoding="utf-8") + print(f"wrote {metadata} with {len(records)} species") + + +if __name__ == "__main__": + main() From eaec79fd1b36ba282640d0655cc5927891d40ac7 Mon Sep 17 00:00:00 2001 From: absol89 Date: Sun, 2 Aug 2026 10:17:05 +0200 Subject: [PATCH 027/116] Add Crystal static back art importer --- CHANGELOG.md | 3 + README.md | 5 ++ assets/battle/back-static/README.md | 6 ++ lib/AnimatedBattleArt.lua | 6 +- tools/import_crystal_back_sprites.py | 115 +++++++++++++++++++++++++++ 5 files changed, 134 insertions(+), 1 deletion(-) create mode 100644 tools/import_crystal_back_sprites.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 04bc6b3..99881d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,6 +63,9 @@ - **Crystal and Emerald front collections.** The local imports cover #001-#151 while preserving every source frame's native canvas and delay. Gen 5 keeps its existing animated fronts and backs in the same folder convention. +- **Mixed-prefix Crystal back importer.** The Gen 2 authoring tool discovers + ordinary `2c`, `2g`, and `2s` archive filenames per species and preserves + each source PNG's indexed pixels and transparency without resampling. ## 1.3.0 diff --git a/README.md b/README.md index 8414bb6..ac09dc0 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,11 @@ animated Emerald `Spr b 3e` backs and the static `Spr b 3r` backs for the first 151 species. The generated atlases and ordinary PNGs remain local, ignored artwork; only their shared atlas metadata is committed. +Run `python tools/import_crystal_back_sprites.py --root .` to import the first +151 static Crystal backs. In ANIMATED mode, `BACK ART SET: GEN 1` and `GEN 2` +still select the static Yellow SGB and Crystal folders respectively; only GEN +3 and GEN 5 invoke an animated back-atlas decoder. + `BACK PLACEMENT: AUTO` keeps STATIC-mode player backs in the world, including ROM fallbacks. Under ANIMATED, supplied Gen 1–4 PNGs and Gen 5 atlases stay in the world while a missing selection leaves the ROM backsprite on its OG UI diff --git a/assets/battle/back-static/README.md b/assets/battle/back-static/README.md index c1fb690..d46a803 100644 --- a/assets/battle/back-static/README.md +++ b/assets/battle/back-static/README.md @@ -43,6 +43,12 @@ For Gen 3, `python tools/import_emerald_back_sprites.py --root .` copies the 151 Emerald `Spr b 3r` source PNGs byte-for-byte into `back-static/gen3` while also preparing the separate animated `Spr b 3e` collection. +For Gen 2, `python tools/import_crystal_back_sprites.py --root .` queries the +[Crystal back-sprite category](https://archives.bulbagarden.net/wiki/Category:Crystal_back_sprites) +and copies its first 151 ordinary PNGs unchanged into `back-static/gen2`. The +importer discovers each species' mixed `Spr b 2c`, `Spr b 2g`, or `Spr b 2s` +archive prefix and excludes shiny and Japanese variants automatically. + `BACK PLACEMENT` can override the layer for comparison. AUTO uses supplied generation PNGs in the world and keeps a missing ANIMATED fallback on OG UI; WORLD and OG UI force either presentation. diff --git a/lib/AnimatedBattleArt.lua b/lib/AnimatedBattleArt.lua index 3e3ba6c..22430cd 100644 --- a/lib/AnimatedBattleArt.lua +++ b/lib/AnimatedBattleArt.lua @@ -17,6 +17,10 @@ local BACK_SETS = { gen3 = V.data("animated_battle_backs_gen3"), gen5 = SETS.gen5, } +-- Gen 1 SGB, Gen 2 Crystal, and Gen 4 backs are intentionally single-frame +-- collections even while BATTLE ART is ANIMATED. Keeping this allowlist +-- narrow prevents a static generation folder from being decoded as an atlas. +local ANIMATED_BACK_SETS = { gen3 = true, gen5 = true } local PLAYER_SETS = V.data("animated_player_trainers") local AnimatedBattleArt = {} @@ -254,7 +258,7 @@ function AnimatedBattleArt.update(battle, dt) local playerSide = BattleArt.playerSide() if playerSide == "back" then local generation = BattleArt.backAnimationSetting:get() - if generation == "gen3" or generation == "gen5" then + if ANIMATED_BACK_SETS[generation] then updateBattler(battle.player, "back", dt, mode) else updateStaticBack(battle.player, generation, mode) diff --git a/tools/import_crystal_back_sprites.py b/tools/import_crystal_back_sprites.py new file mode 100644 index 0000000..ea7de94 --- /dev/null +++ b/tools/import_crystal_back_sprites.py @@ -0,0 +1,115 @@ +"""Import the first 151 ordinary Pokemon Crystal back sprites. + +The Crystal category deliberately mixes `Spr b 2c`, `Spr b 2g`, and possibly +`Spr b 2s` filenames. The category is queried as the source of truth rather +than guessing a prefix per species. Shiny and Japanese variants do not match +the strict ordinary-filename pattern. +""" + +from __future__ import annotations + +import argparse +import json +import re +import urllib.parse +from collections import Counter +from io import BytesIO +from pathlib import Path + +from PIL import Image + +from import_animated_sprites import download, species + + +API = "https://archives.bulbagarden.net/w/api.php" +FILES = "https://archives.bulbagarden.net/wiki/Special:Redirect/file" +CATEGORY = "Category:Crystal back sprites" +ORDINARY = re.compile(r"^File:(Spr b (2[cgs]) (\d{3})\.png)$") + + +def category_files(api: str) -> dict[int, tuple[str, str]]: + result: dict[int, tuple[str, str]] = {} + continuation = None + while True: + query = { + "action": "query", + "list": "categorymembers", + "cmtitle": CATEGORY, + "cmtype": "file", + "cmlimit": "500", + "format": "json", + } + if continuation: + query["cmcontinue"] = continuation + url = f"{api}?{urllib.parse.urlencode(query)}" + payload = json.loads(download(url).decode("utf-8")) + for member in payload["query"]["categorymembers"]: + match = ORDINARY.match(member["title"]) + if not match: + continue + number = int(match.group(3)) + if not 1 <= number <= 151: + continue + if number in result: + raise ValueError( + f"multiple ordinary Crystal backs for #{number:03d}: " + f"{result[number][0]} and {match.group(1)}" + ) + result[number] = (match.group(1), match.group(2)) + continuation = payload.get("continue", {}).get("cmcontinue") + if not continuation: + break + missing = sorted(set(range(1, 152)) - set(result)) + if missing: + raise ValueError(f"missing ordinary Crystal backs: {missing}") + return result + + +def validate_png(raw: bytes, filename: str) -> tuple[int, int, str, bool]: + if not raw.startswith(b"\x89PNG\r\n\x1a\n"): + raise ValueError(f"{filename}: response is not a PNG") + with Image.open(BytesIO(raw)) as image: + if getattr(image, "n_frames", 1) != 1: + raise ValueError(f"{filename}: expected one static frame") + details = ( + image.width, + image.height, + image.mode, + image.info.get("transparency") is not None or "A" in image.mode, + ) + image.verify() + return details + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--root", type=Path, default=Path.cwd()) + parser.add_argument("--api-url", default=API) + parser.add_argument("--file-base-url", default=FILES) + args = parser.parse_args() + + root = args.root.resolve() + roster = species(root) + source_files = category_files(args.api_url) + prefixes = Counter(prefix for _, prefix in source_files.values()) + destination = root / "assets" / "battle" / "back-static" / "gen2" + destination.mkdir(parents=True, exist_ok=True) + + for number, (_, slug) in enumerate(roster, 1): + filename, prefix = source_files[number] + url = f"{args.file_base_url.rstrip('/')}/{urllib.parse.quote(filename)}" + raw = download(url) + width, height, mode, transparent = validate_png(raw, filename) + (destination / f"{slug}.png").write_bytes(raw) + print( + f"[{number:03d}/151] {slug:<12s} {prefix} " + f"{width}x{height} {mode} alpha={transparent}", + flush=True, + ) + + summary = ", ".join(f"{key}={prefixes[key]}" for key in sorted(prefixes)) + print(f"wrote 151 unmodified PNGs to {destination} ({summary})") + + +if __name__ == "__main__": + main() From 7a110b04c4b1a595be222168bf23c91602faf8cc Mon Sep 17 00:00:00 2001 From: absol89 Date: Sun, 2 Aug 2026 10:37:45 +0200 Subject: [PATCH 028/116] Clarify static trainer art filenames --- assets/battle/front-static/README.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/assets/battle/front-static/README.md b/assets/battle/front-static/README.md index 80e9f1b..7d3cdc8 100644 --- a/assets/battle/front-static/README.md +++ b/assets/battle/front-static/README.md @@ -35,19 +35,21 @@ youngster.png bug-catcher.png lass.png sailor.png jr-trainer-m.png jr-trainer-f.png pokemaniac.png super-nerd.png hiker.png biker.png burglar.png engineer.png -unused-juggler.png fisher.png swimmer.png +fisher.png swimmer.png agatha.png cue-ball.png gambler.png beauty.png psychic-tr.png rocker.png juggler.png tamer.png bird-keeper.png blackbelt.png -rival1.png prof-oak.png chief.png +rival1.png prof-oak.png lance.png scientist.png giovanni.png rocket.png cooltrainer-m.png cooltrainer-f.png bruno.png brock.png misty.png lt-surge.png erika.png koga.png blaine.png sabrina.png gentleman.png rival2.png rival3.png lorelei.png channeler.png -agatha.png lance.png Yellow's special Rocket pair uses `jessie-james.png`; other Rocket trainers use `rocket.png`. A missing file in the selected generation retains the ROM trainer picture. The runtime does not borrow it from either of the other sets. + +The front static sprites work differently than animated, to allow mix & match. +Basically put any rightsized sprite named in here, and it will be in the game. \ No newline at end of file From 0cfec973aa40c6037e47b1b7ebb881e28649df46 Mon Sep 17 00:00:00 2001 From: absol89 Date: Sun, 2 Aug 2026 10:37:46 +0200 Subject: [PATCH 029/116] Add Platinum static back art importer --- CHANGELOG.md | 3 + README.md | 4 + assets/battle/back-static/README.md | 7 ++ tools/import_platinum_back_sprites.py | 128 ++++++++++++++++++++++++++ 4 files changed, 142 insertions(+) create mode 100644 tools/import_platinum_back_sprites.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 99881d7..a077319 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,9 @@ - **Mixed-prefix Crystal back importer.** The Gen 2 authoring tool discovers ordinary `2c`, `2g`, and `2s` archive filenames per species and preserves each source PNG's indexed pixels and transparency without resampling. +- **Platinum static back importer.** The Gen 4 authoring tool resolves reused + `4d` sprites and `4p` replacements per species, selecting the male member of + a dimorphic pair as the deterministic Gen 1 default. ## 1.3.0 diff --git a/README.md b/README.md index ac09dc0..875cbd7 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,10 @@ Run `python tools/import_crystal_back_sprites.py --root .` to import the first still select the static Yellow SGB and Crystal folders respectively; only GEN 3 and GEN 5 invoke an animated back-atlas decoder. +Run `python tools/import_platinum_back_sprites.py --root .` to populate the +static GEN 4 back set. It follows the Platinum category's mixture of `4p` and +reused `4d` files and chooses the male image for a dimorphic species. + `BACK PLACEMENT: AUTO` keeps STATIC-mode player backs in the world, including ROM fallbacks. Under ANIMATED, supplied Gen 1–4 PNGs and Gen 5 atlases stay in the world while a missing selection leaves the ROM backsprite on its OG UI diff --git a/assets/battle/back-static/README.md b/assets/battle/back-static/README.md index d46a803..4a90707 100644 --- a/assets/battle/back-static/README.md +++ b/assets/battle/back-static/README.md @@ -49,6 +49,13 @@ and copies its first 151 ordinary PNGs unchanged into `back-static/gen2`. The importer discovers each species' mixed `Spr b 2c`, `Spr b 2g`, or `Spr b 2s` archive prefix and excludes shiny and Japanese variants automatically. +For Gen 4, `python tools/import_platinum_back_sprites.py --root .` imports the +first 151 ordinary backs from the +[Platinum category](https://archives.bulbagarden.net/wiki/Category:Platinum_back_sprites). +It discovers both Platinum `Spr b 4p` files and reused Diamond/Pearl `Spr b +4d` files. When the archive provides a male/female pair, the male image is the +predictable Gen 1 default, matching the animated Gen 4 front importer. + `BACK PLACEMENT` can override the layer for comparison. AUTO uses supplied generation PNGs in the world and keeps a missing ANIMATED fallback on OG UI; WORLD and OG UI force either presentation. diff --git a/tools/import_platinum_back_sprites.py b/tools/import_platinum_back_sprites.py new file mode 100644 index 0000000..68fc1a5 --- /dev/null +++ b/tools/import_platinum_back_sprites.py @@ -0,0 +1,128 @@ +"""Import the first 151 ordinary Pokemon Platinum back sprites. + +The Platinum category mixes `Spr b 4p` files with unchanged `Spr b 4d` +sprites and provides male/female pairs for dimorphic species. Gen 1 has no +Pokemon gender, so this importer chooses the male member of a pair, matching +the Gen 4 animated-front importer, and otherwise uses the sole ordinary file. +""" + +from __future__ import annotations + +import argparse +import json +import re +import urllib.parse +from collections import Counter +from io import BytesIO +from pathlib import Path + +from PIL import Image + +from import_animated_sprites import download, species + + +API = "https://archives.bulbagarden.net/w/api.php" +FILES = "https://archives.bulbagarden.net/wiki/Special:Redirect/file" +CATEGORY = "Category:Platinum back sprites" +ORDINARY = re.compile(r"^File:(Spr b (4[dp]) (\d{3})(?: ([fm]))?\.png)$") + + +def category_files(api: str) -> dict[int, tuple[str, str, str | None]]: + candidates: dict[int, list[tuple[str, str, str | None]]] = {} + continuation = None + while True: + query = { + "action": "query", + "list": "categorymembers", + "cmtitle": CATEGORY, + "cmtype": "file", + "cmlimit": "500", + "format": "json", + } + if continuation: + query["cmcontinue"] = continuation + url = f"{api}?{urllib.parse.urlencode(query)}" + payload = json.loads(download(url).decode("utf-8")) + for member in payload["query"]["categorymembers"]: + match = ORDINARY.match(member["title"]) + if not match: + continue + number = int(match.group(3)) + if 1 <= number <= 151: + candidates.setdefault(number, []).append( + (match.group(1), match.group(2), match.group(4)) + ) + continuation = payload.get("continue", {}).get("cmcontinue") + if not continuation: + break + + selected = {} + for number in range(1, 152): + choices = candidates.get(number, []) + bare = [choice for choice in choices if choice[2] is None] + male = [choice for choice in choices if choice[2] == "m"] + preferred = bare if bare else male + if len(preferred) != 1: + raise ValueError( + f"expected one bare or male Platinum back for #{number:03d}, " + f"got {choices}" + ) + selected[number] = preferred[0] + return selected + + +def validate_png(raw: bytes, filename: str) -> tuple[int, int, str, bool]: + if not raw.startswith(b"\x89PNG\r\n\x1a\n"): + raise ValueError(f"{filename}: response is not a PNG") + with Image.open(BytesIO(raw)) as image: + if getattr(image, "n_frames", 1) != 1: + raise ValueError(f"{filename}: expected one static frame") + details = ( + image.width, + image.height, + image.mode, + image.info.get("transparency") is not None or "A" in image.mode, + ) + image.verify() + return details + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--root", type=Path, default=Path.cwd()) + parser.add_argument("--api-url", default=API) + parser.add_argument("--file-base-url", default=FILES) + args = parser.parse_args() + + root = args.root.resolve() + roster = species(root) + source_files = category_files(args.api_url) + prefixes = Counter(prefix for _, prefix, _ in source_files.values()) + sexes = Counter(sex or "bare" for _, _, sex in source_files.values()) + destination = root / "assets" / "battle" / "back-static" / "gen4" + destination.mkdir(parents=True, exist_ok=True) + + for number, (_, slug) in enumerate(roster, 1): + filename, prefix, sex = source_files[number] + url = f"{args.file_base_url.rstrip('/')}/{urllib.parse.quote(filename)}" + raw = download(url) + width, height, mode, transparent = validate_png(raw, filename) + (destination / f"{slug}.png").write_bytes(raw) + print( + f"[{number:03d}/151] {slug:<12s} {prefix} {sex or '-'} " + f"{width}x{height} {mode} alpha={transparent}", + flush=True, + ) + + prefix_summary = ", ".join( + f"{key}={prefixes[key]}" for key in sorted(prefixes) + ) + sex_summary = ", ".join(f"{key}={sexes[key]}" for key in sorted(sexes)) + print( + f"wrote 151 unmodified PNGs to {destination} " + f"({prefix_summary}; {sex_summary})" + ) + + +if __name__ == "__main__": + main() From 3d07c399c9d4fc19d63f0dd322158977554db0e7 Mon Sep 17 00:00:00 2001 From: absol89 Date: Sun, 2 Aug 2026 10:42:47 +0200 Subject: [PATCH 030/116] Add Black White static back art importer --- CHANGELOG.md | 3 + README.md | 4 ++ assets/battle/back-static/README.md | 5 ++ .../import_black_white_static_back_sprites.py | 64 +++++++++++++++++++ 4 files changed, 76 insertions(+) create mode 100644 tools/import_black_white_static_back_sprites.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a077319..22197a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,6 +69,9 @@ - **Platinum static back importer.** The Gen 4 authoring tool resolves reused `4d` sprites and `4p` replacements per species, selecting the male member of a dimorphic pair as the deterministic Gen 1 default. +- **Black/White static back importer.** The Gen 5 authoring tool copies all + 151 `back-normal` source PNGs without changing their pixels or transparency, + independently of the existing animated Gen 5 atlas collection. ## 1.3.0 diff --git a/README.md b/README.md index 875cbd7..e7dcc1a 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,10 @@ Run `python tools/import_platinum_back_sprites.py --root .` to populate the static GEN 4 back set. It follows the Platinum category's mixture of `4p` and reused `4d` files and chooses the male image for a dimorphic species. +Run `python tools/import_black_white_static_back_sprites.py --root .` to +populate static GEN 5 with the first 151 Black/White back-normal PNGs. This +does not replace or alter the animated GEN 5 atlas collection. + `BACK PLACEMENT: AUTO` keeps STATIC-mode player backs in the world, including ROM fallbacks. Under ANIMATED, supplied Gen 1–4 PNGs and Gen 5 atlases stay in the world while a missing selection leaves the ROM backsprite on its OG UI diff --git a/assets/battle/back-static/README.md b/assets/battle/back-static/README.md index 4a90707..6431e0c 100644 --- a/assets/battle/back-static/README.md +++ b/assets/battle/back-static/README.md @@ -56,6 +56,11 @@ It discovers both Platinum `Spr b 4p` files and reused Diamond/Pearl `Spr b 4d` files. When the archive provides a male/female pair, the male image is the predictable Gen 1 default, matching the animated Gen 4 front importer. +For Gen 5, `python tools/import_black_white_static_back_sprites.py --root .` +copies the first 151 Black/White `back-normal` PNGs unchanged into +`back-static/gen5`. These files are used only by STATIC mode; ANIMATED + GEN 5 +continues to read atlases from `back-animated/gen5`. + `BACK PLACEMENT` can override the layer for comparison. AUTO uses supplied generation PNGs in the world and keeps a missing ANIMATED fallback on OG UI; WORLD and OG UI force either presentation. diff --git a/tools/import_black_white_static_back_sprites.py b/tools/import_black_white_static_back_sprites.py new file mode 100644 index 0000000..12f1941 --- /dev/null +++ b/tools/import_black_white_static_back_sprites.py @@ -0,0 +1,64 @@ +"""Import static Black/White back sprites for the first 151 Pokemon. + +Source PNG bytes are copied unchanged. The runtime's STATIC mode reads these +ordinary files, while ANIMATED + GEN 5 continues to use the separate animated +atlas collection in `back-animated/gen5`. +""" + +from __future__ import annotations + +import argparse +from io import BytesIO +from pathlib import Path + +from PIL import Image + +from import_animated_sprites import download, species + + +SOURCE = "https://img.pokemondb.net/sprites/black-white/back-normal" + + +def validate_png(raw: bytes, filename: str) -> tuple[int, int, str, bool]: + if not raw.startswith(b"\x89PNG\r\n\x1a\n"): + raise ValueError(f"{filename}: response is not a PNG") + with Image.open(BytesIO(raw)) as image: + if getattr(image, "n_frames", 1) != 1: + raise ValueError(f"{filename}: expected one static frame") + details = ( + image.width, + image.height, + image.mode, + image.info.get("transparency") is not None or "A" in image.mode, + ) + image.verify() + return details + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--root", type=Path, default=Path.cwd()) + parser.add_argument("--base-url", default=SOURCE) + args = parser.parse_args() + + root = args.root.resolve() + destination = root / "assets" / "battle" / "back-static" / "gen5" + destination.mkdir(parents=True, exist_ok=True) + roster = species(root) + + for number, (_, slug) in enumerate(roster, 1): + filename = f"{slug}.png" + raw = download(f"{args.base_url.rstrip('/')}/{filename}") + width, height, mode, transparent = validate_png(raw, filename) + (destination / filename).write_bytes(raw) + print( + f"[{number:03d}/151] {slug:<12s} " + f"{width}x{height} {mode} alpha={transparent}", + flush=True, + ) + + print(f"wrote 151 unmodified PNGs to {destination}") + + +if __name__ == "__main__": + main() From 19e2a20ac0d71e1f10aba2772bd5746f1a900e44 Mon Sep 17 00:00:00 2001 From: absol89 Date: Sun, 2 Aug 2026 10:45:21 +0200 Subject: [PATCH 031/116] Remove purchase-specific art extraction tool --- tools/extract_purchased_static_art.py | 228 -------------------------- 1 file changed, 228 deletions(-) delete mode 100644 tools/extract_purchased_static_art.py diff --git a/tools/extract_purchased_static_art.py b/tools/extract_purchased_static_art.py deleted file mode 100644 index 8a7ab69..0000000 --- a/tools/extract_purchased_static_art.py +++ /dev/null @@ -1,228 +0,0 @@ -"""Extract a purchased 10-column Kanto art sheet into static battle PNGs. - -The source artwork is expected to use the regular 150-entry grid found in -Pokemon_Sugimori_151_18x24.jpg, followed by Mew centered on the final row. -All drawings share one scale factor so their relative sizes are preserved. -""" - -from __future__ import annotations - -import argparse -import re -from pathlib import Path - -import numpy as np -from PIL import Image, ImageFilter - - -REFERENCE_WIDTH = 5400 -REFERENCE_HEIGHT = 7200 -REFERENCE_ROW_BOUNDS = ( - 0, 513, 957, 1404, 1930, 2492, 2923, 3381, - 3822, 4231, 4677, 5174, 5633, 6083, 6485, 6852, -) -ENTRY = re.compile( - r'^ ([A-Z0-9_]+) = \{\n' - r' front = \{ image = "assets/battle/front-animated/gen5/([^/]+)\.png"', - re.MULTILINE, -) - -# Populate after reviewing the labelled contact sheet. Entries here are -# mirrored only when the purchased drawing clearly faces right. -FLIP_DEX: set[int] = { - # 001-030, reviewed against the purchased sheet. - 1, 4, 8, 10, 12, 13, 14, 18, 19, 23, 26, 27, 28, - # 031-060, reviewed against the purchased sheet. - 31, 37, 41, 42, 43, 44, 45, 46, 47, 48, 49, 51, 52, 53, 54, 56, 59, - # 061-090, reviewed against the purchased sheet. - 63, 70, 71, 72, 73, 74, 78, 81, 83, 88, - # 091-120, reviewed against the purchased sheet. - 91, 93, 95, 96, 99, 104, 109, 119, - # 121-151, reviewed against the purchased sheet. - 123, 126, 129, 130, 132, 133, 136, 137, 140, 144, 145, 150, 151, -} - -# Species with a detached artifact introduced by the collage layout. This is -# intentionally opt-in because detached particles are legitimate artwork for -# species such as Gastly and Koffing. -RETAIN_LARGEST_COMPONENT_DEX: set[int] = {149} - - -def species(root: Path) -> list[str]: - source = (root / "data" / "animated_battle_sprites_gen5.lua").read_text( - encoding="utf-8" - ) - entries = ENTRY.findall(source) - if len(entries) != 151: - raise ValueError(f"expected 151 Kanto species definitions, got {len(entries)}") - return [slug for _, slug in entries] - - -def smoothstep(values: np.ndarray) -> np.ndarray: - values = np.clip(values, 0.0, 1.0) - return values * values * (3.0 - 2.0 * values) - - -def matte(rgb: np.ndarray, key: np.ndarray) -> Image.Image: - distance = np.max(np.abs(rgb.astype(np.float32) - key), axis=2) - alpha_f = smoothstep((distance - 7.0) / (44.0 - 7.0)) - alpha = np.rint(alpha_f * 255.0).astype(np.uint8) - - # Remove the lavender composite color from partially transparent edge - # pixels. This avoids a purple JPEG fringe after downsampling. - safe_alpha = np.maximum(alpha_f[..., None], 1.0 / 255.0) - foreground = ( - rgb.astype(np.float32) - (1.0 - alpha_f[..., None]) * key - ) / safe_alpha - foreground = np.clip(np.rint(foreground), 0, 255).astype(np.uint8) - foreground[alpha == 0] = 0 - rgba = np.dstack((foreground, alpha)) - return Image.fromarray(rgba, "RGBA") - - -def horizontal_groups(mask: np.ndarray, gap: int) -> list[tuple[int, int]]: - projection = mask.sum(axis=0) - active = np.where(projection > 1)[0] - if not len(active): - return [] - groups: list[tuple[int, int]] = [] - start = previous = int(active[0]) - for position_raw in active[1:]: - position = int(position_raw) - if position - previous > gap: - if projection[start:previous + 1].sum() > 250: - groups.append((start, previous + 1)) - start = position - previous = position - if projection[start:previous + 1].sum() > 250: - groups.append((start, previous + 1)) - return groups - - -def retain_largest_component(image: Image.Image) -> Image.Image: - alpha = np.asarray(image.getchannel("A")) - mask = alpha > 8 - height, width = mask.shape - visited = np.zeros_like(mask, dtype=bool) - components: list[list[tuple[int, int]]] = [] - for y in range(height): - for x in range(width): - if not mask[y, x] or visited[y, x]: - continue - stack = [(x, y)] - visited[y, x] = True - component: list[tuple[int, int]] = [] - while stack: - px, py = stack.pop() - component.append((px, py)) - for nx in range(max(0, px - 1), min(width, px + 2)): - for ny in range(max(0, py - 1), min(height, py + 2)): - if mask[ny, nx] and not visited[ny, nx]: - visited[ny, nx] = True - stack.append((nx, ny)) - components.append(component) - if len(components) <= 1: - return image - largest = max(components, key=len) - keep = Image.new("L", image.size) - keep_pixels = keep.load() - for x, y in largest: - keep_pixels[x, y] = 255 - keep = keep.filter(ImageFilter.MaxFilter(5)) - rgba = np.asarray(image).copy() - rgba[np.asarray(keep) == 0] = 0 - return Image.fromarray(rgba, "RGBA") - - -def extract(source: Path) -> tuple[list[Image.Image], tuple[int, int, int]]: - with Image.open(source) as opened: - rgb = np.asarray(opened.convert("RGB")) - height, width = rgb.shape[:2] - if (width, height) != (REFERENCE_WIDTH, REFERENCE_HEIGHT): - raise ValueError( - f"expected {REFERENCE_WIDTH}x{REFERENCE_HEIGHT}, got {width}x{height}" - ) - - border = np.concatenate(( - rgb[:32].reshape(-1, 3), rgb[-32:].reshape(-1, 3), - rgb[:, :32].reshape(-1, 3), rgb[:, -32:].reshape(-1, 3), - )) - key = np.median(border, axis=0).astype(np.float32) - distance = np.max(np.abs(rgb.astype(np.float32) - key), axis=2) - foreground_mask = distance > 18.0 - - drawings: list[Image.Image] = [] - for row, (top, bottom) in enumerate( - zip(REFERENCE_ROW_BOUNDS, REFERENCE_ROW_BOUNDS[1:]), 1 - ): - groups = horizontal_groups(foreground_mask[top:bottom], gap=40) - if len(groups) != 10: - raise ValueError(f"row {row} contains {len(groups)} drawings, expected 10") - for left, right in groups: - section = rgb[top:bottom, left:right] - image = matte(section, key) - box = image.getchannel("A").getbbox() - if not box: - raise ValueError(f"row {row} contains an empty drawing") - drawings.append(image.crop(box)) - - # The final band contains centered Mew and a seller signature at right. - top = REFERENCE_ROW_BOUNDS[-1] - groups = horizontal_groups(foreground_mask[top:], gap=40) - center = width / 2.0 - mew_left, mew_right = min( - groups, key=lambda group: abs(((group[0] + group[1]) / 2.0) - center) - ) - mew = matte(rgb[top:, mew_left:mew_right], key) - box = mew.getchannel("A").getbbox() - if not box: - raise ValueError("Mew extraction is empty") - drawings.append(mew.crop(box)) - - if len(drawings) != 151: - raise ValueError(f"extracted {len(drawings)} drawings, expected 151") - return drawings, tuple(int(round(value)) for value in key) - - -def save( - drawings: list[Image.Image], names: list[str], output: Path, max_width: int -) -> float: - widest = max(image.width for image in drawings) - scale = min(1.0, max_width / widest) - output.mkdir(parents=True, exist_ok=True) - for number, (image, name) in enumerate(zip(drawings, names), 1): - if number in FLIP_DEX: - image = image.transpose(Image.Transpose.FLIP_LEFT_RIGHT) - size = ( - max(1, round(image.width * scale)), - max(1, round(image.height * scale)), - ) - image = image.resize(size, Image.Resampling.LANCZOS) - if number in RETAIN_LARGEST_COMPONENT_DEX: - image = retain_largest_component(image) - image.save(output / f"{name}.png", optimize=True) - return scale - - -def main() -> None: - parser = argparse.ArgumentParser() - parser.add_argument("source", type=Path) - parser.add_argument("--root", type=Path, default=Path.cwd()) - parser.add_argument("--output", type=Path) - parser.add_argument("--max-width", type=int, default=96) - args = parser.parse_args() - root = args.root.resolve() - output = (args.output or root / "assets" / "battle" / "front-static").resolve() - drawings, key = extract(args.source.resolve()) - scale = save(drawings, species(root), output, args.max_width) - widths = [round(image.width * scale) for image in drawings] - heights = [round(image.height * scale) for image in drawings] - print(f"Extracted 151 PNGs into {output}") - print(f"Background key: #{key[0]:02x}{key[1]:02x}{key[2]:02x}") - print(f"Shared scale: {scale:.6f}") - print(f"Output widths: {min(widths)}-{max(widths)} px") - print(f"Output heights: {min(heights)}-{max(heights)} px") - - -if __name__ == "__main__": - main() From 326dd6559c00ab11b7b8fa595436e8f76b1311b6 Mon Sep 17 00:00:00 2001 From: absol89 Date: Sun, 2 Aug 2026 11:00:32 +0200 Subject: [PATCH 032/116] Fix staged battle entry dimmer --- CHANGELOG.md | 4 ++++ lib/OverworldBattle.lua | 37 +++++++++++++++++++++++++++++++++++-- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 22197a1..b3746d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,10 @@ active battle, and post-battle frames instead of switching through gray. The obsolete zero-alpha frost composite is skipped entirely, preventing a driver-dependent dim veil over the arena. +- **No stale battle-entry dimmer.** Staged battles suppress the engine's + translucent world-fade rectangle at the compositor boundary as well as + clearing its fade state. The normal battle wipe, deliberate battle-exit + fade, and overworld warp fades are unchanged. - **Untinted static front illustrations.** Supplied species PNGs from `front-static` retain their authored brightness and colour instead of being multiplied by the day/night tint. They remain world geometry with display diff --git a/lib/OverworldBattle.lua b/lib/OverworldBattle.lua index f561156..d14467b 100644 --- a/lib/OverworldBattle.lua +++ b/lib/OverworldBattle.lua @@ -884,10 +884,43 @@ function OverworldBattle.install() if not Renderer.dramaticShapeBattleFadeHook then local innerEndFrame = Renderer.endFrame function Renderer:endFrame(zones, worldZones) - if OverworldBattle.shot() and self.worldOverride then + local staged = OverworldBattle.shot() and self.worldOverride + if staged then self.worldFadeAlpha = nil end - return innerEndFrame(self, zones, worldZones) + + -- Newer renderer chains can decide to paint their transition after an + -- outer wrapper has entered endFrame. Clearing the field above is then + -- one instruction too early. Guard the actual compositor rectangle as + -- well: a translucent, window-sized black fill inside Renderer:endFrame + -- is its world fade and nothing else. The battle-entry cascade uses + -- opaque tile/strip rects, while BattleExit paints after this wrapper has + -- returned, so both deliberate transitions remain intact. + local gfx = love.graphics + local rectangle = gfx.rectangle + if staged and rectangle then + gfx.rectangle = function(mode, x, y, w, h, ...) + if mode == "fill" and x == 0 and y == 0 then + local ww, wh = gfx.getDimensions() + local r, g, b, a = gfx.getColor() + if w >= ww and h >= wh and r < 0.001 and g < 0.001 and b < 0.001 + and a > 0 and a < 0.999 then + if session and not session.fadeWarned then + session.fadeWarned = true + V.mod.log:info("suppressed staged battle compositor fade " + .. "(alpha %.3f)", a) + end + return + end + end + return rectangle(mode, x, y, w, h, ...) + end + end + + local ok, result = pcall(innerEndFrame, self, zones, worldZones) + gfx.rectangle = rectangle + if not ok then error(result, 0) end + return result end Renderer.dramaticShapeBattleFadeHook = true end From 4f01f8965d8b9818a749a78ecb3d35942ca2f0c7 Mon Sep 17 00:00:00 2001 From: absol89 Date: Sun, 2 Aug 2026 11:39:16 +0200 Subject: [PATCH 033/116] Improve Emerald back sprite animation timing --- CHANGELOG.md | 4 + data/animated_battle_backs_gen3.lua | 302 +++++++++++++-------------- lib/AnimatedBattleArt.lua | 14 +- main.lua | 5 +- tools/import_animated_sprites.py | 24 ++- tools/import_emerald_back_sprites.py | 11 +- 6 files changed, 197 insertions(+), 163 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b3746d3..ca85385 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,10 @@ scaling, and sends every frame through the same transparency, display filtering, world lighting, depth and shadow path as static art. Missing or malformed atlases fall back per Pokemon to ROM art. +- **Readable Emerald back animation timing.** Consecutive duplicate APNG + frames are coalesced while retaining their combined hold time, and distinct + Gen 3 back poses remain visible for at least 33 ms instead of disappearing + within a single 60 Hz update. - **Independent front/back sets.** `ANIM FRONT GEN` selects animated GEN 2, GEN 3, GEN 4, or GEN 5 fronts. `BACK ART SET` selects the static GEN 1–5 folder in STATIC mode; ANIMATED uses animated GEN 3/5 atlases or static GEN 1/2/4 PNGs. diff --git a/data/animated_battle_backs_gen3.lua b/data/animated_battle_backs_gen3.lua index 926d70d..d9c7589 100644 --- a/data/animated_battle_backs_gen3.lua +++ b/data/animated_battle_backs_gen3.lua @@ -2,456 +2,456 @@ -- Pokemon Emerald animated backs, National Dex #001-#151. return { BULBASAUR = { - back = { image = "assets/battle/back-animated/gen3/bulbasaur.png", width = 64, height = 64, columns = 16, frames = 17, durations = {50,50,33,50,50,50,66,150,16,150,83,50,50,33,50,33,1083} }, + back = { image = "assets/battle/back-animated/gen3/bulbasaur.png", width = 64, height = 64, columns = 16, frames = 17, durations = {50,50,33,50,50,50,67,151,33,151,84,50,50,33,50,33,1084} }, }, IVYSAUR = { - back = { image = "assets/battle/back-animated/gen3/ivysaur.png", width = 64, height = 64, columns = 16, frames = 29, durations = {16,16,16,16,16,83,16,16,16,16,16,16,16,16,83,16,16,16,16,16,16,16,16,83,16,16,16,16,1050} }, + back = { image = "assets/battle/back-animated/gen3/ivysaur.png", width = 64, height = 64, columns = 16, frames = 29, durations = {33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,1050} }, }, VENUSAUR = { - back = { image = "assets/battle/back-animated/gen3/venusaur.png", width = 69, height = 69, columns = 16, frames = 38, durations = {16,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,16,16,1033} }, + back = { image = "assets/battle/back-animated/gen3/venusaur.png", width = 69, height = 69, columns = 16, frames = 38, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, }, CHARMANDER = { - back = { image = "assets/battle/back-animated/gen3/charmander.png", width = 64, height = 64, columns = 16, frames = 31, durations = {33,33,33,33,16,16,16,16,16,33,16,33,33,33,33,16,16,33,16,16,16,33,16,16,16,16,33,16,16,33,1033} }, + back = { image = "assets/battle/back-animated/gen3/charmander.png", width = 64, height = 64, columns = 16, frames = 31, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, }, CHARMELEON = { - back = { image = "assets/battle/back-animated/gen3/charmeleon.png", width = 66, height = 66, columns = 16, frames = 30, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,33,16,16,16,1033} }, + back = { image = "assets/battle/back-animated/gen3/charmeleon.png", width = 66, height = 66, columns = 16, frames = 30, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, }, CHARIZARD = { - back = { image = "assets/battle/back-animated/gen3/charizard.png", width = 75, height = 75, columns = 16, frames = 40, durations = {66,66,66,33,66,66,66,66,100,100,100,117,16,16,33,16,16,16,16,16,33,16,16,16,16,33,16,16,133,100,100,100,66,66,66,66,33,66,66,1066} }, + back = { image = "assets/battle/back-animated/gen3/charizard.png", width = 75, height = 75, columns = 16, frames = 40, durations = {67,67,67,33,67,67,67,67,100,100,100,117,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,134,100,100,100,67,67,67,67,33,67,67,1067} }, }, SQUIRTLE = { - back = { image = "assets/battle/back-animated/gen3/squirtle.png", width = 64, height = 64, columns = 16, frames = 31, durations = {33,33,33,33,16,16,16,16,16,33,16,33,33,33,33,16,16,33,16,16,16,33,16,16,16,16,33,16,16,33,1033} }, + back = { image = "assets/battle/back-animated/gen3/squirtle.png", width = 64, height = 64, columns = 16, frames = 31, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, }, WARTORTLE = { - back = { image = "assets/battle/back-animated/gen3/wartortle.png", width = 64, height = 64, columns = 16, frames = 31, durations = {33,33,33,33,16,16,16,16,16,33,16,33,33,33,33,16,16,33,16,16,16,33,16,16,16,16,33,16,16,33,1033} }, + back = { image = "assets/battle/back-animated/gen3/wartortle.png", width = 64, height = 64, columns = 16, frames = 31, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, }, BLASTOISE = { - back = { image = "assets/battle/back-animated/gen3/blastoise.png", width = 75, height = 75, columns = 16, frames = 40, durations = {66,66,66,33,66,66,66,66,100,100,100,117,16,16,33,16,16,16,16,16,33,16,16,16,16,33,16,16,133,100,100,100,66,66,66,66,33,66,66,1066} }, + back = { image = "assets/battle/back-animated/gen3/blastoise.png", width = 75, height = 75, columns = 16, frames = 40, durations = {67,67,67,33,67,67,67,67,100,100,100,117,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,134,100,100,100,67,67,67,67,33,67,67,1067} }, }, CATERPIE = { - back = { image = "assets/battle/back-animated/gen3/caterpie.png", width = 64, height = 64, columns = 16, frames = 29, durations = {16,16,16,16,16,83,16,16,16,16,16,16,16,16,83,16,16,16,16,16,16,16,16,83,16,16,16,16,1050} }, + back = { image = "assets/battle/back-animated/gen3/caterpie.png", width = 64, height = 64, columns = 16, frames = 29, durations = {33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,1050} }, }, METAPOD = { - back = { image = "assets/battle/back-animated/gen3/metapod.png", width = 64, height = 64, columns = 16, frames = 17, durations = {50,50,33,50,50,50,66,150,16,150,83,50,50,33,50,33,1083} }, + back = { image = "assets/battle/back-animated/gen3/metapod.png", width = 64, height = 64, columns = 16, frames = 17, durations = {50,50,33,50,50,50,67,151,33,151,84,50,50,33,50,33,1084} }, }, BUTTERFREE = { - back = { image = "assets/battle/back-animated/gen3/butterfree.png", width = 89, height = 89, columns = 16, frames = 73, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,33,33,16,16,83,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,16,33,33,16,16,83,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,1033} }, + back = { image = "assets/battle/back-animated/gen3/butterfree.png", width = 89, height = 89, columns = 16, frames = 73, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,84,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,84,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, }, WEEDLE = { - back = { image = "assets/battle/back-animated/gen3/weedle.png", width = 64, height = 64, columns = 16, frames = 29, durations = {16,16,16,16,16,83,16,16,16,16,16,16,16,16,83,16,16,16,16,16,16,16,16,83,16,16,16,16,1050} }, + back = { image = "assets/battle/back-animated/gen3/weedle.png", width = 64, height = 64, columns = 16, frames = 29, durations = {33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,1050} }, }, KAKUNA = { - back = { image = "assets/battle/back-animated/gen3/kakuna.png", width = 64, height = 64, columns = 16, frames = 17, durations = {50,50,33,50,50,50,66,150,16,150,83,50,50,33,50,33,1083} }, + back = { image = "assets/battle/back-animated/gen3/kakuna.png", width = 64, height = 64, columns = 16, frames = 17, durations = {50,50,33,50,50,50,67,151,33,151,84,50,50,33,50,33,1084} }, }, BEEDRILL = { - back = { image = "assets/battle/back-animated/gen3/beedrill.png", width = 80, height = 80, columns = 16, frames = 39, durations = {33,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,1066} }, + back = { image = "assets/battle/back-animated/gen3/beedrill.png", width = 80, height = 80, columns = 16, frames = 39, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1067} }, }, PIDGEY = { - back = { image = "assets/battle/back-animated/gen3/pidgey.png", width = 72, height = 72, columns = 16, frames = 18, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,1050} }, + back = { image = "assets/battle/back-animated/gen3/pidgey.png", width = 72, height = 72, columns = 16, frames = 18, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050} }, }, PIDGEOTTO = { - back = { image = "assets/battle/back-animated/gen3/pidgeotto.png", width = 74, height = 74, columns = 16, frames = 30, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,33,16,16,16,1033} }, + back = { image = "assets/battle/back-animated/gen3/pidgeotto.png", width = 74, height = 74, columns = 16, frames = 30, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, }, PIDGEOT = { - back = { image = "assets/battle/back-animated/gen3/pidgeot.png", width = 76, height = 76, columns = 16, frames = 18, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,1050} }, + back = { image = "assets/battle/back-animated/gen3/pidgeot.png", width = 76, height = 76, columns = 16, frames = 18, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050} }, }, RATTATA = { - back = { image = "assets/battle/back-animated/gen3/rattata.png", width = 79, height = 79, columns = 16, frames = 43, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,1050} }, + back = { image = "assets/battle/back-animated/gen3/rattata.png", width = 79, height = 79, columns = 16, frames = 43, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050} }, }, RATICATE = { - back = { image = "assets/battle/back-animated/gen3/raticate.png", width = 86, height = 86, columns = 16, frames = 43, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,1050} }, + back = { image = "assets/battle/back-animated/gen3/raticate.png", width = 86, height = 86, columns = 16, frames = 43, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050} }, }, SPEAROW = { - back = { image = "assets/battle/back-animated/gen3/spearow.png", width = 67, height = 67, columns = 16, frames = 18, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,1050} }, + back = { image = "assets/battle/back-animated/gen3/spearow.png", width = 67, height = 67, columns = 16, frames = 18, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050} }, }, FEAROW = { - back = { image = "assets/battle/back-animated/gen3/fearow.png", width = 82, height = 82, columns = 16, frames = 30, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,33,16,16,16,1033} }, + back = { image = "assets/battle/back-animated/gen3/fearow.png", width = 82, height = 82, columns = 16, frames = 30, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, }, EKANS = { - back = { image = "assets/battle/back-animated/gen3/ekans.png", width = 71, height = 71, columns = 16, frames = 18, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,1050} }, + back = { image = "assets/battle/back-animated/gen3/ekans.png", width = 71, height = 71, columns = 16, frames = 18, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050} }, }, ARBOK = { - back = { image = "assets/battle/back-animated/gen3/arbok.png", width = 66, height = 66, columns = 16, frames = 37, durations = {16,16,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,16,16,1050} }, + back = { image = "assets/battle/back-animated/gen3/arbok.png", width = 66, height = 66, columns = 16, frames = 37, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050} }, }, PIKACHU = { - back = { image = "assets/battle/back-animated/gen3/pikachu.png", width = 64, height = 64, columns = 16, frames = 30, durations = {50,50,33,16,50,50,50,50,50,50,33,16,33,16,33,16,33,16,33,16,33,16,33,16,33,16,33,16,33,1033} }, + back = { image = "assets/battle/back-animated/gen3/pikachu.png", width = 64, height = 64, columns = 16, frames = 30, durations = {50,50,33,33,50,50,50,50,50,50,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, }, RAICHU = { - back = { image = "assets/battle/back-animated/gen3/raichu.png", width = 64, height = 64, columns = 16, frames = 30, durations = {50,50,33,16,50,50,50,50,50,50,33,16,33,16,33,16,33,16,33,16,33,16,33,16,33,16,33,16,33,1033} }, + back = { image = "assets/battle/back-animated/gen3/raichu.png", width = 64, height = 64, columns = 16, frames = 30, durations = {50,50,33,33,50,50,50,50,50,50,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, }, SANDSHREW = { - back = { image = "assets/battle/back-animated/gen3/sandshrew.png", width = 64, height = 64, columns = 16, frames = 31, durations = {33,33,33,33,16,16,16,16,16,33,16,33,33,33,33,16,16,33,16,16,16,33,16,16,16,16,33,16,16,33,1033} }, + back = { image = "assets/battle/back-animated/gen3/sandshrew.png", width = 64, height = 64, columns = 16, frames = 31, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, }, SANDSLASH = { - back = { image = "assets/battle/back-animated/gen3/sandslash.png", width = 81, height = 81, columns = 16, frames = 44, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,1033} }, + back = { image = "assets/battle/back-animated/gen3/sandslash.png", width = 81, height = 81, columns = 16, frames = 44, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, }, NIDORAN_F = { - back = { image = "assets/battle/back-animated/gen3/nidoran-f.png", width = 64, height = 64, columns = 16, frames = 31, durations = {33,33,33,33,16,16,16,16,16,33,16,33,33,33,33,16,16,33,16,16,16,33,16,16,16,16,33,16,16,33,1033} }, + back = { image = "assets/battle/back-animated/gen3/nidoran-f.png", width = 64, height = 64, columns = 16, frames = 31, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, }, NIDORINA = { - back = { image = "assets/battle/back-animated/gen3/nidorina.png", width = 74, height = 74, columns = 16, frames = 30, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,33,16,16,16,1033} }, + back = { image = "assets/battle/back-animated/gen3/nidorina.png", width = 74, height = 74, columns = 16, frames = 30, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, }, NIDOQUEEN = { - back = { image = "assets/battle/back-animated/gen3/nidoqueen.png", width = 64, height = 64, columns = 16, frames = 37, durations = {16,16,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,16,16,1050} }, + back = { image = "assets/battle/back-animated/gen3/nidoqueen.png", width = 64, height = 64, columns = 16, frames = 37, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050} }, }, NIDORAN_M = { - back = { image = "assets/battle/back-animated/gen3/nidoran-m.png", width = 64, height = 64, columns = 16, frames = 31, durations = {33,33,33,33,16,16,16,16,16,33,16,33,33,33,33,16,16,33,16,16,16,33,16,16,16,16,33,16,16,33,1033} }, + back = { image = "assets/battle/back-animated/gen3/nidoran-m.png", width = 64, height = 64, columns = 16, frames = 31, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, }, NIDORINO = { - back = { image = "assets/battle/back-animated/gen3/nidorino.png", width = 78, height = 78, columns = 16, frames = 30, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,33,16,16,16,1033} }, + back = { image = "assets/battle/back-animated/gen3/nidorino.png", width = 78, height = 78, columns = 16, frames = 30, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, }, NIDOKING = { - back = { image = "assets/battle/back-animated/gen3/nidoking.png", width = 67, height = 67, columns = 16, frames = 37, durations = {16,16,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,16,16,1050} }, + back = { image = "assets/battle/back-animated/gen3/nidoking.png", width = 67, height = 67, columns = 16, frames = 37, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050} }, }, CLEFAIRY = { - back = { image = "assets/battle/back-animated/gen3/clefairy.png", width = 64, height = 64, columns = 16, frames = 17, durations = {50,50,33,50,50,50,66,150,16,150,83,50,50,33,50,33,1083} }, + back = { image = "assets/battle/back-animated/gen3/clefairy.png", width = 64, height = 64, columns = 16, frames = 17, durations = {50,50,33,50,50,50,67,151,33,151,84,50,50,33,50,33,1084} }, }, CLEFABLE = { - back = { image = "assets/battle/back-animated/gen3/clefable.png", width = 64, height = 64, columns = 16, frames = 17, durations = {50,50,33,50,50,50,66,150,16,150,83,50,50,33,50,33,1083} }, + back = { image = "assets/battle/back-animated/gen3/clefable.png", width = 64, height = 64, columns = 16, frames = 17, durations = {50,50,33,50,50,50,67,151,33,151,84,50,50,33,50,33,1084} }, }, VULPIX = { - back = { image = "assets/battle/back-animated/gen3/vulpix.png", width = 74, height = 74, columns = 16, frames = 44, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,1033} }, + back = { image = "assets/battle/back-animated/gen3/vulpix.png", width = 74, height = 74, columns = 16, frames = 44, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, }, NINETALES = { - back = { image = "assets/battle/back-animated/gen3/ninetales.png", width = 66, height = 66, columns = 16, frames = 39, durations = {33,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,1066} }, + back = { image = "assets/battle/back-animated/gen3/ninetales.png", width = 66, height = 66, columns = 16, frames = 39, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1067} }, }, JIGGLYPUFF = { - back = { image = "assets/battle/back-animated/gen3/jigglypuff.png", width = 64, height = 64, columns = 16, frames = 17, durations = {50,50,33,50,50,50,66,150,16,150,83,50,50,33,50,33,1083} }, + back = { image = "assets/battle/back-animated/gen3/jigglypuff.png", width = 64, height = 64, columns = 16, frames = 17, durations = {50,50,33,50,50,50,67,151,33,151,84,50,50,33,50,33,1084} }, }, WIGGLYTUFF = { - back = { image = "assets/battle/back-animated/gen3/wigglytuff.png", width = 64, height = 64, columns = 16, frames = 53, durations = {16,33,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,83,16,83,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,50,1050} }, + back = { image = "assets/battle/back-animated/gen3/wigglytuff.png", width = 64, height = 64, columns = 16, frames = 53, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,84,33,84,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,50,1050} }, }, ZUBAT = { - back = { image = "assets/battle/back-animated/gen3/zubat.png", width = 84, height = 84, columns = 16, frames = 73, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,33,33,16,16,83,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,16,33,33,16,16,83,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,1033} }, + back = { image = "assets/battle/back-animated/gen3/zubat.png", width = 84, height = 84, columns = 16, frames = 73, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,84,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,84,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, }, GOLBAT = { - back = { image = "assets/battle/back-animated/gen3/golbat.png", width = 64, height = 64, columns = 16, frames = 37, durations = {16,16,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,16,16,1050} }, + back = { image = "assets/battle/back-animated/gen3/golbat.png", width = 64, height = 64, columns = 16, frames = 37, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050} }, }, ODDISH = { - back = { image = "assets/battle/back-animated/gen3/oddish.png", width = 64, height = 64, columns = 16, frames = 29, durations = {16,16,16,16,16,83,16,16,16,16,16,16,16,16,83,16,16,16,16,16,16,16,16,83,16,16,16,16,1050} }, + back = { image = "assets/battle/back-animated/gen3/oddish.png", width = 64, height = 64, columns = 16, frames = 29, durations = {33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,1050} }, }, GLOOM = { - back = { image = "assets/battle/back-animated/gen3/gloom.png", width = 64, height = 64, columns = 16, frames = 29, durations = {16,16,16,16,16,83,16,16,16,16,16,16,16,16,83,16,16,16,16,16,16,16,16,83,16,16,16,16,1050} }, + back = { image = "assets/battle/back-animated/gen3/gloom.png", width = 64, height = 64, columns = 16, frames = 29, durations = {33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,1050} }, }, VILEPLUME = { - back = { image = "assets/battle/back-animated/gen3/vileplume.png", width = 69, height = 69, columns = 16, frames = 41, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,1050} }, + back = { image = "assets/battle/back-animated/gen3/vileplume.png", width = 69, height = 69, columns = 16, frames = 41, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050} }, }, PARAS = { - back = { image = "assets/battle/back-animated/gen3/paras.png", width = 64, height = 64, columns = 16, frames = 29, durations = {16,16,16,16,16,83,16,16,16,16,16,16,16,16,83,16,16,16,16,16,16,16,16,83,16,16,16,16,1050} }, + back = { image = "assets/battle/back-animated/gen3/paras.png", width = 64, height = 64, columns = 16, frames = 29, durations = {33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,1050} }, }, PARASECT = { - back = { image = "assets/battle/back-animated/gen3/parasect.png", width = 64, height = 64, columns = 16, frames = 38, durations = {16,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,16,16,1033} }, + back = { image = "assets/battle/back-animated/gen3/parasect.png", width = 64, height = 64, columns = 16, frames = 38, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, }, VENONAT = { - back = { image = "assets/battle/back-animated/gen3/venonat.png", width = 83, height = 83, columns = 16, frames = 43, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,1050} }, + back = { image = "assets/battle/back-animated/gen3/venonat.png", width = 83, height = 83, columns = 16, frames = 43, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050} }, }, VENOMOTH = { - back = { image = "assets/battle/back-animated/gen3/venomoth.png", width = 86, height = 86, columns = 16, frames = 73, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,33,33,16,16,83,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,16,33,33,16,16,83,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,1033} }, + back = { image = "assets/battle/back-animated/gen3/venomoth.png", width = 86, height = 86, columns = 16, frames = 73, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,84,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,84,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, }, DIGLETT = { - back = { image = "assets/battle/back-animated/gen3/diglett.png", width = 64, height = 64, columns = 16, frames = 37, durations = {16,16,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,16,16,1050} }, + back = { image = "assets/battle/back-animated/gen3/diglett.png", width = 64, height = 64, columns = 16, frames = 37, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050} }, }, DUGTRIO = { - back = { image = "assets/battle/back-animated/gen3/dugtrio.png", width = 64, height = 64, columns = 16, frames = 37, durations = {16,16,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,16,16,1050} }, + back = { image = "assets/battle/back-animated/gen3/dugtrio.png", width = 64, height = 64, columns = 16, frames = 37, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050} }, }, MEOWTH = { - back = { image = "assets/battle/back-animated/gen3/meowth.png", width = 70, height = 70, columns = 16, frames = 44, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,1033} }, + back = { image = "assets/battle/back-animated/gen3/meowth.png", width = 70, height = 70, columns = 16, frames = 44, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, }, PERSIAN = { - back = { image = "assets/battle/back-animated/gen3/persian.png", width = 85, height = 85, columns = 16, frames = 18, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,1050} }, + back = { image = "assets/battle/back-animated/gen3/persian.png", width = 85, height = 85, columns = 16, frames = 18, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050} }, }, PSYDUCK = { - back = { image = "assets/battle/back-animated/gen3/psyduck.png", width = 64, height = 64, columns = 16, frames = 29, durations = {16,16,16,16,16,83,16,16,16,16,16,16,16,16,83,16,16,16,16,16,16,16,16,83,16,16,16,16,1050} }, + back = { image = "assets/battle/back-animated/gen3/psyduck.png", width = 64, height = 64, columns = 16, frames = 29, durations = {33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,1050} }, }, GOLDUCK = { - back = { image = "assets/battle/back-animated/gen3/golduck.png", width = 66, height = 66, columns = 16, frames = 41, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,1050} }, + back = { image = "assets/battle/back-animated/gen3/golduck.png", width = 66, height = 66, columns = 16, frames = 41, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050} }, }, MANKEY = { - back = { image = "assets/battle/back-animated/gen3/mankey.png", width = 72, height = 72, columns = 16, frames = 44, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,1033} }, + back = { image = "assets/battle/back-animated/gen3/mankey.png", width = 72, height = 72, columns = 16, frames = 44, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, }, PRIMEAPE = { - back = { image = "assets/battle/back-animated/gen3/primeape.png", width = 75, height = 75, columns = 16, frames = 44, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,1033} }, + back = { image = "assets/battle/back-animated/gen3/primeape.png", width = 75, height = 75, columns = 16, frames = 44, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, }, GROWLITHE = { - back = { image = "assets/battle/back-animated/gen3/growlithe.png", width = 65, height = 65, columns = 16, frames = 30, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,33,16,16,16,1033} }, + back = { image = "assets/battle/back-animated/gen3/growlithe.png", width = 65, height = 65, columns = 16, frames = 30, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, }, ARCANINE = { - back = { image = "assets/battle/back-animated/gen3/arcanine.png", width = 81, height = 81, columns = 16, frames = 30, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,33,16,16,16,1033} }, + back = { image = "assets/battle/back-animated/gen3/arcanine.png", width = 81, height = 81, columns = 16, frames = 30, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, }, POLIWAG = { - back = { image = "assets/battle/back-animated/gen3/poliwag.png", width = 64, height = 64, columns = 16, frames = 31, durations = {33,33,33,33,16,16,16,16,16,33,16,33,33,33,33,16,16,33,16,16,16,33,16,16,16,16,33,16,16,33,1033} }, + back = { image = "assets/battle/back-animated/gen3/poliwag.png", width = 64, height = 64, columns = 16, frames = 31, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, }, POLIWHIRL = { - back = { image = "assets/battle/back-animated/gen3/poliwhirl.png", width = 64, height = 64, columns = 16, frames = 37, durations = {16,16,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,16,16,1050} }, + back = { image = "assets/battle/back-animated/gen3/poliwhirl.png", width = 64, height = 64, columns = 16, frames = 37, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050} }, }, POLIWRATH = { - back = { image = "assets/battle/back-animated/gen3/poliwrath.png", width = 64, height = 64, columns = 16, frames = 36, durations = {16,16,16,16,16,16,33,16,33,16,33,16,33,16,16,33,66,385,16,16,16,16,16,16,16,16,33,16,33,16,16,16,16,50,50,1150} }, + back = { image = "assets/battle/back-animated/gen3/poliwrath.png", width = 64, height = 64, columns = 16, frames = 36, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,67,385,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,50,50,1151} }, }, ABRA = { - back = { image = "assets/battle/back-animated/gen3/abra.png", width = 64, height = 64, columns = 16, frames = 41, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,1050} }, + back = { image = "assets/battle/back-animated/gen3/abra.png", width = 64, height = 64, columns = 16, frames = 41, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050} }, }, KADABRA = { - back = { image = "assets/battle/back-animated/gen3/kadabra.png", width = 64, height = 64, columns = 16, frames = 41, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,1050} }, + back = { image = "assets/battle/back-animated/gen3/kadabra.png", width = 64, height = 64, columns = 16, frames = 41, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050} }, }, ALAKAZAM = { - back = { image = "assets/battle/back-animated/gen3/alakazam.png", width = 73, height = 73, columns = 16, frames = 31, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,1050} }, + back = { image = "assets/battle/back-animated/gen3/alakazam.png", width = 73, height = 73, columns = 16, frames = 31, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050} }, }, MACHOP = { - back = { image = "assets/battle/back-animated/gen3/machop.png", width = 65, height = 65, columns = 16, frames = 30, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,33,16,16,16,1033} }, + back = { image = "assets/battle/back-animated/gen3/machop.png", width = 65, height = 65, columns = 16, frames = 30, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, }, MACHOKE = { - back = { image = "assets/battle/back-animated/gen3/machoke.png", width = 64, height = 64, columns = 16, frames = 37, durations = {16,16,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,16,16,1050} }, + back = { image = "assets/battle/back-animated/gen3/machoke.png", width = 64, height = 64, columns = 16, frames = 37, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050} }, }, MACHAMP = { - back = { image = "assets/battle/back-animated/gen3/machamp.png", width = 66, height = 66, columns = 16, frames = 37, durations = {16,16,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,16,16,1050} }, + back = { image = "assets/battle/back-animated/gen3/machamp.png", width = 66, height = 66, columns = 16, frames = 37, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050} }, }, BELLSPROUT = { - back = { image = "assets/battle/back-animated/gen3/bellsprout.png", width = 64, height = 64, columns = 16, frames = 29, durations = {16,33,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,1050} }, + back = { image = "assets/battle/back-animated/gen3/bellsprout.png", width = 64, height = 64, columns = 16, frames = 29, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050} }, }, WEEPINBELL = { - back = { image = "assets/battle/back-animated/gen3/weepinbell.png", width = 64, height = 64, columns = 16, frames = 30, durations = {16,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,1050} }, + back = { image = "assets/battle/back-animated/gen3/weepinbell.png", width = 64, height = 64, columns = 16, frames = 30, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050} }, }, VICTREEBEL = { - back = { image = "assets/battle/back-animated/gen3/victreebel.png", width = 71, height = 71, columns = 16, frames = 31, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,1050} }, + back = { image = "assets/battle/back-animated/gen3/victreebel.png", width = 71, height = 71, columns = 16, frames = 31, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050} }, }, TENTACOOL = { - back = { image = "assets/battle/back-animated/gen3/tentacool.png", width = 64, height = 64, columns = 16, frames = 29, durations = {16,16,16,16,16,83,16,16,16,16,16,16,16,16,83,16,16,16,16,16,16,16,16,83,16,16,16,16,1050} }, + back = { image = "assets/battle/back-animated/gen3/tentacool.png", width = 64, height = 64, columns = 16, frames = 29, durations = {33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,1050} }, }, TENTACRUEL = { - back = { image = "assets/battle/back-animated/gen3/tentacruel.png", width = 71, height = 71, columns = 16, frames = 29, durations = {16,16,16,16,16,83,16,16,16,16,16,16,16,16,83,16,16,16,16,16,16,16,16,83,16,16,16,16,1050} }, + back = { image = "assets/battle/back-animated/gen3/tentacruel.png", width = 71, height = 71, columns = 16, frames = 29, durations = {33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,1050} }, }, GEODUDE = { - back = { image = "assets/battle/back-animated/gen3/geodude.png", width = 64, height = 64, columns = 16, frames = 36, durations = {16,16,16,16,16,16,33,16,33,16,33,16,33,16,16,33,66,385,16,16,16,16,16,16,16,16,33,16,33,16,16,16,16,50,50,1150} }, + back = { image = "assets/battle/back-animated/gen3/geodude.png", width = 64, height = 64, columns = 16, frames = 36, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,67,385,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,50,50,1151} }, }, GRAVELER = { - back = { image = "assets/battle/back-animated/gen3/graveler.png", width = 64, height = 64, columns = 16, frames = 38, durations = {16,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,16,16,1033} }, + back = { image = "assets/battle/back-animated/gen3/graveler.png", width = 64, height = 64, columns = 16, frames = 38, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, }, GOLEM = { - back = { image = "assets/battle/back-animated/gen3/golem.png", width = 67, height = 67, columns = 16, frames = 38, durations = {16,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,16,16,1033} }, + back = { image = "assets/battle/back-animated/gen3/golem.png", width = 67, height = 67, columns = 16, frames = 38, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, }, PONYTA = { - back = { image = "assets/battle/back-animated/gen3/ponyta.png", width = 64, height = 64, columns = 16, frames = 40, durations = {66,66,66,33,66,66,66,66,100,100,100,117,16,16,33,16,16,16,16,16,33,16,16,16,16,33,16,16,133,100,100,100,66,66,66,66,33,66,66,1066} }, + back = { image = "assets/battle/back-animated/gen3/ponyta.png", width = 64, height = 64, columns = 16, frames = 40, durations = {67,67,67,33,67,67,67,67,100,100,100,117,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,134,100,100,100,67,67,67,67,33,67,67,1067} }, }, RAPIDASH = { - back = { image = "assets/battle/back-animated/gen3/rapidash.png", width = 80, height = 80, columns = 16, frames = 30, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,33,16,16,16,1033} }, + back = { image = "assets/battle/back-animated/gen3/rapidash.png", width = 80, height = 80, columns = 16, frames = 30, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, }, SLOWPOKE = { - back = { image = "assets/battle/back-animated/gen3/slowpoke.png", width = 68, height = 68, columns = 16, frames = 29, durations = {16,16,16,16,16,83,16,16,16,16,16,16,16,16,83,16,16,16,16,16,16,16,16,83,16,16,16,16,1050} }, + back = { image = "assets/battle/back-animated/gen3/slowpoke.png", width = 68, height = 68, columns = 16, frames = 29, durations = {33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,1050} }, }, SLOWBRO = { - back = { image = "assets/battle/back-animated/gen3/slowbro.png", width = 67, height = 67, columns = 16, frames = 17, durations = {50,50,33,50,50,50,66,150,16,150,83,50,50,33,50,33,1083} }, + back = { image = "assets/battle/back-animated/gen3/slowbro.png", width = 67, height = 67, columns = 16, frames = 17, durations = {50,50,33,50,50,50,67,151,33,151,84,50,50,33,50,33,1084} }, }, MAGNEMITE = { - back = { image = "assets/battle/back-animated/gen3/magnemite.png", width = 64, height = 64, columns = 16, frames = 18, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,1050} }, + back = { image = "assets/battle/back-animated/gen3/magnemite.png", width = 64, height = 64, columns = 16, frames = 18, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050} }, }, MAGNETON = { - back = { image = "assets/battle/back-animated/gen3/magneton.png", width = 72, height = 72, columns = 16, frames = 18, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,1050} }, + back = { image = "assets/battle/back-animated/gen3/magneton.png", width = 72, height = 72, columns = 16, frames = 18, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050} }, }, FARFETCHD = { - back = { image = "assets/battle/back-animated/gen3/farfetchd.png", width = 64, height = 64, columns = 16, frames = 29, durations = {16,16,16,16,16,83,16,16,16,16,16,16,16,16,83,16,16,16,16,16,16,16,16,83,16,16,16,16,1050} }, + back = { image = "assets/battle/back-animated/gen3/farfetchd.png", width = 64, height = 64, columns = 16, frames = 29, durations = {33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,1050} }, }, DODUO = { - back = { image = "assets/battle/back-animated/gen3/doduo.png", width = 70, height = 70, columns = 16, frames = 18, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,1050} }, + back = { image = "assets/battle/back-animated/gen3/doduo.png", width = 70, height = 70, columns = 16, frames = 18, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050} }, }, DODRIO = { - back = { image = "assets/battle/back-animated/gen3/dodrio.png", width = 81, height = 81, columns = 16, frames = 30, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,33,16,16,16,1033} }, + back = { image = "assets/battle/back-animated/gen3/dodrio.png", width = 81, height = 81, columns = 16, frames = 30, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, }, SEEL = { - back = { image = "assets/battle/back-animated/gen3/seel.png", width = 65, height = 65, columns = 16, frames = 17, durations = {50,50,33,50,50,50,66,150,16,150,83,50,50,33,50,33,1083} }, + back = { image = "assets/battle/back-animated/gen3/seel.png", width = 65, height = 65, columns = 16, frames = 17, durations = {50,50,33,50,50,50,67,151,33,151,84,50,50,33,50,33,1084} }, }, DEWGONG = { - back = { image = "assets/battle/back-animated/gen3/dewgong.png", width = 64, height = 64, columns = 16, frames = 29, durations = {16,16,16,16,16,83,16,16,16,16,16,16,16,16,83,16,16,16,16,16,16,16,16,83,16,16,16,16,1050} }, + back = { image = "assets/battle/back-animated/gen3/dewgong.png", width = 64, height = 64, columns = 16, frames = 29, durations = {33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,1050} }, }, GRIMER = { - back = { image = "assets/battle/back-animated/gen3/grimer.png", width = 64, height = 64, columns = 16, frames = 30, durations = {16,33,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,1050} }, + back = { image = "assets/battle/back-animated/gen3/grimer.png", width = 64, height = 64, columns = 16, frames = 30, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050} }, }, MUK = { - back = { image = "assets/battle/back-animated/gen3/muk.png", width = 92, height = 92, columns = 16, frames = 31, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,1050} }, + back = { image = "assets/battle/back-animated/gen3/muk.png", width = 92, height = 92, columns = 16, frames = 31, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050} }, }, SHELLDER = { - back = { image = "assets/battle/back-animated/gen3/shellder.png", width = 64, height = 64, columns = 16, frames = 17, durations = {50,50,33,50,50,50,66,150,16,150,83,50,50,33,50,33,1083} }, + back = { image = "assets/battle/back-animated/gen3/shellder.png", width = 64, height = 64, columns = 16, frames = 17, durations = {50,50,33,50,50,50,67,151,33,151,84,50,50,33,50,33,1084} }, }, CLOYSTER = { - back = { image = "assets/battle/back-animated/gen3/cloyster.png", width = 81, height = 81, columns = 16, frames = 18, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,1050} }, + back = { image = "assets/battle/back-animated/gen3/cloyster.png", width = 81, height = 81, columns = 16, frames = 18, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050} }, }, GASTLY = { - back = { image = "assets/battle/back-animated/gen3/gastly.png", width = 81, height = 81, columns = 16, frames = 39, durations = {33,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,1066} }, + back = { image = "assets/battle/back-animated/gen3/gastly.png", width = 81, height = 81, columns = 16, frames = 39, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1067} }, }, HAUNTER = { - back = { image = "assets/battle/back-animated/gen3/haunter.png", width = 68, height = 68, columns = 16, frames = 39, durations = {33,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,1066} }, + back = { image = "assets/battle/back-animated/gen3/haunter.png", width = 68, height = 68, columns = 16, frames = 39, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1067} }, }, GENGAR = { - back = { image = "assets/battle/back-animated/gen3/gengar.png", width = 64, height = 64, columns = 16, frames = 41, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,1050} }, + back = { image = "assets/battle/back-animated/gen3/gengar.png", width = 64, height = 64, columns = 16, frames = 41, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050} }, }, ONIX = { - back = { image = "assets/battle/back-animated/gen3/onix.png", width = 70, height = 70, columns = 16, frames = 37, durations = {16,16,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,16,16,1050} }, + back = { image = "assets/battle/back-animated/gen3/onix.png", width = 70, height = 70, columns = 16, frames = 37, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050} }, }, DROWZEE = { - back = { image = "assets/battle/back-animated/gen3/drowzee.png", width = 64, height = 64, columns = 16, frames = 17, durations = {50,50,33,50,50,50,66,150,16,150,83,50,50,33,50,33,1083} }, + back = { image = "assets/battle/back-animated/gen3/drowzee.png", width = 64, height = 64, columns = 16, frames = 17, durations = {50,50,33,50,50,50,67,151,33,151,84,50,50,33,50,33,1084} }, }, HYPNO = { - back = { image = "assets/battle/back-animated/gen3/hypno.png", width = 64, height = 64, columns = 16, frames = 41, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,1050} }, + back = { image = "assets/battle/back-animated/gen3/hypno.png", width = 64, height = 64, columns = 16, frames = 41, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050} }, }, KRABBY = { - back = { image = "assets/battle/back-animated/gen3/krabby.png", width = 80, height = 80, columns = 16, frames = 43, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,1050} }, + back = { image = "assets/battle/back-animated/gen3/krabby.png", width = 80, height = 80, columns = 16, frames = 43, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050} }, }, KINGLER = { - back = { image = "assets/battle/back-animated/gen3/kingler.png", width = 66, height = 66, columns = 16, frames = 37, durations = {16,16,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,16,16,1050} }, + back = { image = "assets/battle/back-animated/gen3/kingler.png", width = 66, height = 66, columns = 16, frames = 37, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050} }, }, VOLTORB = { - back = { image = "assets/battle/back-animated/gen3/voltorb.png", width = 64, height = 64, columns = 16, frames = 30, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,33,16,16,16,1033} }, + back = { image = "assets/battle/back-animated/gen3/voltorb.png", width = 64, height = 64, columns = 16, frames = 30, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, }, ELECTRODE = { - back = { image = "assets/battle/back-animated/gen3/electrode.png", width = 64, height = 64, columns = 16, frames = 30, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,33,16,16,16,1033} }, + back = { image = "assets/battle/back-animated/gen3/electrode.png", width = 64, height = 64, columns = 16, frames = 30, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, }, EXEGGCUTE = { - back = { image = "assets/battle/back-animated/gen3/exeggcute.png", width = 64, height = 64, columns = 16, frames = 29, durations = {16,16,16,16,16,83,16,16,16,16,16,16,16,16,83,16,16,16,16,16,16,16,16,83,16,16,16,16,1050} }, + back = { image = "assets/battle/back-animated/gen3/exeggcute.png", width = 64, height = 64, columns = 16, frames = 29, durations = {33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,1050} }, }, EXEGGUTOR = { - back = { image = "assets/battle/back-animated/gen3/exeggutor.png", width = 87, height = 87, columns = 16, frames = 44, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,1033} }, + back = { image = "assets/battle/back-animated/gen3/exeggutor.png", width = 87, height = 87, columns = 16, frames = 44, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, }, CUBONE = { - back = { image = "assets/battle/back-animated/gen3/cubone.png", width = 66, height = 66, columns = 16, frames = 30, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,33,16,16,16,1033} }, + back = { image = "assets/battle/back-animated/gen3/cubone.png", width = 66, height = 66, columns = 16, frames = 30, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, }, MAROWAK = { - back = { image = "assets/battle/back-animated/gen3/marowak.png", width = 68, height = 68, columns = 16, frames = 44, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,1033} }, + back = { image = "assets/battle/back-animated/gen3/marowak.png", width = 68, height = 68, columns = 16, frames = 44, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, }, HITMONLEE = { - back = { image = "assets/battle/back-animated/gen3/hitmonlee.png", width = 64, height = 64, columns = 16, frames = 29, durations = {16,16,16,16,16,83,16,16,16,16,16,16,16,16,83,16,16,16,16,16,16,16,16,83,16,16,16,16,1050} }, + back = { image = "assets/battle/back-animated/gen3/hitmonlee.png", width = 64, height = 64, columns = 16, frames = 29, durations = {33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,1050} }, }, HITMONCHAN = { - back = { image = "assets/battle/back-animated/gen3/hitmonchan.png", width = 70, height = 70, columns = 16, frames = 18, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,1050} }, + back = { image = "assets/battle/back-animated/gen3/hitmonchan.png", width = 70, height = 70, columns = 16, frames = 18, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050} }, }, LICKITUNG = { - back = { image = "assets/battle/back-animated/gen3/lickitung.png", width = 64, height = 64, columns = 16, frames = 29, durations = {16,16,16,16,16,83,16,16,16,16,16,16,16,16,83,16,16,16,16,16,16,16,16,83,16,16,16,16,1050} }, + back = { image = "assets/battle/back-animated/gen3/lickitung.png", width = 64, height = 64, columns = 16, frames = 29, durations = {33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,1050} }, }, KOFFING = { - back = { image = "assets/battle/back-animated/gen3/koffing.png", width = 64, height = 64, columns = 16, frames = 53, durations = {16,33,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,83,16,83,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,50,1050} }, + back = { image = "assets/battle/back-animated/gen3/koffing.png", width = 64, height = 64, columns = 16, frames = 53, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,84,33,84,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,50,1050} }, }, WEEZING = { - back = { image = "assets/battle/back-animated/gen3/weezing.png", width = 71, height = 71, columns = 16, frames = 55, durations = {16,33,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,33,50,16,50,33,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,50,1050} }, + back = { image = "assets/battle/back-animated/gen3/weezing.png", width = 71, height = 71, columns = 16, frames = 55, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,50,33,50,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,50,1050} }, }, RHYHORN = { - back = { image = "assets/battle/back-animated/gen3/rhyhorn.png", width = 64, height = 64, columns = 16, frames = 36, durations = {16,16,16,16,16,16,33,16,33,16,33,16,33,16,16,33,66,385,16,16,16,16,16,16,16,16,33,16,33,16,16,16,16,50,50,1150} }, + back = { image = "assets/battle/back-animated/gen3/rhyhorn.png", width = 64, height = 64, columns = 16, frames = 36, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,67,385,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,50,50,1151} }, }, RHYDON = { - back = { image = "assets/battle/back-animated/gen3/rhydon.png", width = 71, height = 71, columns = 16, frames = 36, durations = {16,16,16,16,16,16,33,16,33,16,33,16,33,16,16,33,66,385,16,16,16,16,16,16,16,16,33,16,33,16,16,16,16,50,50,1150} }, + back = { image = "assets/battle/back-animated/gen3/rhydon.png", width = 71, height = 71, columns = 16, frames = 36, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,67,385,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,50,50,1151} }, }, CHANSEY = { - back = { image = "assets/battle/back-animated/gen3/chansey.png", width = 66, height = 66, columns = 16, frames = 31, durations = {33,33,33,33,16,16,16,16,16,33,16,33,33,33,33,16,16,33,16,16,16,33,16,16,16,16,33,16,16,33,1033} }, + back = { image = "assets/battle/back-animated/gen3/chansey.png", width = 66, height = 66, columns = 16, frames = 31, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, }, TANGELA = { - back = { image = "assets/battle/back-animated/gen3/tangela.png", width = 64, height = 64, columns = 16, frames = 29, durations = {16,33,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,33,1050} }, + back = { image = "assets/battle/back-animated/gen3/tangela.png", width = 64, height = 64, columns = 16, frames = 29, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050} }, }, KANGASKHAN = { - back = { image = "assets/battle/back-animated/gen3/kangaskhan.png", width = 65, height = 65, columns = 16, frames = 31, durations = {33,33,33,33,16,16,16,16,16,33,16,33,33,33,33,16,16,33,16,16,16,33,16,16,16,16,33,16,16,33,1033} }, + back = { image = "assets/battle/back-animated/gen3/kangaskhan.png", width = 65, height = 65, columns = 16, frames = 31, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, }, HORSEA = { - back = { image = "assets/battle/back-animated/gen3/horsea.png", width = 64, height = 64, columns = 16, frames = 17, durations = {50,50,33,50,50,50,66,150,16,150,83,50,50,33,50,33,1083} }, + back = { image = "assets/battle/back-animated/gen3/horsea.png", width = 64, height = 64, columns = 16, frames = 17, durations = {50,50,33,50,50,50,67,151,33,151,84,50,50,33,50,33,1084} }, }, SEADRA = { - back = { image = "assets/battle/back-animated/gen3/seadra.png", width = 78, height = 78, columns = 16, frames = 73, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,33,33,16,16,83,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,16,33,33,16,16,83,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,1033} }, + back = { image = "assets/battle/back-animated/gen3/seadra.png", width = 78, height = 78, columns = 16, frames = 73, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,84,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,84,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, }, GOLDEEN = { - back = { image = "assets/battle/back-animated/gen3/goldeen.png", width = 78, height = 78, columns = 16, frames = 73, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,33,33,16,16,83,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,16,33,33,16,16,83,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,1033} }, + back = { image = "assets/battle/back-animated/gen3/goldeen.png", width = 78, height = 78, columns = 16, frames = 73, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,84,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,84,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, }, SEAKING = { - back = { image = "assets/battle/back-animated/gen3/seaking.png", width = 83, height = 83, columns = 16, frames = 73, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,33,33,16,16,83,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,16,33,33,16,16,83,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,1033} }, + back = { image = "assets/battle/back-animated/gen3/seaking.png", width = 83, height = 83, columns = 16, frames = 73, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,84,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,84,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, }, STARYU = { - back = { image = "assets/battle/back-animated/gen3/staryu.png", width = 64, height = 64, columns = 16, frames = 17, durations = {50,50,33,50,50,50,66,150,16,150,83,50,50,33,50,33,1083} }, + back = { image = "assets/battle/back-animated/gen3/staryu.png", width = 64, height = 64, columns = 16, frames = 17, durations = {50,50,33,50,50,50,67,151,33,151,84,50,50,33,50,33,1084} }, }, STARMIE = { - back = { image = "assets/battle/back-animated/gen3/starmie.png", width = 70, height = 70, columns = 16, frames = 40, durations = {66,66,66,33,66,66,66,66,100,100,100,117,16,16,33,16,16,16,16,16,33,16,16,16,16,33,16,16,133,100,100,100,66,66,66,66,33,66,66,1066} }, + back = { image = "assets/battle/back-animated/gen3/starmie.png", width = 70, height = 70, columns = 16, frames = 40, durations = {67,67,67,33,67,67,67,67,100,100,100,117,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,134,100,100,100,67,67,67,67,33,67,67,1067} }, }, MR_MIME = { - back = { image = "assets/battle/back-animated/gen3/mr-mime.png", width = 73, height = 73, columns = 16, frames = 41, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,1050} }, + back = { image = "assets/battle/back-animated/gen3/mr-mime.png", width = 73, height = 73, columns = 16, frames = 41, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050} }, }, SCYTHER = { - back = { image = "assets/battle/back-animated/gen3/scyther.png", width = 76, height = 76, columns = 16, frames = 18, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,1050} }, + back = { image = "assets/battle/back-animated/gen3/scyther.png", width = 76, height = 76, columns = 16, frames = 18, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050} }, }, JYNX = { - back = { image = "assets/battle/back-animated/gen3/jynx.png", width = 64, height = 64, columns = 16, frames = 17, durations = {50,50,33,50,50,50,66,150,16,150,83,50,50,33,50,33,1083} }, + back = { image = "assets/battle/back-animated/gen3/jynx.png", width = 64, height = 64, columns = 16, frames = 17, durations = {50,50,33,50,50,50,67,151,33,151,84,50,50,33,50,33,1084} }, }, ELECTABUZZ = { - back = { image = "assets/battle/back-animated/gen3/electabuzz.png", width = 64, height = 64, columns = 16, frames = 30, durations = {50,50,33,16,50,50,50,50,50,50,33,16,33,16,33,16,33,16,33,16,33,16,33,16,33,16,33,16,33,1033} }, + back = { image = "assets/battle/back-animated/gen3/electabuzz.png", width = 64, height = 64, columns = 16, frames = 30, durations = {50,50,33,33,50,50,50,50,50,50,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, }, MAGMAR = { - back = { image = "assets/battle/back-animated/gen3/magmar.png", width = 64, height = 64, columns = 16, frames = 40, durations = {66,66,66,33,66,66,66,66,100,100,100,117,16,16,33,16,16,16,16,16,33,16,16,16,16,33,16,16,133,100,100,100,66,66,66,66,33,66,66,1066} }, + back = { image = "assets/battle/back-animated/gen3/magmar.png", width = 64, height = 64, columns = 16, frames = 40, durations = {67,67,67,33,67,67,67,67,100,100,100,117,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,134,100,100,100,67,67,67,67,33,67,67,1067} }, }, PINSIR = { - back = { image = "assets/battle/back-animated/gen3/pinsir.png", width = 65, height = 65, columns = 16, frames = 36, durations = {16,16,16,16,16,16,33,16,33,16,33,16,33,16,16,33,66,385,16,16,16,16,16,16,16,16,33,16,33,16,16,16,16,50,50,1150} }, + back = { image = "assets/battle/back-animated/gen3/pinsir.png", width = 65, height = 65, columns = 16, frames = 36, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,67,385,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,50,50,1151} }, }, TAUROS = { - back = { image = "assets/battle/back-animated/gen3/tauros.png", width = 64, height = 64, columns = 16, frames = 36, durations = {16,16,16,16,16,16,33,16,33,16,33,16,33,16,16,33,66,385,16,16,16,16,16,16,16,16,33,16,33,16,16,16,16,50,50,1150} }, + back = { image = "assets/battle/back-animated/gen3/tauros.png", width = 64, height = 64, columns = 16, frames = 36, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,67,385,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,50,50,1151} }, }, MAGIKARP = { - back = { image = "assets/battle/back-animated/gen3/magikarp.png", width = 75, height = 75, columns = 16, frames = 44, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,1033} }, + back = { image = "assets/battle/back-animated/gen3/magikarp.png", width = 75, height = 75, columns = 16, frames = 44, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, }, GYARADOS = { - back = { image = "assets/battle/back-animated/gen3/gyarados.png", width = 70, height = 70, columns = 16, frames = 37, durations = {16,16,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,16,16,1050} }, + back = { image = "assets/battle/back-animated/gen3/gyarados.png", width = 70, height = 70, columns = 16, frames = 37, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050} }, }, LAPRAS = { - back = { image = "assets/battle/back-animated/gen3/lapras.png", width = 65, height = 65, columns = 16, frames = 40, durations = {66,66,66,33,66,66,66,66,100,100,100,117,16,16,33,16,16,16,16,16,33,16,16,16,16,33,16,16,133,100,100,100,66,66,66,66,33,66,66,1066} }, + back = { image = "assets/battle/back-animated/gen3/lapras.png", width = 65, height = 65, columns = 16, frames = 40, durations = {67,67,67,33,67,67,67,67,100,100,100,117,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,134,100,100,100,67,67,67,67,33,67,67,1067} }, }, DITTO = { - back = { image = "assets/battle/back-animated/gen3/ditto.png", width = 64, height = 64, columns = 16, frames = 47, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,50,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,1050} }, + back = { image = "assets/battle/back-animated/gen3/ditto.png", width = 64, height = 64, columns = 16, frames = 47, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,50,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050} }, }, EEVEE = { - back = { image = "assets/battle/back-animated/gen3/eevee.png", width = 64, height = 64, columns = 16, frames = 31, durations = {33,33,33,33,16,16,16,16,16,33,16,33,33,33,33,16,16,33,16,16,16,33,16,16,16,16,33,16,16,33,1033} }, + back = { image = "assets/battle/back-animated/gen3/eevee.png", width = 64, height = 64, columns = 16, frames = 31, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, }, VAPOREON = { - back = { image = "assets/battle/back-animated/gen3/vaporeon.png", width = 64, height = 64, columns = 16, frames = 40, durations = {66,66,66,33,66,66,66,66,100,100,100,117,16,16,33,16,16,16,16,16,33,16,16,16,16,33,16,16,133,100,100,100,66,66,66,66,33,66,66,1066} }, + back = { image = "assets/battle/back-animated/gen3/vaporeon.png", width = 64, height = 64, columns = 16, frames = 40, durations = {67,67,67,33,67,67,67,67,100,100,100,117,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,134,100,100,100,67,67,67,67,33,67,67,1067} }, }, JOLTEON = { - back = { image = "assets/battle/back-animated/gen3/jolteon.png", width = 64, height = 64, columns = 16, frames = 30, durations = {50,50,33,16,50,50,50,50,50,50,33,16,33,16,33,16,33,16,33,16,33,16,33,16,33,16,33,16,33,1033} }, + back = { image = "assets/battle/back-animated/gen3/jolteon.png", width = 64, height = 64, columns = 16, frames = 30, durations = {50,50,33,33,50,50,50,50,50,50,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, }, FLAREON = { - back = { image = "assets/battle/back-animated/gen3/flareon.png", width = 64, height = 64, columns = 16, frames = 40, durations = {66,66,66,33,66,66,66,66,100,100,100,117,16,16,33,16,16,16,16,16,33,16,16,16,16,33,16,16,133,100,100,100,66,66,66,66,33,66,66,1066} }, + back = { image = "assets/battle/back-animated/gen3/flareon.png", width = 64, height = 64, columns = 16, frames = 40, durations = {67,67,67,33,67,67,67,67,100,100,100,117,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,134,100,100,100,67,67,67,67,33,67,67,1067} }, }, PORYGON = { - back = { image = "assets/battle/back-animated/gen3/porygon.png", width = 64, height = 64, columns = 16, frames = 39, durations = {33,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,1066} }, + back = { image = "assets/battle/back-animated/gen3/porygon.png", width = 64, height = 64, columns = 16, frames = 39, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1067} }, }, OMANYTE = { - back = { image = "assets/battle/back-animated/gen3/omanyte.png", width = 64, height = 64, columns = 16, frames = 17, durations = {50,50,33,50,50,50,66,150,16,150,83,50,50,33,50,33,1083} }, + back = { image = "assets/battle/back-animated/gen3/omanyte.png", width = 64, height = 64, columns = 16, frames = 17, durations = {50,50,33,50,50,50,67,151,33,151,84,50,50,33,50,33,1084} }, }, OMASTAR = { - back = { image = "assets/battle/back-animated/gen3/omastar.png", width = 66, height = 66, columns = 16, frames = 17, durations = {50,50,33,50,50,50,66,150,16,150,83,50,50,33,50,33,1083} }, + back = { image = "assets/battle/back-animated/gen3/omastar.png", width = 66, height = 66, columns = 16, frames = 17, durations = {50,50,33,50,50,50,67,151,33,151,84,50,50,33,50,33,1084} }, }, KABUTO = { - back = { image = "assets/battle/back-animated/gen3/kabuto.png", width = 64, height = 64, columns = 16, frames = 17, durations = {50,50,33,50,50,50,66,150,16,150,83,50,50,33,50,33,1083} }, + back = { image = "assets/battle/back-animated/gen3/kabuto.png", width = 64, height = 64, columns = 16, frames = 17, durations = {50,50,33,50,50,50,67,151,33,151,84,50,50,33,50,33,1084} }, }, KABUTOPS = { - back = { image = "assets/battle/back-animated/gen3/kabutops.png", width = 69, height = 69, columns = 16, frames = 30, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,33,16,16,16,1033} }, + back = { image = "assets/battle/back-animated/gen3/kabutops.png", width = 69, height = 69, columns = 16, frames = 30, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, }, AERODACTYL = { - back = { image = "assets/battle/back-animated/gen3/aerodactyl.png", width = 79, height = 79, columns = 16, frames = 30, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,33,16,16,16,1033} }, + back = { image = "assets/battle/back-animated/gen3/aerodactyl.png", width = 79, height = 79, columns = 16, frames = 30, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, }, SNORLAX = { - back = { image = "assets/battle/back-animated/gen3/snorlax.png", width = 67, height = 67, columns = 16, frames = 17, durations = {50,50,33,50,50,50,66,150,16,150,83,50,50,33,50,33,1083} }, + back = { image = "assets/battle/back-animated/gen3/snorlax.png", width = 67, height = 67, columns = 16, frames = 17, durations = {50,50,33,50,50,50,67,151,33,151,84,50,50,33,50,33,1084} }, }, ARTICUNO = { - back = { image = "assets/battle/back-animated/gen3/articuno.png", width = 64, height = 64, columns = 16, frames = 40, durations = {66,66,66,33,66,66,66,66,100,100,100,117,16,16,33,16,16,16,16,16,33,16,16,16,16,33,16,16,133,100,100,100,66,66,66,66,33,66,66,1066} }, + back = { image = "assets/battle/back-animated/gen3/articuno.png", width = 64, height = 64, columns = 16, frames = 40, durations = {67,67,67,33,67,67,67,67,100,100,100,117,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,134,100,100,100,67,67,67,67,33,67,67,1067} }, }, ZAPDOS = { - back = { image = "assets/battle/back-animated/gen3/zapdos.png", width = 64, height = 64, columns = 16, frames = 30, durations = {50,50,33,16,50,50,50,50,50,50,33,16,33,16,33,16,33,16,33,16,33,16,33,16,33,16,33,16,33,1033} }, + back = { image = "assets/battle/back-animated/gen3/zapdos.png", width = 64, height = 64, columns = 16, frames = 30, durations = {50,50,33,33,50,50,50,50,50,50,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, }, MOLTRES = { - back = { image = "assets/battle/back-animated/gen3/moltres.png", width = 67, height = 67, columns = 16, frames = 40, durations = {66,66,66,33,66,66,66,66,100,100,100,117,16,16,33,16,16,16,16,16,33,16,16,16,16,33,16,16,133,100,100,100,66,66,66,66,33,66,66,1066} }, + back = { image = "assets/battle/back-animated/gen3/moltres.png", width = 67, height = 67, columns = 16, frames = 40, durations = {67,67,67,33,67,67,67,67,100,100,100,117,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,134,100,100,100,67,67,67,67,33,67,67,1067} }, }, DRATINI = { - back = { image = "assets/battle/back-animated/gen3/dratini.png", width = 64, height = 64, columns = 16, frames = 29, durations = {16,16,16,16,16,83,16,16,16,16,16,16,16,16,83,16,16,16,16,16,16,16,16,83,16,16,16,16,1050} }, + back = { image = "assets/battle/back-animated/gen3/dratini.png", width = 64, height = 64, columns = 16, frames = 29, durations = {33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,1050} }, }, DRAGONAIR = { - back = { image = "assets/battle/back-animated/gen3/dragonair.png", width = 76, height = 76, columns = 16, frames = 18, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,1050} }, + back = { image = "assets/battle/back-animated/gen3/dragonair.png", width = 76, height = 76, columns = 16, frames = 18, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050} }, }, DRAGONITE = { - back = { image = "assets/battle/back-animated/gen3/dragonite.png", width = 64, height = 64, columns = 16, frames = 37, durations = {16,16,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,16,16,33,16,16,16,16,16,16,16,16,16,16,16,16,16,1050} }, + back = { image = "assets/battle/back-animated/gen3/dragonite.png", width = 64, height = 64, columns = 16, frames = 37, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050} }, }, MEWTWO = { - back = { image = "assets/battle/back-animated/gen3/mewtwo.png", width = 84, height = 84, columns = 16, frames = 31, durations = {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,1050} }, + back = { image = "assets/battle/back-animated/gen3/mewtwo.png", width = 84, height = 84, columns = 16, frames = 31, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050} }, }, MEW = { - back = { image = "assets/battle/back-animated/gen3/mew.png", width = 64, height = 64, columns = 16, frames = 31, durations = {33,33,33,33,16,16,16,16,16,33,16,33,33,33,33,16,16,33,16,16,16,33,16,16,16,16,33,16,16,33,1033} }, + back = { image = "assets/battle/back-animated/gen3/mew.png", width = 64, height = 64, columns = 16, frames = 31, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, }, } diff --git a/lib/AnimatedBattleArt.lua b/lib/AnimatedBattleArt.lua index 22430cd..1074171 100644 --- a/lib/AnimatedBattleArt.lua +++ b/lib/AnimatedBattleArt.lua @@ -18,9 +18,15 @@ local BACK_SETS = { gen5 = SETS.gen5, } -- Gen 1 SGB, Gen 2 Crystal, and Gen 4 backs are intentionally single-frame --- collections even while BATTLE ART is ANIMATED. Keeping this allowlist --- narrow prevents a static generation folder from being decoded as an atlas. -local ANIMATED_BACK_SETS = { gen3 = true, gen5 = true } +-- collections even while BATTLE ART is ANIMATED. An explicit source table +-- prevents a static generation folder from being decoded as an atlas. +local BACK_SOURCE_KIND = { + gen1 = "static", + gen2 = "static", + gen3 = "animated", + gen4 = "static", + gen5 = "animated", +} local PLAYER_SETS = V.data("animated_player_trainers") local AnimatedBattleArt = {} @@ -258,7 +264,7 @@ function AnimatedBattleArt.update(battle, dt) local playerSide = BattleArt.playerSide() if playerSide == "back" then local generation = BattleArt.backAnimationSetting:get() - if ANIMATED_BACK_SETS[generation] then + if BACK_SOURCE_KIND[generation] == "animated" then updateBattler(battle.player, "back", dt, mode) else updateStaticBack(battle.player, generation, mode) diff --git a/main.lua b/main.lua index 0433626..8c0b7e0 100644 --- a/main.lua +++ b/main.lua @@ -371,8 +371,9 @@ local SETTINGS = { end, full = true }, { BattleArt.backAnimationSetting, "Choose the player back-art generation. STATIC reads only a PNG from " - .. "back-static/GEN for every choice. ANIMATED reads static GEN 1-4 " - .. "PNGs or GEN 5 atlases. Missing art falls back to the ROM.", + .. "back-static/GEN for every choice. ANIMATED reads static GEN 1, 2, " + .. "and 4 PNGs, or animated GEN 3 and 5 atlases. Missing art falls " + .. "back to the ROM.", when = function() return stagedBattles() and BattleArt.setting:get() ~= "rom" end, full = true }, diff --git a/tools/import_animated_sprites.py b/tools/import_animated_sprites.py index f40bc91..e6a2eb0 100644 --- a/tools/import_animated_sprites.py +++ b/tools/import_animated_sprites.py @@ -108,14 +108,28 @@ def download_gen4(base: str, number: int) -> bytes: def convert(raw: bytes, destination: Path, columns: int = 16, - max_dimension: int = 2048) -> dict: + max_dimension: int = 2048, *, coalesce: bool = False, + minimum_duration: int = 0) -> dict: with Image.open(BytesIO(raw)) as gif: frames, durations = [], [] for frame in ImageSequence.Iterator(gif): - durations.append( - int(frame.info.get("duration", gif.info.get("duration", 100))) - ) - frames.append(frame.convert("RGBA")) + duration = max(1, round(float( + frame.info.get("duration", gif.info.get("duration", 100)) + ))) + rgba = frame.convert("RGBA") + # APNGs may carry arbitrary RGB values behind fully transparent + # pixels. Normalize them before equality checks and atlas packing: + # those hidden bytes have no visual meaning and LÖVE never sees + # them after alpha compositing. + normalized = Image.new("RGBA", rgba.size) + normalized.alpha_composite(rgba) + if coalesce and frames and normalized.tobytes() == frames[-1].tobytes(): + durations[-1] += duration + else: + frames.append(normalized) + durations.append(duration) + if minimum_duration > 0: + durations = [max(minimum_duration, duration) for duration in durations] if not frames: raise ValueError("GIF contains no frames") width, height = frames[0].size diff --git a/tools/import_emerald_back_sprites.py b/tools/import_emerald_back_sprites.py index cac6efd..2c20b5c 100644 --- a/tools/import_emerald_back_sprites.py +++ b/tools/import_emerald_back_sprites.py @@ -50,7 +50,16 @@ def main() -> None: animated_name = f"Spr b 3e {number:03d}.png" animated = archive_png(args.base_url, animated_name) animated_rel = f"assets/battle/back-animated/gen3/{slug}.png" - info = convert(animated, root / animated_rel) + # The archive APNGs frequently encode holds as repeated cells and use + # one-tick (~16 ms) poses. Merge only consecutive visual duplicates, + # retaining their combined time, then keep every remaining pose on + # screen for at least two 60 Hz updates so it is visible in LÖVE. + info = convert( + animated, + root / animated_rel, + coalesce=True, + minimum_duration=33, + ) static_name = f"Spr b 3r {number:03d}.png" static_path = root / "assets" / "battle" / "back-static" / "gen3" / f"{slug}.png" From c5a62b768264b9fba390c0bf174568b643693c07 Mon Sep 17 00:00:00 2001 From: absol89 Date: Sun, 2 Aug 2026 12:02:34 +0200 Subject: [PATCH 034/116] Preserve Emerald back sprite movement --- CHANGELOG.md | 3 + data/animated_battle_backs_gen3.lua | 302 +++++++++++++-------------- lib/AnimatedBattleArt.lua | 7 +- lib/BattleArt.lua | 23 ++ tools/import_animated_sprites.py | 5 +- tools/import_emerald_back_sprites.py | 4 +- 6 files changed, 189 insertions(+), 155 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ca85385..8d96456 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,9 @@ frames are coalesced while retaining their combined hold time, and distinct Gen 3 back poses remain visible for at least 33 ms instead of disappearing within a single 60 Hz update. +- **Emerald movement-preserving anchors.** Gen 3 back atlases share the final + neutral frame's placement anchor across their animation, preserving authored + translation and scaling instead of re-centering every pose into stillness. - **Independent front/back sets.** `ANIM FRONT GEN` selects animated GEN 2, GEN 3, GEN 4, or GEN 5 fronts. `BACK ART SET` selects the static GEN 1–5 folder in STATIC mode; ANIMATED uses animated GEN 3/5 atlases or static GEN 1/2/4 PNGs. diff --git a/data/animated_battle_backs_gen3.lua b/data/animated_battle_backs_gen3.lua index d9c7589..4bac534 100644 --- a/data/animated_battle_backs_gen3.lua +++ b/data/animated_battle_backs_gen3.lua @@ -2,456 +2,456 @@ -- Pokemon Emerald animated backs, National Dex #001-#151. return { BULBASAUR = { - back = { image = "assets/battle/back-animated/gen3/bulbasaur.png", width = 64, height = 64, columns = 16, frames = 17, durations = {50,50,33,50,50,50,67,151,33,151,84,50,50,33,50,33,1084} }, + back = { image = "assets/battle/back-animated/gen3/bulbasaur.png", width = 64, height = 64, columns = 16, frames = 17, durations = {50,50,33,50,50,50,67,151,33,151,84,50,50,33,50,33,1084}, stableAnchor = true }, }, IVYSAUR = { - back = { image = "assets/battle/back-animated/gen3/ivysaur.png", width = 64, height = 64, columns = 16, frames = 29, durations = {33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,1050} }, + back = { image = "assets/battle/back-animated/gen3/ivysaur.png", width = 64, height = 64, columns = 16, frames = 29, durations = {33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,1050}, stableAnchor = true }, }, VENUSAUR = { - back = { image = "assets/battle/back-animated/gen3/venusaur.png", width = 69, height = 69, columns = 16, frames = 38, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, + back = { image = "assets/battle/back-animated/gen3/venusaur.png", width = 69, height = 69, columns = 16, frames = 38, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033}, stableAnchor = true }, }, CHARMANDER = { - back = { image = "assets/battle/back-animated/gen3/charmander.png", width = 64, height = 64, columns = 16, frames = 31, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, + back = { image = "assets/battle/back-animated/gen3/charmander.png", width = 64, height = 64, columns = 16, frames = 31, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033}, stableAnchor = true }, }, CHARMELEON = { - back = { image = "assets/battle/back-animated/gen3/charmeleon.png", width = 66, height = 66, columns = 16, frames = 30, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, + back = { image = "assets/battle/back-animated/gen3/charmeleon.png", width = 66, height = 66, columns = 16, frames = 30, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033}, stableAnchor = true }, }, CHARIZARD = { - back = { image = "assets/battle/back-animated/gen3/charizard.png", width = 75, height = 75, columns = 16, frames = 40, durations = {67,67,67,33,67,67,67,67,100,100,100,117,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,134,100,100,100,67,67,67,67,33,67,67,1067} }, + back = { image = "assets/battle/back-animated/gen3/charizard.png", width = 75, height = 75, columns = 16, frames = 40, durations = {67,67,67,33,67,67,67,67,100,100,100,117,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,134,100,100,100,67,67,67,67,33,67,67,1067}, stableAnchor = true }, }, SQUIRTLE = { - back = { image = "assets/battle/back-animated/gen3/squirtle.png", width = 64, height = 64, columns = 16, frames = 31, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, + back = { image = "assets/battle/back-animated/gen3/squirtle.png", width = 64, height = 64, columns = 16, frames = 31, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033}, stableAnchor = true }, }, WARTORTLE = { - back = { image = "assets/battle/back-animated/gen3/wartortle.png", width = 64, height = 64, columns = 16, frames = 31, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, + back = { image = "assets/battle/back-animated/gen3/wartortle.png", width = 64, height = 64, columns = 16, frames = 31, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033}, stableAnchor = true }, }, BLASTOISE = { - back = { image = "assets/battle/back-animated/gen3/blastoise.png", width = 75, height = 75, columns = 16, frames = 40, durations = {67,67,67,33,67,67,67,67,100,100,100,117,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,134,100,100,100,67,67,67,67,33,67,67,1067} }, + back = { image = "assets/battle/back-animated/gen3/blastoise.png", width = 75, height = 75, columns = 16, frames = 40, durations = {67,67,67,33,67,67,67,67,100,100,100,117,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,134,100,100,100,67,67,67,67,33,67,67,1067}, stableAnchor = true }, }, CATERPIE = { - back = { image = "assets/battle/back-animated/gen3/caterpie.png", width = 64, height = 64, columns = 16, frames = 29, durations = {33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,1050} }, + back = { image = "assets/battle/back-animated/gen3/caterpie.png", width = 64, height = 64, columns = 16, frames = 29, durations = {33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,1050}, stableAnchor = true }, }, METAPOD = { - back = { image = "assets/battle/back-animated/gen3/metapod.png", width = 64, height = 64, columns = 16, frames = 17, durations = {50,50,33,50,50,50,67,151,33,151,84,50,50,33,50,33,1084} }, + back = { image = "assets/battle/back-animated/gen3/metapod.png", width = 64, height = 64, columns = 16, frames = 17, durations = {50,50,33,50,50,50,67,151,33,151,84,50,50,33,50,33,1084}, stableAnchor = true }, }, BUTTERFREE = { - back = { image = "assets/battle/back-animated/gen3/butterfree.png", width = 89, height = 89, columns = 16, frames = 73, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,84,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,84,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, + back = { image = "assets/battle/back-animated/gen3/butterfree.png", width = 89, height = 89, columns = 16, frames = 73, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,84,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,84,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033}, stableAnchor = true }, }, WEEDLE = { - back = { image = "assets/battle/back-animated/gen3/weedle.png", width = 64, height = 64, columns = 16, frames = 29, durations = {33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,1050} }, + back = { image = "assets/battle/back-animated/gen3/weedle.png", width = 64, height = 64, columns = 16, frames = 29, durations = {33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,1050}, stableAnchor = true }, }, KAKUNA = { - back = { image = "assets/battle/back-animated/gen3/kakuna.png", width = 64, height = 64, columns = 16, frames = 17, durations = {50,50,33,50,50,50,67,151,33,151,84,50,50,33,50,33,1084} }, + back = { image = "assets/battle/back-animated/gen3/kakuna.png", width = 64, height = 64, columns = 16, frames = 17, durations = {50,50,33,50,50,50,67,151,33,151,84,50,50,33,50,33,1084}, stableAnchor = true }, }, BEEDRILL = { - back = { image = "assets/battle/back-animated/gen3/beedrill.png", width = 80, height = 80, columns = 16, frames = 39, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1067} }, + back = { image = "assets/battle/back-animated/gen3/beedrill.png", width = 80, height = 80, columns = 16, frames = 39, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1067}, stableAnchor = true }, }, PIDGEY = { - back = { image = "assets/battle/back-animated/gen3/pidgey.png", width = 72, height = 72, columns = 16, frames = 18, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050} }, + back = { image = "assets/battle/back-animated/gen3/pidgey.png", width = 72, height = 72, columns = 16, frames = 18, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050}, stableAnchor = true }, }, PIDGEOTTO = { - back = { image = "assets/battle/back-animated/gen3/pidgeotto.png", width = 74, height = 74, columns = 16, frames = 30, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, + back = { image = "assets/battle/back-animated/gen3/pidgeotto.png", width = 74, height = 74, columns = 16, frames = 30, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033}, stableAnchor = true }, }, PIDGEOT = { - back = { image = "assets/battle/back-animated/gen3/pidgeot.png", width = 76, height = 76, columns = 16, frames = 18, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050} }, + back = { image = "assets/battle/back-animated/gen3/pidgeot.png", width = 76, height = 76, columns = 16, frames = 18, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050}, stableAnchor = true }, }, RATTATA = { - back = { image = "assets/battle/back-animated/gen3/rattata.png", width = 79, height = 79, columns = 16, frames = 43, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050} }, + back = { image = "assets/battle/back-animated/gen3/rattata.png", width = 79, height = 79, columns = 16, frames = 43, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050}, stableAnchor = true }, }, RATICATE = { - back = { image = "assets/battle/back-animated/gen3/raticate.png", width = 86, height = 86, columns = 16, frames = 43, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050} }, + back = { image = "assets/battle/back-animated/gen3/raticate.png", width = 86, height = 86, columns = 16, frames = 43, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050}, stableAnchor = true }, }, SPEAROW = { - back = { image = "assets/battle/back-animated/gen3/spearow.png", width = 67, height = 67, columns = 16, frames = 18, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050} }, + back = { image = "assets/battle/back-animated/gen3/spearow.png", width = 67, height = 67, columns = 16, frames = 18, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050}, stableAnchor = true }, }, FEAROW = { - back = { image = "assets/battle/back-animated/gen3/fearow.png", width = 82, height = 82, columns = 16, frames = 30, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, + back = { image = "assets/battle/back-animated/gen3/fearow.png", width = 82, height = 82, columns = 16, frames = 30, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033}, stableAnchor = true }, }, EKANS = { - back = { image = "assets/battle/back-animated/gen3/ekans.png", width = 71, height = 71, columns = 16, frames = 18, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050} }, + back = { image = "assets/battle/back-animated/gen3/ekans.png", width = 71, height = 71, columns = 16, frames = 18, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050}, stableAnchor = true }, }, ARBOK = { - back = { image = "assets/battle/back-animated/gen3/arbok.png", width = 66, height = 66, columns = 16, frames = 37, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050} }, + back = { image = "assets/battle/back-animated/gen3/arbok.png", width = 66, height = 66, columns = 16, frames = 37, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050}, stableAnchor = true }, }, PIKACHU = { - back = { image = "assets/battle/back-animated/gen3/pikachu.png", width = 64, height = 64, columns = 16, frames = 30, durations = {50,50,33,33,50,50,50,50,50,50,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, + back = { image = "assets/battle/back-animated/gen3/pikachu.png", width = 64, height = 64, columns = 16, frames = 30, durations = {50,50,33,33,50,50,50,50,50,50,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033}, stableAnchor = true }, }, RAICHU = { - back = { image = "assets/battle/back-animated/gen3/raichu.png", width = 64, height = 64, columns = 16, frames = 30, durations = {50,50,33,33,50,50,50,50,50,50,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, + back = { image = "assets/battle/back-animated/gen3/raichu.png", width = 64, height = 64, columns = 16, frames = 30, durations = {50,50,33,33,50,50,50,50,50,50,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033}, stableAnchor = true }, }, SANDSHREW = { - back = { image = "assets/battle/back-animated/gen3/sandshrew.png", width = 64, height = 64, columns = 16, frames = 31, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, + back = { image = "assets/battle/back-animated/gen3/sandshrew.png", width = 64, height = 64, columns = 16, frames = 31, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033}, stableAnchor = true }, }, SANDSLASH = { - back = { image = "assets/battle/back-animated/gen3/sandslash.png", width = 81, height = 81, columns = 16, frames = 44, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, + back = { image = "assets/battle/back-animated/gen3/sandslash.png", width = 81, height = 81, columns = 16, frames = 44, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033}, stableAnchor = true }, }, NIDORAN_F = { - back = { image = "assets/battle/back-animated/gen3/nidoran-f.png", width = 64, height = 64, columns = 16, frames = 31, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, + back = { image = "assets/battle/back-animated/gen3/nidoran-f.png", width = 64, height = 64, columns = 16, frames = 31, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033}, stableAnchor = true }, }, NIDORINA = { - back = { image = "assets/battle/back-animated/gen3/nidorina.png", width = 74, height = 74, columns = 16, frames = 30, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, + back = { image = "assets/battle/back-animated/gen3/nidorina.png", width = 74, height = 74, columns = 16, frames = 30, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033}, stableAnchor = true }, }, NIDOQUEEN = { - back = { image = "assets/battle/back-animated/gen3/nidoqueen.png", width = 64, height = 64, columns = 16, frames = 37, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050} }, + back = { image = "assets/battle/back-animated/gen3/nidoqueen.png", width = 64, height = 64, columns = 16, frames = 37, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050}, stableAnchor = true }, }, NIDORAN_M = { - back = { image = "assets/battle/back-animated/gen3/nidoran-m.png", width = 64, height = 64, columns = 16, frames = 31, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, + back = { image = "assets/battle/back-animated/gen3/nidoran-m.png", width = 64, height = 64, columns = 16, frames = 31, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033}, stableAnchor = true }, }, NIDORINO = { - back = { image = "assets/battle/back-animated/gen3/nidorino.png", width = 78, height = 78, columns = 16, frames = 30, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, + back = { image = "assets/battle/back-animated/gen3/nidorino.png", width = 78, height = 78, columns = 16, frames = 30, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033}, stableAnchor = true }, }, NIDOKING = { - back = { image = "assets/battle/back-animated/gen3/nidoking.png", width = 67, height = 67, columns = 16, frames = 37, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050} }, + back = { image = "assets/battle/back-animated/gen3/nidoking.png", width = 67, height = 67, columns = 16, frames = 37, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050}, stableAnchor = true }, }, CLEFAIRY = { - back = { image = "assets/battle/back-animated/gen3/clefairy.png", width = 64, height = 64, columns = 16, frames = 17, durations = {50,50,33,50,50,50,67,151,33,151,84,50,50,33,50,33,1084} }, + back = { image = "assets/battle/back-animated/gen3/clefairy.png", width = 64, height = 64, columns = 16, frames = 17, durations = {50,50,33,50,50,50,67,151,33,151,84,50,50,33,50,33,1084}, stableAnchor = true }, }, CLEFABLE = { - back = { image = "assets/battle/back-animated/gen3/clefable.png", width = 64, height = 64, columns = 16, frames = 17, durations = {50,50,33,50,50,50,67,151,33,151,84,50,50,33,50,33,1084} }, + back = { image = "assets/battle/back-animated/gen3/clefable.png", width = 64, height = 64, columns = 16, frames = 17, durations = {50,50,33,50,50,50,67,151,33,151,84,50,50,33,50,33,1084}, stableAnchor = true }, }, VULPIX = { - back = { image = "assets/battle/back-animated/gen3/vulpix.png", width = 74, height = 74, columns = 16, frames = 44, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, + back = { image = "assets/battle/back-animated/gen3/vulpix.png", width = 74, height = 74, columns = 16, frames = 44, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033}, stableAnchor = true }, }, NINETALES = { - back = { image = "assets/battle/back-animated/gen3/ninetales.png", width = 66, height = 66, columns = 16, frames = 39, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1067} }, + back = { image = "assets/battle/back-animated/gen3/ninetales.png", width = 66, height = 66, columns = 16, frames = 39, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1067}, stableAnchor = true }, }, JIGGLYPUFF = { - back = { image = "assets/battle/back-animated/gen3/jigglypuff.png", width = 64, height = 64, columns = 16, frames = 17, durations = {50,50,33,50,50,50,67,151,33,151,84,50,50,33,50,33,1084} }, + back = { image = "assets/battle/back-animated/gen3/jigglypuff.png", width = 64, height = 64, columns = 16, frames = 17, durations = {50,50,33,50,50,50,67,151,33,151,84,50,50,33,50,33,1084}, stableAnchor = true }, }, WIGGLYTUFF = { - back = { image = "assets/battle/back-animated/gen3/wigglytuff.png", width = 64, height = 64, columns = 16, frames = 53, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,84,33,84,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,50,1050} }, + back = { image = "assets/battle/back-animated/gen3/wigglytuff.png", width = 64, height = 64, columns = 16, frames = 53, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,84,33,84,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,50,1050}, stableAnchor = true }, }, ZUBAT = { - back = { image = "assets/battle/back-animated/gen3/zubat.png", width = 84, height = 84, columns = 16, frames = 73, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,84,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,84,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, + back = { image = "assets/battle/back-animated/gen3/zubat.png", width = 84, height = 84, columns = 16, frames = 73, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,84,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,84,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033}, stableAnchor = true }, }, GOLBAT = { - back = { image = "assets/battle/back-animated/gen3/golbat.png", width = 64, height = 64, columns = 16, frames = 37, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050} }, + back = { image = "assets/battle/back-animated/gen3/golbat.png", width = 64, height = 64, columns = 16, frames = 37, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050}, stableAnchor = true }, }, ODDISH = { - back = { image = "assets/battle/back-animated/gen3/oddish.png", width = 64, height = 64, columns = 16, frames = 29, durations = {33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,1050} }, + back = { image = "assets/battle/back-animated/gen3/oddish.png", width = 64, height = 64, columns = 16, frames = 29, durations = {33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,1050}, stableAnchor = true }, }, GLOOM = { - back = { image = "assets/battle/back-animated/gen3/gloom.png", width = 64, height = 64, columns = 16, frames = 29, durations = {33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,1050} }, + back = { image = "assets/battle/back-animated/gen3/gloom.png", width = 64, height = 64, columns = 16, frames = 29, durations = {33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,1050}, stableAnchor = true }, }, VILEPLUME = { - back = { image = "assets/battle/back-animated/gen3/vileplume.png", width = 69, height = 69, columns = 16, frames = 41, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050} }, + back = { image = "assets/battle/back-animated/gen3/vileplume.png", width = 69, height = 69, columns = 16, frames = 41, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050}, stableAnchor = true }, }, PARAS = { - back = { image = "assets/battle/back-animated/gen3/paras.png", width = 64, height = 64, columns = 16, frames = 29, durations = {33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,1050} }, + back = { image = "assets/battle/back-animated/gen3/paras.png", width = 64, height = 64, columns = 16, frames = 29, durations = {33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,1050}, stableAnchor = true }, }, PARASECT = { - back = { image = "assets/battle/back-animated/gen3/parasect.png", width = 64, height = 64, columns = 16, frames = 38, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, + back = { image = "assets/battle/back-animated/gen3/parasect.png", width = 64, height = 64, columns = 16, frames = 38, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033}, stableAnchor = true }, }, VENONAT = { - back = { image = "assets/battle/back-animated/gen3/venonat.png", width = 83, height = 83, columns = 16, frames = 43, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050} }, + back = { image = "assets/battle/back-animated/gen3/venonat.png", width = 83, height = 83, columns = 16, frames = 43, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050}, stableAnchor = true }, }, VENOMOTH = { - back = { image = "assets/battle/back-animated/gen3/venomoth.png", width = 86, height = 86, columns = 16, frames = 73, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,84,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,84,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, + back = { image = "assets/battle/back-animated/gen3/venomoth.png", width = 86, height = 86, columns = 16, frames = 73, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,84,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,84,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033}, stableAnchor = true }, }, DIGLETT = { - back = { image = "assets/battle/back-animated/gen3/diglett.png", width = 64, height = 64, columns = 16, frames = 37, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050} }, + back = { image = "assets/battle/back-animated/gen3/diglett.png", width = 64, height = 64, columns = 16, frames = 37, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050}, stableAnchor = true }, }, DUGTRIO = { - back = { image = "assets/battle/back-animated/gen3/dugtrio.png", width = 64, height = 64, columns = 16, frames = 37, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050} }, + back = { image = "assets/battle/back-animated/gen3/dugtrio.png", width = 64, height = 64, columns = 16, frames = 37, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050}, stableAnchor = true }, }, MEOWTH = { - back = { image = "assets/battle/back-animated/gen3/meowth.png", width = 70, height = 70, columns = 16, frames = 44, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, + back = { image = "assets/battle/back-animated/gen3/meowth.png", width = 70, height = 70, columns = 16, frames = 44, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033}, stableAnchor = true }, }, PERSIAN = { - back = { image = "assets/battle/back-animated/gen3/persian.png", width = 85, height = 85, columns = 16, frames = 18, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050} }, + back = { image = "assets/battle/back-animated/gen3/persian.png", width = 85, height = 85, columns = 16, frames = 18, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050}, stableAnchor = true }, }, PSYDUCK = { - back = { image = "assets/battle/back-animated/gen3/psyduck.png", width = 64, height = 64, columns = 16, frames = 29, durations = {33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,1050} }, + back = { image = "assets/battle/back-animated/gen3/psyduck.png", width = 64, height = 64, columns = 16, frames = 29, durations = {33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,1050}, stableAnchor = true }, }, GOLDUCK = { - back = { image = "assets/battle/back-animated/gen3/golduck.png", width = 66, height = 66, columns = 16, frames = 41, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050} }, + back = { image = "assets/battle/back-animated/gen3/golduck.png", width = 66, height = 66, columns = 16, frames = 41, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050}, stableAnchor = true }, }, MANKEY = { - back = { image = "assets/battle/back-animated/gen3/mankey.png", width = 72, height = 72, columns = 16, frames = 44, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, + back = { image = "assets/battle/back-animated/gen3/mankey.png", width = 72, height = 72, columns = 16, frames = 44, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033}, stableAnchor = true }, }, PRIMEAPE = { - back = { image = "assets/battle/back-animated/gen3/primeape.png", width = 75, height = 75, columns = 16, frames = 44, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, + back = { image = "assets/battle/back-animated/gen3/primeape.png", width = 75, height = 75, columns = 16, frames = 44, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033}, stableAnchor = true }, }, GROWLITHE = { - back = { image = "assets/battle/back-animated/gen3/growlithe.png", width = 65, height = 65, columns = 16, frames = 30, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, + back = { image = "assets/battle/back-animated/gen3/growlithe.png", width = 65, height = 65, columns = 16, frames = 30, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033}, stableAnchor = true }, }, ARCANINE = { - back = { image = "assets/battle/back-animated/gen3/arcanine.png", width = 81, height = 81, columns = 16, frames = 30, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, + back = { image = "assets/battle/back-animated/gen3/arcanine.png", width = 81, height = 81, columns = 16, frames = 30, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033}, stableAnchor = true }, }, POLIWAG = { - back = { image = "assets/battle/back-animated/gen3/poliwag.png", width = 64, height = 64, columns = 16, frames = 31, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, + back = { image = "assets/battle/back-animated/gen3/poliwag.png", width = 64, height = 64, columns = 16, frames = 31, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033}, stableAnchor = true }, }, POLIWHIRL = { - back = { image = "assets/battle/back-animated/gen3/poliwhirl.png", width = 64, height = 64, columns = 16, frames = 37, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050} }, + back = { image = "assets/battle/back-animated/gen3/poliwhirl.png", width = 64, height = 64, columns = 16, frames = 37, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050}, stableAnchor = true }, }, POLIWRATH = { - back = { image = "assets/battle/back-animated/gen3/poliwrath.png", width = 64, height = 64, columns = 16, frames = 36, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,67,385,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,50,50,1151} }, + back = { image = "assets/battle/back-animated/gen3/poliwrath.png", width = 64, height = 64, columns = 16, frames = 36, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,67,385,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,50,50,1151}, stableAnchor = true }, }, ABRA = { - back = { image = "assets/battle/back-animated/gen3/abra.png", width = 64, height = 64, columns = 16, frames = 41, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050} }, + back = { image = "assets/battle/back-animated/gen3/abra.png", width = 64, height = 64, columns = 16, frames = 41, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050}, stableAnchor = true }, }, KADABRA = { - back = { image = "assets/battle/back-animated/gen3/kadabra.png", width = 64, height = 64, columns = 16, frames = 41, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050} }, + back = { image = "assets/battle/back-animated/gen3/kadabra.png", width = 64, height = 64, columns = 16, frames = 41, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050}, stableAnchor = true }, }, ALAKAZAM = { - back = { image = "assets/battle/back-animated/gen3/alakazam.png", width = 73, height = 73, columns = 16, frames = 31, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050} }, + back = { image = "assets/battle/back-animated/gen3/alakazam.png", width = 73, height = 73, columns = 16, frames = 31, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050}, stableAnchor = true }, }, MACHOP = { - back = { image = "assets/battle/back-animated/gen3/machop.png", width = 65, height = 65, columns = 16, frames = 30, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, + back = { image = "assets/battle/back-animated/gen3/machop.png", width = 65, height = 65, columns = 16, frames = 30, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033}, stableAnchor = true }, }, MACHOKE = { - back = { image = "assets/battle/back-animated/gen3/machoke.png", width = 64, height = 64, columns = 16, frames = 37, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050} }, + back = { image = "assets/battle/back-animated/gen3/machoke.png", width = 64, height = 64, columns = 16, frames = 37, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050}, stableAnchor = true }, }, MACHAMP = { - back = { image = "assets/battle/back-animated/gen3/machamp.png", width = 66, height = 66, columns = 16, frames = 37, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050} }, + back = { image = "assets/battle/back-animated/gen3/machamp.png", width = 66, height = 66, columns = 16, frames = 37, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050}, stableAnchor = true }, }, BELLSPROUT = { - back = { image = "assets/battle/back-animated/gen3/bellsprout.png", width = 64, height = 64, columns = 16, frames = 29, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050} }, + back = { image = "assets/battle/back-animated/gen3/bellsprout.png", width = 64, height = 64, columns = 16, frames = 29, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050}, stableAnchor = true }, }, WEEPINBELL = { - back = { image = "assets/battle/back-animated/gen3/weepinbell.png", width = 64, height = 64, columns = 16, frames = 30, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050} }, + back = { image = "assets/battle/back-animated/gen3/weepinbell.png", width = 64, height = 64, columns = 16, frames = 30, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050}, stableAnchor = true }, }, VICTREEBEL = { - back = { image = "assets/battle/back-animated/gen3/victreebel.png", width = 71, height = 71, columns = 16, frames = 31, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050} }, + back = { image = "assets/battle/back-animated/gen3/victreebel.png", width = 71, height = 71, columns = 16, frames = 31, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050}, stableAnchor = true }, }, TENTACOOL = { - back = { image = "assets/battle/back-animated/gen3/tentacool.png", width = 64, height = 64, columns = 16, frames = 29, durations = {33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,1050} }, + back = { image = "assets/battle/back-animated/gen3/tentacool.png", width = 64, height = 64, columns = 16, frames = 29, durations = {33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,1050}, stableAnchor = true }, }, TENTACRUEL = { - back = { image = "assets/battle/back-animated/gen3/tentacruel.png", width = 71, height = 71, columns = 16, frames = 29, durations = {33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,1050} }, + back = { image = "assets/battle/back-animated/gen3/tentacruel.png", width = 71, height = 71, columns = 16, frames = 29, durations = {33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,1050}, stableAnchor = true }, }, GEODUDE = { - back = { image = "assets/battle/back-animated/gen3/geodude.png", width = 64, height = 64, columns = 16, frames = 36, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,67,385,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,50,50,1151} }, + back = { image = "assets/battle/back-animated/gen3/geodude.png", width = 64, height = 64, columns = 16, frames = 36, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,67,385,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,50,50,1151}, stableAnchor = true }, }, GRAVELER = { - back = { image = "assets/battle/back-animated/gen3/graveler.png", width = 64, height = 64, columns = 16, frames = 38, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, + back = { image = "assets/battle/back-animated/gen3/graveler.png", width = 64, height = 64, columns = 16, frames = 38, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033}, stableAnchor = true }, }, GOLEM = { - back = { image = "assets/battle/back-animated/gen3/golem.png", width = 67, height = 67, columns = 16, frames = 38, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, + back = { image = "assets/battle/back-animated/gen3/golem.png", width = 67, height = 67, columns = 16, frames = 38, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033}, stableAnchor = true }, }, PONYTA = { - back = { image = "assets/battle/back-animated/gen3/ponyta.png", width = 64, height = 64, columns = 16, frames = 40, durations = {67,67,67,33,67,67,67,67,100,100,100,117,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,134,100,100,100,67,67,67,67,33,67,67,1067} }, + back = { image = "assets/battle/back-animated/gen3/ponyta.png", width = 64, height = 64, columns = 16, frames = 40, durations = {67,67,67,33,67,67,67,67,100,100,100,117,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,134,100,100,100,67,67,67,67,33,67,67,1067}, stableAnchor = true }, }, RAPIDASH = { - back = { image = "assets/battle/back-animated/gen3/rapidash.png", width = 80, height = 80, columns = 16, frames = 30, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, + back = { image = "assets/battle/back-animated/gen3/rapidash.png", width = 80, height = 80, columns = 16, frames = 30, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033}, stableAnchor = true }, }, SLOWPOKE = { - back = { image = "assets/battle/back-animated/gen3/slowpoke.png", width = 68, height = 68, columns = 16, frames = 29, durations = {33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,1050} }, + back = { image = "assets/battle/back-animated/gen3/slowpoke.png", width = 68, height = 68, columns = 16, frames = 29, durations = {33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,1050}, stableAnchor = true }, }, SLOWBRO = { - back = { image = "assets/battle/back-animated/gen3/slowbro.png", width = 67, height = 67, columns = 16, frames = 17, durations = {50,50,33,50,50,50,67,151,33,151,84,50,50,33,50,33,1084} }, + back = { image = "assets/battle/back-animated/gen3/slowbro.png", width = 67, height = 67, columns = 16, frames = 17, durations = {50,50,33,50,50,50,67,151,33,151,84,50,50,33,50,33,1084}, stableAnchor = true }, }, MAGNEMITE = { - back = { image = "assets/battle/back-animated/gen3/magnemite.png", width = 64, height = 64, columns = 16, frames = 18, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050} }, + back = { image = "assets/battle/back-animated/gen3/magnemite.png", width = 64, height = 64, columns = 16, frames = 18, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050}, stableAnchor = true }, }, MAGNETON = { - back = { image = "assets/battle/back-animated/gen3/magneton.png", width = 72, height = 72, columns = 16, frames = 18, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050} }, + back = { image = "assets/battle/back-animated/gen3/magneton.png", width = 72, height = 72, columns = 16, frames = 18, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050}, stableAnchor = true }, }, FARFETCHD = { - back = { image = "assets/battle/back-animated/gen3/farfetchd.png", width = 64, height = 64, columns = 16, frames = 29, durations = {33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,1050} }, + back = { image = "assets/battle/back-animated/gen3/farfetchd.png", width = 64, height = 64, columns = 16, frames = 29, durations = {33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,1050}, stableAnchor = true }, }, DODUO = { - back = { image = "assets/battle/back-animated/gen3/doduo.png", width = 70, height = 70, columns = 16, frames = 18, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050} }, + back = { image = "assets/battle/back-animated/gen3/doduo.png", width = 70, height = 70, columns = 16, frames = 18, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050}, stableAnchor = true }, }, DODRIO = { - back = { image = "assets/battle/back-animated/gen3/dodrio.png", width = 81, height = 81, columns = 16, frames = 30, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, + back = { image = "assets/battle/back-animated/gen3/dodrio.png", width = 81, height = 81, columns = 16, frames = 30, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033}, stableAnchor = true }, }, SEEL = { - back = { image = "assets/battle/back-animated/gen3/seel.png", width = 65, height = 65, columns = 16, frames = 17, durations = {50,50,33,50,50,50,67,151,33,151,84,50,50,33,50,33,1084} }, + back = { image = "assets/battle/back-animated/gen3/seel.png", width = 65, height = 65, columns = 16, frames = 17, durations = {50,50,33,50,50,50,67,151,33,151,84,50,50,33,50,33,1084}, stableAnchor = true }, }, DEWGONG = { - back = { image = "assets/battle/back-animated/gen3/dewgong.png", width = 64, height = 64, columns = 16, frames = 29, durations = {33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,1050} }, + back = { image = "assets/battle/back-animated/gen3/dewgong.png", width = 64, height = 64, columns = 16, frames = 29, durations = {33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,1050}, stableAnchor = true }, }, GRIMER = { - back = { image = "assets/battle/back-animated/gen3/grimer.png", width = 64, height = 64, columns = 16, frames = 30, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050} }, + back = { image = "assets/battle/back-animated/gen3/grimer.png", width = 64, height = 64, columns = 16, frames = 30, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050}, stableAnchor = true }, }, MUK = { - back = { image = "assets/battle/back-animated/gen3/muk.png", width = 92, height = 92, columns = 16, frames = 31, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050} }, + back = { image = "assets/battle/back-animated/gen3/muk.png", width = 92, height = 92, columns = 16, frames = 31, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050}, stableAnchor = true }, }, SHELLDER = { - back = { image = "assets/battle/back-animated/gen3/shellder.png", width = 64, height = 64, columns = 16, frames = 17, durations = {50,50,33,50,50,50,67,151,33,151,84,50,50,33,50,33,1084} }, + back = { image = "assets/battle/back-animated/gen3/shellder.png", width = 64, height = 64, columns = 16, frames = 17, durations = {50,50,33,50,50,50,67,151,33,151,84,50,50,33,50,33,1084}, stableAnchor = true }, }, CLOYSTER = { - back = { image = "assets/battle/back-animated/gen3/cloyster.png", width = 81, height = 81, columns = 16, frames = 18, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050} }, + back = { image = "assets/battle/back-animated/gen3/cloyster.png", width = 81, height = 81, columns = 16, frames = 18, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050}, stableAnchor = true }, }, GASTLY = { - back = { image = "assets/battle/back-animated/gen3/gastly.png", width = 81, height = 81, columns = 16, frames = 39, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1067} }, + back = { image = "assets/battle/back-animated/gen3/gastly.png", width = 81, height = 81, columns = 16, frames = 39, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1067}, stableAnchor = true }, }, HAUNTER = { - back = { image = "assets/battle/back-animated/gen3/haunter.png", width = 68, height = 68, columns = 16, frames = 39, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1067} }, + back = { image = "assets/battle/back-animated/gen3/haunter.png", width = 68, height = 68, columns = 16, frames = 39, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1067}, stableAnchor = true }, }, GENGAR = { - back = { image = "assets/battle/back-animated/gen3/gengar.png", width = 64, height = 64, columns = 16, frames = 41, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050} }, + back = { image = "assets/battle/back-animated/gen3/gengar.png", width = 64, height = 64, columns = 16, frames = 41, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050}, stableAnchor = true }, }, ONIX = { - back = { image = "assets/battle/back-animated/gen3/onix.png", width = 70, height = 70, columns = 16, frames = 37, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050} }, + back = { image = "assets/battle/back-animated/gen3/onix.png", width = 70, height = 70, columns = 16, frames = 37, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050}, stableAnchor = true }, }, DROWZEE = { - back = { image = "assets/battle/back-animated/gen3/drowzee.png", width = 64, height = 64, columns = 16, frames = 17, durations = {50,50,33,50,50,50,67,151,33,151,84,50,50,33,50,33,1084} }, + back = { image = "assets/battle/back-animated/gen3/drowzee.png", width = 64, height = 64, columns = 16, frames = 17, durations = {50,50,33,50,50,50,67,151,33,151,84,50,50,33,50,33,1084}, stableAnchor = true }, }, HYPNO = { - back = { image = "assets/battle/back-animated/gen3/hypno.png", width = 64, height = 64, columns = 16, frames = 41, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050} }, + back = { image = "assets/battle/back-animated/gen3/hypno.png", width = 64, height = 64, columns = 16, frames = 41, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050}, stableAnchor = true }, }, KRABBY = { - back = { image = "assets/battle/back-animated/gen3/krabby.png", width = 80, height = 80, columns = 16, frames = 43, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050} }, + back = { image = "assets/battle/back-animated/gen3/krabby.png", width = 80, height = 80, columns = 16, frames = 43, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050}, stableAnchor = true }, }, KINGLER = { - back = { image = "assets/battle/back-animated/gen3/kingler.png", width = 66, height = 66, columns = 16, frames = 37, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050} }, + back = { image = "assets/battle/back-animated/gen3/kingler.png", width = 66, height = 66, columns = 16, frames = 37, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050}, stableAnchor = true }, }, VOLTORB = { - back = { image = "assets/battle/back-animated/gen3/voltorb.png", width = 64, height = 64, columns = 16, frames = 30, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, + back = { image = "assets/battle/back-animated/gen3/voltorb.png", width = 64, height = 64, columns = 16, frames = 30, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033}, stableAnchor = true }, }, ELECTRODE = { - back = { image = "assets/battle/back-animated/gen3/electrode.png", width = 64, height = 64, columns = 16, frames = 30, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, + back = { image = "assets/battle/back-animated/gen3/electrode.png", width = 64, height = 64, columns = 16, frames = 30, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033}, stableAnchor = true }, }, EXEGGCUTE = { - back = { image = "assets/battle/back-animated/gen3/exeggcute.png", width = 64, height = 64, columns = 16, frames = 29, durations = {33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,1050} }, + back = { image = "assets/battle/back-animated/gen3/exeggcute.png", width = 64, height = 64, columns = 16, frames = 29, durations = {33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,1050}, stableAnchor = true }, }, EXEGGUTOR = { - back = { image = "assets/battle/back-animated/gen3/exeggutor.png", width = 87, height = 87, columns = 16, frames = 44, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, + back = { image = "assets/battle/back-animated/gen3/exeggutor.png", width = 87, height = 87, columns = 16, frames = 44, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033}, stableAnchor = true }, }, CUBONE = { - back = { image = "assets/battle/back-animated/gen3/cubone.png", width = 66, height = 66, columns = 16, frames = 30, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, + back = { image = "assets/battle/back-animated/gen3/cubone.png", width = 66, height = 66, columns = 16, frames = 30, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033}, stableAnchor = true }, }, MAROWAK = { - back = { image = "assets/battle/back-animated/gen3/marowak.png", width = 68, height = 68, columns = 16, frames = 44, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, + back = { image = "assets/battle/back-animated/gen3/marowak.png", width = 68, height = 68, columns = 16, frames = 44, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033}, stableAnchor = true }, }, HITMONLEE = { - back = { image = "assets/battle/back-animated/gen3/hitmonlee.png", width = 64, height = 64, columns = 16, frames = 29, durations = {33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,1050} }, + back = { image = "assets/battle/back-animated/gen3/hitmonlee.png", width = 64, height = 64, columns = 16, frames = 29, durations = {33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,1050}, stableAnchor = true }, }, HITMONCHAN = { - back = { image = "assets/battle/back-animated/gen3/hitmonchan.png", width = 70, height = 70, columns = 16, frames = 18, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050} }, + back = { image = "assets/battle/back-animated/gen3/hitmonchan.png", width = 70, height = 70, columns = 16, frames = 18, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050}, stableAnchor = true }, }, LICKITUNG = { - back = { image = "assets/battle/back-animated/gen3/lickitung.png", width = 64, height = 64, columns = 16, frames = 29, durations = {33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,1050} }, + back = { image = "assets/battle/back-animated/gen3/lickitung.png", width = 64, height = 64, columns = 16, frames = 29, durations = {33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,1050}, stableAnchor = true }, }, KOFFING = { - back = { image = "assets/battle/back-animated/gen3/koffing.png", width = 64, height = 64, columns = 16, frames = 53, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,84,33,84,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,50,1050} }, + back = { image = "assets/battle/back-animated/gen3/koffing.png", width = 64, height = 64, columns = 16, frames = 53, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,84,33,84,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,50,1050}, stableAnchor = true }, }, WEEZING = { - back = { image = "assets/battle/back-animated/gen3/weezing.png", width = 71, height = 71, columns = 16, frames = 55, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,50,33,50,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,50,1050} }, + back = { image = "assets/battle/back-animated/gen3/weezing.png", width = 71, height = 71, columns = 16, frames = 55, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,50,33,50,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,50,1050}, stableAnchor = true }, }, RHYHORN = { - back = { image = "assets/battle/back-animated/gen3/rhyhorn.png", width = 64, height = 64, columns = 16, frames = 36, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,67,385,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,50,50,1151} }, + back = { image = "assets/battle/back-animated/gen3/rhyhorn.png", width = 64, height = 64, columns = 16, frames = 36, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,67,385,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,50,50,1151}, stableAnchor = true }, }, RHYDON = { - back = { image = "assets/battle/back-animated/gen3/rhydon.png", width = 71, height = 71, columns = 16, frames = 36, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,67,385,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,50,50,1151} }, + back = { image = "assets/battle/back-animated/gen3/rhydon.png", width = 71, height = 71, columns = 16, frames = 36, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,67,385,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,50,50,1151}, stableAnchor = true }, }, CHANSEY = { - back = { image = "assets/battle/back-animated/gen3/chansey.png", width = 66, height = 66, columns = 16, frames = 31, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, + back = { image = "assets/battle/back-animated/gen3/chansey.png", width = 66, height = 66, columns = 16, frames = 31, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033}, stableAnchor = true }, }, TANGELA = { - back = { image = "assets/battle/back-animated/gen3/tangela.png", width = 64, height = 64, columns = 16, frames = 29, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050} }, + back = { image = "assets/battle/back-animated/gen3/tangela.png", width = 64, height = 64, columns = 16, frames = 29, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050}, stableAnchor = true }, }, KANGASKHAN = { - back = { image = "assets/battle/back-animated/gen3/kangaskhan.png", width = 65, height = 65, columns = 16, frames = 31, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, + back = { image = "assets/battle/back-animated/gen3/kangaskhan.png", width = 65, height = 65, columns = 16, frames = 31, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033}, stableAnchor = true }, }, HORSEA = { - back = { image = "assets/battle/back-animated/gen3/horsea.png", width = 64, height = 64, columns = 16, frames = 17, durations = {50,50,33,50,50,50,67,151,33,151,84,50,50,33,50,33,1084} }, + back = { image = "assets/battle/back-animated/gen3/horsea.png", width = 64, height = 64, columns = 16, frames = 17, durations = {50,50,33,50,50,50,67,151,33,151,84,50,50,33,50,33,1084}, stableAnchor = true }, }, SEADRA = { - back = { image = "assets/battle/back-animated/gen3/seadra.png", width = 78, height = 78, columns = 16, frames = 73, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,84,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,84,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, + back = { image = "assets/battle/back-animated/gen3/seadra.png", width = 78, height = 78, columns = 16, frames = 73, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,84,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,84,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033}, stableAnchor = true }, }, GOLDEEN = { - back = { image = "assets/battle/back-animated/gen3/goldeen.png", width = 78, height = 78, columns = 16, frames = 73, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,84,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,84,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, + back = { image = "assets/battle/back-animated/gen3/goldeen.png", width = 78, height = 78, columns = 16, frames = 73, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,84,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,84,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033}, stableAnchor = true }, }, SEAKING = { - back = { image = "assets/battle/back-animated/gen3/seaking.png", width = 83, height = 83, columns = 16, frames = 73, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,84,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,84,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, + back = { image = "assets/battle/back-animated/gen3/seaking.png", width = 83, height = 83, columns = 16, frames = 73, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,84,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,84,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033}, stableAnchor = true }, }, STARYU = { - back = { image = "assets/battle/back-animated/gen3/staryu.png", width = 64, height = 64, columns = 16, frames = 17, durations = {50,50,33,50,50,50,67,151,33,151,84,50,50,33,50,33,1084} }, + back = { image = "assets/battle/back-animated/gen3/staryu.png", width = 64, height = 64, columns = 16, frames = 17, durations = {50,50,33,50,50,50,67,151,33,151,84,50,50,33,50,33,1084}, stableAnchor = true }, }, STARMIE = { - back = { image = "assets/battle/back-animated/gen3/starmie.png", width = 70, height = 70, columns = 16, frames = 40, durations = {67,67,67,33,67,67,67,67,100,100,100,117,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,134,100,100,100,67,67,67,67,33,67,67,1067} }, + back = { image = "assets/battle/back-animated/gen3/starmie.png", width = 70, height = 70, columns = 16, frames = 40, durations = {67,67,67,33,67,67,67,67,100,100,100,117,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,134,100,100,100,67,67,67,67,33,67,67,1067}, stableAnchor = true }, }, MR_MIME = { - back = { image = "assets/battle/back-animated/gen3/mr-mime.png", width = 73, height = 73, columns = 16, frames = 41, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050} }, + back = { image = "assets/battle/back-animated/gen3/mr-mime.png", width = 73, height = 73, columns = 16, frames = 41, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050}, stableAnchor = true }, }, SCYTHER = { - back = { image = "assets/battle/back-animated/gen3/scyther.png", width = 76, height = 76, columns = 16, frames = 18, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050} }, + back = { image = "assets/battle/back-animated/gen3/scyther.png", width = 76, height = 76, columns = 16, frames = 18, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050}, stableAnchor = true }, }, JYNX = { - back = { image = "assets/battle/back-animated/gen3/jynx.png", width = 64, height = 64, columns = 16, frames = 17, durations = {50,50,33,50,50,50,67,151,33,151,84,50,50,33,50,33,1084} }, + back = { image = "assets/battle/back-animated/gen3/jynx.png", width = 64, height = 64, columns = 16, frames = 17, durations = {50,50,33,50,50,50,67,151,33,151,84,50,50,33,50,33,1084}, stableAnchor = true }, }, ELECTABUZZ = { - back = { image = "assets/battle/back-animated/gen3/electabuzz.png", width = 64, height = 64, columns = 16, frames = 30, durations = {50,50,33,33,50,50,50,50,50,50,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, + back = { image = "assets/battle/back-animated/gen3/electabuzz.png", width = 64, height = 64, columns = 16, frames = 30, durations = {50,50,33,33,50,50,50,50,50,50,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033}, stableAnchor = true }, }, MAGMAR = { - back = { image = "assets/battle/back-animated/gen3/magmar.png", width = 64, height = 64, columns = 16, frames = 40, durations = {67,67,67,33,67,67,67,67,100,100,100,117,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,134,100,100,100,67,67,67,67,33,67,67,1067} }, + back = { image = "assets/battle/back-animated/gen3/magmar.png", width = 64, height = 64, columns = 16, frames = 40, durations = {67,67,67,33,67,67,67,67,100,100,100,117,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,134,100,100,100,67,67,67,67,33,67,67,1067}, stableAnchor = true }, }, PINSIR = { - back = { image = "assets/battle/back-animated/gen3/pinsir.png", width = 65, height = 65, columns = 16, frames = 36, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,67,385,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,50,50,1151} }, + back = { image = "assets/battle/back-animated/gen3/pinsir.png", width = 65, height = 65, columns = 16, frames = 36, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,67,385,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,50,50,1151}, stableAnchor = true }, }, TAUROS = { - back = { image = "assets/battle/back-animated/gen3/tauros.png", width = 64, height = 64, columns = 16, frames = 36, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,67,385,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,50,50,1151} }, + back = { image = "assets/battle/back-animated/gen3/tauros.png", width = 64, height = 64, columns = 16, frames = 36, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,67,385,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,50,50,1151}, stableAnchor = true }, }, MAGIKARP = { - back = { image = "assets/battle/back-animated/gen3/magikarp.png", width = 75, height = 75, columns = 16, frames = 44, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, + back = { image = "assets/battle/back-animated/gen3/magikarp.png", width = 75, height = 75, columns = 16, frames = 44, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033}, stableAnchor = true }, }, GYARADOS = { - back = { image = "assets/battle/back-animated/gen3/gyarados.png", width = 70, height = 70, columns = 16, frames = 37, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050} }, + back = { image = "assets/battle/back-animated/gen3/gyarados.png", width = 70, height = 70, columns = 16, frames = 37, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050}, stableAnchor = true }, }, LAPRAS = { - back = { image = "assets/battle/back-animated/gen3/lapras.png", width = 65, height = 65, columns = 16, frames = 40, durations = {67,67,67,33,67,67,67,67,100,100,100,117,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,134,100,100,100,67,67,67,67,33,67,67,1067} }, + back = { image = "assets/battle/back-animated/gen3/lapras.png", width = 65, height = 65, columns = 16, frames = 40, durations = {67,67,67,33,67,67,67,67,100,100,100,117,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,134,100,100,100,67,67,67,67,33,67,67,1067}, stableAnchor = true }, }, DITTO = { - back = { image = "assets/battle/back-animated/gen3/ditto.png", width = 64, height = 64, columns = 16, frames = 47, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,50,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050} }, + back = { image = "assets/battle/back-animated/gen3/ditto.png", width = 64, height = 64, columns = 16, frames = 47, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,50,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050}, stableAnchor = true }, }, EEVEE = { - back = { image = "assets/battle/back-animated/gen3/eevee.png", width = 64, height = 64, columns = 16, frames = 31, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, + back = { image = "assets/battle/back-animated/gen3/eevee.png", width = 64, height = 64, columns = 16, frames = 31, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033}, stableAnchor = true }, }, VAPOREON = { - back = { image = "assets/battle/back-animated/gen3/vaporeon.png", width = 64, height = 64, columns = 16, frames = 40, durations = {67,67,67,33,67,67,67,67,100,100,100,117,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,134,100,100,100,67,67,67,67,33,67,67,1067} }, + back = { image = "assets/battle/back-animated/gen3/vaporeon.png", width = 64, height = 64, columns = 16, frames = 40, durations = {67,67,67,33,67,67,67,67,100,100,100,117,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,134,100,100,100,67,67,67,67,33,67,67,1067}, stableAnchor = true }, }, JOLTEON = { - back = { image = "assets/battle/back-animated/gen3/jolteon.png", width = 64, height = 64, columns = 16, frames = 30, durations = {50,50,33,33,50,50,50,50,50,50,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, + back = { image = "assets/battle/back-animated/gen3/jolteon.png", width = 64, height = 64, columns = 16, frames = 30, durations = {50,50,33,33,50,50,50,50,50,50,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033}, stableAnchor = true }, }, FLAREON = { - back = { image = "assets/battle/back-animated/gen3/flareon.png", width = 64, height = 64, columns = 16, frames = 40, durations = {67,67,67,33,67,67,67,67,100,100,100,117,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,134,100,100,100,67,67,67,67,33,67,67,1067} }, + back = { image = "assets/battle/back-animated/gen3/flareon.png", width = 64, height = 64, columns = 16, frames = 40, durations = {67,67,67,33,67,67,67,67,100,100,100,117,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,134,100,100,100,67,67,67,67,33,67,67,1067}, stableAnchor = true }, }, PORYGON = { - back = { image = "assets/battle/back-animated/gen3/porygon.png", width = 64, height = 64, columns = 16, frames = 39, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1067} }, + back = { image = "assets/battle/back-animated/gen3/porygon.png", width = 64, height = 64, columns = 16, frames = 39, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1067}, stableAnchor = true }, }, OMANYTE = { - back = { image = "assets/battle/back-animated/gen3/omanyte.png", width = 64, height = 64, columns = 16, frames = 17, durations = {50,50,33,50,50,50,67,151,33,151,84,50,50,33,50,33,1084} }, + back = { image = "assets/battle/back-animated/gen3/omanyte.png", width = 64, height = 64, columns = 16, frames = 17, durations = {50,50,33,50,50,50,67,151,33,151,84,50,50,33,50,33,1084}, stableAnchor = true }, }, OMASTAR = { - back = { image = "assets/battle/back-animated/gen3/omastar.png", width = 66, height = 66, columns = 16, frames = 17, durations = {50,50,33,50,50,50,67,151,33,151,84,50,50,33,50,33,1084} }, + back = { image = "assets/battle/back-animated/gen3/omastar.png", width = 66, height = 66, columns = 16, frames = 17, durations = {50,50,33,50,50,50,67,151,33,151,84,50,50,33,50,33,1084}, stableAnchor = true }, }, KABUTO = { - back = { image = "assets/battle/back-animated/gen3/kabuto.png", width = 64, height = 64, columns = 16, frames = 17, durations = {50,50,33,50,50,50,67,151,33,151,84,50,50,33,50,33,1084} }, + back = { image = "assets/battle/back-animated/gen3/kabuto.png", width = 64, height = 64, columns = 16, frames = 17, durations = {50,50,33,50,50,50,67,151,33,151,84,50,50,33,50,33,1084}, stableAnchor = true }, }, KABUTOPS = { - back = { image = "assets/battle/back-animated/gen3/kabutops.png", width = 69, height = 69, columns = 16, frames = 30, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, + back = { image = "assets/battle/back-animated/gen3/kabutops.png", width = 69, height = 69, columns = 16, frames = 30, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033}, stableAnchor = true }, }, AERODACTYL = { - back = { image = "assets/battle/back-animated/gen3/aerodactyl.png", width = 79, height = 79, columns = 16, frames = 30, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, + back = { image = "assets/battle/back-animated/gen3/aerodactyl.png", width = 79, height = 79, columns = 16, frames = 30, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033}, stableAnchor = true }, }, SNORLAX = { - back = { image = "assets/battle/back-animated/gen3/snorlax.png", width = 67, height = 67, columns = 16, frames = 17, durations = {50,50,33,50,50,50,67,151,33,151,84,50,50,33,50,33,1084} }, + back = { image = "assets/battle/back-animated/gen3/snorlax.png", width = 67, height = 67, columns = 16, frames = 17, durations = {50,50,33,50,50,50,67,151,33,151,84,50,50,33,50,33,1084}, stableAnchor = true }, }, ARTICUNO = { - back = { image = "assets/battle/back-animated/gen3/articuno.png", width = 64, height = 64, columns = 16, frames = 40, durations = {67,67,67,33,67,67,67,67,100,100,100,117,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,134,100,100,100,67,67,67,67,33,67,67,1067} }, + back = { image = "assets/battle/back-animated/gen3/articuno.png", width = 64, height = 64, columns = 16, frames = 40, durations = {67,67,67,33,67,67,67,67,100,100,100,117,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,134,100,100,100,67,67,67,67,33,67,67,1067}, stableAnchor = true }, }, ZAPDOS = { - back = { image = "assets/battle/back-animated/gen3/zapdos.png", width = 64, height = 64, columns = 16, frames = 30, durations = {50,50,33,33,50,50,50,50,50,50,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, + back = { image = "assets/battle/back-animated/gen3/zapdos.png", width = 64, height = 64, columns = 16, frames = 30, durations = {50,50,33,33,50,50,50,50,50,50,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033}, stableAnchor = true }, }, MOLTRES = { - back = { image = "assets/battle/back-animated/gen3/moltres.png", width = 67, height = 67, columns = 16, frames = 40, durations = {67,67,67,33,67,67,67,67,100,100,100,117,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,134,100,100,100,67,67,67,67,33,67,67,1067} }, + back = { image = "assets/battle/back-animated/gen3/moltres.png", width = 67, height = 67, columns = 16, frames = 40, durations = {67,67,67,33,67,67,67,67,100,100,100,117,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,134,100,100,100,67,67,67,67,33,67,67,1067}, stableAnchor = true }, }, DRATINI = { - back = { image = "assets/battle/back-animated/gen3/dratini.png", width = 64, height = 64, columns = 16, frames = 29, durations = {33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,1050} }, + back = { image = "assets/battle/back-animated/gen3/dratini.png", width = 64, height = 64, columns = 16, frames = 29, durations = {33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,33,33,33,33,84,33,33,33,33,1050}, stableAnchor = true }, }, DRAGONAIR = { - back = { image = "assets/battle/back-animated/gen3/dragonair.png", width = 76, height = 76, columns = 16, frames = 18, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050} }, + back = { image = "assets/battle/back-animated/gen3/dragonair.png", width = 76, height = 76, columns = 16, frames = 18, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050}, stableAnchor = true }, }, DRAGONITE = { - back = { image = "assets/battle/back-animated/gen3/dragonite.png", width = 64, height = 64, columns = 16, frames = 37, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050} }, + back = { image = "assets/battle/back-animated/gen3/dragonite.png", width = 64, height = 64, columns = 16, frames = 37, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050}, stableAnchor = true }, }, MEWTWO = { - back = { image = "assets/battle/back-animated/gen3/mewtwo.png", width = 84, height = 84, columns = 16, frames = 31, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050} }, + back = { image = "assets/battle/back-animated/gen3/mewtwo.png", width = 84, height = 84, columns = 16, frames = 31, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1050}, stableAnchor = true }, }, MEW = { - back = { image = "assets/battle/back-animated/gen3/mew.png", width = 64, height = 64, columns = 16, frames = 31, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033} }, + back = { image = "assets/battle/back-animated/gen3/mew.png", width = 64, height = 64, columns = 16, frames = 31, durations = {33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,33,1033}, stableAnchor = true }, }, } diff --git a/lib/AnimatedBattleArt.lua b/lib/AnimatedBattleArt.lua index 1074171..7fddd02 100644 --- a/lib/AnimatedBattleArt.lua +++ b/lib/AnimatedBattleArt.lua @@ -107,7 +107,12 @@ local function loadFrames(def, mode) if not image then return end frames[#frames + 1] = image end - if #frames == count then result = frames end + if #frames == count then + if def.stableAnchor then + BattleArt.shareFrameAnchor(frames, #frames) + end + result = frames + end end) if not ok or not result then return nil end remember(def, mode, result) diff --git a/lib/BattleArt.lua b/lib/BattleArt.lua index e8152bd..a020343 100644 --- a/lib/BattleArt.lua +++ b/lib/BattleArt.lua @@ -378,6 +378,29 @@ end function BattleArt.isExternal(img) return external[img] and true or false end function BattleArt.metrics(img) return metrics[img] end +-- Animated transforms are authored inside a fixed logical canvas. Gen 3 back +-- APNGs in particular translate the same opaque drawing across that canvas; +-- recomputing the placement anchor from every frame's opaque bounds cancels +-- the motion. Copy only the neutral reference frame's placement coordinates +-- while retaining each frame's own bounds and pixels. +function BattleArt.shareFrameAnchor(images, referenceIndex) + local reference = images and images[referenceIndex or #images] + local anchor = reference and metrics[reference] + if not anchor then return false end + for _, image in ipairs(images) do + local metric = metrics[image] + if not metric or metric.w ~= anchor.w or metric.h ~= anchor.h then + return false + end + end + for _, image in ipairs(images) do + local metric = metrics[image] + metric.center = anchor.center + metric.y1 = anchor.y1 + metric.padBottom = anchor.padBottom + end + return true +end function BattleArt.isStaticFront(img) local m = img and metrics[img] return m and m.staticFront == true or false diff --git a/tools/import_animated_sprites.py b/tools/import_animated_sprites.py index e6a2eb0..719a999 100644 --- a/tools/import_animated_sprites.py +++ b/tools/import_animated_sprites.py @@ -157,12 +157,13 @@ def convert(raw: bytes, destination: Path, columns: int = 16, } -def lua_definition(path: str, info: dict) -> str: +def lua_definition(path: str, info: dict, *, stable_anchor: bool = False) -> str: durations = ",".join(map(str, info["durations"])) + anchor = ", stableAnchor = true" if stable_anchor else "" return ( f'{{ image = "{path}", width = {info["width"]}, ' f'height = {info["height"]}, columns = {info["columns"]}, ' - f'frames = {info["frames"]}, durations = {{{durations}}} }}' + f'frames = {info["frames"]}, durations = {{{durations}}}{anchor} }}' ) diff --git a/tools/import_emerald_back_sprites.py b/tools/import_emerald_back_sprites.py index 2c20b5c..834a183 100644 --- a/tools/import_emerald_back_sprites.py +++ b/tools/import_emerald_back_sprites.py @@ -87,7 +87,9 @@ def main() -> None: ] for engine_name, path, info in records: lines.append(f" {engine_name} = {{") - lines.append(f" back = {lua_definition(path, info)},") + lines.append( + f" back = {lua_definition(path, info, stable_anchor=True)}," + ) lines.append(" },") lines.append("}") metadata = root / "data" / "animated_battle_backs_gen3.lua" From 64f2c8bac96ae0ebbab0561a73bb6dd1d5deff1f Mon Sep 17 00:00:00 2001 From: absol89 Date: Sun, 2 Aug 2026 12:56:28 +0200 Subject: [PATCH 035/116] Render player battle HUD at compact integer scale --- CHANGELOG.md | 4 ++++ lib/OverworldBattle.lua | 35 ++++++++++++++++++++++++---------- tests/dramatic_shape_test.lua | 36 ++++++++++++++++++++++------------- 3 files changed, 52 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d96456..26782f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,10 @@ active battle, and post-battle frames instead of switching through gray. The obsolete zero-alpha frost composite is skipped entirely, preventing a driver-dependent dim veil over the arena. +- **Lossless compact player HUD.** The right-side player status block renders + one integer display rung below the battle letterbox (2x becomes native 1x), + stays pixel-sharp and right-anchored, and no longer stretches across the + arena far enough to cover the opposing Pokemon or the live textbox. - **No stale battle-entry dimmer.** Staged battles suppress the engine's translucent world-fade rectangle at the compositor boundary as well as clearing its fade state. The normal battle wipe, deliberate battle-exit diff --git a/lib/OverworldBattle.lua b/lib/OverworldBattle.lua index d14467b..bf59c96 100644 --- a/lib/OverworldBattle.lua +++ b/lib/OverworldBattle.lua @@ -239,10 +239,11 @@ end -- screenshot pasted over a diorama rather than as the diorama's own furniture. -- -- So each block is snapped to its own side: the foe's to the left edge of the --- window, the player's to the right. Nothing about either block changes -- same --- tiles, same size, same rows, drawn by the engine's own DrawEnemyHUDAndHPBar --- and DrawPlayerHUDAndHPBar -- only where the pair sits. On a window the shape --- of the GB screen there is nowhere to go and the snap is a no-op. +-- window, the player's to the right. The near/player block is one INTEGER +-- display rung smaller than the battle letterbox. At the common 2x layout it +-- is therefore native 1x: every source pixel stays a sharp screen pixel while +-- the block no longer reaches halfway across the arena and covers the foe. +-- The enemy block retains the letterbox scale. -- -- They cannot simply be MOVED there: the engine draws them into the 160x144 UI -- canvas and everything outside it is clipped away. So the layer is rendered to @@ -269,17 +270,30 @@ OverworldBattle.HUD_BAND = { -- which is the whole cost of the snap and is invisible. function OverworldBattle.snapRects(shot) local s = shot.scale + local ps = math.max(1, s - 1) local e, p = OverworldBattle.HUD_RECT.enemy, OverworldBattle.HUD_RECT.player -- Leave two logical pixels between the foe's name and the window edge. -- The whole enemy band moves with the panel, so long names and HUD shakes -- keep their original internal alignment instead of being clipped. local ex = (2 - e[1]) * s - local px = shot.pw - (p[1] + p[3]) * s -- player: right edge to the far side + local px = shot.pw - (p[1] + p[3]) * ps -- player: right edge to the far side local rects = { enemy = { ex + e[1] * s, shot.ly + e[2] * s, e[3] * s, e[4] * s }, - player = { px + p[1] * s, shot.ly + p[2] * s, p[3] * s, p[4] * s }, + player = { px + p[1] * ps, shot.ly + p[2] * s, + p[3] * ps, p[4] * ps }, + } + local playerBand = OverworldBattle.HUD_BAND.player + return rects, { + enemy = { x = ex, y = shot.ly, scale = s }, + -- Preserve the HUD's original top row while shrinking its internal + -- distance from the band boundary. This leaves clear world between its + -- lower bracket and the live textbox rather than placing it underneath. + player = { + x = px, + y = shot.ly + p[2] * s - (p[2] - playerBand[2]) * ps, + scale = ps, + }, } - return rects, { enemy = ex, player = px } end -- A rect measured in the GB frame, in WORLD-canvas pixels: where the letterbox @@ -1231,7 +1245,7 @@ function OverworldBattle.snapHUDs(battle, shot) return false end local slide = (battle.introSlide or 0) * 4 - local rects, bandX = OverworldBattle.snapRects(shot) + local rects, bandPlacement = OverworldBattle.snapRects(shot) local enemy, player = OverworldBattle.hudLive(battle, slide) local live = {} if enemy then live.enemy = rects.enemy end @@ -1262,10 +1276,11 @@ function OverworldBattle.snapHUDs(battle, shot) end g.setColor(1, 1, 1, 1) for side, band in pairs(OverworldBattle.HUD_BAND) do + local placement = bandPlacement[side] local quad = g.newQuad(band[1], band[2], band[3], band[4], BattleScene.GB_W, BattleScene.GB_H) - g.draw(layer, quad, bandX[side] + band[1] * shot.scale, - shot.ly + band[2] * shot.scale, 0, shot.scale, shot.scale) + g.draw(layer, quad, placement.x + band[1] * placement.scale, + placement.y, 0, placement.scale, placement.scale) end end) if prevCanvas then g.setCanvas(prevCanvas) else g.setCanvas() end diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua index 8476185..cd0868a 100644 --- a/tests/dramatic_shape_test.lua +++ b/tests/dramatic_shape_test.lua @@ -1900,10 +1900,12 @@ T.check(wide > band, "marks further apart hold a deeper slab in focus") -- they are drawn in cannot reach past its own 160 columns. do local hudShot = { lx = 100, ly = 12, scale = 3, pw = 1000, ph = 500 } -local hudRects, bandX = Battles.snapRects(hudShot) +local hudRects, bandPlacement = Battles.snapRects(hudShot) local hudRect = Battles.HUD_RECT +local playerScale = hudShot.scale - 1 -T.eq(hudRects.enemy[1], 0, "the foe's panel starts at the window's left edge") +T.eq(hudRects.enemy[1], 2 * hudShot.scale, + "the foe's panel keeps its two-logical-pixel left inset") T.eq(hudRects.player[1] + hudRects.player[3], hudShot.pw, "and the player's ends at the right one") T.check(hudRects.enemy[1] < hudShot.lx, @@ -1917,27 +1919,35 @@ T.eq(hudRects.enemy[2], hudShot.ly + hudRect.enemy[2] * hudShot.scale, T.eq(hudRects.player[2], hudShot.ly + hudRect.player[2] * hudShot.scale, "and so does the player's") --- and their size: a block is the same GB tiles at the same scale as the rest of --- the art, moved and not stretched +-- The foe retains the frame scale. The near/player block takes one smaller +-- INTEGER rung, keeping every source pixel square without covering the foe. T.eq(hudRects.enemy[3], hudRect.enemy[3] * hudShot.scale, "a block is its own width at the frame's scale") -T.eq(hudRects.player[4], hudRect.player[4] * hudShot.scale, - "and its own height") +T.eq(hudRects.player[3], hudRect.player[3] * playerScale, + "the player block is one integer display rung narrower") +T.eq(hudRects.player[4], hudRect.player[4] * playerScale, + "and one integer display rung shorter") -- the band each block is cut out of is placed so the block lands on the rect -- above; that is what the panel and the glyphs agreeing depends on -T.eq(bandX.enemy + hudRect.enemy[1] * hudShot.scale, hudRects.enemy[1], +T.eq(bandPlacement.enemy.x + + hudRect.enemy[1] * bandPlacement.enemy.scale, hudRects.enemy[1], "the foe's band is offset so its block lands on its panel") -T.eq(bandX.player + hudRect.player[1] * hudShot.scale, hudRects.player[1], +T.eq(bandPlacement.player.x + + hudRect.player[1] * bandPlacement.player.scale, hudRects.player[1], "and the player's likewise") +T.eq(bandPlacement.player.y + + (hudRect.player[2] - Battles.HUD_BAND.player[2]) + * bandPlacement.player.scale, + hudRects.player[2], "the compact band preserves the player's top row") --- a window the shape of the GB screen has nowhere to snap TO, and the player's --- block already ends at column 160, so it must not move at all +-- Even on a GB-shaped window the compact player block remains right-anchored. local snug = { lx = 0, ly = 0, scale = 4, pw = 160 * 4, ph = 144 * 4 } local snugRects = Battles.snapRects(snug) -T.eq(snugRects.player[1], hudRect.player[1] * snug.scale, - "on a GB-shaped window the player's block stays exactly where it was") -T.eq(snugRects.enemy[1], 0, "and the foe's is flush with a left edge it already met") +T.eq(snugRects.player[1] + snugRects.player[3], snug.pw, + "on a GB-shaped window the compact player block keeps the right edge") +T.eq(snugRects.enemy[1], 2 * snug.scale, + "and the foe retains its readable left inset") -- the bands together cover every row drawHUDs draws into (0-96: the two HUDs, -- the pokeball rows and the safari ball count) and never overlap, so nothing it From a65b76a69d912bddd32c24afaa335019e34e63c4 Mon Sep 17 00:00:00 2001 From: absol89 Date: Sun, 2 Aug 2026 13:10:27 +0200 Subject: [PATCH 036/116] Anchor supplied OG UI back sprites by alpha bounds --- CHANGELOG.md | 4 ++++ lib/OverworldBattle.lua | 44 ++++++++++++++++++++++++++++++++--- tests/dramatic_shape_test.lua | 20 ++++++++++++++++ 3 files changed, 65 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 26782f5..3e9d735 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,6 +64,10 @@ one integer display rung below the battle letterbox (2x becomes native 1x), stays pixel-sharp and right-anchored, and no longer stretches across the arena far enough to cover the opposing Pokemon or the live textbox. +- **Alpha-anchored OG UI backs.** Supplied species backs render at their native + 1x instead of the ROM-only 2x default, sit on the classic left-side anchor, + and use their opaque bottom edge rather than transparent canvas padding to + meet the textbox. ROM backs and trainer intros retain upstream placement. - **No stale battle-entry dimmer.** Staged battles suppress the engine's translucent world-fade rectangle at the compositor boundary as well as clearing its fade state. The normal battle wipe, deliberate battle-exit diff --git a/lib/OverworldBattle.lua b/lib/OverworldBattle.lua index bf59c96..d07464a 100644 --- a/lib/OverworldBattle.lua +++ b/lib/OverworldBattle.lua @@ -115,6 +115,31 @@ function OverworldBattle.backPinned() or false end +-- A supplied species back is already a full-display PNG (56px and up), not +-- the ROM's half-resolution 32px picture. OG UI draws it at native 1x and +-- moves the COMPLETE engine draw so its alpha silhouette, rather than its +-- transparent canvas, owns the classic player anchor. Keeping this as an +-- outer translation preserves send-out growth, damage bounce and shake. +local function pinnedSpeciesMetric() + local battle = session and session.battle + if not (battle and battle.player and battle.player.sprite) + or battle.showPlayerBack or not OverworldBattle.backPinned() then + return nil + end + local image = battle.player.sprite + if not BattleArt.isExternal(image) then return nil end + return BattleArt.metrics(image) +end + +function OverworldBattle.backPinOffset(metric, grow) + if not metric then return 0, 0 end + local scale = tonumber(grow) or 1 + local left = 8 + metric.w * (1 - scale) / 2 + local center = left + metric.center * scale + return OverworldBattle.ANCHOR.player[1] - center, + (metric.padBottom or 0) * scale +end + -- ------- both mons face you -- -- Standing on a map, seen from in front, a Pokemon showing you its BACK is @@ -960,10 +985,11 @@ function OverworldBattle.install() -- to the screen -- and a twice-resampled Gen 1 pic is mush. if texturing then return 1 end -- The engine's trainer/back slot defaults to 2x because ROM back pictures - -- are authored at half display resolution. Our animated player-trainer - -- frames are already native 80x80 art. Keep those at 1x on OG UI without - -- changing ROM backs, static pictures, Pokemon, or world billboards. + -- are authored at half display resolution. Supplied species backs and + -- animated player-trainer frames are already full-display art, so both + -- stay native 1x on OG UI. ROM backs retain their intended 2x scale. local battle = session and session.battle + if side == "back" and pinnedSpeciesMetric() then return 1 end if side == "back" and AnimatedBattleArt.hasPlayerTrainerFrame(battle) then return 1 end @@ -1071,6 +1097,18 @@ function OverworldBattle.install() -- the mon, it would remap it to the wrong shade. SE_WAVY_SCREEN lasts a -- second and the hour survives it fine. local tint = not self.grayPics and shot.tint or nil + local metric = pinnedSpeciesMetric() + if metric then + local grow = self.player and self:growInScale(self.player) or nil + local dx, dy = OverworldBattle.backPinOffset(metric, grow) + love.graphics.push() + love.graphics.translate(math.floor(dx + 0.5), math.floor(dy + 0.5)) + local ok, result = pcall(withTint, tint, innerPics, self, slide, + sx, sy, "player", skipMenuClip) + love.graphics.pop() + if not ok then error(result, 0) end + return result + end return withTint(tint, innerPics, self, slide, sx, sy, "player", skipMenuClip) end diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua index cd0868a..1ded490 100644 --- a/tests/dramatic_shape_test.lua +++ b/tests/dramatic_shape_test.lua @@ -2031,6 +2031,26 @@ T.eq(Battles.backPinned(), false, "with 3D-BTL off the setting decides nothing, whatever it is left at") T.eq(Battles.backSetting:get(), true, "without being rewritten underneath") +-- Supplied back PNGs are already full-display art. Their visible silhouette, +-- rather than the outer canvas, owns the classic x=26 / y=96 back-pic anchor. +-- Symmetric grow-in changes size around the same centre, while transparent +-- rows below the feet are compensated in proportion to the current growth. +local pinX, pinY = Battles.backPinOffset( + { w = 80, center = 40, padBottom = 18 }, 1) +T.eq(pinX, -22, "an 80px supplied back is centred on the classic OG UI anchor") +T.eq(pinY, 18, "transparent rows below its feet are moved beneath the anchor") + +local growX, growY = Battles.backPinOffset( + { w = 80, center = 40, padBottom = 18 }, 0.5) +T.eq(growX, -22, "symmetric send-out growth keeps that visible centre pinned") +T.eq(growY, 9, "bottom-padding compensation follows send-out growth") + +local unevenX, unevenY = Battles.backPinOffset( + { w = 80, center = 35, padBottom = 10 }, 0.5) +T.eq(unevenX, -19.5, + "an asymmetric silhouette is anchored by its alpha centre, not canvas centre") +T.eq(unevenY, 5, "asymmetric art still lands its visible foot on the UI") + -- ...so the row comes off the menu with it, on the same reasoning the mod's -- other absent rows come off: a row that no longer decides anything is worse -- than no row From 2c8836c3e12a31ee5447a76233fc2e4e96621945 Mon Sep 17 00:00:00 2001 From: absol89 Date: Sun, 2 Aug 2026 13:30:57 +0200 Subject: [PATCH 037/116] Document Battle Art generation folders --- .gitignore | 15 +++++---------- assets/battle/back-animated/README.md | 3 ++- assets/battle/back-animated/gen3/README.md | 13 +++++++++++++ assets/battle/back-animated/gen5/README.md | 13 +++++++++++++ assets/battle/back-static/gen1/README.md | 11 +++++++++++ assets/battle/back-static/gen2/README.md | 11 +++++++++++ assets/battle/back-static/gen3/README.md | 12 ++++++++++++ assets/battle/back-static/gen4/README.md | 12 ++++++++++++ assets/battle/back-static/gen5/README.md | 11 +++++++++++ assets/battle/front-animated/gen2/README.md | 10 ++++++++++ assets/battle/front-animated/gen3/README.md | 10 ++++++++++ assets/battle/front-animated/gen4/README.md | 11 +++++++++++ assets/battle/front-animated/gen5/README.md | 11 +++++++++++ assets/battle/front-static/gen1/README.md | 7 +++++++ assets/battle/front-static/gen2/README.md | 7 +++++++ assets/battle/front-static/gen3/README.md | 7 +++++++ 16 files changed, 153 insertions(+), 11 deletions(-) create mode 100644 assets/battle/back-animated/gen3/README.md create mode 100644 assets/battle/back-animated/gen5/README.md create mode 100644 assets/battle/back-static/gen1/README.md create mode 100644 assets/battle/back-static/gen2/README.md create mode 100644 assets/battle/back-static/gen3/README.md create mode 100644 assets/battle/back-static/gen4/README.md create mode 100644 assets/battle/back-static/gen5/README.md create mode 100644 assets/battle/front-animated/gen2/README.md create mode 100644 assets/battle/front-animated/gen3/README.md create mode 100644 assets/battle/front-animated/gen4/README.md create mode 100644 assets/battle/front-animated/gen5/README.md create mode 100644 assets/battle/front-static/gen1/README.md create mode 100644 assets/battle/front-static/gen2/README.md create mode 100644 assets/battle/front-static/gen3/README.md diff --git a/.gitignore b/.gitignore index 102394d..ef2087f 100644 --- a/.gitignore +++ b/.gitignore @@ -6,13 +6,8 @@ __pycache__/ # agent worktrees and local scratch .claude/ -# Bring-your-own battle art. Keep the contracts/READMEs tracked, but never -# commit locally supplied Pokemon artwork. -assets/battle/front-static/* -!assets/battle/front-static/README.md -assets/battle/back-static/* -!assets/battle/back-static/README.md -assets/battle/front-animated/* -!assets/battle/front-animated/README.md -assets/battle/back-animated/* -!assets/battle/back-animated/README.md +# Bring-your-own battle art. Preserve the documented example directory tree, +# but never commit locally supplied artwork, atlases, drafts, or sidecars. +assets/battle/** +!assets/battle/**/ +!assets/battle/**/README.md diff --git a/assets/battle/back-animated/README.md b/assets/battle/back-animated/README.md index f9f27c5..56300e7 100644 --- a/assets/battle/back-animated/README.md +++ b/assets/battle/back-animated/README.md @@ -6,7 +6,8 @@ mirrored. GEN 3 and GEN 5 use animated back atlases here. `BACK ART SET` choices GEN 1, GEN 2 and GEN 4 instead read single-frame PNGs from their sibling `../back-static` generation folders, with no atlas processing. Missing selected art retains the ROM back sprite in its original UI layer. -This selector only affects ANIMATED mode. +These atlas folders are read only in ANIMATED mode; STATIC uses the selected +generation under `../back-static` instead. `BACK PLACEMENT` offers AUTO, WORLD, and OG UI. AUTO keeps a valid Gen 5 atlas in the world and leaves a missing atlas's ROM fallback on OG UI. Forcing OG UI diff --git a/assets/battle/back-animated/gen3/README.md b/assets/battle/back-animated/gen3/README.md new file mode 100644 index 0000000..90a9793 --- /dev/null +++ b/assets/battle/back-animated/gen3/README.md @@ -0,0 +1,13 @@ +# Generation 3 animated back sprites + +Source reference: [Emerald back sprites — Bulbagarden Archives](https://archives.bulbagarden.net/wiki/Category:Emerald_back_sprites) + +From the repository root, run: + +```powershell +python tools/import_emerald_back_sprites.py --root . +``` + +This generates the local `Spr b 3e` atlases here and their shared Lua +metadata. Generated artwork is ignored by Git and is not covered by the +mod's MIT license. Verify that you have the right to use and distribute it. diff --git a/assets/battle/back-animated/gen5/README.md b/assets/battle/back-animated/gen5/README.md new file mode 100644 index 0000000..42e62ad --- /dev/null +++ b/assets/battle/back-animated/gen5/README.md @@ -0,0 +1,13 @@ +# Generation 5 animated back sprites + +Source reference: [Black/White animated back sprites — Pokémon Database](https://img.pokemondb.net/sprites/black-white/anim/back-normal/) + +From the repository root, run: + +```powershell +python tools/import_animated_sprites.py --set gen5 --root . +``` + +The command generates both Gen 5 front and back atlases plus shared metadata. +Generated artwork is ignored by Git and is not covered by the mod's MIT +license. Verify that you have the right to use and distribute it. diff --git a/assets/battle/back-static/gen1/README.md b/assets/battle/back-static/gen1/README.md new file mode 100644 index 0000000..735fbc7 --- /dev/null +++ b/assets/battle/back-static/gen1/README.md @@ -0,0 +1,11 @@ +# Generation 1 static back sprites + +Source reference: [Pokémon Yellow Super Game Boy back sprites — Bulbagarden Archives](https://archives.bulbagarden.net/wiki/Category:Yellow_back_sprites_(Super_Game_Boy)) + +```powershell +python tools/import_yellow_sgb_back_sprites.py --root . +``` + +The importer writes the first 151 source PNGs here. Artwork is ignored by Git +and is not covered by the mod's MIT license. Verify that you have the right to +use and distribute it. diff --git a/assets/battle/back-static/gen2/README.md b/assets/battle/back-static/gen2/README.md new file mode 100644 index 0000000..b2f7e6b --- /dev/null +++ b/assets/battle/back-static/gen2/README.md @@ -0,0 +1,11 @@ +# Generation 2 static back sprites + +Source reference: [Pokémon Crystal back sprites — Bulbagarden Archives](https://archives.bulbagarden.net/wiki/Category:Crystal_back_sprites) + +```powershell +python tools/import_crystal_back_sprites.py --root . +``` + +The importer discovers the ordinary `2c`, `2g`, or `2s` file for each of the +first 151 species. Artwork is ignored by Git and is not covered by the mod's +MIT license. Verify that you have the right to use and distribute it. diff --git a/assets/battle/back-static/gen3/README.md b/assets/battle/back-static/gen3/README.md new file mode 100644 index 0000000..ae367ea --- /dev/null +++ b/assets/battle/back-static/gen3/README.md @@ -0,0 +1,12 @@ +# Generation 3 static back sprites + +Source reference: [Emerald back sprites — Bulbagarden Archives](https://archives.bulbagarden.net/wiki/Category:Emerald_back_sprites) + +```powershell +python tools/import_emerald_back_sprites.py --root . +``` + +The importer copies the ordinary `Spr b 3r` PNGs here and builds the separate +animated `Spr b 3e` collection. Artwork is ignored by Git and is not covered +by the mod's MIT license. Verify that you have the right to use and distribute +it. diff --git a/assets/battle/back-static/gen4/README.md b/assets/battle/back-static/gen4/README.md new file mode 100644 index 0000000..90bff03 --- /dev/null +++ b/assets/battle/back-static/gen4/README.md @@ -0,0 +1,12 @@ +# Generation 4 static back sprites + +Source reference: [Pokémon Platinum back sprites — Bulbagarden Archives](https://archives.bulbagarden.net/wiki/Category:Platinum_back_sprites) + +```powershell +python tools/import_platinum_back_sprites.py --root . +``` + +The importer selects the ordinary image for each of the first 151 species, +preferring the male variant where the archive has a gender pair. Artwork is +ignored by Git and is not covered by the mod's MIT license. Verify that you +have the right to use and distribute it. diff --git a/assets/battle/back-static/gen5/README.md b/assets/battle/back-static/gen5/README.md new file mode 100644 index 0000000..d60b0d2 --- /dev/null +++ b/assets/battle/back-static/gen5/README.md @@ -0,0 +1,11 @@ +# Generation 5 static back sprites + +Source reference: [Black/White static back sprites — Pokémon Database](https://img.pokemondb.net/sprites/black-white/back-normal/) + +```powershell +python tools/import_black_white_static_back_sprites.py --root . +``` + +The importer writes the first 151 ordinary PNGs here. Artwork is ignored by +Git and is not covered by the mod's MIT license. Verify that you have the +right to use and distribute it. diff --git a/assets/battle/front-animated/gen2/README.md b/assets/battle/front-animated/gen2/README.md new file mode 100644 index 0000000..3855cc0 --- /dev/null +++ b/assets/battle/front-animated/gen2/README.md @@ -0,0 +1,10 @@ +# Generation 2 animated front sprites + +Source reference: [Pokémon Crystal animated sprites — Blue Moon Falls](https://bluemoonfalls.com/images/sprites/crystal/normal/) + +```powershell +python tools/import_animated_sprites.py --set gen2 --root . +``` + +Generated atlases are ignored by Git and are not covered by the mod's MIT +license. Verify that you have the right to use and distribute them. diff --git a/assets/battle/front-animated/gen3/README.md b/assets/battle/front-animated/gen3/README.md new file mode 100644 index 0000000..194cd51 --- /dev/null +++ b/assets/battle/front-animated/gen3/README.md @@ -0,0 +1,10 @@ +# Generation 3 animated front sprites + +Source reference: [Pokémon Emerald animated sprites — PKMN.NET](https://pkmn.net/sprites/emerald/) + +```powershell +python tools/import_animated_sprites.py --set gen3 --root . +``` + +Generated atlases are ignored by Git and are not covered by the mod's MIT +license. Verify that you have the right to use and distribute them. diff --git a/assets/battle/front-animated/gen4/README.md b/assets/battle/front-animated/gen4/README.md new file mode 100644 index 0000000..018957c --- /dev/null +++ b/assets/battle/front-animated/gen4/README.md @@ -0,0 +1,11 @@ +# Generation 4 animated front sprites + +Source reference: [Diamond and Pearl sprites — Bulbagarden Archives](https://archives.bulbagarden.net/wiki/Category:Diamond_and_Pearl_sprites) + +```powershell +python tools/import_animated_sprites.py --set gen4 --root . +``` + +The importer converts the first 151 animated PNGs into local atlases. Artwork +is ignored by Git and is not covered by the mod's MIT license. Verify that you +have the right to use and distribute it. diff --git a/assets/battle/front-animated/gen5/README.md b/assets/battle/front-animated/gen5/README.md new file mode 100644 index 0000000..00d4937 --- /dev/null +++ b/assets/battle/front-animated/gen5/README.md @@ -0,0 +1,11 @@ +# Generation 5 animated front sprites + +Source reference: [Black/White animated front sprites — Pokémon Database](https://img.pokemondb.net/sprites/black-white/anim/normal/) + +```powershell +python tools/import_animated_sprites.py --set gen5 --root . +``` + +The command generates both Gen 5 front and back atlases plus shared metadata. +Generated artwork is ignored by Git and is not covered by the mod's MIT +license. Verify that you have the right to use and distribute it. diff --git a/assets/battle/front-static/gen1/README.md b/assets/battle/front-static/gen1/README.md new file mode 100644 index 0000000..45d75cf --- /dev/null +++ b/assets/battle/front-static/gen1/README.md @@ -0,0 +1,7 @@ +# Generation 1 opponent trainer fronts + +Place user-supplied static trainer PNGs for `TRAINER ART: GEN 1` here. The +required class filenames are listed in the parent [front-static contract](../README.md). + +Trainer artwork is ignored by Git and is not covered by the mod's MIT license. +No downloader is provided for this user-supplied collection. diff --git a/assets/battle/front-static/gen2/README.md b/assets/battle/front-static/gen2/README.md new file mode 100644 index 0000000..3b2988e --- /dev/null +++ b/assets/battle/front-static/gen2/README.md @@ -0,0 +1,7 @@ +# Generation 2 opponent trainer fronts + +Place user-supplied static trainer PNGs for `TRAINER ART: GEN 2` here. The +required class filenames are listed in the parent [front-static contract](../README.md). + +Trainer artwork is ignored by Git and is not covered by the mod's MIT license. +No downloader is provided for this user-supplied collection. diff --git a/assets/battle/front-static/gen3/README.md b/assets/battle/front-static/gen3/README.md new file mode 100644 index 0000000..507a901 --- /dev/null +++ b/assets/battle/front-static/gen3/README.md @@ -0,0 +1,7 @@ +# Generation 3 opponent trainer fronts + +Place user-supplied static trainer PNGs for `TRAINER ART: GEN 3` here. The +required class filenames are listed in the parent [front-static contract](../README.md). + +Trainer artwork is ignored by Git and is not covered by the mod's MIT license. +No downloader is provided for this user-supplied collection. From d64309b0b1eb9c869d9cc34edf5c844b22b80b18 Mon Sep 17 00:00:00 2001 From: absol89 Date: Sun, 2 Aug 2026 13:34:08 +0200 Subject: [PATCH 038/116] Fix PokemonDB source links --- assets/battle/back-animated/gen5/README.md | 5 ++++- assets/battle/back-static/gen5/README.md | 5 ++++- assets/battle/front-animated/gen5/README.md | 5 ++++- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/assets/battle/back-animated/gen5/README.md b/assets/battle/back-animated/gen5/README.md index 42e62ad..e9ee1f1 100644 --- a/assets/battle/back-animated/gen5/README.md +++ b/assets/battle/back-animated/gen5/README.md @@ -1,6 +1,9 @@ # Generation 5 animated back sprites -Source reference: [Black/White animated back sprites — Pokémon Database](https://img.pokemondb.net/sprites/black-white/anim/back-normal/) +Source reference: [Pokémon Database sprite archive](https://pokemondb.net/sprites) + +The importer downloads individual files using the documented asset pattern, +for example `https://img.pokemondb.net/sprites/black-white/anim/back-normal/pikachu.gif`. From the repository root, run: diff --git a/assets/battle/back-static/gen5/README.md b/assets/battle/back-static/gen5/README.md index d60b0d2..e8a322f 100644 --- a/assets/battle/back-static/gen5/README.md +++ b/assets/battle/back-static/gen5/README.md @@ -1,6 +1,9 @@ # Generation 5 static back sprites -Source reference: [Black/White static back sprites — Pokémon Database](https://img.pokemondb.net/sprites/black-white/back-normal/) +Source reference: [Pokémon Database sprite archive](https://pokemondb.net/sprites) + +The importer downloads individual files using the documented asset pattern, +for example `https://img.pokemondb.net/sprites/black-white/back-normal/pikachu.png`. ```powershell python tools/import_black_white_static_back_sprites.py --root . diff --git a/assets/battle/front-animated/gen5/README.md b/assets/battle/front-animated/gen5/README.md index 00d4937..7cc8203 100644 --- a/assets/battle/front-animated/gen5/README.md +++ b/assets/battle/front-animated/gen5/README.md @@ -1,6 +1,9 @@ # Generation 5 animated front sprites -Source reference: [Black/White animated front sprites — Pokémon Database](https://img.pokemondb.net/sprites/black-white/anim/normal/) +Source reference: [Pokémon Database sprite archive](https://pokemondb.net/sprites) + +The importer downloads individual files using the documented asset pattern, +for example `https://img.pokemondb.net/sprites/black-white/anim/normal/pikachu.gif`. ```powershell python tools/import_animated_sprites.py --set gen5 --root . From fd363d20aba996d640eb38098d1edc6a1cff1ddc Mon Sep 17 00:00:00 2001 From: absol89 Date: Sun, 2 Aug 2026 13:56:10 +0200 Subject: [PATCH 039/116] Document Gen 1 animated-mode front compatibility --- assets/battle/front-animated/gen1/README.md | 38 +++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 assets/battle/front-animated/gen1/README.md diff --git a/assets/battle/front-animated/gen1/README.md b/assets/battle/front-animated/gen1/README.md new file mode 100644 index 0000000..2111060 --- /dev/null +++ b/assets/battle/front-animated/gen1/README.md @@ -0,0 +1,38 @@ +# Generation 1 front compatibility set + +This folder is reserved for single-frame Gen 1, Super Game Boy, and ROM-hack +front sprites used alongside the otherwise ANIMATED battle-art mode. Unlike +the Gen 2–5 collections, these are ordinary PNGs rather than animation +atlases: no Lua sidecar, frame grid, or timing metadata is required. + +Name one PNG per species using the same lowercase names as the other +battle-art folders, for example `pikachu.png`, `farfetchd.png`, +`mr-mime.png`, `nidoran-f.png`, and `nidoran-m.png`. Author opponent art +facing left. Missing or malformed species should fall back directly to the +ROM front sprite rather than borrow from another generation. + +The Pokémon images are independent of the player-trainer introduction. A +five-frame player strip selected by `PLAYER ANIM` can therefore continue to +play while Pokémon fronts from this folder remain single-frame. + +## Placement + +The intended `FRONT PLACEMENT` choices are: + +- `AUTO` — world placement during staged battles. +- `WORLD` — a world card with depth occlusion, alpha-shaped shadow, battle + effects, and display filtering. +- `OG UI` — the original fixed battle slot and its UI-layer movement/effects. + +Large or unusually padded art may crop when forced into `OG UI`; native +Game Boy and Super Game Boy dimensions are the natural fit there. + +## Implementation status + +The folder contract is documented ahead of the renderer change. The current +runtime still offers `ANIM FRONT GEN` choices Gen 2–5 only and does not yet +load this directory. `ANIM FRONT GEN: GEN 1` and `FRONT PLACEMENT` must be +implemented before these files appear in-game. + +Local artwork in this folder is ignored by Git and is not covered by the +mod's MIT license. Verify that you have the right to use and distribute it. From bdf263a6f4c778ef02f7e56a2ce45240ea9a2e40 Mon Sep 17 00:00:00 2001 From: absol89 Date: Sun, 2 Aug 2026 14:12:30 +0200 Subject: [PATCH 040/116] Add Gen 1 animated-mode front collection --- CHANGELOG.md | 4 ++ README.md | 11 ++--- assets/battle/front-animated/README.md | 10 +++-- assets/battle/front-animated/gen1/README.md | 13 +++--- lib/AnimatedBattleArt.lua | 46 ++++++++++++++++++++- lib/BattleArt.lua | 18 +++++++- main.lua | 5 ++- tests/dramatic_shape_test.lua | 7 ++++ 8 files changed, 91 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e9d735..280b2b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,6 +68,10 @@ 1x instead of the ROM-only 2x default, sit on the classic left-side anchor, and use their opaque bottom edge rather than transparent canvas padding to meet the textbox. ROM backs and trainer intros retain upstream placement. +- **Gen 1 fronts alongside animated intros.** `ANIM FRONT GEN: GEN 1` reads + ordinary single-frame species PNGs from `front-animated/gen1`, while the + selected five-frame player-trainer introduction continues independently. + Missing species retain their ROM front sprite; Gen 2–5 keep atlas playback. - **No stale battle-entry dimmer.** Staged battles suppress the engine's translucent world-fade rectangle at the compositor boundary as well as clearing its fade state. The normal battle wipe, deliberate battle-exit diff --git a/README.md b/README.md index e7dcc1a..62467ef 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ menu. | the **TRAINER ART** options row | GEN 1 / GEN 2 / GEN 3 — choose a static opponent-trainer collection | | the **PLAYER ART** options row | PNG / GEN 1 / GEN 2 / GEN 3 / GEN 4 / GEN 5 / ASH / GARY / ROM — choose the static player trainer battle-intro portrait; BATTLE ART: ROM pins it to ROM | | the **PLAYER ANIM** options row | GEN 1 / GEN 2 / GEN 3 / GEN 4 / GEN 5 / ASH / GARY / ROM — choose a five-pose player intro while ANIMATED is selected | -| the **ANIM FRONT GEN** options row | GEN 2 / GEN 3 / GEN 4 / GEN 5 — choose the animated front collection | +| the **ANIM FRONT GEN** options row | GEN 1 / GEN 2 / GEN 3 / GEN 4 / GEN 5 — choose a single-frame Gen 1 compatibility set or an animated Gen 2–5 collection | | the **BACK ART SET** options row | GEN 1 / GEN 2 / GEN 3 / GEN 4 / GEN 5 — STATIC always uses generation PNGs; ANIMATED uses Gen 3/5 atlases and Gen 1/2/4 PNGs | | the **PLAYER** options row | FRONT SPRITES / BACK SPRITES — supplied art is world-placed; a missing selected back uses the ROM's UI-attached pic | | the **BACK PLACEMENT** options row | AUTO / WORLD / OG UI — use the mode-aware default or force every player back onto one layer | @@ -72,10 +72,11 @@ The four battle-art directories are intentionally ignored by Git except for their README contracts. `tools/package_mod.ps1` nevertheless includes local PNGs from them in a test ZIP, so artwork can stay private and uncommitted. `BATTLE ART: ANIMATED` reads independent `ANIM FRONT GEN` and `BACK ART SET` -choices. Front choices remain animated atlases, including converted -Diamond/Pearl APNGs for GEN 4. Animated back GEN 3 and GEN 5 read atlases from -`back-animated/gen3` and `back-animated/gen5`. Back GEN 1, GEN 2 and GEN 4 read -ordinary species PNGs from their `back-static` generation folders. A +choices. GEN 1 fronts are ordinary single-frame PNGs from +`front-animated/gen1`; Gen 2–5 fronts remain animated atlases, including +converted Diamond/Pearl APNGs for GEN 4. Animated back GEN 3 and GEN 5 read +atlases from `back-animated/gen3` and `back-animated/gen5`. Back GEN 1, GEN 2 +and GEN 4 read ordinary species PNGs from their `back-static` generation folders. A loaded back is world-placed, lit, depth-occluded and shadowed. If its selected PNG or atlas is missing or malformed, the unmodified ROM backsprite remains in the original UI layer, including its normal battle motion and filtering. diff --git a/assets/battle/front-animated/README.md b/assets/battle/front-animated/README.md index 53ce596..2f08ade 100644 --- a/assets/battle/front-animated/README.md +++ b/assets/battle/front-animated/README.md @@ -1,14 +1,16 @@ # Front animated battle art -Optional animated opponent/player-front battle atlases are grouped here as -`gen2/`, `gen3/`, `gen4/`, and `gen5/`. Local PNG files are ignored by Git. `ANIM FRONT GEN` +Optional opponent/player-front collections are grouped here as `gen1/` +through `gen5/`. Gen 1 is a single-frame PNG compatibility set; Gen 2–5 are +animated atlases. Local PNG files are ignored by Git. `ANIM FRONT GEN` chooses exactly one folder in ANIMATED mode; missing or malformed art falls back to the ROM sprite rather than silently mixing generations. GIF decoding is authoring-only. The game reads PNG atlases, extracts every cell at its native logical resolution, and uses nearest-neighbour filtering. -The importer creates both the atlases and the selected set's shared metadata; -users do not write one Lua file per Pokemon. From the repository root, run one +The importer creates both the Gen 2–5 atlases and the selected set's shared +metadata; users do not write one Lua file per Pokemon. Gen 1 instead accepts +one ordinary species PNG with no metadata. From the repository root, run one of `python tools/import_animated_sprites.py --set gen2`, `--set gen3`, `--set gen4`, or `--set gen5`. Gen 2 uses Crystal fronts, Gen 3 uses Emerald fronts, Gen 4 uses Diamond/Pearl animated PNGs, and Gen 5 uses Black/White diff --git a/assets/battle/front-animated/gen1/README.md b/assets/battle/front-animated/gen1/README.md index 2111060..3dc4a1a 100644 --- a/assets/battle/front-animated/gen1/README.md +++ b/assets/battle/front-animated/gen1/README.md @@ -1,7 +1,7 @@ # Generation 1 front compatibility set -This folder is reserved for single-frame Gen 1, Super Game Boy, and ROM-hack -front sprites used alongside the otherwise ANIMATED battle-art mode. Unlike +This folder supplies single-frame Gen 1, Super Game Boy, and ROM-hack front +sprites for `BATTLE ART: ANIMATED` with `ANIM FRONT GEN: GEN 1`. Unlike the Gen 2–5 collections, these are ordinary PNGs rather than animation atlases: no Lua sidecar, frame grid, or timing metadata is required. @@ -27,12 +27,9 @@ The intended `FRONT PLACEMENT` choices are: Large or unusually padded art may crop when forced into `OG UI`; native Game Boy and Super Game Boy dimensions are the natural fit there. -## Implementation status - -The folder contract is documented ahead of the renderer change. The current -runtime still offers `ANIM FRONT GEN` choices Gen 2–5 only and does not yet -load this directory. `ANIM FRONT GEN: GEN 1` and `FRONT PLACEMENT` must be -implemented before these files appear in-game. +Front Pokémon remain world-placed in the current renderer. A separate +`FRONT PLACEMENT` selector is not implemented yet; the placement list above +documents the intended follow-up behavior rather than a current menu row. Local artwork in this folder is ignored by Git and is not covered by the mod's MIT license. Verify that you have the right to use and distribute it. diff --git a/lib/AnimatedBattleArt.lua b/lib/AnimatedBattleArt.lua index 7fddd02..9f10bec 100644 --- a/lib/AnimatedBattleArt.lua +++ b/lib/AnimatedBattleArt.lua @@ -17,6 +17,15 @@ local BACK_SETS = { gen3 = V.data("animated_battle_backs_gen3"), gen5 = SETS.gen5, } +-- Gen 1 fronts are ordinary single-frame PNGs. Later generations retain the +-- atlas decoder and timing metadata they have always used. +local FRONT_SOURCE_KIND = { + gen1 = "static", + gen2 = "animated", + gen3 = "animated", + gen4 = "animated", + gen5 = "animated", +} -- Gen 1 SGB, Gen 2 Crystal, and Gen 4 backs are intentionally single-frame -- collections even while BATTLE ART is ANIMATED. An explicit source table -- prevents a static generation folder from being decoded as an atlas. @@ -254,6 +263,38 @@ local function updateStaticBack(battler, generation, mode) battler.sprite = state.image end +local function updateStaticFront(battler, generation, mode) + if not (battler and battler.sprite) then return end + local species = battler.mon and battler.mon.species + local image = species and BattleArt.generationFrontImage(species, generation) + local state = states[battler] + if state and (state.kind ~= "static" or state.side ~= "front" + or state.generation ~= generation or state.mode ~= mode + or state.image ~= image) then + restore(battler) + state = nil + end + if not image then restore(battler); return end + if not state then + state = { kind = "static", side = "front", generation = generation, + mode = mode, original = battler.sprite, image = image } + states[battler] = state + elseif battler.sprite ~= state.image and battler.sprite ~= state.original then + -- A transform or battle effect owns the sprite for this frame. + states[battler] = nil + return + end + battler.sprite = state.image +end + +local function updateFront(battler, generation, dt, mode) + if FRONT_SOURCE_KIND[generation] == "static" then + updateStaticFront(battler, generation, mode) + else + updateBattler(battler, "front", dt, mode) + end +end + function AnimatedBattleArt.update(battle, dt) if not battle then return end if BattleArt.setting:get() ~= "animated" then @@ -265,7 +306,8 @@ function AnimatedBattleArt.update(battle, dt) -- captures the engine portrait, then leave managed animation frames alone. BattleArt.applyTrainers(battle) updatePlayerTrainer(battle, mode) - updateBattler(battle.enemy, "front", dt, mode) + local frontGeneration = BattleArt.frontAnimationSetting:get() + updateFront(battle.enemy, frontGeneration, dt, mode) local playerSide = BattleArt.playerSide() if playerSide == "back" then local generation = BattleArt.backAnimationSetting:get() @@ -275,7 +317,7 @@ function AnimatedBattleArt.update(battle, dt) updateStaticBack(battle.player, generation, mode) end else - updateBattler(battle.player, "front", dt, mode) + updateFront(battle.player, frontGeneration, dt, mode) end end diff --git a/lib/BattleArt.lua b/lib/BattleArt.lua index a020343..41d75f9 100644 --- a/lib/BattleArt.lua +++ b/lib/BattleArt.lua @@ -8,8 +8,8 @@ local BattleArt = {} BattleArt.setting = ModSetting.new("battleArt", "BATTLE ART", { "static", "animated", "rom" }, { "STATIC", "ANIMATED", "ROM" }) BattleArt.frontAnimationSetting = ModSetting.new("frontAnimatedSet", "ANIM FRONT GEN", - { "gen2", "gen3", "gen4", "gen5" }, - { "GEN 2", "GEN 3", "GEN 4", "GEN 5" }) + { "gen1", "gen2", "gen3", "gen4", "gen5" }, + { "GEN 1", "GEN 2", "GEN 3", "GEN 4", "GEN 5" }, 2) -- The selected generation names the static back folder in STATIC mode. In -- ANIMATED mode uses atlases for Gen 3 and Gen 5; Gen 1, 2 and 4 use their -- single images. The mode, not just the generation, decides the decoder. @@ -252,6 +252,20 @@ function BattleArt.generationBackImage(species, generation) return prepare(path, displayMode()) end +-- Gen 1 has no animated front atlas. ANIMATED mode still offers it as a +-- compatibility collection so SGB and ROM-hack fronts can coexist with the +-- independently animated player-trainer intro. Each species is one ordinary +-- image; no metadata or timing sidecar is involved. +function BattleArt.generationFrontImage(species, generation) + if generation ~= "gen1" then return nil end + local rel = ("assets/battle/front-animated/gen1/%s.png"):format( + slug(species)) + local path = V.mod.assets:path(rel) + local fs = love and love.filesystem + if not (fs and fs.getInfo and fs.getInfo(path)) then return nil end + return prepare(path, displayMode()) +end + function BattleArt.namedImage(name, side) local path = staticPathFor(name, side) return path and prepare(path, displayMode()) or nil diff --git a/main.lua b/main.lua index 8c0b7e0..2c00d13 100644 --- a/main.lua +++ b/main.lua @@ -364,8 +364,9 @@ local SETTINGS = { return stagedBattles() and BattleArt.setting:get() == "animated" end, full = true }, { BattleArt.frontAnimationSetting, - "Choose the front generation used by BATTLE ART: ANIMATED. STATIC " - .. "ignores this row. Missing art falls directly back to ROM.", + "Choose the front generation used by BATTLE ART: ANIMATED. GEN 1 reads " + .. "single-frame PNGs; GEN 2-5 read atlases. STATIC ignores this row. " + .. "Missing art falls directly back to ROM.", when = function() return stagedBattles() and BattleArt.setting:get() == "animated" end, full = true }, diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua index 1ded490..fd6551e 100644 --- a/tests/dramatic_shape_test.lua +++ b/tests/dramatic_shape_test.lua @@ -1842,6 +1842,13 @@ T.eq(rig.curve, 0, "the battle camera switches the world curve off") -- a white battle field and a whiteout of the map, the HUD and the text box -- over a world. It is dropped on the way past and put back on the two cards. local Battles = run.loader.exports.DRAMATIC_SHAPE.lib.require("OverworldBattle") +local Art = run.loader.exports.DRAMATIC_SHAPE.lib.require("BattleArt") +T.eq(Art.frontAnimationSetting.values[1], "gen1", + "animated fronts expose the single-frame Gen 1 compatibility collection") +T.eq(Art.frontAnimationSetting.labels[1], "GEN 1", + "the new collection has the expected options-menu label") +T.eq(Art.frontAnimationSetting.defaultIndex, 2, + "adding Gen 1 does not change the established Gen 2 default") T.eq(Battles.flashing(nil), false, "no battle, no flash") T.eq(Battles.flashing({ fx = {}, frame = 0 }), false, "a battle with no flash counter is not flashing") From 1d960f7dc9662191f94ebc95eb2729c82b314566 Mon Sep 17 00:00:00 2001 From: absol89 Date: Sun, 2 Aug 2026 14:23:53 +0200 Subject: [PATCH 041/116] Add static animated-mode player portrait and compact enemy HUD --- CHANGELOG.md | 6 ++++ README.md | 14 ++++++---- assets/battle/back-animated/README.md | 15 +++++----- assets/battle/back-static/README.md | 13 +++++---- lib/AnimatedBattleArt.lua | 40 +++++++++++++++++++++++---- lib/BattleArt.lua | 4 +-- lib/OverworldBattle.lua | 26 +++++++++-------- main.lua | 7 +++-- tests/dramatic_shape_test.lua | 30 ++++++++++++-------- 9 files changed, 103 insertions(+), 52 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 280b2b0..b271fb2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,6 +72,12 @@ ordinary single-frame species PNGs from `front-animated/gen1`, while the selected five-frame player-trainer introduction continues independently. Missing species retain their ROM front sprite; Gen 2–5 keep atlas playback. +- **Static player portrait under ANIMATED.** `PLAYER ANIM: PNG` reads + `back-static/player.png` as a single non-looping portrait, preserving the + engine's intro slide and falling back to its ROM portrait when absent. +- **Matched compact battle HUDs.** Enemy and player Pokémon status blocks now + share the same smaller integer display scale and retain their left/right + screen-edge anchors. - **No stale battle-entry dimmer.** Staged battles suppress the engine's translucent world-fade rectangle at the compositor boundary as well as clearing its fade state. The normal battle wipe, deliberate battle-exit diff --git a/README.md b/README.md index 62467ef..a819a27 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ menu. | the **BATTLE ART** options row | STATIC / ANIMATED / ROM — use optional battle-only art, with a direct ROM fallback when a file or atlas is absent | | the **TRAINER ART** options row | GEN 1 / GEN 2 / GEN 3 — choose a static opponent-trainer collection | | the **PLAYER ART** options row | PNG / GEN 1 / GEN 2 / GEN 3 / GEN 4 / GEN 5 / ASH / GARY / ROM — choose the static player trainer battle-intro portrait; BATTLE ART: ROM pins it to ROM | -| the **PLAYER ANIM** options row | GEN 1 / GEN 2 / GEN 3 / GEN 4 / GEN 5 / ASH / GARY / ROM — choose a five-pose player intro while ANIMATED is selected | +| the **PLAYER ANIM** options row | PNG / GEN 1 / GEN 2 / GEN 3 / GEN 4 / GEN 5 / ASH / GARY / ROM — choose a static `player.png` or five-pose player intro while ANIMATED is selected | | the **ANIM FRONT GEN** options row | GEN 1 / GEN 2 / GEN 3 / GEN 4 / GEN 5 — choose a single-frame Gen 1 compatibility set or an animated Gen 2–5 collection | | the **BACK ART SET** options row | GEN 1 / GEN 2 / GEN 3 / GEN 4 / GEN 5 — STATIC always uses generation PNGs; ANIMATED uses Gen 3/5 atlases and Gen 1/2/4 PNGs | | the **PLAYER** options row | FRONT SPRITES / BACK SPRITES — supplied art is world-placed; a missing selected back uses the ROM's UI-attached pic | @@ -119,11 +119,13 @@ player trainer backs such as `back-static/gen1player.png`, `back-static/ashplayer.png`, or ROM. PNG is the default and reads `back-static/player.png`; every missing named choice tries that same generic PNG before falling back to ROM. -In ANIMATED mode, `PLAYER ANIM` selects the corresponding five-frame strip -from `back-animated`, or the engine portrait under ROM. Frame one holds during -the stationary entrance pose. Frames two through five advance once with the -engine's leftward intro slide and then stop; they do not loop. AUTO placement -keeps this intro on OG UI, while WORLD and OG UI remain explicit overrides. +In ANIMATED mode, `PLAYER ANIM: PNG` reads the static +`back-static/player.png`. Named choices select the corresponding five-frame +strip from `back-animated`, while ROM retains the engine portrait. Frame one +holds during the stationary entrance pose. Frames two through five advance +once with the engine's leftward intro slide and then stop; they do not loop. +AUTO placement keeps this intro on OG UI, while WORLD and OG UI remain +explicit overrides. Both the original five-by-64 strips and normalized five-by-80 strips are read at native resolution. Unlike the ROM's half-resolution player back, a custom animated trainer remains 1x when attached to OG UI. Use diff --git a/assets/battle/back-animated/README.md b/assets/battle/back-animated/README.md index 56300e7..fc06203 100644 --- a/assets/battle/back-animated/README.md +++ b/assets/battle/back-animated/README.md @@ -13,11 +13,13 @@ generation under `../back-static` instead. in the world and leaves a missing atlas's ROM fallback on OG UI. Forcing OG UI is supported for comparison, but large atlas frames may crop there. -This folder also accepts five-pose player-trainer strips selected by the -`PLAYER ANIM` row in ANIMATED mode: +`PLAYER ANIM` also controls the player-trainer introduction in ANIMATED mode. +PNG reads the ordinary static `../back-static/player.png`; the named choices +below read five-pose strips from this folder: | Option | Filename | | --- | --- | +| PNG | `../back-static/player.png` (single static image) | | GEN 1 | `gen1player.png` | | GEN 2 | `gen2player.png` | | GEN 3 | `gen3player.png` | @@ -27,14 +29,13 @@ This folder also accepts five-pose player-trainer strips selected by the | GARY | `garyplayer.png` | | ROM | no file; use the engine portrait | -Each PNG is one horizontal row of exactly five equal-width frames. Existing -320-pixel strips are read as five 64-pixel cells; the recommended 400x80 -format is five 80x80 cells. Copy poses into +Each named strip is one horizontal row of exactly five equal-width frames. +The recommended 400x80 format is five 80x80 cells. Copy poses into `tools/player-animation-template-400x80.png`, but remove the coloured guide dividers in the finished PNG. The first frame holds while the portrait is stationary; frames two through five play once as the trainer slides left and -then stop. The animation never loops and is never resampled. Missing or -malformed selected art falls back to the ROM portrait. Under `BACK PLACEMENT: +then stop. The animation never loops and is never resampled. A missing static +PNG or malformed selected strip falls back to the ROM portrait. Under `BACK PLACEMENT: AUTO`, player-trainer animation uses OG UI; WORLD and OG UI can override it. Custom player frames remain 1x on OG UI, while its ROM fallback retains the engine's intended 2x scale. diff --git a/assets/battle/back-static/README.md b/assets/battle/back-static/README.md index 6431e0c..c9f9687 100644 --- a/assets/battle/back-static/README.md +++ b/assets/battle/back-static/README.md @@ -2,7 +2,7 @@ Drop `.png` player back sprites into a generation subfolder. Art is used as authored and is not mirrored; it should face right toward the -opponent. Files may use any pixel dimensions and require no Lua or metadata. +opponent. Files may use 96x96 pixel dimensions and require no Lua or metadata. These local PNGs are ignored by Git. Missing or invalid files fall back to the ROM sprite. @@ -18,14 +18,14 @@ ROM sprite. - `back-static/gen5/.png` Under `BATTLE ART: STATIC`, all five choices read only these ordinary PNGs. -Static GEN 5 never loads or decodes the similarly named animated atlas. +Static GEN5 never loads or decodes the similarly named animated GEN5 atlas. Under `BATTLE ART: ANIMATED`, GEN 3 and GEN 5 use atlases from their `back-animated` folders. GEN 1, GEN 2 and GEN 4 use these same single-frame PNGs. If the selected file is absent or invalid, the ROM backsprite is used instead. An absent `gen1` directory does not remove GEN 1 from the menu; it is an intentional empty slot for ROM-hack -or other user-supplied artwork. +artwork to be dropped or other user-supplied artwork. For a complete Gen 1 set with prepared transparency, the optional importer downloads the 151 Pokemon Yellow Super Game Boy back sprites from @@ -81,9 +81,10 @@ Filenames are lowercase. The same names apply in every battle-art folder. ## Static player-side trainer backs -This folder supplies the static `PLAYER ART` portraits. ANIMATED mode instead -reads the five-pose player strips documented in `../back-animated/`. Professor -Oak and Old Man remain static and always resolve here: +This folder supplies the static `PLAYER ART` portraits. ANIMATED mode normally +reads the five-pose player strips documented in `../back-animated/`, but its +`PLAYER ANIM: PNG` choice also reads `player.png` here. Professor Oak and Old +Man remain static and always resolve here: | Battle role | Expected filename | | --- | --- | diff --git a/lib/AnimatedBattleArt.lua b/lib/AnimatedBattleArt.lua index 9f10bec..1e02d56 100644 --- a/lib/AnimatedBattleArt.lua +++ b/lib/AnimatedBattleArt.lua @@ -131,12 +131,36 @@ end local function restoreTrainer(battle) local state = battle and trainerStates[battle] if not state then return end - if battle.playerBackPic == state.frames[state.frame] then + if battle.playerBackPic == currentImage(state) then battle.playerBackPic = state.original end trainerStates[battle] = nil end +local function updateStaticPlayerTrainer(battle, mode) + local image = BattleArt.namedImage("player", "back") + local state = trainerStates[battle] + if state and (state.kind ~= "static" or state.mode ~= mode + or state.image ~= image) then + restoreTrainer(battle) + state = nil + end + if not image or not battle.playerBackPic then + restoreTrainer(battle) + return + end + if not state then + state = { kind = "static", mode = mode, original = battle.playerBackPic, + image = image } + trainerStates[battle] = state + elseif battle.playerBackPic ~= state.image + and battle.playerBackPic ~= state.original then + trainerStates[battle] = nil + return + end + battle.playerBackPic = state.image +end + -- Five authored poses are tied to SlideTrainerPicOffScreen rather than to a -- free-running clock. Frame one waits with the stationary intro portrait; -- frames two through five divide the 72-pixel leftward walk, then clamp. @@ -146,6 +170,10 @@ local function updatePlayerTrainer(battle, mode) return end local selected = BattleArt.playerAnimationSetting:get() + if selected == "png" then + updateStaticPlayerTrainer(battle, mode) + return + end local def = selected ~= "rom" and PLAYER_SETS[selected] or nil if not def or not battle.playerBackPic then restoreTrainer(battle) @@ -159,7 +187,8 @@ local function updatePlayerTrainer(battle, mode) if not state then local frames = loadFrames(def, mode) if not frames then return end - state = { def = def, mode = mode, original = battle.playerBackPic, + state = { kind = "animated", def = def, mode = mode, + original = battle.playerBackPic, frames = frames, frame = 1 } trainerStates[battle] = state elseif battle.playerBackPic ~= state.frames[state.frame] @@ -331,9 +360,10 @@ function AnimatedBattleArt.hasWorldBack(battler) and battler.sprite == currentImage(state) or false end --- UI scaling needs to distinguish our native-resolution trainer atlas from --- the ROM's deliberately half-resolution back picture. Both occupy the same --- engine field, but only the ROM image should receive the Game Boy 2x scale. +-- UI scaling needs to distinguish any supplied native-resolution player image +-- (the static PNG or an animated atlas frame) from the ROM's deliberately +-- half-resolution back picture. Both occupy the same engine field, but only +-- the ROM image should receive the Game Boy 2x scale. function AnimatedBattleArt.hasPlayerTrainerFrame(battle) local state = battle and trainerStates[battle] return state and battle.playerBackPic == currentImage(state) or false diff --git a/lib/BattleArt.lua b/lib/BattleArt.lua index 41d75f9..64555f3 100644 --- a/lib/BattleArt.lua +++ b/lib/BattleArt.lua @@ -30,8 +30,8 @@ BattleArt.playerArtSetting = ModSetting.new( { "PNG", "GEN 1", "GEN 2", "GEN 3", "GEN 4", "GEN 5", "ASH", "GARY", "ROM" }) BattleArt.playerAnimationSetting = ModSetting.new( "playerAnimatedSet", "PLAYER ANIM", - { "gen1", "gen2", "gen3", "gen4", "gen5", "ash", "gary", "rom" }, - { "GEN 1", "GEN 2", "GEN 3", "GEN 4", "GEN 5", "ASH", "GARY", "ROM" }) + { "png", "gen1", "gen2", "gen3", "gen4", "gen5", "ash", "gary", "rom" }, + { "PNG", "GEN 1", "GEN 2", "GEN 3", "GEN 4", "GEN 5", "ASH", "GARY", "ROM" }, 2) -- BATTLE ART: ROM owns the normal player portrait as completely as it owns -- species art. Keep the visible PLAYER ART row honest instead of leaving a diff --git a/lib/OverworldBattle.lua b/lib/OverworldBattle.lua index d07464a..b40f9f4 100644 --- a/lib/OverworldBattle.lua +++ b/lib/OverworldBattle.lua @@ -268,7 +268,8 @@ end -- display rung smaller than the battle letterbox. At the common 2x layout it -- is therefore native 1x: every source pixel stays a sharp screen pixel while -- the block no longer reaches halfway across the arena and covers the foe. --- The enemy block retains the letterbox scale. +-- The enemy block uses that same compact rung, so neither side dominates the +-- screen merely because its HUD was left at the letterbox scale. -- -- They cannot simply be MOVED there: the engine draws them into the 160x144 UI -- canvas and everything outside it is clipped away. So the layer is rendered to @@ -295,28 +296,28 @@ OverworldBattle.HUD_BAND = { -- which is the whole cost of the snap and is invisible. function OverworldBattle.snapRects(shot) local s = shot.scale - local ps = math.max(1, s - 1) + local hs = math.max(1, s - 1) local e, p = OverworldBattle.HUD_RECT.enemy, OverworldBattle.HUD_RECT.player -- Leave two logical pixels between the foe's name and the window edge. -- The whole enemy band moves with the panel, so long names and HUD shakes -- keep their original internal alignment instead of being clipped. - local ex = (2 - e[1]) * s - local px = shot.pw - (p[1] + p[3]) * ps -- player: right edge to the far side + local ex = (2 - e[1]) * hs + local px = shot.pw - (p[1] + p[3]) * hs -- player: right edge to the far side local rects = { - enemy = { ex + e[1] * s, shot.ly + e[2] * s, e[3] * s, e[4] * s }, - player = { px + p[1] * ps, shot.ly + p[2] * s, - p[3] * ps, p[4] * ps }, + enemy = { ex + e[1] * hs, shot.ly + e[2] * s, e[3] * hs, e[4] * hs }, + player = { px + p[1] * hs, shot.ly + p[2] * s, + p[3] * hs, p[4] * hs }, } local playerBand = OverworldBattle.HUD_BAND.player return rects, { - enemy = { x = ex, y = shot.ly, scale = s }, + enemy = { x = ex, y = shot.ly, scale = hs }, -- Preserve the HUD's original top row while shrinking its internal -- distance from the band boundary. This leaves clear world between its -- lower bracket and the live textbox rather than placing it underneath. player = { x = px, - y = shot.ly + p[2] * s - (p[2] - playerBand[2]) * ps, - scale = ps, + y = shot.ly + p[2] * s - (p[2] - playerBand[2]) * hs, + scale = hs, }, } end @@ -986,8 +987,9 @@ function OverworldBattle.install() if texturing then return 1 end -- The engine's trainer/back slot defaults to 2x because ROM back pictures -- are authored at half display resolution. Supplied species backs and - -- animated player-trainer frames are already full-display art, so both - -- stay native 1x on OG UI. ROM backs retain their intended 2x scale. + -- custom player-trainer PNGs are already full-display art, so both static + -- images and atlas frames stay native 1x on OG UI. ROM backs retain their + -- intended 2x scale. local battle = session and session.battle if side == "back" and pinnedSpeciesMetric() then return 1 end if side == "back" and AnimatedBattleArt.hasPlayerTrainerFrame(battle) then diff --git a/main.lua b/main.lua index 2c00d13..6102b5d 100644 --- a/main.lua +++ b/main.lua @@ -357,9 +357,10 @@ local SETTINGS = { return stagedBattles() and (mode == "static" or mode == "rom") end, full = true }, { BattleArt.playerAnimationSetting, - "Choose the five-frame player trainer atlas under ANIMATED. Playback " - .. "starts with the leftward intro slide, runs once, and never loops. " - .. "Missing art and ROM retain the engine portrait.", + "Choose player.png as a static portrait or a five-frame player trainer " + .. "atlas under ANIMATED. Atlas playback starts with the leftward intro " + .. "slide, runs once, and never loops. Missing art and ROM retain the " + .. "engine portrait.", when = function() return stagedBattles() and BattleArt.setting:get() == "animated" end, full = true }, diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua index fd6551e..255fd37 100644 --- a/tests/dramatic_shape_test.lua +++ b/tests/dramatic_shape_test.lua @@ -1849,6 +1849,12 @@ T.eq(Art.frontAnimationSetting.labels[1], "GEN 1", "the new collection has the expected options-menu label") T.eq(Art.frontAnimationSetting.defaultIndex, 2, "adding Gen 1 does not change the established Gen 2 default") +T.eq(Art.playerAnimationSetting.values[1], "png", + "animated player intros expose the ordinary player.png choice") +T.eq(Art.playerAnimationSetting.labels[1], "PNG", + "the static animated-mode portrait has the expected menu label") +T.eq(Art.playerAnimationSetting.defaultIndex, 2, + "adding PNG does not change the established Gen 1 player default") T.eq(Battles.flashing(nil), false, "no battle, no flash") T.eq(Battles.flashing({ fx = {}, frame = 0 }), false, "a battle with no flash counter is not flashing") @@ -1909,9 +1915,9 @@ do local hudShot = { lx = 100, ly = 12, scale = 3, pw = 1000, ph = 500 } local hudRects, bandPlacement = Battles.snapRects(hudShot) local hudRect = Battles.HUD_RECT -local playerScale = hudShot.scale - 1 +local hudScale = hudShot.scale - 1 -T.eq(hudRects.enemy[1], 2 * hudShot.scale, +T.eq(hudRects.enemy[1], 2 * hudScale, "the foe's panel keeps its two-logical-pixel left inset") T.eq(hudRects.player[1] + hudRects.player[3], hudShot.pw, "and the player's ends at the right one") @@ -1926,14 +1932,16 @@ T.eq(hudRects.enemy[2], hudShot.ly + hudRect.enemy[2] * hudShot.scale, T.eq(hudRects.player[2], hudShot.ly + hudRect.player[2] * hudShot.scale, "and so does the player's") --- The foe retains the frame scale. The near/player block takes one smaller --- INTEGER rung, keeping every source pixel square without covering the foe. -T.eq(hudRects.enemy[3], hudRect.enemy[3] * hudShot.scale, - "a block is its own width at the frame's scale") -T.eq(hudRects.player[3], hudRect.player[3] * playerScale, - "the player block is one integer display rung narrower") -T.eq(hudRects.player[4], hudRect.player[4] * playerScale, +-- Both status blocks take one smaller INTEGER rung, keeping every source +-- pixel square without either block dominating or covering the arena. +T.eq(hudRects.enemy[3], hudRect.enemy[3] * hudScale, + "the foe block is one integer display rung narrower") +T.eq(hudRects.enemy[4], hudRect.enemy[4] * hudScale, "and one integer display rung shorter") +T.eq(hudRects.player[3], hudRect.player[3] * hudScale, + "the player block uses that same compact integer rung") +T.eq(hudRects.player[4], hudRect.player[4] * hudScale, + "and therefore matches the foe's pixel scale") -- the band each block is cut out of is placed so the block lands on the rect -- above; that is what the panel and the glyphs agreeing depends on @@ -1953,8 +1961,8 @@ local snug = { lx = 0, ly = 0, scale = 4, pw = 160 * 4, ph = 144 * 4 } local snugRects = Battles.snapRects(snug) T.eq(snugRects.player[1] + snugRects.player[3], snug.pw, "on a GB-shaped window the compact player block keeps the right edge") -T.eq(snugRects.enemy[1], 2 * snug.scale, - "and the foe retains its readable left inset") +T.eq(snugRects.enemy[1], 2 * (snug.scale - 1), + "and the compact foe retains its readable left inset") -- the bands together cover every row drawHUDs draws into (0-96: the two HUDs, -- the pokeball rows and the safari ball count) and never overlap, so nothing it From 89340e35df8285d98ff3e1c48472e386d832080c Mon Sep 17 00:00:00 2001 From: absol89 Date: Sun, 2 Aug 2026 14:46:10 +0200 Subject: [PATCH 042/116] Fix custom player scale and HUD spacing on OG UI --- CHANGELOG.md | 5 +++++ README.md | 2 +- assets/battle/back-static/README.md | 4 ++++ lib/OverworldBattle.lua | 18 ++++++++++++------ tests/dramatic_shape_test.lua | 11 ++++++----- 5 files changed, 28 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b271fb2..d1aee8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,6 +78,11 @@ - **Matched compact battle HUDs.** Enemy and player Pokémon status blocks now share the same smaller integer display scale and retain their left/right screen-edge anchors. +- **Native static player portraits on OG UI.** Supplied `PLAYER ART` PNGs now + remain at native 1x when OG UI placement is forced. Only the deliberately + half-resolution ROM player portrait receives the engine's 2x UI scale. +- **Player HUD edge spacing.** The compact player status block now sits one + logical pixel left of the right window edge, retaining integer pixel scale. - **No stale battle-entry dimmer.** Staged battles suppress the engine's translucent world-fade rectangle at the compositor boundary as well as clearing its fade state. The normal battle wipe, deliberate battle-exit diff --git a/README.md b/README.md index a819a27..8016cc4 100644 --- a/README.md +++ b/README.md @@ -128,7 +128,7 @@ AUTO placement keeps this intro on OG UI, while WORLD and OG UI remain explicit overrides. Both the original five-by-64 strips and normalized five-by-80 strips are read at native resolution. Unlike the ROM's half-resolution player back, a custom -animated trainer remains 1x when attached to OG UI. Use +static or animated trainer remains 1x when attached to OG UI. Use `tools/player-animation-template-400x80.png` as the authoring guide, then remove its coloured dividers from the finished atlas. `back-static/oak.png` and `back-static/old-man.png` remain the scripted demo diff --git a/assets/battle/back-static/README.md b/assets/battle/back-static/README.md index c9f9687..f24f036 100644 --- a/assets/battle/back-static/README.md +++ b/assets/battle/back-static/README.md @@ -105,3 +105,7 @@ selection tries `player.png`, then retains the ROM trainer backsprite. PNG tries `player.png` directly; ROM deliberately bypasses it. `PLAYER ART` is independent of species `BATTLE ART`, so its ROM choice does not disable custom Pokémon or opponent trainer art. Opponent trainers never read from a back folder. + +When forced onto `BACK PLACEMENT: OG UI`, supplied player PNGs use their native +1x logical size at the normal left-side UI slot. Only the ROM player portrait +receives the engine's legacy 2x back-picture scale. diff --git a/lib/OverworldBattle.lua b/lib/OverworldBattle.lua index b40f9f4..fdf50ce 100644 --- a/lib/OverworldBattle.lua +++ b/lib/OverworldBattle.lua @@ -289,9 +289,10 @@ OverworldBattle.HUD_BAND = { -- Where each block lands, in WORLD-canvas pixels: the panel rect the frosted -- glass is cut to, plus the x its band is blitted at. -- --- The foe's panel starts at the window's left edge and the player's ends at the --- right one. The vertical is untouched, so both stay on the rows the GB put --- them on. A band's own origin sits outside the window by the panel's inset -- +-- The foe's panel starts near the window's left edge and the player panel keeps +-- one logical pixel clear of the right one. The vertical is untouched, so both +-- stay on the rows the GB put them on. A band's own origin sits outside the +-- window by the panel's inset -- -- the couple of pixels a HUD shake can push past the edge are clipped there, -- which is the whole cost of the snap and is invisible. function OverworldBattle.snapRects(shot) @@ -302,7 +303,8 @@ function OverworldBattle.snapRects(shot) -- The whole enemy band moves with the panel, so long names and HUD shakes -- keep their original internal alignment instead of being clipped. local ex = (2 - e[1]) * hs - local px = shot.pw - (p[1] + p[3]) * hs -- player: right edge to the far side + -- Keep one logical pixel of breathing room at the right window edge. + local px = shot.pw - (p[1] + p[3] + 1) * hs local rects = { enemy = { ex + e[1] * hs, shot.ly + e[2] * s, e[3] * hs, e[4] * hs }, player = { px + p[1] * hs, shot.ly + p[2] * s, @@ -988,10 +990,14 @@ function OverworldBattle.install() -- The engine's trainer/back slot defaults to 2x because ROM back pictures -- are authored at half display resolution. Supplied species backs and -- custom player-trainer PNGs are already full-display art, so both static - -- images and atlas frames stay native 1x on OG UI. ROM backs retain their - -- intended 2x scale. + -- PLAYER ART images and animated atlas frames stay native 1x on OG UI. + -- ROM backs retain their intended 2x scale. local battle = session and session.battle if side == "back" and pinnedSpeciesMetric() then return 1 end + if side == "back" and battle and battle.showPlayerBack + and BattleArt.isExternal(battle.playerBackPic) then + return 1 + end if side == "back" and AnimatedBattleArt.hasPlayerTrainerFrame(battle) then return 1 end diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua index 255fd37..3cccf6c 100644 --- a/tests/dramatic_shape_test.lua +++ b/tests/dramatic_shape_test.lua @@ -1919,8 +1919,8 @@ local hudScale = hudShot.scale - 1 T.eq(hudRects.enemy[1], 2 * hudScale, "the foe's panel keeps its two-logical-pixel left inset") -T.eq(hudRects.player[1] + hudRects.player[3], hudShot.pw, - "and the player's ends at the right one") +T.eq(hudRects.player[1] + hudRects.player[3], hudShot.pw - hudScale, + "and the player's keeps a one-logical-pixel right margin") T.check(hudRects.enemy[1] < hudShot.lx, "so the foe's block has left the letterbox it used to sit in") T.check(hudRects.player[1] > hudShot.lx + hudRect.player[1] * hudShot.scale, @@ -1956,11 +1956,12 @@ T.eq(bandPlacement.player.y * bandPlacement.player.scale, hudRects.player[2], "the compact band preserves the player's top row") --- Even on a GB-shaped window the compact player block remains right-anchored. +-- Even on a GB-shaped window the compact player block remains right-anchored, +-- with the same one-logical-pixel breathing room. local snug = { lx = 0, ly = 0, scale = 4, pw = 160 * 4, ph = 144 * 4 } local snugRects = Battles.snapRects(snug) -T.eq(snugRects.player[1] + snugRects.player[3], snug.pw, - "on a GB-shaped window the compact player block keeps the right edge") +T.eq(snugRects.player[1] + snugRects.player[3], snug.pw - (snug.scale - 1), + "on a GB-shaped window the player block keeps its one-pixel margin") T.eq(snugRects.enemy[1], 2 * (snug.scale - 1), "and the compact foe retains its readable left inset") From 4cb4f8c6b7c3dc98de2c0cd54aefc97cfd9b593e Mon Sep 17 00:00:00 2001 From: absol89 Date: Sun, 2 Aug 2026 14:56:02 +0200 Subject: [PATCH 043/116] Keep OG UI back sprites inside canvas bounds --- CHANGELOG.md | 7 +++++-- lib/OverworldBattle.lua | 14 ++++++++++---- tests/dramatic_shape_test.lua | 21 ++++++++++++++------- 3 files changed, 29 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d1aee8d..ce4f39d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -81,8 +81,11 @@ - **Native static player portraits on OG UI.** Supplied `PLAYER ART` PNGs now remain at native 1x when OG UI placement is forced. Only the deliberately half-resolution ROM player portrait receives the engine's 2x UI scale. -- **Player HUD edge spacing.** The compact player status block now sits one - logical pixel left of the right window edge, retaining integer pixel scale. +- **Player HUD edge spacing.** The compact player status block now sits two + logical pixels left of the right window edge, retaining integer pixel scale. +- **Safe OG UI back-sprite bounds.** Wide supplied Pokemon backs retain the + classic centre anchor when possible and shift right only enough to keep + their opaque silhouette from clipping through the left UI-canvas edge. - **No stale battle-entry dimmer.** Staged battles suppress the engine's translucent world-fade rectangle at the compositor boundary as well as clearing its fade state. The normal battle wipe, deliberate battle-exit diff --git a/lib/OverworldBattle.lua b/lib/OverworldBattle.lua index fdf50ce..f67814b 100644 --- a/lib/OverworldBattle.lua +++ b/lib/OverworldBattle.lua @@ -136,7 +136,13 @@ function OverworldBattle.backPinOffset(metric, grow) local scale = tonumber(grow) or 1 local left = 8 + metric.w * (1 - scale) / 2 local center = left + metric.center * scale - return OverworldBattle.ANCHOR.player[1] - center, + local dx = OverworldBattle.ANCHOR.player[1] - center + -- Wide supplied backs can extend farther left of the classic 32px slot + -- than its centre anchor has room for. Keep that centre when it fits, but + -- never let the opaque silhouette cross the UI canvas edge. + local opaqueLeft = left + (metric.x0 or 0) * scale + dx = math.max(dx, 1 - opaqueLeft) + return dx, (metric.padBottom or 0) * scale end @@ -290,7 +296,7 @@ OverworldBattle.HUD_BAND = { -- glass is cut to, plus the x its band is blitted at. -- -- The foe's panel starts near the window's left edge and the player panel keeps --- one logical pixel clear of the right one. The vertical is untouched, so both +-- two logical pixels clear of the right one. The vertical is untouched, so both -- stay on the rows the GB put them on. A band's own origin sits outside the -- window by the panel's inset -- -- the couple of pixels a HUD shake can push past the edge are clipped there, @@ -303,8 +309,8 @@ function OverworldBattle.snapRects(shot) -- The whole enemy band moves with the panel, so long names and HUD shakes -- keep their original internal alignment instead of being clipped. local ex = (2 - e[1]) * hs - -- Keep one logical pixel of breathing room at the right window edge. - local px = shot.pw - (p[1] + p[3] + 1) * hs + -- Keep two logical pixels of breathing room at the right window edge. + local px = shot.pw - (p[1] + p[3] + 2) * hs local rects = { enemy = { ex + e[1] * hs, shot.ly + e[2] * s, e[3] * hs, e[4] * hs }, player = { px + p[1] * hs, shot.ly + p[2] * s, diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua index 3cccf6c..216135b 100644 --- a/tests/dramatic_shape_test.lua +++ b/tests/dramatic_shape_test.lua @@ -1919,8 +1919,8 @@ local hudScale = hudShot.scale - 1 T.eq(hudRects.enemy[1], 2 * hudScale, "the foe's panel keeps its two-logical-pixel left inset") -T.eq(hudRects.player[1] + hudRects.player[3], hudShot.pw - hudScale, - "and the player's keeps a one-logical-pixel right margin") +T.eq(hudRects.player[1] + hudRects.player[3], hudShot.pw - 2 * hudScale, + "and the player's keeps a two-logical-pixel right margin") T.check(hudRects.enemy[1] < hudShot.lx, "so the foe's block has left the letterbox it used to sit in") T.check(hudRects.player[1] > hudShot.lx + hudRect.player[1] * hudShot.scale, @@ -1957,11 +1957,12 @@ T.eq(bandPlacement.player.y hudRects.player[2], "the compact band preserves the player's top row") -- Even on a GB-shaped window the compact player block remains right-anchored, --- with the same one-logical-pixel breathing room. +-- with the same two-logical-pixel breathing room. local snug = { lx = 0, ly = 0, scale = 4, pw = 160 * 4, ph = 144 * 4 } local snugRects = Battles.snapRects(snug) -T.eq(snugRects.player[1] + snugRects.player[3], snug.pw - (snug.scale - 1), - "on a GB-shaped window the player block keeps its one-pixel margin") +T.eq(snugRects.player[1] + snugRects.player[3], + snug.pw - 2 * (snug.scale - 1), + "on a GB-shaped window the player block keeps its two-pixel margin") T.eq(snugRects.enemy[1], 2 * (snug.scale - 1), "and the compact foe retains its readable left inset") @@ -2048,12 +2049,13 @@ T.eq(Battles.backPinned(), false, T.eq(Battles.backSetting:get(), true, "without being rewritten underneath") -- Supplied back PNGs are already full-display art. Their visible silhouette, --- rather than the outer canvas, owns the classic x=26 / y=96 back-pic anchor. +-- rather than the outer canvas, owns the classic x=26 / y=96 back-pic anchor +-- whenever it fits. Wide full-size art is clamped inside the left canvas edge. -- Symmetric grow-in changes size around the same centre, while transparent -- rows below the feet are compensated in proportion to the current growth. local pinX, pinY = Battles.backPinOffset( { w = 80, center = 40, padBottom = 18 }, 1) -T.eq(pinX, -22, "an 80px supplied back is centred on the classic OG UI anchor") +T.eq(pinX, -7, "a wide supplied back keeps one opaque pixel inside the canvas") T.eq(pinY, 18, "transparent rows below its feet are moved beneath the anchor") local growX, growY = Battles.backPinOffset( @@ -2067,6 +2069,11 @@ T.eq(unevenX, -19.5, "an asymmetric silhouette is anchored by its alpha centre, not canvas centre") T.eq(unevenY, 5, "asymmetric art still lands its visible foot on the UI") +local paddedX = Battles.backPinOffset( + { w = 96, x0 = 24, center = 58, padBottom = 0 }, 1) +T.eq(paddedX, -31, + "transparent left padding is spent before a wide silhouette is shifted") + -- ...so the row comes off the menu with it, on the same reasoning the mod's -- other absent rows come off: a row that no longer decides anything is worse -- than no row From 80c0727a360abeb031c39cd8df574df16951d36f Mon Sep 17 00:00:00 2001 From: absol89 Date: Sun, 2 Aug 2026 15:54:31 +0200 Subject: [PATCH 044/116] Add clean bring-your-own-art package builder --- CHANGELOG.md | 3 ++ README.md | 3 ++ tools/package_clean_mod.ps1 | 98 +++++++++++++++++++++++++++++++++++++ 3 files changed, 104 insertions(+) create mode 100644 tools/package_clean_mod.ps1 diff --git a/CHANGELOG.md b/CHANGELOG.md index ce4f39d..61d2c64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -86,6 +86,9 @@ - **Safe OG UI back-sprite bounds.** Wide supplied Pokemon backs retain the classic centre anchor when possible and shift right only enough to keep their opaque silhouette from clipping through the left UI-canvas edge. +- **Clean BYO package builder.** `tools/package_clean_mod.ps1` creates an + installable ZIP with the complete documented battle-art folder layout while + excluding every local PNG below `assets/battle`. - **No stale battle-entry dimmer.** Staged battles suppress the engine's translucent world-fade rectangle at the compositor boundary as well as clearing its fade state. The normal battle wipe, deliberate battle-exit diff --git a/README.md b/README.md index 8016cc4..fee7c43 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,9 @@ files fall straight back to the ROM art. The four battle-art directories are intentionally ignored by Git except for their README contracts. `tools/package_mod.ps1` nevertheless includes local PNGs from them in a test ZIP, so artwork can stay private and uncommitted. +Use `tools/package_clean_mod.ps1` for a shareable install ZIP that preserves +the documented battle-art folder layout but excludes every PNG below +`assets/battle`. `BATTLE ART: ANIMATED` reads independent `ANIM FRONT GEN` and `BACK ART SET` choices. GEN 1 fronts are ordinary single-frame PNGs from `front-animated/gen1`; Gen 2–5 fronts remain animated atlases, including diff --git a/tools/package_clean_mod.ps1 b/tools/package_clean_mod.ps1 new file mode 100644 index 0000000..2fd6c34 --- /dev/null +++ b/tools/package_clean_mod.ps1 @@ -0,0 +1,98 @@ +param( + [string]$Output = "" +) + +$ErrorActionPreference = "Stop" +$repo = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path + +function Relative-Path([string]$FullName) { + return $FullName.Substring($repo.Length + 1).Replace('\', '/') +} + +if (-not $Output) { + $Output = Join-Path (Split-Path $repo -Parent) ` + "DramaticShapeVoxelMod-battle-art-clean.zip" +} +$Output = [System.IO.Path]::GetFullPath($Output) + +# Match the installable runtime allowlist used by package_mod.ps1. +$source = @() +foreach ($dir in @('data', 'lib')) { + $source += Get-ChildItem -LiteralPath (Join-Path $repo $dir) -Recurse -File | + ForEach-Object { Relative-Path $_.FullName } +} +$source += @('CHANGELOG.md', 'main.lua', 'manifest.json', 'mod.card', 'README.md') + +$battleRoot = Join-Path $repo 'assets\battle' +$battleDirs = @(Get-ChildItem -LiteralPath $battleRoot -Recurse -Directory | + ForEach-Object { (Relative-Path $_.FullName).TrimEnd('/') + '/' }) +$battleDirs += 'assets/battle/' + +# Keep contracts and future non-art metadata, but never ship local battle PNGs. +$battleFiles = @(Get-ChildItem -LiteralPath $battleRoot -Recurse -File -Force | + Where-Object { $_.Extension -ine '.png' } | + ForEach-Object { Relative-Path $_.FullName }) + +$entries = @($source + $battleDirs + $battleFiles | Sort-Object -Unique) +if (-not $entries.Count) { throw "no package entries found" } + +if (Test-Path -LiteralPath $Output) { + Remove-Item -LiteralPath $Output -Force +} + +Add-Type -AssemblyName System.IO.Compression +Add-Type -AssemblyName System.IO.Compression.FileSystem +$archive = [System.IO.Compression.ZipFile]::Open( + $Output, + [System.IO.Compression.ZipArchiveMode]::Create +) +try { + foreach ($entry in $entries) { + if ($entry.EndsWith('/')) { + # A ZIP directory entry keeps an otherwise-empty BYO generation folder + # visible without recursively collecting anything stored below it. + [void]$archive.CreateEntry($entry) + continue + } + $fullName = Join-Path $repo ($entry.Replace('/', '\')) + if (-not (Test-Path -LiteralPath $fullName -PathType Leaf)) { + throw "package source file is missing: $entry" + } + [void][System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile( + $archive, + $fullName, + $entry, + [System.IO.Compression.CompressionLevel]::Optimal + ) + } +} finally { + $archive.Dispose() +} + +$checkArchive = [System.IO.Compression.ZipFile]::OpenRead($Output) +try { + $packed = @($checkArchive.Entries | ForEach-Object { $_.FullName }) +} finally { + $checkArchive.Dispose() +} +$battlePngs = @($packed | Where-Object { + $_ -match '(?i)^assets/battle/.*\.png$' +}) +if ($battlePngs.Count) { + throw "clean package unexpectedly contains battle PNGs: $($battlePngs -join ', ')" +} + +foreach ($required in @('manifest.json', 'main.lua', 'mod.card')) { + if ($packed -notcontains $required) { + throw "clean package is missing required install entry: $required" + } +} + +[PSCustomObject]@{ + Path = $Output + Entries = $packed.Count + BattlePngs = $battlePngs.Count + BattleFolders = $battleDirs.Count + Bytes = (Get-Item -LiteralPath $Output).Length + SHA256 = (Get-FileHash -LiteralPath $Output -Algorithm SHA256).Hash +} From 68c78898aae9f31502ee9b4cd9b0a237cc52a5f1 Mon Sep 17 00:00:00 2001 From: absol89 Date: Sun, 2 Aug 2026 16:04:51 +0200 Subject: [PATCH 045/116] Include public tools in clean package --- CHANGELOG.md | 4 +++- README.md | 5 ++++- tools/package_clean_mod.ps1 | 9 +++++++-- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 61d2c64..0e3db19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -88,7 +88,9 @@ their opaque silhouette from clipping through the left UI-canvas edge. - **Clean BYO package builder.** `tools/package_clean_mod.ps1` creates an installable ZIP with the complete documented battle-art folder layout while - excluding every local PNG below `assets/battle`. + excluding every local PNG below `assets/battle`. It includes the public + import/download and packaging toolkit for ZIP-only distribution, but omits + generated Python bytecode caches. - **No stale battle-entry dimmer.** Staged battles suppress the engine's translucent world-fade rectangle at the compositor boundary as well as clearing its fade state. The normal battle wipe, deliberate battle-exit diff --git a/README.md b/README.md index fee7c43..46310d9 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,10 @@ their README contracts. `tools/package_mod.ps1` nevertheless includes local PNGs from them in a test ZIP, so artwork can stay private and uncommitted. Use `tools/package_clean_mod.ps1` for a shareable install ZIP that preserves the documented battle-art folder layout but excludes every PNG below -`assets/battle`. +`assets/battle`. The clean ZIP includes the complete public `tools` folder, so +users who receive only the archive can import their own art and rebuild it +without cloning the repository. Generated Python `__pycache__`/`.pyc` files +remain excluded. `BATTLE ART: ANIMATED` reads independent `ANIM FRONT GEN` and `BACK ART SET` choices. GEN 1 fronts are ordinary single-frame PNGs from `front-animated/gen1`; Gen 2–5 fronts remain animated atlases, including diff --git a/tools/package_clean_mod.ps1 b/tools/package_clean_mod.ps1 index 2fd6c34..6789f7d 100644 --- a/tools/package_clean_mod.ps1 +++ b/tools/package_clean_mod.ps1 @@ -15,10 +15,15 @@ if (-not $Output) { } $Output = [System.IO.Path]::GetFullPath($Output) -# Match the installable runtime allowlist used by package_mod.ps1. +# Match the installable runtime allowlist used by package_mod.ps1, then add +# the public authoring toolkit so a ZIP recipient does not need a Git clone. $source = @() -foreach ($dir in @('data', 'lib')) { +foreach ($dir in @('data', 'lib', 'tools')) { $source += Get-ChildItem -LiteralPath (Join-Path $repo $dir) -Recurse -File | + Where-Object { + $_.Extension -ine '.pyc' -and + $_.FullName -notmatch '(?i)[\\/]__pycache__[\\/]' + } | ForEach-Object { Relative-Path $_.FullName } } $source += @('CHANGELOG.md', 'main.lua', 'manifest.json', 'mod.card', 'README.md') From ec33147c437b87f64116d767e4ba80e0b798e2f8 Mon Sep 17 00:00:00 2001 From: absol89 Date: Mon, 3 Aug 2026 00:38:05 +0200 Subject: [PATCH 046/116] tests: assert Battle Art options rows by label --- tests/dramatic_shape_test.lua | 30 ++++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua index 503f566..bcc95ac 100644 --- a/tests/dramatic_shape_test.lua +++ b/tests/dramatic_shape_test.lua @@ -384,11 +384,17 @@ end Pipelines.setLevel("voxel", 2) local hookedRows = Runtime.call("ui.options.rows", function(_, r) return r end, { data = Data }, { { id = "text_speed" } }) -T.eq(#hookedRows, 8, "the options hook added a row per setting") -local grid, curve, water = hookedRows[2], hookedRows[3], hookedRows[4] -local battles, backRow, daytime = hookedRows[5], hookedRows[6], hookedRows[7] --- the AA row is hookedRows[8]; it is read in its own block below, because --- this chunk is one main function and has 200 local slots to spend +T.eq(#hookedRows, 13, + "the options hook added the upstream and visible Battle Art settings") +local hookedByLabel = {} +for _, row in ipairs(hookedRows) do hookedByLabel[row.label] = row end +local grid, curve, water = hookedByLabel["V-GRID"], hookedByLabel["V-CURVE"], + hookedByLabel.WATER +local battles, battleArt, daytime = hookedByLabel["3D-BTL"], + hookedByLabel["BATTLE ART"], + hookedByLabel.DAYTIME +-- the AA row is read in its own block below, because this chunk is one main +-- function and has 200 local slots to spend T.eq(water.label, "WATER", "the water row carries its label") T.eq(water.value(), "FULL", "and defaults to FULL -- reflections are the point of having the row") @@ -406,12 +412,12 @@ T.eq(battles.label, "3D-BTL", "the overworld-battle row carries its label") T.eq(battles.value(), "ON", "overworld battles are on by default -- the mode's headline is the world " .. "in 3D, and a battle is where the player spends half the game") -T.eq(backRow.label, "BACK SPRITES", "the back-pic row carries its label") -T.eq(backRow.value(), "OFF", - "and is off by default -- what the mode advertises is BOTH mons out on the " - .. "map, so the classic slot is opt-in") -T.check(backRow.id ~= battles.id and backRow.id:find("battleBack", 1, true), - "on its own key, so it persists beside 3D-BTL rather than over it") +T.eq(battleArt.value(), "STATIC", + "the merged menu retains the bring-your-own-art default") +T.eq(hookedByLabel.PLAYER.value(), "FRONT SPRITES", + "the Battle Art player-view row supersedes upstream's boolean back row") +T.eq(hookedByLabel["BACK PLACEMENT"].value(), "AUTO", + "and its mode-aware placement row remains available") -- stepping writes through to the one place both rows read local settingGame = { save = { options = {} }, mods = { modOptions = {} } } @@ -477,7 +483,7 @@ do local AntiAlias = run.loader.exports.DRAMATIC_SHAPE.lib.require("AntiAlias") local VoxelGrid = run.loader.exports.DRAMATIC_SHAPE.lib.require("VoxelGrid") local aaGame = { save = { options = {} }, mods = { modOptions = {} } } -local aa = hookedRows[8] +local aa = hookedByLabel.AA T.eq(aa.label, "AA", "the anti-aliasing row carries its label") T.eq(aa.value(), "OFF", "and starts off -- supersampling is a cost knob, and a mod must not spend " From e64dc5d6572bab0ad80c52b830d5b91cf3655548 Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Sat, 1 Aug 2026 13:29:51 +0200 Subject: [PATCH 047/116] fix(ios): pin voxel render target scale --- lib/PixelCanvas.lua | 9 +++++++++ lib/ShadowMap.lua | 2 +- lib/TiltShift.lua | 6 ++++-- lib/Voxel3D.lua | 3 ++- 4 files changed, 16 insertions(+), 4 deletions(-) create mode 100644 lib/PixelCanvas.lua diff --git a/lib/PixelCanvas.lua b/lib/PixelCanvas.lua new file mode 100644 index 0000000..ca25769 --- /dev/null +++ b/lib/PixelCanvas.lua @@ -0,0 +1,9 @@ +local V = ... + +local PixelCanvas = {} + +function PixelCanvas.new(w, h) + return pcall(love.graphics.newCanvas, w, h, { dpiscale = 1 }) +end + +return PixelCanvas diff --git a/lib/ShadowMap.lua b/lib/ShadowMap.lua index 18ceffd..d5afc76 100644 --- a/lib/ShadowMap.lua +++ b/lib/ShadowMap.lua @@ -186,7 +186,7 @@ end local function getCanvas(res) if canvas == false then return nil end if canvas and canvasRes == res then return canvas end - local ok, c = pcall(love.graphics.newCanvas, res, res) + local ok, c = V.require("PixelCanvas").new(res, res) if not (ok and c) then canvas = false return nil diff --git a/lib/TiltShift.lua b/lib/TiltShift.lua index 6929101..4b51a27 100644 --- a/lib/TiltShift.lua +++ b/lib/TiltShift.lua @@ -25,6 +25,7 @@ -- failure -- headless, no shader support) apply() hands the canvas back -- untouched, so every other path is byte-for-byte what it always was. +local V = ... local TiltShift = {} TiltShift.level = 0 @@ -87,9 +88,10 @@ end local function getCanvases(w, h) if not ping or cw ~= w or ch ~= h then - local ok, a = pcall(love.graphics.newCanvas, w, h) + local PixelCanvas = V.require("PixelCanvas") + local ok, a = PixelCanvas.new(w, h) if not ok then return nil end - local okB, b = pcall(love.graphics.newCanvas, w, h) + local okB, b = PixelCanvas.new(w, h) if not okB then return nil end -- the gaussian's fractional tap offsets need linear filtering a:setFilter("linear", "linear") diff --git a/lib/Voxel3D.lua b/lib/Voxel3D.lua index dc49ecc..3e2f436 100644 --- a/lib/Voxel3D.lua +++ b/lib/Voxel3D.lua @@ -33,6 +33,7 @@ local WorldCurve = V.require("WorldCurve") local Sky = V.require("Sky") local DayNight = V.require("DayNight") local GlassMask = V.require("GlassMask") +local PixelCanvas = V.require("PixelCanvas") local Voxel3D = {} @@ -644,7 +645,7 @@ function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky, slot) local name = slot or "world" local slotHeld = slots[name] if not (slotHeld and slotHeld.w == w and slotHeld.h == h) then - local ok, c = pcall(love.graphics.newCanvas, w, h) + local ok, c = PixelCanvas.new(w, h) if not ok then return false end c:setFilter("nearest", "nearest") if slotHeld then releaseSlot(slotHeld) end From 6dda3e4fb97d7b841c7daabb1797e7628a592920 Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Sat, 1 Aug 2026 14:37:22 +0200 Subject: [PATCH 048/116] fix(ios): use decal shadows on Metal --- lib/ShadowMap.lua | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/ShadowMap.lua b/lib/ShadowMap.lua index d5afc76..8997a49 100644 --- a/lib/ShadowMap.lua +++ b/lib/ShadowMap.lua @@ -221,6 +221,9 @@ end -- where the canvas cannot be made -- VoxelScene then keeps the flat decal -- shadows, which need nothing but a quad. function ShadowMap.available() + if love.system and love.system.getOS and love.system.getOS() == "iOS" then + return false + end if not (love.graphics and love.graphics.newCanvas and love.graphics.setDepthMode) then return false From 9c458d31fcede873e092339c4b82a435ca16d1d4 Mon Sep 17 00:00:00 2001 From: absol89 Date: Mon, 3 Aug 2026 05:04:49 +0200 Subject: [PATCH 049/116] fix(ios): non-destructive backport of battle HUD fixes from upstream --- lib/OverworldBattle.lua | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/lib/OverworldBattle.lua b/lib/OverworldBattle.lua index baad380..0e76ab8 100644 --- a/lib/OverworldBattle.lua +++ b/lib/OverworldBattle.lua @@ -370,7 +370,11 @@ end -- -- nil when no overworld battle is running. Never more than one: battles do -- not nest. -session = nil +local session = nil + +local function isIOS() + return love.system and love.system.getOS and love.system.getOS() == "iOS" +end local function game() return require("src.core.Game") @@ -566,8 +570,9 @@ function OverworldBattle.update(dt) -- touches the finished arena. The HUDs go directly ON that backdrop, -- snapped out to the window's own -- edges (snapHUDs). Here rather than in the battle's draw for the same - -- reason the scene is: it binds a canvas of its own. Their only contrast - -- treatment is pure white ink plus a dark one-pixel shadow. + -- reason the scene is: it binds a canvas of its own. After the frost, so + -- the glass is frosted from the world alone and never from the glyphs + -- about to sit on it. local okHud, up = pcall(OverworldBattle.snapHUDs, session.battle, shot) session.snapped = (okHud and up) and true or false -- once per battle, not once per frame: a driver that cannot do this cannot From 2fd0aff55c3cdd9c0f50752d4009074bc191cfa8 Mon Sep 17 00:00:00 2001 From: absol89 Date: Mon, 3 Aug 2026 06:04:48 +0200 Subject: [PATCH 050/116] chore: bump mod version to 1.5.3 --- main.lua | 2 +- manifest.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/main.lua b/main.lua index 79f1186..37cf4f0 100644 --- a/main.lua +++ b/main.lua @@ -981,7 +981,7 @@ mod.hooks:wrap("world.tod", function(next, tod, ctx) return DayNight.tod() end) -mod.exports.version = "1.5.0" +mod.exports.version = "1.5.3" -- exposed so a companion mod can pin its own tiles' shapes or read the -- camera without reaching into this mod's file layout mod.exports.lib = V diff --git a/manifest.json b/manifest.json index 58f33a1..b8f4b7d 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "DRAMATIC_SHAPE", "name": "DRAMATIC SHAPE VOXEL MOD BATTLE ART", - "version": "1.5.1", + "version": "1.5.3", "api": 2, "entry": "main.lua", "profile": "content", From c5433fc8ec73de79c6436eafb3cc1b6db4f7c123 Mon Sep 17 00:00:00 2001 From: absol89 Date: Mon, 3 Aug 2026 06:31:14 +0200 Subject: [PATCH 051/116] revert: restore transparent snapHUDs on iOS for battle-art fork --- lib/OverworldBattle.lua | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/lib/OverworldBattle.lua b/lib/OverworldBattle.lua index 0e76ab8..baad380 100644 --- a/lib/OverworldBattle.lua +++ b/lib/OverworldBattle.lua @@ -370,11 +370,7 @@ end -- -- nil when no overworld battle is running. Never more than one: battles do -- not nest. -local session = nil - -local function isIOS() - return love.system and love.system.getOS and love.system.getOS() == "iOS" -end +session = nil local function game() return require("src.core.Game") @@ -570,9 +566,8 @@ function OverworldBattle.update(dt) -- touches the finished arena. The HUDs go directly ON that backdrop, -- snapped out to the window's own -- edges (snapHUDs). Here rather than in the battle's draw for the same - -- reason the scene is: it binds a canvas of its own. After the frost, so - -- the glass is frosted from the world alone and never from the glyphs - -- about to sit on it. + -- reason the scene is: it binds a canvas of its own. Their only contrast + -- treatment is pure white ink plus a dark one-pixel shadow. local okHud, up = pcall(OverworldBattle.snapHUDs, session.battle, shot) session.snapped = (okHud and up) and true or false -- once per battle, not once per frame: a driver that cannot do this cannot From 19b42a87840b558583ce10d43c7136f64eb6e6eb Mon Sep 17 00:00:00 2001 From: absol89 Date: Mon, 3 Aug 2026 06:35:25 +0200 Subject: [PATCH 052/116] chore: bump version to 1.6.0 --- main.lua | 2 +- manifest.json | 2 +- tools/README.txt | 7 +++++++ 3 files changed, 9 insertions(+), 2 deletions(-) create mode 100644 tools/README.txt diff --git a/main.lua b/main.lua index 37cf4f0..ec0ad7b 100644 --- a/main.lua +++ b/main.lua @@ -981,7 +981,7 @@ mod.hooks:wrap("world.tod", function(next, tod, ctx) return DayNight.tod() end) -mod.exports.version = "1.5.3" +mod.exports.version = "1.6.0" -- exposed so a companion mod can pin its own tiles' shapes or read the -- camera without reaching into this mod's file layout mod.exports.lib = V diff --git a/manifest.json b/manifest.json index b8f4b7d..893d02e 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "DRAMATIC_SHAPE", "name": "DRAMATIC SHAPE VOXEL MOD BATTLE ART", - "version": "1.5.3", + "version": "1.6.0", "api": 2, "entry": "main.lua", "profile": "content", diff --git a/tools/README.txt b/tools/README.txt new file mode 100644 index 0000000..930887e --- /dev/null +++ b/tools/README.txt @@ -0,0 +1,7 @@ +Build by executing this command in powershell in the folder above. Or adjust the tools part and run it in this folder + +With your gitignored art (package full art build) +powershell -NoProfile -ExecutionPolicy Bypass -File .\tools\package_mod.ps1 + +Without your gitignored art (package clean build) - including the tools & scripts +powershell -NoProfile -ExecutionPolicy Bypass -File .\tools\package_clean_mod.ps1 \ No newline at end of file From 37c16ee29ff46ce8d02920f44148b76204c42670 Mon Sep 17 00:00:00 2001 From: absol89 Date: Mon, 3 Aug 2026 14:32:15 +0200 Subject: [PATCH 053/116] chore: point github to fork, bump version to 1.60 --- main.lua | 2 +- manifest.json | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/main.lua b/main.lua index ec0ad7b..bd1ca5c 100644 --- a/main.lua +++ b/main.lua @@ -981,7 +981,7 @@ mod.hooks:wrap("world.tod", function(next, tod, ctx) return DayNight.tod() end) -mod.exports.version = "1.6.0" +mod.exports.version = "1.60" -- exposed so a companion mod can pin its own tiles' shapes or read the -- camera without reaching into this mod's file layout mod.exports.lib = V diff --git a/manifest.json b/manifest.json index 893d02e..6dd4a79 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "DRAMATIC_SHAPE", "name": "DRAMATIC SHAPE VOXEL MOD BATTLE ART", - "version": "1.6.0", + "version": "1.60", "api": 2, "entry": "main.lua", "profile": "content", @@ -16,5 +16,5 @@ ], "affects_link": false, "description": "A full 3D diorama overworld: extruded terrain, depth-buffered occlusion, voxel characters and a tilt-shift miniature pass -- and battles fought on the map itself, shot over the shoulder at the nearest clear ground with a slow parallax drift and a depth-of-field pass. Water reflects the sky, the sun, the moon and -- through a screen-space ray march -- the shoreline standing behind it. Registers two render pipelines and claims hotkeys 3, 5, 6, 7, 8 and 9 -- 3 and 5 displace the engine's TILT and GBC FX keys, both still reachable on the OPTIONS menu. Presentational only: it changes what a battle is drawn over, never where anybody stands.", - "github": "DramaticShape/DramaticShapeVoxelMod" + "github": "absol89/DramaticShapeVoxelMod" } From 279671d5e6aa7ed419bc18417ad063327b80ad82 Mon Sep 17 00:00:00 2001 From: absol89 Date: Mon, 3 Aug 2026 15:00:24 +0200 Subject: [PATCH 054/116] feat(player anim): add RED five-pose player intro strip --- assets/battle/back-animated/README.md | 1 + data/animated_player_trainers.lua | 1 + lib/BattleArt.lua | 4 ++-- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/assets/battle/back-animated/README.md b/assets/battle/back-animated/README.md index fc06203..3e436dc 100644 --- a/assets/battle/back-animated/README.md +++ b/assets/battle/back-animated/README.md @@ -27,6 +27,7 @@ below read five-pose strips from this folder: | GEN 5 | `gen5player.png` | | ASH | `ashplayer.png` | | GARY | `garyplayer.png` | +| RED | `redplayer.png` | | ROM | no file; use the engine portrait | Each named strip is one horizontal row of exactly five equal-width frames. diff --git a/data/animated_player_trainers.lua b/data/animated_player_trainers.lua index e2d68a2..4eb7b60 100644 --- a/data/animated_player_trainers.lua +++ b/data/animated_player_trainers.lua @@ -18,4 +18,5 @@ return { gen5 = five("gen5player.png"), ash = five("ashplayer.png"), gary = five("garyplayer.png"), + red = five("redplayer.png"), } diff --git a/lib/BattleArt.lua b/lib/BattleArt.lua index 64555f3..a14733f 100644 --- a/lib/BattleArt.lua +++ b/lib/BattleArt.lua @@ -30,8 +30,8 @@ BattleArt.playerArtSetting = ModSetting.new( { "PNG", "GEN 1", "GEN 2", "GEN 3", "GEN 4", "GEN 5", "ASH", "GARY", "ROM" }) BattleArt.playerAnimationSetting = ModSetting.new( "playerAnimatedSet", "PLAYER ANIM", - { "png", "gen1", "gen2", "gen3", "gen4", "gen5", "ash", "gary", "rom" }, - { "PNG", "GEN 1", "GEN 2", "GEN 3", "GEN 4", "GEN 5", "ASH", "GARY", "ROM" }, 2) + { "png", "gen1", "gen2", "gen3", "gen4", "gen5", "ash", "gary", "red", "rom" }, + { "PNG", "GEN 1", "GEN 2", "GEN 3", "GEN 4", "GEN 5", "ASH", "GARY", "RED", "ROM" }, 2) -- BATTLE ART: ROM owns the normal player portrait as completely as it owns -- species art. Keep the visible PLAYER ART row honest instead of leaving a From 0c787cfd080e0051274fb2e2eacb0f8cc2d72473 Mon Sep 17 00:00:00 2001 From: absol89 Date: Mon, 3 Aug 2026 22:04:40 +0200 Subject: [PATCH 055/116] fix(trainer): set opponent trainer card scale to 1x; bump version to 1.63 --- lib/OverworldBattle.lua | 2 +- main.lua | 2 +- manifest.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/OverworldBattle.lua b/lib/OverworldBattle.lua index baad380..434e1d7 100644 --- a/lib/OverworldBattle.lua +++ b/lib/OverworldBattle.lua @@ -749,7 +749,7 @@ end -- known row. That known point is what the quad is then hung from. local TEX_AX, TEX_AY = 80, 96 -- forced pic centre and baseline local TRAINER_AX, TRAINER_AY = 124, 56 -- the intro trainer pic's own slot -local OPPONENT_TRAINER_SCALE = 2 -- trainer cards only; Pokemon stay 1x +local OPPONENT_TRAINER_SCALE = 1 -- trainer cards only; Pokemon stay 1x OverworldBattle.TEX_AX, OverworldBattle.TEX_AY = TEX_AX, TEX_AY diff --git a/main.lua b/main.lua index bd1ca5c..d0a42b2 100644 --- a/main.lua +++ b/main.lua @@ -981,7 +981,7 @@ mod.hooks:wrap("world.tod", function(next, tod, ctx) return DayNight.tod() end) -mod.exports.version = "1.60" +mod.exports.version = "1.63" -- exposed so a companion mod can pin its own tiles' shapes or read the -- camera without reaching into this mod's file layout mod.exports.lib = V diff --git a/manifest.json b/manifest.json index 6dd4a79..9075c0c 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "DRAMATIC_SHAPE", "name": "DRAMATIC SHAPE VOXEL MOD BATTLE ART", - "version": "1.60", + "version": "1.63", "api": 2, "entry": "main.lua", "profile": "content", From 3cd11a9f9218c825f8f4fb5abcfaf3aa24114072 Mon Sep 17 00:00:00 2001 From: absol89 Date: Tue, 4 Aug 2026 18:05:28 +0200 Subject: [PATCH 056/116] feat(free-roam): port camera steering, 1st/3rd-person control from 7b1ac9b Ports the free-roam camera work from upstream dev commit 7b1ac9b, VR-stripped and iOS-fix-preserving: - lib/BattleCam.lua: battle camera orbit/pitch/zoom steering (frameH, rig 3rd arg, steerable flag) behind pcall-guarded CamControl.tick - lib/CamControl.lua (new): wheel/pinch/right-stick battle zoom + orbit - lib/ThirdPerson.lua (new): boom camera (3RD rung), VR pcall neutralised - lib/FirstPerson.lua + lib/FreeMove.lua: take 7b1ac9b versions (includes fork mobile-spin fix + 3rd-person integration, bodyYaw) - lib/VoxelState.lua: add TP_LEVEL / isThirdPerson / isFreeCam, 8th rung - lib/OverworldBattle.lua: ANCHOR_SPAN + animScale, steerable tick, separation-scaled move animations (hunk-merged, fork trainer-1x + iOS-revert + RED preserved) - lib/BattleArena.lua / lib/BattleScene.lua: rig(..,true) + frameH() hunks - lib/VoxelScene.lua: playerFacing card frame No VR references, no VR tests, no VR DLLs. Transparent HUD kept on non-iOS (iOS bypass removed earlier, not reintroduced). Co-Authored-By: Hermes Agent --- lib/BattleArena.lua | 2 +- lib/BattleCam.lua | 317 +++++++++++++++++++++++++++++- lib/BattleScene.lua | 2 +- lib/CamControl.lua | 415 ++++++++++++++++++++++++++++++++++++++++ lib/FirstPerson.lua | 240 ++++++++++++++++++++--- lib/FreeMove.lua | 94 ++++++--- lib/OverworldBattle.lua | 91 +++++++-- lib/ThirdPerson.lua | 408 +++++++++++++++++++++++++++++++++++++++ lib/VoxelScene.lua | 8 + lib/VoxelState.lua | 25 ++- 10 files changed, 1520 insertions(+), 82 deletions(-) create mode 100644 lib/CamControl.lua create mode 100644 lib/ThirdPerson.lua diff --git a/lib/BattleArena.lua b/lib/BattleArena.lua index 20f0e04..7bbcf44 100644 --- a/lib/BattleArena.lua +++ b/lib/BattleArena.lua @@ -242,7 +242,7 @@ end -- Whether both mons would be in plain view from the battle camera. function BattleArena.clearance(map, arena) local BattleCam = V.require("BattleCam") - local ok, rig = pcall(BattleCam.rig, arena, 0) + local ok, rig = pcall(BattleCam.rig, arena, 0, true) if not (ok and rig and rig.eye) then return true end local eye = rig.eye local H = BattleArena.MON_H diff --git a/lib/BattleCam.lua b/lib/BattleCam.lua index dc812f0..3bd3170 100644 --- a/lib/BattleCam.lua +++ b/lib/BattleCam.lua @@ -66,11 +66,6 @@ local V = ... local BattleCam = {} --- Leave enough headroom for full-height trainer cards. This opens the solved --- battle lens by 70% without resampling any sprite: Pokemon and trainers stay --- at their authored world size while more of the arena enters the frame. -BattleCam.ZOOM_OUT = 1.70 - -- ------- the rig, in world pixels (a map cell is 16, a block 32) -- -- Solved against the four anchors and two spans above, with the two mons 48 @@ -129,12 +124,257 @@ BattleCam.PAN_PERIOD = 26 -- seconds for one there-and-back BattleCam.PAN_DOLLY = 0.02 -- how far the eye breathes, as a fraction BattleCam.DOLLY_PERIOD = 37 +-- ------- the player's own orbit +-- +-- The drift above is the shot breathing. THIS is the player steering it: +-- a right stick, a drag across the screen or the mouse walks the eye +-- around the arena's axis, and it stops at both ends. +-- +-- 0 is the shot the rig was solved for and the LEFT stop, because there is +-- nothing to the left of it -- the composition below is what the whole +-- module exists to land, and past it the two mons start swapping sides. +-- +-- 1 is SIDE-ON: the eye swung round until it is square to the arena's +-- north-south axis, where the two mons stand at the same distance instead +-- of one behind the other. That is as far as the picture stays a battle +-- rather than a diorama with two Pokemon in it, and it is a different angle +-- for each rig -- the tele lens starts 28 degrees off the axis and the wide +-- one 45 -- so the stop is COMPUTED from the rig rather than written down, +-- and retuning either moves its own stop with it. +-- +-- The input is deliberately not 1:1 with the pixels: it accumulates into +-- `orbitGoal` and the live angle eases after it, so a flick reads as the +-- camera being pushed rather than as the camera being dragged. +BattleCam.ORBIT_TIME = 0.22 -- seconds for the eye to catch its goal +BattleCam.ORBIT_DRAG = 1.15 -- fraction of the range per screen width +BattleCam.ORBIT_STICK = 0.9 -- fraction of the range per second, full tilt +BattleCam.ORBIT_MOUSE = 0.0011 -- fraction of the range per mouse count +BattleCam.STICK_DEAD = 0.2 + +-- ------- and the height it is watched from +-- +-- The same steering on the other axis, with the same shape of stop at each +-- end: 0 is the rig's own stance -- the low, near-floor seat the whole +-- composition is solved around, and the DOWN stop, because below it the +-- camera starts looking up the arena's nose -- and 1 is 45 degrees above +-- it, which is high enough to read the ground the fight is standing on +-- without becoming the diorama's own top-down. +-- +-- Raised about the FOCUS rather than about the eye, so the aim stays on +-- the two mons and only the seat climbs; and at a constant radius, so +-- climbing never changes how big anything is -- that is the zoom's job. +BattleCam.PITCH_RANGE = math.rad(45) +BattleCam.PITCH_TIME = 0.22 +BattleCam.PITCH_DRAG = 1.6 -- fraction of the range per screen HEIGHT +BattleCam.PITCH_STICK = 0.9 +BattleCam.PITCH_MOUSE = 0.0016 + +-- ------- and the player's own zoom +-- +-- How much world the frame holds, as a multiple of the rig's own frameH: +-- BELOW one is zoomed in. It has to be the LENS rather than the distance, +-- because the rig derives its field of view from frameH and the distance +-- together -- so moving the eye alone changes the perspective and not the +-- framing, which is exactly what the dolly breath above is for. +BattleCam.ZOOM_MIN = 0.45 -- the pair filling the frame +BattleCam.ZOOM_MAX = 2.0 -- the fight in its own landscape +BattleCam.ZOOM_STEP = 1.15 +BattleCam.ZOOM_TIME = 0.18 + +BattleCam.orbit = 0 +BattleCam.orbitGoal = 0 +BattleCam.pitch = 0 +BattleCam.pitchGoal = 0 +BattleCam.zoom = 1 +BattleCam.zoomGoal = 1 + +-- Whether the player may steer at all. BACK SPRITES clears it: that +-- setting pins the player's own mon to the GB's own slot on the menu +-- (OverworldBattle.backPinned) instead of standing it out on the map, so +-- half the picture is nailed to the frame and half of it is geometry. Swing +-- the camera under that and the two halves come apart -- the foe walks +-- around an arena its opponent is not standing in, and the move animations +-- that reach between them stretch across the gap. There is no angle that +-- composition survives, so the answer is not to allow one. +-- +-- Only the STEER is withheld: the slow drift stays, because it was always +-- there under BACK SPRITES and two degrees is not a composition problem. +BattleCam.steerable = true + +-- Hold the rig perfectly still (VR sets this while a session runs). The +-- drift exists to give a FLAT screen the depth cue the picture cannot +-- have; a headset gets real parallax from the player's own head, and a +-- picture that sways on its own inside VR reads as the world lurching -- +-- on the floating panel especially, where the battle screen is watched +-- from a fixed seat. +BattleCam.still = false + BattleCam.t = 0 +-- Only the DRIFT's phase, so every fight opens on the same breath. Where +-- the player last put the camera is deliberately NOT reset: an angle and a +-- lens they chose are how they want to watch battles, not a thing about +-- this battle, and having to re-find them every encounter would make them +-- not worth setting. They are session state -- a fresh run opens on the +-- rig's own shot, which is the one the composition is solved for. function BattleCam.reset() BattleCam.t = 0 end +-- Back to the solved shot, for anything that wants the composition as +-- authored rather than as steered. +function BattleCam.recentre() + BattleCam.orbit, BattleCam.orbitGoal = 0, 0 + BattleCam.pitch, BattleCam.pitchGoal = 0, 0 + BattleCam.zoom, BattleCam.zoomGoal = 1, 1 +end + +-- How far the eye may swing, in radians, before it is square to the arena's +-- axis. The rig's own stance decides it: `side` and `back` are the offset +-- it starts at, so the bearing it starts on is atan2(side, back) and what +-- is left to a quarter turn is the room the player has. +function BattleCam.orbitRange(arena) + local R = BattleCam.rigFor(arena) + return math.max(0, math.pi / 2 - math.atan2(R.side, R.back)) +end + +-- ------- what the player's inputs reach +-- +-- All four take a signed amount and clamp; positive is RIGHTWARD, toward +-- the side-on stop. Returning whether the goal actually moved lets a +-- caller tell "steered" from "already against the stop". + +-- Both axes go through here, so the "nothing while BACK SPRITES holds the +-- composition" rule and the two stops live in one place each. +local function setAxis(key, goal) + if not BattleCam.steerable then return false end + local was = BattleCam[key] + BattleCam[key] = math.max(0, math.min(1, goal)) + return BattleCam[key] ~= was +end + +-- A drag, in fractions of the screen's width (orbit) or height (pitch). +function BattleCam.dragOrbit(fraction) + return setAxis("orbitGoal", + BattleCam.orbitGoal + (fraction or 0) * BattleCam.ORBIT_DRAG) +end + +function BattleCam.dragPitch(fraction) + return setAxis("pitchGoal", + BattleCam.pitchGoal + (fraction or 0) * BattleCam.PITCH_DRAG) +end + +-- Relative mouse motion, in counts. +function BattleCam.mouseOrbit(dx) + return setAxis("orbitGoal", + BattleCam.orbitGoal + (dx or 0) * BattleCam.ORBIT_MOUSE) +end + +function BattleCam.mousePitch(dy) + return setAxis("pitchGoal", + BattleCam.pitchGoal + (dy or 0) * BattleCam.PITCH_MOUSE) +end + +-- A stick held for `dt` seconds, as a rate with a squared response -- the +-- first half of the throw aims and the rest travels, the same curve the +-- free-roam look uses. +local function curve(v) + local a = math.abs(v or 0) + if a < BattleCam.STICK_DEAD then return 0 end + a = (a - BattleCam.STICK_DEAD) / (1 - BattleCam.STICK_DEAD) + return ((v < 0) and -1 or 1) * a * a +end + +function BattleCam.stickOrbit(x, dt) + local v = curve(x) + if v == 0 then return false end + return setAxis("orbitGoal", + BattleCam.orbitGoal + v * BattleCam.ORBIT_STICK * (dt or 0)) +end + +function BattleCam.stickPitch(y, dt) + local v = curve(y) + if v == 0 then return false end + return setAxis("pitchGoal", + BattleCam.pitchGoal + v * BattleCam.PITCH_STICK * (dt or 0)) +end + +-- The zoom, in notches (positive pulls OUT, like every other zoom here). +function BattleCam.stepZoom(notches) + if not BattleCam.steerable then return false end + local was = BattleCam.zoomGoal + BattleCam.zoomGoal = math.max(BattleCam.ZOOM_MIN, + math.min(BattleCam.ZOOM_MAX, + was * (BattleCam.ZOOM_STEP ^ (notches or 0)))) + return BattleCam.zoomGoal ~= was +end + +-- How far apart the two mons READ from the current orbit, as a multiple of +-- how far apart they read from the solved shot. +-- +-- The arena's axis runs from one mon to the other, and the solved shot +-- looks along it at a shallow 28 degrees, which foreshortens that gap to +-- less than half its length. Swing round to square-on and the +-- foreshortening is gone: the same two cells now read at their full +-- separation, better than twice as wide. Left alone, that threw the pair +-- out to the edges of the frame -- half of each mon off-screen at the +-- side-on stop, which made the whole far end of the range unusable. +-- +-- Climbing does the same thing on the other axis -- a raised camera looks +-- less along the ground and more across it, which un-foreshortens the gap +-- again -- so the correction has to answer to both. +-- +-- What it measures is how much of the arena's axis survives projection: +-- the axis runs due north-south, the view line points back at the arena at +-- plan bearing `beta` and elevation `elev`, and the part of a unit axis +-- that lands across the frame rather than along the view is the sine of +-- the angle between them. The ratio of that to the solved shot's own is +-- the factor the lens opens by -- 1 at the solved shot by construction, +-- about 1.9 at side-on, about 1.7 fully raised. +-- +-- Analytic rather than measured off the built rig, so nothing has to +-- reason about a camera to ask the question, and so the sun's box (which +-- asks through frameH) gets the identical number the lens does. +-- +-- Measured off the STEER alone, deliberately: the drift's own two degrees +-- moved this before and must keep moving it by exactly as much, or every +-- battle shot that has ever been taken shifts. +local function axisSpan(beta, elev) + local c = math.cos(elev) + local s = math.sin(beta) * c + local v = math.sin(elev) + return math.sqrt(s * s + v * v) +end + +function BattleCam.spread(arena) + local R = BattleCam.rigFor(arena) + local beta = math.atan2(R.side, R.back) + local elev = math.atan2(R.height - R.lookY, + math.sqrt((R.side - R.lookX) ^ 2 + R.back ^ 2)) + local home = axisSpan(beta, elev) + if home < 1e-6 then return 1 end + return axisSpan(beta + BattleCam.orbit * BattleCam.orbitRange(arena), + elev + BattleCam.pitch * BattleCam.PITCH_RANGE) / home +end + +-- How much world the frame holds right now: the rig's own reach at the +-- player's zoom and at whatever the orbit has done to the pair's spacing, +-- or the rig's own alone whenever both are being withheld (VR's fixed +-- seat, BACK SPRITES' pinned composition). The sun's box is fitted to this +-- too, so a zoomed shot lights exactly the ground it shows -- which is why +-- BattleScene asks this rather than multiplying for itself. +function BattleCam.frameH(arena) + local base = BattleCam.rigFor(arena).frameH + if BattleCam.still or not BattleCam.steerable then return base end + return base * BattleCam.zoom * BattleCam.spread(arena) +end + +local function chase(now, goal, dt, time) + if now == goal then return goal end + local v = now + (goal - now) * math.min(1, (dt or 0) / time) + return (math.abs(goal - v) < 1e-4) and goal or v +end + -- Real frame time, like every other presentational tween in this mod: a -- fast-forwarded battle must not spin the camera. function BattleCam.update(dt) @@ -143,6 +383,14 @@ function BattleCam.update(dt) -- float precision in the sines below local wrap = BattleCam.PAN_PERIOD * BattleCam.DOLLY_PERIOD if BattleCam.t > wrap then BattleCam.t = BattleCam.t - wrap end + -- and the steered three easing after whatever the player last asked for, + -- which is what keeps a flick of the stick from being a cut + BattleCam.orbit = chase(BattleCam.orbit, BattleCam.orbitGoal, dt, + BattleCam.ORBIT_TIME) + BattleCam.pitch = chase(BattleCam.pitch, BattleCam.pitchGoal, dt, + BattleCam.PITCH_TIME) + BattleCam.zoom = chase(BattleCam.zoom, BattleCam.zoomGoal, dt, + BattleCam.ZOOM_TIME) end local function phase(t, period) @@ -160,33 +408,82 @@ end -- -- `groundY` is the height of the arena floor, so a fight staged on a ledge -- or a raised walkway is shot from above THAT rather than from inside it. -function BattleCam.rig(arena, groundY) +-- `canonical` asks for the shot the rig was SOLVED for -- no drift, no +-- breath, no steer, no zoom -- from a caller that is reasoning about the +-- arena rather than drawing it. BattleArena's clearance test is the one +-- that needs it: whether a fight can be staged somewhere is a fact about +-- the ground, and answering it through whatever angle the player happened +-- to leave the last battle on would pick a different arena depending on +-- where they had swung the camera an hour ago. +function BattleCam.rig(arena, groundY, canonical) groundY = groundY or 0 local R = BattleCam.rigFor(arena) local mx, mz = arena.mid[1], arena.mid[2] + -- VR asks for the same stillness for its own reason (see BattleCam.still) + local fixed = BattleCam.still or canonical + -- and the steer is withheld a second way, on its own: BACK SPRITES holds + -- the composition and the DRIFT still runs under it (see steerable) + local steered = (not fixed) and BattleCam.steerable - local yaw = BattleCam.PAN_YAW * phase(BattleCam.t, BattleCam.PAN_PERIOD) + -- The drift, plus wherever the player has steered to. The steer is + -- NEGATIVE because the rotation below runs the other way from the bearing + -- it turns: rotating (side, back) by +yaw carries the eye back toward the + -- 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 + 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 -- along its own line to the arena and the pitch of the shot never changes - local k = 1 + BattleCam.PAN_DOLLY - * phase(BattleCam.t, BattleCam.DOLLY_PERIOD) + local k = fixed and 1 + or 1 + BattleCam.PAN_DOLLY + * phase(BattleCam.t, BattleCam.DOLLY_PERIOD) local dx = (R.side * c - R.back * s) * k 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 } + -- 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 + -- seat moves, and at a constant radius so climbing never changes how big + -- anything is -- that is the lens's job below, and a rig that did both at + -- once would have no way to do either on purpose. + local lift = steered and BattleCam.pitch * BattleCam.PITCH_RANGE or 0 + if lift > 0 then + local vx, vy, vz = eye[1] - focus[1], eye[2] - focus[2], eye[3] - focus[3] + local flat = math.sqrt(vx * vx + vz * vz) + local r = math.sqrt(flat * flat + vy * vy) + if flat > 1e-6 and r > 1e-6 then + local a = math.atan2(vy, flat) + lift + -- short of straight down, always: the placed camera's up vector is + -- world up, which degenerates against a view looking exactly along it + a = math.min(a, math.rad(85)) + local nf = r * math.cos(a) + eye[1] = focus[1] + vx / flat * nf + eye[3] = focus[3] + vz / flat * nf + eye[2] = focus[2] + r * math.sin(a) + end + end + local ex = eye[1] - focus[1] local ey = eye[2] - focus[2] local ez = eye[3] - focus[3] local dist = math.max(1, math.sqrt(ex * ex + ey * ey + ez * ez)) local horiz = math.sqrt(ex * ex + ez * ez) + -- The lens carries the player's zoom: how much world the frame holds is + -- the one thing that actually changes the framing here, because the field + -- of view is DERIVED from that reach and the distance. Moving the eye + -- instead would leave the picture the same size and only change its + -- perspective -- which is what the dolly breath above is deliberately + -- for, and is not what "zoom" means to anyone holding a wheel. + local frameH = fixed and R.frameH or BattleCam.frameH(arena) return { eye = eye, focus = focus, - fov = 2 * math.atan((R.frameH * BattleCam.ZOOM_OUT / 2) / dist), + fov = 2 * math.atan((frameH / 2) / dist), -- the world curve is a free-roam flourish that bends the horizon away -- from the player; a fixed camera on a staged shot has no player to bend -- around, and the bend would tip the arena floor out from under the mons diff --git a/lib/BattleScene.lua b/lib/BattleScene.lua index b944f84..5913bb5 100644 --- a/lib/BattleScene.lua +++ b/lib/BattleScene.lua @@ -395,7 +395,7 @@ function BattleScene.render(state, arena, textures, token) local cx, cy = arena.mid[1], arena.mid[2] -- the world extents the sun frustum is fitted to; the camera itself is -- framed by cam.fov, so these only have to describe the ground in shot - local vh = BattleCam.rigFor(arena).frameH * ph / (BattleScene.GB_H * s) + local vh = BattleCam.frameH(arena) * ph / (BattleScene.GB_H * s) local vw = vh * pw / ph -- the cards need the camera's eye to face it, so the rig has to be live diff --git a/lib/CamControl.lua b/lib/CamControl.lua new file mode 100644 index 0000000..235958e --- /dev/null +++ b/lib/CamControl.lua @@ -0,0 +1,415 @@ +-- The player's own camera controls: zoom everywhere, and the battle's orbit. +-- +-- This mod has four cameras, and by the time a wheel notch arrives they all +-- want it. So one module owns the INPUTS and answers the only question that +-- matters -- which camera is this aimed at -- rather than each camera +-- growing its own wheel handler and racing the others for the event: +-- +-- a staged battle the camera the fight is shot with (BattleCam): the +-- wheel and Q/E work its lens, and the right stick, +-- a drag or the mouse walk it around the arena. +-- +-- the 3RD rung the boom behind the player's shoulder +-- (ThirdPerson): the wheel, Q/E and a pinch let it +-- out and pull it in. +-- +-- an orbit rung the engine's own survey zoom, which the wheel has +-- always driven -- so here the module mostly gets +-- out of the way, and only ADDS the two keys and the +-- pinch that the engine has no handler for. +-- +-- the 1ST rung nothing. The eye is in the player's head; there is +-- no distance to change, and a pinch there would +-- silently wind the survey zoom for whenever they +-- stepped back out. Inputs pass through untouched. +-- +-- Every claim is answered by a GATE rather than by a mode flag, and every +-- wrap forwards whatever it does not claim -- so with voxel mode off, and +-- on every screen that is not the overworld or a battle, each byte flows +-- exactly where it always did. +-- +-- Installed AFTER FirstPerson (see main.lua), which makes these wraps the +-- outer ones: a battle's controls get first refusal on the mouse and the +-- touch screen, which is right, because while a fight is staged the +-- free-roam look is not driving anyway. + +-- the mod namespace (see main.lua): V.require loads a sibling module +local V = ... + +local Voxel = V.require("VoxelState") +local Voxel3D = V.require("Voxel3D") +local FirstPerson = V.require("FirstPerson") +local ThirdPerson = V.require("ThirdPerson") +local BattleCam = V.require("BattleCam") + +local CamControl = {} + +-- ------- tuning +-- +-- PINCH_SLACK is how far apart two fingers must travel, as a ratio of +-- their starting gap, before the gesture counts as a pinch at all -- below +-- it a two-finger tap wobbles rather than zooms. +-- +-- SURVEY_PINCH is how many of the engine's integer survey steps one +-- doubling of the finger gap is worth. The survey ladder is coarse (whole +-- pixels per world pixel), so a pinch has to be geared down or the first +-- centimetre of travel crosses the whole range. +CamControl.PINCH_SLACK = 0.02 +CamControl.SURVEY_PINCH = 2.2 + +-- ------- gates + +-- A fight staged on the map, drawn and on screen. Asked of the shot rather +-- than of the battle state, because the shot is exactly "there is a 3D +-- battle in front of the player right now" -- with 3D-BTL off, or on a map +-- with no arena, the engine's own flat battle screen is up and its camera +-- is not ours to steer. +-- BACK SPRITES also closes it, through BattleCam.steerable: that setting +-- nails the player's own mon to the GB's slot on the menu while the foe +-- stands out on the map, and no camera angle holds a composition that is +-- half frame and half world (see BattleCam.steerable, which is where the +-- reasoning lives and which the RIG answers to as well -- so a stored +-- angle from before the setting was switched on stands down with it). +local function battleLive() + local ok, shot = pcall(function() + return V.require("OverworldBattle").shot() + end) + return (ok and shot and BattleCam.steerable) and true or false +end + +CamControl.battleLive = battleLive + +-- The free-roam overworld, with the 3D pass carrying it: the gate every +-- zoom that is not a battle's answers to. +local function roaming() + return Voxel.active() and Voxel3D.available() and FirstPerson.onTop() +end + +-- Which camera a zoom is aimed at: "battle", "boom", "survey", or nil for +-- nothing that zooms (1ST, or a screen with no camera of ours behind it). +function CamControl.zoomTarget() + if battleLive() then return "battle" end + if not roaming() then return nil end + if Voxel.isThirdPerson(Voxel.level) then return "boom" end + if Voxel.isFirstPerson(Voxel.level) then return nil end + return "survey" +end + +-- ------- zoom +-- +-- `notches` is signed the way every zoom in this file is: POSITIVE pulls +-- the camera OUT. The engine's own survey step runs the other way, and is +-- negated at the one place it is called rather than everywhere else being +-- bent to match it. +-- +-- Returns true when the input was ours, which is what tells a wrap to stop +-- rather than forward. + +local function surveyStep(notches) + local ok = pcall(function() + local Game = require("src.core.Game") + Game:zoomStep(notches > 0 and -1 or 1) + end) + return ok +end + +function CamControl.zoomBy(notches) + if not notches or notches == 0 then return false end + local target = CamControl.zoomTarget() + if target == "battle" then + BattleCam.stepZoom(notches) + return true + elseif target == "boom" then + ThirdPerson.stepZoom(notches) + return true + elseif target == "survey" then + -- one call per notch: the engine's ladder is integer rungs, and a + -- wheel spun hard should climb them all rather than one + for _ = 1, math.min(8, math.abs(notches)) do surveyStep(notches) end + return true + end + return false +end + +-- A pinch's own scale: > 1 is fingers spreading, which means zoom IN +-- (pull the world closer), which is a NEGATIVE notch count. +function CamControl.pinchBy(factor) + if not (factor and factor > 0) then return false end + local target = CamControl.zoomTarget() + if target == "boom" then + return ThirdPerson.scaleZoom(1 / factor) + elseif target == "battle" then + -- battles take a pinch too: the wheel and the keys reach this camera + -- and a phone has neither, so without it the lens would be the one + -- control a touch screen could not work + return BattleCam.stepZoom(math.log(1 / factor) + / math.log(BattleCam.ZOOM_STEP)) + elseif target == "survey" then + CamControl.surveyAccum = (CamControl.surveyAccum or 0) + + math.log(factor) / math.log(2) * CamControl.SURVEY_PINCH + local moved = false + while CamControl.surveyAccum >= 1 do + CamControl.surveyAccum = CamControl.surveyAccum - 1 + surveyStep(-1) + moved = true + end + while CamControl.surveyAccum <= -1 do + CamControl.surveyAccum = CamControl.surveyAccum + 1 + surveyStep(1) + moved = true + end + return moved + end + return false +end + +CamControl.surveyAccum = 0 + +-- ------- the battle's orbit +-- +-- Only ever the battle's: the free-roam rungs already steer their own look +-- through FirstPerson, and these wraps sit outside it precisely so a fight +-- can borrow the same devices without either of them growing a mode check. + +-- The right stick, read as a rate off the axes FirstPerson's own wrap is +-- already recording (it records whatever the rung, so a battle can read +-- them without a second wrap on the same seam). Ticked from +-- OverworldBattle.update, which runs whatever is on top of the stack. +-- +-- X walks the shot round the arena, Y raises the seat. The Y is NEGATED: +-- a stick pushed forward reads as negative on SDL's axis, and pushing +-- forward should send the camera UP and over -- the same "push the camera +-- where you want it" the drag and the mouse below use. +function CamControl.tick(dt) + if not battleLive() then return end + local x, y = FirstPerson.stickX(), FirstPerson.stickY() + if x ~= 0 then BattleCam.stickOrbit(x, dt) end + if y ~= 0 then BattleCam.stickPitch(-y, dt) end +end + +-- ------- the wraps + +local installed = false + +function CamControl.install() + if installed then return end + installed = true + + local Game = require("src.core.Game") + + -- ------- the wheel + -- + -- The engine's own handler is the survey zoom, so the wrap only has to + -- take the notch away when some OTHER camera wants it; "survey" falls + -- through to exactly the code that always ran. + do + local inner = Game.wheelmoved + function Game:wheelmoved(dx, dy) + local target = CamControl.zoomTarget() + if (target == "battle" or target == "boom") and dy and dy ~= 0 then + CamControl.zoomBy(dy > 0 and -1 or 1) + return + end + return inner(self, dx, dy) + end + end + + -- ------- the stick clicks + -- + -- Q and E, on the pad: the left stick's click pulls the camera out and the + -- right stick's pulls it in. A controller has no wheel and no number row, + -- and the two clicks are the only buttons a Gen 1 pad layout leaves free + -- (SELECT already walks the angle ladder). + -- + -- Claimed for the two cameras a pad player can actually be looking at + -- while pressing them -- the third-person boom and a staged battle's lens + -- -- and forwarded untouched everywhere else, so a player who has rebound + -- either click keeps it on every other screen, a rebind capture included. + -- Not on the orbit rungs: the survey zoom has the OPTIONS row and the + -- wheel already, and taking a pad button for it would be taking one from + -- a player who never asked. + local CLICK_ZOOMS = { boom = true, battle = true } + do + local inner = Game.gamepadpressed + function Game:gamepadpressed(joystick, button) + if (button == "leftstick" or button == "rightstick") + and CLICK_ZOOMS[CamControl.zoomTarget() or ""] then + CamControl.zoomBy(button == "leftstick" and 1 or -1) + return + end + return inner(self, joystick, button) + end + end + + -- ------- the mouse + -- + -- Battle only. The free-roam look already owns relative motion through + -- FirstPerson's own wrap (this one is outside it, so what is claimed here + -- never reaches it) and a fight is exactly when that look is not driving. + -- + -- Bare motion, no button held: moving the mouse moves the shot. + -- + -- Each event's contribution is CLAMPED, though, because not every motion + -- event is a hand moving. The pointer entering the window, a warp back to + -- centre, an alt-tab -- each arrives as ONE event carrying the whole + -- distance from wherever the cursor was last seen, and in testing that + -- was a couple of hundred counts: enough to swing the shot a quarter of + -- the way to side-on before the player had touched anything. A real hand + -- delivers its travel as a stream of small events and is unaffected; a + -- teleport delivers it as one and is cut down to the size of a flick. + local MOUSE_STEP = 40 + local function clamp(v) + return math.max(-MOUSE_STEP, math.min(MOUSE_STEP, v or 0)) + end + do + local inner = love.mousemoved + love.mousemoved = function(x, y, dx, dy, istouch) + if battleLive() and not istouch then + -- dy is NEGATED for the same reason the stick's is: moving the + -- mouse away from you sends the camera up and over + if dx and dx ~= 0 then BattleCam.mouseOrbit(clamp(dx)) end + if dy and dy ~= 0 then BattleCam.mousePitch(-clamp(dy)) end + -- forwarded anyway: the cursor still has UI to point at, and the + -- steer is a read of the motion rather than a claim on it + end + if inner then return inner(x, y, dx, dy, istouch) end + end + end + + -- ------- the touch screen + -- + -- Two gestures, told apart by how many fingers are down on OPEN screen + -- (the overlay's own d-pad and buttons are never either): + -- + -- one finger, in a battle drags the shot around the arena + -- two fingers pinch to zoom, wherever zooming means + -- something -- and while they are down the + -- free-roam look stands aside, so a pinch in + -- 3RD does not also spin the view + local TouchControls = require("src.core.TouchControls") + + local free = {} -- id -> {x, y} for every finger on open screen + local pinch = nil -- { a, b, gap } while two of them are pinching + + local function freeCount() + local n = 0 + for _ in pairs(free) do n = n + 1 end + return n + end + + local function gapOf(a, b) + local dx, dy = free[a].x - free[b].x, free[a].y - free[b].y + return math.sqrt(dx * dx + dy * dy) + end + + -- Two free fingers and a camera that zooms: start measuring. The look + -- drag is dropped for the duration -- FirstPerson never sees the moves + -- below -- and re-seated on whichever finger survives, so the view does + -- not jump by however far the pinch travelled. + local function startPinch() + if pinch or freeCount() < 2 then return end + local ids = {} + for id in pairs(free) do ids[#ids + 1] = id end + local gap = gapOf(ids[1], ids[2]) + if gap < 16 then return end + pinch = { a = ids[1], b = ids[2], gap = gap } + CamControl.surveyAccum = 0 + pcall(FirstPerson.dropLook) + end + + local function endPinch(lifted) + if not pinch then return end + local survivor = nil + for id in pairs(free) do + if id ~= lifted then survivor = id break end + end + pinch = nil + if survivor and free[survivor] then + pcall(FirstPerson.reseatLook, survivor, + free[survivor].x, free[survivor].y) + end + end + + local function onControl(x, y) + local hit = nil + pcall(function() hit = TouchControls:hitTest(x, y) end) + return hit + end + + -- Whether this module has any interest in touches at all this frame. + -- Kept deliberately wide -- a battle, or anything that zooms -- because + -- the wrap forwards everything it does not claim regardless. + local function wantsTouch() + return battleLive() or CamControl.zoomTarget() ~= nil + end + + do + local inner = Game.touchpressed + function Game:touchpressed(id, x, y) + if wantsTouch() and not onControl(x, y) then + free[id] = { x = x, y = y } + if CamControl.zoomTarget() then startPinch() end + -- forwarded even so: a single free finger is the free-roam look's + -- to claim (FirstPerson's wrap is inside this one), and in a + -- battle it is nobody's until it MOVES + end + return inner(self, id, x, y) + end + end + + do + local inner = Game.touchmoved + function Game:touchmoved(id, x, y) + local f = free[id] + if f then + local px, py = f.x, f.y + f.x, f.y = x, y + if pinch and (id == pinch.a or id == pinch.b) then + local gap = gapOf(pinch.a, pinch.b) + local factor = gap / math.max(1, pinch.gap) + if math.abs(factor - 1) > CamControl.PINCH_SLACK then + CamControl.pinchBy(factor) + pinch.gap = gap + end + return -- claimed: never a look drag too + end + if battleLive() and not pinch then + local w, h = 1280, 720 + pcall(function() + w, h = love.graphics.getWidth(), love.graphics.getHeight() + end) + BattleCam.dragOrbit((x - px) / math.max(320, w)) + -- dragged UP sends the camera up and over, the same way the + -- stick and the mouse do + BattleCam.dragPitch(-(y - py) / math.max(240, h)) + return + end + end + return inner(self, id, x, y) + end + end + + do + local inner = Game.touchreleased + function Game:touchreleased(id, x, y) + if free[id] then + if pinch and (id == pinch.a or id == pinch.b) then endPinch(id) end + free[id] = nil + end + return inner(self, id, x, y) + end + end + + -- a reset that drops held input state drops ours with it, exactly as the + -- free-roam look's does + do + local inner = Game.focus + function Game:focus(f) + free, pinch = {}, nil + CamControl.surveyAccum = 0 + return inner(self, f) + end + end +end + +return CamControl diff --git a/lib/FirstPerson.lua b/lib/FirstPerson.lua index d1019b4..44241bb 100644 --- a/lib/FirstPerson.lua +++ b/lib/FirstPerson.lua @@ -1,4 +1,4 @@ --- Voxel world mode: the first-person camera -- the 1ST rung. +-- Voxel world mode: the free-roam camera -- the 1ST and 3RD rungs. -- -- Every other rung is the same camera at a different pitch: an orbit over -- the view centre, described by one number. 1ST is a different rig @@ -9,6 +9,13 @@ -- uniforms, project(), the sky's vanishing line, the water's lean -- reads -- eye and focus the same way it always has. -- +-- 3RD is that same rig with the eye pulled back onto a boom behind the +-- player's shoulder (lib/ThirdPerson.lua). Everything in this file is +-- already general over where the eye stands -- the attitude, the look +-- inputs, the move intent, the cards that turn to face the eye -- so the +-- third-person rung is one number applied at the very end of frame(), +-- rather than a second camera to keep in step with this one. +-- -- What this module owns: -- -- the ATTITUDE yaw and pitch, fed by whichever look input speaks: @@ -47,6 +54,7 @@ local Mat4 = V.require("Mat4") local Voxel = V.require("VoxelState") local Voxel3D = V.require("Voxel3D") local WorldCurve = V.require("WorldCurve") +local ThirdPerson = V.require("ThirdPerson") local FirstPerson = {} @@ -102,6 +110,15 @@ FirstPerson.yaw = 0 FirstPerson.pitch = FirstPerson.PITCH_DEFAULT FirstPerson.blend = 0 +-- A multiplier on the first-person field of view, for anything that wants +-- to narrow the lens without owning the rig: 1 is the ordinary 65 +-- degrees, and horde mode's iron sights ease it down toward 40 while the +-- player is looking down them (lib/HordeGun). Kept here rather than in +-- the caller because the fov is folded into the orbit blend below, and +-- because signature() has to know -- a lens that narrows while the player +-- stands still still has to re-fit the shadow box. +FirstPerson.fovScale = 1 + local wasEngaged = false local stick = { x = 0, y = 0 } -- right stick, latest event values local mouseDX, mouseDY = 0, 0 -- relative counts since last update @@ -133,16 +150,19 @@ end -- ------- gates --- Whether the 1ST rung is selected and the 3D pass can carry it. +-- Whether a free-roam rung -- 1ST or 3RD -- is selected and the 3D pass can +-- carry it. Both stand the camera with the player, so both read the look +-- inputs, both walk free, and both turn the cards; how far behind the head +-- the eye ends up is ThirdPerson's business alone. function FirstPerson.engaged() - return Voxel.isFirstPerson(Voxel.level) and Voxel3D.available() + return Voxel.isFreeCam(Voxel.level) and Voxel3D.available() end --- Whether first person should be READING the player's inputs right now: --- engaged, with the overworld on top of the stack (a menu, a dialog or a --- battle above it owns the buttons, exactly as it does for grid walking). -function FirstPerson.driving() - if not FirstPerson.engaged() then return false end +-- Whether the overworld is what the player is looking at: nothing pushed +-- over it, so the buttons are free-roam's. Shared with everything else in +-- the mod that asks the same question of the same stack (CamControl's +-- zooms above all), rather than each restating the pcall. +function FirstPerson.onTop() local ok, top, ow = pcall(function() local Game = require("src.core.Game") return Game.stack and Game.stack:top(), Game.overworld @@ -150,6 +170,44 @@ function FirstPerson.driving() return ok and top ~= nil and top == ow end +-- Whether first person should be READING the player's inputs right now: +-- engaged, with the overworld on top of the stack (a menu, a dialog or a +-- battle above it owns the buttons, exactly as it does for grid walking). +function FirstPerson.driving() + return FirstPerson.engaged() and FirstPerson.onTop() +end + +-- The right stick's live X, for a camera that is not this one: while a +-- battle is staged the free-roam look is not driving, but the axes are +-- still arriving on the wrap below (which records whatever the rung), and +-- the battle's orbit wants them. Reading them here rather than wrapping +-- the same seam twice. +function FirstPerson.stickX() + return stick.x or 0 +end + +function FirstPerson.stickY() + return stick.y or 0 +end + +-- ------- lending the look finger out +-- +-- A pinch needs both fingers on the screen, and one of them is very likely +-- the finger this module claimed as the look drag. Rather than have the +-- pinch fight for it, CamControl asks for it: dropLook while the gesture +-- runs, reseatLook on whichever finger survives it. Re-seating rather than +-- simply releasing is what stops the view snapping by however far the +-- pinch travelled -- the finger carries on as the look drag from where it +-- now is, which is what it looks like it should do. +function FirstPerson.dropLook() + lookTouch = nil +end + +function FirstPerson.reseatLook(id, x, y) + if id == nil then lookTouch = nil return end + lookTouch = { id = id, x = x, y = y } +end + -- The eased blend, 0 at the orbit and 1 in the head. function FirstPerson.blendEased() return ease(FirstPerson.blend) @@ -168,7 +226,16 @@ end -- deep enough into the blend that the card would fill the lens from -- inside. The sun pass keeps drawing it either way -- a first-person -- player still throws a shadow on the ground ahead. +-- +-- Never while 3RD's boom is genuinely out, whatever the blend: the whole +-- point of a boom is that the character it is booming away from is on +-- screen. (Nor the silhouette that rides the same answer -- seeing your own +-- outline through the building you just walked behind is what a +-- third-person camera owes the player.) A boom SQUEEZED into the head by a +-- wall answers false there and the card comes out again, because at that +-- range it is the first-person problem word for word. function FirstPerson.hidePlayer() + if ThirdPerson.showsPlayer() then return false end return FirstPerson.cardBlend() > 0.9 end @@ -183,15 +250,77 @@ function FirstPerson.lookBy(dyaw, dpitch) FirstPerson.pitch + dpitch)) end +-- A bearing as one of the grid's four directions -- the 45-degree +-- quantisation every facing in this file is made with, in one place so the +-- compass, the body and the card frames can never disagree about where a +-- boundary is. +local function facingOf(a) + local s, c = math.sin(a), math.cos(a) + if math.abs(s) > math.abs(c) then + return s > 0 and "right" or "left" + end + return c > 0 and "down" or "up" +end + -- The view direction's flat compass facing, for everything that still -- thinks in the grid's four directions: the cell A interacts with, the -- sprite the sun sees, the direction a blocked slide bonks in. function FirstPerson.compassFacing() - local s, c = math.sin(FirstPerson.yaw), math.cos(FirstPerson.yaw) - if math.abs(s) > math.abs(c) then - return s > 0 and "right" or "left" + return facingOf(FirstPerson.yaw) +end + +-- Which way the BODY points, as a continuous world bearing, given the +-- world-space direction it is walking (0, 0 while standing). In the head, +-- the body is the head: you face what you look at. On the boom you can see +-- yourself, and a character sliding sideways while facing the lens reads as +-- a bug rather than as a strafe -- so a walking body turns to face its own +-- travel, and a standing one comes back round to the camera's bearing, +-- which is the one A talks along. +function FirstPerson.bodyBearing(wx, wz) + if ThirdPerson.extended() and wx and wz and (wx ~= 0 or wz ~= 0) then + return math.atan2(wx, wz) end - return c > 0 and "down" or "up" + return FirstPerson.yaw +end + +-- The same answer as one of the four facings, which is what the grid game +-- (and the sprite sheet) reasons in. +function FirstPerson.bodyFacing(wx, wz) + return facingOf(FirstPerson.bodyBearing(wx, wz)) +end + +-- ------- the body's live bearing +-- +-- The bearing the player's own body is actually pointing along RIGHT NOW, +-- or nil whenever the free walk is not the thing pointing it (a scripted +-- move, a cutscene, the grid walk with the rung off). FreeMove maintains +-- it; only the player's own card reads it. +-- +-- It exists because the card's frame is chosen by the angle BETWEEN the +-- body and the eye, and quantising the body to a compass direction first +-- throws away exactly the precision that choice needs. A standing body is +-- pointed along the camera's own yaw, so the true angle between them is a +-- flat 180 degrees and the card should show its back and nothing else -- +-- but snap the body to one of four directions on the game tick, then +-- measure it against an eye that has kept turning since, and the pair can +-- read as 135 degrees and pick the PROFILE frame instead. Spin the camera +-- fast and the character flicks to a mirrored side view for a frame or +-- two. Keeping the bearing continuous gives the measurement a full 45 +-- degrees of slack before it can cross a boundary, which no frame's worth +-- of turning comes close to spending. +FirstPerson.bodyYaw = nil + +-- Point the body along the direction it is walking (or, standing, along +-- the camera): records the continuous bearing and hands back the compass +-- facing the caller wants for p.facing. +function FirstPerson.pointBody(wx, wz) + FirstPerson.bodyYaw = FirstPerson.bodyBearing(wx, wz) + return facingOf(FirstPerson.bodyYaw) +end + +-- Hand the body back to whatever else is turning it. +function FirstPerson.releaseBody() + FirstPerson.bodyYaw = nil end -- The unit look direction, and its flat (ground-plane) part. @@ -224,21 +353,43 @@ function FirstPerson.cardYaw(wx, wz) return math.atan2(dx, dz) end +-- Which of the four sprite frames a body at world bearing `phi` shows an +-- eye looking at (wx, wz): the bearing rotated into the viewer's own frame, +-- quantised. nil when there is no rig to be seen from. +local function frameFor(phi, wx, wz) + local eye = rig and rig.eye + if not (eye and phi) then return nil end + local dx, dz = eye[1] - wx, eye[3] - wz + if dx * dx + dz * dz < 1e-9 then return nil end + local rel = wrapPi(phi - math.atan2(dx, dz)) + local idx = math.floor((rel + math.pi / 4) / (math.pi / 2)) % 4 + return FACING_ORDER[idx + 1] +end + -- Which of the four sprite frames an entity shows THIS eye: its facing -- rotated into the viewer's own frame, quantised. The flat game's frames -- are "how this pose looks from the south", so the apparent facing is the -- pose rotated by where the viewer actually stands -- walk behind an NPC -- and you see their back, circle to their flank and you see the profile, -- exactly as the four frames Gen 1 drew intend. +-- +-- An NPC's facing IS one of the four and nothing finer, so this is the +-- whole story for everyone in the world except the one body the camera is +-- attached to -- see playerFacing. function FirstPerson.apparentFacing(facing, wx, wz) - local eye = rig and rig.eye - local phi = FACING_ANGLE[facing] - if not (eye and phi) then return facing end - local dx, dz = eye[1] - wx, eye[3] - wz - if dx * dx + dz * dz < 1e-9 then return facing end - local rel = wrapPi(phi - math.atan2(dx, dz)) - local idx = math.floor((rel + math.pi / 4) / (math.pi / 2)) % 4 - return FACING_ORDER[idx + 1] + return frameFor(FACING_ANGLE[facing], wx, wz) or facing +end + +-- The PLAYER's own card, which is the one case where the body's bearing is +-- known to better than a compass point (bodyYaw, above) -- and the one case +-- where it matters, because the eye is derived FROM that bearing rather +-- than independent of it. Measured continuously, a standing body reads as +-- a flat 180 degrees from its own camera and shows its back, steadily, +-- however fast the camera is spun. Falls back to the four-direction answer +-- whenever something other than the free walk is turning the body. +function FirstPerson.playerFacing(facing, wx, wz) + return frameFor(FirstPerson.bodyYaw, wx, wz) + or FirstPerson.apparentFacing(facing, wx, wz) end -- ------- the move intent @@ -334,6 +485,12 @@ function FirstPerson.update(dt) rig = nil end + -- the boom, on the same tick and for the same reason: it has to keep + -- easing after 3RD is left, and it needs the blend to know whether a + -- change of rung is a SLIDE (already inside the world, 1ST <-> 3RD) or + -- part of the dive in from the orbit, which carries the eye anyway + ThirdPerson.update(dt, FirstPerson.blend) + -- mouse capture follows engagement: captured whenever the rung is on and -- the window has focus, released the moment either ends. Checked against -- the live mode rather than toggled on edges, so a capture lost to the @@ -434,6 +591,12 @@ function FirstPerson.frame(me, cx, cy, vw, vh) head[2] + ly * FirstPerson.FOCUS_DIST, head[3] + lz * FirstPerson.FOCUS_DIST } + -- 3RD: the eye walks back off the head along the very direction it looks, + -- as far as the world allows. Fully in (1ST, and every frame of the + -- diorama) this hands back the head and the focus untouched, so the two + -- rungs are one rig with one number between them. + local camEye, camFocus = ThirdPerson.place(head, lx, ly, lz, fpFocus) + local oEye, oFocus, oFov, oUp = orbitRig(cx, cy, vh) local function mix(p, q) return { p[1] + (q[1] - p[1]) * e, @@ -452,8 +615,8 @@ function FirstPerson.frame(me, cx, cy, vw, vh) local k = WorldCurve.k(vh) * (1 - e) rig = { - eye = mix(oEye, head), - focus = mix(oFocus, fpFocus), + eye = mix(oEye, camEye), + focus = mix(oFocus, camFocus), fov = oFov + (FirstPerson.FOV - oFov) * e, up = up, curve = k, @@ -493,6 +656,9 @@ function FirstPerson.signature() math.floor(b * 64), math.floor(FirstPerson.yaw * 64), math.floor(FirstPerson.pitch * 64), + -- and how far back the boom stands the eye: a wall shortening it moves + -- the camera the sun's box is fitted around, standing still or not + ThirdPerson.signature(), }, ",") end @@ -585,11 +751,30 @@ function FirstPerson.install() -- pressed is remembered per button, so the release always reaches the -- overlay even if the capture ended while the button was down -- -- otherwise a click that outlives the rung strands A held forever. + -- + -- HORDE MODE re-reads the same two buttons as a weapon: left fires, + -- right holds the sights. Claimed BEFORE the A/B mapping below rather + -- than on top of it, so a click during the mode never also lands as a + -- GB button -- otherwise the A that ends the GAME OVER card would be + -- spent by the shot that ended the run. local mouseHeld = {} local MOUSE_BTN = { [1] = "a", [2] = "b" } + local function hordeMouse(button, down) + local Horde = V.require("Horde") + if not Horde.playing() then return false end + if button == 1 then + if down then V.require("HordeGun").fire() end + return true + elseif button == 2 then + V.require("HordeGun").setAds(down) + return true + end + return false + end do local inner = love.mousepressed love.mousepressed = function(x, y, button, istouch, presses) + if captured and not istouch and hordeMouse(button, true) then return end if captured and not istouch and MOUSE_BTN[button] then local Input = require("src.core.Input") mouseHeld[button] = true @@ -602,6 +787,9 @@ function FirstPerson.install() do local inner = love.mousereleased love.mousereleased = function(x, y, button, istouch, presses) + -- a release always reaches whoever owns the press: the horde's + -- aim-hold has to let go even if the mode ended mid-click + if not mouseHeld[button] and hordeMouse(button, false) then return end if mouseHeld[button] then local Input = require("src.core.Input") mouseHeld[button] = nil @@ -641,6 +829,14 @@ function FirstPerson.install() local onControl = nil pcall(function() onControl = TouchControls:hitTest(x, y) end) if not onControl and not lookTouch then + -- HORDE MODE: a tap on open screen is a SHOT, fired on the press + -- rather than on a release that turned out not to be a drag -- + -- a shooter that waits to find out whether you meant it is a + -- shooter that misses. The same finger still becomes the look + -- drag below, so aiming and firing are one gesture. + if V.require("Horde").playing() then + V.require("HordeGun").fire() + end lookTouch = { id = id, x = x, y = y } return end diff --git a/lib/FreeMove.lua b/lib/FreeMove.lua index eacd26a..bf5f9c4 100644 --- a/lib/FreeMove.lua +++ b/lib/FreeMove.lua @@ -1,11 +1,16 @@ --- Voxel world mode: free movement for the first-person rung. +-- Voxel world mode: free movement for the free-roam rungs. -- -- The engine walks a grid: sixteen frames per cell, four directions, --- input locked mid-step. Inside a first-person camera that gait reads as --- riding a rail, so while 1ST drives, this module replaces the WALK and --- nothing else: the player's position becomes continuous, steered by the --- camera's own yaw -- push forward and you go where you look, at any --- angle, sliding along whatever you graze. +-- input locked mid-step. Inside a camera that stands with the player that +-- gait reads as riding a rail, so while 1ST or 3RD drives, this module +-- replaces the WALK and nothing else: the player's position becomes +-- continuous, steered by the camera's own yaw -- push forward and you go +-- where you look, at any angle, sliding along whatever you graze. +-- +-- Both rungs walk identically: the boom behind the shoulder (3RD) changes +-- where the eye stands, not which way it points, and the walk was always +-- rotated by the YAW. The one thing it does change is which way the body +-- POINTS while it moves -- see bodyFacing in the tick. -- -- THE GRID IS STILL THE GAME. Every fact the world cares about is a fact -- about cells -- what blocks, what warps, what rustles, what bites -- and @@ -73,6 +78,11 @@ end function FreeMove.drop() pos = nil + -- and the body with it: while something else is walking the player -- + -- a scripted move, a ledge hop, the grid walk off the rung -- the + -- engine's own four-direction facing is the whole truth about which way + -- they point, so the card must stop reading our finer one + FirstPerson.releaseBody() end -- named for the suite: the module's live position, nil while dropped @@ -177,12 +187,21 @@ end -- ------- the blocked push -- -- The grid game's blocked step is where half its verbs live: the map-edge --- crossing, the ledge hop, the boulder shove, the route-gate warp fired --- by collision, and the honest bonk. Hand the engine the quantised --- direction and let its own handlers decide -- each one validates itself --- (checkLedgeHop matches the tile pair, checkEdgeExit checks the bounds), --- so calling them on every firm push is safe. Returns true when one of --- them took the frame over. +-- crossing, the ledge hop, the boulder shove, and the route-gate warp +-- fired by collision. Hand the engine the quantised direction and let its +-- own handlers decide -- each one validates itself (checkLedgeHop matches +-- the tile pair, checkEdgeExit checks the bounds), so calling them on +-- every firm push is safe. Returns true when one of them took the frame +-- over. +-- +-- The one verb NOT restated here is the bonk. On the grid a blocked step +-- is a discrete event -- you pressed a direction, the game refused, and +-- the bump answers you once. A free walk has no such moment: the body +-- SLIDES along whatever it grazes, so a player walking a fence line or +-- rounding a doorframe is blocked on one axis continuously, and the same +-- sound comes out as a rattle for as long as they keep walking. It is +-- feedback for a refusal that is not happening. The grid walk keeps its +-- own bump (the engine's, in OverworldController) untouched. local function pushSpecials(state, dir, why) local p = state.player p.facing = dir -- the handlers read the push off the facing @@ -199,13 +218,14 @@ local function pushSpecials(state, dir, why) return true end end - if why ~= "entity" then - if (state.bumpCooldown or 0) <= 0 then - local Game = require("src.core.Game") - require("src.core.Sound").play(Game.data, "Collision") - state.bumpCooldown = 16 - end - end + -- and NO bonk. The grid walk's collision sound marks a discrete event: + -- you pressed a direction, the step was refused, nothing happened. A + -- free walk has no such moment -- the body slides along every wall it + -- grazes, continuously, and a corridor taken at a slight angle is a + -- steady graze from end to end. Rate-limited or not, that came out as a + -- machine-gun of bonks for walking normally down a hallway. The wall + -- stopping you is the feedback; the sound only ever said so twice a + -- second whether or not anything had changed. return false end @@ -231,14 +251,26 @@ function FreeMove.tick(state) local input = Game.input -- the head is the facing: what A talks to, what the sun's card shows, - -- which way a bonk points - p.facing = FirstPerson.compassFacing() - - if input:wasPressed("a") then + -- which way a bonk points. (A body that is WALKING may turn along its + -- travel instead -- see below, once there is a travel to turn along; a + -- standing one always faces where the camera looks, which is what makes + -- A predictable.) pointBody rather than compassFacing, so the card also + -- gets the CONTINUOUS bearing behind that compass point. + p.facing = FirstPerson.pointBody(0, 0) + + -- HORDE MODE takes both of these away for as long as it runs: there is + -- no pausing (START), and nobody stops to read a sign with the horde + -- coming (A, which is also the button the mode's own GAME OVER card + -- wants left unspent). Everything below -- the walk, the wall slide and + -- the blocked-push verbs, warps included -- keeps working, because the + -- crowd has to be able to follow the player through a door. + local suppressed = V.require("Horde").suppressWorldInput() + + if not suppressed and input:wasPressed("a") then state:interact() return end - if input:wasPressed("start") then + if not suppressed and input:wasPressed("start") then require("src.core.Sound").play(Game.data, "Start_Menu") require("src.ui.Screens").push(Game, "StartMenu") return @@ -266,6 +298,16 @@ function FreeMove.tick(state) if not moving then return end + -- and once there IS a direction of travel, the body may point along it + -- rather than along the head: on the boom (3RD) you can see yourself, so + -- a strafe has to look like walking sideways. In the head it is the head + -- either way -- bodyBearing says so. + p.facing = FirstPerson.pointBody(wx, wz) + + -- the engine's own bonk clock, kept draining while the free walk has the + -- wheel: nothing here rings it (see pushSpecials), but stepping back onto + -- the grid must not inherit a cooldown frozen at whatever it held when + -- the rung was picked state.bumpCooldown = math.max(0, (state.bumpCooldown or 0) - 1) local speed = (Game.save and Game.save.onBike) and FreeMove.BIKE @@ -307,8 +349,8 @@ function FreeMove.tick(state) FreeMove.drop() return end - -- the push handlers may have turned the facing; the head still rules - p.facing = FirstPerson.compassFacing() + -- the push handlers may have turned the facing; the walk still rules + p.facing = FirstPerson.pointBody(wx, wz) end end diff --git a/lib/OverworldBattle.lua b/lib/OverworldBattle.lua index 434e1d7..a7b206c 100644 --- a/lib/OverworldBattle.lua +++ b/lib/OverworldBattle.lua @@ -225,6 +225,31 @@ OverworldBattle.ANCHOR = { enemy = { 124, 56 }, } +-- How far apart the two anchors are: the spacing every move animation was +-- authored against, and so the yardstick the live pair is measured with. +OverworldBattle.ANCHOR_SPAN = math.sqrt( + (OverworldBattle.ANCHOR.enemy[1] - OverworldBattle.ANCHOR.player[1]) ^ 2 + + (OverworldBattle.ANCHOR.enemy[2] - OverworldBattle.ANCHOR.player[2]) ^ 2) + +-- The effects layer's scale for this shot: how far apart the two mons +-- actually are on screen, over how far apart the slots they were authored +-- for were. Clamped hard at both ends -- an effect is pixel art and a wild +-- factor is worse than a slightly wrong one -- and held at exactly 1 when +-- the marks coincide, which is a projection about to degenerate rather +-- than a pair that has genuinely closed up. +OverworldBattle.ANIM_SCALE_MIN = 0.5 +OverworldBattle.ANIM_SCALE_MAX = 2.0 + +function OverworldBattle.animScale(shot, px, py) + if not (shot and shot.enemy and px and py) then return 1 end + local dx, dy = shot.enemy[1] - px, shot.enemy[2] - py + local span = math.sqrt(dx * dx + dy * dy) + if not (span > 1) then return 1 end + local k = span / OverworldBattle.ANCHOR_SPAN + return math.max(OverworldBattle.ANIM_SCALE_MIN, + math.min(OverworldBattle.ANIM_SCALE_MAX, k)) +end + -- ------- how big a mon is -- -- Not a decision made here. A pic is drawn at its own integer scale -- 1x for @@ -521,6 +546,19 @@ function OverworldBattle.update(dt) return end + -- Whether the shot is the player's to steer at all. BACK SPRITES pins + -- their own mon to the GB's slot on the menu while the foe stands out on + -- the map, and there is no angle that half-framed, half-solid + -- composition survives -- so under it the camera holds the shot the rig + -- was solved for (the slow drift aside, which was always there). Polled + -- per frame rather than latched at battle start: the row is reachable + -- from the mod manager's page mid-session. + BattleCam.steerable = not OverworldBattle.backPinned() + -- the right stick, read as a rate before the rig is built from it: the + -- wheel, the keys, the mouse and a drag all arrive as events and have + -- already landed, but a stick is a HELD position and only a tick can + -- turn it into travel (CamControl, which owns every one of those inputs) + pcall(V.require("CamControl").tick, dt) BattleCam.update(dt) -- the battle only exists once it has been pushed; a session opened at -- pushBattle time has it, one opened from battle.started was handed it @@ -1173,33 +1211,48 @@ function OverworldBattle.install() end) end - -- Move animations are authored against the pics' fixed slots, and a single - -- animation reaches across both sides, so there is no per-side offset to - -- give them. They ride the average, which is where the pair's centre went - -- -- a few pixels at most, and it keeps a hit landing on the mon it is - -- aimed at instead of drifting off it. local innerAnim = BattleState.drawAnimLayer function BattleState:drawAnimLayer(colorized) local shot = self.dramaticShapeShot if not shot then return innerAnim(self, colorized) end - -- Move animations are authored against the pics' old fixed slots, and one + -- Move animations are authored against the pics' fixed slots, and a single -- animation reaches across both sides, so there is no per-side offset to -- give them. They ride to where the PAIR went: the midpoint of the two -- mons' projected positions, less the midpoint of the slots they used to -- sit in. A hit still lands on the mon it is aimed at. - local a = OverworldBattle.ANCHOR - -- BACK SPRITES leaves the player's mon exactly where the GB put it, so that side - -- contributes no movement at all and the pair's centre has gone half as - -- far as the foe's mark did. - local px, py = shot.player[1], shot.player[2] - if OverworldBattle.backPinned() then px, py = a.player[1], a.player[2] end - local dx = (shot.enemy[1] + px) / 2 - (a.enemy[1] + a.player[1]) / 2 - local dy = (shot.enemy[2] + py) / 2 - (a.enemy[2] + a.player[2]) / 2 - love.graphics.push() - love.graphics.translate(math.floor(dx + 0.5), math.floor(dy + 0.5)) - local ok, err = pcall(innerAnim, self, colorized) - love.graphics.pop() - if not ok then error(err, 0) end + -- + -- And they ride the pair's SEPARATION as well, because the mons + -- themselves do. Both are geometry standing on the map, so the camera + -- sizes them: zoom in and they grow, swing round to side-on and the two + -- marks close up as the axis foreshortens. A layer that only slid would + -- have held the authored 106-pixel spacing through all of it -- a beam + -- fired between two mons that are no longer that far apart, ending in + -- the air beside the one it was aimed at. Scaling about the same + -- midpoint keeps every authored offset the same fraction of the gap it + -- was authored as. + local a = OverworldBattle.ANCHOR + -- BACK SPRITES leaves the player's mon exactly where the GB put it, so that side + -- contributes no movement at all and the pair's centre has gone half as + -- far as the foe's mark did. + local px, py = shot.player[1], shot.player[2] + if OverworldBattle.backPinned() then px, py = a.player[1], a.player[2] end + local cx, cy = (shot.enemy[1] + px) / 2, (shot.enemy[2] + py) / 2 + local ax = (a.enemy[1] + a.player[1]) / 2 + local ay = (a.enemy[2] + a.player[2]) / 2 + love.graphics.push() + love.graphics.translate(cx - ax, cy - ay) + -- Clamped, and skipped outright if the marks ever coincide: a + -- degenerate projection must leave the effects the size they were + -- rather than collapse them to nothing or blow them across the screen. + local k = OverworldBattle.animScale(shot, px, py) + if k ~= 1 then + love.graphics.translate(ax, ay) + love.graphics.scale(k, k) + love.graphics.translate(-ax, -ay) + end + local ok, err = pcall(innerAnim, self, colorized) + love.graphics.pop() + if not ok then error(err, 0) end end -- The engine's flash has a SECOND half, and it is the one that reaches the diff --git a/lib/ThirdPerson.lua b/lib/ThirdPerson.lua new file mode 100644 index 0000000..07a8358 --- /dev/null +++ b/lib/ThirdPerson.lua @@ -0,0 +1,408 @@ +-- Voxel world mode: the third-person camera -- the 3RD rung. +-- +-- 3RD is 1ST with the eye pulled off the back of the head. Everything that +-- makes the first-person rung work -- the steered attitude, the placed +-- camera on Voxel3D's seam, the cards that turn to face the eye, the +-- continuous camera-relative walk -- is already general over WHERE the eye +-- stands, so this module adds exactly one thing to it: a BOOM. +-- +-- What the boom owns: +-- +-- the LENGTH how far behind the pivot the eye sits, eased in and out +-- so stepping between 1ST and 3RD slides rather than cuts, +-- and clamped every frame by what the world will allow. +-- +-- the COLLISION a march back along the boom line through the terrain +-- height field and the map's own walkability, so backing +-- into a wall walks the camera in toward the player's +-- shoulders instead of through the wall into the void. +-- The recovery is deliberately slower than the intrusion: +-- a camera must never be a frame late leaving geometry, +-- and must never snap back out the instant a corner clears. +-- +-- the SHOULDER the small lateral rail offset that keeps the character +-- off dead centre, faded out with the boom so a camera +-- jammed against a wall does not also slide sideways into +-- it. +-- +-- Deliberately NOT here: the attitude, the look inputs, the blend, the +-- move intent (all lib/FirstPerson.lua, which drives this module and reads +-- its answer while building the frame's rig), and movement itself +-- (lib/FreeMove.lua, unchanged -- the walk is camera-relative either way, +-- and the camera's yaw is the same number on both rungs). +-- +-- Nothing here is required for the rung to draw: with no overworld to ask +-- (a headless run, the test suite) every query answers "clear" and the boom +-- extends to its full length over an empty world. + +-- the mod namespace (see main.lua): V.require loads a sibling module +local V = ... + +local Voxel = V.require("VoxelState") + +local ThirdPerson = {} + +-- ------- the boom's numbers +-- +-- BOOM is world pixels behind the pivot at full extension. A cell is 16 and +-- a character card is 16 tall, so 48 stands the camera three cells back: +-- with the first-person lens (65 degrees vertical) that frames the player +-- at roughly a quarter of the frame height -- the modern action-game +-- middle ground, close enough to read the four-frame sprite and far enough +-- to see the cell you are about to walk into. +-- +-- PIVOT_LIFT raises the orbit point above the first-person eye, so the +-- boom looks slightly DOWN across the player's shoulder rather than +-- straight through the back of their head. +-- +-- SHOULDER is the lateral rail offset, in world pixels, positive to the +-- camera's right -- which puts the player left of centre, leaving the +-- larger half of the frame in front of them. +ThirdPerson.BOOM = 48 +ThirdPerson.PIVOT_LIFT = 4 +ThirdPerson.SHOULDER = 4 + +-- how long the eye takes to slide out to the boom (and back into the head +-- when 1ST is picked), in seconds -- the same order as FirstPerson's own +-- dive so stepping 75 -> 1ST -> 3RD reads as one continuous camera +ThirdPerson.BOOM_TIME = 0.35 + +-- ------- the player's own zoom +-- +-- A multiplier on BOOM, stepped by the wheel, Q/E or a pinch (see +-- CamControl, which owns every one of those and decides which camera a +-- given input is aimed at). The range is deliberately wider IN than OUT: +-- close is the shot people reach for, and far enough out the character is +-- a few pixels and the rung may as well be an orbit rung. +-- +-- Stepped in fractions rather than world pixels so a notch feels the same +-- at both ends -- the near end of a linear step would crawl and the far +-- end would leap. +ThirdPerson.ZOOM_MIN = 0.45 -- ~22px: over the shoulder, close +ThirdPerson.ZOOM_MAX = 2.4 -- ~115px: the character in a landscape +ThirdPerson.ZOOM_STEP = 1.18 -- one wheel notch / key press +ThirdPerson.ZOOM_TIME = 0.18 -- how fast the eye eases to a new one + +ThirdPerson.zoom = 1 -- eased, what place() actually uses +ThirdPerson.zoomGoal = 1 -- what the input asked for + +-- Step the zoom by `notches` (positive pulls the camera OUT). Returns true +-- when the goal actually moved, so a caller can tell "zoomed" from "already +-- at the stop" and let the input fall through. +function ThirdPerson.stepZoom(notches) + local was = ThirdPerson.zoomGoal + local goal = was * (ThirdPerson.ZOOM_STEP ^ (notches or 0)) + ThirdPerson.zoomGoal = math.max(ThirdPerson.ZOOM_MIN, + math.min(ThirdPerson.ZOOM_MAX, goal)) + return ThirdPerson.zoomGoal ~= was +end + +-- Scale the zoom by a continuous factor -- what a pinch hands over, where +-- the gesture's own scale IS the answer and there are no notches. +function ThirdPerson.scaleZoom(factor) + if not (factor and factor > 0) then return false end + return ThirdPerson.stepZoom(math.log(factor) / math.log(ThirdPerson.ZOOM_STEP)) +end + +-- ------- the collision's numbers +-- +-- STEP is how far apart the samples along the boom line are, in world +-- pixels, and REFINE how many bisections narrow the first blocked one -- +-- four halvings of a 4px step lands the eye within a quarter pixel of the +-- face, which is finer than the boom ever needs to be. +-- +-- PAD is the clearance kept between the eye and whatever stopped it. It +-- has to beat the placed camera's near plane (|eye - focus| * 0.05, which +-- at full extension is about 3.6 world pixels -- see Voxel3D) or the near +-- plane clips a hole in the very wall the boom stopped at. +-- +-- CLEAR is how high above a cell's ground the eye must be to pass OVER +-- something unwalkable rather than being stopped by it: a fence, a kerb or +-- a plant pot should not shove the camera in, a building should. Roughly +-- head height, so the eye clears the props and never the walls. +ThirdPerson.STEP = 4 +ThirdPerson.REFINE = 4 +ThirdPerson.PAD = 5 +ThirdPerson.CLEAR = 20 + +-- How fast the boom is allowed to grow BACK once whatever shortened it is +-- out of the way, in world pixels per second. Shortening is instant (a +-- camera inside a wall is a hole in the frame); lengthening is rationed, +-- so rounding a corner eases the eye back out instead of snapping it. +ThirdPerson.RETURN = 150 + +-- ------- state +-- +-- `out` is the eased extension, 0 in the head and 1 fully boomed -- the +-- number that carries 1ST into 3RD. `len` is the boom's actual length in +-- world pixels after the world has had its say, which is what place() +-- stands the eye at and update() eases back toward `want`. +ThirdPerson.out = 0 +ThirdPerson.len = 0 +ThirdPerson.want = 0 + +local function ease(t) + return t * t * (3 - 2 * t) +end + +-- ------- gates + +-- Whether the 3RD rung is the one selected. Not "is the boom out" -- that +-- is extended() below, which stays true through the ease after the rung is +-- left, the same way FirstPerson.blend outlives its own rung. +-- +-- A live headset declines the boom outright: VR builds its own eye cameras +-- from the tracked pose and never asks place() where to stand, and a +-- headset that seats its wearer three cells behind their own body is a +-- well-known way to make people ill. Answering false here is what keeps +-- everything ELSE the extension decides -- the player's own card, the body +-- that turns as it walks -- honest about the head VR actually puts you in. +-- Required lazily and guarded: VR reaches this module through FirstPerson, +-- and a headless run has no VR module worth loading at all. +local function headset() + -- No VR module in this fork: a headset is never present. + local on = false + return on +end + +function ThirdPerson.selected() + return Voxel.isThirdPerson(Voxel.level) and not headset() +end + +-- The eased extension, 0 at the head and 1 at the full boom. +function ThirdPerson.extension() + return ease(ThirdPerson.out) +end + +-- Whether the boom is out far enough to be a third-person camera at all -- +-- read off the TARGET extension rather than the live length, so it is +-- steady while the world shoves the eye about. What the body reads to +-- decide whether it turns along its own travel. +function ThirdPerson.extended() + return ThirdPerson.extension() > 0.5 +end + +-- How far back the eye must ACTUALLY be, in world pixels, for the player's +-- own card to be worth drawing: a shade under a cell, which is the point +-- where a 16-pixel card stops being a character and starts being a wall of +-- pixels across the lens. +ThirdPerson.SHOW_AT = 14 + +-- Whether the player's own card belongs in the frame. Not the same +-- question as extended(): back into a fence and the boom collapses into +-- the head whatever the rung says, and a card drawn there fills the lens +-- from inside exactly as it would in first person -- so it comes out, and +-- the rung reads as first person for as long as the world insists on it. +function ThirdPerson.showsPlayer() + return ThirdPerson.extension() > 0 and ThirdPerson.len >= ThirdPerson.SHOW_AT +end + +-- ------- the world the boom has to fit through +-- +-- Everything below asks the live overworld and pcall-guards the asking: +-- with no map (headless, the suite, a frame mid-warp) the boom simply +-- extends to its full length, which is the right answer for a world with +-- nothing in it. + +local function overworld() + local ok, ow = pcall(function() + return require("src.core.Game").overworld + end) + if not ok or not ow or not ow.map then return nil end + return ow +end + +-- Which map, and which of its cells, covers a world point. The player's own +-- map first, then the neighbours the scene streams in around it (same ox/oy +-- offsets VoxelScene draws them at) -- without that pass the boom would +-- shorten against "off the map" every time the player walked within three +-- cells of a route connection, which is most of the time. +-- +-- nil means no map covers it: genuinely off the world, where the border +-- ring is drawn and the camera has no business going. +local function cellAt(ow, wx, wz) + local map = ow.map + local cx, cy = math.floor(wx / 16), math.floor(wz / 16) + if map:inBounds(cx, cy) then return map, cx, cy end + for _, nb in ipairs(ow.neighbors or {}) do + if nb.map then + local nx = math.floor((wx - (nb.ox or 0)) / 16) + local ny = math.floor((wz - (nb.oy or 0)) / 16) + if nb.map:inBounds(nx, ny) then return nb.map, nx, ny end + end + end + return nil +end + +-- Whether the eye may not stand at this world point. Two refusals, and +-- they are different questions: +-- +-- the GROUND is the terrain height field the mesh is actually built from +-- (VoxelScene.groundAt -- the same answer a character stands on), so a +-- ledge, a raised bank or a cliff stops the boom exactly where it stops +-- the geometry, at any pitch. +-- +-- the WALKABILITY is the map's own, and stands in for everything built +-- ON the ground that the height field does not describe: house walls, +-- trees, signs, counters. Held to CLEAR above that cell's ground so the +-- short furniture of the world is passed over rather than bumped into. +local function occupied(ow, wx, y, wz) + local map, cx, cy = cellAt(ow, wx, wz) + if not map then return true end + local VoxelScene = V.require("VoxelScene") + local okG, gh = pcall(VoxelScene.groundAt, map, cx, cy) + gh = (okG and gh) or 0 + if y < gh + ThirdPerson.PAD then return true end + local okW, walkable = pcall(function() return map:isWalkableCell(cx, cy) end) + if okW and not walkable and y < gh + ThirdPerson.CLEAR then return true end + return false +end + +ThirdPerson._occupied = occupied -- named for the suite + +-- How far back along (bx, by, bz) from `pivot` the eye can stand, up to +-- `want`. March at STEP, and when a sample refuses, bisect back into the +-- gap between it and the last clear one -- so the answer is the face's own +-- position rather than the sampling grid's, and walking toward a wall +-- draws the camera in smoothly instead of in four-pixel jerks. PAD comes +-- off whatever survives. +function ThirdPerson.reach(ow, pivot, bx, by, bz, want) + if not ow or want <= 0 then return math.max(0, want) end + local function clear(t) + return not occupied(ow, pivot[1] + bx * t, pivot[2] + by * t, + pivot[3] + bz * t) + end + local lo = 0 + local steps = math.ceil(want / ThirdPerson.STEP) + local hi = nil + for i = 1, steps do + local t = math.min(want, i * ThirdPerson.STEP) + if clear(t) then + lo = t + else + hi = t + break + end + end + if not hi then return want end + for _ = 1, ThirdPerson.REFINE do + local mid = (lo + hi) / 2 + if clear(mid) then lo = mid else hi = mid end + end + return math.max(0, lo - ThirdPerson.PAD) +end + +-- ------- the tick +-- +-- Rides FirstPerson.update, which is itself on the pipeline's own update +-- hook, so this runs every frame whatever the rung -- the extension has to +-- keep easing back in after 3RD is left. `blend` is FirstPerson's dive into +-- the head: while it is fully out (the diorama), the extension SNAPS to its +-- target rather than easing, so picking 3RD from an orbit rung is one +-- motion (the dive) rather than two (a dive, then a slide backwards). +function ThirdPerson.update(dt, blend) + -- the player's own zoom FIRST, so everything below measures itself + -- against the boom length this frame actually wants. A step is a request + -- rather than a jump: three notches of wheel should read as one glide. + local zg = ThirdPerson.zoomGoal + if ThirdPerson.zoom ~= zg then + local k = math.min(1, dt / ThirdPerson.ZOOM_TIME) + local z = ThirdPerson.zoom + (zg - ThirdPerson.zoom) * k + ThirdPerson.zoom = (math.abs(zg - z) < 1e-4) and zg or z + end + + local target = ThirdPerson.selected() and 1 or 0 + if (blend or 0) <= 0 then + ThirdPerson.out = target + ThirdPerson.len = ThirdPerson.reachFor() * target + -- and the wanted length with it: place() is what normally maintains it + -- and it does not run at all while the rig is out of the frame, so a + -- stale want left here would have the recovery below creeping the boom + -- back out over a camera that is not on screen + ThirdPerson.want = ThirdPerson.len + else + local step = dt / ThirdPerson.BOOM_TIME + if ThirdPerson.out < target then + ThirdPerson.out = math.min(target, ThirdPerson.out + step) + elseif ThirdPerson.out > target then + ThirdPerson.out = math.max(target, ThirdPerson.out - step) + end + end + + -- the rationed recovery: place() already pulled `len` in to whatever the + -- world allowed this frame, and this is the only thing that lets it back + -- out again + if ThirdPerson.len < ThirdPerson.want then + ThirdPerson.len = math.min(ThirdPerson.want, + ThirdPerson.len + ThirdPerson.RETURN * dt) + end +end + +-- The boom's full length right now, before the world has its say: BOOM at +-- the player's own zoom. Named so the collision march and the shoulder +-- fade measure themselves against the same number. +function ThirdPerson.reachFor() + return ThirdPerson.BOOM * ThirdPerson.zoom +end + +-- ------- the eye +-- +-- Where the camera stands, given the pivot the first-person rig would have +-- put the eye at and the unit look direction it would have looked along. +-- Returns the eye and the focus: both slide by the shoulder offset, so the +-- view direction is untouched and only the frame's contents shift. +-- +-- With the boom fully in this is exactly the first-person answer, to the +-- pixel -- which is what makes 1ST and 3RD one rig with a number between +-- them rather than two cameras to keep in sync. +function ThirdPerson.place(pivot, lx, ly, lz, focus) + local e = ThirdPerson.extension() + if e <= 0 then + ThirdPerson.want, ThirdPerson.len = 0, 0 + return pivot, focus + end + + local up = ThirdPerson.PIVOT_LIFT * e + local orbit = { pivot[1], pivot[2] + up, pivot[3] } + + local want = ThirdPerson.reachFor() * e + ThirdPerson.want = want + local room = ThirdPerson.reach(overworld(), orbit, -lx, -ly, -lz, want) + -- in instantly, out only as fast as update() allows + ThirdPerson.len = math.min(ThirdPerson.len, room) + local len = ThirdPerson.len + + -- the rail offset, faded with how much boom actually survived: a camera + -- squeezed against a wall gives up its shoulder before it gives up its + -- distance. Right of the look, flat: cross(look, worldUp) normalized, + -- which for a look of (sin y, *, cos y) is (-cos y, 0, sin y) -- the same + -- right hand FirstPerson.moveWorld strafes along. + -- The rail rides the ZOOM as well, so it stays the same fraction of the + -- frame at every distance: a fixed four pixels would swamp the close shot + -- and vanish from the wide one. + local flat = math.sqrt(lx * lx + lz * lz) + local sx, sz = 0, 0 + if flat > 1e-6 then + local s = ThirdPerson.SHOULDER * ThirdPerson.zoom * e + * (len / math.max(want, 1e-6)) + sx, sz = -lz / flat * s, lx / flat * s + end + + local eye = { orbit[1] - lx * len + sx, + orbit[2] - ly * len, + orbit[3] - lz * len + sz } + local aim = focus and { focus[1] + sx, focus[2] + up, focus[3] + sz } + or nil + return eye, aim +end + +-- What a shadow signature has to include about the boom: the sun's box is +-- fitted around this camera, so sliding the eye back (or having a wall +-- shove it in) re-fits it even standing still. +function ThirdPerson.signature() + if ThirdPerson.extension() <= 0 then return "" end + return math.floor(ThirdPerson.len) .. "/" .. + math.floor(ThirdPerson.extension() * 64) +end + +return ThirdPerson diff --git a/lib/VoxelScene.lua b/lib/VoxelScene.lua index 48c719d..8899b99 100644 --- a/lib/VoxelScene.lua +++ b/lib/VoxelScene.lua @@ -224,8 +224,16 @@ end -- reads its own shadowing with must describe the same frame, or the -- mirror-flip half of the pair asks the map about texels the sun filed -- under the other cheek. +-- The player's own card asks a different function for the same answer: +-- their body's bearing is what the camera is derived FROM, so it is known +-- continuously rather than as one of four directions, and measuring +-- against the compass point instead flicks the card to a profile for a +-- frame or two when the camera is spun fast (see playerFacing). local function viewFacing(p) if FirstPerson.cardBlend() > 0.5 then + if p.isPlayer then + return FirstPerson.playerFacing(p.facing, p.px + 8, p.py + 8) + end return FirstPerson.apparentFacing(p.facing, p.px + 8, p.py + 8) end return p.facing diff --git a/lib/VoxelState.lua b/lib/VoxelState.lua index 3e8bb45..c77597f 100644 --- a/lib/VoxelState.lua +++ b/lib/VoxelState.lua @@ -41,9 +41,9 @@ local Voxel = {} -- a head should start from. Everything angle-derived (the sky's fade, the -- billboard lean the blend eases away) reads that 75 while the first-person -- rig owns the actual camera. -Voxel.ANGLES_DEG = { 0, 35, 15, 35, 50, 75, 75 } +Voxel.ANGLES_DEG = { 0, 35, 15, 35, 50, 75, 75, 75 } Voxel.ANGLE_LABELS = { "OFF", "FULL", "15", "35", "50", "75", - "1ST (EXPERIMENTAL)" } + "1ST (EXPERIMENTAL)", "3RD (EXPERIMENTAL)" } Voxel.MAX_LEVEL = #Voxel.ANGLES_DEG - 1 -- the rung FULL sits on, so nothing has to hunt for it by label @@ -60,6 +60,25 @@ function Voxel.isFirstPerson(level) return (level or Voxel.level) == Voxel.FP_LEVEL end +-- and the third-person one, which is the same rig with the eye boomed off +-- the back of the head (lib/ThirdPerson.lua) +Voxel.TP_LEVEL = 7 + +function Voxel.isThirdPerson(level) + return (level or Voxel.level) == Voxel.TP_LEVEL +end + +-- The two of them together: the rungs where the camera stands WITH the +-- player rather than orbiting the view centre, which is what decides that +-- the look inputs are read, the walk goes free and the cards turn to face +-- the eye. Everything that used to ask isFirstPerson for those asks this. +function Voxel.isFreeCam(level) + level = level or Voxel.level + return Voxel.isFirstPerson(level) or Voxel.isThirdPerson(level) +end + + + -- ------- what the hotkey walks -- -- The ANGLE rungs only, with FULL left out. The key is a display-mode @@ -73,7 +92,7 @@ end -- exactly what the key promises -- and the key is also the way back OUT of -- first person on a keyboard, where the mouse is captured and the OPTIONS -- menu is a trip. -Voxel.HOTKEY_ORDER = { 0, 2, 3, 4, 5, 6 } -- OFF, 15, 35, 50, 75, 1ST +Voxel.HOTKEY_ORDER = { 0, 2, 3, 4, 5, 6, 7 } -- OFF, 15, 35, 50, 75, 1ST, 3RD -- The rung a press moves to from `level`. -- From 5a890ada7efc25173fc5065d37adf7af4ac1f403 Mon Sep 17 00:00:00 2001 From: absol89 Date: Tue, 4 Aug 2026 18:06:07 +0200 Subject: [PATCH 057/116] chore(main): wire CamControl.install for battle-camera steering Separate commit so the main.lua wiring can be reverted independently of the lib/ port. Adds CamControl.install() after FreeMove.install(); the 7b1ac9b FirstPerson/FreeMove are drop-in and need no other main.lua changes. No VR references. Co-Authored-By: Hermes Agent --- main.lua | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/main.lua b/main.lua index d0a42b2..393e260 100644 --- a/main.lua +++ b/main.lua @@ -878,6 +878,12 @@ OverworldBattle.install() FirstPerson.install() FreeMove.install() +-- CamControl.install wires the battle-camera zoom (wheel / pinch) and the +-- right-stick orbit: the inputs that steer the staged battle's rig. It is +-- pcall-guarded inside OverworldBattle.update, so calling it here only +-- matters when a battle is actually on screen. +V.require("CamControl").install() + -- The overworld's own pushBattle is the choke point for a wild encounter or -- a trainer, and it is wrapped. A battle that arrives some other way -- a -- link battle, a script pushing a BattleState directly -- reaches this From 2d3d319b3d821b1d88087dec935d8fab3e814885 Mon Sep 17 00:00:00 2001 From: absol89 Date: Tue, 4 Aug 2026 18:11:33 +0200 Subject: [PATCH 058/116] test(free-roam): add VR-free camera shots (battle + 3rd-person) Ports cam_shots.lua (battle-camera steering) and tp_shots.lua (third-person boom) from upstream dev commit 7b1ac9b. Both are VR-free. fp_shots.lua was already identical to 7b1ac9b, so left untouched. dramatic_shape_test.lua deliberately NOT ported from 7b1ac9b (it carries 76 VR references); the fork's existing copy is kept. Co-Authored-By: Hermes Agent --- tests/cam_shots.lua | 211 ++++++++++++++++++++++++++++++++++++++++++++ tests/tp_shots.lua | 198 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 409 insertions(+) create mode 100644 tests/cam_shots.lua create mode 100644 tests/tp_shots.lua diff --git a/tests/cam_shots.lua b/tests/cam_shots.lua new file mode 100644 index 0000000..3bcbba3 --- /dev/null +++ b/tests/cam_shots.lua @@ -0,0 +1,211 @@ +-- Scratch driver: the cameras the player steers -- the third-person boom's +-- zoom, and the battle shot's orbit, climb and lens, including the far +-- stops (side-on, 45 degrees up) and what BACK SPRITES takes away. +-- +-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/cam_shots.lua \ +-- SHOT_DIR=.scratchpad/camshots lovec.exe . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pipelines = require("src.render.Pipelines") + + local ROOT = (os.getenv("SHOT_DIR") or "shots/cam") + + local handle = game.mods.exports["DRAMATIC_SHAPE"] + if not (handle and handle.lib) then + print("[cam] DRAMATIC_SHAPE mod not loaded") + return love.event.quit() + end + local V = handle.lib + local FirstPerson = V.require("FirstPerson") + local ThirdPerson = V.require("ThirdPerson") + local BattleCam = V.require("BattleCam") + local OverworldBattle = V.require("OverworldBattle") + local ChunkMesher = V.require("ChunkMesher") + local Voxel = V.require("VoxelState") + local DayNight = V.require("DayNight") + + pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null') + pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul') + + require("src.world.OverworldController").rollEncounter = function() return nil end + local TileRenderer = require("src.render.TileRenderer") + TileRenderer.tick = function() end + -- The flower's animation frame, pinned per shot rather than left to the + -- clock: the mesh spans the union of every frame and each one is cut back + -- out in texture space, so a gap that only opens on frame 2 is invisible + -- to a driver that always photographs frame 0 -- which is exactly how the + -- first cut of the closed sides shipped looking correct. + local ANIM = { frame = 0 } + TileRenderer.animFrame = function() return ANIM.frame end + DayNight.setting:sync("day") + + local function settle() + for _ = 1, 900 do + if ChunkMesher.pending() == 0 then break end + U.wait(1) + end + U.wait(40) + end + + local shots = 0 + local function shot(name) + if U.shot(game, ("%s/%s.png"):format(ROOT, name)) then + shots = shots + 1 + end + end + + -- ------- the boom's zoom + U.teleport(game, "PALLET_TOWN", 13, 14, "down") + Pipelines.setLevel("voxel", Voxel.TP_LEVEL) + Pipelines.setLevel("tiltshift", 0) + settle() + for _ = 1, 200 do + if FirstPerson.blend >= 1 then break end + U.wait(1) + end + FirstPerson.yaw = math.pi + U.wait(20) + for _, z in ipairs({ "min", "default", "max" }) do + ThirdPerson.zoomGoal = (z == "min" and ThirdPerson.ZOOM_MIN) + or (z == "max" and ThirdPerson.ZOOM_MAX) or 1 + for _ = 1, 90 do U.wait(1) end + print(("[cam] boom %-8s zoom %.2f len %.1f"):format(z, ThirdPerson.zoom, + ThirdPerson.len)) + shot("boom_" .. z) + end + ThirdPerson.zoomGoal = 1 + U.wait(60) + + -- ------- the standees' closed sides + -- + -- Pallet's flower beds and Route 1's tall grass, from a low camera close + -- in -- the angle that showed the slabs were open at the ends of every + -- run and let you see straight through them. + ThirdPerson.zoomGoal = ThirdPerson.ZOOM_MIN + for _, s in ipairs({ + { map = "PALLET_TOWN", x = 13, y = 14, yaw = math.pi, + pitch = math.rad(6), label = "flowers_low" }, + { map = "PALLET_TOWN", x = 13, y = 14, yaw = 3 * math.pi / 4, + pitch = math.rad(30), label = "flowers_angled" }, + { map = "ROUTE_1", x = 10, y = 28, yaw = math.pi, + pitch = math.rad(8), label = "grass_low" }, + }) do + U.teleport(game, s.map, s.x, s.y, "down") + settle() + FirstPerson.yaw, FirstPerson.pitch = s.yaw, s.pitch + U.wait(60) + -- EVERY animation frame, because a gap the union closed and a frame + -- reopens is only visible on that frame + for f = 0, 3 do + ANIM.frame = f + U.wait(6) + shot(("%s_f%d"):format(s.label, f)) + end + ANIM.frame = 0 + end + ThirdPerson.zoomGoal = 1 + U.wait(60) + + -- ------- the battle shot + -- + -- Staged the way the game stages one, then steered to each stop with the + -- module's own entry points -- the same ones the wheel, the stick and a + -- drag reach. + Pipelines.setLevel("voxel", 4) + U.wait(60) + do + -- Somebody to fight WITH: a fresh driver save has an empty party, and + -- a trainer battle with nobody to send out ends on the frame it starts + -- -- which is what made every steered shot below sample a dead session. + local Pokemon = require("src.pokemon.Pokemon") + game.save.party = { + Pokemon.new(game.data, "CHARIZARD", 45), + Pokemon.new(game.data, "PIKACHU", 40), + } + game.save.player.name = "RED" + local BattleState = require("src.battle.BattleState") + local class = next(game.data.trainers) + local battle = BattleState.newTrainer(game, class, 1) + battle.onFinish = function() end + game.overworld:pushBattle(battle) + end + -- The wipe, then just enough of the send-out chatter to get both mons + -- standing on the arena -- and NOT one tap more. A is also FIGHT and then + -- the first move, so tapping past the intro starts an exchange and the + -- fight can be over before the camera has been steered anywhere. + U.wait(70) + for _ = 1, 40 do + if OverworldBattle.shot() then break end + U.tap(game, "a") + U.wait(10) + end + U.wait(60) + print(("[cam] staged: arena %s shot %s top %s") + :format(tostring(OverworldBattle.arena() ~= nil), + tostring(OverworldBattle.shot() ~= nil), + tostring(game.stack:top() ~= game.overworld))) + if not OverworldBattle.shot() then + print("[cam] no staged battle -- skipping the battle shots") + print(("[cam] %d shots into %s"):format(shots, ROOT)) + return love.event.quit() + end + + local function settleCam(label) + for _ = 1, 120 do U.wait(1) end + print(("[cam] battle %-20s orbit %.2f/%.2f pitch %.2f/%.2f " + .. "zoom %.2f/%.2f steerable %s live %s") + :format(label, BattleCam.orbit, BattleCam.orbitGoal, + BattleCam.pitch, BattleCam.pitchGoal, + BattleCam.zoom, BattleCam.zoomGoal, + tostring(BattleCam.steerable), + tostring(OverworldBattle.shot() ~= nil))) + shot("battle_" .. label) + end + + BattleCam.recentre() + settleCam("home") + + -- right to the stop: this must land SQUARE to the arena's axis + BattleCam.dragOrbit(10) + settleCam("side_on") + + -- and there is nothing to the left of home + BattleCam.recentre() + BattleCam.dragOrbit(-10) + settleCam("left_stop") + + -- up to the stop, and refusing to go below home + BattleCam.recentre() + BattleCam.dragPitch(10) + settleCam("high") + BattleCam.recentre() + BattleCam.dragPitch(-10) + settleCam("low_stop") + + -- the lens at both ends + BattleCam.recentre() + for _ = 1, 40 do BattleCam.stepZoom(-1) end + settleCam("zoom_in") + BattleCam.recentre() + for _ = 1, 40 do BattleCam.stepZoom(1) end + settleCam("zoom_out") + + -- everything at once, which is the shot a player would actually build + BattleCam.recentre() + BattleCam.dragOrbit(0.55) + BattleCam.dragPitch(0.5) + for _ = 1, 4 do BattleCam.stepZoom(-1) end + settleCam("steered") + + -- What BACK SPRITES takes away is NOT shot here: the flag it works + -- through is re-derived from the row every frame + -- (OverworldBattle.update), so a driver cannot hold it down for the + -- hundred-odd frames a settled shot needs, and a picture that claimed to + -- show a locked camera while the camera was in fact free would be worse + -- than no picture. The suite asserts it instead, on both axes and the + -- lens, and on the RIG as well as on the inputs. + BattleCam.recentre() + + print(("[cam] %d shots into %s"):format(shots, ROOT)) + love.event.quit() +end diff --git a/tests/tp_shots.lua b/tests/tp_shots.lua new file mode 100644 index 0000000..57e45c7 --- /dev/null +++ b/tests/tp_shots.lua @@ -0,0 +1,198 @@ +-- Scratch driver: shots of the 3RD (third-person) rung -- the eye boomed +-- off the back of the player's head, the player's own card drawn and turned +-- to face it, the boom shortening against walls indoors, and the body +-- turning to face where it walks. +-- +-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/tp_shots.lua \ +-- SHOT_DIR=.scratchpad/tpshots lovec.exe . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pipelines = require("src.render.Pipelines") + + local ROOT = (os.getenv("SHOT_DIR") or "shots/tp") + + local handle = game.mods.exports["DRAMATIC_SHAPE"] + if not (handle and handle.lib) then + print("[tp] DRAMATIC_SHAPE mod not loaded") + return love.event.quit() + end + local V = handle.lib + local FirstPerson = V.require("FirstPerson") + local ThirdPerson = V.require("ThirdPerson") + local ChunkMesher = V.require("ChunkMesher") + local Voxel = V.require("VoxelState") + local DayNight = V.require("DayNight") + + pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null') + pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul') + + require("src.world.OverworldController").rollEncounter = function() return nil end + local TileRenderer = require("src.render.TileRenderer") + TileRenderer.tick = function() end + TileRenderer.animFrame = function() return 0 end + DayNight.setting:sync("day") + + local Zoom = require("src.render.Zoom") + pcall(function() + game.save.options.zoom = 0 + Zoom.applyOptions(game.save.options) + end) + + local function settle() + for _ = 1, 900 do + if ChunkMesher.pending() == 0 then break end + U.wait(1) + end + for _ = 1, 300 do + if FirstPerson.blend >= 1 and Voxel.ready + and ChunkMesher.pending() == 0 then break end + U.wait(1) + end + U.wait(40) + end + + -- the nearest WALKABLE cell (fp_shots' helper): a guessed coordinate + -- inside a building footprint buries the pivot in the geometry + local function place(mapId, x, y) + U.teleport(game, mapId, x, y, "down") + local ow = game.stack:top() + local map = ow and ow.map + if not map or map:isWalkableCell(x, y) then return end + for r = 1, 8 do + for dy = -r, r do + for dx = -r, r do + if math.max(math.abs(dx), math.abs(dy)) == r then + local cx, cy = x + dx, y + dy + if map:inBounds(cx, cy) and map:isWalkableCell(cx, cy) then + U.teleport(game, mapId, cx, cy, "down") + print(("[tp] (%d,%d) not walkable; standing at (%d,%d)") + :format(x, y, cx, cy)) + return + end + end + end + end + end + end + + -- yaw is a world bearing: 0 south, pi/2 east, pi north, -pi/2 west + local SCENES = { + -- Pallet Town, mid-street: the player's own card seen from behind, in + -- each compass direction plus a diagonal (the off-grid case, where a + -- south-facing card would be edge-on to any orbit camera) + { map = "PALLET_TOWN", x = 13, y = 14, yaw = math.pi, label = "pallet_north" }, + { map = "PALLET_TOWN", x = 13, y = 14, yaw = 0, label = "pallet_south" }, + { map = "PALLET_TOWN", x = 9, y = 7, yaw = math.pi / 2, label = "pallet_east" }, + { map = "PALLET_TOWN", x = 9, y = 7, yaw = 3 * math.pi / 4, + label = "pallet_diag" }, + -- the shoreline: the boom over water, reflecting the player + { map = "PALLET_TOWN", x = 9, y = 12, yaw = 0, label = "pallet_water" }, + -- pitched down, the classic third-person framing; and up, where the + -- boom has to shorten rather than bury the eye in the ground + { map = "PALLET_TOWN", x = 13, y = 14, yaw = math.pi, + pitch = math.rad(35), label = "pallet_down" }, + { map = "PALLET_TOWN", x = 13, y = 14, yaw = math.pi, + pitch = -math.rad(40), label = "pallet_skyward" }, + -- Route 1: grass and ledges, with the character in frame for scale + { map = "ROUTE_1", x = 10, y = 28, yaw = math.pi, label = "route1_north" }, + -- interiors: the one place a 48px boom cannot fit, so the collision + -- march is the whole shot + { map = "VIRIDIAN_POKECENTER", x = 3, y = 5, yaw = math.pi, + label = "center_north" }, + { map = "REDS_HOUSE_1F", x = 3, y = 4, yaw = math.pi, + label = "reds_house" }, + } + + local shots = 0 + for _, s in ipairs(SCENES) do + place(s.map, s.x, s.y) + Pipelines.setLevel("voxel", Voxel.TP_LEVEL) + Pipelines.setLevel("tiltshift", 0) + settle() + FirstPerson.yaw = s.yaw + FirstPerson.pitch = s.pitch or FirstPerson.PITCH_DEFAULT + U.wait(20) + print(("[tp] %-16s boom %.1f of %.1f"):format(s.label, ThirdPerson.len, + ThirdPerson.BOOM)) + if U.shot(game, ("%s/%s.png"):format(ROOT, s.label)) then + shots = shots + 1 + end + end + + -- the slide from 1ST to 3RD: the eye walks out of the head rather than + -- cutting, so the halfway frame is a real camera position + place("PALLET_TOWN", 13, 14) + Pipelines.setLevel("voxel", Voxel.FP_LEVEL) + settle() + if U.shot(game, ROOT .. "/from_1st.png") then shots = shots + 1 end + Pipelines.setLevel("voxel", Voxel.TP_LEVEL) + for _ = 1, 300 do + if ThirdPerson.out >= 0.5 then break end + U.wait(1) + end + if U.shot(game, ROOT .. "/boom_mid.png") then shots = shots + 1 end + U.wait(60) + if U.shot(game, ROOT .. "/boom_out.png") then shots = shots + 1 end + + -- ------- the free walk, with the body turning + -- + -- Hold forward with the head yawed off-grid: the player must GLIDE (off + -- the 16px grid, which no grid step can do) and the body must end up + -- facing its own travel rather than the lens. + place("PALLET_TOWN", 13, 14) + Pipelines.setLevel("voxel", Voxel.TP_LEVEL) + settle() + local ow = game.stack:top() + local p = ow.player + FirstPerson.yaw = 3 * math.pi / 4 -- northeast, deliberately off-grid + FirstPerson.pitch = FirstPerson.PITCH_DEFAULT + local x0, y0 = p.px, p.py + U.hold(game, "up", 90) + local moved = math.abs(p.px - x0) + math.abs(p.py - y0) + print(("[tp] walk moved %.1f px; off-grid: %s; body faces %s (camera %s)") + :format(moved, + tostring(p.px % 16 ~= 0 or p.py % 16 ~= 0), + tostring(p.facing), tostring(FirstPerson.compassFacing()))) + if U.shot(game, ROOT .. "/walked.png") then shots = shots + 1 end + + -- strafing: the one case the body facing exists for. Hold RIGHT with the + -- head due north and the character must walk east showing its flank. + place("PALLET_TOWN", 13, 14) + settle() + FirstPerson.yaw = math.pi + U.hold(game, "right", 40) + print(("[tp] strafe: body faces %s, camera looks %s") + :format(tostring(p.facing), tostring(FirstPerson.compassFacing()))) + if U.shot(game, ROOT .. "/strafe.png") then shots = shots + 1 end + + -- ------- the spin, sampled + -- + -- Stand still and turn the camera fast, sampling the frame the player's + -- own card actually draws every rendered frame. A standing body is + -- pointed along the camera's own yaw, so it must show its back the whole + -- way round; anything else is the sideways flick. Sampled through the + -- live rig, so this measures the real thing rather than the arithmetic. + place("PALLET_TOWN", 13, 14) + Pipelines.setLevel("voxel", Voxel.TP_LEVEL) + settle() + ow = game.stack:top() + p = ow.player + local seen, frames = {}, 0 + for _ = 1, 240 do + FirstPerson.lookBy(math.rad(7), 0) -- ~420 deg/s, a hard flick + U.wait(1) + local f = FirstPerson.playerFacing(p.facing, p.px + 8, p.py + 8) + seen[f] = (seen[f] or 0) + 1 + frames = frames + 1 + end + local report = {} + for f, n in pairs(seen) do + report[#report + 1] = ("%s x%d"):format(f, n) + end + table.sort(report) + print(("[tp] spin: %d frames, card frames seen: %s") + :format(frames, table.concat(report, ", "))) + + print(("[tp] %d shots into %s"):format(shots, ROOT)) + love.event.quit() +end From fbd9b75dd631a8e6ef176fd362416c06685ce6ef Mon Sep 17 00:00:00 2001 From: absol89 Date: Tue, 4 Aug 2026 18:11:57 +0200 Subject: [PATCH 059/116] chore(release): bump version to 1.65 Marks the free-roam camera steering + 1st/3rd-person control port from 7b1ac9b (commits 3cd11a9, 5a890ad, 2d3d319). Engine runtime still 0.1.60-class; version exceeds upstream to suppress update nags. Co-Authored-By: Hermes Agent --- main.lua | 2 +- manifest.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/main.lua b/main.lua index 393e260..1bf63e1 100644 --- a/main.lua +++ b/main.lua @@ -987,7 +987,7 @@ mod.hooks:wrap("world.tod", function(next, tod, ctx) return DayNight.tod() end) -mod.exports.version = "1.63" +mod.exports.version = "1.65" -- exposed so a companion mod can pin its own tiles' shapes or read the -- camera without reaching into this mod's file layout mod.exports.lib = V diff --git a/manifest.json b/manifest.json index 9075c0c..a5f5afd 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "DRAMATIC_SHAPE", "name": "DRAMATIC SHAPE VOXEL MOD BATTLE ART", - "version": "1.63", + "version": "1.65", "api": 2, "entry": "main.lua", "profile": "content", From e10c060133fe3f31b9f584c5018252e1aedf477c Mon Sep 17 00:00:00 2001 From: absol89 Date: Tue, 4 Aug 2026 18:34:35 +0200 Subject: [PATCH 060/116] fix(boot): guard Horde/HordeGun requires so missing modules don't crash 7b1ac9b's FirstPerson/FreeMove call V.require("Horde") and V.require("HordeGun") at runtime. This fork never ships lib/Horde.lua, so the mod loader threw "lib/Horde.lua is missing -- reinstall the mod" at boot. Horde mode is not part of this fork, so all those calls are now pcall-guarded and no-op when the modules are absent. Free-roam camera, battle steering, 1st/3rd person, and iOS fixes are unaffected. Co-Authored-By: Hermes Agent --- lib/FirstPerson.lua | 29 ++++++++++++++++++----------- lib/FreeMove.lua | 6 +++++- 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/lib/FirstPerson.lua b/lib/FirstPerson.lua index 44241bb..077f6ba 100644 --- a/lib/FirstPerson.lua +++ b/lib/FirstPerson.lua @@ -760,13 +760,21 @@ function FirstPerson.install() local mouseHeld = {} local MOUSE_BTN = { [1] = "a", [2] = "b" } local function hordeMouse(button, down) - local Horde = V.require("Horde") - if not Horde.playing() then return false end + -- Horde mode is not part of this fork: skip it cleanly rather than + -- requiring a module that is not shipped. + local ok, Horde = pcall(V.require, "Horde") + if not (ok and Horde and Horde.playing) or not Horde.playing() then + return false + end if button == 1 then - if down then V.require("HordeGun").fire() end + if down then + local okG, Gun = pcall(V.require, "HordeGun") + if okG and Gun then Gun.fire() end + end return true elseif button == 2 then - V.require("HordeGun").setAds(down) + local okG, Gun = pcall(V.require, "HordeGun") + if okG and Gun then Gun.setAds(down) end return true end return false @@ -829,13 +837,12 @@ function FirstPerson.install() local onControl = nil pcall(function() onControl = TouchControls:hitTest(x, y) end) if not onControl and not lookTouch then - -- HORDE MODE: a tap on open screen is a SHOT, fired on the press - -- rather than on a release that turned out not to be a drag -- - -- a shooter that waits to find out whether you meant it is a - -- shooter that misses. The same finger still becomes the look - -- drag below, so aiming and firing are one gesture. - if V.require("Horde").playing() then - V.require("HordeGun").fire() + -- HORDE MODE: a tap on open screen is a SHOT ... (not shipped in + -- this fork, so the shot never fires here). + local okH, Horde = pcall(V.require, "Horde") + if okH and Horde and Horde.playing and Horde.playing() then + local okG, Gun = pcall(V.require, "HordeGun") + if okG and Gun then Gun.fire() end end lookTouch = { id = id, x = x, y = y } return diff --git a/lib/FreeMove.lua b/lib/FreeMove.lua index bf5f9c4..61fcac1 100644 --- a/lib/FreeMove.lua +++ b/lib/FreeMove.lua @@ -264,7 +264,11 @@ function FreeMove.tick(state) -- wants left unspent). Everything below -- the walk, the wall slide and -- the blocked-push verbs, warps included -- keeps working, because the -- crowd has to be able to follow the player through a door. - local suppressed = V.require("Horde").suppressWorldInput() + -- Horde mode is not shipped in this fork: suppressWorldInput is absent, + -- so default to "not suppressed" and let the walk/interact work normally. + local okH, Horde = pcall(V.require, "Horde") + local suppressed = (okH and Horde and Horde.suppressWorldInput) + and Horde.suppressWorldInput() or false if not suppressed and input:wasPressed("a") then state:interact() From d39ad9cfee5002733c2e1f7ae2f7827a759f90fb Mon Sep 17 00:00:00 2001 From: absol89 Date: Tue, 4 Aug 2026 19:27:58 +0200 Subject: [PATCH 061/116] feat(ui): sprite light UNLIT/SHADED (1.66 backplate C) Adds the SPRITE LIGHT option (SHADED default / UNLIT) as a ModSetting in the new lib/UiBackplates.lua registry. When UNLIT, the mon cards in BattleScene.render skip their day-tint and shadow lookup and draw flat and full bright -- matching the OG battle's unshaded sprites and, crucially, staying readable on the white arena fill (B) that follows. Registered as an OPTIONS row + mod-manager schema alongside the other battle settings; default SHADED leaves current behaviour unchanged. Co-Authored-By: Hermes Agent --- lib/BattleScene.lua | 11 +++++-- lib/UiBackplates.lua | 70 ++++++++++++++++++++++++++++++++++++++++++++ main.lua | 20 +++++++++++++ 3 files changed, 98 insertions(+), 3 deletions(-) create mode 100644 lib/UiBackplates.lua diff --git a/lib/BattleScene.lua b/lib/BattleScene.lua index 5913bb5..93d29db 100644 --- a/lib/BattleScene.lua +++ b/lib/BattleScene.lua @@ -42,6 +42,7 @@ local BattleCam = V.require("BattleCam") local BattleBillboard = V.require("BattleBillboard") local VoxelGrid = V.require("VoxelGrid") local DayNight = V.require("DayNight") +local UiBackplates = V.require("UiBackplates") local AntiAlias = V.require("AntiAlias") local PaletteFX = require("src.render.PaletteFX") local Map = require("src.world.Map") @@ -496,10 +497,14 @@ function BattleScene.render(state, arena, textures, token) -- of being dimmed or colour-cast by the clock. Only the hour tint is -- neutral here; depth and alpha-shaped lighting/shadows stay active. if card.noDayTint then Voxel3D.dayTint({ 1, 1, 1 }) end - -- the sun stored this card snugged (castShadows), so its own shadow - -- lookup must read the same snugged transform -- see ShadowMap.snug + -- SPRITE LIGHT: UNLIT draws the card flat and full bright -- no day + -- tint and no shadow lookup -- so it stays readable on the white arena + -- fill (B) and matches the OG battle's unshaded sprites. SHADED (the + -- default) keeps the world's hour tint and its own cast shadow. + local unlit = UiBackplates.spritesUnlit() Voxel3D.draw(BattleBillboard.mesh(), card.tex, card.model, - BattleBillboard.PULL, ShadowMap.snug(card.model)) + BattleBillboard.PULL, + unlit and nil or ShadowMap.snug(card.model)) if card.noDayTint then Voxel3D.dayTint() end end Voxel3D.glass(true) diff --git a/lib/UiBackplates.lua b/lib/UiBackplates.lua new file mode 100644 index 0000000..ad762ca --- /dev/null +++ b/lib/UiBackplates.lua @@ -0,0 +1,70 @@ +-- The three battle-UI backplate options added for the 1.66 update: +-- +-- C) SPRITE LIGHT UNLIT / SHADED -- whether the mon cards receive the +-- world's day tint and cast shadows (SHADED) or draw flat and full +-- bright (UNLIT). UNLIT is what the white arena fill (B) needs, so the +-- cards stay readable on a solid white field. +-- +-- B) ARENA FILL WHITE / OFF -- a solid white rendering layer in +-- front of the whole voxel world, with only the mons, their attack +-- animations and the menus above it. Hides the 3D terrain while keeping +-- the animated sprites -- the middle step between the OG battle and the +-- full voxel one. Requires sprite light UNLIT. +-- +-- A) TEXTBOX FILL HALF / OFF -- a semi-transparent backplate behind +-- the dialogue text box ONLY (the bottom 48px of the 160x144 battle +-- screen, GB-frame rect {0,96,160,48}). The player and opponent HUD +-- blocks are separate rects and are never covered. +-- +-- Each is a ModSetting: it gets an OPTIONS-menu row and a mod-manager schema +-- for free, and persists under options.modOptions.DRAMATIC_SHAPE like the +-- others. Defining them here -- rather than inline in main.lua -- keeps the +-- three of them, and the render-path queries they answer, in one place. + +-- the mod namespace (see main.lua): V.require loads a sibling module +local V = ... + +local ModSetting = V.require("ModSetting") + +local UiBackplates = {} + +-- ------- C) SPRITE LIGHT ------- + +UiBackplates.spriteLight = ModSetting.new("spriteLight", "SPRITE LIGHT", + { "SHADED", "UNLIT" }, { "SHADED", "UNLIT" }) + +-- Whether the mon cards should be drawn flat and full bright (UNLIT) rather +-- than receiving the world's day tint and shadows (SHADED). The card-draw +-- path in BattleScene calls this to decide whether to skip dayTint/shadow. +function UiBackplates.spritesUnlit() + return UiBackplates.spriteLight:get() == "UNLIT" +end + +-- ------- B) ARENA FILL ------- + +UiBackplates.arenaFill = ModSetting.new("arenaFill", "ARENA FILL", + { "OFF", "WHITE" }, { "OFF", "WHITE" }) + +-- Whether to draw the solid white layer over the voxel world. The white +-- layer only makes sense with sprites unlit, so this also reports false when +-- the player has left sprite light on SHADED -- a white field under shaded +-- (dimmed) cards would just be a dimmer white field for no reason. +function UiBackplates.arenaWhite() + return UiBackplates.arenaFill:get() == "WHITE" + and UiBackplates.spritesUnlit() +end + +-- ------- A) TEXTBOX FILL ------- + +UiBackplates.textboxFill = ModSetting.new("textboxFill", "TEXTBOX FILL", + { "OFF", "HALF" }, { "OFF", "HALF" }) + +-- The backplate alpha for the dialogue text box, or nil when the backplate is +-- off. HALF is a fixed semi-transparent slab, not the frosted HUD panel -- the +-- point is a clean, bounded rectangle the text sits on, not a blurred window +-- into the world behind it. +function UiBackplates.textboxAlpha() + return UiBackplates.textboxFill:get() == "HALF" and 0.5 or nil +end + +return UiBackplates diff --git a/main.lua b/main.lua index 1bf63e1..d64d005 100644 --- a/main.lua +++ b/main.lua @@ -86,6 +86,7 @@ local VoxelGrid = V.require("VoxelGrid") local WorldCurve = V.require("WorldCurve") local OverworldBattle = V.require("OverworldBattle") local BattleArt = V.require("BattleArt") +local UiBackplates = V.require("UiBackplates") local BattleExit = V.require("BattleExit") local DayNight = V.require("DayNight") local DayTint = V.require("DayTint") @@ -432,6 +433,25 @@ local SETTINGS = { .. "let CYCLE run it -- ten minutes of sun, ten of moon, with the " .. "shadows, the sky and the light following -- or SYNC it to the " .. "clock on the wall, so Kanto's evening falls when yours does." }, + -- ------- 1.66 UI backplates (see lib/UiBackplates.lua) ------- + { UiBackplates.spriteLight, + "SHADED lets the mons receive the world's day tint and cast shadows; " + .. "UNLIT draws them flat and full bright. UNLIT is what the white " + .. "arena fill needs, and what the OG battle's sprites look like.", + when = function() return stagedBattles() end, full = true }, + { UiBackplates.arenaFill, + "WHITE draws a solid white layer in front of the whole voxel world, " + .. "with only the mons, their attack animations and the menus above it " + .. "-- the step between the OG battle and the full 3D one. Needs SPRITE " + .. "LIGHT: UNLIT to render the cards readable on white.", + when = function() + return stagedBattles() and UiBackplates.spritesUnlit() + end, full = true }, + { UiBackplates.textboxFill, + "HALF lays a semi-transparent backplate behind the dialogue text box " + .. "only -- not the player or opponent HUD blocks -- so the words read " + .. "over any ground. OFF leaves the text box as the engine draws it.", + full = true }, -- Marked `full` for the opposite reason the battle rows are: this is not a -- knob on the look at all, it is what the look COSTS. FULL is a preset for -- the diorama, not a licence to spend four times the fill rate on the From 814a3862cb7aa02a39ac43490044ddac07ded376 Mon Sep 17 00:00:00 2001 From: absol89 Date: Tue, 4 Aug 2026 19:29:30 +0200 Subject: [PATCH 062/116] feat(ui): arena fill WHITE/OFF (1.66 backplate B) Adds the ARENA FILL option (OFF default / WHITE), implemented in BattleScene.render. When WHITE, the scene clears to solid white and the terrain/water/grass/flower draws are skipped, leaving only the mon cards above the field -- the step between the OG battle and the full voxel one. The 2D attack animations and the menus composite on top afterwards, so they stay above the white layer too. Gated on UiBackplates.arenaWhite(), which also requires SPRITE LIGHT: UNLIT (set in the same commit's companion setting), so the cards stay readable on white. Default OFF leaves the full voxel battle unchanged. Co-Authored-By: Hermes Agent --- lib/BattleScene.lua | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/lib/BattleScene.lua b/lib/BattleScene.lua index 93d29db..afc579c 100644 --- a/lib/BattleScene.lua +++ b/lib/BattleScene.lua @@ -446,9 +446,18 @@ function BattleScene.render(state, arena, textures, token) -- pw and ph, and why the HUDs and the depth of field, drawn onto the -- folded canvas afterwards, stay the chunky GB art they are. local rw, rh = AntiAlias.expand(pw, ph) - if not Voxel3D.beginScene(rw, rh, cx, cy, vw, vh, sky, "battle") then + -- ARENA FILL: WHITE covers the whole voxel world with a solid field and + -- keeps only the mons (drawn below) above it -- the step between the OG + -- battle and the full 3D one. Implemented by clearing the scene to white + -- and skipping the terrain/water/grass/flower draws; the 2D attack + -- animations and the menus composite on top afterwards, so they stay + -- above the white too. Requires sprite light UNLIT (see UiBackplates). + local whiteFill = UiBackplates.arenaWhite() + local skyFill = whiteFill and { 1, 1, 1 } or sky + if not Voxel3D.beginScene(rw, rh, cx, cy, vw, vh, skyFill, "battle") then return end + if not whiteFill then Voxel3D.draw(terrain, atlasFor(host), nil) for i, nb in ipairs(neighbors) do Voxel3D.draw(nbMesh[i], atlasFor(nb.map), @@ -470,6 +479,7 @@ function BattleScene.render(state, arena, textures, token) Mat4.translate(nb.ox, 0, nb.oy)) end end + end -- The mons, standing on their tiles. Depth-tested like everything else, -- so a ledge or a tree between the camera and a Pokemon really is in -- front of it, and the alpha discard cuts the sprite's own outline out of @@ -514,6 +524,7 @@ function BattleScene.render(state, arena, textures, token) -- gives them, measured against THIS camera's pitch rather than the -- orbit's -- there is no character here for them to overdraw, but the -- pull is also what keeps a tuft from z-fighting the floor it stands on + if not whiteFill then local pull = VoxelScene.pull(math.max(pitch, 0.05)) Voxel3D.draw(ChunkMesher.grass(host), atlasFor(host), nil, pull) for _, nb in ipairs(neighbors) do @@ -528,6 +539,7 @@ function BattleScene.render(state, arena, textures, token) Mat4.translate(nb.ox, 0, nb.oy), fpull, ShadowMap.snug(Mat4.translate(nb.ox, 0, nb.oy))) end + end local canvas = AntiAlias.resolve(Voxel3D.endScene(), pw, ph, "battle") if not canvas then return end From 4a44ea9d9a4aaac3596ef14e251be30c6dfa4704 Mon Sep 17 00:00:00 2001 From: absol89 Date: Tue, 4 Aug 2026 19:30:46 +0200 Subject: [PATCH 063/116] feat(ui): textbox backplate HALF/OFF (1.66 backplate A) Adds the TEXTBOX FILL option (OFF default / HALF), drawn in OverworldBattle where the dialogue-box glass is laid down (both the snapHUDs world-image path and the drawHudPanels fallback). When HALF, a semi-transparent white slab fills the box's own GB-frame rect {0,96,160,48} -- the engine's text box area -- and no other. The player and opponent HUD blocks are separate rects and are never covered, so the backplate cannot venture outside the marked area. Default OFF leaves the text box as the engine draws it. Co-Authored-By: Hermes Agent --- lib/OverworldBattle.lua | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/lib/OverworldBattle.lua b/lib/OverworldBattle.lua index a7b206c..7f26188 100644 --- a/lib/OverworldBattle.lua +++ b/lib/OverworldBattle.lua @@ -44,6 +44,7 @@ local BattleCam = V.require("BattleCam") local BattleScene = V.require("BattleScene") local BattleDOF = V.require("BattleDOF") local BattleHud = V.require("BattleHud") +local UiBackplates = V.require("UiBackplates") local BattlePics = V.require("BattlePics") local BattleArt = V.require("BattleArt") local AnimatedBattleArt = V.require("AnimatedBattleArt") @@ -1407,8 +1408,18 @@ function OverworldBattle.snapHUDs(battle, shot) local ok, err = pcall(function() g.setCanvas(shot.canvas) g.setBlendMode("alpha") - for _, rect in pairs(live) do + for key, rect in pairs(live) do BattleHud.panel(rect, shot, whiteInk, true) + -- TEXTBOX FILL: a semi-transparent backplate behind the dialogue box + -- only -- not the player/opponent HUD blocks, which are separate rects. + -- Bounded to the box's own GB-frame rectangle so it cannot venture + -- outside it. Drawn into the world image alongside the frosted panel. + local a = UiBackplates.textboxAlpha() + if a and key == "box" then + g.setColor(1, 1, 1, a) + g.rectangle("fill", rect[1], rect[2], rect[3], rect[4]) + g.setColor(1, 1, 1, 1) + end end g.setColor(1, 1, 1, 1) for side, band in pairs(OverworldBattle.HUD_BAND) do @@ -1450,7 +1461,16 @@ function OverworldBattle.drawHudPanels(battle) for key, r in pairs(OverworldBattle.textRects(battle)) do live[key] = r end if not next(live) then return end battle.dramaticShapeDark = true - for _, r in pairs(live) do BattleHud.panel(r, shot, true) end + for key, r in pairs(live) do + BattleHud.panel(r, shot, true) + local a = UiBackplates.textboxAlpha() + if a and key == "box" then + local g = love.graphics + g.setColor(1, 1, 1, a) + g.rectangle("fill", r[1], r[2], r[3], r[4]) + g.setColor(1, 1, 1, 1) + end + end end return OverworldBattle From cd96d25c5a5268a88899ca3f7f93ce5c590352fa Mon Sep 17 00:00:00 2001 From: absol89 Date: Tue, 4 Aug 2026 19:31:10 +0200 Subject: [PATCH 064/116] chore(release): bump version to 1.66 Marks the 1.66 UI backplate options: SPRITE LIGHT (UNLIT/SHADED), ARENA FILL (WHITE/OFF), and TEXTBOX FILL (HALF/OFF), added as three ModSettings in lib/UiBackplates.lua and wired through BattleScene and OverworldBattle. Version exceeds upstream to suppress update nags. Co-Authored-By: Hermes Agent --- main.lua | 2 +- manifest.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/main.lua b/main.lua index d64d005..8b454d9 100644 --- a/main.lua +++ b/main.lua @@ -1007,7 +1007,7 @@ mod.hooks:wrap("world.tod", function(next, tod, ctx) return DayNight.tod() end) -mod.exports.version = "1.65" +mod.exports.version = "1.66" -- exposed so a companion mod can pin its own tiles' shapes or read the -- camera without reaching into this mod's file layout mod.exports.lib = V diff --git a/manifest.json b/manifest.json index a5f5afd..7283d20 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "DRAMATIC_SHAPE", "name": "DRAMATIC SHAPE VOXEL MOD BATTLE ART", - "version": "1.65", + "version": "1.66", "api": 2, "entry": "main.lua", "profile": "content", From 6bd92bdbe9933cef71260cce40f096e37b1c3532 Mon Sep 17 00:00:00 2001 From: absol89 Date: Tue, 4 Aug 2026 19:51:04 +0200 Subject: [PATCH 065/116] fix(ui): invert HUD + textbox ink under ARENA FILL: WHITE Under ARENA FILL: WHITE the field is solid white, so the usual whitened ink (and the HALF textbox backplate) washed the HUD/text box out. Now: - The player HUD and dialogue box render as BLACK ink with a WHITE drop-shadow on the white field, via a new WHITE_SHADOW shader and an `inverted` flag threaded through BattleHud.flipGlyphs / layerTexture and the drawTextArea / drawHUDs / hudTexture call sites in OverworldBattle. - TEXTBOX FILL: HALF is suppressed under WHITE (UiBackplates.textboxAlpha returns nil), since the box is now plain black-on-white. - The ink inversion is forced even when the luminance verdict would skip it, because the verdict reads the (now white) arena as "bright". Co-Authored-By: Hermes Agent --- lib/BattleHud.lua | 54 +++++++++++++++++++++++++++++++++++++---- lib/OverworldBattle.lua | 35 +++++++++++++++++--------- lib/UiBackplates.lua | 5 +++- 3 files changed, 76 insertions(+), 18 deletions(-) diff --git a/lib/BattleHud.lua b/lib/BattleHud.lua index 223073f..dcb4a85 100644 --- a/lib/BattleHud.lua +++ b/lib/BattleHud.lua @@ -319,9 +319,24 @@ local SHADOW = [[ return vec4(0.0, 0.0, 0.0, a) * color; } ]] +-- The same recognition, but for the WHITE arena fill: the drop-shadow that +-- keeps black ink from disappearing into a black tile becomes WHITE, because +-- the field under it is now white. The glyphs themselves stay black (no flip), +-- so the box reads as plain black text on white with a white halo. +local WHITE_SHADOW = [[ + uniform float ink; + uniform float opacity; + vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) { + vec4 p = Texel(tex, tc); + float luma = dot(p.rgb, vec3(0.299, 0.587, 0.114)); + float a = (p.a > 0.0 && luma <= ink * p.a) ? p.a * opacity : 0.0; + return vec4(1.0, 1.0, 1.0, a) * color; + } +]] local flipShader = nil local shadowShader = nil +local whiteShadowShader = nil local layer = nil local function getFlip() @@ -340,6 +355,14 @@ local function getShadow() return shadowShader or nil end +local function getWhiteShadow() + if whiteShadowShader == nil then + local ok, sh = pcall(love.graphics.newShader, WHITE_SHADOW) + whiteShadowShader = (ok and sh) or false + end + return whiteShadowShader or nil +end + -- Whether the flip pass can run at all, for the shot driver's log. function BattleHud.flipReady() return getFlip() ~= nil @@ -347,8 +370,12 @@ end -- Run `fn` with its ink whitened. Falls back to running it plainly when the -- scratch layer or the shader is unavailable, so a driver that cannot do --- either gets the vanilla black HUD rather than no HUD. -function BattleHud.flipGlyphs(w, h, fn) +-- Run `fn` with its ink rendered. `inverted` (the WHITE arena fill) keeps the +-- engine's own black glyphs and adds a WHITE drop-shadow instead of flipping +-- them to white with a black shadow: on a solid white field black text reads +-- and the white halo keeps it legible over any sprite behind it. Falls back to +-- running fn plainly when the scratch layer or shader is unavailable. +function BattleHud.flipGlyphs(w, h, fn, inverted) local sh = getFlip() if not sh then return fn() end if not layer or layer:getWidth() ~= w or layer:getHeight() ~= h then @@ -372,6 +399,22 @@ function BattleHud.flipGlyphs(w, h, fn) love.graphics.setBlendMode(prevBlend or "alpha", prevAlpha) if not ok then error(err, 0) end + if inverted then + -- WHITE arena fill: black ink (the fn above) with a white drop-shadow. + local white = getWhiteShadow() + if white then + love.graphics.setShader(white) + pcall(white.send, white, "ink", INK) + pcall(white.send, white, "opacity", SHADOW_ALPHA) + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(layer, 1, 1) + end + love.graphics.setShader() + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(layer, 0, 0) + return + end + local shadow = getShadow() if shadow then love.graphics.setShader(shadow) @@ -401,7 +444,7 @@ end -- whiten the terrain behind the glyphs along with them. local hudLayer = nil -function BattleHud.layerTexture(w, h, dark, fn) +function BattleHud.layerTexture(w, h, dark, fn, inverted) if not hudLayer or hudLayer:getWidth() ~= w or hudLayer:getHeight() ~= h then hudLayer = canvasOf(w, h, "nearest") if not hudLayer then return nil end @@ -415,8 +458,9 @@ function BattleHud.layerTexture(w, h, dark, fn) g.setBlendMode("alpha") g.setColor(1, 1, 1, 1) -- flipGlyphs renders fn into its own scratch layer and composites the - -- whitened result into whatever is bound, which is this canvas - if dark then BattleHud.flipGlyphs(w, h, fn) else fn() end + -- whitened result into whatever is bound, which is this canvas. `inverted` + -- (the WHITE arena fill) keeps the glyphs black with a white drop-shadow. + if dark then BattleHud.flipGlyphs(w, h, fn, inverted) else fn() end end) if prevCanvas then g.setCanvas(prevCanvas) else g.setCanvas() end g.setBlendMode(prevBlend or "alpha", prevAlpha) diff --git a/lib/OverworldBattle.lua b/lib/OverworldBattle.lua index 7f26188..b914776 100644 --- a/lib/OverworldBattle.lua +++ b/lib/OverworldBattle.lua @@ -1206,10 +1206,15 @@ function OverworldBattle.install() function BattleState:drawTextArea() if not self.dramaticShapeShot then return innerText(self) end local battle = self - if not self.dramaticShapeDark then return withoutBoxFill(battle, innerText) end - BattleHud.flipGlyphs(BattleScene.GB_W, BattleScene.GB_H, function() - withoutBoxFill(battle, innerText) - end) + -- The WHITE arena fill inverts the box ink: black text with a white + -- drop-shadow on the white field, rather than the usual white-flip. + local inverted = UiBackplates.arenaWhite() + if not self.dramaticShapeDark and not inverted then + return withoutBoxFill(battle, innerText) + end + BattleHud.flipGlyphs(BattleScene.GB_W, BattleScene.GB_H, + function() withoutBoxFill(battle, innerText) end, + inverted) end local innerAnim = BattleState.drawAnimLayer @@ -1312,13 +1317,17 @@ function OverworldBattle.install() -- window's edges and composited into the world image (snapHUDs). Drawing -- them here as well would show each block twice, once in each place. if self.dramaticShapeShot and snapped() then return end - if not (self.dramaticShapeShot and self.dramaticShapeDark) then + -- The WHITE arena fill inverts the HUD ink too: black glyphs with a white + -- drop-shadow. Force the flip pass (inverted) even though the verdict would + -- otherwise skip it over the now-white field. + local inverted = UiBackplates.arenaWhite() + if not (self.dramaticShapeShot and (self.dramaticShapeDark or inverted)) then return innerHUDs(self, slide) end local battle = self BattleHud.flipGlyphs(BattleScene.GB_W, BattleScene.GB_H, function() innerHUDs(battle, slide) - end) + end, inverted) end BattleState.dramaticShapeBattleHook = true @@ -1356,13 +1365,14 @@ end -- Shadowed on the instance for this call only, the way drawZonePass shadows -- activeBgp: putting the field back to whatever it was (normally nil) lets the -- class method be found again. -function OverworldBattle.hudTexture(battle, slide, dark) +function OverworldBattle.hudTexture(battle, slide, dark, inverted) if not (innerHUDs and battle) then return nil end local had = rawget(battle, "colorMode") battle.colorMode = function() return false end local ok, layer = pcall(BattleHud.layerTexture, BattleScene.GB_W, BattleScene.GB_H, dark, - function() innerHUDs(battle, slide) end) + function() innerHUDs(battle, slide) end, + inverted) battle.colorMode = had return ok and layer or nil end @@ -1395,11 +1405,12 @@ function OverworldBattle.snapHUDs(battle, shot) end -- Transparent backplates need one stable ink treatment through every phase -- of battle. Always whiten engine ink and add BattleHud's dark one-pixel - -- shadow; luminance-driven switching made trainer/send-out frames gray and - -- changed colour as the same fight moved between phases. - local whiteInk = true + -- shadow, UNLESS the WHITE arena fill is on -- then the box is plain black + -- ink with a WHITE drop-shadow (inverted), so it reads on the white field. + local whiteInk = not UiBackplates.arenaWhite() if session then session.dark = whiteInk end - local layer = OverworldBattle.hudTexture(battle, slide, whiteInk) + local layer = OverworldBattle.hudTexture(battle, slide, whiteInk, + UiBackplates.arenaWhite()) if not layer then return false end local g = love.graphics diff --git a/lib/UiBackplates.lua b/lib/UiBackplates.lua index ad762ca..7ae9140 100644 --- a/lib/UiBackplates.lua +++ b/lib/UiBackplates.lua @@ -62,8 +62,11 @@ UiBackplates.textboxFill = ModSetting.new("textboxFill", "TEXTBOX FILL", -- The backplate alpha for the dialogue text box, or nil when the backplate is -- off. HALF is a fixed semi-transparent slab, not the frosted HUD panel -- the -- point is a clean, bounded rectangle the text sits on, not a blurred window --- into the world behind it. +-- into the world behind it. Suppressed under ARENA FILL: WHITE, where the +-- dialogue box is plain black ink on the white field with a white drop-shadow +-- (see BattleHud.shadowGlyphs) -- a half-white slab there would just wash out. function UiBackplates.textboxAlpha() + if UiBackplates.arenaWhite() then return nil end return UiBackplates.textboxFill:get() == "HALF" and 0.5 or nil end From d2f69e1313aeecb50f9193bcdc3248d1adfc7868 Mon Sep 17 00:00:00 2001 From: absol89 Date: Tue, 4 Aug 2026 20:04:49 +0200 Subject: [PATCH 066/116] fix(ui): black textbox fill, cover move/mimic menus, decouple arena WHITE Edge-case fixes from on-device testing of the 1.66 backplates: - TEXTBOX FILL: HALF was a translucent WHITE slab that overhung the box by ~2px and skipped the move/mimic menus. Now it is a semi-transparent BLACK slab (matching upstream's default) drawn in the box's exact GB rect, and covers the box + moves + mimic menus (the isTextboxKey set) -- still never the player/opponent HUD blocks. - ARENA FILL: WHITE was gated on SPRITE LIGHT: UNLIT, so a WHITE value under SHADED silently rendered nothing. Decoupled: arenaWhite() no longer requires spritesUnlit(), and the OPTIONS row is offered whenever a battle can be staged, so SHADED cards render fine on the white field too. HUD blocks remain transparent on non-iOS (unchanged); only the dialogue/menu boxes get the HALF backplate. Co-Authored-By: Hermes Agent --- lib/OverworldBattle.lua | 16 ++++++++-------- lib/UiBackplates.lua | 39 ++++++++++++++++++++++++--------------- main.lua | 15 +++++++-------- 3 files changed, 39 insertions(+), 31 deletions(-) diff --git a/lib/OverworldBattle.lua b/lib/OverworldBattle.lua index b914776..d35ef84 100644 --- a/lib/OverworldBattle.lua +++ b/lib/OverworldBattle.lua @@ -1421,13 +1421,13 @@ function OverworldBattle.snapHUDs(battle, shot) g.setBlendMode("alpha") for key, rect in pairs(live) do BattleHud.panel(rect, shot, whiteInk, true) - -- TEXTBOX FILL: a semi-transparent backplate behind the dialogue box - -- only -- not the player/opponent HUD blocks, which are separate rects. - -- Bounded to the box's own GB-frame rectangle so it cannot venture - -- outside it. Drawn into the world image alongside the frosted panel. + -- TEXTBOX FILL: a semi-transparent BLACK backplate behind the dialogue + -- box and its move/mimic menus -- NOT the player/opponent HUD blocks, + -- which are separate rects. Bounded to the box's own GB-frame rect so it + -- cannot venture outside it. (Upstream's default is a black slab.) local a = UiBackplates.textboxAlpha() - if a and key == "box" then - g.setColor(1, 1, 1, a) + if a and UiBackplates.isTextboxKey(key) then + g.setColor(0, 0, 0, a) g.rectangle("fill", rect[1], rect[2], rect[3], rect[4]) g.setColor(1, 1, 1, 1) end @@ -1475,9 +1475,9 @@ function OverworldBattle.drawHudPanels(battle) for key, r in pairs(live) do BattleHud.panel(r, shot, true) local a = UiBackplates.textboxAlpha() - if a and key == "box" then + if a and UiBackplates.isTextboxKey(key) then local g = love.graphics - g.setColor(1, 1, 1, a) + g.setColor(0, 0, 0, a) g.rectangle("fill", r[1], r[2], r[3], r[4]) g.setColor(1, 1, 1, 1) end diff --git a/lib/UiBackplates.lua b/lib/UiBackplates.lua index 7ae9140..582e4c8 100644 --- a/lib/UiBackplates.lua +++ b/lib/UiBackplates.lua @@ -2,14 +2,15 @@ -- -- C) SPRITE LIGHT UNLIT / SHADED -- whether the mon cards receive the -- world's day tint and cast shadows (SHADED) or draw flat and full --- bright (UNLIT). UNLIT is what the white arena fill (B) needs, so the --- cards stay readable on a solid white field. --- +-- bright (UNLIT). UNLIT keeps them readable on the white arena fill (B); +-- SHADED is the default OG look and is also supported on white. + -- B) ARENA FILL WHITE / OFF -- a solid white rendering layer in -- front of the whole voxel world, with only the mons, their attack -- animations and the menus above it. Hides the 3D terrain while keeping -- the animated sprites -- the middle step between the OG battle and the --- full voxel one. Requires sprite light UNLIT. +-- full voxel one. Works with SHADED or UNLIT sprites (UNLIT just keeps +-- the cards brighter on white). -- -- A) TEXTBOX FILL HALF / OFF -- a semi-transparent backplate behind -- the dialogue text box ONLY (the bottom 48px of the 160x144 battle @@ -45,13 +46,11 @@ end UiBackplates.arenaFill = ModSetting.new("arenaFill", "ARENA FILL", { "OFF", "WHITE" }, { "OFF", "WHITE" }) --- Whether to draw the solid white layer over the voxel world. The white --- layer only makes sense with sprites unlit, so this also reports false when --- the player has left sprite light on SHADED -- a white field under shaded --- (dimmed) cards would just be a dimmer white field for no reason. +-- Whether to draw the solid white layer over the voxel world. Decoupled from +-- sprite light: it works with SHADED cards too (they just read a little +-- dimmer on white), so WHITE is offered independently of UNLIT. function UiBackplates.arenaWhite() return UiBackplates.arenaFill:get() == "WHITE" - and UiBackplates.spritesUnlit() end -- ------- A) TEXTBOX FILL ------- @@ -59,15 +58,25 @@ end UiBackplates.textboxFill = ModSetting.new("textboxFill", "TEXTBOX FILL", { "OFF", "HALF" }, { "OFF", "HALF" }) --- The backplate alpha for the dialogue text box, or nil when the backplate is --- off. HALF is a fixed semi-transparent slab, not the frosted HUD panel -- the --- point is a clean, bounded rectangle the text sits on, not a blurred window --- into the world behind it. Suppressed under ARENA FILL: WHITE, where the --- dialogue box is plain black ink on the white field with a white drop-shadow --- (see BattleHud.shadowGlyphs) -- a half-white slab there would just wash out. +-- The rect keys whose dialogue/menu boxes get the HALF backplate. The bottom +-- text box ("box") and the move-select / mimic-select menus that open over it +-- -- NOT the player/opponent HUD blocks, which are separate rects. +UiBackplates.TEXTBOX_KEYS = { box = true, moves = true, mimic = true } + +-- The backplate alpha for the dialogue/menu boxes, or nil when the backplate +-- is off. HALF is a fixed semi-transparent BLACK slab (matching upstream's +-- default), not the frosted HUD panel -- a clean, bounded rectangle the text +-- sits on. Suppressed under ARENA FILL: WHITE, where the boxes are plain black +-- ink on the white field with a white drop-shadow. function UiBackplates.textboxAlpha() if UiBackplates.arenaWhite() then return nil end return UiBackplates.textboxFill:get() == "HALF" and 0.5 or nil end +-- Whether `key` is one of the dialogue/menu boxes that the HALF backplate +-- covers (as opposed to the HUD blocks). +function UiBackplates.isTextboxKey(key) + return UiBackplates.TEXTBOX_KEYS[key] == true +end + return UiBackplates diff --git a/main.lua b/main.lua index 8b454d9..7a292c0 100644 --- a/main.lua +++ b/main.lua @@ -442,15 +442,14 @@ local SETTINGS = { { UiBackplates.arenaFill, "WHITE draws a solid white layer in front of the whole voxel world, " .. "with only the mons, their attack animations and the menus above it " - .. "-- the step between the OG battle and the full 3D one. Needs SPRITE " - .. "LIGHT: UNLIT to render the cards readable on white.", - when = function() - return stagedBattles() and UiBackplates.spritesUnlit() - end, full = true }, + .. "-- the step between the OG battle and the full 3D one. Works with " + .. "SHADED or UNLIT sprites (UNLIT keeps the cards brighter on white).", + when = function() return stagedBattles() end, full = true }, { UiBackplates.textboxFill, - "HALF lays a semi-transparent backplate behind the dialogue text box " - .. "only -- not the player or opponent HUD blocks -- so the words read " - .. "over any ground. OFF leaves the text box as the engine draws it.", + "HALF lays a semi-transparent BLACK backplate behind the dialogue box " + .. "and its move/mimic menus -- not the player or opponent HUD blocks -- " + .. "so the words read over any ground, like the upstream default. OFF " + .. "leaves the text box as the engine draws it.", full = true }, -- Marked `full` for the opposite reason the battle rows are: this is not a -- knob on the look at all, it is what the look COSTS. FULL is a preset for From 8fb733d692f1819fd6fe69b5e0a77947e411be5b Mon Sep 17 00:00:00 2001 From: absol89 Date: Tue, 4 Aug 2026 20:06:17 +0200 Subject: [PATCH 067/116] fix(ui): opaque white textbox under ARENA FILL: WHITE Under WHITE the text box lost its backplate entirely, so a sprite (e.g. the mon standing behind it) showed through the text. Now the box gets an OPAQUE WHITE slab in WHITE mode -- a solid Game Boy panel with the inverted black ink on top -- via textboxFillStyle(), which also keeps the translucent BLACK HALF slab for TEXTBOX FILL on the normal arena. Player/opponent HUD blocks remain uncovered. Co-Authored-By: Hermes Agent --- lib/OverworldBattle.lua | 21 +++++++++++---------- lib/UiBackplates.lua | 22 ++++++++++++++-------- 2 files changed, 25 insertions(+), 18 deletions(-) diff --git a/lib/OverworldBattle.lua b/lib/OverworldBattle.lua index d35ef84..f7f847f 100644 --- a/lib/OverworldBattle.lua +++ b/lib/OverworldBattle.lua @@ -1421,13 +1421,14 @@ function OverworldBattle.snapHUDs(battle, shot) g.setBlendMode("alpha") for key, rect in pairs(live) do BattleHud.panel(rect, shot, whiteInk, true) - -- TEXTBOX FILL: a semi-transparent BLACK backplate behind the dialogue - -- box and its move/mimic menus -- NOT the player/opponent HUD blocks, - -- which are separate rects. Bounded to the box's own GB-frame rect so it - -- cannot venture outside it. (Upstream's default is a black slab.) - local a = UiBackplates.textboxAlpha() - if a and UiBackplates.isTextboxKey(key) then - g.setColor(0, 0, 0, a) + -- TEXTBOX FILL: a backplate behind the dialogue box and its move/mimic + -- menus -- NOT the player/opponent HUD blocks, which are separate rects. + -- Under ARENA FILL: WHITE it is an OPAQUE WHITE slab (no sprite shows + -- through); otherwise a translucent BLACK slab (upstream default). + -- Bounded to the box's own GB-frame rect so it cannot venture outside. + local style = UiBackplates.textboxFillStyle() + if style and UiBackplates.isTextboxKey(key) then + g.setColor(style[1], style[2], style[3], style[4]) g.rectangle("fill", rect[1], rect[2], rect[3], rect[4]) g.setColor(1, 1, 1, 1) end @@ -1474,10 +1475,10 @@ function OverworldBattle.drawHudPanels(battle) battle.dramaticShapeDark = true for key, r in pairs(live) do BattleHud.panel(r, shot, true) - local a = UiBackplates.textboxAlpha() - if a and UiBackplates.isTextboxKey(key) then + local style = UiBackplates.textboxFillStyle() + if style and UiBackplates.isTextboxKey(key) then local g = love.graphics - g.setColor(0, 0, 0, a) + g.setColor(style[1], style[2], style[3], style[4]) g.rectangle("fill", r[1], r[2], r[3], r[4]) g.setColor(1, 1, 1, 1) end diff --git a/lib/UiBackplates.lua b/lib/UiBackplates.lua index 582e4c8..2f42793 100644 --- a/lib/UiBackplates.lua +++ b/lib/UiBackplates.lua @@ -63,14 +63,20 @@ UiBackplates.textboxFill = ModSetting.new("textboxFill", "TEXTBOX FILL", -- -- NOT the player/opponent HUD blocks, which are separate rects. UiBackplates.TEXTBOX_KEYS = { box = true, moves = true, mimic = true } --- The backplate alpha for the dialogue/menu boxes, or nil when the backplate --- is off. HALF is a fixed semi-transparent BLACK slab (matching upstream's --- default), not the frosted HUD panel -- a clean, bounded rectangle the text --- sits on. Suppressed under ARENA FILL: WHITE, where the boxes are plain black --- ink on the white field with a white drop-shadow. -function UiBackplates.textboxAlpha() - if UiBackplates.arenaWhite() then return nil end - return UiBackplates.textboxFill:get() == "HALF" and 0.5 or nil +-- The backplate colour/alpha for the dialogue/menu boxes, or nil when there +-- is none. Two cases: +-- * ARENA FILL: WHITE -- an OPAQUE WHITE slab, so no sprite (e.g. the mon +-- standing behind the box) shows through; the box reads as a solid white +-- Game Boy panel with the inverted black ink on top. +-- * TEXTBOX FILL: HALF -- a translucent BLACK slab (upstream's default), so +-- the words read over any ground. +-- The player/opponent HUD blocks are never covered (see isTextboxKey). +function UiBackplates.textboxFillStyle() + if UiBackplates.arenaWhite() then return { 1, 1, 1, 1 } end + if UiBackplates.textboxFill:get() == "HALF" then + return { 0, 0, 0, 0.5 } + end + return nil end -- Whether `key` is one of the dialogue/menu boxes that the HALF backplate From 81fc64e314d0af414c7ba26356d4b60963eecc6a Mon Sep 17 00:00:00 2001 From: absol89 Date: Tue, 4 Aug 2026 20:13:33 +0200 Subject: [PATCH 068/116] fix(ui): force white box palette under ARENA FILL: WHITE The text/move/status boxes picked up the engine's BG-palette colour 0 -- orange on the intro flash, grey once the mons appeared -- because the mod's opaque white slab sat UNDER the engine's own coloured box fill. The fills come from the zone pass's background palette (activeBgp), so in WHITE mode drawZonePass now returns a white palette {0x1f,0x1f,0x1f} instead of nil, whitening every battle box (text box, TYPE box, move menu) at the palette level. Non-WHITE behaviour is unchanged (still returns nil). Co-Authored-By: Hermes Agent --- lib/OverworldBattle.lua | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/lib/OverworldBattle.lua b/lib/OverworldBattle.lua index f7f847f..ddd3077 100644 --- a/lib/OverworldBattle.lua +++ b/lib/OverworldBattle.lua @@ -1288,7 +1288,15 @@ function OverworldBattle.install() -- field back to whatever it was (normally nil) lets the class method be -- found again local had = rawget(self, "activeBgp") - self.activeBgp = function() return nil end + -- ARENA FILL: WHITE -- the box/move-menu fills come from the BG palette's + -- colour 0 (orange on the intro flash, grey once the mons are up in this + -- hack). Force it to white so every battle box is a clean white panel + -- instead of inheriting the palette's tint. + if UiBackplates.arenaWhite() then + self.activeBgp = function() return { 0x1f, 0x1f, 0x1f } end + else + self.activeBgp = function() return nil end + end local g = love.graphics local rectangle = g.rectangle g.rectangle = function(mode, ...) From 1959f77663b20ad308253aa9a6316a53b53d3bcf Mon Sep 17 00:00:00 2001 From: absol89 Date: Tue, 4 Aug 2026 20:19:35 +0200 Subject: [PATCH 069/116] Revert "fix(ui): force white box palette under ARENA FILL: WHITE" This reverts commit 81fc64e314d0af414c7ba26356d4b60963eecc6a. --- lib/OverworldBattle.lua | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/lib/OverworldBattle.lua b/lib/OverworldBattle.lua index ddd3077..f7f847f 100644 --- a/lib/OverworldBattle.lua +++ b/lib/OverworldBattle.lua @@ -1288,15 +1288,7 @@ function OverworldBattle.install() -- field back to whatever it was (normally nil) lets the class method be -- found again local had = rawget(self, "activeBgp") - -- ARENA FILL: WHITE -- the box/move-menu fills come from the BG palette's - -- colour 0 (orange on the intro flash, grey once the mons are up in this - -- hack). Force it to white so every battle box is a clean white panel - -- instead of inheriting the palette's tint. - if UiBackplates.arenaWhite() then - self.activeBgp = function() return { 0x1f, 0x1f, 0x1f } end - else - self.activeBgp = function() return nil end - end + self.activeBgp = function() return nil end local g = love.graphics local rectangle = g.rectangle g.rectangle = function(mode, ...) From 500659c10215d139281118b0cc2c8a806dc6baa0 Mon Sep 17 00:00:00 2001 From: absol89 Date: Tue, 4 Aug 2026 20:21:19 +0200 Subject: [PATCH 070/116] fix(ui): whiten box fill at draw time under ARENA FILL: WHITE The orange/grey box tint came from the engine's BG-palette colour 0 showing through the box fill (orange on the intro flash, grey after the mons appear). Forcing the palette via activeBgp crashed PaletteFX (nil arithmetic), so instead whiten at draw time: a whiteBoxFill shim replaces any `fill` rect with opaque white during the text-area draw in WHITE mode. Text/border/cursor are glyphs, not fills, so they survive untouched. Non-WHITE mode keeps the existing withoutBoxFill behaviour. Co-Authored-By: Hermes Agent --- lib/OverworldBattle.lua | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/lib/OverworldBattle.lua b/lib/OverworldBattle.lua index f7f847f..5006502 100644 --- a/lib/OverworldBattle.lua +++ b/lib/OverworldBattle.lua @@ -724,6 +724,27 @@ local function withoutBoxFill(battle, fn) if not ok then error(err, 0) end end +-- WHITE arena fill: paint the box background (and anything else the engine +-- fills inside the text-area draw) opaque white, so the engine's coloured BG +-- palette -- orange on the intro flash, grey once the mons appear -- cannot +-- show through. The text, border and cursor are drawn as glyphs, not fills, +-- so they survive untouched. (Forcing the palette itself crashed PaletteFX, +-- so we whiten at draw time instead.) +local function whiteBoxFill(battle, fn) + local g = love.graphics + local rectangle = g.rectangle + g.rectangle = function(mode, ...) + if mode == "fill" then + g.setColor(1, 1, 1, 1) + end + return rectangle(mode, ...) + end + local ok, err = pcall(fn, battle) + g.rectangle = rectangle + g.setColor(1, 1, 1, 1) + if not ok then error(err, 0) end +end + -- ------- the hour's light, on a pic that is not geometry -- -- Everything standing in the arena goes through the voxel shader, and that @@ -1212,8 +1233,12 @@ function OverworldBattle.install() if not self.dramaticShapeDark and not inverted then return withoutBoxFill(battle, innerText) end + -- In WHITE mode the engine's box fill is a coloured palette colour (orange + -- on the intro flash, grey after) -- whiten it opaque so it reads as a + -- clean white panel under the inverted black ink. + local boxFn = inverted and whiteBoxFill or withoutBoxFill BattleHud.flipGlyphs(BattleScene.GB_W, BattleScene.GB_H, - function() withoutBoxFill(battle, innerText) end, + function() boxFn(battle, innerText) end, inverted) end From 5fbc1f9f17b3a2868f97e8316e248926e2a4b19d Mon Sep 17 00:00:00 2001 From: absol89 Date: Tue, 4 Aug 2026 20:47:44 +0200 Subject: [PATCH 071/116] fix(ui): UNLIT sprites ignore the hour/cave tint (flat full-bright) SPRITE LIGHT: UNLIT skipped only the cast shadow, not Voxel3D.tint -- the hour/cave tint set for the arena. So in a cave (or at night) UNLIT cards were still darkened by the tint, and on ARENA FILL: WHITE they read as dark-tinted sprites on the white field instead of flat and full-bright. UNLIT now also clears Voxel3D.tint (and dayTint) per card and restores it afterwards, so the mon cards are truly unlit everywhere. SHADED is unchanged. Co-Authored-By: Hermes Agent --- lib/BattleScene.lua | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/lib/BattleScene.lua b/lib/BattleScene.lua index afc579c..8ca8255 100644 --- a/lib/BattleScene.lua +++ b/lib/BattleScene.lua @@ -507,14 +507,24 @@ function BattleScene.render(state, arena, textures, token) -- of being dimmed or colour-cast by the clock. Only the hour tint is -- neutral here; depth and alpha-shaped lighting/shadows stay active. if card.noDayTint then Voxel3D.dayTint({ 1, 1, 1 }) end - -- SPRITE LIGHT: UNLIT draws the card flat and full bright -- no day - -- tint and no shadow lookup -- so it stays readable on the white arena - -- fill (B) and matches the OG battle's unshaded sprites. SHADED (the - -- default) keeps the world's hour tint and its own cast shadow. + -- SPRITE LIGHT: UNLIT draws the card flat and full bright -- no cast + -- shadow (nil snug) AND no hour/day tint, so a cave or night tint does + -- not dim it. Most visible on the white arena fill, where a darkened + -- card would read wrong; but it is flat/full-bright everywhere. SHADED + -- (the default) keeps the tints and its own shadow, as intended. local unlit = UiBackplates.spritesUnlit() + local savedTint = Voxel3D.tint + if unlit then + Voxel3D.tint = { 1, 1, 1 } + Voxel3D.dayTint({ 1, 1, 1 }) + end Voxel3D.draw(BattleBillboard.mesh(), card.tex, card.model, BattleBillboard.PULL, unlit and nil or ShadowMap.snug(card.model)) + if unlit then + Voxel3D.tint = savedTint + Voxel3D.dayTint() + end if card.noDayTint then Voxel3D.dayTint() end end Voxel3D.glass(true) From 628b65a5353f6a8bfe6644fdbcd33ec2823d4b61 Mon Sep 17 00:00:00 2001 From: absol89 Date: Tue, 4 Aug 2026 21:08:16 +0200 Subject: [PATCH 072/116] feat(ui): ARENA FILL: WHITE forces SPRITE LIGHT UNLIT A solid white battle field carries no night tint -- as in the traditional games -- so WHITE now forces the mon cards to draw flat and true-colour regardless of the SPRITE LIGHT setting. spritesUnlit() reports true under WHITE, so the card loop (which already strips the hour/cave tint and cast shadow when unlit) renders the sprites with no night tint on the white field. The ARENA FILL option description says so. Co-Authored-By: Hermes Agent --- lib/UiBackplates.lua | 7 +++++-- main.lua | 5 +++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/lib/UiBackplates.lua b/lib/UiBackplates.lua index 2f42793..9c4cf8a 100644 --- a/lib/UiBackplates.lua +++ b/lib/UiBackplates.lua @@ -35,9 +35,12 @@ UiBackplates.spriteLight = ModSetting.new("spriteLight", "SPRITE LIGHT", { "SHADED", "UNLIT" }, { "SHADED", "UNLIT" }) -- Whether the mon cards should be drawn flat and full bright (UNLIT) rather --- than receiving the world's day tint and shadows (SHADED). The card-draw --- path in BattleScene calls this to decide whether to skip dayTint/shadow. +-- than receiving the world's day tint and shadows (SHADED). ARENA FILL: WHITE +-- forces this on: a solid white battle field carries no night tint -- as in +-- the traditional games -- so the sprites draw flat and true-colour +-- regardless of the SPRITE LIGHT setting. function UiBackplates.spritesUnlit() + if UiBackplates.arenaWhite() then return true end return UiBackplates.spriteLight:get() == "UNLIT" end diff --git a/main.lua b/main.lua index 7a292c0..838fab0 100644 --- a/main.lua +++ b/main.lua @@ -442,8 +442,9 @@ local SETTINGS = { { UiBackplates.arenaFill, "WHITE draws a solid white layer in front of the whole voxel world, " .. "with only the mons, their attack animations and the menus above it " - .. "-- the step between the OG battle and the full 3D one. Works with " - .. "SHADED or UNLIT sprites (UNLIT keeps the cards brighter on white).", + .. "-- the step between the OG battle and the full 3D one. Forces SPRITE " + .. "LIGHT: UNLIT so the sprites stay flat and true-colour with no night " + .. "tint (as in the traditional games).", when = function() return stagedBattles() end, full = true }, { UiBackplates.textboxFill, "HALF lays a semi-transparent BLACK backplate behind the dialogue box " From e80a337f3a51aab747a0309f5dd0b69a6b91ce57 Mon Sep 17 00:00:00 2001 From: absol89 Date: Tue, 4 Aug 2026 21:27:22 +0200 Subject: [PATCH 073/116] fix(ui): UNLIT also strips the night tint from the animated player back pic The 3D mon cards were unlit by 5fbc1f9, but the player's animated back sprite -- the fallback that never becomes geometry and is drawn by the pics layer -- still received the hour/night tint via withTint(shot.tint). Neutralise that tint when SPRITE LIGHT: UNLIT (or ARENA FILL: WHITE) so the animated back pic is flat/true-colour at night, matching the 3D cards and the static/trainer pics. The wavy-screen grayscale exception is preserved. Co-Authored-By: Hermes Agent --- lib/OverworldBattle.lua | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/OverworldBattle.lua b/lib/OverworldBattle.lua index 5006502..a950fb2 100644 --- a/lib/OverworldBattle.lua +++ b/lib/OverworldBattle.lua @@ -1201,7 +1201,11 @@ function OverworldBattle.install() -- red channel, and a night tint pulls red down -- it would not darken -- the mon, it would remap it to the wrong shade. SE_WAVY_SCREEN lasts a -- second and the hour survives it fine. - local tint = not self.grayPics and shot.tint or nil + -- SPRITE LIGHT: UNLIT draws the player back pic flat and true-colour, + -- with no night tint -- same as the 3D cards in BattleScene. Neutralise + -- the shot's hour tint (and the wavy-path exception below) when unlit. + local tint = not self.grayPics and (UiBackplates.spritesUnlit() + and { 1, 1, 1 } or shot.tint) or nil local metric = pinnedSpeciesMetric() if metric then local grow = self.player and self:growInScale(self.player) or nil From debc0b2c1f66ee11847b57d1e2dde42ab1ef2593 Mon Sep 17 00:00:00 2001 From: absol89 Date: Tue, 4 Aug 2026 21:27:48 +0200 Subject: [PATCH 074/116] chore(release): bump version to 1.68 Releases the 1.66 UI backplate work plus the WHITE-mode and UNLIT fixes: - ARENA FILL: WHITE (black UI + white drop-shadow, opaque white text box) - TEXTBOX FILL: HALF (translucent black, covers box + move/mimic menus) - SPRITE LIGHT: UNLIT strips night/cave tint + cast shadow from the 3D cards and the animated player back pic (true-colour at night); WHITE forces UNLIT Co-Authored-By: Hermes Agent --- main.lua | 2 +- manifest.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/main.lua b/main.lua index 838fab0..4dd2161 100644 --- a/main.lua +++ b/main.lua @@ -1007,7 +1007,7 @@ mod.hooks:wrap("world.tod", function(next, tod, ctx) return DayNight.tod() end) -mod.exports.version = "1.66" +mod.exports.version = "1.68" -- exposed so a companion mod can pin its own tiles' shapes or read the -- camera without reaching into this mod's file layout mod.exports.lib = V diff --git a/manifest.json b/manifest.json index 7283d20..74ca321 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "DRAMATIC_SHAPE", "name": "DRAMATIC SHAPE VOXEL MOD BATTLE ART", - "version": "1.66", + "version": "1.68", "api": 2, "entry": "main.lua", "profile": "content", From 1f37ee5bfa6a28b4bbb7520e1f04184f8b6e286a Mon Sep 17 00:00:00 2001 From: absol89 Date: Thu, 6 Aug 2026 04:12:49 +0200 Subject: [PATCH 075/116] fix(ios): restore the HUD/textarea iOS exception from upstream PR #48 The backplate work had dropped the OverworldBattle.lua half of PR #48's iOS fix: the isIOS() guards that skip snapHUDs (which renders upside-down and opaque on iOS) and fall back to the engine's own opaque HUD panels. - add isIOS() helper - update(): skip snapHUDs on iOS; skip the snap-failure warning there too - drawTextArea(): early-return (engine default, opaque) on iOS - drawHudPanels(): draw opaque white HUD rects (alpha 0.84) and return on iOS The PixelCanvas / ShadowMap.available(false) / TiltShift / Voxel3D parts of PR #48 were untouched and remain intact. On iOS the HUD/textboxes are now whatever PR #48 specifies (opaque), matching the upstream behaviour. Co-Authored-By: Hermes Agent --- lib/OverworldBattle.lua | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/lib/OverworldBattle.lua b/lib/OverworldBattle.lua index a950fb2..9f14d60 100644 --- a/lib/OverworldBattle.lua +++ b/lib/OverworldBattle.lua @@ -54,6 +54,13 @@ local ChunkMesher = V.require("ChunkMesher") local OverworldBattle = {} local session = nil +-- iOS cannot run the HUD-snap (snapHUDs) path: it renders the HUDs +-- upside-down and opaque, so on that platform we skip it and fall back to +-- the engine's own (opaque) HUD/textbox draw. Mirrors upstream PR #48. +local function isIOS() + return love.system and love.system.getOS and love.system.getOS() == "iOS" +end + -- DS_BATTLE_DEBUG=1 logs what the HUD's brightness probe is reading, once a -- second, which is how the glyph flip is checked from a shot run. Read -- through pcall: the loader's sandbox does not hand a mod `os`, and a @@ -607,11 +614,15 @@ function OverworldBattle.update(dt) -- edges (snapHUDs). Here rather than in the battle's draw for the same -- reason the scene is: it binds a canvas of its own. Their only contrast -- treatment is pure white ink plus a dark one-pixel shadow. - local okHud, up = pcall(OverworldBattle.snapHUDs, session.battle, shot) + local ios = isIOS() + local okHud, up = false, false + if not ios then + okHud, up = pcall(OverworldBattle.snapHUDs, session.battle, shot) + end session.snapped = (okHud and up) and true or false -- once per battle, not once per frame: a driver that cannot do this cannot -- do it sixty times a second either, and the fallback is silent and fine - if not okHud and not session.hudWarned then + if not ios and not okHud and not session.hudWarned then session.hudWarned = true V.mod.log:warn("overworld battle HUD snap failed: %s -- the HUDs draw " .. "in the battle frame this battle", tostring(up)) @@ -1230,6 +1241,7 @@ function OverworldBattle.install() local innerText = BattleState.drawTextArea function BattleState:drawTextArea() if not self.dramaticShapeShot then return innerText(self) end + if isIOS() then return innerText(self) end local battle = self -- The WHITE arena fill inverts the box ink: black text with a white -- drop-shadow on the white field, rather than the usual white-flip. @@ -1489,6 +1501,18 @@ function OverworldBattle.drawHudPanels(battle) local shot = battle.dramaticShapeShot battle.dramaticShapeDark = nil if not shot then return end + if isIOS() then + -- iOS cannot run the snapped HUD path (it draws upside-down + opaque); + -- fall back to the engine's own opaque HUD panels. Mirrors upstream PR #48. + local slide = (battle.introSlide or 0) * 4 + local enemy, player = OverworldBattle.hudLive(battle, slide) + local rect = OverworldBattle.HUD_RECT + love.graphics.setColor(1, 1, 1, 0.84) + if enemy then love.graphics.rectangle("fill", rect.enemy[1], rect.enemy[2], rect.enemy[3], rect.enemy[4]) end + if player then love.graphics.rectangle("fill", rect.player[1], rect.player[2], rect.player[3], rect.player[4]) end + love.graphics.setColor(1, 1, 1, 1) + return + end if snapped() then battle.dramaticShapeDark = true return From 132a089c1101d597510db0135df5781759a251b8 Mon Sep 17 00:00:00 2001 From: absol89 Date: Thu, 6 Aug 2026 04:30:29 +0200 Subject: [PATCH 076/116] feat(ui): add an XP bar scaled to the player HUD (ported from QoL mod) Ports the experience bar from unxpected-uxp's pokemon-gen1-recomp-mod-qol (qol_feature_xp_bar.lua), used with the author's permission. The original draws the bar at a fixed GB position at scale 1; this version recomputes its position/scale from the SAME snapRects transform the player HUD uses (hs = scale - 1), so it sits at the HUD's exact location and shrinks to the same "a bit smaller" size, for any window size or camera composition. - lib/ExpBar.lua: draw/anim/colour logic from the QoL source, rescaled and anchored to the snapped player HUD rect; drawn into the world canvas during drawHudPanels so it works on iOS too (no snapHUDs dependency). - Controlled by a new XP BAR ModSetting (OFF default), with an OPTIONS row. - Credits + permission noted in the file header and the option description. Co-Authored-By: Hermes Agent --- lib/ExpBar.lua | 232 ++++++++++++++++++++++++++++++++++++++++ lib/OverworldBattle.lua | 4 + main.lua | 6 ++ 3 files changed, 242 insertions(+) create mode 100644 lib/ExpBar.lua diff --git a/lib/ExpBar.lua b/lib/ExpBar.lua new file mode 100644 index 0000000..8bbf5b6 --- /dev/null +++ b/lib/ExpBar.lua @@ -0,0 +1,232 @@ +-- XP / experience bar for the player's active mon, scaled to match the +-- battle HUD. +-- +-- The drawing, animation and colour logic below is adapted from the +-- pokemon-gen1-recomp-mod-qol project by unxpected-uxp +-- (https://github.com/unxpected-uxp/pokemon-gen1-recomp-mod-qol, +-- qol_feature_xp_bar.lua), used with the author's permission. The original +-- draws the bar at a fixed GB-frame position (EXP_X, EXP_Y) at scale 1; this +-- port recomputes the position/scale from the SAME transform the player HUD +-- uses (OverworldBattle.snapRects -> hs = scale - 1), so the bar sits at the +-- exact player-HUD location and shrinks to the same "a bit smaller" size as +-- the HUD, for any window size or camera composition. It is drawn into the +-- world canvas (shot.canvas) during drawHudPanels, so it works on every +-- platform including iOS (where snapHUDs is skipped). +-- +-- the mod namespace (see main.lua): V.require loads a sibling module +local V = ... + +local ModSetting = V.require("ModSetting") +local OverworldBattle = V.require("OverworldBattle") + +-- engine modules (same as the QoL source) +local Growth = require("src.pokemon.Growth") +local PaletteFX = require("src.render.PaletteFX") + +local ExpBar = {} + +-- ------- enable toggle (OPTIONS row + persisted, like the other backplates) ------- + +ExpBar.enabled = ModSetting.new("xpBar", "XP BAR", + { "OFF", "ON" }, { "OFF", "ON" }) + +-- ------- constants (from the QoL source) ------- + +local EXP_X, EXP_Y, EXP_WIDTH = 80, 89, 67 +local EXP_LEVEL_HOLD_FRAMES = 30 +local EXP_BURST_DIAGONALS = { 0, 1, 2, 4, 5, 7, 8, 9 } +local EXP_BLUE = { 50 / 255, 150 / 255, 250 / 255, 1 } +local EXP_BLACK = { 0, 0, 0, 1 } + +-- ------- colour ------- + +local function paletteExpColor(battle) + local colors = battle.zoneColorsAt + and battle:zoneColorsAt(EXP_X, EXP_Y) + if not colors then return EXP_BLACK end + local bgp = battle.activeBgp and battle:activeBgp() + colors = PaletteFX.effectiveColors(PaletteFX.permute(colors, bgp)) + local color = colors and colors[3] + if not color then return EXP_BLACK end + return { color[1] / 255, color[2] / 255, color[3] / 255, 1 } +end + +-- ------- progress ------- + +local function expPixels(battle) + local mon = battle.player and battle.player.mon + local def = mon and battle.data.pokemon[mon.species] + if not def then return 0 end + local cap = battle.data.constants and battle.data.constants.levelCap or 100 + if mon.level >= cap then return EXP_WIDTH end + local current = Growth.expForLevel(def.growthRate, mon.level, + battle.data.growth_rates) + local nextLevel = Growth.expForLevel(def.growthRate, mon.level + 1, + battle.data.growth_rates) + local needed = nextLevel - current + if needed <= 0 then return 0 end + local progress = math.max(0, math.min(needed, mon.exp - current)) + return math.floor(progress * EXP_WIDTH / needed) +end + +-- per-battle animation state (single active battle at a time; expMon change +-- resets it, as in the source) +local expState = {} + +local function animatedExpPixels(battle) + local mon = battle.player and battle.player.mon + local target = expPixels(battle) + local state = expState + if state.expMon ~= mon or state.expPixels == nil then + state.expMon = mon + state.expPixels = target + state.expLevel = mon and mon.level + state.expPhase = nil + state.expLevelCycles = 0 + state.expBurstFrame = nil + state.expFrame = battle.frame + return target + end + if state.expFrame == battle.frame then return state.expPixels end + state.expFrame = battle.frame + + local level = mon and mon.level or state.expLevel + if level and state.expLevel and level > state.expLevel then + state.expLevelCycles = (state.expLevelCycles or 0) + level - state.expLevel + state.expLevel = level + if not state.expPhase then state.expPhase = "fill_level" end + elseif level and level ~= state.expLevel then + state.expLevel = level + end + + if state.expPhase == "fill_level" then + state.expPixels = math.min(EXP_WIDTH, state.expPixels + 1) + if state.expPixels == EXP_WIDTH then + state.expPhase = "hold_level" + state.expHoldFrames = EXP_LEVEL_HOLD_FRAMES + state.expBurstFrame = 0 + end + elseif state.expPhase == "hold_level" then + if state.expBurstFrame then + if state.expBurstFrame < #EXP_BURST_DIAGONALS - 1 then + state.expBurstFrame = state.expBurstFrame + 1 + else + state.expBurstFrame = nil + end + end + if state.expHoldFrames > 0 then + state.expHoldFrames = state.expHoldFrames - 1 + else + state.expLevelCycles = math.max(0, (state.expLevelCycles or 1) - 1) + local cap = battle.data.constants and battle.data.constants.levelCap or 100 + state.expBurstFrame = nil + if state.expLevelCycles > 0 then + state.expPixels = 0 + state.expPhase = "fill_level" + elseif mon and mon.level >= cap then + state.expPhase = nil + state.expPixels = EXP_WIDTH + else + state.expPixels = 0 + state.expPhase = "after_level" + end + end + elseif state.expPhase == "after_level" then + state.expPixels = math.min(target, state.expPixels + 1) + if state.expPixels >= target then state.expPhase = nil end + elseif state.expPixels < target then + state.expPixels = math.min(target, state.expPixels + 1) + elseif state.expPixels > target then + state.expPixels = math.max(target, state.expPixels - 1) + end + return state.expPixels +end + +-- ------- burst particles (level-up) ------- + +local EXP_BURST_TILE_ROWS = { + "oooooooo", + "oooxxooo", + "ooxxxxoo", + "oxxxxxxo", + "oxxxxxxo", + "ooxxxxoo", + "oooxxooo", + "oooooooo", +} + +local function drawExpBurst(frame, centerX, centerY, scale, color) + if frame == nil then return end + local g = love.graphics + local radius = frame * 2 * scale + local diagonal = EXP_BURST_DIAGONALS[frame + 1] * scale + + local function particle(dx, dy) + local x = centerX + dx - 4 * scale + local y = centerY + dy - 4 * scale + for py, row in ipairs(EXP_BURST_TILE_ROWS) do + for px = 1, 8 do + if row:sub(px, px) == "x" then + local dotX = x + (px - 1) * scale + local dotY = y + (py - 1) * scale + g.rectangle("fill", dotX, dotY, scale, scale) + end + end + end + end + + g.setShader() + g.setColor(color[1], color[2], color[3], color[4]) + particle(radius, 0) + particle(diagonal, diagonal) + particle(0, radius) + particle(-diagonal, diagonal) + particle(-radius, 0) + particle(-diagonal, -diagonal) + particle(0, -radius) + particle(diagonal, -diagonal) +end + +-- ------- draw, scaled + anchored to the player HUD ------- + +-- Called from OverworldBattle.drawHudPanels, with shot.canvas bound (the world +-- surface). Uses the same snapRects transform as the player HUD so the bar +-- lands at the HUD's exact location and the same hs scale. +function ExpBar.draw(battle, shot) + if ExpBar.enabled:get() ~= "ON" then return end + if not battle.player or battle.safari or battle.demo + or battle.showPlayerBack then return end + if battle.introSlide and battle.introSlide ~= 0 then return end + if not shot or not shot.scale then return end + + local colorMode = PaletteFX.mode + local blue = colorMode == "ogred" or colorMode == "gbc" + or colorMode == "redpp" + local color = blue and EXP_BLUE or paletteExpColor(battle) + + local px = animatedExpPixels(battle) + if px <= 0 then return end + + -- player HUD world rect + the hs scale it was drawn at + local rects, bands = OverworldBattle.snapRects(shot) + local pr = rects.player + local hs = bands.player.scale + local HUD = OverworldBattle.HUD_RECT.player -- { 72, 56, 88, 40 } + -- EXP_X/EXP_Y are GB-frame coords inside the player HUD block; offset from + -- the HUD's origin by the same hs the HUD used. + local barX = pr[1] + (EXP_X - HUD[1]) * hs + local barY = pr[2] + (EXP_Y - HUD[2]) * hs + local w = px * hs + local h = 2 * hs + + local g = love.graphics + g.setShader() + g.setColor(color[1], color[2], color[3], color[4]) + g.rectangle("fill", barX, barY, w, h) + + -- level-up burst, centred on the full bar + drawExpBurst(expState.expBurstFrame, + barX + EXP_WIDTH * hs / 2, barY + h / 2, hs, color) +end + +return ExpBar diff --git a/lib/OverworldBattle.lua b/lib/OverworldBattle.lua index 9f14d60..00fc435 100644 --- a/lib/OverworldBattle.lua +++ b/lib/OverworldBattle.lua @@ -45,6 +45,7 @@ local BattleScene = V.require("BattleScene") local BattleDOF = V.require("BattleDOF") local BattleHud = V.require("BattleHud") local UiBackplates = V.require("UiBackplates") +local ExpBar = V.require("ExpBar") local BattlePics = V.require("BattlePics") local BattleArt = V.require("BattleArt") local AnimatedBattleArt = V.require("AnimatedBattleArt") @@ -1501,6 +1502,9 @@ function OverworldBattle.drawHudPanels(battle) local shot = battle.dramaticShapeShot battle.dramaticShapeDark = nil if not shot then return end + -- XP bar follows the player HUD's exact position/scale (snapRects transform), + -- drawn into the world canvas so it works on every platform including iOS. + ExpBar.draw(battle, shot) if isIOS() then -- iOS cannot run the snapped HUD path (it draws upside-down + opaque); -- fall back to the engine's own opaque HUD panels. Mirrors upstream PR #48. diff --git a/main.lua b/main.lua index 4dd2161..2fb8252 100644 --- a/main.lua +++ b/main.lua @@ -452,6 +452,12 @@ local SETTINGS = { .. "so the words read over any ground, like the upstream default. OFF " .. "leaves the text box as the engine draws it.", full = true }, + { ExpBar.enabled, + "ON draws an experience bar under the player's HP in battle, scaled to " + .. "match the (smaller) HUD and pinned to the player HUD's exact " + .. "position. Drawing/animation adapted from unxpected-uxp's " + .. "pokemon-gen1-recomp-mod-qol, used with permission. OFF hides it.", + full = true }, -- Marked `full` for the opposite reason the battle rows are: this is not a -- knob on the look at all, it is what the look COSTS. FULL is a preset for -- the diorama, not a licence to spend four times the fill rate on the From 99e5a5425cbe91dd3eb494324bed3a0f12480b80 Mon Sep 17 00:00:00 2001 From: absol89 Date: Thu, 6 Aug 2026 04:46:21 +0200 Subject: [PATCH 077/116] rename(ui): XP BAR setting key -> BattleArtXpBar Distinguishes our experience-bar toggle from the QoL mod's qol_exp_bar so the two coexist cleanly (the user can disable the other). Only the persisted key and menu label change; the ExpBar.enabled field and all call sites are untouched. Co-Authored-By: Hermes Agent --- lib/ExpBar.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/ExpBar.lua b/lib/ExpBar.lua index 8bbf5b6..c8e58c2 100644 --- a/lib/ExpBar.lua +++ b/lib/ExpBar.lua @@ -27,7 +27,7 @@ local ExpBar = {} -- ------- enable toggle (OPTIONS row + persisted, like the other backplates) ------- -ExpBar.enabled = ModSetting.new("xpBar", "XP BAR", +ExpBar.enabled = ModSetting.new("BattleArtXpBar", "BATTLE ART XP BAR", { "OFF", "ON" }, { "OFF", "ON" }) -- ------- constants (from the QoL source) ------- From da3a175099aef0b25524848a64ef7aa0d0aa6a0f Mon Sep 17 00:00:00 2001 From: absol89 Date: Thu, 6 Aug 2026 04:52:46 +0200 Subject: [PATCH 078/116] chore(release): bump version to 1.70 Ships the iOS HUD/textarea exception (PR #48 restore), the XP bar scaled to the player HUD (ported from unxpected-uxp's QoL mod, with permission, key BattleArtXpBar), and the manifest with the new github app id so the in-game mod manager can offer the 1.68 -> 1.70 update. Co-Authored-By: Hermes Agent --- main.lua | 2 +- manifest.json | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/main.lua b/main.lua index 2fb8252..f2803f0 100644 --- a/main.lua +++ b/main.lua @@ -1013,7 +1013,7 @@ mod.hooks:wrap("world.tod", function(next, tod, ctx) return DayNight.tod() end) -mod.exports.version = "1.68" +mod.exports.version = "1.70" -- exposed so a companion mod can pin its own tiles' shapes or read the -- camera without reaching into this mod's file layout mod.exports.lib = V diff --git a/manifest.json b/manifest.json index 74ca321..102f2de 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { - "id": "DRAMATIC_SHAPE", - "name": "DRAMATIC SHAPE VOXEL MOD BATTLE ART", - "version": "1.68", + "id": "VOXEL MOD BATTLE ART", + "name": "VOXEL MOD BATTLE ART", + "version": "1.70", "api": 2, "entry": "main.lua", "profile": "content", From c6ba8e8409fd6732fea91c0e9aa5b30651547750 Mon Sep 17 00:00:00 2001 From: absol89 Date: Thu, 6 Aug 2026 04:58:49 +0200 Subject: [PATCH 079/116] feat(options): set battle-art defaults to the preferred 3D profile - BATTLE ART: animated (was static) - ANIM FRONT GEN: gen5 (was gen2) - PLAYER ANIM: red (was png) - TEXTBOX FILL: HALF (was OFF) Unchanged: 3D-BTL ON, TRAINER ART gen1, BACK ART SET gen5, ARENA FILL OFF, SPRITE LIGHT SHADED. The manifest id stays "VOXEL MOD BATTLE ART" (the DRAMATIC_SHAPE id caused a conflicting folder path users complained about); the github app id is unchanged so the 1.68 -> 1.70 update still resolves. Co-Authored-By: Hermes Agent --- lib/BattleArt.lua | 6 +++--- lib/UiBackplates.lua | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/BattleArt.lua b/lib/BattleArt.lua index a14733f..3bc3a83 100644 --- a/lib/BattleArt.lua +++ b/lib/BattleArt.lua @@ -6,10 +6,10 @@ local ModSetting = V.require("ModSetting") local BattleArt = {} BattleArt.setting = ModSetting.new("battleArt", "BATTLE ART", - { "static", "animated", "rom" }, { "STATIC", "ANIMATED", "ROM" }) + { "static", "animated", "rom" }, { "STATIC", "ANIMATED", "ROM" }, 2) BattleArt.frontAnimationSetting = ModSetting.new("frontAnimatedSet", "ANIM FRONT GEN", { "gen1", "gen2", "gen3", "gen4", "gen5" }, - { "GEN 1", "GEN 2", "GEN 3", "GEN 4", "GEN 5" }, 2) + { "GEN 1", "GEN 2", "GEN 3", "GEN 4", "GEN 5" }, 5) -- The selected generation names the static back folder in STATIC mode. In -- ANIMATED mode uses atlases for Gen 3 and Gen 5; Gen 1, 2 and 4 use their -- single images. The mode, not just the generation, decides the decoder. @@ -31,7 +31,7 @@ BattleArt.playerArtSetting = ModSetting.new( BattleArt.playerAnimationSetting = ModSetting.new( "playerAnimatedSet", "PLAYER ANIM", { "png", "gen1", "gen2", "gen3", "gen4", "gen5", "ash", "gary", "red", "rom" }, - { "PNG", "GEN 1", "GEN 2", "GEN 3", "GEN 4", "GEN 5", "ASH", "GARY", "RED", "ROM" }, 2) + { "PNG", "GEN 1", "GEN 2", "GEN 3", "GEN 4", "GEN 5", "ASH", "GARY", "RED", "ROM" }, 9) -- BATTLE ART: ROM owns the normal player portrait as completely as it owns -- species art. Keep the visible PLAYER ART row honest instead of leaving a diff --git a/lib/UiBackplates.lua b/lib/UiBackplates.lua index 9c4cf8a..cf0e94c 100644 --- a/lib/UiBackplates.lua +++ b/lib/UiBackplates.lua @@ -59,7 +59,7 @@ end -- ------- A) TEXTBOX FILL ------- UiBackplates.textboxFill = ModSetting.new("textboxFill", "TEXTBOX FILL", - { "OFF", "HALF" }, { "OFF", "HALF" }) + { "OFF", "HALF" }, { "OFF", "HALF" }, 2) -- The rect keys whose dialogue/menu boxes get the HALF backplate. The bottom -- text box ("box") and the move-select / mimic-select menus that open over it From 554aa94da56171fddec36111a7cb3590fa3a179a Mon Sep 17 00:00:00 2001 From: absol89 Date: Thu, 6 Aug 2026 05:26:08 +0200 Subject: [PATCH 080/116] fix(init): break ExpBar/OverworldBattle require cycle (stack overflow) The XP-bar port made lib/ExpBar.lua require OverworldBattle at module top level, while OverworldBattle.lua already requires ExpBar at its own top level. V.require only caches a module after it has finished loading, so the second require re-executed OverworldBattle from the top -- an infinite recursion that blew the stack at mod init and made the mod untestable. ExpBar only touches OverworldBattle inside ExpBar.draw, so the require is now resolved lazily there. Also rename the mod id/folder path DRAMATIC_SHAPE -> BATTLE_ART_VOXEL_FORK everywhere (manifest id, ModSetting namespace + fallback, main.lua error strings, tests, docs, build scripts, mod.card, release workflow, .modkitignore, and the test file dramatic_shape_test.lua -> battle_art_voxel_fork_test.lua) so the on-disk mod folder matches the new id and saved options persist under the new namespace. Co-Authored-By: Hermes Agent --- .github/workflows/release.yml | 4 +- .modkitignore | 2 +- CHANGELOG.md | 12 +- .../sprite_to_voxel_methodology.md | 4 +- lib/AntiAlias.lua | 2 +- lib/Buildings.lua | 2 +- lib/DayNight.lua | 2 +- lib/ExpBar.lua | 7 +- lib/ModSetting.lua | 6 +- lib/UiBackplates.lua | 2 +- lib/VoxelGrid.lua | 2 +- main.lua | 4 +- manifest.json | 4 +- mod.card | 2 +- tests/aa_shots.lua | 4 +- tests/arena_pick.lua | 4 +- ...est.lua => battle_art_voxel_fork_test.lua} | 166 +++++++++--------- tests/battle_shots.lua | 4 +- tests/bike_shop_shots.lua | 4 +- tests/bills_desk_shots.lua | 4 +- tests/cam_shots.lua | 4 +- tests/chief_house_shots.lua | 4 +- tests/daynight_shots.lua | 4 +- tests/fp_shots.lua | 4 +- tests/heal_machine_shots.lua | 4 +- tests/house_furniture_shots.lua | 4 +- tests/mart_shots.lua | 4 +- tests/monline_probe.lua | 4 +- tests/potted_plant_shots.lua | 4 +- tests/roof_curve_shots.lua | 4 +- tests/roof_span_probe.lua | 2 +- tests/round_regress_shots.lua | 2 +- tests/route1_candidates.lua | 4 +- tests/shelf_relief_shots.lua | 4 +- tests/ship_can_shots.lua | 4 +- tests/sky_ramp_probe.lua | 4 +- tests/tp_shots.lua | 4 +- tests/trash_can_shots.lua | 4 +- tests/voxel_acne_probe.lua | 4 +- tests/voxel_anim_probe.lua | 4 +- tests/voxel_bench.lua | 4 +- tests/voxel_building_probe.lua | 4 +- tests/voxel_figure_test.lua | 2 +- tests/voxel_mem_probe.lua | 4 +- tests/voxel_mound_probe.lua | 4 +- tests/voxel_perf_probe.lua | 4 +- tests/voxel_pipeline_probe.lua | 2 +- tests/voxel_shadow_probe.lua | 6 +- tests/voxel_shots_ab.lua | 4 +- tests/voxel_survey.lua | 2 +- tests/voxel_void_probe.lua | 6 +- tests/voxel_water_probe.lua | 4 +- tests/water_curve_shots.lua | 4 +- tests/water_reflect_probe.lua | 4 +- tools/building_images.py | 2 +- tools/building_voxels.py | 2 +- tools/voxel-survey.md | 18 +- 57 files changed, 199 insertions(+), 194 deletions(-) rename tests/{dramatic_shape_test.lua => battle_art_voxel_fork_test.lua} (96%) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6b36f84..42b4090 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -129,7 +129,7 @@ jobs: - name: Build the mod .zip env: VERSION: ${{ steps.ver.outputs.version }} - MOD_ID: "DRAMATIC_SHAPE" + MOD_ID: "BATTLE_ART_VOXEL_FORK" run: | set -euo pipefail staging="$RUNNER_TEMP/pkg" @@ -176,7 +176,7 @@ jobs: GH_TOKEN: ${{ github.token }} VERSION: ${{ steps.ver.outputs.version }} TAG: ${{ steps.ver.outputs.tag }} - MOD_ID: "DRAMATIC_SHAPE" + MOD_ID: "BATTLE_ART_VOXEL_FORK" run: | set -euo pipefail diff --git a/.modkitignore b/.modkitignore index eb0f4a9..88dabcc 100644 --- a/.modkitignore +++ b/.modkitignore @@ -6,7 +6,7 @@ # (CONTRIBUTING-mods.md "What the PR must contain", 2). tests/arena_pick.lua tests/battle_shots.lua -tests/dramatic_shape_test.lua +tests/battle_art_voxel_fork_test.lua tests/voxel_anim_probe.lua tests/voxel_door_probe.lua tests/voxel_mem_probe.lua diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c8eeee..587b4c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1119,7 +1119,7 @@ **The clock rides the save slot.** On the engine's `save.writing` event the cycle's time is written into the mod's own save-file bucket - (`save.modData.DRAMATIC_SHAPE`), and read back when a save is opened. A + (`save.modData.BATTLE_ART_VOXEL_FORK`), and read back when a save is opened. A save with no clock in it starts at day. - **Window glass.** The panes in the overworld art -- the framed squares on @@ -1843,7 +1843,7 @@ First release, ported from the engine-internal voxel branch onto the `render_pipelines` mod API. Interior furniture gets the shapes it depicts -(mods/DRAMATIC_SHAPE/tools/voxel-survey.md is the procedure that found and +(mods/BATTLE_ART_VOXEL_FORK/tools/voxel-survey.md is the procedure that found and verified these). Buildings stop being boxes wearing their own elevation. A profiled @@ -1904,10 +1904,10 @@ long as the thing throwing them is tall. standing monitor, bookcases, TV standing on the floor behind the game console's relief, potted plant, flower pot, both staircases, and the wall/window band. -- `mods/DRAMATIC_SHAPE/tests/voxel_survey.lua`: screenshot-survey driver +- `mods/BATTLE_ART_VOXEL_FORK/tests/voxel_survey.lua`: screenshot-survey driver behind the repeatable inspection procedure (SURVEY_MAP / SURVEY_SPOTS / SURVEY_LEVELS / SHOT_DIR), documented in - mods/DRAMATIC_SHAPE/tools/voxel-survey.md. + mods/BATTLE_ART_VOXEL_FORK/tools/voxel-survey.md. - `lib/Buildings.lua`: a building archetype. Where the volume path folds a whole drawing upright (roof, facade and sloped ends alike) into one box, @@ -2135,7 +2135,7 @@ long as the thing throwing them is tall. branch inside it, because it needs shader derivatives (`fwidth`) -- the one part of the mode a driver can refuse. A refusal costs the grid and nothing else. -- `mods/DRAMATIC_SHAPE/tests/voxel_shadow_probe.lua`: reports the fitted +- `mods/BATTLE_ART_VOXEL_FORK/tests/voxel_shadow_probe.lua`: reports the fitted frustum and the resolution rung, dumps the map itself, and shoots a stand point at every pitch. `SHADOW_SUN="kx,kz"` retunes the bearing for one run, `SHADOW_GRID=1` forces the wireframe on, and `SHADOW_ZOOM` pins @@ -2439,7 +2439,7 @@ one pin set covers all eleven Centers and the Celadon Hotel. announces it, and checked ahead of the active() gate so switching it while voxel mode is off still drops what is cached. - `mods/DRAMATIC_SHAPE/tests/voxel_void_probe.lua` walks the three modes + `mods/BATTLE_ART_VOXEL_FORK/tests/voxel_void_probe.lua` walks the three modes and reports the border block, whether the mesh built and whether the scene took the 3D path. It deliberately does NOT invalidate the cache itself, since doing so would hide the second half of this. diff --git a/assets/docs/buidling_to_voxel/sprite_to_voxel_methodology.md b/assets/docs/buidling_to_voxel/sprite_to_voxel_methodology.md index 40bea60..cfa3895 100644 --- a/assets/docs/buidling_to_voxel/sprite_to_voxel_methodology.md +++ b/assets/docs/buidling_to_voxel/sprite_to_voxel_methodology.md @@ -172,7 +172,7 @@ Tooling: `voxel_build_verify.py` (builds, asserts, renders previews). Because tilesets are shared, the band table can be keyed by tile id rather than by sprite: each id gets a treatment record (extrude / lay-flat / slope, plus ledge, recess, warp flags) in the spirit of -`mods/DRAMATIC_SHAPE/data/voxel_heights.lua`. Pinning blocks 56/57/60/61 with +`mods/BATTLE_ART_VOXEL_FORK/data/voxel_heights.lua`. Pinning blocks 56/57/60/61 with the profile above voxelizes Blue's house identically for free and propagates to the Fuchsia City, Route 25, and Saffron City instances of the same art. The door's lower-left tile (27) is the warp/walkable tile — keep its recessed @@ -181,7 +181,7 @@ front face aligned with the collision cell so the 3D doorway matches ### What shipping it settled -The mod implements this as `mods/DRAMATIC_SHAPE/lib/Buildings.lua`, driven by +The mod implements this as `mods/BATTLE_ART_VOXEL_FORK/lib/Buildings.lua`, driven by a `buildings` list in the profile. Three things changed from the sketch above, each for a reason worth keeping: diff --git a/lib/AntiAlias.lua b/lib/AntiAlias.lua index 30f8761..208eed5 100644 --- a/lib/AntiAlias.lua +++ b/lib/AntiAlias.lua @@ -48,7 +48,7 @@ local ModSetting = V.require("ModSetting") local AntiAlias = {} --- the key under options.modOptions.DRAMATIC_SHAPE, shared by the row in +-- the key under options.modOptions.BATTLE_ART_VOXEL_FORK, shared by the row in -- OPTIONS and the mod manager's own settings page for this mod AntiAlias.KEY = "aa" AntiAlias.LABEL = "AA" diff --git a/lib/Buildings.lua b/lib/Buildings.lua index def6c81..9275cde 100644 --- a/lib/Buildings.lua +++ b/lib/Buildings.lua @@ -39,7 +39,7 @@ -- -- One model is built per template and stamped at every placement: Red's -- and Blue's houses are the same seven-placement drawing, so they cost one --- build between them. mods/DRAMATIC_SHAPE/tools/building_voxels.py is the +-- build between them. mods/BATTLE_ART_VOXEL_FORK/tools/building_voxels.py is the -- reference implementation of the same algorithm and prints the voxel and -- shell counts this one must agree with. -- diff --git a/lib/DayNight.lua b/lib/DayNight.lua index 3be50e5..51d7612 100644 --- a/lib/DayNight.lua +++ b/lib/DayNight.lua @@ -39,7 +39,7 @@ -- already rests on; the caller passes its answer in (applyRig/tint). -- -- Persistence: the running cycle's clock is written into the mod's own --- save-file bucket (save.modData.DRAMATIC_SHAPE, via mod.save) on the +-- save-file bucket (save.modData.BATTLE_ART_VOXEL_FORK, via mod.save) on the -- engine's save.writing event, and read back on save.loaded/created. A save -- with no clock in it starts at noon. diff --git a/lib/ExpBar.lua b/lib/ExpBar.lua index c8e58c2..b87e1d5 100644 --- a/lib/ExpBar.lua +++ b/lib/ExpBar.lua @@ -17,7 +17,11 @@ local V = ... local ModSetting = V.require("ModSetting") -local OverworldBattle = V.require("OverworldBattle") +-- Resolved lazily inside ExpBar.draw: requiring OverworldBattle here would +-- recurse, because OverworldBattle.lua requires this module at its own top +-- level (lib/ExpBar.lua), and V.require only caches a module after it has +-- finished loading -- an infinite loop at mod init (stack overflow). +local OverworldBattle -- engine modules (same as the QoL source) local Growth = require("src.pokemon.Growth") @@ -193,6 +197,7 @@ end -- surface). Uses the same snapRects transform as the player HUD so the bar -- lands at the HUD's exact location and the same hs scale. function ExpBar.draw(battle, shot) + OverworldBattle = OverworldBattle or V.require("OverworldBattle") if ExpBar.enabled:get() ~= "ON" then return end if not battle.player or battle.safari or battle.demo or battle.showPlayerBack then return end diff --git a/lib/ModSetting.lua b/lib/ModSetting.lua index 6580645..3a3514c 100644 --- a/lib/ModSetting.lua +++ b/lib/ModSetting.lua @@ -9,7 +9,7 @@ -- mod setting, and this is the boilerplate two of them would otherwise -- each carry a copy of: -- --- options:define a home in options.modOptions.DRAMATIC_SHAPE, plus a row +-- options:define a home in options.modOptions.BATTLE_ART_VOXEL_FORK, plus a row -- on this mod's page in the mod manager. -- ui.options.rows the same setting on the OPTIONS menu, where the -- player already goes for VOXEL and T-SHIFT. @@ -27,7 +27,7 @@ ModSetting.__index = ModSetting local function modId() local mod = V.mod - return (mod and mod.id) or "DRAMATIC_SHAPE" + return (mod and mod.id) or "BATTLE_ART_VOXEL_FORK" end -- `values` are the stored values in ladder order and `labels` what the row @@ -108,7 +108,7 @@ end function ModSetting:row() local self_ = self return { - id = "DRAMATIC_SHAPE:" .. self.key, + id = modId() .. ":" .. self.key, label = self.label, value = function() return self_.labels[self_:read()] end, step = function(game, dir) diff --git a/lib/UiBackplates.lua b/lib/UiBackplates.lua index cf0e94c..733db04 100644 --- a/lib/UiBackplates.lua +++ b/lib/UiBackplates.lua @@ -18,7 +18,7 @@ -- blocks are separate rects and are never covered. -- -- Each is a ModSetting: it gets an OPTIONS-menu row and a mod-manager schema --- for free, and persists under options.modOptions.DRAMATIC_SHAPE like the +-- for free, and persists under options.modOptions.BATTLE_ART_VOXEL_FORK like the -- others. Defining them here -- rather than inline in main.lua -- keeps the -- three of them, and the render-path queries they answer, in one place. diff --git a/lib/VoxelGrid.lua b/lib/VoxelGrid.lua index b585072..c44e404 100644 --- a/lib/VoxelGrid.lua +++ b/lib/VoxelGrid.lua @@ -27,7 +27,7 @@ local ModSetting = V.require("ModSetting") local VoxelGrid = {} --- the key under options.modOptions.DRAMATIC_SHAPE, shared by the row in +-- the key under options.modOptions.BATTLE_ART_VOXEL_FORK, shared by the row in -- OPTIONS and the mod manager's own settings page for this mod VoxelGrid.KEY = "grid" VoxelGrid.LABEL = "V-GRID" diff --git a/main.lua b/main.lua index f2803f0..27213f4 100644 --- a/main.lua +++ b/main.lua @@ -48,11 +48,11 @@ local V = { mod = mod, path = mod.path } local function chunkFor(rel) local source = mod:read(rel) if not source then - error(("DRAMATIC_SHAPE: %s is missing -- reinstall the mod"):format(rel), 0) + error(("BATTLE_ART_VOXEL_FORK: %s is missing -- reinstall the mod"):format(rel), 0) end local chunk, err = load(source, "@" .. mod.path .. "/" .. rel) if not chunk then - error(("DRAMATIC_SHAPE: %s did not compile: %s"):format(rel, tostring(err)), 0) + error(("BATTLE_ART_VOXEL_FORK: %s did not compile: %s"):format(rel, tostring(err)), 0) end return chunk end diff --git a/manifest.json b/manifest.json index 102f2de..a0f19c8 100644 --- a/manifest.json +++ b/manifest.json @@ -1,6 +1,6 @@ { - "id": "VOXEL MOD BATTLE ART", - "name": "VOXEL MOD BATTLE ART", + "id": "BATTLE_ART_VOXEL_FORK", + "name": "BATTLE ART VOXEL FORK", "version": "1.70", "api": 2, "entry": "main.lua", diff --git a/mod.card b/mod.card index ed6b402..6281f54 100644 --- a/mod.card +++ b/mod.card @@ -3,7 +3,7 @@ return { summary = "The overworld as a 3D diorama, and battles fought on it: real occlusion, tilt-shift, over-the-shoulder fights.", author = "DramaticShape", - contact = "https://github.com/DramaticShape/DRAMATIC_SHAPE", + contact = "https://github.com/DramaticShape/BATTLE_ART_VOXEL_FORK", tags = { "graphics", "3d", "voxel", "render-pipeline", "presentation", "battle" }, differences = { diff --git a/tests/aa_shots.lua b/tests/aa_shots.lua index dcd484a..b65901e 100644 --- a/tests/aa_shots.lua +++ b/tests/aa_shots.lua @@ -35,9 +35,9 @@ return function(game) local ROOT = os.getenv("SHOT_DIR") or "shots/aa" - local handle = game.mods.exports["DRAMATIC_SHAPE"] + local handle = game.mods.exports["BATTLE_ART_VOXEL_FORK"] if not (handle and handle.lib) then - print("[aa] DRAMATIC_SHAPE mod not loaded -- nothing to shoot") + print("[aa] BATTLE_ART_VOXEL_FORK mod not loaded -- nothing to shoot") return end local V = handle.lib diff --git a/tests/arena_pick.lua b/tests/arena_pick.lua index 06bd92a..876d471 100644 --- a/tests/arena_pick.lua +++ b/tests/arena_pick.lua @@ -31,9 +31,9 @@ return function(game) 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 + local lib = exports and exports.BATTLE_ART_VOXEL_FORK and exports.BATTLE_ART_VOXEL_FORK.lib if not lib then - U.log("DRAMATIC_SHAPE is not loaded -- enable it and run again") + U.log("BATTLE_ART_VOXEL_FORK is not loaded -- enable it and run again") return end local Arena = lib.require("BattleArena") diff --git a/tests/dramatic_shape_test.lua b/tests/battle_art_voxel_fork_test.lua similarity index 96% rename from tests/dramatic_shape_test.lua rename to tests/battle_art_voxel_fork_test.lua index bcc95ac..c8364da 100644 --- a/tests/dramatic_shape_test.lua +++ b/tests/battle_art_voxel_fork_test.lua @@ -18,7 +18,7 @@ local MOD_PATH = os.getenv("DS_MOD_PATH") or "mods/DramaticShapeVoxelMod" local run = T.sdk.loadMod(MOD_PATH, { data = Data }) T.eq(#run.errors, 0, - "DRAMATIC_SHAPE loads clean: " .. table.concat(run.errors, "; ")) + "BATTLE_ART_VOXEL_FORK loads clean: " .. table.concat(run.errors, "; ")) -- Game:load does this after the merge; the SDK harness merges into a -- fixture dataset instead, so point the dispatcher at that one. @@ -44,7 +44,7 @@ T.check(defs.tiltshift.drawWorld == nil, -- provenance: a callback that throws at play time must be attributable to -- this mod, not reported as an engine fault -T.eq(defs._owners and defs._owners.voxel, "DRAMATIC_SHAPE", +T.eq(defs._owners and defs._owners.voxel, "BATTLE_ART_VOXEL_FORK", "the merge stamped the pipeline's owning mod") -- ------- the ladders the engine drives @@ -116,7 +116,7 @@ T.eq(byLabel.VOXEL.value(), "FULL", "the row renders the current rung's label") -- settings page looks. local Runtime = require("src.mods.Runtime") -local VoxelState = run.loader.exports.DRAMATIC_SHAPE.lib.require("VoxelState") +local VoxelState = run.loader.exports.BATTLE_ART_VOXEL_FORK.lib.require("VoxelState") -- ------- FULL is a preset that owns the rows describing the LOOK -- @@ -138,24 +138,24 @@ for _, row in ipairs(fullRows) do fullIds[row.id] = true end T.check(fullIds["pipeline:voxel"], "FULL keeps the VOXEL row it lives on") T.check(not fullIds["pipeline:tiltshift"], "FULL takes T-SHIFT off the menu -- it owns the blur") -T.check(not fullIds["DRAMATIC_SHAPE:grid"], "and V-GRID") -T.check(not fullIds["DRAMATIC_SHAPE:curve"], "and V-CURVE") -T.check(not fullIds["DRAMATIC_SHAPE:daytime"], "and DAYTIME") +T.check(not fullIds["BATTLE_ART_VOXEL_FORK:grid"], "and V-GRID") +T.check(not fullIds["BATTLE_ART_VOXEL_FORK:curve"], "and V-CURVE") +T.check(not fullIds["BATTLE_ART_VOXEL_FORK:daytime"], "and DAYTIME") -- but the battle rows survive it: they are not knobs on the look, and FULL -- sets them once rather than holding them, so a player who wants the classic -- back sprite (or no staged fights at all) can still say so from inside FULL -T.check(fullIds["DRAMATIC_SHAPE:battles"], "3D-BTL is still on the menu under FULL") -T.check(fullIds["DRAMATIC_SHAPE:battleBack"], "and BACK SPRITES with it") +T.check(fullIds["BATTLE_ART_VOXEL_FORK:battles"], "3D-BTL is still on the menu under FULL") +T.check(fullIds["BATTLE_ART_VOXEL_FORK:battleBack"], "and BACK SPRITES with it") -- and AA, for the opposite reason: it is not a knob on the look at all, it is -- what the look COSTS, and only the player knows what their machine can carry -T.check(fullIds["DRAMATIC_SHAPE:aa"], "and AA, which FULL neither sets nor owns") +T.check(fullIds["BATTLE_ART_VOXEL_FORK:aa"], "and AA, which FULL neither sets nor owns") -- DAYTIME is not only hidden under FULL, it is HELD at SYNC: the row cannot -- be reached while FULL owns it, so a value changed underneath (the mod -- manager's page, an edited options file) snaps back when the menu asks do - local DayNight = run.loader.exports.DRAMATIC_SHAPE.lib.require("DayNight") + local DayNight = run.loader.exports.BATTLE_ART_VOXEL_FORK.lib.require("DayNight") DayNight.setting:sync("night") Runtime.call("ui.options.rows", function(_, r) return r end, { data = Data }, { { id = "tilt" } }) @@ -176,7 +176,7 @@ end -- chunk and a chunk has 200 local slots, so a section that wants half a dozen -- borrows them rather than spending them for the rest of the run. do -local Battles = run.loader.exports.DRAMATIC_SHAPE.lib.require("OverworldBattle") +local Battles = run.loader.exports.BATTLE_ART_VOXEL_FORK.lib.require("OverworldBattle") T.eq(Battles.enabled(), true, "3D-BTL is on by default, which is what pins it") -- off FULL first: the row on its own has to be enough, and FULL is checked @@ -301,11 +301,11 @@ local grouped = Runtime.call("ui.options.rows", function(_, r) return r end, { id = "void_fill" } }) local order = {} for i, row in ipairs(grouped) do order[row.id] = i end -T.check(order["pipeline:tiltshift"] < order["DRAMATIC_SHAPE:grid"], +T.check(order["pipeline:tiltshift"] < order["BATTLE_ART_VOXEL_FORK:grid"], "the mode's settings follow its pipeline rows") -T.eq(order["DRAMATIC_SHAPE:battles"] - order["pipeline:tiltshift"], 4, +T.eq(order["BATTLE_ART_VOXEL_FORK:battles"] - order["pipeline:tiltshift"], 4, "and sit in one unbroken block, not scattered to the end of the list") -T.check(order["void_fill"] > order["DRAMATIC_SHAPE:battles"], +T.check(order["void_fill"] > order["BATTLE_ART_VOXEL_FORK:battles"], "with the engine's own later rows still after them") -- ------- the open menu notices when FULL is stepped onto or off @@ -329,7 +329,7 @@ local menu = OptionsMenu.new(menuGame) local function rowIndex(m, id) for i, row in ipairs(m.rows) do if row.id == id then return i end end end -T.check(rowIndex(menu, "DRAMATIC_SHAPE:grid"), +T.check(rowIndex(menu, "BATTLE_ART_VOXEL_FORK:grid"), "off FULL the menu opens with the mode's settings on it") -- step the VOXEL row from 15 down to FULL, the way the player would @@ -338,7 +338,7 @@ pressed = { left = true } menu:update(0) pressed = {} T.eq(Pipelines.level("voxel"), 1, "the step landed on FULL") -T.check(not rowIndex(menu, "DRAMATIC_SHAPE:grid"), +T.check(not rowIndex(menu, "BATTLE_ART_VOXEL_FORK:grid"), "and the rows FULL owns left the OPEN menu at once") T.check(not rowIndex(menu, "pipeline:tiltshift"), "T-SHIFT with them") T.check(menu.index <= #menu.rows + 1, "the cursor stayed in range") @@ -349,7 +349,7 @@ pressed = { right = true } menu:update(0) pressed = {} T.eq(Pipelines.level("voxel"), 2, "the step left FULL") -T.check(rowIndex(menu, "DRAMATIC_SHAPE:grid"), +T.check(rowIndex(menu, "BATTLE_ART_VOXEL_FORK:grid"), "and the rows came straight back without reopening the menu") T.check(rowIndex(menu, "pipeline:tiltshift"), "T-SHIFT too") @@ -360,14 +360,14 @@ T.check(rowIndex(menu, "pipeline:tiltshift"), "T-SHIFT too") -- press that switched staged battles on would leave the cursor a row further -- down than the player left it. do -local Battles = run.loader.exports.DRAMATIC_SHAPE.lib.require("OverworldBattle") +local Battles = run.loader.exports.BATTLE_ART_VOXEL_FORK.lib.require("OverworldBattle") Battles.setting:setIndex(2, menuGame) -- staged battles off menuGame.save.options.battleLayout = "wide" Pipelines.setLevel("voxel", 2) local layoutMenu = OptionsMenu.new(menuGame) T.check(rowIndex(layoutMenu, "battleLayout"), "with staged battles off, the engine's BATTLE LAYOUT row is on the menu") -layoutMenu.index = rowIndex(layoutMenu, "DRAMATIC_SHAPE:battles") +layoutMenu.index = rowIndex(layoutMenu, "BATTLE_ART_VOXEL_FORK:battles") pressed = { right = true } layoutMenu:update(0) pressed = {} @@ -375,7 +375,7 @@ T.eq(Battles.setting:get(), true, "the step switched staged battles on") T.check(not rowIndex(layoutMenu, "battleLayout"), "and BATTLE LAYOUT left the open menu with the same keypress") T.eq(menuGame.save.options.battleLayout, "og", "pinned to OG on the way out") -T.eq(layoutMenu.index, rowIndex(layoutMenu, "DRAMATIC_SHAPE:battles"), +T.eq(layoutMenu.index, rowIndex(layoutMenu, "BATTLE_ART_VOXEL_FORK:battles"), "with the cursor still on the row the player just used") end @@ -423,9 +423,9 @@ T.eq(hookedByLabel["BACK PLACEMENT"].value(), "AUTO", local settingGame = { save = { options = {} }, mods = { modOptions = {} } } grid.step(settingGame) T.eq(grid.value(), "ON", "stepping the row toggles the grid") -T.eq(settingGame.save.options.modOptions.DRAMATIC_SHAPE.grid, true, +T.eq(settingGame.save.options.modOptions.BATTLE_ART_VOXEL_FORK.grid, true, "the toggle lands in options.modOptions, where the mod manager reads it") -T.eq(settingGame.mods.modOptions.DRAMATIC_SHAPE.grid, true, +T.eq(settingGame.mods.modOptions.BATTLE_ART_VOXEL_FORK.grid, true, "and in the loader's live copy, which mod.options:get reads") grid.step(settingGame) T.eq(grid.value(), "OFF", "stepping again toggles it back") @@ -433,9 +433,9 @@ T.eq(grid.value(), "OFF", "stepping again toggles it back") -- the curve is a four-rung ladder rather than a toggle, and wraps curve.step(settingGame, 1) T.eq(curve.value(), "1", "stepping the curve climbs its ladder") -T.eq(settingGame.save.options.modOptions.DRAMATIC_SHAPE.curve, 1, +T.eq(settingGame.save.options.modOptions.BATTLE_ART_VOXEL_FORK.curve, 1, "the curve level persists alongside the grid, not over it") -T.eq(settingGame.save.options.modOptions.DRAMATIC_SHAPE.grid, false, +T.eq(settingGame.save.options.modOptions.BATTLE_ART_VOXEL_FORK.grid, false, "and the grid it shares a bucket with is untouched") curve.step(settingGame, 1) curve.step(settingGame, 1) @@ -448,7 +448,7 @@ curve.step(settingGame, 1) -- the strength scales with the view height, so a rung looks the same at -- every zoom -- and is exactly zero when the setting is off -local WorldCurve = run.loader.exports.DRAMATIC_SHAPE.lib.require("WorldCurve") +local WorldCurve = run.loader.exports.BATTLE_ART_VOXEL_FORK.lib.require("WorldCurve") T.eq(WorldCurve.k(154), 0, "an OFF curve bends nothing") curve.step(settingGame, 1) T.check(math.abs(WorldCurve.k(154) - WorldCurve.AMOUNTS[2] / 154) < 1e-9, @@ -480,8 +480,8 @@ T.eq(curve.value(), "OFF", "the curve is left off for the rows below") -- multiplied up into the canvas the pass actually opened: the wireframe's -- line width and the FX overlay's sprite scale. do -local AntiAlias = run.loader.exports.DRAMATIC_SHAPE.lib.require("AntiAlias") -local VoxelGrid = run.loader.exports.DRAMATIC_SHAPE.lib.require("VoxelGrid") +local AntiAlias = run.loader.exports.BATTLE_ART_VOXEL_FORK.lib.require("AntiAlias") +local VoxelGrid = run.loader.exports.BATTLE_ART_VOXEL_FORK.lib.require("VoxelGrid") local aaGame = { save = { options = {} }, mods = { modOptions = {} } } local aa = hookedByLabel.AA T.eq(aa.label, "AA", "the anti-aliasing row carries its label") @@ -499,7 +499,7 @@ T.eq(VoxelGrid.width(), VoxelGrid.WIDTH, aa.step(aaGame, 1) T.eq(aa.value(), "2X", "stepping the row climbs to two samples a pixel") -T.eq(aaGame.save.options.modOptions.DRAMATIC_SHAPE.aa, 2, +T.eq(aaGame.save.options.modOptions.BATTLE_ART_VOXEL_FORK.aa, 2, "the sample count persists beside the other settings, not over them") w, h = AntiAlias.expand(320, 200) T.eq(w, 453, "two samples a pixel is a canvas root-two wider") @@ -545,7 +545,7 @@ end -- The harness has no love.image at all, which is why the checks above never -- reached this branch. Stand up just enough of one to walk it. -local TerrainAtlas = run.loader.exports.DRAMATIC_SHAPE.lib.require("TerrainAtlas") +local TerrainAtlas = run.loader.exports.BATTLE_ART_VOXEL_FORK.lib.require("TerrainAtlas") local TileRenderer = require("src.render.TileRenderer") local realImage, realNewImage = love.image, love.graphics.newImage @@ -787,7 +787,7 @@ TileRenderer.animFrame = nil -- (any frames-animated tile resolves `flower` with no profile entry), -- and the PATCH writes alpha where the frame is not dark. -local TileShape = run.loader.exports.DRAMATIC_SHAPE.lib.require("TileShape") +local TileShape = run.loader.exports.BATTLE_ART_VOXEL_FORK.lib.require("TileShape") local flowerSet = { id = "T_FLOWER_PIN", image = "assets/tilesets/stub.png", @@ -887,7 +887,7 @@ love.image, love.graphics.newImage = realImage, realNewImage -- Every OTHER reload still has to drop it, so this pins the distinction -- rather than just the fix. -local ChunkMesher = run.loader.exports.DRAMATIC_SHAPE.lib.require("ChunkMesher") +local ChunkMesher = run.loader.exports.BATTLE_ART_VOXEL_FORK.lib.require("ChunkMesher") local Runtime = require("src.mods.Runtime") local realInvalidate = ChunkMesher.invalidate @@ -975,7 +975,7 @@ ChunkMesher.refresh = realRefresh -- function, so this stays a claim about the picture rather than a -- restatement of the implementation. -local VoxelScene = run.loader.exports.DRAMATIC_SHAPE.lib.require("VoxelScene") +local VoxelScene = run.loader.exports.BATTLE_ART_VOXEL_FORK.lib.require("VoxelScene") local modeColors = VoxelScene._modeColors T.check(type(modeColors) == "function", "the scene exposes its palette resolve") @@ -1076,8 +1076,8 @@ keyGame = { writeOptions = function() end, } -local VoxelGrid = run.loader.exports.DRAMATIC_SHAPE.lib.require("VoxelGrid") -local Curve = run.loader.exports.DRAMATIC_SHAPE.lib.require("WorldCurve") +local VoxelGrid = run.loader.exports.BATTLE_ART_VOXEL_FORK.lib.require("VoxelGrid") +local Curve = run.loader.exports.BATTLE_ART_VOXEL_FORK.lib.require("WorldCurve") -- ------- 3 walks the ANGLE rungs and steps over FULL -- @@ -1122,7 +1122,7 @@ local curveBefore = Curve.setting:get() Game.keypressed(keyGame, "7") T.neq(Curve.setting:get(), curveBefore, "7 cycles V-CURVE") -local Battles = run.loader.exports.DRAMATIC_SHAPE.lib.require("OverworldBattle") +local Battles = run.loader.exports.BATTLE_ART_VOXEL_FORK.lib.require("OverworldBattle") T.eq(Battles.setting:get(), true, "3D-BTL starts on") Game.keypressed(keyGame, "8") T.eq(Battles.setting:get(), false, "8 toggles overworld battles off") @@ -1212,7 +1212,7 @@ overworld.transitioning = false -- cave is a room with a ceiling, and the void past its walls is the -- outside of a box, not open air. -local Voxel = run.loader.exports.DRAMATIC_SHAPE.lib.require("VoxelState") +local Voxel = run.loader.exports.BATTLE_ART_VOXEL_FORK.lib.require("VoxelState") local skyFor = VoxelScene._skyFor local skyStrength = VoxelScene._skyStrength T.check(type(skyFor) == "function", "the scene exposes its sky resolve") @@ -1220,7 +1220,7 @@ T.check(type(skyFor) == "function", "the scene exposes its sky resolve") -- pinned to DAY for every sky assertion below: the row ships defaulting to -- SYNC -- the machine's own clock -- which would hand these tests whatever -- palette the hour of the test run happened to be -run.loader.exports.DRAMATIC_SHAPE.lib.require("DayNight").setting:sync("day") +run.loader.exports.BATTLE_ART_VOXEL_FORK.lib.require("DayNight").setting:sync("day") local outside = { def = { id = "PALLET_TOWN", tileset = "OVERWORLD" } } local inside = { def = { id = "REDS_HOUSE_1F", tileset = "HOUSE" } } @@ -1298,9 +1298,9 @@ T.check(skyRGB("gbc_inv")[3] ~= blue[3], -- each. Nothing is baked to a fixed size and upscaled -- the bands fill the -- window and the dither grid is cut to the diorama's own pixel scale. do -local Sky = run.loader.exports.DRAMATIC_SHAPE.lib.require("Sky") -local Voxel3D = run.loader.exports.DRAMATIC_SHAPE.lib.require("Voxel3D") -local DayNight = run.loader.exports.DRAMATIC_SHAPE.lib.require("DayNight") +local Sky = run.loader.exports.BATTLE_ART_VOXEL_FORK.lib.require("Sky") +local Voxel3D = run.loader.exports.BATTLE_ART_VOXEL_FORK.lib.require("Voxel3D") +local DayNight = run.loader.exports.BATTLE_ART_VOXEL_FORK.lib.require("DayNight") local function luma(c) return 0.299 * c[1] + 0.587 * c[2] + 0.114 * c[3] end @@ -1561,11 +1561,11 @@ end -- and a lake is either a hole in the world or is drawn twice -- and it is -- pure geometry, so it is driven here against a hand-drawn map. do -local Water = run.loader.exports.DRAMATIC_SHAPE.lib.require("Water") -local Sky = run.loader.exports.DRAMATIC_SHAPE.lib.require("Sky") -local ChunkMesher = run.loader.exports.DRAMATIC_SHAPE.lib.require("ChunkMesher") -local Structures = run.loader.exports.DRAMATIC_SHAPE.lib.require("Structures") -local Shapes = run.loader.exports.DRAMATIC_SHAPE.lib.require("TileShape") +local Water = run.loader.exports.BATTLE_ART_VOXEL_FORK.lib.require("Water") +local Sky = run.loader.exports.BATTLE_ART_VOXEL_FORK.lib.require("Sky") +local ChunkMesher = run.loader.exports.BATTLE_ART_VOXEL_FORK.lib.require("ChunkMesher") +local Structures = run.loader.exports.BATTLE_ART_VOXEL_FORK.lib.require("Structures") +local Shapes = run.loader.exports.BATTLE_ART_VOXEL_FORK.lib.require("TileShape") local TileShapeHeights = Shapes.heights() -- ------- the ladder @@ -1596,7 +1596,7 @@ Water.setting:sync("full") -- pixels that crawls smoothly between them gives away that the quantisation -- is only skin deep. do -local TerrainAtlas = run.loader.exports.DRAMATIC_SHAPE.lib.require("TerrainAtlas") +local TerrainAtlas = run.loader.exports.BATTLE_ART_VOXEL_FORK.lib.require("TerrainAtlas") local realClock = TerrainAtlas._animFrame local frame = 0 TerrainAtlas._animFrame = function() return frame end @@ -1712,8 +1712,8 @@ T.check(rampImg == nil or rampCount == #Sky.bands(), -- tips the reflection toward the way the camera looks by however far that -- camera is from having a horizon in frame. do -local Voxel3D = run.loader.exports.DRAMATIC_SHAPE.lib.require("Voxel3D") -local VoxelState = run.loader.exports.DRAMATIC_SHAPE.lib.require("VoxelState") +local Voxel3D = run.loader.exports.BATTLE_ART_VOXEL_FORK.lib.require("Voxel3D") +local VoxelState = run.loader.exports.BATTLE_ART_VOXEL_FORK.lib.require("VoxelState") local wasAngle, wasCam = VoxelState.angle, Voxel3D.camera Voxel3D.camera = nil @@ -1769,7 +1769,7 @@ end -- showing the sky and the shoreline, which reads as a sticker rather than as -- a shadow. Everything the world casts still shades it. do -local ShadowMap = run.loader.exports.DRAMATIC_SHAPE.lib.require("ShadowMap") +local ShadowMap = run.loader.exports.BATTLE_ART_VOXEL_FORK.lib.require("ShadowMap") T.check(type(ShadowMap.sprites) == "function", "the sun pass can be told it is drawing the cast rather than the world") -- inert outside a pass, like every other toggle on it -- a caller that @@ -2045,7 +2045,7 @@ Voxel.angle = 0 -- strings, one per cell row, "." open and anything else solid; "w" is water -- (open to a surfer only) and "d" a warp tile. -local BattleArena = run.loader.exports.DRAMATIC_SHAPE.lib.require("BattleArena") +local BattleArena = run.loader.exports.BATTLE_ART_VOXEL_FORK.lib.require("BattleArena") local function stubMap(rows) local at = function(cx, cy) @@ -2224,9 +2224,9 @@ T.check(BattleArena.find(roomy, 2, 3, false) ~= nil, -- real camera rather than asserted about the constants, so the day someone -- retunes the rig this either still lands or says so. -local BattleCam = run.loader.exports.DRAMATIC_SHAPE.lib.require("BattleCam") -local BattleScene = run.loader.exports.DRAMATIC_SHAPE.lib.require("BattleScene") -local Voxel3Dcam = run.loader.exports.DRAMATIC_SHAPE.lib.require("Voxel3D") +local BattleCam = run.loader.exports.BATTLE_ART_VOXEL_FORK.lib.require("BattleCam") +local BattleScene = run.loader.exports.BATTLE_ART_VOXEL_FORK.lib.require("BattleScene") +local Voxel3Dcam = run.loader.exports.BATTLE_ART_VOXEL_FORK.lib.require("Voxel3D") -- where a world point lands in the 160x144 frame, or nil behind the camera local function project(cam, point, w, h, fov) @@ -2391,8 +2391,8 @@ T.eq(rig.curve, 0, "the battle camera switches the world curve off") -- The engine draws it as a full-screen white rectangle, which is a flash on -- a white battle field and a whiteout of the map, the HUD and the text box -- over a world. It is dropped on the way past and put back on the two cards. -local Battles = run.loader.exports.DRAMATIC_SHAPE.lib.require("OverworldBattle") -local Art = run.loader.exports.DRAMATIC_SHAPE.lib.require("BattleArt") +local Battles = run.loader.exports.BATTLE_ART_VOXEL_FORK.lib.require("OverworldBattle") +local Art = run.loader.exports.BATTLE_ART_VOXEL_FORK.lib.require("BattleArt") T.eq(Art.frontAnimationSetting.values[1], "gen1", "animated fronts expose the single-frame Gen 1 compatibility collection") T.eq(Art.frontAnimationSetting.labels[1], "GEN 1", @@ -2423,7 +2423,7 @@ T.eq(Battles.flashing({ fx = { flash = 16 }, frame = 5 }), true, -- always wears the seams. The player's own V-GRID row must not be touched by -- that -- an override, not a write, or switching the mode off mid-battle -- would quietly rewrite a setting they chose. -local Grid = run.loader.exports.DRAMATIC_SHAPE.lib.require("VoxelGrid") +local Grid = run.loader.exports.BATTLE_ART_VOXEL_FORK.lib.require("VoxelGrid") Grid.override = nil local rowWas = Grid.setting:get() T.eq(Grid.enabled(), rowWas and true or false, @@ -2443,7 +2443,7 @@ T.eq(Grid.enabled(), rowWas and true or false, -- to be derived from where they landed rather than from a constant -- and it -- has to hold BOTH, which a band narrower than the gap between them would -- not. -local BattleDOF = run.loader.exports.DRAMATIC_SHAPE.lib.require("BattleDOF") +local BattleDOF = run.loader.exports.BATTLE_ART_VOXEL_FORK.lib.require("BattleDOF") local focusY, band, range = BattleDOF.bandFor(96, 56, 144) T.check(math.abs(focusY - 76 / 144) < 1e-9, "the band centres between the two marks") @@ -2585,9 +2585,9 @@ local backGame = { save = { options = { modOptions = {} } }, Battles.setting:setIndex(1, backGame) -- 3D-BTL on Battles.backSetting:setIndex(2, backGame) -- BACK SPRITES on T.eq(Battles.backPinned(), true, "switched on, the back pic is pinned") -T.eq(backGame.save.options.modOptions.DRAMATIC_SHAPE.battleBack, true, +T.eq(backGame.save.options.modOptions.BATTLE_ART_VOXEL_FORK.battleBack, true, "and it persists on its own key, beside 3D-BTL rather than over it") -T.eq(backGame.save.options.modOptions.DRAMATIC_SHAPE.battles, true, +T.eq(backGame.save.options.modOptions.BATTLE_ART_VOXEL_FORK.battles, true, "which is still where it always was") -- and it means nothing at all with staged battles off: there is no staged @@ -2631,8 +2631,8 @@ local offRows = Runtime.call("ui.options.rows", function(_, r) return r end, backGame, { { id = "tilt" } }) local offIds = {} for _, row in ipairs(offRows) do offIds[row.id] = true end -T.check(offIds["DRAMATIC_SHAPE:battles"], "3D-BTL itself is still offered") -T.check(not offIds["DRAMATIC_SHAPE:battleBack"], +T.check(offIds["BATTLE_ART_VOXEL_FORK:battles"], "3D-BTL itself is still offered") +T.check(not offIds["BATTLE_ART_VOXEL_FORK:battleBack"], "but BACK SPRITES is off the menu while there is no staged fight to be about") Battles.setting:setIndex(1, backGame) @@ -2640,8 +2640,8 @@ local onRows = Runtime.call("ui.options.rows", function(_, r) return r end, backGame, { { id = "tilt" } }) local onAt = {} for i, row in ipairs(onRows) do onAt[row.id] = i end -T.check(onAt["DRAMATIC_SHAPE:battleBack"], "switched back on, so is the row") -T.eq(onAt["DRAMATIC_SHAPE:battleBack"] - onAt["DRAMATIC_SHAPE:battles"], 1, +T.check(onAt["BATTLE_ART_VOXEL_FORK:battleBack"], "switched back on, so is the row") +T.eq(onAt["BATTLE_ART_VOXEL_FORK:battleBack"] - onAt["BATTLE_ART_VOXEL_FORK:battles"], 1, "directly under the row it belongs to") -- ------- and which pic is the pinned one is asked with the other side BLANKED @@ -2695,8 +2695,8 @@ end -- canvas IS the moment the world is finished and the paper has not started. -- That is what this drives -- the gates, and the ordering. do -local DayTint = run.loader.exports.DRAMATIC_SHAPE.lib.require("DayTint") -local DayNight = run.loader.exports.DRAMATIC_SHAPE.lib.require("DayNight") +local DayTint = run.loader.exports.BATTLE_ART_VOXEL_FORK.lib.require("DayTint") +local DayNight = run.loader.exports.BATTLE_ART_VOXEL_FORK.lib.require("DayNight") -- the map the hour is asked about is the one the player is standing on, read -- off the live game rather than passed in -- so there has to be one @@ -2801,7 +2801,7 @@ end -- filled the notch between a Rattata's ears and the gap between its body and -- its tail, which are background and have the drawing over them. do -local BattlePics = run.loader.exports.DRAMATIC_SHAPE.lib.require("BattlePics") +local BattlePics = run.loader.exports.BATTLE_ART_VOXEL_FORK.lib.require("BattlePics") -- Run one hand-drawn figure through the real BattlePics and hand back a -- reader over what came out. The pic is faked at the readback seam, which is @@ -3063,7 +3063,7 @@ end -- whatever is on top, which is the fade while it is up, so the fade has to be -- off the stack before the battle finishes and back on it afterwards. do -local Exit = run.loader.exports.DRAMATIC_SHAPE.lib.require("BattleExit") +local Exit = run.loader.exports.BATTLE_ART_VOXEL_FORK.lib.require("BattleExit") T.eq(Data.transitions and Data.transitions[Exit.ID] and Data.transitions[Exit.ID].frames, Exit.FRAMES, @@ -3166,10 +3166,10 @@ end -- camera looks north, so the discs must actually cross the northern sky), -- and the clock's ride through the save file. do -local DayNight = run.loader.exports.DRAMATIC_SHAPE.lib.require("DayNight") -local ShadowMap = run.loader.exports.DRAMATIC_SHAPE.lib.require("ShadowMap") -local Voxel3D = run.loader.exports.DRAMATIC_SHAPE.lib.require("Voxel3D") -local Voxel = run.loader.exports.DRAMATIC_SHAPE.lib.require("VoxelState") +local DayNight = run.loader.exports.BATTLE_ART_VOXEL_FORK.lib.require("DayNight") +local ShadowMap = run.loader.exports.BATTLE_ART_VOXEL_FORK.lib.require("ShadowMap") +local Voxel3D = run.loader.exports.BATTLE_ART_VOXEL_FORK.lib.require("Voxel3D") +local Voxel = run.loader.exports.BATTLE_ART_VOXEL_FORK.lib.require("VoxelState") -- the dial and its pins T.eq(DayNight.setting.values[1], "sync", @@ -3323,7 +3323,7 @@ T.eq(DayNight.tod(0), "MORNING", "dawn is MORNING") T.eq(DayNight.tod(600), "EVENING", "dusk is EVENING") -- the clock rides the save slot -local modApi = run.loader.exports.DRAMATIC_SHAPE.lib.mod +local modApi = run.loader.exports.BATTLE_ART_VOXEL_FORK.lib.mod DayNight.setting:sync("cycle") DayNight.clock = 777 DayNight.store() @@ -3421,7 +3421,7 @@ end -- The scenes wire it as tint(outdoor or isCanopy(map)) over the unchanged -- noon rig, so what is checked here is the classification itself. do -local DayNight = run.loader.exports.DRAMATIC_SHAPE.lib.require("DayNight") +local DayNight = run.loader.exports.BATTLE_ART_VOXEL_FORK.lib.require("DayNight") T.check(DayNight.isCanopy({ id = "VIRIDIAN_FOREST" }), "Viridian Forest stands under a canopy") T.check(not DayNight.isCanopy({ id = "MT_MOON_1F" }), @@ -3440,8 +3440,8 @@ end -- stored depth and nothing about where their shadow falls -- taking most of -- the forgiveness back for the shadow they throw and for nothing else. do -local ShadowMap = run.loader.exports.DRAMATIC_SHAPE.lib.require("ShadowMap") -local Mat4 = run.loader.exports.DRAMATIC_SHAPE.lib.require("Mat4") +local ShadowMap = run.loader.exports.BATTLE_ART_VOXEL_FORK.lib.require("ShadowMap") +local Mat4 = run.loader.exports.BATTLE_ART_VOXEL_FORK.lib.require("Mat4") local dir = ShadowMap.sunDir() local s = -ShadowMap.slack * ShadowMap.SNUG local m = ShadowMap.snug(nil) @@ -3467,9 +3467,9 @@ end -- building's sits a row down inside its tile. The scan takes a pure reader, -- so the geometry is checked here without an image in sight. do -local GlassMask = run.loader.exports.DRAMATIC_SHAPE.lib.require("GlassMask") -local DayNight = run.loader.exports.DRAMATIC_SHAPE.lib.require("DayNight") -local Voxel3D = run.loader.exports.DRAMATIC_SHAPE.lib.require("Voxel3D") +local GlassMask = run.loader.exports.BATTLE_ART_VOXEL_FORK.lib.require("GlassMask") +local DayNight = run.loader.exports.BATTLE_ART_VOXEL_FORK.lib.require("DayNight") +local Voxel3D = run.loader.exports.BATTLE_ART_VOXEL_FORK.lib.require("Voxel3D") local W, H = 32, 16 local blackAt = {} @@ -3540,10 +3540,10 @@ end do local FirstPerson = - run.loader.exports.DRAMATIC_SHAPE.lib.require("FirstPerson") -local VoxelState = run.loader.exports.DRAMATIC_SHAPE.lib.require("VoxelState") -local FreeMove = run.loader.exports.DRAMATIC_SHAPE.lib.require("FreeMove") -local Voxel3D = run.loader.exports.DRAMATIC_SHAPE.lib.require("Voxel3D") + run.loader.exports.BATTLE_ART_VOXEL_FORK.lib.require("FirstPerson") +local VoxelState = run.loader.exports.BATTLE_ART_VOXEL_FORK.lib.require("VoxelState") +local FreeMove = run.loader.exports.BATTLE_ART_VOXEL_FORK.lib.require("FreeMove") +local Voxel3D = run.loader.exports.BATTLE_ART_VOXEL_FORK.lib.require("Voxel3D") T.eq(VoxelState.FP_LEVEL, 6, "1ST is the seventh rung") T.check(VoxelState.isFirstPerson(6), "and isFirstPerson answers for it") @@ -3710,4 +3710,4 @@ end Pipelines.reset() run.release() -T.finish("DRAMATIC_SHAPE") +T.finish("BATTLE_ART_VOXEL_FORK") diff --git a/tests/battle_shots.lua b/tests/battle_shots.lua index af8ae8b..c612f5d 100644 --- a/tests/battle_shots.lua +++ b/tests/battle_shots.lua @@ -64,10 +64,10 @@ return function(game) local Battles = nil local exports = game.mods and game.mods.exports - local lib = exports and exports.DRAMATIC_SHAPE and exports.DRAMATIC_SHAPE.lib + local lib = exports and exports.BATTLE_ART_VOXEL_FORK and exports.BATTLE_ART_VOXEL_FORK.lib if lib then Battles = lib.require("OverworldBattle") end if not Battles then - U.log("DRAMATIC_SHAPE is not loaded -- enable it and run again") + U.log("BATTLE_ART_VOXEL_FORK is not loaded -- enable it and run again") end local function label(place, class) diff --git a/tests/bike_shop_shots.lua b/tests/bike_shop_shots.lua index 7a896ed..0b810ef 100644 --- a/tests/bike_shop_shots.lua +++ b/tests/bike_shop_shots.lua @@ -11,9 +11,9 @@ return function(game) local ROOT = (os.getenv("SHOT_DIR") or "shots/bikes") .. "/" .. (os.getenv("AB_TAG") or "before") - local handle = game.mods.exports["DRAMATIC_SHAPE"] + local handle = game.mods.exports["BATTLE_ART_VOXEL_FORK"] if not (handle and handle.lib) then - print("[bike] DRAMATIC_SHAPE mod not loaded") + print("[bike] BATTLE_ART_VOXEL_FORK mod not loaded") return end local V = handle.lib diff --git a/tests/bills_desk_shots.lua b/tests/bills_desk_shots.lua index f6c7898..7faf058 100644 --- a/tests/bills_desk_shots.lua +++ b/tests/bills_desk_shots.lua @@ -13,9 +13,9 @@ return function(game) local ROOT = (os.getenv("SHOT_DIR") or "shots/billsdesk") .. "/" .. (os.getenv("AB_TAG") or "after") - local handle = game.mods.exports["DRAMATIC_SHAPE"] + local handle = game.mods.exports["BATTLE_ART_VOXEL_FORK"] if not (handle and handle.lib) then - print("[bills] DRAMATIC_SHAPE mod not loaded") + print("[bills] BATTLE_ART_VOXEL_FORK mod not loaded") return end local V = handle.lib diff --git a/tests/cam_shots.lua b/tests/cam_shots.lua index 3bcbba3..d604d0c 100644 --- a/tests/cam_shots.lua +++ b/tests/cam_shots.lua @@ -10,9 +10,9 @@ return function(game) local ROOT = (os.getenv("SHOT_DIR") or "shots/cam") - local handle = game.mods.exports["DRAMATIC_SHAPE"] + local handle = game.mods.exports["BATTLE_ART_VOXEL_FORK"] if not (handle and handle.lib) then - print("[cam] DRAMATIC_SHAPE mod not loaded") + print("[cam] BATTLE_ART_VOXEL_FORK mod not loaded") return love.event.quit() end local V = handle.lib diff --git a/tests/chief_house_shots.lua b/tests/chief_house_shots.lua index 2fec3a8..6ae17a6 100644 --- a/tests/chief_house_shots.lua +++ b/tests/chief_house_shots.lua @@ -12,9 +12,9 @@ return function(game) local ROOT = (os.getenv("SHOT_DIR") or "shots/chief") .. "/" .. (os.getenv("AB_TAG") or "after") - local handle = game.mods.exports["DRAMATIC_SHAPE"] + local handle = game.mods.exports["BATTLE_ART_VOXEL_FORK"] if not (handle and handle.lib) then - print("[chief] DRAMATIC_SHAPE mod not loaded") + print("[chief] BATTLE_ART_VOXEL_FORK mod not loaded") return end local V = handle.lib diff --git a/tests/daynight_shots.lua b/tests/daynight_shots.lua index 08c3f83..4aea345 100644 --- a/tests/daynight_shots.lua +++ b/tests/daynight_shots.lua @@ -13,9 +13,9 @@ return function(game) 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 + local lib = exports and exports.BATTLE_ART_VOXEL_FORK and exports.BATTLE_ART_VOXEL_FORK.lib if not lib then - U.log("DRAMATIC_SHAPE is not loaded -- enable it and run again") + U.log("BATTLE_ART_VOXEL_FORK is not loaded -- enable it and run again") return end local DayNight = lib.require("DayNight") diff --git a/tests/fp_shots.lua b/tests/fp_shots.lua index e972bfe..0c03fd4 100644 --- a/tests/fp_shots.lua +++ b/tests/fp_shots.lua @@ -11,9 +11,9 @@ return function(game) local ROOT = (os.getenv("SHOT_DIR") or "shots/fp") - local handle = game.mods.exports["DRAMATIC_SHAPE"] + local handle = game.mods.exports["BATTLE_ART_VOXEL_FORK"] if not (handle and handle.lib) then - print("[fp] DRAMATIC_SHAPE mod not loaded") + print("[fp] BATTLE_ART_VOXEL_FORK mod not loaded") return love.event.quit() end local V = handle.lib diff --git a/tests/heal_machine_shots.lua b/tests/heal_machine_shots.lua index 7f148cd..712e8db 100644 --- a/tests/heal_machine_shots.lua +++ b/tests/heal_machine_shots.lua @@ -13,9 +13,9 @@ return function(game) local ROOT = (os.getenv("SHOT_DIR") or "shots/healmachine") .. "/" .. (os.getenv("AB_TAG") or "after") - local handle = game.mods.exports["DRAMATIC_SHAPE"] + local handle = game.mods.exports["BATTLE_ART_VOXEL_FORK"] if not (handle and handle.lib) then - print("[heal] DRAMATIC_SHAPE mod not loaded") + print("[heal] BATTLE_ART_VOXEL_FORK mod not loaded") return end local V = handle.lib diff --git a/tests/house_furniture_shots.lua b/tests/house_furniture_shots.lua index 4a3a136..228abd9 100644 --- a/tests/house_furniture_shots.lua +++ b/tests/house_furniture_shots.lua @@ -15,9 +15,9 @@ return function(game) local ROOT = (os.getenv("SHOT_DIR") or "shots/housefurn") .. "/" .. (os.getenv("AB_TAG") or "after") - local handle = game.mods.exports["DRAMATIC_SHAPE"] + local handle = game.mods.exports["BATTLE_ART_VOXEL_FORK"] if not (handle and handle.lib) then - print("[housefurn] DRAMATIC_SHAPE mod not loaded") + print("[housefurn] BATTLE_ART_VOXEL_FORK mod not loaded") return end local V = handle.lib diff --git a/tests/mart_shots.lua b/tests/mart_shots.lua index 428c4dc..22c8b22 100644 --- a/tests/mart_shots.lua +++ b/tests/mart_shots.lua @@ -13,9 +13,9 @@ return function(game) local ROOT = (os.getenv("SHOT_DIR") or "shots/register") .. "/" .. (os.getenv("AB_TAG") or "after") - local handle = game.mods.exports["DRAMATIC_SHAPE"] + local handle = game.mods.exports["BATTLE_ART_VOXEL_FORK"] if not (handle and handle.lib) then - print("[mart] DRAMATIC_SHAPE mod not loaded") + print("[mart] BATTLE_ART_VOXEL_FORK mod not loaded") return end local V = handle.lib diff --git a/tests/monline_probe.lua b/tests/monline_probe.lua index a642202..7b52503 100644 --- a/tests/monline_probe.lua +++ b/tests/monline_probe.lua @@ -16,8 +16,8 @@ return function(game) 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 - U.log("DRAMATIC_SHAPE lib:", tostring(lib)) + local lib = exports and exports.BATTLE_ART_VOXEL_FORK and exports.BATTLE_ART_VOXEL_FORK.lib + U.log("BATTLE_ART_VOXEL_FORK lib:", tostring(lib)) local Battles = lib and lib.require("OverworldBattle") U.log("OverworldBattle:", tostring(Battles)) diff --git a/tests/potted_plant_shots.lua b/tests/potted_plant_shots.lua index 533bdba..2cfbd1b 100644 --- a/tests/potted_plant_shots.lua +++ b/tests/potted_plant_shots.lua @@ -13,9 +13,9 @@ return function(game) local ROOT = (os.getenv("SHOT_DIR") or "shots/plants") .. "/" .. (os.getenv("AB_TAG") or "after") - local handle = game.mods.exports["DRAMATIC_SHAPE"] + local handle = game.mods.exports["BATTLE_ART_VOXEL_FORK"] if not (handle and handle.lib) then - print("[plant] DRAMATIC_SHAPE mod not loaded") + print("[plant] BATTLE_ART_VOXEL_FORK mod not loaded") return end local V = handle.lib diff --git a/tests/roof_curve_shots.lua b/tests/roof_curve_shots.lua index 71c06b0..78feefa 100644 --- a/tests/roof_curve_shots.lua +++ b/tests/roof_curve_shots.lua @@ -11,9 +11,9 @@ return function(game) local ROOT = (os.getenv("SHOT_DIR") or "shots/roofcurve") .. "/" .. (os.getenv("AB_TAG") or "after") - local handle = game.mods.exports["DRAMATIC_SHAPE"] + local handle = game.mods.exports["BATTLE_ART_VOXEL_FORK"] if not (handle and handle.lib) then - print("[roof] DRAMATIC_SHAPE mod not loaded") + print("[roof] BATTLE_ART_VOXEL_FORK mod not loaded") return end local V = handle.lib diff --git a/tests/roof_span_probe.lua b/tests/roof_span_probe.lua index 99e9563..f9e6a29 100644 --- a/tests/roof_span_probe.lua +++ b/tests/roof_span_probe.lua @@ -12,7 +12,7 @@ return function(game) tonumber(os.getenv("BUILD_Y") or "20"), "up") U.wait(30) - local V = game.mods.exports["DRAMATIC_SHAPE"] + local V = game.mods.exports["BATTLE_ART_VOXEL_FORK"] V = V and V.lib local Structures = V and V.require("Structures") local ow = game.overworld diff --git a/tests/round_regress_shots.lua b/tests/round_regress_shots.lua index 246b302..911938b 100644 --- a/tests/round_regress_shots.lua +++ b/tests/round_regress_shots.lua @@ -12,7 +12,7 @@ return function(game) local ROOT = (os.getenv("SHOT_DIR") or "shots/round") .. "/" .. (os.getenv("AB_TAG") or "after") - local handle = game.mods.exports["DRAMATIC_SHAPE"] + local handle = game.mods.exports["BATTLE_ART_VOXEL_FORK"] if not (handle and handle.lib) then return end local V = handle.lib local DayNight = V.require("DayNight") diff --git a/tests/route1_candidates.lua b/tests/route1_candidates.lua index 2b3ebc2..e716cd8 100644 --- a/tests/route1_candidates.lua +++ b/tests/route1_candidates.lua @@ -25,9 +25,9 @@ return function(game) 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 + local lib = exports and exports.BATTLE_ART_VOXEL_FORK and exports.BATTLE_ART_VOXEL_FORK.lib if not lib then - U.log("DRAMATIC_SHAPE is not loaded -- enable it and run again") + U.log("BATTLE_ART_VOXEL_FORK is not loaded -- enable it and run again") return end local Arena = lib.require("BattleArena") diff --git a/tests/shelf_relief_shots.lua b/tests/shelf_relief_shots.lua index 633fe37..4ed2c84 100644 --- a/tests/shelf_relief_shots.lua +++ b/tests/shelf_relief_shots.lua @@ -12,9 +12,9 @@ return function(game) local ROOT = (os.getenv("SHOT_DIR") or "shots/shelves") .. "/" .. (os.getenv("AB_TAG") or "before") - local handle = game.mods.exports["DRAMATIC_SHAPE"] + local handle = game.mods.exports["BATTLE_ART_VOXEL_FORK"] if not (handle and handle.lib) then - print("[shelf] DRAMATIC_SHAPE mod not loaded") + print("[shelf] BATTLE_ART_VOXEL_FORK mod not loaded") return end local V = handle.lib diff --git a/tests/ship_can_shots.lua b/tests/ship_can_shots.lua index 6eb535f..bd2c3d9 100644 --- a/tests/ship_can_shots.lua +++ b/tests/ship_can_shots.lua @@ -12,9 +12,9 @@ return function(game) local ROOT = (os.getenv("SHOT_DIR") or "shots/ssanne") .. "/" .. (os.getenv("AB_TAG") or "after") - local handle = game.mods.exports["DRAMATIC_SHAPE"] + local handle = game.mods.exports["BATTLE_ART_VOXEL_FORK"] if not (handle and handle.lib) then - print("[ship] DRAMATIC_SHAPE mod not loaded") + print("[ship] BATTLE_ART_VOXEL_FORK mod not loaded") return end local V = handle.lib diff --git a/tests/sky_ramp_probe.lua b/tests/sky_ramp_probe.lua index 5dea5b3..e0cd9f6 100644 --- a/tests/sky_ramp_probe.lua +++ b/tests/sky_ramp_probe.lua @@ -14,9 +14,9 @@ return function(game) local DIR = os.getenv("SHOT_DIR") or ".scratchpad/skyramp" local exports = game.mods and game.mods.exports - local lib = exports and exports.DRAMATIC_SHAPE and exports.DRAMATIC_SHAPE.lib + local lib = exports and exports.BATTLE_ART_VOXEL_FORK and exports.BATTLE_ART_VOXEL_FORK.lib if not lib then - U.log("DRAMATIC_SHAPE is not loaded -- enable it and run again") + U.log("BATTLE_ART_VOXEL_FORK is not loaded -- enable it and run again") return end local Sky = lib.require("Sky") diff --git a/tests/tp_shots.lua b/tests/tp_shots.lua index 57e45c7..251901e 100644 --- a/tests/tp_shots.lua +++ b/tests/tp_shots.lua @@ -11,9 +11,9 @@ return function(game) local ROOT = (os.getenv("SHOT_DIR") or "shots/tp") - local handle = game.mods.exports["DRAMATIC_SHAPE"] + local handle = game.mods.exports["BATTLE_ART_VOXEL_FORK"] if not (handle and handle.lib) then - print("[tp] DRAMATIC_SHAPE mod not loaded") + print("[tp] BATTLE_ART_VOXEL_FORK mod not loaded") return love.event.quit() end local V = handle.lib diff --git a/tests/trash_can_shots.lua b/tests/trash_can_shots.lua index 408165e..bc5e8a9 100644 --- a/tests/trash_can_shots.lua +++ b/tests/trash_can_shots.lua @@ -13,9 +13,9 @@ return function(game) local ROOT = (os.getenv("SHOT_DIR") or "shots/cans") .. "/" .. (os.getenv("AB_TAG") or "before") - local handle = game.mods.exports["DRAMATIC_SHAPE"] + local handle = game.mods.exports["BATTLE_ART_VOXEL_FORK"] if not (handle and handle.lib) then - print("[can] DRAMATIC_SHAPE mod not loaded") + print("[can] BATTLE_ART_VOXEL_FORK mod not loaded") return end local V = handle.lib diff --git a/tests/voxel_acne_probe.lua b/tests/voxel_acne_probe.lua index 31ae673..7088cc2 100644 --- a/tests/voxel_acne_probe.lua +++ b/tests/voxel_acne_probe.lua @@ -36,8 +36,8 @@ return function(game) :match("^%s*(%d+)%s*,%s*(%d+)%s*,?%s*(%a*)") facing = (facing ~= "" and facing) or "up" - local handle = game.mods.exports["DRAMATIC_SHAPE"] - local V = assert(handle and handle.lib, "DRAMATIC_SHAPE exports missing") + local handle = game.mods.exports["BATTLE_ART_VOXEL_FORK"] + local V = assert(handle and handle.lib, "BATTLE_ART_VOXEL_FORK exports missing") local ShadowMap = V.require("ShadowMap") local Voxel3D = V.require("Voxel3D") local Mat4 = V.require("Mat4") diff --git a/tests/voxel_anim_probe.lua b/tests/voxel_anim_probe.lua index 3ff4475..4f88026 100644 --- a/tests/voxel_anim_probe.lua +++ b/tests/voxel_anim_probe.lua @@ -6,7 +6,7 @@ -- and flower tile $03 cycles three frames, so the burst should NOT be -- eight copies of the same picture. -- --- POKEPORT_DRIVER=mods/DRAMATIC_SHAPE/tests/voxel_anim_probe.lua lovec . +-- POKEPORT_DRIVER=mods/BATTLE_ART_VOXEL_FORK/tests/voxel_anim_probe.lua lovec . -- -- knobs (env): -- ANIM_MAP map id (default PALLET_TOWN) @@ -53,7 +53,7 @@ return function(game) -- the atlas the terrain mesh actually samples, dumped per step: if these -- differ but the frames do not, the animated tiles are not reaching the -- geometry (baked into prop prisms, say) rather than not animating - local V = game.mods.exports["DRAMATIC_SHAPE"] + local V = game.mods.exports["BATTLE_ART_VOXEL_FORK"] V = V and V.lib local TerrainAtlas = V and V.require("TerrainAtlas") local PaletteFX = require("src.render.PaletteFX") diff --git a/tests/voxel_bench.lua b/tests/voxel_bench.lua index 96fbd33..410d574 100644 --- a/tests/voxel_bench.lua +++ b/tests/voxel_bench.lua @@ -45,9 +45,9 @@ return function(game) -- area" the benchmark is named for never gets entered. local HOLD = math.floor(tonumber(os.getenv("BENCH_HOLD")) or 1300) - local handle = game.mods.exports["DRAMATIC_SHAPE"] + local handle = game.mods.exports["BATTLE_ART_VOXEL_FORK"] if not (handle and handle.lib) then - print("[bench] DRAMATIC_SHAPE mod not loaded -- nothing to measure") + print("[bench] BATTLE_ART_VOXEL_FORK mod not loaded -- nothing to measure") return end local V = handle.lib diff --git a/tests/voxel_building_probe.lua b/tests/voxel_building_probe.lua index 87d77a7..82869f8 100644 --- a/tests/voxel_building_probe.lua +++ b/tests/voxel_building_probe.lua @@ -22,7 +22,7 @@ -- roof is on ROUTE_10, its facade on LAVENDER_TOWN), in which case no -- single map's grid holds the whole thing and each half meshes alone. -- --- BUILD_MAP=LAVENDER_TOWN POKEPORT_DRIVER=mods/DRAMATIC_SHAPE/tests/voxel_building_probe.lua lovec . +-- BUILD_MAP=LAVENDER_TOWN POKEPORT_DRIVER=mods/BATTLE_ART_VOXEL_FORK/tests/voxel_building_probe.lua lovec . return function(game) local U = dofile("tests/drivers/util.lua") local mapId = os.getenv("BUILD_MAP") or "LAVENDER_TOWN" @@ -30,7 +30,7 @@ return function(game) tonumber(os.getenv("BUILD_Y") or "5"), "up") U.wait(10) - local V = game.mods.exports["DRAMATIC_SHAPE"] + local V = game.mods.exports["BATTLE_ART_VOXEL_FORK"] V = V and V.lib local Structures = V and V.require("Structures") local Buildings = V and V.require("Buildings") diff --git a/tests/voxel_figure_test.lua b/tests/voxel_figure_test.lua index 66e193e..423ddbe 100644 --- a/tests/voxel_figure_test.lua +++ b/tests/voxel_figure_test.lua @@ -408,4 +408,4 @@ end T.check(TileShape.propBg("CAVERN") == nil, "a tileset that names none answers nil") -T.finish("DRAMATIC_SHAPE figures") +T.finish("BATTLE_ART_VOXEL_FORK figures") diff --git a/tests/voxel_mem_probe.lua b/tests/voxel_mem_probe.lua index a6d8a21..a30e29e 100644 --- a/tests/voxel_mem_probe.lua +++ b/tests/voxel_mem_probe.lua @@ -7,9 +7,9 @@ return function(game) local U = dofile("tests/drivers/util.lua") local clock = (love.timer and love.timer.getTime) or os.clock - local handle = game.mods.exports["DRAMATIC_SHAPE"] + local handle = game.mods.exports["BATTLE_ART_VOXEL_FORK"] if not (handle and handle.lib) then - print("[mem] DRAMATIC_SHAPE mod not loaded") + print("[mem] BATTLE_ART_VOXEL_FORK mod not loaded") return end local V = handle.lib diff --git a/tests/voxel_mound_probe.lua b/tests/voxel_mound_probe.lua index 11ad540..ea6abb2 100644 --- a/tests/voxel_mound_probe.lua +++ b/tests/voxel_mound_probe.lua @@ -5,7 +5,7 @@ -- for diagnosing cliff/mound columns that extrude to the wrong height -- (Diglett's Cave entrance ridge, bug of 2026-07-27). Then a shot. -- --- POKEPORT_DRIVER=mods/DRAMATIC_SHAPE/tests/voxel_mound_probe.lua lovec . +-- POKEPORT_DRIVER=mods/BATTLE_ART_VOXEL_FORK/tests/voxel_mound_probe.lua lovec . -- -- knobs (env): -- MOUND_MAP map id (default ROUTE_2) @@ -34,7 +34,7 @@ return function(game) math.floor(tonumber(os.getenv("MOUND_LEVEL")) or 3)) U.wait(30) - local V = game.mods.exports["DRAMATIC_SHAPE"] + local V = game.mods.exports["BATTLE_ART_VOXEL_FORK"] V = V and V.lib local Structures = V and V.require("Structures") local ow = game.overworld diff --git a/tests/voxel_perf_probe.lua b/tests/voxel_perf_probe.lua index 49e2677..e161720 100644 --- a/tests/voxel_perf_probe.lua +++ b/tests/voxel_perf_probe.lua @@ -17,9 +17,9 @@ return function(game) U.teleport(game, "ROUTE_1", 10, 30, "down") U.wait(10) - local handle = game.mods.exports["DRAMATIC_SHAPE"] + local handle = game.mods.exports["BATTLE_ART_VOXEL_FORK"] if not (handle and handle.lib) then - print("[perf] DRAMATIC_SHAPE mod not loaded") + print("[perf] BATTLE_ART_VOXEL_FORK mod not loaded") return end local V = handle.lib diff --git a/tests/voxel_pipeline_probe.lua b/tests/voxel_pipeline_probe.lua index eb0c442..44d69dc 100644 --- a/tests/voxel_pipeline_probe.lua +++ b/tests/voxel_pipeline_probe.lua @@ -1,7 +1,7 @@ -- Driver: prove the render-pipeline seam end to end. -- -- Teleports to Pallet Town, screenshots the flat world, engages the --- DRAMATIC_SHAPE mod's pipeline exactly the way the player does (hotkey 6), +-- BATTLE_ART_VOXEL_FORK mod's pipeline exactly the way the player does (hotkey 6), -- screenshots the diorama, then walks the T-SHIFT ladder with hotkey 9. -- Every gate on the path is printed, so a run that comes back flat says -- which check refused rather than just looking wrong. diff --git a/tests/voxel_shadow_probe.lua b/tests/voxel_shadow_probe.lua index 5cf9178..7ec4e46 100644 --- a/tests/voxel_shadow_probe.lua +++ b/tests/voxel_shadow_probe.lua @@ -6,7 +6,7 @@ -- every camera pitch so the cast shadows can be eyeballed against the flat -- ground truth. -- --- POKEPORT_DRIVER=mods/DRAMATIC_SHAPE/tests/voxel_shadow_probe.lua lovec . +-- POKEPORT_DRIVER=mods/BATTLE_ART_VOXEL_FORK/tests/voxel_shadow_probe.lua lovec . -- -- knobs (env): -- SHADOW_MAP map id (default PALLET_TOWN) @@ -27,9 +27,9 @@ return function(game) facing = (facing ~= "" and facing) or "down" -- reach the mod's lib namespace the way a companion mod would - local handle = game.mods.exports["DRAMATIC_SHAPE"] + local handle = game.mods.exports["BATTLE_ART_VOXEL_FORK"] local V = handle and handle.lib - assert(V, "DRAMATIC_SHAPE exports not reachable") + assert(V, "BATTLE_ART_VOXEL_FORK exports not reachable") local ShadowMap = V.require("ShadowMap") local Voxel3D = V.require("Voxel3D") local VoxelGrid = V.require("VoxelGrid") diff --git a/tests/voxel_shots_ab.lua b/tests/voxel_shots_ab.lua index b166c40..af23974 100644 --- a/tests/voxel_shots_ab.lua +++ b/tests/voxel_shots_ab.lua @@ -35,9 +35,9 @@ return function(game) local ROOT = (os.getenv("SHOT_DIR") or "shots/ab") .. "/" .. (os.getenv("AB_TAG") or "before") - local handle = game.mods.exports["DRAMATIC_SHAPE"] + local handle = game.mods.exports["BATTLE_ART_VOXEL_FORK"] if not (handle and handle.lib) then - print("[ab] DRAMATIC_SHAPE mod not loaded -- nothing to compare") + print("[ab] BATTLE_ART_VOXEL_FORK mod not loaded -- nothing to compare") return end local V = handle.lib diff --git a/tests/voxel_survey.lua b/tests/voxel_survey.lua index 9f9f405..c37e577 100644 --- a/tests/voxel_survey.lua +++ b/tests/voxel_survey.lua @@ -6,7 +6,7 @@ -- find sprites whose 3D shape does not match what the object is -- a bed -- extruded into a wall, a flat stool, stairs drawn as a box. -- --- POKEPORT_DRIVER=mods/DRAMATIC_SHAPE/tests/voxel_survey.lua love . +-- POKEPORT_DRIVER=mods/BATTLE_ART_VOXEL_FORK/tests/voxel_survey.lua love . -- -- knobs (env): -- SURVEY_MAP map id (default REDS_HOUSE_2F) diff --git a/tests/voxel_void_probe.lua b/tests/voxel_void_probe.lua index 554a69c..7d16851 100644 --- a/tests/voxel_void_probe.lua +++ b/tests/voxel_void_probe.lua @@ -6,7 +6,7 @@ -- This walks the three modes on an outdoor map, reports whether the mesh -- built and whether the scene actually took the 3D path, and shoots each. -- --- POKEPORT_DRIVER=mods/DRAMATIC_SHAPE/tests/voxel_void_probe.lua lovec . +-- POKEPORT_DRIVER=mods/BATTLE_ART_VOXEL_FORK/tests/voxel_void_probe.lua lovec . -- -- knobs (env): -- VOID_MAP map id (default PALLET_TOWN) @@ -29,9 +29,9 @@ return function(game) :match("^%s*(%d+)%s*,%s*(%d+)%s*,?%s*(%a*)") facing = (facing ~= "" and facing) or "down" - local V = game.mods.exports["DRAMATIC_SHAPE"] + local V = game.mods.exports["BATTLE_ART_VOXEL_FORK"] V = V and V.lib - assert(V, "DRAMATIC_SHAPE exports not reachable") + assert(V, "BATTLE_ART_VOXEL_FORK exports not reachable") local Voxel = V.require("VoxelState") local ChunkMesher = V.require("ChunkMesher") diff --git a/tests/voxel_water_probe.lua b/tests/voxel_water_probe.lua index a3d9298..7e4f8e2 100644 --- a/tests/voxel_water_probe.lua +++ b/tests/voxel_water_probe.lua @@ -6,7 +6,7 @@ -- This walks the chain in the LIVE game and prints where it stops, for the -- palette mode currently active. -- --- POKEPORT_DRIVER=mods/DRAMATIC_SHAPE/tests/voxel_water_probe.lua lovec . +-- POKEPORT_DRIVER=mods/BATTLE_ART_VOXEL_FORK/tests/voxel_water_probe.lua lovec . -- -- knobs (env): -- WATER_MAP map id (default PALLET_TOWN) @@ -34,7 +34,7 @@ return function(game) Pipelines.setLevel("voxel", level) U.wait(30) -- outlast the camera tween - local V = game.mods.exports["DRAMATIC_SHAPE"] + local V = game.mods.exports["BATTLE_ART_VOXEL_FORK"] V = V and V.lib if not V then return say("mod exports unreachable -- is it enabled?") end local TerrainAtlas = V.require("TerrainAtlas") diff --git a/tests/water_curve_shots.lua b/tests/water_curve_shots.lua index 26d8f1f..a051910 100644 --- a/tests/water_curve_shots.lua +++ b/tests/water_curve_shots.lua @@ -10,9 +10,9 @@ return function(game) local ROOT = (os.getenv("SHOT_DIR") or "shots/watercurve") .. "/" .. (os.getenv("AB_TAG") or "after") - local handle = game.mods.exports["DRAMATIC_SHAPE"] + local handle = game.mods.exports["BATTLE_ART_VOXEL_FORK"] if not (handle and handle.lib) then - print("[water] DRAMATIC_SHAPE mod not loaded") + print("[water] BATTLE_ART_VOXEL_FORK mod not loaded") return end local V = handle.lib diff --git a/tests/water_reflect_probe.lua b/tests/water_reflect_probe.lua index 4dcf36a..b8827b2 100644 --- a/tests/water_reflect_probe.lua +++ b/tests/water_reflect_probe.lua @@ -4,7 +4,7 @@ -- back to flat water, which looks exactly like the row being off. This walks -- the chain in the LIVE game and prints where it stops. -- --- POKEPORT_DRIVER=mods/DRAMATIC_SHAPE/tests/water_reflect_probe.lua lovec . +-- POKEPORT_DRIVER=mods/BATTLE_ART_VOXEL_FORK/tests/water_reflect_probe.lua lovec . -- -- knobs (env): -- REFL_MAP map id (default PALLET_TOWN) @@ -30,7 +30,7 @@ return function(game) Pipelines.setLevel("voxel", level) U.wait(40) -- outlast the camera tween and the build - local V = game.mods.exports["DRAMATIC_SHAPE"] + local V = game.mods.exports["BATTLE_ART_VOXEL_FORK"] V = V and V.lib if not V then return say("mod exports unreachable -- is it enabled?") end diff --git a/tools/building_images.py b/tools/building_images.py index de4ed8b..ff9b2cf 100644 --- a/tools/building_images.py +++ b/tools/building_images.py @@ -14,7 +14,7 @@ - B##_atlas.png a strip of the building's distinct tiles, first-appearance order (matches the doc's legend lettering) - python mods/DRAMATIC_SHAPE/tools/building_images.py [outdir] + python mods/BATTLE_ART_VOXEL_FORK/tools/building_images.py [outdir] outdir defaults to .scratchpad/img at the repo root. """ diff --git a/tools/building_voxels.py b/tools/building_voxels.py index c8718c0..b283a22 100644 --- a/tools/building_voxels.py +++ b/tools/building_voxels.py @@ -7,7 +7,7 @@ voxel model with the same rules lib/Buildings.lua ships, asserts the geometric intent, and renders isometric previews. - python mods/DRAMATIC_SHAPE/tools/building_voxels.py [outdir] + python mods/BATTLE_ART_VOXEL_FORK/tools/building_voxels.py [outdir] Keep the TEMPLATES table below in sync with the `buildings` section of data/voxel_heights.lua: the two implementations are meant to be diff --git a/tools/voxel-survey.md b/tools/voxel-survey.md index 9709873..b428b24 100644 --- a/tools/voxel-survey.md +++ b/tools/voxel-survey.md @@ -1,7 +1,7 @@ # Voxel accuracy survey A repeatable procedure -- runnable by an agent or a human -- for finding -map objects the DRAMATIC_SHAPE mod voxelizes into the wrong shape (a bed +map objects the BATTLE_ART_VOXEL_FORK mod voxelizes into the wrong shape (a bed extruded to wall height, a table merged into the wall, stairs lying flat) and for fixing them so each object gets the 3D shape it depicts. @@ -9,14 +9,14 @@ The loop is: **survey → diagnose → pin → re-survey → spot-check**. ## 1. Survey a location -`mods/DRAMATIC_SHAPE/tests/voxel_survey.lua` teleports to a map, walks a +`mods/BATTLE_ART_VOXEL_FORK/tests/voxel_survey.lua` teleports to a map, walks a list of stand points and screenshots each one flat (the 2D ground truth) and at every voxel camera pitch, with tilt-shift forced off (it would blur exactly the edges under inspection). Levels are set through `Pipelines.setLevel`, so the run never writes the player's options. ```powershell -$env:POKEPORT_DRIVER = "mods/DRAMATIC_SHAPE/tests/voxel_survey.lua" +$env:POKEPORT_DRIVER = "mods/BATTLE_ART_VOXEL_FORK/tests/voxel_survey.lua" $env:SHOT_DIR = "" $env:SURVEY_MAP = "REDS_HOUSE_2F" $env:SURVEY_SPOTS = "3,4,up@centre; 2,6,left@bed; 5,2,right@stairs" @@ -67,7 +67,7 @@ big.save("/atlas_ids.png") ## 3. Pin shapes in the profile -`mods/DRAMATIC_SHAPE/data/voxel_heights.lua` maps tile ids to classes per +`mods/BATTLE_ART_VOXEL_FORK/data/voxel_heights.lua` maps tile ids to classes per tileset id; a pinned tile bypasses detection entirely. The classes: - `bed` (h7, art on top) -- anything drawn from above that lies low. @@ -121,16 +121,16 @@ A whole building is not a tile pin. Its drawing packs several 3D facings at once -- roof from above, facade face-on, ends as diagonal silhouettes -- and no single class covers that, so buildings go in the profile's `buildings` list instead, as a BAND TABLE over the drawing's rows -(`mods/DRAMATIC_SHAPE/lib/Buildings.lua`; the pipeline is -`mods/DRAMATIC_SHAPE/assets/docs/buidling_to_voxel/sprite_to_voxel_methodology.md`). +(`mods/BATTLE_ART_VOXEL_FORK/lib/Buildings.lua`; the pipeline is +`mods/BATTLE_ART_VOXEL_FORK/assets/docs/buidling_to_voxel/sprite_to_voxel_methodology.md`). A template is matched by its exact tile grid, which -`mods/DRAMATIC_SHAPE/assets/docs/buildings/` catalogues per building along +`mods/BATTLE_ART_VOXEL_FORK/assets/docs/buildings/` catalogues per building along with every map that places it, so one entry covers all of them. Author only what needs a human to read the drawing -- which rows are roof, how the roof's depth maps onto them, the slab, the eave, an awning band -- because the silhouette, the taper rate (the slope), the eave height and every window are measured off the pixels. -Verify a new template against `mods/DRAMATIC_SHAPE/tools/building_voxels.py`, +Verify a new template against `mods/BATTLE_ART_VOXEL_FORK/tools/building_voxels.py`, which builds the same model offline and renders isometric previews: the voxel and shell counts it prints must match the runtime's (the mod's `Buildings.stats()`), and it asserts the intent -- symmetric profile, @@ -156,7 +156,7 @@ change region shapes, so neighbours can shift. - A change to the shared analysis (anything in `lib/Structures.lua` rather than the data file) affects every map of that kind; survey one unrelated busy interior (e.g. `OAKS_LAB`) to prove nothing regressed. -- `luajit mods/DRAMATIC_SHAPE/tests/dramatic_shape_test.lua` for the headless +- `luajit mods/BATTLE_ART_VOXEL_FORK/tests/battle_art_voxel_fork_test.lua` for the headless invariants. ## Gameplay is out of bounds From 5f1462cd2a041cd7b0f18448ec4cf23cee97019d Mon Sep 17 00:00:00 2001 From: absol89 Date: Thu, 6 Aug 2026 05:30:01 +0200 Subject: [PATCH 081/116] fix(init): declare ExpBar local in main.lua (nil global crash) main.lua referenced ExpBar.enabled in the OPTIONS rows but never required ExpBar into a local -- it was relying on a global that did not exist, so init crashed at main.lua:455 ("attempt to index global 'ExpBar' (a nil value)"). The XP-bar port (132a089) added the reference but not the require; the prior stack-overflow masked it. Add ExpBar to the top-level V.require list. V.require caches, so this resolves the existing table and introduces no new cycle (ExpBar no longer requires OverworldBattle at load time). Co-Authored-By: Hermes Agent --- main.lua | 1 + 1 file changed, 1 insertion(+) diff --git a/main.lua b/main.lua index 27213f4..86d7165 100644 --- a/main.lua +++ b/main.lua @@ -87,6 +87,7 @@ local WorldCurve = V.require("WorldCurve") local OverworldBattle = V.require("OverworldBattle") local BattleArt = V.require("BattleArt") local UiBackplates = V.require("UiBackplates") +local ExpBar = V.require("ExpBar") local BattleExit = V.require("BattleExit") local DayNight = V.require("DayNight") local DayTint = V.require("DayTint") From d2bba33ac7ab11fdfd8d40221a14d7b57d8c9bb3 Mon Sep 17 00:00:00 2001 From: absol89 Date: Thu, 6 Aug 2026 05:53:37 +0200 Subject: [PATCH 082/116] fix(ui): anchor EXP bar to HUD band origin; default ON - ExpBar.enabled now defaults to ON (index 2). - The bar was anchored to rects.player.y, which omits the (p[2]-band[2])*hs top-row preservation snapHUDs applies, dropping it (56-48)*hs world pixels below the HUD glyphs -- so it drifted under the arrow and the gap grew with window scale. Anchor to bands.player.y (the band's actual content origin) with the same hs downscale the HUD uses, so it pins just above the input arrow and scales identically to the player HUD. Co-Authored-By: Hermes Agent --- lib/ExpBar.lua | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/lib/ExpBar.lua b/lib/ExpBar.lua index b87e1d5..194e9cf 100644 --- a/lib/ExpBar.lua +++ b/lib/ExpBar.lua @@ -32,7 +32,7 @@ local ExpBar = {} -- ------- enable toggle (OPTIONS row + persisted, like the other backplates) ------- ExpBar.enabled = ModSetting.new("BattleArtXpBar", "BATTLE ART XP BAR", - { "OFF", "ON" }, { "OFF", "ON" }) + { "OFF", "ON" }, { "OFF", "ON" }, 2) -- ------- constants (from the QoL source) ------- @@ -216,11 +216,15 @@ function ExpBar.draw(battle, shot) local rects, bands = OverworldBattle.snapRects(shot) local pr = rects.player local hs = bands.player.scale + -- Anchor to the HUD band's ACTUAL content origin (bands.player.y), which + -- preserves the top row the way snapHUDs draws it -- NOT rects.player, + -- whose y omits the (p[2]-band[2])*hs top-row preservation. Using rects + -- here dropped the bar by (56-48)*hs world pixels, so it drifted below the + -- arrow and the gap grew with window scale. local HUD = OverworldBattle.HUD_RECT.player -- { 72, 56, 88, 40 } - -- EXP_X/EXP_Y are GB-frame coords inside the player HUD block; offset from - -- the HUD's origin by the same hs the HUD used. + local bandY = bands.player.y -- = shot.ly + p[2]*s - (p[2]-band[2])*hs local barX = pr[1] + (EXP_X - HUD[1]) * hs - local barY = pr[2] + (EXP_Y - HUD[2]) * hs + local barY = bandY + (EXP_Y - HUD[2]) * hs local w = px * hs local h = 2 * hs From 11526af75433a28628fb3801a6254910f42228c8 Mon Sep 17 00:00:00 2001 From: absol89 Date: Thu, 6 Aug 2026 06:00:15 +0200 Subject: [PATCH 083/116] fix(battle): light periphery + resume saved camera (no jump) - The battle sun-shadow frustum was fitted exactly to the GB frame, so terrain outside it fell outside the shadow map and read as fully shadowed even at noon -- the daytime periphery went dark. Widen the frustum extent by 1.6x (camera framing is set by cam.fov and is unaffected) so the surrounding world is lit in daytime. - BattleCam.reset() only reset the drift phase, leaving orbit/pitch/zoom to ease in from the solved shot on frame 1 -- a visible jump to the saved position. Seed the live values from their goals at reset so each battle starts where the camera was last left, with no ease-in swing. Co-Authored-By: Hermes Agent --- lib/BattleCam.lua | 8 ++++++++ lib/BattleScene.lua | 7 +++++++ 2 files changed, 15 insertions(+) diff --git a/lib/BattleCam.lua b/lib/BattleCam.lua index 3bd3170..bd58ab1 100644 --- a/lib/BattleCam.lua +++ b/lib/BattleCam.lua @@ -219,6 +219,14 @@ BattleCam.t = 0 -- rig's own shot, which is the one the composition is solved for. function BattleCam.reset() BattleCam.t = 0 + -- Start the live camera where it was last left, not at the solved shot: + -- seeding the live values from the goals means frame 1 already renders at + -- the saved position, so there is no ease-in "jump" from the rig's own + -- composition to wherever the player steered the previous battle. The + -- saved position persists across battles (these are the only fields kept). + BattleCam.orbit = BattleCam.orbitGoal + BattleCam.pitch = BattleCam.pitchGoal + BattleCam.zoom = BattleCam.zoomGoal end -- Back to the solved shot, for anything that wants the composition as diff --git a/lib/BattleScene.lua b/lib/BattleScene.lua index 8ca8255..3e22d20 100644 --- a/lib/BattleScene.lua +++ b/lib/BattleScene.lua @@ -398,6 +398,13 @@ function BattleScene.render(state, arena, textures, token) -- framed by cam.fov, so these only have to describe the ground in shot local vh = BattleCam.frameH(arena) * ph / (BattleScene.GB_H * s) local vw = vh * pw / ph + -- Fit the sun frustum wider than the frame: terrain outside the GB frame + -- would otherwise fall outside the shadow map and read as fully shadowed + -- even at noon (daytime periphery going dark). The camera framing is set + -- by cam.fov above and is unaffected by this extent. + local SUN_MARGIN = 1.6 + vh = vh * SUN_MARGIN + vw = vw * SUN_MARGIN -- the cards need the camera's eye to face it, so the rig has to be live -- before they are built; Voxel3D.eye is set by viewProjection, which From eb5d1fb233d9d5817b0d5c4ed1eab32be7e10dad Mon Sep 17 00:00:00 2001 From: absol89 Date: Thu, 6 Aug 2026 06:05:06 +0200 Subject: [PATCH 084/116] chore(release): bump version to 1.71 --- main.lua | 2 +- manifest.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/main.lua b/main.lua index 86d7165..1204bc4 100644 --- a/main.lua +++ b/main.lua @@ -1014,7 +1014,7 @@ mod.hooks:wrap("world.tod", function(next, tod, ctx) return DayNight.tod() end) -mod.exports.version = "1.70" +mod.exports.version = "1.71" -- exposed so a companion mod can pin its own tiles' shapes or read the -- camera without reaching into this mod's file layout mod.exports.lib = V diff --git a/manifest.json b/manifest.json index a0f19c8..becde5b 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "BATTLE_ART_VOXEL_FORK", "name": "BATTLE ART VOXEL FORK", - "version": "1.70", + "version": "1.71", "api": 2, "entry": "main.lua", "profile": "content", From ab7eba0071d45c4935780546c87649bbce4308fd Mon Sep 17 00:00:00 2001 From: absol89 Date: Thu, 6 Aug 2026 06:21:01 +0200 Subject: [PATCH 085/116] fix(build): name release zips -.zip from manifest ModUpdate.pickZipAsset prefers exactly "-.zip"; the old hardcoded "DramaticShapeVoxelMod-battle-art" name stopped matching once the manifest id was renamed to BATTLE_ART_VOXEL_FORK, so post-rename updates failed to resolve (and v1.68 grabbed the wrong file). Both package scripts now read id + version from manifest.json and name the output accordingly, so the produced asset matches the manager's preferred path and the update chain works again. Co-Authored-By: Hermes Agent --- tools/package_clean_mod.ps1 | 15 ++++++++++++++- tools/package_mod.ps1 | 16 +++++++++++++++- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/tools/package_clean_mod.ps1 b/tools/package_clean_mod.ps1 index 6789f7d..9dc0d7c 100644 --- a/tools/package_clean_mod.ps1 +++ b/tools/package_clean_mod.ps1 @@ -9,9 +9,22 @@ function Relative-Path([string]$FullName) { return $FullName.Substring($repo.Length + 1).Replace('\', '/') } +# Derive the package name from the mod's own manifest (id + version) so the +# produced .zip matches what the in-game mod manager expects for auto-update: +# ModUpdate.pickZipAsset prefers exactly "-.zip". A hardcoded +# "DramaticShapeVoxelMod-battle-art" name stopped matching once the manifest +# id was renamed, which is why post-rename updates failed to resolve. +$manifest = Get-Content -Raw -LiteralPath (Join-Path $repo 'manifest.json') | + ConvertFrom-Json +$modId = $manifest.id +$modVersion = ($manifest.version -replace '^[vV]', '') +if (-not $modId -or -not $modVersion) { + throw "manifest.json is missing id or version" +} + if (-not $Output) { $Output = Join-Path (Split-Path $repo -Parent) ` - "DramaticShapeVoxelMod-battle-art-clean.zip" + ($modId + '-' + $modVersion + '-clean.zip') } $Output = [System.IO.Path]::GetFullPath($Output) diff --git a/tools/package_mod.ps1 b/tools/package_mod.ps1 index b1b0ef7..d1cb1cb 100644 --- a/tools/package_mod.ps1 +++ b/tools/package_mod.ps1 @@ -7,8 +7,22 @@ $repo = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path function Relative-Path([string]$FullName) { return $FullName.Substring($repo.Length + 1).Replace('\', '/') } + +# Derive the package name from the mod's own manifest (id + version) so the +# produced .zip matches what the in-game mod manager expects for auto-update: +# ModUpdate.pickZipAsset prefers exactly "-.zip". A hardcoded +# "DramaticShapeVoxelMod-battle-art" name stopped matching once the manifest +# id was renamed, which is why post-rename updates failed to resolve. +$manifest = Get-Content -Raw -LiteralPath (Join-Path $repo 'manifest.json') | + ConvertFrom-Json +$modId = $manifest.id +$modVersion = ($manifest.version -replace '^[vV]', '') +if (-not $modId -or -not $modVersion) { + throw "manifest.json is missing id or version" +} + if (-not $Output) { - $Output = Join-Path (Split-Path $repo -Parent) "DramaticShapeVoxelMod-battle-art.zip" + $Output = Join-Path (Split-Path $repo -Parent) ($modId + '-' + $modVersion + '.zip') } $Output = [System.IO.Path]::GetFullPath($Output) From b0f527b26ba1d4032237140e35714f547f0dc157 Mon Sep 17 00:00:00 2001 From: absol89 Date: Thu, 6 Aug 2026 06:37:23 +0200 Subject: [PATCH 086/116] fix(ui): lift EXP bar above arrow; revert misdiagnosed darkness fix - EXP_Y 89 -> 84: the bar sat too low (below the input arrow). The bar's GB-frame Y is measured from the HUD band origin (row 48), drawn at bands.player.y, so the anchor is bandY + (EXP_Y - band[2])*hs. 84 puts it just above the arrow. - Revert the SUN_MARGIN sun-frustum widening: the dark periphery the user saw is the engine's BATTLE BG: WORLD setting, not the arena sun frustum. The change targeted the wrong layer and only softened arena shadows. Co-Authored-By: Hermes Agent --- lib/BattleScene.lua | 7 ------- lib/ExpBar.lua | 11 +++++++++-- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/lib/BattleScene.lua b/lib/BattleScene.lua index 3e22d20..8ca8255 100644 --- a/lib/BattleScene.lua +++ b/lib/BattleScene.lua @@ -398,13 +398,6 @@ function BattleScene.render(state, arena, textures, token) -- framed by cam.fov, so these only have to describe the ground in shot local vh = BattleCam.frameH(arena) * ph / (BattleScene.GB_H * s) local vw = vh * pw / ph - -- Fit the sun frustum wider than the frame: terrain outside the GB frame - -- would otherwise fall outside the shadow map and read as fully shadowed - -- even at noon (daytime periphery going dark). The camera framing is set - -- by cam.fov above and is unaffected by this extent. - local SUN_MARGIN = 1.6 - vh = vh * SUN_MARGIN - vw = vw * SUN_MARGIN -- the cards need the camera's eye to face it, so the rig has to be live -- before they are built; Voxel3D.eye is set by viewProjection, which diff --git a/lib/ExpBar.lua b/lib/ExpBar.lua index 194e9cf..fba9efb 100644 --- a/lib/ExpBar.lua +++ b/lib/ExpBar.lua @@ -36,7 +36,7 @@ ExpBar.enabled = ModSetting.new("BattleArtXpBar", "BATTLE ART XP BAR", -- ------- constants (from the QoL source) ------- -local EXP_X, EXP_Y, EXP_WIDTH = 80, 89, 67 +local EXP_X, EXP_Y, EXP_WIDTH = 80, 84, 67 local EXP_LEVEL_HOLD_FRAMES = 30 local EXP_BURST_DIAGONALS = { 0, 1, 2, 4, 5, 7, 8, 9 } local EXP_BLUE = { 50 / 255, 150 / 255, 250 / 255, 1 } @@ -222,9 +222,16 @@ function ExpBar.draw(battle, shot) -- here dropped the bar by (56-48)*hs world pixels, so it drifted below the -- arrow and the gap grew with window scale. local HUD = OverworldBattle.HUD_RECT.player -- { 72, 56, 88, 40 } + local band = OverworldBattle.HUD_BAND.player -- { 0, 48, 160, 48 } + -- Anchor to the HUD band's actual content origin (bands.player.y), the same + -- top row snapHUDs draws the band at. The EXP bar's GB-frame Y (EXP_Y) is + -- measured from the BAND origin (row 48), not the HUD block top (row 56), + -- so offset by (EXP_Y - band[2]) -- NOT (EXP_Y - HUD[2]). Using HUD[2] + -- here put the bar 8*hs world pixels too HIGH; it must sit lower, just + -- above the input arrow. local bandY = bands.player.y -- = shot.ly + p[2]*s - (p[2]-band[2])*hs local barX = pr[1] + (EXP_X - HUD[1]) * hs - local barY = bandY + (EXP_Y - HUD[2]) * hs + local barY = bandY + (EXP_Y - band[2]) * hs local w = px * hs local h = 2 * hs From 2378162b5897639f6919fa1f03f54600bbddce04 Mon Sep 17 00:00:00 2001 From: absol89 Date: Thu, 6 Aug 2026 06:41:57 +0200 Subject: [PATCH 087/116] fix(battle): force BATTLE BG to WHITE (WORLD is invalid for the diorama) The 3D diorama has no "old system" to show through, so BATTLE BG: WORLD composites as broken dark bars -- it is only valid for the engine's flat 2D battle. Force "white" for every user whenever the voxel mode is on: in applyFull (the FULL-preset transition) and every update tick while FULL, so a boot that starts already at FULL is also covered. Persists on change only. Co-Authored-By: Hermes Agent --- main.lua | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/main.lua b/main.lua index 1204bc4..cacefe7 100644 --- a/main.lua +++ b/main.lua @@ -173,6 +173,20 @@ mod.content.render_pipelines:register("voxel", { -- would make the zoom keys and the wheel dead while the mode was on, and -- would fight anyone who changed one deliberately. applyFull(level) + -- BATTLE BG must be WHITE whenever the diorama is on: WORLD is only valid + -- for the engine's flat 2D battle and composites as broken dark bars with + -- the 3D diorama (there is no "old system" to show through). applyFull + -- only runs on the transition into FULL, so enforce it every frame here + -- too -- including a boot that starts already at FULL -- persisting on + -- change only, never every frame. + if Voxel.isFull(level) then + local Game = require("src.core.Game") + local o = Game.save and Game.save.options + if o and o.battleBg ~= "white" then + o.battleBg = "white" + if Game.writeOptions then pcall(Game.writeOptions, Game) end + end + end Voxel.update(dt, level) -- the first-person head, on the same tick: its blend in and out of the -- orbit, the mouse capture lifecycle, and the frame's stick-rate look. @@ -334,6 +348,12 @@ applyFull = function(level) -- is solved against (OverworldBattle.forceOG); FULL has just switched staged -- fights on, so the layout follows them. OverworldBattle.forceOG(Game) + -- BATTLE BG: WORLD leaves the frozen overworld showing through a battle and + -- is only valid for the engine's flat 2D battle; with the 3D diorama there + -- is no "old system" to show through, so WORLD composites as broken dark + -- bars. Force WHITE (the opaque paper field) for every user, since the mode + -- cannot render the world-behind-battle path at all. + opts.battleBg = "white" -- and the sky on the clock on the wall: FULL pins DAYTIME to SYNC. Unlike -- the rest of the preset this one IS held, not just set -- the row is off -- the menu while FULL owns it (the rows hook below), so a value changed From 8dccc0819e821c94edb2abdc0962da6ed1f62a54 Mon Sep 17 00:00:00 2001 From: absol89 Date: Thu, 6 Aug 2026 06:50:54 +0200 Subject: [PATCH 088/116] fix(camera): start 3D battles at the free-roam camera's saved position The battle camera (BattleCam.orbit/pitch/zoom) only moves when steered DURING a battle and never read the free-roam camera, so a battle opened at the solved shot -- the "very zoomed in" framing the player reported after moving the camera in free-roam. seedFromFreeCamera() maps the free camera into the battle camera at begin(): zoom 1:1 (both are "how much world the frame holds"), pitch by angle, orbit by the free look-yaw across the battle's orbit swing. The pivots differ (player vs arena) so it is a best-effort match, not exact. reset() only overrides when the battle camera is still at its solved defaults, so in-battle steering still persists across battles; otherwise it opens where the free camera was left. Live values start at the goals, so there is no ease-in jump on frame 1. Co-Authored-By: Hermes Agent --- lib/BattleCam.lua | 44 +++++++++++++++++++++++++++++++++++++++----- 1 file changed, 39 insertions(+), 5 deletions(-) diff --git a/lib/BattleCam.lua b/lib/BattleCam.lua index bd58ab1..2fb4253 100644 --- a/lib/BattleCam.lua +++ b/lib/BattleCam.lua @@ -217,13 +217,47 @@ BattleCam.t = 0 -- this battle, and having to re-find them every encounter would make them -- not worth setting. They are session state -- a fresh run opens on the -- rig's own shot, which is the one the composition is solved for. +-- Open the battle camera where the FREE-ROAM camera was last left. The free +-- camera orbits the PLAYER and the battle camera orbits the ARENA, so this is +-- a best-effort mapping, not an exact repro: zoom maps 1:1 (both are "how +-- much world the frame holds"), pitch maps by angle, and orbit maps the free +-- look-yaw across the battle's full orbit swing. Reads the free-camera modules +-- lazily (pcall) so there is no load-time require cycle with them. +function BattleCam.seedFromFreeCamera() + local okT, ThirdPerson = pcall(require, "ThirdPerson") + local okF, FirstPerson = pcall(require, "FirstPerson") + if not (okT and okF) then return end + + -- zoom: the free camera's eased zoom, clamped to the battle's own range + local z = ThirdPerson.zoom or 1 + z = math.max(BattleCam.ZOOM_MIN, math.min(BattleCam.ZOOM_MAX, z)) + + -- pitch: free look-pitch (radians, +down) over the battle's pitch range + local p = (FirstPerson.pitch or 0) / BattleCam.PITCH_RANGE + p = math.max(-1, math.min(1, p)) + + -- orbit: free look-yaw (world radians) across the battle's full orbit swing + local o = (FirstPerson.yaw or 0) / (2 * math.pi) + o = math.max(-0.5, math.min(0.5, o)) + + BattleCam.orbit, BattleCam.orbitGoal = o, o + BattleCam.pitch, BattleCam.pitchGoal = p, p + BattleCam.zoom, BattleCam.zoomGoal = z, z +end + function BattleCam.reset() BattleCam.t = 0 - -- Start the live camera where it was last left, not at the solved shot: - -- seeding the live values from the goals means frame 1 already renders at - -- the saved position, so there is no ease-in "jump" from the rig's own - -- composition to wherever the player steered the previous battle. The - -- saved position persists across battles (these are the only fields kept). + -- If the player never steered a BATTLE camera (it is still at the solved + -- defaults), open where the FREE-ROAM camera was left instead -- that is + -- the "saved coordinate" they actually move, and it fixes battles opening + -- at the zoomed-in solved shot. Once they steer a battle, keep the battle + -- camera's own last position across battles (so in-battle steering persists + -- too). Either way the live values start where the goals are, so frame 1 + -- already renders at the saved position -- no ease-in jump. + local atDefault = BattleCam.orbitGoal == 0 + and BattleCam.pitchGoal == 0 + and BattleCam.zoomGoal == 1 + if atDefault then BattleCam.seedFromFreeCamera() end BattleCam.orbit = BattleCam.orbitGoal BattleCam.pitch = BattleCam.pitchGoal BattleCam.zoom = BattleCam.zoomGoal From ec5d221c7629c636f24de157dbb199dfea88b618 Mon Sep 17 00:00:00 2001 From: absol89 Date: Thu, 6 Aug 2026 06:54:43 +0200 Subject: [PATCH 089/116] fix: force BATTLE BG WHITE on every update tick; lift EXP bar 10 rows - BATTLE BG: the earlier force was gated on Voxel.isFull(), so a boot that starts already at FULL never got forced and the first battle rendered with the engine default (WORLD -> broken dark bars). Force "white" at the top of update() unconditionally (update only runs while the mode is active), so boot, the first battle before any transition, and every state are covered. - EXP bar: EXP_Y 84 -> 74 (~10 GB rows up) per playtest -- was sitting too far down, below the input arrow. Co-Authored-By: Hermes Agent --- lib/ExpBar.lua | 2 +- main.lua | 27 ++++++++++++--------------- 2 files changed, 13 insertions(+), 16 deletions(-) diff --git a/lib/ExpBar.lua b/lib/ExpBar.lua index fba9efb..f7509ae 100644 --- a/lib/ExpBar.lua +++ b/lib/ExpBar.lua @@ -36,7 +36,7 @@ ExpBar.enabled = ModSetting.new("BattleArtXpBar", "BATTLE ART XP BAR", -- ------- constants (from the QoL source) ------- -local EXP_X, EXP_Y, EXP_WIDTH = 80, 84, 67 +local EXP_X, EXP_Y, EXP_WIDTH = 80, 74, 67 local EXP_LEVEL_HOLD_FRAMES = 30 local EXP_BURST_DIAGONALS = { 0, 1, 2, 4, 5, 7, 8, 9 } local EXP_BLUE = { 50 / 255, 150 / 255, 250 / 255, 1 } diff --git a/main.lua b/main.lua index cacefe7..3d0a6ef 100644 --- a/main.lua +++ b/main.lua @@ -168,26 +168,23 @@ mod.content.render_pipelines:register("voxel", { -- pump slice -- so stepping out of a door lands on terrain that is -- already there instead of a flat flash. update = function(dt, level) + -- BATTLE BG must be WHITE whenever this mode is on: WORLD is only valid + -- for the engine's flat 2D battle and composites as broken dark bars with + -- the 3D diorama (there is no "old system" to show through). Force it at + -- the top of every update tick -- not gated on FULL -- so a boot that + -- starts already at FULL, and the first battle before any transition, are + -- both covered. Persists on change only, never every frame. + local Game = require("src.core.Game") + local o = Game.save and Game.save.options + if o and o.battleBg ~= "white" then + o.battleBg = "white" + if Game.writeOptions then pcall(Game.writeOptions, Game) end + end -- FULL is a preset, so it is applied ON THE PRESS rather than held every -- frame: it SETS the other rows and then leaves them alone. Holding them -- would make the zoom keys and the wheel dead while the mode was on, and -- would fight anyone who changed one deliberately. applyFull(level) - -- BATTLE BG must be WHITE whenever the diorama is on: WORLD is only valid - -- for the engine's flat 2D battle and composites as broken dark bars with - -- the 3D diorama (there is no "old system" to show through). applyFull - -- only runs on the transition into FULL, so enforce it every frame here - -- too -- including a boot that starts already at FULL -- persisting on - -- change only, never every frame. - if Voxel.isFull(level) then - local Game = require("src.core.Game") - local o = Game.save and Game.save.options - if o and o.battleBg ~= "white" then - o.battleBg = "white" - if Game.writeOptions then pcall(Game.writeOptions, Game) end - end - end - Voxel.update(dt, level) -- the first-person head, on the same tick: its blend in and out of the -- orbit, the mouse capture lifecycle, and the frame's stick-rate look. -- Unconditional like Voxel.update, because the blend has to keep easing From da92838b136921086415b43a11654552099eef79 Mon Sep 17 00:00:00 2001 From: absol89 Date: Thu, 6 Aug 2026 06:58:34 +0200 Subject: [PATCH 090/116] fix(regression): restore Voxel.update in the pipeline update tick The BATTLE BG force edit dropped Voxel.update(dt, level) from update(), which stopped the voxel pipeline from advancing and broke mode switching (the 3 key). Restore it immediately after applyFull. Co-Authored-By: Hermes Agent --- main.lua | 1 + 1 file changed, 1 insertion(+) diff --git a/main.lua b/main.lua index 3d0a6ef..0420d43 100644 --- a/main.lua +++ b/main.lua @@ -185,6 +185,7 @@ mod.content.render_pipelines:register("voxel", { -- would make the zoom keys and the wheel dead while the mode was on, and -- would fight anyone who changed one deliberately. applyFull(level) + Voxel.update(dt, level) -- the first-person head, on the same tick: its blend in and out of the -- orbit, the mouse capture lifecycle, and the frame's stick-rate look. -- Unconditional like Voxel.update, because the blend has to keep easing From f85c8efa4a9c70c1aaa0ef7dff09d362a57faea3 Mon Sep 17 00:00:00 2001 From: absol89 Date: Thu, 6 Aug 2026 07:00:27 +0200 Subject: [PATCH 091/116] revert(ui): remove the EXP bar entirely The ported XP bar (from unxpected-uxp's QoL mod) never tracked correctly: it drew on the wrong HUD side, did not follow the moving textbox, and our positioning hacks kept failing. It is better handled by dedicated mods, so remove it completely -- the lib/ExpBar.lua file, the OverworldBattle require and draw call, and the main.lua require and options row. Co-Authored-By: Hermes Agent --- lib/ExpBar.lua | 248 ---------------------------------------- lib/OverworldBattle.lua | 4 - main.lua | 7 -- 3 files changed, 259 deletions(-) delete mode 100644 lib/ExpBar.lua diff --git a/lib/ExpBar.lua b/lib/ExpBar.lua deleted file mode 100644 index f7509ae..0000000 --- a/lib/ExpBar.lua +++ /dev/null @@ -1,248 +0,0 @@ --- XP / experience bar for the player's active mon, scaled to match the --- battle HUD. --- --- The drawing, animation and colour logic below is adapted from the --- pokemon-gen1-recomp-mod-qol project by unxpected-uxp --- (https://github.com/unxpected-uxp/pokemon-gen1-recomp-mod-qol, --- qol_feature_xp_bar.lua), used with the author's permission. The original --- draws the bar at a fixed GB-frame position (EXP_X, EXP_Y) at scale 1; this --- port recomputes the position/scale from the SAME transform the player HUD --- uses (OverworldBattle.snapRects -> hs = scale - 1), so the bar sits at the --- exact player-HUD location and shrinks to the same "a bit smaller" size as --- the HUD, for any window size or camera composition. It is drawn into the --- world canvas (shot.canvas) during drawHudPanels, so it works on every --- platform including iOS (where snapHUDs is skipped). --- --- the mod namespace (see main.lua): V.require loads a sibling module -local V = ... - -local ModSetting = V.require("ModSetting") --- Resolved lazily inside ExpBar.draw: requiring OverworldBattle here would --- recurse, because OverworldBattle.lua requires this module at its own top --- level (lib/ExpBar.lua), and V.require only caches a module after it has --- finished loading -- an infinite loop at mod init (stack overflow). -local OverworldBattle - --- engine modules (same as the QoL source) -local Growth = require("src.pokemon.Growth") -local PaletteFX = require("src.render.PaletteFX") - -local ExpBar = {} - --- ------- enable toggle (OPTIONS row + persisted, like the other backplates) ------- - -ExpBar.enabled = ModSetting.new("BattleArtXpBar", "BATTLE ART XP BAR", - { "OFF", "ON" }, { "OFF", "ON" }, 2) - --- ------- constants (from the QoL source) ------- - -local EXP_X, EXP_Y, EXP_WIDTH = 80, 74, 67 -local EXP_LEVEL_HOLD_FRAMES = 30 -local EXP_BURST_DIAGONALS = { 0, 1, 2, 4, 5, 7, 8, 9 } -local EXP_BLUE = { 50 / 255, 150 / 255, 250 / 255, 1 } -local EXP_BLACK = { 0, 0, 0, 1 } - --- ------- colour ------- - -local function paletteExpColor(battle) - local colors = battle.zoneColorsAt - and battle:zoneColorsAt(EXP_X, EXP_Y) - if not colors then return EXP_BLACK end - local bgp = battle.activeBgp and battle:activeBgp() - colors = PaletteFX.effectiveColors(PaletteFX.permute(colors, bgp)) - local color = colors and colors[3] - if not color then return EXP_BLACK end - return { color[1] / 255, color[2] / 255, color[3] / 255, 1 } -end - --- ------- progress ------- - -local function expPixels(battle) - local mon = battle.player and battle.player.mon - local def = mon and battle.data.pokemon[mon.species] - if not def then return 0 end - local cap = battle.data.constants and battle.data.constants.levelCap or 100 - if mon.level >= cap then return EXP_WIDTH end - local current = Growth.expForLevel(def.growthRate, mon.level, - battle.data.growth_rates) - local nextLevel = Growth.expForLevel(def.growthRate, mon.level + 1, - battle.data.growth_rates) - local needed = nextLevel - current - if needed <= 0 then return 0 end - local progress = math.max(0, math.min(needed, mon.exp - current)) - return math.floor(progress * EXP_WIDTH / needed) -end - --- per-battle animation state (single active battle at a time; expMon change --- resets it, as in the source) -local expState = {} - -local function animatedExpPixels(battle) - local mon = battle.player and battle.player.mon - local target = expPixels(battle) - local state = expState - if state.expMon ~= mon or state.expPixels == nil then - state.expMon = mon - state.expPixels = target - state.expLevel = mon and mon.level - state.expPhase = nil - state.expLevelCycles = 0 - state.expBurstFrame = nil - state.expFrame = battle.frame - return target - end - if state.expFrame == battle.frame then return state.expPixels end - state.expFrame = battle.frame - - local level = mon and mon.level or state.expLevel - if level and state.expLevel and level > state.expLevel then - state.expLevelCycles = (state.expLevelCycles or 0) + level - state.expLevel - state.expLevel = level - if not state.expPhase then state.expPhase = "fill_level" end - elseif level and level ~= state.expLevel then - state.expLevel = level - end - - if state.expPhase == "fill_level" then - state.expPixels = math.min(EXP_WIDTH, state.expPixels + 1) - if state.expPixels == EXP_WIDTH then - state.expPhase = "hold_level" - state.expHoldFrames = EXP_LEVEL_HOLD_FRAMES - state.expBurstFrame = 0 - end - elseif state.expPhase == "hold_level" then - if state.expBurstFrame then - if state.expBurstFrame < #EXP_BURST_DIAGONALS - 1 then - state.expBurstFrame = state.expBurstFrame + 1 - else - state.expBurstFrame = nil - end - end - if state.expHoldFrames > 0 then - state.expHoldFrames = state.expHoldFrames - 1 - else - state.expLevelCycles = math.max(0, (state.expLevelCycles or 1) - 1) - local cap = battle.data.constants and battle.data.constants.levelCap or 100 - state.expBurstFrame = nil - if state.expLevelCycles > 0 then - state.expPixels = 0 - state.expPhase = "fill_level" - elseif mon and mon.level >= cap then - state.expPhase = nil - state.expPixels = EXP_WIDTH - else - state.expPixels = 0 - state.expPhase = "after_level" - end - end - elseif state.expPhase == "after_level" then - state.expPixels = math.min(target, state.expPixels + 1) - if state.expPixels >= target then state.expPhase = nil end - elseif state.expPixels < target then - state.expPixels = math.min(target, state.expPixels + 1) - elseif state.expPixels > target then - state.expPixels = math.max(target, state.expPixels - 1) - end - return state.expPixels -end - --- ------- burst particles (level-up) ------- - -local EXP_BURST_TILE_ROWS = { - "oooooooo", - "oooxxooo", - "ooxxxxoo", - "oxxxxxxo", - "oxxxxxxo", - "ooxxxxoo", - "oooxxooo", - "oooooooo", -} - -local function drawExpBurst(frame, centerX, centerY, scale, color) - if frame == nil then return end - local g = love.graphics - local radius = frame * 2 * scale - local diagonal = EXP_BURST_DIAGONALS[frame + 1] * scale - - local function particle(dx, dy) - local x = centerX + dx - 4 * scale - local y = centerY + dy - 4 * scale - for py, row in ipairs(EXP_BURST_TILE_ROWS) do - for px = 1, 8 do - if row:sub(px, px) == "x" then - local dotX = x + (px - 1) * scale - local dotY = y + (py - 1) * scale - g.rectangle("fill", dotX, dotY, scale, scale) - end - end - end - end - - g.setShader() - g.setColor(color[1], color[2], color[3], color[4]) - particle(radius, 0) - particle(diagonal, diagonal) - particle(0, radius) - particle(-diagonal, diagonal) - particle(-radius, 0) - particle(-diagonal, -diagonal) - particle(0, -radius) - particle(diagonal, -diagonal) -end - --- ------- draw, scaled + anchored to the player HUD ------- - --- Called from OverworldBattle.drawHudPanels, with shot.canvas bound (the world --- surface). Uses the same snapRects transform as the player HUD so the bar --- lands at the HUD's exact location and the same hs scale. -function ExpBar.draw(battle, shot) - OverworldBattle = OverworldBattle or V.require("OverworldBattle") - if ExpBar.enabled:get() ~= "ON" then return end - if not battle.player or battle.safari or battle.demo - or battle.showPlayerBack then return end - if battle.introSlide and battle.introSlide ~= 0 then return end - if not shot or not shot.scale then return end - - local colorMode = PaletteFX.mode - local blue = colorMode == "ogred" or colorMode == "gbc" - or colorMode == "redpp" - local color = blue and EXP_BLUE or paletteExpColor(battle) - - local px = animatedExpPixels(battle) - if px <= 0 then return end - - -- player HUD world rect + the hs scale it was drawn at - local rects, bands = OverworldBattle.snapRects(shot) - local pr = rects.player - local hs = bands.player.scale - -- Anchor to the HUD band's ACTUAL content origin (bands.player.y), which - -- preserves the top row the way snapHUDs draws it -- NOT rects.player, - -- whose y omits the (p[2]-band[2])*hs top-row preservation. Using rects - -- here dropped the bar by (56-48)*hs world pixels, so it drifted below the - -- arrow and the gap grew with window scale. - local HUD = OverworldBattle.HUD_RECT.player -- { 72, 56, 88, 40 } - local band = OverworldBattle.HUD_BAND.player -- { 0, 48, 160, 48 } - -- Anchor to the HUD band's actual content origin (bands.player.y), the same - -- top row snapHUDs draws the band at. The EXP bar's GB-frame Y (EXP_Y) is - -- measured from the BAND origin (row 48), not the HUD block top (row 56), - -- so offset by (EXP_Y - band[2]) -- NOT (EXP_Y - HUD[2]). Using HUD[2] - -- here put the bar 8*hs world pixels too HIGH; it must sit lower, just - -- above the input arrow. - local bandY = bands.player.y -- = shot.ly + p[2]*s - (p[2]-band[2])*hs - local barX = pr[1] + (EXP_X - HUD[1]) * hs - local barY = bandY + (EXP_Y - band[2]) * hs - local w = px * hs - local h = 2 * hs - - local g = love.graphics - g.setShader() - g.setColor(color[1], color[2], color[3], color[4]) - g.rectangle("fill", barX, barY, w, h) - - -- level-up burst, centred on the full bar - drawExpBurst(expState.expBurstFrame, - barX + EXP_WIDTH * hs / 2, barY + h / 2, hs, color) -end - -return ExpBar diff --git a/lib/OverworldBattle.lua b/lib/OverworldBattle.lua index 00fc435..9f14d60 100644 --- a/lib/OverworldBattle.lua +++ b/lib/OverworldBattle.lua @@ -45,7 +45,6 @@ local BattleScene = V.require("BattleScene") local BattleDOF = V.require("BattleDOF") local BattleHud = V.require("BattleHud") local UiBackplates = V.require("UiBackplates") -local ExpBar = V.require("ExpBar") local BattlePics = V.require("BattlePics") local BattleArt = V.require("BattleArt") local AnimatedBattleArt = V.require("AnimatedBattleArt") @@ -1502,9 +1501,6 @@ function OverworldBattle.drawHudPanels(battle) local shot = battle.dramaticShapeShot battle.dramaticShapeDark = nil if not shot then return end - -- XP bar follows the player HUD's exact position/scale (snapRects transform), - -- drawn into the world canvas so it works on every platform including iOS. - ExpBar.draw(battle, shot) if isIOS() then -- iOS cannot run the snapped HUD path (it draws upside-down + opaque); -- fall back to the engine's own opaque HUD panels. Mirrors upstream PR #48. diff --git a/main.lua b/main.lua index 0420d43..02169a8 100644 --- a/main.lua +++ b/main.lua @@ -87,7 +87,6 @@ local WorldCurve = V.require("WorldCurve") local OverworldBattle = V.require("OverworldBattle") local BattleArt = V.require("BattleArt") local UiBackplates = V.require("UiBackplates") -local ExpBar = V.require("ExpBar") local BattleExit = V.require("BattleExit") local DayNight = V.require("DayNight") local DayTint = V.require("DayTint") @@ -471,12 +470,6 @@ local SETTINGS = { .. "so the words read over any ground, like the upstream default. OFF " .. "leaves the text box as the engine draws it.", full = true }, - { ExpBar.enabled, - "ON draws an experience bar under the player's HP in battle, scaled to " - .. "match the (smaller) HUD and pinned to the player HUD's exact " - .. "position. Drawing/animation adapted from unxpected-uxp's " - .. "pokemon-gen1-recomp-mod-qol, used with permission. OFF hides it.", - full = true }, -- Marked `full` for the opposite reason the battle rows are: this is not a -- knob on the look at all, it is what the look COSTS. FULL is a preset for -- the diorama, not a licence to spend four times the fill rate on the From 0fd16b767a9700e4a4ad2ee9b910ff9606654f7f Mon Sep 17 00:00:00 2001 From: absol89 Date: Thu, 6 Aug 2026 07:04:40 +0200 Subject: [PATCH 092/116] feat(ui): add HUD SCALE option (SCALED / 1X) for external XP-bar mods The EXP bar is owned by other mods, not this one. Those mods draw their bar assuming a native 1X HUD, but this mod scales the player/opponent HUD with the battle zoom (hs = zoom - 1). Add a HUD SCALE setting: SCALED (default) -- hs follows the battle zoom, the mod's look 1X -- pins the HUD to native size (hs = 1) so an external XP-bar mod lines up Implemented by overriding hs in OverworldBattle.snapRects; snapHUDs and the textbox backplates consume that same hs, so the toggle covers the whole HUD. Exposed as a FULL-preset-safe options row. Co-Authored-By: Hermes Agent --- lib/OverworldBattle.lua | 12 ++++++++++++ main.lua | 8 ++++++++ 2 files changed, 20 insertions(+) diff --git a/lib/OverworldBattle.lua b/lib/OverworldBattle.lua index 9f14d60..f094f74 100644 --- a/lib/OverworldBattle.lua +++ b/lib/OverworldBattle.lua @@ -79,6 +79,15 @@ OverworldBattle.setting = ModSetting.new(OverworldBattle.KEY, OverworldBattle.LABEL, { true, false }, { "ON", "OFF" }) +-- HUD SCALE: SCALED draws the player/opponent HUD at the battle's zoom factor +-- (hs = zoom - 1), the look this mod ships with. 1X pins the HUD to native +-- size (hs = 1) regardless of zoom. External mods that draw an XP bar assume +-- a 1X HUD, so players who add one (the XP bar is NOT owned by this mod) +-- switch to 1X to line the bar up; everyone else keeps SCALED. +OverworldBattle.hudScaleSetting = ModSetting.new("hudScale", "HUD SCALE", + { "scaled", "1x" }, + { "SCALED", "1X" }, 1) + function OverworldBattle.enabled() return OverworldBattle.setting:get() and true or false end @@ -362,6 +371,9 @@ OverworldBattle.HUD_BAND = { function OverworldBattle.snapRects(shot) local s = shot.scale local hs = math.max(1, s - 1) + -- HUD SCALE: 1X pins the HUD to native size so an external XP-bar mod (which + -- assumes a 1X HUD) lines up; SCALED is the mod's default (hs follows zoom). + if OverworldBattle.hudScaleSetting:get() == "1x" then hs = 1 end local e, p = OverworldBattle.HUD_RECT.enemy, OverworldBattle.HUD_RECT.player -- Leave two logical pixels between the foe's name and the window edge. -- The whole enemy band moves with the panel, so long names and HUD shakes diff --git a/main.lua b/main.lua index 02169a8..308156c 100644 --- a/main.lua +++ b/main.lua @@ -395,6 +395,14 @@ local SETTINGS = { "Fight on the map: the battle draws over the nearest clear ground, " .. "shot over the shoulder with a slow parallax drift.", full = true }, + -- HUD SCALE lives with the battle rows: SCALED is the mod's default HUD + -- (it grows with the battle zoom); 1X pins it to native size so an external + -- XP-bar mod -- which this mod does NOT provide -- lines up at 1X. + { OverworldBattle.hudScaleSetting, + "Size of the player and opponent HUD. SCALED grows with the battle " + .. "zoom (the mod default); 1X pins it to native size for use with an " + .. "external XP-bar mod, which this mod does not include.", + full = true }, -- Only offered while a fight can actually be staged on the map. { BattleArt.setting, "Use optional PNGs from assets/battle in fights. Missing art falls " From 2b4300779f52b3ec52acd7de7c66ec5c1be6a0e7 Mon Sep 17 00:00:00 2001 From: absol89 Date: Thu, 6 Aug 2026 07:13:02 +0200 Subject: [PATCH 093/116] fix(ui): force CENTERED uiLayout so the textbox backplate tracks the box The dialogue box backplate is drawn into the GB canvas at the static GB-frame rect {0,96,160,48}. The engine's DYNAMIC uiLayout docks the box to the window's bottom edge (window space, full width), so under any non-1:1 aspect the box and the in-canvas backplate diverge -- they only coincide near 1:1. Force CENTERED uiLayout when a battle begins so the box stays inside the 160x144 frame, where the backplate already is, at every window aspect. This is also the classic battle composition. Co-Authored-By: Hermes Agent --- lib/OverworldBattle.lua | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/lib/OverworldBattle.lua b/lib/OverworldBattle.lua index f094f74..75c897d 100644 --- a/lib/OverworldBattle.lua +++ b/lib/OverworldBattle.lua @@ -505,6 +505,17 @@ function OverworldBattle.begin(state, battle) armed = false, token = 0 } cullCast(state) BattleCam.reset() + -- TEXTBOX FILL (the dialogue-box backplate) is drawn into the GB canvas, so + -- it only lines up with the engine's box when the box stays inside the + -- 160x144 frame. DYNAMIC uiLayout docks the box to the window's bottom edge + -- (window space, full width) where an in-canvas backplate cannot reach -- it + -- then aligns only at ~1:1 aspect. Force CENTERED for the battle so the box, + -- and its backplate, stay in-frame at every window aspect. + local g = game() + if g and g.save and g.save.options then + g.save.options.uiLayout = "centered" + if g.writeOptions then pcall(g.writeOptions, g) end + end return true end From 4fa84bbc889ac59adfd4cdc5a891973553a34f9c Mon Sep 17 00:00:00 2001 From: absol89 Date: Thu, 6 Aug 2026 07:18:31 +0200 Subject: [PATCH 094/116] chore(release): bump version to 1.72.0 (three-part semver) The in-game mod manager's ModUpdate.parseRelease requires a strict X.Y.Z tag (version:match("^(%d+%.%d+%.%d+)$")). Two-part tags like 1.71 / v1.71 are silently dropped, so the manager only saw the old three-part releases (1.3.1) and ignored everything recent. Bumping to 1.72.0 makes the release parse, and the ps1/release.yml already name the zip -.zip = BATTLE_ART_VOXEL_FORK-1.72.0.zip, which pickZipAsset prefers. Semver.parse accepts two-part so installed 1.71 users still get the offer. Co-Authored-By: Hermes Agent --- main.lua | 2 +- manifest.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/main.lua b/main.lua index 308156c..2dabe18 100644 --- a/main.lua +++ b/main.lua @@ -1033,7 +1033,7 @@ mod.hooks:wrap("world.tod", function(next, tod, ctx) return DayNight.tod() end) -mod.exports.version = "1.71" +mod.exports.version = "1.72.0" -- exposed so a companion mod can pin its own tiles' shapes or read the -- camera without reaching into this mod's file layout mod.exports.lib = V diff --git a/manifest.json b/manifest.json index becde5b..87f9cc0 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "BATTLE_ART_VOXEL_FORK", "name": "BATTLE ART VOXEL FORK", - "version": "1.71", + "version": "1.72.0", "api": 2, "entry": "main.lua", "profile": "content", From e2b931700281373fa5502daf719da2ba026122c0 Mon Sep 17 00:00:00 2001 From: absol89 Date: Thu, 6 Aug 2026 07:23:55 +0200 Subject: [PATCH 095/116] fix(ui): HUD SCALE WINDOW mode follows the window scale, not 1px The previous 1X mode set hs = 1, which collapsed the HUD to one GB pixel per screen pixel -- tiny on a large/high-DPI window. Rename it to WINDOW and set hs = shot.scale (Renderer:fitScale, the window-fit scale), matching upstream DynamicShapes' player HUD. That is the scale external XP-bar mods adapted to, so they line up. The backplates are untouched -- only the HUD's on-screen size tracks the window. SCALED (default) keeps hs = zoom - 1. Co-Authored-By: Hermes Agent --- lib/OverworldBattle.lua | 21 ++++++++++++--------- main.lua | 5 +++-- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/lib/OverworldBattle.lua b/lib/OverworldBattle.lua index 75c897d..0070736 100644 --- a/lib/OverworldBattle.lua +++ b/lib/OverworldBattle.lua @@ -80,13 +80,14 @@ OverworldBattle.setting = ModSetting.new(OverworldBattle.KEY, { true, false }, { "ON", "OFF" }) -- HUD SCALE: SCALED draws the player/opponent HUD at the battle's zoom factor --- (hs = zoom - 1), the look this mod ships with. 1X pins the HUD to native --- size (hs = 1) regardless of zoom. External mods that draw an XP bar assume --- a 1X HUD, so players who add one (the XP bar is NOT owned by this mod) --- switch to 1X to line the bar up; everyone else keeps SCALED. +-- (hs = zoom - 1), the look this mod ships with. WINDOW pins the HUD to the +-- window-fit scale (hs = fitScale), like upstream DynamicShapes' player HUD -- +-- the HUD grows and shrinks with the window, which is the scale external +-- EXP-bar mods assume (they adapted to the window-scaling default). The +-- backplates are untouched: only the HUD's on-screen size follows the window. OverworldBattle.hudScaleSetting = ModSetting.new("hudScale", "HUD SCALE", - { "scaled", "1x" }, - { "SCALED", "1X" }, 1) + { "scaled", "window" }, + { "SCALED", "WINDOW" }, 1) function OverworldBattle.enabled() return OverworldBattle.setting:get() and true or false @@ -371,9 +372,11 @@ OverworldBattle.HUD_BAND = { function OverworldBattle.snapRects(shot) local s = shot.scale local hs = math.max(1, s - 1) - -- HUD SCALE: 1X pins the HUD to native size so an external XP-bar mod (which - -- assumes a 1X HUD) lines up; SCALED is the mod's default (hs follows zoom). - if OverworldBattle.hudScaleSetting:get() == "1x" then hs = 1 end + -- HUD SCALE: WINDOW pins the HUD to the window-fit scale (hs = fitScale), + -- like upstream DynamicShapes' player HUD, so an external XP-bar mod (which + -- assumes a window-scaling HUD) lines up; SCALED is the mod's default (hs + -- follows the battle zoom). + if OverworldBattle.hudScaleSetting:get() == "window" then hs = s end local e, p = OverworldBattle.HUD_RECT.enemy, OverworldBattle.HUD_RECT.player -- Leave two logical pixels between the foe's name and the window edge. -- The whole enemy band moves with the panel, so long names and HUD shakes diff --git a/main.lua b/main.lua index 2dabe18..3d1bb62 100644 --- a/main.lua +++ b/main.lua @@ -400,8 +400,9 @@ local SETTINGS = { -- XP-bar mod -- which this mod does NOT provide -- lines up at 1X. { OverworldBattle.hudScaleSetting, "Size of the player and opponent HUD. SCALED grows with the battle " - .. "zoom (the mod default); 1X pins it to native size for use with an " - .. "external XP-bar mod, which this mod does not include.", + .. "zoom (the mod default); WINDOW pins it to the window-fit scale like " + .. "upstream DynamicShapes' player HUD, so an external XP-bar mod -- which " + .. "this mod does not include -- lines up with a window-scaling HUD.", full = true }, -- Only offered while a fight can actually be staged on the map. { BattleArt.setting, From 499f5a7c094c9ddab37bad72cecdbc038b550fcf Mon Sep 17 00:00:00 2001 From: absol89 Date: Thu, 6 Aug 2026 07:38:51 +0200 Subject: [PATCH 096/116] fix(ui): use engine box as the textbox backplate; align player HUD origin - TEXTBOX FILL: drop our own GB-canvas slab. The engine draws the dialogue box (Font.drawBox) in SCREEN space, docked to the window edge, so it already tracks the box at every aspect ratio. Our static-rect slab in the GB canvas could never follow a window-docked box (they only coincided at ~1:1), which is why it drifted. The engine's white box is now the backplate. - Player HUD: remove the +2 breathing-room margin on the player rect. It shifted the whole HUD left by 2*hs vs the engine's player status box, so an external EXP-bar mod (which anchors to that box) sat 2px to the right. Our player HUD now shares the engine's status-box origin. Co-Authored-By: Hermes Agent --- lib/OverworldBattle.lua | 23 +++++++++++------------ manifest.json | 2 +- 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/lib/OverworldBattle.lua b/lib/OverworldBattle.lua index 0070736..8e8cd3e 100644 --- a/lib/OverworldBattle.lua +++ b/lib/OverworldBattle.lua @@ -383,7 +383,11 @@ function OverworldBattle.snapRects(shot) -- keep their original internal alignment instead of being clipped. local ex = (2 - e[1]) * hs -- Keep two logical pixels of breathing room at the right window edge. - local px = shot.pw - (p[1] + p[3] + 2) * hs + -- NOTE: this +2 previously shifted the whole player HUD left by 2*hs, which + -- desynced it from the engine's player status box (and any external EXP-bar + -- mod that anchors to that box). Removed so our player HUD shares the + -- engine's status-box origin -- the external bar then lines up with it. + local px = shot.pw - (p[1] + p[3]) * hs local rects = { enemy = { ex + e[1] * hs, shot.ly + e[2] * s, e[3] * hs, e[4] * hs }, player = { px + p[1] * hs, shot.ly + p[2] * s, @@ -1488,17 +1492,12 @@ function OverworldBattle.snapHUDs(battle, shot) g.setBlendMode("alpha") for key, rect in pairs(live) do BattleHud.panel(rect, shot, whiteInk, true) - -- TEXTBOX FILL: a backplate behind the dialogue box and its move/mimic - -- menus -- NOT the player/opponent HUD blocks, which are separate rects. - -- Under ARENA FILL: WHITE it is an OPAQUE WHITE slab (no sprite shows - -- through); otherwise a translucent BLACK slab (upstream default). - -- Bounded to the box's own GB-frame rect so it cannot venture outside. - local style = UiBackplates.textboxFillStyle() - if style and UiBackplates.isTextboxKey(key) then - g.setColor(style[1], style[2], style[3], style[4]) - g.rectangle("fill", rect[1], rect[2], rect[3], rect[4]) - g.setColor(1, 1, 1, 1) - end + -- TEXTBOX FILL: the dialogue box's backplate is the engine's own + -- Font.drawBox fill, drawn in SCREEN space and docked to the window + -- edge -- so it already tracks the box at every aspect ratio. Drawing + -- our own slab here, in the GB canvas at a static rect, could never + -- follow a window-docked box (they only coincide at ~1:1), so we use + -- the engine's box as the backplate and do not paint over it. end g.setColor(1, 1, 1, 1) for side, band in pairs(OverworldBattle.HUD_BAND) do diff --git a/manifest.json b/manifest.json index 87f9cc0..564dfbf 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "BATTLE_ART_VOXEL_FORK", "name": "BATTLE ART VOXEL FORK", - "version": "1.72.0", + "version": "1.7.2", "api": 2, "entry": "main.lua", "profile": "content", From 4a8c9d5b3793cb7d60f6dfae329229f6eaa39bfc Mon Sep 17 00:00:00 2001 From: absol89 Date: Thu, 6 Aug 2026 08:08:30 +0200 Subject: [PATCH 097/116] fix(camera): seed battle cam from last-known freecam offset; keep drift - Capture the free-roam attitude (yaw/pitch/zoom) into FirstPerson.last* / ThirdPerson.lastZoom WHILE the freecam is engaged, and hold it after the rung is dropped. The staged battle now seeds from these last-known values instead of the live FirstPerson/ThirdPerson numbers, which are stale during a battle (the freecam is not driving) and reset to the sprite facing on the frame the rung is re-entered. So battles open where the player last looked, not where the camera happened to be pointing at battle start. - BattleCam.reset no longer zeroes the drift phase (BattleCam.t): the slow parallax breath now continues from the previous battle's phase instead of snapping back to 0 every encounter. Co-Authored-By: Hermes Agent --- lib/BattleCam.lua | 35 ++++++++++++++++++++++++----------- lib/FirstPerson.lua | 19 +++++++++++++++++++ lib/ThirdPerson.lua | 8 ++++++++ 3 files changed, 51 insertions(+), 11 deletions(-) diff --git a/lib/BattleCam.lua b/lib/BattleCam.lua index 2fb4253..5cb1fdc 100644 --- a/lib/BattleCam.lua +++ b/lib/BattleCam.lua @@ -228,16 +228,26 @@ function BattleCam.seedFromFreeCamera() local okF, FirstPerson = pcall(require, "FirstPerson") if not (okT and okF) then return end + -- Read the LAST-KNOWN free-roam attitude, captured into FirstPerson.last* + -- / ThirdPerson.lastZoom WHILE the freecam was engaged. The live values are + -- stale during a battle (the freecam is not driving) and reset to the sprite + -- facing on the frame the rung is re-entered -- so seeding from them would + -- open every battle where the player happened to be looking, not where they + -- last left the freecam. Fall back to the live numbers only on the very + -- first run, before lastKnown has been populated. + local yaw = FirstPerson.lastYaw or FirstPerson.yaw or 0 + local pitch = FirstPerson.lastPitch or FirstPerson.pitch or 0 + local zoom = ThirdPerson.lastZoom or ThirdPerson.zoom or 1 + -- zoom: the free camera's eased zoom, clamped to the battle's own range - local z = ThirdPerson.zoom or 1 - z = math.max(BattleCam.ZOOM_MIN, math.min(BattleCam.ZOOM_MAX, z)) + local z = math.max(BattleCam.ZOOM_MIN, math.min(BattleCam.ZOOM_MAX, zoom)) -- pitch: free look-pitch (radians, +down) over the battle's pitch range - local p = (FirstPerson.pitch or 0) / BattleCam.PITCH_RANGE + local p = pitch / BattleCam.PITCH_RANGE p = math.max(-1, math.min(1, p)) -- orbit: free look-yaw (world radians) across the battle's full orbit swing - local o = (FirstPerson.yaw or 0) / (2 * math.pi) + local o = yaw / (2 * math.pi) o = math.max(-0.5, math.min(0.5, o)) BattleCam.orbit, BattleCam.orbitGoal = o, o @@ -246,14 +256,17 @@ function BattleCam.seedFromFreeCamera() end function BattleCam.reset() - BattleCam.t = 0 + -- NOTE: the drift phase (BattleCam.t) is deliberately NOT reset here. The + -- drift is a slow parallax breath that should continue from wherever the + -- previous battle left it, so the camera opens on the same motion rather + -- than snapping back to phase 0 every encounter. -- If the player never steered a BATTLE camera (it is still at the solved - -- defaults), open where the FREE-ROAM camera was left instead -- that is - -- the "saved coordinate" they actually move, and it fixes battles opening - -- at the zoomed-in solved shot. Once they steer a battle, keep the battle - -- camera's own last position across battles (so in-battle steering persists - -- too). Either way the live values start where the goals are, so frame 1 - -- already renders at the saved position -- no ease-in jump. + -- defaults), open where the FREE-ROAM camera was last left instead -- that + -- is the "saved coordinate" they actually move, and it fixes battles + -- opening at the zoomed-in solved shot. Once they steer a battle, keep the + -- battle camera's own last position across battles (so in-battle steering + -- persists too). Either way the live values start where the goals are, so + -- frame 1 already renders at the saved position -- no ease-in jump. local atDefault = BattleCam.orbitGoal == 0 and BattleCam.pitchGoal == 0 and BattleCam.zoomGoal == 1 diff --git a/lib/FirstPerson.lua b/lib/FirstPerson.lua index 077f6ba..e5877d7 100644 --- a/lib/FirstPerson.lua +++ b/lib/FirstPerson.lua @@ -110,6 +110,15 @@ FirstPerson.yaw = 0 FirstPerson.pitch = FirstPerson.PITCH_DEFAULT FirstPerson.blend = 0 +-- The last-known free-roam attitude, captured WHILE the freecam is engaged +-- (see update) and held after it is dropped. The staged battle seeds its +-- camera from these so it opens where the player was last looking -- NOT +-- from the live FirstPerson numbers, which are stale (the freecam is not +-- driving during a battle) and which reset to the sprite facing on the +-- frame the rung is re-entered. Nil until the freecam has actually run. +FirstPerson.lastYaw = nil +FirstPerson.lastPitch = nil + -- A multiplier on the first-person field of view, for anything that wants -- to narrow the lens without owning the rig: 1 is the ordinary 65 -- degrees, and horde mode's iron sights ease it down toward 40 while the @@ -465,6 +474,16 @@ function FirstPerson.update(dt) end wasEngaged = engagedNow + -- Capture the live attitude into the last-known record WHILE the rung is + -- engaged AND continuing (not on the entry frame): the entry frame resets + -- yaw/pitch to the sprite facing (above), so writing lastYaw there would + -- throw away the position the player had last time. Held after the rung + -- is dropped, so the staged battle can seed from it later. + if engagedNow and wasEngaged then + FirstPerson.lastYaw = FirstPerson.yaw + FirstPerson.lastPitch = FirstPerson.pitch + end + -- the blend, held at flat until there is terrain to dive into -- the -- same wait Voxel.update keeps for the orbit tween, for the same reason local target = engagedNow and 1 or 0 diff --git a/lib/ThirdPerson.lua b/lib/ThirdPerson.lua index 07a8358..98dff93 100644 --- a/lib/ThirdPerson.lua +++ b/lib/ThirdPerson.lua @@ -86,6 +86,13 @@ ThirdPerson.ZOOM_TIME = 0.18 -- how fast the eye eases to a new one ThirdPerson.zoom = 1 -- eased, what place() actually uses ThirdPerson.zoomGoal = 1 -- what the input asked for +-- The last-known free-roam zoom, captured while the 3RD rung is engaged (see +-- update) and held after it is dropped. The staged battle seeds from this so +-- it opens at the player's last zoom -- not from the live ThirdPerson.zoom, +-- which is stale during a battle (the freecam is not driving) and would +-- otherwise reset to 1 on a fresh run. Nil until the rung has actually run. +ThirdPerson.lastZoom = nil + -- Step the zoom by `notches` (positive pulls the camera OUT). Returns true -- when the goal actually moved, so a caller can tell "zoomed" from "already -- at the stop" and let the input fall through. @@ -312,6 +319,7 @@ function ThirdPerson.update(dt, blend) end local target = ThirdPerson.selected() and 1 or 0 + if target > 0 then ThirdPerson.lastZoom = ThirdPerson.zoom end if (blend or 0) <= 0 then ThirdPerson.out = target ThirdPerson.len = ThirdPerson.reachFor() * target From 0a4d6c03374f683e50f42b1b12d1e7091280b047 Mon Sep 17 00:00:00 2001 From: absol89 Date: Thu, 6 Aug 2026 08:21:13 +0200 Subject: [PATCH 098/116] fix(ui): TEXTBOX FILL drives the engine box backplate (tracks at all aspects) Wrap the engine's Font.drawBox so the battle dialogue box's backplate is controlled by TEXTBOX FILL, applied inside the 160x144 UI canvas the box lives in -- so it tracks the box at every window aspect (a world-canvas slab could never follow a box the engine letterboxes). - OFF -> the engine's white fill is skipped: the backplate is invisible, the box border and the ink still draw. - HALF -> a translucent-black fill (the upstream default) over the diorama. Gated on a live battle session and the unique dialogue-box tile rect {0,12,20,6}, so menus/summary/overworld boxes are untouched. Co-Authored-By: Hermes Agent --- lib/UiBackplates.lua | 75 ++++++++++++++++++++++++++++++++++++++------ main.lua | 5 +++ 2 files changed, 71 insertions(+), 9 deletions(-) diff --git a/lib/UiBackplates.lua b/lib/UiBackplates.lua index 733db04..d8406d2 100644 --- a/lib/UiBackplates.lua +++ b/lib/UiBackplates.lua @@ -67,17 +67,18 @@ UiBackplates.textboxFill = ModSetting.new("textboxFill", "TEXTBOX FILL", UiBackplates.TEXTBOX_KEYS = { box = true, moves = true, mimic = true } -- The backplate colour/alpha for the dialogue/menu boxes, or nil when there --- is none. Two cases: --- * ARENA FILL: WHITE -- an OPAQUE WHITE slab, so no sprite (e.g. the mon --- standing behind the box) shows through; the box reads as a solid white --- Game Boy panel with the inverted black ink on top. --- * TEXTBOX FILL: HALF -- a translucent BLACK slab (upstream's default), so --- the words read over any ground. --- The player/opponent HUD blocks are never covered (see isTextboxKey). +-- is none: +-- * OFF -- no backplate: the engine's white box fill is skipped, so the +-- diorama shows through behind the text (the box border + ink still draw). +-- * HALF -- a translucent BLACK slab (upstream's default) over the diorama, +-- so the words read over any ground. +-- The fill is applied by wrapping the engine's Font.drawBox (see +-- installBoxHook): that draws into the SAME 160x144 UI canvas the engine's +-- box lives in, so it tracks the box at every window aspect -- a slab in the +-- world canvas could never follow a box the engine letterboxes. function UiBackplates.textboxFillStyle() - if UiBackplates.arenaWhite() then return { 1, 1, 1, 1 } end if UiBackplates.textboxFill:get() == "HALF" then - return { 0, 0, 0, 0.5 } + return { 0, 0, 0, 0.55 } end return nil end @@ -88,4 +89,60 @@ function UiBackplates.isTextboxKey(key) return UiBackplates.TEXTBOX_KEYS[key] == true end +-- ------- the engine box hook +-- +-- The engine draws the dialogue box with Font.drawBox: a white interior fill +-- in the 160x144 UI canvas (tile coords) plus a black border. We wrap it so +-- the dialogue box during a battle uses TEXTBOX FILL instead: +-- OFF -- skip the white fill (backplate invisible; border + ink remain) +-- HALF -- draw our translucent-black fill, then the border +-- Every other box (menus, summary, overworld) is passed through untouched. +-- Gated on a live battle session so only the battle dialogue box is affected. +-- +-- The battle dialogue box is the unique tile rect {0, 12, 20, 6} +-- (160x48px at the foot of the 160x144 screen). +local BOX_TX, BOX_TY, BOX_TW, BOX_TH = 0, 12, 20, 6 + +function UiBackplates.installBoxHook() + local okF, Font = pcall(require, "src.render.Font") + if not okF or not Font or not Font.drawBox then return end + if Font.__dramaticShapeBoxHook then return end + local inner = Font.drawBox + local OverworldBattle = V.require("OverworldBattle") + + Font.drawBox = function(tx, ty, tw, th) + if OverworldBattle.session + and tx == BOX_TX and ty == BOX_TY + and tw == BOX_TW and th == BOX_TH then + local style = UiBackplates.textboxFillStyle() + -- border only (in the caller's colour, as the engine does after the + -- fill) -- copied from Font.drawBox so we can skip/replace the fill + local r, g, b, a = love.graphics.getColor() + if style then + love.graphics.setColor(style[1], style[2], style[3], style[4]) + love.graphics.rectangle("fill", tx * 8, ty * 8, tw * 8, th * 8) + end + -- border in black (the dialogue box border colour) + love.graphics.setColor(0, 0, 0, 1) + local B = Font.BORDER + Font.drawCode(B.tl, tx * 8, ty * 8) + Font.drawCode(B.tr, (tx + tw - 1) * 8, ty * 8) + Font.drawCode(B.bl, tx * 8, (ty + th - 1) * 8) + Font.drawCode(B.br, (tx + tw - 1) * 8, (ty + th - 1) * 8) + for i = 1, tw - 2 do + Font.drawCode(B.h, (tx + i) * 8, ty * 8) + Font.drawCode(B.h, (tx + i) * 8, (ty + th - 1) * 8) + end + for j = 1, th - 2 do + Font.drawCode(B.v, tx * 8, (ty + j) * 8) + Font.drawCode(B.v, (tx + tw - 1) * 8, (ty + j) * 8) + end + love.graphics.setColor(r, g, b, a) + return + end + return inner(tx, ty, tw, th) + end + Font.__dramaticShapeBoxHook = true +end + return UiBackplates diff --git a/main.lua b/main.lua index 3d1bb62..60a4c43 100644 --- a/main.lua +++ b/main.lua @@ -899,6 +899,11 @@ end -- so this file keeps naming every engine seam the mod touches. OverworldBattle.install() +-- Wrap the engine's dialogue-box draw so TEXTBOX FILL (HALF/OFF) controls the +-- box's own backplate -- it lives in the 160x144 UI canvas, so it tracks the +-- box at every window aspect. Installed once, here. +UiBackplates.installBoxHook() + -- ------- the first-person rung's inputs and its walk -- -- 1ST needs two things no other rung does, and each is a named seam: From d34890e2e6b53b1a607b8cb0a904219808fb1692 Mon Sep 17 00:00:00 2001 From: absol89 Date: Thu, 6 Aug 2026 08:23:11 +0200 Subject: [PATCH 099/116] Revert "fix(ui): force CENTERED uiLayout so the textbox backplate tracks the box" This reverts commit 2b4300779f52b3ec52acd7de7c66ec5c1be6a0e7. --- lib/OverworldBattle.lua | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/lib/OverworldBattle.lua b/lib/OverworldBattle.lua index 8e8cd3e..54e74cf 100644 --- a/lib/OverworldBattle.lua +++ b/lib/OverworldBattle.lua @@ -512,17 +512,6 @@ function OverworldBattle.begin(state, battle) armed = false, token = 0 } cullCast(state) BattleCam.reset() - -- TEXTBOX FILL (the dialogue-box backplate) is drawn into the GB canvas, so - -- it only lines up with the engine's box when the box stays inside the - -- 160x144 frame. DYNAMIC uiLayout docks the box to the window's bottom edge - -- (window space, full width) where an in-canvas backplate cannot reach -- it - -- then aligns only at ~1:1 aspect. Force CENTERED for the battle so the box, - -- and its backplate, stay in-frame at every window aspect. - local g = game() - if g and g.save and g.save.options then - g.save.options.uiLayout = "centered" - if g.writeOptions then pcall(g.writeOptions, g) end - end return true end From 606ee16a9dfc8386c1e15922d566fc7d95b9a8f0 Mon Sep 17 00:00:00 2001 From: absol89 Date: Thu, 6 Aug 2026 08:23:46 +0200 Subject: [PATCH 100/116] fix(ui): HALF backplate overridden to white under ARENA FILL WHITE When ARENA FILL: WHITE paints a solid white field, a dark HALF backplate would read as a grey slab on white. Override TEXTBOX FILL: HALF to an opaque white fill in that view, so the box is a plain white Game Boy panel with the inverted black ink on top -- no dark engine backplate over white. Also reverts the earlier CENTERED uiLayout force (2b43007): the engine box now tracks at every aspect via the Font.drawBox hook, so forcing uiLayout was redundant and only overrode the player's layout preference. Co-Authored-By: Hermes Agent --- lib/UiBackplates.lua | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lib/UiBackplates.lua b/lib/UiBackplates.lua index d8406d2..e4a226c 100644 --- a/lib/UiBackplates.lua +++ b/lib/UiBackplates.lua @@ -77,6 +77,11 @@ UiBackplates.TEXTBOX_KEYS = { box = true, moves = true, mimic = true } -- box lives in, so it tracks the box at every window aspect -- a slab in the -- world canvas could never follow a box the engine letterboxes. function UiBackplates.textboxFillStyle() + -- ARENA FILL: WHITE paints a solid white field; a dark box backplate would + -- read as a grey slab on white, so HALF is overridden to an OPAQUE WHITE + -- fill -- the box becomes a plain white Game Boy panel, no sprite shows + -- through, and the inverted black ink sits on top of it. + if UiBackplates.arenaWhite() then return { 1, 1, 1, 1 } end if UiBackplates.textboxFill:get() == "HALF" then return { 0, 0, 0, 0.55 } end From deac6aa2f13185bf359c27f0768dd385add22061 Mon Sep 17 00:00:00 2001 From: absol89 Date: Thu, 6 Aug 2026 08:28:27 +0200 Subject: [PATCH 101/116] chore(ui): rename HUD SCALE WINDOW -> OG OG is the original gen1recomp scale (window-fit, what the engine's player HUD uses and external XP-bar mods target); SCALED is this mod's new zoom-dependent scale. Renamed the option value/label and updated the options-row description and code comment accordingly. Co-Authored-By: Hermes Agent --- lib/OverworldBattle.lua | 18 +++++++++--------- main.lua | 9 +++++---- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/lib/OverworldBattle.lua b/lib/OverworldBattle.lua index 54e74cf..a47b0e3 100644 --- a/lib/OverworldBattle.lua +++ b/lib/OverworldBattle.lua @@ -80,14 +80,14 @@ OverworldBattle.setting = ModSetting.new(OverworldBattle.KEY, { true, false }, { "ON", "OFF" }) -- HUD SCALE: SCALED draws the player/opponent HUD at the battle's zoom factor --- (hs = zoom - 1), the look this mod ships with. WINDOW pins the HUD to the --- window-fit scale (hs = fitScale), like upstream DynamicShapes' player HUD -- +-- (hs = zoom - 1), the look this mod ships with. OG pins the HUD to the +-- window-fit scale (hs = fitScale), like upstream gen1recomp's player HUD -- -- the HUD grows and shrinks with the window, which is the scale external -- EXP-bar mods assume (they adapted to the window-scaling default). The -- backplates are untouched: only the HUD's on-screen size follows the window. OverworldBattle.hudScaleSetting = ModSetting.new("hudScale", "HUD SCALE", - { "scaled", "window" }, - { "SCALED", "WINDOW" }, 1) + { "scaled", "og" }, + { "SCALED", "OG" }, 1) function OverworldBattle.enabled() return OverworldBattle.setting:get() and true or false @@ -372,11 +372,11 @@ OverworldBattle.HUD_BAND = { function OverworldBattle.snapRects(shot) local s = shot.scale local hs = math.max(1, s - 1) - -- HUD SCALE: WINDOW pins the HUD to the window-fit scale (hs = fitScale), - -- like upstream DynamicShapes' player HUD, so an external XP-bar mod (which - -- assumes a window-scaling HUD) lines up; SCALED is the mod's default (hs - -- follows the battle zoom). - if OverworldBattle.hudScaleSetting:get() == "window" then hs = s end + -- HUD SCALE: OG pins the HUD to the window-fit scale (hs = fitScale), like + -- upstream gen1recomp's player HUD, so an external XP-bar mod (which assumes + -- a window-scaling HUD) lines up; SCALED is the mod's default (hs follows + -- the battle zoom). + if OverworldBattle.hudScaleSetting:get() == "og" then hs = s end local e, p = OverworldBattle.HUD_RECT.enemy, OverworldBattle.HUD_RECT.player -- Leave two logical pixels between the foe's name and the window edge. -- The whole enemy band moves with the panel, so long names and HUD shakes diff --git a/main.lua b/main.lua index 60a4c43..ece12b4 100644 --- a/main.lua +++ b/main.lua @@ -396,12 +396,13 @@ local SETTINGS = { .. "shot over the shoulder with a slow parallax drift.", full = true }, -- HUD SCALE lives with the battle rows: SCALED is the mod's default HUD - -- (it grows with the battle zoom); 1X pins it to native size so an external - -- XP-bar mod -- which this mod does NOT provide -- lines up at 1X. + -- (it grows with the battle zoom); OG pins it to the window-fit scale like + -- upstream gen1recomp's player HUD, so an external XP-bar mod -- which this + -- mod does NOT provide -- lines up with a window-scaling HUD. { OverworldBattle.hudScaleSetting, "Size of the player and opponent HUD. SCALED grows with the battle " - .. "zoom (the mod default); WINDOW pins it to the window-fit scale like " - .. "upstream DynamicShapes' player HUD, so an external XP-bar mod -- which " + .. "zoom (the mod default); OG pins it to the window-fit scale like " + .. "upstream gen1recomp's player HUD, so an external XP-bar mod -- which " .. "this mod does not include -- lines up with a window-scaling HUD.", full = true }, -- Only offered while a fight can actually be staged on the map. From 6952585441350edf28c0fd3bd913220c9285ef62 Mon Sep 17 00:00:00 2001 From: absol89 Date: Thu, 6 Aug 2026 08:35:07 +0200 Subject: [PATCH 102/116] fix(ui): HALF backplate via the drawTextArea g.rectangle shim The Font.drawBox hook bypassed the battle box fill, which the engine draws through BattleState:drawTextArea as a g.rectangle("fill") (intercepted by the withoutBoxFill/whiteBoxFill shims). So HALF did nothing. Replace the dead hook with a halfBoxFill shim -- recolours the engine's opaque white box fill to a translucent black slab -- routed through drawTextArea alongside the existing WHITE path, so it tracks the box at every aspect. OFF keeps the invisible backplate (withoutBoxFill drops the white fill); WHITE arena still forces opaque white. Co-Authored-By: Hermes Agent --- lib/OverworldBattle.lua | 32 +++++++++++++++++++++ lib/UiBackplates.lua | 62 ++++++----------------------------------- main.lua | 5 ---- 3 files changed, 40 insertions(+), 59 deletions(-) diff --git a/lib/OverworldBattle.lua b/lib/OverworldBattle.lua index a47b0e3..b9f402f 100644 --- a/lib/OverworldBattle.lua +++ b/lib/OverworldBattle.lua @@ -775,6 +775,29 @@ local function whiteBoxFill(battle, fn) if not ok then error(err, 0) end end +-- HALF backplate: the engine's white box fill is recoloured to a translucent +-- BLACK slab, so the dialogue reads over any ground. Only the opaque-white +-- fills (the box paper) are recoloured; glyphs and borders are drawn as ink +-- and pass through. Lives in the same g.rectangle path the engine uses, so it +-- tracks the box at every window aspect. +local function halfBoxFill(battle, fn) + local g = love.graphics + local rectangle = g.rectangle + g.rectangle = function(mode, ...) + if mode == "fill" then + local r, gr, b, a = g.getColor() + if r > 0.99 and gr > 0.99 and b > 0.99 and a > 0.99 then + g.setColor(0, 0, 0, 0.55) + end + end + return rectangle(mode, ...) + end + local ok, err = pcall(fn, battle) + g.rectangle = rectangle + g.setColor(1, 1, 1, 1) + if not ok then error(err, 0) end +end + -- ------- the hour's light, on a pic that is not geometry -- -- Everything standing in the arena goes through the voxel shader, and that @@ -1262,6 +1285,15 @@ function OverworldBattle.install() if not self.dramaticShapeShot then return innerText(self) end if isIOS() then return innerText(self) end local battle = self + local style = UiBackplates.textboxFillStyle() + -- HALF: a translucent-black backplate with white-flipped ink (text on a + -- dark slab reads white). Goes through the same g.rectangle shim path as + -- the WHITE arena fill, so it tracks the box at every window aspect. + if style and style[1] < 0.5 then + BattleHud.flipGlyphs(BattleScene.GB_W, BattleScene.GB_H, + function() halfBoxFill(battle, innerText) end, false) + return + end -- The WHITE arena fill inverts the box ink: black text with a white -- drop-shadow on the white field, rather than the usual white-flip. local inverted = UiBackplates.arenaWhite() diff --git a/lib/UiBackplates.lua b/lib/UiBackplates.lua index e4a226c..c0b190e 100644 --- a/lib/UiBackplates.lua +++ b/lib/UiBackplates.lua @@ -94,60 +94,14 @@ function UiBackplates.isTextboxKey(key) return UiBackplates.TEXTBOX_KEYS[key] == true end --- ------- the engine box hook +-- ------- the backplate is applied in OverworldBattle.drawTextArea -- --- The engine draws the dialogue box with Font.drawBox: a white interior fill --- in the 160x144 UI canvas (tile coords) plus a black border. We wrap it so --- the dialogue box during a battle uses TEXTBOX FILL instead: --- OFF -- skip the white fill (backplate invisible; border + ink remain) --- HALF -- draw our translucent-black fill, then the border --- Every other box (menus, summary, overworld) is passed through untouched. --- Gated on a live battle session so only the battle dialogue box is affected. --- --- The battle dialogue box is the unique tile rect {0, 12, 20, 6} --- (160x48px at the foot of the 160x144 screen). -local BOX_TX, BOX_TY, BOX_TW, BOX_TH = 0, 12, 20, 6 - -function UiBackplates.installBoxHook() - local okF, Font = pcall(require, "src.render.Font") - if not okF or not Font or not Font.drawBox then return end - if Font.__dramaticShapeBoxHook then return end - local inner = Font.drawBox - local OverworldBattle = V.require("OverworldBattle") - - Font.drawBox = function(tx, ty, tw, th) - if OverworldBattle.session - and tx == BOX_TX and ty == BOX_TY - and tw == BOX_TW and th == BOX_TH then - local style = UiBackplates.textboxFillStyle() - -- border only (in the caller's colour, as the engine does after the - -- fill) -- copied from Font.drawBox so we can skip/replace the fill - local r, g, b, a = love.graphics.getColor() - if style then - love.graphics.setColor(style[1], style[2], style[3], style[4]) - love.graphics.rectangle("fill", tx * 8, ty * 8, tw * 8, th * 8) - end - -- border in black (the dialogue box border colour) - love.graphics.setColor(0, 0, 0, 1) - local B = Font.BORDER - Font.drawCode(B.tl, tx * 8, ty * 8) - Font.drawCode(B.tr, (tx + tw - 1) * 8, ty * 8) - Font.drawCode(B.bl, tx * 8, (ty + th - 1) * 8) - Font.drawCode(B.br, (tx + tw - 1) * 8, (ty + th - 1) * 8) - for i = 1, tw - 2 do - Font.drawCode(B.h, (tx + i) * 8, ty * 8) - Font.drawCode(B.h, (tx + i) * 8, (ty + th - 1) * 8) - end - for j = 1, th - 2 do - Font.drawCode(B.v, tx * 8, (ty + j) * 8) - Font.drawCode(B.v, (tx + tw - 1) * 8, (ty + j) * 8) - end - love.graphics.setColor(r, g, b, a) - return - end - return inner(tx, ty, tw, th) - end - Font.__dramaticShapeBoxHook = true -end +-- The engine draws the dialogue box through BattleState:drawTextArea, which +-- issues the white interior as a g.rectangle("fill") (see withoutBoxFill / +-- whiteBoxFill there). The backplate colour is taken from textboxFillStyle() +-- and applied by a g.rectangle shim in that path -- NOT by wrapping +-- Font.drawBox, whose own fill is bypassed during a battle draw. So the fill +-- lives in the 160x144 UI canvas the box is drawn in and tracks it at every +-- window aspect. return UiBackplates diff --git a/main.lua b/main.lua index ece12b4..1138a0f 100644 --- a/main.lua +++ b/main.lua @@ -900,11 +900,6 @@ end -- so this file keeps naming every engine seam the mod touches. OverworldBattle.install() --- Wrap the engine's dialogue-box draw so TEXTBOX FILL (HALF/OFF) controls the --- box's own backplate -- it lives in the 160x144 UI canvas, so it tracks the --- box at every window aspect. Installed once, here. -UiBackplates.installBoxHook() - -- ------- the first-person rung's inputs and its walk -- -- 1ST needs two things no other rung does, and each is a named seam: From 7f0dd2be16558d1b43688cbdc3a9f0233a834f58 Mon Sep 17 00:00:00 2001 From: absol89 Date: Thu, 6 Aug 2026 08:48:45 +0200 Subject: [PATCH 103/116] fix(ui/camera): scope HALF to dialogue box; always seed from saved freecam - HALF backplate: the translucent-black fill was being applied to every white fill during drawTextArea, washing the move-select / type / action boxes. Scope halfBoxFill to the dialogue box's own GB rect {0,96,160,48} so only the dialogue paper is recoloured; the other boxes keep their colours. - Battle camera: reset() only seeded from the freecam when the battle camera was at its defaults, so a steered (or never-seeded) camera opened on the zoomed-in solved shot instead of the saved freecam position. Now reset() always seeds from the last-known freecam attitude when we have one, so battles open where the player last looked (the saved drift). A steered battle camera is only kept when there is no freecam history. Co-Authored-By: Hermes Agent --- lib/BattleCam.lua | 32 ++++++++++++++++++++------------ lib/OverworldBattle.lua | 19 +++++++++++-------- 2 files changed, 31 insertions(+), 20 deletions(-) diff --git a/lib/BattleCam.lua b/lib/BattleCam.lua index 5cb1fdc..1503a51 100644 --- a/lib/BattleCam.lua +++ b/lib/BattleCam.lua @@ -226,7 +226,7 @@ BattleCam.t = 0 function BattleCam.seedFromFreeCamera() local okT, ThirdPerson = pcall(require, "ThirdPerson") local okF, FirstPerson = pcall(require, "FirstPerson") - if not (okT and okF) then return end + if not (okT and okF) then return false end -- Read the LAST-KNOWN free-roam attitude, captured into FirstPerson.last* -- / ThirdPerson.lastZoom WHILE the freecam was engaged. The live values are @@ -239,6 +239,16 @@ function BattleCam.seedFromFreeCamera() local pitch = FirstPerson.lastPitch or FirstPerson.pitch or 0 local zoom = ThirdPerson.lastZoom or ThirdPerson.zoom or 1 + -- If there is no freecam history AND the battle camera was never steered, + -- leave it at the solved default -- nothing to seed from. When there IS + -- history, always open where the freecam was last left (the saved drift). + if FirstPerson.lastYaw == nil + and BattleCam.orbitGoal == 0 + and BattleCam.pitchGoal == 0 + and BattleCam.zoomGoal == 1 then + return false + end + -- zoom: the free camera's eased zoom, clamped to the battle's own range local z = math.max(BattleCam.ZOOM_MIN, math.min(BattleCam.ZOOM_MAX, zoom)) @@ -253,6 +263,7 @@ function BattleCam.seedFromFreeCamera() BattleCam.orbit, BattleCam.orbitGoal = o, o BattleCam.pitch, BattleCam.pitchGoal = p, p BattleCam.zoom, BattleCam.zoomGoal = z, z + return true end function BattleCam.reset() @@ -260,17 +271,14 @@ function BattleCam.reset() -- drift is a slow parallax breath that should continue from wherever the -- previous battle left it, so the camera opens on the same motion rather -- than snapping back to phase 0 every encounter. - -- If the player never steered a BATTLE camera (it is still at the solved - -- defaults), open where the FREE-ROAM camera was last left instead -- that - -- is the "saved coordinate" they actually move, and it fixes battles - -- opening at the zoomed-in solved shot. Once they steer a battle, keep the - -- battle camera's own last position across battles (so in-battle steering - -- persists too). Either way the live values start where the goals are, so - -- frame 1 already renders at the saved position -- no ease-in jump. - local atDefault = BattleCam.orbitGoal == 0 - and BattleCam.pitchGoal == 0 - and BattleCam.zoomGoal == 1 - if atDefault then BattleCam.seedFromFreeCamera() end + -- Open where the FREE-ROAM camera was last left (the "saved drift" the + -- player actually moves) whenever we have a freecam history -- this is the + -- position battles should open at, and it fixes them opening on the + -- zoomed-in solved shot. Only with no freecam history AND a battle camera + -- that was explicitly steered do we keep that steered position instead. + -- Either way the live values start where the goals are, so frame 1 already + -- renders at the saved position -- no ease-in jump. + BattleCam.seedFromFreeCamera() BattleCam.orbit = BattleCam.orbitGoal BattleCam.pitch = BattleCam.pitchGoal BattleCam.zoom = BattleCam.zoomGoal diff --git a/lib/OverworldBattle.lua b/lib/OverworldBattle.lua index b9f402f..f1ef2a3 100644 --- a/lib/OverworldBattle.lua +++ b/lib/OverworldBattle.lua @@ -775,22 +775,25 @@ local function whiteBoxFill(battle, fn) if not ok then error(err, 0) end end --- HALF backplate: the engine's white box fill is recoloured to a translucent --- BLACK slab, so the dialogue reads over any ground. Only the opaque-white --- fills (the box paper) are recoloured; glyphs and borders are drawn as ink --- and pass through. Lives in the same g.rectangle path the engine uses, so it --- tracks the box at every window aspect. +-- HALF backplate: the engine's white dialogue-box fill is recoloured to a +-- translucent BLACK slab, so the dialogue reads over any ground. Scoped to the +-- dialogue box's own GB rect {0,96,160,48} -- the move-select / type / action +-- boxes are separate white fills and must keep their own colours, so only the +-- dialogue paper is recoloured. Glyphs and borders are drawn as ink and pass +-- through. Lives in the same g.rectangle path the engine uses, so it tracks the +-- box at every window aspect. local function halfBoxFill(battle, fn) local g = love.graphics local rectangle = g.rectangle - g.rectangle = function(mode, ...) + g.rectangle = function(mode, x, y, w, h, ...) if mode == "fill" then local r, gr, b, a = g.getColor() - if r > 0.99 and gr > 0.99 and b > 0.99 and a > 0.99 then + if r > 0.99 and gr > 0.99 and b > 0.99 and a > 0.99 + and x == 0 and y == 96 and w == 160 and h == 48 then g.setColor(0, 0, 0, 0.55) end end - return rectangle(mode, ...) + return rectangle(mode, x, y, w, h, ...) end local ok, err = pcall(fn, battle) g.rectangle = rectangle From da81c06e577022e72e4c292507ee3fdee63f7972 Mon Sep 17 00:00:00 2001 From: absol89 Date: Thu, 6 Aug 2026 09:24:27 +0200 Subject: [PATCH 104/116] fix(ui): scope HALF flip to the dialogue box only (no white-on-white) The HALF backplate applied flipGlyphs(white ink) to the WHOLE drawTextArea call, which also draws the move-select / type / mimic menus. Flipping those menus' black glyphs to white left black text on white paper -- white-on-white, the regression reported. Scope the dark slab + white-flip to the dialogue box (phase "messages") only; the menus fall through to the normal withoutBoxFill (black glyphs on the diorama), their original behaviour. The rule: white backplate -> black glyphs, dark backplate -> white glyphs. Updated the stale UiBackplates comment too. Co-Authored-By: Hermes Agent --- lib/OverworldBattle.lua | 10 ++++++---- lib/UiBackplates.lua | 14 +++++++------- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/lib/OverworldBattle.lua b/lib/OverworldBattle.lua index f1ef2a3..c523bd8 100644 --- a/lib/OverworldBattle.lua +++ b/lib/OverworldBattle.lua @@ -1289,10 +1289,12 @@ function OverworldBattle.install() if isIOS() then return innerText(self) end local battle = self local style = UiBackplates.textboxFillStyle() - -- HALF: a translucent-black backplate with white-flipped ink (text on a - -- dark slab reads white). Goes through the same g.rectangle shim path as - -- the WHITE arena fill, so it tracks the box at every window aspect. - if style and style[1] < 0.5 then + -- HALF applies ONLY to the dialogue box (phase "messages"): a translucent + -- black slab with white-flipped ink. The move-select / type / mimic menus + -- are separate phases and must keep their own black-on-diorama drawing -- + -- flipping their ink to white would leave black text on white paper + -- (white-on-white). So the flip is scoped to the messages phase only. + if style and style[1] < 0.5 and self.phase == "messages" then BattleHud.flipGlyphs(BattleScene.GB_W, BattleScene.GB_H, function() halfBoxFill(battle, innerText) end, false) return diff --git a/lib/UiBackplates.lua b/lib/UiBackplates.lua index c0b190e..2cfbebe 100644 --- a/lib/UiBackplates.lua +++ b/lib/UiBackplates.lua @@ -66,16 +66,16 @@ UiBackplates.textboxFill = ModSetting.new("textboxFill", "TEXTBOX FILL", -- -- NOT the player/opponent HUD blocks, which are separate rects. UiBackplates.TEXTBOX_KEYS = { box = true, moves = true, mimic = true } --- The backplate colour/alpha for the dialogue/menu boxes, or nil when there --- is none: +-- The backplate colour/alpha for the dialogue box, or nil when there is none: -- * OFF -- no backplate: the engine's white box fill is skipped, so the -- diorama shows through behind the text (the box border + ink still draw). -- * HALF -- a translucent BLACK slab (upstream's default) over the diorama, --- so the words read over any ground. --- The fill is applied by wrapping the engine's Font.drawBox (see --- installBoxHook): that draws into the SAME 160x144 UI canvas the engine's --- box lives in, so it tracks the box at every window aspect -- a slab in the --- world canvas could never follow a box the engine letterboxes. +-- so the words read over any ground. Applied only to the dialogue box +-- (phase "messages"); the move-select / type / mimic menus keep their own +-- black-on-diorama drawing so their glyphs never become white-on-white. +-- The fill is applied by a g.rectangle shim in OverworldBattle.drawTextArea +-- (see halfBoxFill): that draws into the SAME 160x144 UI canvas the engine's +-- box lives in, so it tracks the box at every window aspect. function UiBackplates.textboxFillStyle() -- ARENA FILL: WHITE paints a solid white field; a dark box backplate would -- read as a grey slab on white, so HALF is overridden to an OPAQUE WHITE From a9cfa9c105022f203149a855dfb3a9c913b4fe44 Mon Sep 17 00:00:00 2001 From: absol89 Date: Thu, 6 Aug 2026 09:37:56 +0200 Subject: [PATCH 105/116] refactor(ui): drop TEXTBOX FILL; battle box is always opaque white The HALF/OFF toggle produced inconsistent boxes (grey dialogue, transparent menus) because the g.rectangle shim flipped glyph ink across the whole drawTextArea call. Replace it with the one predictable backplate: every battle box -- dialogue, action-select and type panels -- is drawn as an opaque white panel with BLACK ink (the same as the overworld / trainer-intro textbox), via the proven whiteBoxFill + flipGlyphs(true) path. Removed: - TEXTBOX FILL ModSetting (HALF/OFF) and its OPTIONS row - halfBoxFill / withoutBoxFill shims and the textboxFillStyle / isTextboxKey queries and the dead snapHUDs slab The box colour now matches the overworld textbox at every window aspect. Co-Authored-By: Hermes Agent --- lib/OverworldBattle.lua | 79 ++++------------------------------------- lib/UiBackplates.lua | 61 ++++++------------------------- main.lua | 8 ++--- 3 files changed, 19 insertions(+), 129 deletions(-) diff --git a/lib/OverworldBattle.lua b/lib/OverworldBattle.lua index c523bd8..cbf1631 100644 --- a/lib/OverworldBattle.lua +++ b/lib/OverworldBattle.lua @@ -739,21 +739,6 @@ end -- The same shim shape as withoutBackgroundFill above, and scoped as tightly: -- installed around a single call, removed on the way out including on error, -- never live outside a battle frame this mode is drawing. -local function withoutBoxFill(battle, fn) - local g = love.graphics - local rectangle = g.rectangle - g.rectangle = function(mode, ...) - if mode == "fill" then - local r, gr, b, a = g.getColor() - if r > 0.99 and gr > 0.99 and b > 0.99 and a > 0.99 then return end - end - return rectangle(mode, ...) - end - local ok, err = pcall(fn, battle) - g.rectangle = rectangle - if not ok then error(err, 0) end -end - -- WHITE arena fill: paint the box background (and anything else the engine -- fills inside the text-area draw) opaque white, so the engine's coloured BG -- palette -- orange on the intro flash, grey once the mons appear -- cannot @@ -775,32 +760,6 @@ local function whiteBoxFill(battle, fn) if not ok then error(err, 0) end end --- HALF backplate: the engine's white dialogue-box fill is recoloured to a --- translucent BLACK slab, so the dialogue reads over any ground. Scoped to the --- dialogue box's own GB rect {0,96,160,48} -- the move-select / type / action --- boxes are separate white fills and must keep their own colours, so only the --- dialogue paper is recoloured. Glyphs and borders are drawn as ink and pass --- through. Lives in the same g.rectangle path the engine uses, so it tracks the --- box at every window aspect. -local function halfBoxFill(battle, fn) - local g = love.graphics - local rectangle = g.rectangle - g.rectangle = function(mode, x, y, w, h, ...) - if mode == "fill" then - local r, gr, b, a = g.getColor() - if r > 0.99 and gr > 0.99 and b > 0.99 and a > 0.99 - and x == 0 and y == 96 and w == 160 and h == 48 then - g.setColor(0, 0, 0, 0.55) - end - end - return rectangle(mode, x, y, w, h, ...) - end - local ok, err = pcall(fn, battle) - g.rectangle = rectangle - g.setColor(1, 1, 1, 1) - if not ok then error(err, 0) end -end - -- ------- the hour's light, on a pic that is not geometry -- -- Everything standing in the arena goes through the voxel shader, and that @@ -1287,31 +1246,14 @@ function OverworldBattle.install() function BattleState:drawTextArea() if not self.dramaticShapeShot then return innerText(self) end if isIOS() then return innerText(self) end - local battle = self - local style = UiBackplates.textboxFillStyle() - -- HALF applies ONLY to the dialogue box (phase "messages"): a translucent - -- black slab with white-flipped ink. The move-select / type / mimic menus - -- are separate phases and must keep their own black-on-diorama drawing -- - -- flipping their ink to white would leave black text on white paper - -- (white-on-white). So the flip is scoped to the messages phase only. - if style and style[1] < 0.5 and self.phase == "messages" then - BattleHud.flipGlyphs(BattleScene.GB_W, BattleScene.GB_H, - function() halfBoxFill(battle, innerText) end, false) - return - end - -- The WHITE arena fill inverts the box ink: black text with a white - -- drop-shadow on the white field, rather than the usual white-flip. - local inverted = UiBackplates.arenaWhite() - if not self.dramaticShapeDark and not inverted then - return withoutBoxFill(battle, innerText) - end - -- In WHITE mode the engine's box fill is a coloured palette colour (orange - -- on the intro flash, grey after) -- whiten it opaque so it reads as a - -- clean white panel under the inverted black ink. - local boxFn = inverted and whiteBoxFill or withoutBoxFill + -- Every battle box -- the dialogue, the FIGHT/ITEM/RUN menu and the + -- move-select / type panels -- is drawn as an OPAQUE WHITE panel with + -- BLACK ink, the same as the overworld / trainer-intro textbox. Black ink + -- sits on white (never white-on-white), so the words always read. This is + -- the one predictable backplate, so TEXTBOX FILL was dropped: the box is + -- simply always white. BattleHud.flipGlyphs(BattleScene.GB_W, BattleScene.GB_H, - function() boxFn(battle, innerText) end, - inverted) + function() whiteBoxFill(battle, innerText) end, true) end local innerAnim = BattleState.drawAnimLayer @@ -1579,13 +1521,6 @@ function OverworldBattle.drawHudPanels(battle) battle.dramaticShapeDark = true for key, r in pairs(live) do BattleHud.panel(r, shot, true) - local style = UiBackplates.textboxFillStyle() - if style and UiBackplates.isTextboxKey(key) then - local g = love.graphics - g.setColor(style[1], style[2], style[3], style[4]) - g.rectangle("fill", r[1], r[2], r[3], r[4]) - g.setColor(1, 1, 1, 1) - end end end diff --git a/lib/UiBackplates.lua b/lib/UiBackplates.lua index 2cfbebe..d93d44f 100644 --- a/lib/UiBackplates.lua +++ b/lib/UiBackplates.lua @@ -4,7 +4,7 @@ -- world's day tint and cast shadows (SHADED) or draw flat and full -- bright (UNLIT). UNLIT keeps them readable on the white arena fill (B); -- SHADED is the default OG look and is also supported on white. - +-- -- B) ARENA FILL WHITE / OFF -- a solid white rendering layer in -- front of the whole voxel world, with only the mons, their attack -- animations and the menus above it. Hides the 3D terrain while keeping @@ -12,11 +12,11 @@ -- full voxel one. Works with SHADED or UNLIT sprites (UNLIT just keeps -- the cards brighter on white). -- --- A) TEXTBOX FILL HALF / OFF -- a semi-transparent backplate behind --- the dialogue text box ONLY (the bottom 48px of the 160x144 battle --- screen, GB-frame rect {0,96,160,48}). The player and opponent HUD --- blocks are separate rects and are never covered. --- +-- A) TEXTBOX FILL was dropped: the battle box is now ALWAYS an opaque white +-- panel with black ink (the same as the overworld / trainer-intro +-- textbox), so there is no HALF / OFF toggle to manage. See +-- BattleState:drawTextArea in OverworldBattle.lua. + -- Each is a ModSetting: it gets an OPTIONS-menu row and a mod-manager schema -- for free, and persists under options.modOptions.BATTLE_ART_VOXEL_FORK like the -- others. Defining them here -- rather than inline in main.lua -- keeps the @@ -56,52 +56,11 @@ function UiBackplates.arenaWhite() return UiBackplates.arenaFill:get() == "WHITE" end --- ------- A) TEXTBOX FILL ------- - -UiBackplates.textboxFill = ModSetting.new("textboxFill", "TEXTBOX FILL", - { "OFF", "HALF" }, { "OFF", "HALF" }, 2) - --- The rect keys whose dialogue/menu boxes get the HALF backplate. The bottom --- text box ("box") and the move-select / mimic-select menus that open over it --- -- NOT the player/opponent HUD blocks, which are separate rects. -UiBackplates.TEXTBOX_KEYS = { box = true, moves = true, mimic = true } - --- The backplate colour/alpha for the dialogue box, or nil when there is none: --- * OFF -- no backplate: the engine's white box fill is skipped, so the --- diorama shows through behind the text (the box border + ink still draw). --- * HALF -- a translucent BLACK slab (upstream's default) over the diorama, --- so the words read over any ground. Applied only to the dialogue box --- (phase "messages"); the move-select / type / mimic menus keep their own --- black-on-diorama drawing so their glyphs never become white-on-white. --- The fill is applied by a g.rectangle shim in OverworldBattle.drawTextArea --- (see halfBoxFill): that draws into the SAME 160x144 UI canvas the engine's --- box lives in, so it tracks the box at every window aspect. -function UiBackplates.textboxFillStyle() - -- ARENA FILL: WHITE paints a solid white field; a dark box backplate would - -- read as a grey slab on white, so HALF is overridden to an OPAQUE WHITE - -- fill -- the box becomes a plain white Game Boy panel, no sprite shows - -- through, and the inverted black ink sits on top of it. - if UiBackplates.arenaWhite() then return { 1, 1, 1, 1 } end - if UiBackplates.textboxFill:get() == "HALF" then - return { 0, 0, 0, 0.55 } - end - return nil -end - --- Whether `key` is one of the dialogue/menu boxes that the HALF backplate --- covers (as opposed to the HUD blocks). -function UiBackplates.isTextboxKey(key) - return UiBackplates.TEXTBOX_KEYS[key] == true -end - -- ------- the backplate is applied in OverworldBattle.drawTextArea -- --- The engine draws the dialogue box through BattleState:drawTextArea, which --- issues the white interior as a g.rectangle("fill") (see withoutBoxFill / --- whiteBoxFill there). The backplate colour is taken from textboxFillStyle() --- and applied by a g.rectangle shim in that path -- NOT by wrapping --- Font.drawBox, whose own fill is bypassed during a battle draw. So the fill --- lives in the 160x144 UI canvas the box is drawn in and tracks it at every --- window aspect. +-- The battle box is ALWAYS drawn as an opaque white panel with black ink by +-- BattleState:drawTextArea (see whiteBoxFill there). TEXTBOX FILL was dropped +-- because the white panel is the one predictable backplate that matches the +-- overworld / trainer-intro textbox and never produces white-on-white. return UiBackplates diff --git a/main.lua b/main.lua index 1138a0f..773e07a 100644 --- a/main.lua +++ b/main.lua @@ -474,12 +474,8 @@ local SETTINGS = { .. "LIGHT: UNLIT so the sprites stay flat and true-colour with no night " .. "tint (as in the traditional games).", when = function() return stagedBattles() end, full = true }, - { UiBackplates.textboxFill, - "HALF lays a semi-transparent BLACK backplate behind the dialogue box " - .. "and its move/mimic menus -- not the player or opponent HUD blocks -- " - .. "so the words read over any ground, like the upstream default. OFF " - .. "leaves the text box as the engine draws it.", - full = true }, + -- The battle box is ALWAYS an opaque white panel with black ink (see + -- BattleState:drawTextArea), so TEXTBOX FILL was dropped -- no option row. -- Marked `full` for the opposite reason the battle rows are: this is not a -- knob on the look at all, it is what the look COSTS. FULL is a preset for -- the diorama, not a licence to spend four times the fill rate on the From d4a8d360496b8d62f77dc3c6c42ecc5dd3498c74 Mon Sep 17 00:00:00 2001 From: absol89 Date: Thu, 6 Aug 2026 09:43:13 +0200 Subject: [PATCH 106/116] fix(ui): define `battle = self` in drawTextArea wrapper (nil crash) The always-white box wrapper referenced `battle` before it was defined, passing nil to whiteBoxFill -> innerText(nil) -> engine drawTextArea indexed a nil `self` (BattleState.lua:5557). Define `local battle = self` and forward `innerText(self)`. Co-Authored-By: Hermes Agent --- lib/OverworldBattle.lua | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/OverworldBattle.lua b/lib/OverworldBattle.lua index cbf1631..946fe36 100644 --- a/lib/OverworldBattle.lua +++ b/lib/OverworldBattle.lua @@ -1246,6 +1246,7 @@ function OverworldBattle.install() function BattleState:drawTextArea() if not self.dramaticShapeShot then return innerText(self) end if isIOS() then return innerText(self) end + local battle = self -- Every battle box -- the dialogue, the FIGHT/ITEM/RUN menu and the -- move-select / type panels -- is drawn as an OPAQUE WHITE panel with -- BLACK ink, the same as the overworld / trainer-intro textbox. Black ink From 98d43dc0193d2128ccfc890858e6918c44fe8464 Mon Sep 17 00:00:00 2001 From: absol89 Date: Thu, 6 Aug 2026 10:03:05 +0200 Subject: [PATCH 107/116] fix(ios): port PR #119 HUD fix -- opaque-white player/opponent panels On iOS the snapped HUD path was skipped (it drew upside-down + opaque), leaving semi-transparent (0.84) in-frame panels. Port the upstream PR #119 correction for our fork: - snapHUDs now runs on iOS: the player/opponent HUDs get OPAQUE WHITE backplates (BattleHud.panel is frosted-only and no-ops at FROST/TINT 0, so a plain white fill is the right primitive) and the HUD layer is blitted with the vertical-flip that corrects iOS's upside-down Canvas-to-Canvas presentation. The text box's backing is skipped on iOS (it would ghost upward), so the box stays as the engine's own opaque-white panel. - HUD glyphs on iOS stay BLACK (dark=false) to read on white; the WHITE arena fill still inverts to black ink + white drop-shadow. - drawHudPanels iOS fallback draws opaque white (1,1,1,1) instead of 0.84. - The dialogue/action/move/type boxes keep the engine's native opaque-white panel with black glyphs on iOS (drawTextArea returns early there). Desktop (Windows/Android) is unchanged. Co-Authored-By: Hermes Agent --- lib/OverworldBattle.lua | 86 +++++++++++++++++++++++++++-------------- 1 file changed, 57 insertions(+), 29 deletions(-) diff --git a/lib/OverworldBattle.lua b/lib/OverworldBattle.lua index 946fe36..9b22483 100644 --- a/lib/OverworldBattle.lua +++ b/lib/OverworldBattle.lua @@ -635,13 +635,15 @@ function OverworldBattle.update(dt) -- treatment is pure white ink plus a dark one-pixel shadow. local ios = isIOS() local okHud, up = false, false - if not ios then - okHud, up = pcall(OverworldBattle.snapHUDs, session.battle, shot) - end + -- iOS now runs the snapped HUD path too (opaque-white backplates + the + -- vertical-flip blit that corrects the upside-down Canvas-to-Canvas + -- presentation). It was previously skipped because the old frosted path + -- drew upside-down and opaque; the corrected path is fine. + okHud, up = pcall(OverworldBattle.snapHUDs, session.battle, shot) session.snapped = (okHud and up) and true or false -- once per battle, not once per frame: a driver that cannot do this cannot -- do it sixty times a second either, and the fallback is silent and fine - if not ios and not okHud and not session.hudWarned then + if not okHud and not session.hudWarned then session.hudWarned = true V.mod.log:warn("overworld battle HUD snap failed: %s -- the HUDs draw " .. "in the battle frame this battle", tostring(up)) @@ -1431,25 +1433,31 @@ function OverworldBattle.snapHUDs(battle, shot) if not (battle and shot and shot.canvas and (shot.scale or 0) > 0) then return false end + local ios = isIOS() local slide = (battle.introSlide or 0) * 4 local rects, bandPlacement = OverworldBattle.snapRects(shot) local enemy, player = OverworldBattle.hudLive(battle, slide) local live = {} if enemy then live.enemy = rects.enemy end if player then live.player = rects.player end - -- and the text box's own glass, on the same pass. It stays in the middle of - -- the frame where the engine draws it -- only the HUDs were snapped out -- - -- so its GB rect is mapped into the letterbox rather than to an edge. - for key, rect in pairs(OverworldBattle.textRects(battle)) do - live[key] = toWorld(rect, shot) + -- On iOS the text box's frosted panel is mirrored upward by the + -- Canvas-to-Canvas path, producing a large ghost rectangle behind the + -- mons, so only the box's backing is skipped there -- the box itself stays + -- in the GB frame as the engine's own opaque-white panel (black glyphs), + -- which is exactly the look we want. On every other platform the box's glass + -- goes into this same world-canvas pass. + if not ios then + for key, rect in pairs(OverworldBattle.textRects(battle)) do + live[key] = toWorld(rect, shot) + end end - -- Transparent backplates need one stable ink treatment through every phase - -- of battle. Always whiten engine ink and add BattleHud's dark one-pixel - -- shadow, UNLESS the WHITE arena fill is on -- then the box is plain black - -- ink with a WHITE drop-shadow (inverted), so it reads on the white field. - local whiteInk = not UiBackplates.arenaWhite() - if session then session.dark = whiteInk end - local layer = OverworldBattle.hudTexture(battle, slide, whiteInk, + -- Ink treatment: the desktop path whitens engine ink over the frosted/dark + -- panel; on iOS the HUDs sit on OPAQUE WHITE backplates, so the glyphs stay + -- BLACK (dark = false) to read on white. The WHITE arena fill still inverts + -- to black ink + white drop-shadow on both. + local dark = ios and false or (not UiBackplates.arenaWhite()) + if session then session.dark = dark end + local layer = OverworldBattle.hudTexture(battle, slide, dark, UiBackplates.arenaWhite()) if not layer then return false end @@ -1459,22 +1467,39 @@ function OverworldBattle.snapHUDs(battle, shot) local ok, err = pcall(function() g.setCanvas(shot.canvas) g.setBlendMode("alpha") - for key, rect in pairs(live) do - BattleHud.panel(rect, shot, whiteInk, true) - -- TEXTBOX FILL: the dialogue box's backplate is the engine's own - -- Font.drawBox fill, drawn in SCREEN space and docked to the window - -- edge -- so it already tracks the box at every aspect ratio. Drawing - -- our own slab here, in the GB canvas at a static rect, could never - -- follow a window-docked box (they only coincide at ~1:1), so we use - -- the engine's box as the backplate and do not paint over it. + if ios then + -- OPAQUE WHITE backplates for the player / opponent HUDs (BattleHud.panel + -- is frosted-only and returns early when FROST/TINT are 0, so a plain + -- white fill is the right primitive here). The text box is not in `live` + -- on iOS -- its backing is the engine's own white panel. + g.setColor(1, 1, 1, 1) + for _, rect in pairs(live) do + g.rectangle("fill", rect[1], rect[2], rect[3], rect[4]) + end + else + for key, rect in pairs(live) do + BattleHud.panel(rect, shot, dark, true) + end end g.setColor(1, 1, 1, 1) for side, band in pairs(OverworldBattle.HUD_BAND) do local placement = bandPlacement[side] local quad = g.newQuad(band[1], band[2], band[3], band[4], BattleScene.GB_W, BattleScene.GB_H) - g.draw(layer, quad, placement.x + band[1] * placement.scale, - placement.y, 0, placement.scale, placement.scale) + if ios then + -- iOS presents this Canvas-to-Canvas HUD texture upside down, so flip + -- it vertically. The player HUD stays on the right; only the enemy + -- band's mirrored destination is corrected. + local y = placement.y + if side == "enemy" then + y = shot.ph - placement.y - band[4] * placement.scale + end + g.draw(layer, quad, placement.x + band[1] * placement.scale, y, 0, + placement.scale, -placement.scale) + else + g.draw(layer, quad, placement.x + band[1] * placement.scale, + placement.y, 0, placement.scale, placement.scale) + end end end) if prevCanvas then g.setCanvas(prevCanvas) else g.setCanvas() end @@ -1496,12 +1521,15 @@ function OverworldBattle.drawHudPanels(battle) battle.dramaticShapeDark = nil if not shot then return end if isIOS() then - -- iOS cannot run the snapped HUD path (it draws upside-down + opaque); - -- fall back to the engine's own opaque HUD panels. Mirrors upstream PR #48. + -- iOS runs the snapped HUD path (snapHUDs) for the window-edge panels with + -- opaque-white backplates and the vertical-flip blit. This in-frame branch + -- is the fallback when that composite could not be made; draw OPAQUE WHITE + -- HUD backplates here too so the player/opponent panels still read over the + -- diorama. Mirrors the corrected iOS HUD from upstream PR #119. local slide = (battle.introSlide or 0) * 4 local enemy, player = OverworldBattle.hudLive(battle, slide) local rect = OverworldBattle.HUD_RECT - love.graphics.setColor(1, 1, 1, 0.84) + love.graphics.setColor(1, 1, 1, 1) if enemy then love.graphics.rectangle("fill", rect.enemy[1], rect.enemy[2], rect.enemy[3], rect.enemy[4]) end if player then love.graphics.rectangle("fill", rect.player[1], rect.player[2], rect.player[3], rect.player[4]) end love.graphics.setColor(1, 1, 1, 1) From 6932be8cff43002a098bcdecdf6247d7945ba373 Mon Sep 17 00:00:00 2001 From: absol89 Date: Thu, 6 Aug 2026 10:32:02 +0200 Subject: [PATCH 108/116] chore(release): set mod.exports.version to 1.7.2 to match manifest Aligns the runtime version string with manifest.json (1.7.2) for the 1.7.2 release tag. Co-Authored-By: Hermes Agent --- main.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.lua b/main.lua index 773e07a..07cbb54 100644 --- a/main.lua +++ b/main.lua @@ -1031,7 +1031,7 @@ mod.hooks:wrap("world.tod", function(next, tod, ctx) return DayNight.tod() end) -mod.exports.version = "1.72.0" +mod.exports.version = "1.7.2" -- exposed so a companion mod can pin its own tiles' shapes or read the -- camera without reaching into this mod's file layout mod.exports.lib = V From 23e97f0bd41055e96ca4e57fa43fd5ee62a6cbf1 Mon Sep 17 00:00:00 2001 From: absol89 Date: Thu, 6 Aug 2026 11:38:41 +0200 Subject: [PATCH 109/116] fix(ios): make player/opponent HUDs transparent (PR #119), keep boxes white The iOS HUD path drew a plain opaque-white g.rectangle over the full snapped HUD band (HUD_RECT), which covered the whole top and bottom-right of the screen -- including empty diorama where no UI belongs -- producing the white cards reported by iOS users. Remove those fills: the HUD glyphs now blit straight onto the diorama (transparent, matching upstream PR #119). Text boxes (dialogue, action-select, move-list, type, yes/no, move-learn) stay opaque white because they are the engine's own Font.drawBox, which drawTextArea preserves on iOS. HUD glyphs keep the engine's native white ink (dark = true) to read over the scene. Desktop (Windows/Android) is unchanged. Co-Authored-By: Hermes Agent --- lib/OverworldBattle.lua | 41 +++++++++++++++++------------------------ 1 file changed, 17 insertions(+), 24 deletions(-) diff --git a/lib/OverworldBattle.lua b/lib/OverworldBattle.lua index 9b22483..9f0d136 100644 --- a/lib/OverworldBattle.lua +++ b/lib/OverworldBattle.lua @@ -1452,10 +1452,10 @@ function OverworldBattle.snapHUDs(battle, shot) end end -- Ink treatment: the desktop path whitens engine ink over the frosted/dark - -- panel; on iOS the HUDs sit on OPAQUE WHITE backplates, so the glyphs stay - -- BLACK (dark = false) to read on white. The WHITE arena fill still inverts - -- to black ink + white drop-shadow on both. - local dark = ios and false or (not UiBackplates.arenaWhite()) + -- panel; on iOS the HUDs are now TRANSPARENT (PR #119), so the glyphs keep + -- the engine's native white ink (dark = true) to read over the diorama. The + -- WHITE arena fill still inverts to black ink + white drop-shadow on both. + local dark = (not ios) and (not UiBackplates.arenaWhite()) or true if session then session.dark = dark end local layer = OverworldBattle.hudTexture(battle, slide, dark, UiBackplates.arenaWhite()) @@ -1468,14 +1468,12 @@ function OverworldBattle.snapHUDs(battle, shot) g.setCanvas(shot.canvas) g.setBlendMode("alpha") if ios then - -- OPAQUE WHITE backplates for the player / opponent HUDs (BattleHud.panel - -- is frosted-only and returns early when FROST/TINT are 0, so a plain - -- white fill is the right primitive here). The text box is not in `live` - -- on iOS -- its backing is the engine's own white panel. - g.setColor(1, 1, 1, 1) - for _, rect in pairs(live) do - g.rectangle("fill", rect[1], rect[2], rect[3], rect[4]) - end + -- Transparent HUDs on iOS (matches upstream PR #119): the player / + -- opponent HUD glyphs are blitted straight onto the diorama with no + -- backplate, so no white card bleeds to the window edges. The text box / + -- action-select / move-list / type / yes-no / move-learn boxes are the + -- engine's own Font.drawBox (opaque white), which drawTextArea keeps on + -- iOS, so those stay white per the spec. else for key, rect in pairs(live) do BattleHud.panel(rect, shot, dark, true) @@ -1521,18 +1519,13 @@ function OverworldBattle.drawHudPanels(battle) battle.dramaticShapeDark = nil if not shot then return end if isIOS() then - -- iOS runs the snapped HUD path (snapHUDs) for the window-edge panels with - -- opaque-white backplates and the vertical-flip blit. This in-frame branch - -- is the fallback when that composite could not be made; draw OPAQUE WHITE - -- HUD backplates here too so the player/opponent panels still read over the - -- diorama. Mirrors the corrected iOS HUD from upstream PR #119. - local slide = (battle.introSlide or 0) * 4 - local enemy, player = OverworldBattle.hudLive(battle, slide) - local rect = OverworldBattle.HUD_RECT - love.graphics.setColor(1, 1, 1, 1) - if enemy then love.graphics.rectangle("fill", rect.enemy[1], rect.enemy[2], rect.enemy[3], rect.enemy[4]) end - if player then love.graphics.rectangle("fill", rect.player[1], rect.player[2], rect.player[3], rect.player[4]) end - love.graphics.setColor(1, 1, 1, 1) + -- iOS HUDs are transparent: the snapped path (snapHUDs) blits the + -- player/opponent glyphs straight onto the diorama with no backplate + -- (matching upstream PR #119), so there is nothing to lay down here. The + -- text box / action-select / move-list / type / yes-no / move-learn boxes + -- remain opaque white because they are the engine's own Font.drawBox, which + -- drawTextArea preserves on iOS. This in-frame branch is only the fallback + -- when the snapped composite could not be made. return end if snapped() then From cbff997f6452eac97a2fcb86181c600cc17d97b1 Mon Sep 17 00:00:00 2001 From: absol89 Date: Thu, 6 Aug 2026 11:40:03 +0200 Subject: [PATCH 110/116] chore(release): bump version to 1.7.3 (iOS transparent HUD fix) - manifest.json version 1.7.2 -> 1.7.3 - main.lua mod.exports.version 1.7.2 -> 1.7.3 Ships the iOS player/opponent HUD transparency fix (23e97f0). Co-Authored-By: Hermes Agent --- main.lua | 2 +- manifest.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/main.lua b/main.lua index 07cbb54..920eb63 100644 --- a/main.lua +++ b/main.lua @@ -1031,7 +1031,7 @@ mod.hooks:wrap("world.tod", function(next, tod, ctx) return DayNight.tod() end) -mod.exports.version = "1.7.2" +mod.exports.version = "1.7.3" -- exposed so a companion mod can pin its own tiles' shapes or read the -- camera without reaching into this mod's file layout mod.exports.lib = V diff --git a/manifest.json b/manifest.json index 564dfbf..3e868d9 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "BATTLE_ART_VOXEL_FORK", "name": "BATTLE ART VOXEL FORK", - "version": "1.7.2", + "version": "1.7.3", "api": 2, "entry": "main.lua", "profile": "content", From 7f5f03e571fc3f01334d5e2bcab8032e217da28b Mon Sep 17 00:00:00 2001 From: absol89 Date: Thu, 6 Aug 2026 18:43:42 +0200 Subject: [PATCH 111/116] fix(ios): correct HUD vertical-flip to flip in place (was center-mirrored) The iOS HUD blit used y = shot.ph - placement.y - band[4]*scale, which mirrored each HUD band about the screen's vertical center, dropping the opponent/player HUD down into the diorama and under the text box. Flip the upside-down Canvas-to-Canvas texture IN PLACE instead (y = placement.y + band[4]*placement.scale) so each band stays in its correct screen rectangle with upright glyphs -- matching the desktop anchor. Bump to 1.7.4. Co-Authored-By: Hermes Agent --- lib/OverworldBattle.lua | 11 +++++------ main.lua | 2 +- manifest.json | 2 +- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/lib/OverworldBattle.lua b/lib/OverworldBattle.lua index 9f0d136..e6b8c3b 100644 --- a/lib/OverworldBattle.lua +++ b/lib/OverworldBattle.lua @@ -1486,12 +1486,11 @@ function OverworldBattle.snapHUDs(battle, shot) BattleScene.GB_W, BattleScene.GB_H) if ios then -- iOS presents this Canvas-to-Canvas HUD texture upside down, so flip - -- it vertically. The player HUD stays on the right; only the enemy - -- band's mirrored destination is corrected. - local y = placement.y - if side == "enemy" then - y = shot.ph - placement.y - band[4] * placement.scale - end + -- it vertically IN PLACE (about the band's own center) to keep the + -- band in its correct screen rectangle with upright glyphs. The earlier + -- mirror (shot.ph - placement.y - ...) moved each band to the opposite + -- vertical half, dropping the HUD into the diorama / under the text box. + local y = placement.y + band[4] * placement.scale g.draw(layer, quad, placement.x + band[1] * placement.scale, y, 0, placement.scale, -placement.scale) else diff --git a/main.lua b/main.lua index 920eb63..6b7cea8 100644 --- a/main.lua +++ b/main.lua @@ -1031,7 +1031,7 @@ mod.hooks:wrap("world.tod", function(next, tod, ctx) return DayNight.tod() end) -mod.exports.version = "1.7.3" +mod.exports.version = "1.7.4" -- exposed so a companion mod can pin its own tiles' shapes or read the -- camera without reaching into this mod's file layout mod.exports.lib = V diff --git a/manifest.json b/manifest.json index 3e868d9..b252c49 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "BATTLE_ART_VOXEL_FORK", "name": "BATTLE ART VOXEL FORK", - "version": "1.7.3", + "version": "1.7.4", "api": 2, "entry": "main.lua", "profile": "content", From 71c800eb143ee3aee49126d675c488282972d3c9 Mon Sep 17 00:00:00 2001 From: absol89 Date: Thu, 6 Aug 2026 19:46:04 +0200 Subject: [PATCH 112/116] fix(ios): revert snapped HUD on iOS; use engine-native in-frame HUD The Canvas-to-Canvas HUD blit is presented upside-down on iOS, and no single flip formula places both bands correctly (enemy [0,48] and player [48,96] are not symmetric about the 144px canvas center). 1.7.3 center-mirrored; 1.7.4 in-place fixed the player band but dropped the enemy to the bottom. Revert to the engine's own in-frame HUD on iOS, which the engine positions correctly. drawHudPanels still elides the backplate there, so the HUD stays transparent (no white cards). Text boxes remain opaque white (engine Font.drawBox). Desktop unchanged. Bump to 1.7.5. Co-Authored-By: Hermes Agent --- lib/OverworldBattle.lua | 57 ++++++++++++++++------------------------- main.lua | 2 +- manifest.json | 2 +- 3 files changed, 24 insertions(+), 37 deletions(-) diff --git a/lib/OverworldBattle.lua b/lib/OverworldBattle.lua index e6b8c3b..e62e266 100644 --- a/lib/OverworldBattle.lua +++ b/lib/OverworldBattle.lua @@ -635,15 +635,23 @@ function OverworldBattle.update(dt) -- treatment is pure white ink plus a dark one-pixel shadow. local ios = isIOS() local okHud, up = false, false - -- iOS now runs the snapped HUD path too (opaque-white backplates + the - -- vertical-flip blit that corrects the upside-down Canvas-to-Canvas - -- presentation). It was previously skipped because the old frosted path - -- drew upside-down and opaque; the corrected path is fine. - okHud, up = pcall(OverworldBattle.snapHUDs, session.battle, shot) + -- iOS cannot run the snapped HUD path: the HUD texture is rendered into a + -- scratch canvas and blitted back, and on iOS that Canvas-to-Canvas blit is + -- presented upside-down. Every flip formula tried (center-mirror in 1.7.3, + -- in-place in 1.7.4) mis-places one of the two bands, because the enemy + -- band [0,48] and player band [48,96] are not symmetric about the 144px + -- canvas center, so a single flip expression cannot land both correctly. + -- Fall back to the engine's own in-frame HUD on iOS, which the engine + -- positions correctly; drawHudPanels already elides the backplate there, so + -- the HUD stays transparent. The text boxes are the engine's Font.drawBox + -- (opaque white) and unaffected. + if not ios then + okHud, up = pcall(OverworldBattle.snapHUDs, session.battle, shot) + end session.snapped = (okHud and up) and true or false -- once per battle, not once per frame: a driver that cannot do this cannot -- do it sixty times a second either, and the fallback is silent and fine - if not okHud and not session.hudWarned then + if not ios and not okHud and not session.hudWarned then session.hudWarned = true V.mod.log:warn("overworld battle HUD snap failed: %s -- the HUDs draw " .. "in the battle frame this battle", tostring(up)) @@ -1451,11 +1459,10 @@ function OverworldBattle.snapHUDs(battle, shot) live[key] = toWorld(rect, shot) end end - -- Ink treatment: the desktop path whitens engine ink over the frosted/dark - -- panel; on iOS the HUDs are now TRANSPARENT (PR #119), so the glyphs keep - -- the engine's native white ink (dark = true) to read over the diorama. The - -- WHITE arena fill still inverts to black ink + white drop-shadow on both. - local dark = (not ios) and (not UiBackplates.arenaWhite()) or true + -- Ink treatment: whiten engine ink over the frosted/dark panel unless the + -- WHITE arena fill is on (then plain black ink + white drop-shadow). iOS does + -- not reach this function (it uses the engine's own in-frame HUD); see update(). + local dark = not UiBackplates.arenaWhite() if session then session.dark = dark end local layer = OverworldBattle.hudTexture(battle, slide, dark, UiBackplates.arenaWhite()) @@ -1467,36 +1474,16 @@ function OverworldBattle.snapHUDs(battle, shot) local ok, err = pcall(function() g.setCanvas(shot.canvas) g.setBlendMode("alpha") - if ios then - -- Transparent HUDs on iOS (matches upstream PR #119): the player / - -- opponent HUD glyphs are blitted straight onto the diorama with no - -- backplate, so no white card bleeds to the window edges. The text box / - -- action-select / move-list / type / yes-no / move-learn boxes are the - -- engine's own Font.drawBox (opaque white), which drawTextArea keeps on - -- iOS, so those stay white per the spec. - else - for key, rect in pairs(live) do - BattleHud.panel(rect, shot, dark, true) - end + for key, rect in pairs(live) do + BattleHud.panel(rect, shot, dark, true) end g.setColor(1, 1, 1, 1) for side, band in pairs(OverworldBattle.HUD_BAND) do local placement = bandPlacement[side] local quad = g.newQuad(band[1], band[2], band[3], band[4], BattleScene.GB_W, BattleScene.GB_H) - if ios then - -- iOS presents this Canvas-to-Canvas HUD texture upside down, so flip - -- it vertically IN PLACE (about the band's own center) to keep the - -- band in its correct screen rectangle with upright glyphs. The earlier - -- mirror (shot.ph - placement.y - ...) moved each band to the opposite - -- vertical half, dropping the HUD into the diorama / under the text box. - local y = placement.y + band[4] * placement.scale - g.draw(layer, quad, placement.x + band[1] * placement.scale, y, 0, - placement.scale, -placement.scale) - else - g.draw(layer, quad, placement.x + band[1] * placement.scale, - placement.y, 0, placement.scale, placement.scale) - end + g.draw(layer, quad, placement.x + band[1] * placement.scale, + placement.y, 0, placement.scale, placement.scale) end end) if prevCanvas then g.setCanvas(prevCanvas) else g.setCanvas() end diff --git a/main.lua b/main.lua index 6b7cea8..6e05ba8 100644 --- a/main.lua +++ b/main.lua @@ -1031,7 +1031,7 @@ mod.hooks:wrap("world.tod", function(next, tod, ctx) return DayNight.tod() end) -mod.exports.version = "1.7.4" +mod.exports.version = "1.7.5" -- exposed so a companion mod can pin its own tiles' shapes or read the -- camera without reaching into this mod's file layout mod.exports.lib = V diff --git a/manifest.json b/manifest.json index b252c49..8e40d91 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "BATTLE_ART_VOXEL_FORK", "name": "BATTLE ART VOXEL FORK", - "version": "1.7.4", + "version": "1.7.5", "api": 2, "entry": "main.lua", "profile": "content", From 12594a14e46c4eff181bcc8ad6ed6d72ad72320e Mon Sep 17 00:00:00 2001 From: absol89 Date: Fri, 7 Aug 2026 01:17:53 +0200 Subject: [PATCH 113/116] feat(1.7.6): ROM-fallback white-fill fix, per-slot SHINY support, gen1 sprites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OverworldBattle.BattleState:picImage now returns the sprite early for both external and ROM images, skipping BattlePics.filled (the opaque-white {1,1,1,1} filler). Transparent gaps in ROM sprites now show through instead of being painted white. Mode-agnostic (static + animated, species + player). - Per-slot SHINY options "FRONT SHINY FIX" / "BACK SHINY FIX" (species only; players can never be shiny). When ON, the resolver prefers the shiny/ folder and, on a missing shiny file, falls back to ROM -- never to the selected generation's normal art and never to player.png. Animated gens (front 2-5, back 3/5) are forced through the single-image shinyPrefix path so their atlases are suppressed and do not play over a shiny mod's sprite. - gen1 compatibility sets: back-static/gen1 (Yellow GBC backs) and front-animated/gen1 (Yellow GBC fronts), 151 each, with provenance READMEs. - Empty shiny/ folders (front/back x static/animated) ship with READMEs so shiny mods have a documented drop target. - Ported upstream PRs (lib hunks only, tests/VR stripped): #125 flat-top meshing fix, #75 LÖVE 12 ShadowMap z-clip, #79 1ST label rename. Co-Authored-By: Hermes Agent --- assets/battle/back-animated/shiny/README.md | 7 ++ assets/battle/back-static/gen1/README.md | 26 ++-- assets/battle/back-static/shiny/README.md | 6 + assets/battle/front-animated/gen1/README.md | 56 ++++----- assets/battle/front-animated/shiny/README.md | 7 ++ assets/battle/front-static/shiny/README.md | 6 + lib/AnimatedBattleArt.lua | 48 +++++++- lib/BattleArt.lua | 40 +++++-- lib/ChunkMesher.lua | 23 +++- lib/OverworldBattle.lua | 4 + lib/ShadowMap.lua | 120 +++++++++++++++++-- lib/Structures.lua | 38 ++++++ lib/VoxelState.lua | 2 +- main.lua | 16 ++- manifest.json | 2 +- 15 files changed, 337 insertions(+), 64 deletions(-) create mode 100644 assets/battle/back-animated/shiny/README.md create mode 100644 assets/battle/back-static/shiny/README.md create mode 100644 assets/battle/front-animated/shiny/README.md create mode 100644 assets/battle/front-static/shiny/README.md diff --git a/assets/battle/back-animated/shiny/README.md b/assets/battle/back-animated/shiny/README.md new file mode 100644 index 0000000..d69052d --- /dev/null +++ b/assets/battle/back-animated/shiny/README.md @@ -0,0 +1,7 @@ +Keep this folder empty. + +It exists so shiny-compatible mods have a stable place to drop their +animated back-art atlases (gen3/gen5). When the SHINY option is enabled +for back art, this folder is checked first; if a species file is absent +here it falls back to the selected generation's normal back art, and +then to ROM. diff --git a/assets/battle/back-static/gen1/README.md b/assets/battle/back-static/gen1/README.md index 735fbc7..41be714 100644 --- a/assets/battle/back-static/gen1/README.md +++ b/assets/battle/back-static/gen1/README.md @@ -1,11 +1,23 @@ # Generation 1 static back sprites -Source reference: [Pokémon Yellow Super Game Boy back sprites — Bulbagarden Archives](https://archives.bulbagarden.net/wiki/Category:Yellow_back_sprites_(Super_Game_Boy)) +These 151 back sprites are the **International Yellow (Game Boy Color) back +sprites** ripped from Pokémon Yellow (international release, GBC). -```powershell -python tools/import_yellow_sgb_back_sprites.py --root . -``` +Source: [International Yellow back sprites (Game Boy Color) — Bulbagarden +Archives](https://archives.bulbagarden.net/wiki/Category:International_Yellow_back_sprites_(Game_Boy_Color)) -The importer writes the first 151 source PNGs here. Artwork is ignored by Git -and is not covered by the mod's MIT license. Verify that you have the right to -use and distribute it. +The files were fetched from the Bulbagarden archive file path for each species +in National Dex order (001 Bulbasaur … 151 Mew), e.g. +`Spr_b_1y_001.png` … `Spr_b_1y_151.png`, and renamed to the lowercase species +slugs used across the battle-art folders (`bulbasaur.png`, `mr-mime.png`, +`farfetchd.png`, etc.). + +They are used as the default `BACK ART: GEN 1` static back set, and as the +back fallback when `BACK SHINY FIX` is enabled and no shiny override is present. + +## Licensing / provenance + +The Pokémon sprite artwork is third-party and is **not** covered by this mod's +MIT license. It is ignored by Git (see the repo `.gitignore`) and must be +supplied locally; verify that you have the right to use and redistribute it +before shipping a build that includes these files. diff --git a/assets/battle/back-static/shiny/README.md b/assets/battle/back-static/shiny/README.md new file mode 100644 index 0000000..cb115b0 --- /dev/null +++ b/assets/battle/back-static/shiny/README.md @@ -0,0 +1,6 @@ +Keep this folder empty. + +It exists so shiny-compatible mods have a stable place to drop their +back-art sprites. When the SHINY option is enabled for back art, this +folder is checked first; if a species file is absent here it falls back +to the selected generation's normal back art, and then to ROM. diff --git a/assets/battle/front-animated/gen1/README.md b/assets/battle/front-animated/gen1/README.md index 3dc4a1a..a6f52b4 100644 --- a/assets/battle/front-animated/gen1/README.md +++ b/assets/battle/front-animated/gen1/README.md @@ -1,35 +1,31 @@ # Generation 1 front compatibility set -This folder supplies single-frame Gen 1, Super Game Boy, and ROM-hack front -sprites for `BATTLE ART: ANIMATED` with `ANIM FRONT GEN: GEN 1`. Unlike -the Gen 2–5 collections, these are ordinary PNGs rather than animation -atlases: no Lua sidecar, frame grid, or timing metadata is required. - -Name one PNG per species using the same lowercase names as the other -battle-art folders, for example `pikachu.png`, `farfetchd.png`, -`mr-mime.png`, `nidoran-f.png`, and `nidoran-m.png`. Author opponent art -facing left. Missing or malformed species should fall back directly to the -ROM front sprite rather than borrow from another generation. - -The Pokémon images are independent of the player-trainer introduction. A -five-frame player strip selected by `PLAYER ANIM` can therefore continue to -play while Pokémon fronts from this folder remain single-frame. - -## Placement - -The intended `FRONT PLACEMENT` choices are: - -- `AUTO` — world placement during staged battles. -- `WORLD` — a world card with depth occlusion, alpha-shaped shadow, battle - effects, and display filtering. -- `OG UI` — the original fixed battle slot and its UI-layer movement/effects. - -Large or unusually padded art may crop when forced into `OG UI`; native -Game Boy and Super Game Boy dimensions are the natural fit there. +These 151 front sprites are the **International Yellow (Game Boy Color) front +sprites** ripped from Pokémon Yellow (international release, GBC). + +Source: [International Yellow sprites (Game Boy Color) — Bulbagarden +Archives](https://archives.bulbagarden.net/wiki/Category:International_Yellow_sprites_(Game_Boy_Color)) + +The files were fetched from the Bulbagarden archive file path for each species +in National Dex order (001 Bulbasaur … 151 Mew), e.g. +`Spr_1y_001.png` … `Spr_1y_151.png`, and renamed to the lowercase species +slugs used across the battle-art folders (`bulbasaur.png`, `mr-mime.png`, +`farfetchd.png`, etc.). + +Unlike the Gen 2–5 collections, these are ordinary single-frame PNGs rather +than animation atlases: no Lua sidecar, frame grid, or timing metadata is +required. They are used as the default `ANIM FRONT GEN: GEN 1` front set, and +as the front fallback when `FRONT SHINY FIX` is enabled and no shiny override +is present. Author opponent art facing left. Missing or malformed species fall +back directly to the ROM front sprite rather than borrowing from another +generation. Front Pokémon remain world-placed in the current renderer. A separate -`FRONT PLACEMENT` selector is not implemented yet; the placement list above -documents the intended follow-up behavior rather than a current menu row. +`FRONT PLACEMENT` selector is not implemented yet. + +## Licensing / provenance -Local artwork in this folder is ignored by Git and is not covered by the -mod's MIT license. Verify that you have the right to use and distribute it. +The Pokémon sprite artwork is third-party and is **not** covered by this mod's +MIT license. It is ignored by Git (see the repo `.gitignore`) and must be +supplied locally; verify that you have the right to use and redistribute it +before shipping a build that includes these files. diff --git a/assets/battle/front-animated/shiny/README.md b/assets/battle/front-animated/shiny/README.md new file mode 100644 index 0000000..1df5050 --- /dev/null +++ b/assets/battle/front-animated/shiny/README.md @@ -0,0 +1,7 @@ +Keep this folder empty. + +It exists so shiny-compatible mods have a stable place to drop their +animated front-art atlases (gen1-5). When the SHINY option is enabled +for front art, this folder is checked first; if a species file is absent +here it falls back to the selected generation's normal front art, and +then to ROM. diff --git a/assets/battle/front-static/shiny/README.md b/assets/battle/front-static/shiny/README.md new file mode 100644 index 0000000..4bbd7ff --- /dev/null +++ b/assets/battle/front-static/shiny/README.md @@ -0,0 +1,6 @@ +Keep this folder empty. + +It exists so shiny-compatible mods have a stable place to drop their +front-art sprites. When the SHINY option is enabled for front art, this +folder is checked first; if a species file is absent here it falls back +to the selected generation's normal front art, and then to ROM. diff --git a/lib/AnimatedBattleArt.lua b/lib/AnimatedBattleArt.lua index 1e02d56..4a76725 100644 --- a/lib/AnimatedBattleArt.lua +++ b/lib/AnimatedBattleArt.lua @@ -222,6 +222,26 @@ local function restore(battler) states[battler] = nil end +local function shinyDefFor(def, side) + if not def or not def.image then return def end + -- Prepend shiny/ into the animated folder: front-animated -> front-animated/shiny + local shiny = def.image:gsub("^(assets/battle/(front|back)-animated)/", + "%1/shiny/") + if shiny == def.image then return def end + local path = V.mod.assets:path(shiny) + local fs = love and love.filesystem + if not (fs and fs.getInfo and fs.getInfo(path)) then + -- SHINY is ON but this species has no shiny atlas: do NOT fall back to the + -- normal generation's art (that is the very sprite we are hiding). Return + -- nil so updateBattler retains the ROM sprite instead. + return nil + end + local copy = {} + for k, v in pairs(def) do copy[k] = v end + copy.image = shiny + return copy +end + local function definition(battler, side) local species = battler and battler.mon and battler.mon.species local key = species and tostring(species):upper() @@ -230,7 +250,15 @@ local function definition(battler, side) local collections = side == "back" and BACK_SETS or SETS local selected = collections[setting:get()] local bySide = selected and key and selected[key] - return bySide and bySide[side] or nil + local def = bySide and bySide[side] or nil + -- SHINY compatibility (species only): when the slot's option is on, prefer + -- the shiny atlas; if it is absent fall back to the normal generation's art + -- (handled by returning the normal def, which then resolves or ROMs). + if def and ((side == "back" and BattleArt.backShinySetting:get() == "on") + or (side == "front" and BattleArt.frontShinySetting:get() == "on")) then + def = shinyDefFor(def, side) + end + return def end local function updateBattler(battler, side, dt, mode) @@ -317,6 +345,15 @@ local function updateStaticFront(battler, generation, mode) end local function updateFront(battler, generation, dt, mode) + -- FRONT SHINY ON overrides the generation entirely: route every front + -- generation through the single-image shinyPrefix path (the same path gen1 + -- already uses) so the selected generation's animated atlas is suppressed + -- and only the shiny folder (or ROM) shows -- never the normal gen sprite + -- playing over a shiny mod's sprite. + if BattleArt.frontShinySetting:get() == "on" then + updateStaticFront(battler, generation, mode) + return + end if FRONT_SOURCE_KIND[generation] == "static" then updateStaticFront(battler, generation, mode) else @@ -340,7 +377,14 @@ function AnimatedBattleArt.update(battle, dt) local playerSide = BattleArt.playerSide() if playerSide == "back" then local generation = BattleArt.backAnimationSetting:get() - if BACK_SOURCE_KIND[generation] == "animated" then + -- BACK SHINY ON overrides the generation entirely: route every back + -- generation through the single-image shinyPrefix path so the selected + -- generation's animated atlas (gen3/gen5) is suppressed and only the + -- shiny folder (or ROM) shows -- never the normal gen sprite playing + -- over a shiny mod's sprite. + if BattleArt.backShinySetting:get() == "on" then + updateStaticBack(battle.player, generation, mode) + elseif BACK_SOURCE_KIND[generation] == "animated" then updateBattler(battle.player, "back", dt, mode) else updateStaticBack(battle.player, generation, mode) diff --git a/lib/BattleArt.lua b/lib/BattleArt.lua index 3bc3a83..1898444 100644 --- a/lib/BattleArt.lua +++ b/lib/BattleArt.lua @@ -32,6 +32,15 @@ BattleArt.playerAnimationSetting = ModSetting.new( "playerAnimatedSet", "PLAYER ANIM", { "png", "gen1", "gen2", "gen3", "gen4", "gen5", "ash", "gary", "red", "rom" }, { "PNG", "GEN 1", "GEN 2", "GEN 3", "GEN 4", "GEN 5", "ASH", "GARY", "RED", "ROM" }, 9) +-- SHINY compatibility for species art only (players can never be shiny). +-- When ON, front/back resolvers check the matching assets/battle/-/ +-- shiny/ folder first; a missing shiny file falls back to the selected +-- generation's normal art, then to ROM. The folders ship empty so a shiny mod +-- can populate them. +BattleArt.frontShinySetting = ModSetting.new("frontShiny", "FRONT SHINY FIX", + { "off", "on" }, { "OFF", "ON" }) +BattleArt.backShinySetting = ModSetting.new("backShiny", "BACK SHINY FIX", + { "off", "on" }, { "OFF", "ON" }) -- BATTLE ART: ROM owns the normal player portrait as completely as it owns -- species art. Keep the visible PLAYER ART row honest instead of leaving a @@ -67,6 +76,15 @@ function BattleArt.playerSide() return BattleArt.viewSetting:get() == "back" and "back" or "front" end +local function shinyPrefix(side) + -- species-only: front uses FRONT SHINY, back uses BACK SHINY. Players can + -- never be shiny, so this never consults player art. + local on = side == "back" + and BattleArt.backShinySetting:get() == "on" + or BattleArt.frontShinySetting:get() == "on" + return on and "shiny/" or "" +end + local function pathFor(species, side) local mode = BattleArt.setting:get() if mode == "rom" then return nil end @@ -79,10 +97,11 @@ local function pathFor(species, side) -- STATIC never consults an atlas. In particular, GEN 5 means the ordinary -- PNG at back-static/gen5 here; only AnimatedBattleArt may resolve the -- similarly named animated generation. - rel = ("assets/battle/back-static/%s/%s.png"):format( - BattleArt.backAnimationSetting:get(), slug(species)) + rel = ("assets/battle/back-static/%s%s/%s.png"):format( + shinyPrefix(side), BattleArt.backAnimationSetting:get(), slug(species)) else - rel = ("assets/battle/front-static/%s.png"):format(slug(species)) + rel = ("assets/battle/front-static/%s%s.png"):format( + shinyPrefix(side), slug(species)) end local path = V.mod.assets:path(rel) local fs = love and love.filesystem @@ -244,8 +263,8 @@ end -- switching the selector does not require renaming or replacing files. function BattleArt.generationBackImage(species, generation) if not tostring(generation or ""):match("^gen[1-5]$") then return nil end - local rel = ("assets/battle/back-static/%s/%s.png"):format( - generation, slug(species)) + local rel = ("assets/battle/back-static/%s%s/%s.png"):format( + shinyPrefix("back"), generation, slug(species)) local path = V.mod.assets:path(rel) local fs = love and love.filesystem if not (fs and fs.getInfo and fs.getInfo(path)) then return nil end @@ -257,9 +276,14 @@ end -- independently animated player-trainer intro. Each species is one ordinary -- image; no metadata or timing sidecar is involved. function BattleArt.generationFrontImage(species, generation) - if generation ~= "gen1" then return nil end - local rel = ("assets/battle/front-animated/gen1/%s.png"):format( - slug(species)) + if not tostring(generation or ""):match("^gen[1-5]$") then return nil end + -- Shiny-compatible: when FRONT SHINY is on, the shinyPrefix prepends + -- "shiny/", pointing at assets/battle/front-animated/shiny//.png + -- (sibling of the generation folders, matching the animated shiny layout). + -- A missing shiny file falls through to ROM -- the normal generation's + -- animated atlas is intentionally NOT used here so SHINY ON suppresses it. + local rel = ("assets/battle/front-animated/%s%s/%s.png"):format( + shinyPrefix("front"), generation, slug(species)) local path = V.mod.assets:path(rel) local fs = love and love.filesystem if not (fs and fs.getInfo and fs.getInfo(path)) then return nil end diff --git a/lib/ChunkMesher.lua b/lib/ChunkMesher.lua index eb2dbb9..e9b4c39 100644 --- a/lib/ChunkMesher.lua +++ b/lib/ChunkMesher.lua @@ -64,6 +64,26 @@ end local ChunkMesher = {} +-- Which drawn row a FLAT-topped volume's top face wears at depth `ty`. +-- +-- A structure is usually deeper than the art that draws it, so the rows +-- cycle and the drawing repeats down the top. That is right for art which +-- genuinely repeats -- the Safari Zone's fence alternates two tiles the +-- whole way down -- and wrong for a RIM over a uniform body: a cliff +-- mound's first row is its top edge, and cycling lays that edge again +-- every second tile, striping a plateau with rims it should not have. +-- +-- Where Structures found the body uniform, the rim is laid once at the +-- north edge and the body held after it. Everything else cycles as before. +function ChunkMesher.flatTopRow(run, ty) + local m = math.min(2, run.extent) + local d = ty - run.north + if run.topUniform then + return run.north + math.min(d, m - 1) + end + return run.north + (d % m) +end + -- Ring of border blocks meshed around the body, matching the width -- TileRenderer draws so the two modes end at the same place. local RING = 3 @@ -528,8 +548,7 @@ local function runGeometry(map, bodyOnly, masks, sink, waterSink) { x0 + 8, neY, z0 }, { x0, nwY, z0 } }, { { u0, v1 }, { u1, v1 }, { u1, v0 }, { u0, v0 } }, 0.95) elseif run then - local m = math.min(2, run.extent) - local topTile = map:tileAt(tx, run.north + ((ty - run.north) % m)) + local topTile = map:tileAt(tx, ChunkMesher.flatTopRow(run, ty)) topQuad(x0, z0, h, topTile, VOLUME_TOP_SHADE) else local topTile = tile diff --git a/lib/OverworldBattle.lua b/lib/OverworldBattle.lua index e62e266..2a20977 100644 --- a/lib/OverworldBattle.lua +++ b/lib/OverworldBattle.lua @@ -1136,6 +1136,10 @@ function OverworldBattle.install() local out = innerPic(self, img) if not OverworldBattle.shot() then return out end if BattleArt.isExternal(out) then return out end + -- ROM-fallback sprites (no authored PNG) are already transparent; the + -- diorama behind them must show through, so do not white-fill their gaps. + -- Mirrors upstream DramaticShapeVoxelMod PR #96 (crystal-sprite skip). + if not BattleArt.isExternal(img) then return out end return BattlePics.filled(out, OverworldBattle.pinnedPic(self, img)) end diff --git a/lib/ShadowMap.lua b/lib/ShadowMap.lua index 8997a49..166ee30 100644 --- a/lib/ShadowMap.lua +++ b/lib/ShadowMap.lua @@ -123,9 +123,11 @@ local SHADER = [[ uniform mat4 model; vec4 position(mat4 transform_projection, vec4 vertex_position) { vec4 c = lightVP * (model * vertex_position); - // the projection is orthographic, so w is 1 and clip z IS the depth, - // linear in world units along the sun line - vDepth = c.z * 0.5 + 0.5; + // the projection is orthographic (w is 1) and fit() maps clip z onto + // [0,1] directly (see Z01 there), so clip z IS the stored depth, + // linear in world units along the sun line -- under every clip-range + // convention a backend can bring + vDepth = c.z; return c; } #endif @@ -159,11 +161,52 @@ local prevBlend, prevAlphaMode = nil, nil local IDENTITY = Mat4.identity() -- world -> [0,1] cube, applied on top of the clip matrix: the main pass --- samples the map with the xy and compares against the z -local TO_UNIT = { 0.5, 0, 0, 0.5, - 0, 0.5, 0, 0.5, - 0, 0, 0.5, 0.5, - 0, 0, 0, 1 } +-- samples the map with the xy and compares against the z. +-- +-- The V ROW's sign is the RUNTIME's to answer, which is why this is a +-- function rather than a constant. This pass bypasses transform_projection +-- -- the one seam where LOVE reconciles clip conventions -- and LOVE 12 +-- changed them out from under it: clip-space output is y-up there, canvas +-- or no canvas, on every backend (the 12.0 changelog says it in as many +-- words). So a draw this pass stores lands with clip +y at texture v 1 +-- under LOVE 11 and at v 0 under LOVE 12, and the reader's v must run the +-- way the writer's rows actually landed or every lookup reads the map +-- VERTICALLY MIRRORED -- the far half of the frustum's shadows stamped +-- across the near field. probeVSign() below measures which world this is +-- once, with a real draw, instead of trusting a version or platform list. +-- +-- The z row is identity: fit() already maps clip z onto [0,1] (see Z01). +local function toUnit(vSign) + return { 0.5, 0, 0, 0.5, + 0, 0.5 * vSign, 0, 0.5, + 0, 0, 1, 0, + 0, 0, 0, 1 } +end + +-- Clip z from GL's [-1,1] onto [0,1], multiplied onto the projection. +-- Mat4.ortho emits the legacy GL range, and under LOVE 12 the sun pass +-- keeps only what lands in [0,1]: without this the map comes back with +-- the near half of the light frustum simply MISSING -- large regions +-- holding nothing but the clear, cut along the straight lines where the +-- z = 0 plane crosses the terrain mesh, and every texel that did survive +-- packing a high byte at or above 128. Measured, not reasoned: this one +-- matrix is the difference between that half-empty map and a full one on +-- both of LOVE 12's backends, with everything else held still. (The +-- camera never shows the same wound because its projection is +-- perspective and everything it can see sits past the crossover at twice +-- the near plane; the ortho here is linear, so it loses exactly half.) +-- [0,1] sits inside the legacy clip volume too, so the one matrix serves +-- LOVE 11 and 12 alike; the PACKED depth the reader compares is +-- bit-identical to what the old scheme packed (vDepth reads clip z +-- directly now), and only the throwaway hardware depth attachment loses +-- range it never used. +local Z01 = { 1, 0, 0, 0, + 0, 1, 0, 0, + 0, 0, 0.5, 0.5, + 0, 0, 0, 1 } + +-- +1 = LOVE 11 storage orientation, -1 = LOVE 12's; nil until probed +local vSign = nil -- world -> light clip space, for the pass that FILLS the map ShadowMap.clipVP = IDENTITY @@ -217,13 +260,57 @@ local function getBlank() return blank or nil end +-- Which way a bypass-projection draw lands in a canvas on THIS runtime, +-- measured rather than assumed: draw the half of clip space above y = 0 +-- through the pass's own shader and transforms, and ask which rows of the +-- readback it wrote. The LOVE 11 calibration everything shipped on stores +-- that half in the image's TOP rows; LOVE 12's y-up convention stores it +-- in the BOTTOM ones. Any failure answers +1 -- the convention every +-- platform ran until now -- so a driver that refuses the readback merely +-- keeps the behaviour it had. +local function probeVSign() + local done, sign = pcall(function() + local sh = getShader() + local tex = getBlank() + if not (sh and tex) then return 1 end + -- dpiscale 1: the readback below indexes rows of the IMAGE, and a + -- dpi-scaled canvas would hand back more of them than it was asked for + local c = love.graphics.newCanvas(4, 4, { dpiscale = 1 }) + -- the quad IS clip space: standard mesh, positions already in clip + -- units, uv pointed at the 1x1 blank so the alpha discard passes + local mesh = love.graphics.newMesh( + { { -1, 0, 0, 0 }, { 1, 0, 1, 0 }, { 1, 1, 1, 1 }, { -1, 1, 0, 1 } }, + "fan", "static") + mesh:setTexture(tex) + love.graphics.setCanvas(c) + love.graphics.clear(1, 1, 0, 1) + love.graphics.setShader(sh) + love.graphics.setColor(1, 1, 1, 1) + -- the production writer's own frame -- the y-flip and the z packing -- + -- with no view or ortho underneath + pcall(sh.send, sh, "lightVP", "row", + Mat4.mul(Z01, Mat4.scale(1, -1, 1))) + pcall(sh.send, sh, "model", "row", IDENTITY) + pcall(sh.send, sh, "sprite", 0) + love.graphics.draw(mesh) + love.graphics.setShader() + love.graphics.setCanvas() + local data = c:newImageData() + local w, h = data:getDimensions() + -- written texels carry packed depth 0.5 (red ~0.5); the clear is red 1 + local topR = data:getPixel(1, 0) + local botR = data:getPixel(1, h - 1) + if topR < 0.9 and botR > 0.9 then return 1 end + if botR < 0.9 and topR > 0.9 then return -1 end + return 1 + end) + return (done and sign) or 1 +end + -- Whether the sun pass can run at all. False headless, without shaders, or -- where the canvas cannot be made -- VoxelScene then keeps the flat decal -- shadows, which need nothing but a quad. function ShadowMap.available() - if love.system and love.system.getOS and love.system.getOS() == "iOS" then - return false - end if not (love.graphics and love.graphics.newCanvas and love.graphics.setDepthMode) then return false @@ -356,9 +443,12 @@ local function fit(cx, cy, vw, vh) -- without this the map is stored upside down relative to the uv the -- main pass reads it with proj = Mat4.mul(Mat4.scale(1, -1, 1), proj) + -- and clip z onto [0,1] -- see Z01 for why this is load-bearing under + -- LOVE 12 and free under LOVE 11 + proj = Mat4.mul(Z01, proj) ShadowMap.clipVP = Mat4.mul(proj, view) - ShadowMap.uvVP = Mat4.mul(TO_UNIT, ShadowMap.clipVP) + ShadowMap.uvVP = Mat4.mul(toUnit(vSign or 1), ShadowMap.clipVP) -- what the frustum ended up covering, for probes: the lateral extent in -- world pixels divided by RES is how fine a shadow edge can land ShadowMap.extent = { r - l, t - b, far - near } @@ -429,6 +519,12 @@ end function ShadowMap.begin(cx, cy, vw, vh) local sh = getShader() if not sh then return false end + -- the runtime's storage orientation, measured once before the first map + -- is fitted (see probeVSign); exposed for the probes and the suite + if vSign == nil then + vSign = probeVSign() + ShadowMap.vSign = vSign + end -- fit first: it is what decides which resolution rung this view wants fit(cx, cy, vw, vh) local c = getCanvas(ShadowMap.res) diff --git a/lib/Structures.lua b/lib/Structures.lua index ad3d5ec..95d7d95 100644 --- a/lib/Structures.lua +++ b/lib/Structures.lua @@ -2225,6 +2225,43 @@ function Structures.buildVolume(S, map, tiles) -- whether the region's dominant columns are flat repeats (a cliff -- mound's plateau) rather than drawn facades (a house's front) local modeRepeat = (repeatVotes[modeH] or 0) * 2 > modeN + + -- Whether this REGION's tops are a rim over a uniform body -- what every + -- cliff mound is drawn as: a top edge, then the same rock the whole way + -- down. The top face may then lay that rim once along its north edge and + -- hold the body after it, instead of cycling the rim back every second + -- tile and striping a plateau with edges it should not have. + -- + -- Answered per column AND per region, because each catches what the + -- other misses. A mound is one structure many columns wide, and the + -- columns carrying its cave mouth read differently from their neighbours + -- (their drawing ends in the mouth's own tiles): per column alone, those + -- kept cycling while the rest held, leaving rim stubs above the doorway. + -- But a region vote alone silences a genuine rim-over-body column that + -- happens to stand in a region of repeating art -- three of them in the + -- Safari Zone. A column holds if EITHER says so. + -- + -- Art that genuinely repeats is not uniform and keeps cycling: the + -- Safari Zone's fence alternates two tiles the whole way down, and there + -- the repeat IS what the drawing says. + local uniformVotes, uniformTotal = 0, 0 + for _, r in ipairs(runs) do + local run = r.run + if run.extent > 2 then + uniformTotal = uniformTotal + 1 + local body = map:tileAt(r.tx, run.north + 1) + local uniform = true + for d = 2, run.extent - 1 do + if map:tileAt(r.tx, run.north + d) ~= body then + uniform = false + break + end + end + run.ownUniform = uniform + if uniform then uniformVotes = uniformVotes + 1 end + end + end + local regionUniform = uniformTotal > 0 and uniformVotes * 2 > uniformTotal for _, r in ipairs(runs) do local run = r.run local h = run.unit * 8 @@ -2272,6 +2309,7 @@ function Structures.buildVolume(S, map, tiles) run.rise = roofRows * 8 run.peak = h run.h = h - run.rise -- facade height: what sides build to + run.topUniform = run.ownUniform or regionUniform for ty = run.north, run.front do S.runs[keyOf(r.tx, ty)] = run end diff --git a/lib/VoxelState.lua b/lib/VoxelState.lua index c77597f..29232d6 100644 --- a/lib/VoxelState.lua +++ b/lib/VoxelState.lua @@ -43,7 +43,7 @@ local Voxel = {} -- rig owns the actual camera. Voxel.ANGLES_DEG = { 0, 35, 15, 35, 50, 75, 75, 75 } Voxel.ANGLE_LABELS = { "OFF", "FULL", "15", "35", "50", "75", - "1ST (EXPERIMENTAL)", "3RD (EXPERIMENTAL)" } + "1ST", "3RD (EXPERIMENTAL)" } Voxel.MAX_LEVEL = #Voxel.ANGLES_DEG - 1 -- the rung FULL sits on, so nothing has to hunt for it by label diff --git a/main.lua b/main.lua index 6e05ba8..6ebeefd 100644 --- a/main.lua +++ b/main.lua @@ -447,6 +447,20 @@ local SETTINGS = { when = function() return stagedBattles() and BattleArt.setting:get() ~= "rom" end, full = true }, + { BattleArt.frontShinySetting, + "When ON, front sprites are read from assets/battle/front-static/shiny " + .. "or front-animated/shiny first. A missing shiny file falls back to the " + .. "selected generation's normal front, then to ROM. Keep the shiny " + .. "folders empty for a shiny mod to populate.", + when = function() return stagedBattles() end, full = true }, + { BattleArt.backShinySetting, + "When ON, back sprites are read from assets/battle/back-static/shiny or " + .. "back-animated/shiny first. A missing shiny file falls back to the " + .. "selected generation's normal back, then to ROM. Players can never be " + .. "shiny, so this never touches the player's own portrait.", + when = function() + return stagedBattles() and BattleArt.setting:get() ~= "rom" + end, full = true }, { BattleArt.viewSetting, "Show the player's Pokemon from the front or back. Supplied art stays " .. "world-placed; a missing selected back falls back to the ROM UI pic.", @@ -1031,7 +1045,7 @@ mod.hooks:wrap("world.tod", function(next, tod, ctx) return DayNight.tod() end) -mod.exports.version = "1.7.5" +mod.exports.version = "1.7.6" -- exposed so a companion mod can pin its own tiles' shapes or read the -- camera without reaching into this mod's file layout mod.exports.lib = V diff --git a/manifest.json b/manifest.json index 8e40d91..782027b 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "BATTLE_ART_VOXEL_FORK", "name": "BATTLE ART VOXEL FORK", - "version": "1.7.5", + "version": "1.7.6", "api": 2, "entry": "main.lua", "profile": "content", From 537d6f5a03f6d34a735d0890ddae396ae19db061 Mon Sep 17 00:00:00 2001 From: Thomas Armstrong Date: Sat, 8 Aug 2026 23:29:34 -0400 Subject: [PATCH 114/116] Modern UI Compatiblity Patch Added checks courtesy https://github.com/FelizNavidad-D/DramaticShapeVoxelMod so Modern UI can take control of Battle UI rendering. --- lib/BattleHud.lua | 23 +++++++++++++++++++++++ main.lua | 24 ++++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/lib/BattleHud.lua b/lib/BattleHud.lua index dcb4a85..1bc565c 100644 --- a/lib/BattleHud.lua +++ b/lib/BattleHud.lua @@ -255,6 +255,12 @@ end -- used, so the contrast is guaranteed rather than hoped for: a dark panel -- gets darker under white text, a bright one brighter under black text. function BattleHud.panel(rect, box, dark, world) + -- FIX: Check if Modern UI Battle mode is active. If yes, abort drawing the frosted glass! + local Game = require("src.core.Game") + if Game and Game.save and Game.save.options and Game.save.options.modOptions then + local mOpts = Game.save.options.modOptions["gen1_modern_ui"] + if mOpts and mOpts.battleUiWip == true then return true end + end -- Fully transparent means absent, not a zero-alpha draw. Avoid touching the -- destination canvas or blend state at all; premultiplied driver paths can -- otherwise retain RGB from a transparent sample as a dim veil. @@ -445,6 +451,23 @@ end local hudLayer = nil function BattleHud.layerTexture(w, h, dark, fn, inverted) + -- FIX: Check if Modern UI Battle mode is active. If yes, abort drawing classic text! + local Game = require("src.core.Game") + if Game and Game.save and Game.save.options and Game.save.options.modOptions then + local mOpts = Game.save.options.modOptions["gen1_modern_ui"] + if mOpts and mOpts.battleUiWip == true then + if not hudLayer or hudLayer:getWidth() ~= w or hudLayer:getHeight() ~= h then + hudLayer = canvasOf(w, h, "nearest") + end + local g = love.graphics + local prev = g.getCanvas() + g.setCanvas(hudLayer) + g.clear(0, 0, 0, 0) + if prev then g.setCanvas(prev) else g.setCanvas() end + return hudLayer + end + end + if not hudLayer or hudLayer:getWidth() ~= w or hudLayer:getHeight() ~= h then hudLayer = canvasOf(w, h, "nearest") if not hudLayer then return nil end diff --git a/main.lua b/main.lua index 6ebeefd..eeb4869 100644 --- a/main.lua +++ b/main.lua @@ -1049,3 +1049,27 @@ mod.exports.version = "1.7.6" -- exposed so a companion mod can pin its own tiles' shapes or read the -- camera without reaching into this mod's file layout mod.exports.lib = V + +-- ===================================================================== +-- FIX: SILENCE VOXEL HUD WHEN MODERN UI BATTLE PRESENTER IS ACTIVE +-- This wraps the Voxel Mod's own HUD hooks to prevent them from drawing +-- the classic floating lifebars when Modern UI is handling the battle. +-- ===================================================================== +local okBS, BattleState = pcall(require, "src.battle.BattleState") +if okBS and BattleState then + local orig_drawHUDs = BattleState.drawHUDs + BattleState.drawHUDs = function(self, ...) + local modernMod = mod.find and mod.find("gen1_modern_ui") + local wipOn = modernMod and modernMod.options and modernMod.options:get("battleUiWip") + if wipOn then return end + if orig_drawHUDs then return orig_drawHUDs(self, ...) end + end + + local orig_drawTextArea = BattleState.drawTextArea + BattleState.drawTextArea = function(self, ...) + local modernMod = mod.find and mod.find("gen1_modern_ui") + local wipOn = modernMod and modernMod.options and modernMod.options:get("battleUiWip") + if wipOn then return end + if orig_drawTextArea then return orig_drawTextArea(self, ...) end + end +end From a71c4912beaf02d5f5538f0e5d287c8f54a84966 Mon Sep 17 00:00:00 2001 From: Thomas Armstrong Date: Sun, 9 Aug 2026 02:04:28 -0400 Subject: [PATCH 115/116] Expose battle presentation compatibility hook --- CHANGELOG.md | 5 ++++ README.md | 48 ++++++++++++++++++++++++++++++ lib/BattleHud.lua | 31 +++++-------------- lib/BattlePresentation.lua | 61 ++++++++++++++++++++++++++++++++++++++ lib/OverworldBattle.lua | 3 ++ main.lua | 26 ++-------------- 6 files changed, 126 insertions(+), 48 deletions(-) create mode 100644 lib/BattlePresentation.lua diff --git a/CHANGELOG.md b/CHANGELOG.md index 587b4c6..42d5fea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ ### Added +- **Renderer-neutral battle presentation contract.** Replacement UI mods can + claim Battle Art's native `hud`, `text`, or `panels` surfaces through the + versioned `battle.presentation.suppress_native.v1` hook. Claims are granular, + compose across consumers, and fail open so missing or broken consumers leave + native battle rendering intact. - **Zero-configuration bring-your-own battle PNGs.** `BATTLE ART: STATIC` looks in `front-static` and the selected `back-static/gen1` through `gen5`, uses native image dimensions, diff --git a/README.md b/README.md index a6443cd..d397727 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,54 @@ menu. **3D-BTL** is on by default and is independent of **VOXEL**: battles draw on the world whether or not the free-roam camera is pitched over. +## Battle presentation compatibility + +Battle Art publishes a renderer-neutral suppression contract for mods that +replace part of the battle presentation. Consumers wrap the public +`battle.presentation.suppress_native.v1` hook and may claim only the native +surface they actually replace: + +| surface | Battle Art-owned rendering | +| --- | --- | +| `hud` | native battle status HUD glyphs and bars | +| `text` | native battle dialogue and command/menu layer | +| `panels` | Battle Art's frosted native HUD backplates | + +Each request has this shape: + +```lua +{ + apiVersion = 1, + sourceModId = "BATTLE_ART_VOXEL_FORK", + surface = "hud", -- or "text" / "panels" +} +``` + +A consumer returns exactly `true` to suppress that one surface for that draw. +It must call `next(request)` first and preserve an existing `true` so multiple +replacement mods compose monotonically: + +```lua +local HOOK = "battle.presentation.suppress_native.v1" + +mod.hooks:wrap(HOOK, function(next, request) + local claimed = next(request) + if claimed == true then return true end + if type(request) ~= "table" or request.apiVersion ~= 1 then return false end + + return myBattlePresenterIsActive() + and request.sourceModId == "BATTLE_ART_VOXEL_FORK" + and request.surface == "hud" +end) +``` + +The source descriptor is available at +`mod.find("BATTLE_ART_VOXEL_FORK").exports.battlePresentation`. Missing hooks, +unknown versions or surfaces, disabled consumers, and throwing consumers all +fail open to Battle Art's native rendering. This contract suppresses drawing +only; Battle Art remains responsible for battle state and animations, and no +third-party draw callback is executed. + ## Bring your own battle art `BATTLE ART: STATIC` is the default. Drop a front PNG named for the species diff --git a/lib/BattleHud.lua b/lib/BattleHud.lua index 1bc565c..460eea9 100644 --- a/lib/BattleHud.lua +++ b/lib/BattleHud.lua @@ -25,6 +25,8 @@ -- the mod namespace (see main.lua): V.require loads a sibling module local V = ... +local BattlePresentation = V.require("BattlePresentation") + local BattleHud = {} -- How solid the frost is over the world behind it, and how far the tint @@ -255,12 +257,7 @@ end -- used, so the contrast is guaranteed rather than hoped for: a dark panel -- gets darker under white text, a bright one brighter under black text. function BattleHud.panel(rect, box, dark, world) - -- FIX: Check if Modern UI Battle mode is active. If yes, abort drawing the frosted glass! - local Game = require("src.core.Game") - if Game and Game.save and Game.save.options and Game.save.options.modOptions then - local mOpts = Game.save.options.modOptions["gen1_modern_ui"] - if mOpts and mOpts.battleUiWip == true then return true end - end + if BattlePresentation.suppressed("panels") then return true end -- Fully transparent means absent, not a zero-alpha draw. Avoid touching the -- destination canvas or blend state at all; premultiplied driver paths can -- otherwise retain RGB from a transparent sample as a dim veil. @@ -451,27 +448,11 @@ end local hudLayer = nil function BattleHud.layerTexture(w, h, dark, fn, inverted) - -- FIX: Check if Modern UI Battle mode is active. If yes, abort drawing classic text! - local Game = require("src.core.Game") - if Game and Game.save and Game.save.options and Game.save.options.modOptions then - local mOpts = Game.save.options.modOptions["gen1_modern_ui"] - if mOpts and mOpts.battleUiWip == true then - if not hudLayer or hudLayer:getWidth() ~= w or hudLayer:getHeight() ~= h then - hudLayer = canvasOf(w, h, "nearest") - end - local g = love.graphics - local prev = g.getCanvas() - g.setCanvas(hudLayer) - g.clear(0, 0, 0, 0) - if prev then g.setCanvas(prev) else g.setCanvas() end - return hudLayer - end - end - if not hudLayer or hudLayer:getWidth() ~= w or hudLayer:getHeight() ~= h then hudLayer = canvasOf(w, h, "nearest") if not hudLayer then return nil end end + local suppressHud = BattlePresentation.suppressed("hud") local g = love.graphics local prevCanvas = g.getCanvas() local prevBlend, prevAlpha = g.getBlendMode() @@ -483,7 +464,9 @@ function BattleHud.layerTexture(w, h, dark, fn, inverted) -- flipGlyphs renders fn into its own scratch layer and composites the -- whitened result into whatever is bound, which is this canvas. `inverted` -- (the WHITE arena fill) keeps the glyphs black with a white drop-shadow. - if dark then BattleHud.flipGlyphs(w, h, fn, inverted) else fn() end + if not suppressHud then + if dark then BattleHud.flipGlyphs(w, h, fn, inverted) else fn() end + end end) if prevCanvas then g.setCanvas(prevCanvas) else g.setCanvas() end g.setBlendMode(prevBlend or "alpha", prevAlpha) diff --git a/lib/BattlePresentation.lua b/lib/BattlePresentation.lua new file mode 100644 index 0000000..9ee1538 --- /dev/null +++ b/lib/BattlePresentation.lua @@ -0,0 +1,61 @@ +-- Public, renderer-neutral compatibility seam for battle UI replacements. +-- +-- A replacement UI may claim one native presentation surface at a time by +-- wrapping SUPPRESS_HOOK and returning exactly true. Battle Art keeps all +-- gameplay, animation, and unclaimed rendering paths intact. + +local Runtime = require("src.mods.Runtime") + +local BattlePresentation = {} + +BattlePresentation.API_VERSION = 1 +BattlePresentation.SUPPRESS_HOOK = "battle.presentation.suppress_native.v1" +BattlePresentation.SOURCE_MOD_ID = "BATTLE_ART_VOXEL_FORK" +BattlePresentation.SURFACES = { + hud = "hud", + text = "text", + panels = "panels", +} + +local validSurface = { + hud = true, + text = true, + panels = true, +} + +local function unclaimed() + return false +end + +function BattlePresentation.suppressed(surface) + if not validSurface[surface] then return false end + if not Runtime.wantsHook(BattlePresentation.SUPPRESS_HOOK) then return false end + + local request = { + apiVersion = BattlePresentation.API_VERSION, + sourceModId = BattlePresentation.SOURCE_MOD_ID, + surface = surface, + } + local ok, claimed = pcall( + Runtime.call, + BattlePresentation.SUPPRESS_HOOK, + unclaimed, + request + ) + return ok and claimed == true +end + +function BattlePresentation.export() + return { + apiVersion = BattlePresentation.API_VERSION, + suppressHook = BattlePresentation.SUPPRESS_HOOK, + sourceModId = BattlePresentation.SOURCE_MOD_ID, + surfaces = { + hud = BattlePresentation.SURFACES.hud, + text = BattlePresentation.SURFACES.text, + panels = BattlePresentation.SURFACES.panels, + }, + } +end + +return BattlePresentation diff --git a/lib/OverworldBattle.lua b/lib/OverworldBattle.lua index 2a20977..9f152dd 100644 --- a/lib/OverworldBattle.lua +++ b/lib/OverworldBattle.lua @@ -44,6 +44,7 @@ local BattleCam = V.require("BattleCam") local BattleScene = V.require("BattleScene") local BattleDOF = V.require("BattleDOF") local BattleHud = V.require("BattleHud") +local BattlePresentation = V.require("BattlePresentation") local UiBackplates = V.require("UiBackplates") local BattlePics = V.require("BattlePics") local BattleArt = V.require("BattleArt") @@ -1259,6 +1260,7 @@ function OverworldBattle.install() local innerText = BattleState.drawTextArea function BattleState:drawTextArea() if not self.dramaticShapeShot then return innerText(self) end + if BattlePresentation.suppressed("text") then return end if isIOS() then return innerText(self) end local battle = self -- Every battle box -- the dialogue, the FIGHT/ITEM/RUN menu and the @@ -1367,6 +1369,7 @@ function OverworldBattle.install() -- only an exactly-black set is remapped. innerHUDs = BattleState.drawHUDs function BattleState:drawHUDs(slide) + if self.dramaticShapeShot and BattlePresentation.suppressed("hud") then return end -- Normally the HUDs have already been drawn this frame, snapped out to the -- window's edges and composited into the world image (snapHUDs). Drawing -- them here as well would show each block twice, once in each place. diff --git a/main.lua b/main.lua index eeb4869..07521c2 100644 --- a/main.lua +++ b/main.lua @@ -85,6 +85,7 @@ local ChunkMesher = V.require("ChunkMesher") local VoxelGrid = V.require("VoxelGrid") local WorldCurve = V.require("WorldCurve") local OverworldBattle = V.require("OverworldBattle") +local BattlePresentation = V.require("BattlePresentation") local BattleArt = V.require("BattleArt") local UiBackplates = V.require("UiBackplates") local BattleExit = V.require("BattleExit") @@ -1046,30 +1047,7 @@ mod.hooks:wrap("world.tod", function(next, tod, ctx) end) mod.exports.version = "1.7.6" +mod.exports.battlePresentation = BattlePresentation.export() -- exposed so a companion mod can pin its own tiles' shapes or read the -- camera without reaching into this mod's file layout mod.exports.lib = V - --- ===================================================================== --- FIX: SILENCE VOXEL HUD WHEN MODERN UI BATTLE PRESENTER IS ACTIVE --- This wraps the Voxel Mod's own HUD hooks to prevent them from drawing --- the classic floating lifebars when Modern UI is handling the battle. --- ===================================================================== -local okBS, BattleState = pcall(require, "src.battle.BattleState") -if okBS and BattleState then - local orig_drawHUDs = BattleState.drawHUDs - BattleState.drawHUDs = function(self, ...) - local modernMod = mod.find and mod.find("gen1_modern_ui") - local wipOn = modernMod and modernMod.options and modernMod.options:get("battleUiWip") - if wipOn then return end - if orig_drawHUDs then return orig_drawHUDs(self, ...) end - end - - local orig_drawTextArea = BattleState.drawTextArea - BattleState.drawTextArea = function(self, ...) - local modernMod = mod.find and mod.find("gen1_modern_ui") - local wipOn = modernMod and modernMod.options and modernMod.options:get("battleUiWip") - if wipOn then return end - if orig_drawTextArea then return orig_drawTextArea(self, ...) end - end -end From decc7e340147eb7345f375cc80967e274ecae05e Mon Sep 17 00:00:00 2001 From: Thomas Armstrong Date: Sun, 9 Aug 2026 03:07:46 -0400 Subject: [PATCH 116/116] Expose battle presentation compatibility hook --- CHANGELOG.md | 5 ++++ README.md | 48 ++++++++++++++++++++++++++++++ lib/BattleHud.lua | 31 +++++-------------- lib/BattlePresentation.lua | 61 ++++++++++++++++++++++++++++++++++++++ lib/OverworldBattle.lua | 3 ++ main.lua | 26 ++-------------- 6 files changed, 126 insertions(+), 48 deletions(-) create mode 100644 lib/BattlePresentation.lua diff --git a/CHANGELOG.md b/CHANGELOG.md index 587b4c6..42d5fea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ ### Added +- **Renderer-neutral battle presentation contract.** Replacement UI mods can + claim Battle Art's native `hud`, `text`, or `panels` surfaces through the + versioned `battle.presentation.suppress_native.v1` hook. Claims are granular, + compose across consumers, and fail open so missing or broken consumers leave + native battle rendering intact. - **Zero-configuration bring-your-own battle PNGs.** `BATTLE ART: STATIC` looks in `front-static` and the selected `back-static/gen1` through `gen5`, uses native image dimensions, diff --git a/README.md b/README.md index a6443cd..d397727 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,54 @@ menu. **3D-BTL** is on by default and is independent of **VOXEL**: battles draw on the world whether or not the free-roam camera is pitched over. +## Battle presentation compatibility + +Battle Art publishes a renderer-neutral suppression contract for mods that +replace part of the battle presentation. Consumers wrap the public +`battle.presentation.suppress_native.v1` hook and may claim only the native +surface they actually replace: + +| surface | Battle Art-owned rendering | +| --- | --- | +| `hud` | native battle status HUD glyphs and bars | +| `text` | native battle dialogue and command/menu layer | +| `panels` | Battle Art's frosted native HUD backplates | + +Each request has this shape: + +```lua +{ + apiVersion = 1, + sourceModId = "BATTLE_ART_VOXEL_FORK", + surface = "hud", -- or "text" / "panels" +} +``` + +A consumer returns exactly `true` to suppress that one surface for that draw. +It must call `next(request)` first and preserve an existing `true` so multiple +replacement mods compose monotonically: + +```lua +local HOOK = "battle.presentation.suppress_native.v1" + +mod.hooks:wrap(HOOK, function(next, request) + local claimed = next(request) + if claimed == true then return true end + if type(request) ~= "table" or request.apiVersion ~= 1 then return false end + + return myBattlePresenterIsActive() + and request.sourceModId == "BATTLE_ART_VOXEL_FORK" + and request.surface == "hud" +end) +``` + +The source descriptor is available at +`mod.find("BATTLE_ART_VOXEL_FORK").exports.battlePresentation`. Missing hooks, +unknown versions or surfaces, disabled consumers, and throwing consumers all +fail open to Battle Art's native rendering. This contract suppresses drawing +only; Battle Art remains responsible for battle state and animations, and no +third-party draw callback is executed. + ## Bring your own battle art `BATTLE ART: STATIC` is the default. Drop a front PNG named for the species diff --git a/lib/BattleHud.lua b/lib/BattleHud.lua index 1bc565c..460eea9 100644 --- a/lib/BattleHud.lua +++ b/lib/BattleHud.lua @@ -25,6 +25,8 @@ -- the mod namespace (see main.lua): V.require loads a sibling module local V = ... +local BattlePresentation = V.require("BattlePresentation") + local BattleHud = {} -- How solid the frost is over the world behind it, and how far the tint @@ -255,12 +257,7 @@ end -- used, so the contrast is guaranteed rather than hoped for: a dark panel -- gets darker under white text, a bright one brighter under black text. function BattleHud.panel(rect, box, dark, world) - -- FIX: Check if Modern UI Battle mode is active. If yes, abort drawing the frosted glass! - local Game = require("src.core.Game") - if Game and Game.save and Game.save.options and Game.save.options.modOptions then - local mOpts = Game.save.options.modOptions["gen1_modern_ui"] - if mOpts and mOpts.battleUiWip == true then return true end - end + if BattlePresentation.suppressed("panels") then return true end -- Fully transparent means absent, not a zero-alpha draw. Avoid touching the -- destination canvas or blend state at all; premultiplied driver paths can -- otherwise retain RGB from a transparent sample as a dim veil. @@ -451,27 +448,11 @@ end local hudLayer = nil function BattleHud.layerTexture(w, h, dark, fn, inverted) - -- FIX: Check if Modern UI Battle mode is active. If yes, abort drawing classic text! - local Game = require("src.core.Game") - if Game and Game.save and Game.save.options and Game.save.options.modOptions then - local mOpts = Game.save.options.modOptions["gen1_modern_ui"] - if mOpts and mOpts.battleUiWip == true then - if not hudLayer or hudLayer:getWidth() ~= w or hudLayer:getHeight() ~= h then - hudLayer = canvasOf(w, h, "nearest") - end - local g = love.graphics - local prev = g.getCanvas() - g.setCanvas(hudLayer) - g.clear(0, 0, 0, 0) - if prev then g.setCanvas(prev) else g.setCanvas() end - return hudLayer - end - end - if not hudLayer or hudLayer:getWidth() ~= w or hudLayer:getHeight() ~= h then hudLayer = canvasOf(w, h, "nearest") if not hudLayer then return nil end end + local suppressHud = BattlePresentation.suppressed("hud") local g = love.graphics local prevCanvas = g.getCanvas() local prevBlend, prevAlpha = g.getBlendMode() @@ -483,7 +464,9 @@ function BattleHud.layerTexture(w, h, dark, fn, inverted) -- flipGlyphs renders fn into its own scratch layer and composites the -- whitened result into whatever is bound, which is this canvas. `inverted` -- (the WHITE arena fill) keeps the glyphs black with a white drop-shadow. - if dark then BattleHud.flipGlyphs(w, h, fn, inverted) else fn() end + if not suppressHud then + if dark then BattleHud.flipGlyphs(w, h, fn, inverted) else fn() end + end end) if prevCanvas then g.setCanvas(prevCanvas) else g.setCanvas() end g.setBlendMode(prevBlend or "alpha", prevAlpha) diff --git a/lib/BattlePresentation.lua b/lib/BattlePresentation.lua new file mode 100644 index 0000000..9ee1538 --- /dev/null +++ b/lib/BattlePresentation.lua @@ -0,0 +1,61 @@ +-- Public, renderer-neutral compatibility seam for battle UI replacements. +-- +-- A replacement UI may claim one native presentation surface at a time by +-- wrapping SUPPRESS_HOOK and returning exactly true. Battle Art keeps all +-- gameplay, animation, and unclaimed rendering paths intact. + +local Runtime = require("src.mods.Runtime") + +local BattlePresentation = {} + +BattlePresentation.API_VERSION = 1 +BattlePresentation.SUPPRESS_HOOK = "battle.presentation.suppress_native.v1" +BattlePresentation.SOURCE_MOD_ID = "BATTLE_ART_VOXEL_FORK" +BattlePresentation.SURFACES = { + hud = "hud", + text = "text", + panels = "panels", +} + +local validSurface = { + hud = true, + text = true, + panels = true, +} + +local function unclaimed() + return false +end + +function BattlePresentation.suppressed(surface) + if not validSurface[surface] then return false end + if not Runtime.wantsHook(BattlePresentation.SUPPRESS_HOOK) then return false end + + local request = { + apiVersion = BattlePresentation.API_VERSION, + sourceModId = BattlePresentation.SOURCE_MOD_ID, + surface = surface, + } + local ok, claimed = pcall( + Runtime.call, + BattlePresentation.SUPPRESS_HOOK, + unclaimed, + request + ) + return ok and claimed == true +end + +function BattlePresentation.export() + return { + apiVersion = BattlePresentation.API_VERSION, + suppressHook = BattlePresentation.SUPPRESS_HOOK, + sourceModId = BattlePresentation.SOURCE_MOD_ID, + surfaces = { + hud = BattlePresentation.SURFACES.hud, + text = BattlePresentation.SURFACES.text, + panels = BattlePresentation.SURFACES.panels, + }, + } +end + +return BattlePresentation diff --git a/lib/OverworldBattle.lua b/lib/OverworldBattle.lua index 2a20977..9f152dd 100644 --- a/lib/OverworldBattle.lua +++ b/lib/OverworldBattle.lua @@ -44,6 +44,7 @@ local BattleCam = V.require("BattleCam") local BattleScene = V.require("BattleScene") local BattleDOF = V.require("BattleDOF") local BattleHud = V.require("BattleHud") +local BattlePresentation = V.require("BattlePresentation") local UiBackplates = V.require("UiBackplates") local BattlePics = V.require("BattlePics") local BattleArt = V.require("BattleArt") @@ -1259,6 +1260,7 @@ function OverworldBattle.install() local innerText = BattleState.drawTextArea function BattleState:drawTextArea() if not self.dramaticShapeShot then return innerText(self) end + if BattlePresentation.suppressed("text") then return end if isIOS() then return innerText(self) end local battle = self -- Every battle box -- the dialogue, the FIGHT/ITEM/RUN menu and the @@ -1367,6 +1369,7 @@ function OverworldBattle.install() -- only an exactly-black set is remapped. innerHUDs = BattleState.drawHUDs function BattleState:drawHUDs(slide) + if self.dramaticShapeShot and BattlePresentation.suppressed("hud") then return end -- Normally the HUDs have already been drawn this frame, snapped out to the -- window's edges and composited into the world image (snapHUDs). Drawing -- them here as well would show each block twice, once in each place. diff --git a/main.lua b/main.lua index eeb4869..07521c2 100644 --- a/main.lua +++ b/main.lua @@ -85,6 +85,7 @@ local ChunkMesher = V.require("ChunkMesher") local VoxelGrid = V.require("VoxelGrid") local WorldCurve = V.require("WorldCurve") local OverworldBattle = V.require("OverworldBattle") +local BattlePresentation = V.require("BattlePresentation") local BattleArt = V.require("BattleArt") local UiBackplates = V.require("UiBackplates") local BattleExit = V.require("BattleExit") @@ -1046,30 +1047,7 @@ mod.hooks:wrap("world.tod", function(next, tod, ctx) end) mod.exports.version = "1.7.6" +mod.exports.battlePresentation = BattlePresentation.export() -- exposed so a companion mod can pin its own tiles' shapes or read the -- camera without reaching into this mod's file layout mod.exports.lib = V - --- ===================================================================== --- FIX: SILENCE VOXEL HUD WHEN MODERN UI BATTLE PRESENTER IS ACTIVE --- This wraps the Voxel Mod's own HUD hooks to prevent them from drawing --- the classic floating lifebars when Modern UI is handling the battle. --- ===================================================================== -local okBS, BattleState = pcall(require, "src.battle.BattleState") -if okBS and BattleState then - local orig_drawHUDs = BattleState.drawHUDs - BattleState.drawHUDs = function(self, ...) - local modernMod = mod.find and mod.find("gen1_modern_ui") - local wipOn = modernMod and modernMod.options and modernMod.options:get("battleUiWip") - if wipOn then return end - if orig_drawHUDs then return orig_drawHUDs(self, ...) end - end - - local orig_drawTextArea = BattleState.drawTextArea - BattleState.drawTextArea = function(self, ...) - local modernMod = mod.find and mod.find("gen1_modern_ui") - local wipOn = modernMod and modernMod.options and modernMod.options:get("battleUiWip") - if wipOn then return end - if orig_drawTextArea then return orig_drawTextArea(self, ...) end - end -end