CHUNITHM 板块实现解析:落雪数据源与 Best30+New20+Selection 体系
引言:第三个游戏,第一套”可复用”的查分架构
rRanker 是一个聚合多款音游数据的 React Native 应用,代码位于 apps/mobile。在 maimai(舞萌 DX)与 Phigros 之后,CHUNITHM(中二节奏)成为第三个接入的游戏。与 maimai 走”查分器+本地”双轨不同,CHUNITHM 板块只有一个数据源——落雪咖啡屋(lxns.net)的公开 API。这个”单一数据源”的设定带来了一系列连锁设计决策:
- 成绩数据必须 OAuth 授权后才能读取,因此 CHUNITHM 板块直接复用了为 maimai 落雪源打造的
lxns-oauth会话体系(连授权凭据都共用同一份); - 曲库数据公开免鉴权,走独立的公共曲库 Provider;
- 服务器端(机台/落雪)已经算好 Rating 与 Best 分段,App 只负责展示与”领域”主题映射,不参与 Rating 计算;
- 成绩图(Best 成绩图片)则完全是中二特有的 UI 重活:Best30 + New20 + Selection 三元组、六难度配色、WORLD’S END 特例处理。
本文按数据流顺序逐层拆解:账号绑定 → 数据拉取 → 缓存回退 → 曲库 → 领域与展示 → 成绩图 → 随机谱面 → 离线同步引导。所有代码引用均来自 apps/mobile/src,行号以仓库当前实际为准。
账号绑定:落雪 OAuth 与”绑定即预热”
PKCE S256 全流程
CHUNITHM 与 maimai 的落雪源共享同一套 OAuth 实现(providers/lxns-oauth.ts)。落雪授权页不支持私密客户端(没有 client_secret 可用),因此走公开客户端 + PKCE(RFC 7636)路线,App 端实现如下:
createPkcePair()(lxns-oauth.ts:42-52)用expo-crypto取 32 字节随机数做 verifier,SHA-256 摘要后做 base64url 编码得到 challenge;beginLxnsAuthorize()(lxns-oauth.ts:67-73)把 verifier 写入 SecureStore(WHEN_UNLOCKED_THIS_DEVICE_ONLY),再拼授权页 URL:response_type=code、client_id、redirect URIurn:ietf:wg:oauth:2.0:oob、scoperead_user_profile write_player read_player、code_challenge与code_challenge_method=S256(lxns-config.ts:2-6)。因为 redirect 是 out-of-band 形式,授权码由用户手动粘贴回 App;exchangeLxnsAuthorizationCode()(lxns-oauth.ts:138-158)取回本地暂存的 verifier,向 token 端点 POSTgrant_type=authorization_code换 token,成功后立即清除 verifier;- 此后过期时走
refreshLxnsAccessToken()(lxns-oauth.ts:160-166),以grant_type=refresh_token换新。
// lxns-oauth.ts 的 PKCE 摘要(简化)
const bytes = await Crypto.getRandomBytesAsync(32);
const verifier = base64UrlFromBytes(bytes);
const challenge = base64Url(await Crypto.digestStringAsync(
Crypto.CryptoDigestAlgorithm.SHA256, verifier,
));
await SecureStore.setItemAsync('rranker.lxns.oauth.pending.v1', verifier);
return buildAuthorizeUrl(challenge); // code_challenge_method=S256
token 响应的解析有讲究:parseTokenPayload()(lxns-oauth.ts:79-89)先按顶层 access_token 解析,失败再尝试嵌套的 data 字段,两种形态都兼容;TokenResponseSchema(lxns-oauth.ts:18-24)要求 expires_in 为正数、refresh_token 非空,属于”与已验证契约不一致就直接报错”的严格风格。所有网络请求带 12 秒 AbortController 超时(lxns-oauth.ts:103),超时/网络/HTTP 4xx 分别映射为 timeout/network/authentication 三类 ProviderError,供上层决定是否可重试。
绑定即预热:绑定瞬间完成快照写入
maimai 的绑定流程是先建账号、后等首次查询才出分;CHUNITHM 板块做了一个更激进的优化——“绑定即预热”(services/lxns-account-binding.ts:78-111):
const provider = new ChunithmScoreProvider(initialSession);
const snapshot = await provider.getSnapshot(); // 一次性拉全量
const account = createChunithmBoundAccount({ ... }); // rating 立即可见
await snapshots.saveResource(
chunithmPersonalResourceKey(account.id),
CHUNITHM_PERSONAL_SNAPSHOT_SCHEMA_VERSION, // v2
snapshot.source.updatedAt, snapshot,
);
await sessions.upsertAccount({ ... session: finalSession });
在授权码换 token 成功的同一帧内,App 就调用了 getSnapshot() 拉取玩家资料 + 全量成绩 + B50 三元组,把快照写进 SQLite 的 resource_snapshots 表(资源键 chunithm-score:{accountId},schema v2,见 domain/chunithm-personal.ts:74-87),并把 Rating、玩家名、领域、头像 URL 直接填进 BoundAccount。因此用户从”粘贴授权码”到”账号列表看到自己的 Rating”没有一次额外的等待——这是 rRanker 里唯一一个绑定即拉全量数据的游戏板块。若玩家资料尚不可用(落雪侧尚未同步),账号会退化为占位态”落雪账号(待同步)“,accountId 用 chunithm:lxns:{credentialId} 兜底(lxns-account-binding.ts:82)。
与 maimai 共享同一份落雪凭据
绑定 CHUNITHM 时不需要重新授权:因为 maimai 落雪账号已经持有 lxns-oauth 会话,CHUNITHM 绑定直接复用这份会话,并以 credentialId 为纽带把两个账号关联起来:
credentialId形如lxns:+ 16 字节随机 hex(lxns-account-binding.ts:31-34);sessionsWithSharedCredential()(state/session-store.ts:304-319)在写入新账号会话时,会把所有持有同一credentialId的账号一并更新,保证两个游戏共享同一个 access/refresh token;- 反向路径
applyLxnsTokenRotation()(session-store.ts:29-47):任一 Provider 刷新 token 后,通过onTokensRotated回调按 credentialId 反查关联账号列表,逐一覆盖内存会话,并持久化到 SecureSessionStore。
这套”凭据一主多从”的设计,让用户在 maimai 侧授权一次,CHUNITHM 侧零授权成本接入;任何一侧发生 token 轮换,另一侧自动跟随,不会出现”maimai 能读、中二 401”的分裂状态。
会话恢复与摘要回填
App 重启时 finishRestore() 从 vault 重建会话(session-store.ts:526-565):sessionsMapFromVault 把 credential → session 的映射平铺成 account → session(session-store.ts:286-296),boundFromStored 则把存储的账号元数据还原为 BoundAccount,其中 chunithm 分支会把持久化的 scoreDisplay 转回 number 作为 rating 填充(session-store.ts:269-277)。这里还有一个细节:若 vault 里已存在正式的中二落雪账号,恢复时会过滤掉 chunithm-temp 临时账号(session-store.ts:549-554),避免临时壳账号污染正式账号列表。
启动完成后,hydrateChunithmAccountSummaries()(services/hydrate-chunithm-account-summaries.ts:14-48)对每个 gameId === 'chunithm' && providerId === 'lxns' 的账号只读地读取 v2 快照缓存,用 player.rating.toFixed(2)、玩家名、头像 URL(buildChunithmMapIconUrl)、领域字段一次性回填账号列表与 SecureStore 元数据。单账号缓存读取失败不阻断列表(hydrate-chunithm-account-summaries.ts:44-46),保持”启动零网络、列表必有分”的体验。
数据拉取:ChunithmScoreProvider
构造期强约束与会话单飞刷新
ChunithmScoreProvider(providers/chunithm-score-provider.ts:34-46)在构造函数里就做了硬性校验:会话模式不是 lxns-oauth 直接抛 authentication 错误——它不接受任何其他形态的会话,从类型上杜绝了”拿 maimai 本地会话去读中二成绩”的可能。
token 刷新的实现值得一提:ensureFreshAccessToken()(chunithm-score-provider.ts:61-74)先判断过期(expiresAt <= now + 60s 缓冲,lxns-config.ts:9),过期时不是直接发刷新请求,而是把刷新逻辑包进一个模块内单飞的 refreshPromise——并发到达的多个请求共享同一个 Promise,刷新只发生一次,完成后统一拿到新 token。这与 maimai 的 LxnsScoreProvider(lxns-score-provider.ts:56-69)是同一套代码形态,属于刻意保留的”双份复制”:两份 Provider 完全解耦,token 轮换通过回调外置,互不依赖。
三个接口与并发快照
中二数据模型与 maimai 最大的不同是”Best 分段”由服务端直接给出,App 侧零计算:
| 接口 | 方法 | 返回 |
|---|---|---|
GET /user/chunithm/player | getPlayer()(chunithm-score-provider.ts:124-132) | 玩家资料(name/rating/领域/class_emblem/角色等) |
GET /user/chunithm/player/scores | getScores()(:134-147) | 全曲成绩数组 |
GET /user/chunithm/player/bests | getBests()(:149-161) | { bests, selections, new_bests } 三元组 |
三者用 Promise.all 并发拉取(chunithm-score-provider.ts:163-175),因为共用同一个 access token、彼此无依赖,串行反而白白多付两个 RTT。ChunithmBestsSchema(domain/chunithm-personal.ts:57-61)把 selections 与 new_bests 设为可选、缺省为空数组,兼容服务端早期版本;ChunithmScoreSchema(:34-52)则是一份严格的契约:level_index 限制 0–5、clear 只接受 catastrophy/absolute/brave/hard/clear/failed 六值、full_combo 为 alljusticecritical/alljustice/fullcombo、full_chain 为 fullchain/fullchain2、rank 限 14 档。任何一条不符,整个请求都会被判为 upstream_schema 错误——宁可全挂也不静默降级,避免脏数据污染 UI 与缓存。
所有响应先过 LxnsEnvelopeSchema(domain/schemas.ts:68-73)解包 { success, code, message, data } 信封:success === false 视为业务拒绝(可选请求下 code 404 视为”无此玩家”返回空),结构对不上则抛 upstream_schema。
值得一提的是 Provider 的生命周期:与 maimai 不同,session-store.providersForAccount 对 chunithm 账号返回空 Provider(session-store.ts:170-173),真正的 ChunithmScoreProvider 由 use-game-data 在每次查询时按需构建(hooks/use-game-data.ts:83-92),构造时注入 applyLxnsTokenRotation(activeAccountId, next) 作为 token 轮换回调。这样的好处是:store 里不持有任何会过期的会话对象,token 永远来自 sessionsByAccountId 最新值,且每次查询天然拿到轮换后的新 token——“会话状态”与”查询动作”彻底解耦。
三层缓存回退:ChunithmPersonalService
ChunithmPersonalService.load()(services/chunithm-personal-service.ts:20-50)是”在线优先、缓存兜底”的标准三级回退,且每一级的语义都有注释级设计:
- 在线直读:
provider.getSnapshot()成功后立即saveResource写回 v2 快照(:22-29)。注意:写缓存发生在返回数据之前,即”下次失败一定有缓存可用”; - v2 回退:网络/超时类错误(
authentication除外,它直接上抛——凭据失效回退缓存只会误导用户)时读 v2 资源; - v1 legacy 回退:v2 缺失时再读 v1 旧 schema,由于 v1 没有 bests 字段,回填
emptyChunithmBests()(:36-39)保证类型完整——历史数据降级时 Best 区显示为空,而不是崩溃。
回退命中后统一改写 source:label: '落雪咖啡屋(缓存)'、isStale: true(:41-48)。isStale 贯穿整个 UI——成绩页的数据源徽标、随机谱面页的 source 列表都会把 stale 态显示为”缓存”,明确告知用户这不是实时数据。resource_snapshots 表本身是通用的(storage/sqlite-snapshot-repository.ts:33-36),按 (resource_key, schema_version) 精确匹配,schema 升级即旧 key 失效自动清理(:113-129),这正是中二板块敢做 v1→v2 迁移的底气。
曲库:公开免鉴权的 CatalogProvider
契约与版本反查
ChunithmCatalogProvider(providers/chunithm-catalog-provider.ts:276-288)走 https://maimai.lxns.net/api/v0/chunithm 公共根(:14),无需任何凭据,三个端点:/song/list、/alias/list、/song/{id}。契约层层收紧:
NotesSchema(:27-34):物量五元组total/tap/hold/slide/air/flick全部要求非负整数;DifficultySchema(:36-46):difficulty0–5、level_value有限非负、note_designer、origin_id、kanji、star皆可空——kanji与star正是 WORLD’S END 谱面”汉字☆星级”展示所需;SongSchema(:48-60):genre缺省”未分类”、bpm缺省 0,difficulties非空数组;CatalogResponseSchema(:62-66):versions至少一条,用于版本映射。
最有意思的是 versionAtOrBefore()(:107-119):落雪返回的每条谱面携带一个数值 version(如 1、2、3…),而曲库的 versions 数组给出每个数值对应的代目标题。映射算法是”取 ≤ 该数值的最大版本”:
function versionAtOrBefore(versions, rawVersion) {
return versions.reduce((matched, item) =>
item.version <= rawVersion && (!matched || item.version > matched.version)
? item : matched, undefined);
}
这样 MASTER 谱面标注”初出 SUN”、而 ULTIMA 谱面标注”初出 RISE”成为可能——同一首歌的不同难度谱面可以归属不同代目,版本过滤器(随机谱面页)也天然按谱面粒度生效。mapSong/mapDifficulty(:160-209)把数字 version 翻译成 versionId/versionTitle 双字段后,无 difficulties 的歌被过滤(:194),保证曲库内每首歌至少有一个可玩谱面。mapChunithmCatalog 再取 versions 中最大值作为 currentVersion(:221-227)——这个字段是示例账号”New 20 只取当前代目”的分界依据(后文详述)。
曲库加载与别名合并
chunithm-catalog-loader.ts 把曲库与别名封装成两个独立资源:曲库 schema v2、别名 schema v1(:11-12),共用 ResourceService(services/resource-service.ts:9-25)——与个人快照同款”在线写缓存、失败回退标 stale”策略。
useChunithmCatalog(hooks/use-chunithm-catalog.ts:11-44)是曲库的唯一入口:仅在 activeGameId === 'chunithm' 时启用(:14),queryFn 先拉曲库、再 Promise.allSettled 拉别名——别名失败不阻塞曲库,只是 source.label 标注”(别名暂不可用)“(:38-40);两者任一来自缓存则整体标记为 kind: 'cache' 的 stale 资源。合并产物是每首歌挂上自己的 aliases: string[] 数组,供搜索页与详情页的别名匹配使用。这套”主资源强依赖、附属资源尽力而为”的合并策略,保证曲库页面在弱网下依然可用。
本地/示例/临时账号:三态并存
除了落雪真实账号,中二板块还维护两个内建账号,都定义在 domain/bound-account.ts:
CHUNITHM_TEST_ACCOUNT_ID = 'chunithm:test'(:27)——示例账号,provider 为chunithm-test,scoreDisplay为rating.toFixed(2),领域写死rainbow(:119-134);CHUNITHM_TEMP_ACCOUNT_ID = 'chunithm:temp'(:28)——无成绩临时账号(createChunithmTempAccount,:105-117),provider 为chunithm-temp,score 恒为—,纯占位壳,绑定正式账号即被替换(components/ProviderLoginSheet.tsx:176)。
这两个内建账号在会话层面的待遇与其他账号一致:clearSession() 清空远程会话时保留 chunithm-test/chunithm-temp 等本地账号(session-store.ts:517-525),SecureSessionStore.setActiveAccountId 也把它们列入 builtin 白名单、无需存在于 vault(storage/secure-session-store.ts:602-607)。也就是说”示例/临时账号”跨重启稳定存在,但从不携带任何凭据,属于纯本地实体。
能力判定集中在 domain/provider-capabilities.ts:canReadChunithmScores()(:3-9)只对 chunithm-test 与 lxns + lxns-oauth 放行;shouldPersistScoreSnapshot()(:11-16)明确排除 chunithm-test——示例账号的成绩是”现场生成”的,写缓存毫无意义。这套判定函数被 session-store 与各页面复用,保证”临时账号不读成绩、示例账号不落库、正式账号不丢缓存”三态互不越界。
示例账号:基于真实曲库的全曲满成绩
MaxedChunithmTestProvider(providers/maxed-chunithm-test-provider.ts)不是静态假数据,而是以真实曲库为输入、现场生成的满成绩世界:
- 每个非 disabled 曲目的每个谱面:
score = 1,010,000、clear = 'catastrophy'、full_combo = 'alljusticecritical'、full_chain = 'fullchain2'、rank = 'sssp'(:58-81); - WORLD’S END(difficulty === 5)刻意不生成
rating与over_power(:69-74)——WE 谱面在官方体系里不计入 Rating,示例账号也必须遵循; - 单谱面满成绩的 Rating 公式:
maxChunithmChartRating(levelValue) = levelValue + 2.15(:49-51);单谱面理论 Over Power:maxChunithmChartOverPower(levelValue) = (levelValue + 3) * 5(:54-56)。这两条公式直接复刻机台规则:满分 1,010,000 恰好拿到”定数 + 2.15”的 Rating 与 100% Over Power; - 排序比较器(
:37-43)先比 rating 再比 score 再比 id,保证同一份曲库在任何设备上生成的 B50 排序完全一致。
B50 三元组的切分算法(buildMaxedChunithmBests,:83-114)严格模拟官方逻辑:
const current = rated.filter(e => e.versionId === catalog.currentVersion.id)
.sort(compareGeneratedScores);
const newBests = current.slice(0, 20); // New 20:仅当前代目
const remaining = rated.filter(notIn(newBests)).sort(...);
const bests = remaining.slice(0, 30); // Best 30:其余全曲谱面
const selections = remaining.slice(30, 40); // Selection 10
最终账号 Rating = Best30 + New20 共 50 个谱面 rating 之和 / 50,truncateToTwo 截断到两位小数(:122-124)——注意是截断不是四舍五入,与机台一致;Over Power 则按”每首歌取最高谱面的 OP,再求和”(:125-136)。player.level = 99、rating_possession = 'rainbow'、friend_code = 'chunithm:test'(:137-154)。数据源标记 kind: 'generated'、label”示例查分器(全曲全谱面满成绩)“(:20-27),在 UI 上诚实标注这是生成数据。示例账号在 GameAccountsScreen 添加后持久化在 kv-store(storage/chunithm-demo-account-store.ts),启动恢复时经 finishRestore 的 optionalAccounts 回到列表。
Rating 领域:从服务端数字到 maimai 主题机的复用
Rating 档位表
真实账号的 Rating 由落雪服务端计算,App 只做两件事:展示与”着色”。domain/chunithm-rating-theme.ts 的 RATING_TIERS(:27-44)是九档纯展示阶梯:
0 → 绿 #00E676
4 → 橙 #FF8A00
7 → 红 #FF2D55
10 → 紫 #B845FF
12 → 铜 #D67A31
13.25 → 银 #B8D7E8
14.5 → 金 #FFD84D
15.25 → 铂金 #8DEBFF
16 → 虹(六色渐变)
resolveChunithmRatingTier()(:62-69)是线性扫描取最后一个命中的档位(tiers 已按 min 升序),Rating 数字的颜色(成绩图上的 text-outline)与背景由此而来。
possession → DX Rating 的”伪映射”
CHUNITHM 的领域(possession)是字符串(silver/gold/platinum/rainbow),而 maimai 的主题机(domain/dx-rating-theme.ts)按数值 DX Rating 分档。resolveChunithmPossessionTheme()(chunithm-rating-theme.ts:81-92)做了巧妙的桥接:
const POSSESSION_DX_RATINGS = {
none: 0, silver: 13_000, gold: 14_000,
platinum: 14_500, rainbow: 15_000,
};
// → resolveDxRatingTheme(13000) 命中 dx 的"银 · 13000–13999"
领域被翻译成 DX Rating 档位下限,从而白嫖了 maimai 一套完整的渐变主题(fill/border 颜色、overlay、textColor),再覆写 id 为 chunithm-possession-{id}、label 为”银领域”等、starCount = 0(:86-91)。账号列表里的 Rating 标签(components/ChunithmRatingTag.tsx)与成绩图里的身份卡都走这条映射——一个字段,两处消费,主题完全一致。normalizeChunithmPossession()(:71-79)对未知值一律归 none,防御上游新增领域值导致的样式崩溃。
成绩展示:7 位分制、成就徽章与流动光效
分制与徽章
domain/chunithm-score-presentation.ts 是中二展示逻辑的纯函数层。chunithmRankFromScore()(:40-55)是完整的 7 位分制阈值表:
SSS+ ≥ 1,009,000 SSS ≥ 1,007,500 SS+ ≥ 1,005,000
SS ≥ 1,000,000 S+ ≥ 990,000 S ≥ 975,000
AAA ≥ 950,000 AA ≥ 925,000 A ≥ 900,000
BBB ≥ 800,000 BB ≥ 700,000 B ≥ 600,000
C ≥ 500,000 否则 D
成就徽章体系(chunithmAchievementBadges,:159-186)分三层:
- FC 层:
alljusticecritical → AJC(彩虹)、alljustice → AJ(铂金)、fullcombo → FC(金),三者互斥; - FULL CHAIN 层:
fullchain(真·全连,铂金)与fullchain2(金)独立叠加在 FC 之上; - CLEAR 六阶梯:
failed → CLEAR → HARD → BRAVE → ABSOLUTE → CATASTROPHY,从灰到彩虹。
也就是说一张卡最多能叠出”FC 徽章 + FULL CHAIN 徽章 + CLEAR 徽章”三个徽章,视觉密度恰好传达中二玩家最关心的三个维度。buildChunithmScoreCards()(:117-157)把成绩与曲库 join 出展示卡片,其中 WE 特例是:difficultyConstant 置空,改用 worldsEndLabel(formatChunithmWorldsEndLabel,:102-115)——优先 kanji☆star(如”極☆13”),退而求其次只显示 kanji,再不行才用谱面 level 字符串。普通谱面则显示 levelValue.toFixed(1) 的定数。同一文件里还有一组纯函数工具:compareChunithmScores(:74-81)先比 rating、再比 score,是成绩图排序的唯一比较器;averageChunithmRating(:83-89)过滤掉无 rating 的条目后算均值——这类”领域内的小工具”与 React 完全解耦,天然可单测。
流动光效
S 及以上的 rank 徽章与成绩数字走渐变主题(chunithmRankUsesGradient,:65-72)。ChunithmScoreCard(components/chunithm/ChunithmScoreCard.tsx:30-42)定义了三段渐变色带 #73CFFF → #EFCB63 → #FF8EC8 循环;useFlowingProgress(components/game-content/use-flowing-progress.ts:5-21)用 Animated.loop + useNativeDriver 驱动一个 0→1 的进度值,配合 interpolate 把渐变轨道从 -width 平移回 0,形成”流光”效果。设计上有个克制点:只有 SSS+ 才流动(ChunithmScoreCard.tsx:177,239),其余渐变档位静态展示——流动是稀缺特效,不是默认态。动画还感知标签页激活状态(useCachedTabActive),标签页切走即停,避免后台空转。
最佳成绩页与成绩图:HTML 模板渲染管线
三元组 → 分页
成绩图(Best 成绩图片)是中二板块最重的 UI。数据源头 payload.bestSections 由 use-game-data 组装为 Best 30 与 New 20 两个 section(hooks/use-game-data.ts:101-104),Selection 单独成列表(:105)。ChunithmBestImageScreen 的组装逻辑(screens/ChunithmBestImageScreen.tsx):
appendChunithmSelectionScores()(features/chunithm-best-image/chunithm-best-image.ts:20-38)把 Selection 追加为第三个分区,数量可选0/5/10(:70的SELECTION_COUNTS),默认 0 即不追加——Selection 只在成绩图里出现,App 内成绩列表不展示它;每账号的选择项(Selection 数量、角色、背景)按账号 ID 持久化在 kv-store(features/chunithm-best-image/chunithm-best-image-preferences.ts:22-29,113-127),且做过 v1/v2/v3 三代的迁移解析(:78-99),旧偏好升级不丢字段;paginateChunithmBestImageSections()(:41-71)按50 + selectionCount每页切分,即选择 10 个 Selection 时每页 60 张卡。分页先展平再按页重分组,保证分区标题不跨页截断(同区记录跨页时新页会重出该区头部);- 每页 HTML 由
buildChunithmBestImageHtml()(features/chunithm-best-image/build-chunithm-best-image-html.ts:168-438)生成,宽度可选 1080/1440/2160 三档(ChunithmBestImageScreen.tsx:69),所有尺寸参数按width等比缩放(profileScale = width / 1080,:181)。
模板里的中二细节
- 难度配色(
build-chunithm-best-image-html.ts:38-45):BASIC#4AA58A、ADVANCED#E27A24、EXPERT#D6403A、MASTER#7526CF、ULTIMA#17171A(近黑)、WORLD’S END#7B61FF(浅紫)。ULTIMA 卡面黑底 + 红描边rgba(232,58,88,.55)(:114,267),WE 卡面浅紫#F3E8FE(:116,268),一眼可辨; - 定数 → Rating 箭头:卡片下部渲染”定数
→Rating”(:150),WE 处左侧换成kanji☆star标签(:118-122); - Rating 数字 8 方向描边:
cssTextOutline()(:77-85)沿 8 个方向生成text-shadow,颜色取自 rating 档位主题色——虹色档位下是六色循环描边; - 玩家名压缩:
fitPlayerName()(:323-340)先缩字号,仍放不下再用transform: scaleX(...)横向压扁(:338),保证长 ID 玩家不破版; - 测高协议:页面用
ResizeObserver + MutationObserver监听布局,measureAndFit()计算内容高度后postMessage({ type: 'best-image-height', width, height })回报原生(:359-409),图片全部加载完成(带 5s 兜底超时)再发best-image-ready(:411-433)。原生侧据此设置导出画布高度,captureRef截 PNG(ChunithmBestImageScreen.tsx:431-491)。
封面、角色与背景
封面加载走 load-chunithm-best-image-jackets.ts:关键点是 resolveChunithmBestImageJacketId()(:12-25)——WORLD’S END 谱面优先用 originId 找封面(WE 谱共用曲目 ID,但封面按谱面独立存在),其余难度用 songId。加载管线用 expo-image 先 prefetch('disk') 再读缓存文件转 base64 data URI(:31-55),并带 Map 级 Promise 去重与失败驱逐,逐张回传进度。角色图走 /character/list 公开接口(load-chunithm-best-image-collections.ts:23-34),支持”当前角色/指定角色/随机/关闭”四态;其中”随机”模式的落子有个细节——每个账号只在本次会话中随机一次并立刻写回偏好(ChunithmBestImageScreen.tsx:171-188 的 randomizedRef),切换账号或重进页面不会反复换人,避免”每次打开都换一张脸”的割裂感。背景默认浅色渐变,可选某首歌的封面做模糊背景(filter: blur(...) + scale(1.04),build-chunithm-best-image-html.ts:243)。整个成绩图复用 maimai 的通用 best-image 框架(WebView 预览、导出、权限、失败重试),中二板块只负责喂 HTML 与素材。
随机歌曲:筛选器与确定性抽样
domain/chunithm-random-charts.ts 的 filterChunithmRandomCharts()(:68-93)在曲库全量谱面上做六维过滤:难度、代目(版本)、定数区间、rank 区间,外加”是否有成绩”约束:
- 难度/版本交给
matchesChunithmChartFilter(domain/chunithm-filters.ts:45-56),其中定数过滤对 WE 谱面直接跳过(:52,difficulty === 5时传入undefined视为不设限)——WE 没有定数,参与定数过滤本身就是伪命题; - rank 过滤走
matchesChunithmRankRange(:58-70),在CHUNITHM_RANKS_ASC序列表(:9-12)上比索引。激活 rank 过滤后,无成绩的谱面直接出局(chunithm-random-charts.ts:83)——“抽 S+ 以上”的语义就是”从你打过的谱面里抽”; - 结果附带
record(若有成绩卡片),无成绩的抽中项渲染为”未游玩”卡片,可跳转歌曲详情(screens/ChunithmRandomChartsScreen.tsx:110-129)。
抽样本身复用了通用 pickRandomItems()(domain/random-charts.ts:91-108):FNV-1a 哈希种子 + Mulberry32 PRNG(:64-83),无放回逐个抽取,同一种子必然产出同一份结果——抽卡结果可复现可分享。抽卡按钮的种子是 Date.now() + Math.random()(ChunithmRandomChartsScreen.tsx:61),抽完立即展示,不需要重查曲库。
账号摘要水合与离线同步引导
启动水合
hydrateChunithmAccountSummaries 前面已述(见”会话恢复”一节),核心是”只读缓存、绝不发网络请求”:它被挂在启动流程里,若每个账号都去请求落雪,启动延迟会被账号数线性放大;改为读 SQLite 后,列表在首帧即可显示完整摘要,真正的成绩刷新留给 use-game-data 的按需查询。
代理 + 微信链接的离线同步
中二的成绩来自机台,落雪侧通过 HTTP 代理抓取机台流量。ChunithmSyncGuideSheet(components/chunithm/ChunithmSyncGuideSheet.tsx:12-16)复用 maimai 的通用引导 Sheet,注入中二专属参数:
- 代理地址
proxy.maimai.lxns.net:8080(components/LxnsSyncGuideSheet.tsx:9-11); - 离线同步链接
https://maimai.lxns.net/api/v0/chunithm/wechat/auth——该链接以微信授权为流程载体,引导文案特别强调”从聊天消息点开、不要粘贴到搜索框”; - 维护窗口拦截:
isChunithmMaintenanceWindow()(domain/chunithm-maintenance.ts:5-8)把 UTC 时间 +8 换算为北京时间,落在 04:00–07:00 之间时,复制代理/链接与同步按钮全部拦截并提示”每日 04:00–07:00 维护”(:1-2)。这个判断只依赖本地时钟、零网络,是纯前端约束,却在最大概率”同步失败”的时间段提前止损。
三步引导(配置代理 → 微信打开链接 → 关代理并点同步)在 UI 上保证每一步的复制键都带维护窗口守卫(LxnsSyncGuideSheet.tsx:128-151),避免用户折腾一圈后落个失败。
结语:中二板块的五个独特设计
回看整个板块,CHUNITHM 的”第三个游戏”定位让它既是复用的受益者,也是新模式的试验田:
- 三元组数据模型:Best30/New20/Selection 由服务端直接给,App 的”成绩图”把三元组重新组合成可配置的三分区布局——这是 maimai 的 Best15/New5 结构完全不同的展示维度;
- 六难度体系:BASIC→ULTIMA 五档正谱 + WORLD’S END 特例(无定数、无 Rating、kanji☆star 标签、originId 封面),每个环节都有独立的特判分支,且特判逻辑集中在展示层而非数据层;
- 7 位分制与徽章:SSS+ 阈值 1,009,000、AJC/CATASTROPHY 的彩虹荣誉,配合”只有 SSS+ 才流动”的光效纪律,把玩家最在意的成就密度浓缩进一张卡片;
- 主题复用:possession → DX Rating 伪映射,让中二账号列表和成绩图白嫖整套 maimai 渐变主题机(
dx-rating-theme.ts),是”领域映射”这一层抽象最漂亮的落地; - 绑定即预热:授权成功的一瞬完成全量拉取 + SQLite 落盘,账号列表立刻有 Rating——这个模式后来被验证为所有远程查分器接入的标准动作。
对音游查分客户端而言,最难的不是渲染一个成绩,而是”在不确定的网络与不确定的上游契约之间,保证用户永远看到可用、诚实、一致的数据”。中二板块用 zod 契约、三层缓存回退、isStale 标注、单飞 token 刷新回答了这个问题——每一个”为什么这样写”都能在这四层设计里找到答案。
