Files
YataiDONNijiiro/Scripts/result/result_bg_layers.lua
T
2026-09-19 13:45:48 -04:00

236 lines
12 KiB
Lua

-- YataiDON-HSS / ROUND 11 -- the animated 1P result backdrop.
--
-- The arcade backdrop is NOT a flat plate. `result/result_bg.nulm` main_mc "1p_l" is
-- depth 2 bg_1p_l (MC 88) -> init = sprite 83 (720 f) : 2 sky halves, Mt Fuji, 11 cloud bars
-- -> success = sprite 87 (720 f) : gold sky, cyan pulsing Fuji, 11 clear
-- cloud bars, 3 fireworks + 10 sparkles
-- depth 3 shape 89 : the white header band, ABOVE all of it
-- Every cloud bar drifts leftwards at its own speed on a 720-frame (12 s) loop and fades in at
-- spawn / out at despawn. Up to round 10 we shipped one baked 1920x1080 PNG of frame 0, which is
-- why the screen looked dead.
--
-- Motion comes from the EXPORTED timeline (`Scripts/anim/result_bg.lua`, ANIM_PIPELINE.md), never
-- from hand-fitted constants. If that table is missing the layers still draw, parked on the arcade
-- frame-0 constants in LAYERS below -- a data file must never be able to take the screen down.
--
-- Geometry note: every one of these shapes has local origin (0,0), so the arcade PlaceObject
-- translation IS the sprite's top-left on the 1920x1080 stage. A negative x-scale means the arcade
-- tx is the sprite's RIGHT edge, so we draw at tx - width*|sx| with mirror = "horizontal".
local anim = require("anim/sampler")
local ResultBgLayers = {}
ResultBgLayers.__index = ResultBgLayers
local LOOP = 720 -- frames, sprite 83 / 87
local FUJI_LOOP = 90 -- frames, huji_1p_l_mc (sprite 86), clear state only.
-- ROUND 84: this is a ONE-SHOT length, not a loop -- the clip
-- stops itself on its frame 89 and holds. Kept for reference;
-- fuji_frame() no longer wraps on it.
local MS_TO_F = 0.06 -- 60 fps
-- depth, cloud texture index, y, scale (|sx| == sy for every one of them), mirror, source width
-- (only a mirrored layer needs it -- and for both of those the base and clear bakes are the same
-- width), arcade track name, frame-0 tx, frame-0 alpha
local LAYERS = {
{ 3, 0, 162.60, 1.0, false, 592, "#67@3", 1705.70, 0 },
{ 4, 1, 905.25, 1.0, false, 960, "#69@4", 1240.00, 0 },
{ 5, 1, 934.00, 1.2, true, 960, "#69@5", 1106.30, 0 },
{ 6, 2, 622.00, 1.0, false, 840, "#71@6", 1382.90, 1 },
{ 7, 3, 873.25, 1.0, false, 824, "#73@7", -272.10, 1 },
{ 8, 4, 873.25, 1.0, false, 800, "#75@8", 668.65, 0 },
{ 9, 5, 595.25, 1.0, false, 848, "#77@9", 468.00, 0 },
{ 10, 6, -17.70, 1.0, false, 616, "#79@10", 1645.00, 1 },
{ 11, 2, 75.10, 0.6, true, 840, "#71@11", 346.40, 0 },
{ 12, 6, 214.30, 1.0, false, 616, "#79@12", 767.80, 1 },
{ 13, 7, 400.75, 1.0, false, 600, "#81@13", -190.35, 0 },
}
local FUJI_X = 583.00 -- shape 65, base
local FUJI_Y = 280.00
local FUJI_CLEAR_X = 584.65 -- shape 84 inside huji_1p_l_mc, clear
local FUJI_H = 800
local FUJI_TRACK = "huji_1p_l_mc/#84@0"
function ResultBgLayers.new(player_num)
local self = setmetatable({}, ResultBgLayers)
self.player_num = player_num
self.two_player = (player_num == 3)
self.t0 = nil
self.fuji_t0 = nil
if self.two_player then
-- The arcade 2P backdrop (main_mc "2p" -> bg_2p, MC 10) is two static sky halves with no
-- clouds and no Fuji, so it stays a single baked plate: the red half (shape 3, placed
-- mirrored) on the left and the cyan half (shape 1) on the right. The header band is
-- the same one the 1P layers draw, so it is drawn here too instead of being baked in.
self.t_bg = tex.get_texture("result/background/background_2p")
self.t_header = tex.get_texture("result/background/bg_header")
return self
end
self.t_sky = tex.get_texture("result/background/background_1p")
self.t_sky_clear = tex.get_texture("result/background/bg_sky_clear")
self.t_fuji = tex.get_texture("result/background/bg_fuji")
self.t_fuji_clear = tex.get_texture("result/background/bg_fuji_clear")
self.t_header = tex.get_texture("result/background/bg_header")
self.t_cloud, self.t_cloud_clear = {}, {}
for i = 0, 7 do
self.t_cloud[i] = tex.get_texture("result/background/bg_cloud_" .. i)
self.t_cloud_clear[i] = tex.get_texture("result/background/bg_cloud_clear_" .. i)
end
-- FAIL SOFT: `anim.safe` returns a NULL clip whose readers return nil, so a missing or
-- malformed table degrades to the LAYERS constants instead of raising.
self.clip = anim.safe("anim/result_bg")
self.fuji = anim.safe("anim/result_bg_fuji")
return self
end
-- arcade frame of the 720-frame loop, from the engine clock
function ResultBgLayers:frame()
local ms = tex.get_current_ms()
if not self.t0 then self.t0 = ms end
return math.fmod((ms - self.t0) * MS_TO_F, LOOP)
end
-- ROUND 17 -- Mt Fuji runs on its OWN clock, zeroed when the clear state fires.
--
-- User report 「result後面, 這個山在錯誤的跳動」. Measured this round:
-- * `result_bg.nulm --sprite 88` (`bg_1p_l`): `crear_1P_l` (sprite 87) is PLACED
-- at frame 5 (the `success` label) with alpha 0 and cross-fades to 1 over
-- frames 5..14 -- and its own cursor starts at 0 on that same frame.
-- * `--sprite 87 --range 0,250` shows the nested `huji_1p_l_mc` cursor running
-- 0,1,...,89,0,1,... -- a plain 90-frame loop, no stop and no label.
-- * `--sprite 86` is that 90-frame timeline: sy 1.000 @f0 -> 0.800 @f22 ->
-- 1.300 @f50 -> 0.900 @f60 -> 1.100 @f74 -> 0.900 @f89, ty compensating so
-- the foot of the mountain stays on y~1064.
-- So the cabinet's mountain starts its squash-and-stretch from REST (sy 1.0) at
-- the exact frame the gold backdrop starts fading in.
--
-- What we did instead: sample `fmod(self:frame(), 90)`, i.e. the SHARED 720-frame
-- cloud clock, which has been free-running since the result screen opened -- but
-- `__hss_result_bg_clear` only leaves 0 tens of seconds later, after the gauge and
-- the count-up. The mountain therefore popped in at a uniformly random point of
-- the bounce (typically mid-stretch at sy 1.2-1.3) and then snapped, which is the
-- wrong jump the user is seeing. The 720 clock is right for the clouds (their
-- tracks are seamless at f719==f0, so phase is invisible); it is wrong for this
-- one-shot-into-bounce channel. Same class of bug as ENTRY's `entry_bg` strobe.
--
-- ROUND 84 (r84-result-fuji-jitter) -- IT IS A ONE-SHOT, NOT A LOOP.
--
-- User report 「result畫面,這個山會一直抽動,但他應該不是一直抽動」. Round 17's
-- "sprite 86 has 90 frames, no stop and no gotoAndPlay" was a TOOL ARTEFACT:
-- `lumen_anim_dump` only honours a clip's own DoAction stop()/goto when it is
-- given `--auto-stop`, and that flag is OFF by default precisely so a clip that
-- stops itself still shows its whole authored timeline. Round 17 dumped without
-- it, saw the dumper's own replay, and read it as an authored loop.
--
-- Re-measured this round, `--sprite 87 --all --leaves --range 0,719`:
-- without --auto-stop : huji_1p_l_mc cursor restarts at parent frames
-- 90, 180, 270, 360, 450, 540, 630 (the artefact)
-- with --auto-stop : cursor 0,1,...,88,89,89,89,... stop flag set at
-- its own frame 89 and never cleared for the remaining
-- 630 frames of the parent.
-- The held pose is ty = 360.89, sy = 0.89997 (foot y = 1080.87) from parent
-- frame 89 to 719 inclusive -- identical at f=90, 120, 200, 300, 400.
-- The right-hand rig is the same design, not a counter-example: char 32 (64
-- frames) stops at its frame 63 on the IDENTICAL (360.89, 0.89997).
--
-- So the cabinet's mountain does its squash-and-stretch EXACTLY ONCE -- 90
-- frames = 1500 ms at the container's 60 fps, starting the frame the gold
-- backdrop begins fading in -- and then parks, permanently, 10% squashed. Our
-- `math.fmod(..., FUJI_LOOP)` restarted that bounce every 1500 ms for as long
-- as the result screen was up, which is the constant twitching the user sees.
--
-- The fix is to let the frame counter run monotonically: `sampler.lua` clamps
-- any frame past the last row to that row ("exactly what a Lumen timeline parked
-- on its last frame does"), and `anim/result_bg_fuji.lua`'s last row IS
-- { 89, 360.89, 0.9 }. No fmod, no clamp of our own, no rebuild.
function ResultBgLayers:fuji_frame(clear)
if clear <= 0 then
self.fuji_t0 = nil -- re-arm: the next clear starts from rest again
return 0
end
local ms = tex.get_current_ms()
if not self.fuji_t0 then self.fuji_t0 = ms end
-- monotonic; the sampler parks it on frame 89 (FUJI_LOOP - 1) forever after.
return (ms - self.fuji_t0) * MS_TO_F
end
local function draw_cloud(id, w, x, y, scale, mirror, a)
if not id or a <= 0.004 then return end
local left = mirror and (x - w * scale) or x
tex.draw_texture(id, {
x = left, y = y, scale = scale, fade = a,
mirror = mirror and "horizontal" or nil,
})
end
-- `fade` multiplies everything (the screen-entry wipe); `clear` is 0..1, the success cross-fade.
function ResultBgLayers:draw(fade, clear)
fade = fade or 1.0
clear = clear or 0.0
if fade <= 0 then return end
if self.two_player then
if self.t_bg then tex.draw_texture(self.t_bg, { fade = fade }) end
if self.t_header then tex.draw_texture(self.t_header, { fade = fade }) end
return
end
local f = self:frame()
-- 1. sky. Base: shape 3 at x=0 and mirrored at x=960. Clear: shape 5 (gold, a different
-- atlas) the other way round -- x=960 upright, x=0 mirrored.
if self.t_sky then
tex.draw_texture(self.t_sky, { index = 0, fade = fade })
tex.draw_texture(self.t_sky, { index = 1, fade = fade, mirror = "horizontal" })
end
if clear > 0 and self.t_sky_clear then
tex.draw_texture(self.t_sky_clear, { index = 0, fade = fade * clear })
tex.draw_texture(self.t_sky_clear, { index = 1, fade = fade * clear, mirror = "horizontal" })
end
-- 2. Mt Fuji. Static in the base state (round 6 recorded a pulse there; the export shows
-- sy == 1.0 for all 720 frames of sprite 83). The pulse is the CLEAR state's
-- huji_1p_l_mc: a 90-frame ONE-SHOT (ROUND 84 -- it stops on its own frame 89),
-- sy 1.000 -> 0.800 @f22 -> 1.300 @f50 -> 0.900 @f60 -> 1.100 @f74 -> 0.900 @f89 and
-- then held forever, with ty compensating so the foot stays on y ~ 1080.9.
if self.t_fuji and clear < 1.0 then
tex.draw_texture(self.t_fuji, { x = FUJI_X, y = FUJI_Y, fade = fade * (1.0 - clear) })
end
if clear > 0 and self.t_fuji_clear then
local ty, sy = self.fuji:at(FUJI_TRACK, self:fuji_frame(clear))
ty = ty or FUJI_Y
sy = sy or 1.0
tex.draw_texture(self.t_fuji_clear, {
x = FUJI_CLEAR_X, y = ty, y2 = FUJI_H * sy - FUJI_H, fade = fade * clear,
})
end
-- 3. the eleven cloud bars. The clear rig's tracks are frame-identical to the base rig's for
-- ten of the eleven placements (only depth 10 drifts faster there), so both states are
-- driven off the one exported table -- which also means the cross-fade never makes a cloud
-- jump. See MAPPING.md ROUND 11.
for i = 1, #LAYERS do
local L = LAYERS[i]
local idx, y, scale, mirror, w, track = L[2], L[3], L[4], L[5], L[6], L[7]
local tx, a = self.clip:at(track, f)
tx = tx or L[8]
a = a or L[9]
if clear < 1.0 then
draw_cloud(self.t_cloud[idx], w, tx, y, scale, mirror, fade * a * (1.0 - clear))
end
if clear > 0 then
draw_cloud(self.t_cloud_clear[idx], w, tx, y, scale, mirror, fade * a * clear)
end
end
-- 4. the header band + the "n曲目" plate, last: main_mc depth 3 is above bg_1p_l depth 2, and
-- two of the cloud bars (depths 10 and 11) do reach into the top 176 px.
if self.t_header then tex.draw_texture(self.t_header, { fade = fade }) end
end
return ResultBgLayers