patch holes on flower/grass models

This commit is contained in:
DramaticShape
2026-08-03 17:35:58 -04:00
parent 9542ba94b1
commit 95771403d9
5 changed files with 401 additions and 40 deletions
+83
View File
@@ -45,6 +45,89 @@
well-known way to make people ill. The rung still changes the walk and well-known way to make people ill. The rung still changes the walk and
the sprites the same way. 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 ## 1.5.2
### Added ### Added
+5
View File
@@ -40,6 +40,11 @@ through the engine's own machinery.
| right stick | look | | right stick | look |
| a touch drag off the overlay's controls | look | | a touch drag off the overlay's controls | look |
| left stick / touch d-pad / arrow keys | walk, relative to where the camera looks | | 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 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 a wall walks the camera in to your shoulders rather than through it — squeeze
+77 -40
View File
@@ -3314,29 +3314,56 @@ end
-- carries the animation: when a frame keys that pixel out, the wall's own -- 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 -- fragments discard with the faces either side of it, so a swaying tuft
-- never leaves a wall standing where its blade no longer is. -- 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, 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) local function texel(px)
return (ax0 + px + 0.5) / atlasW, (ay0 + py + 0.5) / atlasH return (ax0 + px + 0.5) / atlasW, (ay0 + py + 0.5) / atlasH
end end
if not lit(ix - 1, py) then local function left(px, at)
local u, v = texel(ix) local u, v = texel(px)
quads[#quads + 1] = { -- the run's left wall, facing -X quads[#quads + 1] = { -- facing -X
{ ix, yBot, zB }, { ix, yBot, zF }, { at, yBot, zB }, { at, yBot, zF },
{ ix, yTop, zF }, { ix, yTop, zB }, { at, yTop, zF }, { at, yTop, zB },
uv = { { u, v }, { u, v }, { u, v }, { u, v } }, uv = { { u, v }, { u, v }, { u, v }, { u, v } },
shade = OBJ_SHADE.side, shade = OBJ_SHADE.side,
} }
end end
if not lit(ix2 + 1, py) then local function right(px, at)
local u, v = texel(ix2) local u, v = texel(px)
quads[#quads + 1] = { -- and its right wall, facing +X quads[#quads + 1] = { -- facing +X
{ ix2 + 1, yBot, zF }, { ix2 + 1, yBot, zB }, { at, yBot, zF }, { at, yBot, zB },
{ ix2 + 1, yTop, zB }, { ix2 + 1, yTop, zF }, { at, yTop, zB }, { at, yTop, zF },
uv = { { u, v }, { u, v }, { u, v }, { u, v } }, uv = { { u, v }, { u, v }, { u, v }, { u, v } },
shade = OBJ_SHADE.side, shade = OBJ_SHADE.side,
} }
end 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 end
-- A tall-grass CELL is four tufts: 2x2 tiles, and each 8x8 tile is one -- 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 } }, uv = { { u1, v1 }, { u0, v1 }, { u0, v0 }, { u1, v0 } },
shade = OBJ_SHADE.back, shade = OBJ_SHADE.back,
} }
-- petal tips: a top strip where the row above is clear. The -- ------- the shell, closed on all four remaining faces
-- strip samples its own row's texel, so a tip that is not in --
-- the current frame discards with the face beneath it -- A flower SWAYS: the geometry spans the union of every animation
if not on(ix, py - 1) then -- frame's mask and each frame is cut back out of it in texture
quads[#quads + 1] = { -- space (see the header). So "is there a pixel next door" has two
{ ix, yTop, zB }, { ix2 + 1, yTop, zB }, -- different answers -- one in the union this mesh was built from,
{ ix2 + 1, yTop, zF }, { ix, yTop, zF }, -- and one in the frame actually on screen -- and only the second
uv = { { u0, v0 }, { u1, v0 }, { u1, v0 }, { u0, v0 } }, -- 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, shade = OBJ_SHADE.top,
} }
end quads[#quads + 1] = { -- and its floor
-- and the same strip on the bottom, where the row below is clear: { xa, yB, zF }, { xb, yB, zF }, { xb, yB, zB }, { xa, yB, zB },
-- a petal that ends mid-air is a solid thing seen from underneath, uv = { { tu, tv }, { tu, tv }, { tu, tv }, { tu, tv } },
-- 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 } },
shade = OBJ_SHADE.bottom, shade = OBJ_SHADE.bottom,
} }
end 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, sideQuads(quads, ix, ix2, yBot, yTop, zB, zF,
ax0, ay0, atlasW, atlasH, py, on) ax0, ay0, atlasW, atlasH, py, on, true)
ix = ix2 + 1 ix = ix2 + 1
else else
ix = ix + 1 ix = ix + 1
+25
View File
@@ -646,6 +646,25 @@ end
-- --
-- Everything they did is still reachable: uninstall the mod and both rows are -- Everything they did is still reachable: uninstall the mod and both rows are
-- back, at whatever they were last set to. -- 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) local function pinEngineFx(game)
game = game or require("src.core.Game") game = game or require("src.core.Game")
local opts = game and game.save and game.save.options local opts = game and game.save and game.save.options
@@ -654,7 +673,9 @@ local function pinEngineFx(game)
local changed = false local changed = false
if opts then if opts then
changed = (opts.tilt or 0) ~= 0 or (opts.gbcfx or 0) ~= 0 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.tilt, opts.gbcfx = 0, 0
opts.battleBg = "white"
end end
pcall(Tilt.setLevel, 0) pcall(Tilt.setLevel, 0)
pcall(GBCFX.setLevel, 0) pcall(GBCFX.setLevel, 0)
@@ -672,6 +693,10 @@ mod.hooks:wrap("ui.options.rows", function(next, game, rows)
pinEngineFx(game) pinEngineFx(game)
dropRow(out, "tilt") dropRow(out, "tilt")
dropRow(out, "gbcfx") 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 -- 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 -- 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 -- can be composed in (OverworldBattle.forceOG), so the value is pinned there
+211
View File
@@ -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