From 7260b231003da53bf050468c9710fd41ffdc3cb7 Mon Sep 17 00:00:00 2001 From: Seiya KONNO Date: Sun, 11 Oct 2026 22:36:14 +0900 Subject: [PATCH 01/28] feat(skin-sdk): freeze the v2 skin interface Add every field, type, and opt-in flag the skin SDK v2 needs (#239-#260) in one go so skins can be written against a stable interface while the host fills the values in over the following commits. - Raise BE_MUSIC_SKIN_API_VERSION to 2 and keep accepting revision 1. - Move the play options shape into the SDK as BeMusicPlayOptions; PixiPlayOptions and its unions stay as aliases. - Add resolveBeMusicSkinSettings / validateBeMusicSkinSettings (hooked into validateBeMusicSkin) and binJudgeTimings, with tests and bench cases. - Compile the host through: bindings read the new draws* flags, frames carry empty measure lines, no BGA layers, default settings, and empty song images / records; select wires setPlayOption, cyclePlayOption, and toggleReadme to the existing play option and readme code. - Document the new fields, opt-ins, decide screen, skin settings, song images, and the timing sign convention in English and Japanese. --- .changeset/player-web-skin-api-v2.md | 5 + .changeset/skin-sdk-v2-interface.md | 11 + docs/be-music-skin.ja.md | 133 +++++++-- docs/be-music-skin.md | 133 +++++++-- packages/player-web/src/scene/core/select.ts | 243 ++++++---------- .../player-web/src/skin/be-music/binding.ts | 58 +++- .../src/skin/be-music/registry.test.ts | 22 +- packages/skin-sdk/scripts/exports-cases.ts | 35 +++ packages/skin-sdk/src/define.test.ts | 41 ++- packages/skin-sdk/src/define.ts | 20 +- packages/skin-sdk/src/index.ts | 7 +- packages/skin-sdk/src/judge-timing.test.ts | 49 ++++ packages/skin-sdk/src/judge-timing.ts | 53 ++++ packages/skin-sdk/src/play-options.ts | 155 ++++++++++ packages/skin-sdk/src/result-data.ts | 17 +- packages/skin-sdk/src/settings.test.ts | 121 ++++++++ packages/skin-sdk/src/settings.ts | 149 ++++++++++ packages/skin-sdk/src/song.ts | 31 ++ packages/skin-sdk/src/types.ts | 273 +++++++++++++++++- 19 files changed, 1333 insertions(+), 223 deletions(-) create mode 100644 .changeset/player-web-skin-api-v2.md create mode 100644 .changeset/skin-sdk-v2-interface.md create mode 100644 packages/skin-sdk/src/judge-timing.test.ts create mode 100644 packages/skin-sdk/src/judge-timing.ts create mode 100644 packages/skin-sdk/src/play-options.ts create mode 100644 packages/skin-sdk/src/settings.test.ts create mode 100644 packages/skin-sdk/src/settings.ts diff --git a/.changeset/player-web-skin-api-v2.md b/.changeset/player-web-skin-api-v2.md new file mode 100644 index 000000000..b03cc922e --- /dev/null +++ b/.changeset/player-web-skin-api-v2.md @@ -0,0 +1,5 @@ +--- +'@be-music/player-web': patch +--- + +The be-music skin registry accepts skins written for skin API revision 2 as well as revision 1. Skins can change play options and open the readme from the select screen through the new `setPlayOption`, `cyclePlayOption`, and `toggleReadme` actions, and every frame carries the skin's settings resolved to their defaults; the other revision 2 fields are left empty or `undefined` for now. `PixiPlayOptions` and its value unions are now aliases of the SDK's `BeMusicPlayOptions` types. diff --git a/.changeset/skin-sdk-v2-interface.md b/.changeset/skin-sdk-v2-interface.md new file mode 100644 index 000000000..4f5199b36 --- /dev/null +++ b/.changeset/skin-sdk-v2-interface.md @@ -0,0 +1,11 @@ +--- +'@be-music/skin-sdk': minor +--- + +Freeze the skin API at revision 2: `BE_MUSIC_SKIN_API_VERSION` is now 2, and revision 1 skins are still accepted because revision 2 only adds to what skins receive. + +- Gameplay runtime: `song`, `lastJudgeDeltaMs` / `judgeTimings` (positive = late), `judgeWindows`, `laneCovers`, `visibleMs`, `greenNumber`, `minBpm` / `maxBpm` / `mainBpm`, `songLengthMs` / `songElapsedMs`, `failedAtMs` / `allNotesDoneAtMs` / `outroAtMs`, `gaugeRiseAtMs` / `gaugeMaxAtMs`, and `scoreHistory` / `gaugeHistory`. +- Gameplay frame: `measureLines` and `bga` behind the new opt-in `drawsMeasureLines` / `drawsBga` flags, `drawsLaneCover`, per-lane press / release / hold times, long-note `mode` / `state`, `autoplay` on notes, and the `judge` behind each bomb. +- Select frame: `playOptions` / `playOptionChoices` with `setPlayOption`, `cyclePlayOption`, and `toggleReadme` actions, `pointer`, `songRecord(song)`, and `hit` returning whether the pointer is over the rect. `BeMusicPlayOptions` and its value unions now live in the SDK. +- Result data: `lamp`, `gaugeType`, `judgeRuleset`, `fast` / `slow`, and `judgeTimings`, with `binJudgeTimings` for timing histograms. +- An optional decide screen (`skin.decide`), skin settings (`skin.settings`, resolved with `resolveBeMusicSkinSettings` and checked by `validateBeMusicSkin`), and `songImage(song, kind)` on every frame. diff --git a/docs/be-music-skin.ja.md b/docs/be-music-skin.ja.md index 4896ad8fd..b1699f284 100644 --- a/docs/be-music-skin.ja.md +++ b/docs/be-music-skin.ja.md @@ -134,21 +134,23 @@ export default defineBeMusicSkin({ すべてのスキンが以下を宣言します。`defineBeMusicSkin` は内容を検査し、不正なら例外を投げます。壊れたスキンは定義した場所で失敗します。 -| フィールド | 必須 | ルール | -| ---------------------------------------- | ---- | -------------------------------------------------------------------------------------------------------------- | -| `apiVersion` | ○ | ビルドに使った SDK の `BE_MUSIC_SKIN_API_VERSION`。プレイヤーは未対応の API リビジョン向けスキンを拒否します。 | -| `id` | ○ | 小文字英字・数字・ハイフン(`my-skin`)。ホストが保存するので、リリース間で変えないでください。 | -| `label` | ○ | ピッカーに表示する名前。 | -| `version` | ○ | スキン自体のリリース。セマンティックバージョン(`1.2.0`)。 | -| `author` | ○ | `{ name, url? }`。`url` は http(s)。 | -| `description` | — | ピッカー向けの 1〜2 文。 | -| `homepage` | — | プロジェクトページやリポジトリの http(s) URL。 | -| `license` | — | スキンのコードと素材の SPDX 識別子(`MIT`)。 | -| `fontLoads` | ○ | 描画に使う CSS フォント指定(`'400 24px "Anton"'`)。空でも可。 | -| `context` | ○ | `'2d'`、`'webgl'`、`'webgl2'`、`'webgpu'` のいずれか。 | -| `contextAttributes`、`setup`、`teardown` | — | [サーフェス](#サーフェス)と[フレームワークを使う](#フレームワークを使う)を参照。 | -| `stage` | — | デザインキャンバス。既定は `wideStage`。[ステージとレイアウト](#ステージとレイアウト)を参照。 | -| `gameplay`、`select`、`result` | ○ | 各画面の `draw` 関数。[画面](#画面)を参照。 | +| フィールド | 必須 | ルール | +| ---------------------------------------- | ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `apiVersion` | ○ | ビルドに使った SDK の `BE_MUSIC_SKIN_API_VERSION`(現在は 2)。プレイヤーは未対応の API リビジョン向けスキンを拒否します。[バージョニング](#バージョニング)を参照。 | +| `id` | ○ | 小文字英字・数字・ハイフン(`my-skin`)。ホストが保存するので、リリース間で変えないでください。 | +| `label` | ○ | ピッカーに表示する名前。 | +| `version` | ○ | スキン自体のリリース。セマンティックバージョン(`1.2.0`)。 | +| `author` | ○ | `{ name, url? }`。`url` は http(s)。 | +| `description` | — | ピッカー向けの 1〜2 文。 | +| `homepage` | — | プロジェクトページやリポジトリの http(s) URL。 | +| `license` | — | スキンのコードと素材の SPDX 識別子(`MIT`)。 | +| `fontLoads` | ○ | 描画に使う CSS フォント指定(`'400 24px "Anton"'`)。空でも可。 | +| `context` | ○ | `'2d'`、`'webgl'`、`'webgl2'`、`'webgpu'` のいずれか。 | +| `contextAttributes`、`setup`、`teardown` | — | [サーフェス](#サーフェス)と[フレームワークを使う](#フレームワークを使う)を参照。 | +| `stage` | — | デザインキャンバス。既定は `wideStage`。[ステージとレイアウト](#ステージとレイアウト)を参照。 | +| `gameplay`、`select`、`result` | ○ | 各画面の `draw` 関数。[画面](#画面)を参照。 | +| `decide` | — | 選曲とプレイの間の決定画面。[決定画面](#決定画面)を参照。 | +| `settings` | — | プレイヤーが調整できるスキンのオプション。[スキン設定](#スキン設定)を参照。 | `validateBeMusicSkin(skin)` は、例外を投げずに同じ問題をメッセージの配列で返します。 @@ -172,17 +174,31 @@ export default defineBeMusicSkin({ `gameplay.draw(surface, frame)` は `BeMusicGameplayFrame` を受け取ります。 - `layout`: [ステージとレイアウト](#ステージとレイアウト)を参照。 -- `lanes`: 各レーンの矩形(`x`、`w`、`top`、判定ライン位置の `bottom`)、`kind`、`beam`(キービーム強度。押下中は 1、離すと減衰)。 -- `notes`: タップノーツ `{ kind, x, w, y }`。`y` はノーツの下端です。 -- `longNotes`: ロングノーツ `{ kind, x, w, top, bottom }`。`top` が終端、`bottom` が始端で、押下中は判定ラインに張り付きます。 +- `lanes`: 各レーンの矩形(`x`、`w`、`top`、判定ライン位置の `bottom`)、`kind`、`beam`(キービーム強度。押下中は 1、離すと減衰)。`pressedAtMs` / `releasedAtMs` はそのレーンを最後に押した・離したプレイクロック時刻、`holding` と `holdStartedAtMs` はそのレーンでロングノーツを押し続けているかと、その開始時刻です。 +- `notes`: タップノーツ `{ kind, x, w, y }`。`y` はノーツの下端です。オートプレイやオートスクラッチが叩くノーツには `autoplay` が付きます。 +- `longNotes`: ロングノーツ `{ kind, x, w, top, bottom }`。`top` が終端、`bottom` が始端で、押下中は判定ラインに張り付きます。`mode`(`'ln'` / `'cn'` / `'hcn'`)は判定方式、`state`(`'pending'` / `'held'` / `'dropped'` / `'done'`)は進行状態、`autoplay` はオートプレイが処理するかを表します。 - `mines`: 地雷ノーツ `{ kind, x, w, y }`。`y` は下端です。判定ラインを通過する瞬間にそのレーンを押すと BAD になります。スキンが `gameplay.drawsMines`(後述)を設定したときだけ中身が入ります。 -- `bombs`: 生きているヒットエフェクト。`elapsedMs` と固定の `seed` を持ちます。 +- `bombs`: 生きているヒットエフェクト。`elapsedMs`、固定の `seed`、発生させた判定 `judge` を持ちます。 +- `measureLines`: 小節線 `{ side, x, w, y }`。横切るレーンランごとに 1 本です。スキンが `gameplay.drawsMeasureLines` を設定したときだけ中身が入ります。 +- `bga`: 合成する BGA のレイヤー(`{ base?, layer?, poor?, poorActive }`)。スキンが `gameplay.drawsBga` を設定したとき以外は `undefined` です。 - `runtime`: HUD の値(下記)。 - `beatPhase`、`nowMs`、`effects`、`audio`。 +- `settings` と `songImage`: [スキン設定](#スキン設定)と[曲の画像](#曲の画像)を参照。 `gameplay.bombDurationMs`(既定 300)でヒットエフェクトの寿命を指定します。 -`gameplay.drawsMines: true` を設定すると、地雷ノーツを `frame.mines` から自分で描けます。設定しない場合はプレイヤーが黄色の注意縞が入った赤いバーとしてキャンバスの上に描くので、地雷を扱わないスキンでも地雷は表示されます。 +#### 自分で描く + +既定では、いくつかの要素をプレイヤーがキャンバスの上(または後ろ)に描くので、それらを扱わないスキンでも表示されます。自分で描きたい場合は `gameplay` に対応するフラグを設定します。 + +| フラグ | 描画に使うデータ | 設定しない場合 | +| ------------------- | -------------------- | ---------------------------------------------------------------------- | +| `drawsMines` | `frame.mines` | プレイヤーが黄色の注意縞入りの赤いバーをキャンバスの上に描きます。 | +| `drawsLaneCover` | `runtime.laneCovers` | プレイヤーが SUDDEN+ / HIDDEN+ のカバーをキャンバスの上に描きます。 | +| `drawsMeasureLines` | `frame.measureLines` | プレイヤーが自前の小節線を描きます。 | +| `drawsBga` | `frame.bga` | プレイヤーが BGA をキャンバスの後ろ、`layout.bga` の位置に表示します。 | + +どのフラグも既定は `false` で、解禁されるフレームのフィールドは設定するまで空(または `undefined`)のままです。既存のスキンの見た目は変わりません。`drawsBga` では、`layer` の画像は BMS のレイヤーの慣習どおり純粋な黒が透明になった状態で渡され、`base` と `poor` は不透明です。通常のアルファブレンドで描き、`poorActive` の間は他の代わりに `poor` を表示してください。 `runtime`(`BeMusicGameplayRuntime`)の値: @@ -196,6 +212,14 @@ export default defineBeMusicSkin({ - **プレイ状態:** `autoplay`、`hasBga`、`loading`(音声デコードや BGA 変換の間は NOW LOADING を表示)、`progressRatio`、`chartMs`、`beatPhase`、`nowMs` - **イベント時刻:** `judgeAtMs`、`impulseAtMs`、`impulseKind`(直近の判定とキー入力。パンチやインパルスの演出用) - **快適性とサウンド:** `effects`、`audio` +- **譜面:** `song`(プレイ中の `BrowserSongEntry`。レベル、難易度、`#RANK`、`#TOTAL` は `song.chart.metadata` にあります)、`minBpm`、`maxBpm`、`mainBpm` +- **タイミング:** `lastJudgeDeltaMs` と `judgeTimings`(タイミング付きの直近 64 判定、古い順)。[判定タイミング](#判定タイミング)を参照。`judgeWindows` はルールセットの判定幅をノーツの種類(`note`、`scratch`、`longNoteEnd`)ごとに ms で表します。プレイ開始時に `#RANK` から決まり、途中の `#EXRANK` の変更は反映されません。 +- **レーンカバー:** `laneCovers`(サイドごとの `sudden`、`hidden`、`lift`。レーンの高さに対する割合)、`visibleMs`(ノーツが画面に見えている時間)、`greenNumber`(`visibleMs × 0.6`) +- **曲の時計:** `songLengthMs` と `songElapsedMs`。オーディオの時計で、曲の開始とともに 0 から進み、ポーズ中は止まります。残り時間は `songLengthMs - songElapsedMs` です。`chartMs` は譜面の最初の拍から測り、カウントイン中は負になる点が異なります。 +- **1 プレイに 1 度の瞬間:** `failedAtMs`(プレイ中にゲージが尽きた)、`allNotesDoneAtMs`(最後のノーツが判定された)、`outroAtMs`(譜面が終わった)、`gaugeRiseAtMs`(ゲージが初めて上がった)、`gaugeMaxAtMs`(初めて最大に達した)。どれもその瞬間に固定されるプレイクロック時刻で、それまでは `undefined` です。 +- **推移:** `scoreHistory` と `gaugeHistory`。リザルト画面が受け取るのと同じサンプルです。ホストが更新中の配列そのものなので、読むだけにして変更しないでください。 + +runtime のフィールドはすべて省略可能です。ホストが提供できない値は `undefined` になるので、適切な代替表示を用意してください。 ### 選曲 @@ -204,22 +228,76 @@ export default defineBeMusicSkin({ - **`select.draw(surface, frame)`** は `BeMusicSelectFrame` を受け取ります。 - `entries`、`selectedIndex`、`firstVisibleIndex`、`visibleRows`、`focusedSong` - `folderLabel`、`searchQuery`、`totalCharts` - - `actions`(`play`、`autoPlay`、`activateSearch`) + - `actions`: `play`、`autoPlay`、`activateSearch`、`setPlayOption(key, value)`、`cyclePlayOption(key, step?)`(step は 1 か -1、端で折り返し)、`toggleReadme()`(フォーカス中の曲の `.txt` を開閉) + - `playOptions`(`BeMusicPlayOptions`: ハイスピード、ランダム、ゲージ、HIDDEN / SUDDEN、レーンカバー、BGA、リストのフィルタなど)と `playOptionChoices`(列挙型・オンオフのオプションが取りうる値)。選択肢にない値は無視されます。 + - `songRecord(song)`: その譜面でのプレイヤーのベスト記録(`{ lamp, exScore?, maxCombo?, missCount?, playCount? }`)。ホストが記録を持たなければ `undefined` + - `pointer`: デザインピクセルでのポインタ位置。なければ `undefined` + - `settings` と `songImage`: [スキン設定](#スキン設定)と[曲の画像](#曲の画像)を参照 - `revision`: 選曲の状態(カーソル、フォルダ、検索、リスト内容)が変わるたびに増えます。保持型のシーングラフは、これが変わったときだけ作り直せば済みます。 - 登場やフォーカス移動のトランジション用の `sceneStartedAt`、`cursorChangedAt` - `nowMs`、`effects`、`launchAt`、`audio` - - `hit(x, y, w, h, action, cursor?)`: 矩形をそのフレームの間だけクリック可能にします。クリック領域は描画のたびに宣言してください。 + - `hit(x, y, w, h, action, cursor?)`: 矩形をそのフレームの間だけクリック可能にし、ポインタが矩形の上にあるかを返します(ホバー表示用)。クリック領域は描画のたびに宣言してください。 ### リザルト `result.draw(surface, frame)` は `BeMusicResultFrame` を受け取ります。 -- `result`(`BeMusicResultData`): スコア、判定数、ゲージとスコアの推移、プレイログ。 +- `result`(`BeMusicResultData`): スコア、判定数、ゲージとスコアの推移、プレイログ。ホストが提供する場合は、`lamp`(クリアランプ: `FAILED`、クリアしたゲージ、`FULL_COMBO`、`PERFECT`、`MAX`)、`gaugeType`、`judgeRuleset`、`fast` / `slow`、`judgeTimings`(プレイ中のタイミング付き判定すべて)も入ります。 - `rankLabel`(IIDX の DJ LEVEL)、`ratePercent`。 - `elapsedMs`: シーン開始からの経過。登場演出をスキップした後は `Infinity`。 - `nowMs`、`effects`。 +- `settings` と `songImage`: [スキン設定](#スキン設定)と[曲の画像](#曲の画像)を参照。 + +`resolveResultLamp` と `resolveResultTrackRows` が、組み込みスキンの表示するクリアランプとトラック行を返します。`binJudgeTimings(result.judgeTimings, binMs, rangeMs?)` でタイミングをヒストグラムにできます。 + +### 決定画面 + +スキンは、譜面を選んでからプレイが始まるまでの決定画面を追加できます。ホストは `skin.decide` があるときだけこれを表示し、なければそのままプレイに進みます。 + +- **`decide.durationMs`:** 画面を表示する時間(既定 2000)。プレイヤーは Enter で先に進み、Escape で選曲に戻れます。 +- **`decide.draw(surface, frame)`** は `BeMusicDecideFrame` を受け取ります: `song`、`playOptions`、画面表示からの `elapsedMs`、`nowMs`、`effects`、`audio`、`settings`、`songImage`。 + +## 判定タイミング + +タイミングのずれは符号付きのミリ秒で、エンジンの慣習に従います。**正は遅い(SLOW)、負は早い(FAST)**、0 はジャストです。 + +- `runtime.lastJudgeDeltaMs` が直近のずれ、`runtime.judgeTimings` が直近 64 件の `{ atMs, deltaMs, judge, channel? }`(古い順)です。 +- `result.judgeTimings` にはプレイ中のタイミング付き判定がすべて入ります。 +- タイミングを持つのは押下を伴う判定だけです。オートプレイの判定、地雷の BAD、見逃し(押下のない POOR)は含まれません。 +- `binJudgeTimings(timings, binMs = 1, rangeMs?)` は `{ binMs, originMs, counts }` を返します。ビンの境界は `binMs` の倍数に揃います。`rangeMs` を渡すとタイミングにかかわらず `-rangeMs..rangeMs` を覆うので、別のプレイのヒストグラムと並べられます。 + +## スキン設定 + +スキンは、配色テーマ、判定文字の位置、演出のオンオフなど、プレイヤーが調整できるオプションを宣言できます。 + +```ts +settings: [ + { + kind: 'choice', + id: 'theme', + label: 'Theme', + options: [ + { value: 'neon', label: 'Neon' }, + { value: 'mono', label: 'Mono' }, + ], + default: 'neon', + }, + { kind: 'number', id: 'judgeOffset', label: 'Judge offset', min: -40, max: 40, step: 2, default: 0, unit: 'px' }, + { kind: 'toggle', id: 'glow', label: 'Glow', default: true }, +], +``` + +- **種類:** `choice`(`options` のいずれか)、`number`(`min..max`。省略可能な `step` と、`'px'`・`'ms'`・`'%'` の `unit`)、`toggle`。 +- **検査:** `validateBeMusicSkin` がスキーマを検査します。id は重複不可、ラベルは空不可で、`default` は選択肢のいずれか、または範囲内でなければなりません。 +- **値:** すべてのフレームが `settings`(id をキーにしたプレイヤーの値)を持ちます。ホストは値をスキンごとに保存し、`resolveBeMusicSkinSettings(skin, stored)` で解決します。欠けている値、古い値、型の違う値は既定値に戻り、数値は範囲内に収められ、スキンが宣言しなくなった id は捨てられます。宣言した id はすべて正しい型で揃っていると考えて構いません。 + +## 曲の画像 + +`songImage(song, kind)` はすべてのフレーム(選曲、決定、ゲームプレイ、リザルト)にあります。曲の `'stageFile'`、`'banner'`、`'backBmp'` を `ImageBitmap` で返すので、そのまま `drawImage` やテクスチャのアップロードに使えます。 -`resolveResultLamp` と `resolveResultTrackRows` が、組み込みスキンの表示するクリアランプとトラック行を返します。 +- 画像は遅延読み込みです。デコード中の最初の呼び出しは `undefined` を返し、準備ができると画面が描き直されます(選曲では `revision` が進みます)。 +- 曲にその画像がない場合や、ホストが画像を提供しない場合も `undefined` を返すので、必ず代替表示を用意してください。 +- ビットマップを close したり transfer したりしないでください。ホストがキャッシュしています。 ## SDK ヘルパー @@ -232,6 +310,8 @@ export default defineBeMusicSkin({ | ロード中 | `LOADING_WORD`、`loadingDots(nowMs)` | | サウンド | `audioDrive`、`bandAt`、`bandLevel`。[音楽に反応させる](#音楽に反応させる)を参照 | | リザルト | `resolveResultLamp`、`resolveResultTrackRows` | +| タイミング | `binJudgeTimings`。[判定タイミング](#判定タイミング)を参照 | +| 設定 | `resolveBeMusicSkinSettings`、`validateBeMusicSkinSettings`。[スキン設定](#スキン設定)を参照 | | 曲情報 | `resolveSongRowFacts`、`resolveSongStats`、`resolveSongTags`、`formatSongLength`、`formatBpmRange`、`formatPlayVariantLabel` | | テキスト | `DEFAULT_TEXT_FONT`、`layoutTabularRun`(等幅数字の配置) | | モーション | `easeOutCubic`、`easeOutBack`、`stageProgress`、`rollUpValue`、`hash01`(決定的なゆらぎ) | @@ -376,6 +456,7 @@ export default defineBeMusicSkin({ - **`version` を上げるとき:** スキンをリリースするたびに上げます。プレイヤーから見たセマンティックバージョニングに従い、見た目の追加はマイナー、修正はパッチにします。 - **`apiVersion`:** ビルドに使った SDK の定数にします。スキン API に互換性のない変更が入ると、プレイヤーは `BE_MUSIC_SKIN_API_VERSION` を上げます。古いリビジョン向けのスキンは、新しい SDK で再ビルドするまで拒否されます。 +- **リビジョン 2:** 現在のリビジョンです。スキンが受け取るものを増やしただけ(フレームと runtime の新しいフィールド、オプトインの `draws*` フラグ、決定画面、スキン設定、曲の画像)なので、リビジョン 1 のスキンはそのまま動き、プレイヤーは 1 と 2 の両方を受け付けます。新しい SDK で再ビルドして `apiVersion: 2` にする必要があるのは、新しいフィールドを使う場合だけです。なお `frame.hit` は真偽値を返すようになりましたが、戻り値を使わないスキンには影響しません。 - **`id` を固定する:** ホストは選択中のスキンを `id` で保存します。 ## チェックリスト @@ -386,6 +467,10 @@ export default defineBeMusicSkin({ - [ ] `runtime.hasBga` のとき BGA の矩形を透明のまま残している。 - [ ] `runtime.loading` の間は NOW LOADING を表示している。 - [ ] 選曲の行が `select.layout` と揃い、ボタンは描画のたびに `frame.hit` で宣言している。 +- [ ] 設定した `draws*` フラグに対応する要素を自分で描いている。 +- [ ] runtime の省略可能なフィールドや `songImage` が `undefined` のときの代替表示がある。 +- [ ] タイミング表示は正の `deltaMs` を遅い(SLOW)、負を早い(FAST)として扱っている。 +- [ ] `settings` の id が重複せず、既定値がすべて有効。 - [ ] 音への反応は `audioDrive` を通し、`effects` を尊重している。 - [ ] 使うフェイスをすべて `fontLoads` に挙げている。 - [ ] ノーツの多い譜面・高密度でも `draw` が 1 フレームの半分を十分下回り、毎フレームの確保やピクセルの読み戻しがない。 diff --git a/docs/be-music-skin.md b/docs/be-music-skin.md index f58e64833..42fc63fc8 100644 --- a/docs/be-music-skin.md +++ b/docs/be-music-skin.md @@ -134,21 +134,23 @@ The built-in Pixi skins do exactly this through `skins/pixi-kit/define-pixi-skin Every skin declares these fields. `defineBeMusicSkin` checks them and throws when they are malformed, so a broken skin fails where it is defined. -| Field | Required | Rule | -| ---------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------- | -| `apiVersion` | yes | `BE_MUSIC_SKIN_API_VERSION` from the SDK you build against. The player refuses skins written for an API revision it does not support. | -| `id` | yes | Lowercase letters, digits, and hyphens (`my-skin`). Hosts persist it, so keep it stable across releases. | -| `label` | yes | Name shown in pickers. | -| `version` | yes | Your skin's own release as a semantic version (`1.2.0`). | -| `author` | yes | `{ name, url? }`. `url` must be http(s). | -| `description` | no | One or two sentences for pickers. | -| `homepage` | no | http(s) URL of the project page or repository. | -| `license` | no | SPDX identifier of the skin's code and assets (`MIT`). | -| `fontLoads` | yes | CSS font shorthands the skin draws with (`'400 24px "Anton"'`). It may be empty. | -| `context` | yes | `'2d'`, `'webgl'`, `'webgl2'`, or `'webgpu'`. | -| `contextAttributes`, `setup`, `teardown` | no | See [Surfaces](#surfaces) and [Using a framework](#using-a-framework). | -| `stage` | no | Design canvas; defaults to `wideStage`. See [Stage and layout](#stage-and-layout). | -| `gameplay`, `select`, `result` | yes | Each screen's `draw` function; see [Screens](#screens). | +| Field | Required | Rule | +| ---------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `apiVersion` | yes | `BE_MUSIC_SKIN_API_VERSION` from the SDK you build against (currently 2). The player refuses skins written for an API revision it does not support. See [Versioning](#versioning). | +| `id` | yes | Lowercase letters, digits, and hyphens (`my-skin`). Hosts persist it, so keep it stable across releases. | +| `label` | yes | Name shown in pickers. | +| `version` | yes | Your skin's own release as a semantic version (`1.2.0`). | +| `author` | yes | `{ name, url? }`. `url` must be http(s). | +| `description` | no | One or two sentences for pickers. | +| `homepage` | no | http(s) URL of the project page or repository. | +| `license` | no | SPDX identifier of the skin's code and assets (`MIT`). | +| `fontLoads` | yes | CSS font shorthands the skin draws with (`'400 24px "Anton"'`). It may be empty. | +| `context` | yes | `'2d'`, `'webgl'`, `'webgl2'`, or `'webgpu'`. | +| `contextAttributes`, `setup`, `teardown` | no | See [Surfaces](#surfaces) and [Using a framework](#using-a-framework). | +| `stage` | no | Design canvas; defaults to `wideStage`. See [Stage and layout](#stage-and-layout). | +| `gameplay`, `select`, `result` | yes | Each screen's `draw` function; see [Screens](#screens). | +| `decide` | no | The decide screen between select and play; see [Decide](#decide). | +| `settings` | no | Options the player can tune for the skin; see [Skin settings](#skin-settings). | `validateBeMusicSkin(skin)` returns the same problems as a list of messages without throwing. @@ -172,17 +174,31 @@ Every skin declares these fields. `defineBeMusicSkin` checks them and throws whe `gameplay.draw(surface, frame)` receives a `BeMusicGameplayFrame`: - `layout`: see [Stage and layout](#stage-and-layout). -- `lanes`: each lane's rect (`x`, `w`, `top`, `bottom` at the judgement line), `kind`, and `beam`, the key-beam intensity (1 while held, then decaying). -- `notes`: tap notes as `{ kind, x, w, y }`, where `y` is the note's bottom edge. -- `longNotes`: long notes as `{ kind, x, w, top, bottom }`. `top` is the tail; `bottom` is the head, clamped to the judgement line while held. +- `lanes`: each lane's rect (`x`, `w`, `top`, `bottom` at the judgement line), `kind`, and `beam`, the key-beam intensity (1 while held, then decaying). `pressedAtMs` / `releasedAtMs` are the play-clock times of the lane's last press and release; `holding` and `holdStartedAtMs` tell whether a long note in the lane is being held, and since when. +- `notes`: tap notes as `{ kind, x, w, y }`, where `y` is the note's bottom edge. `autoplay` is set on notes autoplay or auto-scratch will hit. +- `longNotes`: long notes as `{ kind, x, w, top, bottom }`. `top` is the tail; `bottom` is the head, clamped to the judgement line while held. `mode` (`'ln'` / `'cn'` / `'hcn'`) says how the note judges, `state` (`'pending'` / `'held'` / `'dropped'` / `'done'`) where it is in its life, and `autoplay` whether autoplay plays it. - `mines`: mine notes as `{ kind, x, w, y }`, where `y` is the bottom edge. Pressing the lane as one passes the judgement line costs a BAD. Empty unless the skin sets `gameplay.drawsMines` (below). -- `bombs`: live hit effects with `elapsedMs` and a stable `seed`. +- `bombs`: live hit effects with `elapsedMs`, a stable `seed`, and the `judge` that set them off. +- `measureLines`: measure lines as `{ side, x, w, y }`, one per lane run they cross. Empty unless the skin sets `gameplay.drawsMeasureLines`. +- `bga`: the BGA layers to composite (`{ base?, layer?, poor?, poorActive }`). `undefined` unless the skin sets `gameplay.drawsBga`. - `runtime`: the HUD values (below). - `beatPhase`, `nowMs`, `effects`, and `audio`. +- `settings` and `songImage`: see [Skin settings](#skin-settings) and [Song images](#song-images). `gameplay.bombDurationMs` (default 300) sets how long a hit effect lives. -Set `gameplay.drawsMines: true` to draw mine notes yourself from `frame.mines`. Without it the player draws them over your canvas as red bars with yellow caution stripes, so a skin that ignores mines still shows them. +#### Drawing it yourself + +By default the player draws a few things over (or behind) your canvas, so a skin that ignores them still shows them. Set the matching flag on `gameplay` to draw them yourself instead: + +| Flag | You draw from | Without it | +| ------------------- | -------------------- | ----------------------------------------------------------------------- | +| `drawsMines` | `frame.mines` | The player draws red bars with yellow caution stripes over your canvas. | +| `drawsLaneCover` | `runtime.laneCovers` | The player draws its SUDDEN+ / HIDDEN+ covers over your canvas. | +| `drawsMeasureLines` | `frame.measureLines` | The player draws its own measure lines. | +| `drawsBga` | `frame.bga` | The player shows the BGA behind your canvas, in `layout.bga`. | + +Every flag defaults to `false`, and the frame field it unlocks stays empty (or `undefined`) until you set it, so existing skins keep their look. With `drawsBga`, the `layer` image arrives with pure black already turned transparent (the BMS layer convention) while `base` and `poor` are opaque: draw them with plain alpha blending, and show `poor` instead of the others while `poorActive`. `runtime` (`BeMusicGameplayRuntime`) carries: @@ -196,6 +212,14 @@ Set `gameplay.drawsMines: true` to draw mine notes yourself from `frame.mines`. - **Play state:** `autoplay`, `hasBga`, `loading` (show NOW LOADING while audio decodes or a BGA transcodes), `progressRatio`, `chartMs`, `beatPhase`, `nowMs` - **Event timestamps:** `judgeAtMs`, `impulseAtMs`, `impulseKind` (the last judgement and key press, for punch and impulse effects) - **Comfort and sound:** `effects`, `audio` +- **Chart:** `song` (the `BrowserSongEntry` being played; level, difficulty, `#RANK`, and `#TOTAL` are in `song.chart.metadata`), `minBpm`, `maxBpm`, `mainBpm` +- **Timing:** `lastJudgeDeltaMs` and `judgeTimings` (the last 64 timed judgements, oldest first); see [Judgement timing](#judgement-timing). `judgeWindows` gives the ruleset's windows in ms per note class (`note`, `scratch`, `longNoteEnd`), resolved from `#RANK` when the play starts (a later `#EXRANK` change is not reflected). +- **Lane cover:** `laneCovers` (per side: `sudden`, `hidden`, `lift` as fractions of the lane height), `visibleMs` (how long a note is on screen), and `greenNumber` (`visibleMs × 0.6`) +- **Song clock:** `songLengthMs` and `songElapsedMs`, on the audio clock: it starts at 0 with the song and stops while paused, so the remaining time is `songLengthMs - songElapsedMs`. `chartMs` differs: it is measured from the chart's first beat and runs negative through the count-in. +- **Once-per-play moments:** `failedAtMs` (the gauge failed mid-play), `allNotesDoneAtMs` (the last playable note was judged), `outroAtMs` (the chart ended), `gaugeRiseAtMs` (the gauge first rose), and `gaugeMaxAtMs` (it first reached its maximum). Each is a play-clock time latched when the moment happens, `undefined` before. +- **Histories:** `scoreHistory` and `gaugeHistory`, the samples the result screen receives. They are the host's live arrays: read them, never mutate them. + +Every runtime field is optional. A host that can't provide one leaves it `undefined`, so draw a sensible fallback. ### Select @@ -204,22 +228,76 @@ Set `gameplay.drawsMines: true` to draw mine notes yourself from `frame.mines`. - **`select.draw(surface, frame)`** receives a `BeMusicSelectFrame`: - `entries`, `selectedIndex`, `firstVisibleIndex`, `visibleRows`, `focusedSong` - `folderLabel`, `searchQuery`, `totalCharts` - - `actions` (`play`, `autoPlay`, `activateSearch`) + - `actions`: `play`, `autoPlay`, `activateSearch`, `setPlayOption(key, value)`, `cyclePlayOption(key, step?)` (step 1 or -1, wrapping), and `toggleReadme()` (opens or closes the focused song's `.txt`) + - `playOptions` (`BeMusicPlayOptions`: hi-speed, random, gauge, HIDDEN / SUDDEN, lane cover, BGA, list filters, …) and `playOptionChoices`, the values each enum or on / off option can take. Values outside the choices are ignored. + - `songRecord(song)`: the player's best record on a chart (`{ lamp, exScore?, maxCombo?, missCount?, playCount? }`), or `undefined` when the host keeps none + - `pointer`: the pointer in design pixels, or `undefined` + - `settings` and `songImage`: see [Skin settings](#skin-settings) and [Song images](#song-images) - `revision`, which increases whenever the select state changes (cursor, folder, search, list contents). A retained scene graph can be rebuilt only when it moves. - `sceneStartedAt` and `cursorChangedAt` for entrance and focus transitions - `nowMs`, `effects`, `launchAt`, and `audio` - - `hit(x, y, w, h, action, cursor?)`: makes a rect clickable for this frame. Declare your hit areas on every draw. + - `hit(x, y, w, h, action, cursor?)`: makes a rect clickable for this frame and returns whether the pointer is over it, for hover states. Declare your hit areas on every draw. ### Result `result.draw(surface, frame)` receives a `BeMusicResultFrame`: -- `result` (`BeMusicResultData`): scores, judgement counts, gauge and score history, and the play log. +- `result` (`BeMusicResultData`): scores, judgement counts, gauge and score history, and the play log. When the host provides them, also `lamp` (the clear lamp: `FAILED`, the gauge it was cleared on, `FULL_COMBO`, `PERFECT`, `MAX`), `gaugeType`, `judgeRuleset`, `fast` / `slow`, and `judgeTimings` (every timed judgement of the play). - `rankLabel` (IIDX DJ level) and `ratePercent`. - `elapsedMs` since the scene mounted, or `Infinity` once the player skipped the entrance. - `nowMs` and `effects`. +- `settings` and `songImage`: see [Skin settings](#skin-settings) and [Song images](#song-images). + +`resolveResultLamp` and `resolveResultTrackRows` give the clear lamp and track rows the built-in skins show. `binJudgeTimings(result.judgeTimings, binMs, rangeMs?)` turns the timings into a histogram. + +### Decide + +A skin may add a decide screen, shown between picking a chart and the play. Hosts mount it only when `skin.decide` exists and go straight to the play otherwise. + +- **`decide.durationMs`:** how long the screen shows (default 2000). The player can skip ahead with Enter or go back to select with Escape. +- **`decide.draw(surface, frame)`** receives a `BeMusicDecideFrame`: `song`, `playOptions`, `elapsedMs` since the screen mounted, `nowMs`, `effects`, `audio`, `settings`, and `songImage`. + +## Judgement timing + +Timing offsets are signed milliseconds with the engine's convention: **positive is late (SLOW), negative is early (FAST)**, 0 is dead on. + +- `runtime.lastJudgeDeltaMs` is the most recent offset, and `runtime.judgeTimings` the last 64 as `{ atMs, deltaMs, judge, channel? }`, oldest first. +- `result.judgeTimings` holds every timed judgement of the play. +- Only judgements with a press carry a timing. Autoplay hits, mine BADs, and missed notes (a POOR without a press) are left out. +- `binJudgeTimings(timings, binMs = 1, rangeMs?)` returns `{ binMs, originMs, counts }`, a histogram whose bin edges sit on multiples of `binMs`. Pass `rangeMs` to cover `-rangeMs..rangeMs` whatever the timings, so histograms of different plays line up. + +## Skin settings + +A skin can declare options the player tunes, such as a colour theme, a judge-text offset, or an effect toggle: + +```ts +settings: [ + { + kind: 'choice', + id: 'theme', + label: 'Theme', + options: [ + { value: 'neon', label: 'Neon' }, + { value: 'mono', label: 'Mono' }, + ], + default: 'neon', + }, + { kind: 'number', id: 'judgeOffset', label: 'Judge offset', min: -40, max: 40, step: 2, default: 0, unit: 'px' }, + { kind: 'toggle', id: 'glow', label: 'Glow', default: true }, +], +``` + +- **Kinds:** `choice` (one of `options`), `number` (`min..max`, with an optional `step` and `unit` of `'px'`, `'ms'`, or `'%'`), and `toggle`. +- **Validation:** `validateBeMusicSkin` checks the schema. Ids must be unique, labels must not be empty, and every `default` must be one of the options or lie in its range. +- **Values:** every frame carries `settings`, the player's values keyed by id. Hosts persist the values per skin and resolve them with `resolveBeMusicSkinSettings(skin, stored)`, which falls back to the default for anything missing, stale, or mistyped, clamps numbers into their range, and drops ids the skin no longer declares. You can rely on every declared id being present with the right type. + +## Song images + +`songImage(song, kind)` is on every frame (select, decide, gameplay, and result). It returns the song's `'stageFile'`, `'banner'`, or `'backBmp'` as an `ImageBitmap`, ready for `drawImage` or a texture upload. -`resolveResultLamp` and `resolveResultTrackRows` give the clear lamp and track rows the built-in skins show. +- Images load lazily. The first calls return `undefined` while an image decodes; the screen is redrawn once it is ready (on select, `revision` moves). +- It also returns `undefined` when the song has no such image or the host doesn't provide images, so always draw a fallback. +- Don't close or transfer the bitmap: the host caches it. ## SDK helpers @@ -232,6 +310,8 @@ Set `gameplay.drawsMines: true` to draw mine notes yourself from `frame.mines`. | Loading | `LOADING_WORD`, `loadingDots(nowMs)` | | Sound | `audioDrive`, `bandAt`, `bandLevel`; see [Reacting to the music](#reacting-to-the-music) | | Result | `resolveResultLamp`, `resolveResultTrackRows` | +| Timing | `binJudgeTimings`; see [Judgement timing](#judgement-timing) | +| Settings | `resolveBeMusicSkinSettings`, `validateBeMusicSkinSettings`; see [Skin settings](#skin-settings) | | Song facts | `resolveSongRowFacts`, `resolveSongStats`, `resolveSongTags`, `formatSongLength`, `formatBpmRange`, `formatPlayVariantLabel` | | Text | `DEFAULT_TEXT_FONT`, `layoutTabularRun` (tabular figures) | | Motion | `easeOutCubic`, `easeOutBack`, `stageProgress`, `rollUpValue`, `hash01` (deterministic jitter) | @@ -376,6 +456,7 @@ List every face you draw with in `fontLoads`. The host loads them with `document - **When to bump `version`:** whenever you release the skin. Follow semantic versioning from the player's point of view: a new look is a minor version, a fix is a patch. - **`apiVersion`:** set it to the SDK constant you built against. When the skin API changes incompatibly, the player raises `BE_MUSIC_SKIN_API_VERSION`. Skins written for an older revision are then refused until they are rebuilt against the new SDK. +- **Revision 2:** the current revision. It only added to what skins receive: new frame and runtime fields, the opt-in `draws*` flags, the decide screen, skin settings, and song images. A revision 1 skin therefore still runs unchanged, and the player accepts both 1 and 2. Rebuilding against the new SDK and setting `apiVersion: 2` only matters if you use the new fields. Note that `frame.hit` now returns a boolean, which a skin that ignores the result never notices. - **Keep `id` stable:** hosts persist the selected skin by its `id`. ## Checklist @@ -386,6 +467,10 @@ List every face you draw with in `fontLoads`. The host loads them with `document - [ ] The BGA rect is left transparent when `runtime.hasBga`. - [ ] NOW LOADING is shown while `runtime.loading`. - [ ] Select rows line up with `select.layout`, and buttons are declared with `frame.hit` on every draw. +- [ ] Every `draws*` flag you set is matched by drawing that element yourself. +- [ ] Optional runtime fields and `songImage` lookups that come back `undefined` have a fallback. +- [ ] Timing displays treat positive `deltaMs` as late (SLOW) and negative as early (FAST). +- [ ] `settings` ids are unique and every default is valid. - [ ] Audio reactions go through `audioDrive`, and `effects` is respected. - [ ] Every face is listed in `fontLoads`. - [ ] `draw` stays well under half a frame on a dense chart at high density, with no per-frame allocations or pixel readback. diff --git a/packages/player-web/src/scene/core/select.ts b/packages/player-web/src/scene/core/select.ts index 55dac37df..bbf44522a 100644 --- a/packages/player-web/src/scene/core/select.ts +++ b/packages/player-web/src/scene/core/select.ts @@ -15,7 +15,22 @@ import type { BrowserSongEntry, } from '../../collection/types.ts'; import { CORE_TEXT_FONT } from './fonts.ts'; -import type { BeMusicEffectLevel, BeMusicSkin } from '../../skin/be-music/types.ts'; +import type { + BeMusicBgaMode, + BeMusicBgaSize, + BeMusicDifficultyFilter, + BeMusicEffectLevel, + BeMusicGaugeType, + BeMusicHiddenSudden, + BeMusicHsFix, + BeMusicKeysFilter, + BeMusicPlayOptionChoices, + BeMusicPlayOptions, + BeMusicRandomMode, + BeMusicSelectActions, + BeMusicSelectSort, + BeMusicSkin, +} from '../../skin/be-music/types.ts'; import { BeMusicSelectBinding, resolveBeMusicSkinStage } from '../../skin/be-music/binding.ts'; import { resolveSelectListWindow } from '../../skin/be-music/registry.ts'; import { phantomSkin } from '../../skins/phantom/index.ts'; @@ -81,164 +96,30 @@ export interface PixiSongSelectNavigation { } /** - * Per-session gameplay tweaks the user picks from the select-screen "PLAY OPTIONS" overlay (toggled with Space). The - * host reads the current snapshot via {@link CoreSongSelectView.getPlayOptions} on song-pick and feeds it into {@link - * PixiGameplayViewOptions}. - * - * Kept deliberately small for now — HiSpeed and AutoPlay are the two choices that change every play session. Note - * arrangement (RANDOM / MIRROR / S-RANDOM), gauge type, and judge rank would slot in here once the gameplay engine - * grows the corresponding transformations. + * Per-session gameplay tweaks the user picks on the select screen. The shape lives in `@be-music/skin-sdk` as + * `BeMusicPlayOptions` so be-music skins can read and change it; these names are kept for existing importers. */ -export interface PixiPlayOptions { - /** - * Visual scroll-speed multiplier. The chart's note-to-second mapping is unchanged; only the on-screen pixels-per-beat - * scaling moves. Range matches gameplay's runtime adjust hotkeys (`ArrowUp` / `ArrowDown` during play). - */ - hiSpeed: number; - /** - * When `true`, every note auto-judges as PERFECT at its scheduled time. Mirrors the LR2 AUTOPLAY skin button - * (`#SRC_BUTTON type=16`); the dedicated `onSongAutoPlay` callback also forces this on for that single launch - * regardless of what's stored here. - */ - autoPlay: boolean; - /** - * BGA (background animation) display mode. Mirrors the LR2 `#SRC_BUTTON,type=72` cycle (OFF / ON / AUTOPLAY ONLY). - * When `'AUTOPLAY_ONLY'` the BGA renders only when {@link autoPlay} (or the AUTOPLAY skin-button override) is also - * active — matches LR2 behavior where AUTOPLAY ONLY hides BGA during regular human-played sessions. - */ - bga: PixiBgaMode; - /** - * BGA frame size — `'NORMAL'` uses the skin's default `#DST_BGA` rect (op 30), `'EXTEND'` uses the larger variant - * gated on op 31. Mirrors `#SRC_BUTTON,type=73`. - */ - bgaSize: PixiBgaSize; - /** - * Score-graph (per-judge prediction line) display flag. Mirrors `#SRC_BUTTON,type=70` and ops 38 (off) / 39 (on). The - * LR2 default skin gates its score-graph chrome on op 39 — toggling this on reveals those elements without needing a - * dedicated renderer in the gameplay scene. - */ - scoreGraph: boolean; - /** - * Difficulty filter for the song-list. `'ALL'` shows every chart; the named values restrict the bar list to charts - * whose `#DIFFICULTY` matches the corresponding LR2 enum (1=BEGINNER.. 5=INSANE). Mirrors `#SRC_BUTTON,type=10` - * (cycling) and types 91..96 (direct set). - */ - difficultyFilter: PixiDifficultyFilter; - /** - * Keymode filter for the song-list. `'ALL'` shows every chart; the named values restrict the bar list to charts whose - * lane usage matches the corresponding LR2 keymode op (160=7K..164=9K). Mirrors `#SRC_BUTTON,type=11` cycling. - */ - keysFilter: PixiKeysFilter; - /** - * Sort order for the song-list. Mirrors `#SRC_BUTTON,type=12` cycling (off / level / title / clear). `'CLEAR'` is a - * no-op for now — without persisted play history every chart has the same "not played" status, so the sorted order - * matches the input order anyway. - */ - sort: PixiSelectSort; - /** - * HS-FIX mode picked from `#SRC_BUTTON,type=55`. Without HS-FIX (`'OFF'`) the visual scroll rate scales with the - * chart's BPM, so high-BPM sections fly past while low-BPM sections crawl. The other modes apply a one-time HS - * multiplier so the user's chosen HS feels consistent across the chart: - * - * - `'MAXBPM'` — pegs the user's HS to the chart's MAX BPM (slower segments scroll proportionally slower). - * - `'MINBPM'` — pegs to MIN BPM (faster segments scroll faster). - * - `'AVERAGE'` — pegs to the time-weighted average BPM. - * - `'CONSTANT'` — adjusts HS at every BPM change so visual scroll is exactly constant. Falls back to `'AVERAGE'` - * behavior for now (per-frame BPM-aware scroll requires a render-pipeline change that hasn't landed yet). - */ - hsFix: PixiHsFix; - /** - * 1P-side HIDDEN / SUDDEN effect picked from `#SRC_BUTTON,type=50`. Cycles OFF → HIDDEN → SUDDEN → HID+SUD on each - * click. Drives the opaque mask over the 1P keyboard / scratch lanes. SP charts only render this side; DP charts pair - * it with {@link hiddenSudden2P}. - */ - hiddenSudden1P: PixiHiddenSudden; - /** - * 2P-side HIDDEN / SUDDEN effect picked from `#SRC_BUTTON,type=51`. Independent from the 1P value — LR2's panel UI - * exposes per-side cycle buttons because real players sometimes want, e.g., HIDDEN on their dominant side and OFF on - * the other. - */ - hiddenSudden2P: PixiHiddenSudden; - /** - * Shutter / LANE COVER coverage (0..1) — the fraction of the playfield the LANE COVER (and any active HIDDEN / SUDDEN - * masks) occupies. Drives slider `type=4 / 5` on the panel-1 shutter track. Adjustable via the 1P 4-key / 6-key - * (`KeyD` / `KeyF`) when panel 1 is open. Independent from {@link laneCover} — height is preserved across ON/OFF - * toggles so the user doesn't have to redial it after re-enabling the cover. - */ - shutter: number; - /** - * LANE COVER ON / OFF toggle. In LR2's SYSTEM OPTION panel this is the binary state shown next to the "LANE COVER" - * label — distinct from {@link shutter} (which is the height). When OFF, the gameplay-side mask is hidden regardless - * of `shutter`. When ON, the mask renders at the `shutter`-derived height. - */ - laneCover: boolean; - /** - * 1P side auto-scratch flag picked from `#SRC_BUTTON,type=44`. When true the scratch lane (channel 16) auto-judges as - * PERFECT at every note's scheduled time, so the player only has to play the keys. - */ - autoScratch1P: boolean; - /** 2P side auto-scratch (`#SRC_BUTTON,type=45`, channel 26). */ - autoScratch2P: boolean; - /** - * DP FLIP toggle picked from `#SRC_BUTTON,type=54`. When true, 1P and 2P lane channels are swapped at chart-prepare - * time. Only DP charts have lanes on both sides, so SP charts are unaffected. LR2 has no dedicated op for DP FLIP — - * it's a pure gameplay-side transformation. - */ - dpFlip: boolean; - /** - * Note-arrangement mode for the 1P keyboard lanes (channels 11..15 + 18 + 19). Mirrors `#SRC_BUTTON,type=42`. Scratch - * (channel 16) is never touched. - * - * - `OFF` — original chart layout - * - `MIRROR` — reverse the lane order (1↔7, 2↔6, 3↔5) - * - `RANDOM` — chart-wide random permutation of the lanes - * - `S-RANDOM` — per-chord random assignment (chord shapes are NOT preserved) - * - `SCATTER` — currently aliased to `RANDOM`; reserved for a future per-measure permutation pass - */ - random1P: PixiRandomMode; - /** 2P side note arrangement (channels 21..25 + 28 + 29). */ - random2P: PixiRandomMode; - /** - * 1P gauge variant (`#SRC_BUTTON,type=40`): - * - * - `GROOVE` — LR2 default cumulative gauge (start 20 %, clear 80 %) - * - `HARD` — survival gauge (start 100 %, fail at 0 %) - * - `DEATH` — instant-death (start 100 %, any miss = 0) - * - `EASY` — gentler GROOVE (clear 60 %) - */ - gauge1P: PixiGaugeType; - /** 2P gauge variant (`#SRC_BUTTON,type=41`). */ - gauge2P: PixiGaugeType; -} - +export type PixiPlayOptions = BeMusicPlayOptions; /** Allowed values for {@link PixiPlayOptions.difficultyFilter}. */ -export type PixiDifficultyFilter = 'ALL' | 'BEGINNER' | 'NORMAL' | 'HYPER' | 'ANOTHER' | 'INSANE'; - +export type PixiDifficultyFilter = BeMusicDifficultyFilter; /** Allowed values for {@link PixiPlayOptions.keysFilter}. */ -export type PixiKeysFilter = 'ALL' | 'KEYS_5' | 'KEYS_7' | 'KEYS_9' | 'KEYS_10' | 'KEYS_14'; - +export type PixiKeysFilter = BeMusicKeysFilter; /** Allowed values for {@link PixiPlayOptions.sort}. */ -export type PixiSelectSort = 'OFF' | 'LEVEL' | 'TITLE' | 'CLEAR'; - +export type PixiSelectSort = BeMusicSelectSort; /** Allowed values for {@link PixiPlayOptions.hsFix}. */ -export type PixiHsFix = 'OFF' | 'MAXBPM' | 'MINBPM' | 'AVERAGE' | 'CONSTANT'; - +export type PixiHsFix = BeMusicHsFix; /** Allowed values for {@link PixiPlayOptions.hiddenSudden1P} / `hiddenSudden2P`. */ -export type PixiHiddenSudden = 'OFF' | 'HIDDEN' | 'SUDDEN' | 'HID+SUD'; - +export type PixiHiddenSudden = BeMusicHiddenSudden; /** Allowed values for {@link PixiPlayOptions.random1P} / `random2P`. */ -export type PixiRandomMode = 'OFF' | 'MIRROR' | 'RANDOM' | 'S-RANDOM' | 'SCATTER'; - +export type PixiRandomMode = BeMusicRandomMode; /** Allowed values for {@link PixiPlayOptions.gauge1P} / `gauge2P`. */ -export type PixiGaugeType = 'GROOVE' | 'HARD' | 'DEATH' | 'EASY'; - -const SHUTTER_DEFAULT = 0.25; - +export type PixiGaugeType = BeMusicGaugeType; /** Allowed values for {@link PixiPlayOptions.bga}. */ -export type PixiBgaMode = 'OFF' | 'ON' | 'AUTOPLAY_ONLY'; - +export type PixiBgaMode = BeMusicBgaMode; /** Allowed values for {@link PixiPlayOptions.bgaSize}. */ -export type PixiBgaSize = 'NORMAL' | 'EXTEND'; +export type PixiBgaSize = BeMusicBgaSize; + +const SHUTTER_DEFAULT = 0.25; /** * Cycle order for {@link PixiPlayOptions.bga}. Matches the LR2 default-skin cell order on the `#SRC_BUTTON,type=72` @@ -394,6 +275,31 @@ function cycleNext(values: readonly T[], current: T): T { return values[(index + 1) % values.length]!; } +/** Choices of every enum / on-off play option, in their LR2 button cycle order — what be-music skins can pick from. */ +const PLAY_OPTION_CHOICES: BeMusicPlayOptionChoices = { + autoPlay: BOOLEAN_CYCLE, + bga: BGA_CYCLE, + bgaSize: BGA_SIZE_CYCLE, + scoreGraph: BOOLEAN_CYCLE, + difficultyFilter: DIFFICULTY_FILTER_CYCLE, + keysFilter: KEYS_FILTER_CYCLE, + sort: SORT_CYCLE, + hsFix: HS_FIX_CYCLE, + hiddenSudden1P: HIDDEN_SUDDEN_CYCLE, + hiddenSudden2P: HIDDEN_SUDDEN_CYCLE, + laneCover: BOOLEAN_CYCLE, + autoScratch1P: BOOLEAN_CYCLE, + autoScratch2P: BOOLEAN_CYCLE, + dpFlip: BOOLEAN_CYCLE, + random1P: RANDOM_CYCLE, + random2P: RANDOM_CYCLE, + gauge1P: GAUGE_CYCLE, + gauge2P: GAUGE_CYCLE, +}; + +/** Play options that filter or reorder the song list: changing one moves the cursor back to the top. */ +const LIST_PLAY_OPTIONS: ReadonlySet = new Set(['difficultyFilter', 'keysFilter', 'sort']); + /** * Bundle of LR2 system sound-effect bytes (typically loaded from `LR2files\Sound\lr2\*.wav`). Each field is the encoded * audio payload (WAV / OGG / MP3 / etc.); {@link CoreSongSelectView} decodes lazily on first play through its own @@ -2348,6 +2254,8 @@ export class CoreSongSelectView this.launchFocusedSong(false), autoPlay: (): void => this.launchFocusedSong(true), activateSearch: (): void => this.options.onSearchActivate?.(), + setPlayOption: (key, value): void => this.setSkinPlayOption(key, value), + cyclePlayOption: (key, step = 1): void => { + const choices = PLAY_OPTION_CHOICES[key] as readonly PixiPlayOptions[typeof key][] | undefined; + if (!choices || choices.length === 0) return; + const index = choices.indexOf(this.playOptions[key]); + const next = index < 0 ? choices[0]! : choices[(index + step + choices.length) % choices.length]!; + this.setSkinPlayOption(key, next); + }, + toggleReadme: (): void => { + const song = this.focusedSong(); + if (song) this.toggleReadText(song); + }, }; + /** + * A be-music skin's play-option change: values outside the option's choices (or range) are dropped, and a list + * filter / sort change moves the cursor back to the top like the LR2 buttons do. + */ + private setSkinPlayOption(key: K, value: PixiPlayOptions[K]): void { + const choices = PLAY_OPTION_CHOICES[key] as readonly PixiPlayOptions[K][] | undefined; + let next = value; + if (choices) { + if (!choices.includes(value)) return; + } else if (typeof value !== 'number') { + return; + } else if (key === 'hiSpeed') { + next = clampHiSpeed(value) as PixiPlayOptions[K]; + } else if (key === 'shutter') { + next = clampShutter(value) as PixiPlayOptions[K]; + } + if (this.playOptions[key] === next) return; + this.setPlayOption(key, next); + if (LIST_PLAY_OPTIONS.has(key)) this.snapCursorAfterFilterChange(); + } + private launchFocusedSong(autoPlay: boolean): void { const focused = this.focusedSong(); if (!focused) return; diff --git a/packages/player-web/src/skin/be-music/binding.ts b/packages/player-web/src/skin/be-music/binding.ts index d7a122f5a..38cf176f1 100644 --- a/packages/player-web/src/skin/be-music/binding.ts +++ b/packages/player-web/src/skin/be-music/binding.ts @@ -1,7 +1,7 @@ import { CanvasSource, Container, Sprite, Texture } from 'pixi.js'; import { getDesignPixelRatio } from '../../scene/core/viewport.ts'; import { ChildPool } from '../../scene/pixi-utils.ts'; -import { wideStage } from '@be-music/skin-sdk'; +import { resolveBeMusicSkinSettings, wideStage } from '@be-music/skin-sdk'; import { logger } from '../../logger.ts'; import type { BeMusicBomb, @@ -13,8 +13,11 @@ import type { BeMusicMine, BeMusicNote, BeMusicResultFrame, + BeMusicMeasureLine, BeMusicSelectFrame, BeMusicSkin, + BeMusicSkinSettingValues, + BeMusicSongImageLookup, BeMusicStage, BeMusicSurface, BeMusicSurfaceContextMap, @@ -27,6 +30,14 @@ import type { const log = logger('be-music-skin'); +/** Song images aren't loaded for be-music skins yet: every lookup reports the image as unavailable. */ +// Wired in a later commit. +const NO_SONG_IMAGE: BeMusicSongImageLookup = () => undefined; + +/** Measure lines aren't collected for skins yet, so opted-in skins see none. */ +// Wired in a later commit. +const NO_MEASURE_LINES: readonly BeMusicMeasureLine[] = Object.freeze([]); + /** Largest canvas edge a surface allocates, whatever the window size. */ const MAX_CANVAS_EDGE = 4096; @@ -156,7 +167,14 @@ export class BeMusicGameplayBinding { readonly bombDurationMs: number; /** Whether the skin draws mines from the frame; when false the scene draws them itself. */ readonly drawsMines: boolean; + /** Whether the skin draws the lane covers from `runtime.laneCovers`; when false the scene draws them itself. */ + readonly drawsLaneCover: boolean; + /** Whether the skin draws measure lines from the frame; when false the scene draws them itself. */ + readonly drawsMeasureLines: boolean; + /** Whether the skin composites the BGA from the frame; when false the scene shows it behind the skin. */ + readonly drawsBga: boolean; private readonly skin: BeMusicSkin; + private readonly settings: BeMusicSkinSettingValues; private readonly surface: SkinSurface; private frame: BeMusicGameplayFrame | undefined; private lanes: BeMusicLaneFrame[] = []; @@ -172,6 +190,10 @@ export class BeMusicGameplayBinding { this.surface = new SkinSurface(skin); this.bombDurationMs = skin.gameplay.bombDurationMs ?? 300; this.drawsMines = skin.gameplay.drawsMines === true; + this.drawsLaneCover = skin.gameplay.drawsLaneCover === true; + this.drawsMeasureLines = skin.gameplay.drawsMeasureLines === true; + this.drawsBga = skin.gameplay.drawsBga === true; + this.settings = resolveBeMusicSkinSettings(skin); } /** Starts a frame and shows the skin's canvas in `layerPool` (the scene's chrome layer, above the BGA). */ @@ -196,9 +218,14 @@ export class BeMusicGameplayBinding { longNotes: this.longNotes, mines: this.mines, bombs: this.bombs, + measureLines: NO_MEASURE_LINES, + // Wired in a later commit: the scene doesn't hand BGA layers to skins yet. + bga: undefined, beatPhase: runtime.beatPhase ?? 0, effects: runtime.effects ?? 'full', audio: runtime.audio, + settings: this.settings, + songImage: NO_SONG_IMAGE, }; const sprite = context.layerPool.acquireSprite(); sprite.texture = this.surface.texture; @@ -241,8 +268,14 @@ export class BeMusicGameplayBinding { }; } -/** The select frame the scene hands over; the binding adds `revision`, `audio`, and `hit`. */ -export type BeMusicSelectSceneFrame = Omit; +/** + * The select frame the scene hands over; the binding adds `revision`, `audio`, `hit`, and the skin-facing extras + * (`pointer`, `songRecord`, `songImage`, `settings`). + */ +export type BeMusicSelectSceneFrame = Omit< + BeMusicSelectFrame, + 'revision' | 'audio' | 'hit' | 'pointer' | 'songRecord' | 'songImage' | 'settings' +>; /** * Select for one scene: {@link render} records the latest state when the scene's input changes, and {@link tick} draws @@ -257,6 +290,9 @@ export class BeMusicSelectBinding { private readonly skin: BeMusicSkin; private readonly surface: SkinSurface; private readonly hitPool: ChildPool; + private readonly settings: BeMusicSkinSettingValues; + /** The pointer in design pixels. Wired in a later commit: the scene doesn't report it yet. */ + private readonly pointer: BeMusicSelectFrame['pointer'] = undefined; private frame: BeMusicSelectSceneFrame | undefined; private revision = 0; @@ -264,6 +300,7 @@ export class BeMusicSelectBinding { this.skin = skin; this.surface = new SkinSurface(skin); this.outroMs = skin.select.outroMs ?? 0; + this.settings = resolveBeMusicSkinSettings(skin); this.backLayer.addChild(new Sprite({ texture: this.surface.texture, roundPixels: true })); this.hitPool = new ChildPool(this.frontLayer); } @@ -285,6 +322,7 @@ export class BeMusicSelectBinding { if (!base) return; const pool = this.hitPool; pool.begin(); + const pointer = this.pointer; const frame: BeMusicSelectFrame = { ...base, nowMs, @@ -292,6 +330,11 @@ export class BeMusicSelectBinding { launchAt, audio, revision: this.revision, + pointer, + // Wired in a later commit: the host keeps no play records for skins yet. + songRecord: () => undefined, + songImage: NO_SONG_IMAGE, + settings: this.settings, hit: (x, y, w, h, action, cursor = 'pointer') => { const area = pool.acquireGraphics(); area.rect(x, y, w, h).fill({ color: 0xffffff, alpha: 0.001 }); @@ -299,6 +342,7 @@ export class BeMusicSelectBinding { area.cursor = cursor; area.removeAllListeners('pointerdown'); area.on('pointerdown', action); + return pointer !== undefined && pointer.x >= x && pointer.x < x + w && pointer.y >= y && pointer.y < y + h; }, }; try { @@ -316,20 +360,26 @@ export class BeMusicSelectBinding { } } +/** The result frame the scene hands over; the binding adds `songImage` and `settings`. */ +export type BeMusicResultSceneFrame = Omit; + /** Result for one scene: draws the skin every frame onto a canvas shown by {@link view}. */ export class BeMusicResultBinding { /** Persistent container showing the skin's canvas; the scene mounts it once. */ readonly view = new Container(); private readonly skin: BeMusicSkin; private readonly surface: SkinSurface; + private readonly settings: BeMusicSkinSettingValues; constructor(skin: BeMusicSkin) { this.skin = skin; this.surface = new SkinSurface(skin); + this.settings = resolveBeMusicSkinSettings(skin); this.view.addChild(new Sprite({ texture: this.surface.texture, roundPixels: true })); } - render(frame: BeMusicResultFrame): void { + render(sceneFrame: BeMusicResultSceneFrame): void { + const frame: BeMusicResultFrame = { ...sceneFrame, songImage: NO_SONG_IMAGE, settings: this.settings }; this.surface.draw((surface) => this.skin.result.draw(surface, frame)); } diff --git a/packages/player-web/src/skin/be-music/registry.test.ts b/packages/player-web/src/skin/be-music/registry.test.ts index a59640287..d70334b23 100644 --- a/packages/player-web/src/skin/be-music/registry.test.ts +++ b/packages/player-web/src/skin/be-music/registry.test.ts @@ -1,8 +1,11 @@ import { describe, expect, it } from 'vite-plus/test'; import { createBeMusicSkinRegistry, resolveBeMusicLaneKind, resolveSelectListWindow } from './registry.ts'; +import { BE_MUSIC_SKIN_API_VERSION } from '@be-music/skin-sdk'; import type { BeMusicSkin } from './types.ts'; -function stubSkin(id: string, apiVersion = 1): BeMusicSkin { +const FUTURE_API_VERSION = BE_MUSIC_SKIN_API_VERSION + 1; + +function stubSkin(id: string, apiVersion = BE_MUSIC_SKIN_API_VERSION): BeMusicSkin { return { apiVersion, id, @@ -25,6 +28,11 @@ describe('createBeMusicSkinRegistry', () => { expect(registry.resolve(undefined).id).toBe('phantom'); }); + it('accepts skins written for revision 1', () => { + const registry = createBeMusicSkinRegistry([stubSkin('legacy', 1)]); + expect(registry.skins.map((skin) => skin.id)).toEqual(['legacy']); + }); + it('rejects an empty list and duplicate ids', () => { expect(() => createBeMusicSkinRegistry([])).toThrow(); expect(() => createBeMusicSkinRegistry([stubSkin('a'), stubSkin('a')])).toThrow(/duplicate/); @@ -32,13 +40,13 @@ describe('createBeMusicSkinRegistry', () => { it('leaves out skins for an unsupported API revision and reports them', () => { const rejected: string[] = []; - const registry = createBeMusicSkinRegistry([stubSkin('future', 2), stubSkin('phantom')], { + const registry = createBeMusicSkinRegistry([stubSkin('future', FUTURE_API_VERSION), stubSkin('phantom')], { onRejected: (skin, problems) => rejected.push(`${skin.id}: ${problems[0]}`), }); expect(registry.skins.map((skin) => skin.id)).toEqual(['phantom']); expect(registry.resolve('future').id).toBe('phantom'); expect(rejected).toHaveLength(1); - expect(rejected[0]).toMatch(/^future: apiVersion 2/u); + expect(rejected[0]).toMatch(/^future: apiVersion 3/u); }); it('adds skins later, replacing one with the same id, and notifies subscribers', () => { @@ -62,13 +70,15 @@ describe('createBeMusicSkinRegistry', () => { it('refuses to add an invalid skin and reports why', () => { const registry = createBeMusicSkinRegistry([stubSkin('phantom')]); - const problems = registry.add(stubSkin('future', 2)); - expect(problems[0]).toMatch(/apiVersion 2/u); + const problems = registry.add(stubSkin('future', FUTURE_API_VERSION)); + expect(problems[0]).toMatch(/apiVersion 3/u); expect(registry.skins.map((skin) => skin.id)).toEqual(['phantom']); }); it('throws when no skin is valid', () => { - expect(() => createBeMusicSkinRegistry([stubSkin('future', 2)], { onRejected: () => {} })).toThrow(/valid/u); + expect(() => createBeMusicSkinRegistry([stubSkin('future', FUTURE_API_VERSION)], { onRejected: () => {} })).toThrow( + /valid/u, + ); }); }); diff --git a/packages/skin-sdk/scripts/exports-cases.ts b/packages/skin-sdk/scripts/exports-cases.ts index 5cc64f983..1640a9039 100644 --- a/packages/skin-sdk/scripts/exports-cases.ts +++ b/packages/skin-sdk/scripts/exports-cases.ts @@ -64,6 +64,26 @@ const BENCH_AUDIO = { bands: Array.from({ length: skinSdkApi.AUDIO_BAND_COUNT }, (_, index) => index / skinSdkApi.AUDIO_BAND_COUNT), }; const BENCH_PALETTE = [0xffffff, 0xffd84a, 0x4ad8ff]; +const BENCH_SETTINGS = [ + { + kind: 'choice', + id: 'theme', + label: 'Theme', + options: [ + { value: 'neon', label: 'Neon' }, + { value: 'mono', label: 'Mono' }, + ], + default: 'neon', + }, + { kind: 'number', id: 'judgeOffset', label: 'Judge offset', min: -40, max: 40, step: 2, default: 0, unit: 'px' }, + { kind: 'toggle', id: 'glow', label: 'Glow', default: true }, +] satisfies skinSdkApi.BeMusicSkinSetting[]; +const BENCH_STORED_SETTINGS = { theme: 'mono', judgeOffset: 72, glow: 'yes', removed: 1 }; +const BENCH_JUDGE_TIMINGS = Array.from({ length: 1000 }, (_, index) => ({ + atMs: index * 120, + deltaMs: Math.round(Math.sin(index * 1.7) * 60), + judge: 'GREAT', +})) satisfies skinSdkApi.BeMusicJudgeTiming[]; const BENCH_SKIN = { apiVersion: skinSdkApi.BE_MUSIC_SKIN_API_VERSION, id: 'bench', @@ -93,6 +113,11 @@ export function registerSkinSdkExportsCases(define: DefineBenchmarkCase): void { skinSdkApi.bandLevel(BENCH_AUDIO.bands, 5, 24); }, }); + define('skin-sdk.binJudgeTimings', { + run: () => { + skinSdkApi.binJudgeTimings(BENCH_JUDGE_TIMINGS, 2, 100); + }, + }); define('skin-sdk.comboTier', { run: () => { skinSdkApi.comboTier(120); @@ -198,6 +223,11 @@ export function registerSkinSdkExportsCases(define: DefineBenchmarkCase): void { skinSdkApi.resolveBeMusicLaneKind('12', 2, '7'); }, }); + define('skin-sdk.resolveBeMusicSkinSettings', { + run: () => { + skinSdkApi.resolveBeMusicSkinSettings({ settings: BENCH_SETTINGS }, BENCH_STORED_SETTINGS); + }, + }); define('skin-sdk.resolveFallbackLaneLayout', { run: () => { skinSdkApi.resolveFallbackLaneLayout({ @@ -299,4 +329,9 @@ export function registerSkinSdkExportsCases(define: DefineBenchmarkCase): void { skinSdkApi.validateBeMusicSkin(BENCH_SKIN); }, }); + define('skin-sdk.validateBeMusicSkinSettings', { + run: () => { + skinSdkApi.validateBeMusicSkinSettings(BENCH_SETTINGS); + }, + }); } diff --git a/packages/skin-sdk/src/define.test.ts b/packages/skin-sdk/src/define.test.ts index f4f253e01..1c40c691a 100644 --- a/packages/skin-sdk/src/define.test.ts +++ b/packages/skin-sdk/src/define.test.ts @@ -24,10 +24,19 @@ function skin(overrides: Partial = {}): BeMusicSkin { } describe('isSupportedBeMusicSkinApiVersion', () => { - it('accepts the current revision only', () => { + it('is at revision 2', () => { + expect(BE_MUSIC_SKIN_API_VERSION).toBe(2); + }); + + it('accepts the current revision and revision 1, which revision 2 only added to', () => { expect(isSupportedBeMusicSkinApiVersion(BE_MUSIC_SKIN_API_VERSION)).toBe(true); + expect(isSupportedBeMusicSkinApiVersion(1)).toBe(true); + }); + + it('refuses future and unknown revisions', () => { expect(isSupportedBeMusicSkinApiVersion(BE_MUSIC_SKIN_API_VERSION + 1)).toBe(false); expect(isSupportedBeMusicSkinApiVersion(0)).toBe(false); + expect(isSupportedBeMusicSkinApiVersion(1.5)).toBe(false); }); }); @@ -53,6 +62,36 @@ describe('validateBeMusicSkin', () => { expect(validateBeMusicSkin(skin({ apiVersion: 99 }))[0]).toMatch(/apiVersion 99/u); }); + it('passes a revision 1 skin', () => { + expect(validateBeMusicSkin(skin({ apiVersion: 1 }))).toEqual([]); + }); + + it('checks an optional decide screen', () => { + expect(validateBeMusicSkin(skin({ decide: { durationMs: 1500, draw: () => {} } }))).toEqual([]); + expect(validateBeMusicSkin(skin({ decide: {} as BeMusicSkin['decide'] }))).toEqual([ + 'decide.draw must be a function', + ]); + expect(validateBeMusicSkin(skin({ decide: { durationMs: -1, draw: () => {} } }))).toEqual([ + 'decide.durationMs -1 must be a non-negative number', + ]); + }); + + it('includes problems with the settings schema', () => { + expect( + validateBeMusicSkin(skin({ settings: [{ kind: 'toggle', id: 'glow', label: 'Glow', default: true }] })), + ).toEqual([]); + expect( + validateBeMusicSkin( + skin({ + settings: [ + { kind: 'toggle', id: 'glow', label: 'Glow', default: true }, + { kind: 'toggle', id: 'glow', label: 'Glow again', default: false }, + ], + }), + ), + ).toEqual(['setting "glow": id is used more than once']); + }); + it('reports malformed ids, versions, and metadata', () => { const problems = validateBeMusicSkin( skin({ diff --git a/packages/skin-sdk/src/define.ts b/packages/skin-sdk/src/define.ts index 5d823e3d1..24bfece1c 100644 --- a/packages/skin-sdk/src/define.ts +++ b/packages/skin-sdk/src/define.ts @@ -1,13 +1,17 @@ +import { validateBeMusicSkinSettings } from './settings.ts'; import type { BeMusicSkin, BeMusicSurfaceContextKind } from './types.ts'; /** * The skin API revision this build of the player implements. Skins record the revision they were written against in * `apiVersion`; a host refuses skins from a revision it doesn't support. */ -export const BE_MUSIC_SKIN_API_VERSION = 1; +export const BE_MUSIC_SKIN_API_VERSION = 2; -/** Skin API revisions this host can run. */ -const SUPPORTED_API_VERSIONS: ReadonlySet = new Set([BE_MUSIC_SKIN_API_VERSION]); +/** + * Skin API revisions this host can run. Revision 2 only added to what revision 1 skins receive (new frame fields, + * opt-in `draws*` flags, the decide screen, skin settings), so revision 1 skins still run unchanged. + */ +const SUPPORTED_API_VERSIONS: ReadonlySet = new Set([1, BE_MUSIC_SKIN_API_VERSION]); /** Whether this host can run a skin written against `apiVersion`. */ export function isSupportedBeMusicSkinApiVersion(apiVersion: number): boolean { @@ -32,7 +36,7 @@ function isHttpUrl(value: string): boolean { /** * Problems with a skin's declaration — an unsupported API revision, a malformed id or version, missing author, bad - * URLs — as human-readable messages. Empty when the skin is fine. + * URLs, an invalid `settings` schema — as human-readable messages. Empty when the skin is fine. */ export function validateBeMusicSkin(skin: BeMusicSkin): string[] { const problems: string[] = []; @@ -57,6 +61,14 @@ export function validateBeMusicSkin(skin: BeMusicSkin): string[] { for (const screen of ['gameplay', 'select', 'result'] as const) { if (typeof skin[screen]?.draw !== 'function') problems.push(`${screen}.draw must be a function`); } + if (skin.decide !== undefined) { + if (typeof skin.decide.draw !== 'function') problems.push('decide.draw must be a function'); + const { durationMs } = skin.decide; + if (durationMs !== undefined && !(Number.isFinite(durationMs) && durationMs >= 0)) { + problems.push(`decide.durationMs ${String(durationMs)} must be a non-negative number`); + } + } + problems.push(...validateBeMusicSkinSettings(skin.settings)); return problems; } diff --git a/packages/skin-sdk/src/index.ts b/packages/skin-sdk/src/index.ts index db8e0e6da..98ecdea82 100644 --- a/packages/skin-sdk/src/index.ts +++ b/packages/skin-sdk/src/index.ts @@ -9,19 +9,24 @@ * - Play state: judgement words (`judgeDisplayWord`, the flashing GREAT of a PERFECT), moments (combo milestones, the * clear line, full combo, count-in progress), effect levels, input impulses, and the loading caption. * - Sound: `audioDrive` turns the frame's audio analysis into ready-to-use drive values. - * - Song facts: select-row facts (`resolveSongRowFacts`), result lamp and track rows. + * - Song facts: select-row facts (`resolveSongRowFacts`), result lamp and track rows, and judgement-timing histograms + * (`binJudgeTimings`). + * - Skin settings: `resolveBeMusicSkinSettings` fills in and checks the values of a skin's `settings` schema. * - Text and motion: tabular figure layout, the default text face, easing, staged timelines, roll-up counters, and a * deterministic hash for jitter. */ export type * from './types.ts'; export type * from './song.ts'; export type * from './result-data.ts'; +export type * from './play-options.ts'; export * from './audio.ts'; export * from './playfield.ts'; export * from './lane-geometry.ts'; export * from './define.ts'; +export * from './settings.ts'; +export * from './judge-timing.ts'; export * from './stage.ts'; export * from './layout.ts'; export * from './fonts.ts'; diff --git a/packages/skin-sdk/src/judge-timing.test.ts b/packages/skin-sdk/src/judge-timing.test.ts new file mode 100644 index 000000000..ce1a7bccf --- /dev/null +++ b/packages/skin-sdk/src/judge-timing.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vite-plus/test'; +import { binJudgeTimings } from './judge-timing.ts'; + +const timings = (...deltas: number[]) => deltas.map((deltaMs) => ({ deltaMs })); + +describe('binJudgeTimings', () => { + it('has no bins without timings or a range', () => { + expect(binJudgeTimings([])).toEqual({ binMs: 1, originMs: 0, counts: [] }); + }); + + it('bins 1 ms wide from the earliest to the latest timing by default', () => { + expect(binJudgeTimings(timings(-2, 0, 0, 1))).toEqual({ binMs: 1, originMs: -2, counts: [1, 0, 2, 1] }); + }); + + it('puts bin edges on multiples of binMs, early timings below zero', () => { + // Bins: [-10, -5) [-5, 0) [0, 5) [5, 10) + expect(binJudgeTimings(timings(-7, -5, -0.5, 0, 4.9, 5, 9), 5)).toEqual({ + binMs: 5, + originMs: -10, + counts: [1, 2, 2, 2], + }); + }); + + it('reports a 0 origin for timings that are all late', () => { + expect(Object.is(binJudgeTimings(timings(0.5, 3), 2).originMs, 0)).toBe(true); + }); + + it('covers -rangeMs..rangeMs when given and leaves timings outside out', () => { + expect(binJudgeTimings(timings(-30, -10, 0, 10, 30), 10, 20)).toEqual({ + binMs: 10, + originMs: -20, + counts: [0, 1, 1, 1, 0], + }); + expect(binJudgeTimings([], 10, 20).counts).toEqual([0, 0, 0, 0, 0]); + }); + + it('skips timings that are not finite', () => { + expect(binJudgeTimings(timings(Number.NaN, 1, Number.POSITIVE_INFINITY))).toEqual({ + binMs: 1, + originMs: 1, + counts: [1], + }); + }); + + it('refuses a bin width that is not positive', () => { + expect(() => binJudgeTimings(timings(1), 0)).toThrow(RangeError); + expect(() => binJudgeTimings(timings(1), Number.NaN)).toThrow(RangeError); + }); +}); diff --git a/packages/skin-sdk/src/judge-timing.ts b/packages/skin-sdk/src/judge-timing.ts new file mode 100644 index 000000000..b249bb14b --- /dev/null +++ b/packages/skin-sdk/src/judge-timing.ts @@ -0,0 +1,53 @@ +import type { BeMusicJudgeTiming } from './types.ts'; + +/** A histogram of judgement timings (see {@link binJudgeTimings}). */ +export interface BeMusicJudgeTimingHistogram { + /** Width of each bin (ms). */ + binMs: number; + /** Lower edge (ms) of `counts[0]`; bin `i` covers `originMs + i × binMs` up to (not including) the next edge. */ + originMs: number; + /** Judgements per bin, earliest (most negative) first. */ + counts: readonly number[]; +} + +/** + * Bins judgement timings by their signed offset for a timing histogram (negative = early / FAST, positive = late / + * SLOW). Bin edges sit on multiples of `binMs`, so the bin starting at 0 holds the dead-on and slightly late presses. + * + * Without `rangeMs` the bins cover the earliest to the latest timing (no bins when `timings` is empty). With it they + * cover `-rangeMs..rangeMs` whatever the timings, so histograms of different plays line up, and timings outside that + * range are left out. + * + * @throws RangeError when `binMs` is not a positive finite number. + */ +export function binJudgeTimings( + timings: readonly Pick[], + binMs = 1, + rangeMs?: number, +): BeMusicJudgeTimingHistogram { + if (!(Number.isFinite(binMs) && binMs > 0)) throw new RangeError(`binMs must be a positive number, got ${binMs}`); + let min: number; + let max: number; + if (rangeMs !== undefined) { + const range = Math.abs(rangeMs); + min = -range; + max = range; + } else { + min = Number.POSITIVE_INFINITY; + max = Number.NEGATIVE_INFINITY; + for (const { deltaMs } of timings) { + if (!Number.isFinite(deltaMs)) continue; + if (deltaMs < min) min = deltaMs; + if (deltaMs > max) max = deltaMs; + } + if (min > max) return { binMs, originMs: 0, counts: [] }; + } + const firstBin = Math.floor(min / binMs); + const counts = Array.from({ length: Math.floor(max / binMs) - firstBin + 1 }, () => 0); + for (const { deltaMs } of timings) { + if (!Number.isFinite(deltaMs) || deltaMs < min || deltaMs > max) continue; + counts[Math.floor(deltaMs / binMs) - firstBin]! += 1; + } + // `+ 0` turns a -0 origin (an all-late set starting in bin 0) into 0. + return { binMs, originMs: firstBin * binMs + 0, counts }; +} diff --git a/packages/skin-sdk/src/play-options.ts b/packages/skin-sdk/src/play-options.ts new file mode 100644 index 000000000..96253a352 --- /dev/null +++ b/packages/skin-sdk/src/play-options.ts @@ -0,0 +1,155 @@ +/** + * Per-session gameplay tweaks the player picks on the select screen: hi-speed, autoplay, note arrangement, gauge type, + * HIDDEN / SUDDEN and the lane cover, BGA display, and the song-list filters. The host owns the live values and hands + * them to every play it launches; be-music skins read them from `BeMusicSelectFrame.playOptions` / + * `BeMusicDecideFrame.playOptions` and change them through `BeMusicSelectActions.setPlayOption` / + * `cyclePlayOption`. Several fields mirror an LR2 `#SRC_BUTTON` type, noted per field. + */ +export interface BeMusicPlayOptions { + /** + * Visual scroll-speed multiplier. The chart's note-to-second mapping is unchanged; only the on-screen pixels-per-beat + * scaling moves. Range matches gameplay's runtime adjust hotkeys (`ArrowUp` / `ArrowDown` during play). + */ + hiSpeed: number; + /** + * When `true`, every note auto-judges as PERFECT at its scheduled time. Mirrors the LR2 AUTOPLAY skin button + * (`#SRC_BUTTON type=16`); the dedicated `onSongAutoPlay` callback also forces this on for that single launch + * regardless of what's stored here. + */ + autoPlay: boolean; + /** + * BGA (background animation) display mode. Mirrors the LR2 `#SRC_BUTTON,type=72` cycle (OFF / ON / AUTOPLAY ONLY). + * When `'AUTOPLAY_ONLY'` the BGA renders only when {@link autoPlay} (or the AUTOPLAY skin-button override) is also + * active — matches LR2 behavior where AUTOPLAY ONLY hides BGA during regular human-played sessions. + */ + bga: BeMusicBgaMode; + /** + * BGA frame size — `'NORMAL'` uses the skin's default `#DST_BGA` rect (op 30), `'EXTEND'` uses the larger variant + * gated on op 31. Mirrors `#SRC_BUTTON,type=73`. + */ + bgaSize: BeMusicBgaSize; + /** + * Score-graph (per-judge prediction line) display flag. Mirrors `#SRC_BUTTON,type=70` and ops 38 (off) / 39 (on). The + * LR2 default skin gates its score-graph chrome on op 39 — toggling this on reveals those elements without needing a + * dedicated renderer in the gameplay scene. + */ + scoreGraph: boolean; + /** + * Difficulty filter for the song-list. `'ALL'` shows every chart; the named values restrict the bar list to charts + * whose `#DIFFICULTY` matches the corresponding LR2 enum (1=BEGINNER.. 5=INSANE). Mirrors `#SRC_BUTTON,type=10` + * (cycling) and types 91..96 (direct set). + */ + difficultyFilter: BeMusicDifficultyFilter; + /** + * Keymode filter for the song-list. `'ALL'` shows every chart; the named values restrict the bar list to charts whose + * lane usage matches the corresponding LR2 keymode op (160=7K..164=9K). Mirrors `#SRC_BUTTON,type=11` cycling. + */ + keysFilter: BeMusicKeysFilter; + /** + * Sort order for the song-list. Mirrors `#SRC_BUTTON,type=12` cycling (off / level / title / clear). `'CLEAR'` is a + * no-op for now — without persisted play history every chart has the same "not played" status, so the sorted order + * matches the input order anyway. + */ + sort: BeMusicSelectSort; + /** + * HS-FIX mode picked from `#SRC_BUTTON,type=55`. Without HS-FIX (`'OFF'`) the visual scroll rate scales with the + * chart's BPM, so high-BPM sections fly past while low-BPM sections crawl. The other modes apply a one-time HS + * multiplier so the user's chosen HS feels consistent across the chart: + * + * - `'MAXBPM'` — pegs the user's HS to the chart's MAX BPM (slower segments scroll proportionally slower). + * - `'MINBPM'` — pegs to MIN BPM (faster segments scroll faster). + * - `'AVERAGE'` — pegs to the time-weighted average BPM. + * - `'CONSTANT'` — adjusts HS at every BPM change so visual scroll is exactly constant. Falls back to `'AVERAGE'` + * behavior for now (per-frame BPM-aware scroll requires a render-pipeline change that hasn't landed yet). + */ + hsFix: BeMusicHsFix; + /** + * 1P-side HIDDEN / SUDDEN effect picked from `#SRC_BUTTON,type=50`. Cycles OFF → HIDDEN → SUDDEN → HID+SUD on each + * click. Drives the opaque mask over the 1P keyboard / scratch lanes. SP charts only render this side; DP charts pair + * it with {@link hiddenSudden2P}. + */ + hiddenSudden1P: BeMusicHiddenSudden; + /** + * 2P-side HIDDEN / SUDDEN effect picked from `#SRC_BUTTON,type=51`. Independent from the 1P value — LR2's panel UI + * exposes per-side cycle buttons because real players sometimes want, e.g., HIDDEN on their dominant side and OFF on + * the other. + */ + hiddenSudden2P: BeMusicHiddenSudden; + /** + * Shutter / LANE COVER coverage (0..1) — the fraction of the playfield the LANE COVER (and any active HIDDEN / SUDDEN + * masks) occupies. Drives slider `type=4 / 5` on the panel-1 shutter track. Adjustable via the 1P 4-key / 6-key + * (`KeyD` / `KeyF`) when panel 1 is open. Independent from {@link laneCover} — height is preserved across ON/OFF + * toggles so the user doesn't have to redial it after re-enabling the cover. + */ + shutter: number; + /** + * LANE COVER ON / OFF toggle. In LR2's SYSTEM OPTION panel this is the binary state shown next to the "LANE COVER" + * label — distinct from {@link shutter} (which is the height). When OFF, the gameplay-side mask is hidden regardless + * of `shutter`. When ON, the mask renders at the `shutter`-derived height. + */ + laneCover: boolean; + /** + * 1P side auto-scratch flag picked from `#SRC_BUTTON,type=44`. When true the scratch lane (channel 16) auto-judges as + * PERFECT at every note's scheduled time, so the player only has to play the keys. + */ + autoScratch1P: boolean; + /** 2P side auto-scratch (`#SRC_BUTTON,type=45`, channel 26). */ + autoScratch2P: boolean; + /** + * DP FLIP toggle picked from `#SRC_BUTTON,type=54`. When true, 1P and 2P lane channels are swapped at chart-prepare + * time. Only DP charts have lanes on both sides, so SP charts are unaffected. LR2 has no dedicated op for DP FLIP — + * it's a pure gameplay-side transformation. + */ + dpFlip: boolean; + /** + * Note-arrangement mode for the 1P keyboard lanes (channels 11..15 + 18 + 19). Mirrors `#SRC_BUTTON,type=42`. Scratch + * (channel 16) is never touched. + * + * - `OFF` — original chart layout + * - `MIRROR` — reverse the lane order (1↔7, 2↔6, 3↔5) + * - `RANDOM` — chart-wide random permutation of the lanes + * - `S-RANDOM` — per-chord random assignment (chord shapes are NOT preserved) + * - `SCATTER` — currently aliased to `RANDOM`; reserved for a future per-measure permutation pass + */ + random1P: BeMusicRandomMode; + /** 2P side note arrangement (channels 21..25 + 28 + 29). */ + random2P: BeMusicRandomMode; + /** + * 1P gauge variant (`#SRC_BUTTON,type=40`): + * + * - `GROOVE` — LR2 default cumulative gauge (start 20 %, clear 80 %) + * - `HARD` — survival gauge (start 100 %, fail at 0 %) + * - `DEATH` — instant-death (start 100 %, any miss = 0) + * - `EASY` — gentler GROOVE (clear 60 %) + */ + gauge1P: BeMusicGaugeType; + /** 2P gauge variant (`#SRC_BUTTON,type=41`). */ + gauge2P: BeMusicGaugeType; +} + +/** Allowed values for {@link BeMusicPlayOptions.difficultyFilter}. */ +export type BeMusicDifficultyFilter = 'ALL' | 'BEGINNER' | 'NORMAL' | 'HYPER' | 'ANOTHER' | 'INSANE'; + +/** Allowed values for {@link BeMusicPlayOptions.keysFilter}. */ +export type BeMusicKeysFilter = 'ALL' | 'KEYS_5' | 'KEYS_7' | 'KEYS_9' | 'KEYS_10' | 'KEYS_14'; + +/** Allowed values for {@link BeMusicPlayOptions.sort}. */ +export type BeMusicSelectSort = 'OFF' | 'LEVEL' | 'TITLE' | 'CLEAR'; + +/** Allowed values for {@link BeMusicPlayOptions.hsFix}. */ +export type BeMusicHsFix = 'OFF' | 'MAXBPM' | 'MINBPM' | 'AVERAGE' | 'CONSTANT'; + +/** Allowed values for {@link BeMusicPlayOptions.hiddenSudden1P} / `hiddenSudden2P`. */ +export type BeMusicHiddenSudden = 'OFF' | 'HIDDEN' | 'SUDDEN' | 'HID+SUD'; + +/** Allowed values for {@link BeMusicPlayOptions.random1P} / `random2P`. */ +export type BeMusicRandomMode = 'OFF' | 'MIRROR' | 'RANDOM' | 'S-RANDOM' | 'SCATTER'; + +/** Allowed values for {@link BeMusicPlayOptions.gauge1P} / `gauge2P`. */ +export type BeMusicGaugeType = 'GROOVE' | 'HARD' | 'DEATH' | 'EASY'; + +/** Allowed values for {@link BeMusicPlayOptions.bga}. */ +export type BeMusicBgaMode = 'OFF' | 'ON' | 'AUTOPLAY_ONLY'; + +/** Allowed values for {@link BeMusicPlayOptions.bgaSize}. */ +export type BeMusicBgaSize = 'NORMAL' | 'EXTEND'; diff --git a/packages/skin-sdk/src/result-data.ts b/packages/skin-sdk/src/result-data.ts index 5e039668f..fc2bf7d1a 100644 --- a/packages/skin-sdk/src/result-data.ts +++ b/packages/skin-sdk/src/result-data.ts @@ -1,6 +1,7 @@ import type { ScoreSummary } from '@be-music/player/core/scoring'; import type { BeMusicPlaylog } from '@be-music/player/playlog'; -import type { BrowserSongEntry } from './song.ts'; +import type { BeMusicJudgeTiming } from './types.ts'; +import type { BeMusicClearLamp, BrowserSongEntry } from './song.ts'; /** * One sample of the gauge polyline. `progress` is the chart-time fraction (0 = first note, 1 = last playable / sample @@ -55,4 +56,18 @@ export interface BeMusicResultData { * legacy paths that finished without the shared engine having produced one. See `@be-music/player/playlog`. */ playlog?: BeMusicPlaylog; + /** The play's clear lamp (FAILED, the gauge it was cleared on, FULL COMBO, …). */ + lamp?: BeMusicClearLamp; + /** Gauge the play ran on, as the ruleset names it (`GROOVE`, `HARD`, `EX-HARD`, …). */ + gaugeType?: string; + /** Compat ruleset that judged the play (`lr2` / `beatoraja` / `iidx`). */ + judgeRuleset?: string; + /** FAST (early) and SLOW (late) GREAT / GOOD counts. */ + fast?: number; + slow?: number; + /** + * Every timed judgement of the play, oldest first (see `BeMusicGameplayRuntime.lastJudgeDeltaMs` for which ones + * carry a timing). Feed it to {@link binJudgeTimings} for a timing histogram. + */ + judgeTimings?: readonly BeMusicJudgeTiming[]; } diff --git a/packages/skin-sdk/src/settings.test.ts b/packages/skin-sdk/src/settings.test.ts new file mode 100644 index 000000000..c5617f0d6 --- /dev/null +++ b/packages/skin-sdk/src/settings.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from 'vite-plus/test'; +import { resolveBeMusicSkinSettings, validateBeMusicSkinSettings, type BeMusicSkinSetting } from './settings.ts'; + +const THEME: BeMusicSkinSetting = { + kind: 'choice', + id: 'theme', + label: 'Theme', + options: [ + { value: 'neon', label: 'Neon' }, + { value: 'mono', label: 'Mono' }, + ], + default: 'neon', +}; +const OFFSET: BeMusicSkinSetting = { + kind: 'number', + id: 'judgeOffset', + label: 'Judge offset', + min: -40, + max: 40, + step: 2, + default: 0, + unit: 'px', +}; +const GLOW: BeMusicSkinSetting = { kind: 'toggle', id: 'glow', label: 'Glow', default: true }; +const SETTINGS = [THEME, OFFSET, GLOW] as const; + +describe('resolveBeMusicSkinSettings', () => { + it('uses the defaults when nothing is stored', () => { + expect(resolveBeMusicSkinSettings({ settings: SETTINGS })).toEqual({ theme: 'neon', judgeOffset: 0, glow: true }); + }); + + it('is empty for a skin without settings', () => { + expect(resolveBeMusicSkinSettings({}, { theme: 'mono' })).toEqual({}); + }); + + it('keeps valid stored values', () => { + expect( + resolveBeMusicSkinSettings({ settings: SETTINGS }, { theme: 'mono', judgeOffset: -12, glow: false }), + ).toEqual({ theme: 'mono', judgeOffset: -12, glow: false }); + }); + + it('clamps stored numbers into the range', () => { + expect(resolveBeMusicSkinSettings({ settings: SETTINGS }, { judgeOffset: 99 }).judgeOffset).toBe(40); + expect(resolveBeMusicSkinSettings({ settings: SETTINGS }, { judgeOffset: -99 }).judgeOffset).toBe(-40); + }); + + it('falls back to the default for stale or mistyped values', () => { + expect( + resolveBeMusicSkinSettings({ settings: SETTINGS }, { theme: 'retired', judgeOffset: Number.NaN, glow: 'yes' }), + ).toEqual({ theme: 'neon', judgeOffset: 0, glow: true }); + expect(resolveBeMusicSkinSettings({ settings: SETTINGS }, { theme: 1, judgeOffset: '4', glow: 0 })).toEqual({ + theme: 'neon', + judgeOffset: 0, + glow: true, + }); + }); + + it('drops stored keys the schema no longer declares', () => { + expect(resolveBeMusicSkinSettings({ settings: [GLOW] }, { glow: false, removed: 3 })).toEqual({ glow: false }); + }); +}); + +describe('validateBeMusicSkinSettings', () => { + it('passes a well-formed schema, and no schema', () => { + expect(validateBeMusicSkinSettings(SETTINGS)).toEqual([]); + expect(validateBeMusicSkinSettings(undefined)).toEqual([]); + expect(validateBeMusicSkinSettings([])).toEqual([]); + }); + + it('reports duplicate and empty ids and empty labels', () => { + expect(validateBeMusicSkinSettings([GLOW, { ...GLOW, label: 'Glow 2' }])).toEqual([ + 'setting "glow": id is used more than once', + ]); + expect(validateBeMusicSkinSettings([{ ...GLOW, id: '' }])).toEqual(['settings[0]: id must not be empty']); + expect(validateBeMusicSkinSettings([{ ...GLOW, label: ' ' }])).toEqual(['setting "glow": label must not be empty']); + }); + + it('checks choice options and defaults', () => { + expect(validateBeMusicSkinSettings([{ ...THEME, options: [] }] as BeMusicSkinSetting[])).toEqual([ + 'setting "theme": options must not be empty', + ]); + expect(validateBeMusicSkinSettings([{ ...THEME, default: 'gold' } as BeMusicSkinSetting])).toEqual([ + 'setting "theme": default "gold" is not one of the option values', + ]); + expect( + validateBeMusicSkinSettings([ + { + ...THEME, + options: [ + { value: 'neon', label: 'Neon' }, + { value: 'neon', label: 'Neon again' }, + ], + } as BeMusicSkinSetting, + ]), + ).toEqual(['setting "theme": option values must be unique']); + }); + + it('checks number ranges, steps, and units', () => { + expect(validateBeMusicSkinSettings([{ ...OFFSET, default: 50 } as BeMusicSkinSetting])).toEqual([ + 'setting "judgeOffset": default 50 is outside -40..40', + ]); + expect(validateBeMusicSkinSettings([{ ...OFFSET, min: 10, max: 0 } as BeMusicSkinSetting])).toEqual([ + 'setting "judgeOffset": min and max must be finite numbers with min <= max', + ]); + expect(validateBeMusicSkinSettings([{ ...OFFSET, step: 0 } as BeMusicSkinSetting])).toEqual([ + 'setting "judgeOffset": step must be a positive number', + ]); + expect(validateBeMusicSkinSettings([{ ...OFFSET, unit: 'em' } as unknown as BeMusicSkinSetting])).toEqual([ + 'setting "judgeOffset": unit "em" must be one of px, ms, %', + ]); + }); + + it('checks toggle defaults and unknown kinds', () => { + expect(validateBeMusicSkinSettings([{ ...GLOW, default: 'on' } as unknown as BeMusicSkinSetting])).toEqual([ + 'setting "glow": default must be a boolean', + ]); + expect(validateBeMusicSkinSettings([{ ...GLOW, kind: 'color' } as unknown as BeMusicSkinSetting])).toEqual([ + 'setting "glow": kind "color" must be choice, number, or toggle', + ]); + }); +}); diff --git a/packages/skin-sdk/src/settings.ts b/packages/skin-sdk/src/settings.ts new file mode 100644 index 000000000..aadbeb804 --- /dev/null +++ b/packages/skin-sdk/src/settings.ts @@ -0,0 +1,149 @@ +import type { BeMusicSkin } from './types.ts'; + +/** A skin setting picked from a fixed list (a colour theme, a layout variant). */ +export interface BeMusicSkinChoiceSetting { + readonly kind: 'choice'; + /** Key of the value in {@link BeMusicSkinSettingValues}; unique within the skin. */ + readonly id: string; + /** Name shown in the host's settings UI. */ + readonly label: string; + /** The choices: `value` is what the skin receives, `label` what the player sees. */ + readonly options: readonly { readonly value: string; readonly label: string }[]; + /** Value used until the player picks one; must be one of the option values. */ + readonly default: string; +} + +/** A numeric skin setting with a range (an offset, an opacity). */ +export interface BeMusicSkinNumberSetting { + readonly kind: 'number'; + /** Key of the value in {@link BeMusicSkinSettingValues}; unique within the skin. */ + readonly id: string; + /** Name shown in the host's settings UI. */ + readonly label: string; + readonly min: number; + readonly max: number; + /** Increment the host's slider moves by. */ + readonly step?: number; + /** Value used until the player picks one; must lie in `min..max`. */ + readonly default: number; + /** Unit shown beside the value. */ + readonly unit?: 'px' | 'ms' | '%'; +} + +/** An on / off skin setting. */ +export interface BeMusicSkinToggleSetting { + readonly kind: 'toggle'; + /** Key of the value in {@link BeMusicSkinSettingValues}; unique within the skin. */ + readonly id: string; + /** Name shown in the host's settings UI. */ + readonly label: string; + readonly default: boolean; +} + +/** One option a skin lets the player tune (see `BeMusicSkin.settings`). */ +export type BeMusicSkinSetting = BeMusicSkinChoiceSetting | BeMusicSkinNumberSetting | BeMusicSkinToggleSetting; + +/** + * The player's value for every setting a skin declares, keyed by setting id: a `string` for a choice, a `number` for a + * number, a `boolean` for a toggle. + */ +export type BeMusicSkinSettingValues = Readonly>; + +const NUMBER_UNITS: ReadonlySet = new Set(['px', 'ms', '%']); + +/** + * Problems with a skin's `settings` schema — duplicate or empty ids, empty labels, defaults outside their range or + * options — as human-readable messages. Empty when the schema is fine (or absent). `validateBeMusicSkin` includes + * these. + */ +export function validateBeMusicSkinSettings(settings: readonly BeMusicSkinSetting[] | undefined): string[] { + if (settings === undefined) return []; + if (!Array.isArray(settings)) return ['settings must be an array']; + const problems: string[] = []; + const ids = new Set(); + settings.forEach((setting: BeMusicSkinSetting, index) => { + const name = + typeof setting?.id === 'string' && setting.id !== '' ? `setting "${setting.id}"` : `settings[${index}]`; + if (typeof setting?.id !== 'string' || setting.id.trim() === '') { + problems.push(`${name}: id must not be empty`); + } else if (ids.has(setting.id)) { + problems.push(`${name}: id is used more than once`); + } else { + ids.add(setting.id); + } + if (typeof setting?.label !== 'string' || setting.label.trim() === '') { + problems.push(`${name}: label must not be empty`); + } + switch (setting?.kind) { + case 'choice': { + if (!Array.isArray(setting.options) || setting.options.length === 0) { + problems.push(`${name}: options must not be empty`); + break; + } + const values = setting.options.map((option) => option.value); + if (new Set(values).size !== values.length) problems.push(`${name}: option values must be unique`); + if (!values.includes(setting.default)) { + problems.push(`${name}: default "${String(setting.default)}" is not one of the option values`); + } + break; + } + case 'number': { + const { min, max, step } = setting; + if (!Number.isFinite(min) || !Number.isFinite(max) || min > max) { + problems.push(`${name}: min and max must be finite numbers with min <= max`); + } else if (!Number.isFinite(setting.default) || setting.default < min || setting.default > max) { + problems.push(`${name}: default ${String(setting.default)} is outside ${min}..${max}`); + } + if (step !== undefined && !(Number.isFinite(step) && step > 0)) { + problems.push(`${name}: step must be a positive number`); + } + if (setting.unit !== undefined && !NUMBER_UNITS.has(setting.unit)) { + problems.push(`${name}: unit "${String(setting.unit)}" must be one of ${[...NUMBER_UNITS].join(', ')}`); + } + break; + } + case 'toggle': + if (typeof setting.default !== 'boolean') problems.push(`${name}: default must be a boolean`); + break; + default: + problems.push( + `${name}: kind "${String((setting as { kind?: unknown })?.kind)}" must be choice, number, or toggle`, + ); + } + }); + return problems; +} + +/** + * The values a skin draws with: `stored` (what the host persisted for the skin, possibly from an older release of it) + * checked against the skin's `settings` schema. Each setting gets its stored value when that is still valid — a known + * option, a finite number (clamped into `min..max`), a boolean — and its `default` otherwise. Stored keys the schema + * no longer declares are dropped. + */ +export function resolveBeMusicSkinSettings( + skin: Pick, + stored?: Readonly>, +): BeMusicSkinSettingValues { + const values: Record = {}; + for (const setting of skin.settings ?? []) { + const value = stored?.[setting.id]; + switch (setting.kind) { + case 'choice': + values[setting.id] = + typeof value === 'string' && setting.options.some((option) => option.value === value) + ? value + : setting.default; + break; + case 'number': + values[setting.id] = + typeof value === 'number' && Number.isFinite(value) + ? Math.min(setting.max, Math.max(setting.min, value)) + : setting.default; + break; + case 'toggle': + values[setting.id] = typeof value === 'boolean' ? value : setting.default; + break; + } + } + return values; +} diff --git a/packages/skin-sdk/src/song.ts b/packages/skin-sdk/src/song.ts index 4a98e909f..c2990880d 100644 --- a/packages/skin-sdk/src/song.ts +++ b/packages/skin-sdk/src/song.ts @@ -39,3 +39,34 @@ export interface BrowserFolderNode { export type BrowserBrowseEntry = | { kind: 'folder'; folder: BrowserFolderNode } | { kind: 'song'; song: BrowserSongEntry }; + +/** A song image a host can hand a skin: the chart's `#STAGEFILE`, `#BANNER`, or `#BACKBMP`. */ +export type BeMusicSongImageKind = 'stageFile' | 'banner' | 'backBmp'; + +/** + * Clear lamp of a play or a best record, worst first: not played, failed, then the gauge the chart was cleared on + * (ASSIST EASY … EX HARD), FULL COMBO (no BAD / POOR), PERFECT (no GOOD or worse), and MAX (every note a PGREAT). + */ +export type BeMusicClearLamp = + | 'NO_PLAY' + | 'FAILED' + | 'ASSIST_EASY' + | 'EASY' + | 'NORMAL' + | 'HARD' + | 'EX_HARD' + | 'FULL_COMBO' + | 'PERFECT' + | 'MAX'; + +/** The player's best record on one chart, as a host keeps it. */ +export interface BeMusicSongRecord { + /** Best clear lamp so far. */ + lamp: BeMusicClearLamp; + exScore?: number; + maxCombo?: number; + /** Fewest BAD + POOR. */ + missCount?: number; + /** How many times the chart was played. */ + playCount?: number; +} diff --git a/packages/skin-sdk/src/types.ts b/packages/skin-sdk/src/types.ts index b3a024070..e7f87319d 100644 --- a/packages/skin-sdk/src/types.ts +++ b/packages/skin-sdk/src/types.ts @@ -1,7 +1,9 @@ import type { ChartPlayVariant } from '@be-music/player/core/lane-layout'; import type { BeMusicAudioFrame } from './audio.ts'; -import type { BeMusicResultData } from './result-data.ts'; -import type { BrowserBrowseEntry, BrowserSongEntry } from './song.ts'; +import type { BeMusicPlayOptions } from './play-options.ts'; +import type { BeMusicResultData, GaugeHistorySample, ScoreHistorySample } from './result-data.ts'; +import type { BeMusicSkinSetting, BeMusicSkinSettingValues } from './settings.ts'; +import type { BeMusicSongImageKind, BeMusicSongRecord, BrowserBrowseEntry, BrowserSongEntry } from './song.ts'; /** * be-music skin format — the code-defined skins the built-in (default) family renders with when no LR2 / beatoraja @@ -56,6 +58,17 @@ export interface BeMusicSkin; readonly select: BeMusicSelectSkin; readonly result: BeMusicResultSkin; + /** + * The decide screen shown between picking a chart and the play (title card, stage file, the chosen options). Hosts + * mount it only when a skin declares one, and go straight from select to gameplay otherwise. + */ + readonly decide?: BeMusicDecideSkin; + /** + * Options the player can tune for this skin (a colour theme, a judge-text offset, an effect toggle). Hosts build + * their settings UI from this schema, persist the values per skin, and hand them back on every frame as `settings`, + * already checked and filled in by {@link resolveBeMusicSkinSettings}. Ids must be unique. + */ + readonly settings?: readonly BeMusicSkinSetting[]; } /** A skin's maker, as shown in skin pickers and credits. */ @@ -115,6 +128,15 @@ export interface BeMusicStage { */ export type BeMusicEffectLevel = 'full' | 'reduced' | 'off'; +/** A judgement grade, best first. */ +export type BeMusicJudge = 'PERFECT' | 'GREAT' | 'GOOD' | 'BAD' | 'POOR'; + +/** A play side. `'2P'` only exists in double play. */ +export type BeMusicSide = '1P' | '2P'; + +/** An image the host hands a skin (song images, BGA layers): anything `drawImage` / `texImage2D` accepts. */ +export type BeMusicImage = CanvasImageSource; + /** Visual class of a lane, resolved from its channel — skins pick colours per class. */ export type BeMusicLaneKind = 'white' | 'black' | 'scratch'; @@ -122,7 +144,7 @@ export type BeMusicLaneKind = 'white' | 'black' | 'scratch'; export interface BeMusicLayoutLane { channel: string | undefined; kind: BeMusicLaneKind; - side: '1P' | '2P'; + side: BeMusicSide; x: number; w: number; } @@ -145,7 +167,7 @@ export interface BeMusicGameplayLayout { top: number; judgementY: number; /** Horizontal extent of each play side present (`2P` only in double play). */ - sides: Partial>; + sides: Partial>; }; /** The BGA rect, or `undefined` when the playfield leaves no room for one. */ bga: BeMusicRect | undefined; @@ -153,7 +175,7 @@ export interface BeMusicGameplayLayout { /** One side's recent judgement. */ export interface BeMusicJudgeState { - side: '1P' | '2P'; + side: BeMusicSide; /** PERFECT / GREAT / GOOD / BAD / POOR. Empty when no recent judge. */ judge?: string; combo?: number; @@ -224,6 +246,110 @@ export interface BeMusicGameplayRuntime { effects?: BeMusicEffectLevel; /** Live analysis of the mix for audio-reactive chrome (loudness, spectrum, onsets); absent without Web Audio. */ audio?: BeMusicAudioFrame; + /** + * The chart being played — level, difficulty, `#RANK`, `#TOTAL` and the rest of its header via + * `song.chart.metadata`, plus the same images {@link BeMusicGameplayFrame.songImage} resolves. + */ + song?: BrowserSongEntry; + /** + * Signed timing offset (ms) of the most recent timed judgement: positive when the press was late (SLOW), negative + * when early (FAST), 0 dead on. Only judgements with a timing carry one — autoplay hits, mine BADs and missed notes + * (POOR without a press) leave it unchanged. + */ + lastJudgeDeltaMs?: number; + /** + * The most recent timed judgements (at most 64), oldest first — drives timing meters. Same rules as + * {@link lastJudgeDeltaMs}. + */ + judgeTimings?: readonly BeMusicJudgeTiming[]; + /** SUDDEN+ / HIDDEN+ / LIFT per play side, as fractions of the lane height. */ + laneCovers?: readonly BeMusicLaneCover[]; + /** + * How long (ms) a note stays on screen, from the edge of the lane cover to the judgement line, at the current speed. + */ + visibleMs?: number; + /** IIDX-style green number (`visibleMs × 0.6`): the note's on-screen time in frames at 60 Hz. */ + greenNumber?: number; + /** Lowest, highest, and main (longest-held) BPM of the chart. */ + minBpm?: number; + maxBpm?: number; + mainBpm?: number; + /** + * Length of the song (ms) and how far the audio has played (ms), on the audio clock: it stops while paused and + * starts at 0 when the song starts. Remaining time is `songLengthMs - songElapsedMs`. Unlike {@link chartMs}, which is + * measured from the chart's first beat and runs negative through the count-in, this measures the song as heard. + */ + songLengthMs?: number; + songElapsedMs?: number; + /** + * Play-clock ms (the {@link nowMs} clock) of moments that happen at most once per play, latched when they occur and + * `undefined` before: the gauge failing mid-play, the last playable note being judged, and the chart's end (the + * outro). + */ + failedAtMs?: number; + allNotesDoneAtMs?: number; + outroAtMs?: number; + /** + * Play-clock ms when the gauge first rose above its starting value, and when it first reached its maximum. + * `undefined` until it happens. + */ + gaugeRiseAtMs?: number; + gaugeMaxAtMs?: number; + /** + * EX-score and gauge samples taken at every judgement so far, the same series the result screen receives. They are + * the host's live arrays, shared by reference: read them, never mutate them. + */ + scoreHistory?: readonly ScoreHistorySample[]; + gaugeHistory?: readonly GaugeHistorySample[]; + /** + * The judge windows of the active ruleset, in ms. Resolved from the chart's `#RANK` when the play starts; a mid-chart + * `#EXRANK` change is not reflected. + */ + judgeWindows?: BeMusicJudgeWindows; +} + +/** + * One timed judgement: when it happened and how far off the press was. `deltaMs` is positive when late (SLOW) and + * negative when early (FAST). See {@link binJudgeTimings} for a histogram. + */ +export interface BeMusicJudgeTiming { + /** Play-clock ms of the judgement. */ + atMs: number; + /** Signed offset (ms): > 0 late, < 0 early. */ + deltaMs: number; + judge: BeMusicJudge; + /** Lane channel of the judged note. */ + channel?: string; +} + +/** One play side's lane covers, as fractions (0..1) of the lane height. */ +export interface BeMusicLaneCover { + side: BeMusicSide; + /** SUDDEN+: the cover over the top of the lanes. */ + sudden: number; + /** HIDDEN+: the cover just above the judgement line. */ + hidden: number; + /** LIFT: how far the judgement line is raised. */ + lift: number; +} + +/** One judge window: how early and how late (both positive ms) a press still earns the grade. */ +export interface BeMusicJudgeWindow { + earlyMs: number; + lateMs: number; +} + +/** The windows of one note class; `emptyPoor` is the window for an empty POOR, when the ruleset has one. */ +export type BeMusicJudgeWindowSet = Record<'perfect' | 'great' | 'good' | 'bad', BeMusicJudgeWindow> & { + emptyPoor?: BeMusicJudgeWindow; +}; + +/** The active ruleset's judge windows per note class. */ +export interface BeMusicJudgeWindows { + note: BeMusicJudgeWindowSet; + scratch: BeMusicJudgeWindowSet; + /** The release of a long note that judges its end (charge / hell charge notes). */ + longNoteEnd: BeMusicJudgeWindowSet; } export interface BeMusicLaneFrame { @@ -236,6 +362,12 @@ export interface BeMusicLaneFrame { bottom: number; /** Key-beam intensity in [0, 1]: 1 while held, decaying after release. */ beam: number; + /** Play-clock ms of the lane's last key press and release (also for autoplay hits). */ + pressedAtMs?: number; + releasedAtMs?: number; + /** True while a long note in this lane is held, and the play-clock ms the hold started. */ + holding?: boolean; + holdStartedAtMs?: number; } /** A tap note as drawn this frame: its lane rect and the y its bottom edge sits at. */ @@ -244,6 +376,8 @@ export interface BeMusicNote { x: number; w: number; y: number; + /** True when autoplay (or auto-scratch) will hit this note. */ + autoplay?: boolean; } /** @@ -265,8 +399,21 @@ export interface BeMusicLongNote { w: number; top: number; bottom: number; + /** + * How the long note judges: `'ln'` (only the start counts), `'cn'` (charge note: the release is judged too), or + * `'hcn'` (hell charge note: the gauge drains while it isn't held). + */ + mode?: BeMusicLongNoteMode; + /** Where the long note is in its life: not yet reached, being held, let go early, or completed. */ + state?: BeMusicLongNoteState; + /** True when autoplay (or auto-scratch) plays this long note. */ + autoplay?: boolean; } +export type BeMusicLongNoteMode = 'ln' | 'cn' | 'hcn'; + +export type BeMusicLongNoteState = 'pending' | 'held' | 'dropped' | 'done'; + export interface BeMusicBomb { channel: string; kind: BeMusicLaneKind; @@ -278,8 +425,42 @@ export interface BeMusicBomb { elapsedMs: number; /** Stable per-hit seed so particle scatter doesn't shimmer frame to frame. */ seed: number; + /** The judgement that set the hit off. */ + judge?: BeMusicJudge; +} + +/** A measure (bar) line crossing one lane run, as drawn this frame. */ +export interface BeMusicMeasureLine { + side: BeMusicSide; + /** Left edge and width of the lane run it crosses. */ + x: number; + w: number; + y: number; } +/** + * The BGA layers showing this frame, for a skin that composites the BGA itself ({@link BeMusicGameplaySkin.drawsBga}). + * Following the BMS convention, the host hands {@link layer} with pure black (`#000000`) already turned transparent, + * while {@link base} and {@link poor} are opaque: draw all three with plain alpha blending, without keying them again. + */ +export interface BeMusicBgaFrame { + /** The base layer (`#BMP` on channel 04), or a video frame. */ + base?: BeMusicImage; + /** The layer drawn over the base (channel 07). */ + layer?: BeMusicImage; + /** The miss image (channel 06), shown while {@link poorActive}. */ + poor?: BeMusicImage; + /** True while a POOR is being shown: draw {@link poor} in place of the base and layer. */ + poorActive: boolean; +} + +/** + * Looks up one of a song's images (`#STAGEFILE`, `#BANNER`, `#BACKBMP`). Images load lazily: the first calls return + * `undefined` while the image decodes, and the screen is redrawn (on select, `revision` moves) once it is ready. Also + * `undefined` when the song has no such image or the host doesn't provide images. + */ +export type BeMusicSongImageLookup = (song: BrowserSongEntry, kind: BeMusicSongImageKind) => ImageBitmap | undefined; + /** Everything on the gameplay screen this frame. */ export interface BeMusicGameplayFrame { nowMs: number; @@ -295,10 +476,20 @@ export interface BeMusicGameplayFrame { mines: readonly BeMusicMine[]; /** Live hit effects with their age. */ bombs: readonly BeMusicBomb[]; + /** + * Measure lines on screen, one per lane run they cross. Always empty unless the skin sets + * {@link BeMusicGameplaySkin.drawsMeasureLines}. + */ + measureLines: readonly BeMusicMeasureLine[]; + /** The BGA layers to composite. Always `undefined` unless the skin sets {@link BeMusicGameplaySkin.drawsBga}. */ + bga: BeMusicBgaFrame | undefined; /** Fractional beat position in [0, 1). */ beatPhase: number; effects: BeMusicEffectLevel; audio: BeMusicAudioFrame | undefined; + /** The player's values for the skin's {@link BeMusicSkin.settings}. */ + settings: BeMusicSkinSettingValues; + songImage: BeMusicSongImageLookup; } export interface BeMusicGameplaySkin { @@ -311,6 +502,21 @@ export interface BeMusicGameplaySkin void; autoPlay: () => void; activateSearch: () => void; + /** Sets one play option. Values outside the option's choices are ignored. */ + setPlayOption(key: K, value: BeMusicPlayOptions[K]): void; + /** Moves one play option to its next (`step` 1, the default) or previous (`-1`) choice, wrapping around. */ + cyclePlayOption(key: keyof BeMusicPlayOptions, step?: 1 | -1): void; + /** Opens or closes the focused song's readme (the `.txt` beside the chart), when it has one. */ + toggleReadme(): void; } +/** The values each play option can take, for options with a fixed set of choices (numeric ones are left out). */ +export type BeMusicPlayOptionChoices = { + readonly [K in keyof BeMusicPlayOptions]?: readonly BeMusicPlayOptions[K][]; +}; + /** Everything on the select screen this frame. */ export interface BeMusicSelectFrame { designWidth: number; @@ -358,12 +575,23 @@ export interface BeMusicSelectFrame { launchAt: number | undefined; /** What is playing right now: the BGM or the chart preview (see {@link BeMusicAudioFrame}). */ audio: BeMusicAudioFrame | undefined; + /** The current play options; change them through {@link BeMusicSelectActions.setPlayOption}. */ + playOptions: Readonly; + /** The choices of every play option that has a fixed set. */ + playOptionChoices: BeMusicPlayOptionChoices; + /** The pointer in design pixels, or `undefined` when it is outside the screen (or there is none). */ + pointer: { x: number; y: number } | undefined; + /** The player's best record on `song`, or `undefined` when the host keeps none (or it was never played). */ + songRecord(song: BrowserSongEntry): BeMusicSongRecord | undefined; + songImage: BeMusicSongImageLookup; + /** The player's values for the skin's {@link BeMusicSkin.settings}. */ + settings: BeMusicSkinSettingValues; /** * Makes the rect (design pixels) run `action` when clicked — e.g. `frame.hit(x, y, w, h, frame.actions.play)`. Hit * areas last for the frame they were declared in, so declare them on every draw. `cursor` is the CSS cursor shown - * over the rect (`'pointer'` by default). + * over the rect (`'pointer'` by default). Returns whether the pointer is over the rect, for hover states. */ - hit(x: number, y: number, w: number, h: number, action: () => void, cursor?: string): void; + hit(x: number, y: number, w: number, h: number, action: () => void, cursor?: string): boolean; } export interface BeMusicSelectSkin { @@ -388,9 +616,40 @@ export interface BeMusicResultFrame { elapsedMs: number; nowMs: number; effects: BeMusicEffectLevel; + songImage: BeMusicSongImageLookup; + /** The player's values for the skin's {@link BeMusicSkin.settings}. */ + settings: BeMusicSkinSettingValues; } export interface BeMusicResultSkin { /** Draws one result frame. Called every frame while the result screen is shown. */ draw(surface: BeMusicSurface, frame: BeMusicResultFrame): void; } + +/** Everything on the decide screen this frame. */ +export interface BeMusicDecideFrame { + designWidth: number; + designHeight: number; + nowMs: number; + /** Milliseconds since the screen mounted. */ + elapsedMs: number; + /** The chart about to be played. */ + song: BrowserSongEntry; + /** The options the play will run with. */ + playOptions: Readonly; + songImage: BeMusicSongImageLookup; + effects: BeMusicEffectLevel; + /** The player's values for the skin's {@link BeMusicSkin.settings}. */ + settings: BeMusicSkinSettingValues; + audio: BeMusicAudioFrame | undefined; +} + +export interface BeMusicDecideSkin { + /** + * How long (ms) the screen shows before the play starts. Default 2000. The player can skip ahead (Enter) or go back to + * select (Escape) meanwhile. + */ + readonly durationMs?: number; + /** Draws one decide frame. Called every frame while the decide screen is shown. */ + draw(surface: BeMusicSurface, frame: BeMusicDecideFrame): void; +} From 5f54f49eccdddb0674aa283a0ddd6bda25798da8 Mon Sep 17 00:00:00 2001 From: Seiya KONNO Date: Sun, 11 Oct 2026 22:44:11 +0900 Subject: [PATCH 02/28] feat(player-web): hand be-music skins the timing of each judgement Log every judgement the engine reports with a timing offset and pass it to skins: the latest offset as runtime.lastJudgeDeltaMs, the last 64 as runtime.judgeTimings for timing meters, and the whole play as the result data's judgeTimings for histograms (#240, #253). The recent window lives in a ring buffer whose oldest-first view is only rebuilt when a judgement arrives, so reading it every frame allocates nothing. Judgements without a timing (autoplay hits, missed notes) are skipped, as the SDK documents. --- .../player-web/src/scene/core/gameplay.ts | 14 ++++ .../src/scene/core/judge-timing-log.test.ts | 66 ++++++++++++++++ .../src/scene/core/judge-timing-log.ts | 76 +++++++++++++++++++ 3 files changed, 156 insertions(+) create mode 100644 packages/player-web/src/scene/core/judge-timing-log.test.ts create mode 100644 packages/player-web/src/scene/core/judge-timing-log.ts diff --git a/packages/player-web/src/scene/core/gameplay.ts b/packages/player-web/src/scene/core/gameplay.ts index 2ed475721..7c41bc327 100644 --- a/packages/player-web/src/scene/core/gameplay.ts +++ b/packages/player-web/src/scene/core/gameplay.ts @@ -76,6 +76,7 @@ import { sanitizeBusVolume, } from '../../runtime/audio-bus.ts'; import { GameplayRecorder, type GameplayRecorderResult } from '../../recording/gameplay-recorder.ts'; +import { JudgeTimingLog } from './judge-timing-log.ts'; import { PerfTracker } from '../perf.ts'; import { type PixiSceneHost } from '../host.ts'; import { ChildPool, staggerDestroyTextures } from '../pixi-utils.ts'; @@ -919,6 +920,11 @@ export class CoreGameplayView = []; /** Same shape as `gaugeHistory`, but tracking running EX score. */ private scoreHistory: Array<{ progress: number; exScore: number }> = []; + /** + * Timed judgements of the current play, fed from the engine's judge-combo bus. Its recent window reaches skins as + * `runtime.judgeTimings`, the full list as the result data's `judgeTimings`. + */ + private readonly judgeTimings = new JudgeTimingLog(); protected lastJudge = ''; protected lastJudgeUntil = 0; /** @@ -1863,6 +1869,7 @@ export class CoreGameplayView this.maxCombo) this.maxCombo = state.combo; const judgeKind = state.judge as JudgeKind; + // Autoplay hits, mine BADs, and missed notes carry no `deltaMs`; the log skips them. + this.judgeTimings.push(this.playClock(), state.judge, state.deltaMs, state.channel); this.publishJudge(judgeKind, this.currentSeconds(), state.channel); if ((judgeKind === 'PERFECT' || judgeKind === 'GREAT') && state.channel) { this.triggerBomb(state.channel); diff --git a/packages/player-web/src/scene/core/judge-timing-log.test.ts b/packages/player-web/src/scene/core/judge-timing-log.test.ts new file mode 100644 index 000000000..0d3d473f7 --- /dev/null +++ b/packages/player-web/src/scene/core/judge-timing-log.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vite-plus/test'; +import { JudgeTimingLog, RECENT_JUDGE_TIMING_COUNT } from './judge-timing-log.ts'; + +describe('JudgeTimingLog', () => { + it('records timed judgements oldest first, with their channel', () => { + const log = new JudgeTimingLog(); + expect(log.push(100, 'PERFECT', -3, '11')).toBe(true); + expect(log.push(200, 'GREAT', 12)).toBe(true); + expect(log.recent).toEqual([ + { atMs: 100, deltaMs: -3, judge: 'PERFECT', channel: '11' }, + { atMs: 200, deltaMs: 12, judge: 'GREAT' }, + ]); + expect(log.all).toEqual(log.recent); + expect(log.lastDeltaMs).toBe(12); + }); + + it('skips judgements without a finite timing or a known grade', () => { + const log = new JudgeTimingLog(); + expect(log.push(0, 'POOR', undefined)).toBe(false); + expect(log.push(0, 'BAD', Number.NaN)).toBe(false); + expect(log.push(0, 'READY', 5)).toBe(false); + expect(log.all).toEqual([]); + expect(log.recent).toEqual([]); + expect(log.lastDeltaMs).toBeUndefined(); + }); + + it('keeps only the latest judgements in recent, oldest first, while all keeps every one', () => { + const log = new JudgeTimingLog(3); + for (let index = 0; index < 5; index += 1) log.push(index, 'GOOD', index); + expect(log.recent.map((timing) => timing.deltaMs)).toEqual([2, 3, 4]); + log.push(5, 'GOOD', 5); + expect(log.recent.map((timing) => timing.deltaMs)).toEqual([3, 4, 5]); + expect(log.all.map((timing) => timing.deltaMs)).toEqual([0, 1, 2, 3, 4, 5]); + }); + + it('defaults to the documented 64 recent judgements', () => { + const log = new JudgeTimingLog(); + for (let index = 0; index < 100; index += 1) log.push(index, 'PERFECT', index); + expect(RECENT_JUDGE_TIMING_COUNT).toBe(64); + expect(log.recent).toHaveLength(64); + expect(log.recent[0]!.deltaMs).toBe(36); + expect(log.recent.at(-1)!.deltaMs).toBe(99); + }); + + it('hands back the same recent array between judgements', () => { + const log = new JudgeTimingLog(2); + log.push(0, 'PERFECT', 1); + const first = log.recent; + expect(log.recent).toBe(first); + log.push(1, 'PERFECT', 2); + expect(log.recent).toBe(first); + }); + + it('starts a fresh all list on clear, leaving the previous one intact', () => { + const log = new JudgeTimingLog(); + log.push(0, 'PERFECT', 1); + const previous = log.all; + log.clear(); + expect(previous).toHaveLength(1); + expect(log.all).toEqual([]); + expect(log.recent).toEqual([]); + expect(log.lastDeltaMs).toBeUndefined(); + log.push(1, 'GREAT', -4); + expect(log.recent).toEqual([{ atMs: 1, deltaMs: -4, judge: 'GREAT' }]); + }); +}); diff --git a/packages/player-web/src/scene/core/judge-timing-log.ts b/packages/player-web/src/scene/core/judge-timing-log.ts new file mode 100644 index 000000000..0bb76a23a --- /dev/null +++ b/packages/player-web/src/scene/core/judge-timing-log.ts @@ -0,0 +1,76 @@ +import type { BeMusicJudge, BeMusicJudgeTiming } from '@be-music/skin-sdk'; + +/** How many judgements {@link JudgeTimingLog.recent} keeps. */ +export const RECENT_JUDGE_TIMING_COUNT = 64; + +const JUDGES: ReadonlySet = new Set(['PERFECT', 'GREAT', 'GOOD', 'BAD', 'POOR']); + +/** + * The timed judgements of one play: every one of them ({@link all}, for the result screen) and the latest + * {@link RECENT_JUDGE_TIMING_COUNT} ({@link recent}, for live timing meters). The recent ones sit in a ring buffer and + * their oldest-first view is rebuilt in place only when a judgement arrives, so reading it every frame allocates + * nothing. + */ +export class JudgeTimingLog { + private readonly ring: BeMusicJudgeTiming[] = []; + private readonly recentView: BeMusicJudgeTiming[] = []; + private head = 0; + private dirty = false; + private allTimings: BeMusicJudgeTiming[] = []; + private latest: number | undefined; + + constructor(private readonly capacity = RECENT_JUDGE_TIMING_COUNT) {} + + /** + * Records one judgement. Ignored (returns `false`) when it carries no finite timing or its grade isn't one of + * PERFECT / GREAT / GOOD / BAD / POOR. + */ + push(atMs: number, judge: string, deltaMs: number | undefined, channel?: string): boolean { + if (deltaMs === undefined || !Number.isFinite(deltaMs) || !JUDGES.has(judge)) return false; + const timing: BeMusicJudgeTiming = { atMs, deltaMs, judge: judge as BeMusicJudge }; + if (channel !== undefined) timing.channel = channel; + this.allTimings.push(timing); + this.latest = deltaMs; + if (this.ring.length < this.capacity) { + this.ring.push(timing); + } else { + this.ring[this.head] = timing; + this.head = (this.head + 1) % this.capacity; + } + this.dirty = true; + return true; + } + + /** Signed offset (ms) of the latest recorded judgement, or `undefined` before the first. */ + get lastDeltaMs(): number | undefined { + return this.latest; + } + + /** The latest judgements, oldest first. The same array every call: read it, never keep it across judgements. */ + get recent(): readonly BeMusicJudgeTiming[] { + if (this.dirty) { + const { ring, head, recentView } = this; + recentView.length = ring.length; + for (let index = 0; index < ring.length; index += 1) { + recentView[index] = ring[(head + index) % ring.length]!; + } + this.dirty = false; + } + return this.recentView; + } + + /** Every recorded judgement of the play, oldest first. */ + get all(): readonly BeMusicJudgeTiming[] { + return this.allTimings; + } + + /** Forgets everything, for a new play. {@link all} becomes a fresh array, so a previous play's list stays intact. */ + clear(): void { + this.ring.length = 0; + this.recentView.length = 0; + this.head = 0; + this.dirty = false; + this.allTimings = []; + this.latest = undefined; + } +} From ff36a4736999b4bc4a68171c425d81f71cfd05dd Mon Sep 17 00:00:00 2001 From: Seiya KONNO Date: Sun, 11 Oct 2026 22:44:20 +0900 Subject: [PATCH 03/28] feat(player-web): fill the gameplay runtime's song facts and play moments Pass be-music skins the rest of the per-play scalars the v2 runtime declares: - song: the chart being played (#239). - minBpm / maxBpm / mainBpm: worked out once per chart from its tempo points, mainBpm being the BPM held longest (#245). - songLengthMs / songElapsedMs: the song length and the audio-clock playhead, clamped to the length so the remaining time never goes negative through the audio tail (#246). - failedAtMs / allNotesDoneAtMs / outroAtMs: latched when the gauge fails mid-play, the last note is judged, and the chart ends (#247). - gaugeRiseAtMs / gaugeMaxAtMs: latched when the gauge first rises above its start and first tops out (#260). - scoreHistory / gaugeHistory: the live sample arrays, by reference (#252). --- .../src/scene/core/gameplay-bpm-range.test.ts | 46 +++++++++++++++++ .../src/scene/core/gameplay-bpm-range.ts | 49 ++++++++++++++++++ .../player-web/src/scene/core/gameplay.ts | 50 +++++++++++++++++-- 3 files changed, 141 insertions(+), 4 deletions(-) create mode 100644 packages/player-web/src/scene/core/gameplay-bpm-range.test.ts create mode 100644 packages/player-web/src/scene/core/gameplay-bpm-range.ts diff --git a/packages/player-web/src/scene/core/gameplay-bpm-range.test.ts b/packages/player-web/src/scene/core/gameplay-bpm-range.test.ts new file mode 100644 index 000000000..6af693ded --- /dev/null +++ b/packages/player-web/src/scene/core/gameplay-bpm-range.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vite-plus/test'; +import { resolveChartBpmRange } from './gameplay-bpm-range.ts'; + +describe('resolveChartBpmRange', () => { + it('reports a constant tempo as its own range', () => { + expect(resolveChartBpmRange([{ bpm: 150, seconds: 0 }], 120)).toEqual({ minBpm: 150, maxBpm: 150, mainBpm: 150 }); + }); + + it('picks the BPM held longest as the main BPM, summing every stretch at that BPM', () => { + const points = [ + { bpm: 120, seconds: 0 }, + { bpm: 240, seconds: 30 }, + { bpm: 120, seconds: 40 }, + { bpm: 60, seconds: 50 }, + ]; + // 120 holds 30 + 10 = 40 s, 240 holds 10 s, 60 holds 50..100 = 50 s. + expect(resolveChartBpmRange(points, 100)).toEqual({ minBpm: 60, maxBpm: 240, mainBpm: 60 }); + // Ending the chart at 80 s leaves 60 BPM with 30 s, so 120 wins. + expect(resolveChartBpmRange(points, 80)).toEqual({ minBpm: 60, maxBpm: 240, mainBpm: 120 }); + }); + + it('gives a tie to the BPM reached first', () => { + const points = [ + { bpm: 200, seconds: 0 }, + { bpm: 100, seconds: 10 }, + ]; + expect(resolveChartBpmRange(points, 20)?.mainBpm).toBe(200); + }); + + it('ignores zero and non-finite BPMs', () => { + const points = [ + { bpm: 140, seconds: 0 }, + { bpm: 0, seconds: 5 }, + { bpm: Number.NaN, seconds: 6 }, + { bpm: 140, seconds: 7 }, + ]; + expect(resolveChartBpmRange(points, 20)).toEqual({ minBpm: 140, maxBpm: 140, mainBpm: 140 }); + }); + + it('falls back to the given BPM when no tempo point is usable', () => { + expect(resolveChartBpmRange([], 60, 130)).toEqual({ minBpm: 130, maxBpm: 130, mainBpm: 130 }); + expect(resolveChartBpmRange([{ bpm: 0, seconds: 0 }], 60, 130)?.mainBpm).toBe(130); + expect(resolveChartBpmRange([], 60)).toBeUndefined(); + expect(resolveChartBpmRange([], 60, 0)).toBeUndefined(); + }); +}); diff --git a/packages/player-web/src/scene/core/gameplay-bpm-range.ts b/packages/player-web/src/scene/core/gameplay-bpm-range.ts new file mode 100644 index 000000000..840dd920e --- /dev/null +++ b/packages/player-web/src/scene/core/gameplay-bpm-range.ts @@ -0,0 +1,49 @@ +import type { TempoPoint } from '@be-music/audio-renderer/triggers'; + +/** The lowest, highest, and main (longest-held) BPM of a chart. */ +export interface ChartBpmRange { + minBpm: number; + maxBpm: number; + mainBpm: number; +} + +/** + * Works out a chart's BPM range from its tempo points. `mainBpm` is the BPM held for the longest time in total (each + * point holds until the next one, the last until `endSeconds`); a tie goes to the BPM reached first. Points with a + * non-positive or non-finite BPM (stops encoded as BPM 0, broken headers) are ignored. Returns `fallbackBpm` for all + * three when no point is usable, and `undefined` when there is no usable fallback either. + */ +export function resolveChartBpmRange( + tempoPoints: readonly Pick[], + endSeconds: number, + fallbackBpm?: number, +): ChartBpmRange | undefined { + let minBpm = Number.POSITIVE_INFINITY; + let maxBpm = Number.NEGATIVE_INFINITY; + const heldSeconds = new Map(); + for (let index = 0; index < tempoPoints.length; index += 1) { + const point = tempoPoints[index]!; + const { bpm } = point; + if (!Number.isFinite(bpm) || bpm <= 0) continue; + if (bpm < minBpm) minBpm = bpm; + if (bpm > maxBpm) maxBpm = bpm; + const next = tempoPoints[index + 1]; + const start = Math.max(0, point.seconds); + const end = next ? next.seconds : Math.max(endSeconds, start); + heldSeconds.set(bpm, (heldSeconds.get(bpm) ?? 0) + Math.max(0, end - start)); + } + if (minBpm > maxBpm) { + if (fallbackBpm === undefined || !Number.isFinite(fallbackBpm) || fallbackBpm <= 0) return undefined; + return { minBpm: fallbackBpm, maxBpm: fallbackBpm, mainBpm: fallbackBpm }; + } + let mainBpm = minBpm; + let longest = Number.NEGATIVE_INFINITY; + // Map iteration follows insertion order, so the strict `>` keeps the earliest BPM on a tie. + for (const [bpm, seconds] of heldSeconds) { + if (seconds > longest) { + longest = seconds; + mainBpm = bpm; + } + } + return { minBpm, maxBpm, mainBpm }; +} diff --git a/packages/player-web/src/scene/core/gameplay.ts b/packages/player-web/src/scene/core/gameplay.ts index 7c41bc327..c31385dd4 100644 --- a/packages/player-web/src/scene/core/gameplay.ts +++ b/packages/player-web/src/scene/core/gameplay.ts @@ -76,6 +76,7 @@ import { sanitizeBusVolume, } from '../../runtime/audio-bus.ts'; import { GameplayRecorder, type GameplayRecorderResult } from '../../recording/gameplay-recorder.ts'; +import { resolveChartBpmRange, type ChartBpmRange } from './gameplay-bpm-range.ts'; import { JudgeTimingLog } from './judge-timing-log.ts'; import { PerfTracker } from '../perf.ts'; import { type PixiSceneHost } from '../host.ts'; @@ -925,6 +926,19 @@ export class CoreGameplayView 0 ? resolved.metadata.bpm : this.song?.bpm, + ); this.bgaTimeline = buildBgaTimeline(resolved, resolver); // BMS spec — when `#POORBGA` is unset but `#BMP00` exists, BMP00 becomes the implicit POOR placeholder until an // explicit `#xxx06` cue fires. Mirrors the TUI BGA renderer's `poorFallbackKey` so the web side stops showing a @@ -3603,6 +3623,7 @@ export class CoreGameplayView 0 ? this.score.total : 0; const seconds = this.currentSeconds(); + const songLengthSeconds = this.resolveSongDurationSeconds(); const usesPlayer2 = this.chartPlayVariant !== '9' && this.laneChannels.some((channel) => channel.startsWith('2')); const judgeSides: SkinlessGameplayJudgeState[] = []; for (const side of ['1P', '2P'] as const) { @@ -3641,10 +3662,7 @@ export class CoreGameplayView { - const totalSeconds = this.resolveSongDurationSeconds(); - return totalSeconds > 0 ? Math.max(0, Math.min(1, seconds / totalSeconds)) : 0; - })(), + progressRatio: songLengthSeconds > 0 ? Math.max(0, Math.min(1, seconds / songLengthSeconds)) : 0, beatPhase: (() => { const beat = this.currentBeat(seconds); return beat - Math.floor(beat); @@ -3663,8 +3681,23 @@ export class CoreGameplayView summary.gauge.initial) moments.gaugeRiseAtMs ??= this.playClock(); + if (next >= summary.gauge.max) moments.gaugeMaxAtMs ??= this.playClock(); } + if (summary.gauge.failedMidPlay === true) this.playMoments.failedAtMs ??= this.playClock(); } + // `poor` excludes empty POORs, so this counts judged notes only. + const judged = summary.perfect + summary.great + summary.good + summary.bad + summary.poor; + if (summary.total > 0 && judged >= summary.total) this.playMoments.allNotesDoneAtMs ??= this.playClock(); // Note: the per-note `judged` flag is no longer synced here. The renderer's `this.notes` / // `this.mineNotes` are references into the same `PreparedPlaybackChartData` instance the engine is // judging against (handed in via `engineOptions.preparedChart`), so when the engine sets @@ -4579,6 +4620,7 @@ export class CoreGameplayView | undefined): void { if (this.disposed || this.chartEnded) return; this.chartEnded = true; + this.playMoments.outroAtMs ??= this.playClock(); const delayMs = resolvePostChartResultDelayMs(this.notes, this.timingResolver, this.currentSeconds()); // Hold the play scene for roughly two measures after the final note. This keeps the last hit / final keysound from // being swallowed by an immediate result transition while still falling back to a one-frame defer on empty charts or From 7b2626561f28ad3b375092bf43b1762105ede293 Mon Sep 17 00:00:00 2001 From: Seiya KONNO Date: Sun, 11 Oct 2026 22:44:29 +0900 Subject: [PATCH 04/28] feat(player-web): report lane press times and autoplayed notes to skins Stamp each lane's last press and release on the play clock, autoplay hits included, and hand them to be-music skins as the lane frame's pressedAtMs / releasedAtMs (#255). Mark notes and long notes the engine plays itself with autoplay: every note under autoplay, and the scratch lanes' notes under auto-scratch, mirroring the engine's single auto-scratch flag (a replay's recorded flag wins) (#260). --- .../player-web/src/scene/core/gameplay.ts | 37 ++++++++++++++++++- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/packages/player-web/src/scene/core/gameplay.ts b/packages/player-web/src/scene/core/gameplay.ts index c31385dd4..e41df7bc5 100644 --- a/packages/player-web/src/scene/core/gameplay.ts +++ b/packages/player-web/src/scene/core/gameplay.ts @@ -928,6 +928,11 @@ export class CoreGameplayView = new Set(); /** * Play-clock ms of once-per-play moments (gauge failed, last note judged, chart end, gauge first risen above its * start / reaching its max), latched as they happen and cleared per {@link prepareSong}. @@ -939,6 +944,9 @@ export class CoreGameplayView(); + private readonly laneReleasedAt = new Map(); protected lastJudge = ''; protected lastJudgeUntil = 0; /** @@ -1883,6 +1891,8 @@ export class CoreGameplayView 0 ? resolved.metadata.bpm : this.song?.bpm, ); + this.autoScratchChannels = this.resolveAutoScratchEnabled() + ? new Set(prepared.laneBindings.filter((binding) => binding.isScratch).map((binding) => binding.channel)) + : new Set(); this.bgaTimeline = buildBgaTimeline(resolved, resolver); // BMS spec — when `#POORBGA` is unset but `#BMP00` exists, BMP00 becomes the implicit POOR placeholder until an // explicit `#xxx06` cue fires. Mirrors the TUI BGA renderer's `poorFallbackKey` so the web side stops showing a @@ -2169,6 +2184,20 @@ export class CoreGameplayView Date: Sun, 11 Oct 2026 22:44:48 +0900 Subject: [PATCH 05/28] feat(player-web): add the clear lamp and judging setup to result data Fill the result data's lamp, gaugeType, judgeRuleset, and fast / slow counts (#249). The lamp comes from a new resolveClearLamp helper: FAILED when the gauge failed, MAX / PERFECT / FULL_COMBO when every note was judged PERFECT / GREAT / GOOD or better, and otherwise the lamp of the gauge the play cleared on (DEATH and HAZARD count as EX_HARD). --- .../src/scene/core/clear-lamp.test.ts | 40 +++++++++++++++++++ .../player-web/src/scene/core/clear-lamp.ts | 40 +++++++++++++++++++ .../player-web/src/scene/core/gameplay.ts | 13 +++++- 3 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 packages/player-web/src/scene/core/clear-lamp.test.ts create mode 100644 packages/player-web/src/scene/core/clear-lamp.ts diff --git a/packages/player-web/src/scene/core/clear-lamp.test.ts b/packages/player-web/src/scene/core/clear-lamp.test.ts new file mode 100644 index 000000000..4d2d58c9a --- /dev/null +++ b/packages/player-web/src/scene/core/clear-lamp.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vite-plus/test'; +import { resolveClearLamp } from './clear-lamp.ts'; + +const score = (perfect: number, great: number, good: number, total = 100) => ({ total, perfect, great, good }); + +describe('resolveClearLamp', () => { + it('reports a failed gauge as FAILED, whatever the judgements', () => { + expect(resolveClearLamp({ cleared: false, score: score(100, 0, 0), gaugeType: 'HARD' })).toBe('FAILED'); + }); + + it('grades full combos by their worst judgement', () => { + expect(resolveClearLamp({ cleared: true, score: score(100, 0, 0) })).toBe('MAX'); + expect(resolveClearLamp({ cleared: true, score: score(90, 10, 0) })).toBe('PERFECT'); + expect(resolveClearLamp({ cleared: true, score: score(80, 15, 5), gaugeType: 'EASY' })).toBe('FULL_COMBO'); + }); + + it('takes the gauge lamp for a clear with misses', () => { + const cases: [string, string][] = [ + ['GROOVE', 'NORMAL'], + ['NORMAL', 'NORMAL'], + ['EASY', 'EASY'], + ['ASSIST-EASY', 'ASSIST_EASY'], + ['ASSISTED-EASY', 'ASSIST_EASY'], + ['HARD', 'HARD'], + ['EX-HARD', 'EX_HARD'], + ['DEATH', 'EX_HARD'], + ['HAZARD', 'EX_HARD'], + ['hard', 'HARD'], + ['CLASS', 'NORMAL'], + ]; + for (const [gaugeType, lamp] of cases) { + expect(resolveClearLamp({ cleared: true, score: score(80, 10, 5), gaugeType })).toBe(lamp); + } + expect(resolveClearLamp({ cleared: true, score: score(80, 10, 5) })).toBe('NORMAL'); + }); + + it('does not call an empty chart a full combo', () => { + expect(resolveClearLamp({ cleared: true, score: score(0, 0, 0, 0), gaugeType: 'HARD' })).toBe('HARD'); + }); +}); diff --git a/packages/player-web/src/scene/core/clear-lamp.ts b/packages/player-web/src/scene/core/clear-lamp.ts new file mode 100644 index 000000000..ba4ecca4f --- /dev/null +++ b/packages/player-web/src/scene/core/clear-lamp.ts @@ -0,0 +1,40 @@ +import type { BeMusicClearLamp } from '@be-music/skin-sdk'; +import type { ScoreSummary } from '@be-music/player/core/scoring'; + +/** + * Clear lamp of a gauge, by the gauge ids the rulesets use (`GROOVE` / `EASY` / `HARD` / `DEATH` under LR2, + * `ASSIST-EASY` / `EASY` / `NORMAL` / `HARD` / `EX-HARD` / `HAZARD` under beatoraja, `ASSISTED-EASY` … under IIDX). + * DEATH and HAZARD clear on the strictest lamp below the full-combo ones. + */ +const GAUGE_LAMPS: Readonly> = { + 'ASSIST-EASY': 'ASSIST_EASY', + 'ASSISTED-EASY': 'ASSIST_EASY', + EASY: 'EASY', + GROOVE: 'NORMAL', + NORMAL: 'NORMAL', + HARD: 'HARD', + 'EX-HARD': 'EX_HARD', + DEATH: 'EX_HARD', + HAZARD: 'EX_HARD', +}; + +/** + * Resolves the clear lamp of a finished play. A failed gauge is `FAILED`; a clear where every note was judged GOOD or + * better is `FULL_COMBO`, `PERFECT` when GREAT or better, and `MAX` when every one was a PERFECT; any other clear takes + * the lamp of its gauge (`NORMAL` for gauges the table doesn't know). Empty POORs never touch a note, so they don't + * cost a full combo. + */ +export function resolveClearLamp(play: { + cleared: boolean; + score: Pick; + gaugeType?: string; +}): BeMusicClearLamp { + if (!play.cleared) return 'FAILED'; + const { total, perfect, great, good } = play.score; + if (total > 0) { + if (perfect >= total) return 'MAX'; + if (perfect + great >= total) return 'PERFECT'; + if (perfect + great + good >= total) return 'FULL_COMBO'; + } + return (play.gaugeType !== undefined ? GAUGE_LAMPS[play.gaugeType.toUpperCase()] : undefined) ?? 'NORMAL'; +} diff --git a/packages/player-web/src/scene/core/gameplay.ts b/packages/player-web/src/scene/core/gameplay.ts index e41df7bc5..57bf8aa20 100644 --- a/packages/player-web/src/scene/core/gameplay.ts +++ b/packages/player-web/src/scene/core/gameplay.ts @@ -76,6 +76,7 @@ import { sanitizeBusVolume, } from '../../runtime/audio-bus.ts'; import { GameplayRecorder, type GameplayRecorderResult } from '../../recording/gameplay-recorder.ts'; +import { resolveClearLamp } from './clear-lamp.ts'; import { resolveChartBpmRange, type ChartBpmRange } from './gameplay-bpm-range.ts'; import { JudgeTimingLog } from './judge-timing-log.ts'; import { PerfTracker } from '../perf.ts'; @@ -2184,6 +2185,11 @@ export class CoreGameplayView Date: Sun, 11 Oct 2026 22:45:01 +0900 Subject: [PATCH 06/28] chore: list the filled skin runtime fields in the changeset --- .changeset/player-web-skin-api-v2.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.changeset/player-web-skin-api-v2.md b/.changeset/player-web-skin-api-v2.md index b03cc922e..fc80df891 100644 --- a/.changeset/player-web-skin-api-v2.md +++ b/.changeset/player-web-skin-api-v2.md @@ -2,4 +2,12 @@ '@be-music/player-web': patch --- -The be-music skin registry accepts skins written for skin API revision 2 as well as revision 1. Skins can change play options and open the readme from the select screen through the new `setPlayOption`, `cyclePlayOption`, and `toggleReadme` actions, and every frame carries the skin's settings resolved to their defaults; the other revision 2 fields are left empty or `undefined` for now. `PixiPlayOptions` and its value unions are now aliases of the SDK's `BeMusicPlayOptions` types. +The be-music skin registry accepts skins written for skin API revision 2 as well as revision 1. Skins can change play options and open the readme from the select screen through the new `setPlayOption`, `cyclePlayOption`, and `toggleReadme` actions, and every frame carries the skin's settings resolved to their defaults. `PixiPlayOptions` and its value unions are now aliases of the SDK's `BeMusicPlayOptions` types. + +The gameplay scene now fills these revision 2 fields: + +- Runtime: `song`, `lastJudgeDeltaMs` / `judgeTimings`, `minBpm` / `maxBpm` / `mainBpm`, `songLengthMs` / `songElapsedMs`, `failedAtMs` / `allNotesDoneAtMs` / `outroAtMs`, `gaugeRiseAtMs` / `gaugeMaxAtMs`, and `scoreHistory` / `gaugeHistory`. +- Lanes and notes: each lane's `pressedAtMs` / `releasedAtMs`, and `autoplay` on notes and long notes played by autoplay or auto-scratch. +- Result data: `lamp`, `gaugeType`, `judgeRuleset`, `fast` / `slow`, and `judgeTimings`. + +The other revision 2 fields are left empty or `undefined` for now. From fade80bdf40c23449b855a83e19e0fbf08b038fa Mon Sep 17 00:00:00 2001 From: Seiya KONNO Date: Sun, 11 Oct 2026 22:51:50 +0900 Subject: [PATCH 07/28] feat(player-web): report lane covers and the green number to be-music skins The gameplay runtime now carries laneCovers (SUDDEN+ / HIDDEN+ per play side, as the scene's shutter draws them), visibleMs, and greenNumber, worked out the way beatoraja's duration readouts are but over the core view's pixel scroll. A skin that sets drawsLaneCover draws the covers itself and the scene stops drawing its own. --- .../player-web/src/scene/core/gameplay.ts | 55 +++++++++++++++- .../src/scene/core/lane-cover.test.ts | 65 +++++++++++++++++++ .../player-web/src/scene/core/lane-cover.ts | 48 ++++++++++++++ 3 files changed, 167 insertions(+), 1 deletion(-) create mode 100644 packages/player-web/src/scene/core/lane-cover.test.ts create mode 100644 packages/player-web/src/scene/core/lane-cover.ts diff --git a/packages/player-web/src/scene/core/gameplay.ts b/packages/player-web/src/scene/core/gameplay.ts index 57bf8aa20..c191a35be 100644 --- a/packages/player-web/src/scene/core/gameplay.ts +++ b/packages/player-web/src/scene/core/gameplay.ts @@ -79,6 +79,7 @@ import { GameplayRecorder, type GameplayRecorderResult } from '../../recording/g import { resolveClearLamp } from './clear-lamp.ts'; import { resolveChartBpmRange, type ChartBpmRange } from './gameplay-bpm-range.ts'; import { JudgeTimingLog } from './judge-timing-log.ts'; +import { fillLaneCover, resolveGreenNumber, resolveNoteVisibleMs } from './lane-cover.ts'; import { PerfTracker } from '../perf.ts'; import { type PixiSceneHost } from '../host.ts'; import { ChildPool, staggerDestroyTextures } from '../pixi-utils.ts'; @@ -139,6 +140,7 @@ import type { BeMusicBomb, BeMusicEffectLevel, BeMusicGameplayLayout, + BeMusicLaneCover, BeMusicLaneFrame, BeMusicSkin, BeMusicStage, @@ -945,6 +947,11 @@ export class CoreGameplayView(); private readonly laneReleasedAt = new Map(); @@ -3311,6 +3318,8 @@ export class CoreGameplayView