Files
PyTaikoGreen/Scripts/campaign/smart_scan.lua
T
2026-11-02 16:06:18 -05:00

410 lines
17 KiB
Lua

local Timing = require("background/bg_objects/timing")
SmartScan = {}
SmartScan.__index = SmartScan
local S = tex.get_screen_scale()
local DIR = "campaign/smart_scan"
local PHASE_MS = { title = 3267, gift = 3583, open = 967, fadeout = 1033 }
local NEXT_PHASE = { title = "gift", gift = "open", open = "keep" }
local TITLE_OUT_MS, TITLE_END_MS = 3183, 3400
local KEEP_S = 30
local FOUND_MS = 5000
local LOCKON_MS, SUCCESS_MS, PRESENT_MS, END_MS, FADEOUT_MS = 33, 2433, 4417, 7750, 12200
local CAMERA = { 334, 66, 612, 452 }
local WHITE, BLACK = { 255, 255, 255, 255 }, { 0, 0, 0, 255 }
local PRIZES = { v3c = { 0, 1, 2, 3, 4, 5, 6, 7 }, korokoro = { 8 }, chao = { 9 } }
local COSTUME = { [0] = 25, 26, 27, 28, 29, 30, 31, 35, 90, 91 }
local CUES = {
{ "title", 0, "smart_vo_campaign" }, { "gift", 150, "smart_vo_moraeru" }, { "gift", 150, "smart_se_popup" },
{ "open", 0, "smart_se_boardslide" }, { "open", 483, "smart_se_frame" }, { "keep", 0, "smart_vo_advice" },
{ "success", 0, "smart_se_camerashot" }, { "success", 33, "smart_se_lockon" }, { "success", 1117, "smart_se_hit" },
{ "success", 1550, "smart_se_hit" }, { "success", 1700, "smart_se_hit" }, { "success", 1867, "smart_se_hit" },
{ "success", 2117, "smart_se_hit" }, { "success", 2433, "smart_vo_success" },
{ "success", 2433, "smart_se_success" }, { "success", 4683, "smart_se_itembound" },
{ "success", 5850, "smart_se_metamor" }, { "success", 7117, "smart_vo_kisekaeget" },
}
-- sparkle bursts around the lock-on: ms, offset from the window centre, scale
-- rings close in one after another: start, gone (ms into the lock-on)
local RINGS = { { 33, 817 }, { 200, 983 }, { 367, 1000 } }
local BURSTS = { { 1550, 91, -106, 1 }, { 1700, -134, 30, 1.2 }, { 1867, 54, 106, 1.5 }, { 2117, -77, -134, 0.8 } }
-- twinkles around the present: ms into the present, offset from (540, 494), scale
local TWINKLES = {
{ 1450, -416, -230, 1 }, { 1450, -338, -14, 1.22 }, { 1450, 492, -314, 1.21 }, { 1450, 636, -92, 1 },
{ 1817, 530, -54, 1 }, { 1817, -299, -159, 1.22 }, { 1817, -377, -314, 1.21 }, { 1817, 672, -243, 1 },
{ 2167, -299, -53, 1 }, { 2167, -477, -139, 1.3 }, { 2167, 589, -421, 1.33 }, { 2167, 542, -166, 1 },
{ 2167, -426, -433, 1.45 }, { 2167, 672, -275, 1.29 },
}
local function play(name)
if audio.has_sound(name) then audio.play_sound(name, name:find("_vo_") and "voice" or "sound") end
end
local function clamp01(v) return math.max(0.0, math.min(1.0, v)) end
local function ramp(t, at, ms) return clamp01((t - at) / ms) end
local function ease_in(u) return u * u end
local function ease_out(u) return u * (2 - u) end
local function screen_rect(r, g, b, a)
tex.draw_rect(0, 0, tex.get_screen_width(), tex.get_screen_height(), r, g, b, a)
end
-- draws `texture` scaled by sx / sy about its centre, moved by dx / dy (unscaled pixels)
local function draw(texture, opts)
opts = opts or {}
local sx, sy = opts.sx or 1, opts.sy or opts.sx or 1
if sx == 1 and sy == 1 then
tex.draw_texture(texture.id, { x = (opts.dx or 0) * S, y = (opts.dy or 0) * S, fade = opts.fade,
frame = opts.frame, mirror = opts.mirror })
return
end
local w, h = texture.w, texture.h
tex.draw_texture(texture.id, {
x = w * (1 - sx) / 2 + (opts.dx or 0) * S, y = h * (1 - sy) / 2 + (opts.dy or 0) * S,
x2 = w * (sx - 1), y2 = h * (sy - 1), fade = opts.fade, frame = opts.frame, mirror = opts.mirror,
})
end
local function lines(key, size)
local out = {}
for line in tex.skin_text(key):gmatch("[^\n]+") do
out[#out + 1] = text.create_raw_text(line, math.floor(size * S), WHITE, BLACK, false, 5, 2.0)
end
return out
end
-- left: left edge (nil centres on cx)
local function draw_lines(list, cx, cy, pitch, max_w, fade, left)
for i, t in ipairs(list) do
local y = (cy + (i - (#list + 1) / 2) * pitch) * S
local scale = math.min(1, max_w * S / t.width)
local x = left and left * S or cx * S - t.width * scale / 2
t:draw({ x = x, y = y - t.height * scale / 2, scale = scale, fade = fade })
end
end
function SmartScan.new(id)
local self = setmetatable({}, SmartScan)
self.id = PRIZES[id] and id or "v3c"
self.tex = {}
local function load(name, key)
local info = tex.get_texture_info(DIR, key)
self.tex[name] = { id = tex.get_texture(DIR .. "/" .. key), w = info and info.x2[1] or 0,
h = info and info.y2[1] or 0 }
end
for _, key in ipairs({
"bg", "title_bg", "gift_note", "scroll", "guide_text", "frame", "window", "skip_label", "keep_box", "ring",
"rays", "stars", "stars_white", "flash", "success_text", "don_jump", "box", "box_open", "box_empty", "poof",
"flowers", "sparkle", "name_bar", "get_text", "prize", "smoke",
}) do
load(key, key)
end
for _, key in ipairs({ "title", "gift", "side", "guide" }) do load(key, key .. "_" .. self.id) end
if self.id ~= "v3c" then load("deco", "deco_" .. self.id) end
self.timer = tex.get_texture("campaign/timer/timer")
self.timer_glow = tex.get_texture("campaign/timer/timer_glow")
self.digits = tex.get_texture("campaign/timer/digits")
self.keep_text = lines("campaign_smart_keep", 39)
self.keep_alt = lines("campaign_smart_keep_alt", 39)
local pool = PRIZES[self.id]
self.prize = pool[math.random(#pool)]
self.prize_name = lines("campaign_smart_prize_" .. self.prize, 32)
self.camera_open = camera.open()
if audio.has_sound("smart_bgm") then audio.play_sound("smart_bgm", "music") end
self.now = tex.get_current_ms()
self:enter("title")
return self
end
function SmartScan:moves(list, scale)
local out = Timing.chain_moves(list, 1, scale or S)
for _, m in ipairs(out) do self.anims[#self.anims + 1] = m end
return out
end
function SmartScan:enter(phase)
self.phase = phase
self.t0 = self.now
self.cue_t = -1
if phase == "fadeout" then return end -- the success scene keeps moving under the fade
self.anims = {}
self.m = {}
local m = self.m
if phase == "open" then
m.side_y = self:moves({ { 183, -560, "out" } })
m.scroll_y = self:moves({ { 183, 566, "out" } })
elseif phase == "success" then
local s = SUCCESS_MS
m.jump_y = self:moves({ { s, 0 }, { 383, -482 }, { 617, 0 }, { 150, 34 }, { 167, -34 }, { 17, 0 },
{ 317, -722 } })
m.jump_scale = self:moves({ { s, 0 }, { 383, 24 }, { 100, -10 } }, 1)
-- the gift drops into the window bottom, bounces, then opens
local p = PRESENT_MS
m.box_y = self:moves({ { p, 0 }, { 267, 696 }, { 83, 6, "out" }, { 100, -6, "in" } })
m.icon_y = self:moves({ { p + 1450, 0 }, { 350, -210, "out" }, { 383, -10 } })
m.icon_scale = self:moves({ { p + 1450, 0 }, { 350, 50, "out" } }, 1)
m.flowers_y = self:moves({ { p + 1450, 0 }, { 350, -250 } })
end
end
function SmartScan:elapsed() return self.now - self.t0 end
function SmartScan:seconds_left()
if self.phase ~= "keep" then return KEEP_S end
return math.max(0, KEEP_S - math.floor(self:elapsed() / 1000))
end
function SmartScan:update(current_ms)
self.now = current_ms
if self.done then return end
for _, a in ipairs(self.anims) do a:update(current_ms) end
local t = self:elapsed()
for _, cue in ipairs(CUES) do
if cue[1] == self.phase and cue[2] > self.cue_t and cue[2] <= t then play(cue[3]) end
end
self.cue_t = t
if self.phase == "keep" and t >= FOUND_MS then
self:found()
elseif self.phase == "success" and t >= FADEOUT_MS then
self:enter("fadeout")
elseif self.phase == "fadeout" and t >= PHASE_MS.fadeout then
self.done = true
self:close()
elseif NEXT_PHASE[self.phase] and t >= PHASE_MS[self.phase] then
self:enter(NEXT_PHASE[self.phase])
end
if self.camera_open and (self.phase == "open" or self.phase == "keep") then camera.update() end
end
function SmartScan:found()
audio.stop_sound("smart_vo_advice")
self:enter("success")
end
function SmartScan:close()
audio.stop_sound("smart_bgm")
if self.camera_open then
camera.close()
self.camera_open = false
end
end
function SmartScan:is_done() return self.done == true end
function SmartScan:get_reward() return COSTUME[self.prize] end
function SmartScan:input(action)
if action ~= 3 or self.done then return false end
if self.phase == "keep" then
self:found()
elseif self.phase == "success" and self:elapsed() >= END_MS then
self:enter("fadeout")
else
return false
end
return true
end
function SmartScan:draw_title(t)
local tx = self.tex
local out = ramp(t, TITLE_OUT_MS, TITLE_END_MS - TITLE_OUT_MS)
local deco_fade = 0.93 * (1 - out)
if out == 0 then deco_fade = 1 end
if self.id == "korokoro" then
local d = tx.deco
draw(d, { fade = deco_fade })
draw(d, { dx = 640, fade = deco_fade, mirror = "horizontal" })
draw(d, { dy = 360, fade = deco_fade, mirror = "vertical" })
draw(d, { dx = 640, dy = 360, fade = deco_fade, mirror = "both" })
elseif self.id == "chao" then
draw(tx.deco, { fade = deco_fade })
draw(tx.deco, { dx = 640, fade = deco_fade, mirror = "horizontal" })
end
local fade, scale = ramp(t, 0, 167), 1.1 - 0.1 * ramp(t, 0, 167)
if out > 0 then fade, scale = 0.93 * (1 - out), 1.007 + 0.093 * out end
draw(tx.title, { sx = scale, fade = fade })
end
function SmartScan:draw_gift(t)
local tx = self.tex
local fade = ramp(t, 167, 150) * (1 - ramp(t, 3500, 83))
draw(tx.gift, { fade = fade })
if self.id == "v3c" then draw(tx.gift_note, { fade = fade }) end
end
-- left side panel, board and guide picture (open onwards)
function SmartScan:draw_boards(t, fade)
local tx, m = self.tex, self.m
local open = self.phase == "open"
local side_y = open and 560 * S + Timing.sum(m.side_y) or 0
local scroll_y = open and -566 * S + Timing.sum(m.scroll_y) or 0
tex.draw_texture(tx.side.id, { y = side_y, fade = fade })
tex.draw_texture(tx.scroll.id, { y = scroll_y, fade = fade })
local text_fade, pic_fade = 1, 1
if open then text_fade, pic_fade = ramp(t, 183, 300), ramp(t, 200, 283) end
draw(tx.guide_text, { fade = fade * text_fade })
draw(tx.guide, { fade = fade * pic_fade })
end
function SmartScan:draw_timer()
local s = tostring(self:seconds_left())
tex.draw_texture(self.timer_glow, { x = 141.4 * S, y = -236.7 * S, fade = 0.3 })
tex.draw_texture(self.timer, { x = 142.8 * S, y = -235.3 * S })
draw(self.tex.skip_label)
local left = #s == 1 and 1140 or 1118
for i = 1, #s do
tex.draw_texture(self.digits, { frame = tonumber(s:sub(i, i)), x = (left + (i - 1) * 44) * S,
y = (67 - 292.5) * S })
end
end
function SmartScan:draw_keep(t)
local tx = self.tex
local fade = ramp(t, 0, 150)
draw(tx.window)
draw(tx.keep_box, { fade = fade })
-- the two instructions cross-fade on a 4 s loop
local u = t < 150 and 0 or (t - 150) % 4000
local a = u < 83 and 1 or u < 1000 and 1 - ease_in((u - 83) / 917) or u < 3000 and 0 or (u - 3000) / 1000
local b = u < 1000 and 0 or u < 2000 and (u - 1000) / 1000 or u < 3000 and 1 - (u - 2000) / 1000 or 0
draw_lines(self.keep_text, 0, 616.5, 46, 700, fade * a, 289)
draw_lines(self.keep_alt, 641, 618.5, 0, 700, fade * b)
self:draw_timer()
end
function SmartScan:draw_lockon(t)
local tx = self.tex
for _, r in ipairs(RINGS) do
local u = ease_in(clamp01((t - r[1]) / 617))
if t >= r[1] and t < r[2] then draw(tx.ring, { sx = 2 - 1.2 * u, fade = u }) end
end
if t >= 1083 and t < 1150 then draw(tx.ring, { sx = 0.8 + 0.2 * ramp(t, 1083, 67), fade = 1 - ramp(t, 1083, 67) }) end
if t >= 1000 and t < 1083 then draw(tx.ring, { sx = 0.8 - 0.05 * ramp(t, 1000, 83), fade = 1 - ramp(t, 1000, 83) }) end
if t >= 1083 and t < 1650 then
local u = ease_out(ramp(t, 1083, 567))
draw(tx.rays, { sx = 0.75 + 0.75 * u, fade = 1 - u })
end
if t >= 1200 and t < 1500 then draw(tx.stars, { sx = 1 + 0.5 * ramp(t, 1200, 300), fade = 1 - ramp(t, 1200, 300) }) end
if t >= 1350 and t < 1550 then draw(tx.stars, { sx = 0.5 + 0.7 * ramp(t, 1350, 200), fade = 1 - ramp(t, 1350, 200) }) end
if t >= 1483 and t < 1667 then
draw(tx.stars_white, { sx = 0.75 + 0.25 * ramp(t, 1483, 183), fade = 0.75 * (1 - ramp(t, 1483, 183)) })
end
for _, b in ipairs(BURSTS) do
local s = t - b[1] + LOCKON_MS
if s >= 0 and s < 333 then
local dx, dy = b[2], b[3]
local u = clamp01(s / 233)
draw(tx.flash, { dx = dx, dy = dy, sx = b[4] * (0.244 + 0.756 * u), fade = 1 - u })
local v = clamp01((s - 67) / 233)
if s >= 67 then draw(tx.stars_white, { dx = dx, dy = dy, sx = b[4] * (0.294 + 0.706 * v), fade = 1 - v }) end
end
end
end
function SmartScan:draw_success_text(t)
local tx, m = self.tex, self.m
local u = t - SUCCESS_MS
if u < 0 or u >= 1650 then return end
if u < 1467 then
local scale, fade
if u < 367 then
scale, fade = 0.222 + 0.855 * ease_out(u / 367), 0.79 * u / 367
else
scale, fade = 1.079 - 0.079 * ramp(u, 367, 116), 0.79 + 0.21 * ramp(u, 367, 116)
end
draw(tx.success_text, { sx = scale, fade = fade * (1 - ramp(u, 1333, 134)) })
end
local scale = 0.86 + Timing.sum(m.jump_scale) / 100
local squash = u >= 1000 and u < 1317 and 1 - 0.035 * (u < 1167 and ramp(u, 1000, 150) or 1 - ramp(u, 1167, 150))
draw(tx.don_jump, { dy = 482 + Timing.sum(m.jump_y) / S, sx = scale, sy = scale * (squash or 1) })
end
function SmartScan:draw_present(t)
local tx, m = self.tex, self.m
local u = t - PRESENT_MS
if u < 0 then return end
local flowers_dy = 250 + Timing.sum(m.flowers_y) / S
if u >= 1450 then
draw(tx.flowers, { dy = flowers_dy })
draw(tx.flowers, { dx = 760, dy = flowers_dy, mirror = "horizontal" })
end
for _, s in ipairs(TWINKLES) do
local v = u - s[1]
if v >= 0 and v < 1000 then
local fade = v < 467 and ease_in(v / 467) or 1 - ease_out(clamp01((v - 467) / 467))
draw(tx.sparkle, { dx = 540 + s[2] - 32, dy = 494 + s[3] - 32, sx = s[4], fade = fade })
end
end
-- the box squashes on landing and again before it opens
local box_dy = -696 + Timing.sum(m.box_y) / S
local sx, sy = 1, 1
if u < 250 then sx, sy = 0.95 + 0.044 * u / 250, 1.22 - 0.194 * u / 250
elseif u < 367 then local k = ease_out(ramp(u, 250, 100)); sx, sy = 1 + 0.16 * k, 1.013 - 0.108 * k
elseif u < 467 then local k = ramp(u, 367, 100); sx, sy = 1.107 - 0.107 * k, 0.987 + 0.163 * k - 0.15 * ramp(u, 400, 67)
elseif u >= 650 and u < 733 then local k = ramp(u, 650, 67); sx, sy = 1 + 0.16 * k, 1 - 0.095 * k
end
if u < 733 then
draw(tx.box, { dy = box_dy, sx = sx, sy = sy })
elseif u < 1450 then
draw(tx.box_open, { dy = 3 * ease_in(ramp(u, 867, 567)), sx = 0.93 + 0.07 * ramp(u, 733, 117),
sy = 1 - 0.05 * ease_in(ramp(u, 867, 567)) })
else
local k = u < 1600 and ease_out(ramp(u, 1450, 150)) or 1 - ease_in(ramp(u, 1617, 133))
draw(tx.box_empty, { dy = -11.4 * k, sy = 1 + 0.156 * k })
end
if u >= 1450 then
draw(tx.prize, { frame = self.prize, dy = 220 + Timing.sum(m.icon_y) / S,
sx = 0.5 + Timing.sum(m.icon_scale) / 100 })
end
if u >= 1450 and u < 1733 then
local k = ease_out(ramp(u, 1450, 267))
draw(tx.poof, { dy = 40 * (1 - k), sx = 0.1 + 0.9 * k, fade = k })
elseif u >= 1733 and u < 2033 then
local step = math.min(2, math.floor((u - 1733) / 100))
local dy = step < 2 and -(u - 1733) / 10 or -20 - 19 * ramp(u, 1933, 100)
draw(tx.smoke, { frame = step, dy = dy, fade = step == 2 and 1 - ramp(u, 1933, 100) or 1 })
end
if u >= 2200 then
draw(tx.name_bar)
draw_lines(self.prize_name, 647, 481, 0, 330, 1)
end
if u >= 2717 then draw(tx.get_text, { dy = -20 * (1 - ramp(u, 2717, 100)), fade = ramp(u, 2717, 100) }) end
end
function SmartScan:draw()
if self.done then return end
screen_rect(0, 0, 0, 255)
local tx = self.tex
local phase, t = self.phase, self:elapsed()
local windowed = phase == "open" or phase == "keep" or phase == "success" or phase == "fadeout"
if windowed then
camera.draw(CAMERA[1] * S, CAMERA[2] * S, CAMERA[3] * S, CAMERA[4] * S)
end
draw(tx.bg)
if phase == "title" or phase == "gift" or (phase == "open" and t < 850) then
draw(tx.title_bg, { fade = phase == "open" and 1 - ramp(t, 750, 100) or 1 })
end
if phase == "title" then
self:draw_title(t)
elseif phase == "gift" then
if t < TITLE_END_MS - PHASE_MS.title then self:draw_title(PHASE_MS.title + t) end
self:draw_gift(t)
elseif phase == "open" or phase == "keep" then
self:draw_boards(t, 1)
draw(tx.frame, { fade = phase == "open" and ramp(t, 500, 233) or 1 })
if phase == "keep" then self:draw_keep(t) end
else
local st = phase == "success" and t or FADEOUT_MS + t
if st < 150 then self:draw_boards(st, 1 - st / 150) end
draw(tx.frame)
if st >= LOCKON_MS and st < SUCCESS_MS + 50 then self:draw_lockon(st - LOCKON_MS) end
self:draw_success_text(st)
self:draw_present(st)
if phase == "fadeout" then screen_rect(0, 0, 0, 255 * ramp(t, 17, 950)) end
end
end