From 95771403d9ffe44ea698462b052927f0ee151be0 Mon Sep 17 00:00:00 2001 From: DramaticShape Date: Mon, 3 Aug 2026 17:35:58 -0400 Subject: [PATCH] patch holes on flower/grass models --- CHANGELOG.md | 83 +++++++++++++++++ README.md | 5 ++ lib/Structures.lua | 117 +++++++++++++++--------- main.lua | 25 ++++++ tests/cam_shots.lua | 211 ++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 401 insertions(+), 40 deletions(-) create mode 100644 tests/cam_shots.lua diff --git a/CHANGELOG.md b/CHANGELOG.md index 22d4f77..ec33298 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,89 @@ well-known way to make people ill. The rung still changes the walk and the sprites the same way. +- **Every camera zooms, on whatever the machine has.** The mouse wheel, + `Q`/`E`, a two-finger pinch and the pad's two stick clicks all reach + whichever camera is actually in front of you -- the third-person boom, + the staged battle's lens, or the engine's own survey zoom on an orbit + rung. One module (`lib/CamControl.lua`) answers "which camera is this + aimed at" so the four cameras never race each other for an event, and + forwards everything it does not claim. 1ST claims nothing: the eye is in + the player's head, and a pinch there would only wind the survey zoom for + whenever they stepped back out. + +- **The battle camera is yours to steer.** The right stick, a drag across + the screen or the mouse walks the staged shot around the arena and raises + the seat; the wheel, `Q`/`E`, a pinch or a stick click work the lens. + + Both axes stop where the composition does. LEFT stops at the shot the rig + was solved for, because there is nothing to the left of it. RIGHT ends + SIDE-ON -- the eye square to the arena's axis, both Pokemon at the same + distance instead of one behind the other -- computed from each rig's own + stance rather than written down. DOWN stops at the rig's low stance and + UP is 45 degrees above it, raised about the focus at a constant radius so + climbing never doubles as zooming. Input accumulates into a goal the eye + eases after, so a flick reads as the camera being pushed rather than + dragged. + + The lens **opens by exactly the amount the pair spreads**: the solved + shot looks along the arena's axis at a shallow angle, which foreshortens + the gap between the two mons to less than half its length, and swinging + round or climbing un-foreshortens it. Left alone that threw both Pokemon + off the edges of the frame at the far end of either range, which made the + whole far end unusable. + + Where you leave the camera is where the next battle opens. An angle and a + lens you chose are how you want to watch battles, not a fact about one + encounter. + +- **Move animations track the camera.** They already slid to follow the + pair's midpoint; now they follow its SEPARATION too. Both mons are + geometry standing on the map, so the camera sizes them -- and an effects + layer that kept the authored 106-pixel spacing through a zoom and a + 60-degree swing fired its beams into the air beside the Pokemon they were + aimed at. + +- **BACK SPRITES locks the battle camera.** That setting pins your own mon + to the GB's slot on the menu while the foe stands out on the map, and no + angle holds a composition that is half frame and half world. The steer, + the climb and the lens all stand down -- in the rig as well as at the + inputs, so an angle stored from before the row was switched on cannot + leave it steered anyway. The slow drift stays: it was always there under + BACK SPRITES and two degrees is not a composition problem. + +- **BATTLE BG is pinned to WHITE and its row comes off the menu.** The row + picks what fills the screen AROUND the battle, and this mode fills the + window with the map the fight is standing on -- there are no voids left + for it to be about. WORLD was actively wrong under it: it makes the + battle non-opaque so the engine draws a second, dimmed copy of the + overworld beneath the arena pass's own. Pinned rather than merely hidden, + so a save written before the mod was installed cannot carry a value the + menu can no longer reach. Uninstall and the row is back. + +### Fixed + +- **Grass and flowers are closed off at the sides.** Both stand as + per-pixel slabs built from runs of lit pixels, and only the front, the + back and a lid were ever emitted -- so from any angle off square you + looked straight in through the open end of every run and out the far + side. At the low cameras this release adds, that is most of the time. + Each run now wears end walls in the colour of the pixel they close off. + + Flowers needed more than that, because a flower SWAYS: the mesh spans the + union of every animation frame and each frame is cut back out in texture + space, so a pixel that drops out of a frame takes the union's wall with + it and leaves an interior boundary that never had one. The first cut of + this looked solid on the base frame and still had gaps on every other. + Every pixel of a flower now carries a cap on all four of its remaining + faces: enclosed and invisible while its neighbour is there, and already + in place the moment the animation takes that neighbour away. + +- **No more machine-gun bonking in 1ST and 3RD.** The grid walk's collision + sound marks a discrete event -- a direction pressed, a step refused. A + free walk has no such moment: the body slides along every wall it grazes, + continuously, so a corridor taken at a slight angle rang the bonk twice a + second from end to end. The wall stopping you is the feedback. + ## 1.5.2 ### Added diff --git a/README.md b/README.md index 2b79a9f..0636994 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,11 @@ through the engine's own machinery. | right stick | look | | a touch drag off the overlay's controls | look | | left stick / touch d-pad / arrow keys | walk, relative to where the camera looks | +| wheel, `Q` / `E`, pinch, or a stick click | **3RD only** — let the boom out and pull it in (`Q` and left stick click out, `E` and right stick click in) | + +On an **orbit rung** the same wheel, `Q`/`E` and pinch drive the engine's own +survey zoom. On **1ST** they do nothing at all: the eye is in your head, and +there is no distance to change. On **3RD** the boom shortens against whatever is behind you, so backing into a wall walks the camera in to your shoulders rather than through it — squeeze diff --git a/lib/Structures.lua b/lib/Structures.lua index be4aac2..2c99c8d 100644 --- a/lib/Structures.lua +++ b/lib/Structures.lua @@ -3314,29 +3314,56 @@ end -- carries the animation: when a frame keys that pixel out, the wall's own -- fragments discard with the faces either side of it, so a swaying tuft -- never leaves a wall standing where its blade no longer is. +-- `everyPixel` is for a standee whose silhouette ANIMATES. The mesh is +-- built once, over the UNION of every frame's mask, and each frame is cut +-- out again in texture space -- so a run that is six pixels wide in the +-- union may be two pixels wide in the frame on screen, and the four pixels +-- that dropped out took the union's end walls with them. What is left +-- exposed is an interior boundary, which had no wall because in the union +-- it was not a boundary at all. That is the gap that survived closing the +-- run ends: the first frame looked solid and every other frame did not. +-- +-- So an animated standee gets a wall on BOTH sides of EVERY pixel. A wall +-- between two lit pixels is enclosed by the front and back faces and never +-- seen; the moment its neighbour is keyed out it becomes the edge, already +-- in place and already wearing the right colour. Each is inset a hair into +-- its own pixel so the two that meet at a boundary are not coplanar -- the +-- voxel pass draws with culling off, and two quads in the same plane would +-- z-fight rather than politely take turns. +local SIDE_INSET = 0.03 + local function sideQuads(quads, ix, ix2, yBot, yTop, zB, zF, - ax0, ay0, atlasW, atlasH, py, lit) + ax0, ay0, atlasW, atlasH, py, lit, everyPixel) local function texel(px) return (ax0 + px + 0.5) / atlasW, (ay0 + py + 0.5) / atlasH end - if not lit(ix - 1, py) then - local u, v = texel(ix) - quads[#quads + 1] = { -- the run's left wall, facing -X - { ix, yBot, zB }, { ix, yBot, zF }, - { ix, yTop, zF }, { ix, yTop, zB }, + local function left(px, at) + local u, v = texel(px) + quads[#quads + 1] = { -- facing -X + { at, yBot, zB }, { at, yBot, zF }, + { at, yTop, zF }, { at, yTop, zB }, uv = { { u, v }, { u, v }, { u, v }, { u, v } }, shade = OBJ_SHADE.side, } end - if not lit(ix2 + 1, py) then - local u, v = texel(ix2) - quads[#quads + 1] = { -- and its right wall, facing +X - { ix2 + 1, yBot, zF }, { ix2 + 1, yBot, zB }, - { ix2 + 1, yTop, zB }, { ix2 + 1, yTop, zF }, + local function right(px, at) + local u, v = texel(px) + quads[#quads + 1] = { -- facing +X + { at, yBot, zF }, { at, yBot, zB }, + { at, yTop, zB }, { at, yTop, zF }, uv = { { u, v }, { u, v }, { u, v }, { u, v } }, shade = OBJ_SHADE.side, } end + if everyPixel then + for px = ix, ix2 do + left(px, px + SIDE_INSET) + right(px, px + 1 - SIDE_INSET) + end + return + end + if not lit(ix - 1, py) then left(ix, ix) end + if not lit(ix2 + 1, py) then right(ix2, ix2 + 1) end end -- A tall-grass CELL is four tufts: 2x2 tiles, and each 8x8 tile is one @@ -3590,41 +3617,51 @@ local function flowerTemplate(map, data, tileId) uv = { { u1, v1 }, { u0, v1 }, { u0, v0 }, { u1, v0 } }, shade = OBJ_SHADE.back, } - -- petal tips: a top strip where the row above is clear. The - -- strip samples its own row's texel, so a tip that is not in - -- the current frame discards with the face beneath it - if not on(ix, py - 1) then - quads[#quads + 1] = { - { ix, yTop, zB }, { ix2 + 1, yTop, zB }, - { ix2 + 1, yTop, zF }, { ix, yTop, zF }, - uv = { { u0, v0 }, { u1, v0 }, { u1, v0 }, { u0, v0 } }, + -- ------- the shell, closed on all four remaining faces + -- + -- A flower SWAYS: the geometry spans the union of every animation + -- frame's mask and each frame is cut back out of it in texture + -- space (see the header). So "is there a pixel next door" has two + -- different answers -- one in the union this mesh was built from, + -- and one in the frame actually on screen -- and only the second + -- decides what is exposed. + -- + -- Closing the union's own edges is therefore not enough, and was + -- the bug the first cut of this shipped: the base frame looked + -- solid and every other frame still had gaps, because a pixel that + -- drops out of a frame takes the union's wall with it and leaves an + -- interior boundary that never had one. + -- + -- So every pixel gets a cap on all four of its remaining faces, + -- whatever its neighbours do. A cap between two lit pixels sits + -- inside the slab, enclosed by the front and back faces, and is + -- never seen; the moment its neighbour is keyed out it IS the edge, + -- already there and already wearing the right colour. Each samples + -- its own pixel's texel, so it appears and vanishes with the pixel + -- it belongs to rather than with the one it is closing off. + -- + -- Inset a hair into its own pixel, because the voxel pass draws + -- with culling off: the two caps that meet at a boundary would be + -- coplanar and z-fight rather than politely take turns. + for px = ix, ix2 do + local tu = (ax0 + px + 0.5) / atlasW + local tv = (ay0 + py + 0.5) / atlasH + local xa, xb = px, px + 1 + local yT = yTop - SIDE_INSET + local yB = yBot + SIDE_INSET + quads[#quads + 1] = { -- the pixel's own lid + { xa, yT, zB }, { xb, yT, zB }, { xb, yT, zF }, { xa, yT, zF }, + uv = { { tu, tv }, { tu, tv }, { tu, tv }, { tu, tv } }, shade = OBJ_SHADE.top, } - end - -- and the same strip on the bottom, where the row below is clear: - -- a petal that ends mid-air is a solid thing seen from underneath, - -- and a low camera (1ST, 3RD, the battle's floor-level seat) is - -- looking straight up at it - if not on(ix, py + 1) then - quads[#quads + 1] = { - { ix, yBot, zF }, { ix2 + 1, yBot, zF }, - { ix2 + 1, yBot, zB }, { ix, yBot, zB }, - uv = { { u0, v1 }, { u1, v1 }, { u1, v1 }, { u0, v1 } }, + quads[#quads + 1] = { -- and its floor + { xa, yB, zF }, { xb, yB, zF }, { xb, yB, zB }, { xa, yB, zB }, + uv = { { tu, tv }, { tu, tv }, { tu, tv }, { tu, tv } }, shade = OBJ_SHADE.bottom, } end - -- The ENDS of the run, which close the slab off sideways. Without - -- them the flower is two faces and a lid: from anywhere but square - -- on you look straight through its open edge and out the far side, - -- which is what stopped it reading as a solid thing. - -- - -- Each wall samples ONE texel -- the run's own end pixel, at its - -- centre -- so the side wears the colour of the pixel it is closing - -- off (the nearest coloured pixel there is) rather than a keyed - -- hole, and discards with that pixel when the animation frame it - -- belongs to is not the one on screen. sideQuads(quads, ix, ix2, yBot, yTop, zB, zF, - ax0, ay0, atlasW, atlasH, py, on) + ax0, ay0, atlasW, atlasH, py, on, true) ix = ix2 + 1 else ix = ix + 1 diff --git a/main.lua b/main.lua index df02cee..4f5ebf9 100644 --- a/main.lua +++ b/main.lua @@ -646,6 +646,25 @@ end -- -- Everything they did is still reachable: uninstall the mod and both rows are -- back, at whatever they were last set to. +-- BATTLE BG rides the same reasoning, and comes off for a reason of its own. +-- The row picks what fills the screen AROUND the battle's 160x144 field -- +-- WHITE paper, BLACK bars, or the frozen overworld dimmed behind it -- and +-- all three were answers to the same question: what to do with the voids, +-- given the battle is a small picture in the middle of a big window. +-- +-- This mod answers that question differently and permanently. A staged fight +-- fills the whole window with the map the fight is standing on, and the +-- flat battle screen it composites over it is drawn on the mode's own +-- surface; there are no voids left for the row to fill. WORLD is the worst +-- of the three under it -- it makes the battle non-opaque so the engine +-- draws the overworld underneath, which is a SECOND copy of the world drawn +-- under the one the arena pass already put there, dimmed and at a different +-- camera. BLACK bars over a diorama read as a letterboxed screenshot. +-- +-- So the value is pinned at WHITE, which is the one the mode was composed +-- against, and the row comes off the menu on the same reasoning as TILT and +-- GBC FX: a row that no longer decides anything is worse than no row. +-- Uninstall the mod and it is back, at whatever it was last set to. local function pinEngineFx(game) game = game or require("src.core.Game") local opts = game and game.save and game.save.options @@ -654,7 +673,9 @@ local function pinEngineFx(game) local changed = false if opts then changed = (opts.tilt or 0) ~= 0 or (opts.gbcfx or 0) ~= 0 + or (opts.battleBg or "white") ~= "white" opts.tilt, opts.gbcfx = 0, 0 + opts.battleBg = "white" end pcall(Tilt.setLevel, 0) pcall(GBCFX.setLevel, 0) @@ -672,6 +693,10 @@ mod.hooks:wrap("ui.options.rows", function(next, game, rows) pinEngineFx(game) dropRow(out, "tilt") dropRow(out, "gbcfx") + -- and BATTLE BG with them: this mode fills the window with the map, so + -- the row's whole question -- what to put in the voids around the battle + -- -- no longer has voids to be about (see pinEngineFx) + dropRow(out, "battleBg") -- BATTLE LAYOUT is the ENGINE's row, and this is the one place the mod takes -- one away. While a fight can be staged on the map, OG is the only layout it -- can be composed in (OverworldBattle.forceOG), so the value is pinned there diff --git a/tests/cam_shots.lua b/tests/cam_shots.lua new file mode 100644 index 0000000..3bcbba3 --- /dev/null +++ b/tests/cam_shots.lua @@ -0,0 +1,211 @@ +-- Scratch driver: the cameras the player steers -- the third-person boom's +-- zoom, and the battle shot's orbit, climb and lens, including the far +-- stops (side-on, 45 degrees up) and what BACK SPRITES takes away. +-- +-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/cam_shots.lua \ +-- SHOT_DIR=.scratchpad/camshots lovec.exe . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pipelines = require("src.render.Pipelines") + + local ROOT = (os.getenv("SHOT_DIR") or "shots/cam") + + local handle = game.mods.exports["DRAMATIC_SHAPE"] + if not (handle and handle.lib) then + print("[cam] DRAMATIC_SHAPE mod not loaded") + return love.event.quit() + end + local V = handle.lib + local FirstPerson = V.require("FirstPerson") + local ThirdPerson = V.require("ThirdPerson") + local BattleCam = V.require("BattleCam") + local OverworldBattle = V.require("OverworldBattle") + local ChunkMesher = V.require("ChunkMesher") + local Voxel = V.require("VoxelState") + local DayNight = V.require("DayNight") + + pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null') + pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul') + + require("src.world.OverworldController").rollEncounter = function() return nil end + local TileRenderer = require("src.render.TileRenderer") + TileRenderer.tick = function() end + -- The flower's animation frame, pinned per shot rather than left to the + -- clock: the mesh spans the union of every frame and each one is cut back + -- out in texture space, so a gap that only opens on frame 2 is invisible + -- to a driver that always photographs frame 0 -- which is exactly how the + -- first cut of the closed sides shipped looking correct. + local ANIM = { frame = 0 } + TileRenderer.animFrame = function() return ANIM.frame end + DayNight.setting:sync("day") + + local function settle() + for _ = 1, 900 do + if ChunkMesher.pending() == 0 then break end + U.wait(1) + end + U.wait(40) + end + + local shots = 0 + local function shot(name) + if U.shot(game, ("%s/%s.png"):format(ROOT, name)) then + shots = shots + 1 + end + end + + -- ------- the boom's zoom + U.teleport(game, "PALLET_TOWN", 13, 14, "down") + Pipelines.setLevel("voxel", Voxel.TP_LEVEL) + Pipelines.setLevel("tiltshift", 0) + settle() + for _ = 1, 200 do + if FirstPerson.blend >= 1 then break end + U.wait(1) + end + FirstPerson.yaw = math.pi + U.wait(20) + for _, z in ipairs({ "min", "default", "max" }) do + ThirdPerson.zoomGoal = (z == "min" and ThirdPerson.ZOOM_MIN) + or (z == "max" and ThirdPerson.ZOOM_MAX) or 1 + for _ = 1, 90 do U.wait(1) end + print(("[cam] boom %-8s zoom %.2f len %.1f"):format(z, ThirdPerson.zoom, + ThirdPerson.len)) + shot("boom_" .. z) + end + ThirdPerson.zoomGoal = 1 + U.wait(60) + + -- ------- the standees' closed sides + -- + -- Pallet's flower beds and Route 1's tall grass, from a low camera close + -- in -- the angle that showed the slabs were open at the ends of every + -- run and let you see straight through them. + ThirdPerson.zoomGoal = ThirdPerson.ZOOM_MIN + for _, s in ipairs({ + { map = "PALLET_TOWN", x = 13, y = 14, yaw = math.pi, + pitch = math.rad(6), label = "flowers_low" }, + { map = "PALLET_TOWN", x = 13, y = 14, yaw = 3 * math.pi / 4, + pitch = math.rad(30), label = "flowers_angled" }, + { map = "ROUTE_1", x = 10, y = 28, yaw = math.pi, + pitch = math.rad(8), label = "grass_low" }, + }) do + U.teleport(game, s.map, s.x, s.y, "down") + settle() + FirstPerson.yaw, FirstPerson.pitch = s.yaw, s.pitch + U.wait(60) + -- EVERY animation frame, because a gap the union closed and a frame + -- reopens is only visible on that frame + for f = 0, 3 do + ANIM.frame = f + U.wait(6) + shot(("%s_f%d"):format(s.label, f)) + end + ANIM.frame = 0 + end + ThirdPerson.zoomGoal = 1 + U.wait(60) + + -- ------- the battle shot + -- + -- Staged the way the game stages one, then steered to each stop with the + -- module's own entry points -- the same ones the wheel, the stick and a + -- drag reach. + Pipelines.setLevel("voxel", 4) + U.wait(60) + do + -- Somebody to fight WITH: a fresh driver save has an empty party, and + -- a trainer battle with nobody to send out ends on the frame it starts + -- -- which is what made every steered shot below sample a dead session. + local Pokemon = require("src.pokemon.Pokemon") + game.save.party = { + Pokemon.new(game.data, "CHARIZARD", 45), + Pokemon.new(game.data, "PIKACHU", 40), + } + game.save.player.name = "RED" + local BattleState = require("src.battle.BattleState") + local class = next(game.data.trainers) + local battle = BattleState.newTrainer(game, class, 1) + battle.onFinish = function() end + game.overworld:pushBattle(battle) + end + -- The wipe, then just enough of the send-out chatter to get both mons + -- standing on the arena -- and NOT one tap more. A is also FIGHT and then + -- the first move, so tapping past the intro starts an exchange and the + -- fight can be over before the camera has been steered anywhere. + U.wait(70) + for _ = 1, 40 do + if OverworldBattle.shot() then break end + U.tap(game, "a") + U.wait(10) + end + U.wait(60) + print(("[cam] staged: arena %s shot %s top %s") + :format(tostring(OverworldBattle.arena() ~= nil), + tostring(OverworldBattle.shot() ~= nil), + tostring(game.stack:top() ~= game.overworld))) + if not OverworldBattle.shot() then + print("[cam] no staged battle -- skipping the battle shots") + print(("[cam] %d shots into %s"):format(shots, ROOT)) + return love.event.quit() + end + + local function settleCam(label) + for _ = 1, 120 do U.wait(1) end + print(("[cam] battle %-20s orbit %.2f/%.2f pitch %.2f/%.2f " + .. "zoom %.2f/%.2f steerable %s live %s") + :format(label, BattleCam.orbit, BattleCam.orbitGoal, + BattleCam.pitch, BattleCam.pitchGoal, + BattleCam.zoom, BattleCam.zoomGoal, + tostring(BattleCam.steerable), + tostring(OverworldBattle.shot() ~= nil))) + shot("battle_" .. label) + end + + BattleCam.recentre() + settleCam("home") + + -- right to the stop: this must land SQUARE to the arena's axis + BattleCam.dragOrbit(10) + settleCam("side_on") + + -- and there is nothing to the left of home + BattleCam.recentre() + BattleCam.dragOrbit(-10) + settleCam("left_stop") + + -- up to the stop, and refusing to go below home + BattleCam.recentre() + BattleCam.dragPitch(10) + settleCam("high") + BattleCam.recentre() + BattleCam.dragPitch(-10) + settleCam("low_stop") + + -- the lens at both ends + BattleCam.recentre() + for _ = 1, 40 do BattleCam.stepZoom(-1) end + settleCam("zoom_in") + BattleCam.recentre() + for _ = 1, 40 do BattleCam.stepZoom(1) end + settleCam("zoom_out") + + -- everything at once, which is the shot a player would actually build + BattleCam.recentre() + BattleCam.dragOrbit(0.55) + BattleCam.dragPitch(0.5) + for _ = 1, 4 do BattleCam.stepZoom(-1) end + settleCam("steered") + + -- What BACK SPRITES takes away is NOT shot here: the flag it works + -- through is re-derived from the row every frame + -- (OverworldBattle.update), so a driver cannot hold it down for the + -- hundred-odd frames a settled shot needs, and a picture that claimed to + -- show a locked camera while the camera was in fact free would be worse + -- than no picture. The suite asserts it instead, on both axes and the + -- lens, and on the RIG as well as on the inputs. + BattleCam.recentre() + + print(("[cam] %d shots into %s"):format(shots, ROOT)) + love.event.quit() +end