Files
DramaticShapeVoxelMod/tests/shiny_one.lua
T
DramaticShape 55145e3faf cave lineup: Mewtwo and Articuno, each where it lives
Cerulean Cave B1F and Seafoam Islands B4F. The RUNS rows gain a rung column
because a cave is where the choice actually matters: STADIUM A stages the
fight on the map and wants clear ground, and a cave floor often has none,
which is what STADIUM B's carried discs exist for. Both are on A -- checked
rather than assumed, and Cerulean Cave stages fine (noArena=0), with the
walls framing the model better than open ground does.

Worth noting for anyone reading the shot: shiny Mewtwo does NOT come out lime
green here. The Stadium values rotate its purple toward blue instead, which
is the fidelity:poor case the guide flags -- the model is right, the
divergence is Stadium's own.
2026-08-08 13:49:26 -04:00

165 lines
6.6 KiB
Lua

-- Driver: ONE shiny encounter, at real 1x speed, held open until you close
-- the window. Meant to be launched once per Pokemon by tests/shiny_run.sh.
--
-- DS_SPECIES=GYARADOS DS_LEVEL=45 DS_MAP=CERULEAN_CITY DS_CX=10 DS_CY=12 \
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/shiny_one.lua \
-- "/c/Program Files/LOVE/lovec.exe" .
--
-- ------- why the pacing is done here
--
-- A driver run is deliberately UNPACED: main.lua's pacingEnabled() returns
-- false whenever POKEPORT_DRIVER is set, so love.run spins as fast as the
-- machine will go and the loop takes one logic step (Game:update(1/60)) per
-- turn of it. That is right for a screenshot script and wrong for a capture:
-- the game runs at whatever multiple of real time the hardware manages, so
-- "wait 180 frames" is three seconds only by coincidence.
--
-- The fix does not need an engine change. The loop is blocked while this
-- coroutine is running, so sleeping HERE before each yield paces the whole
-- thing -- one 1/60 logic step per 1/60 of real time, which is 1x by
-- construction. Nothing else in the engine has to know.
--
-- ------- and why it never finishes
--
-- When a driver coroutine goes dead, main.lua calls love.event.quit(). So
-- holding the battle open forever is exactly how the window stays up until
-- somebody closes it, which is the handshake this run wants: one window, one
-- Pokemon, closed by hand, and only then the next.
return function(game)
local U = dofile("tests/drivers/util.lua")
local BattleState = require("src.battle.BattleState")
local Pokemon = require("src.pokemon.Pokemon")
local SPECIES = os.getenv("DS_SPECIES") or "GYARADOS"
local LEVEL = tonumber(os.getenv("DS_LEVEL") or "") or 45
local MAP = os.getenv("DS_MAP") or "ROUTE_1"
local CX = tonumber(os.getenv("DS_CX") or "") or 5
local CY = tonumber(os.getenv("DS_CY") or "") or 8
local LEAD = tonumber(os.getenv("DS_LEAD") or "") or 2.0 -- seconds
-- ------- real-time pacing
--
-- Each call is one logic step AND one sixtieth of a second of wall clock.
-- The target is carried forward rather than measured from "now" so a slow
-- frame is absorbed by the next one instead of compounding into drift.
local nextAt = nil
local function step()
local now = love.timer.getTime()
nextAt = (nextAt and (nextAt + 1 / 60)) or (now + 1 / 60)
local slack = nextAt - now
if slack > 0 then
love.timer.sleep(slack)
else
nextAt = now -- fell behind; do not try to catch up
end
coroutine.yield()
end
local function hold(seconds)
for _ = 1, math.floor(seconds * 60) do step() end
end
-- Unpaced, on purpose: a model rebuild is a loading screen, not part of
-- the capture, and there is no reason to watch it at 1x.
local function fast(n)
for _ = 1, n do coroutine.yield() end
end
local exports = game.mods and game.mods.exports
local lib = exports and exports.DRAMATIC_SHAPE and exports.DRAMATIC_SHAPE.lib
if not lib then
U.log("DRAMATIC_SHAPE is not loaded")
return
end
local Shiny = lib.require("Shiny")
local OverworldBattle = lib.require("OverworldBattle")
local StadiumInstall = lib.require("StadiumInstall")
if not StadiumInstall.ready() then
U.log("building stadium models (not part of the capture)...")
StadiumInstall.begin()
local guard = 0
while not StadiumInstall.ready() and guard < 2000 do
for _ = 1, 6 do StadiumInstall.step() end
fast(1)
guard = guard + 1
end
end
-- STADIUM A stages the fight on the MAP, which wants clear ground. A cave
-- floor often has none, and the mode's answer there is STADIUM B: the two
-- carried discs, which work anywhere. DS_RUNG picks between them per
-- encounter rather than forcing one choice on every location.
OverworldBattle.setting:setValue(os.getenv("DS_RUNG") or "stadium", game)
-- THE PARTY IS BUILT FIRST, at ordinary odds, and the roll is only pinned
-- afterwards. Pokemon.new is where shininess is decided, so setting the
-- odds before this line made the player's own Pikachu shiny too -- and a
-- shiny on the player's side tints that side's pic, which during the intro
-- is the TRAINER, so the player sprite came out discoloured for the whole
-- send-out. The foe is the one this run is about.
game.save.player.name = "RED"
game.save.party = { Pokemon.new(game.data, "PIKACHU", 50) }
Shiny.setOdds(1) -- from here on: the encounter
if not game.data.pokemon[SPECIES] then
U.log("no such species: " .. SPECIES)
return
end
if not game.data.maps[MAP] then
U.log("no such map: " .. MAP)
return
end
U.teleport(game, MAP, CX, CY, "down")
hold(1.6) -- let the neighbourhood mesh
hold(LEAD) -- a beat before the fight opens
local battle = BattleState.newWild(game, SPECIES, LEVEL)
battle.onFinish = function() end
game.overworld:pushBattle(battle)
local mon = battle.enemy and battle.enemy.mon
U.log(("%s at %s -- shiny=%s (1x; close the window for the next one)")
:format(SPECIES, MAP, tostring(Shiny.isShiny(mon))))
-- The battle stays up, at 1x, until the window is closed by hand. No taps:
-- the recording should be the Pokemon standing there, not a text box being
-- clicked through.
--
-- DS_AUTOCLOSE is for checking the pacing without a person in the loop: set
-- it to a number of seconds and the run should take about that long by the
-- wall clock, which is the only way to prove 1x is actually 1x.
-- DS_SHOTS: capture the ARRIVAL as a strip, for checking that the sparkle
-- fires on its own rather than only when a test arms it by hand. This is
-- the same code path the capture run uses, which is the point -- the last
-- bug here hid precisely because it was verified through a driver that
-- armed the effect itself.
local shots = os.getenv("DS_SHOTS")
if shots then
for k = 1, 26 do
U.shot(game, ("%s/arrive_%02d.png"):format(shots, k))
hold(0.15)
end
local ShinyFx = lib.require("ShinyFx")
local d = ShinyFx.debug or {}
U.log(("fx: armed=%s cleared=%s calls=%s noArena=%s noLive=%s quads=%s")
:format(tostring(d.armed), tostring(d.cleared), tostring(d.calls),
tostring(d.noArena), tostring(d.noLive), tostring(d.quads)))
U.log("arrival strip written to " .. shots)
return
end
local auto = tonumber(os.getenv("DS_AUTOCLOSE") or "") or 0
if auto > 0 then
local t0 = love.timer.getTime()
hold(auto)
U.log(("autoclose: asked for %.1fs, took %.2fs")
:format(auto, love.timer.getTime() - t0))
return
end
while true do step() end
end