Files
PyTaikoGreen/Scripts/background/bg_objects/dancer.lua
T
2026-10-10 14:52:27 -04:00

318 lines
12 KiB
Lua

local Timing = require("background/bg_objects/timing")
local pose_at = Timing.pose_at
-- BaseDancer: a background dancer. It enters (from its N_start frames), dances a
-- looping routine (N_loop) in step with the rest of its group, and leaves (N_end) when
-- the gauge drops. Each part is a pose table (see timing.lua), timed in beats:
-- at: { x, y } where the routine stands; poses are offsets from the origin, x drawn relative to it
-- enter: { beats = length, poses... } or { beats = length, layer, layer, ... }
-- dance: a pose table (usually Timing.steps of phrases), or a list of layers
-- leave: like enter (optional: without it the dancer just disappears)
-- a part can carry `move`: a pose table whose x, y is added to all its layers, for a path
-- that glides across frame changes (spinning while hopping in)
-- origin: { x, y } where the dancer's origin (its anchor point) sits in its images (plus `loop = { x, y }` etc.
-- for keys whose images differ). Texture.json puts that
-- point at the dancer's place on screen; poses move it, and it's the pivot for
-- scaling and turning.
-- A layer is a pose table drawn from `key` ("start", "loop", "end" or "_name" for this
-- dancer's own textures, else a shared key such as "ink_l"), with its own `origin` if it differs
-- and a `blend` (e.g. "additive") its poses can turn off with blend = false.
-- Dancer.define(specs) makes a class for BaseDancerGroup; specs[index] or specs.default.
local BaseDancer = {}
BaseDancer.__index = BaseDancer
local DEFAULT_KEY = { enter = "start", dance = "loop", leave = "end" }
-- a part is either one layer (a pose table) or a list of layers
-- a part's `beats` ends its layers, so a last pose's `to` glide finishes on time
local function layers(part)
if not part then return {} end
local list = (type(part[1]) == "table" and type(part[1][1]) == "number") and { part } or part
for _, layer in ipairs(list) do layer.length = layer.length or part.beats end
return list
end
function BaseDancer.new(cls, name, index, bpm, path)
local self = setmetatable({}, cls)
self.name = name
self.index = index
self.path = path
self.spec = cls.specs[index] or cls.specs.default
if self.spec.plain then -- not ported yet: step through the loop frames every half beat
local info = tex.get_texture_info(name, index .. "_loop")
local frames = {}
for f = 0, (info and info.frame_count or 1) - 1 do frames[#frames + 1] = { f, 0.5 } end
self.spec = { enter = { beats = 0 }, dance = Timing.steps(frames) }
end
self.state = nil -- nil (hidden), "enter", "dance", "leave"
self.beat = 60000 / bpm
self.ids = {}
self.parts = {
enter = layers(self.spec.enter),
dance = layers(self.spec.dance),
leave = layers(self.spec.leave),
}
return self
end
function BaseDancer:texture(key)
if key == "start" or key == "loop" or key == "end" then key = self.index .. "_" .. key end
if key:sub(1, 1) == "_" then key = self.index .. key end -- this dancer's own, e.g. "_poof"
if not self.ids[key] then
self.ids[key] = { id = tex.get_texture(self.path .. "/" .. self.name .. "/" .. key) or 0,
info = tex.get_texture_info(self.name, key) }
end
return self.ids[key]
end
function BaseDancer:play(state, now)
self.state, self.t0 = state, now
end
function BaseDancer:start() self.start_now = true end
function BaseDancer:leave()
if self.spec.leave then self.leave_now = true else self.state = nil end
end
function BaseDancer:update(current_time_ms, bpm)
local now = current_time_ms
if bpm then
local beat = 60000 / bpm
if self.t0 and beat ~= self.beat then -- keep our place in the part at the new tempo
self.t0 = now - (now - self.t0) / self.beat * beat
end
self.beat = beat
end
if self.start_now then self.start_now = nil; self:play("enter", now) end
if self.leave_now then self.leave_now = nil; self:play("leave", now) end
self.now = now
if not self.state then return end
local t = (now - self.t0) / self.beat
if self.state == "enter" and t >= self.spec.enter.beats then
self:play("dance", now)
elseif self.state == "leave" and t >= self.spec.leave.beats then
self.state = nil
end
end
-- beats into the current part; the routine follows the group's clock
function BaseDancer:part_time()
if self.state == "dance" then
local t = self.group and self.group:dance_clock(self.now, self.beat) or (self.now - self.t0) / self.beat
local period = self.parts.dance[1].length or self.spec.dance.beats
return t % period
end
return (self.now - self.t0) / self.beat
end
local NO_AT, NO_ORIGIN = { 0, 0 }, { 0, 0 }
-- where the dancer's origin sits in `key`'s images: spec.origin, or its entry for that key
function BaseDancer:origin(key)
local o = self.spec.origin or NO_ORIGIN
return o[key] or o
end
-- draw_texture reads these straight away, so every dancer shares one pair
local PARAMS, ORIGIN = {}, { 0, 0 }
-- x: where the group puts this dancer's routine (left edge of its loop texture)
function BaseDancer:draw(x)
if not self.state then return end
local t, at = self:part_time(), self.spec.at or NO_AT
local move = self.spec[self.state].move and pose_at(self.spec[self.state].move, t)
local mx, my = move and move.x or 0, move and move.y or 0
for _, layer in ipairs(self.parts[self.state]) do
local pose = pose_at(layer, t)
if pose then
local tx = self:texture(pose.key or layer.key or DEFAULT_KEY[self.state])
local info = tx.info
if info and info.width then
local w, h = info.width, info.height
local W, H = w * math.abs(pose.sx), h * pose.sy
local o = layer.origin or self:origin(pose.key or layer.key or DEFAULT_KEY[self.state])
ORIGIN[1], ORIGIN[2] = o[1] * math.abs(pose.sx), o[2] * pose.sy
PARAMS.frame = pose.frame
PARAMS.x = x + self:origin("loop")[1] + (pose.x or at[1]) - at[1] + mx
PARAMS.y = (pose.y or at[2]) + my
PARAMS.x2 = W - w
PARAMS.y2 = H - h
PARAMS.origin = ORIGIN
PARAMS.rotation = pose.rot
PARAMS.mirror = (pose.mirror or pose.sx < 0) and "horizontal" or nil
PARAMS.fade = pose.alpha
PARAMS.blend = (pose.blend == nil and layer.blend or pose.blend) or nil
PARAMS.color = nil -- donbg's dark wrapper may have set one
tex.draw_texture(tx.id, PARAMS)
end
end
end
end
-- A dancer class for BaseDancerGroup from specs (by dancer index, or `default`)
local function define(specs)
local cls = setmetatable({ specs = specs }, { __index = BaseDancer })
cls.__index = cls
cls.new = function(...) return BaseDancer.new(cls, ...) end
return cls
end
local PLAIN = define({ default = { plain = true } })
-- BaseDancerGroup: manages a pool of dancers spread across the screen
local BaseDancerGroup = {}
BaseDancerGroup.__index = BaseDancerGroup
local function get_variant_count(dancer_name)
local keys = tex.get_texture_keys(dancer_name)
if not keys then return 1 end
local variants = {}
for _, key in ipairs(keys) do
local num = tonumber(key:match("^(%d+)"))
if num then variants[num] = true end
end
local count = 0
for _ in pairs(variants) do count = count + 1 end
return math.max(1, count)
end
-- dancer_class: a class from Dancer.define; collabs not ported yet get the plain dancer
function BaseDancerGroup.new(index, bpm, max_dancers, path, dancer_class)
local self = setmetatable({}, BaseDancerGroup)
dancer_class = dancer_class or PLAIN
self.name = "dancer_" .. index
self.active_count = 0
self.path = path or "background"
tex.load_folder(self.path, "dancer/" .. self.name)
-- center(2), left(1), right(3), far-left(0), far-right(4) — 0-indexed
self.spawn_positions = { 2, 1, 3, 0, 4 }
self.active_dancers = {}
self.max_dancers = max_dancers
local variant_count = get_variant_count(self.name)
self.dancers = {}
for i = 1, max_dancers do
local variant_idx = (i - 1) % variant_count
local dancer = dancer_class.new(self.name, variant_idx, bpm, self.path)
dancer.group = self
table.insert(self.dancers, dancer)
end
-- Shuffle dancers
for i = #self.dancers, 2, -1 do
local j = math.random(1, i)
self.dancers[i], self.dancers[j] = self.dancers[j], self.dancers[i]
end
self.needs_initial_dancer = true
return self
end
function BaseDancerGroup:add_dancer()
if self.active_count < #self.dancers and self.active_count < #self.spawn_positions then
self.active_count = self.active_count + 1
local position = self.spawn_positions[self.active_count] + 1 -- convert to 1-indexed
local dancer = self.dancers[self.active_count]
self.active_dancers[position] = dancer
dancer:start()
end
end
function BaseDancerGroup:remove_dancer()
if self.active_count > 1 then
local position = self.spawn_positions[self.active_count] + 1
self.active_dancers[position] = nil
self.active_count = self.active_count - 1
end
end
function BaseDancerGroup:update(current_time_ms, bpm)
if self.needs_initial_dancer then
self.needs_initial_dancer = false
self:add_dancer()
end
for _, dancer in ipairs(self.dancers) do
dancer:update(current_time_ms, bpm)
end
end
function BaseDancerGroup:draw()
local screen_width = tex.get_screen_width()
local num_dancers = self.max_dancers
local first_dancer = nil
for i = 1, num_dancers do
local d = self.active_dancers[i]
if d then
first_dancer = d; break
end
end
if not first_dancer then return end
local info = tex.get_texture_info(self.name, tostring(first_dancer.index) .. "_loop")
local dancer_width = info and info.width or 0
local available_space = screen_width - (num_dancers * dancer_width)
local spacing = available_space / (num_dancers + 1)
for i = 1, num_dancers do
local dancer = self.active_dancers[i]
if dancer then
local x_pos = math.floor(spacing + (i - 1) * (dancer_width + spacing))
dancer:draw(x_pos)
end
end
end
-- Draws dancers on fixed slot centres instead of even spacing
function BaseDancerGroup:draw_slots(centers, order)
local screen_scale = tex.get_screen_scale()
for _, i in ipairs(order) do
local dancer = self.active_dancers[i]
if dancer then
local info = tex.get_texture_info(self.name, tostring(dancer.index) .. "_loop")
dancer:draw(math.floor(centers[i] * screen_scale - info.width / 2))
end
end
end
-- Beats since the first dancer started dancing: the routine's shared clock, so dancers that
-- join later dance in step with the rest. Re-anchored when the bpm changes so it doesn't jump.
function BaseDancerGroup:dance_clock(now, beat)
local c = self.clock
if not c then
c = { t0 = now, beat = beat }
self.clock = c
elseif c.beat ~= beat then
c.t0, c.beat = now - (now - c.t0) / c.beat * beat, beat
end
return (now - c.t0) / beat
end
-- Like remove_dancer, but the dancer stays in its slot to play its exit
function BaseDancerGroup:leave_dancer()
if self.active_count > 1 then
self.active_dancers[self.spawn_positions[self.active_count] + 1]:leave()
self.active_count = self.active_count - 1
end
end
function BaseDancerGroup:destroy()
tex.unload_folder(self.path, "dancer/" .. self.name)
end
local SETS = require("background/bg_objects/dancer/sets")
local Dancer = {}
Dancer.Base = BaseDancer
Dancer.BaseDancerGroup = BaseDancerGroup
Dancer.define = define
Dancer.Plain = PLAIN
-- The default dancer sets (dancer_0..20), from the hand-written specs in dancer/sets.lua
function Dancer.create(index, bpm, path, max_dancers)
return BaseDancerGroup.new(index, bpm, max_dancers or 5, path, define(SETS[index]))
end
return Dancer