Files
PyTaikoGreen/Scripts/background/bg_collabs/imas.lua
T
2026-10-10 11:20:40 -04:00

483 lines
21 KiB
Lua

local DonBGBase = require("background/bg_objects/donbg/base")
local Fever = require("background/bg_objects/fever")
local Footer = require("background/bg_objects/footer")
local Dancer = require("background/bg_objects/dancer")
local RendaController = require("background/bg_objects/renda")
local ChibiController = require("background/bg_objects/chibi")
local BaseChibi = require("background/bg_objects/chibi/base")
local ChibiWave = require("background/bg_objects/chibi/wave")
local Timing = require("background/bg_objects/timing")
local pose_at = Timing.pose_at
-- THE IDOLM@STER (IMAS, IMAS_CG, IMAS_ML). The three share the stage, drumroll and footer
-- (same Lumen data and art), loaded from IMAS's folder; the don band, dancers, chibis and
-- line-up are each collab's own.
local IMAS_PATH = "background/collab/imas"
-- Spotlights: additive beams, each tinted one colour
local CYAN, YELLOW, MAGENTA = { 0, 255, 255, 255 }, { 255, 255, 0, 255 }, { 255, 0, 255, 255 }
-- 0..1 opacity of a light that fades in over `fade` beats from `on`, holds, and fades out over
-- `fade` from `off`, in a cycle of `period` beats (a fade-out may wrap past the cycle's end)
local function window(t, on, off, fade, period)
local u = t % period
if u < on then u = u + period end
return math.max(0, math.min(1, (u - on) / fade, (off + fade - u) / fade))
end
-- the stage's glow strip pulses out and back in every 2 beats
local function overlay_fade(t) return math.abs(1 - t % 2) end
-- Normal background: the stage under a glowing strip and five spotlights (cyan, yellow,
-- magenta, cyan, yellow). Two groups take turns: 1, 3, 5 shine for 1.25 beats, then 2 and 4,
-- in a 2.75-beat cycle. Timed in beats.
local NORMAL_SPOT_COLORS = { CYAN, YELLOW, MAGENTA, CYAN, YELLOW }
local NORMAL_SPOT_CYCLE = 2.75
local SPOT_FADE = 0.25
local function normal_spot_fade(t, i)
local on = (i % 2 == 0) and 0 or 1.25
return window(t, on, on + 1.25, SPOT_FADE, NORMAL_SPOT_CYCLE)
end
local function draw_normal_spots(id, t)
for i = 0, 4 do
local fade = normal_spot_fade(t, i)
if fade > 0 then
tex.draw_texture(id, { index = i, color = NORMAL_SPOT_COLORS[i + 1], fade = fade,
mirror = "horizontal", blend = "additive" })
end
end
end
local ImasBGNormal = {}
ImasBGNormal.__index = ImasBGNormal
function ImasBGNormal.new(bpm)
local self = setmetatable({}, ImasBGNormal)
self.name = "bg_0"
tex.load_folder(IMAS_PATH, "bg_normal/" .. self.name)
local function id(key) return tex.get_texture(IMAS_PATH .. "/" .. self.name .. "/" .. key) or 0 end
self._id_bg = id("background")
self._id_overlay = id("overlay")
self._id_spotlight = id("spotlight")
self.beat = 60000 / bpm
self.t = 0
return self
end
function ImasBGNormal:update(current_time_ms, bpm)
if bpm then self.beat = 60000 / bpm end
self.t = self.t + (current_time_ms - (self.last_ms or current_time_ms)) / self.beat
self.last_ms = current_time_ms
end
function ImasBGNormal:draw_background() tex.draw_texture(self._id_bg) end
function ImasBGNormal:draw()
self:draw_background()
local fade = overlay_fade(self.t)
for _ = 1, 2 do tex.draw_texture(self._id_overlay, { fade = fade, blend = "additive" }) end
draw_normal_spots(self._id_spotlight, self.t)
end
function ImasBGNormal:destroy() tex.unload_folder(IMAS_PATH, "bg_normal/" .. self.name) end
-- Clear background: the stage cuts to its lit-up version 0.375 beat in, under four big stars
-- spinning around the stage on their own paths, and two sets of five spotlights taking turns
-- every 1.23 beats. Orange and green floor lights pulse against each other every 2 beats.
-- Losing clear drops the stars and lights at once, switches to the normal spotlights and cuts
-- back to the normal stage after 0.375 beat (it's gone at 0.75). Timed in beats; drawn over
-- bg_normal, which takes over again after.
local CUT_BEATS = 0.375
local LEAVE_BEATS = 0.75
local FEVER_SPOTS = {
{ first = 5, colors = { CYAN, MAGENTA, YELLOW, CYAN, MAGENTA } },
{ first = 10, colors = { YELLOW, MAGENTA, CYAN, YELLOW, MAGENTA } },
}
local FEVER_SPOT_START = 0.775 -- beats after the clear
local FEVER_SPOT_TURN = 7.4 / 6 -- each set shines this long
-- Stars: { beat, x, y, sx, sy, ease } corners of each star's path in a 7.4-beat loop that
-- starts a beat after the clear, as offsets from the star's texture.json position (Lumen
-- mirrors it). sx narrows as the star turns edge-on; it glides straight between corners.
local STAR_LOOP = 7.4
local STAR_START = 1
local STAR_PIVOT = { 142, 128 } -- the star's centre in its image
local STARS = {
{ -- from the far left across to the right edge and back
{ 0, -1228, -145, 0.3, 1.5 }, { 0.35, -1125, -11, 0.33, 1.34 }, { 0.725, -952, 44, 0.6, 1.19, "out" },
{ 0.875, -888, 40, 1, 1.16 }, { 1.725, -480, -86, 1, 1 }, { 2.275, -220, -30, 0.95, 1.07 },
{ 2.825, 33, 32, 0.67, 1.13 }, { 3.425, 282, -43, 0.3, 1.2 }, { 3.95, 41, 32, 0.52, 1.14, "out" },
{ 4.125, -42, 26, 0.9, 1.12 }, { 4.65, -296, -59, 0.9, 1.06 }, { 4.975, -451, -87, 0.9, 1.03 },
{ 5.55, -680, -20, 0.8, 1 }, { 6.35, -966, 44, 0.6, 1 }, { 6.875, -1127, -13, 0.45, 1.1 },
{ 7.15, -1190, -80, 0.37, 1.16, "in" },
},
{ -- leaps at the far left, crosses along the top and leaps at the right
{ 0, -495, -72, 1, 1 }, { 0.625, -783, -12, 0.93, 1 }, { 0.85, -889, -24, 0.9, 1 },
{ 1, -956, -48, 0.4, 1.14 }, { 1.35, -1082, -155, 0.42, 1.45 }, { 1.7, -1148, -308, 0.43, 1.77 },
{ 2.15, -1075, -145, 0.39, 1.43 }, { 2.575, -943, -42, 0.35, 1.11 }, { 2.725, -886, -23, 1, 1 },
{ 2.925, -781, -12, 1, 1 }, { 3.85, -303, -118, 1, 1 }, { 4.425, -11, -37, 1, 1 },
{ 4.675, 112, 2, 0.48, 1.2 }, { 4.925, 211, -75, 0.51, 1.4 }, { 5.175, 252, -202, 0.53, 1.6 },
{ 5.65, 211, -76, 0.49, 1.36 }, { 6.125, 112, 2, 0.44, 1.13 }, { 6.375, 43, -14, 0.41, 1 },
{ 7.025, -298, -118, 0.9, 1 },
},
{ -- from the right, across the top to a leap at the far left and back
{ 0, 298, -7, 0.6, 1.5 }, { 0.7, -1, -108, 0.42, 1.29 }, { 0.85, -67, -118, 0.95, 1.25 },
{ 1.225, -246, -131, 0.92, 1.16 }, { 1.975, -600, -89, 0.86, 0.98 }, { 2.725, -935, 30, 0.8, 0.8 },
{ 3.125, -1048, 40, 0.73, 1.11 }, { 3.9, -1248, -28, 0.6, 1.73 }, { 4.425, -1049, 40, 0.56, 1.35 },
{ 4.625, -968, 34, 0.54, 1.2 }, { 4.95, -814, -1, 1, 1.34 }, { 5.6, -537, -103, 1, 1.3 },
{ 6.225, -254, -131, 1, 1.26 }, { 6.65, -62, -118, 1, 1.23 }, { 6.875, 36, -100, 0.44, 1.22 },
},
{ -- wobbles at the right, then runs the whole stage to the far left and back
{ 0, -460, -73, 1, 1 }, { 1.025, -10, 27, 1, 1.25 }, { 1.275, 108, 38, 0.54, 1.33 },
{ 1.675, 280, -14, 0.4, 1.57 }, { 2, 162, 35, 0.4, 1.55 }, { 2.275, 53, 35, 0.4, 1.53 },
{ 2.575, -64, 17, 1.1, 1.1 }, { 4.025, -637, -90, 1.1, 1.1 }, { 4.625, -876, -34, 1.1, 1.1 },
{ 4.95, -996, -6, 0.35, 1.56 }, { 5.475, -1202, -3, 0.43, 1.98 }, { 5.675, -1122, 5, 0.49, 1.88 },
{ 6.95, -629, -90, 0.87, 1.22 },
},
}
-- as pose tables: each corner glides `to` the next (the last back to the first)
for s, corners in ipairs(STARS) do
local poses = { length = STAR_LOOP }
for i, c in ipairs(corners) do
local n = corners[i + 1] or corners[1]
poses[i] = { c[1], 0, c[2], c[3], sx = c[4], sy = c[5], to = { n[2], n[3], c[6], sx = n[4], sy = n[5] } }
end
STARS[s] = poses
end
local ImasBGFever = {}
ImasBGFever.__index = ImasBGFever
function ImasBGFever.new(bpm)
local self = setmetatable({}, ImasBGFever)
self.name = "bg_fever_0"
tex.load_folder(IMAS_PATH, "bg_fever/" .. self.name)
local function id(key) return tex.get_texture(IMAS_PATH .. "/" .. self.name .. "/" .. key) or 0 end
self._id_bg = id("background")
self._id_overlay = id("overlay")
self._id_spotlight = id("spotlight")
self._id_star = id("star")
self._id_light_orange = id("light_orange")
self._id_light_green = id("light_green")
self.star_info = tex.get_texture_info(self.name, "star")
self.beat = 60000 / bpm
self.state = nil -- nil (hidden), "in", "out"
return self
end
function ImasBGFever:start() self.state, self.t, self.overlay_t = "in", 0, 0 end
function ImasBGFever:stop()
if self.state ~= "in" then return end
self.state, self.t = "out", 0
end
function ImasBGFever:update(current_time_ms, bpm)
if bpm then self.beat = 60000 / bpm end
local dt = (current_time_ms - (self.last_ms or current_time_ms)) / self.beat
self.last_ms = current_time_ms
if not self.state then return end
self.t, self.overlay_t = self.t + dt, self.overlay_t + dt
if self.state == "out" and self.t >= LEAVE_BEATS then self.state = nil end
end
function ImasBGFever:draw_star(poses, t)
local p = pose_at(poses, (t - STAR_START) % STAR_LOOP)
local w, h = self.star_info.width, self.star_info.height
local ox, oy = STAR_PIVOT[1] * p.sx, STAR_PIVOT[2] * p.sy
tex.draw_texture(self._id_star, {
x = p.x + STAR_PIVOT[1], y = p.y + STAR_PIVOT[2], x2 = w * (p.sx - 1), y2 = h * (p.sy - 1),
origin = { ox, oy }, mirror = "horizontal", blend = "additive",
})
end
-- Draws over bg_normal while clear (or leaving it); bg_normal shows its stage before the cut
function ImasBGFever:draw(bg_normal)
if not self.state then return false end
local t = self.t
local fever_bg = (self.state == "in") == (t >= CUT_BEATS)
if fever_bg then tex.draw_texture(self._id_bg) else bg_normal:draw_background() end
tex.draw_texture(self._id_overlay, { fade = overlay_fade(self.overlay_t), blend = "additive" })
if self.state == "out" then
draw_normal_spots(self._id_spotlight, t)
return true
end
for _, star in ipairs(STARS) do self:draw_star(star, t) end
for k, set in ipairs(FEVER_SPOTS) do
local on = FEVER_SPOT_START + (k - 1) * FEVER_SPOT_TURN
local fade = t >= on and window(t, on, on + FEVER_SPOT_TURN, SPOT_FADE, 2 * FEVER_SPOT_TURN) or 0
if fade > 0 then
for i = 0, 4 do
tex.draw_texture(self._id_spotlight, { index = set.first + i, color = set.colors[i + 1],
fade = fade, mirror = "horizontal", blend = "additive" })
end
end
end
local pulse = math.abs(1 - t % 2) -- orange brightens as green dims, and back
tex.draw_texture(self._id_light_orange, { fade = 1 - pulse / 2, blend = "additive" })
tex.draw_texture(self._id_light_green, { fade = 0.5 + pulse / 2, blend = "additive" })
return true
end
function ImasBGFever:destroy() tex.unload_folder(IMAS_PATH, "bg_fever/" .. self.name) end
-- Drumroll: notes fly up and to the right, the default renda_c flight
local RENDA = { ms = { 2000 }, dx = 1560, dy = -1648, x_range = 500, y_range = 20 }
-- IMAS ---------------------------------------------------------------------------------------
-- Don band: the base scrolls one tile left every 5.33s; the clear set scrolls with it under
-- a pink glow strip that pulses every second
local IMAS_DONBG = {
fade = 0.2,
layers = {
{ key = "background", ms = 5333 },
{ key = "glow", clear_only = true, tile = 0, alpha = 0.148, blend = "additive",
period = 1000, pulse = { { 500, -1000 }, { 500, 1000, "out" } } },
},
}
-- Dancers: all five jump up from below, land with a squash, dance a 4-beat routine (a pose a
-- quarter beat, two held for a half) and hop up and drop away when removed. Timed in beats;
-- poses are offsets from y 670, pivoting about Lumen's origin in the images.
local function imas_dancer(rest)
return {
origin = { 152, 152 },
at = { 0, rest },
enter = {
beats = 2,
{ 0, 0, 0, rest + 320, to = { 0, rest - 60, "out" } }, -- jumps up from below
{ 0.675, 0, 0, rest - 60, to = { 0, rest, "in" } },
{ 0.925, 1, 0, rest, to = { nil, nil, nil, sy = 0.92 } }, -- lands with a squash
{ 1.025, 1, 0, rest, sy = 0.95, to = { nil, nil, nil, sy = 1 } },
{ 1.1, 1, 0, rest },
},
dance = Timing.steps({
{ 0, 0.25, x = 0, y = rest }, { 1, 0.25 }, { 2, 0.25 }, { 3, 0.25 }, { 4, 0.25 }, { 5, 0.5 },
{ 6, 0.25 }, { 7, 0.25 }, { 8, 0.25 }, { 9, 0.25 }, { 10, 0.25 }, { 11, 0.25 }, { 12, 0.5 },
{ 13, 0.25 },
}),
leave = {
beats = 1.45,
{ 0, 0, 0, rest },
{ 0.1, 1, 0, rest, to = { 0, rest - 60, "out" } }, -- hops up and drops away
{ 0.5, 1, 0, rest - 60, to = { 0, rest + 320, "in" } },
},
}
end
local IMAS_DANCERS = Dancer.define({ default = imas_dancer(-145), [1] = imas_dancer(-142), [2] = imas_dancer(-148) })
-- Fixed slots (far-left .. far-right), from Lumen's dancer positions; each dancer has its own
local DANCER_CENTERS = { 213, 427, 640, 853, 1067 }
-- (joins fill the centre, left, right, far-left, far-right slots)
-- Chibis for good hits: one of four idols hops in growing from 70% size, then bobs across
-- the don band in a zig-zag, flapping between two poses every half beat
local IMAS_CHIBIS = {}
for key = 0, 3 do
IMAS_CHIBIS[key + 1] = {
key = tostring(key),
run = { y = -1 },
wave = { first = 17, height = 40, beats = 0.85 },
period = 1,
poses = { { 0, 0 }, { 0.5, 1 } },
}
end
-- IMAS CG ------------------------------------------------------------------------------------
-- Three dancers on fixed slots (Lumen's dancer2, dancer1, dancer3 at x 427, 640, 853; index 1,
-- 2, 0), standing 8px under the dancer's base. Same routine as IMAS, but their images are
-- bottom-anchored and the routine borrows frame 0 of the entry once. Timed in beats; poses
-- are offsets from the rest position.
local function cg_dancer(origin)
return {
origin = origin,
at = { 0, 0 },
enter = {
beats = 2,
{ 0, 0, 20, 310, to = { 20, -46, "out" } }, -- jumps up from below
{ 0.65, 0, 20, -46, to = { 20, 4, "in" } },
{ 0.925, 1, 0, 4, to = { 0, 3, "out", sy = 0.95 } }, -- lands with a squash
{ 1.025, 1, 0, 3, sy = 0.95, to = { 0, 0, sy = 1 } },
{ 1.1, 1, 0, 0 },
},
dance = Timing.steps({
{ 0, 0.25, x = 0, y = 0 }, { 1, 0.25 }, { 2, 0.25 }, { 3, 0.25 }, { 4, 0.25 }, { 5, 0.5 },
{ 6, 0.25 }, { 7, 0.25 }, { 0, 0.25, key = "start" }, { 8, 0.25 }, { 9, 0.25 }, { 10, 0.25 },
{ 11, 0.625 }, { 12, 0.125 },
}),
leave = {
beats = 1.45,
{ 0, 0, 0, 4, key = "start", to = { 0, 3, "in", sy = 0.935 } },
{ 0.125, 0, 0, -4, key = "start", sy = 0.943, to = { 0, -50, "out", sy = 1 } }, -- hops up
{ 0.475, 0, 0, -46, key = "end", to = { 0, 374, "in" } }, -- and drops away
},
}
end
local CG_DANCERS = Dancer.define({
[0] = cg_dancer({ 112, 280 }), [1] = cg_dancer({ 112, 280 }), [2] = cg_dancer({ 116, 288 }),
})
-- Chibis for good hits: one of two idols (frames 0, 1) hops in from 70% size, then bobs across
-- the don band in a zig-zag. One image each; the bob is 33px high, a swing every 0.45 beat.
local CG_CHIBIS = {}
for frame = 0, 1 do
CG_CHIBIS[frame + 1] = {
key = "0",
first = frame,
hop = { from = { -428, 13 }, peak = { -354, -36 }, up = 0.25 },
run = { x = -294, y = 5, distance = 1222 },
wave = { first = -29, height = 33, beats = 0.45 },
}
end
local VARIANTS = {
[IMAS_PATH] = {
donbg = IMAS_DONBG,
dancers = IMAS_DANCERS,
chibis = IMAS_CHIBIS,
fever = { rise = 210, bounce = 70, drop = 400, poses = true },
-- dancer index by join order, and the slots' draw order
join_order = { 4, 3, 2, 1, 0 },
draw_order = { 5, 1, 4, 2, 3 },
},
["background/collab/imas_cg"] = {
donbg = IMAS_DONBG,
dancers = CG_DANCERS,
chibis = CG_CHIBIS,
fever = { rise = 249, bounce = 70, drop = 439, poses = true },
join_order = { 2, 1, 0 },
draw_order = { 2, 4, 3 },
},
-- not reviewed yet: the default band, dancers and chibis
["background/collab/imas_ml"] = { fever = { rise = 249, bounce = 70, drop = 439, poses = true } },
}
-- Background factory (IMAS, IMAS_CG, IMAS_ML)
local ImasBackground = {}
ImasBackground.__index = ImasBackground
function ImasBackground.new(player_num, bpm, path, max_dancers)
local self = setmetatable({}, ImasBackground)
local v = VARIANTS[path]
self.variant = v
self.path = path
self.donbg = DonBGBase.new(1, player_num, path, v.donbg)
self.bg_normal = ImasBGNormal.new(bpm)
self.bg_fever = ImasBGFever.new(bpm)
self.footer = Footer.new(IMAS_PATH, 0)
self.fever = Fever.new(bpm, path, v.fever)
self.dancer = Dancer.BaseDancerGroup.new(0, bpm, max_dancers, path, v.dancers)
if v.dancers then
local by_index = {}
for _, d in ipairs(self.dancer.dancers) do by_index[d.index] = d end
for i, index in ipairs(v.join_order) do self.dancer.dancers[i] = by_index[index] end
end
self.renda = RendaController.new(IMAS_PATH, 0, RENDA)
self.chibi = ChibiController.new(bpm, path, 0)
self.max_dancers = max_dancers
self.is_clear = false
self.is_rainbow = false
self.last_milestone = 1
return self
end
function ImasBackground:update(current_ms, bpm)
self.donbg:update(current_ms, bpm)
self.bg_normal:update(current_ms, bpm)
self.bg_fever:update(current_ms, bpm)
self.fever:update(current_ms, bpm)
self.dancer:update(current_ms, bpm)
self.renda:update(current_ms)
self.chibi:update(current_ms, bpm)
end
function ImasBackground:add_chibi(player_num)
local chibis = self.variant.chibis
if chibis then
BaseChibi.add(self.chibi, chibis[math.random(#chibis)], self.path, player_num, ChibiWave, "chibi_0")
else
self.chibi:add_chibi(player_num, false)
end
end
function ImasBackground:handle_good(player_num) self:add_chibi(player_num) end
function ImasBackground:handle_ok(player_num) self:add_chibi(player_num) end
function ImasBackground:handle_bad(player_num) self.chibi:add_chibi(player_num, true) end
function ImasBackground:handle_drumroll(player_num) self.renda:add_renda() end
function ImasBackground:handle_balloon(player_num) self.renda:add_renda() end
function ImasBackground:handle_gauge(player_num, progress, is_clear, is_rainbow)
self.donbg:set_clear(is_clear)
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
local md = self.max_dancers
local current_milestone
if is_clear then
current_milestone = md
else
current_milestone = math.min(md - 1, math.floor(progress * md))
end
if current_milestone > self.last_milestone then
for _ = 1, current_milestone - self.last_milestone do self.dancer:add_dancer() end
self.last_milestone = current_milestone
elseif current_milestone < self.last_milestone then
for _ = 1, self.last_milestone - current_milestone do self.dancer:leave_dancer() end
self.last_milestone = current_milestone
end
end
function ImasBackground:draw_back()
if not self.bg_fever:draw(self.bg_normal) then self.bg_normal:draw() end
self.footer:draw()
self.donbg:draw()
self.renda:draw()
if self.variant.dancers then
self.dancer:draw_slots(DANCER_CENTERS, self.variant.draw_order)
else
self.dancer:draw()
end
self.fever:draw()
self.chibi:draw()
end
function ImasBackground:draw_fore() end
function ImasBackground:destroy()
self.donbg:destroy()
self.bg_normal:destroy()
self.bg_fever:destroy()
self.fever:destroy()
self.dancer:destroy()
self.footer:destroy()
self.renda:destroy()
self.chibi:destroy()
end
-- Export the factory
local Imas = {}
Imas.new = ImasBackground.new
-- Callable as a collab constructor: Imas.new(player_num, bpm, path, max_dancers)
setmetatable(Imas, {
__call = function(_, player_num, bpm, path, max_dancers)
return ImasBackground.new(player_num, bpm, path, max_dancers)
end
})
return Imas