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

363 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 RendaController = require("background/bg_objects/renda")
local ChibiController = require("background/bg_objects/chibi")
local BaseChibi = require("background/bg_objects/chibi/base")
local Timing = require("background/bg_objects/timing")
local COLLAB_PATH = "background/collab/gumi"
-- fever line-up: two poses, 70px bounce
local FEVER_SPEC = { bounce = 70, poses = true }
local MAX_DANCERS = 5
local chain_moves, frame_change, sum = Timing.chain_moves, Timing.frame_change, Timing.sum
-- Normal background: the stage screens spell out G, U, M, I, one letter every 1.5 beats,
-- the next letter fading in over the last half beat. Timed in beats.
local BG_NORMAL_PANEL = { { 1, 0 }, { 0.5, 1000 } } -- next panel's opacity in thousandths
local GumiBGNormal = {}
GumiBGNormal.__index = GumiBGNormal
function GumiBGNormal.new(bpm)
local self = setmetatable({}, GumiBGNormal)
self.name = "bg_0"
tex.load_folder(COLLAB_PATH, "bg_normal/" .. self.name)
self.frames = tex.get_texture_info(self.name, "background").frame_count
self._id_bg = tex.get_texture(COLLAB_PATH .. "/" .. self.name .. "/" .. "background") or 0
self.beat = 60000 / bpm
self.panel = 0
self.fade = chain_moves(BG_NORMAL_PANEL, self.beat, 1)
return self
end
-- rebuilt per panel so a tempo change takes effect on the next letter
function GumiBGNormal:update(current_time_ms, bpm)
if bpm then self.beat = 60000 / bpm end
for _, m in ipairs(self.fade) do m:update(current_time_ms) end
if self.fade[#self.fade].is_finished then
self.panel = (self.panel + 1) % self.frames
self.fade = chain_moves(BG_NORMAL_PANEL, self.beat, 1)
end
end
function GumiBGNormal:draw()
tex.draw_texture(self._id_bg, { frame = self.panel })
tex.draw_texture(self._id_bg, { frame = (self.panel + 1) % self.frames, fade = sum(self.fade) / 1000 })
end
function GumiBGNormal:destroy() tex.unload_folder(COLLAB_PATH, "bg_normal/" .. self.name) end
-- Clear background: a white flash over the stage, then the screens cycle through eight
-- panels, one a beat, with a white pulse and pulsing spotlights on every beat. Losing clear
-- cuts to the dark stage, flickers the last panel and fades back to the normal stage.
-- Timed in beats; drawn over bg_normal, which shows through before and after.
local BG_FEVER_PANELS = 8 -- frame_order in texture.json
local BG_FEVER_FLASH = { { 0.375, 1000, "out" }, { 0.75, -1000, "in" } } -- entry flash
local BG_FEVER_LEAD = 0.375 -- panels start under it
local BG_FEVER_SPOTS = { { 0.475, 0 }, { 1.025, 1000 } } -- spotlights fade in
-- per beat: the next panel fades in at the end; additive white pulse; spotlight pulse
local BG_FEVER_FADE = { { 0.625, 0 }, { 0.375, 1000 } }
-- a full white pulse is too strong in game; toned down to 40% (peak in thousandths) so the stage stays visible
local BG_FEVER_PULSE = { { 0.375, 400, "in" }, { 0, -55 }, { 0.325, -345, "out" } }
local BG_FEVER_SPOT = { { 0.375, 0 }, { 0, -43 }, { 0.575, -957 } } -- from 1000
-- exit: the first panel flickers over the dark stage, then fades out
local BG_FEVER_FLICKER = {
{ 0.025, 0 }, { 0, 1000 }, { 0.15, 0 }, { 0, -1000 }, { 0.025, 0 }, { 0, 1000 }, { 0.1, 0 },
{ 0, -1000 }, { 0.025, 0 }, { 0, 1000 }, { 0.1, 0 }, { 0, -1000 }, { 0.025, 0 }, { 0, 1000 },
{ 0.525, -1000 },
}
local BG_FEVER_FLICKER_FRAMES = { { 0, 0.2 }, { 2, 0.125 }, { 0, 0.65 } }
local BG_FEVER_SPOTS_OUT = 0.55
local LIGHT_FRAMES = { 0, 1, 1, 2, 2, 3, 3 } -- texture.json "light" indices
local LIGHT_MIRROR = { false, false, true, false, true, false, true }
local GumiBGFever = {}
GumiBGFever.__index = GumiBGFever
function GumiBGFever.new(bpm)
local self = setmetatable({}, GumiBGFever)
self.name = "bg_fever_0"
tex.load_folder(COLLAB_PATH, "bg_fever/" .. 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_flash = id("flash")
self._id_dark = id("dark")
self._id_light = id("light")
self.beat = 60000 / bpm
self.state = nil -- nil (hidden), "in", "out"
return self
end
local function update_all(moves, current_time_ms)
for _, m in ipairs(moves) do m:update(current_time_ms) end
end
function GumiBGFever:spots() return (self.spots_offset + sum(self.spot_env)) / 1000 end
function GumiBGFever:next_beat()
self.fade = chain_moves(BG_FEVER_FADE, self.beat, 1)
self.pulse = chain_moves(BG_FEVER_PULSE, self.beat, 1)
self.spot = chain_moves(BG_FEVER_SPOT, self.beat, 1)
end
function GumiBGFever:start()
self.state = "in"
self.flash = chain_moves(BG_FEVER_FLASH, self.beat, 1)
self.spots_offset = 0
self.spot_env = chain_moves(BG_FEVER_SPOTS, self.beat, 1)
self.lead = anim.move(BG_FEVER_LEAD * self.beat, { total_distance = 1 })
self.lead:start()
self.panel = 0
self.fade = nil -- the beat loop starts once the lead is over
end
-- spotlights fade from wherever they are
function GumiBGFever:stop()
if self.state ~= "in" then return end
local spots = math.floor(self:spots() * 1000)
self.state = "out"
self.spots_offset = spots
self.spot_env = chain_moves({ { BG_FEVER_SPOTS_OUT, -spots } }, self.beat, 1)
self.flicker = chain_moves(BG_FEVER_FLICKER, self.beat, 1)
self.flicker_frame = frame_change(BG_FEVER_FLICKER_FRAMES, self.beat)
self.flicker_frame:start()
end
function GumiBGFever:update(current_time_ms, bpm)
if bpm then self.beat = 60000 / bpm end
if not self.state then return end
update_all(self.spot_env, current_time_ms)
update_all(self.flash, current_time_ms)
self.lead:update(current_time_ms)
if self.state == "out" then
update_all(self.flicker, current_time_ms)
self.flicker_frame:update(current_time_ms)
if self.flicker[#self.flicker].is_finished then self.state = nil end
end
if not self.lead.is_finished then return end
if not self.fade then self:next_beat() end
update_all(self.fade, current_time_ms)
update_all(self.pulse, current_time_ms)
update_all(self.spot, current_time_ms)
if self.fade[#self.fade].is_finished then
self.panel = (self.panel + 1) % BG_FEVER_PANELS
self:next_beat()
end
end
function GumiBGFever:draw_spots()
if not self.fade then return end
local fade = self:spots() * (1000 + sum(self.spot)) / 1000
for i, frame in ipairs(LIGHT_FRAMES) do
tex.draw_texture(self._id_light, {
index = i - 1, frame = frame, fade = fade, blend = "additive",
mirror = LIGHT_MIRROR[i] and "horizontal" or nil,
})
end
end
function GumiBGFever:draw()
if not self.state then return end
if self.state == "in" then
if self.fade then
tex.draw_texture(self._id_bg, { frame = self.panel })
tex.draw_texture(self._id_bg, { frame = (self.panel + 1) % BG_FEVER_PANELS, fade = sum(self.fade) / 1000 })
tex.draw_texture(self._id_flash, { fade = sum(self.pulse) / 1000, blend = "additive" })
end
tex.draw_texture(self._id_flash, { fade = sum(self.flash) / 1000 })
else
tex.draw_texture(self._id_dark)
tex.draw_texture(self._id_bg, { frame = self.flicker_frame.attribute, fade = sum(self.flicker) / 1000 })
end
self:draw_spots()
end
function GumiBGFever: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. Each jumps up from below,
-- lands with a squash, then loops a 16-beat routine; the small ones hop off when removed.
-- Small dancers: pose 0.725 beat, in-between 0.275 beat. Dancer 3 ends its routine on frame 18.
local function small_dance(last)
return {
{ 1, 0.725 }, { 2, 0.275 }, { 3, 0.725 }, { 4, 0.275 },
{ 1, 0.725 }, { 2, 0.275 }, { 3, 0.725 }, { 4, 0.275 },
{ 0, 0.725 }, { 5, 0.275 }, { 6, 0.725 }, { 7, 0.275 },
{ 0, 0.725 }, { 8, 0.275 }, { 9, 0.45 }, { 10, 0.275 }, { 0, 0.275 },
{ 11, 0.725 }, { 12, 0.275 }, { 13, 0.725 }, { 14, 0.275 },
{ 11, 0.725 }, { 12, 0.275 }, { 13, 0.725 }, { 15, 0.275 },
{ 16, 1 }, { 17, 0.475 }, { 16, 0.525 }, { 17, 1.725 }, { last, 0.275 },
}
end
-- poses are offsets from where each dancer stands, pivoting about its feet (bottom-left)
local function small_dancer(last)
return {
origin = { 0, 200 },
enter = {
beats = 2,
{ 0, 0, 0, 213 },
{ 0.35, 0, 0, 213, to = { 0, -43, "out" } }, -- jumps up from below
{ 0.925, 0, 0, -43, to = { 0, 0, "in" } },
{ 1.2, 0, 0, 0, to = { nil, nil, "out", sy = 0.94 } }, -- lands with a squash
{ 1.3, 0, 0, 0, sy = 0.94, to = { nil, nil, "in", sy = 1 } },
{ 1.425, 0, 0, 0 },
},
dance = Timing.steps(small_dance(last)),
leave = {
beats = 1.325,
{ 0, 0, 0, 0, to = { nil, nil, "out", sy = 0.93 } }, -- crouches
{ 0.125, 0, 0, 0, sy = 0.93, to = { nil, nil, "in", sy = 1 } },
{ 0.25, 0, 0, -21, to = { 0, -100, "out" } }, -- hops up and drops away
{ 0.475, 0, 0, -100, to = { 0, 214, "in" } },
},
}
end
-- GUMI in the centre: lands on her second entry frame, which sits 13px higher
local GUMI_DANCER = {
origin = { 0, 296 },
enter = {
beats = 2,
{ 0, 0, 0, 366 },
{ 0.35, 0, 0, 366, to = { 0, -40, "out" } },
{ 0.925, 0, 0, -40, to = { 0, 13, "in" } },
{ 1.175, 0, 0, 0 },
{ 1.2, 1, 0, 0, to = { nil, nil, "out", sy = 0.95 } },
{ 1.325, 1, 0, 0, sy = 0.95, to = { nil, nil, "in", sy = 1 } },
{ 1.45, 1, 0, 0 },
},
dance = Timing.steps({
{ 2, 0.725 }, { 3, 0.275 }, { 4, 0.725 }, { 5, 0.275 },
{ 2, 0.725 }, { 3, 0.275 }, { 6, 0.725 }, { 7, 0.275 },
{ 8, 0.725 }, { 9, 0.275 }, { 10, 0.725 }, { 11, 0.275 },
{ 12, 0.275 }, { 13, 0.45 }, { 14, 0.275 },
{ 15, 0.725 }, { 16, 0.275 }, { 17, 0.725 }, { 18, 0.275 },
{ 19, 0.725 }, { 20, 0.275 }, { 17, 0.725 }, { 18, 0.275 }, { 19, 0.725 }, { 20, 0.275 },
{ 21, 1 }, { 22, 0.5 }, { 23, 0.5 }, { 24, 1.725 }, { 11, 0.275 },
}),
}
local GUMI_INDEX = 4
local GUMI_DANCERS = Dancer.define({
[0] = small_dancer(0), [1] = small_dancer(0), [2] = small_dancer(0),
[3] = small_dancer(18), [GUMI_INDEX] = GUMI_DANCER,
})
-- Dancers stand on fixed slots (far-left .. far-right) instead of the group's even
-- spacing, since the centre dancer is wider than the others
local DANCER_CENTERS = { 183, 417, 620, 863, 1097 }
local DANCER_DRAW_ORDER = { 5, 1, 4, 2, 3 } -- outer pairs first, centre on top
-- Chibi GUMI for good hits: hops in on her carrot growing from 70% size, then rides it
-- straight across the don band
local GUMI_CHIBI = {
key = "1",
hop = { from = { -418, 21 }, peak = { -334, -36 }, up = 0.3 },
hop_frames = { { 0, 0.3 }, { 1, 0.175 }, { 0, 0.025 } },
period = 2,
poses = { { 0, 0 }, { 0.3, 1 }, { 0.65, 2 }, { 1, 3 }, { 1.325, 4 }, { 1.675, 5 } },
}
-- Background
local GumiBackground = {}
GumiBackground.__index = GumiBackground
function GumiBackground.new(player_num, bpm)
local self = setmetatable({}, GumiBackground)
self.donbg = DonBGBase.new(1, player_num, COLLAB_PATH, DonBG.parallax())
self.bg_normal = GumiBGNormal.new(bpm)
self.bg_fever = GumiBGFever.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, GUMI_DANCERS)
for i, d in ipairs(self.dancer.dancers) do
if d.index == GUMI_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 GumiBackground: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 GumiBackground:handle_good(player_num) BaseChibi.add(self.chibi, GUMI_CHIBI, COLLAB_PATH, player_num) end
function GumiBackground:handle_ok(player_num) BaseChibi.add(self.chibi, GUMI_CHIBI, COLLAB_PATH, player_num) end
function GumiBackground:handle_bad(player_num) self.chibi:add_chibi(player_num, true) end
function GumiBackground:handle_drumroll(player_num) self.renda:add_renda() end
function GumiBackground:handle_balloon(player_num) self.renda:add_renda() end
function GumiBackground: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 GumiBackground: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 GumiBackground:draw_fore() end
function GumiBackground: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 GumiBackground