From 6280cdffe7f62578d9788577d097389684f0ece0 Mon Sep 17 00:00:00 2001 From: DramaticShape Date: Tue, 28 Jul 2026 13:01:38 -0400 Subject: [PATCH 1/2] Added overworld battles --- .modkitignore | 3 + CHANGELOG.md | 47 ++- README.md | 48 ++- data/battle_arenas.lua | 184 +++++++++ lib/BattleArena.lua | 353 ++++++++++++++++++ lib/BattleBillboard.lua | 120 ++++++ lib/BattleCam.lua | 193 ++++++++++ lib/BattleDOF.lua | 187 ++++++++++ lib/BattleHud.lua | 349 +++++++++++++++++ lib/BattlePics.lua | 157 ++++++++ lib/BattleScene.lua | 422 +++++++++++++++++++++ lib/OverworldBattle.lua | 683 ++++++++++++++++++++++++++++++++++ lib/Voxel3D.lua | 72 +++- lib/VoxelScene.lua | 27 +- main.lua | 79 +++- manifest.json | 4 +- mod.card | 17 +- tests/arena_pick.lua | 173 +++++++++ tests/battle_shots.lua | 130 +++++++ tests/dramatic_shape_test.lua | 402 +++++++++++++++++++- tools/contact_sheets.py | 122 ++++++ 21 files changed, 3734 insertions(+), 38 deletions(-) create mode 100644 data/battle_arenas.lua create mode 100644 lib/BattleArena.lua create mode 100644 lib/BattleBillboard.lua create mode 100644 lib/BattleCam.lua create mode 100644 lib/BattleDOF.lua create mode 100644 lib/BattleHud.lua create mode 100644 lib/BattlePics.lua create mode 100644 lib/BattleScene.lua create mode 100644 lib/OverworldBattle.lua create mode 100644 tests/arena_pick.lua create mode 100644 tests/battle_shots.lua create mode 100644 tools/contact_sheets.py diff --git a/.modkitignore b/.modkitignore index c40bb83..eb0f4a9 100644 --- a/.modkitignore +++ b/.modkitignore @@ -4,6 +4,8 @@ # The SDK suite and the probes it grew out of. A shipped test that requires # an engine module reads as a private require against the archive # (CONTRIBUTING-mods.md "What the PR must contain", 2). +tests/arena_pick.lua +tests/battle_shots.lua tests/dramatic_shape_test.lua tests/voxel_anim_probe.lua tests/voxel_door_probe.lua @@ -19,6 +21,7 @@ tests/voxel_void_probe.lua # ROM, read the local cache, and emit assets/voxels/*.lua; the carved models # are what a player installs, the carving is not. tools/build_voxels.py +tools/contact_sheets.py tools/building_images.py tools/building_voxels.py tools/voxel-survey.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 526da35..495b14f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,51 @@ # Changelog -## Unreleased +## 1.1.0 + +### Added + +- **Battles happen on the map you were standing on.** The battle screen's + white field is replaced by the world: the mod finds the nearest patch of + open ground, points a placed over-the-shoulder camera at it, and draws the + fight over that. New **3D-BTL** row and hotkey `8`, on by default. + + The arena is a 3x6 clearing of cells the player could walk on, with the + two mons three cells apart down the middle column and a one-cell apron all + round so the camera looks across floor rather than into a wall. Where no + map has room for that -- a corridor, a cave, a shop -- the search relaxes + to a 1x4 corridor with the apron given up, and where even that will not + fit the battle draws exactly as it always did. + + Everything else in the frame is the engine's own. The mon pics, HUDs, HP + bars, move animations, faint slides and text box are drawn by BattleState, + in its order, at its coordinates -- the GB's own layout, with the player's + mon low and left and the enemy's high and right, which is why the camera + is placed east of the arena axis rather than the layout being moved to + suit the camera. What changes is what is behind them. + + Three things carry the shot. The overworld's cast is culled before the + wipe, so it plays over an empty map and no bystander is standing in the + arena. The camera drifts on a slow orbit about a point between the two + mons, which moves the near ground and the far ground by different amounts + -- parallax, not a sliding backdrop. And a depth-of-field pass holds the + band of frame the two mons stand in sharp and softens the middle distance + and the foreground; both mons are in focus by construction, because they + are drawn as the battle screen's own pics after the pass has run. + + **Nobody moves.** The arena is where the CAMERA goes. Nothing here writes + a cell, a facing, a flag or a warp, so a trainer's post-battle dialogue is + still talking to someone standing in front of them, and the blackout path, + sight lines and every script find the player exactly where they left them. + + The two HUD blocks gain the backing the white field used to be. Gen 1 + draws them as black glyphs straight onto the background with no box round + them, and black-on-grass is not readable; the backing is painted inside + `drawHUDs`, so it lands in the same target and takes the same zone colour + as the HUD it sits under, in both the colorized and flat pipelines. + + Declines cleanly at every step it cannot take: no depth support, no open + ground, the row switched off, or a terrain mesh still building all end at + the battle screen the engine has always drawn. ### Changed diff --git a/README.md b/README.md index de48a41..b85e313 100644 --- a/README.md +++ b/README.md @@ -9,10 +9,18 @@ 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. +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 +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*. Battles, menus and cutscenes are untouched; only the free-roam -overworld draws differently. +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. ## Controls @@ -25,6 +33,42 @@ 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 | + +**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. + +## Where a battle is staged + +The mod looks for the nearest clearing shaped like this, where every `x` is +a cell the player could walk onto — the two mons three cells apart down the +middle, with a one-cell apron so the camera looks across floor rather than +into a wall: + +``` +x x x +x O x O the enemy's mon +x x x +x x x +x P x P your mon +x x x +``` + +Indoors, in a corridor or in a cave there is often no room for that, so the +search relaxes to the same three-cell gap with the apron given up: + +``` +O +x +x +P +``` + +If neither will fit — and on a map with no open ground at all, or on a +machine with no depth-buffer support — the battle draws exactly the way it +always did. Water counts as open ground while you are surfing and not +otherwise, and warp tiles never count, so a fight is never framed in a +doorway. Two of those keys are taken off the engine: `3` was **TILT** and `5` was **GBC FX**. Neither is reachable by key while this mod is enabled, and both diff --git a/data/battle_arenas.lua b/data/battle_arenas.lua new file mode 100644 index 0000000..f9b1a26 --- /dev/null +++ b/data/battle_arenas.lua @@ -0,0 +1,184 @@ +-- Where each map's battles happen. +-- +-- One authored spot per area, rather than whichever clearing happens to be +-- nearest the step the fight started on. A battle on Route 1 should look the +-- same every time it happens on Route 1 -- and, more importantly, "open +-- ground" is not the same question as "you can see the two of them": the +-- battle camera sits low and a long way back, so a hedge, a ledge lip or the +-- corner of a house anywhere along that sightline hides a Pokemon completely +-- while every cell it is standing on is perfectly walkable. +-- +-- Each entry is the arena's north-west corner in map CELLS, and which of +-- BattleArena.SHAPES it is. Picked by tools/arena_pick (BattleArena.search +-- with the clearance test on, from the middle of the map) and then looked at, +-- one screenshot per map -- these are eyeballed answers, not just passing +-- ones. Grass and flowers around a mon's feet are fine and wanted; anything +-- that cuts into a body is not. +-- +-- A map with no entry here falls back to the nearest-clear search at battle +-- time, so a mod that adds maps, or an entry that goes stale against an +-- edited map, degrades to the old behaviour rather than to no battle. +-- +-- The entries below are generated; regenerate with +-- +-- SHOT_DIR=.scratchpad/arenas \ +-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/arena_pick.lua love . + +-- `cam = "wide"` on an entry swaps the long default lens for the 44-degree +-- one (BattleCam.RIGS). Both frame the same composition -- the two mons land +-- on the same screen anchors either way -- so it is purely a choice about how +-- much of the place is in shot, at the cost of a smaller pair. Rooms the long +-- lens cannot stand back from NEED it; anywhere that simply reads better with +-- more of itself visible may ask for it. +-- +-- Tall grass is never part of an arena (BattleArena.openCell rejects it), so +-- a route's spot is always its bare path rather than the field beside it -- +-- grass is knee-high geometry to a Pokemon and this camera is nearly level +-- with the floor, so tufts on the mon's own tile stand between it and the +-- lens. Flowers are left alone: they are ankle height and read as ground. + +return { + -- ------- routes + ["ROUTE_1"] = { x = 0, y = 16, shape = "wide" }, + ["ROUTE_2"] = { x = 1, y = 49, shape = "wide" }, + ["ROUTE_3"] = { x = 57, y = 1, shape = "wide" }, + ["ROUTE_4"] = { x = 46, y = 7, shape = "wide" }, + ["ROUTE_5"] = { x = 13, y = 24, shape = "wide" }, + ["ROUTE_6"] = { x = 5, y = 17, shape = "narrow" }, + ["ROUTE_7"] = { x = 8, y = 8, shape = "narrow" }, + ["ROUTE_8"] = { x = 25, y = 7, shape = "wide" }, + -- the whole route admits six bare wide arenas, all in the west cliff + -- corridor; this is the best of them. A flower cluster crosses the far + -- mon's hind legs, which the brief allows -- every alternative put a + -- terrace through the near mon's waist, which it does not. + ["ROUTE_9"] = { x = 1, y = 11, shape = "wide", cam = "wide" }, + ["ROUTE_10"] = { x = 7, y = 40, shape = "wide" }, + ["ROUTE_11"] = { x = 9, y = 6, shape = "wide" }, + ["ROUTE_12"] = { x = 0, y = 73, shape = "wide" }, + ["ROUTE_13"] = { x = 50, y = 8, shape = "narrow" }, + ["ROUTE_14"] = { x = 11, y = 25, shape = "wide" }, + ["ROUTE_15"] = { x = 9, y = 10, shape = "wide" }, + ["ROUTE_16"] = { x = 6, y = 10, shape = "wide" }, + ["ROUTE_17"] = { x = 14, y = 70, shape = "wide" }, + ["ROUTE_18"] = { x = 11, y = 4, shape = "wide" }, + + -- ------- buildings and caves + -- + -- Indoors the narrow shape earns its keep: a room with furniture, machinery + -- or gravestones in it rarely holds a 3x6 clearing whose whole width is + -- also SEEN, and giving up the apron is usually the difference between a + -- fight in the open and one behind a console. + ["POKEMON_MANSION_1F"] = { x = 4, y = 12, shape = "wide" }, + ["POKEMON_MANSION_2F"] = { x = 15, y = 17, shape = "wide" }, + ["POKEMON_MANSION_3F"] = { x = 23, y = 2, shape = "narrow", cam = "wide" }, + ["POKEMON_MANSION_B1F"] = { x = 19, y = 10, shape = "wide" }, + ["POKEMON_TOWER_2F"] = { x = 4, y = 7, shape = "narrow" }, + ["POKEMON_TOWER_3F"] = { x = 4, y = 6, shape = "wide" }, + -- every one of 4F's thirty candidate spots puts a gravestone through a + -- mon; the floor below is the same tower and has the room, so the fight + -- is shot there + ["POKEMON_TOWER_4F"] = { map = "POKEMON_TOWER_3F", x = 4, y = 6, + shape = "wide" }, + ["POKEMON_TOWER_5F"] = { x = 10, y = 1, shape = "narrow" }, + ["POKEMON_TOWER_6F"] = { x = 14, y = 6, shape = "narrow" }, + ["POKEMON_TOWER_7F"] = { x = 9, y = 5, shape = "wide" }, + ["POWER_PLANT"] = { x = 18, y = 5, shape = "narrow" }, + ["ROCK_TUNNEL_1F"] = { x = 14, y = 15, shape = "wide" }, + ["ROCK_TUNNEL_B1F"] = { x = 20, y = 17, shape = "wide" }, + ["ROCKET_HIDEOUT_B1F"] = { x = 11, y = 6, shape = "narrow" }, + ["ROCKET_HIDEOUT_B2F"] = { x = 19, y = 7, shape = "narrow" }, + ["ROCKET_HIDEOUT_B3F"] = { x = 22, y = 11, shape = "narrow" }, + ["ROCKET_HIDEOUT_B4F"] = { x = 17, y = 3, shape = "narrow" }, + ["SAFARI_ZONE_CENTER"] = { x = 1, y = 8, shape = "wide" }, + ["SAFARI_ZONE_EAST"] = { x = 21, y = 8, shape = "wide" }, + ["SAFARI_ZONE_NORTH"] = { x = 19, y = 14, shape = "wide" }, + ["SAFARI_ZONE_WEST"] = { x = 18, y = 3, shape = "wide" }, + ["SEAFOAM_ISLANDS_1F"] = { x = 14, y = 7, shape = "wide" }, + ["SEAFOAM_ISLANDS_B1F"] = { x = 11, y = 1, shape = "wide" }, + ["SEAFOAM_ISLANDS_B2F"] = { x = 16, y = 2, shape = "wide" }, + ["SEAFOAM_ISLANDS_B3F"] = { x = 25, y = 7, shape = "wide" }, + ["SEAFOAM_ISLANDS_B4F"] = { x = 12, y = 6, shape = "narrow" }, + -- Silph Co is office floors partitioned into small rooms, so the long lens + -- often lands outside the walls it is meant to be looking between; the + -- floors that could not be framed any other way ask for the wide one. + ["SILPH_CO_2F"] = { x = 16, y = 8, shape = "narrow" }, + ["SILPH_CO_4F"] = { x = 24, y = 2, shape = "narrow" }, + ["SILPH_CO_5F"] = { x = 16, y = 7, shape = "wide" }, + ["SILPH_CO_6F"] = { x = 10, y = 8, shape = "narrow" }, + ["SILPH_CO_7F"] = { x = 1, y = 2, shape = "wide", cam = "wide" }, + ["SILPH_CO_8F"] = { x = 8, y = 6, shape = "narrow" }, + ["SILPH_CO_9F"] = { x = 20, y = 11, shape = "wide", cam = "wide" }, + ["SS_ANNE_1F_ROOMS"] = { x = 10, y = 1, shape = "narrow", cam = "wide" }, + -- the ship is all two-cell corridors, so the wide arena shape fits nowhere + -- aboard and the long lens always lands outside the hull + ["SS_ANNE_2F"] = { x = 36, y = 8, shape = "narrow", cam = "wide" }, + -- these two decks are byte-identical geometry, so they take the same spot + ["SS_ANNE_2F_ROOMS"] = { x = 11, y = 12, shape = "narrow" }, + ["SS_ANNE_B1F_ROOMS"] = { x = 11, y = 12, shape = "narrow" }, + ["SS_ANNE_BOW"] = { x = 8, y = 3, shape = "wide" }, + ["VICTORY_ROAD_2F"] = { x = 16, y = 6, shape = "wide" }, + ["VICTORY_ROAD_3F"] = { x = 20, y = 1, shape = "wide" }, + + -- ------- towns and the last interiors + ["CERULEAN_CITY"] = { x = 15, y = 16, shape = "wide" }, + ["GAME_CORNER"] = { x = 8, y = 7, shape = "wide" }, + -- the lab is ten cells by twelve, so the long lens is always off-map, and + -- on it a desk clipped one mon and a pillar the other + ["OAKS_LAB"] = { x = 3, y = 2, shape = "narrow", cam = "wide" }, + -- Saffron's gym is a grid of small walled cells: no wide shape exists + -- anywhere in it, and the long lens sits inside a divider + ["SAFFRON_GYM"] = { x = 9, y = 7, shape = "narrow", cam = "wide" }, + ["SILPH_CO_3F"] = { x = 18, y = 11, shape = "wide", cam = "wide" }, + ["SILPH_CO_10F"] = { x = 1, y = 2, shape = "wide" }, + ["SILPH_CO_11F"] = { x = 1, y = 11, shape = "wide", cam = "wide" }, + -- the upper corridor (cols 4-5, rows 1-4) is sealed at runtime by the + -- gym's barrier, so arenas there silently fail the fit test + ["VERMILION_GYM"] = { x = 4, y = 11, shape = "narrow" }, + ["VICTORY_ROAD_1F"] = { x = 11, y = 2, shape = "narrow" }, + ["VIRIDIAN_FOREST"] = { x = 16, y = 34, shape = "narrow" }, + + -- ------- the remaining routes + ["ROUTE_19"] = { x = 8, y = 6, shape = "narrow" }, + ["ROUTE_20"] = { x = 53, y = 3, shape = "wide" }, + ["ROUTE_21"] = { x = 16, y = 4, shape = "wide" }, + ["ROUTE_22"] = { x = 35, y = 7, shape = "wide" }, + ["ROUTE_23"] = { x = 4, y = 36, shape = "wide" }, + ["ROUTE_24"] = { x = 13, y = 15, shape = "wide" }, + ["ROUTE_25"] = { x = 32, y = 2, shape = "wide", cam = "wide" }, + + -- ------- caves, gyms and the Elite Four + -- + -- None of these tilesets has a grass tile at all, so the no-grass rule + -- constrained nothing here; what constrains them is furniture, rock + -- pillars and how small the rooms are. + ["AGATHAS_ROOM"] = { x = 2, y = 1, shape = "narrow", cam = "wide" }, + ["BRUNOS_ROOM"] = { x = 3, y = 1, shape = "narrow" }, + ["CELADON_GYM"] = { x = 0, y = 3, shape = "narrow" }, + ["CERULEAN_CAVE_1F"] = { x = 1, y = 7, shape = "narrow" }, + -- 2F is a maze of one-cell rock corridors; all 24 of its candidate spots + -- hide a mon, so it borrows the floor below -- the same cave + ["CERULEAN_CAVE_2F"] = { map = "CERULEAN_CAVE_B1F", x = 2, y = 0, + shape = "wide" }, + ["CERULEAN_CAVE_B1F"] = { x = 2, y = 0, shape = "wide" }, + ["CERULEAN_GYM"] = { x = 0, y = 1, shape = "narrow" }, + ["CHAMPIONS_ROOM"] = { x = 2, y = 2, shape = "narrow", cam = "wide" }, + ["CINNABAR_GYM"] = { x = 18, y = 10, shape = "narrow" }, + ["DIGLETTS_CAVE"] = { x = 19, y = 16, shape = "wide" }, + ["FIGHTING_DOJO"] = { x = 4, y = 1, shape = "narrow" }, + ["LANCES_ROOM"] = { x = 5, y = 15, shape = "wide" }, + ["LORELEIS_ROOM"] = { x = 5, y = 2, shape = "narrow" }, + ["MT_MOON_1F"] = { x = 24, y = 17, shape = "wide" }, + ["MT_MOON_B1F"] = { x = 5, y = 12, shape = "wide" }, + ["MT_MOON_B2F"] = { x = 2, y = 16, shape = "wide" }, + + -- The three gyms the default rig cannot stand back from. Five blocks is + -- further than these rooms are wide, so the eye landed outside the map and + -- the border ring -- extruded into a cliff by this mode -- crossed the near + -- mon wherever it stood. `cam = "wide"` swaps in the 44-degree lens (see + -- BattleCam), which fits inside the room; the mons come out smaller and all + -- three became stageable. It is asked for HERE, per map, so every area that + -- does not ask keeps the long lens it was framed for. + ["FUCHSIA_GYM"] = { x = 7, y = 6, shape = "narrow", cam = "wide" }, + ["PEWTER_GYM"] = { x = 4, y = 8, shape = "narrow", cam = "wide" }, + ["VIRIDIAN_GYM"] = { x = 10, y = 8, shape = "narrow", cam = "wide" }, +} diff --git a/lib/BattleArena.lua b/lib/BattleArena.lua new file mode 100644 index 0000000..e215b8c --- /dev/null +++ b/lib/BattleArena.lua @@ -0,0 +1,353 @@ +-- Overworld battles: where the fight is staged. +-- +-- A battle in this mod happens ON THE MAP, so it needs a patch of ground +-- clear enough to stand two Pokemon on and point a camera down. This module +-- finds it: the nearest patch of open cells, in the shape below. +-- +-- x x x +-- x O x O the enemy's mon +-- x x x +-- x x x +-- x P x P the player's mon +-- x x x +-- +-- Every `x` is an OPEN cell -- one with no obstruction, i.e. one the player +-- could walk onto. The two mons stand three cells apart down the middle +-- column, with a one-cell apron all round so the camera looks across floor +-- rather than into a wall. +-- +-- When no map has room for that -- a corridor, a cave, a shop floor -- the +-- search relaxes to the narrow shape, which is the same three-cell gap with +-- the apron given up: +-- +-- O +-- x +-- x +-- P +-- +-- and if even that will not fit, the caller gets nil and the battle draws +-- the way it always did. A mod that cannot find a stage does not invent +-- one. +-- +-- Nothing here MOVES anybody: the arena is where the CAMERA goes and where +-- the two mons are staged for the shot. The player's own cell, the party, +-- every script and flag are exactly where the battle left them, which is +-- what keeps a trainer's post-battle dialogue talking to someone still +-- standing in front of them. + +-- the mod namespace (see main.lua): V.require loads a sibling module +local V = ... + +local BattleArena = {} + +-- ------- the authored spot +-- +-- Every map gets ONE place its battles happen, chosen once and written down +-- in data/battle_arenas.lua, rather than whatever clearing happens to be +-- nearest to wherever the fight started. Two reasons. +-- +-- A fight should look the same every time it happens somewhere. Picking the +-- nearest patch means Route 1 has a dozen different battle scenes depending +-- on which step of the grass you were on, some of them behind a tree. +-- +-- And "open ground" is not the same question as "you can SEE the two of +-- them". The camera is low and a long way back, so a hedge, a ledge lip or a +-- building corner anywhere along that line hides a mon completely while the +-- cells it stands on are perfectly walkable. That is what `clearance` below +-- measures, and it is what the authored list is chosen against. +-- +-- A map with no entry falls back to the search, so a mod that adds maps, or +-- an entry that goes stale, degrades to the old behaviour rather than to no +-- battle. +-- An entry may also name ANOTHER MAP to stage on: +-- +-- ["MT_MOON_B2F"] = { map = "MT_MOON_1F", x = 12, y = 8, shape = "wide" } +-- +-- because some maps simply have nowhere to put a fight. A cave's lower floor +-- can be nothing but two-cell-wide corridors between rock walls; a gym is a +-- room full of furniture. Rather than stage a battle there badly -- both +-- Pokemon behind a boulder -- the fight is shot on a floor of the SAME cave, +-- or a floor of the same building, that does have the room for it. It is the +-- same place, and no worse a fiction than a battle happening on ground the +-- player is not standing on, which is what every one of these already is. +local authored = nil +local overrides = {} + +local function authoredFor(mapId) + -- `~= nil`, not truthiness: `false` is a meaningful entry here (an + -- authored refusal), so it has to reach the caller rather than read as + -- "nothing set" and fall through to the data file + local forced = overrides[mapId] + if forced ~= nil then return forced end + if authored == nil then + local ok, list = pcall(V.data, "battle_arenas") + authored = (ok and type(list) == "table") and list or false + end + if not authored then return nil end + return authored[mapId] +end + +BattleArena.authoredFor = authoredFor + +-- Force one map's entry at runtime, ahead of the data file. The authoring +-- tool's handle: it is how a spot chosen by eye is staged and photographed +-- before it is written down, and the only way to check a cross-floor entry +-- without editing the shipped list first. Pass nil to drop it again. +function BattleArena.setOverride(mapId, entry) + overrides[mapId] = entry +end + +-- Cell size in world pixels, the unit every coordinate here is in when it +-- crosses into the renderer (Map's walk grid is 16px cells). +local CELL = 16 + +-- The two shapes, in preference order. `w`/`h` are in cells; `enemy` and +-- `player` are the offsets, from the shape's north-west corner, of the two +-- cells a mon stands on. +BattleArena.SHAPES = { + { id = "wide", w = 3, h = 6, enemy = { 1, 1 }, player = { 1, 4 } }, + { id = "narrow", w = 1, h = 4, enemy = { 0, 0 }, player = { 0, 3 } }, +} + +-- Whether a cell is open ground for the purpose above. +-- +-- "Open" is the walk test the player themselves answer to, so an arena can +-- never be laid over a wall, a counter, a tree or a ledge face. Water counts +-- only for a surfer, which is the one case where the player is standing on +-- it too -- a sea battle staged on the beach half a route away would read as +-- a teleport. +-- +-- Warp cells are excluded on top of that. They are walkable by definition +-- (they are the doormat), and a fight framed in a doorway both looks wrong +-- and puts the camera inside the building's geometry. +-- +-- And TALL GRASS is excluded, which is the surprising one, because grass is +-- where wild battles come from and standing in it is the obvious place to +-- have one. It does not survive contact with the camera. Grass is real +-- geometry in this mode -- a row of tufts about knee height on a Pokemon -- +-- drawn with the same camera-ward bias that lets it overdraw a walking +-- character's feet in the free-roam world. From a camera nearly level with +-- the floor that bias stops being feet-deep: the tufts on and around a mon's +-- own tile stand between it and the lens and eat most of the sprite. +-- +-- So the arena is laid on bare ground -- the whole footprint, not just the +-- two cells a mon stands on, because the apron south of the near mon is +-- exactly the row whose grass would cover it. Grass FURTHER back toward the +-- camera is fine and stays: it is far enough forward to project low and wide +-- across the bottom of the frame, where it reads as a field rather than as +-- something in the way. +local function openCell(map, cx, cy, surfing) + if not map:inBounds(cx, cy) then return false end + if map:warpAtCell(cx, cy) then return false end + if map:isWarpTileCell(cx, cy) then return false end + if map.isGrassCell and map:isGrassCell(cx, cy) then return false end + if map:isWalkableCell(cx, cy) then return true end + return (surfing and map:isWaterCell(cx, cy)) or false +end + +BattleArena.openCell = openCell + +-- The map's open cells as one flat boolean grid, so the rectangle test +-- below is a lookup rather than a tileset walk per cell. Built once per +-- search; a battle asks for one. +local function openGrid(map, surfing) + local w, h = map.widthCells, map.heightCells + local grid = {} + for cy = 0, h - 1 do + local row = cy * w + for cx = 0, w - 1 do + grid[row + cx] = openCell(map, cx, cy, surfing) + end + end + return grid, w, h +end + +local function fits(grid, gw, x, y, w, h) + for cy = y, y + h - 1 do + local row = cy * gw + for cx = x, x + w - 1 do + if not grid[row + cx] then return false end + end + end + return true +end + +-- Build the record the renderer reads: the two mons' cells and, in world +-- pixels, the centre of each and of the pair. +local function place(shape, x, y) + local ex, ey = x + shape.enemy[1], y + shape.enemy[2] + local px, py = x + shape.player[1], y + shape.player[2] + local arena = { + shape = shape.id, + x = x, y = y, w = shape.w, h = shape.h, + enemyCell = { ex, ey }, + playerCell = { px, py }, + -- world-pixel centres of the two cells a mon stands on + enemy = { ex * CELL + CELL / 2, ey * CELL + CELL / 2 }, + player = { px * CELL + CELL / 2, py * CELL + CELL / 2 }, + } + arena.mid = { (arena.enemy[1] + arena.player[1]) / 2, + (arena.enemy[2] + arena.player[2]) / 2 } + return arena +end + +-- ------- can the two of them actually be SEEN there +-- +-- The camera sits low and far back on one side, so what hides a mon is not +-- what is on its own tile -- it is anything TALL between the camera and it. +-- A tree two cells to the south-east blocks the near mon completely while +-- every cell of the arena is open ground. +-- +-- So the line from the eye to each mon is walked in short steps and the +-- terrain height under each step is compared with how high the line is +-- there. Three lines per mon -- to its feet, its middle and its head -- +-- because a hedge that clears the head still cuts the body in half. +-- +-- Grass and flowers are deliberately not obstacles: they stand at ankle +-- height, they are what a field looks like, and a mon standing in them +-- reads as standing in a field rather than as being hidden by one. +BattleArena.SAMPLE_STEP = 4 -- world pixels along the line +BattleArena.MON_H = 16 -- how tall a mon stands, in world pixels +BattleArena.CLEAR_EPS = 1.5 -- slack, so a flush kerb is not an obstacle + +local function heightAt(map, wx, wz) + local cx, cy = math.floor(wx / CELL), math.floor(wz / CELL) + if not map:inBounds(cx, cy) then + -- off the map the border ring is drawn, and on most outdoor maps that + -- ring is trees; treat it as solid so an arena is never framed through it + return 32 + end + local ok, h = pcall(V.require("VoxelScene").groundAt, map, cx, cy) + return (ok and h) or 0 +end + +-- Whether the segment from `eye` to (tx, ty, tz) clears the terrain. +local function lineClear(map, eye, tx, ty, tz) + local dx, dy, dz = tx - eye[1], ty - eye[2], tz - eye[3] + local len = math.sqrt(dx * dx + dy * dy + dz * dz) + if len <= 1 then return true end + local steps = math.ceil(len / BattleArena.SAMPLE_STEP) + -- skip the ends: the eye is in open air by construction and the last step + -- is the mon's own tile, which it is standing on + for i = 1, steps - 1 do + local t = i / steps + local wx = eye[1] + dx * t + local wy = eye[2] + dy * t + local wz = eye[3] + dz * t + if heightAt(map, wx, wz) > wy + BattleArena.CLEAR_EPS then return false end + end + return true +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) + if not (ok and rig and rig.eye) then return true end + local eye = rig.eye + local H = BattleArena.MON_H + for _, mark in ipairs({ arena.player, arena.enemy }) do + for _, hy in ipairs({ 1, H * 0.5, H }) do + if not lineClear(map, eye, mark[1], hy, mark[2]) then return false end + end + end + return true +end + +-- The nearest arena to (fromX, fromY) -- the player's cell -- or nil when +-- the map has room for neither shape. +-- +-- Distance is measured from the player to the arena's MIDPOINT, so "nearest" +-- means the fight is staged as close to where it was triggered as the ground +-- allows, rather than merely having a corner nearby. +-- +-- Both shapes are searched over the whole map before the next one is tried: +-- a wide arena on the far side of a route still beats a narrow one +-- underfoot, because the wide one is the shot this mode is framed for. +function BattleArena.find(map, fromX, fromY, surfing) + if not (map and map.widthCells) then return nil end + + -- the authored spot wins outright when the map has one and it still holds + local pick = authoredFor(map.id) + -- `false` is an authored REFUSAL: a map looked at and found to have nowhere + -- a fight can be seen, with no other floor to borrow. Declining is the + -- honest answer -- the battle draws on the plain screen -- and it has to be + -- said explicitly, because the fallback search below would otherwise go and + -- find one of the bad spots that were already rejected by eye. + if pick == false then return nil end + if pick then + local shape = nil + for _, s in ipairs(BattleArena.SHAPES) do + if s.id == (pick.shape or "wide") then shape = s end + end + -- an entry may point at another floor of the same cave or building; the + -- arena is then measured against THAT map, and carries it + local host = map + if shape and pick.map and pick.map ~= map.id then + local ok, other = pcall(function() + local Game = require("src.core.Game") + return require("src.world.MapLoader").load(Game.data, pick.map) + end) + host = (ok and other) or nil + end + if shape and host then + local grid, gw = openGrid(host, surfing) + if fits(grid, gw, pick.x, pick.y, shape.w, shape.h) then + local arena = place(shape, pick.x, pick.y) + arena.map = host + -- which camera rig this spot is framed for; nil is the default long + -- lens, "close" the short one small rooms need (see BattleCam) + arena.cam = pick.cam + return arena + end + end + end + + local found = BattleArena.search(map, fromX, fromY, surfing) + if found then found.map = map end + return found +end + +-- The arena at a given north-west corner, whatever the map says about it. +-- The authoring tool's manual override: a spot chosen by eye rather than by +-- the search, so it can be photographed and judged before it is written down. +function BattleArena.at(x, y, shapeId) + for _, shape in ipairs(BattleArena.SHAPES) do + if shape.id == (shapeId or "wide") then return place(shape, x, y) end + end + return nil +end + +-- The nearest arena the map can offer, preferring one the pair can be SEEN +-- in. Two passes rather than one score: a clear arena on the far side of a +-- route beats an obstructed one underfoot, because being able to see the +-- fight is the point, but an obstructed one still beats no battle at all. +function BattleArena.search(map, fromX, fromY, surfing, wantClear) + local grid, gw, gh = openGrid(map, surfing) + for _, shape in ipairs(BattleArena.SHAPES) do + for _, needClear in ipairs({ true, false }) do + local best, bestD = nil, nil + for y = 0, gh - shape.h do + for x = 0, gw - shape.w do + if fits(grid, gw, x, y, shape.w, shape.h) then + local mx = x + (shape.w - 1) / 2 + local my = y + (shape.h - 1) / 2 + local dx, dy = mx - fromX, my - fromY + local d = dx * dx + dy * dy + if not bestD or d < bestD then + local cand = place(shape, x, y) + if not needClear or BattleArena.clearance(map, cand) then + best, bestD = cand, d + end + end + end + end + end + if best then return best end + if wantClear and needClear then return nil end + end + end + return nil +end + +return BattleArena diff --git a/lib/BattleBillboard.lua b/lib/BattleBillboard.lua new file mode 100644 index 0000000..306a2de --- /dev/null +++ b/lib/BattleBillboard.lua @@ -0,0 +1,120 @@ +-- Overworld battles: the two mons, as geometry standing on the map. +-- +-- Not pics composited over a picture of the world -- quads INSIDE it, drawn +-- in the same 3D pass as the terrain, from the same camera, through the same +-- shader. Which means they get everything the world gets and nothing has to +-- be faked for them: the depth buffer decides what is in front of what, the +-- sun pass sees them and throws their real silhouettes across the ground, +-- and their size on screen is whatever standing on that tile at that +-- distance actually looks like. +-- +-- One quad, standing upright with its feet on the cell and yawed to face the +-- camera. Upright rather than leaned back, unlike the free-roam mode's +-- character cards: those lean because that camera looks DOWN and a standing +-- card would foreshorten to nothing, and this one looks along the ground +-- from about a foot above it, where a card standing up is simply correct. +-- +-- The shader's alpha discard cuts the mon's exact outline out of the quad, +-- so a Pokemon is its own silhouette against the world with no matte, no +-- billboard edge and no sorting to get wrong. + +-- the mod namespace (see main.lua): V.require loads a sibling module +local V = ... + +local Mat4 = V.require("Mat4") +local Voxel3D = V.require("Voxel3D") + +local BattleBillboard = {} + +-- How wide a full-size (7x7 tile) mon stands, in world pixels. One overworld +-- square, so a Pokemon covers the tile it is on and no more; a species whose +-- pic is smaller than the full buffer comes out proportionally smaller, +-- which is how the artwork's own size differences survive the trip. +BattleBillboard.FULL_W = 16 +BattleBillboard.FULL_PIC = 56 -- the pic size FULL_W refers to + +-- A hair of camera-ward bias, so a card standing ON the ground plane wins +-- the depth test against it instead of z-fighting the tile it is rooted to. +BattleBillboard.PULL = 1.5 + +local quad = nil -- nil = untried, false = unavailable + +-- The unit card: x in -0.5..0.5, y in 0..1, z = 0, UV over the whole +-- texture. Feet on the model origin, so the model matrix only has to say +-- where the mon is standing and how big it is. +local function unitQuad() + if quad ~= nil then return quad or nil end + local verts = { + { -0.5, 0, 0, 0, 1, 1 }, + { 0.5, 0, 0, 1, 1, 1 }, + { 0.5, 1, 0, 1, 0, 1 }, + { -0.5, 1, 0, 0, 0, 1 }, + } + local indices = {} + Voxel3D.pushQuad(indices, 0) + local mesh = Voxel3D.newMesh(verts, indices) + quad = mesh or false + return quad or nil +end + +BattleBillboard.mesh = unitQuad + +-- The yaw that turns the card's face toward the eye. The quad's normal is +-- +Z before rotation, so this is just the bearing from the mon to the +-- camera -- flattened to the horizontal, because a card that also tipped to +-- face a camera above it would lift its feet off the floor. +function BattleBillboard.yawToward(x, z, eye) + if not eye then return 0 end + return math.atan2(eye[1] - x, eye[3] - z) +end + +-- Stand a `w` x `h` card with its feet centred on world (x, y, z). +function BattleBillboard.matrix(x, y, z, w, h, yaw) + return Mat4.mul(Mat4.mul(Mat4.translate(x, y, z), Mat4.rotateY(yaw)), + Mat4.scale(w, h, 1)) +end + +-- The world size a pic of `pw` x `ph` texture pixels stands at. +function BattleBillboard.sizeFor(pw, ph) + if not (pw and ph and pw > 0 and ph > 0) then return 0, 0 end + local scale = BattleBillboard.FULL_W / BattleBillboard.FULL_PIC + return pw * scale, ph * scale +end + +-- Draw one mon. `tex` is the pic already rendered to a texture (see +-- OverworldBattle, which lets the engine's own battler draw produce it, so +-- every faint slide, blink and squish comes along), `grow` the send-out +-- animation's scale or nil. +function BattleBillboard.draw(tex, x, y, z, grow) + local mesh = unitQuad() + if not (mesh and tex) then return false end + local pw, ph = tex:getDimensions() + local w, h = BattleBillboard.sizeFor(pw, ph) + if grow then w, h = w * grow, h * grow end + if w <= 0 or h <= 0 then return false end + local yaw = BattleBillboard.yawToward(x, z, Voxel3D.eye) + Voxel3D.draw(mesh, tex, BattleBillboard.matrix(x, y, z, w, h, yaw), + BattleBillboard.PULL) + return true +end + +-- The same card, as the SUN sees it: no camera-ward pull (that is a trick +-- for the view's own depth buffer and would drag the shadow off its owner) +-- and no draw call of its own, because the shadow pass has its own. +function BattleBillboard.caster(shadowMap, tex, x, y, z, grow) + local mesh = unitQuad() + if not (mesh and tex) then return false end + local pw, ph = tex:getDimensions() + local w, h = BattleBillboard.sizeFor(pw, ph) + if grow then w, h = w * grow, h * grow end + if w <= 0 or h <= 0 then return false end + local yaw = BattleBillboard.yawToward(x, z, Voxel3D.eye) + shadowMap.draw(mesh, tex, BattleBillboard.matrix(x, y, z, w, h, yaw)) + return true +end + +function BattleBillboard.invalidate() + quad = nil +end + +return BattleBillboard diff --git a/lib/BattleCam.lua b/lib/BattleCam.lua new file mode 100644 index 0000000..07bc78e --- /dev/null +++ b/lib/BattleCam.lua @@ -0,0 +1,193 @@ +-- Overworld battles: the over-the-shoulder camera and its parallax drift. +-- +-- The two mons are PINNED to their cells: each pic is drawn wherever its +-- patch of ground projects to, not at a fixed screen slot. So the camera is +-- not decoration -- it is the thing that decides where the fight appears, +-- and it has to put those two patches of ground exactly where the battle +-- screen wants its two pics: +-- +-- the player's mon (26, 96) back pic, feet on the text box, well left +-- the enemy's mon (124, 56) front pic, bottom of the 7x7 slot +-- +-- Four screen coordinates, so four equations. The rig below is the solution: +-- SIDE / BACK / HEIGHT place the eye relative to the arena's midpoint, LOOK +-- aims it, and FRAME_H sets the lens, and together they land both marks +-- within a thousandth of a pixel of the targets. They are not hand-picked +-- numbers that looked about right -- they came out of a solver, and the +-- suite reprojects them so a future edit either still lands or says so. +-- +-- East is what decides which mon is on which side. The arena axis runs north +-- (the enemy) to south (the player's mon), and a camera east of that axis +-- sees the near end swing LEFT and the far end RIGHT -- the layout arrived +-- at by standing in the right place rather than by mirroring anything. +-- +-- ------- and two more equations, from the pixels +-- +-- The pics are pixel art and their size on screen is not something the mod +-- gets to choose: 56 pixels for a front pic, 64 for a back one. And a mon has +-- to stand in ONE OVERWORLD SQUARE, or it towers over the houses and gives +-- away that the world behind it is a picture. Together those say the square +-- each mon stands on must project to about the width of its own pic, which is +-- two more equations for the same six unknowns -- and they are what set the +-- distance. +-- +-- The answer is a LONG LENS FROM A LOW STANCE: twelve degrees above the +-- floor, twelve degrees wide, from five blocks back. Not a stylistic choice +-- -- it is what a 56-pixel sprite standing on a 16-pixel tile forces on a +-- 160-pixel screen. Roughly three tiles fit across the frame, so the camera +-- has to be far away and zoomed in rather than near and wide. That is the +-- DEFAULT rig, and every map that can take it gets it. +-- +-- ------- the exception: rooms too small to stand back from +-- +-- Five blocks back is further than some rooms are wide. A gym is about ten +-- cells across, so on one the eye lands OUTSIDE the map, where the border +-- ring the engine draws round every map -- extruded into a cliff by this mode +-- -- crosses the near Pokemon wherever it stands. Three gyms could not be +-- staged anywhere at all for that reason. +-- +-- So there is a second rig, and an arena asks for it by name (cam = "wide" +-- in data/battle_arenas.lua). It comes in to about four cells with the lens +-- opened up to match: an ordinary 44-degree shot that fits inside the room. +-- The mons render smaller for it -- a bit over half a tile rather than a +-- whole one -- which is the price. Both rigs are solved against the SAME four +-- anchors, so the composition is identical either way; only the lens and the +-- distance differ, which is what makes it safe to pick per map. +-- +-- Rooms too small for the long lens are the reason it exists, but it is not +-- only for them: an area that simply reads better with more of itself in +-- shot can ask for it too. +-- +-- Purely presentational, like everything else in this mod: the camera looks +-- at the map, and nothing it does reaches collision, movement or scripts. + +-- the mod namespace (see main.lua): V.require loads a sibling module +local V = ... + +local BattleCam = {} + +-- ------- 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 +-- world pixels (three cells) apart -- BattleArena.SHAPES is where that gap is +-- set, and changing it invalidates these. + +-- `frameH` is how much world the frame is tall enough to hold at the aim +-- distance, which together with that distance is the lens. +-- Named for the LENS, because that is what an author is choosing between +-- when they look at a shot and decide it wants more room in it. +BattleCam.RIGS = { + -- the default: a long 11.5-degree lens from five blocks back, which is + -- what makes one tile big enough to stand a 56-pixel mon on + tele = { + side = 78.79, back = 144.96, height = 37.88, + lookX = -0.26, lookY = 0.34, frameH = 34.11, + }, + -- 44 degrees from four cells: fits inside a room the long lens cannot + -- stand back from, and shows more of anywhere else, at the cost of a + -- smaller pair + wide = { + side = 41.98, back = 41.16, height = 28.48, + lookX = -3.24, lookY = -1.35, frameH = 55.62, + }, +} + +BattleCam.DEFAULT_RIG = "tele" + +-- The rig an arena asks for, falling back to the default for anything that +-- does not ask (and for a name that is not one of the two). +function BattleCam.rigFor(arena) + local want = arena and arena.cam + return BattleCam.RIGS[want] or BattleCam.RIGS[BattleCam.DEFAULT_RIG] +end + +-- ------- the drift +-- +-- A slow orbit about the arena's vertical axis. Rotating about a point +-- BETWEEN the two mons is what makes it parallax rather than a pan: the mons +-- are pinned to the ground, so the near one slides one way across the frame +-- and the far one slides the OTHER, by the amount their difference in +-- distance implies. Over a full swing that is about eight pixels of relative +-- movement -- plainly visible as depth, far too slow to fight the fight. +-- The angle is small because the lens is long: two degrees of orbit is seven +-- pixels of travel through an eleven-degree field of view. +-- +-- Under it, a much smaller breath in and out along the same line, on an +-- unrelated period, so the pair never returns to the same pose on any cycle +-- a battle is long enough to show. A DOLLY rather than a pan of the aim: +-- moving the aim point would slide both mons the same way, which with pinned +-- pics is just the whole picture walking sideways. Changing the DISTANCE +-- moves them apart and back together about the frame's centre, which is the +-- same depth cue the orbit gives, from the other axis. +BattleCam.PAN_YAW = math.rad(2) -- half-angle of the orbit +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 + +BattleCam.t = 0 + +function BattleCam.reset() + BattleCam.t = 0 +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) + BattleCam.t = BattleCam.t + (dt or 0) + -- keep the phase small forever rather than letting a long session lose + -- 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 +end + +local function phase(t, period) + return math.sin(2 * math.pi * t / period) +end + +-- The camera for `arena` this instant: the record Voxel3D.camera takes, plus +-- the pitch the pull and the sun frustum want (measured from straight down, +-- the same convention Voxel.angle uses). +-- +-- `fov` here frames the GB's 160x144. A caller rendering at window +-- resolution widens it for the extra picture around that frame -- see +-- BattleScene.letterboxFov, which is what keeps the pins exact at any window +-- size. +-- +-- `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) + groundY = groundY or 0 + local R = BattleCam.rigFor(arena) + local mx, mz = arena.mid[1], arena.mid[2] + + local yaw = 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 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 } + + 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) + + return { + eye = eye, + focus = focus, + fov = 2 * math.atan((R.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 + -- the pics are pinned to + curve = 0, + }, math.atan2(horiz, math.max(1e-3, ey)) +end + +return BattleCam diff --git a/lib/BattleDOF.lua b/lib/BattleDOF.lua new file mode 100644 index 0000000..1b58ca4 --- /dev/null +++ b/lib/BattleDOF.lua @@ -0,0 +1,187 @@ +-- Overworld battles: the depth-of-field pass over the arena. +-- +-- A photographic lens focused on two subjects three cells apart holds a +-- narrow slab of the world sharp and loses everything in front of and +-- behind it. On this shot that slab is a BAND ACROSS THE FRAME, because the +-- camera is fixed and looking down at a floor: distance from the lens runs +-- monotonically up the picture, so screen height IS depth, and a band of +-- rows is a slab of world. The floor the mons stand on stays sharp, the +-- middle distance and the horizon soften, and the foreground the camera is +-- leaning over softens the other way. +-- +-- BOTH MONS ARE IN FOCUS, and not by tuning: they are drawn as the battle +-- screen's own pics AFTER this runs, so they are never blurred at all. This +-- pass only ever touches the world behind them -- which is exactly the +-- separation a real shot of two subjects at the same distance would have. +-- +-- Two separable gaussian passes over a 160x144 image: two full-frame draws +-- of 23,040 pixels, which is nothing, and it buys the one cue that stops a +-- 3D backdrop reading as wallpaper. + +local BattleDOF = {} + +-- Switched off for now. The pass is kept whole -- band maths, shader, +-- canvases -- because the reason to have it has not gone away: it is the one +-- cue that separates the pair from the ground behind them. It is off because +-- the mons are geometry in the scene now rather than pics over it, and a blur +-- that softens their own tile softens THEM, which the pinned-pic version +-- never had to answer for. Turning it back on means solving that first. +BattleDOF.ENABLED = false + +-- Fallbacks for the band, in canvas uv. The caller normally measures it off +-- the two ground marks -- the whole point is that the slab in focus is the +-- one the mons are standing in -- and these are what a caller that cannot +-- gets instead. +BattleDOF.FOCUS_Y = 0.52 +BattleDOF.BAND = 0.16 +BattleDOF.RANGE = 0.32 + +-- How much floor either side of the two marks stays sharp, as a fraction of +-- the gap between them: a mon is taller than the patch it stands on, and the +-- ground just in front of and behind it belongs to the same slab. +BattleDOF.BAND_MARGIN = 0.55 + +-- How far past the band it takes to reach full blur, in the same units as +-- the band itself. +BattleDOF.RANGE_SCALE = 2.0 + +-- Tap spacing at full blur, as a fraction of the canvas height, so the blur +-- is the same depth of field in a window and fullscreen. The gaussian's +-- reach is four taps and the two passes compound, so a little goes a long +-- way. +BattleDOF.SPACING = 0.0095 + +-- A touch of saturation on the way out, the same trick the tilt-shift pass +-- uses: a blurred background reads as further away when it is also a +-- little richer than the sharp subject in front of it. +BattleDOF.SATURATION = 1.12 + +local SHADER = [[ + uniform vec2 dir; // one texel step along the axis being blurred + uniform float focusY; + uniform float band; + uniform float range; + uniform float spacing; + uniform float boost; // 0 = plain blur pass, 1 = final pass (colour pop) + uniform float saturation; + vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) { + float d = abs(tc.y - focusY) - band; + float s = clamp(d / range, 0.0, 1.0); + s = s * s; // ease in, so the band edge has no visible seam + vec2 o = dir * (s * spacing); + vec4 sum = Texel(tex, tc) * 0.2270270270; + sum += (Texel(tex, tc + o) + Texel(tex, tc - o)) * 0.1945945946; + sum += (Texel(tex, tc + 2.0 * o) + Texel(tex, tc - 2.0 * o)) * 0.1216216216; + sum += (Texel(tex, tc + 3.0 * o) + Texel(tex, tc - 3.0 * o)) * 0.0540540541; + sum += (Texel(tex, tc + 4.0 * o) + Texel(tex, tc - 4.0 * o)) * 0.0162162162; + if (boost > 0.5) { + float luma = dot(sum.rgb, vec3(0.299, 0.587, 0.114)); + sum.rgb = mix(vec3(luma), sum.rgb, saturation); + } + return sum * color; + } +]] + +local shader = nil -- nil = untried, false = unavailable +local ping, pong, cw, ch = nil, nil, 0, 0 + +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 + +-- Its own pair of canvases rather than the tilt-shift pass's: those are +-- sized to the window and this is sized to the GB frame, and sharing them +-- would reallocate both every time a battle started or ended. +local function getCanvases(w, h) + if not ping or cw ~= w or ch ~= h then + local ok, a = pcall(love.graphics.newCanvas, w, h) + if not ok then return nil end + local okB, b = pcall(love.graphics.newCanvas, w, h) + if not okB then return nil end + -- the gaussian's fractional tap offsets need linear filtering + a:setFilter("linear", "linear") + b:setFilter("linear", "linear") + ping, pong, cw, ch = a, b, w, h + end + return ping, pong +end + +-- The sharp band for a shot whose two ground marks land at canvas rows +-- `y1` and `y2`, as (focusY, band, range) in uv. This is the depth of field +-- proper: the band is the slab of world the two mons occupy, and everything +-- nearer or further softens away from it. +function BattleDOF.bandFor(y1, y2, h) + if not (y1 and y2 and h and h > 0) then + return BattleDOF.FOCUS_Y, BattleDOF.BAND, BattleDOF.RANGE + end + local mid = (y1 + y2) / 2 / h + local half = math.abs(y1 - y2) / 2 / h + local band = half * (1 + BattleDOF.BAND_MARGIN) + return math.min(1, math.max(0, mid)), + band, + math.max(1e-3, band * BattleDOF.RANGE_SCALE) +end + +-- Run the pass over `canvas` and return the result, or the input unchanged +-- when it cannot run (headless, no shader support) -- so the caller always +-- has something to composite. `focusY`, `band` and `range` are in canvas uv; +-- omit them for the fixed fallback band. +function BattleDOF.apply(canvas, focusY, band, range) + if not (canvas and BattleDOF.ENABLED) then return canvas end + local sh = getShader() + if not sh then return canvas end + local w, h = canvas:getDimensions() + local a, b = getCanvases(w, h) + if not a then return canvas end + focusY = focusY or BattleDOF.FOCUS_Y + band = band or BattleDOF.BAND + range = range or BattleDOF.RANGE + + local prevBlend, prevAlpha = love.graphics.getBlendMode() + local prevCanvas = love.graphics.getCanvas() + + -- the scene canvas filters nearest for its 1:1 blit; the taps need linear, + -- restored below so the composite sees what it expects + canvas:setFilter("linear", "linear") + love.graphics.setShader(sh) + love.graphics.setColor(1, 1, 1, 1) + -- replace, not alpha-blend: these are image-processing copies + love.graphics.setBlendMode("replace", "premultiplied") + pcall(sh.send, sh, "focusY", focusY) + pcall(sh.send, sh, "band", band) + pcall(sh.send, sh, "range", range) + pcall(sh.send, sh, "spacing", math.max(0.75, h * BattleDOF.SPACING)) + pcall(sh.send, sh, "saturation", BattleDOF.SATURATION) + + local ok = pcall(function() + love.graphics.setCanvas(a) + pcall(sh.send, sh, "dir", { 1 / w, 0 }) + pcall(sh.send, sh, "boost", 0) + love.graphics.draw(canvas) + love.graphics.setCanvas(b) + pcall(sh.send, sh, "dir", { 0, 1 / h }) + pcall(sh.send, sh, "boost", 1) + love.graphics.draw(a) + end) + + if prevCanvas then + love.graphics.setCanvas(prevCanvas) + else + love.graphics.setCanvas() + end + love.graphics.setShader() + love.graphics.setBlendMode(prevBlend or "alpha", prevAlpha) + canvas:setFilter("nearest", "nearest") + return ok and b or canvas +end + +-- Drop the GPU objects (window resize, hot reload). +function BattleDOF.invalidate() + ping, pong, cw, ch = nil, nil, 0, 0 +end + +return BattleDOF diff --git a/lib/BattleHud.lua b/lib/BattleHud.lua new file mode 100644 index 0000000..f88f264 --- /dev/null +++ b/lib/BattleHud.lua @@ -0,0 +1,349 @@ +-- Overworld battles: the HUD's footing on a world that is not white. +-- +-- Gen 1 draws its battle HUDs as black glyphs and bar tiles straight onto +-- the white field, with no box around them -- the field IS the backing. Take +-- the field away and put a route underneath and the name, the level and the +-- HP numbers are black on grass, which is not readable. +-- +-- So each HUD block gets a panel: the world behind it, blurred to frosted +-- glass and laid back down translucent, with a tint that pushes it away from +-- whatever colour the text is about to be. Frosted rather than opaque +-- because the point of the mode is that you can see where you are standing, +-- and an opaque slab in the corner of the frame is the white field back +-- again by another name. +-- +-- And the text flips. A panel over a sunlit meadow is bright and wants black +-- glyphs; the same panel over a cave floor or a dark roof is not, and wants +-- white ones. So the panel's average brightness is measured and the glyphs +-- follow it, with hysteresis so a slow camera drift across the threshold +-- cannot strobe them. +-- +-- The measurement is a one-pixel readback, which is a GPU stall, so it runs +-- a few times a second rather than every frame. The camera drifts at about +-- a pixel a second; brightness cannot outrun that. + +-- the mod namespace (see main.lua): V.require loads a sibling module +local V = ... + +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 + +-- The luminance the glyphs flip at, with a dead band so a drift across it +-- settles rather than strobes. +BattleHud.DARK_ENTER = 0.44 -- below this, the panel is dark: white glyphs +BattleHud.DARK_LEAVE = 0.56 -- above this, back to black ones + +-- Frames between brightness readbacks. +BattleHud.SAMPLE_EVERY = 12 + +-- The frost buffer's height; width follows the source's aspect. Small on +-- purpose: the downscale is most of the blur, and what is read back for the +-- brightness is one pixel of it. +BattleHud.FROST_H = 72 + +local frost, frostW, frostH = nil, 0, 0 +local blurA, blurB = nil, nil +local probe = nil +local frame = 0 +local luma = {} -- panel key -> { value, dark, at } + +local SHADER = [[ + uniform vec2 dir; + vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) { + vec4 sum = Texel(tex, tc) * 0.2270270270; + sum += (Texel(tex, tc + dir) + Texel(tex, tc - dir)) * 0.1945945946; + sum += (Texel(tex, tc + 2.0 * dir) + Texel(tex, tc - 2.0 * dir)) * 0.1216216216; + sum += (Texel(tex, tc + 3.0 * dir) + Texel(tex, tc - 3.0 * dir)) * 0.0540540541; + sum += (Texel(tex, tc + 4.0 * dir) + Texel(tex, tc - 4.0 * dir)) * 0.0162162162; + return sum * 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 + +local function canvasOf(w, h, filter) + local ok, c = pcall(love.graphics.newCanvas, w, h) + if not ok then return nil end + c:setFilter(filter or "linear", filter or "linear") + return c +end + +-- Build (or rebuild) the frosted copy of `src` for this frame. +-- +-- Two steps, because one is not enough: the downscale to a 72-row buffer +-- averages the world down to something that no longer reads as terrain, and +-- the separable gaussian over that turns the remaining structure into +-- frosted glass rather than a mosaic of the tiles it came from. +function BattleHud.build(src) + if not src then return nil end + local blur = getShader() + local sw, sh = src:getDimensions() + if sw <= 0 or sh <= 0 then return nil end + local h = BattleHud.FROST_H + local w = math.max(1, math.floor(sw * h / sh + 0.5)) + if not frost or frostW ~= w or frostH ~= h then + frost = canvasOf(w, h) + blurA = canvasOf(w, h) + blurB = canvasOf(w, h) + probe = probe or canvasOf(1, 1) + if not (frost and blurA and blurB) then + frost, blurA, blurB, frostW, frostH = nil, nil, nil, 0, 0 + return nil + end + frostW, frostH = w, h + end + + local prevCanvas = love.graphics.getCanvas() + local prevBlend, prevAlpha = love.graphics.getBlendMode() + local prevFilter = { src:getFilter() } + src:setFilter("linear", "linear") + love.graphics.setColor(1, 1, 1, 1) + love.graphics.setBlendMode("replace", "premultiplied") + + local ok = pcall(function() + love.graphics.setCanvas(frost) + love.graphics.draw(src, 0, 0, 0, w / sw, h / sh) + if blur then + love.graphics.setShader(blur) + love.graphics.setCanvas(blurA) + pcall(blur.send, blur, "dir", { 2.5 / w, 0 }) + love.graphics.draw(frost) + love.graphics.setCanvas(blurB) + pcall(blur.send, blur, "dir", { 0, 2.5 / h }) + love.graphics.draw(blurA) + love.graphics.setShader() + frost, blurB = blurB, frost -- the blurred one is the frost now + end + end) + + love.graphics.setShader() + if prevCanvas then + love.graphics.setCanvas(prevCanvas) + else + love.graphics.setCanvas() + end + love.graphics.setBlendMode(prevBlend or "alpha", prevAlpha) + src:setFilter(prevFilter[1] or "nearest", prevFilter[2] or "nearest") + frame = frame + 1 + return ok and frost or nil +end + +function BattleHud.frame() + return frame +end + +-- Average luminance of the frost under `key`'s rect, in frost-canvas pixels. +-- +-- Averaged by letting the GPU do it: the rect is drawn into a one-pixel +-- canvas, which IS the mean, and that one pixel is read back. Cached for +-- SAMPLE_EVERY frames because the readback synchronises the pipeline and +-- nothing it measures moves faster than that. +local function sampleLuma(key, fx, fy, fw, fh) + local hit = luma[key] + if hit and (frame - hit.at) < BattleHud.SAMPLE_EVERY then return hit.value end + if not (frost and probe and frostW > 0) then return hit and hit.value end + if fw <= 0 or fh <= 0 then return hit and hit.value end + + local prevCanvas = love.graphics.getCanvas() + local prevBlend, prevAlpha = love.graphics.getBlendMode() + local value = hit and hit.value or 1 + local ok = pcall(function() + love.graphics.setCanvas(probe) + love.graphics.setBlendMode("replace", "premultiplied") + love.graphics.setColor(1, 1, 1, 1) + local quad = love.graphics.newQuad(fx, fy, fw, fh, frostW, frostH) + love.graphics.draw(frost, quad, 0, 0, 0, 1 / fw, 1 / fh) + love.graphics.setCanvas() + local data = probe:newImageData() + local r, g, b = data:getPixel(0, 0) + if data.release then pcall(data.release, data) end + value = 0.299 * r + 0.587 * g + 0.114 * b + end) + + if prevCanvas then + love.graphics.setCanvas(prevCanvas) + else + love.graphics.setCanvas() + end + love.graphics.setBlendMode(prevBlend or "alpha", prevAlpha) + if not ok then return hit and hit.value end + + luma[key] = { value = value, at = frame } + return value +end + +-- Map a GB-frame rect onto the frost canvas, given where the letterbox sits +-- in the source the frost was built from. +local function frostRect(rect, box) + local kx = frostW / box.pw + local ky = frostH / box.ph + local fx = (box.lx + rect[1] * box.scale) * kx + local fy = (box.ly + rect[2] * box.scale) * ky + local fw = rect[3] * box.scale * kx + local fh = rect[4] * box.scale * ky + return fx, fy, math.max(1, fw), math.max(1, fh) +end + +-- ------- the verdict +-- +-- ONE answer for the whole frame, not one per panel. Both HUDs draw in a +-- single pass and there is only one glyph colour to be had out of it -- and +-- a frame with a black-lettered HUD in one corner and a white-lettered one +-- in the other would read as a bug rather than as adaptation. The DARKER +-- panel decides, because it is the one that cannot afford to be wrong, and +-- the tint below then commits both panels to that reading. +local wasDark = false + +function BattleHud.verdict(rects, box) + if not (frost and box and box.scale and box.scale > 0) then return false end + local darkest = nil + for key, rect in pairs(rects) do + local fx, fy, fw, fh = frostRect(rect, box) + local v = sampleLuma(key, fx, fy, fw, fh) + if v and (not darkest or v < darkest) then darkest = v end + end + if not darkest then return wasDark end + -- hysteresis: it takes a clear move past the far threshold to flip back, + -- so a camera drifting across the boundary settles instead of strobing + if wasDark then + wasDark = darkest < BattleHud.DARK_LEAVE + else + wasDark = darkest < BattleHud.DARK_ENTER + end + return wasDark +end + +-- Draw one HUD panel into the current target, in GB coordinates. +-- +-- The tint always pushes AWAY from the glyph colour that is about to be +-- 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) + if not (frost and box and box.scale and box.scale > 0) then return false end + local fx, fy, fw, fh = frostRect(rect, box) + local ok = pcall(function() + local quad = love.graphics.newQuad(fx, fy, fw, fh, frostW, frostH) + love.graphics.setColor(1, 1, 1, BattleHud.FROST) + love.graphics.draw(frost, quad, rect[1], rect[2], 0, + rect[3] / fw, rect[4] / fh) + local shade = dark and 0 or 1 + love.graphics.setColor(shade, shade, shade, BattleHud.TINT) + love.graphics.rectangle("fill", rect[1], rect[2], rect[3], rect[4]) + love.graphics.setColor(1, 1, 1, 1) + end) + return ok +end + +-- ------- flipping the glyphs +-- +-- Over a dark panel the HUD's black text has to go white, and it cannot be +-- done by setting a draw colour: LOVE MULTIPLIES by it, and a black glyph +-- times white is still black. The colour channel has to be REPLACED. +-- +-- So the HUD is drawn into a scratch layer and that layer is composited back +-- through a shader that whitens whatever is nearly black and leaves the rest +-- alone. "Nearly black" is the text, the tick marks and the bar's outline -- +-- everything the HUD draws as ink -- while the HP bar's own greens and reds +-- are well clear of the threshold and come through untouched. +-- +-- Composited back into whatever the caller had bound, which is what makes it +-- work in both pipelines without knowing which one it is in: in the colorized +-- one that target is the grayscale BG canvas, where white IS shade 0 and the +-- zone pass then colours the flipped glyphs like every other lightest-shade +-- surface; in the flat fallback it is the screen, where white is white. +local INK = 0.35 -- luminance at or under which a pixel counts as ink + +local FLIP = [[ + uniform float ink; + 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)); + if (p.a > 0.0 && luma <= ink * p.a) p.rgb = vec3(p.a); + return p * color; + } +]] + +local flipShader = nil +local layer = nil + +local function getFlip() + if flipShader == nil then + local ok, sh = pcall(love.graphics.newShader, FLIP) + flipShader = (ok and sh) or false + end + return flipShader or nil +end + +-- Whether the flip pass can run at all, for the shot driver's log. +function BattleHud.flipReady() + return getFlip() ~= nil +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) + local sh = getFlip() + if not sh then return fn() end + if not layer or layer:getWidth() ~= w or layer:getHeight() ~= h then + layer = canvasOf(w, h, "nearest") + if not layer then return fn() end + end + + local prevCanvas = love.graphics.getCanvas() + local prevBlend, prevAlpha = love.graphics.getBlendMode() + local ok, err = pcall(function() + love.graphics.setCanvas(layer) + love.graphics.clear(0, 0, 0, 0) + love.graphics.setBlendMode("alpha") + fn() + end) + if prevCanvas then + love.graphics.setCanvas(prevCanvas) + else + love.graphics.setCanvas() + end + love.graphics.setBlendMode(prevBlend or "alpha", prevAlpha) + if not ok then error(err, 0) end + + love.graphics.setShader(sh) + pcall(sh.send, sh, "ink", INK) + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(layer, 0, 0) + love.graphics.setShader() +end + +-- The last luminance measured, for the shot driver's log. +function BattleHud.lastLuma() + local best = nil + for _, hit in pairs(luma) do + if not best or hit.value < best then best = hit.value end + end + return best +end + +function BattleHud.invalidate() + frost, blurA, blurB, probe = nil, nil, nil, nil + frostW, frostH = 0, 0 + luma = {} + wasDark = false +end + +return BattleHud diff --git a/lib/BattlePics.lua b/lib/BattlePics.lua new file mode 100644 index 0000000..9cafa7b --- /dev/null +++ b/lib/BattlePics.lua @@ -0,0 +1,157 @@ +-- Overworld battles: giving a battle pic its paper back. +-- +-- Gen 1 battle pics are two-bit art whose lightest shade is WHITE, and the +-- engine's decoded PNGs key that shade to alpha 0 -- which was free, because +-- the field behind them was white too. A transparent belly on a white page +-- is a white belly. +-- +-- Put a route behind it and the belly is grass. Charizard's chest, the whites +-- of every eye, the highlight down a Pikachu's cheek: all of it turns into a +-- hole with the world showing through, and the mon reads as a stencil. +-- +-- So the paper is put back, and only where the paper was: the pic is read +-- back once, the transparent region OUTSIDE the figure is flood-filled from +-- the border, and every transparent pixel the flood could not reach -- every +-- hole enclosed by the artwork -- is filled opaque white. The silhouette is +-- untouched, so the mon still cuts cleanly against the world; only its +-- insides stop being see-through. +-- +-- Read back off the GPU rather than off the asset, deliberately. What comes +-- back is the pic the engine actually decided to draw -- species palette, +-- forced-mono rebuild, shiny recolour, a mod's replacement art -- so this +-- needs to know nothing about how any of that was arrived at. Once per pic +-- per session, cached on the image itself. + +-- the mod namespace (see main.lua): V.require loads a sibling module +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" }) + +-- 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. +BattlePics.FILL = { 1, 1, 1, 1 } + +-- Anything at or under this alpha counts as keyed-out rather than drawn. +local CUT = 0.5 + +-- Read the pixels the engine would actually blit. A LOVE Image does not hand +-- its data back, so it is drawn into a canvas of its own size and the canvas +-- is read -- which is also what makes this work for every path that produces +-- a pic, without knowing which one produced this one. +local function readBack(img) + local w, h = img:getDimensions() + if w <= 0 or h <= 0 then return nil end + local prevCanvas = love.graphics.getCanvas() + local prevBlend, prevAlpha = love.graphics.getBlendMode() + local prevR, prevG, prevB, prevA = love.graphics.getColor() + local data = nil + local ok = pcall(function() + local canvas = love.graphics.newCanvas(w, h) + love.graphics.setCanvas(canvas) + love.graphics.clear(0, 0, 0, 0) + love.graphics.setBlendMode("replace", "premultiplied") + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(img, 0, 0) + love.graphics.setCanvas() + data = canvas:newImageData() + if canvas.release then pcall(canvas.release, canvas) end + end) + if prevCanvas then + love.graphics.setCanvas(prevCanvas) + else + love.graphics.setCanvas() + end + love.graphics.setBlendMode(prevBlend or "alpha", prevAlpha) + love.graphics.setColor(prevR or 1, prevG or 1, prevB or 1, prevA or 1) + return ok and data or nil +end + +-- Mark every transparent pixel reachable from the border. That set is the +-- OUTSIDE; everything transparent it does not reach is an enclosed hole. +-- +-- 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) + local outside = {} + local stack, top = {}, 0 + local function push(x, y) + if x < 0 or y < 0 or x >= w or y >= h then return end + local key = y * w + x + if outside[key] then return end + local _, _, _, a = data:getPixel(x, y) + if a > CUT then return end + outside[key] = true + top = top + 1 + stack[top] = key + 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 key = stack[top] + top = top - 1 + local x, y = key % w, math.floor(key / w) + push(x - 1, y) + push(x + 1, y) + push(x, y - 1) + push(x, y + 1) + end + return outside +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) + if not img then return img end + local hit = cache[img] + if hit ~= nil then return hit or img end + + local made = nil + local ok = pcall(function() + local data = readBack(img) + if not data then return end + local w, h = data:getDimensions() + local outside = markOutside(data, w, h) + local fill = BattlePics.FILL + local changed = false + for y = 0, h - 1 do + local row = y * w + for x = 0, w - 1 do + 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]) + changed = true + end + end + end + end + -- nothing enclosed: hand the original back rather than a copy of it + if not changed then return end + local out = love.graphics.newImage(data) + out:setFilter("nearest", "nearest") + made = out + end) + + cache[img] = (ok and made) or false + return made or img +end + +function BattlePics.invalidate() + cache = setmetatable({}, { __mode = "k" }) +end + +return BattlePics diff --git a/lib/BattleScene.lua b/lib/BattleScene.lua new file mode 100644 index 0000000..8d80615 --- /dev/null +++ b/lib/BattleScene.lua @@ -0,0 +1,422 @@ +-- Overworld battles: one frame of the arena, as geometry. +-- +-- The same world the free-roam mode draws, from a placed camera instead of +-- the orbit, at the WINDOW's own pixel resolution -- not the GB's. The +-- backdrop reaches the screen through Renderer's worldOverride, the seam a +-- render pipeline's finished world image already composites through, which +-- is drawn one canvas pixel to one screen pixel; the 160x144 battle screen +-- then blits over it in the classic letterbox. So the world is as crisp as +-- the free-roam diorama and the pics, HUDs and text box stay exactly the +-- chunky GB art they are. +-- +-- Rendering the whole window rather than just the letterbox means the +-- framing has to be split in two. The RIG frames the GB's 160x144 (see +-- BattleCam, which is solved against coordinates in that frame); this +-- module widens the lens by exactly the ratio the window bears to the +-- letterbox, so the letterbox sub-rectangle of what gets rendered is +-- bit-for-bit the framing the rig asked for, and everything outside it is +-- extra picture. That is what lets the two mons be PINNED: their cells +-- project to the same GB coordinates at any window size or zoom. +-- +-- Characters are deliberately absent. The overworld cast is culled for the +-- length of the battle (see OverworldBattle), so this pass has terrain, +-- grass and flowers and nothing that walks -- the arena is empty, which is +-- what makes it an arena. +-- +-- Everything expensive is shared with the free-roam mode rather than +-- duplicated: the same chunk meshes out of ChunkMesher, the same palette +-- atlas out of TerrainAtlas, the same sun out of ShadowMap. A battle on a +-- map already meshed for walking around costs the frame it draws and +-- nothing else. + +-- the mod namespace (see main.lua): V.require loads a sibling module +local V = ... + +local Mat4 = V.require("Mat4") +local Voxel3D = V.require("Voxel3D") +local ShadowMap = V.require("ShadowMap") +local ChunkMesher = V.require("ChunkMesher") +local TerrainAtlas = V.require("TerrainAtlas") +local VoxelScene = V.require("VoxelScene") +local BattleCam = V.require("BattleCam") +local BattleBillboard = V.require("BattleBillboard") +local PaletteFX = require("src.render.PaletteFX") + +local BattleScene = {} + +-- The GB frame the battle screen is drawn in, and the frame BattleCam's rig +-- is solved against. +BattleScene.GB_W = 160 +BattleScene.GB_H = 144 + +-- A map cell in world pixels: the overworld square a mon stands on, which is +-- both what the arena is measured in and what a mon is sized to. +BattleScene.CELL = 16 + +-- How far into black a shadow goes in the arena, against the free-roam +-- mode's own lighter setting. +-- +-- Darker on purpose, and only here. Walking around, a shadow is scenery and +-- wants to stay out of the way of reading the map. In a battle it is doing +-- one specific job: the two mons are flat cards, and the ONLY thing telling +-- the eye they are standing on that floor rather than hanging in front of it +-- is the shadow they put on it. A faint one leaves them floating. +BattleScene.SHADOW_ALPHA = 0.68 + +-- Which rung of the sky ramp an indoor void is painted with. A room has no +-- sky, but it does have somewhere the geometry stops, and leaving that +-- transparent would show the letterbox clear through the gaps. +local INDOOR_SHADE = 4 + +-- ------- where the GB frame sits inside the window +-- +-- Renderer blits worldOverride one canvas pixel to one screen pixel and then +-- blits the 160x144 UI canvas into a centred, integer-scaled letterbox. So +-- these have to agree with Renderer:endFrame exactly, or the pins land off +-- the mons by however much they disagree. +function BattleScene.letterbox() + local Renderer = require("src.render.Renderer") + local pw, ph = BattleScene.pixelSize() + local s = Renderer:fitScale() + return math.floor((pw - BattleScene.GB_W * s) / 2), + math.floor((ph - BattleScene.GB_H * s) / 2), + s, pw, ph +end + +-- The window in FRAMEBUFFER pixels, which is what the override blit works +-- in. love.graphics.getDimensions is in LOVE units and differs from this by +-- the display density on mobile. +function BattleScene.pixelSize() + if love.graphics.getPixelDimensions then + local pw, ph = love.graphics.getPixelDimensions() + if pw and ph and pw > 0 and ph > 0 then return pw, ph end + end + return love.graphics.getDimensions() +end + +-- Widen the rig's vertical field of view from the GB frame to the whole +-- window, so the letterbox rows show exactly what the rig framed. +-- +-- The horizontal falls out of it: at aspect pw/ph the window's half-width is +-- tan(fov/2) * pw/ph, and the letterbox is 160*s of those pw pixels, which +-- works back out to the GB frame's own 160/144. So one scale on the vertical +-- pins both axes. +function BattleScene.letterboxFov(fovGB, ph, s) + local span = BattleScene.GB_H * s + if span <= 0 then return fovGB end + return 2 * math.atan(math.tan(fovGB / 2) * ph / span) +end + +-- ------- palette +-- +-- The world palette a map draws under, in the shape VoxelScene's colour +-- helpers take. Rebuilt per frame from the overworld state, which is where +-- the engine's own pipeline context gets it too (OverworldController's +-- ctx.paletteFor). +local function paletteFor(state, home) + return function(map) + return PaletteFX.pal(require("src.core.Game").data, + state:paletteNameFor(map or home)) + end +end + +-- ------- the map the fight is staged on +-- +-- Normally the one the player is standing on. An authored arena may name +-- another floor of the same cave or building (see BattleArena), and then the +-- scene is THAT map: its terrain, its palette, its sky. Nothing else in the +-- battle changes -- the fight, the party, the player's own position are all +-- exactly where they were. +-- +-- A foreign floor is meshed alone, with no connected neighbours: connections +-- are the player's neighbourhood, and the map the camera has gone to visit is +-- not standing in it. Both maps are kept live so neither the arena's mesh nor +-- the one waiting to be walked back onto is evicted mid-battle. +local function prefetchArena(state, host) + if host == state.map then return VoxelScene.prefetch(state) end + local live = { [host.id] = true, [state.map.id] = true } + 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, {} +end + +-- ------- the sun +-- +-- Only has to be drawn once per battle: the arena does not move, and neither +-- does the light. So the signature is the map, the arena and the meshes -- +-- not the camera, which is the one thing that IS moving and the one thing +-- the sun does not care about. +-- ------- the two mons, hung on their cells +-- +-- The billboard texture is the battle screen's own 160x144 pics layer with +-- one side rendered into it (see OverworldBattle.sideTexture), so the quad is +-- that whole frame stood up on the map -- which is what carries every pic +-- effect the engine applies without any of them being reimplemented here. +-- +-- Its size follows from one number: a full 7x7-tile mon covers one overworld +-- square, so a canvas pixel is FULL_W / FULL_PIC world pixels and the card is +-- the canvas at that scale. Its placement follows from the anchor the +-- texture reports -- the column the pic was centred on and the row its feet +-- were put on -- which is translated onto the cell before the card is stood +-- up, so a mon of any size in any pose has its feet on the ground. +-- `mirror` flips the card about its own anchor column. Both mons wear their +-- FRONT pic, which is drawn facing out of the screen -- so dropped into the +-- world unaltered the pair stand back to back, both looking the same way past +-- each other. Mirroring the near one turns it to face the far one, which is +-- what a fight looks like; and because it is a flip about the pic's own +-- centre the feet do not move off the tile. +-- +-- The player's TRAINER pic is the exception, and it is exempted below. That +-- one is a BACK view -- the player seen from behind, already turned to face +-- up the field -- so it arrives pointing the right way and mirroring it would +-- 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 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 + return Mat4.mul(Mat4.mul(Mat4.translate(x, groundY, z), Mat4.rotateY(yaw)), + card) +end + +-- Every mon that has something to show this frame, as (texture, matrix). +local function monCards(arena, groundY, textures) + local out = {} + if not textures then return out end + for _, side in ipairs({ "enemy", "player" }) do + local tex = textures[side] + 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 + out[#out + 1] = { tex = tex.canvas, + model = monMatrix(tex, cell[1], groundY, cell[2], + mirror) } + end + end + return out +end + +BattleScene.monCards = monCards + +-- The sun has to see the mons too, or they stand on the ground without +-- putting anything on it. They are the one thing in this scene that MOVES, +-- so `token` -- a counter the caller bumps whenever a pic could have changed +-- -- goes in the signature; the terrain half of the answer would otherwise +-- keep a stale pass alive and freeze the shadows in whatever pose they were +-- first drawn in. +local function shadowSignature(state, arena, terrain, nbMesh, token) + local host = arena.map or state.map + local parts = { "battle", host.id, arena.x, arena.y, arena.shape, + tostring(terrain), tostring(token or 0) } + for i = 1, #nbMesh do parts[#parts + 1] = tostring(nbMesh[i]) end + return table.concat(parts, ",") +end + +local function castShadows(state, arena, terrain, nbMesh, cx, cy, vw, vh, + atlasFor, cards, token, host, neighbors) + if not ShadowMap.available() then return end + local sig = shadowSignature(state, arena, terrain, nbMesh, token) + if not ShadowMap.stale(sig) then return end + if not ShadowMap.begin(cx, cy, vw, vh) then return end + + ShadowMap.draw(terrain, atlasFor(host), nil) + for i, nb in ipairs(neighbors) do + ShadowMap.draw(nbMesh[i], atlasFor(nb.map), Mat4.translate(nb.ox, 0, nb.oy)) + end + ShadowMap.draw(ChunkMesher.flowers(host), atlasFor(host), nil) + for _, nb in ipairs(neighbors) do + ShadowMap.draw(ChunkMesher.flowers(nb.map), atlasFor(nb.map), + Mat4.translate(nb.ox, 0, nb.oy)) + end + + -- 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. + for _, card in ipairs(cards or {}) do + ShadowMap.draw(BattleBillboard.mesh(), card.tex, card.model) + end + + ShadowMap.finish(sig) +end + +-- The height of the arena floor: the ground the two mons stand on. Both +-- cells are open, so they are normally the same; take the player's, which is +-- the one nearer the camera and therefore the one a mismatch would show up +-- against. +function BattleScene.groundY(map, arena) + local ok, h = pcall(VoxelScene.groundAt, map, + arena.playerCell[1], arena.playerCell[2]) + return (ok and h) or 0 +end + +-- Where a world point lands in GB frame coordinates under `vp`, or nil when +-- it is behind the camera. This is the function the pins are built on: it +-- takes the window-resolution clip position and divides the letterbox back +-- out of it, so the answer is in the same 160x144 space the battle screen +-- draws its pics in. +function BattleScene.toGB(vp, wx, wy, wz, lx, ly, s, pw, ph) + local cx = vp[1] * wx + vp[2] * wy + vp[3] * wz + vp[4] + local cy = vp[5] * wx + vp[6] * wy + vp[7] * wz + vp[8] + local cw = vp[13] * wx + vp[14] * wy + vp[15] * wz + vp[16] + if cw <= 1e-6 then return nil end + -- viewProjection already flipped clip Y into LOVE's Y-down convention + local px = (cx / cw * 0.5 + 0.5) * pw + local py = (cy / cw * 0.5 + 0.5) * ph + return (px - lx) / s, (py - ly) / s +end + +-- Render the arena and hand back { canvas, player = {x,y}, enemy = {x,y} }, +-- the two marks in GB coordinates -- or nil when there is nothing to draw +-- yet (the terrain mesh is still building, the driver has no depth support). +-- nil is not a failure: the caller simply leaves the battle screen as the +-- engine drew it for that frame. +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 + + -- the floor the fight is staged on: normally the player's own, sometimes + -- another floor of the same cave or building (see BattleArena) + local host = arena.map or state.map + local neighbors = (host == state.map) and (state.neighbors or {}) or {} + + -- 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) + if not terrain then return nil end + + local lx, ly, s, pw, ph = BattleScene.letterbox() + if not (pw > 0 and ph > 0 and s > 0) then return nil end + + local palette = paletteFor(state, host) + local function atlasFor(map) + return TerrainAtlas.forMap(map, VoxelScene._modeColors(palette, map)) + end + + local groundY = BattleScene.groundY(host, arena) + local cam, pitch = BattleCam.rig(arena, groundY) + cam.fov = BattleScene.letterboxFov(cam.fov, ph, s) + + 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 vw = vh * pw / ph + + -- 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 + -- beginScene calls -- so a provisional one is taken here for the sun pass + -- and the real one is rebuilt inside the scene below. + Voxel3D.camera = cam + Voxel3D.viewProjection(cx, cy, vw, vh) + local cards = monCards(arena, groundY, textures) + Voxel3D.camera = nil + castShadows(state, arena, terrain, nbMesh, cx, cy, vw, vh, atlasFor, + cards, token, host, neighbors) + + -- 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 + -- of the same ramp, which is a room's "past the wall". Transparent -- the + -- free-roam default -- would let the letterbox clear through wherever the + -- geometry stops. + local sky = VoxelScene.skyColor(host, 1) + or VoxelScene.skyShade(INDOOR_SHADE, 1) + + Voxel3D.camera = cam + -- the sun is turned up for the arena and put back afterwards, so the + -- free-roam world it shares this module with keeps its own weight + local sunWas = Voxel3D.SHADOW_ALPHA + Voxel3D.SHADOW_ALPHA = BattleScene.SHADOW_ALPHA + local out = nil + local ok, err = pcall(function() + -- 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 + return + end + Voxel3D.draw(terrain, atlasFor(host), nil) + for i, nb in ipairs(neighbors) do + Voxel3D.draw(nbMesh[i], atlasFor(nb.map), + Mat4.translate(nb.ox, 0, nb.oy)) + 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 + -- the card. A small camera-ward pull keeps a card rooted to the ground + -- plane from z-fighting the tile it is standing on. + for _, card in ipairs(monCards(arena, groundY, textures)) do + Voxel3D.draw(BattleBillboard.mesh(), card.tex, card.model, + BattleBillboard.PULL) + end + -- grass and flowers ride the same camera-ward pull the free-roam pass + -- 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 + local pull = VoxelScene.pull(math.max(pitch, 0.05)) + Voxel3D.draw(ChunkMesher.grass(host), atlasFor(host), nil, pull) + for _, nb in ipairs(neighbors) do + Voxel3D.draw(ChunkMesher.grass(nb.map), atlasFor(nb.map), + Mat4.translate(nb.ox, 0, nb.oy), pull) + end + local fpull = math.max(0, pull - 8 * math.sin(math.max(pitch, 0.05))) + Voxel3D.draw(ChunkMesher.flowers(host), atlasFor(host), nil, fpull) + for _, nb in ipairs(neighbors) do + Voxel3D.draw(ChunkMesher.flowers(nb.map), atlasFor(nb.map), + Mat4.translate(nb.ox, 0, nb.oy), fpull) + end + local canvas = Voxel3D.endScene() + if not canvas then return end + + local vp = Voxel3D.vp + local pmx, pmy = BattleScene.toGB(vp, arena.player[1], groundY, + arena.player[2], lx, ly, s, pw, ph) + local emx, emy = BattleScene.toGB(vp, arena.enemy[1], groundY, + arena.enemy[2], lx, ly, s, pw, ph) + if not (pmx and emx) then return end + -- How wide one overworld square is on screen where each mon stands, in + -- GB pixels. This is what the pics are scaled to: a mon covers its own + -- square and no more, at whatever the drift has done to the distance. + local half = BattleScene.CELL / 2 + local pl = BattleScene.toGB(vp, arena.player[1] - half, groundY, + arena.player[2], lx, ly, s, pw, ph) + local pr = BattleScene.toGB(vp, arena.player[1] + half, groundY, + arena.player[2], lx, ly, s, pw, ph) + local el = BattleScene.toGB(vp, arena.enemy[1] - half, groundY, + arena.enemy[2], lx, ly, s, pw, ph) + local er = BattleScene.toGB(vp, arena.enemy[1] + half, groundY, + arena.enemy[2], lx, ly, s, pw, ph) + if not (pl and pr and el and er) then return end + out = { + canvas = canvas, + player = { pmx, pmy }, + enemy = { emx, emy }, + playerSpan = math.abs(pr - pl), + enemySpan = math.abs(er - el), + -- the letterbox, so the depth-of-field pass can put its sharp band on + -- the two marks rather than on a fraction of the window + lx = lx, ly = ly, scale = s, pw = pw, ph = ph, + } + end) + -- the placed camera is ours for exactly this pass; anything else that + -- renders (the free-roam pipeline, next frame) must find the orbit back + Voxel3D.camera = nil + Voxel3D.SHADOW_ALPHA = sunWas + if not ok then + -- endScene never ran, so the canvas is still bound and the shader still + -- set; put the frame back the way it was found before rethrowing + pcall(love.graphics.setShader) + pcall(love.graphics.setDepthMode) + pcall(love.graphics.setCanvas) + error(err, 0) + end + return out +end + +return BattleScene diff --git a/lib/OverworldBattle.lua b/lib/OverworldBattle.lua new file mode 100644 index 0000000..672e445 --- /dev/null +++ b/lib/OverworldBattle.lua @@ -0,0 +1,683 @@ +-- Overworld battles: fights that happen on the map you were standing on. +-- +-- The engine's battle is a screen: a white field with two pics on it, pushed +-- over a frozen overworld that stops drawing. This turns that white field +-- into the world -- the same terrain the free-roam mode extrudes, shot from +-- a placed over-the-shoulder camera at a clear patch of ground nearby -- +-- while leaving the battle ITSELF alone. Every pic, HUD, HP bar, move +-- animation, faint slide and text box is the engine's own, drawn in the +-- engine's own order. What changes is what is behind them, and where the two +-- pics stand. +-- +-- The sequence, from the moment something picks a fight: +-- +-- 1. the overworld cast is culled -- every NPC vanishes, so the wipe +-- plays over an empty map and no bystander is left standing in the +-- arena shot +-- 2. the engine's own transition wipes the screen (untouched: it is the +-- right wipe, picked by the right three bits) +-- 3. the battle draws over a live, window-resolution render of the arena, +-- with each mon PINNED to the cell it is standing on, the camera +-- drifting slowly enough to read as parallax, and a depth-of-field pass +-- holding the slab of world the two of them occupy sharp +-- 4. the battle ends, the cast comes back, and the player is exactly +-- where they were standing +-- +-- WHAT DOES NOT MOVE. The arena is where the CAMERA goes, not where the +-- player goes: nothing here writes a cell, a facing, a flag or a warp. A +-- real warp would have to survive trainer sight-lines, post-battle +-- dialogue, the blackout path and every script that assumes the player is +-- where it left them -- and it would have to put them back afterwards. +-- Moving the camera buys the whole shot and owes nothing back. +-- +-- The feature declines cleanly rather than half-working: no depth support, +-- no open ground on the map, the row switched off, or a mesh still building +-- all end at the same place, which is the battle screen the engine has +-- always drawn. + +-- the mod namespace (see main.lua): V.require loads a sibling module +local V = ... + +local ModSetting = V.require("ModSetting") +local BattleArena = V.require("BattleArena") +local BattleCam = V.require("BattleCam") +local BattleScene = V.require("BattleScene") +local BattleDOF = V.require("BattleDOF") +local BattleHud = V.require("BattleHud") +local BattlePics = V.require("BattlePics") +local Voxel3D = V.require("Voxel3D") +local ChunkMesher = V.require("ChunkMesher") + +local OverworldBattle = {} + +-- 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 +-- diagnostic must never be the reason the mod fails to load. +local DEBUG = select(2, pcall(function() return os.getenv("DS_BATTLE_DEBUG") end)) +if DEBUG == nil or DEBUG == false then DEBUG = nil end + +OverworldBattle.KEY = "battles" +OverworldBattle.LABEL = "3D-BTL" + +-- On by default: a mod whose headline is "the world in 3D" should not need +-- the player to go and find the switch before the world shows up in a +-- battle. ON is first, so it is also what an unreadable stored value falls +-- back to. +OverworldBattle.setting = ModSetting.new(OverworldBattle.KEY, + OverworldBattle.LABEL, + { true, false }, { "ON", "OFF" }) + +function OverworldBattle.enabled() + return OverworldBattle.setting:get() and true or false +end + +-- ------- both mons face you +-- +-- Standing on a map, seen from in front, a Pokemon showing you its BACK is +-- wrong twice over: it is turned away from the camera that is looking at it, +-- and the back pics are a different, smaller drawing made for a slot the +-- player never really sees. So the player's side asks for the FRONT pic too, +-- 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. +-- +-- 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 +-- whether one on this map WOULD be -- the row is on, the 3D pass is +-- available, and the map has an arena -- which is the same question with the +-- same answer a moment later. Cached per map, because the arena search walks +-- the whole grid and this runs once per battler. +local staged = { mapId = nil, ok = false } + +function OverworldBattle.wantsFront() + if not OverworldBattle.enabled() 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 + 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 + if staged.mapId ~= ow.map.id then + local ok, arena = pcall(BattleArena.find, ow.map, + ow.player.cellX, ow.player.cellY, + ow.player.surfing) + staged = { mapId = ow.map.id, ok = (ok and arena) and true or false } + end + return staged.ok +end + +-- ------- where the engine's own pics stand +-- +-- The GB draws the player's back pic with its feet on the text box at row 96 +-- and its 7x7-tile slot centred on x=40, and the enemy's front pic +-- bottom-aligned in a 7x7 slot centred on x=124 ending at row 56. Those two +-- points are the pics' FEET, they hold for every species at every scale (the +-- engine's placement helpers pin the bottom edge and the centre), and they +-- are what BattleCam is solved to put the two arena cells under. +-- +-- Which makes the pin a subtraction: whatever the drift has done to the +-- camera this frame, each pic moves by its own cell's projected position +-- minus its anchor. At the middle of the drift that is zero. +OverworldBattle.ANCHOR = { + player = { 26, 96 }, + enemy = { 124, 56 }, +} + +-- ------- how big a mon is +-- +-- Not a decision made here. A pic is drawn at its own integer scale -- 1x for +-- a 56px front pic, 2x for a 32px back one -- because that is the only way it +-- keeps every pixel the artist drew, and the CAMERA is solved so that one +-- overworld square is that big on screen (see BattleCam). The mon fits its +-- tile because the tile was sized to the mon, not the other way round. +OverworldBattle.SLOT_W = { front = 56, back = 32 } + +-- The two HUD blocks, as the pixel spans DrawEnemyHUDAndHPBar and +-- DrawPlayerHUDAndHPBar actually reach. Neither overlaps its side's pic at +-- the anchors above. +OverworldBattle.HUD_RECT = { + enemy = { 8, 0, 80, 32 }, + player = { 72, 56, 88, 40 }, +} + +-- ------- the live battle +-- +-- nil when no overworld battle is running. Never more than one: battles do +-- not nest. +local session = nil + +local function game() + return require("src.core.Game") +end + +-- Put the map's cast back. Both lists are handed back by identity, so +-- anything that captured one before the battle still sees the same table. +local function restoreCast() + if not (session and session.state) then return end + if session.entities then session.state.entities = session.entities end + if session.ghosts then session.state.ghosts = session.ghosts end + session.entities, session.ghosts = nil, nil +end + +-- Cull them. The player stays -- they are not an NPC, they are who the +-- battle belongs to, and Fly/surf animations and the save's own capture read +-- state.player through this list. +-- +-- Only the DRAW lists are touched, and only while the overworld is frozen +-- underneath a battle: StateStack updates the top state alone, so nothing +-- walks, wanders, triggers or collides against a list that is short for +-- these frames. The originals go back at battle.ended. +local function cullCast(state) + session.entities = state.entities + session.ghosts = state.ghosts + state.entities = { state.player } + state.ghosts = {} +end + +-- Stage a battle triggered from `state`, if this mode can. Returns true when +-- a session started -- which is also the only case where anything visible +-- changes, so a map with no room for an arena plays exactly the vanilla +-- battle it always did, cast and all. +function OverworldBattle.begin(state, battle) + OverworldBattle.finish() + if not OverworldBattle.enabled() then return false end + if not (state and state.map and state.player) then return false end + if not Voxel3D.available() then return false end + + local ok, arena = pcall(BattleArena.find, state.map, + state.player.cellX, state.player.cellY, + state.player.surfing) + if not (ok and arena) then return false end + + session = { state = state, arena = arena, battle = battle, shot = nil, + armed = false, token = 0 } + cullCast(state) + BattleCam.reset() + return true +end + +-- The fallback entry point: a battle that arrived without going through the +-- overworld's own pushBattle (a link battle, a script pushing a BattleState +-- directly). Nothing visible depends on the cull for those -- the wipe has +-- already been and gone -- but the arena still has to be picked. +function OverworldBattle.ensure(battle) + if session then + -- a battle pushed through the overworld reaches begin() before it is + -- built far enough to draw; battle.started is where it is finished + if battle and not session.battle then session.battle = battle end + return + end + local g = game() + local ow = g and g.overworld + if ow and ow.map then OverworldBattle.begin(ow, battle) end +end + +-- The arena this battle is staged on, or nil. Read by the shot driver so a +-- screenshot can be labelled with the ground it was taken on. +function OverworldBattle.arena() + return session and session.arena or nil +end + +function OverworldBattle.finish() + if not session then return end + restoreCast() + session = nil + Voxel3D.camera = nil +end + +-- ------- per-frame +-- +-- Driven from the voxel pipeline's update hook, which the engine ticks every +-- frame regardless of which state is on top -- including the frames the +-- transition wipe covers, which is what gets the arena's meshes built before +-- the first battle frame needs them. +-- +-- The scene is rendered HERE rather than inside the battle's draw, because +-- update runs with no canvas bound: a 3D pass that binds a depth target and +-- unbinds to the screen when it is done cannot do that in the middle of +-- someone else's frame without putting the frame back itself. +function OverworldBattle.update(dt) + if not session then return end + + local g = game() + local top = g and g.stack and g.stack:top() + local ow = g and g.overworld + -- A battle that ended without saying so (a script tearing the state down, + -- a path that never emits battle.ended) would otherwise leave the cast + -- culled for good. Armed only once something has actually covered the + -- overworld, because begin() runs while the overworld is still on top. + if top ~= nil and top ~= ow then + session.armed = true + elseif session.armed then + OverworldBattle.finish() + return + end + + 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 + session.battle = session.battle or (top ~= ow and top or nil) + -- the world pass is hidden behind the battle, so mesh builds get the wide + -- slice: nothing visible can hitch on them + ChunkMesher.pump(true) + + -- The mons' textures are rendered HERE, with no canvas bound, for the same + -- reason the scene is: the pics layer binds its own targets, and doing that + -- inside somebody else's frame means putting the frame back afterwards. + local okTex, textures = pcall(OverworldBattle.textures, session.battle) + if not okTex then textures = nil end + session.token = (session.token or 0) + 1 + local ok, shot = pcall(BattleScene.render, session.state, session.arena, + textures, session.token) + if not ok then + -- One failure retires the arena for THIS battle and nothing else: the + -- battle screen carries on as the engine's own, the free-roam pipeline + -- this runs inside keeps rendering the overworld, and the next battle + -- tries again. Rethrowing would hand the whole voxel mode to Pipelines' + -- guard, which retires a pipeline for the session. + session.shot = nil + session.broken = true + V.mod.log:warn("overworld battle scene failed: %s -- this battle draws " + .. "on the plain battle background", tostring(shot)) + return + end + if shot and shot.canvas then + -- the depth of field is measured off the two marks: the slab in focus is + -- the one the mons are standing in, at whatever the drift has done to + -- where that lands + local y1 = shot.ly + shot.player[2] * shot.scale + local y2 = shot.ly + shot.enemy[2] * shot.scale + local focusY, band, range = BattleDOF.bandFor(y1, y2, shot.ph) + 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) + end + session.shot = shot +end + +-- The finished shot for this frame, or nil when there is none and the battle +-- should draw the way it always did. +function OverworldBattle.shot() + if not session or session.broken then return nil end + local s = session.shot + if s and s.canvas then return s end + return nil +end + +function OverworldBattle.invalidate() + BattleDOF.invalidate() + BattleHud.invalidate() + BattlePics.invalidate() +end + +-- ------- the battle screen's background +-- +-- BattleState opens by filling 160x144 white -- that fill IS the battle's +-- background, and in the colorized pipeline it is also the BG canvas's clear +-- (nothing else clears it, so skipping it outright would ghost last frame). +-- So for the length of one draw, that one call is intercepted: on the two +-- offscreen canvases it becomes a transparent clear, so the shade-remap pass +-- composites the HUD and the text box over the arena and leaves the empty +-- field showing it; on the screen it is simply dropped, because the UI canvas +-- has already been cleared transparent for the world to show through. +-- +-- Matched exactly -- fill, the full frame, at the origin, in opaque white -- +-- so the text box (a 20x6 box lower down), a mon pic, an HP bar and the +-- move-animation flash (which is white at 0.85) all pass through untouched. +-- +-- This is a shim over love.graphics and it is the one invasive thing here, +-- so it is scoped as tightly as it can be: installed around a single call, +-- removed on the way out including on error, and never live outside a battle +-- frame this mode is drawing. +local function withoutBackgroundFill(battle, fn) + local g = love.graphics + local rectangle = g.rectangle + g.rectangle = function(mode, x, y, w, h, ...) + if mode == "fill" and x == 0 and y == 0 + and w == BattleScene.GB_W and h == BattleScene.GB_H 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 + local target = g.getCanvas() + if target ~= nil + and (target == battle.bgCanvas or target == battle.waveCanvas) then + g.clear(0, 0, 0, 0) + end + return + end + end + return rectangle(mode, x, y, w, h, ...) + end + local ok, err = pcall(fn, battle) + g.rectangle = rectangle + if not ok then error(err, 0) end +end + +-- ------- the mons, as textures for the 3D pass +-- +-- The two Pokemon are not composited over the world any more: they are quads +-- standing in it (see BattleBillboard). What that needs from the battle +-- screen is a TEXTURE per side -- and the honest way to get one is to let the +-- engine draw its own pics layer, unchanged, into a canvas. +-- +-- So the layer is rendered twice, once per side, with the other side +-- falsified out of existence by nulling exactly the fields its branches +-- test. Everything the engine does to a pic comes along for free that way: +-- the trainer pic before the send-out, the grow-out-of-the-ball scale, the +-- faint slide, the damage blink, the squish, every SE displacement. None of +-- it is reimplemented and none of it can drift. +-- +-- Two things are forced during that render. The scale, to 1, so the texture +-- carries the artwork's own pixels and the BILLBOARD does the sizing; and the +-- placement, so the pic lands centred on a known column with its feet on a +-- 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 + +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 texCanvas = {} +local innerPics = nil -- captured by install() + +local function texCanvasFor(side) + 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 }) + if not ok then return nil end + made:setFilter("nearest", "nearest") + texCanvas[side] = made + return made +end + +-- Whether this side has anything to draw at all. Mirrors drawPicsLayer's own +-- guards, so an empty canvas is never hung on a quad: a fainted, hidden or +-- not-yet-sent-out mon simply has no billboard this frame. +local function sideVisible(battle, side) + if side == "enemy" then + if battle.showEnemyTrainer and battle.trainerPic then return true end + return (battle.enemy and battle.enemy.sprite and not battle.enemyHidden + and not battle.enemySendingOut + and not battle:fxHidden(battle.enemy)) and true or false + end + if battle.showPlayerBack and battle.playerBackPic then return true end + local hide = battle.safari or battle.demo + return (battle.player and battle.player.sprite and not hide + and not battle.sendingOut + and not battle:fxHidden(battle.player)) and true or false +end + +local OFF = { + enemy = { player = false, showPlayerBack = false }, + player = { enemy = false, showEnemyTrainer = false }, +} + +-- Render one side's pics layer into its canvas and report where the pic's +-- feet ended up, in canvas coordinates. +function OverworldBattle.sideTexture(battle, side) + if not (innerPics and battle) then return nil end + if not sideVisible(battle, side) then return nil end + local canvas = texCanvasFor(side) + if not canvas then return nil end + + local g = love.graphics + local prevCanvas = g.getCanvas() + local prevBlend, prevAlpha = g.getBlendMode() + -- The pic-window scissors are in the battle screen's fixed coordinates and + -- would clip a pic that has been moved to the middle of its own canvas. + -- There is nothing here for them to protect -- no HUD, no text box, just + -- the one pic -- so they are switched off for the render. + local setScissor, intersectScissor = g.setScissor, g.intersectScissor + local getScissor = g.getScissor + g.setScissor = function() end + g.intersectScissor = function() end + g.getScissor = function() return nil end + + local saved = {} + for k, v in pairs(OFF[side]) do saved[k] = battle[k]; battle[k] = v end + texturing = side + + local ok, err = pcall(function() + g.setCanvas(canvas) + g.clear(0, 0, 0, 0) + g.setBlendMode("alpha") + g.setColor(1, 1, 1, 1) + innerPics(battle, 0, 0, 0) + end) + + texturing = nil + for k in pairs(OFF[side]) do battle[k] = saved[k] end + g.setScissor, g.intersectScissor, g.getScissor = + setScissor, intersectScissor, getScissor + if prevCanvas then g.setCanvas(prevCanvas) else g.setCanvas() end + 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. + if side == "enemy" and battle.showEnemyTrainer and battle.trainerPic then + ax, ay, trainer = TRAINER_AX, TRAINER_AY, true + elseif side == "player" and battle.showPlayerBack and battle.playerBackPic then + trainer = true + end + return { canvas = canvas, ax = ax, ay = ay, trainer = trainer } +end + +-- Both sides, or nil when neither has anything to show. +function OverworldBattle.textures(battle) + if not battle then return nil end + local out = {} + local okE, enemy = pcall(OverworldBattle.sideTexture, battle, "enemy") + local okP, player = pcall(OverworldBattle.sideTexture, battle, "player") + out.enemy = okE and enemy or nil + out.player = okP and player or nil + if not (out.enemy or out.player) then return nil end + return out +end + +-- ------- engine seams +-- +-- Four wraps, each idempotent so a hot reload cannot stack them. + +function OverworldBattle.install() + local OverworldState = require("src.world.OverworldController") + if not OverworldState.dramaticShapeBattleHook then + local inner = OverworldState.pushBattle + -- The one place the overworld starts a battle, and it runs BEFORE the + -- transition is pushed -- which is what lets the cull happen off-screen + -- and the wipe play over a map with nobody on it. + function OverworldState:pushBattle(battle) + pcall(OverworldBattle.begin, self, battle) + return inner(self, battle) + end + OverworldState.dramaticShapeBattleHook = true + end + + local BattleState = require("src.battle.BattleState") + if BattleState.dramaticShapeBattleHook then return end + + -- Integer scales only. The camera is solved to make one overworld square + -- exactly big enough for a pic at its own integer scale (see BattleCam), so + -- the fit never has to come out of the pixels -- and a species override or + -- a battle_sprite_scales entry that asks for 1.7x would undo that and + -- resample the sprite into mush. Rounded rather than refused, so such a mod + -- still gets the bigger or smaller mon it asked for, on the pixel grid. + local innerScale = BattleState.resolveBattleScale + function BattleState.resolveBattleScale(data, side, path, species) + local base = innerScale(data, side, path, species) + -- 1:1 into the billboard texture: the artwork's own pixels, with the + -- quad's world size doing every bit of the scaling. Anything else would + -- 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 + if not OverworldBattle.shot() then return base end + return math.max(1, math.floor((tonumber(base) or 1) + 0.5)) + end + + -- Keyed-out whites inside a pic used to be filled by the white field + -- 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. + 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) + end + + -- While a billboard texture is being rendered both pics are put in the same + -- known place -- centred on TEX_AX with their feet on TEX_AY -- so the quad + -- has one anchor to hang from whichever side and whichever species it is + -- carrying. Outside that render both helpers answer exactly as they always + -- did. + local innerBack = BattleState.backPlacement + 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 + 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 + end + + local innerDraw = BattleState.draw + function BattleState:draw() + local shot = OverworldBattle.shot() + -- AskName blanks the field on purpose (the nickname prompt is meant to + -- sit on nothing); leave that one alone. + if not shot or self.blankForAskName then + -- nil, not false: the class default is inherited again, so a battle + -- that loses its arena mid-fight goes back to white voids + self.letterboxWhite = nil + self.dramaticShapeShot = nil + return innerDraw(self) + end + self.dramaticShapeShot = shot + -- The world reaches the screen through the seam a render pipeline's + -- finished world image already uses: one window-resolution canvas, + -- blitted a pixel to a pixel, with the 160x144 UI canvas composited over + -- it in the classic letterbox afterwards. That is what makes the backdrop + -- as crisp as the free-roam diorama while the pics and text stay GB art. + local renderer = game().renderer + if renderer and renderer.setWorldOverride then + renderer:setWorldOverride(shot.canvas) + end + -- beginFrame clears the UI canvas white for an opaque state; the world is + -- under it now, so clear it back to nothing and let it through. Safe to + -- do here: an opaque battle is the lowest state drawn, so nothing has + -- drawn into this canvas yet. + love.graphics.clear(0, 0, 0, 0) + -- the white letterbox exists so the window matches the white battle + -- canvas; there is a world out to the window edges now + self.letterboxWhite = false + OverworldBattle.drawHudPanels(self) + withoutBackgroundFill(self, innerDraw) + end + + -- The mons are geometry standing on the map now, drawn in the 3D pass + -- before this screen is composited at all, so the flat pics layer has + -- 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. + innerPics = BattleState.drawPicsLayer + function BattleState:drawPicsLayer(slide, sx, sy) + if self.dramaticShapeShot then return end + return innerPics(self, slide, sx, sy) + 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 + -- 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 + local dx = (shot.enemy[1] + shot.player[1]) / 2 + - (a.enemy[1] + a.player[1]) / 2 + local dy = (shot.enemy[2] + shot.player[2]) / 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 + end + + -- Black glyphs on grass are not readable; over a frosted panel measured + -- dark they are not readable either, so they go white. Mapped rather than + -- rewritten: the HUD sets pure black for its text and nothing else, and in + -- the colorized pipeline this lands in the grayscale BG canvas, where + -- white IS shade 0 and the zone pass then colours it like every other + -- lightest-shade surface. One rule, both pipelines. + -- + -- The HP bar is untouched: it is drawn in its own greens and reds, and + -- only an exactly-black set is remapped. + local innerHUDs = BattleState.drawHUDs + function BattleState:drawHUDs(slide) + if not (self.dramaticShapeShot and self.dramaticShapeDark) then + return innerHUDs(self, slide) + end + local battle = self + BattleHud.flipGlyphs(BattleScene.GB_W, BattleScene.GB_H, function() + innerHUDs(battle, slide) + end) + end + + BattleState.dramaticShapeBattleHook = true +end + +-- Whether each HUD block is on screen this frame. +-- +-- READ-ONLY duplicates of drawHUDs' own two guards, because there is no seam +-- that reports "the enemy HUD is up". A panel under a HUD that is not there +-- would be a frosted slab floating in the arena, so it is worth mirroring; +-- the worst a future engine change can do is show an empty one for a frame, +-- never break a battle. +function OverworldBattle.hudLive(battle, slide) + local enemy = battle.enemy and not battle.showEnemyTrainer + and not battle.enemySendingOut + and not battle:growInScale(battle.enemy) and slide == 0 + and not battle.enemy.fainted + local player = battle.player and not (battle.safari or battle.demo) + and not battle.showPlayerBack and slide == 0 + return enemy and true or false, player and true or false +end + +-- Lay the frosted glass down under whichever HUD is about to draw, and +-- record which way the glyphs have to flip. +function OverworldBattle.drawHudPanels(battle) + local shot = battle.dramaticShapeShot + battle.dramaticShapeDark = nil + if not shot then return end + local slide = (battle.introSlide or 0) * 4 + local enemy, player = OverworldBattle.hudLive(battle, slide) + if not (enemy or player) then return end + local rect = OverworldBattle.HUD_RECT + local live = {} + if enemy then live.enemy = rect.enemy end + if player then live.player = rect.player end + local dark = BattleHud.verdict(live, shot) + battle.dramaticShapeDark = dark + for _, r in pairs(live) do BattleHud.panel(r, shot, dark) end +end + +return OverworldBattle diff --git a/lib/Voxel3D.lua b/lib/Voxel3D.lua index 6023738..7a22f0d 100644 --- a/lib/Voxel3D.lua +++ b/lib/Voxel3D.lua @@ -232,7 +232,15 @@ local SHADER = [[ -- Each entry is nil = untried, false = unavailable. local shaders = { [false] = nil, [true] = nil } local activeShader = nil -- the variant this pass bound -local canvas, canvasW, canvasH = nil, 0, 0 + +-- Scene canvases, one per NAMED SLOT. There are exactly two callers and +-- they want different sizes -- the free-roam pass renders at the window's +-- pixel dimensions, the overworld battle at the GB's 160x144 -- and a +-- single cached canvas made every battle entry and exit reallocate one. +-- A slot reallocates only when its OWN size changes, which is a window +-- resize, so the pair is stable for a session. +local slots = {} +local canvas, canvasW, canvasH = nil, 0, 0 -- the slot this pass bound local active = false local IDENTITY = Mat4.identity() @@ -312,9 +320,44 @@ 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 }. +-- +-- 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 +-- their own map ever needs. A staged shot -- the overworld battle's +-- over-the-shoulder rig (see BattleCam) -- is a placed camera: it has a yaw, +-- it does not sit above its focus, and its framing comes from the arena +-- rather than from the view size. Rather than widen the orbit into +-- something that could express both and be the wrong shape for each, a +-- caller with a camera of its own simply hands it over. +-- +-- Everything downstream is unchanged by this: the shader uniforms, project() +-- and the overlay all read Voxel3D.vp / Voxel3D.eye, which are set the same +-- way either way. +Voxel3D.camera = nil + -- 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) + local cam = Voxel3D.camera + if cam then + local eye, focus = cam.eye, cam.focus + Voxel3D.eye = eye + 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)) + 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 + -- 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 })) + end + local a = Voxel.angle local focal = Voxel.FOCAL local dist = focal * vh @@ -351,7 +394,9 @@ end -- `sky` is an optional {r, g, b, a} in 0..1 to clear the void to, for the -- pitch where the horizon is in frame (VoxelScene.skyFor). nil leaves the -- void transparent, which is what every rung below it wants. -function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky) +-- `slot` names which cached canvas to render into (see `slots` above); +-- omitted is the free-roam world pass. +function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky, slot) -- the wireframe variant when the player has it on AND it built; either -- answer falls through to the plain scene rather than to no scene local grid = VoxelGrid.enabled() @@ -360,12 +405,19 @@ function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky) grid, sh = false, Voxel3D.shader() end if not sh then return false end - if not canvas or canvasW ~= w or canvasH ~= h then + local name = slot or "world" + local held = slots[name] + if not (held and held.w == w and held.h == h) then local ok, c = pcall(love.graphics.newCanvas, w, h) if not ok then return false end c:setFilter("nearest", "nearest") - canvas, canvasW, canvasH = c, w, h + 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 end + 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, @@ -411,8 +463,10 @@ function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky) pcall(sh.send, sh, "ghost", 0) pcall(sh.send, sh, "ghostColor", Voxel3D.GHOST_COLOR) -- the curved world bends about the camera's focus, so the horizon keeps - -- a fixed distance ahead of the player rather than sitting on the map - Voxel3D.curveK = WorldCurve.k(vh) + -- a fixed distance ahead of the player rather than sitting on the map. + -- A placed camera may decline it outright (Voxel3D.camera.curve = 0). + local placed = Voxel3D.camera + Voxel3D.curveK = (placed and placed.curve) or WorldCurve.k(vh) Voxel3D.curveX, Voxel3D.curveZ = cx, cy pcall(sh.send, sh, "curve", { cx, cy, Voxel3D.curveK }) -- clip w at the focus point, the reference depth project() reports scale @@ -658,6 +712,12 @@ 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 + slots[name] = nil + end canvas, canvasW, canvasH = nil, 0, 0 ShadowMap.invalidate() end diff --git a/lib/VoxelScene.lua b/lib/VoxelScene.lua index 5b025d4..b3d4d6c 100644 --- a/lib/VoxelScene.lua +++ b/lib/VoxelScene.lua @@ -84,13 +84,25 @@ local function skyStrength(angleRad) return t end -local function skyFor(map) - if not (map and map.def and Map.isOutdoor(map.def)) then return nil end - local t = skyStrength(Voxel.angle) - if t <= 0 then return nil end +-- One shade off the sky ramp, transformed by the display mode, as an +-- {r, g, b, a} in 0..1. `shade` picks the rung (SKY_SHADE is the sky +-- proper; 4 is its darkest, which is what an indoor void wants). +function VoxelScene.skyShade(shade, alpha) local shades = PaletteFX.effectiveColors(SKY_SHADES) or SKY_SHADES - local c = shades[SKY_SHADE] or SKY_SHADES[SKY_SHADE] - return { c[1] / 255, c[2] / 255, c[3] / 255, t } + local c = shades[shade] or SKY_SHADES[shade] or SKY_SHADES[SKY_SHADE] + return { c[1] / 255, c[2] / 255, c[3] / 255, alpha or 1 } +end + +-- The sky `map` stands under at strength `t`, or nil where there is no sky +-- to paint: indoors, or with the horizon out of frame. +function VoxelScene.skyColor(map, t) + if not (map and map.def and Map.isOutdoor(map.def)) then return nil end + if not t or t <= 0 then return nil end + return VoxelScene.skyShade(SKY_SHADE, t) +end + +local function skyFor(map) + return VoxelScene.skyColor(map, skyStrength(Voxel.angle)) end VoxelScene._skyFor = skyFor -- named for the suite @@ -131,6 +143,9 @@ local function groundAt(map, cellX, cellY) end VoxelScene.YAW = YAW +-- shared with the overworld battle, which stands its mons on map cells and +-- needs the same answer about what height "the floor" is there +VoxelScene.groundAt = groundAt -- Camera-ward pull distance for billboards (and the grass rows, which -- must keep their relative depth to feet): just enough that a leaned-back diff --git a/main.lua b/main.lua index 2d19fab..b77e555 100644 --- a/main.lua +++ b/main.lua @@ -77,6 +77,7 @@ local TiltShift = V.require("TiltShift") local ChunkMesher = V.require("ChunkMesher") local VoxelGrid = V.require("VoxelGrid") local WorldCurve = V.require("WorldCurve") +local OverworldBattle = V.require("OverworldBattle") -- The last VOID FILL the terrain was meshed under; see the update hook. -- The scene canvas's size, in FRAMEBUFFER PIXELS. @@ -145,6 +146,15 @@ mod.content.render_pipelines:register("voxel", { -- already there instead of a flat flash. update = function(dt, level) Voxel.update(dt, level) + -- The overworld battle rides this hook rather than owning a pipeline of + -- its own, because it owns no pass of the FRAME: it draws under a battle + -- screen the engine composites, which is not a stage the registry has. + -- What it needs is a tick that keeps running once the overworld stops + -- being the top state, and this is one -- Game:update calls + -- Pipelines.update unconditionally, so it survives the transition wipe + -- and the whole battle. Ahead of the active() gate below, because a 3D + -- battle does not require the free-roam mode to be switched on. + OverworldBattle.update(dt) -- VOID FILL picks the block the border ring is made of, and in this -- mode that ring is BAKED INTO THE MESH rather than drawn each frame. -- So the option has to reach the cache or nothing happens on screen @@ -186,6 +196,7 @@ mod.content.render_pipelines:register("voxel", { invalidate = function() Voxel3D.invalidate() + OverworldBattle.invalidate() ChunkMesher.invalidate() -- no map id = every cached mesh end, }) @@ -226,6 +237,9 @@ local SETTINGS = { { VoxelGrid.setting, "One-pixel wireframe along every voxel edge." }, { WorldCurve.setting, "Bend the world down over the horizon, Animal Crossing style." }, + { OverworldBattle.setting, + "Fight on the map: the battle draws over the nearest clear ground, " + .. "shot over the shoulder with a slow parallax drift." }, } local schema = {} @@ -240,13 +254,14 @@ mod.options:define(schema) -- 5 V-GRID toggle the wireframe (new) -- 6 T-SHIFT cycle the blur ladder (was 9) -- 7 V-CURVE cycle the horizon bend (new) +-- 8 3D-BTL toggle overworld battles (new) -- -- 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, -- 5 GBC FX -- and only then offers the key to Pipelines.hotkey, expressly -- so "a pipeline can never shadow one" (Schemas, render_pipelines.hotkey). --- 3 and 5 are two of those, and 7 belongs to a pair of plain mod settings --- that own no pass and so have no registry to claim a key from at all. +-- 3 and 5 are two of those, and 7 and 8 belong to plain mod settings that +-- own no pass and so have no registry to claim a key from at all. -- -- So this wraps Game:keypressed. It is the invasive option and it is the -- only one: polling the keyboard in update() would fire alongside the @@ -268,6 +283,7 @@ local HOTKEYS = { ["6"] = "pipeline", -- tiltshift, likewise ["5"] = VoxelGrid.setting, ["7"] = WorldCurve.setting, + ["8"] = OverworldBattle.setting, } do @@ -304,10 +320,14 @@ do return end elseif Pipelines.canToggle("voxel", top, self.overworld) then - -- Both settings parameterise the voxel pass, so they answer to the - -- same free-roam gate it does -- 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. + -- All three 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 + -- 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) return end @@ -416,7 +436,52 @@ mod.events:on("map.reloaded", function(payload) if mapId then ChunkMesher.invalidate(mapId) end end) -mod.exports.version = "1.0.6" +-- ------- battles on the map +-- +-- The wraps this needs -- OverworldState:pushBattle, BattleState:draw and +-- BattleState:drawHUDs -- all live in lib/OverworldBattle.lua, which is +-- where the reasoning for each one is written down. Installed once, here, +-- so this file keeps naming every engine seam the mod touches. +OverworldBattle.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 +-- instead, which stages the arena from wherever the player is standing. +-- Nothing visible is lost by being late: the cull only has to beat the +-- battle screen, and the wipe those battles skip is where it would have +-- shown. +mod.events:on("battle.started", function(payload) + OverworldBattle.ensure(payload and payload.battle) +end) + +-- Both mons face the camera, so the player's side wants its FRONT pic where +-- the battle screen would have used the back one. The engine's own +-- pokemon.sprite hook is the seam for exactly this: it is asked for every +-- battle pic with the side it is resolving, so swapping one side's answer +-- needs no battle code at all -- and every path that builds a battler goes +-- through it, including a Transform mid-fight. +-- +-- next() first, so a sprite-replacing mod loaded before this one still gets +-- the last word on WHICH art is used; this only changes which SIDE is asked +-- for. +mod.hooks:wrap("pokemon.sprite", function(next, path, ctx) + local out = 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 + local def = ctx.data and ctx.data.pokemon and ctx.data.pokemon[ctx.species] + return (def and def.spriteFront) or out +end) + +-- Every ending path emits this, including a battle skipped before it drew, +-- so this is where the map's cast comes back. +mod.events:on("battle.ended", function() + OverworldBattle.finish() +end) + +mod.exports.version = "1.1.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 89de570..3ac3b2e 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "DRAMATIC_SHAPE", "name": "Dramatic Shape Voxel Mod", - "version": "1.0.6", + "version": "1.1.0", "api": 2, "entry": "main.lua", "profile": "content", @@ -13,5 +13,5 @@ "conflicts": [], "permissions": ["engine_internals"], "affects_link": false, - "description": "A full 3D diorama overworld: extruded terrain, depth-buffered occlusion, voxel characters and a tilt-shift miniature pass. Registers two render pipelines and claims hotkeys 3, 5, 6 and 7 -- 3 and 5 displace the engine's TILT and GBC FX keys, both still reachable on the OPTIONS menu. Presentational only." + "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." } diff --git a/mod.card b/mod.card index cac50a1..762df5b 100644 --- a/mod.card +++ b/mod.card @@ -1,14 +1,18 @@ -- Sharing metadata (25-community-and-ecosystem.md 3.2). Read by tooling -- and the manager detail pane; never by the loader's merge. return { - summary = "The overworld as a 3D diorama: extruded terrain, real occlusion, voxel characters, tilt-shift miniature blur.", + 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", - tags = { "graphics", "3d", "voxel", "render-pipeline", "presentation" }, + tags = { "graphics", "3d", "voxel", "render-pipeline", "presentation", + "battle" }, differences = { 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, a battle draws over the map's nearest clear ground instead of over a white field", + "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", "VOXEL and the engine's TILT are mutually exclusive -- turning one on switches the other off", "hotkeys 3 and 5 are taken over from the engine's TILT and GBC FX; both remain on the OPTIONS menu", "the VOXEL key (3) turns TILT and GBC FX off on every press -- both fight the diorama, and 3 is now the only key that reaches either", @@ -17,12 +21,17 @@ 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", + "3D-BTL on hotkey 8 (ON / OFF, on by default), battles fought on the world map", + "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", "a hand-authored tile shape profile (data/voxel_heights.lua) a mod can extend", }, known = { - "needs shader and depth-canvas support; without them the row still cycles but the world stays 2D", - "battles, menus and cutscenes are unaffected -- the mode only draws the free-roam overworld", + "needs shader and depth-canvas support; without them the rows still cycle but the world stays 2D and battles draw plainly", + "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", + "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", }, }, diff --git a/tests/arena_pick.lua b/tests/arena_pick.lua new file mode 100644 index 0000000..d8af2d8 --- /dev/null +++ b/tests/arena_pick.lua @@ -0,0 +1,173 @@ +-- Driver: choose and photograph one battle arena per map. +-- +-- data/battle_arenas.lua holds one authored spot per area. This is what +-- authors it: for every map a battle can happen on, it asks BattleArena for +-- the arena nearest the map's middle that its clearance test says both mons +-- can be SEEN in, stages a real battle there, and takes one screenshot. +-- +-- Two outputs. A `PICK` line per map, ready to paste into the data file, and +-- a PNG per map to look at -- because the clearance test answers a geometry +-- question ("nothing tall on the sightline") and the actual question is +-- whether the picture reads. Grass and flowers around a mon's feet are fine +-- and wanted; a body cut in half by a wall is not, and only an eye catches +-- the difference. +-- +-- SHOT_DIR=.scratchpad/arenas ARENA_FROM=1 ARENA_COUNT=20 \ +-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/arena_pick.lua love . +-- +-- ARENA_FROM / ARENA_COUNT slice the map list so several runs can share the +-- work; ARENA_MAPS=ID,ID,... does an explicit set instead. SHOT_DIR must +-- already exist -- the capture writes with io.open, which does not create +-- directories. +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or ".scratchpad/arenas" + local Pokemon = require("src.pokemon.Pokemon") + local BattleState = require("src.battle.BattleState") + + game.save.party = { Pokemon.new(game.data, "CHARIZARD", 45) } + game.save.player.name = "RED" + + local exports = game.mods and game.mods.exports + local lib = exports and exports.DRAMATIC_SHAPE and exports.DRAMATIC_SHAPE.lib + if not lib then + U.log("DRAMATIC_SHAPE is not loaded -- enable it and run again") + return + end + local Arena = lib.require("BattleArena") + local Battles = lib.require("OverworldBattle") + + -- Which maps a battle can actually happen on: anything with a wild + -- encounter table or an object that fights. Everything else -- a shop + -- floor, a stairwell, a bedroom -- would be authoring a spot for a fight + -- that never happens there. + -- + -- The encounter tables are their OWN registry keyed by map id, not a field + -- on the map record. An earlier cut of this read `def.encounters`, which is + -- always nil -- so the list silently narrowed to trainer maps only and + -- every route, cave and Safari zone fell out of it. Nothing failed; the + -- tool just quietly stopped offering to author the places wild battles + -- actually happen. + local function fights(id, def) + local enc = game.data.encounters and game.data.encounters[id] + if enc then + for _, kind in ipairs({ "grass", "water" }) do + local t = enc[kind] + if t and (t.rate or 0) > 0 and t.slots and t.slots[1] then + return true + end + end + end + for _, obj in ipairs(def.objects or {}) do + if obj.trainer or obj.trainerClass or obj.species then return true end + end + return false + end + + local ids = {} + local explicit = os.getenv("ARENA_MAPS") + if explicit and explicit ~= "" then + for id in explicit:gmatch("[^,%s]+") do ids[#ids + 1] = id end + else + for id, def in pairs(game.data.maps) do + if type(id) == "string" and type(def) == "table" and fights(id, def) then + ids[#ids + 1] = id + end + end + table.sort(ids) + end + + local from = tonumber(os.getenv("ARENA_FROM") or "") or 1 + local count = tonumber(os.getenv("ARENA_COUNT") or "") or #ids + U.log(("%d battle maps; doing %d..%d"):format(#ids, from, + math.min(#ids, from + count - 1))) + -- ARENA_COUNT=0 lists what WOULD be done and stops. The set this filter + -- picks is the authoritative answer to "which areas need an arena", so it + -- has to be readable without sitting through a capture -- that is how the + -- bug above went unnoticed. + if count <= 0 then + for i = 1, #ids, 8 do + U.log("LIST " .. table.concat(ids, ",", i, math.min(#ids, i + 7))) + end + U.log("done -- listing only") + return + end + + for i = from, math.min(#ids, from + count - 1) do + local id = ids[i] + local def = game.data.maps[id] + local cx = math.floor(def.width) -- the middle, in cells + local cy = math.floor(def.height) + + -- the pick is made against the MAP, before anything is staged, so a map + -- with no clear arena at all is reported rather than quietly given a bad + -- one + local ok, err = pcall(function() + U.teleport(game, id, 1, 1, "down") + end) + if not ok then + U.log(("SKIP %s -- could not enter (%s)"):format(id, tostring(err))) + else + local map = game.overworld.map + local clear, any + -- ARENA_AT=x,y[,shape] photographs one specific spot instead of the + -- picked one, which is how a map the automatic choice got wrong is + -- re-aimed by hand and checked in the same loop. + -- ARENA_MAP=OTHER_ID stages on another floor of the same cave or + -- building, for a map that has nowhere of its own to put a fight + local at = os.getenv("ARENA_AT") + local onMap = os.getenv("ARENA_MAP") + if at and at ~= "" and (explicit and explicit ~= "") then + local ax, ay, ashape = at:match("^(%-?%d+)%s*,%s*(%-?%d+)%s*,?%s*(%a*)$") + if ax then + -- forced through the authored-entry seam, so what gets staged is + -- exactly what was asked for rather than whatever the search + -- would have picked from the player's cell + local onCam = os.getenv("ARENA_CAM") + Arena.setOverride(id, { + x = tonumber(ax), y = tonumber(ay), + shape = (ashape ~= "" and ashape) or "wide", + map = (onMap ~= "" and onMap) or nil, + cam = (onCam and onCam ~= "" and onCam) or nil, + }) + any = Arena.find(map, 0, 0, false) + clear = any and Arena.clearance(any.map or map, any) or false + end + end + if not any then + clear = Arena.search(map, cx, cy, false, true) + any = clear or Arena.search(map, cx, cy, false) + end + if not any then + U.log(("NONE %s -- no arena of either shape"):format(id)) + else + local onOther = any.map and any.map.id ~= id and any.map.id or nil + U.log(("PICK [%q] = { %sx = %d, y = %d, shape = %q%s },%s") + :format(id, onOther and ("map = %q, "):format(onOther) or "", + any.x, any.y, any.shape, + any.cam and (", cam = %q"):format(any.cam) or "", + clear and "" or " -- OBSTRUCTED: no clear spot")) + -- stand the player on the arena so the staged battle uses it, then + -- fight something there and photograph the result + game.overworld.player.cellX = any.playerCell[1] + game.overworld.player.cellY = any.playerCell[2] + U.wait(90) -- let the meshes land + local battle = BattleState.newWild(game, "NIDORINO", 20) + battle.onFinish = function() end + game.overworld:pushBattle(battle) + U.wait(70) + for _ = 1, 14 do U.tap(game, "a"); U.wait(8) end + local got = Battles.arena() + U.log(("SHOT %s at %s,%s"):format(id, + tostring(got and got.x), tostring(got and got.y))) + U.shot(game, ("%s/%s.png"):format(DIR, id:lower())) + while game.stack:top() and game.stack:top() ~= game.overworld do + game.stack:pop() + end + U.wait(6) + end + end + end + + U.log("done -- " .. DIR) +end diff --git a/tests/battle_shots.lua b/tests/battle_shots.lua new file mode 100644 index 0000000..af8ae8b --- /dev/null +++ b/tests/battle_shots.lua @@ -0,0 +1,130 @@ +-- Driver: screenshot an overworld battle against eight random trainers. +-- +-- The mode's whole claim is that a fight reads as happening on the map, so +-- what has to be looked at is a spread of maps: open route, town, forest, +-- cave, gym floor. Each one gets the same three beats -- the menu (both HUD +-- panels up over whatever ground they landed on), a move animation, and the +-- frame a few seconds later, which is where the parallax drift shows. +-- +-- The eight are drawn with a fixed seed, so a re-run after a tweak produces +-- the same eight battles and the shots compare directly. +-- +-- SHOT_DIR=.scratchpad POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/battle_shots.lua love . +-- +-- SHOT_DIR must already exist: the capture writes with io.open, which does +-- not create directories. +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or ".scratchpad" + local Pokemon = require("src.pokemon.Pokemon") + local BattleState = require("src.battle.BattleState") + + local SEED = tonumber(os.getenv("SHOT_SEED") or "") or 20260727 + love.math.setRandomSeed(SEED) + + -- Somebody to fight with. Two very different back pics, so a pin that is + -- wrong for one silhouette and right for another cannot hide. + game.save.party = { + Pokemon.new(game.data, "CHARIZARD", 45), + Pokemon.new(game.data, "PIKACHU", 40), + } + game.save.player.name = "RED" + + -- Where to stage them: outdoor, wooded, urban, underground, indoor. Any id + -- the merged dataset does not have is dropped rather than guessed at. + local PLACES = { + { "ROUTE_1", 5, 8 }, + { "VIRIDIAN_FOREST", 12, 20 }, + { "PALLET_TOWN", 5, 6 }, + { "MT_MOON_1F", 12, 12 }, + { "CERULEAN_CITY", 10, 12 }, + { "ROUTE_25", 12, 5 }, + { "PEWTER_GYM", 4, 8 }, + { "ROUTE_4", 10, 5 }, + { "VIRIDIAN_CITY", 20, 20 }, + { "ROUTE_3", 10, 5 }, + } + local places = {} + for _, p in ipairs(PLACES) do + if game.data.maps[p[1]] then places[#places + 1] = p end + end + + -- every trainer class the merged data carries, in a stable order so the + -- seed picks the same ones every run + local classes = {} + for id, rec in pairs(game.data.trainers) do + if type(id) == "string" and id:sub(1, 1) ~= "_" + and type(rec) == "table" and rec.parties and rec.parties[1] then + classes[#classes + 1] = id + end + end + table.sort(classes) + U.log(("%d trainer classes, %d places, seed %d") + :format(#classes, #places, SEED)) + + local Battles = nil + local exports = game.mods and game.mods.exports + local lib = exports and exports.DRAMATIC_SHAPE and exports.DRAMATIC_SHAPE.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") + end + + local function label(place, class) + local arena = Battles and Battles.arena() + if not arena then + return ("%s / %s -- NO ARENA (plain battle screen)") + :format(place[1], class) + end + return ("%s / %s -- %s arena at cell %d,%d; enemy %d,%d player %d,%d") + :format(place[1], class, arena.shape, arena.x, arena.y, + arena.enemyCell[1], arena.enemyCell[2], + arena.playerCell[1], arena.playerCell[2]) + end + + for i = 1, 8 do + -- the TRAINERS are the random part; the places are walked in order, so + -- one run covers open route, town, forest, cave and gym floor rather + -- than landing on the same meadow eight times + local place = places[(i - 1) % #places + 1] + local class = classes[love.math.random(#classes)] + local rec = game.data.trainers[class] + local partyIndex = love.math.random(#rec.parties) + local tag = ("%02d_%s_%s"):format(i, place[1]:lower(), class:lower()) + + U.teleport(game, place[1], place[2], place[3], "down") + -- let the neighbourhood's meshes land before the fight starts, so the + -- first battle frame is not the flat fallback + U.wait(90) + + local battle = BattleState.newTrainer(game, class, partyIndex) + battle.onFinish = function() end + game.overworld:pushBattle(battle) + + -- the wipe, then the send-out chatter + U.wait(70) + for _ = 1, 14 do U.tap(game, "a"); U.wait(8) end + U.log(label(place, class)) + U.shot(game, ("%s/%s_1_menu.png"):format(DIR, tag)) + + -- FIGHT -> first move -> the animation + U.tap(game, "a") + U.wait(12) + U.tap(game, "a") + U.wait(24) + U.shot(game, ("%s/%s_2_anim.png"):format(DIR, tag)) + + -- and a while later, where the drift has moved the world under them + U.wait(240) + U.shot(game, ("%s/%s_3_drift.png"):format(DIR, tag)) + + -- out of the battle and on to the next: pop whatever is above the + -- overworld rather than trying to play the fight to its end + 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/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua index 9612840..006a23e 100644 --- a/tests/dramatic_shape_test.lua +++ b/tests/dramatic_shape_test.lua @@ -10,7 +10,12 @@ local T = require("tests.modkit") local Pipelines = require("src.render.Pipelines") local Data = T.fixtures.load() -local run = T.sdk.loadMod("mods/DRAMATIC_SHAPE", { data = Data }) +-- DS_MOD_PATH lets the suite run against a copy of the mod. The game holds +-- an open handle on the live directory while it is running, and on Windows +-- that is enough to make the headless loader's directory probe fail, so a +-- run alongside a live session points at a copy instead. +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, "; ")) @@ -100,19 +105,25 @@ T.eq(byLabel.VOXEL.value(), "15", "the row renders the current rung's label") -- ------- this mod's own settings -- --- Neither is a pipeline (they parameterise the voxel pass rather than --- owning one), so they reach the same menu through the ui.options.rows --- hook, and store themselves where the mod manager's settings page looks. +-- None of them is a pipeline -- two parameterise the voxel pass rather than +-- owning one, and the third decides what a BATTLE is drawn over, which is +-- not a stage the registry has -- so they reach the same menu through the +-- ui.options.rows hook, and store themselves where the mod manager's +-- settings page looks. local Runtime = require("src.mods.Runtime") local hookedRows = Runtime.call("ui.options.rows", function(_, r) return r end, { data = Data }, { { id = "text_speed" } }) -T.eq(#hookedRows, 3, "the options hook added a row per setting") -local grid, curve = hookedRows[2], hookedRows[3] +T.eq(#hookedRows, 4, "the options hook added a row per setting") +local grid, curve, battles = hookedRows[2], hookedRows[3], hookedRows[4] T.eq(grid.label, "V-GRID", "the grid row carries its label") 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(), "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") -- stepping writes through to the one place both rows read local settingGame = { save = { options = {} }, mods = { modOptions = {} } } @@ -694,13 +705,14 @@ T.eq(modeColors(nil), nil, "and a pipeline given no paletteFor at all is safe") -- 5 V-GRID toggle the wireframe -- 6 T-SHIFT cycle the blur ladder -- 7 V-CURVE cycle the horizon bend +-- 8 3D-BTL toggle overworld battles -- -- Only 6 reaches the pipeline registry the documented way. Game:keypressed -- answers the engine's own display keys first and returns -- 3 is TILT and --- 5 is GBC FX -- expressly so a pipeline cannot shadow one, and 7 belongs --- to settings that own no pass and so have no registry to claim from. The --- mod therefore wraps Game:keypressed, and these pin what that wrapper is --- allowed to take. +-- 5 is GBC FX -- expressly so a pipeline cannot shadow one, and 7 and 8 +-- belong to settings that own no pass and so have no registry to claim +-- from. The mod therefore wraps Game:keypressed, and these pin what that +-- wrapper is allowed to take. local Game = require("src.core.Game") Pipelines.reset() @@ -739,6 +751,13 @@ 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") +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") +Game.keypressed(keyGame, "8") +T.eq(Battles.setting:get(), true, "and back on") + -- 3 also clears the two engine modes it displaced. Without this a player -- who left TILT or GBC FX on before enabling the mod has no key left to -- turn them off with, and both fight the diorama -- TILT is the flat fake @@ -890,6 +909,369 @@ T.check(skyRGB("gbc_inv")[3] ~= blue[3], Voxel.angle = 0 +-- ------- overworld battles: where the fight is staged +-- +-- The arena search is pure map arithmetic, so it is driven here against +-- hand-drawn maps rather than a fixture: the shape it looks for, the order +-- it relaxes in, and what it refuses are all things a picture can state +-- exactly. +-- +-- The stub answers the small surface BattleArena asks a Map for. `rows` are +-- 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 function stubMap(rows) + local at = function(cx, cy) + local row = rows[cy + 1] + return row and row:sub(cx + 1, cx + 1) or "#" + end + return { + widthCells = #rows[1], + heightCells = #rows, + inBounds = function(_, cx, cy) + return cx >= 0 and cy >= 0 and cx < #rows[1] and cy < #rows + end, + warpAtCell = function() return nil end, + isWarpTileCell = function(_, cx, cy) return at(cx, cy) == "d" end, + isWalkableCell = function(_, cx, cy) return at(cx, cy) == "." end, + isWaterCell = function(_, cx, cy) return at(cx, cy) == "w" end, + isGrassCell = function(_, cx, cy) return at(cx, cy) == "g" end, + } +end + +-- a field with room for the wide arena on its right-hand side only +local field = stubMap({ + "##########", + "#....#####", + "#....#####", + "#....#####", + "#....#####", + "#....#####", + "#....#####", + "##########", +}) + +local arena = BattleArena.find(field, 2, 3, false) +T.check(arena ~= nil, "a field with a 3x6 clearing has an arena") +T.eq(arena.shape, "wide", "and it is the wide shape, not the fallback") +T.eq(arena.w, 3, "the wide arena is three cells across") +T.eq(arena.h, 6, "and six deep") + +-- the two mons stand three cells apart down the middle column, which is +-- what the picture in BattleArena says and what the camera is built around +T.eq(arena.enemyCell[1], arena.playerCell[1], + "both mons stand in the arena's middle column") +T.eq(arena.playerCell[2] - arena.enemyCell[2], 3, + "with three cells of ground between them") +T.check(arena.enemyCell[2] < arena.playerCell[2], + "the enemy is the NORTH one -- the far end from a camera parked south") + +-- every cell of the shape is open, apron included: that one-cell margin is +-- the difference between a staged shot and a fight in a doorway +for cy = arena.y, arena.y + arena.h - 1 do + for cx = arena.x, arena.x + arena.w - 1 do + T.check(field:isWalkableCell(cx, cy), + ("arena cell (%d,%d) is open ground"):format(cx, cy)) + end +end + +-- world-pixel centres, which is the unit everything downstream works in +T.eq(arena.player[1], arena.playerCell[1] * 16 + 8, + "the player's mark is the centre of its cell in world pixels") +T.eq(arena.mid[2], (arena.enemy[2] + arena.player[2]) / 2, + "and the midpoint is halfway between the two") + +-- nearest, not first found: two clearings, and the one under the player wins. +-- Wide enough that the battle camera -- which stands a few cells east and +-- south of whatever it is aimed at -- is over real ground for BOTH of them, +-- so this measures proximity rather than the clearance preference below. +local twin = stubMap({ + "########################", + "#.##########.###########", + "#.##########.###########", + "#.##########.###########", + "#.##########.###########", + "########################", + "########################", + "########################", + "########################", + "########################", +}) +local near = BattleArena.find(twin, 12, 3, false) +T.eq(near.shape, "narrow", "no 3x6 anywhere, so the search relaxes") +T.eq(near.x, 12, "and takes the corridor the player is standing in") +T.eq(BattleArena.find(twin, 1, 3, false).x, 1, + "the same map from the other side picks the other one") + +-- the wide shape wins even when a narrow one is closer: it is the shot this +-- mode is framed for, so proximity does not get to overrule it +local both = stubMap({ + "#.#########", + "#.####...##", + "#.####...##", + "#.####...##", + "######...##", + "######...##", + "######...##", +}) +T.eq(BattleArena.find(both, 1, 1, false).shape, "wide", + "a wide arena across the map beats a narrow one underfoot") + +-- water is ground for a surfer and nothing at all for anyone else +local sea = stubMap({ + "wwwwww", + "wwwwww", + "wwwwww", + "wwwwww", + "wwwwww", + "wwwwww", +}) +T.eq(BattleArena.find(sea, 2, 2, false), nil, + "open water is not open ground to someone walking") +T.check(BattleArena.find(sea, 2, 2, true) ~= nil, + "but it is to a surfer, who is standing on it") + +-- tall grass is walkable and is still not a stage: it is knee-high geometry +-- standing between a nearly-level camera and the mon behind it +local meadow = stubMap({ + "########", + "#ggg..g#", + "#ggg..g#", + "#ggg..g#", + "#ggg..g#", + "#ggg..g#", + "#ggg..g#", + "########", +}) +local mown = BattleArena.find(meadow, 2, 3, false) +T.check(mown ~= nil, "a meadow with a bare strip still has an arena") +for cy = mown.y, mown.y + mown.h - 1 do + for cx = mown.x, mown.x + mown.w - 1 do + T.check(not meadow:isGrassCell(cx, cy), + ("no cell of the arena is tall grass (%d,%d)"):format(cx, cy)) + end +end +T.eq(BattleArena.find(stubMap({ + "#####", "#ggg#", "#ggg#", "#ggg#", "#ggg#", "#ggg#", "#ggg#", "#####", +}), 2, 3, false), nil, + "a map that is nothing but grass has nowhere to stand a fight") + +-- a doormat is walkable and is still not a stage +local hall = stubMap({ + "######", + "#..d.#", + "#....#", + "#....#", + "#....#", + "######", +}) +local halled = BattleArena.find(hall, 2, 3, false) +T.check(halled == nil or halled.shape == "narrow", + "a warp tile is excluded, so the room's only 3x6 does not qualify") + +T.eq(BattleArena.find(stubMap({ "###", "###" }), 1, 1, false), nil, + "a map with no room at all yields no arena, and the battle draws plainly") + +-- an authored refusal is honoured over the search: a map looked at and found +-- to have nowhere a fight reads has to be able to say so, or the fallback +-- goes and finds one of the spots that were already rejected by eye +local roomy = stubMap({ + "#####", "#...#", "#...#", "#...#", "#...#", "#...#", "#...#", "#####", +}) +roomy.id = "TEST_REFUSED" +T.check(BattleArena.find(roomy, 2, 3, false) ~= nil, + "a roomy map finds an arena by search when nothing is authored") +BattleArena.setOverride("TEST_REFUSED", false) +T.eq(BattleArena.find(roomy, 2, 3, false), nil, + "an authored false refuses the map outright, search and all") +BattleArena.setOverride("TEST_REFUSED", nil) +T.check(BattleArena.find(roomy, 2, 3, false) ~= nil, + "and dropping the refusal restores the search") + +-- ------- overworld battles: the over-the-shoulder framing +-- +-- The claim the whole shot rests on. The two pics are PINNED to their cells, +-- so the rig has to put those two patches of ground exactly where the GB's +-- own battle screen puts its two pics -- the player's low and left at +-- (40, 96), the enemy's high and right at (124, 56). Reprojected through the +-- 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") + +-- where a world point lands in the 160x144 frame, or nil behind the camera +local function project(cam, point, w, h, fov) + local saved = cam.fov + if fov then cam.fov = fov end + Voxel3Dcam.camera = cam + local m = Voxel3Dcam.viewProjection(0, 0, w or 160, h or 144) + Voxel3Dcam.camera = nil + cam.fov = saved + local x = m[1] * point[1] + m[2] * 0 + m[3] * point[2] + m[4] + local y = m[5] * point[1] + m[6] * 0 + m[7] * point[2] + m[8] + local cw = m[13] * point[1] + m[14] * 0 + m[15] * point[2] + m[16] + if cw <= 1e-6 then return nil end + return (x / cw * 0.5 + 0.5) * (w or 160), (y / cw * 0.5 + 0.5) * (h or 144) +end + +BattleCam.reset() +local shot = BattleArena.find(field, 2, 3, false) +local rig, pitch = BattleCam.rig(shot, 0) + +local px, py = project(rig, shot.player) +local ex, ey = project(rig, shot.enemy) +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 " + .. "(%.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 " + .. "(%.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") + +-- low and long: the eye is near the floor looking almost along it, which is +-- what a 56-pixel sprite standing on a 16-pixel tile costs +T.check(pitch > math.rad(60) and pitch < math.rad(85), + "the rig watches the arena from near ground level") + +-- ------- a mon covers its own square +-- +-- The pics are drawn at INTEGER scales -- 56 pixels for a front pic at 1x, 64 +-- for a back pic at 2x -- so the only way a mon can stand in one overworld +-- square is for the camera to make that square that big. This is the pair of +-- equations the default rig was solved against alongside the two anchors, and +-- it is the one that sets how far away the camera has to be. +local function span(cam, point) + local a = project(cam, { point[1] - 8, point[2] }) + local b = project(cam, { point[1] + 8, point[2] }) + 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") + :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") + :format(span(rig, shot.enemy))) + +-- ------- the close rig, for rooms the default cannot stand back from +-- +-- Five blocks is further than a gym is wide, so on one the default eye lands +-- outside the map and the border ring crosses the near mon. An arena asks for +-- the short rig by name, and the two things that have to hold are that it +-- really is close enough to sit in a room, and that it frames the SAME shot +-- -- both marks still on their anchors -- so swapping rigs changes the lens +-- and nothing about the composition. +local function eyeDistance(cam) + local dx = cam.eye[1] - cam.focus[1] + local dy = cam.eye[2] - cam.focus[2] + local dz = cam.eye[3] - cam.focus[3] + return math.sqrt(dx * dx + dy * dy + dz * dz) +end + +T.check(eyeDistance(rig) > 120, + "the default long lens stands well back -- that is what sizes the mons") + +BattleCam.reset() +local snug = { mid = shot.mid, cam = "wide" } +local closeRig = BattleCam.rig(snug, 0) +local cd = eyeDistance(closeRig) +T.check(cd < 80, + ("the wide lens is within five cells, so it fits inside a gym: got %.1f") + :format(cd)) +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, + ("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, + ("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") + +-- an arena picks its rig by name, and anything unnamed gets the default +T.eq(BattleCam.rigFor({ cam = "wide" }), BattleCam.RIGS.wide, + "an arena that asks for the wide lens gets it") +T.eq(BattleCam.rigFor({}), BattleCam.RIGS.tele, "and one that asks for nothing") +T.eq(BattleCam.rigFor({ cam = "nonsense" }), BattleCam.RIGS.tele, + "as does one that asks for a rig that does not exist") + +-- ------- the pins survive the window +-- +-- The scene renders at the WINDOW's resolution, not the GB's, so the rig's +-- field of view is widened by the ratio the window bears to the letterbox. +-- What that has to buy is exactness: the letterbox sub-rectangle of the +-- widened render must be the framing the rig asked for, or the pics come +-- unpinned from the ground by however much it is out. + +for _, win in ipairs({ { 1920, 1080, 7 }, { 640, 576, 4 }, { 1280, 1024, 7 }, + { 800, 720, 5 } }) do + local pw, ph, s = win[1], win[2], win[3] + local lx = math.floor((pw - 160 * s) / 2) + local ly = math.floor((ph - 144 * s) / 2) + local wide = BattleScene.letterboxFov(rig.fov, ph, s) + T.check(wide >= rig.fov - 1e-9, + "a window taller than the letterbox needs a wider lens, never a tighter one") + local wx, wy = project(rig, shot.player, pw, ph, wide) + local gx, gy = (wx - lx) / s, (wy - ly) / s + T.check(math.abs(gx - px) < 0.01 and math.abs(gy - py) < 0.01, + ("%dx%d at scale %d reproduces the GB framing exactly: (%.3f, %.3f) vs " + .. "(%.3f, %.3f)"):format(pw, ph, s, gx, gy, px, py)) +end + +-- ------- the drift +-- +-- With the pics pinned, the drift is not decoration on a backdrop -- it +-- moves the mons themselves, and the whole reason it reads as depth is that +-- it moves the near one and the far one by DIFFERENT amounts. A backdrop +-- that merely slid would move them by the same one. +BattleCam.update(BattleCam.PAN_PERIOD / 4) +local rig2 = BattleCam.rig(shot, 0) +local px2 = project(rig2, shot.player) +local ex2 = project(rig2, shot.enemy) +T.check(math.abs(px2 - px) > 0.5, "the drift moves the near mark") +T.check((px2 - px) * (ex2 - ex) < 0, + "and the far one the OTHER WAY -- parallax about a point between them") +T.check(math.abs(px2 - px) < 8 and math.abs(ex2 - ex) < 8, + "neither is flung across the frame: the mons drift, they do not travel") + +-- very slow: a quarter of the cycle is several seconds, and what it moves in +-- one FRAME has to be imperceptible +BattleCam.reset() +BattleCam.update(1 / 60) +local slow = BattleCam.rig(shot, 0) +local sx = project(slow, shot.player) +T.check(math.abs(sx - px) < 0.2, + "one frame of drift moves a mon by a fifth of a pixel") + +-- a placed camera declines the world curve outright: the bend exists to +-- drop the horizon away from a walking player, and here it would tip the +-- arena floor out from under the two mons pinned to it +T.eq(rig.curve, 0, "the battle camera switches the world curve off") + +-- ------- the depth of field is measured off the two marks +-- +-- The slab held sharp is the one the mons are standing in, so the band has +-- 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 focusY, band, range = BattleDOF.bandFor(96, 56, 144) +T.check(math.abs(focusY - 76 / 144) < 1e-9, + "the band centres between the two marks") +T.check(focusY - band < 56 / 144 and focusY + band > 96 / 144, + "and is wide enough that both of them are inside it") +T.check(range > 0, "with a ramp out of it, so the band edge has no seam") +local wide = select(2, BattleDOF.bandFor(120, 30, 144)) +T.check(wide > band, "marks further apart hold a deeper slab in focus") + Pipelines.reset() run.release() diff --git a/tools/contact_sheets.py b/tools/contact_sheets.py new file mode 100644 index 0000000..cab9ca4 --- /dev/null +++ b/tools/contact_sheets.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +"""Assemble the per-map battle-arena screenshots into contact sheets. + +One shot per map is a lot of files to open one at a time, so this lays them +out eight to a page -- a 2x4 grid, each tile labelled with its map id -- which +is enough to judge a whole region at a glance and spot the one that is wrong. + + python mods/DramaticShapeVoxelMod/tools/contact_sheets.py \ + .scratchpad/areas .scratchpad/sheets [order.txt] + +Reads every PNG in the source directory and writes sheet_01.png, +sheet_02.png ... into the destination. + +The optional third argument is a file of map ids, one per line, which both +FILTERS and ORDERS the sheets. That is what makes them worth looking at: the +authored list in data/battle_arenas.lua is grouped by region, so feeding its +keys through puts the routes together and the caves together, instead of +alphabetical order interleaving a gym with a tower floor. Without it every +PNG in the directory is used, sorted by name. +""" + +import os +import sys + +from PIL import Image, ImageDraw, ImageFont + +PER_SHEET = 8 +COLS, ROWS = 2, 4 +TILE_W = 960 # each shot is 3840 wide; a quarter of that +LABEL_H = 34 +PAD = 8 +BG = (18, 18, 20) +LABEL_BG = (32, 32, 36) +LABEL_FG = (232, 232, 236) + + +def font(size): + for name in ("consola.ttf", "DejaVuSansMono.ttf", "arial.ttf"): + try: + return ImageFont.truetype(name, size) + except OSError: + continue + return ImageFont.load_default() + + +def tile(path, w, h, label, f): + """One labelled cell: the shot scaled to fit, with its map id under it.""" + cell = Image.new("RGB", (w, h + LABEL_H), LABEL_BG) + try: + shot = Image.open(path).convert("RGB") + except OSError as err: + d = ImageDraw.Draw(cell) + d.text((8, 8), "unreadable: %s" % err, fill=(220, 90, 90), font=f) + return cell + shot.thumbnail((w, h), Image.LANCZOS) + cell.paste(shot, ((w - shot.width) // 2, (h - shot.height) // 2)) + d = ImageDraw.Draw(cell) + d.rectangle([0, h, w, h + LABEL_H], fill=LABEL_BG) + d.text((10, h + 7), label, fill=LABEL_FG, font=f) + return cell + + +def wanted(src, order_path): + """The PNGs to lay out, in the order to lay them out in.""" + have = {n.lower(): n for n in os.listdir(src) + if n.lower().endswith(".png")} + if not order_path: + return sorted(have.values()), [] + names, missing = [], [] + with open(order_path, encoding="utf-8") as fh: + for line in fh: + key = line.strip() + if not key or key.startswith("#"): + continue + hit = have.get(key.lower() + ".png") + if hit: + names.append(hit) + else: + missing.append(key) + return names, missing + + +def main(src, dst, order_path=None): + names, missing = wanted(src, order_path) + if not names: + sys.exit("no PNGs to lay out from " + src) + # said out loud rather than silently skipped: a sheet set that quietly + # dropped a map would read as "every area is covered" when it is not + for key in missing: + print("MISSING no shot for %s" % key) + os.makedirs(dst, exist_ok=True) + + probe = Image.open(os.path.join(src, names[0])) + tile_h = round(TILE_W * probe.height / probe.width) + f = font(24) + sheet_w = COLS * TILE_W + (COLS + 1) * PAD + sheet_h = ROWS * (tile_h + LABEL_H) + (ROWS + 1) * PAD + + made = [] + for page, start in enumerate(range(0, len(names), PER_SHEET), 1): + batch = names[start:start + PER_SHEET] + sheet = Image.new("RGB", (sheet_w, sheet_h), BG) + for i, name in enumerate(batch): + cell = tile(os.path.join(src, name), TILE_W, tile_h, + os.path.splitext(name)[0].upper(), f) + col, row = i % COLS, i // COLS + x = PAD + col * (TILE_W + PAD) + y = PAD + row * (tile_h + LABEL_H + PAD) + sheet.paste(cell, (x, y)) + out = os.path.join(dst, "sheet_%02d.png" % page) + sheet.save(out, optimize=True) + made.append((out, [os.path.splitext(n)[0] for n in batch])) + print("%s %s" % (out, ", ".join(m for _, b in [made[-1]] for m in b))) + + print("\n%d shots -> %d sheets" % (len(names), len(made))) + + +if __name__ == "__main__": + if len(sys.argv) not in (3, 4): + sys.exit(__doc__) + main(sys.argv[1], sys.argv[2], + sys.argv[3] if len(sys.argv) == 4 else None) From 5781c28b609b9e8227a711b5249f335859f0c597 Mon Sep 17 00:00:00 2001 From: DramaticShape Date: Tue, 28 Jul 2026 13:37:19 -0400 Subject: [PATCH 2/2] added FULL menu option for voxel mod --- CHANGELOG.md | 38 +++++++++ data/battle_arenas.lua | 6 +- lib/BattleArena.lua | 9 +- lib/BattleScene.lua | 8 ++ lib/VoxelGrid.lua | 12 +++ lib/VoxelState.lua | 53 +++++++++++- main.lua | 151 ++++++++++++++++++++++++++++++++-- manifest.json | 6 +- tests/arena_pick.lua | 70 ++++++++++++++-- tests/dramatic_shape_test.lua | 151 ++++++++++++++++++++++++++++++++-- 10 files changed, 478 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 495b14f..a814303 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,43 @@ # Changelog +## 1.2.0 + +### Added + +- **A `FULL` rung on the VOXEL row**, directly after `OFF`. One choice that + puts the whole mode in its intended state -- the 50-degree camera, the + miniature blur at maximum, the horizon flat, the view fitted, and battles + on the map -- rather than making a player assemble it from four rows. + + While it is selected, every row it owns comes OFF the menu: V-GRID, + V-CURVE, 3D-BTL and T-SHIFT. A row that no longer decides anything is + worse than no row. Stepping onto or off `FULL` rebuilds the open menu in + place, so the rows leave and return under the cursor instead of waiting + for the menu to be reopened. + + It applies its settings when the row ARRIVES at `FULL`, not every frame: + holding them would make the zoom keys and the wheel dead while it was on. + Leaving it deliberately undoes nothing -- reverting would discard whatever + had been changed since. + +### Changed + +- **Hotkey `3` walks the angle rungs only and steps over `FULL`.** The key is + a display-mode cycler -- it should change the camera and nothing else -- + and `FULL` reaches in and rewrites four other settings. Landing on it + mid-walk would silently push the blur to maximum and flatten the horizon + with nothing on screen saying a keypress had done it. `FULL` stays on the + OPTIONS row, where a preset that changes other rows belongs. + + A press FROM `FULL` goes to `75`. `FULL` is already the 50-degree camera, + so stepping to the rung of that name would look like the key had done + nothing. + +- **The mode's four options are one block in the menu.** The engine splices + a pipeline row in beside TILT and lands a mod's own rows at the end of the + list, which had these four in two places with unrelated engine rows + between them. The settings now follow the pipeline rows directly. + ## 1.1.0 ### Added diff --git a/data/battle_arenas.lua b/data/battle_arenas.lua index f9b1a26..df2d343 100644 --- a/data/battle_arenas.lua +++ b/data/battle_arenas.lua @@ -139,8 +139,10 @@ return { -- ------- the remaining routes ["ROUTE_19"] = { x = 8, y = 6, shape = "narrow" }, - ["ROUTE_20"] = { x = 53, y = 3, shape = "wide" }, - ["ROUTE_21"] = { x = 16, y = 4, shape = "wide" }, + -- the two surf routes fight AFLOAT, in the middle of their own sea rather + -- than on the rim of beach the land search would otherwise find + ["ROUTE_20"] = { x = 23, y = 7, shape = "wide" }, + ["ROUTE_21"] = { x = 8, y = 46, shape = "wide" }, ["ROUTE_22"] = { x = 35, y = 7, shape = "wide" }, ["ROUTE_23"] = { x = 4, y = 36, shape = "wide" }, ["ROUTE_24"] = { x = 13, y = 15, shape = "wide" }, diff --git a/lib/BattleArena.lua b/lib/BattleArena.lua index e215b8c..20f0e04 100644 --- a/lib/BattleArena.lua +++ b/lib/BattleArena.lua @@ -291,7 +291,14 @@ function BattleArena.find(map, fromX, fromY, surfing) host = (ok and other) or nil end if shape and host then - local grid, gw = openGrid(host, surfing) + -- An authored spot is checked with WATER COUNTING AS GROUND, whatever + -- the player is doing. The surfing test exists to stop the automatic + -- search staging a walker's fight out at sea; an authored entry was + -- chosen and looked at by a person, so if it is on water that is the + -- point of it -- the surf routes fight in the middle of their own + -- ocean rather than on a scrap of beach at the edge of the map. Land + -- entries are unaffected: land passes the test either way. + local grid, gw = openGrid(host, true) if fits(grid, gw, pick.x, pick.y, shape.w, shape.h) then local arena = place(shape, pick.x, pick.y) arena.map = host diff --git a/lib/BattleScene.lua b/lib/BattleScene.lua index 8d80615..7cb945f 100644 --- a/lib/BattleScene.lua +++ b/lib/BattleScene.lua @@ -40,6 +40,7 @@ local TerrainAtlas = V.require("TerrainAtlas") local VoxelScene = V.require("VoxelScene") local BattleCam = V.require("BattleCam") local BattleBillboard = V.require("BattleBillboard") +local VoxelGrid = V.require("VoxelGrid") local PaletteFX = require("src.render.PaletteFX") local BattleScene = {} @@ -333,6 +334,12 @@ function BattleScene.render(state, arena, textures, token) -- free-roam world it shares this module with keeps its own weight local sunWas = Voxel3D.SHADOW_ALPHA Voxel3D.SHADOW_ALPHA = BattleScene.SHADOW_ALPHA + -- and the wireframe is ON for a battle whatever the V-GRID row says. The + -- arena is a staged shot rather than the world being walked through, and + -- the seams are what make it read as built rather than photographed. Forced + -- through the override so the player's own row is never written to. + local gridWas = VoxelGrid.override + VoxelGrid.override = true local out = nil local ok, err = pcall(function() -- its own canvas slot: this renders at the window's pixel size and the @@ -408,6 +415,7 @@ function BattleScene.render(state, arena, textures, token) -- renders (the free-roam pipeline, next frame) must find the orbit back Voxel3D.camera = nil Voxel3D.SHADOW_ALPHA = sunWas + VoxelGrid.override = gridWas if not ok then -- endScene never ran, so the canvas is still bound and the shader still -- set; put the frame back the way it was found before rethrowing diff --git a/lib/VoxelGrid.lua b/lib/VoxelGrid.lua index 1f68bab..1fb7ce7 100644 --- a/lib/VoxelGrid.lua +++ b/lib/VoxelGrid.lua @@ -48,7 +48,19 @@ VoxelGrid.WIDTH = 1.0 VoxelGrid.setting = ModSetting.new(VoxelGrid.KEY, VoxelGrid.LABEL, { false, true }, { "OFF", "ON" }) +-- A pass that needs the wireframe whatever the player left the row on sets +-- this for the length of its own draw and puts it back after. nil means +-- "follow the setting", which is every frame outside such a pass. +-- +-- The overworld battle is the one user: a fight is a STAGED shot, not the +-- world being walked around in, and the seams are what make it read as +-- constructed rather than as a photograph of somewhere. The row still owns +-- what free-roam looks like, and is not written to -- switching the mode off +-- mid-battle would silently rewrite the player's own setting. +VoxelGrid.override = nil + function VoxelGrid.enabled() + if VoxelGrid.override ~= nil then return VoxelGrid.override end return VoxelGrid.setting:get() and true or false end diff --git a/lib/VoxelState.lua b/lib/VoxelState.lua index 38a920b..ca85fac 100644 --- a/lib/VoxelState.lua +++ b/lib/VoxelState.lua @@ -23,10 +23,59 @@ local Voxel = {} -Voxel.ANGLES_DEG = { 0, 15, 35, 50, 75 } -Voxel.ANGLE_LABELS = { "OFF", "15", "35", "50", "75" } +-- FULL is a PRESET, not another angle: one rung that puts the whole mode in +-- its intended state at once -- this camera, the miniature blur at full, the +-- horizon flat, the view fitted -- so a player who wants "the diorama" picks +-- it rather than assembling it from four rows. It sits directly after OFF +-- because that is the order those two get used in. +-- +-- Its ANGLE is 50 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, 50, 15, 35, 50, 75 } +Voxel.ANGLE_LABELS = { "OFF", "FULL", "15", "35", "50", "75" } Voxel.MAX_LEVEL = #Voxel.ANGLES_DEG - 1 +-- the rung FULL sits on, so nothing has to hunt for it by label +Voxel.FULL_LEVEL = 1 + +function Voxel.isFull(level) + return (level or Voxel.level) == Voxel.FULL_LEVEL +end + +-- ------- what the hotkey walks +-- +-- The ANGLE rungs only, with FULL left out. The key is a display-mode +-- cycler: pressing it should change the camera and nothing else, and FULL +-- reaches in and rewrites four other settings. Landing on it by accident, +-- 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 + +-- The rung a press moves to from `level`. +-- +-- A level that is not on the key's path -- FULL, reached from the menu -- +-- steps on from whichever rung shows the SAME camera it does. FULL is 50 +-- degrees, so a press from it goes to 75 rather than back to 50, and the key +-- never appears to do nothing. +function Voxel.nextHotkeyLevel(level) + level = level or Voxel.level + local order = Voxel.HOTKEY_ORDER + local at = nil + for i, rung in ipairs(order) do + if rung == level then at = i break end + end + if not at then + local deg = Voxel.ANGLES_DEG[level + 1] + for i, rung in ipairs(order) do + if Voxel.ANGLES_DEG[rung + 1] == deg then at = i break end + end + end + if not at then return order[1] end + return order[at % #order + 1] +end + Voxel.level = 0 Voxel.angle = 0 Voxel.from = 0 diff --git a/main.lua b/main.lua index b77e555..d07292b 100644 --- a/main.lua +++ b/main.lua @@ -79,6 +79,12 @@ local VoxelGrid = V.require("VoxelGrid") local WorldCurve = V.require("WorldCurve") local OverworldBattle = V.require("OverworldBattle") +-- Forward declaration: the voxel pipeline's update hook (registered below) +-- calls this, and it is defined further down with the settings it drives. +-- Declared rather than left global -- a mod writing to _G would leak into +-- every other mod's namespace. +local applyFull + -- The last VOID FILL the terrain was meshed under; see the update hook. -- The scene canvas's size, in FRAMEBUFFER PIXELS. -- @@ -145,6 +151,11 @@ 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) + -- 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) Voxel.update(dt, level) -- The overworld battle rides this hook rather than owning a pipeline of -- its own, because it owns no pass of the FRAME: it draws under a battle @@ -233,6 +244,46 @@ mod.content.render_pipelines:register("tiltshift", { -- instead -- see ModSetting for where they persist and how the two rows -- each ends up on stay in step. +-- ------- the FULL preset +-- +-- Everything the mode wants switched to at once. Applied when the VOXEL row +-- ARRIVES at FULL and not again, so the player can still move the camera or +-- the zoom afterwards -- it is a starting point, not a lock. +-- +-- Leaving FULL deliberately does NOT undo any of it. A preset that reverted +-- would throw away whatever the player had changed since, and "put it back +-- how it was" is not a thing this can know. +local fullWas = nil + +applyFull = function(level) + local isFull = Voxel.isFull(level) + local was = fullWas + fullWas = isFull + if not isFull or was == true or was == nil then return end + + local Game = require("src.core.Game") + local Pipelines = require("src.render.Pipelines") + local Zoom = require("src.render.Zoom") + local opts = Game.save and Game.save.options + if not opts then return end + + -- the miniature blur at its strongest: FULL is the diorama look, and the + -- tilt-shift is most of what makes it read as a model + Pipelines.setLevel("tiltshift", Pipelines.maxLevel("tiltshift")) + Pipelines.syncOptions(opts) + -- 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 view fitted to the window + opts.zoom = 0 + Zoom.applyOptions(opts) + -- battles on the map too: FULL means the whole mode, and a fight is where + -- half of it is spent. Set rather than forced -- the row is gone from the + -- menu while FULL is on, but a save that already had it off gets it on. + OverworldBattle.setting:setIndex(1, Game) + if Game.writeOptions then pcall(Game.writeOptions, Game) end +end + local SETTINGS = { { VoxelGrid.setting, "One-pixel wireframe along every voxel edge." }, { WorldCurve.setting, @@ -250,7 +301,7 @@ mod.options:define(schema) -- ------- this mod's hotkeys -- --- 3 VOXEL cycle the camera ladder (was 6) +-- 3 VOXEL cycle the camera ladder (was 6; skips FULL) -- 5 V-GRID toggle the wireframe (new) -- 6 T-SHIFT cycle the blur ladder (was 9) -- 7 V-CURVE cycle the horizon bend (new) @@ -299,7 +350,20 @@ do -- render mode. Only free-roam presses are ours to take. if claim and not (top and top.onKeyPressed) then if claim == "pipeline" then - if Pipelines.hotkey(key, top, self.overworld) then + -- 3 walks the ANGLE rungs and steps over FULL (Voxel.HOTKEY_ORDER), + -- so the registry's plain "advance one and wrap" is not what it + -- wants; 6 still is. The gate is the registry's own either way. + local stepped = false + if key == "3" then + if Pipelines.canToggle("voxel", top, self.overworld) then + Pipelines.setLevel("voxel", + Voxel.nextHotkeyLevel(Pipelines.level("voxel"))) + stepped = true + end + else + stepped = Pipelines.hotkey(key, top, self.overworld) and true + end + if stepped then Pipelines.syncOptions(self.save.options) -- 3 is the key that used to turn TILT on and sits next to the one -- that used to turn GBC FX on, and this mod has taken both away. @@ -336,15 +400,54 @@ do end end +-- ------- the mode's rows, kept together +-- +-- The engine splices a pipeline's row in beside TILT, because a display mode +-- belongs with the other display modes; a mod's own ui.options.rows +-- additions land at the END of the list. That left this mod's four rows in +-- two places with unrelated engine rows between them, which reads as two +-- unrelated features rather than one mode with settings. +-- +-- So the plain settings are inserted directly after the last of this mod's +-- PIPELINE rows instead of appended. Nothing else moves: the block lands +-- where the engine already decided display modes go. +local function insertGrouped(out, extra) + local anchor = nil + for i, row in ipairs(out) do + local id = type(row) == "table" and row.id + if id == "pipeline:voxel" or id == "pipeline:tiltshift" then anchor = i end + end + if not anchor then + for _, row in ipairs(extra) do out[#out + 1] = row end + return out + end + for i, row in ipairs(extra) do table.insert(out, anchor + i, row) end + return out +end + +-- FULL owns every one of those settings, so while it is selected they are +-- taken off the menu rather than left to be changed under it -- including +-- T-SHIFT, which is a pipeline row the engine put there. A row that no +-- longer decides anything is worse than no row. +local function dropRow(out, id) + for i = #out, 1, -1 do + if type(out[i]) == "table" and out[i].id == id then table.remove(out, i) end + end + return out +end + -- call next() first and decorate what comes back, so every other mod's -- rows survive this one mod.hooks:wrap("ui.options.rows", function(next, game, rows) local out = next(game, rows) if type(out) ~= "table" then return out end - for _, entry in ipairs(SETTINGS) do - out[#out + 1] = entry[1]:row() + local Pipelines = require("src.render.Pipelines") + if Voxel.isFull(Pipelines.level("voxel")) then + return dropRow(out, "pipeline:tiltshift") end - return out + local extra = {} + for _, entry in ipairs(SETTINGS) do extra[#extra + 1] = entry[1]:row() end + return insertGrouped(out, extra) end) -- The mod manager writes and persists on its own, so the only thing left @@ -436,6 +539,42 @@ mod.events:on("map.reloaded", function(payload) if mapId then ChunkMesher.invalidate(mapId) end end) +-- ------- FULL takes rows off the menu, so the menu has to notice +-- +-- OptionsMenu builds its row list ONCE, when it is opened, and then reads +-- that list every frame. So stepping the VOXEL row onto or off FULL changed +-- which rows the hook would return but not which rows were on screen -- the +-- settings FULL owns stayed visible until the menu was closed and reopened, +-- and a player who stepped off FULL could not see the rows come back. +-- +-- Rebuilt in place, and only on a step that crosses FULL: 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 VOXEL row it was just used on instead of +-- jumping to the top when the list below it shortens. +do + local OptionsMenu = require("src.ui.OptionsMenu") + if not OptionsMenu.dramaticShapeFullHook then + local Pipelines = require("src.render.Pipelines") + local inner = OptionsMenu.update + + function OptionsMenu:update(dt) + local before = Pipelines.level("voxel") + inner(self, dt) + local after = Pipelines.level("voxel") + if after ~= before + and (Voxel.isFull(before) or Voxel.isFull(after)) then + local rebuilt = OptionsMenu.new(self.game) + self.rows = rebuilt.rows + local cancel = #self.rows + 1 + if (self.index or 1) > cancel then self.index = cancel end + end + end + + OptionsMenu.dramaticShapeFullHook = true + end +end + -- ------- battles on the map -- -- The wraps this needs -- OverworldState:pushBattle, BattleState:draw and @@ -481,7 +620,7 @@ mod.events:on("battle.ended", function() OverworldBattle.finish() end) -mod.exports.version = "1.1.0" +mod.exports.version = "1.2.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 3ac3b2e..e53591d 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "DRAMATIC_SHAPE", "name": "Dramatic Shape Voxel Mod", - "version": "1.1.0", + "version": "1.2.0", "api": 2, "entry": "main.lua", "profile": "content", @@ -11,7 +11,9 @@ "dependencies": [], "optional_dependencies": [], "conflicts": [], - "permissions": ["engine_internals"], + "permissions": [ + "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." } diff --git a/tests/arena_pick.lua b/tests/arena_pick.lua index d8af2d8..06bd92a 100644 --- a/tests/arena_pick.lua +++ b/tests/arena_pick.lua @@ -16,9 +16,11 @@ -- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/arena_pick.lua love . -- -- ARENA_FROM / ARENA_COUNT slice the map list so several runs can share the --- work; ARENA_MAPS=ID,ID,... does an explicit set instead. SHOT_DIR must --- already exist -- the capture writes with io.open, which does not create --- directories. +-- work; ARENA_MAPS=ID,ID,... does an explicit set instead. ARENA_COUNT=0 +-- lists the maps and stops. ARENA_SURF=1 stages the fight out on the water, +-- centred in the map's biggest body of it, which is what the surf routes +-- want. SHOT_DIR must already exist -- the capture writes with io.open, +-- which does not create directories. return function(game) local U = dofile("tests/drivers/util.lua") local DIR = os.getenv("SHOT_DIR") or ".scratchpad/arenas" @@ -37,6 +39,51 @@ return function(game) local Arena = lib.require("BattleArena") local Battles = lib.require("OverworldBattle") + -- ------- staging out on the water + -- + -- A surf route's land is a rim of beach round the edge of the map, so the + -- ordinary search always picks the rim and the fight happens on sand at + -- the corner of a sea. ARENA_SURF=1 says stage this map afloat: water + -- counts as ground, and the search starts from the middle of the map's + -- BIGGEST body of water rather than the middle of the map, so the arena + -- lands out in the open sea instead of against the first shoreline it + -- finds. + -- + -- Biggest body, not all water at once: a map with a lake and an ocean has + -- a centroid between them that is on neither, and the arena would be + -- pinned to whichever shore that landed nearest. + local function waterCentre(map) + local w, h = map.widthCells, map.heightCells + local seen, best = {}, nil + for y0 = 0, h - 1 do + for x0 = 0, w - 1 do + if not seen[y0 * w + x0] and map:isWaterCell(x0, y0) then + -- one connected body, flooded from this cell + local stack, n, sx, sy = { { x0, y0 } }, 0, 0, 0 + seen[y0 * w + x0] = true + while #stack > 0 do + local cell = table.remove(stack) + local cx, cy = cell[1], cell[2] + n, sx, sy = n + 1, sx + cx, sy + cy + for _, d in ipairs({ { 1, 0 }, { -1, 0 }, { 0, 1 }, { 0, -1 } }) do + local nx, ny = cx + d[1], cy + d[2] + local key = ny * w + nx + if nx >= 0 and ny >= 0 and nx < w and ny < h and not seen[key] + and map:isWaterCell(nx, ny) then + seen[key] = true + stack[#stack + 1] = { nx, ny } + end + end + end + if not best or n > best.n then + best = { n = n, x = sx / n, y = sy / n } + end + end + end + end + return best + end + -- Which maps a battle can actually happen on: anything with a wild -- encounter table or an object that fights. Everything else -- a shop -- floor, a stairwell, a bedroom -- would be authoring a spot for a fight @@ -134,9 +181,22 @@ return function(game) clear = any and Arena.clearance(any.map or map, any) or false end end + local surf = os.getenv("ARENA_SURF") + surf = surf ~= nil and surf ~= "" and surf ~= "0" if not any then - clear = Arena.search(map, cx, cy, false, true) - any = clear or Arena.search(map, cx, cy, false) + local ox, oy = cx, cy + if surf then + local sea = waterCentre(map) + if sea then + ox, oy = sea.x, sea.y + U.log(("SEA %s: biggest body is %d cells, centre %.1f,%.1f") + :format(id, sea.n, sea.x, sea.y)) + else + U.log("SEA " .. id .. ": no water on this map") + end + end + clear = Arena.search(map, ox, oy, surf, true) + any = clear or Arena.search(map, ox, oy, surf) end if not any then U.log(("NONE %s -- no arena of either shape"):format(id)) diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua index 006a23e..a685495 100644 --- a/tests/dramatic_shape_test.lua +++ b/tests/dramatic_shape_test.lua @@ -49,11 +49,13 @@ T.eq(defs._owners and defs._owners.voxel, "DRAMATIC_SHAPE", -- ------- the ladders the engine drives -T.eq(#defs.voxel.levels, 5, "voxel exposes a five-rung ladder") +T.eq(#defs.voxel.levels, 6, "voxel exposes a six-rung ladder") T.eq(defs.voxel.levels[1], "OFF", "rung 0 is OFF") -T.eq(defs.voxel.levels[5], "75", "the top rung is the 75-degree camera") -T.eq(Pipelines.maxLevel("voxel"), 4, "the engine reads the ladder height") -T.eq(Pipelines.levelLabel("voxel", 2), "35", "the engine reads the rung labels") +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(Pipelines.levelLabel("voxel", 3), "35", "the engine reads the rung labels") -- ------- gating: inert until switched on, and inert without a GPU @@ -101,7 +103,7 @@ local byLabel = {} for _, row in ipairs(rows) do byLabel[row.label] = row end T.check(byLabel.VOXEL ~= nil, "the VOXEL row is offered") T.check(byLabel["T-SHIFT"] ~= nil, "the T-SHIFT row is offered") -T.eq(byLabel.VOXEL.value(), "15", "the row renders the current rung's label") +T.eq(byLabel.VOXEL.value(), "FULL", "the row renders the current rung's label") -- ------- this mod's own settings -- @@ -112,6 +114,95 @@ T.eq(byLabel.VOXEL.value(), "15", "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") + +-- ------- FULL is a preset that owns the other rows +-- +-- While it is selected the settings it drives come OFF the menu -- including +-- T-SHIFT, which is a pipeline row the engine spliced in. A row that no +-- longer decides anything is worse than no row. +Pipelines.setLevel("voxel", VoxelState.FULL_LEVEL) +local fullRows = Runtime.call("ui.options.rows", function(_, r) return r end, + { data = Data }, + { { id = "tilt" }, { id = "pipeline:voxel" }, + { id = "pipeline:tiltshift" } }) +local fullIds = {} +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:battles"], "and 3D-BTL") + +-- ------- and off FULL, the rows come back, grouped with the mode +-- +-- The engine splices a pipeline row in beside TILT and lands a mod's own +-- additions at the END of the list, which would leave this mode's four rows +-- in two places with unrelated rows between them. +Pipelines.setLevel("voxel", 2) +local grouped = Runtime.call("ui.options.rows", function(_, r) return r end, + { data = Data }, + { { id = "tilt" }, { id = "pipeline:voxel" }, + { id = "pipeline:tiltshift" }, + { 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"], + "the mode's settings follow its pipeline rows") +T.eq(order["DRAMATIC_SHAPE:battles"] - order["pipeline:tiltshift"], 3, + "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") + +-- ------- the open menu notices when FULL is stepped onto or off +-- +-- OptionsMenu reads its row list every frame but builds it once, so without +-- a rebuild the rows FULL owns stay on screen until the menu is reopened -- +-- and stepping OFF FULL never brings them back. +local OptionsMenu = require("src.ui.OptionsMenu") +local pressed = {} +local menuGame = { + data = Data, + save = { options = { pipelines = {}, modOptions = {} } }, + mods = { modOptions = {} }, + input = { wasPressed = function(_, k) return pressed[k] or false end }, + stack = { pop = function() end }, + writeOptions = function() end, +} + +Pipelines.setLevel("voxel", 2) +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"), + "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 +menu.index = rowIndex(menu, "pipeline:voxel") +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"), + "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") + +-- and back off it again +menu.index = rowIndex(menu, "pipeline:voxel") +pressed = { right = true } +menu:update(0) +pressed = {} +T.eq(Pipelines.level("voxel"), 2, "the step left FULL") +T.check(rowIndex(menu, "DRAMATIC_SHAPE:grid"), + "and the rows came straight back without reopening the menu") +T.check(rowIndex(menu, "pipeline:tiltshift"), "T-SHIFT too") + +-- level 2 is the "15" rung: any rung that is not FULL, so the settings the +-- preset owns are back on the menu +Pipelines.setLevel("voxel", 2) local hookedRows = Runtime.call("ui.options.rows", function(_, r) return r end, { data = Data }, { { id = "text_speed" } }) T.eq(#hookedRows, 4, "the options hook added a row per setting") @@ -734,10 +825,33 @@ keyGame = { local VoxelGrid = run.loader.exports.DRAMATIC_SHAPE.lib.require("VoxelGrid") local Curve = run.loader.exports.DRAMATIC_SHAPE.lib.require("WorldCurve") +-- ------- 3 walks the ANGLE rungs and steps over FULL +-- +-- The key is a display-mode cycler: it should change the camera and nothing +-- else. FULL reaches in and rewrites four other settings, so landing on it +-- mid-walk would silently turn the blur to maximum and flatten the horizon +-- with nothing on screen saying a keypress had done it. +Pipelines.setLevel("voxel", 0) +local walk = {} +for _ = 1, 6 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") + +-- FULL is 50 degrees, so a press from it goes ON to 75 rather than back to +-- the rung that shows the same camera -- the key never appears to do nothing +Pipelines.setLevel("voxel", VoxelState.FULL_LEVEL) Game.keypressed(keyGame, "3") -T.eq(Pipelines.level("voxel"), 1, "3 cycles the voxel camera ladder") +T.eq(Pipelines.levelLabel("voxel"), "75", + "a press from FULL goes to 75, since FULL already IS the 50 camera") + +Pipelines.setLevel("voxel", 0) Game.keypressed(keyGame, "3") -T.eq(Pipelines.level("voxel"), 2, "and keeps climbing it") +T.eq(Pipelines.level("voxel"), 2, "3 cycles the voxel camera ladder") +Game.keypressed(keyGame, "3") +T.eq(Pipelines.level("voxel"), 3, "and keeps climbing it") Game.keypressed(keyGame, "6") T.eq(Pipelines.level("tiltshift"), 1, "6 cycles the tilt-shift blur") @@ -780,7 +894,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. -Pipelines.setLevel("voxel", Pipelines.maxLevel("voxel")) +-- 5 is the "75" rung, the last one the key walks before it wraps to OFF +Pipelines.setLevel("voxel", 5) Tilt.setLevel(3) GBCFX.setLevel(4) keyGame.save.options.tilt = 3 @@ -1256,6 +1371,26 @@ T.check(math.abs(sx - px) < 0.2, -- arena floor out from under the two mons pinned to it T.eq(rig.curve, 0, "the battle camera switches the world curve off") +-- ------- the wireframe is forced on in a battle +-- +-- A fight is a staged shot rather than the world being walked through, so it +-- 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") +Grid.override = nil +local rowWas = Grid.setting:get() +T.eq(Grid.enabled(), rowWas and true or false, + "with no override the wireframe follows the row") +Grid.override = true +T.eq(Grid.enabled(), true, "an override forces it on") +T.eq(Grid.setting:get(), rowWas, "and leaves the player's row alone") +Grid.override = false +T.eq(Grid.enabled(), false, "an override can force it off too") +Grid.override = nil +T.eq(Grid.enabled(), rowWas and true or false, + "and clearing it hands the answer back to the row") + -- ------- the depth of field is measured off the two marks -- -- The slab held sharp is the one the mons are standing in, so the band has