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