Files
PyTaikoGreen/Scripts/background/bg_collabs/miku.lua
T
2026-10-09 01:24:13 -04:00

380 lines
15 KiB
Lua

local DonBGBase = require("background/bg_objects/donbg/base")
local DonBG = require("background/bg_objects/donbg")
local Fever = require("background/bg_objects/fever")
local Footer = require("background/bg_objects/footer")
local Dancer = require("background/bg_objects/dancer")
local ChibiController = require("background/bg_objects/chibi")
local BaseChibi = require("background/bg_objects/chibi/base")
local RendaController = require("background/bg_objects/renda")
local Timing = require("background/bg_objects/timing")
local COLLAB_PATH = "background/collab/miku"
-- fever line-up: two poses, 70px bounce
local FEVER_SPEC = { bounce = 70, poses = true }
local MAX_DANCERS = 5
local chain_moves, frame_change, sum, Loop =
Timing.chain_moves, Timing.frame_change, Timing.sum, Timing.Loop
-- Normal background: a DJ booth whose three monitors spell out MIKU, a flickering
-- glow over the stage and thumping speakers on both sides. Plain ms timings: the
-- monitors run on their own loops rather than the song's beat.
-- Right monitor: powers on, glitches into M, I, K, U, glitches out and powers off again
local SCREEN_R = {
{ 0, 333 }, { 1, 117 }, { 2, 67 }, { 1, 117 }, { 2, 67 }, { 1, 133 }, { 2, 167 },
{ 3, 667 }, { 4, 667 }, { 5, 667 }, { 6, 683 },
{ 1, 117 }, { 2, 67 }, { 1, 117 }, { 2, 67 }, { 1, 133 }, { 2, 200 }, { 0, 350 },
}
-- its opacity, in thousandths: fades in when the loop starts and again when it powers off
local SCREEN_R_FADE = { { 100, 1000, "out" }, { 4283, 0 }, { 0, -1000 }, { 333, 1000 } }
local SCREEN_C = {
{ 0, 917 }, { 1, 800 }, { 2, 883 },
{ 3, 117 }, { 4, 50 }, { 3, 117 }, { 4, 83 }, { 3, 117 }, { 4, 67 },
{ 3, 117 }, { 4, 50 }, { 3, 117 }, { 4, 83 }, { 3, 117 }, { 5, 700 },
}
local SCREEN_L = {
{ 0, 567 }, { 1, 350 },
{ 2, 67 }, { 3, 67 }, { 2, 67 }, { 3, 117 }, { 2, 67 }, { 3, 33 }, { 2, 67 },
{ 3, 200 }, { 2, 67 }, { 3, 33 }, { 2, 67 }, { 3, 383 },
{ 4, 833 }, { 5, 783 },
}
local BAND_MOVE = { { 3250, -56 } } -- scanline sweeping up the centre monitor, then gone
local BAND_START = 28
-- glow opacity in thousandths: three quick dips and a slow one
local GLOW_FLICKER = {
{ 67, -200 }, { 67, 200 }, { 67, -200 }, { 67, 200 }, { 67, -200 }, { 117, 200 },
{ 250, -290 }, { 83, 290 },
}
local GLOW_OPACITY = 0.84
local SPEAKER_PULSE = { { 150, -50, "out" }, { 183, 50, "in" } } -- scale in thousandths
local MikuBGNormal = {}
MikuBGNormal.__index = MikuBGNormal
function MikuBGNormal.new()
local self = setmetatable({}, MikuBGNormal)
self.name = "bg_0"
tex.load_folder(COLLAB_PATH, "bg_normal/" .. self.name)
local function id(key) return tex.get_texture(COLLAB_PATH .. "/" .. self.name .. "/" .. key) or 0 end
self._id_bg = id("background")
self._id_screen_l = id("screen_l")
self._id_screen_c = id("screen_c")
self._id_screen_r = id("screen_r")
self._id_band = id("band")
self._id_glow = id("glow")
self._id_speaker = id("speaker")
self.speaker_width = tex.get_texture_info(self.name, "speaker").width
local scale = tex.get_screen_scale()
self.screen_l = Loop.new(3767, function()
local f = frame_change(SCREEN_L, 1)
f:start()
return { frames = f }
end)
self.screen_c = Loop.new(4333, function()
local f = frame_change(SCREEN_C, 1)
f:start()
return { frames = f, band = chain_moves(BAND_MOVE, 1, scale) }
end)
self.screen_r = Loop.new(4733, function()
local f = frame_change(SCREEN_R, 1)
f:start()
return { frames = f, fade = chain_moves(SCREEN_R_FADE, 1, 1) }
end)
self.glow = Loop.new(850, function() return { flicker = chain_moves(GLOW_FLICKER, 1, 1) } end)
self.speaker = Loop.new(333, function() return { pulse = chain_moves(SPEAKER_PULSE, 1, 1) } end)
self.band_start = math.floor(BAND_START * scale)
return self
end
function MikuBGNormal:update(current_time_ms)
self.screen_l:update(current_time_ms)
self.screen_c:update(current_time_ms)
self.screen_r:update(current_time_ms)
self.glow:update(current_time_ms)
self.speaker:update(current_time_ms)
end
function MikuBGNormal:draw()
if not self.glow.anims then return end -- not updated yet
tex.draw_texture(self._id_bg)
-- the right monitor is off (opacity 0) at the start of its loop
tex.draw_texture(self._id_screen_r, {
frame = self.screen_r.anims.frames.attribute,
fade = sum(self.screen_r.anims.fade) / 1000,
})
tex.draw_texture(self._id_screen_c, { frame = self.screen_c.anims.frames.attribute })
local band = self.screen_c.anims.band[1]
if not band.is_finished then
tex.draw_texture(self._id_band, { y = self.band_start + band.attribute })
end
tex.draw_texture(self._id_screen_l, { frame = self.screen_l.anims.frames.attribute })
tex.draw_texture(self._id_glow, {
fade = GLOW_OPACITY * (1 + sum(self.glow.anims.flicker) / 1000),
blend = "additive",
})
-- speakers shrink towards their outer edge
local s = 1 + sum(self.speaker.anims.pulse) / 1000
local shift = self.speaker_width * (1 - s) / 2
tex.draw_texture(self._id_speaker, { index = 0, scale = s, center = true, x = -shift })
tex.draw_texture(self._id_speaker, { index = 1, scale = s, center = true, x = shift, mirror = "horizontal" })
end
function MikuBGNormal:destroy() tex.unload_folder(COLLAB_PATH, "bg_normal/" .. self.name) end
local MIKU_DONBG_FAST_X = -164 -- don band fast layer's tile phase
-- Chibi Miku for good hits: hops in growing from 70% size, then runs straight across
-- the don band (no bounce). Bad hits play the shared tamashii.
local MIKU_CHIBI = {
key = "1",
period = 1,
poses = { { 0, 0 }, { 0.2, 1 }, { 0.4, 2 }, { 0.6, 3 }, { 0.8, 4 } },
}
-- Clear background: the panel drops in from above and bounces, then the light rays fade
-- in; losing clear drops it away again. Transitions are timed in beats, the tile scroll
-- in plain ms.
local BG_FEVER_SCROLL_MS = 1333 -- one tile
local BG_FEVER_DROP_Y0 = -360
local BG_FEVER_DROP = { { 1, 360, "in" }, { 0.425, -50, "out" }, { 0.325, 50, "in" } }
local BG_FEVER_RAYS_IN = { { 1, 0 }, { 0.75, 1000 } } -- opacity in thousandths
local BG_FEVER_FALL = { { 1, 390 } }
local BG_FEVER_RAYS_OUT = 0.65
local MikuBGFever = {}
MikuBGFever.__index = MikuBGFever
function MikuBGFever.new(bpm)
local self = setmetatable({}, MikuBGFever)
self.name = "bg_fever_0"
tex.load_folder(COLLAB_PATH, "bg_fever/" .. self.name)
self.tile = tex.get_texture_info(self.name, "background").width
self._id_bg = tex.get_texture(COLLAB_PATH .. "/" .. self.name .. "/" .. "background") or 0
self._id_ray = tex.get_texture(COLLAB_PATH .. "/" .. self.name .. "/" .. "ray") or 0
self.beat = 60000 / bpm
self.scroll = anim.move(BG_FEVER_SCROLL_MS, { total_distance = -self.tile, loop = true })
self.scroll:start()
self.state = nil -- nil (hidden), "in", "out"
return self
end
function MikuBGFever:y() return self.offset + sum(self.drop) end
function MikuBGFever:ray_opacity() return (self.ray_offset + sum(self.rays)) / 1000 end
function MikuBGFever:start()
local scale = tex.get_screen_scale()
self.state = "in"
self.offset = math.floor(BG_FEVER_DROP_Y0 * scale)
self.drop = chain_moves(BG_FEVER_DROP, self.beat, scale)
self.ray_offset = 0
self.rays = chain_moves(BG_FEVER_RAYS_IN, self.beat, 1)
end
-- falls away from wherever it is, rays fading from their current opacity
function MikuBGFever:stop()
if not self.state then return end
local opacity = math.floor(self:ray_opacity() * 1000)
self.state = "out"
self.offset = self:y()
self.drop = chain_moves(BG_FEVER_FALL, self.beat, tex.get_screen_scale())
self.ray_offset = opacity
self.rays = chain_moves({ { BG_FEVER_RAYS_OUT, -opacity } }, self.beat, 1)
end
function MikuBGFever:update(current_time_ms, bpm)
if bpm then self.beat = 60000 / bpm end
self.scroll:update(current_time_ms)
if not self.state then return end
for _, m in ipairs(self.drop) do m:update(current_time_ms) end
for _, m in ipairs(self.rays) do m:update(current_time_ms) end
if self.state == "out" and self.drop[#self.drop].is_finished then self.state = nil end
end
function MikuBGFever:draw()
if not self.state then return end
local y = self:y()
for i = 0, 4 do
tex.draw_texture(self._id_bg, { x = i * self.tile + self.scroll.attribute, y = y })
end
local fade = self:ray_opacity()
tex.draw_texture(self._id_ray, { index = 0, fade = fade })
tex.draw_texture(self._id_ray, { index = 1, fade = fade, mirror = "horizontal" })
end
function MikuBGFever:destroy() tex.unload_folder(COLLAB_PATH, "bg_fever/" .. self.name) end
-- Dancers
-- All timings are in beats so the dancers follow the song's BPM.
-- One beat of the small dancers: a pose held most of the beat, then a short in-between
local function small_dance(s)
return {
{ s, 0.175 }, { s + 1, 0.55 }, { s + 2, 0.275 },
{ s + 3, 0.725 }, { s + 4, 0.275 },
{ s + 1, 0.725 }, { s + 2, 0.275 },
{ s + 3, 0.725 }, { s + 4, 0.275 },
}
end
-- poses are offsets from where each dancer stands, pivoting about its feet (bottom-left)
local SMALL_DANCER = {
origin = { 0, 200 },
enter = {
beats = 2,
{ 0, 0, 0, 213 },
{ 0.35, 0, 0, 213, to = { 0, -43, "out" } }, -- jumps up from below, hangs
{ 0.825, 0, 0, -43 },
{ 1.125, 0, 0, -43, to = { 0, 0, "in" } },
{ 1.275, 0, 0, 0, to = { sy = 0.95 } }, -- lands with a squash
{ 1.35, 0, 0, 0, sy = 0.95, to = { sy = 1 } },
{ 1.45, 0, 0, 0 },
},
dance = Timing.steps(small_dance(0), small_dance(5)),
leave = {
beats = 1.425,
{ 0, 0, 0, 0 },
{ 0.1, 0, 0, 0, to = { 0, -100, "out" } }, -- hops up and drops away
{ 0.475, 0, 0, -100, to = { 0, 214, "in" } },
},
}
local MIKU_A = {
{ 2, 0.1 }, { 3, 0.075 }, { 4, 0.55 }, { 5, 0.075 }, { 6, 0.2 },
{ 7, 0.725 }, { 8, 0.075 }, { 9, 0.2 },
{ 4, 0.725 }, { 5, 0.075 }, { 6, 0.2 },
{ 7, 0.725 }, { 8, 0.075 }, { 9, 0.2 },
}
local MIKU_B = {
{ 10, 0.1 }, { 11, 0.075 }, { 12, 0.55 }, { 13, 0.075 }, { 14, 0.2 },
{ 15, 0.725 }, { 16, 0.075 }, { 17, 0.2 },
{ 12, 0.725 }, { 13, 0.075 }, { 14, 0.2 },
{ 18, 0.725 }, { 16, 0.075 }, { 17, 0.2 },
}
local MIKU_FINALE = {
{ 19, 0.35 }, { 20, 0.325 }, { 21, 0.325 }, { 22, 0.3 },
{ 23, 0.35 }, { 24, 0.35 }, { 25, 0.35 }, { 26, 0.375 }, { 27, 1.275 },
}
local MIKU_DANCER = {
origin = { 0, 304 },
enter = {
beats = 2,
{ 0, 0, 0, 343 },
{ 0.35, 0, 0, 343, to = { 0, -69, "out" } },
{ 0.925, 0, 0, -69, to = { 0, 0, "in" } },
{ 1.2, 1, 0, 0 },
{ 1.225, 1, 0, 0, to = { sy = 0.95 } },
{ 1.25, 1, 0, 0, sy = 0.95, to = { sy = 1 } },
{ 1.325, 1, 0, 0 },
},
dance = Timing.steps(MIKU_A, MIKU_B, MIKU_A, MIKU_FINALE),
}
local MIKU_INDEX = 4
local MIKU_DANCERS = Dancer.define({ [MIKU_INDEX] = MIKU_DANCER, default = SMALL_DANCER })
-- Dancers stand on fixed slots (far-left .. far-right) instead of the group's even spacing,
-- since Miku's frames are wider than the others
local DANCER_CENTERS = { 173, 387, 640, 893, 1107 }
local DANCER_DRAW_ORDER = { 5, 1, 4, 2, 3 } -- outer pairs first, centre on top
-- Background
local MikuBackground = {}
MikuBackground.__index = MikuBackground
function MikuBackground.new(player_num, bpm)
local self = setmetatable({}, MikuBackground)
self.donbg = DonBGBase.new(1, player_num, COLLAB_PATH, DonBG.parallax(MIKU_DONBG_FAST_X))
self.bg_normal = MikuBGNormal.new()
self.bg_fever = MikuBGFever.new(bpm)
self.footer = Footer.new(COLLAB_PATH, 0, { false, true })
self.fever = Fever.new(bpm, COLLAB_PATH, FEVER_SPEC)
self.dancer = Dancer.BaseDancerGroup.new(0, bpm, MAX_DANCERS, COLLAB_PATH, MIKU_DANCERS)
for i, d in ipairs(self.dancer.dancers) do
if d.index == MIKU_INDEX then
self.dancer.dancers[i], self.dancer.dancers[1] = self.dancer.dancers[1], self.dancer.dancers[i]
break
end
end
self.renda = RendaController.new(COLLAB_PATH, 0, RendaController.ACROSS)
self.chibi = ChibiController.new(bpm, COLLAB_PATH, 1)
self.is_clear = false
self.is_rainbow = false
self.last_milestone = 1
return self
end
function MikuBackground:update(current_ms, bpm)
self.donbg:update(current_ms, bpm)
self.bg_normal:update(current_ms)
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 MikuBackground:handle_good(player_num) BaseChibi.add(self.chibi, MIKU_CHIBI, COLLAB_PATH, player_num) end
function MikuBackground:handle_ok(player_num) BaseChibi.add(self.chibi, MIKU_CHIBI, COLLAB_PATH, player_num) end
function MikuBackground:handle_bad(player_num) self.chibi:add_chibi(player_num, true) end
function MikuBackground:handle_drumroll(player_num) self.renda:add_renda() end
function MikuBackground:handle_balloon(player_num) self.renda:add_renda() end
function MikuBackground: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 current_milestone
if is_clear then
current_milestone = MAX_DANCERS
else
current_milestone = math.min(MAX_DANCERS - 1, math.floor(progress * MAX_DANCERS))
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 MikuBackground:draw_back()
self.bg_normal:draw()
self.bg_fever:draw()
self.footer:draw()
self.donbg:draw()
self.renda:draw()
self.dancer:draw_slots(DANCER_CENTERS, DANCER_DRAW_ORDER)
self.fever:draw()
self.chibi:draw()
end
function MikuBackground:draw_fore() end
function MikuBackground: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
return MikuBackground