1995 lines
89 KiB
Lua
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
|