diff --git a/.changeset/be-music-skins.md b/.changeset/be-music-skins.md index d95a7be3..2db1bbbb 100644 --- a/.changeset/be-music-skins.md +++ b/.changeset/be-music-skins.md @@ -4,10 +4,44 @@ Add the be-music skin format and a second built-in skin, Synesthesia. -The default (skinless) family now renders through a `BeMusicSkin`: one object bundling the HUD chrome, lane, note, long-note, bomb, select, and result renderers over the fixed 640x480 design canvas. The shared scenes keep input, timing, and layout contracts and delegate everything they paint to the active skin, so skins can be swapped by passing `beMusicSkin` to `DefaultPixiGameplayView` / `DefaultPixiSongSelectView` / `DefaultPixiResultView` (or `PixiGameplayView` / `PixiSongSelectView` / `PixiResultView` for their skinless fallbacks). +The default (skinless) family now renders through a `BeMusicSkin`: a skin that draws every screen (gameplay, select, result) onto a canvas the player hands it, from plain per-frame data, over a fixed design canvas (the skin's `stage`, the 16:9 `wideStage` unless it declares another). The shared scenes keep input, timing, judging, and layout and show the skin's canvas, so skins can be swapped by passing `beMusicSkin` to `DefaultPixiGameplayView` / `DefaultPixiSongSelectView` / `DefaultPixiResultView`. -- New exports: `BeMusicSkin` and its context types, `createBeMusicSkinRegistry`, `resolveBeMusicLaneKind`, `resolveSelectListWindow`, `phantomSkin`, `synesthesiaSkin`, and `BUILT_IN_BE_MUSIC_SKINS`. -- Phantom is the existing poster skin, unchanged in look and still the default. -- Synesthesia is inspired by the sound-and-light games of Tetsuya Mizuguchi: deep space with a streaming 3D starfield, a scrolling wireframe floor grid, glass panels, and colour that drifts with the song and swells on the beat. Hits detonate as perspective-projected 3D particle fountains with motion trails, tilted shockwave rings, a light pillar, a core flash, and an anamorphic streak. Select has a BPM-paced star tunnel, a tumbling wireframe icosahedron, and a ring of light orbiting the focused card; result has an entrance timeline with a 3D particle ring that bursts as the rank ignites. It draws with Michroma and M PLUS 1p, which hosts should load. +- New exports: `BeMusicSkin` and its frame types, `createBeMusicSkinRegistry`, `resolveBeMusicLaneKind`, `resolveSelectListWindow`, `phantomSkin`, `synesthesiaSkin`, `latticeSkin`, `plainSkin`, and `BUILT_IN_BE_MUSIC_SKINS`. +- Skins can be made outside the player with any renderer: the skin contract and its helpers now live in the new `@be-music/skin-sdk` package, which imports no rendering framework, and `@be-music/player-web` depends on it. A skin names the canvas context it wants, may create its own renderer in an async `setup` and release it in `teardown`, and brings its framework (PixiJS, three.js, …) as its own dependency. Phantom, Synesthesia, and Lattice run their Pixi scenes on their own renderer this way, and the built-in skins live under `src/skins/` importing only `@be-music/skin-sdk`, their framework, and their own files (a test enforces the boundary). The lane geometry, song entry, result data, and audio frame types the player shares with skins move to the SDK; `@be-music/player-web` keeps re-exporting them. +- Hosts extend a skin registry at runtime: `BeMusicSkinRegistry.add` registers another skin (validated; a skin with a known id replaces it) and `subscribe` reports additions so a picker can refresh. `BUILT_IN_BE_MUSIC_SKINS` now loads Synesthesia (the default), Phantom, and Plain; Lattice still ships as `latticeSkin` for hosts to add. The demo's Debug Menu can add Lattice or a skin module from a URL (after a trust prompt). +- A skin declares `apiVersion` (`BE_MUSIC_SKIN_API_VERSION`), its own semantic `version`, and an `author` (name and optional URL), with an optional `description`, `homepage`, and `license`. `defineBeMusicSkin` / `validateBeMusicSkin` check the declaration, and `createBeMusicSkinRegistry` leaves out skins written for an unsupported API revision or with a malformed declaration (reporting them through `onRejected`) instead of letting them break the host. +- The player collects each gameplay frame's layout, lanes, notes, long notes, hit effects, and HUD values and calls the skin's `draw` once before rendering; select and result draw every frame, select with a `revision` that moves when its state changes and a `hit` callback for clickable areas. `'2d'` canvases come cleared and scaled to design pixels. Each canvas is sized to the device pixels the stage covers on screen (viewport scale × `devicePixelRatio`) and shown dot by dot with nearest sampling. A new built-in example skin, Plain (`plainSkin`), is drawn with the Canvas 2D API only. +- Each gameplay frame carries the `layout` (stage, lanes, playfield bounds, judgement line, and BGA rect) resolved by the host through `resolveGameplayLayout`, so skins place their chrome from it instead of computing geometry themselves. +- Phantom is the existing poster skin. Its showpieces now cut in on torn-paper strips (a red splash behind a ripped white rim around an ink band, with speed lines racing through it) carrying ransom-note lettering — READY? / GO!!, every 100 combo, and FULL COMBO; launching a chart splits the screen on a torn diagonal seam between a red bokeh strip on the left quarter and a black-and-white starburst carrying the title; and the result wipe tears away along a ripped edge with RESULT cut from magazines. +- Synesthesia is inspired by synaesthetic sound-and-light rhythm shooters: a black void, a world built from fine ember / gold particles with electric blue and magenta accents, data dust pouring out of the vanishing point (no speed lines radiating from it), a floor of light points, rivers of particles, point-cloud pyramids, and a roaming audio orb in the manner of classic music visualizers — a hollow shell of violet / blue / pink sparks bristling with fibres, orbited by a black moon that reflects its light. Schools of light fish (3D boids in ember, blue, and magenta) swim through the space, tightening on the beat and scattering on every key press, and the 3D backdrop roams between random camera shots — flying or hard-cutting, with a handheld drift — while the idle BGA monitor follows the orb. Hits detonate as perspective-projected 3D fountains of fine powder — dense, tiny grains with only the coarsest few trailing a hairline or carrying a glint — a light pillar, a core flash, an anamorphic streak, and a lock-on reticle that snaps shut; HUD panels are hairline frames with lock-on corners. Select snaps a lock-on reticle onto the focused chart over the particle world; result has an entrance timeline with sparks bursting as the rank ignites. It draws with Michroma and M PLUS 1p, which hosts should load. +- Lattice is a third built-in skin in the manner of precise, typographic interaction design: warm paper with graph-paper rules, ink with one cobalt accent, and a field of fine needles that turns like iron filings to the beat, to every key press, toward a combo milestone, and into a swirl on a full combo. Text decodes in with a text scramble throughout, the combo is an odometer, judgements drop in letter by letter on a spring, bead-chain pendulums swing under Verlet physics when keys strike them, select rows and level squares drop and bounce into place, and launching flips the page to ink tile by tile. It draws with Inter, Azeret Mono, and M PLUS 1p, which hosts should load. +- Skins can now react to what is playing: a new `BeMusicAudioFrame` (loudness in 0..1 and dBFS, bass / mid / high energy, a 16-band log spectrum, and an onset envelope from spectral-flux transient detection) reaches every gameplay and select frame (`audio`). Gameplay analyses the whole mix off the audio bus output; select analyses the BGM and chart preview. Each band and the bass / mid / high energies are placed within the range they covered over the last few seconds (an automatic gain), so a loud, compressed mix no longer pins the spectrum at the top: bars drop between hits and reach the top on them, and the skins' audio-driven sizes (Phantom's halftone, bursts, and rank badge, Synesthesia's orb) swing much wider. The built-in skins use it, gated by the effects level: Phantom's halftone and bursts pump on the bass and a slanted spectrum strip sits under the monitor (and along the select list); Synesthesia's dust, haze, floor, rivers, and fish schools follow loudness, bass, mids, and onsets, and the orb's latitudes swell and grow fibres with their spectrum bands, breathe on the bass, and burst on onsets; Lattice's needles stand up into an equalizer on the select page, onsets ripple through the field and strike the pendulums, the idle monitor becomes a tile equalizer, and the tally shows a live spectrum and dB readout. +- 24 KEY and 48 KEY charts no longer squeeze 24 lanes per side into the 7K playfield (8 px each): 24 KEY fills the column left of the BGA monitor (about 10.5 px a lane, the monitor stays), and 48 KEY spreads both banks across 616 px (about 12.8 px a lane). The core lane renderer and every built-in skin share the new layout, so chrome, notes, and judgements line up. +- Key beams read more clearly in every built-in skin: a pressed lane lights a laser in its colour (cool white, electric blue, and red in Phantom; each lane's light in Synesthesia; graphite, cobalt, and vermilion ink in Lattice) that stands near solid at the judgement line and eases out up the lane, so the upper lane where notes are still falling only carries a faint tint. +- 5 / 7 / 10 / 14 KEY lanes now have fixed widths in proportions taken from the arcade cabinet — a 41 px scratch, 24 px white keys, 19 px black keys (a 7K side stays 194 px) — so a 5K or DP chart never stretches its lanes sideways; the playfield narrows or widens instead. White-key lanes also get their own background tone in every built-in skin, while the scratch lane shares the black keys' background. +- Skinless lanes now sit edge to edge (the 2 px gaps between lanes are gone) and every built-in skin draws notes and long notes across the full lane width. In IIDX double play (10 / 14 KEY) the 1P and 2P banks stand 60 px apart, about 0.31 of a side as on the arcade cabinet (`IIDX_DP_SIDE_GAP`), and every built-in skin draws the judgement line and lane grid per side (`resolveLaneRuns` in the skin SDK) so nothing crosses the gap; 48 KEY keeps its banks together. +- Built-in skin text is now vertically aligned by its capitals: Pixi sizes a line box from accent and descender ink, so faces like Anton and Michroma sat visibly low in every plate and button; each face's offset is measured once and corrected through the text pivot. - Built-in skin text now rasterizes at the final device density (viewport scale x renderer resolution) instead of being magnified from the 640x480 design canvas, so labels and numbers stay crisp at any window size; glow shadows get texture padding so they no longer clip into hard rectangles. - The Pixi renderer now uses the display's full `devicePixelRatio` instead of capping it at 2x, so 3x displays render the scenes at native density. +- Showpieces for both built-in skins: a count-in before the first beat (Phantom slashes READY? into GO!!, Synesthesia condenses READY and flares START), a cut-in every 100 combo, a CLEAR LINE flourish when the gauge crosses the clear line (marking the line, not a cleared song), and a full-combo finale (Phantom's turning burst with confetti, Synesthesia's 3D spark sphere). Phantom's result screen opens with an ink wipe carrying a giant RESULT slug. +- Synesthesia enters a zone as the combo builds: from 25 / 50 / 100 / 200 combo the dust speeds up and multiplies, the floor burns hotter, more particle rivers appear, and hits throw more sparks further. Overlapping hits now dim their white flash so colour stays the hero. +- Judgements and the combo counter punch in on every hit, and lanes, rails, and the Synesthesia sky react to each key press. A BAD / POOR draws no effect at all — no shake, vignette, glitch, needle jitter, or combo-break shatter — so the playfield stays still and the next notes stay readable. +- Phantom's judgement and combo count are cut out of magazines like its READY? / GO!! count-in: each hit sets them as fresh ransom notes whose cards pop in one after another, with the judgement colour on the letters of the dark cards (the combo turns gold from 200). Both are set small so the notes falling through them stay readable. +- A PERFECT now prints as a colour-cycling GREAT, as on the arcade cabinet, in every built-in skin (each with its own palette); a plain GREAT keeps its steady colour. +- Starting a chart from select now plays a short launch outro (Phantom's ink slabs with LET'S GO!, Synesthesia's dash through black space, its particles trailing toward the chosen title at the centre) before gameplay begins; input is ignored while it plays. +- Moments and HUD frames make room for a BGA when one is shown, and HUD labels are no smaller than 9 px. The 100-combo cut-in no longer covers the lanes: it sits in the column beside them in single play and in the band below them when a double-play field fills that column. +- The built-in skins draw much less per frame. Synesthesia's floor lattice, dust, powder, point clouds, and orb shells and Lattice's needle field are drawn as GPU particles (a shared `particle-layer` that rides along with a pooled graphics) instead of `Graphics` geometry rebuilt every frame; the remaining Synesthesia light shapes are merged into one fill per colour bucket, colour maths no longer allocates, and overlapping bombs share a per-frame spark budget. HUD text takes its fill from a tint so colour changes reuse the glyph texture, the cap-height alignment no longer re-measures fonts every frame while a face is still loading, Lattice's paper grid is drawn once, Phantom's halftone fields fill in one pass, and the rebuilt-every-frame result screens reuse their label textures. On a dense 7K chart this cuts the gameplay frame work by roughly 38 % (Synesthesia), 10 % (Phantom), and the Lattice select screen by about 15 %. +- Every built-in skin's select, gameplay, and result screens are set on a layout grid — a fixed page margin and gutter, blocks sharing their top, bottom, and side lines across columns, and rows on one pitch inside each panel (score panels pair SCORE / COMBO, EX SCORE / MAX, EX RATE / RANK on shared rows; result judgement rows end on the graphs' baseline) — so related figures read as groups and the screens feel ordered. +- Built-in skin type no longer collides with rules and frames: Phantom's judge tally clears the status bar's teeth, Synesthesia's header backs its type over the playfield rails, CLEAR sits clear of the playfield frame, SOUND ONLY gets a slip over the idle monitor, the select cursor marker no longer sits on the panel frame, and Lattice's needle field leaves the footer readouts and the milestone figures clean. +- New `beMusicEffects` option (`'full'` / `'reduced'` / `'off'`) on the default gameplay, select, and result views tones down or disables flashes, particles, and animation for comfort or low-end devices; select renderers can declare an `outroMs` and receive `effects` and `launchAt` in their frame. +- The built-in skins are 16:9: they declare a new `BeMusicSkin.stage` (854x480 with a `resolveBgaRect` hook) and the default gameplay, select, and result scenes size their design canvas from it, while LR2 / beatoraja themes keep their own canvas. The playfield keeps its 4:3 pixels — same lane widths, judgement line, and note speed — and the extra width goes to the BGA and HUD: the BGA grows to a 284 px square centred between the lanes and a right-hand judge column (Phantom adds a spectrum under the tally there), shrinks to fit beside a double-play field instead of disappearing, the score panel moves to the right margin, and the track card widens so long titles keep their room. +- Select-list rows use the wider list to show what a player checks before picking a chart: the artist after the title, gimmick tags (LN, SOFLAN, STOP, MINE), the note count, the play length, and the tempo range, in fixed columns that line up down the list. +- Result screens gain a track column: title, artist, genre, mode, level, BPM, combo breaks, and the clear lamp (PERFECT / FULL COMBO / CLEAR / FAILED), replacing the title line in the header. +- While a chart's audio decodes or its BGA video transcodes, the lanes show NOW LOADING in each skin's style (Phantom's ransom note, Synesthesia's breathing light, Lattice's re-decoding caption with a sweeping plotter head) and the count-in waits for it; `mount()` now shows the playfield during this load instead of a blank stage. +- `SkinlessGameplayChromeRuntime` gains `totalNotes`, `chartMs`, `judgeAtMs`, `impulseAtMs`, `impulseKind`, `effects`, and `loading`, and the be-music lane and bomb contexts gain `combo` and `effects`. +- With no BGA, every built-in skin's monitor now reads SOUND ONLY instead of STAND BY / STANDBY / NO BGA; Phantom cuts it out of magazines as a ransom note, re-cut every so often over its turning starburst. +- Long notes share one shape across the built-in skins, taken from Synesthesia: a translucent beam in the lane colour with a centre filament between the head and tail bars (Phantom in poster tones with a paper-white filament, Lattice as a faint ink wash with an ink filament, Plain with a white centre line). +- Select-list rows label the note count NOTES instead of a bare N. +- Phantom drops the sheet-music staves from its floor wedge and result slash, and Synesthesia drops its ember horizon and every elliptical effect (hit shockwave rings, floor beat rings, the count-in ring, the 100-combo shockwave, and the result rank ring). +- The built-in skins do much less work per frame: notes share one Graphics, the select screen keeps unchanged labels' text textures through its transitions, skin canvases no longer preserve their drawing buffer, Phantom's static ground and halftone fields come from cached geometry, result screens stop rebuilding their static backgrounds and per-dot fills, Synesthesia projects its particle fields without allocating, Lattice works its needle field out by row and column, and Plain reuses its gradient and font strings. Phantom gameplay drops from about 430 to 155 ms of main-thread time per second, and Synesthesia gameplay from about 340 to 235. +- Screen transitions are smoother: with a be-music skin the play screen fades up from black as it appears and fades to black with the audio before the result or select screen takes over, the full-combo showpieces close within the post-chart hold, and the result entrances settle sooner. diff --git a/.changeset/player-variant-lane-channels.md b/.changeset/player-variant-lane-channels.md new file mode 100644 index 00000000..33213f95 --- /dev/null +++ b/.changeset/player-variant-lane-channels.md @@ -0,0 +1,5 @@ +--- +'@be-music/player': minor +--- + +Add `resolvePlayVariantLaneChannels` to `@be-music/player/core/lane-layout`: the full lane set of a chart's play variant (5 / 7 / 9 / 10 / 14 / 24 / 48 KEY) in rendering order, whether or not the chart puts notes on every lane. 9 KEY picks the PMS layout when the chart uses `22..25` and the BME layout otherwise, and lanes in use outside the variant are kept. diff --git a/.changeset/player-web-full-lanes.md b/.changeset/player-web-full-lanes.md new file mode 100644 index 00000000..5ec23d01 --- /dev/null +++ b/.changeset/player-web-full-lanes.md @@ -0,0 +1,5 @@ +--- +'@be-music/player-web': patch +--- + +Fix sparse charts playing on only the lanes they use: a chart with notes on a single lane laid out a single lane. Gameplay now lays out every lane of the chart's play variant (5 / 7 / 9 / 10 / 14 / 24 / 48 KEY), so the playfield, key bindings, and skins always show the whole keyboard. diff --git a/.changeset/skin-sdk-package.md b/.changeset/skin-sdk-package.md new file mode 100644 index 00000000..4e092069 --- /dev/null +++ b/.changeset/skin-sdk-package.md @@ -0,0 +1,10 @@ +--- +'@be-music/skin-sdk': minor +--- + +Add `@be-music/skin-sdk`, the package be-music skins for the browser player are written against. It imports no rendering framework: the player hands a skin a canvas and plain per-frame data, and the skin draws with whatever it brings (the Canvas 2D API, raw WebGL / WebGPU, PixiJS, three.js, …). + +- Skin contract: `BeMusicSkin` and its surface and frame types (gameplay, select, result), `defineBeMusicSkin`, `validateBeMusicSkin`, and `BE_MUSIC_SKIN_API_VERSION`. A skin declares its metadata (id, semantic version, author), the canvas context it wants, optional `setup` / `teardown` hooks for its own renderer, and one `draw` per screen. +- Stage and layout: `wideStage` and its BGA placement, `resolveGameplayLayout`, `resolveLaneRuns`, the lane geometry the player lays lanes out with (IIDX lane widths, the double-play gap, keyboard modes), and `resolveBeMusicLaneKind`. +- Play state and sound: judgement words, moments (count-in, combo milestones, the clear line, full combo, combo break), effect levels, the loading caption, and `audioDrive` / `bandLevel` over the `BeMusicAudioFrame` analysis. +- Song and result facts: the song entry types, select-row facts, result lamp and track rows, plus tabular figure layout and motion helpers. diff --git a/README.ja.md b/README.ja.md index cbc89778..d15c46f0 100644 --- a/README.ja.md +++ b/README.ja.md @@ -16,6 +16,7 @@ TypeScript + pnpm workspaces で構成した BMS/BMSON ツールチェーンで - `@be-music/player-tui`: autoplay、keyboard play、Music Select、BGA、SEA build を扱う terminal UI と `bms-player` CLI frontend - `@be-music/lr2-skin`: Lunatic Rave 2 skin parser、asset resolver、theme loader を renderer 非依存で提供する package - `@be-music/beatoraja-skin`: beatoraja JSON/Lua skin parser、normalizer、theme loader を renderer 非依存で提供する package +- `@be-music/skin-sdk`: browser player 向け be-music スキンを書くための描画フレームワーク非依存の SDK - `@be-music/player-web`: 選曲、built-in default / LR2 / beatoraja skin 描画、gameplay、result scene、録画を扱う browser PixiJS player core - `@be-music/player-web-demo`: folder / ZIP drop、LR2 / beatoraja theme、debug control、browser 再生を接続する private Vite demo - `@be-music/editor`: CLI エディタ (インポート・編集・エクスポート) @@ -151,6 +152,13 @@ tag は `@be-music/package-name@x.y.z` 形式で作成されます。 - `property[]`、`filepath[]`、category group、custom offset、wildcard source path、case-insensitive asset を解決 - image、imageset、value、float-value、text、slider、note、judge、gauge、graph、BPM graph、timing graph、song-list、custom event、destination、PM character element を normalize +### be-music skin SDK (`@be-music/skin-sdk`) + +- browser player の built-in family 向けスキンの契約。スキンは毎フレームのデータからキャンバスに各画面を描く +- 描画フレームワークを import しない。フレームワークはスキンが持ち込む(Canvas 2D、WebGL / WebGPU、PixiJS、three.js など) +- stage、lane layout、moment、判定表示、audio drive、曲情報、motion の helper +- [be-music スキンの作り方](docs/be-music-skin.ja.md) を参照 + ### browser player (`@be-music/player-web` / `@be-music/player-web-demo`) - dropped folder、ZIP、song / LR2 / beatoraja theme 混在 drop を扱う browser song library diff --git a/README.md b/README.md index 3a9dc94a..a340d418 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ BMS/BMSON toolchain composed of TypeScript + pnpm workspaces. - `@be-music/player-tui`: Terminal UI and `bms-player` CLI frontend for autoplay, keyboard play, Music Select, BGA, and SEA builds - `@be-music/lr2-skin`: Renderer-independent Lunatic Rave 2 skin parser, asset resolver, and theme loader - `@be-music/beatoraja-skin`: Renderer-independent beatoraja JSON/Lua skin parser, normalizer, and theme loader +- `@be-music/skin-sdk`: Framework-free SDK for writing be-music skins for the browser player - `@be-music/player-web`: Browser PixiJS player core for song selection, built-in default / LR2 / beatoraja skin rendering, gameplay, result scenes, and recording - `@be-music/player-web-demo`: Private Vite demo that wires folder/ZIP drops, LR2/beatoraja themes, debug controls, and browser playback together - `@be-music/editor`: CLI editor (import/edit/export) @@ -151,6 +152,13 @@ The semantics helper of the score is separated into `@be-music/chart`, and `@be- - Resolves `property[]`, `filepath[]`, category groups, custom offsets, wildcard source paths, and case-insensitive assets - Normalizes image, imageset, value, float-value, text, slider, note, judge, gauge, graph, BPM graph, timing graph, song-list, custom event, destination, and PM character elements +### be-music skin SDK (`@be-music/skin-sdk`) + +- Skin contract for the browser player's built-in family: the skin draws each screen onto a canvas from plain per-frame data +- Imports no rendering framework; a skin brings its own (Canvas 2D, WebGL / WebGPU, PixiJS, three.js, …) +- Stage, lane layout, moment, judgement word, audio drive, song fact, and motion helpers +- See [Writing a be-music skin](./docs/be-music-skin.md) + ### browser player (`@be-music/player-web` / `@be-music/player-web-demo`) - Browser song library for dropped folders, ZIPs, and mixed song/LR2/beatoraja theme drops diff --git a/docs/README.ja.md b/docs/README.ja.md index d96f4772..800f1fc5 100644 --- a/docs/README.ja.md +++ b/docs/README.ja.md @@ -13,6 +13,7 @@ - [プレイログ(プレイ履歴)仕様](playlog.ja.md) - [LR2 skin 実装メモ](lr2-skin.ja.md) - [beatoraja skin 実装メモ](beatoraja-skin.ja.md) +- [be-music スキンの作り方](be-music-skin.ja.md) - [BMS/BMSON 中間表現 (`@be-music/json`) 実装仕様](json-spec.ja.md) - [用語集](glossary.ja.md) diff --git a/docs/README.md b/docs/README.md index 7b9217e5..b4afc305 100644 --- a/docs/README.md +++ b/docs/README.md @@ -13,6 +13,7 @@ This directory is a Markdown collection of specifications used in the `be-music` - [Play log (play history) specification](./playlog.md) - [LR2 skin implementation notes](./lr2-skin.md) - [beatoraja skin implementation notes](./beatoraja-skin.md) +- [Writing a be-music skin](./be-music-skin.md) - [BMS/BMSON intermediate representation (`@be-music/json`) implementation specification](./json-spec.md) - [Glossary](./glossary.md) diff --git a/docs/be-music-skin.ja.md b/docs/be-music-skin.ja.md new file mode 100644 index 00000000..5104cbf8 --- /dev/null +++ b/docs/be-music-skin.ja.md @@ -0,0 +1,389 @@ +[English version](./be-music-skin.md) + +# be-music スキンの作り方 + +このガイドは、ブラウザプレイヤーの組み込み(デフォルト)ファミリー向けにスキンを作りたい人のためのものです。スキンは `@be-music/skin-sdk` パッケージに対して書きます。組み込みスキンもまさにこのパッケージだけを使っており、Synesthesia・Phantom・Lattice・Plain はプレイヤーの他の部分を一切 import していません。組み込みスキンにできることは、あなたのスキンにもできます。 + +be-music スキンはデータファイルではなくコードです。LR2 / beatoraja テーマはシーンが解釈しますが、be-music スキンはすべての画面を自分で描きます。それ以外はすべてプレイヤーの担当です。 + +- 入力、タイミング、オーディオ、判定 +- レーンのレイアウトとノーツの位置 +- 選曲リストの当たり判定 + +プレイヤーは毎フレーム、キャンバスとそのフレームの内容(プレーンなデータ)をスキンに渡します。見た目はスキンが決めます。 + +## 描画フレームワークはスキンが持ち込む + +SDK 自体は描画を行わず、描画フレームワークも import しません。キャンバスは、スキンに合ったもので描いてください。 + +| 描画手段 | `context` | 方法 | 作例 | +| ----------------------------- | ----------------------------------- | ---------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | +| Canvas 2D API | `'2d'` | そのまま描く。キャンバスはクリア済みで、デザインピクセルにスケール済み | Plain(`packages/player-web/src/skins/plain/`) | +| PixiJS | `'webgl2'` | `setup` でキャンバス上に Pixi レンダラーを作り、シーングラフを組んで `draw` で描画 | Synesthesia、Phantom、Lattice(`packages/player-web/src/skins/`、`skins/pixi-kit/` 経由) | +| three.js、素の WebGL / WebGPU | `'webgl'` / `'webgl2'` / `'webgpu'` | `setup` でレンダラーやパイプラインを作り、`draw` で描画し、`teardown` で解放 | — | + +フレームワークは、スキン自身の依存として同梱します。Plain は意図的に小さく読みやすく作ってあるので、出発点としてコピーして使えます。 + +## クイックスタート: Canvas 2D スキン + +```ts +import { BE_MUSIC_SKIN_API_VERSION, defineBeMusicSkin, resolveLaneRuns } from '@be-music/skin-sdk'; + +export default defineBeMusicSkin({ + apiVersion: BE_MUSIC_SKIN_API_VERSION, + id: 'my-skin', + label: 'My Skin', + version: '1.0.0', + author: { name: 'Your Name', url: 'https://example.com' }, + fontLoads: ['700 12px "M PLUS 1p"'], + context: '2d', + gameplay: { + draw({ context: ctx, width, height }, frame) { + ctx.fillStyle = '#000'; + ctx.fillRect(0, 0, width, height); + // BGA 動画はキャンバスの背後で再生される。上から塗らず、矩形をくり抜く。 + const bga = frame.layout.bga; + if (bga && frame.runtime.hasBga) ctx.clearRect(bga.x, bga.y, bga.w, bga.h); + for (const lane of frame.lanes) { + ctx.fillStyle = lane.kind === 'scratch' ? '#311' : '#111'; + ctx.fillRect(lane.x, lane.top, lane.w, lane.bottom - lane.top); + } + ctx.fillStyle = '#fff'; + for (const note of frame.notes) ctx.fillRect(note.x, note.y - 6, note.w, 6); + // 判定ラインはプレイサイドごとに 1 本。DP の隙間をまたがない。 + ctx.fillStyle = '#f33'; + for (const run of resolveLaneRuns(frame.lanes)) { + ctx.fillRect(run.left, frame.layout.playfield.judgementY, run.right - run.left, 2); + } + }, + }, + select: { + layout: { listX: 322, listTop: 56, listBottomInset: 28, rowHeight: 28 }, + draw({ context: ctx, width, height }, frame) { + ctx.fillStyle = '#000'; + ctx.fillRect(0, 0, width, height); + // frame.entries[frame.firstVisibleIndex …] の行を描き、ボタンをクリック可能にする: + frame.hit(16, 400, 120, 32, frame.actions.play); + }, + }, + result: { + draw({ context: ctx, width, height }, frame) { + ctx.fillStyle = '#000'; + ctx.fillRect(0, 0, width, height); + }, + }, +}); +``` + +## フレームワークを使う + +`setup` でサーフェスのキャンバス上にフレームワークのレンダラーを作り、`teardown` で解放します。`setup` はサーフェスごとに最初の描画前に 1 回呼ばれ、Promise を返すとプレイヤーはその完了を待ちます。サーフェスは画面ごとにあります(ゲームプレイ 1 つ、選曲画面ごと 1 つ、リザルト画面ごと 1 つ)。 + +```ts +import { WebGLRenderer, Container } from 'pixi.js'; +import { BE_MUSIC_SKIN_API_VERSION, defineBeMusicSkin } from '@be-music/skin-sdk'; + +const renderers = new WeakMap(); + +export default defineBeMusicSkin({ + // …メタデータ… + context: 'webgl2', + // マスクにはステンシルバッファが必要。キャンバスは BGA の上に重なるのでアルファを保つ。 + contextAttributes: { alpha: true, premultipliedAlpha: true, stencil: true, preserveDrawingBuffer: true }, + async setup(surface) { + const renderer = new WebGLRenderer(); + await renderer.init({ + canvas: surface.canvas, + context: surface.context, + width: surface.width, + height: surface.height, + resolution: surface.pixelRatio, + backgroundAlpha: 0, + }); + renderers.set(surface.canvas, renderer); + }, + teardown(surface) { + renderers.get(surface.canvas)?.destroy(); + renderers.delete(surface.canvas); + }, + gameplay: { + draw(surface, frame) { + const renderer = renderers.get(surface.canvas)!; + const stage = buildGameplayStage(frame); // このフレームのシーングラフ + renderer.render({ container: stage, clear: true }); + }, + }, + // select, result … +}); +``` + +組み込みの Pixi スキンは、`skins/pixi-kit/define-pixi-skin.ts` を通してまさにこれを行っています。このファイルは、プレイヤーのフレームデータを保持型の Pixi シーンに流し込む作例として読めます。選曲シーンを保持しておき、`frame.revision` が変わったときだけ作り直す方法も示しています。 + +## サーフェス + +- **`draw` が呼ばれるタイミング:** + - ゲームプレイは、レンダリング直前に 1 フレーム 1 回。 + - 選曲とリザルトは、表示中は毎フレーム。 +- **`'2d'` サーフェス:** クリア・スケール済みなので、デザインピクセル(デフォルトステージで 854×480)で描けます。 +- **WebGL / WebGPU サーフェス:** 全体をスキンが扱います。`surface.pixelRatio` がデザインピクセルあたりのキャンバスピクセル数です。値が変わったらレンダラーをリサイズしてください。 +- **ドットバイドット:** 各キャンバスは画面上でステージが覆うデバイスピクセル数(ビューポート倍率 × `devicePixelRatio`)で確保され、nearest サンプリングで表示されます。描いたピクセルは画面に 1 対 1 で対応します。 +- **透過:** キャンバスは BGA 動画の上に合成されます。動画を見せたい場所では BGA の矩形を透明のまま残してください。 +- **`contextAttributes`:** `canvas.getContext` にそのまま渡されます。 + +## スキンのメタデータ + +すべてのスキンが以下を宣言します。`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` 関数。[画面](#画面)を参照。 | + +`validateBeMusicSkin(skin)` は、例外を投げずに同じ問題をメッセージの配列で返します。 + +## ステージとレイアウト + +- **ステージ:** スキンが描くデザインキャンバスです。`wideStage` は組み込みスキンが使う 16:9 のステージ(854×480)で、既定値でもあります。 +- **BGA の配置:** `BeMusicStage.resolveBgaRect(playfieldRight)` がゲームプレイの BGA の位置を決めます。プレイヤーはそこに動画を合成し、スキンは同じ矩形に枠を描きます。`wideStage` は SP では BGA を大きく取り、幅の広い DP や鍵盤モードの横では縮めます。 +- **フレームごとのレイアウト:** ゲームプレイの各フレームには、ホストが解決した `layout`(`BeMusicGameplayLayout`)が付きます。 + - `stage`: 幅と高さ。 + - `lanes`: 全レーン。チャンネル、種類(`white` / `black` / `scratch`)、サイド、x、幅を持ちます。譜面のプレイバリアント(5 / 7 / 9 / 10 / 14 / 24 / 48 KEY)の全レーンが、譜面で使われないレーンも含めて常に並びます。 + - `playfield`: `left`、`right`、`centerX`、`top`、`judgementY`、`sides`(1P と、DP なら 2P の水平範囲)。 + - `bga`: BGA の矩形。プレイフィールドに余地がなければ `undefined`。 +- **クロームの配置:** ジオメトリをハードコードせず、これらの値から配置してください。DP、鍵盤モード、新しいアスペクト比などホスト側のレイアウト変更が、そのままスキンにも反映されます。 +- **DP の隙間:** 1P と 2P のバンクは 60 px 離れています。`resolveLaneRuns(lanes)` は連続したバンクごとに `{ left, right }` を 1 つ返します。判定ラインやレーングリッドはラン単位で描き、隙間をまたがないようにします。 +- **レーン幅:** レーン幅はプレイヤーが決めます。スキンはレーンに色を付けますが、大きさや位置は変えません。 + +## 画面 + +### ゲームプレイ + +`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` が始端で、押下中は判定ラインに張り付きます。 +- `bombs`: 生きているヒットエフェクト。`elapsedMs` と固定の `seed` を持ちます。 +- `runtime`: HUD の値(下記)。 +- `beatPhase`、`nowMs`、`effects`、`audio`。 + +`gameplay.bombDurationMs`(既定 300)でヒットエフェクトの寿命を指定します。 + +`runtime`(`BeMusicGameplayRuntime`)の値: + +- **曲とスコア:** + - `songTitle`、`songArtist`、`bpm`、`hiSpeed` + - `score`、`exScore`、`exScoreMax`、`combo`、`maxCombo` + - 判定数(`perfect` 〜 `poor`、`fast`、`slow`) + - `rank`、`totalNotes` +- **ゲージ:** `gauge`、`clearThreshold`、`gaugeLabel`、`gaugeSurvival` +- **判定:** `lastJudge`、`judgeSides`(サイドごとの直近の判定とコンボ) +- **プレイ状態:** `autoplay`、`hasBga`、`loading`(音声デコードや BGA 変換の間は NOW LOADING を表示)、`progressRatio`、`chartMs`、`beatPhase`、`nowMs` +- **イベント時刻:** `judgeAtMs`、`impulseAtMs`、`impulseKind`(直近の判定とキー入力。パンチやインパルスの演出用) +- **快適性とサウンド:** `effects`、`audio` + +### 選曲 + +- **`select.layout`:** `listX`、`listTop`、`listBottomInset`、`rowHeight`。シーンはこの値で行の当たり判定をするので、描く行と一致させてください。 +- **`select.outroMs`:** 曲決定後のアウトロの長さ。終わるまで `frame.launchAt` を設定したまま描画が続き、その間の入力は無視されます。 +- **`select.draw(surface, frame)`** は `BeMusicSelectFrame` を受け取ります。 + - `entries`、`selectedIndex`、`firstVisibleIndex`、`visibleRows`、`focusedSong` + - `folderLabel`、`searchQuery`、`totalCharts` + - `actions`(`play`、`autoPlay`、`activateSearch`) + - `revision`: 選曲の状態(カーソル、フォルダ、検索、リスト内容)が変わるたびに増えます。保持型のシーングラフは、これが変わったときだけ作り直せば済みます。 + - 登場やフォーカス移動のトランジション用の `sceneStartedAt`、`cursorChangedAt` + - `nowMs`、`effects`、`launchAt`、`audio` + - `hit(x, y, w, h, action, cursor?)`: 矩形をそのフレームの間だけクリック可能にします。クリック領域は描画のたびに宣言してください。 + +### リザルト + +`result.draw(surface, frame)` は `BeMusicResultFrame` を受け取ります。 + +- `result`(`BeMusicResultData`): スコア、判定数、ゲージとスコアの推移、プレイログ。 +- `rankLabel`(IIDX の DJ LEVEL)、`ratePercent`。 +- `elapsedMs`: シーン開始からの経過。登場演出をスキップした後は `Infinity`。 +- `nowMs`、`effects`。 + +`resolveResultLamp` と `resolveResultTrackRows` が、組み込みスキンの表示するクリアランプとトラック行を返します。 + +## SDK ヘルパー + +| 分野 | ヘルパー | +| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| ステージ | `wideStage`、`resolveStageBgaRect`、`STAGE_WIDTH`、`STAGE_HEIGHT`、`STAGE_MARGIN`、`STAGE_SIDE_COLUMN`、`STAGE_BGA_BAND` | +| レイアウト | `resolveGameplayLayout`、`resolveLaneRuns`、`resolveMilestoneArea` | +| 判定 | `judgeDisplayWord`(PERFECT は GREAT と表示)、`isFlashingGreat`、`flashingGreatColor` | +| 演出 | `trackMoments` / `updateMoments`(カウントイン、100 コンボごと、クリアライン、フルコンボ、コンボ切れ)、`comboTier`、`momentProgress`、`impulse`、`punchScale`、`effectProfile` | +| ロード中 | `LOADING_WORD`、`loadingDots(nowMs)` | +| サウンド | `audioDrive`、`bandAt`、`bandLevel`。[音楽に反応させる](#音楽に反応させる)を参照 | +| リザルト | `resolveResultLamp`、`resolveResultTrackRows` | +| 曲情報 | `resolveSongRowFacts`、`resolveSongStats`、`resolveSongTags`、`formatSongLength`、`formatBpmRange`、`formatPlayVariantLabel` | +| テキスト | `DEFAULT_TEXT_FONT`、`layoutTabularRun`(等幅数字の配置) | +| モーション | `easeOutCubic`、`easeOutBack`、`stageProgress`、`rollUpValue`、`hash01`(決定的なゆらぎ) | + +## 音楽に反応させる + +すべての画面は `audio`(`BeMusicAudioFrame`)を受け取ります。1 フレームに 1 回サンプリングされるライブ解析です。ゲームプレイはミックス全体を、選曲は BGM と譜面プレビューを解析します。ホストに Web Audio がなければ `undefined` です。 + +- **音量:** `level`、`peak`(0..1)、`db`(dBFS)。 +- **帯域エネルギー:** `bass`、`mid`、`high`。 +- **スペクトラム:** `bands`。約 30 Hz〜14 kHz の対数間隔 16 バンド。 +- **オンセット:** `onset` はトランジェントごとに 1 へ跳ね、約 150 ms で減衰します。`onsetAtMs` は直近のオンセットの時刻です。 + +各バンドと bass / mid / high は、直近数秒に取った範囲の中での位置に正規化されています。そのため、音圧の高いコンプレッションの効いたミックスでも、上に張り付かずヒットの合間に上下します。 + +使う前に `audioDrive(audio, effects)` を通してください。エフェクトレベルに応じて値をスケールする(`'reduced'` で半分、`'off'` で無音)ので、プレイヤーの快適性設定に自動で従います。`bandLevel(drive.bands, index, count)` はバンドを任意の本数に再サンプリングし、そのままスペアナに使えます。 + +反応は読み取れる大きさにしてください。数 % の脈動は 60 fps では見えません。組み込みスキンは bass に応じて形状をおよそ 0.8〜1.6 倍に変えています。 + +## エフェクトレベル + +`effects`(`'full'` / `'reduced'` / `'off'`)はプレイヤーの快適性設定です。 + +- `'full'`: すべて有効。 +- `'reduced'`: 画面揺れや全画面フラッシュなし、パーティクル少なめ。 +- `'off'`: 静的なクロームと簡素なヒットフラッシュのみ。 + +`effectProfile(level)` は、組み込みスキンが使う量に変換します。この設定を必要とするプレイヤーがいるので、必ず尊重してください。 + +## パフォーマンス + +スキンの描画は、プレイヤーの判定、オーディオのスケジューリング、BGA のデコードと同じフレームを分け合います。120 Hz では 1 フレーム全体が約 8 ms なので、中程度のノート PC で `draw` をその半分より十分短く収めることを目指してください。リズムゲームでは、フレーム落ちが一般的なアプリ以上に体感されます。 + +### 共通のテクニック + +- **まず計測する。** ブラウザの Performance パネルでプレイを記録するか、`draw` を `performance.now()` で囲みます。ノーツの多い譜面、できるだけ高いリフレッシュレート、高い `devicePixelRatio`、`effects: 'full'` で試してください。 +- **変わったものだけ描く。** + - 静的なレイヤーは一度描いて使い回せます。背景、枠、ラベル、レーン下地など、レイアウトだけで決まるものです。オフスクリーンキャンバスやテクスチャに描いておきます。 + - 作り直すのは入力が変わったときだけにします。`layout` は譜面のレーン構成で、選曲の状態は `frame.revision` で変わります。 +- **ホットパスで確保しない。** 毎フレーム新しい配列・オブジェクト・クロージャ・文字列を作るとガベージコレクタの負荷になり、その停止がカクつきとして現れます。代わりに次のようにします。 + - バッファやプールを再利用する。 + - 色の文字列やルックアップテーブルを事前に計算しておく。 + - ヒットごとのランダムさは、パーティクルのオブジェクトを保持せず、`hash01` とシードで決定的に求める。 +- **状態ごとにまとめる。** 色・テクスチャ・ブレンドモード・シェーダーが同じものをグループにして、グループ単位でまとめて描きます。ノーツごとに状態を切り替えるのが、たいてい一番のコストです。 +- **テキストは画像として扱う。** テキストのラスタライズは高価です。 + - ラベルは一度描き、値が変わったときだけ描き直す。 + - 数字は数字アトラスから組み立てる。グリフの位置は `layoutTabularRun` で求められます。 +- **エフェクトに予算を設ける。** + - パーティクル数に上限を設け、重なったヒットにはヒットごとではなく共通の予算を割り当てる。 + - `effectProfile(effects)` に合わせて量を変える。 + - `'reduced'` と `'off'` では、装飾的なレイヤーから削る。 +- **フィルレートに注意する。** サーフェスはドットバイドットなので、`devicePixelRatio` 3 では 1 のときの 9 倍のピクセルがあります。全画面グラデーション、大きな半透明オーバーレイ、ぼかしはどれもこれに比例して重くなります。高価なソフトなレイヤーは低解像度で描いて拡大するか、事前に描いたスプライトで置き換えてください。 +- **ピクセルを読み戻さない。** `getImageData`、`readPixels`、バッファの `mapAsync` はパイプラインを止めます。 +- **`setup` で準備する。** + - 画像のデコード(`createImageBitmap`、`await image.decode()`)、アトラス作成、シェーダーのコンパイルは `setup` で済ませ、最初の描画で行わない。 + - フォントは `fontLoads` に挙げる。 + +### Canvas 2D + +- **塗り色でまとめる。** `fillStyle` は図形ごとではなく色のグループごとに 1 回設定します。必要ならノーツを種類で並べ替えます。 +- **パスをまとめる。** + - 矩形には `fillRect` を使う。 + - 同じスタイルの図形が多いときは、`beginPath()` を 1 回呼び、すべての `rect()` / `arc()` を追加してから `fill()` を 1 回呼ぶ。 +- **`shadowBlur` と `filter` を避ける。** どちらも描画呼び出しごとに非常に遅くなります。グローは事前に描いたグロースプライトを `drawImage` で描き、加算には `globalCompositeOperation = 'lighter'` を使って表現します。 +- **繰り返す形はスタンプする。** ノーツ、ボムの枠、グリフなどは一度 `OffscreenCanvas` に描き、`drawImage` でスタンプします。パスを組み直すよりずっと安く済みます。 +- **整数ピクセルに揃える。** 座標を丸める(`Math.round`)と、アンチエイリアスの処理が省け、ドットバイドットのサーフェスで輪郭もシャープになります。 +- **余計な状態変更を避ける。** `font`、`globalAlpha`、合成モードの変更にはコストがかかります。`measureText` の結果はキャッシュします。 +- **二重にクリアしない。** サーフェスは毎フレームクリア済みで渡されます。 + +### WebGL / WebGL 2 + +- **描画呼び出しを減らす。** + - ノーツ全部(パーティクル全部)をインスタンシングで 1 回で描く。WebGL 2 は `drawArraysInstanced`、WebGL 1 は `ANGLE_instanced_arrays` を使います。 + - 画像はテクスチャアトラスにまとめる。 + - vertex array object を使い、描画ごとの属性設定を省く。 +- **GPU リソースを生かし続ける。** + - バッファとテクスチャは `setup` で確保する。 + - 毎フレームの更新は、事前に確保した `DYNAMIC_DRAW` バッファへの `bufferSubData` で行い、フレームごとに作成・削除しない。 +- **同期呼び出しを避ける。** + - uniform と attribute の location は 1 回だけ取得する。 + - `getError`、`getParameter`、`readPixels` はホットパスに置かない。どれも CPU に GPU の完了を待たせます。 +- **状態で並べ替える。** 描画はプログラム、テクスチャ、ブレンドモードの順に並べます。 +- **正しく合成する。** キャンバスは BGA の上に合成されるので、premultiplied な色を出力し、`blendFunc(ONE, ONE_MINUS_SRC_ALPHA)` でブレンドしてください。`contextAttributes` では `{ alpha: true, premultipliedAlpha: true }` を指定します。 +- **アンチエイリアスを見極める。** MSAA(`antialias: true`)は高密度でフィルコストを何倍にもします。ピクセル精度のスキンなら切っても構いません。 +- **コンテキストロストに備える。** キャンバスの `webglcontextlost` / `webglcontextrestored` を監視し、リソースを作り直します。 + +### WebGPU + +- **すべて先に作る。** パイプライン(`createRenderPipelineAsync`)、bind group layout、bind group、バッファは `setup` で作り、bind group はフレームごとに作らずキャッシュします。 +- **BGA に合わせて設定する。** コンテキストは `format: navigator.gpu.getPreferredCanvasFormat()` と `alphaMode: 'premultiplied'` で一度だけ設定します。こうすると透明なピクセルから BGA が見えます。 +- **フレームのエンコードを軽くする。** + - command encoder は 1 つ、render pass はできるだけ少なくする。 + - 毎フレームのデータは、永続バッファへの `queue.writeBuffer` で送る。 + - ノーツとパーティクルはインスタンシングで描く。 +- **読み戻さない。** フレームループで `mapAsync` を使わないでください。 +- **デバイスロストに備える。** `device.lost` を監視し、デバイスとリソースを作り直します。 + +### フレームワークを使う場合 + +- **シーングラフを保持する。** + - 選曲シーンは `frame.revision` が変わったときだけ作り直す。 + - フレームごとの表示オブジェクトは、作成・破棄せずプールする。`skins/pixi-kit` の `ChildPool` と `definePixiSkin` が両方の例です。 +- **PixiJS の場合:** + - `Text` オブジェクトと `TextStyle` インスタンスを再利用し、テキストのテクスチャを保つ。 + - 数千のスプライトには `ParticleContainer` を使う。 + - `eventMode = 'none'` にする(入力はプレイヤーが扱います)。 +- **three.js の場合:** + - ノーツとパーティクルには `InstancedMesh` を使い、静的なジオメトリはマージする。 + - マテリアルを使い回す。 + - `teardown` ですべて `dispose()` する。 + +## フォント + +描画に使うフェイスはすべて `fontLoads` に挙げてください。ホストはマウント前に `document.fonts.load` で読み込むので、最初のフレームが代替フォントでラスタライズされることを防げます。フェイス自体をホストが入手できるようにもしてください(フォントサービス経由、またはスキンに同梱)。 + +## パッケージングと読み込み + +- **インストール:** `@be-music/skin-sdk` をスキンの依存に追加します(`npm install @be-music/skin-sdk`)。使う描画フレームワークも同様です。`@be-music/player-web` は不要です。 +- **import の範囲:** スキンモジュールが import するのは `@be-music/skin-sdk`、選んだ描画フレームワーク、自身のファイルです。組み込みスキンもテストでこのルールを守らされているので、組み込みスキンがプレイヤーから使っているものはすべて使えます。 +- **export:** スキンはモジュールの default export にします。 +- **ホスト側:** ホストはスキンをレジストリに登録します。 + + ```ts + import { BUILT_IN_BE_MUSIC_SKINS, DefaultPixiGameplayView } from '@be-music/player-web/scenes'; + import { createBeMusicSkinRegistry } from '@be-music/player-web/skin'; + import mySkin from 'my-be-music-skin'; + + const skins = createBeMusicSkinRegistry(BUILT_IN_BE_MUSIC_SKINS); + const problems = skins.add(mySkin); // 追加できれば []、拒否されたらその理由 + const skin = skins.resolve('my-skin'); // 未知の id なら最初のスキンにフォールバック + // デフォルトファミリーのビューに渡す: new DefaultPixiGameplayView({ …, beMusicSkin: skin }) + ``` + + - `add` はスキンを検査します。同じ id が登録済みなら置き換えるので、開発中の再読み込みに便利です。 + - `subscribe(listener)` で追加を通知でき、ピッカーを更新できます。 + - 検査に通らないスキン(未対応の `apiVersion` など)は、ホストを壊さずに除外されます。 + +- **組み込みスキン:** `BUILT_IN_BE_MUSIC_SKINS` には Synesthesia(既定)、Phantom、Plain が入っています。Lattice は `latticeSkin` として同梱されており、ホストが追加します。 +- **デモ:** デモの Debug Menu から、モジュール URL でスキンを追加できます(_Add skin_ → _Skin module URL_)。スキンはページの全権限で動くため、デモは先に確認を求めます。信頼できるスキンだけを読み込んでください。 + +## バージョニング + +- **`version` を上げるとき:** スキンをリリースするたびに上げます。プレイヤーから見たセマンティックバージョニングに従い、見た目の追加はマイナー、修正はパッチにします。 +- **`apiVersion`:** ビルドに使った SDK の定数にします。スキン API に互換性のない変更が入ると、プレイヤーは `BE_MUSIC_SKIN_API_VERSION` を上げます。古いリビジョン向けのスキンは、新しい SDK で再ビルドするまで拒否されます。 +- **`id` を固定する:** ホストは選択中のスキンを `id` で保存します。 + +## チェックリスト + +- [ ] メタデータが `validateBeMusicSkin` を通る。 +- [ ] `setup` で作ったフレームワークのリソースを `teardown` で解放している。 +- [ ] クロームは `layout` から配置し、判定ラインはレーンランごとに描いている。 +- [ ] `runtime.hasBga` のとき BGA の矩形を透明のまま残している。 +- [ ] `runtime.loading` の間は NOW LOADING を表示している。 +- [ ] 選曲の行が `select.layout` と揃い、ボタンは描画のたびに `frame.hit` で宣言している。 +- [ ] 音への反応は `audioDrive` を通し、`effects` を尊重している。 +- [ ] 使うフェイスをすべて `fontLoads` に挙げている。 +- [ ] ノーツの多い譜面・高密度でも `draw` が 1 フレームの半分を十分下回り、毎フレームの確保やピクセルの読み戻しがない。 +- [ ] SP、DP、5 / 7 / 9 / 10 / 14 / 24 / 48 KEY、BGA あり・なしで確認した。 diff --git a/docs/be-music-skin.md b/docs/be-music-skin.md new file mode 100644 index 00000000..080c6831 --- /dev/null +++ b/docs/be-music-skin.md @@ -0,0 +1,389 @@ +[Japanese version](./be-music-skin.ja.md) + +# Writing a be-music skin + +This guide is for people who want to make a skin for the browser player's built-in (default) family. Skins are written against the `@be-music/skin-sdk` package. The built-in skins use exactly this package: Synesthesia, Phantom, Lattice, and Plain import nothing else from the player. Anything they do, your skin can do too. + +A be-music skin is code, not a data file. LR2 and beatoraja themes are interpreted by a scene; a be-music skin draws every screen itself. The player keeps ownership of everything else: + +- input, timing, audio, and judging; +- the lane layout and note positions; +- select-list hit-testing. + +Each frame, the player hands your skin a canvas and the frame's content as plain data. Your skin decides how it looks. + +## Bring your own renderer + +The SDK does not draw and imports no rendering framework. Draw the canvas with whatever suits your skin: + +| Renderer | `context` | How | Example | +| ---------------------------- | ------------------------------------ | ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | +| Canvas 2D API | `'2d'` | Draw directly; the canvas comes cleared and scaled to design pixels | Plain (`packages/player-web/src/skins/plain/`) | +| PixiJS | `'webgl2'` | Create a Pixi renderer on the canvas in `setup`, build a scene graph, render it in `draw` | Synesthesia, Phantom, Lattice (`packages/player-web/src/skins/`, through `skins/pixi-kit/`) | +| three.js, raw WebGL / WebGPU | `'webgl'`, `'webgl2'`, or `'webgpu'` | Create the renderer or pipelines in `setup`, render in `draw`, release them in `teardown` | — | + +Your skin bundles its own framework as a dependency. Plain is deliberately small and readable; copy it as a starting point. + +## Quick start: a Canvas 2D skin + +```ts +import { BE_MUSIC_SKIN_API_VERSION, defineBeMusicSkin, resolveLaneRuns } from '@be-music/skin-sdk'; + +export default defineBeMusicSkin({ + apiVersion: BE_MUSIC_SKIN_API_VERSION, + id: 'my-skin', + label: 'My Skin', + version: '1.0.0', + author: { name: 'Your Name', url: 'https://example.com' }, + fontLoads: ['700 12px "M PLUS 1p"'], + context: '2d', + gameplay: { + draw({ context: ctx, width, height }, frame) { + ctx.fillStyle = '#000'; + ctx.fillRect(0, 0, width, height); + // The BGA video plays behind the canvas: cut its rect out instead of painting over it. + const bga = frame.layout.bga; + if (bga && frame.runtime.hasBga) ctx.clearRect(bga.x, bga.y, bga.w, bga.h); + for (const lane of frame.lanes) { + ctx.fillStyle = lane.kind === 'scratch' ? '#311' : '#111'; + ctx.fillRect(lane.x, lane.top, lane.w, lane.bottom - lane.top); + } + ctx.fillStyle = '#fff'; + for (const note of frame.notes) ctx.fillRect(note.x, note.y - 6, note.w, 6); + // One judgement line per play side, so it never crosses the double-play gap. + ctx.fillStyle = '#f33'; + for (const run of resolveLaneRuns(frame.lanes)) { + ctx.fillRect(run.left, frame.layout.playfield.judgementY, run.right - run.left, 2); + } + }, + }, + select: { + layout: { listX: 322, listTop: 56, listBottomInset: 28, rowHeight: 28 }, + draw({ context: ctx, width, height }, frame) { + ctx.fillStyle = '#000'; + ctx.fillRect(0, 0, width, height); + // Draw rows from frame.entries[frame.firstVisibleIndex …] and make buttons clickable: + frame.hit(16, 400, 120, 32, frame.actions.play); + }, + }, + result: { + draw({ context: ctx, width, height }, frame) { + ctx.fillStyle = '#000'; + ctx.fillRect(0, 0, width, height); + }, + }, +}); +``` + +## Using a framework + +Create the framework's renderer on the surface's canvas in `setup`, and release it in `teardown`. The player calls `setup` once per surface, before the first draw, and waits for a returned promise. Each screen gets its own surface: one for gameplay, one per select screen, and one per result screen. + +```ts +import { WebGLRenderer, Container } from 'pixi.js'; +import { BE_MUSIC_SKIN_API_VERSION, defineBeMusicSkin } from '@be-music/skin-sdk'; + +const renderers = new WeakMap(); + +export default defineBeMusicSkin({ + // …metadata… + context: 'webgl2', + // Masks need a stencil buffer; the canvas sits over the BGA, so it keeps alpha. + contextAttributes: { alpha: true, premultipliedAlpha: true, stencil: true, preserveDrawingBuffer: true }, + async setup(surface) { + const renderer = new WebGLRenderer(); + await renderer.init({ + canvas: surface.canvas, + context: surface.context, + width: surface.width, + height: surface.height, + resolution: surface.pixelRatio, + backgroundAlpha: 0, + }); + renderers.set(surface.canvas, renderer); + }, + teardown(surface) { + renderers.get(surface.canvas)?.destroy(); + renderers.delete(surface.canvas); + }, + gameplay: { + draw(surface, frame) { + const renderer = renderers.get(surface.canvas)!; + const stage = buildGameplayStage(frame); // your scene graph for this frame + renderer.render({ container: stage, clear: true }); + }, + }, + // select, result … +}); +``` + +The built-in Pixi skins do exactly this through `skins/pixi-kit/define-pixi-skin.ts`. That file is a worked example of replaying the player's frame data into a retained Pixi scene. It also shows how to keep a select scene and rebuild it only when `frame.revision` changes. + +## Surfaces + +- **When `draw` runs:** + - Gameplay draws once per frame, right before the player renders. + - Select and result draw every frame while they are shown. +- **`'2d'` surfaces** come cleared and scaled, so you draw in design pixels (854×480 on the default stage). +- **WebGL and WebGPU surfaces** are entirely yours. `surface.pixelRatio` gives the canvas pixels per design pixel; resize your renderer when it changes. +- **Dot by dot:** each canvas is sized to the device pixels the stage covers on screen (viewport scale × `devicePixelRatio`) and shown with nearest sampling. Your pixels map one to one onto the screen. +- **Transparency:** the canvas is composited over the BGA video. Leave the BGA rect transparent wherever the video should show. +- **`contextAttributes`:** passed through to `canvas.getContext`. + +## Skin metadata + +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). | + +`validateBeMusicSkin(skin)` returns the same problems as a list of messages without throwing. + +## Stage and layout + +- **Stage:** the stage is the design canvas a skin draws on. `wideStage` is the 16:9 stage the built-in skins use (854×480), and the default. +- **BGA placement:** `BeMusicStage.resolveBgaRect(playfieldRight)` decides where the gameplay BGA goes. The player composites the video there, and your skin frames the same rect. `wideStage` grows the BGA beside a single-play field and shrinks it next to wide double-play or keyboard fields. +- **Layout per frame:** each gameplay frame comes with `layout` (`BeMusicGameplayLayout`), resolved by the host: + - `stage`: width and height. + - `lanes`: every lane, with its channel, kind (`white` / `black` / `scratch`), side, x, and width. The full layout of the chart's play variant (5 / 7 / 9 / 10 / 14 / 24 / 48 KEY) is always present, even for lanes the chart never uses. + - `playfield`: `left`, `right`, `centerX`, `top`, `judgementY`, and `sides` (the horizontal extent of 1P and, in double play, 2P). + - `bga`: the BGA rect, or `undefined` when the playfield leaves no room. +- **Placing chrome:** place your chrome from these values instead of hard-coding geometry. Host-side layout changes (double play, keyboard modes, new aspect ratios) then reach your skin for free. +- **Double-play gap:** the 1P and 2P banks stand 60 px apart. `resolveLaneRuns(lanes)` returns one `{ left, right }` run per contiguous bank. Draw the judgement line and lane grid per run so nothing crosses the gap. +- **Lane widths:** lane widths are fixed by the player. Skins colour lanes, but never resize or move them. + +## Screens + +### Gameplay + +`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. +- `bombs`: live hit effects with `elapsedMs` and a stable `seed`. +- `runtime`: the HUD values (below). +- `beatPhase`, `nowMs`, `effects`, and `audio`. + +`gameplay.bombDurationMs` (default 300) sets how long a hit effect lives. + +`runtime` (`BeMusicGameplayRuntime`) carries: + +- **Song and score:** + - `songTitle`, `songArtist`, `bpm`, `hiSpeed` + - `score`, `exScore`, `exScoreMax`, `combo`, `maxCombo` + - judgement counts (`perfect` … `poor`, `fast`, `slow`) + - `rank`, `totalNotes` +- **Gauge:** `gauge`, `clearThreshold`, `gaugeLabel`, `gaugeSurvival` +- **Judgements:** `lastJudge` and `judgeSides` (the latest judgement and combo per side) +- **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` + +### Select + +- **`select.layout`:** `listX`, `listTop`, `listBottomInset`, and `rowHeight`. The scene hit-tests rows with these numbers, so the rows you draw must match them. +- **`select.outroMs`:** length of the launch outro. After a chart is picked, the screen keeps drawing with `frame.launchAt` set until it ends, and ignores input meanwhile. +- **`select.draw(surface, frame)`** receives a `BeMusicSelectFrame`: + - `entries`, `selectedIndex`, `firstVisibleIndex`, `visibleRows`, `focusedSong` + - `folderLabel`, `searchQuery`, `totalCharts` + - `actions` (`play`, `autoPlay`, `activateSearch`) + - `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. + +### Result + +`result.draw(surface, frame)` receives a `BeMusicResultFrame`: + +- `result` (`BeMusicResultData`): scores, judgement counts, gauge and score history, and the play log. +- `rankLabel` (IIDX DJ level) and `ratePercent`. +- `elapsedMs` since the scene mounted, or `Infinity` once the player skipped the entrance. +- `nowMs` and `effects`. + +`resolveResultLamp` and `resolveResultTrackRows` give the clear lamp and track rows the built-in skins show. + +## SDK helpers + +| Area | Helpers | +| ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Stage | `wideStage`, `resolveStageBgaRect`, `STAGE_WIDTH`, `STAGE_HEIGHT`, `STAGE_MARGIN`, `STAGE_SIDE_COLUMN`, `STAGE_BGA_BAND` | +| Layout | `resolveGameplayLayout`, `resolveLaneRuns`, `resolveMilestoneArea` | +| Judgements | `judgeDisplayWord` (a PERFECT prints as GREAT), `isFlashingGreat`, `flashingGreatColor` | +| Moments | `trackMoments` / `updateMoments` (count-in, every 100 combo, clear line, full combo, combo break), `comboTier`, `momentProgress`, `impulse`, `punchScale`, `effectProfile` | +| Loading | `LOADING_WORD`, `loadingDots(nowMs)` | +| Sound | `audioDrive`, `bandAt`, `bandLevel`; see [Reacting to the music](#reacting-to-the-music) | +| Result | `resolveResultLamp`, `resolveResultTrackRows` | +| Song facts | `resolveSongRowFacts`, `resolveSongStats`, `resolveSongTags`, `formatSongLength`, `formatBpmRange`, `formatPlayVariantLabel` | +| Text | `DEFAULT_TEXT_FONT`, `layoutTabularRun` (tabular figures) | +| Motion | `easeOutCubic`, `easeOutBack`, `stageProgress`, `rollUpValue`, `hash01` (deterministic jitter) | + +## Reacting to the music + +Every screen receives `audio` (`BeMusicAudioFrame`), a live analysis sampled once per frame. Gameplay analyses the whole mix; select analyses the BGM and the chart preview. It is `undefined` when the host has no Web Audio. + +- **Loudness:** `level`, `peak` (0..1), and `db` (dBFS). +- **Energy:** `bass`, `mid`, and `high`. +- **Spectrum:** `bands`, 16 log-spaced bands from about 30 Hz to 14 kHz. +- **Onsets:** `onset`, which jumps to 1 on each transient and decays over about 150 ms; `onsetAtMs` is the time of the last one. + +The bands and the bass / mid / high energies are placed within the range they covered over the last few seconds. As a result, they swing between hits even in a loud, compressed mix instead of resting at the top. + +Pass the frame through `audioDrive(audio, effects)` before using it. It scales every value by the effects level (half at `'reduced'`, silent at `'off'`), so your reactions respect the player's comfort setting automatically. `bandLevel(drive.bands, index, count)` resamples the bands to your bar count, ready for a spectrum. + +Make reactions large enough to read: a pulse of a few percent is invisible at 60 fps. The built-in skins scale shapes by roughly 0.8 to 1.6 with the bass. + +## Effects level + +`effects` (`'full'` / `'reduced'` / `'off'`) is the player's comfort setting: + +- `'full'`: everything. +- `'reduced'`: no screen shake or full-screen flashes, and lighter particle counts. +- `'off'`: static chrome and a plain hit flash only. + +`effectProfile(level)` turns it into the amounts the built-in skins use. Respect it: some players need it. + +## Performance + +A skin shares each frame with the player's judging, audio scheduling, and BGA decoding. At 120 Hz a whole frame is about 8 ms, so aim to keep your `draw` well under half of that on a mid-range laptop. Rhythm games feel a dropped frame more than most apps do. + +### General techniques + +- **Measure first.** Record a play in the browser's Performance panel, or wrap your `draw` in `performance.now()` calls. Test on a dense chart, at the highest refresh rate you can, at a high `devicePixelRatio`, and with `effects: 'full'`. +- **Draw only what changed.** + - Static layers can be rendered once and reused: backgrounds, frames, labels, lane beds, and anything else that depends only on the layout. Keep them in an offscreen canvas or texture. + - Rebuild them only when their inputs change: `layout` changes with the chart's lanes, and select state changes with `frame.revision`. +- **Don't allocate in the hot path.** New arrays, objects, closures, and strings every frame feed the garbage collector, and its pauses show up as hitches. Instead: + - Reuse buffers and pools. + - Precompute colour strings and lookup tables. + - Keep per-hit randomness deterministic with `hash01` and a seed instead of storing particle objects. +- **Batch by state.** Group everything that shares a colour, texture, blend mode, or shader, and draw each group in one go. Changing state between every note is usually the biggest cost. +- **Treat text as an image.** Rasterizing text is expensive. + - Render a label once and redraw it only when its value changes. + - Build numbers from a digit atlas. `layoutTabularRun` gives you the glyph positions. +- **Budget effects.** + - Cap particle counts, and share one budget across overlapping hits instead of giving each hit its own. + - Scale with `effectProfile(effects)`. + - Drop the decorative layers first under `'reduced'` and `'off'`. +- **Mind the fill rate.** The surface is dot by dot, so at `devicePixelRatio` 3 there are nine times as many pixels as at 1. Full-screen gradients, large translucent overlays, and blurs all scale with that. Render costly soft layers at a lower resolution and scale them up, or replace them with pre-rendered sprites. +- **Never read pixels back.** `getImageData`, `readPixels`, and buffer `mapAsync` stall the pipeline. +- **Prepare in `setup`.** + - Decode images (`createImageBitmap`, `await image.decode()`), build atlases, and compile shaders there, never in the first draw. + - List your fonts in `fontLoads`. + +### Canvas 2D + +- **Group by fill.** Set `fillStyle` once per colour group rather than once per shape; sort notes by kind if that helps. +- **Batch paths.** + - Use `fillRect` for rectangles. + - For many shapes of one style, call `beginPath()` once, add every `rect()` / `arc()`, and `fill()` once. +- **Avoid `shadowBlur` and `filter`.** Both are very slow per draw call. Fake glows with a pre-rendered glow sprite drawn with `drawImage`, using `globalCompositeOperation = 'lighter'` for additive light. +- **Stamp repeated shapes.** Draw a note, bomb frame, or glyph once into an `OffscreenCanvas` and stamp it with `drawImage`. That is far cheaper than rebuilding the path. +- **Snap to whole pixels.** Rounding coordinates (`Math.round`) avoids anti-aliasing work and keeps edges crisp on the dot-by-dot surface. +- **Avoid needless state changes.** Changing `font`, `globalAlpha`, or the composite mode costs; cache `measureText` results. +- **Don't clear twice.** The surface comes cleared every frame. + +### WebGL / WebGL 2 + +- **Cut draw calls.** + - Draw all notes (or all particles) in one call with instancing: `drawArraysInstanced` in WebGL 2, or `ANGLE_instanced_arrays` in WebGL 1. + - Pack images into texture atlases. + - Use vertex array objects to skip per-draw attribute setup. +- **Keep GPU resources alive.** + - Allocate buffers and textures in `setup`. + - Update them each frame with `bufferSubData` into a preallocated `DYNAMIC_DRAW` buffer; never create or delete them per frame. +- **Avoid synchronous calls.** + - Look up uniform and attribute locations once. + - Keep `getError`, `getParameter`, and `readPixels` out of the hot path; each forces the CPU to wait for the GPU. +- **Sort by state.** Order draws by program, then texture, then blend mode. +- **Composite correctly.** The canvas is composited over the BGA, so output premultiplied colour and blend with `blendFunc(ONE, ONE_MINUS_SRC_ALPHA)`. Request `{ alpha: true, premultipliedAlpha: true }` in `contextAttributes`. +- **Weigh anti-aliasing.** MSAA (`antialias: true`) multiplies fill cost at high density. Pixel-precise skins can turn it off. +- **Handle context loss.** Listen for `webglcontextlost` / `webglcontextrestored` on the canvas and rebuild your resources. + +### WebGPU + +- **Build everything up front.** Create pipelines (`createRenderPipelineAsync`), bind group layouts, bind groups, and buffers in `setup`; cache bind groups instead of recreating them per frame. +- **Configure for the BGA.** Configure the context once with `format: navigator.gpu.getPreferredCanvasFormat()` and `alphaMode: 'premultiplied'`, so the BGA shows through transparent pixels. +- **Encode lean frames.** + - Use one command encoder and as few render passes as possible. + - Upload per-frame data with `queue.writeBuffer` into persistent buffers. + - Draw notes and particles instanced. +- **No readback.** Avoid `mapAsync` in the frame loop. +- **Handle device loss.** Watch `device.lost` and recreate the device and resources. + +### With a framework + +- **Retain the scene graph.** + - Rebuild the select scene only when `frame.revision` changes. + - Pool per-frame display objects instead of creating and destroying them. `skins/pixi-kit` shows both, with `ChildPool` and `definePixiSkin`. +- **PixiJS specifics:** + - Reuse `Text` objects and `TextStyle` instances so texts keep their textures. + - Use `ParticleContainer` for thousands of sprites. + - Set `eventMode = 'none'`; the player handles input. +- **three.js specifics:** + - Use `InstancedMesh` for notes and particles, and merge static geometry. + - Reuse materials. + - `dispose()` everything in `teardown`. + +## Fonts + +List every face you draw with in `fontLoads`. The host loads them with `document.fonts.load` before mounting, so the first frame does not rasterize with a fallback face. Make sure the host can obtain the face itself, either from a font service or bundled with your skin. + +## Packaging and loading + +- **Install:** add `@be-music/skin-sdk` as a dependency of your skin (`npm install @be-music/skin-sdk`), along with the rendering framework you use. You don't need `@be-music/player-web`. +- **Imports:** a skin module imports `@be-music/skin-sdk`, the rendering framework it chooses, and its own files. The built-in skins are held to this rule by a test, so anything they use from the player is available to you. +- **Export:** export the skin as the module's default export. +- **Hosts:** a host registers skins in a registry: + + ```ts + import { BUILT_IN_BE_MUSIC_SKINS, DefaultPixiGameplayView } from '@be-music/player-web/scenes'; + import { createBeMusicSkinRegistry } from '@be-music/player-web/skin'; + import mySkin from 'my-be-music-skin'; + + const skins = createBeMusicSkinRegistry(BUILT_IN_BE_MUSIC_SKINS); + const problems = skins.add(mySkin); // [] when added; otherwise why it was refused + const skin = skins.resolve('my-skin'); // falls back to the first skin for unknown ids + // Pass it to the default-family views: new DefaultPixiGameplayView({ …, beMusicSkin: skin }) + ``` + + - `add` validates the skin. A skin with an id that is already registered replaces the old one, which is handy while developing. + - `subscribe(listener)` reports additions so a picker can refresh. + - Skins that fail validation, for example because of an unsupported `apiVersion`, are left out instead of breaking the host. + +- **Built-in skins:** `BUILT_IN_BE_MUSIC_SKINS` contains Synesthesia (the default), Phantom, and Plain. Lattice ships as `latticeSkin` for hosts to add. +- **Demo:** the demo's Debug Menu can add a skin from a module URL (_Add skin_ → _Skin module URL_). A skin runs with the page's full access, so the demo asks for confirmation first. Only load skins you trust. + +## Versioning + +- **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. +- **Keep `id` stable:** hosts persist the selected skin by its `id`. + +## Checklist + +- [ ] Metadata passes `validateBeMusicSkin`. +- [ ] Framework resources created in `setup` are released in `teardown`. +- [ ] Chrome is placed from `layout`, and the judgement line is drawn per lane run. +- [ ] 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. +- [ ] 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. +- [ ] Checked in single play, double play, 5 / 7 / 9 / 10 / 14 / 24 / 48 KEY, and with and without a BGA. diff --git a/docs/player-web.ja.md b/docs/player-web.ja.md index eb575bb2..b3cd7791 100644 --- a/docs/player-web.ja.md +++ b/docs/player-web.ja.md @@ -92,6 +92,9 @@ scene に依存しない LR2 Pixi helper は [`skin/lr2/render.ts`](../packages/ built-in default family は、LR2 / beatoraja theme がない場合、または host が default family を明示的に選んだ場合に使う skinless path です。 theme file なしで select、gameplay、result の表示を提供します。 +この family はすべての画面を be-music スキン(公開パッケージ `@be-music/skin-sdk` 上に書かれたコード定義のスキン)で描きます。 +スキンの作り方は [be-music スキンの作り方](./be-music-skin.ja.md) を参照してください。 + gameplay は `PixiGameplayView` と共通の engine、note renderer、BGA renderer、audio bus、input handling を共有します。 ただし default chrome は `scene/default/gameplay.ts` から `skinlessChromeRenderer` option 経由で注入します。 LR2 gameplay scene は default renderer を import せず、共有 gameplay runtime と render layer だけを提供します。 diff --git a/docs/player-web.md b/docs/player-web.md index 4ad19598..7784d169 100644 --- a/docs/player-web.md +++ b/docs/player-web.md @@ -96,6 +96,9 @@ The built-in default family is the skinless path used when no LR2 or beatoraja t explicitly chooses the default family. It provides select, gameplay, and result presentation without requiring theme files. +The family draws every screen through a be-music skin, a code-defined skin built on the public +`@be-music/skin-sdk` package. See [Writing a be-music skin](./be-music-skin.md) to make one. + Gameplay still shares the common engine, note renderer, BGA renderer, audio bus, and input handling with `PixiGameplayView`, but the default chrome is injected from `scene/default/gameplay.ts` through the `skinlessChromeRenderer` option. The LR2 gameplay scene does not import the default renderer; it only provides the diff --git a/package.json b/package.json index fa0cfad7..d144e459 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "@be-music/player": "workspace:*", "@be-music/player-tui": "workspace:*", "@be-music/player-web": "workspace:*", + "@be-music/skin-sdk": "workspace:*", "@be-music/stringifier": "workspace:*", "@be-music/utils": "workspace:*", "@changesets/cli": "^3.0.3", diff --git a/packages/player-web-demo/index.html b/packages/player-web-demo/index.html index 3bdb9d9c..30cf810a 100644 --- a/packages/player-web-demo/index.html +++ b/packages/player-web-demo/index.html @@ -7,7 +7,7 @@ be-music web player diff --git a/packages/player-web-demo/src/main.ts b/packages/player-web-demo/src/main.ts index 3ab60c1f..bd904871 100644 --- a/packages/player-web-demo/src/main.ts +++ b/packages/player-web-demo/src/main.ts @@ -3,6 +3,7 @@ // single area (`logger`, `Rectangle`). import { BUILT_IN_BE_MUSIC_SKINS, + latticeSkin, DefaultPixiGameplayView, DefaultPixiResultView, DefaultPixiSongSelectView, @@ -23,6 +24,7 @@ import { import { BeatorajaSkinAudioPlayer, createBeMusicSkinRegistry, + type BeMusicSkin, discoverBeatorajaSelectBgmPath, discoverBeatorajaSystemSoundPaths, findBeatorajaThemeBgm, @@ -141,6 +143,14 @@ app.innerHTML = DEMO_APP_HTML; /** Built-in be-music skins the default family can render with; the Debug Menu's "Built-in skin" picks one. */ const BE_MUSIC_SKINS = createBeMusicSkinRegistry(BUILT_IN_BE_MUSIC_SKINS); const BUILT_IN_SKIN_STORAGE_KEY = 'be-music-demo.built-in-skin'; +// Lattice ships with the player but isn't loaded by default; bring it back when it was the last skin picked. +if (readStoredBuiltInSkinId() === latticeSkin.id) BE_MUSIC_SKINS.add(latticeSkin); + +/** Loads an added skin's faces in the background, so its first frame doesn't rasterize with a fallback font. */ +function loadSkinFonts(skin: BeMusicSkin): void { + if (!('fonts' in document)) return; + for (const font of skin.fontLoads) void document.fonts.load(font).catch(() => {}); +} function readStoredBuiltInSkinId(): string | undefined { try { @@ -150,6 +160,19 @@ function readStoredBuiltInSkinId(): string | undefined { } } +const SKIN_EFFECTS_STORAGE_KEY = 'be-music-demo.skin-effects'; + +/** Stored effect level, else `'reduced'` when the OS asks for reduced motion, else `'full'`. */ +function readStoredSkinEffects(): 'full' | 'reduced' | 'off' { + try { + const stored = window.localStorage.getItem(SKIN_EFFECTS_STORAGE_KEY); + if (stored === 'full' || stored === 'reduced' || stored === 'off') return stored; + } catch { + // Storage blocked — fall through to the media query. + } + return window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ? 'reduced' : 'full'; +} + function storeBuiltInSkinId(id: string): void { try { window.localStorage.setItem(BUILT_IN_SKIN_STORAGE_KEY, id); @@ -197,8 +220,9 @@ const DEFAULT_UI_FONT_LOADS: readonly string[] = [ '400 22px "LINE Seed JP"', '700 18px "LINE Seed JP"', '900 32px "Azeret Mono"', - // Every built-in skin's faces, so switching skins never rasterizes a first frame with a fallback font. - ...new Set(BUILT_IN_BE_MUSIC_SKINS.flatMap((skin) => skin.fontLoads)), + // Every registered skin's faces (the built-ins plus any added at startup, such as a remembered Lattice), so switching + // skins never rasterizes a first frame with a fallback font. + ...new Set(BE_MUSIC_SKINS.skins.flatMap((skin) => skin.fontLoads)), ]; async function waitForDefaultUiFonts(): Promise { @@ -547,6 +571,7 @@ class PlayerWebDemoApp { // in the dropdown only when their theme is loaded (see {@link rebuildSkinFamilyPicker}). skinFamilyOverride: 'auto', builtInSkin: BE_MUSIC_SKINS.resolve(readStoredBuiltInSkinId()).id, + skinEffects: readStoredSkinEffects(), status: 'Ready', openFolder: () => this.elements.songInput.click(), record: () => { @@ -832,12 +857,37 @@ class PlayerWebDemoApp { this.rebuildSkinFamilyPicker(); // Built-in (be-music) skin used whenever the default family renders — i.e. no LR2 / beatoraja theme covers the // scene. Persisted so a reload keeps the pick. - gui - .add(this.guiState, 'builtInSkin', Object.fromEntries(BE_MUSIC_SKINS.skins.map((skin) => [skin.label, skin.id]))) + const skinOptions = () => Object.fromEntries(BE_MUSIC_SKINS.skins.map((skin) => [skin.label, skin.id])); + const skinPicker = gui + .add(this.guiState, 'builtInSkin', skinOptions()) .name('Built-in skin') .onChange((id: string) => { this.handleBuiltInSkinChange(id); }); + // Skins added at runtime join the picker (in place: re-adding the controller would stack a second handler). + BE_MUSIC_SKINS.subscribe(() => skinPicker.options(skinOptions()).updateDisplay()); + // Add skins: the bundled Lattice, or a skin module from a URL (its default export, built with the skin SDK). + const addSkins = gui.addFolder('Add skin'); + const skinSource = { + url: '', + addLattice: () => this.addBeMusicSkin(latticeSkin), + addFromUrl: () => void this.addBeMusicSkinFromUrl(skinSource.url), + }; + addSkins.add(skinSource, 'addLattice').name('Add Lattice'); + addSkins.add(skinSource, 'url').name('Skin module URL'); + addSkins.add(skinSource, 'addFromUrl').name('Add from URL'); + addSkins.close(); + gui + .add(this.guiState, 'skinEffects', { Full: 'full', Reduced: 'reduced', Off: 'off' }) + .name('Skin effects') + .onChange((level: 'full' | 'reduced' | 'off') => { + try { + window.localStorage.setItem(SKIN_EFFECTS_STORAGE_KEY, level); + } catch { + // Private windows / blocked storage: the pick still applies for this session. + } + this.handleSkinFamilyOverrideChange(this.guiState.skinFamilyOverride); + }); // LR2 theme picker — visible only when the most recent drop covered multiple themes (e.g. someone dropped the // entire `LR2files/Theme/` parent). Single-theme drops keep this hidden so the panel doesn't grow a useless // 1-option dropdown. @@ -1693,6 +1743,47 @@ class PlayerWebDemoApp { return BE_MUSIC_SKINS.resolve(this.guiState.builtInSkin); } + /** Registers `skin` (validated), loads its fonts, and switches to it; reports why when it is refused. */ + private addBeMusicSkin(skin: BeMusicSkin): void { + const problems = BE_MUSIC_SKINS.add(skin); + if (problems.length > 0) { + this.setStatus(`Skin "${skin.id}" was not added: ${problems.join('; ')}`); + return; + } + loadSkinFonts(skin); + this.handleBuiltInSkinChange(skin.id); + this.setStatus(`Added skin ${skin.label} ${skin.version} by ${skin.author.name}`); + } + + /** + * Imports a skin module from `url` and adds its default export. A skin is code that runs with this page's full + * access, so the user confirms the source first. + */ + private async addBeMusicSkinFromUrl(url: string): Promise { + const trimmed = url.trim(); + if (!trimmed) { + this.setStatus('Enter the URL of a skin module first'); + return; + } + if ( + !window.confirm( + `Load the skin at ${trimmed}?\n\nA skin is code that runs with full access to this page. Only load skins you trust.`, + ) + ) { + return; + } + try { + const module = (await import(/* @vite-ignore */ trimmed)) as { default?: BeMusicSkin }; + if (!module.default) { + this.setStatus('That module has no default export to use as a skin'); + return; + } + this.addBeMusicSkin(module.default); + } catch (error) { + this.setStatus(`Could not load the skin: ${(error as Error).message}`); + } + } + /** * Apply a Debug Menu built-in skin change: persist it and rebuild the persistent select scene (the same dispose-and- * rebuild the skin-family override uses). Gameplay / result pick the skin up on their next mount. @@ -3152,7 +3243,11 @@ class PlayerWebDemoApp { }; this.selectView = lr2SelectSkin ? new PixiSongSelectView({ skin: lr2SelectSkin, ...selectSceneOptions }) - : new DefaultPixiSongSelectView({ ...selectSceneOptions, beMusicSkin: this.beMusicSkin }); + : new DefaultPixiSongSelectView({ + ...selectSceneOptions, + beMusicSkin: this.beMusicSkin, + beMusicEffects: this.guiState.skinEffects, + }); await this.selectView.mount(this.sceneHost); this.selectView.setCollection(this.collection); } @@ -3310,7 +3405,11 @@ class PlayerWebDemoApp { if (playSkin === undefined) { // Default-family path: no LR2 skin loaded for this chart. `DefaultPixiGameplayView` strips the skin / invisible- // note-skin slots from its option shape, so neither value flows in here. - return new DefaultPixiGameplayView({ ...sharedOptions, beMusicSkin: this.beMusicSkin }); + return new DefaultPixiGameplayView({ + ...sharedOptions, + beMusicSkin: this.beMusicSkin, + beMusicEffects: this.guiState.skinEffects, + }); } return new PixiGameplayView({ ...sharedOptions, @@ -3460,7 +3559,11 @@ class PlayerWebDemoApp { }; this.resultView = lr2ResultSkin ? new PixiResultView({ skin: lr2ResultSkin, ...sharedResultOptions }) - : new DefaultPixiResultView({ ...sharedResultOptions, beMusicSkin: this.beMusicSkin }); + : new DefaultPixiResultView({ + ...sharedResultOptions, + beMusicSkin: this.beMusicSkin, + beMusicEffects: this.guiState.skinEffects, + }); await this.resultView.mount(this.sceneHost, data); this.gameplayView?.dispose({ preserveAudioTail: true }); this.gameplayView = undefined; diff --git a/packages/player-web-demo/src/types.ts b/packages/player-web-demo/src/types.ts index 8c8fe5fb..465492c7 100644 --- a/packages/player-web-demo/src/types.ts +++ b/packages/player-web-demo/src/types.ts @@ -53,6 +53,8 @@ export interface DemoGuiState { autoPlay: boolean; /** Id of the be-music skin the default (no-theme) family renders with. Persisted in `localStorage`. */ builtInSkin: string; + /** Showmanship level for the built-in skins. Persisted in `localStorage`. */ + skinEffects: 'full' | 'reduced' | 'off'; /** * When true, gameplay auto-pauses on tab visibility change / window blur and auto-resumes on focus. False (the * default) keeps the play scene running in the background — convenient for capturing recordings while another window diff --git a/packages/player-web-demo/vite.config.ts b/packages/player-web-demo/vite.config.ts index 50289b8c..4bb68425 100644 --- a/packages/player-web-demo/vite.config.ts +++ b/packages/player-web-demo/vite.config.ts @@ -494,6 +494,7 @@ const workspaceRootAliases: WorkspaceAlias[] = [ { find: '@be-music/lr2-skin', replacement: resolve(repositoryDir, 'packages/lr2-skin/src/index.ts') }, { find: '@be-music/parser', replacement: resolve(repositoryDir, 'packages/parser/src/index.ts') }, { find: '@be-music/player-web', replacement: resolve(repositoryDir, 'packages/player-web/src/index.ts') }, + { find: '@be-music/skin-sdk', replacement: resolve(repositoryDir, 'packages/skin-sdk/src/index.ts') }, ]; const workspaceAliases = [...workspaceSubpathAliases, ...workspaceRootAliases]; diff --git a/packages/player-web/package.json b/packages/player-web/package.json index 57e26466..25e17bea 100644 --- a/packages/player-web/package.json +++ b/packages/player-web/package.json @@ -54,6 +54,7 @@ "@be-music/lr2-skin": "workspace:*", "@be-music/parser": "workspace:*", "@be-music/player": "workspace:*", + "@be-music/skin-sdk": "workspace:*", "@be-music/utils": "workspace:*", "@ffmpeg/core": "^0.12.10", "@ffmpeg/core-mt": "^0.12.10", diff --git a/packages/player-web/src/collection/types.ts b/packages/player-web/src/collection/types.ts index 0444f257..a343b834 100644 --- a/packages/player-web/src/collection/types.ts +++ b/packages/player-web/src/collection/types.ts @@ -1,6 +1,11 @@ -import type { BeMusicJson } from '@be-music/json'; +import type { BrowserSongEntry, BrowserSongSourceKind } from '@be-music/skin-sdk'; -export type BrowserSongSourceKind = 'directory' | 'zip' | 'files'; +export type { + BrowserBrowseEntry, + BrowserFolderNode, + BrowserSongEntry, + BrowserSongSourceKind, +} from '@be-music/skin-sdk'; /** * Asset payload kept inside a {@link BrowserSongAssetSource}. Each file from the dropped bundle is either: @@ -24,48 +29,12 @@ export interface BrowserSongAssetSource { files: ReadonlyMap; } -export interface BrowserSongEntry { - id: string; - sourceId: string; - sourceLabel: string; - sourceKind: BrowserSongSourceKind; - chartPath: string; - directoryLabel: string; - fileLabel: string; - title: string; - subtitle?: string; - artist?: string; - genre?: string; - playLevel?: number | string; - bpm?: number; - totalNotes: number; - chart: BeMusicJson; -} - export interface BrowserSongCollection { sources: BrowserSongAssetSource[]; songs: BrowserSongEntry[]; errors: Array<{ sourceId: string; path?: string; message: string }>; } -/** - * One folder of songs surfaced by `groupSongsByFolder`. The label is the human-readable folder name (top-level - * directory inside the source, falling back to the source label) and `songs` are all the BMS charts whose - * `directoryLabel` resolves to it. - */ -export interface BrowserFolderNode { - label: string; - songs: readonly BrowserSongEntry[]; -} - -/** - * One entry in the bar list when navigating the song collection. A select view either shows folder bars (when at the - * root) or song bars (when inside a folder). - */ -export type BrowserBrowseEntry = - | { kind: 'folder'; folder: BrowserFolderNode } - | { kind: 'song'; song: BrowserSongEntry }; - /** * Phases reported by the dropped-folder loaders so a host UI can show a meaningful "Loading…" state instead of a frozen * unresponsive screen. diff --git a/packages/player-web/src/runtime/audio-analysis.test.ts b/packages/player-web/src/runtime/audio-analysis.test.ts new file mode 100644 index 00000000..b4711d18 --- /dev/null +++ b/packages/player-web/src/runtime/audio-analysis.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from 'vite-plus/test'; +import { + AUDIO_BAND_COUNT, + SILENT_AUDIO, + analyzeAudioFrame, + createAudioAnalysisState, + createRecentRange, + logBandBins, + normalizeToRecentRange, + type AudioFrameInput, +} from './audio-analysis.ts'; + +const SAMPLE_RATE = 48000; +const FFT = 2048; + +function frame( + overrides: Partial & { level?: number; lowBins?: number; allBins?: number }, +): AudioFrameInput { + const frequency = new Uint8Array(FFT / 2).fill(overrides.allBins ?? 0); + if (overrides.lowBins !== undefined) frequency.fill(overrides.lowBins, 0, 7); + const waveform = new Float32Array(FFT); + const amplitude = overrides.level ?? 0; + for (let index = 0; index < waveform.length; index += 1) waveform[index] = amplitude * Math.sin(index / 7); + return { frequency, waveform, sampleRate: SAMPLE_RATE, fftSize: FFT, dtSeconds: 1 / 60, nowMs: 0, ...overrides }; +} + +describe('logBandBins', () => { + it('covers the range with contiguous, non-empty, widening bands', () => { + const bins = logBandBins(16, 30, 14000, SAMPLE_RATE, FFT); + expect(bins).toHaveLength(16); + for (let band = 1; band < bins.length; band += 1) { + expect(bins[band]![0]).toBe(bins[band - 1]![1]); + expect(bins[band]![1]).toBeGreaterThan(bins[band]![0]); + } + expect(bins.at(-1)![1] - bins.at(-1)![0]).toBeGreaterThan(bins[0]![1] - bins[0]![0]); + expect(bins.at(-1)![1]).toBeLessThanOrEqual(FFT / 2); + }); +}); + +describe('analyzeAudioFrame', () => { + it('reports silence as zero', () => { + const features = analyzeAudioFrame(createAudioAnalysisState(), frame({})); + expect(features.level).toBe(0); + expect(features.db).toBe(-96); + expect(features.onset).toBe(0); + expect(features.bands).toHaveLength(AUDIO_BAND_COUNT); + }); + + it('measures loudness in dBFS and follows it', () => { + const state = createAudioAnalysisState(); + let features = SILENT_AUDIO; + for (let step = 0; step < 30; step += 1) + features = analyzeAudioFrame(state, frame({ level: 0.5, nowMs: step * 16 })); + // A 0.5-amplitude sine has RMS ≈ 0.354 ≈ -9 dBFS. + expect(features.db).toBeCloseTo(-9, 0); + expect(features.level).toBeGreaterThan(0.75); + expect(features.peak).toBeGreaterThanOrEqual(features.level - 1e-9); + }); + + it('puts low-bin energy into bass and the first bands', () => { + const state = createAudioAnalysisState(); + let features = SILENT_AUDIO; + for (let step = 0; step < 20; step += 1) { + features = analyzeAudioFrame(state, frame({ level: 0.3, lowBins: 230, nowMs: step * 16 })); + } + expect(features.bass).toBeGreaterThan(0.5); + expect(features.high).toBeLessThan(0.05); + expect(features.bands[0]).toBeGreaterThan(features.bands.at(-1)!); + }); + + it('detects an onset on a sudden rise, then decays and rate-limits', () => { + const state = createAudioAnalysisState(); + for (let step = 0; step < 30; step += 1) + analyzeAudioFrame(state, frame({ level: 0.2, allBins: 20, nowMs: step * 16 })); + const hit = analyzeAudioFrame(state, frame({ level: 0.6, allBins: 200, nowMs: 30 * 16 })); + expect(hit.onset).toBe(1); + expect(hit.onsetAtMs).toBe(30 * 16); + // Another rise 16 ms later is inside the minimum gap. + const again = analyzeAudioFrame(state, frame({ level: 0.9, allBins: 255, nowMs: 31 * 16 })); + expect(again.onsetAtMs).toBe(30 * 16); + expect(again.onset).toBeLessThan(1); + }); + + it('ignores invalid frame steps', () => { + const state = createAudioAnalysisState(); + const features = analyzeAudioFrame(state, frame({ level: 0.5, allBins: 200, dtSeconds: Number.NaN })); + expect(features.onset).toBe(0); + expect(features.level).toBe(0); + }); +}); + +describe('normalizeToRecentRange', () => { + it('stretches a signal over the range it recently covered', () => { + const range = createRecentRange(); + normalizeToRecentRange(0.9, range, 1 / 60); + normalizeToRecentRange(0.7, range, 1 / 60); + expect(normalizeToRecentRange(0.9, range, 1 / 60)).toBeGreaterThan(0.9); + expect(normalizeToRecentRange(0.7, range, 1 / 60)).toBeLessThan(0.1); + }); + + it('reads a loud, steady signal as mid-scale instead of pinning it at the maximum', () => { + const range = createRecentRange(); + let value = 0; + for (let frame = 0; frame < 60 * 8; frame += 1) value = normalizeToRecentRange(0.95, range, 1 / 60); + expect(value).toBeCloseTo(0.5, 2); + }); + + it('swings fully with a loud signal that pulses', () => { + const range = createRecentRange(); + let low = 1; + let high = 0; + for (let frame = 0; frame < 60 * 6; frame += 1) { + const value = normalizeToRecentRange(frame % 30 < 15 ? 0.95 : 0.8, range, 1 / 60); + if (frame > 60 * 3) { + low = Math.min(low, value); + high = Math.max(high, value); + } + } + expect(high).toBeGreaterThan(0.9); + expect(low).toBeLessThan(0.2); + }); +}); diff --git a/packages/player-web/src/runtime/audio-analysis.ts b/packages/player-web/src/runtime/audio-analysis.ts new file mode 100644 index 00000000..65de12e3 --- /dev/null +++ b/packages/player-web/src/runtime/audio-analysis.ts @@ -0,0 +1,247 @@ +/** + * Live audio features for audio-reactive skins: loudness, a log-spaced spectrum, bass / mid / high energy, and onset + * (transient) detection by spectral flux. {@link analyzeAudioFrame} is pure over an explicit state object so it can be + * tested without Web Audio; {@link AudioAnalyzer} wraps an `AnalyserNode` and samples it once per rendered frame. + */ + +import { AUDIO_BAND_COUNT, SILENT_AUDIO, type BeMusicAudioFrame } from '@be-music/skin-sdk'; + +export { AUDIO_BAND_COUNT, SILENT_AUDIO }; + +/** One frame's analysis (the skin SDK's {@link BeMusicAudioFrame}). */ +export type AudioFeatures = BeMusicAudioFrame; + +/** Half-open FFT bin ranges `[start, end)` for `count` log-spaced bands between `minHz` and `maxHz`. */ +export function logBandBins( + count: number, + minHz: number, + maxHz: number, + sampleRate: number, + fftSize: number, +): Array { + const binCount = Math.floor(fftSize / 2); + const hzPerBin = sampleRate / fftSize; + const ranges: Array = []; + let previousEnd = Math.max(1, Math.floor(minHz / hzPerBin)); + for (let band = 0; band < count; band += 1) { + const upperHz = minHz * (maxHz / minHz) ** ((band + 1) / count); + const end = Math.min(binCount, Math.max(previousEnd + 1, Math.round(upperHz / hzPerBin))); + ranges.push([Math.min(previousEnd, end - 1), end]); + previousEnd = end; + } + return ranges; +} + +/** A running window of what a signal has recently done: its floor and ceiling (see {@link normalizeToRecentRange}). */ +export interface RecentRange { + floor: number; + ceiling: number; +} + +/** Narrowest window a signal is stretched over, so a near-steady signal stays near-steady instead of amplifying noise. */ +/** An empty range: the first value seen becomes both its floor and its ceiling. */ +export function createRecentRange(): RecentRange { + return { floor: Number.POSITIVE_INFINITY, ceiling: Number.NEGATIVE_INFINITY }; +} + +/** Share of the absolute level kept in the analysed bands next to their recent-range placement. */ +const ABSOLUTE_SHARE = 0.35; +const MIN_RECENT_SPAN = 0.12; +/** Seconds the floor takes to rise after a quiet passage / the ceiling to fall after a loud one. */ +const FLOOR_RISE_SECONDS = 3; +const CEILING_FALL_SECONDS = 2; + +/** + * Automatic gain for visuals: places `value` within the range the signal has covered over the last few seconds and + * returns 0..1, updating `range` in place. The floor drops instantly and creeps back up; the ceiling jumps instantly and + * eases back down. A loud, compressed mix whose raw spectrum sits near full scale therefore still swings from bottom + * to top with the music instead of pinning at the maximum. + */ +export function normalizeToRecentRange(value: number, range: RecentRange, dtSeconds: number): number { + const dt = Math.max(0, dtSeconds); + if (value < range.floor) range.floor = value; + else range.floor += (value - range.floor) * Math.min(1, dt / FLOOR_RISE_SECONDS); + if (value > range.ceiling) range.ceiling = value; + else range.ceiling += (value - range.ceiling) * Math.min(1, dt / CEILING_FALL_SECONDS); + // A window narrower than the minimum is widened about its middle, so a steady signal reads mid-scale. + const span = Math.max(MIN_RECENT_SPAN, range.ceiling - range.floor); + const bottom = (range.floor + range.ceiling - span) / 2; + return Math.max(0, Math.min(1, (value - bottom) / span)); +} + +export interface AudioAnalysisState { + bands: number[]; + /** Recent range of each raw band and of bass / mid / high, for their automatic gain. */ + bandRanges: RecentRange[]; + bassRange: RecentRange; + midRange: RecentRange; + highRange: RecentRange; + bass: number; + mid: number; + high: number; + level: number; + peak: number; + /** Previous raw band energies, for spectral flux. */ + previousRaw: number[]; + /** Running mean of the flux (adaptive onset threshold). */ + fluxMean: number; + onset: number; + onsetAtMs: number | undefined; +} + +export function createAudioAnalysisState(): AudioAnalysisState { + return { + bands: Array.from({ length: AUDIO_BAND_COUNT }, () => 0), + bandRanges: Array.from({ length: AUDIO_BAND_COUNT }, () => createRecentRange()), + bassRange: createRecentRange(), + midRange: createRecentRange(), + highRange: createRecentRange(), + bass: 0, + mid: 0, + high: 0, + level: 0, + peak: 0, + previousRaw: Array.from({ length: AUDIO_BAND_COUNT }, () => 0), + fluxMean: 0, + onset: 0, + onsetAtMs: undefined, + }; +} + +export interface AudioFrameInput { + /** `AnalyserNode.getByteFrequencyData` output: one 0..255 value per bin. */ + frequency: Uint8Array; + /** `AnalyserNode.getFloatTimeDomainData` output: samples in [-1, 1]. */ + waveform: Float32Array; + sampleRate: number; + fftSize: number; + /** Seconds since the previous frame (clamped to 0..0.25). */ + dtSeconds: number; + nowMs: number; +} + +const MIN_ONSET_GAP_MS = 90; + +/** Advances `state` by one frame and returns the features for it. */ +export function analyzeAudioFrame(state: AudioAnalysisState, input: AudioFrameInput): AudioFeatures { + const dt = Math.max(0, Math.min(0.25, Number.isFinite(input.dtSeconds) ? input.dtSeconds : 0)); + const { frequency, waveform, sampleRate, fftSize } = input; + // Loudness from the waveform's RMS. + let sum = 0; + for (let index = 0; index < waveform.length; index += 1) sum += waveform[index]! * waveform[index]!; + const rms = waveform.length > 0 ? Math.sqrt(sum / waveform.length) : 0; + const db = rms > 1e-5 ? Math.max(-96, Math.min(0, 20 * Math.log10(rms))) : -96; + const loudness = Math.max(0, Math.min(1, (db + 48) / 48)); + + const average = (fromHz: number, toHz: number): number => { + const hzPerBin = sampleRate / fftSize; + const start = Math.max(0, Math.floor(fromHz / hzPerBin)); + const end = Math.min(frequency.length, Math.max(start + 1, Math.ceil(toHz / hzPerBin))); + let total = 0; + for (let bin = start; bin < end; bin += 1) total += frequency[bin] ?? 0; + return end > start ? total / (end - start) / 255 : 0; + }; + const ranges = logBandBins(AUDIO_BAND_COUNT, 30, 14000, sampleRate, fftSize); + const raw = ranges.map(([start, end]) => { + let total = 0; + for (let bin = start; bin < end; bin += 1) total += frequency[bin] ?? 0; + return end > start ? total / (end - start) / 255 : 0; + }); + + // Fast attack, slower release: visuals snap to a hit and ease off. + const follow = (current: number, target: number, releaseSeconds: number) => + target > current + ? current + (target - current) * Math.min(1, dt * 30) + : current + (target - current) * Math.min(1, dt / releaseSeconds); + // Bands and bass / mid / high are mostly placed within their recent range (see `normalizeToRecentRange`), so visuals + // swing with the music however loud the mix is; a little of the absolute level stays so loud still reads louder than + // quiet, and silence still reads as zero. + const gain = (value: number, range: RecentRange) => + value < 0.01 ? 0 : ABSOLUTE_SHARE * value + (1 - ABSOLUTE_SHARE) * normalizeToRecentRange(value, range, dt); + for (let band = 0; band < AUDIO_BAND_COUNT; band += 1) { + state.bands[band] = follow(state.bands[band]!, gain(raw[band]!, state.bandRanges[band]!), 0.18); + } + state.bass = follow(state.bass, gain(average(20, 150), state.bassRange), 0.2); + state.mid = follow(state.mid, gain(average(150, 2000), state.midRange), 0.2); + state.high = follow(state.high, gain(average(2000, 12000), state.highRange), 0.15); + state.level = follow(state.level, loudness, 0.25); + state.peak = Math.max(loudness, state.peak * Math.exp(-dt / 0.35)); + + // Onsets: spectral flux (rise in band energy, weighted toward the low end) against its own running mean. + let flux = 0; + for (let band = 0; band < AUDIO_BAND_COUNT; band += 1) { + const rise = raw[band]! - state.previousRaw[band]!; + if (rise > 0) flux += rise * (band < 5 ? 1.5 : 1); + } + flux /= AUDIO_BAND_COUNT; + state.previousRaw = raw; + const threshold = state.fluxMean * 1.6 + 0.012; + const sinceOnset = state.onsetAtMs === undefined ? Number.POSITIVE_INFINITY : input.nowMs - state.onsetAtMs; + if (dt > 0 && flux > threshold && sinceOnset >= MIN_ONSET_GAP_MS && loudness > 0.05) { + state.onset = 1; + state.onsetAtMs = input.nowMs; + } else { + state.onset *= Math.exp(-dt / 0.15); + } + state.fluxMean += (flux - state.fluxMean) * Math.min(1, dt / 0.5); + + return { + level: state.level, + peak: state.peak, + db, + bass: state.bass, + mid: state.mid, + high: state.high, + bands: state.bands.slice(), + onset: state.onset, + onsetAtMs: state.onsetAtMs, + }; +} + +/** + * Taps an audio graph with an `AnalyserNode` (connect sources to {@link input}; it outputs nowhere) and turns it into + * {@link AudioFeatures} once per frame. Repeated `sample` calls within the same millisecond reuse the last result. + */ +export class AudioAnalyzer { + public readonly input: AnalyserNode; + private readonly frequency: Uint8Array; + private readonly waveform: Float32Array; + private readonly state = createAudioAnalysisState(); + private readonly sampleRate: number; + private lastMs: number | undefined; + private last: AudioFeatures = SILENT_AUDIO; + + public constructor(context: BaseAudioContext) { + this.input = context.createAnalyser(); + this.input.fftSize = 2048; + this.input.smoothingTimeConstant = 0.5; + this.frequency = new Uint8Array(this.input.frequencyBinCount); + this.waveform = new Float32Array(this.input.fftSize); + this.sampleRate = context.sampleRate; + } + + public sample(nowMs: number): AudioFeatures { + if (this.lastMs !== undefined && nowMs === this.lastMs) return this.last; + const dt = this.lastMs === undefined ? 0 : (nowMs - this.lastMs) / 1000; + this.lastMs = nowMs; + this.input.getByteFrequencyData(this.frequency); + this.input.getFloatTimeDomainData(this.waveform); + this.last = analyzeAudioFrame(this.state, { + frequency: this.frequency, + waveform: this.waveform, + sampleRate: this.sampleRate, + fftSize: this.input.fftSize, + dtSeconds: dt, + nowMs, + }); + return this.last; + } + + public dispose(): void { + try { + this.input.disconnect(); + } catch { + // Already disconnected (or the context closed first). + } + } +} diff --git a/packages/player-web/src/scene/core/gameplay.ts b/packages/player-web/src/scene/core/gameplay.ts index 83b4843a..8864369d 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 { PerfTracker } from '../perf.ts'; import { type PixiSceneHost } from '../host.ts'; import { ChildPool, staggerDestroyTextures } from '../pixi-utils.ts'; +import { AudioAnalyzer, type AudioFeatures } from '../../runtime/audio-analysis.ts'; import { runEngineDriver } from '../../runtime/engine-driver.ts'; import { createWebAudioSession, type WebAudioSession } from '../../runtime/web-audio-session.ts'; import { drainWebUiSignals, type WebUiRuntimeCallbacks } from '../../runtime/web-ui-runtime.ts'; @@ -119,11 +120,10 @@ import { } from './gameplay-bga.ts'; import { isPlayableInputChannel, - resolveFallbackLaneLayout, - resolveLaneChannels, + resolvePlayVariantLaneChannels, resolveLr2LaneIndex, resolveSideRelativeLaneIndex, - shouldPreserveFallbackSideWidth, + resolveSkinlessLaneLayout, } from '../gameplay-lanes.ts'; import type { SkinlessGameplayChromeRenderer, @@ -132,10 +132,30 @@ import type { } from '../gameplay-chrome.ts'; import { resolveGameplayAudioTailCleanupDelayMs, resolvePostChartResultDelayMs } from './gameplay-result-delay.ts'; import { CORE_TEXT_FONT } from './fonts.ts'; -import type { BeMusicBomb, BeMusicLaneFrame, BeMusicSkin } from '../../skin/be-music/types.ts'; +import type { + BeMusicBomb, + BeMusicEffectLevel, + BeMusicGameplayLayout, + BeMusicLaneFrame, + BeMusicSkin, + BeMusicStage, +} from '../../skin/be-music/types.ts'; import { resolveBeMusicLaneKind } from '../../skin/be-music/registry.ts'; -import { phantomSkin } from '../default/phantom/index.ts'; -import { resolveDesignTextResolution, resolveScaledViewport, setDesignTextResolution } from './viewport.ts'; +import { resolveGameplayLayout } from '@be-music/skin-sdk'; +import { BeMusicGameplayBinding, resolveBeMusicSkinStage } from '../../skin/be-music/binding.ts'; +import { + PHANTOM_BOMB_DURATION_MS, + renderPhantomBombs, + renderPhantomLanes, + renderPhantomLongNote, + renderPhantomNote, +} from '../../skins/phantom/playfield.ts'; +import { + resolveDesignTextResolution, + resolveScaledViewport, + setDesignPixelRatio, + setDesignTextResolution, +} from './viewport.ts'; import type { PixiGameplayResultData } from './result-data.ts'; import { logger } from '../../logger.ts'; @@ -205,20 +225,35 @@ export interface GameplayBgaTarget { } /** Default-family BGA rect: the fixed {@link BGA} square, drawn opaque and untinted. */ -const DEFAULT_BGA_TARGET: GameplayBgaTarget = { - x: BGA.x, - y: BGA.y, - w: BGA.w, - h: BGA.h, - noBase: false, - noLayer: false, - noPoor: false, - applyToSprite: (sprite) => { - sprite.alpha = 1; - sprite.tint = 0xffffff; - sprite.blendMode = 'normal'; - }, -}; +const DEFAULT_BGA_TARGET: GameplayBgaTarget = createDefaultBgaTarget(BGA); + +function createDefaultBgaTarget(rect: { x: number; y: number; w: number; h: number }): GameplayBgaTarget { + return { + x: rect.x, + y: rect.y, + w: rect.w, + h: rect.h, + noBase: false, + noLayer: false, + noPoor: false, + applyToSprite: (sprite) => { + sprite.alpha = 1; + sprite.tint = 0xffffff; + sprite.blendMode = 'normal'; + }, + }; +} + +/** Design canvas of the scene: its width / height and, for the be-music path, the skin's stage. */ +export interface GameplayStageSize { + width: number; + height: number; +} + +/** The LR2-compatible 640x480 canvas. */ +const LEGACY_STAGE_SIZE: GameplayStageSize = { width: DESIGN_WIDTH, height: DESIGN_HEIGHT }; +/** The 640x480 stage with the fixed default-family BGA square, for skins that declare no stage of their own. */ +const LEGACY_STAGE: BeMusicStage = { ...LEGACY_STAGE_SIZE, resolveBgaRect: () => ({ ...BGA }) }; /** Lane rectangle in design pixels. `bottom` is the judgement line a note's bottom edge lands on. */ export interface GameplayLaneRect { @@ -277,6 +312,14 @@ const DEFAULT_THEME_TIMING: GameplayThemeTiming = { closeMs: 0, }; +/** + * Scene transitions for be-music skins: the play screen fades up from black as it appears (every select outro ends + * dark) and fades back to black, with the audio, before the result or select screen takes over — instead of cutting + * in and out mid-animation. + */ +const BE_MUSIC_ENTER_FADE_MS = 400; +const BE_MUSIC_THEME_TIMING: GameplayThemeTiming = { ...DEFAULT_THEME_TIMING, fadeOutMs: 450 }; + /** * Scene moments a theme can react to (the LR2 family stamps its skin timers from these). `at` is the * {@link CoreGameplayView.playClock} value the scene recorded for the moment. @@ -310,6 +353,8 @@ export interface CoreGameplayViewOptions { * Phantom skin. */ beMusicSkin?: BeMusicSkin; + /** Showmanship level for the be-music skin (count-ins, shakes, particles). Defaults to `'full'`. */ + beMusicEffects?: BeMusicEffectLevel; onExit?: () => void; /** * Restart hook. Fired when the player presses the restart hotkey (`R` by default) — host should dispose this view and @@ -575,6 +620,12 @@ export class CoreGameplayView { + // No splash covers the load window on this path, so the playfield comes up straight away and shows the loading + // state (see `revealWhilePreparing`) instead of a blank stage while chart audio decodes. + this.revealWhilePreparing = true; await this.prepare(host, song, source); if (this.disposed) return; this.start(); @@ -1183,11 +1290,12 @@ export class CoreGameplayView { log.warn('BGA preload failed; continuing without it', error); }); + void this.bgaReadyPromise.then(() => { + this.assetsLoading = false; + }); } /** @@ -1324,6 +1442,7 @@ export class CoreGameplayView 0 ? playStartOffsetMs : FALLBACK_INTRO_DELAY_MS; + this.scheduledIntroMs = introMs; // The chart waits on BOTH the configured PLAY START delay AND the BGA preload (which may still be transcoding video // in the background — see `prepare()`). Until the gate opens below, `startTime = +Infinity` keeps `isIntroPlaying` // true and the rAF loop in the intro (LR2 LOADING) phase. @@ -1632,6 +1751,9 @@ export class CoreGameplayView` declares the chart's master volume scaling (100 = unity, 80 = 80 % loud, > 100 @@ -2824,7 +2951,13 @@ export class CoreGameplayView this.renderThemeLayer(DESIGN_WIDTH, DESIGN_HEIGHT)); + this.audioFrame = this.audioAnalyzer?.sample(this.playClock()); + this.perf.time('renderSkin', () => this.renderThemeLayer(designWidth, designHeight)); this.perf.time('renderBga', () => this.renderBga(seconds)); - this.perf.time('renderLanes', () => this.renderLanes(DESIGN_WIDTH, DESIGN_HEIGHT)); - this.perf.time('renderNotes', () => this.renderNotes(seconds, DESIGN_HEIGHT)); + this.perf.time('renderLanes', () => this.renderLanes(designWidth, designHeight)); + this.perf.time('renderNotes', () => this.renderNotes(seconds, designHeight)); this.perf.time('renderShutter', () => this.renderShutter()); this.perf.time('renderBombs', () => this.renderBombs()); - this.perf.time('renderText', () => this.renderText(DESIGN_WIDTH, DESIGN_HEIGHT, seconds)); + this.perf.time('renderText', () => this.renderText(designWidth, designHeight, seconds)); } /** @@ -3210,12 +3347,33 @@ export class CoreGameplayView 0 ? this.score.total : 0; const seconds = this.currentSeconds(); @@ -3464,6 +3642,15 @@ export class CoreGameplayView { // A theme-authored lane rect wins; channels the theme doesn't place use the default-family layout. const themeLane = this.resolveThemeLaneRect(channel, width, height); const fallbackLane = fallbackLanes[index]; - const x = themeLane ? themeLane.x : (fallbackLane?.x ?? PLAYFIELD.x); + const x = themeLane ? themeLane.x : (fallbackLane?.x ?? fallbackLeft); const w = themeLane ? themeLane.w : Math.max(4, fallbackLane?.w ?? PLAYFIELD.w); const top = themeLane ? themeLane.top : fallbackTop; const bottom = themeLane ? themeLane.bottom : fallbackBottom; @@ -3552,11 +3758,19 @@ export class CoreGameplayView 0) { const beat = this.currentBeat(this.currentSeconds()); - this.playfieldSkin.gameplay.renderLanes({ + const binding = this.skinBinding; + if (binding) { + binding.addLanes(skinlessLanes); + return; + } + renderPhantomLanes({ graphics: this.laneLayer, lanes: skinlessLanes, beatPhase: beat - Math.floor(beat), nowMs: this.playClock(), + combo: this.tracker.combo, + effects: this.options.beMusicEffects ?? 'full', + audio: this.audioFrame, }); } } @@ -3872,16 +4086,15 @@ export class CoreGameplayView(); /** Fallback summary panel (used when no theme paints the frame). */ private readonly fallbackLayer = new Container(); + /** Draws the be-music skin's result on its canvas, shown in `fallbackLayer`; created on first use. */ + private skinBinding: BeMusicResultBinding | undefined; /** Clip mask for the design rect — keeps theme artwork from bleeding into the letterbox bars. */ private readonly designClipMask = new Graphics(); /** @@ -309,6 +319,8 @@ export class CoreResultView { log.warn('texture cleanup threw', error); } try { + this.skinBinding?.dispose(); + this.skinBinding = undefined; this.sceneRoot.destroy({ children: true, context: true }); } catch (error) { log.warn('sceneRoot.destroy threw', error); @@ -348,10 +360,12 @@ export class CoreResultView { const screenWidth = this.app.screen.width || FALLBACK_DESIGN_WIDTH; const screenHeight = this.app.screen.height || FALLBACK_DESIGN_HEIGHT; const themeSize = this.themeDesignSize; - const designWidth = themeSize ? themeSize.width : FALLBACK_DESIGN_WIDTH; - const designHeight = themeSize ? themeSize.height : FALLBACK_DESIGN_HEIGHT; + const stage = resolveBeMusicSkinStage(this.options.beMusicSkin ?? phantomSkin); + const designWidth = themeSize ? themeSize.width : stage.width; + const designHeight = themeSize ? themeSize.height : stage.height; const viewport = resolveScaledViewport(screenWidth, screenHeight, designWidth, designHeight); setDesignTextResolution(resolveDesignTextResolution(viewport.scale, this.app.renderer.resolution)); + setDesignPixelRatio(viewport.scale * this.app.renderer.resolution); if (this.cachedScreenWidth !== screenWidth || this.cachedScreenHeight !== screenHeight) { this.viewportBackground.clear().rect(0, 0, screenWidth, screenHeight).fill(BG); this.cachedScreenWidth = screenWidth; @@ -370,11 +384,12 @@ export class CoreResultView { // state alive, which the original report described as "browser freezes after the song ends" — accumulated // GraphicsContext + glyph atlas slots stalled the next reconcile pass. See `pixi-utils.ts` for the full rationale. disposeChildren(this.skinLayer); - disposeChildren(this.fallbackLayer); if (this.renderTheme()) { // No empty-state hint here — the theme's own artwork covers the whole canvas. + this.fallbackLayer.visible = false; return; } + this.fallbackLayer.visible = true; this.renderFallbackPanel(designWidth, designHeight); } @@ -385,8 +400,11 @@ export class CoreResultView { const now = performance.now(); // A skip (Enter before the chart draw finishes → timer 151) jumps the skin's entrance to its settled layout. const skipped = this.timerStartedAt.has(151); - (this.options.beMusicSkin ?? phantomSkin).result.render({ - layer: this.fallbackLayer, + if (!this.skinBinding) { + this.skinBinding = new BeMusicResultBinding(this.options.beMusicSkin ?? phantomSkin); + this.fallbackLayer.addChild(this.skinBinding.view); + } + this.skinBinding.render({ designWidth, designHeight, result, @@ -394,6 +412,7 @@ export class CoreResultView { ratePercent: computeScoreRate(result.score) * 100, elapsedMs: skipped ? Number.POSITIVE_INFINITY : now - this.sceneStartedAt, nowMs: now, + effects: this.options.beMusicEffects ?? 'full', }); } diff --git a/packages/player-web/src/scene/core/select.ts b/packages/player-web/src/scene/core/select.ts index 9d5135d3..55dac37d 100644 --- a/packages/player-web/src/scene/core/select.ts +++ b/packages/player-web/src/scene/core/select.ts @@ -5,6 +5,7 @@ import { disposeChildren } from '../pixi-utils.ts'; import { groupSongsByFolder, loadAssetBytes, resolveSongSource } from '../../collection/collection.ts'; import { dirname } from '@be-music/utils/core'; import { ChartPreviewEngine } from '../../chart/preview.ts'; +import { AudioAnalyzer } from '../../runtime/audio-analysis.ts'; import { resolveKeyModeOp, SELECT_KEYS_FILTER_TO_OP } from '../select-ops.ts'; import { logger } from '../../logger.ts'; import type { @@ -14,10 +15,16 @@ import type { BrowserSongEntry, } from '../../collection/types.ts'; import { CORE_TEXT_FONT } from './fonts.ts'; -import type { BeMusicSelectRenderer, BeMusicSkin } from '../../skin/be-music/types.ts'; +import type { BeMusicEffectLevel, 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 '../default/phantom/index.ts'; -import { resolveDesignTextResolution, resolveScaledViewport, setDesignTextResolution } from './viewport.ts'; +import { phantomSkin } from '../../skins/phantom/index.ts'; +import { + resolveDesignTextResolution, + resolveScaledViewport, + setDesignPixelRatio, + setDesignTextResolution, +} from './viewport.ts'; import { masterOutput } from '../../runtime/master-volume.ts'; const log = logger('select'); @@ -36,8 +43,6 @@ const MUTED = new Color('#a9a39a'); */ const FALLBACK_DESIGN_WIDTH = 640; const FALLBACK_DESIGN_HEIGHT = 480; -/** Design canvas the be-music fallback path renders into. */ -const FALLBACK_DESIGN_SIZE: SelectDesignSize = { width: FALLBACK_DESIGN_WIDTH, height: FALLBACK_DESIGN_HEIGHT }; /** * Pixel scroll step for the readtext modal's arrow-key nudge — roughly two lines at the body's 14px / 18px line-height. @@ -416,6 +421,8 @@ export interface PixiSongSelectSystemSounds { export interface CoreSongSelectViewOptions { /** be-music skin for the skinless path (no LR2 select skin). Defaults to the built-in Phantom skin. */ beMusicSkin?: BeMusicSkin; + /** Showmanship level for the be-music skin (entrance, outro, ambient motion). Defaults to `'full'`. */ + beMusicEffects?: BeMusicEffectLevel; onSongSelected?: (song: BrowserSongEntry) => void; /** * AUTOPLAY-mode launch hook. Fired when the user clicks the skin's AUTOPLAY button (#SRC_BUTTON `type = 16`) or @@ -608,7 +615,7 @@ export class CoreSongSelectView this.render()); } if (this.beMusicBackHolder.visible) { - this.beMusicRenderer?.tick(now, this.focusedSong()); + this.beMusicRenderer?.tick(now, this.focusedSong(), this.launchAt, this.selectAudioAnalyzer?.sample(now)); } } const report = this.perf.endFrame(() => ({ @@ -1333,6 +1342,7 @@ export class CoreSongSelectView undefined); this.selectBgmContext = undefined; this.selectBgmGain = undefined; @@ -1652,6 +1664,8 @@ export class CoreSongSelectView { @@ -1999,7 +2014,7 @@ export class CoreSongSelectView { - if (!this.visible) return; + if (!this.visible || this.launchAt !== undefined) return; // Retry BGM start on the first user gesture — browsers gate `AudioContext.resume()` behind a user-input event, so // the mount-time / setVisible-time start may have left the context suspended. Cheap to call repeatedly // (early-returns when a source is already playing). @@ -2016,7 +2031,7 @@ export class CoreSongSelectView { + // A launch outro is playing — the chart is already chosen. + if (this.launchAt !== undefined) { + return; + } // Same gesture-retry as `handlePointerDown` — most users will arrive at the select view via the keyboard rather // than a mouse on macOS / touchpad-only devices, so we hook here too. void this.startSelectBgm(); @@ -2225,7 +2244,7 @@ export class CoreSongSelectView { + if (autoPlay && this.options.onSongAutoPlay) { + this.options.onSongAutoPlay(song); + return; + } + this.options.onSongSelected?.(song); + }; + const outroMs = + this.themeDesignSize === undefined && (this.options.beMusicEffects ?? 'full') !== 'off' + ? (this.beMusicRenderer?.outroMs ?? 0) + : 0; + if (outroMs <= 0) { + fire(); return; } - this.options.onSongSelected?.(focused); + this.launchAt = performance.now(); + this.beMusicNeedsFrame = true; + this.render(); + this.launchTimer = window.setTimeout(() => { + this.launchTimer = undefined; + this.launchAt = undefined; + if (this.disposed) return; + fire(); + // Settle the screen behind the gameplay handoff so a return to select doesn't flash the outro's last frame. + this.render(); + }, outroMs); } - /** Design canvas for the current frame: the active theme's, or the 640×480 be-music fallback. */ + /** Design canvas for the current frame: the active theme's, or the be-music skin's stage. */ private resolveDesignSize(): SelectDesignSize { - return this.themeDesignSize ?? FALLBACK_DESIGN_SIZE; + return this.themeDesignSize ?? this.beMusicDesignSize; + } + + /** The be-music skin's stage (640×480 when the skin declares none). */ + private get beMusicDesignSize(): SelectDesignSize { + const stage = resolveBeMusicSkinStage(this.beMusicSkin); + return { width: stage.width, height: stage.height }; } /** diff --git a/packages/player-web/src/scene/core/viewport.ts b/packages/player-web/src/scene/core/viewport.ts index aebc7139..e60a0c16 100644 --- a/packages/player-web/src/scene/core/viewport.ts +++ b/packages/player-web/src/scene/core/viewport.ts @@ -51,3 +51,18 @@ export function setDesignTextResolution(value: number): void { export function getDesignTextResolution(): number { return designTextResolution; } + +let designPixelRatio = 1; + +/** + * Device pixels per design pixel on screen right now (`viewport scale × renderer resolution`, not rounded). Set by the + * shared scenes next to {@link setDesignTextResolution}; canvas skins size their canvases from it so each canvas pixel + * lands on exactly one screen pixel. + */ +export function setDesignPixelRatio(value: number): void { + designPixelRatio = Number.isFinite(value) && value > 0 ? value : 1; +} + +export function getDesignPixelRatio(): number { + return designPixelRatio; +} diff --git a/packages/player-web/src/scene/default/built-in-skins.ts b/packages/player-web/src/scene/default/built-in-skins.ts deleted file mode 100644 index 8bd1b177..00000000 --- a/packages/player-web/src/scene/default/built-in-skins.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { BeMusicSkin } from '../../skin/be-music/types.ts'; -import { phantomSkin } from './phantom/index.ts'; -import { synesthesiaSkin } from './synesthesia/index.ts'; - -/** Every be-music skin that ships with the player, in picker order. The first is the default. */ -export const BUILT_IN_BE_MUSIC_SKINS: readonly BeMusicSkin[] = [phantomSkin, synesthesiaSkin]; diff --git a/packages/player-web/src/scene/default/gameplay.ts b/packages/player-web/src/scene/default/gameplay.ts index 35a068ef..ab01d1c1 100644 --- a/packages/player-web/src/scene/default/gameplay.ts +++ b/packages/player-web/src/scene/default/gameplay.ts @@ -1,5 +1,5 @@ import { CoreGameplayView, type CoreGameplayViewOptions } from '../core/gameplay.ts'; -import { phantomSkin } from './phantom/index.ts'; +import { phantomSkin } from '../../skins/phantom/index.ts'; /** * Constructor options for {@link DefaultPixiGameplayView}. `skinlessChromeRenderer` is stripped because the default diff --git a/packages/player-web/src/scene/default/index.ts b/packages/player-web/src/scene/default/index.ts index e78cff60..b2486415 100644 --- a/packages/player-web/src/scene/default/index.ts +++ b/packages/player-web/src/scene/default/index.ts @@ -3,16 +3,24 @@ * loaded theme doesn't cover the requested scene / chart variant. * * The scene classes here extend the family-neutral scenes in `scene/core/` directly and render everything through a - * be-music skin (`phantomSkin` by default, `synesthesiaSkin`, or any `BeMusicSkin`), so this family has no dependency + * be-music skin (`phantomSkin` by default, `synesthesiaSkin`, `latticeSkin`, or any `BeMusicSkin`), so this family has no dependency * on the LR2 or beatoraja code paths. * * The family metadata itself (`defaultSkinFamily`) lives in `skin/default/family.ts` alongside the LR2 / beatoraja * metadata — see that file for the family contract. */ -export * from './gameplay-render.ts'; +// The Phantom chrome's long-standing standalone entry points (kept for hosts that call them directly). +export { + renderDefaultGameplayFrame, + renderFallbackLr2Frame, + type FallbackGameplayRenderOptions, + type FallbackGameplayRuntime, +} from '../../skins/phantom/chrome.ts'; export * from './gameplay.ts'; export * from './result.ts'; export * from './select.ts'; -export { phantomSkin } from './phantom/index.ts'; -export { synesthesiaSkin } from './synesthesia/index.ts'; -export { BUILT_IN_BE_MUSIC_SKINS } from './built-in-skins.ts'; +export { latticeSkin } from '../../skins/lattice/index.ts'; +export { phantomSkin } from '../../skins/phantom/index.ts'; +export { plainSkin } from '../../skins/plain/index.ts'; +export { synesthesiaSkin } from '../../skins/synesthesia/index.ts'; +export { BUILT_IN_BE_MUSIC_SKINS } from '../../skins/built-in.ts'; diff --git a/packages/player-web/src/scene/default/phantom-style.ts b/packages/player-web/src/scene/default/phantom-style.ts deleted file mode 100644 index f1fdc935..00000000 --- a/packages/player-web/src/scene/default/phantom-style.ts +++ /dev/null @@ -1,181 +0,0 @@ -/** - * "Phantom" visual language shared by the default skin family's gameplay, select, and result chrome: a three-ink - * poster palette (blood red / ink black / paper white), slanted parallelogram plates, jagged starbursts, and halftone - * dot fields. The shape helpers are pure and return flat `[x0, y0, x1, y1, ...]` point lists so the Pixi renderers can - * hand them straight to `Graphics.poly()`. - */ - -export const PHANTOM_RED = 0xe60019; -export const PHANTOM_RED_HOT = 0xff2b45; -export const PHANTOM_RED_DEEP = 0x6e0010; -export const PHANTOM_INK = 0x000000; -export const PHANTOM_BLACK = 0x0c0c0e; -export const PHANTOM_CHARCOAL = 0x1a1a1d; -export const PHANTOM_SLATE = 0x2c2c31; -export const PHANTOM_WHITE = 0xffffff; -export const PHANTOM_PAPER = 0xf4f1ea; -export const PHANTOM_ASH = 0x8d8a93; -/** Single warm accent reserved for "money" moments (PERFECT, high combo, top rank) so red stays the base ink. */ -export const PHANTOM_GOLD = 0xffd21f; -export const PHANTOM_CYAN = 0x4fd8ff; -export const PHANTOM_ORANGE = 0xff8a1f; - -/** - * Parallelogram with horizontal top / bottom edges. `slant` shifts the top edge right relative to the bottom edge - * (positive leans like italic type). The bounding box is `[x, x + w + max(0, slant)]` horizontally — the bottom-left - * corner stays anchored at `(x, y + h)`. - */ -export function parallelogramPoints(x: number, y: number, w: number, h: number, slant: number): number[] { - const topShift = Math.max(0, slant); - const bottomShift = Math.max(0, -slant); - return [x + topShift, y, x + w + topShift, y, x + w + bottomShift, y + h, x + bottomShift, y + h]; -} - -/** - * Jagged comic starburst. Spikes alternate between `outerRadius` and `innerRadius`; `jitter` (0..1) roughens the - * outer tips deterministically from `seed` so the burst reads hand-cut instead of a perfect star, while staying - * stable frame to frame for the same seed. - */ -export function starburstPoints( - cx: number, - cy: number, - outerRadius: number, - innerRadius: number, - spikes: number, - rotation = 0, - jitter = 0, - seed = 1, -): number[] { - const count = Math.max(3, Math.floor(spikes)); - const points: number[] = []; - for (let index = 0; index < count * 2; index += 1) { - const outer = index % 2 === 0; - const angle = rotation + (Math.PI * index) / count; - const roughness = outer && jitter > 0 ? 1 - jitter * hash01(seed * 131 + index) : 1; - const radius = (outer ? outerRadius : innerRadius) * roughness; - points.push(cx + Math.cos(angle) * radius, cy + Math.sin(angle) * radius); - } - return points; -} - -export interface HalftoneDot { - x: number; - y: number; - r: number; -} - -export interface HalftoneFieldOptions { - x: number; - y: number; - w: number; - h: number; - /** Grid pitch between dot centres. */ - pitch: number; - /** Dot radius where the ramp is at full strength. */ - maxRadius: number; - /** - * Direction the dots grow toward, as a unit-ish vector. `{ x: 1, y: 0 }` makes dots swell left → right. The ramp is - * measured across the field's projected extent, so any direction covers 0..1 over the rectangle. - */ - direction: { x: number; y: number }; - /** Radii below this are dropped entirely so the fade end stays clean. Defaults to 0.35 px. */ - minRadius?: number; -} - -/** - * Halftone dot field on a staggered grid: every other row is offset by half a pitch, and each dot's radius ramps - * linearly along `direction`. Returns only the dots that survive `minRadius`, so callers can loop and `circle()` them. - */ -export function halftoneField(options: HalftoneFieldOptions): HalftoneDot[] { - const { x, y, w, h, pitch, maxRadius } = options; - if (pitch <= 0 || w <= 0 || h <= 0 || maxRadius <= 0) return []; - const minRadius = options.minRadius ?? 0.35; - const length = Math.hypot(options.direction.x, options.direction.y) || 1; - const dx = options.direction.x / length; - const dy = options.direction.y / length; - // Project the rectangle's corners onto the direction to find the ramp's 0..1 span. - const projections = [0, w * dx, h * dy, w * dx + h * dy]; - const rampStart = Math.min(...projections); - const rampSpan = Math.max(...projections) - rampStart || 1; - const dots: HalftoneDot[] = []; - let row = 0; - for (let py = y; py <= y + h; py += pitch, row += 1) { - const offset = row % 2 === 0 ? 0 : pitch / 2; - for (let px = x + offset; px <= x + w; px += pitch) { - const ramp = ((px - x) * dx + (py - y) * dy - rampStart) / rampSpan; - const r = maxRadius * Math.max(0, Math.min(1, ramp)); - if (r >= minRadius) { - dots.push({ x: px, y: py, r }); - } - } - } - return dots; -} - -/** - * Deterministic [0, 1) hash for decorative jitter — the same seed always yields the same value, so pooled per-frame - * redraws don't shimmer. - */ -export function hash01(seed: number): number { - const value = Math.sin(seed * 12.9898 + 78.233) * 43758.5453; - return value - Math.floor(value); -} - -export interface TabularGlyph { - char: string; - /** Left edge of the glyph's cell, relative to the run's start. */ - x: number; - /** Cell width — the shared digit advance for `0-9`, the glyph's own advance otherwise. */ - w: number; -} - -/** - * Lays out a string with tabular (fixed-width) figures. Canvas 2D — which Pixi `Text` rasterizes through — exposes no - * `font-feature-settings`, so OpenType `tnum` can't be switched on; instead every digit gets a cell as wide as the - * widest digit, and callers centre each glyph in its cell. Other characters (`/`, `%`, `.`, `x`, spaces) keep their - * natural advance. Returns the cells plus the run's total width. - */ -export function layoutTabularRun( - text: string, - measure: (char: string) => number, -): { glyphs: TabularGlyph[]; width: number } { - let digitAdvance = 0; - for (let digit = 0; digit <= 9; digit += 1) { - digitAdvance = Math.max(digitAdvance, measure(String(digit))); - } - const glyphs: TabularGlyph[] = []; - let x = 0; - for (const char of text) { - const w = char >= '0' && char <= '9' ? digitAdvance : measure(char); - glyphs.push({ char, x, w }); - x += w; - } - return { glyphs, width: x }; -} - -/** - * 0..1 progress of a staged animation step that starts `delayMs` after the timeline origin and runs for `durationMs`. - * Clamped at both ends so callers can compute every step from one elapsed clock. - */ -export function stageProgress(elapsedMs: number, delayMs: number, durationMs: number): number { - if (durationMs <= 0) return elapsedMs >= delayMs ? 1 : 0; - return Math.max(0, Math.min(1, (elapsedMs - delayMs) / durationMs)); -} - -export function easeOutCubic(t: number): number { - const clamped = Math.max(0, Math.min(1, t)); - return 1 - (1 - clamped) ** 3; -} - -/** Ease-out with a small overshoot past 1 before settling — the "slam" landing for tags and stamps. */ -export function easeOutBack(t: number, overshoot = 1.70158): number { - const clamped = Math.max(0, Math.min(1, t)); - const shifted = clamped - 1; - return 1 + (overshoot + 1) * shifted ** 3 + overshoot * shifted ** 2; -} - -/** Counter roll-up: the integer shown at `progress` (0..1) of counting up to `target`. */ -export function rollUpValue(target: number, progress: number): number { - if (!Number.isFinite(target)) return 0; - return Math.round(target * easeOutCubic(progress)); -} diff --git a/packages/player-web/src/scene/default/skin-text.ts b/packages/player-web/src/scene/default/skin-text.ts deleted file mode 100644 index 5c7eb9ea..00000000 --- a/packages/player-web/src/scene/default/skin-text.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { type Color, type Container, Graphics, Text, TextStyle } from 'pixi.js'; -import { resolveChartPlayVariant } from '../../collection/collection.ts'; -import type { BrowserSongEntry } from '../../collection/types.ts'; -import { getDesignTextResolution } from '../core/viewport.ts'; -import { DEFAULT_TEXT_FONT } from './fonts.ts'; - -export interface SkinTextOptions { - size?: number; - weight?: '300' | '400' | '500' | '600' | '700' | '800' | '900'; - fill?: number | Color; - fontFamily?: string; - letterSpacing?: number; - anchorX?: number; - anchorY?: number; - maxWidth?: number; - skewX?: number; - alpha?: number; - stroke?: { color: number; width: number; alignment?: number; join?: 'round' | 'bevel' | 'miter' }; - /** Hard offset shadow (blur 0) at 45°. */ - dropShadow?: { color: number; distance: number; blur?: number; alpha?: number; angle?: number }; -} - -/** - * One text node for built-in skin chrome that is rebuilt on input (select / result), squeezed horizontally to - * `maxWidth` when needed. Per-frame HUD text uses pooled nodes instead (see the gameplay chrome renderers). - */ -export function addSkinText(layer: Container, text: string, x: number, y: number, options: SkinTextOptions = {}): Text { - const shadow = options.dropShadow; - const node = new Text({ - text, - // Rasterize at the final device density so text stays crisp after the viewport magnifies the design canvas. - resolution: getDesignTextResolution(), - style: new TextStyle({ - fill: options.fill ?? 0xffffff, - fontSize: options.size ?? 10, - fontWeight: options.weight ?? '400', - fontFamily: options.fontFamily ?? DEFAULT_TEXT_FONT, - letterSpacing: options.letterSpacing ?? 0, - stroke: options.stroke, - // Room for the blurred glow; without it the shadow is clipped to the glyph box and shows as a hard rectangle. - padding: shadow ? Math.ceil((shadow.blur ?? 0) * 2 + shadow.distance) : 0, - ...(shadow - ? { - dropShadow: { - color: shadow.color, - alpha: shadow.alpha ?? 1, - blur: shadow.blur ?? 0, - distance: shadow.distance, - angle: shadow.angle ?? Math.PI / 4, - }, - } - : {}), - }), - }); - node.anchor.set(options.anchorX ?? 0, options.anchorY ?? 0); - node.position.set(x, y); - node.skew.set(options.skewX ?? 0, 0); - node.alpha = options.alpha ?? 1; - if (options.maxWidth !== undefined && node.width > options.maxWidth) { - node.scale.x = options.maxWidth / node.width; - } - layer.addChild(node); - return node; -} - -/** `'7K KEYS'`-style mode label for a song's play variant. */ -export function formatPlayVariantLabel(song: BrowserSongEntry): string { - return `${resolveChartPlayVariant(song)} KEYS`; -} - -/** Transparent click target that forwards `pointerdown` to `action`. */ -export function addHitArea( - layer: Container, - x: number, - y: number, - w: number, - h: number, - cursor: string, - action: () => void, -): void { - const hit = new Graphics(); - hit.rect(x, y, w, h).fill({ color: 0xffffff, alpha: 0.001 }); - hit.eventMode = 'static'; - hit.cursor = cursor; - hit.on('pointerdown', action); - layer.addChild(hit); -} diff --git a/packages/player-web/src/scene/default/synesthesia/gameplay-chrome.ts b/packages/player-web/src/scene/default/synesthesia/gameplay-chrome.ts deleted file mode 100644 index d03c6726..00000000 --- a/packages/player-web/src/scene/default/synesthesia/gameplay-chrome.ts +++ /dev/null @@ -1,700 +0,0 @@ -import { Graphics, type Container } from 'pixi.js'; -import { BGA, DESIGN_HEIGHT, DESIGN_WIDTH, GROOVE, PLAYFIELD } from '../../gameplay-constants.ts'; -import type { SkinlessGameplayChromeRenderContext, SkinlessGameplayChromeRuntime } from '../../gameplay-chrome.ts'; -import { resolveFallbackLaneLayout, shouldPreserveFallbackSideWidth } from '../../gameplay-lanes.ts'; -import type { ChildPool } from '../../pixi-utils.ts'; -import { addHudNumber, addHudText, type HudTextOptions } from '../hud-text.ts'; -import { hsvToHex, icosahedron, projectPoint, rotateX, rotateY, starfieldPoint } from './space.ts'; -import { - SYN_AMBER, - SYN_CYAN, - SYN_DEEP, - SYN_DIM, - SYN_DISPLAY_FONT, - SYN_GLASS, - SYN_GREEN, - SYN_MAGENTA, - SYN_MIST, - SYN_RED, - SYN_TEXT_FONT, - SYN_VIOLET, - SYN_VOID, - SYN_WHITE, - sceneHue, -} from './style.ts'; - -type Runtime = SkinlessGameplayChromeRuntime; - -const SCORE_PANEL = { x: 384, y: 352, w: 234, h: 108 } as const; -const SONG_PLATE = { x: 16, y: 420, w: 344, h: 46 } as const; -const PLAYFIELD_FRAME_BOTTOM = 344; -/** Floor grid: horizon y, camera height above the floor, focal length. */ -const FLOOR = { horizon: 326, height: 154, focal: 200 } as const; -const ICOSAHEDRON = icosahedron(); - -/** - * Synesthesia gameplay HUD: a 3D starfield streaming out of a vanishing point, a Rez-style perspective floor grid - * scrolling toward the player, glass panels with glowing rims, and light, wide type. Colour drifts with the song and - * swells on every beat. - */ -export function renderSynesthesiaChrome({ - layer, - overlayLayer, - layerPool, - overlayLayerPool, - runtime, -}: SkinlessGameplayChromeRenderContext): void { - const seconds = (runtime.nowMs ?? 0) / 1000; - const beatPhase = runtime.beatPhase ?? 0; - const pulse = (1 - beatPhase) ** 2; - const hue = sceneHue(seconds, beatPhase); - const accent = hsvToHex(hue, 0.62, 1); - const hasBga = runtime.hasBga === true; - const playfieldRight = resolvePlayfieldRight(runtime); - - const space = layerPool.acquireGraphics(); - space.label = 'synesthesia-gameplay/space'; - drawSpace(space, seconds, pulse, hue, hasBga); - drawFloorGrid(space, seconds, pulse, hue); - - const panels = layerPool.acquireGraphics(); - panels.label = 'synesthesia-gameplay/panels'; - drawPlayfieldWell(panels, playfieldRight, runtime.progressRatio, accent); - drawBgaFrame(panels, layer, hasBga, seconds, pulse, hue, layerPool); - drawGauge(panels, layer, runtime, accent, layerPool); - drawSongPlate(panels, layer, runtime, accent, layerPool); - drawScorePanel(panels, layer, runtime, accent, layerPool); - const tallyX = BGA.x + BGA.w + 22; - if (playfieldRight + 14 <= tallyX) { - drawJudgeTally(panels, layer, runtime, tallyX, layerPool); - } - - const front = overlayLayerPool.acquireGraphics(); - front.label = 'synesthesia-gameplay/header'; - drawHeader(front, overlayLayer, runtime, accent, pulse, overlayLayerPool); - drawJudgements(overlayLayer, runtime, playfieldRight, seconds, overlayLayerPool); -} - -function resolvePlayfieldRight(runtime: Runtime): number { - const lanes = resolveFallbackLaneLayout({ - channels: runtime.laneChannels, - laneCount: runtime.laneCount, - playVariant: runtime.playVariant, - x: PLAYFIELD.x, - w: PLAYFIELD.w, - preserveSideWidth: shouldPreserveFallbackSideWidth(runtime.laneChannels, runtime.playVariant), - }); - return Math.max(PLAYFIELD.x + PLAYFIELD.w, ...lanes.map((lane) => lane.x + lane.w)); -} - -function insideBga(x: number, y: number, margin = 0): boolean { - return x > BGA.x - margin && x < BGA.x + BGA.w + margin && y > BGA.y - margin && y < BGA.y + BGA.h + margin; -} - -/** - * Deep-space ground (indigo at the top falling into void), a pair of soft nebulae, and 3D stars flying out of the - * vanishing point. With a live BGA the ground leaves the BGA rect open and stars skip it. - */ -function drawSpace(graphics: Graphics, seconds: number, pulse: number, hue: number, hasBga: boolean): void { - const bands: ReadonlyArray = [ - [SYN_DEEP, 120], - [0x080620, 220], - [0x05051a, 320], - [0x030410, 400], - [SYN_VOID, DESIGN_HEIGHT], - ]; - let top = 0; - for (const [color, bottom] of bands) { - fillAroundBga(graphics, 0, top, DESIGN_WIDTH, bottom - top, color, hasBga); - top = bottom; - } - // Nebulae: stacked translucent discs, placed clear of the BGA rect. - for (const [x, y, radius, color] of [ - [150, 470, 150, SYN_MAGENTA], - [600, 420, 120, hsvToHex(hue, 0.7, 1)], - ] as const) { - for (let ring = 3; ring >= 1; ring -= 1) { - graphics.circle(x, y, (radius * ring) / 3).fill({ color, alpha: 0.025 + 0.012 * pulse }); - } - } - // Starfield streaming toward the camera from the horizon. - const cx = 320; - const cy = FLOOR.horizon - 90; - for (let index = 0; index < 120; index += 1) { - const point = starfieldPoint(index, seconds, { spread: 520, near: 20, far: 900, speed: 110 + 40 * pulse }); - const projected = projectPoint(point, cx, cy, 180); - if (!projected.visible) continue; - if (projected.x < 0 || projected.x > DESIGN_WIDTH || projected.y < 0 || projected.y > DESIGN_HEIGHT) continue; - if (hasBga && insideBga(projected.x, projected.y, 4)) continue; - const nearness = Math.min(1, projected.scale); - const radius = 0.4 + 1.8 * nearness; - const color = hsvToHex(hue + (index % 5) * 0.06, 0.35 + 0.3 * (index % 2), 1); - graphics.circle(projected.x, projected.y, radius * 3).fill({ color, alpha: 0.06 * nearness }); - graphics.circle(projected.x, projected.y, radius).fill({ color: SYN_WHITE, alpha: 0.25 + 0.6 * nearness }); - } -} - -/** Rez-style floor: radial lines into the horizon and cross lines scrolling toward the player on the beat. */ -function drawFloorGrid(graphics: Graphics, seconds: number, pulse: number, hue: number): void { - const { horizon, height, focal } = FLOOR; - const color = hsvToHex(hue + 0.08, 0.7, 1); - const project = (x: number, z: number) => projectPoint({ x, y: height, z }, 320, horizon, focal); - for (let x = -1200; x <= 1200; x += 80) { - const near = project(x, 0); - const far = project(x, 2400); - graphics.moveTo(near.x, near.y).lineTo(far.x, far.y).stroke({ color, width: 1, alpha: 0.16 }); - } - const spacing = 90; - const offset = (seconds * 150) % spacing; - for (let z = spacing - offset; z < 2400; z += spacing) { - const left = project(-1200, z); - const right = project(1200, z); - const nearness = 1 - z / 2400; - graphics - .moveTo(left.x, left.y) - .lineTo(right.x, right.y) - .stroke({ color, width: 1, alpha: (0.08 + 0.22 * nearness) * (0.7 + 0.3 * pulse) }); - } - // Horizon glow. - graphics.rect(0, horizon - 1, DESIGN_WIDTH, 2).fill({ color, alpha: 0.18 + 0.2 * pulse }); -} - -function fillAroundBga( - graphics: Graphics, - x: number, - y: number, - w: number, - h: number, - color: number, - hasBga: boolean, -): void { - const right = x + w; - const bottom = y + h; - const holeRight = BGA.x + BGA.w; - const holeBottom = BGA.y + BGA.h; - if (!hasBga || right <= BGA.x || x >= holeRight || bottom <= BGA.y || y >= holeBottom) { - graphics.rect(x, y, w, h).fill(color); - return; - } - if (y < BGA.y) graphics.rect(x, y, w, BGA.y - y).fill(color); - const bandTop = Math.max(y, BGA.y); - const bandBottom = Math.min(bottom, holeBottom); - if (x < BGA.x) graphics.rect(x, bandTop, BGA.x - x, bandBottom - bandTop).fill(color); - if (right > holeRight) graphics.rect(holeRight, bandTop, right - holeRight, bandBottom - bandTop).fill(color); - if (bottom > holeBottom) graphics.rect(x, holeBottom, w, bottom - holeBottom).fill(color); -} - -function glassPanel(graphics: Graphics, x: number, y: number, w: number, h: number, rim: number): void { - graphics.roundRect(x, y, w, h, 6).fill({ color: SYN_GLASS, alpha: 0.62 }); - graphics.roundRect(x - 1.5, y - 1.5, w + 3, h + 3, 7).stroke({ color: rim, width: 3, alpha: 0.08 }); - graphics.roundRect(x, y, w, h, 6).stroke({ color: rim, width: 1, alpha: 0.45 }); - graphics.rect(x + 10, y, w - 20, 1).fill({ color: SYN_WHITE, alpha: 0.35 }); -} - -function drawPlayfieldWell(graphics: Graphics, right: number, progressRatio: number | undefined, accent: number): void { - const left = PLAYFIELD.x - 4; - const w = right - left + 4; - graphics.rect(left, 0, w, PLAYFIELD_FRAME_BOTTOM).fill({ color: SYN_VOID, alpha: 0.72 }); - // Glowing side rails. - for (const x of [left - 1, right + 2]) { - graphics.rect(x - 2, 0, 5, PLAYFIELD_FRAME_BOTTOM).fill({ color: accent, alpha: 0.06 }); - graphics.rect(x, 0, 1, PLAYFIELD_FRAME_BOTTOM).fill({ color: accent, alpha: 0.6 }); - } - // Song progress as a light rising along the left rail. - const ratio = - progressRatio !== undefined && Number.isFinite(progressRatio) ? Math.max(0, Math.min(1, progressRatio)) : 0; - const trackH = PLAYFIELD_FRAME_BOTTOM - 12; - if (ratio > 0) { - const y = 6 + trackH * (1 - ratio); - graphics.rect(left - 3, y, 5, trackH * ratio).fill({ color: SYN_WHITE, alpha: 0.12 }); - graphics.circle(left - 1, y, 3).fill({ color: SYN_WHITE, alpha: 0.95 }); - graphics.circle(left - 1, y, 7).fill({ color: accent, alpha: 0.25 }); - } - graphics.rect(left - 1, PLAYFIELD_FRAME_BOTTOM, w + 4, 1).fill({ color: accent, alpha: 0.7 }); -} - -/** - * Monitor frame: a thin glowing rim with corner ticks. With no BGA the screen idles on a slowly tumbling wireframe - * icosahedron wrapped in an orbiting ring of light — the "nothing is playing, but the space is alive" state. - */ -function drawBgaFrame( - graphics: Graphics, - layer: Container, - hasBga: boolean, - seconds: number, - pulse: number, - hue: number, - pool: ChildPool, -): void { - const color = hsvToHex(hue, 0.55, 1); - const margin = 6; - graphics - .rect(BGA.x - margin, BGA.y - margin, BGA.w + margin * 2, BGA.h + margin * 2) - .stroke({ color, width: 4, alpha: 0.07 }); - graphics - .rect(BGA.x - margin, BGA.y - margin, BGA.w + margin * 2, BGA.h + margin * 2) - .stroke({ color, width: 1, alpha: 0.5 }); - const tick = 16; - for (const [x, y, dx, dy] of [ - [BGA.x - margin, BGA.y - margin, 1, 1], - [BGA.x + BGA.w + margin, BGA.y - margin, -1, 1], - [BGA.x - margin, BGA.y + BGA.h + margin, 1, -1], - [BGA.x + BGA.w + margin, BGA.y + BGA.h + margin, -1, -1], - ] as const) { - graphics.rect(dx > 0 ? x : x - tick, y - (dy < 0 ? 2 : 0), tick, 2).fill({ color: SYN_WHITE, alpha: 0.9 }); - graphics.rect(x - (dx < 0 ? 2 : 0), dy > 0 ? y : y - tick, 2, tick).fill({ color: SYN_WHITE, alpha: 0.9 }); - } - if (hasBga) return; - - graphics.rect(BGA.x, BGA.y, BGA.w, BGA.h).fill({ color: SYN_VOID, alpha: 0.85 }); - const cx = BGA.x + BGA.w / 2; - const cy = BGA.y + BGA.h / 2 - 8; - const radius = 64 * (1 + 0.05 * pulse); - const projected = ICOSAHEDRON.vertices.map((vertex) => - projectPoint( - rotateX( - rotateY({ x: vertex.x * radius, y: vertex.y * radius, z: vertex.z * radius }, seconds * 0.5), - 0.4 + seconds * 0.23, - ), - cx, - cy, - 260, - ), - ); - for (const [a, b] of ICOSAHEDRON.edges) { - const pa = projected[a]!; - const pb = projected[b]!; - const depth = (pa.scale + pb.scale) / 2; - graphics - .moveTo(pa.x, pa.y) - .lineTo(pb.x, pb.y) - .stroke({ color, width: 3, alpha: 0.08 * depth }); - graphics - .moveTo(pa.x, pa.y) - .lineTo(pb.x, pb.y) - .stroke({ color: SYN_WHITE, width: 1, alpha: 0.35 + 0.4 * (depth - 0.8) }); - } - for (const point of projected) { - graphics.circle(point.x, point.y, 1.6 * point.scale).fill({ color: SYN_WHITE, alpha: 0.9 }); - } - // Orbiting ring of light points, tilted so it reads in 3D. - for (let index = 0; index < 48; index += 1) { - const angle = (Math.PI * 2 * index) / 48 + seconds * 0.8; - const point = projectPoint( - rotateX({ x: Math.cos(angle) * 108, y: 0, z: Math.sin(angle) * 108 }, 0.42), - cx, - cy, - 260, - ); - graphics.circle(point.x, point.y, 1.2 * point.scale).fill({ - color: hsvToHex(hue + index / 48 / 3, 0.6, 1), - alpha: 0.35 + 0.5 * (point.scale - 0.7), - }); - } - addHudText( - layer, - 'STANDBY', - cx, - BGA.y + BGA.h - 26, - { ...displayStyle(9, SYN_MIST), letterSpacing: 6, anchorX: 0.5 }, - pool, - ); -} - -function drawHeader( - graphics: Graphics, - layer: Container, - runtime: Runtime, - accent: number, - pulse: number, - pool: ChildPool, -): void { - const autoplay = runtime.autoplay === true; - graphics.rect(0, 0, DESIGN_WIDTH, 34).fill({ color: SYN_VOID, alpha: 0.55 }); - graphics.rect(0, 34, DESIGN_WIDTH, 1).fill({ color: accent, alpha: 0.35 + 0.35 * pulse }); - graphics.circle(20, 17, 3).fill({ color: autoplay ? SYN_AMBER : accent, alpha: 1 }); - graphics.circle(20, 17, 8).fill({ color: autoplay ? SYN_AMBER : accent, alpha: 0.18 + 0.2 * pulse }); - addHudText( - layer, - autoplay ? 'AUTO PLAY' : 'PLAY', - 34, - 12, - { ...displayStyle(10, SYN_WHITE), letterSpacing: 3 }, - pool, - ); - addHudText(layer, 'BPM', 150, 14, labelStyle(), pool); - addHudNumber(layer, formatBpm(runtime.bpm), 186, 9, displayStyle(14, SYN_WHITE), pool); - addHudText(layer, 'SPEED', 236, 14, labelStyle(), pool); - addHudNumber(layer, `x${formatHiSpeed(runtime.hiSpeed)}`, 296, 9, displayStyle(14, SYN_WHITE), pool); - addHudText( - layer, - formatRuleset(runtime.rulesetLabel), - 506, - 12, - { ...displayStyle(9, accent), letterSpacing: 3, anchorX: 1, maxWidth: 110 }, - pool, - ); -} - -function drawGauge(graphics: Graphics, layer: Container, runtime: Runtime, accent: number, pool: ChildPool): void { - const gauge = clampPercent(runtime.gauge ?? 0); - const clear = clampPercent(runtime.clearThreshold ?? 80); - const survival = runtime.gaugeSurvival === true || clear <= 0; - const cleared = survival ? gauge > 0 : gauge >= clear; - glassPanel(graphics, GROOVE.x - 14, GROOVE.y - 26, GROOVE.w + 28, 48, accent); - addHudText( - layer, - `${(runtime.gaugeLabel ?? 'GROOVE').toUpperCase()} GAUGE`, - GROOVE.x - 2, - GROOVE.y - 19, - { ...labelStyle(), maxWidth: 130 }, - pool, - ); - addHudNumber( - layer, - `${Math.round(gauge)}%`, - GROOVE.x + GROOVE.w + 4, - GROOVE.y - 22, - { ...displayStyle(12, cleared ? SYN_MAGENTA : SYN_WHITE), anchorX: 1 }, - pool, - ); - const cells = 50; - const stride = GROOVE.w / cells; - const lit = Math.round((gauge / 100) * cells); - const clearCell = Math.round((clear / 100) * cells); - const flicker = runtime.nowMs !== undefined ? 0.65 + 0.35 * Math.abs(Math.sin(runtime.nowMs / 60)) : 1; - const barY = GROOVE.y + 2; - const barH = GROOVE.h - 4; - if (lit > 0) { - graphics - .rect(GROOVE.x, barY - 3, lit * stride, barH + 6) - .fill({ color: cleared ? SYN_MAGENTA : SYN_CYAN, alpha: 0.12 }); - } - for (let cell = 0; cell < cells; cell += 1) { - const x = GROOVE.x + cell * stride; - if (cell >= lit) { - graphics.rect(x, barY, stride - 1, barH).fill({ color: SYN_DIM, alpha: 0.18 }); - continue; - } - const hot = survival || cell >= clearCell; - const color = hot ? hsvToHex(0.9 - (cell / cells) * 0.05, 0.65, 1) : hsvToHex(0.5 + (cell / cells) * 0.12, 0.6, 1); - graphics - .rect(x, barY, stride - 1, barH) - .fill({ color: cell === lit - 1 ? SYN_WHITE : color, alpha: cell === lit - 1 ? flicker : 0.95 }); - } - if (!survival) { - const x = GROOVE.x + clearCell * stride - 1; - graphics.rect(x, barY - 5, 1.5, barH + 10).fill({ color: SYN_AMBER, alpha: 1 }); - } -} - -function drawSongPlate(graphics: Graphics, layer: Container, runtime: Runtime, accent: number, pool: ChildPool): void { - const { x, y, w, h } = SONG_PLATE; - glassPanel(graphics, x, y, w, h, accent); - addHudText( - layer, - runtime.songTitle?.trim() || 'Untitled chart', - x + 14, - y + 6, - { - size: 15, - weight: '500', - fill: SYN_WHITE, - fontFamily: SYN_TEXT_FONT, - maxWidth: w - 28, - dropShadow: { color: accent, alpha: 0.7, blur: 6, distance: 0 }, - }, - pool, - ); - const artist = runtime.songArtist?.trim(); - if (artist) { - addHudText( - layer, - artist, - x + 14, - y + 28, - { size: 10, weight: '300', fill: SYN_MIST, fontFamily: SYN_TEXT_FONT, maxWidth: w - 28 }, - pool, - ); - } -} - -function drawScorePanel(graphics: Graphics, layer: Container, runtime: Runtime, accent: number, pool: ChildPool): void { - const { x, y, w, h } = SCORE_PANEL; - glassPanel(graphics, x, y, w, h, accent); - graphics.rect(x + 146, y + 14, 1, h - 28).fill({ color: accent, alpha: 0.3 }); - addHudText(layer, 'SCORE', x + 14, y + 12, labelStyle(), pool); - addHudNumber( - layer, - formatCount(runtime.score), - x + 136, - y + 22, - { - ...displayStyle(17, SYN_WHITE), - anchorX: 1, - maxWidth: 120, - dropShadow: { color: accent, alpha: 0.8, blur: 6, distance: 0 }, - }, - pool, - ); - addHudText(layer, 'EX SCORE', x + 14, y + 54, labelStyle(), pool); - addHudNumber( - layer, - `${formatCount(runtime.exScore)}/${formatCount(runtime.exScoreMax)}`, - x + 136, - y + 52, - { ...displayStyle(8, SYN_MIST), anchorX: 1, maxWidth: 56 }, - pool, - ); - addHudText(layer, 'EX RATE', x + 14, y + 76, labelStyle(), pool); - addHudNumber( - layer, - formatExRate(runtime.exScore, runtime.exScoreMax), - x + 136, - y + 74, - { ...displayStyle(8, SYN_MIST), anchorX: 1, maxWidth: 56 }, - pool, - ); - // Rate meter as a light filament with the IIDX ninth ticks. - const rate = - runtime.exScore !== undefined && runtime.exScoreMax !== undefined && runtime.exScoreMax > 0 - ? Math.max(0, Math.min(1, runtime.exScore / runtime.exScoreMax)) - : 0; - const meterX = x + 14; - const meterW = 122; - graphics.rect(meterX, y + 94, meterW, 2).fill({ color: SYN_DIM, alpha: 0.3 }); - if (rate > 0) { - graphics.rect(meterX, y + 92, meterW * rate, 6).fill({ color: accent, alpha: 0.15 }); - graphics.rect(meterX, y + 94, meterW * rate, 2).fill({ color: rate >= 8 / 9 ? SYN_AMBER : SYN_WHITE, alpha: 0.95 }); - } - for (let ninth = 1; ninth < 9; ninth += 1) { - graphics - .rect(meterX + (meterW * ninth) / 9, y + 91, 1, 8) - .fill({ color: SYN_WHITE, alpha: ninth >= 6 ? 0.45 : 0.18 }); - } - addHudText(layer, 'COMBO', x + 158, y + 12, labelStyle(), pool); - addHudNumber( - layer, - formatCount(runtime.combo), - x + w - 12, - y + 24, - { ...displayStyle(14, SYN_WHITE), anchorX: 1, maxWidth: 62 }, - pool, - ); - addHudText(layer, 'MAX', x + 158, y + 54, labelStyle(), pool); - addHudNumber( - layer, - formatCount(runtime.maxCombo), - x + w - 12, - y + 52, - { ...displayStyle(9, SYN_MIST), anchorX: 1, maxWidth: 48 }, - pool, - ); - const rank = runtime.rank && runtime.rank !== '-' ? runtime.rank : 'F'; - addHudText(layer, 'RANK', x + 158, y + 76, labelStyle(), pool); - addHudText( - layer, - rank, - x + w - 12, - y + 84, - { - ...displayStyle(rank.length >= 3 ? 12 : 16, SYN_AMBER), - anchorX: 1, - maxWidth: 46, - dropShadow: { color: SYN_AMBER, alpha: 0.8, blur: 8, distance: 0 }, - }, - pool, - ); -} - -const TALLY: ReadonlyArray = [ - ['PG', 'perfect'], - ['GR', 'great'], - ['GD', 'good'], - ['BD', 'bad'], - ['PR', 'poor'], -]; - -function drawJudgeTally(graphics: Graphics, layer: Container, runtime: Runtime, x: number, pool: ChildPool): void { - const y = BGA.y - 6; - const w = DESIGN_WIDTH - x - 6; - glassPanel(graphics, x, y, w, 186, SYN_VIOLET); - for (let row = 0; row < TALLY.length; row += 1) { - const [label, key] = TALLY[row]!; - const rowY = y + 14 + row * 26; - const color = JUDGE_COLORS[TALLY_NAMES[key]] ?? SYN_WHITE; - graphics.circle(x + 9, rowY + 6, 2).fill({ color, alpha: 1 }); - graphics.circle(x + 9, rowY + 6, 5).fill({ color, alpha: 0.2 }); - addHudText(layer, label, x + 17, rowY + 1, { ...labelStyle(), fill: color }, pool); - addHudNumber( - layer, - formatCount(runtime[key]), - x + w - 7, - rowY - 1, - { ...displayStyle(10, SYN_WHITE), anchorX: 1, maxWidth: w - 36 }, - pool, - ); - } - const footerY = y + 14 + TALLY.length * 26 + 4; - graphics.rect(x + 8, footerY - 6, w - 16, 1).fill({ color: SYN_VIOLET, alpha: 0.4 }); - for (const [row, label, color, count] of [ - [0, 'FAST', SYN_CYAN, runtime.fast], - [1, 'SLOW', SYN_MAGENTA, runtime.slow], - ] as const) { - const rowY = footerY + row * 20; - addHudText(layer, label, x + 8, rowY + 2, { ...labelStyle(), fill: color }, pool); - addHudNumber( - layer, - formatCount(count), - x + w - 7, - rowY, - { ...displayStyle(9, SYN_WHITE), anchorX: 1, maxWidth: w - 40 }, - pool, - ); - } -} - -const TALLY_NAMES = { perfect: 'PERFECT', great: 'GREAT', good: 'GOOD', bad: 'BAD', poor: 'POOR' } as const; -const JUDGE_COLORS: Record = { - PERFECT: 0xc8fbff, - GREAT: SYN_AMBER, - GOOD: SYN_GREEN, - BAD: SYN_VIOLET, - POOR: SYN_RED, -}; - -/** - * Judgement word and combo as light: wide tracked type with a same-colour bloom. PERFECT cycles through the hue wheel - * like a prism so a clean run literally shimmers. - */ -function drawJudgements( - layer: Container, - runtime: Runtime, - playfieldRight: number, - seconds: number, - pool: ChildPool, -): void { - const displays = resolveJudgeDisplays(runtime, playfieldRight); - for (const display of displays) { - const color = - display.judge === 'PERFECT' - ? hsvToHex(seconds * 1.4 + display.x / 400, 0.35, 1) - : (JUDGE_COLORS[display.judge] ?? SYN_WHITE); - addHudText( - layer, - display.judge, - display.x, - 236, - { - ...displayStyle(16, color), - letterSpacing: 4, - anchorX: 0.5, - anchorY: 0.5, - maxWidth: display.maxWidth, - dropShadow: { color, alpha: 0.95, blur: 10, distance: 0 }, - }, - pool, - ); - const combo = resolveVisibleCombo(display.judge, display.combo); - if (combo > 0) { - addHudNumber( - layer, - formatCount(combo), - display.x, - 264, - { - ...displayStyle(22, combo >= 200 ? SYN_AMBER : SYN_WHITE), - anchorX: 0.5, - anchorY: 0.5, - maxWidth: Math.max(60, display.maxWidth - 40), - dropShadow: { color: combo >= 200 ? SYN_AMBER : SYN_CYAN, alpha: 0.85, blur: 10, distance: 0 }, - }, - pool, - ); - } - } -} - -function resolveJudgeDisplays( - runtime: Runtime, - playfieldRight: number, -): Array<{ judge: string; combo: number | undefined; x: number; maxWidth: number }> { - const lanes = resolveFallbackLaneLayout({ - channels: runtime.laneChannels, - laneCount: runtime.laneCount, - playVariant: runtime.playVariant, - x: PLAYFIELD.x, - w: PLAYFIELD.w, - preserveSideWidth: shouldPreserveFallbackSideWidth(runtime.laneChannels, runtime.playVariant), - }); - const centerOf = (side: '1P' | '2P'): number | undefined => { - const own = lanes.filter((lane) => lane.side === side); - if (own.length === 0) return undefined; - return (Math.min(...own.map((lane) => lane.x)) + Math.max(...own.map((lane) => lane.x + lane.w))) / 2; - }; - const doublePlay = centerOf('2P') !== undefined; - const maxWidth = doublePlay ? 122 : 180; - const fallbackX = (PLAYFIELD.x + playfieldRight) / 2; - const sides = runtime.judgeSides?.filter((state) => typeof state.judge === 'string' && state.judge.length > 0); - if (sides?.length) { - return sides.map((state) => ({ - judge: state.judge!, - combo: state.combo, - x: centerOf(state.side) ?? fallbackX, - maxWidth, - })); - } - if (!runtime.lastJudge) return []; - return [{ judge: runtime.lastJudge, combo: runtime.combo, x: centerOf('1P') ?? fallbackX, maxWidth }]; -} - -function resolveVisibleCombo(judge: string, combo: number | undefined): number { - if (judge !== 'PERFECT' && judge !== 'GREAT' && judge !== 'GOOD') return 0; - return combo !== undefined && Number.isFinite(combo) ? Math.max(0, Math.floor(combo)) : 0; -} - -function labelStyle(): HudTextOptions { - return { size: 7, fill: SYN_DIM, fontFamily: SYN_DISPLAY_FONT, letterSpacing: 2 }; -} - -function displayStyle(size: number, fill: number): HudTextOptions { - return { size, fill, fontFamily: SYN_DISPLAY_FONT, weight: '400' }; -} - -function formatCount(value: number | undefined): string { - if (value === undefined || !Number.isFinite(value)) return '0'; - return String(Math.max(0, Math.floor(value))); -} - -function formatBpm(value: number | undefined): string { - if (value === undefined || !Number.isFinite(value)) return '---'; - return String(Math.round(value)); -} - -function formatHiSpeed(value: number | undefined): string { - if (value === undefined || !Number.isFinite(value)) return '0.0'; - return value.toFixed(1); -} - -function formatRuleset(value: string | undefined): string { - switch (value) { - case 'beatoraja': - return 'BEATORAJA'; - case 'iidx': - return 'IIDX'; - default: - return 'LR2'; - } -} - -function formatExRate(exScore: number | undefined, max: number | undefined): string { - if (exScore === undefined || max === undefined || !Number.isFinite(exScore) || !Number.isFinite(max) || max <= 0) { - return '0.0%'; - } - return `${((exScore / max) * 100).toFixed(1)}%`; -} - -function clampPercent(value: number): number { - if (!Number.isFinite(value)) return 0; - return Math.max(0, Math.min(100, value)); -} diff --git a/packages/player-web/src/scene/default/synesthesia/playfield.ts b/packages/player-web/src/scene/default/synesthesia/playfield.ts deleted file mode 100644 index 9384b71a..00000000 --- a/packages/player-web/src/scene/default/synesthesia/playfield.ts +++ /dev/null @@ -1,296 +0,0 @@ -import { Color, FillGradient, type Graphics } from 'pixi.js'; -import type { - BeMusicBomb, - BeMusicBombsContext, - BeMusicLaneKind, - BeMusicLanesContext, - BeMusicLongNoteContext, - BeMusicNoteContext, -} from '../../../skin/be-music/types.ts'; -import type { ChildPool } from '../../pixi-utils.ts'; -import { - burstParticlePosition, - burstParticles, - hsvToHex, - projectPoint, - rotateY, - type BurstParticle, - type Vec3, -} from './space.ts'; -import { SYN_GLOW_SIZE, synGlowTexture } from './style.ts'; - -/** Per-lane-class light: note body, glow hue (0..1), and the colour the lane beam / key cap light up in. */ -interface LaneLight { - body: number; - glow: number; - hue: number; -} - -// Readable first: white keys are icy white, black keys blue-violet, scratch magenta — each with a soft same-hue halo. -const LIGHTS: Record = { - white: { body: 0xe9fbff, glow: 0x5ff4ff, hue: 0.52 }, - black: { body: 0x8aa2ff, glow: 0x8f74ff, hue: 0.7 }, - scratch: { body: 0xff7ae3, glow: 0xff4fd8, hue: 0.88 }, -}; - -const NOTE_HEIGHT = 8; -export const SYNESTHESIA_BOMB_DURATION_MS = 620; - -export function renderSynesthesiaLanes({ graphics, lanes, beatPhase }: BeMusicLanesContext): void { - const pulse = (1 - beatPhase) ** 2; - let gridTop = Number.POSITIVE_INFINITY; - let gridBottom = 0; - let gridLeft = Number.POSITIVE_INFINITY; - let gridRight = 0; - for (const lane of lanes) { - const { x, w, top, bottom } = lane; - const light = LIGHTS[lane.kind]; - const laneHeight = Math.max(1, bottom - top); - gridTop = Math.min(gridTop, top); - gridBottom = Math.max(gridBottom, bottom); - gridLeft = Math.min(gridLeft, x); - gridRight = Math.max(gridRight, x + w); - - // Dark glass bed, so the starfield behind the playfield reads only as a faint depth cue. - graphics.rect(x, top, w, laneHeight).fill({ color: 0x03040f, alpha: 0.78 }); - graphics.rect(x, top, 1, laneHeight).fill({ fill: resolveColumnGradient(light.glow), alpha: 0.5 }); - - if (lane.beam > 0) { - const beamHeight = Math.min(laneHeight, 220); - const beamAlpha = lane.beam * lane.beam * (3 - 2 * lane.beam); - graphics - .rect(x + 1, bottom - beamHeight, w - 2, beamHeight) - .fill({ fill: resolveColumnGradient(light.glow), alpha: beamAlpha * 0.45 }); - graphics - .rect(x + 1, bottom - 26, w - 2, 26) - .fill({ fill: resolveColumnGradient(0xffffff), alpha: beamAlpha * 0.25 }); - } - - // Key caps: a glass sliver that lights up in the lane's colour on press. - const pressed = lane.beam > 0.6; - graphics - .rect(x + 1, bottom + 4, Math.max(2, w - 2), 10) - .fill({ color: pressed ? light.glow : 0x0c1030, alpha: 0.9 }); - graphics.rect(x + 1, bottom + 13, Math.max(2, w - 2), 1).fill({ color: light.glow, alpha: pressed ? 1 : 0.45 }); - } - if (lanes.length === 0) return; - graphics.rect(gridRight - 1, gridTop, 1, Math.max(1, gridBottom - gridTop)).fill({ color: 0x5ff4ff, alpha: 0.25 }); - - // Judgement line: a white filament wrapped in stacked cyan bloom that swells on each beat. - const lineW = gridRight - gridLeft; - const y = gridBottom; - graphics.rect(gridLeft, y - 14, lineW, 22).fill({ color: 0x5ff4ff, alpha: 0.05 + 0.08 * pulse }); - graphics.rect(gridLeft, y - 6, lineW, 8).fill({ color: 0x5ff4ff, alpha: 0.16 + 0.14 * pulse }); - graphics.rect(gridLeft, y - 3, lineW, 3).fill({ color: 0x9ff8ff, alpha: 0.85 }); - graphics.rect(gridLeft, y - 2, lineW, 1).fill(0xffffff); -} - -export function renderSynesthesiaNote({ graphics, kind, x, w, y }: BeMusicNoteContext): void { - drawNote(graphics, x + 2, y, Math.max(4, w - 4), LIGHTS[kind]); -} - -export function renderSynesthesiaLongNote({ graphics, kind, x, w, top, bottom }: BeMusicLongNoteContext): void { - const light = LIGHTS[kind]; - const bodyX = x + 2; - const bodyW = Math.max(4, w - 4); - const bodyTop = top - NOTE_HEIGHT; - const bodyH = Math.max(1, bottom - top); - // A beam of light between head and tail: soft outer glow, translucent core, bright centre filament. - graphics.rect(bodyX - 2, bodyTop, bodyW + 4, bodyH).fill({ color: light.glow, alpha: 0.08 }); - graphics.rect(bodyX + 1, bodyTop, bodyW - 2, bodyH).fill({ color: light.body, alpha: 0.22 }); - graphics.rect(bodyX + bodyW / 2 - 1, bodyTop, 2, bodyH).fill({ color: 0xffffff, alpha: 0.55 }); - drawNote(graphics, bodyX, bottom, bodyW, light); - drawNote(graphics, bodyX, top, bodyW, light); -} - -/** A flat bar with a white filament on top and a same-hue halo — glowing, but still a plain rectangle to read. */ -function drawNote(graphics: Graphics, x: number, y: number, w: number, light: LaneLight): void { - graphics.rect(x - 3, y - NOTE_HEIGHT - 3, w + 6, NOTE_HEIGHT + 6).fill({ color: light.glow, alpha: 0.14 }); - graphics.rect(x, y - NOTE_HEIGHT, w, NOTE_HEIGHT).fill(light.body); - graphics.rect(x, y - NOTE_HEIGHT, w, 2).fill({ color: 0xffffff, alpha: 0.95 }); -} - -const BURST_PARTICLES = 52; -const BURST_FOCAL = 260; -const PARTICLE_CACHE = new Map(); - -function cachedBurst(seed: number): BurstParticle[] { - let particles = PARTICLE_CACHE.get(seed); - if (!particles) { - particles = burstParticles(seed, BURST_PARTICLES); - PARTICLE_CACHE.set(seed, particles); - // Live bombs never exceed a few dozen; keep the cache bounded. - if (PARTICLE_CACHE.size > 64) { - const oldest = PARTICLE_CACHE.keys().next().value; - if (oldest !== undefined) PARTICLE_CACHE.delete(oldest); - } - } - return particles; -} - -/** - * The Synesthesia hit: a 3D particle burst in perspective. Layered back to front — - * - * 1. a light pillar rising up the lane, - * 2. two shockwave rings lying in the XZ plane (so they read as tilted ellipses) expanding out of the line, - * 3. motion trails for every spark, - * 4. depth-sorted glow sparks that launch white-hot and cool into the lane's hue, - * 5. a core flash and an anamorphic streak across the line. - * - * Everything is additive, so overlapping hits bloom into each other like light instead of stacking opaque shapes. - */ -export function renderSynesthesiaBombs({ pool, bombs }: BeMusicBombsContext): void { - for (const bomb of bombs) { - renderBomb(pool, bomb); - } -} - -function renderBomb(pool: ChildPool, bomb: BeMusicBomb): void { - const t = Math.max(0, Math.min(1, bomb.elapsedMs / SYNESTHESIA_BOMB_DURATION_MS)); - if (t >= 1) return; - const light = LIGHTS[bomb.kind]; - const cx = bomb.x + bomb.w / 2; - const cy = bomb.y - 3; - const unit = Math.max(10, bomb.w); - const fade = 1 - t; - // Slow spin around the vertical axis gives the burst parallax as it expands. - const spin = (bomb.seed % 2 === 0 ? 1 : -1) * t * 0.9; - const project = (point: Vec3) => projectPoint(rotateY(point, spin), cx, cy, BURST_FOCAL); - - const effects = pool.acquireGraphics(); - effects.label = `synesthesia-bomb[ch=${bomb.channel}]`; - effects.blendMode = 'add'; - - // 1. Light pillar. - const pillarH = unit * 5.5 * (0.6 + 0.4 * fade); - effects - .rect(bomb.x + 1, cy - pillarH, bomb.w - 2, pillarH) - .fill({ fill: resolveColumnGradient(light.glow), alpha: 0.55 * fade * fade }); - - // 2. Shockwave rings in the XZ plane — the second one trails slightly and tilts the other way. - for (const [delay, tilt, strength] of [ - [0, 0.35, 1], - [0.12, -0.25, 0.6], - ] as const) { - const ringT = Math.max(0, (t - delay) / (1 - delay)); - if (ringT <= 0 || ringT >= 1) continue; - const radius = unit * (0.5 + 2.6 * (1 - (1 - ringT) ** 2)); - const ring: number[] = []; - for (let step = 0; step <= 36; step += 1) { - const angle = (Math.PI * 2 * step) / 36; - const point = project({ - x: Math.cos(angle) * radius, - y: Math.sin(angle) * radius * tilt, - z: Math.sin(angle) * radius, - }); - ring.push(point.x, point.y); - } - effects.poly(ring, false).stroke({ - color: hsvToHex(light.hue + 0.04 * delay, 0.55, 1), - width: 0.6 + 2.2 * (1 - ringT), - alpha: 0.75 * strength * (1 - ringT), - }); - } - - // 3 + 4. Sparks: compute every projected position once, draw trails, then depth-sorted glow sprites. - const particles = cachedBurst(bomb.seed); - const radius = unit * 4.6; - const gravity = unit * 3.2; - const sparks: Array<{ x: number; y: number; z: number; scale: number; life: number; particle: BurstParticle }> = []; - // Fountain shaping: narrow the lateral spread so neighbouring lanes don't merge into one white band, and add an - // upward launch so sparks visibly leap off the line before gravity bends them back. - const fountain = (particle: BurstParticle, life: number): Vec3 => { - const position = burstParticlePosition(particle, life, radius, gravity); - return { - x: position.x * 0.55, - y: position.y * 1.1 - unit * 2.8 * particle.speed * (1 - (1 - life) ** 2), - z: position.z * 0.8, - }; - }; - for (const particle of particles) { - const life = t / particle.life; - if (life >= 1) continue; - const position = fountain(particle, life); - const head = project(position); - if (!head.visible) continue; - const tail = project(fountain(particle, Math.max(0, life - 0.14))); - const trailAlpha = (1 - life) * 0.6; - if (trailAlpha > 0.02) { - effects - .moveTo(tail.x, tail.y) - .lineTo(head.x, head.y) - .stroke({ - color: hsvToHex(light.hue + particle.hueShift, Math.min(0.9, 0.35 + life * 2), 1), - width: Math.max(0.8, 2.2 * head.scale * particle.size), - alpha: trailAlpha, - }); - } - sparks.push({ x: head.x, y: head.y, z: position.z, scale: head.scale, life, particle }); - } - sparks.sort((left, right) => right.z - left.z); - const glow = synGlowTexture(); - for (const spark of sparks) { - const sprite = pool.acquireSprite(); - sprite.texture = glow; - sprite.anchor.set(0.5); - sprite.blendMode = 'add'; - const size = (6 + 12 * spark.particle.size) * spark.scale * Math.sqrt(1 - spark.life); - sprite.width = size; - sprite.height = size; - sprite.position.set(spark.x, spark.y); - // White-hot at launch, cooling into the lane's hue. - sprite.tint = hsvToHex(light.hue + spark.particle.hueShift, Math.min(0.9, 0.45 + spark.life * 1.8), 1); - sprite.alpha = (1 - spark.life) ** 0.8; - } - - // 5. Core flash + anamorphic streak. - const flash = Math.max(0, 1 - t * 5); - if (flash > 0) { - const core = pool.acquireSprite(); - core.texture = glow; - core.anchor.set(0.5); - core.blendMode = 'add'; - const coreSize = (unit * 1.3 + unit * 2 * t) * (SYN_GLOW_SIZE / 64); - core.width = coreSize; - core.height = coreSize; - core.position.set(cx, cy); - core.tint = hsvToHex(light.hue, t * 2, 1); - core.alpha = flash * 0.7; - } - const streakAlpha = fade ** 2.5; - if (streakAlpha > 0.02) { - const streak = pool.acquireSprite(); - streak.texture = glow; - streak.anchor.set(0.5); - streak.blendMode = 'add'; - streak.width = unit * (6 + 5 * t); - streak.height = Math.max(2, unit * 0.32 * fade); - streak.position.set(cx, cy); - streak.tint = hsvToHex(light.hue, 0.35, 1); - streak.alpha = streakAlpha * 0.6; - } -} - -const COLUMN_GRADIENTS = new Map(); - -/** Vertical light column for `color`: transparent at the top, brightening toward the base. Local texture space. */ -function resolveColumnGradient(color: number): FillGradient { - let gradient = COLUMN_GRADIENTS.get(color); - if (!gradient) { - const rgb = new Color(color); - gradient = new FillGradient({ - type: 'linear', - start: { x: 0, y: 0 }, - end: { x: 0, y: 1 }, - textureSpace: 'local', - colorStops: [ - { offset: 0, color: rgb.setAlpha(0).toRgbaString() }, - { offset: 0.5, color: rgb.setAlpha(0.1).toRgbaString() }, - { offset: 0.85, color: rgb.setAlpha(0.32).toRgbaString() }, - { offset: 1, color: rgb.setAlpha(0.6).toRgbaString() }, - ], - }); - COLUMN_GRADIENTS.set(color, gradient); - } - return gradient; -} diff --git a/packages/player-web/src/scene/default/synesthesia/result.ts b/packages/player-web/src/scene/default/synesthesia/result.ts deleted file mode 100644 index d1505690..00000000 --- a/packages/player-web/src/scene/default/synesthesia/result.ts +++ /dev/null @@ -1,350 +0,0 @@ -import { Container, Graphics } from 'pixi.js'; -import type { BeMusicResultFrame, BeMusicResultSkin } from '../../../skin/be-music/types.ts'; -import { easeOutBack, easeOutCubic, rollUpValue, stageProgress } from '../phantom-style.ts'; -import { addSkinText, type SkinTextOptions } from '../skin-text.ts'; -import { - burstParticlePosition, - burstParticles, - hsvToHex, - projectPoint, - rotateX, - rotateY, - starfieldPoint, -} from './space.ts'; -import { - SYN_AMBER, - SYN_CYAN, - SYN_DEEP, - SYN_DIM, - SYN_DISPLAY_FONT, - SYN_GLASS, - SYN_GREEN, - SYN_MAGENTA, - SYN_MIST, - SYN_RED, - SYN_TEXT_FONT, - SYN_VIOLET, - SYN_VOID, - SYN_WHITE, - sceneHue, -} from './style.ts'; - -const ROLL_DELAY_MS = 700; -const ROLL_MS = 1100; -const RANK_DELAY_MS = 1600; -const RANK_BURST = burstParticles(29, 90); - -export const synesthesiaResultSkin: BeMusicResultSkin = { render: (frame) => renderSynesthesiaResult(frame) }; - -/** - * Synesthesia result: deep space with streaming stars and a floor grid; the verdict condenses out of wide-tracked - * light, glass panels fade up in sequence while counters roll, graphs draw as glowing filaments, and the rank letter - * ignites inside a spinning 3D ring of particles that bursts outward the moment it lands. - */ -export function renderSynesthesiaResult(frame: BeMusicResultFrame): void { - const { result, designWidth, designHeight, elapsedMs: elapsed, nowMs, rankLabel, layer } = frame; - const seconds = nowMs / 1000; - const hue = sceneHue(seconds); - const accent = hsvToHex(hue, 0.6, 1); - const cleared = result.cleared; - const verdictColor = cleared ? SYN_CYAN : SYN_RED; - const exMax = Math.max(0, result.score.total * 2); - const roll = stageProgress(elapsed, ROLL_DELAY_MS, ROLL_MS); - - const group = (label: string): { root: Container; g: Graphics } => { - const root = new Container(); - root.label = `synesthesia-result/${label}`; - const g = new Graphics(); - root.addChild(g); - layer.addChild(root); - return { root, g }; - }; - const text = (target: Container, value: string, x: number, y: number, options: SkinTextOptions) => - addSkinText(target, value, x, y, options); - /** Fade + rise a group in. */ - const rise = (root: Container, delayMs: number, distance = 18, durationMs = 520): void => { - const eased = easeOutCubic(stageProgress(elapsed, delayMs, durationMs)); - root.y = distance * (1 - eased); - root.alpha = eased; - }; - const display = (size: number, fill: number, extra: SkinTextOptions = {}): SkinTextOptions => ({ - size, - fill, - fontFamily: SYN_DISPLAY_FONT, - ...extra, - }); - const label = (value: string, x: number, y: number, target: Container, fill: number = SYN_DIM) => - text(target, value, x, y, display(7, fill, { letterSpacing: 2.5 })); - - // Space. - const space = group('space'); - const bands: ReadonlyArray = [ - [SYN_DEEP, 0.25], - [0x080620, 0.45], - [0x05051a, 0.65], - [0x030410, 0.82], - [SYN_VOID, 1], - ]; - let top = 0; - for (const [color, until] of bands) { - const bottom = designHeight * until; - space.g.rect(0, top, designWidth, bottom - top).fill(color); - top = bottom; - } - for (let index = 0; index < 110; index += 1) { - const point = starfieldPoint(index, seconds, { spread: 560, near: 20, far: 900, speed: 90 }); - const projected = projectPoint(point, designWidth / 2, designHeight * 0.45, 180); - if (!projected.visible) continue; - const nearness = Math.min(1, projected.scale); - const color = hsvToHex(hue + (index % 5) * 0.06, 0.4, 1); - space.g.circle(projected.x, projected.y, (0.5 + 2 * nearness) * 3).fill({ color, alpha: 0.05 * nearness }); - space.g - .circle(projected.x, projected.y, 0.5 + 2 * nearness) - .fill({ color: SYN_WHITE, alpha: 0.2 + 0.6 * nearness }); - } - const horizon = designHeight * 0.74; - const floor = (x: number, z: number) => projectPoint({ x, y: 130, z }, designWidth / 2, horizon, 200); - for (let x = -1400; x <= 1400; x += 100) { - const near = floor(x, 0); - const far = floor(x, 2600); - space.g.moveTo(near.x, near.y).lineTo(far.x, far.y).stroke({ color: accent, width: 1, alpha: 0.1 }); - } - const offset = (seconds * 110) % 100; - for (let z = 100 - offset; z < 2600; z += 100) { - const left = floor(-1400, z); - const right = floor(1400, z); - space.g - .moveTo(left.x, left.y) - .lineTo(right.x, right.y) - .stroke({ color: accent, width: 1, alpha: 0.05 + 0.16 * (1 - z / 2600) }); - } - - // Verdict: tracking condenses from wide to settled while it fades in. - const verdict = group('verdict'); - const verdictT = easeOutCubic(stageProgress(elapsed, 100, 900)); - verdict.g.rect(0, 44, designWidth, 1).fill({ color: verdictColor, alpha: 0.5 * verdictT }); - text(verdict.root, cleared ? 'STAGE CLEAR' : 'FAILED', 24, 14, { - ...display(15, SYN_WHITE), - letterSpacing: 6 + 22 * (1 - verdictT), - alpha: verdictT, - dropShadow: { color: verdictColor, distance: 0, blur: 12, alpha: 1 }, - }); - text( - verdict.root, - `${result.song.title}${result.song.artist ? ` / ${result.song.artist}` : ''}`, - designWidth - 20, - 17, - { - size: 12, - weight: '500', - fill: SYN_MIST, - fontFamily: SYN_TEXT_FONT, - anchorX: 1, - maxWidth: 360, - alpha: easeOutCubic(stageProgress(elapsed, 300, 600)), - }, - ); - - // Rank: a spinning 3D particle ring that bursts outward as the letter ignites. - const rank = group('rank'); - const rankCx = 110; - const rankCy = 150; - const topRank = rankLabel === 'AAA' || rankLabel === 'AA'; - const rankColor = topRank ? SYN_AMBER : accent; - glass(rank.g, 20, 66, 180, 164, rankColor); - label('DJ LEVEL', 34, 78, rank.root, rankColor); - const ringIn = easeOutCubic(stageProgress(elapsed, 400, 900)); - for (let index = 0; index < 60; index += 1) { - const angle = (Math.PI * 2 * index) / 60 + seconds * 0.9; - const radius = 56 * ringIn; - const point = projectPoint( - rotateX( - rotateY({ x: Math.cos(angle) * radius, y: 0, z: Math.sin(angle) * radius }, 0.2), - 1.05 + 0.1 * Math.sin(seconds), - ), - rankCx, - rankCy, - 220, - ); - const color = hsvToHex(hue + index / 60 / 2, 0.55, 1); - rank.g.circle(point.x, point.y, 3.5 * point.scale).fill({ color, alpha: 0.12 * ringIn }); - rank.g.circle(point.x, point.y, 1.3 * point.scale).fill({ color: SYN_WHITE, alpha: 0.8 * ringIn * point.scale }); - } - const burstT = stageProgress(elapsed, RANK_DELAY_MS, 1100); - if (burstT > 0 && burstT < 1) { - for (const particle of RANK_BURST) { - const life = burstT / particle.life; - if (life >= 1) continue; - const position = burstParticlePosition(particle, life, 120, 30); - const point = projectPoint(rotateY(position, seconds * 0.4), rankCx, rankCy, 240); - if (!point.visible) continue; - const color = hsvToHex(hue + particle.hueShift, Math.min(0.85, life * 1.5), 1); - rank.g.circle(point.x, point.y, (2 + 5 * particle.size) * point.scale).fill({ color, alpha: 0.15 * (1 - life) }); - rank.g - .circle(point.x, point.y, (0.8 + 1.4 * particle.size) * point.scale) - .fill({ color: SYN_WHITE, alpha: 1 - life }); - } - } - const stamp = stageProgress(elapsed, RANK_DELAY_MS, 420); - const letter = new Container(); - rank.root.addChild(letter); - const heartbeat = (1 - ((seconds * 1.4) % 1)) ** 4; - text(letter, rankLabel, rankCx, rankCy, { - ...display(rankLabel.length >= 3 ? 28 : 40, SYN_WHITE), - anchorX: 0.5, - anchorY: 0.5, - maxWidth: 130, - alpha: Math.min(1, stamp * 2), - dropShadow: { color: rankColor, distance: 0, blur: 14 + 8 * heartbeat, alpha: 1 }, - }); - letter.pivot.set(rankCx, rankCy); - letter.position.set(rankCx, rankCy); - letter.scale.set(stamp >= 1 ? 1 + 0.03 * heartbeat : 1.8 - 0.8 * easeOutBack(stamp)); - const rate = frame.ratePercent * easeOutCubic(roll); - text(rank.root, `${rate.toFixed(2)}%`, rankCx, 212, { - ...display(10, SYN_MIST), - anchorX: 0.5, - anchorY: 0.5, - letterSpacing: 1, - }); - rise(rank.root, 250); - - // Metric panels. - const metrics: ReadonlyArray = [ - [218, 66, 'SCORE', String(rollUpValue(result.score.score, roll)), SYN_WHITE], - [218, 122, 'EX SCORE', `${rollUpValue(result.score.exScore, roll)} / ${exMax}`, SYN_WHITE], - [218, 178, 'MAX COMBO', String(rollUpValue(result.maxCombo, roll)), SYN_AMBER], - [424, 66, 'GAUGE', `${rollUpValue(Math.round(result.gauge), roll)}%`, cleared ? SYN_CYAN : SYN_RED], - [424, 122, 'PLAY TIME', `${(result.playSeconds * easeOutCubic(roll)).toFixed(1)}s`, SYN_WHITE], - [424, 178, 'NOTES', String(rollUpValue(result.score.total, roll)), SYN_WHITE], - ]; - metrics.forEach(([x, y, name, value, fill], index) => { - const panel = group(`metric-${index}`); - glass(panel.g, x, y, 196, 46, accent); - label(name, x + 14, y + 10, panel.root); - text(panel.root, value, x + 182, y + 30, { - ...display(14, fill), - anchorX: 1, - anchorY: 0.5, - maxWidth: 120, - dropShadow: { color: fill, distance: 0, blur: 6, alpha: 0.7 }, - }); - rise(panel.root, 320 + index * 80); - }); - - // Judgement rows. - const judges = group('judgement'); - glass(judges.g, 20, 244, 290, 176, SYN_VIOLET); - label('JUDGEMENT', 34, 256, judges.root, SYN_VIOLET); - const rows: ReadonlyArray = [ - ['PGREAT', result.score.perfect, 0xc8fbff], - ['GREAT', result.score.great, SYN_AMBER], - ['GOOD', result.score.good, SYN_GREEN], - ['BAD', result.score.bad, SYN_VIOLET], - ['POOR', result.score.poor, SYN_RED], - ]; - const judgeTotal = Math.max(1, result.score.total); - rows.forEach(([name, count, color], index) => { - const y = 278 + index * 27; - const grow = easeOutCubic(stageProgress(elapsed, ROLL_DELAY_MS + index * 70, ROLL_MS)); - judges.g.circle(38, y + 6, 2.5).fill({ color, alpha: 1 }); - judges.g.circle(38, y + 6, 6).fill({ color, alpha: 0.2 }); - label(name, 50, y + 2, judges.root, color); - judges.g.rect(118, y + 6, 130, 1).fill({ color: SYN_DIM, alpha: 0.35 }); - const barW = (130 * Math.min(count, judgeTotal) * grow) / judgeTotal; - if (barW > 0) { - judges.g.rect(118, y + 4, barW, 5).fill({ color, alpha: 0.18 }); - judges.g.rect(118, y + 6, barW, 1.5).fill({ color, alpha: 0.95 }); - } - text(judges.root, String(rollUpValue(count, roll)), 294, y + 6, { - ...display(11, SYN_WHITE), - anchorX: 1, - anchorY: 0.5, - }); - }); - rise(judges.root, 520); - - // Graphs as glowing filaments drawing in. - const graphs = group('graphs'); - glass(graphs.g, 326, 244, 294, 176, accent); - label('GROOVE GAUGE', 342, 256, graphs.root); - label('EX SCORE', 342, 338, graphs.root); - const draw = easeOutCubic(stageProgress(elapsed, ROLL_DELAY_MS + 150, ROLL_MS + 300)); - filament( - graphs.g, - 342, - 270, - 262, - 56, - result.gaugeHistory.map((s) => ({ x: s.progress, y: s.value / 100 })), - cleared ? SYN_CYAN : SYN_RED, - draw, - ); - filament( - graphs.g, - 342, - 352, - 262, - 56, - result.scoreHistory.map((s) => ({ x: s.progress, y: exMax > 0 ? s.exScore / exMax : 0 })), - SYN_MAGENTA, - draw, - ); - rise(graphs.root, 600); - - // Total score. - const total = group('total'); - total.g.rect(0, designHeight - 44, designWidth, 1).fill({ color: accent, alpha: 0.4 }); - label('TOTAL SCORE', 24, designHeight - 26, total.root, accent); - text(total.root, String(rollUpValue(result.score.score, roll)), 130, designHeight - 22, { - ...display(16, SYN_WHITE), - anchorY: 0.5, - letterSpacing: 2, - dropShadow: { color: accent, distance: 0, blur: 10, alpha: 0.9 }, - }); - rise(total.root, 440, 10); -} - -function glass(graphics: Graphics, x: number, y: number, w: number, h: number, rim: number): void { - graphics.roundRect(x, y, w, h, 8).fill({ color: SYN_GLASS, alpha: 0.6 }); - graphics.roundRect(x - 2, y - 2, w + 4, h + 4, 10).stroke({ color: rim, width: 4, alpha: 0.07 }); - graphics.roundRect(x, y, w, h, 8).stroke({ color: rim, width: 1, alpha: 0.5 }); - graphics.rect(x + 12, y, w - 24, 1).fill({ color: SYN_WHITE, alpha: 0.4 }); -} - -/** Polyline drawn up to `progress` of its horizontal span, stroked twice (bloom + filament) with a glowing head. */ -function filament( - graphics: Graphics, - x: number, - y: number, - w: number, - h: number, - points: ReadonlyArray<{ x: number; y: number }>, - color: number, - progress: number, -): void { - graphics.rect(x, y + h, w, 1).fill({ color: SYN_DIM, alpha: 0.3 }); - if (points.length === 0 || progress <= 0) return; - const clamp = (value: number) => (Number.isFinite(value) ? Math.max(0, Math.min(1, value)) : 0); - const path: number[] = []; - let previous = points[0]!; - path.push(x + clamp(previous.x) * w, y + (1 - clamp(previous.y)) * h); - for (let index = 1; index < points.length; index += 1) { - const point = points[index]!; - if (clamp(point.x) > progress) { - const span = clamp(point.x) - clamp(previous.x); - const t = span > 0 ? (progress - clamp(previous.x)) / span : 0; - path.push(x + progress * w, y + (1 - (clamp(previous.y) + (clamp(point.y) - clamp(previous.y)) * t)) * h); - break; - } - path.push(x + clamp(point.x) * w, y + (1 - clamp(point.y)) * h); - previous = point; - } - if (path.length < 4) path.push(path[0]! + 0.5, path[1]!); - graphics.poly(path, false).stroke({ color, width: 5, alpha: 0.12 }); - graphics.poly(path, false).stroke({ color, width: 1.5, alpha: 0.95 }); - const headX = path[path.length - 2]!; - const headY = path[path.length - 1]!; - graphics.circle(headX, headY, 6).fill({ color, alpha: 0.2 }); - graphics.circle(headX, headY, 2).fill({ color: SYN_WHITE, alpha: 1 }); -} diff --git a/packages/player-web/src/scene/default/synesthesia/select.ts b/packages/player-web/src/scene/default/synesthesia/select.ts deleted file mode 100644 index 265e2a23..00000000 --- a/packages/player-web/src/scene/default/synesthesia/select.ts +++ /dev/null @@ -1,496 +0,0 @@ -import { Container, Graphics, Sprite } from 'pixi.js'; -import type { BrowserBrowseEntry, BrowserSongEntry } from '../../../collection/types.ts'; -import type { - BeMusicSelectFrame, - BeMusicSelectLayout, - BeMusicSelectRenderer, - BeMusicSelectSkin, -} from '../../../skin/be-music/types.ts'; -import { easeOutCubic, stageProgress } from '../phantom-style.ts'; -import { addHitArea, addSkinText, formatPlayVariantLabel, type SkinTextOptions } from '../skin-text.ts'; -import { hsvToHex, icosahedron, projectPoint, rotateX, rotateY, starfieldPoint } from './space.ts'; -import { - SYN_AMBER, - SYN_CYAN, - SYN_DEEP, - SYN_DIM, - SYN_DISPLAY_FONT, - SYN_GLASS, - SYN_MAGENTA, - SYN_MIST, - SYN_TEXT_FONT, - SYN_VOID, - SYN_WHITE, - sceneHue, - synGlowTexture, -} from './style.ts'; - -const LAYOUT: BeMusicSelectLayout = { listX: 322, listTop: 56, listBottomInset: 28, rowHeight: 28 }; -const SLIDE_MS = 320; -const INTRO_STAGGER_MS = 45; -const STAR_COUNT = 150; -const ORBIT_COUNT = 14; -const ICOSAHEDRON = icosahedron(); - -export const synesthesiaSelectSkin: BeMusicSelectSkin = { - layout: LAYOUT, - createRenderer: () => new SynesthesiaSelectRenderer(), -}; - -function labelStyle(fill: number = SYN_DIM): SkinTextOptions { - return { size: 7, fill, fontFamily: SYN_DISPLAY_FONT, letterSpacing: 2.5 }; -} - -function glassPanel(graphics: Graphics, x: number, y: number, w: number, h: number, rim: number, radius = 8): void { - graphics.roundRect(x, y, w, h, radius).fill({ color: SYN_GLASS, alpha: 0.58 }); - graphics.roundRect(x - 2, y - 2, w + 4, h + 4, radius + 2).stroke({ color: rim, width: 4, alpha: 0.07 }); - graphics.roundRect(x, y, w, h, radius).stroke({ color: rim, width: 1, alpha: 0.5 }); - graphics.rect(x + radius + 4, y, w - radius * 2 - 8, 1).fill({ color: SYN_WHITE, alpha: 0.4 }); -} - -/** - * Synesthesia song select. The persistent back layer is a 3D space — a star tunnel whose speed follows the focused - * chart's BPM, a large wireframe icosahedron tumbling behind the info panel, and a floor grid scrolling into the - * horizon — all animated by transform / cheap redraws in `tick`. The front layer orbits a ring of light around the - * focused card. - */ -class SynesthesiaSelectRenderer implements BeMusicSelectRenderer { - public readonly backLayer = new Container(); - public readonly frontLayer = new Container(); - private readonly ground = new Graphics(); - private readonly wireframe = new Graphics(); - private readonly floor = new Graphics(); - private readonly stars: Sprite[] = []; - private readonly orbit: Sprite[] = []; - private readonly cursorGlow = new Sprite(); - private built = false; - private designWidth = 640; - private designHeight = 480; - private activeCard: { x: number; y: number; w: number; h: number } | undefined; - - public constructor() { - this.backLayer.label = 'synesthesia-select/space'; - this.frontLayer.label = 'synesthesia-select/front'; - } - - public render(frame: BeMusicSelectFrame): boolean { - this.ensureBuilt(frame.designWidth, frame.designHeight); - this.activeCard = undefined; - let needsFrame = this.renderChrome(frame); - const listWidth = frame.designWidth - LAYOUT.listX - 14; - for (let visibleIndex = 0; visibleIndex < frame.visibleRows; visibleIndex += 1) { - const entryIndex = frame.firstVisibleIndex + visibleIndex; - const entry = frame.entries[entryIndex]; - if (!entry) break; - needsFrame = this.renderRow(frame, entry, entryIndex, visibleIndex, listWidth) || needsFrame; - } - const visible = this.activeCard !== undefined; - this.cursorGlow.visible = visible; - for (const sprite of this.orbit) sprite.visible = visible; - return needsFrame; - } - - public tick(nowMs: number, focusedSong: BrowserSongEntry | undefined): void { - const seconds = nowMs / 1000; - const bpm = focusedSong?.bpm; - const beatsPerSecond = (bpm !== undefined && Number.isFinite(bpm) && bpm > 0 ? Math.min(bpm, 300) : 120) / 60; - const beatPhase = (seconds * beatsPerSecond) % 1; - const pulse = (1 - beatPhase) ** 3; - const hue = sceneHue(seconds, beatPhase); - const accent = hsvToHex(hue, 0.6, 1); - - // Star tunnel — speed follows the focused chart's tempo. - const cx = this.designWidth * 0.58; - const cy = this.designHeight * 0.44; - const speed = 80 + beatsPerSecond * 55; - for (let index = 0; index < this.stars.length; index += 1) { - const star = this.stars[index]!; - const point = starfieldPoint(index, seconds, { spread: 560, near: 10, far: 1000, speed }); - const projected = projectPoint(point, cx, cy, 200); - star.visible = projected.visible; - if (!projected.visible) continue; - const nearness = Math.min(1, projected.scale); - const size = 2 + 16 * nearness * nearness; - star.position.set(projected.x, projected.y); - star.width = size; - star.height = size; - star.alpha = 0.15 + 0.85 * nearness; - star.tint = hsvToHex(hue + (index % 6) * 0.05, 0.25 + 0.35 * (index % 3 === 0 ? 1 : 0), 1); - } - - // Wireframe icosahedron tumbling behind the info panel, breathing on the beat. - const radius = 150 * (1 + 0.04 * pulse); - const projected = ICOSAHEDRON.vertices.map((vertex) => - projectPoint( - rotateX( - rotateY({ x: vertex.x * radius, y: vertex.y * radius, z: vertex.z * radius }, seconds * 0.22), - 0.5 + seconds * 0.13, - ), - 150, - 250, - 420, - ), - ); - this.wireframe.clear(); - for (const [a, b] of ICOSAHEDRON.edges) { - const pa = projected[a]!; - const pb = projected[b]!; - const depth = (pa.scale + pb.scale) / 2; - this.wireframe - .moveTo(pa.x, pa.y) - .lineTo(pb.x, pb.y) - .stroke({ color: accent, width: 4, alpha: 0.05 * depth }); - this.wireframe - .moveTo(pa.x, pa.y) - .lineTo(pb.x, pb.y) - .stroke({ color: accent, width: 1, alpha: 0.2 + 0.25 * (depth - 0.8) }); - } - for (const point of projected) { - this.wireframe.circle(point.x, point.y, 2 * point.scale).fill({ color: SYN_WHITE, alpha: 0.6 }); - } - - // Floor grid scrolling toward the viewer. - const horizon = this.designHeight * 0.7; - const project = (x: number, z: number) => projectPoint({ x, y: 150, z }, cx, horizon, 200); - this.floor.clear(); - for (let x = -1400; x <= 1400; x += 100) { - const near = project(x, 0); - const far = project(x, 2600); - this.floor.moveTo(near.x, near.y).lineTo(far.x, far.y).stroke({ color: accent, width: 1, alpha: 0.12 }); - } - const spacing = 100; - const offset = (seconds * speed) % spacing; - for (let z = spacing - offset; z < 2600; z += spacing) { - const left = project(-1400, z); - const right = project(1400, z); - this.floor - .moveTo(left.x, left.y) - .lineTo(right.x, right.y) - .stroke({ color: accent, width: 1, alpha: (0.06 + 0.2 * (1 - z / 2600)) * (0.7 + 0.3 * pulse) }); - } - this.floor.rect(0, horizon - 1, this.designWidth, 2).fill({ color: accent, alpha: 0.2 + 0.2 * pulse }); - - // Ring of light orbiting the focused card, plus a breathing glow under it. - const card = this.activeCard; - if (card) { - const perimeter = 2 * (card.w + card.h); - for (let index = 0; index < this.orbit.length; index += 1) { - const sprite = this.orbit[index]!; - const along = (((seconds * 140 + (index * perimeter) / this.orbit.length) % perimeter) + perimeter) % perimeter; - const point = pointOnRect(card, along); - sprite.position.set(point.x, point.y); - const size = 10 + 8 * pulse; - sprite.width = size; - sprite.height = size; - sprite.tint = hsvToHex(hue + index / this.orbit.length / 2, 0.55, 1); - sprite.alpha = 0.65; - } - this.cursorGlow.position.set(card.x + card.w / 2, card.y + card.h / 2); - this.cursorGlow.width = card.w * 1.15; - this.cursorGlow.height = card.h * 3.2; - this.cursorGlow.tint = accent; - this.cursorGlow.alpha = 0.18 + 0.2 * pulse; - } - } - - public dispose(): void { - this.backLayer.destroy({ children: true }); - this.frontLayer.destroy({ children: true }); - } - - private ensureBuilt(designWidth: number, designHeight: number): void { - if (this.built) return; - this.built = true; - this.designWidth = designWidth; - this.designHeight = designHeight; - const bands: ReadonlyArray = [ - [SYN_DEEP, 0.25], - [0x080620, 0.45], - [0x05051a, 0.65], - [0x030410, 0.8], - [SYN_VOID, 1], - ]; - let top = 0; - for (const [color, until] of bands) { - const bottom = designHeight * until; - this.ground.rect(0, top, designWidth, bottom - top).fill(color); - top = bottom; - } - for (const [x, y, radius, color] of [ - [120, 440, 220, SYN_MAGENTA], - [560, 90, 200, SYN_CYAN], - ] as const) { - for (let ring = 4; ring >= 1; ring -= 1) { - this.ground.circle(x, y, (radius * ring) / 4).fill({ color, alpha: 0.03 }); - } - } - const glow = synGlowTexture(); - const starLayer = new Container(); - starLayer.blendMode = 'add'; - for (let index = 0; index < STAR_COUNT; index += 1) { - const star = new Sprite(glow); - star.anchor.set(0.5); - star.blendMode = 'add'; - starLayer.addChild(star); - this.stars.push(star); - } - this.backLayer.addChild(this.ground, this.floor, starLayer, this.wireframe); - - this.cursorGlow.texture = glow; - this.cursorGlow.anchor.set(0.5); - this.cursorGlow.blendMode = 'add'; - this.frontLayer.addChild(this.cursorGlow); - for (let index = 0; index < ORBIT_COUNT; index += 1) { - const sprite = new Sprite(glow); - sprite.anchor.set(0.5); - sprite.blendMode = 'add'; - this.frontLayer.addChild(sprite); - this.orbit.push(sprite); - } - } - - private renderChrome(frame: BeMusicSelectFrame): boolean { - const { layer, designWidth, designHeight, entries, focusedSong: song } = frame; - const chrome = new Graphics(); - chrome.label = 'synesthesia-select/chrome'; - const hue = sceneHue(frame.nowMs / 1000); - const accent = hsvToHex(hue, 0.6, 1); - const addText = (text: string, x: number, y: number, options: SkinTextOptions = {}) => - addSkinText(layer, text, x, y, options); - - const songTitle = song?.title ?? 'No chart selected'; - const songArtist = song?.artist || song?.subtitle || ''; - const playLevel = song?.playLevel !== undefined ? String(song.playLevel) : '-'; - const playLevelNumber = - song?.playLevel !== undefined ? Number.parseFloat(String(song.playLevel).replace(/^[^\d.]+/u, '')) : NaN; - const songBpm = song?.bpm !== undefined ? String(Math.round(song.bpm)) : '-'; - const modeLabel = song ? formatPlayVariantLabel(song) : '- KEYS'; - const categoryName = frame.searchQuery ? `Search: ${frame.searchQuery}` : (frame.folderLabel ?? 'Library'); - const position = - entries.length > 0 ? `${Math.min(frame.selectedIndex + 1, entries.length)} / ${entries.length}` : '0 / 0'; - - // Header: title as light, a hairline, and the folder on the right. - chrome.rect(0, 0, designWidth, 38).fill({ color: SYN_VOID, alpha: 0.5 }); - chrome.rect(0, 38, designWidth, 1).fill({ color: accent, alpha: 0.5 }); - const intro = easeOutCubic(stageProgress(frame.nowMs - frame.sceneStartedAt, 0, 900)); - addText('MUSIC SELECT', 18, 13, { - size: 12, - fill: SYN_WHITE, - fontFamily: SYN_DISPLAY_FONT, - letterSpacing: 4 + 8 * (1 - intro) + 2, - alpha: intro, - dropShadow: { color: accent, distance: 0, blur: 8, alpha: 0.9 }, - }); - addText(categoryName, designWidth - 18, 14, { - size: 10, - weight: '500', - fill: SYN_MIST, - fontFamily: SYN_TEXT_FONT, - anchorX: 1, - maxWidth: 220, - }); - - // Info panel. - glassPanel(chrome, 14, 56, 290, 302, accent); - addText('NOW SELECTING', 30, 70, labelStyle(accent)); - const slide = 1 - easeOutCubic(stageProgress(frame.nowMs - frame.cursorChangedAt, 0, SLIDE_MS)); - addText(songTitle, 30 + slide * 24, 86, { - size: 18, - weight: '500', - fill: SYN_WHITE, - fontFamily: SYN_TEXT_FONT, - maxWidth: 258, - alpha: 1 - slide, - dropShadow: { color: accent, distance: 0, blur: 10, alpha: 0.8 }, - }); - if (songArtist) { - addText(songArtist, 30 + slide * 40, 114, { - size: 10, - weight: '300', - fill: SYN_MIST, - fontFamily: SYN_TEXT_FONT, - maxWidth: 258, - alpha: 1 - slide, - }); - } - chrome.rect(30, 136, 258, 1).fill({ color: accent, alpha: 0.35 }); - - const stats: ReadonlyArray = [ - [30, 80, 'MODE', modeLabel, SYN_WHITE], - [118, 76, 'BPM', songBpm, SYN_WHITE], - [202, 86, 'LEVEL', playLevel, SYN_AMBER], - ]; - for (const [x, w, label, value, fill] of stats) { - chrome.roundRect(x, 150, w, 50, 6).fill({ color: SYN_VOID, alpha: 0.5 }); - chrome.roundRect(x, 150, w, 50, 6).stroke({ color: accent, width: 1, alpha: 0.25 }); - addText(label, x + 10, 158, labelStyle()); - addText(value, x + 10, 174, { - size: 14, - fill, - fontFamily: SYN_DISPLAY_FONT, - maxWidth: w - 18, - dropShadow: { color: fill, distance: 0, blur: 6, alpha: 0.6 }, - }); - } - - // Level as a row of lights, cooling cyan → hot magenta. - const levelRatio = Number.isFinite(playLevelNumber) ? Math.max(0.04, Math.min(1, playLevelNumber / 12)) : 0; - const dots = 12; - for (let dot = 0; dot < dots; dot += 1) { - const lit = dot < Math.round(levelRatio * dots); - const x = 38 + dot * 22; - const color = hsvToHex(0.52 + (dot / dots) * 0.38, 0.6, 1); - if (lit) { - chrome.circle(x, 226, 7).fill({ color, alpha: 0.18 }); - chrome.circle(x, 226, 3).fill({ color: SYN_WHITE, alpha: 0.95 }); - } else { - chrome.circle(x, 226, 2).fill({ color: SYN_DIM, alpha: 0.5 }); - } - } - if (song?.fileLabel) { - addText(song.fileLabel, 30, 246, { - size: 8, - weight: '300', - fill: SYN_DIM, - fontFamily: SYN_TEXT_FONT, - maxWidth: 258, - }); - } - addText(position, 288, 268, { size: 9, fill: SYN_MIST, fontFamily: SYN_DISPLAY_FONT, anchorX: 1 }); - - // PLAY (primary glowing pill) / AUTO PLAY (secondary outline). - chrome.roundRect(26, 296, 170, 40, 20).fill({ color: accent, alpha: 0.18 }); - chrome.roundRect(30, 300, 162, 32, 16).fill({ color: accent, alpha: 0.85 }); - chrome.roundRect(30, 300, 162, 32, 16).stroke({ color: SYN_WHITE, width: 1, alpha: 0.8 }); - addText('PLAY', 111, 316, { - size: 14, - fill: SYN_VOID, - fontFamily: SYN_DISPLAY_FONT, - letterSpacing: 6, - anchorX: 0.5, - anchorY: 0.5, - }); - chrome.roundRect(204, 303, 86, 26, 13).stroke({ color: SYN_MIST, width: 1, alpha: 0.6 }); - addText('AUTO', 247, 316, { - size: 9, - fill: SYN_MIST, - fontFamily: SYN_DISPLAY_FONT, - letterSpacing: 3, - anchorX: 0.5, - anchorY: 0.5, - }); - addHitArea(layer, 26, 296, 172, 40, 'pointer', frame.actions.play); - addHitArea(layer, 202, 300, 92, 32, 'pointer', frame.actions.autoPlay); - - // Search and library. - glassPanel(chrome, 14, 372, 290, 30, accent, 15); - addText('SEARCH', 30, 384, labelStyle(accent)); - addText(frame.searchQuery || 'Title / artist / genre', 90, 380, { - size: 10, - weight: '300', - fill: frame.searchQuery ? SYN_WHITE : SYN_DIM, - fontFamily: SYN_TEXT_FONT, - maxWidth: 200, - }); - addHitArea(layer, 14, 372, 290, 30, 'text', frame.actions.activateSearch); - addText('LIBRARY', 30, 422, labelStyle()); - addText(`${entries.length} shown / ${frame.totalCharts} charts`, 30, 436, { - size: 11, - weight: '500', - fill: SYN_MIST, - fontFamily: SYN_TEXT_FONT, - }); - addText( - frame.searchQuery ? 'SEARCH RESULTS' : frame.folderLabel ? 'CHARTS' : 'FOLDERS', - designWidth - 18, - designHeight - 20, - { ...labelStyle(), anchorX: 1 }, - ); - - layer.addChildAt(chrome, 0); - return slide > 0 || intro < 1; - } - - private renderRow( - frame: BeMusicSelectFrame, - entry: BrowserBrowseEntry, - entryIndex: number, - visibleIndex: number, - listWidth: number, - ): boolean { - const { listX, listTop, rowHeight } = LAYOUT; - const y = listTop + visibleIndex * rowHeight; - const active = entryIndex === frame.selectedIndex; - const song = entry.kind === 'song' ? entry.song : undefined; - const folder = entry.kind === 'folder' ? entry.folder : undefined; - const hue = sceneHue(frame.nowMs / 1000); - const accent = hsvToHex(hue, 0.6, 1); - const slide = active ? 1 - easeOutCubic(stageProgress(frame.nowMs - frame.cursorChangedAt, 0, SLIDE_MS)) : 0; - const intro = - 1 - easeOutCubic(stageProgress(frame.nowMs - frame.sceneStartedAt, 120 + visibleIndex * INTRO_STAGGER_MS, 420)); - const rowX = (active ? listX - 8 : listX) + slide * 26 + intro * 120; - const rowW = active ? listWidth + 8 : listWidth; - const alpha = 1 - intro; - const row = new Graphics(); - row.alpha = alpha; - row.label = `fallback-row[idx=${entryIndex},kind=${entry.kind}${active ? ',active' : ''}]`; - if (active) { - row.roundRect(rowX, y, rowW, rowHeight - 4, 12).fill({ color: accent, alpha: 0.22 }); - row.roundRect(rowX, y, rowW, rowHeight - 4, 12).stroke({ color: SYN_WHITE, width: 1, alpha: 0.85 }); - this.activeCard = { x: rowX, y, w: rowW, h: rowHeight - 4 }; - } else { - row.roundRect(rowX, y, rowW, rowHeight - 4, 12).fill({ color: SYN_GLASS, alpha: 0.5 }); - row.roundRect(rowX, y, rowW, rowHeight - 4, 12).stroke({ color: accent, width: 1, alpha: 0.18 }); - } - // Level as a glowing orb on the leading edge. - const level = song?.playLevel !== undefined ? String(song.playLevel) : folder ? 'DIR' : '-'; - const orbColor = song ? hsvToHex(0.52 + Math.min(1, Number.parseFloat(level) / 12 || 0) * 0.38, 0.6, 1) : SYN_MIST; - row.circle(rowX + 16, y + (rowHeight - 4) / 2, 9).fill({ color: orbColor, alpha: active ? 0.35 : 0.18 }); - frame.layer.addChild(row); - const midY = y + (rowHeight - 4) / 2; - addSkinText(frame.layer, level, rowX + 16, midY, { - size: level.length > 2 ? 6 : 8, - fill: SYN_WHITE, - fontFamily: SYN_DISPLAY_FONT, - anchorX: 0.5, - anchorY: 0.5, - alpha, - maxWidth: 16, - }); - const meta = song - ? `${song.bpm ? `${Math.round(song.bpm)} BPM` : ''}` - : `${folder?.songs.length ?? 0} chart${folder?.songs.length === 1 ? '' : 's'}`; - const metaNode = addSkinText(frame.layer, meta, rowX + rowW - 14, midY, { - size: 8, - fill: active ? SYN_WHITE : SYN_DIM, - fontFamily: SYN_DISPLAY_FONT, - letterSpacing: 1, - anchorX: 1, - anchorY: 0.5, - alpha, - maxWidth: 70, - }); - metaNode.label = `fallback-meta[idx=${entryIndex}]`; - const metaWidth = meta ? Math.min(70, metaNode.width) + 12 : 0; - const title = addSkinText(frame.layer, song?.title ?? folder?.label ?? '', rowX + 34, midY, { - size: 11, - weight: active ? '500' : '300', - fill: SYN_WHITE, - fontFamily: SYN_TEXT_FONT, - anchorY: 0.5, - alpha, - maxWidth: Math.max(24, rowW - 48 - metaWidth), - ...(active ? { dropShadow: { color: accent, distance: 0, blur: 8, alpha: 0.9 } } : {}), - }); - title.label = `fallback-title[idx=${entryIndex}]`; - return slide > 0 || intro > 0; - } -} - -/** Point `distance` px along the perimeter of `rect`, clockwise from the top-left corner. */ -function pointOnRect(rect: { x: number; y: number; w: number; h: number }, distance: number): { x: number; y: number } { - if (distance < rect.w) return { x: rect.x + distance, y: rect.y }; - if (distance < rect.w + rect.h) return { x: rect.x + rect.w, y: rect.y + distance - rect.w }; - if (distance < rect.w * 2 + rect.h) return { x: rect.x + rect.w - (distance - rect.w - rect.h), y: rect.y + rect.h }; - return { x: rect.x, y: rect.y + rect.h - (distance - rect.w * 2 - rect.h) }; -} diff --git a/packages/player-web/src/scene/default/synesthesia/space.test.ts b/packages/player-web/src/scene/default/synesthesia/space.test.ts deleted file mode 100644 index 1c7c3aa7..00000000 --- a/packages/player-web/src/scene/default/synesthesia/space.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { describe, expect, it } from 'vite-plus/test'; -import { - burstParticlePosition, - burstParticles, - hsvToHex, - icosahedron, - projectPoint, - rotateX, - rotateY, - starfieldPoint, -} from './space.ts'; - -describe('projectPoint', () => { - it('maps z = 0 one-to-one around the origin', () => { - expect(projectPoint({ x: 10, y: -5, z: 0 }, 100, 50, 200)).toEqual({ x: 110, y: 45, scale: 1, visible: true }); - }); - - it('shrinks toward the vanishing point with depth', () => { - const far = projectPoint({ x: 10, y: 0, z: 200 }, 0, 0, 200); - expect(far.scale).toBe(0.5); - expect(far.x).toBe(5); - }); - - it('hides points behind the camera', () => { - expect(projectPoint({ x: 0, y: 0, z: -250 }, 0, 0, 200).visible).toBe(false); - }); -}); - -describe('rotations', () => { - it('rotate a point a quarter turn', () => { - const y = rotateY({ x: 1, y: 0, z: 0 }, Math.PI / 2); - expect(y.x).toBeCloseTo(0, 12); - expect(y.z).toBeCloseTo(-1, 12); - const x = rotateX({ x: 0, y: 1, z: 0 }, Math.PI / 2); - expect(x.y).toBeCloseTo(0, 12); - expect(x.z).toBeCloseTo(1, 12); - }); -}); - -describe('burstParticles', () => { - it('is deterministic per seed and differs across seeds', () => { - expect(burstParticles(4, 8)).toEqual(burstParticles(4, 8)); - expect(burstParticles(4, 8)).not.toEqual(burstParticles(5, 8)); - }); - - it('keeps every attribute in range and biases sparks upward', () => { - const particles = burstParticles(11, 200); - for (const particle of particles) { - expect(particle.speed).toBeGreaterThanOrEqual(0.35); - expect(particle.speed).toBeLessThanOrEqual(1); - expect(particle.life).toBeGreaterThanOrEqual(0.55); - expect(Math.abs(particle.hueShift)).toBeLessThanOrEqual(0.08); - } - const upward = particles.filter((particle) => particle.direction.y < 0).length; - expect(upward).toBeGreaterThan(particles.length / 2); - }); - - it('launches from the origin and travels outward over time', () => { - const [particle] = burstParticles(2, 1); - const start = burstParticlePosition(particle!, 0, 40, 10); - expect(Math.hypot(start.x, start.y, start.z)).toBe(0); - const end = burstParticlePosition(particle!, 1, 40, 0); - expect(Math.hypot(end.x, end.y, end.z)).toBeGreaterThan(0); - }); -}); - -describe('starfieldPoint', () => { - const options = { spread: 300, near: 0, far: 600, speed: 120 }; - - it('keeps depth inside the loop range', () => { - for (let index = 0; index < 50; index += 1) { - const point = starfieldPoint(index, 17.3, options); - expect(point.z).toBeGreaterThan(options.near); - expect(point.z).toBeLessThanOrEqual(options.far); - expect(Math.abs(point.x)).toBeLessThanOrEqual(options.spread); - } - }); - - it('moves stars toward the camera as time advances', () => { - const before = starfieldPoint(3, 1, options); - const after = starfieldPoint(3, 1.1, options); - expect(after.z === before.z - 12 || after.z > before.z).toBe(true); - }); -}); - -describe('icosahedron', () => { - it('has 12 unit vertices and 30 edges', () => { - const { vertices, edges } = icosahedron(); - expect(vertices).toHaveLength(12); - expect(edges).toHaveLength(30); - for (const vertex of vertices) { - expect(Math.hypot(vertex.x, vertex.y, vertex.z)).toBeCloseTo(1, 12); - } - }); -}); - -describe('hsvToHex', () => { - it('converts primaries and wraps hue', () => { - expect(hsvToHex(0, 1, 1)).toBe(0xff0000); - expect(hsvToHex(1 / 3, 1, 1)).toBe(0x00ff00); - expect(hsvToHex(2 / 3, 1, 1)).toBe(0x0000ff); - expect(hsvToHex(1.5, 0, 1)).toBe(0xffffff); - expect(hsvToHex(-1 / 3, 1, 1)).toBe(0x0000ff); - }); -}); diff --git a/packages/player-web/src/scene/default/synesthesia/space.ts b/packages/player-web/src/scene/default/synesthesia/space.ts deleted file mode 100644 index 94a021be..00000000 --- a/packages/player-web/src/scene/default/synesthesia/space.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { hash01 } from '../phantom-style.ts'; - -/** - * Pure 3D helpers for the Synesthesia skin: a pinhole projection, deterministic particle bursts, a looping starfield, - * a wireframe icosahedron, and hue-based colour. Everything is a function of (seed, time) so the pooled per-frame - * redraw needs no particle state and never shimmers. - */ - -export interface Vec3 { - x: number; - y: number; - z: number; -} - -export interface Projected { - x: number; - y: number; - /** Perspective scale at this depth (1 at `z = 0`). */ - scale: number; - /** False when the point is behind the camera. */ - visible: boolean; -} - -/** - * Pinhole projection. The camera sits at `z = -focal` looking down +z, so a point at `z = 0` projects 1:1 around the - * origin `(cx, cy)`; larger `z` recedes toward the vanishing point and shrinks. - */ -export function projectPoint(point: Vec3, cx: number, cy: number, focal: number): Projected { - const depth = focal + point.z; - if (depth <= 1e-3) { - return { x: cx, y: cy, scale: 0, visible: false }; - } - const scale = focal / depth; - return { x: cx + point.x * scale, y: cy + point.y * scale, scale, visible: true }; -} - -export function rotateY(point: Vec3, angle: number): Vec3 { - const cos = Math.cos(angle); - const sin = Math.sin(angle); - return { x: point.x * cos + point.z * sin, y: point.y, z: -point.x * sin + point.z * cos }; -} - -export function rotateX(point: Vec3, angle: number): Vec3 { - const cos = Math.cos(angle); - const sin = Math.sin(angle); - return { x: point.x, y: point.y * cos - point.z * sin, z: point.y * sin + point.z * cos }; -} - -export interface BurstParticle { - /** Unit-ish launch direction (upper hemisphere biased). */ - direction: Vec3; - /** Launch speed multiplier in [0.35, 1]. */ - speed: number; - /** Size multiplier in [0.4, 1]. */ - size: number; - /** Hue offset in [-0.08, 0.08] around the burst's base hue. */ - hueShift: number; - /** Lifetime fraction in [0.55, 1] — some sparks burn out early. */ - life: number; -} - -/** - * `count` burst particles on a sphere, deterministic for `seed`. Directions are biased upward (screen −y) so a hit - * reads as sparks thrown off the judgement line rather than a symmetric puff. - */ -export function burstParticles(seed: number, count: number): BurstParticle[] { - const particles: BurstParticle[] = []; - for (let index = 0; index < count; index += 1) { - const base = seed * 97 + index * 13; - // Uniform sphere sampling via (cos θ, φ), then squash the lower hemisphere. - const cosTheta = 1 - 2 * hash01(base + 1); - const sinTheta = Math.sqrt(Math.max(0, 1 - cosTheta * cosTheta)); - const phi = Math.PI * 2 * hash01(base + 2); - const rawY = cosTheta; - particles.push({ - direction: { - x: sinTheta * Math.cos(phi), - y: rawY > 0 ? -rawY : rawY * -0.35, - z: sinTheta * Math.sin(phi), - }, - speed: 0.35 + 0.65 * hash01(base + 3), - size: 0.4 + 0.6 * hash01(base + 4), - hueShift: (hash01(base + 5) - 0.5) * 0.16, - life: 0.55 + 0.45 * hash01(base + 6), - }); - } - return particles; -} - -/** - * Position of burst particle `particle` at normalized time `t` (0..1): ease-out launch with drag, plus a little - * gravity pulling sparks back down late in their life. - */ -export function burstParticlePosition(particle: BurstParticle, t: number, radius: number, gravity: number): Vec3 { - const clamped = Math.max(0, Math.min(1, t)); - const travel = (1 - (1 - clamped) ** 3) * radius * particle.speed; - return { - x: particle.direction.x * travel, - y: particle.direction.y * travel + gravity * clamped * clamped, - z: particle.direction.z * travel, - }; -} - -/** - * Star `index` of a looping starfield flying toward the camera. Depth cycles over `[near, far]` at `speed` units/s; - * lateral position is a fixed per-star spread in `[-spread, spread]`. - */ -export function starfieldPoint( - index: number, - seconds: number, - options: { spread: number; near: number; far: number; speed: number }, -): Vec3 { - const span = options.far - options.near; - const phase = hash01(index * 7 + 3) * span; - const z = options.far - ((seconds * options.speed + phase) % span); - return { - x: (hash01(index * 7 + 1) * 2 - 1) * options.spread, - y: (hash01(index * 7 + 2) * 2 - 1) * options.spread * 0.62, - z, - }; -} - -/** Unit icosahedron: 12 vertices and its 30 edges (vertex index pairs). */ -export function icosahedron(): { vertices: Vec3[]; edges: Array } { - const golden = (1 + Math.sqrt(5)) / 2; - const raw: Array = [ - [-1, golden, 0], - [1, golden, 0], - [-1, -golden, 0], - [1, -golden, 0], - [0, -1, golden], - [0, 1, golden], - [0, -1, -golden], - [0, 1, -golden], - [golden, 0, -1], - [golden, 0, 1], - [-golden, 0, -1], - [-golden, 0, 1], - ]; - const norm = Math.hypot(1, golden); - const vertices = raw.map(([x, y, z]) => ({ x: x / norm, y: y / norm, z: z / norm })); - const edges: Array = []; - const edgeLength = 2 / norm; - for (let a = 0; a < vertices.length; a += 1) { - for (let b = a + 1; b < vertices.length; b += 1) { - const va = vertices[a]!; - const vb = vertices[b]!; - if (Math.abs(Math.hypot(va.x - vb.x, va.y - vb.y, va.z - vb.z) - edgeLength) < 1e-6) { - edges.push([a, b]); - } - } - } - return { vertices, edges }; -} - -/** HSV (all in 0..1, hue wrapping) to a 0xRRGGBB number. */ -export function hsvToHex(hue: number, saturation: number, value: number): number { - const h = (((hue % 1) + 1) % 1) * 6; - const s = Math.max(0, Math.min(1, saturation)); - const v = Math.max(0, Math.min(1, value)); - const sector = Math.floor(h); - const fraction = h - sector; - const p = v * (1 - s); - const q = v * (1 - s * fraction); - const t = v * (1 - s * (1 - fraction)); - const [r, g, b] = [ - [v, t, p], - [q, v, p], - [p, v, t], - [p, q, v], - [t, p, v], - [v, p, q], - ][sector % 6]!; - return (Math.round(r! * 255) << 16) | (Math.round(g! * 255) << 8) | Math.round(b! * 255); -} diff --git a/packages/player-web/src/scene/gameplay-chrome.ts b/packages/player-web/src/scene/gameplay-chrome.ts index 819ea324..473a4418 100644 --- a/packages/player-web/src/scene/gameplay-chrome.ts +++ b/packages/player-web/src/scene/gameplay-chrome.ts @@ -1,65 +1,12 @@ -import type { ChartPlayVariant } from '@be-music/player/core/lane-layout'; import type { Container } from 'pixi.js'; +import type { BeMusicGameplayRuntime, BeMusicJudgeState } from '../skin/be-music/types.ts'; import type { ChildPool } from './pixi-utils.ts'; -/** - * One side's recent judgement state for skinless chrome renderers. - */ -export interface SkinlessGameplayJudgeState { - side: '1P' | '2P'; - /** PERFECT / GREAT / GOOD / BAD / POOR. Empty when no recent judge. */ - judge?: string; - combo?: number; -} +/** One side's recent judgement state for skinless chrome renderers (the be-music skin type). */ +export type SkinlessGameplayJudgeState = BeMusicJudgeState; -/** - * Live gameplay values a skinless chrome renderer can paint without reaching into the LR2 scene internals. - */ -export interface SkinlessGameplayChromeRuntime { - songTitle?: string; - songArtist?: string; - bpm?: number; - hiSpeed?: number; - score?: number; - exScore?: number; - exScoreMax?: number; - combo?: number; - maxCombo?: number; - perfect?: number; - great?: number; - good?: number; - bad?: number; - poor?: number; - gauge?: number; - clearThreshold?: number; - laneCount?: number; - laneChannels?: readonly string[]; - playVariant?: ChartPlayVariant; - /** PERFECT / GREAT / GOOD / BAD / POOR. Empty when no recent judge. */ - lastJudge?: string; - /** Per-side judgement snapshots. DP renderers can paint 1P / 2P independently from these values. */ - judgeSides?: readonly SkinlessGameplayJudgeState[]; - /** AAA / AA / A / B / C / D / E / F. */ - rank?: string; - autoplay?: boolean; - hasBga?: boolean; - /** Monotonic play clock (ms) for chrome-side animation (gauge hot-tip flicker, subtle pulses). */ - nowMs?: number; - /** Song progress in [0, 1] — drives the chrome's progress track. */ - progressRatio?: number; - /** Fractional beat position in [0, 1) — drives beat-synced pulses (judge-line glow, accent trim). */ - beatPhase?: number; - /** Active compat ruleset id (`lr2` / `beatoraja` / `iidx`) — shown as a chip in the header HUD. */ - rulesetLabel?: string; - /** Ruleset-scoped gauge id (`GROOVE` / `HARD` / `HAZARD` / ...) labelling the gauge housing. */ - gaugeLabel?: string; - /** True for survival gauges — the chrome runs the all-red scheme and drops the clear notch. */ - gaugeSurvival?: boolean; - /** FAST (early GREAT/GOOD) count. */ - fast?: number; - /** SLOW (late GREAT/GOOD) count. */ - slow?: number; -} +/** Live gameplay values a skinless chrome renderer can paint (the be-music skin type). */ +export type SkinlessGameplayChromeRuntime = BeMusicGameplayRuntime; export interface SkinlessGameplayChromeRenderContext { layer: Container; diff --git a/packages/player-web/src/scene/gameplay-constants.ts b/packages/player-web/src/scene/gameplay-constants.ts index 082e8446..f835958b 100644 --- a/packages/player-web/src/scene/gameplay-constants.ts +++ b/packages/player-web/src/scene/gameplay-constants.ts @@ -50,11 +50,7 @@ export const LR2_2P_BOMB_TIMER_BASE = 60; export const LR2_1P_LN_HOLD_TIMER_BASE = 70; export const LR2_2P_LN_HOLD_TIMER_BASE = 80; -/** - * Playfield rectangle for the no-skin lane geometry. Coordinates come from LR2's default 7K skin so LR2 skins and the - * built-in default family share the same note-position contract while their chrome stays separate. - */ -export const PLAYFIELD = { x: 33, y: 0, w: 194, judgementY: 321 } as const; +export { PLAYFIELD } from './gameplay-geometry.ts'; /** * BGA rectangle used by the built-in/default chrome path. */ diff --git a/packages/player-web/src/scene/gameplay-geometry.ts b/packages/player-web/src/scene/gameplay-geometry.ts new file mode 100644 index 00000000..eeefcd21 --- /dev/null +++ b/packages/player-web/src/scene/gameplay-geometry.ts @@ -0,0 +1 @@ +export { PLAYFIELD } from '@be-music/skin-sdk'; diff --git a/packages/player-web/src/scene/gameplay-lanes.ts b/packages/player-web/src/scene/gameplay-lanes.ts index 3b66ff09..0278b11c 100644 --- a/packages/player-web/src/scene/gameplay-lanes.ts +++ b/packages/player-web/src/scene/gameplay-lanes.ts @@ -1,136 +1,25 @@ -import type { ChartPlayVariant } from '@be-music/player/core/lane-layout'; -import { resolveSideKeySlot as resolveCoreSideKeySlot } from '@be-music/player/core/lane-layout'; - +/** Lane geometry shared with the be-music skins lives in `@be-music/skin-sdk`; re-exported for the player's scenes. */ +export { + IIDX_DP_SIDE_GAP, + IIDX_LANE_WIDTHS, + isScratchLaneForVariant, + resolveFallbackLaneLayout, + resolveFallbackPlayfieldSpan, + resolveSkinlessLaneLayout, + shouldPreserveFallbackSideWidth, + usesIidxLaneWidths, + type FallbackLaneLayoutRect, + type FixedLaneWidths, + type ResolveFallbackLaneLayoutOptions, + type SkinlessLaneLayout, +} from '@be-music/skin-sdk'; export { isPlayableInputChannel, isScratch, resolveKeyChannel, resolveLaneChannels, resolveLr2LaneIndex, + resolvePlayVariantLaneChannels, resolveSideKeySlot, resolveSideRelativeLaneIndex, } from '@be-music/player/core/lane-layout'; - -const FALLBACK_LANE_GAP = 2; -const FALLBACK_DP_SIDE_GAP = 14; -const FALLBACK_SCRATCH_LANE_WEIGHT = 1.55; - -export interface FallbackLaneLayoutRect { - channel: string | undefined; - x: number; - w: number; - isScratch: boolean; - side: '1P' | '2P'; -} - -export interface ResolveFallbackLaneLayoutOptions { - channels?: readonly string[]; - laneCount?: number; - playVariant?: ChartPlayVariant; - x: number; - w: number; - gap?: number; - sideGap?: number; - /** - * Keep one side at the requested width and let additional DP-side lanes extend the fallback playfield horizontally. - */ - preserveSideWidth?: boolean; -} - -export function isScratchLaneForVariant(channel: string, playVariant?: ChartPlayVariant): boolean { - return resolveCoreSideKeySlot(channel, playVariant) === 0; -} - -export function shouldPreserveFallbackSideWidth( - channels: readonly string[] | undefined, - playVariant?: ChartPlayVariant, -): boolean { - if (playVariant === '10' || playVariant === '14') { - return true; - } - return playVariant !== '9' && channels?.some((channel) => channel.startsWith('2')) === true; -} - -export function resolveFallbackLaneLayout(options: ResolveFallbackLaneLayoutOptions): FallbackLaneLayoutRect[] { - const fallbackLaneCount = Math.max(1, Math.trunc(options.laneCount ?? 8)); - const channels = - options.channels && options.channels.length > 0 - ? [...options.channels] - : (Array.from({ length: fallbackLaneCount }) as Array); - const gap = Math.max(0, options.gap ?? FALLBACK_LANE_GAP); - const scratchFlags = channels.map((channel) => - channel === undefined ? false : isScratchLaneForVariant(channel, options.playVariant), - ); - const sideFlags = channels.map((channel) => resolveFallbackLaneSide(channel, options.playVariant)); - const displayOrder = resolveFallbackLaneDisplayOrder(channels, scratchFlags, options.playVariant); - const hasBothSides = sideFlags.includes('1P') && sideFlags.includes('2P'); - const sideGap = Math.max(0, options.sideGap ?? (hasBothSides ? FALLBACK_DP_SIDE_GAP : 0)); - const laneWeights = scratchFlags.map((isScratch) => (isScratch ? FALLBACK_SCRATCH_LANE_WEIGHT : 1)); - const referenceLaneCount = options.preserveSideWidth ? Math.max(1, Math.ceil(channels.length / 2)) : channels.length; - const referenceWeights = laneWeights.slice(0, referenceLaneCount); - const referenceWeight = referenceWeights.reduce((sum, weight) => sum + weight, 0); - const totalInterLaneGap = resolveTotalInterLaneGap(displayOrder, sideFlags, gap, sideGap); - const referenceInterLaneGap = options.preserveSideWidth - ? gap * Math.max(0, referenceLaneCount - 1) - : totalInterLaneGap; - const availableWidth = Math.max(0, options.w - referenceInterLaneGap); - const unit = referenceWeight > 0 ? availableWidth / referenceWeight : 0; - const rects: FallbackLaneLayoutRect[] = Array.from({ length: channels.length }); - let x = options.x; - let priorSide: '1P' | '2P' | undefined; - - for (const index of displayOrder) { - const side = sideFlags[index]!; - if (priorSide !== undefined) { - x += priorSide === side ? gap : sideGap; - } - const w = unit * laneWeights[index]!; - rects[index] = { channel: channels[index], x, w, isScratch: scratchFlags[index]!, side }; - x += w; - priorSide = side; - } - return rects; -} - -function resolveFallbackLaneSide(channel: string | undefined, playVariant?: ChartPlayVariant): '1P' | '2P' { - return playVariant !== '9' && channel?.startsWith('2') === true ? '2P' : '1P'; -} - -function resolveTotalInterLaneGap( - displayOrder: readonly number[], - sideFlags: ReadonlyArray<'1P' | '2P'>, - gap: number, - sideGap: number, -): number { - let total = 0; - for (let i = 1; i < displayOrder.length; i += 1) { - const previous = sideFlags[displayOrder[i - 1]!]!; - const next = sideFlags[displayOrder[i]!]!; - total += previous === next ? gap : sideGap; - } - return total; -} - -function resolveFallbackLaneDisplayOrder( - channels: ReadonlyArray, - scratchFlags: ReadonlyArray, - playVariant?: ChartPlayVariant, -): number[] { - if (playVariant === '9') { - return channels.map((_, index) => index); - } - return channels - .map((channel, index) => ({ - index, - side: channel?.startsWith('2') === true ? 1 : 0, - scratchOrder: scratchFlags[index] ? (channel?.startsWith('2') === true ? 1 : -1) : 0, - slot: channel === undefined ? index : resolveCoreSideKeySlot(channel, playVariant), - })) - .sort((a, b) => { - if (a.side !== b.side) return a.side - b.side; - if (a.scratchOrder !== b.scratchOrder) return a.scratchOrder - b.scratchOrder; - if (a.slot !== b.slot) return a.slot - b.slot; - return a.index - b.index; - }) - .map((entry) => entry.index); -} diff --git a/packages/player-web/src/scene/lr2/gameplay-lanes.test.ts b/packages/player-web/src/scene/lr2/gameplay-lanes.test.ts index 8babb6d8..6b3e59d4 100644 --- a/packages/player-web/src/scene/lr2/gameplay-lanes.test.ts +++ b/packages/player-web/src/scene/lr2/gameplay-lanes.test.ts @@ -2,7 +2,11 @@ import { describe, expect, it } from 'vite-plus/test'; import { isScratchLaneForVariant, resolveFallbackLaneLayout, + resolveFallbackPlayfieldSpan, + resolveSkinlessLaneLayout, + IIDX_DP_SIDE_GAP, shouldPreserveFallbackSideWidth, + usesIidxLaneWidths, } from '../gameplay-lanes.ts'; describe('isScratchLaneForVariant', () => { @@ -17,7 +21,7 @@ describe('isScratchLaneForVariant', () => { }); describe('resolveFallbackLaneLayout', () => { - it('makes scratch lanes wider than normal key lanes while fitting the requested width', () => { + it('makes scratch lanes wider than normal key lanes, edge to edge, while fitting the requested width', () => { const lanes = resolveFallbackLaneLayout({ channels: ['16', '11', '12', '13', '14', '15', '18', '19'], playVariant: '7', @@ -28,7 +32,7 @@ describe('resolveFallbackLaneLayout', () => { expect(lanes).toHaveLength(8); expect(lanes[0]?.isScratch).toBe(true); expect(lanes[0]!.w).toBeGreaterThan(lanes[1]!.w); - expect(lanes[1]!.x - (lanes[0]!.x + lanes[0]!.w)).toBeCloseTo(2); + expect(lanes[1]!.x - (lanes[0]!.x + lanes[0]!.w)).toBeCloseTo(0); expect(lanes.at(-1)!.x + lanes.at(-1)!.w).toBeCloseTo(33 + 194); }); @@ -61,7 +65,7 @@ describe('resolveFallbackLaneLayout', () => { expect(lanes.at(-1)!.x + lanes.at(-1)!.w).toBeCloseTo(33 + 194); }); - it('splits the 48 keyboard-mode lanes into 1P / 2P banks with a side gap', () => { + it('splits the 48 keyboard-mode lanes into edge-to-edge 1P / 2P banks', () => { const lanes = ['1', '2'].flatMap((side) => Array.from('123456789ABCDEFGHIJKLMNO', (lane) => `${side}${lane}`)); const rects = resolveFallbackLaneLayout({ channels: lanes, @@ -74,10 +78,10 @@ describe('resolveFallbackLaneLayout', () => { expect(rects).toHaveLength(48); expect(rects.filter((rect) => rect.side === '1P')).toHaveLength(24); expect(rects.filter((rect) => rect.side === '2P')).toHaveLength(24); - // The 2P bank starts strictly right of the 1P bank's trailing edge — the side gap. + // The 2P bank starts exactly on the 1P bank's trailing edge — no side gap. const lastOfP1 = rects[23]!; const firstOfP2 = rects[24]!; - expect(firstOfP2.x).toBeGreaterThan(lastOfP1.x + lastOfP1.w); + expect(firstOfP2.x).toBeCloseTo(lastOfP1.x + lastOfP1.w); // Per-side width is preserved, so a 48-lane chart extends the playfield instead of squashing each lane. const spRects = resolveFallbackLaneLayout({ channels: lanes.slice(0, 24), @@ -135,7 +139,7 @@ describe('resolveFallbackLaneLayout', () => { expect(twoScratch.x + twoScratch.w).toBeCloseTo(Math.max(...lanes.map((lane) => lane.x + lane.w))); }); - it('inserts a visible gap between 1P and 2P fallback lanes', () => { + it('butts the 2P fallback lanes straight onto the 1P lanes by default', () => { const lanes = resolveFallbackLaneLayout({ channels: ['16', '11', '12', '13', '14', '15', '18', '19', '26', '21', '22', '23', '24', '25', '28', '29'], playVariant: '14', @@ -147,7 +151,7 @@ describe('resolveFallbackLaneLayout', () => { const oneRight = Math.max(...lanes.filter((lane) => lane.side === '1P').map((lane) => lane.x + lane.w)); const twoLeft = Math.min(...lanes.filter((lane) => lane.side === '2P').map((lane) => lane.x)); - expect(twoLeft - oneRight).toBeCloseTo(14); + expect(twoLeft - oneRight).toBeCloseTo(0); }); it('uses the requested DP side gap when provided', () => { @@ -175,3 +179,126 @@ describe('shouldPreserveFallbackSideWidth', () => { expect(shouldPreserveFallbackSideWidth(['11', '12', '21'], '9')).toBe(false); }); }); + +describe('resolveFallbackPlayfieldSpan', () => { + it('keeps the LR2 default 7K span for the IIDX / PMS families', () => { + for (const variant of [undefined, '5', '7', '9', '10', '14'] as const) { + expect(resolveFallbackPlayfieldSpan(variant)).toEqual({ x: 33, w: 194 }); + } + }); + + it('widens the keyboard modes', () => { + expect(resolveFallbackPlayfieldSpan('24')).toEqual({ x: 12, w: 252 }); + expect(resolveFallbackPlayfieldSpan('48')).toEqual({ x: 12, w: 308 }); + }); +}); + +/** One side's 24 keyboard-mode channels: `11..19`, `1A..1O` (or the `2x` bank). */ +function keyboardChannels(side: '1' | '2'): string[] { + return Array.from('123456789ABCDEFGHIJKLMNO', (digit) => `${side}${digit}`); +} + +describe('resolveSkinlessLaneLayout', () => { + it('lays a 7K chart out on the default span', () => { + const layout = resolveSkinlessLaneLayout(['16', '11', '12', '13', '14', '15', '18', '19'], 8, '7'); + + expect(layout.left).toBe(33); + expect(layout.right).toBeCloseTo(227); + }); + + it('gives 24 KEY lanes the wider column, left of the BGA', () => { + const channels = keyboardChannels('1'); + const layout = resolveSkinlessLaneLayout(channels, channels.length, '24'); + + expect(layout.lanes).toHaveLength(24); + expect(layout.left).toBe(12); + expect(layout.right).toBeCloseTo(264); + for (const lane of layout.lanes) expect(lane.w).toBeCloseTo(10.5); + }); + + it('spreads both 48 KEY banks across the page', () => { + const channels = [...keyboardChannels('1'), ...keyboardChannels('2')]; + const layout = resolveSkinlessLaneLayout(channels, channels.length, '48'); + + expect(layout.lanes).toHaveLength(48); + expect(layout.right).toBeCloseTo(12 + 308 * 2); + for (const lane of layout.lanes) expect(lane.w).toBeCloseTo(308 / 24); + }); +}); + +describe('usesIidxLaneWidths', () => { + it('covers the 5 / 7 / 10 / 14 KEY families only', () => { + for (const variant of ['5', '7', '10', '14'] as const) expect(usesIidxLaneWidths(variant)).toBe(true); + for (const variant of [undefined, '9', '24', '48'] as const) expect(usesIidxLaneWidths(variant)).toBe(false); + }); +}); + +describe('resolveSkinlessLaneLayout fixed IIDX widths', () => { + const widthsByChannel = (channels: string[], variant: '5' | '7' | '10' | '14') => { + const layout = resolveSkinlessLaneLayout(channels, channels.length, variant); + return { layout, widths: Object.fromEntries(layout.lanes.map((lane) => [lane.channel, lane.w])) }; + }; + + it('gives 7K a 41 px scratch, 24 px white keys, and 19 px black keys', () => { + const { layout, widths } = widthsByChannel(['16', '11', '12', '13', '14', '15', '18', '19'], '7'); + + expect(widths).toEqual({ '16': 41, '11': 24, '12': 19, '13': 24, '14': 19, '15': 24, '18': 19, '19': 24 }); + expect(layout.right).toBe(33 + 194); + }); + + it('keeps the same widths in 5K, so the playfield narrows instead of stretching', () => { + const { layout, widths } = widthsByChannel(['16', '11', '12', '13', '14', '15'], '5'); + + expect(widths).toEqual({ '16': 41, '11': 24, '12': 19, '13': 24, '14': 19, '15': 24 }); + expect(layout.right).toBe(33 + 41 + 24 * 3 + 19 * 2); + }); + + it('keeps the same widths on both DP sides', () => { + const tenKey = widthsByChannel(['16', '11', '12', '13', '14', '15', '21', '22', '23', '24', '25', '26'], '10'); + const fourteenKey = widthsByChannel( + ['16', '11', '12', '13', '14', '15', '18', '19', '21', '22', '23', '24', '25', '28', '29', '26'], + '14', + ); + + expect(tenKey.widths['26']).toBe(41); + expect(tenKey.widths['21']).toBe(24); + expect(tenKey.widths['22']).toBe(19); + expect(tenKey.layout.right).toBe(33 + (41 + 24 * 3 + 19 * 2) * 2 + IIDX_DP_SIDE_GAP); + expect(fourteenKey.widths['29']).toBe(24); + expect(fourteenKey.widths['28']).toBe(19); + expect(fourteenKey.layout.right).toBe(33 + 194 * 2 + IIDX_DP_SIDE_GAP); + }); + + it('opens the arcade gap between the DP banks', () => { + const { layout } = widthsByChannel( + ['16', '11', '12', '13', '14', '15', '18', '19', '21', '22', '23', '24', '25', '28', '29', '26'], + '14', + ); + const oneP = layout.lanes.filter((lane) => lane.side === '1P'); + const twoP = layout.lanes.filter((lane) => lane.side === '2P'); + const oneRight = Math.max(...oneP.map((lane) => lane.x + lane.w)); + const twoLeft = Math.min(...twoP.map((lane) => lane.x)); + expect(oneRight).toBe(33 + 194); + expect(twoLeft - oneRight).toBe(IIDX_DP_SIDE_GAP); + // About 0.31 of a side, as on the cabinet. + expect(IIDX_DP_SIDE_GAP / 194).toBeCloseTo(0.31, 1); + }); + + it('keeps the 48 KEY banks together', () => { + const channels = [...keyboardChannels('1'), ...keyboardChannels('2')]; + const layout = resolveSkinlessLaneLayout(channels, channels.length, '48'); + const oneRight = Math.max(...layout.lanes.filter((lane) => lane.side === '1P').map((lane) => lane.x + lane.w)); + const twoLeft = Math.min(...layout.lanes.filter((lane) => lane.side === '2P').map((lane) => lane.x)); + expect(twoLeft).toBeCloseTo(oneRight); + }); + + it('packs the lanes edge to edge in display order', () => { + const { layout } = widthsByChannel(['16', '11', '12', '13', '14', '15', '18', '19'], '7'); + const sorted = [...layout.lanes].sort((a, b) => a.x - b.x); + + expect(sorted[0]!.channel).toBe('16'); + for (let index = 1; index < sorted.length; index += 1) { + expect(sorted[index]!.x).toBeCloseTo(sorted[index - 1]!.x + sorted[index - 1]!.w); + } + }); +}); diff --git a/packages/player-web/src/scene/lr2/gameplay.ts b/packages/player-web/src/scene/lr2/gameplay.ts index 17ae2f2e..39a4810e 100644 --- a/packages/player-web/src/scene/lr2/gameplay.ts +++ b/packages/player-web/src/scene/lr2/gameplay.ts @@ -31,6 +31,8 @@ import { BOMB_CYCLE_MS, BOMB_DIVX, BOMB_DIVY, + DESIGN_HEIGHT, + DESIGN_WIDTH, HISPEED_MAX, HISPEED_MIN, LR2_1P_BOMB_TIMER_BASE, @@ -64,6 +66,7 @@ import { KEY_ON_FADE_OUT_MS, type CoreGameplayViewOptions, type GameplayBgaTarget, + type GameplayStageSize, type GameplayLaneRect, type GameplayMeasureLineFrame, type GameplayThemeEvent, @@ -394,6 +397,11 @@ export class PixiGameplayView extends CoreGameplayView this.updateTurntable(this.playClock()); } + /** An LR2 skin is authored for the 640x480 canvas; without one the scene paints the be-music stage. */ + protected override get stageSize(): GameplayStageSize { + return this.options.skin ? { width: DESIGN_WIDTH, height: DESIGN_HEIGHT } : super.stageSize; + } + protected override collectBgaTargets(out: GameplayBgaTarget[]): void { const skin = this.options.skin; if (!skin) { diff --git a/packages/player-web/src/scene/pixi-utils.test.ts b/packages/player-web/src/scene/pixi-utils.test.ts index 5e2b1f7c..849969f1 100644 --- a/packages/player-web/src/scene/pixi-utils.test.ts +++ b/packages/player-web/src/scene/pixi-utils.test.ts @@ -1,6 +1,12 @@ import { Container, Graphics, Sprite, Text, Texture } from 'pixi.js'; import { describe, expect, test, vi } from 'vite-plus/test'; -import { ChildPool, destroyUniqueTextures, disposeChildren, staggerDestroyTextures } from './pixi-utils.ts'; +import { + ChildPool, + LaggedDisposer, + destroyUniqueTextures, + disposeChildren, + staggerDestroyTextures, +} from './pixi-utils.ts'; /** * The hot render loops in `scene/lr2/gameplay.ts` / `scene/lr2/result.ts` / `scene/lr2/select.ts` originally cleared their dynamic @@ -262,3 +268,34 @@ describe('ChildPool', () => { expect(layer.children.length).toBe(0); }); }); + +describe('LaggedDisposer', () => { + test("hides this cycle's children in place and destroys them on the next cycle", () => { + const layer = new Container(); + const disposer = new LaggedDisposer(); + const first = new Graphics(); + layer.addChild(first); + disposer.cycle(layer); + expect(layer.children).toEqual([first]); + expect(first.visible).toBe(false); + expect(first.destroyed).toBe(false); + + const second = new Graphics(); + layer.addChild(second); + disposer.cycle(layer); + expect(first.destroyed).toBe(true); + expect(layer.children).toEqual([second]); + expect(second.visible).toBe(false); + }); + + test('flush destroys whatever is still waiting', () => { + const layer = new Container(); + const disposer = new LaggedDisposer(); + const child = new Container(); + layer.addChild(child); + disposer.cycle(layer); + disposer.flush(); + expect(child.destroyed).toBe(true); + expect(layer.children).toHaveLength(0); + }); +}); diff --git a/packages/player-web/src/scene/pixi-utils.ts b/packages/player-web/src/scene/pixi-utils.ts index b7f38f74..592a1ea5 100644 --- a/packages/player-web/src/scene/pixi-utils.ts +++ b/packages/player-web/src/scene/pixi-utils.ts @@ -1,4 +1,4 @@ -import { Container, Graphics, Sprite, Text, type Texture } from 'pixi.js'; +import { type Container, type Texture } from 'pixi.js'; import { destroyTextureAndRevokeBlobUrl } from '../media/textures.ts'; /** @@ -29,268 +29,7 @@ export function disposeChildren(container: Container): void { } } -/** - * Per-frame `Sprite` / `Graphics` / `Text` recycler for a single `Container` layer. - * - * The render pass calls `begin()` to reset the cursor, then `acquireSprite()` / `acquireGraphics()` / - * `acquireText()` for each child it wants to draw. The pool returns a previously-allocated, parented child when - * possible (just toggling it back to `visible = true`); only when the cursor outruns the pool does it allocate a - * fresh one and parent it. After the pass, `end()` hides every child the pass didn't claim — so the next render's - * `begin()` starts from a known clean state without paying the GC cost of `disposeChildren`'s destroy + recreate. - * - * Three separate sub-cursors are tracked so a single render pass can mix sprite / graphics / text reuse without one - * starvation type forcing churn on another. A `Graphics` returned from `acquireGraphics()` is `clear()`ed so the - * caller starts from a blank canvas; a `Sprite` is *not* reset (the caller owns texture / position / size). Text - * objects keep their previous `style` to avoid the heavy paragraph-rebuild cost; the caller updates `text` (and - * `style` only if it actually changed). - * - * The pool's lifetime is tied to its owning layer — the layer's parent view's `dispose()` should call `destroy()` - * here too, which destroys every pooled child. - */ -export class ChildPool { - /** - * Sub-container that hosts every pooled {@link Graphics}. Lives at z-index 0 inside the owner-supplied `layer` so - * its children render BEHIND every {@link Sprite} pooled by this same pool — the LR2 render order has measure - * lines and long-note bodies (Graphics) underneath note heads and skin sprites (Sprite). Keeping every Graphics - * grouped under one sub-container is the whole point of the split: Pixi's batcher walks children in order and - * issues a fresh draw call every time the type changes (Sprite → Graphics or vice-versa, see Pixi's - * `pixijs-performance` skill §Batching), so a layer that interleaves the two types fragments into many small - * draws. Putting all Graphics first, then all Sprites, then all Text reduces a dense frame's - * note-layer draw count from O(num type-switches) to ~3. - */ - private readonly graphicsHost: Container; - private readonly spriteHost: Container; - private readonly textHost: Container; - private readonly sprites: Sprite[] = []; - private spriteCursor = 0; - private readonly graphics: Graphics[] = []; - private graphicsCursor = 0; - private readonly texts: Text[] = []; - private textCursor = 0; - /** - * Per-bucket count of consecutive frames where the bucket's high-water mark exceeded its actual usage by more than - * {@link CHILD_POOL_TRIM_SLACK}. When the count crosses {@link CHILD_POOL_TRIM_AFTER_FRAMES} we shrink the bucket - * back down to `cursor + CHILD_POOL_TRIM_SLACK` and reset the counter. Tracked separately per bucket so a still-hot - * sprite cursor doesn't keep an unused text tail pinned (and vice-versa). - */ - private idleFramesSprites = 0; - private idleFramesGraphics = 0; - private idleFramesTexts = 0; - private readonly layer: Container; - - public constructor(layer: Container) { - this.layer = layer; - this.graphicsHost = new Container(); - this.spriteHost = new Container(); - this.textHost = new Container(); - // Z-order matches LR2 visual layering: Graphics (measure lines, LN body, fallback rects) → Sprite (notes, - // skin chrome) → Text (in-skin labels, fallback HUD). The hosts inherit the parent layer's `eventMode` so - // setting `layer.eventMode = 'none'` (as `scene/lr2/gameplay.ts` does for render-only layers) propagates here too. - this.layer.addChild(this.graphicsHost, this.spriteHost, this.textHost); - } - - /** Resets every cursor. Call once at the top of every render pass. */ - public begin(): void { - this.spriteCursor = 0; - this.graphicsCursor = 0; - this.textCursor = 0; - } - - /** - * Returns a parented `Sprite` ready to receive `texture` / `position` / `width` / `height` / `tint` / `alpha` - * updates. Reuses an existing pooled sprite when possible; allocates and parents a fresh one when the pool is - * exhausted. **The caller must overwrite every property they care about** — values left over from a previous - * pass are not cleared (and resetting them all would defeat the perf win pooling exists for). - */ - public acquireSprite(): Sprite { - let sprite = this.sprites[this.spriteCursor]; - if (!sprite) { - sprite = new Sprite(); - this.spriteHost.addChild(sprite); - this.sprites.push(sprite); - } - // Keep `tint` / `alpha` reset so a previous pass's coloured / faded sprite doesn't leak into a fresh draw. - sprite.visible = true; - sprite.tint = 0xffffff; - sprite.alpha = 1; - sprite.rotation = 0; - sprite.skew.set(0, 0); - sprite.anchor.set(0, 0); - sprite.label = ''; - this.spriteCursor += 1; - return sprite; - } - - /** - * Returns a parented, *cleared* `Graphics` ready for the caller's draw commands. Same recycle rules as - * `acquireSprite`; the only auto-reset is `clear()` (so the previous pass's geometry is gone) plus - * `tint / alpha / position / scale` so a moved / coloured graphic from a prior pass doesn't carry over. - */ - public acquireGraphics(): Graphics { - let graphics = this.graphics[this.graphicsCursor]; - if (!graphics) { - graphics = new Graphics(); - this.graphicsHost.addChild(graphics); - this.graphics.push(graphics); - } - graphics.clear(); - graphics.visible = true; - graphics.tint = 0xffffff; - graphics.alpha = 1; - graphics.position.set(0, 0); - graphics.scale.set(1, 1); - graphics.rotation = 0; - graphics.label = ''; - this.graphicsCursor += 1; - return graphics; - } - - /** - * Returns a parented `Text` ready to receive `text` (and optionally `style`) updates. Text objects are reused - * verbatim — re-assigning the same `style` triggers Pixi's paragraph rebuild, so callers should update `style` - * only when something actually changed. - */ - public acquireText(): Text { - let text = this.texts[this.textCursor]; - if (!text) { - text = new Text(); - this.textHost.addChild(text); - this.texts.push(text); - } - text.visible = true; - text.tint = 0xffffff; - text.alpha = 1; - text.rotation = 0; - text.anchor.set(0, 0); - text.label = ''; - this.textCursor += 1; - return text; - } - - /** - * Hides every pooled child the current pass didn't acquire, and idle-decay-shrinks each bucket whose high-water - * mark hasn't been touched recently. Call once at the bottom of every render pass. - * - * Without the shrink, a single peak frame (e.g. a transition where dozens of skin sprites are visible at once) would - * pin the bucket's allocation for the rest of the scene's lifetime; downstream sections of the chart that draw far - * fewer children would still pay the memory cost. The decay is intentionally gentle — the bucket has to underflow by - * more than {@link CHILD_POOL_TRIM_SLACK} for {@link CHILD_POOL_TRIM_AFTER_FRAMES} consecutive frames before any - * children are destroyed — so a noisy frame-to-frame range (e.g. between bombs and no-bombs) never triggers the - * shrink. When it does fire, the bucket is trimmed back to `cursor + slack`; subsequent peaks just allocate fresh - * children, paying a one-frame allocation cost in exchange for the steady-state memory saving. - */ - public end(): void { - for (let i = this.spriteCursor; i < this.sprites.length; i++) { - this.sprites[i]!.visible = false; - } - for (let i = this.graphicsCursor; i < this.graphics.length; i++) { - this.graphics[i]!.visible = false; - } - for (let i = this.textCursor; i < this.texts.length; i++) { - this.texts[i]!.visible = false; - } - this.idleFramesSprites = trimIdleBucket(this.sprites, this.spriteCursor, this.idleFramesSprites); - this.idleFramesGraphics = trimIdleBucket(this.graphics, this.graphicsCursor, this.idleFramesGraphics); - this.idleFramesTexts = trimIdleBucket(this.texts, this.textCursor, this.idleFramesTexts); - } - - /** - * Returns the bucket's currently-allocated child count for debugging / telemetry. The cursor is omitted on purpose; - * call sites only need the underlying capacity. - */ - public size(): { sprites: number; graphics: number; texts: number } { - return { sprites: this.sprites.length, graphics: this.graphics.length, texts: this.texts.length }; - } - - /** Destroys every pooled child (and `clear()`s every Graphics' context). Call from the owner's `dispose()`. */ - public destroy(): void { - for (const sprite of this.sprites) { - try { - sprite.destroy(); - } catch { - // Already destroyed / detached — both terminal states. - } - } - for (const graphics of this.graphics) { - try { - graphics.destroy({ context: true }); - } catch { - // ditto - } - } - for (const text of this.texts) { - try { - text.destroy(); - } catch { - // ditto - } - } - // Tear down the sub-host Containers as well. The pooled children are already destroyed above so the host - // destroy is just freeing the empty Container shells; we nevertheless pass `children: true` defensively in - // case a caller injected something into the host directly (we don't, but a future caller might). - for (const host of [this.graphicsHost, this.spriteHost, this.textHost]) { - try { - host.destroy({ children: true }); - } catch { - // ditto - } - } - this.sprites.length = 0; - this.graphics.length = 0; - this.texts.length = 0; - this.spriteCursor = 0; - this.graphicsCursor = 0; - this.textCursor = 0; - this.idleFramesSprites = 0; - this.idleFramesGraphics = 0; - this.idleFramesTexts = 0; - } -} - -/** - * Number of consecutive underutilized frames before the {@link ChildPool} shrinks the affected bucket. ~5 s at 60 fps — - * long enough that frame-to-frame noise never fires the shrink, short enough that a section of the chart that stops - * spawning bombs / particles releases its peak allocation while it's still paused. - */ -const CHILD_POOL_TRIM_AFTER_FRAMES = 300; -/** - * Slack kept above the cursor when the {@link ChildPool} shrinks a bucket. Avoids re-allocating a child the very next - * frame after a one-frame valley between two peaks; also amortises the shrink/grow cycle if the chart keeps the cursor - * within a small band around its trim target. - */ -const CHILD_POOL_TRIM_SLACK = 8; - -/** - * Per-bucket helper for {@link ChildPool.end}: increments the idle-frame counter when the bucket is over-allocated by - * more than {@link CHILD_POOL_TRIM_SLACK}, fires the destroy + tail-pop when the counter crosses - * {@link CHILD_POOL_TRIM_AFTER_FRAMES}, and resets the counter as soon as utilization comes back up. Returns the new - * counter value so the caller can persist it on the pool. - */ -function trimIdleBucket(bucket: T[], cursor: number, idleFrames: number): number { - if (bucket.length <= cursor + CHILD_POOL_TRIM_SLACK) { - return 0; - } - const next = idleFrames + 1; - if (next < CHILD_POOL_TRIM_AFTER_FRAMES) { - return next; - } - const target = cursor + CHILD_POOL_TRIM_SLACK; - while (bucket.length > target) { - const child = bucket.pop()!; - try { - // `Graphics` needs `context: true` to free its owned `GraphicsContext` (matches the destroy options used by - // {@link ChildPool.destroy}). For Sprite / Text the default options are correct. - if (child instanceof Graphics) { - child.destroy({ context: true }); - } else { - child.destroy(); - } - } catch { - // Already destroyed / detached — both terminal states; nothing to do. - } - } - return 0; -} +export { ChildPool, LaggedDisposer } from '../skins/pixi-kit/pools.ts'; export function destroyUniqueTextures(textures: Iterable, destroySource = true): number { const destroyed = new Set(); diff --git a/packages/player-web/src/skin/be-music/binding.ts b/packages/player-web/src/skin/be-music/binding.ts new file mode 100644 index 00000000..d1c57279 --- /dev/null +++ b/packages/player-web/src/skin/be-music/binding.ts @@ -0,0 +1,329 @@ +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 { logger } from '../../logger.ts'; +import type { + BeMusicBomb, + BeMusicGameplayFrame, + BeMusicGameplayLayout, + BeMusicGameplayRuntime, + BeMusicLaneFrame, + BeMusicLongNote, + BeMusicNote, + BeMusicResultFrame, + BeMusicSelectFrame, + BeMusicSkin, + BeMusicStage, + BeMusicSurface, + BeMusicSurfaceContextMap, +} from './types.ts'; + +/** + * The player's side of a be-music skin: each screen gets a canvas sized to the screen pixels the stage covers, the skin + * draws it with whatever it likes, and the canvas is shown in the Pixi scene as one texture. Skins never see Pixi. + */ + +const log = logger('be-music-skin'); + +/** Largest canvas edge a surface allocates, whatever the window size. */ +const MAX_CANVAS_EDGE = 4096; + +/** The design canvas `skin` draws on (the 16:9 `wideStage` unless it declares another). */ +export function resolveBeMusicSkinStage(skin: BeMusicSkin): BeMusicStage { + return skin.stage ?? wideStage; +} + +/** One canvas + the texture showing it, sized to the screen pixels the stage covers (dot by dot). */ +class SkinSurface { + private readonly canvas = document.createElement('canvas'); + private readonly source: CanvasSource; + readonly texture: Texture; + private context: BeMusicSurfaceContextMap[keyof BeMusicSurfaceContextMap] | undefined; + private state: 'new' | 'pending' | 'ready' | 'failed' | 'destroyed' = 'new'; + private pixelRatio = 1; + private readonly skin: BeMusicSkin; + private readonly stage: BeMusicStage; + + constructor(skin: BeMusicSkin) { + this.skin = skin; + this.stage = resolveBeMusicSkinStage(skin); + // Nearest sampling: the canvas is sized to the screen pixels it covers, so it is shown dot by dot, never filtered. + this.source = new CanvasSource({ resource: this.canvas, scaleMode: 'nearest' }); + this.texture = new Texture({ source: this.source }); + } + + /** Runs `paint` on the surface (once it is set up) and uploads the result. */ + draw(paint: (surface: BeMusicSurface) => void): void { + this.resize(); + if (!this.ensureContext() || this.state !== 'ready') return; + const surface = this.surface(); + if (this.skin.context === '2d') { + const ctx = surface.context as CanvasRenderingContext2D; + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); + ctx.setTransform(this.pixelRatio, 0, 0, this.pixelRatio, 0, 0); + } + try { + paint(surface); + } catch (error) { + log.warn(`"${this.skin.id}": draw failed`, error); + } + this.source.update(); + } + + destroy(): void { + if (this.state === 'destroyed') return; + // Only a surface the skin set up is handed back to it. + if (this.state === 'ready' || this.state === 'pending') { + try { + this.skin.teardown?.(this.surface()); + } catch (error) { + log.warn(`"${this.skin.id}": teardown failed`, error); + } + } + this.state = 'destroyed'; + this.texture.destroy(true); + } + + private surface(): BeMusicSurface { + return { + canvas: this.canvas, + context: this.context!, + width: this.stage.width, + height: this.stage.height, + pixelRatio: this.pixelRatio, + }; + } + + /** + * Sizes the canvas to the device pixels the stage covers on screen (viewport scale × devicePixelRatio), so one canvas + * pixel is one screen pixel. Capped at {@link MAX_CANVAS_EDGE} on the long side. + */ + private resize(): void { + const { width, height } = this.stage; + const pixelWidth = Math.max(1, Math.min(MAX_CANVAS_EDGE, Math.round(width * getDesignPixelRatio()))); + const ratio = pixelWidth / width; + if (ratio === this.pixelRatio && this.canvas.width > 0) return; + this.pixelRatio = ratio; + this.source.resize(width, height, ratio); + } + + private ensureContext(): boolean { + if (this.state === 'new') { + const context = this.canvas.getContext(this.skin.context, this.skin.contextAttributes) as + | BeMusicSurfaceContextMap[keyof BeMusicSurfaceContextMap] + | null; + if (!context) { + log.warn(`"${this.skin.id}": the browser offers no "${this.skin.context}" canvas context`); + this.state = 'failed'; + return false; + } + this.context = context; + this.state = 'pending'; + let setup: void | Promise; + try { + setup = this.skin.setup?.(this.surface()); + } catch (error) { + log.warn(`"${this.skin.id}": setup failed`, error); + this.state = 'failed'; + return false; + } + if (setup instanceof Promise) { + setup.then( + () => { + if (this.state === 'pending') this.state = 'ready'; + }, + (error: unknown) => { + log.warn(`"${this.skin.id}": setup failed`, error); + if (this.state === 'pending') this.state = 'failed'; + }, + ); + } else { + this.state = 'ready'; + } + } + return this.state !== 'failed' && this.state !== 'destroyed'; + } +} + +/** + * Gameplay for one scene: the scene reports lanes, notes, long notes, and hit effects as it walks them, and + * {@link renderChrome} places the skin's canvas, which is drawn once — with everything collected — when Pixi renders. + */ +export class BeMusicGameplayBinding { + readonly bombDurationMs: number; + private readonly skin: BeMusicSkin; + private readonly surface: SkinSurface; + private frame: BeMusicGameplayFrame | undefined; + private lanes: BeMusicLaneFrame[] = []; + private notes: BeMusicNote[] = []; + private longNotes: BeMusicLongNote[] = []; + private bombs: BeMusicBomb[] = []; + private frameNumber = 0; + private drawnFrame = -1; + + constructor(skin: BeMusicSkin) { + this.skin = skin; + this.surface = new SkinSurface(skin); + this.bombDurationMs = skin.gameplay.bombDurationMs ?? 300; + } + + /** Starts a frame and shows the skin's canvas in `layerPool` (the scene's chrome layer, above the BGA). */ + renderChrome(context: { + layerPool: ChildPool; + runtime: BeMusicGameplayRuntime; + layout: BeMusicGameplayLayout; + }): void { + const { runtime, layout } = context; + this.frameNumber += 1; + this.lanes = []; + this.notes = []; + this.longNotes = []; + this.bombs = []; + this.frame = { + nowMs: runtime.nowMs ?? 0, + runtime, + layout, + lanes: this.lanes, + notes: this.notes, + longNotes: this.longNotes, + bombs: this.bombs, + beatPhase: runtime.beatPhase ?? 0, + effects: runtime.effects ?? 'full', + audio: runtime.audio, + }; + const sprite = context.layerPool.acquireSprite(); + sprite.texture = this.surface.texture; + // The texture's size is in design pixels already (its source carries the pixel ratio as its resolution). + sprite.position.set(0, 0); + sprite.scale.set(1); + sprite.roundPixels = true; + sprite.onRender = this.drawFrame; + } + + addLanes(lanes: readonly BeMusicLaneFrame[]): void { + for (const lane of lanes) this.lanes.push({ ...lane }); + } + + addNote(note: BeMusicNote): void { + this.notes.push(note); + } + + addLongNote(note: BeMusicLongNote): void { + this.longNotes.push(note); + } + + addBombs(bombs: readonly BeMusicBomb[]): void { + for (const bomb of bombs) this.bombs.push({ ...bomb }); + } + + dispose(): void { + this.surface.destroy(); + } + + private readonly drawFrame = (): void => { + const frame = this.frame; + if (!frame || this.drawnFrame === this.frameNumber) return; + this.drawnFrame = this.frameNumber; + this.surface.draw((surface) => this.skin.gameplay.draw(surface, frame)); + }; +} + +/** The select frame the scene hands over; the binding adds `revision`, `audio`, and `hit`. */ +export type BeMusicSelectSceneFrame = Omit; + +/** + * Select for one scene: {@link render} records the latest state when the scene's input changes, and {@link tick} draws + * the skin every frame with it. Hit areas the skin declares become transparent pointer targets in {@link frontLayer}. + */ +export class BeMusicSelectBinding { + readonly outroMs: number; + /** Shows the skin's canvas, behind the scene's list layer. */ + readonly backLayer = new Container(); + /** Holds the hit areas, in front of everything. */ + readonly frontLayer = new Container(); + private readonly skin: BeMusicSkin; + private readonly surface: SkinSurface; + private readonly hitPool: ChildPool; + private frame: BeMusicSelectSceneFrame | undefined; + private revision = 0; + + constructor(skin: BeMusicSkin) { + this.skin = skin; + this.surface = new SkinSurface(skin); + this.outroMs = skin.select.outroMs ?? 0; + this.backLayer.addChild(new Sprite({ texture: this.surface.texture, roundPixels: true })); + this.hitPool = new ChildPool(this.frontLayer); + } + + /** Records the select state after an input-driven change. Drawing happens in {@link tick}. */ + render(frame: BeMusicSelectSceneFrame): void { + this.frame = frame; + this.revision += 1; + } + + /** Draws the skin with the latest state, the current time, and what is playing. */ + tick( + nowMs: number, + focusedSong: BeMusicSelectFrame['focusedSong'], + launchAt: number | undefined, + audio: BeMusicSelectFrame['audio'], + ): void { + const base = this.frame; + if (!base) return; + const pool = this.hitPool; + pool.begin(); + const frame: BeMusicSelectFrame = { + ...base, + nowMs, + focusedSong, + launchAt, + audio, + revision: this.revision, + hit: (x, y, w, h, action, cursor = 'pointer') => { + const area = pool.acquireGraphics(); + area.rect(x, y, w, h).fill({ color: 0xffffff, alpha: 0.001 }); + area.eventMode = 'static'; + area.cursor = cursor; + area.removeAllListeners('pointerdown'); + area.on('pointerdown', action); + }, + }; + try { + this.surface.draw((surface) => this.skin.select.draw(surface, frame)); + } finally { + pool.end(); + } + } + + dispose(): void { + this.hitPool.destroy(); + this.backLayer.destroy({ children: true }); + this.frontLayer.destroy({ children: true }); + this.surface.destroy(); + } +} + +/** 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; + + constructor(skin: BeMusicSkin) { + this.skin = skin; + this.surface = new SkinSurface(skin); + this.view.addChild(new Sprite({ texture: this.surface.texture, roundPixels: true })); + } + + render(frame: BeMusicResultFrame): void { + this.surface.draw((surface) => this.skin.result.draw(surface, frame)); + } + + dispose(): void { + this.view.destroy({ children: true }); + this.surface.destroy(); + } +} 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 078a3cff..a5964028 100644 --- a/packages/player-web/src/skin/be-music/registry.test.ts +++ b/packages/player-web/src/skin/be-music/registry.test.ts @@ -2,8 +2,19 @@ import { describe, expect, it } from 'vite-plus/test'; import { createBeMusicSkinRegistry, resolveBeMusicLaneKind, resolveSelectListWindow } from './registry.ts'; import type { BeMusicSkin } from './types.ts'; -function stubSkin(id: string): BeMusicSkin { - return { id, label: id.toUpperCase() } as unknown as BeMusicSkin; +function stubSkin(id: string, apiVersion = 1): BeMusicSkin { + return { + apiVersion, + id, + label: id.toUpperCase(), + version: '1.0.0', + author: { name: 'be-music' }, + fontLoads: [], + context: '2d', + gameplay: { draw: () => {} }, + select: { layout: { listX: 0, listTop: 0, listBottomInset: 0, rowHeight: 20 }, draw: () => {} }, + result: { draw: () => {} }, + }; } describe('createBeMusicSkinRegistry', () => { @@ -18,6 +29,47 @@ describe('createBeMusicSkinRegistry', () => { expect(() => createBeMusicSkinRegistry([])).toThrow(); expect(() => createBeMusicSkinRegistry([stubSkin('a'), stubSkin('a')])).toThrow(/duplicate/); }); + + it('leaves out skins for an unsupported API revision and reports them', () => { + const rejected: string[] = []; + const registry = createBeMusicSkinRegistry([stubSkin('future', 2), 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); + }); + + it('adds skins later, replacing one with the same id, and notifies subscribers', () => { + const registry = createBeMusicSkinRegistry([stubSkin('phantom')]); + let changes = 0; + const stop = registry.subscribe(() => { + changes += 1; + }); + expect(registry.add(stubSkin('lattice'))).toEqual([]); + expect(registry.skins.map((skin) => skin.id)).toEqual(['phantom', 'lattice']); + expect(registry.resolve('lattice').id).toBe('lattice'); + const replacement = { ...stubSkin('lattice'), label: 'Lattice 2' } as BeMusicSkin; + expect(registry.add(replacement)).toEqual([]); + expect(registry.skins).toHaveLength(2); + expect(registry.resolve('lattice').label).toBe('Lattice 2'); + expect(changes).toBe(2); + stop(); + registry.add(stubSkin('neon')); + expect(changes).toBe(2); + }); + + 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); + 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); + }); }); describe('resolveBeMusicLaneKind', () => { diff --git a/packages/player-web/src/skin/be-music/registry.ts b/packages/player-web/src/skin/be-music/registry.ts index 2242cf87..f2eee26a 100644 --- a/packages/player-web/src/skin/be-music/registry.ts +++ b/packages/player-web/src/skin/be-music/registry.ts @@ -1,18 +1,54 @@ -import type { ChartPlayVariant } from '@be-music/player/core/lane-layout'; -import { isScratchLaneForVariant, resolveSideRelativeLaneIndex } from '../../scene/gameplay-lanes.ts'; -import type { BeMusicLaneKind, BeMusicSelectLayout, BeMusicSkin } from './types.ts'; +import { validateBeMusicSkin, type BeMusicSelectLayout, type BeMusicSkin } from '@be-music/skin-sdk'; +import { logger } from '../../logger.ts'; + +export { resolveBeMusicLaneKind } from '@be-music/skin-sdk'; + +const log = logger('be-music-skin'); export interface BeMusicSkinRegistry { + /** Every registered skin, in registration order (a skin added later with a known id keeps its place). */ readonly skins: readonly BeMusicSkin[]; /** The skin with `id`, or the first registered skin when `id` is unknown / undefined. */ resolve(id: string | undefined): BeMusicSkin; + /** + * Registers another skin — a third-party one, or an optional built-in such as `latticeSkin`. The skin is validated + * like the initial ones (see `validateBeMusicSkin`); a skin with an id already registered replaces that one (handy + * when reloading a skin under development). Returns the problems that kept the skin out, empty when it was added. + */ + add(skin: BeMusicSkin): string[]; + /** Calls `listener` after every successful {@link add}; returns the function that stops it. */ + subscribe(listener: () => void): () => void; +} + +export interface BeMusicSkinRegistryOptions { + /** + * Called for each skin left out because its declaration is invalid or written for an unsupported API revision (see + * `validateBeMusicSkin`). Defaults to a logged warning. + */ + onRejected?: (skin: BeMusicSkin, problems: readonly string[]) => void; } -/** Registry over a fixed, non-empty skin list. The first entry is the fallback for unknown ids. */ -export function createBeMusicSkinRegistry(skins: readonly BeMusicSkin[]): BeMusicSkinRegistry { +/** + * Registry over a skin list that hosts can extend with {@link BeMusicSkinRegistry.add}. Skins whose declaration doesn't validate (an unsupported `apiVersion`, a malformed + * id or version, …) are left out, so a third-party skin built for another player release can't break the host. The + * first accepted entry is the fallback for unknown ids; at least one must be accepted. + */ +export function createBeMusicSkinRegistry( + candidates: readonly BeMusicSkin[], + options: BeMusicSkinRegistryOptions = {}, +): BeMusicSkinRegistry { + const onRejected = + options.onRejected ?? + ((skin: BeMusicSkin, problems: readonly string[]) => + log.warn(`skipping skin "${skin.id}": ${problems.join('; ')}`)); + const skins = candidates.filter((skin) => { + const problems = validateBeMusicSkin(skin); + if (problems.length > 0) onRejected(skin, problems); + return problems.length === 0; + }); const first = skins[0]; if (!first) { - throw new Error('createBeMusicSkinRegistry: at least one skin is required'); + throw new Error('createBeMusicSkinRegistry: at least one valid skin is required'); } const byId = new Map(); for (const skin of skins) { @@ -21,33 +57,28 @@ export function createBeMusicSkinRegistry(skins: readonly BeMusicSkin[]): BeMusi } byId.set(skin.id, skin); } + const listeners = new Set<() => void>(); return { - skins, - resolve: (id) => (id !== undefined ? byId.get(id) : undefined) ?? first, + get skins() { + return [...byId.values()]; + }, + resolve: (id) => (id !== undefined ? byId.get(id) : undefined) ?? byId.values().next().value ?? first, + add(skin) { + const problems = validateBeMusicSkin(skin); + if (problems.length > 0) return problems; + byId.set(skin.id, skin); + for (const listener of listeners) listener(); + return []; + }, + subscribe(listener) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, }; } -/** Chromatic offsets of the black keys within an octave (C# D# F# G# A#). */ -const KEYBOARD_BLACK_KEY_SEMITONES = new Set([1, 3, 6, 8, 10]); - -/** - * Visual class of a lane. `laneIndex` is the LR2 lane id (0 / 10 = scratch, 1..9 / 11..19 = keys, -1 when the lane - * has no LR2 rect). IIDX convention: odd keys white, even keys black. The 24 / 48-key keyboard modes colour by piano - * key instead, repeating every octave. - */ -export function resolveBeMusicLaneKind( - channel: string, - laneIndex: number, - playVariant: ChartPlayVariant | undefined, -): BeMusicLaneKind { - if (isScratchLaneForVariant(channel, playVariant)) return 'scratch'; - if (playVariant === '24' || playVariant === '48') { - const semitone = (resolveSideRelativeLaneIndex(channel, playVariant) - 1) % 12; - return KEYBOARD_BLACK_KEY_SEMITONES.has(semitone) ? 'black' : 'white'; - } - return (laneIndex % 10) % 2 === 0 ? 'black' : 'white'; -} - /** * The slice of the entry list the select renderer draws: keeps the focused row near the vertical centre, clamped so * the window never scrolls past either end. Shared by rendering and row hit-testing so both agree on row positions. diff --git a/packages/player-web/src/skin/be-music/types.ts b/packages/player-web/src/skin/be-music/types.ts index 7ec3d6f3..1e519886 100644 --- a/packages/player-web/src/skin/be-music/types.ts +++ b/packages/player-web/src/skin/be-music/types.ts @@ -1,187 +1,2 @@ -import type { Container, Graphics } from 'pixi.js'; -import type { BrowserBrowseEntry, BrowserSongEntry } from '../../collection/types.ts'; -import type { SkinlessGameplayChromeRenderer } from '../../scene/gameplay-chrome.ts'; -import type { PixiGameplayResultData } from '../../scene/core/result-data.ts'; -import type { ChildPool } from '../../scene/pixi-utils.ts'; - -/** - * be-music skin format — the code-defined skins the built-in (default) family renders with when no LR2 / beatoraja - * theme is loaded. Unlike LR2 / beatoraja themes (data files interpreted by a scene), a be-music skin is a set of - * renderer functions over a fixed 640x480 design canvas: the shared scenes own input, timing, audio, and layout - * contracts (lane geometry, select hit areas), and call into the active skin for every pixel of chrome. - * - * Hosts pick one with {@link BeMusicSkin.id} from a registry (`createBeMusicSkinRegistry`) and pass it to the default - * scene classes via their `beMusicSkin` option. Swapping skins only needs the scenes to be rebuilt. - */ -export interface BeMusicSkin { - /** Stable identifier (persisted by hosts, e.g. `'phantom'`). */ - readonly id: string; - /** Human-readable name for pickers. */ - readonly label: string; - /** - * CSS font shorthands (`'400 24px "Anton"'`) the skin draws with. Hosts should load the faces (e.g. via Google Fonts) - * and may `document.fonts.load` these before mounting so the first frame doesn't rasterize with a fallback face. - */ - readonly fontLoads: readonly string[]; - readonly gameplay: BeMusicGameplaySkin; - readonly select: BeMusicSelectSkin; - readonly result: BeMusicResultSkin; -} - -/** Visual class of a lane, resolved from its channel — skins pick colours per class. */ -export type BeMusicLaneKind = 'white' | 'black' | 'scratch'; - -export interface BeMusicGameplaySkin { - /** HUD chrome around the playfield (header, gauge, score, BGA frame, judgement / combo text). */ - readonly renderChrome: SkinlessGameplayChromeRenderer; - /** Lane beds, key beams, judgement line and key caps for every lane, drawn into one cleared `Graphics`. */ - renderLanes(context: BeMusicLanesContext): void; - /** One tap note; `context.graphics` is a fresh pooled `Graphics` owned by this note. */ - renderNote(context: BeMusicNoteContext): void; - /** One long note body plus its head / tail caps; `context.graphics` is a fresh pooled `Graphics`. */ - renderLongNote(context: BeMusicLongNoteContext): void; - /** Every live hit effect for this frame. Acquire children from `context.pool` (Graphics / Sprite / Text). */ - renderBombs(context: BeMusicBombsContext): void; - /** How long a hit effect lives (ms) before the scene retires it. */ - readonly bombDurationMs: number; -} - -export interface BeMusicLaneFrame { - channel: string; - kind: BeMusicLaneKind; - x: number; - w: number; - top: number; - /** Judgement line y — notes land with their bottom edge here. */ - bottom: number; - /** Key-beam intensity in [0, 1]: 1 while held, decaying after release. */ - beam: number; -} - -export interface BeMusicLanesContext { - graphics: Graphics; - lanes: readonly BeMusicLaneFrame[]; - /** Fractional beat position in [0, 1). */ - beatPhase: number; - nowMs: number; -} - -export interface BeMusicNoteContext { - graphics: Graphics; - kind: BeMusicLaneKind; - /** Lane rect x / width. Skins choose their own inset. */ - x: number; - w: number; - /** Just-timing y: the note's bottom edge. */ - y: number; - nowMs: number; -} - -export interface BeMusicLongNoteContext { - graphics: Graphics; - kind: BeMusicLaneKind; - x: number; - w: number; - /** Tail just-timing y (upper). */ - top: number; - /** Head just-timing y (lower), clamped to the judgement line while held. */ - bottom: number; - nowMs: number; -} - -export interface BeMusicBomb { - channel: string; - kind: BeMusicLaneKind; - x: number; - w: number; - /** Judgement line y of the lane. */ - y: number; - /** Milliseconds since the hit. */ - elapsedMs: number; - /** Stable per-hit seed so particle scatter doesn't shimmer frame to frame. */ - seed: number; -} - -export interface BeMusicBombsContext { - pool: ChildPool; - bombs: readonly BeMusicBomb[]; - nowMs: number; -} - -/** Song-list geometry shared by the select renderer (drawing) and the scene (row hit-testing). */ -export interface BeMusicSelectLayout { - listX: number; - listTop: number; - /** Distance from the canvas bottom to the list's last row edge. */ - listBottomInset: number; - rowHeight: number; -} - -export interface BeMusicSelectSkin { - readonly layout: BeMusicSelectLayout; - /** Creates the per-scene renderer; called once per select scene instance. */ - createRenderer(): BeMusicSelectRenderer; -} - -export interface BeMusicSelectActions { - play: () => void; - autoPlay: () => void; - activateSearch: () => void; -} - -export interface BeMusicSelectFrame { - /** Rebuilt every render: the renderer adds its chrome, rows, and hit areas here. */ - layer: Container; - designWidth: number; - designHeight: number; - nowMs: number; - /** `performance.now()` when the scene was (re)shown — drives entrance animations. */ - sceneStartedAt: number; - /** `performance.now()` of the last cursor move — drives focus transitions. */ - cursorChangedAt: number; - entries: readonly BrowserBrowseEntry[]; - selectedIndex: number; - /** First entry index drawn in the list window, and how many rows fit (see `resolveSelectListWindow`). */ - firstVisibleIndex: number; - visibleRows: number; - focusedSong: BrowserSongEntry | undefined; - /** Current folder label (or search label), `undefined` at the library root. */ - folderLabel: string | undefined; - searchQuery: string; - totalCharts: number; - actions: BeMusicSelectActions; -} - -export interface BeMusicSelectRenderer { - /** Persistent layer mounted behind the rebuilt `frame.layer` (ambient backgrounds). */ - readonly backLayer: Container; - /** Persistent layer mounted in front of `frame.layer` (cursor, glints). */ - readonly frontLayer: Container; - /** - * Rebuilds the input-driven chrome into `frame.layer`. Returns `true` while a transition is still in flight, asking - * the scene to render again next frame. - */ - render(frame: BeMusicSelectFrame): boolean; - /** Per-frame, transform-only animation of the persistent layers. */ - tick(nowMs: number, focusedSong: BrowserSongEntry | undefined): void; - dispose(): void; -} - -export interface BeMusicResultFrame { - /** Rebuilt every frame. */ - layer: Container; - designWidth: number; - designHeight: number; - result: PixiGameplayResultData; - /** IIDX DJ level (`AAA` … `F`). */ - rankLabel: string; - /** EX-score rate in percent. */ - ratePercent: number; - /** Milliseconds since the scene mounted, or `Infinity` once the player skipped the entrance. */ - elapsedMs: number; - nowMs: number; -} - -export interface BeMusicResultSkin { - render(frame: BeMusicResultFrame): void; -} +/** The be-music skin contract lives in `@be-music/skin-sdk`; re-exported here for the player's own modules. */ +export type * from '@be-music/skin-sdk'; diff --git a/packages/player-web/src/skins/boundary.test.ts b/packages/player-web/src/skins/boundary.test.ts new file mode 100644 index 00000000..03cd3695 --- /dev/null +++ b/packages/player-web/src/skins/boundary.test.ts @@ -0,0 +1,60 @@ +import { readdirSync, readFileSync } from 'node:fs'; +import { dirname, join, relative, resolve, sep } from 'node:path'; +import { describe, expect, it } from 'vite-plus/test'; + +/** + * The built-in skins are written like third-party skins: each may import only the public skin SDK (`@be-music/skin-sdk`), files inside + * its own folder, and the rendering framework it brings (tests may also use the test runner and Node built-ins). The + * Pixi skins bring `pixi.js` and the shared Pixi kit (`pixi-kit/`, itself limited to the SDK and Pixi); Plain, the + * Canvas 2D example, brings neither. This keeps the SDK honest — if a built-in skin needs something from the player, + * the SDK has to offer it. + */ +const SKINS_DIR = import.meta.dirname; +const SDK_PACKAGE = '@be-music/skin-sdk'; +const PIXI_KIT = join(SKINS_DIR, 'pixi-kit'); +const PIXI_KIT_BARREL = join(PIXI_KIT, 'index.ts'); +/** Skins drawn without Pixi. */ +const FRAMEWORK_FREE = new Set(['plain']); +const SPECIFIER = /\b(?:import|export)\b[^'"]*?\bfrom\s*['"]([^'"]+)['"]|\bimport\s*\(\s*['"]([^'"]+)['"]\s*\)/gu; + +function listFiles(dir: string): string[] { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const path = join(dir, entry.name); + if (entry.isDirectory()) return listFiles(path); + return entry.name.endsWith('.ts') ? [path] : []; + }); +} + +function violations(file: string): string[] { + const skinDir = join(SKINS_DIR, relative(SKINS_DIR, file).split(sep)[0]!); + const isTest = file.endsWith('.test.ts'); + const found: string[] = []; + for (const match of readFileSync(file, 'utf8').matchAll(SPECIFIER)) { + const specifier = match[1] ?? match[2]!; + const usesPixi = !FRAMEWORK_FREE.has(relative(SKINS_DIR, skinDir)); + if (specifier === SDK_PACKAGE || (specifier === 'pixi.js' && usesPixi)) continue; + if (isTest && (specifier === 'vite-plus/test' || specifier.startsWith('node:'))) continue; + if (specifier.startsWith('.')) { + const target = resolve(dirname(file), specifier); + if (target.startsWith(skinDir + sep)) continue; + if (target === PIXI_KIT_BARREL && usesPixi && skinDir !== PIXI_KIT) continue; + } + found.push(`${relative(SKINS_DIR, file)} imports ${specifier}`); + } + return found; +} + +describe('built-in skin import boundary', () => { + const skins = readdirSync(SKINS_DIR, { withFileTypes: true }).filter((entry) => entry.isDirectory()); + + it('covers every built-in skin', () => { + expect(skins.map((entry) => entry.name).sort()).toEqual(['lattice', 'phantom', 'pixi-kit', 'plain', 'synesthesia']); + }); + + it.each(skins.map((entry) => entry.name))( + '%s imports only the skin SDK, its framework, and its own files', + (name) => { + expect(listFiles(join(SKINS_DIR, name)).flatMap(violations)).toEqual([]); + }, + ); +}); diff --git a/packages/player-web/src/skins/built-in.ts b/packages/player-web/src/skins/built-in.ts new file mode 100644 index 00000000..ec08247d --- /dev/null +++ b/packages/player-web/src/skins/built-in.ts @@ -0,0 +1,10 @@ +import { phantomSkin } from './phantom/index.ts'; +import { plainSkin } from './plain/index.ts'; +import { synesthesiaSkin } from './synesthesia/index.ts'; +import type { BeMusicSkin } from '@be-music/skin-sdk'; + +/** + * The be-music skins a player loads by default, in picker order; the first is the default. More skins — the bundled + * `latticeSkin`, or third-party ones — are added to a registry with `BeMusicSkinRegistry.add`. + */ +export const BUILT_IN_BE_MUSIC_SKINS: readonly BeMusicSkin[] = [synesthesiaSkin, phantomSkin, plainSkin]; diff --git a/packages/player-web/src/skins/lattice/draw.ts b/packages/player-web/src/skins/lattice/draw.ts new file mode 100644 index 00000000..3fda64a7 --- /dev/null +++ b/packages/player-web/src/skins/lattice/draw.ts @@ -0,0 +1,277 @@ +import type { Container, Graphics } from 'pixi.js'; +import { + bendNeedle, + digitTransitions, + needleColumnAngle, + needleRowAngle, + springEase, + type NeedleFieldInput, +} from './field.ts'; +import { LAT_ACCENT, LAT_DISPLAY_FONT, LAT_GRAPHITE, LAT_GRID, LAT_INK, LAT_MONO_FONT, LAT_RULE } from './style.ts'; +import { + addHudText, + addSkinText, + type ChildPool, + type HudTextOptions, + pointLayerFor, + type SkinTextOptions, +} from '../pixi-kit/index.ts'; + +/** + * Shared Lattice drawing: the graph-paper grid, the needle field, ruler ticks, per-letter staggered type, and the + * odometer counter. Callers own the `Graphics` / pools; these only append geometry and text. + */ + +export interface Rect { + x: number; + y: number; + w: number; + h: number; +} + +/** Faint graph-paper rules every {@link LAT_GRID} px inside `area`, skipping `hole` (a live BGA) when given. */ +export function drawPaperGrid(graphics: Graphics, area: Rect, alpha: number, hole?: Rect): void { + const right = area.x + area.w; + const bottom = area.y + area.h; + for (let x = Math.ceil(area.x / LAT_GRID) * LAT_GRID; x <= right; x += LAT_GRID) { + if (hole && x > hole.x && x < hole.x + hole.w) { + graphics + .moveTo(x, area.y) + .lineTo(x, hole.y) + .moveTo(x, hole.y + hole.h) + .lineTo(x, bottom); + } else { + graphics.moveTo(x, area.y).lineTo(x, bottom); + } + } + for (let y = Math.ceil(area.y / LAT_GRID) * LAT_GRID; y <= bottom; y += LAT_GRID) { + if (hole && y > hole.y && y < hole.y + hole.h) { + graphics + .moveTo(area.x, y) + .lineTo(hole.x, y) + .moveTo(hole.x + hole.w, y) + .lineTo(right, y); + } else { + graphics.moveTo(area.x, y).lineTo(right, y); + } + } + graphics.stroke({ color: LAT_RULE, width: 1, alpha, pixelLine: true }); +} + +/** + * The needle field: one short needle per grid point in `area`, oriented by {@link needleAngle}. Needles that the field + * has turned far from rest grow and switch to the accent colour, so waves read as travelling light. + * + * With `particles` the needles are rotated thin quads on `graphics`' {@link pointLayerFor particle layer}, so the field + * costs no geometry rebuild per frame — use it when `graphics` is a persistent or pooled node (the same object every + * frame). Otherwise they are stroked into `graphics` (two strokes total). + */ +export function drawNeedleField( + graphics: Graphics, + area: Rect, + input: NeedleFieldInput, + options: { + pitch?: number; + length?: number; + alpha?: number; + skip?: (x: number, y: number) => boolean; + /** Rects the field leaves clean (needles strictly inside are skipped) — cheaper than an equivalent `skip`. */ + clean?: readonly Rect[]; + particles?: boolean; + } = {}, +): void { + const pitch = options.pitch ?? LAT_GRID / 2; + const length = options.length ?? 4.5; + const alpha = options.alpha ?? 0.5; + const inkAlpha = alpha * 0.55; + const accentAlpha = Math.min(1, alpha * 1.6); + const layer = options.particles ? pointLayerFor(graphics) : undefined; + const accentPath: number[] = []; + let inkCount = 0; + const x0 = Math.ceil(area.x / pitch) * pitch; + const y0 = Math.ceil(area.y / pitch) * pitch; + // The base angle splits into a column term and a row term: work each out once instead of once per needle. + const columns = Math.max(0, Math.floor((area.x + area.w - x0) / pitch) + 1); + if (COLUMN_ANGLES.length < columns) COLUMN_ANGLES = new Float64Array(columns * 2); + for (let column = 0; column < columns; column += 1) + COLUMN_ANGLES[column] = needleColumnAngle(x0 + column * pitch, input); + const clean = options.clean ?? []; + const rowClean = ROW_CLEAN; + for (let y = y0; y <= area.y + area.h; y += pitch) { + const rowAngle = needleRowAngle(y, input); + rowClean.length = 0; + for (const rect of clean) if (y > rect.y && y < rect.y + rect.h) rowClean.push(rect); + for (let column = 0; column < columns; column += 1) { + const x = x0 + column * pitch; + if (insideAny(rowClean, x)) continue; + if (options.skip?.(x, y)) continue; + const angle = bendNeedle(COLUMN_ANGLES[column]! + rowAngle, x, y, input); + let turn = Math.abs((((angle - Math.PI / 4) % Math.PI) + Math.PI) % Math.PI); + turn = Math.min(turn, Math.PI - turn) / (Math.PI / 2); + const half = (length * (1 + 0.7 * turn)) / 2; + const dx = Math.cos(angle) * half; + const dy = Math.sin(angle) * half; + if (layer) { + if (turn > 0.45) layer.segment(x - dx, y - dy, x + dx, y + dy, 1, LAT_ACCENT, accentAlpha); + else layer.segment(x - dx, y - dy, x + dx, y + dy, 0.75, LAT_INK, inkAlpha); + } else if (turn > 0.45) { + accentPath.push(x - dx, y - dy, x + dx, y + dy); + } else { + graphics.moveTo(x - dx, y - dy).lineTo(x + dx, y + dy); + inkCount += 1; + } + } + } + if (inkCount > 0) graphics.stroke({ color: LAT_INK, width: 0.75, alpha: inkAlpha }); + for (let index = 0; index < accentPath.length; index += 4) { + graphics.moveTo(accentPath[index]!, accentPath[index + 1]!).lineTo(accentPath[index + 2]!, accentPath[index + 3]!); + } + if (accentPath.length > 0) graphics.stroke({ color: LAT_ACCENT, width: 1, alpha: accentAlpha }); +} + +/** Per-column base angles for the needle field being drawn, reused across frames. */ +let COLUMN_ANGLES = new Float64Array(128); +/** The `clean` rects crossing the row being drawn. */ +const ROW_CLEAN: Rect[] = []; + +/** Whether `x` lies strictly inside the horizontal span of any of `rects`. */ +function insideAny(rects: readonly Rect[], x: number): boolean { + for (const rect of rects) if (x > rect.x && x < rect.x + rect.w) return true; + return false; +} + +/** Ruler ticks along a horizontal edge: a long tick every `major` px, short ones every `minor`. */ +export function drawRulerTicks( + graphics: Graphics, + x: number, + y: number, + w: number, + direction: 1 | -1, + color = LAT_INK, + alpha = 0.6, + minor = 5, + major = 20, +): void { + for (let offset = 0; offset <= w + 1e-6; offset += minor) { + const long = Math.round(offset) % major === 0; + graphics.moveTo(x + offset, y).lineTo(x + offset, y + direction * (long ? 5 : 2.5)); + } + graphics.stroke({ color, width: 1, alpha }); +} + +/** Per-letter motion for {@link addStaggeredHudText}: offset, opacity, and scale of letter `index` of `count`. */ +export type LetterMotion = ( + index: number, + count: number, +) => { dx?: number; dy?: number; alpha?: number; scale?: number }; + +/** + * Pooled HUD text laid out letter by letter so each glyph can move on its own (kinetic typography). `anchorX` of + * `options` aligns the whole word; letters keep proportional advances. + */ +export function addStaggeredHudText( + layer: Container, + text: string, + x: number, + y: number, + options: HudTextOptions, + pool: ChildPool, + motion: LetterMotion, +): void { + const chars = Array.from(text); + const nodes = chars.map((char) => + addHudText(layer, char, 0, y, { ...options, anchorX: 0, maxWidth: undefined, scale: undefined }, pool), + ); + const widths = nodes.map((node) => node.width); + const total = widths.reduce((sum, width) => sum + width, 0); + let cursor = x - total * (options.anchorX ?? 0); + nodes.forEach((node, index) => { + const move = motion(index, nodes.length); + const scale = move.scale ?? 1; + // Scale each glyph around its own centre. + node.anchor.set(0.5, options.anchorY ?? 0); + node.position.set(cursor + widths[index]! / 2 + (move.dx ?? 0), y + (move.dy ?? 0)); + node.scale.set(scale); + node.alpha = move.alpha ?? 1; + cursor += widths[index]!; + }); +} + +/** + * Odometer counter: tabular digits where every wheel that turned since `previous` rolls in from below with a spring + * while its old digit slides out upward. `t` is the roll progress (0..1) since the count changed. + */ +export function addOdometer( + layer: Container, + value: number, + previous: number, + t: number, + x: number, + y: number, + options: HudTextOptions & { cell: number; travel: number }, + pool: ChildPool, +): void { + const digits = digitTransitions(previous, value); + const width = digits.length * options.cell; + const left = x - width * (options.anchorX ?? 0); + const roll = springEase(Math.max(0, Math.min(1, t)), 2, 0.5); + const base: HudTextOptions = { ...options, anchorX: 0.5, maxWidth: undefined }; + digits.forEach((digit, index) => { + const cx = left + options.cell * (index + 0.5); + if (!digit.changed || t >= 1) { + addHudText(layer, digit.char, cx, y, base, pool); + return; + } + const incoming = addHudText(layer, digit.char, cx, y + options.travel * (1 - roll), base, pool); + incoming.alpha = Math.min(1, roll * 1.4); + if (digit.previous) { + const outgoing = addHudText(layer, digit.previous, cx, y - options.travel * Math.min(1, roll), base, pool); + outgoing.alpha = Math.max(0, 1 - roll * 1.6); + } + }); +} + +/** Small index / label type: Azeret Mono 9 px. */ +export function monoStyle(fill: number = LAT_GRAPHITE): HudTextOptions { + return { size: 9, weight: '500', fill, fontFamily: LAT_MONO_FONT, letterSpacing: 0.6 }; +} + +/** Inter display type; thin weights for large figures. */ +export function displayStyle( + size: number, + fill: number, + weight: '200' | '300' | '400' | '600' = '300', +): HudTextOptions { + return { size, fill, fontFamily: LAT_DISPLAY_FONT, weight }; +} + +/** + * Non-pooled twin of {@link addStaggeredHudText} for scenes rebuilt per render (select / result). A word wider than + * `options.maxWidth` is squeezed as a whole, letters and advances alike. + */ +export function addStaggeredSkinText( + layer: Container, + text: string, + x: number, + y: number, + options: SkinTextOptions, + motion: LetterMotion, +): void { + const chars = Array.from(text); + const nodes = chars.map((char) => addSkinText(layer, char, 0, y, { ...options, anchorX: 0, maxWidth: undefined })); + const widths = nodes.map((node) => node.width); + const total = widths.reduce((sum, width) => sum + width, 0); + const squeeze = options.maxWidth !== undefined && total > options.maxWidth ? options.maxWidth / total : 1; + let cursor = x - total * squeeze * (options.anchorX ?? 0); + const baseAlpha = options.alpha ?? 1; + nodes.forEach((node, index) => { + const move = motion(index, nodes.length); + const scale = move.scale ?? 1; + const advance = widths[index]! * squeeze; + node.anchor.set(0.5, options.anchorY ?? 0); + node.position.set(cursor + advance / 2 + (move.dx ?? 0), y + (move.dy ?? 0)); + node.scale.set(scale * squeeze, scale); + node.alpha = baseAlpha * (move.alpha ?? 1); + cursor += advance; + }); +} diff --git a/packages/player-web/src/skins/lattice/field.test.ts b/packages/player-web/src/skins/lattice/field.test.ts new file mode 100644 index 00000000..c9356512 --- /dev/null +++ b/packages/player-web/src/skins/lattice/field.test.ts @@ -0,0 +1,222 @@ +import { describe, expect, it } from 'vite-plus/test'; +import { + RIPPLE_LIFE_MS, + countInStep, + digitTransitions, + mixLineAngle, + bendNeedle, + needleAngle, + needleColumnAngle, + needleRowAngle, + scrambleText, + scrambleTick, + springEase, + tileFlipPhase, +} from './field.ts'; + +/** Angle difference between two undirected lines, in [0, π/2]. */ +function lineDelta(a: number, b: number): number { + const d = Math.abs((((a - b) % Math.PI) + Math.PI) % Math.PI); + return Math.min(d, Math.PI - d); +} + +describe('needleAngle', () => { + const calm = { seconds: 0, beatPhase: 0, flow: 0 }; + + it('rests at 45 degrees with no forces', () => { + expect(needleAngle(100, 100, calm)).toBeCloseTo(Math.PI / 4, 12); + }); + + it('points at a full-strength attractor', () => { + const angle = needleAngle(0, 0, { ...calm, attractor: { x: 10, y: 0, strength: 1 } }); + expect(lineDelta(angle, 0)).toBeCloseTo(0, 9); + }); + + it('lies tangent to a full-strength swirl', () => { + const angle = needleAngle(10, 0, { ...calm, swirl: { x: 0, y: 0, strength: 1 } }); + expect(lineDelta(angle, Math.PI / 2)).toBeCloseTo(0, 9); + }); + + it('turns needles on a ripple front and forgets expired ripples', () => { + const at = (ageMs: number) => needleAngle(50, 0, { ...calm, ripples: [{ x: 0, y: 0, ageMs, strength: 1 }] }); + expect(lineDelta(at(100), Math.PI / 4)).toBeGreaterThan(0.5); + expect(at(RIPPLE_LIFE_MS)).toBeCloseTo(Math.PI / 4, 12); + }); + + it('sweeps a beat wave through the field', () => { + const onFront = needleAngle(320, 0, { ...calm, beatPhase: 0.5, beatWave: 1 }); + const offFront = needleAngle(0, 0, { ...calm, beatPhase: 0.5, beatWave: 1 }); + expect(onFront - Math.PI / 4).toBeCloseTo(Math.PI / 2, 6); + expect(offFront).toBeCloseTo(Math.PI / 4, 6); + }); + + it('trembles within its amplitude', () => { + for (let step = 0; step < 30; step += 1) { + const angle = needleAngle(40 + step * 10, 60, { ...calm, seconds: step / 30, tremble: 0.1 }); + expect(Math.abs(angle - Math.PI / 4)).toBeLessThanOrEqual(0.1 + 1e-9); + } + }); + + it('stands needles upright inside a spectrum bar and leaves the rest alone', () => { + const spectrum = { levels: [1, 0], left: 0, right: 200, top: 0, bottom: 100 }; + // Left column is full: every needle in it stands. + expect(lineDelta(needleAngle(50, 10, { ...calm, spectrum }), Math.PI / 2)).toBeCloseTo(0, 9); + // Right column is silent. + expect(needleAngle(150, 90, { ...calm, spectrum })).toBeCloseTo(Math.PI / 4, 12); + // Outside the spectrum rect nothing changes. + expect(needleAngle(250, 50, { ...calm, spectrum })).toBeCloseTo(Math.PI / 4, 12); + }); + + it('raises a half-height bar with a feathered top', () => { + const spectrum = { levels: [0.5], left: 0, right: 100, top: 0, bottom: 100 }; + expect(lineDelta(needleAngle(50, 90, { ...calm, spectrum }), Math.PI / 2)).toBeCloseTo(0, 9); + expect(needleAngle(50, 20, { ...calm, spectrum })).toBeCloseTo(Math.PI / 4, 12); + const edge = needleAngle(50, 44, { ...calm, spectrum }); + expect(lineDelta(edge, Math.PI / 4)).toBeGreaterThan(0); + expect(lineDelta(edge, Math.PI / 2)).toBeGreaterThan(0); + }); + + it('is deterministic under jitter', () => { + const input = { ...calm, seconds: 1.23, jitter: 1 }; + expect(needleAngle(40, 60, input)).toBe(needleAngle(40, 60, input)); + }); +}); + +describe('mixLineAngle', () => { + it('turns the short way for undirected lines', () => { + // 170° and 10° are 20° apart as lines; halfway lands on 0° / 180°. + const mixed = mixLineAngle((170 * Math.PI) / 180, (10 * Math.PI) / 180, 0.5); + expect(lineDelta(mixed, 0)).toBeCloseTo(0, 9); + }); +}); + +describe('springEase', () => { + it('starts at 0, overshoots, and settles at 1', () => { + expect(springEase(0)).toBe(0); + expect(springEase(1)).toBe(1); + let peak = 0; + for (let step = 1; step < 100; step += 1) peak = Math.max(peak, springEase(step / 100)); + expect(peak).toBeGreaterThan(1.1); + expect(peak).toBeLessThan(1.35); + expect(springEase(0.95)).toBeCloseTo(1, 1); + }); + + it('clamps out-of-range time', () => { + expect(springEase(-1)).toBe(0); + expect(springEase(Number.NaN)).toBe(0); + expect(springEase(4)).toBe(1); + }); +}); + +describe('digitTransitions', () => { + it('turns only the wheels that change', () => { + expect(digitTransitions(129, 130).map((digit) => digit.changed)).toEqual([false, true, true]); + }); + + it('marks a new leading digit', () => { + expect(digitTransitions(99, 100)).toEqual([ + { char: '1', previous: '', changed: true }, + { char: '0', previous: '9', changed: true }, + { char: '0', previous: '9', changed: true }, + ]); + }); +}); + +describe('countInStep', () => { + it('counts 3 / 2 / 1 then GO', () => { + expect(countInStep(-2400)).toEqual({ label: '3', progress: 0 }); + expect(countInStep(-1200)).toEqual({ label: '2', progress: 0.5 }); + expect(countInStep(-1)?.label).toBe('1'); + expect(countInStep(300)).toEqual({ label: 'GO', progress: 0.5 }); + }); + + it('is silent outside the count-in', () => { + expect(countInStep(-2401)).toBeUndefined(); + expect(countInStep(600)).toBeUndefined(); + expect(countInStep(Number.NEGATIVE_INFINITY)).toBeUndefined(); + }); +}); + +describe('tileFlipPhase', () => { + it('flips the top-left tile first and the bottom-right tile last', () => { + expect(tileFlipPhase(0, 0, 10, 8, 0.5)).toBe(1); + expect(tileFlipPhase(9, 7, 10, 8, 0.5)).toBe(0); + expect(tileFlipPhase(9, 7, 10, 8, 1)).toBe(1); + }); + + it('stays within 0..1', () => { + for (const t of [-1, 0, 0.3, 0.7, 2]) { + const phase = tileFlipPhase(4, 3, 10, 8, t); + expect(phase).toBeGreaterThanOrEqual(0); + expect(phase).toBeLessThanOrEqual(1); + } + }); +}); + +describe('scrambleText', () => { + it('is blank before, noisy during, and exact after', () => { + expect(scrambleText('LATTICE', 0, 1)).toMatch(/^.\s{6}$/u); + const middle = scrambleText('LATTICE', 0.5, 1); + expect(Array.from(middle)).toHaveLength(7); + expect(middle).not.toBe('LATTICE'); + expect(scrambleText('LATTICE', 1, 1)).toBe('LATTICE'); + expect(scrambleText('LATTICE', 2, 1)).toBe('LATTICE'); + }); + + it('decodes left to right', () => { + const early = scrambleText('ABCDEFGH', 0.55, 3); + expect(early.slice(0, 2)).toBe('AB'); + expect(early.at(-1)).not.toBe('H'); + }); + + it('keeps spaces and line length, scrambling wide characters through katakana', () => { + const scrambled = scrambleText('曲 名です', 0.5, 2); + expect(Array.from(scrambled)).toHaveLength(5); + expect(Array.from(scrambled)[1]).toBe(' '); + for (const char of scrambled) { + if (char !== ' ') expect(char.codePointAt(0)!).toBeGreaterThan(0x2e7f); + } + }); + + it('re-rolls glyphs with the tick but is stable within one', () => { + expect(scrambleText('SCRAMBLE', 0.5, 9)).toBe(scrambleText('SCRAMBLE', 0.5, 9)); + const rolls = new Set([1, 2, 3, 4, 5, 6].map((tick) => scrambleText('SCRAMBLE', 0.5, tick))); + expect(rolls.size).toBeGreaterThan(1); + }); + + it('handles empty text and bad progress', () => { + expect(scrambleText('', 0.5, 1)).toBe(''); + expect(scrambleText('ABC', Number.NaN, 1)).toBe('ABC'); + }); +}); + +describe('scrambleTick', () => { + it('advances every interval', () => { + expect(scrambleTick(0)).toBe(0); + expect(scrambleTick(44)).toBe(0); + expect(scrambleTick(45)).toBe(1); + expect(scrambleTick(Number.NaN)).toBe(0); + }); +}); + +describe('needle angle parts', () => { + const input = { seconds: 3.2, beatPhase: 0.4, flow: 0.6, beatWave: 0.5 }; + + it('split the base angle into a column term and a row term', () => { + for (const [x, y] of [ + [0, 0], + [120, 300], + [640, 72], + ] as const) { + expect(needleColumnAngle(x, input) + needleRowAngle(y, input)).toBeCloseTo(needleAngle(x, y, input), 12); + } + }); + + it('leaves needles far from a ripple front untouched', () => { + const ripple = { ...input, ripples: [{ x: 0, y: 0, ageMs: 200, strength: 1 }] }; + const base = needleColumnAngle(400, input) + needleRowAngle(0, input); + // The front sits 100 px out; 400 px away the band is far below a micro-radian. + expect(bendNeedle(base, 400, 0, ripple)).toBe(base); + expect(bendNeedle(base, 100, 0, ripple)).not.toBe(base); + }); +}); diff --git a/packages/player-web/src/skins/lattice/field.ts b/packages/player-web/src/skins/lattice/field.ts new file mode 100644 index 00000000..32edaba6 --- /dev/null +++ b/packages/player-web/src/skins/lattice/field.ts @@ -0,0 +1,226 @@ +import { hash01 } from '@be-music/skin-sdk'; + +/** + * Pure motion helpers for the Lattice skin: the needle field (short strokes on a grid that turn like iron filings in + * a field), a closed-form spring, odometer digit transitions, the count-in steps, and a diagonal tile-flip wave. + * Everything is a function of its inputs, so the pooled per-frame redraw needs no animation state. + */ + +export interface NeedleFieldInput { + seconds: number; + /** Fractional beat in [0, 1): a wave front sweeps left → right across the field once per beat. */ + beatPhase: number; + /** Ambient flow amplitude (radians-ish, 0 = every needle at rest at 45°). */ + flow: number; + /** Strength of the per-beat sweep, 0..1. */ + beatWave?: number; + /** Ripples spreading from points (key presses), each fading out over {@link RIPPLE_LIFE_MS}. */ + ripples?: ReadonlyArray<{ x: number; y: number; ageMs: number; strength: number }>; + /** Needles turn to point at this spot with `strength` 0..1. */ + attractor?: { x: number; y: number; strength: number }; + /** Needles lie tangent to circles around this spot with `strength` 0..1. */ + swirl?: { x: number; y: number; strength: number }; + /** Random per-needle shake, 0..1 (a miss). */ + jitter?: number; + /** Constant nervous tremor (radians of peak shake), re-rolled every 1/30 s. */ + tremble?: number; + /** + * A spectrum drawn by the needles: `levels` (0..1 each) split `[left, right]` into equal columns, and in each column + * the needles from `bottom` up to the column's bar height stand upright — an equalizer of iron filings. The bar's + * top edge feathers over {@link SPECTRUM_FEATHER} px. + */ + spectrum?: { levels: readonly number[]; left: number; right: number; top: number; bottom: number }; +} + +export const SPECTRUM_FEATHER = 12; + +export const RIPPLE_LIFE_MS = 700; +const REST_ANGLE = Math.PI / 4; + +/** Orientation (radians) of the needle at `(x, y)`. Needles are undirected strokes, so angles repeat every π. */ +export function needleAngle(x: number, y: number, input: NeedleFieldInput): number { + return bendNeedle(needleColumnAngle(x, input) + needleRowAngle(y, input), x, y, input); +} + +/** + * The part of a needle's angle that depends only on its column: the rest angle, the flow's horizontal wave, and the + * beat sweep. With {@link needleRowAngle} it splits the field's base angle, so a whole field can work each column and + * row out once instead of once per needle. + */ +export function needleColumnAngle(x: number, input: NeedleFieldInput): number { + let angle = REST_ANGLE + input.flow * 0.5 * Math.sin(x * 0.017 + input.seconds * 0.6); + const beatWave = input.beatWave ?? 0; + if (beatWave > 0) { + let d = x / 640 - input.beatPhase; + d -= Math.round(d); + angle += beatWave * (Math.PI / 2) * Math.exp(-(d * d) / 0.004); + } + return angle; +} + +/** The part of a needle's angle that depends only on its row: the flow's vertical wave. */ +export function needleRowAngle(y: number, input: NeedleFieldInput): number { + return input.flow * 0.5 * Math.cos(y * 0.023 - input.seconds * 0.45); +} + +/** Distance from a ripple's front beyond which its band would turn a needle by under 1e-6 rad, so it is skipped. */ +const RIPPLE_REACH = 72; + +/** Turns a needle at `(x, y)` from its base `angle` by the ripples, attractor, swirl, spectrum, tremble, and jitter. */ +export function bendNeedle(base: number, x: number, y: number, input: NeedleFieldInput): number { + let angle = base; + const s = input.seconds; + for (const ripple of input.ripples ?? []) { + if (ripple.ageMs < 0 || ripple.ageMs >= RIPPLE_LIFE_MS) continue; + const dx = x - ripple.x; + const dy = y - ripple.y; + const front = ripple.ageMs * 0.5; + // Only the ring around the wave front turns needles; skip the square root and exponential everywhere else. + const distance2 = dx * dx + dy * dy; + const outer = front + RIPPLE_REACH; + const inner = front - RIPPLE_REACH; + if (distance2 > outer * outer || (inner > 0 && distance2 < inner * inner)) continue; + const r = Math.sqrt(distance2); + const band = Math.exp(-(((r - front) / 18) ** 2)) * (1 - ripple.ageMs / RIPPLE_LIFE_MS); + angle += band * ripple.strength * (Math.PI / 2); + } + if (input.attractor && input.attractor.strength > 0) { + const target = Math.atan2(input.attractor.y - y, input.attractor.x - x); + angle = mixLineAngle(angle, target, input.attractor.strength); + } + if (input.swirl && input.swirl.strength > 0) { + const target = Math.atan2(y - input.swirl.y, x - input.swirl.x) + Math.PI / 2; + angle = mixLineAngle(angle, target, input.swirl.strength); + } + const spectrum = input.spectrum; + if (spectrum && spectrum.levels.length > 0 && x >= spectrum.left && x <= spectrum.right && y <= spectrum.bottom) { + const span = Math.max(1e-6, spectrum.right - spectrum.left); + const column = Math.min( + spectrum.levels.length - 1, + Math.floor(((x - spectrum.left) / span) * spectrum.levels.length), + ); + const level = Math.max(0, Math.min(1, spectrum.levels[column] ?? 0)); + const barTop = spectrum.bottom - level * (spectrum.bottom - spectrum.top); + const stand = level > 0 ? Math.max(0, Math.min(1, (y - barTop + SPECTRUM_FEATHER) / SPECTRUM_FEATHER)) : 0; + if (stand > 0) angle = mixLineAngle(angle, Math.PI / 2, stand); + } + const tremble = input.tremble ?? 0; + if (tremble > 0) { + angle += (hash01(x * 1.97 + y * 5.13 + Math.floor(s * 30) * 0.71) - 0.5) * 2 * tremble; + } + const jitter = input.jitter ?? 0; + if (jitter > 0) { + angle += (hash01(x * 7.13 + y * 3.71 + Math.floor(s * 30) * 0.37) - 0.5) * Math.PI * jitter; + } + return angle; +} + +/** Blend two undirected line angles along the shorter turn (lines repeat every π). */ +export function mixLineAngle(from: number, to: number, t: number): number { + let diff = (to - from) % Math.PI; + if (diff > Math.PI / 2) diff -= Math.PI; + if (diff < -Math.PI / 2) diff += Math.PI; + return from + diff * Math.max(0, Math.min(1, t)); +} + +/** + * Under-damped spring from 0 to 1 over normalized time `t` (0..1, clamped): overshoots about 20 % and settles by + * `t = 1`. `frequency` is oscillations per unit time. + */ +export function springEase(t: number, frequency = 2.2, damping = 0.45): number { + if (!(t > 0)) return 0; + if (t >= 1) return 1; + const omega = Math.PI * 2 * frequency; + const damped = omega * Math.sqrt(1 - damping * damping); + const envelope = Math.exp(-damping * omega * t); + return 1 - envelope * (Math.cos(damped * t) + ((damping * omega) / damped) * Math.sin(damped * t)); +} + +export interface DigitTransition { + char: string; + /** The digit this slot showed before (`''` when the number grew a digit). */ + previous: string; + changed: boolean; +} + +/** Right-aligned per-digit comparison of two counts — which odometer wheels turn when `previous` becomes `next`. */ +export function digitTransitions(previous: number, next: number): DigitTransition[] { + const before = String(Math.max(0, Math.floor(previous))); + const after = String(Math.max(0, Math.floor(next))); + const pad = after.length - before.length; + return Array.from(after).map((char, index) => { + const prior = index - pad >= 0 ? (before[index - pad] ?? '') : ''; + return { char, previous: prior, changed: prior !== char }; + }); +} + +/** + * Count-in step at `chartMs` (negative before the first note): 3 / 2 / 1 on 800 ms steps, then GO for 600 ms. + * `progress` runs 0..1 within the step. + */ +export function countInStep(chartMs: number): { label: '3' | '2' | '1' | 'GO'; progress: number } | undefined { + if (!Number.isFinite(chartMs) || chartMs < -2400 || chartMs >= 600) return undefined; + if (chartMs >= 0) return { label: 'GO', progress: chartMs / 600 }; + const step = Math.floor((chartMs + 2400) / 800); + const labels = ['3', '2', '1'] as const; + return { label: labels[step]!, progress: (chartMs + 2400 - step * 800) / 800 }; +} + +/** + * Local flip progress (0..1) of tile `(column, row)` in a `columns` × `rows` grid when a diagonal wave sweeping from + * the top-left is at global progress `t`. `spread` is the share of the timeline the wave takes to cross the grid. + */ +export function tileFlipPhase( + column: number, + row: number, + columns: number, + rows: number, + t: number, + spread = 0.5, +): number { + const along = ((columns > 1 ? column / (columns - 1) : 0) + (rows > 1 ? row / (rows - 1) : 0)) / 2; + const delay = along * spread; + return Math.max(0, Math.min(1, (t - delay) / (1 - spread))); +} + +const SCRAMBLE_LATIN = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789#%&*+=/<>?'; +const SCRAMBLE_WIDE = 'アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン'; + +/** + * Text scramble: `target` decodes left to right as `progress` runs 0 → 1. Each character first flickers through random + * glyphs (re-rolled whenever `tick` changes), then locks to its final form; characters the wave has not reached yet + * are blank. Spaces stay spaces, and wide (CJK) characters scramble through katakana so the line keeps its width. + */ +export function scrambleText(target: string, progress: number, tick: number, seed = 0): string { + const chars = Array.from(target); + if (!(progress < 1)) return target; + const count = chars.length; + if (count === 0) return target; + const t = Math.max(0, progress); + // Each character starts scrambling at `start` and settles `window` later; the wave crosses the text in 60 % of the run. + const window = 0.4; + let out = ''; + for (let index = 0; index < count; index += 1) { + const char = chars[index]!; + if (char === ' ' || char === ' ') { + out += char; + continue; + } + const start = count > 1 ? (index / (count - 1)) * (1 - window) : 0; + if (t < start) { + out += ' '; + } else if (t >= start + window) { + out += char; + } else { + const wide = (char.codePointAt(0) ?? 0) > 0x2e7f; + const set = wide ? SCRAMBLE_WIDE : SCRAMBLE_LATIN; + out += set[Math.floor(hash01(seed * 131 + index * 17 + tick * 7.31) * set.length) % set.length]; + } + } + return out; +} + +/** Glyph re-roll counter for {@link scrambleText}: a new random glyph set every `intervalMs` (default 45 ms). */ +export function scrambleTick(nowMs: number, intervalMs = 45): number { + return Number.isFinite(nowMs) ? Math.floor(nowMs / intervalMs) : 0; +} diff --git a/packages/player-web/src/skins/lattice/gameplay-chrome.ts b/packages/player-web/src/skins/lattice/gameplay-chrome.ts new file mode 100644 index 00000000..5b00644a --- /dev/null +++ b/packages/player-web/src/skins/lattice/gameplay-chrome.ts @@ -0,0 +1,994 @@ +import { Graphics, type Container } from 'pixi.js'; +import { GROOVE } from './layout.ts'; +import { + addOdometer, + addStaggeredHudText, + drawNeedleField, + drawPaperGrid, + drawRulerTicks, + displayStyle, + monoStyle, + type Rect, +} from './draw.ts'; +import { scrambleText, scrambleTick, springEase, tileFlipPhase, type NeedleFieldInput } from './field.ts'; +import { drawLatticeMoments, FULL_COMBO_MS, MILESTONE_MS } from './moments.ts'; +import { createChain, kickChain, stepChain, type Chain } from './physics.ts'; +import { + LAT_ACCENT, + LAT_GRAPHITE, + LAT_INK, + LAT_PAPER, + LAT_RULE, + LAT_SIGNAL, + LAT_TEXT_FONT, + LAT_WHITE, +} from './style.ts'; +import { + audioDrive, + type AudioDrive, + bandLevel, + type BeMusicRect, + comboTier, + effectProfile, + flashingGreatColor, + isFlashingGreat, + judgeDisplayWord, + LOADING_WORD, + loadingDots, + momentProgress, + type MomentState, + trackMoments, + resolveMilestoneArea, + type BeMusicGameplayLayout, + type BeMusicGameplayRuntime, + STAGE_BGA_BAND, + STAGE_HEIGHT as DESIGN_HEIGHT, + STAGE_SIDE_COLUMN, + STAGE_WIDTH as DESIGN_WIDTH, +} from '@be-music/skin-sdk'; +import { addHudNumber, addHudText, type ChildPool, type PixiChromeContext } from '../pixi-kit/index.ts'; + +type Runtime = BeMusicGameplayRuntime; + +/** + * HUD text scramble: during the count-in every label and the song title decode in, staggered by `delayMs`; once the + * chart runs (or with no count-in clock) text shows settled. + */ +function hudScramble(runtime: Runtime): (value: string, delayMs: number, seed: number) => string { + const chartMs = runtime.chartMs; + const tick = scrambleTick(runtime.nowMs ?? 0); + if (chartMs === undefined || runtime.effects === 'off') return (value) => value; + return (value, delayMs, seed) => + scrambleText(value, Math.max(0, Math.min(1, (chartMs + 2700 - delayMs) / 520)), tick, seed); +} + +/** + * HUD grid (design px). The playfield frame's left edge (x 32) is the page's left axis — the gauge rule and the song + * plate start on it — and the right blocks end on a 16 px margin. The score panel's top rule continues the gauge rule, + * its second row lines up with the song plate's rule, and its bottom with the plate's. + */ +const HUD_LEFT = 32; +const HUD_RIGHT = DESIGN_WIDTH - 16; +/** The score panel sits on the right margin; the track line runs from the left margin up to a gutter before it. */ +const SCORE_PANEL = { x: HUD_RIGHT - 240, y: GROOVE.y - 24, w: 240, h: 466 - (GROOVE.y - 24) } as const; +const SONG_PLATE = { x: HUD_LEFT, y: 420, w: HUD_RIGHT - 240 - 24 - HUD_LEFT, h: 46 } as const; + +/** + * The monitor rect of the frame being drawn: the stage's BGA square for the current playfield (it shrinks beside a + * double-play field). Updated at the top of {@link renderLatticeChrome} and read by the drawing helpers below. + */ +const BGA: BeMusicRect = { x: 0, y: 0, w: 0, h: 0 }; +/** Stand-in for a layout without a BGA (a page-wide keyboard field): an empty rect, so no monitor is drawn. */ +const NO_MONITOR: BeMusicRect = { x: 0, y: 0, w: 0, h: 0 }; +const HEADER_H = 30; + +/** + * Lattice gameplay HUD — precise interaction design on paper: graph-paper rules, a field of needles that turn like + * iron filings (a beat wave sweeping through, ripples from every key press, pulled toward a milestone, swirling on a + * full combo), print crop marks and ruler ticks on the monitor, and every figure set as type on a strict grid. The + * combo counter is an odometer; judgements drop in letter by letter on a spring. + */ +export function renderLatticeChrome({ + layer, + overlayLayer, + layerPool, + overlayLayerPool, + runtime, + layout, +}: PixiChromeContext): void { + const nowMs = runtime.nowMs ?? 0; + const seconds = nowMs / 1000; + const beatPhase = runtime.beatPhase ?? 0; + const { left: playfieldLeft, right: playfieldRight } = layout.playfield; + Object.assign(BGA, layout.bga ?? NO_MONITOR); + const hasMonitor = BGA.w > 0; + const hasBga = runtime.hasBga === true && hasMonitor; + const effects = effectProfile(runtime.effects); + const tier = effects.enabled ? Math.min(comboTier(runtime.combo ?? 0), effects.screenWide ? 4 : 2) : 0; + // The paper listens: needles flow harder with loudness and ripple out of the monitor on every onset, the pendulums + // are struck by transients, the idle monitor becomes a tile equalizer, and the tally carries a live spectrum + dB. + const drive = audioDrive(runtime.audio, runtime.effects); + const moments = trackMoments(layer, { + nowMs, + combo: runtime.combo ?? 0, + gauge: runtime.gauge ?? 0, + clearThreshold: runtime.clearThreshold ?? 80, + survival: runtime.gaugeSurvival === true, + judged: + (runtime.perfect ?? 0) + (runtime.great ?? 0) + (runtime.good ?? 0) + (runtime.bad ?? 0) + (runtime.poor ?? 0), + totalNotes: runtime.totalNotes ?? 0, + bad: runtime.bad ?? 0, + poor: runtime.poor ?? 0, + }); + + ensureStaticPaper(layer, hasBga); + + const field = layerPool.acquireGraphics(); + field.label = 'lattice-gameplay/field'; + field.blendMode = 'normal'; + // The field fills the paper around the type: the playfield and every HUD block stay clean. + const clean: Rect[] = [ + { x: playfieldLeft - 20, y: 0, w: playfieldRight - playfieldLeft + 26, h: 346 }, + ...(hasMonitor ? [{ x: BGA.x - 13, y: BGA.y - 13, w: BGA.w + 26, h: BGA.h + 26 }] : []), + { x: GROOVE.x - 18, y: GROOVE.y - 30, w: GROOVE.w + 36, h: 58 }, + { x: SONG_PLATE.x - 4, y: SONG_PLATE.y - 6, w: SONG_PLATE.w + 8, h: SONG_PLATE.h + 12 }, + { x: SCORE_PANEL.x - 8, y: SCORE_PANEL.y - 6, w: SCORE_PANEL.w + 16, h: SCORE_PANEL.h + 14 }, + { x: STAGE_SIDE_COLUMN.x - 8, y: STAGE_BGA_BAND.top - 12, w: DESIGN_WIDTH, h: 252 }, + ]; + // The gap between the lanes and the monitor holds the pendulums when it is wide enough (single play). + const pendulums = hasMonitor && playfieldRight + 40 <= BGA.x; + if (pendulums) clean.push({ x: playfieldRight + 6, y: HEADER_H, w: BGA.x - playfieldRight - 18, h: 290 }); + drawNeedleField( + field, + { x: 0, y: HEADER_H, w: DESIGN_WIDTH, h: DESIGN_HEIGHT - HEADER_H }, + resolveFieldInput(layer, runtime, moments, tier, effects.amount, layout, drive), + { + alpha: 0.55, + clean, + particles: true, + }, + ); + + const panels = layerPool.acquireGraphics(); + panels.label = 'lattice-gameplay/panels'; + panels.blendMode = 'normal'; + drawPlayfieldFrame(panels, playfieldLeft, playfieldRight, runtime.progressRatio); + if (pendulums) { + drawPendulums(panels, layer, runtime, (playfieldRight + BGA.x - 5) / 2, effects.amount, drive, layerPool); + } + if (hasMonitor) { + drawBgaFrame(panels, layer, hasBga, seconds, beatPhase, effects.amount, drive, layerPool); + } + drawGauge(panels, layer, runtime, moments, layerPool); + drawSongPlate(panels, layer, runtime, layerPool); + drawScorePanel(panels, layer, runtime, layerPool); + // The judge tally owns the side column on the right edge. + drawJudgeTally(panels, layer, runtime, STAGE_SIDE_COLUMN.x, drive, layerPool); + + const front = overlayLayerPool.acquireGraphics(); + front.label = 'lattice-gameplay/header'; + front.blendMode = 'normal'; + // During a screen-wide full combo the tiles cover the page; the header and judgement type would print through them. + const fullCombo = momentProgress(moments.fullComboAtMs, nowMs, FULL_COMBO_MS); + const covered = effects.screenWide && fullCombo !== undefined && fullCombo > 0.25 && fullCombo < 0.8; + if (!covered) { + drawHeader(front, overlayLayer, runtime, beatPhase, overlayLayerPool); + if (runtime.loading) { + drawLoading(overlayLayer, runtime, (playfieldLeft + playfieldRight) / 2, overlayLayerPool); + } else { + drawJudgements(overlayLayer, runtime, layout, effects.amount, overlayLayerPool); + } + } + drawLatticeMoments(overlayLayer, runtime, moments, (playfieldLeft + playfieldRight) / 2, overlayLayerPool, layout); +} + +const IMPULSE_HISTORY = new WeakMap(); +const ONSET_HISTORY = new WeakMap(); + +interface PendulumState { + chains: Chain[]; + lastMs: number; + lastImpulseAt: number | undefined; + lastBeat: number; + beats: number; + lastOnsetAt: number | undefined; +} +const PENDULUMS = new WeakMap(); +/** Two fine bead chains of different lengths (so they drift out of phase), hanging from the header rule. */ +const PENDULUM_SPECS = [ + { dx: -7, beads: 17, spacing: 8 }, + { dx: 7, beads: 23, spacing: 8 }, +] as const; + +/** + * Physics: two Verlet bead chains hang in the gap between the playfield and the monitor. Every key press strikes their + * bobs (white keys right, black keys and scratch left), the beat nudges them alternately, and they swing, whip and + * settle under gravity — with a live angle readout ticking above each. + */ +function drawPendulums( + graphics: Graphics, + layer: Container, + runtime: Runtime, + centerX: number, + amount: number, + drive: AudioDrive, + pool: ChildPool, +): void { + const nowMs = runtime.nowMs ?? 0; + const anchorY = HEADER_H + 14; + let state = PENDULUMS.get(layer); + if (!state || nowMs < state.lastMs) { + state = { + chains: PENDULUM_SPECS.map((spec) => createChain(centerX + spec.dx, anchorY, spec.beads, spec.spacing)), + lastMs: nowMs, + lastImpulseAt: runtime.impulseAtMs, + lastBeat: runtime.beatPhase ?? 0, + beats: 0, + lastOnsetAt: drive.onsetAtMs, + }; + PENDULUMS.set(layer, state); + } + if (amount > 0) { + if (runtime.impulseAtMs !== undefined && runtime.impulseAtMs !== state.lastImpulseAt) { + state.lastImpulseAt = runtime.impulseAtMs; + const direction = runtime.impulseKind === 'white' ? 1 : -1; + state.chains.forEach((chain, index) => { + kickChain(chain, chain.count - 1, direction * (80 + 30 * index) * amount, -30 * amount); + }); + } + const beatPhase = runtime.beatPhase ?? 0; + if (beatPhase < state.lastBeat) { + state.beats += 1; + const direction = state.beats % 2 === 0 ? 1 : -1; + state.chains.forEach((chain) => kickChain(chain, Math.floor(chain.count / 2), direction * 40 * amount, 0)); + } + state.lastBeat = beatPhase; + // Transients in the mix strike the strings too, harder on the bass. + if (drive.onsetAtMs !== undefined && drive.onsetAtMs !== state.lastOnsetAt) { + state.lastOnsetAt = drive.onsetAtMs; + const direction = state.beats % 2 === 0 ? -1 : 1; + state.chains.forEach((chain, index) => { + kickChain(chain, chain.count - 1 - index * 3, direction * (40 + 60 * drive.bass) * amount, 0); + }); + } + const dt = (nowMs - state.lastMs) / 1000; + state.chains.forEach((chain, index) => { + stepChain(chain, dt, { anchorX: centerX + PENDULUM_SPECS[index]!.dx, anchorY, gravity: 1400, damping: 0.992 }); + }); + } + state.lastMs = nowMs; + // The pivot bar. + graphics.rect(centerX - 14, anchorY - 1, 28, 1).fill(LAT_INK); + graphics.moveTo(centerX - 14, anchorY - 1).lineTo(centerX - 14, anchorY - 5); + graphics.moveTo(centerX + 14, anchorY - 1).lineTo(centerX + 14, anchorY - 5); + graphics.stroke({ color: LAT_INK, width: 1 }); + state.chains.forEach((chain, index) => { + for (let bead = 1; bead < chain.count; bead += 1) { + graphics.moveTo(chain.x[bead - 1]!, chain.y[bead - 1]!).lineTo(chain.x[bead]!, chain.y[bead]!); + } + graphics.stroke({ color: LAT_INK, width: 0.75, alpha: 0.7 }); + for (let bead = 1; bead < chain.count - 1; bead += 1) { + graphics.rect(chain.x[bead]! - 0.75, chain.y[bead]! - 0.75, 1.5, 1.5).fill(LAT_INK); + } + const end = chain.count - 1; + const bobX = chain.x[end]!; + const bobY = chain.y[end]!; + graphics.rect(bobX - 3, bobY - 3, 6, 6).fill(index === 0 ? LAT_ACCENT : LAT_INK); + // Live angle readout of the whole string, from pivot to bob. + const angle = (Math.atan2(bobX - chain.x[0]!, bobY - chain.y[0]!) * 180) / Math.PI; + addHudNumber( + layer, + `${angle >= 0 ? '+' : '-'}${Math.abs(angle).toFixed(1)}`, + bobX, + bobY + 6, + { ...monoStyle(index === 0 ? LAT_ACCENT : LAT_GRAPHITE), size: 9, anchorX: 0.5 }, + pool, + ); + }); +} + +/** + * Field forces for this frame: ambient flow and the beat sweep (stronger in the zone), ripples from the last few key + * presses rolling out of the judgement line, a pull toward the monitor during a milestone, and a swirl on a full combo. + * A miss adds nothing, so the field stays calm around the notes. + */ +function resolveFieldInput( + key: object, + runtime: Runtime, + moments: MomentState, + tier: number, + amount: number, + layout: BeMusicGameplayLayout, + drive: AudioDrive, +): NeedleFieldInput { + const nowMs = runtime.nowMs ?? 0; + let onsets = ONSET_HISTORY.get(key); + if (!onsets) { + onsets = []; + ONSET_HISTORY.set(key, onsets); + } + if (drive.onsetAtMs !== undefined && onsets[onsets.length - 1] !== drive.onsetAtMs) { + onsets.push(drive.onsetAtMs); + if (onsets.length > 4) onsets.shift(); + } + let history = IMPULSE_HISTORY.get(key); + if (!history) { + history = []; + IMPULSE_HISTORY.set(key, history); + } + if (runtime.impulseAtMs !== undefined && history[history.length - 1] !== runtime.impulseAtMs) { + history.push(runtime.impulseAtMs); + if (history.length > 6) history.shift(); + } + const originX = layout.playfield.centerX; + const ripples = + amount > 0 + ? [ + ...history.map((atMs) => ({ + x: originX, + y: layout.playfield.judgementY, + ageMs: nowMs - atMs, + strength: amount, + })), + // Onsets ring out of the monitor. + ...onsets.map((atMs) => ({ + x: BGA.x + BGA.w / 2, + y: BGA.y + BGA.h / 2, + ageMs: nowMs - atMs, + strength: 0.8 * amount, + })), + ] + : []; + const milestone = momentProgress(moments.milestone?.atMs, nowMs, MILESTONE_MS); + const fullCombo = momentProgress(moments.fullComboAtMs, nowMs, FULL_COMBO_MS); + const envelope = (t: number | undefined) => (t === undefined ? 0 : Math.sin(Math.min(1, t) * Math.PI)); + return { + seconds: (nowMs / 1000) * (amount > 0 ? 1 : 0), + beatPhase: runtime.beatPhase ?? 0, + flow: (0.35 + 0.12 * tier) * amount + 0.35 * drive.level, + beatWave: (0.45 + 0.12 * tier) * amount + 0.4 * drive.bass, + ripples, + attractor: (() => { + // Needles swing toward wherever the milestone plays (beside or below the lanes). + const area = resolveMilestoneArea(layout.playfield.right, 346, layout.stage.width); + const x = area.mode === 'below' ? area.x + area.w / 2 : BGA.x + BGA.w / 2; + const y = area.mode === 'below' ? area.y + area.h / 2 : BGA.y + BGA.h / 2; + return { x, y, strength: envelope(milestone) * amount }; + })(), + swirl: { x: DESIGN_WIDTH / 2, y: DESIGN_HEIGHT / 2, strength: envelope(fullCombo) * amount }, + jitter: 0, + tremble: 0.07 * amount, + }; +} + +/** + * NOW LOADING on the lanes: the mono caption re-decodes itself every couple of seconds, over a hairline + * whose blue segment sweeps back and forth like a plotter head. + */ +function drawLoading(layer: Container, runtime: Runtime, cx: number, pool: ChildPool): void { + const nowMs = runtime.nowMs ?? 0; + // Re-decodes once per cycle (a short scramble, then the plain caption for the rest of it) so it stays readable. + const word = scrambleText(LOADING_WORD, Math.min(1, (nowMs % 1800) / 360), scrambleTick(nowMs), 77); + addHudText( + layer, + `${word}${loadingDots(nowMs).padEnd(3, ' ')}`, + cx, + 190, + { ...monoStyle(), size: 12, fill: LAT_INK, letterSpacing: 2, anchorX: 0.5, anchorY: 0.5, maxWidth: 180 }, + pool, + ); + const graphics = pool.acquireGraphics(); + graphics.label = 'lattice-gameplay/loading'; + const w = 120; + const x = cx - w / 2; + graphics.rect(x, 206, w, 1).fill(LAT_INK); + const sweep = (Math.sin(nowMs / 380) + 1) / 2; + graphics.rect(x + sweep * (w - 24), 204, 24, 4).fill(LAT_ACCENT); +} + +/** + * The paper and its graph-paper rules never move, so they are drawn once into a persistent graphics at the back of the + * layer (redrawn only when a BGA appears or goes) instead of being rebuilt with the per-frame pooled graphics. + */ +const STATIC_PAPER = new WeakMap(); + +function ensureStaticPaper(layer: Container, hasBga: boolean): void { + // The paper is cut around the live BGA, whose rect depends on the playfield; redraw when either changes. + const hole = hasBga ? `${BGA.x},${BGA.y},${BGA.w},${BGA.h}` : ''; + let entry = STATIC_PAPER.get(layer); + if (!entry) { + const graphics = new Graphics(); + graphics.label = 'lattice-gameplay/paper'; + layer.addChildAt(graphics, 0); + entry = { graphics, hole: '-' }; + STATIC_PAPER.set(layer, entry); + } + if (entry.hole === hole) return; + entry.hole = hole; + entry.graphics.clear(); + fillAroundBga(entry.graphics, 0, 0, DESIGN_WIDTH, DESIGN_HEIGHT, LAT_PAPER, hasBga); + drawPaperGrid(entry.graphics, { x: 0, y: 0, w: DESIGN_WIDTH, h: DESIGN_HEIGHT }, 0.45, hasBga ? BGA : undefined); +} + +function fillAroundBga( + graphics: Graphics, + x: number, + y: number, + w: number, + h: number, + color: number, + hasBga: boolean, +): void { + const right = x + w; + const bottom = y + h; + if (!hasBga) { + graphics.rect(x, y, w, h).fill(color); + return; + } + const holeRight = BGA.x + BGA.w; + const holeBottom = BGA.y + BGA.h; + graphics.rect(x, y, w, BGA.y - y).fill(color); + graphics.rect(x, BGA.y, BGA.x - x, BGA.h).fill(color); + graphics.rect(holeRight, BGA.y, right - holeRight, BGA.h).fill(color); + graphics.rect(x, holeBottom, w, bottom - holeBottom).fill(color); +} + +/** Print crop mark: an L of hairlines just outside a corner. */ +function cropMark(graphics: Graphics, x: number, y: number, dx: 1 | -1, dy: 1 | -1): void { + graphics + .moveTo(x - dx * 4, y) + .lineTo(x - dx * 14, y) + .moveTo(x, y - dy * 4) + .lineTo(x, y - dy * 14); +} + +function drawPlayfieldFrame(graphics: Graphics, left: number, right: number, progressRatio: number | undefined): void { + graphics.rect(left - 1, HEADER_H, right - left + 2, 340 - HEADER_H).stroke({ color: LAT_INK, width: 1, alpha: 0.9 }); + cropMark(graphics, left - 1, 340, 1, -1); + cropMark(graphics, right + 1, 340, -1, -1); + graphics.stroke({ color: LAT_INK, width: 1 }); + // Progress: a ruler down the left edge with an accent cursor. + const ratio = + progressRatio !== undefined && Number.isFinite(progressRatio) ? Math.max(0, Math.min(1, progressRatio)) : 0; + const trackTop = HEADER_H + 6; + const trackH = 340 - trackTop - 6; + for (let step = 0; step <= 20; step += 1) { + const y = trackTop + (trackH * step) / 20; + graphics.moveTo(left - 10, y).lineTo(left - (step % 5 === 0 ? 16 : 13), y); + } + graphics.stroke({ color: LAT_INK, width: 1, alpha: 0.45 }); + const cursorY = trackTop + trackH * (1 - ratio); + graphics.rect(left - 17, cursorY - 1, 9, 2).fill(LAT_ACCENT); +} + +/** + * Monitor: a hairline frame with ruler ticks along its top and bottom edges and crop marks at the corners. With no BGA + * the screen idles on an 8 × 8 grid of tiles flipping in a diagonal wave on every beat, plus a running clock. + */ +function drawBgaFrame( + graphics: Graphics, + layer: Container, + hasBga: boolean, + seconds: number, + beatPhase: number, + amount: number, + drive: AudioDrive, + pool: ChildPool, +): void { + const margin = 5; + const x = BGA.x - margin; + const y = BGA.y - margin; + const w = BGA.w + margin * 2; + const h = BGA.h + margin * 2; + graphics.rect(x, y, w, h).stroke({ color: LAT_INK, width: 1 }); + cropMark(graphics, x, y, 1, 1); + cropMark(graphics, x + w, y, -1, 1); + cropMark(graphics, x, y + h, 1, -1); + cropMark(graphics, x + w, y + h, -1, -1); + graphics.stroke({ color: LAT_INK, width: 1 }); + drawRulerTicks(graphics, x, y, w, 1, LAT_INK, 0.55); + drawRulerTicks(graphics, x, y + h, w, -1, LAT_INK, 0.55); + if (hasBga) return; + + graphics.rect(BGA.x, BGA.y, BGA.w, BGA.h).fill(LAT_WHITE); + const columns = 16; + const rows = 14; + const pitch = BGA.w / columns; + const tile = pitch - 4; + const top = BGA.y + (BGA.h - 22 - rows * pitch) / 2; + const wave = amount > 0 ? beatPhase * 1.6 : 1; + // While music plays the tiles become an equalizer: each column fills from the bottom to its band's level, the top + // tile squashed by the fraction and printed in cobalt. In silence they fall back to the beat's flip wave. + if (drive.level > 0.02) { + for (let column = 0; column < columns; column += 1) { + const filled = bandLevel(drive.bands, column, columns) * rows; + for (let row = 0; row < rows; row += 1) { + const fromBottom = rows - 1 - row; + const fill = Math.max(0, Math.min(1, filled - fromBottom)); + const cx = BGA.x + pitch * (column + 0.5); + const cy = top + pitch * (row + 0.5); + if (fill <= 0) { + graphics.rect(cx - 1, cy - 1, 2, 2).fill({ color: LAT_RULE, alpha: 1 }); + continue; + } + const th = Math.max(1, tile * fill); + const peak = fill < 1 || fromBottom === Math.ceil(filled) - 1; + graphics.rect(cx - tile / 2, cy + tile / 2 - th, tile, th).fill(peak ? LAT_ACCENT : LAT_INK); + } + } + } + for (let row = 0; row < rows && drive.level <= 0.02; row += 1) { + for (let column = 0; column < columns; column += 1) { + const phase = tileFlipPhase(column, row, columns, rows, wave, 0.6); + // Each tile flips about its horizontal axis: its height follows |cos|, its face changes at the halfway point. + const flip = springEase(phase, 1.6, 0.55); + const squash = Math.abs(Math.cos(flip * Math.PI)); + const face = flip < 0.5 ? LAT_INK : (column + row) % 5 === 0 ? LAT_ACCENT : LAT_INK; + const hollow = flip >= 0.5 && (column * 3 + row) % 4 === 0; + const cx = BGA.x + pitch * (column + 0.5); + const cy = top + pitch * (row + 0.5); + const th = Math.max(0.8, tile * squash); + if (hollow) { + graphics.rect(cx - tile / 2 + 0.5, cy - th / 2, tile - 1, th).stroke({ color: LAT_INK, width: 1 }); + } else { + graphics.rect(cx - tile / 2, cy - th / 2, tile, th).fill({ color: face, alpha: 0.9 }); + } + } + } + graphics.rect(BGA.x, BGA.y + BGA.h - 22, BGA.w, 1).fill({ color: LAT_RULE, alpha: 1 }); + // SOUND ONLY re-decodes every few seconds while the monitor idles. + const idle = amount > 0 ? (seconds % 5) / 0.7 : 1; + addHudText( + layer, + scrambleText('SOUND ONLY', idle, scrambleTick(seconds * 1000), 60), + BGA.x + 8, + BGA.y + BGA.h - 16, + monoStyle(LAT_INK), + pool, + ); + const minutes = Math.floor(seconds / 60); + const secs = seconds - minutes * 60; + addHudNumber( + layer, + `${String(minutes).padStart(2, '0')}:${secs.toFixed(2).padStart(5, '0')}`, + BGA.x + BGA.w - 8, + BGA.y + BGA.h - 17, + { ...monoStyle(LAT_INK), anchorX: 1 }, + pool, + ); +} + +/** Header: mode and tempo set as type, a beat clock whose hand ticks round once per beat, and the ruleset chip. */ +function drawHeader(graphics: Graphics, layer: Container, runtime: Runtime, beatPhase: number, pool: ChildPool): void { + graphics.rect(0, 0, DESIGN_WIDTH, HEADER_H).fill({ color: LAT_PAPER, alpha: 0.96 }); + graphics.rect(0, HEADER_H - 1, DESIGN_WIDTH, 1).fill(LAT_INK); + const autoplay = runtime.autoplay === true; + // Beat clock, its left edge on the 16 px page margin. + const cx = 24; + const cy = 15; + graphics.circle(cx, cy, 8).stroke({ color: LAT_INK, width: 1 }); + for (let tick = 0; tick < 12; tick += 1) { + const angle = (tick / 12) * Math.PI * 2; + const inner = tick % 3 === 0 ? 5 : 6.5; + graphics.moveTo(cx + Math.cos(angle) * inner, cy + Math.sin(angle) * inner); + graphics.lineTo(cx + Math.cos(angle) * 8, cy + Math.sin(angle) * 8); + } + graphics.stroke({ color: LAT_INK, width: 1, alpha: 0.6 }); + const hand = -Math.PI / 2 + springEase(Math.min(1, beatPhase * 3), 2.4, 0.5) * Math.PI * 2; + graphics + .moveTo(cx, cy) + .lineTo(cx + Math.cos(hand) * 7, cy + Math.sin(hand) * 7) + .stroke({ color: autoplay ? LAT_ACCENT : LAT_SIGNAL, width: 1.5 }); + graphics.circle(cx, cy, 1.5).fill(LAT_INK); + + const sc = hudScramble(runtime); + addHudText( + layer, + sc(autoplay ? 'AUTO PLAY' : 'PLAY', 0, 1), + 40, + 10, + { ...monoStyle(LAT_INK), letterSpacing: 1.5 }, + pool, + ); + addHudText(layer, sc('BPM', 60, 2), 128, 11, monoStyle(), pool); + addHudNumber(layer, formatBpm(runtime.bpm), 152, 7, displayStyle(15, LAT_INK, '300'), pool); + addHudText(layer, sc('SPEED', 120, 3), 214, 11, monoStyle(), pool); + addHudNumber(layer, `×${formatHiSpeed(runtime.hiSpeed)}`, 250, 7, displayStyle(15, LAT_INK, '300'), pool); + // Nervous readouts: chart time to the millisecond, the beat fraction, and a 16-step beat meter. + const chartMs = runtime.chartMs; + addHudText(layer, sc('T', 200, 9), 326, 11, monoStyle(), pool); + addHudNumber( + layer, + chartMs !== undefined && Number.isFinite(chartMs) ? formatChartTime(chartMs) : '+000.000', + 338, + 11, + { ...monoStyle(LAT_INK) }, + pool, + ); + addHudText(layer, sc('BEAT', 240, 10), 416, 11, monoStyle(), pool); + addHudNumber(layer, beatPhase.toFixed(3).slice(1), 444, 11, { ...monoStyle(LAT_INK) }, pool); + const lit = Math.floor(beatPhase * 16); + for (let step = 0; step < 16; step += 1) { + const x = 486 + step * 4; + if (step <= lit) graphics.rect(x, 11, 2, 8).fill(step === lit ? LAT_ACCENT : LAT_INK); + else graphics.rect(x, 17, 2, 2).fill({ color: LAT_RULE, alpha: 1 }); + } + addHudText( + layer, + sc(formatRuleset(runtime.rulesetLabel), 180, 4), + HUD_RIGHT, + 11, + { ...monoStyle(LAT_INK), letterSpacing: 1.5, anchorX: 1 }, + pool, + ); +} + +/** Gauge as a ruler: 50 ticks, ink below the clear line and cobalt past it, a triangle marking the line. */ +function drawGauge( + graphics: Graphics, + layer: Container, + runtime: Runtime, + moments: MomentState, + pool: ChildPool, +): void { + const gauge = clampPercent(runtime.gauge ?? 0); + const clear = clampPercent(runtime.clearThreshold ?? 80); + const survival = runtime.gaugeSurvival === true || clear <= 0; + const nowMs = runtime.nowMs ?? 0; + const sc = hudScramble(runtime); + graphics.rect(HUD_LEFT, GROOVE.y - 24, GROOVE.w + 24, 1).fill(LAT_INK); + addHudText( + layer, + sc(`${(runtime.gaugeLabel ?? 'GROOVE').toUpperCase()} GAUGE`, 240, 5), + HUD_LEFT, + GROOVE.y - 18, + { ...monoStyle(), maxWidth: 130 }, + pool, + ); + addHudNumber( + layer, + `${Math.round(gauge)}%`, + GROOVE.x + GROOVE.w + 12, + GROOVE.y - 22, + { ...displayStyle(14, LAT_INK, '300'), anchorX: 1 }, + pool, + ); + const cells = 50; + const stride = GROOVE.w / cells; + const lit = Math.round((gauge / 100) * cells); + const clearCell = Math.round((clear / 100) * cells); + // Crossing the clear line flips every tick in a wave. + const clearT = momentProgress(moments.clearAtMs, nowMs, 900); + for (let cell = 0; cell < cells; cell += 1) { + const x = GROOVE.x + cell * stride; + const flip = clearT === undefined ? 1 : Math.abs(Math.cos(tileFlipPhase(cell, 0, cells, 1, clearT, 0.6) * Math.PI)); + if (cell >= lit) { + graphics.rect(x, GROOVE.y + GROOVE.h - 4, Math.max(1, stride - 1.5), 4).fill({ color: LAT_RULE, alpha: 1 }); + continue; + } + const color = survival ? LAT_SIGNAL : cell >= clearCell ? LAT_ACCENT : LAT_INK; + const h = GROOVE.h * Math.max(0.1, flip); + graphics.rect(x, GROOVE.y + (GROOVE.h - h) / 2, Math.max(1, stride - 1.5), h).fill(color); + } + if (!survival) { + const x = GROOVE.x + clearCell * stride; + graphics + .poly([x - 3.5, GROOVE.y + GROOVE.h + 7, x + 3.5, GROOVE.y + GROOVE.h + 7, x, GROOVE.y + GROOVE.h + 2]) + .fill(LAT_INK); + } + drawRulerTicks(graphics, GROOVE.x, GROOVE.y + GROOVE.h + 10, GROOVE.w, 1, LAT_INK, 0.35); +} + +function drawSongPlate(graphics: Graphics, layer: Container, runtime: Runtime, pool: ChildPool): void { + const { x, y, w } = SONG_PLATE; + const sc = hudScramble(runtime); + graphics.rect(x, y, w, 1).fill(LAT_INK); + addHudText(layer, sc('TRACK', 300, 6), x, y + 6, monoStyle(), pool); + addHudText( + layer, + sc(runtime.songTitle?.trim() || 'Untitled chart', 360, 7), + x + 48, + y + 4, + { size: 15, weight: '500', fill: LAT_INK, fontFamily: LAT_TEXT_FONT, maxWidth: w - 48 }, + pool, + ); + const artist = runtime.songArtist?.trim(); + if (artist) { + addHudText( + layer, + sc(artist, 460, 8), + x + 48, + y + 26, + { size: 10, weight: '300', fill: LAT_GRAPHITE, fontFamily: LAT_TEXT_FONT, maxWidth: w - 48 }, + pool, + ); + } +} + +/** Score block: labelled figures on a two-column grid split by hairlines; the EX rate meter is a ruler. */ +function drawScorePanel(graphics: Graphics, layer: Container, runtime: Runtime, pool: ChildPool): void { + const { x, y, w, h } = SCORE_PANEL; + const sc = hudScramble(runtime); + graphics.rect(x, y, w, 1).fill(LAT_INK); + graphics.rect(x + 146, y + 10, 1, h - 16).fill({ color: LAT_RULE, alpha: 1 }); + graphics.rect(x, y + h, w, 1).fill({ color: LAT_RULE, alpha: 1 }); + addHudText(layer, sc('01 SCORE', 520, 20), x, y + 8, monoStyle(), pool); + addHudNumber( + layer, + formatCount(runtime.score), + x + 136, + y + 18, + { ...displayStyle(24, LAT_INK, '200'), anchorX: 1, maxWidth: 130 }, + pool, + ); + addHudText(layer, sc('02 EX', 580, 21), x, y + 56, monoStyle(), pool); + addHudNumber( + layer, + `${formatCount(runtime.exScore)}/${formatCount(runtime.exScoreMax)}`, + x + 136, + y + 54, + { ...displayStyle(10, LAT_INK, '400'), anchorX: 1, maxWidth: 80 }, + pool, + ); + addHudText(layer, sc('03 RATE', 640, 22), x, y + 74, monoStyle(), pool); + addHudNumber( + layer, + formatExRate(runtime.exScore, runtime.exScoreMax), + x + 136, + y + 72, + { ...displayStyle(10, LAT_INK, '400'), anchorX: 1, maxWidth: 80 }, + pool, + ); + const rate = + runtime.exScore !== undefined && runtime.exScoreMax !== undefined && runtime.exScoreMax > 0 + ? Math.max(0, Math.min(1, runtime.exScore / runtime.exScoreMax)) + : 0; + const meterW = 136; + graphics.rect(x, y + 94, meterW, 1).fill({ color: LAT_RULE, alpha: 1 }); + if (rate > 0) graphics.rect(x, y + 93, meterW * rate, 3).fill(rate >= 8 / 9 ? LAT_ACCENT : LAT_INK); + for (let ninth = 0; ninth <= 9; ninth += 1) { + graphics.moveTo(x + (meterW * ninth) / 9, y + 97).lineTo(x + (meterW * ninth) / 9, y + (ninth >= 6 ? 102 : 100)); + } + graphics.stroke({ color: LAT_INK, width: 1, alpha: 0.5 }); + addHudText(layer, sc('04 COMBO', 700, 23), x + 156, y + 8, monoStyle(), pool); + addHudNumber( + layer, + formatCount(runtime.combo), + x + w, + y + 20, + { ...displayStyle(16, LAT_INK, '300'), anchorX: 1, maxWidth: 70 }, + pool, + ); + addHudText(layer, sc('05 MAX', 760, 24), x + 156, y + 56, monoStyle(), pool); + addHudNumber( + layer, + formatCount(runtime.maxCombo), + x + w, + y + 54, + { ...displayStyle(10, LAT_INK, '400'), anchorX: 1, maxWidth: 60 }, + pool, + ); + const rank = runtime.rank && runtime.rank !== '-' ? runtime.rank : 'F'; + addHudText(layer, sc('06 RANK', 820, 25), x + 156, y + 74, monoStyle(), pool); + addHudText( + layer, + rank, + x + w, + y + 70, + { ...displayStyle(rank.length >= 3 ? 16 : 20, rank.startsWith('AA') ? LAT_ACCENT : LAT_INK, '300'), anchorX: 1 }, + pool, + ); +} + +const TALLY: ReadonlyArray = [ + ['PG', 'perfect'], + ['GR', 'great'], + ['GD', 'good'], + ['BD', 'bad'], + ['PR', 'poor'], +]; + +/** Judge tally as a column of type with a proportional hairline bar under each count. */ +function drawJudgeTally( + graphics: Graphics, + layer: Container, + runtime: Runtime, + x: number, + drive: AudioDrive, + pool: ChildPool, +): void { + const y = STAGE_BGA_BAND.top - 5; + const w = HUD_RIGHT - x; + const sc = hudScramble(runtime); + graphics.rect(x, y, w, 1).fill(LAT_INK); + const total = Math.max(1, runtime.totalNotes ?? 0); + for (let row = 0; row < TALLY.length; row += 1) { + const [label, key] = TALLY[row]!; + const rowY = y + 10 + row * 28; + const count = runtime[key] ?? 0; + const color = JUDGE_COLORS[TALLY_NAMES[key]] ?? LAT_INK; + addHudText(layer, sc(label, 300 + row * 60, 40 + row), x, rowY, { ...monoStyle(color) }, pool); + addHudNumber( + layer, + formatCount(count), + x + w, + rowY - 2, + { ...displayStyle(11, LAT_INK, '400'), anchorX: 1, maxWidth: w - 22 }, + pool, + ); + graphics.rect(x, rowY + 15, w, 1).fill({ color: LAT_RULE, alpha: 1 }); + const bar = (w * Math.min(count, total)) / total; + if (bar > 0) graphics.rect(x, rowY + 14, Math.max(1, bar), 2).fill(color); + } + const footerY = y + 10 + TALLY.length * 28 + 4; + for (const [row, label, color, count] of [ + [0, 'FAST', LAT_ACCENT, runtime.fast], + [1, 'SLOW', LAT_SIGNAL, runtime.slow], + ] as const) { + const rowY = footerY + row * 18; + addHudText(layer, sc(label, 620 + row * 60, 50 + row), x, rowY, monoStyle(color), pool); + addHudNumber( + layer, + formatCount(count), + x + w, + rowY - 2, + { ...displayStyle(11, LAT_INK, '400'), anchorX: 1, maxWidth: w - 34 }, + pool, + ); + } + // Live spectrum and level readout under the tally. + const specY = footerY + 64; + const bars = 16; + const barPitch = w / bars; + graphics.rect(x, specY, w, 1).fill(LAT_INK); + for (let bar = 0; bar < bars; bar += 1) { + const value = bandLevel(drive.bands, bar, bars); + const h = Math.round(26 * value); + if (h < 1) continue; + graphics + .rect(x + bar * barPitch, specY - h, Math.max(1, barPitch - 1.5), h) + .fill(value > 0.8 ? LAT_ACCENT : LAT_INK); + } + addHudText(layer, sc('dB', 700, 60), x, specY + 5, monoStyle(), pool); + addHudNumber( + layer, + drive.db <= -95 ? '-inf' : drive.db.toFixed(1), + x + w, + specY + 5, + { ...monoStyle(LAT_INK), anchorX: 1 }, + pool, + ); +} + +const TALLY_NAMES = { perfect: 'PERFECT', great: 'GREAT', good: 'GOOD', bad: 'BAD', poor: 'POOR' } as const; +const JUDGE_COLORS: Record = { + PERFECT: LAT_ACCENT, + GREAT: LAT_INK, + GOOD: LAT_GRAPHITE, + BAD: 0xd9730d, + POOR: LAT_SIGNAL, +}; + +/** Colours the PERFECT judgement's flashing GREAT cycles through — inks that hold up on the paper. */ +const FLASHING_GREAT = [LAT_ACCENT, LAT_SIGNAL, 0x00a37a, 0xb02cff, 0xe0a100] as const; + +/** + * Judgement word dropping in letter by letter on a spring, with the combo as an odometer whose changed wheels roll up. + * PERFECT prints as a GREAT flashing through the inks; a miss lands still. + */ +function drawJudgements( + layer: Container, + runtime: Runtime, + layout: BeMusicGameplayLayout, + amount: number, + pool: ChildPool, +): void { + const nowMs = runtime.nowMs ?? 0; + const age = runtime.judgeAtMs !== undefined ? nowMs - runtime.judgeAtMs : Number.POSITIVE_INFINITY; + for (const display of resolveJudgeDisplays(runtime, layout)) { + const word = judgeDisplayWord(display.judge); + const color = isFlashingGreat(display.judge) + ? flashingGreatColor(nowMs, FLASHING_GREAT) + : (JUDGE_COLORS[display.judge] ?? LAT_INK); + addStaggeredHudText( + layer, + amount > 0 ? scrambleText(word, age / 90, scrambleTick(nowMs, 25), word.length) : word, + display.x, + 228, + { ...displayStyle(14, color, '600'), letterSpacing: 4, anchorX: 0.5, anchorY: 0.5 }, + pool, + (index) => { + const t = amount > 0 ? Math.max(0, (age - index * 9) / 130) : 1; + // A hair of nervous vertical tremor while the letter settles. + const tremor = t < 1 ? (Math.floor(age / 16 + index) % 2 === 0 ? 0.5 : -0.5) : 0; + return { dy: -8 * (1 - springEase(Math.min(1, t), 3, 0.4)) + tremor, alpha: Math.min(1, t * 5) }; + }, + ); + const combo = resolveVisibleCombo(display.judge, display.combo); + if (combo > 0) { + addOdometer( + layer, + combo, + combo - 1, + amount > 0 ? age / 240 : 1, + display.x, + 256, + { + ...displayStyle(30, combo >= 100 ? LAT_ACCENT : LAT_INK, '200'), + anchorX: 0.5, + anchorY: 0.5, + cell: 18, + travel: 14, + }, + pool, + ); + } + } +} + +function resolveJudgeDisplays( + runtime: Runtime, + layout: BeMusicGameplayLayout, +): Array<{ judge: string; combo: number | undefined; x: number }> { + const centerOf = (side: '1P' | '2P'): number | undefined => { + const bounds = layout.playfield.sides[side]; + return bounds ? (bounds.left + bounds.right) / 2 : undefined; + }; + const fallbackX = layout.playfield.centerX; + const sides = runtime.judgeSides?.filter((state) => typeof state.judge === 'string' && state.judge.length > 0); + if (sides?.length) { + return sides.map((state) => ({ judge: state.judge!, combo: state.combo, x: centerOf(state.side) ?? fallbackX })); + } + if (!runtime.lastJudge) return []; + return [{ judge: runtime.lastJudge, combo: runtime.combo, x: centerOf('1P') ?? fallbackX }]; +} + +function resolveVisibleCombo(judge: string, combo: number | undefined): number { + if (judge !== 'PERFECT' && judge !== 'GREAT' && judge !== 'GOOD') return 0; + return combo !== undefined && Number.isFinite(combo) ? Math.max(0, Math.floor(combo)) : 0; +} + +/** `+012.345` / `-001.200` seconds with millisecond digits. */ +function formatChartTime(ms: number): string { + const sign = ms < 0 ? '-' : '+'; + const seconds = Math.min(999.999, Math.abs(ms) / 1000); + return `${sign}${seconds.toFixed(3).padStart(7, '0')}`; +} + +function formatCount(value: number | undefined): string { + if (value === undefined || !Number.isFinite(value)) return '0'; + return String(Math.max(0, Math.floor(value))); +} + +function formatBpm(value: number | undefined): string { + if (value === undefined || !Number.isFinite(value)) return '---'; + return String(Math.round(value)); +} + +function formatHiSpeed(value: number | undefined): string { + if (value === undefined || !Number.isFinite(value)) return '0.0'; + return value.toFixed(1); +} + +function formatRuleset(value: string | undefined): string { + switch (value) { + case 'beatoraja': + return 'BEATORAJA'; + case 'iidx': + return 'IIDX'; + default: + return 'LR2'; + } +} + +function formatExRate(exScore: number | undefined, max: number | undefined): string { + if (exScore === undefined || max === undefined || !Number.isFinite(exScore) || !Number.isFinite(max) || max <= 0) { + return '0.0%'; + } + return `${((exScore / max) * 100).toFixed(1)}%`; +} + +function clampPercent(value: number): number { + if (!Number.isFinite(value)) return 0; + return Math.max(0, Math.min(100, value)); +} diff --git a/packages/player-web/src/skins/lattice/index.ts b/packages/player-web/src/skins/lattice/index.ts new file mode 100644 index 00000000..767aeb66 --- /dev/null +++ b/packages/player-web/src/skins/lattice/index.ts @@ -0,0 +1,50 @@ +import { renderLatticeChrome } from './gameplay-chrome.ts'; +import { + LATTICE_BOMB_DURATION_MS, + renderLatticeBombs, + renderLatticeLanes, + renderLatticeLongNote, + renderLatticeNote, +} from './playfield.ts'; +import { latticeResultSkin } from './result.ts'; +import { latticeSelectSkin } from './select.ts'; +import { BE_MUSIC_SKIN_API_VERSION, wideStage, type BeMusicSkin } from '@be-music/skin-sdk'; +import { definePixiSkin } from '../pixi-kit/index.ts'; + +/** + * Lattice — a skin in the manner of precise, typographic interaction design (the Japanese web / broadcast interaction-design + * school): warm paper and graph-paper rules, ink with one cobalt accent, a field of + * needles that turns like iron filings to the beat and to every key press, odometer counters, letters that drop in on + * springs, and tile-flip transitions. + */ +export const latticeSkin: BeMusicSkin = definePixiSkin({ + apiVersion: BE_MUSIC_SKIN_API_VERSION, + id: 'lattice', + label: 'Lattice', + version: '1.0.0', + author: { name: 'be-music', url: 'https://github.com/nulltask/be-music' }, + description: + 'Precise, typographic paper skin: graph-paper rules, ink with one cobalt accent, and a needle field that turns with the beat.', + homepage: 'https://github.com/nulltask/be-music', + license: 'MIT', + fontLoads: [ + '200 32px "Inter"', + '300 16px "Inter"', + '400 12px "Inter"', + '600 12px "Inter"', + '500 10px "Azeret Mono"', + '300 12px "M PLUS 1p"', + '500 12px "M PLUS 1p"', + ], + stage: wideStage, + gameplay: { + renderChrome: renderLatticeChrome, + renderLanes: renderLatticeLanes, + renderNote: renderLatticeNote, + renderLongNote: renderLatticeLongNote, + renderBombs: renderLatticeBombs, + bombDurationMs: LATTICE_BOMB_DURATION_MS, + }, + select: latticeSelectSkin, + result: latticeResultSkin, +}); diff --git a/packages/player-web/src/skins/lattice/layout.ts b/packages/player-web/src/skins/lattice/layout.ts new file mode 100644 index 00000000..93ee81a1 --- /dev/null +++ b/packages/player-web/src/skins/lattice/layout.ts @@ -0,0 +1,4 @@ +/** Lattice's own HUD geometry on the 16:9 stage. */ + +/** The groove gauge's bar (its rules, ticks, and labels are drawn around it). */ +export const GROOVE = { x: 44, y: 387, w: 200, h: 14 } as const; diff --git a/packages/player-web/src/skins/lattice/moments.ts b/packages/player-web/src/skins/lattice/moments.ts new file mode 100644 index 00000000..27a4f198 --- /dev/null +++ b/packages/player-web/src/skins/lattice/moments.ts @@ -0,0 +1,268 @@ +import type { Container, Graphics } from 'pixi.js'; +import { GROOVE } from './layout.ts'; +import { addStaggeredHudText, displayStyle, monoStyle } from './draw.ts'; +import { countInStep, scrambleText, scrambleTick, springEase, tileFlipPhase } from './field.ts'; +import { LAT_ACCENT, LAT_INK, LAT_PAPER, LAT_WHITE } from './style.ts'; +import { + easeOutCubic, + type BeMusicRect, + effectProfile, + hash01, + type MilestoneArea, + momentProgress, + type MomentState, + resolveMilestoneArea, + type BeMusicGameplayLayout, + type BeMusicGameplayRuntime, + STAGE_HEIGHT as DESIGN_HEIGHT, + STAGE_WIDTH as DESIGN_WIDTH, +} from '@be-music/skin-sdk'; +import { addHudText, type ChildPool } from '../pixi-kit/index.ts'; + +/** Stand-in for a layout without a BGA (a page-wide keyboard field): milestones then play in their area. */ +const NO_MONITOR: BeMusicRect = { x: 0, y: 0, w: 0, h: 0 }; + +export const MILESTONE_MS = 1400; +export const FULL_COMBO_MS = 2600; +const CLEAR_MS = 1000; + +/** + * Lattice showpieces, set as kinetic type on the front layer (the needle field reacts to the same moments in the + * chrome): + * + * - the count-in: 3 / 2 / 1 roll through a thin ring that draws itself round each step, then GO; + * - every 100 combo: the figures drop onto the monitor digit by digit on a spring while the field swings toward them; + * - the clear line: CLEAR slides out of the gauge on a hairline; + * - a full combo: ink tiles flip across the screen in a diagonal wave, FULL COMBO drops in letter by letter in white, + * and the tiles flip back — while the field swirls; + * - a miss or a broken combo draws nothing, so the notes stay readable. + */ +export function drawLatticeMoments( + layer: Container, + runtime: BeMusicGameplayRuntime, + moments: MomentState, + playfieldCenterX: number, + pool: ChildPool, + layout: BeMusicGameplayLayout, +): void { + const nowMs = runtime.nowMs ?? 0; + const effects = effectProfile(runtime.effects); + if (!effects.enabled) return; + const graphics = pool.acquireGraphics(); + graphics.label = 'lattice/moments'; + graphics.blendMode = 'normal'; + + // Misses get no effect at all: the playfield stays still and clear so the next notes read. + if (runtime.chartMs !== undefined) { + drawCountIn(graphics, layer, runtime.chartMs, playfieldCenterX, pool); + } + const milestone = momentProgress(moments.milestone?.atMs, nowMs, MILESTONE_MS); + if (milestone !== undefined && moments.milestone) { + drawMilestone( + graphics, + layer, + moments.milestone.value, + milestone, + runtime.hasBga === true, + resolveMilestoneArea(layout.playfield.right, 346, layout.stage.width), + layout.bga ?? NO_MONITOR, + pool, + ); + } + const clear = momentProgress(moments.clearAtMs, nowMs, CLEAR_MS); + if (clear !== undefined) { + drawClear(graphics, layer, clear, pool); + } + const fullCombo = momentProgress(moments.fullComboAtMs, nowMs, FULL_COMBO_MS); + if (fullCombo !== undefined) { + drawFullCombo(graphics, layer, fullCombo, effects.screenWide, pool); + } +} + +function drawCountIn( + graphics: Graphics, + layer: Container, + chartMs: number, + playfieldCenterX: number, + pool: ChildPool, +): void { + const step = countInStep(chartMs); + if (!step) return; + const cx = playfieldCenterX; + const cy = 190; + const radius = 34; + if (step.label !== 'GO') { + // The ring draws itself round once per step, starting at twelve o'clock. + graphics.circle(cx, cy, radius).stroke({ color: LAT_INK, width: 1, alpha: 0.2 }); + const sweep = easeOutCubic(Math.min(1, step.progress * 1.15)) * Math.PI * 2; + if (sweep > 0.01) { + graphics + .moveTo(cx, cy - radius) + .arc(cx, cy, radius, -Math.PI / 2, -Math.PI / 2 + sweep) + .stroke({ color: LAT_ACCENT, width: 2 }); + } + const drop = springEase(Math.min(1, step.progress * 2.4), 2.2, 0.5); + const digit = addHudText( + layer, + step.label, + cx, + cy - 14 * (1 - drop), + { ...displayStyle(40, LAT_INK, '200'), anchorX: 0.5, anchorY: 0.5 }, + pool, + ); + digit.alpha = Math.min(1, step.progress * 6) * (1 - Math.max(0, (step.progress - 0.85) / 0.15)); + addHudText( + layer, + scrambleText('READY', step.progress * 2.5, scrambleTick(chartMs), 1), + cx, + cy + radius + 12, + { ...monoStyle(LAT_INK), letterSpacing: 3, anchorX: 0.5 }, + pool, + ); + return; + } + // GO: the ring bursts into a square that expands and fades, the word springs up. + const t = step.progress; + const size = radius * 2 * (1 + 1.6 * easeOutCubic(t)); + graphics + .rect(cx - size / 2, cy - size / 2, size, size) + .stroke({ color: LAT_ACCENT, width: 2 * (1 - t), alpha: 1 - t }); + addStaggeredHudText( + layer, + 'GO', + cx, + cy, + { ...displayStyle(40, LAT_INK, '600'), letterSpacing: 6, anchorX: 0.5, anchorY: 0.5 }, + pool, + (index) => { + const local = Math.max(0, t * 3 - index * 0.25); + return { dy: 12 * (1 - springEase(Math.min(1, local), 2.2, 0.45)), alpha: Math.min(1, local * 3) * (1 - t) }; + }, + ); +} + +function drawMilestone( + graphics: Graphics, + layer: Container, + value: number, + t: number, + hasBga: boolean, + area: MilestoneArea, + bga: BeMusicRect, + pool: ChildPool, +): void { + // Over the monitor beside the lanes, or in the band below them when no monitor fits — never over the notes. + const below = area.mode === 'below' || bga.w === 0; + hasBga = hasBga && bga.w > 0; + const compact = hasBga || below; + const cx = below ? area.x + area.w / 2 : bga.x + bga.w / 2; + const cy = below ? area.y + area.h / 2 - 8 : hasBga ? bga.y + 36 : bga.y + bga.h / 2 - 12; + const out = Math.max(0, (t - 0.78) / 0.22); + const size = compact ? 32 : 64; + // A paper label under the figures, so neither the live video nor the monitor's EQ tiles run through the type. + const plateW = (compact ? 170 : 210) * easeOutCubic(Math.min(1, t / 0.2)); + const plateTop = cy - (compact ? 26 : 46); + const plateH = compact ? 62 : 104; + graphics.rect(cx - plateW / 2, plateTop, plateW, plateH).fill({ color: LAT_PAPER, alpha: 0.95 * (1 - out) }); + addStaggeredHudText( + layer, + String(value), + cx, + cy, + { ...displayStyle(size, LAT_ACCENT, '200'), anchorX: 0.5, anchorY: 0.5 }, + pool, + (index) => { + const local = Math.max(0, (t - index * 0.06) / 0.3); + return { + dy: -size * 0.45 * (1 - springEase(Math.min(1, local), 2, 0.45)) - out * 18, + alpha: Math.min(1, local * 3) * (1 - out), + }; + }, + ); + const rule = 120 * easeOutCubic(Math.min(1, Math.max(0, (t - 0.15) / 0.3))); + graphics.rect(cx - rule / 2, cy + size * 0.42, rule, 1).fill({ color: LAT_INK, alpha: 1 - out }); + const label = addHudText( + layer, + scrambleText('COMBO', (t - 0.18) / 0.3, scrambleTick(t * MILESTONE_MS), value), + cx, + cy + size * 0.42 + 6, + { ...monoStyle(LAT_INK), letterSpacing: 4, anchorX: 0.5 }, + pool, + ); + label.alpha = Math.min(1, Math.max(0, (t - 0.2) / 0.15)) * (1 - out); +} + +function drawClear(graphics: Graphics, layer: Container, t: number, pool: ChildPool): void { + const out = Math.max(0, (t - 0.75) / 0.25); + const reach = 64 * easeOutCubic(Math.min(1, t / 0.3)); + const x = GROOVE.x + GROOVE.w + 12; + const y = GROOVE.y - 38; + graphics.rect(x - reach, y + 14, reach, 1).fill({ color: LAT_ACCENT, alpha: 1 - out }); + addStaggeredHudText( + layer, + scrambleText('CLEAR LINE', t / 0.35, scrambleTick(t * CLEAR_MS, 35), 3), + x, + y, + { ...monoStyle(LAT_ACCENT), size: 11, letterSpacing: 3, anchorX: 1 }, + pool, + (index, count) => { + const local = Math.max(0, (t - (count - 1 - index) * 0.04) / 0.25); + return { dx: 10 * (1 - springEase(Math.min(1, local))), alpha: Math.min(1, local * 3) * (1 - out) }; + }, + ); +} + +const FC_COLUMNS = 16; +const FC_ROWS = 12; + +function drawFullCombo(graphics: Graphics, layer: Container, t: number, screenWide: boolean, pool: ChildPool): void { + const cx = DESIGN_WIDTH / 2; + const cy = DESIGN_HEIGHT / 2 - 10; + // Tiles flip in (0 → 0.35), hold, and flip back out (0.72 → 1). + const flipIn = Math.min(1, t / 0.35); + const flipOut = Math.max(0, (t - 0.72) / 0.28); + if (screenWide) { + const tileW = DESIGN_WIDTH / FC_COLUMNS; + const tileH = DESIGN_HEIGHT / FC_ROWS; + for (let row = 0; row < FC_ROWS; row += 1) { + for (let column = 0; column < FC_COLUMNS; column += 1) { + const inPhase = tileFlipPhase(column, row, FC_COLUMNS, FC_ROWS, flipIn, 0.55); + const outPhase = tileFlipPhase(column, row, FC_COLUMNS, FC_ROWS, flipOut, 0.55); + const cover = inPhase * (1 - outPhase); + if (cover <= 0) continue; + const h = tileH * springEase(cover, 1.8, 0.6); + const color = hash01(column * 31 + row * 17) > 0.95 ? LAT_ACCENT : LAT_INK; + graphics.rect(column * tileW, row * tileH + (tileH - h) / 2, tileW + 0.5, h).fill(color); + } + } + } else { + // Reduced: a single ink band behind the words. + const band = 90 * easeOutCubic(flipIn) * (1 - flipOut); + graphics.rect(0, cy - band / 2, DESIGN_WIDTH, band).fill(LAT_INK); + } + const textIn = Math.max(0, (t - 0.18) / 0.3); + addStaggeredHudText( + layer, + scrambleText('FULL COMBO', textIn * 1.3, scrambleTick(t * FULL_COMBO_MS, 40), 7), + cx, + cy, + { ...displayStyle(40, LAT_WHITE, '200'), letterSpacing: 10, anchorX: 0.5, anchorY: 0.5 }, + pool, + (index) => { + const local = Math.max(0, textIn - index * 0.07); + return { + dy: -26 * (1 - springEase(Math.min(1, local), 2, 0.42)), + alpha: Math.min(1, local * 3) * (1 - flipOut), + }; + }, + ); + const label = addHudText( + layer, + scrambleText('ALL NOTES CONNECTED', (t - 0.42) / 0.25, scrambleTick(t * FULL_COMBO_MS), 8), + cx, + cy + 34, + { ...monoStyle(LAT_WHITE), letterSpacing: 3, anchorX: 0.5 }, + pool, + ); + label.alpha = Math.min(1, Math.max(0, (t - 0.45) / 0.1)) * (1 - flipOut); +} diff --git a/packages/player-web/src/skins/lattice/physics.test.ts b/packages/player-web/src/skins/lattice/physics.test.ts new file mode 100644 index 00000000..3242fc6d --- /dev/null +++ b/packages/player-web/src/skins/lattice/physics.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vite-plus/test'; +import { bounceHeight, createChain, kickChain, stepChain } from './physics.ts'; + +describe('chain', () => { + const options = { anchorX: 100, anchorY: 0, gravity: 900 }; + + it('hangs straight down at rest and keeps its link lengths within 2 %', () => { + const chain = createChain(100, 0, 8, 20); + for (let frame = 0; frame < 120; frame += 1) stepChain(chain, 1 / 60, options); + for (let index = 0; index < chain.count; index += 1) { + expect(chain.x[index]!).toBeCloseTo(100, 3); + } + for (let index = 1; index < chain.count; index += 1) { + const link = Math.hypot(chain.x[index]! - chain.x[index - 1]!, chain.y[index]! - chain.y[index - 1]!); + expect(Math.abs(link - 20)).toBeLessThan(0.4); + } + }); + + it('swings when kicked and settles back under damping', () => { + const chain = createChain(100, 0, 6, 20); + kickChain(chain, 5, 400, 0); + let widest = 0; + for (let frame = 0; frame < 60; frame += 1) { + stepChain(chain, 1 / 60, options); + widest = Math.max(widest, Math.abs(chain.x[5]! - 100)); + } + expect(widest).toBeGreaterThan(10); + for (let frame = 0; frame < 1800; frame += 1) stepChain(chain, 1 / 60, { ...options, damping: 0.97 }); + expect(Math.abs(chain.x[5]! - 100)).toBeLessThan(1); + }); + + it('keeps the top bead pinned to a moving anchor', () => { + const chain = createChain(0, 0, 4, 10); + stepChain(chain, 1 / 60, { ...options, anchorX: 30, anchorY: 5 }); + expect(chain.x[0]).toBe(30); + expect(chain.y[0]).toBe(5); + }); + + it('ignores zero and invalid time steps', () => { + const chain = createChain(0, 0, 4, 10); + const before = chain.y.slice(); + stepChain(chain, 0, options); + stepChain(chain, Number.NaN, options); + expect(chain.y).toEqual(before); + }); +}); + +describe('bounceHeight', () => { + it('drops from its height to the floor', () => { + expect(bounceHeight(0, 100, 1000, 0.5)).toBe(100); + const fall = Math.sqrt(0.2); + expect(bounceHeight(fall, 100, 1000, 0.5)).toBeCloseTo(0, 6); + }); + + it('bounces lower each time and settles', () => { + const fall = Math.sqrt(0.2); + const speed = 1000 * fall * 0.5; + const firstPeak = bounceHeight(fall + speed / 1000, 100, 1000, 0.5); + expect(firstPeak).toBeCloseTo(25, 4); + expect(bounceHeight(10, 100, 1000, 0.5)).toBe(0); + }); + + it('never goes below the floor', () => { + for (let t = 0; t < 3; t += 0.013) { + expect(bounceHeight(t, 60, 1200, 0.6)).toBeGreaterThanOrEqual(0); + } + }); +}); diff --git a/packages/player-web/src/skins/lattice/physics.ts b/packages/player-web/src/skins/lattice/physics.ts new file mode 100644 index 00000000..fcaba4b2 --- /dev/null +++ b/packages/player-web/src/skins/lattice/physics.ts @@ -0,0 +1,101 @@ +/** + * Tiny physics for the Lattice skin: a Verlet chain (beads joined by rigid links, pinned at the top — a pendulum or a + * hanging string that can be kicked), and an analytic bouncing drop with restitution. The chain is stepped in place; + * the bounce is a pure function of time. + */ + +export interface Chain { + count: number; + x: Float32Array; + y: Float32Array; + /** Previous positions (Verlet velocity is `x - px`). */ + px: Float32Array; + py: Float32Array; + /** Rest length of each link. */ + spacing: number; +} + +/** A chain of `count` beads hanging straight down from `(anchorX, anchorY)`, `spacing` apart, at rest. */ +export function createChain(anchorX: number, anchorY: number, count: number, spacing: number): Chain { + const x = new Float32Array(count); + const y = new Float32Array(count); + for (let index = 0; index < count; index += 1) { + x[index] = anchorX; + y[index] = anchorY + index * spacing; + } + return { count, x, y, px: x.slice(), py: y.slice(), spacing }; +} + +export interface ChainStep { + anchorX: number; + anchorY: number; + /** px / s² downward. */ + gravity: number; + /** Fraction of velocity kept per 1/60 s (air drag). */ + damping?: number; + /** Constraint relaxation passes. */ + iterations?: number; +} + +/** Advances the chain by `dt` seconds (clamped to 1/30 s): Verlet integration, then link constraints; bead 0 pinned. */ +export function stepChain(chain: Chain, dt: number, options: ChainStep): void { + const step = Math.max(0, Math.min(1 / 30, Number.isFinite(dt) ? dt : 0)); + if (step === 0) return; + const keep = (options.damping ?? 0.995) ** (step * 60); + const { x, y, px, py, count, spacing } = chain; + for (let index = 1; index < count; index += 1) { + const vx = (x[index]! - px[index]!) * keep; + const vy = (y[index]! - py[index]!) * keep; + px[index] = x[index]!; + py[index] = y[index]!; + x[index] = x[index]! + vx; + y[index] = y[index]! + vy + options.gravity * step * step; + } + x[0] = options.anchorX; + y[0] = options.anchorY; + px[0] = options.anchorX; + py[0] = options.anchorY; + const iterations = options.iterations ?? 8; + for (let pass = 0; pass < iterations; pass += 1) { + for (let index = 1; index < count; index += 1) { + const dx = x[index]! - x[index - 1]!; + const dy = y[index]! - y[index - 1]!; + const distance = Math.hypot(dx, dy) || 1e-6; + const correction = (distance - spacing) / distance; + // The upper bead is pinned (index 0) or shares the correction. + const upper = index - 1 === 0 ? 0 : 0.5; + const lower = 1 - upper; + x[index - 1] = x[index - 1]! + dx * correction * upper; + y[index - 1] = y[index - 1]! + dy * correction * upper; + x[index] = x[index]! - dx * correction * lower; + y[index] = y[index]! - dy * correction * lower; + } + } +} + +/** Kicks bead `index` with a velocity of (`vx`, `vy`) px/s — e.g. a key press striking the pendulum. */ +export function kickChain(chain: Chain, index: number, vx: number, vy: number, dt = 1 / 60): void { + const bead = Math.max(1, Math.min(chain.count - 1, index)); + chain.px[bead] = chain.px[bead]! - vx * dt; + chain.py[bead] = chain.py[bead]! - vy * dt; +} + +/** + * Height above the floor (≥ 0) of a ball dropped from `height` at `t` seconds, bouncing with `restitution` (0..1) of + * its speed kept each impact, under `gravity` px/s². Settles at 0 once the bounces die out. + */ +export function bounceHeight(t: number, height: number, gravity: number, restitution: number): number { + if (!(t > 0)) return Math.max(0, height); + if (height <= 0 || gravity <= 0) return 0; + const fall = Math.sqrt((2 * height) / gravity); + if (t < fall) return height - 0.5 * gravity * t * t; + let elapsed = t - fall; + let speed = gravity * fall * Math.max(0, Math.min(1, restitution)); + for (let bounce = 0; bounce < 12 && speed > 1; bounce += 1) { + const flight = (2 * speed) / gravity; + if (elapsed < flight) return Math.max(0, speed * elapsed - 0.5 * gravity * elapsed * elapsed); + elapsed -= flight; + speed *= restitution; + } + return 0; +} diff --git a/packages/player-web/src/skins/lattice/playfield.ts b/packages/player-web/src/skins/lattice/playfield.ts new file mode 100644 index 00000000..0529d86c --- /dev/null +++ b/packages/player-web/src/skins/lattice/playfield.ts @@ -0,0 +1,203 @@ +import type { Graphics } from 'pixi.js'; +import { springEase } from './field.ts'; +import { LAT_ACCENT, LAT_INK, LAT_RULE, LAT_SIGNAL } from './style.ts'; +import { + easeOutCubic, + type BeMusicBomb, + type BeMusicLaneKind, + comboTier, + effectProfile, + hash01, + resolveLaneRuns, +} from '@be-music/skin-sdk'; +import { + type PixiBombsContext, + type PixiLanesContext, + type PixiLongNoteContext, + type PixiNoteContext, + keyBeamGradient, +} from '../pixi-kit/index.ts'; + +/** Lane-class colour: white keys print in ink, black keys in cobalt, the scratch in vermilion. */ +const LANE_COLORS: Record = { + white: LAT_INK, + black: LAT_ACCENT, + scratch: LAT_SIGNAL, +}; + +/** Beam inks: the note colours, with the white keys' ink lifted to graphite so its beam doesn't swallow the notes. */ +const BEAM_INKS: Record = { white: 0x55544f, black: LAT_ACCENT, scratch: LAT_SIGNAL }; + +const NOTE_HEIGHT = 6; +const LANE_BED = 0xfbfaf7; +const LANE_BED_DEEP = 0xe6e3dc; +export const LATTICE_BOMB_DURATION_MS = 380; + +/** + * Lattice lanes: bright paper columns split by hairlines, a key beam that prints the lane's colour as a soft wash, an + * ink judgement line with ruler ticks and a beat marker sliding along it, and square key caps that fill on press. + */ +export function renderLatticeLanes({ graphics, lanes, beatPhase, combo, effects }: PixiLanesContext): void { + if (lanes.length === 0) return; + const tier = effectProfile(effects).enabled ? comboTier(combo ?? 0) : 0; + let left = Number.POSITIVE_INFINITY; + let right = 0; + let top = Number.POSITIVE_INFINITY; + let bottom = 0; + for (const lane of lanes) { + left = Math.min(left, lane.x); + right = Math.max(right, lane.x + lane.w); + top = Math.min(top, lane.top); + bottom = Math.max(bottom, lane.bottom); + } + for (const lane of lanes) { + const { x, w } = lane; + const height = Math.max(1, lane.bottom - lane.top); + // White-key lanes are the bright paper; black keys and the scratch sit on the deeper stock. + graphics + .rect(x, lane.top, w, height) + .fill({ color: lane.kind === 'white' ? LANE_BED : LANE_BED_DEEP, alpha: 0.94 }); + // Key beam: the lane colour printed up from the judgement line, dense at the line and thinning out up the paper, so + // a press reads at a glance while the notes still on their way down stay crisp. + if (lane.beam > 0) { + const beamHeight = Math.min(height, 240); + const strength = lane.beam * lane.beam * (3 - 2 * lane.beam); + graphics + .rect(x, lane.bottom - beamHeight, w, beamHeight) + .fill({ fill: keyBeamGradient(BEAM_INKS[lane.kind]), alpha: strength * 0.7 }); + } + // Key cap: an outlined square that fills with the lane colour while held. + const cap = Math.min(10, w - 4); + const capX = x + (w - cap) / 2; + if (lane.beam > 0.6) { + graphics.rect(capX, lane.bottom + 7, cap, cap).fill(LANE_COLORS[lane.kind]); + } else { + graphics.rect(capX + 0.5, lane.bottom + 7.5, cap - 1, cap - 1).stroke({ color: LAT_INK, width: 1, alpha: 0.55 }); + } + } + // Lane hairlines. + for (const lane of lanes) { + graphics.moveTo(lane.x + 0.5, lane.top).lineTo(lane.x + 0.5, lane.bottom); + } + // Close each play side's grid on its right edge (each lane only rules its left hairline). + for (const run of resolveLaneRuns(lanes)) graphics.moveTo(run.right - 0.5, top).lineTo(run.right - 0.5, bottom); + graphics.stroke({ color: LAT_RULE, width: 1, alpha: 0.9 }); + + // Judgement line: ink, with ruler ticks hanging under it; it thickens as the run builds. One per play side, so it + // never crosses the gap between the double-play banks. + const lineWidth = 2 + (tier >= 2 ? 1 : 0); + for (const run of resolveLaneRuns(lanes)) { + graphics.rect(run.left, bottom - lineWidth, run.right - run.left, lineWidth).fill(LAT_INK); + for (let x = run.left; x <= run.right + 1e-6; x += 4) { + const long = Math.round(x - run.left) % 20 === 0; + graphics.moveTo(x, bottom).lineTo(x, bottom + (long ? 5 : 2)); + } + } + graphics.stroke({ color: LAT_INK, width: 1, alpha: 0.5 }); + // Beat marker: a cobalt block that springs from lane to lane along the line, one lane per beat. + const ordered = [...lanes].sort((a, b) => a.x - b.x); + const steps = Math.max(1, ordered.length); + const beat = Math.min(steps - 1, Math.floor(beatPhase * steps)); + const within = beatPhase * steps - beat; + const lane = ordered[beat]; + if (lane) { + const markerX = lane.x + springEase(Math.min(1, within * 2), 2.4, 0.5) * Math.max(0, lane.w - 6); + graphics.rect(markerX, bottom - lineWidth - 3, 6, 3).fill(LAT_ACCENT); + } +} + +export function renderLatticeNote({ graphics, kind, x, w, y }: PixiNoteContext): void { + graphics.rect(x, y - NOTE_HEIGHT, Math.max(3, w), NOTE_HEIGHT).fill(LANE_COLORS[kind]); +} + +/** + * Long note: the shared long-note shape — a translucent beam with a centre filament between the head and tail bars — + * printed in ink: a faint wash of the lane colour and a solid ink filament (white would vanish on the paper). + */ +export function renderLatticeLongNote({ graphics, kind, x, w, top, bottom }: PixiLongNoteContext): void { + const color = LANE_COLORS[kind]; + const bodyX = x; + const bodyW = Math.max(3, w); + const bodyTop = top - NOTE_HEIGHT; + const bodyBottom = bottom - NOTE_HEIGHT; + if (bodyBottom > bodyTop) { + graphics.rect(bodyX + 1, bodyTop, bodyW - 2, bodyBottom - bodyTop).fill({ color, alpha: 0.16 }); + graphics.rect(bodyX + bodyW / 2 - 1, bodyTop, 2, bodyBottom - bodyTop).fill({ color, alpha: 0.9 }); + } + renderLatticeNote({ graphics, kind, x, w, y: bottom, nowMs: 0 }); + renderLatticeNote({ graphics, kind, x, w, y: top, nowMs: 0 }); +} + +/** + * The Lattice hit — a precise, mechanical flourish rather than light: a hairline ring snaps open, a cross springs a + * quarter turn, small squares shoot out along the diagonals (more sets as the combo tier rises), and a hairline + * retracts down the lane. At tier 3+ a dashed orbit spins around the hit. + */ +export function renderLatticeBombs({ pool, bombs, combo, effects }: PixiBombsContext): void { + const profile = effectProfile(effects); + const tier = profile.enabled ? Math.min(comboTier(combo ?? 0), profile.screenWide ? 4 : 2) : 0; + if (bombs.length === 0) return; + const graphics = pool.acquireGraphics(); + graphics.label = 'lattice-bombs'; + graphics.blendMode = 'normal'; + for (const bomb of bombs) { + renderBomb(graphics, bomb, tier, profile.enabled, profile.amount); + } +} + +function renderBomb(graphics: Graphics, bomb: BeMusicBomb, tier: number, enabled: boolean, amount: number): void { + const t = Math.max(0, Math.min(1, bomb.elapsedMs / LATTICE_BOMB_DURATION_MS)); + if (t >= 1) return; + const color = LANE_COLORS[bomb.kind]; + const cx = bomb.x + bomb.w / 2; + const cy = bomb.y - 3; + const unit = Math.max(12, bomb.w); + const open = easeOutCubic(t); + const fade = 1 - t; + // Ring. + graphics.circle(cx, cy, unit * (0.3 + 0.75 * open)).stroke({ color, width: 0.6 + 1.4 * fade, alpha: fade }); + if (!enabled) return; + // Cross springing a quarter turn. + const turn = springEase(Math.min(1, t * 1.8), 2, 0.45) * (Math.PI / 2); + const arm = unit * 0.42 * (1 - 0.5 * t); + for (const base of [0, Math.PI / 2]) { + const angle = base + turn + Math.PI / 4; + const dx = Math.cos(angle) * arm; + const dy = Math.sin(angle) * arm; + graphics.moveTo(cx - dx, cy - dy).lineTo(cx + dx, cy + dy); + } + graphics.stroke({ color: LAT_INK, width: 1.25, alpha: fade }); + // Squares along the diagonals — one set of four, plus a set per combo tier at an offset angle. + const sets = Math.max(1, Math.round((2 + tier) * amount)); + for (let set = 0; set < sets; set += 1) { + const offset = (set * Math.PI) / (4 * sets) + (hash01(bomb.seed + set) - 0.5) * 0.2; + const reach = unit * (0.4 + (1 + 0.2 * set) * open); + const size = Math.max(1, (2.2 - 0.2 * set) * fade); + for (let arm4 = 0; arm4 < 4; arm4 += 1) { + const angle = Math.PI / 4 + (arm4 * Math.PI) / 2 + offset; + // Snap to whole pixels: the motion reads as stepped, mechanical. + // Snapped to whole pixels with a one-pixel twitch: the motion reads as stepped, mechanical, nervous. + const twitch = (hash01(bomb.seed * 7 + set * 13 + arm4 + Math.floor(bomb.elapsedMs / 33)) - 0.5) * 2; + const px = Math.round(cx + Math.cos(angle) * reach + twitch); + const py = Math.round(cy + Math.sin(angle) * reach - twitch); + graphics + .rect(px - size / 2, py - size / 2, size, size) + .fill({ color: set % 2 === 0 ? color : LAT_INK, alpha: fade }); + } + } + // Hairline retracting down the lane. + const beam = unit * 7 * (1 - open); + if (beam > 1) { + graphics.rect(cx - 0.5, cy - beam, 1, beam).fill({ color, alpha: 0.8 * fade }); + } + if (tier >= 3) { + const radius = unit * 1.3; + const spin = t * Math.PI * (bomb.seed % 2 === 0 ? 1 : -1); + for (let dash = 0; dash < 12; dash += 1) { + const a0 = spin + (dash * Math.PI * 2) / 12; + const r = radius * (0.8 + 0.2 * open); + graphics.moveTo(cx + Math.cos(a0) * r, cy + Math.sin(a0) * r).arc(cx, cy, r, a0, a0 + 0.22); + } + graphics.stroke({ color, width: 1, alpha: 0.7 * fade }); + } +} diff --git a/packages/player-web/src/skins/lattice/result.ts b/packages/player-web/src/skins/lattice/result.ts new file mode 100644 index 00000000..5ed2d068 --- /dev/null +++ b/packages/player-web/src/skins/lattice/result.ts @@ -0,0 +1,332 @@ +import { type Container, Graphics } from 'pixi.js'; +import { addStaggeredSkinText, drawNeedleField, drawPaperGrid, drawRulerTicks } from './draw.ts'; +import { scrambleText, scrambleTick, springEase } from './field.ts'; +import { bounceHeight } from './physics.ts'; +import { + LAT_ACCENT, + LAT_DISPLAY_FONT, + LAT_GRAPHITE, + LAT_INK, + LAT_MONO_FONT, + LAT_PAPER, + LAT_RULE, + LAT_SIGNAL, + LAT_TEXT_FONT, +} from './style.ts'; +import { + easeOutCubic, + resolveResultLamp, + resolveResultTrackRows, + rollUpValue, + stageProgress, +} from '@be-music/skin-sdk'; +import { addSkinText, type PixiResultFrame, type PixiResultSkin, type SkinTextOptions } from '../pixi-kit/index.ts'; + +/** How fast the entrance timeline plays: the choreography below is authored at 1x and runs 1.3x, so the card settles + * sooner without changing its order or easing. */ +const ENTRANCE_PACE = 1.3; +/** Stands in for an unbounded edge of a rect the needle field keeps clean. */ +const FAR = 1e6; +const ROLL_DELAY_MS = 600; +const ROLL_MS = 1100; +const RANK_DELAY_MS = 1500; + +export const latticeResultSkin: PixiResultSkin = { render: (frame) => renderLatticeResult(frame) }; + +function mono(fill: number = LAT_GRAPHITE, extra: SkinTextOptions = {}): SkinTextOptions { + return { size: 9, weight: '500', fill, fontFamily: LAT_MONO_FONT, letterSpacing: 0.6, ...extra }; +} + +function display(size: number, fill: number, weight: SkinTextOptions['weight'] = '300'): SkinTextOptions { + return { size, fill, fontFamily: LAT_DISPLAY_FONT, weight }; +} + +/** + * Lattice result: a typeset report on graph paper. The verdict sets itself letter by letter, rules draw across the + * page, figures roll up on a tabular grid, the judgement bars and graphs draw in as ruled lines, and the rank drops in + * as huge thin letters on a spring while the needle field behind swings to point at it. + */ +export function renderLatticeResult(frame: PixiResultFrame): void { + const { result, designWidth, designHeight, nowMs, rankLabel, layer, backdrop } = frame; + const elapsed = frame.effects === 'off' ? Number.POSITIVE_INFINITY : frame.elapsedMs * ENTRANCE_PACE; + const motion = frame.effects === 'off' ? 0 : frame.effects === 'reduced' ? 0.5 : 1; + const cleared = result.cleared; + const verdictColor = cleared ? LAT_ACCENT : LAT_SIGNAL; + const exMax = Math.max(0, result.score.total * 2); + const roll = stageProgress(elapsed, ROLL_DELAY_MS, ROLL_MS); + const g = new Graphics(); + g.label = 'lattice-result/paper'; + layer.addChild(g); + const text = (value: string, x: number, y: number, options: SkinTextOptions) => + addSkinText(layer, value, x, y, options); + const rule = (x: number, y: number, w: number, delayMs: number, color = LAT_INK) => { + const grow = easeOutCubic(stageProgress(elapsed, delayMs, 500)); + if (grow > 0) g.rect(x, y, w * grow, 1).fill(color); + }; + const fadeIn = (delayMs: number) => Math.min(1, stageProgress(elapsed, delayMs, 300)); + // Text scramble: every string decodes in as its block arrives. + const tick = scrambleTick(nowMs); + const decode = (value: string, delayMs: number, seed: number, durationMs = 460) => + scrambleText(value, stageProgress(elapsed, delayMs, durationMs), tick, seed); + + // The paper and its grid never change: drawn once into the persistent backdrop. The needle field updates in place + // there as GPU particles, instead of thousands of stroked needles rebuilt every frame. + const ground = resultGround(backdrop, designWidth, designHeight); + const rankCx = 112; + const rankCy = 156; + const rankIn = stageProgress(elapsed, RANK_DELAY_MS, 600); + ground.field.clear(); + drawNeedleField( + ground.field, + { x: 0, y: 44, w: designWidth, h: designHeight - 44 }, + { + seconds: (nowMs / 1000) * motion, + beatPhase: ((nowMs / 1000) * motion * 0.5) % 1, + flow: 0.3 * motion, + beatWave: 0.25 * motion, + attractor: { x: rankCx, y: rankCy, strength: 0.7 * easeOutCubic(rankIn) }, + }, + { + alpha: 0.4, + // The report's body, and a band under the footer line (open-ended to the left, right, and bottom). + clean: [ + { x: 14, y: 56, w: designWidth - 28, h: 374 }, + { x: -FAR, y: designHeight - 50, w: 2 * FAR, h: FAR }, + ], + particles: true, + }, + ); + + // Verdict and title. + rule(0, 43, designWidth, 0); + addStaggeredSkinText( + layer, + decode(cleared ? 'STAGE CLEAR' : 'FAILED', 60, 1, 620), + 20, + 10, + { ...display(20, verdictColor, '200'), letterSpacing: 5 }, + (index) => { + const local = Math.max(0, (elapsed - 80 - index * 40) / 320); + return { dy: -12 * (1 - springEase(Math.min(1, local))), alpha: Math.min(1, local * 3) }; + }, + ); + + // Rank block with crop marks. + const rankBox = { x: 20, y: 66, w: 184, h: 166 }; + g.rect(rankBox.x, rankBox.y, rankBox.w, rankBox.h).fill({ color: LAT_PAPER, alpha: fadeIn(200) }); + rule(rankBox.x, rankBox.y, rankBox.w, 200); + text(decode('01 DJ LEVEL', 250, 3), rankBox.x, rankBox.y + 8, { ...mono(), alpha: fadeIn(250) }); + addStaggeredSkinText( + layer, + rankLabel, + rankCx, + rankCy, + { + ...display(rankLabel.length >= 3 ? 64 : 92, rankLabel.startsWith('AA') ? LAT_ACCENT : LAT_INK, '200'), + anchorX: 0.5, + anchorY: 0.5, + maxWidth: 170, + }, + (index) => { + // Physics: each letter drops from above the box and bounces to rest. + const age = (elapsed - RANK_DELAY_MS - index * 110) / 1000; + return { dy: -bounceHeight(age, 90, 2800, 0.45), alpha: age > 0 ? 1 : 0 }; + }, + ); + const rate = frame.ratePercent * easeOutCubic(roll); + text(`${rate.toFixed(2)}%`, rankBox.x + rankBox.w, rankBox.y + rankBox.h - 18, { + ...display(12, LAT_INK, '400'), + anchorX: 1, + alpha: fadeIn(ROLL_DELAY_MS), + }); + drawRulerTicks(g, rankBox.x, rankBox.y + rankBox.h, rankBox.w, -1, LAT_INK, 0.5 * fadeIn(300)); + + // Figures. + const metrics: ReadonlyArray = [ + [220, 66, '02 SCORE', String(rollUpValue(result.score.score, roll)), LAT_INK], + [220, 122, '03 EX SCORE', `${rollUpValue(result.score.exScore, roll)} / ${exMax}`, LAT_INK], + [220, 178, '04 MAX COMBO', String(rollUpValue(result.maxCombo, roll)), LAT_ACCENT], + [428, 66, '05 GAUGE', `${rollUpValue(Math.round(result.gauge), roll)}%`, cleared ? LAT_ACCENT : LAT_SIGNAL], + [428, 122, '06 PLAY TIME', `${(result.playSeconds * easeOutCubic(roll)).toFixed(1)}s`, LAT_INK], + [428, 178, '07 NOTES', String(rollUpValue(result.score.total, roll)), LAT_INK], + ]; + metrics.forEach(([x, y, name, value, fill], index) => { + const delay = 300 + index * 70; + rule(x, y, 192, delay); + text(decode(name, delay, 10 + index), x, y + 8, { ...mono(), alpha: fadeIn(delay) }); + text(value, x + 192, y + 20, { ...display(22, fill, '200'), anchorX: 1, maxWidth: 150, alpha: fadeIn(delay + 60) }); + }); + + // Judgement bars. + rule(20, 244, 290, 520); + text(decode('08 JUDGEMENT', 520, 4), 20, 252, { ...mono(), alpha: fadeIn(520) }); + const rows: ReadonlyArray = [ + ['PGREAT', result.score.perfect, LAT_ACCENT], + ['GREAT', result.score.great, LAT_INK], + ['GOOD', result.score.good, LAT_GRAPHITE], + ['BAD', result.score.bad, 0xd9730d], + ['POOR', result.score.poor, LAT_SIGNAL], + ]; + const judgeTotal = Math.max(1, result.score.total); + rows.forEach(([name, count, color], index) => { + // Rows on a 32 px pitch, so the last one shares the EX SCORE plot's baseline across the gutter. + const y = 276 + index * 32; + const grow = easeOutCubic(stageProgress(elapsed, ROLL_DELAY_MS + index * 70, ROLL_MS)); + text(decode(name, 560 + index * 50, 20 + index), 20, y, { ...mono(color), alpha: fadeIn(560 + index * 50) }); + g.rect(84, y + 5, 170, 1).fill({ color: LAT_RULE, alpha: 1 }); + const barW = (170 * Math.min(count, judgeTotal) * grow) / judgeTotal; + if (barW > 0) g.rect(84, y + 3, Math.max(1, barW), 5).fill(color); + text(String(rollUpValue(count, roll)), 310, y - 3, { ...display(13, LAT_INK, '400'), anchorX: 1 }); + }); + + // Graphs as ruled lines. + rule(326, 244, 294, 600); + text(decode('09 GROOVE GAUGE', 600, 5), 326, 252, { ...mono(), alpha: fadeIn(600) }); + text(decode('10 EX SCORE', 640, 6), 326, 338, { ...mono(), alpha: fadeIn(640) }); + const draw = easeOutCubic(stageProgress(elapsed, ROLL_DELAY_MS + 150, ROLL_MS + 300)); + plot( + g, + 326, + 266, + 294, + 58, + result.gaugeHistory.map((s) => ({ x: s.progress, y: s.value / 100 })), + cleared ? LAT_ACCENT : LAT_SIGNAL, + draw, + true, + ); + plot( + g, + 326, + 352, + 294, + 58, + result.scoreHistory.map((s) => ({ x: s.progress, y: exMax > 0 ? s.exScore / exMax : 0 })), + LAT_INK, + draw, + false, + ); + + // Track column (16:9): the chart's card and facts as numbered entries, the clear lamp ruled off at the foot. + const trackX = 636; + const trackW = designWidth - 20 - trackX; + rule(trackX, 66, trackW, 680); + text(decode('11 TRACK', 680, 8), trackX, 74, { ...mono(), alpha: fadeIn(680) }); + text(decode(result.song.title || 'Untitled chart', 700, 9, 600), trackX, 92, { + size: 15, + weight: '500', + fill: LAT_INK, + fontFamily: LAT_TEXT_FONT, + maxWidth: trackW, + alpha: fadeIn(700), + }); + text(result.song.artist ?? '', trackX, 114, { + size: 10, + weight: '400', + fill: LAT_GRAPHITE, + fontFamily: LAT_TEXT_FONT, + maxWidth: trackW, + alpha: fadeIn(720), + }); + if (result.song.genre) { + text(result.song.genre.toUpperCase(), trackX, 130, { ...mono(), maxWidth: trackW, alpha: fadeIn(740) }); + } + resolveResultTrackRows(result).forEach((row, index) => { + const y = 178 + index * 44; + const delay = 740 + index * 60; + rule(trackX, y, trackW, delay, LAT_RULE); + text(decode(`${12 + index} ${row.label}`, delay, 30 + index), trackX, y + 8, { ...mono(), alpha: fadeIn(delay) }); + text(row.value, trackX + trackW, y + 18, { + ...display(18, LAT_INK, '300'), + anchorX: 1, + maxWidth: trackW - 20, + alpha: fadeIn(delay + 60), + }); + }); + const lamp = resolveResultLamp(result); + const lampColor = lamp === 'FAILED' ? LAT_SIGNAL : lamp === 'CLEAR' ? LAT_INK : LAT_ACCENT; + const lampY = 378; + rule(trackX, lampY, trackW, 980, lampColor); + g.rect(trackX, lampY + 1, trackW, 31).fill({ color: lampColor, alpha: 0.08 * fadeIn(1000) }); + text(decode(lamp, 1000, 40), trackX + trackW / 2, lampY + 16, { + ...display(16, lampColor, '400'), + anchorX: 0.5, + anchorY: 0.5, + letterSpacing: 4, + maxWidth: trackW - 12, + alpha: fadeIn(1000), + }); + + // Total score. + rule(0, designHeight - 44, designWidth, 440); + text(decode('TOTAL SCORE', 440, 7), 20, designHeight - 27, { ...mono(LAT_INK), alpha: fadeIn(440) }); + text(String(rollUpValue(result.score.score, roll)), 120, designHeight - 22, { + ...display(18, LAT_INK, '300'), + anchorY: 0.5, + letterSpacing: 2, + alpha: fadeIn(460), + }); +} + +/** A graph drawn up to `progress` of its span: stepped (like a plotter) or straight, over a ruler baseline. */ +function plot( + graphics: Graphics, + x: number, + y: number, + w: number, + h: number, + points: ReadonlyArray<{ x: number; y: number }>, + color: number, + progress: number, + stepped: boolean, +): void { + graphics.rect(x, y + h, w, 1).fill(LAT_INK); + drawRulerTicks(graphics, x, y + h + 1, w, 1, LAT_INK, 0.4, 10, 50); + if (points.length === 0 || progress <= 0) return; + const clamp = (value: number) => (Number.isFinite(value) ? Math.max(0, Math.min(1, value)) : 0); + const path: number[] = []; + let previousY: number | undefined; + for (const point of points) { + const px = clamp(point.x); + if (px > progress) break; + const sx = x + px * w; + const sy = y + (1 - clamp(point.y)) * h; + if (stepped && previousY !== undefined) path.push(sx, previousY); + path.push(sx, sy); + previousY = sy; + } + if (path.length < 4) return; + graphics.poly(path, false).stroke({ color, width: 1.5 }); + const headX = path[path.length - 2]!; + const headY = path[path.length - 1]!; + graphics.rect(headX - 2, headY - 2, 4, 4).fill(color); +} + +/** The result's persistent paper (drawn once per size) and the Graphics its needle field is drawn into. */ +interface ResultGround { + width: number; + height: number; + paper: Graphics; + field: Graphics; +} + +const RESULT_GROUNDS = new WeakMap(); + +function resultGround(backdrop: Container, width: number, height: number): ResultGround { + let ground = RESULT_GROUNDS.get(backdrop); + if (!ground) { + ground = { width: 0, height: 0, paper: new Graphics(), field: new Graphics() }; + ground.paper.label = 'lattice-result/paper'; + ground.field.label = 'lattice-result/needles'; + backdrop.addChild(ground.paper, ground.field); + RESULT_GROUNDS.set(backdrop, ground); + } + if (ground.width !== width || ground.height !== height) { + ground.width = width; + ground.height = height; + ground.paper.clear(); + ground.paper.rect(0, 0, width, height).fill(LAT_PAPER); + drawPaperGrid(ground.paper, { x: 0, y: 0, w: width, h: height }, 0.45); + } + return ground; +} diff --git a/packages/player-web/src/skins/lattice/select.ts b/packages/player-web/src/skins/lattice/select.ts new file mode 100644 index 00000000..f077d3c7 --- /dev/null +++ b/packages/player-web/src/skins/lattice/select.ts @@ -0,0 +1,599 @@ +import { Container, Graphics, type Text } from 'pixi.js'; +import { addStaggeredSkinText, drawNeedleField, drawPaperGrid, type Rect } from './draw.ts'; +import { scrambleText, scrambleTick, springEase, tileFlipPhase } from './field.ts'; +import { bounceHeight } from './physics.ts'; +import { + LAT_ACCENT, + LAT_DISPLAY_FONT, + LAT_GRAPHITE, + LAT_INK, + LAT_MONO_FONT, + LAT_PAPER, + LAT_RULE, + LAT_TEXT_FONT, + LAT_WHITE, +} from './style.ts'; +import { + easeOutCubic, + audioDrive, + bandLevel, + type BeMusicAudioFrame, + type BeMusicSelectLayout, + type BrowserBrowseEntry, + type BrowserSongEntry, + formatPlayVariantLabel, + resolveSongRowFacts, + stageProgress, +} from '@be-music/skin-sdk'; +import { + addHitArea, + addSkinText, + type PixiSelectFrame, + type PixiSelectRenderer, + type PixiSelectSkin, + type SkinTextOptions, +} from '../pixi-kit/index.ts'; + +const LAYOUT: BeMusicSelectLayout = { listX: 322, listTop: 56, listBottomInset: 28, rowHeight: 28 }; +/** Fixed widths of a song row's fact columns (right to left: tempo, length, note count), so they align down the list. */ +const ROW_BPM_W = 76; +const ROW_LENGTH_W = 32; +const ROW_NOTES_W = 76; +const CURSOR_MS = 360; +const OUTRO_MS = 650; +const INTRO_STAGGER_MS = 40; +const OUTRO_COLUMNS = 16; +const OUTRO_ROWS = 12; + +export const latticeSelectSkin: PixiSelectSkin = { + outroMs: OUTRO_MS, + layout: LAYOUT, + createRenderer: () => new LatticeSelectRenderer(), +}; + +function mono(fill: number = LAT_GRAPHITE, extra: SkinTextOptions = {}): SkinTextOptions { + return { size: 9, weight: '500', fill, fontFamily: LAT_MONO_FONT, letterSpacing: 0.6, ...extra }; +} + +function display(size: number, fill: number, weight: SkinTextOptions['weight'] = '300'): SkinTextOptions { + return { size, fill, fontFamily: LAT_DISPLAY_FONT, weight }; +} + +/** + * Lattice song select: a typographic table on graph paper. The back layer is a needle field whose strokes all lean + * toward the focused row (and ripple when it changes), with a beat wave at the chart's tempo; the cursor is an ink bar + * that springs from row to row; the focused title sets itself letter by letter. Launching flips the page to ink tile + * by tile. + */ +class LatticeSelectRenderer implements PixiSelectRenderer { + public readonly backLayer = new Container(); + public readonly frontLayer = new Container(); + private readonly field = new Graphics(); + private built = false; + private designWidth = 640; + private designHeight = 480; + private effects: PixiSelectFrame['effects'] = 'full'; + private selected: number | undefined; + private previousSelected: number | undefined; + private cursorChangedAt = Number.NEGATIVE_INFINITY; + /** Where the needles lean: the focused row's leading edge. */ + private target: { x: number; y: number } | undefined; + /** Text-heavy areas the field leaves blank. */ + private occupied: Rect[] = []; + /** Live cursor y (updated while the bar springs) and the readouts that tick every frame. */ + private barY = 0; + private focusedRow = 0; + private clockText: Text | undefined; + private levelText: Text | undefined; + /** Recent onsets of the BGM / preview, each rippling out of the list. */ + private onsets: number[] = []; + private cursorText: Text | undefined; + + public constructor() { + this.backLayer.label = 'lattice-select/paper'; + this.frontLayer.label = 'lattice-select/front'; + } + + public render(input: PixiSelectFrame): boolean { + this.effects = input.effects; + const frame = + input.effects === 'off' + ? { ...input, sceneStartedAt: Number.NEGATIVE_INFINITY, cursorChangedAt: Number.NEGATIVE_INFINITY } + : input; + this.ensureBuilt(frame.designWidth, frame.designHeight); + if (this.selected !== frame.selectedIndex) { + this.previousSelected = this.selected; + this.selected = frame.selectedIndex; + } + this.cursorChangedAt = frame.cursorChangedAt; + let needsFrame = this.renderChrome(frame); + needsFrame = this.renderList(frame) || needsFrame; + if (frame.launchAt !== undefined) { + this.renderOutro(frame); + return true; + } + return needsFrame; + } + + public tick( + nowMs: number, + focusedSong: BrowserSongEntry | undefined, + _launchAt?: number, + audio?: BeMusicAudioFrame, + ): void { + const rate = this.effects === 'off' ? 0 : this.effects === 'reduced' ? 0.5 : 1; + // The BGM / chart preview: needles along the lower page stand up into an equalizer, onsets ripple out of the list, + // and the footer reads the level in dB. + const drive = audioDrive(audio, this.effects); + if (drive.onsetAtMs !== undefined && this.onsets[this.onsets.length - 1] !== drive.onsetAtMs) { + this.onsets.push(drive.onsetAtMs); + if (this.onsets.length > 4) this.onsets.shift(); + } + if (this.levelText) { + this.levelText.text = `dB ${drive.db <= -95 ? '-inf' : drive.db.toFixed(1).padStart(5, ' ')}`; + } + const bpm = focusedSong?.bpm; + const beatsPerSecond = (bpm !== undefined && Number.isFinite(bpm) && bpm > 0 ? Math.min(bpm, 300) : 120) / 60; + const seconds = (nowMs / 1000) * rate; + const cursorAge = nowMs - this.cursorChangedAt; + const target = this.target; + if (this.cursorText) { + this.cursorText.text = `ROW ${String(this.focusedRow + 1).padStart(3, '0')} Y ${this.barY.toFixed(1).padStart(5, '0')}`; + } + if (this.clockText) { + const ms = Math.max(0, nowMs); + const minutes = Math.floor(ms / 60000); + this.clockText.text = `T ${String(minutes % 100).padStart(2, '0')}:${((ms % 60000) / 1000).toFixed(3).padStart(6, '0')}`; + } + this.field.clear(); + drawNeedleField( + this.field, + { x: 0, y: 44, w: this.designWidth, h: this.designHeight - 44 }, + { + seconds, + beatPhase: (seconds * beatsPerSecond) % 1, + flow: 0.3 * rate, + beatWave: 0.4 * rate, + ripples: + rate > 0 + ? [ + ...(target ? [{ x: target.x, y: target.y, ageMs: cursorAge, strength: 0.8 }] : []), + ...this.onsets.map((atMs) => ({ x: 470, y: 250, ageMs: nowMs - atMs, strength: 0.7 * rate })), + ] + : [], + attractor: target ? { x: target.x, y: target.y, strength: 0.55 } : undefined, + tremble: 0.06 * rate + 0.08 * drive.high, + spectrum: { + levels: Array.from({ length: 32 }, (_, column) => bandLevel(drive.bands, column, 32)), + left: 0, + right: this.designWidth, + top: this.designHeight - 190, + bottom: this.designHeight - 8, + }, + }, + { + alpha: 0.5, + clean: this.occupied, + particles: true, + }, + ); + } + + public dispose(): void { + this.backLayer.destroy({ children: true }); + this.frontLayer.destroy({ children: true }); + } + + private ensureBuilt(designWidth: number, designHeight: number): void { + if (this.built) return; + this.built = true; + this.designWidth = designWidth; + this.designHeight = designHeight; + const paper = new Graphics(); + paper.rect(0, 0, designWidth, designHeight).fill(LAT_PAPER); + drawPaperGrid(paper, { x: 0, y: 0, w: designWidth, h: designHeight }, 0.45); + this.backLayer.addChild(paper, this.field); + // Readouts in the footer, updated in `tick` so they run every frame without rebuilding the page. + this.cursorText = addSkinText(this.frontLayer, '', 330, designHeight - 20, mono(LAT_GRAPHITE)); + this.clockText = addSkinText(this.frontLayer, '', 470, designHeight - 20, mono(LAT_GRAPHITE)); + this.levelText = addSkinText(this.frontLayer, '', 226, designHeight - 20, mono(LAT_GRAPHITE)); + } + + private renderChrome(frame: PixiSelectFrame): boolean { + const { layer, designWidth, designHeight, entries, focusedSong: song } = frame; + const chrome = new Graphics(); + chrome.label = 'lattice-select/chrome'; + const text = (value: string, x: number, y: number, options: SkinTextOptions) => + addSkinText(layer, value, x, y, options); + const intro = easeOutCubic(stageProgress(frame.nowMs - frame.sceneStartedAt, 0, 700)); + const tick = scrambleTick(frame.nowMs); + const sceneAge = frame.nowMs - frame.sceneStartedAt; + const titleAge = frame.nowMs - frame.cursorChangedAt; + // Text scramble: every string decodes in on scene entry, the focused chart's details again on each cursor move. + const onEntry = (value: string, delayMs: number, seed: number, durationMs = 420) => + scrambleText(value, stageProgress(sceneAge, delayMs, durationMs), tick, seed); + const onCursor = (value: string, delayMs: number, seed: number, durationMs = 360) => + scrambleText( + value, + Math.min(stageProgress(titleAge, delayMs, durationMs), stageProgress(sceneAge, delayMs, durationMs)), + tick, + seed, + ); + + // Header. + chrome.rect(0, 0, designWidth, 38).fill({ color: LAT_PAPER, alpha: 0.96 }); + chrome.rect(0, 37, designWidth * intro, 1).fill(LAT_INK); + addStaggeredSkinText( + layer, + onEntry('MUSIC SELECT', 0, 1, 600), + 18, + 14, + mono(LAT_INK, { size: 11, letterSpacing: 4 }), + (index) => { + const local = Math.max(0, (frame.nowMs - frame.sceneStartedAt - index * 30) / 300); + return { dy: -8 * (1 - springEase(Math.min(1, local))), alpha: Math.min(1, local * 3) }; + }, + ); + const categoryName = frame.searchQuery ? `Search: ${frame.searchQuery}` : (frame.folderLabel ?? 'Library'); + text(onEntry(categoryName, 200, 2), designWidth - 18, 13, { + size: 10, + weight: '500', + fill: LAT_INK, + fontFamily: LAT_TEXT_FONT, + anchorX: 1, + maxWidth: 220, + }); + + // Focused chart. + const songTitle = song?.title ?? 'No chart selected'; + const songArtist = song?.artist || song?.subtitle || ''; + const position = + entries.length > 0 + ? `No. ${String(Math.min(frame.selectedIndex + 1, entries.length)).padStart(3, '0')} / ${String(entries.length).padStart(3, '0')}` + : 'No. 000 / 000'; + chrome.rect(14, 56, 290, 1).fill(LAT_INK); + text(onEntry('NOW SELECTING', 120, 3), 14, 63, mono()); + text(onCursor(position, 0, 4, 260), 304, 63, mono(LAT_INK, { anchorX: 1 })); + addStaggeredSkinText( + layer, + onCursor(songTitle, 0, 5, 460), + 14, + 84, + { size: 20, weight: '500', fill: LAT_INK, fontFamily: LAT_TEXT_FONT, maxWidth: 290 }, + (index) => { + const local = Math.max(0, (titleAge - index * 14) / 260); + return { dy: 10 * (1 - springEase(Math.min(1, local), 2.2, 0.5)), alpha: Math.min(1, local * 3) }; + }, + ); + if (songArtist) { + text(onCursor(songArtist, 90, 6), 14, 114, { + size: 10, + weight: '300', + fill: LAT_GRAPHITE, + fontFamily: LAT_TEXT_FONT, + maxWidth: 290, + alpha: Math.min(1, Math.max(0, (titleAge - 120) / 200)), + }); + } + + // Stats table. + chrome.rect(14, 140, 290, 1).fill(LAT_INK); + chrome.rect(14, 196, 290, 1).fill({ color: LAT_RULE, alpha: 1 }); + const playLevel = song?.playLevel !== undefined ? String(song.playLevel) : '-'; + const stats: ReadonlyArray = [ + [14, '01 MODE', song ? formatPlayVariantLabel(song) : '- KEYS', LAT_INK], + [114, '02 BPM', song?.bpm !== undefined ? String(Math.round(song.bpm)) : '-', LAT_INK], + [214, '03 LEVEL', playLevel, LAT_ACCENT], + ]; + stats.forEach(([x, label, value, fill], index) => { + if (x > 14) chrome.rect(x - 8, 148, 1, 40).fill({ color: LAT_RULE, alpha: 1 }); + text(onEntry(label, 200 + index * 60, 10 + index), x, 148, mono()); + text(onCursor(value, 60 + index * 60, 20 + index, 300), x, 164, { ...display(20, fill, '300'), maxWidth: 88 }); + }); + // Level as a row of squares. + const levelNumber = + song?.playLevel !== undefined ? Number.parseFloat(String(song.playLevel).replace(/^[^\d.]+/u, '')) : NaN; + const lit = Number.isFinite(levelNumber) ? Math.max(1, Math.min(12, Math.round(levelNumber))) : 0; + for (let dot = 0; dot < 12; dot += 1) { + const x = 14 + dot * 24; + if (dot < lit) { + // Physics: the lit squares drop onto the baseline one after another and bounce. + const drop = bounceHeight((titleAge - dot * 28) / 1000, 34, 2600, 0.42); + chrome.rect(x, 210 - drop, 12, 12).fill(dot >= 9 ? LAT_ACCENT : LAT_INK); + } else { + chrome.rect(x + 0.5, 210.5, 11, 11).stroke({ color: LAT_RULE, width: 1 }); + } + } + chrome.rect(14, 223, 288, 1).fill({ color: LAT_INK, alpha: 0.6 }); + if (song?.fileLabel) { + text(onCursor(song.fileLabel, 220, 7), 14, 234, mono(LAT_GRAPHITE, { maxWidth: 290 })); + } + + // PLAY (ink) / AUTO (outline). + chrome.rect(14, 296, 182, 40).fill(LAT_INK); + text(onEntry('PLAY', 380, 8), 30, 316, { ...display(13, LAT_WHITE, '600'), letterSpacing: 6, anchorY: 0.5 }); + text('→', 180, 315, { ...display(14, LAT_WHITE, '300'), anchorX: 1, anchorY: 0.5 }); + chrome.rect(206.5, 296.5, 97, 39).stroke({ color: LAT_INK, width: 1 }); + text(onEntry('AUTO', 440, 9), 255, 316, { + ...display(10, LAT_INK, '600'), + letterSpacing: 4, + anchorX: 0.5, + anchorY: 0.5, + }); + addHitArea(14, 296, 182, 40, 'pointer', frame.actions.play); + addHitArea(206, 296, 98, 40, 'pointer', frame.actions.autoPlay); + + // Search and library. + text(onEntry('SEARCH', 480, 30), 14, 372, mono()); + text(onEntry(frame.searchQuery || 'Title / artist / genre', 520, 31), 70, 369, { + size: 11, + weight: '300', + fill: frame.searchQuery ? LAT_INK : LAT_GRAPHITE, + fontFamily: LAT_TEXT_FONT, + maxWidth: 230, + }); + chrome.rect(14, 388, 290, 1).fill(LAT_INK); + addHitArea(14, 362, 290, 30, 'text', frame.actions.activateSearch); + text(onEntry('LIBRARY', 560, 32), 14, 420, mono()); + text(onEntry(`${entries.length} shown / ${frame.totalCharts} charts`, 600, 33), 14, 434, { + size: 11, + weight: '500', + fill: LAT_INK, + fontFamily: LAT_TEXT_FONT, + }); + text( + onEntry(frame.searchQuery ? 'SEARCH RESULTS' : frame.folderLabel ? 'CHARTS' : 'FOLDERS', 640, 34), + designWidth - 18, + designHeight - 20, + { + ...mono(), + anchorX: 1, + }, + ); + + layer.addChildAt(chrome, 0); + // The field fills the page but leaves the type blocks (and the rows actually shown) clean. + const shownRows = Math.max(0, Math.min(frame.visibleRows, entries.length - frame.firstVisibleIndex)); + this.occupied = [ + { x: 6, y: 48, w: 306, h: 196 }, + { x: 6, y: 288, w: 306, h: 56 }, + { x: 6, y: 360, w: 306, h: 36 }, + { x: 6, y: 412, w: 200, h: 36 }, + // Footer readouts (dB / row / clock / list label). + { x: 6, y: designHeight - 28, w: designWidth - 12, h: 26 }, + { + x: LAYOUT.listX - 18, + y: LAYOUT.listTop - 6, + w: designWidth - LAYOUT.listX + 18, + h: shownRows * LAYOUT.rowHeight + 6, + }, + ]; + return intro < 1 || titleAge < 1200 || sceneAge < 1400; + } + + private renderList(frame: PixiSelectFrame): boolean { + const { listX, listTop, rowHeight } = LAYOUT; + const listWidth = frame.designWidth - listX - 14; + const rows = new Graphics(); + rows.label = 'lattice-select/rows'; + frame.layer.addChild(rows); + // Cursor bar: springs from the previous row to the focused one. + const cursorAge = frame.nowMs - frame.cursorChangedAt; + const cursorT = springEase(Math.min(1, Math.max(0, cursorAge / CURSOR_MS)), 2.6, 0.42); + const rowY = (index: number) => listTop + (index - frame.firstVisibleIndex) * rowHeight; + const fromIndex = this.previousSelected ?? frame.selectedIndex; + const fromY = Math.max(listTop - rowHeight, Math.min(rowY(fromIndex), listTop + frame.visibleRows * rowHeight)); + const barY = fromY + (rowY(frame.selectedIndex) - fromY) * cursorT; + this.barY = barY; + this.focusedRow = frame.selectedIndex; + const visibleSelected = + frame.selectedIndex >= frame.firstVisibleIndex && + frame.selectedIndex < frame.firstVisibleIndex + frame.visibleRows; + if (visibleSelected && frame.entries.length > 0) { + // The bar stretches while it travels, like a mechanism taking up slack. + const stretch = Math.abs(rowY(frame.selectedIndex) - fromY) * (1 - cursorT) * 0.25; + rows.rect(listX - 10, barY - stretch / 2, listWidth + 10, rowHeight - 4 + stretch).fill(LAT_INK); + this.target = { x: listX - 16, y: rowY(frame.selectedIndex) + (rowHeight - 4) / 2 }; + } else { + this.target = undefined; + } + let animating = cursorAge < CURSOR_MS; + for (let visibleIndex = 0; visibleIndex < frame.visibleRows; visibleIndex += 1) { + const entryIndex = frame.firstVisibleIndex + visibleIndex; + const entry = frame.entries[entryIndex]; + if (!entry) break; + animating = this.renderRow(frame, rows, entry, entryIndex, visibleIndex, listWidth) || animating; + } + return animating; + } + + private renderRow( + frame: PixiSelectFrame, + rows: Graphics, + entry: BrowserBrowseEntry, + entryIndex: number, + visibleIndex: number, + listWidth: number, + ): boolean { + const { listX, listTop, rowHeight } = LAYOUT; + const y = listTop + visibleIndex * rowHeight; + const active = entryIndex === frame.selectedIndex; + const song = entry.kind === 'song' ? entry.song : undefined; + const folder = entry.kind === 'folder' ? entry.folder : undefined; + const introT = stageProgress(frame.nowMs - frame.sceneStartedAt, 100 + visibleIndex * INTRO_STAGGER_MS, 700); + const x = listX; + const alpha = Math.min(1, introT * 4); + // Physics: rows drop into place from above and bounce on their rule. + const drop = bounceHeight( + (frame.nowMs - frame.sceneStartedAt - 100 - visibleIndex * INTRO_STAGGER_MS) / 1000, + 22, + 3000, + 0.38, + ); + const midY = y + (rowHeight - 4) / 2 - drop; + const ink = active ? LAT_WHITE : LAT_INK; + // Rows decode in on entry; the focused row's title decodes again when the cursor lands on it. + const tick = scrambleTick(frame.nowMs); + const rowDelay = 100 + visibleIndex * INTRO_STAGGER_MS; + const rowScramble = stageProgress(frame.nowMs - frame.sceneStartedAt, rowDelay, 460); + const titleScramble = active + ? Math.min(rowScramble, stageProgress(frame.nowMs - frame.cursorChangedAt, 0, 320)) + : rowScramble; + rows.rect(listX - 10, y + rowHeight - 2, (listWidth + 10) * alpha, 1).fill({ color: LAT_RULE, alpha: 1 }); + addSkinText( + frame.layer, + scrambleText(String(entryIndex + 1).padStart(3, '0'), rowScramble, tick, entryIndex), + x, + midY, + { + ...mono(active ? LAT_WHITE : LAT_GRAPHITE), + anchorY: 0.5, + alpha, + }, + ).label = `fallback-index[idx=${entryIndex}]`; + const level = song?.playLevel !== undefined ? String(song.playLevel) : folder ? 'DIR' : '-'; + rows.rect(x + 30.5, midY - 7.5, 22, 15).stroke({ color: active ? LAT_WHITE : LAT_INK, width: 1, alpha }); + addSkinText(frame.layer, level, x + 41.5, midY, { + ...display(level.length > 2 ? 8 : 10, ink, '600'), + anchorX: 0.5, + anchorY: 0.5, + alpha, + maxWidth: 20, + }); + // On the right, the facts a player checks before picking — gimmick tags, note count, length and tempo range — in + // fixed columns so they line up down the list. Folders show their size instead. + const facts = song ? resolveSongRowFacts(song) : undefined; + const meta = facts + ? `${facts.bpm} BPM` + : `${folder?.songs.length ?? 0} chart${folder?.songs.length === 1 ? '' : 's'}`; + const metaNode = addSkinText( + frame.layer, + scrambleText(meta, rowScramble, tick, entryIndex + 500), + listX + listWidth - 10, + midY, + { + ...mono(active ? LAT_WHITE : LAT_GRAPHITE), + anchorX: 1, + anchorY: 0.5, + alpha, + maxWidth: facts ? ROW_BPM_W : 70, + }, + ); + metaNode.label = `fallback-meta[idx=${entryIndex}]`; + let factsLeft = listX + listWidth - 10 - (facts ? ROW_BPM_W : meta ? Math.min(70, metaNode.width) : 0) - 14; + if (facts) { + const mute = active ? LAT_WHITE : LAT_GRAPHITE; + addSkinText(frame.layer, scrambleText(facts.length, rowScramble, tick, entryIndex + 600), factsLeft, midY, { + ...mono(ink), + anchorX: 1, + anchorY: 0.5, + alpha, + maxWidth: ROW_LENGTH_W, + }); + factsLeft -= ROW_LENGTH_W + 14; + addSkinText( + frame.layer, + scrambleText(`${facts.notes} NOTES`, rowScramble, tick, entryIndex + 700), + factsLeft, + midY, + { + ...mono(ink), + anchorX: 1, + anchorY: 0.5, + alpha, + maxWidth: ROW_NOTES_W, + }, + ); + factsLeft -= ROW_NOTES_W + 10; + // Gimmick tags: hairline-boxed mono labels, like the level box on the left. + for (let index = facts.tags.length - 1; index >= 0; index -= 1) { + const tag = facts.tags[index]!; + const chipW = 8 + tag.length * 6; + factsLeft -= chipW; + rows + .rect(factsLeft + 0.5, midY - 6.5, chipW, 13) + .stroke({ color: active ? LAT_WHITE : LAT_INK, width: 1, alpha }); + addSkinText(frame.layer, tag, factsLeft + chipW / 2, midY, { + ...mono(mute), + size: 8, + anchorX: 0.5, + anchorY: 0.5, + alpha, + maxWidth: chipW - 4, + }); + factsLeft -= 5; + } + factsLeft -= 6; + } + const title = addSkinText( + frame.layer, + scrambleText(song?.title ?? folder?.label ?? '', titleScramble, tick, entryIndex + 900), + x + 62, + midY, + { + size: 11, + weight: active ? '500' : '300', + fill: ink, + fontFamily: LAT_TEXT_FONT, + anchorY: 0.5, + alpha, + maxWidth: Math.max(24, factsLeft - x - 62), + }, + ); + title.label = `fallback-title[idx=${entryIndex}]`; + // The artist trails the title in graphite while there is room for it. + const artist = song?.artist?.trim(); + const artistX = x + 62 + title.width + 10; + if (artist && factsLeft - artistX >= 40) { + addSkinText(frame.layer, artist, artistX, midY, { + size: 9, + weight: '300', + fill: active ? LAT_WHITE : LAT_GRAPHITE, + fontFamily: LAT_TEXT_FONT, + anchorY: 0.5, + alpha, + maxWidth: factsLeft - artistX, + }); + } + return introT < 1 || titleScramble < 1; + } + + /** Launch outro: ink tiles flip over the page in a diagonal wave and the chosen title sets itself in white. */ + private renderOutro(frame: PixiSelectFrame): void { + const t = Math.min(1, (frame.nowMs - (frame.launchAt ?? frame.nowMs)) / OUTRO_MS); + const { designWidth, designHeight, layer } = frame; + const g = new Graphics(); + g.label = 'lattice-select/outro'; + const tileW = designWidth / OUTRO_COLUMNS; + const tileH = designHeight / OUTRO_ROWS; + for (let row = 0; row < OUTRO_ROWS; row += 1) { + for (let column = 0; column < OUTRO_COLUMNS; column += 1) { + const phase = tileFlipPhase(column, row, OUTRO_COLUMNS, OUTRO_ROWS, t, 0.5); + if (phase <= 0) continue; + const h = tileH * Math.min(1, springEase(phase, 1.8, 0.6)); + g.rect(column * tileW, row * tileH + (tileH - h) / 2, tileW + 0.5, h).fill(LAT_INK); + } + } + layer.addChild(g); + const textIn = Math.max(0, (t - 0.35) / 0.5); + addStaggeredSkinText( + layer, + scrambleText(frame.focusedSong?.title ?? '', textIn * 1.4, scrambleTick(frame.nowMs), 77), + designWidth / 2, + designHeight / 2, + { + size: 22, + weight: '500', + fill: LAT_WHITE, + fontFamily: LAT_TEXT_FONT, + anchorX: 0.5, + anchorY: 0.5, + maxWidth: designWidth - 80, + }, + (index, count) => { + const local = Math.max(0, textIn * (1 + count * 0.04) - index * 0.04); + return { dy: 12 * (1 - springEase(Math.min(1, local))), alpha: Math.min(1, local * 3) }; + }, + ); + } +} diff --git a/packages/player-web/src/skins/lattice/style.ts b/packages/player-web/src/skins/lattice/style.ts new file mode 100644 index 00000000..0450784d --- /dev/null +++ b/packages/player-web/src/skins/lattice/style.ts @@ -0,0 +1,20 @@ +/** + * Lattice skin tokens — precise, typographic interaction design: warm paper, ink, one cobalt accent and a vermilion + * signal, hairline rules on a 20 px grid, and motion that is mechanical but springy. Inter carries numerals and Latin + * display type (thin for large figures), Azeret Mono the small index labels, M PLUS 1p Japanese titles. + */ +export const LAT_PAPER = 0xf3f2ee; +export const LAT_PAPER_DEEP = 0xe7e5df; +export const LAT_INK = 0x121212; +export const LAT_GRAPHITE = 0x6d6c68; +export const LAT_RULE = 0xcac7bf; +export const LAT_ACCENT = 0x1d4cff; +export const LAT_SIGNAL = 0xff3c1f; +export const LAT_WHITE = 0xffffff; + +export const LAT_DISPLAY_FONT = 'Inter, Helvetica Neue, Helvetica, Arial, M PLUS 1p, sans-serif'; +export const LAT_MONO_FONT = 'Azeret Mono, ui-monospace, Menlo, monospace'; +export const LAT_TEXT_FONT = 'M PLUS 1p, LINE Seed JP, Inter, ui-sans-serif, system-ui, sans-serif'; + +/** Grid pitch (px) every Lattice layout snaps to. */ +export const LAT_GRID = 20; diff --git a/packages/player-web/src/scene/default/gameplay-render.ts b/packages/player-web/src/skins/phantom/chrome.ts similarity index 51% rename from packages/player-web/src/scene/default/gameplay-render.ts rename to packages/player-web/src/skins/phantom/chrome.ts index 1a3f8e3b..f7bbd9d3 100644 --- a/packages/player-web/src/scene/default/gameplay-render.ts +++ b/packages/player-web/src/skins/phantom/chrome.ts @@ -1,12 +1,5 @@ -import { Container, Graphics } from 'pixi.js'; -import type { ChartPlayVariant } from '@be-music/player/core/lane-layout'; -import { BGA, DESIGN_HEIGHT, DESIGN_WIDTH, GROOVE, PLAYFIELD } from '../gameplay-constants.ts'; -import { - resolveFallbackLaneLayout, - shouldPreserveFallbackSideWidth, - type FallbackLaneLayoutRect, -} from '../gameplay-lanes.ts'; -import type { SkinlessGameplayChromeRuntime } from '../gameplay-chrome.ts'; +import { Container, Graphics, GraphicsContext } from 'pixi.js'; +import { GROOVE } from './layout.ts'; import { DEFAULT_DISPLAY_FONT, DEFAULT_HEADLINE_FONT } from './fonts.ts'; import { PHANTOM_ASH, @@ -22,27 +15,64 @@ import { PHANTOM_RED_HOT, PHANTOM_SLATE, PHANTOM_WHITE, + drawNoteEmblem, halftoneField, parallelogramPoints, starburstPoints, -} from './phantom-style.ts'; -import type { ChildPool } from '../pixi-utils.ts'; -import { addHudNumber as addNumber, addHudText as addText, type HudTextOptions as TextOptions } from './hud-text.ts'; +} from './style.ts'; +import { drawPhantomMoments } from './moments.ts'; +import { addRansomText, type GlyphFactory } from './tear.ts'; +import { + audioDrive, + type AudioDrive, + bandLevel, + type BeMusicRect, + effectProfile, + flashingGreatColor, + impulse, + isFlashingGreat, + judgeDisplayWord, + loadingDots, + resolveGameplayLayout, + type BeMusicGameplayRuntime, + type BeMusicGameplayLayout, + type BeMusicLayoutLane, + wideStage, + STAGE_BGA_BAND, + STAGE_HEIGHT as DESIGN_HEIGHT, + STAGE_SIDE_COLUMN, + STAGE_WIDTH as DESIGN_WIDTH, +} from '@be-music/skin-sdk'; +import { + addHudNumber as addNumber, + addHudText as addText, + type HudTextOptions as TextOptions, + type ChildPool, + type PixiChromeContext, +} from '../pixi-kit/index.ts'; const DISPLAY_FONT = DEFAULT_DISPLAY_FONT; /** Italic lean applied to every display-face text node — the whole HUD reads as moving forward. */ const TYPE_SKEW = -0.18; -const SCORE_PANEL = { x: 384, y: 352, w: 232, h: 108 } as const; -const SONG_PLATE = { x: 16, y: 420, w: 340, h: 44 } as const; +/** Page margin of the HUD grid: the left and right blocks line up 16 px in from the canvas edges. */ +const HUD_MARGIN = 16; +/** Score panel: right edge (with its shadow) on the margin, top on the gauge housing's line, bottom on the song plate's. */ +const SCORE_PANEL = { x: DESIGN_WIDTH - HUD_MARGIN - 6 - 272, y: 365, w: 272, h: 99 } as const; +/** Score panel columns: the left one's values end at {@link SCORE_LEFT_END}, the right one starts after the divider. */ +const SCORE_LEFT_END = 148; +const SCORE_DIVIDER = 162; +const SCORE_RIGHT = SCORE_DIVIDER + 14; +/** The track card runs from the left margin to a gutter before the score panel, so long titles keep their room. */ +const SONG_PLATE = { x: 16, y: 420, w: SCORE_PANEL.x - 16 - 40, h: 44 } as const; /** Red floor wedge behind the score panel. Its top edge stays below the BGA rect so a live video is never covered. */ -const FLOOR_WEDGE: readonly number[] = [236, DESIGN_HEIGHT, DESIGN_WIDTH, 326, DESIGN_WIDTH, DESIGN_HEIGHT]; +const FLOOR_WEDGE: readonly number[] = [236, DESIGN_HEIGHT, DESIGN_WIDTH, 352, DESIGN_WIDTH, DESIGN_HEIGHT]; type PlaySide = '1P' | '2P'; /** * Live runtime values painted into the built-in gameplay chrome. */ -export type FallbackGameplayRuntime = SkinlessGameplayChromeRuntime; +export type FallbackGameplayRuntime = BeMusicGameplayRuntime; export interface FallbackGameplayRenderOptions { /** @@ -55,7 +85,8 @@ export interface FallbackGameplayRenderOptions { } interface FallbackPlayfieldLayout { - lanes: FallbackLaneLayoutRect[]; + lanes: readonly BeMusicLayoutLane[]; + top: number; x: number; w: number; centerX: number; @@ -64,33 +95,77 @@ interface FallbackPlayfieldLayout { sideGap?: { x: number; w: number }; } +/** Phantom's `renderChrome`: draws the frame from the layout the host resolved. */ +export function renderPhantomChrome({ + layer, + overlayLayer, + layerPool, + overlayLayerPool, + runtime, + layout, +}: PixiChromeContext): void { + renderChromeFrame(layer, runtime, layout, { overlayLayer, layerPool, overlayLayerPool }); +} + /** - * Built-in gameplay chrome for the default skin family, drawn in the "Phantom" poster style (see `phantom-style.ts`): - * ink-black ground, blood-red slabs and a halftone floor wedge, slanted paper plates, and condensed italic type. It is + * Built-in gameplay chrome for the default skin family, drawn in the "Phantom" poster style (see `style.ts`): ink-black + * ground, blood-red slabs and a halftone floor wedge, slanted paper plates, and condensed italic type. It is * intentionally not an LR2 atlas facsimile: this path is the skinless experience, so it keeps only the information a - * player can act on while playing. + * player can act on while playing. Lays the frame out on the 16:9 stage itself; skins receive the layout from the host + * instead (see {@link renderPhantomChrome}). */ export function renderDefaultGameplayFrame( layer: Container, runtime: FallbackGameplayRuntime = {}, options: FallbackGameplayRenderOptions = {}, +): void { + renderChromeFrame(layer, runtime, resolveGameplayLayout(runtime, wideStage), options); +} + +function renderChromeFrame( + layer: Container, + runtime: FallbackGameplayRuntime, + layout: BeMusicGameplayLayout, + options: FallbackGameplayRenderOptions, ): void { const layerPool = options.layerPool; const frame = layerPool?.acquireGraphics() ?? new Graphics(); - const hasBga = runtime.hasBga === true; - const playfield = resolveFallbackPlayfieldLayout(runtime.laneChannels, runtime.laneCount, runtime.playVariant); + const playfield = playfieldFromLayout(layout); + // The scene composites the BGA into the layout's rect; without one the monitor (and its hole) is left out. + const bga = layout.bga; + const hasBga = runtime.hasBga === true && bga !== undefined; frame.label = 'default-gameplay/chrome'; - drawBackground(frame, hasBga); + // The poster moves with the music: halftone swells on the bass, the idle burst kicks on onsets, a spectrum strip. + const drive = audioDrive(runtime.audio, runtime.effects); + const hole = hasBga ? bga : undefined; + const idleMonitor = bga !== undefined && !hasBga ? bga : undefined; + if (layerPool) { + // The ground, the floor wedge, and the idle monitor's field barely change: they come from cached geometry, keyed by + // the BGA rect and the audio level quantized well below a pixel, instead of being re-tessellated every frame. + showStaticLayers(layer, hole, drive, idleMonitor); + } else { + drawGround(frame.context, hole); + drawWedge(frame.context, quantizeLevel(drive.bass)); + } - drawPlayfield(frame, playfield, runtime.progressRatio); - drawBgaFrame(frame, layer, hasBga, runtime.nowMs, layerPool); + const effects = effectProfile(runtime.effects); + drawPlayfield( + frame, + playfield, + runtime.progressRatio, + impulse(runtime.impulseAtMs, runtime.nowMs ?? 0, 140) * effects.amount, + ); + // The monitor shrinks to fit beside a double-play field; only a page-wide keyboard field leaves no room for it. + if (bga) { + if (!layerPool && idleMonitor) drawMonitorField(frame.context, idleMonitor, quantizeLevel(drive.level)); + drawBgaFrame(frame, layer, bga, hasBga, runtime.nowMs, drive, layerPool); + } drawGauge(frame, layer, runtime, layerPool); drawSongPlate(frame, layer, runtime, layerPool); - drawScorePlate(frame, layer, runtime, layerPool); - const tallyX = resolveJudgeTallyX(playfield); - if (tallyX !== undefined) { - drawJudgeTally(frame, layer, runtime, tallyX, layerPool); - } + drawScorePlate(frame, layer, runtime, drive, layerPool); + // The side column on the right edge: the judge tally on top, the spectrum below it. + const tallyBottom = drawJudgeTally(frame, layer, runtime, STAGE_SIDE_COLUMN.x, layerPool); + drawSpectrumColumn(frame, layer, drive, tallyBottom + 18, layerPool); if (!layerPool) { layer.addChildAt(frame, 0); } @@ -103,7 +178,11 @@ export function renderDefaultGameplayFrame( frontLayer.addChild(front); } drawStatusBar(front, frontLayer, runtime, frontPool); - drawJudgements(frontLayer, runtime, playfield, frontPool); + // Showpieces (count-in, combo milestones, clear line, full combo) sit above the HUD. They draw before the judgements + // because a screen-wide full combo covers the page and the judgement type would print through its strip. + const covered = drawPhantomMoments(layer, frontLayer, runtime, frontPool, layout); + if (runtime.loading) drawLoading(frontLayer, runtime.nowMs ?? 0, playfield, frontPool); + else if (!covered) drawJudgements(frontLayer, runtime, playfield, frontPool, effects.amount); } /** @@ -115,135 +194,206 @@ export function renderDefaultGameplayFrame( */ export const renderFallbackLr2Frame: typeof renderDefaultGameplayFrame = renderDefaultGameplayFrame; +/** Steps the audio-driven halftone radius is quantized to — well under a pixel, so the swell still reads smooth. */ +const HALFTONE_LEVELS = 32; + +function quantizeLevel(value: number): number { + return Math.round(Math.max(0, Math.min(1, value)) * HALFTONE_LEVELS) / HALFTONE_LEVELS; +} + +/** The layer's persistent Graphics under all pooled chrome: the ground, the floor wedge, and the idle monitor field. */ +interface StaticLayers { + ground: Graphics; + wedge: Graphics; + monitor: Graphics; +} + +const STATIC_LAYERS = new WeakMap(); +const GROUND_CONTEXTS = new Map(); +const WEDGE_CONTEXTS = new Map(); +const MONITOR_CONTEXTS = new Map(); +/** Monitor fields kept: a handful of rects (single / double play) at every level. */ +const MONITOR_CONTEXT_LIMIT = 4 * (HALFTONE_LEVELS + 1); + +function rectKey(rect: BeMusicRect | undefined): string { + return rect ? `${rect.x},${rect.y},${rect.w},${rect.h}` : '-'; +} + +function cachedContext( + cache: Map, + key: K, + draw: (context: GraphicsContext) => void, + limit = 64, +) { + let context = cache.get(key); + if (!context) { + if (cache.size >= limit) { + const oldest = cache.keys().next().value as K; + // Retired contexts may still be shown this frame by another layer; let them go with the garbage collector. + cache.delete(oldest); + } + context = new GraphicsContext(); + draw(context); + cache.set(key, context); + } + return context; +} + +function showStaticLayers( + layer: Container, + hole: BeMusicRect | undefined, + drive: AudioDrive, + idleMonitor: BeMusicRect | undefined, +): void { + let layers = STATIC_LAYERS.get(layer); + if (!layers) { + layers = { ground: new Graphics(), wedge: new Graphics(), monitor: new Graphics() }; + layers.ground.label = 'default-gameplay/ground'; + layers.wedge.label = 'default-gameplay/wedge'; + layers.monitor.label = 'default-gameplay/monitor-field'; + layer.addChildAt(layers.monitor, 0); + layer.addChildAt(layers.wedge, 0); + layer.addChildAt(layers.ground, 0); + STATIC_LAYERS.set(layer, layers); + } + layers.ground.context = cachedContext(GROUND_CONTEXTS, rectKey(hole), (context) => drawGround(context, hole)); + const bass = quantizeLevel(drive.bass); + layers.wedge.context = cachedContext(WEDGE_CONTEXTS, bass, (context) => drawWedge(context, bass)); + layers.monitor.visible = idleMonitor !== undefined; + if (idleMonitor) { + const level = quantizeLevel(drive.level); + layers.monitor.context = cachedContext( + MONITOR_CONTEXTS, + `${rectKey(idleMonitor)}:${level}`, + (context) => drawMonitorField(context, idleMonitor, level), + MONITOR_CONTEXT_LIMIT, + ); + } +} + /** - * Ink ground and the halftone floor wedge. The playfield itself stays undecorated so notes read cleanly. With a live BGA the ground leaves a - * hole over the BGA rect — the BGA layer renders BEHIND this chrome layer, so anything painted there would cover the - * video. Every decoration is placed so it never crosses that rect. + * Ink ground with diagonal pinstripes, and the floor wedge's red faces. The playfield itself stays undecorated so notes + * read cleanly. With a live BGA the ground leaves a hole over the BGA rect — the BGA layer renders BEHIND this chrome + * layer, so anything painted there would cover the video. Every decoration is placed so it never crosses that rect. */ -function drawBackground(frame: Graphics, hasBga: boolean): void { - fillRectAroundHole(frame, 0, 0, DESIGN_WIDTH, DESIGN_HEIGHT, PHANTOM_BLACK, hasBga); +function drawGround(context: GraphicsContext, hole: BeMusicRect | undefined): void { + fillRectAroundHole(context, 0, 0, DESIGN_WIDTH, DESIGN_HEIGHT, PHANTOM_BLACK, hole); // Faint diagonal pinstripes over the lower-left quadrant — gives the ink ground a printed texture. - for (let stripe = 0; stripe < 9; stripe += 1) { + for (let stripe = 0; stripe < 12; stripe += 1) { const x0 = -120 + stripe * 44; - frame.poly([x0, DESIGN_HEIGHT, x0 + 14, DESIGN_HEIGHT, x0 + 134, 352, x0 + 120, 352]).fill({ + context.poly([x0, DESIGN_HEIGHT, x0 + 14, DESIGN_HEIGHT, x0 + 134, 352, x0 + 120, 352]).fill({ color: PHANTOM_CHARCOAL, alpha: 0.7, }); } - - // Floor wedge: deep red base, hot red face, ink halftone swelling toward the lower-right corner. - frame - .poly([FLOOR_WEDGE[0]! - 18, DESIGN_HEIGHT, DESIGN_WIDTH, 316, DESIGN_WIDTH, DESIGN_HEIGHT]) + // Floor wedge: deep red base and hot red face. + context + .poly([FLOOR_WEDGE[0]! - 18, DESIGN_HEIGHT, DESIGN_WIDTH, 342, DESIGN_WIDTH, DESIGN_HEIGHT]) .fill(PHANTOM_RED_DEEP); - frame.poly([...FLOOR_WEDGE]).fill(PHANTOM_RED); + context.poly([...FLOOR_WEDGE]).fill(PHANTOM_RED); +} + +/** The wedge's ink halftone, swelling toward the lower-right corner and with the bass, then its paper-white cut line. */ +function drawWedge(context: GraphicsContext, bass: number): void { for (const dot of halftoneField({ x: 300, - y: 340, + y: 352, w: DESIGN_WIDTH - 300, - h: DESIGN_HEIGHT - 340, + h: DESIGN_HEIGHT - 352, pitch: 9, - maxRadius: 4.2, + maxRadius: 4.2 * (1 + 1.2 * bass), direction: { x: 1, y: 1 }, })) { - if (isInsideFloorWedge(dot.x, dot.y)) { - frame.circle(dot.x, dot.y, dot.r).fill({ color: PHANTOM_INK, alpha: 0.55 }); - } + if (isInsideFloorWedge(dot.x, dot.y)) context.circle(dot.x, dot.y, dot.r); } + // One fill for the whole field instead of one per dot. + context.fill({ color: PHANTOM_INK, alpha: 0.55 }); // Paper-white cut line tracing the wedge's leading edge. - frame - .poly([FLOOR_WEDGE[0]! + 26, DESIGN_HEIGHT, DESIGN_WIDTH, 334, DESIGN_WIDTH, 337, FLOOR_WEDGE[0]! + 32, 480]) + context + .poly([FLOOR_WEDGE[0]! + 26, DESIGN_HEIGHT, DESIGN_WIDTH, 360, DESIGN_WIDTH, 363, FLOOR_WEDGE[0]! + 32, 480]) .fill({ color: PHANTOM_WHITE, alpha: 0.9 }); } +/** The idle monitor's ink ground and red halftone, swelling with the loudness. */ +function drawMonitorField(context: GraphicsContext, bga: BeMusicRect, level: number): void { + context.rect(bga.x, bga.y, bga.w, bga.h).fill(PHANTOM_INK); + for (const dot of halftoneField({ + x: bga.x + 4, + y: bga.y + 4, + w: bga.w - 8, + h: bga.h - 8, + pitch: 12, + maxRadius: 5 * (1 + 1.1 * level), + direction: { x: -0.6, y: 1 }, + })) { + context.circle(dot.x, dot.y, dot.r); + } + context.fill({ color: PHANTOM_RED, alpha: 0.75 }); +} + function isInsideFloorWedge(x: number, y: number): boolean { const [ax, ay, bx, by] = FLOOR_WEDGE as readonly [number, number, number, number, number, number]; // Above-line test against the wedge's top edge (A → B); the other two edges are the canvas borders. return (bx - ax) * (y - ay) - (by - ay) * (x - ax) > 0 && x <= DESIGN_WIDTH && y <= DESIGN_HEIGHT; } -/** Fills `rect` minus the BGA rect (when `hasBga`) as up to four axis-aligned pieces. */ +/** Fills `rect` minus the `hole` rect (the live BGA) as up to four axis-aligned pieces. */ function fillRectAroundHole( - frame: Graphics, + frame: GraphicsContext, x: number, y: number, w: number, h: number, color: number, - hasBga: boolean, + hole: BeMusicRect | undefined, ): void { const right = x + w; const bottom = y + h; - const holeRight = BGA.x + BGA.w; - const holeBottom = BGA.y + BGA.h; - if (!hasBga || right <= BGA.x || x >= holeRight || bottom <= BGA.y || y >= holeBottom) { + if (!hole || right <= hole.x || x >= hole.x + hole.w || bottom <= hole.y || y >= hole.y + hole.h) { frame.rect(x, y, w, h).fill(color); return; } - if (y < BGA.y) frame.rect(x, y, w, BGA.y - y).fill(color); - const bandTop = Math.max(y, BGA.y); + const holeRight = hole.x + hole.w; + const holeBottom = hole.y + hole.h; + if (y < hole.y) frame.rect(x, y, w, hole.y - y).fill(color); + const bandTop = Math.max(y, hole.y); const bandBottom = Math.min(bottom, holeBottom); - if (x < BGA.x) frame.rect(x, bandTop, BGA.x - x, bandBottom - bandTop).fill(color); + if (x < hole.x) frame.rect(x, bandTop, hole.x - x, bandBottom - bandTop).fill(color); if (right > holeRight) frame.rect(holeRight, bandTop, right - holeRight, bandBottom - bandTop).fill(color); if (bottom > holeBottom) frame.rect(x, holeBottom, w, bottom - holeBottom).fill(color); } -function resolveFallbackPlayfieldLayout( - laneChannels: readonly string[] | undefined, - laneCount: number | undefined, - playVariant: ChartPlayVariant | undefined, -): FallbackPlayfieldLayout { - const lanes = resolveFallbackLaneLayout({ - channels: laneChannels, - laneCount, - playVariant, - x: PLAYFIELD.x, - w: PLAYFIELD.w, - preserveSideWidth: shouldPreserveFallbackSideWidth(laneChannels, playVariant), - }); - const right = Math.max(PLAYFIELD.x + PLAYFIELD.w, ...lanes.map((lane) => lane.x + lane.w)); - const w = Math.max(1, right - PLAYFIELD.x); - const sideBounds = resolveSideBounds(lanes); +/** The playfield bounds Phantom draws around, from the host's layout. */ +function playfieldFromLayout(layout: BeMusicGameplayLayout): FallbackPlayfieldLayout { + const { left, right, sides } = layout.playfield; const sideCenters: Partial> = {}; - if (sideBounds['1P']) { - sideCenters['1P'] = (sideBounds['1P'].left + sideBounds['1P'].right) / 2; - } - if (sideBounds['2P']) { - sideCenters['2P'] = (sideBounds['2P'].left + sideBounds['2P'].right) / 2; - } + if (sides['1P']) sideCenters['1P'] = (sides['1P'].left + sides['1P'].right) / 2; + if (sides['2P']) sideCenters['2P'] = (sides['2P'].left + sides['2P'].right) / 2; return { - lanes, - x: PLAYFIELD.x, - w, - centerX: PLAYFIELD.x + w / 2, + lanes: layout.lanes, + top: layout.playfield.top, + x: left, + w: Math.max(1, right - left), + centerX: layout.playfield.centerX, right, sideCenters, sideGap: - sideBounds['1P'] && sideBounds['2P'] && sideBounds['2P'].left > sideBounds['1P'].right - ? { x: sideBounds['1P'].right, w: sideBounds['2P'].left - sideBounds['1P'].right } + sides['1P'] && sides['2P'] && sides['2P'].left > sides['1P'].right + ? { x: sides['1P'].right, w: sides['2P'].left - sides['1P'].right } : undefined, }; } -function resolveSideBounds( - lanes: readonly FallbackLaneLayoutRect[], -): Partial> { - const bounds: Partial> = {}; - for (const lane of lanes) { - const side = lane.side; - const current = bounds[side]; - const right = lane.x + lane.w; - bounds[side] = current - ? { left: Math.min(current.left, lane.x), right: Math.max(current.right, right) } - : { left: lane.x, right }; - } - return bounds; -} - /** Bottom edge of the playfield frame — leaves room for the key-cap strip renderLanes paints below the judge line. */ const PLAYFIELD_FRAME_BOTTOM = 344; -function drawPlayfield(frame: Graphics, playfield: FallbackPlayfieldLayout, progressRatio: number | undefined): void { - const wellTop = PLAYFIELD.y; +function drawPlayfield( + frame: Graphics, + playfield: FallbackPlayfieldLayout, + progressRatio: number | undefined, + hit: number, +): void { + const wellTop = playfield.top; const wellBottom = PLAYFIELD_FRAME_BOTTOM; const wellHeight = wellBottom - wellTop; const railW = 8; @@ -264,6 +414,10 @@ function drawPlayfield(frame: Graphics, playfield: FallbackPlayfieldLayout, prog frame.rect(railX, wellTop, railW, wellHeight).fill(PHANTOM_RED); frame.rect(railX, wellTop, 1, wellHeight).fill(PHANTOM_WHITE); frame.rect(railX + railW - 1, wellTop, 1, wellHeight).fill(PHANTOM_WHITE); + // Every press flashes the rails white, bottom-heavy, so the cabinet answers the player's hands. + if (hit > 0) { + frame.rect(railX, wellHeight * 0.45, railW, wellHeight * 0.55).fill({ color: PHANTOM_WHITE, alpha: 0.75 * hit }); + } } // Song-progress track inside the left rail, filling bottom-up in paper white. @@ -284,68 +438,93 @@ function drawPlayfield(frame: Graphics, playfield: FallbackPlayfieldLayout, prog frame.poly(parallelogramPoints(leftRailX + 6, wellBottom + 5, footerW - 12, 4, -6)).fill(PHANTOM_RED); } -/** X where the judge tally column can sit, or undefined when the (DP-wide) playfield covers it. */ -function resolveJudgeTallyX(playfield: FallbackPlayfieldLayout): number | undefined { - const x = BGA.x + BGA.w + 22; - return playfield.right + 14 <= x && x + 60 <= DESIGN_WIDTH ? x : undefined; -} - /** * Monitor frame: a red offset parallelogram and a paper-white one, both wide enough to enclose the BGA rect so their - * strokes never cross the video. With no BGA the screen idles on a red halftone field with a "STAND BY" slug. + * strokes never cross the video. With no BGA the screen idles on a red halftone field with SOUND ONLY cut out of + * magazines over a turning starburst. */ function drawBgaFrame( frame: Graphics, layer: Container, + bga: BeMusicRect, hasBga: boolean, nowMs: number | undefined, + drive: AudioDrive, pool: ChildPool | undefined, ): void { const slant = 6; const margin = 6; - const frameX = BGA.x - margin - slant; - const frameW = BGA.w + margin * 2 + slant; - frame.poly(parallelogramPoints(frameX + 4, BGA.y - margin + 4, frameW, BGA.h + margin * 2, slant)).stroke({ + const frameX = bga.x - margin - slant; + const frameW = bga.w + margin * 2 + slant; + frame.poly(parallelogramPoints(frameX + 4, bga.y - margin + 4, frameW, bga.h + margin * 2, slant)).stroke({ color: PHANTOM_RED, width: 4, join: 'miter', }); - frame.poly(parallelogramPoints(frameX, BGA.y - margin, frameW, BGA.h + margin * 2, slant)).stroke({ + frame.poly(parallelogramPoints(frameX, bga.y - margin, frameW, bga.h + margin * 2, slant)).stroke({ color: PHANTOM_WHITE, width: 3, join: 'miter', }); + // The ink ground and red halftone under the idle burst come from `drawMonitorField`, beneath this frame. if (hasBga) return; - frame.rect(BGA.x, BGA.y, BGA.w, BGA.h).fill(PHANTOM_INK); - for (const dot of halftoneField({ - x: BGA.x + 4, - y: BGA.y + 4, - w: BGA.w - 8, - h: BGA.h - 8, - pitch: 12, - maxRadius: 5, - direction: { x: -0.6, y: 1 }, - })) { - frame.circle(dot.x, dot.y, dot.r).fill({ color: PHANTOM_RED, alpha: 0.75 }); - } // Slow-turning starburst behind the slug — the idle screen is alive, not a hole in the cabinet. - const spin = nowMs !== undefined ? nowMs / 6000 : 0; - const cx = BGA.x + BGA.w / 2; - const cy = BGA.y + BGA.h / 2; - frame.poly(starburstPoints(cx, cy, 92, 58, 14, spin, 0.18, 3)).fill({ color: PHANTOM_RED, alpha: 0.95 }); - frame.poly(starburstPoints(cx, cy, 70, 46, 14, spin + 0.12, 0.2, 5)).fill(PHANTOM_INK); - frame.poly(parallelogramPoints(cx - 70, cy - 15, 132, 30, 8)).fill(PHANTOM_WHITE); - addText( - layer, - 'STAND BY', - cx + 4, - cy, - { size: 22, fill: PHANTOM_INK, fontFamily: DISPLAY_FONT, anchorX: 0.5, anchorY: 0.5, skewX: TYPE_SKEW }, - pool, - ); + // It pumps on the bass and jolts a notch round on every onset. Sized off the monitor, which shrinks in double play. + const spin = (nowMs !== undefined ? nowMs / 6000 : 0) + 0.25 * drive.onset; + const pump = (0.8 + 0.6 * drive.bass + 0.3 * drive.onset) * (bga.w / 256); + const cx = bga.x + bga.w / 2; + const cy = bga.y + bga.h / 2; + frame + .poly(starburstPoints(cx, cy, 92 * pump, 58 * pump, 14, spin, 0.18, 3)) + .fill({ color: PHANTOM_RED, alpha: 0.95 }); + frame.poly(starburstPoints(cx, cy, 70 * pump, 46 * pump, 14, spin + 0.12, 0.2, 5)).fill(PHANTOM_INK); + // The ransom note is re-cut every so often, like NOW LOADING, so the idle monitor never sits still. + const cards = pool?.acquireGraphics() ?? new Graphics(); + cards.label = 'default-gameplay/sound-only'; + if (!pool) layer.addChild(cards); + const glyph = hudGlyphs(layer, pool); + const recut = Math.floor((nowMs ?? 0) / 1400); + const size = Math.min(26, (bga.w - 48) / (5 * 1.05)); + addRansomText(cards, glyph, 'SOUND', cx, cy - size * 0.55, { size, seed: recut * 3 + 7, angle: -0.07 }); + addRansomText(cards, glyph, 'ONLY', cx + size * 0.3, cy + size * 0.65, { size, seed: recut * 3 + 8, angle: 0.05 }); } +/** + * Spectrum in the side column under the judge tally: 16 slanted red bars rising off a paper rule, white-capped, hot + * bands turning gold — the poster's equalizer. Silent (or effects off) leaves just the rule. + */ +function drawSpectrumColumn( + frame: Graphics, + layer: Container, + drive: AudioDrive, + top: number, + pool: ChildPool | undefined, +): void { + const x = STAGE_SIDE_COLUMN.x; + const w = STAGE_SIDE_COLUMN.w; + const baseY = SPECTRUM_BASE_Y; + const maxH = baseY - top - 14; + if (maxH < 16) return; + frame.poly(parallelogramPoints(x - 4, top - 6, 52, 17, 6)).fill(PHANTOM_RED); + addText(layer, 'SOUND', x + 22, top + 2, { ...displayStyle(12, PHANTOM_WHITE), anchorX: 0.5, anchorY: 0.5 }, pool); + const count = 16; + const pitch = w / count; + frame.rect(x, baseY, w, 2).fill(PHANTOM_WHITE); + for (let bar = 0; bar < count; bar += 1) { + const value = bandLevel(drive.bands, bar, count); + const h = Math.round(maxH * value); + if (h < 2) continue; + const bx = x + bar * pitch + 1; + const bw = pitch - 2; + frame.poly(parallelogramPoints(bx, baseY - h, bw, h, 2)).fill(value > 0.82 ? PHANTOM_GOLD : PHANTOM_RED); + frame.rect(bx + 2, baseY - h, bw, 2).fill(PHANTOM_WHITE); + } +} + +/** The spectrum's rule sits on the BGA band's floor, level with the monitor's bottom edge. */ +const SPECTRUM_BASE_Y = STAGE_BGA_BAND.bottom; + /** * Header strip: ink bar, a red jagged kicker that swells on every beat, the paper mode tag, BPM / HI-SPEED readouts, * and the red ruleset tag. @@ -369,12 +548,12 @@ function drawStatusBar( status.rect(0, 37, DESIGN_WIDTH, 1).fill(PHANTOM_WHITE); // Mode tag — paper plate with a red shadow; AUTO PLAY flips it to a red plate so a demo run is unmistakable. - status.poly(parallelogramPoints(15, 11, 96, 22, 9)).fill(autoplay ? PHANTOM_WHITE : PHANTOM_RED); - status.poly(parallelogramPoints(11, 7, 96, 22, 9)).fill(autoplay ? PHANTOM_RED : PHANTOM_WHITE); + status.poly(parallelogramPoints(HUD_MARGIN + 4, 11, 96, 22, 9)).fill(autoplay ? PHANTOM_WHITE : PHANTOM_RED); + status.poly(parallelogramPoints(HUD_MARGIN, 7, 96, 22, 9)).fill(autoplay ? PHANTOM_RED : PHANTOM_WHITE); addText( layer, autoplay ? 'AUTO PLAY' : 'PLAY', - 63, + HUD_MARGIN + 52, 18, { size: 15, @@ -388,18 +567,21 @@ function drawStatusBar( pool, ); - addText(layer, 'BPM', 132, 14, tagLabelStyle(PHANTOM_RED), pool); - addNumber(layer, formatBpmValue(runtime.bpm), 156, 5, displayStyle(22, PHANTOM_WHITE), pool); - addText(layer, 'HI-SPEED', 214, 14, tagLabelStyle(PHANTOM_RED), pool); - addNumber(layer, `x${formatHiSpeed(runtime.hiSpeed)}`, 262, 5, displayStyle(22, PHANTOM_WHITE), pool); + addText(layer, 'BPM', 136, 14, tagLabelStyle(PHANTOM_RED), pool); + addNumber(layer, formatBpmValue(runtime.bpm), 160, 5, displayStyle(22, PHANTOM_WHITE), pool); + addText(layer, 'HI-SPEED', 218, 14, tagLabelStyle(PHANTOM_RED), pool); + addNumber(layer, `x${formatHiSpeed(runtime.hiSpeed)}`, 266, 5, displayStyle(22, PHANTOM_WHITE), pool); - status.poly(parallelogramPoints(392, 8, 118, 22, -8)).fill(PHANTOM_RED); - status.rect(397, 13, 3, 12).fill(PHANTOM_WHITE); - addText(layer, 'RULESET', 410, 15, tagLabelStyle(PHANTOM_INK), pool); + // The ruleset tag sits on the right margin; the skin's mark sits midway between it and the tempo readouts. + const rulesetX = DESIGN_WIDTH - HUD_MARGIN - 126; + drawNoteEmblem(status, (310 + rulesetX) / 2, 25, 22, PHANTOM_WHITE, PHANTOM_RED); + status.poly(parallelogramPoints(rulesetX, 8, 118, 22, -8)).fill(PHANTOM_RED); + status.rect(rulesetX + 5, 13, 3, 12).fill(PHANTOM_WHITE); + addText(layer, 'RULESET', rulesetX + 18, 15, tagLabelStyle(PHANTOM_INK), pool); addText( layer, formatRulesetLabel(runtime.rulesetLabel), - 500, + rulesetX + 108, 19, { ...displayStyle(16, PHANTOM_WHITE), anchorX: 1, anchorY: 0.5, maxWidth: 56 }, pool, @@ -427,7 +609,7 @@ function drawGauge( const clearCell = Math.round((clear / 100) * cellCount); frame - .poly(parallelogramPoints(GROOVE.x - 16, GROOVE.y - 22, GROOVE.w + 30, 46, 8)) + .poly(parallelogramPoints(GROOVE.x - 24, GROOVE.y - 22, GROOVE.w + 38, 46, 8)) .fill(PHANTOM_INK) .stroke({ color: PHANTOM_WHITE, @@ -435,13 +617,11 @@ function drawGauge( join: 'miter', }); // Gauge-type tag knifed into the housing's top-left corner. - frame - .poly(parallelogramPoints(GROOVE.x - 20, GROOVE.y - 30, 106, 16, 6)) - .fill(survival ? PHANTOM_WHITE : PHANTOM_RED); + frame.poly(parallelogramPoints(HUD_MARGIN, GROOVE.y - 30, 106, 16, 6)).fill(survival ? PHANTOM_WHITE : PHANTOM_RED); addText( layer, `${(runtime.gaugeLabel ?? 'GROOVE').toUpperCase()} GAUGE`, - GROOVE.x - 10, + HUD_MARGIN + 10, GROOVE.y - 22, { ...tagLabelStyle(survival ? PHANTOM_RED : PHANTOM_WHITE), anchorY: 0.5, maxWidth: 92 }, pool, @@ -508,6 +688,7 @@ function drawScorePlate( frame: Graphics, layer: Container, runtime: FallbackGameplayRuntime, + drive: AudioDrive, pool: ChildPool | undefined, ): void { const { x, y, w, h } = SCORE_PANEL; @@ -516,35 +697,37 @@ function drawScorePlate( .poly(parallelogramPoints(x, y, w, h, -10)) .fill(PHANTOM_BLACK) .stroke({ color: PHANTOM_WHITE, width: 2 }); - frame.rect(x + 14, y + 40, 116, 1).fill({ color: PHANTOM_SLATE }); - frame.rect(x + 14, y + 70, 116, 1).fill({ color: PHANTOM_SLATE }); - frame.poly([x + 146, y + 10, x + 148, y + 10, x + 150, y + h - 10, x + 148, y + h - 10]).fill(PHANTOM_RED); + // Three rows on a 28 px pitch shared by both columns: label on the left, value on the right of each row. + frame.rect(x + 14, y + 36, SCORE_LEFT_END - 18, 1).fill({ color: PHANTOM_SLATE }); + frame.rect(x + 14, y + 64, SCORE_LEFT_END - 18, 1).fill({ color: PHANTOM_SLATE }); + const d = x + SCORE_DIVIDER; + frame.poly([d - 2, y + 10, d, y + 10, d + 2, y + h - 10, d, y + h - 10]).fill(PHANTOM_RED); addText(layer, 'SCORE', x + 14, y + 12, tagLabelStyle(PHANTOM_RED), pool); addNumber( layer, formatCount(runtime.score), - x + 134, + x + SCORE_LEFT_END, y + 2, - { ...displayStyle(26, PHANTOM_WHITE), anchorX: 1, maxWidth: 80 }, + { ...displayStyle(26, PHANTOM_WHITE), anchorX: 1, maxWidth: 94 }, pool, ); - addText(layer, 'EX SCORE', x + 14, y + 49, tagLabelStyle(PHANTOM_ASH), pool); + addText(layer, 'EX SCORE', x + 14, y + 44, tagLabelStyle(PHANTOM_ASH), pool); addNumber( layer, `${formatCount(runtime.exScore)} / ${formatCount(runtime.exScoreMax)}`, - x + 134, - y + 44, - { ...displayStyle(14, PHANTOM_PAPER), anchorX: 1, maxWidth: 66 }, + x + SCORE_LEFT_END, + y + 39, + { ...displayStyle(14, PHANTOM_PAPER), anchorX: 1, maxWidth: 76 }, pool, ); - addText(layer, 'EX RATE', x + 14, y + 78, tagLabelStyle(PHANTOM_ASH), pool); + addText(layer, 'EX RATE', x + 14, y + 72, tagLabelStyle(PHANTOM_ASH), pool); addNumber( layer, formatExRate(runtime.exScore, runtime.exScoreMax), - x + 134, - y + 73, - { ...displayStyle(14, PHANTOM_PAPER), anchorX: 1, maxWidth: 74 }, + x + SCORE_LEFT_END, + y + 67, + { ...displayStyle(14, PHANTOM_PAPER), anchorX: 1, maxWidth: 84 }, pool, ); @@ -554,34 +737,35 @@ function drawScorePlate( ? Math.max(0, Math.min(1, runtime.exScore / runtime.exScoreMax)) : 0; const segments = 9; - const segmentW = 116 / segments; + const segmentW = (SCORE_LEFT_END - 18) / segments; for (let segment = 0; segment < segments; segment += 1) { const fill = Math.max(0, Math.min(1, rate * segments - segment)); const sx = x + 14 + segment * segmentW; - frame.poly(parallelogramPoints(sx, y + 96, segmentW - 2, 5, 2)).fill(PHANTOM_SLATE); + frame.poly(parallelogramPoints(sx, y + 88, segmentW - 2, 5, 2)).fill(PHANTOM_SLATE); if (fill > 0) { frame - .poly(parallelogramPoints(sx, y + 96, (segmentW - 2) * fill, 5, 2)) + .poly(parallelogramPoints(sx, y + 88, (segmentW - 2) * fill, 5, 2)) .fill(segment >= 7 ? PHANTOM_GOLD : PHANTOM_RED_HOT); } } - // Right column: COMBO headline on its own row, MAX inline beneath, rank badge in the lower corner. - addText(layer, 'COMBO', x + 160, y + 10, tagLabelStyle(PHANTOM_RED), pool); + // Right column on the same rows: COMBO, MAX, then RANK beside the badge in the lower corner. + // The combo value sits under its label (a 3–4 digit combo would run into the label on one line), still clear of MAX. + addText(layer, 'COMBO', x + SCORE_RIGHT, y + 12, tagLabelStyle(PHANTOM_RED), pool); addNumber( layer, formatCount(runtime.combo), x + w - 12, - y + 20, + y + 15, { ...displayStyle(22, PHANTOM_WHITE), anchorX: 1, maxWidth: 60 }, pool, ); - addText(layer, 'MAX', x + 160, y + 55, tagLabelStyle(PHANTOM_ASH), pool); + addText(layer, 'MAX', x + SCORE_RIGHT, y + 44, tagLabelStyle(PHANTOM_ASH), pool); addNumber( layer, formatCount(runtime.maxCombo), - x + w - 10, - y + 51, + x + w - 12, + y + 40, { ...displayStyle(13, PHANTOM_PAPER), anchorX: 1, maxWidth: 40 }, pool, ); @@ -592,12 +776,14 @@ function drawScorePlate( // Anchored on the plate's lower-right corner and allowed to break out of the frame, so it never crowds COMBO / MAX. const badgeX = x + w - 10; const badgeY = y + h - 14; - const badge = starburstPoints(badgeX, badgeY, 27, 17, 12, -0.2, 0.22, rank.length); + // The badge pumps with the bass. + const pump = 1 + 0.45 * drive.bass; + const badge = starburstPoints(badgeX, badgeY, 27 * pump, 17 * pump, 12, -0.2, 0.22, rank.length); frame .poly(badge) .fill(topRank ? PHANTOM_GOLD : PHANTOM_RED) .stroke({ color: PHANTOM_WHITE, width: 1.5 }); - addText(layer, 'RANK', x + 160, y + 84, tagLabelStyle(PHANTOM_ASH), pool); + addText(layer, 'RANK', x + SCORE_RIGHT, y + 72, tagLabelStyle(PHANTOM_ASH), pool); addText( layer, rank, @@ -631,9 +817,10 @@ function drawJudgeTally( runtime: FallbackGameplayRuntime, x: number, pool?: ChildPool, -): void { - const y = BGA.y - 12; - const w = DESIGN_WIDTH - x - 6; +): number { + // Top-aligned with the BGA band so the header chip clears the status bar's beat-driven teeth. + const y = 56; + const w = DESIGN_WIDTH - HUD_MARGIN - x; const rowH = 24; const h = 22 + JUDGE_TALLY_ROWS.length * rowH + 46; frame.rect(x, y, w, h).fill(PHANTOM_INK).stroke({ color: PHANTOM_WHITE, width: 1 }); @@ -679,7 +866,7 @@ function drawJudgeTally( for (let row = 0; row < footerRows.length; row += 1) { const [label, color, count] = footerRows[row]!; const rowY = footerY + 2 + row * 20; - addText(layer, label, x + 7, rowY + 4, { ...tagLabelStyle(color), size: 8 }, pool); + addText(layer, label, x + 7, rowY + 3, tagLabelStyle(color), pool); addNumber( layer, formatCount(count), @@ -689,6 +876,7 @@ function drawJudgeTally( pool, ); } + return y + h; } const TALLY_JUDGE_NAMES = { @@ -715,46 +903,89 @@ function drawJudgements( runtime: FallbackGameplayRuntime, playfield: FallbackPlayfieldLayout, pool: ChildPool | undefined, + amount: number, ): void { + const nowMs = runtime.nowMs ?? 0; for (const display of resolveJudgeDisplays(runtime, playfield)) { const combo = resolveVisibleCombo(display.judge, display.combo); const style = judgeStyle(display.judge); + const word = judgeDisplayWord(display.judge); + // The judgement and the combo are cut out of magazines like the count-in's READY? / GO!!: a fresh ransom note on + // every hit, each card popping in a beat after the last (a miss lands plainly). The letters on the dark cards carry + // the judgement colour (a PERFECT's GREAT flashes through the palette); the combo turns gold from 200. Both stay + // small so the notes falling through them stay readable. + const miss = display.judge === 'POOR' || display.judge === 'BAD'; + const age = runtime.judgeAtMs !== undefined ? nowMs - runtime.judgeAtMs : Number.POSITIVE_INFINITY; + const cards = pool?.acquireGraphics() ?? new Graphics(); + cards.label = 'default-gameplay/judge-cards'; + if (!pool) layer.addChild(cards); + const glyph = hudGlyphs(layer, pool); + // Fast enough to finish between hits in a dense passage, and only a slight overshoot so it never covers lanes. + const appear = (delayMs: number) => (index: number) => + miss || amount <= 0 ? 1 : (age - delayMs - index * 10) / 60; + addRansomText(cards, glyph, word, display.x, 238, { + size: Math.min(JUDGE_WORD_SIZE, display.maxWidth / (word.length * 0.95)), + seed: (display.combo ?? 0) * 7 + word.length, + angle: -0.06, + accent: isFlashingGreat(display.judge) ? flashingGreatColor(nowMs, PHANTOM_FLASHING_GREAT) : style.fill, + appear: appear(0), + pop: 0.22, + }); + if (combo > 0) { + const digits = formatCount(combo); + addRansomText(cards, glyph, digits, display.x, 238 + JUDGE_WORD_SIZE + 2, { + size: Math.min(JUDGE_COMBO_SIZE, display.maxWidth / (digits.length * 0.95)), + seed: combo * 13 + 5, + angle: 0.05, + accent: combo >= 200 ? PHANTOM_GOLD : PHANTOM_WHITE, + appear: appear(30), + pop: 0.22, + }); + } + } +} + +/** + * NOW LOADING on the lanes while the chart's assets load: the words cut out of magazines like the count-in, re-cut + * every so often so the notice looks alive, with ink dots ticking underneath. + */ +function drawLoading( + layer: Container, + nowMs: number, + playfield: FallbackPlayfieldLayout, + pool: ChildPool | undefined, +): void { + const cards = pool?.acquireGraphics() ?? new Graphics(); + cards.label = 'default-gameplay/loading'; + if (!pool) layer.addChild(cards); + const glyph = hudGlyphs(layer, pool); + const cx = playfield.centerX; + const cy = 170; + const recut = Math.floor(nowMs / 700); + const size = Math.min(24, (playfield.w - 24) / (7 * 0.95)); + addRansomText(cards, glyph, 'NOW', cx, cy, { size, seed: recut * 3 + 1, angle: -0.06 }); + addRansomText(cards, glyph, 'LOADING', cx, cy + size + 6, { size, seed: recut * 3 + 2, angle: 0.04 }); + addText( + layer, + loadingDots(nowMs).padEnd(3, ' '), + cx, + cy + size * 2 + 16, + { ...displayStyle(22, PHANTOM_WHITE), anchorX: 0.5, anchorY: 0.5 }, + pool, + ); +} + +/** Ransom-note glyphs from the pooled HUD text (or fresh nodes without a pool). */ +function hudGlyphs(layer: Container, pool: ChildPool | undefined): GlyphFactory { + return (char, options) => addText( layer, - display.judge, - display.x, - 238, - { - size: 30, - fill: style.fill, - fontFamily: DISPLAY_FONT, - letterSpacing: 1, - anchorX: 0.5, - anchorY: 0.5, - skewX: TYPE_SKEW, - stroke: { color: PHANTOM_INK, width: 6, alignment: 0.5, join: 'miter' }, - dropShadow: { color: style.shadow, alpha: 1, blur: 0, distance: 4, angle: Math.PI / 4 }, - maxWidth: display.maxWidth, - }, + char, + 0, + 0, + { size: options.size, weight: options.weight, fill: options.fill, fontFamily: options.fontFamily }, pool, ); - if (combo > 0) { - addNumber( - layer, - formatCount(combo), - display.x, - 270, - { - ...displayStyle(24, combo >= 200 ? PHANTOM_GOLD : PHANTOM_WHITE), - anchorX: 0.5, - anchorY: 0.5, - stroke: { color: PHANTOM_INK, width: 4, alignment: 0.5, join: 'miter' }, - maxWidth: Math.max(72, display.maxWidth - 36), - }, - pool, - ); - } - } } function resolveJudgeDisplays( @@ -837,6 +1068,13 @@ function clampPercent(value: number): number { return Math.max(0, Math.min(100, value)); } +/** Judgement word / combo ransom-note sizes: small enough that the notes read through them. */ +const JUDGE_WORD_SIZE = 22; +const JUDGE_COMBO_SIZE = 18; + +/** Colours the PERFECT judgement's flashing GREAT cycles through. */ +const PHANTOM_FLASHING_GREAT = [PHANTOM_WHITE, PHANTOM_CYAN, PHANTOM_GOLD, 0xff7ad9] as const; + /** Fill / hard-shadow pair for each judgement word, plus the tally-count colour. */ function judgeStyle(judge: string): { fill: number; shadow: number; tally: number } { switch (judge) { diff --git a/packages/player-web/src/scene/default/fonts.ts b/packages/player-web/src/skins/phantom/fonts.ts similarity index 65% rename from packages/player-web/src/scene/default/fonts.ts rename to packages/player-web/src/skins/phantom/fonts.ts index 08c0ce81..5d93a5b3 100644 --- a/packages/player-web/src/scene/default/fonts.ts +++ b/packages/player-web/src/skins/phantom/fonts.ts @@ -1,13 +1,12 @@ /** - * Default-family type stack. The poster look pairs three faces, each with LINE Seed JP as the offline fallback: + * Phantom's type stack. The poster look pairs the SDK's `DEFAULT_TEXT_FONT` (M PLUS 1p, for small UI text and Japanese + * body copy) with two faces of its own, each with LINE Seed JP as the offline fallback: * - * - `DEFAULT_TEXT_FONT` — M PLUS 1p, a tight, sharp gothic for small UI text and Japanese body copy. * - `DEFAULT_HEADLINE_FONT` — Dela Gothic One, a heavy poster gothic for song titles (Latin and Japanese). * - `DEFAULT_DISPLAY_FONT` — Anton, a condensed Latin face for tags, judgements and numerals. * * Dela Gothic One and Anton each ship a single 400 weight, so callers draw them at `'400'` — a heavier request would * make the browser synthesize a smeared faux-bold. */ -export const DEFAULT_TEXT_FONT = 'M PLUS 1p, LINE Seed JP, ui-sans-serif, system-ui, sans-serif'; export const DEFAULT_HEADLINE_FONT = 'Dela Gothic One, M PLUS 1p, LINE Seed JP, sans-serif'; export const DEFAULT_DISPLAY_FONT = 'Anton, M PLUS 1p, LINE Seed JP, Impact, Arial Narrow, sans-serif'; diff --git a/packages/player-web/src/scene/default/phantom/index.ts b/packages/player-web/src/skins/phantom/index.ts similarity index 56% rename from packages/player-web/src/scene/default/phantom/index.ts rename to packages/player-web/src/skins/phantom/index.ts index 10caa084..234eb34d 100644 --- a/packages/player-web/src/scene/default/phantom/index.ts +++ b/packages/player-web/src/skins/phantom/index.ts @@ -1,5 +1,4 @@ -import type { BeMusicSkin } from '../../../skin/be-music/types.ts'; -import { renderDefaultGameplayFrame } from '../gameplay-render.ts'; +import { renderPhantomChrome } from './chrome.ts'; import { PHANTOM_BOMB_DURATION_MS, renderPhantomBombs, @@ -9,18 +8,27 @@ import { } from './playfield.ts'; import { phantomResultSkin } from './result.ts'; import { phantomSelectSkin } from './select.ts'; +import { BE_MUSIC_SKIN_API_VERSION, wideStage, type BeMusicSkin } from '@be-music/skin-sdk'; +import { definePixiSkin } from '../pixi-kit/index.ts'; /** * Phantom — the built-in poster skin: ink black, blood red, and paper white, with slanted plates, starbursts, * halftone fields, and condensed italic type. The playfield itself stays plain and IIDX-like. */ -export const phantomSkin: BeMusicSkin = { +export const phantomSkin: BeMusicSkin = definePixiSkin({ + apiVersion: BE_MUSIC_SKIN_API_VERSION, id: 'phantom', label: 'Phantom', + version: '1.0.0', + author: { name: 'be-music', url: 'https://github.com/nulltask/be-music' }, + description: + 'Poster-style skin in ink black, blood red, and paper white: slanted plates, starbursts, halftone fields, and ransom-note showpieces.', + homepage: 'https://github.com/nulltask/be-music', + license: 'MIT', fontLoads: ['400 24px "Anton"', '400 18px "Dela Gothic One"', '700 12px "M PLUS 1p"', '800 12px "M PLUS 1p"'], + stage: wideStage, gameplay: { - renderChrome: ({ layer, overlayLayer, layerPool, overlayLayerPool, runtime }) => - renderDefaultGameplayFrame(layer, runtime, { overlayLayer, layerPool, overlayLayerPool }), + renderChrome: renderPhantomChrome, renderLanes: renderPhantomLanes, renderNote: renderPhantomNote, renderLongNote: renderPhantomLongNote, @@ -29,4 +37,4 @@ export const phantomSkin: BeMusicSkin = { }, select: phantomSelectSkin, result: phantomResultSkin, -}; +}); diff --git a/packages/player-web/src/skins/phantom/layout.ts b/packages/player-web/src/skins/phantom/layout.ts new file mode 100644 index 00000000..f57c3bbf --- /dev/null +++ b/packages/player-web/src/skins/phantom/layout.ts @@ -0,0 +1,4 @@ +/** Phantom's own HUD geometry on the 16:9 stage. */ + +/** The groove gauge's bar (its housing and labels are drawn around it). */ +export const GROOVE = { x: 44, y: 387, w: 200, h: 14 } as const; diff --git a/packages/player-web/src/skins/phantom/moments.ts b/packages/player-web/src/skins/phantom/moments.ts new file mode 100644 index 00000000..31271e6d --- /dev/null +++ b/packages/player-web/src/skins/phantom/moments.ts @@ -0,0 +1,339 @@ +import { Graphics, type Container } from 'pixi.js'; +import { GROOVE } from './layout.ts'; +import { DEFAULT_DISPLAY_FONT } from './fonts.ts'; +import { addRansomText, drawTearStrip, stripPoint, type GlyphFactory, type TearStrip } from './tear.ts'; +import { + PHANTOM_GOLD, + PHANTOM_INK, + PHANTOM_PAPER, + PHANTOM_RED, + PHANTOM_RED_HOT, + PHANTOM_WHITE, + parallelogramPoints, + starburstPoints, +} from './style.ts'; +import { + easeOutBack, + easeOutCubic, + effectProfile, + hash01, + momentProgress, + trackMoments, + resolveMilestoneArea, + type BeMusicGameplayLayout, + type BeMusicGameplayRuntime, + STAGE_HEIGHT as DESIGN_HEIGHT, + STAGE_WIDTH as DESIGN_WIDTH, + stageProgress, +} from '@be-music/skin-sdk'; +import { addHudText, type ChildPool } from '../pixi-kit/index.ts'; + +const MILESTONE_MS = 1100; +const CLEAR_MS = 1000; +const FULL_COMBO_MS = 2400; +const SKEW = -0.18; + +/** + * Phantom showpieces, painted on the front layer above the HUD: + * + * - the count-in: a torn-paper strip rips across the screen with READY? in ransom-note letters, then a second rip + * tears the other way and slams GO!! as the first beat lands; + * - every 100 combo: a torn strip rips in over the BGA side (never over the lanes) with the count in a gold burst and + * COMBO!! cut from magazines; + * - the gauge crossing the clear line: a gold CLEAR LINE tag pops off the gauge with a flash (it marks the line + * being reached, not the song being cleared); + * - a full combo: a white flash, a giant turning burst, confetti, and a screen-wide rip carrying FULL COMBO in + * ransom-note letters (the chart is over, so it may cover the playfield). + */ +/** Returns true while a moment covers the playfield (the full combo), so the caller can hold back judgement type. */ +export function drawPhantomMoments( + chromeLayer: Container, + layer: Container, + runtime: BeMusicGameplayRuntime, + pool: ChildPool | undefined, + layout: BeMusicGameplayLayout, +): boolean { + const nowMs = runtime.nowMs ?? 0; + const moments = trackMoments(chromeLayer, { + nowMs, + combo: runtime.combo ?? 0, + gauge: runtime.gauge ?? 0, + clearThreshold: runtime.clearThreshold ?? 80, + survival: runtime.gaugeSurvival === true, + judged: + (runtime.perfect ?? 0) + (runtime.great ?? 0) + (runtime.good ?? 0) + (runtime.bad ?? 0) + (runtime.poor ?? 0), + totalNotes: runtime.totalNotes ?? 0, + bad: runtime.bad ?? 0, + poor: runtime.poor ?? 0, + }); + const effects = effectProfile(runtime.effects); + if (!effects.enabled) return false; + const graphics = pool?.acquireGraphics() ?? new Graphics(); + graphics.label = 'phantom/moments'; + if (!pool) layer.addChild(graphics); + + // Misses get no effect at all: the playfield stays still and clear so the next notes read. + + if (runtime.chartMs !== undefined) { + drawCountIn(graphics, layer, runtime.chartMs, pool); + } + const milestone = momentProgress(moments.milestone?.atMs, nowMs, MILESTONE_MS); + if (milestone !== undefined && moments.milestone) { + drawMilestone(graphics, layer, moments.milestone.value, milestone, runtime.hasBga === true, pool, nowMs, layout); + } + const clear = momentProgress(moments.clearAtMs, nowMs, CLEAR_MS); + if (clear !== undefined) { + drawClear(graphics, layer, clear, pool); + } + const fullCombo = momentProgress(moments.fullComboAtMs, nowMs, FULL_COMBO_MS); + if (fullCombo !== undefined) { + drawFullCombo(graphics, layer, fullCombo, nowMs, effects.screenWide, pool); + } + return fullCombo !== undefined && fullCombo > 0.04 && fullCombo < 0.9; +} + +/** READY? → GO!! count-in over the last ~2.8 s before the first beat. */ +function drawCountIn(graphics: Graphics, layer: Container, chartMs: number, pool: ChildPool | undefined): void { + if (chartMs < -2800 || chartMs > 450) return; + const cy = 236; + const glyph = hudGlyphs(layer, pool); + // READY?: the rip opens and holds until GO, then snaps shut. + const readyT = + chartMs < -650 ? ((chartMs + 2800) / 2150) * 0.8 : 0.85 + Math.min(0.15, ((chartMs + 650) / 150) * 0.15); + const ready: TearStrip = { cx: DESIGN_WIDTH / 2, cy, angle: -0.12, length: 900, thickness: 112, seed: 5 }; + const readyOpen = drawTearStrip(graphics, ready, readyT, chartMs); + if (readyOpen > 0.05 && chartMs < -650) { + // A nervous little shake while waiting. + const shake = Math.sin(chartMs / 28) * 1.4; + const at = stripPoint(ready, shake, 0); + addRansomText(graphics, glyph, 'READY?', at.x, at.y, { + size: 54 * Math.min(1, readyOpen), + seed: 11, + angle: ready.angle, + appear: (index) => stageProgress(chartMs, -2700 + index * 70, 180), + }); + } + if (chartMs < -650) return; + // GO!!: a second rip tears the other way with a gold slam on a turning burst. + const go: TearStrip = { cx: DESIGN_WIDTH / 2, cy, angle: 0.1, length: 900, thickness: 128, seed: 9 }; + const goT = (chartMs + 650) / 1100; + const goOpen = drawTearStrip(graphics, go, goT, chartMs); + if (goOpen <= 0.02) return; + const slam = easeOutBack(stageProgress(chartMs, -650, 280)); + const scale = (2.2 - 1.2 * slam) * Math.min(1, goOpen); + graphics + .poly(starburstPoints(DESIGN_WIDTH / 2, cy, 150 * scale, 96 * scale, 16, chartMs / 900, 0.18, 7)) + .fill({ color: PHANTOM_RED, alpha: 0.9 }); + const text = addHudText( + layer, + 'GO!!', + DESIGN_WIDTH / 2, + cy, + { + size: 88, + fill: PHANTOM_GOLD, + fontFamily: DEFAULT_DISPLAY_FONT, + letterSpacing: 4, + anchorX: 0.5, + anchorY: 0.5, + skewX: SKEW, + rotation: go.angle, + stroke: { color: PHANTOM_INK, width: 8, alignment: 0.5, join: 'miter' }, + dropShadow: { color: PHANTOM_WHITE, alpha: 1, blur: 0, distance: 6, angle: Math.PI / 4 }, + }, + pool, + ); + text.scale.set(scale); +} + +/** Pooled (or fresh) HUD glyphs for ransom-note lettering. */ +function hudGlyphs(layer: Container, pool: ChildPool | undefined): GlyphFactory { + return (char, options) => + addHudText( + layer, + char, + 0, + 0, + { size: options.size, weight: options.weight, fill: options.fill, fontFamily: options.fontFamily }, + pool, + ); +} + +/** 100 COMBO!! — a torn strip rips in over the BGA side with the count in a gold burst. */ +function drawMilestone( + graphics: Graphics, + layer: Container, + value: number, + t: number, + hasBga: boolean, + pool: ChildPool | undefined, + nowMs: number, + layout: BeMusicGameplayLayout, +): void { + // Never over the lanes: the strip lives in the column right of them (riding the monitor's bottom edge over a live + // BGA), or in the band under them when double play leaves no room — and it slides in from the right. + const area = resolveMilestoneArea(layout.playfield.right, 346, layout.stage.width); + const bga = layout.bga; + if (!bga) hasBga = false; + const compact = hasBga || area.mode === 'below'; + const strip: TearStrip = + area.mode === 'below' + ? { cx: area.x + area.w / 2, cy: area.y + area.h / 2, angle: -0.03, length: area.w, thickness: 56, seed: value } + : { + cx: area.x + area.w / 2, + cy: hasBga && bga ? bga.y + bga.h - 14 : area.y + 160, + angle: hasBga ? -0.07 : -0.1, + length: area.w - 6, + thickness: hasBga ? 54 : 80, + seed: value, + }; + const open = drawTearStrip(graphics, strip, t, nowMs, 'right'); + if (open <= 0.05) return; + const size = Math.min(1, open); + // Burst and word sit inside the strip whatever its length: the burst near the leading end, the word centred in what + // is left, shrunk to fit a short strip. + const burstU = Math.max(-128, -strip.length / 2 + 46); + const burst = stripPoint(strip, burstU, 0); + graphics + .poly( + starburstPoints( + burst.x, + burst.y, + 50 * size * (compact ? 0.8 : 1), + 32 * size * (compact ? 0.8 : 1), + 12, + t * 3, + 0.22, + value, + ), + ) + .fill(PHANTOM_GOLD) + .stroke({ color: PHANTOM_INK, width: 3 }); + addHudText( + layer, + String(value), + burst.x + 2, + burst.y, + { + size: compact ? 28 : 34, + fill: PHANTOM_INK, + fontFamily: DEFAULT_DISPLAY_FONT, + anchorX: 0.5, + anchorY: 0.5, + skewX: SKEW, + rotation: strip.angle, + maxWidth: 70, + }, + pool, + ).scale.y *= size; + const wordStart = burstU + 50; + const wordEnd = Math.min(strip.length / 2 - 8, burstU + 330); + const word = stripPoint(strip, (wordStart + wordEnd) / 2, 0); + // Seven ransom cards run about 5.2 × the size wide. + const wordSize = Math.min(compact ? 30 : 40, (wordEnd - wordStart) / 5.2); + addRansomText(graphics, hudGlyphs(layer, pool), 'COMBO!!', word.x, word.y, { + size: wordSize * size, + seed: value + 3, + angle: strip.angle, + appear: (index) => Math.max(0, (t - 0.04 - index * 0.025) / 0.08), + }); +} + +/** Gauge crossing the clear line: a flash over the gauge and a CLEAR LINE tag popping off it. */ +function drawClear(graphics: Graphics, layer: Container, t: number, pool: ChildPool | undefined): void { + const flash = Math.max(0, 1 - t * 3); + graphics + .rect(GROOVE.x - 6, GROOVE.y - 4, GROOVE.w + 12, GROOVE.h + 8) + .fill({ color: PHANTOM_WHITE, alpha: 0.85 * flash }); + const pop = easeOutBack(Math.min(1, t / 0.2)); + const lift = easeOutCubic(t) * 10; + const alpha = 1 - Math.max(0, (t - 0.75) / 0.25); + // Beside the gauge housing's right end, clear of the playfield frame above it. + const cx = GROOVE.x + GROOVE.w + 80; + const cy = GROOVE.y - 6 - lift; + graphics + .poly(parallelogramPoints(cx - 62 * pop, cy - 14 * pop, 124 * pop, 28 * pop, 8 * pop)) + .fill({ color: PHANTOM_GOLD, alpha }) + .stroke({ color: PHANTOM_INK, width: 2, alpha }); + const text = addHudText( + layer, + 'CLEAR LINE', + cx + 3, + cy, + { + size: 17, + fill: PHANTOM_INK, + fontFamily: DEFAULT_DISPLAY_FONT, + letterSpacing: 2, + anchorX: 0.5, + anchorY: 0.5, + skewX: SKEW, + }, + pool, + ); + text.scale.set(pop); + text.alpha = alpha; +} + +/** Full combo: flash, turning burst, confetti, and the stamp. */ +function drawFullCombo( + graphics: Graphics, + layer: Container, + t: number, + nowMs: number, + screenWide: boolean, + pool: ChildPool | undefined, +): void { + const cx = DESIGN_WIDTH / 2; + const cy = DESIGN_HEIGHT / 2; + const fadeOut = 1 - Math.max(0, (t - 0.82) / 0.18); + const flash = screenWide ? Math.max(0, 1 - t * 7) : 0; + if (flash > 0) { + graphics.rect(0, 0, DESIGN_WIDTH, DESIGN_HEIGHT).fill({ color: PHANTOM_WHITE, alpha: 0.9 * flash }); + } + const grow = easeOutCubic(Math.min(1, t / 0.25)); + graphics + .poly(starburstPoints(cx, cy, 330 * grow, 210 * grow, 20, nowMs / 1400, 0.16, 5)) + .fill({ color: PHANTOM_RED, alpha: 0.92 * fadeOut }); + graphics + .poly(starburstPoints(cx, cy, 250 * grow, 170 * grow, 20, -nowMs / 1100, 0.2, 9)) + .fill({ color: PHANTOM_INK, alpha: 0.85 * fadeOut }); + // Confetti: paper, red, and gold slips thrown out of the centre, tumbling as they fall. + const seconds = t * (FULL_COMBO_MS / 1000); + for (let index = 0; index < 46; index += 1) { + const angle = hash01(index + 3) * Math.PI * 2; + const speed = 160 + hash01(index + 7) * 260; + const x = cx + Math.cos(angle) * speed * seconds; + const y = cy + Math.sin(angle) * speed * seconds * 0.8 + 120 * seconds * seconds; + const spin = seconds * (4 + hash01(index + 11) * 6); + const size = 5 + hash01(index + 13) * 7; + const color = [PHANTOM_PAPER, PHANTOM_RED_HOT, PHANTOM_GOLD][index % 3]!; + graphics.poly(rotatedRect(x, y, size, size * 0.55, spin)).fill({ color, alpha: fadeOut }); + } + // The rip: a screen-wide torn strip carrying FULL COMBO cut from magazines. + const strip: TearStrip = { cx, cy, angle: -0.1, length: 980, thickness: 132, seed: 21 }; + const open = drawTearStrip(graphics, strip, Math.max(0, (t - 0.05) / 0.95), nowMs); + if (open > 0.05) { + addRansomText(graphics, hudGlyphs(layer, pool), 'FULL COMBO', cx, cy, { + size: 60 * Math.min(1, open), + seed: 31, + angle: strip.angle, + appear: (index) => Math.max(0, (t - 0.1 - index * 0.022) / 0.06), + }); + } +} + +function rotatedRect(cx: number, cy: number, w: number, h: number, angle: number): number[] { + const cos = Math.cos(angle); + const sin = Math.sin(angle); + const points: number[] = []; + for (const [dx, dy] of [ + [-w / 2, -h / 2], + [w / 2, -h / 2], + [w / 2, h / 2], + [-w / 2, h / 2], + ] as const) { + points.push(cx + dx * cos - dy * sin, cy + dx * sin + dy * cos); + } + return points; +} diff --git a/packages/player-web/src/scene/default/phantom/playfield.ts b/packages/player-web/src/skins/phantom/playfield.ts similarity index 68% rename from packages/player-web/src/scene/default/phantom/playfield.ts rename to packages/player-web/src/skins/phantom/playfield.ts index 1e72459b..b8fca58e 100644 --- a/packages/player-web/src/scene/default/phantom/playfield.ts +++ b/packages/player-web/src/skins/phantom/playfield.ts @@ -1,11 +1,12 @@ import { Color, FillGradient, type Graphics } from 'pixi.js'; -import type { - BeMusicBombsContext, - BeMusicLaneKind, - BeMusicLanesContext, - BeMusicLongNoteContext, - BeMusicNoteContext, -} from '../../../skin/be-music/types.ts'; +import { type BeMusicLaneKind, resolveLaneRuns } from '@be-music/skin-sdk'; +import { + type PixiBombsContext, + type PixiLanesContext, + type PixiLongNoteContext, + type PixiNoteContext, + keyBeamGradient, +} from '../pixi-kit/index.ts'; /** Colours for one lane class: note highlight / body / shade, and the key cap at rest / pressed. */ interface LaneTone { @@ -27,33 +28,40 @@ const TONES: Record = { const NOTE_HEIGHT = 10; export const PHANTOM_BOMB_DURATION_MS = 150; -export function renderPhantomLanes({ graphics, lanes, beatPhase }: BeMusicLanesContext): void { +/** Laser colour per lane: a cool white, an electric blue, and the poster red. */ +const BEAM_COLORS: Record = { white: 0xe8ecff, black: 0x2f7bff, scratch: 0xff1430 }; + +/** Bed of the white-key lanes, a step lighter than the ink well the other lanes show. */ +const WHITE_LANE_BED = 0x18181d; + +export function renderPhantomLanes({ graphics, lanes, beatPhase }: PixiLanesContext): void { const beatDecay = 1 - beatPhase; let gridTop = Number.POSITIVE_INFINITY; let gridBottom = 0; - let gridRight = 0; for (const lane of lanes) { const { x, w, top, bottom } = lane; const tone = TONES[lane.kind]; const laneHeight = Math.max(1, bottom - top); gridTop = Math.min(gridTop, top); gridBottom = Math.max(gridBottom, bottom); - gridRight = Math.max(gridRight, x + w); - // Lane bed — a faint wash of the lane's colour, plus a charcoal hairline on the left edge. - graphics.rect(x, top, w, laneHeight).fill({ color: tone.body, alpha: lane.kind === 'scratch' ? 0.06 : 0.035 }); + // Lane bed — white-key lanes sit on a lifted charcoal; black keys and the scratch share the same faint blue wash. + // A charcoal hairline marks the left edge. + if (lane.kind === 'white') graphics.rect(x, top, w, laneHeight).fill(WHITE_LANE_BED); + else graphics.rect(x, top, w, laneHeight).fill({ color: TONES.black.body, alpha: 0.035 }); graphics.rect(x, top, 1, laneHeight).fill({ color: 0x2c2c31, alpha: 0.9 }); - // Key beam — a smooth gradient in the lane's colour fading toward the top, with a soft white base. Ease the release - // so the beam dims quickly at first and lingers softly instead of fading linearly. + // Key beam — a laser in the lane's colour, near solid at the judgement line and easing out up the lane, over a short + // white-hot base. Ease the release so the beam dims quickly at first and lingers softly instead of fading linearly. if (lane.beam > 0) { - const beamHeight = Math.min(laneHeight, 190); + const beamHeight = Math.min(laneHeight, 250); const beamAlpha = lane.beam * lane.beam * (3 - 2 * lane.beam); graphics .rect(x + 1, bottom - beamHeight, w - 2, beamHeight) - .fill({ fill: resolveBeamGradient(tone.body), alpha: beamAlpha }); + // White notes fall through white beams, so that laser runs a little softer. + .fill({ fill: keyBeamGradient(BEAM_COLORS[lane.kind]), alpha: beamAlpha * (lane.kind === 'white' ? 0.7 : 1) }); graphics - .rect(x + 1, bottom - 22, w - 2, 22) + .rect(x + 1, bottom - 24, w - 2, 24) .fill({ fill: resolveBeamGradient(0xffffff), alpha: beamAlpha * 0.7 }); } @@ -73,32 +81,32 @@ export function renderPhantomLanes({ graphics, lanes, beatPhase }: BeMusicLanesC graphics.rect(x + 1, capTop - 2, capW, 2).fill({ color: 0xffffff, alpha: 0.95 }); } } - // Close the grid on the right-most lane — each lane only draws its LEFT hairline. - if (lanes.length > 0) { - graphics.rect(gridRight - 1, gridTop, 1, Math.max(1, gridBottom - gridTop)).fill({ color: 0x2c2c31, alpha: 0.9 }); + // Close each play side's grid on its right edge — each lane only draws its LEFT hairline. + for (const run of resolveLaneRuns(lanes)) { + graphics.rect(run.right - 1, gridTop, 1, Math.max(1, gridBottom - gridTop)).fill({ color: 0x2c2c31, alpha: 0.9 }); } } -export function renderPhantomNote({ graphics, kind, x, w, y }: BeMusicNoteContext): void { - drawNoteBody(graphics, x + 1, y, Math.max(4, w - 2), TONES[kind]); +export function renderPhantomNote({ graphics, kind, x, w, y }: PixiNoteContext): void { + drawNoteBody(graphics, x, y, Math.max(4, w), TONES[kind]); } -export function renderPhantomLongNote({ graphics, kind, x, w, top, bottom }: BeMusicLongNoteContext): void { +export function renderPhantomLongNote({ graphics, kind, x, w, top, bottom }: PixiLongNoteContext): void { const tone = TONES[kind]; - const bodyX = x + 1; - const bodyW = Math.max(4, w - 2); + const bodyX = x; + const bodyW = Math.max(4, w); const bodyTop = top - NOTE_HEIGHT; const bodyH = Math.max(1, bottom - top); - // Translucent core with solid side rails — reads as "hold the lane", not a solid wall of colour. - graphics.rect(bodyX + 1, bodyTop, bodyW - 2, bodyH).fill({ color: tone.body, alpha: 0.35 }); - graphics.rect(bodyX, bodyTop, 2, bodyH).fill({ color: tone.body, alpha: 0.9 }); - graphics.rect(bodyX + bodyW - 2, bodyTop, 2, bodyH).fill({ color: tone.body, alpha: 0.9 }); + // The shared long-note shape (a translucent beam with a centre filament between the head and tail bars), in poster + // tones: a denser ink of the lane colour and a paper-white filament. + graphics.rect(bodyX + 1, bodyTop, bodyW - 2, bodyH).fill({ color: tone.body, alpha: 0.3 }); + graphics.rect(bodyX + bodyW / 2 - 1, bodyTop, 2, bodyH).fill({ color: tone.top, alpha: 0.8 }); drawNoteBody(graphics, bodyX, bottom, bodyW, tone); drawNoteBody(graphics, bodyX, top, bodyW, tone); } /** Plain IIDX-style hit flash: an expanding ring over a white core. */ -export function renderPhantomBombs({ pool, bombs }: BeMusicBombsContext): void { +export function renderPhantomBombs({ pool, bombs }: PixiBombsContext): void { for (const bomb of bombs) { const progress = Math.max(0, Math.min(1, bomb.elapsedMs / PHANTOM_BOMB_DURATION_MS)); const eased = 1 - (1 - progress) * (1 - progress); diff --git a/packages/player-web/src/scene/default/phantom/result.ts b/packages/player-web/src/skins/phantom/result.ts similarity index 53% rename from packages/player-web/src/scene/default/phantom/result.ts rename to packages/player-web/src/skins/phantom/result.ts index 1ae74d2c..cecc07ce 100644 --- a/packages/player-web/src/scene/default/phantom/result.ts +++ b/packages/player-web/src/skins/phantom/result.ts @@ -1,7 +1,7 @@ -import { Container, Graphics, Text, TextStyle, type Color } from 'pixi.js'; -import type { BeMusicResultFrame, BeMusicResultSkin } from '../../../skin/be-music/types.ts'; -import { DEFAULT_DISPLAY_FONT, DEFAULT_HEADLINE_FONT, DEFAULT_TEXT_FONT } from '../fonts.ts'; +import { Container, Graphics, Text, type Color } from 'pixi.js'; +import { DEFAULT_DISPLAY_FONT, DEFAULT_HEADLINE_FONT } from './fonts.ts'; import { + PHANTOM_ASH, PHANTOM_BLACK, PHANTOM_CHARCOAL, PHANTOM_CYAN, @@ -13,29 +13,68 @@ import { PHANTOM_RED_HOT, PHANTOM_SLATE, PHANTOM_WHITE, + drawNoteEmblem, + halftoneField, + parallelogramPoints, + starburstPoints, + tornEdgePoints, +} from './style.ts'; +import { addRansomText, type GlyphFactory } from './tear.ts'; +import { easeOutBack, easeOutCubic, - halftoneField, + DEFAULT_TEXT_FONT, hash01, - parallelogramPoints, + resolveResultLamp, + resolveResultTrackRows, rollUpValue, stageProgress, - starburstPoints, -} from '../phantom-style.ts'; +} from '@be-music/skin-sdk'; +import { alignCapCenter, type PixiResultFrame, type PixiResultSkin, resolveSkinTextStyle } from '../pixi-kit/index.ts'; +/** How fast the entrance timeline plays: the choreography below is authored at 1x and runs 1.3x, so the card settles + * sooner without changing its order or easing. */ +const ENTRANCE_PACE = 1.3; /** Default-family result entrance timeline (ms from scene start): counters roll up, then the rank badge lands. */ const RESULT_ROLL_DELAY_MS = 760; const RESULT_ROLL_MS = 1000; const RESULT_RANK_DELAY_MS = 1500; -export const phantomResultSkin: BeMusicResultSkin = { render: (frame) => renderPhantomResult(frame) }; +/** + * Result grid (design px): a 16 px page margin and 12 px gutters. The top row holds the rank panel and two columns of + * metric plates (40 px each with their shadow, 16 px apart); the bottom row holds the judgement tally and the run + * charts, whose rows share one top and bottom line. + */ +const RESULT_MARGIN = 16; +const RESULT_TOP = 64; +const RESULT_TOP_ROW_H = 152; +const RESULT_BOTTOM_TOP = 232; +const RESULT_BOTTOM_H = 200; +const RESULT_ROWS_TOP = RESULT_BOTTOM_TOP + 26; +const RESULT_ROWS_BOTTOM = RESULT_ROWS_TOP + 4 * 32 + 20; +const RANK_CX = RESULT_MARGIN + 86; +const RANK_CY = RESULT_TOP + 62; +const METRIC_W = 192; +const METRIC_H = 36; +const METRIC_COLUMNS = [204, 420] as const; +const METRIC_ROWS = [RESULT_TOP, RESULT_TOP + 56, RESULT_TOP + 112] as const; +/** + * Track column on the right of the 16:9 grid: from one gutter after the run panel to the right margin (its slanted + * bottom edge included), spanning the top row's top to the bottom row's bottom. + */ +const TRACK_X = 324 + 292 + 8 + 16; +const TRACK_SLANT = 8; + +export const phantomResultSkin: PixiResultSkin = { render: (frame) => renderPhantomResult(frame) }; /** * Phantom result: an entrance timeline (red slash sweep, verdict slam, staggered plates, counter roll-up, growing * bars, graphs drawing in, rank stamp) over ambient loops (drifting halftone, scrolling kicker, streaks, rank pulse). */ -export function renderPhantomResult(frame: BeMusicResultFrame): void { - const { result, designWidth, designHeight, elapsedMs: elapsed, nowMs: now, rankLabel } = frame; +export function renderPhantomResult(frame: PixiResultFrame): void { + const { result, designWidth, designHeight, nowMs: now, rankLabel } = frame; + // Effects off: skip the entrance and render the settled card. + const elapsed = frame.effects === 'off' ? Number.POSITIVE_INFINITY : frame.elapsedMs * ENTRANCE_PACE; const rate = frame.ratePercent; const seconds = now / 1000; const layer = frame.layer; @@ -69,16 +108,17 @@ export function renderPhantomResult(frame: BeMusicResultFrame): void { ): Text => { const node = new Text({ text, - style: new TextStyle({ + style: resolveSkinTextStyle({ fill: options.fill ?? PHANTOM_WHITE, - fontSize: options.size ?? 10, - fontWeight: options.weight ?? '500', + size: options.size ?? 10, + weight: options.weight ?? '500', fontFamily: options.fontFamily ?? DEFAULT_TEXT_FONT, letterSpacing: options.letterSpacing ?? 0, stroke: options.stroke, }), }); node.anchor.set(options.anchorX ?? 0, options.anchorY ?? 0); + alignCapCenter(node, options.fontFamily ?? DEFAULT_TEXT_FONT, options.weight ?? '500', options.size ?? 10); node.position.set(x, y); node.skew.set(options.skew ?? 0, 0); if (options.maxWidth !== undefined && node.width > options.maxWidth) { @@ -139,9 +179,11 @@ export function renderPhantomResult(frame: BeMusicResultFrame): void { })) { const dx = dot.x + drift; if ((dx - 392) * (designHeight - 48) + (dot.y - 48) * (392 - 196) > 0 && dx <= designWidth) { - slash.g.circle(dx, dot.y, dot.r).fill({ color: PHANTOM_INK, alpha: 0.5 }); + slash.g.circle(dx, dot.y, dot.r); } } + // One fill for the whole field: the dots never overlap, so this matches filling them one by one. + slash.g.fill({ color: PHANTOM_INK, alpha: 0.5 }); slash.g.poly([380, 48, 386, 48, 190, designHeight, 184, designHeight]).fill(PHANTOM_WHITE); slideIn(slash.root, 0, 460, 0, 380); slash.root.alpha = 1; @@ -169,25 +211,10 @@ export function renderPhantomResult(frame: BeMusicResultFrame): void { teeth.push(designWidth + 20, 46); header.g.poly(teeth).fill(PHANTOM_RED); header.g.rect(0, 45, designWidth, 1).fill(PHANTOM_WHITE); - const title = group('title'); - addText( - title.root, - `${result.song.title}${result.song.artist ? ` / ${result.song.artist}` : ''}`, - designWidth - 18, - 16, - { - size: 13, - fill: PHANTOM_WHITE, - fontFamily: DEFAULT_HEADLINE_FONT, - anchorX: 1, - maxWidth: 420, - }, - ); - slideIn(title.root, 260, 120, 0); const verdict = group('verdict'); - verdict.g.poly(parallelogramPoints(18, 12, 150, 28, 10)).fill(cleared ? PHANTOM_WHITE : PHANTOM_RED); - verdict.g.poly(parallelogramPoints(12, 7, 150, 28, 10)).fill(cleared ? PHANTOM_RED : PHANTOM_WHITE); - addText(verdict.root, cleared ? 'STAGE CLEAR' : 'FAILED', 92, 21, { + verdict.g.poly(parallelogramPoints(22, 12, 150, 28, 10)).fill(cleared ? PHANTOM_WHITE : PHANTOM_RED); + verdict.g.poly(parallelogramPoints(RESULT_MARGIN, 7, 150, 28, 10)).fill(cleared ? PHANTOM_RED : PHANTOM_WHITE); + addText(verdict.root, cleared ? 'STAGE CLEAR' : 'FAILED', 96, 21, { ...display(20, cleared ? PHANTOM_WHITE : PHANTOM_RED), letterSpacing: 1.5, anchorX: 0.5, @@ -197,49 +224,52 @@ export function renderPhantomResult(frame: BeMusicResultFrame): void { const glint = (seconds % 3.2) / 0.45; if (elapsed > 800 && glint <= 1) { verdict.g - .poly(parallelogramPoints(12 + glint * 136, 7, 12, 28, 10)) + .poly(parallelogramPoints(RESULT_MARGIN + glint * 136, 7, 12, 28, 10)) .fill({ color: cleared ? PHANTOM_WHITE : PHANTOM_RED, alpha: 0.55 }); } - slam(verdict.root, 120, 88, 21, 2.6); + drawNoteEmblem(verdict.g, 194, 29, 24, PHANTOM_WHITE, PHANTOM_RED); + slam(verdict.root, 120, 92, 21, 2.6); // Rank panel slides in from the left; the burst pops, then the letter stamps down on it. const topRank = rankLabel === 'AAA' || rankLabel === 'AA'; const rankPanel = group('rank-panel'); rankPanel.g - .poly(parallelogramPoints(18, 72, 172, 148, -8)) + .poly(parallelogramPoints(RESULT_MARGIN, RESULT_TOP, 168, RESULT_TOP_ROW_H, -8)) .fill(PHANTOM_INK) .stroke({ color: PHANTOM_WHITE, width: 2 }); - rankPanel.g.poly(parallelogramPoints(26, 66, 56, 16, 6)).fill(PHANTOM_RED); - addText(rankPanel.root, 'RANK', 38, 74, { ...display(11, PHANTOM_WHITE), anchorY: 0.5 }); + rankPanel.g.poly(parallelogramPoints(RESULT_MARGIN + 8, RESULT_TOP - 6, 56, 16, 6)).fill(PHANTOM_RED); + addText(rankPanel.root, 'RANK', RESULT_MARGIN + 20, RESULT_TOP + 2, { ...display(11, PHANTOM_WHITE), anchorY: 0.5 }); slideIn(rankPanel.root, 280, -220, 0); const burst = group('rank-burst'); // Ambient: a slow wobble plus a heartbeat pulse keeps the badge alive after it lands. const heartbeat = (1 - ((seconds * 1.6) % 1)) ** 4; burst.g - .poly(starburstPoints(106, 134, 60, 44, 18, seconds * 0.35, 0.15, 11)) + .poly(starburstPoints(RANK_CX, RANK_CY, 60, 44, 18, seconds * 0.35, 0.15, 11)) .fill({ color: topRank ? PHANTOM_RED : PHANTOM_RED_DEEP, alpha: 0.9 }); burst.g - .poly(starburstPoints(106, 134, 50, 33, 13, -0.15 + Math.sin(seconds * 1.3) * 0.06, 0.2, rankLabel.length + 3)) + .poly( + starburstPoints(RANK_CX, RANK_CY, 50, 33, 13, -0.15 + Math.sin(seconds * 1.3) * 0.06, 0.2, rankLabel.length + 3), + ) .fill(topRank ? PHANTOM_GOLD : PHANTOM_RED) .stroke({ color: PHANTOM_WHITE, width: 2, join: 'miter' }); - slam(burst.root, RESULT_RANK_DELAY_MS, 106, 134, 0, 420); + slam(burst.root, RESULT_RANK_DELAY_MS, RANK_CX, RANK_CY, 0, 420); if (stageProgress(elapsed, RESULT_RANK_DELAY_MS, 420) >= 1) { burst.root.scale.set(1 + 0.05 * heartbeat); } const letter = group('rank-letter'); - addText(letter.root, rankLabel, 108, 134, { + addText(letter.root, rankLabel, RANK_CX + 2, RANK_CY, { ...display(rankLabel.length >= 3 ? 34 : 50, topRank ? PHANTOM_INK : PHANTOM_WHITE), anchorX: 0.5, anchorY: 0.5, stroke: { color: topRank ? PHANTOM_WHITE : PHANTOM_INK, width: 4, alignment: 0.5, join: 'miter' }, maxWidth: 96, }); - slam(letter.root, RESULT_RANK_DELAY_MS + 220, 106, 134, 2.4, 300); + slam(letter.root, RESULT_RANK_DELAY_MS + 220, RANK_CX, RANK_CY, 2.4, 300); // The rate sits in front of the badge so the pulsing burst never covers it. const rateGroup = group('rank-rate'); const shownRate = rate * easeOutCubic(roll); - addText(rateGroup.root, `${shownRate.toFixed(1)}%`, 104, 202, { + addText(rateGroup.root, `${shownRate.toFixed(1)}%`, RANK_CX - 2, RESULT_TOP + RESULT_TOP_ROW_H - 18, { ...display(18, PHANTOM_WHITE), anchorX: 0.5, anchorY: 0.5, @@ -248,12 +278,24 @@ export function renderPhantomResult(frame: BeMusicResultFrame): void { // Metric plates fly in from the right one after another; their values roll up. const metrics: ReadonlyArray = [ - [228, 80, 'SCORE', String(rollUpValue(result.score.score, roll)), PHANTOM_WHITE], - [228, 126, 'EX SCORE', `${rollUpValue(result.score.exScore, roll)} / ${exMax}`, PHANTOM_WHITE], - [228, 172, 'MAX COMBO', String(rollUpValue(result.maxCombo, roll)), PHANTOM_GOLD], - [434, 80, 'GAUGE', `${rollUpValue(Math.round(result.gauge), roll)}%`, statusColor], - [434, 126, 'PLAY TIME', `${(result.playSeconds * easeOutCubic(roll)).toFixed(1)}s`, PHANTOM_WHITE], - [434, 172, 'NOTES', String(rollUpValue(result.score.total, roll)), PHANTOM_WHITE], + [METRIC_COLUMNS[0], METRIC_ROWS[0], 'SCORE', String(rollUpValue(result.score.score, roll)), PHANTOM_WHITE], + [ + METRIC_COLUMNS[0], + METRIC_ROWS[1], + 'EX SCORE', + `${rollUpValue(result.score.exScore, roll)} / ${exMax}`, + PHANTOM_WHITE, + ], + [METRIC_COLUMNS[0], METRIC_ROWS[2], 'MAX COMBO', String(rollUpValue(result.maxCombo, roll)), PHANTOM_GOLD], + [METRIC_COLUMNS[1], METRIC_ROWS[0], 'GAUGE', `${rollUpValue(Math.round(result.gauge), roll)}%`, statusColor], + [ + METRIC_COLUMNS[1], + METRIC_ROWS[1], + 'PLAY TIME', + `${(result.playSeconds * easeOutCubic(roll)).toFixed(1)}s`, + PHANTOM_WHITE, + ], + [METRIC_COLUMNS[1], METRIC_ROWS[2], 'NOTES', String(rollUpValue(result.score.total, roll)), PHANTOM_WHITE], ]; metrics.forEach(([x, y, label, value, fill], index) => { const plate = group(`metric-${index}`); @@ -264,11 +306,15 @@ export function renderPhantomResult(frame: BeMusicResultFrame): void { // Judgement tally — ransom-note chips; each row slides in, then its count bar grows while the number rolls up. const judgePanel = group('judgement'); judgePanel.g - .poly(parallelogramPoints(18, 246, 286, 180, -8)) + .poly(parallelogramPoints(RESULT_MARGIN, RESULT_BOTTOM_TOP, 288, RESULT_BOTTOM_H, -8)) .fill(PHANTOM_INK) .stroke({ color: PHANTOM_WHITE, width: 2 }); - judgePanel.g.poly(parallelogramPoints(26, 240, 96, 16, 6)).fill(PHANTOM_RED); - addText(judgePanel.root, 'JUDGEMENT', 38, 248, { ...display(11, PHANTOM_WHITE), letterSpacing: 1, anchorY: 0.5 }); + judgePanel.g.poly(parallelogramPoints(RESULT_MARGIN + 8, RESULT_BOTTOM_TOP - 6, 96, 16, 6)).fill(PHANTOM_RED); + addText(judgePanel.root, 'JUDGEMENT', RESULT_MARGIN + 20, RESULT_BOTTOM_TOP + 2, { + ...display(11, PHANTOM_WHITE), + letterSpacing: 1, + anchorY: 0.5, + }); slideIn(judgePanel.root, 560, -260, 0); const judges: Array = [ ['PGREAT', result.score.perfect, PHANTOM_WHITE], @@ -279,87 +325,192 @@ export function renderPhantomResult(frame: BeMusicResultFrame): void { ]; const judgeTotal = Math.max(1, result.score.total); for (let i = 0; i < judges.length; i += 1) { - const jy = 270 + i * 29; + const jy = RESULT_ROWS_TOP + i * 32; const [label, count, fill] = judges[i]!; const inverted = i % 2 === 1; const row = group(`judge-${i}`); - row.g.poly(parallelogramPoints(34, jy, 70, 20, inverted ? -5 : 5)).fill(inverted ? PHANTOM_WHITE : PHANTOM_RED); - addText(row.root, label, 70, jy + 10, { + row.g.poly(parallelogramPoints(32, jy, 70, 20, inverted ? -5 : 5)).fill(inverted ? PHANTOM_WHITE : PHANTOM_RED); + addText(row.root, label, 68, jy + 10, { ...display(13, inverted ? PHANTOM_INK : PHANTOM_WHITE), anchorX: 0.5, anchorY: 0.5, maxWidth: 60, }); - row.g.poly(parallelogramPoints(112, jy + 14, 140, 4, 3)).fill(PHANTOM_SLATE); + row.g.poly(parallelogramPoints(110, jy + 14, 144, 4, 3)).fill(PHANTOM_SLATE); const barProgress = easeOutCubic(stageProgress(elapsed, RESULT_ROLL_DELAY_MS + i * 60, RESULT_ROLL_MS)); if (count > 0 && barProgress > 0) { row.g .poly( parallelogramPoints( - 112, + 110, jy + 14, - Math.max(3, ((140 * Math.min(count, judgeTotal)) / judgeTotal) * barProgress), + Math.max(3, ((144 * Math.min(count, judgeTotal)) / judgeTotal) * barProgress), 4, 3, ), ) .fill(fill); } - addText(row.root, String(rollUpValue(count, roll)), 282, jy - 2, { ...display(20, fill), anchorX: 1 }); + addText(row.root, String(rollUpValue(count, roll)), 296, jy - 2, { ...display(20, fill), anchorX: 1 }); slideIn(row.root, 640 + i * 60, -200, 0, 280); } // Run graphs rise in from below and draw left to right. const run = group('run'); run.g - .poly(parallelogramPoints(324, 246, 298, 180, -8)) + .poly(parallelogramPoints(324, RESULT_BOTTOM_TOP, 292, RESULT_BOTTOM_H, -8)) .fill(PHANTOM_INK) .stroke({ color: PHANTOM_WHITE, width: 2 }); - run.g.poly(parallelogramPoints(332, 240, 56, 16, 6)).fill(PHANTOM_RED); - addText(run.root, 'RUN', 346, 248, { ...display(11, PHANTOM_WHITE), letterSpacing: 1, anchorY: 0.5 }); - run.g.rect(342, 272, 264, 62).fill(PHANTOM_CHARCOAL); - run.g.rect(342, 346, 264, 62).fill(PHANTOM_CHARCOAL); - addText(run.root, 'GAUGE', 352, 278, { ...display(10, PHANTOM_RED_HOT), letterSpacing: 1 }); - addText(run.root, 'EX SCORE', 352, 352, { ...display(10, PHANTOM_RED_HOT), letterSpacing: 1 }); + run.g.poly(parallelogramPoints(332, RESULT_BOTTOM_TOP - 6, 56, 16, 6)).fill(PHANTOM_RED); + addText(run.root, 'RUN', 344, RESULT_BOTTOM_TOP + 2, { + ...display(11, PHANTOM_WHITE), + letterSpacing: 1, + anchorY: 0.5, + }); + // Two charts sharing the judgement rows' top and bottom lines. + const chartH = (RESULT_ROWS_BOTTOM - RESULT_ROWS_TOP - 12) / 2; + const chartYs = [RESULT_ROWS_TOP, RESULT_ROWS_TOP + chartH + 12] as const; + run.g.rect(340, chartYs[0], 268, chartH).fill(PHANTOM_CHARCOAL); + run.g.rect(340, chartYs[1], 268, chartH).fill(PHANTOM_CHARCOAL); + addText(run.root, 'GAUGE', 350, chartYs[0] + 6, { ...display(10, PHANTOM_RED_HOT), letterSpacing: 1 }); + addText(run.root, 'EX SCORE', 350, chartYs[1] + 6, { ...display(10, PHANTOM_RED_HOT), letterSpacing: 1 }); const graphProgress = easeOutCubic(stageProgress(elapsed, RESULT_ROLL_DELAY_MS + 100, RESULT_ROLL_MS + 300)); drawSeries( run.g, - 420, - 282, - 176, - 44, + 418, + chartYs[0] + 10, + 180, + chartH - 18, result.gaugeHistory.map((sample) => ({ x: sample.progress, y: sample.value / 100 })), cleared ? PHANTOM_WHITE : PHANTOM_RED_HOT, graphProgress, ); drawSeries( run.g, - 420, - 356, - 176, - 44, + 418, + chartYs[1] + 10, + 180, + chartH - 18, result.scoreHistory.map((sample) => ({ x: sample.progress, y: exMax > 0 ? sample.exScore / exMax : 0 })), PHANTOM_GOLD, graphProgress, ); slideIn(run.root, 620, 0, 160); + // Track column: the chart's card (title, artist, genre), its facts, and the clear lamp, sliding in from the right. + const track = group('track'); + const trackW = designWidth - RESULT_MARGIN - TRACK_SLANT - TRACK_X; + const trackTop = RESULT_TOP; + const trackBottom = RESULT_BOTTOM_TOP + RESULT_BOTTOM_H; + track.g + .poly(parallelogramPoints(TRACK_X, trackTop, trackW, trackBottom - trackTop, -TRACK_SLANT)) + .fill(PHANTOM_INK) + .stroke({ color: PHANTOM_WHITE, width: 2 }); + track.g.poly(parallelogramPoints(TRACK_X + 8, trackTop - 6, 64, 16, 6)).fill(PHANTOM_RED); + addText(track.root, 'TRACK', TRACK_X + 20, trackTop + 2, { + ...display(11, PHANTOM_WHITE), + letterSpacing: 1, + anchorY: 0.5, + }); + const textX = TRACK_X + 16; + const textW = trackW - 24; + addText(track.root, result.song.title || 'Untitled chart', textX, trackTop + 22, { + size: 16, + fill: PHANTOM_WHITE, + fontFamily: DEFAULT_HEADLINE_FONT, + maxWidth: textW, + }); + addText(track.root, result.song.artist ?? '', textX, trackTop + 46, { + size: 10, + weight: '800', + fill: PHANTOM_RED_HOT, + maxWidth: textW, + }); + if (result.song.genre) { + addText(track.root, result.song.genre, textX, trackTop + 62, { size: 9, fill: PHANTOM_ASH, maxWidth: textW }); + } + track.g.rect(textX, trackTop + 84, textW, 1).fill(PHANTOM_SLATE); + resolveResultTrackRows(result).forEach((row, index) => { + const rowY = trackTop + 96 + index * 36; + addText(track.root, row.label, textX, rowY + 2, { ...display(10, PHANTOM_RED_HOT), letterSpacing: 1 }); + addText(track.root, row.value, textX + textW, rowY + 10, { + ...display(20, PHANTOM_WHITE), + anchorX: 1, + anchorY: 0.5, + maxWidth: textW - 80, + }); + track.g.rect(textX, rowY + 26, textW, 1).fill({ color: PHANTOM_SLATE, alpha: 0.6 }); + }); + // Clear lamp: a slanted plate on the column's foot — gold for PERFECT, paper for FULL COMBO, red for CLEAR. + const lamp = resolveResultLamp(result); + const lampFill = + lamp === 'PERFECT' + ? PHANTOM_GOLD + : lamp === 'FULL COMBO' + ? PHANTOM_WHITE + : lamp === 'CLEAR' + ? PHANTOM_RED + : PHANTOM_BLACK; + const lampInk = lamp === 'CLEAR' ? PHANTOM_WHITE : lamp === 'FAILED' ? PHANTOM_RED_HOT : PHANTOM_INK; + const lampY = trackBottom - 52; + track.g.poly(parallelogramPoints(textX + 4, lampY + 4, textW, 34, 8)).fill(PHANTOM_RED_DEEP); + track.g + .poly(parallelogramPoints(textX, lampY, textW, 34, 8)) + .fill(lampFill) + .stroke({ color: PHANTOM_WHITE, width: 1.5, join: 'miter' }); + addText(track.root, lamp, textX + textW / 2 + 4, lampY + 17, { + ...display(20, lampInk), + letterSpacing: 1.5, + anchorX: 0.5, + anchorY: 0.5, + maxWidth: textW - 24, + }); + slideIn(track.root, 700, 240, 0); + // Footer rises; the total score rolls up with the rest. const footer = group('footer'); footer.g.rect(0, designHeight - 36, designWidth, 36).fill(PHANTOM_INK); footer.g.rect(0, designHeight - 36, designWidth, 2).fill(PHANTOM_WHITE); - footer.g.poly(parallelogramPoints(12, designHeight - 28, 118, 20, 8)).fill(PHANTOM_RED); - addText(footer.root, 'TOTAL SCORE', 72, designHeight - 18, { + footer.g.poly(parallelogramPoints(RESULT_MARGIN, designHeight - 28, 118, 20, 8)).fill(PHANTOM_RED); + addText(footer.root, 'TOTAL SCORE', RESULT_MARGIN + 60, designHeight - 18, { ...display(13, PHANTOM_WHITE), letterSpacing: 1, anchorX: 0.5, anchorY: 0.5, }); - addText(footer.root, String(rollUpValue(result.score.score, roll)), 146, designHeight - 18, { + addText(footer.root, String(rollUpValue(result.score.score, roll)), RESULT_MARGIN + 138, designHeight - 18, { ...display(22, PHANTOM_WHITE), anchorY: 0.5, }); slideIn(footer.root, 420, 0, 50); + + // Entrance wipe: the screen opens behind an ink slab carrying RESULT in ransom-note letters that tears away to the + // right along a ripped red-and-white edge, uncovering the panels as they fly in. + const wipeT = stageProgress(elapsed, 0, 760); + if (wipeT < 1) { + const wipe = group('wipe'); + const eased = wipeT * wipeT * (3 - 2 * wipeT); + const edge = -120 + eased * (designWidth + 360); + const rip = tornEdgePoints(edge + 120, -10, edge, designHeight + 10, 10, 3, 12); + const shifted = (dx: number) => rip.map((value, index) => (index % 2 === 0 ? value + dx : value)); + const region = (dx: number) => [...shifted(dx), designWidth + 200, designHeight + 10, designWidth + 200, -10]; + // Red splash, white rim, then the ink slab — each edge ripped. + wipe.g.poly(region(-34)).fill(PHANTOM_RED); + wipe.g.poly(region(-12)).fill(PHANTOM_WHITE); + wipe.g.poly(region(0)).fill(PHANTOM_INK); + const glyph: GlyphFactory = (char, options) => + addText(wipe.root, char, 0, 0, { + size: options.size, + weight: options.weight, + fill: options.fill, + fontFamily: options.fontFamily, + }); + addRansomText(wipe.g, glyph, 'RESULT', edge + 170 + designWidth / 2, designHeight / 2, { + size: 92, + seed: 7, + angle: -0.08, + }); + } } function renderMetric( @@ -371,35 +522,33 @@ function renderMetric( value: string, fill: number, ): void { - chrome.poly(parallelogramPoints(x + 4, y + 4, 172, 36, 8)).fill(PHANTOM_INK); + chrome.poly(parallelogramPoints(x + 4, y + 4, METRIC_W, METRIC_H, 8)).fill(PHANTOM_INK); chrome - .poly(parallelogramPoints(x, y, 172, 36, 8)) + .poly(parallelogramPoints(x, y, METRIC_W, METRIC_H, 8)) .fill(PHANTOM_BLACK) .stroke({ color: PHANTOM_WHITE, width: 1.5, join: 'miter' }); const labelText = new Text({ text: label, - style: new TextStyle({ + style: resolveSkinTextStyle({ fill: PHANTOM_RED_HOT, - fontSize: 10, + size: 10, fontFamily: DEFAULT_DISPLAY_FONT, letterSpacing: 1, }), }); labelText.skew.set(-0.18, 0); - labelText.position.set(x + 12, y + 5); + alignCapCenter(labelText, DEFAULT_DISPLAY_FONT, '400', 10); + labelText.position.set(x + 14, y + 5); target.addChild(labelText); const valueText = new Text({ text: value, - style: new TextStyle({ - fill, - fontSize: 22, - fontFamily: DEFAULT_DISPLAY_FONT, - }), + style: resolveSkinTextStyle({ fill, size: 22, fontFamily: DEFAULT_DISPLAY_FONT }), }); valueText.skew.set(-0.18, 0); valueText.anchor.set(1, 0.5); - valueText.position.set(x + 166, y + 19); + alignCapCenter(valueText, DEFAULT_DISPLAY_FONT, '400', 22); + valueText.position.set(x + METRIC_W - 8, y + METRIC_H / 2 + 1); if (valueText.width > 100) { valueText.scale.x = 100 / valueText.width; } diff --git a/packages/player-web/src/scene/default/phantom/select.ts b/packages/player-web/src/skins/phantom/select.ts similarity index 57% rename from packages/player-web/src/scene/default/phantom/select.ts rename to packages/player-web/src/skins/phantom/select.ts index 25546fe7..4dbb399c 100644 --- a/packages/player-web/src/scene/default/phantom/select.ts +++ b/packages/player-web/src/skins/phantom/select.ts @@ -1,12 +1,5 @@ import { Container, Graphics } from 'pixi.js'; -import type { BrowserBrowseEntry, BrowserSongEntry } from '../../../collection/types.ts'; -import type { - BeMusicSelectFrame, - BeMusicSelectLayout, - BeMusicSelectRenderer, - BeMusicSelectSkin, -} from '../../../skin/be-music/types.ts'; -import { DEFAULT_DISPLAY_FONT, DEFAULT_HEADLINE_FONT } from '../fonts.ts'; +import { DEFAULT_DISPLAY_FONT, DEFAULT_HEADLINE_FONT } from './fonts.ts'; import { PHANTOM_ASH, PHANTOM_BLACK, @@ -18,18 +11,44 @@ import { PHANTOM_RED_HOT, PHANTOM_SLATE, PHANTOM_WHITE, - easeOutCubic, + drawNoteEmblem, halftoneField, - hash01, parallelogramPoints, - stageProgress, starburstPoints, -} from '../phantom-style.ts'; -import { addHitArea, addSkinText, formatPlayVariantLabel, type SkinTextOptions } from '../skin-text.ts'; + tornEdgePoints, +} from './style.ts'; +import { addRansomText, type GlyphFactory } from './tear.ts'; +import { + easeOutBack, + easeOutCubic, + audioDrive, + bandLevel, + type BeMusicAudioFrame, + type BeMusicSelectLayout, + type BrowserBrowseEntry, + type BrowserSongEntry, + formatPlayVariantLabel, + hash01, + resolveSongRowFacts, + stageProgress, +} from '@be-music/skin-sdk'; +import { + addHitArea, + addSkinText, + type PixiSelectFrame, + type PixiSelectRenderer, + type PixiSelectSkin, + type SkinTextOptions, +} from '../pixi-kit/index.ts'; -const LAYOUT: BeMusicSelectLayout = { listX: 320, listTop: 54, listBottomInset: 26, rowHeight: 28 }; +const LAYOUT: BeMusicSelectLayout = { listX: 320, listTop: 60, listBottomInset: 26, rowHeight: 28 }; const SLIDE_MS = 240; +const OUTRO_MS = 860; const INTRO_STAGGER_MS = 40; +/** Fixed widths of a song row's fact columns (right to left: tempo, length, note count), so they align down the list. */ +const ROW_BPM_W = 78; +const ROW_LENGTH_W = 30; +const ROW_NOTES_W = 74; const KICKER_PITCH = 20; const GLINT_W = 14; /** Authored glint height; scaled to the focused card's height at tick time. */ @@ -46,7 +65,8 @@ function tagStyle(fill: number): SkinTextOptions { return { size: 9, fill, fontFamily: DEFAULT_DISPLAY_FONT, letterSpacing: 1.2, skewX: -0.18 }; } -export const phantomSelectSkin: BeMusicSelectSkin = { +export const phantomSelectSkin: PixiSelectSkin = { + outroMs: OUTRO_MS, layout: LAYOUT, createRenderer: () => new PhantomSelectRenderer(), }; @@ -56,7 +76,7 @@ export const phantomSelectSkin: BeMusicSelectSkin = { * starburst, and speed streaks), a slanted info panel, PLAY / AUTO PLAY cards, and a racked song list whose focused * card slides out with a pointer and a glint. */ -class PhantomSelectRenderer implements BeMusicSelectRenderer { +class PhantomSelectRenderer implements PixiSelectRenderer { public readonly backLayer = new Container(); public readonly frontLayer = new Container(); private readonly pointer = new Graphics(); @@ -66,6 +86,10 @@ class PhantomSelectRenderer implements BeMusicSelectRenderer { private dots: Graphics | undefined; private burst: Graphics | undefined; private streaks: Graphics[] = []; + private streakAlpha: number[] = []; + /** Equalizer bars rising off the bottom edge behind the list, redrawn from the BGM / preview spectrum. */ + private readonly spectrum = new Graphics(); + private designHeight = 480; private activeRowY: number | undefined; private activeCard: { x: number; y: number; w: number; h: number } | undefined; private designWidth = 640; @@ -77,7 +101,15 @@ class PhantomSelectRenderer implements BeMusicSelectRenderer { this.frontLayer.addChild(this.kicker, this.glint, this.pointer); } - public render(frame: BeMusicSelectFrame): boolean { + private effects: PixiSelectFrame['effects'] = 'full'; + + public render(input: PixiSelectFrame): boolean { + this.effects = input.effects; + // With effects off every entrance / focus transition renders settled. + const frame = + input.effects === 'off' + ? { ...input, sceneStartedAt: Number.NEGATIVE_INFINITY, cursorChangedAt: Number.NEGATIVE_INFINITY } + : input; this.ensureBuilt(frame.designWidth, frame.designHeight); this.activeRowY = undefined; this.activeCard = undefined; @@ -91,21 +123,136 @@ class PhantomSelectRenderer implements BeMusicSelectRenderer { } this.pointer.visible = this.activeRowY !== undefined; this.glint.visible = this.activeCard !== undefined; + if (frame.launchAt !== undefined) { + this.renderOutro(frame); + return true; + } return needsFrame; } - public tick(nowMs: number, focusedSong: BrowserSongEntry | undefined): void { - const seconds = nowMs / 1000; + /** + * Launch outro — the split screen: a blood-red quarter full of out-of-focus bokeh slides in from the left while a + * black-and-white starburst slams in from the right; they meet on a torn diagonal seam, the chosen title lands on + * a tilted ink label, LET'S GO! is cut out of magazines letter by letter, and the page falls to ink for the count-in. + */ + private renderOutro(frame: PixiSelectFrame): void { + const t = Math.min(1, (frame.nowMs - (frame.launchAt ?? frame.nowMs)) / OUTRO_MS); + const { designWidth: w, designHeight: h, layer, nowMs } = frame; + const g = new Graphics(); + g.label = 'default-select/outro'; + layer.addChild(g); + const meet = easeOutCubic(Math.min(1, t / 0.32)); + // The seam: a torn line leaning right, from (seamTop, 0) to (seamBottom, h). The red half keeps to the left + // quarter so the starburst half carries the title. + const seamTop = w * 0.32; + const seamBottom = w * 0.18; + const seam = tornEdgePoints(seamTop, -10, seamBottom, h + 10, 9, 13, 12); + // Red half, entering from the left. + const redShift = (1 - meet) * -(w * 0.7); + const red: number[] = [-20 + redShift, -10]; + for (let index = 0; index < seam.length; index += 2) red.push(seam[index]! + redShift, seam[index + 1]!); + red.push(-20 + redShift, h + 10); + // Starburst half, slamming in from the right. + const burstShift = (1 - meet) * (w * 0.7); + const paper: number[] = [w + 20 + burstShift, -10, w + 20 + burstShift, h + 10]; + for (let index = seam.length - 2; index >= 0; index -= 2) paper.push(seam[index]! + burstShift, seam[index + 1]!); + g.poly(paper).fill(PHANTOM_PAPER); + const rayX = w * 0.66 + burstShift; + const rayY = h * 0.42; + const spin = t * 0.5; + for (let ray = 0; ray < 18; ray += 1) { + const a0 = (ray / 18) * Math.PI * 2 + spin; + const a1 = a0 + Math.PI / 18; + const reach = w * 0.9; + const poly = [ + rayX, + rayY, + rayX + Math.cos(a0) * reach, + rayY + Math.sin(a0) * reach, + rayX + Math.cos(a1) * reach, + rayY + Math.sin(a1) * reach, + ]; + g.poly(poly).fill(PHANTOM_INK); + } + // The red half is laid over the fan (so the rays stop at the seam), full of out-of-focus bokeh. + g.poly(red).fill(PHANTOM_RED); + for (let bokeh = 0; bokeh < 16; bokeh += 1) { + const bx = hash01(bokeh * 3 + 1) * seamBottom * 1.05 + redShift + Math.sin(nowMs / 900 + bokeh) * 6; + const by = hash01(bokeh * 3 + 2) * h; + const radius = 12 + 40 * hash01(bokeh * 3 + 3); + const color = bokeh % 4 === 0 ? PHANTOM_WHITE : PHANTOM_RED_HOT; + // Stacked translucent discs read as a soft, out-of-focus light. + for (let ring = 3; ring >= 1; ring -= 1) { + g.circle(bx, by, (radius * ring) / 3).fill({ color, alpha: bokeh % 4 === 0 ? 0.08 : 0.12 }); + } + } + const rip: number[] = []; + for (let index = 0; index < seam.length; index += 2) + rip.push(seam[index]! + (redShift + burstShift) / 2, seam[index + 1]!); + g.poly(rip, false).stroke({ color: PHANTOM_WHITE, width: 6, join: 'miter' }); + g.poly(rip, false).stroke({ color: PHANTOM_INK, width: 2, join: 'miter' }); + + // Title on a tilted ink label, and LET'S GO! cut from magazines, on the starburst half. + const land = stageProgress(t, 0.3, 0.2); + if (land > 0) { + const labelX = w * 0.62; + const labelY = h * 0.3; + const pop = 1.5 - 0.5 * easeOutBack(land, 2); + const title = frame.focusedSong?.title ?? ''; + g.poly(parallelogramPoints(labelX - 150 * pop, labelY - 22 * pop, 300 * pop, 44 * pop, 12)).fill(PHANTOM_INK); + addSkinText(layer, title, labelX, labelY, { + size: 20, + fill: PHANTOM_WHITE, + fontFamily: DEFAULT_HEADLINE_FONT, + anchorX: 0.5, + anchorY: 0.5, + maxWidth: 270, + alpha: Math.min(1, land * 3), + }).scale.y *= pop; + const glyph: GlyphFactory = (char, options) => + addSkinText(layer, char, 0, 0, { + size: options.size, + weight: options.weight, + fill: options.fill, + fontFamily: options.fontFamily, + }); + addRansomText(g, glyph, "LET'S GO!", w * 0.62, h * 0.56, { + size: 44, + seed: 17, + angle: -0.1, + appear: (index) => stageProgress(t, 0.38 + index * 0.035, 0.12), + }); + } + // Fall to ink for the handoff. + const dark = Math.max(0, (t - 0.86) / 0.14); + if (dark > 0) { + const cover = new Graphics(); + cover.rect(0, 0, w, h).fill({ color: PHANTOM_INK, alpha: dark }); + layer.addChild(cover); + } + } + + public tick( + nowMs: number, + focusedSong: BrowserSongEntry | undefined, + _launchAt?: number, + audio?: BeMusicAudioFrame, + ): void { + // Ambient motion: frozen with effects off, half speed when reduced. + const seconds = this.effects === 'off' ? 0 : (nowMs / 1000) * (this.effects === 'reduced' ? 0.5 : 1); + // The BGM / chart preview drives the poster: burst and halftone pump with it, streaks flare, an equalizer rises. + const drive = audioDrive(audio, this.effects); if (this.dots) { this.dots.y = (seconds * 14) % DOT_PERIOD; + this.dots.alpha = Math.min(1, 0.75 + 0.6 * drive.level); } // Beat clock from the focused chart's BPM, so the whole screen previews the song's tempo. const bpm = focusedSong?.bpm; const beatsPerSecond = (bpm !== undefined && Number.isFinite(bpm) && bpm > 0 ? Math.min(bpm, 300) : 120) / 60; const beatPulse = (1 - ((seconds * beatsPerSecond) % 1)) ** 3; if (this.burst) { - this.burst.rotation = seconds * 0.12; - this.burst.scale.set(1 + 0.06 * beatPulse); + this.burst.rotation = seconds * 0.12 + 0.12 * drive.onset; + this.burst.scale.set(0.85 + 0.06 * beatPulse + 0.6 * drive.bass + 0.3 * drive.onset); } this.kicker.x = -((seconds * 36) % KICKER_PITCH); const card = this.activeCard; @@ -122,11 +269,38 @@ class PhantomSelectRenderer implements BeMusicSelectRenderer { const span = this.designWidth + 260; streak.x = ((seconds * speed + hash01(index + 31) * span) % span) - 200; streak.y = 60 + hash01(index + 41) * 380; + streak.alpha = Math.min(1, (this.streakAlpha[index] ?? 0.2) * (1 + 2.2 * drive.high + 1.5 * drive.onset)); } + this.drawSpectrum(drive.bands); if (this.activeRowY !== undefined) { // Pointer kicks toward the card on every beat. - this.pointer.position.set(LAYOUT.listX - 12 + 5 * beatPulse, this.activeRowY); - this.pointer.scale.set(1 + 0.18 * beatPulse); + const kick = Math.max(beatPulse, drive.onset); + this.pointer.position.set(LAYOUT.listX - 12 + 5 * kick, this.activeRowY); + this.pointer.scale.set(1 + 0.18 * kick); + } + } + + /** 24 slanted red bars along the bottom edge under the list, capped in white; hot bands go gold. */ + private drawSpectrum(bands: readonly number[]): void { + const g = this.spectrum; + g.clear(); + const count = 24; + const left = LAYOUT.listX; + const pitch = (this.designWidth - 10 - left) / count; + const base = this.designHeight; + for (let bar = 0; bar < count; bar += 1) { + const value = bandLevel(bands, bar, count); + const h = Math.round(70 * value); + if (h < 3) continue; + const x = left + bar * pitch; + g.poly(parallelogramPoints(x, base - h, pitch - 4, h, 6)).fill({ + color: value > 0.85 ? PHANTOM_GOLD : PHANTOM_RED, + alpha: 0.55, + }); + g.poly(parallelogramPoints(x + 6 * (1 - 3 / h), base - h, pitch - 4, 3, (6 * 3) / h)).fill({ + color: PHANTOM_WHITE, + alpha: 0.8, + }); } } @@ -143,6 +317,7 @@ class PhantomSelectRenderer implements BeMusicSelectRenderer { if (this.built) return; this.built = true; this.designWidth = designWidth; + this.designHeight = designHeight; const ground = new Graphics(); ground.rect(0, 0, designWidth, designHeight).fill(PHANTOM_BLACK); ground.poly(slabPoints(designHeight)).fill(PHANTOM_RED); @@ -179,10 +354,11 @@ class PhantomSelectRenderer implements BeMusicSelectRenderer { streak.alpha = 0.12 + hash01(index + 5) * 0.2; streakLayer.addChild(streak); this.streaks.push(streak); + this.streakAlpha.push(streak.alpha); } this.dots = dots; this.burst = burst; - this.backLayer.addChild(ground, burst, burstMask, dots, slabMask, edge, streakLayer); + this.backLayer.addChild(ground, burst, burstMask, dots, slabMask, edge, this.spectrum, streakLayer); // Header kicker, one tooth wider than the canvas so scrolling by a tooth pitch loops seamlessly. const teeth: number[] = [-KICKER_PITCH, 38]; @@ -197,7 +373,7 @@ class PhantomSelectRenderer implements BeMusicSelectRenderer { } /** Header, info panel, action cards, search box, and library footer. Returns true while the title slides in. */ - private renderChrome(frame: BeMusicSelectFrame): boolean { + private renderChrome(frame: PixiSelectFrame): boolean { const { layer, designWidth, designHeight, entries, focusedSong: song } = frame; const chrome = new Graphics(); chrome.label = 'default-select/chrome'; @@ -226,6 +402,7 @@ class PhantomSelectRenderer implements BeMusicSelectRenderer { skewX: -0.18, dropShadow: { color: PHANTOM_RED, distance: 3 }, }); + drawNoteEmblem(chrome, 214, 27, 22, PHANTOM_WHITE, PHANTOM_RED); chrome.poly(parallelogramPoints(designWidth - 196, 9, 180, 20, -8)).fill(PHANTOM_WHITE); addText(categoryName, designWidth - 28, 19, { size: 11, @@ -237,9 +414,11 @@ class PhantomSelectRenderer implements BeMusicSelectRenderer { }); // Info panel — ink card with a white rim over a red-shifted shadow card. - chrome.poly(parallelogramPoints(18, 60, 286, 300, -6)).fill(PHANTOM_INK); + // Left column on a 16 px rhythm: info card, search, library — the card's top and the library's bottom line up with + // the list well's. + chrome.poly(parallelogramPoints(18, 60, 286, 292, -6)).fill(PHANTOM_INK); chrome - .poly(parallelogramPoints(12, 54, 286, 300, -6)) + .poly(parallelogramPoints(12, 54, 286, 292, -6)) .fill(PHANTOM_BLACK) .stroke({ color: PHANTOM_WHITE, width: 2, join: 'miter' }); chrome.poly(parallelogramPoints(20, 62, 74, 16, 6)).fill(PHANTOM_RED); @@ -292,17 +471,17 @@ class PhantomSelectRenderer implements BeMusicSelectRenderer { .fill(lit ? (segment >= 9 ? PHANTOM_GOLD : PHANTOM_RED_HOT) : PHANTOM_SLATE); } if (fileLabel) { - addText(fileLabel, 24, 250, { size: 8, weight: '600', fill: PHANTOM_ASH, maxWidth: 268 }); + addText(fileLabel, 24, 249, { size: 9, weight: '600', fill: PHANTOM_ASH, maxWidth: 268 }); } // PLAY is the primary action (big red card); AUTO PLAY is a secondary paper tag. Hit areas below mirror these. - chrome.poly(parallelogramPoints(29, 309, 168, 36, 8)).fill(PHANTOM_INK); + chrome.poly(parallelogramPoints(29, 303, 168, 36, 8)).fill(PHANTOM_INK); chrome - .poly(parallelogramPoints(24, 304, 168, 36, 8)) + .poly(parallelogramPoints(24, 298, 168, 36, 8)) .fill(PHANTOM_RED) .stroke({ color: PHANTOM_WHITE, width: 2, join: 'miter' }); - chrome.poly(parallelogramPoints(208, 310, 82, 26, 6)).fill(PHANTOM_PAPER); - addText('PLAY', 112, 322, { + chrome.poly(parallelogramPoints(206, 303, 80, 26, 6)).fill(PHANTOM_PAPER); + addText('PLAY', 112, 316, { size: 24, fill: PHANTOM_WHITE, fontFamily: DEFAULT_DISPLAY_FONT, @@ -311,7 +490,7 @@ class PhantomSelectRenderer implements BeMusicSelectRenderer { anchorX: 0.5, anchorY: 0.5, }); - addText('AUTO PLAY', 252, 323, { + addText('AUTO PLAY', 249, 316, { size: 12, fill: PHANTOM_INK, fontFamily: DEFAULT_DISPLAY_FONT, @@ -321,7 +500,7 @@ class PhantomSelectRenderer implements BeMusicSelectRenderer { anchorY: 0.5, maxWidth: 70, }); - addText(selectedPosition, 292, 284, { + addText(selectedPosition, 292, 276, { size: 14, fill: PHANTOM_ASH, fontFamily: DEFAULT_DISPLAY_FONT, @@ -330,34 +509,34 @@ class PhantomSelectRenderer implements BeMusicSelectRenderer { }); // Song list well. - chrome.rect(316, 50, designWidth - 332, designHeight - 76).fill({ color: PHANTOM_INK, alpha: 0.9 }); - chrome.rect(316, 50, 3, designHeight - 76).fill(PHANTOM_RED); + chrome.rect(316, 54, designWidth - 332, designHeight - 80).fill({ color: PHANTOM_INK, alpha: 0.9 }); + chrome.rect(316, 54, 3, designHeight - 80).fill(PHANTOM_RED); addText( frame.searchQuery ? 'SEARCH RESULTS' : frame.folderLabel ? 'CHARTS' : 'FOLDERS', - designWidth - 20, - designHeight - 22, + designWidth - 16, + designHeight - 20, { ...tagStyle(PHANTOM_ASH), anchorX: 1 }, ); chrome - .poly(parallelogramPoints(12, 376, 286, 28, -6)) + .poly(parallelogramPoints(12, 368, 286, 28, -6)) .fill(PHANTOM_INK) .stroke({ color: PHANTOM_WHITE, width: 1.5, join: 'miter' }); - addText('SEARCH', 24, 385, tagStyle(PHANTOM_RED)); - addText(frame.searchQuery || 'Title / artist / genre', 82, 384, { + addText('SEARCH', 24, 377, tagStyle(PHANTOM_RED)); + addText(frame.searchQuery || 'Title / artist / genre', 82, 376, { size: 10, weight: '600', fill: frame.searchQuery ? PHANTOM_WHITE : PHANTOM_ASH, maxWidth: 210, }); - addHitArea(layer, 12, 376, 292, 28, 'text', frame.actions.activateSearch); - addHitArea(layer, 24, 304, 176, 36, 'pointer', frame.actions.play); - addHitArea(layer, 206, 308, 90, 30, 'pointer', frame.actions.autoPlay); - - chrome.poly(parallelogramPoints(12, 420, 286, 42, -6)).fill(PHANTOM_INK); - chrome.poly(parallelogramPoints(18, 428, 6, 26, -3)).fill(PHANTOM_RED); - addText('LIBRARY', 32, 430, tagStyle(PHANTOM_ASH)); - addText(`${entries.length} shown / ${frame.totalCharts} charts`, 32, 443, { + addHitArea(12, 368, 292, 28, 'text', frame.actions.activateSearch); + addHitArea(24, 298, 176, 36, 'pointer', frame.actions.play); + addHitArea(206, 302, 86, 30, 'pointer', frame.actions.autoPlay); + + chrome.poly(parallelogramPoints(12, 412, 286, 42, -6)).fill(PHANTOM_INK); + chrome.poly(parallelogramPoints(18, 420, 6, 26, -3)).fill(PHANTOM_RED); + addText('LIBRARY', 32, 422, tagStyle(PHANTOM_ASH)); + addText(`${entries.length} shown / ${frame.totalCharts} charts`, 32, 435, { size: 11, weight: '700', fill: PHANTOM_WHITE, @@ -369,7 +548,7 @@ class PhantomSelectRenderer implements BeMusicSelectRenderer { /** One list row; returns true while it is still flying in or sliding out. */ private renderRow( - frame: BeMusicSelectFrame, + frame: PixiSelectFrame, entry: BrowserBrowseEntry, entryIndex: number, visibleIndex: number, @@ -383,11 +562,12 @@ class PhantomSelectRenderer implements BeMusicSelectRenderer { const folder = entry.kind === 'folder' ? entry.folder : undefined; const titleText = song?.title ?? folder?.label ?? ''; const keyText = song ? formatPlayVariantLabel(song).replace(' KEYS', '') : 'DIR'; - // One line per row: the title centred vertically, with the BPM (or folder size) right-aligned beside it. - const metaText = song - ? song.bpm - ? `${Math.round(song.bpm)} BPM` - : '' + // One line per row: the title (and artist) on the left; on the right the facts a player checks before picking — + // gimmick tags, note count, length and tempo range in fixed columns, so they line up down the list. Folders show + // their size instead. + const facts = song ? resolveSongRowFacts(song) : undefined; + const metaText = facts + ? `${facts.bpm} BPM` : `${folder?.songs.length ?? 0} chart${folder?.songs.length === 1 ? '' : 's'}`; const playLevelText = song?.playLevel !== undefined ? String(song.playLevel) : folder ? String(folder.songs.length) : '-'; @@ -403,7 +583,6 @@ class PhantomSelectRenderer implements BeMusicSelectRenderer { const levelPillX = keyPillX + keyPillW + 4; const levelPillW = 26; const titleX = levelPillX + levelPillW + 10; - const textMaxWidth = Math.max(24, rowX + rowW - titleX - 10); row.label = `fallback-row[idx=${entryIndex},kind=${entry.kind}${active ? ',active' : ''}]`; if (active) { row.poly(parallelogramPoints(rowX + 5, y + 3, rowW, rowHeight - 3, 6)).fill(PHANTOM_RED); @@ -446,18 +625,69 @@ class PhantomSelectRenderer implements BeMusicSelectRenderer { skewX: -0.18, anchorX: 1, anchorY: 0.5, - maxWidth: 64, + maxWidth: facts ? ROW_BPM_W : 64, }); meta.label = `fallback-meta[idx=${entryIndex}]`; - const metaWidth = metaText ? Math.min(64, meta.width) + 10 : 0; + let factsLeft = rowX + rowW - 12 - (facts ? ROW_BPM_W : metaText ? Math.min(64, meta.width) : 0) - 10; + if (facts) { + const factStyle = (fill: number): SkinTextOptions => ({ + size: 11, + fill, + fontFamily: DEFAULT_DISPLAY_FONT, + skewX: -0.18, + anchorX: 1, + anchorY: 0.5, + }); + const ink = active ? PHANTOM_INK : PHANTOM_PAPER; + addSkinText(frame.layer, facts.length, factsLeft, rowMidY, { ...factStyle(ink), maxWidth: ROW_LENGTH_W }); + factsLeft -= ROW_LENGTH_W + 10; + addSkinText(frame.layer, facts.notes, factsLeft - 34, rowMidY, { ...factStyle(ink), maxWidth: ROW_NOTES_W - 36 }); + addSkinText(frame.layer, 'NOTES', factsLeft, rowMidY + 1, { + ...factStyle(active ? PHANTOM_RED : PHANTOM_ASH), + size: 8, + }); + factsLeft -= ROW_NOTES_W + 8; + // Gimmick tags: small slanted chips, red on the focused card. + for (let index = facts.tags.length - 1; index >= 0; index -= 1) { + const tag = facts.tags[index]!; + const chipW = 8 + tag.length * 6; + factsLeft -= chipW; + row + .poly(parallelogramPoints(factsLeft, y + 8, chipW, rowHeight - 17, 3)) + .fill(active ? PHANTOM_RED : PHANTOM_SLATE); + addSkinText(frame.layer, tag, factsLeft + chipW / 2 + 1, rowMidY, { + size: 8, + fill: PHANTOM_WHITE, + fontFamily: DEFAULT_DISPLAY_FONT, + anchorX: 0.5, + anchorY: 0.5, + maxWidth: chipW - 4, + }); + factsLeft -= 4; + } + factsLeft -= 6; + } + const titleMax = Math.max(24, factsLeft - titleX); const title = addSkinText(frame.layer, titleText, titleX, rowMidY, { size: 11, weight: '800', fill: active ? PHANTOM_INK : PHANTOM_WHITE, anchorY: 0.5, - maxWidth: Math.max(24, textMaxWidth - metaWidth), + maxWidth: titleMax, }); title.label = `fallback-title[idx=${entryIndex}]`; + // The artist trails the title in a quieter weight while there is room for it. + const artist = song?.artist?.trim(); + const artistX = titleX + title.width + 10; + if (artist && factsLeft - artistX >= 40) { + addSkinText(frame.layer, artist, artistX, rowMidY, { + size: 9, + weight: '700', + fill: active ? PHANTOM_RED : PHANTOM_ASH, + anchorY: 0.5, + maxWidth: factsLeft - artistX, + }); + } return slide > 0 || intro > 0; } } diff --git a/packages/player-web/src/scene/default/phantom-style.test.ts b/packages/player-web/src/skins/phantom/style.test.ts similarity index 51% rename from packages/player-web/src/scene/default/phantom-style.test.ts rename to packages/player-web/src/skins/phantom/style.test.ts index e653cf6d..276c302a 100644 --- a/packages/player-web/src/scene/default/phantom-style.test.ts +++ b/packages/player-web/src/skins/phantom/style.test.ts @@ -1,15 +1,12 @@ import { describe, expect, it } from 'vite-plus/test'; import { - easeOutBack, - easeOutCubic, halftoneField, - hash01, - layoutTabularRun, parallelogramPoints, - rollUpValue, - stageProgress, + ransomLayout, starburstPoints, -} from './phantom-style.ts'; + tornBandPoints, + tornEdgePoints, +} from './style.ts'; describe('parallelogramPoints', () => { it('shifts the top edge right for a positive slant', () => { @@ -78,83 +75,63 @@ describe('halftoneField', () => { }); }); -describe('hash01', () => { - it('is deterministic and stays in [0, 1)', () => { - for (let seed = 0; seed < 50; seed += 1) { - const value = hash01(seed); - expect(value).toBe(hash01(seed)); - expect(value).toBeGreaterThanOrEqual(0); - expect(value).toBeLessThan(1); - } - }); -}); - -describe('layoutTabularRun', () => { - // A proportional face where "1" is narrow and "0" is wide. - const widths: Record = { '0': 10, '1': 4, '2': 9, '/': 5, '%': 12 }; - const measure = (char: string) => widths[char] ?? 8; - - it('gives every digit the widest digit advance', () => { - const { glyphs, width } = layoutTabularRun('101', measure); - expect(glyphs.map((glyph) => [glyph.char, glyph.x, glyph.w])).toEqual([ - ['1', 0, 10], - ['0', 10, 10], - ['1', 20, 10], - ]); - expect(width).toBe(30); +describe('tornEdgePoints', () => { + it('starts and ends exactly on the line', () => { + const points = tornEdgePoints(0, 0, 200, 0, 6, 3); + expect(points.slice(0, 2)).toEqual([0, 0]); + expect(points.slice(-2)).toEqual([200, 0]); }); - it('keeps the natural advance for non-digits', () => { - const { glyphs, width } = layoutTabularRun('1/2%', measure); - expect(glyphs.map((glyph) => glyph.w)).toEqual([10, 5, 10, 12]); - expect(glyphs.map((glyph) => glyph.x)).toEqual([0, 10, 15, 25]); - expect(width).toBe(37); + it('keeps every tooth within the shard bound of the line', () => { + const points = tornEdgePoints(0, 50, 400, 50, 8, 7); + for (let index = 1; index < points.length; index += 2) { + expect(Math.abs(points[index]! - 50)).toBeLessThanOrEqual(8 * 2.5 + 1e-9); + } }); - it('makes equal-length numbers equally wide regardless of digits', () => { - expect(layoutTabularRun('111', measure).width).toBe(layoutTabularRun('000', measure).width); + it('is jagged, deterministic, and seed-dependent', () => { + const points = tornEdgePoints(0, 0, 300, 0, 6, 1); + const offsets = new Set(points.filter((_, index) => index % 2 === 1).map((y) => Math.round(y * 10))); + expect(offsets.size).toBeGreaterThan(5); + expect(tornEdgePoints(0, 0, 300, 0, 6, 1)).toEqual(points); + expect(tornEdgePoints(0, 0, 300, 0, 6, 2)).not.toEqual(points); }); }); -describe('stageProgress', () => { - it('stays at 0 before the delay, ramps, and clamps at 1', () => { - expect(stageProgress(50, 100, 200)).toBe(0); - expect(stageProgress(200, 100, 200)).toBe(0.5); - expect(stageProgress(900, 100, 200)).toBe(1); - }); - - it('treats a zero duration as an instant step at the delay', () => { - expect(stageProgress(99, 100, 0)).toBe(0); - expect(stageProgress(100, 100, 0)).toBe(1); +describe('tornBandPoints', () => { + it('wraps a strip of the given thickness around the line', () => { + const band = tornBandPoints(0, 100, 400, 100, 40, 5, 4); + const ys = band.filter((_, index) => index % 2 === 1); + expect(Math.min(...ys)).toBeLessThan(80); + expect(Math.max(...ys)).toBeGreaterThan(120); + expect(Math.min(...ys)).toBeGreaterThanOrEqual(80 - 5 * 2.5 - 1e-9); + expect(Math.max(...ys)).toBeLessThanOrEqual(120 + 5 * 2.5 + 1e-9); }); }); -describe('easing', () => { - it('pins both curves to 0 and 1 at the ends', () => { - expect(easeOutCubic(0)).toBe(0); - expect(easeOutCubic(1)).toBe(1); - expect(easeOutBack(0)).toBeCloseTo(0, 12); - expect(easeOutBack(1)).toBe(1); - }); - - it('lets easeOutBack overshoot past 1 mid-flight', () => { - expect(Math.max(...[0.6, 0.7, 0.8, 0.9].map((t) => easeOutBack(t)))).toBeGreaterThan(1); - }); - - it('clamps inputs outside 0..1', () => { - expect(easeOutCubic(-1)).toBe(0); - expect(easeOutCubic(2)).toBe(1); +describe('ransomLayout', () => { + it('cuts every letter differently within its ranges', () => { + const glyphs = ransomLayout('SHOW TIME', 4); + expect(glyphs).toHaveLength(9); + expect(glyphs[4]!.space).toBe(true); + for (const glyph of glyphs.filter((entry) => !entry.space)) { + expect(glyph.font).toBeGreaterThanOrEqual(0); + expect(glyph.font).toBeLessThan(4); + expect(glyph.scale).toBeGreaterThanOrEqual(0.82); + expect(glyph.scale).toBeLessThanOrEqual(1.2); + expect(Math.abs(glyph.rotation)).toBeLessThanOrEqual(0.14); + expect(Math.abs(glyph.dy)).toBeLessThanOrEqual(0.12); + } }); -}); -describe('rollUpValue', () => { - it('counts from 0 to the target', () => { - expect(rollUpValue(1234, 0)).toBe(0); - expect(rollUpValue(1234, 1)).toBe(1234); - expect(rollUpValue(100, 0.5)).toBe(88); + it('never gives neighbouring letters the same card', () => { + const glyphs = ransomLayout('TAKEYOURHEART', 9); + for (let index = 1; index < glyphs.length; index += 1) { + expect(glyphs[index]!.paper).not.toBe(glyphs[index - 1]!.paper); + } }); - it('returns 0 for a non-finite target', () => { - expect(rollUpValue(Number.NaN, 1)).toBe(0); + it('is deterministic for a seed', () => { + expect(ransomLayout('GO', 1)).toEqual(ransomLayout('GO', 1)); }); }); diff --git a/packages/player-web/src/skins/phantom/style.ts b/packages/player-web/src/skins/phantom/style.ts new file mode 100644 index 00000000..a3b7a395 --- /dev/null +++ b/packages/player-web/src/skins/phantom/style.ts @@ -0,0 +1,265 @@ +import { hash01 } from '@be-music/skin-sdk'; +import type { Graphics } from 'pixi.js'; + +/** + * "Phantom" visual language shared by the default skin family's gameplay, select, and result chrome: a three-ink + * poster palette (blood red / ink black / paper white), slanted parallelogram plates, jagged starbursts, and halftone + * dot fields. The shape helpers are pure and return flat `[x0, y0, x1, y1, ...]` point lists so the Pixi renderers can + * hand them straight to `Graphics.poly()`. + */ + +export const PHANTOM_RED = 0xe60019; +export const PHANTOM_RED_HOT = 0xff2b45; +export const PHANTOM_RED_DEEP = 0x6e0010; +export const PHANTOM_INK = 0x000000; +export const PHANTOM_BLACK = 0x0c0c0e; +export const PHANTOM_CHARCOAL = 0x1a1a1d; +export const PHANTOM_SLATE = 0x2c2c31; +export const PHANTOM_WHITE = 0xffffff; +export const PHANTOM_PAPER = 0xf4f1ea; +export const PHANTOM_ASH = 0x8d8a93; +/** Single warm accent reserved for "money" moments (PERFECT, high combo, top rank) so red stays the base ink. */ +export const PHANTOM_GOLD = 0xffd21f; +export const PHANTOM_CYAN = 0x4fd8ff; +export const PHANTOM_ORANGE = 0xff8a1f; + +/** + * Parallelogram with horizontal top / bottom edges. `slant` shifts the top edge right relative to the bottom edge + * (positive leans like italic type). The bounding box is `[x, x + w + max(0, slant)]` horizontally — the bottom-left + * corner stays anchored at `(x, y + h)`. + */ +export function parallelogramPoints(x: number, y: number, w: number, h: number, slant: number): number[] { + const topShift = Math.max(0, slant); + const bottomShift = Math.max(0, -slant); + return [x + topShift, y, x + w + topShift, y, x + w + bottomShift, y + h, x + bottomShift, y + h]; +} + +/** + * Jagged comic starburst. Spikes alternate between `outerRadius` and `innerRadius`; `jitter` (0..1) roughens the + * outer tips deterministically from `seed` so the burst reads hand-cut instead of a perfect star, while staying + * stable frame to frame for the same seed. + */ +export function starburstPoints( + cx: number, + cy: number, + outerRadius: number, + innerRadius: number, + spikes: number, + rotation = 0, + jitter = 0, + seed = 1, +): number[] { + const count = Math.max(3, Math.floor(spikes)); + const points: number[] = []; + for (let index = 0; index < count * 2; index += 1) { + const outer = index % 2 === 0; + const angle = rotation + (Math.PI * index) / count; + const roughness = outer && jitter > 0 ? 1 - jitter * hash01(seed * 131 + index) : 1; + const radius = (outer ? outerRadius : innerRadius) * roughness; + points.push(cx + Math.cos(angle) * radius, cy + Math.sin(angle) * radius); + } + return points; +} + +export interface HalftoneDot { + x: number; + y: number; + r: number; +} + +export interface HalftoneFieldOptions { + x: number; + y: number; + w: number; + h: number; + /** Grid pitch between dot centres. */ + pitch: number; + /** Dot radius where the ramp is at full strength. */ + maxRadius: number; + /** + * Direction the dots grow toward, as a unit-ish vector. `{ x: 1, y: 0 }` makes dots swell left → right. The ramp is + * measured across the field's projected extent, so any direction covers 0..1 over the rectangle. + */ + direction: { x: number; y: number }; + /** Radii below this are dropped entirely so the fade end stays clean. Defaults to 0.35 px. */ + minRadius?: number; +} + +/** + * Halftone dot field on a staggered grid: every other row is offset by half a pitch, and each dot's radius ramps + * linearly along `direction`. Returns only the dots that survive `minRadius`, so callers can loop and `circle()` them. + */ +export function halftoneField(options: HalftoneFieldOptions): HalftoneDot[] { + const { x, y, w, h, pitch, maxRadius } = options; + if (pitch <= 0 || w <= 0 || h <= 0 || maxRadius <= 0) return []; + const minRadius = options.minRadius ?? 0.35; + const length = Math.hypot(options.direction.x, options.direction.y) || 1; + const dx = options.direction.x / length; + const dy = options.direction.y / length; + // Project the rectangle's corners onto the direction to find the ramp's 0..1 span. + const projections = [0, w * dx, h * dy, w * dx + h * dy]; + const rampStart = Math.min(...projections); + const rampSpan = Math.max(...projections) - rampStart || 1; + const dots: HalftoneDot[] = []; + let row = 0; + for (let py = y; py <= y + h; py += pitch, row += 1) { + const offset = row % 2 === 0 ? 0 : pitch / 2; + for (let px = x + offset; px <= x + w; px += pitch) { + const ramp = ((px - x) * dx + (py - y) * dy - rampStart) / rampSpan; + const r = maxRadius * Math.max(0, Math.min(1, ramp)); + if (r >= minRadius) { + dots.push({ x: px, y: py, r }); + } + } + } + return dots; +} + +/** + * Phantom's own mark: a slanted eighth note (tilted oval head, stem, and a knife-cut flag) — the skin's signature, set + * into the header and the result card so the poster language reads as *this* rhythm game rather than a borrowed one. + * `size` is the overall height; `(x, y)` is the head's centre. + */ +export function drawNoteEmblem( + graphics: Graphics, + x: number, + y: number, + size: number, + color: number, + ink: number, +): void { + const headW = size * 0.36; + const headH = size * 0.24; + const tilt = -0.42; + const head: number[] = []; + for (let step = 0; step < 14; step += 1) { + const angle = (Math.PI * 2 * step) / 14; + const px = Math.cos(angle) * headW; + const py = Math.sin(angle) * headH; + head.push(x + px * Math.cos(tilt) - py * Math.sin(tilt), y + px * Math.sin(tilt) + py * Math.cos(tilt)); + } + const stemX = x + headW * 0.78; + const stemTop = y - size * 0.86; + // Ink shadow first, then the mark, for the same offset-print look as the plates. + const shadow = size * 0.08; + graphics.poly(head.map((value) => value + shadow)).fill(ink); + graphics.poly(head).fill(color); + graphics.rect(stemX - size * 0.05, stemTop, size * 0.1, y - stemTop).fill(color); + graphics + .poly([ + stemX, + stemTop, + stemX + size * 0.42, + stemTop + size * 0.3, + stemX + size * 0.3, + stemTop + size * 0.44, + stemX, + stemTop + size * 0.2, + ]) + .fill(color); +} + +/** + * Torn-paper edge: points every ~`step` px from `(x0, y0)` to `(x1, y1)`, each pushed off the line along its normal + * by up to `amplitude` — sharp alternating teeth with the odd long shard (up to 2.5 × `amplitude`), like the rip in a + * Phantom cut-in. Deterministic for `seed`. Returns a flat `[x, y, ...]` list. + */ +export function tornEdgePoints( + x0: number, + y0: number, + x1: number, + y1: number, + amplitude: number, + seed: number, + step = 10, +): number[] { + const length = Math.hypot(x1 - x0, y1 - y0); + const count = Math.max(2, Math.ceil(length / step)); + const nx = length > 0 ? -(y1 - y0) / length : 0; + const ny = length > 0 ? (x1 - x0) / length : 1; + const points: number[] = []; + for (let index = 0; index <= count; index += 1) { + const base = seed * 211 + index * 7; + // Jitter each tooth along the edge a little so the rip never looks regular. + const along = Math.min( + 1, + Math.max(0, (index + (index > 0 && index < count ? (hash01(base + 1) - 0.5) * 0.6 : 0)) / count), + ); + const shard = hash01(base + 2) > 0.86 ? 2.5 : 1; + const side = index % 2 === 0 ? 1 : -0.45; + const offset = index === 0 || index === count ? 0 : amplitude * shard * side * (0.35 + 0.65 * hash01(base + 3)); + points.push(x0 + (x1 - x0) * along + nx * offset, y0 + (y1 - y0) * along + ny * offset); + } + return points; +} + +/** + * A torn band: a strip `thickness` thick along the line `(x0, y0) → (x1, y1)` whose two long edges are both + * {@link tornEdgePoints} rips (with different seeds). A closed polygon ready for `Graphics.poly()`. + */ +export function tornBandPoints( + x0: number, + y0: number, + x1: number, + y1: number, + thickness: number, + amplitude: number, + seed: number, + step = 10, +): number[] { + const length = Math.hypot(x1 - x0, y1 - y0) || 1; + const nx = (-(y1 - y0) / length) * (thickness / 2); + const ny = ((x1 - x0) / length) * (thickness / 2); + const top = tornEdgePoints(x0 - nx, y0 - ny, x1 - nx, y1 - ny, amplitude, seed, step); + const bottom = tornEdgePoints(x1 + nx, y1 + ny, x0 + nx, y0 + ny, amplitude, seed + 97, step); + return [...top, ...bottom]; +} + +export type RansomPaper = 'ink' | 'paper' | 'red'; + +export interface RansomGlyph { + char: string; + /** True for spaces: advance only, no card. */ + space: boolean; + /** Index into the caller's font list (0..3). */ + font: number; + /** Size multiplier in [0.82, 1.2]. */ + scale: number; + /** Card tilt in radians, [-0.14, 0.14]. */ + rotation: number; + /** Baseline shift as a fraction of the size, [-0.12, 0.12]. */ + dy: number; + /** The card behind the letter; neighbours never share one. */ + paper: RansomPaper; +} + +/** + * Ransom-note lettering: every character cut from a different magazine — its own face, size, tilt, baseline, and card + * (ink, paper, or red, never the same as the letter before). Deterministic for `seed`. + */ +export function ransomLayout(text: string, seed: number): RansomGlyph[] { + const papers: RansomPaper[] = ['ink', 'paper', 'red']; + const glyphs: RansomGlyph[] = []; + let previous: RansomPaper | undefined; + Array.from(text).forEach((char, index) => { + const base = seed * 389 + index * 13; + if (char === ' ') { + glyphs.push({ char, space: true, font: 0, scale: 1, rotation: 0, dy: 0, paper: 'ink' }); + previous = undefined; + return; + } + let paper = papers[Math.floor(hash01(base + 1) * papers.length) % papers.length]!; + if (paper === previous) paper = papers[(papers.indexOf(paper) + 1) % papers.length]!; + previous = paper; + glyphs.push({ + char, + space: false, + font: Math.floor(hash01(base + 2) * 4) % 4, + scale: 0.82 + 0.38 * hash01(base + 3), + rotation: (hash01(base + 4) - 0.5) * 0.28, + dy: (hash01(base + 5) - 0.5) * 0.24, + paper, + }); + }); + return glyphs; +} diff --git a/packages/player-web/src/skins/phantom/tear.test.ts b/packages/player-web/src/skins/phantom/tear.test.ts new file mode 100644 index 00000000..4d32bf56 --- /dev/null +++ b/packages/player-web/src/skins/phantom/tear.test.ts @@ -0,0 +1,63 @@ +import type { Graphics, Text } from 'pixi.js'; +import { describe, expect, it } from 'vite-plus/test'; +import { PHANTOM_INK, ransomLayout } from './style.ts'; +import { addRansomText, stripPoint, tearOpenness, type GlyphFactory } from './tear.ts'; + +describe('tearOpenness', () => { + it('is shut before and after the cut-in', () => { + expect(tearOpenness(0)).toBe(0); + expect(tearOpenness(1)).toBe(0); + expect(tearOpenness(-0.5)).toBe(0); + }); + + it('rips open with an overshoot, holds, then snaps shut', () => { + let peak = 0; + for (let step = 1; step <= 12; step += 1) peak = Math.max(peak, tearOpenness(step / 100)); + expect(peak).toBeGreaterThan(1); + expect(tearOpenness(0.5)).toBeCloseTo(1, 9); + expect(tearOpenness(0.95)).toBeLessThan(0.3); + }); +}); + +describe('stripPoint', () => { + it('maps along / across the strip axis', () => { + const strip = { cx: 100, cy: 50, angle: Math.PI / 2, length: 200, thickness: 40, seed: 1 }; + const point = stripPoint(strip, 10, 5); + expect(point.x).toBeCloseTo(95, 9); + expect(point.y).toBeCloseTo(60, 9); + }); +}); + +describe('addRansomText', () => { + /** Records each glyph's fill; the nodes are just enough of a `Text` for the layout to place them. */ + function recordGlyphs(): { fills: number[]; glyph: GlyphFactory } { + const fills: number[] = []; + const glyph: GlyphFactory = (_char, options) => { + fills.push(options.fill); + const point = { set: () => undefined }; + return { + width: options.size * 0.6, + anchor: point, + position: point, + scale: { x: 1, y: 1, set: () => undefined }, + } as unknown as Text; + }; + return { fills, glyph }; + } + const graphics = { poly: () => ({ fill: () => undefined }) } as unknown as Graphics; + + it('recolours the letters on ink and red cards with the accent, keeping ink letters on paper cards', () => { + const { fills, glyph } = recordGlyphs(); + addRansomText(graphics, glyph, 'GREAT', 0, 0, { size: 30, seed: 4, accent: 0x123456 }); + + const papers = ransomLayout('GREAT', 4).map((entry) => entry.paper); + expect(fills).toEqual(papers.map((paper) => (paper === 'paper' ? PHANTOM_INK : 0x123456))); + }); + + it('keeps the default letter colours without an accent', () => { + const { fills, glyph } = recordGlyphs(); + addRansomText(graphics, glyph, 'GREAT', 0, 0, { size: 30, seed: 4 }); + + expect(fills).not.toContain(0x123456); + }); +}); diff --git a/packages/player-web/src/skins/phantom/tear.ts b/packages/player-web/src/skins/phantom/tear.ts new file mode 100644 index 00000000..fea5d67a --- /dev/null +++ b/packages/player-web/src/skins/phantom/tear.ts @@ -0,0 +1,185 @@ +import type { Graphics, Text } from 'pixi.js'; +import { DEFAULT_DISPLAY_FONT, DEFAULT_HEADLINE_FONT } from './fonts.ts'; +import { + PHANTOM_INK, + PHANTOM_PAPER, + PHANTOM_RED, + PHANTOM_WHITE, + ransomLayout, + tornBandPoints, + type RansomPaper, +} from './style.ts'; +import { easeOutBack, easeOutCubic, DEFAULT_TEXT_FONT, hash01 } from '@be-music/skin-sdk'; + +/** + * Phantom cut-in pieces: the torn-paper strip that rips across the screen (red splash behind a white rim around an ink + * band, with speed lines racing through it), and ransom-note lettering. Callers own the `Graphics` and supply a text + * factory, so the same pieces serve pooled gameplay HUD text and per-render select / result text. + */ + +export interface TearStrip { + /** Centre of the strip. */ + cx: number; + cy: number; + /** Direction of the rip, radians (small negative tilts it up to the right). */ + angle: number; + length: number; + thickness: number; + seed: number; +} + +/** How open the strip is at cut-in progress `t`: it rips open fast, holds, and snaps shut from 85 %. */ +export function tearOpenness(t: number): number { + if (t <= 0 || t >= 1) return 0; + const open = easeOutBack(Math.min(1, t / 0.12), 2.2); + const close = t > 0.85 ? easeOutCubic((t - 0.85) / 0.15) : 0; + return Math.max(0, open * (1 - close)); +} + +/** Point `u` px along and `v` px across the strip from its centre (for placing content inside it). */ +export function stripPoint(strip: TearStrip, u: number, v: number): { x: number; y: number } { + const cos = Math.cos(strip.angle); + const sin = Math.sin(strip.angle); + return { x: strip.cx + u * cos - v * sin, y: strip.cy + u * sin + v * cos }; +} + +/** + * Draws the strip at cut-in progress `t`: the rip slides in along its own axis as it opens, then shuts to a hairline + * and flies on. Returns the openness (0..1) so content can scale with it. + */ +export function drawTearStrip( + graphics: Graphics, + strip: TearStrip, + t: number, + nowMs: number, + enterFrom: 'left' | 'right' = 'left', +): number { + const open = tearOpenness(t); + if (open <= 0.001) return 0; + const enter = easeOutCubic(Math.min(1, t / 0.14)); + const exit = t > 0.85 ? easeOutCubic((t - 0.85) / 0.15) : 0; + // It slides in along its axis from `enterFrom` and flies off to the right; a strip entering from the right never + // reaches left of its rest position (so it can sit beside the lanes without crossing them). + const slide = (1 - enter) * (enterFrom === 'left' ? -1 : 1) * strip.length * 0.25 + exit * strip.length * 0.3; + const from = stripPoint(strip, -strip.length / 2 + slide, 0); + const to = stripPoint(strip, strip.length / 2 + slide, 0); + const thickness = strip.thickness * open; + // Red splash, white rim, ink band — each ripped with its own teeth. + graphics.poly(tornBandPoints(from.x, from.y, to.x, to.y, thickness * 1.42, 11, strip.seed, 13)).fill(PHANTOM_RED); + graphics.poly(tornBandPoints(from.x, from.y, to.x, to.y, thickness * 1.14, 7, strip.seed + 3, 9)).fill(PHANTOM_WHITE); + graphics.poly(tornBandPoints(from.x, from.y, to.x, to.y, thickness, 5, strip.seed + 7, 8)).fill(PHANTOM_INK); + // Red shards flung off the rip. + for (let shard = 0; shard < 7; shard += 1) { + const u = (hash01(strip.seed * 17 + shard) - 0.5) * strip.length * 0.9 + slide; + const side = shard % 2 === 0 ? 1 : -1; + const v = side * (thickness * 0.72 + 6 + 16 * hash01(strip.seed * 23 + shard) * open); + const size = 5 + 9 * hash01(strip.seed * 29 + shard); + const tip = stripPoint(strip, u, v); + const left = stripPoint(strip, u - size, v - side * size * 0.4); + const right = stripPoint(strip, u + size * 0.6, v - side * size * 0.5); + graphics.poly([tip.x, tip.y, left.x, left.y, right.x, right.y]).fill(PHANTOM_RED); + } + // Speed lines racing along the band. + for (let line = 0; line < 9; line += 1) { + const lane = (hash01(strip.seed * 31 + line) - 0.5) * thickness * 0.8; + const speed = 900 + 700 * hash01(strip.seed * 37 + line); + const span = strip.length; + const length = 40 + 60 * hash01(line + 5); + // Lines stay inside the band's ends. + const u = (((nowMs / 1000) * speed + hash01(strip.seed * 41 + line) * span) % span) - span / 2 + slide; + const a = stripPoint(strip, u, lane); + const b = stripPoint(strip, Math.min(strip.length / 2 + slide, u + length), lane); + graphics.moveTo(a.x, a.y).lineTo(b.x, b.y); + } + graphics.stroke({ color: PHANTOM_WHITE, width: 1.5, alpha: 0.35 * open }); + return open; +} + +/** A text node factory: creates one glyph (the caller's pool or a fresh node) and returns it for placement. */ +export type GlyphFactory = ( + char: string, + options: { size: number; weight: '400' | '800'; fill: number; fontFamily: string }, +) => Text; + +const RANSOM_FONTS: ReadonlyArray<{ family: string; weight: '400' | '800'; tighten: number }> = [ + { family: DEFAULT_DISPLAY_FONT, weight: '400', tighten: 1.05 }, + { family: DEFAULT_HEADLINE_FONT, weight: '400', tighten: 0.9 }, + { family: 'Azeret Mono, ui-monospace, monospace', weight: '800', tighten: 0.92 }, + { family: DEFAULT_TEXT_FONT, weight: '800', tighten: 0.95 }, +]; + +const CARD_FILL: Record = { ink: PHANTOM_INK, paper: PHANTOM_PAPER, red: PHANTOM_RED }; +const LETTER_FILL: Record = { ink: PHANTOM_WHITE, paper: PHANTOM_INK, red: PHANTOM_WHITE }; + +/** + * Ransom-note text centred on `(x, y)` along a baseline tilted by `angle`: each letter on its own tilted card (see + * {@link ransomLayout}). `appear(index, count)` returns 0..1 per letter so callers can stagger the cards in — each + * pops from oversized as it lands. Cards go into `graphics`; letters come from `glyph`. `accent` recolours the letters + * on the ink and red cards (paper cards keep ink letters), so a word can carry a status colour. + */ +export function addRansomText( + graphics: Graphics, + glyph: GlyphFactory, + text: string, + x: number, + y: number, + options: { + size: number; + seed: number; + angle?: number; + appear?: (index: number, count: number) => number; + accent?: number; + /** How oversized a card starts as it pops in (0.9 = 1.9x). Small values keep a quick-fire word in place. */ + pop?: number; + }, +): void { + const layout = ransomLayout(text, options.seed); + const angle = options.angle ?? 0; + const nodes = layout.map((entry) => { + if (entry.space) return undefined; + const font = RANSOM_FONTS[entry.font]!; + return glyph(entry.char, { + size: Math.round(options.size * entry.scale * font.tighten), + weight: font.weight, + fill: options.accent !== undefined && entry.paper !== 'paper' ? options.accent : LETTER_FILL[entry.paper], + fontFamily: font.family, + }); + }); + const pad = options.size * 0.12; + const gap = options.size * 0.05; + const widths = nodes.map((node) => (node ? node.width + pad * 2 : options.size * 0.4)); + const total = widths.reduce((sum, width) => sum + width + gap, -gap); + const cos = Math.cos(angle); + const sin = Math.sin(angle); + let cursor = -total / 2; + layout.forEach((entry, index) => { + const width = widths[index]!; + const node = nodes[index]; + const along = cursor + width / 2; + cursor += width + gap; + if (!node) return; + const appear = Math.max(0, Math.min(1, options.appear?.(index, layout.length) ?? 1)); + if (appear <= 0) { + node.visible = false; + return; + } + const pop = 1 + (options.pop ?? 0.9) * (1 - easeOutBack(appear, 2.4)); + const lift = entry.dy * options.size; + const cx = x + along * cos - lift * sin; + const cy = y + along * sin + lift * cos; + const rotation = angle + entry.rotation; + const h = options.size * entry.scale * 1.18 * pop; + const w = width * pop; + const c = Math.cos(rotation); + const s = Math.sin(rotation); + const corner = (dx: number, dy: number) => [cx + dx * c - dy * s, cy + dx * s + dy * c]; + graphics + .poly([...corner(-w / 2, -h / 2), ...corner(w / 2, -h / 2), ...corner(w / 2, h / 2), ...corner(-w / 2, h / 2)]) + .fill({ color: CARD_FILL[entry.paper], alpha: Math.min(1, appear * 3) }); + node.anchor.set(0.5, 0.5); + node.position.set(cx, cy); + node.rotation = rotation; + node.scale.set(node.scale.x * pop, node.scale.y * pop); + node.alpha = Math.min(1, appear * 3); + }); +} diff --git a/packages/player-web/src/skins/pixi-kit/define-pixi-skin.ts b/packages/player-web/src/skins/pixi-kit/define-pixi-skin.ts new file mode 100644 index 00000000..edc5df3a --- /dev/null +++ b/packages/player-web/src/skins/pixi-kit/define-pixi-skin.ts @@ -0,0 +1,278 @@ +import { Container, Graphics, WebGLRenderer } from 'pixi.js'; +import { + defineBeMusicSkin, + type BeMusicGameplayFrame, + type BeMusicResultFrame, + type BeMusicSelectFrame, + type BeMusicSkin, + type BeMusicSurface, +} from '@be-music/skin-sdk'; +import { ChildPool, LaggedDisposer } from './pools.ts'; +import { setTextResolution } from './resolution.ts'; +import { collectHitAreas, type PixiHitArea } from './skin-text.ts'; +import type { PixiSelectFrame, PixiSelectRenderer, PixiSkinDefinition } from './types.ts'; + +/** + * Turns a {@link PixiSkinDefinition} into a be-music skin. Every surface the player hands the skin gets its own Pixi + * `WebGLRenderer` on the surface's WebGL 2 context; each frame replays the player's plain frame data into the skin's + * Pixi renderer functions and renders the result into the canvas. + */ +export function definePixiSkin(definition: PixiSkinDefinition): BeMusicSkin<'webgl2'> { + const { gameplay, select, result: _result, ...meta } = definition; + const surfaces = new WeakMap(); + const surfaceFor = (surface: BeMusicSurface<'webgl2'>): PixiSurface | undefined => surfaces.get(surface.canvas); + + return defineBeMusicSkin>({ + ...meta, + context: 'webgl2', + // Pixi needs a stencil buffer for masks; the canvas is composited over the BGA, so it keeps (premultiplied) alpha. + // The player uploads the canvas in the same frame it is drawn, so the drawing buffer needn't be preserved (keeping + // it would cost a full-surface copy every frame). + contextAttributes: { + alpha: true, + premultipliedAlpha: true, + antialias: true, + stencil: true, + preserveDrawingBuffer: false, + }, + async setup(surface) { + const renderer = new WebGLRenderer(); + await renderer.init({ + canvas: surface.canvas, + context: surface.context, + width: surface.width, + height: surface.height, + resolution: surface.pixelRatio, + backgroundAlpha: 0, + antialias: true, + autoDensity: false, + }); + surfaces.set(surface.canvas, new PixiSurface(renderer)); + }, + teardown(surface) { + surfaceFor(surface)?.destroy(); + surfaces.delete(surface.canvas); + }, + gameplay: { + bombDurationMs: gameplay.bombDurationMs, + draw(surface, frame) { + const target = surfaceFor(surface); + if (!target) return; + target.screen ??= new GameplayScreen(definition); + if (!(target.screen instanceof GameplayScreen)) return; + const screen = target.screen; + target.render(surface, () => screen.build(frame)); + }, + }, + select: { + layout: select.layout, + outroMs: select.outroMs, + draw(surface, frame) { + const target = surfaceFor(surface); + if (!target) return; + target.screen ??= new SelectScreen(definition.select.createRenderer()); + if (!(target.screen instanceof SelectScreen)) return; + const screen = target.screen; + target.render(surface, () => screen.build(frame)); + }, + }, + result: { + draw(surface, frame) { + const target = surfaceFor(surface); + if (!target) return; + target.screen ??= new ResultScreen(definition); + if (!(target.screen instanceof ResultScreen)) return; + const screen = target.screen; + target.render(surface, () => screen.build(frame)); + }, + }, + }); +} + +/** One surface's renderer and the screen it shows. */ +class PixiSurface { + screen: GameplayScreen | SelectScreen | ResultScreen | undefined; + private readonly renderer: WebGLRenderer; + + constructor(renderer: WebGLRenderer) { + this.renderer = renderer; + } + + /** Matches the renderer to the surface's size and density, builds the frame, and renders it into the canvas. */ + render(surface: BeMusicSurface, build: () => Container): void { + const renderer = this.renderer; + setTextResolution(surface.pixelRatio); + const root = build(); + // The canvas never sees the pointer (the player handles input), so skip Pixi's hit-testing tree entirely. + root.eventMode = 'none'; + root.interactiveChildren = false; + if ( + renderer.resolution !== surface.pixelRatio || + renderer.screen.width !== surface.width || + renderer.screen.height !== surface.height + ) { + renderer.resize(surface.width, surface.height, surface.pixelRatio); + } + renderer.render({ container: root, clear: true }); + } + + destroy(): void { + this.screen?.destroy(); + this.screen = undefined; + this.renderer.destroy(); + } +} + +/** Gameplay: chrome behind, then lanes, notes, hit effects, and the chrome overlay — the player's old layer order. */ +class GameplayScreen { + private readonly definition: PixiSkinDefinition; + private readonly root = new Container(); + private readonly layer = new Container(); + private readonly lanes = new Graphics(); + /** Every note and long note this frame, drawn into one Graphics (they only add geometry, in draw order). */ + private readonly notes = new Graphics(); + private readonly bombs = new Container(); + private readonly overlayLayer = new Container(); + private readonly layerPool = new ChildPool(this.layer); + private readonly overlayLayerPool = new ChildPool(this.overlayLayer); + private readonly bombPool = new ChildPool(this.bombs); + + constructor(definition: PixiSkinDefinition) { + this.definition = definition; + this.root.addChild(this.layer, this.lanes, this.notes, this.bombs, this.overlayLayer); + } + + build(frame: BeMusicGameplayFrame): Container { + const skin = this.definition.gameplay; + const { runtime, nowMs, effects, audio } = frame; + const combo = runtime.combo; + + // Chrome renderers may shake or scale their layers; every frame starts from rest. + this.layer.position.set(0, 0); + this.layer.scale.set(1); + this.overlayLayer.position.set(0, 0); + this.overlayLayer.scale.set(1); + this.layerPool.begin(); + this.overlayLayerPool.begin(); + try { + skin.renderChrome({ + layer: this.layer, + overlayLayer: this.overlayLayer, + layerPool: this.layerPool, + overlayLayerPool: this.overlayLayerPool, + runtime, + layout: frame.layout, + }); + } finally { + this.layerPool.end(); + this.overlayLayerPool.end(); + } + + this.lanes.clear(); + skin.renderLanes({ + graphics: this.lanes, + lanes: frame.lanes, + beatPhase: frame.beatPhase, + nowMs, + combo, + effects, + audio, + }); + + const notes = this.notes; + notes.clear(); + for (const note of frame.longNotes) { + skin.renderLongNote({ + graphics: notes, + kind: note.kind, + x: note.x, + w: note.w, + top: note.top, + bottom: note.bottom, + nowMs, + }); + } + for (const note of frame.notes) { + skin.renderNote({ graphics: notes, kind: note.kind, x: note.x, w: note.w, y: note.y, nowMs }); + } + + this.bombPool.begin(); + try { + skin.renderBombs({ pool: this.bombPool, bombs: frame.bombs, nowMs, combo, effects, audio }); + } finally { + this.bombPool.end(); + } + return this.root; + } + + destroy(): void { + for (const pool of [this.layerPool, this.overlayLayerPool, this.bombPool]) pool.destroy(); + this.root.destroy({ children: true }); + } +} + +/** + * Select: the renderer's persistent back / front layers around a layer rebuilt whenever the select state changes (or + * while the renderer asks for it). Hit areas declared during the rebuild are handed to the player on every frame. + */ +class SelectScreen { + private readonly renderer: PixiSelectRenderer; + private readonly root = new Container(); + private readonly layer = new Container(); + /** Retires the previous rebuild a frame late, so unchanged labels keep their text textures. */ + private readonly disposer = new LaggedDisposer(); + private revision = -1; + private needsFrame = false; + private hits: PixiHitArea[] = []; + + constructor(renderer: PixiSelectRenderer) { + this.renderer = renderer; + this.root.addChild(renderer.backLayer, this.layer, renderer.frontLayer); + } + + build(frame: BeMusicSelectFrame): Container { + if (frame.revision !== this.revision || this.needsFrame || frame.launchAt !== undefined) { + this.revision = frame.revision; + this.disposer.cycle(this.layer); + // The Pixi renderer declares its click targets through `addHitArea`; they are replayed into `frame.hit` below. + const pixiFrame: PixiSelectFrame = { ...frame, layer: this.layer }; + this.hits = collectHitAreas(() => { + this.needsFrame = this.renderer.render(pixiFrame); + }); + } + for (const area of this.hits) frame.hit(area.x, area.y, area.w, area.h, area.action, area.cursor); + this.renderer.tick(frame.nowMs, frame.focusedSong, frame.launchAt, frame.audio); + return this.root; + } + + destroy(): void { + this.disposer.flush(); + this.renderer.dispose(); + this.root.destroy({ children: true }); + } +} + +/** Result: one layer rebuilt every frame, its nodes retired a frame late so unchanged labels keep their textures. */ +class ResultScreen { + private readonly definition: PixiSkinDefinition; + private readonly root = new Container(); + private readonly backdrop = new Container(); + private readonly layer = new Container(); + private readonly disposer = new LaggedDisposer(); + + constructor(definition: PixiSkinDefinition) { + this.definition = definition; + this.root.addChild(this.backdrop, this.layer); + } + + build(frame: BeMusicResultFrame): Container { + this.disposer.cycle(this.layer); + this.definition.result.render({ ...frame, layer: this.layer, backdrop: this.backdrop }); + return this.root; + } + + destroy(): void { + this.disposer.flush(); + this.root.destroy({ children: true }); + } +} diff --git a/packages/player-web/src/skins/pixi-kit/hud-text.test.ts b/packages/player-web/src/skins/pixi-kit/hud-text.test.ts new file mode 100644 index 00000000..ac3c1185 --- /dev/null +++ b/packages/player-web/src/skins/pixi-kit/hud-text.test.ts @@ -0,0 +1,17 @@ +import { TextStyle } from 'pixi.js'; +import { describe, expect, it } from 'vite-plus/test'; +import { rememberStyle } from './hud-text.ts'; + +describe('rememberStyle', () => { + it('caches styles and forgets the oldest once full', () => { + const cache = new Map(); + const first = new TextStyle(); + rememberStyle(cache, 'first', first); + for (let index = 0; index < 511; index += 1) rememberStyle(cache, `k${index}`, new TextStyle()); + expect(cache.get('first')).toBe(first); + rememberStyle(cache, 'overflow', new TextStyle()); + expect(cache.size).toBe(512); + expect(cache.has('first')).toBe(false); + expect(cache.has('overflow')).toBe(true); + }); +}); diff --git a/packages/player-web/src/scene/default/hud-text.ts b/packages/player-web/src/skins/pixi-kit/hud-text.ts similarity index 63% rename from packages/player-web/src/scene/default/hud-text.ts rename to packages/player-web/src/skins/pixi-kit/hud-text.ts index 84ad2c7c..9c76b7c2 100644 --- a/packages/player-web/src/scene/default/hud-text.ts +++ b/packages/player-web/src/skins/pixi-kit/hud-text.ts @@ -1,8 +1,8 @@ import { CanvasTextMetrics, type Container, Text, TextStyle } from 'pixi.js'; -import type { ChildPool } from '../pixi-utils.ts'; -import { getDesignTextResolution } from '../core/viewport.ts'; -import { DEFAULT_TEXT_FONT } from './fonts.ts'; -import { layoutTabularRun } from './phantom-style.ts'; +import { DEFAULT_TEXT_FONT, layoutTabularRun } from '@be-music/skin-sdk'; +import type { ChildPool } from './pools.ts'; +import { textResolution } from './resolution.ts'; +import { alignCapCenter } from './text-metrics.ts'; /** * Per-frame HUD text for built-in skins. Nodes come from a {@link ChildPool} when one is given (so a 60 fps redraw @@ -10,7 +10,7 @@ import { layoutTabularRun } from './phantom-style.ts'; * figures. */ -type TextWeight = '300' | '400' | '500' | '600' | '700' | '800' | '900'; +type TextWeight = '200' | '300' | '400' | '500' | '600' | '700' | '800' | '900'; export interface HudTextOptions { size?: number; @@ -26,6 +26,8 @@ export interface HudTextOptions { rotation?: number; stroke?: { color: number; width: number; alignment?: number; join?: 'round' | 'bevel' | 'miter' }; dropShadow?: { color: number; alpha: number; blur: number; distance: number; angle?: number }; + /** Uniform scale around the anchor, applied after the `maxWidth` squeeze (judge / combo "punch"). */ + scale?: number; } export function addHudText( @@ -38,16 +40,21 @@ export function addHudText( ): Text { const node = pool?.acquireText() ?? new Text(); // Rasterize at the final device density so text stays crisp after the viewport magnifies the design canvas. - const resolution = getDesignTextResolution(); + const resolution = textResolution(); if (node.resolution !== resolution) { node.resolution = resolution; } - const style = resolveTextStyle(opts); + // A plain fill (no stroke or shadow to keep their own colours) rasterizes white and takes its colour from the tint, so + // a colour change — a flashing judgement, a combo turning gold — reuses the glyph texture instead of re-rendering it. + const tintable = isTintable(opts); + const style = resolveTextStyle(opts, tintable); node.text = text; if (node.style !== style) { node.style = style; } + node.tint = tintable ? (opts.fill ?? 0xffffff) : 0xffffff; node.anchor.set(opts.anchorX ?? 0, opts.anchorY ?? 0); + alignCapCenter(node, opts.fontFamily ?? DEFAULT_TEXT_FONT, opts.weight ?? '500', opts.size ?? 10); node.position.set(x, y); node.scale.set(1, 1); // Pooled texts keep their previous skew / rotation, so both are always written. @@ -56,6 +63,10 @@ export function addHudText( if (opts.maxWidth !== undefined && node.width > opts.maxWidth) { node.scale.x = opts.maxWidth / node.width; } + if (opts.scale !== undefined && opts.scale !== 1) { + node.scale.x *= opts.scale; + node.scale.y = opts.scale; + } if (!pool) { layer.addChild(node); } @@ -74,25 +85,26 @@ export function addHudNumber( opts: HudTextOptions = {}, pool?: ChildPool, ): void { - const style = resolveTextStyle(opts); + const style = resolveTextStyle(opts, isTintable(opts)); const run = layoutTabularRun(text, (char) => measureGlyph(char, style)); const squeeze = opts.maxWidth !== undefined && run.width > opts.maxWidth ? opts.maxWidth / run.width : 1; - const left = x - run.width * squeeze * (opts.anchorX ?? 0); + const scale = opts.scale ?? 1; + const left = x - run.width * squeeze * scale * (opts.anchorX ?? 0); for (const glyph of run.glyphs) { if (glyph.char === ' ') continue; const node = addHudText( layer, glyph.char, - left + (glyph.x + glyph.w / 2) * squeeze, + left + (glyph.x + glyph.w / 2) * squeeze * scale, y, - { ...opts, anchorX: 0.5, maxWidth: undefined }, + { ...opts, anchorX: 0.5, maxWidth: undefined, scale: undefined }, pool, ); - node.scale.x = squeeze; + node.scale.set(squeeze * scale, scale); } } -const GLYPH_WIDTH_CACHE = new Map>(); +const GLYPH_WIDTH_CACHE = new WeakMap>(); function measureGlyph(char: string, style: TextStyle): number { let widths = GLYPH_WIDTH_CACHE.get(style); @@ -109,11 +121,17 @@ function measureGlyph(char: string, style: TextStyle): number { return width; } -function resolveTextStyle(opts: HudTextOptions): TextStyle { +/** Whether `opts` paints a plain fill, which {@link addHudText} applies as a tint over a white raster. */ +function isTintable(opts: HudTextOptions): boolean { + return opts.stroke === undefined && opts.dropShadow === undefined; +} + +function resolveTextStyle(opts: HudTextOptions, whiteFill = false): TextStyle { const stroke = opts.stroke; const shadow = opts.dropShadow; + const fill = whiteFill ? 0xffffff : (opts.fill ?? 0xffffff); const key = [ - opts.fill ?? 0xffffff, + fill, opts.size ?? 10, opts.weight ?? '500', opts.fontFamily ?? DEFAULT_TEXT_FONT, @@ -131,7 +149,7 @@ function resolveTextStyle(opts: HudTextOptions): TextStyle { let style = TEXT_STYLE_CACHE.get(key); if (!style) { style = new TextStyle({ - fill: opts.fill ?? 0xffffff, + fill, fontSize: opts.size ?? 10, fontWeight: opts.weight ?? '500', fontFamily: opts.fontFamily ?? DEFAULT_TEXT_FONT, @@ -151,9 +169,21 @@ function resolveTextStyle(opts: HudTextOptions): TextStyle { } : {}), }); - TEXT_STYLE_CACHE.set(key, style); + rememberStyle(TEXT_STYLE_CACHE, key, style); } return style; } const TEXT_STYLE_CACHE = new Map(); + +/** Styles kept per cache: plenty for every look on screen, bounded when an effect animates a style every frame. */ +const STYLE_CACHE_LIMIT = 512; + +/** Caches `style` under `key`, forgetting the oldest entry once the cache is full (nodes keep the styles they use). */ +export function rememberStyle(cache: Map, key: string, style: TextStyle): void { + if (cache.size >= STYLE_CACHE_LIMIT) { + const oldest = cache.keys().next().value; + if (oldest !== undefined) cache.delete(oldest); + } + cache.set(key, style); +} diff --git a/packages/player-web/src/skins/pixi-kit/index.ts b/packages/player-web/src/skins/pixi-kit/index.ts new file mode 100644 index 00000000..139914c9 --- /dev/null +++ b/packages/player-web/src/skins/pixi-kit/index.ts @@ -0,0 +1,14 @@ +/** + * Pixi kit — what the built-in Pixi skins (Phantom, Synesthesia, Lattice) share on top of the framework-free skin SDK: + * {@link definePixiSkin}, which runs Pixi renderer functions on the canvas the player hands a skin, plus pooled HUD + * text, skin text and hit areas, cap-height alignment, GPU point particles, and key-beam gradients. It belongs to the + * skins, not the SDK: a skin built with another framework (three.js, raw WebGL, Canvas 2D) needs none of it. + */ +export * from './define-pixi-skin.ts'; +export * from './types.ts'; +export * from './pools.ts'; +export * from './hud-text.ts'; +export * from './skin-text.ts'; +export * from './text-metrics.ts'; +export * from './particle-layer.ts'; +export * from './key-beam.ts'; diff --git a/packages/player-web/src/skins/pixi-kit/key-beam.test.ts b/packages/player-web/src/skins/pixi-kit/key-beam.test.ts new file mode 100644 index 00000000..90708549 --- /dev/null +++ b/packages/player-web/src/skins/pixi-kit/key-beam.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vite-plus/test'; +import { KEY_BEAM_STOPS } from './key-beam.ts'; + +describe('KEY_BEAM_STOPS', () => { + it('runs from clear at the top to near solid at the judgement line', () => { + expect(KEY_BEAM_STOPS[0]).toEqual({ offset: 0, alpha: 0 }); + expect(KEY_BEAM_STOPS[KEY_BEAM_STOPS.length - 1]!.offset).toBe(1); + expect(KEY_BEAM_STOPS[KEY_BEAM_STOPS.length - 1]!.alpha).toBeGreaterThanOrEqual(0.85); + }); + + it('only ever brightens toward the judgement line', () => { + for (let index = 1; index < KEY_BEAM_STOPS.length; index += 1) { + expect(KEY_BEAM_STOPS[index]!.offset).toBeGreaterThan(KEY_BEAM_STOPS[index - 1]!.offset); + expect(KEY_BEAM_STOPS[index]!.alpha).toBeGreaterThan(KEY_BEAM_STOPS[index - 1]!.alpha); + } + }); + + it('keeps the upper half of the beam faint so falling notes stay readable', () => { + const upper = KEY_BEAM_STOPS.filter((stop) => stop.offset <= 0.5); + for (const stop of upper) expect(stop.alpha).toBeLessThanOrEqual(0.1); + }); +}); diff --git a/packages/player-web/src/skins/pixi-kit/key-beam.ts b/packages/player-web/src/skins/pixi-kit/key-beam.ts new file mode 100644 index 00000000..cf887bbb --- /dev/null +++ b/packages/player-web/src/skins/pixi-kit/key-beam.ts @@ -0,0 +1,39 @@ +import { Color, FillGradient } from 'pixi.js'; + +/** + * Key-beam gradient shared by the built-in skins: the lane's colour standing solid at the judgement line and easing out + * toward the top along a curve, so a press reads as a clear laser at a glance while the upper lane — where the notes + * are still on their way down — only carries a faint tint. + */ + +/** Opacity stops, from the top of the beam (offset 0) to the judgement line (offset 1). */ +export const KEY_BEAM_STOPS: ReadonlyArray<{ offset: number; alpha: number }> = [ + { offset: 0, alpha: 0 }, + { offset: 0.3, alpha: 0.05 }, + { offset: 0.6, alpha: 0.2 }, + { offset: 0.82, alpha: 0.48 }, + { offset: 0.94, alpha: 0.75 }, + { offset: 1, alpha: 0.9 }, +]; + +const GRADIENTS = new Map(); + +/** Vertical beam gradient in `color` (cached per colour); fill a lane-wide rect ending at the judgement line with it. */ +export function keyBeamGradient(color: number): FillGradient { + let gradient = GRADIENTS.get(color); + if (!gradient) { + const rgb = new Color(color); + gradient = new FillGradient({ + type: 'linear', + start: { x: 0, y: 0 }, + end: { x: 0, y: 1 }, + textureSpace: 'local', + colorStops: KEY_BEAM_STOPS.map((stop) => ({ + offset: stop.offset, + color: rgb.setAlpha(stop.alpha).toRgbaString(), + })), + }); + GRADIENTS.set(color, gradient); + } + return gradient; +} diff --git a/packages/player-web/src/skins/pixi-kit/particle-layer.ts b/packages/player-web/src/skins/pixi-kit/particle-layer.ts new file mode 100644 index 00000000..345a19b2 --- /dev/null +++ b/packages/player-web/src/skins/pixi-kit/particle-layer.ts @@ -0,0 +1,113 @@ +import { Particle, ParticleContainer, Texture, type Container, type Graphics } from 'pixi.js'; + +/** + * Points and short segments drawn as GPU particles instead of `Graphics` geometry. + * + * The built-in skins scatter thousands of tiny marks every frame (Synesthesia's floor lattice, dust, and powder; + * Lattice's needle field). As `Graphics` shapes each one is re-triangulated and re-uploaded per frame, which dominated + * the skins' frame time and garbage. Here each mark is a {@link Particle} (a 1x1 white texture scaled, rotated, and + * tinted), kept across frames and only rewritten — no geometry is rebuilt. + * + * A layer rides along with an anchor `Graphics` (a pooled graphics the skin acquires in the same order every frame): + * it sits right after the anchor in the anchor's parent and copies the anchor's blend mode, so its points paint exactly + * where the anchor's rects would have. Each frame the points written since the last render are shown and the rest are + * dropped, so a layer whose anchor stops receiving points simply goes empty. + */ +export class PointLayer { + private readonly container: ParticleContainer; + private readonly particles: Particle[] = []; + private cursor = 0; + private touched = false; + private shown = 0; + + private readonly anchor: Graphics; + + public constructor(anchor: Graphics) { + this.anchor = anchor; + this.container = new ParticleContainer({ + dynamicProperties: { position: true, vertex: true, color: true, rotation: true, uvs: false }, + }); + this.container.label = 'particle-layer'; + this.container.onRender = () => this.commit(); + } + + /** Writes one square point centred on `(cx, cy)`. */ + public point(cx: number, cy: number, size: number, color: number, alpha: number): void { + const particle = this.next(); + particle.x = cx; + particle.y = cy; + particle.scaleX = size; + particle.scaleY = size; + particle.rotation = 0; + particle.tint = color; + particle.alpha = alpha; + } + + /** Writes one straight segment from `(x0, y0)` to `(x1, y1)`, `width` px thick (a rotated thin quad). */ + public segment(x0: number, y0: number, x1: number, y1: number, width: number, color: number, alpha: number): void { + const particle = this.next(); + const dx = x1 - x0; + const dy = y1 - y0; + particle.x = (x0 + x1) / 2; + particle.y = (y0 + y1) / 2; + particle.scaleX = Math.hypot(dx, dy); + particle.scaleY = width; + particle.rotation = Math.atan2(dy, dx); + particle.tint = color; + particle.alpha = alpha; + } + + private next(): Particle { + this.attach(); + if (!this.touched) { + this.touched = true; + this.cursor = 0; + } + let particle = this.particles[this.cursor]; + if (!particle) { + particle = new Particle({ texture: Texture.WHITE, anchorX: 0.5, anchorY: 0.5 }); + this.particles.push(particle); + } + this.cursor += 1; + return particle; + } + + /** Keeps the layer right after its anchor, so the anchor's later siblings (HUD panels, text) stay on top. */ + private attach(): void { + const parent: Container | null = this.anchor.parent; + if (!parent) return; + if (this.container.parent !== parent) parent.addChild(this.container); + const target = parent.getChildIndex(this.anchor) + 1; + if (parent.getChildIndex(this.container) !== target) { + parent.setChildIndex(this.container, Math.min(target, parent.children.length - 1)); + } + this.container.blendMode = this.anchor.blendMode; + this.container.visible = this.anchor.visible; + } + + /** Runs just before the container renders: shows this frame's points and drops the rest. */ + private commit(): void { + const count = this.touched ? this.cursor : 0; + if (count !== this.shown) { + const children = this.container.particleChildren; + children.length = 0; + for (let index = 0; index < count; index += 1) children.push(this.particles[index]!); + this.container.update(); + this.shown = count; + } + this.touched = false; + this.cursor = 0; + } +} + +const LAYERS = new WeakMap(); + +/** The particle layer riding along with `anchor` (created on first use). */ +export function pointLayerFor(anchor: Graphics): PointLayer { + let layer = LAYERS.get(anchor); + if (!layer) { + layer = new PointLayer(anchor); + LAYERS.set(anchor, layer); + } + return layer; +} diff --git a/packages/player-web/src/skins/pixi-kit/pools.ts b/packages/player-web/src/skins/pixi-kit/pools.ts new file mode 100644 index 00000000..05c5d26d --- /dev/null +++ b/packages/player-web/src/skins/pixi-kit/pools.ts @@ -0,0 +1,293 @@ +import { Container, Graphics, Sprite, Text } from 'pixi.js'; + +/** + * Disposes a rebuilt-every-frame layer's children one frame late. + * + * Pixi shares a text texture between live `Text` nodes with the same text, style, and resolution — but a node gives up + * its texture as soon as it leaves the scene. Destroying (or even detaching) last frame's nodes before this frame's are + * drawn drops every such texture to zero references, so each identical label is rasterized and uploaded again every + * frame. {@link cycle} instead hides last frame's children in place and destroys them on the following cycle — by then + * this frame's identical labels have picked up the still-live textures. + */ +export class LaggedDisposer { + private retired: Container[] = []; + + /** Destroys the children retired on the previous cycle, then hides `container`'s children for the next one. */ + public cycle(container: Container): void { + this.flush(); + this.retired = container.children.slice(); + for (const child of this.retired) child.visible = false; + } + + /** Destroys everything still waiting (call on teardown). */ + public flush(): void { + for (const child of this.retired) { + child.removeFromParent(); + child.destroy({ children: true, context: true }); + } + this.retired = []; + } +} + +/** + * Per-frame `Sprite` / `Graphics` / `Text` recycler for a single `Container` layer. + * + * The render pass calls `begin()` to reset the cursor, then `acquireSprite()` / `acquireGraphics()` / + * `acquireText()` for each child it wants to draw. The pool returns a previously-allocated, parented child when + * possible (just toggling it back to `visible = true`); only when the cursor outruns the pool does it allocate a + * fresh one and parent it. After the pass, `end()` hides every child the pass didn't claim — so the next render's + * `begin()` starts from a known clean state without paying the GC cost of `disposeChildren`'s destroy + recreate. + * + * Three separate sub-cursors are tracked so a single render pass can mix sprite / graphics / text reuse without one + * starvation type forcing churn on another. A `Graphics` returned from `acquireGraphics()` is `clear()`ed so the + * caller starts from a blank canvas; a `Sprite` is *not* reset (the caller owns texture / position / size). Text + * objects keep their previous `style` to avoid the heavy paragraph-rebuild cost; the caller updates `text` (and + * `style` only if it actually changed). + * + * The pool's lifetime is tied to its owning layer — the layer's parent view's `dispose()` should call `destroy()` + * here too, which destroys every pooled child. + */ +export class ChildPool { + /** + * Sub-container that hosts every pooled {@link Graphics}. Lives at z-index 0 inside the owner-supplied `layer` so + * its children render BEHIND every {@link Sprite} pooled by this same pool — the LR2 render order has measure + * lines and long-note bodies (Graphics) underneath note heads and skin sprites (Sprite). Keeping every Graphics + * grouped under one sub-container is the whole point of the split: Pixi's batcher walks children in order and + * issues a fresh draw call every time the type changes (Sprite → Graphics or vice-versa, see Pixi's + * `pixijs-performance` skill §Batching), so a layer that interleaves the two types fragments into many small + * draws. Putting all Graphics first, then all Sprites, then all Text reduces a dense frame's + * note-layer draw count from O(num type-switches) to ~3. + */ + private readonly graphicsHost: Container; + private readonly spriteHost: Container; + private readonly textHost: Container; + private readonly sprites: Sprite[] = []; + private spriteCursor = 0; + private readonly graphics: Graphics[] = []; + private graphicsCursor = 0; + private readonly texts: Text[] = []; + private textCursor = 0; + /** + * Per-bucket count of consecutive frames where the bucket's high-water mark exceeded its actual usage by more than + * {@link CHILD_POOL_TRIM_SLACK}. When the count crosses {@link CHILD_POOL_TRIM_AFTER_FRAMES} we shrink the bucket + * back down to `cursor + CHILD_POOL_TRIM_SLACK` and reset the counter. Tracked separately per bucket so a still-hot + * sprite cursor doesn't keep an unused text tail pinned (and vice-versa). + */ + private idleFramesSprites = 0; + private idleFramesGraphics = 0; + private idleFramesTexts = 0; + private readonly layer: Container; + + public constructor(layer: Container) { + this.layer = layer; + this.graphicsHost = new Container(); + this.spriteHost = new Container(); + this.textHost = new Container(); + // Z-order matches LR2 visual layering: Graphics (measure lines, LN body, fallback rects) → Sprite (notes, + // skin chrome) → Text (in-skin labels, fallback HUD). The hosts inherit the parent layer's `eventMode` so + // setting `layer.eventMode = 'none'` (as `scene/lr2/gameplay.ts` does for render-only layers) propagates here too. + this.layer.addChild(this.graphicsHost, this.spriteHost, this.textHost); + } + + /** Resets every cursor. Call once at the top of every render pass. */ + public begin(): void { + this.spriteCursor = 0; + this.graphicsCursor = 0; + this.textCursor = 0; + } + + /** + * Returns a parented `Sprite` ready to receive `texture` / `position` / `width` / `height` / `tint` / `alpha` + * updates. Reuses an existing pooled sprite when possible; allocates and parents a fresh one when the pool is + * exhausted. **The caller must overwrite every property they care about** — values left over from a previous + * pass are not cleared (and resetting them all would defeat the perf win pooling exists for). + */ + public acquireSprite(): Sprite { + let sprite = this.sprites[this.spriteCursor]; + if (!sprite) { + sprite = new Sprite(); + this.spriteHost.addChild(sprite); + this.sprites.push(sprite); + } + // Keep `tint` / `alpha` reset so a previous pass's coloured / faded sprite doesn't leak into a fresh draw. + sprite.visible = true; + sprite.tint = 0xffffff; + sprite.alpha = 1; + sprite.rotation = 0; + sprite.skew.set(0, 0); + sprite.anchor.set(0, 0); + sprite.label = ''; + this.spriteCursor += 1; + return sprite; + } + + /** + * Returns a parented, *cleared* `Graphics` ready for the caller's draw commands. Same recycle rules as + * `acquireSprite`; the only auto-reset is `clear()` (so the previous pass's geometry is gone) plus + * `tint / alpha / position / scale` so a moved / coloured graphic from a prior pass doesn't carry over. + */ + public acquireGraphics(): Graphics { + let graphics = this.graphics[this.graphicsCursor]; + if (!graphics) { + graphics = new Graphics(); + this.graphicsHost.addChild(graphics); + this.graphics.push(graphics); + } + graphics.clear(); + graphics.visible = true; + graphics.tint = 0xffffff; + graphics.alpha = 1; + graphics.position.set(0, 0); + graphics.scale.set(1, 1); + graphics.rotation = 0; + graphics.label = ''; + this.graphicsCursor += 1; + return graphics; + } + + /** + * Returns a parented `Text` ready to receive `text` (and optionally `style`) updates. Text objects are reused + * verbatim — re-assigning the same `style` triggers Pixi's paragraph rebuild, so callers should update `style` + * only when something actually changed. + */ + public acquireText(): Text { + let text = this.texts[this.textCursor]; + if (!text) { + text = new Text(); + this.textHost.addChild(text); + this.texts.push(text); + } + text.visible = true; + text.tint = 0xffffff; + text.alpha = 1; + text.rotation = 0; + text.anchor.set(0, 0); + text.label = ''; + this.textCursor += 1; + return text; + } + + /** + * Hides every pooled child the current pass didn't acquire, and idle-decay-shrinks each bucket whose high-water + * mark hasn't been touched recently. Call once at the bottom of every render pass. + * + * Without the shrink, a single peak frame (e.g. a transition where dozens of skin sprites are visible at once) would + * pin the bucket's allocation for the rest of the scene's lifetime; downstream sections of the chart that draw far + * fewer children would still pay the memory cost. The decay is intentionally gentle — the bucket has to underflow by + * more than {@link CHILD_POOL_TRIM_SLACK} for {@link CHILD_POOL_TRIM_AFTER_FRAMES} consecutive frames before any + * children are destroyed — so a noisy frame-to-frame range (e.g. between bombs and no-bombs) never triggers the + * shrink. When it does fire, the bucket is trimmed back to `cursor + slack`; subsequent peaks just allocate fresh + * children, paying a one-frame allocation cost in exchange for the steady-state memory saving. + */ + public end(): void { + for (let i = this.spriteCursor; i < this.sprites.length; i++) { + this.sprites[i]!.visible = false; + } + for (let i = this.graphicsCursor; i < this.graphics.length; i++) { + this.graphics[i]!.visible = false; + } + for (let i = this.textCursor; i < this.texts.length; i++) { + this.texts[i]!.visible = false; + } + this.idleFramesSprites = trimIdleBucket(this.sprites, this.spriteCursor, this.idleFramesSprites); + this.idleFramesGraphics = trimIdleBucket(this.graphics, this.graphicsCursor, this.idleFramesGraphics); + this.idleFramesTexts = trimIdleBucket(this.texts, this.textCursor, this.idleFramesTexts); + } + + /** + * Returns the bucket's currently-allocated child count for debugging / telemetry. The cursor is omitted on purpose; + * call sites only need the underlying capacity. + */ + public size(): { sprites: number; graphics: number; texts: number } { + return { sprites: this.sprites.length, graphics: this.graphics.length, texts: this.texts.length }; + } + + /** Destroys every pooled child (and `clear()`s every Graphics' context). Call from the owner's `dispose()`. */ + public destroy(): void { + for (const sprite of this.sprites) { + try { + sprite.destroy(); + } catch { + // Already destroyed / detached — both terminal states. + } + } + for (const graphics of this.graphics) { + try { + graphics.destroy({ context: true }); + } catch { + // ditto + } + } + for (const text of this.texts) { + try { + text.destroy(); + } catch { + // ditto + } + } + // Tear down the sub-host Containers as well. The pooled children are already destroyed above so the host + // destroy is just freeing the empty Container shells; we nevertheless pass `children: true` defensively in + // case a caller injected something into the host directly (we don't, but a future caller might). + for (const host of [this.graphicsHost, this.spriteHost, this.textHost]) { + try { + host.destroy({ children: true }); + } catch { + // ditto + } + } + this.sprites.length = 0; + this.graphics.length = 0; + this.texts.length = 0; + this.spriteCursor = 0; + this.graphicsCursor = 0; + this.textCursor = 0; + this.idleFramesSprites = 0; + this.idleFramesGraphics = 0; + this.idleFramesTexts = 0; + } +} + +/** + * Number of consecutive underutilized frames before the {@link ChildPool} shrinks the affected bucket. ~5 s at 60 fps — + * long enough that frame-to-frame noise never fires the shrink, short enough that a section of the chart that stops + * spawning bombs / particles releases its peak allocation while it's still paused. + */ +const CHILD_POOL_TRIM_AFTER_FRAMES = 300; +/** + * Slack kept above the cursor when the {@link ChildPool} shrinks a bucket. Avoids re-allocating a child the very next + * frame after a one-frame valley between two peaks; also amortises the shrink/grow cycle if the chart keeps the cursor + * within a small band around its trim target. + */ +const CHILD_POOL_TRIM_SLACK = 8; + +/** + * Per-bucket helper for {@link ChildPool.end}: increments the idle-frame counter when the bucket is over-allocated by + * more than {@link CHILD_POOL_TRIM_SLACK}, fires the destroy + tail-pop when the counter crosses + * {@link CHILD_POOL_TRIM_AFTER_FRAMES}, and resets the counter as soon as utilization comes back up. Returns the new + * counter value so the caller can persist it on the pool. + */ +function trimIdleBucket(bucket: T[], cursor: number, idleFrames: number): number { + if (bucket.length <= cursor + CHILD_POOL_TRIM_SLACK) { + return 0; + } + const next = idleFrames + 1; + if (next < CHILD_POOL_TRIM_AFTER_FRAMES) { + return next; + } + const target = cursor + CHILD_POOL_TRIM_SLACK; + while (bucket.length > target) { + const child = bucket.pop()!; + try { + // `Graphics` needs `context: true` to free its owned `GraphicsContext` (matches the destroy options used by + // {@link ChildPool.destroy}). For Sprite / Text the default options are correct. + if (child instanceof Graphics) { + child.destroy({ context: true }); + } else { + child.destroy(); + } + } catch { + // Already destroyed / detached — both terminal states; nothing to do. + } + } + return 0; +} diff --git a/packages/player-web/src/skins/pixi-kit/resolution.test.ts b/packages/player-web/src/skins/pixi-kit/resolution.test.ts new file mode 100644 index 00000000..63d5205b --- /dev/null +++ b/packages/player-web/src/skins/pixi-kit/resolution.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vite-plus/test'; +import { setTextResolution, textResolution } from './resolution.ts'; + +describe('setTextResolution', () => { + it('follows the surface density, capped at 8 and falling back to 1', () => { + setTextResolution(2.5); + expect(textResolution()).toBe(2.5); + setTextResolution(12); + expect(textResolution()).toBe(8); + setTextResolution(0); + expect(textResolution()).toBe(1); + setTextResolution(Number.NaN); + expect(textResolution()).toBe(1); + }); +}); diff --git a/packages/player-web/src/skins/pixi-kit/resolution.ts b/packages/player-web/src/skins/pixi-kit/resolution.ts new file mode 100644 index 00000000..8fc7c555 --- /dev/null +++ b/packages/player-web/src/skins/pixi-kit/resolution.ts @@ -0,0 +1,17 @@ +/** Most texels per design pixel text rasterizes at, whatever the display. */ +const MAX_TEXT_RESOLUTION = 8; + +let current = 1; + +/** + * Sets the density Pixi-kit text rasterizes at: the surface's canvas pixels per design pixel, so one glyph texel lands + * on one screen pixel. The Pixi skin adapter calls it before each frame. + */ +export function setTextResolution(pixelRatio: number): void { + current = Number.isFinite(pixelRatio) && pixelRatio > 0 ? Math.min(MAX_TEXT_RESOLUTION, pixelRatio) : 1; +} + +/** Density text should rasterize at this frame (see {@link setTextResolution}). */ +export function textResolution(): number { + return current; +} diff --git a/packages/player-web/src/skins/pixi-kit/skin-text.test.ts b/packages/player-web/src/skins/pixi-kit/skin-text.test.ts new file mode 100644 index 00000000..c2995a3e --- /dev/null +++ b/packages/player-web/src/skins/pixi-kit/skin-text.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vite-plus/test'; +import { addHitArea, collectHitAreas } from './skin-text.ts'; + +describe('collectHitAreas', () => { + it('gathers the hit areas declared while building', () => { + const play = (): void => {}; + const hits = collectHitAreas(() => { + addHitArea(10, 20, 30, 40, 'pointer', play); + }); + expect(hits).toEqual([{ x: 10, y: 20, w: 30, h: 40, cursor: 'pointer', action: play }]); + }); + + it('ignores hit areas declared outside a build and keeps nested builds apart', () => { + addHitArea(0, 0, 1, 1, 'pointer', () => {}); + const outer = collectHitAreas(() => { + const inner = collectHitAreas(() => addHitArea(1, 1, 1, 1, 'text', () => {})); + expect(inner).toHaveLength(1); + addHitArea(2, 2, 2, 2, 'pointer', () => {}); + }); + expect(outer.map((hit) => hit.x)).toEqual([2]); + }); +}); diff --git a/packages/player-web/src/skins/pixi-kit/skin-text.ts b/packages/player-web/src/skins/pixi-kit/skin-text.ts new file mode 100644 index 00000000..e709fb89 --- /dev/null +++ b/packages/player-web/src/skins/pixi-kit/skin-text.ts @@ -0,0 +1,124 @@ +import { type Color, type Container, Text, TextStyle } from 'pixi.js'; +import { DEFAULT_TEXT_FONT } from '@be-music/skin-sdk'; +import { rememberStyle } from './hud-text.ts'; +import { textResolution } from './resolution.ts'; +import { alignCapCenter } from './text-metrics.ts'; + +export interface SkinTextOptions { + size?: number; + weight?: '200' | '300' | '400' | '500' | '600' | '700' | '800' | '900'; + fill?: number | Color; + fontFamily?: string; + letterSpacing?: number; + anchorX?: number; + anchorY?: number; + maxWidth?: number; + skewX?: number; + alpha?: number; + stroke?: { color: number; width: number; alignment?: number; join?: 'round' | 'bevel' | 'miter' }; + /** Hard offset shadow (blur 0) at 45°. */ + dropShadow?: { color: number; distance: number; blur?: number; alpha?: number; angle?: number }; +} + +/** + * One text node for built-in skin chrome that is rebuilt on input (select / result), squeezed horizontally to + * `maxWidth` when needed. Per-frame HUD text uses pooled nodes instead (see the gameplay chrome renderers). + */ +export function addSkinText(layer: Container, text: string, x: number, y: number, options: SkinTextOptions = {}): Text { + const node = new Text({ + text, + // Rasterize at the final device density so text stays crisp after the viewport magnifies the design canvas. + resolution: textResolution(), + style: resolveSkinTextStyle(options), + }); + node.anchor.set(options.anchorX ?? 0, options.anchorY ?? 0); + alignCapCenter(node, options.fontFamily ?? DEFAULT_TEXT_FONT, options.weight ?? '400', options.size ?? 10); + node.position.set(x, y); + node.skew.set(options.skewX ?? 0, 0); + node.alpha = options.alpha ?? 1; + if (options.maxWidth !== undefined && node.width > options.maxWidth) { + node.scale.x = options.maxWidth / node.width; + } + layer.addChild(node); + return node; +} + +const SKIN_TEXT_STYLES = new Map(); + +/** + * The shared `TextStyle` for `options`. Pixi keys a text's texture by its style *instance*, so screens rebuilt every + * frame must reuse one instance per look for unchanged labels to keep their texture instead of re-rasterizing. + */ +export function resolveSkinTextStyle(options: SkinTextOptions): TextStyle { + const shadow = options.dropShadow; + const stroke = options.stroke; + const fill = options.fill ?? 0xffffff; + const key = [ + typeof fill === 'number' ? fill : fill.toNumber(), + options.size ?? 10, + options.weight ?? '400', + options.fontFamily ?? DEFAULT_TEXT_FONT, + options.letterSpacing ?? 0, + stroke ? `${stroke.color}/${stroke.width}/${stroke.alignment ?? ''}/${stroke.join ?? ''}` : '', + shadow ? `${shadow.color}/${shadow.distance}/${shadow.blur ?? 0}/${shadow.alpha ?? 1}/${shadow.angle ?? ''}` : '', + ].join('|'); + let style = SKIN_TEXT_STYLES.get(key); + if (!style) { + style = new TextStyle({ + fill, + fontSize: options.size ?? 10, + fontWeight: options.weight ?? '400', + fontFamily: options.fontFamily ?? DEFAULT_TEXT_FONT, + letterSpacing: options.letterSpacing ?? 0, + stroke: options.stroke, + // Room for the blurred glow; without it the shadow is clipped to the glyph box and shows as a hard rectangle. + padding: shadow ? Math.ceil((shadow.blur ?? 0) * 2 + shadow.distance) : 0, + ...(shadow + ? { + dropShadow: { + color: shadow.color, + alpha: shadow.alpha ?? 1, + blur: shadow.blur ?? 0, + distance: shadow.distance, + angle: shadow.angle ?? Math.PI / 4, + }, + } + : {}), + }); + rememberStyle(SKIN_TEXT_STYLES, key, style); + } + return style; +} + +/** A click target declared while a select screen rebuilds (see {@link addHitArea}). */ +export interface PixiHitArea { + x: number; + y: number; + w: number; + h: number; + cursor: string; + action: () => void; +} + +let hitCollector: PixiHitArea[] | undefined; + +/** Collects the hit areas `build` declares through {@link addHitArea}. */ +export function collectHitAreas(build: () => void): PixiHitArea[] { + const previous = hitCollector; + const collected: PixiHitArea[] = []; + hitCollector = collected; + try { + build(); + } finally { + hitCollector = previous; + } + return collected; +} + +/** + * Makes the rect (design pixels) run `action` when clicked. Pixi skins draw onto their own canvas, which never sees the + * pointer, so the rect is handed to the player (`frame.hit`) instead of becoming an interactive Pixi node. + */ +export function addHitArea(x: number, y: number, w: number, h: number, cursor: string, action: () => void): void { + hitCollector?.push({ x, y, w, h, cursor, action }); +} diff --git a/packages/player-web/src/skins/pixi-kit/text-metrics.test.ts b/packages/player-web/src/skins/pixi-kit/text-metrics.test.ts new file mode 100644 index 00000000..1c4b5004 --- /dev/null +++ b/packages/player-web/src/skins/pixi-kit/text-metrics.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vite-plus/test'; +import { capCenterOffset, capCenterOffsetEm, fontShorthand } from './text-metrics.ts'; + +describe('capCenterOffset', () => { + it('is zero when the capitals are centred in the line box', () => { + // Box from -80 (ascent) to +20 (descent): centre at -30 above the baseline; caps 60 tall centre at -30 too. + expect(capCenterOffset(80, 20, 60)).toBe(0); + }); + + it('is positive for a top-heavy box (tall accent, shallow descender)', () => { + // Anton-like metrics: the capitals sit 12 units below the box centre. + expect(capCenterOffset(122.9, 12.2, 86.7)).toBeCloseTo(12, 6); + }); + + it('is zero for non-finite input', () => { + expect(capCenterOffset(Number.NaN, 10, 10)).toBe(0); + }); +}); + +describe('fontShorthand', () => { + it('quotes named families and leaves generic ones bare', () => { + expect(fontShorthand('400', 100, 'Dela Gothic One, M PLUS 1p, sans-serif')).toBe( + '400 100px "Dela Gothic One", "M PLUS 1p", sans-serif', + ); + }); + + it('does not double-quote pre-quoted names', () => { + expect(fontShorthand('800', 12, '"Azeret Mono", monospace')).toBe('800 12px "Azeret Mono", monospace'); + }); +}); + +describe('capCenterOffsetEm', () => { + it('falls back to no correction without a DOM', () => { + expect(capCenterOffsetEm('Anton', '400')).toBe(0); + }); +}); diff --git a/packages/player-web/src/skins/pixi-kit/text-metrics.ts b/packages/player-web/src/skins/pixi-kit/text-metrics.ts new file mode 100644 index 00000000..7b74201d --- /dev/null +++ b/packages/player-web/src/skins/pixi-kit/text-metrics.ts @@ -0,0 +1,96 @@ +import type { Text } from 'pixi.js'; + +/** + * Optical vertical alignment for built-in skin text. + * + * Pixi sizes a text line from the actual ink of `"|ÉqÅM"`, so a face with a tall accent and a shallow descender (Anton, + * Michroma) gets a top-heavy line box, and its capitals sit well below the box centre — every `anchorY` lands the + * type low. {@link alignCapCenter} measures how far a face's cap-height centre sits from that box centre and shifts + * the node's pivot by it, so `anchorY: 0.5` centres capitals on `y` for every font. Using the pivot (not the position) + * keeps the correction through later repositioning, rotation, and scaling by per-letter animations. + */ + +/** Pixi's line-metrics probe (see `CanvasTextMetrics.METRICS_STRING` + `BASELINE_SYMBOL`). */ +const LINE_PROBE = '|ÉqÅM'; +const CAP_PROBE = 'HXE0'; +const PROBE_SIZE = 100; + +/** + * Distance (in the same units as the inputs) from the centre of Pixi's line box to the centre of the capitals — + * positive when the capitals sit below the box centre. `ascent` / `descent` are the line probe's ink extents above / + * below the baseline, `capAscent` the capitals' height. + */ +export function capCenterOffset(ascent: number, descent: number, capAscent: number): number { + if (![ascent, descent, capAscent].every(Number.isFinite)) return 0; + return ascent - capAscent / 2 - (ascent + descent) / 2; +} + +/** CSS font shorthand for a family list, quoting multi-word names the way Pixi does. */ +export function fontShorthand(weight: string, sizePx: number, fontFamily: string): string { + const generic = new Set([ + 'serif', + 'sans-serif', + 'monospace', + 'cursive', + 'fantasy', + 'system-ui', + 'ui-sans-serif', + 'ui-monospace', + 'ui-serif', + ]); + const families = fontFamily + .split(',') + .map((family) => family.trim().replace(/^["']|["']$/gu, '')) + .filter((family) => family.length > 0) + .map((family) => (generic.has(family) ? family : `"${family}"`)); + return `${weight} ${sizePx}px ${families.join(', ')}`; +} + +const OFFSETS = new Map(); +/** + * Provisional offsets measured before a face reported loaded, each kept for {@link PROVISIONAL_TTL_MS}. Some faces + * (unicode-range subsets) can keep `document.fonts.check` false indefinitely; without this the per-frame HUD would + * re-measure them on every text node, every frame. + */ +const PROVISIONAL = new Map(); +const PROVISIONAL_TTL_MS = 1000; +let context: CanvasRenderingContext2D | null | undefined; +let listeningForFonts = false; + +/** + * Cap-centre offset of `fontFamily` at `weight`, as a fraction of the font size. Cached once the face has loaded; + * 0 where there is no DOM canvas (headless tests / benchmarks) or before the face is available. + */ +export function capCenterOffsetEm(fontFamily: string, weight: string): number { + const key = `${weight}|${fontFamily}`; + const cached = OFFSETS.get(key); + if (cached !== undefined) return cached; + const now = typeof performance !== 'undefined' ? performance.now() : Date.now(); + const provisional = PROVISIONAL.get(key); + if (provisional && provisional.until > now) return provisional.offset; + if (typeof document === 'undefined') return 0; + if (!listeningForFonts && document.fonts && typeof document.fonts.addEventListener === 'function') { + // A face finishing its load invalidates every provisional measurement. + document.fonts.addEventListener('loadingdone', () => PROVISIONAL.clear()); + listeningForFonts = true; + } + if (context === undefined) context = document.createElement('canvas').getContext('2d'); + if (!context) return 0; + const font = fontShorthand(weight, PROBE_SIZE, fontFamily); + const loaded = typeof document.fonts?.check !== 'function' || document.fonts.check(font); + context.font = font; + const line = context.measureText(LINE_PROBE); + const caps = context.measureText(CAP_PROBE); + const offset = + capCenterOffset(line.actualBoundingBoxAscent, line.actualBoundingBoxDescent, caps.actualBoundingBoxAscent) / + PROBE_SIZE; + // Until the web font arrives the browser measures a fallback; keep asking rather than caching the wrong face. + if (loaded) OFFSETS.set(key, offset); + else PROVISIONAL.set(key, { offset, until: now + PROVISIONAL_TTL_MS }); + return offset; +} + +/** Shifts `node`'s pivot so its capitals centre where Pixi would centre the line box (see the module note). */ +export function alignCapCenter(node: Text, fontFamily: string, weight: string, fontSize: number): void { + node.pivot.y = capCenterOffsetEm(fontFamily, weight) * fontSize; +} diff --git a/packages/player-web/src/skins/pixi-kit/types.ts b/packages/player-web/src/skins/pixi-kit/types.ts new file mode 100644 index 00000000..2c7f1451 --- /dev/null +++ b/packages/player-web/src/skins/pixi-kit/types.ts @@ -0,0 +1,146 @@ +import type { Container, Graphics } from 'pixi.js'; +import type { + BeMusicAudioFrame, + BeMusicBomb, + BeMusicEffectLevel, + BeMusicGameplayLayout, + BeMusicGameplayRuntime, + BeMusicLaneFrame, + BeMusicLaneKind, + BeMusicResultFrame, + BeMusicSelectFrame, + BeMusicSelectLayout, + BeMusicSkin, + BrowserSongEntry, +} from '@be-music/skin-sdk'; +import type { ChildPool } from './pools.ts'; + +/** + * A be-music skin written with PixiJS display objects. {@link definePixiSkin} runs it on its own Pixi renderer over the + * canvas the player hands the skin, so the player never sees Pixi: the gameplay frame is replayed into the renderer + * functions below (chrome, lanes, each note, hit effects), the select screen into a retained renderer, and the result + * into a layer rebuilt every frame. + */ +export interface PixiSkinDefinition extends Omit< + BeMusicSkin, + 'context' | 'contextAttributes' | 'setup' | 'teardown' | 'gameplay' | 'select' | 'result' +> { + readonly gameplay: PixiGameplaySkin; + readonly select: PixiSelectSkin; + readonly result: PixiResultSkin; +} + +/** What {@link PixiGameplaySkin.renderChrome} receives: its layers and pools, the runtime values, and the layout. */ +export interface PixiChromeContext { + /** Chrome behind the lanes. */ + layer: Container; + /** Chrome in front of the notes and hit effects. */ + overlayLayer: Container; + layerPool: ChildPool; + overlayLayerPool: ChildPool; + runtime: BeMusicGameplayRuntime; + layout: BeMusicGameplayLayout; +} + +export interface PixiGameplaySkin { + /** HUD chrome around the playfield (header, gauge, score, BGA frame, judgement / combo text). */ + readonly renderChrome: (context: PixiChromeContext) => void; + /** Lane beds, key beams, judgement line and key caps for every lane, drawn into one cleared `Graphics`. */ + renderLanes(context: PixiLanesContext): void; + /** One tap note; `context.graphics` is a fresh pooled `Graphics` owned by this note. */ + renderNote(context: PixiNoteContext): void; + /** One long note body plus its head / tail caps; `context.graphics` is a fresh pooled `Graphics`. */ + renderLongNote(context: PixiLongNoteContext): void; + /** Every live hit effect for this frame. Acquire children from `context.pool` (Graphics / Sprite / Text). */ + renderBombs(context: PixiBombsContext): void; + /** How long a hit effect lives (ms) before the player retires it. */ + readonly bombDurationMs: number; +} + +export interface PixiLanesContext { + graphics: Graphics; + lanes: readonly BeMusicLaneFrame[]; + /** Fractional beat position in [0, 1). */ + beatPhase: number; + nowMs: number; + /** Current combo — lets skins escalate the playfield as a run builds. */ + combo?: number; + effects?: BeMusicEffectLevel; + audio?: BeMusicAudioFrame; +} + +export interface PixiNoteContext { + graphics: Graphics; + kind: BeMusicLaneKind; + /** Lane rect x / width. Skins choose their own inset. */ + x: number; + w: number; + /** Just-timing y: the note's bottom edge. */ + y: number; + nowMs: number; +} + +export interface PixiLongNoteContext { + graphics: Graphics; + kind: BeMusicLaneKind; + x: number; + w: number; + /** Tail just-timing y (upper). */ + top: number; + /** Head just-timing y (lower), clamped to the judgement line while held. */ + bottom: number; + nowMs: number; +} + +export interface PixiBombsContext { + pool: ChildPool; + bombs: readonly BeMusicBomb[]; + nowMs: number; + /** Current combo — lets skins escalate hit effects as a run builds. */ + combo?: number; + effects?: BeMusicEffectLevel; + audio?: BeMusicAudioFrame; +} + +/** The select frame, with the layer rebuilt on each state change. Declare click targets with `addHitArea`. */ +export interface PixiSelectFrame extends Omit { + /** Rebuilt whenever the select state changes: the renderer adds its chrome and rows here. */ + layer: Container; +} + +export interface PixiSelectRenderer { + /** Persistent layer behind the rebuilt `frame.layer` (ambient backgrounds). */ + readonly backLayer: Container; + /** Persistent layer in front of `frame.layer` (cursor, glints). */ + readonly frontLayer: Container; + /** + * Rebuilds the input-driven chrome into `frame.layer`. Returns `true` while a transition is still in flight, asking + * to be rebuilt again next frame. + */ + render(frame: PixiSelectFrame): boolean; + /** Per-frame, transform-only animation of the persistent layers. */ + tick(nowMs: number, focusedSong: BrowserSongEntry | undefined, launchAt?: number, audio?: BeMusicAudioFrame): void; + dispose(): void; +} + +export interface PixiSelectSkin { + readonly layout: BeMusicSelectLayout; + /** Length of the launch outro (ms) the select screen keeps drawing after a chart is picked. */ + readonly outroMs?: number; + /** Creates the renderer for one select screen. */ + createRenderer(): PixiSelectRenderer; +} + +export interface PixiResultFrame extends BeMusicResultFrame { + /** Rebuilt every frame. */ + layer: Container; + /** + * Persistent layer behind {@link layer}, kept across frames for what needn't be rebuilt (a static ground, a particle + * field updated in place). Its children are destroyed with the screen. + */ + backdrop: Container; +} + +export interface PixiResultSkin { + render(frame: PixiResultFrame): void; +} diff --git a/packages/player-web/src/skins/plain/gameplay.ts b/packages/player-web/src/skins/plain/gameplay.ts new file mode 100644 index 00000000..bde42887 --- /dev/null +++ b/packages/player-web/src/skins/plain/gameplay.ts @@ -0,0 +1,211 @@ +import { + flashingGreatColor, + isFlashingGreat, + judgeDisplayWord, + loadingDots, + resolveLaneRuns, + type BeMusicGameplayFrame, + type BeMusicSurface, +} from '@be-music/skin-sdk'; +import { + ACCENT, + DANGER, + FLASHING_GREAT, + INK, + LANE_FILL, + LINE, + MUTED, + NOTE_FILL, + PANEL, + TEXT, + css, + stat, + text, +} from './theme.ts'; + +const NOTE_HEIGHT = 6; +/** Top of the right-hand judgement column: level with the BGA band's top, whatever size the BGA takes. */ +const COLUMN_TOP = 66; +const MARGIN = 16; + +/** The play screen: lanes and notes on the left, the BGA beside them, the HUD around them. */ +export function drawGameplay({ context: ctx, width, height }: BeMusicSurface<'2d'>, frame: BeMusicGameplayFrame): void { + const { layout, runtime } = frame; + + // Background. The BGA plays *behind* this canvas, so its rect is cut out rather than painted over. + ctx.fillStyle = INK; + ctx.fillRect(0, 0, width, height); + const bga = layout.bga; + if (bga) { + if (runtime.hasBga) { + ctx.clearRect(bga.x, bga.y, bga.w, bga.h); + } else { + ctx.fillStyle = PANEL; + ctx.fillRect(bga.x, bga.y, bga.w, bga.h); + text(ctx, 'SOUND ONLY', bga.x + bga.w / 2, bga.y + bga.h / 2, { size: 12, color: MUTED, align: 'center' }); + } + ctx.strokeStyle = LINE; + ctx.strokeRect(bga.x - 0.5, bga.y - 0.5, bga.w + 1, bga.h + 1); + } + + drawLanes(ctx, frame); + drawNotes(ctx, frame); + drawBombs(ctx, frame); + drawJudgement(ctx, frame); + drawHud(ctx, frame, width, height); +} + +/** Lane backgrounds, a light column for each held key, and the judgement line. */ +function drawLanes(ctx: CanvasRenderingContext2D, frame: BeMusicGameplayFrame): void { + for (const lane of frame.lanes) { + ctx.fillStyle = LANE_FILL[lane.kind]; + ctx.fillRect(lane.x, lane.top, lane.w, lane.bottom - lane.top); + if (lane.beam > 0) { + // One gradient serves every lane; how bright the beam is comes from the alpha. + ctx.fillStyle = beamGradient(ctx, lane.top, lane.bottom); + ctx.globalAlpha = lane.beam; + ctx.fillRect(lane.x, lane.top, lane.w, lane.bottom - lane.top); + ctx.globalAlpha = 1; + } + } + // One judgement line per play side, so it never crosses the gap between the double-play banks. + ctx.fillStyle = DANGER; + for (const run of resolveLaneRuns(frame.lanes)) { + ctx.fillRect(run.left, frame.layout.playfield.judgementY, run.right - run.left, 2); + } +} + +const BEAM_GRADIENTS = new WeakMap< + CanvasRenderingContext2D, + { top: number; bottom: number; gradient: CanvasGradient } +>(); + +/** The key-beam gradient (clear at the top, blue at the judgement line), made once per context and lane height. */ +function beamGradient(ctx: CanvasRenderingContext2D, top: number, bottom: number): CanvasGradient { + const cached = BEAM_GRADIENTS.get(ctx); + if (cached && cached.top === top && cached.bottom === bottom) return cached.gradient; + const gradient = ctx.createLinearGradient(0, top, 0, bottom); + gradient.addColorStop(0, 'rgba(61, 123, 255, 0)'); + gradient.addColorStop(1, 'rgba(61, 123, 255, 0.45)'); + BEAM_GRADIENTS.set(ctx, { top, bottom, gradient }); + return gradient; +} + +/** + * Tap notes as flat bars. Long notes: a translucent body with a white centre line between the head and tail bars, the + * shape every built-in skin uses. + */ +function drawNotes(ctx: CanvasRenderingContext2D, frame: BeMusicGameplayFrame): void { + for (const note of frame.longNotes) { + const bodyTop = note.top - NOTE_HEIGHT; + const bodyHeight = Math.max(1, note.bottom - note.top); + ctx.fillStyle = NOTE_FILL[note.kind]; + ctx.globalAlpha = 0.25; + ctx.fillRect(note.x + 1, bodyTop, note.w - 2, bodyHeight); + ctx.fillStyle = '#ffffff'; + ctx.globalAlpha = 0.6; + ctx.fillRect(note.x + note.w / 2 - 1, bodyTop, 2, bodyHeight); + ctx.globalAlpha = 1; + ctx.fillStyle = NOTE_FILL[note.kind]; + ctx.fillRect(note.x, note.top - NOTE_HEIGHT, note.w, NOTE_HEIGHT); + ctx.fillRect(note.x, note.bottom - NOTE_HEIGHT, note.w, NOTE_HEIGHT); + } + for (const note of frame.notes) { + ctx.fillStyle = NOTE_FILL[note.kind]; + ctx.fillRect(note.x, note.y - NOTE_HEIGHT, note.w, NOTE_HEIGHT); + } +} + +/** Each hit draws a ring that widens and fades over the effect's lifetime. */ +function drawBombs(ctx: CanvasRenderingContext2D, frame: BeMusicGameplayFrame): void { + for (const bomb of frame.bombs) { + const t = Math.min(1, bomb.elapsedMs / 300); + ctx.strokeStyle = `rgba(255, 255, 255, ${1 - t})`; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.arc(bomb.x + bomb.w / 2, bomb.y, 6 + 18 * t, 0, Math.PI * 2); + ctx.stroke(); + } +} + +/** The latest judgement and combo over each play side — or NOW LOADING while the chart loads. */ +function drawJudgement(ctx: CanvasRenderingContext2D, frame: BeMusicGameplayFrame): void { + const { runtime, layout, nowMs } = frame; + const y = 220; + if (runtime.loading) { + text(ctx, `NOW LOADING${loadingDots(nowMs)}`, layout.playfield.left + 12, y, { size: 13, color: MUTED }); + return; + } + for (const side of runtime.judgeSides ?? []) { + if (!side.judge) continue; + const bounds = layout.playfield.sides[side.side]; + const x = bounds ? (bounds.left + bounds.right) / 2 : layout.playfield.centerX; + const color = isFlashingGreat(side.judge) ? css(flashingGreatColor(nowMs, FLASHING_GREAT)) : judgeColor(side.judge); + text(ctx, judgeDisplayWord(side.judge), x, y, { size: 20, weight: 800, color, align: 'center' }); + const combo = side.combo ?? 0; + const keepsCombo = side.judge === 'PERFECT' || side.judge === 'GREAT' || side.judge === 'GOOD'; + if (keepsCombo && combo > 0) text(ctx, String(combo), x, y + 24, { size: 16, align: 'center' }); + } +} + +function judgeColor(judge: string): string { + if (judge === 'GOOD') return ACCENT; + if (judge === 'BAD' || judge === 'POOR') return DANGER; + return TEXT; +} + +/** Header (mode, tempo, speed), the gauge under the lanes, the track and the score figures along the bottom. */ +function drawHud(ctx: CanvasRenderingContext2D, frame: BeMusicGameplayFrame, width: number, height: number): void { + const { runtime } = frame; + + text(ctx, runtime.autoplay ? 'AUTO PLAY' : 'PLAY', MARGIN, 18, { size: 12, weight: 800, color: ACCENT }); + text(ctx, `BPM ${Math.round(runtime.bpm ?? 0)} SPEED x${(runtime.hiSpeed ?? 0).toFixed(1)}`, 110, 18, { + size: 12, + color: MUTED, + }); + + // Gauge: a bar under the lanes with a tick at the clear line. + const gaugeX = MARGIN; + const gaugeY = 372; + const gaugeW = 240; + const gauge = Math.max(0, Math.min(100, runtime.gauge ?? 0)); + const clear = runtime.clearThreshold ?? 80; + text(ctx, `${runtime.gaugeLabel ?? 'GROOVE'} GAUGE`, gaugeX, gaugeY - 10, { size: 9, color: MUTED, weight: 700 }); + text(ctx, `${Math.round(gauge)}%`, gaugeX + gaugeW, gaugeY - 10, { size: 12, align: 'right' }); + ctx.fillStyle = PANEL; + ctx.fillRect(gaugeX, gaugeY, gaugeW, 8); + ctx.fillStyle = gauge >= clear ? DANGER : ACCENT; + ctx.fillRect(gaugeX, gaugeY, (gaugeW * gauge) / 100, 8); + ctx.fillStyle = TEXT; + ctx.fillRect(gaugeX + (gaugeW * clear) / 100, gaugeY - 3, 1, 14); + + // Track, bottom left. + text(ctx, runtime.songTitle ?? '', MARGIN, height - 50, { size: 16, weight: 700, maxWidth: 380 }); + text(ctx, runtime.songArtist ?? '', MARGIN, height - 28, { size: 11, color: MUTED, maxWidth: 380 }); + + // Score figures, bottom right, in one row. + const figures: Array<[string, string]> = [ + ['SCORE', String(runtime.score ?? 0)], + ['EX SCORE', `${runtime.exScore ?? 0}/${runtime.exScoreMax ?? 0}`], + ['COMBO', String(runtime.combo ?? 0)], + ['RANK', runtime.rank ?? '-'], + ]; + figures.forEach(([label, value], index) => { + stat(ctx, label, value, width - MARGIN - 400 + index * 106, height - 56); + }); + + // Judgement counts down the right edge, beside the BGA. + const counts: Array<[string, number | undefined]> = [ + ['PG', runtime.perfect], + ['GR', runtime.great], + ['GD', runtime.good], + ['BD', runtime.bad], + ['PR', runtime.poor], + ]; + const columnX = width - MARGIN - 80; + counts.forEach(([label, count], index) => { + const y = COLUMN_TOP + index * 22; + text(ctx, label, columnX, y, { size: 11, color: MUTED, weight: 700 }); + text(ctx, String(count ?? 0), columnX + 80, y, { size: 13, align: 'right' }); + }); +} diff --git a/packages/player-web/src/skins/plain/index.ts b/packages/player-web/src/skins/plain/index.ts new file mode 100644 index 00000000..c04c63af --- /dev/null +++ b/packages/player-web/src/skins/plain/index.ts @@ -0,0 +1,24 @@ +import { BE_MUSIC_SKIN_API_VERSION, defineBeMusicSkin, type BeMusicSkin } from '@be-music/skin-sdk'; +import { drawGameplay } from './gameplay.ts'; +import { drawResult } from './result.ts'; +import { SELECT_LAYOUT, SELECT_OUTRO_MS, drawSelect } from './select.ts'; + +/** + * Plain — the skin SDK's worked example: every screen is drawn with the Canvas 2D API, no Pixi involved. Start here + * when writing a skin of your own; `gameplay.ts`, `select.ts`, and `result.ts` each draw one screen. + */ +export const plainSkin: BeMusicSkin = defineBeMusicSkin({ + apiVersion: BE_MUSIC_SKIN_API_VERSION, + id: 'plain', + label: 'Plain', + version: '1.0.0', + author: { name: 'be-music', url: 'https://github.com/nulltask/be-music' }, + description: 'A minimal skin drawn with the Canvas 2D API — the skin SDK example.', + homepage: 'https://github.com/nulltask/be-music', + license: 'MIT', + fontLoads: ['500 12px "M PLUS 1p"', '700 12px "M PLUS 1p"', '800 12px "M PLUS 1p"'], + context: '2d', + gameplay: { draw: drawGameplay }, + select: { layout: SELECT_LAYOUT, outroMs: SELECT_OUTRO_MS, draw: drawSelect }, + result: { draw: drawResult }, +}); diff --git a/packages/player-web/src/skins/plain/result.ts b/packages/player-web/src/skins/plain/result.ts new file mode 100644 index 00000000..1a3061ec --- /dev/null +++ b/packages/player-web/src/skins/plain/result.ts @@ -0,0 +1,71 @@ +import { + resolveResultLamp, + resolveResultTrackRows, + type BeMusicResultFrame, + type BeMusicSurface, +} from '@be-music/skin-sdk'; +import { ACCENT, DANGER, INK, MUTED, PANEL, stat, text } from './theme.ts'; + +const MARGIN = 16; + +/** The result screen: verdict and rank, the score figures, the judgement counts, and the chart's facts. */ +export function drawResult({ context: ctx, width, height }: BeMusicSurface<'2d'>, frame: BeMusicResultFrame): void { + const { result } = frame; + ctx.fillStyle = INK; + ctx.fillRect(0, 0, width, height); + + text(ctx, result.cleared ? 'STAGE CLEAR' : 'FAILED', MARGIN, 28, { + size: 20, + weight: 800, + color: result.cleared ? ACCENT : DANGER, + }); + + // Rank and rate. + ctx.fillStyle = PANEL; + ctx.fillRect(MARGIN, 60, 200, 160); + text(ctx, frame.rankLabel, MARGIN + 100, 130, { size: 56, weight: 800, align: 'center' }); + text(ctx, `${frame.ratePercent.toFixed(2)}%`, MARGIN + 100, 196, { size: 14, color: MUTED, align: 'center' }); + + // Score figures in two rows of three. + const figures: Array<[string, string]> = [ + ['SCORE', String(result.score.score)], + ['EX SCORE', `${result.score.exScore} / ${result.score.total * 2}`], + ['MAX COMBO', String(result.maxCombo)], + ['GAUGE', `${Math.round(result.gauge)}%`], + ['PLAY TIME', `${result.playSeconds.toFixed(1)}s`], + ['NOTES', String(result.score.total)], + ]; + figures.forEach(([label, value], index) => { + stat(ctx, label, value, 240 + (index % 3) * 130, 76 + Math.floor(index / 3) * 56); + }); + + // Judgement counts. + const counts: Array<[string, number]> = [ + ['PGREAT', result.score.perfect], + ['GREAT', result.score.great], + ['GOOD', result.score.good], + ['BAD', result.score.bad], + ['POOR', result.score.poor], + ]; + counts.forEach(([label, count], index) => { + const y = 250 + index * 26; + text(ctx, label, MARGIN, y, { size: 12, color: MUTED, weight: 700 }); + text(ctx, String(count), MARGIN + 200, y, { size: 14, align: 'right' }); + }); + + // The chart, its facts, and the clear lamp on the right. + const x = width - MARGIN - 220; + ctx.fillStyle = PANEL; + ctx.fillRect(x, 60, 220, 330); + text(ctx, result.song.title, x + 14, 82, { size: 14, weight: 800, maxWidth: 192 }); + text(ctx, result.song.artist ?? '', x + 14, 102, { size: 11, color: MUTED, maxWidth: 192 }); + resolveResultTrackRows(result).forEach((row, index) => { + const y = 140 + index * 34; + text(ctx, row.label, x + 14, y, { size: 10, color: MUTED, weight: 700 }); + text(ctx, row.value, x + 206, y, { size: 14, align: 'right' }); + }); + const lamp = resolveResultLamp(result); + text(ctx, lamp, x + 110, 360, { size: 18, weight: 800, align: 'center', color: lamp === 'FAILED' ? DANGER : ACCENT }); + + text(ctx, `TOTAL SCORE ${result.score.score}`, MARGIN, height - 24, { size: 14, weight: 700 }); +} diff --git a/packages/player-web/src/skins/plain/select.ts b/packages/player-web/src/skins/plain/select.ts new file mode 100644 index 00000000..16c1bc03 --- /dev/null +++ b/packages/player-web/src/skins/plain/select.ts @@ -0,0 +1,130 @@ +import { + formatPlayVariantLabel, + resolveSongRowFacts, + type BeMusicSelectLayout, + type BrowserBrowseEntry, + type BeMusicSelectFrame, + type BeMusicSurface, +} from '@be-music/skin-sdk'; +import { ACCENT, INK, LINE, MUTED, PANEL, TEXT, stat, text } from './theme.ts'; + +/** Where the song list sits; the player uses the same numbers to tell which row was clicked. */ +export const SELECT_LAYOUT: BeMusicSelectLayout = { listX: 322, listTop: 56, listBottomInset: 28, rowHeight: 28 }; + +/** Fade to black after a chart is picked. */ +export const SELECT_OUTRO_MS = 300; + +const MARGIN = 16; +const PANEL_W = 290; + +/** The select screen: the focused chart's card and buttons on the left, the song list on the right. */ +export function drawSelect({ context: ctx, width, height }: BeMusicSurface<'2d'>, frame: BeMusicSelectFrame): void { + ctx.fillStyle = INK; + ctx.fillRect(0, 0, width, height); + text(ctx, 'MUSIC SELECT', MARGIN, 24, { size: 14, weight: 800 }); + text(ctx, frame.folderLabel ?? `${frame.totalCharts} charts`, width - MARGIN, 24, { + size: 11, + color: MUTED, + align: 'right', + }); + + drawFocusedSong(ctx, frame); + drawList(ctx, frame, width); + + if (frame.searchQuery) { + text(ctx, `SEARCH: ${frame.searchQuery}`, MARGIN, height - 20, { size: 11, color: MUTED }); + } + + // After a pick, darken the screen over the outro. + if (frame.launchAt !== undefined) { + const t = Math.min(1, (frame.nowMs - frame.launchAt) / SELECT_OUTRO_MS); + ctx.fillStyle = `rgba(0, 0, 0, ${t})`; + ctx.fillRect(0, 0, width, height); + } +} + +/** The left card: title, artist, the chart's key facts, and the PLAY / AUTO buttons. */ +function drawFocusedSong(ctx: CanvasRenderingContext2D, frame: BeMusicSelectFrame): void { + const song = frame.focusedSong; + const x = MARGIN; + const y = 56; + ctx.fillStyle = PANEL; + ctx.fillRect(x, y, PANEL_W, 300); + if (!song) { + text(ctx, 'Pick a folder', x + 16, y + 28, { size: 14, color: MUTED }); + return; + } + text(ctx, song.title, x + 16, y + 28, { size: 18, weight: 800, maxWidth: PANEL_W - 32 }); + text(ctx, song.artist ?? '', x + 16, y + 52, { size: 11, color: MUTED, maxWidth: PANEL_W - 32 }); + + const facts = resolveSongRowFacts(song); + stat(ctx, 'MODE', formatPlayVariantLabel(song), x + 16, y + 92); + stat(ctx, 'LEVEL', String(song.playLevel ?? '-'), x + 110, y + 92); + stat(ctx, 'BPM', facts.bpm, x + 190, y + 92); + stat(ctx, 'NOTES', facts.notes, x + 16, y + 140); + stat(ctx, 'LENGTH', facts.length, x + 110, y + 140); + stat(ctx, 'GIMMICKS', facts.tags.join(' ') || '-', x + 190, y + 140); + + button(ctx, frame, 'PLAY', x + 16, y + 236, 160, frame.actions.play, true); + button(ctx, frame, 'AUTO', x + 186, y + 236, 88, frame.actions.autoPlay, false); +} + +function button( + ctx: CanvasRenderingContext2D, + frame: BeMusicSelectFrame, + label: string, + x: number, + y: number, + w: number, + action: () => void, + primary: boolean, +): void { + const h = 40; + ctx.fillStyle = primary ? ACCENT : INK; + ctx.fillRect(x, y, w, h); + ctx.strokeStyle = primary ? ACCENT : LINE; + ctx.strokeRect(x + 0.5, y + 0.5, w - 1, h - 1); + text(ctx, label, x + w / 2, y + h / 2, { size: 14, weight: 800, align: 'center' }); + frame.hit(x, y, w, h, action); +} + +/** The visible window of the list: one row per entry, the selected one highlighted. */ +function drawList(ctx: CanvasRenderingContext2D, frame: BeMusicSelectFrame, width: number): void { + const { listX, listTop, rowHeight } = SELECT_LAYOUT; + const rowW = width - listX - MARGIN; + for (let row = 0; row < frame.visibleRows; row += 1) { + const index = frame.firstVisibleIndex + row; + const entry = frame.entries[index]; + if (!entry) break; + const y = listTop + row * rowHeight; + const selected = index === frame.selectedIndex; + ctx.fillStyle = selected ? ACCENT : row % 2 === 0 ? PANEL : INK; + ctx.fillRect(listX, y, rowW, rowHeight - 2); + drawRow(ctx, entry, listX, y + (rowHeight - 2) / 2, rowW, selected); + } +} + +function drawRow( + ctx: CanvasRenderingContext2D, + entry: BrowserBrowseEntry, + x: number, + midY: number, + w: number, + selected: boolean, +): void { + const dim = selected ? TEXT : MUTED; + if (entry.kind === 'folder') { + text(ctx, `▸ ${entry.folder.label}`, x + 12, midY, { size: 12, weight: 700, maxWidth: w - 120 }); + text(ctx, `${entry.folder.songs.length} charts`, x + w - 12, midY, { size: 11, color: dim, align: 'right' }); + return; + } + const song = entry.song; + const facts = resolveSongRowFacts(song); + text(ctx, String(song.playLevel ?? '-'), x + 22, midY, { size: 12, weight: 800, align: 'center' }); + text(ctx, song.title, x + 44, midY, { size: 12, weight: 700, maxWidth: w - 300 }); + text(ctx, `${facts.notes} NOTES ${facts.length} ${facts.bpm} BPM`, x + w - 12, midY, { + size: 11, + color: dim, + align: 'right', + }); +} diff --git a/packages/player-web/src/skins/plain/theme.ts b/packages/player-web/src/skins/plain/theme.ts new file mode 100644 index 00000000..06b6a784 --- /dev/null +++ b/packages/player-web/src/skins/plain/theme.ts @@ -0,0 +1,64 @@ +/** Plain's palette, type, and a few small drawing helpers shared by its three screens. */ + +export const INK = '#111114'; +export const PANEL = '#1c1c21'; +export const LINE = '#3a3a42'; +export const TEXT = '#f2f2f2'; +export const MUTED = '#8c8c96'; +export const ACCENT = '#3d7bff'; +export const DANGER = '#ff4d5e'; + +/** Lane backgrounds and note colours by lane kind. */ +export const LANE_FILL = { white: '#1d1d22', black: '#16161a', scratch: '#16161a' } as const; +export const NOTE_FILL = { white: '#f2f2f2', black: '#5b8cff', scratch: '#ff4d5e' } as const; + +/** Colours the PERFECT judgement (shown as GREAT) cycles through. */ +export const FLASHING_GREAT = [0xffffff, 0x5b8cff, 0xffd84d] as const; + +export const FONT = '"M PLUS 1p", system-ui, sans-serif'; + +const FONTS = new Map(); + +/** A CSS font for `ctx.font` (the same string for the same size and weight). */ +export function font(size: number, weight = 500): string { + const key = `${weight}/${size}`; + let value = FONTS.get(key); + if (!value) { + value = `${weight} ${size}px ${FONT}`; + FONTS.set(key, value); + } + return value; +} + +/** The font last set on each context: the browser re-parses `ctx.font` on every assignment, so skip repeats. */ +const CURRENT_FONTS = new WeakMap(); + +/** `0xrrggbb` → `#rrggbb`, for colours the SDK hands out as numbers. */ +export function css(color: number): string { + return `#${color.toString(16).padStart(6, '0')}`; +} + +/** Draws `text` with its left edge (or `align` point) at `x` and its middle at `y`. */ +export function text( + ctx: CanvasRenderingContext2D, + value: string, + x: number, + y: number, + options: { size: number; color?: string; weight?: number; align?: CanvasTextAlign; maxWidth?: number }, +): void { + const wanted = font(options.size, options.weight); + if (CURRENT_FONTS.get(ctx) !== wanted) { + ctx.font = wanted; + CURRENT_FONTS.set(ctx, wanted); + } + ctx.fillStyle = options.color ?? TEXT; + ctx.textAlign = options.align ?? 'left'; + ctx.textBaseline = 'middle'; + ctx.fillText(value, x, y, options.maxWidth); +} + +/** A label above a value: the small grey caption and the figure under it. */ +export function stat(ctx: CanvasRenderingContext2D, label: string, value: string, x: number, y: number): void { + text(ctx, label, x, y, { size: 9, color: MUTED, weight: 700 }); + text(ctx, value, x, y + 16, { size: 16 }); +} diff --git a/packages/player-web/src/skins/synesthesia/boids.test.ts b/packages/player-web/src/skins/synesthesia/boids.test.ts new file mode 100644 index 00000000..0a6cf585 --- /dev/null +++ b/packages/player-web/src/skins/synesthesia/boids.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vite-plus/test'; +import { createFlock, flockLeader, stepFlock, type Flock } from './boids.ts'; + +const BOUNDS = { minX: -600, maxX: 600, minY: -250, maxY: 100, minZ: 200, maxZ: 1400 }; + +function spread(flock: Flock): number { + let cx = 0; + let cy = 0; + let cz = 0; + for (let i = 0; i < flock.count; i += 1) { + cx += flock.position[i * 3]!; + cy += flock.position[i * 3 + 1]!; + cz += flock.position[i * 3 + 2]!; + } + cx /= flock.count; + cy /= flock.count; + cz /= flock.count; + let sum = 0; + for (let i = 0; i < flock.count; i += 1) { + sum += Math.hypot(flock.position[i * 3]! - cx, flock.position[i * 3 + 1]! - cy, flock.position[i * 3 + 2]! - cz); + } + return sum / flock.count; +} + +function run(flock: Flock, seconds: number, forces: (t: number) => { gather?: number; scatter?: number } = () => ({})) { + const dt = 1 / 60; + for (let t = 0; t < seconds; t += dt) stepFlock(flock, dt, { seconds: t, ...forces(t) }); +} + +describe('createFlock', () => { + it('is deterministic for a seed', () => { + expect(createFlock(3, 20, BOUNDS)).toEqual(createFlock(3, 20, BOUNDS)); + expect(createFlock(3, 20, BOUNDS).position).not.toEqual(createFlock(4, 20, BOUNDS).position); + }); +}); + +describe('stepFlock', () => { + it('keeps speeds within the limits', () => { + const flock = createFlock(1, 40, BOUNDS); + run(flock, 3); + for (let i = 0; i < flock.count; i += 1) { + const speed = Math.hypot(flock.velocity[i * 3]!, flock.velocity[i * 3 + 1]!, flock.velocity[i * 3 + 2]!); + expect(speed).toBeGreaterThanOrEqual(89); + expect(speed).toBeLessThanOrEqual(261); + } + }); + + it('stays near its box over a long swim', () => { + const flock = createFlock(2, 40, BOUNDS); + run(flock, 20); + for (let i = 0; i < flock.count; i += 1) { + expect(flock.position[i * 3]!).toBeGreaterThanOrEqual(BOUNDS.minX - 200); + expect(flock.position[i * 3]!).toBeLessThanOrEqual(BOUNDS.maxX + 200); + expect(flock.position[i * 3 + 2]!).toBeGreaterThanOrEqual(BOUNDS.minZ - 200); + expect(flock.position[i * 3 + 2]!).toBeLessThanOrEqual(BOUNDS.maxZ + 200); + } + }); + + it('schools together, and a scatter spreads the school out', () => { + const calm = createFlock(5, 40, BOUNDS); + const scattered = createFlock(5, 40, BOUNDS); + run(calm, 4); + run(scattered, 4, () => ({ scatter: 1 })); + expect(spread(calm)).toBeLessThan(260); + expect(spread(scattered)).toBeGreaterThan(spread(calm)); + }); + + it('ignores zero and invalid time steps', () => { + const flock = createFlock(6, 10, BOUNDS); + const before = flock.position.slice(); + stepFlock(flock, 0, { seconds: 0 }); + stepFlock(flock, Number.NaN, { seconds: 0 }); + expect(flock.position).toEqual(before); + }); +}); + +describe('flockLeader', () => { + it('wanders inside the box', () => { + for (let t = 0; t < 120; t += 1.7) { + const leader = flockLeader(BOUNDS, t); + expect(leader.x).toBeGreaterThan(BOUNDS.minX); + expect(leader.x).toBeLessThan(BOUNDS.maxX); + expect(leader.y).toBeGreaterThan(BOUNDS.minY); + expect(leader.y).toBeLessThan(BOUNDS.maxY); + expect(leader.z).toBeGreaterThan(BOUNDS.minZ); + expect(leader.z).toBeLessThan(BOUNDS.maxZ); + } + }); +}); diff --git a/packages/player-web/src/skins/synesthesia/boids.ts b/packages/player-web/src/skins/synesthesia/boids.ts new file mode 100644 index 00000000..36efaf20 --- /dev/null +++ b/packages/player-web/src/skins/synesthesia/boids.ts @@ -0,0 +1,176 @@ +import { hash01 } from '@be-music/skin-sdk'; + +/** + * A 3D school of boids (Reynolds' separation / alignment / cohesion) for the Synesthesia backdrop: fish of light that + * swim after a slowly wandering lead point inside a box, tighten on the beat, and scatter on a key press. State lives + * in flat typed arrays and is advanced in place by {@link stepFlock}; everything else is deterministic for the seed. + */ + +export interface FlockBounds { + minX: number; + maxX: number; + minY: number; + maxY: number; + minZ: number; + maxZ: number; +} + +export interface Flock { + count: number; + /** x, y, z per boid. */ + position: Float32Array; + /** vx, vy, vz per boid (units / s). */ + velocity: Float32Array; + bounds: FlockBounds; +} + +export interface FlockForces { + /** Seconds on the scene clock — drives the wandering lead point. */ + seconds: number; + /** 0..1 extra cohesion (the beat pulls the school tight). */ + gather?: number; + /** 0..1 burst of separation and speed (a key press scatters the school). */ + scatter?: number; +} + +const SEPARATION_RADIUS = 58; +const NEIGHBOUR_RADIUS = 150; +const MIN_SPEED = 90; +const MAX_SPEED = 260; + +export function createFlock(seed: number, count: number, bounds: FlockBounds): Flock { + const position = new Float32Array(count * 3); + const velocity = new Float32Array(count * 3); + const cx = (bounds.minX + bounds.maxX) / 2; + const cy = (bounds.minY + bounds.maxY) / 2; + const cz = (bounds.minZ + bounds.maxZ) / 2; + for (let index = 0; index < count; index += 1) { + const base = seed * 173 + index * 11; + // Start as one loose school near the centre, heading roughly the same way. + position[index * 3] = cx + (hash01(base + 1) - 0.5) * 240; + position[index * 3 + 1] = cy + (hash01(base + 2) - 0.5) * 120; + position[index * 3 + 2] = cz + (hash01(base + 3) - 0.5) * 240; + velocity[index * 3] = 120 + (hash01(base + 4) - 0.5) * 60; + velocity[index * 3 + 1] = (hash01(base + 5) - 0.5) * 40; + velocity[index * 3 + 2] = (hash01(base + 6) - 0.5) * 60; + } + return { count, position, velocity, bounds }; +} + +/** The point the school chases at `seconds`: a slow Lissajous loop through the middle of the box. */ +export function flockLeader(bounds: FlockBounds, seconds: number): { x: number; y: number; z: number } { + const hx = (bounds.maxX - bounds.minX) / 2; + const hy = (bounds.maxY - bounds.minY) / 2; + const hz = (bounds.maxZ - bounds.minZ) / 2; + return { + x: bounds.minX + hx + Math.sin(seconds * 0.23) * hx * 0.75, + y: bounds.minY + hy + Math.sin(seconds * 0.37 + 1.1) * hy * 0.6, + z: bounds.minZ + hz + Math.cos(seconds * 0.17) * hz * 0.7, + }; +} + +/** Advances `flock` by `dt` seconds (clamped to 1/15 s). */ +export function stepFlock(flock: Flock, dt: number, forces: FlockForces): void { + const step = Math.max(0, Math.min(1 / 15, Number.isFinite(dt) ? dt : 0)); + if (step === 0) return; + const { count, position: p, velocity: v, bounds } = flock; + const leader = flockLeader(bounds, forces.seconds); + const gather = forces.gather ?? 0; + const scatter = forces.scatter ?? 0; + const separationRadius = SEPARATION_RADIUS * (1 + 1.5 * scatter); + const maxSpeed = MAX_SPEED * (1 + 0.8 * scatter); + const accel = new Float32Array(count * 3); + for (let i = 0; i < count; i += 1) { + const ix = p[i * 3]!; + const iy = p[i * 3 + 1]!; + const iz = p[i * 3 + 2]!; + let sepX = 0; + let sepY = 0; + let sepZ = 0; + let aliX = 0; + let aliY = 0; + let aliZ = 0; + let cohX = 0; + let cohY = 0; + let cohZ = 0; + let neighbours = 0; + for (let j = 0; j < count; j += 1) { + if (j === i) continue; + const dx = p[j * 3]! - ix; + const dy = p[j * 3 + 1]! - iy; + const dz = p[j * 3 + 2]! - iz; + const d2 = dx * dx + dy * dy + dz * dz; + if (d2 > NEIGHBOUR_RADIUS * NEIGHBOUR_RADIUS) continue; + neighbours += 1; + aliX += v[j * 3]!; + aliY += v[j * 3 + 1]!; + aliZ += v[j * 3 + 2]!; + cohX += dx; + cohY += dy; + cohZ += dz; + if (d2 < separationRadius * separationRadius && d2 > 1e-6) { + const push = 1 / d2; + sepX -= dx * push; + sepY -= dy * push; + sepZ -= dz * push; + } + } + let ax = 0; + let ay = 0; + let az = 0; + if (neighbours > 0) { + // Strong alignment and light cohesion: the school streams in ribbons rather than balling up. + const cohesion = 0.3 * (1 + 2 * gather) * (1 - scatter); + ax += (aliX / neighbours - v[i * 3]!) * 2.2 + (cohX / neighbours) * cohesion; + ay += (aliY / neighbours - v[i * 3 + 1]!) * 2.2 + (cohY / neighbours) * cohesion; + az += (aliZ / neighbours - v[i * 3 + 2]!) * 2.2 + (cohZ / neighbours) * cohesion; + } + const separation = 16000 * (1 + 4 * scatter); + ax += sepX * separation; + ay += sepY * separation; + az += sepZ * separation; + // Chase the leader. + ax += (leader.x - ix) * 0.16; + ay += (leader.y - iy) * 0.16; + az += (leader.z - iz) * 0.16; + // Soft walls. + ax += wall(ix, bounds.minX, bounds.maxX); + ay += wall(iy, bounds.minY, bounds.maxY); + az += wall(iz, bounds.minZ, bounds.maxZ); + accel[i * 3] = ax; + accel[i * 3 + 1] = ay; + accel[i * 3 + 2] = az; + } + for (let i = 0; i < count; i += 1) { + let vx = v[i * 3]! + accel[i * 3]! * step; + let vy = v[i * 3 + 1]! + accel[i * 3 + 1]! * step; + let vz = v[i * 3 + 2]! + accel[i * 3 + 2]! * step; + const speed = Math.hypot(vx, vy, vz); + const clamped = Math.max(MIN_SPEED, Math.min(maxSpeed, speed)); + if (speed > 1e-6) { + vx = (vx / speed) * clamped; + vy = (vy / speed) * clamped; + vz = (vz / speed) * clamped; + } else { + vx = clamped; + } + v[i * 3] = vx; + v[i * 3 + 1] = vy; + v[i * 3 + 2] = vz; + p[i * 3] = clampTo(p[i * 3]! + vx * step, bounds.minX - 200, bounds.maxX + 200); + p[i * 3 + 1] = clampTo(p[i * 3 + 1]! + vy * step, bounds.minY - 200, bounds.maxY + 200); + p[i * 3 + 2] = clampTo(p[i * 3 + 2]! + vz * step, bounds.minZ - 200, bounds.maxZ + 200); + } +} + +/** Steering back into `[min, max]`, growing with how far outside a margin the boid has strayed. */ +function wall(value: number, min: number, max: number): number { + const margin = (max - min) * 0.12; + if (value < min + margin) return (min + margin - value) * 6; + if (value > max - margin) return (max - margin - value) * 6; + return 0; +} + +function clampTo(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} diff --git a/packages/player-web/src/skins/synesthesia/burst-budget.test.ts b/packages/player-web/src/skins/synesthesia/burst-budget.test.ts new file mode 100644 index 00000000..040cac00 --- /dev/null +++ b/packages/player-web/src/skins/synesthesia/burst-budget.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vite-plus/test'; +import { BURST_FRAME_BUDGET, burstGrainCount } from './burst-budget.ts'; + +describe('burstGrainCount', () => { + it('draws the full tier-scaled burst while few bombs overlap', () => { + expect(burstGrainCount(170, 24, 0, 1, 1)).toBe(170); + expect(burstGrainCount(170, 24, 4, 1, 2)).toBe(266); + expect(burstGrainCount(170, 24, 2, 0.5, 1)).toBe(109); + }); + + it('shares the frame budget once many bombs overlap', () => { + expect(burstGrainCount(170, 24, 4, 1, 8)).toBe(BURST_FRAME_BUDGET / 8); + expect(burstGrainCount(170, 24, 4, 1, 16) * 16).toBeLessThanOrEqual(BURST_FRAME_BUDGET); + }); + + it('never goes negative', () => { + expect(burstGrainCount(170, 24, 0, 0, 3)).toBe(0); + expect(burstGrainCount(170, 24, 0, 1, 0)).toBe(170); + }); +}); diff --git a/packages/player-web/src/skins/synesthesia/burst-budget.ts b/packages/player-web/src/skins/synesthesia/burst-budget.ts new file mode 100644 index 00000000..c4c19915 --- /dev/null +++ b/packages/player-web/src/skins/synesthesia/burst-budget.ts @@ -0,0 +1,24 @@ +/** + * Spark budget for Synesthesia bombs. A lone hit throws its full burst; when many hits overlap, the frame's total grain + * count is capped and shared between them, so a dense chord or a chart's busiest passage costs about as much to draw + * as a few hits — the overlapping additive light reads just as full either way. + */ + +/** Most spark grains drawn across every live bomb in one frame. */ +export const BURST_FRAME_BUDGET = 960; + +/** + * Grains one bomb draws: its tier-scaled burst (`base + perTier × tier`, scaled by the effects `amount`), capped by an + * even share of {@link BURST_FRAME_BUDGET} among `liveBombs`. + */ +export function burstGrainCount( + base: number, + perTier: number, + tier: number, + amount: number, + liveBombs: number, +): number { + const wanted = Math.max(0, Math.round((base + perTier * tier) * amount)); + const share = Math.floor(BURST_FRAME_BUDGET / Math.max(1, liveBombs)); + return Math.min(wanted, share); +} diff --git a/packages/player-web/src/skins/synesthesia/draw.test.ts b/packages/player-web/src/skins/synesthesia/draw.test.ts new file mode 100644 index 00000000..a4cac6b9 --- /dev/null +++ b/packages/player-web/src/skins/synesthesia/draw.test.ts @@ -0,0 +1,77 @@ +import type { Graphics } from 'pixi.js'; +import { describe, expect, it } from 'vite-plus/test'; +import { ShapeBatch } from './draw.ts'; + +/** Records the geometry and paint calls a `Graphics` receives, in order. */ +function recorder(): { graphics: Graphics; calls: string[] } { + const calls: string[] = []; + const graphics = { + rect(x: number, y: number, w: number, h: number) { + calls.push(`rect ${x},${y},${w},${h}`); + return graphics; + }, + moveTo(x: number, y: number) { + calls.push(`move ${x},${y}`); + return graphics; + }, + lineTo(x: number, y: number) { + calls.push(`line ${x},${y}`); + return graphics; + }, + fill(style: { color: number; alpha: number }) { + calls.push(`fill ${style.color.toString(16)} ${style.alpha}`); + return graphics; + }, + stroke(style: { color: number; width: number; alpha: number }) { + calls.push(`stroke ${style.color.toString(16)} ${style.width} ${style.alpha}`); + return graphics; + }, + } as unknown as Graphics; + return { graphics, calls }; +} + +describe('ShapeBatch', () => { + it('merges rects of one quantized colour and alpha into a single fill', () => { + const { graphics, calls } = recorder(); + const batch = new ShapeBatch(); + batch.rect(graphics, 0xff6612, 0.5, 0, 0, 1, 1); + batch.rect(graphics, 0xff6a1e, 0.52, 2, 2, 1, 1); + batch.flush(); + expect(calls).toEqual(['rect 0,0,1,1', 'rect 2,2,1,1', 'fill f86818 0.5']); + }); + + it('keeps different colours, alphas, and targets in separate buckets', () => { + const a = recorder(); + const b = recorder(); + const batch = new ShapeBatch(); + batch.rect(a.graphics, 0x00ff00, 1, 0, 0, 1, 1); + batch.rect(a.graphics, 0x0000ff, 1, 1, 0, 1, 1); + batch.rect(a.graphics, 0x00ff00, 0.25, 2, 0, 1, 1); + batch.rect(b.graphics, 0x00ff00, 1, 3, 0, 1, 1); + batch.flush(); + expect(a.calls.filter((call) => call.startsWith('fill'))).toHaveLength(3); + expect(b.calls).toEqual(['rect 3,0,1,1', 'fill 8f808 1']); + }); + + it('strokes lines per width bucket and skips invisible shapes', () => { + const { graphics, calls } = recorder(); + const batch = new ShapeBatch(); + batch.line(graphics, 0xffffff, 1, 1, 0, 0, 5, 5); + batch.line(graphics, 0xffffff, 1, 2, 0, 1, 5, 6); + batch.rect(graphics, 0xffffff, 0.01, 0, 0, 1, 1); + batch.flush(); + expect(calls).toEqual(['move 0,0', 'line 5,5', 'stroke f8f8f8 1 1', 'move 0,1', 'line 5,6', 'stroke f8f8f8 2 1']); + }); + + it('starts every flush empty and routes buckets to the graphics given that frame', () => { + const first = recorder(); + const second = recorder(); + const batch = new ShapeBatch(); + batch.rect(first.graphics, 0xff0000, 1, 0, 0, 1, 1); + batch.flush(); + batch.rect(second.graphics, 0xff0000, 1, 9, 9, 1, 1); + batch.flush(); + expect(first.calls).toEqual(['rect 0,0,1,1', 'fill f80808 1']); + expect(second.calls).toEqual(['rect 9,9,1,1', 'fill f80808 1']); + }); +}); diff --git a/packages/player-web/src/skins/synesthesia/draw.ts b/packages/player-web/src/skins/synesthesia/draw.ts new file mode 100644 index 00000000..1f0e9c5d --- /dev/null +++ b/packages/player-web/src/skins/synesthesia/draw.ts @@ -0,0 +1,636 @@ +import type { Graphics, Sprite } from 'pixi.js'; +import type { Flock } from './boids.ts'; +import { + emberColor, + limbGlow, + orbitPosition, + projectPoint, + reflectedLight, + rotateX, + rotateY, + viewPoint, + type CameraPose, + type CloudPoint, + type OrbitParticle, + type Projected, + type Vec3, +} from './space.ts'; +import { SYN_CYAN, SYN_GLASS, SYN_MAGENTA, SYN_WHITE, synGlowTexture } from './style.ts'; +import { type AudioDrive, bandLevel, hash01 } from '@be-music/skin-sdk'; +import { pointLayerFor } from '../pixi-kit/index.ts'; + +/** + * Shared Synesthesia drawing: hairline frames with lock-on corners (a rhythm-shooter targeting reticle) and projected point + * clouds. Callers own the `Graphics`; these only append geometry. + */ + +/** + * Batches thousands of tiny additive shapes into a handful of draw instructions: rects and line segments are bucketed + * by (target graphics, colour quantized to 4 bits per channel, alpha quantized to eighths, stroke width) and emitted as + * one `fill` / `stroke` per bucket on {@link flush}. Emission order is bucket order, so only use it where overlap order does not matter + * (additive light). + */ +export class ShapeBatch { + /** + * @param points When true, square rects are drawn as particles on the target's {@link pointLayerFor point layer} + * instead of `Graphics` geometry. Off for tests and for targets outside the scene graph. + */ + private readonly points: boolean; + + public constructor(points = false) { + this.points = points; + } + + // Buckets are keyed by a packed integer and kept across flushes (only their data is reset), so a steady frame + // allocates no keys, buckets, or arrays. + private readonly rects = new Map(); + private readonly lines = new Map(); + private readonly ids = new Map(); + private readonly targets: Graphics[] = []; + + private id(graphics: Graphics): number { + let id = this.ids.get(graphics); + if (id === undefined) { + id = this.targets.length; + this.ids.set(graphics, id); + this.targets.push(graphics); + } + return id; + } + + public rect(graphics: Graphics, rawColor: number, alpha: number, x: number, y: number, w: number, h: number): void { + const eighths = Math.round(Math.min(1, alpha) * 8); + if (eighths <= 0) return; + if (this.points && w === h) { + // Square points become GPU particles (exact colour and alpha, no geometry rebuild). + pointLayerFor(graphics).point(x + w / 2, y + h / 2, w, rawColor, Math.min(1, alpha)); + return; + } + const key = (this.id(graphics) * 9 + eighths) * 4096 + colorIndex(rawColor); + let bucket = this.rects.get(key); + if (!bucket) { + bucket = { target: this.id(graphics), color: quantizeColor(rawColor), alpha: eighths / 8, width: 0, data: [] }; + this.rects.set(key, bucket); + } + bucket.data.push(x, y, w, h); + } + + public line( + graphics: Graphics, + rawColor: number, + alpha: number, + width: number, + x0: number, + y0: number, + x1: number, + y1: number, + ): void { + const eighths = Math.round(Math.min(1, alpha) * 8); + if (eighths <= 0) return; + const halfSteps = Math.min(63, Math.max(1, Math.round(width * 2))); + const key = ((this.id(graphics) * 9 + eighths) * 64 + halfSteps) * 4096 + colorIndex(rawColor); + let bucket = this.lines.get(key); + if (!bucket) { + bucket = { + target: this.id(graphics), + color: quantizeColor(rawColor), + alpha: eighths / 8, + width: halfSteps / 2, + data: [], + }; + this.lines.set(key, bucket); + } + bucket.data.push(x0, y0, x1, y1); + } + + public flush(): void { + for (const bucket of this.rects.values()) { + const { data } = bucket; + if (data.length === 0) continue; + const graphics = this.targets[bucket.target]!; + for (let index = 0; index < data.length; index += 4) { + graphics.rect(data[index]!, data[index + 1]!, data[index + 2]!, data[index + 3]!); + } + graphics.fill({ color: bucket.color, alpha: bucket.alpha }); + data.length = 0; + } + for (const bucket of this.lines.values()) { + const { data } = bucket; + if (data.length === 0) continue; + const graphics = this.targets[bucket.target]!; + for (let index = 0; index < data.length; index += 4) { + graphics.moveTo(data[index]!, data[index + 1]!).lineTo(data[index + 2]!, data[index + 3]!); + } + graphics.stroke({ color: bucket.color, width: bucket.width, alpha: bucket.alpha }); + data.length = 0; + } + // Pooled graphics can be handed out in a different order next frame; re-learn the targets each flush. + this.ids.clear(); + this.targets.length = 0; + if (this.rects.size + this.lines.size > 4096) { + this.rects.clear(); + this.lines.clear(); + } + } +} + +/** + * One batch shared by every Synesthesia renderer. Renderers draw synchronously and flush before returning, so sharing + * is safe and lets steady frames reuse its buckets instead of allocating a batch per call. + */ +export const sharedShapeBatch: ShapeBatch = new ShapeBatch(true); + +interface ShapeBucket { + target: number; + color: number; + alpha: number; + width: number; + data: number[]; +} + +/** 12-bit index of a colour's {@link quantizeColor} bucket (the top nibble of each channel). */ +function colorIndex(color: number): number { + return ((color >> 12) & 0xf00) | ((color >> 8) & 0xf0) | ((color >> 4) & 0xf); +} + +/** `color` snapped to 16 levels per channel (bucket-friendly, visually identical for light). */ +function quantizeColor(color: number): number { + return (color & 0xf0f0f0) | 0x080808; +} + +/** + * Lock-on reticle: four rounded corner brackets around `(x, y, w, h)` and an optional centre cross. `arm` is the + * bracket length. + */ +export function drawReticle( + graphics: Graphics, + x: number, + y: number, + w: number, + h: number, + color: number, + alpha: number, + options: { arm?: number; width?: number; cross?: boolean } = {}, +): void { + const arm = Math.min(options.arm ?? 10, w / 2, h / 2); + const width = options.width ?? 1.5; + const right = x + w; + const bottom = y + h; + const bend = Math.min(3, arm / 2); + graphics + .moveTo(x, y + arm) + .lineTo(x, y + bend) + .quadraticCurveTo(x, y, x + bend, y) + .lineTo(x + arm, y) + .moveTo(right - arm, y) + .lineTo(right - bend, y) + .quadraticCurveTo(right, y, right, y + bend) + .lineTo(right, y + arm) + .moveTo(right, bottom - arm) + .lineTo(right, bottom - bend) + .quadraticCurveTo(right, bottom, right - bend, bottom) + .lineTo(right - arm, bottom) + .moveTo(x + arm, bottom) + .lineTo(x + bend, bottom) + .quadraticCurveTo(x, bottom, x, bottom - bend) + .lineTo(x, bottom - arm) + .stroke({ color, width, alpha, cap: 'round' }); + if (options.cross) { + const cx = x + w / 2; + const cy = y + h / 2; + const size = Math.min(w, h) * 0.08 + 2; + graphics + .moveTo(cx - size, cy) + .lineTo(cx + size, cy) + .moveTo(cx, cy - size) + .lineTo(cx, cy + size) + .stroke({ color, width: 1, alpha }); + } +} + +/** A near-black frame with a warm hairline rim and bright lock-on corners — a targeting-HUD take on a panel. */ +export function drawFrame( + graphics: Graphics, + x: number, + y: number, + w: number, + h: number, + rim: number, + options: { fill?: number; arm?: number } = {}, +): void { + graphics.rect(x, y, w, h).fill({ color: SYN_GLASS, alpha: options.fill ?? 0.55 }); + graphics.rect(x, y, w, h).stroke({ color: rim, width: 1, alpha: 0.22 }); + drawReticle(graphics, x - 1, y - 1, w + 2, h + 2, SYN_WHITE, 0.85, { arm: options.arm ?? 9, width: 1.25 }); +} + +export interface CloudTransform { + /** Uniform scale applied to the unit shape. */ + scale: number; + /** World offset after rotation. */ + x: number; + y: number; + z: number; + yaw: number; + pitch?: number; +} + +/** + * Projects `points` through (scale → yaw / pitch → offset) into screen space and draws each as a tiny additive-ready + * square — nearer and heavier points are bigger and brighter. `colorOf` picks a colour per point; `shimmer` (0..1) + * flickers individual points over time so the cloud looks alive. `skip` lets the caller drop points (e.g. inside a + * live BGA). + */ +export function drawPointCloud( + graphics: Graphics, + points: readonly CloudPoint[], + transform: CloudTransform, + view: { cx: number; cy: number; focal: number; camera?: CameraPose; orbit?: number }, + style: { + colorOf: (point: CloudPoint, index: number) => number; + alpha: number; + size: number; + seconds?: number; + shimmer?: number; + /** Perspective scale at which `size` / `alpha` apply 1:1 (default 1) — set it near the cloud's depth. */ + referenceScale?: number; + /** Heavy points also get a faint square halo this many times their size. */ + bokeh?: number; + skip?: (x: number, y: number) => boolean; + }, +): void { + const reference = style.referenceScale ?? 1; + const seconds = style.seconds ?? 0; + const shimmer = style.shimmer ?? 0; + const batch = sharedShapeBatch; + for (let index = 0; index < points.length; index += 1) { + const point = points[index]!; + let world = { x: point.x * transform.scale, y: point.y * transform.scale, z: point.z * transform.scale }; + world = rotateY(world, transform.yaw); + if (transform.pitch) world = rotateX(world, transform.pitch); + const placed = { x: world.x + transform.x, y: world.y + transform.y, z: world.z + transform.z }; + const projected = projectPoint( + view.camera ? viewPoint(placed, view.camera, view.orbit) : placed, + view.cx, + view.cy, + view.focal, + ); + if (!projected.visible) continue; + if (style.skip?.(projected.x, projected.y)) continue; + const flicker = shimmer > 0 ? 1 - shimmer * (0.5 + 0.5 * Math.sin(seconds * 7 + index * 2.39)) : 1; + const depth = projected.scale / reference; + const alpha = style.alpha * point.weight * flicker * Math.min(1.4, depth); + if (alpha < 0.02) continue; + const size = Math.max(0.6, style.size * (0.5 + point.weight) * depth); + const color = style.colorOf(point, index); + if (style.bokeh && point.weight > 0.75) { + const halo = size * style.bokeh; + batch.rect( + graphics, + color, + Math.min(1, alpha) * 0.12, + projected.x - halo / 2, + projected.y - halo / 2, + halo, + halo, + ); + } + batch.rect(graphics, color, Math.min(1, alpha), projected.x - size / 2, projected.y - size / 2, size, size); + } + batch.flush(); +} + +/** + * Draws a school of boids as fish of light: a tapering streak trailing each boid along its heading with a hot head, + * nearer fish larger and brighter. `project` maps a world point to screen (camera + perspective); `skip` drops fish + * (e.g. over a live BGA). `palette` picks the school's light: ember (with the odd blue fish), electric blue, or magenta. + */ +export function drawSchool( + graphics: Graphics, + flock: Flock, + project: (point: Vec3) => Projected, + style: { alpha: number; palette?: 'ember' | 'blue' | 'magenta'; skip?: (x: number, y: number) => boolean }, +): void { + const palette = style.palette ?? 'ember'; + const { position: p, velocity: v } = flock; + const batch = sharedShapeBatch; + for (let index = 0; index < flock.count; index += 1) { + const x = p[index * 3]!; + const y = p[index * 3 + 1]!; + const z = p[index * 3 + 2]!; + const head = project({ x, y, z }); + if (!head.visible) continue; + if (style.skip?.(head.x, head.y)) continue; + // Tail length follows speed, so a darting fish stretches. + const tail = project({ + x: x - v[index * 3]! * 0.16, + y: y - v[index * 3 + 1]! * 0.16, + z: z - v[index * 3 + 2]! * 0.16, + }); + if (!tail.visible) continue; + const nearness = Math.min(1, head.scale * 2.4); + const heat = hash01(index * 5 + 3); + const color = + palette === 'blue' + ? heat > 0.75 + ? SYN_WHITE + : SYN_CYAN + : palette === 'magenta' + ? heat > 0.8 + ? 0xffb3d9 + : SYN_MAGENTA + : index % 7 === 0 + ? SYN_CYAN + : emberColor(0.55 + 0.4 * heat); + const alpha = style.alpha * (0.35 + 0.65 * nearness); + const mx = (head.x + tail.x) / 2; + const my = (head.y + tail.y) / 2; + batch.line(graphics, color, alpha * 0.55, 0.6 + 1 * nearness, tail.x, tail.y, mx, my); + batch.line(graphics, color, alpha, 1 + 2 * nearness, mx, my, head.x, head.y); + const size = 1.2 + 2.2 * nearness; + batch.rect( + graphics, + heat > 0.8 ? SYN_WHITE : color, + Math.min(1, alpha * 1.3), + head.x - size / 2, + head.y - size / 2, + size, + size, + ); + } + batch.flush(); +} + +export interface MagnetoOrbOptions { + /** World centre and rest shell radius. */ + x: number; + y: number; + z: number; + radius: number; + view: { cx: number; cy: number; focal: number; camera?: CameraPose; orbit?: number }; + drive: AudioDrive; + seconds: number; + alpha: number; + /** Tilt of the shell's spin (radians) and spin speed (rad / s). */ + tilt?: number; + spin?: number; + /** + * A black moon revolving round the orb like a satellite: `size` and `distance` are fractions of the shell radius, + * `speed` in rad / s, `tilt` the orbit plane's inclination. Omit for none. + */ + moon?: { size: number; distance: number; speed: number; phase: number; tilt?: number }; + /** Screen rect the orb must stay inside (e.g. the idle monitor). */ + clip?: { x: number; y: number; w: number; h: number }; +} + +/** The three layers an orb draws into, back to front: additive light, a normal-blend layer for the black moon, and + * additive light again for the half of the shell facing the camera. */ +export interface MagnetoOrbLayers { + back: Graphics; + moon: Graphics; + front: Graphics; +} + +const ORB_HAZE = 0x5a3cff; +const ORB_VIOLET = 0x8a6bff; +const ORB_BLUE = 0x5b8bff; +const ORB_PINK = 0xff7ad9; +const ORB_WHITE = 0xf2eaff; +const ORB_FIBER = 0xb9a8ff; +const ORB_MOON = 0x0b0518; +const SHELL_COLORS = [ORB_VIOLET, ORB_BLUE, ORB_WHITE, ORB_VIOLET, ORB_PINK, ORB_BLUE] as const; +/** Samples per orbit tail. */ +const TRAIL_STEPS = 12; + +/** + * A particle-shell orb in the manner of classic music visualizers: a hollow shell of countless violet / blue / pink / + * white sparks, brightest at its silhouette, bristling with fine fibres that shoot out from the surface, wrapped in a + * violet haze and a few lights racing round on tilted orbits — and, for contrast, a black moon circling it. The + * music drives it: each latitude swells and grows longer fibres with its spectrum band (lows south, highs north), + * the shell breathes on the bass, onsets burst the fibres outward, the highs twinkle the sparks. Returns the + * projected centre and shell radius. + */ +export function drawMagnetoOrb( + layers: MagnetoOrbLayers, + acquireSprite: () => Sprite, + shell: readonly CloudPoint[], + particles: readonly OrbitParticle[], + options: MagnetoOrbOptions, +): { x: number; y: number; radius: number } | undefined { + const { view, drive, clip } = options; + const toScreen = (point: Vec3) => + projectPoint(view.camera ? viewPoint(point, view.camera, view.orbit) : point, view.cx, view.cy, view.focal); + const centre = toScreen({ x: options.x, y: options.y, z: options.z }); + if (!centre.visible) return undefined; + const outside = (x: number, y: number) => + clip !== undefined && (x < clip.x || x > clip.x + clip.w || y < clip.y || y > clip.y + clip.h); + const levels = Array.from({ length: 16 }, (_, column) => bandLevel(drive.bands, column, 16)); + const glow = synGlowTexture(); + const sparkle = (x: number, y: number, size: number, tint: number, alpha: number) => { + if (alpha < 0.02 || size < 0.8 || outside(x, y)) return; + const node = acquireSprite(); + node.texture = glow; + node.anchor.set(0.5); + node.blendMode = 'add'; + node.position.set(x, y); + node.width = size; + node.height = size; + node.tint = tint; + node.alpha = Math.min(1, alpha); + }; + const place = (local: Vec3) => toScreen({ x: options.x + local.x, y: options.y + local.y, z: options.z + local.z }); + const breathe = options.radius * (0.8 + 0.55 * drive.bass + 0.25 * drive.onset); + const screenRadius = breathe * centre.scale; + const spin = options.seconds * (options.spin ?? 0.25); + const tilt = options.tilt ?? 0.35; + + // Thousands of dots, fibres, and tail segments go out as a handful of batched instructions. + const batch = sharedShapeBatch; + const addDot = (layer: Graphics, color: number, alpha: number, x: number, y: number, size: number) => + batch.rect(layer, color, alpha, x - size / 2, y - size / 2, size, size); + const addLine = ( + layer: Graphics, + color: number, + alpha: number, + width: number, + x0: number, + y0: number, + x1: number, + y1: number, + ) => batch.line(layer, color, alpha, width, x0, y0, x1, y1); + + // Violet haze behind everything. + for (let ring = 4; ring >= 1; ring -= 1) { + const r = screenRadius * (0.9 + 0.45 * ring); + if ( + clip && + (centre.x - r < clip.x || + centre.x + r > clip.x + clip.w || + centre.y - r < clip.y || + centre.y + r > clip.y + clip.h) + ) { + continue; + } + layers.back + .circle(centre.x, centre.y, r) + .fill({ color: ORB_HAZE, alpha: (0.05 + 0.05 * drive.level) * options.alpha }); + } + + // The shell and its fibres; each point lands on the back or front layer by which way it faces. + for (let index = 0; index < shell.length; index += 1) { + const point = shell[index]!; + const latitude = (1 - point.y) / 2; + const band = levels[Math.min(15, Math.floor(latitude * 16))] ?? 0; + const heat = hash01(index * 7 + 3); + const radius = breathe * (1 + 0.22 * band); + let normal: Vec3 = rotateY(point, spin); + normal = rotateX(normal, tilt); + const projected = place({ x: normal.x * radius, y: normal.y * radius, z: normal.z * radius }); + if (!projected.visible || outside(projected.x, projected.y)) continue; + const limb = limbGlow(normal.z); + const target = normal.z < 0 ? layers.front : layers.back; + const depth = projected.scale / Math.max(1e-6, centre.scale); + const color = SHELL_COLORS[index % SHELL_COLORS.length]!; + const twinkle = 1 - 0.5 * drive.high * (0.5 + 0.5 * Math.sin(options.seconds * 9 + index * 1.7)); + const alpha = Math.min(1, options.alpha * limb * (0.35 + 0.65 * point.weight) * (0.75 + 0.5 * band) * twinkle); + const size = Math.max(0.7, (0.8 + 1.7 * point.weight * limb) * depth * Math.min(1.6, centre.scale * 2.2)); + addDot(target, color, alpha, projected.x, projected.y, size); + // Fibres: every other point bristles outward, longest where its band is loud and on onsets. + if (index % 2 === 0 && limb > 0.3) { + const length = breathe * (0.25 + 1.1 * band + 0.9 * drive.onset * heat) * (0.55 + 0.45 * point.weight); + const mid = place({ + x: normal.x * (radius + length * 0.5), + y: normal.y * (radius + length * 0.5), + z: normal.z * (radius + length * 0.5), + }); + const tip = place({ + x: normal.x * (radius + length), + y: normal.y * (radius + length), + z: normal.z * (radius + length), + }); + if (mid.visible && tip.visible && !outside(tip.x, tip.y)) { + const fibreAlpha = 0.45 * limb * (0.45 + band) * options.alpha; + addLine(target, ORB_FIBER, fibreAlpha, 0.7, projected.x, projected.y, mid.x, mid.y); + addLine(target, ORB_FIBER, fibreAlpha * 0.4, 0.5, mid.x, mid.y, tip.x, tip.y); + } + } + if (heat > 0.95 && limb > 0.45) { + sparkle( + projected.x, + projected.y, + (8 + 14 * band) * Math.min(1.4, centre.scale * 2), + heat > 0.975 ? ORB_WHITE : color, + alpha * 0.8, + ); + } + } + + // A few lights racing round on tilted orbits, dragging tails. + const speedUp = 1 + 0.8 * drive.level; + const trailStep = 0.07 * (1 + 1.2 * drive.level); + for (const particle of particles) { + const band = levels[particle.band] ?? 0; + const radius = breathe * particle.reach * (1 + 0.35 * band + 0.5 * drive.onset * (particle.weight > 0.7 ? 1 : 0.4)); + const color = particle.band < 5 ? ORB_PINK : particle.band < 11 ? ORB_VIOLET : ORB_WHITE; + const head = particle.phase + options.seconds * particle.speed * speedUp; + const direction = particle.speed > 0 ? -1 : 1; + let previous: { x: number; y: number } | undefined; + for (let step = 0; step <= TRAIL_STEPS; step += 1) { + const local = orbitPosition(particle, head + direction * step * trailStep, radius); + const point = place(local); + if (!point.visible || outside(point.x, point.y)) { + previous = undefined; + continue; + } + const target = local.z < 0 ? layers.front : layers.back; + if (previous) { + const fade = (1 - step / (TRAIL_STEPS + 1)) ** 1.6; + addLine( + target, + color, + fade * (0.3 + 0.5 * band) * options.alpha, + (0.5 + 1.2 * particle.weight) * Math.min(1.6, point.scale * 1.6), + previous.x, + previous.y, + point.x, + point.y, + ); + } else if (step === 0) { + sparkle( + point.x, + point.y, + (5 + 6 * particle.weight) * Math.min(1.5, point.scale * 2) * (1 + band), + color, + (0.4 + 0.5 * band) * options.alpha, + ); + } + previous = { x: point.x, y: point.y }; + } + } + + batch.flush(); + + // The black moon: an opaque dark satellite revolving round the orb on a tilted orbit (traced faintly), catching a + // thin violet rim. On the far side of its orbit the orb's front shell draws over it. + if (options.moon) { + const moon = options.moon; + const path = { reach: 1, tiltX: moon.tilt ?? 0.28, tiltZ: -0.22, speed: moon.speed, phase: 0, band: 0, weight: 1 }; + const distance = options.radius * moon.distance; + let previous: { x: number; y: number } | undefined; + for (let step = 0; step <= 64; step += 1) { + const point = place(orbitPosition(path, (step / 64) * Math.PI * 2, distance)); + if (!point.visible || outside(point.x, point.y)) { + previous = undefined; + continue; + } + if (previous) { + layers.back + .moveTo(previous.x, previous.y) + .lineTo(point.x, point.y) + .stroke({ color: ORB_VIOLET, width: 0.75, alpha: 0.18 * options.alpha }); + } + previous = { x: point.x, y: point.y }; + } + const orbit = orbitPosition(path, moon.phase + options.seconds * moon.speed, distance); + const at = place(orbit); + const r = breathe * moon.size * at.scale; + if (at.visible && !outside(at.x - r, at.y - r) && !outside(at.x + r, at.y + r)) { + const g = layers.moon; + g.circle(at.x, at.y, r).fill({ color: ORB_MOON, alpha: 0.97 * options.alpha }); + // It reflects the orb: the side facing it glows violet → blue in soft bands, a specular glint sits toward the + // light, and the orb's sparks are mirrored along the lit limb — all brighter when the orb is close and loud. + const light = reflectedLight( + centre.x - at.x, + centre.y - at.y, + Math.hypot(centre.x - at.x, centre.y - at.y), + screenRadius, + Math.max(drive.level, drive.bass), + ); + const lit = light.strength * options.alpha; + for (let band = 0; band < 5; band += 1) { + const spread = 1.25 - band * 0.2; + const radius = r * (0.93 - band * 0.11); + g.moveTo(at.x + Math.cos(light.angle - spread) * radius, at.y + Math.sin(light.angle - spread) * radius); + g.arc(at.x, at.y, radius, light.angle - spread, light.angle + spread).stroke({ + color: band < 2 ? ORB_BLUE : ORB_VIOLET, + width: r * 0.12, + alpha: lit * (0.45 - band * 0.07), + }); + } + const glintX = at.x + Math.cos(light.angle - 0.35) * r * 0.55; + const glintY = at.y + Math.sin(light.angle - 0.35) * r * 0.55; + g.circle(glintX, glintY, r * 0.26).fill({ color: ORB_VIOLET, alpha: 0.3 * lit }); + g.circle(glintX, glintY, r * 0.1).fill({ color: ORB_WHITE, alpha: Math.min(1, 1.1 * lit) }); + for (let spark = 0; spark < 7; spark += 1) { + const angle = light.angle + (spark - 3) * 0.28 + Math.sin(options.seconds * 1.7 + spark) * 0.05; + const twinkle = 0.5 + 0.5 * Math.sin(options.seconds * 6 + spark * 2.1); + g.circle(at.x + Math.cos(angle) * r * 0.8, at.y + Math.sin(angle) * r * 0.8, Math.max(0.6, r * 0.045)).fill({ + color: SHELL_COLORS[spark % SHELL_COLORS.length]!, + alpha: lit * (0.35 + 0.5 * twinkle * drive.high + 0.2 * twinkle), + }); + } + // A faint fresnel rim all the way round. + g.circle(at.x, at.y, r).stroke({ color: ORB_VIOLET, width: 1, alpha: (0.25 + 0.35 * lit) * options.alpha }); + } + } + return { x: centre.x, y: centre.y, radius: screenRadius }; +} diff --git a/packages/player-web/src/skins/synesthesia/gameplay-chrome.ts b/packages/player-web/src/skins/synesthesia/gameplay-chrome.ts new file mode 100644 index 00000000..a1551d43 --- /dev/null +++ b/packages/player-web/src/skins/synesthesia/gameplay-chrome.ts @@ -0,0 +1,1030 @@ +import { Graphics, type Container } from 'pixi.js'; +import { GROOVE } from './layout.ts'; +import { drawSynesthesiaMoments } from './moments.ts'; +import { createFlock, stepFlock, type Flock } from './boids.ts'; +import { drawFrame, drawMagnetoOrb, drawPointCloud, drawReticle, drawSchool, sharedShapeBatch } from './draw.ts'; +import { + clipSegmentOutsideRect, + emberColor, + cameraBasis, + hsvToHex, + mixCamera, + particleRiverPoint, + fibonacciSphere, + orbitParticles, + pointCloudPyramid, + projectPoint, + projectViewInto, + REST_CAMERA, + roamingCamera, + scratchProjected, + starfieldInto, + vanishingPoint, + viewPoint, + wanderPoint, + type CameraPose, +} from './space.ts'; +import { + SYN_AMBER, + SYN_CYAN, + SYN_GREEN, + SYN_DIM, + SYN_DISPLAY_FONT, + SYN_EMBER, + SYN_FLARE, + SYN_MAGENTA, + SYN_MIST, + SYN_RED, + SYN_TEXT_FONT, + SYN_VOID, + SYN_WHITE, + sceneHue, +} from './style.ts'; +import { + audioDrive, + type AudioDrive, + type BeMusicRect, + comboTier, + effectProfile, + flashingGreatColor, + hash01, + impulse, + isFlashingGreat, + judgeDisplayWord, + LOADING_WORD, + loadingDots, + type BeMusicGameplayLayout, + type BeMusicGameplayRuntime, + STAGE_HEIGHT as DESIGN_HEIGHT, + STAGE_SIDE_COLUMN, + STAGE_WIDTH as DESIGN_WIDTH, + punchScale, +} from '@be-music/skin-sdk'; +import { + addHudNumber, + addHudText, + type ChildPool, + type HudTextOptions, + type PixiChromeContext, +} from '../pixi-kit/index.ts'; + +type Runtime = BeMusicGameplayRuntime; + +/** + * HUD grid (design px): a 16 px page margin and a 12 px gutter. The left column (16..272) stacks the gauge frame and + * the song plate; the score panel fills the right column (284..624) from the gauge frame's top to the plate's bottom. + */ +const HUD_MARGIN = 16; +const GAUGE_FRAME = { x: HUD_MARGIN, y: GROOVE.y - 26, w: 256, h: 48 } as const; +const SONG_PLATE = { x: HUD_MARGIN, y: 420, w: DESIGN_WIDTH - HUD_MARGIN * 2 - 340 - 12, h: 46 } as const; +/** The score panel sits on the right margin; the track card runs from the left margin up to a gutter before it. */ +const SCORE_PANEL = { x: DESIGN_WIDTH - HUD_MARGIN - 340, y: GAUGE_FRAME.y, w: 340, h: 466 - GAUGE_FRAME.y } as const; +/** Score panel's column divider, from its left edge. */ +const SCORE_DIVIDER = 200; +const PLAYFIELD_FRAME_BOTTOM = 344; +/** Floor grid: horizon y, camera height above the floor, focal length. */ +const FLOOR = { horizon: 326, height: 154, focal: 200 } as const; +/** How far the gameplay camera roams: eye offset (floor frame, floor 154 below) and turn / tilt. */ +const CAMERA_RANGE = { x: 230, y: 110, yaw: 0.34, pitch: 0.18 } as const; +const CAMERA_CYCLE_S = 7; +const SCHOOL_SIZE = 70; +/** + * Three schools, each with its own swimming box (floor frame, floor at y 154), light, and seed — their leaders wander + * independently, so the flocks cross, part, and pass in front of one another. + */ +const SCHOOL_SPECS = [ + { seed: 17, palette: 'ember', bounds: { minX: -650, maxX: 450, minY: -200, maxY: 110, minZ: 160, maxZ: 900 } }, + { seed: 41, palette: 'blue', bounds: { minX: -300, maxX: 800, minY: -260, maxY: 40, minZ: 300, maxZ: 1200 } }, + { seed: 73, palette: 'magenta', bounds: { minX: -800, maxX: 800, minY: -240, maxY: 80, minZ: 700, maxZ: 1700 } }, +] as const; +const SCHOOLS = new WeakMap(); + +/** The chrome layer's schools, created on first use (or when their size changes) and stepped to `nowMs`. */ +function advanceSchools( + key: object, + nowMs: number, + size: number, + forces: { gather: number; scatter: number }, +): Flock[] { + let state = SCHOOLS.get(key); + if (!state || state.flocks[0]!.count !== size || nowMs < state.lastMs) { + state = { flocks: SCHOOL_SPECS.map((spec) => createFlock(spec.seed, size, spec.bounds)), lastMs: nowMs }; + SCHOOLS.set(key, state); + } + const dt = (nowMs - state.lastMs) / 1000; + state.flocks.forEach((flock, index) => { + // Offset each leader's clock so the schools never shadow one another. + stepFlock(flock, dt, { seconds: nowMs / 1000 + index * 41.7, ...forces }); + }); + state.lastMs = nowMs; + return state.flocks; +} +/** The idle monitor's orb shell: dense enough to read as a sphere at monitor size, sparse enough to stay cheap. */ +const ORB_SHELL = fibonacciSphere(560, 11); +const ORB = orbitParticles(11, 22); +const PYRAMIDS = [pointCloudPyramid(3, 520), pointCloudPyramid(8, 420), pointCloudPyramid(5, 700)]; + +/** + * Synesthesia gameplay HUD after a cosmic particle world: a black void lit by an ember horizon, data dust and speed + * dust pouring out of the vanishing point, a floor of light points scrolling toward the player, rivers of particles + * once the run is in the zone, and hairline frames with lock-on corners. With no BGA the monitor idles on a floating + * audio orb roaming in front of particle pyramids. Colour drifts through the ember band and swells on every beat. + */ +export function renderSynesthesiaChrome({ + layer, + overlayLayer, + layerPool, + overlayLayerPool, + runtime, + layout, +}: PixiChromeContext): void { + const seconds = (runtime.nowMs ?? 0) / 1000; + const beatPhase = runtime.beatPhase ?? 0; + const pulse = (1 - beatPhase) ** 2; + const hue = sceneHue(seconds, beatPhase); + const accent = hsvToHex(hue, 0.62, 1); + const { left: playfieldLeft, right: playfieldRight } = layout.playfield; + Object.assign(BGA, layout.bga ?? NO_MONITOR); + const hasBga = runtime.hasBga === true && BGA.w > 0; + + const space = layerPool.acquireGraphics(); + space.label = 'synesthesia-gameplay/space'; + space.blendMode = 'normal'; + // Zone: the space intensifies with the combo (faster, denser dust, a hotter floor, particle rivers). + // Reduced effects cap the escalation; effects off keep the calm tier-0 space. + const effects = effectProfile(runtime.effects); + const tier = effects.enabled ? Math.min(comboTier(runtime.combo ?? 0), effects.screenWide ? 4 : 2) : 0; + // Input reaction: every press surges the dust and tints the sky with the pressed lane's light. + const hit = impulse(runtime.impulseAtMs, runtime.nowMs ?? 0, 280) * effects.amount; + const hitColor = IMPULSE_COLORS[runtime.impulseKind ?? 'white']; + // Camera: the background roams between random shots (flying, sometimes hard-cutting) with a handheld drift, + // reframing floor, rivers, and schools. Reduced effects halve the moves; effects off keep the camera at rest. + const camera = mixCamera(REST_CAMERA, roamingCamera(seconds, 7, CAMERA_RANGE, CAMERA_CYCLE_S), effects.amount); + const horizon = vanishingPoint(camera, CENTER_X, FLOOR.horizon, FLOOR.focal).y; + // The space listens to the mix: the ember haze and floor swell on the bass, dust speeds with loudness, rivers weave with the mids, the schools tighten on bass hits and scatter on transients. + const drive = audioDrive(runtime.audio, runtime.effects); + drawGround(space, hasBga, hit, hitColor, horizon); + + const light = layerPool.acquireGraphics(); + light.label = 'synesthesia-gameplay/light'; + light.blendMode = 'add'; + drawDust(light, seconds, pulse, hasBga, tier, hit, camera, drive); + drawFloor(light, seconds, pulse, hasBga, tier, hit, camera, drive); + const rivers = tier >= 4 ? 3 : tier >= 2 ? 2 : 1; + for (let river = 0; river < rivers; river += 1) { + drawRiver(light, seconds, river, hasBga, tier, camera, drive); + } + if (effects.enabled) { + // Schools of light fish swimming through the space: tighter on the beat, scattering on every key press. + const schools = advanceSchools(layer, runtime.nowMs ?? 0, effects.screenWide ? SCHOOL_SIZE : SCHOOL_SIZE / 2, { + gather: Math.max(pulse * 0.6, drive.bass), + scatter: Math.max(impulse(runtime.impulseAtMs, runtime.nowMs ?? 0, 520) * effects.amount, 0.8 * drive.onset), + }); + const shown = effects.screenWide ? schools.length : 1; + for (let school = 0; school < shown; school += 1) { + drawSchool( + light, + schools[school]!, + (point) => projectPoint(viewPoint(point, camera), CENTER_X, FLOOR.horizon, FLOOR.focal), + { + alpha: 0.95, + palette: SCHOOL_SPECS[school]!.palette, + skip: (x, y) => + x < -10 || x > DESIGN_WIDTH + 10 || y < 0 || y > DESIGN_HEIGHT || (hasBga && insideBga(x, y, 4)), + }, + ); + } + } + + const panels = layerPool.acquireGraphics(); + panels.label = 'synesthesia-gameplay/panels'; + panels.blendMode = 'normal'; + drawPlayfieldWell(panels, playfieldLeft, playfieldRight, runtime.progressRatio, accent); + // The monitor shrinks to fit beside a double-play field; only a page-wide keyboard field leaves no room for it. + if (BGA.w > 0) { + const standby = layerPool.acquireGraphics(); + standby.label = 'synesthesia-gameplay/standby'; + standby.blendMode = 'add'; + drawBgaFrame(panels, standby, layer, hasBga, seconds, pulse, hue, camera, drive, layerPool); + } + drawGauge(panels, layer, runtime, accent, layerPool); + drawSongPlate(panels, layer, runtime, accent, layerPool); + drawScorePanel(panels, layer, runtime, accent, layerPool); + // The judge tally owns the side column on the right edge. + drawJudgeTally(panels, layer, runtime, STAGE_SIDE_COLUMN.x, layerPool); + + const front = overlayLayerPool.acquireGraphics(); + front.label = 'synesthesia-gameplay/header'; + drawHeader(front, overlayLayer, runtime, accent, pulse, overlayLayerPool); + if (runtime.loading) { + drawLoading(overlayLayer, runtime.nowMs ?? 0, (playfieldLeft + playfieldRight) / 2, accent, overlayLayerPool); + } else { + drawJudgements(overlayLayer, runtime, layout, seconds, overlayLayerPool); + } + drawSynesthesiaMoments( + layer, + overlayLayer, + runtime, + (playfieldLeft + playfieldRight) / 2, + hue, + overlayLayerPool, + layout, + ); +} + +/** NOW LOADING on the lanes: wide-tracked light breathing in the accent glow, dots ticking beneath. */ +function drawLoading(layer: Container, nowMs: number, cx: number, accent: number, pool: ChildPool): void { + const breath = 0.55 + 0.45 * Math.sin(nowMs / 420) ** 2; + addHudText( + layer, + LOADING_WORD, + cx, + 186, + { + size: 13, + fill: SYN_WHITE, + fontFamily: SYN_DISPLAY_FONT, + letterSpacing: 4, + anchorX: 0.5, + anchorY: 0.5, + maxWidth: 180, + dropShadow: { color: accent, alpha: breath, blur: 10, distance: 0 }, + }, + pool, + ); + addHudText( + layer, + loadingDots(nowMs).padEnd(3, ' '), + cx, + 208, + { size: 13, fill: accent, fontFamily: SYN_DISPLAY_FONT, letterSpacing: 6, anchorX: 0.5, anchorY: 0.5 }, + pool, + ); +} + +function insideBga(x: number, y: number, margin = 0): boolean { + return x > BGA.x - margin && x < BGA.x + BGA.w + margin && y > BGA.y - margin && y < BGA.y + BGA.h + margin; +} + +/** + * The monitor rect of the frame being drawn: the stage's BGA square for the current playfield (it shrinks beside a + * double-play field). Updated at the top of {@link renderSynesthesiaChrome} and read by the drawing helpers below. + */ +const BGA: BeMusicRect = { x: 0, y: 0, w: 0, h: 0 }; +/** Stand-in for a layout without a BGA (a page-wide keyboard field): an empty rect, so no monitor is drawn. */ +const NO_MONITOR: BeMusicRect = { x: 0, y: 0, w: 0, h: 0 }; + +/** Horizontal centre of the stage — the space's vanishing point. */ +const CENTER_X = DESIGN_WIDTH / 2; + +const IMPULSE_COLORS: Record<'white' | 'black' | 'scratch', number> = { + white: SYN_EMBER, + black: SYN_CYAN, + scratch: SYN_MAGENTA, +}; + +/** Vanishing point the dust pours out of (behind the monitor). */ +const VANISH = { x: CENTER_X, y: FLOOR.horizon - 90 } as const; + +/** + * Warm-black ground (a stack of bands, so a live BGA stays untouched), plus a brief tint of the pressed lane's light on + * each key press above `horizon`, which follows the camera's pitch. + */ +function drawGround(graphics: Graphics, hasBga: boolean, hit: number, hitColor: number, horizon: number): void { + const bands: ReadonlyArray = [ + [0x090302, 140], + [0x060201, 260], + [0x040100, 380], + [SYN_VOID, DESIGN_HEIGHT], + ]; + let top = 0; + for (const [color, bottom] of bands) { + fillAroundBga(graphics, 0, top, DESIGN_WIDTH, bottom - top, color, hasBga); + top = bottom; + } + if (hit > 0) { + fillAroundBga(graphics, 0, 0, DESIGN_WIDTH, horizon, hitColor, hasBga, 0.06 * hit); + } +} + +/** + * Data dust (tiny warm squares, the odd blue / magenta mote) flying out of the vanishing point, faster with the combo + * tier and on key presses. + */ +function drawDust( + graphics: Graphics, + seconds: number, + pulse: number, + hasBga: boolean, + tier: number, + hit: number, + camera: CameraPose, + drive: AudioDrive, +): void { + const batch = sharedShapeBatch; + const count = 220 + 50 * tier; + const speed = (120 + 40 * pulse) * (1 + 0.45 * tier) * (1 + 2.2 * hit) * (1 + 1.2 * drive.level); + const field = { spread: 640, near: 20, far: 900, speed }; + const basis = cameraBasis(camera, SCRATCH_BASIS); + for (let index = 0; index < count; index += 1) { + const point = starfieldInto(SCRATCH_POINT, index, seconds, field); + const projected = projectViewInto(SCRATCH_A, point.x, point.y, point.z, basis, VANISH.x, VANISH.y, 180); + if (!projected.visible) continue; + if (projected.x < 0 || projected.x > DESIGN_WIDTH || projected.y < 0 || projected.y > DESIGN_HEIGHT) continue; + if (hasBga && insideBga(projected.x, projected.y, 4)) continue; + const nearness = Math.min(1, projected.scale); + const size = 0.5 + 1.3 * nearness; + const color = index % 9 === 0 ? SYN_CYAN : index % 13 === 0 ? SYN_MAGENTA : emberColor(0.35 + 0.65 * nearness); + if (nearness > 0.45) { + // Near motes get a soft bokeh square around them. + const bokeh = size * 3.2; + batch.rect(graphics, color, 0.05 * nearness, projected.x - bokeh / 2, projected.y - bokeh / 2, bokeh, bokeh); + } + batch.rect(graphics, color, 0.3 + 0.6 * nearness, projected.x - size / 2, projected.y - size / 2, size, size); + } + batch.flush(); +} + +/** Particle-world floor: a lattice of light points scrolling toward the player on the beat, over faint radial guide lines. */ +function drawFloor( + graphics: Graphics, + seconds: number, + pulse: number, + hasBga: boolean, + tier: number, + hit: number, + camera: CameraPose, + drive: AudioDrive, +): void { + const { horizon, height, focal } = FLOOR; + const glow = 1 + 0.3 * tier + 0.8 * hit + 1.2 * drive.bass; + const basis = cameraBasis(camera, SCRATCH_BASIS); + // The floor runs up to a live BGA but never over it: lines are clipped around the monitor, points inside it skipped. + const pieces = FLOOR_PIECES; + pieces.length = 0; + for (let x = -1500; x <= 1500; x += 100) { + const near = projectViewInto(SCRATCH_A, x, height, 0, basis, CENTER_X, horizon, focal); + const far = projectViewInto(SCRATCH_B, x, height, 2400, basis, CENTER_X, horizon, focal); + if (!near.visible || !far.visible) continue; + if (hasBga) clipSegmentOutsideRect(near.x, near.y, far.x, far.y, BGA, pieces); + else pieces.push(near.x, near.y, far.x, far.y); + } + for (let index = 0; index < pieces.length; index += 4) { + graphics.moveTo(pieces[index]!, pieces[index + 1]!).lineTo(pieces[index + 2]!, pieces[index + 3]!); + } + graphics.stroke({ color: SYN_EMBER, width: 1, alpha: Math.min(0.4, 0.05 * glow) }); + const spacing = 90; + const offset = (seconds * 150 * (1 + 0.3 * tier)) % spacing; + for (let z = spacing - offset; z < 2400; z += spacing) { + const nearness = 1 - z / 2400; + const alpha = Math.min(1, (0.12 + 0.7 * nearness * nearness) * (0.7 + 0.3 * pulse) * glow); + const size = 0.6 + 1.4 * nearness * nearness; + const color = emberColor(0.3 + 0.65 * nearness); + for (let x = -1500; x <= 1500; x += 50) { + const point = projectViewInto(SCRATCH_A, x, height, z, basis, CENTER_X, horizon, focal); + if (!point.visible || point.x < -4 || point.x > DESIGN_WIDTH + 4 || point.y > DESIGN_HEIGHT + 4) continue; + if (hasBga && insideBga(point.x, point.y, 2)) continue; + sharedShapeBatch.rect(graphics, color, alpha, point.x - size / 2, point.y - size / 2, size, size); + } + } + sharedShapeBatch.flush(); +} + +/** Scratch buffer for clipped floor lines (`x0, y0, x1, y1` quads), reused every frame. */ +const FLOOR_PIECES: number[] = []; +/** Scratch objects for the allocation-free projection in the hot loops above. */ +const SCRATCH_BASIS = cameraBasis(REST_CAMERA); +const SCRATCH_A = scratchProjected(); +const SCRATCH_B = scratchProjected(); +const SCRATCH_POINT = { x: 0, y: 0, z: 0 }; + +const RIVER_PARTICLES = 280; + +/** + * A river of light particles streaming low over the floor, receding diagonally into the distance — golden + * flows. Each particle is a short streak along the flow; river 1 runs blue-white, the others ember. + */ +function drawRiver( + graphics: Graphics, + seconds: number, + river: number, + hasBga: boolean, + tier: number, + camera: CameraPose, + drive: AudioDrive, +): void { + const options = { + length: 1500, + speed: 260 + 60 * tier, + amplitude: 28 * (1 + 1.4 * drive.mid), + width: 16, + seed: river + 1, + }; + const baseY = [118, 70, 134][river] ?? 118; + const baseZ = [520, 900, 300][river] ?? 520; + const slope = [0.55, -0.4, 0.3][river] ?? 0.5; + const toWorld = (local: { x: number; y: number; z: number }) => + viewPoint({ x: local.x, y: baseY + local.y, z: baseZ + local.z + local.x * slope }, camera); + for (let index = 0; index < RIVER_PARTICLES; index += 1) { + const local = particleRiverPoint(index, seconds, options); + const head = projectPoint(toWorld(local), CENTER_X, FLOOR.horizon, FLOOR.focal); + const tail = projectPoint(toWorld({ ...local, x: local.x - 26 }), CENTER_X, FLOOR.horizon, FLOOR.focal); + if (!head.visible || !tail.visible) continue; + if (head.x < -20 || head.x > DESIGN_WIDTH + 20 || head.y < 0 || head.y > DESIGN_HEIGHT) continue; + if (hasBga && (insideBga(head.x, head.y, 4) || insideBga(tail.x, tail.y, 4))) continue; + const heat = hash01(index * 3 + river * 101); + const color = river === 1 ? (heat > 0.7 ? SYN_WHITE : SYN_CYAN) : emberColor(0.5 + 0.5 * heat); + const nearness = Math.min(1, head.scale); + sharedShapeBatch.line( + graphics, + color, + 0.25 + 0.55 * heat, + 0.4 + 0.9 * nearness * (0.5 + heat), + tail.x, + tail.y, + head.x, + head.y, + ); + } + sharedShapeBatch.flush(); +} + +function fillAroundBga( + graphics: Graphics, + x: number, + y: number, + w: number, + h: number, + color: number, + hasBga: boolean, + alpha = 1, +): void { + const fill = { color, alpha }; + const right = x + w; + const bottom = y + h; + const holeRight = BGA.x + BGA.w; + const holeBottom = BGA.y + BGA.h; + if (!hasBga || right <= BGA.x || x >= holeRight || bottom <= BGA.y || y >= holeBottom) { + graphics.rect(x, y, w, h).fill(fill); + return; + } + if (y < BGA.y) graphics.rect(x, y, w, BGA.y - y).fill(fill); + const bandTop = Math.max(y, BGA.y); + const bandBottom = Math.min(bottom, holeBottom); + if (x < BGA.x) graphics.rect(x, bandTop, BGA.x - x, bandBottom - bandTop).fill(fill); + if (right > holeRight) graphics.rect(holeRight, bandTop, right - holeRight, bandBottom - bandTop).fill(fill); + if (bottom > holeBottom) graphics.rect(x, holeBottom, w, bottom - holeBottom).fill(fill); +} + +function drawPlayfieldWell( + graphics: Graphics, + playfieldLeft: number, + right: number, + progressRatio: number | undefined, + accent: number, +): void { + const left = playfieldLeft - 4; + const w = right - left + 4; + graphics.rect(left, 0, w, PLAYFIELD_FRAME_BOTTOM).fill({ color: SYN_VOID, alpha: 0.72 }); + // Glowing side rails. + for (const x of [left - 1, right + 2]) { + graphics.rect(x - 2, 0, 5, PLAYFIELD_FRAME_BOTTOM).fill({ color: accent, alpha: 0.06 }); + graphics.rect(x, 0, 1, PLAYFIELD_FRAME_BOTTOM).fill({ color: accent, alpha: 0.6 }); + } + // Song progress as a light rising along the left rail. + const ratio = + progressRatio !== undefined && Number.isFinite(progressRatio) ? Math.max(0, Math.min(1, progressRatio)) : 0; + const trackH = PLAYFIELD_FRAME_BOTTOM - 12; + if (ratio > 0) { + const y = 6 + trackH * (1 - ratio); + graphics.rect(left - 3, y, 5, trackH * ratio).fill({ color: SYN_WHITE, alpha: 0.12 }); + graphics.circle(left - 1, y, 3).fill({ color: SYN_WHITE, alpha: 0.95 }); + graphics.circle(left - 1, y, 7).fill({ color: accent, alpha: 0.25 }); + } + graphics.rect(left - 1, PLAYFIELD_FRAME_BOTTOM, w + 4, 1).fill({ color: accent, alpha: 0.7 }); +} + +/** + * Monitor frame: a warm hairline with lock-on corners. With no BGA the screen idles on a miniature particle world — a floating + * visualizer-style orb roaming in front of particle pyramids on an ember horizon — the + * "nothing is playing, but the space is alive" state. The idle scene draws into `light` (additive). + */ +function drawBgaFrame( + graphics: Graphics, + light: Graphics, + layer: Container, + hasBga: boolean, + seconds: number, + pulse: number, + hue: number, + camera: CameraPose, + drive: AudioDrive, + pool: ChildPool, +): void { + const color = hsvToHex(hue, 0.6, 1); + const margin = 6; + const frameX = BGA.x - margin; + const frameY = BGA.y - margin; + const frameW = BGA.w + margin * 2; + const frameH = BGA.h + margin * 2; + graphics.rect(frameX, frameY, frameW, frameH).stroke({ color, width: 4, alpha: 0.06 }); + graphics.rect(frameX, frameY, frameW, frameH).stroke({ color, width: 1, alpha: 0.4 }); + drawReticle(graphics, frameX, frameY, frameW, frameH, SYN_WHITE, 0.9, { arm: 18, width: 2 }); + if (hasBga) return; + + graphics.rect(BGA.x, BGA.y, BGA.w, BGA.h).fill({ color: SYN_VOID, alpha: 0.92 }); + const inside = (x: number, y: number) => !insideBga(x, y, -2); + // The monitor's camera circles the scene: the shot's turn is exaggerated into an orbit, its travel damped. + const orbit = 60; + const orbitCamera: CameraPose = { + x: camera.x * 0.2, + y: camera.y * 0.3, + z: 0, + yaw: camera.yaw * 2.2, + pitch: camera.pitch * 0.8, + }; + const cx = BGA.x + BGA.w / 2; + const baseHorizon = BGA.y + BGA.h * 0.66; + const view = { cx, cy: baseHorizon, focal: 220, camera: orbitCamera, orbit }; + // Particle pyramids on the horizon. + for (const [pyramid, x, z, scale, yaw] of [ + [PYRAMIDS[2]!, 40, 1500, 520, 0.2], + [PYRAMIDS[0]!, -330, 700, 250, 0.5], + [PYRAMIDS[1]!, 330, 820, 230, -0.3], + ] as const) { + drawPointCloud(light, pyramid, { scale, x, y: 0, z, yaw: yaw + seconds * 0.04 }, view, { + colorOf: (point) => emberColor(0.35 + 0.55 * point.weight), + alpha: 0.8, + size: 1, + seconds, + shimmer: 0.3, + referenceScale: 220 / (220 + z), + skip: (px, py) => inside(px, py), + }); + } + // Floor points scrolling in. + // A coarser lattice than the main floor: the monitor is small, so this keeps the point count down. + const offset = (seconds * 90) % 80; + for (let z = 80 - offset; z < 1400; z += 80) { + const nearness = 1 - z / 1400; + for (let x = -600; x <= 600; x += 40) { + const point = projectPoint(viewPoint({ x, y: 70, z }, orbitCamera, orbit), cx, baseHorizon, 220); + if (!point.visible || inside(point.x, point.y)) continue; + const size = 0.5 + 1.1 * nearness * nearness; + sharedShapeBatch.rect( + light, + emberColor(0.3 + 0.6 * nearness), + 0.2 + 0.6 * nearness * nearness, + point.x - size / 2, + point.y - size / 2, + size, + size, + ); + } + } + sharedShapeBatch.flush(); + // The audio orb roams the monitor's little world close to the camera: a visualizer-style shell of sparks and + // fibres with a black moon circling it. + const roam = wanderPoint(seconds * 0.8, 2, { minX: -55, maxX: 55, minY: -125, maxY: -70, minZ: -120, maxZ: 0 }); + const moonLayer = pool.acquireGraphics(); + moonLayer.label = 'synesthesia-gameplay/orb-moon'; + moonLayer.blendMode = 'normal'; + const frontLayer = pool.acquireGraphics(); + frontLayer.label = 'synesthesia-gameplay/orb-front'; + frontLayer.blendMode = 'add'; + const orb = drawMagnetoOrb( + { back: light, moon: moonLayer, front: frontLayer }, + () => pool.acquireSprite(), + ORB_SHELL, + ORB, + { + ...roam, + radius: 24 * (1 + 0.03 * pulse), + view, + drive, + seconds, + alpha: 1, + moon: { size: 0.45, distance: 2, speed: 0.65, phase: 1.1, tilt: 0.35 }, + clip: { x: BGA.x, y: BGA.y, w: BGA.w, h: BGA.h - 22 }, + }, + ); + if (orb) { + // Lock-on reticle tracking it. + const lock = orb.radius * 1.6 + 5 * pulse; + drawReticle(light, orb.x - lock, orb.y - lock, lock * 2, lock * 2, SYN_FLARE, 0.5, { arm: 8, cross: true }); + } + // A dark slip under the label, so the monitor's floor horizon never runs through the type. + const slip = pool.acquireGraphics(); + slip.label = 'synesthesia-gameplay/standby-slip'; + slip.blendMode = 'normal'; + slip.rect(cx - 76, BGA.y + BGA.h - 26, 152, 17).fill({ color: SYN_VOID, alpha: 0.9 }); + addHudText( + layer, + 'SOUND ONLY', + cx, + BGA.y + BGA.h - 22, + { ...displayStyle(9, SYN_MIST), letterSpacing: 6, anchorX: 0.5 }, + pool, + ); +} + +function drawHeader( + graphics: Graphics, + layer: Container, + runtime: Runtime, + accent: number, + pulse: number, + pool: ChildPool, +): void { + const autoplay = runtime.autoplay === true; + // Near-opaque, so the playfield rails and lanes stop under the header instead of running through its type. + graphics.rect(0, 0, DESIGN_WIDTH, 34).fill({ color: SYN_VOID, alpha: 0.92 }); + graphics.rect(0, 34, DESIGN_WIDTH, 1).fill({ color: accent, alpha: 0.35 + 0.35 * pulse }); + graphics.circle(HUD_MARGIN + 8, 17, 3).fill({ color: autoplay ? SYN_AMBER : accent, alpha: 1 }); + graphics.circle(HUD_MARGIN + 8, 17, 8).fill({ color: autoplay ? SYN_AMBER : accent, alpha: 0.18 + 0.2 * pulse }); + addHudText( + layer, + autoplay ? 'AUTO PLAY' : 'PLAY', + HUD_MARGIN + 22, + 12, + { ...displayStyle(10, SYN_WHITE), letterSpacing: 3 }, + pool, + ); + // Readouts on a fixed rhythm after the mode: label, then its value 40 px on; groups 96 px apart. + addHudText(layer, 'BPM', 172, 14, labelStyle(), pool); + addHudNumber(layer, formatBpm(runtime.bpm), 212, 9, displayStyle(14, SYN_WHITE), pool); + addHudText(layer, 'SPEED', 268, 14, labelStyle(), pool); + addHudNumber(layer, `x${formatHiSpeed(runtime.hiSpeed)}`, 328, 9, displayStyle(14, SYN_WHITE), pool); + addHudText( + layer, + formatRuleset(runtime.rulesetLabel), + DESIGN_WIDTH - HUD_MARGIN, + 12, + { ...displayStyle(9, accent), letterSpacing: 3, anchorX: 1, maxWidth: 110 }, + pool, + ); +} + +function drawGauge(graphics: Graphics, layer: Container, runtime: Runtime, accent: number, pool: ChildPool): void { + const gauge = clampPercent(runtime.gauge ?? 0); + const clear = clampPercent(runtime.clearThreshold ?? 80); + const survival = runtime.gaugeSurvival === true || clear <= 0; + const cleared = survival ? gauge > 0 : gauge >= clear; + drawFrame(graphics, GAUGE_FRAME.x, GAUGE_FRAME.y, GAUGE_FRAME.w, GAUGE_FRAME.h, accent); + addHudText( + layer, + `${(runtime.gaugeLabel ?? 'GROOVE').toUpperCase()} GAUGE`, + GROOVE.x - 2, + GROOVE.y - 19, + { ...labelStyle(), maxWidth: 130 }, + pool, + ); + addHudNumber( + layer, + `${Math.round(gauge)}%`, + GAUGE_FRAME.x + GAUGE_FRAME.w - 14, + GROOVE.y - 22, + { ...displayStyle(12, cleared ? SYN_AMBER : SYN_WHITE), anchorX: 1 }, + pool, + ); + const cells = 50; + const stride = GROOVE.w / cells; + const lit = Math.round((gauge / 100) * cells); + const clearCell = Math.round((clear / 100) * cells); + const flicker = runtime.nowMs !== undefined ? 0.65 + 0.35 * Math.abs(Math.sin(runtime.nowMs / 60)) : 1; + const barY = GROOVE.y + 2; + const barH = GROOVE.h - 4; + if (lit > 0) { + graphics + .rect(GROOVE.x, barY - 3, lit * stride, barH + 6) + .fill({ color: cleared ? SYN_EMBER : SYN_CYAN, alpha: 0.14 }); + } + for (let cell = 0; cell < cells; cell += 1) { + const x = GROOVE.x + cell * stride; + if (cell >= lit) { + graphics.rect(x, barY, stride - 1, barH).fill({ color: SYN_DIM, alpha: 0.18 }); + continue; + } + const hot = survival || cell >= clearCell; + // Below the clear line the cells run electric blue; past it they catch fire, ember → gold. + const color = hot + ? emberColor(0.4 + (0.55 * (cell - clearCell)) / Math.max(1, cells - clearCell)) + : hsvToHex(0.58 - (cell / cells) * 0.04, 0.72, 1); + graphics + .rect(x, barY, stride - 1, barH) + .fill({ color: cell === lit - 1 ? SYN_WHITE : color, alpha: cell === lit - 1 ? flicker : 0.95 }); + } + if (!survival) { + const x = GROOVE.x + clearCell * stride - 1; + graphics.rect(x, barY - 5, 1.5, barH + 10).fill({ color: SYN_WHITE, alpha: 1 }); + } +} + +function drawSongPlate(graphics: Graphics, layer: Container, runtime: Runtime, accent: number, pool: ChildPool): void { + const { x, y, w, h } = SONG_PLATE; + drawFrame(graphics, x, y, w, h, accent); + addHudText( + layer, + runtime.songTitle?.trim() || 'Untitled chart', + x + 14, + y + 6, + { + size: 15, + weight: '500', + fill: SYN_WHITE, + fontFamily: SYN_TEXT_FONT, + maxWidth: w - 28, + dropShadow: { color: accent, alpha: 0.7, blur: 6, distance: 0 }, + }, + pool, + ); + const artist = runtime.songArtist?.trim(); + if (artist) { + addHudText( + layer, + artist, + x + 14, + y + 28, + { size: 10, weight: '300', fill: SYN_MIST, fontFamily: SYN_TEXT_FONT, maxWidth: w - 28 }, + pool, + ); + } +} + +function drawScorePanel(graphics: Graphics, layer: Container, runtime: Runtime, accent: number, pool: ChildPool): void { + const { x, y, w, h } = SCORE_PANEL; + drawFrame(graphics, x, y, w, h, accent); + graphics.rect(x + SCORE_DIVIDER, y + 14, 1, h - 28).fill({ color: accent, alpha: 0.3 }); + addHudText(layer, 'SCORE', x + 14, y + 12, labelStyle(), pool); + addHudNumber( + layer, + formatCount(runtime.score), + x + SCORE_DIVIDER - 14, + y + 22, + { + ...displayStyle(17, SYN_WHITE), + anchorX: 1, + maxWidth: 120, + dropShadow: { color: accent, alpha: 0.8, blur: 6, distance: 0 }, + }, + pool, + ); + addHudText(layer, 'EX SCORE', x + 14, y + 54, labelStyle(), pool); + addHudNumber( + layer, + `${formatCount(runtime.exScore)}/${formatCount(runtime.exScoreMax)}`, + x + SCORE_DIVIDER - 14, + y + 52, + { ...displayStyle(8, SYN_MIST), anchorX: 1, maxWidth: 46 }, + pool, + ); + addHudText(layer, 'EX RATE', x + 14, y + 76, labelStyle(), pool); + addHudNumber( + layer, + formatExRate(runtime.exScore, runtime.exScoreMax), + x + SCORE_DIVIDER - 14, + y + 74, + { ...displayStyle(8, SYN_MIST), anchorX: 1, maxWidth: 46 }, + pool, + ); + // Rate meter as a light filament with the IIDX ninth ticks. + const rate = + runtime.exScore !== undefined && runtime.exScoreMax !== undefined && runtime.exScoreMax > 0 + ? Math.max(0, Math.min(1, runtime.exScore / runtime.exScoreMax)) + : 0; + const meterX = x + 14; + const meterW = SCORE_DIVIDER - 28; + graphics.rect(meterX, y + 94, meterW, 2).fill({ color: SYN_DIM, alpha: 0.3 }); + if (rate > 0) { + graphics.rect(meterX, y + 92, meterW * rate, 6).fill({ color: accent, alpha: 0.15 }); + graphics.rect(meterX, y + 94, meterW * rate, 2).fill({ color: rate >= 8 / 9 ? SYN_AMBER : SYN_WHITE, alpha: 0.95 }); + } + for (let ninth = 1; ninth < 9; ninth += 1) { + graphics + .rect(meterX + (meterW * ninth) / 9, y + 91, 1, 8) + .fill({ color: SYN_WHITE, alpha: ninth >= 6 ? 0.45 : 0.18 }); + } + addHudText(layer, 'COMBO', x + SCORE_DIVIDER + 14, y + 12, labelStyle(), pool); + addHudNumber( + layer, + formatCount(runtime.combo), + x + w - 12, + y + 24, + { ...displayStyle(14, SYN_WHITE), anchorX: 1, maxWidth: 62 }, + pool, + ); + addHudText(layer, 'MAX', x + SCORE_DIVIDER + 14, y + 54, labelStyle(), pool); + addHudNumber( + layer, + formatCount(runtime.maxCombo), + x + w - 12, + y + 52, + { ...displayStyle(9, SYN_MIST), anchorX: 1, maxWidth: 48 }, + pool, + ); + const rank = runtime.rank && runtime.rank !== '-' ? runtime.rank : 'F'; + addHudText(layer, 'RANK', x + SCORE_DIVIDER + 14, y + 76, labelStyle(), pool); + addHudText( + layer, + rank, + x + w - 12, + y + 84, + { + ...displayStyle(rank.length >= 3 ? 12 : 16, SYN_AMBER), + anchorX: 1, + maxWidth: 46, + dropShadow: { color: SYN_AMBER, alpha: 0.8, blur: 8, distance: 0 }, + }, + pool, + ); +} + +const TALLY: ReadonlyArray = [ + ['PG', 'perfect'], + ['GR', 'great'], + ['GD', 'good'], + ['BD', 'bad'], + ['PR', 'poor'], +]; + +function drawJudgeTally(graphics: Graphics, layer: Container, runtime: Runtime, x: number, pool: ChildPool): void { + const y = BGA.y - 6; + const w = DESIGN_WIDTH - HUD_MARGIN - x; + drawFrame(graphics, x, y, w, 186, SYN_EMBER); + for (let row = 0; row < TALLY.length; row += 1) { + const [label, key] = TALLY[row]!; + const rowY = y + 14 + row * 26; + const color = JUDGE_COLORS[TALLY_NAMES[key]] ?? SYN_WHITE; + graphics.circle(x + 9, rowY + 6, 2).fill({ color, alpha: 1 }); + graphics.circle(x + 9, rowY + 6, 5).fill({ color, alpha: 0.2 }); + addHudText(layer, label, x + 17, rowY + 1, { ...labelStyle(), fill: color }, pool); + addHudNumber( + layer, + formatCount(runtime[key]), + x + w - 7, + rowY - 1, + { ...displayStyle(9, SYN_WHITE), anchorX: 1, maxWidth: w - 46 }, + pool, + ); + } + const footerY = y + 14 + TALLY.length * 26 + 4; + graphics.rect(x + 8, footerY - 6, w - 16, 1).fill({ color: SYN_EMBER, alpha: 0.35 }); + for (const [row, label, color, count] of [ + [0, 'FAST', SYN_CYAN, runtime.fast], + [1, 'SLOW', SYN_MAGENTA, runtime.slow], + ] as const) { + const rowY = footerY + row * 20; + addHudText(layer, label, x + 8, rowY + 2, { ...labelStyle(), fill: color }, pool); + addHudNumber( + layer, + formatCount(count), + x + w - 7, + rowY, + { ...displayStyle(9, SYN_WHITE), anchorX: 1, maxWidth: w - 52 }, + pool, + ); + } +} + +const TALLY_NAMES = { perfect: 'PERFECT', great: 'GREAT', good: 'GOOD', bad: 'BAD', poor: 'POOR' } as const; +const JUDGE_COLORS: Record = { + PERFECT: SYN_FLARE, + GREAT: SYN_AMBER, + GOOD: SYN_CYAN, + BAD: SYN_MAGENTA, + POOR: SYN_RED, +}; + +/** Colours the PERFECT judgement's flashing GREAT cycles through. */ +const FLASHING_GREAT = [SYN_CYAN, SYN_WHITE, SYN_AMBER, SYN_MAGENTA, SYN_GREEN] as const; + +/** + * Judgement word and combo as light: wide tracked type with a same-colour bloom. PERFECT prints as a GREAT that flashes + * through the prism colours, so a clean run literally shimmers. + */ +function drawJudgements( + layer: Container, + runtime: Runtime, + layout: BeMusicGameplayLayout, + seconds: number, + pool: ChildPool, +): void { + const displays = resolveJudgeDisplays(runtime, layout); + const nowMs = runtime.nowMs ?? 0; + const amount = effectProfile(runtime.effects).amount; + for (const display of displays) { + const judgeScale = punchScale(runtime.judgeAtMs, nowMs, 120, 0.22 * amount); + const color = isFlashingGreat(display.judge) + ? flashingGreatColor(nowMs, FLASHING_GREAT) + : (JUDGE_COLORS[display.judge] ?? SYN_WHITE); + addHudText( + layer, + judgeDisplayWord(display.judge), + display.x, + 236, + { + scale: judgeScale, + ...displayStyle(16, color), + letterSpacing: 4, + anchorX: 0.5, + anchorY: 0.5, + maxWidth: display.maxWidth, + dropShadow: { color, alpha: 0.95, blur: 10, distance: 0 }, + }, + pool, + ); + const combo = resolveVisibleCombo(display.judge, display.combo); + if (combo > 0) { + addHudNumber( + layer, + formatCount(combo), + display.x, + 264, + { + scale: punchScale(runtime.judgeAtMs, nowMs, 150, (combo % 100 === 0 ? 0.45 : 0.14) * amount), + ...displayStyle(22, combo >= 200 ? SYN_AMBER : SYN_WHITE), + anchorX: 0.5, + anchorY: 0.5, + maxWidth: Math.max(60, display.maxWidth - 40), + dropShadow: { color: combo >= 200 ? SYN_AMBER : SYN_EMBER, alpha: 0.85, blur: 10, distance: 0 }, + }, + pool, + ); + } + } +} + +function resolveJudgeDisplays( + runtime: Runtime, + layout: BeMusicGameplayLayout, +): Array<{ judge: string; combo: number | undefined; x: number; maxWidth: number }> { + const centerOf = (side: '1P' | '2P'): number | undefined => { + const bounds = layout.playfield.sides[side]; + return bounds ? (bounds.left + bounds.right) / 2 : undefined; + }; + const doublePlay = centerOf('2P') !== undefined; + const maxWidth = doublePlay ? 122 : 180; + const fallbackX = layout.playfield.centerX; + const sides = runtime.judgeSides?.filter((state) => typeof state.judge === 'string' && state.judge.length > 0); + if (sides?.length) { + return sides.map((state) => ({ + judge: state.judge!, + combo: state.combo, + x: centerOf(state.side) ?? fallbackX, + maxWidth, + })); + } + if (!runtime.lastJudge) return []; + return [{ judge: runtime.lastJudge, combo: runtime.combo, x: centerOf('1P') ?? fallbackX, maxWidth }]; +} + +function resolveVisibleCombo(judge: string, combo: number | undefined): number { + if (judge !== 'PERFECT' && judge !== 'GREAT' && judge !== 'GOOD') return 0; + return combo !== undefined && Number.isFinite(combo) ? Math.max(0, Math.floor(combo)) : 0; +} + +function labelStyle(): HudTextOptions { + return { size: 9, fill: SYN_DIM, fontFamily: SYN_DISPLAY_FONT, letterSpacing: 0.8 }; +} + +function displayStyle(size: number, fill: number): HudTextOptions { + return { size, fill, fontFamily: SYN_DISPLAY_FONT, weight: '400' }; +} + +function formatCount(value: number | undefined): string { + if (value === undefined || !Number.isFinite(value)) return '0'; + return String(Math.max(0, Math.floor(value))); +} + +function formatBpm(value: number | undefined): string { + if (value === undefined || !Number.isFinite(value)) return '---'; + return String(Math.round(value)); +} + +function formatHiSpeed(value: number | undefined): string { + if (value === undefined || !Number.isFinite(value)) return '0.0'; + return value.toFixed(1); +} + +function formatRuleset(value: string | undefined): string { + switch (value) { + case 'beatoraja': + return 'BEATORAJA'; + case 'iidx': + return 'IIDX'; + default: + return 'LR2'; + } +} + +function formatExRate(exScore: number | undefined, max: number | undefined): string { + if (exScore === undefined || max === undefined || !Number.isFinite(exScore) || !Number.isFinite(max) || max <= 0) { + return '0.0%'; + } + return `${((exScore / max) * 100).toFixed(1)}%`; +} + +function clampPercent(value: number): number { + if (!Number.isFinite(value)) return 0; + return Math.max(0, Math.min(100, value)); +} diff --git a/packages/player-web/src/scene/default/synesthesia/index.ts b/packages/player-web/src/skins/synesthesia/index.ts similarity index 59% rename from packages/player-web/src/scene/default/synesthesia/index.ts rename to packages/player-web/src/skins/synesthesia/index.ts index 8a49a5c7..f7f880c1 100644 --- a/packages/player-web/src/scene/default/synesthesia/index.ts +++ b/packages/player-web/src/skins/synesthesia/index.ts @@ -1,4 +1,3 @@ -import type { BeMusicSkin } from '../../../skin/be-music/types.ts'; import { renderSynesthesiaChrome } from './gameplay-chrome.ts'; import { SYNESTHESIA_BOMB_DURATION_MS, @@ -9,16 +8,26 @@ import { } from './playfield.ts'; import { synesthesiaResultSkin } from './result.ts'; import { synesthesiaSelectSkin } from './select.ts'; +import { BE_MUSIC_SKIN_API_VERSION, wideStage, type BeMusicSkin } from '@be-music/skin-sdk'; +import { definePixiSkin } from '../pixi-kit/index.ts'; /** - * Synesthesia — a skin after the sound-and-light games of Tetsuya Mizuguchi (Rez Infinite, Tetris Effect): deep space, + * Synesthesia — a skin after synaesthetic sound-and-light rhythm games: deep space, * streaming 3D stars, a wireframe floor grid, glass panels, colour that keeps time with the music, and hits that * detonate as perspective-projected particle bursts. */ -export const synesthesiaSkin: BeMusicSkin = { +export const synesthesiaSkin: BeMusicSkin = definePixiSkin({ + apiVersion: BE_MUSIC_SKIN_API_VERSION, id: 'synesthesia', label: 'Synesthesia', + version: '1.0.0', + author: { name: 'be-music', url: 'https://github.com/nulltask/be-music' }, + description: + 'A cosmic particle world in ember and gold with electric blue and magenta accents, where the space flies, flocks, and bursts with the music.', + homepage: 'https://github.com/nulltask/be-music', + license: 'MIT', fontLoads: ['400 16px "Michroma"', '300 12px "M PLUS 1p"', '500 12px "M PLUS 1p"'], + stage: wideStage, gameplay: { renderChrome: renderSynesthesiaChrome, renderLanes: renderSynesthesiaLanes, @@ -29,4 +38,4 @@ export const synesthesiaSkin: BeMusicSkin = { }, select: synesthesiaSelectSkin, result: synesthesiaResultSkin, -}; +}); diff --git a/packages/player-web/src/skins/synesthesia/layout.ts b/packages/player-web/src/skins/synesthesia/layout.ts new file mode 100644 index 00000000..fefc9bc8 --- /dev/null +++ b/packages/player-web/src/skins/synesthesia/layout.ts @@ -0,0 +1,4 @@ +/** Synesthesia's own HUD geometry on the 16:9 stage. */ + +/** The groove gauge's bar (its frame and labels are drawn around it). */ +export const GROOVE = { x: 44, y: 387, w: 200, h: 14 } as const; diff --git a/packages/player-web/src/skins/synesthesia/moments.ts b/packages/player-web/src/skins/synesthesia/moments.ts new file mode 100644 index 00000000..3b9c8308 --- /dev/null +++ b/packages/player-web/src/skins/synesthesia/moments.ts @@ -0,0 +1,313 @@ +import { Graphics, type Container } from 'pixi.js'; +import { GROOVE } from './layout.ts'; +import { drawReticle } from './draw.ts'; +import { + burstParticlePosition, + burstParticles, + emberColor, + hsvToHex, + projectPoint, + rotateX, + rotateY, +} from './space.ts'; +import { SYN_AMBER, SYN_CYAN, SYN_DISPLAY_FONT, SYN_FLARE, SYN_MAGENTA, SYN_WHITE } from './style.ts'; +import { + easeOutCubic, + type BeMusicRect, + effectProfile, + type MilestoneArea, + momentProgress, + trackMoments, + resolveMilestoneArea, + type BeMusicGameplayLayout, + type BeMusicGameplayRuntime, + STAGE_HEIGHT as DESIGN_HEIGHT, + STAGE_WIDTH as DESIGN_WIDTH, + stageProgress, +} from '@be-music/skin-sdk'; +import { addHudText, type ChildPool } from '../pixi-kit/index.ts'; + +/** Stand-in for a layout without a BGA (a page-wide keyboard field): milestones then play in their area. */ +const NO_MONITOR: BeMusicRect = { x: 0, y: 0, w: 0, h: 0 }; + +const MILESTONE_MS = 1300; +const CLEAR_MS = 1100; +const FULL_COMBO_MS = 2600; +const FULL_COMBO_BURST = burstParticles(77, 640); +const MILESTONE_COLORS = [SYN_AMBER, SYN_CYAN, SYN_MAGENTA] as const; + +/** + * Synesthesia showpieces, painted on the front layer: + * + * - the count-in: READY condenses out of wide-tracked light over a widening filament, then START flares out on the + * first beat; + * - every 100 combo: the count glows over the BGA monitor, caught in a lock-on reticle that snaps shut on it; + * - the clear line: a light sweep runs the length of the gauge; + * - a full combo: a warm bloom, a 3D sphere of voxel sparks bursting from the centre of the screen, and FULL COMBO + * glowing gold. + */ +export function drawSynesthesiaMoments( + chromeLayer: Container, + layer: Container, + runtime: BeMusicGameplayRuntime, + playfieldCenterX: number, + hue: number, + pool: ChildPool, + layout: BeMusicGameplayLayout, +): void { + const nowMs = runtime.nowMs ?? 0; + const moments = trackMoments(chromeLayer, { + nowMs, + combo: runtime.combo ?? 0, + gauge: runtime.gauge ?? 0, + clearThreshold: runtime.clearThreshold ?? 80, + survival: runtime.gaugeSurvival === true, + judged: + (runtime.perfect ?? 0) + (runtime.great ?? 0) + (runtime.good ?? 0) + (runtime.bad ?? 0) + (runtime.poor ?? 0), + totalNotes: runtime.totalNotes ?? 0, + bad: runtime.bad ?? 0, + poor: runtime.poor ?? 0, + }); + const effects = effectProfile(runtime.effects); + if (!effects.enabled) return; + const graphics = pool.acquireGraphics(); + graphics.label = 'synesthesia/moments'; + graphics.blendMode = 'add'; + + // Misses get no effect at all: the playfield stays still and clear so the next notes read. + + if (runtime.chartMs !== undefined) { + drawCountIn(graphics, layer, runtime.chartMs, playfieldCenterX, layout.playfield.judgementY, hue, pool); + } + const milestone = momentProgress(moments.milestone?.atMs, nowMs, MILESTONE_MS); + if (milestone !== undefined && moments.milestone) { + drawMilestone( + graphics, + layer, + moments.milestone.value, + milestone, + resolveMilestoneArea(layout.playfield.right, 346, layout.stage.width), + layout.bga ?? NO_MONITOR, + runtime.hasBga === true, + pool, + ); + } + const clear = momentProgress(moments.clearAtMs, nowMs, CLEAR_MS); + if (clear !== undefined) { + drawClear(graphics, layer, clear, pool); + } + const fullCombo = momentProgress(moments.fullComboAtMs, nowMs, FULL_COMBO_MS); + if (fullCombo !== undefined) { + drawFullCombo(graphics, layer, fullCombo, nowMs, hue, effects.screenWide, effects.amount, pool); + } +} + +function drawCountIn( + graphics: Graphics, + layer: Container, + chartMs: number, + playfieldCenterX: number, + judgeY: number, + hue: number, + pool: ChildPool, +): void { + if (chartMs < -2600 || chartMs > 900) return; + const color = hsvToHex(hue, 0.55, 1); + const form = easeOutCubic(stageProgress(chartMs, -2600, 1400)); + const fade = 1 - stageProgress(chartMs, -300, 300); + if (fade > 0) { + const cy = 214; + const half = 30 + 260 * form; + graphics.rect(DESIGN_WIDTH / 2 - half, cy + 22, half * 2, 1).fill({ color: SYN_WHITE, alpha: 0.85 * fade }); + graphics.rect(DESIGN_WIDTH / 2 - half, cy + 19, half * 2, 7).fill({ color, alpha: 0.18 * fade }); + const text = addHudText( + layer, + 'READY', + DESIGN_WIDTH / 2, + cy, + { + size: 26, + fill: SYN_WHITE, + fontFamily: SYN_DISPLAY_FONT, + letterSpacing: 10 + 40 * (1 - form), + anchorX: 0.5, + anchorY: 0.5, + dropShadow: { color, alpha: 1, blur: 14, distance: 0 }, + }, + pool, + ); + text.alpha = form * fade; + } + // First beat: START flares out. + const start = stageProgress(chartMs, 0, 900); + if (start > 0 && start < 1) { + const eased = easeOutCubic(start); + const text = addHudText( + layer, + 'START', + DESIGN_WIDTH / 2, + 214, + { + size: 22, + fill: SYN_WHITE, + fontFamily: SYN_DISPLAY_FONT, + letterSpacing: 12 + 20 * eased, + anchorX: 0.5, + anchorY: 0.5, + dropShadow: { color, alpha: 1, blur: 14, distance: 0 }, + }, + pool, + ); + text.alpha = 1 - start; + } +} + +function drawMilestone( + graphics: Graphics, + layer: Container, + value: number, + t: number, + area: MilestoneArea, + bga: BeMusicRect, + hasBga: boolean, + pool: ChildPool, +): void { + // Each hundred takes the next accent light in turn: gold, electric blue, magenta. + const color = MILESTONE_COLORS[Math.max(0, Math.floor(value / 100) - 1) % MILESTONE_COLORS.length]!; + // The count plays in the milestone area — over the monitor beside the lanes, or in the band below them in double + // play — never over the notes. Over a live BGA it sits small on the monitor's top edge. + // A keyboard field too wide for a monitor plays the count in the middle of its area instead. + const below = area.mode === 'below' || bga.w === 0; + hasBga = hasBga && bga.w > 0; + const compact = hasBga || below; + const cx = below ? area.x + area.w / 2 : bga.x + bga.w / 2; + const cy = below ? area.y + area.h / 2 - 10 : hasBga ? bga.y + 30 : bga.y + bga.h / 2 - 10; + const alpha = Math.min(1, t / 0.12) * (1 - Math.max(0, (t - 0.7) / 0.3)); + const count = addHudText( + layer, + String(value), + cx, + cy, + { + size: compact ? 30 : 52, + fill: SYN_WHITE, + fontFamily: SYN_DISPLAY_FONT, + letterSpacing: 4, + anchorX: 0.5, + anchorY: 0.5, + dropShadow: { color, alpha: 1, blur: 18, distance: 0 }, + }, + pool, + ); + count.alpha = alpha; + count.scale.set(1.25 - 0.25 * easeOutCubic(Math.min(1, t / 0.25))); + const snap = easeOutCubic(Math.min(1, t / 0.2)); + const lockW = (compact ? 120 : 190) * (1.6 - 0.6 * snap); + const lockH = (compact ? 74 : 110) * (1.6 - 0.6 * snap); + drawReticle(graphics, cx - lockW / 2, cy + (compact ? 14 : 22) - lockH / 2, lockW, lockH, color, 0.9 * alpha, { + arm: 14, + width: 2, + }); + const label = addHudText( + layer, + 'COMBO', + cx, + cy + (compact ? 28 : 44), + { + size: 11, + fill: SYN_WHITE, + fontFamily: SYN_DISPLAY_FONT, + letterSpacing: 10, + anchorX: 0.5, + anchorY: 0.5, + dropShadow: { color, alpha: 1, blur: 8, distance: 0 }, + }, + pool, + ); + label.alpha = alpha; +} + +function drawClear(graphics: Graphics, layer: Container, t: number, pool: ChildPool): void { + const color = SYN_AMBER; + const head = GROOVE.x + GROOVE.w * easeOutCubic(Math.min(1, t / 0.5)); + const fade = 1 - Math.max(0, (t - 0.55) / 0.45); + graphics.rect(GROOVE.x, GROOVE.y - 2, head - GROOVE.x, GROOVE.h + 4).fill({ color, alpha: 0.22 * fade }); + graphics.rect(head - 3, GROOVE.y - 8, 6, GROOVE.h + 16).fill({ color: SYN_WHITE, alpha: 0.9 * fade }); + graphics.circle(head, GROOVE.y + GROOVE.h / 2, 14).fill({ color, alpha: 0.3 * fade }); + const text = addHudText( + layer, + 'CLEAR LINE', + GROOVE.x + GROOVE.w - 8, + // Between the playfield frame's bottom edge and the gauge panel, touching neither. + GROOVE.y - 34, + { + size: 12, + fill: SYN_WHITE, + fontFamily: SYN_DISPLAY_FONT, + letterSpacing: 6, + anchorX: 1, + anchorY: 0.5, + dropShadow: { color, alpha: 1, blur: 10, distance: 0 }, + }, + pool, + ); + text.alpha = Math.min(1, t / 0.15) * fade; +} + +function drawFullCombo( + graphics: Graphics, + layer: Container, + t: number, + nowMs: number, + hue: number, + screenWide: boolean, + amount: number, + pool: ChildPool, +): void { + const cx = DESIGN_WIDTH / 2; + const cy = DESIGN_HEIGHT / 2 - 10; + const fadeOut = 1 - Math.max(0, (t - 0.8) / 0.2); + const bloom = screenWide ? Math.max(0, 1 - t * 5) : 0; + if (bloom > 0) { + graphics.rect(0, 0, DESIGN_WIDTH, DESIGN_HEIGHT).fill({ color: SYN_FLARE, alpha: 0.55 * bloom }); + } + // A sphere of sparks bursting out of the centre, turning slowly in 3D. + const burstT = Math.min(1, t / 0.85); + const spin = nowMs / 2200; + const sparkCount = Math.round(FULL_COMBO_BURST.length * amount); + for (const particle of FULL_COMBO_BURST.slice(0, sparkCount)) { + const life = burstT / particle.life; + if (life >= 1) continue; + const position = burstParticlePosition(particle, life, 360, 60); + const point = projectPoint(rotateX(rotateY(position, spin), 0.3), cx, cy, 380); + if (!point.visible) continue; + // Mostly ember cooling from white-hot, with a scatter of blue and magenta voxels. + const color = + particle.hueShift > 0.055 ? SYN_CYAN : particle.hueShift < -0.065 ? SYN_MAGENTA : emberColor(1 - life * 0.7); + const size = (0.5 + 1.1 * particle.size) * point.scale * (1 - life * 0.5); + graphics.circle(point.x, point.y, size * 3).fill({ color, alpha: 0.06 * (1 - life) * fadeOut }); + graphics + .rect(point.x - size, point.y - size, size * 2, size * 2) + .fill({ color: life < 0.2 ? SYN_WHITE : color, alpha: (1 - life) * fadeOut }); + } + const form = easeOutCubic(Math.min(1, t / 0.3)); + const color = emberColor(0.6 + 0.3 * Math.sin(nowMs / 260)); + const text = addHudText( + layer, + 'FULL COMBO', + cx, + cy, + { + size: 38, + fill: SYN_WHITE, + fontFamily: SYN_DISPLAY_FONT, + letterSpacing: 8 + 30 * (1 - form), + anchorX: 0.5, + anchorY: 0.5, + dropShadow: { color, alpha: 1, blur: 22, distance: 0 }, + maxWidth: DESIGN_WIDTH - 40, + }, + pool, + ); + text.alpha = form * fadeOut; +} diff --git a/packages/player-web/src/skins/synesthesia/playfield.ts b/packages/player-web/src/skins/synesthesia/playfield.ts new file mode 100644 index 00000000..ab10caff --- /dev/null +++ b/packages/player-web/src/skins/synesthesia/playfield.ts @@ -0,0 +1,376 @@ +import { Color, FillGradient, type Graphics } from 'pixi.js'; +import { + burstParticlePosition, + burstParticles, + emberColor, + hsvToHex, + projectPoint, + rotateY, + type BurstParticle, + type Vec3, +} from './space.ts'; +import { burstGrainCount } from './burst-budget.ts'; +import { drawReticle, sharedShapeBatch } from './draw.ts'; +import { SYN_GLOW_SIZE, synGlowTexture } from './style.ts'; +import { + audioDrive, + type BeMusicBomb, + type BeMusicLaneKind, + comboTier, + effectProfile, + resolveLaneRuns, +} from '@be-music/skin-sdk'; +import { + type PixiBombsContext, + type PixiLanesContext, + type PixiLongNoteContext, + type PixiNoteContext, + type ChildPool, + keyBeamGradient, +} from '../pixi-kit/index.ts'; + +/** Per-lane-class light: note body, glow hue (0..1), and the colour the lane beam / key cap light up in. */ +interface LaneLight { + body: number; + glow: number; + hue: number; +} + +// Readable first, in the skin's ember palette: white keys burn warm white-gold with an ember halo, black keys run +// electric blue, scratch hot magenta. +/** + * Lane lights. `body` colours the notes: they differ in lightness as well as hue (white, deep blue, hot magenta) so the + * three lane kinds stay apart at a glance, even on a lit beam; `glow` colours beams, halos, and bursts. + */ +const LIGHTS: Record = { + white: { body: 0xffffff, glow: 0xff7a1e, hue: 0.07 }, + black: { body: 0x2f6dff, glow: 0x3fb4ff, hue: 0.57 }, + scratch: { body: 0xff2b8a, glow: 0xff3d9e, hue: 0.92 }, +}; + +const NOTE_HEIGHT = 8; +export const SYNESTHESIA_BOMB_DURATION_MS = 620; + +export function renderSynesthesiaLanes({ + graphics, + lanes, + beatPhase, + nowMs, + combo, + effects, + audio, +}: PixiLanesContext): void { + const pulse = (1 - beatPhase) ** 2; + const drive = audioDrive(audio, effects); + // The judgement line burns brighter as the run builds, and cycles the spectrum once it is in the zone. + const tier = effectProfile(effects).enabled ? comboTier(combo ?? 0) : 0; + // ...and swells on every bass hit of the mix. + const heat = 1 + 0.3 * tier + 0.9 * drive.bass; + const lineColor = tier >= 3 ? emberColor(0.55 + 0.35 * Math.sin(nowMs / 700)) : 0xff8a2a; + let gridTop = Number.POSITIVE_INFINITY; + let gridBottom = 0; + let gridLeft = Number.POSITIVE_INFINITY; + for (const lane of lanes) { + const { x, w, top, bottom } = lane; + const light = LIGHTS[lane.kind]; + const laneHeight = Math.max(1, bottom - top); + gridTop = Math.min(gridTop, top); + gridBottom = Math.max(gridBottom, bottom); + gridLeft = Math.min(gridLeft, x); + + // Near-black bed, so the particle world behind the playfield reads only as a faint depth cue; white-key lanes take + // a warmer, lifted bed so they read apart from the black keys and the scratch. + graphics + .rect(x, top, w, laneHeight) + .fill(lane.kind === 'white' ? { color: 0x180c06, alpha: 0.88 } : { color: 0x050201, alpha: 0.8 }); + graphics.rect(x, top, 1, laneHeight).fill({ fill: resolveColumnGradient(light.glow), alpha: 0.5 }); + + // Key beam: a column of the lane's light, near solid at the judgement line and easing out up the lane, so a press + // reads clearly while the notes still on their way down keep their contrast. + if (lane.beam > 0) { + const beamHeight = Math.min(laneHeight, 250); + const beamAlpha = lane.beam * lane.beam * (3 - 2 * lane.beam); + graphics + .rect(x + 1, bottom - beamHeight, w - 2, beamHeight) + .fill({ fill: keyBeamGradient(light.glow), alpha: beamAlpha * 0.9 }); + graphics + .rect(x + 1, bottom - 26, w - 2, 26) + .fill({ fill: resolveColumnGradient(0xffffff), alpha: beamAlpha * 0.25 }); + } + + // Key caps: a glass sliver that lights up in the lane's colour on press. + const pressed = lane.beam > 0.6; + graphics + .rect(x + 1, bottom + 4, Math.max(2, w - 2), 10) + .fill({ color: pressed ? light.glow : 0x140804, alpha: 0.9 }); + graphics.rect(x + 1, bottom + 13, Math.max(2, w - 2), 1).fill({ color: light.glow, alpha: pressed ? 1 : 0.45 }); + } + if (lanes.length === 0) return; + // Close each play side's grid on its right edge (each lane only draws its left hairline). + for (const run of resolveLaneRuns(lanes)) { + graphics.rect(run.right - 1, gridTop, 1, Math.max(1, gridBottom - gridTop)).fill({ color: 0xff7a1e, alpha: 0.25 }); + } + + // Judgement line: a white filament wrapped in stacked ember bloom that swells on each beat — one per play side, so + // it never crosses the gap between the double-play banks. + const y = gridBottom; + for (const run of resolveLaneRuns(lanes)) { + const lineW = run.right - run.left; + graphics.rect(run.left, y - 14, lineW, 22).fill({ color: lineColor, alpha: (0.05 + 0.08 * pulse) * heat }); + graphics + .rect(run.left, y - 6, lineW, 8) + .fill({ color: lineColor, alpha: Math.min(0.8, (0.16 + 0.14 * pulse) * heat) }); + graphics.rect(run.left, y - 3, lineW, 3).fill({ color: 0xffd08a, alpha: 0.85 }); + graphics.rect(run.left, y - 2, lineW, 1).fill(0xffffff); + } +} + +export function renderSynesthesiaNote({ graphics, kind, x, w, y }: PixiNoteContext): void { + drawNote(graphics, x, y, Math.max(4, w), LIGHTS[kind]); +} + +export function renderSynesthesiaLongNote({ graphics, kind, x, w, top, bottom }: PixiLongNoteContext): void { + const light = LIGHTS[kind]; + const bodyX = x; + const bodyW = Math.max(4, w); + const bodyTop = top - NOTE_HEIGHT; + const bodyH = Math.max(1, bottom - top); + // A beam of light between head and tail: a translucent core with a bright centre filament. + graphics.rect(bodyX + 1, bodyTop, bodyW - 2, bodyH).fill({ color: light.body, alpha: 0.22 }); + graphics.rect(bodyX + bodyW / 2 - 1, bodyTop, 2, bodyH).fill({ color: 0xffffff, alpha: 0.55 }); + drawNote(graphics, bodyX, bottom, bodyW, light); + drawNote(graphics, bodyX, top, bodyW, light); +} + +/** A flat bar with a white filament on top and a dark rim — a plain rectangle that reads at a glance. */ +function drawNote(graphics: Graphics, x: number, y: number, w: number, light: LaneLight): void { + // A dark rim lifts the bar off the lit lane and the beam behind it. + graphics.rect(x - 1, y - NOTE_HEIGHT - 1, w + 2, NOTE_HEIGHT + 2).fill({ color: 0x05010a, alpha: 0.9 }); + graphics.rect(x, y - NOTE_HEIGHT, w, NOTE_HEIGHT).fill(light.body); + graphics.rect(x, y - NOTE_HEIGHT, w, 2).fill({ color: 0xffffff, alpha: 0.6 }); +} + +/** Sparks per hit at combo tier 0; each tier adds {@link BURST_PARTICLES_PER_TIER}. */ +const BURST_PARTICLES = 170; +const BURST_PARTICLES_PER_TIER = 24; +const BURST_PARTICLES_MAX = BURST_PARTICLES + BURST_PARTICLES_PER_TIER * 4; +const BURST_FOCAL = 260; +/** Grain size (0.4..1) from which a spark leaves a hairline trail / carries a glow sprite; the rest is fine powder. */ +const POWDER_TRAIL_SIZE = 0.85; +const POWDER_GLINT_SIZE = 0.94; +const PARTICLE_CACHE = new Map(); + +function cachedBurst(seed: number): BurstParticle[] { + let particles = PARTICLE_CACHE.get(seed); + if (!particles) { + particles = burstParticles(seed, BURST_PARTICLES_MAX); + PARTICLE_CACHE.set(seed, particles); + // Live bombs never exceed a few dozen; keep the cache bounded. + if (PARTICLE_CACHE.size > 64) { + const oldest = PARTICLE_CACHE.keys().next().value; + if (oldest !== undefined) PARTICLE_CACHE.delete(oldest); + } + } + return particles; +} + +/** + * The Synesthesia hit: a 3D particle burst in perspective. Layered back to front — + * + * 1. a light pillar rising up the lane, + * 2. motion trails for every spark, + * 3. depth-sorted glow sparks that launch white-hot and cool into the lane's hue, + * 4. a core flash and an anamorphic streak across the line, + * 5. a lock-on reticle that snaps shut on the hit and fades. + * + * The smaller sparks fly as square voxels rather than round glows, like shattering wireframes. + * Everything is additive, so overlapping hits bloom into each other like light instead of stacking opaque shapes. + */ +export function renderSynesthesiaBombs({ pool, bombs, combo, effects }: PixiBombsContext): void { + const profile = effectProfile(effects); + if (!profile.enabled) { + // Effects off: a small, plain core flash per hit. + renderPlainFlashes(pool, bombs); + return; + } + const tier = Math.min(comboTier(combo ?? 0), profile.screenWide ? 4 : 2); + // Keep colour the hero: when many hits overlap, dim the white parts (flash, pillar, streak) so additive stacking + // doesn't burn the line to white, while sparks keep most of their colour. + const crowd = 1 / Math.sqrt(Math.max(1, bombs.length / 2)); + for (const bomb of bombs) { + const grains = burstGrainCount(BURST_PARTICLES, BURST_PARTICLES_PER_TIER, tier, profile.amount, bombs.length); + renderBomb(pool, bomb, tier, crowd, profile.amount, grains); + } +} + +function renderPlainFlashes(pool: ChildPool, bombs: readonly BeMusicBomb[]): void { + const glow = synGlowTexture(); + for (const bomb of bombs) { + const t = Math.min(1, bomb.elapsedMs / 180); + if (t >= 1) continue; + const sprite = pool.acquireSprite(); + sprite.texture = glow; + sprite.anchor.set(0.5); + sprite.blendMode = 'add'; + const size = Math.max(10, bomb.w) * (1.2 + t); + sprite.width = size; + sprite.height = size; + sprite.position.set(bomb.x + bomb.w / 2, bomb.y - 3); + sprite.tint = LIGHTS[bomb.kind].glow; + sprite.alpha = 0.7 * (1 - t); + } +} + +function renderBomb( + pool: ChildPool, + bomb: BeMusicBomb, + tier: number, + crowd: number, + amount: number, + grains: number, +): void { + const t = Math.max(0, Math.min(1, bomb.elapsedMs / SYNESTHESIA_BOMB_DURATION_MS)); + if (t >= 1) return; + const light = LIGHTS[bomb.kind]; + const cx = bomb.x + bomb.w / 2; + const cy = bomb.y - 3; + const unit = Math.max(10, bomb.w); + const fade = 1 - t; + // Slow spin around the vertical axis gives the burst parallax as it expands. + const spin = (bomb.seed % 2 === 0 ? 1 : -1) * t * 0.9; + const project = (point: Vec3) => projectPoint(rotateY(point, spin), cx, cy, BURST_FOCAL); + + const effects = pool.acquireGraphics(); + effects.label = `synesthesia-bomb[ch=${bomb.channel}]`; + effects.blendMode = 'add'; + + // 1. Light pillar. + const pillarH = unit * 5.5 * (0.6 + 0.4 * fade); + effects + .rect(bomb.x + 1, cy - pillarH, bomb.w - 2, pillarH) + .fill({ fill: resolveColumnGradient(light.glow), alpha: 0.55 * fade * fade * crowd }); + + // 2 + 3. Sparks: hairline trails and powder grains go through the shared batch (one fill per colour bucket instead of + // one per grain); the few coarse grains get a glow sprite. Everything here is additive, so draw order is free and no + // depth sort (or per-frame spark list) is needed. + const particles = cachedBurst(bomb.seed); + const count = Math.min(particles.length, grains); + const radius = unit * 4.6 * (1 + 0.1 * tier); + const gravity = unit * 3.2; + // Fountain shaping: narrow the lateral spread so neighbouring lanes don't merge into one white band, and add an + // upward launch so sparks visibly leap off the line before gravity bends them back. + const fountain = (particle: BurstParticle, life: number): Vec3 => { + const position = burstParticlePosition(particle, life, radius, gravity); + return { + x: position.x * 0.55, + y: position.y * 1.1 - unit * 2.8 * particle.speed * (1 - (1 - life) ** 2), + z: position.z * 0.8, + }; + }; + const batch = sharedShapeBatch; + const glow = synGlowTexture(); + for (let index = 0; index < count; index += 1) { + const particle = particles[index]!; + const life = t / particle.life; + if (life >= 1) continue; + const head = project(fountain(particle, life)); + if (!head.visible) continue; + // Only the coarser grains leave a short, hairline trail; the powder itself just drifts. + const trailAlpha = particle.size >= POWDER_TRAIL_SIZE ? (1 - life) * 0.45 : 0; + if (trailAlpha > 0.02) { + const tail = project(fountain(particle, Math.max(0, life - 0.07))); + batch.line( + effects, + hsvToHex(light.hue + particle.hueShift, Math.min(0.9, 0.35 + life * 2), 1), + trailAlpha, + Math.max(0.5, 1 * head.scale * particle.size), + tail.x, + tail.y, + head.x, + head.y, + ); + } + const tint = hsvToHex(light.hue + particle.hueShift, Math.min(0.9, 0.45 + life * 1.8), 1); + const sparkAlpha = (1 - life) ** 0.8 * (0.65 + 0.35 * crowd); + // Powder: most grains are tiny squares; only the coarsest few carry a soft glint. + if (particle.size < POWDER_GLINT_SIZE) { + const side = Math.max(0.7, (0.6 + 1 * particle.size) * head.scale * (1 - life * 0.5)); + batch.rect(effects, tint, sparkAlpha, head.x - side / 2, head.y - side / 2, side, side); + continue; + } + const sprite = pool.acquireSprite(); + sprite.texture = glow; + sprite.anchor.set(0.5); + sprite.blendMode = 'add'; + const size = (1.6 + 2.4 * particle.size) * head.scale * Math.sqrt(1 - life); + sprite.width = size; + sprite.height = size; + sprite.position.set(head.x, head.y); + // White-hot at launch, cooling into the lane's hue. + sprite.tint = tint; + sprite.alpha = sparkAlpha; + } + batch.flush(); + + // 4. Core flash + anamorphic streak. + const flash = Math.max(0, 1 - t * 5); + if (flash > 0) { + const core = pool.acquireSprite(); + core.texture = glow; + core.anchor.set(0.5); + core.blendMode = 'add'; + const coreSize = (unit * 1.3 + unit * 2 * t) * (SYN_GLOW_SIZE / 64); + core.width = coreSize; + core.height = coreSize; + core.position.set(cx, cy); + core.tint = hsvToHex(light.hue, t * 2, 1); + core.alpha = flash * 0.7 * crowd; + } + // 5. Lock-on: corner brackets snap in from wide to tight around the hit, then hold and fade. + if (t < 0.55) { + const snap = Math.min(1, t / 0.16); + const size = unit * (1.15 + 1.4 * (1 - snap) ** 3); + const lockAlpha = (t < 0.16 ? 0.95 : 0.95 * (1 - (t - 0.16) / 0.39)) * (0.6 + 0.4 * crowd); + drawReticle(effects, cx - size / 2, cy - size / 2, size, size, 0xfff2dc, lockAlpha, { + arm: size * 0.3, + width: 1.25, + cross: t < 0.3, + }); + } + const streakAlpha = amount < 1 ? 0 : fade ** 2.5; + if (streakAlpha > 0.02) { + const streak = pool.acquireSprite(); + streak.texture = glow; + streak.anchor.set(0.5); + streak.blendMode = 'add'; + streak.width = unit * (6 + 5 * t); + streak.height = Math.max(2, unit * 0.32 * fade); + streak.position.set(cx, cy); + streak.tint = hsvToHex(light.hue, 0.35, 1); + streak.alpha = streakAlpha * 0.6 * crowd; + } +} + +const COLUMN_GRADIENTS = new Map(); + +/** Vertical light column for `color`: transparent at the top, brightening toward the base. Local texture space. */ +function resolveColumnGradient(color: number): FillGradient { + let gradient = COLUMN_GRADIENTS.get(color); + if (!gradient) { + const rgb = new Color(color); + gradient = new FillGradient({ + type: 'linear', + start: { x: 0, y: 0 }, + end: { x: 0, y: 1 }, + textureSpace: 'local', + colorStops: [ + { offset: 0, color: rgb.setAlpha(0).toRgbaString() }, + { offset: 0.5, color: rgb.setAlpha(0.1).toRgbaString() }, + { offset: 0.85, color: rgb.setAlpha(0.32).toRgbaString() }, + { offset: 1, color: rgb.setAlpha(0.6).toRgbaString() }, + ], + }); + COLUMN_GRADIENTS.set(color, gradient); + } + return gradient; +} diff --git a/packages/player-web/src/skins/synesthesia/result.ts b/packages/player-web/src/skins/synesthesia/result.ts new file mode 100644 index 00000000..03242249 --- /dev/null +++ b/packages/player-web/src/skins/synesthesia/result.ts @@ -0,0 +1,428 @@ +import { Container, Graphics } from 'pixi.js'; +import { drawFrame, drawReticle } from './draw.ts'; +import { + burstParticlePosition, + burstParticles, + emberColor, + hsvToHex, + projectPoint, + rotateY, + starfieldPoint, +} from './space.ts'; +import { + SYN_AMBER, + SYN_CYAN, + SYN_DEEP, + SYN_DIM, + SYN_DISPLAY_FONT, + SYN_EMBER, + SYN_FLARE, + SYN_MAGENTA, + SYN_MIST, + SYN_RED, + SYN_TEXT_FONT, + SYN_VOID, + SYN_WHITE, + sceneHue, +} from './style.ts'; +import { + easeOutBack, + easeOutCubic, + resolveResultLamp, + resolveResultTrackRows, + rollUpValue, + stageProgress, +} from '@be-music/skin-sdk'; +import { addSkinText, type PixiResultFrame, type PixiResultSkin, type SkinTextOptions } from '../pixi-kit/index.ts'; + +/** How fast the entrance timeline plays: the choreography below is authored at 1x and runs 1.3x, so the card settles + * sooner without changing its order or easing. */ +const ENTRANCE_PACE = 1.3; +const ROLL_DELAY_MS = 700; +const ROLL_MS = 1100; +const RANK_DELAY_MS = 1600; + +/** + * Result grid (design px): a 16 px page margin and 12 px gutters. Top row: the DJ LEVEL panel and two columns of + * metric panels (44 px tall, 10 px apart); bottom row: the judgement rows and the two graphs, which share one top and + * bottom line. + */ +const MARGIN = 16; +const TOP = 64; +const TOP_ROW_H = 152; +const METRIC_X = [204, 420] as const; +const METRIC_Y = [TOP, TOP + 54, TOP + 108] as const; +const METRIC_W = 204; +const METRIC_H = 44; +const BOTTOM_TOP = 232; +const BOTTOM_H = 200; +const GRAPHS_X = MARGIN + 292 + 12; +/** Right edge shared by the metric columns and the graphs; the 16:9 track column starts one gutter after it. */ +const GRID_RIGHT = METRIC_X[1] + METRIC_W; +const TRACK_X = GRID_RIGHT + 12; +const ROWS_TOP = BOTTOM_TOP + 34; +const ROWS_BOTTOM = ROWS_TOP + 4 * 32 + 12; +const RANK_BURST = burstParticles(29, 260); + +export const synesthesiaResultSkin: PixiResultSkin = { render: (frame) => renderSynesthesiaResult(frame) }; + +/** + * Synesthesia result, in the light of a cosmic particle world: a black void with ember dust and a floor of light + * points; the verdict condenses out of wide-tracked light, hairline frames fade up in sequence while counters roll, + * graphs draw as glowing filaments, and the rank letter ignites in a burst of sparks the moment it lands — a lock-on + * reticle snapping shut on it. + */ +export function renderSynesthesiaResult(frame: PixiResultFrame): void { + const { result, designWidth, designHeight, nowMs, rankLabel, layer } = frame; + // Effects off: skip the entrance and render the settled result. + const elapsed = frame.effects === 'off' ? Number.POSITIVE_INFINITY : frame.elapsedMs * ENTRANCE_PACE; + const seconds = nowMs / 1000; + const hue = sceneHue(seconds); + const accent = hsvToHex(hue, 0.6, 1); + const cleared = result.cleared; + const verdictColor = cleared ? SYN_AMBER : SYN_RED; + const exMax = Math.max(0, result.score.total * 2); + const roll = stageProgress(elapsed, ROLL_DELAY_MS, ROLL_MS); + + const group = (label: string): { root: Container; g: Graphics } => { + const root = new Container(); + root.label = `synesthesia-result/${label}`; + const g = new Graphics(); + root.addChild(g); + layer.addChild(root); + return { root, g }; + }; + const text = (target: Container, value: string, x: number, y: number, options: SkinTextOptions) => + addSkinText(target, value, x, y, options); + /** Fade + rise a group in. */ + const rise = (root: Container, delayMs: number, distance = 18, durationMs = 520): void => { + const eased = easeOutCubic(stageProgress(elapsed, delayMs, durationMs)); + root.y = distance * (1 - eased); + root.alpha = eased; + }; + const display = (size: number, fill: number, extra: SkinTextOptions = {}): SkinTextOptions => ({ + size, + fill, + fontFamily: SYN_DISPLAY_FONT, + ...extra, + }); + const label = (value: string, x: number, y: number, target: Container, fill: number = SYN_DIM) => + text(target, value, x, y, display(9, fill, { letterSpacing: 2 })); + + // Space: warm black, dust pouring out of the vanishing point, and a floor of points. + const space = group('space'); + const bands: ReadonlyArray = [ + [SYN_DEEP, 0.3], + [0x070201, 0.55], + [0x040100, 0.8], + [SYN_VOID, 1], + ]; + let top = 0; + for (const [color, until] of bands) { + const bottom = designHeight * until; + space.g.rect(0, top, designWidth, bottom - top).fill(color); + top = bottom; + } + const light = new Graphics(); + light.blendMode = 'add'; + space.root.addChild(light); + const horizon = designHeight * 0.74; + const vanishX = designWidth / 2; + const vanishY = designHeight * 0.45; + for (let index = 0; index < 260; index += 1) { + const point = starfieldPoint(index, seconds, { spread: 560, near: 20, far: 900, speed: 90 }); + const projected = projectPoint(point, vanishX, vanishY, 180); + if (!projected.visible) continue; + const nearness = Math.min(1, projected.scale); + const color = index % 9 === 0 ? SYN_CYAN : index % 13 === 0 ? SYN_MAGENTA : emberColor(0.35 + 0.65 * nearness); + const size = 0.5 + 1.3 * nearness; + light + .rect(projected.x - size / 2, projected.y - size / 2, size, size) + .fill({ color, alpha: 0.25 + 0.6 * nearness }); + } + const floor = (x: number, z: number) => projectPoint({ x, y: 130, z }, vanishX, horizon, 200); + const offset = (seconds * 110) % 100; + for (let z = 100 - offset; z < 2600; z += 100) { + const nearness = 1 - z / 2600; + const size = 0.6 + 1.4 * nearness * nearness; + const color = emberColor(0.3 + 0.65 * nearness); + const alpha = 0.12 + 0.7 * nearness * nearness; + for (let x = -1400; x <= 1400; x += 50) { + const point = floor(x, z); + if (point.x < -4 || point.x > designWidth + 4) continue; + light.rect(point.x - size / 2, point.y - size / 2, size, size); + } + // One fill per row: its points share a colour and alpha and never overlap. + light.fill({ color, alpha }); + } + + // Verdict: tracking condenses from wide to settled while it fades in. + const verdict = group('verdict'); + const verdictT = easeOutCubic(stageProgress(elapsed, 100, 900)); + verdict.g.rect(0, 44, designWidth, 1).fill({ color: verdictColor, alpha: 0.5 * verdictT }); + text(verdict.root, cleared ? 'STAGE CLEAR' : 'FAILED', MARGIN, 14, { + ...display(15, SYN_WHITE), + letterSpacing: 6 + 22 * (1 - verdictT), + alpha: verdictT, + dropShadow: { color: verdictColor, distance: 0, blur: 12, alpha: 1 }, + }); + + // Rank: sparks burst outward as the letter ignites. + const rank = group('rank'); + const rankCx = MARGIN + 88; + // The rank sits between the DJ LEVEL label and the rate, clear of both. + const rankCy = TOP + 76; + const topRank = rankLabel === 'AAA' || rankLabel === 'AA'; + const rankColor = topRank ? SYN_AMBER : accent; + glass(rank.g, MARGIN, TOP, 176, TOP_ROW_H, rankColor); + label('DJ LEVEL', MARGIN + 14, TOP + 12, rank.root, rankColor); + const burstT = stageProgress(elapsed, RANK_DELAY_MS, 1100); + if (burstT > 0 && burstT < 1) { + for (const particle of RANK_BURST) { + const life = burstT / particle.life; + if (life >= 1) continue; + const position = burstParticlePosition(particle, life, 120, 30); + const point = projectPoint(rotateY(position, seconds * 0.4), rankCx, rankCy, 240); + if (!point.visible) continue; + const color = particle.hueShift > 0.06 ? SYN_CYAN : emberColor(1 - life * 0.7); + rank.g + .circle(point.x, point.y, (1 + 2.5 * particle.size) * point.scale) + .fill({ color, alpha: 0.12 * (1 - life) }); + rank.g + .circle(point.x, point.y, (0.5 + 0.8 * particle.size) * point.scale) + .fill({ color: SYN_WHITE, alpha: 1 - life }); + } + } + const stamp = stageProgress(elapsed, RANK_DELAY_MS, 420); + if (stamp > 0) { + // Lock-on: brackets snap shut on the letter as it lands. + const snap = easeOutCubic(Math.min(1, stamp * 1.6)); + const lock = 46 + 60 * (1 - snap); + drawReticle(rank.g, rankCx - lock, rankCy - lock, lock * 2, lock * 2, SYN_FLARE, 0.4 + 0.5 * snap, { + arm: 12, + width: 1.75, + cross: stamp < 1, + }); + } + const letter = new Container(); + rank.root.addChild(letter); + const heartbeat = (1 - ((seconds * 1.4) % 1)) ** 4; + text(letter, rankLabel, rankCx, rankCy, { + ...display(rankLabel.length >= 3 ? 28 : 40, SYN_WHITE), + anchorX: 0.5, + anchorY: 0.5, + maxWidth: 130, + alpha: Math.min(1, stamp * 2), + dropShadow: { color: rankColor, distance: 0, blur: 14 + 8 * heartbeat, alpha: 1 }, + }); + letter.pivot.set(rankCx, rankCy); + letter.position.set(rankCx, rankCy); + letter.scale.set(stamp >= 1 ? 1 + 0.03 * heartbeat : 1.8 - 0.8 * easeOutBack(stamp)); + const rate = frame.ratePercent * easeOutCubic(roll); + text(rank.root, `${rate.toFixed(2)}%`, rankCx, TOP + TOP_ROW_H - 14, { + ...display(10, SYN_MIST), + anchorX: 0.5, + anchorY: 0.5, + letterSpacing: 1, + }); + rise(rank.root, 250); + + // Metric panels. + const metrics: ReadonlyArray = [ + [METRIC_X[0], METRIC_Y[0], 'SCORE', String(rollUpValue(result.score.score, roll)), SYN_WHITE], + [METRIC_X[0], METRIC_Y[1], 'EX SCORE', `${rollUpValue(result.score.exScore, roll)} / ${exMax}`, SYN_WHITE], + [METRIC_X[0], METRIC_Y[2], 'MAX COMBO', String(rollUpValue(result.maxCombo, roll)), SYN_AMBER], + [ + METRIC_X[1], + METRIC_Y[0], + 'GAUGE', + `${rollUpValue(Math.round(result.gauge), roll)}%`, + cleared ? SYN_AMBER : SYN_RED, + ], + [METRIC_X[1], METRIC_Y[1], 'PLAY TIME', `${(result.playSeconds * easeOutCubic(roll)).toFixed(1)}s`, SYN_WHITE], + [METRIC_X[1], METRIC_Y[2], 'NOTES', String(rollUpValue(result.score.total, roll)), SYN_WHITE], + ]; + metrics.forEach(([x, y, name, value, fill], index) => { + const panel = group(`metric-${index}`); + glass(panel.g, x, y, METRIC_W, METRIC_H, accent); + label(name, x + 14, y + 10, panel.root); + text(panel.root, value, x + METRIC_W - 14, y + 29, { + ...display(14, fill), + anchorX: 1, + anchorY: 0.5, + maxWidth: 120, + dropShadow: { color: fill, distance: 0, blur: 6, alpha: 0.7 }, + }); + rise(panel.root, 320 + index * 80); + }); + + // Judgement rows. + const judges = group('judgement'); + glass(judges.g, MARGIN, BOTTOM_TOP, 292, BOTTOM_H, SYN_EMBER); + label('JUDGEMENT', MARGIN + 14, BOTTOM_TOP + 12, judges.root, SYN_EMBER); + const rows: ReadonlyArray = [ + ['PGREAT', result.score.perfect, SYN_FLARE], + ['GREAT', result.score.great, SYN_AMBER], + ['GOOD', result.score.good, SYN_CYAN], + ['BAD', result.score.bad, SYN_MAGENTA], + ['POOR', result.score.poor, SYN_RED], + ]; + const judgeTotal = Math.max(1, result.score.total); + rows.forEach(([name, count, color], index) => { + const y = ROWS_TOP + index * 32; + const grow = easeOutCubic(stageProgress(elapsed, ROLL_DELAY_MS + index * 70, ROLL_MS)); + judges.g.circle(MARGIN + 18, y + 6, 2.5).fill({ color, alpha: 1 }); + judges.g.circle(MARGIN + 18, y + 6, 6).fill({ color, alpha: 0.2 }); + label(name, MARGIN + 30, y + 2, judges.root, color); + judges.g.rect(MARGIN + 98, y + 6, 140, 1).fill({ color: SYN_DIM, alpha: 0.35 }); + const barW = (140 * Math.min(count, judgeTotal) * grow) / judgeTotal; + if (barW > 0) { + judges.g.rect(MARGIN + 98, y + 4, barW, 5).fill({ color, alpha: 0.18 }); + judges.g.rect(MARGIN + 98, y + 6, barW, 1.5).fill({ color, alpha: 0.95 }); + } + text(judges.root, String(rollUpValue(count, roll)), MARGIN + 292 - 14, y + 6, { + ...display(11, SYN_WHITE), + anchorX: 1, + anchorY: 0.5, + }); + }); + rise(judges.root, 520); + + // Graphs as glowing filaments drawing in. + const graphs = group('graphs'); + // The two filaments share the judgement rows' top and bottom lines. + const graphX = GRAPHS_X + 14; + const graphW = GRID_RIGHT - 14 - graphX; + const graphH = (ROWS_BOTTOM - ROWS_TOP - 24) / 2; + glass(graphs.g, GRAPHS_X, BOTTOM_TOP, GRID_RIGHT - GRAPHS_X, BOTTOM_H, accent); + label('GROOVE GAUGE', graphX, BOTTOM_TOP + 12, graphs.root); + label('EX SCORE', graphX, ROWS_TOP + graphH + 12, graphs.root); + const draw = easeOutCubic(stageProgress(elapsed, ROLL_DELAY_MS + 150, ROLL_MS + 300)); + filament( + graphs.g, + graphX, + ROWS_TOP, + graphW, + graphH, + result.gaugeHistory.map((s) => ({ x: s.progress, y: s.value / 100 })), + cleared ? SYN_AMBER : SYN_RED, + draw, + ); + filament( + graphs.g, + graphX, + ROWS_BOTTOM - graphH, + graphW, + graphH, + result.scoreHistory.map((s) => ({ x: s.progress, y: exMax > 0 ? s.exScore / exMax : 0 })), + SYN_CYAN, + draw, + ); + rise(graphs.root, 600); + + // Track column: the chart's card, its facts, and the clear lamp glowing at the foot. + const track = group('track'); + const trackW = designWidth - MARGIN - TRACK_X; + const trackH = BOTTOM_TOP + BOTTOM_H - TOP; + glass(track.g, TRACK_X, TOP, trackW, trackH, accent); + const trackTextX = TRACK_X + 14; + const trackTextW = trackW - 28; + label('TRACK', trackTextX, TOP + 12, track.root, SYN_EMBER); + text(track.root, result.song.title || 'Untitled chart', trackTextX, TOP + 30, { + size: 15, + weight: '500', + fill: SYN_WHITE, + fontFamily: SYN_TEXT_FONT, + maxWidth: trackTextW, + dropShadow: { color: accent, alpha: 0.7, blur: 6, distance: 0 }, + }); + text(track.root, result.song.artist ?? '', trackTextX, TOP + 52, { + size: 10, + weight: '300', + fill: SYN_MIST, + fontFamily: SYN_TEXT_FONT, + maxWidth: trackTextW, + }); + if (result.song.genre) { + label(result.song.genre.toUpperCase(), trackTextX, TOP + 68, track.root); + } + track.g.rect(trackTextX, TOP + 88, trackTextW, 1).fill({ color: accent, alpha: 0.3 }); + resolveResultTrackRows(result).forEach((row, index) => { + const rowY = TOP + 100 + index * 34; + label(row.label, trackTextX, rowY + 4, track.root); + text(track.root, row.value, trackTextX + trackTextW, rowY + 9, { + ...display(15, SYN_WHITE), + anchorX: 1, + anchorY: 0.5, + maxWidth: trackTextW - 90, + }); + track.g.rect(trackTextX, rowY + 24, trackTextW, 1).fill({ color: SYN_DIM, alpha: 0.3 }); + }); + const lamp = resolveResultLamp(result); + const lampColor = + lamp === 'PERFECT' ? SYN_WHITE : lamp === 'FULL COMBO' ? SYN_CYAN : lamp === 'CLEAR' ? SYN_AMBER : SYN_RED; + const lampY = TOP + trackH - 50; + drawFrame(track.g, trackTextX, lampY, trackTextW, 34, lampColor, { fill: 0.6, arm: 8 }); + text(track.root, lamp, trackTextX + trackTextW / 2, lampY + 17, { + ...display(14, SYN_WHITE), + anchorX: 0.5, + anchorY: 0.5, + letterSpacing: 4, + maxWidth: trackTextW - 16, + dropShadow: { color: lampColor, distance: 0, blur: 12, alpha: 1 }, + }); + rise(track.root, 680); + + // Total score. + const total = group('total'); + total.g.rect(0, designHeight - 44, designWidth, 1).fill({ color: accent, alpha: 0.4 }); + label('TOTAL SCORE', MARGIN, designHeight - 26, total.root, accent); + text(total.root, String(rollUpValue(result.score.score, roll)), 150, designHeight - 22, { + ...display(16, SYN_WHITE), + anchorY: 0.5, + letterSpacing: 2, + dropShadow: { color: accent, distance: 0, blur: 10, alpha: 0.9 }, + }); + rise(total.root, 440, 10); +} + +function glass(graphics: Graphics, x: number, y: number, w: number, h: number, rim: number): void { + drawFrame(graphics, x, y, w, h, rim, { fill: 0.6, arm: 10 }); +} + +/** Polyline drawn up to `progress` of its horizontal span, stroked twice (bloom + filament) with a glowing head. */ +function filament( + graphics: Graphics, + x: number, + y: number, + w: number, + h: number, + points: ReadonlyArray<{ x: number; y: number }>, + color: number, + progress: number, +): void { + graphics.rect(x, y + h, w, 1).fill({ color: SYN_DIM, alpha: 0.3 }); + if (points.length === 0 || progress <= 0) return; + const clamp = (value: number) => (Number.isFinite(value) ? Math.max(0, Math.min(1, value)) : 0); + const path: number[] = []; + let previous = points[0]!; + path.push(x + clamp(previous.x) * w, y + (1 - clamp(previous.y)) * h); + for (let index = 1; index < points.length; index += 1) { + const point = points[index]!; + if (clamp(point.x) > progress) { + const span = clamp(point.x) - clamp(previous.x); + const t = span > 0 ? (progress - clamp(previous.x)) / span : 0; + path.push(x + progress * w, y + (1 - (clamp(previous.y) + (clamp(point.y) - clamp(previous.y)) * t)) * h); + break; + } + path.push(x + clamp(point.x) * w, y + (1 - clamp(point.y)) * h); + previous = point; + } + if (path.length < 4) path.push(path[0]! + 0.5, path[1]!); + graphics.poly(path, false).stroke({ color, width: 5, alpha: 0.12 }); + graphics.poly(path, false).stroke({ color, width: 1.5, alpha: 0.95 }); + const headX = path[path.length - 2]!; + const headY = path[path.length - 1]!; + graphics.circle(headX, headY, 6).fill({ color, alpha: 0.2 }); + graphics.circle(headX, headY, 2).fill({ color: SYN_WHITE, alpha: 1 }); +} diff --git a/packages/player-web/src/skins/synesthesia/select.ts b/packages/player-web/src/skins/synesthesia/select.ts new file mode 100644 index 00000000..4040e1c9 --- /dev/null +++ b/packages/player-web/src/skins/synesthesia/select.ts @@ -0,0 +1,816 @@ +import { Container, Graphics, Sprite } from 'pixi.js'; +import { createFlock, stepFlock } from './boids.ts'; +import { drawMagnetoOrb, drawPointCloud, drawReticle, drawSchool, sharedShapeBatch } from './draw.ts'; +import { + cameraBasis, + emberColor, + hsvToHex, + mixCamera, + particleRiverPoint, + fibonacciSphere, + orbitParticles, + pointCloudPyramid, + projectPoint, + projectViewInto, + REST_CAMERA, + roamingCamera, + scratchProjected, + starfieldInto, + viewPoint, + wanderPoint, +} from './space.ts'; +import { + SYN_AMBER, + SYN_CYAN, + SYN_DEEP, + SYN_DIM, + SYN_DISPLAY_FONT, + SYN_EMBER, + SYN_FLARE, + SYN_GLASS, + SYN_MAGENTA, + SYN_MIST, + SYN_TEXT_FONT, + SYN_VOID, + SYN_WHITE, + sceneHue, + synGlowTexture, +} from './style.ts'; +import { + easeOutCubic, + audioDrive, + type BeMusicAudioFrame, + type BeMusicSelectLayout, + type BrowserBrowseEntry, + type BrowserSongEntry, + formatPlayVariantLabel, + hash01, + resolveSongRowFacts, + stageProgress, +} from '@be-music/skin-sdk'; +import { + addHitArea, + addSkinText, + type PixiSelectFrame, + type PixiSelectRenderer, + type PixiSelectSkin, + ChildPool, + type SkinTextOptions, +} from '../pixi-kit/index.ts'; + +const LAYOUT: BeMusicSelectLayout = { listX: 322, listTop: 56, listBottomInset: 28, rowHeight: 28 }; +/** Fixed widths of a song row's fact columns (right to left: tempo, length, note count), so they align down the list. */ +const ROW_BPM_W = 76; +const ROW_LENGTH_W = 30; +const ROW_NOTES_W = 84; +const SLIDE_MS = 320; +const OUTRO_MS = 700; +const INTRO_STAGGER_MS = 45; +/** Stars drifting past while browsing, and the denser field the launch warp dashes through. */ +const STAR_COUNT = 320; +/** The star field's volume: particles stream from `far` to `near` along z. */ +const STARFIELD = { spread: 560, near: 10, far: 1000, speed: 1 } as const; +/** How much faster the field streams at the peak of the launch warp, and how long a trail each particle draws. */ +const WARP_SPEEDUP = 7; +const STAR_TRAIL_SECONDS = 0.07; +const STAR_TRAIL_MAX = 220; +const WARP_STAR_COUNT = 900; +const RIVER_PARTICLES = 340; +const ORB_SHELL = fibonacciSphere(900, 23); +const ORB = orbitParticles(23, 30); +const SMALL_SHELL = fibonacciSphere(400, 31); +const SMALL_ORB = orbitParticles(31, 12); +/** How far the select camera roams around the pyramid field. */ +const CAMERA_RANGE = { x: 240, y: 110, yaw: 0.38, pitch: 0.16 } as const; +const CAMERA_CYCLE_S = 6; +const SCHOOL_SIZE = 80; +/** Three schools in the floor frame (floor at y 150), each with its own box, light, and seed. */ +const SCHOOL_SPECS = [ + { seed: 29, palette: 'ember', bounds: { minX: -700, maxX: 500, minY: -240, maxY: 90, minZ: 140, maxZ: 900 } }, + { seed: 57, palette: 'blue', bounds: { minX: -300, maxX: 900, minY: -300, maxY: 40, minZ: 300, maxZ: 1300 } }, + { seed: 91, palette: 'magenta', bounds: { minX: -900, maxX: 900, minY: -280, maxY: 60, minZ: 800, maxZ: 1900 } }, +] as const; +/** Depth the world shots pivot around — the pyramid field. */ +const WORLD_ORBIT = 900; +const PYRAMIDS = [pointCloudPyramid(12, 900), pointCloudPyramid(4, 560), pointCloudPyramid(9, 520)]; + +export const synesthesiaSelectSkin: PixiSelectSkin = { + outroMs: OUTRO_MS, + layout: LAYOUT, + createRenderer: () => new SynesthesiaSelectRenderer(), +}; + +function labelStyle(fill: number = SYN_DIM): SkinTextOptions { + return { size: 9, fill, fontFamily: SYN_DISPLAY_FONT, letterSpacing: 2 }; +} + +function framePanel(graphics: Graphics, x: number, y: number, w: number, h: number, rim: number): void { + graphics.rect(x, y, w, h).fill({ color: SYN_GLASS, alpha: 0.5 }); + graphics.rect(x, y, w, h).stroke({ color: rim, width: 1, alpha: 0.28 }); + drawReticle(graphics, x - 1, y - 1, w + 2, h + 2, SYN_WHITE, 0.85, { arm: 10, width: 1.25 }); +} + +/** + * Synesthesia song select, set in a cosmic particle world. The persistent back layer is a particle world — data dust + * pouring out of the vanishing point at the focused chart's tempo, point-cloud pyramids, a floor of light points, a + * golden river of particles, and a roaming visualizer-style audio orb — redrawn cheaply in `tick`. The front layer snaps a lock-on reticle onto the focused card. + */ +class SynesthesiaSelectRenderer implements PixiSelectRenderer { + public readonly backLayer = new Container(); + public readonly frontLayer = new Container(); + private readonly ground = new Graphics(); + /** Additive particle world: floor, pyramids, river, and the orb's tails. */ + private readonly world = new Graphics(); + private readonly stars: Sprite[] = []; + /** Holds {@link stars}; the one layer that stays lit through the launch warp. */ + private readonly starLayer = new Container(); + /** Scratch objects for the allocation-free projection in `tick`'s per-particle loops. */ + private readonly scratchBasis = cameraBasis(REST_CAMERA); + private readonly scratchPoint = { x: 0, y: 0, z: 0 }; + private readonly scratchHead = scratchProjected(); + private readonly scratchTail = scratchProjected(); + /** Each star's trail during the launch warp, drawn behind the stars. */ + private readonly trails = new Graphics(); + private readonly lock = new Graphics(); + private readonly cursorGlow = new Sprite(); + private cursorChangedAt = Number.NEGATIVE_INFINITY; + /** Glow sprites for the audio orb, drawn over the world graphics. */ + private readonly orbHost = new Container(); + /** Normal-blend layer for the orb's black moon, and the additive layer for the camera-facing half of its shell. */ + private readonly orbMoon = new Graphics(); + private readonly orbFront = new Graphics(); + private readonly orbSprites = new ChildPool(this.orbHost); + private readonly flocks = SCHOOL_SPECS.map((spec) => createFlock(spec.seed, SCHOOL_SIZE, spec.bounds)); + private built = false; + private designWidth = 640; + private designHeight = 480; + /** Accumulated star / grid travel (speed-weighted seconds), so a speed change never makes the field jump. */ + private travel = 0; + private lastTickMs: number | undefined; + private effects: PixiSelectFrame['effects'] = 'full'; + private activeCard: { x: number; y: number; w: number; h: number } | undefined; + + public constructor() { + this.backLayer.label = 'synesthesia-select/space'; + this.frontLayer.label = 'synesthesia-select/front'; + } + + public render(input: PixiSelectFrame): boolean { + this.effects = input.effects; + const frame = + input.effects === 'off' + ? { ...input, sceneStartedAt: Number.NEGATIVE_INFINITY, cursorChangedAt: Number.NEGATIVE_INFINITY } + : input; + this.ensureBuilt(frame.designWidth, frame.designHeight); + this.activeCard = undefined; + let needsFrame = this.renderChrome(frame); + const listWidth = frame.designWidth - LAYOUT.listX - 14; + for (let visibleIndex = 0; visibleIndex < frame.visibleRows; visibleIndex += 1) { + const entryIndex = frame.firstVisibleIndex + visibleIndex; + const entry = frame.entries[entryIndex]; + if (!entry) break; + needsFrame = this.renderRow(frame, entry, entryIndex, visibleIndex, listWidth) || needsFrame; + } + const visible = this.activeCard !== undefined; + this.cursorGlow.visible = visible; + this.lock.visible = visible; + this.cursorChangedAt = frame.cursorChangedAt; + if (frame.launchAt !== undefined) { + this.renderOutro(frame); + return true; + } + return needsFrame; + } + + /** + * Launch outro — the warp: the panels and list fade away while the camera dashes straight through the particle field + * in black space (in `tick`), and the chosen title condenses at the centre out of wide-tracked light with its artist + * under it, a lock-on reticle snapping shut around it. Then the screen falls to black for the count-in. + */ + private renderOutro(frame: PixiSelectFrame): void { + const t = Math.min(1, (frame.nowMs - (frame.launchAt ?? frame.nowMs)) / OUTRO_MS); + const { designWidth, designHeight, layer } = frame; + const song = frame.focusedSong; + const cx = designWidth / 2; + const cy = designHeight / 2; + const dark = Math.max(0, (t - 0.72) / 0.28); + // Fade the list and panels out of the way, leaving black space and the particles rushing past. + const away = 1 - easeOutCubic(Math.min(1, t / 0.25)); + for (const child of layer.children) child.alpha *= away; + const form = easeOutCubic(Math.min(1, t / 0.4)); + const alpha = Math.min(1, t * 4) * (1 - dark); + const title = addSkinText(layer, song?.title ?? '', cx, cy - 6, { + size: 26, + weight: '500', + fill: SYN_WHITE, + fontFamily: SYN_TEXT_FONT, + letterSpacing: 2 + 14 * (1 - form), + anchorX: 0.5, + anchorY: 0.5, + maxWidth: designWidth - 120, + alpha, + dropShadow: { color: SYN_EMBER, distance: 0, blur: 16, alpha: 1 }, + }); + if (song?.artist) { + addSkinText(layer, song.artist, cx, cy + 24, { + size: 11, + fill: SYN_MIST, + fontFamily: SYN_TEXT_FONT, + letterSpacing: 2, + anchorX: 0.5, + anchorY: 0.5, + maxWidth: designWidth - 160, + alpha: alpha * Math.min(1, Math.max(0, (t - 0.15) / 0.2)), + }); + } + // Lock-on: brackets close from wide to tight around the title. + const lock = easeOutCubic(Math.min(1, t / 0.35)); + const boxW = Math.min(designWidth - 80, title.width + 48) + 160 * (1 - lock); + const boxH = 64 + 60 * (1 - lock); + const reticle = new Graphics(); + reticle.label = 'synesthesia-select/outro-lock'; + drawReticle(reticle, cx - boxW / 2, cy - boxH / 2 + 4, boxW, boxH, SYN_WHITE, 0.85 * alpha, { + arm: 12, + width: 1.5, + }); + if (dark > 0) reticle.rect(0, 0, designWidth, designHeight).fill({ color: SYN_VOID, alpha: dark }); + layer.addChild(reticle); + } + + public tick( + nowMs: number, + focusedSong: BrowserSongEntry | undefined, + launchAt?: number, + audio?: BeMusicAudioFrame, + ): void { + // Ambient motion: frozen with effects off, half speed when reduced. + const rate = this.effects === 'off' ? 0 : this.effects === 'reduced' ? 0.5 : 1; + // The BGM / chart preview drives the space: dust speeds with loudness, the floor swells on the bass, + // the schools pulse, and the orb's orbits breathe with the spectrum. + const drive = audioDrive(audio, this.effects); + const seconds = (nowMs / 1000) * rate; + const dt = this.lastTickMs === undefined ? 0 : Math.min(0.1, (nowMs - this.lastTickMs) / 1000); + this.lastTickMs = nowMs; + const warp = launchAt !== undefined ? Math.min(1, (nowMs - launchAt) / OUTRO_MS) : 0; + const bpm = focusedSong?.bpm; + const beatsPerSecond = (bpm !== undefined && Number.isFinite(bpm) && bpm > 0 ? Math.min(bpm, 300) : 120) / 60; + const beatPhase = (seconds * beatsPerSecond) % 1; + const pulse = (1 - beatPhase) ** 3; + const hue = sceneHue(seconds, beatPhase); + const accent = hsvToHex(hue, 0.6, 1); + + // Camera: the backdrop roams between random shots (flying, sometimes hard-cutting) with a handheld drift. + // Reduced effects halve the moves; off keeps it at rest. + // On launch the camera settles and flies dead straight, so the warp reads as a dash through the particles. + const settle = easeOutCubic(Math.min(1, warp / 0.3)); + const camera = mixCamera( + REST_CAMERA, + roamingCamera(seconds, 13, CAMERA_RANGE, CAMERA_CYCLE_S), + (this.effects === 'off' ? 0 : this.effects === 'reduced' ? 0.5 : 1) * (1 - settle), + ); + // Data dust pouring out of the vanishing point — speed follows the focused chart's tempo. Launching steers the + // vanishing point to the centre of the screen, where the chosen title appears, and opens the throttle — kept to a + // speed the eye can follow, with each particle trailing its own path so the dash reads as forward motion. + const cx = this.designWidth * (0.58 - 0.08 * settle); + const cy = this.designHeight * (0.44 + 0.06 * settle); + const speed = (80 + beatsPerSecond * 55) * (1 + WARP_SPEEDUP * warp * warp) * (1 + 1.2 * drive.level); + // World units each particle's trail reaches back along its path: about the last few frames of travel. + const trail = warp > 0 ? Math.min(STAR_TRAIL_MAX, speed * STAR_TRAIL_SECONDS) * settle : 0; + const trails = this.trails; + trails.clear(); + this.travel += dt * speed * (warp > 0 ? 1 : rate); + const basis = cameraBasis(camera, this.scratchBasis); + const shownStars = Math.round(STAR_COUNT + (WARP_STAR_COUNT - STAR_COUNT) * settle); + for (let index = 0; index < this.stars.length; index += 1) { + const star = this.stars[index]!; + if (index >= shownStars) { + star.visible = false; + continue; + } + const point = starfieldInto(this.scratchPoint, index, this.travel, STARFIELD); + const projected = projectViewInto(this.scratchHead, point.x, point.y, point.z, basis, cx, cy, 200); + star.visible = projected.visible; + if (!projected.visible) continue; + const nearness = Math.min(1, projected.scale); + // Rushing past, the near particles swell and brighten. + const size = (1 + 3 * nearness * nearness) * (1 + 2.5 * warp * nearness); + star.position.set(projected.x, projected.y); + star.rotation = 0; + star.width = size; + star.height = size; + star.alpha = Math.min(1, (0.2 + 0.8 * nearness) * (1 + warp)); + star.tint = index % 9 === 0 ? SYN_CYAN : index % 13 === 0 ? SYN_MAGENTA : emberColor(0.4 + 0.6 * nearness); + if (trail > 0) { + // The tail sits further down the same path, so every trail points back at the vanishing point. + const tailZ = Math.min(STARFIELD.far, point.z + trail); + const tail = projectViewInto(this.scratchTail, point.x, point.y, tailZ, basis, cx, cy, 200); + if (tail.visible) { + sharedShapeBatch.line( + trails, + star.tint, + star.alpha * 0.7, + Math.max(0.6, size * 0.45), + tail.x, + tail.y, + projected.x, + projected.y, + ); + } + } + } + sharedShapeBatch.flush(); + + // The cursor's lock-on lets go as the warp starts, and everything but the rushing particles falls behind. + this.frontLayer.alpha = 1 - settle; + const behind = 1 - 0.9 * settle; + for (const node of this.backLayer.children) { + if (node !== this.starLayer && node !== this.trails && node !== this.ground) node.alpha = behind; + } + const world = this.world; + world.clear(); + const floorY = this.designHeight * 0.7; + // Point-cloud pyramids, far to near. + const view = { cx, cy: floorY, focal: 200, camera, orbit: WORLD_ORBIT }; + for (const [pyramid, x, z, scale, yaw] of [ + [PYRAMIDS[0]!, -120, 1700, 700, 0.3], + [PYRAMIDS[1]!, -760, 1100, 420, 0.7], + [PYRAMIDS[2]!, 620, 1250, 440, -0.4], + ] as const) { + drawPointCloud(world, pyramid, { scale, x, y: 150, z, yaw: yaw + seconds * 0.03 }, view, { + colorOf: (point) => emberColor(0.3 + 0.6 * point.weight), + alpha: 0.7 * (1 - warp), + size: 0.9, + seconds, + shimmer: 0.3, + referenceScale: 200 / (200 + z), + }); + } + // Floor of light points scrolling toward the viewer. + const spacing = 100; + const offset = this.travel % spacing; + // Floor points and the river go out as a few batched instructions. + const batch = sharedShapeBatch; + for (let z = spacing - offset; z < 2600; z += spacing) { + const nearness = 1 - z / 2600; + const size = 0.6 + 1.5 * nearness * nearness; + const color = emberColor(0.3 + 0.65 * nearness); + const alpha = Math.min(1, (0.12 + 0.7 * nearness * nearness) * (0.7 + 0.3 * pulse) * (1 + drive.bass)); + for (let x = -1800; x <= 1800; x += 50) { + const point = projectViewInto(this.scratchHead, x, 150, z, basis, cx, floorY, 200, WORLD_ORBIT); + if (!point.visible || point.x < -4 || point.x > this.designWidth + 4 || point.y > this.designHeight + 4) { + continue; + } + batch.rect(world, color, alpha, point.x - size / 2, point.y - size / 2, size, size); + } + } + // A golden river of particles sweeping across the floor. + const riverOptions = { + length: 1800, + speed: 120 + beatsPerSecond * 60, + amplitude: 36 * (1 + 1.3 * drive.mid), + width: 20, + seed: 2, + }; + const riverWorld = (local: { x: number; y: number; z: number }) => + viewPoint({ x: local.x, y: 110 + local.y, z: 620 + local.z + local.x * 0.5 }, camera, WORLD_ORBIT); + for (let index = 0; index < RIVER_PARTICLES; index += 1) { + const local = particleRiverPoint(index, this.travel / 90, riverOptions); + const head = projectPoint(riverWorld(local), cx, floorY, 200); + const tail = projectPoint(riverWorld({ ...local, x: local.x - 30 }), cx, floorY, 200); + if (!head.visible || !tail.visible) continue; + const heat = hash01(index * 3 + 7); + batch.line( + world, + heat > 0.85 ? SYN_WHITE : emberColor(0.5 + 0.5 * heat), + (0.25 + 0.55 * heat) * (1 - warp), + 0.4 + 1 * Math.min(1, head.scale) * (0.5 + heat), + tail.x, + tail.y, + head.x, + head.y, + ); + } + batch.flush(); + // Schools of light fish sweeping through the pyramids: tight on the beat, scattering when the cursor moves. + if (rate > 0) { + const scatter = Math.max(Math.max(0, 1 - (nowMs - this.cursorChangedAt) / 600) ** 2, 0.8 * drive.onset) + warp; + const shown = this.effects === 'reduced' ? 1 : this.flocks.length; + for (let school = 0; school < shown; school += 1) { + stepFlock(this.flocks[school]!, dt * rate, { + seconds: seconds + school * 41.7, + gather: Math.max(pulse * 0.6, drive.bass), + scatter, + }); + drawSchool( + world, + this.flocks[school]!, + (point) => projectPoint(viewPoint(point, camera, WORLD_ORBIT), cx, floorY, 200), + { alpha: 0.95 * (1 - warp), palette: SCHOOL_SPECS[school]!.palette }, + ); + } + } + // Two audio orbs roam the space — the big one and its black moon right up by the camera — visualizer-style shells of sparks and fibres, the big one + // with a black moon circling it. On launch they swell into the warp. + this.orbSprites.begin(); + this.orbMoon.clear(); + this.orbFront.clear(); + const layers = { back: world, moon: this.orbMoon, front: this.orbFront }; + const orbView = { cx, cy: floorY, focal: 200, camera, orbit: WORLD_ORBIT }; + const big = wanderPoint(seconds * 0.6, 5, { minX: -210, maxX: 230, minY: -140, maxY: -20, minZ: -70, maxZ: 150 }); + const small = wanderPoint(seconds * 0.75 + 40, 9, { + minX: -360, + maxX: 360, + minY: -190, + maxY: 30, + minZ: 80, + maxZ: 700, + }); + // Draw the farther orb first so the nearer one's shell lands over it. + const orbs = [ + { at: big, shell: ORB_SHELL, particles: ORB, radius: 58, moon: true }, + { at: small, shell: SMALL_SHELL, particles: SMALL_ORB, radius: 34, moon: false }, + ].sort((left, right) => right.at.z - left.at.z); + for (const orb of orbs) { + drawMagnetoOrb(layers, () => this.orbSprites.acquireSprite(), orb.shell, orb.particles, { + ...orb.at, + radius: orb.radius * (1 + 0.03 * pulse) * (1 + 0.8 * warp), + view: orbView, + drive, + seconds, + alpha: 1 - warp * 0.5, + tilt: orb.moon ? 0.35 : -0.5, + spin: orb.moon ? 0.22 : -0.35, + ...(orb.moon ? { moon: { size: 0.42, distance: 2.1, speed: 0.55, phase: 0.4, tilt: 0.3 } } : {}), + }); + } + this.orbSprites.end(); + + // Lock-on reticle snapping onto the focused card, plus a breathing glow under it. + const card = this.activeCard; + this.lock.clear(); + if (card) { + const snap = easeOutCubic(stageProgress(nowMs - this.cursorChangedAt, 0, 220)); + const grow = 18 * (1 - snap); + drawReticle( + this.lock, + card.x - 5 - grow, + card.y - 4 - grow * 0.5, + card.w + 10 + grow * 2, + card.h + 8 + grow, + SYN_FLARE, + 0.5 + 0.5 * snap, + { arm: 9, width: 1.75 }, + ); + this.cursorGlow.position.set(card.x + card.w / 2, card.y + card.h / 2); + this.cursorGlow.width = card.w * 1.15; + this.cursorGlow.height = card.h * 3.2; + this.cursorGlow.tint = accent; + this.cursorGlow.alpha = 0.16 + 0.18 * pulse; + } + } + + public dispose(): void { + this.backLayer.destroy({ children: true }); + this.frontLayer.destroy({ children: true }); + } + + private ensureBuilt(designWidth: number, designHeight: number): void { + if (this.built) return; + this.built = true; + this.designWidth = designWidth; + this.designHeight = designHeight; + const bands: ReadonlyArray = [ + [SYN_DEEP, 0.3], + [0x070201, 0.55], + [0x040100, 0.8], + [SYN_VOID, 1], + ]; + let top = 0; + for (const [color, until] of bands) { + const bottom = designHeight * until; + this.ground.rect(0, top, designWidth, bottom - top).fill(color); + top = bottom; + } + const glow = synGlowTexture(); + const starLayer = this.starLayer; + starLayer.blendMode = 'add'; + for (let index = 0; index < WARP_STAR_COUNT; index += 1) { + const star = new Sprite(glow); + star.anchor.set(0.5); + star.blendMode = 'add'; + starLayer.addChild(star); + this.stars.push(star); + } + this.world.blendMode = 'add'; + this.orbHost.blendMode = 'add'; + this.orbFront.blendMode = 'add'; + this.trails.blendMode = 'add'; + this.backLayer.addChild(this.ground, this.world, this.trails, starLayer, this.orbMoon, this.orbFront, this.orbHost); + + this.cursorGlow.texture = glow; + this.cursorGlow.anchor.set(0.5); + this.cursorGlow.blendMode = 'add'; + this.frontLayer.addChild(this.cursorGlow, this.lock); + } + + private renderChrome(frame: PixiSelectFrame): boolean { + const { layer, designWidth, designHeight, entries, focusedSong: song } = frame; + const chrome = new Graphics(); + chrome.label = 'synesthesia-select/chrome'; + const hue = sceneHue(frame.nowMs / 1000); + const accent = hsvToHex(hue, 0.6, 1); + const addText = (text: string, x: number, y: number, options: SkinTextOptions = {}) => + addSkinText(layer, text, x, y, options); + + const songTitle = song?.title ?? 'No chart selected'; + const songArtist = song?.artist || song?.subtitle || ''; + const playLevel = song?.playLevel !== undefined ? String(song.playLevel) : '-'; + const playLevelNumber = + song?.playLevel !== undefined ? Number.parseFloat(String(song.playLevel).replace(/^[^\d.]+/u, '')) : NaN; + const songBpm = song?.bpm !== undefined ? String(Math.round(song.bpm)) : '-'; + const modeLabel = song ? formatPlayVariantLabel(song) : '- KEYS'; + const categoryName = frame.searchQuery ? `Search: ${frame.searchQuery}` : (frame.folderLabel ?? 'Library'); + const position = + entries.length > 0 ? `${Math.min(frame.selectedIndex + 1, entries.length)} / ${entries.length}` : '0 / 0'; + + // Header: title as light, a hairline, and the folder on the right. + chrome.rect(0, 0, designWidth, 38).fill({ color: SYN_VOID, alpha: 0.5 }); + chrome.rect(0, 38, designWidth, 1).fill({ color: accent, alpha: 0.5 }); + const intro = easeOutCubic(stageProgress(frame.nowMs - frame.sceneStartedAt, 0, 900)); + addText('MUSIC SELECT', 18, 13, { + size: 12, + fill: SYN_WHITE, + fontFamily: SYN_DISPLAY_FONT, + letterSpacing: 4 + 8 * (1 - intro) + 2, + alpha: intro, + dropShadow: { color: accent, distance: 0, blur: 8, alpha: 0.9 }, + }); + addText(categoryName, designWidth - 18, 14, { + size: 10, + weight: '500', + fill: SYN_MIST, + fontFamily: SYN_TEXT_FONT, + anchorX: 1, + maxWidth: 220, + }); + + // Info panel. + framePanel(chrome, 14, 56, 290, 302, accent); + addText('NOW SELECTING', 30, 70, labelStyle(accent)); + const slide = 1 - easeOutCubic(stageProgress(frame.nowMs - frame.cursorChangedAt, 0, SLIDE_MS)); + addText(songTitle, 30 + slide * 24, 86, { + size: 18, + weight: '500', + fill: SYN_WHITE, + fontFamily: SYN_TEXT_FONT, + maxWidth: 258, + alpha: 1 - slide, + dropShadow: { color: accent, distance: 0, blur: 10, alpha: 0.8 }, + }); + if (songArtist) { + addText(songArtist, 30 + slide * 40, 114, { + size: 10, + weight: '300', + fill: SYN_MIST, + fontFamily: SYN_TEXT_FONT, + maxWidth: 258, + alpha: 1 - slide, + }); + } + chrome.rect(30, 136, 258, 1).fill({ color: accent, alpha: 0.35 }); + + const stats: ReadonlyArray = [ + [30, 80, 'MODE', modeLabel, SYN_WHITE], + [118, 76, 'BPM', songBpm, SYN_WHITE], + [202, 86, 'LEVEL', playLevel, SYN_AMBER], + ]; + for (const [x, w, label, value, fill] of stats) { + chrome.rect(x, 150, w, 50).fill({ color: SYN_VOID, alpha: 0.5 }); + chrome.rect(x, 150, w, 50).stroke({ color: accent, width: 1, alpha: 0.25 }); + drawReticle(chrome, x, 150, w, 50, SYN_MIST, 0.6, { arm: 5, width: 1 }); + addText(label, x + 10, 158, labelStyle()); + addText(value, x + 10, 174, { + size: 14, + fill, + fontFamily: SYN_DISPLAY_FONT, + maxWidth: w - 18, + dropShadow: { color: fill, distance: 0, blur: 6, alpha: 0.6 }, + }); + } + + // Level as a row of lights, from cool electric blue through magenta to hot ember. + const levelRatio = Number.isFinite(playLevelNumber) ? Math.max(0.04, Math.min(1, playLevelNumber / 12)) : 0; + const dots = 12; + for (let dot = 0; dot < dots; dot += 1) { + const lit = dot < Math.round(levelRatio * dots); + const x = 38 + dot * 22; + const color = levelColor(dot / dots); + if (lit) { + chrome.circle(x, 226, 7).fill({ color, alpha: 0.18 }); + chrome.circle(x, 226, 3).fill({ color: SYN_WHITE, alpha: 0.95 }); + } else { + chrome.circle(x, 226, 2).fill({ color: SYN_DIM, alpha: 0.5 }); + } + } + if (song?.fileLabel) { + addText(song.fileLabel, 30, 245, { + size: 9, + weight: '300', + fill: SYN_DIM, + fontFamily: SYN_TEXT_FONT, + maxWidth: 258, + }); + } + addText(position, 288, 268, { size: 9, fill: SYN_MIST, fontFamily: SYN_DISPLAY_FONT, anchorX: 1 }); + + // PLAY (primary glowing pill) / AUTO PLAY (secondary outline). + chrome.rect(26, 296, 170, 40).fill({ color: accent, alpha: 0.16 }); + chrome.rect(30, 300, 162, 32).fill({ color: accent, alpha: 0.88 }); + drawReticle(chrome, 26, 296, 170, 40, SYN_WHITE, 0.95, { arm: 8, width: 1.5 }); + addText('PLAY', 111, 316, { + size: 14, + fill: SYN_VOID, + fontFamily: SYN_DISPLAY_FONT, + letterSpacing: 6, + anchorX: 0.5, + anchorY: 0.5, + }); + // AUTO's right edge sits on the content column's (288), like the LEVEL chip above it. + chrome.rect(202, 303, 86, 26).stroke({ color: SYN_MIST, width: 1, alpha: 0.45 }); + drawReticle(chrome, 202, 303, 86, 26, SYN_MIST, 0.8, { arm: 5, width: 1 }); + addText('AUTO', 245, 316, { + size: 9, + fill: SYN_MIST, + fontFamily: SYN_DISPLAY_FONT, + letterSpacing: 3, + anchorX: 0.5, + anchorY: 0.5, + }); + addHitArea(26, 296, 172, 40, 'pointer', frame.actions.play); + addHitArea(200, 300, 90, 32, 'pointer', frame.actions.autoPlay); + + // Search and library. + framePanel(chrome, 14, 372, 290, 30, accent); + addText('SEARCH', 30, 387, { ...labelStyle(accent), anchorY: 0.5 }); + addText(frame.searchQuery || 'Title / artist / genre', 116, 387, { + size: 10, + anchorY: 0.5, + weight: '300', + fill: frame.searchQuery ? SYN_WHITE : SYN_DIM, + fontFamily: SYN_TEXT_FONT, + maxWidth: 180, + }); + addHitArea(14, 372, 290, 30, 'text', frame.actions.activateSearch); + addText('LIBRARY', 30, 422, labelStyle()); + addText(`${entries.length} shown / ${frame.totalCharts} charts`, 30, 436, { + size: 11, + weight: '500', + fill: SYN_MIST, + fontFamily: SYN_TEXT_FONT, + }); + addText( + frame.searchQuery ? 'SEARCH RESULTS' : frame.folderLabel ? 'CHARTS' : 'FOLDERS', + designWidth - 14, + designHeight - 20, + { ...labelStyle(), anchorX: 1 }, + ); + + layer.addChildAt(chrome, 0); + return slide > 0 || intro < 1; + } + + private renderRow( + frame: PixiSelectFrame, + entry: BrowserBrowseEntry, + entryIndex: number, + visibleIndex: number, + listWidth: number, + ): boolean { + const { listX, listTop, rowHeight } = LAYOUT; + const y = listTop + visibleIndex * rowHeight; + const active = entryIndex === frame.selectedIndex; + const song = entry.kind === 'song' ? entry.song : undefined; + const folder = entry.kind === 'folder' ? entry.folder : undefined; + const hue = sceneHue(frame.nowMs / 1000); + const accent = hsvToHex(hue, 0.6, 1); + const slide = active ? 1 - easeOutCubic(stageProgress(frame.nowMs - frame.cursorChangedAt, 0, SLIDE_MS)) : 0; + const intro = + 1 - easeOutCubic(stageProgress(frame.nowMs - frame.sceneStartedAt, 120 + visibleIndex * INTRO_STAGGER_MS, 420)); + const rowX = (active ? listX - 8 : listX) + slide * 26 + intro * 120; + const rowW = active ? listWidth + 8 : listWidth; + const alpha = 1 - intro; + const row = new Graphics(); + row.alpha = alpha; + row.label = `fallback-row[idx=${entryIndex},kind=${entry.kind}${active ? ',active' : ''}]`; + if (active) { + row.rect(rowX, y, rowW, rowHeight - 4).fill({ color: accent, alpha: 0.24 }); + row.rect(rowX, y, rowW, rowHeight - 4).stroke({ color: SYN_FLARE, width: 1, alpha: 0.6 }); + this.activeCard = { x: rowX, y, w: rowW, h: rowHeight - 4 }; + } else { + row.rect(rowX, y, rowW, rowHeight - 4).fill({ color: SYN_GLASS, alpha: 0.55 }); + row.rect(rowX, y, rowW, rowHeight - 4).stroke({ color: accent, width: 1, alpha: 0.16 }); + } + // Level as a glowing orb on the leading edge. + const level = song?.playLevel !== undefined ? String(song.playLevel) : folder ? 'DIR' : '-'; + const orbColor = song ? levelColor(Math.min(1, Number.parseFloat(level) / 12 || 0)) : SYN_MIST; + row.circle(rowX + 16, y + (rowHeight - 4) / 2, 9).fill({ color: orbColor, alpha: active ? 0.35 : 0.18 }); + frame.layer.addChild(row); + const midY = y + (rowHeight - 4) / 2; + addSkinText(frame.layer, level, rowX + 16, midY, { + size: level.length > 2 ? 7 : 9, + fill: SYN_WHITE, + fontFamily: SYN_DISPLAY_FONT, + anchorX: 0.5, + anchorY: 0.5, + alpha, + maxWidth: 16, + }); + // On the right, the facts a player checks before picking — gimmick tags, note count, length and tempo range — in + // fixed columns so they line up down the list. Folders show their size instead. + const facts = song ? resolveSongRowFacts(song) : undefined; + const meta = facts + ? `${facts.bpm} BPM` + : `${folder?.songs.length ?? 0} chart${folder?.songs.length === 1 ? '' : 's'}`; + const metaNode = addSkinText(frame.layer, meta, rowX + rowW - 14, midY, { + size: 9, + fill: active ? SYN_WHITE : SYN_DIM, + fontFamily: SYN_DISPLAY_FONT, + letterSpacing: 1, + anchorX: 1, + anchorY: 0.5, + alpha, + maxWidth: facts ? ROW_BPM_W : 70, + }); + metaNode.label = `fallback-meta[idx=${entryIndex}]`; + let factsLeft = rowX + rowW - 14 - (facts ? ROW_BPM_W : meta ? Math.min(70, metaNode.width) : 0) - 12; + if (facts) { + const factStyle = (fill: number) => ({ + size: 9, + fill, + fontFamily: SYN_DISPLAY_FONT, + letterSpacing: 1, + anchorX: 1, + anchorY: 0.5, + alpha, + }); + const ink = active ? SYN_WHITE : SYN_MIST; + addSkinText(frame.layer, facts.length, factsLeft, midY, { ...factStyle(ink), maxWidth: ROW_LENGTH_W }); + factsLeft -= ROW_LENGTH_W + 12; + addSkinText(frame.layer, 'NOTES', factsLeft, midY, { ...factStyle(SYN_DIM), size: 7 }); + addSkinText(frame.layer, facts.notes, factsLeft - 46, midY, { ...factStyle(ink), maxWidth: ROW_NOTES_W - 48 }); + factsLeft -= ROW_NOTES_W + 8; + // Gimmick tags glow as small outlined capsules in the accent light. + for (let index = facts.tags.length - 1; index >= 0; index -= 1) { + const tag = facts.tags[index]!; + const chipW = 10 + tag.length * 6.5; + factsLeft -= chipW; + row + .roundRect(factsLeft, midY - 6, chipW, 12, 6) + .stroke({ color: active ? SYN_FLARE : accent, width: 1, alpha: 0.7 }); + addSkinText(frame.layer, tag, factsLeft + chipW / 2, midY, { + size: 7, + fill: active ? SYN_WHITE : SYN_MIST, + fontFamily: SYN_DISPLAY_FONT, + letterSpacing: 0.5, + anchorX: 0.5, + anchorY: 0.5, + alpha, + maxWidth: chipW - 4, + }); + factsLeft -= 5; + } + factsLeft -= 6; + } + const title = addSkinText(frame.layer, song?.title ?? folder?.label ?? '', rowX + 34, midY, { + size: 11, + weight: active ? '500' : '300', + fill: SYN_WHITE, + fontFamily: SYN_TEXT_FONT, + anchorY: 0.5, + alpha, + maxWidth: Math.max(24, factsLeft - rowX - 34), + ...(active ? { dropShadow: { color: accent, distance: 0, blur: 8, alpha: 0.9 } } : {}), + }); + title.label = `fallback-title[idx=${entryIndex}]`; + // The artist trails the title in a dimmer light while there is room for it. + const artist = song?.artist?.trim(); + const artistX = rowX + 34 + title.width + 10; + if (artist && factsLeft - artistX >= 40) { + addSkinText(frame.layer, artist, artistX, midY, { + size: 9, + weight: '300', + fill: active ? SYN_MIST : SYN_DIM, + fontFamily: SYN_TEXT_FONT, + anchorY: 0.5, + alpha, + maxWidth: factsLeft - artistX, + }); + } + return slide > 0 || intro > 0; + } +} + +/** Level light for `ratio` (0..1): electric blue for easy charts through magenta to ember for the hardest. */ +function levelColor(ratio: number): number { + return hsvToHex(0.58 + Math.max(0, Math.min(1, ratio)) * 0.5, 0.72, 1); +} diff --git a/packages/player-web/src/skins/synesthesia/space.test.ts b/packages/player-web/src/skins/synesthesia/space.test.ts new file mode 100644 index 00000000..cf7c44c3 --- /dev/null +++ b/packages/player-web/src/skins/synesthesia/space.test.ts @@ -0,0 +1,442 @@ +import { describe, expect, it } from 'vite-plus/test'; +import { + burstParticlePosition, + clipSegmentOutsideRect, + burstParticles, + cameraBasis, + emberColor, + fibonacciSphere, + limbGlow, + hsvToHex, + mixCamera, + orbitParticles, + orbitPosition, + particleRiverPoint, + pointCloudPyramid, + projectPoint, + projectViewInto, + randomShot, + reflectedLight, + REST_CAMERA, + roamingCamera, + rotateX, + rotateY, + scratchProjected, + starfieldInto, + starfieldPoint, + vanishingPoint, + viewPoint, + wanderPoint, +} from './space.ts'; + +describe('projectPoint', () => { + it('maps z = 0 one-to-one around the origin', () => { + expect(projectPoint({ x: 10, y: -5, z: 0 }, 100, 50, 200)).toEqual({ x: 110, y: 45, scale: 1, visible: true }); + }); + + it('shrinks toward the vanishing point with depth', () => { + const far = projectPoint({ x: 10, y: 0, z: 200 }, 0, 0, 200); + expect(far.scale).toBe(0.5); + expect(far.x).toBe(5); + }); + + it('hides points behind the camera', () => { + expect(projectPoint({ x: 0, y: 0, z: -250 }, 0, 0, 200).visible).toBe(false); + }); +}); + +describe('rotations', () => { + it('rotate a point a quarter turn', () => { + const y = rotateY({ x: 1, y: 0, z: 0 }, Math.PI / 2); + expect(y.x).toBeCloseTo(0, 12); + expect(y.z).toBeCloseTo(-1, 12); + const x = rotateX({ x: 0, y: 1, z: 0 }, Math.PI / 2); + expect(x.y).toBeCloseTo(0, 12); + expect(x.z).toBeCloseTo(1, 12); + }); +}); + +describe('burstParticles', () => { + it('is deterministic per seed and differs across seeds', () => { + expect(burstParticles(4, 8)).toEqual(burstParticles(4, 8)); + expect(burstParticles(4, 8)).not.toEqual(burstParticles(5, 8)); + }); + + it('keeps every attribute in range and biases sparks upward', () => { + const particles = burstParticles(11, 200); + for (const particle of particles) { + expect(particle.speed).toBeGreaterThanOrEqual(0.35); + expect(particle.speed).toBeLessThanOrEqual(1); + expect(particle.life).toBeGreaterThanOrEqual(0.55); + expect(Math.abs(particle.hueShift)).toBeLessThanOrEqual(0.08); + } + const upward = particles.filter((particle) => particle.direction.y < 0).length; + expect(upward).toBeGreaterThan(particles.length / 2); + }); + + it('launches from the origin and travels outward over time', () => { + const [particle] = burstParticles(2, 1); + const start = burstParticlePosition(particle!, 0, 40, 10); + expect(Math.hypot(start.x, start.y, start.z)).toBe(0); + const end = burstParticlePosition(particle!, 1, 40, 0); + expect(Math.hypot(end.x, end.y, end.z)).toBeGreaterThan(0); + }); +}); + +describe('starfieldPoint', () => { + const options = { spread: 300, near: 0, far: 600, speed: 120 }; + + it('keeps depth inside the loop range', () => { + for (let index = 0; index < 50; index += 1) { + const point = starfieldPoint(index, 17.3, options); + expect(point.z).toBeGreaterThan(options.near); + expect(point.z).toBeLessThanOrEqual(options.far); + expect(Math.abs(point.x)).toBeLessThanOrEqual(options.spread); + } + }); + + it('moves stars toward the camera as time advances', () => { + const before = starfieldPoint(3, 1, options); + const after = starfieldPoint(3, 1.1, options); + expect(after.z === before.z - 12 || after.z > before.z).toBe(true); + }); +}); + +describe('hsvToHex', () => { + it('converts primaries and wraps hue', () => { + expect(hsvToHex(0, 1, 1)).toBe(0xff0000); + expect(hsvToHex(1 / 3, 1, 1)).toBe(0x00ff00); + expect(hsvToHex(2 / 3, 1, 1)).toBe(0x0000ff); + expect(hsvToHex(1.5, 0, 1)).toBe(0xffffff); + expect(hsvToHex(-1 / 3, 1, 1)).toBe(0x0000ff); + }); +}); + +describe('emberColor', () => { + it('runs from a red ember to a white-hot flare', () => { + expect(emberColor(0)).toBe(0xb82c08); + expect(emberColor(1)).toBe(0xfff6e2); + expect(emberColor(0.65)).toBe(0xffaa30); + }); + + it('clamps out-of-range and non-finite input', () => { + expect(emberColor(-1)).toBe(emberColor(0)); + expect(emberColor(4)).toBe(emberColor(1)); + expect(emberColor(Number.NaN)).toBe(emberColor(0)); + }); + + it('warms monotonically in green as it brightens', () => { + let previous = -1; + for (let step = 0; step <= 20; step += 1) { + const green = (emberColor(step / 20) >> 8) & 0xff; + expect(green).toBeGreaterThanOrEqual(previous); + previous = green; + } + }); +}); + +describe('pointCloudPyramid', () => { + it('keeps every point inside the pyramid volume', () => { + const points = pointCloudPyramid(3, 400); + expect(points).toHaveLength(400); + for (const point of points) { + expect(point.y).toBeLessThanOrEqual(1e-9); + expect(point.y).toBeGreaterThanOrEqual(-1.3 - 1e-9); + // The cross-section shrinks linearly toward the apex. + const halfWidth = 1 + point.y / 1.3; + expect(Math.abs(point.x)).toBeLessThanOrEqual(halfWidth + 1e-9); + expect(Math.abs(point.z)).toBeLessThanOrEqual(halfWidth + 1e-9); + expect(point.weight).toBeGreaterThanOrEqual(0.3); + expect(point.weight).toBeLessThanOrEqual(1); + } + }); + + it('is deterministic for a seed', () => { + expect(pointCloudPyramid(9, 20)).toEqual(pointCloudPyramid(9, 20)); + expect(pointCloudPyramid(9, 20)).not.toEqual(pointCloudPyramid(10, 20)); + }); +}); + +describe('fibonacciSphere', () => { + it('spreads unit vectors evenly over the sphere', () => { + const points = fibonacciSphere(500, 2); + expect(points).toHaveLength(500); + let mx = 0; + let my = 0; + let mz = 0; + for (const point of points) { + expect(Math.hypot(point.x, point.y, point.z)).toBeCloseTo(1, 9); + mx += point.x; + my += point.y; + mz += point.z; + } + expect(Math.hypot(mx, my, mz) / points.length).toBeLessThan(0.01); + }); +}); + +describe('limbGlow', () => { + it('peaks on the silhouette and dims toward the disc centre', () => { + expect(limbGlow(0)).toBe(1); + expect(limbGlow(-1)).toBeCloseTo(0.25, 12); + expect(limbGlow(1, 0.1)).toBeCloseTo(0.1, 12); + expect(limbGlow(-0.5)).toBeGreaterThan(limbGlow(-0.9)); + }); +}); + +describe('orbitParticles', () => { + it('builds particles within their ranges', () => { + const particles = orbitParticles(4, 80); + expect(particles).toHaveLength(80); + for (const particle of particles) { + expect(particle.reach).toBeGreaterThanOrEqual(1.3); + expect(particle.reach).toBeLessThanOrEqual(2.8); + expect(Math.abs(particle.speed)).toBeGreaterThanOrEqual(0.5); + expect(particle.band).toBeGreaterThanOrEqual(0); + expect(particle.band).toBeLessThan(16); + } + expect(orbitParticles(4, 5)).toEqual(orbitParticles(4, 5)); + }); +}); + +describe('orbitPosition', () => { + it('keeps every point of the orbit at its radius', () => { + const [particle] = orbitParticles(1, 1); + for (let angle = 0; angle < Math.PI * 2; angle += 0.3) { + const point = orbitPosition(particle!, angle, 50); + expect(Math.hypot(point.x, point.y, point.z)).toBeCloseTo(50, 9); + } + }); + + it('lies flat in the xz plane with no tilt', () => { + const flat = { reach: 2, tiltX: 0, tiltZ: 0, speed: 1, phase: 0, band: 0, weight: 1 }; + expect(orbitPosition(flat, Math.PI / 2, 10).y).toBeCloseTo(0, 12); + expect(orbitPosition(flat, Math.PI / 2, 10).z).toBeCloseTo(10, 12); + }); +}); + +describe('particleRiverPoint', () => { + const options = { length: 800, speed: 100, amplitude: 30, width: 10 }; + + it('stays within the river bounds', () => { + for (let index = 0; index < 200; index += 1) { + const point = particleRiverPoint(index, 3.7, options); + expect(Math.abs(point.x)).toBeLessThanOrEqual(400); + expect(Math.abs(point.y)).toBeLessThanOrEqual(40); + expect(Math.abs(point.z)).toBeLessThanOrEqual(10); + } + }); + + it('flows forward along x over time', () => { + const a = particleRiverPoint(5, 0, options); + const b = particleRiverPoint(5, 0.1, options); + expect(b.x - a.x).toBeCloseTo(10, 6); + }); +}); + +describe('mixCamera', () => { + it('scales a pose toward rest', () => { + const half = mixCamera(REST_CAMERA, { x: 10, y: 20, z: 30, yaw: 0.4, pitch: -0.2 }, 0.5); + expect(half).toEqual({ x: 5, y: 10, z: 15, yaw: 0.2, pitch: -0.1 }); + }); +}); + +describe('viewPoint', () => { + it('is the identity at rest', () => { + expect(viewPoint({ x: 3, y: 4, z: 5 }, REST_CAMERA)).toEqual({ x: 3, y: 4, z: 5 }); + }); + + it('moves the world opposite to the eye', () => { + const point = viewPoint({ x: 0, y: 0, z: 100 }, { ...REST_CAMERA, x: 20, y: -10 }); + expect(point).toEqual({ x: -20, y: 10, z: 100 }); + }); + + it('turning right swings points ahead to the left', () => { + const point = viewPoint({ x: 0, y: 0, z: 100 }, { ...REST_CAMERA, yaw: 0.3 }); + expect(point.x).toBeLessThan(0); + }); + + it('keeps the orbit pivot fixed', () => { + const pivot = viewPoint({ x: 0, y: 0, z: 200 }, { ...REST_CAMERA, yaw: 0.8, pitch: 0.2 }, 200); + expect(pivot.x).toBeCloseTo(0, 9); + expect(pivot.y).toBeCloseTo(0, 9); + expect(pivot.z).toBeCloseTo(200, 9); + }); +}); + +describe('vanishingPoint', () => { + it('sits at the view centre at rest', () => { + expect(vanishingPoint(REST_CAMERA, 320, 240, 200)).toEqual({ x: 320, y: 240 }); + }); + + it('shifts against the turn and rises when looking down', () => { + const point = vanishingPoint({ ...REST_CAMERA, yaw: 0.3, pitch: 0.1 }, 320, 240, 200); + expect(point.x).toBeLessThan(320); + expect(point.y).toBeLessThan(240); + expect(point.y).toBeCloseTo(240 - 200 * Math.tan(0.1), 6); + }); + + it('agrees with projecting a far point', () => { + const camera = { ...REST_CAMERA, yaw: -0.25, pitch: 0.12 }; + const far = projectPoint(viewPoint({ x: 0, y: 0, z: 1e7 }, camera), 320, 240, 200); + const vanish = vanishingPoint(camera, 320, 240, 200); + expect(far.x).toBeCloseTo(vanish.x, 2); + expect(far.y).toBeCloseTo(vanish.y, 2); + }); +}); + +describe('randomShot', () => { + const range = { x: 200, y: 100, yaw: 0.3, pitch: 0.15 }; + + it('opens at rest and stays within range', () => { + expect(randomShot(0, 4, range)).toEqual(REST_CAMERA); + for (let index = 1; index < 50; index += 1) { + const shot = randomShot(index, 4, range); + expect(Math.abs(shot.x)).toBeLessThanOrEqual(200); + expect(Math.abs(shot.y)).toBeLessThanOrEqual(100); + expect(Math.abs(shot.yaw)).toBeLessThanOrEqual(0.3); + expect(Math.abs(shot.pitch)).toBeLessThanOrEqual(0.15); + } + }); + + it('varies with index and seed but is deterministic', () => { + expect(randomShot(3, 4, range)).toEqual(randomShot(3, 4, range)); + expect(randomShot(3, 4, range)).not.toEqual(randomShot(4, 4, range)); + expect(randomShot(3, 4, range)).not.toEqual(randomShot(3, 5, range)); + }); +}); + +describe('roamingCamera', () => { + const range = { x: 200, y: 100, yaw: 0.3, pitch: 0.15 }; + + it('starts near rest and never strays far outside the range', () => { + const start = roamingCamera(0, 9, range, 8); + expect(Math.abs(start.x)).toBeLessThan(range.x * 0.1); + for (let t = 0; t < 200; t += 0.37) { + const pose = roamingCamera(t, 9, range, 8); + expect(Math.abs(pose.x)).toBeLessThanOrEqual(range.x * 1.09); + expect(Math.abs(pose.yaw)).toBeLessThanOrEqual(range.yaw * 1.09); + } + }); + + it('moves continuously within a cycle (no jumps outside a cut)', () => { + const dt = 1 / 60; + let largest = 0; + for (let t = 0; t < 8; t += dt) { + largest = Math.max( + largest, + Math.abs(roamingCamera(t + dt, 2, range, 8, 0).x - roamingCamera(t, 2, range, 8, 0).x), + ); + } + // Fastest legal fly: the whole range in a one-second smoothstep ≈ 1.5 × span / s. + expect(largest).toBeLessThan(range.x * 2 * 1.5 * dt * 12); + }); +}); + +describe('wanderPoint', () => { + const bounds = { minX: -500, maxX: 500, minY: -140, maxY: 60, minZ: 250, maxZ: 1400 }; + + it('stays inside its box and moves continuously', () => { + let previous = wanderPoint(0, 3, bounds); + for (let t = 1 / 60; t < 300; t += 1 / 60) { + const point = wanderPoint(t, 3, bounds); + expect(point.x).toBeGreaterThanOrEqual(bounds.minX); + expect(point.x).toBeLessThanOrEqual(bounds.maxX); + expect(point.y).toBeGreaterThanOrEqual(bounds.minY); + expect(point.y).toBeLessThanOrEqual(bounds.maxY); + expect(point.z).toBeGreaterThanOrEqual(bounds.minZ); + expect(point.z).toBeLessThanOrEqual(bounds.maxZ); + expect(Math.hypot(point.x - previous.x, point.z - previous.z)).toBeLessThan(10); + previous = point; + } + }); + + it('roams across most of the box', () => { + const xs: number[] = []; + for (let t = 0; t < 600; t += 0.5) xs.push(wanderPoint(t, 3, bounds).x); + expect(Math.max(...xs) - Math.min(...xs)).toBeGreaterThan(700); + }); + + it('differs by seed', () => { + expect(wanderPoint(10, 1, bounds)).not.toEqual(wanderPoint(10, 2, bounds)); + }); +}); + +describe('reflectedLight', () => { + it('points at the source', () => { + expect(reflectedLight(0, -10, 10, 5, 1).angle).toBeCloseTo(-Math.PI / 2, 12); + }); + + it('is stronger close to the source and when it is loud', () => { + const near = reflectedLight(10, 0, 20, 10, 0.5).strength; + const far = reflectedLight(10, 0, 200, 10, 0.5).strength; + expect(near).toBeGreaterThan(far); + expect(reflectedLight(10, 0, 50, 10, 1).strength).toBeGreaterThan(reflectedLight(10, 0, 50, 10, 0).strength); + expect(reflectedLight(1, 0, 1, 10, 1).strength).toBe(1); + }); +}); + +describe('clipSegmentOutsideRect', () => { + const rect = { x: 10, y: 10, w: 20, h: 20 }; + + it('keeps a segment that misses the rect whole', () => { + const out: number[] = []; + expect(clipSegmentOutsideRect(0, 0, 40, 0, rect, out)).toBe(1); + expect(out).toEqual([0, 0, 40, 0]); + }); + + it('splits a segment crossing the rect into the two outside pieces', () => { + const out: number[] = []; + expect(clipSegmentOutsideRect(0, 20, 40, 20, rect, out)).toBe(2); + expect(out).toEqual([0, 20, 10, 20, 30, 20, 40, 20]); + }); + + it('keeps only the outside part of a segment ending inside', () => { + const out: number[] = []; + expect(clipSegmentOutsideRect(0, 20, 20, 20, rect, out)).toBe(1); + expect(out).toEqual([0, 20, 10, 20]); + }); + + it('drops a segment lying inside', () => { + const out: number[] = []; + expect(clipSegmentOutsideRect(12, 12, 28, 28, rect, out)).toBe(0); + expect(out).toEqual([]); + }); + + it('handles diagonal crossings', () => { + const out: number[] = []; + expect(clipSegmentOutsideRect(0, 0, 40, 40, rect, out)).toBe(2); + expect(out).toEqual([0, 0, 10, 10, 30, 30, 40, 40]); + }); +}); + +describe('projectViewInto', () => { + it('matches projectPoint(viewPoint(...)) without allocating', () => { + const camera = { x: 12, y: -30, z: 40, yaw: 0.3, pitch: -0.2 }; + const basis = cameraBasis(camera); + const out = scratchProjected(); + for (const [x, y, z, orbit] of [ + [0, 0, 0, 0], + [120, 80, 500, 0], + [-300, 150, 900, 220], + [10, 10, -400, 0], + ] as const) { + const expected = projectPoint(viewPoint({ x, y, z }, camera, orbit), 400, 240, 200); + const actual = projectViewInto(out, x, y, z, basis, 400, 240, 200, orbit); + expect(actual).toBe(out); + expect(actual.visible).toBe(expected.visible); + expect(actual.x).toBeCloseTo(expected.x, 9); + expect(actual.y).toBeCloseTo(expected.y, 9); + expect(actual.scale).toBeCloseTo(expected.scale, 9); + } + }); +}); + +describe('starfieldInto', () => { + it('matches starfieldPoint', () => { + const options = { spread: 560, near: 10, far: 1000, speed: 1 }; + const out = { x: 0, y: 0, z: 0 }; + for (const index of [0, 7, 319]) { + expect(starfieldInto(out, index, 123.4, options)).toEqual(starfieldPoint(index, 123.4, options)); + } + }); +}); diff --git a/packages/player-web/src/skins/synesthesia/space.ts b/packages/player-web/src/skins/synesthesia/space.ts new file mode 100644 index 00000000..d8354da3 --- /dev/null +++ b/packages/player-web/src/skins/synesthesia/space.ts @@ -0,0 +1,626 @@ +import { hash01 } from '@be-music/skin-sdk'; + +/** + * Pure 3D helpers for the Synesthesia skin: a pinhole projection, deterministic particle bursts, a looping starfield, + * point-cloud shapes (pyramids), orbiting particles for the audio orb, particle rivers, and ember / hue-based colour. Everything is a + * function of (seed, time) so the pooled per-frame redraw needs no particle state and never shimmers. + */ + +export interface Vec3 { + x: number; + y: number; + z: number; +} + +export interface Projected { + x: number; + y: number; + /** Perspective scale at this depth (1 at `z = 0`). */ + scale: number; + /** False when the point is behind the camera. */ + visible: boolean; +} + +/** + * Pinhole projection. The camera sits at `z = -focal` looking down +z, so a point at `z = 0` projects 1:1 around the + * origin `(cx, cy)`; larger `z` recedes toward the vanishing point and shrinks. + */ +export function projectPoint(point: Vec3, cx: number, cy: number, focal: number): Projected { + const depth = focal + point.z; + if (depth <= 1e-3) { + return { x: cx, y: cy, scale: 0, visible: false }; + } + const scale = focal / depth; + return { x: cx + point.x * scale, y: cy + point.y * scale, scale, visible: true }; +} + +/** A camera's yaw / pitch sines and cosines, worked out once per frame for {@link projectViewInto}. */ +export interface CameraBasis { + camera: CameraPose; + cosYaw: number; + sinYaw: number; + cosPitch: number; + sinPitch: number; +} + +/** The basis for `camera`, written into `out` (a fresh object when omitted). */ +export function cameraBasis(camera: CameraPose, out?: CameraBasis): CameraBasis { + const basis = out ?? { camera, cosYaw: 1, sinYaw: 0, cosPitch: 1, sinPitch: 0 }; + basis.camera = camera; + basis.cosYaw = Math.cos(camera.yaw); + basis.sinYaw = Math.sin(camera.yaw); + basis.cosPitch = Math.cos(camera.pitch); + basis.sinPitch = Math.sin(camera.pitch); + return basis; +} + +/** + * `projectPoint(viewPoint({ x, y, z }, camera, orbit), cx, cy, focal)` without allocating: the result is written into + * `out`. For the hot loops that project thousands of points a frame. + */ +export function projectViewInto( + out: Projected, + x: number, + y: number, + z: number, + basis: CameraBasis, + cx: number, + cy: number, + focal: number, + orbit = 0, +): Projected { + const camera = basis.camera; + const lx = x - camera.x; + const ly = y - camera.y; + const lz = z - camera.z - orbit; + // rotateY by -yaw, then rotateX by pitch (see `viewPoint`). + const x1 = lx * basis.cosYaw - lz * basis.sinYaw; + const z1 = lx * basis.sinYaw + lz * basis.cosYaw; + const y2 = ly * basis.cosPitch - z1 * basis.sinPitch; + const z2 = ly * basis.sinPitch + z1 * basis.cosPitch + orbit; + const depth = focal + z2; + if (depth <= 1e-3) { + out.x = cx; + out.y = cy; + out.scale = 0; + out.visible = false; + return out; + } + const scale = focal / depth; + out.x = cx + x1 * scale; + out.y = cy + y2 * scale; + out.scale = scale; + out.visible = true; + return out; +} + +/** A blank {@link Projected} to reuse with {@link projectViewInto}. */ +export function scratchProjected(): Projected { + return { x: 0, y: 0, scale: 0, visible: false }; +} + +export function rotateY(point: Vec3, angle: number): Vec3 { + const cos = Math.cos(angle); + const sin = Math.sin(angle); + return { x: point.x * cos + point.z * sin, y: point.y, z: -point.x * sin + point.z * cos }; +} + +export function rotateX(point: Vec3, angle: number): Vec3 { + const cos = Math.cos(angle); + const sin = Math.sin(angle); + return { x: point.x, y: point.y * cos - point.z * sin, z: point.y * sin + point.z * cos }; +} + +export interface BurstParticle { + /** Unit-ish launch direction (upper hemisphere biased). */ + direction: Vec3; + /** Launch speed multiplier in [0.35, 1]. */ + speed: number; + /** Size multiplier in [0.4, 1]. */ + size: number; + /** Hue offset in [-0.08, 0.08] around the burst's base hue. */ + hueShift: number; + /** Lifetime fraction in [0.55, 1] — some sparks burn out early. */ + life: number; +} + +/** + * `count` burst particles on a sphere, deterministic for `seed`. Directions are biased upward (screen −y) so a hit + * reads as sparks thrown off the judgement line rather than a symmetric puff. + */ +export function burstParticles(seed: number, count: number): BurstParticle[] { + const particles: BurstParticle[] = []; + for (let index = 0; index < count; index += 1) { + const base = seed * 97 + index * 13; + // Uniform sphere sampling via (cos θ, φ), then squash the lower hemisphere. + const cosTheta = 1 - 2 * hash01(base + 1); + const sinTheta = Math.sqrt(Math.max(0, 1 - cosTheta * cosTheta)); + const phi = Math.PI * 2 * hash01(base + 2); + const rawY = cosTheta; + particles.push({ + direction: { + x: sinTheta * Math.cos(phi), + y: rawY > 0 ? -rawY : rawY * -0.35, + z: sinTheta * Math.sin(phi), + }, + speed: 0.35 + 0.65 * hash01(base + 3), + size: 0.4 + 0.6 * hash01(base + 4), + hueShift: (hash01(base + 5) - 0.5) * 0.16, + life: 0.55 + 0.45 * hash01(base + 6), + }); + } + return particles; +} + +/** + * Position of burst particle `particle` at normalized time `t` (0..1): ease-out launch with drag, plus a little + * gravity pulling sparks back down late in their life. + */ +export function burstParticlePosition(particle: BurstParticle, t: number, radius: number, gravity: number): Vec3 { + const clamped = Math.max(0, Math.min(1, t)); + const travel = (1 - (1 - clamped) ** 3) * radius * particle.speed; + return { + x: particle.direction.x * travel, + y: particle.direction.y * travel + gravity * clamped * clamped, + z: particle.direction.z * travel, + }; +} + +/** + * Star `index` of a looping starfield flying toward the camera. Depth cycles over `[near, far]` at `speed` units/s; + * lateral position is a fixed per-star spread in `[-spread, spread]`. + */ +/** {@link starfieldPoint} written into `out` instead of a fresh object. */ +export function starfieldInto( + out: Vec3, + index: number, + seconds: number, + options: { spread: number; near: number; far: number; speed: number }, +): Vec3 { + const span = options.far - options.near; + const phase = hash01(index * 7 + 3) * span; + out.x = (hash01(index * 7 + 1) * 2 - 1) * options.spread; + out.y = (hash01(index * 7 + 2) * 2 - 1) * options.spread * 0.62; + out.z = options.far - ((seconds * options.speed + phase) % span); + return out; +} + +export function starfieldPoint( + index: number, + seconds: number, + options: { spread: number; near: number; far: number; speed: number }, +): Vec3 { + const span = options.far - options.near; + const phase = hash01(index * 7 + 3) * span; + const z = options.far - ((seconds * options.speed + phase) % span); + return { + x: (hash01(index * 7 + 1) * 2 - 1) * options.spread, + y: (hash01(index * 7 + 2) * 2 - 1) * options.spread * 0.62, + z, + }; +} + +/** HSV (all in 0..1, hue wrapping) to a 0xRRGGBB number. */ +export function hsvToHex(hue: number, saturation: number, value: number): number { + const h = (((hue % 1) + 1) % 1) * 6; + const s = Math.max(0, Math.min(1, saturation)); + const v = Math.max(0, Math.min(1, value)); + const sector = Math.floor(h); + const fraction = h - sector; + const p = v * (1 - s); + const q = v * (1 - s * fraction); + const t = v * (1 - s * (1 - fraction)); + // Allocation-free sector switch: this runs for thousands of particles per frame. + let r: number; + let g: number; + let b: number; + switch (sector % 6) { + case 0: + r = v; + g = t; + b = p; + break; + case 1: + r = q; + g = v; + b = p; + break; + case 2: + r = p; + g = v; + b = t; + break; + case 3: + r = p; + g = q; + b = v; + break; + case 4: + r = t; + g = p; + b = v; + break; + default: + r = v; + g = p; + b = q; + } + return (Math.round(r * 255) << 16) | (Math.round(g * 255) << 8) | Math.round(b * 255); +} + +/** One point of a point-cloud shape: a position plus a per-point size / brightness weight in [0.3, 1]. */ +export interface CloudPoint extends Vec3 { + weight: number; +} + +/** {@link emberColor}'s ramp: `[t, r, g, b]` stops, hoisted so the hot path allocates nothing. */ +const EMBER_STOPS: ReadonlyArray = [ + [0, 0xb8, 0x2c, 0x08], + [0.35, 0xff, 0x66, 0x12], + [0.65, 0xff, 0xaa, 0x30], + [0.85, 0xff, 0xd8, 0x86], + [1, 0xff, 0xf6, 0xe2], +]; + +/** + * Ember → gold → white-hot ramp for `t` in 0..1 (clamped) — the warm light that dominates a cosmic particle + * worlds. Low `t` is a deep red-orange ember, high `t` a pale gold flare. + */ +export function emberColor(t: number): number { + const clamped = Math.max(0, Math.min(1, Number.isFinite(t) ? t : 0)); + let index = 1; + while (index < EMBER_STOPS.length - 1 && clamped > EMBER_STOPS[index]![0]) index += 1; + const from = EMBER_STOPS[index - 1]!; + const to = EMBER_STOPS[index]!; + const f = (clamped - from[0]) / (to[0] - from[0]); + const r = Math.round(from[1] + (to[1] - from[1]) * f); + const g = Math.round(from[2] + (to[2] - from[2]) * f); + const b = Math.round(from[3] + (to[3] - from[3]) * f); + return (r << 16) | (g << 8) | b; +} + +/** + * Square pyramid as a point cloud: base half-width 1 on `y = 0`, apex at `y = -1.3` (screen up). Four in ten points + * sit on the edges so the silhouette reads crisply; the rest scatter over the four faces. Deterministic for `seed`. + */ +export function pointCloudPyramid(seed: number, count: number): CloudPoint[] { + const apex: Vec3 = { x: 0, y: -1.3, z: 0 }; + const corners: Vec3[] = [ + { x: -1, y: 0, z: -1 }, + { x: 1, y: 0, z: -1 }, + { x: 1, y: 0, z: 1 }, + { x: -1, y: 0, z: 1 }, + ]; + const points: CloudPoint[] = []; + for (let index = 0; index < count; index += 1) { + const base = seed * 131 + index * 17; + const face = Math.floor(hash01(base + 1) * 4) % 4; + const a = corners[face]!; + const b = corners[(face + 1) % 4]!; + const weight = 0.3 + 0.7 * hash01(base + 4); + if (hash01(base + 2) < 0.4) { + // Edge point: either a side edge (corner → apex) or a base edge. + const along = hash01(base + 3); + const from = hash01(base + 5) < 0.7 ? apex : b; + points.push({ ...lerp3(a, from, along), weight: Math.min(1, weight + 0.25) }); + continue; + } + // Face point via uniform barycentric sampling of the triangle (a, b, apex). + let u = hash01(base + 3); + let v = hash01(base + 5); + if (u + v > 1) { + u = 1 - u; + v = 1 - v; + } + points.push({ + x: a.x + (b.x - a.x) * u + (apex.x - a.x) * v, + y: a.y + (b.y - a.y) * u + (apex.y - a.y) * v, + z: a.z + (b.z - a.z) * u + (apex.z - a.z) * v, + weight, + }); + } + return points; +} + +/** + * `count` points spread evenly over the unit sphere (a Fibonacci lattice), each with a per-point size / brightness + * weight in [0.3, 1]. Deterministic for `seed` (which only varies the weights). + */ +export function fibonacciSphere(count: number, seed: number): CloudPoint[] { + const points: CloudPoint[] = []; + const golden = Math.PI * (3 - Math.sqrt(5)); + for (let index = 0; index < count; index += 1) { + const y = count > 1 ? 1 - (2 * index) / (count - 1) : 0; + const ring = Math.sqrt(Math.max(0, 1 - y * y)); + const theta = golden * index; + points.push({ + x: Math.cos(theta) * ring, + y, + z: Math.sin(theta) * ring, + weight: 0.3 + 0.7 * hash01(seed * 173 + index), + }); + } + return points; +} + +/** + * Limb brightening for a shell point whose view-space normal has depth component `normalZ` (−1 facing the camera, + * 0 on the silhouette, +1 facing away): 1 on the silhouette falling to `floor` at the centre of the disc, the way a + * glowing shell reads brightest at its edge. + */ +export function limbGlow(normalZ: number, floor = 0.25): number { + const edge = 1 - Math.min(1, Math.abs(Number.isFinite(normalZ) ? normalZ : 0)); + return floor + (1 - floor) * edge ** 1.5; +} + +/** + * One particle of the audio orb: a light riding a tilted circular orbit around the core, tied to a + * spectrum band. + */ +export interface OrbitParticle { + /** Orbit radius as a multiple of the core radius, [1.3, 2.8]. */ + reach: number; + /** Orbit-plane tilts (radians). */ + tiltX: number; + tiltZ: number; + /** Angular speed (rad / s), signed. */ + speed: number; + phase: number; + /** Spectrum band (0..15) the orbit breathes with. */ + band: number; + /** Size / brightness weight in [0.4, 1]. */ + weight: number; +} + +/** `count` orbit particles, deterministic for `seed`. */ +export function orbitParticles(seed: number, count: number): OrbitParticle[] { + const particles: OrbitParticle[] = []; + for (let index = 0; index < count; index += 1) { + const base = seed * 263 + index * 29; + particles.push({ + reach: 1.3 + 1.5 * hash01(base + 1) ** 1.4, + tiltX: (hash01(base + 2) - 0.5) * Math.PI, + tiltZ: (hash01(base + 3) - 0.5) * Math.PI, + speed: (0.5 + 1.4 * hash01(base + 4)) * (hash01(base + 5) > 0.5 ? 1 : -1), + phase: hash01(base + 6) * Math.PI * 2, + band: Math.floor(hash01(base + 7) * 16) % 16, + weight: 0.4 + 0.6 * hash01(base + 8), + }); + } + return particles; +} + +/** Position of `particle` at orbit `angle` on a circle of `radius` (in the orb's local frame, centred on the core). */ +export function orbitPosition(particle: OrbitParticle, angle: number, radius: number): Vec3 { + const flat: Vec3 = { x: Math.cos(angle) * radius, y: 0, z: Math.sin(angle) * radius }; + const tilted = rotateX(flat, particle.tiltX); + // Tilt about z: rotate the (x, y) pair. + const cos = Math.cos(particle.tiltZ); + const sin = Math.sin(particle.tiltZ); + return { x: tilted.x * cos - tilted.y * sin, y: tilted.x * sin + tilted.y * cos, z: tilted.z }; +} + +/** + * Particle `index` of a flowing river of light, in river-local space: it travels along `x` from `-length / 2` to + * `length / 2` at `speed` units/s (looping), weaving on a slow sine, with a fixed per-particle offset across the + * stream so the river has body. Deterministic for (`index`, `seconds`). + */ +export function particleRiverPoint( + index: number, + seconds: number, + options: { length: number; speed: number; amplitude: number; width: number; seed?: number }, +): Vec3 { + const base = (options.seed ?? 0) * 211 + index * 23; + const along = (hash01(base + 1) + (seconds * options.speed) / options.length) % 1; + const x = (along - 0.5) * options.length; + // Sum of two uniforms ≈ triangular spread: dense core, feathered edges. + const across = (hash01(base + 2) + hash01(base + 3) - 1) * options.width; + const depth = (hash01(base + 4) + hash01(base + 5) - 1) * options.width; + const wave = Math.sin(along * Math.PI * 2 * 1.5 + seconds * 0.6 + (options.seed ?? 0)) * options.amplitude; + return { x, y: wave + across, z: depth }; +} + +function lerp3(a: Vec3, b: Vec3, t: number): Vec3 { + return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t, z: a.z + (b.z - a.z) * t }; +} + +/** + * A camera pose for the background 3D space: an offset of the eye (`x` right, `y` down, `z` forward) plus `yaw` + * (positive turns right) and `pitch` (positive looks down), in radians. + */ +export interface CameraPose { + x: number; + y: number; + z: number; + yaw: number; + pitch: number; +} + +export const REST_CAMERA: CameraPose = { x: 0, y: 0, z: 0, yaw: 0, pitch: 0 }; + +/** Blend between two poses (`t` = 0 → `from`, 1 → `to`); `mixCamera(REST_CAMERA, pose, 0.5)` halves a move. */ +export function mixCamera(from: CameraPose, to: CameraPose, t: number): CameraPose { + const mix = (a: number, b: number) => a + (b - a) * t; + return { + x: mix(from.x, to.x), + y: mix(from.y, to.y), + z: mix(from.z, to.z), + yaw: mix(from.yaw, to.yaw), + pitch: mix(from.pitch, to.pitch), + }; +} + +/** + * World `point` seen from `camera`: translate by the eye offset, then turn by yaw / pitch about a pivot `orbit` units + * ahead (0 turns the camera in place; a positive value circles the camera around a subject at that depth). + */ +export function viewPoint(point: Vec3, camera: CameraPose, orbit = 0): Vec3 { + const local = { x: point.x - camera.x, y: point.y - camera.y, z: point.z - camera.z - orbit }; + const turned = rotateX(rotateY(local, -camera.yaw), camera.pitch); + return { x: turned.x, y: turned.y, z: turned.z + orbit }; +} + +/** Screen position of the direction straight ahead in world space (+z) — where the floor's horizon and warp meet. */ +export function vanishingPoint(camera: CameraPose, cx: number, cy: number, focal: number): { x: number; y: number } { + const direction = rotateX(rotateY({ x: 0, y: 0, z: 1 }, -camera.yaw), camera.pitch); + if (direction.z <= 1e-6) return { x: cx, y: cy }; + return { x: cx + (direction.x / direction.z) * focal, y: cy + (direction.y / direction.z) * focal }; +} + +/** Per-axis extents a roaming camera may wander within (each axis spans `[-range, range]`). */ +export interface CameraRange { + x: number; + y: number; + yaw: number; + pitch: number; +} + +/** Shot `index` of a seeded random sequence: a pose inside `range`, deterministic for (`index`, `seed`). */ +export function randomShot(index: number, seed: number, range: CameraRange): CameraPose { + const roll = (offset: number) => hash01(seed * 977 + index * 31 + offset) * 2 - 1; + // Shot 0 opens at rest so every scene starts on the composed default view. + if (index === 0) return REST_CAMERA; + return { x: roll(1) * range.x, y: roll(2) * range.y, z: 0, yaw: roll(3) * range.yaw, pitch: roll(4) * range.pitch }; +} + +/** + * A roaming camera: every `cycleSeconds` a new random shot (see {@link randomShot}). Within each cycle the camera holds, + * then flies to the next shot over a random 1–3.5 s — or, one cycle in five, hard-cuts to it — and a slow handheld + * drift (incommensurate sines, `drift` × the range) keeps it breathing between moves. + */ +export function roamingCamera( + seconds: number, + seed: number, + range: CameraRange, + cycleSeconds: number, + drift = 0.08, +): CameraPose { + const time = Number.isFinite(seconds) ? seconds : 0; + const step = Math.floor(time / cycleSeconds); + const local = time - step * cycleSeconds; + const from = randomShot(step, seed, range); + const to = randomShot(step + 1, seed, range); + const cut = hash01(seed * 53 + step * 7) < 0.2; + const move = cut ? 0.18 : 1 + 2.5 * hash01(seed * 61 + step * 11); + const hold = cycleSeconds - move; + const raw = local <= hold ? 0 : Math.min(1, (local - hold) / move); + const pose = mixCamera(from, to, raw * raw * (3 - 2 * raw)); + const wobble = (a: number, b: number, c: number) => + (Math.sin(time * a) + Math.sin(time * b + 1.3) * 0.6 + Math.sin(time * c + 2.1) * 0.3) / 1.9; + return { + x: pose.x + wobble(0.31, 0.53, 1.07) * range.x * drift, + y: pose.y + wobble(0.27, 0.61, 0.97) * range.y * drift, + z: pose.z, + yaw: pose.yaw + wobble(0.23, 0.47, 1.13) * range.yaw * drift, + pitch: pose.pitch + wobble(0.29, 0.43, 0.89) * range.pitch * drift, + }; +} + +/** An axis-aligned box in world space. */ +export interface Box3 { + minX: number; + maxX: number; + minY: number; + maxY: number; + minZ: number; + maxZ: number; +} + +/** + * A free-roaming position inside `bounds` at `seconds`: each axis sums two incommensurate sines (phases from `seed`), + * so the path wanders smoothly and never visibly repeats. Always inside the box. + */ +export function wanderPoint(seconds: number, seed: number, bounds: Box3): Vec3 { + const t = Number.isFinite(seconds) ? seconds : 0; + const axis = (a: number, b: number, offset: number) => + (Math.sin(t * a + hash01(seed * 31 + offset) * 6.283) * 0.62 + + Math.sin(t * b + hash01(seed * 37 + offset) * 6.283) * 0.38) * + 0.5 + + 0.5; + const mix = (min: number, max: number, u: number) => min + (max - min) * u; + return { + x: mix(bounds.minX, bounds.maxX, axis(0.137, 0.311, 1)), + y: mix(bounds.minY, bounds.maxY, axis(0.193, 0.427, 2)), + z: mix(bounds.minZ, bounds.maxZ, axis(0.101, 0.263, 3)), + }; +} + +/** + * How a glossy body at screen offset (`dx`, `dy`) *toward* a light source `distance` px away catches it: `angle` is + * the direction of the light on screen, and `strength` (0..1) falls off with distance in units of the source's + * radius and rises with its `energy` (0..1, e.g. the music's level). + */ +export function reflectedLight( + dx: number, + dy: number, + distance: number, + sourceRadius: number, + energy: number, +): { angle: number; strength: number } { + const angle = Math.atan2(dy, dx); + const reach = Math.max(1e-6, sourceRadius) * 2.2; + const falloff = Math.min(1, reach / Math.max(distance, sourceRadius)); + const strength = Math.max(0, Math.min(1, falloff * (0.45 + 0.55 * Math.max(0, Math.min(1, energy))))); + return { angle, strength }; +} + +/** Axis-aligned rectangle (x / y / w / h) for {@link clipSegmentOutsideRect}. */ +export interface ClipRect { + x: number; + y: number; + w: number; + h: number; +} + +/** + * The parts of the segment (x0, y0) → (x1, y1) that lie outside `rect`, appended to `out` as `x0, y0, x1, y1` quads: + * the whole segment when it misses the rect, one or two pieces when it crosses it, nothing when it lies inside. Lets + * the space's floor lines run up to a live BGA without being drawn over it. Returns the number of pieces appended. + */ +export function clipSegmentOutsideRect( + x0: number, + y0: number, + x1: number, + y1: number, + rect: ClipRect, + out: number[], +): number { + const dx = x1 - x0; + const dy = y1 - y0; + // Liang–Barsky: narrow [enter, exit] to the part of the segment inside the rect. + let enter = 0; + let exit = 1; + const edges: ReadonlyArray = [ + [-dx, x0 - rect.x], + [dx, rect.x + rect.w - x0], + [-dy, y0 - rect.y], + [dy, rect.y + rect.h - y0], + ]; + for (const [p, q] of edges) { + if (p === 0) { + if (q < 0) { + enter = 1; + exit = 0; + break; + } + continue; + } + const t = q / p; + if (p < 0) enter = Math.max(enter, t); + else exit = Math.min(exit, t); + } + if (enter >= exit) { + out.push(x0, y0, x1, y1); + return 1; + } + let pieces = 0; + if (enter > 0) { + out.push(x0, y0, x0 + dx * enter, y0 + dy * enter); + pieces += 1; + } + if (exit < 1) { + out.push(x0 + dx * exit, y0 + dy * exit, x1, y1); + pieces += 1; + } + return pieces; +} diff --git a/packages/player-web/src/scene/default/synesthesia/style.ts b/packages/player-web/src/skins/synesthesia/style.ts similarity index 60% rename from packages/player-web/src/scene/default/synesthesia/style.ts rename to packages/player-web/src/skins/synesthesia/style.ts index a6fbd3cf..ffe48e87 100644 --- a/packages/player-web/src/scene/default/synesthesia/style.ts +++ b/packages/player-web/src/skins/synesthesia/style.ts @@ -1,33 +1,37 @@ import { Texture } from 'pixi.js'; /** - * Synesthesia skin tokens: a void-to-indigo space, neon cyan / magenta / amber light, and glass panels with glowing - * rims. Type is wide and light — Michroma for Latin labels and numerals, M PLUS 1p Light for Japanese and titles. + * Synesthesia skin tokens, after a cosmic particle world: a true-black void, a world built from ember / gold particles, + * electric blue and magenta as the only cool accents, and hairline frames with lock-on corners instead of glass. + * Type is wide and light — Michroma for Latin labels and numerals, M PLUS 1p Light for Japanese and titles. */ -export const SYN_VOID = 0x020309; -export const SYN_DEEP = 0x0b0826; -export const SYN_INDIGO = 0x1a1150; -export const SYN_GLASS = 0x0a0d26; -export const SYN_CYAN = 0x5ff4ff; -export const SYN_MAGENTA = 0xff4fd8; -export const SYN_AMBER = 0xffc76b; -export const SYN_VIOLET = 0x8f74ff; -export const SYN_GREEN = 0x6dffb0; -export const SYN_RED = 0xff5a7a; +export const SYN_VOID = 0x010000; +export const SYN_DEEP = 0x0c0402; +export const SYN_INDIGO = 0x1a0904; +export const SYN_GLASS = 0x060302; +/** Electric blue — the cool accent (black keys, below-clear gauge, FAST). */ +export const SYN_CYAN = 0x46c4ff; +export const SYN_MAGENTA = 0xff3d9e; +export const SYN_EMBER = 0xff6a14; +export const SYN_AMBER = 0xffb23a; +export const SYN_FLARE = 0xffe7b8; +export const SYN_VIOLET = 0x9c7cff; +export const SYN_GREEN = 0x7dffb4; +export const SYN_RED = 0xff4a4a; export const SYN_WHITE = 0xffffff; -export const SYN_MIST = 0xb9c6ff; -export const SYN_DIM = 0x6c73a8; +export const SYN_MIST = 0xf1dcc8; +export const SYN_DIM = 0x9a7a66; export const SYN_DISPLAY_FONT = 'Michroma, Exo 2, M PLUS 1p, LINE Seed JP, sans-serif'; export const SYN_TEXT_FONT = 'M PLUS 1p, LINE Seed JP, ui-sans-serif, system-ui, sans-serif'; /** - * Base hue of the whole scene at `seconds`: a slow drift through the cyan → violet → magenta band, nudged forward on - * each beat so colour literally keeps time with the music. + * Base hue of the whole scene at `seconds`: a slow drift through the red-orange → amber band (the world's ember light), + * nudged forward on each beat so colour literally keeps time with the music. */ export function sceneHue(seconds: number, beatPhase = 0): number { - const drift = 0.5 + 0.12 * Math.sin(seconds * 0.11); - return drift + 0.03 * (1 - beatPhase) ** 2; + const drift = 0.075 + 0.025 * Math.sin(seconds * 0.11); + return drift + 0.015 * (1 - beatPhase) ** 2; } let glowTexture: Texture | undefined; diff --git a/packages/player-web/vite.config.ts b/packages/player-web/vite.config.ts index c49a045a..ab7fcd74 100644 --- a/packages/player-web/vite.config.ts +++ b/packages/player-web/vite.config.ts @@ -15,6 +15,7 @@ export default defineConfig({ chart: 'src/chart/index.ts', collection: 'src/collection/index.ts', runtime: 'src/runtime/index.ts', + // Everything a be-music skin is written against (the built-in skins use nothing else). }, }), }); diff --git a/packages/player/src/core/lane-layout.test.ts b/packages/player/src/core/lane-layout.test.ts index 343d0e31..0dde77c4 100644 --- a/packages/player/src/core/lane-layout.test.ts +++ b/packages/player/src/core/lane-layout.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from 'vite-plus/test'; import { createLaneBindings, resolveLaneChannels, + resolvePlayVariantLaneChannels, resolveLaneDisplayMode, resolveLr2LaneIndex, resolveSideKeySlot, @@ -251,3 +252,78 @@ describe('resolveSideRelativeLaneIndex', () => { expect(resolveSideRelativeLaneIndex('25', '9')).toBe(9); }); }); + +describe('resolvePlayVariantLaneChannels', () => { + const only = (...channels: string[]) => channels.map((channel) => ({ channel })); + + test('lays out every lane of the variant even when the chart uses one', () => { + expect(resolvePlayVariantLaneChannels(only('11'), '5')).toEqual(['16', '11', '12', '13', '14', '15']); + expect(resolvePlayVariantLaneChannels(only('11'), '7')).toEqual(['16', '11', '12', '13', '14', '15', '18', '19']); + }); + + test('double play lays out both sides, 2P scratch first in rendering order', () => { + expect(resolvePlayVariantLaneChannels(only('11', '21'), '10')).toEqual([ + '16', + '11', + '12', + '13', + '14', + '15', + '26', + '21', + '22', + '23', + '24', + '25', + ]); + expect(resolvePlayVariantLaneChannels(only('11'), '14')).toHaveLength(16); + }); + + test('picks the 9 KEY layout from the channels in use', () => { + expect(resolvePlayVariantLaneChannels(only('11', '17'), '9')).toEqual([ + '11', + '12', + '13', + '14', + '15', + '16', + '17', + '18', + '19', + ]); + expect(resolvePlayVariantLaneChannels(only('11', '23'), '9')).toEqual([ + '11', + '12', + '13', + '14', + '15', + '22', + '23', + '24', + '25', + ]); + }); + + test('lays out all 24 / 48 keyboard columns', () => { + expect(resolvePlayVariantLaneChannels(only('11'), '24')).toHaveLength(24); + expect(resolvePlayVariantLaneChannels(only('11'), '48')).toHaveLength(48); + }); + + test('keeps a lane in use outside the variant', () => { + expect(resolvePlayVariantLaneChannels(only('11', '21'), '7')).toEqual([ + '16', + '11', + '12', + '13', + '14', + '15', + '18', + '19', + '21', + ]); + }); + + test('without a variant, lists only the lanes in use', () => { + expect(resolvePlayVariantLaneChannels(only('11', '15'))).toEqual(['11', '15']); + }); +}); diff --git a/packages/player/src/core/lane-layout.ts b/packages/player/src/core/lane-layout.ts index 1eff44f6..82a89f0e 100644 --- a/packages/player/src/core/lane-layout.ts +++ b/packages/player/src/core/lane-layout.ts @@ -183,14 +183,51 @@ export function isPlayableInputChannel(channel: string): boolean { } export function resolveLaneChannels(notes: ReadonlyArray, playVariant?: ChartPlayVariant): string[] { - const preferred = - playVariant === '24' || playVariant === '48' - ? KEYBOARD_MODE_PREFERRED_CHANNELS[playVariant] - : playVariant === '9' - ? ['11', '12', '13', '14', '15', '16', '17', '18', '19', '22', '23', '24', '25'] - : ['16', '11', '12', '13', '14', '15', '18', '19', '26', '21', '22', '23', '24', '25', '28', '29']; const used = new Set(notes.map((note) => note.channel).filter(isPlayableInputChannel)); - return preferred.filter((channel) => used.has(channel)); + return preferredLaneOrder(playVariant).filter((channel) => used.has(channel)); +} + +/** Left-to-right rendering order of every lane a play variant can use. */ +function preferredLaneOrder(playVariant?: ChartPlayVariant): readonly string[] { + if (playVariant === '24' || playVariant === '48') return KEYBOARD_MODE_PREFERRED_CHANNELS[playVariant]; + if (playVariant === '9') return ['11', '12', '13', '14', '15', '16', '17', '18', '19', '22', '23', '24', '25']; + return ['16', '11', '12', '13', '14', '15', '18', '19', '26', '21', '22', '23', '24', '25', '28', '29']; +} + +/** Every lane of each play variant (9 KEY has two layouts, see {@link resolvePlayVariantLaneChannels}). */ +const PLAY_VARIANT_LANES: Record<'5' | '7' | '10' | '14', readonly string[]> = { + '5': ['16', '11', '12', '13', '14', '15'], + '7': ['16', '11', '12', '13', '14', '15', '18', '19'], + '10': ['16', '11', '12', '13', '14', '15', '26', '21', '22', '23', '24', '25'], + '14': ['16', '11', '12', '13', '14', '15', '18', '19', '26', '21', '22', '23', '24', '25', '28', '29'], +}; +const POPN_9KEY_BME_LANES = ['11', '12', '13', '14', '15', '16', '17', '18', '19']; +const POPN_9KEY_PMS_LANES = ['11', '12', '13', '14', '15', '22', '23', '24', '25']; + +/** + * The full lane set of a chart's play variant, in rendering order — every lane of the 5 / 7 / 9 / 10 / 14 / 24 / + * 48 KEY layout, whether or not the chart puts notes on it, so a chart that only uses a few lanes still plays on the + * whole keyboard. 9 KEY takes the PMS layout (`22..25` on the right) when the chart uses any of those channels, the + * BME layout (`11..19`) otherwise. Lanes the chart uses outside the variant's set are kept too, so no note loses its + * lane. Without a variant this is {@link resolveLaneChannels}: just the lanes in use. + */ +export function resolvePlayVariantLaneChannels( + notes: ReadonlyArray, + playVariant?: ChartPlayVariant, +): string[] { + const used = resolveLaneChannels(notes, playVariant); + if (!playVariant) return used; + let variantLanes: readonly string[]; + if (playVariant === '24' || playVariant === '48') { + variantLanes = KEYBOARD_MODE_PREFERRED_CHANNELS[playVariant]; + } else if (playVariant === '9') { + const pms = used.some((channel) => POPN_9KEY_PMS_LANES.includes(channel) && !POPN_9KEY_BME_LANES.includes(channel)); + variantLanes = pms ? POPN_9KEY_PMS_LANES : POPN_9KEY_BME_LANES; + } else { + variantLanes = PLAY_VARIANT_LANES[playVariant]; + } + const lanes = new Set([...variantLanes, ...used]); + return preferredLaneOrder(playVariant).filter((channel) => lanes.has(channel)); } const IIDX_5KEY_SP_BINDINGS: FixedLaneDefinition[] = [ diff --git a/packages/skin-sdk/LICENSE b/packages/skin-sdk/LICENSE new file mode 100644 index 00000000..5a266f50 --- /dev/null +++ b/packages/skin-sdk/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 nulltask + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/skin-sdk/package.json b/packages/skin-sdk/package.json new file mode 100644 index 00000000..c150e684 --- /dev/null +++ b/packages/skin-sdk/package.json @@ -0,0 +1,34 @@ +{ + "name": "@be-music/skin-sdk", + "version": "0.0.0", + "description": "Framework-free SDK for writing be-music skins for the be-music browser player", + "license": "MIT", + "files": [ + "dist" + ], + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "source": "./src/index.ts", + "default": "./dist/index.js" + } + }, + "scripts": { + "build:bundle": "vp pack", + "build": "vp pack", + "clean": "rimraf dist tsconfig.tsbuildinfo", + "bench": "node --conditions=source scripts/bench.ts" + }, + "dependencies": { + "@be-music/audio-renderer": "workspace:*", + "@be-music/chart": "workspace:*", + "@be-music/json": "workspace:*", + "@be-music/player": "workspace:*" + }, + "devDependencies": { + "@be-music/parser": "workspace:*" + } +} diff --git a/packages/skin-sdk/scripts/bench.ts b/packages/skin-sdk/scripts/bench.ts new file mode 100644 index 00000000..b770e923 --- /dev/null +++ b/packages/skin-sdk/scripts/bench.ts @@ -0,0 +1,3 @@ +import packageBenchmarkCli from '../../../scripts/bench/package-cli.ts'; + +packageBenchmarkCli.runPackageExportsBenchmarkScript('skin-sdk', import.meta.url); diff --git a/packages/skin-sdk/scripts/exports-cases.ts b/packages/skin-sdk/scripts/exports-cases.ts new file mode 100644 index 00000000..5cc64f98 --- /dev/null +++ b/packages/skin-sdk/scripts/exports-cases.ts @@ -0,0 +1,302 @@ +import { parseChart } from '@be-music/parser'; +import * as skinSdkApi from '@be-music/skin-sdk'; +import type { DefineBenchmarkCase } from '../../../scripts/bench/exports.types.ts'; + +const BENCH_CHART = parseChart( + ['#BPM 150', '#WAV01 a.wav', '#00111:01010101', '#00112:00010001', '#00216:01000000', '#00311:01000000'].join('\n'), + 'bms', +); +const BENCH_SONG = { + id: 'bench', + sourceId: 'source', + sourceLabel: 'Bench', + sourceKind: 'files', + chartPath: 'Songs/Bench/main.bms', + directoryLabel: 'Bench', + fileLabel: 'main.bms', + title: 'Bench', + artist: 'be-music', + bpm: 150, + totalNotes: 8, + chart: BENCH_CHART, +} satisfies skinSdkApi.BrowserSongEntry; +const BENCH_STATS = skinSdkApi.resolveSongStats(BENCH_SONG); +const BENCH_SCORE = { + total: 100, + perfect: 60, + great: 30, + good: 5, + bad: 3, + poor: 2, + emptyPoor: 0, + exScore: 150, + score: 75_000, +}; +const BENCH_RESULT = { + score: BENCH_SCORE, + maxCombo: 42, + gauge: 82, + cleared: true, + playSeconds: 95, + song: BENCH_SONG, + gaugeHistory: [{ progress: 0, value: 20 }], + scoreHistory: [{ progress: 0, exScore: 0 }], +} satisfies skinSdkApi.BeMusicResultData; +const BENCH_CHANNELS_7K = ['16', '11', '12', '13', '14', '15', '18', '19']; +const BENCH_LAYOUT_INPUT = { laneChannels: BENCH_CHANNELS_7K, playVariant: '7' } as const; +const BENCH_LANES = skinSdkApi.resolveGameplayLayout(BENCH_LAYOUT_INPUT, skinSdkApi.wideStage).lanes; +const BENCH_MOMENT_INPUT = { + nowMs: 12_000, + combo: 120, + gauge: 84, + clearThreshold: 80, + survival: false, + judged: 120, + totalNotes: 400, + bad: 0, + poor: 0, +}; +const BENCH_MOMENT_KEY = {}; +const BENCH_AUDIO = { + ...skinSdkApi.SILENT_AUDIO, + level: 0.6, + bass: 0.7, + bands: Array.from({ length: skinSdkApi.AUDIO_BAND_COUNT }, (_, index) => index / skinSdkApi.AUDIO_BAND_COUNT), +}; +const BENCH_PALETTE = [0xffffff, 0xffd84a, 0x4ad8ff]; +const BENCH_SKIN = { + apiVersion: skinSdkApi.BE_MUSIC_SKIN_API_VERSION, + id: 'bench', + label: 'Bench', + version: '1.0.0', + author: { name: 'be-music' }, + fontLoads: [], + context: '2d', + gameplay: { draw: () => {} }, + select: { layout: { listX: 0, listTop: 0, listBottomInset: 0, rowHeight: 20 }, draw: () => {} }, + result: { draw: () => {} }, +} satisfies skinSdkApi.BeMusicSkin<'2d'>; + +export function registerSkinSdkExportsCases(define: DefineBenchmarkCase): void { + define('skin-sdk.audioDrive', { + run: () => { + skinSdkApi.audioDrive(BENCH_AUDIO, 'reduced'); + }, + }); + define('skin-sdk.bandAt', { + run: () => { + skinSdkApi.bandAt(BENCH_AUDIO.bands, 5, 24); + }, + }); + define('skin-sdk.bandLevel', { + run: () => { + skinSdkApi.bandLevel(BENCH_AUDIO.bands, 5, 24); + }, + }); + define('skin-sdk.comboTier', { + run: () => { + skinSdkApi.comboTier(120); + }, + }); + define('skin-sdk.createMomentState', { + run: () => { + skinSdkApi.createMomentState(); + }, + }); + define('skin-sdk.defineBeMusicSkin', { + run: () => { + skinSdkApi.defineBeMusicSkin(BENCH_SKIN); + }, + }); + define('skin-sdk.easeOutBack', { + run: () => { + skinSdkApi.easeOutBack(0.6); + }, + }); + define('skin-sdk.easeOutCubic', { + run: () => { + skinSdkApi.easeOutCubic(0.6); + }, + }); + define('skin-sdk.effectProfile', { + run: () => { + skinSdkApi.effectProfile('reduced'); + }, + }); + define('skin-sdk.flashingGreatColor', { + run: () => { + skinSdkApi.flashingGreatColor(1234, BENCH_PALETTE); + }, + }); + define('skin-sdk.formatBpmRange', { + run: () => { + skinSdkApi.formatBpmRange(120, 180); + }, + }); + define('skin-sdk.formatPlayVariantLabel', { + run: () => { + skinSdkApi.formatPlayVariantLabel(BENCH_SONG); + }, + }); + define('skin-sdk.formatSongLength', { + run: () => { + skinSdkApi.formatSongLength(125.4); + }, + }); + define('skin-sdk.hash01', { + run: () => { + skinSdkApi.hash01(1234); + }, + }); + define('skin-sdk.impulse', { + run: () => { + skinSdkApi.impulse(1000, 1100, 300); + }, + }); + define('skin-sdk.isFlashingGreat', { + run: () => { + skinSdkApi.isFlashingGreat('PERFECT'); + }, + }); + define('skin-sdk.isScratchLaneForVariant', { + run: () => { + skinSdkApi.isScratchLaneForVariant('16', '7'); + }, + }); + define('skin-sdk.isSupportedBeMusicSkinApiVersion', { + run: () => { + skinSdkApi.isSupportedBeMusicSkinApiVersion(skinSdkApi.BE_MUSIC_SKIN_API_VERSION); + }, + }); + define('skin-sdk.judgeDisplayWord', { + run: () => { + skinSdkApi.judgeDisplayWord('PERFECT'); + }, + }); + define('skin-sdk.layoutTabularRun', { + run: () => { + skinSdkApi.layoutTabularRun('1,234,567', (char) => (char === ',' ? 4 : 8)); + }, + }); + define('skin-sdk.loadingDots', { + run: () => { + skinSdkApi.loadingDots(1234); + }, + }); + define('skin-sdk.momentProgress', { + run: () => { + skinSdkApi.momentProgress(1000, 1300, 900); + }, + }); + define('skin-sdk.punchScale', { + run: () => { + skinSdkApi.punchScale(1000, 1050, 180, 0.2); + }, + }); + define('skin-sdk.resolveBeMusicLaneKind', { + run: () => { + skinSdkApi.resolveBeMusicLaneKind('12', 2, '7'); + }, + }); + define('skin-sdk.resolveFallbackLaneLayout', { + run: () => { + skinSdkApi.resolveFallbackLaneLayout({ + channels: BENCH_CHANNELS_7K, + playVariant: '7', + x: 33, + w: 194, + fixedWidths: skinSdkApi.IIDX_LANE_WIDTHS, + }); + }, + }); + define('skin-sdk.resolveFallbackPlayfieldSpan', { + run: () => { + skinSdkApi.resolveFallbackPlayfieldSpan('24'); + }, + }); + define('skin-sdk.resolveGameplayLayout', { + run: () => { + skinSdkApi.resolveGameplayLayout(BENCH_LAYOUT_INPUT, skinSdkApi.wideStage); + }, + }); + define('skin-sdk.resolveLaneRuns', { + run: () => { + skinSdkApi.resolveLaneRuns(BENCH_LANES); + }, + }); + define('skin-sdk.resolveMilestoneArea', { + run: () => { + skinSdkApi.resolveMilestoneArea(260); + }, + }); + define('skin-sdk.resolveResultLamp', { + run: () => { + skinSdkApi.resolveResultLamp(BENCH_RESULT); + }, + }); + define('skin-sdk.resolveResultTrackRows', { + run: () => { + skinSdkApi.resolveResultTrackRows(BENCH_RESULT); + }, + }); + define('skin-sdk.resolveSkinlessLaneLayout', { + run: () => { + skinSdkApi.resolveSkinlessLaneLayout(BENCH_CHANNELS_7K, BENCH_CHANNELS_7K.length, '7'); + }, + }); + define('skin-sdk.resolveSongRowFacts', { + run: () => { + skinSdkApi.resolveSongRowFacts(BENCH_SONG); + }, + }); + define('skin-sdk.resolveSongStats', { + run: () => { + skinSdkApi.resolveSongStats(BENCH_SONG); + }, + }); + define('skin-sdk.resolveSongTags', { + run: () => { + skinSdkApi.resolveSongTags(BENCH_STATS); + }, + }); + define('skin-sdk.resolveStageBgaRect', { + run: () => { + skinSdkApi.resolveStageBgaRect(260); + }, + }); + define('skin-sdk.rollUpValue', { + run: () => { + skinSdkApi.rollUpValue(123_456, 0.4); + }, + }); + define('skin-sdk.shouldPreserveFallbackSideWidth', { + run: () => { + skinSdkApi.shouldPreserveFallbackSideWidth(BENCH_CHANNELS_7K, '14'); + }, + }); + define('skin-sdk.stageProgress', { + run: () => { + skinSdkApi.stageProgress(400, 100, 600); + }, + }); + define('skin-sdk.trackMoments', { + run: () => { + skinSdkApi.trackMoments(BENCH_MOMENT_KEY, BENCH_MOMENT_INPUT); + }, + }); + define('skin-sdk.updateMoments', { + run: () => { + skinSdkApi.updateMoments(skinSdkApi.createMomentState(), BENCH_MOMENT_INPUT); + }, + }); + define('skin-sdk.usesIidxLaneWidths', { + run: () => { + skinSdkApi.usesIidxLaneWidths('7'); + }, + }); + define('skin-sdk.validateBeMusicSkin', { + run: () => { + skinSdkApi.validateBeMusicSkin(BENCH_SKIN); + }, + }); +} diff --git a/packages/skin-sdk/src/audio-drive.test.ts b/packages/skin-sdk/src/audio-drive.test.ts new file mode 100644 index 00000000..8c2f5247 --- /dev/null +++ b/packages/skin-sdk/src/audio-drive.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vite-plus/test'; +import { SILENT_AUDIO } from './audio.ts'; +import { audioDrive, bandAt, bandLevel } from './audio-drive.ts'; + +const LOUD = { + ...SILENT_AUDIO, + level: 0.8, + peak: 0.9, + db: -6, + bass: 0.6, + mid: 0.4, + high: 0.2, + bands: Array.from({ length: 16 }, (_, index) => index / 15), + onset: 1, + onsetAtMs: 1234, +}; + +describe('audioDrive', () => { + it('passes the analysis through at full effects', () => { + const drive = audioDrive(LOUD, 'full'); + expect(drive.bass).toBe(0.6); + expect(drive.bands).toBe(LOUD.bands); + expect(drive.onsetAtMs).toBe(1234); + }); + + it('halves every reaction when reduced', () => { + const drive = audioDrive(LOUD, 'reduced'); + expect(drive.level).toBeCloseTo(0.4, 9); + expect(drive.bands[15]).toBeCloseTo(0.5, 9); + expect(drive.onset).toBe(0.5); + expect(drive.db).toBe(-6); + }); + + it('is silent when effects are off or there is no audio', () => { + for (const drive of [audioDrive(LOUD, 'off'), audioDrive(undefined, 'full')]) { + expect(drive.level).toBe(0); + expect(drive.onset).toBe(0); + expect(drive.onsetAtMs).toBeUndefined(); + expect(drive.bands.every((band) => band === 0)).toBe(true); + } + }); +}); + +describe('bandAt', () => { + const bands = [0, 0.25, 0.5, 1]; + + it('maps columns onto bands', () => { + expect([0, 1, 2, 3].map((index) => bandAt(bands, index, 4))).toEqual(bands); + expect([0, 1, 2, 3, 4, 5, 6, 7].map((index) => bandAt(bands, index, 8))).toEqual([ + 0, 0, 0.25, 0.25, 0.5, 0.5, 1, 1, + ]); + }); + + it('is zero for empty input', () => { + expect(bandAt([], 0, 4)).toBe(0); + expect(bandAt(bands, 0, 0)).toBe(0); + }); +}); + +describe('bandLevel', () => { + it('stretches quiet and loud bands to the full 0..1 range', () => { + expect(bandLevel([0.1, 0.1], 0, 2)).toBe(0); + expect(bandLevel([0.95, 0.95], 0, 2)).toBe(1); + }); + + it('keeps a mid-scale band near mid-height', () => { + const value = bandLevel([0.6, 0.6], 0, 2); + expect(value).toBeGreaterThan(0.35); + expect(value).toBeLessThan(0.65); + }); + + it('tilts toward the treble', () => { + const flat = [0.4, 0.4, 0.4, 0.4]; + expect(bandLevel(flat, 3, 4)).toBeGreaterThan(bandLevel(flat, 0, 4)); + }); +}); diff --git a/packages/skin-sdk/src/audio-drive.ts b/packages/skin-sdk/src/audio-drive.ts new file mode 100644 index 00000000..7cd3226e --- /dev/null +++ b/packages/skin-sdk/src/audio-drive.ts @@ -0,0 +1,60 @@ +import { AUDIO_BAND_COUNT, SILENT_AUDIO, type BeMusicAudioFrame } from './audio.ts'; + +/** + * Audio drive for built-in skins: the frame's audio features scaled by the effects level (full = as analysed, reduced + * = half, off = silent), so every audio-reactive visual honours the comfort setting in one place. + */ +export interface AudioDrive { + level: number; + peak: number; + db: number; + bass: number; + mid: number; + high: number; + bands: readonly number[]; + /** Onset envelope (1 → 0 over ~150 ms after each transient). */ + onset: number; + onsetAtMs: number | undefined; +} + +const SILENT_BANDS: readonly number[] = Object.freeze(Array.from({ length: AUDIO_BAND_COUNT }, () => 0)); + +export function audioDrive( + audio: BeMusicAudioFrame | undefined, + effects: 'full' | 'reduced' | 'off' | undefined, +): AudioDrive { + const amount = effects === 'off' ? 0 : effects === 'reduced' ? 0.5 : 1; + const source = audio ?? SILENT_AUDIO; + if (amount === 0 || !audio) { + return { ...SILENT_AUDIO, db: source.db, bands: SILENT_BANDS, onsetAtMs: undefined }; + } + return { + level: source.level * amount, + peak: source.peak * amount, + db: source.db, + bass: source.bass * amount, + mid: source.mid * amount, + high: source.high * amount, + bands: amount === 1 ? source.bands : source.bands.map((band) => band * amount), + onset: source.onset * amount, + onsetAtMs: source.onsetAtMs, + }; +} + +/** Band `index` resampled to `count` columns (nearest), for skins that draw fewer or more bars than bands. */ +export function bandAt(bands: readonly number[], index: number, count: number): number { + if (bands.length === 0 || count <= 0) return 0; + const band = Math.min(bands.length - 1, Math.max(0, Math.floor(((index + 0.5) / count) * bands.length))); + return bands[band] ?? 0; +} + +/** + * Display level of column `index` of `count` (0..1): {@link bandAt} with a slight treble tilt and the floor / ceiling + * stretched so bars use their full height. The analysed bands already follow each band's recent range, so a bar drops + * to the bottom between hits and reaches the top on them rather than resting near either end. + */ +export function bandLevel(bands: readonly number[], index: number, count: number): number { + const position = count > 1 ? index / (count - 1) : 0; + const tilted = bandAt(bands, index, count) * (1 + 0.3 * position); + return Math.max(0, Math.min(1, (tilted - 0.3) / 0.62)); +} diff --git a/packages/skin-sdk/src/audio.ts b/packages/skin-sdk/src/audio.ts new file mode 100644 index 00000000..14c1a9d6 --- /dev/null +++ b/packages/skin-sdk/src/audio.ts @@ -0,0 +1,38 @@ +/** Number of log-spaced spectrum bands in {@link BeMusicAudioFrame.bands}. */ +export const AUDIO_BAND_COUNT = 16; + +/** + * Live analysis of what is playing, sampled once per frame. Gameplay taps the whole mix; select taps the BGM and chart + * preview. Absent when the host has no Web Audio. + */ +export interface BeMusicAudioFrame { + /** Smoothed loudness in 0..1 (RMS mapped from -48 dBFS → 0 to 0 dBFS → 1). */ + readonly level: number; + /** Fast-attack, slow-release loudness in 0..1 — good for meters and flashes. */ + readonly peak: number; + /** Instantaneous RMS level in dBFS, clamped to [-96, 0]. */ + readonly db: number; + /** Smoothed energy in 0..1 below 150 Hz / 150 Hz–2 kHz / above 2 kHz. */ + readonly bass: number; + readonly mid: number; + readonly high: number; + /** {@link AUDIO_BAND_COUNT} log-spaced band energies (≈ 30 Hz → 14 kHz), each 0..1, smoothed. */ + readonly bands: readonly number[]; + /** 1 on a detected transient (kick, snare, stab), decaying toward 0 over ~150 ms. */ + readonly onset: number; + /** Clock ms of the most recent onset (`undefined` until the first). */ + readonly onsetAtMs: number | undefined; +} + +/** A frame of silence. */ +export const SILENT_AUDIO: BeMusicAudioFrame = Object.freeze({ + level: 0, + peak: 0, + db: -96, + bass: 0, + mid: 0, + high: 0, + bands: Object.freeze(Array.from({ length: AUDIO_BAND_COUNT }, () => 0)), + onset: 0, + onsetAtMs: undefined, +}); diff --git a/packages/skin-sdk/src/define.test.ts b/packages/skin-sdk/src/define.test.ts new file mode 100644 index 00000000..f4f253e0 --- /dev/null +++ b/packages/skin-sdk/src/define.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vite-plus/test'; +import type { BeMusicSkin } from './types.ts'; +import { + BE_MUSIC_SKIN_API_VERSION, + defineBeMusicSkin, + isSupportedBeMusicSkinApiVersion, + validateBeMusicSkin, +} from './define.ts'; + +function skin(overrides: Partial = {}): BeMusicSkin { + return { + apiVersion: BE_MUSIC_SKIN_API_VERSION, + id: 'neon', + label: 'Neon', + version: '1.2.0', + author: { name: 'Jane Doe', url: 'https://example.com/jane' }, + fontLoads: [], + context: '2d', + gameplay: { draw: () => {} }, + select: { layout: { listX: 0, listTop: 0, listBottomInset: 0, rowHeight: 20 }, draw: () => {} }, + result: { draw: () => {} }, + ...overrides, + }; +} + +describe('isSupportedBeMusicSkinApiVersion', () => { + it('accepts the current revision only', () => { + expect(isSupportedBeMusicSkinApiVersion(BE_MUSIC_SKIN_API_VERSION)).toBe(true); + expect(isSupportedBeMusicSkinApiVersion(BE_MUSIC_SKIN_API_VERSION + 1)).toBe(false); + expect(isSupportedBeMusicSkinApiVersion(0)).toBe(false); + }); +}); + +describe('validateBeMusicSkin', () => { + it('passes a well-formed declaration', () => { + expect(validateBeMusicSkin(skin())).toEqual([]); + expect(validateBeMusicSkin(skin({ version: '2.0.0-beta.1+build.5', homepage: 'https://example.com' }))).toEqual([]); + }); + + it('reports an unknown canvas context', () => { + expect(validateBeMusicSkin(skin({ context: 'canvas' as BeMusicSkin['context'] }))).toEqual([ + 'context "canvas" must be one of 2d, webgl, webgl2, webgpu', + ]); + }); + + it('reports a screen without a draw function', () => { + expect(validateBeMusicSkin(skin({ result: {} as BeMusicSkin['result'] }))).toEqual([ + 'result.draw must be a function', + ]); + }); + + it('reports an unsupported API revision', () => { + expect(validateBeMusicSkin(skin({ apiVersion: 99 }))[0]).toMatch(/apiVersion 99/u); + }); + + it('reports malformed ids, versions, and metadata', () => { + const problems = validateBeMusicSkin( + skin({ + id: 'Neon Skin', + label: ' ', + version: '1.0', + author: { name: '', url: 'javascript:alert(1)' }, + homepage: 'ftp://example.com', + }), + ); + expect(problems).toHaveLength(6); + expect(problems.join('\n')).toMatch(/id "Neon Skin"/u); + expect(problems.join('\n')).toMatch(/version "1\.0"/u); + expect(problems.join('\n')).toMatch(/author\.url/u); + expect(problems.join('\n')).toMatch(/homepage/u); + }); +}); + +describe('defineBeMusicSkin', () => { + it('returns a valid skin unchanged', () => { + const value = skin(); + expect(defineBeMusicSkin(value)).toBe(value); + }); + + it('throws with every problem listed', () => { + expect(() => defineBeMusicSkin(skin({ version: 'latest', apiVersion: 7 }))).toThrow( + /apiVersion 7.*version "latest"/u, + ); + }); +}); diff --git a/packages/skin-sdk/src/define.ts b/packages/skin-sdk/src/define.ts new file mode 100644 index 00000000..5d823e3d --- /dev/null +++ b/packages/skin-sdk/src/define.ts @@ -0,0 +1,88 @@ +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; + +/** Skin API revisions this host can run. */ +const SUPPORTED_API_VERSIONS: ReadonlySet = new Set([BE_MUSIC_SKIN_API_VERSION]); + +/** Whether this host can run a skin written against `apiVersion`. */ +export function isSupportedBeMusicSkinApiVersion(apiVersion: number): boolean { + return SUPPORTED_API_VERSIONS.has(apiVersion); +} + +const CONTEXT_KINDS: ReadonlySet = new Set(['2d', 'webgl', 'webgl2', 'webgpu']); + +const SKIN_ID = /^[a-z0-9][a-z0-9-]*$/u; +// Semantic Versioning 2.0.0: MAJOR.MINOR.PATCH with optional pre-release and build metadata. +const SEMVER = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/u; + +function isHttpUrl(value: string): boolean { + try { + const url = new URL(value); + return url.protocol === 'https:' || url.protocol === 'http:'; + } catch { + return false; + } +} + +/** + * 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. + */ +export function validateBeMusicSkin(skin: BeMusicSkin): string[] { + const problems: string[] = []; + if (!isSupportedBeMusicSkinApiVersion(skin.apiVersion)) { + problems.push( + `apiVersion ${String(skin.apiVersion)} is not supported (this player runs ${[...SUPPORTED_API_VERSIONS].join(', ')})`, + ); + } + if (!SKIN_ID.test(skin.id)) problems.push(`id "${skin.id}" must be lowercase letters, digits, and hyphens`); + if (skin.label.trim() === '') problems.push('label must not be empty'); + if (!SEMVER.test(skin.version)) problems.push(`version "${skin.version}" is not a semantic version (e.g. 1.0.0)`); + if (skin.author.name.trim() === '') problems.push('author.name must not be empty'); + if (skin.author.url !== undefined && !isHttpUrl(skin.author.url)) { + problems.push(`author.url "${skin.author.url}" must be an http(s) URL`); + } + if (skin.homepage !== undefined && !isHttpUrl(skin.homepage)) { + problems.push(`homepage "${skin.homepage}" must be an http(s) URL`); + } + if (!CONTEXT_KINDS.has(skin.context)) { + problems.push(`context "${String(skin.context)}" must be one of ${[...CONTEXT_KINDS].join(', ')}`); + } + for (const screen of ['gameplay', 'select', 'result'] as const) { + if (typeof skin[screen]?.draw !== 'function') problems.push(`${screen}.draw must be a function`); + } + return problems; +} + +/** + * Declares a be-music skin. Returns the skin unchanged after checking its declaration (see {@link validateBeMusicSkin}), + * so a malformed skin fails where it is defined rather than when a host first mounts it. + * + * ```ts + * export default defineBeMusicSkin({ + * apiVersion: BE_MUSIC_SKIN_API_VERSION, + * id: 'neon', + * label: 'Neon', + * version: '1.0.0', + * author: { name: 'Jane Doe', url: 'https://example.com' }, + * fontLoads: [], + * context: '2d', + * gameplay: { draw: ({ context: ctx }, frame) => { … } }, + * select: { layout, draw: ({ context: ctx }, frame) => { … } }, + * result: { draw: ({ context: ctx }, frame) => { … } }, + * }); + * ``` + */ +export function defineBeMusicSkin(skin: TSkin): TSkin { + const problems = validateBeMusicSkin(skin); + if (problems.length > 0) { + throw new Error(`defineBeMusicSkin("${skin.id}"): ${problems.join('; ')}`); + } + return skin; +} diff --git a/packages/skin-sdk/src/fonts.ts b/packages/skin-sdk/src/fonts.ts new file mode 100644 index 00000000..b2da9f6c --- /dev/null +++ b/packages/skin-sdk/src/fonts.ts @@ -0,0 +1,5 @@ +/** + * The SDK's default text face: M PLUS 1p, a tight, sharp gothic for small UI text and Japanese body copy, with LINE Seed + * JP and the system sans as fallbacks. Text helpers use it when a skin doesn't name a font. + */ +export const DEFAULT_TEXT_FONT = 'M PLUS 1p, LINE Seed JP, ui-sans-serif, system-ui, sans-serif'; diff --git a/packages/skin-sdk/src/framework-free.test.ts b/packages/skin-sdk/src/framework-free.test.ts new file mode 100644 index 00000000..f30c4c6c --- /dev/null +++ b/packages/skin-sdk/src/framework-free.test.ts @@ -0,0 +1,43 @@ +import { readFileSync } from 'node:fs'; +import { dirname, relative, resolve } from 'node:path'; +import { describe, expect, it } from 'vite-plus/test'; + +/** + * The skin SDK never touches a rendering framework: skins bring their own (Canvas 2D, WebGL, PixiJS, three.js, …). This + * walks every module the SDK barrel reaches — type-only imports included, since they shape the published declarations — + * and checks that it stays inside this package and that its only package imports are the repository's own engine + * packages, none of which draws. + */ +const SDK_BARREL = resolve(import.meta.dirname, 'index.ts'); +const SRC_DIR = import.meta.dirname; +/** Packages the SDK may build on: the chart model and engine, which import no renderer. */ +const ALLOWED_PACKAGES = ['@be-music/audio-renderer/', '@be-music/chart', '@be-music/json', '@be-music/player/']; +const SPECIFIER = /\b(?:import|export)\b[^'"]*?\bfrom\s*['"]([^'"]+)['"]|\bimport\s*\(\s*['"]([^'"]+)['"]\s*\)/gu; + +function packageImports(entry: string): string[] { + const seen = new Set(); + const found: string[] = []; + const pending = [entry]; + while (pending.length > 0) { + const file = pending.pop()!; + if (seen.has(file)) continue; + seen.add(file); + for (const match of readFileSync(file, 'utf8').matchAll(SPECIFIER)) { + const specifier = match[1] ?? match[2]!; + if (specifier.startsWith('.')) { + const target = resolve(dirname(file), specifier); + if (!target.startsWith(SRC_DIR)) found.push(`${relative(SRC_DIR, file)} leaves the package for ${specifier}`); + else pending.push(target); + } else if (!ALLOWED_PACKAGES.some((allowed) => specifier === allowed || specifier.startsWith(allowed))) { + found.push(`${relative(SRC_DIR, file)} imports ${specifier}`); + } + } + } + return found; +} + +describe('skin SDK', () => { + it('stays inside the package and reaches no rendering framework', () => { + expect(packageImports(SDK_BARREL)).toEqual([]); + }); +}); diff --git a/packages/skin-sdk/src/index.ts b/packages/skin-sdk/src/index.ts new file mode 100644 index 00000000..db8e0e6d --- /dev/null +++ b/packages/skin-sdk/src/index.ts @@ -0,0 +1,35 @@ +/** + * `@be-music/player-web/skin-sdk` — everything a be-music skin is written against, and nothing that draws. A skin gets + * a canvas and plain frame data from the player and renders with whatever it brings: the Canvas 2D API, raw WebGL / + * WebGPU, or a framework such as PixiJS or three.js. The SDK itself imports no rendering framework. + * + * - Declaring a skin: {@link defineBeMusicSkin}, {@link BE_MUSIC_SKIN_API_VERSION}, and the `BeMusicSkin` types. + * - Stage and layout: {@link wideStage} (the 16:9 stage the built-ins use), the `BeMusicGameplayLayout` each gameplay + * frame carries, {@link resolveLaneRuns}, and {@link resolveMilestoneArea} for placing cut-ins clear of the lanes. + * - 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. + * - 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 * from './audio.ts'; +export * from './playfield.ts'; +export * from './lane-geometry.ts'; + +export * from './define.ts'; +export * from './stage.ts'; +export * from './layout.ts'; +export * from './fonts.ts'; +export * from './motion.ts'; +export * from './tabular.ts'; +export * from './judge-word.ts'; +export * from './moments.ts'; +export * from './loading.ts'; +export * from './audio-drive.ts'; +export * from './song-stats.ts'; +export * from './result-track.ts'; diff --git a/packages/skin-sdk/src/judge-word.test.ts b/packages/skin-sdk/src/judge-word.test.ts new file mode 100644 index 00000000..deeeb77c --- /dev/null +++ b/packages/skin-sdk/src/judge-word.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vite-plus/test'; +import { FLASHING_GREAT_STEP_MS, flashingGreatColor, isFlashingGreat, judgeDisplayWord } from './judge-word.ts'; + +describe('judgeDisplayWord', () => { + it('prints PERFECT as GREAT', () => { + expect(judgeDisplayWord('PERFECT')).toBe('GREAT'); + }); + + it('keeps the other judgements', () => { + for (const judge of ['GREAT', 'GOOD', 'BAD', 'POOR']) expect(judgeDisplayWord(judge)).toBe(judge); + }); +}); + +describe('isFlashingGreat', () => { + it('only PERFECT flashes', () => { + expect(isFlashingGreat('PERFECT')).toBe(true); + expect(isFlashingGreat('GREAT')).toBe(false); + }); +}); + +describe('flashingGreatColor', () => { + const palette = [0x111111, 0x222222, 0x333333]; + + it('steps through the palette and wraps', () => { + expect(flashingGreatColor(0, palette)).toBe(0x111111); + expect(flashingGreatColor(FLASHING_GREAT_STEP_MS - 1, palette)).toBe(0x111111); + expect(flashingGreatColor(FLASHING_GREAT_STEP_MS, palette)).toBe(0x222222); + expect(flashingGreatColor(FLASHING_GREAT_STEP_MS * 3, palette)).toBe(0x111111); + }); + + it('falls back on an empty palette or a bad clock', () => { + expect(flashingGreatColor(100, [])).toBe(0xffffff); + expect(flashingGreatColor(Number.NaN, palette)).toBe(0x111111); + expect(flashingGreatColor(-500, palette)).toBe(0x111111); + }); +}); diff --git a/packages/skin-sdk/src/judge-word.ts b/packages/skin-sdk/src/judge-word.ts new file mode 100644 index 00000000..c5b8ba36 --- /dev/null +++ b/packages/skin-sdk/src/judge-word.ts @@ -0,0 +1,24 @@ +/** + * Judgement word shared by the built-in skins. Like the arcade cabinet, a PERFECT (PGREAT) prints as a colour-cycling + * GREAT; the steady GREAT keeps its skin colour, so the flicker alone tells the two apart. + */ + +/** How long each colour of the flashing GREAT holds, in ms (about three frames at 60 fps). */ +export const FLASHING_GREAT_STEP_MS = 50; + +/** Word a judgement prints as: PERFECT reads GREAT, everything else as is. */ +export function judgeDisplayWord(judge: string): string { + return judge === 'PERFECT' ? 'GREAT' : judge; +} + +/** Whether `judge` prints as the colour-cycling GREAT. */ +export function isFlashingGreat(judge: string): boolean { + return judge === 'PERFECT'; +} + +/** Colour of the flashing GREAT at `nowMs`: steps through `palette`, one entry per {@link FLASHING_GREAT_STEP_MS}. */ +export function flashingGreatColor(nowMs: number, palette: readonly number[]): number { + if (palette.length === 0) return 0xffffff; + const step = Math.floor((Number.isFinite(nowMs) ? Math.max(0, nowMs) : 0) / FLASHING_GREAT_STEP_MS); + return palette[step % palette.length]!; +} diff --git a/packages/skin-sdk/src/lane-geometry.ts b/packages/skin-sdk/src/lane-geometry.ts new file mode 100644 index 00000000..0d52a898 --- /dev/null +++ b/packages/skin-sdk/src/lane-geometry.ts @@ -0,0 +1,254 @@ +import type { ChartPlayVariant } from '@be-music/player/core/lane-layout'; +import { + resolveSideKeySlot as resolveCoreSideKeySlot, + resolveSideRelativeLaneIndex, +} from '@be-music/player/core/lane-layout'; +import type { BeMusicLaneKind } from './types.ts'; + +/** Lanes sit edge to edge, and in double play the 2P bank butts straight onto the 1P bank. */ +const FALLBACK_LANE_GAP = 0; +const FALLBACK_DP_SIDE_GAP = 0; +const FALLBACK_SCRATCH_LANE_WEIGHT = 1.55; + +/** + * Fixed lane widths for the IIDX families (5 / 7 / 10 / 14 KEY), in design px. The proportions follow the arcade + * cabinet — the scratch lane about 1.7x a white key, black keys about 0.8x — scaled so a 7K side is exactly the 194 px + * span. Every mode uses the same widths, so a 5K or DP chart never stretches its lanes sideways; the playfield just + * gets narrower or wider. + */ +export const IIDX_LANE_WIDTHS = { scratch: 41, white: 24, black: 19 } as const; + +/** + * Gap between the 1P and 2P banks in IIDX double play (10 / 14 KEY), in design px. The arcade cabinet splits the two + * sides by about 0.31 of a side's width (the turntable housings sit between them); on the 194 px 7K side that is 60 px. + * Keyboard double play (48 KEY) keeps its banks together. + */ +export const IIDX_DP_SIDE_GAP = 60; + +export interface FixedLaneWidths { + scratch: number; + /** Odd-numbered keys (1 / 3 / 5 / 7). */ + white: number; + /** Even-numbered keys (2 / 4 / 6). */ + black: number; +} + +/** Left edge and per-side width of the skinless playfield, mirroring LR2's default 7K skin. */ +const FALLBACK_PLAYFIELD_SPAN = { x: 33, w: 194 } as const; +/** + * Keyboard modes run 24 lanes per side, which the 7K span squeezes to 8 px each. Like the keyboard cabinet, they take + * the screen width instead: 24 KEY fills the column left of the BGA monitor (its frame still clears the rails), and + * 48 KEY spreads both banks across the whole page. + */ +const KEYBOARD_PLAYFIELD_SPANS: Record<'24' | '48', { x: number; w: number }> = { + '24': { x: 12, w: 252 }, + '48': { x: 12, w: 308 }, +}; + +export interface FallbackLaneLayoutRect { + channel: string | undefined; + x: number; + w: number; + isScratch: boolean; + side: '1P' | '2P'; +} + +export interface ResolveFallbackLaneLayoutOptions { + channels?: readonly string[]; + laneCount?: number; + playVariant?: ChartPlayVariant; + x: number; + w: number; + gap?: number; + sideGap?: number; + /** + * Keep one side at the requested width and let additional DP-side lanes extend the fallback playfield horizontally. + */ + preserveSideWidth?: boolean; + /** + * Give every lane a fixed width by kind (scratch / white key / black key) instead of sharing `w` out. `w` and + * `preserveSideWidth` are then ignored: the playfield is as wide as its lanes. + */ + fixedWidths?: FixedLaneWidths; +} + +/** + * Left edge and per-side width the skinless playfield uses for a chart's play variant (see + * {@link resolveSkinlessLaneLayout}). + */ +export function resolveFallbackPlayfieldSpan(playVariant?: ChartPlayVariant): { x: number; w: number } { + return playVariant === '24' || playVariant === '48' ? KEYBOARD_PLAYFIELD_SPANS[playVariant] : FALLBACK_PLAYFIELD_SPAN; +} + +export interface SkinlessLaneLayout { + lanes: FallbackLaneLayoutRect[]; + /** Left edge of the leftmost lane. */ + left: number; + /** Right edge of the rightmost lane (never left of one side's span). */ + right: number; +} + +/** + * Lane rects for the skinless / built-in playfield: the variant's span, with double play extending one side's width to + * the right. Every built-in skin and the core lane renderer share this, so chrome and notes line up. + */ +export function resolveSkinlessLaneLayout( + channels: readonly string[] | undefined, + laneCount: number | undefined, + playVariant?: ChartPlayVariant, +): SkinlessLaneLayout { + const span = resolveFallbackPlayfieldSpan(playVariant); + const fixedWidths = usesIidxLaneWidths(playVariant) ? IIDX_LANE_WIDTHS : undefined; + const lanes = resolveFallbackLaneLayout({ + channels, + laneCount, + playVariant, + x: span.x, + w: span.w, + preserveSideWidth: shouldPreserveFallbackSideWidth(channels, playVariant), + fixedWidths, + sideGap: fixedWidths ? IIDX_DP_SIDE_GAP : undefined, + }); + const lanesRight = Math.max(span.x, ...lanes.map((lane) => lane.x + lane.w)); + // Fixed-width lanes define the playfield; shared-out lanes keep at least one side's span. + const right = fixedWidths ? lanesRight : Math.max(span.x + span.w, lanesRight); + return { lanes, left: span.x, right }; +} + +/** Whether `playVariant` is an IIDX family (5 / 7 / 10 / 14 KEY), whose lanes use {@link IIDX_LANE_WIDTHS}. */ +export function usesIidxLaneWidths(playVariant?: ChartPlayVariant): boolean { + return playVariant === '5' || playVariant === '7' || playVariant === '10' || playVariant === '14'; +} + +export function isScratchLaneForVariant(channel: string, playVariant?: ChartPlayVariant): boolean { + return resolveCoreSideKeySlot(channel, playVariant) === 0; +} + +export function shouldPreserveFallbackSideWidth( + channels: readonly string[] | undefined, + playVariant?: ChartPlayVariant, +): boolean { + if (playVariant === '10' || playVariant === '14') { + return true; + } + return playVariant !== '9' && channels?.some((channel) => channel.startsWith('2')) === true; +} + +export function resolveFallbackLaneLayout(options: ResolveFallbackLaneLayoutOptions): FallbackLaneLayoutRect[] { + const fallbackLaneCount = Math.max(1, Math.trunc(options.laneCount ?? 8)); + const channels = + options.channels && options.channels.length > 0 + ? [...options.channels] + : (Array.from({ length: fallbackLaneCount }) as Array); + const gap = Math.max(0, options.gap ?? FALLBACK_LANE_GAP); + const scratchFlags = channels.map((channel) => + channel === undefined ? false : isScratchLaneForVariant(channel, options.playVariant), + ); + const sideFlags = channels.map((channel) => resolveFallbackLaneSide(channel, options.playVariant)); + const displayOrder = resolveFallbackLaneDisplayOrder(channels, scratchFlags, options.playVariant); + const hasBothSides = sideFlags.includes('1P') && sideFlags.includes('2P'); + const sideGap = Math.max(0, options.sideGap ?? (hasBothSides ? FALLBACK_DP_SIDE_GAP : 0)); + const laneWeights = scratchFlags.map((isScratch) => (isScratch ? FALLBACK_SCRATCH_LANE_WEIGHT : 1)); + const referenceLaneCount = options.preserveSideWidth ? Math.max(1, Math.ceil(channels.length / 2)) : channels.length; + const referenceWeights = laneWeights.slice(0, referenceLaneCount); + const referenceWeight = referenceWeights.reduce((sum, weight) => sum + weight, 0); + const totalInterLaneGap = resolveTotalInterLaneGap(displayOrder, sideFlags, gap, sideGap); + const referenceInterLaneGap = options.preserveSideWidth + ? gap * Math.max(0, referenceLaneCount - 1) + : totalInterLaneGap; + const availableWidth = Math.max(0, options.w - referenceInterLaneGap); + const unit = referenceWeight > 0 ? availableWidth / referenceWeight : 0; + const rects: FallbackLaneLayoutRect[] = Array.from({ length: channels.length }); + let x = options.x; + let priorSide: '1P' | '2P' | undefined; + + const fixedWidths = options.fixedWidths; + for (const index of displayOrder) { + const side = sideFlags[index]!; + if (priorSide !== undefined) { + x += priorSide === side ? gap : sideGap; + } + const w = fixedWidths + ? resolveFixedLaneWidth(fixedWidths, channels[index], scratchFlags[index]!, options.playVariant) + : unit * laneWeights[index]!; + rects[index] = { channel: channels[index], x, w, isScratch: scratchFlags[index]!, side }; + x += w; + priorSide = side; + } + return rects; +} + +function resolveFixedLaneWidth( + widths: FixedLaneWidths, + channel: string | undefined, + isScratch: boolean, + playVariant: ChartPlayVariant | undefined, +): number { + if (isScratch) return widths.scratch; + const slot = channel === undefined ? -1 : resolveCoreSideKeySlot(channel, playVariant); + return slot > 0 && slot % 2 === 0 ? widths.black : widths.white; +} + +function resolveFallbackLaneSide(channel: string | undefined, playVariant?: ChartPlayVariant): '1P' | '2P' { + return playVariant !== '9' && channel?.startsWith('2') === true ? '2P' : '1P'; +} + +function resolveTotalInterLaneGap( + displayOrder: readonly number[], + sideFlags: ReadonlyArray<'1P' | '2P'>, + gap: number, + sideGap: number, +): number { + let total = 0; + for (let i = 1; i < displayOrder.length; i += 1) { + const previous = sideFlags[displayOrder[i - 1]!]!; + const next = sideFlags[displayOrder[i]!]!; + total += previous === next ? gap : sideGap; + } + return total; +} + +function resolveFallbackLaneDisplayOrder( + channels: ReadonlyArray, + scratchFlags: ReadonlyArray, + playVariant?: ChartPlayVariant, +): number[] { + if (playVariant === '9') { + return channels.map((_, index) => index); + } + return channels + .map((channel, index) => ({ + index, + side: channel?.startsWith('2') === true ? 1 : 0, + scratchOrder: scratchFlags[index] ? (channel?.startsWith('2') === true ? 1 : -1) : 0, + slot: channel === undefined ? index : resolveCoreSideKeySlot(channel, playVariant), + })) + .sort((a, b) => { + if (a.side !== b.side) return a.side - b.side; + if (a.scratchOrder !== b.scratchOrder) return a.scratchOrder - b.scratchOrder; + if (a.slot !== b.slot) return a.slot - b.slot; + return a.index - b.index; + }) + .map((entry) => entry.index); +} + +/** Chromatic offsets of the black keys within an octave (C# D# F# G# A#). */ +const KEYBOARD_BLACK_KEY_SEMITONES = new Set([1, 3, 6, 8, 10]); + +/** + * Visual class of a lane. `laneIndex` is the LR2 lane id (0 / 10 = scratch, 1..9 / 11..19 = keys, -1 when the lane + * has no LR2 rect). IIDX convention: odd keys white, even keys black. The 24 / 48-key keyboard modes colour by piano + * key instead, repeating every octave. + */ +export function resolveBeMusicLaneKind( + channel: string, + laneIndex: number, + playVariant: ChartPlayVariant | undefined, +): BeMusicLaneKind { + if (isScratchLaneForVariant(channel, playVariant)) return 'scratch'; + if (playVariant === '24' || playVariant === '48') { + const semitone = (resolveSideRelativeLaneIndex(channel, playVariant) - 1) % 12; + return KEYBOARD_BLACK_KEY_SEMITONES.has(semitone) ? 'black' : 'white'; + } + return (laneIndex % 10) % 2 === 0 ? 'black' : 'white'; +} diff --git a/packages/skin-sdk/src/layout.test.ts b/packages/skin-sdk/src/layout.test.ts new file mode 100644 index 00000000..bd347714 --- /dev/null +++ b/packages/skin-sdk/src/layout.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vite-plus/test'; +import { resolveGameplayLayout, resolveLaneRuns } from './layout.ts'; +import { wideStage } from './stage.ts'; + +const SP = ['16', '11', '12', '13', '14', '15', '18', '19']; +const DP = [...SP, '21', '22', '23', '24', '25', '28', '29', '26']; + +describe('resolveGameplayLayout', () => { + it('lays out a 7K side with its scratch, keys, and the stage BGA', () => { + const layout = resolveGameplayLayout({ laneChannels: SP, playVariant: '7' }, wideStage); + expect(layout.stage).toEqual({ width: 854, height: 480 }); + expect(layout.lanes).toHaveLength(8); + expect(layout.lanes[0]!.kind).toBe('scratch'); + expect(layout.lanes.map((lane) => lane.kind).slice(1)).toEqual([ + 'white', + 'black', + 'white', + 'black', + 'white', + 'black', + 'white', + ]); + expect(layout.playfield.right - layout.playfield.left).toBe(194); + expect(layout.playfield.centerX).toBe((layout.playfield.left + layout.playfield.right) / 2); + expect(layout.playfield.sides['2P']).toBeUndefined(); + expect(layout.bga).toEqual(wideStage.resolveBgaRect(layout.playfield.right)); + }); + + it('reports both sides in double play and keeps the BGA clear of them', () => { + const layout = resolveGameplayLayout({ laneChannels: DP, playVariant: '14' }, wideStage); + expect(layout.playfield.sides['1P']).toBeDefined(); + expect(layout.playfield.sides['2P']!.right).toBe(layout.playfield.right); + expect(layout.bga!.x).toBeGreaterThan(layout.playfield.right); + }); + + it('leaves the BGA out when the stage has no room for it', () => { + const layout = resolveGameplayLayout( + { laneChannels: SP, playVariant: '7' }, + { width: 640, height: 480, resolveBgaRect: () => ({ x: 0, y: 0, w: 0, h: 0 }) }, + ); + expect(layout.bga).toBeUndefined(); + }); +}); + +describe('resolveLaneRuns', () => { + it('merges lanes that touch into one run', () => { + expect( + resolveLaneRuns([ + { x: 33, w: 41 }, + { x: 74, w: 24 }, + { x: 98, w: 19 }, + ]), + ).toEqual([{ left: 33, right: 117 }]); + }); + + it('splits at a gap, sorting lanes by position', () => { + expect( + resolveLaneRuns([ + { x: 300, w: 24 }, + { x: 33, w: 41 }, + { x: 74, w: 24 }, + { x: 324, w: 41 }, + ]), + ).toEqual([ + { left: 33, right: 98 }, + { left: 300, right: 365 }, + ]); + }); + + it('gives one run per side in IIDX double play', () => { + const layout = resolveGameplayLayout({ laneChannels: DP, playVariant: '14' }, wideStage); + const runs = resolveLaneRuns(layout.lanes); + expect(runs).toHaveLength(2); + expect(runs[0]).toEqual(layout.playfield.sides['1P']); + expect(runs[1]).toEqual(layout.playfield.sides['2P']); + }); + + it('is empty without lanes', () => { + expect(resolveLaneRuns([])).toEqual([]); + }); +}); diff --git a/packages/skin-sdk/src/layout.ts b/packages/skin-sdk/src/layout.ts new file mode 100644 index 00000000..0f1c0612 --- /dev/null +++ b/packages/skin-sdk/src/layout.ts @@ -0,0 +1,81 @@ +import type { ChartPlayVariant } from '@be-music/player/core/lane-layout'; +import { resolveLr2LaneIndex } from '@be-music/player/core/lane-layout'; +import { PLAYFIELD } from './playfield.ts'; +import { resolveBeMusicLaneKind, resolveSkinlessLaneLayout } from './lane-geometry.ts'; +import type { BeMusicGameplayLayout, BeMusicStage } from './types.ts'; + +/** What {@link resolveGameplayLayout} needs to know about the chart: its lane channels and play variant. */ +export interface GameplayLayoutInput { + laneChannels?: readonly string[]; + laneCount?: number; + playVariant?: ChartPlayVariant; +} + +/** + * The gameplay layout the host hands to a skin's chrome every frame (`context.layout`), built from the chart's lanes and + * the skin's stage. Hosts call it; skins normally just read `context.layout`, but can call it to build a layout for a + * preview or a test frame. + */ +export function resolveGameplayLayout(input: GameplayLayoutInput, stage: BeMusicStage): BeMusicGameplayLayout { + const channels = input.laneChannels; + const variant = input.playVariant; + const { lanes, left, right } = resolveSkinlessLaneLayout(channels, input.laneCount ?? channels?.length, variant); + const sides: BeMusicGameplayLayout['playfield']['sides'] = {}; + const layoutLanes = lanes.map((lane, index) => { + const bounds = sides[lane.side]; + sides[lane.side] = bounds + ? { left: Math.min(bounds.left, lane.x), right: Math.max(bounds.right, lane.x + lane.w) } + : { left: lane.x, right: lane.x + lane.w }; + const channel = lane.channel; + return { + channel, + kind: channel + ? resolveBeMusicLaneKind(channel, resolveLr2LaneIndex(channel, variant), variant) + : index === 0 + ? ('scratch' as const) + : ('white' as const), + side: lane.side, + x: lane.x, + w: lane.w, + }; + }); + const bga = stage.resolveBgaRect(right); + return { + stage: { width: stage.width, height: stage.height }, + lanes: layoutLanes, + playfield: { + left, + right, + centerX: (left + right) / 2, + top: PLAYFIELD.y, + judgementY: PLAYFIELD.judgementY, + sides, + }, + bga: bga.w > 0 && bga.h > 0 ? { ...bga } : undefined, + }; +} + +/** A run of lanes that sit edge to edge (one play side in double play). */ +export interface LaneRun { + left: number; + right: number; +} + +/** + * Groups lanes that touch into runs, left to right — one run in single play, one per side in IIDX double play, where + * the 1P and 2P banks stand apart. Draw anything that spans the lanes (the judgement line, a frame) once per run so + * it never crosses the gap between the sides. + */ +export function resolveLaneRuns(lanes: ReadonlyArray<{ x: number; w: number }>): LaneRun[] { + const sorted = [...lanes].sort((a, b) => a.x - b.x); + const runs: LaneRun[] = []; + for (const lane of sorted) { + const last = runs[runs.length - 1]; + if (last && lane.x <= last.right + 0.5) { + last.right = Math.max(last.right, lane.x + lane.w); + } else { + runs.push({ left: lane.x, right: lane.x + lane.w }); + } + } + return runs; +} diff --git a/packages/skin-sdk/src/loading.test.ts b/packages/skin-sdk/src/loading.test.ts new file mode 100644 index 00000000..c34eae03 --- /dev/null +++ b/packages/skin-sdk/src/loading.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vite-plus/test'; +import { LOADING_DOT_MS, loadingDots } from './loading.ts'; + +describe('loadingDots', () => { + it('grows to three dots and starts over', () => { + expect(loadingDots(0)).toBe(''); + expect(loadingDots(LOADING_DOT_MS)).toBe('.'); + expect(loadingDots(LOADING_DOT_MS * 2 + 1)).toBe('..'); + expect(loadingDots(LOADING_DOT_MS * 3)).toBe('...'); + expect(loadingDots(LOADING_DOT_MS * 4)).toBe(''); + }); + + it('treats a missing clock as the start', () => { + expect(loadingDots(Number.NaN)).toBe(''); + expect(loadingDots(-50)).toBe(''); + }); +}); diff --git a/packages/skin-sdk/src/loading.ts b/packages/skin-sdk/src/loading.ts new file mode 100644 index 00000000..864e8667 --- /dev/null +++ b/packages/skin-sdk/src/loading.ts @@ -0,0 +1,10 @@ +/** The loading caption the built-in skins print on the lanes while a chart's assets load. */ +export const LOADING_WORD = 'NOW LOADING'; + +/** One dot per step, cycling `''` → `.` → `..` → `...` every {@link LOADING_DOT_MS}. */ +export const LOADING_DOT_MS = 320; + +export function loadingDots(nowMs: number): string { + const step = Math.floor(Math.max(0, Number.isFinite(nowMs) ? nowMs : 0) / LOADING_DOT_MS) % 4; + return '.'.repeat(step); +} diff --git a/packages/skin-sdk/src/moments.test.ts b/packages/skin-sdk/src/moments.test.ts new file mode 100644 index 00000000..ab3109f2 --- /dev/null +++ b/packages/skin-sdk/src/moments.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from 'vite-plus/test'; +import { + comboTier, + createMomentState, + effectProfile, + impulse, + momentProgress, + punchScale, + updateMoments, + type MomentInput, + MILESTONE_MIN_WIDTH, + resolveMilestoneArea, +} from './moments.ts'; + +const base: MomentInput = { + nowMs: 0, + combo: 0, + gauge: 20, + clearThreshold: 80, + survival: false, + judged: 0, + totalNotes: 300, + bad: 0, + poor: 0, +}; + +describe('updateMoments', () => { + it('stamps each combo milestone once, on the frame it is reached', () => { + let state = updateMoments(createMomentState(), { ...base, combo: 99, nowMs: 10 }); + expect(state.milestone).toBeUndefined(); + state = updateMoments(state, { ...base, combo: 100, nowMs: 20 }); + expect(state.milestone).toEqual({ value: 100, atMs: 20 }); + state = updateMoments(state, { ...base, combo: 101, nowMs: 30 }); + expect(state.milestone).toEqual({ value: 100, atMs: 20 }); + state = updateMoments(state, { ...base, combo: 203, nowMs: 40 }); + expect(state.milestone).toEqual({ value: 200, atMs: 40 }); + }); + + it('re-fires a milestone after the combo breaks and rebuilds', () => { + let state = updateMoments(createMomentState(), { ...base, combo: 100, nowMs: 5 }); + state = updateMoments(state, { ...base, combo: 0, nowMs: 6 }); + state = updateMoments(state, { ...base, combo: 100, nowMs: 7 }); + expect(state.milestone).toEqual({ value: 100, atMs: 7 }); + }); + + it('stamps the clear line only when the gauge rises across it', () => { + let state = updateMoments(createMomentState(), { ...base, gauge: 78, nowMs: 1 }); + expect(state.clearAtMs).toBeUndefined(); + state = updateMoments(state, { ...base, gauge: 80, nowMs: 2 }); + expect(state.clearAtMs).toBe(2); + state = updateMoments(state, { ...base, gauge: 90, nowMs: 3 }); + expect(state.clearAtMs).toBe(2); + }); + + it('ignores the clear line for survival gauges', () => { + const state = updateMoments(createMomentState(), { ...base, gauge: 100, survival: true, nowMs: 1 }); + expect(state.clearAtMs).toBeUndefined(); + }); + + it('stamps a full combo once every note is judged without BAD / POOR', () => { + let state = updateMoments(createMomentState(), { ...base, judged: 299, nowMs: 1 }); + expect(state.fullComboAtMs).toBeUndefined(); + state = updateMoments(state, { ...base, judged: 300, nowMs: 2 }); + expect(state.fullComboAtMs).toBe(2); + state = updateMoments(state, { ...base, judged: 300, nowMs: 9 }); + expect(state.fullComboAtMs).toBe(2); + }); + + it('does not call a run with a POOR a full combo', () => { + const state = updateMoments(createMomentState(), { ...base, judged: 300, poor: 1, nowMs: 2 }); + expect(state.fullComboAtMs).toBeUndefined(); + }); +}); + +describe('comboTier', () => { + it('steps up at 25 / 50 / 100 / 200', () => { + expect([0, 24, 25, 49, 50, 99, 100, 199, 200, 999].map(comboTier)).toEqual([0, 0, 1, 1, 2, 2, 3, 3, 4, 4]); + }); +}); + +describe('momentProgress', () => { + it('reports 0..1 while running and undefined otherwise', () => { + expect(momentProgress(undefined, 10, 100)).toBeUndefined(); + expect(momentProgress(100, 150, 100)).toBe(0.5); + expect(momentProgress(100, 90, 100)).toBeUndefined(); + expect(momentProgress(100, 200, 100)).toBeUndefined(); + }); +}); + +describe('combo breaks', () => { + it('stamps a break only for combos of at least 20', () => { + let state = updateMoments(createMomentState(), { ...base, combo: 19, nowMs: 1 }); + state = updateMoments(state, { ...base, combo: 0, nowMs: 2 }); + expect(state.comboBreak).toBeUndefined(); + state = updateMoments(state, { ...base, combo: 35, nowMs: 3 }); + state = updateMoments(state, { ...base, combo: 0, nowMs: 4 }); + expect(state.comboBreak).toEqual({ combo: 35, atMs: 4 }); + }); +}); + +describe('impulse / punchScale', () => { + it('decays from 1 to 0 over the duration', () => { + expect(impulse(100, 100, 200)).toBe(1); + expect(impulse(100, 200, 200)).toBe(0.25); + expect(impulse(100, 300, 200)).toBe(0); + expect(impulse(undefined, 100, 200)).toBe(0); + expect(impulse(100, 50, 200)).toBe(0); + }); + + it('punches above 1 and settles back', () => { + expect(punchScale(0, 0, 100, 0.2)).toBe(1.2); + expect(punchScale(0, 100, 100, 0.2)).toBe(1); + }); +}); + +describe('effectProfile', () => { + it('maps each level to its multipliers', () => { + expect(effectProfile('full')).toEqual({ amount: 1, screenWide: true, enabled: true }); + expect(effectProfile(undefined)).toEqual({ amount: 1, screenWide: true, enabled: true }); + expect(effectProfile('reduced')).toEqual({ amount: 0.5, screenWide: false, enabled: true }); + expect(effectProfile('off')).toEqual({ amount: 0, screenWide: false, enabled: false }); + }); +}); + +describe('resolveMilestoneArea', () => { + it('uses the column right of single-play lanes', () => { + const area = resolveMilestoneArea(227); + expect(area.mode).toBe('beside'); + expect(area.x).toBeGreaterThan(227); + expect(area.x + area.w).toBeLessThanOrEqual(854); + expect(area.y + area.h).toBeLessThanOrEqual(346); + }); + + it('keeps double play beside the lanes on the 16:9 stage', () => { + const area = resolveMilestoneArea(421); + expect(area.mode).toBe('beside'); + expect(area.x).toBeGreaterThan(421); + }); + + it('drops below the lanes when they leave too little room', () => { + const area = resolveMilestoneArea(628); + expect(area.mode).toBe('below'); + expect(area.y).toBeGreaterThanOrEqual(346); + expect(area.y + area.h).toBeLessThanOrEqual(480); + expect(area.x + area.w).toBeLessThanOrEqual(854); + }); + + it('follows a narrower stage', () => { + expect(resolveMilestoneArea(421, 346, 640).mode).toBe('below'); + expect(resolveMilestoneArea(421, 346, 640).w).toBe(624); + }); + + it('switches exactly at the minimum width', () => { + expect(resolveMilestoneArea(854 - 16 - MILESTONE_MIN_WIDTH).mode).toBe('beside'); + expect(resolveMilestoneArea(854 - 16 - MILESTONE_MIN_WIDTH + 1).mode).toBe('below'); + }); +}); diff --git a/packages/skin-sdk/src/moments.ts b/packages/skin-sdk/src/moments.ts new file mode 100644 index 00000000..b788a993 --- /dev/null +++ b/packages/skin-sdk/src/moments.ts @@ -0,0 +1,166 @@ +import { STAGE_WIDTH } from './stage.ts'; + +/** + * "Moments" — the showpiece events built-in skins stage during a play: combo milestones, the gauge crossing the clear + * line, and a full combo. Pure state transitions so chrome renderers (which redraw every frame from a runtime snapshot) + * can detect the frame an event happens and animate against its timestamp. + */ + +export interface MomentInput { + nowMs: number; + combo: number; + gauge: number; + clearThreshold: number; + survival: boolean; + /** Notes judged so far (PG + GR + GD + BD + PR). */ + judged: number; + totalNotes: number; + bad: number; + poor: number; +} + +export interface MomentState { + lastCombo: number; + lastGauge: number; + /** Most recent combo milestone (a multiple of {@link COMBO_MILESTONE}) and when it was reached. */ + milestone: { value: number; atMs: number } | undefined; + /** When the gauge last rose onto / above the clear line from below. */ + clearAtMs: number | undefined; + /** When the final note was judged with no BAD / POOR. */ + fullComboAtMs: number | undefined; + /** When a combo of at least {@link COMBO_BREAK_MIN} broke, and how long it was. */ + comboBreak: { combo: number; atMs: number } | undefined; +} + +export const COMBO_MILESTONE = 100; +/** Combos shorter than this break silently. */ +export const COMBO_BREAK_MIN = 20; + +export function createMomentState(): MomentState { + return { + lastCombo: 0, + lastGauge: 0, + milestone: undefined, + clearAtMs: undefined, + fullComboAtMs: undefined, + comboBreak: undefined, + }; +} + +/** Advances `state` by one frame of `input`, stamping any event that happened since the previous frame. */ +export function updateMoments(state: MomentState, input: MomentInput): MomentState { + const next: MomentState = { ...state, lastCombo: input.combo, lastGauge: input.gauge }; + const reached = Math.floor(input.combo / COMBO_MILESTONE); + if (input.combo < state.lastCombo && state.lastCombo >= COMBO_BREAK_MIN) { + next.comboBreak = { combo: state.lastCombo, atMs: input.nowMs }; + } + if (reached >= 1 && reached > Math.floor(state.lastCombo / COMBO_MILESTONE)) { + next.milestone = { value: reached * COMBO_MILESTONE, atMs: input.nowMs }; + } + if ( + !input.survival && + input.clearThreshold > 0 && + state.lastGauge < input.clearThreshold && + input.gauge >= input.clearThreshold + ) { + next.clearAtMs = input.nowMs; + } + if ( + state.fullComboAtMs === undefined && + input.totalNotes > 0 && + input.judged >= input.totalNotes && + input.bad === 0 && + input.poor === 0 + ) { + next.fullComboAtMs = input.nowMs; + } + return next; +} + +/** Escalation tier for a combo: 0 below 25, then 1 / 2 / 3 / 4 from 25 / 50 / 100 / 200. */ +export function comboTier(combo: number): 0 | 1 | 2 | 3 | 4 { + if (combo >= 200) return 4; + if (combo >= 100) return 3; + if (combo >= 50) return 2; + if (combo >= 25) return 1; + return 0; +} + +/** Progress (0..1) of a moment that started at `atMs` and lasts `durationMs`, or `undefined` when not running. */ +export function momentProgress(atMs: number | undefined, nowMs: number, durationMs: number): number | undefined { + if (atMs === undefined) return undefined; + const t = (nowMs - atMs) / durationMs; + return t >= 0 && t < 1 ? t : undefined; +} + +/** + * Per-scene moment state keyed by the chrome layer the renderer draws into, so each gameplay scene instance tracks its + * own events without the renderer holding scene references. + */ +const STATES = new WeakMap(); + +export function trackMoments(key: object, input: MomentInput): MomentState { + const state = updateMoments(STATES.get(key) ?? createMomentState(), input); + STATES.set(key, state); + return state; +} + +/** Strength (1 → 0) of an impulse that fired at `atMs`, easing out over `durationMs`; 0 when absent or finished. */ +export function impulse(atMs: number | undefined, nowMs: number, durationMs: number): number { + if (atMs === undefined || durationMs <= 0) return 0; + const t = (nowMs - atMs) / durationMs; + if (t < 0 || t >= 1) return 0; + return (1 - t) ** 2; +} + +/** Scale for a "punch" on `atMs`: overshoots to `1 + amount` instantly and eases back to 1 over `durationMs`. */ +export function punchScale(atMs: number | undefined, nowMs: number, durationMs: number, amount: number): number { + return 1 + amount * impulse(atMs, nowMs, durationMs); +} + +/** Multipliers skins apply per effect level: overall motion / particle `amount`, and whether screen-wide hits run. */ +export function effectProfile(level: 'full' | 'reduced' | 'off' | undefined): { + amount: number; + screenWide: boolean; + enabled: boolean; +} { + switch (level) { + case 'off': + return { amount: 0, screenWide: false, enabled: false }; + case 'reduced': + return { amount: 0.5, screenWide: false, enabled: true }; + default: + return { amount: 1, screenWide: true, enabled: true }; + } +} + +/** A screen region a combo milestone may play in without covering any lane. */ +export interface MilestoneArea { + x: number; + y: number; + w: number; + h: number; + /** `beside`: the column right of the lanes; `below`: the band under the judgement line (double play). */ + mode: 'beside' | 'below'; +} + +/** Narrowest column right of the lanes a milestone will use before falling back to the band below them. */ +export const MILESTONE_MIN_WIDTH = 240; + +/** + * Where a combo milestone may play so notes stay visible: the column right of the playfield when it is at least + * {@link MILESTONE_MIN_WIDTH} px wide, otherwise the band under the lanes (`lanesBottom`) — over the HUD for a moment + * rather than over the notes. Coordinates are in the stage's design space (`stageWidth` × 480; the built-in skins' + * 16:9 stage by default). + */ +export function resolveMilestoneArea( + playfieldRight: number, + lanesBottom = 346, + stageWidth: number = STAGE_WIDTH, +): MilestoneArea { + const free = stageWidth - playfieldRight - 16; + if (free >= MILESTONE_MIN_WIDTH) { + return { x: playfieldRight + 8, y: 40, w: free, h: lanesBottom - 40, mode: 'beside' }; + } + return { x: 8, y: lanesBottom + 4, w: stageWidth - 16, h: 480 - lanesBottom - 8, mode: 'below' }; +} diff --git a/packages/skin-sdk/src/motion.test.ts b/packages/skin-sdk/src/motion.test.ts new file mode 100644 index 00000000..84a83bdd --- /dev/null +++ b/packages/skin-sdk/src/motion.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vite-plus/test'; +import { easeOutBack, easeOutCubic, hash01, rollUpValue, stageProgress } from './motion.ts'; + +describe('hash01', () => { + it('is deterministic and stays in [0, 1)', () => { + for (let seed = 0; seed < 50; seed += 1) { + const value = hash01(seed); + expect(value).toBe(hash01(seed)); + expect(value).toBeGreaterThanOrEqual(0); + expect(value).toBeLessThan(1); + } + }); +}); + +describe('stageProgress', () => { + it('stays at 0 before the delay, ramps, and clamps at 1', () => { + expect(stageProgress(50, 100, 200)).toBe(0); + expect(stageProgress(200, 100, 200)).toBe(0.5); + expect(stageProgress(900, 100, 200)).toBe(1); + }); + + it('treats a zero duration as an instant step at the delay', () => { + expect(stageProgress(99, 100, 0)).toBe(0); + expect(stageProgress(100, 100, 0)).toBe(1); + }); +}); + +describe('easing', () => { + it('pins both curves to 0 and 1 at the ends', () => { + expect(easeOutCubic(0)).toBe(0); + expect(easeOutCubic(1)).toBe(1); + expect(easeOutBack(0)).toBeCloseTo(0, 12); + expect(easeOutBack(1)).toBe(1); + }); + + it('lets easeOutBack overshoot past 1 mid-flight', () => { + expect(Math.max(...[0.6, 0.7, 0.8, 0.9].map((t) => easeOutBack(t)))).toBeGreaterThan(1); + }); + + it('clamps inputs outside 0..1', () => { + expect(easeOutCubic(-1)).toBe(0); + expect(easeOutCubic(2)).toBe(1); + }); +}); + +describe('rollUpValue', () => { + it('counts from 0 to the target', () => { + expect(rollUpValue(1234, 0)).toBe(0); + expect(rollUpValue(1234, 1)).toBe(1234); + expect(rollUpValue(100, 0.5)).toBe(88); + }); + + it('returns 0 for a non-finite target', () => { + expect(rollUpValue(Number.NaN, 1)).toBe(0); + }); +}); diff --git a/packages/skin-sdk/src/motion.ts b/packages/skin-sdk/src/motion.ts new file mode 100644 index 00000000..a6492ae9 --- /dev/null +++ b/packages/skin-sdk/src/motion.ts @@ -0,0 +1,37 @@ +/** Timing and easing helpers for skin animation, plus a deterministic hash for decorative jitter. */ + +/** + * Deterministic [0, 1) hash for decorative jitter — the same seed always yields the same value, so pooled per-frame + * redraws don't shimmer. + */ +export function hash01(seed: number): number { + const value = Math.sin(seed * 12.9898 + 78.233) * 43758.5453; + return value - Math.floor(value); +} + +/** + * 0..1 progress of a staged animation step that starts `delayMs` after the timeline origin and runs for `durationMs`. + * Clamped at both ends so callers can compute every step from one elapsed clock. + */ +export function stageProgress(elapsedMs: number, delayMs: number, durationMs: number): number { + if (durationMs <= 0) return elapsedMs >= delayMs ? 1 : 0; + return Math.max(0, Math.min(1, (elapsedMs - delayMs) / durationMs)); +} + +export function easeOutCubic(t: number): number { + const clamped = Math.max(0, Math.min(1, t)); + return 1 - (1 - clamped) ** 3; +} + +/** Ease-out with a small overshoot past 1 before settling — the "slam" landing for tags and stamps. */ +export function easeOutBack(t: number, overshoot = 1.70158): number { + const clamped = Math.max(0, Math.min(1, t)); + const shifted = clamped - 1; + return 1 + (overshoot + 1) * shifted ** 3 + overshoot * shifted ** 2; +} + +/** Counter roll-up: the integer shown at `progress` (0..1) of counting up to `target`. */ +export function rollUpValue(target: number, progress: number): number { + if (!Number.isFinite(target)) return 0; + return Math.round(target * easeOutCubic(progress)); +} diff --git a/packages/skin-sdk/src/playfield.ts b/packages/skin-sdk/src/playfield.ts new file mode 100644 index 00000000..4d007f4e --- /dev/null +++ b/packages/skin-sdk/src/playfield.ts @@ -0,0 +1,6 @@ +/** + * Playfield rectangle for the no-skin lane geometry. Coordinates come from LR2's default 7K skin so LR2 skins and the + * built-in default family share the same note-position contract while their chrome stays separate. Kept free of any + * renderer import so the skin SDK's layout helpers can use it. + */ +export const PLAYFIELD = { x: 33, y: 0, w: 194, judgementY: 321 } as const; diff --git a/packages/skin-sdk/src/result-data.ts b/packages/skin-sdk/src/result-data.ts new file mode 100644 index 00000000..5e039668 --- /dev/null +++ b/packages/skin-sdk/src/result-data.ts @@ -0,0 +1,58 @@ +import type { ScoreSummary } from '@be-music/player/core/scoring'; +import type { BeMusicPlaylog } from '@be-music/player/playlog'; +import type { BrowserSongEntry } from './song.ts'; + +/** + * One sample of the gauge polyline. `progress` is the chart-time fraction (0 = first note, 1 = last playable / sample + * trigger); `value` is the gauge percentage at that moment (0..100). + * + * Used by the LR2 result scene's `#SRC_GAUGECHART` renderer (`PixiResultView`). The series always contains at + * least one entry (the chart-start origin seeded in `prepareSong`). + */ +export interface GaugeHistorySample { + progress: number; + value: number; +} + +/** + * One sample of the EX-score polyline. Same shape as {@link GaugeHistorySample} but the value is an absolute EX-score + * count (0..`total*2`). The result scene normalizes by the chart's theoretical max when drawing. + */ +export interface ScoreHistorySample { + progress: number; + exScore: number; +} + +/** + * Snapshot of the play session, captured at chart-end (or whenever the host asks for it via `getResultData`). Routed through the host into the result scene so it can render (LR2 skin or be-music + * skin) without holding onto the gameplay view. + * + * Field meaning: - `score` — same shape as {@link ScoreSummary}: per-judge counts, total notes, EX-score, and the + * displayed (count-up smoothed) IIDX score. - `maxCombo` — longest GREAT-or-better streak observed during the play + * (resets on every BAD/POOR). - `gauge` — final gauge percentage (0–100), used to drive pass / fail ops on the result + * skin. - `cleared` — `true` when the gauge ended at-or-above the chart's pass threshold (≥ 80 % for HARD-style charts; + * we use NORMAL's 80 % default for now since gauge type isn't user-selectable). - `playSeconds` — clock time the player + * spent on the chart, for the result skin's "TIME" readout. - `song` — chart metadata (title, artist, BPM, …) for the + * song info panel; the same `BrowserSongEntry` the gameplay view was mounted with. + */ +export interface BeMusicResultData { + score: ScoreSummary; + maxCombo: number; + gauge: number; + cleared: boolean; + playSeconds: number; + song: BrowserSongEntry; + /** + * Per-judge samples of `(progress, gauge%)`. Populated through the play session by `publishJudge`. The result scene + * uses this to draw `#SRC_GAUGECHART_1P` / `_2P` polylines that animate left- to-right between the SRC's `start` and + * `end` ms. + */ + gaugeHistory: GaugeHistorySample[]; + /** Per-judge samples of `(progress, exScore)`. Drives `#SRC_SCORECHART`. */ + scoreHistory: ScoreHistorySample[]; + /** + * Play-log recorded by the shared engine (resolved chart + raw input replay + play settings). `undefined` for + * legacy paths that finished without the shared engine having produced one. See `@be-music/player/playlog`. + */ + playlog?: BeMusicPlaylog; +} diff --git a/packages/skin-sdk/src/result-track.test.ts b/packages/skin-sdk/src/result-track.test.ts new file mode 100644 index 00000000..b36eadd9 --- /dev/null +++ b/packages/skin-sdk/src/result-track.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vite-plus/test'; +import type { BeMusicResultData } from './result-data.ts'; +import { resolveResultLamp, resolveResultTrackRows } from './result-track.ts'; + +function score(partial: Partial): BeMusicResultData['score'] { + return { total: 100, perfect: 0, great: 0, good: 0, bad: 0, poor: 0, emptyPoor: 0, exScore: 0, score: 0, ...partial }; +} + +describe('resolveResultLamp', () => { + it('reads FAILED for an uncleared play whatever the judgements', () => { + expect(resolveResultLamp({ cleared: false, score: score({ perfect: 100 }) })).toBe('FAILED'); + }); + + it('reads PERFECT when every note is a PGREAT', () => { + expect(resolveResultLamp({ cleared: true, score: score({ perfect: 100 }) })).toBe('PERFECT'); + }); + + it('reads FULL COMBO without BAD / POOR, ignoring empty POORs', () => { + expect(resolveResultLamp({ cleared: true, score: score({ perfect: 90, great: 10, emptyPoor: 4 }) })).toBe( + 'FULL COMBO', + ); + }); + + it('reads CLEAR once a BAD or POOR broke the combo', () => { + expect(resolveResultLamp({ cleared: true, score: score({ perfect: 98, bad: 1, poor: 1 }) })).toBe('CLEAR'); + }); + + it('reads CLEAR for a chart without notes', () => { + expect(resolveResultLamp({ cleared: true, score: score({ total: 0 }) })).toBe('CLEAR'); + }); +}); + +describe('resolveResultTrackRows', () => { + const song = { + title: 'Song', + playLevel: 12, + bpm: 149.6, + totalNotes: 100, + chartPath: 'song.bms', + chart: { events: [{ measure: 0, channel: '11', position: [0, 1], value: '01' }], bms: { player: 1 } }, + } as unknown as BeMusicResultData['song']; + + it('lists level, rounded tempo and the combo breaks', () => { + const rows = resolveResultTrackRows({ song, score: score({ bad: 2, poor: 3 }) }); + expect(rows.map((row) => row.label)).toEqual(['MODE', 'LEVEL', 'BPM', 'COMBO BREAK']); + expect(rows[0]!.value).toMatch(/KEYS$/u); + expect(rows[1]!.value).toBe('12'); + expect(rows[2]!.value).toBe('150'); + expect(rows[3]!.value).toBe('5'); + }); + + it('dashes out a missing level or tempo', () => { + const bare = { ...song, playLevel: undefined, bpm: undefined } as BeMusicResultData['song']; + const rows = resolveResultTrackRows({ song: bare, score: score({}) }); + expect(rows[1]!.value).toBe('-'); + expect(rows[2]!.value).toBe('-'); + }); +}); diff --git a/packages/skin-sdk/src/result-track.ts b/packages/skin-sdk/src/result-track.ts new file mode 100644 index 00000000..2292b9ee --- /dev/null +++ b/packages/skin-sdk/src/result-track.ts @@ -0,0 +1,39 @@ +import type { BeMusicResultData } from './result-data.ts'; +import { formatPlayVariantLabel } from './song-stats.ts'; + +/** + * The play's clear lamp as the result track column labels it: `PERFECT` (every note a PGREAT), `FULL COMBO` (no BAD / + * POOR), `CLEAR`, or `FAILED`. Empty POORs never break a combo, so they don't cost a full combo either. + */ +export type ResultLamp = 'PERFECT' | 'FULL COMBO' | 'CLEAR' | 'FAILED'; + +export function resolveResultLamp(result: Pick): ResultLamp { + const { score } = result; + if (!result.cleared) return 'FAILED'; + if (score.total <= 0) return 'CLEAR'; + if (score.bad + score.poor > 0) return 'CLEAR'; + return score.perfect >= score.total ? 'PERFECT' : 'FULL COMBO'; +} + +/** One label / value line of the track column. */ +export interface ResultTrackRow { + label: string; + value: string; +} + +/** + * The chart facts the 16:9 result screens list beside the score grid: play mode, level, tempo, and the combo breaks + * (BAD + POOR) of the run. + */ +export function resolveResultTrackRows(result: Pick): ResultTrackRow[] { + const { song, score } = result; + return [ + { label: 'MODE', value: formatPlayVariantLabel(song) }, + { + label: 'LEVEL', + value: song.playLevel !== undefined && String(song.playLevel) !== '' ? String(song.playLevel) : '-', + }, + { label: 'BPM', value: song.bpm !== undefined && Number.isFinite(song.bpm) ? String(Math.round(song.bpm)) : '-' }, + { label: 'COMBO BREAK', value: String(Math.max(0, score.bad + score.poor)) }, + ]; +} diff --git a/packages/skin-sdk/src/song-stats.test.ts b/packages/skin-sdk/src/song-stats.test.ts new file mode 100644 index 00000000..bb39961b --- /dev/null +++ b/packages/skin-sdk/src/song-stats.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vite-plus/test'; +import { parseChart } from '@be-music/parser'; +import { + formatBpmRange, + formatPlayVariantLabel, + formatSongLength, + resolveSongRowFacts, + resolveSongStats, + resolveSongTags, +} from './song-stats.ts'; + +function song(source: string) { + return { chart: parseChart(source, 'bms'), bpm: undefined }; +} + +const PLAIN = ['#BPM 120', '#WAV01 a.wav', '#00111:01010101', '#00211:01000000'].join('\n'); + +describe('resolveSongStats', () => { + it('measures a steady chart up to its last note', () => { + const stats = resolveSongStats(song(PLAIN)); + // Two 4/4 measures at 120 BPM: the last note sits on measure 2's downbeat, 4 seconds in. + expect(stats.lengthSeconds).toBeCloseTo(4, 3); + expect(stats.minBpm).toBe(120); + expect(stats.maxBpm).toBe(120); + expect(stats.longNotes).toBe(0); + expect(stats.mines).toBe(0); + expect(stats.stops).toBe(false); + expect(resolveSongTags(stats)).toEqual([]); + }); + + it('collects the tempo range, long notes, mines and stops', () => { + const stats = resolveSongStats( + song( + [ + '#BPM 120', + '#LNTYPE 1', + '#WAV01 a.wav', + '#STOP01 96', + '#00111:01010101', + '#00103:00F0', + '#00109:0001', + '#00151:01000100', + '#001D2:00000001', + '#00211:01000000', + ].join('\n'), + ), + ); + expect(stats.minBpm).toBe(120); + expect(stats.maxBpm).toBe(240); + expect(stats.longNotes).toBe(1); + expect(stats.mines).toBe(1); + expect(stats.stops).toBe(true); + expect(resolveSongTags(stats)).toEqual(['LN', 'SOFLAN', 'STOP', 'MINE']); + }); + + it('caches per chart', () => { + const entry = song(PLAIN); + expect(resolveSongStats(entry)).toBe(resolveSongStats(entry)); + }); +}); + +describe('formatSongLength', () => { + it('prints m:ss', () => { + expect(formatSongLength(125.4)).toBe('2:05'); + expect(formatSongLength(0)).toBe('0:00'); + expect(formatSongLength(Number.NaN)).toBe('0:00'); + }); +}); + +describe('formatBpmRange', () => { + it('prints one tempo or a range', () => { + expect(formatBpmRange(150, 150)).toBe('150'); + expect(formatBpmRange(119.6, 240.2)).toBe('120-240'); + expect(formatBpmRange(0, 0)).toBe('-'); + }); +}); + +describe('resolveSongRowFacts', () => { + it('formats the notes, length, tempo and tags of a row', () => { + const facts = resolveSongRowFacts({ ...song(PLAIN), totalNotes: 5 }); + expect(facts).toEqual({ notes: '5', length: '0:04', bpm: '120', tags: [] }); + }); +}); + +describe('formatPlayVariantLabel', () => { + it("names the chart's play variant", () => { + expect(formatPlayVariantLabel({ chartPath: 'a.bms', chart: parseChart(PLAIN, 'bms') })).toBe('5 KEYS'); + const sevenKeys = [PLAIN, '#00118:01000000'].join('\n'); + expect(formatPlayVariantLabel({ chartPath: 'a.bms', chart: parseChart(sevenKeys, 'bms') })).toBe('7 KEYS'); + }); +}); diff --git a/packages/skin-sdk/src/song-stats.ts b/packages/skin-sdk/src/song-stats.ts new file mode 100644 index 00000000..8810cfbc --- /dev/null +++ b/packages/skin-sdk/src/song-stats.ts @@ -0,0 +1,112 @@ +import type { BeMusicJson } from '@be-music/json'; +import { createTimingResolver } from '@be-music/audio-renderer/triggers'; +import { resolveChartPlayVariant } from '@be-music/chart'; +import { extractLandmineNotes, extractPlayableNotes } from '@be-music/player/playable-notes'; +import type { BrowserSongEntry } from './song.ts'; + +/** + * What a player wants to know about a chart before starting it, beyond its title and level: how long it runs, the + * tempo range it travels (soflan), and the gimmicks it carries (long notes, mines, stops). + */ +export interface SongStats { + /** Seconds from the chart's start to the last playable note's end. */ + lengthSeconds: number; + minBpm: number; + maxBpm: number; + longNotes: number; + mines: number; + /** Whether the chart has STOP sequences. */ + stops: boolean; +} + +const STATS = new WeakMap(); + +/** {@link SongStats} for a song, computed once per chart (the select screen asks every frame). */ +export function resolveSongStats(song: Pick): SongStats { + const cached = STATS.get(song.chart); + if (cached) return cached; + const stats = computeSongStats(song.chart, song.bpm); + STATS.set(song.chart, stats); + return stats; +} + +function computeSongStats(chart: BeMusicJson, fallbackBpm: number | undefined): SongStats { + let lengthSeconds = 0; + let longNotes = 0; + let mines = 0; + let minBpm = Number.POSITIVE_INFINITY; + let maxBpm = 0; + let stops = false; + try { + for (const note of extractPlayableNotes(chart, { inferBmsLnTypeWhenMissing: true })) { + lengthSeconds = Math.max(lengthSeconds, note.endSeconds ?? note.seconds); + if (note.endBeat !== undefined) longNotes += 1; + } + mines = extractLandmineNotes(chart).length; + const timing = createTimingResolver(chart); + for (const point of timing.tempoPoints) { + // Zero / negative tempos are stop-like gimmicks, not a tempo the player reads. + if (!(point.bpm > 0) || !Number.isFinite(point.bpm)) continue; + // Only tempos in effect before the last note count — a closing tempo change after it never plays. + if (point.seconds > lengthSeconds && minBpm !== Number.POSITIVE_INFINITY) continue; + minBpm = Math.min(minBpm, point.bpm); + maxBpm = Math.max(maxBpm, point.bpm); + } + stops = timing.stopPoints.some((stop) => stop.seconds > 0 && stop.seconds <= lengthSeconds); + } catch { + // A chart the timing pass can't read still lists; it just shows no extra facts. + } + if (minBpm === Number.POSITIVE_INFINITY) { + const bpm = fallbackBpm !== undefined && fallbackBpm > 0 ? fallbackBpm : 0; + minBpm = bpm; + maxBpm = bpm; + } + return { lengthSeconds, minBpm, maxBpm, longNotes, mines, stops }; +} + +/** `m:ss` play length. */ +export function formatSongLength(seconds: number): string { + const total = Math.max(0, Math.round(Number.isFinite(seconds) ? seconds : 0)); + return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}`; +} + +/** `150`, or `120-240` for a chart whose tempo changes. */ +export function formatBpmRange(minBpm: number, maxBpm: number): string { + const low = Math.round(minBpm); + const high = Math.round(maxBpm); + if (high <= 0) return '-'; + return low === high ? String(high) : `${low}-${high}`; +} + +/** Short gimmick tags for a row: `LN`, `MINE`, `STOP`, and `SOFLAN` when the tempo moves. */ +export function resolveSongTags(stats: SongStats): string[] { + const tags: string[] = []; + if (stats.longNotes > 0) tags.push('LN'); + if (Math.round(stats.minBpm) !== Math.round(stats.maxBpm)) tags.push('SOFLAN'); + if (stats.stops) tags.push('STOP'); + if (stats.mines > 0) tags.push('MINE'); + return tags; +} + +/** The facts a select-list row prints for a song, already formatted (see {@link resolveSongStats}). */ +export interface SongRowFacts { + notes: string; + length: string; + bpm: string; + tags: string[]; +} + +export function resolveSongRowFacts(song: Pick): SongRowFacts { + const stats = resolveSongStats(song); + return { + notes: String(Math.max(0, song.totalNotes)), + length: formatSongLength(stats.lengthSeconds), + bpm: formatBpmRange(stats.minBpm, stats.maxBpm), + tags: resolveSongTags(stats), + }; +} + +/** `'7K KEYS'`-style mode label for a song's play variant. */ +export function formatPlayVariantLabel(song: Pick): string { + return `${resolveChartPlayVariant({ chartPath: song.chartPath, events: song.chart.events, bms: song.chart.bms })} KEYS`; +} diff --git a/packages/skin-sdk/src/song.ts b/packages/skin-sdk/src/song.ts new file mode 100644 index 00000000..4a98e909 --- /dev/null +++ b/packages/skin-sdk/src/song.ts @@ -0,0 +1,41 @@ +import type { BeMusicJson } from '@be-music/json'; + +/** Where a song came from: a dropped directory, a zip, or loose files. */ +export type BrowserSongSourceKind = 'directory' | 'zip' | 'files'; + +/** One chart in the song collection: its metadata and the parsed chart. */ +export interface BrowserSongEntry { + id: string; + sourceId: string; + sourceLabel: string; + sourceKind: BrowserSongSourceKind; + chartPath: string; + directoryLabel: string; + fileLabel: string; + title: string; + subtitle?: string; + artist?: string; + genre?: string; + playLevel?: number | string; + bpm?: number; + totalNotes: number; + chart: BeMusicJson; +} + +/** + * One folder of songs surfaced by `groupSongsByFolder`. The label is the human-readable folder name (top-level + * directory inside the source, falling back to the source label) and `songs` are all the BMS charts whose + * `directoryLabel` resolves to it. + */ +export interface BrowserFolderNode { + label: string; + songs: readonly BrowserSongEntry[]; +} + +/** + * One entry in the bar list when navigating the song collection. A select view either shows folder bars (when at the + * root) or song bars (when inside a folder). + */ +export type BrowserBrowseEntry = + | { kind: 'folder'; folder: BrowserFolderNode } + | { kind: 'song'; song: BrowserSongEntry }; diff --git a/packages/skin-sdk/src/stage.test.ts b/packages/skin-sdk/src/stage.test.ts new file mode 100644 index 00000000..738658d2 --- /dev/null +++ b/packages/skin-sdk/src/stage.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vite-plus/test'; +import { STAGE_BGA_BAND, STAGE_SIDE_COLUMN, STAGE_WIDTH, resolveStageBgaRect } from './stage.ts'; + +describe('resolveStageBgaRect', () => { + it('gives single play the full band, centred between the lanes and the side column', () => { + const rect = resolveStageBgaRect(227); + expect(rect.w).toBe(STAGE_BGA_BAND.bottom - STAGE_BGA_BAND.top); + expect(rect.h).toBe(rect.w); + expect(rect.y).toBe(STAGE_BGA_BAND.top); + expect(rect.x).toBeGreaterThan(227 + 24); + expect(rect.x + rect.w).toBeLessThan(STAGE_SIDE_COLUMN.x); + const leftGap = rect.x - (227 + 32); + const rightGap = STAGE_SIDE_COLUMN.x - 20 - (rect.x + rect.w); + expect(Math.abs(leftGap - rightGap)).toBeLessThanOrEqual(1); + }); + + it('shrinks beside a double-play field instead of covering its lanes', () => { + const rect = resolveStageBgaRect(421); + expect(rect.w).toBeGreaterThan(200); + expect(rect.w).toBeLessThan(STAGE_BGA_BAND.bottom - STAGE_BGA_BAND.top); + expect(rect.x).toBeGreaterThanOrEqual(421 + 32); + expect(rect.x + rect.w).toBeLessThanOrEqual(STAGE_SIDE_COLUMN.x - 20); + // Still centred vertically in the band. + expect(Math.abs(rect.y + rect.h / 2 - (STAGE_BGA_BAND.top + STAGE_BGA_BAND.bottom) / 2)).toBeLessThanOrEqual(1); + }); + + it('hides the BGA when a page-wide keyboard field leaves no usable room', () => { + const rect = resolveStageBgaRect(628); + expect(rect.w).toBe(0); + expect(rect.h).toBe(0); + }); + + it('stays on the 16:9 canvas', () => { + expect(STAGE_WIDTH).toBe(854); + expect(STAGE_SIDE_COLUMN.x + STAGE_SIDE_COLUMN.w).toBeLessThanOrEqual(STAGE_WIDTH - 16); + }); +}); diff --git a/packages/skin-sdk/src/stage.ts b/packages/skin-sdk/src/stage.ts new file mode 100644 index 00000000..482139d2 --- /dev/null +++ b/packages/skin-sdk/src/stage.ts @@ -0,0 +1,51 @@ +import type { BeMusicRect, BeMusicStage } from './types.ts'; + +/** + * 16:9 design canvas shared by the built-in skins. It keeps the 480 px height of the LR2-compatible 640x480 canvas, so + * the playfield (lane widths, judgement line, note speed) is pixel-for-pixel the 4:3 one; the extra 214 px of width + * goes to the BGA and the HUD on the right. + */ +export const STAGE_WIDTH = 854; +export const STAGE_HEIGHT = 480; + +/** Horizontal page margin every built-in skin aligns its outer chrome to. */ +export const STAGE_MARGIN = 16; + +/** + * The column on the right edge holding the judgement tally, and the vertical band the BGA may use (between the header + * and the bottom HUD). + */ +export const STAGE_SIDE_COLUMN = { x: 746, w: 92 } as const; +export const STAGE_BGA_BAND = { top: 56, bottom: 340 } as const; +/** Gap kept between the playfield (rails included) and the BGA, and between the BGA and the side column. */ +const BGA_GUTTER_LEFT = 32; +const BGA_GUTTER_RIGHT = 20; +/** Below this the BGA would be a postage stamp (48 KEY spans the page); it is hidden instead. */ +const MIN_BGA_SIZE = 120; + +/** + * The BGA square for a playfield ending at `playfieldRight`: as large as the band allows (284 px) and centred in the + * room between the playfield and the side column, shrinking only when a wide playfield (double play) leaves less room. + * The square stays vertically centred in the band. A playfield too wide for a {@link MIN_BGA_SIZE} square + * gets an empty rect (no BGA). + */ +export function resolveStageBgaRect(playfieldRight: number): BeMusicRect { + const left = playfieldRight + BGA_GUTTER_LEFT; + const right = STAGE_SIDE_COLUMN.x - BGA_GUTTER_RIGHT; + const band = STAGE_BGA_BAND.bottom - STAGE_BGA_BAND.top; + const fit = Math.min(band, right - left); + const size = fit >= MIN_BGA_SIZE ? fit : 0; + return { + x: Math.round(left + (right - left - size) / 2), + y: Math.round(STAGE_BGA_BAND.top + (band - size) / 2), + w: size, + h: size, + }; +} + +/** The stage every built-in skin declares. */ +export const wideStage: BeMusicStage = { + width: STAGE_WIDTH, + height: STAGE_HEIGHT, + resolveBgaRect: resolveStageBgaRect, +}; diff --git a/packages/skin-sdk/src/tabular.test.ts b/packages/skin-sdk/src/tabular.test.ts new file mode 100644 index 00000000..8e7f13aa --- /dev/null +++ b/packages/skin-sdk/src/tabular.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vite-plus/test'; +import { layoutTabularRun } from './tabular.ts'; + +describe('layoutTabularRun', () => { + // A proportional face where "1" is narrow and "0" is wide. + const widths: Record = { '0': 10, '1': 4, '2': 9, '/': 5, '%': 12 }; + const measure = (char: string) => widths[char] ?? 8; + + it('gives every digit the widest digit advance', () => { + const { glyphs, width } = layoutTabularRun('101', measure); + expect(glyphs.map((glyph) => [glyph.char, glyph.x, glyph.w])).toEqual([ + ['1', 0, 10], + ['0', 10, 10], + ['1', 20, 10], + ]); + expect(width).toBe(30); + }); + + it('keeps the natural advance for non-digits', () => { + const { glyphs, width } = layoutTabularRun('1/2%', measure); + expect(glyphs.map((glyph) => glyph.w)).toEqual([10, 5, 10, 12]); + expect(glyphs.map((glyph) => glyph.x)).toEqual([0, 10, 15, 25]); + expect(width).toBe(37); + }); + + it('makes equal-length numbers equally wide regardless of digits', () => { + expect(layoutTabularRun('111', measure).width).toBe(layoutTabularRun('000', measure).width); + }); +}); diff --git a/packages/skin-sdk/src/tabular.ts b/packages/skin-sdk/src/tabular.ts new file mode 100644 index 00000000..4229b5c6 --- /dev/null +++ b/packages/skin-sdk/src/tabular.ts @@ -0,0 +1,33 @@ +/** Tabular-figure layout for numerals drawn glyph by glyph. */ + +export interface TabularGlyph { + char: string; + /** Left edge of the glyph's cell, relative to the run's start. */ + x: number; + /** Cell width — the shared digit advance for `0-9`, the glyph's own advance otherwise. */ + w: number; +} + +/** + * Lays out a string with tabular (fixed-width) figures. Canvas 2D — which Pixi `Text` rasterizes through — exposes no + * `font-feature-settings`, so OpenType `tnum` can't be switched on; instead every digit gets a cell as wide as the + * widest digit, and callers centre each glyph in its cell. Other characters (`/`, `%`, `.`, `x`, spaces) keep their + * natural advance. Returns the cells plus the run's total width. + */ +export function layoutTabularRun( + text: string, + measure: (char: string) => number, +): { glyphs: TabularGlyph[]; width: number } { + let digitAdvance = 0; + for (let digit = 0; digit <= 9; digit += 1) { + digitAdvance = Math.max(digitAdvance, measure(String(digit))); + } + const glyphs: TabularGlyph[] = []; + let x = 0; + for (const char of text) { + const w = char >= '0' && char <= '9' ? digitAdvance : measure(char); + glyphs.push({ char, x, w }); + x += w; + } + return { glyphs, width: x }; +} diff --git a/packages/skin-sdk/src/types.ts b/packages/skin-sdk/src/types.ts new file mode 100644 index 00000000..7c8fb243 --- /dev/null +++ b/packages/skin-sdk/src/types.ts @@ -0,0 +1,375 @@ +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'; + +/** + * be-music skin format — the code-defined skins the built-in (default) family renders with when no LR2 / beatoraja + * theme is loaded. Unlike LR2 / beatoraja themes (data files interpreted by a scene), a be-music skin draws every + * screen itself onto a canvas the player hands it, with whatever it likes: the Canvas 2D API, raw WebGL / WebGPU, or a + * framework such as PixiJS or three.js that the skin brings along. The player owns input, timing, audio, judging, and + * layout, collects what is on screen each frame as plain data, and shows the skin's canvas. + * + * Nothing here depends on a rendering framework. Skins are written against the public `@be-music/player-web/skin-sdk` + * subpath and declared with its `defineBeMusicSkin`, which checks {@link BeMusicSkin.apiVersion} and the metadata. + * Hosts pick one with {@link BeMusicSkin.id} from a registry (`createBeMusicSkinRegistry`) and pass it to the default + * scene classes via their `beMusicSkin` option. + */ +export interface BeMusicSkin { + /** + * The skin API revision the skin was written against (`BE_MUSIC_SKIN_API_VERSION` when it was built). Hosts refuse a + * skin whose revision they don't support and fall back to a built-in one. + */ + readonly apiVersion: number; + /** Stable identifier (persisted by hosts, e.g. `'phantom'`). */ + readonly id: string; + /** Human-readable name for pickers. */ + readonly label: string; + /** The skin's own release, as a semantic version (`'1.2.0'`). */ + readonly version: string; + /** Who made the skin. */ + readonly author: BeMusicSkinAuthor; + /** One or two sentences for skin pickers. */ + readonly description?: string; + /** Where to find the skin (project page, repository). */ + readonly homepage?: string; + /** SPDX license identifier of the skin's code and assets (`'MIT'`). */ + readonly license?: string; + /** + * CSS font shorthands (`'400 24px "Anton"'`) the skin draws with. Hosts should load the faces (e.g. via Google Fonts) + * and may `document.fonts.load` these before mounting so the first frame doesn't rasterize with a fallback face. + */ + readonly fontLoads: readonly string[]; + /** Design canvas the skin draws on and where its gameplay BGA sits. Defaults to the 16:9 `wideStage`. */ + readonly stage?: BeMusicStage; + /** Which canvas context the player creates for each surface. */ + readonly context: K; + /** Attributes passed to `canvas.getContext` (e.g. `{ alpha: true }`, `{ antialias: true, stencil: true }`). */ + readonly contextAttributes?: CanvasRenderingContext2DSettings | WebGLContextAttributes; + /** + * Called once for every new surface before its first draw — create a framework renderer on the canvas, compile + * shaders, request a GPU device. Drawing waits until a returned promise settles. + */ + setup?(surface: BeMusicSurface): void | Promise; + /** Called when the player is done with a surface — release what `setup` created (renderers, GPU resources). */ + teardown?(surface: BeMusicSurface): void; + readonly gameplay: BeMusicGameplaySkin; + readonly select: BeMusicSelectSkin; + readonly result: BeMusicResultSkin; +} + +/** A skin's maker, as shown in skin pickers and credits. */ +export interface BeMusicSkinAuthor { + readonly name: string; + /** Profile, home page, or contact URL. */ + readonly url?: string; +} + +/** Canvas rendering contexts a skin can draw with. */ +export interface BeMusicSurfaceContextMap { + '2d': CanvasRenderingContext2D; + webgl: WebGLRenderingContext; + webgl2: WebGL2RenderingContext; + webgpu: GPUCanvasContext; +} + +export type BeMusicSurfaceContextKind = keyof BeMusicSurfaceContextMap; + +/** + * The canvas a skin draws one screen on. It is sized to the device pixels the stage covers on screen, so drawing at + * `pixelRatio` canvas pixels per design pixel shows dot by dot. + */ +export interface BeMusicSurface { + canvas: HTMLCanvasElement; + context: BeMusicSurfaceContextMap[K]; + /** Size in design pixels (the stage size). */ + width: number; + height: number; + /** Canvas pixels per design pixel. `'2d'` surfaces come cleared and pre-scaled by it; other contexts apply it. */ + pixelRatio: number; +} + +/** Axis-aligned rectangle in design pixels. */ +export interface BeMusicRect { + x: number; + y: number; + w: number; + h: number; +} + +/** A skin's design canvas (see {@link BeMusicSkin.stage}). */ +export interface BeMusicStage { + readonly width: number; + readonly height: number; + /** + * The gameplay BGA rect for a playfield whose rightmost lane ends at `playfieldRight`. The scene composites the BGA + * here and the skin frames the same rect, so the BGA can make room for wide (double play / keyboard) playfields. + */ + resolveBgaRect(playfieldRight: number): BeMusicRect; +} + +/** + * How much showmanship a skin should put on: `'full'` (everything), `'reduced'` (no screen shake / full-screen flashes, + * lighter particle counts — also the sensible default for `prefers-reduced-motion`), or `'off'` (static chrome and a + * plain hit flash only). + */ +export type BeMusicEffectLevel = 'full' | 'reduced' | 'off'; + +/** Visual class of a lane, resolved from its channel — skins pick colours per class. */ +export type BeMusicLaneKind = 'white' | 'black' | 'scratch'; + +/** One lane of the gameplay layout, in design pixels. */ +export interface BeMusicLayoutLane { + channel: string | undefined; + kind: BeMusicLaneKind; + side: '1P' | '2P'; + x: number; + w: number; +} + +/** + * Where everything sits on the gameplay stage this frame, as the host resolved it: the stage size, every lane, the + * playfield's bounds and judgement line, and the BGA rect (from the skin's {@link BeMusicStage}). Skins place their + * chrome from this instead of computing geometry themselves, so host-side layout changes (double play, keyboard + * modes, new aspect ratios) reach every skin. + */ +export interface BeMusicGameplayLayout { + stage: { width: number; height: number }; + lanes: readonly BeMusicLayoutLane[]; + playfield: { + /** Left edge of the leftmost lane / right edge of the rightmost lane. */ + left: number; + right: number; + centerX: number; + /** Top of the lanes, and the judgement line notes land on. */ + top: number; + judgementY: number; + /** Horizontal extent of each play side present (`2P` only in double play). */ + sides: Partial>; + }; + /** The BGA rect, or `undefined` when the playfield leaves no room for one. */ + bga: BeMusicRect | undefined; +} + +/** One side's recent judgement. */ +export interface BeMusicJudgeState { + side: '1P' | '2P'; + /** PERFECT / GREAT / GOOD / BAD / POOR. Empty when no recent judge. */ + judge?: string; + combo?: number; +} + +/** Live gameplay values for the HUD. */ +export interface BeMusicGameplayRuntime { + songTitle?: string; + songArtist?: string; + bpm?: number; + hiSpeed?: number; + score?: number; + exScore?: number; + exScoreMax?: number; + combo?: number; + maxCombo?: number; + perfect?: number; + great?: number; + good?: number; + bad?: number; + poor?: number; + gauge?: number; + clearThreshold?: number; + laneCount?: number; + laneChannels?: readonly string[]; + playVariant?: ChartPlayVariant; + /** PERFECT / GREAT / GOOD / BAD / POOR. Empty when no recent judge. */ + lastJudge?: string; + /** Per-side judgement snapshots. DP renderers can paint 1P / 2P independently from these values. */ + judgeSides?: readonly BeMusicJudgeState[]; + /** AAA / AA / A / B / C / D / E / F. */ + rank?: string; + autoplay?: boolean; + hasBga?: boolean; + /** Monotonic play clock (ms) for animation. */ + nowMs?: number; + /** Song progress in [0, 1] — drives a progress track. */ + progressRatio?: number; + /** Fractional beat position in [0, 1) — drives beat-synced pulses. */ + beatPhase?: number; + /** Active compat ruleset id (`lr2` / `beatoraja` / `iidx`). */ + rulesetLabel?: string; + /** Ruleset-scoped gauge id (`GROOVE` / `HARD` / `HAZARD` / ...) labelling the gauge. */ + gaugeLabel?: string; + /** True for survival gauges — no clear notch. */ + gaugeSurvival?: boolean; + /** FAST (early GREAT/GOOD) count. */ + fast?: number; + /** SLOW (late GREAT/GOOD) count. */ + slow?: number; + /** Playable notes in the chart (0 while unknown). */ + totalNotes?: number; + /** + * Milliseconds since the chart's first beat: negative during the intro count-in, `undefined` before the play has + * been scheduled. Lets chrome stage entrance cut-ins against the real start. + */ + chartMs?: number; + /** True while the chart's audio / BGA are still loading (or a BGA video is transcoding) before the play starts. */ + loading?: boolean; + /** Play-clock ms of the most recent judgement — drives the judge / combo "punch". */ + judgeAtMs?: number; + /** Play-clock ms and lane class of the most recent key press (or autoplay hit) — drives input-reactive visuals. */ + impulseAtMs?: number; + impulseKind?: BeMusicLaneKind; + /** Showmanship level to render at (defaults to `'full'`). */ + effects?: BeMusicEffectLevel; + /** Live analysis of the mix for audio-reactive chrome (loudness, spectrum, onsets); absent without Web Audio. */ + audio?: BeMusicAudioFrame; +} + +export interface BeMusicLaneFrame { + channel: string; + kind: BeMusicLaneKind; + x: number; + w: number; + top: number; + /** Judgement line y — notes land with their bottom edge here. */ + bottom: number; + /** Key-beam intensity in [0, 1]: 1 while held, decaying after release. */ + beam: number; +} + +/** A tap note as drawn this frame: its lane rect and the y its bottom edge sits at. */ +export interface BeMusicNote { + kind: BeMusicLaneKind; + x: number; + w: number; + y: number; +} + +/** A long note as drawn this frame: its lane rect, tail (top) and head (bottom, clamped to the line while held). */ +export interface BeMusicLongNote { + kind: BeMusicLaneKind; + x: number; + w: number; + top: number; + bottom: number; +} + +export interface BeMusicBomb { + channel: string; + kind: BeMusicLaneKind; + x: number; + w: number; + /** Judgement line y of the lane. */ + y: number; + /** Milliseconds since the hit. */ + elapsedMs: number; + /** Stable per-hit seed so particle scatter doesn't shimmer frame to frame. */ + seed: number; +} + +/** Everything on the gameplay screen this frame. */ +export interface BeMusicGameplayFrame { + nowMs: number; + /** HUD values: score, combo, gauge, judgements, song, loading, … */ + runtime: BeMusicGameplayRuntime; + /** Stage, lanes, playfield bounds, judgement line, and the BGA rect (leave it transparent: the video is behind). */ + layout: BeMusicGameplayLayout; + /** Every lane with its key-beam intensity. */ + lanes: readonly BeMusicLaneFrame[]; + notes: readonly BeMusicNote[]; + longNotes: readonly BeMusicLongNote[]; + /** Live hit effects with their age. */ + bombs: readonly BeMusicBomb[]; + /** Fractional beat position in [0, 1). */ + beatPhase: number; + effects: BeMusicEffectLevel; + audio: BeMusicAudioFrame | undefined; +} + +export interface BeMusicGameplaySkin { + /** Draws one gameplay frame. Called once per frame, right before the player renders. */ + draw(surface: BeMusicSurface, frame: BeMusicGameplayFrame): void; + /** How long a hit effect lives (ms) before the player retires it. Default 300. */ + readonly bombDurationMs?: number; +} + +/** Song-list geometry shared by the skin (drawing) and the scene (row hit-testing). */ +export interface BeMusicSelectLayout { + listX: number; + listTop: number; + /** Distance from the canvas bottom to the list's last row edge. */ + listBottomInset: number; + rowHeight: number; +} + +export interface BeMusicSelectActions { + play: () => void; + autoPlay: () => void; + activateSearch: () => void; +} + +/** Everything on the select screen this frame. */ +export interface BeMusicSelectFrame { + designWidth: number; + designHeight: number; + nowMs: number; + /** + * Increases whenever the select state changes (cursor, folder, search, list contents). Skins that keep a scene graph + * can rebuild it only when this moves. + */ + revision: number; + /** `performance.now()` when the scene was (re)shown — drives entrance animations. */ + sceneStartedAt: number; + /** `performance.now()` of the last cursor move — drives focus transitions. */ + cursorChangedAt: number; + entries: readonly BrowserBrowseEntry[]; + selectedIndex: number; + /** First entry index drawn in the list window, and how many rows fit (see `resolveSelectListWindow`). */ + firstVisibleIndex: number; + visibleRows: number; + focusedSong: BrowserSongEntry | undefined; + /** Current folder label (or search label), `undefined` at the library root. */ + folderLabel: string | undefined; + searchQuery: string; + totalCharts: number; + actions: BeMusicSelectActions; + effects: BeMusicEffectLevel; + /** `performance.now()` when a chart was launched and the skin's outro is playing, otherwise `undefined`. */ + launchAt: number | undefined; + /** What is playing right now: the BGM or the chart preview (see {@link BeMusicAudioFrame}). */ + audio: BeMusicAudioFrame | undefined; + /** + * 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). + */ + hit(x: number, y: number, w: number, h: number, action: () => void, cursor?: string): void; +} + +export interface BeMusicSelectSkin { + /** Where the song list sits (the player hit-tests the rows with it). */ + readonly layout: BeMusicSelectLayout; + /** Length of the launch outro (ms) the select screen keeps drawing after a chart is picked. Default 0. */ + readonly outroMs?: number; + /** Draws one select frame. Called every frame while the select screen is shown. */ + draw(surface: BeMusicSurface, frame: BeMusicSelectFrame): void; +} + +/** Everything on the result screen this frame. */ +export interface BeMusicResultFrame { + designWidth: number; + designHeight: number; + result: BeMusicResultData; + /** IIDX DJ level (`AAA` … `F`). */ + rankLabel: string; + /** EX-score rate in percent. */ + ratePercent: number; + /** Milliseconds since the scene mounted, or `Infinity` once the player skipped the entrance. */ + elapsedMs: number; + nowMs: number; + effects: BeMusicEffectLevel; +} + +export interface BeMusicResultSkin { + /** Draws one result frame. Called every frame while the result screen is shown. */ + draw(surface: BeMusicSurface, frame: BeMusicResultFrame): void; +} diff --git a/packages/skin-sdk/tsconfig.build.json b/packages/skin-sdk/tsconfig.build.json new file mode 100644 index 00000000..30b0433b --- /dev/null +++ b/packages/skin-sdk/tsconfig.build.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.build.options.json", + "compilerOptions": { + "allowImportingTsExtensions": true, + "rootDir": "src", + "outDir": "dist", + "emitDeclarationOnly": true + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts", "src/**/*.spec.ts"] +} diff --git a/packages/skin-sdk/tsconfig.json b/packages/skin-sdk/tsconfig.json new file mode 100644 index 00000000..1cf58a8b --- /dev/null +++ b/packages/skin-sdk/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.typecheck.json", + "include": ["src/**/*.ts", "scripts/**/*.ts"] +} diff --git a/packages/skin-sdk/vite.config.ts b/packages/skin-sdk/vite.config.ts new file mode 100644 index 00000000..9a34b4a7 --- /dev/null +++ b/packages/skin-sdk/vite.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vite-plus'; +import { createPackageConfig } from '../../vite.package.config.ts'; + +export default defineConfig({ + pack: createPackageConfig({ + packageDir: import.meta.dirname, + entries: { + index: 'src/index.ts', + }, + }), +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cfd0c544..2f77257c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -54,6 +54,9 @@ importers: '@be-music/player-web': specifier: workspace:* version: link:packages/player-web + '@be-music/skin-sdk': + specifier: workspace:* + version: link:packages/skin-sdk '@be-music/stringifier': specifier: workspace:* version: link:packages/stringifier @@ -261,6 +264,9 @@ importers: '@be-music/player': specifier: workspace:* version: link:../player + '@be-music/skin-sdk': + specifier: workspace:* + version: link:../skin-sdk '@be-music/utils': specifier: workspace:* version: link:../utils @@ -329,6 +335,25 @@ importers: specifier: ^4.145.0 version: 4.145.0(@cloudflare/workers-types@5.20261001.1)(@types/node@26.6.1) + packages/skin-sdk: + dependencies: + '@be-music/audio-renderer': + specifier: workspace:* + version: link:../audio-renderer + '@be-music/chart': + specifier: workspace:* + version: link:../chart + '@be-music/json': + specifier: workspace:* + version: link:../json + '@be-music/player': + specifier: workspace:* + version: link:../player + devDependencies: + '@be-music/parser': + specifier: workspace:* + version: link:../parser + packages/stringifier: dependencies: '@be-music/chart': diff --git a/scripts/bench/aggregate-results.test.ts b/scripts/bench/aggregate-results.test.ts index 2c66cdb5..190ea03f 100644 --- a/scripts/bench/aggregate-results.test.ts +++ b/scripts/bench/aggregate-results.test.ts @@ -30,6 +30,7 @@ function createSnapshot( player: [], 'player-tui': [], 'lr2-skin': [], + 'skin-sdk': [], 'player-web': [], }, totals: { diff --git a/scripts/bench/cli-utils.test.ts b/scripts/bench/cli-utils.test.ts index 60f0ec8f..312e6202 100644 --- a/scripts/bench/cli-utils.test.ts +++ b/scripts/bench/cli-utils.test.ts @@ -45,6 +45,7 @@ function createSnapshot(overrides: Partial = {}): Expo player: [], 'player-tui': [], 'lr2-skin': [], + 'skin-sdk': [], 'player-web': [], }, totals: { diff --git a/scripts/bench/compare-results.test.ts b/scripts/bench/compare-results.test.ts index bd1513b9..f6068ef2 100644 --- a/scripts/bench/compare-results.test.ts +++ b/scripts/bench/compare-results.test.ts @@ -36,6 +36,7 @@ function createSnapshot( player: [], 'player-tui': [], 'lr2-skin': [], + 'skin-sdk': [], 'player-web': [], }, totals: { diff --git a/scripts/bench/exports.types.ts b/scripts/bench/exports.types.ts index 4ed6bd32..257fa0b6 100644 --- a/scripts/bench/exports.types.ts +++ b/scripts/bench/exports.types.ts @@ -13,6 +13,7 @@ export const PACKAGE_NAMES = [ 'player', 'player-tui', 'lr2-skin', + 'skin-sdk', 'player-web', ] as const; diff --git a/scripts/bench/packages/index.ts b/scripts/bench/packages/index.ts index 9ae2cfdd..f46f3321 100644 --- a/scripts/bench/packages/index.ts +++ b/scripts/bench/packages/index.ts @@ -13,6 +13,7 @@ import { lr2SkinBenchmarkPackage } from './lr2-skin.ts'; import { playerBenchmarkPackage } from './player.ts'; import { playerTuiBenchmarkPackage } from './player-tui.ts'; import { playerWebCoreBenchmarkPackage } from './player-web.ts'; +import { skinSdkBenchmarkPackage } from './skin-sdk.ts'; import { stringifierBenchmarkPackage } from './stringifier.ts'; import { utilsBenchmarkPackage } from './utils.ts'; @@ -27,6 +28,7 @@ export const PACKAGE_DEFINITIONS: Record, + registerCases: registerSkinSdkExportsCases, +}; diff --git a/vite.config.ts b/vite.config.ts index c2ca73e1..eb010b91 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -15,6 +15,7 @@ const workspacePackages = [ 'player-tui', 'lr2-skin', 'beatoraja-skin', + 'skin-sdk', 'editor', 'player-web', ] as const;