update battle water
DramaticShape · 2026-08-01 ·
parent 22b58e27a4
update battle water
Changed files
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
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 "