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

409 lines
16 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 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/a3"
local MAX_DANCERS = 4
local chain_moves, frame_change, sum, concat, Loop =
Timing.chain_moves, Timing.frame_change, Timing.sum, Timing.concat, Timing.Loop
-- A value in thousandths that eases from wherever it is to a target (fade in/out envelopes)
local function envelope(current, moves, unit)
return { offset = current, moves = chain_moves(moves, unit, 1) }
end
local function env_value(env) return env.offset + sum(env.moves) end
local function env_update(env, current_time_ms)
for _, m in ipairs(env.moves) do m:update(current_time_ms) end
end
-- Don band: one tile scrolls by every 21.3 seconds; clearing fades the festival set in
-- and back out over a quarter beat (the default)
local A3_DONBG = { layers = { { key = "background", ms = 21333 } } }
-- Normal background: the festival street under the stage curtain, with the lanterns' glow
-- flickering additively over it (ambient, in ms; opacity in thousandths from 699)
local FLICKER_MS = 333
local FLICKER_BASE = 699
local FLICKER = { { 83, -97 }, { 83, 46 }, { 84, -97 }, { 83, 148 } }
local A3BGNormal = {}
A3BGNormal.__index = A3BGNormal
function A3BGNormal.new()
local self = setmetatable({}, A3BGNormal)
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_overlay = id("overlay")
self._id_curtain = id("curtain")
self.flicker = Loop.new(FLICKER_MS, function() return { glow = chain_moves(FLICKER, 1, 1) } end)
return self
end
function A3BGNormal:update(current_time_ms)
self.flicker:update(current_time_ms)
end
function A3BGNormal:draw()
tex.draw_texture(self._id_bg)
if self.flicker.anims then
local glow = (FLICKER_BASE + sum(self.flicker.anims.glow)) / 1000
tex.draw_texture(self._id_overlay, { fade = glow, blend = "additive" })
end
tex.draw_texture(self._id_curtain)
end
function A3BGNormal:destroy()
tex.unload_folder(COLLAB_PATH, "bg_normal/" .. self.name)
end
-- Clear background: drawn over the normal one, which stays underneath. A golden haze fades
-- in over 1.5 beats; a beat in, two streams of petals fade in over half a beat. Losing clear
-- fades the haze out over a beat and the petals over a quarter. Transitions in beats.
-- Each petal stream is three tiles (texture.json "petals" indices) drifting down-left; a new
-- pass starts every half of a pass's flight, so two are always on screen. Flights in ms.
local HAZE_IN, HAZE_OUT = 1.5, 1
local PETALS_IN = { { 1, 0 }, { 0.5, 1000 } }
local PETALS_OUT = 0.25
local PETAL_STREAMS = {
-- falling petals: glow additively at 80%, fading out over the second half of the flight
{ flight = 6667, dx = -340, dy = 690, first = 0, alpha = 0.8, fade_out = true, blend = "additive" },
-- turned a quarter: drift across at full opacity
{ flight = 5000, dx = -260, dy = 670, first = 3, alpha = 1, rotation = 90 },
}
local PETAL_TILES = 3
local PETAL_HALF = 164 -- half the petal texture, its centre for rotation
local A3BGFever = {}
A3BGFever.__index = A3BGFever
function A3BGFever.new(bpm)
local self = setmetatable({}, A3BGFever)
self.name = "bg_fever_0"
tex.load_folder(COLLAB_PATH, "bg_fever/" .. self.name)
self._id_overlay = tex.get_texture(COLLAB_PATH .. "/" .. self.name .. "/" .. "overlay") or 0
self._id_petals = tex.get_texture(COLLAB_PATH .. "/" .. self.name .. "/" .. "petals") or 0
self.beat = 60000 / bpm
self.haze = envelope(0, {}, 1)
self.petals = envelope(0, {}, 1)
self.streams = {}
return self
end
local function new_pass(stream)
local half = anim.move(stream.flight / 2, { total_distance = 1000 })
half:start()
return half
end
function A3BGFever:start()
local haze, petals = env_value(self.haze), env_value(self.petals)
self.haze = envelope(haze, { { HAZE_IN, 1000 - haze } }, self.beat)
self.petals = envelope(petals, { PETALS_IN[1], { PETALS_IN[2][1], 1000 - petals } }, self.beat)
-- the streams start over with only the first pass on screen
for i, stream in ipairs(PETAL_STREAMS) do
self.streams[i] = { half = new_pass(stream), laps = 0 }
end
end
function A3BGFever:stop()
local haze, petals = env_value(self.haze), env_value(self.petals)
self.haze = envelope(haze, { { HAZE_OUT, -haze } }, self.beat)
self.petals = envelope(petals, { { PETALS_OUT, -petals } }, self.beat)
end
function A3BGFever:update(current_time_ms, bpm)
if bpm then self.beat = 60000 / bpm end
env_update(self.haze, current_time_ms)
env_update(self.petals, current_time_ms)
for i, s in pairs(self.streams) do
s.half:update(current_time_ms)
if s.half.is_finished then
s.half = new_pass(PETAL_STREAMS[i])
s.laps = s.laps + 1
s.half:update(current_time_ms)
end
end
end
function A3BGFever:draw_stream(stream, s, opacity, scale)
-- pass 0 is in the first half of its flight, pass 1 (once there is one) in the second
for pass = 0, math.min(s.laps, 1) do
local p = (pass * 1000 + s.half.attribute) / 2000
local fade = opacity * stream.alpha
if stream.fade_out and p > 0.5 then fade = fade * (1 - p) * 2 end
local origin = stream.rotation and PETAL_HALF * scale or 0
for t = 0, PETAL_TILES - 1 do
tex.draw_texture(self._id_petals, {
index = stream.first + t,
x = math.floor(stream.dx * p * scale) + origin,
y = math.floor(stream.dy * p * scale) + origin,
fade = fade,
blend = stream.blend,
rotation = stream.rotation,
origin = stream.rotation and { origin, origin } or nil,
})
end
end
end
function A3BGFever:draw()
local haze = env_value(self.haze) / 1000
if haze > 0 then tex.draw_texture(self._id_overlay, { fade = haze }) end
local petals = env_value(self.petals) / 1000
if petals <= 0 then return end
local scale = tex.get_screen_scale()
for i, stream in ipairs(PETAL_STREAMS) do
if self.streams[i] then self:draw_stream(stream, self.streams[i], petals, scale) end
end
end
function A3BGFever:destroy()
tex.unload_folder(COLLAB_PATH, "bg_fever/" .. self.name)
end
-- Dancers: four fox-hooded dancers on fixed slots. They pop in and out with no
-- entrance, all following one 16-beat routine, so a dancer joining mid-song is in step.
-- Each dancer's N_loop holds its poses, then the same poses mirrored. Timed in beats.
-- Routine steps: { pose, beats, mirrored }; "x"/"after_x"/"y" are per-dancer poses
local DANCE = {
{ 0, 0.35 }, { 1, 0.25 }, { 2, 1.4 }, { 1, 0.1 }, { 0, 0.45 }, { 1, 0.05 }, { 2, 0.85 }, { 3, 0.55 },
{ 0, 0.35, true }, { 1, 0.25, true }, { 2, 1.4, true }, { 1, 0.1, true }, { 0, 0.25, true },
{ 1, 0.25, true }, { 2, 0.85, true }, { 3, 0.475, true }, { "x", 0.075, true },
{ "after_x", 0.6, true }, { 4, 0.2, true }, { 5, 0.2, true }, { 6, 0.85, true }, { 7, 0.6, true },
{ 8, 1.55, true },
{ "y", 0.6 }, { 4, 0.2 }, { 5, 0.2 }, { 6, 0.85 }, { 7, 0.6 }, { 8, 1.55 },
}
local DANCE_BEATS = 16
-- index = skin folder; centre = the slot's x. Dancer 1 has an extra pose (4) and shifts the
-- rest up by one.
local DANCERS = {
[0] = { center = 775, poses = 9, shift = 0, x = 1, after_x = 0, y = 1 },
[1] = { center = 505, poses = 10, shift = 1, x = 4, after_x = 4, y = 4 },
[2] = { center = 1035, poses = 9, shift = 0, x = 1, after_x = 0, y = 0 },
[3] = { center = 245, poses = 9, shift = 0, x = 1, after_x = 0, y = 0 },
}
local DRAW_ORDER = { 3, 2, 1, 0 } -- back to front
-- a hop every two beats for the first half (px), and the shadow shrinking under it
local HOP = { { 0.375, -16 }, { 0.5, 23 }, { 0.15, -7 } }
local HOPS = concat({ { 0.6, 0 } }, HOP, { { 0.975, 0 } }, HOP, { { 0.975, 0 } }, HOP,
{ { 0.975, 0 } }, HOP)
local SHADOW = { { 0.6, 0 }, { 0.35, -100 }, { 0.375, 100 }, { 0.675, 0 } } -- scale, per 2 beats
-- squash: px off the 312px height, feet planted. A dip on the pose changes, B a dip and a
-- stretch on the turn, C a slow sink on the last pose
local SQUASH_A = { { 0.2, 16 }, { 0.225, -16 } }
local SQUASH_B = { { 0.4, 16 }, { 0.6, -20 }, { 0.25, 4 } }
local SQUASH_C = { { 0, 6 }, { 0.525, 6 }, { 0.275, -12 } }
local SQUASH = concat({ { 0.125, 0 } }, SQUASH_A, { { 1.55, 0 } }, SQUASH_A, { { 1.6, 0 } },
SQUASH_A, { { 1.55, 0 } }, SQUASH_A, { { 1.475, 0 } }, SQUASH_B, { { 1.2, 0 } }, SQUASH_C,
{ { 0.75, 0 } }, SQUASH_B, { { 1.2, 0 } }, SQUASH_C)
-- dancer 0's music note pops by its head twice (texture.json "note" indices 0, 1)
local NOTES = {
{ { 3.45, 0 }, { 0.3, 1000 }, { 0.35, -1000 } },
{ { 7.45, 0 }, { 0.325, 1000 }, { 0.325, -1000 } },
}
local SHADOW_ALPHA = 0.45
local function dance_steps(spec)
local steps = {}
for _, step in ipairs(DANCE) do
local pose = step[1]
if type(pose) == "string" then
pose = spec[pose]
elseif pose >= 4 then
pose = pose + spec.shift
end
table.insert(steps, { pose + (step[3] and spec.poses or 0), step[2] })
end
return steps
end
local A3Dancers = {}
A3Dancers.__index = A3Dancers
function A3Dancers.new(bpm)
local self = setmetatable({}, A3Dancers)
self.name = "dancer_0"
tex.load_folder(COLLAB_PATH, "dancer/" .. self.name)
local function id(key) return tex.get_texture(COLLAB_PATH .. "/" .. self.name .. "/" .. key) or 0 end
self._id_shadow = id("shadow")
self._id_note = id("note")
self._ids = {}
self.steps = {}
for i, spec in pairs(DANCERS) do
self._ids[i] = id(i .. "_loop")
self.steps[i] = dance_steps(spec)
end
self.width = tex.get_texture_info(self.name, "0_loop").width
self.shown = 1
self:set_bpm(bpm)
return self
end
function A3Dancers:set_bpm(bpm)
self.bpm = bpm
local beat = 60000 / bpm
local scale = tex.get_screen_scale()
self.routine = Loop.new(DANCE_BEATS * beat, function()
local frames = {}
for i in pairs(DANCERS) do
frames[i + 1] = frame_change(self.steps[i], beat)
frames[i + 1]:start()
end
local shadow = {}
for _ = 1, 4 do shadow = concat(shadow, SHADOW) end
return {
frames = frames,
hop = chain_moves(HOPS, beat, scale),
squash = chain_moves(SQUASH, beat, scale),
shadow = chain_moves(shadow, beat, 1),
note1 = chain_moves(NOTES[1], beat, 1),
note2 = chain_moves(NOTES[2], beat, 1),
}
end)
end
function A3Dancers:set_count(count) self.shown = count end
function A3Dancers:update(current_time_ms, bpm)
if bpm and bpm ~= self.bpm then self:set_bpm(bpm) end
self.routine:update(current_time_ms)
end
function A3Dancers:draw()
local a = self.routine.anims
if not a then return end
local scale = tex.get_screen_scale()
local squash = sum(a.squash)
local shadow = (1000 + sum(a.shadow)) / 1000
for _, i in ipairs(DRAW_ORDER) do
if i < self.shown then
local x = math.floor(DANCERS[i].center * scale - self.width / 2)
tex.draw_texture(self._id_shadow, { x = x, center = true, scale = shadow, fade = SHADOW_ALPHA * shadow })
tex.draw_texture(self._ids[i], {
frame = a.frames[i + 1].attribute,
x = x,
y = sum(a.hop) + squash,
y2 = -squash,
})
if i == 0 then
for n, note in ipairs({ a.note1, a.note2 }) do
local fade = sum(note) / 1000
if fade > 0 then tex.draw_texture(self._id_note, { index = n - 1, x = x, fade = fade }) end
end
end
end
end
end
function A3Dancers:destroy()
tex.unload_folder(COLLAB_PATH, "dancer/" .. self.name)
end
-- Chibi for good hits: one of four festival tiles hops in growing from 70% size, then bobs
-- across the don band
local A3_CHIBI_MOTION = {
key = "1",
hop = { from = { -418, 0 }, peak = { -335, -47 }, up = 0.3 },
run = { y = -3 },
wave = { first = -12, height = 22, beats = 0.5 }, -- a steady bob
}
local A3_CHIBIS = {}
for frame = 0, 3 do -- one character per frame of chibi_1/1
A3_CHIBIS[frame + 1] = setmetatable({ first = frame }, { __index = A3_CHIBI_MOTION })
end
-- Background
local A3Background = {}
A3Background.__index = A3Background
function A3Background.new(player_num, bpm)
local self = setmetatable({}, A3Background)
self.donbg = DonBGBase.new(1, player_num, COLLAB_PATH, A3_DONBG)
self.bg_normal = A3BGNormal.new()
self.bg_fever = A3BGFever.new(bpm)
-- A3 has no dodai or fever of its own; the game uses the regular ones
self.footer = Footer.new("background", math.random(0, 2))
self.fever = Fever.create(math.random(0, 3), bpm, "background")
self.dancer = A3Dancers.new(bpm)
self.renda = RendaController.new(COLLAB_PATH, 0) -- the default drumroll flight
self.chibi = ChibiController.new(bpm, COLLAB_PATH, 1)
self.is_clear = false
self.is_rainbow = false
return self
end
function A3Background: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
local function add_chibi(self, player_num)
BaseChibi.add(self.chibi, A3_CHIBIS[math.random(#A3_CHIBIS)], COLLAB_PATH, player_num, ChibiWave)
end
function A3Background:handle_good(player_num) add_chibi(self, player_num) end
function A3Background:handle_ok(player_num) add_chibi(self, player_num) end
function A3Background:handle_bad(player_num) self.chibi:add_chibi(player_num, true) end
function A3Background:handle_drumroll(player_num) self.renda:add_renda() end
function A3Background:handle_balloon(player_num) self.renda:add_renda() end
function A3Background: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
-- one dancer to start, up to three with the gauge, all four once cleared
if is_clear then
self.dancer:set_count(MAX_DANCERS)
else
self.dancer:set_count(math.max(1, math.min(MAX_DANCERS - 1, math.floor(progress * MAX_DANCERS))))
end
end
function A3Background:draw_back()
self.bg_normal:draw()
self.bg_fever:draw()
self.footer:draw()
self.donbg:draw()
self.renda:draw()
self.dancer:draw()
self.fever:draw()
self.chibi:draw()
end
function A3Background:draw_fore() end
function A3Background:destroy()
self.donbg:destroy()
self.bg_normal:destroy()
self.bg_fever:destroy()
self.footer:destroy()
self.fever:destroy()
self.dancer:destroy()
self.renda:destroy()
self.chibi:destroy()
end
return A3Background