当前浏览器不支持 SVG backdrop-filter 位移折射;页面会自动使用较实的半透明材质。
尘言头像尘言 · CHNYNNYA

CHUNITHM 板块实现解析:落雪数据源与 Best30+New20+Selection 体系

解析 rRanker 中 CHUNITHM 板块:落雪 OAuth 绑定即预热、单飞 token 刷新、三层缓存回退、Rating 领域映射、成绩图 Selection 分区与随机谱面抽样的实现原理。

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 端实现如下:

  1. createPkcePair()lxns-oauth.ts:42-52)用 expo-crypto 取 32 字节随机数做 verifier,SHA-256 摘要后做 base64url 编码得到 challenge;
  2. beginLxnsAuthorize()lxns-oauth.ts:67-73)把 verifier 写入 SecureStore(WHEN_UNLOCKED_THIS_DEVICE_ONLY),再拼授权页 URL:response_type=codeclient_id、redirect URI urn:ietf:wg:oauth:2.0:oob、scope read_user_profile write_player read_playercode_challengecode_challenge_method=S256lxns-config.ts:2-6)。因为 redirect 是 out-of-band 形式,授权码由用户手动粘贴回 App;
  3. exchangeLxnsAuthorizationCode()lxns-oauth.ts:138-158)取回本地暂存的 verifier,向 token 端点 POST grant_type=authorization_code 换 token,成功后立即清除 verifier;
  4. 此后过期时走 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 字段,两种形态都兼容;TokenResponseSchemalxns-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

构造期强约束与会话单飞刷新

ChunithmScoreProviderproviders/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 的 LxnsScoreProviderlxns-score-provider.ts:56-69)是同一套代码形态,属于刻意保留的”双份复制”:两份 Provider 完全解耦,token 轮换通过回调外置,互不依赖。

三个接口与并发快照

中二数据模型与 maimai 最大的不同是”Best 分段”由服务端直接给出,App 侧零计算:

接口方法返回
GET /user/chunithm/playergetPlayer()chunithm-score-provider.ts:124-132玩家资料(name/rating/领域/class_emblem/角色等)
GET /user/chunithm/player/scoresgetScores():134-147全曲成绩数组
GET /user/chunithm/player/bestsgetBests():149-161{ bests, selections, new_bests } 三元组

三者用 Promise.all 并发拉取(chunithm-score-provider.ts:163-175),因为共用同一个 access token、彼此无依赖,串行反而白白多付两个 RTT。ChunithmBestsSchemadomain/chunithm-personal.ts:57-61)把 selectionsnew_bests 设为可选、缺省为空数组,兼容服务端早期版本;ChunithmScoreSchema:34-52)则是一份严格的契约:level_index 限制 0–5、clear 只接受 catastrophy/absolute/brave/hard/clear/failed 六值、full_comboalljusticecritical/alljustice/fullcombofull_chainfullchain/fullchain2rank 限 14 档。任何一条不符,整个请求都会被判为 upstream_schema 错误——宁可全挂也不静默降级,避免脏数据污染 UI 与缓存。

所有响应先过 LxnsEnvelopeSchemadomain/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),真正的 ChunithmScoreProvideruse-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)是”在线优先、缓存兜底”的标准三级回退,且每一级的语义都有注释级设计:

  1. 在线直读provider.getSnapshot() 成功后立即 saveResource 写回 v2 快照(:22-29)。注意:写缓存发生在返回数据之前,即”下次失败一定有缓存可用”;
  2. v2 回退:网络/超时类错误(authentication 除外,它直接上抛——凭据失效回退缓存只会误导用户)时读 v2 资源;
  3. v1 legacy 回退:v2 缺失时再读 v1 旧 schema,由于 v1 没有 bests 字段,回填 emptyChunithmBests():36-39)保证类型完整——历史数据降级时 Best 区显示为空,而不是崩溃。

回退命中后统一改写 sourcelabel: '落雪咖啡屋(缓存)'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

契约与版本反查

ChunithmCatalogProviderproviders/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):difficulty 0–5、level_value 有限非负、note_designerorigin_idkanjistar 皆可空——kanjistar 正是 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),共用 ResourceServiceservices/resource-service.ts:9-25)——与个人快照同款”在线写缓存、失败回退标 stale”策略。

useChunithmCataloghooks/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-testscoreDisplayrating.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.tscanReadChunithmScores():3-9)只对 chunithm-testlxns + lxns-oauth 放行;shouldPersistScoreSnapshot():11-16)明确排除 chunithm-test——示例账号的成绩是”现场生成”的,写缓存毫无意义。这套判定函数被 session-store 与各页面复用,保证”临时账号不读成绩、示例账号不落库、正式账号不丢缓存”三态互不越界。

示例账号:基于真实曲库的全曲满成绩

MaxedChunithmTestProviderproviders/maxed-chunithm-test-provider.ts)不是静态假数据,而是以真实曲库为输入、现场生成的满成绩世界:

  • 每个非 disabled 曲目的每个谱面:score = 1,010,000clear = 'catastrophy'full_combo = 'alljusticecritical'full_chain = 'fullchain2'rank = 'sssp':58-81);
  • WORLD’S END(difficulty === 5)刻意不生成 ratingover_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 = 99rating_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.tsRATING_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   SSS1,007,500   SS+1,005,000
SS1,000,000   S+990,000     S975,000
AAA950,000     AA925,000     A900,000
BBB800,000     BB700,000     B600,000
C500,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 置空,改用 worldsEndLabelformatChunithmWorldsEndLabel: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)。ChunithmScoreCardcomponents/chunithm/ChunithmScoreCard.tsx:30-42)定义了三段渐变色带 #73CFFF → #EFCB63 → #FF8EC8 循环;useFlowingProgresscomponents/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.bestSectionsuse-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:70SELECTION_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-imageprefetch('disk') 再读缓存文件转 base64 data URI(:31-55),并带 Map 级 Promise 去重与失败驱逐,逐张回传进度。角色图走 /character/list 公开接口(load-chunithm-best-image-collections.ts:23-34),支持”当前角色/指定角色/随机/关闭”四态;其中”随机”模式的落子有个细节——每个账号只在本次会话中随机一次并立刻写回偏好(ChunithmBestImageScreen.tsx:171-188randomizedRef),切换账号或重进页面不会反复换人,避免”每次打开都换一张脸”的割裂感。背景默认浅色渐变,可选某首歌的封面做模糊背景(filter: blur(...) + scale(1.04)build-chunithm-best-image-html.ts:243)。整个成绩图复用 maimai 的通用 best-image 框架(WebView 预览、导出、权限、失败重试),中二板块只负责喂 HTML 与素材。

随机歌曲:筛选器与确定性抽样

domain/chunithm-random-charts.tsfilterChunithmRandomCharts():68-93)在曲库全量谱面上做六维过滤:难度、代目(版本)、定数区间、rank 区间,外加”是否有成绩”约束:

  • 难度/版本交给 matchesChunithmChartFilterdomain/chunithm-filters.ts:45-56),其中定数过滤对 WE 谱面直接跳过(:52difficulty === 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 代理抓取机台流量。ChunithmSyncGuideSheetcomponents/chunithm/ChunithmSyncGuideSheet.tsx:12-16)复用 maimai 的通用引导 Sheet,注入中二专属参数:

  • 代理地址 proxy.maimai.lxns.net:8080components/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 的”第三个游戏”定位让它既是复用的受益者,也是新模式的试验田:

  1. 三元组数据模型:Best30/New20/Selection 由服务端直接给,App 的”成绩图”把三元组重新组合成可配置的三分区布局——这是 maimai 的 Best15/New5 结构完全不同的展示维度;
  2. 六难度体系:BASIC→ULTIMA 五档正谱 + WORLD’S END 特例(无定数、无 Rating、kanji☆star 标签、originId 封面),每个环节都有独立的特判分支,且特判逻辑集中在展示层而非数据层;
  3. 7 位分制与徽章:SSS+ 阈值 1,009,000、AJC/CATASTROPHY 的彩虹荣誉,配合”只有 SSS+ 才流动”的光效纪律,把玩家最在意的成就密度浓缩进一张卡片;
  4. 主题复用:possession → DX Rating 伪映射,让中二账号列表和成绩图白嫖整套 maimai 渐变主题机(dx-rating-theme.ts),是”领域映射”这一层抽象最漂亮的落地;
  5. 绑定即预热:授权成功的一瞬完成全量拉取 + SQLite 落盘,账号列表立刻有 Rating——这个模式后来被验证为所有远程查分器接入的标准动作。

对音游查分客户端而言,最难的不是渲染一个成绩,而是”在不确定的网络与不确定的上游契约之间,保证用户永远看到可用、诚实、一致的数据”。中二板块用 zod 契约、三层缓存回退、isStale 标注、单飞 token 刷新回答了这个问题——每一个”为什么这样写”都能在这四层设计里找到答案。

上一篇舞萌DX 板块实现解析:从 Score Hub 同步到 B50 成绩图下一篇rRanker 总体架构解析:一个多音游数据聚合应用的设计