Files
YataiDONNijiiro/Scripts/song_select/song_select.lua
T
2026-10-01 14:47:49 -04:00

329 lines
16 KiB
Lua

local STATE_BROWSING = 0
local STATE_SONG_SELECTED = 1
local STATE_DIFF_SORTING = 2
local STATE_SEARCHING = 3
local STATE_DAN_SELECTED = 4
-- 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
local base = require("song_select/file_navigator/box_base")
local folder = require("song_select/file_navigator/box_folder")
require("song_select/file_navigator/box_song")
require("song_select/file_navigator/box_back")
require("song_select/file_navigator/box_dan")
require("song_select/selector")
require("song_select/modifier")
require("song_select/diff_sort")
local dan_transition_ok, dan_transition = pcall(require, "song_select/dan_transition")
local folder_leg = base.folder_leg
local DIFF_EASY, DIFF_URA = base.DIFF_EASY, base.DIFF_URA
local get_any = base.get_any
local draw_centered = base.draw_centered
local draw_title = base.draw_title
local draw_guide_label = base.draw_guide_label
local outline = base.outline
local GUIDE_SELECT_CX, GUIDE_DECIDE_CX, GUIDE_LABEL_CY = base.GUIDE_SELECT_CX, base.GUIDE_DECIDE_CX, base.GUIDE_LABEL_CY
local MOD_CX, MOD_JSON_Y, MOD_HEADER_CY = base.MOD_CX, base.MOD_JSON_Y, base.MOD_HEADER_CY
local leg_f, leg_event, is_decided = base.leg_f, base.leg_event, base.is_decided
local folder_frame = folder.folder_frame
local _last_box_draw_ms = nil
function SongSelect.new()
local self = setmetatable({}, SongSelect)
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.
function SongSelect:draw_top(dan_progress)
if not dan_transition_ok then return end
dan_transition.set_progress(dan_progress)
dan_transition.draw()
end
function SongSelect:draw_overlays(state)
self:draw_decided_bridge()
if dan_transition_ok then dan_transition.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.
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
-- 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
-- The decided genre board while the loader owns it, until it has furled.
function SongSelect:draw_decided_bridge()
if not folder_leg.dec_box then return end
local lf = (folder_leg.kind == "open") and leg_f() or nil
if lf and lf < folder_leg.open_swap - 1 then
if self._dec_frame ~= self._frame_no then
pcall(self.draw_folder_open, self, folder_leg.dec_box)
end
else
folder_leg.dec_id, folder_leg.dec_box = nil, nil
end
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
-- 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
folder_leg.have_events = true
if folder_leg.seq == nil then
folder_leg.seq = seq
elseif seq ~= folder_leg.seq then
folder_leg.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
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
-- 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
folder_leg.nohold_once = true
folder_leg.nohold_ms = nowms
end
_last_box_draw_ms = nowms
local kind, state = box:lua_kind(), box:draw_state()
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
self:draw_other_board(box, state)
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