Files
YataiDONNijiiro/Scripts/song_select/song_select.lua
T
2026-09-30 15:29:22 -04:00

1995 lines
89 KiB
Lua

-- Arcade song-select board, folder and difficulty-select renderer. All positions are
-- 1080p pixels; box positions and engine animations are already in screen pixels.
local STATE_BROWSING = 0
local STATE_SONG_SELECTED = 1
local STATE_DIFF_SORTING = 2
local STATE_SEARCHING = 3
local STATE_DAN_SELECTED = 4
local DIFF_BACK, DIFF_MODIFIER, DIFF_NEIRO = -3, -2, -1
local DIFF_EASY, DIFF_NORMAL, DIFF_HARD, DIFF_ONI, DIFF_URA = 0, 1, 2, 3, 4
local CROWN_NONE, CROWN_CLEAR, CROWN_FC, CROWN_DFC = 0, 1, 2, 3
-- Arcade board geometry (960x352 board texture: top/bottom caps + centre slice).
local BOARD_W = 960
local CAP_H = 56
local CEN_SRC_H = 240
local CEN_CLOSED_H = 52
local CEN_OPEN_H = 240
local GLOW_CAP_H = 80 -- cursor glow 1024x400: 80 / 240 / 80
local GLOW_W = 1024
-- Event/search folders are matched by display name (the engine folds DEFAULT/RECOMMENDED/
-- FAVORITE/DAN/DIFFICULTY into genre frame 0).
local EVENT_FOLDER_NAMES = { ["ドドンと!新曲祭り!"] = true, ["New Songs"] = true, ["New"] = true,
["おすすめ曲"] = true, ["Recommended"] = true, ["推薦歌曲"] = true }
local SORT_FOLDER_NAMES = { ["曲検索"] = true, ["Search Song"] = true, ["搜尋歌曲"] = true,
["むずかしさで探す"] = true, ["Difficulty Sort"] = true, ["難易度排序"] = true,
["むずかしさからえらぶ"] = true, ["Diff Sort"] = true }
local FOLDER_FRAME_EVENT, FOLDER_FRAME_SORT, FOLDER_FRAME_DANI = 11, 12, 10
-- global/indicator/background is the arcade control_guide loop, baked every GUIDE_STEP frames.
local GUIDE_STEP = 2
local GUIDE_RIM_FRAMES = math.floor(420 / GUIDE_STEP)
SongSelect = {}
SongSelect.__index = SongSelect
-- tex.get_texture tries "<name>" then "<name>_<language>"; fall back to _en/_ja so we
-- never hand draw_texture a nil id.
local function get_any(subset, name)
return tex.get_texture("song_select/" .. subset .. "/" .. name)
or tex.get_texture("song_select/" .. subset .. "/" .. name .. "_en")
or tex.get_texture("song_select/" .. subset .. "/" .. name .. "_ja")
end
-- draw a w x h texture centred on (cx, cy)
local function draw_centered(id, cx, cy, w, h, extra)
extra = extra or {}
extra.x = cx - w / 2
extra.y = cy - h / 2
tex.draw_texture(id, extra)
end
-- Same, but subtracts the texture.json base offset the engine adds when it draws in 2P.
local function draw_centered_b(id, base, cx, cy, w, h, extra)
extra = extra or {}
extra.x = cx - w / 2 - base[1]
extra.y = cy - h / 2 - base[2]
tex.draw_texture(id, extra)
end
local SEL_BASE_BALLOON = { 683, 308 } -- 1p/2p_balloon(_half) = cursor bubble
-- Title centred on (bx, cy). max_w (optional): squeeze long text horizontally down to the
-- box width, never below min_scale (default 0.75). Destination x/y snap to whole pixels.
local function draw_title(text, bx, cy, fade, max_w, min_scale)
if not text then return end
local w = text.width
if max_w and w > max_w then
w = math.floor(math.max(max_w, w * (min_scale or 0.75)) + 0.5)
end
text:draw({ x = math.floor(bx - w / 2 + 0.5), y = math.floor(cy - text.height / 2 + 0.5),
x2 = w - text.width, fade = fade })
end
-- control-guide pill labels
local GUIDE_SELECT_CX, GUIDE_DECIDE_CX, GUIDE_LABEL_CY = 904, 1077, 980
local GUIDE_LABEL_SPACING = 2.0
local function draw_guide_label(t, cx, cy, fade)
if not t then return end
t:draw({ x = math.floor(cx - (t.width - GUIDE_LABEL_SPACING) / 2 + 0.5),
y = math.floor(cy - t.height / 2 + 0.5), fade = fade })
end
local SONG_TEXT_BOX_W = 860 -- main_song_board text_title / text_sub box width
local DS_TEXT_BOX_W = 1000 -- main_diff text_title_song / text_song_title_sub box width
-- OutlinedText multiplies the outline thickness by screen_scale, so divide the stage-px
-- border back out.
local function outline(border) return border / tex.get_screen_scale() end
local SONG_TITLE_OUTLINE = 5.0
local SONG_TITLE_SIZE = nil -- resolved once from skin_config song_box_name
local SONG_TITLE_CACHE_MAX = 128 -- bound the per-title texture cache
-- Board title/subtitle outline colour by genre frame (0..12).
local BOARD_OUTLINE = {
[0] = { 18, 72, 76, 255 },
[1] = { 18, 72, 76, 255 },
[2] = { 155, 24, 99, 255 },
[3] = { 78, 29, 118, 255 },
[4] = { 150, 31, 0, 255 },
[5] = { 101, 67, 42, 255 },
[6] = { 20, 79, 20, 255 },
[7] = { 165, 50, 0, 255 },
[8] = { 38, 52, 73, 255 },
[9] = { 18, 72, 76, 255 },
[10] = { 0, 25, 95, 255 },
[11] = { 100, 6, 22, 255 },
[12] = { 119, 26, 45, 255 },
}
local BOARD_OUTLINE_BACK = { 76, 50, 20, 255 }
local function board_outline(frame)
return BOARD_OUTLINE[frame or 0] or BOARD_OUTLINE[0]
end
function SongSelect.new()
local self = setmetatable({}, SongSelect)
tex.load_animations("song_select")
tex.load_folder("song_select", "global")
tex.load_folder("song_select", "box")
tex.load_folder("song_select", "yellow_box")
tex.load_folder("song_select", "diff_select")
self.text_fade_in = tex.get_animation(4)
self.text_fade_in:start()
-- global
self._id_footer = tex.get_texture("song_select/global/footer") or 0
self._id_song_select = get_any("global", "song_select") or 0 -- 352x80 header at (30,30)
self._id_difficulty_select = get_any("global", "difficulty_select") or 0
-- Arcade control_guide panel drawn here, under the header, from the global/indicator bake.
-- The engine's own Indicator is parked off-screen; the arcade hides it during course select.
self._id_guide_panel = tex.get_texture("global/indicator/background")
self._guide_t0 = tex.get_current_ms()
-- box
self._id_bar_genre = tex.get_texture("song_select/box/bar_genre") -- song boards, 960x352, frame = genre
self._id_bar_folder = tex.get_texture("song_select/box/folder_graphic") -- genre (folder) boards, same layout
self._id_bar_back = tex.get_texture("song_select/box/bar_genre_back") -- "back" board (brown)
self._id_glow = tex.get_texture("song_select/box/bar_genre_overlay") -- yellow cursor glow 1024x400
self._id_background = tex.get_texture("song_select/box/background") -- 2880x1080 scrolling bg, frame = genre
self._id_box_chara = tex.get_texture("song_select/box/box_chara") -- 1920x480 genre characters (L at 510, R at 1410)
self._id_pattern = tex.get_texture("song_select/box/genre_overlay_large") -- 960x120 event pattern strip
self._id_song_counter = tex.get_texture("song_select/yellow_box/song_count_back") -- 280x72 number_of_songs plate
-- yellow_box
self._id_crown_dfc = tex.get_texture("song_select/yellow_box/crown_dfc") -- 72x72, frame = diff
self._id_crown_fc = tex.get_texture("song_select/yellow_box/crown_fc")
self._id_crown_clear = tex.get_texture("song_select/yellow_box/crown_clear")
self._id_score_rank = tex.get_texture("song_select/yellow_box/score_rank") -- 72x72, frame = (rank-1)*5 + diff
self._id_plate = tex.get_texture("song_select/yellow_box/frame_score") -- 184x96 diff plate, frame = diff
self._id_level_number = tex.get_texture("song_select/yellow_box/level_number") -- 48x48, frame = diff*11 + (level-1), 11 = "10+"
self._id_star = tex.get_texture("song_select/yellow_box/star") -- 40x40 tinted star, frame = diff
self._id_branch = tex.get_texture("song_select/yellow_box/branch_indicator") -- 40x40
self._id_branch_diff = tex.get_texture("song_select/yellow_box/branch_indicator_diff") -- 40x40 (diff select)
self._id_new_icon = get_any("yellow_box", "ex_data_new_song") -- 120x96 NEW icon
-- diff_select
self._id_backboard = tex.get_texture("song_select/diff_select/difficulty_back") -- 1272x784, frame = genre
self._id_diff_board = tex.get_texture("song_select/diff_select/difficulty_bar") -- 200x360, frame = diff (4 = ura)
self._id_course_frame = tex.get_texture("song_select/diff_select/difficulty_select_bar") -- 248x408 yellow glow frame
self._id_course_frame2 = tex.get_texture("song_select/diff_select/difficulty_select_bar2") -- 248x408 outline variant
self._id_bubble_1p = tex.get_texture("song_select/diff_select/1p_balloon") -- 124x124 cursor bubble + 1P
self._id_bubble_2p = tex.get_texture("song_select/diff_select/2p_balloon")
self._id_btn_back = get_any("diff_select", "back") -- 168x168 (localised lookup)
self._id_btn_option = get_any("diff_select", "option")
local neiro_row = tex.get_skin_config("option_neiro_row")
self._neiro_in_options = (neiro_row ~= nil) and (neiro_row.x >= 1)
self._id_btn_glow = tex.get_texture("song_select/yellow_box/s_crown_clear") -- 168x168 button cursor plate
self._id_small_crown = tex.get_texture("song_select/diff_select/difficulty_crown") -- 40x40, frame = crown (0 none)
self._id_small_rank = tex.get_texture("song_select/yellow_box/s_crown_outline") -- 40x40, frame = rank-1
self._id_small_star = tex.get_texture("song_select/diff_select/difficulty_star") -- 40x40
self._id_dot_bar = tex.get_texture("song_select/diff_select/ura_oni_plate") -- 172x24 dotted level bar
self._id_dot_star = tex.get_texture("song_select/diff_select/disable") -- 24x24 star for the dots
self._id_guide_s = tex.get_texture("song_select/diff_select/diff_tower") -- 372x96 control_guide_s loop sheet
self._id_diff_mark = tex.get_texture("song_select/diff_select/diff_tower_shadow") -- 680x680 main_diff_mark 'wait', frame = course
-- main_diff_mark 'course_decide_start' opaque shapes (mark_course + white backing).
tex.load_folder("song_select", "diff_mark_anim")
self._id_mark, self._id_mark_w = {}, {}
for i = DIFF_EASY, DIFF_URA do
self._id_mark[i] = tex.get_texture("song_select/diff_mark_anim/mark_" .. i)
self._id_mark_w[i] = tex.get_texture("song_select/diff_mark_anim/white_" .. i)
end
-- oni<->ura switch cells (340x400, 45 cells); the engine's own ura_switch is transparent.
self._id_ura_to_ura = tex.get_texture("song_select/yellow_box/s_crown_dfc")
self._id_ura_to_oni = tex.get_texture("song_select/yellow_box/s_crown_fc")
self._neiro_label = nil
self._back_label = nil
self._count_cache = {}
self._folder_titles = {}
self._song_titles = {}
self._song_titles_n = 0
self._course_labels = {}
self._chip_labels = {}
self._mark_diff = {} -- per player: last cursor column for main_diff_mark
self._mark_decided = {} -- per player: { t0 = ms, course } once decided
self._ura_prev = nil -- box.is_ura seen last frame (toggle detection)
self._ura_anim = nil -- { t0 = ms, to_ura = bool } while the change plays
self._guide_labels = nil
self._mod_title = nil
self._mod_seen = {} -- per player: modifier_active() last frame
self._mod_t0 = {} -- per player: ms the panel appeared
self._mod_off = {} -- per player: panel y offset this frame (nil = no panel)
return self
end
function SongSelect:update(current_ms)
self.text_fade_in:update(current_ms)
end
function SongSelect:restart_text_fade()
self.text_fade_in:start()
end
function SongSelect:draw_footer()
end
-- 「M曲」: the blue right half of the number_of_songs plate (only when a limit is set).
local SONG_MAX_CX = 1824
function SongSelect:draw_song_max()
if not tex.song_limit then return end
local limit = tex.song_limit()
if not limit or limit <= 0 then return end
if self._song_max_n ~= limit then
local sc = tex.get_skin_config("song_num") or {}
self._song_max_y = sc.y or 252
self._song_max = text.create_raw_text((tex.skin_text("song_num_max"):gsub("{0}", tostring(limit))),
sc.font_size or 24, { 255, 255, 255, 255 }, { 0, 0, 0, 255 }, false, outline(3.5), 2.0)
self._song_max_n = limit
end
local t = self._song_max
if not t then return end
t:draw({ x = math.floor(SONG_MAX_CX - (t.width - 2.0) / 2 + 0.5),
y = math.floor(self._song_max_y + 0.5) })
end
-- 段位道場 shutter doors, drawn on the top-most skin slot.
local dan_doors_ok, dan_doors = pcall(require, "song_select/dan_doors")
function SongSelect:draw_top(dan_progress)
if not dan_doors_ok then return end
dan_doors.set_progress(dan_progress)
dan_doors.draw()
end
function SongSelect:draw_overlays(state)
self:draw_decided_bridge()
if dan_doors_ok then dan_doors.arm(state == STATE_DAN_SELECTED) end
-- number_of_songs plate, under the engine's own song_num text.
if self._id_song_counter then
tex.draw_texture(self._id_song_counter, { x = 1754 - 140, y = 270 - 36 })
end
self:draw_song_max()
local label_fade = self.text_fade_in.attribute
-- control_guide panel (browse only; fades with the header swap)
if self._id_guide_panel then
local panel_fade = (state == STATE_SONG_SELECTED) and (1 - label_fade) or 1.0
if panel_fade > 0 then
local elapsed = tex.get_current_ms() - self._guide_t0
local frame = math.floor(elapsed / 1000 * 60 / GUIDE_STEP) % GUIDE_RIM_FRAMES
tex.draw_texture(self._id_guide_panel, { frame = frame, x = 0, y = 0, fade = panel_fade })
end
end
if state == STATE_SONG_SELECTED then
-- course-select drum guide at (960,978)
if self._id_guide_s then
local frame = math.floor(tex.get_current_ms() / 1000 * 30) % 210
local g = self:guide_labels()
-- hand the draw to coin_overlay.lua so the pill sits above the credit text
local id, fade = self._id_guide_s, label_fade
YD_DRAW_ABOVE_CREDIT = function()
draw_centered(id, 960, 978, 372, 96, { frame = frame, fade = fade })
draw_guide_label(g.select, GUIDE_SELECT_CX, GUIDE_LABEL_CY, fade)
draw_guide_label(g.decide, GUIDE_DECIDE_CX, GUIDE_LABEL_CY, fade)
end
end
self:draw_modifier_title()
else
self._mod_seen = {}; self._mod_off = {}
end
end
-- 演奏オプション header title of the C++ option panel.
local MOD_SLIDE, MOD_SLIDE_MS, MOD_JSON_Y, MOD_HEADER_CY, MOD_CX = 548, 333.33, 1080, 49, 215
function SongSelect:draw_modifier_title()
if self._mod_title == nil then
self._mod_title = tex.get_skin_config("modifier_title")
and text.create_text("modifier_title", { 255, 255, 255, 255 }, { 0, 0, 0, 255 }, false, outline(4.0), 2.0) or false
end
if not self._mod_title then return end
local p2x = tex.get_skin_config("option_p2"); p2x = p2x and p2x.x or 1490
for p = 1, 2 do
local off = self._mod_off[p]
if off then
draw_title(self._mod_title, MOD_CX + (p - 1) * p2x, MOD_JSON_Y + MOD_HEADER_CY + off, 1.0)
end
end
end
--------------------------------------------------------------------------------
-- 演奏オプション / 音色 board — replaces ModifierSelector::draw() / NeiroSelector::draw().
-- Same texture.json bases and skin_config keys the C++ board used, so geometry is exact.
--------------------------------------------------------------------------------
local OPT_CURSOR_MS = 1000 -- cursor loop = 60 frames @ 60 fps
local OPT_CURSOR_MIN = 51 / 256 -- its alpha floor
local OPT_GREY = { 166, 168, 171, 255 }
local OPT_CHANGED = { 255, 255, 0, 255 }
local OPT_SCRIM = { 0, 0, 0, 128 }
local OPT_ARROW_NUDGE = 5
function SongSelect:_opt_ids()
if self._opt_ids_t then return self._opt_ids_t end
-- Must load these subsets here: script_manager.tex is its own TextureWrapper, so a
-- subset the engine loaded elsewhere is not resident in the Lua one.
tex.load_folder("song_select", "modifier")
tex.load_folder("song_select", "neiro")
local t = {}
t.top = get_any("modifier", "top")
t.bottom = get_any("modifier", "bottom")
t.bg = get_any("modifier", "background")
t.row = get_any("modifier", "mod_bg")
t.row_hl = get_any("modifier", "mod_bg_highlight")
t.box = get_any("modifier", "mod_box")
t.arrow = get_any("modifier", "blue_arrow")
t.p = { get_any("modifier", "1p"), get_any("modifier", "2p") }
t.icon = {
auto = get_any("modifier", "mod_auto"),
speed = get_any("modifier", "mod_baisaku"),
sanbai = get_any("modifier", "mod_sanbai"),
yonbai = get_any("modifier", "mod_yonbai"),
speed_by_value = {
[11] = get_any("modifier", "mod_speed_x1_1"), [12] = get_any("modifier", "mod_speed_x1_2"),
[13] = get_any("modifier", "mod_speed_x1_3"), [14] = get_any("modifier", "mod_speed_x1_4"),
[15] = get_any("modifier", "mod_speed_x1_5"), [16] = get_any("modifier", "mod_speed_x1_6"),
[17] = get_any("modifier", "mod_speed_x1_7"), [18] = get_any("modifier", "mod_speed_x1_8"),
[19] = get_any("modifier", "mod_speed_x1_9"), [20] = get_any("modifier", "mod_speed_x2"),
[25] = get_any("modifier", "mod_speed_x2_5"), [30] = get_any("modifier", "mod_speed_x3"),
[35] = get_any("modifier", "mod_speed_x3_5"), [40] = get_any("modifier", "mod_speed_x4"),
},
display = get_any("modifier", "mod_doron"),
inverse = get_any("modifier", "mod_abekobe"),
kimagure = get_any("modifier", "mod_kimagure"),
detarame = get_any("modifier", "mod_detarame"),
}
self._opt_ids_t = t
return t
end
-- Row label / value strings as OutlinedText, cached by content.
function SongSelect:_opt_text(s)
if s == nil or s == "" then return nil end
if not self._opt_texts then
self._opt_texts = {}
local cfg = tex.get_skin_config("modifier_text")
self._opt_font = (cfg and cfg.font_size ~= 0 and cfg.font_size) or 26
end
local t = self._opt_texts[s]
if t == nil then
t = text.create_raw_text(s, self._opt_font, { 255, 255, 255, 255 }, { 0, 0, 0, 255 }, false, outline(3.0), 2.0) or false
self._opt_texts[s] = t
end
return t or nil
end
-- Arcade cursor pulse, sampled from anim/option_cursor; hand triangle is the fallback.
local option_cursor_ok, option_cursor_anim = pcall(require, "anim/sampler")
local OPT_CURSOR_CLIP = (option_cursor_ok and option_cursor_anim.safe("anim/option_cursor"))
or { get = function() return nil end, first = function() return 0 end,
last = function() return 0 end }
local OPT_CURSOR_LEN = math.max(1, OPT_CURSOR_CLIP:last() - OPT_CURSOR_CLIP:first() + 1)
local function opt_cursor_alpha(ms)
local f = (ms * 0.06) % OPT_CURSOR_LEN
local a = OPT_CURSOR_CLIP:get("#3@0", f, "a")
if a then return a end
local u = (ms % OPT_CURSOR_MS) / OPT_CURSOR_MS
local tri = (u < 0.5) and (u * 2) or ((1 - u) * 2)
return 1.0 - (1.0 - OPT_CURSOR_MIN) * tri
end
-- One board. `rows` = {label, value, changed, greyed, name}; `cursor` = 1-based row (nil = none).
function SongSelect:draw_option_board(player, offset, rows, cursor, change)
local ids = self:_opt_ids()
if not ids.top or not ids.row or not ids.box then return end
local p = player:player_num()
local p2cfg = tex.get_skin_config("option_p2")
local x = (p - 1) * ((p2cfg and p2cfg.x) or 1490)
local pitch = tex.get_skin_config("modifier_offset")
pitch = (pitch and pitch.y ~= 0 and pitch.y) or 61
local tcfg = tex.get_skin_config("modifier_text")
local ocfg = tex.get_skin_config("modifier_offset_2")
local text_x, text_y = (tcfg and tcfg.x) or 300, (tcfg and tcfg.y) or 1178
local name_x = (ocfg and ocfg.x) or 44
local arrow_span = (ocfg and ocfg.y) or 140
local n = #rows
local ms = tex.get_current_ms()
tex.draw_texture(ids.top, { x = x, y = offset })
if ids.p[p] then tex.draw_texture(ids.p[p], { x = x, y = offset }) end
if ids.bottom then tex.draw_texture(ids.bottom, { x = x, y = offset + n * pitch }) end
for i = 1, n do
local row = rows[i]
local row_y = offset + (i - 1) * pitch
local is_cur = (cursor == i)
if ids.bg then tex.draw_texture(ids.bg, { x = x, y = row_y }) end
tex.draw_texture(ids.row, { x = x, y = row_y })
if row.greyed then
-- arcade "grayout": flat grey laid on top of the row plate
if ids.row_hl then tex.draw_texture(ids.row_hl, { x = x, y = row_y, color = OPT_GREY }) end
elseif is_cur and ids.row_hl then
tex.draw_texture(ids.row_hl, { x = x, y = row_y, fade = opt_cursor_alpha(ms) })
end
tex.draw_texture(ids.box, { x = x, y = row_y, color = row.changed and OPT_CHANGED or nil })
local label = self:_opt_text(row.label)
if label then label:draw({ x = name_x + x, y = text_y + row_y }) end
local value = self:_opt_text(row.value)
if value then
value:draw({ x = math.floor(text_x - value.width / 2 + 0.5) + x, y = text_y + row_y })
end
-- engine row icon; state is the raw value so it never depends on the localised string
local icon
local st = row.state or 0
if row.name == "speed" then
if st > 10 then
local best
for v, id in pairs(ids.icon.speed_by_value or {}) do
if id and v <= st and (not best or v > best) then best = v end
end
icon = best and ids.icon.speed_by_value[best] or nil
end
if not icon then
if st >= 40 then icon = ids.icon.yonbai
elseif st >= 30 then icon = ids.icon.sanbai
elseif st > 10 then icon = ids.icon.speed end
end
elseif row.name == "random" then
if st == 2 then icon = ids.icon.detarame
elseif st == 1 then icon = ids.icon.kimagure end
elseif row.changed then
icon = ids.icon[row.name]
end
if icon then tex.draw_texture(icon, { x = x, y = row_y }) end
if is_cur and ids.arrow then
local nl, nr = 0, 0
if change and change.active then
local amp = OPT_ARROW_NUDGE * math.max(0, math.min(1, change.fade))
if change.dir > 0 then nl = amp else nr = amp end
end
tex.draw_texture(ids.arrow, { x = x - nl, y = row_y })
tex.draw_texture(ids.arrow, { mirror = "horizontal", x = x + arrow_span + nr, y = row_y })
end
if row.greyed then tex.draw_texture(ids.row, { x = x, y = row_y, color = OPT_SCRIM }) end
end
end
-- Engine entry point. kind: 1 = 演奏オプション, 2 = the standalone 音色 panel.
function SongSelect:draw_option_panel(player, kind)
if kind == 1 then
local rows = player.modifier_rows and player:modifier_rows()
local off = player.modifier_offset and player:modifier_offset()
if not rows or not off then return end
local cursor = player.modifier_index and player:modifier_index() or nil
if cursor and (cursor < 1 or cursor > #rows) then cursor = nil end
if player.modifier_confirmed and player:modifier_confirmed() then cursor = nil end
self._mod_off[player:player_num()] = off
self:draw_option_board(player, off, rows, cursor,
player.modifier_change and player:modifier_change())
else
local names = player.neiro_names and player:neiro_names()
local off = player.neiro_offset and player:neiro_offset()
if not names or not off then return end
local idx = (player.neiro_index and player:neiro_index()) or 1
local rows = { { name = "neiro", state = idx - 1, value = names[idx] or "",
changed = (idx ~= 1), greyed = false } }
self:draw_option_board(player, off, rows, 1, nil)
end
end
--------------------------------------------------------------------------------
-- Scrolling genre backdrop (2880x1080 per genre)
--------------------------------------------------------------------------------
function SongSelect:draw_background(nav)
self._frame_no = (self._frame_no or 0) + 1
self:refresh_folder_context(nav)
local info = tex.get_texture_info("box", "background")
local width = info.width
local move = nav:background_move().attribute % width
local fade_change = nav:background_fade_change().attribute
local last_frame = nav:last_bg_genre_frame()
local curr_frame = nav:bg_genre_frame()
for i = 0, width * 2 - 1, width do
tex.draw_texture(self._id_background, { frame = last_frame, x = i - move })
tex.draw_texture(self._id_background, { frame = curr_frame, x = i - move, fade = 1 - fade_change })
end
end
--------------------------------------------------------------------------------
-- Board chrome: 3-slice (cap / centre / cap) from a 960x352 texture, centred on (bx,by).
-- sx = optional horizontal scale (the folder-ENTER furl/unfurl); a furled board is drawn
-- from flat column slices so its edges stay sharp.
--------------------------------------------------------------------------------
local function draw_board(bar_id, frame, bx, by, cen_h, fade, crop_center, sx)
local w = BOARD_W
if sx and sx ~= 1.0 then w = math.max(2, math.floor(BOARD_W * sx + 0.5)) end
local x = math.floor(bx - w / 2 + 0.5)
local top = math.floor(by - cen_h / 2 + 0.5) - CAP_H
local src_y, src_h = CAP_H, CEN_SRC_H
if crop_center ~= false and cen_h < CEN_SRC_H then
src_h = math.max(1, math.floor(cen_h + 0.5))
src_y = CAP_H + math.floor((CEN_SRC_H - src_h) / 2)
cen_h = src_h
end
local bands = { { top, 0, CAP_H, CAP_H }, { top + CAP_H, src_y, src_h, cen_h },
{ top + CAP_H + cen_h, CAP_H + CEN_SRC_H, CAP_H, CAP_H } }
if w == BOARD_W then
for _, b in ipairs(bands) do
tex.draw_texture(bar_id, { frame = frame, x = x, y = b[1], y2 = b[4] - 352, fade = fade,
src = { x = 0, y = b[2], width = BOARD_W, height = b[3] } })
end
return
end
local CORNER, BAND, PANEL = 40, 43, 794 -- source widths; 40+43+794+43+40 = 960
local cols
if w >= 2 * CORNER then
local corner = CORNER
local inner = w - 2 * corner
local band = math.floor(inner * BAND / (2 * BAND + PANEL) + 0.5)
local panel = inner - 2 * band
cols = {
{ 0, CORNER, x, corner },
{ CORNER + BAND / 2 - 4, 8, x + corner, band },
{ BOARD_W / 2 - 4, 8, x + corner + band, panel },
{ BOARD_W - CORNER - BAND / 2 - 4, 8, x + corner + band + panel, band },
{ BOARD_W - CORNER, CORNER, x + w - corner, corner },
}
else
-- narrower than both corners: draw the outline columns + a flat centre slice
local ol = math.max(2, math.min(8, math.floor(w / 6)))
local mid = math.max(0, w - 2 * ol)
cols = {
{ 13, 8, x, ol },
{ BOARD_W / 2 - 4, 8, x + ol, mid },
{ 940, 8, x + ol + mid, ol },
}
end
for _, b in ipairs(bands) do
for _, c in ipairs(cols) do
if c[4] > 0 then
tex.draw_texture(bar_id, { frame = frame, x = c[3], y = b[1], x2 = c[4] - BOARD_W, y2 = b[4] - 352,
fade = fade, src = { x = c[1], y = b[2], width = c[2], height = b[3] } })
end
end
end
end
-- Yellow cursor glow around the board.
function SongSelect:draw_glow(bx, by, cen_h, fade)
local x = math.floor(bx - GLOW_W / 2 + 0.5)
local top = math.floor(by - cen_h / 2 + 0.5) - CAP_H - 24
local src_h = math.max(1, math.min(240, math.floor(cen_h + 0.5)))
cen_h = src_h
tex.draw_texture(self._id_glow, { x = x, y = top, y2 = GLOW_CAP_H - 400, fade = fade,
src = { x = 0, y = 0, width = GLOW_W, height = GLOW_CAP_H } })
tex.draw_texture(self._id_glow, { x = x, y = top + GLOW_CAP_H, y2 = cen_h - 400, fade = fade,
src = { x = 0, y = GLOW_CAP_H + math.floor((240 - src_h) / 2), width = GLOW_W, height = src_h } })
tex.draw_texture(self._id_glow, { x = x, y = top + GLOW_CAP_H + cen_h, y2 = GLOW_CAP_H - 400, fade = fade,
src = { x = 0, y = GLOW_CAP_H + 240, width = GLOW_W, height = GLOW_CAP_H } })
end
local glow_ok, glow_anim = pcall(require, "anim/sampler")
if not glow_ok then glow_anim = { safe = function()
local n = {}
n.at = function() return nil end; n.get = function() return nil end
n.label = function() return nil end; n.has = function() return false end
n.first = function() return 0 end; n.last = function() return 0 end
return n
end } end
local GLOW_CLIP = glow_anim.safe("anim/cursor_glow")
local GLOW_LEN = math.max(1, GLOW_CLIP:last() - GLOW_CLIP:first() + 1) -- 60 frames = 1 s
-- song-board oni/ura chip cross-fade loop
local URA_LOOP = glow_anim.safe("anim/song_board_ura")
local URA_LOOP_F0 = URA_LOOP:first() -- label 'ura' = f10
local URA_LOOP_LEN = math.max(1, URA_LOOP:last() - URA_LOOP_F0 + 1) -- 180 frames = 3 s
local function glow_pulse()
local f = (tex.get_current_ms() * 0.06) % GLOW_LEN
return GLOW_CLIP:get("#12@0", f, "a") or 1.0
end
-- Arcade genre board open / close, sampled from anim/folder_board.
local folder_anim = glow_anim
local FOLDER_CLIP = folder_anim.safe("anim/folder_board")
local F_SELECT_ON = FOLDER_CLIP:label("select_on") or 5
local F_SELECT_OFF = FOLDER_CLIP:label("select_off") or 30
local FOLDER_CLOSE_END_F = 8 -- select_off + 8 = f38, the last frame that moves
local FOLDER_CLOSE_MS = FOLDER_CLOSE_END_F / 0.06
-- Duration the engine keeps the box "closed" between navigate() and expand_box().
local NAV_REOPEN_MS = 166
-- A transient navigation closure is < NAV_REOPEN_MS; anything longer resets the open timer.
local STALE_TIMER_MS = math.max(FOLDER_CLOSE_MS, NAV_REOPEN_MS) + 50
local CABINET_OPEN_WAIT_MS = 61 / 120 * 1000 -- = 508.33 ms
local EXTRA_OPEN_HOLD_MS = math.max(0, CABINET_OPEN_WAIT_MS - NAV_REOPEN_MS) -- = 342.33 ms
-- centre band scaleY at the two rest states -> 0..1 openness
local SY_CLOSED, SY_OPEN = 0.2168, 1.0
local function clamp01(v) return math.max(0.0, math.min(1.0, v)) end
-- Song board timeline, same labels as the genre board.
local SONG_CLIP = folder_anim.safe("anim/song_board")
local S_SELECT_ON = SONG_CLIP:label("select_on") or 5
local S_SELECT_OFF = SONG_CLIP:label("select_off") or 30
local SONG_CLOSE_END_F = 13
local SONG_CLOSE_MS = SONG_CLOSE_END_F / 0.06 -- = 216.7 ms
local SONG_STALE_MS = math.max(SONG_CLOSE_MS, NAV_REOPEN_MS) + 50
-- Folder enter/exit wheel choreography: slots sweep out along the tilted line, hold, then
-- fly back in (sampled from anim/wheel_decide).
local WHEEL_FLY = folder_anim.safe("anim/wheel_decide")
local FLY_END_F = WHEEL_FLY:label("return") or 150
local FLY_TRACKS_UP = { "kanban_genre_2", "kanban_genre_4", "kanban_genre_6", "kanban_genre_8" }
local FLY_TRACKS_DOWN = { "kanban_genre_3", "kanban_genre_5", "kanban_genre_7" }
local FLY_CENTER_Y, FLY_ROW_GAP, FLY_ROW_PITCH = 540, 120, 135
do -- wheel geometry is skin_config-driven; follow it if restated
local ok, pitch = pcall(tex.get_skin_config, "song_select_row_pitch")
if ok and pitch and pitch.x and pitch.x > 0 then
FLY_ROW_PITCH = pitch.x
if pitch.y and pitch.y > 0 then FLY_ROW_GAP = pitch.y end
end
end
-- env-gate reads (skin Lua runs without sol::lib::os)
local function read_env(name)
local ok, v = pcall(function() return tex.get_env(name) end)
if ok and v ~= nil and v ~= "" then return v end
return nil
end
local R56_DISABLED = read_env("YATAIDON_R56_DISABLE") ~= nil
local R63_DBG = read_env("YATAIDON_R63_DEBUG") ~= nil
local _r63_ms, _r63_seen, _r63_n = nil, nil, 0
-- A wheel animation-timer key must be unique per BOX. text_name is not (two charts of one
-- song share a title), so prefer box:path() and fall back to title+subtitle+ex_data.
local function box_id(box)
local ok, p = pcall(function() return box:path() end)
if ok and p and p ~= "" then return p end
local id = box.text_name or ""
local ok_s, sub = pcall(function() return box.text_subtitle end)
if ok_s and sub then id = id .. "\1" .. sub end
local ok_x, x = pcall(function() return box:ex_data_flag() end)
if ok_x and x then id = id .. "\1" .. tostring(x) end
return id
end
-- Folder enter/exit leg state (anim/genre_open), driven by the engine's wheel events.
local R61 = {
disabled = (read_env("YATAIDON_R85_DISABLE") or read_env("YATAIDON_R61_DISABLE")) ~= nil,
dbg = (read_env("YATAIDON_R85_DEBUG") or read_env("YATAIDON_R61_DEBUG")) ~= nil,
clip = nil,
t0 = nil, f0 = 0, kind = nil, swapped = false,
open_f0 = 45, open_swap = 114, open_end = 145,
close_f0 = 150, close_swap = 219, close_end = 250,
open_furl = 100, -- first frame the decided genre board furls shut
seq = nil,
have_events = false,
snap_once = false, snap_ms = nil,
nohold_once = false, nohold_ms = nil,
}
do
local ok, t = pcall(require, "anim/genre_open")
if ok and type(t) == "table" and type(t.tracks) == "table" then
R61.clip = t
for _, k in ipairs({ "open_f0", "open_swap", "open_end",
"close_f0", "close_swap", "close_end" }) do
if t[k] then R61[k] = t[k] end
end
local rows = t.tracks and t.tracks.genre_board_sx
if rows then
for i = 1, #rows do
if rows[i][2] < 0.999 then R61.open_furl = rows[i][1]; break end
end
end
end
end
-- Linear sampler over the generated {frame, v1, v2...} rows.
local function go_get(name, f, col)
if not R61.clip then return nil end
local rows = R61.clip.tracks[name]
if not rows or #rows == 0 then return nil end
col = col or 1
if f <= rows[1][1] then return rows[1][col + 1] end
if f >= rows[#rows][1] then return rows[#rows][col + 1] end
for i = 1, #rows - 1 do
local a, b = rows[i], rows[i + 1]
if f >= a[1] and f <= b[1] then
local span = b[1] - a[1]
if span <= 0 then return a[col + 1] end
local u = (f - a[1]) / span
return a[col + 1] + (b[col + 1] - a[col + 1]) * u
end
end
return rows[#rows][col + 1]
end
-- Current clip frame, clamped just short of the swap until the engine signals it.
local function leg_f()
if R61.disabled or not R61.clip or not R61.t0 then return nil end
local f = R61.f0 + (tex.get_current_ms() - R61.t0) * 0.06
if not R61.swapped then
-- hold until the swap: the open leg parks on the furl, the close leg one frame
-- short of its swap, so the board stays up as the loading state
local hold = (R61.kind == "close") and (R61.close_swap - 1) or (R61.open_furl - 1)
if f > hold then f = hold end
return f
end
local fin = (R61.kind == "close") and R61.close_end or R61.open_end
if f >= fin then R61.t0 = nil; R61.kind = nil; return nil end
return f
end
-- The engine's own box fade, ignored during the pre-swap half of the close leg.
local function box_fade(box)
if R61.kind == "close" and R61.t0 and not R61.swapped then return 1.0 end
return box.fade.attribute
end
-- Start / advance the leg. `begin` puts the clock on the label frame; `swap` re-anchors
-- it on the content-swap frame.
local function leg_event(id)
local now = tex.get_current_ms()
if id == 3 then -- OPEN_BEGIN
R61.t0, R61.f0, R61.kind, R61.swapped = now, R61.open_f0, "open", false
R61.dec_id, R61.dec_box = R61.last_open_id, R61.last_open_box
elseif id == 4 then -- OPEN_SWAP
local f = (R61.kind == "open" and R61.t0) and (R61.f0 + (now - R61.t0) * 0.06) or nil
if not f or f >= R61.open_furl - 1 then
R61.t0, R61.f0 = now, R61.open_furl
end
R61.kind, R61.swapped = "open", true
R61.snap_once, R61.snap_ms = true, now
elseif id == 5 then -- CLOSE_BEGIN
R61.t0, R61.f0, R61.kind, R61.swapped = now, R61.close_f0, "close", false
elseif id == 6 then -- CLOSE_SWAP
R61.t0, R61.f0, R61.kind, R61.swapped = now, R61.close_swap, "close", true
R61.snap_once, R61.snap_ms = true, now
elseif id == 1 or id == 7 then -- SCENE_ENTRY / COURSE_BACK
R61.nohold_once, R61.nohold_ms = true, now
end
-- id == 2 (CURSOR_MOVE) needs no flag.
if R61.dbg then
pcall(tex.log, string.format("[r85] event id=%d ms=%.1f", id, now))
end
end
local _fly_dbg = read_env("YATAIDON_R56_DEBUG") ~= nil
-- The wheel clip shares the board clips' labels, so one clock drives all three.
local function fly_offset(box)
if R56_DISABLED then return 0.0, 0.0 end
local f = leg_f()
if not f then return 0.0, 0.0 end
if R61.kind == "open" then
if f >= FLY_END_F then return 0.0, 0.0 end
elseif R61.kind == "close" then
if f >= (WHEEL_FLY:label("genre_deceide2") or 255) - 1 then return 0.0, 0.0 end
else
return 0.0, 0.0
end
local st = box:draw_state()
if st == "open" or st == "diff_select" then return 0.0, 0.0 end -- the decided board stays
local d = box:box_y() - FLY_CENTER_Y
local ring = math.floor((math.abs(d) - FLY_ROW_GAP) / FLY_ROW_PITCH + 0.5)
if ring < 1 then ring = 1 end
local track
if d < 0 then track = FLY_TRACKS_UP[math.min(ring, #FLY_TRACKS_UP)]
else track = FLY_TRACKS_DOWN[math.min(ring, #FLY_TRACKS_DOWN)] end
local x, y = WHEEL_FLY:get(track, f, "tx"), WHEEL_FLY:get(track, f, "ty")
if not x or not y then return 0.0, 0.0 end
local rx = WHEEL_FLY:get(track, FLY_END_F, "tx") or 0.0
local ry = WHEEL_FLY:get(track, FLY_END_F, "ty") or 0.0
if _fly_dbg then pcall(tex.log, string.format("[r56fly] f=%.1f %s dx=%.1f dy=%.1f", f, track, x - rx, y - ry)) end
return x - rx, y - ry
end
-- The decided genre board while the loader owns it, until it has furled.
function SongSelect:draw_decided_bridge()
if not R61.dec_box then return end
local lf = (R61.kind == "open") and leg_f() or nil
if lf and lf < R61.open_swap - 1 then
if self._dec_frame ~= self._frame_no then
pcall(self.draw_folder_open, self, R61.dec_box)
end
else
R61.dec_id, R61.dec_box = nil, nil
end
end
-- true while `box` is the genre board the player just decided on (open leg running)
local function is_decided(box)
return R61.kind == "open" and R61.t0 ~= nil and R61.dec_id ~= nil and box_id(box) == R61.dec_id
end
-- How a fresh open starts: "hold" (cursor move), "snap" (folder enter/exit), "nohold"
-- (scene entry / course back). One-shots are armed by the engine's real transitions.
local _diff_seen_ms = nil
local _last_box_draw_ms = nil
local OPEN_SNAP_TTL_MS = 2000.0
local OPEN_NOHOLD_TTL_MS = 3000.0
local function open_start_mode(now)
if R61.snap_once then
R61.snap_once = false
if R61.snap_ms and (now - R61.snap_ms) < OPEN_SNAP_TTL_MS then return "snap" end
end
if R61.nohold_once then
R61.nohold_once = false
if R61.nohold_ms and (now - R61.nohold_ms) < OPEN_NOHOLD_TTL_MS then return "nohold" end
end
-- Legacy fallback for a binary without the event channel.
if not R61.have_events and _diff_seen_ms and (now - _diff_seen_ms) < 300.0 then
return "nohold"
end
return "hold"
end
-- Shared wheel-panel open/close wall-clock timers. Keys are "<kind>\0<name>".
local wheel_open_start = {}
local wheel_open_mode = {}
local wheel_open_seen = {}
local wheel_close_at = {}
local wheel_close_start= {}
local wheel_last_draw = {}
local wheel_last_fresh_key, wheel_last_fresh_ms = nil, nil
-- Returns t in ms into the select_on growth window (0..233, clamped), or nil during the
-- 508 ms pre-growth hold (draw fully closed).
local function wheel_open_t(key)
local now = tex.get_current_ms()
wheel_close_start[key] = nil
wheel_open_seen[key] = now
wheel_close_at[key] = nil
-- off-screen for > NAV_REOPEN_MS with the animation finished -> reset the timer
local last = wheel_last_draw[key]
wheel_last_draw[key] = now
if wheel_open_start[key] and last and (now - last) > NAV_REOPEN_MS then
local hold = (wheel_open_mode[key] == "hold") and EXTRA_OPEN_HOLD_MS or 0.0
if (now - wheel_open_start[key]) >= (hold + 233.0) then
wheel_open_start[key] = nil
end
end
if not wheel_open_start[key] then
wheel_open_start[key] = now
wheel_open_mode[key] = open_start_mode(now)
wheel_last_fresh_key, wheel_last_fresh_ms = key, now
end
local mode = wheel_open_mode[key]
if mode == "snap" then return 233.0 end
local elapsed = now - wheel_open_start[key]
local hold = (mode == "hold") and EXTRA_OPEN_HOLD_MS or 0.0
if elapsed < hold then return nil end
return math.min(elapsed - hold, 233.0)
end
-- Closed-state bookkeeping + close animation. Returns e in ms into the select_off
-- timeline while closing, else nil (draw at rest).
local function wheel_close_step(key, close_ms, stale_ms)
local now = tex.get_current_ms()
if not wheel_close_start[key] then
wheel_close_start[key] = now
end
if wheel_open_start[key] and (now - wheel_close_start[key]) > stale_ms then
wheel_open_start[key] = nil
end
local seen = wheel_open_seen[key]
if seen and now - seen < 100 then -- was drawn open a frame ago -> start closing
wheel_close_at[key] = now
wheel_open_seen[key] = nil
end
local c = wheel_close_at[key]
if c then
local e = now - c
if e < close_ms then return e end
wheel_close_at[key] = nil
end
return nil
end
-- `f` is an absolute arcade frame on sprite 73's timeline.
-- pb = board progress 0..1, ch_alpha = character alpha, ch_off = character offset,
-- ta = intro ("N songs") text alpha.
local function folder_sample(f)
local sy = FOLDER_CLIP:get("instance_board_center", f, "sy") or SY_CLOSED
local pb = clamp01((sy - SY_CLOSED) / (SY_OPEN - SY_CLOSED))
local ch_alpha = FOLDER_CLIP:get("instance_character_l", f, "a") or 0.0
local ch_x = FOLDER_CLIP:get("instance_character_l", f, "tx") or -340.0
local ta = FOLDER_CLIP:get("text_kanban_intro_genre", f, "a") or 0.0
return pb, ch_alpha, -ch_x, ta
end
local function folder_open_timeline(t)
return folder_sample(F_SELECT_ON + t * 0.06)
end
local function folder_close_timeline(e)
return folder_sample(F_SELECT_OFF + e * 0.06)
end
-- Same reader for the song board: pb = board progress, ia = contents alpha, ic = NEW-icon alpha.
local function song_sample(f)
local sy = SONG_CLIP:get("instance_board_center", f, "sy") or SY_CLOSED
local pb = clamp01((sy - SY_CLOSED) / (SY_OPEN - SY_CLOSED))
local ia = SONG_CLIP:get("song_kanban_info", f, "a") or (pb >= 1.0 and 1.0 or 0.0)
local ic = SONG_CLIP:get("instance_icon", f, "a") or (1.0 - pb)
return pb, clamp01(ia), clamp01(ic)
end
local function song_open_timeline(t) return song_sample(S_SELECT_ON + t * 0.06) end
local function song_close_timeline(e) return song_sample(S_SELECT_OFF + e * 0.06) end
-- crown / rank badges (72x72) at arcade positions, one column per player.
local ICON_ARTS = nil -- name -> texture id (resolved lazily)
function SongSelect:song_icon(box)
if ICON_ARTS == nil then ICON_ARTS = { icon_new = self._id_new_icon } end
local list = {}
if box.is_new then list[#list + 1] = "icon_new" end
if #list == 0 then return nil end
local pick = list[(math.floor(tex.get_current_ms() / 1000.0) % #list) + 1]
return ICON_ARTS[pick]
end
function SongSelect:badge_diffs(box, pl)
local sel = self._mark_diff and self._mark_diff[pl]
local crown_d, rank_d
if sel and sel >= DIFF_EASY and sel <= DIFF_URA then
local info = box:course_info(sel)
if info.has_course then
if info.crown ~= CROWN_NONE then crown_d = sel end
if (info.rank or 0) > 0 then rank_d = sel end
end
end
if not crown_d or not rank_d then
local cbest, rbest
for d = DIFF_EASY, DIFF_URA do
local info = box:course_info(d)
if info.has_course then
if info.crown ~= CROWN_NONE then cbest = d end
if (info.rank or 0) > 0 then rbest = d end
end
end
crown_d = crown_d or cbest
rank_d = rank_d or rbest
end
return crown_d, rank_d
end
function SongSelect:draw_badges(box, bx, by, p, fade)
local scale = 0.75 + 0.25 * p
local size = 72 * scale
local players = math.min(2, tex.player_num())
for pl = 1, players do
local cx = bx + ((pl == 1) and -425 or 425)
local crown_d, rank_d = self:badge_diffs(box, pl)
if crown_d then
local info = box:course_info(crown_d)
local id = (info.crown == CROWN_DFC) and self._id_crown_dfc
or (info.crown == CROWN_FC) and self._id_crown_fc or self._id_crown_clear
local cy = by + (-27 + (-114 + 27) * p)
draw_centered(id, cx, cy, size, size, { frame = crown_d, x2 = size - 72, y2 = size - 72, fade = fade })
end
if rank_d then
local info = box:course_info(rank_d)
local cy = by + (27 + (-42 - 27) * p)
draw_centered(self._id_score_rank, cx, cy, size, size,
{ frame = (info.rank - 1) * 5 + rank_d, x2 = size - 72, y2 = size - 72, fade = fade })
end
end
end
--------------------------------------------------------------------------------
-- SongBox
--------------------------------------------------------------------------------
-- pb = board progress (0..1), ia = contents alpha, ic = NEW-icon alpha.
function SongSelect:draw_song_board(box, pb, ia, ic)
local bx, by = box:box_x(), box:box_y()
local fdx, fdy = fly_offset(box); bx, by = bx + fdx, by + fdy -- wheel fly-in
local fade = math.min(1.0, box_fade(box))
-- folder-ENTER/EXIT unfurl/furl: while the slab's sx < 1 only the slab is drawn,
-- and the contents alphas come from the clip's own channels.
local gof = leg_f()
local ltitle = 1.0
if R61.dbg and gof then
pcall(function()
tex.log(string.format("[r85] f=%.1f kind=%s swapped=%s state=%s box=%s", gof,
tostring(R61.kind), tostring(R61.swapped),
tostring(box:draw_state()), tostring(box.text_name)))
end)
end
if gof and R61.kind == "open" and gof < R61.open_swap - 1 and box:draw_state() == "open" then
return -- the new content's board does not exist until the swap frame
end
if gof and box:draw_state() == "open" then
local sx, ba = go_get("song_board_sx", gof, 1), go_get("song_board_sx", gof, 2)
if sx and ba then
if ba <= 0.0 then return end -- board not on screen on this frame
if sx < 1.0 then -- unfurl / furl: the slab alone
if R61.dbg then
pcall(tex.log, string.format(
"[r85s] ms=%.1f f=%.2f kind=%s sx=%.4f a=%.3f w=%.1f",
tex.get_current_ms(), gof, tostring(R61.kind), sx, ba,
BOARD_W * sx))
end
draw_board(self._id_bar_genre, box:genre_frame(), bx, by, CEN_OPEN_H,
fade * ba, nil, sx)
return
end
local ia2 = go_get("song_info_a", gof, 1)
if ia2 then ia = math.min(ia or 1.0, ia2) end
ltitle = go_get("song_title_a", gof, 1) or 1.0
local ic2 = go_get("song_icon_a", gof, 1)
if ic2 then ic = math.min(ic or 0.0, ic2) end
end
end
local cen_h = CEN_CLOSED_H + (CEN_OPEN_H - CEN_CLOSED_H) * pb
if pb > 0 then self:draw_glow(bx, by, cen_h, fade * pb * glow_pulse()) end
draw_board(self._id_bar_genre, box:genre_frame(), bx, by, cen_h, fade)
if pb <= 0.0 then
draw_title(self:song_title(box), bx, by, fade, SONG_TEXT_BOX_W, 0)
self:draw_badges(box, bx, by, 0.0, fade)
-- instance_icon hangs off the board's left edge (-532)
local icon = (ic > 0) and self:song_icon(box) or nil
if icon then
draw_centered(icon, bx - 532, by + 4, 120, 96, { fade = fade * ic })
end
return
end
local cfade = fade * ia
if cfade > 0 then
self:draw_plates(box, bx, by, cfade)
self:draw_badges(box, bx, by, pb, cfade)
end
-- title rises from the closed position (y=0) to the open one (y=-70) with the growth
draw_title(self:song_title(box), bx, by - 70 * pb, fade * ltitle, SONG_TEXT_BOX_W, 0)
local sub = self:song_subtitle(box)
if sub and cfade > 0 then draw_title(sub, bx, by - 28, cfade, SONG_TEXT_BOX_W) end
if tex.display_bpm() and cfade > 0 then
local bpm_cfg = tex.get_skin_config("song_box_bpm")
box:bpm_text():draw({ x = bx + bpm_cfg.x, y = by + bpm_cfg.y, fade = cfade })
end
local icon = (ic > 0) and self:song_icon(box) or nil
if icon then
draw_centered(icon, bx - 532, by + 4, 120, 96, { fade = fade * ic })
end
-- 新音 / 旧音 / 期間限定 marker, hung off the board's right edge (mirror of the icon)
local ok_exd, exd = pcall(function() return box:ex_data_flag() end)
if ok_exd and exd and exd > 0 and cfade > 0 then
local art = (exd == 1) and get_any("yellow_box", "ex_data_new_audio")
or (exd == 2) and get_any("yellow_box", "ex_data_old_audio")
or get_any("yellow_box", "ex_data_limited_time")
if art then
local s = tex.get_screen_scale()
draw_centered_b(art, { 458 * s, 120 * s }, bx + 532, by + 4,
224 * s, 64 * s, { fade = cfade })
end
end
if box.is_favorite and cfade > 0 then
local fav = get_any("yellow_box", "favorite_" .. tex.player_num() .. "p")
if fav then tex.draw_texture(fav, { x = bx - 480, y = by - 176, fade = cfade }) end
end
end
function SongSelect:draw_song_closed(box)
local key = "s\0" .. box_id(box) -- per-BOX, not per-title
local e = wheel_close_step(key, SONG_CLOSE_MS, SONG_STALE_MS)
if e then
self:draw_song_board(box, song_close_timeline(e))
return
end
self:draw_song_board(box, 0.0, 0.0, 1.0)
end
-- Diff plates row (song_kanban_info, board y +75): plate 184x96 with the course name
-- under the icon, a star and the level number.
function SongSelect:chip_label(diff)
local lbl = self._chip_labels[diff]
if lbl == nil then
local key = "chip_course_name_" .. diff
if tex.get_skin_config(key) then
lbl = text.create_text(key, { 255, 255, 255, 255 }, { 40, 20, 20, 255 }, false, outline(1.5), 1.0)
end
self._chip_labels[diff] = lbl or false
end
return lbl or nil
end
function SongSelect:draw_plates(box, bx, by, fade)
local has_ura = box:has_ura()
local ura_weight = 0.0
local oni_alpha = 1.0
if has_ura then
local f = URA_LOOP_F0 + (tex.get_current_ms() * 0.06) % URA_LOOP_LEN
ura_weight = URA_LOOP:get("ura/text_course_ura", f, "a") or 0.0
oni_alpha = URA_LOOP:get("oni/text_course_oni", f, "a") or 1.0
end
local py = by + 75
local function plate(cx, diff, f)
local info = box:course_info(diff)
if not info.has_course or f <= 0 then return end
draw_centered(self._id_plate, cx, py, 184, 96, { frame = diff, fade = f })
draw_centered(self._id_star, cx + 27, py, 40, 40, { frame = diff, fade = f })
local lv = math.max(1, math.min(11, info.level))
draw_centered(self._id_level_number, cx + 61, py, 48, 48, { frame = diff * 11 + (lv - 1), fade = f })
local name = self:chip_label(diff)
if name then draw_title(name, cx - 42, py + 30, f) end
if info.is_branching then
draw_centered(self._id_branch, cx - 69, py - 23, 40, 40, { fade = f })
end
end
-- charted columns: easy / normal / hard / (oni|ura)
local cols = {}
for d = 0, 2 do if box:course_info(d).has_course then cols[#cols + 1] = d end end
local has_oni = box:course_info(DIFF_ONI).has_course
if has_oni or has_ura then cols[#cols + 1] = DIFF_ONI end
local n = #cols
for i, d in ipairs(cols) do
local cx = bx + (i - 1 - (n - 1) / 2) * 182
if d == DIFF_ONI and has_ura then
if has_oni then
plate(cx, DIFF_ONI, fade * oni_alpha) -- oni below, ura on top
plate(cx, DIFF_URA, fade * ura_weight)
else
plate(cx, DIFF_URA, fade)
end
else
plate(cx, d, fade)
end
end
return ura_weight
end
function SongSelect:draw_song_open(box)
local key = "s\0" .. box_id(box)
local t = wheel_open_t(key)
if t == nil then -- 508 ms pre-growth hold: fully closed
self:draw_song_board(box, 0.0, 0.0, 1.0)
return
end
self:draw_song_board(box, song_open_timeline(t))
end
--------------------------------------------------------------------------------
-- Difficulty select
--------------------------------------------------------------------------------
local DS_SLOT_X = { [DIFF_BACK] = 440, [DIFF_MODIFIER] = 572, [DIFF_NEIRO] = 440,
[DIFF_EASY] = 745, [DIFF_NORMAL] = 960, [DIFF_HARD] = 1175, [DIFF_ONI] = 1390 }
local DS_BOARD_Y = 583
local DS_COURSE_NAME_DY = 49 -- course-name centre below the card origin
local URA_ANIM_MS = 1500 -- oni_main change_ura/change_oni: 90 frames @ 60 fps
local URA_SWAP_MS = 450 -- board_oni -> board_ura swap
local URA_CELLS = 90
local DS_BTN_Y = 462
local DS_CURSOR_Y = 370
local DS_NEIRO_Y = 602
-- main_diff_mark: 680x680, drawn under the buttons/cards.
local DS_MARK_X = { [1] = 200, [2] = 1720 }
local DS_MARK_Y = 408
-- main_diff_mark 'course_decide_start', one row per frame:
-- { mark alpha, mark y-scale, white alpha, white y-scale, white backing alpha }.
local DS_MARK_DECIDE = {
{ 0.5, 1, 0, 1, 0 },
{ 0.5938, 1.0447, 0.1914, 1.0447, 0.1016 },
{ 0.6797, 1.0847, 0.3594, 1.0847, 0.1992 },
{ 0.7539, 1.12, 0.5117, 1.12, 0.3008 },
{ 0.8203, 1.1506, 0.6406, 1.1506, 0.3984 },
{ 0.875, 1.1765, 0.75, 1.1765, 0.5 },
{ 0.9219, 1.1976, 0.8398, 1.1976, 0.6016 },
{ 0.9531, 1.2141, 0.9102, 1.2141, 0.6992 },
{ 0.9805, 1.2259, 0.9609, 1.2259, 0.8008 },
{ 0.9961, 1.2329, 0.9883, 1.2329, 0.8984 },
{ 1, 1.2353, 1, 1.2353, 1 },
{ 1, 1.2229, 0.9961, 1.2346, 1 },
{ 1, 1.2105, 0.9883, 1.2327, 1 },
{ 1, 1.1981, 0.9766, 1.2294, 1 },
{ 1, 1.1858, 0.957, 1.2248, 1 },
{ 1, 1.1733, 0.9297, 1.219, 1 },
{ 1, 1.161, 0.8984, 1.2118, 1 },
{ 1, 1.1486, 0.8633, 1.2033, 1 },
{ 1, 1.1362, 0.8242, 1.1936, 1 },
{ 1, 1.1238, 0.7773, 1.1825, 1 },
{ 1, 1.1114, 0.7227, 1.1701, 1 },
{ 1, 1.0991, 0.6641, 1.1564, 1 },
{ 1, 1.0867, 0.6016, 1.1414, 1 },
{ 1, 1.0743, 0.5312, 1.1251, 1 },
{ 1, 1.0619, 0.457, 1.1075, 1 },
{ 1, 1.0495, 0.375, 1.0886, 1 },
{ 1, 1.0371, 0.2891, 1.0684, 1 },
{ 1, 1.0247, 0.1992, 1.0469, 1 },
{ 1, 1.0124, 0.1016, 1.0241, 1 },
{ 1, 1, 0, 1, 1 },
}
local DS_MARK_DECIDE_MS = 1000 / 60
function SongSelect:draw_diff_mark_decided(p, dec, fade)
local f = math.floor((tex.get_current_ms() - dec.t0) / DS_MARK_DECIDE_MS) + 1
local k = DS_MARK_DECIDE[math.min(f, #DS_MARK_DECIDE)]
local id, id_w = self._id_mark[dec.course], self._id_mark_w[dec.course]
local cx, cy = DS_MARK_X[p], DS_MARK_Y
local function stretched(tid, a, sy)
if a <= 0 then return end
tex.draw_texture(tid, { x = cx, y = cy, y2 = 680 * (sy - 1), origin = { 340, 340 * sy }, fade = a * fade })
end
stretched(id_w, k[5], 1) -- mark_course_white_2: backing
stretched(id, k[1], k[2]) -- mark_course
stretched(id_w, k[3], k[4]) -- mark_course_white: the flash on top
end
function SongSelect:draw_song_diff_select_bg(box)
local fade = box.diff_fade_in.attribute
for p = 1, 2 do
local d = self._mark_diff[p]
local dec = self._mark_decided[p]
if dec and self._id_mark[dec.course] then
self:draw_diff_mark_decided(p, dec, fade)
elseif self._id_diff_mark and d and d >= DIFF_EASY and DS_MARK_X[p] then
draw_centered(self._id_diff_mark, DS_MARK_X[p], DS_MARK_Y, 680, 680, { frame = d, fade = fade })
end
end
tex.draw_texture(self._id_backboard, { frame = box:genre_frame(), x = 960 - 636, y = 440 - 392, fade = fade })
end
function SongSelect:draw_song_diff_select(box)
_diff_seen_ms = tex.get_current_ms()
local fade = box.diff_fade_in.attribute
local is_ura = box.is_ura
local side = (tex.player_num() == 2) and 1 or -1
local now = tex.get_current_ms()
-- oni<->ura toggle detection
if fade < 1.0 then
self._ura_prev = is_ura; self._ura_anim = nil
elseif self._ura_prev ~= nil and is_ura ~= self._ura_prev then
self._ura_anim = { t0 = now, to_ura = is_ura }
end
self._ura_prev = is_ura
local ura_elapsed = self._ura_anim and (now - self._ura_anim.t0) or nil
if ura_elapsed and ura_elapsed >= URA_ANIM_MS then self._ura_anim = nil; ura_elapsed = nil end
local ura_mid = (not ura_elapsed) or ura_elapsed >= URA_SWAP_MS
-- what the oni slot shows: before the swap point the previous course, after it the new one
local oni_shown = is_ura
if ura_elapsed and not ura_mid then oni_shown = not is_ura end
-- buttons: return + a small option wrench (the engine cursor can still reach MODIFIER)
draw_centered(self._id_btn_back, DS_SLOT_X[DIFF_BACK], DS_BTN_Y, 128, 128, { fade = fade })
draw_centered(self._id_btn_option, DS_SLOT_X[DIFF_MODIFIER], DS_BTN_Y, 128, 128, { fade = fade })
-- diff boards
for i = 0, 3 do
local cx = DS_SLOT_X[i]
local diff = (i == DIFF_ONI and oni_shown) and DIFF_URA or i
local info = box:course_info(diff)
local f = info.has_course and fade or math.min(fade, 0.4)
draw_centered(self._id_diff_board, cx, DS_BOARD_Y, 200, 360, { frame = diff, fade = f })
if info.has_course and not (i == DIFF_ONI and ura_elapsed) then
local crown = info.crown or 0
draw_centered(self._id_small_crown, cx + side * 58, DS_BOARD_Y - 138, 40, 40, { frame = crown, fade = f })
if (info.rank or 0) > 0 then
draw_centered(self._id_small_rank, cx + side * 20, DS_BOARD_Y - 138, 40, 40, { frame = info.rank - 1, fade = f })
end
local lv = math.max(1, math.min(11, info.level))
tex.draw_texture(self._id_small_star, { frame = 0, x = cx - 40, y = DS_BOARD_Y + 66, fade = f })
tex.draw_texture(self._id_small_star, { frame = lv, x = cx - 4, y = DS_BOARD_Y + 68, fade = f })
draw_centered(self._id_dot_bar, cx, DS_BOARD_Y + 116, 172, 24, { fade = f })
for k = 0, math.min(10, info.level) - 1 do
draw_centered(self._id_dot_star, cx - 67 + k * 15, DS_BOARD_Y + 116, 24, 24, { fade = f })
end
if info.is_branching then
draw_centered(self._id_branch_diff, cx, DS_BOARD_Y + 144, 40, 40, { fade = f })
end
end
end
-- change animation cells over the oni slot
if ura_elapsed then
local id = self._ura_anim.to_ura and self._id_ura_to_ura or self._id_ura_to_oni
if id then
local cell = math.min(URA_CELLS - 1, math.floor(ura_elapsed / URA_ANIM_MS * URA_CELLS))
tex.draw_texture(id, { frame = cell, x = DS_SLOT_X[DIFF_ONI] - 170, y = DS_BOARD_Y - 210, fade = fade })
end
end
for i = 0, 3 do
local diff = (i == DIFF_ONI and oni_shown) and DIFF_URA or i
local info = box:course_info(diff)
if info.has_course then
local lbl = self:course_label(diff)
if lbl then draw_title(lbl, DS_SLOT_X[i], DS_BOARD_Y + DS_COURSE_NAME_DY, fade) end
end
end
-- title / subtitle inside the backboard header
draw_title(self:header_title(box), 960, 178, fade, DS_TEXT_BOX_W, 0)
draw_title(self:header_subtitle(box), 960, 242, fade, DS_TEXT_BOX_W, 0)
end
function SongSelect:song_subtitle(box)
local str = box.text_subtitle
if not str or str == "" then return nil end
local gf = self._inside_frame or box:genre_frame()
local key = gf .. "\0" .. str
if self._sub_key ~= key then
self._sub_key = key
self._sub_label = text.create_raw_text(str, 24, { 255, 255, 255, 255 }, board_outline(gf), false, outline(3.5), 2.0)
end
return self._sub_label
end
-- Song board title, arcade-exact: font from skin_config song_box_name, black outline,
-- cached per title (the cache is dropped wholesale past SONG_TITLE_CACHE_MAX).
function SongSelect:song_title(box)
local name = box.text_name
if not name or name == "" then return box:name() end
local gf = self._inside_frame or box:genre_frame()
local key = gf .. "\0" .. name -- the outline follows the genre, so it keys the cache
local t = self._song_titles[key]
if t == nil then
if SONG_TITLE_SIZE == nil then
local cfg = tex.get_skin_config("song_box_name")
SONG_TITLE_SIZE = (cfg and cfg.font_size and cfg.font_size > 0) and cfg.font_size or 42
end
if self._song_titles_n >= SONG_TITLE_CACHE_MAX then
self._song_titles = {}
self._song_titles_n = 0
end
t = text.create_raw_text(name, SONG_TITLE_SIZE, { 255, 255, 255, 255 }, board_outline(gf),
false, outline(SONG_TITLE_OUTLINE), 2.0)
self._song_titles[key] = t or false
self._song_titles_n = self._song_titles_n + 1
end
return t or box:name()
end
function SongSelect:header_title(box)
local name = box.text_name or ""
if self._header_title_key ~= name then
self._header_title_key = name
self._header_title = text.create_raw_text(name, 48, { 255, 255, 255, 255 }, { 0, 0, 0, 255 }, false, outline(7.0), 2.0)
end
return self._header_title
end
function SongSelect:header_subtitle(box)
local sub = box.text_subtitle
if sub == nil then return box:subtitle_large() end -- engine without the binding
if sub == "" then return nil end
if self._header_sub_key ~= sub then
self._header_sub_key = sub
self._header_sub = text.create_raw_text(sub, 30, { 255, 255, 255, 255 }, { 0, 0, 0, 255 }, false, outline(4.0), 2.0)
end
return self._header_sub
end
function SongSelect:course_label(diff)
local lbl = self._course_labels[diff]
if lbl == nil then
local key = "course_name_" .. diff
if tex.get_skin_config(key) then
lbl = text.create_text(key, { 255, 255, 255, 255 }, { 20, 20, 20, 255 }, false, outline(4.5), 1.0)
end
self._course_labels[diff] = lbl or false
end
return lbl or nil
end
function SongSelect:guide_labels()
if not self._guide_labels then
local function mk(key)
if tex.get_skin_config(key) then
return text.create_text(key, { 40, 40, 40, 255 }, { 0, 0, 0, 0 }, false, 0, 2.0)
end
end
self._guide_labels = { select = mk("guide_select_label"), decide = mk("guide_decide_label") }
end
return self._guide_labels
end
-- Cursor bubble + yellow course frame / button glow, drawn in two passes (under/over the wheel).
function SongSelect:draw_selector(player, is_half, fade_in, pass)
local under = (pass == nil) or (pass == 0)
local over = (pass == nil) or (pass == 1)
local diff = player:selected_difficulty()
local p = player:player_num()
if under then
self._mark_diff[p] = diff
local decided = diff >= DIFF_EASY and player.difficulty_decided and player:difficulty_decided()
if decided then
if not self._mark_decided[p] then
self._mark_decided[p] = { t0 = tex.get_current_ms(), course = diff }
end
else
self._mark_decided[p] = nil
end
end
if fade_in < 1.0 then return end
local mod_on = player:modifier_active()
if player.modifier_offset then
self._mod_off[p] = player:modifier_offset()
elseif mod_on then
if not self._mod_seen[p] then
local now = tex.get_current_ms()
local prev = self._last_sel_ms or now
self._mod_t0[p] = (now - prev < 250) and math.min(now, prev + 16.7) or now
end
local u = math.max(0.0, math.min(1.0, (tex.get_current_ms() - self._mod_t0[p]) / MOD_SLIDE_MS))
self._mod_off[p] = -MOD_SLIDE * u * (2 - u)
else
self._mod_off[p] = nil
end
self._last_sel_ms = tex.get_current_ms()
self._mod_seen[p] = mod_on
local fade = (player:neiro_active() or mod_on) and 0.5 or fade_in
-- the cursor offset is +-40 only while both players sit on the same slot
local cursor_dx = is_half and ((p == 2) and 40 or -40) or 0
local sx = DS_SLOT_X[(diff >= DIFF_EASY) and math.min(DIFF_ONI, diff) or diff]
if not sx then return end
if diff >= DIFF_EASY then
local col = math.min(DIFF_ONI, diff)
if col == DIFF_ONI and self._ura_anim then return end -- cursor/frame hidden during the change
sx = DS_SLOT_X[col]
local frame_id = (p == 1) and self._id_course_frame or self._id_course_frame2
if under then draw_centered(frame_id, sx, DS_BOARD_Y, 248, 408, { fade = fade }) end
if over then
draw_centered_b((p == 1) and self._id_bubble_1p or self._id_bubble_2p, SEL_BASE_BALLOON,
sx + cursor_dx, DS_CURSOR_Y, 124, 124, { fade = fade })
end
else
local by = (diff == DIFF_NEIRO) and DS_NEIRO_Y or DS_BTN_Y
if under then draw_centered(self._id_btn_glow, sx, by, 168, 168, { fade = fade }) end
if over then
draw_centered_b((p == 1) and self._id_bubble_1p or self._id_bubble_2p, SEL_BASE_BALLOON,
sx + cursor_dx, DS_CURSOR_Y, 124, 124, { fade = fade })
end
end
end
--------------------------------------------------------------------------------
-- FolderBox (genre kanban)
--------------------------------------------------------------------------------
local SORT_COLLECTIONS = { SEARCH = true, DIFFICULTY = true }
local EVENT_COLLECTIONS = { NEW = true, RECOMMENDED = true }
local function folder_frame(box)
local n = box.text_name
if n and EVENT_FOLDER_NAMES[n] then return FOLDER_FRAME_EVENT, true end
local col = box.collection
if col and col ~= "" then
if EVENT_COLLECTIONS[col] then return FOLDER_FRAME_EVENT, true end
if SORT_COLLECTIONS[col] then return FOLDER_FRAME_SORT, false end
end
if box.kind and box:kind() == "dan" then return FOLDER_FRAME_DANI, false end
if n and SORT_FOLDER_NAMES[n] then return FOLDER_FRAME_SORT, false end
return box:genre_frame(), false
end
-- Inside an event folder every song board uses the event colour add, not its own genre.
-- Refreshed once per frame from draw_background (the only method handed the navigator).
function SongSelect:refresh_folder_context(nav)
self._inside_frame = nil
if R61.dbg and not self._r61_ctx_logged then
self._r61_ctx_logged = true
pcall(function()
tex.log("r61 ctx: nav=" .. tostring(nav ~= nil)
.. " has_current_folder=" .. tostring(nav and nav.current_folder ~= nil))
end)
end
-- read the engine's wheel events (a bumped counter; only the newest transition matters)
if nav then
local ok, seq = pcall(function() return nav.wheel_event_seq end)
if ok and seq ~= nil then
R61.have_events = true
if R61.seq == nil then
R61.seq = seq
elseif seq ~= R61.seq then
R61.seq = seq
local ok2, id = pcall(function() return nav.wheel_event end)
if ok2 and id then leg_event(id) end
end
end
end
if not (nav and nav.current_folder) then return end
local f = nav:current_folder()
if not f then return end
local ff, is_event = folder_frame(f)
if is_event then self._inside_frame = ff end
end
-- instance_board_pattern: 960x120 strip centred on the board, y follows the title
function SongSelect:draw_pattern(bx, by, p, fade)
if not self._id_pattern then return end
tex.draw_texture(self._id_pattern, { x = bx - 480, y = by - 94 * p - 60, fade = fade })
end
-- The genre board title, re-made at the skin_config folder_box_name size.
local FOLDER_TITLE_SIZE = nil
function SongSelect:folder_title(box)
if FOLDER_TITLE_SIZE == nil then
local cfg = tex.get_skin_config("folder_box_name")
FOLDER_TITLE_SIZE = (cfg and cfg.font_size and cfg.font_size > 0) and cfg.font_size or false
end
if not FOLDER_TITLE_SIZE then return box:name() end
local key = box.text_name
if not key or key == "" then return box:name() end
local ff = folder_frame(box)
key = ff .. "\0" .. key
local t = self._folder_titles[key]
if t == nil then
t = text.create_raw_text(box.text_name, FOLDER_TITLE_SIZE, { 255, 255, 255, 255 },
board_outline(ff), false, outline(7.5), 2.0)
self._folder_titles[key] = t or false
end
return t or box:name()
end
function SongSelect:draw_folder_board(box, pb, ch_alpha, ch_off, ta, glow_a)
local bx, by = box:box_x(), box:box_y()
local fdx, fdy = fly_offset(box); bx, by = bx + fdx, by + fdy -- wheel fly-in
local cen_h = CEN_CLOSED_H + (CEN_OPEN_H - CEN_CLOSED_H) * pb
local fade = box_fade(box)
local frame, has_pattern = folder_frame(box)
-- the genre board furls/unfurls horizontally across the folder transition
local ltitle = 1.0
if not R61.disabled then
local lf = (box:draw_state() == "open" or is_decided(box)) and leg_f() or nil
if lf then
local sx, ba = go_get("genre_board_sx", lf, 1), go_get("genre_board_sx", lf, 2)
if sx and ba then
if ba <= 0.0 then return end -- the board is not on screen
if sx < 1.0 then -- furl / unfurl: the open slab alone
if R61.dbg then
pcall(tex.log, string.format(
"[r85g] ms=%.1f f=%.2f kind=%s sx=%.4f a=%.3f w=%.1f",
tex.get_current_ms(), lf, tostring(R61.kind), sx, ba,
BOARD_W * sx))
end
draw_board(self._id_bar_folder, frame, bx, by, CEN_OPEN_H,
fade * ba, false, sx)
return
end
ch_alpha = math.min(ch_alpha, go_get("genre_chara_a", lf, 1) or 1.0)
ta = math.min(ta, go_get("genre_intro_a", lf, 1) or 1.0)
ltitle = go_get("genre_title_a", lf, 1) or 1.0
if R61.dbg then
pcall(tex.log, string.format(
"[r85g] ms=%.1f f=%.2f kind=%s sx=%.4f a=%.3f w=%.1f",
tex.get_current_ms(), lf, tostring(R61.kind), sx, ba,
BOARD_W * sx))
end
end
end
end
if glow_a > 0 then self:draw_glow(bx, by, cen_h, fade * glow_a * glow_pulse()) end
-- closed folder = bar_genre, open folder = folder_graphic (title band on top)
if pb <= 0.0 then
draw_board(self._id_bar_genre, frame, bx, by, cen_h, fade)
else
draw_board(self._id_bar_folder, frame, bx, by, cen_h, fade, pb >= 1.0)
if pb < 1.0 then draw_board(self._id_bar_genre, frame, bx, by, cen_h, fade * (1.0 - pb)) end
end
if has_pattern then self:draw_pattern(bx, by, pb, fade) end
if box:has_box_texture() then
-- a library-supplied box.png is fitted (aspect kept, shrink only) into the board rect
if ch_alpha > 0 then
box:draw_box_texture({ cx = bx, cy = by,
max_w = BOARD_W, max_h = CAP_H * 2 + CEN_OPEN_H,
fade = fade * ch_alpha })
end
-- title drawn after the art, matching the cabinet's depth order
draw_title(self:folder_title(box), bx, by - 94 * pb, fade * ltitle)
return
end
-- characters: texture has L at x=510 and R at x=1410
if ch_alpha > 0 then
local slide = 450.0 - ch_off
local gframe = box:genre_frame()
tex.draw_texture(self._id_box_chara, { frame = gframe, x = bx - 960 + slide, y = by - 240, x2 = 960 - 1920, fade = fade * ch_alpha,
src = { x = 0, y = 0, width = 960, height = 480 } })
tex.draw_texture(self._id_box_chara, { frame = gframe, x = bx - slide, y = by - 240, x2 = 960 - 1920, fade = fade * ch_alpha,
src = { x = 960, y = 0, width = 960, height = 480 } })
end
-- title rises to y-94 when open; the song count stands in for the intro text
draw_title(self:folder_title(box), bx, by - 94 * pb, fade * ltitle)
local n = box.tja_count
if ta > 0 and n and n > 0 then
local ckey = frame .. "\0" .. n
local label = self._count_cache[ckey]
if not label then
label = text.create_raw_text(tostring(n) .. " songs", 32, { 255, 255, 255, 255 }, board_outline(frame), false, outline(4.0), 2.0)
self._count_cache[ckey] = label
end
if label then draw_title(label, bx, by + 49 * pb, fade * ta) end
end
end
function SongSelect:draw_folder_closed(box)
local key = "f\0" .. box_id(box)
local e = wheel_close_step(key, FOLDER_CLOSE_MS, STALE_TIMER_MS)
if e then
local pb, ch_alpha, ch_off, ta = folder_close_timeline(e)
self:draw_folder_board(box, pb, ch_alpha, ch_off, ta, pb)
return
end
self:draw_folder_board(box, 0.0, 0.0, 340.0, 0.0, 0.0)
end
function SongSelect:draw_folder_open(box)
-- remember the decided board: the engine removes it from the wheel while its songs
-- load, and draw_overlays keeps drawing it until it has furled away
R61.last_open_id, R61.last_open_box = box_id(box), box
if is_decided(box) then
self._dec_frame = self._frame_no
self:draw_folder_board(box, 1.0, 1.0, 440.0, 1.0, 1.0)
return
end
local key = "f\0" .. box_id(box)
local t = wheel_open_t(key)
if t == nil then -- 508 ms pre-growth hold: fully closed
self:draw_folder_board(box, 0.0, 0.0, 340.0, 0.0, 0.0)
return
end
local pb, ch_alpha, ch_off, ta = folder_open_timeline(t)
self:draw_folder_board(box, pb, ch_alpha, ch_off, ta, pb)
end
--------------------------------------------------------------------------------
-- BackBox ("back" brown board with a label)
--------------------------------------------------------------------------------
function SongSelect:back_label()
if not self._back_label then
local cfg = tex.get_skin_config("song_back_label")
if cfg then
self._back_label = text.create_text("song_back_label", { 255, 255, 255, 255 }, BOARD_OUTLINE_BACK, false, outline(5.0), 2.0)
else
self._back_label = text.create_raw_text("BACK", 44, { 255, 255, 255, 255 }, BOARD_OUTLINE_BACK, false, outline(5.0), 2.0)
end
end
return self._back_label
end
local function back_pb_open(box)
local key = "b\0" .. box_id(box)
local t = wheel_open_t(key)
if t == nil then return 0.0 end
return (folder_open_timeline(t))
end
local function back_pb_closed(box)
local key = "b\0" .. box_id(box)
local e = wheel_close_step(key, FOLDER_CLOSE_MS, STALE_TIMER_MS)
if e == nil then return 0.0 end
return (folder_close_timeline(e))
end
function SongSelect:draw_back_board(box, p)
local bx, by = box:box_x(), box:box_y()
local fdx, fdy = fly_offset(box); bx, by = bx + fdx, by + fdy -- wheel fly-in
local cen_h = CEN_CLOSED_H + (CEN_OPEN_H - CEN_CLOSED_H) * p
local fade = math.min(1.0, box_fade(box))
local lta = 1.0
if not R61.disabled then
local lf = (box:draw_state() == "open") and leg_f() or nil
if lf and R61.kind == "open" and lf < R61.open_swap - 1 then return end -- not yet
if lf then
local sx, ba = go_get("song_board_sx", lf, 1), go_get("song_board_sx", lf, 2)
if sx and ba then
if ba <= 0.0 then return end
if sx < 1.0 then
if R61.dbg then
pcall(tex.log, string.format(
"[r85s] ms=%.1f f=%.2f kind=%s sx=%.4f a=%.3f w=%.1f",
tex.get_current_ms(), lf, tostring(R61.kind), sx, ba,
BOARD_W * sx))
end
draw_board(self._id_bar_back, 0, bx, by, CEN_OPEN_H,
fade * ba, false, sx)
return
end
lta = go_get("song_title_a", lf, 1) or 1.0
end
end
end
if p > 0 then self:draw_glow(bx, by, cen_h, fade * p * glow_pulse()) end
draw_board(self._id_bar_back, 0, bx, by, cen_h, fade)
draw_title(self:back_label(), bx, by, fade * lta)
end
function SongSelect:draw_back_closed(box)
self:draw_back_board(box, back_pb_closed(box))
end
function SongSelect:draw_back_open(box)
self:draw_back_board(box, back_pb_open(box))
end
--------------------------------------------------------------------------------
-- Difficulty sort window
--------------------------------------------------------------------------------
local SW_ROW_Y = { 410, 551, 692 }
local SW_BOARD_W, SW_BOARD_H = 1320, 760
local SW_CURSOR_W, SW_CURSOR_H = 1240, 180
local SW_PILL_CX = 1124 -- 960 + 164
local SW_PILL_W, SW_PILL_H = 792, 104
local SW_MARK_CX = 871 -- 960 - 89
local SW_ARROW_LX = 786 -- 960 - 174
local SW_ARROW_RX = 1462 -- 960 + 502
local SW_HEADER_CX = 560 -- 960 - 400
local SW_TEXT_DY = -13
local SW_GUIDE_Y = 827
local SW_GUIDE_W, SW_GUIDE_H = 372, 96
local SW_NUM_RIGHT = 1379
local SW_NUM_Y = 289
local SW_NUM_PITCH = 34
local SW_NUM_W, SW_NUM_H = 48, 56
local SW_SONGS_X = 1402 -- ink LEFT edge (EditText align = left)
local SW_SONGS_Y = 277
local SW_TITLE_X = 405
local SW_TITLE_Y = 272
local SW_TITLE_OUTLINE = { 119, 26, 45, 255 }
local SW_HEADER_BOX_W = 310
local SW_ITEM_BOX_W = { 620, 430, 430 }
local SW_CURSOR_CYCLE = 51.0
local SW_KEYS = {
header = { "sort_window_diff", "sort_window_star", "sort_window_order" },
course = { "course_name_0", "course_name_1", "course_name_2", "course_name_3", "course_name_3" },
order = { "sort_window_order_0", "sort_window_order_1", "sort_window_order_2", "sort_window_order_3" },
title = "folder_sort_select",
songs = "sort_window_songs",
}
local function sw_words(group)
local k = SW_KEYS[group]
if type(k) == "table" then
local out = {}
for i, key in ipairs(k) do out[i] = tex.skin_text(key) end
return out
end
return tex.skin_text(k)
end
-- OutlinedText cache: key = group .. "\0" .. index.
function SongSelect:_sw_text(key, str, size, thickness, outline)
self._sw_texts = self._sw_texts or {}
local t = self._sw_texts[key]
if t == nil then
t = text.create_raw_text(str, size, { 255, 255, 255, 255 },
outline or { 0, 0, 0, 255 }, false, thickness, 2.0)
self._sw_texts[key] = t or false
end
return t or nil
end
-- Fit-shrink: when a string does not fit its box, re-rasterise it at a smaller (uniformly
-- scaled) point size instead of stretching the bitmap, so glyphs keep their proportions.
local SW_FIT_MIN_SIZE = 22
function SongSelect:_sw_text_fit(key, str, size, thickness, outline, max_w)
self._sw_texts = self._sw_texts or {}
local t = self._sw_texts[key]
if t == nil then
t = text.create_raw_text(str, size, { 255, 255, 255, 255 },
outline or { 0, 0, 0, 255 }, false, thickness, 2.0)
if t and max_w and t.width > max_w then
local fit_size = math.max(SW_FIT_MIN_SIZE, math.floor(size * max_w / t.width))
if fit_size < size then
local fit_thickness = thickness * fit_size / size
local t2 = text.create_raw_text(str, fit_size, { 255, 255, 255, 255 },
outline or { 0, 0, 0, 255 }, false, fit_thickness, 2.0)
if t2 then t = t2 end
end
end
self._sw_texts[key] = t or false
end
return t or nil
end
-- Draw an OutlinedText with its ink LEFT edge on x.
local function draw_text_left(t, x, cy, fade)
if not t then return end
t:draw({ x = math.floor(x + 0.5), y = math.floor(cy - t.height / 2 + 0.5), fade = fade })
end
-- cursor glow alpha over the 51-frame loop, sampled from anim/sort_cursor.
local sort_cursor_ok, sort_cursor_anim = pcall(require, "anim/sampler")
local SW_CURSOR_CLIP = (sort_cursor_ok and sort_cursor_anim.safe("anim/sort_cursor"))
or { get = function() return nil end, label = function() return nil end }
local SW_ON_F = SW_CURSOR_CLIP:label("on") or 9
local function sw_cursor_glow(ms)
local r = (ms * 0.06) % SW_CURSOR_CYCLE
local a = SW_CURSOR_CLIP:get("#3@0", SW_ON_F + r, "a")
if a then return a end
if r <= 20.0 then return 1.0 - r / 20.0 end
return (r - 20.0) / 30.0
end
function SongSelect:_sw_ids()
if self._sw_loaded then return end
tex.load_folder("song_select", "diff_sort")
self._id_sw_board = tex.get_texture("song_select/diff_sort/background") -- 1320x760 window
self._id_sw_pill = tex.get_texture("song_select/diff_sort/level_box") -- 792x104 white item pill
self._id_sw_cur = tex.get_texture("song_select/diff_sort/box_highlight") -- 1240x180 flat yellow bar
self._id_sw_glow = tex.get_texture("song_select/diff_sort/box_outline") -- 1240x180 pulsing glow
self._id_sw_mark = tex.get_texture("song_select/diff_sort/diff") -- 96x96, frame = course
self._id_sw_arrow = tex.get_texture("song_select/diff_sort/arrow") -- 48x56, frame 1 = rotated
self._id_sw_digit = tex.get_texture("song_select/diff_sort/stat_num") -- 48x56, frame = digit
self._id_sw_guide = tex.get_texture("song_select/diff_sort/star_select_text") -- s_menfuchi_kp loop
self._sw_loaded = true
end
function SongSelect:draw_sort_window(w)
self:_sw_ids()
local a = w:alpha()
if a <= 0.0 or not self._id_sw_board then return end
local ms = tex.get_current_ms()
local session = w:session()
local vals = { w:diff(), w:star(), w:order() }
draw_centered(self._id_sw_board, 960, 540, SW_BOARD_W, SW_BOARD_H, { fade = a })
-- the three rows, in the cabinet's own depth order (order, star, diff)
for row = 3, 1, -1 do
local ry = SW_ROW_Y[row]
local idx = row - 1 -- 0-based, matching SortWindow:session()
if idx == session and self._id_sw_cur then
if self._id_sw_glow then
draw_centered(self._id_sw_glow, 960, ry, SW_CURSOR_W, SW_CURSOR_H,
{ fade = a * sw_cursor_glow(ms) })
end
draw_centered(self._id_sw_cur, 960, ry, SW_CURSOR_W, SW_CURSOR_H, { fade = a })
end
if self._id_sw_pill then
draw_centered(self._id_sw_pill, SW_PILL_CX, ry, SW_PILL_W, SW_PILL_H, { fade = a })
end
-- diff_mark is visible on the むずかしさ row only
if row == 1 and self._id_sw_mark then
draw_centered(self._id_sw_mark, SW_MARK_CX, ry, 96, 96,
{ frame = math.max(0, math.min(4, vals[1] - 1)), fade = a })
end
if self._id_sw_arrow then
local dx = w:arrow_offset(idx)
draw_centered(self._id_sw_arrow, SW_ARROW_LX + dx, ry, 48, 56, { frame = 0, fade = a })
draw_centered(self._id_sw_arrow, SW_ARROW_RX - dx, ry, 48, 56, { frame = 1, fade = a })
end
-- item value
local str, key
if row == 1 then
local n = math.max(1, math.min(5, vals[1]))
str, key = sw_words("course")[n], "course" .. n
elseif row == 2 then
str, key = tostring(vals[2]), "star" .. vals[2]
else
local n = math.max(1, math.min(4, vals[3]))
str, key = sw_words("order")[n], "order" .. n
end
draw_title(self:_sw_text_fit(key, str, 42, outline(6.5), nil, SW_ITEM_BOX_W[row]),
SW_PILL_CX, ry + SW_TEXT_DY, a, SW_ITEM_BOX_W[row], 0)
-- header
draw_title(self:_sw_text_fit("hdr" .. row, sw_words("header")[row], 42, outline(6.5), nil, SW_HEADER_BOX_W),
SW_HEADER_CX, ry + SW_TEXT_DY, a, SW_HEADER_BOX_W, 0)
end
-- control guide + its two pill labels
if self._id_sw_guide then
local cell = math.floor((ms * 0.06) % 210)
draw_centered(self._id_sw_guide, 960, SW_GUIDE_Y, SW_GUIDE_W, SW_GUIDE_H,
{ frame = cell, fade = a })
local labels = self:guide_labels()
if labels then
draw_guide_label(labels.select, GUIDE_SELECT_CX, SW_GUIDE_Y + 2, a)
draw_guide_label(labels.decide, GUIDE_DECIDE_CX, SW_GUIDE_Y + 2, a)
end
end
-- song counter: 「N 曲」 (right-aligned digits, then the unit label)
local n = math.max(0, math.min(9999, w:song_num()))
local digits = tostring(n)
for i = 1, #digits do
local d = tonumber(digits:sub(i, i))
local x = SW_NUM_RIGHT - (#digits - i) * SW_NUM_PITCH
draw_centered(self._id_sw_digit, x, SW_NUM_Y, SW_NUM_W, SW_NUM_H, { frame = d, fade = a })
end
draw_text_left(self:_sw_text("songs", sw_words("songs"), 42, outline(6.5)), SW_SONGS_X, SW_SONGS_Y, a)
-- board title, with the cabinet's dark-red outline add
draw_text_left(self:_sw_text("title", sw_words("title"), 54, outline(7.5), SW_TITLE_OUTLINE),
SW_TITLE_X, SW_TITLE_Y, a)
end
--------------------------------------------------------------------------------
-- Dispatch
--------------------------------------------------------------------------------
-- In 2P the engine draws neither the diff-select backboard nor the Lua selector, so both
-- are driven from draw_box.
local function is_2p_screen()
return (tex.current_screen and tex.current_screen() or "") == "SONG_SELECT_2P"
end
function SongSelect:draw_box(box)
-- scene-entry detection: the first box draw (or the first after a >1 s gap) arms "nohold"
local nowms = tex.get_current_ms()
if _last_box_draw_ms == nil or (nowms - _last_box_draw_ms) > 1000.0 then
R61.nohold_once = true
R61.nohold_ms = nowms
end
_last_box_draw_ms = nowms
local kind, state = box:lua_kind(), box:draw_state()
-- env-gated wheel trace (off unless YATAIDON_R63_DEBUG is set)
if R63_DBG then
local p = box_id(box)
if nowms ~= _r63_ms then
_r63_ms, _r63_seen, _r63_n = nowms, {}, 0
pcall(tex.log, string.format("[r63] --- frame ms=%.1f ---", nowms))
end
_r63_n = _r63_n + 1
local dup = _r63_seen[p] and " DUPLICATE-IN-FRAME" or ""
_r63_seen[p] = true
pcall(tex.log, string.format("[r63] #%d kind=%s state=%s y=%.1f name=%q id=%q%s",
_r63_n, tostring(kind), tostring(state), box:box_y(),
tostring(box.text_name), p, dup))
end
if kind == "song" then
if state == "diff_select" then
-- in 2P the backboard is drawn once; latch _bg_drawn so it never double-draws
if is_2p_screen() and not self._bg_drawn then
self:draw_song_diff_select_bg(box)
end
self:draw_song_diff_select(box)
elseif state == "open" then self:draw_song_open(box)
else self:draw_song_closed(box) end
elseif kind == "folder" then
if state == "open" or is_decided(box) then self:draw_folder_open(box)
else self:draw_folder_closed(box) end
elseif kind == "back" then
if state == "open" then self:draw_back_open(box)
else self:draw_back_closed(box) end
else
-- dan / other boxes: plain board in the arcade "dani" colour
local bx, by = box:box_x(), box:box_y()
local fdx, fdy = fly_offset(box); bx, by = bx + fdx, by + fdy -- wheel fly-in
local key = "o\0" .. box_id(box)
local p
if state == "open" then
local t = wheel_open_t(key)
p = t and (folder_open_timeline(t)) or 0.0
else
local e = wheel_close_step(key, FOLDER_CLOSE_MS, STALE_TIMER_MS)
p = e and (folder_close_timeline(e)) or 0.0
end
local cen_h = CEN_CLOSED_H + (CEN_OPEN_H - CEN_CLOSED_H) * p
local fade = math.min(1.0, box_fade(box))
if p > 0 then self:draw_glow(bx, by, cen_h, fade * p * glow_pulse()) end
draw_board((p > 0) and self._id_bar_folder or self._id_bar_genre, 10, bx, by, cen_h, fade, p <= 0 or p >= 1)
if p > 0 and p < 1 then draw_board(self._id_bar_genre, 10, bx, by, cen_h, fade * (1.0 - p)) end
draw_title(self:folder_title(box), bx, by - 94 * p, fade)
end
end
function SongSelect:draw_box_bg(box)
if box:lua_kind() ~= "song" then return end
self._bg_drawn = true
self:draw_song_diff_select_bg(box)
end