diff --git a/CHANGELOG.md b/CHANGELOG.md index 96668f7..094bb36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,17 +4,33 @@ ### Added -- **STADIUM battles: two new rungs on the 3D-BTL row.** The row that used - to read ON / OFF now reads **2D-3D / STADIUM A / STADIUM B / OFF**. 2D-3D - is what ON always was -- the fight staged on the map with the Game Boy's - own pics stood up on their tiles as quads. Both STADIUM rungs keep the - whole staging and replace those quads with the **Pokemon Stadium battle - models**: all 151 species, skinned, lit and animated, playing the +- **STADIUM battles, and a disc stage: three new rungs on the 3D-BTL row.** + The row that used to read ON / OFF now reads **2D-3D A / 2D-3D B / + STADIUM A / STADIUM B / OFF**, which is two independent choices laid out + as one ladder -- WHAT is standing there, and WHERE: + + | | on the MAP | on two DISCS | + | ---------- | ----------- | ------------ | + | **pics** | 2D-3D A | 2D-3D B | + | **models** | STADIUM A | STADIUM B | + + 2D-3D A is what ON always was -- the fight staged on the map with the Game + Boy's own pics stood up on their tiles as quads. The STADIUM rungs keep + the whole staging and replace those quads with the **Pokemon Stadium + battle models**: all 151 species, skinned, lit and animated, playing the animation the move being used actually calls for. - **STADIUM A** stands them on the map, in the world's own light and - weather. **STADIUM B** stands them on two discs against the sky and draws - no map at all -- the Game Boy's own framing, staged rather than found. + **A** stands the fight on the map, in the world's own light and weather. + **B** stands it on two discs against the sky and draws no map at all -- + the Game Boy's own framing, staged rather than found. + + All four combinations are reachable rather than only the diagonal. The + discs do not know what is on them -- they are two platforms at two cells, + drawn off the arena alone -- so **2D-3D B** costs one value on the ladder + and gives the disc framing to a player who has no Pokemon Stadium ROM and + would rather not go and find one. It needs nothing the base game did not + ship: the stage is generated in Lua and the Pokemon on it are the game's + own art. B exists because the map does not always cooperate. Half of Kanto's interiors are furniture, a cave floor can be nothing but two-cell @@ -72,18 +88,23 @@ all fall back to the flat card, on that side alone, with the other side keeping its model. - Stored as new values on the same key, with 2D-3D still first -- so a save - written before this update reads back as exactly the mode it was written - for. + Stored as new values on the same key, with 2D-3D A still first -- so a + save written before this update reads back as exactly the mode it was + written for. - **The models are built on your machine, from your own cartridge.** The mod ships no Pokemon Stadium data and cannot: it is that game's. What it ships is the READER. Drop a Pokemon Stadium (US) ROM -- `.z64`, `.n64` or `.v64`, the byte - order is detected -- into a `baseroms/` folder beside the game, and the - first time it runs the 151 battle models are built out of it on a loading - screen, in about ten seconds, one species a frame. After that they sit in + order is detected -- into a `baseroms/` folder beside the game, straight in + it rather than in a revision subfolder under it, and the first time it runs + the 151 battle models are built out of it on a loading screen, in about ten + seconds, one species a frame. The screen says what it is doing in those + words -- **ONE-TIME EXTRACTION OF STADIUM ASSETS** -- carries a progress bar + filled by species written rather than by elapsed time, and names the + Pokemon it is on, which is the difference between a bar that is trusted and + one that is suspected of having hung. After that they sit in the save directory and are read like any other asset. Until then the two STADIUM rungs simply are not on the row: they are skipped rather than shown and refused, because a setting that can be selected and then does @@ -129,6 +150,57 @@ ### Fixed +- **Idle animations snapped, and then ran at half the frame rate.** Two + bugs with the same cause, fixed in opposite directions. + + The animation streams are not keyframes: they carry one value per frame at + 30 Hz and the game steps them. Blended naively against a 60 Hz camera + that produced the reported glitch -- Rattata's idle snapping almost upside + down for a frame, Charmander's arms turning inside out for a few -- + because rotations here are **Euler triples**, and two triples can describe + nearly the same orientation while being nowhere near each other component + by component. `(0, 20976, 32736)` and `(0, -19936, -5904)` are a real + consecutive pair out of the set. Walking from one to the other passes + through orientations that are nothing like either end. + + Dropping the blend fixed that and cost the smoothness: every pose then + held for two frames, and a set of models moving at half the rate of + everything around them reads as a stutter. So the blend is back, and the + snaps are **detected** rather than smoothed. A bone whose rotation moves + more than a quarter turn inside one 30 Hz frame is not being animated, it + is being re-expressed, and it holds its frame instead of blending -- all + three components together, because they are one rotation. The same guard + covers a translation that teleports more than half the Pokemon's own + height in a frame. Angles blend the short way round the +-pi seam, and + texture animations still step whole frames, because an eye is open or shut + and there is no halfway swap to draw. + + Measured, not eyeballed: walking every species' standby loop in quarter + frames and differencing each bone's world origin against itself, the + number of species that jump more than a quarter of their own height in a + quarter frame goes **42 -> 11 -> 3**: naive blend, stepped, guarded blend. + The three that remain (Pidgeot, Dodrio, Grimer) have erratic rotation data + at source and are held rather than smoothed, which is exactly the guard + doing its job. + +- **Exeggutor, Tangela and Magmar are drawn as battle sprites instead of + models.** Those three come out of the ROM with standby loops that throw + bones hundreds of units off the body -- the game's own index arithmetic + evidently reads their channel streams differently from the way this does. + They used to stand still in their **bind pose** instead, on the reasoning + that a Pokemon looking like itself beats one coming apart. + + It does, but only just: a bind pose is a rigging pose, not a portrait, and + three species holding a T-pose among a hundred and forty-eight that + breathe read as broken rather than as still. So they now decline the model + outright and the Game Boy's own battle pic stands on the tile instead -- + the same per-Pokemon fallback a species with no pack at all already took, + in the same arena, under the same light. + + Keyed on the packer's own measurement rather than on a list of dex + numbers, so a re-extraction that fixes those streams -- or breaks a fourth + species -- moves this with it. + - **The eyes blinked several times a second.** A texture animation was running on a clock of its own and wrapping on its own stream's length. Rattata's standby loop is forty frames and its blink is five -- `6 8 7 8 @@ -162,6 +234,29 @@ the shot driver's `DS_FAINT` case prints the frame the bar empties against the frame the animation starts, and they are the same frame. +- **And the faint animation now gets to finish.** A fainted Pokemon left the + field when its PIC did, and the engine's pic slide is fourteen frames of a + 60 Hz clock -- `SlideDownFaintedMonPic`, seven rows two frames apart, under + a quarter of a second. The Stadium faint animations are nothing like that + short: the briefest in the set is 49 frames of a 30 Hz clock, the median is + 110 and the longest 230. Every model was therefore cut off inside the first + fifth of its own collapse -- the Pokemon began to fall and then vanished + mid-fall, which is worse than not animating at all. + + A model that is collapsing now stays until it has finished collapsing, and + the two timings are simply not tied to each other any more: how long a flat + pic takes to slide off the bottom of a 160x144 frame has nothing to say + about how long it takes a Gyarados to fall over. Bounded at both ends -- + it ends when the animation does, so nothing is left lying on the field for + the rest of the fight, and the side is reset outright the moment a + different battler stands in that slot, which is what stops the next + Pokemon out of the ball arriving face down (that one bites when a trainer + leads with two of the same species, where the dex number never changes and + nothing downstream would otherwise notice the swap). + + `DS_FAINT` now prints the span as well as the moment: 127 frames against + the pic's 14. + - Two ordering bugs in the extraction pipeline that made its output **non-reproducible**. The generated flame nodes were deduplicated through a Python `set` of tuples containing strings, so their order moved with @@ -186,10 +281,16 @@ ### Known - Three species -- Exeggutor, Tangela and Magmar -- have standby loops that - are corrupt in the source extraction, throwing bones hundreds of units - off the body. The packer measures each species' loop against its own bind - pose and marks those three to hold the bind pose instead, so they stand - still and look like themselves rather than coming apart. + are corrupt in the source extraction, and fall back to their battle + sprites on the STADIUM rungs (see above). 148 of the 151 have models. + +- Pidgeot, Dodrio and Grimer have a handful of erratic rotation frames in + their standby loops -- a few frames of junk at the top of the loop rather + than a corrupt stream. The blend guard holds those frames rather than + smoothing through them, so they step where the source steps. Not chased + further: the extraction is byte-identical to a reference pipeline whose + sampling was validated against the decompilation's own arithmetic, and + guessing at the format to fix three species risks the other 145. ## 1.5.5 diff --git a/README.md b/README.md index 4b34c2c..0eaecbb 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ menu. | `5`, or the **V-GRID** options row | OFF / ON — a one-pixel wireframe on every voxel | | `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 | +| `8`, or the **3D-BTL** options row | 2D-3D A / 2D-3D B / STADIUM A / STADIUM B / OFF — fight in 3D instead of on a white field. **A** stages it on the map, **B** on two discs against the sky; **2D-3D** uses the game's own battle pics and **STADIUM** the Pokémon Stadium battle models | | `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 | @@ -82,21 +82,33 @@ the one the rig was solved for. ## STADIUM battles -The **3D-BTL** row has four rungs: +The **3D-BTL** row has five rungs, which are two choices — what is standing +there, and where: | rung | the fight | | --- | --- | -| **2D-3D** | staged on the map, with the Game Boy's own pics stood up on their tiles | +| **2D-3D A** | staged on the map, with the Game Boy's own pics stood up on their tiles | +| **2D-3D B** | those same pics on two discs against the sky, with no map drawn | | **STADIUM A** | staged on the map, with the Pokémon Stadium battle models | -| **STADIUM B** | the same models on two discs against the sky, with no map drawn | +| **STADIUM B** | those models on the discs | | **OFF** | the engine's own battle screen | -All 151 species, skinned and animated, playing the animation the move being -used actually calls for — the Stadium ROM's own per-species move table, so -**DIG** really does put Diglett into the ground. Damage plays the hit -reaction, fainting plays the faint and holds there, a send-out plays the -entrance, and between all of that the standby loop runs. Eyes blink and go -dizzy; Charmander's tail flame and Weezing's gas are drawn over the body. +**A** is the map — real ground, in that place's own weather and light. **B** +is the carried stage, which works everywhere, including the caves and shop +floors that have nowhere to put a fight. Only the STADIUM rungs need a ROM; +**2D-3D B** is generated in Lua and uses the game's own art. + +Skinned and animated, playing the animation the move being used actually +calls for — the Stadium ROM's own per-species move table, so **DIG** really +does put Diglett into the ground. Damage plays the hit reaction, fainting +plays the faint and holds there, a send-out plays the entrance, and between +all of that the standby loop runs. Eyes blink and go dizzy; Charmander's tail +flame and Weezing's gas are drawn over the body. + +148 of the 151 have models. Exeggutor, Tangela and Magmar come out of the ROM +with corrupt standby loops and stand as their Game Boy battle sprites +instead, on their own tile, in the same arena — the same per-Pokémon fallback +a substitute doll and the pre-send-out trainer pic already take. **B is for the maps that cannot host a fight.** Half of Kanto's interiors are furniture, a cave floor can be nothing but corridors, and a map where neither @@ -112,12 +124,13 @@ under that cave's void and its own flat light. data. What ships is the reader; you supply the cartridge, exactly as this engine already asks you to supply the Game Boy ROM it is a recompilation of. -1. Put a **Pokémon Stadium (US)** ROM in a `baseroms/` folder beside the game. - `.z64`, `.n64` and `.v64` all work — the byte order is detected. +1. Put a **Pokémon Stadium (US)** ROM in a `baseroms/` folder beside the game + — straight in it, not in a subfolder. `.z64`, `.n64` and `.v64` all work; + the byte order is detected. In a packaged build, `baseroms/` goes in the save directory; the mod logs the exact path on startup when it cannot find one. 2. Start the game. The 151 models are built out of the ROM on a loading - screen, in about ten seconds. + screen that says so and shows a progress bar, in about ten seconds. 3. The two STADIUM rungs appear on the 3D-BTL row. The built models live in the save directory, not in the mod folder, and are diff --git a/lib/BattleScene.lua b/lib/BattleScene.lua index 566731c..008d84b 100644 --- a/lib/BattleScene.lua +++ b/lib/BattleScene.lua @@ -321,7 +321,7 @@ local function castShadows(state, arena, terrain, nbMesh, cx, cy, vw, vh, if not ShadowMap.stale(sig) then return end if not ShadowMap.begin(cx, cy, vw, vh) then return end - -- STADIUM B: the two discs are the only ground there is, so they are the + -- A DISC RUNG: the two discs are the only ground there is, so they are the -- only thing the sun has to see besides the Pokemon themselves. Everything -- below this is a map that is not in the shot. if arena.discs then @@ -382,7 +382,7 @@ end -- the one nearer the camera and therefore the one a mismatch would show up -- against. function BattleScene.groundY(map, arena) - -- STADIUM B's discs are carried, not found: their tops ARE the ground + -- A disc rung's discs are carried, not found: their tops ARE the ground -- plane, so there is no terrain height to read and reading one would put -- the stage at whatever elevation the map happens to have at a spot the -- fight is not actually happening on @@ -476,7 +476,7 @@ function BattleScene.render(state, arena, textures, token) -- moving, and a shimmer on background windows would fight the mons Voxel3D.glassGlint = 0 - -- STADIUM B stands the fight on two carried discs against the sky, with no + -- A B RUNG stands the fight on two carried discs against the sky, with no -- map in the shot at all (see StadiumStage). Everything below still runs -- -- the letterbox, the camera solve, the sun, the pins, the tint, the depth -- of field -- because none of it is about the terrain; what changes is @@ -535,7 +535,7 @@ function BattleScene.render(state, arena, textures, token) -- geometry stops. local sky = VoxelScene.skyColor(host, 1) or VoxelScene.skyShade(INDOOR_SHADE, 1) - -- On STADIUM B the void is not a backdrop behind the scenery -- it IS the + -- On a disc rung the void is not a backdrop behind the scenery -- it IS the -- scenery, because the map is not drawn. So outdoors it gets the full -- treatment the free-roam camera gets: the banded gradient and the hour's -- own sun or moon hanging in it (Voxel3D.beginScene paints those when the @@ -580,7 +580,7 @@ function BattleScene.render(state, arena, textures, token) return end if discs then - -- STADIUM B: the two platforms, and nothing else. No terrain, no + -- discs: the two platforms, and nothing else. No terrain, no -- neighbouring maps, no water, no grass and no flowers -- see the -- matching skips further down. What is behind them is the sky the -- clear painted. diff --git a/lib/OverworldBattle.lua b/lib/OverworldBattle.lua index c074008..f4155d5 100644 --- a/lib/OverworldBattle.lua +++ b/lib/OverworldBattle.lua @@ -60,48 +60,81 @@ if DEBUG == nil or DEBUG == false then DEBUG = nil end OverworldBattle.KEY = "battles" OverworldBattle.LABEL = "3D-BTL" --- Four rungs, and the ladder's order is the order they were added in rather --- than OFF-to-most: +-- Five rungs. Two independent choices, laid out as one ladder because they +-- are one question to the player -- WHAT is standing there, and WHERE: -- --- 2D-3D the mode this file was written for: the fight is staged on +-- on the MAP on two DISCS +-- pics 2D-3D A 2D-3D B +-- models STADIUM A STADIUM B +-- +-- 2D-3D A the mode this file was written for: the fight is staged on -- the map and the two Pokemon are the GB's OWN PICS, stood up -- on their tiles as quads (BattleBillboard). --- STADIUM A the same staged fight with the Pokemon Stadium battle models --- in place of those quads -- skinned, animated, and playing the +-- 2D-3D B those same pics on a pair of DISCS against the sky, with no +-- map at all (see lib/StadiumStage.lua). The Game Boy's own +-- framing with the Game Boy's own art, in three dimensions -- +-- and, like every B rung, it works everywhere, including the +-- caves and shop floors that have nowhere to stage a fight. +-- STADIUM A the staged fight with the Pokemon Stadium battle models in +-- place of those quads -- skinned, animated, and playing the -- animation the move being used actually calls for (see -- lib/Stadium.lua). The world is still the world: the fight -- happens on real ground, in the map's own weather and light. --- STADIUM B the same models on a pair of DISCS against the sky, with no --- map at all -- the Game Boy's own framing, staged rather than --- found (see lib/StadiumStage.lua). Works everywhere, including --- the maps that have nowhere to put a fight. +-- STADIUM B the models on the discs: both halves swapped at once. -- OFF the engine's own white battle screen. -- --- 2D-3D stays FIRST because ModSetting's values[1] is both the default and +-- A and B is the STAGE and it is the same stage either way -- the discs do +-- not know what is standing on them and BattleScene draws them off +-- `arena.discs` alone, which is why the second column cost a value in this +-- table and nothing else. The four combinations are all reachable rather +-- than only the diagonal, because a player who cannot use the STADIUM rungs +-- -- no ROM, or a ROM they would rather not go and find -- should still be +-- able to have the disc framing, and because the discs are the answer to +-- "this map has nowhere to fight" whichever art is standing on them. +-- +-- 2D-3D A stays FIRST because ModSetting's values[1] is both the default and -- what an unrecognised stored value falls back to, and the stored value for -- this row has been `true` since the row existed. Keeping `true` at the head --- means every save written before the STADIUM rungs existed reads back as --- the 2D-3D it was written for, and a mod whose headline is "the world in --- 3D" still does not need the player to go and find the switch. +-- means every save written before the later rungs existed reads back as the +-- 2D-3D it was written for, and a mod whose headline is "the world in 3D" +-- still does not need the player to go and find the switch. -- --- The stored value for STADIUM A is still the bare string "stadium" it was --- before there was a B, so a save written against the three-rung ladder --- keeps the mode it chose. +-- Every other stored value is likewise the one it has always been -- +-- "stadium" from before there was a B, "stadiumB" from before there was a +-- flat one -- so no save loses the mode it chose. -- -- Both STADIUM rungs are GATED on the models existing: the mod ships no -- Pokemon Stadium data, and until the player's own ROM has been found and -- built from (StadiumInstall) the row simply has two fewer stops. See -- ModSetting.setGate for why they are skipped rather than shown and refused. +-- 2D-3D B is NOT gated: its stage is generated in Lua and its Pokemon are +-- the game's own art, so it needs nothing the base game did not ship. +OverworldBattle.FLAT_B = "flatB" + OverworldBattle.setting = ModSetting.new(OverworldBattle.KEY, OverworldBattle.LABEL, - { true, "stadium", "stadiumB", false }, - { "2D-3D", "STADIUM A", "STADIUM B", "OFF" }) + { true, "flatB", "stadium", "stadiumB", false }, + { "2D-3D A", "2D-3D B", "STADIUM A", "STADIUM B", "OFF" }) :setGate(function(value) if value ~= "stadium" and value ~= "stadiumB" then return true end local ok, install = pcall(V.require, "StadiumInstall") return ok and install and install.available() end) +-- Whether the fight stands on the two carried DISCS rather than on the map +-- -- the B column above, whichever row of it. Asked by stageFor (what to +-- stage on), wantsFront (whether this map needs an arena at all) and, once +-- the arena carries the answer as `arena.discs`, by BattleScene and +-- VoxelScene for what to draw. +-- +-- Read straight off the row rather than through Stadium, because it is a +-- question about the STAGE and half the rungs that answer yes have no +-- Stadium models on them at all. +function OverworldBattle.discs() + local value = OverworldBattle.setting:get() + return (value == OverworldBattle.FLAT_B or value == "stadiumB") +end + -- Whether the VR row is ON -- read lazily, because VR requires modules -- that sit above this one. While it is, this mode stops being optional: -- the headset's battle seat, the pokedex screen and the effects plane @@ -216,10 +249,9 @@ function OverworldBattle.wantsFront() local g = require("src.core.Game") local ow = g and g.overworld if not (ow and ow.map and ow.player) then return false end - -- STADIUM B carries its own stage, so the answer is yes on every map and + -- a B rung carries its own stage, so the answer is yes on every map and -- there is nothing to search or to cache - local okS, stadium = pcall(V.require, "Stadium") - if okS and stadium and stadium.discs() then return true end + if OverworldBattle.discs() then return true end if staged.mapId ~= ow.map.id then local ok, arena = pcall(BattleArena.find, ow.map, ow.player.cellX, ow.player.cellY, @@ -468,17 +500,16 @@ function OverworldBattle.forceOG(g) end -- Where THIS fight stands, on whichever rung is running: the map's own --- ground, or the pair of discs STADIUM B carries with it. +-- ground, or the pair of discs a B rung carries with it. -- --- The one place the two rungs actually diverge, and it is worth stating --- plainly. On every other rung the answer can be NO -- a corridor, a shop --- floor, a map whose authored entry is a refusal -- and the battle then plays --- exactly as the vanilla game does. STADIUM B cannot fail: its stage is not --- something the map has to have room for, so a fight in the tightest cave in --- Kanto is staged as readily as one on Route 1. +-- The one place the two columns actually diverge, and it is worth stating +-- plainly. On an A rung the answer can be NO -- a corridor, a shop floor, a +-- map whose authored entry is a refusal -- and the battle then plays exactly +-- as the vanilla game does. A B rung cannot fail: its stage is not something +-- the map has to have room for, so a fight in the tightest cave in Kanto is +-- staged as readily as one on Route 1. function OverworldBattle.stageFor(state) - local ok, stadium = pcall(V.require, "Stadium") - if ok and stadium and stadium.discs() and Voxel3D.available() then + if OverworldBattle.discs() and Voxel3D.available() then local okStage, arena = pcall(function() return V.require("StadiumStage").arena(state.map) end) diff --git a/lib/Stadium.lua b/lib/Stadium.lua index 1103b20..239e8f1 100644 --- a/lib/Stadium.lua +++ b/lib/Stadium.lua @@ -1,10 +1,11 @@ -- STADIUM battles: the two Pokemon as real 3D models. -- --- The 3D-BTL row's third rung. OFF is the engine's own white battle field; --- 2D-3D stands the GB's own pics up on the map as quads (BattleBillboard); --- STADIUM replaces those quads with the Pokemon Stadium battle models -- --- skinned, animated, and playing the animation the move being used --- actually calls for. +-- The 3D-BTL row's two STADIUM rungs. OFF is the engine's own white battle +-- field; the 2D-3D rungs stand the GB's own pics up as quads +-- (BattleBillboard); STADIUM replaces those quads with the Pokemon Stadium +-- battle models -- skinned, animated, and playing the animation the move +-- being used actually calls for. A or B decides whether that happens on the +-- map or on two discs, and is the same choice on either pair of rungs. -- -- The models come out of the Stadium ROM through model_extract, and are -- packed into assets/stadium/NNN.dsm by tools/stadium_pack.py. Nothing here @@ -93,11 +94,15 @@ function Stadium.mode() return nil end --- Whether the fight is staged on the DISCS rather than on the map. Asked by --- BattleScene (what to draw the pair against), BattleArena (whether the map --- has to have room) and BattleCam (which framing to solve). +-- Whether the fight is staged on the DISCS rather than on the map. +-- +-- Not this file's question any more: the flat 2D-3D B rung stands the game's +-- own pics on the same two discs with no model anywhere in the frame, so the +-- stage and the actors are chosen separately (see OverworldBattle's ladder). +-- Kept as a forwarder because "are we on discs" is a fair thing to ask the +-- module named after the mode, and because the shot drivers ask it here. function Stadium.discs() - return Stadium.mode() == "B" + return V.require("OverworldBattle").discs() end function Stadium.enabled() @@ -120,6 +125,9 @@ function Stadium.begin(arena) -- sides that are going to collapse, but whose HP bar has not finished -- emptying yet (see faintReady) faintPending = {}, + -- who was standing in each slot last frame, so a replacement is noticed + -- even when it is the same species (see update) + at = {}, } return true end @@ -162,7 +170,32 @@ end -- mirrors them for the flat cards: there is no seam that reports "the foe is -- off screen right now", and a model left standing through a send-out or a -- damage blink would be the one thing in the frame that ignored the battle. -local function onField(battle, side) +-- ------- and the collapse gets to finish +-- +-- A fainted Pokemon leaves the field when its pic does, which is the end of +-- the engine's slide -- SlideDownFaintedMonPic, seven rows two frames apart, +-- FOURTEEN frames of a 60 Hz clock. Under a quarter of a second. +-- +-- The Stadium faint animations are nothing like that short. The briefest in +-- the set is 49 frames of a 30 Hz clock -- a second and two thirds -- the +-- median is 110 and the longest 230, which is nearly eight seconds. Held to +-- the pic's window every one of them was cut off inside its first fifth: the +-- Pokemon began to fall and vanished mid-fall, which is worse than not +-- animating at all, because the eye has been told something is happening and +-- then had it taken away. +-- +-- So a model that is COLLAPSING stays until it has finished collapsing, and +-- the two timings stop being tied to each other. That is the whole of the +-- divergence: the slide is how long a flat pic takes to slide off the bottom +-- of a 160x144 frame, and it has nothing to say about how long it takes a +-- Gyarados to fall over. +-- +-- Bounded at both ends rather than open-ended. It ends when the animation +-- does (StadiumMon.finished), not when the battle moves on -- so nothing is +-- left lying on the field for the rest of the fight -- and the side is reset +-- outright the moment a different battler stands in that slot (see update), +-- which is what stops the next Pokemon out of the ball arriving face down. +local function onField(battle, side, mon) local battler = side == "player" and battle.player or battle.enemy if not (battler and battler.sprite) then return false end if side == "enemy" then @@ -172,15 +205,18 @@ local function onField(battle, side) end local ok, hidden = pcall(battle.fxHidden, battle, battler) if ok and hidden then return false end - -- a fainted Pokemon is gone once its slide has finished, exactly as its - -- pic is if battler.fainted then local okF, sliding = pcall(battle.fxFaintActive, battle, battler) - if not (okF and sliding) then return false end + if okF and sliding then return true end + -- the pic has finished sliding away; the model has not finished falling + return (mon and mon.state == "faint" and not mon:finished()) and true + or false end return true end +Stadium._onField = onField + -- Whether the 3D model is standing in for this side's pic this frame. The -- one question OverworldBattle asks, and the answer that decides whether a -- billboard texture gets rendered for that side at all. @@ -227,8 +263,9 @@ local function faintStillDue(battler) and battler.mon and (battler.mon.hp or 0) <= 0) and true or false end --- named for the suite: the timing rule is the whole of this change, and it --- is testable without a graphics context where the mode itself is not +-- named for the suite: these timing rules are the whole of what decides when +-- a Pokemon falls and when it goes, and they are testable without a graphics +-- context where the mode itself is not Stadium._faintReady = faintReady Stadium._faintStillDue = faintStillDue @@ -249,6 +286,22 @@ function Stadium.update(dt, battle, groundY) if battler and not showingTrainer(battle, side) then dex = session.transform[side] or dexOf(battler.mon and battler.mon.species) end + + -- A DIFFERENT POKEMON IS IN THIS SLOT. Normally that shows up as a + -- change of species and setSpecies rebuilds everything -- but a trainer + -- who leads with two Rattata sends the second one out onto the first + -- one's dex number, so nothing downstream would notice. What it would + -- inherit is the state, and the state after a faint is `faint`, which + -- refuses every request there is (see StadiumMon.request -- a faint is + -- meant to be final). The new Pokemon would arrive lying on the ground. + -- + -- The battler TABLE is the identity here rather than the species or the + -- mon: it is the slot's occupant, and the engine replaces it on a switch, + -- a send-out and a new battle alike. + if session.at[side] ~= battler then + session.at[side] = battler + if mon and mon.rig and mon.state == "faint" then mon:play("idle") end + end -- the collapse this side is owed, once its bar has finished emptying if session.faintPending and session.faintPending[side] then if not faintStillDue(battler) then @@ -260,7 +313,7 @@ function Stadium.update(dt, battle, groundY) end mon:setSpecies(dex) - mon.visible = (mon.rig ~= nil) and onField(battle, side) + mon.visible = (mon.rig ~= nil) and onField(battle, side, mon) and not (battler and battler.substituteHP) -- cleared up front, so a side that has just lost its rig cannot leave -- last frame's matrix behind it @@ -331,6 +384,15 @@ function Stadium.animOf(side) return mon and mon.state or nil end +-- Whether this side's model is actually being drawn this frame. Named for +-- the shot drivers alongside animOf: "how long does it stay" is a span, and +-- a screenshot taken at one moment has no span in it. +function Stadium.showing(side) + if not session then return false end + local mon = session[side] + return (mon and mon.visible) and true or false +end + -- How wide the Pokemon on `side` stands, in world pixels, or nil when there -- is not one. What STADIUM B sizes that side's platform to (StadiumStage). function Stadium.footprint(side) diff --git a/lib/StadiumInstall.lua b/lib/StadiumInstall.lua index 1314e4b..0bb4210 100644 --- a/lib/StadiumInstall.lua +++ b/lib/StadiumInstall.lua @@ -17,12 +17,17 @@ -- because PhysFS searches the save directory AND the game folder under the -- same names: -- --- * a folder install, or a checkout -- `baseroms/` next to main.lua, which --- is also where the decompilation's own `make init` puts it +-- * a folder install, or a checkout -- `baseroms/` next to main.lua -- * a packaged or fused build, where the game folder is inside an archive -- and cannot be written to -- `baseroms/` in the save directory, whose -- absolute path this reports on screen so it can be found -- +-- The file goes STRAIGHT IN THERE, with no revision subfolder under it. The +-- decompilation's own `make init` uses `baseroms/us/`, and the offline +-- pipeline under model_extract/ still reads from there because it shares that +-- tree -- but the instruction given to a player is "drop the file in this +-- folder", and one folder is the whole of it. +-- -- Any of `.z64`, `.n64` and `.v64` is accepted; StadiumRom normalises the -- byte order on load. -- @@ -52,11 +57,17 @@ StadiumInstall.FORMAT = "DSM3" StadiumInstall.COUNT = 151 --- Named ROM files, in the order the decompilation's own pipeline looks for --- them, then any ROM at all sitting in the folder. +-- Named ROM files, then any ROM at all sitting in the folder. +-- +-- Flat in `baseroms/`, with no revision subfolder: the offline pipeline under +-- model_extract/ keeps the decompilation's own `baseroms/us/` convention +-- because it shares that tree, but what is being asked of a PLAYER here is +-- "drop the file in this folder", and one folder is the whole of that +-- instruction. A path they have to build out of two parts is a path half of +-- them will get wrong, and the failure is silent -- the rungs are simply not +-- on the row. local NAMED = { StadiumInstall.ROM_DIR .. "/baserom.z64", - StadiumInstall.ROM_DIR .. "/us/baserom.z64", StadiumInstall.ROM_DIR .. "/baserom.n64", StadiumInstall.ROM_DIR .. "/baserom.v64", } diff --git a/lib/StadiumMon.lua b/lib/StadiumMon.lua index 7374aa9..8594364 100644 --- a/lib/StadiumMon.lua +++ b/lib/StadiumMon.lua @@ -145,11 +145,38 @@ end -- ------- which species this side is showing -- --- Returns true when the model is ready to draw. A species with no pack, or --- one whose meshes would not build, answers false -- and Stadium then --- leaves that side to the flat card, which is a per-POKEMON decline rather --- than a per-battle one: a fight can perfectly well have a model on one --- side and a pic on the other. +-- Returns true when the model is ready to draw. A species with no pack, one +-- whose meshes would not build, or one whose animation data is corrupt at +-- source answers false -- and Stadium then leaves that side to the flat +-- card, which is a per-POKEMON decline rather than a per-battle one: a fight +-- can perfectly well have a model on one side and a pic on the other. +-- +-- ------- the three the extraction cannot read +-- +-- Exeggutor, Tangela and Magmar come out of the ROM with standby loops that +-- throw bones hundreds of units off the body -- the game's own index +-- arithmetic evidently reads those channel streams differently from the way +-- this does. StadiumBuild.idleIsBroken measures it and the pack carries the +-- verdict as `staticPose`. +-- +-- That flag used to mean "stand this one still in its bind pose", on the +-- reasoning that a Pokemon standing there looking like itself beats one +-- coming apart. It does -- but only just. A bind pose is a RIGGING pose, not +-- a portrait: arms out, neck straight, nothing where the artwork put it. Set +-- among a hundred and forty-eight species that breathe, the three that hold +-- a T-pose for the whole fight do not read as "these ones do not animate", +-- they read as broken -- which they are. +-- +-- So they decline instead, and the Game Boy's own battle sprite stands on +-- the tile in front of the same arena, lit the same way, drawn by the same +-- 2D-3D path every species uses when its model is unavailable. The mode +-- already had that fallback for a species with no pack at all; this is three +-- more species taking it. +-- +-- Keyed on the DATA rather than on a list of dex numbers on purpose: the +-- test that produced the flag is in the packer, so a re-extraction that +-- fixes those streams -- or breaks a fourth species -- moves this with it +-- and nothing here has to be edited. function StadiumMon:setSpecies(dex) if dex == self.species then return self.rig ~= nil end if self.rig then self.rig:release() end @@ -157,6 +184,7 @@ function StadiumMon:setSpecies(dex) if not dex then return false end local model = StadiumPack.load(dex) if not model then return false end + if model.staticPose then return false end local rig = StadiumRig.new(model) if not rig then return false end self.model, self.rig = model, rig @@ -196,12 +224,9 @@ function StadiumMon:play(state, animIndex, auxIndex) if not anim then return false end self.state, self.anim, self.time = state, index, 0 - -- A species whose animations are corrupt at source stands in its BIND - -- pose and does not move. The state machine still runs -- the fight is - -- still asking for a hit or a faint, and something may want to know -- - -- but nothing is ever sampled, so the Pokemon simply stands there - -- looking like itself, which is the one thing the broken data cannot do. - if model.staticPose then self.anim = nil end + self.done = false + -- (a species whose animations are corrupt at source never gets this far: + -- setSpecies declines it outright and its flat pic stands instead) self.loop = def.loop and true or false self.hold = def.hold and true or false -- The eyes that go with it. Every skeletal animation carries the texture @@ -260,6 +285,12 @@ function StadiumMon:update(dt) -- a faint stays down: hold the last frame rather than snapping back -- to a standing pose the moment the animation runs out self.time = math.max(0, anim.seconds - 1 / StadiumMon.FPS) + -- and SAY so, once. The clamp above means the clock can no longer be + -- asked whether the animation is over -- it stops a frame short of the + -- end and stays there forever -- and something has to know, because a + -- collapse that has finished is the moment the Pokemon may leave the + -- field (see Stadium's onField). + self.done = true else local nextState = (STATES[self.state] or {}).next or "idle" self:play(nextState) @@ -267,6 +298,14 @@ function StadiumMon:update(dt) end end +-- Whether a HELD animation -- which in practice means a faint -- has played +-- all the way through and is now sitting on its last frame. Always false for +-- a looping one, which never finishes, and for one that hands on to another +-- state, which has already stopped being itself by the time anyone can ask. +function StadiumMon:finished() + return self.done and true or false +end + -- How tall this species stands on the map, in world pixels. function StadiumMon:worldHeight() local model = self.model @@ -336,8 +375,8 @@ end -- doing on the CPU. function StadiumMon:build() if not (self.rig and self.model) then return false end - -- self.anim is nil for a static species, and pose() reads that as "the - -- bind pose", which is exactly what is wanted + -- self.anim is nil while a species has nothing to play, and pose() reads + -- that as "the bind pose", which is exactly what is wanted self.rig:pose(self.anim, self.time * StadiumMon.FPS, self.loop) self.rig:skin(self.yaw or 0) -- no clock of its own: the texture animation rides the frame pose() just diff --git a/lib/StadiumRig.lua b/lib/StadiumRig.lua index bb7614a..57f5f48 100644 --- a/lib/StadiumRig.lua +++ b/lib/StadiumRig.lua @@ -152,32 +152,71 @@ end -- animation's looping. -- One component at one frame. --- --- ------- why there is no interpolation here --- --- There used to be, and it was the cause of a real glitch: bones snapping to --- an upside-down pose for a frame, arms turning inside out for a few. --- --- These streams are not keyframes. They carry ONE VALUE PER FRAME at 30 Hz --- -- the sampler they come out of indexes them by frame number and clamps --- (`min(frame, count - 1)`), with no times and nothing to interpolate --- between. The game plays them a frame at a time, and at 60 Hz each pose --- simply holds for two. --- --- Blending between consecutive entries therefore invented motion that was --- never authored, and for ROTATION it invented the wrong motion. These are --- Euler triples, and a Euler triple is not a direction you can walk along: --- interpolating (0, 20976, 32736) toward (0, -19936, -5904) component by --- component passes through orientations that are nothing like either end -- --- which is precisely a bone flipping over and back inside one frame. --- Slerping proper quaternions would fix the direction, but there is still --- nothing to slerp TOWARD: the next entry is the next frame, not a keyframe --- some distance away, and holding it is what the source does. local function sampleAt(c, i) if type(c) == "number" then return c end return c[i] end +-- ------- interpolation, and the one place it must not happen +-- +-- These streams are not keyframes: they carry ONE VALUE PER FRAME at 30 Hz, +-- and the game steps them a frame at a time. So at 60 Hz the honest replay +-- is each pose held for two frames -- which is exactly what it looks like, +-- a set of models moving at half the frame rate of everything around them. +-- Blending between consecutive entries is therefore not reconstructing +-- something the source had; it is INVENTING the halfway pose. It is worth +-- inventing, because a 30 Hz step against a 60 Hz camera reads as a stutter +-- and the halfway pose is right far more often than it is wrong. +-- +-- Where it IS wrong is the reason a naive version of this shipped once and +-- had to be taken out: bones snapping to an upside-down pose for a frame, +-- arms turning inside out for a few. Rotations here are EULER TRIPLES, and +-- a Euler triple is not a direction you can walk along. Two triples can +-- describe nearly the same orientation and be nowhere near each other +-- component by component -- (0, 20976, 32736) and (0, -19936, -5904) are a +-- real pair out of the set -- so walking from one to the other passes +-- through orientations that are nothing like either end. That is precisely +-- a bone flipping over and back inside one frame. +-- +-- Shortest-arc wrapping (below) fixes the easy half of that, where a +-- component crosses the +-pi seam. It cannot fix the hard half, where the +-- source simply RE-EXPRESSES a rotation. So the hard half is not fixed, it +-- is DETECTED: a bone whose rotation moves more than BREAK_ANGLE in a +-- single frame is not being animated, it is being re-expressed or snapped, +-- and that bone holds its frame instead of blending. Per bone and all three +-- components together, because the three are one rotation and blending two +-- of them while holding the third is its own wrong answer. +-- +-- The same guard, in the same spirit, for TRANSLATION: BREAK_MOVE of the +-- model's own height inside one frame is a teleport rather than a stride. +-- Scale needs none -- a linear blend of two scales lies between them, and +-- there is no way for that to be a pose neither end had. + +-- 32768 binary-angle units is pi, so this is a quarter turn in one 30 Hz +-- frame -- 2700 degrees a second. Nothing in the set genuinely moves that +-- fast; everything that reads as moving that fast is a re-expression. +local BREAK_ANGLE = 16384 + +-- and half the Pokemon's own height in one frame, which is fifteen body +-- heights a second +local BREAK_MOVE = 0.5 + +-- The signed distance from `c[i0]` to `c[i1]` the SHORT way round, for a +-- binary angle. Interpolating 32700 toward -32700 the long way spins the +-- bone most of a full turn inside one frame; the short way is 136 units, +-- which is what actually happened. +local function angleDelta(c, i0, i1) + if type(c) == "number" then return 0 end + local d = c[i1] - c[i0] + if d > 32768 then d = d - 65536 elseif d < -32768 then d = d + 65536 end + return d +end + +local function linearDelta(c, i0, i1) + if type(c) == "number" then return 0 end + return c[i1] - c[i0] +end + -- ------- the pose -- -- `anim` is an index into model.anims (or nil for the bind pose), `frame` a @@ -190,34 +229,48 @@ function StadiumRig:pose(anim, frame, wrap) local tracks = anim and StadiumPack.tracks(model, anim) or nil local frames = anim and model.anims[anim] and model.anims[anim].frames or 1 - -- which frame of the animation this instant shows. One frame, not two: - -- the streams are per-frame and are stepped, not blended (see sampleAt). - local i0 = 1 + -- The two frames this instant falls between, and how far. `k` is 0 on + -- every whole frame, so a caller that steps in whole frames -- the test + -- suite, the blink probe -- sees exactly the frame it asked for. + local i0, i1, k = 1, 1, 0 if tracks and frames > 1 then local f = frame if f < 0 then f = 0 end local base = floor(f) + k = f - base + local loop = model.anims[anim].loopStart or 0 + if not (loop > 0 and loop < frames) then loop = 0 end if base >= frames then if wrap then -- the far end joins back to loopStart, which is where the game's own -- player sends the counter (func_80016FBC) - local loop = model.anims[anim].loopStart or 0 - if loop > 0 and loop < frames then - base = loop + (base - loop) % (frames - loop) - else - base = base % frames - end + base = loop + (base - loop) % (frames - loop) else base = frames - 1 -- a faint holds where it fell + k = 0 end end i0 = base + 1 if i0 > frames then i0 = frames end if i0 < 1 then i0 = 1 end + -- and the frame after it, which past the end of a loop is loopStart -- + -- the same seam the counter itself crosses. An animation that HOLDS + -- (a faint) has nothing after its last frame, so it blends with itself. + if i0 < frames then + i1 = i0 + 1 + elseif wrap then + i1 = loop + 1 + else + i1, k = i0, 0 + end end -- The frame this animation is actually SHOWING, after the wrap or the - -- hold, 0-based. Stashed rather than recomputed because the texture + -- hold, 0-based -- the WHOLE frame, never the blend. A texture swap has no + -- halfway: an eye is open or it is shut, and a pupil interpolated toward a + -- swirl is not a thing the hardware could draw. So the skeleton runs at 60 + -- and the textures step at 30, which is what the game does with both. + -- Stashed rather than recomputed because the texture -- animation is sampled at the very same frame (see textures) -- in the -- game one counter drives both, and 73% of the paired animations in the -- set are the same length as each other, which is what that looks like @@ -230,6 +283,17 @@ function StadiumRig:pose(anim, frame, wrap) local pivot, drw = self.pivotM, self.drawM local accX, accY, accZ = self.accX, self.accY, self.accZ + -- how far a bone may travel in one frame before it is read as a teleport + -- rather than a stride. In the vertices' own RAW units, which is what the + -- tracks are in: model.height is measured after the model_root scale. + local moveBreak = nil + if k > 0 then + local root = model.rootScale + if not (root and root > 0) then root = 1 end + local h = (model.height or 0) / root + if h > 0 then moveBreak = h * BREAK_MOVE end + end + for b = 1, n do local o3 = (b - 1) * 3 local tx, ty, tz, rx, ry, rz, kx, ky, kz @@ -244,6 +308,38 @@ function StadiumRig:pose(anim, frame, wrap) kx = sampleAt(comps[7], i0) ky = sampleAt(comps[8], i0) kz = sampleAt(comps[9], i0) + if k > 0 then + -- ROTATION, all three at once: a bone that snaps holds its frame, + -- and a bone that moves holds none of it (see BREAK_ANGLE) + local dx = angleDelta(comps[4], i0, i1) + local dy = angleDelta(comps[5], i0, i1) + local dz = angleDelta(comps[6], i0, i1) + if dx < 0 then dx = -dx end + if dy < 0 then dy = -dy end + if dz < 0 then dz = -dz end + if dx <= BREAK_ANGLE and dy <= BREAK_ANGLE and dz <= BREAK_ANGLE then + rx = rx + angleDelta(comps[4], i0, i1) * k + ry = ry + angleDelta(comps[5], i0, i1) * k + rz = rz + angleDelta(comps[6], i0, i1) * k + end + -- TRANSLATION, likewise together: the three are one offset + local mx = linearDelta(comps[1], i0, i1) + local my = linearDelta(comps[2], i0, i1) + local mz = linearDelta(comps[3], i0, i1) + local far = false + if moveBreak then + far = (mx > moveBreak or mx < -moveBreak) + or (my > moveBreak or my < -moveBreak) + or (mz > moveBreak or mz < -moveBreak) + end + if not far then + tx, ty, tz = tx + mx * k, ty + my * k, tz + mz * k + end + -- SCALE, which cannot land anywhere the two ends did not bracket + kx = kx + linearDelta(comps[7], i0, i1) * k + ky = ky + linearDelta(comps[8], i0, i1) * k + kz = kz + linearDelta(comps[9], i0, i1) * k + end else -- a bone this animation never touches keeps its rest transform tx, ty, tz = restT[o3 + 1], restT[o3 + 2], restT[o3 + 3] diff --git a/lib/StadiumScreen.lua b/lib/StadiumScreen.lua index 97a1e81..bfeee92 100644 --- a/lib/StadiumScreen.lua +++ b/lib/StadiumScreen.lua @@ -15,9 +15,20 @@ -- -- ------- what it says -- --- Which Pokemon it is on, by name. That is not decoration: it is the --- difference between a progress bar the player trusts and one they suspect --- has hung, and it costs one lookup a frame. +-- Three things, and each of them is answering a question the player would +-- otherwise have to guess at while the game sits there: +-- +-- WHAT is happening -- "ONE-TIME EXTRACTION OF STADIUM ASSETS", in those +-- words, because the two facts that matter to somebody who has just +-- installed a mod and been handed a loading screen are that this is the +-- mod's own asset build and that it is not going to happen again. +-- +-- HOW FAR through it is -- a bar, filled by species written rather than by +-- elapsed time, so it cannot lie about the remaining work. +-- +-- THAT IT IS ALIVE -- which Pokemon it is on, by name. Not decoration: it +-- is the difference between a progress bar the player trusts and one they +-- suspect has hung, and it costs one lookup a frame. -- the mod namespace (see main.lua): V.require loads a sibling module local V = ... @@ -138,14 +149,18 @@ function StadiumScreen:draw() love.graphics.setColor(0.93, 0.94, 0.90, 1) love.graphics.rectangle("fill", 0, 0, W, H) - centred("POKEMON STADIUM", 24) - centred("BATTLE MODELS", 34) + centred("POKEMON STADIUM", 16) + -- the headline, in two lines because the frame is 160 pixels wide and the + -- font is a fixed eight: the longer of these is nineteen glyphs + centred("ONE-TIME EXTRACTION", 36) + centred("OF STADIUM ASSETS", 46) if status.state == "failed" then - centred("COULD NOT BUILD", 62) + centred("COULD NOT BUILD", 70) local why = tostring(status.error or "unknown"):sub(1, 24) - centred(why, 76) - centred("STADIUM IS OFF", 96) + centred(why, 84) + centred("STADIUM IS OFF", 104) + love.graphics.setColor(1, 1, 1, 1) return end @@ -153,23 +168,34 @@ function StadiumScreen:draw() local total = status.total or StadiumInstall.COUNT local frac = (total > 0) and (done / total) or 1 if status.state == "done" then frac = 1 end + if frac < 0 then frac = 0 elseif frac > 1 then frac = 1 end - -- the bar: a dark frame with a dark fill, so it reads on the light plate - local bx, by, bw, bh = 24, 66, W - 48, 9 + -- The bar: a dark frame, an EMPTY interior the same colour as the plate, + -- and a dark fill growing left to right. + -- + -- The track has to be the plate's own white rather than a light grey. This + -- draws inside the Game Boy frame, so the colorization pass quantises + -- everything here into the four GB shades and paints them -- and a grey + -- track lands one shade down, which comes out as a bar that is GREEN where + -- the work is still to do and dark where it is done. The eye reads colour + -- as the filled part and gets the progress exactly backwards. + local bx, by, bw, bh = 24, 68, W - 48, 9 love.graphics.setColor(0.06, 0.05, 0.09, 1) love.graphics.rectangle("fill", bx - 1, by - 1, bw + 2, bh + 2) - love.graphics.setColor(0.80, 0.80, 0.78, 1) + love.graphics.setColor(0.93, 0.94, 0.90, 1) love.graphics.rectangle("fill", bx, by, bw, bh) love.graphics.setColor(0.06, 0.05, 0.09, 1) love.graphics.rectangle("fill", bx, by, math.floor(bw * frac + 0.5), bh) if status.state == "done" then - centred("READY", 84) + centred("READY", 86) else local name = speciesName(status.species) - centred(("%d/%d"):format(done, total), 84) - if name then centred(name, 96) end - centred("BUILDING FROM ROM", 112) + centred(("%d/%d"):format(done, total), 86) + if name then centred(name, 98) end + -- last, and lowest: the reassurance is worth saying and is the least + -- urgent thing on the plate + centred("THIS RUNS ONCE", 122) end love.graphics.setColor(1, 1, 1, 1) end diff --git a/lib/StadiumStage.lua b/lib/StadiumStage.lua index 12582cb..7336ff9 100644 --- a/lib/StadiumStage.lua +++ b/lib/StadiumStage.lua @@ -1,8 +1,16 @@ --- STADIUM B: the two discs the fight is staged on. +-- The B rungs: the two discs the fight is staged on. -- --- The 3D-BTL row's fourth rung. Where STADIUM A puts the Pokemon Stadium --- models on the MAP -- real ground, whatever the route happens to look like --- -- this puts them on two platforms against the sky and draws no map at all. +-- Where an A rung puts the fight on the MAP -- real ground, whatever the +-- route happens to look like -- a B rung puts it on two platforms against +-- the sky and draws no map at all. +-- +-- ------- one stage, two rungs +-- +-- The discs do not know what is standing on them. 2D-3D B stands the Game +-- Boy's own battle pics there and STADIUM B stands the Pokemon Stadium +-- models, and this file is identical for both: it draws two platforms at two +-- cells and sizes each to whatever footprint it is given. That is why the +-- flat disc rung cost a value in the 3D-BTL ladder and nothing else. -- -- ------- why this is a rung and not a fix -- @@ -73,10 +81,12 @@ StadiumStage.RADIUS = 18 StadiumStage.MAX_RADIUS = 34 StadiumStage.PAD = 1.8 --- The platform for a mon of this footprint. `r` may be nil (nothing standing --- there yet, which is every frame of the send-out before the model appears), --- and then the platform is the plain one -- it has to be there BEFORE the --- Pokemon lands on it. +-- The platform for a mon of this footprint. `r` may be nil -- nothing is +-- standing there yet, which is every frame of the send-out before the model +-- appears, and it is also the whole of the flat 2D-3D B rung, where a +-- Pokemon is a battle pic sized to cover exactly one map cell and RADIUS is +-- already a little over that. Either way the platform is the plain one, and +-- it has to be there BEFORE the Pokemon lands on it. function StadiumStage.radiusFor(r) local want = (r or 0) * StadiumStage.PAD if want < StadiumStage.RADIUS then return StadiumStage.RADIUS end @@ -275,7 +285,7 @@ end -- ------- the synthetic arena -- --- STADIUM B does not search the map, because it does not stand on it. The +-- A B rung does not search the map, because it does not stand on it. The -- arena is the same WIDE shape every other staged fight uses -- so the two -- cells are three apart down the middle and BattleCam frames them exactly as -- it always has -- just placed at a fixed spot rather than a found one. diff --git a/lib/VoxelScene.lua b/lib/VoxelScene.lua index f7abea3..dd4a146 100644 --- a/lib/VoxelScene.lua +++ b/lib/VoxelScene.lua @@ -1088,7 +1088,7 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor, eyes) -- model faces its opponent, not the viewer, so both eyes see the same -- pose from their own seats, which is what makes it read as solid. -- - -- On STADIUM B the platforms come with them. In a headset the world is + -- On a disc rung the platforms come with them. In a headset the world is -- still drawn -- the player is standing IN it, which is the whole point -- of the headset, so the rung's "no map" does not apply here -- and the -- discs then read as a stage set down on the ground, which is what they diff --git a/main.lua b/main.lua index 9fd4a6d..af08d7d 100644 --- a/main.lua +++ b/main.lua @@ -429,16 +429,16 @@ local SETTINGS = { -- and forbids back sprites (backPinned answers false), so both rows -- decide nothing there and a dead switch on the menu reads as broken. { OverworldBattle.setting, - "Fight on the map: the battle draws over the nearest clear ground, " - .. "shot over the shoulder with a slow parallax drift. 2D-3D stands " - .. "the game's own pics up on their tiles. STADIUM A replaces them with " - .. "the Pokemon Stadium battle models, animated, playing the animation " - .. "the move being used actually calls for. STADIUM B stands those same " - .. "models on two discs against the sky instead of on the map, which " - .. "works everywhere -- including the caves and shop floors that have " - .. "nowhere to stage a fight. The STADIUM rungs only appear once the " - .. "models have been built: drop a Pokemon Stadium (US) ROM in the " - .. "baseroms folder and restart.", + "Fight in three dimensions, shot over the shoulder with a slow parallax " + .. "drift. 2D-3D stands the game's own battle pics up as cards; STADIUM " + .. "replaces them with the Pokemon Stadium battle models, animated, " + .. "playing the animation the move being used actually calls for. A " + .. "stages the fight on the MAP -- the nearest clear ground, in that " + .. "place's own weather and light; B stands it on two discs against the " + .. "sky instead, which works everywhere, including the caves and shop " + .. "floors that have nowhere to stage a fight. The STADIUM rungs only " + .. "appear once the models have been built: drop a Pokemon Stadium (US) " + .. "ROM in the baseroms folder and restart.", when = function() return not VR.enabled() end, 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, diff --git a/manifest.json b/manifest.json index 7b63f85..37c5cae 100644 --- a/manifest.json +++ b/manifest.json @@ -15,6 +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. The 3D-BTL row also offers two STADIUM rungs, which replace the flat battle pics with the Pokemon Stadium battle models -- all 151 species, animated, playing the animation the move being used calls for -- either on the map or on two discs against the sky; those need a Pokemon Stadium (US) ROM of your own dropped in a baseroms/ folder, and build themselves out of it on first run. 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. The 3D-BTL row is five rungs across two choices: 2D-3D stands the game's own battle pics up as cards and STADIUM replaces them with the Pokemon Stadium battle models -- 148 of the 151 species, animated, playing the animation the move being used calls for -- while A stages the fight on the map and B on two carried discs against the sky, which works on every map including the ones with nowhere to put a fight. The STADIUM rungs need a Pokemon Stadium (US) ROM of your own dropped in a baseroms/ folder, and build themselves out of it on first run. 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" } diff --git a/mod.card b/mod.card index 2b60209..414fbc2 100644 --- a/mod.card +++ b/mod.card @@ -10,8 +10,9 @@ return { changed = { "with VOXEL on, the overworld draws as 3D geometry instead of flat tiles", "occlusion comes from a depth buffer rather than a y-sort, so buildings really hide what is behind them", - "with 3D-BTL on 2D-3D, a battle draws over the map's nearest clear ground instead of over a white field", - "with 3D-BTL on STADIUM A or STADIUM B, the same fight is staged with the Pokemon Stadium battle models in place of the flat pics -- all 151 species, skinned and animated, playing the animation the move being used actually calls for. A stands them on the map; B stands them on two discs against the sky, which works on every map including the caves and shop floors that have nowhere to stage a fight", + "with 3D-BTL on 2D-3D A, a battle draws over the map's nearest clear ground instead of over a white field", + "with 3D-BTL on a B rung, the fight is staged on two discs against the sky with no map drawn at all, which works on every map including the caves and shop floors that have nowhere to stage a fight", + "with 3D-BTL on STADIUM A or STADIUM B, the same fight is staged with the Pokemon Stadium battle models in place of the flat pics -- 148 of the 151 species, skinned and animated, playing the animation the move being used actually calls for; Exeggutor, Tangela and Magmar have corrupt animation data at source and keep their battle sprites", "the battle's text box and menu are frosted glass over that ground rather than an opaque white slab, on the same panels the HUDs sit on", "the map's NPCs are culled for the length of a battle, so the wipe plays over an empty map", "a battle's letterbox voids go black rather than white, because the battle canvas is no longer white", @@ -32,7 +33,7 @@ return { "V-GRID on hotkey 5 and V-CURVE on hotkey 7", "Q and E zoom whichever camera is in front of you -- the third-person boom, a staged battle's lens, or the engine's own survey zoom on an orbit rung -- alongside the mouse wheel, a two-finger pinch, and the pad's left and right stick clicks (out and in). 1ST claims none of them: the eye is in the player's head and there is no distance to change", "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 (2D-3D / STADIUM A / STADIUM B / OFF, 2D-3D by default), battles fought on the world map -- 2D-3D stands the game's own pics up on their tiles, and both STADIUM rungs replace them with the Pokemon Stadium battle models. The STADIUM rungs are only on the row once those models have been built (see below)", + "3D-BTL on hotkey 8 (2D-3D A / 2D-3D B / STADIUM A / STADIUM B / OFF, 2D-3D A by default), battles fought in 3D -- 2D-3D stands the game's own pics up as cards and STADIUM replaces them with the Pokemon Stadium battle models, while A stages the fight on the map and B on two carried discs against the sky. Only the STADIUM rungs need a ROM, and they are on the row once those models have been built (see below); 2D-3D B is generated in Lua and needs nothing", "the STADIUM animations are driven from the fight: a move plays the animation that species' own battle table names for it (so DIG really does put Diglett into the ground), damage plays the hit reaction, fainting plays the faint and holds there, and a send-out plays the entrance. The eyes blink and go dizzy, and Charmander's tail flame and Weezing's gas are drawn over the body", "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", "VR options row (OFF / ON, off by default): PCVR through OpenXR on Windows -- the diorama as a head-tracked tabletop model presented at the rung's own angle and framing on the orbit rungs, life-size first person on 1ST, a staged battle snapping the headset (through a fade to black) into the flat game's own over-the-shoulder seat at life scale, a voxel Pokedex flush along the left controller in first person and in battles (menus, dialogs and the 2D battle screen on its screen; the diorama does without it), the sky and its sun and moon anchored in space (bands, GBC dither and twilight glow alike -- nothing in the sky reacts to the head), the floating panel wearing the GB frame near-square rather than the whole monitor-wide window (scaled into the headset, so the picture and its ratio are identical at every window size, fullscreen included), the window as mirror; needs a runtime (SteamVR/Oculus/WMR) and the mod on a real folder", diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua index 244e8ff..f99b8f6 100644 --- a/tests/dramatic_shape_test.lua +++ b/tests/dramatic_shape_test.lua @@ -419,7 +419,7 @@ T.eq(grid.value(), "OFF", "the grid starts off") T.eq(curve.label, "V-CURVE", "the curve row carries its label") T.eq(curve.value(), "OFF", "the curve starts off") T.eq(battles.label, "3D-BTL", "the overworld-battle row carries its label") -T.eq(battles.value(), "2D-3D", +T.eq(battles.value(), "2D-3D A", "overworld battles are on by default, on the rung that stands the game's " .. "own pics on the map -- the mode's headline is the world in 3D, and a " .. "battle is where the player spends half the game") @@ -1136,18 +1136,25 @@ Game.keypressed(keyGame, "7") T.neq(Curve.setting:get(), curveBefore, "7 cycles V-CURVE") local Battles = run.loader.exports.DRAMATIC_SHAPE.lib.require("OverworldBattle") -T.eq(Battles.setting:get(), true, "3D-BTL starts on 2D-3D") +T.eq(Battles.setting:get(), true, "3D-BTL starts on 2D-3D A") +T.eq(Battles.discs(), false, "which stages the fight on the map") +Game.keypressed(keyGame, "8") +T.eq(Battles.setting:get(), "flatB", + "8 steps to 2D-3D B -- the same pics, on the discs") +T.eq(Battles.discs(), true, + "and that IS a disc rung, with no Stadium model anywhere in it") Game.keypressed(keyGame, "8") T.eq(Battles.setting:get(), "stadium", - "8 steps to STADIUM A -- one press off the default is the more elaborate " - .. "staged fight, not the absent one") + "again and it is STADIUM A, the models on the map") +T.eq(Battles.discs(), false, "back on the map") Game.keypressed(keyGame, "8") T.eq(Battles.setting:get(), "stadiumB", - "again and it is STADIUM B, the discs") + "again and it is STADIUM B, the models on the discs") +T.eq(Battles.discs(), true, "on the discs again") Game.keypressed(keyGame, "8") T.eq(Battles.setting:get(), false, "again and overworld battles are off") Game.keypressed(keyGame, "8") -T.eq(Battles.setting:get(), true, "and the ladder wraps back to 2D-3D") +T.eq(Battles.setting:get(), true, "and the ladder wraps back to 2D-3D A") -- ------- the eyes blink once a loop, not six times a second -- @@ -1201,6 +1208,101 @@ T.eq(Battles.setting:get(), true, "and the ladder wraps back to 2D-3D") Pack.image = realImage end)() +-- ------- the skeleton runs at 60, the textures step at 30 +-- +-- The animation streams carry one value per frame at 30 Hz, so replayed +-- honestly against a 60 Hz camera every pose holds for two frames and the +-- models visibly stutter against everything around them. StadiumRig blends +-- between consecutive frames instead -- but NOT across a snap, because these +-- are Euler triples and two triples that describe nearly the same rotation +-- can be nowhere near each other component by component. Walking from one to +-- the other is a bone flipping over inside a frame, which is exactly the +-- glitch a naive version of this shipped with. +-- +-- Driven on a REAL species, through a rig with no meshes (there is no +-- graphics context here, and pose() only touches the matrix arrays). +;(function() + local lib = run.loader.exports.DRAMATIC_SHAPE.lib + local Pack, Rig = lib.require("StadiumPack"), lib.require("StadiumRig") + local model = Pack.load(25) -- Pikachu + local rig = setmetatable({ model = model, pivotM = {}, drawM = {}, + accX = {}, accY = {}, accZ = {}, parts = {} }, Rig) + local slot = model.ctx[Pack.SLOT.idle] + local anim = (slot ~= Pack.NONE) and (slot + 1) or nil + local frames = anim and model.anims[anim].frames or 0 + + -- every bone's world origin under the pose as it stands + local function origins() + local out = {} + for b = 1, model.boneCount do + local o = (b - 1) * 12 + out[b] = { rig.drawM[o + 4], rig.drawM[o + 8], rig.drawM[o + 12] } + end + return out + end + + T.check(frames > 8, "Pikachu's standby loop is long enough to sample") + + rig:pose(anim, 3, true) + local a = origins() + rig:pose(anim, 4, true) + local c = origins() + rig:pose(anim, 3.5, true) + local b = origins() + + T.eq(rig.frameAt, 3, + "half a frame in, the TEXTURE frame is still the whole one -- an eye is " + .. "open or it is shut and there is no halfway swap to draw") + + -- the bone that travels furthest between those two frames is the one with + -- something to say about the blend + local best, moved = nil, 0 + for i = 1, #a do + local d = ((c[i][1] - a[i][1]) ^ 2 + (c[i][2] - a[i][2]) ^ 2 + + (c[i][3] - a[i][3]) ^ 2) ^ 0.5 + if d > moved then best, moved = i, d end + end + T.check(moved > 0, "and some bone actually moves between frames 3 and 4") + + -- halfway is HALFWAY: within a twentieth of the step of the midpoint of the + -- two frames it sits between, on every axis. A blend that overshoots, or + -- that walks the long way round a wrapped angle, fails this by miles. + local slack = moved / 20 + for axis = 1, 3 do + local mid = (a[best][axis] + c[best][axis]) / 2 + T.check(math.abs(b[best][axis] - mid) <= slack, + ("the half-frame pose sits between its two frames on axis %d"):format(axis)) + end + + -- and a whole frame is the frame itself, untouched: the blend has to be + -- exactly nothing at k = 0, or every stepped caller (the blink probe, the + -- oracle diff) is reading a pose the pack does not contain + rig:pose(anim, 4, true) + local again = origins() + T.eq(again[best][1], c[best][1], + "and a whole frame is that frame exactly, with nothing blended into it") +end)() + +-- ------- the three species the extraction cannot read stand as PICS +-- +-- Exeggutor, Tangela and Magmar come out of the ROM with standby loops that +-- throw bones off the body; the packer measures that and flags them. They +-- used to hold their bind pose for the whole fight, which among a hundred and +-- forty-eight species that breathe reads as broken rather than as still. So +-- they decline the model outright and the Game Boy's own battle pic stands +-- instead -- the same fallback a species with no pack at all takes. +;(function() + local lib = run.loader.exports.DRAMATIC_SHAPE.lib + local Mon = lib.require("StadiumMon") + for _, dex in ipairs({ 103, 114, 126 }) do + local mon = Mon.new("enemy") + T.eq(mon:setSpecies(dex), false, + ("dex %d has corrupt animation data, so no model stands for it") + :format(dex)) + T.eq(mon.rig, nil, "and there is no rig left behind to draw") + end +end)() + -- ------- the collapse waits for the HP bar -- -- onFaint fires the moment HP reaches zero, but the engine queues the visible @@ -1230,32 +1332,81 @@ end)() T.eq(due({ mon = { hp = 0 } }), false, "and an unqueued battler owes nothing") end)() +-- ------- and it gets to FINISH +-- +-- The engine takes a fainted pic off the field when its slide ends, which is +-- fourteen frames of a 60 Hz clock -- under a quarter of a second. The +-- shortest faint animation in the Stadium set is 49 frames of a 30 Hz one, +-- the median 110 and the longest 230, so held to the pic's window every model +-- was cut off inside the first fifth of its own collapse. It now stays until +-- the animation is done, and not one frame past that. +;(function() + local Stad = run.loader.exports.DRAMATIC_SHAPE.lib.require("Stadium") + local onField = Stad._onField + + -- the engine's own guards, mirrored in the shape onField reads them + local battle = { fxHidden = function() return false end, + fxFaintActive = function(_, b) return b.sliding end } + local function mon(state, done) + return { state = state, finished = function() return done end } + end + + battle.enemy = { sprite = true } + T.eq(onField(battle, "enemy", mon("idle", false)), true, + "a Pokemon that is standing there is on the field") + + battle.enemy = { sprite = true, fainted = true, sliding = true } + T.eq(onField(battle, "enemy", mon("faint", false)), true, + "and one whose pic is still sliding is too, as it always was") + + battle.enemy = { sprite = true, fainted = true, sliding = false } + T.eq(onField(battle, "enemy", mon("faint", false)), true, + "the pic has gone but the model has not finished falling -- it STAYS, " + .. "which is the whole of the fix: a quarter-second window was cutting " + .. "off animations that run for one to eight seconds") + T.eq(onField(battle, "enemy", mon("faint", true)), false, + "and the frame its collapse finishes, it goes -- nothing is left lying " + .. "on the field for the rest of the fight") + T.eq(onField(battle, "enemy", mon("idle", false)), false, + "a fainted battler whose model never got as far as the faint goes with " + .. "the pic, exactly as before") + T.eq(onField(battle, "enemy", nil), false, + "and a side with no model at all is not held open by this") +end)() + -- ------- and the two STADIUM rungs are SKIPPED when the models are not there -- -- The mod ships no Pokemon Stadium data, so on a machine whose owner has not --- supplied that ROM the row has two stops rather than four. Checked by gating --- them off by hand rather than by hiding the packs, because what is being --- tested is the ladder's behaviour and not the installer's. +-- supplied that ROM the row has three stops rather than five. Checked by +-- gating them off by hand rather than by hiding the packs, because what is +-- being tested is the ladder's behaviour and not the installer's. +-- +-- 2D-3D B survives that, which is the point of it being its own rung: the +-- discs are generated in Lua and the Pokemon on them are the game's own art, +-- so the disc framing is available to a player who has no Stadium ROM at all. ;(function() local gate = Battles.setting.gate Battles.setting:setGate(function(value) return value ~= "stadium" and value ~= "stadiumB" end) - T.eq(Battles.setting:rungs(), 2, - "with no models built the 3D-BTL row offers two rungs, not four") + T.eq(Battles.setting:rungs(), 3, + "with no models built the 3D-BTL row offers three rungs, not five") Battles.setting:setValue(true, Game) Game.keypressed(keyGame, "8") - T.eq(Battles.setting:get(), false, - "and 8 steps straight past both STADIUM rungs to OFF") + T.eq(Battles.setting:get(), "flatB", + "8 still reaches 2D-3D B, which needs no ROM") Game.keypressed(keyGame, "8") - T.eq(Battles.setting:get(), true, "and back to 2D-3D") + T.eq(Battles.setting:get(), false, + "and the next press steps straight past both STADIUM rungs to OFF") + Game.keypressed(keyGame, "8") + T.eq(Battles.setting:get(), true, "and back to 2D-3D A") -- a save that CHOSE stadium before the ROM went missing reads as the -- default, rather than as a mode with nothing behind it -- and the stored -- value is left alone, so putting the ROM back restores the choice - Battles.setting.index = 2 + Battles.setting.index = 3 T.eq(Battles.setting:get(), true, - "a stored STADIUM with no models behind it reads as 2D-3D") + "a stored STADIUM with no models behind it reads as 2D-3D A") Battles.setting:setGate(gate) T.eq(Battles.setting:get(), "stadium", "and comes back the moment the models do") diff --git a/tests/stadium_shots.lua b/tests/stadium_shots.lua index b1d9066..fae53da 100644 --- a/tests/stadium_shots.lua +++ b/tests/stadium_shots.lua @@ -44,7 +44,10 @@ return function(game) { "ONIX", "DIGLETT" }, -- the tallest against nearly the shortest { "ZUBAT", "TENTACRUEL" }, -- hovers above its origin / hangs below it { "GASTLY", "CATERPIE" }, -- a floater and the smallest in the set - { "EXEGGUTOR", "MAGMAR" }, -- both idle animations are wrong at source + -- the two species whose animation data is corrupt at source: BOTH of + -- these must come out as flat battle pics standing on their tiles, not + -- as models (see StadiumMon.setSpecies). Tangela is the third. + { "EXEGGUTOR", "MAGMAR" }, { "GYARADOS", "SNORLAX" }, -- the two biggest bodies in the set } @@ -93,7 +96,8 @@ return function(game) -- the rung under test, and the back pic OFF: BACK SPRITES would keep the -- player's own side on the menu, which is exactly the half of the shot -- this driver exists to look at - local RUNGS = { cards = true, a = "stadium", b = "stadiumB" } + local RUNGS = { cards = true, cardsb = "flatB", a = "stadium", + b = "stadiumB" } local rung = RUNGS[os.getenv("DS_RUNG") or "a"] or "stadium" Battles.setting:setValue(rung, game) Battles.backSetting:setValue(false, game) @@ -205,8 +209,8 @@ return function(game) U.wait(12) U.tap(game, "a") - local barZeroAt, animAt, shot = nil, nil, false - for f = 1, 400 do + local barZeroAt, animAt, shot, stood = nil, nil, false, 0 + for f = 1, 700 do -- keep advancing the text, as a player would. Without this the queue -- blocks on the damage message and the HP bar never drains at all -- -- which is a perfectly good demonstration that the collapse now waits, @@ -230,8 +234,18 @@ return function(game) U.shot(game, ("%s/faint_1_draining.png"):format(DIR)) shot = true end - if animAt and f > animAt + 20 then - U.shot(game, ("%s/faint_2_collapsed.png"):format(DIR)) + -- and how long the model then STAYS. The engine takes the flat pic off + -- after a fourteen-frame slide; the animation runs for one to eight + -- seconds, and being cut off mid-fall is worse than not falling at all. + if animAt and Stadium2.showing("enemy") then stood = f - animAt end + if animAt and f == animAt + 20 then + U.shot(game, ("%s/faint_2_collapsing.png"):format(DIR)) + end + if animAt and f == animAt + 90 then + U.shot(game, ("%s/faint_3_still_falling.png"):format(DIR)) + end + if animAt and f > animAt + 20 and not Stadium2.showing("enemy") then + U.shot(game, ("%s/faint_4_gone.png"):format(DIR)) break end end @@ -242,6 +256,9 @@ return function(game) and " OK -- the collapse waits for the bar" or " WRONG -- the collapse ran while the bar was still draining") end + U.log((" the model stayed on the field for %d frames (%.2fs) after the " + .. "collapse began; the engine's own pic slide is 14 (%.2fs)") + :format(stood, stood / 60, 14 / 60)) while game.stack:top() and game.stack:top() ~= game.overworld do game.stack:pop() end