Files
PyTaikoGreen/Scripts/game/fc_animation.lua
T
2026-10-13 13:22:39 -04:00

416 lines
18 KiB
Lua

FCAnimation = {}
FCAnimation.__index = FCAnimation
-- Lumen action_result/fullcombo: the clear letters pop up and flash, the bachio pair
-- slide out, crouch and hop to the far sides while the "full combo" text rises over
-- them, fans open behind it and confetti shoots from the fans. After the intro the
-- bachio keep hopping, sparkles twinkle and the text flashes (all looping).
-- Times are Lumen frames (1/60 s). Lane-local centres are relative to (806, 73);
-- offsets from the bachio json base follow clear_animation.lua.
local FRAME_MS = 1000 / 60
local PIECE_X = { 0, 59, 118, 185, 256 }
local PIECE_START, PIECE_STEP = 20, 3 -- frames
local PIECE_POP = 15
local OUT_POSES = { { -233, 1 }, { -204, -20 }, { -204, -20 }, { -212, 1 } }
local IN_REST = { -1, 1 }
local HOP_START = 59 -- frame the bachio leave the out poses
local SECOND_HOP, LOOP_FRAMES = 231, 100 -- crouch-and-hop that repeats every 100 f
local TEXT_APPEAR, VOICE_AT = 89, 89
local FLASH_LOOP = 220 -- sprite 120 loops f220..439
-- track: {frame, value, ease}; ease ("o" out / "i" in) shapes the segment ending at the key
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
local p = (f - k0[1]) / (k1[1] - k0[1])
if k1[3] == "o" then p = 1 - (1 - p) * (1 - p)
elseif k1[3] == "i" then p = p * p end
return k0[2] + (k1[2] - k0[2]) * p
end
end
return keys[#keys][2]
end
-- the left bachio's centre x/y and squash (bottom edge pinned) after it leaves the out poses
local HOP_X = { { 59, -241 }, { 76, -241 }, { 97, -351.7, "o" }, { 118, -440, "i" } }
local HOP_Y = {
{ 59, 8 }, { 67, 16.9, "o" }, { 73, 17 }, { 75, 13 }, { 77, -6.55 }, { 78, -20.5 }, { 97, -135, "o" },
{ 118, 8, "i" }, { 119, 12.2 }, { 121, 15.5 }, { 123, 12.15 }, { 124, 8 }, { 126, 6 }, { 128, 8 },
}
local HOP_D = {
{ 59, 1 }, { 67, 0.889 }, { 73, 0.887 }, { 75, 0.938 }, { 76, 1 }, { 118, 1 }, { 119, 0.941 },
{ 121, 0.894 }, { 123, 0.941 }, { 124, 1 }, { 126, 1.025 }, { 128, 1 },
}
-- the repeating hop, frames counted from SECOND_HOP
local HOP2_X = { { 0, -440 }, { 29, -437.7, "o" }, { 45, -440, "i" } }
local HOP2_Y = {
{ 0, 10.25 }, { 5, 16.3 }, { 11, 16.5 }, { 14, 11.75 }, { 15, 8 }, { 29, -17.65, "o" },
{ 45, 8, "i" }, { 46, 10.15 }, { 49, 16.5 }, { 51, 12.3 }, { 52, 10.1 }, { 53, 8 },
}
local HOP2_D = {
{ 0, 0.972 }, { 5, 0.896 }, { 11, 0.894 }, { 14, 0.954 }, { 15, 1 }, { 45, 1 }, { 46, 0.973 },
{ 48, 0.92 }, { 49, 0.894 }, { 50, 0.92 }, { 51, 0.947 }, { 52, 0.973 }, { 53, 1 },
}
-- the text and its glow share one vertical path and squash
local TEXT_Y = { { 89, -15 }, { 102, 0, "i" }, { 103, 2.3 }, { 105, 7 }, { 106, 4.7 }, { 108, 0 } }
local TEXT_D = { { 102, 1 }, { 103, 0.939 }, { 105, 0.818 }, { 106, 0.879 }, { 108, 1 } }
local GLOW_Y = { { 66, 0 }, { 80, -15, "o" }, { 89, -15 }, { 102, 0, "i" } }
local GLOW_A = { { 66, -1 }, { 80, 1, "o" }, { 89, 1 }, { 102, 0, "i" } }
local ADD_A = { { 66, 0 }, { 80, 1, "o" }, { 89, 1 }, { 102, 0.5, "i" }, { 105, 0.25 }, { 108, 0 } }
local OVERLAY_IN = { { 89, 0 }, { 102, 1, "i" } }
-- sparkles (sprite 51): { start frame, x, y }; the ones from f220 repeat every 100 f
local SPARKLES = {
{ 66, -30, -45 }, { 66, 30, 45 }, { 69, 75, -45 }, { 69, -75, 45 }, { 72, -120, -45 }, { 72, 120, 45 },
{ 75, -165, 45 }, { 75, 165, -45 }, { 77, -306, -7 }, { 77, 306, -7 }, { 78, -210, -45 }, { 78, 210, 45 },
{ 80, -340, 7 }, { 80, 340, 7 }, { 83, -382, -20 }, { 83, 382, -20 }, { 83, -262, 43 }, { 83, 262, 43 },
{ 89, -236, -24 }, { 89, 236, -24 }, { 92, -360, 31 }, { 92, 360, 31 }, { 108, -212, 0 },
{ 116, 55.35, 44.35 }, { 119, -270, -7 }, { 119, 270, -7 }, { 123, -66.65, -46.65 }, { 130, -112, 44.35 },
{ 134, -340, 7 }, { 134, 340, 7 }, { 138, 212.35, 18 }, { 145, 136.35, -46.65 }, { 149, -280, -38 },
{ 149, 280, -38 }, { 155, -212, 0 }, { 166, -262, 43 }, { 166, 262, 43 }, { 183, -236, -24 },
{ 183, 236, -24 }, { 200, -360, 31 }, { 200, 360, 31 },
}
local SPARKLES_LOOP = { -- from f220, period 100
{ 220, -270, -7 }, { 220, 270, -7 }, { 235, -340, 7 }, { 235, 340, 7 }, { 250, -280, -38 },
{ 250, 280, -38 }, { 267, -262, 43 }, { 267, 262, 43 }, { 284, -236, -24 }, { 284, 236, -24 },
{ 301, -360, 31 }, { 301, 360, 31 },
}
-- sparkles of sprite 120 (period 220 from f255 and f350), 8 f apart
local SPARKLES_FLASH = {
{ -202, -21 }, { -141.65, 26.35 }, { -62.65, -16.65 }, { 22, 18.35 }, { 87.35, -34.65 },
{ 146.35, 24.35 }, { 175, -37 },
}
local FLASH_SPARKLE_STARTS = { 255, 350 }
-- confetti (sprite 118): one piece every 3.33 f from f120, shot out of each fan; the path
-- (frame, x, y from the launch point) is shared by the five colours' own arcs
local CONFETTI_START, CONFETTI_STEP, CONFETTI_LIFE = 120, 100 / 30, 70
local EMITTER = { 315, 14 }
local CONFETTI_OFFSETS = {
{ 20, -70 }, { 40, -55 }, { -20, -86 }, { 2, -87 }, { 24, -57 }, { -40, -90 }, { -40, -95 },
{ -60, -126 }, { -38, -67 }, { -36, -117 }, { -50, -70 }, { -40, -65 }, { -40, -86 },
{ -58, -87 }, { -56, -57 },
}
local CONFETTI_PATHS = {
{ { 0, 7.2, 65 }, { 3, 33.5, 30.4 }, { 7, 68.4, -3.4 }, { 10, 93.1, -20.1 }, { 14, 123.9, -28.9 },
{ 20, 145.9, -22.1 }, { 30, 169.4, 6.1 }, { 40, 183.6, 45.2 }, { 50, 191.7, 91.4 }, { 62, 195, 153.6 },
{ 75, 206.4, 227.4 } },
{ { 0, 5.9, 67 }, { 3, 32.7, 45.8 }, { 7, 60, 17.3 }, { 10, 78.3, -0.9 }, { 14, 102.6, -16.6 },
{ 20, 129.7, -13.5 }, { 30, 140.4, 17.9 }, { 40, 139.6, 55 }, { 50, 135.9, 95 }, { 62, 133.6, 148.6 },
{ 75, 137.2, 214.4 } },
{ { 0, 4.8, 66.6 }, { 3, 17.8, 43 }, { 7, 39.3, 19.5 }, { 10, 58.7, 11.1 }, { 14, 82.3, 7.8 },
{ 20, 108.6, 11.4 }, { 30, 132.1, 32.4 }, { 40, 148.3, 65.8 }, { 50, 163.4, 105.9 }, { 62, 187.7, 157.5 },
{ 75, 219.9, 216.9 } },
{ { 0, 5.9, 67 }, { 3, 24.2, 38.7 }, { 7, 58.2, 16 }, { 10, 82.8, 5.2 }, { 14, 112.7, -2.5 },
{ 20, 148.2, 1.8 }, { 30, 167.5, 27.2 }, { 40, 161.9, 65.7 }, { 50, 146.8, 107.3 }, { 62, 132.9, 164.1 },
{ 75, 136.4, 236.1 } },
{ { 0, 5.9, 67 }, { 3, 25.7, 40.2 }, { 7, 55.3, 14.1 }, { 10, 77.5, 0.6 }, { 14, 105.6, -9.7 },
{ 20, 139.8, -5.5 }, { 30, 160.3, 14.6 }, { 40, 173.9, 45.2 }, { 50, 179.4, 83 }, { 62, 181.4, 135.3 },
{ 75, 191.8, 196.6 } },
}
local function path_at(path, f)
for i = 2, #path do
local a, b = path[i - 1], path[i]
if f <= b[1] then
local p = (f - a[1]) / (b[1] - a[1])
return a[2] + (b[2] - a[2]) * p, a[3] + (b[3] - a[3]) * p
end
end
local last = path[#path]
return last[2], last[3]
end
function FCAnimation.new(is_2p, donderful)
local self = setmetatable({}, FCAnimation)
self.is_2p = is_2p
self.idx = is_2p and 1 or 0
self.name = "in"
self.frame = 0
self.start_ms = nil
self.now = 0
self.voice_played = false
self.dx, self.dy, self.dh = IN_REST[1], IN_REST[2], 0
local has_dfc_tex = donderful
and tex.get_texture("game/ending_donderful/full_combo") ~= nil
and tex.get_texture("game/ending_donderful/full_combo_highlight") ~= nil
and tex.get_texture("game/ending_donderful/full_combo_overlay") ~= nil
local subset = has_dfc_tex and "ending_donderful/" or "ending_anim/"
self.combo_tex = tex.get_texture("game/" .. subset .. "full_combo")
self.combo_highlight_tex = tex.get_texture("game/" .. subset .. "full_combo_highlight")
self.combo_overlay_tex = tex.get_texture("game/" .. subset .. "full_combo_overlay")
self.has_panel = has_dfc_tex and tex.get_texture("game/ending_donderful/background") ~= nil
self.panel_tex = self.has_panel and tex.get_texture("game/ending_donderful/background") or nil
self.panel_fade_in = anim.fade(250, { initial_opacity = 0.0, final_opacity = 1.0, delay = TEXT_APPEAR * FRAME_MS })
local has_dfc_sound = donderful and audio.has_sound("donderful_combo")
self.combo_sound = has_dfc_sound and "donderful_combo" or "full_combo"
self.combo_voice = (donderful and audio.has_sound("donderful_combo_voice")) and "donderful_combo_voice" or "full_combo_voice"
local function game_anim(id) return tex.get_animation(id, "game") end
self.bachio_fade_in = game_anim(46)
self.pose_in = game_anim(94)
self.pose_out = game_anim(95)
self.slide = game_anim(96)
self.overshoot = game_anim(97)
self.settle = game_anim(98)
self.highlight_in = game_anim(99)
self.highlight_out = anim.fade(200, { initial_opacity = 1.0, final_opacity = 0.0, delay = 73 * FRAME_MS })
self.anims = {
self.bachio_fade_in, self.pose_in, self.pose_out, self.slide, self.overshoot, self.settle,
self.highlight_in, self.highlight_out, self.panel_fade_in,
}
-- the highlight of the letters fades in over f61-72 like the clear animation's, and out over f73-85
self.piece_fade, self.piece_rise, self.piece_fall = {}, {}, {}
for i = 1, 5 do
local delay = (PIECE_START + (i - 1) * PIECE_STEP) * FRAME_MS
self.piece_fade[i] = anim.fade(167, { initial_opacity = 0.0, final_opacity = 1.0, delay = delay, ease_out = "quadratic" })
self.piece_rise[i] = anim.move(167, { total_distance = PIECE_POP, delay = delay, ease_out = "quadratic" })
self.piece_fall[i] = anim.move(150, { total_distance = PIECE_POP, delay = delay + 167, ease_in = "quadratic" })
for _, a in ipairs({ self.piece_fade[i], self.piece_rise[i], self.piece_fall[i] }) do
self.anims[#self.anims + 1] = a
end
end
for _, a in ipairs(self.anims) do a:start() end
audio.play_sound(self.combo_sound, "sound")
self.t_fan_l = tex.get_texture("game/ending_anim/fan_l")
self.t_fan_r = tex.get_texture("game/ending_anim/fan_r")
self.t_clear_separated = tex.get_texture("game/ending_anim/clear_separated")
self.t_clear_sep_hl = tex.get_texture("game/ending_anim/clear_separated_highlight")
self.t_bachio_l_in = tex.get_texture("game/ending_anim/bachio_l_in")
self.t_bachio_l_out = tex.get_texture("game/ending_anim/bachio_l_out")
self.t_bachio_r_in = tex.get_texture("game/ending_anim/bachio_r_in")
self.t_bachio_r_out = tex.get_texture("game/ending_anim/bachio_r_out")
self.t_sparkle = tex.get_texture("game/ending_anim/sparkle")
self.t_confetti = tex.get_texture("game/ending_anim/confetti")
return self
end
-- the left bachio's centre (cx, cy) and height factor d at frame f
local function hop_state(f)
if f >= SECOND_HOP then
local p = (f - SECOND_HOP) % LOOP_FRAMES
if p <= 53 then
return track(p, HOP2_X), track(p, HOP2_Y), track(p, HOP2_D)
end
return -440, 8, 1
elseif f > 128 then
return -440, 8, 1
end
return track(f, HOP_X), track(f, HOP_Y), track(f, HOP_D)
end
function FCAnimation:update(current_ms)
self.start_ms = self.start_ms or current_ms
self.now = current_ms - self.start_ms
for _, a in ipairs(self.anims) do a:update(current_ms) end
local f = self.now / FRAME_MS
self.f = f
if not self.voice_played and f >= VOICE_AT then
self.voice_played = true
audio.play_sound(self.combo_voice, "voice")
end
if f >= HOP_START then
self.name = "hop"
local cx, cy, d = hop_state(f)
-- json base is 701, 187: the left bachio's top-left is (730 + cx, 261 + cy - 80 d) - 701/187 in lane terms
self.dx = 29 + cx
self.dy = 74 + cy - 80 * d
self.dh = 160 * (d - 1)
elseif self.pose_in.is_finished then
self.name = "out"
local step = math.floor(self.pose_out.attribute) + 1
self.dx, self.dy, self.dh = OUT_POSES[math.min(step, 4)][1], OUT_POSES[math.min(step, 4)][2], 0
self.frame = step
else
self.name = "in"
self.frame = math.floor(self.pose_in.attribute)
if self.frame == 0 then
self.dx, self.dy = IN_REST[1], IN_REST[2]
else
self.dx = self.slide.attribute + self.overshoot.attribute - self.settle.attribute
self.dy = IN_REST[2]
end
self.dh = 0
end
end
function FCAnimation:draw_bachio(side)
local t, frame
if self.name == "in" or (self.name == "out" and self.frame == 1) then
t = side < 0 and self.t_bachio_l_in or self.t_bachio_r_in
frame = self.name == "in" and self.frame or 0
else
t = side < 0 and self.t_bachio_l_out or self.t_bachio_r_out
frame = self.name == "hop" and 3 or self.frame - 2
end
tex.draw_texture(t, {
frame = frame,
x = -side * self.dx,
y = self.dy,
y2 = self.dh,
fade = self.bachio_fade_in.attribute,
index = self.idx,
})
end
local function sparkle_params(age) -- age in frames, 0..20
if age < 0 or age >= 20 then return nil end
local scale = 0.2 + 0.8 * math.min(age / 10, 1)
local fade = age <= 10 and 1.0 or 0.891 * (1 - (age - 11) / 9)
return scale, math.max(fade, 0)
end
function FCAnimation:draw_sparkle(x, y, age)
local scale, fade = sparkle_params(age)
if not scale then return end
tex.draw_texture(self.t_sparkle, { x = x, y = y, scale = scale, center = true, fade = fade, index = self.idx })
end
function FCAnimation:draw_sparkles(f)
for _, s in ipairs(SPARKLES) do self:draw_sparkle(s[2], s[3], f - s[1]) end
if f >= 220 then
for _, s in ipairs(SPARKLES_LOOP) do
self:draw_sparkle(s[2], s[3], (f - s[1]) % LOOP_FRAMES)
end
local lf = (f - FLASH_LOOP) % 220 + FLASH_LOOP
for _, start in ipairs(FLASH_SPARKLE_STARTS) do
for k, s in ipairs(SPARKLES_FLASH) do
self:draw_sparkle(s[1], s[2], lf - start - (k - 1) * 8)
end
end
end
end
function FCAnimation:draw_confetti(f)
if f < CONFETTI_START then return end
local newest = math.floor((f - CONFETTI_START) / CONFETTI_STEP)
for i = math.max(0, newest - math.ceil(CONFETTI_LIFE / CONFETTI_STEP)), newest do
local age = f - CONFETTI_START - i * CONFETTI_STEP
if age >= 0 and age < CONFETTI_LIFE then
local kind = i % 5
local off = CONFETTI_OFFSETS[i % 15 + 1]
local px, py = path_at(CONFETTI_PATHS[kind + 1], age)
local fade = age <= 50 and 1.0 or 0.949 * (1 - (age - 51) / 19)
for side = -1, 1, 2 do
tex.draw_texture(self.t_confetti, {
frame = kind,
x = side * (EMITTER[1] - off[1] - px) + 16,
y = EMITTER[2] + off[2] + py + 16,
rotation = (age * FRAME_MS) * 0.72,
origin = { 16, 16 },
fade = math.max(fade, 0),
index = self.idx,
})
end
end
end
end
function FCAnimation:draw()
local f = self.f or 0
self:draw_confetti(f)
-- letters, then their highlight; both are gone once the text takes over
if f < 73 then
for i = 5, 1, -1 do
local pop = self.piece_rise[i].attribute - self.piece_fall[i].attribute
tex.draw_texture(self.t_clear_separated, {
frame = i - 1,
x = PIECE_X[i],
y = -pop,
y2 = pop,
fade = self.piece_fade[i].attribute,
index = self.idx,
})
end
end
if f >= 61 then
for i = 5, 1, -1 do
local pop = f < 73 and (self.piece_rise[i].attribute - self.piece_fall[i].attribute) or 0
tex.draw_texture(self.t_clear_sep_hl, {
frame = i - 1,
x = PIECE_X[i],
y = -pop,
y2 = pop,
fade = f < 73 and self.highlight_in.attribute or self.highlight_out.attribute,
index = self.idx,
})
end
end
if f >= TEXT_APPEAR then
if self.has_panel then
tex.draw_texture(self.panel_tex, { fade = self.panel_fade_in.attribute, index = self.idx })
end
end
local text_y = f >= TEXT_APPEAR and track(f, TEXT_Y) or 0
local text_d = track(f, TEXT_D)
local text_h = 128 * (text_d - 1)
local text_top = text_y + 64 * (1 - text_d)
if f >= TEXT_APPEAR then
-- additive overlay pulses every 20 frames (0.8 -> 1 -> 0.8) once it has faded in
local phase = (f - TEXT_APPEAR) % 20
local pulse = phase <= 10 and (0.801 + 0.199 * phase / 10) or (0.977 - 0.176 * (phase - 11) / 8)
tex.draw_texture(self.combo_overlay_tex, {
y = text_top, y2 = text_h,
fade = track(f, OVERLAY_IN) * pulse,
blend = "additive",
index = self.idx,
})
tex.draw_texture(self.combo_tex, { y = text_top, y2 = text_h, index = self.idx })
end
-- rising glow (f66-108) and the looping flash from f220
local glow_y, glow_a, add_a = 0, 0, 0
if f >= 66 and f <= 108 then
glow_y, glow_a, add_a = track(f, GLOW_Y), math.max(track(f, GLOW_A), 0), track(f, ADD_A)
if f > 102 then glow_a, glow_y = 0, text_top end
elseif f >= FLASH_LOOP then
local p = (f - FLASH_LOOP) % 220
glow_a = p <= 15 and p / 15 or (p <= 30 and 0.934 * (1 - (p - 16) / 14) or 0)
add_a = glow_a
end
local glow_h = (f > 102 and f <= 108) and text_h or 0
if glow_a > 0 then
tex.draw_texture(self.combo_highlight_tex, { y = glow_y, y2 = glow_h, fade = math.min(glow_a, 1), index = self.idx })
end
if add_a > 0 then
tex.draw_texture(self.combo_overlay_tex, { y = glow_y, y2 = glow_h, fade = math.min(add_a, 1), blend = "additive", index = self.idx })
end
-- fans: open from f70, then flap every 3 frames from f118
if f >= 70 then
local fan_frame
if f < 77 then fan_frame = 0
elseif f < 79 then fan_frame = 1
elseif f < 80 then fan_frame = 2
elseif f < 81 then fan_frame = 3
elseif f < 118 then fan_frame = 4
else fan_frame = (math.floor((f - 118) / 3) % 2 == 0) and 5 or 4 end
local fan_fade = math.min((f - 70) / 4, 1)
tex.draw_texture(self.t_fan_l, { frame = fan_frame, fade = fan_fade, index = self.idx })
tex.draw_texture(self.t_fan_r, { frame = fan_frame, fade = fan_fade, index = self.idx })
end
self:draw_sparkles(f)
self:draw_bachio(-1)
self:draw_bachio(1)
end