Files
PyTaikoGreen/Scripts/background/bg_collabs/dq10.lua
T
2026-10-09 14:19:17 -04:00

418 lines
17 KiB
Lua

local DonBGBase = require("background/bg_objects/donbg/base")
local BGNormal = require("background/bg_objects/bg_normal")
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/dq10"
local MAX_DANCERS = 5
local chain_moves, sum, pose_at, steps = Timing.chain_moves, Timing.sum, Timing.pose_at, Timing.steps
-- Don band: slimes scrolling one tile every 5.3s; clearing fades in coloured slimes over a
-- yellow glow. Ambient, in ms.
local DONBG_SPEC = { layers = {
{ key = "backdrop", tile = 0, clear_only = true },
{ key = "background", ms = 5333 },
} }
-- Clear background: the view tilts up the stage into the sky (the day panel pans down 52px
-- over 1.5 beats), then night falls: the day fades off the night panel and sparkles burst in
-- the night sky behind the arches (the front panel has the sky cut out). Losing clear fades
-- the day back in over the night as the view tilts back down onto the normal stage, which
-- the day panel matches at the bottom of its pan. Timed in beats; sparkles in ms.
local TILT, TILT_BEATS = 52, 1.5
local NIGHT_BEATS = 0.45
-- a burst every 150ms somewhere in the sky, 16 frames of 33ms; Lumen loops a fixed 4.9s
-- pattern of them, spread like this
local SPARKLE_EVERY_MS, SPARKLE_FRAME_MS, SPARKLE_FRAMES = 150, 33, 16
local SPARKLE_X, SPARKLE_Y = { 0, 1280 }, { 330, 600 }
local SPARKLE_SCALE, SPARKLE_BIG, SPARKLE_BIG_CHANCE = { 0.3, 0.8 }, 1.1, 0.15
local DQ10BGFever = {}
DQ10BGFever.__index = DQ10BGFever
function DQ10BGFever.new(bpm)
local self = setmetatable({}, DQ10BGFever)
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_day = id("day")
self._id_night = id("night")
self._id_front = id("front")
self._id_sparkle = id("sparkle")
self.sparkle_half = (tex.get_texture_info(self.name, "sparkle") or {}).width or 256
self.sparkle_half = self.sparkle_half / 2
self.beat = 60000 / bpm
self.state = nil -- nil (hidden), "in", "out"
self.tilt = { from = 0, moves = {} }
self.day = { from = 1000, moves = {} }
self.sparkles = {}
return self
end
local function track(t) return t.from + sum(t.moves) end
local function retrack(t, moves, beat)
t.from, t.moves = track(t), chain_moves(moves, beat, 1)
end
-- both directions start from wherever the tilt and the day fade are
function DQ10BGFever:start()
local tilt, day = self.state and track(self.tilt) or 0, self.state and track(self.day) or 1000
self.tilt.from, self.day.from = tilt, day
local up = TILT_BEATS * (TILT - tilt) / TILT
self.tilt.moves = chain_moves({ { up, TILT - tilt } }, self.beat, 1)
self.day.moves = chain_moves({ { up, 0 }, { NIGHT_BEATS * day / 1000, -day } }, self.beat, 1)
self.state = "in"
end
function DQ10BGFever:stop()
if self.state ~= "in" then return end
local tilt, day = track(self.tilt), track(self.day)
retrack(self.tilt, { { TILT_BEATS * tilt / TILT, -tilt } }, self.beat)
retrack(self.day, { { NIGHT_BEATS * (1000 - day) / 1000, 1000 - day } }, self.beat)
self.state = "out"
end
function DQ10BGFever:is_night() return self.state == "in" and track(self.day) <= 0 end
function DQ10BGFever:update(current_time_ms, bpm)
if bpm then self.beat = 60000 / bpm end
if not self.state then return end
for _, t in ipairs({ self.tilt, self.day }) do
for _, m in ipairs(t.moves) do m:update(current_time_ms) end
end
if self.state == "out" and track(self.tilt) <= 0 then
self.state = nil
return
end
local now = current_time_ms
for i = #self.sparkles, 1, -1 do
if now - self.sparkles[i].t0 >= SPARKLE_FRAMES * SPARKLE_FRAME_MS then table.remove(self.sparkles, i) end
end
if not self:is_night() then
self.next_sparkle = nil
elseif not self.next_sparkle or now >= self.next_sparkle then
local s = math.random() < SPARKLE_BIG_CHANCE and SPARKLE_BIG
or SPARKLE_SCALE[1] + math.random() * (SPARKLE_SCALE[2] - SPARKLE_SCALE[1])
table.insert(self.sparkles, {
t0 = now, scale = s,
x = math.random(SPARKLE_X[1], SPARKLE_X[2]), y = math.random(SPARKLE_Y[1], SPARKLE_Y[2]),
})
self.next_sparkle = now + SPARKLE_EVERY_MS
end
self.now = now
end
function DQ10BGFever:draw()
if not self.state then return end
local scale = tex.get_screen_scale()
local y = math.floor((track(self.tilt) - TILT) * scale)
tex.draw_texture(self._id_night, { y = y })
if self:is_night() then
for _, s in ipairs(self.sparkles) do
tex.draw_texture(self._id_sparkle, {
frame = math.min(SPARKLE_FRAMES - 1, math.floor((self.now - s.t0) / SPARKLE_FRAME_MS)),
center = true,
scale = s.scale,
blend = "additive",
x = s.x * scale - self.sparkle_half,
y = s.y * scale - self.sparkle_half,
})
end
tex.draw_texture(self._id_front, { y = y })
end
local day = track(self.day) / 1000
if day > 0 then tex.draw_texture(self._id_day, { y = y, fade = day }) end
end
function DQ10BGFever:destroy() tex.unload_folder(COLLAB_PATH, "bg_fever/" .. self.name) end
-- Fever: the song's closing notes call up the five races, a pair from both edges each:
-- the first balloon, then five dons near the end of the chart. The last pair brings the
-- DRAGON QUEST X logo: the art fades in, the title shrinks into place, the X zooms down
-- onto it, and a glow and the subtitle fade in. Fades in beats. Each character is an
-- outline (frame 0) and its colours (frame 1); a side's outlines all sit under its colours.
local CAST_FADE = 0.45
local CAST_DEPTH = { 1, 3, 6, 2, 4, 5 } -- draw order of right_N / left_N (N = entrance order)
local CAST_PAIRS = #CAST_DEPTH
-- chart times (ms from the song's first update, shifted by the offset) of the dons that bring
-- pairs 2-6; pair 1 comes with the first balloon
local FEVER_DON_TIMES_MS = { 70966, 71941, 72917, 77343, 79326 }
local CHART_OFFSET_MS = -3715 - 975
local function fade_in(beat, length)
return { { beat, 0, 0, 0, alpha = 0, to = { nil, nil, nil, alpha = 1 } }, { beat + length, 0, 0, 0 } }
end
-- logo layers in draw order, in beats from the last pair
local LOGO = {
{ "art_glow", fade_in(6.05, 1.5) },
{ "art", fade_in(0, 1.05) },
{ "subtitle", fade_in(8.4, 1.5) },
{ "logo", { { 1.9, 0, 0, 0, alpha = 0, to = { nil, nil, "in", alpha = 1, scale = 0.605 } },
{ 4.65, 0, 0, 0, scale = 0.605 } } },
{ "x", { { 3.225, 0, 0, 0, alpha = 0, scale = 1.78, to = { nil, nil, "in", alpha = 1, scale = 0.605 } },
{ 5.425, 0, 0, 0, scale = 0.605 } } },
}
local CAST_POSES = fade_in(0, CAST_FADE)
local DQ10Fever = {}
DQ10Fever.__index = DQ10Fever
function DQ10Fever.new(bpm)
local self = setmetatable({}, DQ10Fever)
self.name = "fever_0"
tex.load_folder(COLLAB_PATH, "fever/" .. self.name)
local function id(key) return tex.get_texture(COLLAB_PATH .. "/" .. self.name .. "/" .. key) or 0 end
self.cast = {}
for _, side in ipairs({ "right", "left" }) do
for _, n in ipairs(CAST_DEPTH) do table.insert(self.cast, { pair = n, id = id(side .. "_" .. n) }) end
end
self.logo = {}
for _, l in ipairs(LOGO) do table.insert(self.logo, { id = id(l[1]), poses = l[2] }) end
self.beat = 60000 / bpm
self.shown = {} -- ms each pair was called up
self.pending = 0
self.next_trigger = 1
return self
end
-- calls up the next pair
function DQ10Fever:pulse() self.pending = self.pending + 1 end
function DQ10Fever:update(current_time_ms, bpm)
local now = current_time_ms
if bpm then self.beat = 60000 / bpm end
if not self.epoch_zero then self.epoch_zero = now end
local ms_from_start = (now - self.epoch_zero) + CHART_OFFSET_MS
while self.next_trigger <= #FEVER_DON_TIMES_MS and ms_from_start >= FEVER_DON_TIMES_MS[self.next_trigger] do
self:pulse()
self.next_trigger = self.next_trigger + 1
end
for _ = 1, self.pending do
if #self.shown < CAST_PAIRS then table.insert(self.shown, now) end
end
self.pending, self.now = 0, now
end
function DQ10Fever:beats_since(ms) return (self.now - ms) / self.beat end
function DQ10Fever:draw()
if #self.shown == 0 then return end
for i = 1, #self.cast, CAST_PAIRS do -- per side: outlines, then colours
for frame = 0, 1 do
for k = i, i + CAST_PAIRS - 1 do
local c = self.cast[k]
local p = self.shown[c.pair] and pose_at(CAST_POSES, self:beats_since(self.shown[c.pair]))
if p then tex.draw_texture(c.id, { frame = frame, fade = p.alpha }) end
end
end
end
local last = self.shown[CAST_PAIRS]
if not last then return end
for _, l in ipairs(self.logo) do
local p = pose_at(l.poses, self:beats_since(last))
if p then tex.draw_texture(l.id, { center = true, scale = p.sx, fade = p.alpha }) end
end
end
function DQ10Fever:destroy() tex.unload_folder(COLLAB_PATH, "fever/" .. self.name) end
-- Dancers: one of two sets per play, five of the same character. Timed in beats; poses are
-- Lumen's offsets from the dancer's origin (the centre of its images' Lumen box).
-- set 0: spins round as it hops in, then a 24-step routine, a third of a beat a step;
-- leaving, it springs up and drops away
local SPIN_DANCER = {
origin = { 112, 128 },
enter = {
beats = 1.975,
move = {
{ 0, 0, 0, 296, to = { 0, -52, "out" } },
{ 0.85, 0, 0, -52, to = { 0, 10, "in" } },
{ 1.275, 0, 0, 10, to = { 0, 0, "out" } },
{ 1.625, 0, 0, 0 },
},
steps({ { 0, 0.125, x = 0, y = -125, key = "loop" }, { 1, 0.125, key = "loop" }, { 2, 0.125, key = "loop" },
{ 3, 0.125, key = "loop" }, { 4, 0.125, key = "loop" }, { 5, 0.125, key = "loop" },
{ 6, 0.125, key = "loop" }, { 7, 0.125, key = "loop" }, { 8, 0.125, key = "loop" },
{ 9, 0.35, key = "loop" }, { 10, 0.125, key = "loop" }, { 11, 0.125, key = "loop" },
{ 12, 0.125, key = "loop" }, { 13, 0.125, key = "loop" } }),
},
dance = steps({
{ 14, 1 / 3, x = 0, y = -125 }, { 15, 1 / 3 }, { 16, 1 / 3 }, { 17, 1 / 3 }, { 18, 1 / 3 }, { 19, 1 / 3 },
{ 14, 1 / 3 }, { 15, 1 / 3 }, { 20, 1 / 3 }, { 21, 1 / 3 },
{ 0, 1 / 3 }, { 1, 1 / 3 }, { 2, 1 / 3 }, { 3, 1 / 3 }, { 4, 1 / 3 }, { 5, 1 / 3 }, { 6, 1 / 3 },
{ 7, 1 / 3 }, { 8, 1 / 3 }, { 9, 1 / 3 }, { 10, 1 / 3 }, { 11, 1 / 3 }, { 12, 1 / 3 }, { 13, 1 / 3 },
}),
leave = {
beats = 1.2,
{ 0, 11, 0, -125, key = "loop" },
{ 0.15, 13, 0, -125, key = "loop", to = { 0, -176, "out" } },
{ 0.425, 12, 0, -181, key = "loop" },
{ 0.5, 11, 0, -181, key = "loop", to = { 0, 203, "in" } },
},
}
-- set 1: shoots up from below and settles, then an 8-beat routine of beat-long poses with
-- quick in-betweens and a hop; leaving, it bobs up and drops away
local HOP_DANCER = {
origin = { 148, 148 },
enter = {
beats = 2,
{ 0.25, 0, 0, 226, to = { 0, -139, "out" } },
{ 1.375, 0, 0, -135, to = { 0, -99 } },
{ 1.625, 1, 0, -99, key = "loop" },
{ 1.75, 2, 0, -99, key = "loop" },
},
dance = steps({
{ 3, 0.75, x = 0, y = -99 }, { 4, 0.25 }, { 2, 0.75 }, { 5, 0.25 }, { 6, 0.75 }, { 7, 0.25 },
{ 8, 0.85 }, { 1, 0.15 },
{ 9, 0.4, y = -79, to = { 0, -99, "out" } }, { 9, 0.375, to = { 0, -79, "in" } }, { 9, 0.1 },
{ 1, 0.125, y = -99 },
{ 10, 0.75 }, { 11, 0.25 }, { 12, 0.75 }, { 13, 0.125 }, { 14, 0.125 }, { 2, 0.75 }, { 15, 0.25 },
}),
leave = {
beats = 1.525,
{ 0, 16, 0, -99, key = "loop", to = { 0, -109, "out" } },
{ 0.4, 16, 0, -109, key = "loop", to = { 0, 226, "in" } },
},
}
-- slot centres, far-left .. far-right; the outer pairs are drawn first, the centre on top
local DANCER_SETS_SPECS = {
[0] = { spec = { default = SPIN_DANCER }, centers = { 183, 417, 640, 863, 1097 } },
[1] = { spec = { default = HOP_DANCER }, centers = { 172, 406, 640, 874, 1108 } },
}
local DANCER_DRAW_ORDER = { 5, 1, 4, 2, 3 }
-- Chibis for good hits: one of three characters hops in and runs across, each step a
-- shuffle back on its walking frames and a skip forward
local CHIBI_POSES = {
{ 0, 1, 0, 0 }, { 0.025, 2, -2, 0 }, { 0.05, 3, -4, 0 }, { 0.075, 2, -6, 0 },
{ 0.1, 1, -8, 0, to = { -40, 0 } },
{ 0.25, 2, -40, 0 },
{ 0.275, 3, -40, 0 },
{ 0.35, 3, -41, 0, to = { -38, -20 } },
{ 0.45, 4, -38, -20, to = { -32, -25 } },
{ 0.525, 0, -32, -25, to = { -30, -28 } },
{ 0.6, 5, -30, -28, to = { -14, -35, "in" } },
{ 0.775, 0, -14, -35, to = { -8, -20, "in" } },
{ 0.9, 0, -8, -20, to = { 0, 0 } },
}
local CHIBIS = {}
for k = 0, 2 do
CHIBIS[k + 1] = { key = tostring(k), hop = { from = { -418, 19 }, peak = { -326, -36 }, up = 0.3 },
run = { y = 0 }, period = 1, poses = CHIBI_POSES }
end
-- Drumroll: the default renda_a_03 flight, up and to the right
local RENDA_SPEC = { ms = { 2000 }, dx = 1560, dy = -1648, x_range = 500, y_range = 20 }
-- Background
local DQ10Background = {}
DQ10Background.__index = DQ10Background
function DQ10Background.new(player_num, bpm)
local self = setmetatable({}, DQ10Background)
self.donbg = DonBGBase.new(1, player_num, COLLAB_PATH, DONBG_SPEC)
self.bg_normal = BGNormal.Base.new(0, COLLAB_PATH)
self.bg_fever = DQ10BGFever.new(bpm)
self.footer = Footer.new(COLLAB_PATH, 0)
self.fever = DQ10Fever.new(bpm)
local set = math.random(0, 1)
self.dancer_centers = DANCER_SETS_SPECS[set].centers
local dancer_class = Dancer.define(DANCER_SETS_SPECS[set].spec)
self.dancer = Dancer.BaseDancerGroup.new(set, bpm, MAX_DANCERS, COLLAB_PATH, dancer_class)
self.renda = RendaController.new(COLLAB_PATH, 0, RENDA_SPEC)
self.chibi = ChibiController.new(bpm, COLLAB_PATH, 1)
self.is_clear = false
self.is_rainbow = false
self.last_milestone = 1
self.balloon_pulsed = false
return self
end
function DQ10Background: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, CHIBIS[math.random(#CHIBIS)], COLLAB_PATH, player_num)
end
function DQ10Background:handle_good(player_num) add_chibi(self, player_num) end
function DQ10Background:handle_ok(player_num) add_chibi(self, player_num) end
function DQ10Background:handle_bad(player_num) self.chibi:add_chibi(player_num, true) end
function DQ10Background:handle_drumroll(player_num) self.renda:add_renda() end
function DQ10Background:handle_balloon(player_num)
self.renda:add_renda()
if not self.balloon_pulsed then
self.balloon_pulsed = true
self.fever:pulse()
end
end
function DQ10Background: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
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 DQ10Background:draw_back()
self.bg_normal:draw()
self.bg_fever:draw()
self.footer:draw()
self.donbg:draw()
self.renda:draw()
self.dancer:draw_slots(self.dancer_centers, DANCER_DRAW_ORDER)
self.fever:draw()
self.chibi:draw()
end
function DQ10Background:draw_fore() end
function DQ10Background: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 DQ10Background