Files
2026-10-21 22:14:26 -04:00

264 lines
10 KiB
Lua

KusudamaCounter = {}
KusudamaCounter.__index = KusudamaCounter
local FRAME_MS = 1000.0 / 60.0
local function clamp(v, lo, hi) return math.max(lo, math.min(hi, v)) end
local function ramp(t, t0, t1, a, b, e)
if t1 <= t0 then return b end
local p = clamp((t - t0) / (t1 - t0), 0, 1)
if e == "in" then p = p * p
elseif e == "out" then p = p * (2 - p) end
return a + (b - a) * p
end
-- 0 -> 1 -> 0 with a half period of `half` ms
local function tri(t, half)
local u = (t % (half * 2)) / half
return u <= 1 and u or 2 - u
end
-- Fade in over `in_ms`, out from `out0` to `out1`
local function envelope(t, in_ms, out0, out1)
return math.min(ramp(t, 0, in_ms, 0, 1), ramp(t, out0, out1, 1, 0))
end
-- Ball size at rest and at the peak of a hit pulse, per stage (count * 6 / total)
local STAGE_REST = {[0] = 1.0, 1.0, 1.02, 1.02, 1.04, 1.06}
local STAGE_PEAK = {[0] = 1.01, 1.02, 1.04, 1.051, 1.071, 1.113}
-- Layer strength per stage: soft vignette, glow over the ball, speed lines
local STAGE_VIGNETTE = {[0] = 0.0, 0.1, 0.2, 0.4, 0.6, 0.8}
local STAGE_GLOW = {[0] = 0.0, 0.0, 0.1, 0.2, 0.4, 1.0}
local STAGE_LINES = {[0] = 0.0, 0.2, 0.4, 0.6, 0.8, 1.0}
-- Ball centre: texture.json y + move attribute at rest + half the image
local REST_MOVE = 472.0
local POP_SCALE = 1.06
local MISS_FALL_START = 11 * FRAME_MS
local MISS_FALL_END = 25 * FRAME_MS
local MISS_FALL_DISTANCE = 1150.0
-- Mascots that fly out of the popped ball; t is ms since each was placed (5 frames after the pop)
local function cotton_pose(t)
return ramp(t, 0, 1000, 0, 250, "in"), ramp(t, 0, 1000, 0, -302, "in"),
ramp(t, 0, 983, 1, 1.6, "in") * (0.9 + 0.1 * tri(t, 233.3)),
envelope(t, 117, 883, 983), 0
end
local function bell_pose(t)
local first = t < 608
return first and ramp(t, 0, 600, 52, 417) or ramp(t, 617, 1000, 425, 664),
first and ramp(t, 0, 600, 38, -232) or ramp(t, 617, 1000, -232, -66),
ramp(t, 0, 983, 1, 1.6, "in"), envelope(t, 100, 867, 983),
t < 550 and 0 or (t < 617 and 1 or 2)
end
local function bird_pose(t)
local first = t < 175
return first and ramp(t, 0, 167, 66, 169) or ramp(t, 183, 983, 180, 579, "out"),
first and ramp(t, 0, 167, 46, 19) or ramp(t, 183, 983, 19, 312, "out"),
first and ramp(t, 0, 167, 1, 1.113) or ramp(t, 183, 983, 1.126, 2, "out"),
envelope(t, 100, 867, 1000),
t < 267 and 0 or 1 + (math.floor((t - 267) / 50) % 2)
end
local function balloon_pose(t)
local first = t < 125
return first and ramp(t, 0, 117, 84, 146, "in") or ramp(t, 133, 1000, 157, 356, "in"),
first and ramp(t, 0, 117, 117, 78, "in") or ramp(t, 133, 1000, 79, 590, "in"),
ramp(t, 0, 1000, 1, 2, "in"), envelope(t, 83, 917, 1017),
t < 233 and 0 or (t < 317 and 1 or (t < 383 and 2 or 3))
end
local function chick_pose(t)
return 0, t < 325 and ramp(t, 0, 317, -36, -206, "out") or ramp(t, 333, 1000, -204, -118),
ramp(t, 0, 1000, 1, 2, "out"), envelope(t, 100, 900, 1017),
t < 333 and 0 or 1 + (math.floor((t - 333) / 50) % 2)
end
local function draw_pose(texture, left, left_frame, x, y, scale, alpha, frame)
if alpha <= 0 then return end
tex.draw_texture(texture, {
frame = frame + (left and left_frame or 0), scale = scale, center = true,
x = left and -x or x, y = y, fade = alpha,
})
end
function KusudamaCounter.new(total)
local self = setmetatable({}, KusudamaCounter)
self.total = total
self.count = 0
self.stage = 0
self.now = 0
self.pop_ms = 0
self.miss_ms = 0
self.is_popped = false
self.is_missed = false
self.move = tex.get_animation(11)
self.renda_move = tex.get_animation(13)
self.renda_fade_in = tex.get_animation(14)
self.stretch = tex.get_animation(15)
self.breathing = tex.get_animation(16)
self.renda_breathe = tex.get_animation(17)
self.open = tex.get_animation(19)
self.renda_fade_out = tex.get_animation(20)
self.fade_out = tex.get_animation(21)
self.move:start()
self.renda_move:start()
self.renda_fade_in:start()
self.open:reset()
self.renda_fade_out:reset()
self.fade_out:reset()
self.t_kusudama = tex.get_texture("game/kusudama/kusudama")
self.t_renda = tex.get_texture("game/kusudama/renda")
self.t_counter = tex.get_texture("game/kusudama/counter")
-- Effect layers (glows, speed lines, mascots); only used when the skin has all of them
self.fx = true
for _, key in ipairs({"glow", "lines", "vignette", "cotton", "bell", "bird", "balloon", "chick"}) do
local t = tex.get_texture("game/kusudama/" .. key)
self[key] = t
if not t then self.fx = false end
end
return self
end
-- Skins with the effect layers keep the counter on screen while the ball falls after a failed kusudama.
function KusudamaCounter:miss(current_ms)
if not self.fx or self.is_popped or self.is_missed then return end
self.is_missed = true
self.miss_ms = current_ms
end
function KusudamaCounter:update_count(count)
if self.count == count then return end
self.count = count
if self.total > 0 then self.stage = math.min(5, count * 6 // self.total) end
self.stretch:start()
self.breathing:start()
if count == self.total then
self.is_popped = true
self.pop_ms = self.now
self.open:start()
self.renda_fade_out:start()
self.fade_out:start()
end
end
function KusudamaCounter:update(current_ms, count)
self.now = current_ms
self.move:update(current_ms)
self.renda_move:update(current_ms)
self.renda_fade_in:update(current_ms)
self.renda_fade_out:update(current_ms)
self.fade_out:update(current_ms)
self.stretch:update(current_ms)
self.breathing:update(current_ms)
if self.renda_move.is_finished and not self.renda_breathe.is_started then
self.renda_breathe:start()
end
self.renda_breathe:update(current_ms)
self.open:update(current_ms)
self:update_count(count)
end
function KusudamaCounter:has_popped() return self.is_popped end
function KusudamaCounter:has_missed() return self.is_missed end
function KusudamaCounter:is_finished()
if self.is_missed then return self.now - self.miss_ms >= MISS_FALL_END end
return self.fade_out.is_finished
end
function KusudamaCounter:draw_mascots(pop_t)
local t = pop_t - 5 * FRAME_MS
if t < 0 then return end
for _, left in ipairs({false, true}) do
draw_pose(self.cotton, left, 1, cotton_pose(t))
draw_pose(self.bell, left, 3, bell_pose(t))
draw_pose(self.bird, left, 3, bird_pose(t))
draw_pose(self.balloon, left, 4, balloon_pose(t))
end
draw_pose(self.chick, false, 0, chick_pose(t))
end
function KusudamaCounter:draw_fx()
local now = self.now
local popped, missed = self.is_popped, self.is_missed
local pop_t = popped and now - self.pop_ms or 0
local miss_t = missed and now - self.miss_ms or 0
local stage = self.stage
local rest = popped and POP_SCALE or STAGE_REST[stage]
local scale = rest
if not popped and not missed then
local pulse = clamp((self.breathing.attribute - 1.0) / 0.05, 0, 1)
scale = rest + (STAGE_PEAK[stage] - rest) * pulse
end
local fall = missed and ramp(miss_t, MISS_FALL_START, MISS_FALL_END, 0, MISS_FALL_DISTANCE, "in") or 0
local y = self.move.attribute + fall
local dy = y - REST_MOVE -- ball centre follows the drop
local live = missed and ramp(miss_t, 0, 10 * FRAME_MS, 1, 0) or 1.0
local glow_pulse = 1.0 - 0.5 * tri(now, 250)
local vignette_pulse = 0.4 - 0.2 * tri(now, 250)
if popped then
tex.draw_texture(self.vignette, { fade = vignette_pulse * ramp(pop_t, 933, 1083, 1, 0) })
elseif STAGE_VIGNETTE[stage] > 0 then
tex.draw_texture(self.vignette, { fade = STAGE_VIGNETTE[stage] * vignette_pulse * live })
end
if not missed and not popped then
tex.draw_texture(self.glow, { scale = scale, center = true, y = dy, fade = glow_pulse })
end
tex.draw_texture(self.t_kusudama, { frame = math.floor(self.open.attribute), scale = scale, center = true, y = y, fade = self.fade_out.attribute })
if popped then
tex.draw_texture(self.glow, {
scale = ramp(pop_t, 0, 333, 1, 1.4), center = true, y = dy,
fade = ramp(pop_t, 0, 333, 1, 0) * glow_pulse,
})
self:draw_mascots(pop_t)
elseif STAGE_GLOW[stage] > 0 then
tex.draw_texture(self.glow, {
scale = scale, center = true, y = dy,
fade = STAGE_GLOW[stage] * glow_pulse * live,
})
end
local lines = popped and ramp(pop_t, 950, 1083, 1, 0) or STAGE_LINES[stage] * live
if lines > 0 then
tex.draw_texture(self.lines, { frame = math.floor(now / 50) % 3, fade = 0.3 * lines })
end
end
function KusudamaCounter:draw_counter()
if not self.move.is_finished or self.is_popped then return end
local remaining = math.max(0, self.total - self.count)
if remaining == 0 then return end
local digits = tostring(remaining)
local margin = 160 * tex.get_screen_scale()
local total_width = #digits * margin
local stretch = self.stretch.attribute
for i = 1, #digits do
tex.draw_texture(self.t_counter, {
frame = tonumber(digits:sub(i, i)),
x = -(total_width / 2) + (i - 1) * margin,
y = -stretch, y2 = stretch,
})
end
end
function KusudamaCounter:draw()
local renda_fade = math.min(self.renda_fade_in.attribute, self.renda_fade_out.attribute)
if self.fx then
self:draw_fx()
if self.is_missed then return end
else
tex.draw_texture(self.t_kusudama, {
frame = math.floor(self.open.attribute), scale = self.breathing.attribute, center = true,
y = self.move.attribute, fade = self.fade_out.attribute,
})
end
tex.draw_texture(self.t_renda, { y = self.renda_move.attribute + self.renda_breathe.attribute, fade = renda_fade })
self:draw_counter()
end
return KusudamaCounter