Files
PyTaikoGreen/Scripts/campaign/kinotake.lua
T
2026-11-03 23:40:03 -05:00

338 lines
13 KiB
Lua

require("entry/campaign_menu")
require("campaign/smart_scan")
Kinotake = {}
Kinotake.__index = Kinotake
local S = tex.get_screen_scale()
local DIR = "campaign/kinotake"
local F = 1000 / 60
local LEFT, RIGHT, DECIDE = 1, 2, 3
local KINOKO, TAKENOKO = 0, 1
local COSTUME = { [KINOKO] = 105, [TAKENOKO] = 106 }
local TITLE_F = 229
local CURSOR_X = { [KINOKO] = -150, [TAKENOKO] = 145 }
local SIDE_WAIT_F, SIDE_FADE_F, MOVE_F = 60, 20, 5
local SIDE_SELECT_MS = 20000
local BALLOON_WAIT_F, BALLOON_F = 120, 129
local GIFT_F = 220
local function clamp01(v) return math.max(0.0, math.min(1.0, v)) end
local function ramp(t, at, len) return clamp01((t - at) / len) end
local function ease_in(u) return u * u end
local function ease_out(u) return u * (2 - u) end
-- { frame, value, ease of the segment ending here } -> value at frame f
local function track(f, keys)
if f <= keys[1][1] then return keys[1][2] end
for i = 2, #keys do
local a, b = keys[i - 1], keys[i]
if f < b[1] then
local u = (f - a[1]) / (b[1] - a[1])
if b[3] == "out" then u = ease_out(u) elseif b[3] == "in" then u = ease_in(u) end
return a[2] + (b[2] - a[2]) * u
end
end
return keys[#keys][2]
end
local function play(name, kind)
if audio.has_sound(name) then audio.play_sound(name, kind or (name:find("_vo_") and "voice" or "sound")) end
end
function Kinotake.new(seats)
local self = setmetatable({}, Kinotake)
self.tex = {}
for _, key in ipairs({
"bg", "cover", "logo", "banner", "logo_flash", "banner_flash", "burst_l", "burst_r", "copyright", "board",
"cloud_kinoko", "cloud_takenoko", "frame_1p", "frame_2p", "half_1p", "half_2p", "glow", "glow_half",
"bubble_1p", "bubble_2p", "bubble_1p_duet", "bubble_2p_duet", "flash", "flash_1p_duet", "flash_2p_duet",
"balloon", "gift_note", "gift_kid", "gift_kinoko", "gift_takenoko",
}) do
local id = tex.get_texture(DIR .. "/" .. key) -- loads the folder, so before the info lookup
local info = tex.get_texture_info(DIR:match("[^/]+$"), key) -- textures are keyed by their own folder
self.tex[key] = { id = id, w = info and info.x2[1] or 0,
h = info and info.y2[1] or 0 }
end
self.seats = {}
for _, p in ipairs(seats or { 1 }) do self.seats[#self.seats + 1] = p end
self.cursors = {}
self.team = {}
self.reward = -1
self.now = tex.get_current_ms()
play("kinotake_bgm", "music")
self:enter("title")
return self
end
-- draws `t` scaled by sx / sy about its centre, moved by dx / dy (unscaled pixels)
function Kinotake:put(t, opts)
opts = opts or {}
local sx, sy = opts.sx or 1, opts.sy or opts.sx or 1
tex.draw_texture(t.id, {
x = t.w * (1 - sx) / 2 + (opts.dx or 0) * S, y = t.h * (1 - sy) / 2 + (opts.dy or 0) * S,
x2 = t.w * (sx - 1), y2 = t.h * (sy - 1), fade = opts.fade, mirror = opts.mirror,
})
end
function Kinotake:enter(phase)
self.phase = phase
self.t0 = self.now
self.cue_f = -1
if phase == "select" then
self.menu = CampaignMenu.new({ "kinotake", "recog", "skip" }, { dir = DIR, sound = "kinotake_vo_sel_which" })
elseif phase == "recog" then
self.recog_team = math.random(0, 1)
self.reward = COSTUME[self.recog_team]
local function k(key) return DIR .. "/" .. key end
self.scan = SmartScan.new("v3c", {
tex = {
bg = k("bg"), title_bg = k("cover"), side = k("recog_side"), guide = k("recog_guide"),
keep_box = k("recog_keep_box"), recog_keep_text = k("recog_keep_text"), prize = k("prize"),
flowers_kinoko = k("flowers_kinoko"), flowers_takenoko = k("flowers_takenoko"),
},
skip_title = true, gift_ms = GIFT_F * F, draw_gift = function(_, t) self:draw_gift(t) end,
keep_tex = "recog_keep_text", flowers = { "flowers_kinoko", "flowers_takenoko" },
prize = self.recog_team, prize_text = "kinotake_costume_" .. self.recog_team, reward = self.reward,
bgm = false, sounds = { smart_vo_campaign = false, smart_vo_moraeru = "kinotake_vo_exp_recog" },
})
elseif phase == "side" then
for i, p in ipairs(self.seats) do
local index = self.recog_team or (i == 1 and KINOKO or TAKENOKO)
self.cursors[i] = { player = p, index = index, from = index, moved = -MOVE_F * F }
end
self.all_decided = nil
self.side_t0 = self.now
play("kinotake_vo_which")
elseif phase == "balloon" then
play("kinotake_vo_entered")
end
end
function Kinotake:frames() return (self.now - self.t0) / F end
function Kinotake:cue(f, name)
if f > self.cue_f and f <= self:frames() then play(name) end
end
function Kinotake:update(current_ms)
self.now = current_ms
if self.done then return end
local f = self:frames()
if self.phase == "title" then
self:cue(1, "kinotake_se_popup")
self:cue(35, "kinotake_se_logoshine")
self:cue(54, "kinotake_vo_title")
self:cue(100, "kinotake_se_popup")
self:cue(170, "kinotake_se_popup")
if f >= TITLE_F then self:enter("select") end
elseif self.phase == "select" then
self.menu:update(current_ms)
if self.menu:is_done() then
local choice = self.menu:get_choice()
if choice == "skip" then self:finish() else self:enter(choice == "recog" and "recog" or "side") end
end
elseif self.phase == "recog" then
self.scan:update(current_ms)
if self.scan:is_done() then
if self.scan:failed() then self.recog_team, self.reward = nil, -1 end -- no camera: pick a side as usual
self:enter("side")
end
elseif self.phase == "side" then
if self.menu then self.menu:update(current_ms) end
if not self.all_decided and current_ms - self.t0 >= (SIDE_WAIT_F + SIDE_FADE_F) * F + SIDE_SELECT_MS then
for _, c in ipairs(self.cursors) do self:decide(c) end
end
if self.all_decided and current_ms - self.all_decided >= BALLOON_WAIT_F * F then self:enter("balloon") end
elseif self.phase == "balloon" then
self:cue(19, "kinotake_se_breakout")
if f >= BALLOON_F then self:finish() end
end
self.cue_f = f
end
function Kinotake:finish()
self.done = true
self:close()
end
function Kinotake:close()
audio.stop_sound("kinotake_bgm")
if self.scan then self.scan:close() end
end
function Kinotake:cursor_of(player)
for _, c in ipairs(self.cursors) do
if c.player == player then return c end
end
end
function Kinotake:decide(c)
if c.decided then return end
c.decided = self.now
self.team[c.player] = c.index
for _, o in ipairs(self.cursors) do
if not o.decided then return end
end
self.all_decided = self.now
end
function Kinotake:input(action, player)
if self.done then return false end
if self.phase == "select" then
return self.menu:input(action)
elseif self.phase == "recog" then
return action == DECIDE and self.scan:input(action)
elseif self.phase ~= "side" or self:frames() < SIDE_WAIT_F + SIDE_FADE_F or self.all_decided then
return false
end
local c = self:cursor_of(player or self.seats[1]) or self.cursors[1]
if not c or c.decided or self.now - c.moved < MOVE_F * F then return false end
if action == DECIDE then
self:decide(c)
return true
end
local to = action == LEFT and KINOKO or TAKENOKO
if to == c.index then return false end
c.from, c.index, c.moved = c.index, to, self.now
return true
end
function Kinotake:is_done() return self.done == true end
function Kinotake:get_team(player) return self.team[player] or -1 end
function Kinotake:get_reward() return self.reward end
function Kinotake:draw_backdrop()
tex.draw_texture(self.tex.cover.id)
tex.draw_texture(self.tex.bg.id)
end
function Kinotake:draw_title(f)
local tx = self.tex
self:draw_backdrop()
local lift = track(f, { { 0, 700 }, { 20, 118.15, "out" }, { 25, 135, "in" }, { 75, 135 }, { 87, 0, "out" } })
self:put(tx.logo, { dy = lift })
self:put(tx.banner, { dy = lift })
if f >= 25 and f < 54 then
local s, a = 1, ramp(f, 25, 10)
if f >= 36 then
local u = ease_out(ramp(f, 36, 18))
s, a = 1.328 + 2.872 * u, 0.9 * (1 - u)
end
self:put(tx.logo_flash, { dy = lift, sx = s, fade = a })
self:put(tx.banner_flash, { dy = lift, sx = s, fade = a })
end
for _, b in ipairs({ { tx.burst_l, 74 }, { tx.burst_r, 145 } }) do
local u = f - b[2]
if u >= 0 then
local dy = track(u, { { 0, 548.15 }, { 13, -16.85, "out" }, { 21, 9, "in" }, { 26, 0 } })
local sy = track(u, { { 13, 1 }, { 21, 0.95, "in" }, { 26, 1 } })
self:put(b[1], { dy = dy, sy = sy, sx = 1 })
end
end
tex.draw_texture(tx.copyright.id, { fade = ramp(f, 87, 6) })
end
function Kinotake:draw_gift(t)
local tx, f = self.tex, t / F
local out = 1 - ramp(f, 214, 5)
self:put(tx.gift_note, { fade = ramp(f, 10, 9) * out })
if f >= 5 then
local dy = track(f, { { 4, 490 }, { 15, -44.05, "out" }, { 23, -11.4, "in" }, { 28, 7.65, "out" }, { 32, 0, "in" } })
self:put(tx.gift_kid, { dy = dy, fade = out })
end
-- the boxes pop up, then breathe out of step
for i, b in ipairs({ { tx.gift_kinoko, 10 }, { tx.gift_takenoko, 11 } }) do
local u = f - b[2]
if u >= 0 then
local dy = track(u, { { 0, 498 }, { 9, -8 }, { 13, 0 } })
local p = u < 14 and 0 or ((u - 14) % 40) / 40
local s = p < 0.5 and p * 2 or 1 - (p - 0.5) * 2
if i == 1 then s = 0.95 + 0.05 * s else s = 1 - 0.05 * s end
self:put(b[1], { dy = dy, sx = s, fade = out })
end
end
end
function Kinotake:draw_cloud(t, f)
local p = (f % 40)
local s = p < 17 and 1 + 0.05 * ease_out(p / 17) or p < 24 and 1.05 or 1.05 - 0.05 * ease_in((p - 24) / 16)
self:put(t, { sx = s })
end
function Kinotake:draw_cursor(c, f, duet)
local tx = self.tex
local wait = SIDE_WAIT_F
if f < wait then return end
local fade = ramp(f, wait, SIDE_FADE_F)
local moved = (self.now - c.moved) / (MOVE_F * F)
local x = CURSOR_X[c.from] + (CURSOR_X[c.index] - CURSOR_X[c.from]) * clamp01(moved)
local flash = 0
if c.decided then
local d = (self.now - c.decided) / F
flash = ramp(d, 0, 15)
fade = fade * (1 - ramp(d, 16, 13))
if fade <= 0 then return end
end
local p1 = c.player == 1
local suffix = p1 and "1p" or "2p"
local frame = duet and tx["half_" .. suffix] or tx["frame_" .. suffix]
local bubble = duet and tx["bubble_" .. suffix .. "_duet"] or tx["bubble_" .. suffix]
local flash_tex = duet and tx["flash_" .. suffix .. "_duet"] or tx.flash
self:put(bubble, { dx = x, fade = fade })
if flash > 0 then self:put(flash_tex, { dx = x, fade = fade * flash }) end
self:put(frame, { dx = x, fade = fade })
-- white rim glow: in 467 ms, out 500 ms, every 2 s
local g = ((self.now - self.side_t0) % 2000)
local glow = g < 467 and 0.58 * g / 467 or g < 967 and 0.6 * (1 - (g - 467) / 500) or 0
self:put(duet and tx.glow_half or tx.glow, { dx = x, fade = fade * glow,
mirror = duet and p1 and "horizontal" or nil })
end
function Kinotake:draw_side()
local tx = self.tex
local f = (self.now - self.side_t0) / F
self:draw_backdrop()
self:put(tx.board)
self:draw_cloud(tx.cloud_kinoko, f)
self:draw_cloud(tx.cloud_takenoko, f)
local a, b = self.cursors[1], self.cursors[2]
local duet = a and b and a.index == b.index
for i = #self.cursors, 1, -1 do self:draw_cursor(self.cursors[i], f, duet) end
if self.menu then self.menu:draw_hint(1) end
end
function Kinotake:draw_balloon(f)
local s, a = 1, 1
if f < 19 then
s, a = 2 - f / 19, f / 19
elseif f < 23 then
s = 1.013 + 0.037 * ramp(f, 20, 3)
elseif f < 29 then
s = 1.042 - 0.042 * ramp(f, 24, 5)
end
self:put(self.tex.balloon, { sx = s, fade = a })
if f >= 65 then
tex.draw_rect(0, 0, tex.get_screen_width(), tex.get_screen_height(), 0, 0, 0, 255 * ramp(f, 65, 60))
end
end
function Kinotake:draw()
if self.done then return end
local f = self:frames()
if self.phase == "title" then
self:draw_title(f)
elseif self.phase == "select" then
self:draw_backdrop()
self.menu:draw()
elseif self.phase == "recog" then
self.scan:draw()
elseif self.phase == "side" then
self:draw_side()
elseif self.phase == "balloon" then
self:draw_side()
self:draw_balloon(f)
end
end