Files
YataiDONNijiiro/Scripts/background/background.lua
T
TaikoZhongandClaude Fable 5.1 f1744e859b Ending banners: replay the original clips (clear / full combo / donderful / fail)
Uses the engine's new Background:handle_ending / draw_ending hook. The four
ending clips are exported frame by frame (Scripts/game/ending_anim, textures
under game/ending_<kind>_anim) and replayed on the lane at 60 fps: the banner
text reveal, the fans, the two dancers and the confetti all move as in the
original, then loop. Without the engine hook the skin's static plates still
draw as before.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-09 14:07:48 +08:00

492 lines
23 KiB
Lua

-- YataiDON-HSS child override of PyTaikoGreen/Scripts/background/background.lua:
-- byte-copy of the parent plus the ArcadeGauge overlay (3 hooks, marked below).
local DonBG = require("background/bg_objects/donbg")
local BGNormal = require("background/bg_objects/bg_normal")
local BGFever = require("background/bg_objects/bg_fever")
local Fever = require("background/bg_objects/fever")
local Footer = require("background/bg_objects/footer")
local Dancer = require("background/bg_objects/dancer")
local Ending = require("background/bg_objects/ending")
local RendaController = require("background/bg_objects/renda")
local ChibiController = require("background/bg_objects/chibi")
local ArcadeGauge = require("background/bg_objects/arcade_gauge") -- child-only
local EnsoSkip = require("background/bg_objects/enso_skip") -- child-only
local PracticeBackground = require("background/bg_collabs/practice")
local TutorialBackground = require("background/bg_collabs/tutorial")
local A3Background = require("background/bg_collabs/a3")
local AnimalBackground = require("background/bg_collabs/animal")
local ButtoBackground = require("background/bg_collabs/buttoburst")
local DanBackground = require("background/bg_collabs/dan")
local FunassyiBackground = require("background/bg_collabs/funassyi")
local ImasBackground = require("background/bg_collabs/imas")
local ImasSidemBackground = require("background/bg_collabs/imas_sidem")
local OshiriBackground = require("background/bg_collabs/oshiri")
local COLLABS = {
PRACTICE = { PracticeBackground.new, nil, 1 },
TUTORIAL = { TutorialBackground.new, nil, 1 },
A3 = { A3Background.new, "background/collab/a3", 4 },
ANIMAL = { AnimalBackground.new, "background/collab/animal", 5 },
BUTTOBURST = { ButtoBackground.new, "background/collab/buttoburst", 4 },
DAN = { DanBackground.new, "background/collab/dan", 1 },
FUNASSYI = { FunassyiBackground.new, "background/collab/funassyi", 5 },
IMAS = { ImasBackground.new, "background/collab/imas", 5 },
IMAS_CG = { ImasBackground.new, "background/collab/imas_cg", 3 },
IMAS_ML = { ImasBackground.new, "background/collab/imas_ml", 3 },
IMAS_SIDEM = { ImasSidemBackground.new, "background/collab/imas_sidem", 3 },
OSHIRI = { OshiriBackground.new, "background/collab/oshiri", 5 },
}
Background = {}
Background.__index = Background
function Background.new(player_num, bpm, scene_preset)
local self = setmetatable({}, Background)
self.player_num = player_num
self.bpm = bpm
self.scene_preset = scene_preset
self.is_2p = (player_num == 3) -- PlayerNum::TWO_PLAYER
tex.load_animations("background")
-- child-only: arcade soul-gauge overlay, drawn in draw_fore (in front of the HUD)
self.arcade_gauge = ArcadeGauge.new()
-- child-only: 演奏スキップ countdown card / dim / banner.
-- Fed by Background:handle_skip, which GameScreen::push_skip_state calls
-- every frame once the feature is armed.
--
-- ROUND 19 (r19-skip2): the arcade own post-info draw list
-- (datatable/enso_post.bin, target 0/2/3/4/5/6/7 -- every single-player
-- enso target) places `skip.nulstb` (part 30) right after `song_info.nulstb`
-- (part 16) and ahead of only `papamama.nulstb`, i.e. AFTER the title/genre
-- plate and the soul gauge, not before them. So the skip panel (card,
-- numeral, dim, banner -- one Lumen subtree, drawn as one unit at that one
-- point in the list) has to sit on top of the whole HUD for the whole time
-- it is alive, not just draw_fore. `allnet_indicator.lua`
-- (global/allnet_indicator.lua) is the engine last Lua paint hook on GAME
-- (game.cpp draw_overlay: it runs after song_info.draw(), draw_skip(), the
-- transitions), so it is the only place this skin can put something that
-- actually lands on top of song_info and the gauge. `self.enso_skip` is
-- published on the shared Lua globals table for that hook to pick up and
-- draw (all skin scripts run in one sol::state -- ScriptManager::init /
-- LuaScript::load -- so a plain global is visible everywhere); it is
-- cleared in Background:destroy so a stale reference cannot survive into
-- the next screen.
self.enso_skip = EnsoSkip.new()
_G.g_active_enso_skip = self.enso_skip
-- ROUND 52 (r52-lua-divergence-fixes, user bug report 「兩個人的狀態下,
-- collab會出現graphic bug」): a SCENEPRESET song in 2P used to build the
-- collab rig anyway, but every collab background is a 1P LAYOUT -- one
-- full-height stage, dancers at 1P-stage y, a single top band -- with no
-- knowledge of game_2p_offset or the dual donbg bands, so the idol sprite
-- drew clipped behind the 2P lane and the collab's top band landed
-- corrupted over the 2P chrome. The skin's 2P background design is the
-- dual-band donbg rig and nothing else (ROUND 18/49 audit: "2P branch: no
-- bg objects by design"), so 2P now always routes there; a real 2P collab
-- stage rig is future work (needs per-collab 2P geometry).
local entry = (not self.is_2p) and scene_preset and COLLABS[scene_preset] or nil
if entry then
local ctor, path, max_dancers = entry[1], entry[2], entry[3]
if path then
self.collab = ctor(player_num, bpm, path, max_dancers)
else
self.collab = ctor(player_num, bpm)
end
else
-- Standard (non-collab) case
local max_dancers = 5
self.max_dancers = max_dancers
if self.is_2p then
local donbg_index = math.random(1, 6)
self.donbg_1 = DonBG.create(1, "background", donbg_index)
self.donbg_2 = DonBG.create(2, "background", donbg_index)
local skin_cfg = tex.get_skin_config("game_2p_offset")
self.p2_y_offset = skin_cfg and skin_cfg.y or 0
self.is_clear_1 = false
self.is_clear_2 = false
else
self.donbg = DonBG.create(player_num, "background")
self.bg_normal = BGNormal.create(math.random(0, 4), "background")
self.bg_fever = BGFever.create(math.random(0, 3), "background")
self.fever = Fever.create(math.random(0, 3), bpm, "background")
self.dancer = Dancer.create(math.random(0, 20), bpm, "background", max_dancers)
self.footer = Footer.new("background", math.random(0, 2))
end
-- ROUND 58 (r58-2p-background-polish): the cabinet's 2P enso_post set
-- (the reference build target 1) carries TWO renda_effect instances of the SAME
-- clip -- playerNo 0 at y=0 and playerNo 1 at y=804 (the 2P donbg
-- band; = game_2p_offset in skin space). Each seat gets its own
-- 25-slot pool, and a P2 roll rains into the P2 band.
local renda_index = math.random(0, 2)
self.renda = RendaController.new("background", renda_index)
if self.is_2p then
self.renda_2 = RendaController.new("background", renda_index, self.p2_y_offset)
end
self.chibi = ChibiController.new(bpm, "background")
self.is_clear = false
self.is_rainbow = false
-- ROUND 18 (r18-misc): the troupe size is an absolute function of the
-- gauge (see handle_gauge), not a milestone counter, so there is no
-- `last_milestone` to keep in step any more.
self.clear_progress = nil
end
return self
end
function Background:update(current_ms, bpm)
self.bpm = bpm
self.enso_skip:update(current_ms)
if self.collab then
self.collab:update(current_ms, bpm)
else
if self.is_2p then
self.donbg_1:update(current_ms)
self.donbg_2:update(current_ms)
else
self.donbg:update(current_ms)
self.bg_normal:update(current_ms)
self.bg_fever:update(current_ms)
self.fever:update(current_ms, bpm)
self.dancer:update(current_ms, bpm)
end
self.renda:update(current_ms)
if self.renda_2 then self.renda_2:update(current_ms) end
self.chibi:update(current_ms, bpm)
end
end
function Background:handle_good(player_num)
if self.collab then
self.collab:handle_good(player_num)
else
self.chibi:add_chibi(player_num, false)
end
end
function Background:handle_ok(player_num)
if self.collab then
self.collab:handle_ok(player_num)
else
self.chibi:add_chibi(player_num, false)
end
end
function Background:handle_bad(player_num)
if self.collab then
self.collab:handle_bad(player_num)
else
self.chibi:add_chibi(player_num, true)
end
end
function Background:handle_drumroll(player_num)
if self.collab then
self.collab:handle_drumroll(player_num)
else
-- ROUND 58: seat-routed in 2P (arcade target 1 has one renda_effect
-- instance per playerNo; P2's lives in the bottom band).
if player_num == 2 and self.renda_2 then
self.renda_2:add_renda()
else
self.renda:add_renda()
end
end
end
-- ROUND 52 (r52-lua-divergence-fixes): balloons and kusudama no longer spawn
-- renda effects. the reference build `EnsoGraphicRendaEffect::Process` (0x14012BDA0) arms
-- its per-frame effect start ONLY while an active note of kind 6 or 9 is in
-- flight, and the the reference build note-kind table (tlb research enso_options.md:258) is
-- explicit: "6/9 drumroll, 0xA balloon, 0xC kusudama" -- so the effect family
-- belongs to DRUMROLLS alone. The engine's handle_balloon covers both the
-- balloon and the kusudama (player.cpp check_balloon/check_kusudama), neither
-- of which the cabinet decorates. Kept as a delegate for collabs that define
-- their own handler.
function Background:handle_balloon(player_num)
if self.collab then
self.collab:handle_balloon(player_num)
end
end
-- ROUND 14 (r14-dan). `Background` is a WRAPPER that delegates to self.collab, and
-- the engine binds its hooks off THIS object (Background.cpp reads
-- lua_object["handle_dan"]). A handle_dan defined only on DanBackground is
-- therefore invisible to the engine -- `wants_dan()` returns false and the
-- binding never fires at all. This delegate is what makes it reachable; it is
-- additive and no-ops for every collab that does not define the method.
-- See ENGINE_BINDINGS.md "ROUND 14" and background/bg_objects/dan_info.lua.
function Background:handle_dan(player_num, state)
if self.collab and self.collab.handle_dan then
self.collab:handle_dan(player_num, state)
end
end
-- `clear_progress` (ROUND 18) is the 5th argument: clear_start/gauge_max for this
-- chart's difficulty. It is optional -- an engine that does not send it leaves it
-- nil and handle_dancer_count falls back (see there).
-- `flash` (ROUND 19, r19-gauge) is the 6th argument: the engine gauge-up
-- flash's current fade value (1 -> 0), 0 when none is active. Forwarded so
-- arcade_gauge.lua can redraw the flash on ITS OWN 21px segment grid instead
-- of the engine's 12px one -- see arcade_gauge.lua for why the stock flash
-- (still drawn by gauge.cpp, harmlessly hidden under this overlay's redraw)
-- cannot just be left alone.
function Background:handle_gauge(player_num, progress, is_clear, is_rainbow, clear_progress, flash)
-- child-only
-- ROUND 39 (r39-ghostgauge-secondsong): this forwarding call still only
-- passed 5 args after ROUND 37 (r37-duplicate-gauge-clear) added the 6th
-- formal parameter to arcade_gauge.lua's own handle_gauge -- so `flash`
-- (the real 6th arg here) was landing in arcade_gauge's `clear_progress`
-- slot, and arcade_gauge's own `flash` was silently nil every frame,
-- permanently disabling the gauge-up flash redraw. Fixed to forward all
-- six.
self.arcade_gauge:handle_gauge(player_num, progress, is_clear, is_rainbow, clear_progress, flash)
if self.collab then
-- ROUND 52: forward clear_progress (5th arg) so the collab dancer
-- model can divide by the CLEAR point like reference dance_b_* does
-- (collabs that ignore it are unaffected).
self.collab:handle_gauge(player_num, progress, is_clear, is_rainbow, clear_progress)
elseif self.is_2p then
-- Update each player's donbg clear state independently
if player_num == 1 then
self.donbg_1:set_clear(is_clear)
self.is_clear_1 = is_clear
elseif player_num == 2 then
self.donbg_2:set_clear(is_clear)
self.is_clear_2 = is_clear
end
self.is_clear = self.is_clear_1 or self.is_clear_2
self.is_rainbow = is_rainbow
else
self.donbg:set_clear(is_clear)
-- ROUND 52 (r52-lua-divergence-fixes): BOTH edges, matching the reference build
-- BgFever/FeverEffect/DonBg::Process (each caches its flag and plays a
-- clip on every change -- `normal_fever` rising, `fever_normal`
-- falling, re-triggerable per gogo/clear excursion).
if is_clear and not self.is_clear then self.bg_fever:start() end
if not is_clear and self.is_clear then self.bg_fever:stop() end
if is_rainbow and not self.is_rainbow then self.fever:start() end
if not is_rainbow and self.is_rainbow then self.fever:stop() end
self.is_clear = is_clear
self.is_rainbow = is_rainbow
self:handle_dancer_count(progress, is_clear, clear_progress)
end
end
-- ROUND 18 (r18-misc) -- the cabinet's dancer count.
--
-- `App::EnsoGraphicDancer::Process` (reference source `EnsoGraphicDancer.obj.c:2723-2752`)
-- is evaluated EVERY FRAME and is a pure function of the gauge:
--
-- r = current_gauge / CLEAR_POINT -- +327256 / +327164
-- n = clamp(floor(2r + 3), 3, 5) -- min3 troupes (all six normal ones)
-- CheckDancerNum(this, n)
--
-- so: **3 dancers from the first frame of the song** (r = 0), 4 from HALF the
-- clear point, 5 once the gauge reaches the clear point. It is symmetric --
-- the count falls again when the gauge falls -- and 5 is a hard maximum
-- (`do {...} while (v3 < 5)`, `:2828-2895`).
--
-- What was wrong before: `math.floor(progress * 5)` starting from
-- `last_milestone = 1` gave ONE dancer at song start and reached three only at
-- 60 % of the whole bar. That is the user's 「進歌曲其實預設是3隻」.
--
-- The divisor is the CLEAR point, not the full bar: `AiEnsoGameManager.obj.c:1513`
-- tests `gauge >= (float)*(int *)(v16 + 327164)` for the clear, which pins
-- +327164 as the norma line. Our engine's equivalent is
-- `clear_start = {52, 60, 69, 69} / 87` (`src/objects/game/gauge.cpp:20`), passed
-- in as `clear_progress`. FAIL-SOFT: on an engine that does not pass it (the
-- parent, or a pre-round-18 build) we fall back to the HARD/ONI line, and then
-- correct ourselves the moment `is_clear` flips -- the same "learn the
-- difficulty at the flip" trick `arcade_gauge.lua` already uses, because
-- difficulty itself is not bound to Lua.
local GAUGE_MAX = 87.0
local CLEAR_STARTS = { 52, 60, 69 } -- easy / normal / hard+oni
local CLEAR_DEFAULT = 69 / GAUGE_MAX -- 0.79310
function Background:handle_dancer_count(progress, is_clear, clear_progress)
if not self.dancer or not self.dancer.set_dancer_count then return end
if clear_progress and clear_progress > 0 then
self.clear_progress = clear_progress
elseif is_clear and not self.clear_progress then
-- The engine flips is_clear at gauge_length > clear_start - 1, so the
-- progress at the flip is just under clear_start/87; pick the first
-- threshold at or above it.
local p = progress or 0
for _, cs in ipairs(CLEAR_STARTS) do
if p < cs / GAUGE_MAX + 0.0005 then
self.clear_progress = cs / GAUGE_MAX
break
end
end
self.clear_progress = self.clear_progress or CLEAR_DEFAULT
end
local cp = self.clear_progress or CLEAR_DEFAULT
local r = (progress or 0) / cp
local n = math.floor(2 * r + 3)
if n < 3 then n = 3 elseif n > 5 then n = 5 end
-- is_clear flips one gauge unit early; honour it so the fifth dancer and the
-- クリア state arrive on the same frame.
if is_clear then n = 5 end
self.dancer:set_dancer_count(n)
end
function Background:draw_back()
if self.collab then
self.collab:draw_back()
else
if not self.is_2p then
-- ROUND 52: draw the fever backdrop while it is entering, steady
-- OR exiting (the falling-edge fade), with bg_normal beneath
-- whenever it is not fully opaque.
if self.bg_fever:is_visible() then
if not self.bg_fever.transitioned then
self.bg_normal:draw()
end
self.bg_fever:draw()
else
self.bg_normal:draw()
end
end
if self.is_2p then
self.donbg_1:draw(0)
self.donbg_2:draw(self.p2_y_offset)
else
self.donbg:draw()
end
self.renda:draw()
if self.renda_2 then self.renda_2:draw() end
if not self.is_2p then
self.dancer:draw()
-- ROUND 18 (r18-play): the 魂-max crowd goes UNDER the `dai` band.
-- The cabinet's crowd (enso/fever/fever_effect1) is a 1920x540 stage
-- whose art runs all the way to stage row 539 = screen 1079, i.e. it
-- fully overlaps the dai band's screen rows 1014..1080; drawn on top
-- it wipes the 紅白幕 out in both bottom corners, which is what the
-- user reported. The crowd rises from BEHIND the stand, so `footer`
-- (= bg_dai) is painted last. `dancer` was already behind it and stays
-- there -- the dancers' feet stop at ~y 1010, above the band, so the
-- band does not clip them.
-- ROUND 52: gate on the object's own visibility (covers the exit
-- ramp), not the raw is_rainbow flag.
if self.fever:is_visible() then self.fever:draw() end
self.footer:draw()
end
self.chibi:draw()
end
end
-- child-only. The engine only calls this when the skin declares it
-- (Background::wants_skip), and it then suppresses its own skip_counter text.
function Background:handle_skip(player_num, state)
self.enso_skip:handle(state)
end
-- Ending banner (engine hook): the cabinet clip for the result kind
-- ("clear" / "full_combo" / "donderful" / "fail") replaces the engine's drawing
-- when its exported data exists; otherwise the engine keeps drawing its own.
function Background:handle_ending(player_num, kind)
self.endings = self.endings or {}
if self.collab and self.collab.handle_ending then
self.collab:handle_ending(player_num, kind)
end
if Ending.available(kind) then
self.endings[player_num] = Ending.new(kind, player_num, "game")
end
end
function Background:draw_ending(player_num)
local e = self.endings and self.endings[player_num]
if e then e:draw(tex.get_current_ms()) end
end
-- ROUND 80 (r80-gauge-layering-recheck): child-only. The arcade gauge fill
-- USED TO be painted from draw_fore() below, i.e. after the whole GAME HUD --
-- which put the skin's half of the soul gauge on top of every element the
-- cabinet draws OVER the gauge. `datatable/enso_post.bin` target 0 (1P normal
-- enso) lists the graphics in the order EnsoGraphicFactory::CreateEnsoGraphics
-- appends them, and `tamashiigage/tamashii_gage` is row 12 of 33: above
-- donbg / bg_nomal / bg_fever / renda_effect / dancer / bg_dai / gogo_effect /
-- fever_effect / lane / lane_hit / onp_replace, and BELOW don_enso, lane_left,
-- taiko, onp_jump, hit_effect, course, option, score, combo_number,
-- action_result, branch_effect, don_fukidashi, renda_number, name_plate,
-- action_fusen, action_kusudama, song_info, skip and papamama.
--
-- The engine now calls this hook from Player::draw(), immediately after that
-- lane's own Gauge::draw(), so the gauge is ONE layer again. Measured defect
-- it fixes: the note arc / 魂 badge burst (the cabinet's `onp_jump`, row 17)
-- was being clipped dead at the gauge's left edge, screen x 738 -- see
-- MAPPING_hud ROUND 80.
--
-- `player_num` is the lane whose engine gauge just drew; only that record is
-- repainted, so in 2P each lane lands in its own slot.
function Background:draw_gauge(player_num)
self.arcade_gauge:draw_one(player_num)
-- ROUND 90 (r90-danplate-brown-and-place): the collab gets this slot too.
-- GAME_DAN's 魂ゲージ condition plate is `gauge_border`, a CHILD of `gauge_mc`
-- inside the cabinet's tamashii_gauge.nulm, so it belongs in the gauge's own
-- layer, not at the end of the frame -- see bg_collabs/dan.lua:draw_gauge.
if self.collab and self.collab.draw_gauge then
self.collab:draw_gauge(player_num)
end
end
function Background:draw_fore()
if self.collab then
self.collab:draw_fore()
end
-- ROUND 80: the arcade gauge fill moved OUT of here, into draw_gauge()
-- above. Do NOT put it back: this hook runs after the whole GAME HUD.
-- ROUND 19 (r19-skip2): the 演奏スキップ panel used to draw here too, but
-- draw_fore runs BEFORE song_info.draw() and the gauge HUD (game.cpp
-- draw() -> draw_players() -> background->draw_fore() -> draw_overlay()),
-- so its full-screen dim could never reach the title/genre plate or the
-- gauge -- exactly the defect the cabinet capture showed. It now draws
-- from global/allnet_indicator.lua's GAME branch instead, the engine's
-- last Lua paint hook on GAME, which lands after song_info AND the gauge,
-- matching datatable/enso_post.bin's own draw order (skip.nulstb right
-- after song_info.nulstb). See the comment at self.enso_skip's
-- construction above for the full citation.
end
function Background:destroy()
if _G.g_active_enso_skip == self.enso_skip then
_G.g_active_enso_skip = nil
end
if self.endings then
for _, e in pairs(self.endings) do e:destroy() end
self.endings = nil
end
if self.collab then
self.collab:destroy()
else
if self.is_2p then
self.donbg_1:destroy()
self.donbg_2:destroy()
else
self.donbg:destroy()
self.bg_normal:destroy()
self.bg_fever:destroy()
self.fever:destroy()
self.dancer:destroy()
self.footer:destroy()
end
self.renda:destroy()
-- renda_2 shares the same texture folder; unload_folder is dedup-keyed
-- so the second destroy is a safe no-op.
if self.renda_2 then self.renda_2:destroy() end
self.chibi:destroy()
end
end