DramaticShapeVoxelMod archival mirror · read-only
980383bb92c42fa7410829344e7bebcb20f337fc

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 "