Files
YataiDONNijiiro/Scripts/dan_select/dan_select.lua
T
TaikoZhongandClaude Opus 5.5 601fed93d6 dan select: 自己ベスト rows and all six clear stamps
- Each exam shows its 自己ベスト row as on the cabinet: a white pill (part of
  exam_gauge_box, and yellow_box/exam_best_pill for the right-hand boxes, one per song
  segment) with the label on the left and the recorded best on the right. Needs the
  engine's box:exam_best / exam_song_best; without them, or before a course is played,
  the label is shown alone.
- box/clear_mark and yellow_box/clear_mark have the cabinet's six stamps (red/gold x
  pass/full combo/all good), picked by the record's rank (box.record_rank); an engine
  without it falls back to clear_mark (frames 0 and 5).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 18:58:16 +08:00

489 lines
21 KiB
Lua

-- DAN_SELECT paint surface: selection cursor breathing loop and arrow chips,
-- sampled from the cabinet's own clips. The dan boxes themselves are drawn by the
-- parent skin's dan_select.lua, which is loaded first and extended here.
-- the parent skin's copy of this script: the second match on package.path (the first is this file)
local function load_parent(name)
local seen = false
for tmpl in package.path:gmatch("[^;]+") do
local chunk = loadfile((tmpl:gsub("%?", name)))
if chunk then
if seen then return chunk end
seen = true
end
end
end
local parent = load_parent("dan_select/dan_select")
if parent then parent() end
if not DanSelect then
DanSelect = {}
DanSelect.__index = DanSelect
end
local parent_new = DanSelect.new
local ok, anim = pcall(require, "anim/sampler")
if not ok then anim = { safe = function()
local n = {}
n.at = function() return nil end; n.get = function() return nil end
n.label = function() return nil end; n.has = function() return false end
n.first = function() return 0 end; n.last = function() return 0 end
return n
end } end
local CUR = anim.safe("anim/dan_cursor")
local ARW = anim.safe("anim/dan_arrow")
local ARW_OFF = ARW:label("arrow_off") or 61
-- No real clip data at all -> no draw_cursor: the engine draws the cursor from its
-- inline tables (the boxes are still drawn by the parent's DanSelect).
local HAS_CLIPS = CUR:has("#259@0") or ARW:has("#261@0")
function DanSelect.new()
local self = parent_new and parent_new() or setmetatable({}, DanSelect)
self.t_cursor = tex.get_texture("dan_select/box/cursor")
self.t_arrow_r = tex.get_texture("dan_select/box/arrow_r")
self.t_arrow_l = tex.get_texture("dan_select/box/arrow_l")
return self
end
-- pos: selected chip x (the textures' own json positions carry the rest);
-- now_ms: engine clock; moved_ms: ms since the last ribbon step (-1 = never);
-- y: how far the selected chip has dropped (see draw_box below).
local function draw_cursor(self, pos, now_ms, moved_ms, y)
y = y or 0
if self.t_cursor then
local f = (now_ms * 0.06) % 60
local a = CUR:get("#259@0", f, "a") or 1
tex.draw_texture(self.t_cursor, { x = pos, y = y, fade = a })
end
if moved_ms and moved_ms >= 0 then
local f = moved_ms * 0.06
if f <= (ARW_OFF - 1) then
local drift = ARW:get("#261@0", f, "tx") or 0
local a = ARW:get("#261@0", f, "a") or 0
if a > 0 then
if self.t_arrow_r then
tex.draw_texture(self.t_arrow_r, { x = pos + drift, y = y, fade = a })
end
if self.t_arrow_l then
tex.draw_texture(self.t_arrow_l, { x = pos - drift, y = y, fade = a })
end
end
end
end
end
if HAS_CLIPS then DanSelect.draw_cursor = draw_cursor end
-- The level is the cabinet's own art, one "star + number" image per level
-- (yellow_box/difficulty_level, frames 1..10 and 10+), in place of the parent's star and
-- level digits, which are held back.
local function draw_level(level, x, y, f)
local id = tex.get_texture("dan_select/yellow_box/difficulty_level")
if not id or level < 1 then return end
tex.draw_texture(id, { frame = math.min(level, 11) - 1, x = x, y = y, fade = f })
end
-- The course rows are set as the cabinet's: the song title alone (no subtitle line), and the
-- exam values with narrow digits beside full-width 以上/未満 and a little room between them.
-- The skin has one font, so each text is cut into runs of ASCII and of everything else, each
-- run made on its own and scaled as it is drawn, the runs butted together. The parent is handed
-- the box through a view whose song_subtitle_text gives nothing and whose song_title_text gives
-- such a title, and the captions it makes for the exam values are made the same way. Only CJK
-- languages are rescaled; the skin's font for the others is narrow already.
local function cjk_language()
local l = tex.language() or ""
return l:find("^ja") or l:find("^zh") or l:find("^ko") or l == "tw" or l == "cn"
end
local TITLE_CJK = { latin = 1, cjk = 1.02, sy = 1, dy = 0, max_w = 1100 }
local VALUE_CJK = { latin = 0.85, cjk = 1.0, sy = 1.0, dy = 0, gap = 8 }
local TITLE_PLAIN = { latin = 1, cjk = 1, sy = 1, dy = 0, max_w = 1100 }
local VALUE_PLAIN = { latin = 1, cjk = 1, sy = 1, dy = 0 }
local function runs_of(str)
local runs, cur, ascii = {}, {}, nil
for ch in str:gmatch("[%z\1-\127\194-\244][\128-\191]*") do
local a = ch:byte() < 128
if ascii ~= nil and a ~= ascii then
runs[#runs + 1] = { text = table.concat(cur), ascii = ascii }
cur = {}
end
cur[#cur + 1] = ch
ascii = a
end
if #cur > 0 then runs[#runs + 1] = { text = table.concat(cur), ascii = ascii } end
return runs
end
-- create: text.create_raw_text as it was before any wrapping. The parts' sizes are read when
-- they are used, since a text's texture (and size) may still be building when it is made.
local function mixed_text(create, str, size, color, outline_color, outline, spacing, style)
local pad = math.floor((outline or 0) * tex.get_screen_scale()) + 2
local parts = {}
for _, r in ipairs(runs_of(str)) do
parts[#parts + 1] = { t = create(r.text, size, color, outline_color, false, outline, spacing),
sx = r.ascii and style.latin or style.cjk }
end
local function layout()
local function span(k)
local x = 0
for n, q in ipairs(parts) do
q.x = x
local next_sx = parts[n + 1] and parts[n + 1].sx * k or 0
x = x + (q.t.width - pad) * q.sx * k - pad * next_sx
if parts[n + 1] then x = x + (style.gap or 0) * k end
end
local last = parts[#parts]
return last and (last.x + last.t.width * last.sx * k) or 0
end
local k, w = 1, span(1)
if style.max_w and w > style.max_w then
k = style.max_w / w
w = span(k)
end
local h = 0
for _, q in ipairs(parts) do h = math.max(h, q.t.height) end
return k, w, h * style.sy
end
return setmetatable({
text = str,
draw = function(_, p)
local k = layout()
for _, q in ipairs(parts) do
local sx = q.sx * k
q.t:draw({ x = p.x + q.x, y = (p.y or 0) + style.dy, fade = p.fade,
x2 = q.t.width * (sx - 1), y2 = q.t.height * (style.sy - 1) })
end
end,
}, {
__index = function(_, key)
if key == "width" then local _, w = layout(); return w end
if key == "height" then local _, _, h = layout(); return h end
end,
})
end
local title_cache = {} -- by string, size and outline: texts are built once, not per frame
local raw_create = text.create_raw_text -- the engine's, before draw_dan_open wraps it
local function course_view(box)
local titles = {}
return setmetatable({}, {
__index = function(_, k)
if k == "song_subtitle_text" then return function() return nil end end
if k == "song_title_text" then
return function(_, i)
if titles[i] == nil then
local t = box:song_title_text(i)
local ok, str = pcall(function() return t and t.text end)
local td = tex.get_skin_config("dan_title")
if t and ok and type(str) == "string" and str ~= "" and td then
local ol = td.outline >= 0 and td.outline or 5
local key = str .. "\0" .. td.font_size .. "\0" .. ol
title_cache[key] = title_cache[key]
or mixed_text(raw_create, str, td.font_size,
{ 255, 255, 255, 255 }, { 0, 0, 0, 255 }, ol, 2,
cjk_language() and TITLE_CJK or TITLE_PLAIN)
titles[i] = title_cache[key]
else
titles[i] = t or false -- an engine without OutlinedText.text
end
end
return titles[i] or nil
end
end
local v = box[k]
if type(v) == "function" then
return function(_, ...) return v(box, ...) end
end
return v
end,
})
end
-- The clear stamps are the cabinet's six (box/clear_mark and yellow_box/clear_mark, frames
-- 0..5 = red pass, full combo, all good, gold pass, full combo, all good), picked by the
-- saved record's rank (2..7). An engine without box.record_rank only gives clear_mark
-- (1 passed, 2 rank 7), which maps to frames 0 and 5.
local function stamp_frame(box)
local ok, rank = pcall(function() return box.record_rank end)
if ok and type(rank) == "number" then
return rank >= 2 and math.min(rank, 7) - 2 or nil
end
if box.clear_mark == 1 then return 0 end
if box.clear_mark == 2 then return 5 end
return nil
end
-- The course's clear stamp (yellow_box/clear_mark) sits over the dan plate and Don, as the
-- cabinet's stamp_detail does (it is above don_1p). The parent draws it with the box, under
-- both, so it is held back there and drawn with the title, which the engine draws after
-- the chara and the nameplate.
local parent_open, parent_title = DanSelect.draw_dan_open, DanSelect.draw_title
local held_stamp -- shared: the boxes and the title can be drawn through different instances
if parent_open then
function DanSelect:draw_dan_open(box)
local stamp = tex.get_texture("dan_select/yellow_box/clear_mark")
local level = tex.get_texture("dan_select/yellow_box/difficulty_level")
local hide = {}
if level then
for _, n in ipairs({ "difficulty_star", "difficulty_num", "difficulty_x" }) do
local t = tex.get_texture("dan_select/yellow_box/" .. n)
if t then hide[#hide + 1] = t end
end
end
local draw = tex.draw_texture
tex.draw_texture = function(id, p)
if id == stamp then return end
for _, t in ipairs(hide) do -- handles compare with ==, not as table keys
if id == t then return end
end
return draw(id, p)
end
local create = text.create_raw_text
text.create_raw_text = function(str, size, color, outline_color, vertical, outline, spacing, ...)
if vertical or type(str) ~= "string" then -- only the exam value captions
return raw_create(str, size, color, outline_color, vertical, outline, spacing, ...)
end
return mixed_text(raw_create, str, size, color, outline_color, outline, spacing,
cjk_language() and VALUE_CJK or VALUE_PLAIN)
end
local ok, err = pcall(parent_open, self, course_view(box))
text.create_raw_text = create
tex.draw_texture = draw
if not ok then error(err, 0) end
local f = box.open_fade.attribute
local off = tex.get_skin_config("dan_yellow_box_offset") or { x = 0, y = 0 }
if level then
for i = 0, box:song_count() - 1 do
draw_level(box:song_level(i), i * (off.x or 0), i * (off.y or 0), f)
end
end
local sf = stamp and stamp_frame(box)
if sf then held_stamp = { id = stamp, frame = sf, fade = f } end
end
function DanSelect:draw_title()
if parent_title then parent_title(self) end
local s = held_stamp
held_stamp = nil
if s then tex.draw_texture(s.id, { frame = s.frame, fade = s.fade }) end
end
end
-- The back entry is one more chip in the top strip (back/folder, laid out like box/folder);
-- the parent's opening board is its own layout, so nothing opens under it here.
-- Its label comes from skin_config dan_back_label, so it follows the language setting. It is set
-- like the dan chip names (black, no outline: dan_chip_name_black), vertical for CJK and
-- across, squeezed to the chip, for other languages.
local CJK = { ja = true, zh = true, ko = true, tw = true, cn = true }
local back_label
local BACK_ACROSS_SIZE = 0.7 -- size of a label set across, against dan_back_label.font_size
local function get_back_label()
if back_label == nil then
local cfg = tex.get_skin_config("dan_back_label")
if not cfg then back_label = false; return false end
local lang = tex.language()
local vertical = CJK[lang] or CJK[lang:sub(1, 2)] or false
-- a word set across has only the chip's width, so it takes a smaller size than the
-- vertical labels rather than being squeezed hard
local size = vertical and cfg.font_size or math.floor(cfg.font_size * BACK_ACROSS_SIZE + 0.5)
back_label = {
cfg = cfg,
vertical = vertical,
main = raw_create(tex.skin_text("dan_back_label"), size,
{ 0, 0, 0, 255 }, { 0, 0, 0, 255 }, vertical, 0, 2),
}
end
return back_label
end
local function draw_back_label(bx, fade, y)
local label = get_back_label()
if not label then return end
local cfg, main = label.cfg, label.main
local sx, sy = 1, 1
if label.vertical then
sy = math.min(1, cfg.height / main.height)
else
sx = math.min(1, cfg.width / main.width) -- width: chip face less the border
end
local w, h = main.width * sx, main.height * sy
main:draw({ x = bx + cfg.x - w / 2, y = cfg.y + (cfg.height - h) / 2 + y,
x2 = w - main.width, y2 = h - main.height, fade = fade })
end
-- The selected chip drops below the strip, as the cabinet's does, easing there and back.
-- Only draw_cursor (called after every box) is told where the cursor is, so the boxes are
-- held back in draw_box and drawn from draw_cursor.
local SELECTED_DROP = 22
local DROP_MS = 60 -- time constant of the ease
local drop = {} -- chip key -> current drop
local held_boxes = {}
local last_ms
local function chip_key(box)
return box.is_back and "\0back" or box.text_name
end
local function chip_drop(box)
return drop[chip_key(box)] or 0
end
local function draw_chip_name(box, bx, y, fade)
local name = box:chip_name()
if not name then return end
local nb = tex.get_skin_config("dan_chip_name") or { x = 0, y = 0 }
name:draw({
x = bx + nb.x - name.width / 2,
y = nb.y + y,
y2 = math.min(name.height, nb.height or name.height) - name.height,
fade = fade,
})
end
-- the chip as the parent draws it (folder, stamp, name), at its drop
function DanSelect:draw_dan_closed(box)
local bx, fade, y = box:box_x(), box.fade.attribute, chip_drop(box)
local id = tex.get_texture("dan_select/box/folder")
if id then tex.draw_texture(id, { frame = box.dan_color, x = bx, y = y, fade = fade }) end
local sf = stamp_frame(box)
local mark = sf and tex.get_texture("dan_select/box/clear_mark")
if mark then tex.draw_texture(mark, { frame = sf, x = bx, y = y, fade = fade }) end
draw_chip_name(box, bx, y, fade)
end
function DanSelect:draw_back_box(box)
local bx, fade, y = box:box_x(), box.fade.attribute, chip_drop(box)
local id = tex.get_texture("dan_select/back/folder")
if id then tex.draw_texture(id, { x = bx, y = y, fade = fade }) end
draw_back_label(bx, fade, y)
end
-- The cursor's glow goes over the chip, its emblem and name (they grey under it, as on the
-- cabinet) but not over the clear stamp, which is drawn again on top of it.
local function draw_chip_front(box)
if box.is_back then return end
local sf = stamp_frame(box)
local mark = sf and tex.get_texture("dan_select/box/clear_mark")
if mark then
tex.draw_texture(mark, { frame = sf, x = box:box_x(), y = chip_drop(box),
fade = box.fade.attribute })
end
end
local parent_draw_box = DanSelect.draw_box
function DanSelect:draw_box(box)
held_boxes[#held_boxes + 1] = box
end
local cursor_parent = DanSelect.draw_cursor
function DanSelect:draw_cursor(pos, now_ms, moved_ms)
local boxes = held_boxes
held_boxes = {}
local k = last_ms and math.min(1, math.max(0, now_ms - last_ms) / DROP_MS) or 1
last_ms = now_ms
local selected
for _, box in ipairs(boxes) do
local key = chip_key(box)
local is_sel = math.abs(box:box_x() - pos) < 1
if is_sel then selected = box end
local d = drop[key] or 0
d = d + ((is_sel and SELECTED_DROP or 0) - d) * k
drop[key] = (math.abs(d) >= 0.05) and d or nil
end
-- draw every box even if one fails, then report the first failure
local err
for _, box in ipairs(boxes) do
local ok, e = pcall(parent_draw_box, self, box)
if not ok and not err then err = e end
end
if err then error(err, 0) end
local y = selected and chip_drop(selected) or 0
if cursor_parent then
cursor_parent(self, pos, now_ms, moved_ms, y)
elseif self.t_cursor then
tex.draw_texture(self.t_cursor, { x = pos, y = y })
end
if selected then draw_chip_front(selected) end
return true
end
-- Each exam's 自己ベスト row, as on the cabinet: a white pill under the value with the label
-- on its left and the saved best on its right (box:exam_best / exam_song_best; an engine
-- without them, or a course never played, shows the label alone). The gauge box's pill is
-- part of exam_gauge_box; the right-hand boxes get yellow_box/exam_best_pill, one per song
-- segment. The boxes are walked as the parent's draw_exam_grid walks them.
local BEST_GAUGE = { x = 10, y = 116, w = 301 } -- the pill in exam_gauge_box, from the box
local BEST_RIGHT = { x = 11, y = 86, w = 317 } -- exam_best_pill, from the box (+ seg dx)
local BEST_H = 31
local BEST_LABEL_X, BEST_VALUE_R = 8, 14 -- label from the pill's left, value from its right
local best_texts = {}
local function best_text(key, str)
local cfg = tex.get_skin_config(key)
if not cfg or str == nil or str == "" then return nil end
local k = key .. "\0" .. str
if best_texts[k] == nil then
best_texts[k] = raw_create(str, cfg.font_size, { 20, 20, 20, 255 }, { 0, 0, 0, 255 },
false, 0, 1) or false
end
return best_texts[k] or nil
end
local function call(box, name, ...)
local ok, fn = pcall(function() return box[name] end)
if not ok or type(fn) ~= "function" then return nil end
local ok2, v = pcall(fn, box, ...)
return ok2 and v or nil
end
local function draw_best_row(box, value, etype, px, py, pw, f)
local label = best_text("dan_exam_best_label", tex.skin_text("dan_exam_best_label"))
if label then
label:draw({ x = px + BEST_LABEL_X, y = py + (BEST_H - label.height) / 2, fade = f })
end
if value then
local str = tostring(value) .. (etype == "gauge" and " %" or "")
local t = best_text("dan_exam_best_value", str)
if t then
t:draw({ x = px + pw - BEST_VALUE_R - t.width, y = py + (BEST_H - t.height) / 2, fade = f })
end
end
end
local parent_grid = DanSelect.draw_exam_grid
if parent_grid then
function DanSelect:draw_exam_grid(box, f)
parent_grid(self, box, f)
local pill = tex.get_texture("dan_select/yellow_box/exam_best_pill")
local gb = tex.get_skin_config("dan_exam_gauge_box") or { x = 436, y = 610 }
local sb = tex.get_skin_config("dan_exam_score_box") or { x = 769, y = 610, height = 132 }
local sg = tex.get_skin_config("dan_exam_seg") or { width = 327 }
local pitch = (sb.height and sb.height > 0) and sb.height or 132
local seg_dx = (sg.width and sg.width > 0) and sg.width or 327
local right_row, gauge_used = 0, false
for i = 0, box:exam_count() - 1 do
local etype = box:exam_type(i)
local left = (etype == "gauge" and not gauge_used)
if left then gauge_used = true end
if left then
draw_best_row(box, call(box, "exam_best", i), etype,
gb.x + BEST_GAUGE.x, gb.y + BEST_GAUGE.y, BEST_GAUGE.w, f)
elseif right_row < 3 then
local by = sb.y + pitch * right_row
local gothrough = box:exam_gothrough(i)
local segs = gothrough and 1 or math.min(box:song_count(), 3)
for k = 0, segs - 1 do
local px = sb.x + BEST_RIGHT.x + seg_dx * k
local py = by + BEST_RIGHT.y
if pill then tex.draw_texture(pill, { x = px, y = py, fade = f }) end
local v = gothrough and call(box, "exam_best", i) or call(box, "exam_song_best", i, k)
draw_best_row(box, v, etype, px, py, BEST_RIGHT.w, f)
end
right_row = right_row + 1
end
end
end
end