Files
YataiDONNijiiro/Scripts/global/transition.lua
T
2026-09-02 14:25:04 -04:00

299 lines
16 KiB
Lua

-- YataiDON-HSS — the song-loading (rainbow) transition, ROUND 11.
--
-- The engine (`src/objects/game/transition.cpp`) offers a scripted backdrop: a
-- skin that defines `SongTransition` gets `update` / `draw_bg` / `draw_info`
-- called instead of `Transition::draw_default()`. We take `update` + `draw_bg`
-- only and leave `draw_info` to the engine, so the song title / subtitle stay
-- the engine's own `OutlinedText` at their `skin_config` positions with the
-- engine's own fades — the band under them is ours, drawn in `draw_bg`, which
-- the engine calls first, so the z-order comes out right for free.
--
-- ROUND 12 (text alignment). Those `skin_config` positions are now derived from the
-- movie's own `DefineEditText` records through the arcade pen rule, not eyeballed:
-- song_title_text ET10 box (0,311)-(1920,439) size 64 border 7 align 2 jp_64
-- song_sub_title_text ET12 box (0,396.99)-(1920,525.01) size 40 border 0 align 2 jp_64
-- pen.y = box.top + (box.h - lineHeight)/2 + border, lineHeight = 92 * size/64
-- -> title cell top 336, subtitle cell top 432.25; the kanji ink inside a jp_64 cell runs
-- 14..72 of 97, so the cabinet's ink centres are **379.0** and **459.1**. Our
-- OutlinedText centres its whole image and its kanji ink sits 47.9/1024 * size above
-- that centre, so `transition_title` y = 379 + 3.0 + 1224 = 1606 and
-- `transition_subtitle` y = 459.1 + 2.5 + 1224 = 1686 (the +1224 is `rainbow_up`,
-- which `Transition::draw_song_info` subtracts). Measured in the framebuffer
-- afterwards: title ink centre 379.0, subtitle 459.5.
-- See Graphics/loading/MAPPING.md ROUND 12.
--
-- WHAT THE ARCADE ACTUALLY DOES (the reference build `loading/loading_song.nulm`, root MC 58,
-- 110 frames @ 60 fps, labels init 0 / in 5 / stop 55 / out 60 / end 109; the
-- numbers are exported by scratchpad/anim_export.py into `anim/loading_song.lua`):
--
-- depth 0/1 char 47 / 49 two 960x1080 CURVED half-rainbows. f5..f34 they
-- slide in from off-screen (tx -240 -> 480 / 2160 -> 1440) while
-- stretching horizontally (sx 0.5 -> 1.0). At f34 they have met
-- but still leave a wedge of the screen behind them uncovered.
-- f79..f109 is the exact reverse — they retract sideways.
-- depth 2 char 51 the flat 7-stripe full-screen rainbow (7x 1920x160
-- slabs placed at (960,1000)). Present f35..f78 ONLY: it replaces
-- the two curtains the frame after they meet, and that is what
-- fills the wedge. Static while present.
-- depth 3 char 53 an 8x160 gradient drawn 1920x512 at y 568..1080,
-- **blend = add**, alpha 0 -> 77/256 over f35..f55, held to f60,
-- back to 0 by f69. A warm glow along the bottom, NOT a dark
-- strip (round 2 read it as one; round 4 deleted it because the
-- arcade capture showed nothing dark — both wrong, it is additive).
-- depth 4 sprite 3 ELEVEN copies of one 128x128 star (char 1) at fixed
-- positions and scales. No motion, no sprite sheet: the whole
-- group fades 0 -> 1 over f45..f55 and 1 -> 0 over f60..f69.
-- depth 5/6 don / katsu 560x560. They fly in along an ARC from off-screen
-- low (f35 (-166,738)/(2086,738) -> f55 (160,786)/(1760,786), the
-- ty rising to 656 at f43), hold, dip 786 -> 806 over f60..f65,
-- then fly straight up to y 294 and fade out by f79.
-- depth 7 char 56 the 1600x256 title band at (960,400). f45..f55 it
-- OPENS: sx 0.25 -> 1 with alpha 0 -> 1. f60..f69 it SQUASHES
-- shut: sx 1 -> 1.5, sy 1 -> 0.1, alpha -> 0.
-- depth 8+ song title / subtitle / ruby / tips: alpha 0 until f55, 1 at
-- `stop`, 0 at `out`. The cabinet parks on f55 for the whole load.
--
-- MAPPING ONTO THIS ENGINE. There is no "park" here: `song_select.cpp` shows
-- the transition for exactly `song_info_fade` (delay 266 + duration 266 = 532 ms,
-- global animation.json ids 0/3) and then `game.cpp` builds a second one
-- (`is_second`) that runs another 532 ms while the chart loads. So:
-- scene 1 (is_second == false): t 0..532 ms -> arcade frame 5 .. 55
-- scene 2 (is_second == true): t 0..532 ms -> arcade frame 60 ..109
-- Both joins are frames that fully cover the screen (f55 and f60 are both inside
-- the full-rainbow window), so neither scene change can flash.
--
-- The title band and the hint block are the exception: they are driven off the
-- engine's own fade curves rather than off the arcade frame, because they have
-- to appear and disappear in lockstep with the engine-drawn title. The arcade's
-- band open is linear in both sx and alpha simultaneously, so re-timing it onto
-- `song_info_fade` keeps its shape exactly and only changes its window.
--
-- TEXTURE SLOTS. `build/generated/texture_ids_generated.h` gives this subset
-- seven names and nothing else loads, so the new layers are packed into the two
-- that `draw_default()` used for 8x8 blanks and that nothing reads once a skin
-- scripts `SongTransition`. See Graphics/loading/MAPPING.md ROUND 11.
-- rainbow_bg the flat rainbow, 1:1 with the screen
-- rainbow_bg_top atlas: curtain L (0,0,960,1080) | curtain R (960,0,960,1080)
-- rainbow_bg_bottom atlas: band (0,0,1600,256) / glow (0,288,1600,512)
-- / sparkle (0,832,128,128)
-- text_bg 8x8 transparent — suppresses the engine's own band draw
local _ok, anim = pcall(require, "anim/sampler")
local CLIP = (_ok and anim and anim.safe) and anim.safe("anim/loading_song") or nil
SongTransition = {}
SongTransition.__index = SongTransition
local FADE_DELAY, FADE_MS, FADE_OUT_MS = 266.0, 266.0, 133.0
local SCENE_MS = FADE_DELAY + FADE_MS -- 532 ms: how long each scene shows
local F_IN, F_STOP, F_OUT, F_END = 5, 55, 60, 109
local F_FULL_A, F_FULL_B = 35, 78 -- the full-rainbow window
-- sub-rects inside the two atlases
local SRC_CURT_L = { x = 0, y = 0, width = 960, height = 1080 }
local SRC_CURT_R = { x = 960, y = 0, width = 960, height = 1080 }
local SRC_BAND = { x = 0, y = 0, width = 1600, height = 256 }
local SRC_GLOW = { x = 0, y = 288, width = 1600, height = 512 }
local SRC_STAR = { x = 0, y = 832, width = 128, height = 128 }
-- rest positions written into texture.json; every draw below passes a delta off
-- them. The two tables must agree (scratchpad/r11ld/bake_r11.py writes the json).
local REST_CHARA_L = { -120, 506 }
local REST_CHARA_R = { 1480, 506 }
local REST_HINT = { 0, 568 }
-- sprite 3's eleven star placements, world coords (the sprite sits at (960,540)
-- with an identity matrix, so its children's tx/ty already ARE screen pixels).
local STARS = {
{ 209.75, 542.55, 0.59998 }, { 92.35, 283.00, 0.47829 },
{ 154.20, 330.75, 0.26784 }, { 292.35, 246.35, 0.44998 },
{ 1259.00, 201.85, 0.78998 }, { 1845.70, 228.45, 0.37422 },
{ 1791.70, 268.40, 0.24998 }, { 1388.50, 664.80, 0.44713 },
{ 1821.20, 423.20, 0.56000 }, { 1472.00, 607.70, 0.25038 },
{ 552.60, 628.95, 0.74998 },
}
local function clamp(v, a, b) if v < a then return a elseif v > b then return b else return v end end
-- Fallbacks for every value the generated table supplies, so a missing or
-- renamed `anim/loading_song.lua` degrades to the arcade's own end states
-- instead of taking the loading screen down.
local function track(name, f, field, default)
if not CLIP then return default end
local v = CLIP:get(name, f, field)
if v == nil then return default end
return v
end
-- 演奏スキップON badge. `loading/loading_song.nulm`: mc `enso_skip_on` (sprite 46)
-- sits at stage (350, 106) and holds shape 43, whose local bounds are
-- (-312,-52)..(312,52) -- a 624x104 pill, so its top-left is (38, 54). Its child
-- `text_enso_skip` is a DefineEditText with box (-2,-2)..(598,78) placed at
-- (52, 71), i.e. stage x 50..650 / y 69..149, centre-aligned at font size 30.
local SKIP_BADGE_X, SKIP_BADGE_Y = 38, 54
local SKIP_BADGE_CX = 350 -- 38 + 624/2, the EditText's centre
local SKIP_TEXT_CY = 109 -- 69 + 80/2, the EditText box's centre
local SKIP_TEXT_SIZE = 30
-- `put()` adds the texture's OWN texture.json origin to the coordinates it is
-- given, so every caller passes that origin as `rest` to cancel it out (see
-- REST_HINT). badge.png declares x=38,y=54 -- the arcade position -- so its rest
-- is the same pair; passing ORIGIN instead drew it at (76,108).
local SKIP_REST = { 38, 54 }
function SongTransition.new(title, subtitle, is_second)
local self = setmetatable({}, SongTransition)
self.is_second = is_second and true or false
self.t0 = nil
self.f = self.is_second and F_OUT or F_IN
self.info = 0 -- band / hint opacity, 0..1
self.rainbow = tex.load_texture("global/rainbow_transition/rainbow_bg")
self.atlas_c = tex.load_texture("global/rainbow_transition/rainbow_bg_top")
self.atlas_b = tex.load_texture("global/rainbow_transition/rainbow_bg_bottom")
self.chara_l = tex.load_texture("global/rainbow_transition/chara_left")
self.chara_r = tex.load_texture("global/rainbow_transition/chara_right")
self.hint = tex.load_texture("global/rainbow_transition/chara_center")
-- 演奏スキップON badge (the reference build loading/loading_song.nulm, mc `enso_skip_on`)
self.skip_badge = tex.load_texture("loading/skip_badge/badge")
-- NOT latched here: GameScreen::on_screen_start builds the transition
-- (game.cpp:69) BEFORE it calls init_skip() (game.cpp:76), so at construction
-- time tex.skip_enabled() is still reporting the PREVIOUS enso. Resolved on
-- first draw instead -- see SongTransition:skip_armed().
self.skip_on = nil
if text and text.create_raw_text then
-- The cabinet does NOT bake this label: `text_enso_skip` is a Lumen
-- DefineEditText whose HTML is
-- <p align="center"><font face="MS Pゴシック" size="30"
-- color="#000000">演奏スキップON</font></p>
-- black, size 30, centred, NO outline (its border field is 0.0). It has
-- no language variants, so the cabinet shows the Japanese string in every
-- locale and so do we. Built HERE and not in skip_armed(): the glyph
-- atlas needs a frame to upload, so creating it on the first drawn frame
-- left the first frames showing an empty pill.
self.skip_text = text.create_raw_text("演奏スキップON", SKIP_TEXT_SIZE,
{ 0, 0, 0, 255 }, { 0, 0, 0, 0 }, false, 0, 2)
end
return self
end
-- Resolved lazily, once, on the first frame that actually draws: by then
-- init_skip() has run and tex.skip_enabled() is true iff the feature ARMED for
-- this enso (skin row on + a player has it on + a free drum, so false in 2P).
function SongTransition:skip_armed()
if self.skip_on == nil then
self.skip_on = (tex.skip_enabled ~= nil) and tex.skip_enabled() or false
end
return self.skip_on
end
function SongTransition:update(current_ms, rainbow_up, song_info_fade)
if not self.t0 then self.t0 = current_ms end
local t = clamp(current_ms - self.t0, 0, SCENE_MS)
if self.is_second then
self.f = F_OUT + (F_END - F_OUT) * (t / SCENE_MS)
self.info = clamp(1.0 - t / FADE_OUT_MS, 0, 1)
else
self.f = F_IN + (F_STOP - F_IN) * (t / SCENE_MS)
-- the engine hands us `song_info_fade`; use it so the band can never
-- drift from the title the engine draws on top of it
self.info = clamp(song_info_fade or ((t - FADE_DELAY) / FADE_MS), 0, 1)
end
end
-- Draw one texture into an absolute screen rect. `scale = 0` makes the engine's
-- `x2[i] * scale + params.x2` collapse to exactly `params.x2`, which is the only
-- way to get independent width and height out of `draw_texture`.
local function put(id, rest, x, y, w, h, a, src, blend)
if not id then return end
tex.draw_texture(id, {
x = x - rest[1], y = y - rest[2],
scale = 0, x2 = w, y2 = h,
fade = a, src = src, blend = blend,
})
end
local ORIGIN = { 0, 0 }
function SongTransition:draw_bg(total_offset, rainbow_up, mini_up, chara_down)
local f = self.f
-- 1. backdrop: two curved curtains, or the flat full-screen rainbow
if f >= F_FULL_A and f <= F_FULL_B then
put(self.rainbow, ORIGIN, 0, 0, 1920, 1080, 1)
else
local lx = track("#47@0", f, "tx", -240)
local ls = track("#47@0", f, "sx", 0.5)
local rx = track("#49@1", f, "tx", 2160)
local rs = track("#49@1", f, "sx", 0.5)
put(self.atlas_c, ORIGIN, lx - 480 * ls, 0, 960 * ls, 1080, 1, SRC_CURT_L)
put(self.atlas_c, ORIGIN, rx - 480 * rs, 0, 960 * rs, 1080, 1, SRC_CURT_R)
end
-- 2. additive glow along the bottom (char 53, 1920x512 at y568, alpha -> 77/256)
local ga = track("#55@3", f, "a", 0)
if ga > 0.002 then
put(self.atlas_b, ORIGIN, 0, 568, 1920, 512, ga, SRC_GLOW, "additive")
end
-- 3. the eleven sparkles — fixed placements, one group alpha
local sa = track("#3@4", f, "a", 0)
if sa > 0.002 then
for i = 1, #STARS do
local s = STARS[i]
local d = 128 * s[3]
put(self.atlas_b, ORIGIN, s[1] - d * 0.5, s[2] - d * 0.5, d, d, sa, SRC_STAR)
end
end
-- 4. don / katsu on their arc (560x560, registration = canvas centre)
local da = track("don", f, "a", 1)
if da > 0.002 then
put(self.chara_l, REST_CHARA_L,
track("don", f, "tx", 160) - 280, track("don", f, "ty", 786) - 280,
560, 560, da)
end
local ka = track("katsu", f, "a", 1)
if ka > 0.002 then
put(self.chara_r, REST_CHARA_R,
track("katsu", f, "tx", 1760) - 280, track("katsu", f, "ty", 786) - 280,
560, 560, ka)
end
-- 5. the title band. Arcade open: sx 0.25 -> 1 and alpha 0 -> 1, both linear
-- and simultaneous, re-timed onto the engine's own title fade. Arcade close:
-- sx 1 -> 1.5, sy 1 -> 0.1, alpha -> 0, on the fade-out.
local info = self.info
if info > 0.002 then
local bw, bh
if self.is_second then
bw = 1600 * (1.5 - 0.5 * info)
bh = 256 * (0.1 + 0.9 * info)
else
bw = 1600 * (0.25 + 0.75 * info)
bh = 256
end
put(self.atlas_b, ORIGIN, 960 - bw * 0.5, 400 - bh * 0.5, bw, bh, info, SRC_BAND)
-- 6. the hint block (「ゲームのヒント」 + 3 lines) on the same curve: the
-- arcade turns every text field on at `stop` and off at `out`.
put(self.hint, REST_HINT, 0, 568, 1920, 512, info)
end
-- 7. 演奏スキップON badge, top-left, on the same fade as the band. The arcade
-- shows it ONLY on the song-loading screens (loading/loading_song,
-- enso_ai/loading/loading_ai and the two collab AI loadings) -- there is no
-- `enso_skip_on` anywhere in the song-select lumens, so nothing is drawn
-- there. It is skipped in 2P for free: tex.skip_enabled() is false whenever
-- the feature did not arm, which is exactly the arcade's greyed-row case.
if self:skip_armed() and self.skip_badge then
put(self.skip_badge, SKIP_REST, SKIP_BADGE_X, SKIP_BADGE_Y, 624, 104, info)
if self.skip_text then
self.skip_text:draw({
x = SKIP_BADGE_CX - self.skip_text.width / 2,
y = SKIP_TEXT_CY - self.skip_text.height / 2,
fade = info,
})
end
end
end