diff --git a/.gitignore b/.gitignore index 73bb17e..63a3a5a 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,6 @@ __pycache__/ Thumbs.db results/local-*.json prompt0.md + +# Agent instructions belong in the user-level DSH home, not in this repo. +AGENTS.md diff --git a/docs/dan.md b/docs/dan.md index 6a488d3..a7ccf0f 100644 --- a/docs/dan.md +++ b/docs/dan.md @@ -156,23 +156,70 @@ active ──四道全部通关──→ cleared(通过) **第一层是单题结算**,在每道题出成绩时弹出:用的就是普通计时练习那张结算皮(`public/dx-result.css` 的 `.dx-result-sheet`),显示达成率、此前最佳、评级、用时、判定计数、单题 rating,以及结算那一刻的 DX Rating 总分与增量。这段对比读的是 `practice_timers.settlement_json` 里冻结的那一份 —— 段位認定不另算一套分。 -**第二层是整轮结算页**,四道打完(或中途失败)后出现,版式照原作的段位認定结算(`Process/Course/CourseResultProcess`): - -- **奶油色纸盘**:橙红双圈描边,两侧各一条水引红绳(蝴蝶结 + 竖绳 + 流苏)。 -- **逐曲卡**:每道一张。左侧一枚八角的「可」印(没通关是「不可」,未开始是灰的);右侧是难度配色的横卡 —— 封面那一格放题号(我们抽的是 CF 题、没有曲绘,放题号比放装饰诚实),然后是 `STAGE 0n` / 难度 / 题目名,下面一行「达成率」+ 金色描边的大数字 + 判定(评级,或超时/中断),最右边是 `LV`、`DX分数`(该题算出的单题 rating)与这道题的限时。 -- **底部一行**:左边判定框写「合格」/「不合格」与通关题数;中间一颗心;右边「总达成率」与 `DX分数`。 -- **右下角**一个「好」按钮,旁边是「再来一轮 · 档位」。 +**第二层是整轮结算页**,四道打完(或中途失败)后出现,直接上原作的画面素材(`Process/Course/CourseResultProcess` 那一屏): + +段位認定结算页,虚拟演示数据 + +- **底图**:整屏就是原作那张 980×928 的成品图 —— `UI_DNM_Result_Base_01`(「段位認定」)与 `UI_DNM_Result_Base_03`(「ランダム段位認定」)。页头文字、格纹页眉、两侧水引(蝴蝶结 + 竖绳 + 流苏)、下半部色带与左下角那格白框**全部烘焙在底图里**,所以档位挑战与随机段位各用自己那一版,标题不再由页面画。 +- **底图上那几块白不补**:下半部留了三处**挖白的空面板** —— 量下来是「标签白格」`(503,663) 354×50`(中间被折页的斜边切开,上半段 `503–708`、下半段被切成两小段)与「DX 分数白牌」`(608,810) 360×80`(左端还有个空着的圆白圆头)。那是原作留给运行时文字的位置,运行时会被盖住。我们一开始照「露白像空框」的思路用 `scripts/patch-maimai-result-band.py` 把这几块续画填掉了 —— 那是错的:官方版式本来就要在这三处写字,填掉等于自己把位置抹了。现在补丁脚本已删、底图回到原始素材,标签与大数字就摆回这三格(见下「底部色带」)。 +- **组件按官方坐标拼**:底图之上每一件都放在原作预制体(`Process/Course/CourseResultProcess`、逐题行的 `UI_CMN_KOP_TrackData`)量出来的位置上,尺寸也照官方:逐题行 `x 267–843`、第 1 行 `y 152`、行距 `122`(576 宽 = 官方 640 × 0.9);行首的印是 `108×108`(官方原始尺寸,不是缩过的);达成率数字字高 `44`;行内的每一项(边框本体、曲绘槽、歌名条、白盒、底带、右下白牌、徽章)另外照官方贴图 `UI_CMN_RSL_KopMBase_*` 逐像素量了一遍,换算与数字写在 `public/dan.css` 的「逐题行」一节,`scripts/dan-settlement-e2e.mjs` 里逐条量它们(`边框本体照原版量出来的比例摆`、`歌名条占顶部一整行`、`DX 分数在卡片右下角`、`限时写进底带左侧`、`评级徽章放进右上那块紫场`),底部白框、白牌的坐标也是拿底图逐行数像素量出来的。 +- **逐题卡**:每道一张。左侧一枚原作印(`UI_DNM_Icon_Result_01` 可 / `_02` 不可,未抽到的变灰);右侧横卡用原作底板 `UI_DNM_Result_musicBase_01` 撑起来,上面再压一层**乐曲边框** —— 版式照官方 `UI_CMN_RSL_KopMBase_*` 逐像素量出来重画:外圈 **2px 主色环** + 一块平的主色场 + 底部**浅色带** + 左侧方曲绘槽 + **深蓝歌名条(占顶部一整行)** + 白色达成率盒(**右下角一道大 R 弧** —— 原版那块「右侧带缺口的面板」)+ **右下角白牌**(原版烘的是日文 `でらっくスコア`)。**换算**:导出的原版是 636×136 的精裁版(官方设计 640×140,左右上下各被紧裁掉 2px 透明边),我们的行内区 576×126 = 官方 ×0.9,所以 `行内坐标 = (原版坐标 + 2) × 0.9`;边框本体落在行内 `(3.6, 3.6)`、尺寸 `568.8×118.8`,边框内的组件再减去这 3.6。**颜色不烘死**:官方那五张(BSC/ADV/EXP/MST/MST_Re)把难度色和难度名都印在图里,套不上我们自己的 CF 分档,所以主色场取 `--tc1`、外圈环取 `--tc2`(`cfRatingColor(...).tone` → `TRACK_TONE`)—— **一块平色**,不做上下渐变;曲绘槽与底部浅色带取主色的**浅色版本**(`color-mix(in srgb, var(--tc1) 42% / 55%, #fff)`),原版那格是曲绘,换成纯白牌会像在卡上挖了个洞。 +- **信息分配(照 2026-10-10 的口径重排)**:**最上面歌名占一整行**(原版左上那块难度名牌删掉,见下);左侧曲绘槽里是完整题号(`1554C` 这种「场次 + 小问」)与这题在 CF 上的 rating;达成率大数字 + 官方彩色「%」字形排在**白盒里**,数字**右对齐**:满 100 的那串右端固定在同一个位置,不到 100 的少一位数就少在左边(`96.0000%` 的右端仍与 `100.8000%` 齐平);**DX 分数挪到卡片右下角那块白牌**(行内 `450.9, 97.2`、`119.7×23.4` 胶囊,原版 `でらっくスコア` 那一格);**限时写进底带的左侧**(原版那条浅色带是空的一块,行内从 `122.4` 起);逐题评级徽章放进**右上那块紫场**(白盒右端…边框内右沿 / 歌名条下沿…右下白牌上沿 = `119.5×68.4`,原版空着,白盒右下角那道大 R 弧就是给它让位的)。 + - 官方那一格难度名牌(烘的是 `MASTER 大師` 这类难度名)**整个删掉了**:同一张卡上题号、达成率、单题 rating 已经把信息说全,再写一遍档位名既重复、又占掉半个标题条。歌名条因此从行内 `x 122.4` 一路铺到 `565.2`(边框内 `118.8 + 442.8`,右端离边框外沿 7.2px,与原版一致)。 +- **评级徽章用游戏内那套**:`UI_GAM_Rank_*`(`D`/`C`/`B`/`BB`/`BBB`/`A`/`AA`/`AAA`/`S`/`S+`/`SS`/`SS+`/`SSS`/`SSS+` 单级各一张,源图紧裁 36–110 × 42–44)。早先用的是 `UI_CMN_TabTitle_Rank_*` —— 那是**页签标题**用的区间图:AAA 那张画的是「A~AAA」、BBB 那张是「~BBB」,摆在逐题卡片上根本不是那一级的评级,而且它没有 D/C/B/BB/A/AA 这些级,低分全挤在同一张牌上。换过来之后 14 级一级一张,框按 `99×40`(官方 SSS+ 那张紧裁图 110×44 ×0.9)按高度贴齐,摆在右上那块紫场里。 +- **底部色带**:左下白框那一格原作放**段位名牌**,我们发档位不发段位名,就把档位名(`初级`/`中级`/`上级`/`超上级`/…)照原版那样写进去 —— 深棕重字 + 很淡的米白描边(`-webkit-text-stroke` + `paint-order: stroke fill`)+ 一点投影。原版那两个字是**烘死的毛笔字贴图**(`UI_DNM_DaniTitle_*` 只有日文段位名,套不上我们的档位),只能用系统字体近似:字体栈取 `Noto Serif SC` 的 **900** 字重(次选系统宋体/楷体那一类衬线),SimSun 那种细宋笔画只有原版一半粗。名字超过两个字自动缩号(`data-len`)。名字下面一行小字写「合格 · 4 / 4 道通关」。中间是原作的剩余生命底盘(`UI_DNM_Base_Life_01` 绿 / `_03` 红),照官方放大到 `200×200`,心里那个数字是「四道里过了几道」(数字图集里没有「/」字形,退回普通文字会被两边数字的描边吃掉,所以不写 `4/4`)。右边两块**照底图预留的白格**摆:`总达成率` 写进上面那格白(实测白区是 `x 503..708`,右边那截 `753..856` 被折页斜边切开,所以四个字按 `503..708` **居中**、字号 23),官方数字图集拼的大数字落在下面的色带上(`y 727`、字高 66,右端对齐 `x 950`);`DX分数` 与它的数字写进下面那条白牌(`(608,810) 360×80`,标签在左、数字在右,两端各留 46px,左端那个空的白圆头按原样空着,文字靠上排 —— 白牌下半截也被折页切掉一块),两处都是和标题条同一个深蓝 `#16306b`。 +- 数字不是 CSS 字体画的:**官方数字图集逐格切**。图集是规整 4×4 网格,格子由同名 Sprite 定 rect;白字族每格拆成「官方 `_Outline` 剪影当遮罩上深色(描边)+ 官方填充图集」两层 —— 图集本身是**纯白剪影**(游戏运行时才染色),background 层没法单独染色,所以用两个真元素叠。这套「剪影 + 运行时染色」的能力也直接用在了 DX 分数上(染成帧上的深蓝 `#0d2a63`,见下)。对照表(一张张看出来的):白字族 `[0-9]` `[10]'+'` `[11]'-'` `[12]','` `[13]'·'`(居中的圆点,就是这套字体的小数点)`[14]'%'`;分数族 `[10]'+'` `[11]'/'` `[12]'%'` `[13]'.'`。 +- **数字怎么排**:**一个字形一格,格子之间按 `advance` 等距** —— 就是图集自己的排法,小数点、逗号这些跟数字走同一条路,不开特例。唯一要做的是把每格右边的留白收掉:图集格宽比字形宽(分数族格宽 74、字形只有 60–63),照格宽排会读成「一个字一个字分开」,所以每格收回 `格宽 − advance`(分数族 74→67、90p 89→67、26p 34→24,见 `NUM_FONTS.advance`),最后一格不收回。 + - **小数点**:白字族 `[13]` 那一格是**画在字高中线上的方块**(26p 只有 8×8、90p 24×24),直接排会浮在数字中间像断字符;官方那只方块的**底边压在数字基线上**(在 `official-dani.png` 上量过)。所以这一格照用,只按 `dotBaseline`(该格墨迹底线在格高里的比例:26p 0.775 / 90p 0.6)把它压到基线上 —— 字形来自同一个族,粗细和数字一致,不需要另画也不需要借用别的族。 + - 旁边有小字标签时(两处 `DX分数`)用 `numText` 的 `baseline` 选项:整块数字往下补 `(1 − baseline) × 字高`。flex 认的基线是这一格的**底边**,而图集里数字的墨迹底线在格高的 `baseline` 处,不补这一下数字就浮在标签上面;两处都用 `align-items: baseline` 排,实测标签与数字的基线都落在同一条 y 上。 + - 末尾那个「%」是独立图片(80×80、墨迹 `x 8..70`,比图集里的 `%` 格大一号),按 `` 直排会在数字和 `%` 之间多出一截,所以放大到 `1.1 × 字高` 再用负边距把左边的留白抵掉。 +- **达成率按分数换色**:官方那套 `UI_NUM_Score_0001111_{Blue,Gold,Red}` 是同布局的三色分数图集(自带颜色、不叠描边),末尾的大「%」字形 `UI_RSL_Score_Per_{Blue,Gold,Red}` 同样三色 —— 所以达成率的档位就是三档:`≥100%` 金(SSS/SSS+)、`≥97%` 蓝(S~SS+)、再低红。`scoreTone()` 在 `public/dan.js` 里,换色在同一条 `achievementNumber()` 里做。**官方那张 `UI_NUM_Score_0001111_Base` 是一张空图集**(16 格里一个字形都没有,整张只有几列杂散像素),照它渲染出来就是一片看不见的灰影,别用。三档同时出现的样子: + +四道达成率分别为 100.8 / 98.5 / 96.0 / 92.0 时的结算页:金、蓝、红三色 +- **DX 分数不上分数色**:那一格显示的是单题 rating、不是 DX 分数比率,所以三色专门留给达成率;它用白字族剪影染深蓝(跟标题条、标签同一个深蓝)。**所有「没有成绩」的占位「—」也走这套染色**:白字族是纯白剪影,直接铺在白色达成率框或浅色底图上等于看不见。 +- **说明与按钮在画面之外**(深色底上):「好」按钮与「再来一轮 · 档位」不往那张成品屏里塞;这一轮的范围说明(档位区间、题数、限时、抽题方式、标签)也挪到了成品屏下面 —— 原作那张纸上没有放这行字的位置,硬塞只能压住页头或第一行。 两处刻意的改写: -- 原作那一格写的是**段位名**(例如「十段」)。随机段位認定不发段位名,所以改成写判定结果「合格」/「不合格」。 -- 原作那颗心是**剩余生命**。我们没有生命值这个概念,用它显示「通关几道」(合格绿、失败灰)。 +- 原作那一格写的是**段位名**(例如「十段」)。我们不发段位名(挑战模式只发档位),所以那一格写**档位名**、字重描边照原版那一路走;「合格/不合格」不另盖印,改成名字下面一行小字。 +- 原作那颗心是**剩余生命**。我们没有生命值这个概念,用它显示「四道里过了几道」(全过绿底、失败红底)。 + +两处数字口径与原来的对照:原作的「总达成率」是四首歌达成率之和,这里就是各道达成率之和;原作的 `DX分数` 是四首 DX 分数之和,这里对应各道单题 rating 之和(也就是本轮的 `totalRating`)。**这是展示口径**:那一格不等于 `B50` 里这道题贡献的分数增量,真实增量写在结算屏下面那行说明里。 + +### 结算页的素材来源 + +`public/assets/maimai/` 下那 31 张 PNG 是从 maimai DX 客户端资源里直接提取的,不是重画的: + +| 仓库文件 | 客户端贴图 | 用途 | +| --- | --- | --- | +| `result-dani.png` | `UI_DNM_Result_Base_01` | 「段位認定」整屏底图 980×928 | +| `result-random.png` | `UI_DNM_Result_Base_03` | 「ランダム段位認定」整屏底图 | +| `track-plate.png` | `UI_DNM_Result_musicBase_01` | 逐题行底板 576×124 | +| `stamp-clear.png` / `stamp-fail.png` | `UI_DNM_Icon_Result_01` / `_02` | 逐题「可」/「不可」印 | +| `num-26p.png` / `num-90p.png` | `UI_CMN_Num_26p` / `90p` | 小 / 大号白字图集(136×160 · 356×420,4×4 格) | +| `num-26p-outline.png` / `num-90p-outline.png` | `UI_CMN_Num_26p_Outline` / `90p` | 同布局的描边剪影(上深色当描边用) | +| `num-score-blue.png` / `-gold.png` / `-red.png` | `UI_NUM_Score_0001111_Blue` / `_Gold` / `_Red` | 达成率的三色分数图集(自带颜色,296×392) | +| `score-per-blue.png` / `-gold.png` / `-red.png` | `UI_RSL_Score_Per_Blue` / `_Gold` / `_Red` | 达成率末尾那个大「%」字形(80×80,同样三色) | +| `gam-rank-sssp.png` … `gam-rank-d.png`(14 张) | `UI_GAM_Rank_SSSp` … `_D` | 逐题评级徽章(游戏内那套,单级各一张) | +| `life-base-green.png` / `life-base-red.png` | `UI_DNM_Base_Life_01` / `_03` | 剩余生命底盘(绿 = 通关 / 红 = 未通关) | + +**故意不搬的一件**:逐题行的乐曲边框 `UI_CMN_RSL_KopMBase_{BSC,ADV,EXP,MST,MST_Re}`(640×140,五种难度各一张)。它是逐题行那一圈底色,但难度色与难度名(BASIC/ADVANCED/EXPERT/MASTER/Re:MASTER)都烘死在图里,套不上我们的 CF 分档,所以照它的版式用 CSS 画一层、颜色交给 `--tc1/--tc2`(见 `public/dan.css` 的「逐题行」一节,尺寸全按那张贴图量出来)。 + +提取脚本留在 `scripts/extract-maimai-assets.py`(开发工具,需要 `UnityPy` + `texture2ddecoder` + `Pillow`,不是应用依赖): + +```bash +python scripts/extract-maimai-assets.py "<含 resources.assets 的游戏目录>" +``` + +贴图对应的**贴图名**是拿 UnityPy 枚举客户端 `resources.assets` 的 `Texture2D` 得到的(那一个文件里有 5916 张,`UI_DNM_*` 家族 159 张)。数字图集的**格位**不用量:每格都有同名 Sprite(`UI_CMN_Num_90p_0 … _14`),网格正好 4×4,`下标 × 格宽/格高` 就是裁切位置;`[12]` 是逗号、`[13]` 是居中的圆点(原作这套字体的小数点就画在字高中线上,不是落在基线上的点),这两格是放大到 6 倍 + 量墨迹包围盒确认的。分数族那三套的格位同样按 4×4 / 74×98 切;**同名的 `Base` 那张是空图集**,是数每格不透明像素数(16 格全是 0)发现的 —— 只看缩略图会以为它有内容。 + +版式数字不是估的:它们是拿 `UnityPy` 读 `CourseResultProcess` / `UI_CMN_KOP_TrackData` 预制体里每个 `RectTransform` 的 anchors / anchoredPosition / pivot / sizeDelta,再按「美术坐标 = 屏幕中心 + 局部位置」换算到 980×928 底图上(逐题行 576×126 = 官方 640×140 × 0.9)。乐曲边框那几块(边框本体、外圈环、歌名条、曲绘槽、白盒、底带、右下 DX 白牌)另外拿官方贴图 `UI_CMN_RSL_KopMBase_MST.png` 逐行扫像素复核过一遍(每块的首像素…末像素,再按 `(坐标 + 2) × 0.9` 换算),两边对得上。早先那版是按底图像素分区(纸区上边界 `y=140/928=15.1%`、底部色带从 `71.6%` 起、左下白框 `x 8.7–31.6% / y 73.3–94.8%`)粗摆的,现在换成官方坐标后位置差了几十像素 —— 也就是「组件没对齐」的那几十像素。 -两处数字口径与原来的对照:原作的「总达成率」是四首歌达成率之和,这里就是各道达成率之和;原作的 `DX分数` 是四首 DX 分数之和,这里对应各道单题 rating 之和(也就是本轮的 `totalRating`)。段位名那一格改成写判定结果,所以「总达成率」下面不再有段位线。 +盘面锁死 `980×928`(固定宽高,不再用 `aspect-ratio` 缩放),窄屏让盘面横向滚动而不是压扁 —— 内部一律绝对像素,缩放交给浏览器等比处理,这样「一个数字多宽」这类关系可以直接抄官方数字。 -对照的素材包元素(用 UnityPy 解 `resources.assets` 的 GameObject / RectTransform 得到):那枚「可」印是 `UI_DNM_Stamp` 的 `Clear` / `NoClear`,两侧绳子是 `UI_DNM_Separator`,底部那块是 `UI_DNM_DaniData`。 +一处已知的将就:**单题模式(自定义条件/每日一题)也复用这张结算盘**,它们是 1 道题,底图借的是档位那一版,所以页头会写着「段位認定」。这不是这轮引入的 —— 改造前那张 CSS 皮的标题也是写死「段位認定」(`#danRunTitle` 一直没按 `kind` 变过),这一版只是把标题从 CSS 文字换成了烘进底图的字。单题模式要专属皮的话,得另找一张不带页头的纸底图,或者按 `kind` 裁掉底图上那 15.1% 的页头带。 -**贴图没有从素材包里搬**:全部用 CSS 重画。游戏美术有版权,打进一个 MIT 仓库、再提到上游是不合适的;这里只沿用版式与构成。盘面用大圆角而不是正圆 —— 四道题卡竖排进正圆,在常见宽度下会挤到不可读。 +**这些美术素材有版权**:它们不属于这个仓库的 MIT 许可范围,也不随上游项目走。只在自用/自部署的实例里使用;要不要把它们留在分支上,由部署的人自己判断。这一段替换了早先「用 CSS 重画、不搬游戏美术」的做法,是按 2026-10-09 的明确要求改的。 ### 下一道由你点,不由轮询抽 diff --git a/docs/images/dan-card-vs-original.png b/docs/images/dan-card-vs-original.png new file mode 100644 index 0000000..0d1363f Binary files /dev/null and b/docs/images/dan-card-vs-original.png differ diff --git a/docs/images/dan-card.png b/docs/images/dan-card.png new file mode 100644 index 0000000..597e3b0 Binary files /dev/null and b/docs/images/dan-card.png differ diff --git a/docs/images/dan-card2.png b/docs/images/dan-card2.png new file mode 100644 index 0000000..1f93601 Binary files /dev/null and b/docs/images/dan-card2.png differ diff --git a/docs/images/dan-dialog-mixed.png b/docs/images/dan-dialog-mixed.png new file mode 100644 index 0000000..63b188b Binary files /dev/null and b/docs/images/dan-dialog-mixed.png differ diff --git a/docs/images/dan-page-mixed.png b/docs/images/dan-page-mixed.png new file mode 100644 index 0000000..889d7a5 Binary files /dev/null and b/docs/images/dan-page-mixed.png differ diff --git a/docs/images/dan-page-perfect.png b/docs/images/dan-page-perfect.png new file mode 100644 index 0000000..f6e38f6 Binary files /dev/null and b/docs/images/dan-page-perfect.png differ diff --git a/docs/images/dan-settlement-mixed.jpg b/docs/images/dan-settlement-mixed.jpg new file mode 100644 index 0000000..8c74b83 Binary files /dev/null and b/docs/images/dan-settlement-mixed.jpg differ diff --git a/docs/images/dan-settlement.jpg b/docs/images/dan-settlement.jpg new file mode 100644 index 0000000..5e95901 Binary files /dev/null and b/docs/images/dan-settlement.jpg differ diff --git a/docs/images/original-card/UI_CMN_RSL_AllScorekBase.png b/docs/images/original-card/UI_CMN_RSL_AllScorekBase.png new file mode 100644 index 0000000..243782f Binary files /dev/null and b/docs/images/original-card/UI_CMN_RSL_AllScorekBase.png differ diff --git a/docs/images/original-card/UI_CMN_RSL_JaketTrack.png b/docs/images/original-card/UI_CMN_RSL_JaketTrack.png new file mode 100644 index 0000000..217d5db Binary files /dev/null and b/docs/images/original-card/UI_CMN_RSL_JaketTrack.png differ diff --git a/docs/images/original-card/UI_CMN_RSL_JaketTrackNo_0.png b/docs/images/original-card/UI_CMN_RSL_JaketTrackNo_0.png new file mode 100644 index 0000000..00df414 Binary files /dev/null and b/docs/images/original-card/UI_CMN_RSL_JaketTrackNo_0.png differ diff --git a/docs/images/original-card/UI_CMN_RSL_KopMBase_ADV.png b/docs/images/original-card/UI_CMN_RSL_KopMBase_ADV.png new file mode 100644 index 0000000..805dfa1 Binary files /dev/null and b/docs/images/original-card/UI_CMN_RSL_KopMBase_ADV.png differ diff --git a/docs/images/original-card/UI_CMN_RSL_KopMBase_BSC.png b/docs/images/original-card/UI_CMN_RSL_KopMBase_BSC.png new file mode 100644 index 0000000..752cde2 Binary files /dev/null and b/docs/images/original-card/UI_CMN_RSL_KopMBase_BSC.png differ diff --git a/docs/images/original-card/UI_CMN_RSL_KopMBase_EXP.png b/docs/images/original-card/UI_CMN_RSL_KopMBase_EXP.png new file mode 100644 index 0000000..3af4b0d Binary files /dev/null and b/docs/images/original-card/UI_CMN_RSL_KopMBase_EXP.png differ diff --git a/docs/images/original-card/UI_CMN_RSL_KopMBase_MST.png b/docs/images/original-card/UI_CMN_RSL_KopMBase_MST.png new file mode 100644 index 0000000..be5f3f9 Binary files /dev/null and b/docs/images/original-card/UI_CMN_RSL_KopMBase_MST.png differ diff --git a/docs/images/original-card/UI_CMN_RSL_KopMBase_MST_Re.png b/docs/images/original-card/UI_CMN_RSL_KopMBase_MST_Re.png new file mode 100644 index 0000000..e649a75 Binary files /dev/null and b/docs/images/original-card/UI_CMN_RSL_KopMBase_MST_Re.png differ diff --git a/docs/images/original-card/UI_DNM_Result_musicBase_01.png b/docs/images/original-card/UI_DNM_Result_musicBase_01.png new file mode 100644 index 0000000..034a719 Binary files /dev/null and b/docs/images/original-card/UI_DNM_Result_musicBase_01.png differ diff --git a/public/assets/maimai/gam-rank-a.png b/public/assets/maimai/gam-rank-a.png new file mode 100644 index 0000000..2e3a5f6 Binary files /dev/null and b/public/assets/maimai/gam-rank-a.png differ diff --git a/public/assets/maimai/gam-rank-aa.png b/public/assets/maimai/gam-rank-aa.png new file mode 100644 index 0000000..1574915 Binary files /dev/null and b/public/assets/maimai/gam-rank-aa.png differ diff --git a/public/assets/maimai/gam-rank-aaa.png b/public/assets/maimai/gam-rank-aaa.png new file mode 100644 index 0000000..d2e16e4 Binary files /dev/null and b/public/assets/maimai/gam-rank-aaa.png differ diff --git a/public/assets/maimai/gam-rank-b.png b/public/assets/maimai/gam-rank-b.png new file mode 100644 index 0000000..d2bb9a8 Binary files /dev/null and b/public/assets/maimai/gam-rank-b.png differ diff --git a/public/assets/maimai/gam-rank-bb.png b/public/assets/maimai/gam-rank-bb.png new file mode 100644 index 0000000..248c4b7 Binary files /dev/null and b/public/assets/maimai/gam-rank-bb.png differ diff --git a/public/assets/maimai/gam-rank-bbb.png b/public/assets/maimai/gam-rank-bbb.png new file mode 100644 index 0000000..63ab8ee Binary files /dev/null and b/public/assets/maimai/gam-rank-bbb.png differ diff --git a/public/assets/maimai/gam-rank-c.png b/public/assets/maimai/gam-rank-c.png new file mode 100644 index 0000000..4bf5f27 Binary files /dev/null and b/public/assets/maimai/gam-rank-c.png differ diff --git a/public/assets/maimai/gam-rank-d.png b/public/assets/maimai/gam-rank-d.png new file mode 100644 index 0000000..b8e69ae Binary files /dev/null and b/public/assets/maimai/gam-rank-d.png differ diff --git a/public/assets/maimai/gam-rank-s.png b/public/assets/maimai/gam-rank-s.png new file mode 100644 index 0000000..23a458a Binary files /dev/null and b/public/assets/maimai/gam-rank-s.png differ diff --git a/public/assets/maimai/gam-rank-sp.png b/public/assets/maimai/gam-rank-sp.png new file mode 100644 index 0000000..aa152fa Binary files /dev/null and b/public/assets/maimai/gam-rank-sp.png differ diff --git a/public/assets/maimai/gam-rank-ss.png b/public/assets/maimai/gam-rank-ss.png new file mode 100644 index 0000000..107b8d4 Binary files /dev/null and b/public/assets/maimai/gam-rank-ss.png differ diff --git a/public/assets/maimai/gam-rank-ssp.png b/public/assets/maimai/gam-rank-ssp.png new file mode 100644 index 0000000..7eb5baf Binary files /dev/null and b/public/assets/maimai/gam-rank-ssp.png differ diff --git a/public/assets/maimai/gam-rank-sss.png b/public/assets/maimai/gam-rank-sss.png new file mode 100644 index 0000000..a2e97e5 Binary files /dev/null and b/public/assets/maimai/gam-rank-sss.png differ diff --git a/public/assets/maimai/gam-rank-sssp.png b/public/assets/maimai/gam-rank-sssp.png new file mode 100644 index 0000000..92734a5 Binary files /dev/null and b/public/assets/maimai/gam-rank-sssp.png differ diff --git a/public/assets/maimai/life-base-green.png b/public/assets/maimai/life-base-green.png new file mode 100644 index 0000000..0e98d0d Binary files /dev/null and b/public/assets/maimai/life-base-green.png differ diff --git a/public/assets/maimai/life-base-red.png b/public/assets/maimai/life-base-red.png new file mode 100644 index 0000000..7903fec Binary files /dev/null and b/public/assets/maimai/life-base-red.png differ diff --git a/public/assets/maimai/num-26p-outline.png b/public/assets/maimai/num-26p-outline.png new file mode 100644 index 0000000..4b1c14c Binary files /dev/null and b/public/assets/maimai/num-26p-outline.png differ diff --git a/public/assets/maimai/num-26p.png b/public/assets/maimai/num-26p.png new file mode 100644 index 0000000..23d4313 Binary files /dev/null and b/public/assets/maimai/num-26p.png differ diff --git a/public/assets/maimai/num-90p-outline.png b/public/assets/maimai/num-90p-outline.png new file mode 100644 index 0000000..4376753 Binary files /dev/null and b/public/assets/maimai/num-90p-outline.png differ diff --git a/public/assets/maimai/num-90p.png b/public/assets/maimai/num-90p.png new file mode 100644 index 0000000..ad1078c Binary files /dev/null and b/public/assets/maimai/num-90p.png differ diff --git a/public/assets/maimai/num-score-blue.png b/public/assets/maimai/num-score-blue.png new file mode 100644 index 0000000..342b9d9 Binary files /dev/null and b/public/assets/maimai/num-score-blue.png differ diff --git a/public/assets/maimai/num-score-gold.png b/public/assets/maimai/num-score-gold.png new file mode 100644 index 0000000..049d37a Binary files /dev/null and b/public/assets/maimai/num-score-gold.png differ diff --git a/public/assets/maimai/num-score-red.png b/public/assets/maimai/num-score-red.png new file mode 100644 index 0000000..9d7ceea Binary files /dev/null and b/public/assets/maimai/num-score-red.png differ diff --git a/public/assets/maimai/result-dani.png b/public/assets/maimai/result-dani.png new file mode 100644 index 0000000..abb5cf8 Binary files /dev/null and b/public/assets/maimai/result-dani.png differ diff --git a/public/assets/maimai/result-random.png b/public/assets/maimai/result-random.png new file mode 100644 index 0000000..71dea32 Binary files /dev/null and b/public/assets/maimai/result-random.png differ diff --git a/public/assets/maimai/score-per-blue.png b/public/assets/maimai/score-per-blue.png new file mode 100644 index 0000000..bd0ae7f Binary files /dev/null and b/public/assets/maimai/score-per-blue.png differ diff --git a/public/assets/maimai/score-per-gold.png b/public/assets/maimai/score-per-gold.png new file mode 100644 index 0000000..707bdf8 Binary files /dev/null and b/public/assets/maimai/score-per-gold.png differ diff --git a/public/assets/maimai/score-per-red.png b/public/assets/maimai/score-per-red.png new file mode 100644 index 0000000..8e43e27 Binary files /dev/null and b/public/assets/maimai/score-per-red.png differ diff --git a/public/assets/maimai/stamp-clear.png b/public/assets/maimai/stamp-clear.png new file mode 100644 index 0000000..b877a64 Binary files /dev/null and b/public/assets/maimai/stamp-clear.png differ diff --git a/public/assets/maimai/stamp-fail.png b/public/assets/maimai/stamp-fail.png new file mode 100644 index 0000000..3c6d8d3 Binary files /dev/null and b/public/assets/maimai/stamp-fail.png differ diff --git a/public/assets/maimai/track-plate.png b/public/assets/maimai/track-plate.png new file mode 100644 index 0000000..7515a67 Binary files /dev/null and b/public/assets/maimai/track-plate.png differ diff --git a/public/dan.css b/public/dan.css index fd3d0bc..6eddaaa 100644 --- a/public/dan.css +++ b/public/dan.css @@ -138,144 +138,261 @@ .dx-result-time .dx-result-limit { color: var(--muted); font-size: .62rem; } .dx-result-time { flex-direction: column; gap: 1px; } -/* ---------- 段位認定结算页 ---------- - * 版式照原作段位認定结算(Process/Course/CourseResultProcess): - * 奶油色纸盘 + 两侧水引红绳 + 每道一个「可」印 + 竖排逐曲卡 - * (封面 / TRACK 0n / 难度 / 曲名 / 达成率 / LV / DX分数) - * + 底部「十段」框 · 绿心 · 总达成率 · DX分数 + 「好」按钮。 - * 逐个对得上素材包 resources.assets 里的 UI_DNM_*:那枚「可」印是 UI_DNM_Stamp 的 - * Clear / NoClear,两侧绳子是 UI_DNM_Separator,底部那块是 UI_DNM_DaniData。 - * 贴图没有从素材包里搬:全部用 CSS 重画,避免把游戏美术带进这个 MIT 仓库。 - * 盘面用大圆角而不是正圆 —— 三道题卡竖排进正圆,在常见宽度下会挤到不可读。 +/* ---------- 段位認定结算页(原作底图 + 官方组件按官方坐标拼) ---------- + * 版式不是照感觉摆的,是从 resources.assets 的 CourseResultProcess 预制体里量出来的 + * (dump-layout.py 读每个 RectTransform 的 anchors / anchoredPosition / pivot / sizeDelta, + * 再按「美术坐标 = 屏幕中心 + 局部位置」换成 980×928 底图上的像素)。关键坐标: + * 逐题行 x 267–843(576 = 官方的 640 × 0.9),第 1 行 y 152–276,行距 122,共四行 + * 可/不可印 108×108(UI_DNM_Icon_Result 的原始尺寸),圆心 x 203、第 1 行圆心 y 212 + * 底部白框(原作放段位名牌那一格)x 85–310 / y 680–880 + * 心(原作剩余生命那一格)100×100,圆心 (434, 790) + * 总达成率 / DX分数右下角一区:x ~470–965 / y ~620–845 + * 组件也全是官方素材,不用 CSS 画: + * 数字 = UI_CMN_Num_26p/70p/90p 图集逐格切,配同布局的 _Outline 剪影层叠出描边 + * 达成率 = UI_NUM_Score_0001111_{Blue,Gold,Red} 三色分数图集(按分数换色)+ 官方彩色「%」 + * (同名那套 Base 是空图集,不能用;「%」字形也正好只有三张 ⇒ 官方就是三档) + * 评级 = UI_CMN_TabTitle_Rank_*,心 = UI_DNM_Base_Life_01/03,DX 标签 = UI_RSL_DXScore_Base + * 底图 = UI_DNM_Result_Base_01(段位認定)/ _03(ランダム段位認定),行底板 = ..._musicBase_01 + * 唯一的例外是逐题行的**乐曲边框**:官方那张 `UI_CMN_RSL_KopMBase_*` 把难度色与难度名 + * 烘死在图里(五种难度各一张),套不上我们自己的 CF 分档,所以照它逐像素量出来的版式 + * 重画一层(见下面的「逐题行」一节),颜色由 CF 分档给。 + * 素材来源、量法与重新提取见 docs/dan.md「结算页的素材来源」与 + * scripts/extract-maimai-assets.py。 + * + * 盘面锁死 980×928、内部一律绝对像素:缩放交给浏览器对整张图等比缩放, + * 于是「一个数字多宽」这类关系到还原度的结论可以直接抄官方数字。 */ -/* dialog 默认是 shrink-to-fit,只写 max-width 撑不宽,这里必须给 width。 */ -.dan-result { border: 0; padding: 0; background: transparent; width: min(1040px, 96vw); max-width: none; color: #2b1c12; } -.dan-result::backdrop { background: #05070ce0; } -.dan-result-sheet { - position: relative; overflow: auto; max-height: 90vh; - background: radial-gradient(120% 80% at 50% -10%, #fffaf0, #f7e7cd 55%, #eed7b4), #f7e7cd; - border: 9px solid #d8461f; border-radius: 46px; - box-shadow: inset 0 0 0 3px #f7b23f, inset 0 0 0 7px #d8461f, inset 0 0 60px #e8b98155, 0 22px 60px #000b; - padding: 20px 46px 22px; display: grid; gap: 11px; justify-items: center; -} -.dan-result[data-verdict="failed"] .dan-result-sheet { border-color: #8c2b1a; } -.dan-result[data-verdict="abandoned"] .dan-result-sheet { border-color: #8a8578; } -.dan-result-close { position: absolute; top: 12px; right: 18px; padding: 0; font-size: 1.5rem; line-height: 1; color: #a8623a; background: none; border: 0; cursor: pointer; z-index: 2; } -.dan-result-close:hover { color: #d8461f; } -.dan-result-title { margin: 2px 0 0; font-size: 1.85rem; font-weight: 900; letter-spacing: .22em; color: #33200f; text-shadow: 0 1px 0 #fff; } -.dan-result-sub { margin: 0 0 2px; font-size: .8rem; color: #9a7550; text-align: center; } +/* dialog 默认 shrink-to-fit,只写 max-width 撑不宽,这里必须给 width。 */ +.dan-result { border: 0; padding: 0; background: transparent; width: min(1000px, 96vw); max-width: none; color: #23324c; } +.dan-result::backdrop { background: #05070ce8; } +/* 窄屏不把 980px 的成品屏压扁,而是让盘面横向滚动:压扁了字没法读,也谈不上还原。 */ +.dan-result-sheet { overflow: auto; max-height: 92vh; border-radius: 18px; } +.dan-result-body { display: grid; gap: 11px; width: 980px; } -/* 两侧水引(UI_DNM_Separator):上端蝴蝶结 + 竖绳 + 下端流苏 */ -.dan-cord { position: absolute; top: 16px; bottom: 16px; width: 30px; pointer-events: none; } -.dan-cord.left { left: 14px; } -.dan-cord.right { right: 14px; } -.dan-cord::before { - content: ''; position: absolute; left: 50%; top: 34px; bottom: 36px; width: 5px; transform: translateX(-50%); - background: linear-gradient(#ff9d7a, #e0402a 40%, #c9381f); border-radius: 3px; -} -.dan-cord::after { - content: ''; position: absolute; left: 50%; top: 8px; width: 22px; height: 22px; - transform: translateX(-50%) rotate(45deg); background: #f2603c; border-radius: 4px 13px 6px 13px; - box-shadow: inset 0 0 0 2px #ffd2bd, 0 2px 5px #0004; -} -.dan-cord i { - position: absolute; left: 50%; bottom: 6px; width: 20px; height: 30px; transform: translateX(-50%); - background: repeating-linear-gradient(90deg, #e0402a 0 2px, transparent 2px 5px); +.dan-result-art { + position: relative; width: 980px; height: 928px; overflow: hidden; border-radius: 18px; + background: #f7f1e7 url('/assets/maimai/result-dani.png') 0 0 / 100% 100% no-repeat; + box-shadow: 0 18px 50px #000a; } +.dan-result-art[data-art="random"] { background-image: url('/assets/maimai/result-random.png'); } +/* 980×928 的设计尺寸舞台:所有组件在这层里用官方像素坐标绝对定位。 */ +.dan-result-stage { position: absolute; inset: 0; } + +.dan-result-close { position: absolute; top: 10px; right: 16px; z-index: 3; padding: 0; font-size: 1.5rem; line-height: 1; color: #2b6cb8; background: none; border: 0; cursor: pointer; } +.dan-result-close:hover { color: #d8461f; } +/* 页头那行字烘焙在底图里,这个 h2 只留给读屏(id 仍挂在 dialog 的 aria-labelledby 上)。 */ +.dan-result-title { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; } +/* 这一轮的范围说明放在成品屏外面(深色底上):原作那张纸上没有放这行字的位置, + * 硬塞进去就只能压在页头或第一行上。 */ +.dan-result-sub { margin: 0; justify-self: center; text-align: center; font-size: .8rem; line-height: 1.6; color: #c9d2de; } -/* 逐曲卡:左边「可」印(UI_DNM_Stamp),右边一条难度配色的横卡 */ -.dan-result-tracks { display: grid; gap: 7px; width: 100%; margin: 0; padding: 0; list-style: none; } -.dan-track { display: grid; grid-template-columns: 46px minmax(0, 1fr); gap: 9px; align-items: center; } +/* ---------- 官方数字贴图 ---------- + * 每格是一个 ,里面两层 :官方 _Outline 剪影(当遮罩上深色 = 描边)+ 官方填充图集。 + * 两层都是绝对定位,DOM 顺序决定填充盖在描边之上。图集本身是纯白剪影, + * 颜色是运行时给的 —— 和游戏里一样。 */ +.dan-num { display: inline-flex; align-items: center; } +/* 图集每格都留了空白边,照格宽排会读成「一个字一个字分开」。官方数字是密排的, + * 所以 numText 会把「格宽 − 步进」写成 --dan-num-trim,每格右边收回这么多; + * 最后一格不收回,免得数字尾巴多出一段空白。 */ +.dan-num-char { position: relative; display: block; flex: none; margin-right: calc(-1 * var(--dan-num-trim, 0px)); } +.dan-num-char.dan-num-last { margin-right: 0; } +.dan-num-char > b { position: absolute; inset: 0; display: block; background-repeat: no-repeat; } +.dan-num-plain { display: grid; place-items: center; color: #4a3a2a; font-family: var(--mono); font-weight: 900; line-height: 1; } + +/* ---------- 逐题行 ---------- + * 一行 = 原作的行底板(UI_DNM_Result_musicBase_01,576×124)+ 一层**乐曲边框**。 + * 边框照原作 `UI_CMN_RSL_KopMBase_*` 逐像素量出来重画 —— 官方那五张的颜色与难度名 + * (BASiC/ADVANCED/EXPERT/MASTER/Re:MASTER)都烘死在图里,套不上我们自己的 CF 分档, + * 所以只抄版式:主色场交给 --tc1、外圈主色环交给 --tc2(**一块平色**,不做上下渐变)。 + * + * **换算**(下面每个数字的唯一来源):素材包里的 Sprite 精裁版是 636×136,官方设计尺寸 + * 640×140(左右上下各被紧裁掉 2px 透明边,`docs/images/original-card/UI_CMN_RSL_KopMBase_MST.png` + * 就是精裁版);而我们的行内区是 576×126 = 官方 640×140 × 0.9。于是 + * 行内坐标 = (原版文件坐标 + 2) × 0.9 + * 「边框内坐标」= 行内坐标 − 3.6(边框本体左上角落在行内 (3.6, 3.6))。 + * 量法是逐行/逐列扫那张贴图,取每块的首像素…末像素,再把「末像素 + 1」当开区间边界代入: + * 边框本体 原版 2…634 × 2…134 → 行内 (3.6, 3.6) 568.8×118.8;外圈主色环 2px、圆角 ~8 + * 曲绘槽 原版 x 10…127 / y 10…127 → 行内 (10.8, 10.8) 105.3×105.3(白槽、圆角 ~3) + * 歌名条 原版 y 10…30 / 右端 626 → 行内 (122.4, 10.8) 442.8×18 深蓝 #012268 胶囊 + * (左端本来接着难度名牌那一格,名牌删掉后由歌名条顶满上面一整行) + * 白盒 原版 x 134…499 / y 38…106 → 行内 (122.4, 36) 328.5×61.2 + * (原版圆角:左上 9 / 右上 17 / 右下 17 / 左下 0 —— 右下那道大弧就是原版 + * 那块「右侧带缺口的面板」,它一路弯到底带里) + * 底带 原版 x 134…518 / y 106…132 → 行内 (122.4, 97.2) 345.6×23.4 浅色带 + * 右下白牌 原版 x 499…632 / y 106…132 → 行内 (450.9, 97.2) 119.7×23.4 胶囊(两端圆头) + * (原版烘的是日文 `でらっくスコア`;我们寄不出那套字,写「DX分数」) + * 右上紫场 白盒右端…边框内右沿 / 歌名条下沿…白牌上沿 = 119.5×68.4,原版空着, + * 我们拿它放逐题评级徽章(原版白盒那个大 R 弧就是为它让位的)。 + * + * 信息分配(照用户口径):**最上面歌名占一整行**(难度名牌删掉)、曲绘槽在左、 + * 达成率大数字排在白盒里、**DX 分数挪到卡片右下角那块白牌**、**限时写进底带左侧**。 + */ +.dan-result-tracks { position: absolute; inset: 0; margin: 0; padding: 0; list-style: none; } +.dan-track { position: absolute; left: 267px; width: 576px; height: 126px; } +/* 印在行左边、行外那一条(原作里印和底板本来就不在同一个容器里)。 */ .dan-track-stamp { - box-sizing: border-box; width: 46px; height: 50px; display: grid; place-items: center; - font-size: 1.15rem; font-weight: 900; color: #e0402a; background: #fff8ec; - border: 3px solid #e0402a; border-radius: 2px; box-shadow: 0 2px 6px #0002; - clip-path: polygon(29% 0, 71% 0, 100% 29%, 100% 71%, 71% 100%, 29% 100%, 0 71%, 0 29%); -} -.dan-track[data-outcome="timeout"] .dan-track-stamp, -.dan-track[data-outcome="interrupted"] .dan-track-stamp { color: #8f8f8f; border-color: #9d9d9d; background: #f4f1ec; } -.dan-track[data-outcome="pending"] .dan-track-stamp { color: #b9b1a6; border-color: #bcb4a8; background: #f4f1ec; } -.dan-track-card { - display: grid; grid-template-columns: 96px minmax(0, 1fr) auto; gap: 10px; align-items: center; - padding: 6px 12px 6px 6px; border-radius: 9px; border: 2px solid #ffffff59; - background: linear-gradient(100deg, var(--tc1, #8b6cf0), var(--tc2, #4a2fb0)); - box-shadow: 0 3px 10px #0005, inset 0 1px #ffffff40; + position: absolute; left: -118px; top: 8px; width: 108px; height: 108px; + object-fit: contain; filter: drop-shadow(0 2px 3px #0003); +} +/* 还没抽到的那道不盖章 —— 印是真的,只是这一格还没轮到他。 */ +.dan-track[data-outcome="pending"] .dan-track-stamp { opacity: .22; filter: grayscale(1); } +.dan-track-plate { position: absolute; inset: 0; background: url('/assets/maimai/track-plate.png') 0 0 / 100% 100% no-repeat; } + +/* 乐曲边框:**单色**主色场(原版那块底板就是一块平色)+ 外圈 2px 主色环 + 底部浅色带。 + * 环用 inset box-shadow 画、带用子元素画,都不占盒模型 —— 边框**不是**真 border, + * 真 border 会把子元素坐标系推进边框宽度。 */ +.dan-track-frame { + position: absolute; left: 3.6px; top: 3.6px; width: 568.8px; height: 118.8px; border-radius: 7px; + overflow: hidden; + background: var(--tc1, #8b6cf0); + box-shadow: inset 0 0 0 2px var(--tc2, #4a2fb0); } +/* 底部浅色带(原版那一块:从曲绘槽右边一直铺到右下白牌底下)。它是**主色的浅色版本**, + * 不是白色 —— 白色留给白盒与右下白牌。`overflow: hidden` 让它的左下角跟着边框圆弧收掉。 */ +.dan-track-band { + position: absolute; left: 118.8px; top: 93.6px; width: 345.6px; height: 23.4px; + background: color-mix(in srgb, var(--tc1, #8b6cf0) 55%, #ffffff); +} +/* 边框里的组件用**边框内坐标**(= 行内坐标 − 3.6)。 */ +/* 深蓝歌名条:删掉难度名牌之后它**占满上面一整行** —— 左端与白盒左端对齐(边框内 118.8), + * 右端贴到边框内右沿(118.8+442.8=561.6,离主色环还有 ~7px,与原版一致)。 */ +.dan-track-titlebar { + position: absolute; left: 118.8px; top: 7.2px; width: 442.8px; height: 18px; + border-radius: 9px; background: #012268; +} +/* 行首那块「STAGE 01」小牌删掉了:官方那张牌压在曲绘上,我们没有曲绘,它就只是在左上角 + * 多挂一块深蓝方块。样式留成 display:none(不再渲染这个节点,这条只是兜底)。 */ +.dan-track-no { display: none; } +/* 官方的曲绘槽(JacketImage_S):原版是一块 105×105 的**方槽**(圆角只有 ~3px), + * 从边框左上角一直占到接近底部。我们抽的是 CF 题、没有曲绘 —— 槽里放题号本身 + * (`1554C` 这种「题号 + 小问」的写法)与这题在 CF 上的 rating。 + * 底色是**卡片主色的浅色版本**(原版那格是曲绘,换成白牌会像挖了个洞)。 */ .dan-track-jacket { - width: 96px; height: 68px; border-radius: 5px; display: grid; place-items: center; - border: 2px solid #ffffff66; box-shadow: inset 0 0 22px #00000066; - background: linear-gradient(140deg, #ffffff2e, #00000059); -} -.dan-track-jacket b { font-family: var(--mono); font-size: 1.5rem; font-weight: 900; line-height: 1; color: #fff; text-shadow: 0 2px 5px #000b; } -.dan-track-jacket small { font-family: var(--mono); font-size: .58rem; color: #ffffffcc; } -.dan-track-info { display: grid; gap: 3px; min-width: 0; } -.dan-track-head { display: flex; gap: 7px; align-items: baseline; flex-wrap: nowrap; min-width: 0; } -.dan-track-no { font-family: var(--mono); font-size: .66rem; font-weight: 700; letter-spacing: .1em; color: #ffe0b0; white-space: nowrap; } -.dan-track-diff { font-family: var(--mono); font-size: .64rem; color: #fff; background: #0000004d; border-radius: 3px; padding: 1px 6px; white-space: nowrap; } -.dan-track-name { flex: 1; min-width: 0; font-size: .76rem; color: #ffffffeb; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.dan-track-ach { display: flex; align-items: baseline; gap: 8px; } -.dan-track-achlabel { font-size: .68rem; color: #ffffffd0; } -.dan-track-achvalue { font-family: var(--mono); font-size: 1.8rem; font-weight: 900; letter-spacing: -.02em; } -.dan-track-judge { font-size: .62rem; color: #3a2418; background: #ffd76a; border-radius: 999px; padding: 1px 9px; white-space: nowrap; } + position: absolute; left: 10.8px; top: 10.8px; width: 105.3px; height: 105.3px; + border-radius: 3px; display: flex; flex-direction: column; + justify-content: center; align-items: center; gap: 2px; + background: color-mix(in srgb, var(--tc1, #8b6cf0) 42%, #ffffff); + box-shadow: inset 0 0 0 2px #ffffff4d, 0 2px 6px #00000024; +} +.dan-track-jacket b { + font-family: var(--mono); font-size: 1.3rem; font-weight: 900; line-height: 1; color: #17265c; +} +.dan-track-jacket small { font-family: var(--mono); font-size: .85rem; font-weight: 700; color: #3d4a75; } +/* 单题 DX 分数(= 该题算出的 rating):原版那块牌烘着日文 `でらっくスコア`,压在**右下角**、 + * 底带的右端上(它就是原版「右下角是 dx 分数」那一格)。两端是圆头 ⇒ 胶囊。 + * `align-items: baseline`:小字标签与数字按**同一条基线**排(数字那一侧由 numText 的 + * `baseline` 选项按图集基线补一个相对偏移,见 dan.js)。 */ +.dan-track-dxbox { + position: absolute; left: 450.9px; top: 97.2px; width: 119.7px; height: 23.4px; + border-radius: 11.7px; background: #fff; display: flex; align-items: baseline; padding: 0 13px; + box-shadow: 0 1px 4px #00000033; +} +.dan-track-dxlabel { flex: none; font-size: .54rem; font-weight: 900; color: #0d2a63; } +.dan-track-dxnum { margin-left: auto; } +.dan-track-name { + position: absolute; left: 128.8px; top: 7.2px; width: 422.8px; height: 18px; + font-size: .68rem; line-height: 18px; color: #fff; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +/* 达成率白盒:原版那一格是 `UI_AchivementCounter`,白底、**右下角一道大 R 弧**(连续到 + * 底带上,为右上那块紫场让位)。左上角烘着原版的 `ACHIEVEMENT` 小字,我们照位置写 + * 「達成率」。圆角按原版量到再 ×0.9:左上 8.1、右上 15.3,**下面两角都是直角** —— 白盒的 + * 底边和下面那条底带连成一整块,中间不该出现圆角缺口(左下、右下都为 0)。 */ +.dan-track-achbox { + position: absolute; left: 118.8px; top: 32.4px; width: 328.5px; height: 61.2px; + border-radius: 8.1px 15.3px 0 0; background: #fff; +} +.dan-track-achlabel { + position: absolute; left: 10px; top: 4px; font-size: .6rem; font-weight: 900; letter-spacing: .08em; color: #0d2a63; +} +/* 达成率数字**右对齐**:满 100 的那串(`100.8000%`)右端永远贴在同一个位置,不到 100 的 + * 少一位数就少在**左边**(`96.0000%` 的右端仍与 100 的齐平)。左对齐时短的那串右端会缩回来。 + * 垂直上排在「達成率」那行小字**下面**:盒内 y 13…61(盒高 61.2),字高 44 居中, + * 整串右端离盒右沿 8px。 */ +.dan-track-achvalue { position: absolute; left: 9px; top: 13px; width: 311.5px; height: 48px; display: flex; align-items: center; justify-content: flex-end; } +.dan-per { display: block; } +/* 右上那块紫场:白盒右端…边框内右沿 / 歌名条下沿…右下白牌上沿 = 行内 450.9…570.4 / + * 28.8…97.2,原版空着(白盒那道大 R 弧就是给它让位的)。逐题评级徽章放这里 —— + * 99×40 是官方 SSS+ 那张紧裁图(110×44)×0.9,摆进去离白盒 13px、离边框内右沿 7.4px。 + * 徽章源图是紧裁的(SSS+ 110×44、D 38×42),给 99×40 的框按高度贴齐。 */ +.dan-track-badge { position: absolute; left: 464px; top: 43px; width: 99px; height: 40px; object-fit: contain; filter: drop-shadow(0 2px 5px #0000003d); } +.dan-track-judge { + position: absolute; left: 464px; top: 43px; width: 99px; height: 40px; + display: grid; place-items: center; font-size: .72rem; font-weight: 900; color: #0d2a63; + background: #ffd76a; border-radius: 10px; box-shadow: inset 0 0 0 2px #ffffffa6, 0 2px 5px #0000003d; +} .dan-track[data-outcome="timeout"] .dan-track-judge, .dan-track[data-outcome="interrupted"] .dan-track-judge, -.dan-track[data-outcome="pending"] .dan-track-judge { background: #d9d2c8; } -.dan-track-side { display: grid; gap: 4px; justify-items: end; } -.dan-track-lv { - font-family: var(--mono); font-size: .95rem; font-weight: 900; color: #fff; padding: 0 8px; - border: 2px solid #ffffff8c; border-radius: 6px; background: linear-gradient(#7b5cff, #4628b8); -} -.dan-track-score { display: flex; align-items: baseline; gap: 5px; } -.dan-track-score small { font-size: .58rem; color: #ffffffb8; } -.dan-track-score b { font-family: var(--mono); font-size: .9rem; color: #fff; } -.dan-track-limit { font-size: .56rem; color: #ffffff9e; } - -/* 金色描边数字:白→金→橙渐变填充 + 深棕描边(原作那套 DX 数字,用 CSS 重画)。 - * 必须分两层,试过两条捷径都不行: - * - 八向 text-shadow:偏移副本会把渐变填充整个盖住,只剩一圈棕边; - * - -webkit-text-stroke + paint-order: stroke fill:Chromium 的 paint-order 只对 - * SVG 生效,HTML 文字上描边照样盖在填充之上,6px 描边直接吃掉整个笔画。 - * 所以描边交给 ::before、渐变填充交给 ::after,两层叠在同一段文字(data-text)上。 - * 这两点是抠图像素数数出来的,DOM 层面完全看不出来。 */ -.dan-gold { position: relative; display: inline-block; color: transparent; } -.dan-gold::before, .dan-gold::after { - content: attr(data-text); position: absolute; left: 0; top: 0; white-space: nowrap; pointer-events: none; -} -.dan-gold::before { color: #5a2a10; -webkit-text-stroke: 6px #5a2a10; } -.dan-gold::after { - background: linear-gradient(#fffce8, #ffd85c 42%, #ff9a1f 68%, #ffd85c); - -webkit-background-clip: text; background-clip: text; color: transparent; -} -.dan-gold { filter: drop-shadow(0 4px 7px #00000059); } +.dan-track[data-outcome="pending"] .dan-track-judge { background: #e8e2d6; } +/* 限时写进**底带的左侧空白**(原版这条底带是空的一块浅色,右下角才是 DX 分数):所以 + * 它自己没有白牌面,只把字压在浅色带上,左端与底带左端对齐。 */ +.dan-track-limit { + position: absolute; left: 122.4px; top: 97.2px; width: 328.5px; height: 23.4px; + display: flex; align-items: center; padding-left: 12px; + font-size: .58rem; font-weight: 700; color: #0d2a63; +} -/* 底部:判定框 · 心 · 总达成率 / DX分数 */ -.dan-result-bottomrow { display: grid; grid-template-columns: minmax(140px, 210px) auto minmax(0, 1fr); gap: 16px; align-items: center; width: 100%; margin-top: 3px; } -.dan-result-rankbox { - display: grid; place-items: center; gap: 1px; padding: 9px 12px; background: #fffdf6; - border: 3px solid #e6d3b4; border-radius: 13px; box-shadow: 0 3px 10px #00000030, inset 0 1px #fff; -} -.dan-result-rankbox strong { font-size: 2.35rem; font-weight: 900; letter-spacing: .05em; line-height: 1.1; color: #2b1c12; } -.dan-result[data-verdict="failed"] .dan-result-rankbox strong { color: #b32a15; } -.dan-result[data-verdict="abandoned"] .dan-result-rankbox strong { color: #7d8794; } -.dan-result-rankbox small { font-size: .68rem; color: #9a7550; } -.dan-result-life { position: relative; width: 88px; height: 88px; display: grid; place-items: center; } -.dan-result-life::before { - content: '♥'; position: absolute; inset: 0; display: grid; place-items: center; - font-size: 92px; line-height: 1; color: #35c257; - -webkit-text-stroke: 5px #e6fbec; text-shadow: 0 4px 10px #0000004d; -} -.dan-result-life[data-state="bad"]::before { color: #9aa3ae; } -.dan-result-life span { position: relative; z-index: 1; font-family: var(--mono); font-size: 1.2rem; font-weight: 900; color: #fff; text-shadow: 0 1px 4px #000000b3; } -.dan-result-life span small { font-size: .62rem; opacity: .9; } -.dan-result-totalblock { display: grid; gap: 2px; min-width: 0; } -.dan-total-label { font-size: .74rem; font-weight: 700; letter-spacing: .1em; color: #8a5a2a; } -.dan-total-ach { font-family: var(--mono); font-size: 2.5rem; font-weight: 900; letter-spacing: -.02em; } -.dan-total-score { display: inline-flex; align-items: baseline; gap: 7px; justify-self: start; margin-top: 2px; padding: 2px 12px; border-radius: 6px; background: #fffdf699; border: 2px solid #e6d3b4; } -.dan-total-score small { font-size: .64rem; color: #9a7550; } -.dan-total-score b { font-family: var(--mono); font-size: 1rem; color: #2b1c12; } +/* ---------- 底部:段位名牌 · 心 · 总达成率 / DX分数 ---------- + * 底图在这一块**预留了三处白**(官方在那儿写运行时文字):(503,663) 与 (753,677) 是 + * 「総達成率」那个标签的白格(中间被折页的斜边切开),(608,810) 360×80 是「DX分数」 + * 那条白牌(左端还有个空的圆白圆头)。所以这三块的**位置不用猜**,照白格摆: + * 标签进白格 → 大数字落在下面的色带上 → DX 分数进下面那条白牌。 */ +.dan-bottom { position: absolute; inset: 0; } +/* 左下白框(底图 (74,672) 240×230):原版这里写段位名(十段/皆伝…)。 + * 我们发的是档位、不发段位名,所以照原版把**档位名**写进去: + * 深棕重字 + 米白描边 + 一点投影(原版那一路毛笔字我们寄不出字体,用系统里的重明朝近似)。 */ +.dan-verdict { + position: absolute; left: 74px; top: 672px; width: 240px; height: 230px; + display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 6px; +} +.dan-plate-name { + /* 原版那两个字是**重毛笔字**。客户端里它是烘死的贴图,我们只能用系统字体近似: + * 先取思源宋体的 900(这台机器上是 `NotoSerifSC-VF`,最重的一档),再退回系统宋体/ + * 楷体那一类衬线。SimSun 那种细宋差得太远(笔画只有原版一半粗)。 */ + font-family: "Noto Serif SC", "Source Han Serif SC", "Songti SC", "STKaiti", "KaiTi", "SimSun", serif; + font-size: 104px; font-weight: 900; line-height: 1.08; letter-spacing: .01em; + color: #3a2114; paint-order: stroke fill; + /* 原版那两个字是深棕重字 + 很淡的米白边缘 + 一点投影;描边给厚了会像贴纸。 */ + -webkit-text-stroke: 7px #fff6e6; + text-shadow: 0 3px 0 #00000024; +} +/* 名字长的档位(小随机段位 / 大随机段位)自动缩号,别顶穿白框。 */ +.dan-plate-name[data-len="3"] { font-size: 88px; } +.dan-plate-name[data-len="4"] { font-size: 68px; } +.dan-plate-name[data-len="5"], .dan-plate-name[data-len="6"] { font-size: 54px; } +.dan-verdict-count { font-size: .76rem; font-weight: 800; letter-spacing: .04em; color: #7a5a34; } +/* 原作这一格是剩余生命的圆心。素材本身是 272×272,之前压到 104 —— 照官方放大到 200, + * 位置保持原来那个圆心(434,790)。 */ +.dan-life { position: absolute; left: 334px; top: 690px; width: 200px; height: 200px; } +.dan-life-base { width: 100%; height: 100%; object-fit: contain; filter: drop-shadow(0 3px 8px #00000038); } +.dan-life-count { position: absolute; inset: 0; display: grid; place-items: center; } +/* 总达成率:标签写进底图预留的白格,大数字落在下面的色带上,右端对齐 x 945 + * (与下面那条 DX 分数白牌的右端同一条边)。 */ +.dan-total-row { position: absolute; inset: 0; } +/* 「总达成率」四个字写在白格**偏左**的位置(官方那四个字不在白格正中),字号压到 23。 + * 底图这块白实测是 x 503..708(右边那截 753..856 被折页斜边切开),所以按 503..708 居中。 */ +.dan-total-label { + position: absolute; left: 503px; top: 663px; width: 354px; height: 52px; + display: grid; place-items: center; padding-right: 140px; padding-bottom: 12px; + font-size: 23px; font-weight: 800; letter-spacing: .1em; color: #16306b; +} +.dan-total-num { + position: absolute; left: 490px; top: 727px; width: 460px; height: 66px; + display: flex; align-items: center; justify-content: flex-end; +} +/* DX 分数:官方这条白牌就是底图预留的 (608,810) 360×80,标签在左、数字在右, + * 都是深蓝(和每题那张卡上的 DX 分数同一个色)。白牌下半截被折页斜切掉一块, + * 所以文字靠上排(顶到 y 816),不跟着整块矩形居中。 */ +.dan-dxscore-row { + position: absolute; left: 608px; top: 810px; width: 360px; height: 80px; + display: flex; align-items: baseline; justify-content: space-between; padding: 0 46px 0; +} +.dan-dxscore-label { font-size: 30px; font-weight: 800; letter-spacing: .06em; color: #16306b; } +.dan-dxscore-num { display: flex; align-items: center; } -.dan-result-note { margin: 2px 0 0; max-width: 72ch; text-align: center; font-size: .78rem; line-height: 1.7; color: #96714b; } -.dan-result-actions { display: flex; gap: 10px; flex-wrap: wrap; justify-content: flex-end; align-items: center; width: 100%; } +/* 说明与按钮在成品屏之外(深色底上),不往那张纸上塞东西。 */ +.dan-result-note { margin: 0; max-width: 92ch; justify-self: center; text-align: center; font-size: .78rem; line-height: 1.7; color: #a9b4c4; } +.dan-result-actions { display: flex; gap: 10px; flex-wrap: wrap; justify-content: flex-end; align-items: center; } .dan-result-ok { min-width: 112px; padding: 7px 26px; font-size: 1.25rem; font-weight: 900; color: #fff; cursor: pointer; background: linear-gradient(#ff9a4d, #e8542a); border: 3px solid #c9381f; border-radius: 11px; @@ -286,17 +403,8 @@ @media (max-width: 860px) { .dan-grid { grid-template-columns: 1fr; } - .dan-result-sheet { padding: 16px 22px 18px; border-radius: 30px; } - .dan-result-title { font-size: 1.4rem; } - .dan-cord { display: none; } - .dan-track-card { grid-template-columns: 62px minmax(0, 1fr); row-gap: 6px; } - .dan-track-jacket { width: 62px; height: 56px; } - .dan-track-jacket b { font-size: 1.15rem; } - .dan-track-side { grid-column: 2; grid-auto-flow: column; align-items: baseline; gap: 8px; justify-items: start; } - .dan-track-achvalue { font-size: 1.35rem; } - .dan-track-name { flex-basis: 8ch; } - .dan-result-bottomrow { grid-template-columns: 1fr; justify-items: center; } - .dan-total-ach { font-size: 2rem; } - .dan-total-score { justify-self: center; } + /* 结算盘是一张 980×928 的成品屏,窄屏不重排、只横向滚动(见 .dan-result-body 的 + * min-width)—— 把四行字和页头压进 360px 宽只会压成不可读。 */ + .dan-result-close { top: 6px; right: 10px; font-size: 1.25rem; } .dan-result-actions { justify-content: center; } } \ No newline at end of file diff --git a/public/dan.js b/public/dan.js index 4cc9861..361d4d2 100644 --- a/public/dan.js +++ b/public/dan.js @@ -1,4 +1,4 @@ -import { startAutoSync } from './auto-sync.js'; +import { startAutoSync } from './auto-sync.js'; import { cfRatingColor } from './cf-rating-colors.js'; /* @@ -590,7 +590,8 @@ function drainSheet() { /* ---------------- 段位認定结算页(对齐原作 UI_DNM_Result_* 的构成) ---------------- */ -/** 难度色号 → 逐曲卡的渐变(与 CF 分色同一套边界;violet 是原作 MASTER 的紫)。 */ +/** 难度色号 → 逐曲卡的 [主色场, 外圈主色环](与 CF 分色同一套边界;violet 是原作 MASTER 的紫)。 + * 两个色都只当**平色**用,不做上下渐变 —— 原版那块乐曲底板就是一块平色。 */ const TRACK_TONE = { red: ['#e8564f', '#a3211c'], orange: ['#f08c3a', '#a85a12'], violet: ['#8b6cf0', '#4a2fb0'], blue: ['#4a7fe0', '#1e3f96'], @@ -602,26 +603,45 @@ let resultDialog = null; function buildResultDialog() { const dialog = el('dialog', 'dan-result'); dialog.setAttribute('aria-labelledby', 'danRunTitle'); + // 盘面是一张 980×928 的固定设计尺寸舞台,里面所有组件都用**官方预制体量出来的像素坐标** + // (见 dan.css 顶部注释)。窄屏横向滚动,不做响应式重排 —— 重排就还原不出来了。 dialog.innerHTML = `
- - -

段位認定

-

-
    -
    -
    -
    -
    - 总达成率 - - 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