-
-
-
-
总达成率
-
-
DX分数
+
+
+
+
段位認定
+
+
+
+
+
+
+
+
+
![]()
+
+
+
+
+ 总达成率
+
+
+
+ DX分数
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
`;
document.body.append(dialog);
field(dialog, '.dan-result-close').addEventListener('click', () => dialog.close());
@@ -629,13 +649,204 @@ function buildResultDialog() {
return dialog;
}
-/** `.dan-gold` 要两层(描边 ::before + 渐变填充 ::after),两层都读 `data-text`。 */
-function goldNode(cls, text) {
- const node = el('strong', cls, text);
- node.dataset.text = text;
- return node;
+/* ---------- 官方数字字体(图集逐格切) ----------
+ * 图集是规整的 4×4 网格,每格的位置由素材包里同名的 Sprite 给出
+ * (UI_CMN_Num_90p_0 … _14),不用自己量。
+ * 「下标 → 字符」是拿对照表一张张看出来的:
+ * 白字族(UI_CMN_Num_26p/90p)0-9 · [10]'+' · [11]'-' · [12]',' · [13]'.' · [14]'%'
+ * 分数族(UI_NUM_Score_0001111_*)0-9 · [10]'+' · [11]'/' · [12]'%' · [13]'.'
+ * 白字族各带一层 _Outline(同布局的实心剪影)—— 官方就是这么叠出描边数字的:
+ * 两个背景层同一位置,填充层画在描边层之上。
+ */
+const NUM_INDEX = {
+ '0': 0, '1': 1, '2': 2, '3': 3, '4': 4, '5': 5, '6': 6, '7': 7, '8': 8, '9': 9,
+ '+': 10, '-': 11, ',': 12, '.': 13, '%': 14,
+};
+const SCORE_INDEX = {
+ '0': 0, '1': 1, '2': 2, '3': 3, '4': 4, '5': 5, '6': 6, '7': 7, '8': 8, '9': 9,
+ '+': 10, '/': 11, '%': 12, '.': 13,
+};
+/** 白字族借用的小数点已经不需要了:注释见 NUM_FONTS 的 `dotBaseline`。 */
+/**
+ * 图集每格留了空白边(90p 那格 89px 宽、字形只有 63px),照格宽排字会读起来「一个字一个字
+ * 分开」。官方是等距密排(字与字几乎贴上),所以每格按 `advance` 收回去一点,见 numText。
+ *
+ * `baseline`:数字墨迹底线在格高里的比例(量出来的)—— 旁边有小字标签时用它把两边按同一条
+ * 基线排(见 numText 的 `baseline` 选项)。
+ *
+ * `decimal`:**借来的小数点**。白字族里没有句点这一格 —— `[13]` 是中黑点(画在字高中线上)、
+ * `[12]` 是逗号,都不是小数点该有的样子(小字号下读出来像断字符)。分数族 `[13]` 才是这套
+ * 数字里真正的句点(方形、压在基线附近),官方达成率图里的「101.0000%」用的就是它。
+ * `dotBaseline`:小数点那一格(`[13]`)墨迹底线在格高里的比例。这一格在字高中线上
+ * (26p 只有 8×8、90p 24×24),照原样排出来是浮在数字中间的一粒、像断字符 —— 官方那只
+ * 方块的底边压在数字基线上,所以按它压下去(字号小的那两个族才需要,见 numText)。
+ */
+const NUM_FONTS = {
+ n26: {
+ fill: 'num-26p', outline: 'num-26p-outline', atlas: [136, 160], cell: [34, 40], advance: 24,
+ baseline: 0.825, dotBaseline: 0.775, chars: NUM_INDEX,
+ },
+ n90: {
+ fill: 'num-90p', outline: 'num-90p-outline', atlas: [356, 420], cell: [89, 105], advance: 67,
+ baseline: 0.886, dotBaseline: 0.6, chars: NUM_INDEX,
+ },
+ // 分数数字是**三套**同布局不同颜色的图集(自带颜色,不再叠描边层):
+// blue / gold / red,按达成率高低换。官方还有一张 `UI_NUM_Score_0001111_Base`,
+// 但那是一张**空图集**(整张几乎全透明,量下来 16 格一个字形都没有),不能用;
+// 「%」字形也正好只有蓝/金/红三张 —— 所以官方的档位就是三档,不是四档。
+'score-blue': { fill: 'num-score-blue', outline: '', atlas: [296, 392], cell: [74, 98], advance: 67, baseline: 0.949, dotBaseline: 0.827, chars: SCORE_INDEX },
+ 'score-gold': { fill: 'num-score-gold', outline: '', atlas: [296, 392], cell: [74, 98], advance: 67, baseline: 0.949, dotBaseline: 0.827, chars: SCORE_INDEX },
+ 'score-red': { fill: 'num-score-red', outline: '', atlas: [296, 392], cell: [74, 98], advance: 67, baseline: 0.949, dotBaseline: 0.827, chars: SCORE_INDEX },
+};
+const NUM_COLS = 4;
+/** 达成率末尾那个大「%」字形:官方按分数分色,蓝/金/红三张。 */
+const PERCENT_ART = { gold: 'score-per-gold.png', blue: 'score-per-blue.png', red: 'score-per-red.png' };
+
+/**
+ * 达成率取哪一档颜色 —— 原作就是按分数换 `UI_NUM_Score_0001111_*` 那一套。
+ * 三档的界限(2026-10-10 用户给的官方口径):**97% 以上金、80% 以上红、再低蓝**。
+ * 注意低分段是**蓝**、不是红 —— 别按「高金/中蓝/低红」的直觉写。
+ */
+function scoreTone(achievement) {
+ if (achievement >= 97) return 'gold';
+ if (achievement >= 80) return 'red';
+ return 'blue';
+}
+
+/** 达成率数字 + 官方「%」字形。 */
+function achievementNumber(achievement, height) {
+ const tone = scoreTone(achievement);
+ const art = PERCENT_ART[tone];
+ const text = achievement.toFixed(4);
+ const wrap = numText(text, `score-${tone}`, height);
+ // 「%」是独立字形(img 的 alt 为空),读屏那行 aria-label 要把它补回去。
+ wrap.setAttribute('aria-label', `${text}%`);
+ const percent = el('img', 'dan-per');
+ percent.src = `/assets/maimai/${art}`;
+ percent.alt = '';
+ // 官方那个「%」跟数字差不多高,而且**贴着**最后一个数字。`score-per-*` 是 80×80 的方图、
+ // 四周留白(墨迹 x 8..70 / y 10..69),直排会在数字和 % 之间多出一截空隙,所以放大一点
+ // 再把左边那圈留白用负边距抵掉。
+ percent.style.height = `${Math.round(height * 1.1)}px`;
+ percent.style.marginLeft = `${-Math.round(height * 0.16)}px`;
+ wrap.append(percent);
+ return wrap;
}
+/** DX 分数(其实是单题 rating)用的深蓝:和边框里的标题条、标签同一个色 —— 不走分数色,
+ * 那套三色专门留给达成率。 */
+const DX_NUM_COLOR = '#0d2a63';
+
+/**
+ * 把一串字符渲染成官方数字贴图。`height` 是字高(px,按 980×928 的设计尺寸)。
+ * 每格拆成两个叠起来的层:官方 `_Outline` 剪影上深色(描边)+ 官方填充图集。
+ * 之所以要两个真元素而不是两个 background 层:图集是**纯白剪影**(游戏运行时才上色),
+ * background 层没法单独染色,而伪元素又只能画在宿主背景之上(描边会盖住填充)。
+ * `color` 给定时就用图集当**遮罩**把数字染成这个颜色 —— 这正是游戏里给白字族上色的做法
+ * (分数族自带颜色,不走这条路;注意 `UI_NUM_Score_0001111_Base` 是张空图集,不能用)。
+ * 字体里没有的字符(例如数字族没有 '/')退回普通文字,不会静默丢字符。
+ *
+ * `opts.outline === false`:**不叠描边层**。小号的 DX 分数在官方那里是干净的深蓝数字,
+ * 叠一层 4px 描边会糊成一团 —— 小字一律不用描边。
+ * `opts.trim === false`:不收紧字距(留给需要等宽对齐的场景)。
+ *
+ * 排版就一件事:**一个字形一格**,格子之间按 `advance` 等距。小数点(含逗号、加号这些)
+ * 跟数字走同一条路 —— 它本来就是图集里的一格,官方也是这么排的,别为它开小灶。
+ *
+ * `opts.baseline`:把整块数字往下补 `(1 − baseline) × 字高`。旁边有小字标签、两边用
+ * `align-items: baseline` 排的时候要用它 —— flex 认的基线是这一格的**底边**,而图集里数字的
+ * 墨迹底线在格高的 `baseline` 处,不补这一下,数字就会浮在标签上面。
+ */
+const NUM_OUTLINE_COLOR = '#2b3350';
+function numText(text, fontKey, height, color, opts = {}) {
+ const font = NUM_FONTS[fontKey];
+ const scale = height / font.cell[1];
+ const wrap = el('span', `dan-num dan-num-${fontKey}`);
+ wrap.setAttribute('role', 'img');
+ wrap.setAttribute('aria-label', text);
+ wrap.style.height = `${height}px`;
+ if (opts.baseline) {
+ wrap.style.position = 'relative';
+ wrap.style.top = `${((1 - font.baseline) * height).toFixed(2)}px`;
+ }
+ const chars = [...text];
+ if (opts.trim !== false) {
+ // 图集每格右边都留了空(分数族格宽 74、字形只有 60 左右),照格宽排会把一串数字
+ // 读成「一个字一个字分开」。收回 (格宽 − 步进):注意 CSS 里是**负**边距。
+ wrap.style.setProperty('--dan-num-trim', `${(font.cell[0] - (font.advance ?? font.cell[0])) * scale}px`);
+ }
+ chars.forEach((ch, index) => {
+ const cell = el('i', 'dan-num-char');
+ cell.style.height = `${height}px`;
+ if (index === chars.length - 1) cell.classList.add('dan-num-last');
+ const idx = font.chars[ch];
+ if (idx === undefined) {
+ cell.textContent = ch;
+ cell.classList.add('dan-num-plain');
+ if (color) cell.style.color = color;
+ wrap.append(cell);
+ return;
+ }
+ const sx = (idx % NUM_COLS) * font.cell[0];
+ const sy = Math.floor(idx / NUM_COLS) * font.cell[1];
+ const size = `${font.atlas[0] * scale}px ${font.atlas[1] * scale}px`;
+ // 小数点那一格画在字高中线上(26p 只有 8×8、90p 24×24),照原样排出来就是浮在数字中间
+ // 的一粒,像断字符。官方那只方块是**底边压在数字基线上**的,所以按 `dotBaseline`
+ // (那一格墨迹底线在格高里的比例)把它压下去 —— 字体自己的字形,只是摆到该在的位置。
+ //
+ // 注意:**不能**用挪 `background-position` 的办法下压 —— 图集是整张背景,往下挪 dy 就会把
+ // 上一格(同一列、上一行)的下半截露在这一格里(用户提过两次的那个 bug)。裁剪也救不了,
+ // 因为承载背景的那个 `b` 就是格子本身、并没有溢出。改成把**整格**往下挪:图集位置不动,
+ // 字形还是它自己那一格,只是这一格整体下沉 dy。
+ const dy = ch === '.' && font.dotBaseline ? (font.baseline - font.dotBaseline) * height : 0;
+ if (dy) cell.style.top = `${dy}px`;
+ const pos = `${-sx * scale}px ${-sy * scale}px`;
+ cell.style.width = `${font.cell[0] * scale}px`;
+ if (font.outline && opts.outline !== false) {
+ const outline = el('b', 'dan-num-outline');
+ const url = `url(/assets/maimai/${font.outline}.png)`;
+ outline.style.setProperty('background-color', NUM_OUTLINE_COLOR);
+ for (const prop of ['mask-image', '-webkit-mask-image']) outline.style.setProperty(prop, url);
+ for (const prop of ['mask-size', '-webkit-mask-size']) outline.style.setProperty(prop, size);
+ for (const prop of ['mask-position', '-webkit-mask-position']) outline.style.setProperty(prop, pos);
+ for (const prop of ['mask-repeat', '-webkit-mask-repeat']) outline.style.setProperty(prop, 'no-repeat');
+ cell.append(outline);
+ }
+ const fill = el('b', 'dan-num-fill');
+ const fillUrl = `url(/assets/maimai/${font.fill}.png)`;
+ if (color) {
+ // 白字族是纯白剪影:当遮罩 + 纯色背景 = 想染什么色就什么色(游戏里也是运行时染色)。
+ fill.style.setProperty('background-color', color);
+ for (const prop of ['mask-image', '-webkit-mask-image']) fill.style.setProperty(prop, fillUrl);
+ for (const prop of ['mask-size', '-webkit-mask-size']) fill.style.setProperty(prop, size);
+ for (const prop of ['mask-position', '-webkit-mask-position']) fill.style.setProperty(prop, pos);
+ for (const prop of ['mask-repeat', '-webkit-mask-repeat']) fill.style.setProperty(prop, 'no-repeat');
+ } else {
+ fill.style.backgroundImage = fillUrl;
+ fill.style.backgroundSize = size;
+ fill.style.backgroundPosition = pos;
+ }
+ cell.append(fill);
+ wrap.append(cell);
+ });
+ return wrap;
+}
+
+/**
+ * 官方评级徽章:`UI_GAM_Rank_*` —— 游戏内那套**单级全套**(D/C/B/BB/BBB/A/AA/AAA/S/S+/SS/SS+/SSS/SSS+)。
+ *
+ * 之前用的是 `UI_CMN_TabTitle_Rank_*`,但那是**页签标题**用的区间图:AAA 那张画的是
+ * 「A~AAA」、BBB 那张是「~BBB」,摆在逐题卡片上根本不是那一级的评级(而且没有
+ * D/C/B/BB/A/AA 这些级,低分全挤到同一张牌上)。换成游戏内那套之后 14 级各有一张。
+ */
+const RANK_ART = {
+ 'SSS+': 'gam-rank-sssp.png', SSS: 'gam-rank-sss.png', 'SS+': 'gam-rank-ssp.png', SS: 'gam-rank-ss.png',
+ 'S+': 'gam-rank-sp.png', S: 'gam-rank-s.png',
+ AAA: 'gam-rank-aaa.png', AA: 'gam-rank-aa.png', A: 'gam-rank-a.png',
+ BBB: 'gam-rank-bbb.png', BB: 'gam-rank-bb.png', B: 'gam-rank-b.png',
+ C: 'gam-rank-c.png', D: 'gam-rank-d.png',
+};
+
function openRunResult(run) {
resultDialog ??= buildResultDialog();
const dialog = resultDialog;
@@ -644,6 +855,22 @@ function openRunResult(run) {
const scored = cleared.filter((stage) => stage.achievement !== null);
dialog.dataset.verdict = verdict;
+ // 底图用原作素材,按模式选:档位挑战是「段位認定」那一版,
+ // 小/大随机段位是「ランダム段位認定」那一版(原作本来就把这两件事分成两块屏)。
+ // 页头文字与两侧水引都烘焙在底图里,所以标题 h2 只留给读屏。
+ field(dialog, '.dan-result-art').dataset.art =
+ run.tier === 'small_random' || run.tier === 'big_random' ? 'random' : 'dani';
+ const clearedRun = run.status === 'cleared';
+ // 左下那格是原版的段位名牌:照原版写**名字**(我们发档位不发段位名),不盖合格印。
+ // 通关几道另起一行小字放在名字下面,别把名字挤掉。
+ const plateName = field(dialog, '.dan-plate-name');
+ plateName.textContent = run.tierName;
+ plateName.dataset.len = String([...run.tierName].length);
+ // 结论(合格/不合格/中断)从原来那枚印改成名字下面的小字:印让位给段位名,
+ // 但「到底过没过」这句话还是得留着。
+ field(dialog, '.dan-verdict-count').textContent =
+ `${VERDICT_TEXT[run.status] ?? '中断'} · ${cleared.length} / ${run.stageCount} 道通关`;
+
const limitText = run.perStageLimit ? '每道限时按当题难度' : `每道限时 ${fmtLimit(run.limitSeconds)}`;
field(dialog, '.dan-result-sub').textContent =
`${run.tierName} · ${run.minRating}–${run.maxRating} · 共 ${run.stageCount} 道 · ${limitText}`
@@ -653,66 +880,118 @@ function openRunResult(run) {
const tracks = field(dialog, '.dan-result-tracks');
tracks.replaceChildren();
for (const stage of run.stages) {
- const tone = TRACK_TONE[cfRatingColor(stage.difficulty).tone] ?? TRACK_TONE.violet;
+ const color = cfRatingColor(stage.difficulty);
+ const tone = TRACK_TONE[color.tone] ?? TRACK_TONE.violet;
const item = el('li', 'dan-track');
item.dataset.outcome = stage.outcome ?? 'pending';
- item.append(el('span', 'dan-track-stamp', stage.outcome === 'cleared' ? '可' : '不可'));
-
- const card = el('div', 'dan-track-card');
- card.style.setProperty('--tc1', tone[0]);
- card.style.setProperty('--tc2', tone[1]);
-
- // 「封面」那一格放题号:我们抽的是 CF 题、没有曲绘,放题号比放装饰诚实。
+ // 逐题行的纵向节距是官方的 122px(见 dan.css)。
+ item.style.top = `${152 + 122 * (stage.index - 1)}px`;
+ // 底色变量挂在这一行上:乐曲边框读它(--tc1 主色场 / --tc2 外圈主色环)。
+ // 官方那五张边框的颜色与难度名是烘死的,我们按自己的 CF 分档上色。
+ item.style.setProperty('--tc1', tone[0]);
+ item.style.setProperty('--tc2', tone[1]);
+
+ // 可 / 不可 用原作那两枚印(UI_DNM_Icon_Result_01/02)。
+ const stamp = el('img', 'dan-track-stamp');
+ stamp.src = stage.outcome === 'cleared' ? '/assets/maimai/stamp-clear.png' : '/assets/maimai/stamp-fail.png';
+ stamp.alt = stage.outcome === 'cleared' ? '通过' : '未通过';
+ item.append(stamp);
+
+ // 行底板还是原作那张(UI_DNM_Result_musicBase_01);边框本体比行内区小 3.6px,
+ // 四条边会露出一点底板(原版贴图本来就留了这圈透明边)。
+ item.append(el('div', 'dan-track-plate'));
+
+ // 乐曲边框:照原作 UI_CMN_RSL_KopMBase_* 的版式画一层(尺寸全是从那张贴图量出来的比例,
+ // 见 dan.css 的「逐题行」一节)—— 外圈主色环 + 平色主色场 + 底部浅色带 + 左侧曲绘槽
+ // + 深蓝歌名条(占顶部一整行)+ 白色达成率盒 + 右下角 DX 分数白牌。
+ // 官方那五张(BSC/ADV/EXP/MST/MST_Re)颜色与难度名都烘死在图里,套不上我们的 CF 分档。
+ const frame = el('div', 'dan-track-frame');
+ // 底带先 append:它在白盒与右下白牌**下面**(原版就是这样一层浅色)。
+ frame.append(el('div', 'dan-track-band'));
+ // 歌名条:原版左上那格是难度名牌(MASTER 那块),删掉之后歌名条顶满上面一整行。
+ frame.append(el('span', 'dan-track-titlebar'));
+ // 题目名压在歌名条上(两者框内坐标相同)。抽到的题在本人提交里还没有记录时,退回题号 ——
+ // 空着比写「—」更难看,题号至少能对上。
+ frame.append(el('span', 'dan-track-name', stage.title ?? stage.problemId ?? '—'));
+ const achBox = el('div', 'dan-track-achbox');
+ // 官方白盒左上角那行小字(原作烘在边框贴图里,我们画边框所以自己写)。
+ // 用原版那三个字:`達成率` —— 别自己写英文,官方结算这一行就是日文。
+ achBox.append(el('small', 'dan-track-achlabel', '達成率'));
+ // 官方分数图集(按分数分色)+ 官方彩色「%」字形 —— 这是这一行最大的一块数字。
+ // 没有成绩时用白字族的「—」,**染深蓝**:白字族是纯白剪影,落在达成率那个白框上会看不见
+ // (numText 里没这个字形,会退回普通文字,不会静默丢字符)。
+ const achValue = stage.achievement === null
+ ? numText('—', 'n90', 44, DX_NUM_COLOR)
+ : achievementNumber(stage.achievement, 44);
+ achValue.classList.add('dan-track-achvalue');
+ achBox.append(achValue);
+ frame.append(achBox);
+ item.append(frame);
+
+ // 单题 DX 分数(= 该题算出的 rating)挪到**卡片右下角**:原版那块白牌就压在底带右端,
+ // 上面烘的是日文 `でらっくスコア`(我们写「DX分数」)。底带的左半边留给限时。
+ const dxBox = el('div', 'dan-track-dxbox');
+ dxBox.append(el('small', 'dan-track-dxlabel', 'DX分数'));
+ // 这一格是**小字**:不叠描边层 —— 官方那处也是干净的深蓝数字,叠上去只会糊。
+ const dx = stage.rating === null
+ ? numText('—', 'n26', 16, DX_NUM_COLOR, { outline: false, baseline: true })
+ : numText(String(stage.rating.toFixed(1)), 'n26', 16, DX_NUM_COLOR, { outline: false, baseline: true });
+ dx.classList.add('dan-track-dxnum');
+ dxBox.append(dx);
+ item.append(dxBox);
+
+ // 官方的 JacketImage_S 那一格:我们抽的是 CF 题、没有曲绘,槽里放**题号**(`1554C`:
+ // 场次 + 小问)与这题在 CF 上的 rating。原来那版把场次和小问拆成两行、题号只留个小问
+ // 字母,一屏四张卡看不出是哪道题 —— 现在一行题号、一行 rating。
const [contest, index] = (stage.problemId ?? '').split(':');
const jacket = el('div', 'dan-track-jacket');
- jacket.append(el('b', '', index || '?'), el('small', '', contest || '未开始'));
- card.append(jacket);
-
- const info = el('div', 'dan-track-info');
- const head = el('div', 'dan-track-head');
- head.append(el('span', 'dan-track-no', `STAGE ${String(stage.index).padStart(2, '0')}`));
- head.append(el('span', 'dan-track-diff', `${stage.difficulty} 分`));
- head.append(el('span', 'dan-track-name', stage.title ?? '—'));
- info.append(head);
-
- const achievement = el('div', 'dan-track-ach');
- achievement.append(el('span', 'dan-track-achlabel', '达成率'));
- achievement.append(goldNode('dan-track-achvalue dan-gold',
- stage.achievement === null ? '—' : `${stage.achievement.toFixed(4)}%`));
- achievement.append(el('span', 'dan-track-judge',
- stage.outcome === 'cleared' ? (stage.rank ?? '通关')
- : stage.outcome === 'timeout' ? '超时' : stage.outcome === 'interrupted' ? '中断' : '未开始'));
- info.append(achievement);
- card.append(info);
-
- const side = el('div', 'dan-track-side');
- side.append(el('span', 'dan-track-lv', String(stage.difficulty)));
- const score = el('span', 'dan-track-score');
- score.append(el('small', '', 'DX分数'), el('b', '', stage.rating === null ? '—' : stage.rating.toFixed(1)));
- side.append(score);
- side.append(el('small', 'dan-track-limit', `限时 ${fmtLimit(stage.limitSeconds)}`));
- card.append(side);
- item.append(card);
+ jacket.append(
+ el('b', '', stage.problemId ? `${contest}${index}` : '未开始'),
+ // 题号没下发(还没开始做)时 rating 也不写:那边 `problemId` 是 null,这一格照同一口径。
+ el('small', '', stage.problemId && stage.difficulty != null ? String(stage.difficulty) : '—'),
+ );
+ item.append(jacket);
+
+ // 题目名已经在上面跟歌名条一起 append 了(两者框内坐标相同)。原来标题条左边还有一格
+ // **档位名牌**(写「上级」「初级」),删掉了:同一张卡上达成率、单题分、题号已经把信息
+ // 说全,档位名重复而且占了半个标题条 —— 歌名条因此顶满上面一整行。
+
+ // 评级用官方徽章;超时/中断/未开始没有评级,退回文字。徽章放在**右上那块紫场**里
+ // (白盒右端…边框内右沿 / 歌名条下沿…右下白牌上沿):原版那一块本来就是空的。
+ if (stage.outcome === 'cleared' && RANK_ART[stage.rank]) {
+ const badge = el('img', 'dan-track-badge');
+ badge.src = `/assets/maimai/${RANK_ART[stage.rank]}`;
+ badge.alt = stage.rank;
+ item.append(badge);
+ } else {
+ item.append(el('span', 'dan-track-judge',
+ stage.outcome === 'timeout' ? '超时' : stage.outcome === 'interrupted' ? '中断' : '未开始'));
+ }
+
+ // 限时写在**底带的左侧**(原版这条底带是空的一块浅色,右端才是 DX 分数那块白牌)。
+ item.append(el('small', 'dan-track-limit', `限时 ${fmtLimit(stage.limitSeconds)}`));
+
tracks.append(item);
}
- field(dialog, '.dan-result-rankbox strong').textContent = VERDICT_TEXT[run.status] ?? '中断';
- field(dialog, '.dan-result-rankbox small').textContent = `${cleared.length} / ${run.stageCount} 道通关`;
-
- // 原作这一格是显示剩余生命的心;我们没有生命值,用「通关几道」占这一格。
- const life = field(dialog, '.dan-result-life');
+ // 原作这一格是显示剩余生命的心;我们没有生命值,用心里的那个数字表示「四道里过了几道」。
+ // 不写 4/4:数字图集里没有「/」这个字形,退回普通文字会被两边数字的描边吃掉看不见;
+ // 过没过、过几道,白框名字下面那行小字写得更清楚。
+ const life = field(dialog, '.dan-life');
life.dataset.state = run.status === 'cleared' ? 'ok' : 'bad';
- field(dialog, '.dan-result-life span').replaceChildren(
- document.createTextNode(String(cleared.length)), el('small', '', `/${run.stageCount}`));
-
- // 原作的「总达成率」是四首歌达成率之和、「DX分数」是四首 DX 分数之和;
- // 这里对应各道达成率之和与各道单题 rating 之和。
+ field(dialog, '.dan-life-base').src = clearedRun
+ ? '/assets/maimai/life-base-green.png' : '/assets/maimai/life-base-red.png';
+ field(dialog, '.dan-life-base').alt = clearedRun ? '通关' : '未通关';
+ field(dialog, '.dan-life-count').replaceChildren(numText(String(cleared.length), 'n26', 46));
+
+ // 原作的「总达成率」是四首歌达成率之和、「でらっくスコア」是四首单曲 DX 分数之和。
+ // 我们这边对应:各道达成率之和,以及**各道单题 rating 之和**(同一道题进 B50 用的就是
+ // 这个单题 rating;之和只是展示,不等于 B50 总分真的涨这么多)。
const totalAchievement = scored.reduce((sum, stage) => sum + stage.achievement, 0);
- const totalText = scored.length ? `${totalAchievement.toFixed(4)}%` : '—';
- const totalNode = field(dialog, '.dan-total-ach');
- totalNode.textContent = totalText;
- totalNode.dataset.text = totalText;
- field(dialog, '.dan-total-score b').textContent = run.totalRating === null ? '—' : run.totalRating.toFixed(1);
+ field(dialog, '.dan-total-num').replaceChildren(
+ scored.length ? achievementNumber(totalAchievement, 60) : numText('—', 'n90', 60, DX_NUM_COLOR));
+ field(dialog, '.dan-dxscore-num').replaceChildren(
+ numText(run.totalRating === null ? '—' : String(run.totalRating.toFixed(1)), 'n90', 30, DX_NUM_COLOR, { outline: false, baseline: true }));
const deltas = run.stages.map((stage) => stage.comparison?.ratingDelta).filter((value) => typeof value === 'number');
let note = run.status === 'cleared'
diff --git a/scripts/dan-settlement-e2e.mjs b/scripts/dan-settlement-e2e.mjs
new file mode 100644
index 0000000..5fba8f4
--- /dev/null
+++ b/scripts/dan-settlement-e2e.mjs
@@ -0,0 +1,588 @@
+/**
+ * 段位認定结算页的浏览器 E2E:真的起一个 headless Edge,打开 /dan.html,
+ * 把整轮结算那一屏量一遍,顺手出三张图给 paint-probe.py 数像素:
+ *
+ * 1. 四道都打满(SSS+,达成率全金)—— 版式与着色是否照官方那套
+ * 2. 四道达成率故意拉开(金 / 蓝 / 红)—— 分数分色到底有没有落实
+ * 3. 一道超时取消 —— 不合格那一屏的占位符与红心
+ *
+ * 需要本地已经跑着一个服务(默认 `127.0.0.1:8789`,库 `data/dan-ui.sqlite`):
+ *
+ * node scripts/dan-settlement-e2e.mjs
+ * DAN_BASE=http://127.0.0.1:8788 DAN_DB=data/demo-b50.sqlite node scripts/dan-settlement-e2e.mjs
+ *
+ * 这不是 `node --test` 那套单测(那些不碰浏览器);这个脚本要 Edge、要一个跑着的服务,
+ * 所以不进 CI,只当本地验收用。段位规则本身由 tests/dan.test.ts 覆盖。
+ */
+import { spawn } from 'node:child_process';
+import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { dirname, join, resolve } from 'node:path';
+import { setTimeout as sleep } from 'node:timers/promises';
+import { DatabaseSync } from 'node:sqlite';
+import { fileURLToPath, pathToFileURL } from 'node:url';
+
+const REPO = resolve(dirname(fileURLToPath(import.meta.url)), '..');
+const DB = process.env.DAN_DB ? resolve(process.env.DAN_DB) : join(REPO, 'data', 'dan-ui.sqlite');
+const BASE = process.env.DAN_BASE ?? 'http://127.0.0.1:8789';
+const SHOTS = process.env.DAN_SHOTS ?? join(tmpdir(), 'dan-e2e-shots');
+const EDGE = process.env.DAN_EDGE ?? 'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe';
+const USER = Number(process.env.DAN_USER ?? 1);
+
+/** 结算盘坐标:底图 980×928,逐题行与底部三块都按底图里量出来的位置。 */
+const ROW = { left: 267, width: 576, tops: [152, 274, 396, 518] };
+const PLATE = { left: 74, top: 672, w: 240, h: 230 }; // 左下段位名牌
+const LIFE = { left: 334, top: 690, w: 200, h: 200 };
+const TOTAL_LABEL = { left: 503, top: 663, w: 354, h: 52 }; // 底图预留的白格
+const DX_ROW = { left: 608, top: 810, w: 360, h: 80 }; // 底图预留的白牌
+const RIGHT_EDGE = 950; // 大数字的右端(与 DX 白牌右端同一条边)
+/**
+ * 逐题行内部的版式(**行内坐标**)。每个数字都是从原版 `UI_CMN_RSL_KopMBase_MST.png`
+ * (636×136 的精裁版,官方设计尺寸 640×140)逐行扫像素量出来的,换算见 public/dan.css:
+ * `行内坐标 = (原版文件坐标 + 2) × 0.9`(我们的行内区 576×126 = 官方 640×140 × 0.9)。
+ */
+const CARD = {
+ frame: { left: 3.6, top: 3.6, w: 568.8, h: 118.8 }, // 边框本体(外圈 2px 主色环)
+ slot: { left: 10.8, top: 10.8, w: 105.3, h: 105.3 }, // 左上的曲绘槽(原版是方槽)
+ titlebar: { left: 122.4, top: 10.8, w: 442.8, h: 18 }, // 深蓝歌名条(占顶部一整行)
+ achbox: { left: 122.4, top: 36, w: 328.5, h: 61.2 }, // 白色达成率盒(右下角一道大 R 弧)
+ band: { left: 122.4, top: 97.2, w: 345.6, h: 23.4 }, // 底部浅色带
+ dxbox: { left: 450.9, top: 97.2, w: 119.7, h: 23.4 }, // 右下角 DX 分数白牌(胶囊)
+ badge: { left: 464, top: 43, w: 99, h: 40 }, // 右上紫场里的评级徽章
+};
+const near = (value, want, tol = 2) => Math.abs(value - want) <= tol;
+const rectNear = (rect, want, tol = 2, keys = ['left', 'top', 'w', 'h']) => Boolean(rect)
+ && keys.every((key) => near(rect[key], want[key], tol));
+const rectIn = (inner, outer, slack = 2) => Boolean(inner && outer)
+ && inner.left >= outer.left - slack && inner.top >= outer.top - slack
+ && inner.left + inner.w <= outer.left + outer.w + slack
+ && inner.top + inner.h <= outer.top + outer.h + slack;
+const fmtRect = (rect) => (rect ? `${rect.left},${rect.top} ${rect.w}×${rect.h}` : '缺失');
+/** 等级 → 素材:官方 `UI_GAM_Rank_*`(游戏内那套单级全套)。 */
+const RANK_FILE = {
+ 'SSS+': 'gam-rank-sssp.png', SSS: 'gam-rank-sss.png', 'SS+': 'gam-rank-ssp.png', SS: 'gam-rank-ss.png',
+ 'S+': 'gam-rank-sp.png', S: 'gam-rank-s.png', AAA: 'gam-rank-aaa.png', AA: 'gam-rank-aa.png',
+ A: 'gam-rank-a.png', BBB: 'gam-rank-bbb.png', BB: 'gam-rank-bb.png', B: 'gam-rank-b.png',
+ C: 'gam-rank-c.png', D: 'gam-rank-d.png',
+};
+
+let failures = 0;
+const check = (label, ok, extra = '') => {
+ console.log(`${ok ? ' PASS' : ' FAIL'} ${label}${extra ? ` — ${extra}` : ''}`);
+ if (!ok) failures += 1;
+};
+const open = () => {
+ const db = new DatabaseSync(DB);
+ db.exec('PRAGMA busy_timeout = 8000');
+ return db;
+};
+const call = async (path, body) => {
+ const res = await fetch(`${BASE}${path}`, body
+ ? { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }
+ : { cache: 'no-store' });
+ return { status: res.status, body: await res.json() };
+};
+const run = async (path, body) => {
+ const res = await call(path, body);
+ if (res.status !== 200) throw new Error(`${path} → HTTP ${res.status} ${JSON.stringify(res.body)}`);
+ return res.body;
+};
+
+if (!existsSync(EDGE)) {
+ console.log(`Edge 不在 ${EDGE},跳过浏览器验收`);
+ process.exit(0);
+}
+if (!existsSync(SHOTS)) mkdirSync(SHOTS, { recursive: true });
+
+/** 题目名:先看本人提交里的真名,没有就尽力从 CF 全量清单里查一次,都没有就退回题号。 */
+let cfNames = null;
+const problemName = async (problemId) => {
+ const known = open().prepare(
+ "SELECT MAX(problem_title) AS title FROM submissions WHERE platform='codeforces' AND problem_id=?").get(problemId);
+ if (known?.title) return known.title;
+ if (cfNames === null) {
+ cfNames = new Map();
+ try {
+ const res = await fetch('https://codeforces.com/api/problemset.problems', { signal: AbortSignal.timeout(20000) });
+ const body = await res.json();
+ for (const p of body?.result?.problems ?? []) cfNames.set(`${p.contestId}:${p.index}`, p.name);
+ } catch { /* 离线就用题号,不影响验收 */ }
+ }
+ return cfNames.get(problemId) ?? problemId;
+};
+
+const { secondsForAchievement } = await import(pathToFileURL(join(REPO, 'src', 'dx', 'rating.ts')).href);
+
+/* ---------- 造一轮:四道都按指定达成率通关 ---------- */
+
+const seeded = [];
+
+async function clearRun({ tier, kind, achievements }) {
+ seeded.length = 0;
+ const started = await run('/api/dan/start', { userId: USER, kind, tier, tz: 480 });
+ const sessionId = started.session.id;
+ const stageCount = started.session.stageCount;
+ for (let round = 1; round <= stageCount; round += 1) {
+ await run('/api/dan/claim', { userId: USER, sessionId });
+ const timer = open().prepare(
+ "SELECT id, problem_id FROM practice_timers WHERE status='running' ORDER BY started_at DESC LIMIT 1").get();
+ const stage = open().prepare(
+ 'SELECT difficulty FROM dan_stages WHERE session_id=? AND stage_index=?').get(sessionId, round);
+ const now = Math.floor(Date.now() / 1000);
+ const target = achievements[round - 1];
+ // `SLOW_SECONDS_FACTOR[i]` 把第 i 道的时间乘一个系数 —— 评分曲线只在 ≥97% 那段可逆,
+ const seconds = Math.round(secondsForAchievement(stage.difficulty, target).seconds);
+ const title = await problemName(timer.problem_id);
+ const write = open();
+ write.prepare('UPDATE practice_timers SET started_at=? WHERE id=?').run(now - seconds, timer.id);
+ write.prepare(`INSERT INTO submissions(account_id,platform,submission_id,problem_id,problem_title,status,submitted_at,difficulty)
+ VALUES(?,?,?,?,?,?,?,?)`).run(accountId, 'codeforces', `dan-e2e-${sessionId.slice(0, 6)}-${round}-${now}`,
+ timer.problem_id, title, 'AC', now, stage.difficulty);
+ write.close();
+ seeded.push({ sessionId, index: round, title });
+ await run('/api/dan/settle', { userId: USER, tz: 480 });
+ if (round < stageCount) await run('/api/dan/next', { userId: USER, tz: 480 });
+ }
+ const row = (await call(`/api/dan?user=${USER}&tz=480`)).body.history.find((r) => r.id === sessionId);
+ return row;
+}
+
+// 清场:收掉遗留的活动会话、清掉 dan 记录,从零开始。
+const overview = (await call(`/api/dan?user=${USER}&tz=480`)).body;
+if (overview.active) await call('/api/dan/abandon', { userId: USER, sessionId: overview.active.id });
+{
+ const clean = open();
+ clean.exec('DELETE FROM dan_stages; DELETE FROM dan_sessions; DELETE FROM practice_timers;');
+ clean.close();
+}
+const accountId = Number(open().prepare(
+ "SELECT id FROM accounts WHERE platform='codeforces' AND is_archived=0 ORDER BY id LIMIT 1").get().id);
+console.log(`账号 id=${accountId},库 ${DB},服务 ${BASE},截图 ${SHOTS}`);
+
+console.log('\n=== 1) 四道都打满(SSS+) ===');
+const perfect = await clearRun({ tier: 'advanced', kind: 'challenge', achievements: [100.8, 100.8, 100.8, 100.8] });
+check('这一轮判为 cleared', perfect?.status === 'cleared', `${perfect?.status}`);
+check('四道都记了达成率与单题 rating',
+ perfect?.stages.every((s) => typeof s.achievement === 'number' && typeof s.rating === 'number'),
+ perfect?.stages.map((s) => `${s.difficulty}:${s.achievement.toFixed(2)}%/${s.rating}`).join(' '));
+const expectAch = perfect.stages.reduce((sum, s) => sum + s.achievement, 0);
+const expectRating = Math.round(perfect.stages.reduce((sum, s) => sum + s.rating, 0) * 10) / 10;
+check('本轮总分 = 四道单题 rating 之和', Math.abs(perfect.totalRating - expectRating) < 0.05,
+ `${perfect.totalRating} vs ${expectRating}`);
+
+/* ---------- 起浏览器 ---------- */
+
+const dir = mkdtempSync(join(tmpdir(), 'dan-e2e-'));
+const edge = spawn(EDGE, ['--headless=new', '--remote-debugging-port=0', `--user-data-dir=${dir}`,
+ '--no-first-run', '--no-default-browser-check', '--window-size=1440,1400', 'about:blank'],
+ { stdio: 'ignore', windowsHide: true });
+
+let cdp = null;
+const problems = [];
+const badResponses = [];
+try {
+ for (let i = 0; i < 40 && !cdp; i += 1) {
+ await sleep(250);
+ try {
+ const [port, wsPath] = readFileSync(join(dir, 'DevToolsActivePort'), 'utf8').trim().split(/\r?\n/);
+ cdp = await (await import(pathToFileURL(join(REPO, 'src', 'fetchers', 'cf-browser.ts')).href))
+ .Cdp.connect(`ws://127.0.0.1:${port}${wsPath}`);
+ } catch { /* Edge 还没起来 */ }
+ }
+ if (!cdp) throw new Error('没连上 Edge 的 CDP');
+ const { targetId } = await cdp.call('Target.createTarget', { url: 'about:blank' });
+ const { sessionId } = await cdp.call('Target.attachToTarget', { targetId, flatten: true });
+ cdp.socket.addEventListener('message', (event) => {
+ const message = JSON.parse(String(event.data));
+ if (message.method === 'Runtime.exceptionThrown') {
+ problems.push(`异常: ${message.params.exceptionDetails.text} ${message.params.exceptionDetails.exception?.description ?? ''}`);
+ }
+ if (message.method === 'Runtime.consoleAPICalled' && ['error', 'warning'].includes(message.params.type)) {
+ problems.push(`console.${message.params.type}: ${message.params.args.map((a) => a.value ?? a.description).join(' ')}`);
+ }
+ if (message.method === 'Network.responseReceived') {
+ const { url, status } = message.params.response;
+ if (status >= 400 && url.includes('127.0.0.1')) badResponses.push(`${status} ${url}`);
+ }
+ });
+ for (const domain of ['Runtime', 'Log', 'Page', 'Network']) await cdp.call(`${domain}.enable`, {}, sessionId);
+ await cdp.call('Emulation.setDeviceMetricsOverride',
+ { width: 1440, height: 1400, deviceScaleFactor: 1, mobile: false }, sessionId);
+
+ const evaluate = async (expression) => {
+ const result = await cdp.call('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true }, sessionId);
+ if (result.exceptionDetails) throw new Error(`页面内异常 ${JSON.stringify(result.exceptionDetails)}`);
+ return result.result.value;
+ };
+ const openPage = async (url, wait = 3200) => { await cdp.call('Page.navigate', { url }, sessionId); await sleep(wait); };
+ const shot = async (name) => {
+ const { data } = await cdp.call('Page.captureScreenshot', { format: 'png', captureBeyondViewport: true }, sessionId);
+ writeFileSync(join(SHOTS, name), Buffer.from(data, 'base64'));
+ console.log(` 截图 → ${join(SHOTS, name)}`);
+ };
+ /** 只拍结算盘本身(文档插图用,不带上页面下那堆说明)。 */
+ const shotDialog = async (name) => {
+ const rect = JSON.parse(await evaluate(`(() => {
+ const d = document.querySelector('dialog.dan-result');
+ if (!d) return 'null';
+ const r = d.getBoundingClientRect();
+ return JSON.stringify({ x: r.left + window.scrollX, y: r.top + window.scrollY,
+ width: r.width, height: r.height });
+ })()`));
+ if (!rect) return;
+ const { data } = await cdp.call('Page.captureScreenshot',
+ { format: 'png', clip: { ...rect, scale: 2 }, captureBeyondViewport: true }, sessionId);
+ writeFileSync(join(SHOTS, name), Buffer.from(data, 'base64'));
+ console.log(` 截图 → ${join(SHOTS, name)}`);
+ };
+ const clickByText = (selector, text) => evaluate(`(() => {
+ const node = [...document.querySelectorAll(${JSON.stringify(selector)})].find((n) => n.textContent.includes(${JSON.stringify(text)}));
+ if (!node) return 'NOT_FOUND';
+ node.click();
+ return 'CLICKED';
+ })()`);
+ /** 打开最新一轮的结算页:认定记录里那条「查看结算」。 */
+ const openLatestResult = async () => {
+ await openPage(`${BASE}/dan.html`);
+ const clicked = await clickByText('.dan-actions .btn', '查看结算');
+ await sleep(1500);
+ return clicked;
+ };
+ /** 读结算盘:行内组件用**行底板**做原点,底部组件用底图做原点(都是底图 980×928 那一套坐标)。 */
+ const readLayout = () => evaluate(`(() => {
+ const dialog = document.querySelector('dialog.dan-result');
+ const art = dialog?.querySelector('.dan-result-art');
+ if (!art) return JSON.stringify({ open: false, rows: [] });
+ const box = art.getBoundingClientRect();
+ const relTo = (origin, node) => {
+ if (!node) return null;
+ const r = node.getBoundingClientRect();
+ return { left: Math.round(r.left - origin.left), top: Math.round(r.top - origin.top),
+ w: Math.round(r.width), h: Math.round(r.height) };
+ };
+ const rel = (node) => relTo(box, node);
+ const text = (root, sel) => root.querySelector(sel)?.textContent ?? null;
+ // aria-label 挂在最里面那层 .dan-num 上;选择器可能指的是包着它的容器。
+ const numNode = (root, sel) => {
+ const node = root.querySelector(sel);
+ if (!node) return null;
+ return node.classList.contains('dan-num') ? node : node.querySelector('.dan-num');
+ };
+ const shown = (root, sel) => numNode(root, sel)?.getAttribute('aria-label') ?? null;
+ const font = (root, sel) => {
+ const node = numNode(root, sel);
+ if (!node) return null;
+ // 真正上色/上贴图的是里面那两层
:分数族走 background-image(自带颜色),
+ // 白字族走 mask + background-color(游戏里就是这样运行时染色的)。
+ const fill = node.querySelector('.dan-num-fill');
+ const style = getComputedStyle(fill ?? node);
+ const cell = node.querySelector('.dan-num-char');
+ const cellStyle = cell ? getComputedStyle(cell) : null;
+ const advance = cell
+ ? cell.getBoundingClientRect().width + (parseFloat(cellStyle.marginRight) || 0) : 0;
+ // 图集里没有的字符(「—」这种占位符)走普通文字那条路:颜色落在文字本身上。
+ const plain = node.querySelector('.dan-num-plain');
+ return {
+ fill: plain ? getComputedStyle(plain).color : style.backgroundColor,
+ mask: style.maskImage || style.webkitMaskImage,
+ atlas: style.backgroundImage,
+ outline: Boolean(node.querySelector('.dan-num-outline')),
+ label: node.getAttribute('aria-label'),
+ height: Math.round(node.getBoundingClientRect().height),
+ advance: Math.round(advance * 100) / 100,
+ width: Math.round(node.getBoundingClientRect().width),
+ };
+ };
+ return JSON.stringify({
+ open: Boolean(dialog?.open),
+ plate: { rect: rel(dialog.querySelector('.dan-verdict')), name: text(dialog, '.dan-plate-name'),
+ nameLen: dialog.querySelector('.dan-plate-name')?.dataset.len ?? null,
+ nameFont: (() => { const n = dialog.querySelector('.dan-plate-name'); if (!n) return null;
+ const s = getComputedStyle(n); return { size: s.fontSize, weight: s.fontWeight, fill: s.color }; })(),
+ count: text(dialog, '.dan-verdict-count'),
+ legacyStamp: Boolean(dialog.querySelector('.dan-verdict-img')),
+ nameRect: rel(dialog.querySelector('.dan-plate-name')) },
+ life: { rect: rel(dialog.querySelector('.dan-life')), label: shown(dialog, '.dan-life-count'),
+ base: dialog.querySelector('.dan-life-base')?.getAttribute('src')?.split('/').pop() ?? null },
+ total: { label: text(dialog, '.dan-total-label'), labelRect: rel(dialog.querySelector('.dan-total-label')),
+ num: shown(dialog, '.dan-total-num'), rect: rel(dialog.querySelector('.dan-total-num')),
+ font: font(dialog, '.dan-total-num') },
+ dx: { label: text(dialog, '.dan-dxscore-label'), num: shown(dialog, '.dan-dxscore-num'),
+ rect: rel(numNode(dialog, '.dan-dxscore-num')), rowRect: rel(dialog.querySelector('.dan-dxscore-row')),
+ labelRect: rel(dialog.querySelector('.dan-dxscore-label')), font: font(dialog, '.dan-dxscore-num') },
+ rows: [...dialog.querySelectorAll('.dan-track')].map((track) => {
+ const rowBox = track.getBoundingClientRect();
+ return {
+ rect: rel(track),
+ no: text(track, '.dan-track-no'), name: text(track, '.dan-track-name'),
+ // 档位名牌已经删掉;这两项留着是为了断言它**不在**(并且标题条左移顶上那一格)。
+ diff: text(track, '.dan-track-diff'), diffLines: track.querySelectorAll('.dan-track-diff > *').length,
+ titlebar: relTo(rowBox, track.querySelector('.dan-track-titlebar')),
+ jacket: text(track, '.dan-track-jacket'), jacketBig: text(track, '.dan-track-jacket b'),
+ jacketSmall: text(track, '.dan-track-jacket small'),
+ ach: shown(track, '.dan-track-achvalue'), achFont: font(track, '.dan-track-achvalue'),
+ // 达成率那串数字自己的框(取里面那层 .dan-num,不是外面那个定宽容器)。
+ achNumRect: relTo(rowBox, numNode(track, '.dan-track-achvalue')),
+ dx: shown(track, '.dan-track-dxnum'), dxFont: font(track, '.dan-track-dxnum'),
+ limit: text(track, '.dan-track-limit'), judge: text(track, '.dan-track-judge'),
+ badge: track.querySelector('.dan-track-badge')?.getAttribute('src')?.split('/').pop() ?? '',
+ // badgeRect / stamp 是**底图坐标**(抠图用),badgeRow 才是行内坐标。
+ badgeRect: rel(track.querySelector('.dan-track-badge')),
+ badgeRow: relTo(rowBox, track.querySelector('.dan-track-badge')),
+ stamp: rel(track.querySelector('.dan-track-stamp')),
+ boxes: ['no', 'jacket', 'dxbox', 'dxnum', 'titlebar', 'name', 'diff', 'achbox', 'achvalue',
+ 'badge', 'limit', 'frame', 'band']
+ .map((key) => [key, relTo(rowBox, track.querySelector('.dan-track-' + key))]).filter(([, r]) => r),
+ };
+ }),
+ });
+ })()`);
+
+ console.log('\n=== 2) 结算页版式(四道满分成 → 金) ===');
+ check('认定记录里点得到「查看结算」', await openLatestResult() === 'CLICKED');
+ const page = JSON.parse(await readLayout());
+ check('结算页弹出来了', page.open === true);
+ const rows = page.rows ?? [];
+ check('四道各一张卡', rows.length === 4, `${rows.length} 张`);
+ check('四张卡按官方坐标摆:x 267 宽 576,行距 122',
+ rows.every((row, i) => Math.abs(row.rect.left - ROW.left) <= 2 && Math.abs(row.rect.w - ROW.width) <= 2
+ && Math.abs(row.rect.top - ROW.tops[i]) <= 2),
+ rows.map((row) => `${row.rect.top}@${row.rect.w}`).join(' '));
+ const outOfRow = [];
+ for (const row of rows) {
+ for (const [key, rect] of row.boxes) {
+ if (rect.left < -2 || rect.top < -2 || rect.left + rect.w > row.rect.w + 2 || rect.top + rect.h > row.rect.h + 2) {
+ outOfRow.push(`${row.no}:${key}`);
+ }
+ }
+ }
+ check('行内组件都待在底板里', outOfRow.length === 0, outOfRow.join(','));
+ /** 行内子组件的 rect(`boxes` 里量的,全部是**行内坐标**)。 */
+ const boxOf = (row, key) => (row.boxes.find(([name]) => name === key) ?? [])[1];
+ check('边框本体照原版量出来的比例摆(行内 3.6,3.6 / 568.8×118.8)',
+ rows.every((row) => rectNear(boxOf(row, 'frame'), CARD.frame)),
+ fmtRect(boxOf(rows[0], 'frame')));
+ check('曲绘槽是原版那块方槽(行内 10.8,10.8 / 105.3×105.3),里面写完整题号 + 这一题的 CF rating',
+ rows.every((row) => rectNear(boxOf(row, 'jacket'), CARD.slot)
+ && /^\d+[A-Z]+\d*$/.test(row.jacketBig) && /^\d+$/.test(row.jacketSmall)),
+ `${fmtRect(boxOf(rows[0], 'jacket'))} · ${rows[0].jacketBig}/${rows[0].jacketSmall}`);
+ check('难度名牌已删掉,歌名条占顶部一整行(左端与白盒左端对齐、右端贴到边框内右沿)',
+ rows.every((row) => !row.diff && row.diffLines === 0
+ && rectNear(row.titlebar, CARD.titlebar)
+ // 「占一整行」= 左端跟白盒左端对齐(原版那格难度名牌的位置被歌名条顶掉),
+ // 右端离边框外沿只剩 7.2px(原版 8px × 0.9)。
+ && near(row.titlebar.left, boxOf(row, 'achbox').left)
+ && near(row.titlebar.left + row.titlebar.w, CARD.frame.left + CARD.frame.w - 7.2)
+ && near(row.titlebar.top, CARD.frame.top + 7.2)),
+ rows.map((row) => `diff=${row.diff || '无'} 条 ${row.titlebar?.left}…${row.titlebar?.left + row.titlebar?.w}`).join(' '));
+ // 达成率那串数字必须待在白盒里,而且白盒不能碰到右边那块紫场里的评级徽章
+ // (原版白盒右下角那道大 R 弧就是给徽章让位的)。
+ const spill = rows.map((row) => {
+ const box = boxOf(row, 'achbox');
+ const num = row.achNumRect;
+ if (!box || !num) return 'no-box';
+ const right = num.left + num.w;
+ if (num.left < box.left) return `数字出盒左 ${num.left} < ${box.left}`;
+ if (right > box.left + box.w - 6) return `数字出盒右 ${right} > ${box.left + box.w - 6}`;
+ // 右对齐:整串的右端固定在离白盒右沿 6…20px 的地方(短一位数的串不许往左缩)。
+ if (right < box.left + box.w - 20) return `数字没右对齐 ${right} < ${box.left + box.w - 20}`;
+ if (num.top < box.top || num.top + num.h > box.top + box.h + 1) {
+ return `数字出盒高 ${num.top}..${num.top + num.h} vs ${box.top}..${box.top + box.h}`;
+ }
+ if (row.badgeRow && box.left + box.w + 10 > row.badgeRow.left) {
+ return `白盒碰徽章 ${Math.round(box.left + box.w + 10 - row.badgeRow.left)}px`;
+ }
+ return '';
+ }).filter(Boolean);
+ check('达成率数字不溢出白盒、白盒与右侧徽章留 10px 以上空隙',
+ spill.length === 0,
+ spill.join(',') || `数字 ${fmtRect(rows[0].achNumRect)} 盒 ${fmtRect(boxOf(rows[0], 'achbox'))}`
+ + ` 徽章左 ${rows[0].badgeRow?.left}`);
+ check('DX 分数在卡片右下角、右端贴到边框内右沿(与底带同高的那块白牌)',
+ rows.every((row) => {
+ const dx = boxOf(row, 'dxbox');
+ const box = boxOf(row, 'achbox');
+ return rectNear(dx, CARD.dxbox)
+ && near(dx.left + dx.w, CARD.frame.left + CARD.frame.w - 1.8)
+ && dx.top >= box.top + box.h - 2 && dx.left >= box.left + box.w;
+ }),
+ fmtRect(boxOf(rows[0], 'dxbox')));
+ check('限时写进底带左侧(原版那条浅色带,右端才是 DX 分数白牌)',
+ rows.every((row) => {
+ const band = boxOf(row, 'band');
+ const limit = boxOf(row, 'limit');
+ const dx = boxOf(row, 'dxbox');
+ return rectNear(band, CARD.band) && rectIn(limit, band, 0) && near(limit.left, band.left)
+ && limit.left + limit.w <= dx.left + 1;
+ }),
+ `带 ${fmtRect(boxOf(rows[0], 'band'))} · 限时 ${fmtRect(boxOf(rows[0], 'limit'))}`);
+ check('评级徽章放进右上那块紫场(白盒右边 · 歌名条下边 · 白牌上边)',
+ rows.every((row) => {
+ if (!row.badgeRow) return true; // 超时/未开始那一屏用文字判定,没有徽章
+ const box = boxOf(row, 'achbox');
+ const dx = boxOf(row, 'dxbox');
+ return rectNear(row.badgeRow, CARD.badge)
+ && row.badgeRow.left >= box.left + box.w
+ && row.badgeRow.top >= row.titlebar.top + row.titlebar.h
+ && row.badgeRow.top + row.badgeRow.h <= dx.top;
+ }),
+ fmtRect(rows[0].badgeRow));
+ check('标题条写题目名,不是「段位認定第 N 道」',
+ rows.every((row, i) => row.name && row.name === seeded[i]?.title && !/段位認定第/.test(row.name)),
+ rows.map((row) => row.name).join(' | '));
+ check('评级徽章是游戏内那套单级素材且按原版比例放到 99×40(不再拿「A~AAA」区间图充数)',
+ rows.every((row) => row.badge === 'gam-rank-sssp.png' && Math.abs(row.badgeRect.w - CARD.badge.w) <= 2
+ && Math.abs(row.badgeRect.h - CARD.badge.h) <= 2),
+ `${rows[0]?.badge} ${rows[0]?.badgeRect?.w}×${rows[0]?.badgeRect?.h}`);
+ check('四道达成率都上了官方金色图集',
+ rows.every((row) => /num-score-gold/.test(row.achFont?.atlas ?? '')), rows.map((row) => row.achFont?.atlas).join(' | '));
+ check('单题 DX 分数不叠描边层(小字要干净)',
+ rows.every((row) => row.dxFont?.outline === false && /rgb\(13, 42, 99\)/.test(row.dxFont?.fill ?? '')),
+ `描边层 ${rows[0]?.dxFont?.outline} / ${rows[0]?.dxFont?.fill}`);
+
+ check('左下白框里写档位名(不再是合格印)',
+ page.plate.name === '上级' && page.plate.legacyStamp === false, `${page.plate.name} · 旧印 ${page.plate.legacyStamp}`);
+ check('段位名落在白框里、字重够重',
+ page.plate.rect.left === PLATE.left && page.plate.rect.top === PLATE.top
+ && page.plate.rect.w === PLATE.w && page.plate.rect.h === PLATE.h && Number(page.plate.nameFont.weight) >= 800,
+ `${page.plate.rect.left},${page.plate.rect.top} ${page.plate.rect.w}×${page.plate.rect.h} · ${page.plate.nameFont.size}/${page.plate.nameFont.weight}`);
+ check('结论变成名字下面一行小字', /合格 · 4 \/ 4 道通关/.test(page.plate.count ?? ''), page.plate.count);
+ check('心放大到 200×200 且用官方绿色底盘,心里写通关道数',
+ page.life.rect.w === LIFE.w && page.life.rect.h === LIFE.h
+ && page.life.base === 'life-base-green.png' && page.life.label === '4',
+ `${page.life.rect.w}×${page.life.rect.h} · ${page.life.base} · ${page.life.label}`);
+
+ check('「总达成率」标签落进底图预留的白格',
+ Math.abs(page.total.labelRect.left - TOTAL_LABEL.left) <= 2 && Math.abs(page.total.labelRect.top - TOTAL_LABEL.top) <= 2
+ && Math.abs(page.total.labelRect.w - TOTAL_LABEL.w) <= 2 && Math.abs(page.total.labelRect.h - TOTAL_LABEL.h) <= 2,
+ `${page.total.labelRect.left},${page.total.labelRect.top} ${page.total.labelRect.w}×${page.total.labelRect.h}`);
+ check('总达成率 = 四道达成率之和(官方金色图集)',
+ Math.abs(Number(String(page.total.num).replace('%', '')) - expectAch) < 0.01 && /%$/.test(page.total.num ?? ''),
+ `${page.total.num} vs ${expectAch.toFixed(4)}%`);
+ const totalRight = page.total.rect.left + page.total.rect.w;
+ check('大数字落在色带上、右端对齐 x 950',
+ Math.abs(totalRight - RIGHT_EDGE) <= 6 && page.total.rect.top >= 700 && page.total.rect.top + page.total.rect.h <= DX_ROW.top,
+ `右 ${totalRight} · y ${page.total.rect.top}..${page.total.rect.top + page.total.rect.h}`);
+ const glyphs = String(page.total.num).length + 1;
+ // 官方是密排:分数族那套图集格宽 74 / 格高 98,密排后每字步进 67 ⇒ 0.68 上下。
+ check('数字密排(官方字距,不再一个字一个字分开)',
+ page.total.font.advance / page.total.font.height <= 0.69,
+ `每字步进 ${page.total.font.advance}px / 字高 ${page.total.font.height} = ${(page.total.font.advance / page.total.font.height).toFixed(3)}(占位 ${glyphs} 个字形,整串宽 ${page.total.font.width})`);
+
+ check('DX 分数条就是底图预留的那条白牌',
+ Math.abs(page.dx.rowRect.left - DX_ROW.left) <= 2 && Math.abs(page.dx.rowRect.top - DX_ROW.top) <= 2
+ && Math.abs(page.dx.rowRect.w - DX_ROW.w) <= 2 && Math.abs(page.dx.rowRect.h - DX_ROW.h) <= 2,
+ `${page.dx.rowRect.left},${page.dx.rowRect.top} ${page.dx.rowRect.w}×${page.dx.rowRect.h}`);
+ check('标签写「DX分数」(照官方,不再自造「合计」)', page.dx.label === 'DX分数', page.dx.label);
+ check('DX 分数 = 四道单题 rating 之和(深蓝、不带描边)',
+ Math.abs(Number(page.dx.num) - expectRating) < 0.05 && /rgb\(13, 42, 99\)/.test(page.dx.font?.fill ?? '')
+ && page.dx.font?.outline === false,
+ `${page.dx.num} vs ${expectRating} · ${page.dx.font?.fill} · 描边层 ${page.dx.font?.outline}`);
+ check('DX 数字在白牌右半边、且没出牌面',
+ page.dx.rect.left >= DX_ROW.left + 120 && page.dx.rect.left + page.dx.rect.w <= DX_ROW.left + DX_ROW.w + 2,
+ `${page.dx.rect.left}..${page.dx.rect.left + page.dx.rect.w} 牌面 ${DX_ROW.left}..${DX_ROW.left + DX_ROW.w}`);
+
+ await shot('E-整轮结算页.png');
+ await shotDialog('D-结算盘.png');
+ // 给 paint-probe.py 数像素用的小图(按底图坐标抠)
+ const origin = JSON.parse(await evaluate(`(() => {
+ const b = document.querySelector('dialog.dan-result .dan-result-art').getBoundingClientRect();
+ return JSON.stringify({ x: b.left + window.scrollX, y: b.top + window.scrollY });
+ })()`));
+ const row0 = rows[0];
+ // 行内那些小图是**行内相对坐标**,抠图要加上第 1 行在底图上的原点,否则会拍错地方;
+ // 印与徽章本来就是底图坐标(readLayout 里用 rel(art) 量的),不能再加一次。
+ const inRow = (rel) => (rel ? { left: rel.left + row0.rect.left, top: rel.top + row0.rect.top, w: rel.w, h: rel.h } : null);
+ const rectOf = (key) => inRow((row0.boxes.find(([name]) => name === key) ?? [])[1]);
+ const clipTargets = {
+ ach: rectOf('achvalue'), achbox: rectOf('achbox'), score: rectOf('dxnum'),
+ stamp: row0.stamp, badge: row0.badgeRect,
+ total: page.total.rect, dxtotal: page.dx.rect, life: page.life.rect,
+ };
+ const rectsOut = {};
+ for (const [name, rect] of Object.entries(clipTargets)) {
+ if (!rect) continue;
+ const clip = { x: origin.x + rect.left, y: origin.y + rect.top, width: rect.w, height: rect.h, scale: 1 };
+ rectsOut[name] = clip;
+ const { data } = await cdp.call('Page.captureScreenshot', { format: 'png', clip, captureBeyondViewport: true }, sessionId);
+ writeFileSync(join(SHOTS, `H-${name}.png`), Buffer.from(data, 'base64'));
+ }
+ writeFileSync(join(SHOTS, 'H-rects.json'), JSON.stringify(rectsOut, null, 2));
+
+ /* ---------- 3) 达成率拉开:金 / 蓝 / 红 ---------- */
+
+ console.log('\n=== 3) 四道达成率拉开(金 / 蓝 / 红) ===');
+ /* 注:达成率是由目标值反推秒数再提交的,而评分曲线只在 ≥97% 段可逆 —— 想看到 <80% 的
+ * 蓝档只能从秒数那头放大(见 SLOW_SECONDS_FACTOR)。第 4 道 ×1.6 落到 60% 上下 ⇒ 蓝。 */
+ let slowSeconds = [];
+ const mixed = await clearRun({ tier: 'advanced', kind: 'challenge', achievements: [100.8, 98.5, 96.0, 92.0] });
+ check('这一轮同样判为 cleared(限时内 AC 就算过,跟达成率高低无关)', mixed?.status === 'cleared', `${mixed?.status}`);
+ check('四道达成率确实拉开了',
+ new Set(mixed.stages.map((s) => Math.round(s.achievement))).size === 4,
+ mixed.stages.map((s) => `${s.achievement.toFixed(2)}%`).join(' '));
+ await openLatestResult();
+ const mixedPage = JSON.parse(await readLayout());
+ const tones = mixedPage.rows.map((row) => (/num-score-(\w+)\.png/.exec(row.achFont?.atlas ?? '') ?? [])[1]);
+ check('颜色按达成率换:≥97 金、80~96.99 红(合成用例只够得到这两档)',
+ tones.join(',') === 'gold,gold,red,red', `${tones.join(',')} · ${mixed.stages.map((s) => `${s.achievement.toFixed(2)}%`).join(' ')}`);
+ check('评级徽章跟着达成率走(逐级对应,不再共用一个区间图)',
+ mixedPage.rows.every((row, i) => row.badge === RANK_FILE[mixed.stages[i].rank]),
+ mixedPage.rows.map((row, i) => `${mixed.stages[i].rank}=${row.badge}`).join(' '));
+ check('总达成率是四道之和 ⇒ 仍然 ≥100% ⇒ 金',
+ /num-score-gold/.test(mixedPage.total.font?.atlas ?? '') && /%$/.test(mixedPage.total.num ?? ''),
+ `${mixedPage.total.num} · ${mixedPage.total.font?.atlas}`);
+ await shot('F-分数分色结算页.png');
+ await shotDialog('D-结算盘-分色.png');
+
+ /* ---------- 4) 超时取消 ---------- */
+
+ console.log('\n=== 4) 超时取消那一轮 ===');
+ const timedId = `e2e-timeout-${Date.now()}`;
+ const timerId = `${timedId}-1`;
+ {
+ const t = Math.floor(Date.now() / 1000);
+ const write = open();
+ write.prepare(`INSERT INTO dan_sessions(id,user_id,tier,kind,stage_count,limit_seconds,min_rating,max_rating,status,started_at)
+ VALUES(?,?,?,?,?,?,?,?,?,?)`).run(timedId, USER, 'beginner', 'challenge', 4, 1800, 800, 1100, 'active', t + 1);
+ write.prepare(`INSERT INTO practice_timers(id,user_id,account_id,handle_key,problem_id,practice_kind,status,started_at,ended_at)
+ VALUES(?,?,?,?,?,?,?,?,?)`).run(timerId, USER, accountId, 'dan-e2e', '559:A', 'unknown', 'completed', t - 4000, t - 1000);
+ write.prepare(`INSERT INTO dan_stages(session_id,stage_index,problem_id,difficulty,drawn_at,claimed_at,timer_id,limit_seconds)
+ VALUES(?,?,?,?,?,?,?,?)`).run(timedId, 1, '559:A', 800, t - 4000, t - 4000, timerId, 1800);
+ write.close();
+ }
+ await call('/api/dan/settle', { userId: USER, tz: 480 });
+ const failed = (await call(`/api/dan?user=${USER}&tz=480`)).body.history.find((r) => r.id === timedId);
+ check('超时那一道被记为 timeout', failed?.stages[0]?.outcome === 'timeout', `${failed?.stages[0]?.outcome}`);
+ check('这一轮判为 failed,且没有继续抽第 2 道', failed?.status === 'failed' && failed?.stages.length === 1,
+ `${failed?.status} / ${failed?.stages.length} 道`);
+ await openLatestResult();
+ const failPage = JSON.parse(await readLayout());
+ check('失败那一屏也弹得出来', failPage.open === true);
+ check('档位名照样写在白框里,结论是小字「不合格」',
+ failPage.plate.name === '初级' && /不合格 · 0 \/ 4 道通关/.test(failPage.plate.count ?? ''),
+ `${failPage.plate.name} · ${failPage.plate.count}`);
+ check('只有一张卡、没有评级徽章、判定写「超时」',
+ failPage.rows.length === 1 && failPage.rows[0].badge === '' && failPage.rows[0].judge === '超时',
+ `${failPage.rows.length} 张 / 徽章「${failPage.rows[0]?.badge}」/ 判定「${failPage.rows[0]?.judge}」`);
+ check('没有成绩的两处都是占位「—」', failPage.total.num === '—' && failPage.dx.num === '—',
+ `${failPage.total.num} / ${failPage.dx.num}`);
+ check('占位「—」也染了深蓝(白字族是纯白剪影,白底上会看不见)',
+ /rgb\(13, 42, 99\)/.test(failPage.total.font?.fill ?? '') && /rgb\(13, 42, 99\)/.test(failPage.dx.font?.fill ?? ''),
+ `${failPage.total.font?.fill} / ${failPage.dx.font?.fill}`);
+ check('心换成红色底盘,写 0', failPage.life.base === 'life-base-red.png' && failPage.life.label === '0',
+ `${failPage.life.base} / ${failPage.life.label}`);
+ await shot('G-不合格结算页.png');
+
+ console.log('\n=== 5) 控制台与请求 ===');
+ check('页面没有抛异常 / console.error', problems.length === 0, problems.slice(0, 3).join(' | '));
+ check('没有指向本服务的 4xx/5xx', badResponses.length === 0, badResponses.slice(0, 3).join(' | '));
+} catch (error) {
+ console.log(`\n出错:${error instanceof Error ? error.message : String(error)}`);
+ failures += 1;
+} finally {
+ try { cdp?.socket.close(); } catch { /* 已经关了 */ }
+ edge.kill();
+}
+
+console.log(`\n结果:${failures === 0 ? '全部通过' : `${failures} 项失败`}`);
+process.exit(failures === 0 ? 0 : 1);
\ No newline at end of file
diff --git a/scripts/export-original-card.py b/scripts/export-original-card.py
new file mode 100644
index 0000000..cbafa9b
--- /dev/null
+++ b/scripts/export-original-card.py
@@ -0,0 +1,79 @@
+#!/usr/bin/env python3
+"""把**原版**的乐曲卡片素材原样导出来,供对照。
+
+用途:现在卡片的边框是照着官方贴图的版式用 CSS 画的(`.dan-track-frame`),用户要直接看
+原版素材本身。这里把逐题行那一圈的原图导到 `docs/images/original-card/`,并顺手把当前渲染
+出来的第 1 张卡裁成一张独立小图 `docs/images/dan-card.png`。
+
+用法:
+ python scripts/export-original-card.py "<游戏目录>" # 含 resources.assets 的那一层
+"""
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+
+import UnityPy
+
+# 逐题行那一圈的原版贴图。`UI_CMN_RSL_KopMBase_*` 就是官方乐曲卡片的边框(640×140,
+# 五种难度各一张,颜色与难度名 BASiC/ADVANCED/EXPERT/MASTER/Re:MASTER 都烘在图里)。
+WANTED = [
+ "UI_CMN_RSL_KopMBase_BSC",
+ "UI_CMN_RSL_KopMBase_ADV",
+ "UI_CMN_RSL_KopMBase_EXP",
+ "UI_CMN_RSL_KopMBase_MST",
+ "UI_CMN_RSL_KopMBase_MST_Re",
+ "UI_CMN_RSL_JacketImage_S",
+ "UI_CMN_RSL_MusicJacket_Base",
+ "UI_CMN_RSL_JaketTrack",
+ "UI_CMN_RSL_JaketTrackNo_0",
+ "UI_CMN_RSL_AllScorekBase",
+ "UI_DNM_Result_musicBase_01",
+ "UI_RSL_AchivementCounter",
+ "UI_DNM_AchivementCounter",
+]
+PREFIXES = ("UI_CMN_RSL_", "UI_DNM_Result_music", "UI_RSL_Achivement", "UI_DNM_Achivement")
+
+
+def main() -> int:
+ if len(sys.argv) < 2:
+ print(__doc__)
+ return 2
+ game = Path(sys.argv[1])
+ out = Path(__file__).resolve().parent.parent / "docs" / "images" / "original-card"
+ out.mkdir(parents=True, exist_ok=True)
+ env = UnityPy.load(str(game / "resources.assets"))
+ saved, extra = [], []
+ for obj in env.objects:
+ if obj.type.name not in ("Texture2D", "Sprite"):
+ continue
+ try:
+ data = obj.read()
+ name = getattr(data, "m_Name", "") or ""
+ if not name:
+ continue
+ if name in WANTED:
+ image = data.image
+ image.save(out / f"{name}.png")
+ saved.append((name, image.size))
+ elif any(name.startswith(p) for p in PREFIXES):
+ extra.append(name)
+ except Exception as exc: # 有的贴图数据在别的 bundle 里,读不到就跳过
+ if getattr(obj, "read", None):
+ try:
+ name = obj.read().m_Name
+ except Exception:
+ name = "?"
+ if name in WANTED:
+ print(f" 跳过 {name}:{type(exc).__name__}")
+ for name, size in sorted(saved):
+ print(f" 导出 {name} {size[0]}x{size[1]}")
+ if extra:
+ print("同前缀但未导出的(想看哪个再说):")
+ for name in sorted(extra):
+ print(f" {name}")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
\ No newline at end of file
diff --git a/scripts/extract-maimai-assets.py b/scripts/extract-maimai-assets.py
new file mode 100644
index 0000000..061ace6
--- /dev/null
+++ b/scripts/extract-maimai-assets.py
@@ -0,0 +1,114 @@
+#!/usr/bin/env python3
+"""从 maimai DX 客户端资源里提取段位認定(Dani Mode)结算页用到的贴图。
+
+这是**开发工具**,不是应用依赖:仓库里已经放好了提取结果
+(public/assets/maimai/*.png),只有要重新提一遍时才跑它。
+
+用法:
+ pip install UnityPy texture2ddecoder Pillow
+ python scripts/extract-maimai-assets.py "<游戏目录>" [输出目录]
+
+`<游戏目录>` 是客户端里含 `resources.assets` 的那一层,例如
+ E:/Downloads/156Sinmai_Data/156Sinmai_Data/156Sinmai_Data
+
+为什么要这么提:段位認定结算那整屏是**成品图**(页头文字、格纹页眉、两侧水引、
+下半部色带与左下角段位名牌白框全部烘焙在底图里),运行时只是把逐题行、达成率数字、
+段位名与总达成率盖上去。所以页面直接用这张底图 + 几件小素材,比自己用 CSS 画近似得多。
+注意:底图下半部那两三块**挖白的空面板**是原作留给运行时文字的位置,不要再拿别的脚本
+把它们续画填掉(第 12 轮干过一次,第 13 轮撤了)。
+"""
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+
+import UnityPy
+
+# 仓库里用的文件名 ← 游戏里的贴图名。只搬这几件,不是整个 UI_DNM 家族(159 张)。
+#
+# 故意**不**搬的一件:逐题行的乐曲边框 `UI_CMN_RSL_KopMBase_{BSC,ADV,EXP,MST,MST_Re}`
+# (640×140,五种难度各一张)。它是逐题行那一圈底色,但颜色与难度名(BASIC/ADVANCED/
+# EXPERT/MASTER/Re:MASTER)都烘死在图里,套不上我们自己的 CF 难度分档。所以边框按它的
+# 版式用 CSS 画一层(见 dan.css 的 .dan-track-frame):外圈浅色环 + 主色场 + 底部浅色带
+# + 左侧白曲绘槽 + 深蓝标题条 + 白色达成率框,尺寸全按这张贴图量出来的像素。
+WANTED: dict[str, str] = {
+ "UI_DNM_Result_Base_01": "result-dani.png", # 「段位認定」整屏底图 980×928
+ "UI_DNM_Result_Base_03": "result-random.png", # 「ランダム段位認定」整屏底图
+ "UI_DNM_Result_musicBase_01": "track-plate.png", # 逐题行底板 576×124
+ "UI_DNM_Icon_Result_01": "stamp-clear.png", # 逐题「可」印 108×108
+ "UI_DNM_Icon_Result_02": "stamp-fail.png", # 逐题「不可」印
+ # 数字图集:规整 4×4 网格,逐格由同名 Sprite 定 rect(见 dan.js 的 NUM_FONTS)。
+ # 图集里全是**纯白剪影**(游戏运行时才染色),所以描边要另外提一层 _Outline。
+ "UI_CMN_Num_26p": "num-26p.png", # 小号数字 136×160,格 34×40
+ "UI_CMN_Num_26p_Outline": "num-26p-outline.png", # 同布局的描边剪影
+ "UI_CMN_Num_90p": "num-90p.png", # 没有成绩时的占位「—」用,356×420,格 89×105
+ "UI_CMN_Num_90p_Outline": "num-90p-outline.png",
+ # 分数数字:同布局的三套(blue / gold / red)296×392,格 74×98,自带颜色、
+ # 不用描边层 —— 原作就是按达成率高低换这一套的颜色。
+ # 同名的 `UI_NUM_Score_0001111_Base` 故意不搬:那是一张**空图集**(16 格里一个字形都没有,
+ # 整张只有几列杂散像素),照它渲染数字会得到一片看不见的灰影。
+ "UI_NUM_Score_0001111_Blue": "num-score-blue.png",
+ "UI_NUM_Score_0001111_Gold": "num-score-gold.png",
+ "UI_NUM_Score_0001111_Red": "num-score-red.png",
+ # 达成率末尾那个大「%」字形:同样按分数分色(没有 base 那版,低档用图集里的 % 格)
+ "UI_RSL_Score_Per_Gold": "score-per-gold.png", # 80×80
+ "UI_RSL_Score_Per_Blue": "score-per-blue.png",
+ "UI_RSL_Score_Per_Red": "score-per-red.png",
+ # 逐题评级徽章:`UI_GAM_Rank_*` 是**游戏内**那套,单级各一张(源图紧裁,36–110 × 42–44)。
+ # 故意不用 `UI_CMN_TabTitle_Rank_*`:那是**页签标题**上的区间图 —— AAA 那张画的是
+ # 「A~AAA」、BBB 是「~BBB」,而且它没有 D/C/B/BB/A/AA,低分全挤在同一张牌上。
+ "UI_GAM_Rank_D": "gam-rank-d.png",
+ "UI_GAM_Rank_C": "gam-rank-c.png",
+ "UI_GAM_Rank_B": "gam-rank-b.png",
+ "UI_GAM_Rank_BB": "gam-rank-bb.png",
+ "UI_GAM_Rank_BBB": "gam-rank-bbb.png",
+ "UI_GAM_Rank_A": "gam-rank-a.png",
+ "UI_GAM_Rank_AA": "gam-rank-aa.png",
+ "UI_GAM_Rank_AAA": "gam-rank-aaa.png",
+ "UI_GAM_Rank_S": "gam-rank-s.png",
+ "UI_GAM_Rank_Sp": "gam-rank-sp.png",
+ "UI_GAM_Rank_SS": "gam-rank-ss.png",
+ "UI_GAM_Rank_SSp": "gam-rank-ssp.png",
+ "UI_GAM_Rank_SSS": "gam-rank-sss.png",
+ "UI_GAM_Rank_SSSp": "gam-rank-sssp.png",
+ # 右下角那个组件:剩余生命底盘(绿 = 通关 / 红 = 未通关)。
+ # `UI_RSL_DXScore_Base`(「でらっくスコア」标签)**故意不搬**:官方那格是单曲按打击
+ # 精度算的 DX 分数,我们显示的是四道单题 rating 之和,口径不同,套那个框等于撒谎。
+ "UI_DNM_Base_Life_01": "life-base-green.png",
+ "UI_DNM_Base_Life_03": "life-base-red.png",
+}
+
+
+def main(argv: list[str]) -> int:
+ if len(argv) < 2:
+ print(__doc__)
+ return 2
+ game_dir = Path(argv[1])
+ out_dir = Path(argv[2]) if len(argv) > 2 else Path("public/assets/maimai")
+ assets = game_dir / "resources.assets"
+ if not assets.exists():
+ print(f"找不到 {assets}")
+ return 1
+
+ out_dir.mkdir(parents=True, exist_ok=True)
+ env = UnityPy.load(str(assets))
+ written: dict[str, str] = {}
+ for obj in env.objects:
+ if obj.type.name != "Texture2D":
+ continue
+ data = obj.read()
+ name = getattr(data, "m_Name", None) or getattr(data, "name", "") or ""
+ target = WANTED.get(name)
+ if target is None or target in written:
+ continue
+ image = data.image
+ image.save(out_dir / target)
+ written[target] = f"{name} ({image.width}x{image.height})"
+
+ for target in WANTED.values():
+ print(f" {written.get(target, '缺失!'):<34} → {target}")
+ return 0 if len(written) == len(WANTED) else 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(main(sys.argv))
\ No newline at end of file