From 980383bb92c42fa7410829344e7bebcb20f337fc Mon Sep 17 00:00:00 2001 From: DramaticShape Date: Sat, 1 Aug 2026 00:28:54 -0400 Subject: [PATCH] update battle water --- CHANGELOG.md | 26 ++++++++++++ lib/BattleScene.lua | 33 ++++++--------- lib/VoxelScene.lua | 5 ++- lib/Water.lua | 80 ++++++++++++++++++++++++++++++----- tests/dramatic_shape_test.lua | 23 +++++++++- 5 files changed, 133 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 952ec1a..172bfd4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -147,6 +147,32 @@ painted. The reflection lands a hair off the sprite's own depth and exactly on its colour, which at a lake's worth of wave is the same picture. +### Changed + +- **The waves arrive in sets now, and a little slower.** Three fixed trains + are an exactly periodic field -- every forty-odd pixels of sea wore the + same crest at the same height, which reads as wallpaper the moment a lake + is bigger than the repeat. Two long-wavelength fields now ride the + dominant train, four to five carrier wavelengths apiece so neither reads + as a wave itself: a SWELL that breathes its amplitude, so a few tall + crests march through and hand over to a lull that is itself moving, and a + BEND that bows its phase, so a crest line curves across the surface + instead of ruling itself over all of it. The two lesser trains stay + plain: they are texture rather than structure, and a third modulator is + the soup the train weights exist to avoid. The step beat comes down from + 15 to 12 a second -- the crests were hurrying, and a big wave is slower + than a walk cycle -- still a clean divisor of the engine's 60, and still + exactly one world pixel of dominant-crest travel per step. + +- **Staged battles draw their water plain, whatever the WATER row says.** + The reflective pass is tuned for the overworld's ladder of cameras; a + battle's camera is PLACED -- low, tilted, framed like a picture -- and + under it the pass read wrong: Fresnel opened all the way up, the leaned + sky landed on bands the framing never shows, and a lake-sized arena came + out as murk wearing the tile art. The battle is a stage set, and stage + water is painted: the flat animated tiles the mode always drew, with the + mons compositing over them like everything else on the set. + ### Fixed - **On Android the water stayed flat, as if the row were off -- and once it diff --git a/lib/BattleScene.lua b/lib/BattleScene.lua index 2fc3cce..170caac 100644 --- a/lib/BattleScene.lua +++ b/lib/BattleScene.lua @@ -435,31 +435,22 @@ function BattleScene.render(state, arena, textures, token) Voxel3D.draw(nbMesh[i], atlasFor(nb.map), Mat4.translate(nb.ox, 0, nb.oy)) end - -- and the water over it, reflecting the arena and the hour's sky (see - -- VoxelScene.drawWater -- the arena fights on the same lakes). The two - -- mons are this shot's cast: painted into the reflection copy alone, so - -- a fight staged at the water's edge has both of them in the water while - -- they still composite over it, exactly as the overworld's walkers do. - local waterDraws = {} - if water then waterDraws[#waterDraws + 1] = { water, atlasFor(host) } end + -- and the water over it -- PLAIN, always: the flat animated tiles, never + -- the reflective pass, whatever the WATER row says. The reflection is + -- tuned for the overworld's ladder of cameras; this shot's is PLACED -- + -- low, tilted and framed like a picture -- and under it the pass reads + -- wrong: Fresnel opens all the way up, the leaned sky lands on bands the + -- framing never shows, and a lake-sized arena comes out as murk wearing + -- the tile art. The battle is a stage set, and stage water is painted. + -- (No mirror also means the mons need no second draw into one -- they + -- just composite over the water below, like everything else on the set.) + if water then Voxel3D.draw(water, atlasFor(host)) end for i, nb in ipairs(neighbors) do if nbWater and nbWater[i] then - waterDraws[#waterDraws + 1] = { nbWater[i], atlasFor(nb.map), - Mat4.translate(nb.ox, 0, nb.oy) } + Voxel3D.draw(nbWater[i], atlasFor(nb.map), + Mat4.translate(nb.ox, 0, nb.oy)) end end - if #waterDraws > 0 then - VoxelScene.drawWater(waterDraws, function() - Voxel3D.seams(false) - Voxel3D.glass(false) - for _, card in ipairs(monCards(arena, groundY, textures)) do - Voxel3D.draw(BattleBillboard.mesh(), card.tex, card.model, - BattleBillboard.PULL, ShadowMap.snug(card.model)) - end - Voxel3D.glass(true) - Voxel3D.seams(true) - end) - end -- The mons, standing on their tiles. Depth-tested like everything else, -- so a ledge or a tree between the camera and a Pokemon really is in -- front of it, and the alpha discard cuts the sprite's own outline out of diff --git a/lib/VoxelScene.lua b/lib/VoxelScene.lua index b9244fb..daa4131 100644 --- a/lib/VoxelScene.lua +++ b/lib/VoxelScene.lua @@ -580,8 +580,9 @@ end -- with the row OFF, no depth texture to read, or a shader that would not -- build, the same meshes go through the ordinary scene shader and come out -- as the flat animated water this mode always drew. --- Shared with the overworld battle, which stages its fights on the same --- lakes and wants the same water under them. +-- The overworld's alone: the staged battle draws its water plain, always -- +-- its placed camera reads this pass wrong, and a stage set wants painted +-- water anyway (see BattleScene, where the choice is argued). function VoxelScene.drawWater(draws, cast) local plain = true if Water.enabled() and Voxel3D.depthReadable() then diff --git a/lib/Water.lua b/lib/Water.lua index b7ce4f8..9b7956c 100644 --- a/lib/Water.lua +++ b/lib/Water.lua @@ -237,14 +237,56 @@ Water.WAVE_TRAINS = { { -0.041, 0.033, 0.55, 0.11 }, } +-- ------- and what keeps them from reading as one pattern +-- +-- Three fixed trains are still an exactly periodic field: every forty-odd +-- pixels of sea wears the same crest at the same height, and a lake's worth +-- of that reads as wallpaper. Real swell varies two ways a sum of sines +-- cannot: waves arrive in SETS -- a few tall ones, then a lull -- and a +-- crest line curves as it runs rather than ruling itself across the whole +-- surface. Both are put back with one long-wavelength field each, riding +-- the DOMINANT train only; the two lesser trains stay plain, because they +-- are texture rather than structure and three modulators is soup again. +-- +-- Both wear the trains' own shape, { fx, fz, speed, x }: a direction whose +-- length is the spatial frequency, a phase rate, and what the field does. +-- Their wavelengths sit four to five times the carrier's, far enough apart +-- that neither reads as a wave itself -- the swell as slow weather over the +-- crests, the bend as the crests' own drift. +-- +-- THE SWELL scales the dominant train's amplitude; `x` is the DEPTH of the +-- deepest lull, as the fraction of the train it takes away. It runs roughly +-- along the carrier's own direction and slower than it, which is a wave +-- group's honest habit (deep-water groups travel at about half the phase +-- speed) -- so sets of crests swell up, march a while, and hand over to a +-- calm patch that is itself moving. +Water.WAVE_SWELL = { 0.0325, 0.0134, 0.55, 0.35 } + +-- THE BEND adds a slow wobble to the dominant train's phase; `x` is the +-- wobble's reach in RADIANS of carrier phase. 1.1 radians against a carrier +-- of about forty pixels bows a crest some seven pixels off its line over +-- the bend's own hundred-and-seventy-five -- a visible curve, not a +-- scribble -- and it runs ACROSS the carrier, which is the direction a +-- crest line actually wanders. What it costs is exactness in waveRate's +-- derivation: the carrier's local frequency now breathes around the number +-- the rate is derived from, so the one-pixel step is the average step +-- rather than every step's. The step CLOCK is untouched; only how far a +-- bowed stretch of crest moves on one tick varies, and by under a pixel. +Water.WAVE_BEND = { -0.0138, 0.0333, 0.35, 1.10 } + -- ------- and the beat they move on -- --- The surface does not slide, it advances in STEPS -- 15 a second, off the --- engine's own frame counter, which is the cadence hand-drawn pixel art is --- animated at and the reason this reads as art rather than as a simulation --- someone forgot to stylise. A surface built out of whole pixels that crawls --- between them smoothly gives away that the quantisation is only skin deep. -Water.WAVE_FPS = 15 +-- The surface does not slide, it advances in STEPS, off the engine's own +-- frame counter -- the move that makes this read as art rather than as a +-- simulation someone forgot to stylise. A surface built out of whole pixels +-- that crawls between them smoothly gives away that the quantisation is +-- only skin deep. +-- +-- 12 a second, a shade under the 15 hand-drawn pixel art is usually +-- animated at: the crests were hurrying, and a big wave is slower than a +-- sprite's walk cycle. Still a clean divisor of the engine's 60, so every +-- step spans the same whole number of frames. +Water.WAVE_FPS = 12 -- How far the dominant train advances each of those steps, in WORLD PIXELS. -- One is the honest choice for a stepped surface: the whole field shifts by @@ -984,12 +1026,30 @@ Water._craterSource = craterSource -- named for the suite -- and the wave trains, pasted in for the same reason: the rate is derived -- from this table (Water.waveRate), so the field the shader sums has to be --- the one that table describes rather than a copy of it kept in step by hand +-- the one that table describes rather than a copy of it kept in step by hand. +-- +-- The dominant train carries the swell and the bend (see WAVE_SWELL): its +-- phase wobbles by the bend field and its amplitude breathes with the swell +-- envelope, both off the same tables the constants above document. One +-- statement per train either way, which is what the suite counts. local function trainSource() local out = {} - for _, t in ipairs(Water.WAVE_TRAINS) do - out[#out + 1] = (" h += sin(dot(q, vec2(%.4f, %.4f)) + waveT * %.4f)" - .. " * %.4f;"):format(t[1], t[2], t[3], t[4]) + for i, t in ipairs(Water.WAVE_TRAINS) do + if i == 1 then + local s = Water.WAVE_SWELL + local b = Water.WAVE_BEND + out[#out + 1] = ( + " h += sin(dot(q, vec2(%.4f, %.4f)) + waveT * %.4f\n" + .. " + %.4f * sin(dot(q, vec2(%.4f, %.4f)) + waveT * %.4f))\n" + .. " * %.4f * (1.0 - %.4f * (0.5 + 0.5 *\n" + .. " sin(dot(q, vec2(%.4f, %.4f)) + waveT * %.4f)));") + :format(t[1], t[2], t[3], + b[4], b[1], b[2], b[3], + t[4], s[4], s[1], s[2], s[3]) + else + out[#out + 1] = (" h += sin(dot(q, vec2(%.4f, %.4f)) + waveT * %.4f)" + .. " * %.4f;"):format(t[1], t[2], t[3], t[4]) + end end return table.concat(out, "\n") end diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua index 27930f3..6bdfb73 100644 --- a/tests/dramatic_shape_test.lua +++ b/tests/dramatic_shape_test.lua @@ -1542,7 +1542,10 @@ local function at(f) end local period = 60 / Water.WAVE_FPS -T.eq(period, 4, "15 steps a second is one every four engine frames") +T.eq(period, 5, "12 steps a second is one every five engine frames") +T.eq(math.floor(period), period, + "and the beat divides the engine's 60 exactly, so every step spans the " + .. "same whole number of frames") -- inside one step nothing moves; crossing one, it does T.eq(at(0), at(period - 1), "every frame inside one wave step gets the same phase -- the surface " @@ -1575,6 +1578,24 @@ T.eq(select(2, trains:gsub("h %+= sin", "")), #Water.WAVE_TRAINS, T.check(trains:find(("%.4f"):format(t[1]), 1, true) ~= nil, "at the frequency the table states") +-- the variation that keeps three periodic trains from reading as wallpaper: +-- the dominant train's amplitude breathes with the swell and its crests bow +-- with the bend, both pasted from their own tables like the trains are +T.check(trains:find(("%.4f"):format(Water.WAVE_SWELL[1]), 1, true) ~= nil + and trains:find(("%.4f"):format(Water.WAVE_BEND[1]), 1, true) ~= nil, + "the swell and the bend reach the shader off the tables that document " + .. "them, not off copies kept in step by hand") +T.check(Water.WAVE_SWELL[4] > 0 and Water.WAVE_SWELL[4] < 1, + "the swell's deepest lull thins the dominant train without deleting or " + .. "inverting it -- a sea with sets in it, not a sea that turns off") +for _, mod in ipairs({ Water.WAVE_SWELL, Water.WAVE_BEND }) do + local mf = math.sqrt(mod[1] * mod[1] + mod[2] * mod[2]) + T.check(mf * 3.5 < freq, + "a modulator's wavelength sits several times the carrier's, far enough " + .. "apart that it reads as weather over the waves rather than as a " + .. "fourth wave -- which would be the soup the weights exist to avoid") +end + T.check(Water.WAVE_HEIGHT > -TileShapeHeights.water, "the crests stand taller than the recess TileShape sinks water into -- " .. "they are RELIEF inside the quad's own footprint, so a bar that reaches "