Merge pull request #13 from DramaticShape/3d-battle-mod

3d battle mod
This commit is contained in:
DramaticShape
2026-07-29 22:55:21 -04:00
committed by GitHub
8 changed files with 148 additions and 48 deletions
+29 -19
View File
@@ -57,13 +57,16 @@
horizon above the frame, so the arena keeps exactly the flat sky it had.
- **A day/night cycle**, on a new **DAYTIME** options row: `DAY`, `NIGHT`,
`DUSK`, `DAWN`, `CYCLE`. One twenty-minute clock underneath all five -- ten
minutes of sun, ten of moon -- where the four named settings are PINS on
that dial (noon, mid-night, sunset, sunrise) and `CYCLE` lets it run,
picking up from whichever pin the player was just looking at. Everything is
a pure function of the clock, so the pinned DUSK is exactly the running
cycle stopped at sunset. Setting **VOXEL** to `FULL` switches DAYTIME to
`CYCLE` along with the rest of the preset.
`DUSK`, `DAWN`, `SYNC`, `CYCLE`. One twenty-minute clock underneath all of
them -- ten minutes of sun, ten of moon -- where the four named settings
are PINS on that dial (noon, mid-night, sunset, sunrise), `CYCLE` lets it
run, picking up from whichever pin or SYNC sky the player was just looking
at, and `SYNC` -- the DEFAULT -- lays the machine's own clock onto the
dial: local noon is the DAY pin, midnight is NIGHT, six and eighteen the
twilights, an hour of the real day is fifty seconds of dial. Everything is a pure function of
the clock, so the pinned DUSK is exactly the running cycle stopped at
sunset. Setting **VOXEL** to `FULL` switches DAYTIME to `CYCLE` along with
the rest of the preset.
**The sun and the moon are in the sky**, and their positions are honest:
the disc is the light's own direction projected through the same matrix the
@@ -130,14 +133,18 @@
at any angle, in free-roam and staged battles alike, with no geometry
work.
By day a thin diagonal glint crosses the panes WHILE THE VIEW MOVES: the
sweep's phase is fed by the camera's own travel and its strength fades out
within a beat of standing still -- a reflection is something the viewpoint
does, so still camera means still glass. It lifts the texels toward
sky-white and leaves the diagonal shine art visible through it. The mask
is consulted only by meshes textured from the tileset atlas
(Voxel3D.glass), never by sprite sheets, whose coordinates would land on
the panes' atlas positions by accident.
By day a thin glint crosses the panes WHILE THE VIEW MOVES: the sweep's
phase is fed by the camera's own travel and its strength fades out within
a beat of standing still -- a reflection is something the viewpoint does,
so still camera means still glass. The sweep pattern lives in the pane's
OWN texels, not the screen's: a screen-anchored pattern has the world
sliding through it at zoom speed whenever the camera pans, which strobed
(worst walking against the sweep); anchored to the glass, panning moves
nothing and a step advances the glint a fraction of a texel, the same in
every direction. It lifts the texels toward sky-white and leaves the
shine art visible through it. The mask is consulted only by meshes
textured from the tileset atlas (Voxel3D.glass), never by sprite sheets,
whose coordinates would land on the panes' atlas positions by accident.
After dark the panes are LIT: the texel's own pattern carried into a warm
lamp colour, replacing the shaded answer entirely -- a lit window ignores
the sun, every shadow and the hour's tint, exactly as a window with a lamp
@@ -202,10 +209,13 @@
own ray (`ShadowMap.snug`): moving along the ray changes nothing about
where a shadow falls, but storing the card shallower takes three quarters
of the forgiveness back for the shadow it throws -- and for nothing else:
no terrain moved, so the acne margin is untouched where it matters, and
the quarter left keeps the card off the float-equality knife edge against
its own stored depth. The shadow root lands back under the feet at every
hour.
no terrain moved, so the acne margin is untouched where it matters. The
obligation that comes with it: every snugged caster's LIT draw hands the
same snugged transform to its own shadow lookup (Voxel3D.draw's
`sunModel`), so stored and lookup agree exactly and the compare keeps its
full margin -- read un-snugged, the missing nine tenths showed up as
diagonal moire bands crawling across every sprite. The shadow root lands
back under the feet at every hour.
### Changed
+7 -3
View File
@@ -416,8 +416,10 @@ function BattleScene.render(state, arena, textures, token)
-- tileset atlas, so the mask's coordinates mean nothing on them
Voxel3D.glass(false)
for _, card in ipairs(monCards(arena, groundY, textures)) do
-- the sun stored this card snugged (castShadows), so its own shadow
-- lookup must read the same snugged transform -- see ShadowMap.snug
Voxel3D.draw(BattleBillboard.mesh(), card.tex, card.model,
BattleBillboard.PULL)
BattleBillboard.PULL, ShadowMap.snug(card.model))
end
Voxel3D.glass(true)
Voxel3D.seams(true)
@@ -433,10 +435,12 @@ function BattleScene.render(state, arena, textures, token)
Mat4.translate(nb.ox, 0, nb.oy), pull)
end
local fpull = math.max(0, pull - 8 * math.sin(math.max(pitch, 0.05)))
Voxel3D.draw(ChunkMesher.flowers(host), atlasFor(host), nil, fpull)
Voxel3D.draw(ChunkMesher.flowers(host), atlasFor(host), nil, fpull,
ShadowMap.snug(nil))
for _, nb in ipairs(neighbors) do
Voxel3D.draw(ChunkMesher.flowers(nb.map), atlasFor(nb.map),
Mat4.translate(nb.ox, 0, nb.oy), fpull)
Mat4.translate(nb.ox, 0, nb.oy), fpull,
ShadowMap.snug(Mat4.translate(nb.ox, 0, nb.oy)))
end
local canvas = Voxel3D.endScene()
if not canvas then return end
+30 -6
View File
@@ -63,11 +63,14 @@ DayNight.T = { dawn = 0, day = 300, dusk = 600, night = 900 }
DayNight.KEY = "daytime"
DayNight.LABEL = "DAYTIME"
-- "day" first: an unset or unreadable value is DAY, per the row's contract
-- (ModSetting values[1] is the default)
-- "sync" first: an unset or unreadable value follows the machine's own
-- clock, per the row's contract (ModSetting values[1] is the default).
-- "cycle" stays LAST: the FULL preset reaches for it by position.
DayNight.setting = ModSetting.new(DayNight.KEY, DayNight.LABEL,
{ "day", "night", "dusk", "dawn", "cycle" },
{ "DAY", "NIGHT", "DUSK", "DAWN", "CYCLE" })
{ "sync", "day", "night", "dusk",
"dawn", "cycle" },
{ "SYNC", "DAY", "NIGHT", "DUSK",
"DAWN", "CYCLE" })
DayNight.clock = DayNight.T.day -- the running cycle's own position
@@ -311,10 +314,28 @@ local function mode()
return DayNight.setting:get() or "day"
end
-- The effective time: the pin, or the running clock under CYCLE.
-- Where SYNC reads the real clock: local hours, 0..24 with the minutes as
-- fraction. A named seam rather than a bare os.date call, so the suite can
-- hand it a fixed hour.
function DayNight.hours()
local d = os.date("*t")
return d.hour + d.min / 60 + d.sec / 3600
end
-- SYNC: the machine's own time of day laid onto the dial. Local noon is
-- the DAY pin, midnight the NIGHT pin, six and eighteen the twilights --
-- an hour of the real day is fifty seconds of dial, and Kanto's evening
-- falls when the player's does.
function DayNight.syncTime()
return ((DayNight.hours() - 6) * (DayNight.CYCLE / 24)) % DayNight.CYCLE
end
-- The effective time: the pin, the running clock under CYCLE, or the wall
-- clock under SYNC.
function DayNight.time()
local m = mode()
if m == "cycle" then return DayNight.clock end
if m == "sync" then return DayNight.syncTime() end
return DayNight.T[m] or DayNight.T.day
end
@@ -326,8 +347,11 @@ end
function DayNight.update(dt)
local m = mode()
if m ~= lastMode then
if m == "cycle" and DayNight.T[lastMode] then
if m == "cycle" then
-- from a pin, its time; from SYNC, wherever the real sky already was
DayNight.clock = DayNight.T[lastMode]
or (lastMode == "sync" and DayNight.syncTime())
or DayNight.clock
end
lastMode = m
end
+10 -2
View File
@@ -390,8 +390,16 @@ ShadowMap.SNUG = 0.9
-- terrain moved, so the acne margin the slack exists for is intact where
-- it matters. For sprite cards and other thin stand-ins only.
--
-- Valid between begin() and finish(), which is when `slack` matches the
-- frustum the map is being drawn with.
-- ONE OBLIGATION comes with it: the caster's LIT draw must hand this same
-- snugged transform to its shadow lookup (Voxel3D.draw's `sunModel`).
-- Stored and lookup then agree exactly, as they did before snugging, and
-- the compare keeps its full acne margin. A caster stored snugged but read
-- un-snugged is 0.9 of the margin short, and the loss shows up as diagonal
-- moire bands crawling across the card.
--
-- Valid between begin() and the next begin(): `slack` and the sun hold
-- still between redraws of the map, so a lit frame that reuses last
-- frame's map computes the same displacement it was stored with.
function ShadowMap.snug(model)
local f = sunDir()
local s = -ShadowMap.slack * ShadowMap.SNUG
+13 -2
View File
@@ -205,6 +205,7 @@ local SHADER = [[
uniform float ghost; // 0 = shade normally, 1 = flatten to it
uniform vec3 dayTint; // the hour's light on the world; 1,1,1 = noon
uniform Image glassMask; // opaque where the atlas texel is window glass
uniform vec2 glassSize; // the mask's dimensions: tc -> atlas texels
uniform float glassNight; // 0 = daylight .. 1 = the lamps are on
uniform float glassPhase; // the glint's phase: advances with TRAVEL
uniform float glassGlint; // and its strength: 0 while standing still
@@ -241,7 +242,13 @@ local SHADER = [[
// cast with lamplight at night.
float glass = Texel(glassMask, tc).a * glassOn;
if (glass > 0.0) {
float sweep = sin((sc.x + sc.y) * 0.04 - glassPhase);
// the sweep lives in the PANE's own space (atlas texels), not the
// screen's: a pattern anchored to the screen has the world sliding
// through it at zoom speed whenever the camera pans, which strobed --
// worst where the pan and the phase ran opposite ways. Anchored to
// the glass, panning moves nothing; only the phase does, a fraction
// of a texel per step, the same in every walking direction.
float sweep = sin(tc.x * glassSize.x * 0.8 - glassPhase);
float glint = pow(max(sweep, 0.0), 20.0) * 0.55 * glassGlint;
vec3 pane = mix(rgb, vec3(0.93, 0.97, 1.0), glint * glass);
float shine = dot(p.rgb, vec3(0.299, 0.587, 0.114));
@@ -607,7 +614,11 @@ function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky, slot)
-- declared either way, and unbound is a driver-dependent crash), how lit
-- the panes are, and the movement-fed glint as the caller last set it
local mask = Voxel3D.glassMask or GlassMask.blank()
if mask then pcall(sh.send, sh, "glassMask", mask) end
if mask then
pcall(sh.send, sh, "glassMask", mask)
local ok, mw, mh = pcall(mask.getDimensions, mask)
pcall(sh.send, sh, "glassSize", { ok and mw or 1, ok and mh or 1 })
end
pcall(sh.send, sh, "glassNight", Voxel3D.glassNight or 0)
pcall(sh.send, sh, "glassPhase", Voxel3D.glassPhase or 0)
pcall(sh.send, sh, "glassGlint", Voxel3D.glassGlint or 0)
+19 -9
View File
@@ -307,12 +307,13 @@ local function drawEntity(sprite, px, py, facing, phase, flip, gh, colors,
-- drift): lets the leaned-back head win against the wall it leans
-- OVER while a character genuinely BEHIND a building is dozens of
-- pixels deeper and still loses, so real occlusion works.
-- the same card UNLEANED is what the sun saw (castShadows draws
-- exactly this mesh), so that is where each vertex asks whether the
-- light reached it -- see Voxel3D.draw
-- the same card UNLEANED -- and SNUGGED, exactly as the sun stored it
-- (castShadows draws this mesh through ShadowMap.snug) -- is where each
-- vertex asks whether the light reached it; see ShadowMap.snug for why
-- the lookup must match the stored transform to the letter
Voxel3D.draw(mesh, tex, billboardMatrix(px, py, y, mirror),
billboardPull(),
Voxel3D.casterMatrix(px, py, y, mirror))
ShadowMap.snug(Voxel3D.casterMatrix(px, py, y, mirror)))
return true
end
@@ -464,7 +465,11 @@ end
-- with distance covered and its strength fades in over a few steps of
-- walking and back out within a beat of standing still. Stand still and
-- the glass is still; move and the light crosses it.
VoxelScene.GLINT_RATE = 0.22 -- radians of sweep per world pixel travelled
-- The rate is slow on purpose: the sweep pattern lives in the pane's own
-- texels (see the scene shader), so this is a FRACTION of a texel per world
-- pixel walked -- one full pass of the glint across a pane per eight or so
-- cells of travel, with no frame ever jumping it far enough to strobe.
VoxelScene.GLINT_RATE = 0.05 -- radians of sweep per world pixel travelled
VoxelScene.GLINT_IN = 0.12 -- strength gained per moving frame
VoxelScene.GLINT_OUT = 0.08 -- and lost per resting frame
@@ -699,11 +704,13 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor)
-- wins the overlap, which is the order the flat game draws them in.
local figPull = billboardPull()
eachFigure(state.map, 0, 0, function(mesh, model, caster)
Voxel3D.draw(mesh, atlasFor(state.map), model, figPull, caster)
Voxel3D.draw(mesh, atlasFor(state.map), model, figPull,
ShadowMap.snug(caster))
end)
for _, nb in ipairs(state.neighbors or {}) do
eachFigure(nb.map, nb.ox, nb.oy, function(mesh, model, caster)
Voxel3D.draw(mesh, atlasFor(nb.map), model, figPull, caster)
Voxel3D.draw(mesh, atlasFor(nb.map), model, figPull,
ShadowMap.snug(caster))
end)
end
-- and the seams are back on for the terrain art that follows: grass and
@@ -733,11 +740,14 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor)
-- ON, while the nearest flower of the cell south (+20) stays in front
-- and keeps overdrawing their feet.
local fpull = math.max(0, pull - 8 * math.sin(math.max(Voxel.angle, 0.05)))
-- flowers are snugged casters too, so they read their own shadowing
-- through the same snugged transform the sun stored them with
Voxel3D.draw(ChunkMesher.flowers(state.map), atlasFor(state.map), nil,
fpull)
fpull, ShadowMap.snug(nil))
for _, nb in ipairs(state.neighbors or {}) do
Voxel3D.draw(ChunkMesher.flowers(nb.map), atlasFor(nb.map),
Mat4.translate(nb.ox, 0, nb.oy), fpull)
Mat4.translate(nb.ox, 0, nb.oy), fpull,
ShadowMap.snug(Mat4.translate(nb.ox, 0, nb.oy)))
end
return Voxel3D.endScene()
+3 -2
View File
@@ -321,8 +321,9 @@ local SETTINGS = {
.. "shot over the shoulder with a slow parallax drift." },
{ DayNight.setting,
"What time it is outdoors: pin the sky to DAY, NIGHT, DUSK or DAWN, "
.. "or let CYCLE run it -- ten minutes of sun, ten of moon, with the "
.. "shadows, the sky and the light following." },
.. "let CYCLE run it -- ten minutes of sun, ten of moon, with the "
.. "shadows, the sky and the light following -- or SYNC it to the "
.. "clock on the wall, so Kanto's evening falls when yours does." },
}
local schema = {}
+37 -5
View File
@@ -297,8 +297,8 @@ T.eq(#hookedRows, 5, "the options hook added a row per setting")
local grid, curve, battles = hookedRows[2], hookedRows[3], hookedRows[4]
local daytime = hookedRows[5]
T.eq(daytime.label, "DAYTIME", "the day/night row carries its label")
T.eq(daytime.value(), "DAY",
"and starts pinned to DAY -- no time set means it is day")
T.eq(daytime.value(), "SYNC",
"and defaults to SYNC -- no value set follows the clock on the wall")
T.eq(grid.label, "V-GRID", "the grid row carries its label")
T.eq(grid.value(), "OFF", "the grid starts off")
T.eq(curve.label, "V-CURVE", "the curve row carries its label")
@@ -1055,6 +1055,11 @@ local skyFor = VoxelScene._skyFor
local skyStrength = VoxelScene._skyStrength
T.check(type(skyFor) == "function", "the scene exposes its sky resolve")
-- pinned to DAY for every sky assertion below: the row ships defaulting to
-- SYNC -- the machine's own clock -- which would hand these tests whatever
-- palette the hour of the test run happened to be
run.loader.exports.DRAMATIC_SHAPE.lib.require("DayNight").setting:sync("day")
local outside = { def = { id = "PALLET_TOWN", tileset = "OVERWORLD" } }
local inside = { def = { id = "REDS_HOUSE_1F", tileset = "HOUSE" } }
local TOP = math.rad(Voxel.ANGLES_DEG[Voxel.MAX_LEVEL + 1])
@@ -1928,7 +1933,10 @@ local Voxel3D = run.loader.exports.DRAMATIC_SHAPE.lib.require("Voxel3D")
local Voxel = run.loader.exports.DRAMATIC_SHAPE.lib.require("VoxelState")
-- the dial and its pins
T.eq(DayNight.setting:get(), "day", "no time set means DAY: the default pin")
T.eq(DayNight.setting.values[1], "sync",
"no value set means SYNC: values[1] is the row's contract default, and "
.. "it follows the machine's clock")
DayNight.setting:sync("day")
T.eq(DayNight.time(), 300, "and DAY is noon on the dial")
local PINS = { day = 300, night = 900, dusk = 600, dawn = 0 }
for name, t in pairs(PINS) do
@@ -2087,6 +2095,30 @@ modApi.save:set(DayNight.SAVE_KEY, nil)
DayNight.restore()
T.eq(DayNight.clock, 300, "a save with no clock in it starts at day")
-- SYNC lays the machine's own clock onto the dial: local noon is the DAY
-- pin, midnight is NIGHT, six and eighteen the twilights
local hoursWas = DayNight.hours
DayNight.setting:sync("sync")
DayNight.hours = function() return 12 end
T.eq(DayNight.time(), 300, "local noon is the DAY pin")
DayNight.hours = function() return 18 end
T.eq(DayNight.time(), 600, "six in the evening is DUSK")
DayNight.hours = function() return 0 end
T.eq(DayNight.time(), 900, "midnight is mid-night")
DayNight.hours = function() return 6.5 end
T.check(math.abs(DayNight.time() - 25) < 1e-9,
"half past six in the morning is just after dawn")
-- and stepping from SYNC onto CYCLE picks up from the sky already showing
DayNight.update(0)
DayNight.setting:sync("cycle")
DayNight.update(0)
T.check(math.abs(DayNight.clock - 25) < 1e-9,
"CYCLE picks up from wherever SYNC's sky already was")
DayNight.hours = hoursWas
T.eq(DayNight.setting.values[#DayNight.setting.values], "cycle",
"cycle stays the ladder's last rung -- the FULL preset reaches for it "
.. "by position")
-- arriving at FULL sets the clock going
local Game = require("src.core.Game")
local hadSave = Game.save
@@ -2098,8 +2130,8 @@ T.eq(DayNight.setting:get(), "cycle",
"FULL switches DAYTIME to CYCLE -- the diorama gets its running sky")
Game.save = hadSave
-- put the room back the way it was found
DayNight.setting:sync("day")
-- put the room back the way it was found (SYNC is the shipped default)
DayNight.setting:sync("sync")
DayNight.clock = 300
DayNight.applyRig(false)
Voxel3D.tint = { 1, 1, 1 }