Compare commits

...

6 Commits

Author SHA1 Message Date
DramaticShape 917fbac38c Merge pull request #7 from DramaticShape/3d-battle-mod
fix white screen flash on shake on versions > 1.3.0
2026-07-28 16:16:45 -04:00
DramaticShape d8d1d7e336 fix white screen flash on shake on versions > 1.3.0 2026-07-28 16:10:58 -04:00
DramaticShape 4e6aa30c2c Merge pull request #6 from DramaticShape/3d-battle-mod
fixed full screen flashing in battles
2026-07-28 15:22:35 -04:00
DramaticShape 2e0ae37bcd fixed full screen flashing in battles 2026-07-28 15:22:13 -04:00
DramaticShape 9207712b19 Merge pull request #5 from DramaticShape/3d-battle-mod
set FULL tilt to 35
2026-07-28 14:01:54 -04:00
DramaticShape 2447046aee set FULL tilt to 35 2026-07-28 14:00:03 -04:00
8 changed files with 168 additions and 19 deletions
+24 -3
View File
@@ -1,11 +1,23 @@
# Changelog
## 1.1.1
### Fixed
- A move that shakes the screen no longer whites out the frame. The zone pass
fills each zone with its blank colour before drawing the shifted copy -- the
hardware showing empty BG in the strip the shake vacated -- and a shake
program alternates offset and no-offset frames, so over the map that read as
the whole battle screen, menu box included, flashing white a few times a
second. The fill is dropped while a battle is staged on the map; the shake
itself still moves the HUD.
## 1.2.0
### Added
- **A `FULL` rung on the VOXEL row**, directly after `OFF`. One choice that
puts the whole mode in its intended state -- the 50-degree camera, the
puts the whole mode in its intended state -- the 35-degree camera, the
miniature blur at maximum, the horizon flat, the view fitted, and battles
on the map -- rather than making a player assemble it from four rows.
@@ -20,6 +32,15 @@
Leaving it deliberately undoes nothing -- reverting would discard whatever
had been changed since.
### Fixed
- **The hit flash whited out the whole screen.** The engine draws it as a
full-screen white rectangle, which is a flash on a white battle field and
a whiteout of the map, the HUD and the text box over a world. It is now
dropped on the way past and put back where it was ever about: the two
Pokemon go solid white for those frames, silhouette and all, and nothing
else in the frame moves.
### Changed
- **Hotkey `3` walks the angle rungs only and steps over `FULL`.** The key is
@@ -29,9 +50,9 @@
with nothing on screen saying a keypress had done it. `FULL` stays on the
OPTIONS row, where a preset that changes other rows belongs.
A press FROM `FULL` goes to `75`. `FULL` is already the 50-degree camera,
A press FROM `FULL` goes to `50`. `FULL` is already the 35-degree camera,
so stepping to the rung of that name would look like the key had done
nothing.
nothing. Matched by angle, so it follows `FULL` if that is ever retuned.
- **The mode's four options are one block in the menu.** The engine splices
a pipeline row in beside TILT and lands a mod's own rows at the end of the
+21
View File
@@ -278,6 +278,17 @@ end
-- yet (the terrain mesh is still building, the driver has no depth support).
-- nil is not a failure: the caller simply leaves the battle screen as the
-- engine drew it for that frame.
-- White, for the hit flash, and how far toward it the card goes.
--
-- The shader replaces the card's colour rather than multiplying it, so at
-- full strength this is the sprite turned into a solid white silhouette --
-- which is what the effect is on a flat GB screen and far too much on a
-- sprite standing in a lit world. Held well short of 1, the mon's own
-- shading still reads through the flash: it looks struck rather than
-- deleted.
BattleScene.FLASH_COLOR = { 1, 1, 1 }
BattleScene.FLASH_STRENGTH = 0.5
function BattleScene.render(state, arena, textures, token)
if not (state and state.map and arena) then return nil end
if not Voxel3D.available() then return nil end
@@ -358,10 +369,20 @@ function BattleScene.render(state, arena, textures, token)
-- front of it, and the alpha discard cuts the sprite's own outline out of
-- the card. A small camera-ward pull keeps a card rooted to the ground
-- plane from z-fighting the tile it is standing on.
-- The engine's hit flash is a full-screen white rectangle, which on a
-- white battle field is a flash and over a world is a whiteout of the
-- map, the HUD and the text box alike. It is dropped on the way past
-- (see OverworldBattle) and put back HERE, on the two things it was ever
-- about: the mons themselves go solid white for those frames.
local flashing = textures and textures.flash
if flashing then
Voxel3D.flatten(BattleScene.FLASH_COLOR, BattleScene.FLASH_STRENGTH)
end
for _, card in ipairs(monCards(arena, groundY, textures)) do
Voxel3D.draw(BattleBillboard.mesh(), card.tex, card.model,
BattleBillboard.PULL)
end
if flashing then Voxel3D.flatten(nil) end
-- grass and flowers ride the same camera-ward pull the free-roam pass
-- gives them, measured against THIS camera's pitch rather than the
-- orbit's -- there is no character here for them to overdraw, but the
+69 -5
View File
@@ -340,11 +340,21 @@ local function withoutBackgroundFill(battle, fn)
if mode == "fill" and x == 0 and y == 0
and w == BattleScene.GB_W and h == BattleScene.GB_H then
local r, gr, b, a = g.getColor()
if r > 0.99 and gr > 0.99 and b > 0.99 and a > 0.99 then
local target = g.getCanvas()
if target ~= nil
and (target == battle.bgCanvas or target == battle.waveCanvas) then
g.clear(0, 0, 0, 0)
if r > 0.99 and gr > 0.99 and b > 0.99 then
-- Two different full-frame whites, both replaced rather than drawn.
--
-- OPAQUE is the battle's background, and on the offscreen canvases it
-- doubles as their clear, so there it becomes a transparent one.
--
-- TRANSLUCENT is the hit flash. Over a white field that reads as a
-- flash; over a world it whites out the map, the HUD and the text box
-- together. BattleScene puts it back on the mons alone.
if a > 0.99 then
local target = g.getCanvas()
if target ~= nil
and (target == battle.bgCanvas or target == battle.waveCanvas) then
g.clear(0, 0, 0, 0)
end
end
return
end
@@ -471,6 +481,18 @@ function OverworldBattle.sideTexture(battle, side)
return { canvas = canvas, ax = ax, ay = ay, trainer = trainer }
end
-- Whether the hit flash is showing this frame.
--
-- Mirrors BattleState:draw's own test, because the flash is a DRAW-time
-- decision there (a counter plus the frame parity that makes it flicker) and
-- there is no seam that reports it. Read-only, so the worst a future engine
-- change can do is flash on a frame the engine would not have.
function OverworldBattle.flashing(battle)
local fx = battle and battle.fx
if not (fx and fx.flash and fx.flash > 0) then return false end
return (battle.frame or 0) % 4 < 2
end
-- Both sides, or nil when neither has anything to show.
function OverworldBattle.textures(battle)
if not battle then return nil end
@@ -480,6 +502,7 @@ function OverworldBattle.textures(battle)
out.enemy = okE and enemy or nil
out.player = okP and player or nil
if not (out.enemy or out.player) then return nil end
out.flash = OverworldBattle.flashing(battle)
return out
end
@@ -622,6 +645,47 @@ function OverworldBattle.install()
if not ok then error(err, 0) end
end
-- The engine's flash has a SECOND half, and it is the one that reaches the
-- menu. Beside the white rectangle (dropped above) the flash moves are
-- driven by a BGP palette fade -- BGP_LIGHT and friends -- which the
-- colorized pipeline applies in drawZonePass to the WHOLE background
-- canvas. That canvas carries the HUD glyphs and the text box, so a fade
-- meant for the two mons washed the menu out with them.
--
-- The fade is left switched on for the pics, which read it through
-- picImage, and switched off for the zone pass alone. So the mons flash
-- and the furniture around them does not.
--
-- The zone pass has a SECOND thing it paints, and this is the one that
-- reads as the menu box flashing. A screen shake makes it fill every zone
-- with the zone's own color 0 before it draws the offset copy -- the
-- hardware showing empty BG in the strip the shake vacated. On a white
-- battle field that fill is invisible; over a world it is an opaque white
-- sheet across the whole frame, and since a shake program alternates
-- offset and no-offset frames (SE_SHAKE_SCREEN steps dx 1, 0, 1, 0...) it
-- switches on and off a few times a second. It is dropped: the background
-- here is the map, so what the shake vacates should show the map.
local innerZone = BattleState.drawZonePass
function BattleState:drawZonePass(src, sx, sy)
if not self.dramaticShapeShot then return innerZone(self, src, sx, sy) end
-- shadow the method on the instance for this call only; putting the
-- field back to whatever it was (normally nil) lets the class method be
-- found again
local had = rawget(self, "activeBgp")
self.activeBgp = function() return nil end
local g = love.graphics
local rectangle = g.rectangle
g.rectangle = function(mode, ...)
-- the pass draws no other rectangle; the shake still shifts the copy
if mode == "fill" then return end
return rectangle(mode, ...)
end
local ok, err = pcall(innerZone, self, src, sx, sy)
g.rectangle = rectangle
self.activeBgp = had
if not ok then error(err, 0) end
end
-- Black glyphs on grass are not readable; over a frosted panel measured
-- dark they are not readable either, so they go white. Mapped rather than
-- rewritten: the HUD sets pure black for its text and nothing else, and in
+21
View File
@@ -537,6 +537,27 @@ function Voxel3D.beginGhost()
end
end
-- Flatten whatever is drawn next to one solid colour, or nil to stop.
--
-- The same `ghost` path the silhouette uses, WITHOUT beginGhost's inverted
-- depth test and half alpha -- this is for something drawn normally that
-- simply wants to come out one colour, which is what a hit flash on a sprite
-- is. beginScene resets the uniform every frame, so a pass that forgets to
-- clear it cannot leak into the next one.
-- `amount` is how far toward that colour, 0..1; omitted is all the way.
-- Anything short of 1 leaves the sprite's own shading showing through, which
-- is the difference between a hit flash and a white cut-out.
function Voxel3D.flatten(color, amount)
if not (active and activeShader) then return end
local sh = activeShader
if color then
pcall(sh.send, sh, "ghostColor", color)
pcall(sh.send, sh, "ghost", math.max(0, math.min(1, amount or 1)))
else
pcall(sh.send, sh, "ghost", 0)
end
end
function Voxel3D.endGhost()
if not active then return end
pcall(love.graphics.setDepthMode, "lequal", true)
+6 -5
View File
@@ -29,10 +29,10 @@ local Voxel = {}
-- it rather than assembling it from four rows. It sits directly after OFF
-- because that is the order those two get used in.
--
-- Its ANGLE is 50 degrees, the same as the rung of that name. The duplicate
-- Its ANGLE is 35 degrees, the same as the rung of that name. The duplicate
-- in the table is deliberate: the ladder is a list of what each rung LOOKS
-- like, and two rungs may look the same while meaning different things.
Voxel.ANGLES_DEG = { 0, 50, 15, 35, 50, 75 }
Voxel.ANGLES_DEG = { 0, 35, 15, 35, 50, 75 }
Voxel.ANGLE_LABELS = { "OFF", "FULL", "15", "35", "50", "75" }
Voxel.MAX_LEVEL = #Voxel.ANGLES_DEG - 1
@@ -56,9 +56,10 @@ Voxel.HOTKEY_ORDER = { 0, 2, 3, 4, 5 } -- OFF, 15, 35, 50, 75
-- The rung a press moves to from `level`.
--
-- A level that is not on the key's path -- FULL, reached from the menu --
-- steps on from whichever rung shows the SAME camera it does. FULL is 50
-- degrees, so a press from it goes to 75 rather than back to 50, and the key
-- never appears to do nothing.
-- steps on from whichever rung shows the SAME camera it does. FULL is 35
-- degrees, so a press from it goes to 50 rather than back to 35, and the key
-- never appears to do nothing. Matched by ANGLE rather than by a hardcoded
-- rung, so retuning FULL moves the key's answer with it.
function Voxel.nextHotkeyLevel(level)
level = level or Voxel.level
local order = Voxel.HOTKEY_ORDER
+1 -1
View File
@@ -620,7 +620,7 @@ mod.events:on("battle.ended", function()
OverworldBattle.finish()
end)
mod.exports.version = "1.2.0"
mod.exports.version = "1.1.1"
-- exposed so a companion mod can pin its own tiles' shapes or read the
-- camera without reaching into this mod's file layout
mod.exports.lib = V
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "DRAMATIC_SHAPE",
"name": "Dramatic Shape Voxel Mod",
"version": "1.2.0",
"version": "1.1.1",
"api": 2,
"entry": "main.lua",
"profile": "content",
+25 -4
View File
@@ -840,12 +840,15 @@ end
T.eq(table.concat(walk, ","), "15,35,50,75,OFF,15",
"3 walks OFF -> 15 -> 35 -> 50 -> 75 and wraps, never touching FULL")
-- FULL is 50 degrees, so a press from it goes ON to 75 rather than back to
-- the rung that shows the same camera -- the key never appears to do nothing
-- FULL is 35 degrees, so a press from it goes ON to 50 rather than back to
-- the rung that shows the same camera -- the key never appears to do nothing.
-- Matched by angle, so this follows FULL if it is ever retuned.
T.eq(VoxelState.ANGLES_DEG[VoxelState.FULL_LEVEL + 1], 35,
"FULL is the 35-degree camera")
Pipelines.setLevel("voxel", VoxelState.FULL_LEVEL)
Game.keypressed(keyGame, "3")
T.eq(Pipelines.levelLabel("voxel"), "75",
"a press from FULL goes to 75, since FULL already IS the 50 camera")
T.eq(Pipelines.levelLabel("voxel"), "50",
"a press from FULL goes to 50, since FULL already IS the 35 camera")
Pipelines.setLevel("voxel", 0)
Game.keypressed(keyGame, "3")
@@ -1371,6 +1374,24 @@ T.check(math.abs(sx - px) < 0.2,
-- arena floor out from under the two mons pinned to it
T.eq(rig.curve, 0, "the battle camera switches the world curve off")
-- ------- the hit flash belongs to the mons, not the screen
--
-- The engine draws it as a full-screen white rectangle, which is a flash on
-- a white battle field and a whiteout of the map, the HUD and the text box
-- over a world. It is dropped on the way past and put back on the two cards.
local Battles = run.loader.exports.DRAMATIC_SHAPE.lib.require("OverworldBattle")
T.eq(Battles.flashing(nil), false, "no battle, no flash")
T.eq(Battles.flashing({ fx = {}, frame = 0 }), false,
"a battle with no flash counter is not flashing")
T.eq(Battles.flashing({ fx = { flash = 0 }, frame = 0 }), false,
"nor one whose counter has run out")
T.eq(Battles.flashing({ fx = { flash = 16 }, frame = 0 }), true,
"a live counter flashes on the frames the engine would")
T.eq(Battles.flashing({ fx = { flash = 16 }, frame = 2 }), false,
"and is dark on the others, which is what makes it flicker")
T.eq(Battles.flashing({ fx = { flash = 16 }, frame = 5 }), true,
"on a four-frame cycle")
-- ------- the wireframe is forced on in a battle
--
-- A fight is a staged shot rather than the world being walked through, so it