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