149 lines
6.2 KiB
Lua
149 lines
6.2 KiB
Lua
-- Scripts/anim/sampler.lua — read the tables scratchpad/anim_export.py emits.
|
|
--
|
|
-- ROUND 7. Every other file in this directory is GENERATED: one row per kept
|
|
-- arcade frame, per named MovieClip, straight out of the cabinet's own Lumen
|
|
-- timeline (see ANIM_PIPELINE.md). The exporter guarantees that between two
|
|
-- kept rows every field is LINEAR, so sampling is a plain lerp and nothing here
|
|
-- has to know what curve the animator drew.
|
|
--
|
|
-- local anim = require("anim/sampler")
|
|
-- local board = anim.load("anim/folder_board")
|
|
-- local tx, ty, sy, a = board:at("instance_board_center", f) -- f in arcade frames
|
|
-- local f0 = board:label("select_off") -- label -> frame
|
|
--
|
|
-- `f` may be fractional (the engine's clock is ms, not frames): pass
|
|
-- `anim.frames(ms)` or do `ms * 0.06` yourself. Frames before the first row
|
|
-- clamp to it and frames after the last row clamp to that, which is exactly what
|
|
-- a Lumen timeline parked on its last frame does.
|
|
--
|
|
-- `:at` returns the fields in the table's own `fields` order. A track that does
|
|
-- not exist returns nothing, so a caller can `if not tx then return end` instead
|
|
-- of crashing a screen over a renamed clip.
|
|
|
|
local M = {}
|
|
|
|
local Track = {}
|
|
Track.__index = Track
|
|
|
|
local Clip = {}
|
|
Clip.__index = Clip
|
|
|
|
local FPS_MS = 60.0 / 1000.0 -- ms -> arcade frames
|
|
|
|
function M.frames(ms) return ms * FPS_MS end
|
|
function M.ms(frames) return frames / FPS_MS end
|
|
|
|
-- ROUND 53 (r53-anim-lumen-sweep): the module-level M.frames/M.ms assume 60 fps, which is
|
|
-- true of every clip exported so far -- but each generated table carries its OWN `fps`
|
|
-- field from the .nulm container, and R49's audit flagged that this file ignored it. A
|
|
-- clip whose container says anything other than 60 must be sampled through these
|
|
-- per-clip converters instead of M.frames/M.ms.
|
|
function Clip:frames(ms) return ms * (self.fps or 60) / 1000.0 end
|
|
function Clip:msof(frames) return frames * 1000.0 / (self.fps or 60) end
|
|
|
|
-- binary search: the last row whose frame is <= f
|
|
local function seek(rows, f)
|
|
local lo, hi = 1, #rows
|
|
if f <= rows[lo][1] then return lo end
|
|
if f >= rows[hi][1] then return hi end
|
|
while lo < hi do
|
|
local mid = math.floor((lo + hi + 1) / 2)
|
|
-- `hi = mid - 1`, NOT `hi = mid`: with hi == lo + 1 the upper-biased midpoint IS
|
|
-- hi, so `hi = mid` never shrinks the window and the loop spins forever (it hung
|
|
-- the check harness before this line was right; on the cabinet it would have
|
|
-- frozen the screen).
|
|
if rows[mid][1] <= f then lo = mid else hi = mid - 1 end
|
|
end
|
|
return lo
|
|
end
|
|
|
|
-- Lua 5.1 spells it `unpack`, 5.2+ `table.unpack`; the engine's sol2 build is 5.4 but the
|
|
-- fallback costs nothing and an `and/or` chain here would be wrong (a nil first field
|
|
-- would silently take the other branch).
|
|
local tunpack = table.unpack
|
|
if tunpack == nil then tunpack = unpack end
|
|
|
|
function Clip:at(name, f)
|
|
local rows = self.tracks[name]
|
|
if not rows or #rows == 0 then return nil end
|
|
local n = #rows
|
|
local i = seek(rows, f)
|
|
local a = rows[i]
|
|
if i >= n or f <= a[1] then return tunpack(a, 2, #a) end
|
|
local b = rows[i + 1]
|
|
local u = (f - a[1]) / (b[1] - a[1])
|
|
local out = {}
|
|
for k = 2, #a do out[k - 1] = a[k] + (b[k] - a[k]) * u end
|
|
return tunpack(out, 1, #out)
|
|
end
|
|
|
|
-- one named field of one track, by the name the exporter wrote into `fields`
|
|
function Clip:get(name, f, field)
|
|
local idx = self.index[field]
|
|
if not idx then return nil end
|
|
local rows = self.tracks[name]
|
|
if not rows or #rows == 0 then return nil end
|
|
local n = #rows
|
|
local i = seek(rows, f)
|
|
local a = rows[i]
|
|
if i >= n or f <= a[1] then return a[idx + 1] end
|
|
local b = rows[i + 1]
|
|
local u = (f - a[1]) / (b[1] - a[1])
|
|
return a[idx + 1] + (b[idx + 1] - a[idx + 1]) * u
|
|
end
|
|
|
|
function Clip:label(name) return self.labels and self.labels[name] end
|
|
function Clip:has(name) return self.tracks[name] ~= nil end
|
|
function Clip:first() return self.range[1] end
|
|
function Clip:last() return self.range[2] end
|
|
|
|
-- ROUND 8 — FAIL SOFT. A generated table is the churn-prone part of this pipeline: it is
|
|
-- rewritten by a script, it can be renamed, and a screen must never go down because one of
|
|
-- them is missing or malformed. `M.safe` therefore ALWAYS returns something with the Clip
|
|
-- interface: the real clip, or this NULL clip, whose readers return nil and whose range is
|
|
-- empty. Every call site in the skin already ends in `or <the previous constant>` (or
|
|
-- tests the first return for nil), so a null clip degrades to the pre-pipeline behaviour
|
|
-- rather than raising.
|
|
--
|
|
-- ALSO A TRAP WORTH KEEPING: the engine indexes skin scripts FLAT, by file STEM
|
|
-- (`src/libs/script.cpp` `index_scripts`), across every Scripts/ subfolder, first one wins,
|
|
-- and `anim/` sorts before every screen folder. A table named `anim/result_crown.lua`
|
|
-- therefore SHADOWED `result/result_crown.lua`, so `preload("ResultCrown", "result_crown")`
|
|
-- loaded the data table, the `ResultCrown` global was never defined, and every frame of the
|
|
-- result screen threw. A file in this directory MUST NOT share its stem with any screen
|
|
-- script in this skin or its parent -- that one is now `anim/crown_mc.lua`.
|
|
local NULL = {}
|
|
NULL.__index = NULL
|
|
function NULL:at() return nil end
|
|
function NULL:get() return nil end
|
|
function NULL:label() return nil end
|
|
function NULL:has() return false end
|
|
function NULL:first() return 0 end
|
|
function NULL:last() return 0 end
|
|
function NULL:frames(ms) return ms * FPS_MS end -- ROUND 53: keep NULL's interface complete
|
|
function NULL:msof(frames) return frames / FPS_MS end
|
|
M.NULL = setmetatable({}, NULL)
|
|
|
|
-- never raises: returns the clip, or the NULL clip if the table is missing or malformed
|
|
function M.safe(path)
|
|
local ok, clip = pcall(M.load, path)
|
|
if ok and clip and clip.tracks and clip.fields and clip.range then return clip end
|
|
return M.NULL
|
|
end
|
|
|
|
local cache = {}
|
|
|
|
-- `path` is a require() path, e.g. "anim/folder_board".
|
|
function M.load(path)
|
|
local c = cache[path]
|
|
if c then return c end
|
|
local data = require(path)
|
|
data.index = {}
|
|
for i, name in ipairs(data.fields) do data.index[name] = i end
|
|
setmetatable(data, Clip)
|
|
cache[path] = data
|
|
return data
|
|
end
|
|
|
|
return M
|