Files
PyTaikoGreen/Scripts/game/ai_battle.lua
T
TaikoZhongandClaude Opus 5.5 a3066eaa65 AI battle: AIどん plate, section timer position, title at the bottom right
- ai_plate: the AI lane's "AIどん" plate, drawn with the AI scoreboard
  (needs the engine's Player::use_ai_lane, which drops the lane's own
  plate / auto marker, course mark, and switches to the AI lane cover).
- Section timer: frame, bar and labels moved to sit on the score plate
  (frame at 160,154; bar 189,166 150x16, drawn over the frame), bar
  colours from the timer art.
- ai_game_song_info: the AI game's title / song number / genre plate at
  the bottom right.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 18:01:45 +08:00

927 lines
39 KiB
Lua

require("game/gauge")
AIBattleOverlay = {}
AIBattleOverlay.__index = AIBattleOverlay
local DIR = "ai_battle/"
local TILES = 19
local TILE_WIDTH = 72
local SECTIONS = 5
local GAUGE_SCALE = 0.5
local GAUGE_X, GAUGE_Y = 10, 15
local PROGRESS = { x = 189, y = 166, w = 150, h = 16 }
local S = tex.get_screen_scale()
local FRAME_MS = 1000 / 60
-- A triangle wave between 0 and 1.
local function flash(ms, period)
return 1 - math.abs(2 * ((ms % period) / period) - 1)
end
-- Linear value of a {frame, value} key list; outside the keys it holds the end value.
local function track(f, keys)
if f <= keys[1][1] then return keys[1][2] end
for i = 2, #keys do
local k0, k1 = keys[i - 1], keys[i]
if f <= k1[1] then
return k0[2] + (k1[2] - k0[2]) * (f - k0[1]) / (k1[1] - k0[1])
end
end
return keys[#keys][2]
end
-- Fades in and out over `length` frames: the stage flash the Lumen sprites share.
local function blink(f, length)
if f < 0 or f > length then return 0 end
return 1 - math.abs(2 * f / length - 1)
end
function AIBattleOverlay.new()
local self = setmetatable({}, AIBattleOverlay)
self.ids = {}
self.sizes = {}
self:reset()
return self
end
function AIBattleOverlay:id(key)
self.ids[key] = self.ids[key] or tex.get_texture(key)
return self.ids[key]
end
function AIBattleOverlay:reset()
self.wins = {}
self.section = 0
self.section_count = SECTIONS
self.event = nil -- latest section result: { won, start }
self.final = nil -- match result: { won, full_combo, start }
self.start_ms = nil
self.phase = 1
self.phase_start = nil
self.played = {}
self.flame_tier = {}
end
function AIBattleOverlay:set_section_result(section, won)
self.wins[section] = won
self.event = { won = won, section = section }
end
function AIBattleOverlay:set_match_result(won, full_combo)
self.final = { won = won, full_combo = full_combo }
end
function AIBattleOverlay:spr(key, cx, cy, o)
o = o or {}
local info = self.sizes[key]
if not info then
info = tex.get_texture_info("ai_battle", key)
self.sizes[key] = info
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(self:id(DIR .. key), {
x = cx * S, y = cy * S,
x2 = w * (sx - 1), y2 = h * (sy - 1),
origin = { w * sx * (o.ax or 0.5), h * sy * (o.ay or 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
local function stamp(event, ms)
if event and not event.start then event.start = ms end
end
function AIBattleOverlay:cue(name, preset, frame, at, tag)
local key = name .. (tag or "")
if frame >= (at or 0) and not self.played[key] then
self.played[key] = true
name = "ai_battle_" .. name
if audio.has_sound(name) then audio.play_sound(name, preset) end
end
end
function AIBattleOverlay:event_frame(ms)
if not self.event or not self.event.start then return nil end
local f = (ms - self.event.start) / FRAME_MS
return f < 179 and f or nil
end
local function pattern_phase(section, count)
if section >= count - 1 then return 3 end
return section >= count // 2 and 2 or 1
end
local COLUMNS = {
{ "pattern_column_triangle", 358, 0, 289, 814, 440 }, { "pattern_column_triangle", 1136, 0, 244, 439, 813 },
{ "pattern_column_wave", 852, 0, 299, 441, 812 }, { "pattern_column_wave", 1206, 0, 165, 811, 438 },
{ "pattern_column_wave", 73, 0, 236, 440, 809 }, { "pattern_column_wave", 499, 82, 136, 441, 812 },
{ "pattern_column_maze", 923, 58, 185, 811, 440 }, { "pattern_column_triangle", 640, 0, 299, 438, 811 },
{ "pattern_column_diamond", 568, 24, 259, 814, 438 }, { "pattern_column_diamond", 287, 24, 170, 437, 813 },
{ "pattern_column_diamond", 993, 55, 211, 437, 811 },
}
local QUADS = {
{ 0, 71, 133, { 40, 133, 216 }, 0.4 }, { 1, 589, 73, { 40, 158 }, 0.8 }, { 2, 835, 50, { 82, 201 }, 0.3 },
{ 3, 1047, 104, { 40, 223 }, 0.3 }, { 4, 454, 134, { 48, 191 }, 0.2 }, { 5, 708, 169, { 130, 259 }, 0.4 },
{ 6, 949, 134, { 245 }, 0.3 }, { 7, 444, 48, { 91 }, 0.2 }, { 8, 1257, 169, { 73, 258 }, 0.3 },
{ 9, 754, 101, { 139 }, 0.2 }, { 10, 321, 75, { 153, 248 }, 0.4 },
}
local LINES = { "line_upper_1", "line_upper_2", "line_upper_3", "line_upper_4", "line_upper_5" }
local LINE_Y = { 161, 133, 103, 75, 47 }
function AIBattleOverlay:draw_patterns(ms)
local phase = self.phase
local since = (ms - (self.phase_start or ms)) / FRAME_MS
local fade_in = math.min(since / 20, 1)
local scroll = (ms % 8500) / 8500 * 1152
local first = phase == 1 and 0.5 or (phase == 2 and 0.5 * (1 - math.min(since / 50, 1)) or 0)
local second = phase == 2 and 0.5 * fade_in or (phase == 3 and 0.5 * (1 - math.min(since / 50, 1)) or 0)
if first > 0 then
tex.draw_texture(self:id(DIR .. "lower_triangles_1"), { y = scroll, fade = first })
end
if second > 0 then
tex.draw_texture(self:id(DIR .. "lower_triangles_2"), { y = scroll, fade = second })
end
if phase < 2 then return end
local f = (ms / FRAME_MS) % 480
local rise = {
{ "pattern_swirl", 545, f - 73, 196 }, { "pattern_triangle", 239, f - 10, 200 },
{ "pattern_flower", 818, f, 135 }, { "pattern_triangle", 839, f - 267, 187 },
{ "pattern_triangle", 257, f - 311, 168 },
}
for _, r in ipairs(rise) do
if r[3] >= 0 and r[3] <= r[4] then
local p = r[3] / r[4]
self:spr(r[1], r[2], 794 - 324 * p, {
rot = (r[2] < 640 and 1 or -1) * 70 * p, fade = math.min(1 - p, fade_in), add = true,
})
end
end
if phase < 3 then return end
local g = (ms / FRAME_MS) % 300
self:spr("pattern_sprinkle", 642, 384 + 475 * g / 300, { fade = fade_in, add = true })
self:spr("pattern_sprinkle", 642, 384 + 475 * ((g + 150) % 300) / 300, { fade = fade_in, add = true })
for _, c in ipairs(COLUMNS) do
local p = g - c[3]
if p >= 0 and p <= c[4] then
self:spr(c[1], c[2], c[5] + (c[6] - c[5]) * p / c[4], { fade = fade_in, add = true })
end
end
for _, q in ipairs(QUADS) do
for _, start in ipairs(q[4]) do
local a = blink(g - start, 40)
if a > 0 then self:spr("flash_quad", q[2], q[3], { frame = q[1], fade = a * q[5], add = true }) end
end
end
for i, key in ipairs(LINES) do
local a = math.max(blink(g, 40), 0.7 * blink(g - 40 - 4 * i, 20))
if a > 0 then self:spr(key, 640, LINE_Y[i], { fade = a, add = true }) end
end
for i, y in ipairs({ 698, 650, 603, 553 }) do
local a = math.max(blink(g, 40), 0.7 * blink(g - 36 - 4 * i, 20), 0.7 * blink(g - 125 - 4 * i, 20))
if a > 0 then self:spr("line_lower", 640, y, { fade = a, add = true }) end
end
if since < 110 then
for i, x in ipairs({ 429, 641, 1066, 216, 499, 851, 1207, 74 }) do
local h = since - (i - 1) * 8
local a = blink(h, 60)
if a > 0 then
self:spr(i % 2 == 0 and "flash_tile_narrow" or "flash_tile_wide", x, 628, { fade = a, add = true })
self:spr(i % 2 == 0 and "flash_triangle_white" or "flash_triangle_yellow", x, 628 + 30 * (h / 60), {
s = 0.8 * (1 - 0.8 * h / 60), rot = 45 * h / 60, fade = a, add = true,
})
end
end
end
end
function AIBattleOverlay:draw_back(ms, territory)
stamp(self.event, ms)
stamp(self.final, ms)
self.start_ms = self.start_ms or ms
local since_start = (ms - self.start_ms) / FRAME_MS
self:cue("se_enter", "sound", since_start)
self:cue("vo_begin", "voice", since_start, 45)
local event = self.event
if event and event.start then
local f = (ms - event.start) / FRAME_MS
if not self.final then
self:cue(event.won and "vo_section_win" or "vo_section_lose", "voice", f, 0, event.section)
end
self:cue(event.won and "se_section_win" or "se_section_lose", "sound", f, 10, event.section)
end
local phase = pattern_phase(self.section, self.section_count)
if phase ~= self.phase then
self.phase, self.phase_start = phase, ms
end
local red = math.min(math.max(1, territory + 10), TILES - 1)
local showing_highlight = true
local upper = "bg_upper"
local back_fade
local since_end
if self.final and self.final.start then
local f = (ms - self.final.start) / FRAME_MS
since_end = f
showing_highlight = false
red = self.final.won and math.min(f // 1 + 1, TILES) or math.max(TILES - f // 1 - 1, 0)
upper = self.final.won and "bg_final_upper" or "bg_lose_upper"
back_fade = 1 - math.min(f / 5, 1)
else
local f = self:event_frame(ms)
if f then
showing_highlight = false
red = self.event.won and math.min(f // 2 + 1, TILES) or math.max(TILES - f // 2 - 1, 0)
upper = self.event.won and "bg_win_upper" or "bg_lose_upper"
back_fade = 1 - math.min(f / 5, 1)
end
end
red = math.floor(red)
local blue = TILES - red
local highlight = flash(ms, 332)
tex.draw_texture(self:id(DIR .. "bg_lower"))
for i = 0, red - 1 do
tex.draw_texture(self:id(DIR .. "red_tile_lower"), { frame = i, x = i * TILE_WIDTH })
end
for i = 0, blue - 1 do
tex.draw_texture(self:id(DIR .. "blue_tile_lower"), { frame = i, x = (TILES - 1 - i) * TILE_WIDTH })
end
self:draw_patterns(ms)
if showing_highlight then
tex.draw_texture(self:id(DIR .. "highlight_tile_lower"), { x = red * TILE_WIDTH, fade = highlight })
end
tex.draw_texture(self:id(DIR .. upper))
if back_fade and back_fade > 0 then
tex.draw_texture(self:id(DIR .. "bg_upper"), { fade = back_fade })
end
for i = 0, red - 1 do
tex.draw_texture(self:id(DIR .. "red_tile_upper"), { frame = i, index = i })
end
for i = 0, blue - 1 do
tex.draw_texture(self:id(DIR .. "blue_tile_upper"), { frame = i, index = TILES - 1 - i })
end
tex.draw_texture(self:id(DIR .. "bg_outline_upper"))
if showing_highlight then
local params = { frame = red - 1, index = red - 1, fade = highlight }
if red > 9 then
params.frame = TILES - red
params.mirror = "horizontal"
end
tex.draw_texture(self:id(DIR .. "highlight_tile_upper"), params)
end
if self.final and self.final.won and since_end then
local a = math.min(math.max(since_end - 18, 0) / 26, 1)
self:spr("final_glow_lower", 640, 628, { fade = a })
self:spr("final_glow_upper", 640, 94, { fade = a })
self:spr("final_wreath", 640, 110, { fade = a, add = true })
local h = since_end - 18
if h >= 0 and h <= 26 then
self:spr("final_wreath_flash", 640, 110, { s = 1 + 1.5 * h / 26, fade = 1 - h / 26 })
end
end
local intro = (ms - self.start_ms) / FRAME_MS
if intro < 60 then
for side = 1, 2 do
local base = side == 1 and 151 or 1129
for i = 0, 11 do
local h = intro - i * 2
if h >= 0 and h < 24 then
self:spr("trail_quad", base + (side == 1 and 1 or -1) * (i - 6) * 42, 106 - 8 * ((i % 4) - 2), {
frame = i, fade = 0.4 * (1 - h / 24), mirror = side == 2 and "horizontal" or nil,
})
end
end
end
end
end
function AIBattleOverlay:draw_front(ms, section, section_count, progress)
local scale = tex.get_screen_scale()
local r, g, b, a = 0, 255, 0, 255
if progress >= 0.75 then
r, g, b, a = 253, 249, 0, 255 * flash(ms, 266)
end
tex.draw_texture(self:id(DIR .. "progress_bar"))
tex.draw_rect(PROGRESS.x * scale, PROGRESS.y * scale, PROGRESS.w * scale, PROGRESS.h * scale, 95, 94, 95, 255)
tex.draw_rect(PROGRESS.x * scale, PROGRESS.y * scale, progress * PROGRESS.w * scale, PROGRESS.h * scale, r, g, b, a)
if section < section_count then
tex.draw_texture(self:id(DIR .. "section_text"), { index = 0, frame = section })
end
if section < section_count - 1 then
tex.draw_texture(self:id(DIR .. "section_text"), { index = 1, frame = section + 1 })
end
self.section, self.section_count = section, section_count
if section == section_count - 1 and not self.final then
local won, lost = 0, 0
for i = 0, section - 1 do
if self.wins[i] == true then won = won + 1 elseif self.wins[i] == false then lost = lost + 1 end
end
self:cue(won > lost and "vo_reach_win" or (won < lost and "vo_reach_lose" or "vo_reach_draw"), "voice", 0)
end
-- 3 section songs use only the right three slots of the 5 slot row
local slot_base = section_count == 3 and 2 or 0
tex.draw_texture(self:id(DIR .. (section_count == 3 and "sections_three" or "sections")))
if section < section_count then
tex.draw_texture(self:id(DIR .. "section_highlight_green"), { index = slot_base + section })
tex.draw_texture(self:id(DIR .. "section_highlight_white"), { index = slot_base + section, fade = flash(ms, 700) })
end
for i = 0, SECTIONS - 1 do
if self.wins[i] ~= nil then
tex.draw_texture(self:id(DIR .. (self.wins[i] and "section_win" or "section_lose")), { index = slot_base + i })
end
end
end
-- Mid-height x of the territory border at each red tile count; the upper band's tiles fan out, so not even.
local BORDER_X = { 135, 193, 256, 316, 376, 436, 495, 553, 612, 667, 722, 783, 842, 903, 963, 1022, 1083, 1142 }
-- Each side's flame grows through four tiers. A tier's clock restarts when it is entered, so the opening
-- flare plays once and then only the part's loop repeats. x and y in a layer are the image's centre,
-- measured from the flame's origin with +x towards the opponent. { key, from frame, to frame, ... }.
local function fire_tier_2(first, second, sparks, spark_x, wisps, wisps_x)
return {
intro = 20, loop = 28,
{ first, 0, 19, sx = { 0.1, 1.2 }, x = { 93, -16 } },
{ first, 20, 26, sx = { 0.8, 1 }, x = { 26, 5 } },
{ second, 27, 33, sx = { 0.8, 1 }, x = { 26, 5 } },
{ first, 34, 40, sx = { 0.8, 1 }, x = { 26, 5 } },
{ second, 41, 47, sx = { 0.8, 1 }, x = { 26, 5 } },
{ sparks, 0, 14, sx = { 0.05, 0.6 }, x = { spark_x, -14 } },
{ sparks, 15, 19, sx = 0.6, x = { -23, -59 }, a = { 0.8, 0 } },
{ wisps, 20, 41, sx = { 0.2, 1.5 }, x = wisps_x, y = 1, a = { 1, 0 } },
}
end
local function small_horn(key, x, y, flip)
return {
start = 20, loop = 14, x = x, y = y, flip = flip,
{ key, 0, 9, s = { 1, 1.19 }, rot = { 0, -8 }, x = { -2, -26 }, y = { 0, -19 }, a = { 1, 0 }, n = 2 },
}
end
local PLAYER_HORN = {
loop = 15, normal = true,
{ "player_horn", 0, 9, sx = { 0.69, 0.88 }, x = { 137, 125 }, y = { -58, -69 }, a = { 0.28, 0.6 }, n = 2 },
{ "player_horn", 10, 14, sx = { 0.9, 1 }, x = { 123, 117 }, y = { -71, -77 }, a = { 0.48, 0 }, n = 2 },
}
local AI_HORN = {
loop = 20, normal = true,
{ "ai_horn", 0, 14, sx = { 0.44, 0.85 }, x = { 151, 126 }, y = { -58, -64 }, a = { 0.28, 0.6 }, n = 2 },
{ "ai_horn", 15, 19, sx = { 0.88, 1 }, x = { 125, 118 }, y = { -67, -78 }, a = { 0.48, 0 }, n = 2 },
}
local function placed(part, fields)
local placement = setmetatable(fields, { __index = part })
for i, layer in ipairs(part) do placement[i] = layer end
return placement
end
-- The opening stream of sparks and the big flame's flare are the same on both sides at the top tier.
local function fire_tier_4(large, burst, sparks, n, opening_burst)
local part = {
intro = 20, loop = 20,
{ burst, 20, 24, s = { 1, 1.2 }, x = { 19, -14 }, n = n },
{ burst, 30, 34, sx = { 0.8, 1.12 }, sy = { 1.2, 1.39 }, x = { 49, -3 }, a = { 1, 0.2 }, n = n },
{ large, 25, 29, s = { 1, 1.2 }, x = { 16, -16 }, n = n },
{ large, 35, 39, sx = { 0.6, 1 }, sy = 1.2, x = { 82, 16 }, n = n },
{ large, 0, 4, sx = { 0.1, 1 }, x = { 162, 27 }, n = n },
{ large, 5, 14, s = { 1.02, 1.2 }, x = { 24, -4 }, n = n },
{ large, 15, 19, sx = { 1.22, 1.29 }, sy = 1.2, x = { -7, -20 }, a = { 0.8, 0 }, n = n },
{ sparks, 0, 4, sx = { 0.1, 1 }, x = { 156, 27 } },
{ sparks, 5, 14, x = { 16, -83 } },
{ sparks, 15, 19, sx = { 1.02, 1.08 }, x = { -88, -105 }, a = { 0.8, 0 } },
}
if opening_burst then
part[#part + 1] = { burst, 0, 19, x = 19, n = n }
end
return part
end
local function spark_flash(key)
return {
loop = 60,
{ key, 0, 4, sx = { 0.1, 1 }, x = { 156, 0 } },
{ key, 5, 9, s = { 1.03, 1.13 }, x = { -24, -122 } },
{ key, 10, 14, s = { 1.15, 1.2 }, x = { -134, -183 }, a = { 0.8, 0 } },
}
end
local PLAYER_STREAK = {
loop = 20, normal = true,
{ "player_streak", 1, 5, sx = { 0.28, 1 }, sy = { 0.47, 1 }, x = { 125, 0 }, y = { -23, 0 } },
{ "player_streak", 6, 12, sx = { 1.03, 1.2 }, x = { -15, -106 }, y = 1 },
{ "player_streak", 13, 19, sx = 1.2, x = { -112, -149 }, y = 1, a = { 0.86, 0 } },
}
local AI_STREAK = {
loop = 20,
{ "ai_streak", 0, 5, sx = { 0.28, 1 }, sy = { 0.47, 1 }, x = { 88, -132 }, y = { -27, -8 } },
{ "ai_streak", 6, 11, sx = { 1.03, 1.17 }, x = { -138, -171 }, y = -8 },
{ "ai_streak", 12, 19, sx = 1.2, x = { -178, -193 }, y = -7, a = { 1, 0 } },
}
local PLAYER_FLAMES = {
{ x = -32, y = 101, {
loop = 19,
{ "player_small", 0, 4, s = { 0.9, 1 }, x = { -46, -53 }, frame = 0 },
{ "player_mid_1", 5, 10, sx = { 0.54, 0.62 }, sy = { 1, 1.15 }, x = -60 },
{ "player_small", 11, 14, x = -54.5, frame = 1 },
{ "player_small_end", 15, 18 },
} },
{ x = -134, y = 101, small_horn("player_horn_small", 67, -41),
small_horn("player_horn_small", 67, 49, true),
fire_tier_2("player_mid_2", "player_mid_1", "player_sparks", 96, "player_wisps", { 81, -48 }) },
{ x = -211, y = 91, placed(PLAYER_HORN, { x = -35, y = 8, s = 1.2, normal = false }),
placed(PLAYER_HORN, { x = -43, y = 21, s = 1.2, flip = true, normal = false }), {
intro = 20, loop = 20, x = -35, s = 1.2,
{ "player_medium_1", 20, 23, sx = { 0.6, 0.68 }, x = { 79, 67 } },
{ "player_medium_1", 24, 25, sx = { 0.7, 0.5 }, x = 63, a = { 1, 0 } },
{ "player_medium_1", 30, 33, sx = { 0.6, 0.68 }, x = { 79, 67 } },
{ "player_medium_1", 34, 35, sx = { 0.7, 0.5 }, x = 63, a = { 1, 0 } },
{ "player_medium_2", 4, 16, sx = { 1, 1.14 }, x = { 16, -4 } },
{ "player_medium_2", 17, 19, sx = { 1.16, 1.19 }, x = { -7, -13 }, a = { 0.67, 0 } },
{ "player_medium_2", 25, 29, sx = { 0.6, 0.7 }, x = { 80, 63 } },
{ "player_medium_2", 35, 39, sx = { 0.6, 0.7 }, x = { 80, 63 } },
{ "player_medium_2", 0, 16, sx = { 0.1, 1.14 }, x = { 162, -4 }, ease = true },
{ "player_medium_2", 17, 19, sx = { 1.16, 1.19 }, x = { -7, -13 }, a = { 0.67, 0 } },
{ "player_wisps_wide", 20, 29, sx = { 0.09, 1.24 }, x = { 165, -15 }, y = 10, a = { 1, 0 } },
{ "player_sparks", 0, 4, sx = { 0.1, 1 }, x = { 156, 16 } },
{ "player_sparks", 5, 16, x = { 7, -94 } },
{ "player_sparks", 17, 19, sx = { 1.02, 1.05 }, x = { -99, -107 }, a = { 0.67, 0 } },
} },
{ x = -210, y = 91,
placed(PLAYER_STREAK, { x = -18, y = 84 }), placed(PLAYER_STREAK, { x = -19, y = -61, flip = true }),
placed(PLAYER_HORN, { x = -29, y = 10, s = 1.2 }),
placed(PLAYER_HORN, { x = -29, y = 18, sx = 1.2, sy = 1.08, flip = true }),
fire_tier_4("player_large", "player_burst", "player_sparks", 1), spark_flash("player_sparks") },
}
local AI_FLAMES = {
{ x = 41, y = 101, {
loop = 19,
{ "ai_small", 11, 14, x = -50.5, frame = 2 },
{ "ai_small", 0, 4, s = { 0.9, 1 }, x = { -46, -53 }, frame = 0, n = 3 },
{ "ai_small", 5, 10, sx = { 0.54, 0.62 }, sy = { 1, 1.15 }, x = { -29, -37 }, frame = 1, n = 3 },
{ "ai_small_end", 15, 18, n = 3 },
} },
{ x = -63, y = 101, small_horn("ai_horn_small", 66, -41),
small_horn("ai_horn_small", 66, 49, true),
fire_tier_2("ai_mid_1", "ai_mid_2", "ai_sparks", 94, "ai_wisps", { 84, 24 }) },
{ x = -138, y = 91, placed(AI_HORN, { x = -33, y = 8, s = 1.2 }),
placed(AI_HORN, { x = -33, y = 20, s = 1.2, flip = true }), {
intro = 20, loop = 20, x = -38, s = 1.2,
{ "ai_medium_1", 20, 23, sx = { 0.6, 0.68 }, x = { 83, 70 } },
{ "ai_medium_1", 24, 25, sx = { 0.7, 0.5 }, x = { 66, 63 }, a = { 1, 0 } },
{ "ai_medium_1", 30, 33, sx = { 0.6, 0.68 }, x = { 83, 70 } },
{ "ai_medium_1", 34, 35, sx = { 0.7, 0.5 }, x = { 66, 63 }, a = { 1, 0 } },
{ "ai_medium_2", 0, 16, sx = { 0.1, 1.14 }, x = { 162, -4 }, ease = true },
{ "ai_medium_2", 17, 19, sx = { 1.16, 1.19 }, x = { -7, -13 }, a = { 0.67, 0 } },
{ "ai_medium_2", 25, 29, sx = { 0.6, 0.7 }, x = { 82, 67 } },
{ "ai_medium_2", 35, 39, sx = { 0.6, 0.7 }, x = { 82, 67 } },
{ "player_sparks", 0, 4, sx = { 0.1, 1 }, x = { 156, 16 } },
{ "player_sparks", 5, 16, x = { 7, -94 } },
{ "player_sparks", 17, 19, sx = { 1.02, 1.05 }, x = { -99, -107 }, a = { 0.67, 0 } },
{ "ai_wisps_wide", 20, 34, sx = { 0.09, 1.24 }, x = { 169, 33 }, y = 10, a = { 1, 0 } },
} },
{ x = -145, y = 91,
placed(AI_STREAK, { x = 45, y = 81 }), placed(AI_STREAK, { x = 46, y = -59, flip = true }),
placed(AI_HORN, { x = -29, y = 18, s = 1.2 }),
placed(AI_HORN, { x = -29, y = 18, sx = 1.2, sy = 1.08, flip = true }),
fire_tier_4("ai_large", "ai_burst", "ai_sparks", 2, true), spark_flash("ai_sparks") },
}
-- The player's section-winning shot: one flare, not a whole flame tier.
local PLAYER_SHOT = {
{ "player_large", 0, 16, sx = { 0.1, 1.3 }, x = { 145, -54 }, ease = true },
{ "player_large", 17, 19, sx = { 1.33, 1.4 }, x = { -60, -72 }, a = { 0.67, 0 } },
{ "player_sparks", 0, 4, sx = { 0.1, 1 }, x = { 139, 0 } },
{ "player_sparks", 5, 14, x = { -12, -115 } },
{ "player_sparks", 15, 19, x = { -127, -173 }, a = { 0.8, 0 } },
}
local function player_flame_tier(position)
if position <= 3 then return 1 end
if position <= 7 then return 2 end
return position <= 13 and 3 or 4
end
local function ai_flame_tier(position)
if position <= 2 then return 4 end
if position <= 10 then return 3 end
return position <= 14 and 2 or 1
end
local function sweep(value, p, default)
if type(value) == "table" then return value[1] + (value[2] - value[1]) * p end
return value or default
end
local function part_clock(part, f)
f = f - (part.start or 0)
if f < 0 then return nil end
local intro = part.intro or 0
if not part.loop or f < intro then return f end
return intro + (f - intro) % part.loop
end
function AIBattleOverlay:draw_flame_part(part, f, origin, y, dir, alpha)
f = part_clock(part, f)
if not f then return end
local px, py = part.x or 0, part.y or 0
local psx, psy = part.sx or part.s or 1, part.sy or part.s or 1
local ydir = part.flip and -1 or 1
for _, l in ipairs(part) do
if f >= l[2] and f < l[3] + 1 then
local p = l[3] > l[2] and math.min((f - l[2]) / (l[3] - l[2]), 1) or 1
if l.ease then p = 1 - (1 - p) * (1 - p) end
local rot = sweep(l.rot, p, 0)
local mirror
if dir < 0 and part.flip then
rot = rot + 180
elseif dir < 0 then
mirror, rot = "horizontal", -rot
elseif part.flip then
mirror, rot = "vertical", -rot
end
for _ = 1, l.n or 1 do
self:spr("flame_" .. l[1], origin + dir * (px + psx * sweep(l.x, p, 0)),
y + py + ydir * psy * sweep(l.y, p, 0), {
sx = sweep(l.sx or l.s, p, 1) * psx, sy = sweep(l.sy or l.s, p, 1) * psy,
rot = rot, frame = l.frame, fade = sweep(l.a, p, 1) * alpha,
mirror = mirror, add = not part.normal,
})
end
end
end
end
function AIBattleOverlay:draw_flames(ms, border, position, dir)
local tier = dir > 0 and player_flame_tier(position) or ai_flame_tier(position)
local entered = self.flame_tier[dir]
if not entered or entered.tier ~= tier then
entered = { tier = tier, start = ms }
self.flame_tier[dir] = entered
end
local spec = (dir > 0 and PLAYER_FLAMES or AI_FLAMES)[tier]
local f = (ms - entered.start) / FRAME_MS
for _, part in ipairs(spec) do
self:draw_flame_part(part, f, border + dir * spec.x, spec.y, dir, 1)
end
end
function AIBattleOverlay:border_x(territory)
return BORDER_X[math.min(math.max(1, territory + 10), TILES - 1)]
end
function AIBattleOverlay:draw_blast(f, border, dir)
if f < 23 or f >= 32 then return end
local p = math.min((f - 23) / 5, 1)
local alpha = f < 29 and 1 or 0.67 * (1 - (f - 29) / 3)
if dir > 0 then
local x = -41 + (border - 140 + 41) * p
self:draw_flame_part(PLAYER_SHOT, f - 23, x, 81, 1, alpha)
else
local x = 1164 + (border + 110 - 1164) * p
for _, part in ipairs(AI_FLAMES[4]) do
self:draw_flame_part(part, f - 23, x, 101, -1, alpha)
end
end
end
local FLAME_Y = 100
function AIBattleOverlay:draw_impact(f, x)
local h = f - 31
if h < 0 or h > 60 then return end
local p = h / 60
self:spr("burst_lightning", x, FLAME_Y, { s = 0.8 + 0.5 * math.min(h / 11, 1), fade = 1 - math.min(h / 11, 1), add = true })
self:spr("burst_rays", x, FLAME_Y, { s = 0.1 + 0.9 * p, rot = 120 * p, fade = 0.63 * (1 - p), add = true })
for i = 0, 3 do
local a = i * math.pi / 2 + 0.7
self:spr("burst_star", x + math.cos(a) * 130 * p, FLAME_Y + math.sin(a) * 90 * p, {
s = 1 - p, fade = 1 - p, add = true,
})
end
end
local SPARKS = {
{ "spark_yellow_small", -70, 53, 50, -60, 90 }, { "spark_yellow", -87, -43, -60, -133, 80 },
{ "spark_purple", -165, -85, -132, -50, 90 }, { "spark_pink_small", -66, -20, 30, 40, 80 },
{ "spark_white", -82, 103, 40, 60, 80 },
}
function AIBattleOverlay:draw_sparks(f, x, y, scale)
for _, s in ipairs(SPARKS) do
if f >= 0 and f <= s[6] then
local p = f / s[6]
self:spr(s[1], x + (s[2] + (s[4] - s[2]) * p) * scale, y + (s[3] + (s[5] - s[3]) * p) * scale, {
s = scale * (1.2 - 0.9 * p), rot = 360 * p, fade = math.min(p * 4, 1 - p), add = true,
})
end
end
end
function AIBattleOverlay:draw_rising(f)
if f < 0 or f > 180 then return end
local p = f / 179
local fade = math.min(1, (1 - p) * 4)
self:spr("rising_emblems_1", 209, 859 - 259 * p, { fade = fade, add = true })
self:spr("rising_emblems_2", 1039, 855 - 256 * p, { fade = fade, add = true })
self:spr("rising_emblems_1", 209, 227 - 154 * p, { fade = fade, add = true, mirror = "horizontal" })
self:spr("rising_emblems_2", 1039, 296 - 196 * p, { fade = fade, add = true, mirror = "horizontal" })
for _, spot in ipairs({ { 524, 625 }, { 948, 120 }, { 448, 120 } }) do
for i = 0, 2 do
local a = i * 2.1 + spot[1]
self:spr("rising_triangle", spot[1] + math.cos(a) * 95 * p, spot[2] + math.sin(a) * 60 * p, {
s = 0.75 - 0.5 * p, rot = 360 * p, fade = fade, add = true,
})
end
self:spr("rising_outline", spot[1], spot[2], { s = 0.5, rot = 360 * p, fade = fade, add = true })
end
self:draw_sparks(f % 100, 924, 574, 0.8)
self:draw_sparks(f % 100, 355, 574, 0.8)
end
function AIBattleOverlay:draw_clash(ms, territory, angry)
local border = self:border_x(territory)
local active = not self.final and not self:event_frame(ms)
if active then
self:draw_flames(ms, border, territory + 9, 1)
self:draw_flames(ms, border, territory + 9, -1)
else
self.flame_tier = {}
end
local event_f = self:event_frame(ms)
if event_f and not self.final then
self:draw_blast(event_f, border, self.event.won and 1 or -1)
self:draw_impact(event_f, self.event.won and 1200 or 80)
if self.event.won then self:draw_rising(event_f) end
elseif self.final and self.final.start and self.final.won then
self:draw_rising((ms - self.final.start) / FRAME_MS)
end
if angry and not self.final then
local f = (ms / FRAME_MS) % 20
local x = border + 79
self:spr("emotion_angry", x, 50, { s = 1 + 0.1 * flash(f, 20) })
if f >= 9 then
local h = (f - 9) / 10
self:spr("emotion_angry_flash", x, 50, { s = 1.1 + 0.4 * h, fade = 1 - h })
end
end
end
function AIBattleOverlay:draw_section_result(ms)
local event = self.event
if not event or not event.start then return end
local f = (ms - event.start) / FRAME_MS - 4
if f < 0 or f > 104 then return end
local key = event.won and "result_win" or "result_lose"
local CX, CY = 403, 93
if f <= 95 then
local frame = 0
if f >= 55 and f < 75 then frame = math.min(1 + (f - 55) // 4, 5) end
if f < 75 then
self:spr(key, CX, CY, { s = track(f, { { 0, 2.714 }, { 16, 1 } }), frame = math.floor(frame) })
else
local h = (f - 75) / 9
self:spr(key, CX, CY)
if h <= 1 then
self:spr(key .. "_flash", CX, CY, { s = 1 + 2 * h, fade = 1 - h, add = true })
end
end
else
local h = (f - 96) / 8
self:spr(key, CX - 120 * h, CY, { s = 1 - 0.7 * h, fade = 1 - h })
end
end
function AIBattleOverlay:draw_section_pops(ms)
local event = self.event
if not event or not event.start or not event.won then return end
local h = ((ms - event.start) / FRAME_MS - 104) / 12
if h < 0 or h > 1 then return end
local slot = (self.section_count == 3 and 2 or 0) + event.section
local x = 190 + slot * 31
local y = slot % 2 == 0 and 111 or 126
self:spr("section_pop", x, y, { s = 0.8 + 0.6 * h, fade = 1 - h, add = true })
end
function AIBattleOverlay:draw_circuit(f)
if f > 68 then return end
local fade = f < 50 and 1 or (68 - f) / 18
for side = 1, 2 do
local dir = side == 1 and 1 or -1
local mirror = side == 2 and "horizontal" or nil
local x0 = side == 1 and 200 or 1077
local y0 = side == 1 and 376 or 336
self:spr("finish_circuit", x0 + dir * 94, y0, { mirror = mirror, fade = fade * 0.9 })
for i, streak in ipairs({ { 3, 434, 200, -15 }, { 23, 411, 330, -121 }, { 35, 308, 100, -172 } }) do
local h = (f - streak[1]) / 20
if h >= 0 and h <= 1 then
self:spr("finish_streak", x0 + dir * (streak[2] + (streak[3] - streak[2]) * h), y0 + dir * streak[4], {
sx = 2.5 * (1 - h) + 0.2, fade = fade, mirror = mirror, add = true,
})
self:spr("finish_dot", x0 + dir * (streak[2] + (streak[3] - streak[2]) * h), y0 + dir * streak[4], {
s = 1 + h, fade = fade * (1 - h), add = true,
})
end
if i == 3 and f >= 46 then
self:spr("finish_dot", x0 + dir * 12, y0 - dir * 33, {
s = 1 + (f - 46) / 20, fade = fade * (1 - (f - 46) / 20), add = true,
})
end
end
end
end
local function dim(alpha)
tex.draw_rect(0, 0, 1280 * S, 720 * S, 0, 0, 0, 255 * alpha)
end
function AIBattleOverlay:draw_finish(f)
local CX, CY = 640, 360
dim(math.min(f / 124, 1) * 0.5)
self:draw_circuit(f)
local h = f - 4
if h >= 0 and h <= 40 then
local p = h / 40
self:spr("finish_ring_flash", CX, CY, { s = 0.1 + 1.9 * p, rot = 270 * p, fade = 1 - p * p })
end
if h >= 0 and h <= 8 then
self:spr("finish_disc", CX, CY, { s = 0.5 + 1.136 * h / 8, fade = 1 - h / 8 })
end
if f <= 12 then
self:spr("finish_dot", CX, CY, { s = 1 + 10 * f / 12, fade = 1 - f / 12 })
end
local g = f - 27 -- ring, disc and logo arrive
if g < 0 then return end
local glow_phase = (f % 400) / 400
self:spr("finish_glow", CX, CY, {
s = 1 + 0.2 * flash(f, 120), fade = math.min(g / 60, 1) * track(f % 400, { { 0, 0 }, { 75, 0.2 }, { 120, 0 }, { 400, 0 } }),
})
local settle = math.min(g / 96, 1)
self:spr("finish_ring_dashed", CX, CY, {
s = 0.1 + 0.9 * math.min(g / 40, 1), rot = (f < 125 and -90 * (1 - settle) or 360 * glow_phase),
})
if f >= 37 then
self:spr("finish_ring", CX, CY, {
s = track(f - 37, { { 0, 0.4 }, { 40, 1 } }), rot = (f < 125 and 80 * (1 - settle) or -360 * glow_phase),
})
end
if g <= 20 then
self:spr("finish_disc", CX, CY, { s = track(g, { { 0, 0.7 }, { 12, 1.152 }, { 20, 0.85 } }) })
elseif f <= 41 + 27 then
self:spr("finish_disc", CX, CY, { s = 0.85 + 0.95 * (g - 20) / 21, fade = 1 - (g - 20) / 21 })
end
local pulse = f < 125 and 1 or 1 - 0.05 * flash(f, 60)
self:spr("finish_logo", CX, CY - 2, {
s = track(f - 27, { { 0, 0.8 }, { 14, 1.2 }, { 30, 1 } }) * pulse, fade = math.min(g / 14, 1),
})
end
function AIBattleOverlay:draw_win(f)
local LAUREL_X, LAUREL_Y = 634, 351
local BATTLE_X, BATTLE_Y = 628, 264
local TEXT_X, TEXT_Y = 631, 430
local grow = track(f, { { 0, 3 }, { 20, 1 } })
self:spr("win_laurel", LAUREL_X, LAUREL_Y, { s = grow, fade = math.min(f / 20, 1) })
local flash_f = (f - 42)
if flash_f >= 0 and flash_f <= 40 then
self:spr("win_laurel_flash", LAUREL_X, LAUREL_Y, { s = 1 + flash_f / 40, fade = 1 - flash_f / 40, add = true })
elseif f > 149 and (f - 149) % 128 >= 64 then
self:spr("win_laurel_flash", LAUREL_X, LAUREL_Y, { fade = blink(((f - 149) % 128) - 64, 64), add = true })
end
local ray = f - 40
if ray >= 0 and ray <= 90 then
local p = ray / 90
self:spr("fc_rays", LAUREL_X, LAUREL_Y + 76, { rot = -90 * p, fade = 0.63 * (1 - p), add = true })
self:spr("fc_rays", LAUREL_X, LAUREL_Y + 76, { rot = 90 * p, fade = 0.63 * (1 - p), add = true })
end
if f >= 20 then
self:spr("win_text", TEXT_X, TEXT_Y, { s = track(f, { { 20, 3 }, { 40, 1 } }), fade = math.min((f - 20) / 20, 1) })
self:spr("win_battle_text", BATTLE_X, BATTLE_Y, { fade = math.min((f - 20) / 5, 1) })
end
if f >= 60 then
self:spr("win_shadow", TEXT_X + 1, TEXT_Y, { fade = track(f, { { 60, 0.8 }, { 72, 0 } }) })
end
for i, b in ipairs({ { 343, -175 }, { 444, 159 }, { 66, 507 }, { -571, -163 }, { -778, 150 }, { -493, 488 } }) do
self:draw_sparks(f - 42 - (i % 3) * 5, 803 + b[1], 270 + b[2], 0.87)
end
if f >= 73 then
for i, star in ipairs({ { 638, 346 }, { 963, 400 }, { 613, 330 }, { 650, 250 } }) do
local a = ((f - 73 + i * 14) % 75) / 40
if a <= 1 then
self:spr("win_star", star[1], star[2], {
s = track(a, { { 0, 0.6 }, { 1, 1.3 } }), fade = blink(a, 1), add = true,
})
end
end
end
end
function AIBattleOverlay:draw_full_combo(f)
local CX, CY = 640, 360
local loop = f <= 250 and f or 130 + (f - 130) % 120
self:spr("fc_glow", CX, CY - 20, { fade = track(loop, { { 0, 0 }, { 129, 0.99 }, { 130, 0.99 } }), add = true })
if loop >= 40 then
local p = math.min((loop - 40) / 90, 1)
self:spr("fc_rays", CX, CY, { rot = -180 * p, fade = 0.63 * (1 - p), add = true })
self:spr("fc_rays", CX, CY, { rot = 180 * p, fade = 0.63 * (1 - p), add = true })
end
if f >= 22 then
local h = math.min((f - 22) / 48, 1)
self:spr("fc_emblem", CX, CY, { s = 0.1 + 1.1 * h, fade = 1 - h })
end
local text_scale = track(f, { { 0, 3 }, { 40, 1 } })
self:spr("fc_text", 636, 348, { s = text_scale, fade = math.min(f / 40, 1) })
if f >= 40 and f <= 70 then
local h = (f - 40) / 30
self:spr("fc_text_flash", 636, 348, { s = 1 + 2 * h, fade = 1 - h, add = true })
end
if loop >= 130 and loop <= 199 then
self:spr("fc_text_flash", 636, 348, { fade = 0.5 * blink(loop - 130, 69), add = true })
end
for i, b in ipairs({ { 1175, 73 }, { 1276, 407 }, { 898, 755 }, { 261, 85 }, { 54, 398 }, { 339, 736 } }) do
self:draw_sparks(loop - 42 - (i % 3) * 5, b[1], b[2], 0.87)
end
if loop >= 40 then
for i, star in ipairs({ { 440, 294 }, { 941, 384 }, { 604, 406 }, { 780, 284 } }) do
local a = ((loop - 40 + i * 14) % 69) / 40
if a <= 1 then self:spr("win_star", star[1], star[2], { s = 0.6 + 0.7 * a, fade = blink(a, 1), add = true }) end
end
end
end
function AIBattleOverlay:draw_finale(ms)
self:draw_section_result(ms)
self:draw_section_pops(ms)
local final = self.final
if not final or not final.start then return end
local f = (ms - final.start) / FRAME_MS
local start_length = final.won and 149 or 125
self:cue(final.won and "se_win_laurel" or "se_lose_beam", "sound", f)
self:cue(final.won and "vo_last_win" or "vo_last_lose", "voice", f)
self:cue(final.won and "se_win_logo" or "se_lose_logo", "sound", f, final.won and 25 or 4)
if final.full_combo then
self:cue("se_full_combo_logo", "sound", f, start_length + 22)
self:cue("se_full_combo_spark", "sound", f, start_length + 40)
end
if final.full_combo and f >= start_length then
self:draw_full_combo(f - start_length)
elseif final.won then
self:draw_win(f)
else
self:draw_finish(f)
end
end
function AIBattleOverlay:draw_scoreboard(is_ai, good, ok, bad, drumroll)
if is_ai then tex.draw_texture(self:id(DIR .. "ai_plate")) end
tex.draw_texture(self:id(DIR .. (is_ai and "scoreboard_ai" or "scoreboard")))
local digit = self:id(DIR .. "scoreboard_num")
local margin = 8
for row, counter in ipairs({ good, ok, bad, drumroll }) do
local digits = tostring(counter)
local width = #digits * margin
for i = 1, #digits do
tex.draw_texture(digit, {
frame = tonumber(digits:sub(i, i)),
x = -(width // 2) + (i - 1) * margin,
index = (row - 1) + (is_ai and 4 or 0),
})
end
end
end
AIGauge = setmetatable({}, { __index = Gauge })
AIGauge.__index = AIGauge
function AIGauge.new(is_2p, dan, string_diff)
local opts = { folder = "gauge_ai", scale = GAUGE_SCALE, footer = true, no_border = true }
return setmetatable(Gauge.new(is_2p, dan, string_diff, opts), AIGauge)
end
function AIGauge:blit(t, mirror, x, y, ...)
return Gauge.blit(self, t, mirror, (x or 0) + GAUGE_X / GAUGE_SCALE, y + GAUGE_Y, ...)
end
function AIGauge:draw(_, ...)
return Gauge.draw(self, 0, ...)
end