2595 lines
148 KiB
Lua
2595 lines
148 KiB
Lua
-- YataiDON-HSS song select: reference cabinet layout at native 1080p.
|
|
-- Started from YataiDON-HSS/Scripts/song_select/song_select.lua (the horizontal_song_select
|
|
-- path) and re-laid-out against the arcade lumen placements (see song_select/MAPPING.md).
|
|
--
|
|
-- COORDINATE NOTE: every child texture is loaded at scale 1.0 (1080p pixels) and every
|
|
-- child skin_config entry is read unscaled, so this script works in 1080p pixels and never
|
|
-- multiplies its own constants by tex.get_screen_scale(). Box positions (box:box_x/box_y)
|
|
-- and engine animations (open_anim = 150*screen_scale px) are already in screen pixels.
|
|
--
|
|
-- ROUND 2 (arcade chrome, from song_select_song_disp_data.lua / song_select_genre_disp_data.lua /
|
|
-- song_select_ui.lua / song_select_course_*.lua — see MAPPING.md "Round 2"):
|
|
-- * NEW badge (instance_icon @ icon_new) only on CLOSED boards (board label 'on' hides it),
|
|
-- * genre boards: event/feature boards (beige 'event1' + pattern strip) and sort_select (red),
|
|
-- title rises to -94 when open, pattern strip follows, intro/count text at the centre,
|
|
-- * song counter plate (number_of_songs) under the timer at (1754,270),
|
|
-- * diff select: title/subtitle inside the backboard header, course names on the cards,
|
|
-- drum guide (control_guide_s 's_menfuchi_kr') at (960,978).
|
|
-- ROUND 4 (user capture comparison, see MAPPING.md "ROUND 4"):
|
|
-- * no 「曲をえらぶ」/「むずかしさをえらぶ」 header (the arcade shows only the control-guide panel),
|
|
-- * no floor band (draw_footer empty): the genre backdrop runs to the bottom, フリープレイ on it,
|
|
-- * song_kanban_info chips: course name (text_course_* font 18) under the icon inside each plate,
|
|
-- chips centred on the board (kanban_info 'default' = 3 chips at -182/0/182, 'oni' = 4 at ±91/±273),
|
|
-- * diff select: main_diff_mark (translucent course mark, 680x680 @ (200,408) / (1720,408)) behind
|
|
-- the buttons; option wrench kept (the engine cursor can reach MODIFIER/NEIRO: song_select/player.cpp
|
|
-- move_left/right walk NEIRO -> MODIFIER -> BACK) but drawn small, no "NEIRO" label.
|
|
|
|
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 (common_song_select_main_song_board / *_board_top/center/bottom)
|
|
local BOARD_W = 960
|
|
local CAP_H = 56 -- top / bottom cap rows of the 960x352 board texture
|
|
local CEN_SRC_H = 240 -- centre slice rows (56..296) in the texture
|
|
local CEN_CLOSED_H = 52 -- arcade closed board: centre scaled 0.22 (240*0.22)
|
|
local CEN_OPEN_H = 240
|
|
local GLOW_CAP_H = 80 -- bar_genre_overlay (cursor glow) 1024x400: 80 / 240 / 80
|
|
local GLOW_W = 1024
|
|
|
|
-- Feature ("event") folders get the arcade event board (folder_graphic frame 11 + pattern strip) and
|
|
-- the search / difficulty-sort folders the red sort_select board (frame 12). The engine does not
|
|
-- expose the folder's collection/genre_index to Lua (genre_frame() folds DEFAULT/RECOMMENDED/
|
|
-- FAVORITE/DAN/DIFFICULTY into frame 0), so they are matched by display name (box.text_name).
|
|
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,
|
|
-- ROUND 6: the names this skin's own Songs/16 Difficulty Sort/box.def ships
|
|
-- (measured in game: without these the board fell through to genre frame 0)
|
|
["むずかしさからえらぶ"] = 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 'all' loop baked every GUIDE_STEP arcade
|
|
-- frames (round 4: step 2 = 30 fps, 325 cells); cells 0..209 = arcade frames 0..419 = rim hit + arrow
|
|
-- (right stick 0..209, left stick 210..419), 7.0 s. Keep in sync with global/indicator.lua FRAME_STEP.
|
|
local GUIDE_STEP = 2
|
|
local GUIDE_RIM_FRAMES = math.floor(420 / GUIDE_STEP)
|
|
|
|
SongSelect = {}
|
|
SongSelect.__index = SongSelect
|
|
|
|
-- tex.get_id tries "<name>" then "<name>_<language>"; for languages without an art
|
|
-- variant (e.g. zh) fall back to the _en one so we never hand draw_texture a nil id.
|
|
local function get_any(subset, name)
|
|
return tex.get_id(subset, name) or tex.get_id(subset, name .. "_en") or tex.get_id(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
|
|
|
|
-- ROUND 6 (2P): a few textures now carry a non-zero texture.json x/y because the ENGINE draws
|
|
-- them in 2P (SongSelectPlayer::draw_selector); the engine ADDS that base to params.x/y, so when
|
|
-- this script draws the same texture it has to subtract the base again.
|
|
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 (card 1 @ 745,370)
|
|
|
|
-- Title centred on (bx, cy). max_w (optional): arcade EditText boxes squeeze long text HORIZONTALLY down to
|
|
-- the box width (song board title/sub box ~860 px); never below min_scale (default 0.75).
|
|
-- ROUND 11 (text sharpness): the destination x/y are SNAPPED TO WHOLE PIXELS. An
|
|
-- OutlinedText is an already-rasterised texture drawn 1:1 with TEXTURE_FILTER_BILINEAR;
|
|
-- centring an odd-width label on an integer centre lands it on a .5 and the GPU blends
|
|
-- every texel 50/50 with its neighbour. Measured cost of that half-pixel alone:
|
|
-- +34 % edge width on the current (resampled) text, +63..69 % on sharp text
|
|
-- (scratchpad/r11ts/compare.py). `w` is rounded too so `x2` keeps the quad integral.
|
|
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
|
|
-- ROUND 6: control-guide pill labels (`common/control_guide_s` DefineEditText id 5).
|
|
-- Box centres measured from the lumen (see draw_overlays); GUIDE_LABEL_SPACING is the `spacing`
|
|
-- passed to text.create_text in guide_labels(), which MeasureTextEx also adds after the last glyph.
|
|
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), -- ROUND 11: whole px
|
|
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 EditText box width
|
|
local DS_TEXT_BOX_W = 1000 -- main_diff text_title_song / text_song_title_sub EditText box width
|
|
|
|
-- ROUND 6 (item 15): the wheel's song-board title is drawn by this script from `box.text_name`
|
|
-- instead of the engine's `box:name()`. Measured from the lumen `CS/main_song_board`
|
|
-- DefineEditText id 82 (`text_song_title`): MS 明朝 **42**, `color="#ffffff"`, outline field
|
|
-- **5.0** — i.e. exactly the same white-fill / black-edge / 5 px shape as id 79 (もどる, 42/5.0)
|
|
-- and id 85 (the subtitle, 24/3.5) which round 5 already ported. The engine builds `name()` as
|
|
-- `OutlinedText(text_name, song_box_name.font_size, WHITE, fore_color, …)` and subtracts
|
|
-- 10·screen_scale = 15 px from the font for titles of >= 30 characters (`BaseBox::horizontal_name`,
|
|
-- box_base.h) — the arcade never changes the font size, it squeezes the EditText box horizontally,
|
|
-- which `draw_title(…, SONG_TEXT_BOX_W)` already does. So this drops BOTH the genre-coloured
|
|
-- outline and the >= 30-char shrink. The squeeze is passed `min_scale = 0` (squeeze all the way
|
|
-- down to the 860 px box) instead of the 0.75 floor round 5 used for the subtitle: with the floor,
|
|
-- a 45-character title (`My Muscle Heart ~…~`) still came out 1095 px wide and hung ~120 px off
|
|
-- BOTH ends of the 960 px board. The diff-select header gets the same treatment against its own
|
|
-- 1000 px box (`main_diff` `text_title_song` / `text_song_title_sub`).
|
|
-- ROUND 16 (r16-outline) -- OUTLINE THICKNESS IS NOT IN STAGE PIXELS.
|
|
-- `OutlinedText`'s ctor multiplies whatever it is given by `screen_scale`
|
|
-- (src/libs/text.cpp:200), which is 1.5 in this skin, and then draws a 16-point
|
|
-- circle of that radius -- the same construction as the cabinet's own
|
|
-- `RenderBorderText` (research/lumen_text.md S4). A Lumen DefineEditText
|
|
-- `border` is ALREADY in 1080p stage px: measured on the cabinet's own
|
|
-- renderer, `main_song_board` field 82 (border 5.0) pads its glyph mask by
|
|
-- exactly 5 px on all four sides (`scratchpad/r16ol/arc_sb_g.png`). So the
|
|
-- value to hand `create_*text` is `border / screen_scale`, and every constant
|
|
-- below is written as the record's own border run through `ol()`. Passing the
|
|
-- border straight through drew the ring 50 % too thick.
|
|
local function ol(border) return border / tex.get_screen_scale() end
|
|
local SONG_TITLE_OUTLINE = 5.0 -- lumen EditText id 82 outline field 5.0 (stage px)
|
|
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
|
|
|
|
-- ROUND 6 (board text colours). The claim above that "the outline colour is renderer-fixed and is
|
|
-- BLACK" is WRONG for the boards, and this table replaces it. Established from the data, not by eye:
|
|
--
|
|
-- * `CSS/song_select_song_disp_data.lua` `SetSongText`/`SetSongColor` and
|
|
-- `CSS/song_select_genre_disp_data.lua` `SetFolderText` issue **zero** SetTextColor /
|
|
-- SetTextBorderColor calls. What they do is `GotoAndStop("main/board/text_kanban_title_song",
|
|
-- <genreIdStr>)` — the text field is wrapped in a clip whose frames are LABELLED PER GENRE.
|
|
-- * Each of those frames re-places the inner text clip with a different PlaceObject **colour-ADD**
|
|
-- transform (`main_song_board` sprites 89 `text_kanban_title_song` / 92 `text_song_title_sub` /
|
|
-- 95 `text_song_title_ruby`; `main_genre_board` sprites 70 `text_kanban_title_genre` /
|
|
-- 30 `text_kanban_intro_genre`). All five carry the SAME add table; colour-MUL is identity.
|
|
-- * The EditText fill is `#ffffff` (style-table entry 74/67 = 255,255,255,255). White + add clamps
|
|
-- to white, so the add lands only on the outline.
|
|
-- * The outline's own colour was the last unknown (`AppLumenRenderer+17632`, listed "not extracted"
|
|
-- in the reference research notes research/lumen_text.md §5.2). It is now extracted:
|
|
-- `App::AppLumenRenderer::set_font` @0x14037C180 does
|
|
-- `this[1102] = (fill.rgb != 0) ? xmmword_140CE64A0 : xmmword_140CE64B0`
|
|
-- and reading those two constants out of the reference build gives (0,0,0,1) = BLACK and (1,1,1,1) = WHITE.
|
|
-- So the baked outline is BLACK whenever the fill is not black (and WHITE when it is — which is
|
|
-- why the arcade's black `text_course_*` labels have a white edge).
|
|
-- * BLACK outline + the per-genre colour add ⇒ **the board title / subtitle / intro outline is the
|
|
-- genre colour**, exactly the values below. Fill stays white.
|
|
--
|
|
-- Indexed by the skin's own genre frame (`box:genre_frame()` / `folder_frame`, 0..12), which is the
|
|
-- same numbering `box/bar_genre` and `box/folder_graphic` already use.
|
|
local BOARD_OUTLINE = {
|
|
[0] = { 18, 72, 76, 255 }, -- default / tutorial / howto / favorite (arcade 'howto' = pops)
|
|
[1] = { 18, 72, 76, 255 }, -- pops
|
|
[2] = { 155, 24, 99, 255 }, -- anime
|
|
[3] = { 78, 29, 118, 255 }, -- game
|
|
[4] = { 150, 31, 0, 255 }, -- namco
|
|
[5] = { 101, 67, 42, 255 }, -- classic
|
|
[6] = { 20, 79, 20, 255 }, -- variety
|
|
[7] = { 165, 50, 0, 255 }, -- kids
|
|
[8] = { 38, 52, 73, 255 }, -- vocaloid
|
|
[9] = { 18, 72, 76, 255 }, -- recent (arcade 'recent' shares the pops entry)
|
|
[10] = { 0, 25, 95, 255 }, -- dani
|
|
[11] = { 100, 6, 22, 255 }, -- event
|
|
[12] = { 119, 26, 45, 255 }, -- sort
|
|
}
|
|
local BOARD_OUTLINE_BACK = { 76, 50, 20, 255 } -- main_song_board 'back' label add (もどる board)
|
|
|
|
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_id("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 (white drum panel, common/control_guide 'main' @ 'default': panel at
|
|
-- (0,34)-(296,290)) is drawn HERE, under the header, from the global/indicator bake (see
|
|
-- Scripts/global/indicator.lua: 130 frames, every 5th arcade frame, frames 0..86 = rim-hit loop).
|
|
-- The engine's own Indicator is parked off-screen by skin_config song_select_indicator.x = -2000
|
|
-- because it is drawn after draw_overlays and would cover the header; the arcade hides the
|
|
-- guide during course select (song_select_all.lua: SetVisibleControlGuide(false) on
|
|
-- change_courseselect), so it is only drawn while browsing.
|
|
self._id_guide_panel = tex.load_texture("global/indicator/background")
|
|
self._guide_t0 = tex.get_current_ms()
|
|
|
|
-- box
|
|
self._id_bar_genre = tex.get_id("box", "bar_genre") -- song boards, 960x352, frame = genre
|
|
self._id_bar_folder = tex.get_id("box", "folder_graphic") -- genre (folder) boards, same layout
|
|
self._id_bar_back = tex.get_id("box", "bar_genre_back") -- "back" board (brown)
|
|
self._id_glow = tex.get_id("box", "bar_genre_overlay") -- yellow cursor glow 1024x400
|
|
self._id_background = tex.get_id("box", "background") -- 2880x1080 scrolling bg, frame = genre
|
|
self._id_box_chara = tex.get_id("box", "box_chara") -- 1920x480 genre characters (L at 510, R at 1410)
|
|
self._id_pattern = tex.get_id("box", "genre_overlay_large") -- 960x120 event pattern strip (name reused; new texture names need an engine rebuild)
|
|
self._id_song_counter = tex.get_id("yellow_box", "song_count_back") -- 280x72 number_of_songs plate (name reused)
|
|
|
|
-- yellow_box
|
|
self._id_crown_dfc = tex.get_id("yellow_box", "crown_dfc") -- 72x72, frame = diff
|
|
self._id_crown_fc = tex.get_id("yellow_box", "crown_fc")
|
|
self._id_crown_clear = tex.get_id("yellow_box", "crown_clear")
|
|
self._id_score_rank = tex.get_id("yellow_box", "score_rank") -- 72x72, frame = (rank-1)*5 + diff
|
|
self._id_plate = tex.get_id("yellow_box", "frame_score") -- 184x96 diff plate, frame = diff
|
|
self._id_level_number = tex.get_id("yellow_box", "level_number") -- 48x48, frame = diff*11 + (level-1), 11 = "10+"
|
|
self._id_star = tex.get_id("yellow_box", "star") -- 40x40 tinted star, frame = diff
|
|
self._id_branch = tex.get_id("yellow_box", "branch_indicator") -- 40x40
|
|
self._id_branch_diff = tex.get_id("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_id("diff_select", "difficulty_back") -- 1272x784, frame = genre
|
|
self._id_diff_board = tex.get_id("diff_select", "difficulty_bar") -- 200x360, frame = diff (4 = ura)
|
|
self._id_course_frame = tex.get_id("diff_select", "difficulty_select_bar") -- 248x408 yellow glow frame
|
|
self._id_course_frame2 = tex.get_id("diff_select", "difficulty_select_bar2") -- 248x408 outline variant
|
|
self._id_bubble_1p = tex.get_id("diff_select", "1p_balloon") -- 124x124 cursor bubble + 1P
|
|
self._id_bubble_2p = tex.get_id("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")
|
|
-- ROUND 4c: with the engine's opt-in "option_neiro_row" the 音色 row lives inside
|
|
-- the 演奏オプション panel, so the NEIRO slot is unreachable and is not drawn.
|
|
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_neiro = (not self._neiro_in_options) and get_any("diff_select", "neiro") or nil
|
|
self._id_btn_glow = tex.get_id("yellow_box", "s_crown_clear") -- 168x168 button cursor plate (moved off diff_select: every 1p/2p_* name there is drawn by the engine in 2P)
|
|
self._id_small_crown = tex.get_id("diff_select", "difficulty_crown") -- 40x40, frame = crown (0 none)
|
|
self._id_small_rank = tex.get_id("yellow_box", "s_crown_outline") -- 40x40, frame = rank-1 (moved off 1p_outline_back)
|
|
self._id_small_star = tex.get_id("diff_select", "difficulty_star") -- 40x40
|
|
self._id_dot_bar = tex.get_id("diff_select", "ura_oni_plate") -- 172x24 dotted level bar
|
|
self._id_dot_star = tex.get_id("diff_select", "disable") -- 24x24 star for the dots
|
|
self._id_guide_s = tex.get_id("diff_select", "diff_tower") -- 372x96 control_guide_s, 210-cell loop sheet (name reused)
|
|
self._id_diff_mark = tex.get_id("diff_select", "diff_tower_shadow") -- 680x680 main_diff_mark 'wait' (alpha 154/256), frame = course (name reused)
|
|
-- ROUND 4b: oni<->ura switch = CS/main_diff_board oni_main 'change_ura' / 'change_oni' (90 arcade frames,
|
|
-- 1.5 s) baked every 2nd frame into 45 cells of 340x400 (origin 170,210 = card centre). Names reused
|
|
-- (1p/2p_outline_half are vertical-path-only). The engine's own ura_switch is a transparent texture.
|
|
self._id_ura_to_ura = tex.get_id("yellow_box", "s_crown_dfc")
|
|
self._id_ura_to_oni = tex.get_id("yellow_box", "s_crown_fc")
|
|
|
|
self._neiro_label = nil
|
|
self._back_label = nil
|
|
self._count_cache = {}
|
|
self._folder_titles = {}
|
|
self._song_titles = {} -- ROUND 6: text_name -> OutlinedText (arcade song-board title)
|
|
self._song_titles_n = 0
|
|
self._course_labels = {}
|
|
self._chip_labels = {}
|
|
self._mark_diff = {} -- per player: last cursor column for main_diff_mark (set by draw_selector)
|
|
self._ura_prev = nil -- box.is_ura seen last frame (toggle detection; C++ flips it instantly)
|
|
self._ura_anim = nil -- { t0 = ms, to_ura = bool } while the arcade change animation plays
|
|
self._guide_labels = nil
|
|
-- ROUND 5: 演奏オプション header title drawn from Lua (modifier/top.png is baked without text).
|
|
-- Lua only sees player:modifier_active() (panel exists), so the slide is re-timed here from the
|
|
-- frame it turns true (animation.json id 28: MOD_SLIDE px, MOD_SLIDE_MS, ease-out quadratic).
|
|
self._mod_title = nil
|
|
self._mod_seen = {} -- per player: modifier_active() last frame (set by draw_selector)
|
|
self._mod_t0 = {} -- per player: ms the panel appeared (fallback timing only)
|
|
self._mod_off = {} -- per player: panel y offset this frame (nil = no panel), set by draw_selector
|
|
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()
|
|
-- ROUND 4: the arcade song select has no floor band; the genre backdrop runs to the bottom.
|
|
end
|
|
|
|
-- ROUND 9 - 「M曲」, the blue right half of the number_of_songs plate
|
|
-- (LUA_CAPABILITIES item 17). Unblocked by the new `tex.song_limit()` binding:
|
|
-- until now general.song_limit had no binding and `io` is not opened, so the
|
|
-- right half of the plate had to stay empty. Arcade: plate 280x72 centred
|
|
-- (1754,270) -> white half 1614..1754, blue half 1754..1894, centre 1824.
|
|
-- Nothing is drawn when song_limit <= 0 (no limit configured = the arcade's
|
|
-- free-play cabinet, which prints no total either).
|
|
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(tostring(limit) .. "曲",
|
|
-- `common_song_select_number_of_songs` id 3 `text_total_songs`: size 24, border 3.5
|
|
sc.font_size or 24, { 255, 255, 255, 255 }, { 0, 0, 0, 255 }, false, ol(3.5), 2.0)
|
|
self._song_max_n = limit
|
|
end
|
|
local t = self._song_max
|
|
if not t then return end
|
|
-- ink centre = (width - spacing)/2, the same correction every label in this
|
|
-- file uses (raylib adds the spacing after the last glyph too).
|
|
t:draw({ x = math.floor(SONG_MAX_CX - (t.width - 2.0) / 2 + 0.5), -- ROUND 11: whole px
|
|
y = math.floor(self._song_max_y + 0.5) })
|
|
end
|
|
|
|
-- 段位道場 shutter doors. draw_overlays is the only place the skin can see
|
|
-- SongSelectState, and DAN_SELECTED (4) is set on the same frame song_select.cpp does
|
|
-- `dan_transition.emplace(); dan_transition->start()`; ROUND 11 latched a private clock
|
|
-- off that because DanTransition's own animation is invisible from Lua (C-dan-2).
|
|
-- ROUND 14 keeps the arm() call only as the fallback for an engine without draw_top —
|
|
-- when draw_top runs it supplies DanTransition::progress() and that wins.
|
|
local _dd_ok, dan_doors = pcall(require, "song_select/dan_doors")
|
|
|
|
-- ROUND 14: the top-most skin slot on SONG_SELECT, above the song counter, the select
|
|
-- timer, the allnet chip, the coin overlay and the control-guide indicator, and the last
|
|
-- draw before the engine's own (deliberately zero-height) DAN_TRANSITION quad.
|
|
-- `dan_progress` is DanTransition::progress() in 0..1, or -1 when none is running.
|
|
function SongSelect:draw_top(dan_progress)
|
|
if not _dd_ok then return end
|
|
dan_doors.set_progress(dan_progress)
|
|
dan_doors.draw()
|
|
end
|
|
|
|
function SongSelect:draw_overlays(state)
|
|
if _dd_ok then dan_doors.arm(state == STATE_DAN_SELECTED) end
|
|
-- number_of_songs plate: arcade song_of_number_instance at stage (1754,270), 280x72 centred.
|
|
-- The engine draws its own song_num_bg (made transparent in this skin) and the "N曲目" text
|
|
-- (skin_config song_num, font 32, right edge 1722 / top 248) on top of this.
|
|
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
|
|
-- ROUND 4: no 「曲をえらぶ」/「むずかしさをえらぶ」 header art (arcade: control-guide panel only).
|
|
if state == STATE_SONG_SELECTED then
|
|
-- course-select drum guide (main_diff instance_guide = common/control_guide_s 's_menfuchi_kr')
|
|
-- at stage (960,978), on top of the footer band (drawn here because draw_box runs before the footer)
|
|
if self._id_guide_s then
|
|
-- ROUND 4d: 210 cells = every 2nd arcade frame of the 420-frame select loop (decide loop 210)
|
|
-- -> 30 cells/s; stage (960,978) = root(960,540) + board(0,-132) + instance_guide(0,570).
|
|
-- ROUND 6 (text alignment): the label position is the EditText BOX, not the "free plate area".
|
|
-- `common/control_guide_s` has exactly one DefineEditText (id 5; the rect index is record word[5]):
|
|
-- box = bounds[2] = (-2,-2,82,46) -> 84x48, font 24, outline 0, align=center (word[8] low byte 2),
|
|
-- text #000000. It is placed inside `text_guide_instance` (MC 6) which sits at +58 in each
|
|
-- instance, so the box lands at instance + (+35..+119, -22..+26): pill-centre + (-98..-14)
|
|
-- for select_instance (-133) and + (+75..+159) for decide_instance (+40) -> box centres
|
|
-- select = 960 - 133 + 77 = 904, decide = 960 + 40 + 77 = 1077, y = 978 + 2 = 980.
|
|
-- Lumen dynamic text is ALWAYS centred in its rect on BOTH axes
|
|
-- (FontImpl::GetAlignOffsetY = (boxH - textH)/2 for every align value the renderer emits;
|
|
-- research/lumen_text.md §3a), so the label texture is centred on the box centre.
|
|
-- raylib MeasureTextEx adds the glyph spacing after the LAST glyph too, so the ink centre of an
|
|
-- OutlinedText is (width - spacing)/2, not width/2 (Graphics/entry/MAPPING.md ROUND 4).
|
|
local frame = math.floor(tex.get_current_ms() / 1000 * 30) % 210
|
|
local g = self:guide_labels()
|
|
-- The pill must sit ABOVE the フリープレイ credit text, which the engine draws later
|
|
-- (coin_overlay.draw after script draw_overlays): hand the draw to coin_overlay.lua via a
|
|
-- shared-state global (one sol::state), consumed + cleared there every frame.
|
|
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 of the C++ option panel (modifier/top.png 420x538 at json (5,1080), open top 532):
|
|
-- arcade text_option_title = EditText font 32 centred on the board header; here centred on the panel
|
|
-- (x 215, header centre = json y 1080 + 49 + panel offset), 2P at +option_p2.x. The offset comes from
|
|
-- player:modifier_offset() (see draw_selector), so the title rides the panel in, while open and out
|
|
-- (engine: in = 548 px / 333 ms ease-out quadratic from json y; out = restart + song_select_offset.x).
|
|
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")
|
|
-- `song_select_main_diff_option` id 55 `text_option_title`: size 32, border 4.0
|
|
and text.create_text("modifier_title", { 255, 255, 255, 255 }, { 0, 0, 0, 255 }, false, ol(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 — LUA_CAPABILITIES items 19, 20, 21.
|
|
-- Measurements + arcade source: Graphics/song_select/MAPPING_option.md.
|
|
--
|
|
-- The engine calls this INSTEAD of ModifierSelector::draw() / NeiroSelector::draw()
|
|
-- (SongSelectScript::draw_option_panel, added round 14): defining the method turns
|
|
-- the C++ board off, the same contract draw_background / draw_box use. It has to be
|
|
-- an all-or-nothing takeover — the arcade paints the changed value box, the greyed
|
|
-- row and the row cursor UNDER the row texts, and `draw_overlays` runs above them.
|
|
--
|
|
-- Everything is drawn through the same texture.json bases and skin_config keys the
|
|
-- C++ board used (modifier/* at (5,1080) etc., modifier_offset.y = 61 row pitch,
|
|
-- modifier_text (300,1178), modifier_offset_2 (44,140), option_p2.x = 1490), so the
|
|
-- geometry is exactly what round 4/5 measured; what is new is the STATE:
|
|
-- * value box turns yellow when the row is not on its default (arcade
|
|
-- option_select_change_mc label "change": cmul (0,0,0,256) + cadd (255,255,0,0)),
|
|
-- * the row cursor pulses (arcade MC5 `cursor`: yellow SH3, alpha 256 -> 51 over
|
|
-- 29 frames and back over 30, a 60-frame = 1000 ms triangle),
|
|
-- * a row the engine will ignore is greyed (arcade MC17 frame "grayout": row bg
|
|
-- forced to flat RGB(166,168,171) plus a 50 % black scrim over the whole row —
|
|
-- today only 演奏スキップ with both drums in the enso),
|
|
-- * the arrow that was pressed nudges 5 px outward instead of bouncing forever.
|
|
local OPT_CURSOR_MS = 1000 -- arcade cursor MC5 loop = 60 frames @ 60 fps
|
|
local OPT_CURSOR_MIN = 51 / 256 -- its alpha floor (frame 29)
|
|
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 -- arcade arrow label "select": 0 -> -5 -> 0 over 10 frames
|
|
|
|
function SongSelect:_opt_ids()
|
|
if self._opt_ids_t then return self._opt_ids_t end
|
|
-- MUST load the subsets here. `script_manager.tex` (script.h) is its OWN
|
|
-- TextureWrapper, separate from the global `tex` the C++ screens use, so a
|
|
-- subset the engine loaded in load_screen_textures is NOT in the Lua one:
|
|
-- measured on this screen, `box` was resident (the wheel script loads it)
|
|
-- while `modifier`, `neiro` and `diff_select` were not, and every
|
|
-- draw_texture against them was a silent no-op (draw_texture returns early
|
|
-- when the id is missing). tex.get_id only reads the generated enum, so it
|
|
-- can never be used as a "is it loaded" test - get_texture_info can.
|
|
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"),
|
|
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 become OutlinedText exactly as ModifierSelector::make_text
|
|
-- built them (modifier_text.font_size, white fill, black edge, thickness 3, spacing 2),
|
|
-- cached by content: the value set is small and closed (on/off, 1.0..4.0, the neiro
|
|
-- names, きまぐれ / でたらめ).
|
|
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
|
|
-- `song_select_main_diff_option` ids 13/15 `text_item_name`/`text_option_name`: size 26, border 3.0
|
|
t = text.create_raw_text(s, self._opt_font, { 255, 255, 255, 255 }, { 0, 0, 0, 255 }, false, ol(3.0), 2.0) or false
|
|
self._opt_texts[s] = t
|
|
end
|
|
return t or nil
|
|
end
|
|
|
|
-- Arcade cursor pulse — ROUND 53 (r53-anim-lumen-sweep): sampled from the cabinet's own
|
|
-- timeline. `Scripts/anim/option_cursor.lua` is MC5 `cursor` of
|
|
-- `enso_normal/song_select/song_select_main_diff_option.nulm` exported per-frame: alpha
|
|
-- 1.0 -> 0.1992 over f0..29 and back over f29..59 (60 f = 1000 ms loop). The old hand
|
|
-- triangle put the floor at 500 ms; the cabinet's floor is f29 = 483.3 ms — the halves are
|
|
-- 29/30 frames, not 30/30. Fail-soft: the NULL clip returns nil and the hand triangle
|
|
-- below is the fallback (ROUND 8 rule). NB: this block sits ABOVE the file's shared
|
|
-- `glow_anim` require (~l.760), so it guards its own require — a later local is not in
|
|
-- scope up here and referencing it would kill the whole screen script at load.
|
|
local _oc_ok, _oc_anim = pcall(require, "anim/sampler")
|
|
local OPT_CURSOR_CLIP = (_oc_ok and _oc_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` is the modifier_rows() shape ({label, value, changed, greyed, name});
|
|
-- `cursor` is the 1-based row the cursor is on (nil = none, e.g. after the decide).
|
|
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": the row plate becomes an opaque flat grey. mod_bg.png
|
|
-- carries the arcade's 154/256 white, so the grey is laid on top with the
|
|
-- (233/256) highlight plate to get close to opaque without a new texture.
|
|
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
|
|
|
|
-- The engine's own row icon (the arcade board has none; this keeps the
|
|
-- PyTaikoGreen affordance the skin already shipped).
|
|
-- row.state is the raw engine value (modifier_speed / modifier_random /
|
|
-- 0-1 for a bool), so the icon never depends on the localised `value` string.
|
|
local icon
|
|
local st = row.state or 0
|
|
if row.name == "speed" 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
|
|
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.
|
|
-- The arcade has no separate 音色 board — 音色 is selector_8 of the option board
|
|
-- (song_select_option_selector.lua sessionIndex 8) — so the standalone panel, which
|
|
-- the engine cursor can still reach (Difficulty::NEIRO), is drawn as the same board
|
|
-- with a single 音色 row.
|
|
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
|
|
-- No row LABEL here: `tex.get_skin_config` returns geometry only, not the
|
|
-- localised string, and NeiroSelector has no lua_rows() to take it from.
|
|
-- The 音色 row inside the option board (kind 1) does carry its label.
|
|
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 (enso_normal/song_select/song_select_bg, 2880x1080 per genre)
|
|
--------------------------------------------------------------------------------
|
|
function SongSelect:draw_background(nav)
|
|
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).
|
|
-- cen_h = 52 closed, 240 open (arcade song_board: centre scaled 0.22 -> 1.0).
|
|
--------------------------------------------------------------------------------
|
|
-- ROUND 5: the engine loads textures with mipmaps + TRILINEAR filtering, so squashing the 240-row centre
|
|
-- slice to 52 rows (closed bar) made the GPU sample a coarse mip level -> the board's left/right ends
|
|
-- (outline, rounded panel edge) came out blurred. The centre rows of bar_genre / bar_genre_back are
|
|
-- vertically uniform, so a closed/partly-open board now takes a cen_h-row slice out of the middle of the
|
|
-- centre region and draws it 1:1 (crop_center = true, default). folder_graphic's open layout (title band
|
|
-- on top) is not uniform -> pass crop_center = false while it animates (scaled, cross-faded under the
|
|
-- closed look) and it is 1:1 anyway when fully open.
|
|
-- ROUND 61 (r61-genreanim-from-chn-source): `sx` is the board's HORIZONTAL scale about
|
|
-- its own centre, the channel the folder-ENTER animation drives (0.025 -> 1 over clip
|
|
-- f115..f128 of common_song_select_main_song_board sprite 222; see anim/genre_open).
|
|
-- Every draw_texture dest rect is `mapping.x2 * scale + params.x2` wide
|
|
-- (src/libs/texture.cpp:707), i.e. params.x2 is an ADDITIVE width delta on the natural
|
|
-- width -- exactly how the existing `y2 = h - 352` calls already drive the height. So a
|
|
-- horizontal scale is `x2 = BOARD_W * (sx - 1)` plus a re-centred x, with no engine
|
|
-- change. sx = nil/1 leaves every pre-r61 call byte-identical.
|
|
local function draw_board(bar_id, frame, bx, by, cen_h, fade, crop_center, sx)
|
|
local w = BOARD_W
|
|
local x2 = nil
|
|
if sx and sx ~= 1.0 then
|
|
w = BOARD_W * sx
|
|
x2 = w - BOARD_W
|
|
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
|
|
tex.draw_texture(bar_id, { frame = frame, x = x, y = top, y2 = CAP_H - 352, x2 = x2, fade = fade,
|
|
src = { x = 0, y = 0, width = BOARD_W, height = CAP_H } })
|
|
tex.draw_texture(bar_id, { frame = frame, x = x, y = top + CAP_H, y2 = cen_h - 352, x2 = x2, fade = fade,
|
|
src = { x = 0, y = src_y, width = BOARD_W, height = src_h } })
|
|
tex.draw_texture(bar_id, { frame = frame, x = x, y = top + CAP_H + cen_h, y2 = CAP_H - 352, x2 = x2, fade = fade,
|
|
src = { x = 0, y = CAP_H + CEN_SRC_H, width = BOARD_W, height = CAP_H } })
|
|
end
|
|
|
|
-- Yellow cursor glow around the board (arcade cursor_top/center/bottom): the glow's caps
|
|
-- start 24px outside the board caps and its centre stretches with the board centre.
|
|
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))) -- ROUND 5: 1:1 slice, no mip blur
|
|
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
|
|
|
|
-- ROUND 51: open_progress (the engine's raw 233 ms open_anim ramp) removed - every wheel
|
|
-- panel now runs the cabinet's own select_on/select_off timelines on the shared
|
|
-- wall-clock machinery below (wheel_open_t / wheel_close_step).
|
|
|
|
-- ROUND 8 — the cursor glow's pulse. Round 5 wrote `0.75 + 0.25*sin(2*pi*t)`, a 1 Hz sine
|
|
-- between 0.75 and 1.0, and Graphics/song_select/MAPPING.md recorded it as "Pulses 0.75-1.0".
|
|
-- Both are wrong. `common_song_select_main_song_board.nulm` char 16 (the cursor glow MC that
|
|
-- cursor_top/center/bottom all instance) is a 60-frame loop in which the pulsing gradient --
|
|
-- `#12@0`/`#12@1`, i.e. `cursor_center__shape12_g0`, which is exactly what
|
|
-- `bar_genre_overlay.png` is a bake of -- runs 1.0 -> 0.0 -> 1.0, quadratic in over 29 frames
|
|
-- and quadratic out over 30. (The third leaf `#14@2` barely moves, but it is NOT in our
|
|
-- bake, so the plate must carry the full 1 -> 0 swing.) Sampled from `anim/cursor_glow`.
|
|
-- ROUND 8: fail soft. `pcall` the require and use `anim.safe`, which hands back a NULL
|
|
-- clip when a generated table is missing or malformed, so every reader below falls
|
|
-- through to its `or <constant>` default instead of taking the screen down.
|
|
local _ga_ok, glow_anim = pcall(require, "anim/sampler")
|
|
if not _ga_ok then glow_anim = { safe = function()
|
|
-- sampler.lua itself is missing: hand back the same NULL clip shape it would
|
|
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 (see draw_plates)
|
|
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 (CS/main_genre_board 'board' timeline, 60 fps, decoded frame by frame):
|
|
-- select_on (f5) -> hold 3 f -> f8..f13 board grows LINEARLY (centre 0.217 -> 1.0, caps +-26 -> +-120,
|
|
-- title 0 -> -94, cursor glow alpha 0 -> 1); characters slide +-340 -> +-450 and fade in with a
|
|
-- quadratic ease-out over the same 5 f, then settle 450 -> 440 (quadratic ease-in, f13..f16);
|
|
-- intro text fades in linearly f13..f18 (83 ms); 'on' = f20.
|
|
-- select_off (f30) -> f30..f35 characters fade out (alpha 1-u^2) and drift 440 -> 390 (50*u^2),
|
|
-- intro text fades out linearly; f33..f38 board shrinks linearly back to closed (title/glow follow).
|
|
-- The engine gives Lua open_anim (0 -> 1 linear over 233 ms, after the 133 ms slide) and no close
|
|
-- animation, so: open = timeline driven by t = p * 233 ms; close = own timer keyed by folder name,
|
|
-- started the frame a folder that was drawn open is drawn closed.
|
|
-- ROUND 7 — the four channels below are no longer hand-fitted closed forms: they are
|
|
-- SAMPLED out of the cabinet's own timeline, exported by scratchpad/anim_export.py from
|
|
-- `common_songselect/common_song_select_main_genre_board.nulm` sprite 73 (labels
|
|
-- init 0 / off 1 / select_on 5 / on 20 / select_off 30 / genre_deceide 45 / return 150).
|
|
-- See ANIM_PIPELINE.md. What the export changed (everything else round 5/6 derived by
|
|
-- hand was confirmed exact, to the 1/256 the engine quantises alpha to):
|
|
-- * CLOSE used to hold the open board for 5 frames after `select_off` and then shrink
|
|
-- over 5 (FCLOSE_FADE = FCLOSE_SHRINK = 83 ms). The cabinet holds for THREE frames
|
|
-- (f30..f33) and shrinks f33..f38, so ours ran 33 ms late and 33 ms long.
|
|
-- * the whole close is 8 frames (133 ms), not 10 (166 ms).
|
|
local folder_anim = glow_anim -- ROUND 8: same guarded module (see line ~435)
|
|
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 in "closed" state between navigate() and expand_box()
|
|
-- completing. Comes from the C++ call set_positions(false, 166) in SongSelectPlayer.
|
|
-- NOT a Lua constant — if the engine changes this, update NAV_REOPEN_MS to match.
|
|
local NAV_REOPEN_MS = 166
|
|
-- Threshold used in draw_folder_closed to distinguish a transient navigation closure
|
|
-- (< NAV_REOPEN_MS) from a genuine closed state that should reset the open-animation
|
|
-- timer. Must be > max(FOLDER_CLOSE_MS, NAV_REOPEN_MS). Currently both are <233 ms.
|
|
local STALE_TIMER_MS = math.max(FOLDER_CLOSE_MS, NAV_REOPEN_MS) + 50
|
|
-- ROUND 39 (r39-genreanim-recheck): the cabinet does NOT start growing the newly-focused
|
|
-- board as soon as the cursor lands. song_select_song_main.lua's `MoveCursor` (fired by
|
|
-- real INPUT_1P_LEFT/RIGHT, not an attract-only path -- ROUND 32's "attract-mode-only /
|
|
-- IsAllOnpuEnd" claim for this constant does not match the source; that string does not
|
|
-- appear anywhere in song_select_song_main.lua) sets state "Scroll", which sets
|
|
-- `openPanelWaitCount = kopenPanelWaitCount` (61, line 27) and returns to "Main". Every
|
|
-- Main tick calls `ProcKanbanWaitCountToSelectOn` (line 1394-1413), which counts
|
|
-- `openPanelWaitCount` down to 0 before it ever calls `GetGenreKanbanMc/GetSongKanbanMc
|
|
-- :GotoAndPlay("select_on")` (line 1408/1410) -- i.e. the board stays fully closed for
|
|
-- 61 script-logic ticks after the input, at the established Common.FPS = 120 script clock
|
|
-- (this session's ground truth; see ENGINE_BINDINGS.md), = 508.3 ms, before growth even
|
|
-- begins. ROUND 17's wall-clock timer instead started growth as soon as the C++
|
|
-- position-slide finished (NAV_REOPEN_MS = 166 ms after navigate()), ~342 ms too early --
|
|
-- the whole open animation finished around navigate()+400ms instead of the cabinet's
|
|
-- navigate()+741ms, reading as rushed/foreshortened rather than a bug-free replay of the
|
|
-- ROUND 17 growth curve (which is itself correct, confirmed by re-measurement this round).
|
|
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
|
|
-- the centre band's scaleY at the two rest states, so `pb` stays the 0..1 the rest of this
|
|
-- file (and the parent skin's board crossfade) already speaks
|
|
local SY_CLOSED, SY_OPEN = 0.2168, 1.0
|
|
local function clamp01(v) return math.max(0.0, math.min(1.0, v)) end
|
|
|
|
-- ROUND 51 (r51-genreanim-chn-port): the song-board timeline, exported the same way as
|
|
-- the genre one (common_song_select_main_song_board.nulm sprite 222, labels identical to
|
|
-- sprite 73's: init 0 / off 1 / select_on 5 / on 20 / select_off 30 / genre_deceide 45).
|
|
-- Growth f8..f13 matches the genre board; song_kanban_info (diff plates/subtitle/BPM row)
|
|
-- fades in AFTER the growth (f13 a=0.5 -> f18 a=1); instance_icon (NEW) fades out f5..f10
|
|
-- on open and back in f38..f43 after the close shrink.
|
|
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 -- f30..33 hold, f33..38 shrink, f38..43 NEW icon fade-back
|
|
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
|
|
|
|
-- ROUND 56 (r56-result-songselect-polish): the cabinet's folder enter/exit WHEEL
|
|
-- choreography — the "rotation-like" motion of the the reference build 分類開啟 the user reported.
|
|
-- Source: enso_normal/song_select/song_select_main_genre.nulm sprite 19 (the kanban
|
|
-- container that owns the 8 wheel slots), label `genre_deceide` f45..f150:
|
|
-- * f82..f99 every NON-focused slot slides exactly 3 slot pitches OUTWARD along the
|
|
-- wheel's tilted line (quadratic ease-in, outer slots first, ~13 f each) — since all
|
|
-- bars sweep together along the drum axis, the wheel reads as ROTATING away;
|
|
-- * f99..f128 hold off-screen while the slots swap content (genre -> folder songs);
|
|
-- * f128..f145 they ease back IN, inner slots first (`return` f150 = at rest).
|
|
-- No slot carries any rotation matrix (verified: every m = [a,0,0,d], rot = 0 across
|
|
-- f0..f150 of both sprite 19 and the board sprite 73) — the "rotation" is this
|
|
-- coordinated diagonal sweep. This engine opens folders INLINE (the songs insert into
|
|
-- the live wheel), so the out-leg has no counterpart content to show; what maps 1:1 is
|
|
-- the second half: from the moment the folder context flips, boxes sit swept-out and
|
|
-- fly back in on the cabinet's own curves. t0 -> clip f99 (hold), so the fly-in starts
|
|
-- 483 ms later and lasts 283 ms, exactly the cabinet's post-swap timing.
|
|
-- Slot geometry: this skin's wheel (song_select_row_pitch 135/120, curve 40, MAPPING.md
|
|
-- R29) matches the cabinet slot table (pitch 135/255-center, curve 40/50) 1:1, so clip
|
|
-- deltas are used unscaled. FAIL-SOFT: missing table -> zero offset (pre-r56 look).
|
|
local WHEEL_FLY = folder_anim.safe("anim/wheel_decide")
|
|
-- ROUND 61: was 99. R56 anchored the folder-context flip on "bars fully swept out"
|
|
-- (sprite 19 f99), but the reference build pins the flip to the CONTENT SWAP, which OpenFolderState
|
|
-- (ai_song_select_song_main.lua:350) performs when the board clip reads frame 114 --
|
|
-- and f114 is inside sprite 19's own swap hold (f99..f128), so the two clips agree.
|
|
-- Anchoring at 99 started the wheel's return leg 15 frames (250 ms) early, which also
|
|
-- put it out of phase with the board unfurl that shares the same genre_deceide play.
|
|
-- ROUND 85: FLY_HOLD_F0 removed -- the fly rides the shared leg clock (see fly_offset).
|
|
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 -- the 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
|
|
-- one-binary A/B, same env-gate pattern as ROUND 45/48/55. NB skin Lua runs
|
|
-- without sol::lib::os, so this reads the NEW r56 engine binding tex.get_env
|
|
-- (script.cpp); on an older exe the pcall fails soft -> gate off (arcade path).
|
|
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
|
|
-- ROUND 63 (r63-duplicate-song-box): env-gated per-frame wheel trace, see draw_box.
|
|
local R63_DBG = read_env("YATAIDON_R63_DEBUG") ~= nil
|
|
local _r63_ms, _r63_seen, _r63_n = nil, nil, 0
|
|
|
|
-- ROUND 63 (r63-duplicate-song-box) -- a wheel animation-timer key MUST be unique
|
|
-- per BOX. `text_name` is not: TJAParser deliberately STRIPS "-New Audio-" /
|
|
-- "-Old Audio-" (and 「新曲」/「旧曲」) out of TITLE and raises
|
|
-- ex_data.new_audio / ex_data.old_audio instead (src/libs/parsers/tja.cpp:460),
|
|
-- so the two charts of one song carry the SAME display title; a library can also
|
|
-- hold two genuinely different charts under one title (「1・2・3」 vs
|
|
-- 「1・2・3 (2019)」). When two boxes share a key they share ONE open/close clock,
|
|
-- and the focused twin's per-frame wheel_open_t() clears wheel_close_at/
|
|
-- wheel_close_start every frame, so the unfocused twin re-enters
|
|
-- wheel_close_step at e = 0 forever -- i.e. it is redrawn as a FULLY OPEN,
|
|
-- glowing board that never closes. That is the user's 「同時選兩首歌」.
|
|
-- `box:path()` is the r63 engine binding (box_lua_bindings.cpp); the pcall keeps
|
|
-- an older binary on the pre-r63 title key instead of erroring.
|
|
local function box_id(box)
|
|
local ok, p = pcall(function() return box:path() end)
|
|
if ok and p and p ~= "" then return p end
|
|
-- Pre-r63 binary: no path binding. Fall back to the strongest identity the
|
|
-- OLD bindings can build -- title + subtitle + ex_data flag. That alone
|
|
-- separates the -New Audio- / -Old Audio- pair the user reported (flag 1 vs
|
|
-- 2), so the defect is fixed even on a binary that predates this round; it
|
|
-- is only a weaker key for two charts that agree on all three.
|
|
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
|
|
-- ROUND 61 (r61-genreanim-from-chn-source) -- the folder-ENTER leg, from the the reference build source.
|
|
--
|
|
-- What the five previous rounds on this element could not see: anim/folder_board and
|
|
-- anim/song_board were exported `--range 0,50`, and `genre_deceide` is label f45 on BOTH
|
|
-- clips. Sprite 73 is 255 frames long and sprite 222 is 300; the exports therefore carry
|
|
-- five frames of a leg that runs to f255 / f150, and in those five frames nothing moves.
|
|
-- Every round since R17 has been tuning the WHEN of an animation whose WHAT was missing.
|
|
--
|
|
-- the reference build authority (the reference research notes, the reference-sourced
|
|
-- cabinet script; the non-AI song_select_song_main.tlb is structurally identical):
|
|
-- :1027 SelectGenreFolder -> GetGenreKanbanMc(kSelectNumber+openOffset)
|
|
-- :GotoAndPlay("genre_deceide") (+ the song board, + wheel)
|
|
-- :25 kOpenFolderChangeFrame = 114
|
|
-- :20 kCloseFolderChangeFrame = 219
|
|
-- :350 OpenFolderState polls `GetGenreKanbanMc(..).current_frame == 114`, and only
|
|
-- THEN calls SetSongDataAll() -- the genre->song content swap -- and sends the
|
|
-- board to frame 219 (reveal) or "off".
|
|
-- The genre board's slab goes alpha 0 at f113, one frame before that test, so the swap is
|
|
-- hidden behind a blank board. Our engine opens folders inline and `Navigator:
|
|
-- current_folder()` flips at exactly that swap, so our folder-context event IS clip f114.
|
|
--
|
|
-- The motion itself (anim/genre_open, sampled over the real extent):
|
|
-- the incoming song board is placed at f113 already at FULL HEIGHT but sx = 0.025 --
|
|
-- a vertical hairline -- and unfurls sideways to sx = 1 over f115..f128 (13 frames,
|
|
-- 217 ms), ease-in. Its title then fades in f132..f145. Every content node is alpha 0
|
|
-- for the whole unfurl, so only the slab is drawn.
|
|
-- That horizontal unfurl is the "rotation-like" motion this element keeps being reported
|
|
-- for. R56 checked for a rotation matrix and correctly found none (every m = [a,0,0,d]);
|
|
-- it looked for the wrong quantity, and only inside f0..f150 of sprite 19 / f0..f50 of
|
|
-- sprite 73 -- the sx sweep is on sprite 222 at f115..f128.
|
|
-- All of ROUND 61's state lives in ONE local: this file is already close to Lua's
|
|
-- 200-locals-per-function limit at chunk scope (adding nine plain locals here overflowed
|
|
-- it -- "too many local variables" at load time, i.e. the whole skin fails to parse).
|
|
local R61 = {
|
|
-- one-binary A/B gate, same env pattern as R45/48/55/56. Both names are
|
|
-- honoured so an existing probe script keeps working.
|
|
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, -- anim/genre_open, or nil -> every consumer falls back
|
|
-- ROUND 85: the leg's own frame clock. `t0` is the wall-clock ms at which
|
|
-- frame `f0` was showing; `kind` is "open" or "close"; `swapped` says the
|
|
-- engine has told us the content swap happened, which is the ONLY thing
|
|
-- that lets the clip past its pre-swap hold.
|
|
t0 = nil, f0 = 0, kind = nil, swapped = false,
|
|
-- clip landmarks (overwritten from the generated table)
|
|
open_f0 = 45, open_swap = 114, open_end = 145,
|
|
close_f0 = 150, close_swap = 219, close_end = 250,
|
|
-- event bookkeeping
|
|
seq = nil, -- last Navigator.wheel_event_seq we saw
|
|
have_events = false, -- engine actually exposes the r85 channel
|
|
snap_once = false, snap_ms = nil, -- one-shot open modes, armed by events
|
|
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
|
|
end
|
|
end
|
|
-- Linear sampler over the generated {frame, v1, v2...} rows. Fail-soft: an absent table
|
|
-- or track returns nil and every consumer falls back to its pre-r61 path.
|
|
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
|
|
-- ANCHORING -- ROUND 85, and this is the structural half of this round.
|
|
--
|
|
-- On the cabinet the swap is instantaneous: at clip f114 OpenFolderState calls
|
|
-- SetSongDataAll() and the boards the script is ALREADY holding simply change what they
|
|
-- display. This engine loads the opened folder's songs on a loader thread
|
|
-- (Navigator::load_songs_inline_async), so the moment `current_folder()` flips there is
|
|
-- nothing to unfurl -- ROUND 61 proved it: a leg timed off the flip logged `armed` and
|
|
-- then ZERO drawn frames. R61's workaround was to let the flip only ARM the leg and
|
|
-- start the clock on the first frame a song board happened to be drawn, i.e. the
|
|
-- animation's start was a function of loader latency.
|
|
--
|
|
-- It is now a function of a real engine event. `Navigator` emits
|
|
-- WHEEL_EVENT_OPEN_BEGIN from begin_inline_load() (= SelectGenreFolder, clip f45) and
|
|
-- WHEEL_EVENT_OPEN_SWAP from flush_pending_boxes() at the instant the folder's boxes
|
|
-- exist, are sorted and are placed (= SetSongDataAll, clip f114); CLOSE_BEGIN/CLOSE_SWAP
|
|
-- are the same two points on the `return` leg (f150 / f219). Between BEGIN and SWAP the
|
|
-- clip is CLAMPED one frame short of the swap, so the cabinet's own pre-swap window --
|
|
-- 69 frames / 1150 ms of hold, contents fade-out and the genre board's horizontal FURL --
|
|
-- is what covers the load, exactly as the brief's "hold the transition until the boxes
|
|
-- are ready" option. The load is not serialised behind it: the loader still starts when
|
|
-- the engine's genre_bg move finishes (~1100 ms in, i.e. ~f111) and runs during the
|
|
-- clip's own blank stretch. If the load is slower than the clip, the board simply waits
|
|
-- at f113 -- blank, which is what the cabinet shows there anyway.
|
|
--
|
|
-- Result: the unfurl ALWAYS begins on the swap frame and always lasts 13 clip frames.
|
|
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 one frame short of the swap until the engine says it happened
|
|
local hold = (R61.kind == "close") and (R61.close_swap - 1) or (R61.open_swap - 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
|
|
|
|
-- Start / advance the leg. `begin` puts the clock on the label frame; `swap` re-anchors
|
|
-- it on the cabinet's content-swap frame so the post-swap half is phase-exact no matter
|
|
-- how long the loader took.
|
|
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
|
|
elseif id == 4 then -- OPEN_SWAP
|
|
R61.t0, R61.f0, R61.kind, R61.swapped = now, R61.open_swap, "open", true
|
|
R61.snap_once, R61.snap_ms = true, now -- OpenFolderState ends GotoAndPlay("on")
|
|
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 -- CloseFolderState ends GotoAndPlay("on")
|
|
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: "hold" is open_start_mode's default.
|
|
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 -- log sampled offsets (verification aid)
|
|
|
|
-- ROUND 85: the wheel clip (song_select_main_genre.nulm sprite 19) carries the SAME two
|
|
-- labels at the SAME frames as both board clips -- `genre_deceide` f45, `return` f150
|
|
-- (label tables re-read this round) -- so one GotoAndPlay drives all three off ONE clock.
|
|
-- The fly therefore rides `leg_f()` directly and FLY_HOLD_F0 is gone: R61 moved it 99->114
|
|
-- to line the fly up with the swap, but with a real shared clock there is nothing to line
|
|
-- up by hand. R56 only ever shipped the fly-IN half (this engine's INLINE folder model
|
|
-- has no content for the out-half, and the engine runs its own box slide before the swap),
|
|
-- so the offset stays zero until the swap event and then plays f114..f145 as before.
|
|
local function fly_offset(box)
|
|
if R56_DISABLED then return 0.0, 0.0 end
|
|
local f = leg_f()
|
|
if not f or not R61.swapped or R61.kind ~= "open" then return 0.0, 0.0 end
|
|
if f >= FLY_END_F then 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
|
|
|
|
-- ROUND 51: how a FRESH open animation starts is not one-size-fits-all. From the real
|
|
-- the reference build song_select_song_main.lua state machine (scratchpad dec/, verified this round):
|
|
-- * cursor move Scroll -> Main -> ProcKanbanWaitCountToSelectOn (61-tick hold,
|
|
-- 508 ms) then GotoAndPlay("select_on") -> mode "hold"
|
|
-- * scene entry SecondLoading plays select_on IMMEDIATELY (l.218) -> mode "nohold"
|
|
-- * folder enter/exit OpenFolderState (l.369/371) and CloseFolderState (l.433) end with
|
|
-- GotoAndPlay("on") - the focused board SNAPS fully open, no growth
|
|
-- -> mode "snap"
|
|
-- * back from course ReLoading + CourseBackFlag (l.256-257) plays select_on
|
|
-- select immediately -> mode "nohold"
|
|
-- ROUND 17/39 played hold+growth in every case, which is why the board re-ran a 508 ms
|
|
-- dead hold + growth every time a folder was entered or left - visibly wrong vs cabinet.
|
|
-- ROUND 85: these one-shots are no longer armed by change-detection and TTL-bounded
|
|
-- guesses -- they are armed by the engine's real transitions (see leg_event / R61.snap_*).
|
|
-- The TTLs stay as a belt-and-braces bound only.
|
|
local _diff_seen_ms = nil -- last ms a diff_select box was drawn (legacy fallback)
|
|
local _last_box_draw_ms = nil -- any-box draw clock (scene-entry / long-away detect)
|
|
|
|
-- One-shot flags expire so a mis-armed flag can never leak onto a much later
|
|
-- cursor-move open: the engine's folder transition and the scene intro both put the
|
|
-- next fresh open well inside these windows.
|
|
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: only reachable on a binary with no r85 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 (ROUND 17/39 folder machinery,
|
|
-- generalized to every panel kind this round). Keys are "<kind>\0<name>" so a song
|
|
-- that happens to share a folder's title cannot collide.
|
|
local wheel_open_start = {} -- key -> ms the open timer started
|
|
local wheel_open_mode = {} -- key -> "hold" | "nohold" | "snap"
|
|
local wheel_open_seen = {} -- key -> last ms drawn open
|
|
local wheel_close_at = {} -- key -> ms the close animation started
|
|
local wheel_close_start= {} -- key -> ms the box first entered closed state this run
|
|
local wheel_last_draw = {} -- key -> last ms drawn open (gap/off-screen detection)
|
|
local wheel_last_fresh_key, wheel_last_fresh_ms = nil, nil -- most recent fresh timer start
|
|
|
|
-- Returns t in ms into the select_on growth window (0..233, clamped), or nil while the
|
|
-- cabinet's 508 ms pre-growth hold is still running (draw fully closed).
|
|
local function wheel_open_t(key)
|
|
local now = tex.get_current_ms()
|
|
wheel_close_start[key] = nil -- box is open; reset closed-state clock
|
|
wheel_open_seen[key] = now
|
|
wheel_close_at[key] = nil
|
|
|
|
-- Gap detection (ROUND 17): off-screen for > NAV_REOPEN_MS with the animation already
|
|
-- finished -> stale timer, reset so the replay starts from scratch.
|
|
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 the close animation is running, 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
|
|
|
|
-- shared reader: `f` is an ABSOLUTE arcade frame on sprite 73's timeline.
|
|
-- pb = board progress 0..1, ch_alpha = character alpha, ch_off = character offset from the
|
|
-- centre, 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
|
|
|
|
-- t = ms since the engine open started (p * 233).
|
|
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
|
|
|
|
-- ROUND 51: same reader for the song board. pb = board progress, ia = contents alpha
|
|
-- (song_kanban_info: diff plates / subtitle / BPM), ic = NEW-icon alpha (instance_icon).
|
|
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 art) at arcade positions. closed: (±425, -27)/(+27) at 0.75;
|
|
-- open: (±425, -114)/(-42) at 1.0. p interpolates.
|
|
-- ROUND 56 (r56): rebuilt on the arcade rule (song_select_song_disp_data.lua SetCrown :311
|
|
-- + SearchAnyCrown :521, R49 audit row closed). ONE badge column PER PLAYER — crown_1
|
|
-- hangs at -425, crown_2 at +425 — and each player's crown shows their CURRENTLY
|
|
-- SELECTED course (cardPlayerSelectedCourse -> the last diff-select column this skin
|
|
-- already caches in _mark_diff, from player:selected_difficulty()), falling back to the
|
|
-- ascending any-crown walk where the LAST hit wins (= highest difficulty with a crown).
|
|
-- The rank chip follows the same per-player choice (this engine has one local score DB,
|
|
-- so both players read the same records — the seat split is still the cabinet's layout).
|
|
-- ROUND 56 (r56): NextSongIcon rotation (song_select_song_disp_data.lua NextSongIcon
|
|
-- :1107 + SearchNextIcon :1306; R49 audit row). The cabinet rotates the board's
|
|
-- instance_icon once per second (wait_icon_change_cnt = 1*Common.FPS) through every
|
|
-- icon that applies to the song, in the fixed cycle dani -> new -> papamama ->
|
|
-- official -> compe -> challenge. This engine's data model exposes only `is_new`
|
|
-- today — there is no song->dan membership at wheel time (chart hashes are computed
|
|
-- lazily on play, so a dan.json hash match would silently miss unplayed songs), no
|
|
-- papamama/演奏skip concept, and no compe/challenge — so the applicable list holds at
|
|
-- most one entry and the cycle is latent. The machinery IS the cabinet's: any future
|
|
-- engine flag joins the rotation by appending its name + art here.
|
|
local ICON_ARTS = nil -- name -> texture id (resolved lazily; only icon_new has art)
|
|
function SongSelect:song_icon(box)
|
|
if ICON_ARTS == nil then ICON_ARTS = { icon_new = self._id_new_icon } end
|
|
local list = {}
|
|
-- icon_dani: no engine counterpart (see block comment)
|
|
if box.is_new then list[#list + 1] = "icon_new" end
|
|
-- icon_papamama / icon_official / icon_compe / icon_challenge: no engine counterpart
|
|
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 -- SearchAnyCrown: ascending walk, latest set wins
|
|
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
|
|
-- solo play keeps the single left badge the cabinet shows for 1P
|
|
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
|
|
--------------------------------------------------------------------------------
|
|
-- ROUND 51 (r51-genreanim-chn-port): song boards now play the cabinet's own select_on /
|
|
-- select_off timelines (anim/song_board, sprite 222) on the same wall-clock + 508 ms
|
|
-- hold machinery as the folders, instead of the raw engine open_anim ramp (which started
|
|
-- 342 ms early and spread the crisp 50 ms plateau + 83 ms growth over a mushy 233 ms).
|
|
-- All states funnel through this one body:
|
|
-- pb = board progress (0 closed .. 1 open), from instance_board_center sy
|
|
-- ia = contents alpha (diff plates / subtitle / BPM / favorite), from song_kanban_info
|
|
-- ic = NEW-icon alpha, from instance_icon (fades out f5..f10, back in f38..f43)
|
|
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 -- r56 wheel fly-in
|
|
local fade = math.min(1.0, box.fade.attribute)
|
|
|
|
-- ROUND 61: the folder-ENTER unfurl. On the frames the cabinet is playing
|
|
-- genre_deceide f113..f145 the incoming song board is NOT growing vertically from
|
|
-- the closed slab -- it is already at full height and unfurls HORIZONTALLY from a
|
|
-- 2.5%-wide hairline (anim/genre_open song_board_sx). Contents are alpha 0 for the
|
|
-- whole sweep, so this returns before any of them are drawn; the title picks up on
|
|
-- song_title_a (f132..f145) once the slab is at rest.
|
|
-- Strictly additive: this branch OWNS the board only while the sweep is actually in
|
|
-- progress (clip f113..f127, sx < 1). From f128 the slab is at rest and every later
|
|
-- frame falls through to the normal body untouched, so the leg cannot regress the
|
|
-- glow / characters / icon / plates the rest of this function draws. (First cut also
|
|
-- took over the f128..f145 contents fade-in and dropped exactly those -- caught by the
|
|
-- YATAIDON_R61_DISABLE A/B at t=1000 ms, scratchpad/r61/t1000_{on,off}.png.)
|
|
-- ROUND 85: the same block now serves BOTH legs. Open: f113 placed at full
|
|
-- height sx 0.025, alpha 1 from f115, unfurls to sx 1 over f115..f128, contents
|
|
-- fade in f133..f145. Close (`return`, f150): contents fade out f188..f200, the
|
|
-- slab FURLS back sx 1 -> 0.025 over f205..f217 and goes alpha 0 at f218.
|
|
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 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
|
|
-- slab at rest: the contents ride the clip's own alphas rather than the
|
|
-- select_on curve, so the fade-in after the unfurl (f133..f145) and the
|
|
-- fade-out before the furl (f188..f200) are the cabinet's.
|
|
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)
|
|
-- r56: per-player crown/rank (SetCrown / SearchAnyCrown, see draw_badges)
|
|
self:draw_badges(box, bx, by, 0.0, fade)
|
|
-- instance_icon: the arcade hangs it off the board's left edge (-532); which
|
|
-- icon shows rotates once per second (r56 song_icon = NextSongIcon)
|
|
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
|
|
|
|
-- growing / open / closing board. Contents follow the cabinet's song_kanban_info
|
|
-- alpha (in f13..f18 AFTER the growth, out f30..f35 BEFORE the shrink), not the old
|
|
-- p >= 0.66 gate on the engine ramp.
|
|
local cfade = fade * ia
|
|
if cfade > 0 then
|
|
self:draw_plates(box, bx, by, cfade)
|
|
-- r56: badges are per player + selected course now (SetCrown), so the old
|
|
-- oni/ura crossfade pair is one call — the ura toggle switches the player's
|
|
-- selected course, which is what the cabinet's GotoAndStop follows.
|
|
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 -- user-tuned: -28 (arcade text_sub box y -53..+9 under the title box)
|
|
|
|
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 -- board label 'on' hides instance_icon (f5..f10 fade)
|
|
if icon then
|
|
draw_centered(icon, bx - 532, by + 4, 120, 96, { fade = fade * ic })
|
|
end
|
|
-- ROUND 63 (r63-duplicate-song-box): the 新音 / 旧音 (and 期間限定) marker.
|
|
-- TJAParser STRIPS the "-New Audio-" / "-Old Audio-" suffix out of TITLE and
|
|
-- raises ex_data.new_audio / ex_data.old_audio instead (tja.cpp:460), and the
|
|
-- engine's own C++ board draws the badge that carries that information
|
|
-- (box_song.cpp:430). This skin's Lua board replaced that draw and never
|
|
-- restored it, so two charts of one song were left with identical titles AND
|
|
-- no marker -- the 「還是只是歌名相同?」 half of the user's report. Art is the
|
|
-- parent skin's 224x64 pill, inherited at 1.5x like every other yellow_box
|
|
-- badge this skin does not override (MAPPING, yellow_box table). Drawn BEFORE
|
|
-- the favorite pill so the precedence matches box_song.cpp's else-if chain.
|
|
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")
|
|
-- Hung off the board's RIGHT edge at (bx+532, by+4), the mirror of the
|
|
-- instance_icon slot this file already uses at (bx-532, by+4). The
|
|
-- engine's own slot (the favorite pill's, texture.json 458,120) lands the
|
|
-- pill straight on top of the Ura difficulty chip on this skin's 960 px
|
|
-- board -- verified, scratchpad/r63/fix_d_back1.png. These parent-skin
|
|
-- textures carry a texture.json base that the engine ADDS and that the
|
|
-- parent loader already scaled by screen_scale, so it is subtracted back
|
|
-- out here (the draw_centered_b contract).
|
|
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) -- r63: per-BOX, not per-title (see box_id)
|
|
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 = icon (left column, centre -42) with the
|
|
-- course name (text_course_*, font 18) under it, ★ at +27 and the level number at +61 on the dark block.
|
|
-- Arcade labels: 'default' = easy/normal/hard at -182/0/+182, 'oni'/'ura' = 4 chips at -273/-91/+91/+273
|
|
-- (pitch 182, centred on the board); songs with fewer courses keep the same pitch, centred.
|
|
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
|
|
-- `main_song_board` `text_course_easy/normal/hard/oni/ura`: size 18, border 1.5
|
|
lbl = text.create_text(key, { 255, 255, 255, 255 }, { 40, 20, 20, 255 }, false, ol(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
|
|
-- ROUND 8: arcade song_kanban_info (`common_song_select_main_song_board.nulm` MC 78) label
|
|
-- 'ura' = f10..f189, a 180-frame (3 s) loop cross-fading the oni chip (depth 0) and the ura
|
|
-- chip (depth 1, on top). Sampled from `anim/song_board_ura` -- tracks
|
|
-- `oni/text_course_oni` and `ura/text_course_ura`.
|
|
-- What the export changed: round 5 read the four 15-frame ramps as starting at 1.00 / 1.25 /
|
|
-- 2.50 / 2.75 s. The cabinet starts each of them ONE FRAME EARLIER (ura up f69..f84, oni
|
|
-- down f84..f99, oni up f159..f174, ura down f174..f189 absolute), so every ramp ran 16.7 ms
|
|
-- late and the ura chip was still a sliver visible on the frame the loop wraps.
|
|
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
|
|
-- ROUND 8: the branch chip does NOT blink on the cabinet. `branch` is MC 36 of
|
|
-- common_song_select_main_song_board (and MC 10 of main_diff_board), a two-label
|
|
-- selector (`off` f0 / `on` f5) with a stop() on frame 0 -- `--auto-stop` on the
|
|
-- dumper shows it parked, and neither half animates. The 1 Hz blink round 5 wrote
|
|
-- here had no arcade source at all; drawn steady now.
|
|
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 (lumen depth order)
|
|
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) -- r63: per-BOX, not per-title (see box_id)
|
|
local t = wheel_open_t(key)
|
|
if t == nil then -- cabinet 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 (common_song_select_main_diff layout, stage root 960,540, board at y-132)
|
|
-- backboard 1272x784 centred (960,440); return (440,462) option (572,462) [neiro (440,602) added];
|
|
-- diff boards 200x360 at x = 745,960,1175,1390 y = 583; cursor bubble y = 370, x = slot -/+ 40
|
|
--------------------------------------------------------------------------------
|
|
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 (ROUND 5c, user-measured arcade +48..50)
|
|
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 at arcade frame 27 of the change
|
|
local URA_CELLS = 90 -- ROUND 4d: every arcade frame (60 cells/s)
|
|
local DS_BTN_Y = 462
|
|
local DS_CURSOR_Y = 370 -- player_cursor_1/2 row (same for both): board 442-72 = 426-56 = 370
|
|
local DS_NEIRO_Y = 602
|
|
|
|
-- main_diff: instance_mark_1p / _2p at board (+-760, 0) -> stage (200,408) / (1720,408), 680x680, label per
|
|
-- the player's cursor column (course_1..5; 'none' on the buttons). Drawn under the buttons/cards.
|
|
-- ROUND 16 (r16-select, QA defect #5): the mark is drawn BEFORE the backboard, not after.
|
|
-- Re-measured from the cabinet with `lumen_tools/inst.py common_songselect/common_song_select_main_diff 0 2`:
|
|
-- `common_song_select_main_diff` sprite 46 (`board`) frame 0 places
|
|
-- d0 instance_mark_2p (1720,408) d1 instance_mark_1p (200,408)
|
|
-- ... d6 instance_backboard (960,440) d7/d10 course_frame_N ... d14-17 instance_board_N
|
|
-- i.e. both marks sit at the BOTTOM of the board's display list, under `instance_backboard`.
|
|
-- The 680x680 mark spans x -140..540 / 1380..2060, so on the cabinet only the part OUTSIDE the
|
|
-- 1272x784 backboard (x 324..1596) is visible -- the plum/bamboo peeks out past the board's edge
|
|
-- and is hidden behind it. We drew it after the backboard, so its 154/256 alpha bled across the
|
|
-- board's left/right edge (QA `shots/select_c1/z_plum.png`, `select_c2/c2_03_p2_moved.png`).
|
|
local DS_MARK_X = { [1] = 200, [2] = 1720 }
|
|
local DS_MARK_Y = 408
|
|
|
|
function SongSelect:draw_song_diff_select_bg(box)
|
|
local fade = box.diff_fade_in.attribute
|
|
if self._id_diff_mark then
|
|
for p = 1, 2 do
|
|
local d = self._mark_diff[p]
|
|
if 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
|
|
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)
|
|
-- ROUND 51: course-back detection. The cabinet's ReLoading state (CourseBackFlag)
|
|
-- replays select_on IMMEDIATELY (no 508 ms hold) on the focused boards when the
|
|
-- player backs out of course select - a fresh open that starts within 300 ms of the
|
|
-- last diff_select draw takes mode "nohold" (see open_start_mode).
|
|
_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 (player.cpp toggle_ura_mode flips box.is_ura at once and plays
|
|
-- song_select/ura_switch = arcade appear_ura; the arcade TurnOniPanel plays oni_main change_ura/
|
|
-- change_oni for 90 frames, hides crown/rank and the toggling player's cursor meanwhile).
|
|
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: arcade shows only return (440,462); the engine's MODIFIER slot stays reachable
|
|
-- (player.cpp move_left: first course -> [NEIRO ->] MODIFIER -> BACK), so the wrench is kept but
|
|
-- small; the NEIRO slot is a faint frame (no label) only while it is still a separate panel.
|
|
draw_centered(self._id_btn_back, DS_SLOT_X[DIFF_BACK], DS_BTN_Y, 128, 128, { fade = fade })
|
|
-- ROUND 4d (user): option = same 128 px plain icon as return, full opacity (main_diff_other_board
|
|
-- 'option' / 'return' are the same-size boards; the cursor frame/glow geometry is shared in draw_selector)
|
|
draw_centered(self._id_btn_option, DS_SLOT_X[DIFF_MODIFIER], DS_BTN_Y, 128, 128, { fade = fade })
|
|
if self._id_btn_neiro then
|
|
draw_centered(self._id_btn_neiro, DS_SLOT_X[DIFF_NEIRO], DS_NEIRO_Y, 80, 80,
|
|
{ fade = fade * 0.35, x2 = 80 - 168, y2 = 80 - 168 })
|
|
end
|
|
|
|
-- 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
|
|
-- crown / rank badges (arcade: crown at ±58, rank at ±20, y -138)
|
|
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
|
|
-- level (ROUND 4d): difficulty_star.png = 12 cells 56x40: frame 0 = main_diff_board shape42 ★
|
|
-- (40x40, pure white) at cell x 0..40; frames 1..11 = the board's own diff_1..diff_11 glyphs
|
|
-- (pure white, no outline; local (0,-16)..(40|56,16)) at cell x 0, y 4. Lumen: ★ centred
|
|
-- (-20,+86), glyph origin (-4,+88) -> cells at (cx-40, y+66) and (cx-4, y+68). Dotted bar y+116.
|
|
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
|
|
-- ROUND 11: the centre was cx-67.5, i.e. the 24x24 cell landed on a HALF pixel
|
|
-- (x = cx-79.5). Textures load with mipmaps + TRILINEAR (texture.h), so a .5
|
|
-- offset costs a full 50/50 texel blend - this is why the dotted star bar read
|
|
-- softer than the cabinet's. Snapped to whole px (0.5 px shift, arcade-invisible).
|
|
draw_centered(self._id_dot_star, cx - 67 + k * 15, DS_BOARD_Y + 116, 24, 24, { fade = f })
|
|
end
|
|
if info.is_branching then -- ROUND 8: steady, not blinking (see draw_plates)
|
|
draw_centered(self._id_branch_diff, cx, DS_BOARD_Y + 144, 40, 40, { fade = f })
|
|
end
|
|
end
|
|
end
|
|
|
|
-- course names on the cards (main_diff_board text_course_title EditText: MS 明朝 34, box 160x80 at
|
|
-- (-78,-3)). ROUND 5c (user crops, arcade vs ours): the arcade name ink is ~6-7 % narrower than our
|
|
-- font-30 label and its centre sits at card y+48..50 (ours was +35) -> skin_config course_name_N
|
|
-- font 28, centred at DS_BOARD_Y + DS_COURSE_NAME_DY (49). (★/level unchanged.)
|
|
-- change animation cells over the oni slot (the cells carry the board art at 'none', so the card
|
|
-- under it is covered exactly like the arcade board MC during the change)
|
|
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 (main_diff text_title_song: MS 明朝 54, box 1000x60
|
|
-- at stage y 148..208 -> centre 178; text_song_title_sub: 32, box 1000x40 at y 222..262 -> centre 242),
|
|
-- both centred on x=960. ROUND 4d (user): BLACK outline + arcade size for the title -> Lua-made
|
|
-- raw text (box.text_name, font 48, outline 4) instead of the C++ name_large (song_box_name x1.5 = 63,
|
|
-- outline = genre fore_color). ROUND 5c: the subtitle also gets a BLACK outline (arcade
|
|
-- text_song_title_sub = plain black-edged white text, 32) -> `box.text_subtitle` (new SongBox binding
|
|
-- in box_lua_bindings.cpp) rendered here at font 30 / outline 3; the C++ `subtitle_large`
|
|
-- (fore_color outline) is only the fallback for an engine without the binding.
|
|
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
|
|
-- ROUND 6 (board text colours): main_song_board sprite 92 `text_song_title_sub` carries the SAME
|
|
-- per-genre colour add as the title, so the subtitle's edge is the genre colour too, not black.
|
|
-- ROUND 9: `_inside_frame` (from Navigator:current_folder()) wins inside an event folder.
|
|
local gf = self._inside_frame or box:genre_frame()
|
|
local key = gf .. "\0" .. str
|
|
if self._sub_key ~= key then
|
|
self._sub_key = key
|
|
-- `main_song_board` id 85 `text_song_title_sub`: size 24, border 3.5
|
|
self._sub_label = text.create_raw_text(str, 24, { 255, 255, 255, 255 }, board_outline(gf), false, ol(3.5), 2.0)
|
|
end
|
|
return self._sub_label
|
|
end
|
|
|
|
-- ROUND 6 (item 15): the song board title, arcade-exact (see SONG_TITLE_OUTLINE above).
|
|
-- Font size comes from skin_config `song_box_name` (42 = lumen id 82) so the size stays skinnable,
|
|
-- but WITHOUT the engine's >= 30-char shrink; outline is BLACK, not the genre fore_color.
|
|
-- Cached per title string (the engine caches one OutlinedText per box, so this is the same order
|
|
-- of cost); the cache is dropped wholesale past SONG_TITLE_CACHE_MAX so a long scroll cannot grow
|
|
-- it without bound.
|
|
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, ol(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
|
|
-- `common_song_select_main_diff` id 29 `text_title_song`: border 7.0 (its size is 54; we draw 48)
|
|
self._header_title = text.create_raw_text(name, 48, { 255, 255, 255, 255 }, { 0, 0, 0, 255 }, false, ol(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
|
|
-- `common_song_select_main_diff` id 27 `text_song_title_sub`: border 4.0 (its size is 32; we draw 30)
|
|
self._header_sub = text.create_raw_text(sub, 30, { 255, 255, 255, 255 }, { 0, 0, 0, 255 }, false, ol(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
|
|
-- `main_diff_board` id 13 `text_course_title`: size 34, border 4.5
|
|
lbl = text.create_text(key, { 255, 255, 255, 255 }, { 20, 20, 20, 255 }, false, ol(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 (1P red / 2P teal with the P icon) + yellow course frame / button glow.
|
|
-- ROUND 15 — TWO PASSES, because the cabinet splits this element across the boards.
|
|
-- `common_song_select_main_diff` sprite 46 depth order, measured:
|
|
-- d6 instance_backboard
|
|
-- d7 course_frame_1 <- the yellow selection frame ...
|
|
-- d10 course_frame_2
|
|
-- d12 instance_return_board / d13 instance_option_board
|
|
-- d14..d17 instance_board_1..4 ... the difficulty cards sit ON TOP of the frame
|
|
-- d21 player_cursor_1 / d22 player_cursor_2 <- the 1P/2P bubble is above everything
|
|
-- So the frame/button glow belongs UNDER the wheel and the bubble ON TOP of it. The engine
|
|
-- calls this twice per frame: `pass` 0 before Navigator::draw(), 1 after it. An engine that
|
|
-- calls it once (pass = nil) gets both, which is the pre-round-15 behaviour.
|
|
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 -- main_diff_mark course for draw_song_diff_select_bg (next frame)
|
|
end
|
|
if fade_in < 1.0 then return end
|
|
local mod_on = player:modifier_active()
|
|
-- ROUND 5: engine binding SongSelectPlayer:modifier_offset() (box_lua_bindings.cpp) = the exact y offset
|
|
-- ModifierSelector::draw adds to the modifier/* textures (slide-in, hold AND slide-out); nil = no panel.
|
|
-- Fallback for an exe without it: re-time the slide-in from the frame modifier_active() turned true.
|
|
if player.modifier_offset then
|
|
self._mod_off[p] = player:modifier_offset()
|
|
elseif mod_on then
|
|
if not self._mod_seen[p] then
|
|
-- the C++ ctor starts the slide clock, then stalls that frame ~80 ms building its texts, so use
|
|
-- the start of this frame (previous frame + 1/60 s) as the slide origin
|
|
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
|
|
-- song_select_course_main.lua PlayerMoveSelect: player_cursor_N takes tbl_DuoCursorMoveLabel
|
|
-- (pos_N, x = slot -+ 40) only while `player_num >= 2 and selectIndex[1] == selectIndex[2]`
|
|
-- (exactly the engine's `is_half`); otherwise tbl_SoloCursorMoveLabel (pos_center_N, x = 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 -- TurnOniPanel: 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: same 3-slice board, characters slide out from ±340 to ±450)
|
|
--------------------------------------------------------------------------------
|
|
-- folder board frame: arcade label per folder kind (event / sort_select / genre colour)
|
|
--
|
|
-- ROUND 9: engine bindings `BaseBox.collection` (the raw `#COLLECTION:` from
|
|
-- box.def) and `FolderBox:kind()` ("dan" / "sort" / "genre") replace the
|
|
-- display-name matching for everything the engine actually knows about
|
|
-- (LUA_CAPABILITIES item 26). The name tables stay as the fallback for the one
|
|
-- case the engine has no concept of - an *event* folder is not an engine idea,
|
|
-- there is no marker for it in box.def - and for skins/song trees that use
|
|
-- plain genre folders with those names.
|
|
-- SEARCH / DIFFICULTY -> the red sort_select board
|
|
-- NEW / RECOMMENDED -> the event board (arcade: 「ドドンと!新曲祭り!」/「おすすめ曲」)
|
|
-- RECENT / FAVORITE -> deliberately NOT remapped: the arcade gives them
|
|
-- their own adds, and frame 0 is the closest we ship.
|
|
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
|
|
|
|
-- ROUND 9 (MAPPING "ROUND 6 - board text colours", section 3): inside an EVENT
|
|
-- folder the arcade paints every song board with the `event` add (100,6,22),
|
|
-- not the song's own genre. That needed "which folder am I inside", which the
|
|
-- engine now exposes as `Navigator:current_folder()`. Refreshed once per frame
|
|
-- from draw_background (which is the only method that is handed the navigator)
|
|
-- and consumed by song_title / song_subtitle.
|
|
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
|
|
-- ROUND 85 -----------------------------------------------------------------
|
|
-- The real state-machine transitions, straight from the engine.
|
|
--
|
|
-- ROUND 51 built the five-entry open behaviour on `current_folder()` change
|
|
-- detection plus TTL'd one-shot flags and wrote, in its own trade-offs section,
|
|
-- that these were "behavioural approximations of state-machine transitions the
|
|
-- engine does not expose to Lua ... if the engine ever exposes real
|
|
-- OpenFolder/CloseFolder events, route the modes off those instead". ROUND 56
|
|
-- and ROUND 61 both kept that detection point. `Navigator` now emits the
|
|
-- transitions themselves (Navigator::WheelEvent, navigator.h) and both the open
|
|
-- modes AND the board/wheel animation clock hang off them.
|
|
--
|
|
-- A bumped counter rather than a queue: only the newest transition is read, which
|
|
-- is correct here because the legs it starts are hundreds of ms long and two of
|
|
-- them can never be one frame apart. pcall + `have_events` keep a binary that
|
|
-- predates this round on the ROUND 51 path instead of erroring (and the shipped
|
|
-- exe HAS been out of date before -- R61 found it missing R56's tex.get_env,
|
|
-- which had silently made a whole A/B meaningless).
|
|
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 -- first frame: adopt, do not replay
|
|
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 = 0 closed -> -94 open (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
|
|
|
|
|
|
-- ROUND 5b: the genre board's own title is bigger than a song board's. Arcade
|
|
-- CS/main_genre_board `text_kanban_title_genre` is an MS明朝 EditText at size 54
|
|
-- (unscaled in both the 'off' and 'on' board frames), while main_song_board's
|
|
-- `text_song_title` is 42 = the engine's song_box_name. The engine builds every
|
|
-- box title from song_box_name, so the folder title is re-made here at the size
|
|
-- skin_config `folder_box_name` asks for, keeping the engine's white fill. Falls back to
|
|
-- the engine text if the key is missing. ROUND 6: the outline is no longer the engine's
|
|
-- fore_color -- see `board_outline` / the ROUND 6 "board text colours" MAPPING section.
|
|
local FOLDER_TITLE_SIZE = nil -- resolved once from skin_config
|
|
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
|
|
-- ROUND 6 (board text colours): the outline is the arcade genre colour add on
|
|
-- `main_genre_board` sprite 70, not the engine's `fore_color` (which is a darkened
|
|
-- back_color and does not match the cabinet). `folder_frame` already resolves
|
|
-- event (11) / sort (12) folders.
|
|
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 },
|
|
-- `main_genre_board` id 31 `text_kanban_title_genre`: border 7.5
|
|
board_outline(ff), false, ol(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 -- r56 wheel fly-in
|
|
local cen_h = CEN_CLOSED_H + (CEN_OPEN_H - CEN_CLOSED_H) * pb
|
|
local fade = box.fade.attribute
|
|
local frame, has_pattern = folder_frame(box)
|
|
|
|
-- ROUND 85 ---------------------------------------------------------------
|
|
-- THE PIECE FIVE ROUNDS NEVER DREW. The genre board does not merely go
|
|
-- alpha 0 when a folder opens: `instance_board_center.sx` on sprite 73 sweeps
|
|
-- 1.0 -> 0.025 over clip f100..f112 -- the board FURLS SHUT HORIZONTALLY to a
|
|
-- vertical hairline, 13 f = 216.7 ms, the exact mirror of the song board's
|
|
-- unfurl -- and only THEN vanishes at f113. Coming back out it unfurls
|
|
-- 0.025 -> 1.0 over f220..f232 and its contents return f238..f250.
|
|
-- ROUND 61 sampled these channels into anim/genre_open and left them
|
|
-- unconsumed (its own section 9: "the outgoing genre board still just
|
|
-- disappears"), so the whole visible signature of the transition was absent
|
|
-- on our side. That is what six reports of 「分類開啟動畫還是不對」 were
|
|
-- pointing at.
|
|
local ltitle = 1.0
|
|
if not R61.disabled then
|
|
local lf = (box:draw_state() == "open") 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 -- f113..f219: 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
|
|
-- ROUND 5: closed folder = bar_genre, open folder = folder_graphic (coloured title band on top,
|
|
-- pale panel only below it), frames 10..12 = dani/event/sort. While the board grows
|
|
-- (pb 0 -> 1, 83 ms) the closed look is cross-faded out over the open one.
|
|
-- ROUND 14: both textures are now the cabinet's own shapes, not round 5's flat rounded rect --
|
|
-- `main_genre_board` shape 19 (the 960x24 white lip, 8 clear rows on top) + shape 23 (the 960x8
|
|
-- white bar, x 82..878) at cmul alpha 141, plus shapes 1/3/5 (the black drop shadow).
|
|
-- closed bake rows 32..56 lip / 56..296 fill / 296..320 flipped lip;
|
|
-- open bake rows 126..150 lip / 150..296 fill / 296..320 flipped lip (the cabinet leaves the top
|
|
-- lip at board -26 and grows the white DOWNWARDS, which is why the open board has a 70 px
|
|
-- coloured band above the panel). See Graphics/song_select/MAPPING.md ROUND 14.
|
|
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
|
|
-- ROUND 82 (r82-songselect-overflow-and-searchbox): a library-supplied
|
|
-- `box.png` is arbitrary-sized character art with no cabinet counterpart of
|
|
-- a known size, and it used to be pasted at 1:1 * screen_scale from a fixed
|
|
-- top-left -- Songs/17 New's 344x424 banner came out 516x636 and hung ~359 px
|
|
-- below the board, over 曲検索. The cabinet does not scissor this board
|
|
-- (0 ClipDepth masks across all 22 common_songselect movies, ~8,900
|
|
-- PlaceObject records -- scratchpad/r82/); its art simply never exceeds its
|
|
-- slot, every genre character frame being exactly 480x480. So the banner is
|
|
-- FITTED (aspect kept, shrink only) into the board's own 960x352 3-slice rect
|
|
-- centred on (bx, by) -- MAPPING.md ROUND 14: "the 960x352 3-slice texture
|
|
-- (cap 0..56 / centre 56..296 / cap 296..352)", canvas y = board y + 176.
|
|
-- No size of input can leave the board's own footprint.
|
|
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
|
|
-- The title is drawn AFTER the art, matching the cabinet's own depth order:
|
|
-- `main_genre_board` puts `text_kanban_title_genre` at depth 18, ABOVE
|
|
-- `instance_character_l/r` at depths 15/16 (MAPPING.md ROUND 14 table). It
|
|
-- used to be drawn first here, so a banner that reaches the title band
|
|
-- covered the folder name.
|
|
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 (+-450 from centre = the arcade overshoot
|
|
-- position); ch_off is the arcade character offset (340 closed -> 450 -> 440 settled).
|
|
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
|
|
-- arcade board label 'on': text_kanban_title_genre rises to y-94, text_kanban_intro_genre (font 32)
|
|
-- sits at the centre; 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
|
|
-- ROUND 6 (board text colours): the intro text this stands in for
|
|
-- (`main_genre_board` sprite 30 `text_kanban_intro_genre`, size 32 / outline 4.0, fill #ffffff)
|
|
-- carries the same per-genre colour add as the title, so its edge is the genre colour.
|
|
local ckey = frame .. "\0" .. n
|
|
local label = self._count_cache[ckey]
|
|
if not label then
|
|
-- `main_genre_board` id 28 `text_kanban_intro_genre`: size 32, border 4.0
|
|
label = text.create_raw_text(tostring(n) .. " songs", 32, { 255, 255, 255, 255 }, board_outline(frame), false, ol(4.0), 2.0)
|
|
self._count_cache[ckey] = label
|
|
end
|
|
-- ROUND 14: the arcade's own ink centre for this field. `main_genre_board`
|
|
-- DefineEditText id 28 has rect index 7 = (-2,-2)-(758,134) (centre y +66) and its
|
|
-- owner sprite 29 places it at ty -62+... = -17, inside `text_kanban_intro_genre`
|
|
-- (sprite 30), which sprite 73 parks at board ty 0 in BOTH 'off' and 'on'
|
|
-- => ink centre = board y +49 (Lumen dynamic text is always vertically centred in
|
|
-- its rect, MAPPING "DefineEditText" ROUND 6). That is also the centre of the
|
|
-- re-baked pale panel (board -50..+144 -> +47); round 5's +40 was the centre of the
|
|
-- flat panel it shipped with.
|
|
if label then draw_title(label, bx, by + 49 * pb, fade * ta) end
|
|
end
|
|
end
|
|
|
|
-- ROUND 51: the ROUND 17/39 per-name timer bodies moved into wheel_open_t /
|
|
-- wheel_close_step (shared with song / back / dani boards) - the behaviour for a
|
|
-- cursor-move refocus is unchanged (hold 508 ms, grow 233 ms, close 133 ms), but a
|
|
-- folder enter/exit now SNAPS open and a scene entry grows without the hold, matching
|
|
-- the cabinet state machine (see the mode table above open_start_mode).
|
|
function SongSelect:draw_folder_closed(box)
|
|
local key = "f\0" .. box_id(box) -- r63: per-BOX, not per-title (see box_id)
|
|
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)
|
|
local key = "f\0" .. box_id(box) -- r63: per-BOX, not per-title (see box_id)
|
|
local t = wheel_open_t(key)
|
|
if t == nil then -- cabinet 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")
|
|
-- ROUND 6 (board text colours): main_song_board sprite 86 `text_kanban_title_back` at label
|
|
-- 'back' carries colour add (76,50,20) -> that is the exact もどる outline (was 80,50,10).
|
|
if cfg then
|
|
-- `main_song_board` id 79 `text_kanban_title_back`: size 42, border 5.0
|
|
self._back_label = text.create_text("song_back_label", { 255, 255, 255, 255 }, BOARD_OUTLINE_BACK, false, ol(5.0), 2.0)
|
|
else
|
|
self._back_label = text.create_raw_text("BACK", 44, { 255, 255, 255, 255 }, BOARD_OUTLINE_BACK, false, ol(5.0), 2.0)
|
|
end
|
|
end
|
|
return self._back_label
|
|
end
|
|
|
|
-- ROUND 51: the back board is a wheel panel like any other (main_song_board label 'back'),
|
|
-- so the cabinet's ProcKanbanWaitCountToSelectOn hold + select_on growth applies to it
|
|
-- too. pb comes from the same folder-clip growth curve (identical f8..f13 window).
|
|
local function back_pb_open(box)
|
|
local key = "b\0" .. box_id(box) -- r63: per-BOX, not per-title (see box_id)
|
|
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) -- r63: per-BOX, not per-title (see box_id)
|
|
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 -- r56 wheel fly-in
|
|
local cen_h = CEN_CLOSED_H + (CEN_OPEN_H - CEN_CLOSED_H) * p
|
|
local fade = math.min(1.0, box.fade.attribute)
|
|
|
|
-- ROUND 85: on the cabinet the panel that unfurls across the swap is the
|
|
-- newly-focused one (`GetSongKanbanMc(kSelectNumber + openOffset)`). In THIS
|
|
-- engine a folder enter builds the wheel around a もどる box and leaves the
|
|
-- cursor on it (`setup_back_box`, navigator.cpp), so the back board IS that
|
|
-- panel -- the first live trace of this round drew 1146 genre-board frames and
|
|
-- not one song-board frame, which is what pointed here. It rides the same
|
|
-- song_board_sx channel (the cabinet draws every wheel panel from
|
|
-- main_song_board sprite 222; `back` is just one of its title labels).
|
|
local lta = 1.0
|
|
if not R61.disabled then
|
|
local lf = (box:draw_state() == "open") and leg_f() or nil
|
|
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
|
|
|
|
--------------------------------------------------------------------------------
|
|
-- ROUND 15 — むずかしさからえらぶ / the arcade sort window
|
|
--------------------------------------------------------------------------------
|
|
-- Source: `common_songselect/common_song_select_sort_window.nulm` (geometry) and
|
|
-- `common_song_select/song_select_select_sort_window.lua` (state logic). The cabinet
|
|
-- shows ONE 1320x760 window with three rows that are decided in turn - むずかしさ,
|
|
-- ★の数, 表示順 - not PyTaikoGreen's row of course boxes followed by a star page.
|
|
-- The engine (DiffSortSelect, arcade mode) owns the state machine and hands this
|
|
-- function a `SortWindow`; everything drawn below is measured off the .nulm:
|
|
--
|
|
-- main_instance (960,540) board shape1 1320x760 -> x 300..1620, y 160..920
|
|
-- select_diff_instance row y 540-130 = 410 \ each row (sprite 30):
|
|
-- select_star_instance row y 540+ 11 = 551 | cursor_instance ( 0, 0) 1240x180
|
|
-- select_order_instance row y 540+152 = 692 | shape8 pill (+164, 0) 792x104
|
|
-- | diff_mark ( -89, 0) 96x96
|
|
-- | right_arrow (-174, 0) 48x56
|
|
-- | left_arrow (+502, 0) 48x56, matrix -1,0,0,-1
|
|
-- | tx_item_01/02 (+164, 0) ink centre -13
|
|
-- \ tx_header (-400, 0) ink centre -13
|
|
-- dummy_control_guide (960, 827) control_guide_s `s_menfuchi_kp`
|
|
-- board_songs_num ink left (1402, 277) EditText id 32, align left, size 42
|
|
-- num_song digits right-aligned at 1379, centre y 289, pitch 34, 48x56
|
|
-- board_genre_title ink left ( 405, 272) EditText id 56, align left, size 54,
|
|
-- colour-add (119,26,45) on its outline
|
|
--
|
|
-- Text ink centres: every one of the three EditText owners places its field so that the
|
|
-- rect centre lands at instance y -13 (id 24 rect (322.75,31)-(942.75,127) placed at
|
|
-- (-632.75,-92); id 26 (418.75,31)-(848.75,127) at (-633.75,-92); id 28
|
|
-- (-2,85.65)-(308,181.65) at (-153,-146.65)) - Lumen dynamic text is always vertically
|
|
-- centred in its rect (MAPPING ROUND 6 "DefineEditText").
|
|
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
|
|
-- text_kanban colour add on board_genre_title_instance (colour table entry 43)
|
|
local SW_TITLE_OUTLINE = { 119, 26, 45, 255 }
|
|
local SW_HEADER_BOX_W = 310 -- EditText id 28 rect width
|
|
local SW_ITEM_BOX_W = { 620, 430, 430 } -- id 24 (diff row) / id 26 (star, order)
|
|
-- cursor_instance (sprite 7): label on@9, shape3 (glow, below) alpha ramps 256 -> 0 over
|
|
-- 20 frames then 0 -> 256 over 30, and the clip loops at f59 => a 51-frame / 850 ms cycle.
|
|
local SW_CURSOR_CYCLE = 51.0
|
|
|
|
-- The strings are the cabinet's own word table (the reference build `datatable` wordlist), not invented:
|
|
-- sort_window_diff / _star / _order, course_easy..course_mania, sort_window_order_*,
|
|
-- folder_sort_select, sort_window_songs. Keyed by `tex.language()` with en as the fallback.
|
|
local SW_WORDS = {
|
|
header = {
|
|
ja = { "むずかしさ", "★の数", "表示順" },
|
|
en = { "Difficulty", 'Number of "★"', "Sort by" },
|
|
zh = { "難度", "★數", "顯示順序" },
|
|
},
|
|
course = {
|
|
ja = { "かんたん", "ふつう", "むずかしい", "おに", "おに" },
|
|
en = { "Easy", "Normal", "Hard", "Extreme", "Extreme" },
|
|
zh = { "簡單", "普通", "困難", "魔王", "魔王" },
|
|
},
|
|
order = {
|
|
ja = { "いつもどおり", "未クリア優先", "未フルコンボ優先", "未ドンダフルコンボ優先" },
|
|
en = { "Default", "Not cleared", "Not Full Combo", "Not Donderful Combo" },
|
|
zh = { "通常", "未通關優先", "未全連段優先", "未全良連段優先" },
|
|
},
|
|
title = { ja = "むずかしさからえらぶ", en = "Select by difficulty", zh = "以難易度選擇" },
|
|
songs = { ja = "曲", en = "Songs", zh = "首" },
|
|
}
|
|
|
|
local function sw_words(group)
|
|
local lang = (tex.language and tex.language()) or "en"
|
|
local t = SW_WORDS[group]
|
|
return t[lang] or t.en
|
|
end
|
|
|
|
-- OutlinedText cache: key = group .. "\0" .. index (the strings never change at runtime).
|
|
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
|
|
|
|
-- ROUND 19 (r19-sortwindow): the sort window's row/header boxes are the cabinet's own
|
|
-- Japanese-sized EditText rects (header 310px, item 430/620px - measured off the .nulm,
|
|
-- see the geometry table above). English localisation strings run much longer than any
|
|
-- Japanese or Chinese equivalent ('Number of "★"' at 368px vs a 310px header box;
|
|
-- "Not Donderful Combo" at 535px vs a 430px item box), so `draw_title`'s squeeze (which
|
|
-- stretches the dst quad's WIDTH only, leaving height untouched - see `draw_title` above
|
|
-- and `OutlinedText::draw`, src/libs/text.cpp) was squashing those two strings ~16-20%
|
|
-- horizontally while their outline strokes stayed full height: an anisotropic (x-only)
|
|
-- scale of an already-rasterised, diagonal-heavy hand-drawn font, which reads as the
|
|
-- glyphs being tilted/crooked rather than merely narrower. User report: "diff select的字
|
|
-- 稍微歪了" (sort_window_star / order row text).
|
|
--
|
|
-- Fix: when a string does not fit its box, re-rasterise it at a smaller (uniformly
|
|
-- scaled) point size instead of stretching the finished bitmap - same fix shape as
|
|
-- `draw_title`'s max_w/min_scale squeeze, but scaling BOTH axes together so the glyphs
|
|
-- keep their proportions. Falls back to the normal squeeze only if even the smallest
|
|
-- attempted size still overflows (should not happen at these box widths / string
|
|
-- lengths, kept only as a safety net for a future, longer translation).
|
|
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
|
|
-- shrink proportionally (both axes) until it fits, or we hit the floor
|
|
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 -- ROUND 16: border scales with 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 (EditText align = left).
|
|
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
|
|
|
|
-- shape3 (the glow half of the cursor) alpha over the 51-frame loop.
|
|
-- ROUND 53 (r53-anim-lumen-sweep): sampled from the cabinet's own timeline —
|
|
-- `Scripts/anim/sort_cursor.lua` is sprite 7 (`cursor_instance`) of
|
|
-- `common_song_select_sort_window.nulm` exported per-frame: track `#3@0` alpha 1 -> 0 over
|
|
-- f9..f29 and back over f29..f59, looping from the `on` label (f9) => the 51-frame cycle.
|
|
-- The R15 hand ramp had the same shape (the export confirms it is exactly piecewise
|
|
-- linear); the table is now the source of truth. Fail-soft to the hand ramp (ROUND 8).
|
|
local _sc_ok, _sc_anim = pcall(require, "anim/sampler")
|
|
local SW_CURSOR_CLIP = (_sc_ok and _sc_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_id("diff_sort", "background") -- 1320x760 window (name reused)
|
|
self._id_sw_pill = tex.get_id("diff_sort", "level_box") -- 792x104 white item pill
|
|
self._id_sw_cur = tex.get_id("diff_sort", "box_highlight") -- 1240x180 flat yellow bar
|
|
self._id_sw_glow = tex.get_id("diff_sort", "box_outline") -- 1240x180 pulsing glow
|
|
self._id_sw_mark = tex.get_id("diff_sort", "diff") -- 96x96, frame = course
|
|
self._id_sw_arrow = tex.get_id("diff_sort", "arrow") -- 48x56, frame 1 = rotated
|
|
self._id_sw_digit = tex.get_id("diff_sort", "stat_num") -- 48x56, frame = digit
|
|
self._id_sw_guide = tex.get_id("diff_sort", "star_select_text") -- s_menfuchi_kp 210-cell 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() }
|
|
|
|
-- main_instance: board shape1 centred on the stage root
|
|
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
|
|
-- cursor_instance @ label `on`: shape3 (glow) below, shape5 (flat) above
|
|
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_instance is visible on the むずかしさ row only (SetItemName)
|
|
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
|
|
-- `common_song_select_sort_window` ids 24/26/28/32: size 42, border 6.5
|
|
-- ROUND 19: fit-shrink (both axes) instead of draw_title's x-only squeeze, so a
|
|
-- too-wide English string (e.g. "Not Donderful Combo") never comes out anisotropically
|
|
-- squashed/crooked. draw_title's own max_w squeeze is kept as a last-resort safety net.
|
|
draw_title(self:_sw_text_fit(key, str, 42, ol(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, ol(6.5), nil, SW_HEADER_BOX_W),
|
|
SW_HEADER_CX, ry + SW_TEXT_DY, a, SW_HEADER_BOX_W, 0)
|
|
end
|
|
|
|
-- control guide (s_menfuchi_kp) + 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, ol(6.5)), SW_SONGS_X, SW_SONGS_Y, a)
|
|
|
|
-- board title, with the cabinet's own dark-red outline add
|
|
-- `common_song_select_sort_window` id 56 `board_genre_title_instance`: size 54, border 7.5
|
|
draw_text_left(self:_sw_text("title", sw_words("title"), 54, ol(7.5), SW_TITLE_OUTLINE),
|
|
SW_TITLE_X, SW_TITLE_Y, a)
|
|
end
|
|
|
|
--------------------------------------------------------------------------------
|
|
-- Dispatch
|
|
--------------------------------------------------------------------------------
|
|
-- scenes/song_select_2p.cpp calls neither Navigator::draw_diff_select_bg() nor
|
|
-- SongSelectPlayer::try_lua_selector(), so in 2P the arcade backboard has to be drawn from
|
|
-- draw_box (which IS called) and the cursors come from the engine's own draw_selector.
|
|
local function is_2p_screen()
|
|
return (tex.current_screen and tex.current_screen() or "") == "SONG_SELECT_2P"
|
|
end
|
|
|
|
function SongSelect:draw_box(box)
|
|
-- ROUND 51: scene-entry detection. The cabinet's SecondLoading plays select_on
|
|
-- IMMEDIATELY on the initially-focused board (no 508 ms hold), so the first box draw
|
|
-- ever - and the first after any >1 s gap in box draws (came back from enso etc.,
|
|
-- if this Lua state survives the scene) - arms the one-shot "nohold" mode.
|
|
local nowms = tex.get_current_ms()
|
|
-- (r85: the engine also emits WHEEL_EVENT_SCENE_ENTRY, but Navigator::init()
|
|
-- runs before this Lua state has polled once, so this stays as the detector for
|
|
-- "first box draw ever" and for a Lua state that outlives the scene.)
|
|
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()
|
|
|
|
-- ROUND 63 (r63-duplicate-song-box) -- env-gated wheel trace. Settles
|
|
-- "two identical song boxes" the only way it can be settled: A (two entries
|
|
-- in the wheel) logs two DIFFERENT paths; B (one box drawn twice) logs the
|
|
-- same path twice inside one frame. Off unless YATAIDON_R63_DEBUG is set.
|
|
if R63_DBG then
|
|
local p = box_id(box) -- r63: per-BOX identity
|
|
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
|
|
-- ROUND 15: the 2P scene now calls Navigator::draw_diff_select_bg() itself
|
|
-- (scenes/song_select_2p.cpp, gated on has_box_bg) BEFORE the wheel, and the
|
|
-- skin's course frame is drawn right after it. Drawing the 1272x784 backboard
|
|
-- a second time from here painted over that frame, which is why the 1P/2P
|
|
-- yellow course frame was missing in 2P. `_bg_drawn` latches the first time
|
|
-- the engine calls draw_box_bg, so an engine that never calls it still gets
|
|
-- the ROUND 6 fallback and a current one 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" 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 (folder_graphic frame 10)
|
|
-- ROUND 51: timed like every other wheel panel (508 ms hold + clip growth /
|
|
-- select_off close) instead of the raw engine open_anim ramp.
|
|
local bx, by = box:box_x(), box:box_y()
|
|
local fdx, fdy = fly_offset(box); bx, by = bx + fdx, by + fdy -- r56 wheel fly-in
|
|
local key = "o\0" .. box_id(box) -- r63: per-BOX, not per-title (see box_id)
|
|
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.attribute)
|
|
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) -- ROUND 5: closed = full pale panel
|
|
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
|
|
-- ROUND 15: latch that the ENGINE drives the backdrop, so draw_box's 2P
|
|
-- fallback never draws it a second time over the course frame.
|
|
self._bg_drawn = true
|
|
self:draw_song_diff_select_bg(box)
|
|
end
|