Files
YataiDONNijiiro/Scripts/song_select/file_navigator/box_song.lua
T
TaikoZhongandClaude Opus 5.5 809df3eb20 Song select: smaller board titles outside English
English uses the condensed font_en, but Japanese (font.ttf), Chinese (font_cn), Korean (font_kr)
and the other languages get full-width fonts, so at the same point size their song, folder and
header titles look far larger and fill the boards. Draw those titles at 0.8x when the language
is not English. English is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 11:23:46 +08:00

462 lines
21 KiB
Lua

-- SongBox rendering (song board, difficulty plates, badges, diff select), split out of
-- song_select.lua. All shared helpers/state come from box_base.
local base = require("song_select/file_navigator/box_base")
local folder_leg = base.folder_leg
local DIFF_BACK, DIFF_MODIFIER = base.DIFF_BACK, base.DIFF_MODIFIER
local DIFF_EASY, DIFF_NORMAL, DIFF_HARD, DIFF_ONI, DIFF_URA =
base.DIFF_EASY, base.DIFF_NORMAL, base.DIFF_HARD, base.DIFF_ONI, base.DIFF_URA
local CROWN_NONE, CROWN_FC, CROWN_DFC = base.CROWN_NONE, base.CROWN_FC, base.CROWN_DFC
local BOARD_W = base.BOARD_W
local CEN_CLOSED_H, CEN_OPEN_H = base.CEN_CLOSED_H, base.CEN_OPEN_H
local SONG_TEXT_BOX_W, DS_TEXT_BOX_W = base.SONG_TEXT_BOX_W, base.DS_TEXT_BOX_W
local SONG_CLOSE_MS, SONG_STALE_MS = base.SONG_CLOSE_MS, base.SONG_STALE_MS
local URA_LOOP, URA_LOOP_F0, URA_LOOP_LEN = base.URA_LOOP, base.URA_LOOP_F0, base.URA_LOOP_LEN
local DS_SLOT_X, DS_BOARD_Y, DS_COURSE_NAME_DY = base.DS_SLOT_X, base.DS_BOARD_Y, base.DS_COURSE_NAME_DY
local URA_ANIM_MS, URA_SWAP_MS, URA_CELLS = base.URA_ANIM_MS, base.URA_SWAP_MS, base.URA_CELLS
local DS_BTN_Y, DS_CURSOR_Y, DS_NEIRO_Y = base.DS_BTN_Y, base.DS_CURSOR_Y, base.DS_NEIRO_Y
local DS_MARK_X, DS_MARK_Y = base.DS_MARK_X, base.DS_MARK_Y
local DS_MARK_DECIDE, DS_MARK_DECIDE_MS = base.DS_MARK_DECIDE, base.DS_MARK_DECIDE_MS
local get_any = base.get_any
local draw_centered, draw_centered_b = base.draw_centered, base.draw_centered_b
local draw_title = base.draw_title
local outline, board_outline = base.outline, base.board_outline
local draw_board, draw_glow, glow_pulse = base.draw_board, base.draw_glow, base.glow_pulse
local box_id = base.box_id
local go_get, leg_f, box_fade, fly_offset = base.go_get, base.leg_f, base.box_fade, base.fly_offset
local wheel_open_t, wheel_close_step = base.wheel_open_t, base.wheel_close_step
local song_open_timeline, song_close_timeline = base.song_open_timeline, base.song_close_timeline
local SONG_TITLE_OUTLINE = 5.0
local SONG_TITLE_SIZE = nil -- resolved once from skin_config song_select_box_name
local SONG_TITLE_CACHE_MAX = 128 -- bound the per-title texture cache
-- 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 gof and folder_leg.kind == "open" and gof < folder_leg.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
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 draw_glow(self._id_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_select_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
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
local function mark_intro_pose(i)
local function lerp(a, b, u) return a + (b - a) * u end
if i <= 11 then
local u = 1 - (1 - i / 11) ^ 2
return lerp(150, -48.55, u), lerp(1, 0.95, u), lerp(1, 1.099, u), lerp(0, 0.496, u)
elseif i <= 13 then
return -48.6, 0.95, 1.099, 0.5
end
local u = math.min(1, (i - 14) / 10) ^ 2
return lerp(-48.6, 0, u), lerp(0.951, 1, u), lerp(1.097, 1, u), lerp(0.504, 0.602, u)
end
local MARK_INTRO_FRAMES = 25
function SongSelect:draw_diff_mark_intro(p, d, i, fade)
local dy, sx, sy, a = mark_intro_pose(i)
local cx, cy = DS_MARK_X[p], DS_MARK_Y + dy
tex.draw_texture(self._id_mark[d], { x = cx, y = cy, x2 = 680 * (sx - 1), y2 = 680 * (sy - 1),
origin = { 340 * sx, 340 * sy }, fade = a * fade })
end
function SongSelect:draw_song_diff_select_bg(box)
local fade = box.diff_fade_in.attribute
local now = tex.get_current_ms()
local intro = self._mark_intro or {}
if now - (self._mark_bg_ms or -1e9) > 100 then intro = {} end
self._mark_bg_ms = now
self._mark_intro = intro
for p = 1, 2 do
local d = self._mark_diff[p]
local dec = self._mark_decided[p]
local shown = d and d >= DIFF_EASY and DS_MARK_X[p] and fade > 0
if not shown or dec then intro[p] = nil
elseif not intro[p] then intro[p] = now end
if dec and self._id_mark[dec.course] then
self:draw_diff_mark_decided(p, dec, fade)
elseif shown and self._id_mark[d] and (now - intro[p]) / DS_MARK_DECIDE_MS < MARK_INTRO_FRAMES then
self:draw_diff_mark_intro(p, d, math.floor((now - intro[p]) / DS_MARK_DECIDE_MS), fade)
elseif self._id_diff_mark and shown 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)
folder_leg.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_select_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 scale = base.title_scale()
local key = gf .. "\0" .. scale .. "\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_select_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, math.floor(SONG_TITLE_SIZE * scale + 0.5), { 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 ""
local scale = base.title_scale()
if self._header_title_key ~= name or self._header_title_scale ~= scale then
self._header_title_key = name
self._header_title_scale = scale
self._header_title = text.create_raw_text(name, math.floor(48 * scale + 0.5), { 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
local scale = base.title_scale()
if self._header_sub_key ~= sub or self._header_sub_scale ~= scale then
self._header_sub_key = sub
self._header_sub_scale = scale
self._header_sub = text.create_raw_text(sub, math.floor(30 * scale + 0.5), { 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