Files
PyTaikoGreen/Scripts/background/bg_collabs/kumamon.lua
T
2026-10-09 16:16:41 -04:00

353 lines
15 KiB
Lua

local DonBG = require("background/bg_objects/donbg")
local DonBGBase = require("background/bg_objects/donbg/base")
local BGNormal = require("background/bg_objects/bg_normal")
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 COLLAB_PATH = "background/collab/kumamon"
local MAX_DANCERS = 5
local chain_moves, sum = Timing.chain_moves, Timing.sum
-- Clear background: Kumamoto Castle swaps in under a sunburst that fades in over 1.75 beats
-- and swaps panels every 0.75 beat. The prefecture map pops in from double size, overshoots
-- small and settles; the produce strip (three copies, 1216 px apart) drops in from above,
-- bounces once and scrolls 1212 px left every 14 beats. Leaving, the burst fades, the map
-- shrinks away and the strip falls off over 0.85 beat; the castle cuts back 1.35 beats in.
-- Timed in beats.
local BG_FEVER_IN = 1.75
local BG_FEVER_OUT = 0.85
local BG_FEVER_CUT = 1.35
local BG_FEVER_SWAP = 0.75
local PRODUCE = { beats = 14, distance = -1212, tile = 1216, from = -362, drop = 364 }
local MAP_FROM = 2000 -- scale in thousandths
local KumamonBGFever = {}
KumamonBGFever.__index = KumamonBGFever
-- a value made of an offset plus chained moves, so a new move starts from wherever it is
local function track(value) return { offset = value, moves = {} } end
local function value(t) return t.offset + sum(t.moves) end
local function retarget(t, moves, unit, scale, from)
t.offset = from or value(t)
t.moves = chain_moves(moves, unit, scale)
end
function KumamonBGFever.new(bpm)
local self = setmetatable({}, KumamonBGFever)
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_rays = id("rays")
self._id_map = id("map")
self._id_produce = id("produce")
self.map_info = tex.get_texture_info(self.name, "map")
self.beat = 60000 / bpm
self.rays = track(0)
self.map_scale = track(MAP_FROM)
self.map_alpha = track(0)
self.produce_y = track(0)
self.cut = {}
self.shown = false
return self
end
function KumamonBGFever:start()
local scale, beat = tex.get_screen_scale(), self.beat
local swap = BG_FEVER_SWAP * beat
-- after a full exit everything starts over; mid-exit it comes back from where it is
local fresh = not self.shown or sum(self.cut) > 0
self.shown, self.cut = true, {}
retarget(self.rays, { { BG_FEVER_IN, 1000 - value(self.rays) } }, beat, 1, fresh and 0 or nil)
local s = fresh and MAP_FROM or value(self.map_scale)
retarget(self.map_scale, { { 0.8, 900 - s, "out" }, { 0.25, 150, "in" }, { 0.425, -50, "out" } }, beat, 1, s)
local a = fresh and 0 or value(self.map_alpha)
retarget(self.map_alpha, { { 0.8, 1000 - a } }, beat, 1, a)
local y = fresh and math.floor(PRODUCE.from * scale) or value(self.produce_y)
retarget(self.produce_y, { { 0.825, -y / scale, "in" }, { 0.325, -58, "out" }, { 0.3, 58, "in" } }, beat, scale, y)
if fresh then
self.panels = anim.texture_change(2 * swap, { { 0, swap, 0 }, { swap, 2 * swap, 1 } }, { loop = true })
self.scroll = anim.move(PRODUCE.beats * beat, { total_distance = math.floor(PRODUCE.distance * scale), loop = true })
self.panels:start()
self.scroll:start()
end
end
function KumamonBGFever:stop()
local scale, beat = tex.get_screen_scale(), self.beat
retarget(self.rays, { { BG_FEVER_OUT, -value(self.rays) } }, beat, 1)
retarget(self.map_scale, { { BG_FEVER_OUT, 400 - value(self.map_scale), "in" } }, beat, 1)
retarget(self.map_alpha, { { BG_FEVER_OUT, -value(self.map_alpha) } }, beat, 1)
local y = value(self.produce_y)
retarget(self.produce_y, { { BG_FEVER_OUT, PRODUCE.drop - y / scale, "in" } }, beat, scale)
self.cut = chain_moves({ { BG_FEVER_CUT, 0 }, { 0, 1 } }, beat, 1)
end
function KumamonBGFever:update(current_time_ms, bpm)
if bpm then self.beat = 60000 / bpm end
for _, t in ipairs({ self.rays, self.map_scale, self.map_alpha, self.produce_y }) do
for _, m in ipairs(t.moves) do m:update(current_time_ms) end
end
for _, m in ipairs(self.cut) do m:update(current_time_ms) end
if self.panels then
self.panels:update(current_time_ms)
self.scroll:update(current_time_ms)
end
end
function KumamonBGFever:draw()
if not self.shown or sum(self.cut) > 0 then return end
tex.draw_texture(self._id_bg)
local rays = value(self.rays) / 1000
if rays > 0 then tex.draw_texture(self._id_rays, { frame = self.panels.attribute, fade = rays }) end
local alpha = value(self.map_alpha) / 1000
if alpha > 0 then
local k = value(self.map_scale) / 1000 - 1
local dw, dh = self.map_info.width * k, self.map_info.height * k
tex.draw_texture(self._id_map, { x = -dw / 2, y = -dh / 2, x2 = dw, y2 = dh, fade = alpha })
end
local y = value(self.produce_y)
local tile = math.floor(PRODUCE.tile * tex.get_screen_scale())
for k = 0, 2 do
tex.draw_texture(self._id_produce, { x = self.scroll.attribute + k * tile, y = y })
end
end
function KumamonBGFever:destroy() tex.unload_folder(COLLAB_PATH, "bg_fever/" .. self.name) end
-- Fever: the local mascots in two halves jump up 340 px, bounce 40 px a beat swapping poses,
-- and drop 340 px when the rainbow is lost
local FEVER_SPEC = { keys = { "left", "right" }, rise = 300, peak = 40, bounce = 40, drop = 340, poses = true }
-- Dancers
-- Timed in beats; poses are offsets from Lumen's dancer origin (texture.json y 670), moved
-- to the dancers' feet so squashes keep them planted. Every part uses the loop frames.
-- The four small dancers shoot up from below, land with a squash, then loop a 16-beat
-- routine of poses with a stretch on each change; leaving, they squash, spring up and drop.
local SMALL_Y = 28
local SMALL_ENTER = Timing.steps({
{ 0, 0.625, x = 0, y = 394, to = { 0, -15, "out" } }, -- shoots up from below
{ 0, 0.25, to = { nil, SMALL_Y, "in" } },
{ 0, 0.25, to = { nil, nil, "out", sy = 0.94 } }, -- lands
{ 0, 0.25, sy = 0.94, to = { nil, nil, "in", sy = 1 } },
{ 0, 0.35 },
{ 1, 0.15, to = { nil, nil, "out", sy = 1.04 } },
{ 1, 0.125, sy = 1.04, to = { nil, nil, "in", sy = 1 } },
})
SMALL_ENTER.beats, SMALL_ENTER.key = 2, "loop"
-- a pose change with a stretch: up to 1.04 and back
local function stretch(frame, up, back)
return { frame, up, to = { nil, nil, "out", sy = 1.04 } }, { frame, back, sy = 1.04, to = { nil, nil, "in", sy = 1 } }
end
-- a, b: the first phrase's pose pair (the far-right dancer has them the other way round)
local function small_dance(a, b)
local s1, s2 = stretch(b, 0.15, 0.125)
local s3, s4 = stretch(3, 0.15, 0.1)
local s5, s6 = stretch(b, 0.15, 0.125)
return Timing.steps({
{ a, 0.725, x = 0, y = SMALL_Y }, s1, s2, { a, 0.75 }, { b, 0.25 },
{ 0, 1.75 },
s3, s4, { 4, 0.75 }, s3, s4, { 4, 0.75 }, { 3, 0.25 },
{ 0, 2.025 },
{ 0, 0.325, to = { nil, nil, "out", sy = 0.96 } }, { 0, 0.325, sy = 0.96 }, -- crouch
{ 5, 0.675 }, { 0, 0.675 },
{ 0, 0.925, to = { nil, nil, "out", sy = 1.04 } }, { 0, 0.4, sy = 1.04 }, -- stretch up
{ 0, 0.65, sy = 1.04, to = { nil, nil, "in", sy = 1 } },
{ 6, 1.025 },
{ 6, 0.25, to = { nil, nil, "out", sy = 0.94 } }, { 6, 0.25, sy = 0.94, to = { nil, nil, "in", sy = 1 } },
{ 7, 0.25, to = { nil, nil, "out", sy = 1.08 } }, { 7, 0.25, sy = 1.08, to = { nil, nil, "in", sy = 1 } },
{ 7, 1.7 },
s5, s6,
})
end
local SMALL_LEAVE = Timing.steps({
{ 8, 0.15, x = 0, y = SMALL_Y, to = { nil, nil, "out", sy = 0.93 } },
{ 8, 0.125, sy = 0.93, to = { nil, nil, "in", sy = 1 } },
{ 8, 0.225, to = { nil, -72, "out" } }, -- springs up
{ 8, 0.85, to = { nil, 242, "in" } }, -- and drops off
})
SMALL_LEAVE.beats, SMALL_LEAVE.key = 1.35, "loop"
-- Kumamon in the middle shoots up the same way, then dances its own 16 beats: four poses,
-- the same four turned round, a run of quick poses, a long stretch and a sink; it never leaves.
local BIG_Y = 25
local BIG_ENTER = Timing.steps({
{ 0, 0.625, x = 0, y = 392, to = { 0, -1, "out" } }, -- shoots up from below
{ 0, 0.25, to = { nil, BIG_Y - 2, "in" } },
{ 1, 0.225, to = { nil, nil, "out", sy = 0.96 } }, -- lands
{ 1, 0.05, sy = 0.96 },
{ 1, 0.225, sy = 0.96, to = { nil, nil, "in", sy = 1 } },
{ 1, 0.35 },
{ 2, 0.15, y = BIG_Y, to = { nil, nil, "out", sy = 1.04 } },
{ 2, 0.125, sy = 1.04, to = { nil, nil, "in", sy = 1 } },
})
BIG_ENTER.beats, BIG_ENTER.key = 2, "loop"
-- turned round: mirror every step
local function turned(list)
for _, step in ipairs(list) do step.mirror = true end
return list
end
local BIG_DANCE = Timing.steps(
{ { 3, 0.725, x = 0, y = BIG_Y } }, { stretch(4, 0.15, 0.125) },
{ { 5, 0.75 } }, { stretch(6, 0.15, 0.1) },
{ { 7, 1.75 } },
-- the same four poses, turned round and 20 px over
turned({ { 2, 0.15, x = 20, to = { nil, nil, "out", sy = 1.04 } }, { 2, 0.1, sy = 1.04, to = { nil, nil, "in", sy = 1 } } }),
turned({ { 3, 0.75 } }), turned({ stretch(4, 0.15, 0.1) }),
turned({ { 5, 0.75 } }), turned({ stretch(6, 0.15, 0.1) }),
turned({ { 7, 1 } }),
turned({ -- quick poses, a long stretch and a sink
{ 0, 1, x = 0 }, { 8, 0.35 }, { 9, 0.325 }, { 10, 0.325 }, { 11, 0.35 }, { 0, 0.325 }, { 8, 0.325 },
{ 9, 0.95, to = { nil, nil, "out", sy = 1.04 } },
{ 9, 0.25, sy = 1.04 },
{ 9, 0.8, sy = 1.04, to = { nil, nil, "in", sy = 0.96 } },
{ 12, 1.025, y = BIG_Y + 1 },
{ 12, 0.225, to = { nil, nil, "out", sy = 0.96 } },
{ 12, 0.05, sy = 0.96 },
{ 12, 0.225, sy = 0.96, to = { nil, nil, "in", sy = 1 } },
}), {
{ 1, 0.25, x = -4, y = BIG_Y, to = { nil, nil, "out", sy = 1.04 } },
{ 1, 0.25, sy = 1.04, to = { nil, nil, "in", sy = 1 } },
{ 1, 1.7 },
}, { stretch(2, 0.15, 0.125) })
local SMALL = { origin = { 136, 200 }, enter = SMALL_ENTER, dance = small_dance(2, 1), leave = SMALL_LEAVE }
local KUMAMON_DANCERS = Dancer.define({
[0] = { origin = { 136, 200 }, enter = SMALL_ENTER, dance = small_dance(1, 2), leave = SMALL_LEAVE },
[4] = { origin = { 140, 304 }, enter = BIG_ENTER, dance = BIG_DANCE },
default = SMALL,
})
-- Lumen's slots, left to right; the group fills centre, left, right, far-left, far-right,
-- which is Lumen's dancer1..5 (skin dancers 4, 3, 2, 1, 0)
local DANCER_CENTERS = { 200, 420, 640, 860, 1080 }
local DANCER_DRAW_ORDER = { 5, 1, 4, 2, 3 }
local DANCER_JOIN_ORDER = { 4, 3, 2, 1, 0 }
-- Chibi for good hits: a tomato, a dekopon or a pomelo hops in growing from 70% size, then
-- zigzags up and down across the don band
local KUMAMON_CHIBI_MOTION = {
key = "1",
hop = { from = { -418, 21 }, peak = { -345, -31 }, up = 0.3 },
run = { y = -8 },
wave = { first = 24, height = 39, beats = 0.8 },
}
local KUMAMON_CHIBIS = {}
for frame = 0, 2 do -- one fruit per frame of chibi_1/1
KUMAMON_CHIBIS[frame + 1] = setmetatable({ first = frame }, { __index = KUMAMON_CHIBI_MOTION })
end
-- Background
local KumamonBackground = {}
KumamonBackground.__index = KumamonBackground
function KumamonBackground.new(player_num, bpm)
local self = setmetatable({}, KumamonBackground)
-- the don band is donbg_a_01's motion with new art
self.donbg = DonBGBase.new(1, player_num, COLLAB_PATH, DonBG.SPECS[1])
self.bg_normal = BGNormal.Base.new(0, COLLAB_PATH)
self.bg_fever = KumamonBGFever.new(bpm)
-- the footer and renda are the default dodai_a_03 and renda_a_02
self.footer = Footer.new("background", 2)
self.fever = Fever.new(bpm, COLLAB_PATH, FEVER_SPEC)
self.dancer = Dancer.BaseDancerGroup.new(0, bpm, MAX_DANCERS, COLLAB_PATH, KUMAMON_DANCERS)
local by_index = {}
for _, d in ipairs(self.dancer.dancers) do by_index[d.index] = d end
for i, index in ipairs(DANCER_JOIN_ORDER) do self.dancer.dancers[i] = by_index[index] end
self.renda = RendaController.new("background", 1)
self.chibi = ChibiController.new(bpm, COLLAB_PATH, 1)
self.is_clear = false
self.is_rainbow = false
self.last_milestone = 1
return self
end
function KumamonBackground:update(current_ms, bpm)
self.donbg: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
local function add_chibi(self, player_num)
BaseChibi.add(self.chibi, KUMAMON_CHIBIS[math.random(#KUMAMON_CHIBIS)], COLLAB_PATH, player_num, ChibiWave)
end
function KumamonBackground:handle_good(player_num) add_chibi(self, player_num) end
function KumamonBackground:handle_ok(player_num) add_chibi(self, player_num) end
function KumamonBackground:handle_bad(player_num) self.chibi:add_chibi(player_num, true) end
function KumamonBackground:handle_drumroll(player_num) self.renda:add_renda() end
function KumamonBackground:handle_balloon(player_num) self.renda:add_renda() end
function KumamonBackground: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 KumamonBackground: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 KumamonBackground:draw_fore() end
function KumamonBackground: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 KumamonBackground