AISectionBoard = {} AISectionBoard.__index = AISectionBoard local S = tex.get_screen_scale() local FRAME_MS = 1000 / 60 local SECTION_END_WAIT, TOTAL_WAIT = 60, 60 local PLAYER_WAIT, DETAIL_WAIT = 600, 1200 local BADGE_FIRST_WAIT, BADGE_CHALLENGE_WAIT, BADGE_UNMET_WAIT = 60, 90, 60 local VOICE_WAIT, TOKEN_WAIT, WIN_WAIT = 60, 120, 210 local TOKEN_STEP, COUNT_FRAMES = 15, 30 local BOARD_FADE = 10 local RANK_MAX = 11 local LOGO_X, LOGO_DETAIL_X, LOGO_Y = 889, 1089, 502 local SIMPLE = { [3] = { x = { 300, 428, 556 }, y = { 485, 539, 485 } }, [5] = { x = { 116, 244, 372, 500, 628 }, y = { 485, 539, 485, 539, 485 } }, } local DETAIL = { [3] = { x = { 322, 510.5, 699 }, y = { 510, 528, 510 } }, [5] = { x = { 168, 328, 488, 648, 808 }, y = { 510, 536, 510, 536, 510 } }, } local ROW_COUNT_Y = { -1, 32, 65, 99 } local ROW_DIFF_Y = { 0, 33, 66, 99 } local ROW_KEYS = { "good", "ok", "bad", "drumroll" } local function clamp01(v) return math.max(0, math.min(1, v)) end local function ramp(t, at, len) return clamp01((t - at) / len) end local function lerp(a, b, u) return a + (b - a) * u end 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 beats_best(now, best) if now.good ~= best.good then return now.good > best.good end if now.ok ~= best.ok then return now.ok > best.ok end if now.bad ~= best.bad then return now.bad < best.bad end return now.drumroll > best.drumroll end local function no_best(best) return not best or (best.good == 0 and best.ok == 0 and best.bad == 0 and best.drumroll == 0) end function AISectionBoard.new(data) local self = setmetatable({}, AISectionBoard) self.sizes, self.ids = {}, {} self.sections = data.sections self.count = #data.sections self.won = data.won self.f = 0 self.started = false tex.load_folder("ai_section_result", "ai_background") tex.load_folder("ai_section_result", "ai_board") if audio.has_sound("ai_bgm") then audio.play_sound("ai_bgm", "music") end return self end function AISectionBoard:begin(progress) self.progress = progress self.first_play = progress == nil if progress then for i, section in ipairs(self.sections) do section.best = progress.bests[i] self.first_play = self.first_play or no_best(section.best) end end self.best_updated = {} for i, section in ipairs(self.sections) do self.best_updated[i] = not self.first_play and beats_best(section, section.best) end self.start_ms = nil self.f = 0 self.played = {} self.total_at = self:simple_end() self.rank_at = self.total_at + TOTAL_WAIT self.rank_events = self:rank_schedule() self.wait_at = self.rank_at + self.rank_events.finish self.mode = "simple" self.mode_at = 0 self.leaving = false self.detail_seen_at = nil self.deadline = self.wait_at + PLAYER_WAIT self.started = true play("vo_call") end function AISectionBoard:simple_end() return 15 + SECTION_END_WAIT end function AISectionBoard:rank_schedule() local p = self.progress local events = { finish = 0 } if not p or not self.won then return events end local t = 0 if p.tokens_after ~= p.tokens_before and p.rank_before < RANK_MAX then if p.challenge then t = t + BADGE_CHALLENGE_WAIT events.challenge = t end if p.first then t = t + BADGE_FIRST_WAIT events.first = t end t = t + VOICE_WAIT if p.first or p.challenge then events.voice = t end t = t + TOKEN_WAIT events.tokens = t t = t + (p.tokens_after - p.tokens_before) * TOKEN_STEP events.tokens_end = t elseif not p.fulfilled and p.rank_before < RANK_MAX then t = t + BADGE_UNMET_WAIT events.unmet = t end t = t + WIN_WAIT events.wins = t events.finish = t + COUNT_FRAMES return events end function AISectionBoard:skip_reveal() if self.f < self.wait_at then self.f = self.wait_at self.start_ms = self.now_ms - self.f * FRAME_MS for _, key in ipairs({ "popup", "marks", "logo", "challenge", "first", "unmet", "voice" }) do self.played[key] = true end return true end return false end function AISectionBoard:decide() if self.leaving then return false end if self:skip_reveal() then return true end if self.mode == "detail" and self.f < self.mode_at + self:detail_length() then self.mode_at = self.f - self:detail_length() return true end self.leaving = true return true end function AISectionBoard:toggle() if self.leaving or self.f < self.wait_at then return false end self.mode = self.mode == "simple" and "detail" or "simple" self.mode_at = self.f self.deadline = self.f + DETAIL_WAIT return true end function AISectionBoard:detail_length() return BOARD_FADE + 2 * COUNT_FRAMES + 20 end function AISectionBoard:once(key, at) if not self.played[key] and self.f >= at then self.played[key] = true return true end return false end function AISectionBoard:update(ms) self.now_ms = ms if not self.started then return end self.start_ms = self.start_ms or ms self.f = (ms - self.start_ms) / FRAME_MS if self:once("popup", 5) then play("se_popup") end if self:once("marks", 7) then play("se_section_mark") end if self:once("logo", self.total_at) then play(self.won and "se_logo_win" or "se_logo_lose") end local e, rank_t = self.rank_events, self.rank_at if e.challenge and self:once("challenge", rank_t + e.challenge) then play("se_badge") end if e.first and self:once("first", rank_t + e.first) then play("se_badge") if e.challenge then play("se_badge_add") end end if e.unmet and self:once("unmet", rank_t + e.unmet) then play("se_badge") end if e.voice and self:once("voice", rank_t + e.voice) then play("vo_bonus") end if e.tokens then local steps = math.floor((self.f - rank_t - e.tokens) / TOKEN_STEP) if steps >= 0 and steps < self.progress.tokens_after - self.progress.tokens_before and self:once("token" .. steps, rank_t + e.tokens + steps * TOKEN_STEP) then play("se_count") end end if self:rankup() and e.tokens_end then if self:once("rankup", rank_t + e.tokens_end) then play("se_rankup_panel") end if self:once("rankup_shine", rank_t + e.tokens_end + 65) then play("se_rankup_shine") end end if e.wins and self:once("wins", rank_t + e.wins) then play("se_win_count") end if self.mode == "detail" and not self.detail_seen_at then self.detail_seen_at = self.mode_at end if self.mode == "detail" then local t = self.f - self.mode_at if t >= BOARD_FADE and t < BOARD_FADE + COUNT_FRAMES and self:once("detail_count", self.mode_at + BOARD_FADE) then play("se_count") end if not self.first_play then for _, updated in ipairs(self.best_updated) do if updated and self:once("best", self.mode_at + BOARD_FADE + 2 * COUNT_FRAMES) then play("se_best") end end end end if self.f >= self.deadline then self.leaving = true end end function AISectionBoard:rankup() local p = self.progress return p and p.next_rank_tokens >= 0 and p.tokens_after >= p.next_rank_tokens end function AISectionBoard:is_done() return self.leaving end function AISectionBoard:put(folder, key, cx, cy, o) o = o or {} local path = folder .. "/" .. key local id = self.ids[path] if id == nil then id = tex.get_texture("ai_section_result/" .. path) self.ids[path] = id self.sizes[path] = tex.get_texture_info(folder, key) end local info = self.sizes[path] if not info then return end local sx, sy = o.sx or o.s or 1, o.sy or o.s or 1 local w, h = info.width * S, info.height * S tex.draw_texture(id, { x = cx * S, y = cy * S, x2 = w * (sx - 1), y2 = h * (sy - 1), origin = { w * sx * 0.5, h * sy * 0.5 }, rotation = o.rot or 0, fade = o.fade or 1, frame = o.frame or 0, mirror = o.mirror, blend = o.add and "additive" or nil, }) end function AISectionBoard:board(key, cx, cy, o) self:put("ai_board", key, cx, cy, o) end function AISectionBoard:draw_background() self.bg_start = self.bg_start or self.now_ms or 0 local f = ((self.now_ms or 0) - self.bg_start) / FRAME_MS local bg = "ai_background" if self.won then self:put(bg, "gradient_win", 640, 360, { sx = 160 }) for k = 0, 1 do local u = ((f * 1.35 + k * 425) % 850) / 850 self:put(bg, "pattern", 640, 844 - u * 850, { fade = 0.13 * math.sin(math.pi * u), add = true, mirror = k == 0 and "horizontal" or nil, }) end for k = 0, 2 do local u = ((f * 0.5 + k * 280) % 840) / 840 self:put(bg, "triangle", 300 + k * 340, 800 - u * 880, { rot = f * 1.7 + k * 120, fade = 0.4 }) end else self:put(bg, "gradient_lose", 640, 360, { sx = 160 }) for k = 0, 2 do local u = ((f * 1.55 + k * 640) % 1920) / 1920 self:put(bg, "pattern", -320 + u * 1920, 364, { fade = 0.13 * math.sin(math.pi * u), add = true }) end end end function AISectionBoard:draw_simple(fade) local layout = SIMPLE[self.count] if not layout or fade <= 0 then return end local t = self.f for i, section in ipairs(self.sections) do local x, y = layout.x[i], layout.y[i] self:board("section_back", x, y, { fade = fade }) self:board("section_back_top", x, y, { fade = fade }) if section.won then local s = t < 7 and 1 or t < 10 and lerp(0.95, 1.05, ramp(t, 7, 3)) or t < 13 and lerp(1.05, 1, ramp(t, 10, 3)) or 1 self:board("section_win", x, y, { s = s, fade = fade * ramp(t, 5, 1) }) if t >= 7 and t < 17 then self:board("section_flash", x, y, { s = lerp(1, 1.5, ramp(t, 7, 10)), fade = fade * (1 - ramp(t, 7, 10)) }) end else self:board("section_lose", x, y, { fade = fade * ramp(t, 5, 8) }) end self:board("section_name", x - 2, y - 73, { frame = i - 1, fade = fade * ramp(t, 5, 1) }) end end -- Right aligned digits; slots step left from the anchor. function AISectionBoard:draw_number(key, value, right_x, y, step, fade) value = math.max(0, math.floor(value)) local x = right_x repeat self:board(key, x, y, { frame = value % 10, fade = fade }) value = value // 10 x = x - step until value == 0 end function AISectionBoard:draw_detail(fade) local layout = DETAIL[self.count] if not layout or fade <= 0 then return end local t = self.f - self.mode_at - BOARD_FADE local count_u = ramp(t, 0, COUNT_FRAMES) local diff_u = ramp(t, COUNT_FRAMES, COUNT_FRAMES) for i, section in ipairs(self.sections) do local x, y = layout.x[i], layout.y[i] self:board("detail_panel", x, y, { fade = fade }) self:board(section.won and "detail_win" or "detail_lose", x + 37, y - 76, { fade = fade }) self:board("section_name", x - 38, y - 76, { frame = i - 1, fade = fade }) for row, key in ipairs(ROW_KEYS) do self:draw_number("digits_count", section[key] * count_u, x + 20, y - 2 + ROW_COUNT_Y[row], 17, fade) if not self.first_play and diff_u > 0 then local diff = section[key] - section.best[key] local sign = diff > 0 and "sign_plus" or diff < 0 and "sign_minus" or "sign_even" self:board(sign, x + 36, y - 2 + ROW_DIFF_Y[row], { fade = fade * diff_u }) self:draw_number("digits_diff", math.abs(diff) * diff_u, x + 65, y - 2 + ROW_DIFF_Y[row], 10, fade * diff_u) end end if self.best_updated[i] then local best_fade = ramp(t, 2 * COUNT_FRAMES, 10) if best_fade > 0 then self:board("best_updated", x, y - 33, { fade = fade * best_fade }) end end end end function AISectionBoard:draw_logo(x, fade) if fade <= 0 then return end local t = self.f - self.total_at local y = LOGO_Y if self.won then local pulse = t < 14 and 1.087 or lerp(1.087, 0.725, math.abs(((t - 14) % 118) / 59 - 1)) self:board("win_glow", x, y, { s = pulse, fade = fade * ramp(t, 2, 12) }) if t >= 14 then self:board("win_ring_glow", x, y, { add = true, fade = fade * lerp(0.2, 1, math.abs(((t - 14) % 118) / 59 - 1)) }) end if t >= 9 then self:board("win_laurel", x, y, { s = lerp(1.268, 1, ramp(t, 9, 5)), fade = fade * ramp(t, 9, 5) }) self:board("win_rays", x, y, { s = 0.85, rot = t * 3, add = true, fade = fade * 0.3 * ramp(t, 9, 5) }) end if t >= 9 and t < 14 then self:board("win_flash", x, y, { s = lerp(1.087, 1.812, ramp(t, 9, 5)), fade = fade * (1 - ramp(t, 9, 5)) }) end for k, key in ipairs({ "triangle_yellow", "triangle_pale", "triangle_purple", "triangle_pink", "triangle_white" }) do local u = ramp(t, 23 + k * 2, 22) if u > 0 and u < 1 then local a = (k * 72) * math.pi / 180 self:board(key, x + math.cos(a) * 170 * u, y + math.sin(a) * 170 * u, { rot = t * 8, fade = fade * (1 - u) }) end end local logo_s = t < 6 and lerp(1, 2, ramp(t, 0, 6)) or lerp(1.772, 1, ramp(t, 6, 8)) self:board("logo_win", x, y, { s = logo_s, fade = fade * ramp(t, 0, 5) }) for k, spot in ipairs({ { 156, -107 }, { -167, 121 }, { -11, -159 }, { 140, 136 } }) do local at = 29 + (k - 1) * 15 if t >= at then local u = ((t - at) % 170) / 60 if u < 1 then self:board("sparkle", x + spot[1], y + spot[2], { fade = fade * math.sin(math.pi * u) }) end end end else if t < 13 then self:board("lose_burst", x, y, { s = lerp(0.145, 1.8, ramp(t, 0, 12)), fade = fade * (1 - ramp(t, 6, 6)) }) end local show = fade * ramp(t, 6, 7) self:board("lose_disc", x, y, { fade = show }) self:board("lose_ring", x, y, { rot = -t * 0.75, fade = show }) self:board("lose_ring_thin", x, y, { rot = t * 0.75, fade = show }) self:board("logo_lose", x, y, { fade = show }) end end function AISectionBoard:draw_rank(fade) local p = self.progress if not p or fade <= 0 then return end local e, t = self.rank_events, self.f - self.rank_at local rankup = self:rankup() and e.tokens_end and t >= e.tokens_end local rank = rankup and p.rank_after or p.rank_before if rank >= RANK_MAX then self:board("rank_panel_max", 1159, 440, { fade = fade }) else self:board("rank_panel", 1159, 440, { fade = fade }) self:draw_number("digits_rank", rank, 1214 + (rank >= 10 and 17 or 0), 424, 35, fade) if p.next_rank_tokens >= 0 then local tokens = p.tokens_before if e.tokens and t >= e.tokens then tokens = math.min(p.tokens_after, p.tokens_before + math.floor((t - e.tokens) / TOKEN_STEP) + 1) end self:draw_number("digits_count", math.max(0, p.next_rank_tokens - tokens), 1201, 459, 17, fade) end end local wins = p.wins_before if e.wins and t >= e.wins then wins = lerp(p.wins_before, p.wins_after, ramp(t, e.wins, COUNT_FRAMES)) end local to_next = p.next_reward_wins >= 0 and p.next_reward_wins - math.floor(wins) or nil self:board(to_next and "wins_panel_next" or "wins_panel", 1160, 522, { fade = fade }) self:draw_number("digits_wins", wins, 1244, 508, 17, fade) if to_next then self:draw_number("digits_count", to_next, 1218, 543, 17, fade) end if e.wins and p.reward_kind ~= "" and t >= e.wins + COUNT_FRAMES then local r = t - e.wins - COUNT_FRAMES self:board("reward_text", 1160, 522, { s = r < 10 and lerp(1.5, 1, ramp(r, 0, 10)) or 1, fade = fade * ramp(r, 0, 10) }) end local slide = function(at) return lerp(225, 0, ramp(t, at + 1, 6)), lerp(0.5, 1, ramp(t, at + 1, 6)) end if e.challenge and t >= e.challenge then local dx, a = slide(e.challenge) self:board("badge_challenge", 1150 + dx, 379, { fade = fade * a }) end if e.unmet and t >= e.unmet then local dx, a = slide(e.unmet) self:board("badge_unmet", 1150 + dx, 379, { fade = fade * a }) end if e.first and t >= e.first then local dx, a = slide(e.first) self:board("badge_first", 1171 + dx, p.challenge and 345 or 383, { fade = fade * a }) end if rankup then local r = t - e.tokens_end - 65 if r >= 0 then local s = r < 10 and lerp(1, 1.5, ramp(r, 0, 10)) or r < 14 and lerp(1.375, 1, ramp(r, 10, 4)) or 1 local a = ramp(r, 0, 10) * fade self:board("rankup_glow", 1160, 440, { s = s + (r >= 14 and 0.02 * math.sin(r / 11) or 0), fade = a }) self:board("rankup_text", 1160, 440, { s = s, fade = a }) end end end function AISectionBoard:draw_hints() local t = self.f % 120 local simple = self.mode == "simple" self:board("hint_end", 1169, 687) self:board(simple and "hint_detail" or "hint_back", 930, 687) self:board("drum", 810, 688) self:board("drum", 1053, 688) local rim = t < 60 and 1 - math.abs(t / 30 - 1) or 0 self:board("drum_rim", 798, 688, { fade = rim }) self:board("drum_rim", 822, 688, { fade = rim, mirror = "horizontal" }) local arrow = t < 60 and 1 - t / 60 or 0 self:board("drum_arrow", 783 - 7 * (1 - arrow), 688, { fade = arrow }) self:board("drum_arrow", 837 + 7 * (1 - arrow), 688, { fade = arrow, mirror = "horizontal" }) self:board("drum_center", 1053, 688, { fade = rim }) end function AISectionBoard:draw() if not self.started then return end local since = self.f - self.mode_at local detail = self.mode == "detail" local logo_u = ramp(self.f - self.total_at, 1, 8) if detail then local out = 1 - ramp(since, 0, 4) self:draw_simple(out) self:draw_rank(out) self:draw_logo(LOGO_X, logo_u * out) self:draw_logo(LOGO_DETAIL_X, ramp(since, 2, 7)) self:draw_detail(ramp(since, 1, BOARD_FADE)) else local back = self.detail_seen_at and ramp(since, 0, 4) or 1 self:draw_simple(back) self:draw_rank(back) self:draw_logo(LOGO_X, math.min(logo_u, back)) end if self.f >= self.wait_at then self:draw_hints() end end