Files
PyTaikoGreen/Scripts/background/bg_collabs/toho.lua
T
2026-10-10 01:11:03 -04:00

444 lines
19 KiB
Lua

local DonBGBase = require("background/bg_objects/donbg/base")
local BGNormal = require("background/bg_objects/bg_normal")
local Fever = require("background/bg_objects/fever")
local Dancer = require("background/bg_objects/dancer")
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/toho"
local MAX_DANCERS = 5
-- No footer: Lumen has no dodai, the shrine path is painted into the stage.
local function clamp01(v) return math.max(0, math.min(1, v)) end
local function ease_out(p) return p * (2 - p) end
local function ease_in(p) return p * p end
-- Don band: one strip of flowers scrolling a tile left every 18s, gold in the clear set
local DONBG_SPEC = { layers = { { key = "background", ms = 18000, x = -81 } } }
-- Clear background: the shrine at night, over the day stage. Clearing fades the night in over
-- 1.5 beats while a string of lanterns drops in and settles; then the lanterns glow, pulsing
-- once a beat. Losing the clear cuts the glow, lifts the lanterns away and fades the night out
-- over 1.45 beats. Timed in beats.
local NIGHT_IN, NIGHT_OUT = 1.5, 1.45
local LANTERN_HIDDEN = -116 -- px above their rest, behind the lane
local LANTERN_IN = { at = 0.75, drop = 0.575, overshoot = 8, settle = 0.175 }
local LANTERN_OUT = { dip = 0.125, lift = 0.5 }
local GLOW_AT = 1.5
local TohoBGFever = {}
TohoBGFever.__index = TohoBGFever
function TohoBGFever.new(bpm)
local self = setmetatable({}, TohoBGFever)
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_night, self._id_lanterns, self._id_glow = id("night"), id("lanterns"), id("glow")
self.beat = 60000 / bpm
return self
end
function TohoBGFever:t() return ((self.now or 0) - (self.t0 or self.now or 0)) / self.beat end
-- night opacity and lantern offset (nil while hidden) at the current time
function TohoBGFever:state()
local t = self:t()
if self.out then
local o = self.out
local night = o.night * (1 - clamp01(t / NIGHT_OUT))
if not o.lanterns then return night end
local d = LANTERN_OUT
if t < d.dip then
return night, o.lanterns + (LANTERN_IN.overshoot - o.lanterns) * ease_out(t / d.dip)
elseif t < d.dip + d.lift then
return night, LANTERN_IN.overshoot + (LANTERN_HIDDEN - LANTERN_IN.overshoot) * ease_in((t - d.dip) / d.lift)
end
return night
end
local night, l = clamp01(t / NIGHT_IN), LANTERN_IN
if t < l.at then return night end
if t < l.at + l.drop then
return night, LANTERN_HIDDEN + (l.overshoot - LANTERN_HIDDEN) * ease_out((t - l.at) / l.drop)
end
return night, l.overshoot * (1 - ease_in(clamp01((t - l.at - l.drop) / l.settle)))
end
function TohoBGFever:start() self.out, self.t0 = nil, self.now end
function TohoBGFever:stop()
if not self.t0 or self.out then return end
local night, lanterns = self:state()
self.out, self.t0 = { night = night, lanterns = lanterns }, self.now
end
function TohoBGFever:update(current_time_ms, bpm)
if bpm then
local beat = 60000 / bpm
if self.t0 and self.now then self.t0 = self.now - (self.now - self.t0) / self.beat * beat end
self.beat = beat
end
self.now = current_time_ms
end
function TohoBGFever:draw()
if not self.t0 then return end
local night, lanterns = self:state()
if night <= 0 then return end
tex.draw_texture(self._id_night, { fade = night })
if lanterns then tex.draw_texture(self._id_lanterns, { y = lanterns }) end
local t = self:t()
if not self.out and t >= GLOW_AT then
local p = (t - GLOW_AT) % 1
local glow = p < 0.5 and p / 0.5 or (1 - p) / 0.5
for i = 0, 14 do tex.draw_texture(self._id_glow, { index = i, fade = glow, blend = "additive" }) end
end
end
function TohoBGFever:destroy() tex.unload_folder(COLLAB_PATH, "bg_fever/" .. self.name) end
-- Fever line-up: two trios jump up 210px, bounce 70px a beat and drop away on rainbow loss
local FEVER_SPEC = { keys = { "left", "right" }, peak = 70, bounce = 70 }
-- Dancers
-- Timed in beats from Lumen's `in` label; poses are offsets of the dancers' feet (origin
-- { 160, 336 }, texture.json y 695), the pivot for their squash. All five dance one 8-beat
-- routine: two 2-beat phrases of arm poses, then a squash, a hop and a landing squash.
-- p: frames for the rest pose (p0), the arm poses (p1-p4) and squash/hop/fall/land (p5-p8)
local function routine(p)
local a = { { p[2], 0.5, x = 0, y = 0 }, { p[1], 0.175 }, { p[0], 0.175 }, { p[3], 0.15 },
{ p[4], 0.5 }, { p[3], 0.175 }, { p[0], 0.175 }, { p[1], 0.15 } }
local b = { { p[2], 0.5 }, { p[1], 0.175 }, { p[0], 0.175 }, { p[3], 0.15 },
{ p[4], 0.5 }, { p[3], 0.25 }, { p[0], 0.25 } }
local c = { { p[5], 1, to = { nil, nil, nil, sy = 0.95 } },
{ p[6], 0.5, y = 0, to = { nil, -29, "out" } },
{ p[7], 0.5, y = -29, to = { nil, 0, "in" } },
{ p[8], 0.275, y = 0, to = { nil, nil, "out", sy = 0.95 } },
{ p[8], 0.225, sy = 0.95, to = { nil, nil, "in", sy = 1 } },
{ p[8], 1.175 }, { p[0], 0.175 }, { p[1], 0.15 } }
return Timing.steps(a, b, c)
end
local function frames(list) -- list is p0..p8
local p = {}
for i, f in ipairs(list) do p[i - 1] = f end
return p
end
-- rises from below the stage, falls back onto the slot with a squash, then the routine's lead-in
local function jump_in(rise, fall, land, p)
return { beats = 2, key = "loop",
{ 0, rise, 0, 353, to = { 0, -33, "out" } },
{ 0.575, fall, 0, -33, to = { 0, 0, "in" } },
{ 0.825, land, 0, 0, to = { nil, nil, "out", sy = 0.95 } },
{ 0.975, land, sy = 0.95, to = { nil, nil, "in", sy = 1 } },
{ 1.1, land },
{ 1.7, p[0] }, { 1.85, p[1] },
}
end
-- hops up and drops off the bottom
local function jump_out(frame)
return { beats = 0.925, key = "loop",
{ 0, frame, 0, 0, to = { 0, -33, "out" } },
{ 0.3, frame, 0, -33, to = { 0, 353, "in" } },
}
end
-- Remilia's wings beat 10° about her back (image point 160, 219) every eighth of a beat
local WING_PIVOT = { 160, 219 }
local function wing(frame, rot)
local poses = { key = "loop", origin = WING_PIVOT }
for k = 0, 10 do poses[#poses + 1] = { k * 0.125, frame, 0, WING_PIVOT[2] - 336, rot = k % 2 == 1 and rot or 0 } end
poses[#poses + 1] = { 1.3, frame, hide = true }
return poses
end
local REMILIA = frames({ 4, 5, 6, 7, 8, 9, 10, 11, 12 })
local ALICE = frames({ 3, 4, 5, 6, 7, 8, 0, 1, 9 })
local SAKUYA = frames({ 4, 5, 6, 7, 8, 9, 10, 11, 12 })
local MARISA = frames({ 2, 3, 4, 5, 6, 7, 8, 1, 9 })
local REIMU = frames({ 3, 4, 5, 6, 7, 8, 0, 1, 9 })
-- skin index i is Lumen's dancer (5 - i)
local TOHO_DANCERS = Dancer.define({
-- Remilia flies up from below on beating wings (outline, wings, then her)
[0] = {
origin = { 160, 336 },
enter = { beats = 2,
move = { { 0, 0, 0, 353, to = { 0, -33, "out" } }, { 0.6, 0, 0, -32, to = { 0, 0 } }, { 1.3, 0, 0, 0 } },
{ key = "loop", { 0, 0, 0, 0 }, { 1.3, REMILIA[0] }, { 1.85, REMILIA[1] } },
wing(1, 10), wing(2, -10),
{ key = "loop", { 0, 3, 0, 0 }, { 1.3, 3, hide = true } },
},
dance = routine(REMILIA),
leave = jump_out(13),
},
[1] = { origin = { 160, 336 }, enter = jump_in(0, 1, 2, ALICE), dance = routine(ALICE), leave = jump_out(10) },
-- Sakuya appears in a white flash with her afterimage splitting off both sides, and
-- vanishes the same way in reverse
[2] = {
origin = { 160, 336 },
enter = { beats = 2,
{ key = "loop", { 0, 1, hide = true }, { 0.35, 1, 22, 0, alpha = 0, to = { nil, nil, nil, alpha = 1 } },
{ 0.6, 1, 22, 0 }, { 1.7, SAKUYA[0], 0, 0 }, { 1.85, SAKUYA[1] } },
{ key = "loop", { 0, 0, 22, 0, alpha = 0, to = { nil, nil, nil, alpha = 1 } }, { 0.375, 0, hide = true } },
{ key = "loop", { 0, 2, hide = true }, { 0.35, 2, 22, 0, to = { -42, nil, "out", alpha = 0 } }, { 0.875, 2, hide = true } },
{ key = "loop", { 0, 3, hide = true }, { 0.35, 3, 22, 0, to = { 62, nil, "out", alpha = 0 } }, { 0.875, 3, hide = true } },
},
dance = routine(SAKUYA),
leave = { beats = 0.875,
{ key = "loop", { 0, 1, 22, 0, to = { nil, nil, nil, alpha = 0 } }, { 0.375, 1, hide = true } },
{ key = "loop", { 0, 2, -42, 0, alpha = 0, to = { 22, nil, "out", alpha = 1 } },
{ 0.475, 0, 22, 0, to = { nil, nil, nil, alpha = 0 } } },
{ key = "loop", { 0, 3, 62, 0, alpha = 0, to = { 22, nil, "out", alpha = 1 } }, { 0.475, 3, hide = true } },
},
},
-- Marisa swoops in on her broom from the upper right, overshoots and settles; a star
-- spins off her hand when she lands from the routine's hop
[3] = {
origin = { 160, 336 },
enter = { beats = 2, key = "loop",
move = { { 0, 0, 0, -177, to = { 0, 0, "out" } }, { 0.5, 0, 0, 0 } },
{ 0, 0, 570, 0, to = { -10, nil, "out" } },
{ 0.825, 0, -10, 0, to = { 0, nil } },
{ 1.1, 0, 0, 0, to = { nil, nil, "out", sy = 0.95 } },
{ 1.225, 0, sy = 0.95, to = { nil, nil, "in", sy = 1 } },
{ 1.325, 1, 0, -10, to = { 0, 14 } },
{ 1.525, MARISA[0], 0, 7 },
{ 1.7, MARISA[0], 0, 0 }, { 1.85, MARISA[1] },
},
dance = { beats = 8, routine(MARISA),
{ key = "_star", origin = { 24, 24 }, { 0, 0, hide = true },
{ 6, 0, 44, -184, scale = 0.1, to = { 68, -187, nil, scale = 1, rot = 180 } },
{ 6.5, 0, 68, -187, rot = 180, alpha = 0.875, to = { nil, nil, nil, alpha = 0, rot = 252 } },
{ 6.7, 0, hide = true } },
},
leave = jump_out(10),
},
-- Reimu has no exit: she just vanishes
[4] = { origin = { 160, 336 }, enter = jump_in(0, 1, 2, REIMU), dance = routine(REIMU) },
})
-- Fixed slots (far-left .. far-right), from Lumen's dancer positions
local DANCER_CENTERS = { 183, 417, 640, 863, 1097 }
local DANCER_DRAW_ORDER = { 5, 1, 4, 2, 3 } -- outer pairs first, the centre on top
-- dancer index by join order: joins fill the centre, left, right, far-left, far-right slots
local DANCER_JOIN_ORDER = { 4, 3, 2, 1, 0 }
-- Chibis for good hits: one of the five hops in growing from 70% size, then zigzags 35px up
-- and down across the don band at a steady pace, Remilia and Reimu flapping every half beat.
-- Bad hits are the default spirit (toho's tamashii is the same art).
local CHIBI_LIFE = 5
local CHIBI_HOP = { length = 0.5,
{ 0, 0, -420, 22, scale = 0.7, to = { -312, -50, "in", scale = 0.89 } },
{ 0.325, 0, -312, -50, scale = 0.89, to = { -234, -15, nil, scale = 1 } },
}
local CHIBI_RUN = { from = -234, to = 915 }
local CHIBI_ZIG = { land = -15, top = -31, bottom = 4, lead = 0.4, half = 0.55 }
local CHIBIS = { { first = 0, flap = true }, { first = 2 }, { first = 3 }, { first = 4 }, { first = 5, flap = true } }
local TohoChibi = setmetatable({}, { __index = BaseChibi })
TohoChibi.__index = TohoChibi
function TohoChibi.new(spec, path, folder, bpm, is_2p)
local self = setmetatable({}, TohoChibi)
self:init(spec, path, folder, bpm, is_2p)
return self
end
function TohoChibi:update(current_time_ms)
self.t0 = self.t0 or current_time_ms
self.t = (current_time_ms - self.t0) / self.beat
self.done = self.t >= CHIBI_LIFE
end
function TohoChibi:draw()
if not self.t then return end
local t = self.t
if t < CHIBI_HOP.length then
local p = Timing.pose_at(CHIBI_HOP, t)
self:draw_image({ frame = self.first, x = p.x, y = p.y, sx = p.sx, sy = p.sy })
return
end
local run, z = t - CHIBI_HOP.length, CHIBI_ZIG
local y
if run < z.lead then
y = z.land + (z.bottom - z.land) * run / z.lead
else
local leg = (run - z.lead) / z.half
local half = leg - math.floor(leg)
if math.floor(leg) % 2 == 1 then half = 1 - half end -- odd legs fall
y = z.bottom + (z.top - z.bottom) * half
end
local x = CHIBI_RUN.from + (CHIBI_RUN.to - CHIBI_RUN.from) * run / (CHIBI_LIFE - CHIBI_HOP.length)
local frame = self.first + ((self.spec.flap and math.floor(run / 0.5) % 2 == 1) and 1 or 0)
self:draw_image({ frame = frame, x = x, y = y })
end
-- Drumroll: danmaku. Each hit fires one of nine patterns from a random spot above the dancers
-- (Lumen's generator: x 400-880, y 300-320): a big ring bullet falling down and to one side
-- over 2s, or a fan of nine small bullets 20° apart spraying 1000px downwards, slowing,
-- over 2.65s.
local RENDA_AT = { x = 400, x_range = 480, y = 300, y_range = 20 }
local RENDA_LIMIT = 25
local RINGS = { ms = 1983, dy = 680, from_y = -40, dx = { [0] = -320, 320, -160, 160 } } -- by frame
local FAN = { ms = 2650, distance = 1000, first = 100, step = 20, count = 9 }
local TohoRenda = {}
TohoRenda.__index = TohoRenda
function TohoRenda.new()
local self = setmetatable({}, TohoRenda)
tex.load_folder(COLLAB_PATH, "renda")
self._id_ring = tex.get_texture(COLLAB_PATH .. "/renda/renda_0") or 0
self._id_bullet = tex.get_texture(COLLAB_PATH .. "/renda/bullet") or 0
self.ring = tex.get_texture_info("renda", "renda_0") or {}
self.bullet = tex.get_texture_info("renda", "bullet") or {}
self.shots = {}
return self
end
function TohoRenda:add_renda()
if #self.shots >= RENDA_LIMIT then return end
local kind = math.random(0, 8) -- 0-3 rings, 4-8 fans
table.insert(self.shots, {
ring = kind < 4,
frame = kind < 4 and kind or kind - 4,
x = RENDA_AT.x + math.random(0, RENDA_AT.x_range),
y = RENDA_AT.y + math.random(0, RENDA_AT.y_range),
})
end
function TohoRenda:update(current_time_ms)
self.now = current_time_ms
for i = #self.shots, 1, -1 do
local s = self.shots[i]
s.t0 = s.t0 or current_time_ms
if current_time_ms - s.t0 >= (s.ring and RINGS.ms or FAN.ms) then table.remove(self.shots, i) end
end
end
-- draws a centred image at (x, y), turned rot degrees about its centre
local function centred(id, info, frame, x, y, rot)
local s = tex.get_screen_scale()
local w, h = (info.width or 0) / 2, (info.height or 0) / 2
tex.draw_texture(id, { frame = frame, x = x * s, y = y * s, origin = { w, h }, rotation = rot })
end
function TohoRenda:draw()
for _, s in ipairs(self.shots) do
local elapsed = (self.now or 0) - (s.t0 or self.now or 0)
if s.ring then
local p = elapsed / RINGS.ms
centred(self._id_ring, self.ring, s.frame, s.x + RINGS.dx[s.frame] * p, s.y + RINGS.from_y + RINGS.dy * p, 0)
else
local d = FAN.distance * ease_out(clamp01(elapsed / FAN.ms))
for k = 0, FAN.count - 1 do
local a = FAN.first + FAN.step * k
local r = math.rad(a)
centred(self._id_bullet, self.bullet, s.frame, s.x + d * math.sin(r), s.y - d * math.cos(r), a)
end
end
end
end
function TohoRenda:destroy() tex.unload_folder(COLLAB_PATH, "renda") end
-- Background
local TohoBackground = {}
TohoBackground.__index = TohoBackground
function TohoBackground.new(player_num, bpm)
local self = setmetatable({}, TohoBackground)
self.donbg = DonBGBase.new(1, player_num, COLLAB_PATH, DONBG_SPEC)
self.bg_normal = BGNormal.Base.new(0, COLLAB_PATH)
self.bg_fever = TohoBGFever.new(bpm)
self.fever = Fever.new(bpm, COLLAB_PATH, FEVER_SPEC)
self.dancer = Dancer.BaseDancerGroup.new(0, bpm, MAX_DANCERS, COLLAB_PATH, TOHO_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 = TohoRenda.new()
self.chibi = ChibiController.new(bpm, COLLAB_PATH, 1)
self.is_clear = false
self.is_rainbow = false
self.last_milestone = 1
return self
end
function TohoBackground: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
function TohoBackground:add_chibi(player_num)
local spec = CHIBIS[math.random(#CHIBIS)]
BaseChibi.add(self.chibi, { key = "1", first = spec.first, flap = spec.flap }, COLLAB_PATH, player_num, TohoChibi)
end
function TohoBackground:handle_good(player_num) self:add_chibi(player_num) end
function TohoBackground:handle_ok(player_num) self:add_chibi(player_num) end
function TohoBackground:handle_bad(player_num) self.chibi:add_chibi(player_num, true) end
function TohoBackground:handle_drumroll(player_num) self.renda:add_renda() end
function TohoBackground:handle_balloon(player_num) self.renda:add_renda() end
function TohoBackground: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 TohoBackground:draw_back()
self.bg_normal:draw()
self.bg_fever:draw()
self.donbg:draw()
self.renda:draw()
self.dancer:draw_slots(DANCER_CENTERS, DANCER_DRAW_ORDER)
self.fever:draw()
self.chibi:draw()
end
function TohoBackground:draw_fore() end
function TohoBackground:destroy()
self.donbg:destroy()
self.bg_normal:destroy()
self.bg_fever:destroy()
self.fever:destroy()
self.dancer:destroy()
self.renda:destroy()
self.chibi:destroy()
end
return TohoBackground