From cff4ec9185acf75718e9da073c53d7cd96cc59c2 Mon Sep 17 00:00:00 2001 From: Seiya KONNO Date: Mon, 28 Sep 2026 18:19:10 +0900 Subject: [PATCH 01/65] feat(player-web): add showpiece moments and a combo zone to the built-in skins Detect combo milestones, the gauge crossing the clear line, and a full combo as pure state transitions, and stage them in both skins: - Phantom: READY?/GO!! count-in slash, 100-combo banner cut-ins, a CLEAR! pop, a full-combo burst with confetti, and a RESULT ink wipe - Synesthesia: a light count-in and start ring, combo shockwaves, a gauge sweep, and a full-combo 3D spark sphere with prism rings Synesthesia also escalates with the combo (denser, faster stars, hotter grid, aurora, beat rings, hyperspace streaks, bigger bursts) and dims overlapping hit flashes so colour survives dense chords. --- .changeset/be-music-skins.md | 3 + .../player-web/src/scene/core/gameplay.ts | 18 +- .../src/scene/default/gameplay-render.ts | 3 + .../src/scene/default/moments.test.ts | 77 +++++ .../player-web/src/scene/default/moments.ts | 90 +++++ .../src/scene/default/phantom/moments.ts | 315 ++++++++++++++++++ .../src/scene/default/phantom/result.ts | 20 ++ .../default/synesthesia/gameplay-chrome.ts | 106 +++++- .../src/scene/default/synesthesia/moments.ts | 281 ++++++++++++++++ .../scene/default/synesthesia/playfield.ts | 44 ++- .../player-web/src/scene/gameplay-chrome.ts | 7 + .../player-web/src/skin/be-music/types.ts | 4 + 12 files changed, 936 insertions(+), 32 deletions(-) create mode 100644 packages/player-web/src/scene/default/moments.test.ts create mode 100644 packages/player-web/src/scene/default/moments.ts create mode 100644 packages/player-web/src/scene/default/phantom/moments.ts create mode 100644 packages/player-web/src/scene/default/synesthesia/moments.ts diff --git a/.changeset/be-music-skins.md b/.changeset/be-music-skins.md index d95a7be3..e067e503 100644 --- a/.changeset/be-music-skins.md +++ b/.changeset/be-music-skins.md @@ -11,3 +11,6 @@ The default (skinless) family now renders through a `BeMusicSkin`: one object bu - 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. - 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 detonates a ring of light), a cut-in every 100 combo, a flourish when the gauge crosses the clear line, and a full-combo finale (Phantom's turning burst with confetti, Synesthesia's 3D spark sphere and prism rings). 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 stars speed up and multiply, the floor grid burns hotter, aurora ribbons and beat rings appear, stars smear into hyperspace streaks, and hits throw more sparks further. Overlapping hits now dim their white flash so colour stays the hero. +- `SkinlessGameplayChromeRuntime` gains `totalNotes` and `chartMs`, and the be-music lane and bomb contexts gain `combo`. diff --git a/packages/player-web/src/scene/core/gameplay.ts b/packages/player-web/src/scene/core/gameplay.ts index 5c8654cc..e6fc792b 100644 --- a/packages/player-web/src/scene/core/gameplay.ts +++ b/packages/player-web/src/scene/core/gameplay.ts @@ -693,6 +693,8 @@ 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. @@ -3164,7 +3167,7 @@ export class CoreGameplayView 0 ? this.score.total : 0; const seconds = this.currentSeconds(); @@ -3418,6 +3431,8 @@ export class CoreGameplayView { + 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(); + }); +}); diff --git a/packages/player-web/src/scene/default/moments.ts b/packages/player-web/src/scene/default/moments.ts new file mode 100644 index 00000000..f55dd5bd --- /dev/null +++ b/packages/player-web/src/scene/default/moments.ts @@ -0,0 +1,90 @@ +/** + * "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; +} + +export const COMBO_MILESTONE = 100; + +export function createMomentState(): MomentState { + return { lastCombo: 0, lastGauge: 0, milestone: undefined, clearAtMs: undefined, fullComboAtMs: 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 (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; +} diff --git a/packages/player-web/src/scene/default/phantom/moments.ts b/packages/player-web/src/scene/default/phantom/moments.ts new file mode 100644 index 00000000..8db04472 --- /dev/null +++ b/packages/player-web/src/scene/default/phantom/moments.ts @@ -0,0 +1,315 @@ +import { Graphics, type Container } from 'pixi.js'; +import { DESIGN_HEIGHT, DESIGN_WIDTH, GROOVE } from '../../gameplay-constants.ts'; +import type { SkinlessGameplayChromeRuntime } from '../../gameplay-chrome.ts'; +import type { ChildPool } from '../../pixi-utils.ts'; +import { DEFAULT_DISPLAY_FONT } from '../fonts.ts'; +import { addHudText } from '../hud-text.ts'; +import { momentProgress, trackMoments } from '../moments.ts'; +import { + PHANTOM_GOLD, + PHANTOM_INK, + PHANTOM_PAPER, + PHANTOM_RED, + PHANTOM_RED_HOT, + PHANTOM_WHITE, + easeOutBack, + easeOutCubic, + hash01, + parallelogramPoints, + stageProgress, + starburstPoints, +} from '../phantom-style.ts'; + +const MILESTONE_MS = 1100; +const CLEAR_MS = 1000; +const FULL_COMBO_MS = 3000; +const SKEW = -0.18; + +/** + * Phantom showpieces, painted on the front layer above the HUD: + * + * - the count-in: an ink slab slashes across the screen with "READY?", flips red and slams "GO!!", then tears away + * to the right as the first beat lands; + * - every 100 combo: a red banner with a gold burst cuts in over the BGA side (never over the lanes); + * - the gauge crossing the clear line: a gold CLEAR! tag pops off the gauge with a flash; + * - a full combo: a white flash, a giant turning burst, confetti, and FULL COMBO stamped across the screen (the chart + * is over, so it may cover the playfield). + */ +export function drawPhantomMoments( + key: object, + layer: Container, + runtime: SkinlessGameplayChromeRuntime, + pool: ChildPool | undefined, +): void { + const nowMs = runtime.nowMs ?? 0; + const moments = trackMoments(key, { + 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 graphics = pool?.acquireGraphics() ?? new Graphics(); + graphics.label = 'phantom/moments'; + if (!pool) layer.addChild(graphics); + + 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, 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, pool); + } +} + +/** 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 enter = easeOutCubic(stageProgress(chartMs, -2800, 320)); + const exit = easeOutCubic(stageProgress(chartMs, 0, 420)); + const go = chartMs >= -650; + const shift = (1 - enter) * -DESIGN_WIDTH * 1.2 + exit * DESIGN_WIDTH * 1.3; + const alpha = 1 - exit; + // Shadow slab, main slab, and paper cut lines. + graphics + .poly(parallelogramPoints(shift - 60, cy - 62, DESIGN_WIDTH + 120, 124, 40)) + .fill({ color: go ? PHANTOM_INK : PHANTOM_RED, alpha: 0.9 * alpha }); + graphics + .poly(parallelogramPoints(shift - 60, cy - 52, DESIGN_WIDTH + 120, 104, 40)) + .fill({ color: go ? PHANTOM_RED : PHANTOM_INK, alpha: alpha }); + graphics + .poly(parallelogramPoints(shift - 60, cy - 56, DESIGN_WIDTH + 120, 3, 40)) + .fill({ color: PHANTOM_WHITE, alpha }); + graphics + .poly(parallelogramPoints(shift - 60, cy + 50, DESIGN_WIDTH + 120, 3, 40)) + .fill({ color: PHANTOM_WHITE, alpha }); + if (!go) { + // A nervous little shake while waiting. + const shake = Math.sin(chartMs / 28) * 1.6; + addHudText( + layer, + 'READY?', + DESIGN_WIDTH / 2 + shift + shake, + cy, + { + size: 64, + fill: PHANTOM_WHITE, + fontFamily: DEFAULT_DISPLAY_FONT, + letterSpacing: 6, + anchorX: 0.5, + anchorY: 0.5, + skewX: SKEW, + dropShadow: { color: PHANTOM_RED, alpha: 1, blur: 0, distance: 6, angle: Math.PI / 4 }, + }, + pool, + ); + return; + } + const slam = easeOutBack(stageProgress(chartMs, -650, 280)); + const scale = 2.2 - 1.2 * slam; + graphics + .poly(starburstPoints(DESIGN_WIDTH / 2 + shift, cy, 150 * scale, 96 * scale, 16, chartMs / 900, 0.18, 7)) + .fill({ color: PHANTOM_INK, alpha: 0.55 * alpha }); + const text = addHudText( + layer, + 'GO!!', + DESIGN_WIDTH / 2 + shift, + cy, + { + size: 88, + fill: PHANTOM_GOLD, + fontFamily: DEFAULT_DISPLAY_FONT, + letterSpacing: 4, + anchorX: 0.5, + anchorY: 0.5, + skewX: SKEW, + 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); + text.alpha = alpha; +} + +/** "100 COMBO!!" banner cutting in over the BGA side. */ +function drawMilestone( + graphics: Graphics, + layer: Container, + value: number, + t: number, + pool: ChildPool | undefined, +): void { + const inT = easeOutCubic(Math.min(1, t / 0.14)); + const outT = easeOutCubic(Math.max(0, (t - 0.8) / 0.2)); + const x = 300 + (1 - inT) * 380 + outT * 380; + const y = 196; + graphics.poly(parallelogramPoints(x + 8, y + 8, 360, 58, 18)).fill(PHANTOM_INK); + graphics + .poly(parallelogramPoints(x, y, 360, 58, 18)) + .fill(PHANTOM_RED) + .stroke({ color: PHANTOM_WHITE, width: 3 }); + const burstX = x + 58; + graphics + .poly(starburstPoints(burstX, y + 29, 50, 32, 12, t * 3, 0.22, value)) + .fill(PHANTOM_GOLD) + .stroke({ color: PHANTOM_INK, width: 3 }); + addHudText( + layer, + String(value), + burstX + 2, + y + 29, + { + size: 34, + fill: PHANTOM_INK, + fontFamily: DEFAULT_DISPLAY_FONT, + anchorX: 0.5, + anchorY: 0.5, + skewX: SKEW, + maxWidth: 70, + }, + pool, + ); + addHudText( + layer, + 'COMBO!!', + x + 118, + y + 29, + { + size: 40, + fill: PHANTOM_WHITE, + fontFamily: DEFAULT_DISPLAY_FONT, + letterSpacing: 3, + anchorY: 0.5, + skewX: SKEW, + dropShadow: { color: PHANTOM_INK, alpha: 1, blur: 0, distance: 4, angle: Math.PI / 4 }, + }, + pool, + ); +} + +/** Gauge crossing the clear line: a flash over the gauge and a CLEAR! 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 + 62; + const cy = GROOVE.y - 6 - lift; + graphics + .poly(parallelogramPoints(cx - 44 * pop, cy - 14 * pop, 88 * pop, 28 * pop, 8 * pop)) + .fill({ color: PHANTOM_GOLD, alpha }) + .stroke({ color: PHANTOM_INK, width: 2, alpha }); + const text = addHudText( + layer, + 'CLEAR!', + cx + 3, + cy, + { + size: 18, + 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, + 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 = Math.max(0, 1 - t * 7); + 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 }); + } + const slam = easeOutBack(stageProgress(t, 0.06, 0.12)); + graphics + .poly(parallelogramPoints(-40, cy - 46, DESIGN_WIDTH + 80, 92, 30)) + .fill({ color: PHANTOM_INK, alpha: 0.8 * fadeOut * Math.min(1, slam) }); + const text = addHudText( + layer, + 'FULL COMBO', + cx, + cy, + { + size: 72, + fill: PHANTOM_GOLD, + fontFamily: DEFAULT_DISPLAY_FONT, + letterSpacing: 4, + anchorX: 0.5, + anchorY: 0.5, + skewX: SKEW, + stroke: { color: PHANTOM_INK, width: 8, alignment: 0.5, join: 'miter' }, + dropShadow: { color: PHANTOM_RED, alpha: 1, blur: 0, distance: 7, angle: Math.PI / 4 }, + maxWidth: DESIGN_WIDTH - 60, + }, + pool, + ); + text.scale.set(Math.max(0.01, 2.4 - 1.4 * slam)); + text.alpha = fadeOut * Math.min(1, slam * 1.5); +} + +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/result.ts b/packages/player-web/src/scene/default/phantom/result.ts index 1ae74d2c..0cdc4268 100644 --- a/packages/player-web/src/scene/default/phantom/result.ts +++ b/packages/player-web/src/scene/default/phantom/result.ts @@ -360,6 +360,26 @@ export function renderPhantomResult(frame: BeMusicResultFrame): void { anchorY: 0.5, }); slideIn(footer.root, 420, 0, 50); + + // Entrance wipe: the screen opens behind an ink slab carrying a giant RESULT slug that tears away to the right with a + // red leading 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); + wipe.g + .poly([edge + 120, 0, designWidth + 200, 0, designWidth + 200, designHeight, edge, designHeight]) + .fill(PHANTOM_INK); + wipe.g.poly([edge + 80, 0, edge + 128, 0, edge + 8, designHeight, edge - 40, designHeight]).fill(PHANTOM_RED); + wipe.g.poly([edge + 136, 0, edge + 142, 0, edge + 22, designHeight, edge + 16, designHeight]).fill(PHANTOM_WHITE); + addText(wipe.root, 'RESULT', edge + 150 + designWidth / 2, designHeight / 2, { + ...display(120, PHANTOM_WHITE), + letterSpacing: 8, + anchorX: 0.5, + anchorY: 0.5, + }); + } } function renderMetric( diff --git a/packages/player-web/src/scene/default/synesthesia/gameplay-chrome.ts b/packages/player-web/src/scene/default/synesthesia/gameplay-chrome.ts index d03c6726..2f8a893e 100644 --- a/packages/player-web/src/scene/default/synesthesia/gameplay-chrome.ts +++ b/packages/player-web/src/scene/default/synesthesia/gameplay-chrome.ts @@ -4,6 +4,8 @@ import type { SkinlessGameplayChromeRenderContext, SkinlessGameplayChromeRuntime import { resolveFallbackLaneLayout, shouldPreserveFallbackSideWidth } from '../../gameplay-lanes.ts'; import type { ChildPool } from '../../pixi-utils.ts'; import { addHudNumber, addHudText, type HudTextOptions } from '../hud-text.ts'; +import { comboTier } from '../moments.ts'; +import { drawSynesthesiaMoments } from './moments.ts'; import { hsvToHex, icosahedron, projectPoint, rotateX, rotateY, starfieldPoint } from './space.ts'; import { SYN_AMBER, @@ -54,8 +56,11 @@ export function renderSynesthesiaChrome({ const space = layerPool.acquireGraphics(); space.label = 'synesthesia-gameplay/space'; - drawSpace(space, seconds, pulse, hue, hasBga); - drawFloorGrid(space, seconds, pulse, hue); + // Zone: the space intensifies with the combo (faster, denser stars, hotter grid, aurora, beat rings). + const tier = comboTier(runtime.combo ?? 0); + drawSpace(space, seconds, pulse, hue, hasBga, tier); + drawFloorGrid(space, seconds, pulse, hue, tier, beatPhase); + if (tier >= 2) drawAurora(space, seconds, hue, tier, hasBga); const panels = layerPool.acquireGraphics(); panels.label = 'synesthesia-gameplay/panels'; @@ -73,6 +78,7 @@ export function renderSynesthesiaChrome({ front.label = 'synesthesia-gameplay/header'; drawHeader(front, overlayLayer, runtime, accent, pulse, overlayLayerPool); drawJudgements(overlayLayer, runtime, playfieldRight, seconds, overlayLayerPool); + drawSynesthesiaMoments(layer, overlayLayer, runtime, (PLAYFIELD.x + playfieldRight) / 2, hue, overlayLayerPool); } function resolvePlayfieldRight(runtime: Runtime): number { @@ -95,7 +101,14 @@ function insideBga(x: number, y: number, margin = 0): boolean { * 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 { +function drawSpace( + graphics: Graphics, + seconds: number, + pulse: number, + hue: number, + hasBga: boolean, + tier: number, +): void { const bands: ReadonlyArray = [ [SYN_DEEP, 120], [0x080620, 220], @@ -114,38 +127,59 @@ function drawSpace(graphics: Graphics, seconds: number, pulse: number, hue: numb [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 }); + graphics.circle(x, y, (radius * ring) / 3).fill({ color, alpha: (0.025 + 0.012 * pulse) * (1 + 0.5 * tier) }); } } // 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 starCount = 100 + 25 * tier; + const starSpeed = (110 + 40 * pulse) * (1 + 0.45 * tier); + for (let index = 0; index < starCount; index += 1) { + const point = starfieldPoint(index, seconds, { spread: 520, near: 20, far: 900, speed: starSpeed }); 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); + const color = hsvToHex(hue + (index % 5) * (0.06 + 0.03 * tier), 0.35 + 0.3 * (index % 2) + 0.06 * tier, 1); + if (tier >= 4) { + // Hyperspace: stars smear into streaks along their flight line. + const tail = projectPoint({ ...point, z: point.z + 90 }, cx, cy, 180); + graphics + .moveTo(tail.x, tail.y) + .lineTo(projected.x, projected.y) + .stroke({ color, width: radius, alpha: 0.35 * nearness }); + } 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 { +function drawFloorGrid( + graphics: Graphics, + seconds: number, + pulse: number, + hue: number, + tier: number, + beatPhase: number, +): void { const { horizon, height, focal } = FLOOR; - const color = hsvToHex(hue + 0.08, 0.7, 1); + const color = hsvToHex(hue + 0.08 + (tier >= 3 ? Math.sin(seconds * 0.7) * 0.12 : 0), 0.7, 1); + const glow = 1 + 0.35 * tier; 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 }); + graphics + .moveTo(near.x, near.y) + .lineTo(far.x, far.y) + .stroke({ color, width: 1, alpha: 0.16 * glow }); } const spacing = 90; - const offset = (seconds * 150) % spacing; + const offset = (seconds * 150 * (1 + 0.3 * tier)) % spacing; for (let z = spacing - offset; z < 2400; z += spacing) { const left = project(-1200, z); const right = project(1200, z); @@ -153,10 +187,50 @@ function drawFloorGrid(graphics: Graphics, seconds: number, pulse: number, hue: 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) }); + .stroke({ color, width: 1, alpha: Math.min(0.9, (0.08 + 0.22 * nearness) * (0.7 + 0.3 * pulse) * glow) }); } // Horizon glow. - graphics.rect(0, horizon - 1, DESIGN_WIDTH, 2).fill({ color, alpha: 0.18 + 0.2 * pulse }); + graphics.rect(0, horizon - 1, DESIGN_WIDTH, 2).fill({ color, alpha: (0.18 + 0.2 * pulse) * glow }); + if (tier >= 3) { + // Beat rings rolling out of the vanishing point across the floor. + for (const phase of [beatPhase, (beatPhase + 0.5) % 1]) { + const rx = 30 + phase * 520; + graphics + .ellipse(320, horizon + 4 + phase * 60, rx, rx * 0.16) + .stroke({ color, width: 1 + (1 - phase) * 2, alpha: 0.45 * (1 - phase) }); + } + } +} + +/** + * Aurora ribbons flowing across the sky once the run is in the zone (combo tier 2+): layered sine curves in the scene + * hue, broken around the BGA rect so a live video stays clean. + */ +function drawAurora(graphics: Graphics, seconds: number, hue: number, tier: number, hasBga: boolean): void { + const ribbons = tier >= 4 ? 4 : tier >= 3 ? 3 : 2; + for (let ribbon = 0; ribbon < ribbons; ribbon += 1) { + const color = hsvToHex(hue + ribbon * 0.09 + Math.sin(seconds * 0.3 + ribbon) * 0.05, 0.75, 1); + const baseY = 70 + ribbon * 34; + let previous: { x: number; y: number } | undefined; + for (let x = -10; x <= DESIGN_WIDTH + 10; x += 16) { + const y = + baseY + + Math.sin(x * 0.012 + seconds * (0.9 + ribbon * 0.25) + ribbon) * 18 + + Math.sin(x * 0.031 - seconds * 1.3) * 6; + const point = { x, y }; + if (previous && !(hasBga && (insideBga(previous.x, previous.y, 12) || insideBga(x, y, 12)))) { + graphics + .moveTo(previous.x, previous.y) + .lineTo(x, y) + .stroke({ color, width: 16, alpha: 0.04 * tier }); + graphics + .moveTo(previous.x, previous.y) + .lineTo(x, y) + .stroke({ color, width: 2, alpha: 0.12 * tier }); + } + previous = point; + } + } } function fillAroundBga( @@ -443,7 +517,7 @@ function drawScorePanel(graphics: Graphics, layer: Container, runtime: Runtime, `${formatCount(runtime.exScore)}/${formatCount(runtime.exScoreMax)}`, x + 136, y + 52, - { ...displayStyle(8, SYN_MIST), anchorX: 1, maxWidth: 56 }, + { ...displayStyle(8, SYN_MIST), anchorX: 1, maxWidth: 50 }, pool, ); addHudText(layer, 'EX RATE', x + 14, y + 76, labelStyle(), pool); @@ -452,7 +526,7 @@ function drawScorePanel(graphics: Graphics, layer: Container, runtime: Runtime, formatExRate(runtime.exScore, runtime.exScoreMax), x + 136, y + 74, - { ...displayStyle(8, SYN_MIST), anchorX: 1, maxWidth: 56 }, + { ...displayStyle(8, SYN_MIST), anchorX: 1, maxWidth: 50 }, pool, ); // Rate meter as a light filament with the IIDX ninth ticks. @@ -531,7 +605,7 @@ function drawJudgeTally(graphics: Graphics, layer: Container, runtime: Runtime, formatCount(runtime[key]), x + w - 7, rowY - 1, - { ...displayStyle(10, SYN_WHITE), anchorX: 1, maxWidth: w - 36 }, + { ...displayStyle(9, SYN_WHITE), anchorX: 1, maxWidth: w - 42 }, pool, ); } diff --git a/packages/player-web/src/scene/default/synesthesia/moments.ts b/packages/player-web/src/scene/default/synesthesia/moments.ts new file mode 100644 index 00000000..db581f77 --- /dev/null +++ b/packages/player-web/src/scene/default/synesthesia/moments.ts @@ -0,0 +1,281 @@ +import { Graphics, type Container } from 'pixi.js'; +import { BGA, DESIGN_HEIGHT, DESIGN_WIDTH, GROOVE, PLAYFIELD } from '../../gameplay-constants.ts'; +import type { SkinlessGameplayChromeRuntime } from '../../gameplay-chrome.ts'; +import type { ChildPool } from '../../pixi-utils.ts'; +import { addHudText } from '../hud-text.ts'; +import { momentProgress, trackMoments } from '../moments.ts'; +import { easeOutCubic, stageProgress } from '../phantom-style.ts'; +import { burstParticlePosition, burstParticles, hsvToHex, projectPoint, rotateX, rotateY } from './space.ts'; +import { SYN_DISPLAY_FONT, SYN_WHITE } from './style.ts'; + +const MILESTONE_MS = 1300; +const CLEAR_MS = 1100; +const FULL_COMBO_MS = 3400; +const FULL_COMBO_BURST = burstParticles(77, 180); +const JUDGE_Y = PLAYFIELD.judgementY; + +/** + * Synesthesia showpieces, painted on the front layer: + * + * - the count-in: READY condenses out of wide-tracked light over a widening filament, then the first beat detonates a + * ring of light from the judgement line; + * - every 100 combo: a shockwave of light rolls out from the playfield and the count glows over the BGA monitor; + * - the clear line: a light sweep runs the length of the gauge; + * - a full combo: a bloom, a 3D sphere of sparks bursting from the centre of the screen, prism rings, and FULL COMBO + * cycling through the spectrum. + */ +export function drawSynesthesiaMoments( + key: object, + layer: Container, + runtime: SkinlessGameplayChromeRuntime, + playfieldCenterX: number, + hue: number, + pool: ChildPool, +): void { + const nowMs = runtime.nowMs ?? 0; + const moments = trackMoments(key, { + 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 graphics = pool.acquireGraphics(); + graphics.label = 'synesthesia/moments'; + graphics.blendMode = 'add'; + + if (runtime.chartMs !== undefined) { + drawCountIn(graphics, layer, runtime.chartMs, playfieldCenterX, hue, pool); + } + const milestone = momentProgress(moments.milestone?.atMs, nowMs, MILESTONE_MS); + if (milestone !== undefined && moments.milestone) { + drawMilestone(graphics, layer, moments.milestone.value, milestone, playfieldCenterX, hue, pool); + } + const clear = momentProgress(moments.clearAtMs, nowMs, CLEAR_MS); + if (clear !== undefined) { + drawClear(graphics, layer, clear, hue, pool); + } + const fullCombo = momentProgress(moments.fullComboAtMs, nowMs, FULL_COMBO_MS); + if (fullCombo !== undefined) { + drawFullCombo(graphics, layer, fullCombo, nowMs, hue, pool); + } +} + +function drawCountIn( + graphics: Graphics, + layer: Container, + chartMs: number, + playfieldCenterX: 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: a ring of light detonates off the judgement line. + const start = stageProgress(chartMs, 0, 900); + if (start > 0 && start < 1) { + const eased = easeOutCubic(start); + for (const [delay, strength] of [ + [0, 1], + [0.12, 0.55], + ] as const) { + const t = Math.max(0, (start - delay) / (1 - delay)); + if (t <= 0) continue; + graphics + .ellipse(playfieldCenterX, JUDGE_Y, 20 + 560 * easeOutCubic(t), (20 + 560 * easeOutCubic(t)) * 0.3) + .stroke({ color, width: 1 + 5 * (1 - t), alpha: 0.8 * strength * (1 - t) }); + } + 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, + playfieldCenterX: number, + hue: number, + pool: ChildPool, +): void { + const color = hsvToHex(hue + value / 1000, 0.6, 1); + const wave = easeOutCubic(Math.min(1, t / 0.7)); + const radius = 30 + 700 * wave; + graphics + .ellipse(playfieldCenterX, JUDGE_Y, radius, radius * 0.42) + .stroke({ color, width: 2 + 8 * (1 - wave), alpha: 0.7 * (1 - wave) }); + graphics + .ellipse(playfieldCenterX, JUDGE_Y, radius * 0.8, radius * 0.34) + .stroke({ color: SYN_WHITE, width: 1.5, alpha: 0.5 * (1 - wave) }); + const alpha = Math.min(1, t / 0.12) * (1 - Math.max(0, (t - 0.7) / 0.3)); + const cx = BGA.x + BGA.w / 2; + const cy = BGA.y + BGA.h / 2 - 10; + const count = addHudText( + layer, + String(value), + cx, + cy, + { + size: 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 label = addHudText( + layer, + 'COMBO', + cx, + cy + 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, hue: number, pool: ChildPool): void { + const color = hsvToHex(hue + 0.35, 0.6, 1); + 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', + GROOVE.x + GROOVE.w - 8, + GROOVE.y - 38, + { + 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, + 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 = Math.max(0, 1 - t * 5); + if (bloom > 0) { + graphics.rect(0, 0, DESIGN_WIDTH, DESIGN_HEIGHT).fill({ color: SYN_WHITE, 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; + for (const particle of FULL_COMBO_BURST) { + 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; + const color = hsvToHex(hue + particle.hueShift * 3 + life * 0.5, 0.35 + 0.5 * life, 1); + const size = (1.5 + 3.5 * particle.size) * point.scale * (1 - life * 0.6); + graphics.circle(point.x, point.y, size * 3).fill({ color, alpha: 0.12 * (1 - life) * fadeOut }); + graphics.circle(point.x, point.y, size).fill({ color: SYN_WHITE, alpha: (1 - life) * fadeOut }); + } + // Prism rings expanding from the centre. + for (let ring = 0; ring < 5; ring += 1) { + const ringT = (t * 2.2 + ring * 0.18) % 1; + const radius = 40 + ringT * 420; + graphics.ellipse(cx, cy, radius, radius * 0.55).stroke({ + color: hsvToHex(hue + ring * 0.12 + t, 0.7, 1), + width: 2 + 4 * (1 - ringT), + alpha: 0.5 * (1 - ringT) * fadeOut, + }); + } + const form = easeOutCubic(Math.min(1, t / 0.3)); + const color = hsvToHex(hue + nowMs / 1800, 0.55, 1); + 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/scene/default/synesthesia/playfield.ts b/packages/player-web/src/scene/default/synesthesia/playfield.ts index 9384b71a..675caa96 100644 --- a/packages/player-web/src/scene/default/synesthesia/playfield.ts +++ b/packages/player-web/src/scene/default/synesthesia/playfield.ts @@ -18,6 +18,7 @@ import { type Vec3, } from './space.ts'; import { SYN_GLOW_SIZE, synGlowTexture } from './style.ts'; +import { comboTier } from '../moments.ts'; /** Per-lane-class light: note body, glow hue (0..1), and the colour the lane beam / key cap light up in. */ interface LaneLight { @@ -36,8 +37,12 @@ const LIGHTS: Record = { const NOTE_HEIGHT = 8; export const SYNESTHESIA_BOMB_DURATION_MS = 620; -export function renderSynesthesiaLanes({ graphics, lanes, beatPhase }: BeMusicLanesContext): void { +export function renderSynesthesiaLanes({ graphics, lanes, beatPhase, nowMs, combo }: BeMusicLanesContext): void { const pulse = (1 - beatPhase) ** 2; + // The judgement line burns brighter as the run builds, and cycles the spectrum once it is in the zone. + const tier = comboTier(combo ?? 0); + const heat = 1 + 0.3 * tier; + const lineColor = tier >= 3 ? hsvToHex(0.52 + Math.sin(nowMs / 900) * 0.2, 0.45, 1) : 0x5ff4ff; let gridTop = Number.POSITIVE_INFINITY; let gridBottom = 0; let gridLeft = Number.POSITIVE_INFINITY; @@ -79,8 +84,10 @@ export function renderSynesthesiaLanes({ graphics, lanes, beatPhase }: BeMusicLa // 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 - 14, lineW, 22).fill({ color: lineColor, alpha: (0.05 + 0.08 * pulse) * heat }); + graphics + .rect(gridLeft, y - 6, lineW, 8) + .fill({ color: lineColor, alpha: Math.min(0.8, (0.16 + 0.14 * pulse) * heat) }); graphics.rect(gridLeft, y - 3, lineW, 3).fill({ color: 0x9ff8ff, alpha: 0.85 }); graphics.rect(gridLeft, y - 2, lineW, 1).fill(0xffffff); } @@ -110,14 +117,17 @@ function drawNote(graphics: Graphics, x: number, y: number, w: number, light: La graphics.rect(x, y - NOTE_HEIGHT, w, 2).fill({ color: 0xffffff, alpha: 0.95 }); } -const BURST_PARTICLES = 52; +/** Sparks per hit at combo tier 0; each tier adds {@link BURST_PARTICLES_PER_TIER}. */ +const BURST_PARTICLES = 44; +const BURST_PARTICLES_PER_TIER = 6; +const BURST_PARTICLES_MAX = BURST_PARTICLES + BURST_PARTICLES_PER_TIER * 4; 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); + 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) { @@ -139,13 +149,17 @@ function cachedBurst(seed: number): BurstParticle[] { * * Everything is additive, so overlapping hits bloom into each other like light instead of stacking opaque shapes. */ -export function renderSynesthesiaBombs({ pool, bombs }: BeMusicBombsContext): void { +export function renderSynesthesiaBombs({ pool, bombs, combo }: BeMusicBombsContext): void { + const tier = comboTier(combo ?? 0); + // 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) { - renderBomb(pool, bomb); + renderBomb(pool, bomb, tier, crowd); } } -function renderBomb(pool: ChildPool, bomb: BeMusicBomb): void { +function renderBomb(pool: ChildPool, bomb: BeMusicBomb, tier: number, crowd: 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]; @@ -165,7 +179,7 @@ function renderBomb(pool: ChildPool, bomb: BeMusicBomb): void { 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 }); + .fill({ fill: resolveColumnGradient(light.glow), alpha: 0.55 * fade * fade * crowd }); // 2. Shockwave rings in the XZ plane — the second one trails slightly and tilts the other way. for (const [delay, tilt, strength] of [ @@ -188,13 +202,13 @@ function renderBomb(pool: ChildPool, bomb: BeMusicBomb): void { 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), + alpha: 0.75 * strength * (1 - ringT) * (0.5 + 0.5 * crowd), }); } // 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 particles = cachedBurst(bomb.seed).slice(0, BURST_PARTICLES + BURST_PARTICLES_PER_TIER * tier); + const radius = unit * 4.6 * (1 + 0.1 * tier); 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 @@ -240,7 +254,7 @@ function renderBomb(pool: ChildPool, bomb: BeMusicBomb): void { 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; + sprite.alpha = (1 - spark.life) ** 0.8 * (0.65 + 0.35 * crowd); } // 5. Core flash + anamorphic streak. @@ -255,7 +269,7 @@ function renderBomb(pool: ChildPool, bomb: BeMusicBomb): void { core.height = coreSize; core.position.set(cx, cy); core.tint = hsvToHex(light.hue, t * 2, 1); - core.alpha = flash * 0.7; + core.alpha = flash * 0.7 * crowd; } const streakAlpha = fade ** 2.5; if (streakAlpha > 0.02) { @@ -267,7 +281,7 @@ function renderBomb(pool: ChildPool, bomb: BeMusicBomb): void { 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; + streak.alpha = streakAlpha * 0.6 * crowd; } } diff --git a/packages/player-web/src/scene/gameplay-chrome.ts b/packages/player-web/src/scene/gameplay-chrome.ts index 819ea324..133793bc 100644 --- a/packages/player-web/src/scene/gameplay-chrome.ts +++ b/packages/player-web/src/scene/gameplay-chrome.ts @@ -59,6 +59,13 @@ export interface SkinlessGameplayChromeRuntime { 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; } export interface SkinlessGameplayChromeRenderContext { diff --git a/packages/player-web/src/skin/be-music/types.ts b/packages/player-web/src/skin/be-music/types.ts index 7ec3d6f3..dd1b5709 100644 --- a/packages/player-web/src/skin/be-music/types.ts +++ b/packages/player-web/src/skin/be-music/types.ts @@ -64,6 +64,8 @@ export interface BeMusicLanesContext { /** Fractional beat position in [0, 1). */ beatPhase: number; nowMs: number; + /** Current combo — lets skins escalate the playfield as a run builds. */ + combo?: number; } export interface BeMusicNoteContext { @@ -106,6 +108,8 @@ export interface BeMusicBombsContext { pool: ChildPool; bombs: readonly BeMusicBomb[]; nowMs: number; + /** Current combo — lets skins escalate hit effects as a run builds. */ + combo?: number; } /** Song-list geometry shared by the select renderer (drawing) and the scene (row hit-testing). */ From 9c2430c662f47adb5e73d9b135eef650800b8abb Mon Sep 17 00:00:00 2001 From: Seiya KONNO Date: Mon, 28 Sep 2026 19:19:48 +0900 Subject: [PATCH 02/65] feat(player-web): add hit punch, miss drama, launch outros, and an effects level to the built-in skins Judgements and combo punch in on hits, lanes react to key presses, misses shake the screen and break combos shatter the counter, select plays a launch outro before gameplay, HUD text is no smaller than 9px, moments make room for a BGA, and a new beMusicEffects option ('full' / 'reduced' / 'off') tones effects down. --- .changeset/be-music-skins.md | 6 +- packages/player-web-demo/src/main.ts | 43 +++++++- packages/player-web-demo/src/types.ts | 2 + .../player-web/src/scene/core/gameplay.ts | 34 +++++- packages/player-web/src/scene/core/result.ts | 5 +- packages/player-web/src/scene/core/select.ts | 94 +++++++++++----- .../src/scene/default/gameplay-render.ts | 50 +++++++-- .../player-web/src/scene/default/hud-text.ts | 15 ++- .../src/scene/default/moments.test.ts | 46 +++++++- .../player-web/src/scene/default/moments.ts | 45 +++++++- .../src/scene/default/phantom-style.ts | 67 ++++++++++++ .../src/scene/default/phantom/moments.ts | 93 ++++++++++++++-- .../src/scene/default/phantom/result.ts | 11 +- .../src/scene/default/phantom/select.ts | 72 ++++++++++++- .../default/synesthesia/gameplay-chrome.ts | 101 +++++++++++------- .../src/scene/default/synesthesia/moments.ts | 92 ++++++++++++++-- .../scene/default/synesthesia/playfield.ts | 52 +++++++-- .../src/scene/default/synesthesia/result.ts | 6 +- .../src/scene/default/synesthesia/select.ts | 81 +++++++++++--- .../player-web/src/scene/gameplay-chrome.ts | 7 ++ .../player-web/src/skin/be-music/types.ts | 20 +++- 21 files changed, 814 insertions(+), 128 deletions(-) diff --git a/.changeset/be-music-skins.md b/.changeset/be-music-skins.md index e067e503..98011bac 100644 --- a/.changeset/be-music-skins.md +++ b/.changeset/be-music-skins.md @@ -13,4 +13,8 @@ The default (skinless) family now renders through a `BeMusicSkin`: one object bu - 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 detonates a ring of light), a cut-in every 100 combo, a flourish when the gauge crosses the clear line, and a full-combo finale (Phantom's turning burst with confetti, Synesthesia's 3D spark sphere and prism rings). 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 stars speed up and multiply, the floor grid burns hotter, aurora ribbons and beat rings appear, stars smear into hyperspace streaks, and hits throw more sparks further. Overlapping hits now dim their white flash so colour stays the hero. -- `SkinlessGameplayChromeRuntime` gains `totalNotes` and `chartMs`, and the be-music lane and bomb contexts gain `combo`. +- Judgements and the combo counter punch in on every hit, and lanes, rails, and the Synesthesia sky react to each key press; a miss shakes the screen with a red vignette (Phantom) or an RGB glitch (Synesthesia), and breaking a combo of 20 or more shatters the counter. +- Starting a chart from select now plays a short launch outro (Phantom's ink slabs with LET'S GO!, Synesthesia's warp into a white-out) 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. +- New `beMusicEffects` option (`'full'` / `'reduced'` / `'off'`) on the default gameplay, select, and result views tones down or disables screen shake, flashes, particles, and animation for comfort or low-end devices; select renderers can declare an `outroMs` and receive `effects` and `launchAt` in their frame. +- `SkinlessGameplayChromeRuntime` gains `totalNotes`, `chartMs`, `judgeAtMs`, `impulseAtMs`, `impulseKind`, and `effects`, and the be-music lane and bomb contexts gain `combo` and `effects`. diff --git a/packages/player-web-demo/src/main.ts b/packages/player-web-demo/src/main.ts index 628b96c4..89571d59 100644 --- a/packages/player-web-demo/src/main.ts +++ b/packages/player-web-demo/src/main.ts @@ -138,6 +138,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); @@ -497,6 +510,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: () => { @@ -729,6 +743,17 @@ class PlayerWebDemoApp { .onChange((id: string) => { this.handleBuiltInSkinChange(id); }); + 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. @@ -3013,7 +3038,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); } @@ -3169,7 +3198,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, @@ -3319,7 +3352,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 ba7465a2..df5fd8cb 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/src/scene/core/gameplay.ts b/packages/player-web/src/scene/core/gameplay.ts index e6fc792b..2e03f6fc 100644 --- a/packages/player-web/src/scene/core/gameplay.ts +++ b/packages/player-web/src/scene/core/gameplay.ts @@ -128,7 +128,7 @@ 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, BeMusicLaneFrame, BeMusicSkin } 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'; @@ -306,6 +306,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 @@ -695,6 +697,10 @@ export class CoreGameplayView 0 ? this.score.total : 0; const seconds = this.currentSeconds(); @@ -3433,6 +3456,10 @@ export class CoreGameplayView void; /** * AUTOPLAY-mode launch hook. Fired when the user clicks the skin's AUTOPLAY button (#SRC_BUTTON `type = 16`) or @@ -1311,7 +1313,7 @@ export class CoreSongSelectView this.render()); } if (this.beMusicBackHolder.visible) { - this.beMusicRenderer?.tick(now, this.focusedSong()); + this.beMusicRenderer?.tick(now, this.focusedSong(), this.launchAt); } } const report = this.perf.endFrame(() => ({ @@ -1332,6 +1334,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). @@ -2065,11 +2068,15 @@ 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(); @@ -2224,7 +2231,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. */ diff --git a/packages/player-web/src/scene/default/gameplay-render.ts b/packages/player-web/src/scene/default/gameplay-render.ts index fb70fdec..65a4831d 100644 --- a/packages/player-web/src/scene/default/gameplay-render.ts +++ b/packages/player-web/src/scene/default/gameplay-render.ts @@ -22,12 +22,15 @@ import { PHANTOM_RED_HOT, PHANTOM_SLATE, PHANTOM_WHITE, + drawNoteEmblem, + drawStaves, halftoneField, parallelogramPoints, starburstPoints, } from './phantom-style.ts'; import type { ChildPool } from '../pixi-utils.ts'; import { drawPhantomMoments } from './phantom/moments.ts'; +import { effectProfile, impulse, punchScale } from './moments.ts'; import { addHudNumber as addNumber, addHudText as addText, type HudTextOptions as TextOptions } from './hud-text.ts'; const DISPLAY_FONT = DEFAULT_DISPLAY_FONT; @@ -83,8 +86,17 @@ export function renderDefaultGameplayFrame( frame.label = 'default-gameplay/chrome'; drawBackground(frame, hasBga); - 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, + ); + // A double-play field reaches into the monitor's column; the frame and its idle screen would sit on the 2P lanes. + if (playfield.right + 24 <= BGA.x) { + drawBgaFrame(frame, layer, hasBga, runtime.nowMs, layerPool); + } drawGauge(frame, layer, runtime, layerPool); drawSongPlate(frame, layer, runtime, layerPool); drawScorePlate(frame, layer, runtime, layerPool); @@ -104,7 +116,7 @@ export function renderDefaultGameplayFrame( frontLayer.addChild(front); } drawStatusBar(front, frontLayer, runtime, frontPool); - drawJudgements(frontLayer, runtime, playfield, frontPool); + drawJudgements(frontLayer, runtime, playfield, frontPool, effects.amount); // Showpieces (count-in, combo milestones, clear line, full combo) sit above everything else. drawPhantomMoments(layer, frontLayer, runtime, frontPool); } @@ -152,6 +164,13 @@ function drawBackground(frame: Graphics, hasBga: boolean): void { frame.circle(dot.x, dot.y, dot.r).fill({ color: PHANTOM_INK, alpha: 0.55 }); } } + // Sheet music on the floor: two ink staves ruled across the wedge, following its slanted top edge. + const wedgeLeftAt = (lineY: number): number => + FLOOR_WEDGE[0]! + + ((DESIGN_HEIGHT - lineY) * (DESIGN_WIDTH - FLOOR_WEDGE[0]!)) / (DESIGN_HEIGHT - FLOOR_WEDGE[3]!) + + 6; + drawStaves(frame, 0, DESIGN_WIDTH, 404, 5, PHANTOM_INK, 0.55, wedgeLeftAt); + drawStaves(frame, 0, DESIGN_WIDTH, 446, 5, PHANTOM_INK, 0.55, wedgeLeftAt); // 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]) @@ -245,7 +264,12 @@ function resolveSideBounds( /** 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 { +function drawPlayfield( + frame: Graphics, + playfield: FallbackPlayfieldLayout, + progressRatio: number | undefined, + hit: number, +): void { const wellTop = PLAYFIELD.y; const wellBottom = PLAYFIELD_FRAME_BOTTOM; const wellHeight = wellBottom - wellTop; @@ -267,6 +291,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. @@ -396,6 +424,8 @@ function drawStatusBar( addText(layer, 'HI-SPEED', 214, 14, tagLabelStyle(PHANTOM_RED), pool); addNumber(layer, `x${formatHiSpeed(runtime.hiSpeed)}`, 262, 5, displayStyle(22, PHANTOM_WHITE), pool); + // The skin's mark, between the tempo readouts and the ruleset tag. + drawNoteEmblem(status, 350, 25, 22, PHANTOM_WHITE, PHANTOM_RED); 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); @@ -682,7 +712,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), @@ -718,16 +748,23 @@ 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); + // Punch: every judgement lands with a quick overshoot; misses also rattle sideways. + const judgeScale = punchScale(runtime.judgeAtMs, nowMs, 120, 0.24 * amount); + const miss = display.judge === 'POOR' || display.judge === 'BAD'; + const rattle = miss ? Math.sin(nowMs / 14) * 4 * impulse(runtime.judgeAtMs, nowMs, 220) * amount : 0; addText( layer, display.judge, - display.x, + display.x + rattle, 238, { + scale: judgeScale, size: 30, fill: style.fill, fontFamily: DISPLAY_FONT, @@ -748,6 +785,7 @@ function drawJudgements( display.x, 270, { + scale: punchScale(runtime.judgeAtMs, nowMs, 150, (combo % 100 === 0 ? 0.5 : 0.16) * amount), ...displayStyle(24, combo >= 200 ? PHANTOM_GOLD : PHANTOM_WHITE), anchorX: 0.5, anchorY: 0.5, diff --git a/packages/player-web/src/scene/default/hud-text.ts b/packages/player-web/src/scene/default/hud-text.ts index 84ad2c7c..2409d797 100644 --- a/packages/player-web/src/scene/default/hud-text.ts +++ b/packages/player-web/src/scene/default/hud-text.ts @@ -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( @@ -56,6 +58,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); } @@ -77,18 +83,19 @@ export function addHudNumber( const style = resolveTextStyle(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); } } diff --git a/packages/player-web/src/scene/default/moments.test.ts b/packages/player-web/src/scene/default/moments.test.ts index 417c6d76..3d0e253c 100644 --- a/packages/player-web/src/scene/default/moments.test.ts +++ b/packages/player-web/src/scene/default/moments.test.ts @@ -1,5 +1,14 @@ import { describe, expect, it } from 'vite-plus/test'; -import { comboTier, createMomentState, momentProgress, updateMoments, type MomentInput } from './moments.ts'; +import { + comboTier, + createMomentState, + effectProfile, + impulse, + momentProgress, + punchScale, + updateMoments, + type MomentInput, +} from './moments.ts'; const base: MomentInput = { nowMs: 0, @@ -75,3 +84,38 @@ describe('momentProgress', () => { 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 }); + }); +}); diff --git a/packages/player-web/src/scene/default/moments.ts b/packages/player-web/src/scene/default/moments.ts index f55dd5bd..3239039b 100644 --- a/packages/player-web/src/scene/default/moments.ts +++ b/packages/player-web/src/scene/default/moments.ts @@ -26,18 +26,32 @@ export interface MomentState { 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 }; + 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 }; } @@ -88,3 +102,32 @@ export function trackMoments(key: object, input: MomentInput): MomentState { 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 }; + } +} diff --git a/packages/player-web/src/scene/default/phantom-style.ts b/packages/player-web/src/scene/default/phantom-style.ts index f1fdc935..8836a420 100644 --- a/packages/player-web/src/scene/default/phantom-style.ts +++ b/packages/player-web/src/scene/default/phantom-style.ts @@ -1,3 +1,5 @@ +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 @@ -179,3 +181,68 @@ export function rollUpValue(target: number, progress: number): number { if (!Number.isFinite(target)) return 0; return Math.round(target * easeOutCubic(progress)); } + +/** + * 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); +} + +/** + * Five-line stave rows across `[x0, x1]` at `y`, each line `gap` apart — sheet music woven into the poster ground. + * `clipLeftAt(y)` optionally narrows a line's start (to follow a slanted edge). + */ +export function drawStaves( + graphics: Graphics, + x0: number, + x1: number, + y: number, + gap: number, + color: number, + alpha: number, + clipLeftAt?: (lineY: number) => number, +): void { + for (let line = 0; line < 5; line += 1) { + const lineY = y + line * gap; + const start = Math.max(x0, clipLeftAt ? clipLeftAt(lineY) : x0); + if (start < x1) graphics.rect(start, lineY, x1 - start, 1).fill({ color, alpha }); + } +} diff --git a/packages/player-web/src/scene/default/phantom/moments.ts b/packages/player-web/src/scene/default/phantom/moments.ts index 8db04472..d39b6bb2 100644 --- a/packages/player-web/src/scene/default/phantom/moments.ts +++ b/packages/player-web/src/scene/default/phantom/moments.ts @@ -1,10 +1,10 @@ import { Graphics, type Container } from 'pixi.js'; -import { DESIGN_HEIGHT, DESIGN_WIDTH, GROOVE } from '../../gameplay-constants.ts'; +import { BGA, DESIGN_HEIGHT, DESIGN_WIDTH, GROOVE, PLAYFIELD } from '../../gameplay-constants.ts'; import type { SkinlessGameplayChromeRuntime } from '../../gameplay-chrome.ts'; import type { ChildPool } from '../../pixi-utils.ts'; import { DEFAULT_DISPLAY_FONT } from '../fonts.ts'; import { addHudText } from '../hud-text.ts'; -import { momentProgress, trackMoments } from '../moments.ts'; +import { effectProfile, impulse, momentProgress, trackMoments } from '../moments.ts'; import { PHANTOM_GOLD, PHANTOM_INK, @@ -36,13 +36,13 @@ const SKEW = -0.18; * is over, so it may cover the playfield). */ export function drawPhantomMoments( - key: object, + chromeLayer: Container, layer: Container, runtime: SkinlessGameplayChromeRuntime, pool: ChildPool | undefined, ): void { const nowMs = runtime.nowMs ?? 0; - const moments = trackMoments(key, { + const moments = trackMoments(chromeLayer, { nowMs, combo: runtime.combo ?? 0, gauge: runtime.gauge ?? 0, @@ -54,16 +54,33 @@ export function drawPhantomMoments( bad: runtime.bad ?? 0, poor: runtime.poor ?? 0, }); + const effects = effectProfile(runtime.effects); + if (!effects.enabled) return; const graphics = pool?.acquireGraphics() ?? new Graphics(); graphics.label = 'phantom/moments'; if (!pool) layer.addChild(graphics); + // Misses hit back: a red vignette, and (full effects) a short sideways shake of the whole HUD. + const miss = runtime.lastJudge === 'POOR' || runtime.lastJudge === 'BAD' ? impulse(runtime.judgeAtMs, nowMs, 260) : 0; + if (miss > 0) { + drawMissVignette(graphics, miss); + if (effects.screenWide) { + const shake = Math.sin(nowMs / 11) * 5 * miss; + chromeLayer.position.set(shake, 0); + layer.position.set(shake, 0); + } + } + const comboBreak = momentProgress(moments.comboBreak?.atMs, nowMs, BREAK_MS); + if (comboBreak !== undefined && moments.comboBreak) { + drawComboBreak(graphics, layer, moments.comboBreak.combo, comboBreak, pool); + } + 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, pool); + drawMilestone(graphics, layer, moments.milestone.value, milestone, runtime.hasBga === true, pool); } const clear = momentProgress(moments.clearAtMs, nowMs, CLEAR_MS); if (clear !== undefined) { @@ -71,8 +88,65 @@ export function drawPhantomMoments( } const fullCombo = momentProgress(moments.fullComboAtMs, nowMs, FULL_COMBO_MS); if (fullCombo !== undefined) { - drawFullCombo(graphics, layer, fullCombo, nowMs, pool); + drawFullCombo(graphics, layer, fullCombo, nowMs, effects.screenWide, pool); + } +} + +const BREAK_MS = 700; + +/** Red edges bleeding in from the screen border on a BAD / POOR. */ +function drawMissVignette(graphics: Graphics, strength: number): void { + const depth = 34; + for (let band = 0; band < 3; band += 1) { + const inset = band * (depth / 3); + const alpha = 0.22 * strength * (1 - band / 3); + const w = depth / 3; + graphics.rect(inset, 0, w, DESIGN_HEIGHT).fill({ color: PHANTOM_RED, alpha }); + graphics.rect(DESIGN_WIDTH - inset - w, 0, w, DESIGN_HEIGHT).fill({ color: PHANTOM_RED, alpha }); + graphics.rect(0, inset, DESIGN_WIDTH, w).fill({ color: PHANTOM_RED, alpha }); + graphics.rect(0, DESIGN_HEIGHT - inset - w, DESIGN_WIDTH, w).fill({ color: PHANTOM_RED, alpha }); + } +} + +/** A broken combo shatters: the old count drops and fades in red while ink / red shards fly off it. */ +function drawComboBreak( + graphics: Graphics, + layer: Container, + combo: number, + t: number, + pool: ChildPool | undefined, +): void { + const cx = PLAYFIELD.x + PLAYFIELD.w / 2; + const cy = 270; + const fall = easeOutCubic(t); + for (let shard = 0; shard < 10; shard += 1) { + const angle = -Math.PI / 2 + (hash01(shard + combo) - 0.5) * 2.6; + const speed = 40 + hash01(shard * 3 + 1) * 70; + const x = cx + Math.cos(angle) * speed * fall; + const y = cy + Math.sin(angle) * speed * fall + 90 * t * t; + const size = 5 + hash01(shard + 9) * 7; + graphics + .poly(rotatedRect(x, y, size, size * 0.6, t * 8 + shard)) + .fill({ color: shard % 2 === 0 ? PHANTOM_RED_HOT : PHANTOM_INK, alpha: 1 - t }); } + const text = addHudText( + layer, + String(combo), + cx, + cy + 30 * t * t, + { + size: 24, + fill: PHANTOM_RED_HOT, + fontFamily: DEFAULT_DISPLAY_FONT, + anchorX: 0.5, + anchorY: 0.5, + skewX: SKEW, + stroke: { color: PHANTOM_INK, width: 4, alignment: 0.5, join: 'miter' }, + }, + pool, + ); + text.rotation = 0.35 * t; + text.alpha = 1 - t; } /** READY? → GO!! count-in over the last ~2.8 s before the first beat. */ @@ -152,12 +226,14 @@ function drawMilestone( layer: Container, value: number, t: number, + hasBga: boolean, pool: ChildPool | undefined, ): void { const inT = easeOutCubic(Math.min(1, t / 0.14)); const outT = easeOutCubic(Math.max(0, (t - 0.8) / 0.2)); const x = 300 + (1 - inT) * 380 + outT * 380; - const y = 196; + // Over a live BGA the banner rides the monitor's bottom edge instead of covering the middle of the video. + const y = hasBga ? BGA.y + BGA.h - 22 : 196; graphics.poly(parallelogramPoints(x + 8, y + 8, 360, 58, 18)).fill(PHANTOM_INK); graphics .poly(parallelogramPoints(x, y, 360, 58, 18)) @@ -244,12 +320,13 @@ function drawFullCombo( 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 = Math.max(0, 1 - t * 7); + 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 }); } diff --git a/packages/player-web/src/scene/default/phantom/result.ts b/packages/player-web/src/scene/default/phantom/result.ts index 0cdc4268..43ef8fa0 100644 --- a/packages/player-web/src/scene/default/phantom/result.ts +++ b/packages/player-web/src/scene/default/phantom/result.ts @@ -13,6 +13,8 @@ import { PHANTOM_RED_HOT, PHANTOM_SLATE, PHANTOM_WHITE, + drawNoteEmblem, + drawStaves, easeOutBack, easeOutCubic, halftoneField, @@ -35,7 +37,9 @@ export const phantomResultSkin: BeMusicResultSkin = { render: (frame) => renderP * 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; + 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; const rate = frame.ratePercent; const seconds = now / 1000; const layer = frame.layer; @@ -143,6 +147,10 @@ export function renderPhantomResult(frame: BeMusicResultFrame): void { } } slash.g.poly([380, 48, 386, 48, 190, designHeight, 184, designHeight]).fill(PHANTOM_WHITE); + // Sheet-music staves ruled across the slash, following its slanted left edge. + const slashLeftAt = (lineY: number): number => 392 - ((lineY - 48) * (392 - 196)) / (designHeight - 48) + 8; + drawStaves(slash.g, 0, designWidth, 226, 6, PHANTOM_INK, 0.5, slashLeftAt); + drawStaves(slash.g, 0, designWidth, 432, 6, PHANTOM_INK, 0.5, slashLeftAt); slideIn(slash.root, 0, 460, 0, 380); slash.root.alpha = 1; @@ -200,6 +208,7 @@ export function renderPhantomResult(frame: BeMusicResultFrame): void { .poly(parallelogramPoints(12 + glint * 136, 7, 12, 28, 10)) .fill({ color: cleared ? PHANTOM_WHITE : PHANTOM_RED, alpha: 0.55 }); } + drawNoteEmblem(verdict.g, 190, 29, 24, PHANTOM_WHITE, PHANTOM_RED); slam(verdict.root, 120, 88, 21, 2.6); // Rank panel slides in from the left; the burst pops, then the letter stamps down on it. diff --git a/packages/player-web/src/scene/default/phantom/select.ts b/packages/player-web/src/scene/default/phantom/select.ts index 25546fe7..f79f2bd5 100644 --- a/packages/player-web/src/scene/default/phantom/select.ts +++ b/packages/player-web/src/scene/default/phantom/select.ts @@ -18,6 +18,8 @@ import { PHANTOM_RED_HOT, PHANTOM_SLATE, PHANTOM_WHITE, + drawNoteEmblem, + easeOutBack, easeOutCubic, halftoneField, hash01, @@ -29,6 +31,7 @@ import { addHitArea, addSkinText, formatPlayVariantLabel, type SkinTextOptions } const LAYOUT: BeMusicSelectLayout = { listX: 320, listTop: 54, listBottomInset: 26, rowHeight: 28 }; const SLIDE_MS = 240; +const OUTRO_MS = 620; const INTRO_STAGGER_MS = 40; const KICKER_PITCH = 20; const GLINT_W = 14; @@ -77,7 +80,16 @@ class PhantomSelectRenderer implements BeMusicSelectRenderer { this.frontLayer.addChild(this.kicker, this.glint, this.pointer); } - public render(frame: BeMusicSelectFrame): boolean { + public readonly outroMs = OUTRO_MS; + private effects: BeMusicSelectFrame['effects'] = 'full'; + + public render(input: BeMusicSelectFrame): 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,11 +103,64 @@ 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; } + /** + * Launch outro: an ink slab and a red slab slash in from the right to cover the screen, the chosen title slams onto + * the ink, and LET'S GO! punches in beneath it — the handoff into the gameplay count-in. + */ + private renderOutro(frame: BeMusicSelectFrame): 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 = 'default-select/outro'; + const cover = easeOutCubic(Math.min(1, t / 0.45)); + const redEdge = designWidth + 80 - cover * (designWidth + 260); + g.poly([redEdge, 0, designWidth + 200, 0, designWidth + 200, designHeight, redEdge - 140, designHeight]).fill( + PHANTOM_RED, + ); + const inkEdge = redEdge + 70; + g.poly([inkEdge, 0, designWidth + 200, 0, designWidth + 200, designHeight, inkEdge - 140, designHeight]).fill( + PHANTOM_INK, + ); + g.poly([inkEdge - 6, 0, inkEdge, 0, inkEdge - 140, designHeight, inkEdge - 146, designHeight]).fill(PHANTOM_WHITE); + layer.addChild(g); + const slam = easeOutBack(stageProgress(t, 0.35, 0.3)); + if (slam > 0) { + const title = addSkinText(layer, frame.focusedSong?.title ?? '', designWidth / 2 + 30, designHeight / 2 - 26, { + size: 30, + fill: PHANTOM_WHITE, + fontFamily: DEFAULT_HEADLINE_FONT, + anchorX: 0.5, + anchorY: 0.5, + maxWidth: designWidth - 120, + alpha: Math.min(1, slam * 2), + }); + title.scale.set(title.scale.x * (1.6 - 0.6 * slam), 1.6 - 0.6 * slam); + const go = addSkinText(layer, "LET'S GO!", designWidth / 2 + 30, designHeight / 2 + 32, { + size: 44, + fill: PHANTOM_GOLD, + fontFamily: DEFAULT_DISPLAY_FONT, + letterSpacing: 3, + skewX: -0.18, + anchorX: 0.5, + anchorY: 0.5, + stroke: { color: PHANTOM_INK, width: 6, alignment: 0.5, join: 'miter' }, + dropShadow: { color: PHANTOM_RED, distance: 5 }, + alpha: Math.min(1, slam * 2), + }); + go.scale.set(2 - slam); + } + } + public tick(nowMs: number, focusedSong: BrowserSongEntry | undefined): void { - const seconds = nowMs / 1000; + // Ambient motion: frozen with effects off, half speed when reduced. + const seconds = this.effects === 'off' ? 0 : (nowMs / 1000) * (this.effects === 'reduced' ? 0.5 : 1); if (this.dots) { this.dots.y = (seconds * 14) % DOT_PERIOD; } @@ -226,6 +291,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, @@ -292,7 +358,7 @@ 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. diff --git a/packages/player-web/src/scene/default/synesthesia/gameplay-chrome.ts b/packages/player-web/src/scene/default/synesthesia/gameplay-chrome.ts index 2f8a893e..b278af4a 100644 --- a/packages/player-web/src/scene/default/synesthesia/gameplay-chrome.ts +++ b/packages/player-web/src/scene/default/synesthesia/gameplay-chrome.ts @@ -4,7 +4,7 @@ import type { SkinlessGameplayChromeRenderContext, SkinlessGameplayChromeRuntime import { resolveFallbackLaneLayout, shouldPreserveFallbackSideWidth } from '../../gameplay-lanes.ts'; import type { ChildPool } from '../../pixi-utils.ts'; import { addHudNumber, addHudText, type HudTextOptions } from '../hud-text.ts'; -import { comboTier } from '../moments.ts'; +import { comboTier, effectProfile, impulse, punchScale } from '../moments.ts'; import { drawSynesthesiaMoments } from './moments.ts'; import { hsvToHex, icosahedron, projectPoint, rotateX, rotateY, starfieldPoint } from './space.ts'; import { @@ -56,20 +56,29 @@ export function renderSynesthesiaChrome({ const space = layerPool.acquireGraphics(); space.label = 'synesthesia-gameplay/space'; - // Zone: the space intensifies with the combo (faster, denser stars, hotter grid, aurora, beat rings). - const tier = comboTier(runtime.combo ?? 0); - drawSpace(space, seconds, pulse, hue, hasBga, tier); - drawFloorGrid(space, seconds, pulse, hue, tier, beatPhase); + // Zone: the space intensifies with the combo (faster, denser stars, hotter grid, aurora, beat rings). 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 starfield 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']; + drawSpace(space, seconds, pulse, hue, hasBga, tier, hit, hitColor); + drawFloorGrid(space, seconds, pulse, hue, tier, beatPhase, hit); if (tier >= 2) drawAurora(space, seconds, hue, tier, hasBga); const panels = layerPool.acquireGraphics(); panels.label = 'synesthesia-gameplay/panels'; drawPlayfieldWell(panels, playfieldRight, runtime.progressRatio, accent); - drawBgaFrame(panels, layer, hasBga, seconds, pulse, hue, layerPool); + // A double-play field reaches into the monitor's column; the frame and its idle screen would sit on the 2P lanes. + if (playfieldRight + 14 <= BGA.x) { + 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; + // Tucked close to the monitor frame so the counts get room for four digits. + const tallyX = BGA.x + BGA.w + 14; if (playfieldRight + 14 <= tallyX) { drawJudgeTally(panels, layer, runtime, tallyX, layerPool); } @@ -101,6 +110,12 @@ function insideBga(x: number, y: number, margin = 0): boolean { * 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. */ +const IMPULSE_COLORS: Record<'white' | 'black' | 'scratch', number> = { + white: 0x5ff4ff, + black: 0x8f74ff, + scratch: 0xff4fd8, +}; + function drawSpace( graphics: Graphics, seconds: number, @@ -108,6 +123,8 @@ function drawSpace( hue: number, hasBga: boolean, tier: number, + hit: number, + hitColor: number, ): void { const bands: ReadonlyArray = [ [SYN_DEEP, 120], @@ -121,6 +138,9 @@ function drawSpace( fillAroundBga(graphics, 0, top, DESIGN_WIDTH, bottom - top, color, hasBga); top = bottom; } + if (hit > 0) { + fillAroundBga(graphics, 0, 0, DESIGN_WIDTH, FLOOR.horizon, hitColor, hasBga, 0.07 * hit); + } // Nebulae: stacked translucent discs, placed clear of the BGA rect. for (const [x, y, radius, color] of [ [150, 470, 150, SYN_MAGENTA], @@ -134,7 +154,7 @@ function drawSpace( const cx = 320; const cy = FLOOR.horizon - 90; const starCount = 100 + 25 * tier; - const starSpeed = (110 + 40 * pulse) * (1 + 0.45 * tier); + const starSpeed = (110 + 40 * pulse) * (1 + 0.45 * tier) * (1 + 2.2 * hit); for (let index = 0; index < starCount; index += 1) { const point = starfieldPoint(index, seconds, { spread: 520, near: 20, far: 900, speed: starSpeed }); const projected = projectPoint(point, cx, cy, 180); @@ -165,19 +185,19 @@ function drawFloorGrid( hue: number, tier: number, beatPhase: number, + hit: number, ): void { const { horizon, height, focal } = FLOOR; const color = hsvToHex(hue + 0.08 + (tier >= 3 ? Math.sin(seconds * 0.7) * 0.12 : 0), 0.7, 1); - const glow = 1 + 0.35 * tier; + const glow = 1 + 0.35 * tier + 0.9 * hit; const project = (x: number, z: number) => projectPoint({ x, y: height, z }, 320, horizon, focal); + // Radial lines share one style, so they go out as a single stroked path. 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 * glow }); + graphics.moveTo(near.x, near.y).lineTo(far.x, far.y); } + graphics.stroke({ color, width: 1, alpha: Math.min(0.9, 0.16 * glow) }); const spacing = 90; const offset = (seconds * 150 * (1 + 0.3 * tier)) % spacing; for (let z = spacing - offset; z < 2400; z += spacing) { @@ -211,25 +231,27 @@ function drawAurora(graphics: Graphics, seconds: number, hue: number, tier: numb for (let ribbon = 0; ribbon < ribbons; ribbon += 1) { const color = hsvToHex(hue + ribbon * 0.09 + Math.sin(seconds * 0.3 + ribbon) * 0.05, 0.75, 1); const baseY = 70 + ribbon * 34; - let previous: { x: number; y: number } | undefined; + // One polyline per unbroken run (runs split around the BGA rect) — two strokes per run instead of per segment. + let run: number[] = []; + const flush = (): void => { + if (run.length >= 4) { + graphics.poly(run, false).stroke({ color, width: 16, alpha: 0.04 * tier }); + graphics.poly(run, false).stroke({ color, width: 2, alpha: 0.12 * tier }); + } + run = []; + }; for (let x = -10; x <= DESIGN_WIDTH + 10; x += 16) { const y = baseY + Math.sin(x * 0.012 + seconds * (0.9 + ribbon * 0.25) + ribbon) * 18 + Math.sin(x * 0.031 - seconds * 1.3) * 6; - const point = { x, y }; - if (previous && !(hasBga && (insideBga(previous.x, previous.y, 12) || insideBga(x, y, 12)))) { - graphics - .moveTo(previous.x, previous.y) - .lineTo(x, y) - .stroke({ color, width: 16, alpha: 0.04 * tier }); - graphics - .moveTo(previous.x, previous.y) - .lineTo(x, y) - .stroke({ color, width: 2, alpha: 0.12 * tier }); + if (hasBga && insideBga(x, y, 12)) { + flush(); + continue; } - previous = point; + run.push(x, y); } + flush(); } } @@ -241,21 +263,23 @@ function fillAroundBga( 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(color); + graphics.rect(x, y, w, h).fill(fill); return; } - if (y < BGA.y) graphics.rect(x, y, w, BGA.y - y).fill(color); + 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(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); + 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 glassPanel(graphics: Graphics, x: number, y: number, w: number, h: number, rim: number): void { @@ -397,7 +421,7 @@ function drawHeader( pool, ); addHudText(layer, 'BPM', 150, 14, labelStyle(), pool); - addHudNumber(layer, formatBpm(runtime.bpm), 186, 9, displayStyle(14, SYN_WHITE), pool); + addHudNumber(layer, formatBpm(runtime.bpm), 190, 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( @@ -517,7 +541,7 @@ function drawScorePanel(graphics: Graphics, layer: Container, runtime: Runtime, `${formatCount(runtime.exScore)}/${formatCount(runtime.exScoreMax)}`, x + 136, y + 52, - { ...displayStyle(8, SYN_MIST), anchorX: 1, maxWidth: 50 }, + { ...displayStyle(8, SYN_MIST), anchorX: 1, maxWidth: 46 }, pool, ); addHudText(layer, 'EX RATE', x + 14, y + 76, labelStyle(), pool); @@ -526,7 +550,7 @@ function drawScorePanel(graphics: Graphics, layer: Container, runtime: Runtime, formatExRate(runtime.exScore, runtime.exScoreMax), x + 136, y + 74, - { ...displayStyle(8, SYN_MIST), anchorX: 1, maxWidth: 50 }, + { ...displayStyle(8, SYN_MIST), anchorX: 1, maxWidth: 46 }, pool, ); // Rate meter as a light filament with the IIDX ninth ticks. @@ -605,7 +629,7 @@ function drawJudgeTally(graphics: Graphics, layer: Container, runtime: Runtime, formatCount(runtime[key]), x + w - 7, rowY - 1, - { ...displayStyle(9, SYN_WHITE), anchorX: 1, maxWidth: w - 42 }, + { ...displayStyle(9, SYN_WHITE), anchorX: 1, maxWidth: w - 46 }, pool, ); } @@ -622,7 +646,7 @@ function drawJudgeTally(graphics: Graphics, layer: Container, runtime: Runtime, formatCount(count), x + w - 7, rowY, - { ...displayStyle(9, SYN_WHITE), anchorX: 1, maxWidth: w - 40 }, + { ...displayStyle(9, SYN_WHITE), anchorX: 1, maxWidth: w - 52 }, pool, ); } @@ -649,7 +673,10 @@ function drawJudgements( pool: ChildPool, ): void { const displays = resolveJudgeDisplays(runtime, playfieldRight); + 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 = display.judge === 'PERFECT' ? hsvToHex(seconds * 1.4 + display.x / 400, 0.35, 1) @@ -660,6 +687,7 @@ function drawJudgements( display.x, 236, { + scale: judgeScale, ...displayStyle(16, color), letterSpacing: 4, anchorX: 0.5, @@ -677,6 +705,7 @@ function drawJudgements( 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, @@ -728,7 +757,7 @@ function resolveVisibleCombo(judge: string, combo: number | undefined): number { } function labelStyle(): HudTextOptions { - return { size: 7, fill: SYN_DIM, fontFamily: SYN_DISPLAY_FONT, letterSpacing: 2 }; + return { size: 9, fill: SYN_DIM, fontFamily: SYN_DISPLAY_FONT, letterSpacing: 0.8 }; } function displayStyle(size: number, fill: number): HudTextOptions { diff --git a/packages/player-web/src/scene/default/synesthesia/moments.ts b/packages/player-web/src/scene/default/synesthesia/moments.ts index db581f77..417b6605 100644 --- a/packages/player-web/src/scene/default/synesthesia/moments.ts +++ b/packages/player-web/src/scene/default/synesthesia/moments.ts @@ -3,8 +3,8 @@ import { BGA, DESIGN_HEIGHT, DESIGN_WIDTH, GROOVE, PLAYFIELD } from '../../gamep import type { SkinlessGameplayChromeRuntime } from '../../gameplay-chrome.ts'; import type { ChildPool } from '../../pixi-utils.ts'; import { addHudText } from '../hud-text.ts'; -import { momentProgress, trackMoments } from '../moments.ts'; -import { easeOutCubic, stageProgress } from '../phantom-style.ts'; +import { effectProfile, impulse, momentProgress, trackMoments } from '../moments.ts'; +import { easeOutCubic, hash01, stageProgress } from '../phantom-style.ts'; import { burstParticlePosition, burstParticles, hsvToHex, projectPoint, rotateX, rotateY } from './space.ts'; import { SYN_DISPLAY_FONT, SYN_WHITE } from './style.ts'; @@ -25,7 +25,7 @@ const JUDGE_Y = PLAYFIELD.judgementY; * cycling through the spectrum. */ export function drawSynesthesiaMoments( - key: object, + chromeLayer: Container, layer: Container, runtime: SkinlessGameplayChromeRuntime, playfieldCenterX: number, @@ -33,7 +33,7 @@ export function drawSynesthesiaMoments( pool: ChildPool, ): void { const nowMs = runtime.nowMs ?? 0; - const moments = trackMoments(key, { + const moments = trackMoments(chromeLayer, { nowMs, combo: runtime.combo ?? 0, gauge: runtime.gauge ?? 0, @@ -45,16 +45,42 @@ export function drawSynesthesiaMoments( 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 glitch the signal: red scanline tears and edge bleed, plus (full effects) a jolt of the whole HUD. + const miss = runtime.lastJudge === 'POOR' || runtime.lastJudge === 'BAD' ? impulse(runtime.judgeAtMs, nowMs, 240) : 0; + if (miss > 0) { + drawGlitch(graphics, miss, nowMs, runtime.hasBga === true); + if (effects.screenWide) { + const jolt = Math.sin(nowMs / 9) * 4 * miss; + chromeLayer.position.set(jolt, 0); + layer.position.set(jolt, 0); + } + } + const comboBreak = momentProgress(moments.comboBreak?.atMs, nowMs, BREAK_MS); + if (comboBreak !== undefined && moments.comboBreak) { + drawComboBreak(graphics, moments.comboBreak.combo, comboBreak, playfieldCenterX); + } + if (runtime.chartMs !== undefined) { drawCountIn(graphics, layer, runtime.chartMs, playfieldCenterX, hue, pool); } const milestone = momentProgress(moments.milestone?.atMs, nowMs, MILESTONE_MS); if (milestone !== undefined && moments.milestone) { - drawMilestone(graphics, layer, moments.milestone.value, milestone, playfieldCenterX, hue, pool); + drawMilestone( + graphics, + layer, + moments.milestone.value, + milestone, + playfieldCenterX, + hue, + runtime.hasBga === true, + pool, + ); } const clear = momentProgress(moments.clearAtMs, nowMs, CLEAR_MS); if (clear !== undefined) { @@ -62,7 +88,7 @@ export function drawSynesthesiaMoments( } const fullCombo = momentProgress(moments.fullComboAtMs, nowMs, FULL_COMBO_MS); if (fullCombo !== undefined) { - drawFullCombo(graphics, layer, fullCombo, nowMs, hue, pool); + drawFullCombo(graphics, layer, fullCombo, nowMs, hue, effects.screenWide, effects.amount, pool); } } @@ -142,6 +168,7 @@ function drawMilestone( t: number, playfieldCenterX: number, hue: number, + hasBga: boolean, pool: ChildPool, ): void { const color = hsvToHex(hue + value / 1000, 0.6, 1); @@ -154,15 +181,16 @@ function drawMilestone( .ellipse(playfieldCenterX, JUDGE_Y, radius * 0.8, radius * 0.34) .stroke({ color: SYN_WHITE, width: 1.5, alpha: 0.5 * (1 - wave) }); const alpha = Math.min(1, t / 0.12) * (1 - Math.max(0, (t - 0.7) / 0.3)); + // Over a live BGA the count sits small on the monitor's top edge instead of the middle of the video. const cx = BGA.x + BGA.w / 2; - const cy = BGA.y + BGA.h / 2 - 10; + const cy = hasBga ? BGA.y + 30 : BGA.y + BGA.h / 2 - 10; const count = addHudText( layer, String(value), cx, cy, { - size: 52, + size: hasBga ? 30 : 52, fill: SYN_WHITE, fontFamily: SYN_DISPLAY_FONT, letterSpacing: 4, @@ -178,7 +206,7 @@ function drawMilestone( layer, 'COMBO', cx, - cy + 44, + cy + (hasBga ? 28 : 44), { size: 11, fill: SYN_WHITE, @@ -225,19 +253,22 @@ function drawFullCombo( 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 = Math.max(0, 1 - t * 5); + const bloom = screenWide ? Math.max(0, 1 - t * 5) : 0; if (bloom > 0) { graphics.rect(0, 0, DESIGN_WIDTH, DESIGN_HEIGHT).fill({ color: SYN_WHITE, 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; - for (const particle of FULL_COMBO_BURST) { + 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); @@ -279,3 +310,42 @@ function drawFullCombo( ); text.alpha = form * fadeOut; } + +const BREAK_MS = 800; + +/** Red scanline tears across the screen and a bleed at the edges; tears skip a live BGA rect. */ +function drawGlitch(graphics: Graphics, strength: number, nowMs: number, hasBga: boolean): void { + const frame = Math.floor(nowMs / 40); + for (let tear = 0; tear < 7; tear += 1) { + const y = hash01(frame * 7 + tear) * DESIGN_HEIGHT; + const h = 2 + hash01(frame * 13 + tear) * 6; + const offset = (hash01(frame * 3 + tear) - 0.5) * 60; + if (hasBga && y > BGA.y - 4 && y < BGA.y + BGA.h + 4) { + graphics.rect(offset, y, BGA.x - 8, h).fill({ color: 0xff3a6a, alpha: 0.35 * strength }); + graphics.rect(BGA.x + BGA.w + 8, y, DESIGN_WIDTH, h).fill({ color: 0xff3a6a, alpha: 0.35 * strength }); + } else { + graphics.rect(offset, y, DESIGN_WIDTH, h).fill({ color: 0xff3a6a, alpha: 0.35 * strength }); + } + } + for (let band = 0; band < 3; band += 1) { + const w = 10; + const alpha = 0.18 * strength * (1 - band / 3); + graphics.rect(band * w, 0, w, DESIGN_HEIGHT).fill({ color: 0xff3a6a, alpha }); + graphics.rect(DESIGN_WIDTH - (band + 1) * w, 0, w, DESIGN_HEIGHT).fill({ color: 0xff3a6a, alpha }); + } +} + +/** A broken combo disperses into red sparks that drift up and fade from the combo readout. */ +function drawComboBreak(graphics: Graphics, combo: number, t: number, cx: number): void { + const cy = 264; + for (let spark = 0; spark < 36; spark += 1) { + const angle = hash01(spark + combo * 3) * Math.PI * 2; + const speed = 30 + hash01(spark * 5 + 1) * 90; + const eased = easeOutCubic(t); + const x = cx + Math.cos(angle) * speed * eased; + const y = cy + Math.sin(angle) * speed * eased * 0.6 - 40 * eased; + const size = 1 + hash01(spark + 11) * 2; + graphics.circle(x, y, size * 3).fill({ color: 0xff3a6a, alpha: 0.12 * (1 - t) }); + graphics.circle(x, y, size).fill({ color: 0xffd0dc, alpha: 1 - t }); + } +} diff --git a/packages/player-web/src/scene/default/synesthesia/playfield.ts b/packages/player-web/src/scene/default/synesthesia/playfield.ts index 675caa96..06407c3f 100644 --- a/packages/player-web/src/scene/default/synesthesia/playfield.ts +++ b/packages/player-web/src/scene/default/synesthesia/playfield.ts @@ -18,7 +18,7 @@ import { type Vec3, } from './space.ts'; import { SYN_GLOW_SIZE, synGlowTexture } from './style.ts'; -import { comboTier } from '../moments.ts'; +import { comboTier, effectProfile } from '../moments.ts'; /** Per-lane-class light: note body, glow hue (0..1), and the colour the lane beam / key cap light up in. */ interface LaneLight { @@ -37,10 +37,17 @@ const LIGHTS: Record = { const NOTE_HEIGHT = 8; export const SYNESTHESIA_BOMB_DURATION_MS = 620; -export function renderSynesthesiaLanes({ graphics, lanes, beatPhase, nowMs, combo }: BeMusicLanesContext): void { +export function renderSynesthesiaLanes({ + graphics, + lanes, + beatPhase, + nowMs, + combo, + effects, +}: BeMusicLanesContext): void { const pulse = (1 - beatPhase) ** 2; // The judgement line burns brighter as the run builds, and cycles the spectrum once it is in the zone. - const tier = comboTier(combo ?? 0); + const tier = effectProfile(effects).enabled ? comboTier(combo ?? 0) : 0; const heat = 1 + 0.3 * tier; const lineColor = tier >= 3 ? hsvToHex(0.52 + Math.sin(nowMs / 900) * 0.2, 0.45, 1) : 0x5ff4ff; let gridTop = Number.POSITIVE_INFINITY; @@ -149,17 +156,41 @@ function cachedBurst(seed: number): BurstParticle[] { * * Everything is additive, so overlapping hits bloom into each other like light instead of stacking opaque shapes. */ -export function renderSynesthesiaBombs({ pool, bombs, combo }: BeMusicBombsContext): void { - const tier = comboTier(combo ?? 0); +export function renderSynesthesiaBombs({ pool, bombs, combo, effects }: BeMusicBombsContext): 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) { - renderBomb(pool, bomb, tier, crowd); + renderBomb(pool, bomb, tier, crowd, profile.amount); + } +} + +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): void { +function renderBomb(pool: ChildPool, bomb: BeMusicBomb, tier: number, crowd: number, amount: 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]; @@ -207,7 +238,10 @@ function renderBomb(pool: ChildPool, bomb: BeMusicBomb, tier: number, crowd: num } // 3 + 4. Sparks: compute every projected position once, draw trails, then depth-sorted glow sprites. - const particles = cachedBurst(bomb.seed).slice(0, BURST_PARTICLES + BURST_PARTICLES_PER_TIER * tier); + const particles = cachedBurst(bomb.seed).slice( + 0, + Math.round((BURST_PARTICLES + BURST_PARTICLES_PER_TIER * tier) * amount), + ); const radius = unit * 4.6 * (1 + 0.1 * tier); const gravity = unit * 3.2; const sparks: Array<{ x: number; y: number; z: number; scale: number; life: number; particle: BurstParticle }> = []; @@ -271,7 +305,7 @@ function renderBomb(pool: ChildPool, bomb: BeMusicBomb, tier: number, crowd: num core.tint = hsvToHex(light.hue, t * 2, 1); core.alpha = flash * 0.7 * crowd; } - const streakAlpha = fade ** 2.5; + const streakAlpha = amount < 1 ? 0 : fade ** 2.5; if (streakAlpha > 0.02) { const streak = pool.acquireSprite(); streak.texture = glow; diff --git a/packages/player-web/src/scene/default/synesthesia/result.ts b/packages/player-web/src/scene/default/synesthesia/result.ts index d1505690..ef1e6656 100644 --- a/packages/player-web/src/scene/default/synesthesia/result.ts +++ b/packages/player-web/src/scene/default/synesthesia/result.ts @@ -42,7 +42,9 @@ export const synesthesiaResultSkin: BeMusicResultSkin = { render: (frame) => ren * 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 { 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; const seconds = nowMs / 1000; const hue = sceneHue(seconds); const accent = hsvToHex(hue, 0.6, 1); @@ -74,7 +76,7 @@ export function renderSynesthesiaResult(frame: BeMusicResultFrame): void { ...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 })); + text(target, value, x, y, display(9, fill, { letterSpacing: 2 })); // Space. const space = group('space'); diff --git a/packages/player-web/src/scene/default/synesthesia/select.ts b/packages/player-web/src/scene/default/synesthesia/select.ts index 265e2a23..c0504213 100644 --- a/packages/player-web/src/scene/default/synesthesia/select.ts +++ b/packages/player-web/src/scene/default/synesthesia/select.ts @@ -27,6 +27,7 @@ import { const LAYOUT: BeMusicSelectLayout = { listX: 322, listTop: 56, listBottomInset: 28, rowHeight: 28 }; const SLIDE_MS = 320; +const OUTRO_MS = 700; const INTRO_STAGGER_MS = 45; const STAR_COUNT = 150; const ORBIT_COUNT = 14; @@ -38,7 +39,7 @@ export const synesthesiaSelectSkin: BeMusicSelectSkin = { }; function labelStyle(fill: number = SYN_DIM): SkinTextOptions { - return { size: 7, fill, fontFamily: SYN_DISPLAY_FONT, letterSpacing: 2.5 }; + return { size: 9, fill, fontFamily: SYN_DISPLAY_FONT, letterSpacing: 2 }; } function glassPanel(graphics: Graphics, x: number, y: number, w: number, h: number, rim: number, radius = 8): void { @@ -66,6 +67,11 @@ class SynesthesiaSelectRenderer implements BeMusicSelectRenderer { 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: BeMusicSelectFrame['effects'] = 'full'; + public readonly outroMs = OUTRO_MS; private activeCard: { x: number; y: number; w: number; h: number } | undefined; public constructor() { @@ -73,7 +79,12 @@ class SynesthesiaSelectRenderer implements BeMusicSelectRenderer { this.frontLayer.label = 'synesthesia-select/front'; } - public render(frame: BeMusicSelectFrame): boolean { + public render(input: BeMusicSelectFrame): 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); @@ -87,11 +98,52 @@ class SynesthesiaSelectRenderer implements BeMusicSelectRenderer { const visible = this.activeCard !== undefined; this.cursorGlow.visible = visible; for (const sprite of this.orbit) sprite.visible = visible; + 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 warp: the star tunnel accelerates into streaks (in `tick`), a white bloom opens from the + * vanishing point with the chosen title in it, and the screen falls to black for the gameplay count-in. + */ + private renderOutro(frame: BeMusicSelectFrame): 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 = 'synesthesia-select/outro'; + const cx = designWidth * 0.58; + const cy = designHeight * 0.44; + const bloom = easeOutCubic(Math.min(1, t / 0.7)); + for (let ring = 5; ring >= 1; ring -= 1) { + g.circle(cx, cy, (40 + 520 * bloom) * (ring / 5)).fill({ color: SYN_WHITE, alpha: 0.1 * bloom }); + } + const dark = Math.max(0, (t - 0.72) / 0.28); + if (dark > 0) g.rect(0, 0, designWidth, designHeight).fill({ color: SYN_VOID, alpha: dark }); + layer.addChild(g); + addSkinText(layer, frame.focusedSong?.title ?? '', cx, cy, { + size: 22, + weight: '500', + fill: SYN_WHITE, + fontFamily: SYN_TEXT_FONT, + letterSpacing: 2 + 10 * bloom, + anchorX: 0.5, + anchorY: 0.5, + maxWidth: designWidth - 80, + alpha: Math.min(1, t * 3) * (1 - dark), + dropShadow: { color: SYN_CYAN, distance: 0, blur: 16, alpha: 1 }, + }); + } + + public tick(nowMs: number, focusedSong: BrowserSongEntry | undefined, launchAt?: number): void { + // Ambient motion: frozen with effects off, half speed when reduced. + const rate = this.effects === 'off' ? 0 : this.effects === 'reduced' ? 0.5 : 1; + 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; @@ -102,17 +154,20 @@ class SynesthesiaSelectRenderer implements BeMusicSelectRenderer { // 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; + const speed = (80 + beatsPerSecond * 55) * (1 + 16 * warp * warp); + this.travel += dt * speed * (warp > 0 ? 1 : rate); 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 point = starfieldPoint(index, this.travel, { spread: 560, near: 10, far: 1000, speed: 1 }); 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; + // During the warp stars stretch into streaks pointing out of the vanishing point. + star.rotation = Math.atan2(projected.y - cy, projected.x - cx); + star.width = size * (1 + 9 * warp); 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); @@ -159,7 +214,7 @@ class SynesthesiaSelectRenderer implements BeMusicSelectRenderer { 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; + const offset = this.travel % spacing; for (let z = spacing - offset; z < 2600; z += spacing) { const left = project(-1400, z); const right = project(1400, z); @@ -348,8 +403,8 @@ class SynesthesiaSelectRenderer implements BeMusicSelectRenderer { } } if (song?.fileLabel) { - addText(song.fileLabel, 30, 246, { - size: 8, + addText(song.fileLabel, 30, 245, { + size: 9, weight: '300', fill: SYN_DIM, fontFamily: SYN_TEXT_FONT, @@ -385,7 +440,7 @@ class SynesthesiaSelectRenderer implements BeMusicSelectRenderer { // 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, { + addText(frame.searchQuery || 'Title / artist / genre', 100, 380, { size: 10, weight: '300', fill: frame.searchQuery ? SYN_WHITE : SYN_DIM, @@ -449,7 +504,7 @@ class SynesthesiaSelectRenderer implements BeMusicSelectRenderer { frame.layer.addChild(row); const midY = y + (rowHeight - 4) / 2; addSkinText(frame.layer, level, rowX + 16, midY, { - size: level.length > 2 ? 6 : 8, + size: level.length > 2 ? 7 : 9, fill: SYN_WHITE, fontFamily: SYN_DISPLAY_FONT, anchorX: 0.5, @@ -461,7 +516,7 @@ class SynesthesiaSelectRenderer implements BeMusicSelectRenderer { ? `${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, + size: 9, fill: active ? SYN_WHITE : SYN_DIM, fontFamily: SYN_DISPLAY_FONT, letterSpacing: 1, diff --git a/packages/player-web/src/scene/gameplay-chrome.ts b/packages/player-web/src/scene/gameplay-chrome.ts index 133793bc..c1cd445d 100644 --- a/packages/player-web/src/scene/gameplay-chrome.ts +++ b/packages/player-web/src/scene/gameplay-chrome.ts @@ -66,6 +66,13 @@ export interface SkinlessGameplayChromeRuntime { * been scheduled. Lets chrome stage entrance cut-ins against the real start. */ chartMs?: number; + /** 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?: 'white' | 'black' | 'scratch'; + /** Showmanship level the chrome should render at (defaults to `'full'`). */ + effects?: 'full' | 'reduced' | 'off'; } export interface SkinlessGameplayChromeRenderContext { diff --git a/packages/player-web/src/skin/be-music/types.ts b/packages/player-web/src/skin/be-music/types.ts index dd1b5709..ed9f6d7b 100644 --- a/packages/player-web/src/skin/be-music/types.ts +++ b/packages/player-web/src/skin/be-music/types.ts @@ -28,6 +28,13 @@ export interface BeMusicSkin { readonly result: BeMusicResultSkin; } +/** + * 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'; @@ -66,6 +73,7 @@ export interface BeMusicLanesContext { nowMs: number; /** Current combo — lets skins escalate the playfield as a run builds. */ combo?: number; + effects?: BeMusicEffectLevel; } export interface BeMusicNoteContext { @@ -110,6 +118,7 @@ export interface BeMusicBombsContext { nowMs: number; /** Current combo — lets skins escalate hit effects as a run builds. */ combo?: number; + effects?: BeMusicEffectLevel; } /** Song-list geometry shared by the select renderer (drawing) and the scene (row hit-testing). */ @@ -154,9 +163,17 @@ export interface BeMusicSelectFrame { 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; } export interface BeMusicSelectRenderer { + /** + * Length (ms) of the outro the renderer plays after a chart is launched, before the scene hands off to gameplay. + * `0` / omitted launches immediately. The scene keeps rendering frames (with `frame.launchAt` set) until it elapses. + */ + readonly outroMs?: number; /** Persistent layer mounted behind the rebuilt `frame.layer` (ambient backgrounds). */ readonly backLayer: Container; /** Persistent layer mounted in front of `frame.layer` (cursor, glints). */ @@ -167,7 +184,7 @@ export interface BeMusicSelectRenderer { */ render(frame: BeMusicSelectFrame): boolean; /** Per-frame, transform-only animation of the persistent layers. */ - tick(nowMs: number, focusedSong: BrowserSongEntry | undefined): void; + tick(nowMs: number, focusedSong: BrowserSongEntry | undefined, launchAt?: number): void; dispose(): void; } @@ -184,6 +201,7 @@ export interface BeMusicResultFrame { /** Milliseconds since the scene mounted, or `Infinity` once the player skipped the entrance. */ elapsedMs: number; nowMs: number; + effects: BeMusicEffectLevel; } export interface BeMusicResultSkin { From 40105d9a0ed7bc2987638865bb00152d8108ac09 Mon Sep 17 00:00:00 2001 From: Seiya KONNO Date: Tue, 29 Sep 2026 01:52:42 +0900 Subject: [PATCH 03/65] feat(player-web): rework Synesthesia as a cosmic particle world with a roaming camera Rebuild the skin as a cosmic particle world: a black void with an ember horizon, fine ember / gold particles with blue and magenta accents, a point floor, particle rivers, point-cloud pyramids and a floating point-cloud figure, voxel sparks and lock-on reticles, and hairline frames. The 3D backdrop now flies between camera shots periodically, and the idle BGA monitor orbits the figure. --- .changeset/be-music-skins.md | 4 +- .../src/scene/default/synesthesia/draw.ts | 143 +++++ .../default/synesthesia/gameplay-chrome.ts | 509 +++++++++++------- .../src/scene/default/synesthesia/moments.ts | 58 +- .../scene/default/synesthesia/playfield.ts | 57 +- .../src/scene/default/synesthesia/result.ts | 134 +++-- .../src/scene/default/synesthesia/select.ts | 344 +++++++----- .../scene/default/synesthesia/space.test.ts | 184 ++++++- .../src/scene/default/synesthesia/space.ts | 251 +++++++-- .../src/scene/default/synesthesia/style.ts | 40 +- 10 files changed, 1258 insertions(+), 466 deletions(-) create mode 100644 packages/player-web/src/scene/default/synesthesia/draw.ts diff --git a/.changeset/be-music-skins.md b/.changeset/be-music-skins.md index 98011bac..3af2c761 100644 --- a/.changeset/be-music-skins.md +++ b/.changeset/be-music-skins.md @@ -8,11 +8,11 @@ The default (skinless) family now renders through a `BeMusicSkin`: one object bu - 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. +- Synesthesia is inspired by the sound-and-light games of Tetsuya Mizuguchi, after Rez Infinite's Area X: a black void lit by an ember horizon, a world built from fine ember / gold particles with electric blue and magenta accents, data dust and speed streaks pouring out of the vanishing point, a floor of light points, rivers of particles, point-cloud pyramids, and a floating point-cloud figure. The 3D backdrop periodically flies the camera to a new shot (height, heading, and tilt), reframing the whole space, and the idle BGA monitor orbits the figure. Hits detonate as perspective-projected 3D particle fountains with voxel sparks, motion trails, tilted shockwave rings, 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 a 3D particle ring that bursts as the rank ignites. It draws with Michroma and M PLUS 1p, which hosts should load. - 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 detonates a ring of light), a cut-in every 100 combo, a flourish when the gauge crosses the clear line, and a full-combo finale (Phantom's turning burst with confetti, Synesthesia's 3D spark sphere and prism rings). 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 stars speed up and multiply, the floor grid burns hotter, aurora ribbons and beat rings appear, stars smear into hyperspace streaks, and hits throw more sparks further. Overlapping hits now dim their white flash so colour stays the hero. +- Synesthesia enters a zone as the combo builds: from 25 / 50 / 100 / 200 combo the dust speeds up and multiplies, speed streaks thicken, the floor burns hotter, more particle rivers and beat rings 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 miss shakes the screen with a red vignette (Phantom) or an RGB glitch (Synesthesia), and breaking a combo of 20 or more shatters the counter. - Starting a chart from select now plays a short launch outro (Phantom's ink slabs with LET'S GO!, Synesthesia's warp into a white-out) 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. diff --git a/packages/player-web/src/scene/default/synesthesia/draw.ts b/packages/player-web/src/scene/default/synesthesia/draw.ts new file mode 100644 index 00000000..d13a5e7f --- /dev/null +++ b/packages/player-web/src/scene/default/synesthesia/draw.ts @@ -0,0 +1,143 @@ +import type { Graphics } from 'pixi.js'; +import { projectPoint, rotateX, rotateY, viewPoint, type CameraPose, type CloudPoint } from './space.ts'; +import { SYN_GLASS, SYN_WHITE } from './style.ts'; + +/** + * Shared Synesthesia drawing: hairline frames with lock-on corners (Rez's targeting reticle) and projected point + * clouds. Callers own the `Graphics`; these only append geometry. + */ + +/** + * Rez 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 — the Rez take on a HUD 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; + 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; + graphics + .rect(projected.x - halo / 2, projected.y - halo / 2, halo, halo) + .fill({ color, alpha: Math.min(1, alpha) * 0.12 }); + } + graphics + .rect(projected.x - size / 2, projected.y - size / 2, size, size) + .fill({ color, alpha: Math.min(1, alpha) }); + } +} diff --git a/packages/player-web/src/scene/default/synesthesia/gameplay-chrome.ts b/packages/player-web/src/scene/default/synesthesia/gameplay-chrome.ts index b278af4a..ed2fc16d 100644 --- a/packages/player-web/src/scene/default/synesthesia/gameplay-chrome.ts +++ b/packages/player-web/src/scene/default/synesthesia/gameplay-chrome.ts @@ -6,20 +6,34 @@ import type { ChildPool } from '../../pixi-utils.ts'; import { addHudNumber, addHudText, type HudTextOptions } from '../hud-text.ts'; import { comboTier, effectProfile, impulse, punchScale } from '../moments.ts'; import { drawSynesthesiaMoments } from './moments.ts'; -import { hsvToHex, icosahedron, projectPoint, rotateX, rotateY, starfieldPoint } from './space.ts'; +import { hash01 } from '../phantom-style.ts'; +import { drawFrame, drawPointCloud, drawReticle } from './draw.ts'; +import { + cameraShot, + emberColor, + hsvToHex, + mixCamera, + particleRiverPoint, + pointCloudHumanoid, + pointCloudPyramid, + projectPoint, + REST_CAMERA, + starfieldPoint, + vanishingPoint, + viewPoint, + type CameraPose, +} from './space.ts'; import { SYN_AMBER, SYN_CYAN, - SYN_DEEP, SYN_DIM, SYN_DISPLAY_FONT, - SYN_GLASS, - SYN_GREEN, + SYN_EMBER, + SYN_FLARE, SYN_MAGENTA, SYN_MIST, SYN_RED, SYN_TEXT_FONT, - SYN_VIOLET, SYN_VOID, SYN_WHITE, sceneHue, @@ -32,12 +46,28 @@ 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(); +/** + * Camera shots the background flies between (floor frame: eye at the origin, floor 154 below): a low glide banking + * right, a high sweep looking down from the left, a skim just over the floor, and a high centre shot. Each holds for + * {@link SHOT_HOLD_S} then flies to the next over {@link SHOT_MOVE_S}. + */ +const GAMEPLAY_SHOTS: readonly CameraPose[] = [ + REST_CAMERA, + { x: 170, y: 64, z: 0, yaw: 0.28, pitch: -0.05 }, + { x: -210, y: -110, z: 0, yaw: -0.32, pitch: 0.15 }, + { x: 30, y: 92, z: 0, yaw: 0.12, pitch: -0.09 }, + { x: 80, y: -150, z: 0, yaw: -0.14, pitch: 0.21 }, +]; +const SHOT_HOLD_S = 9; +const SHOT_MOVE_S = 3.5; +const FIGURE = pointCloudHumanoid(11, 1400); +const PYRAMIDS = [pointCloudPyramid(3, 520), pointCloudPyramid(8, 420), pointCloudPyramid(5, 700)]; /** - * 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. + * Synesthesia gameplay HUD after Rez Infinite's Area X: a black void lit by an ember horizon, data dust and speed + * streaks 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 + * point-cloud figure in front of particle pyramids. Colour drifts through the ember band and swells on every beat. */ export function renderSynesthesiaChrome({ layer, @@ -56,23 +86,40 @@ export function renderSynesthesiaChrome({ const space = layerPool.acquireGraphics(); space.label = 'synesthesia-gameplay/space'; - // Zone: the space intensifies with the combo (faster, denser stars, hotter grid, aurora, beat rings). Reduced effects - // cap the escalation; effects off keep the calm tier-0 space. + space.blendMode = 'normal'; + // Zone: the space intensifies with the combo (faster, denser dust, more streaks, 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 starfield and tints the sky with the pressed lane's light. + // Input reaction: every press surges the dust and streaks 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']; - drawSpace(space, seconds, pulse, hue, hasBga, tier, hit, hitColor); - drawFloorGrid(space, seconds, pulse, hue, tier, beatPhase, hit); - if (tier >= 2) drawAurora(space, seconds, hue, tier, hasBga); + // Camera: the background periodically flies to a new shot, reframing floor, rivers and warp. Reduced effects halve + // the moves; effects off keep the camera at rest. + const camera = mixCamera(REST_CAMERA, cameraShot(seconds, GAMEPLAY_SHOTS, SHOT_HOLD_S, SHOT_MOVE_S), effects.amount); + const horizon = vanishingPoint(camera, 320, FLOOR.horizon, FLOOR.focal).y; + drawGround(space, pulse, hasBga, tier, hit, hitColor, horizon); + + const light = layerPool.acquireGraphics(); + light.label = 'synesthesia-gameplay/light'; + light.blendMode = 'add'; + drawDust(light, seconds, pulse, hasBga, tier, hit, camera); + drawFloor(light, seconds, pulse, tier, beatPhase, hit, camera); + const rivers = tier >= 4 ? 3 : tier >= 2 ? 2 : 1; + for (let river = 0; river < rivers; river += 1) { + drawRiver(light, seconds, river, hasBga, tier, camera); + } const panels = layerPool.acquireGraphics(); panels.label = 'synesthesia-gameplay/panels'; + panels.blendMode = 'normal'; drawPlayfieldWell(panels, playfieldRight, runtime.progressRatio, accent); // A double-play field reaches into the monitor's column; the frame and its idle screen would sit on the 2P lanes. if (playfieldRight + 14 <= BGA.x) { - drawBgaFrame(panels, layer, hasBga, seconds, pulse, hue, layerPool); + const standby = layerPool.acquireGraphics(); + standby.label = 'synesthesia-gameplay/standby'; + standby.blendMode = 'add'; + drawBgaFrame(panels, standby, layer, hasBga, seconds, pulse, hue, camera, layerPool); } drawGauge(panels, layer, runtime, accent, layerPool); drawSongPlate(panels, layer, runtime, accent, layerPool); @@ -106,31 +153,32 @@ 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. - */ const IMPULSE_COLORS: Record<'white' | 'black' | 'scratch', number> = { - white: 0x5ff4ff, - black: 0x8f74ff, - scratch: 0xff4fd8, + white: SYN_EMBER, + black: SYN_CYAN, + scratch: SYN_MAGENTA, }; -function drawSpace( +/** Vanishing point the dust and streaks pour out of (behind the monitor). */ +const VANISH = { x: 320, y: FLOOR.horizon - 90 } as const; + +/** + * Warm-black ground with an ember haze hugging the horizon (a stack of thin bands, so a live BGA stays untouched), plus + * a brief tint of the pressed lane's light on each key press. `horizon` follows the camera's pitch. + */ +function drawGround( graphics: Graphics, - seconds: number, pulse: number, - hue: number, hasBga: boolean, tier: number, hit: number, hitColor: number, + horizon: number, ): void { const bands: ReadonlyArray = [ - [SYN_DEEP, 120], - [0x080620, 220], - [0x05051a, 320], - [0x030410, 400], + [0x090302, 140], + [0x060201, 260], + [0x040100, 380], [SYN_VOID, DESIGN_HEIGHT], ]; let top = 0; @@ -139,119 +187,159 @@ function drawSpace( top = bottom; } if (hit > 0) { - fillAroundBga(graphics, 0, 0, DESIGN_WIDTH, FLOOR.horizon, hitColor, hasBga, 0.07 * hit); + fillAroundBga(graphics, 0, 0, DESIGN_WIDTH, horizon, hitColor, hasBga, 0.06 * hit); } - // 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) * (1 + 0.5 * tier) }); - } + const haze = (0.05 + 0.04 * pulse) * (1 + 0.35 * tier); + for (let band = 0; band < 12; band += 1) { + const falloff = (1 - band / 12) ** 2; + const h = 6; + // Above and below the horizon line, fading out with distance. + fillAroundBga(graphics, 0, horizon - (band + 1) * h, DESIGN_WIDTH, h, SYN_EMBER, hasBga, haze * falloff); + fillAroundBga(graphics, 0, horizon + band * h * 0.7, DESIGN_WIDTH, h * 0.7, SYN_EMBER, hasBga, haze * falloff); } - // Starfield streaming toward the camera from the horizon. - const cx = 320; - const cy = FLOOR.horizon - 90; - const starCount = 100 + 25 * tier; - const starSpeed = (110 + 40 * pulse) * (1 + 0.45 * tier) * (1 + 2.2 * hit); - for (let index = 0; index < starCount; index += 1) { - const point = starfieldPoint(index, seconds, { spread: 520, near: 20, far: 900, speed: starSpeed }); - const projected = projectPoint(point, cx, cy, 180); +} + +/** + * Data dust (tiny warm squares, the odd blue / magenta mote) flying out of the vanishing point, and speed streaks — + * Rez's warp lines — that multiply with the combo tier and surge on key presses. + */ +function drawDust( + graphics: Graphics, + seconds: number, + pulse: number, + hasBga: boolean, + tier: number, + hit: number, + camera: CameraPose, +): void { + const count = 220 + 50 * tier; + const speed = (120 + 40 * pulse) * (1 + 0.45 * tier) * (1 + 2.2 * hit); + for (let index = 0; index < count; index += 1) { + const point = starfieldPoint(index, seconds, { spread: 520, near: 20, far: 900, speed }); + const projected = projectPoint(viewPoint(point, camera), 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 radius = 0.4 + 1.8 * nearness; - const color = hsvToHex(hue + (index % 5) * (0.06 + 0.03 * tier), 0.35 + 0.3 * (index % 2) + 0.06 * tier, 1); - if (tier >= 4) { - // Hyperspace: stars smear into streaks along their flight line. - const tail = projectPoint({ ...point, z: point.z + 90 }, cx, cy, 180); + 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; graphics - .moveTo(tail.x, tail.y) - .lineTo(projected.x, projected.y) - .stroke({ color, width: radius, alpha: 0.35 * nearness }); + .rect(projected.x - bokeh / 2, projected.y - bokeh / 2, bokeh, bokeh) + .fill({ color, alpha: 0.05 * nearness }); } - 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 }); + graphics + .rect(projected.x - size / 2, projected.y - size / 2, size, size) + .fill({ color, alpha: 0.3 + 0.6 * nearness }); + } + const vanish = vanishingPoint(camera, VANISH.x, VANISH.y, 180); + const streaks = Math.round(8 + 12 * tier + 14 * hit); + const streakSpeed = (0.45 + 0.3 * tier) * (1 + 1.5 * hit); + for (let index = 0; index < streaks; index += 1) { + const angle = hash01(index * 5 + 1) * Math.PI * 2; + const progress = (hash01(index * 5 + 2) + seconds * streakSpeed * (0.7 + 0.6 * hash01(index * 5 + 3))) % 1; + const inner = 40 + progress * progress * 520; + const outer = inner + (12 + 80 * progress) * (1 + 0.4 * tier); + const cos = Math.cos(angle); + const sin = Math.sin(angle) * 0.72; + const x0 = vanish.x + cos * inner; + const y0 = vanish.y + sin * inner; + const x1 = vanish.x + cos * outer; + const y1 = vanish.y + sin * outer; + if (hasBga && (insideBga(x0, y0, 6) || insideBga(x1, y1, 6))) continue; + const alpha = Math.sin(progress * Math.PI) * (0.35 + 0.1 * tier); + const color = index % 6 === 0 ? SYN_CYAN : emberColor(0.55 + 0.4 * hash01(index * 5 + 4)); + graphics + .moveTo(x0, y0) + .lineTo(x1, y1) + .stroke({ color, width: 0.8 + progress * 1.4, alpha }); } } -/** Rez-style floor: radial lines into the horizon and cross lines scrolling toward the player on the beat. */ -function drawFloorGrid( +/** + * Area X floor: a lattice of light points scrolling toward the player on the beat, over faint radial guide lines, with + * beat rings rolling out of the vanishing point once the run is deep in the zone. + */ +function drawFloor( graphics: Graphics, seconds: number, pulse: number, - hue: number, tier: number, beatPhase: number, hit: number, + camera: CameraPose, ): void { const { horizon, height, focal } = FLOOR; - const color = hsvToHex(hue + 0.08 + (tier >= 3 ? Math.sin(seconds * 0.7) * 0.12 : 0), 0.7, 1); - const glow = 1 + 0.35 * tier + 0.9 * hit; - const project = (x: number, z: number) => projectPoint({ x, y: height, z }, 320, horizon, focal); - // Radial lines share one style, so they go out as a single stroked path. - for (let x = -1200; x <= 1200; x += 80) { + const glow = 1 + 0.3 * tier + 0.8 * hit; + const project = (x: number, z: number) => projectPoint(viewPoint({ x, y: height, z }, camera), 320, horizon, focal); + const vanish = vanishingPoint(camera, 320, horizon, focal); + for (let x = -1500; x <= 1500; x += 100) { const near = project(x, 0); const far = project(x, 2400); + if (!near.visible || !far.visible) continue; graphics.moveTo(near.x, near.y).lineTo(far.x, far.y); } - graphics.stroke({ color, width: 1, alpha: Math.min(0.9, 0.16 * glow) }); + 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 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: Math.min(0.9, (0.08 + 0.22 * nearness) * (0.7 + 0.3 * pulse) * glow) }); + 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 = project(x, z); + if (!point.visible || point.x < -4 || point.x > DESIGN_WIDTH + 4 || point.y > DESIGN_HEIGHT + 4) continue; + graphics.rect(point.x - size / 2, point.y - size / 2, size, size).fill({ color, alpha }); + } } - // Horizon glow. - graphics.rect(0, horizon - 1, DESIGN_WIDTH, 2).fill({ color, alpha: (0.18 + 0.2 * pulse) * glow }); + graphics.rect(0, vanish.y - 1, DESIGN_WIDTH, 2).fill({ color: SYN_AMBER, alpha: (0.22 + 0.2 * pulse) * glow }); if (tier >= 3) { - // Beat rings rolling out of the vanishing point across the floor. for (const phase of [beatPhase, (beatPhase + 0.5) % 1]) { const rx = 30 + phase * 520; graphics - .ellipse(320, horizon + 4 + phase * 60, rx, rx * 0.16) - .stroke({ color, width: 1 + (1 - phase) * 2, alpha: 0.45 * (1 - phase) }); + .ellipse(vanish.x, vanish.y + 4 + phase * 60, rx, rx * 0.16) + .stroke({ color: SYN_AMBER, width: 1 + (1 - phase) * 2, alpha: 0.4 * (1 - phase) }); } } } +const RIVER_PARTICLES = 280; + /** - * Aurora ribbons flowing across the sky once the run is in the zone (combo tier 2+): layered sine curves in the scene - * hue, broken around the BGA rect so a live video stays clean. + * A river of light particles streaming low over the floor, receding diagonally into the distance — Area X's golden + * flows. Each particle is a short streak along the flow; river 1 runs blue-white, the others ember. */ -function drawAurora(graphics: Graphics, seconds: number, hue: number, tier: number, hasBga: boolean): void { - const ribbons = tier >= 4 ? 4 : tier >= 3 ? 3 : 2; - for (let ribbon = 0; ribbon < ribbons; ribbon += 1) { - const color = hsvToHex(hue + ribbon * 0.09 + Math.sin(seconds * 0.3 + ribbon) * 0.05, 0.75, 1); - const baseY = 70 + ribbon * 34; - // One polyline per unbroken run (runs split around the BGA rect) — two strokes per run instead of per segment. - let run: number[] = []; - const flush = (): void => { - if (run.length >= 4) { - graphics.poly(run, false).stroke({ color, width: 16, alpha: 0.04 * tier }); - graphics.poly(run, false).stroke({ color, width: 2, alpha: 0.12 * tier }); - } - run = []; - }; - for (let x = -10; x <= DESIGN_WIDTH + 10; x += 16) { - const y = - baseY + - Math.sin(x * 0.012 + seconds * (0.9 + ribbon * 0.25) + ribbon) * 18 + - Math.sin(x * 0.031 - seconds * 1.3) * 6; - if (hasBga && insideBga(x, y, 12)) { - flush(); - continue; - } - run.push(x, y); - } - flush(); +function drawRiver( + graphics: Graphics, + seconds: number, + river: number, + hasBga: boolean, + tier: number, + camera: CameraPose, +): void { + const options = { length: 1500, speed: 260 + 60 * tier, amplitude: 28, 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), 320, FLOOR.horizon, FLOOR.focal); + const tail = projectPoint(toWorld({ ...local, x: local.x - 26 }), 320, 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); + graphics + .moveTo(tail.x, tail.y) + .lineTo(head.x, head.y) + .stroke({ color, width: 0.4 + 0.9 * nearness * (0.5 + heat), alpha: 0.25 + 0.55 * heat }); } } @@ -282,13 +370,6 @@ function fillAroundBga( if (bottom > holeBottom) graphics.rect(x, holeBottom, w, bottom - holeBottom).fill(fill); } -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; @@ -312,88 +393,137 @@ function drawPlayfieldWell(graphics: Graphics, right: number, progressRatio: num } /** - * 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. + * Monitor frame: a warm hairline with lock-on corners. With no BGA the screen idles on a miniature Area X — a floating + * point-cloud figure turning slowly in front of particle pyramids on an ember horizon, streaks pouring past — 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, pool: ChildPool, ): void { - const color = hsvToHex(hue, 0.55, 1); + const color = hsvToHex(hue, 0.6, 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 }); - } + 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.85 }); + 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 figure: 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 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), + const baseHorizon = BGA.y + BGA.h * 0.66; + const vanish = vanishingPoint(orbitCamera, cx, baseHorizon, 220); + const horizon = vanish.y; + // Ember horizon glow. + for (let band = 0; band < 10; band += 1) { + const falloff = (1 - band / 10) ** 2; + const alpha = (0.07 + 0.05 * pulse) * falloff; + light.rect(BGA.x, horizon - (band + 1) * 5, BGA.w, 5).fill({ color: SYN_EMBER, alpha }); + light.rect(BGA.x, horizon + band * 3, BGA.w, 3).fill({ color: SYN_EMBER, alpha: alpha * 0.8 }); + } + light.rect(BGA.x, horizon - 0.5, BGA.w, 1).fill({ color: SYN_AMBER, alpha: 0.6 }); + 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. + const offset = (seconds * 90) % 60; + for (let z = 60 - offset; z < 1400; z += 60) { + const nearness = 1 - z / 1400; + for (let x = -600; x <= 600; x += 30) { + 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; + light + .rect(point.x - size / 2, point.y - size / 2, size, size) + .fill({ color: emberColor(0.3 + 0.6 * nearness), alpha: 0.2 + 0.6 * nearness * nearness }); + } + } + // Streaks pouring past from the vanishing point. + for (let index = 0; index < 18; index += 1) { + const angle = hash01(index * 7 + 1) * Math.PI * 2; + const progress = (hash01(index * 7 + 2) + seconds * 0.5) % 1; + const inner = 10 + progress * progress * 200; + const outer = inner + 6 + 40 * progress; + const x0 = vanish.x + Math.cos(angle) * inner; + const y0 = horizon - 40 + Math.sin(angle) * inner * 0.7; + const x1 = vanish.x + Math.cos(angle) * outer; + const y1 = horizon - 40 + Math.sin(angle) * outer * 0.7; + if (inside(x0, y0) || inside(x1, y1)) continue; + light + .moveTo(x0, y0) + .lineTo(x1, y1) + .stroke({ color: index % 5 === 0 ? SYN_CYAN : SYN_AMBER, width: 1, alpha: Math.sin(progress * Math.PI) * 0.5 }); + } + // The figure: drifting, slowly turning, breathing on the beat, a hot core at the chest. + const bob = Math.sin(seconds * 0.9) * 6; + const figureScale = 104 * (1 + 0.03 * pulse); + drawPointCloud( + light, + FIGURE, + { scale: figureScale, x: 0, y: -72 + bob, z: 60, yaw: Math.sin(seconds * 0.35) * 0.9, pitch: 0.12 }, + view, + { + colorOf: (point, index) => (index % 17 === 0 ? SYN_CYAN : emberColor(0.45 + 0.55 * point.weight)), + alpha: 0.8, + size: 1, + seconds, + shimmer: 0.35, + referenceScale: 220 / 280, + bokeh: 3.5, + skip: (px, py) => inside(px, py), + }, + ); + const core = projectPoint( + viewPoint({ x: 0, y: -72 + bob - 0.5 * figureScale, z: 60 }, orbitCamera, orbit), + cx, + baseHorizon, + 220, + ); + for (let ring = 4; ring >= 1; ring -= 1) { + light.circle(core.x, core.y, ring * 5 * (1 + 0.3 * pulse)).fill({ color: SYN_AMBER, alpha: 0.06 + 0.02 * pulse }); + } + // Lock-on reticle on the figure. + const lock = 34 + 6 * pulse; + drawReticle(light, core.x - lock, core.y - lock, lock * 2, lock * 2, SYN_FLARE, 0.55, { arm: 8, cross: true }); addHudText( layer, 'STANDBY', cx, - BGA.y + BGA.h - 26, + BGA.y + BGA.h - 22, { ...displayStyle(9, SYN_MIST), letterSpacing: 6, anchorX: 0.5 }, pool, ); @@ -439,7 +569,7 @@ function drawGauge(graphics: Graphics, layer: Container, runtime: Runtime, accen 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); + drawFrame(graphics, GROOVE.x - 14, GROOVE.y - 26, GROOVE.w + 28, 48, accent); addHudText( layer, `${(runtime.gaugeLabel ?? 'GROOVE').toUpperCase()} GAUGE`, @@ -453,7 +583,7 @@ function drawGauge(graphics: Graphics, layer: Container, runtime: Runtime, accen `${Math.round(gauge)}%`, GROOVE.x + GROOVE.w + 4, GROOVE.y - 22, - { ...displayStyle(12, cleared ? SYN_MAGENTA : SYN_WHITE), anchorX: 1 }, + { ...displayStyle(12, cleared ? SYN_AMBER : SYN_WHITE), anchorX: 1 }, pool, ); const cells = 50; @@ -466,7 +596,7 @@ function drawGauge(graphics: Graphics, layer: Container, runtime: Runtime, accen if (lit > 0) { graphics .rect(GROOVE.x, barY - 3, lit * stride, barH + 6) - .fill({ color: cleared ? SYN_MAGENTA : SYN_CYAN, alpha: 0.12 }); + .fill({ color: cleared ? SYN_EMBER : SYN_CYAN, alpha: 0.14 }); } for (let cell = 0; cell < cells; cell += 1) { const x = GROOVE.x + cell * stride; @@ -475,20 +605,23 @@ function drawGauge(graphics: Graphics, layer: Container, runtime: Runtime, accen 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); + // 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_AMBER, alpha: 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; - glassPanel(graphics, x, y, w, h, accent); + drawFrame(graphics, x, y, w, h, accent); addHudText( layer, runtime.songTitle?.trim() || 'Untitled chart', @@ -519,7 +652,7 @@ function drawSongPlate(graphics: Graphics, layer: Container, runtime: Runtime, a 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); + drawFrame(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( @@ -616,7 +749,7 @@ const TALLY: ReadonlyArray = { - PERFECT: 0xc8fbff, + PERFECT: SYN_FLARE, GREAT: SYN_AMBER, - GOOD: SYN_GREEN, - BAD: SYN_VIOLET, + GOOD: SYN_CYAN, + BAD: SYN_MAGENTA, POOR: SYN_RED, }; @@ -679,7 +812,7 @@ function drawJudgements( const judgeScale = punchScale(runtime.judgeAtMs, nowMs, 120, 0.22 * amount); const color = display.judge === 'PERFECT' - ? hsvToHex(seconds * 1.4 + display.x / 400, 0.35, 1) + ? emberColor(0.82 + 0.18 * Math.sin(seconds * 9 + display.x / 60)) : (JUDGE_COLORS[display.judge] ?? SYN_WHITE); addHudText( layer, @@ -710,7 +843,7 @@ function drawJudgements( 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 }, + dropShadow: { color: combo >= 200 ? SYN_AMBER : SYN_EMBER, alpha: 0.85, blur: 10, distance: 0 }, }, pool, ); diff --git a/packages/player-web/src/scene/default/synesthesia/moments.ts b/packages/player-web/src/scene/default/synesthesia/moments.ts index 417b6605..ae19c91b 100644 --- a/packages/player-web/src/scene/default/synesthesia/moments.ts +++ b/packages/player-web/src/scene/default/synesthesia/moments.ts @@ -5,24 +5,36 @@ import type { ChildPool } from '../../pixi-utils.ts'; import { addHudText } from '../hud-text.ts'; import { effectProfile, impulse, momentProgress, trackMoments } from '../moments.ts'; import { easeOutCubic, hash01, stageProgress } from '../phantom-style.ts'; -import { burstParticlePosition, burstParticles, hsvToHex, projectPoint, rotateX, rotateY } from './space.ts'; -import { SYN_DISPLAY_FONT, SYN_WHITE } from './style.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_EMBER, SYN_FLARE, SYN_MAGENTA, SYN_WHITE } from './style.ts'; const MILESTONE_MS = 1300; const CLEAR_MS = 1100; const FULL_COMBO_MS = 3400; -const FULL_COMBO_BURST = burstParticles(77, 180); +const FULL_COMBO_BURST = burstParticles(77, 640); const JUDGE_Y = PLAYFIELD.judgementY; +const MILESTONE_COLORS = [SYN_AMBER, SYN_CYAN, SYN_MAGENTA] as const; +const RING_COLORS = [SYN_AMBER, SYN_EMBER, SYN_CYAN, SYN_FLARE, 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 the first beat detonates a * ring of light from the judgement line; - * - every 100 combo: a shockwave of light rolls out from the playfield and the count glows over the BGA monitor; + * - every 100 combo: a shockwave of light rolls out from the playfield and 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 bloom, a 3D sphere of sparks bursting from the centre of the screen, prism rings, and FULL COMBO - * cycling through the spectrum. + * - a full combo: a warm bloom, a 3D sphere of voxel sparks bursting from the centre of the screen, rings of ember / + * blue / magenta light, and FULL COMBO glowing gold. */ export function drawSynesthesiaMoments( chromeLayer: Container, @@ -84,7 +96,7 @@ export function drawSynesthesiaMoments( } const clear = momentProgress(moments.clearAtMs, nowMs, CLEAR_MS); if (clear !== undefined) { - drawClear(graphics, layer, clear, hue, pool); + drawClear(graphics, layer, clear, pool); } const fullCombo = momentProgress(moments.fullComboAtMs, nowMs, FULL_COMBO_MS); if (fullCombo !== undefined) { @@ -171,7 +183,8 @@ function drawMilestone( hasBga: boolean, pool: ChildPool, ): void { - const color = hsvToHex(hue + value / 1000, 0.6, 1); + // Each hundred takes the next Rez light in turn: gold, electric blue, magenta. + const color = MILESTONE_COLORS[Math.max(0, Math.floor(value / 100) - 1) % MILESTONE_COLORS.length]!; const wave = easeOutCubic(Math.min(1, t / 0.7)); const radius = 30 + 700 * wave; graphics @@ -202,6 +215,13 @@ function drawMilestone( ); 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 = (hasBga ? 120 : 190) * (1.6 - 0.6 * snap); + const lockH = (hasBga ? 74 : 110) * (1.6 - 0.6 * snap); + drawReticle(graphics, cx - lockW / 2, cy + (hasBga ? 14 : 22) - lockH / 2, lockW, lockH, color, 0.9 * alpha, { + arm: 14, + width: 2, + }); const label = addHudText( layer, 'COMBO', @@ -221,8 +241,8 @@ function drawMilestone( label.alpha = alpha; } -function drawClear(graphics: Graphics, layer: Container, t: number, hue: number, pool: ChildPool): void { - const color = hsvToHex(hue + 0.35, 0.6, 1); +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 }); @@ -262,7 +282,7 @@ function drawFullCombo( 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_WHITE, alpha: 0.55 * bloom }); + 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); @@ -274,23 +294,27 @@ function drawFullCombo( const position = burstParticlePosition(particle, life, 360, 60); const point = projectPoint(rotateX(rotateY(position, spin), 0.3), cx, cy, 380); if (!point.visible) continue; - const color = hsvToHex(hue + particle.hueShift * 3 + life * 0.5, 0.35 + 0.5 * life, 1); - const size = (1.5 + 3.5 * particle.size) * point.scale * (1 - life * 0.6); - graphics.circle(point.x, point.y, size * 3).fill({ color, alpha: 0.12 * (1 - life) * fadeOut }); - graphics.circle(point.x, point.y, size).fill({ color: SYN_WHITE, alpha: (1 - life) * fadeOut }); + // 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 }); } // Prism rings expanding from the centre. for (let ring = 0; ring < 5; ring += 1) { const ringT = (t * 2.2 + ring * 0.18) % 1; const radius = 40 + ringT * 420; graphics.ellipse(cx, cy, radius, radius * 0.55).stroke({ - color: hsvToHex(hue + ring * 0.12 + t, 0.7, 1), + color: RING_COLORS[ring % RING_COLORS.length]!, width: 2 + 4 * (1 - ringT), alpha: 0.5 * (1 - ringT) * fadeOut, }); } const form = easeOutCubic(Math.min(1, t / 0.3)); - const color = hsvToHex(hue + nowMs / 1800, 0.55, 1); + const color = emberColor(0.6 + 0.3 * Math.sin(nowMs / 260)); const text = addHudText( layer, 'FULL COMBO', diff --git a/packages/player-web/src/scene/default/synesthesia/playfield.ts b/packages/player-web/src/scene/default/synesthesia/playfield.ts index 06407c3f..da698540 100644 --- a/packages/player-web/src/scene/default/synesthesia/playfield.ts +++ b/packages/player-web/src/scene/default/synesthesia/playfield.ts @@ -11,12 +11,14 @@ import type { ChildPool } from '../../pixi-utils.ts'; import { burstParticlePosition, burstParticles, + emberColor, hsvToHex, projectPoint, rotateY, type BurstParticle, type Vec3, } from './space.ts'; +import { drawReticle } from './draw.ts'; import { SYN_GLOW_SIZE, synGlowTexture } from './style.ts'; import { comboTier, effectProfile } from '../moments.ts'; @@ -27,11 +29,12 @@ interface LaneLight { hue: number; } -// Readable first: white keys are icy white, black keys blue-violet, scratch magenta — each with a soft same-hue halo. +// Readable first, in Rez Infinite's palette: white keys burn warm white-gold with an ember halo, black keys run +// electric blue, scratch hot magenta. 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 }, + white: { body: 0xfff1dc, glow: 0xff7a1e, hue: 0.07 }, + black: { body: 0xa6dcff, glow: 0x3fb4ff, hue: 0.57 }, + scratch: { body: 0xff9fd0, glow: 0xff3d9e, hue: 0.92 }, }; const NOTE_HEIGHT = 8; @@ -49,7 +52,7 @@ export function renderSynesthesiaLanes({ // 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; const heat = 1 + 0.3 * tier; - const lineColor = tier >= 3 ? hsvToHex(0.52 + Math.sin(nowMs / 900) * 0.2, 0.45, 1) : 0x5ff4ff; + 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; @@ -63,8 +66,8 @@ export function renderSynesthesiaLanes({ 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 }); + // Near-black bed, so the particle world behind the playfield reads only as a faint depth cue. + graphics.rect(x, top, w, laneHeight).fill({ color: 0x050201, alpha: 0.8 }); graphics.rect(x, top, 1, laneHeight).fill({ fill: resolveColumnGradient(light.glow), alpha: 0.5 }); if (lane.beam > 0) { @@ -82,20 +85,20 @@ export function renderSynesthesiaLanes({ 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 }); + .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; - graphics.rect(gridRight - 1, gridTop, 1, Math.max(1, gridBottom - gridTop)).fill({ color: 0x5ff4ff, alpha: 0.25 }); + graphics.rect(gridRight - 1, gridTop, 1, Math.max(1, gridBottom - gridTop)).fill({ color: 0xff7a1e, alpha: 0.25 }); - // Judgement line: a white filament wrapped in stacked cyan bloom that swells on each beat. + // Judgement line: a white filament wrapped in stacked ember bloom that swells on each beat. const lineW = gridRight - gridLeft; const y = gridBottom; graphics.rect(gridLeft, y - 14, lineW, 22).fill({ color: lineColor, alpha: (0.05 + 0.08 * pulse) * heat }); graphics .rect(gridLeft, y - 6, lineW, 8) .fill({ color: lineColor, alpha: Math.min(0.8, (0.16 + 0.14 * pulse) * heat) }); - graphics.rect(gridLeft, y - 3, lineW, 3).fill({ color: 0x9ff8ff, alpha: 0.85 }); + graphics.rect(gridLeft, y - 3, lineW, 3).fill({ color: 0xffd08a, alpha: 0.85 }); graphics.rect(gridLeft, y - 2, lineW, 1).fill(0xffffff); } @@ -125,8 +128,8 @@ function drawNote(graphics: Graphics, x: number, y: number, w: number, light: La } /** Sparks per hit at combo tier 0; each tier adds {@link BURST_PARTICLES_PER_TIER}. */ -const BURST_PARTICLES = 44; -const BURST_PARTICLES_PER_TIER = 6; +const BURST_PARTICLES = 110; +const BURST_PARTICLES_PER_TIER = 16; const BURST_PARTICLES_MAX = BURST_PARTICLES + BURST_PARTICLES_PER_TIER * 4; const BURST_FOCAL = 260; const PARTICLE_CACHE = new Map(); @@ -152,8 +155,10 @@ function cachedBurst(seed: number): BurstParticle[] { * 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. + * 5. a core flash and an anamorphic streak across the line, + * 6. a Rez lock-on reticle that snaps shut on the hit and fades. * + * The smaller sparks fly as square voxels rather than round glows, like Rez's 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 }: BeMusicBombsContext): void { @@ -278,17 +283,24 @@ function renderBomb(pool: ChildPool, bomb: BeMusicBomb, tier: number, crowd: num sparks.sort((left, right) => right.z - left.z); const glow = synGlowTexture(); for (const spark of sparks) { + const tint = hsvToHex(light.hue + spark.particle.hueShift, Math.min(0.9, 0.45 + spark.life * 1.8), 1); + const sparkAlpha = (1 - spark.life) ** 0.8 * (0.65 + 0.35 * crowd); + if (spark.particle.size < 0.55) { + const side = Math.max(1, (1 + 2.2 * spark.particle.size) * spark.scale * (1 - spark.life * 0.6)); + effects.rect(spark.x - side / 2, spark.y - side / 2, side, side).fill({ color: tint, alpha: sparkAlpha }); + continue; + } 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); + const size = (3 + 6 * 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 * (0.65 + 0.35 * crowd); + sprite.tint = tint; + sprite.alpha = sparkAlpha; } // 5. Core flash + anamorphic streak. @@ -305,6 +317,17 @@ function renderBomb(pool: ChildPool, bomb: BeMusicBomb, tier: number, crowd: num core.tint = hsvToHex(light.hue, t * 2, 1); core.alpha = flash * 0.7 * crowd; } + // 6. 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(); diff --git a/packages/player-web/src/scene/default/synesthesia/result.ts b/packages/player-web/src/scene/default/synesthesia/result.ts index ef1e6656..3c055903 100644 --- a/packages/player-web/src/scene/default/synesthesia/result.ts +++ b/packages/player-web/src/scene/default/synesthesia/result.ts @@ -2,9 +2,12 @@ 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 { hash01 } from '../phantom-style.ts'; +import { drawFrame, drawReticle } from './draw.ts'; import { burstParticlePosition, burstParticles, + emberColor, hsvToHex, projectPoint, rotateX, @@ -17,13 +20,12 @@ import { SYN_DEEP, SYN_DIM, SYN_DISPLAY_FONT, - SYN_GLASS, - SYN_GREEN, + SYN_EMBER, + SYN_FLARE, SYN_MAGENTA, SYN_MIST, SYN_RED, SYN_TEXT_FONT, - SYN_VIOLET, SYN_VOID, SYN_WHITE, sceneHue, @@ -32,14 +34,15 @@ import { const ROLL_DELAY_MS = 700; const ROLL_MS = 1100; const RANK_DELAY_MS = 1600; -const RANK_BURST = burstParticles(29, 90); +const RANK_BURST = burstParticles(29, 260); 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. + * Synesthesia result, in Rez Infinite's Area X light: a black void with ember dust, speed streaks 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 inside a spinning 3D ring of particles that bursts + * outward the moment it lands — a lock-on reticle snapping shut on it. */ export function renderSynesthesiaResult(frame: BeMusicResultFrame): void { const { result, designWidth, designHeight, nowMs, rankLabel, layer } = frame; @@ -49,7 +52,7 @@ export function renderSynesthesiaResult(frame: BeMusicResultFrame): void { const hue = sceneHue(seconds); const accent = hsvToHex(hue, 0.6, 1); const cleared = result.cleared; - const verdictColor = cleared ? SYN_CYAN : SYN_RED; + 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); @@ -78,13 +81,12 @@ export function renderSynesthesiaResult(frame: BeMusicResultFrame): void { 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. + // Space: warm black, an ember horizon, dust and streaks pouring out of the vanishing point, and a floor of points. const space = group('space'); const bands: ReadonlyArray = [ - [SYN_DEEP, 0.25], - [0x080620, 0.45], - [0x05051a, 0.65], - [0x030410, 0.82], + [SYN_DEEP, 0.3], + [0x070201, 0.55], + [0x040100, 0.8], [SYN_VOID, 1], ]; let top = 0; @@ -93,33 +95,58 @@ export function renderSynesthesiaResult(frame: BeMusicResultFrame): void { space.g.rect(0, top, designWidth, bottom - top).fill(color); top = bottom; } - for (let index = 0; index < 110; index += 1) { + 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 band = 0; band < 14; band += 1) { + const falloff = (1 - band / 14) ** 2; + light.rect(0, horizon - (band + 1) * 7, designWidth, 7).fill({ color: SYN_EMBER, alpha: 0.06 * falloff }); + light.rect(0, horizon + band * 4, designWidth, 4).fill({ color: SYN_EMBER, alpha: 0.05 * falloff }); + } + 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, designWidth / 2, designHeight * 0.45, 180); + const projected = projectPoint(point, vanishX, vanishY, 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 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 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 }); + for (let index = 0; index < 18; index += 1) { + const angle = hash01(index * 5 + 1) * Math.PI * 2; + const progress = (hash01(index * 5 + 2) + seconds * 0.4 * (0.7 + 0.6 * hash01(index * 5 + 3))) % 1; + const inner = 40 + progress * progress * 520; + const outer = inner + 12 + 80 * progress; + const cos = Math.cos(angle); + const sin = Math.sin(angle) * 0.72; + light + .moveTo(vanishX + cos * inner, vanishY + sin * inner) + .lineTo(vanishX + cos * outer, vanishY + sin * outer) + .stroke({ + color: index % 6 === 0 ? SYN_CYAN : emberColor(0.55 + 0.4 * hash01(index * 5 + 4)), + width: 0.8 + progress * 1.4, + alpha: Math.sin(progress * Math.PI) * 0.35, + }); } + 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 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) }); + 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).fill({ color, alpha }); + } } + light.rect(0, horizon - 1, designWidth, 2).fill({ color: SYN_AMBER, alpha: 0.4 }); // Verdict: tracking condenses from wide to settled while it fades in. const verdict = group('verdict'); @@ -168,7 +195,7 @@ export function renderSynesthesiaResult(frame: BeMusicResultFrame): void { rankCy, 220, ); - const color = hsvToHex(hue + index / 60 / 2, 0.55, 1); + const color = index % 10 === 0 ? SYN_CYAN : emberColor(0.5 + 0.45 * Math.sin(index * 0.7 + seconds * 2) ** 2); 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 }); } @@ -180,14 +207,26 @@ export function renderSynesthesiaResult(frame: BeMusicResultFrame): void { 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) }); + const color = particle.hueShift > 0.06 ? SYN_CYAN : emberColor(1 - life * 0.7); rank.g - .circle(point.x, point.y, (0.8 + 1.4 * particle.size) * point.scale) + .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; @@ -216,7 +255,7 @@ export function renderSynesthesiaResult(frame: BeMusicResultFrame): void { [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, 66, 'GAUGE', `${rollUpValue(Math.round(result.gauge), roll)}%`, cleared ? SYN_AMBER : 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], ]; @@ -236,13 +275,13 @@ export function renderSynesthesiaResult(frame: BeMusicResultFrame): void { // Judgement rows. const judges = group('judgement'); - glass(judges.g, 20, 244, 290, 176, SYN_VIOLET); - label('JUDGEMENT', 34, 256, judges.root, SYN_VIOLET); + glass(judges.g, 20, 244, 290, 176, SYN_EMBER); + label('JUDGEMENT', 34, 256, judges.root, SYN_EMBER); const rows: ReadonlyArray = [ - ['PGREAT', result.score.perfect, 0xc8fbff], + ['PGREAT', result.score.perfect, SYN_FLARE], ['GREAT', result.score.great, SYN_AMBER], - ['GOOD', result.score.good, SYN_GREEN], - ['BAD', result.score.bad, SYN_VIOLET], + ['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); @@ -279,7 +318,7 @@ export function renderSynesthesiaResult(frame: BeMusicResultFrame): void { 262, 56, result.gaugeHistory.map((s) => ({ x: s.progress, y: s.value / 100 })), - cleared ? SYN_CYAN : SYN_RED, + cleared ? SYN_AMBER : SYN_RED, draw, ); filament( @@ -289,7 +328,7 @@ export function renderSynesthesiaResult(frame: BeMusicResultFrame): void { 262, 56, result.scoreHistory.map((s) => ({ x: s.progress, y: exMax > 0 ? s.exScore / exMax : 0 })), - SYN_MAGENTA, + SYN_CYAN, draw, ); rise(graphs.root, 600); @@ -298,7 +337,7 @@ export function renderSynesthesiaResult(frame: BeMusicResultFrame): void { 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, { + text(total.root, String(rollUpValue(result.score.score, roll)), 150, designHeight - 22, { ...display(16, SYN_WHITE), anchorY: 0.5, letterSpacing: 2, @@ -308,10 +347,7 @@ export function renderSynesthesiaResult(frame: BeMusicResultFrame): void { } 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 }); + 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. */ diff --git a/packages/player-web/src/scene/default/synesthesia/select.ts b/packages/player-web/src/scene/default/synesthesia/select.ts index c0504213..0e77476b 100644 --- a/packages/player-web/src/scene/default/synesthesia/select.ts +++ b/packages/player-web/src/scene/default/synesthesia/select.ts @@ -8,13 +8,31 @@ import type { } 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 { hash01 } from '../phantom-style.ts'; +import { drawPointCloud, drawReticle } from './draw.ts'; +import { + cameraShot, + emberColor, + hsvToHex, + mixCamera, + particleRiverPoint, + pointCloudHumanoid, + pointCloudPyramid, + projectPoint, + REST_CAMERA, + starfieldPoint, + vanishingPoint, + viewPoint, + type CameraPose, +} 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, @@ -29,9 +47,25 @@ const LAYOUT: BeMusicSelectLayout = { listX: 322, listTop: 56, listBottomInset: const SLIDE_MS = 320; const OUTRO_MS = 700; const INTRO_STAGGER_MS = 45; -const STAR_COUNT = 150; -const ORBIT_COUNT = 14; -const ICOSAHEDRON = icosahedron(); +const STAR_COUNT = 320; +const STREAK_COUNT = 26; +const RIVER_PARTICLES = 340; +const FIGURE = pointCloudHumanoid(23, 1600); +/** + * Camera shots the select backdrop flies between, circling the pyramids: rest, a low pass banking right, a high + * sweep from the right looking down, and a skim just over the floor. + */ +const SELECT_SHOTS: readonly CameraPose[] = [ + REST_CAMERA, + { x: -190, y: 40, z: 0, yaw: 0.34, pitch: -0.04 }, + { x: 230, y: -120, z: 0, yaw: -0.3, pitch: 0.15 }, + { x: 0, y: 70, z: 0, yaw: -0.12, pitch: -0.08 }, +]; +const SHOT_HOLD_S = 7; +const SHOT_MOVE_S = 3; +/** 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: BeMusicSelectSkin = { layout: LAYOUT, @@ -42,28 +76,28 @@ function labelStyle(fill: number = SYN_DIM): SkinTextOptions { return { size: 9, fill, fontFamily: SYN_DISPLAY_FONT, letterSpacing: 2 }; } -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 }); +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. 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. + * Synesthesia song select, after Rez Infinite's Area X. The persistent back layer is a particle world — data dust and + * speed streaks pouring out of the vanishing point at the focused chart's tempo, point-cloud pyramids on an ember + * horizon, a floor of light points, a golden river of particles, and a floating point-cloud figure — redrawn cheaply + * in `tick`. The front layer snaps a lock-on reticle onto 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(); + /** Additive particle world: floor, pyramids, river, streaks, and the figure. */ + private readonly world = new Graphics(); private readonly stars: Sprite[] = []; - private readonly orbit: Sprite[] = []; + private readonly lock = new Graphics(); private readonly cursorGlow = new Sprite(); + private cursorChangedAt = Number.NEGATIVE_INFINITY; private built = false; private designWidth = 640; private designHeight = 480; @@ -97,7 +131,8 @@ class SynesthesiaSelectRenderer implements BeMusicSelectRenderer { } const visible = this.activeCard !== undefined; this.cursorGlow.visible = visible; - for (const sprite of this.orbit) sprite.visible = visible; + this.lock.visible = visible; + this.cursorChangedAt = frame.cursorChangedAt; if (frame.launchAt !== undefined) { this.renderOutro(frame); return true; @@ -118,7 +153,7 @@ class SynesthesiaSelectRenderer implements BeMusicSelectRenderer { const cy = designHeight * 0.44; const bloom = easeOutCubic(Math.min(1, t / 0.7)); for (let ring = 5; ring >= 1; ring -= 1) { - g.circle(cx, cy, (40 + 520 * bloom) * (ring / 5)).fill({ color: SYN_WHITE, alpha: 0.1 * bloom }); + g.circle(cx, cy, (40 + 520 * bloom) * (ring / 5)).fill({ color: SYN_FLARE, alpha: 0.1 * bloom }); } const dark = Math.max(0, (t - 0.72) / 0.28); if (dark > 0) g.rect(0, 0, designWidth, designHeight).fill({ color: SYN_VOID, alpha: dark }); @@ -133,7 +168,7 @@ class SynesthesiaSelectRenderer implements BeMusicSelectRenderer { anchorY: 0.5, maxWidth: designWidth - 80, alpha: Math.min(1, t * 3) * (1 - dark), - dropShadow: { color: SYN_CYAN, distance: 0, blur: 16, alpha: 1 }, + dropShadow: { color: SYN_EMBER, distance: 0, blur: 16, alpha: 1 }, }); } @@ -151,100 +186,169 @@ class SynesthesiaSelectRenderer implements BeMusicSelectRenderer { const hue = sceneHue(seconds, beatPhase); const accent = hsvToHex(hue, 0.6, 1); - // Star tunnel — speed follows the focused chart's tempo. + // Camera: the backdrop periodically flies to a new shot. Reduced effects halve the moves; off keeps it at rest. + const camera = mixCamera( + REST_CAMERA, + cameraShot(seconds, SELECT_SHOTS, SHOT_HOLD_S, SHOT_MOVE_S), + this.effects === 'off' ? 0 : this.effects === 'reduced' ? 0.5 : 1, + ); + // Data dust pouring out of the vanishing point — speed follows the focused chart's tempo. const cx = this.designWidth * 0.58; const cy = this.designHeight * 0.44; + const dustVanish = vanishingPoint(camera, cx, cy, 200); const speed = (80 + beatsPerSecond * 55) * (1 + 16 * warp * warp); this.travel += dt * speed * (warp > 0 ? 1 : rate); for (let index = 0; index < this.stars.length; index += 1) { const star = this.stars[index]!; const point = starfieldPoint(index, this.travel, { spread: 560, near: 10, far: 1000, speed: 1 }); - const projected = projectPoint(point, cx, cy, 200); + const projected = projectPoint(viewPoint(point, camera), cx, cy, 200); star.visible = projected.visible; if (!projected.visible) continue; const nearness = Math.min(1, projected.scale); - const size = 2 + 16 * nearness * nearness; + const size = 1 + 3 * nearness * nearness; star.position.set(projected.x, projected.y); - // During the warp stars stretch into streaks pointing out of the vanishing point. - star.rotation = Math.atan2(projected.y - cy, projected.x - cx); - star.width = size * (1 + 9 * warp); + // During the warp motes stretch into streaks pointing out of the vanishing point. + star.rotation = Math.atan2(projected.y - dustVanish.y, projected.x - dustVanish.x); + star.width = size * (1 + 14 * warp); 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); + star.alpha = 0.2 + 0.8 * nearness; + star.tint = index % 9 === 0 ? SYN_CYAN : index % 13 === 0 ? SYN_MAGENTA : emberColor(0.4 + 0.6 * nearness); } - // 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 }); + const world = this.world; + world.clear(); + const floorY = this.designHeight * 0.7; + const horizon = vanishingPoint(camera, cx, floorY, 200).y; + // Point-cloud pyramids on the horizon, 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 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 }); + // Ember haze along the horizon. + for (let band = 0; band < 14; band += 1) { + const falloff = (1 - band / 14) ** 2; + const alpha = (0.05 + 0.04 * pulse) * falloff; + world.rect(0, horizon - (band + 1) * 7, this.designWidth, 7).fill({ color: SYN_EMBER, alpha }); + world.rect(0, horizon + band * 4, this.designWidth, 4).fill({ color: SYN_EMBER, alpha: alpha * 0.8 }); } + world.rect(0, horizon - 1, this.designWidth, 2).fill({ color: SYN_AMBER, alpha: 0.35 + 0.25 * pulse }); + // Floor of light points scrolling toward the viewer. + const project = (x: number, z: number) => + projectPoint(viewPoint({ x, y: 150, z }, camera, WORLD_ORBIT), cx, floorY, 200); const spacing = 100; const offset = this.travel % 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) }); + const nearness = 1 - z / 2600; + const size = 0.6 + 1.5 * nearness * nearness; + const color = emberColor(0.3 + 0.65 * nearness); + const alpha = (0.12 + 0.7 * nearness * nearness) * (0.7 + 0.3 * pulse); + for (let x = -1800; x <= 1800; x += 50) { + const point = project(x, z); + if (!point.visible || point.x < -4 || point.x > this.designWidth + 4 || point.y > this.designHeight + 4) { + continue; + } + world.rect(point.x - size / 2, point.y - size / 2, size, size).fill({ color, alpha }); + } + } + // A golden river of particles sweeping across the floor. + const riverOptions = { length: 1800, speed: 120 + beatsPerSecond * 60, amplitude: 36, 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); + world + .moveTo(tail.x, tail.y) + .lineTo(head.x, head.y) + .stroke({ + color: heat > 0.85 ? SYN_WHITE : emberColor(0.5 + 0.5 * heat), + width: 0.4 + 1 * Math.min(1, head.scale) * (0.5 + heat), + alpha: (0.25 + 0.55 * heat) * (1 - warp), + }); + } + // Speed streaks — Rez's warp lines — thicken into a tunnel on launch. + const streakSpeed = (0.35 + beatsPerSecond * 0.12) * (1 + 6 * warp); + for (let index = 0; index < STREAK_COUNT; index += 1) { + const angle = hash01(index * 5 + 1) * Math.PI * 2; + const progress = (hash01(index * 5 + 2) + seconds * streakSpeed * (0.7 + 0.6 * hash01(index * 5 + 3))) % 1; + const inner = 30 + progress * progress * 560; + const outer = inner + (14 + 90 * progress) * (1 + 4 * warp); + const cos = Math.cos(angle); + const sin = Math.sin(angle) * 0.72; + world + .moveTo(dustVanish.x + cos * inner, dustVanish.y + sin * inner) + .lineTo(dustVanish.x + cos * outer, dustVanish.y + sin * outer) + .stroke({ + color: index % 6 === 0 ? SYN_CYAN : emberColor(0.55 + 0.4 * hash01(index * 5 + 4)), + width: 0.8 + progress * 1.6, + alpha: Math.sin(progress * Math.PI) * (0.35 + 0.4 * warp), + }); } - this.floor.rect(0, horizon - 1, this.designWidth, 2).fill({ color: accent, alpha: 0.2 + 0.2 * pulse }); + // The figure, drifting behind the list — it turns to face you and breathes on the beat. + const bob = Math.sin(seconds * 0.8) * 8; + drawPointCloud( + world, + FIGURE, + { + scale: 120 * (1 + 0.03 * pulse), + x: 90, + y: -40 + bob, + z: 260, + yaw: Math.sin(seconds * 0.3) * 0.8, + pitch: 0.1, + }, + { cx, cy: floorY - 120, focal: 300, camera: { ...camera, x: camera.x * 0.3, y: camera.y * 0.3 }, orbit: 260 }, + { + colorOf: (point, index) => (index % 17 === 0 ? SYN_CYAN : emberColor(0.45 + 0.55 * point.weight)), + alpha: 0.55 * (1 - warp), + size: 0.9, + seconds, + shimmer: 0.35, + referenceScale: 300 / 560, + bokeh: 2.5, + }, + ); - // Ring of light orbiting the focused card, plus a breathing glow under it. + // Lock-on reticle snapping onto the focused card, plus a breathing glow under it. const card = this.activeCard; + this.lock.clear(); 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; - } + 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 }, + ); + // Target marker leading the card. + const markerX = card.x - 16; + const markerY = card.y + card.h / 2; + this.lock.circle(markerX, markerY, 3 + 2 * pulse).stroke({ color: accent, width: 1.25, alpha: 0.9 }); + this.lock.circle(markerX, markerY, 1.2).fill({ color: SYN_WHITE, alpha: 1 }); 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; + this.cursorGlow.alpha = 0.16 + 0.18 * pulse; } } @@ -259,10 +363,9 @@ class SynesthesiaSelectRenderer implements BeMusicSelectRenderer { this.designWidth = designWidth; this.designHeight = designHeight; const bands: ReadonlyArray = [ - [SYN_DEEP, 0.25], - [0x080620, 0.45], - [0x05051a, 0.65], - [0x030410, 0.8], + [SYN_DEEP, 0.3], + [0x070201, 0.55], + [0x040100, 0.8], [SYN_VOID, 1], ]; let top = 0; @@ -271,14 +374,6 @@ class SynesthesiaSelectRenderer implements BeMusicSelectRenderer { 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'; @@ -289,19 +384,13 @@ class SynesthesiaSelectRenderer implements BeMusicSelectRenderer { starLayer.addChild(star); this.stars.push(star); } - this.backLayer.addChild(this.ground, this.floor, starLayer, this.wireframe); + this.world.blendMode = 'add'; + this.backLayer.addChild(this.ground, this.world, starLayer); 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); - } + this.frontLayer.addChild(this.cursorGlow, this.lock); } private renderChrome(frame: BeMusicSelectFrame): boolean { @@ -346,7 +435,7 @@ class SynesthesiaSelectRenderer implements BeMusicSelectRenderer { }); // Info panel. - glassPanel(chrome, 14, 56, 290, 302, accent); + 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, { @@ -376,8 +465,9 @@ class SynesthesiaSelectRenderer implements BeMusicSelectRenderer { [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 }); + 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, @@ -388,13 +478,13 @@ class SynesthesiaSelectRenderer implements BeMusicSelectRenderer { }); } - // Level as a row of lights, cooling cyan → hot magenta. + // 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 = hsvToHex(0.52 + (dot / dots) * 0.38, 0.6, 1); + 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 }); @@ -414,9 +504,9 @@ class SynesthesiaSelectRenderer implements BeMusicSelectRenderer { 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 }); + 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, @@ -425,7 +515,8 @@ class SynesthesiaSelectRenderer implements BeMusicSelectRenderer { anchorX: 0.5, anchorY: 0.5, }); - chrome.roundRect(204, 303, 86, 26, 13).stroke({ color: SYN_MIST, width: 1, alpha: 0.6 }); + chrome.rect(204, 303, 86, 26).stroke({ color: SYN_MIST, width: 1, alpha: 0.45 }); + drawReticle(chrome, 204, 303, 86, 26, SYN_MIST, 0.8, { arm: 5, width: 1 }); addText('AUTO', 247, 316, { size: 9, fill: SYN_MIST, @@ -438,7 +529,7 @@ class SynesthesiaSelectRenderer implements BeMusicSelectRenderer { addHitArea(layer, 202, 300, 92, 32, 'pointer', frame.actions.autoPlay); // Search and library. - glassPanel(chrome, 14, 372, 290, 30, accent, 15); + framePanel(chrome, 14, 372, 290, 30, accent); addText('SEARCH', 30, 384, labelStyle(accent)); addText(frame.searchQuery || 'Title / artist / genre', 100, 380, { size: 10, @@ -490,16 +581,16 @@ class SynesthesiaSelectRenderer implements BeMusicSelectRenderer { 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 }); + 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.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 }); + 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 ? hsvToHex(0.52 + Math.min(1, Number.parseFloat(level) / 12 || 0) * 0.38, 0.6, 1) : SYN_MIST; + 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; @@ -542,10 +633,7 @@ class SynesthesiaSelectRenderer implements BeMusicSelectRenderer { } } -/** 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) }; +/** 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/scene/default/synesthesia/space.test.ts b/packages/player-web/src/scene/default/synesthesia/space.test.ts index 1c7c3aa7..be614297 100644 --- a/packages/player-web/src/scene/default/synesthesia/space.test.ts +++ b/packages/player-web/src/scene/default/synesthesia/space.test.ts @@ -2,12 +2,20 @@ import { describe, expect, it } from 'vite-plus/test'; import { burstParticlePosition, burstParticles, + cameraShot, + emberColor, hsvToHex, - icosahedron, + mixCamera, + particleRiverPoint, + pointCloudHumanoid, + pointCloudPyramid, projectPoint, + REST_CAMERA, rotateX, rotateY, starfieldPoint, + vanishingPoint, + viewPoint, } from './space.ts'; describe('projectPoint', () => { @@ -83,17 +91,6 @@ describe('starfieldPoint', () => { }); }); -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); @@ -103,3 +100,166 @@ describe('hsvToHex', () => { 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('pointCloudHumanoid', () => { + it('stands about two units tall, head up', () => { + const points = pointCloudHumanoid(1, 600); + expect(points).toHaveLength(600); + const ys = points.map((point) => point.y); + expect(Math.min(...ys)).toBeGreaterThan(-1.05); + expect(Math.min(...ys)).toBeLessThan(-0.9); + expect(Math.max(...ys)).toBeLessThan(1.05); + expect(Math.max(...ys)).toBeGreaterThan(0.85); + // Arms spread wider than the torso. + const xs = points.map((point) => Math.abs(point.x)); + expect(Math.max(...xs)).toBeGreaterThan(0.6); + }); + + it('is deterministic for a seed', () => { + expect(pointCloudHumanoid(4, 30)).toEqual(pointCloudHumanoid(4, 30)); + }); +}); + +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('cameraShot', () => { + const shots = [ + { x: 0, y: 0, z: 0, yaw: 0, pitch: 0 }, + { x: 100, y: -40, z: 0, yaw: 0.3, pitch: 0.1 }, + ]; + + it('holds each shot, then eases to the next and wraps around', () => { + expect(cameraShot(5, shots, 10, 2)).toEqual(shots[0]); + expect(cameraShot(11, shots, 10, 2).x).toBeCloseTo(50, 9); + expect(cameraShot(12, shots, 10, 2).x).toBeCloseTo(100, 9); + expect(cameraShot(20, shots, 10, 2)).toEqual(shots[1]); + // Shot 1 flies back to shot 0. + expect(cameraShot(24, shots, 10, 2).x).toBeCloseTo(0, 9); + }); + + it('eases in and out of each move', () => { + const early = cameraShot(10.2, shots, 10, 2).x; + const late = cameraShot(11.8, shots, 10, 2).x; + expect(early).toBeLessThan(10); + expect(late).toBeGreaterThan(90); + }); + + it('falls back to the rest pose for empty sequences and bad time', () => { + expect(cameraShot(3, [], 10, 2)).toEqual(REST_CAMERA); + expect(cameraShot(Number.NaN, shots, 10, 2)).toEqual(shots[0]); + expect(cameraShot(-1, shots, 10, 2).x).toBeCloseTo(cameraShot(23, shots, 10, 2).x, 9); + }); +}); + +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); + }); +}); diff --git a/packages/player-web/src/scene/default/synesthesia/space.ts b/packages/player-web/src/scene/default/synesthesia/space.ts index 94a021be..582bc13c 100644 --- a/packages/player-web/src/scene/default/synesthesia/space.ts +++ b/packages/player-web/src/scene/default/synesthesia/space.ts @@ -2,8 +2,8 @@ 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. + * point-cloud shapes (pyramids, a human figure), 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 { @@ -120,39 +120,6 @@ export function starfieldPoint( }; } -/** 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; @@ -173,3 +140,217 @@ export function hsvToHex(hue: number, saturation: number, value: number): number ][sector % 6]!; 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; +} + +/** + * Ember → gold → white-hot ramp for `t` in 0..1 (clamped) — the warm light that dominates Rez Infinite's particle + * worlds. Low `t` is a deep red-orange ember, high `t` a pale gold flare. + */ +export function emberColor(t: number): number { + const 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], + ]; + const clamped = Math.max(0, Math.min(1, Number.isFinite(t) ? t : 0)); + let index = 1; + while (index < stops.length - 1 && clamped > stops[index]![0]) index += 1; + const [t0, r0, g0, b0] = stops[index - 1]!; + const [t1, r1, g1, b1] = stops[index]!; + const f = (clamped - t0) / (t1 - t0); + const mix = (a: number, b: number) => Math.round(a + (b - a) * f); + return (mix(r0, r1) << 16) | (mix(g0, g1) << 8) | mix(b0, b1); +} + +/** + * 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; +} + +/** + * A floating human figure as a point cloud — Rez's particle avatar. Roughly 2 units tall (head top at `y ≈ -1`, + * feet at `y ≈ 1`), arms spread slightly down and out, legs trailing as if drifting. Points scatter inside capsules + * along each limb, weighted by limb volume. Deterministic for `seed`. + */ +export function pointCloudHumanoid(seed: number, count: number): CloudPoint[] { + const points: CloudPoint[] = []; + const totalWeight = HUMANOID_LIMBS.reduce((sum, limb) => sum + limb.volume, 0); + for (let index = 0; index < count; index += 1) { + const base = seed * 151 + index * 19; + let pick = hash01(base + 1) * totalWeight; + let limb = HUMANOID_LIMBS[HUMANOID_LIMBS.length - 1]!; + for (const candidate of HUMANOID_LIMBS) { + pick -= candidate.volume; + if (pick <= 0) { + limb = candidate; + break; + } + } + const centre = lerp3(limb.from, limb.to, hash01(base + 2)); + // Random direction inside the capsule; sqrt biases toward the surface so the body reads as a shell of light. + const cosTheta = 1 - 2 * hash01(base + 3); + const sinTheta = Math.sqrt(Math.max(0, 1 - cosTheta * cosTheta)); + const phi = Math.PI * 2 * hash01(base + 4); + const r = limb.radius * Math.sqrt(hash01(base + 5)); + points.push({ + x: centre.x + sinTheta * Math.cos(phi) * r, + y: centre.y + cosTheta * r, + z: centre.z + sinTheta * Math.sin(phi) * r, + weight: 0.3 + 0.7 * hash01(base + 6), + }); + } + return points; +} + +const HUMANOID_LIMBS: ReadonlyArray<{ from: Vec3; to: Vec3; radius: number; volume: number }> = ( + [ + // [from, to, radius] + [{ x: 0, y: -0.86, z: 0 }, { x: 0, y: -0.8, z: 0 }, 0.13], // head + [{ x: 0, y: -0.66, z: 0 }, { x: 0, y: -0.12, z: 0.02 }, 0.17], // torso + [{ x: -0.1, y: -0.12, z: 0 }, { x: 0.1, y: -0.12, z: 0 }, 0.13], // hips + [{ x: -0.2, y: -0.62, z: 0 }, { x: -0.46, y: -0.32, z: 0.05 }, 0.06], // left upper arm + [{ x: -0.46, y: -0.32, z: 0.05 }, { x: -0.66, y: -0.02, z: 0.12 }, 0.05], // left forearm + [{ x: 0.2, y: -0.62, z: 0 }, { x: 0.46, y: -0.34, z: -0.04 }, 0.06], // right upper arm + [{ x: 0.46, y: -0.34, z: -0.04 }, { x: 0.7, y: -0.1, z: -0.1 }, 0.05], // right forearm + [{ x: -0.1, y: -0.06, z: 0 }, { x: -0.18, y: 0.42, z: 0.1 }, 0.08], // left thigh + [{ x: -0.18, y: 0.42, z: 0.1 }, { x: -0.22, y: 0.94, z: 0.24 }, 0.06], // left shin + [{ x: 0.1, y: -0.06, z: 0 }, { x: 0.14, y: 0.4, z: -0.08 }, 0.08], // right thigh + [{ x: 0.14, y: 0.4, z: -0.08 }, { x: 0.2, y: 0.9, z: 0.06 }, 0.06], // right shin + ] as const +).map(([from, to, radius]) => { + const length = Math.hypot(to.x - from.x, to.y - from.y, to.z - from.z); + return { from, to, radius, volume: (length + radius) * radius * radius }; +}); + +/** + * 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 }; + +/** + * The camera at `seconds` for a looping sequence of `shots`: each shot holds for `holdSeconds`, then the camera flies + * to the next one over `moveSeconds` with a smoothstep ease, wrapping back to the first shot at the end. + */ +export function cameraShot( + seconds: number, + shots: readonly CameraPose[], + holdSeconds: number, + moveSeconds: number, +): CameraPose { + if (shots.length === 0) return REST_CAMERA; + const time = Number.isFinite(seconds) ? seconds : 0; + const cycle = holdSeconds + moveSeconds; + const step = Math.floor(time / cycle); + const local = time - step * cycle; + const index = ((step % shots.length) + shots.length) % shots.length; + const from = shots[index]!; + const to = shots[(index + 1) % shots.length]!; + const raw = local <= holdSeconds || moveSeconds <= 0 ? 0 : (local - holdSeconds) / moveSeconds; + return mixCamera(from, to, raw * raw * (3 - 2 * raw)); +} + +/** 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 }; +} diff --git a/packages/player-web/src/scene/default/synesthesia/style.ts b/packages/player-web/src/scene/default/synesthesia/style.ts index a6fbd3cf..d7ff4cb4 100644 --- a/packages/player-web/src/scene/default/synesthesia/style.ts +++ b/packages/player-web/src/scene/default/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 Rez Infinite's Area X: 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 (Area X'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; From 13137cca3ca02314b8ff84f9c5d9f067b592f601 Mon Sep 17 00:00:00 2001 From: Seiya KONNO Date: Tue, 29 Sep 2026 02:32:43 +0900 Subject: [PATCH 04/65] feat(player-web): add fish schools and a roaming camera to Synesthesia Three 3D boid schools swim through the backdrop, tightening on the beat and scattering on key presses, and the camera now roams between seeded random shots with occasional hard cuts and a handheld drift instead of a fixed shot loop. --- .../scene/default/synesthesia/boids.test.ts | 89 +++++++++ .../src/scene/default/synesthesia/boids.ts | 176 ++++++++++++++++++ .../src/scene/default/synesthesia/draw.ts | 74 +++++++- .../default/synesthesia/gameplay-chrome.ts | 77 ++++++-- .../src/scene/default/synesthesia/select.ts | 48 +++-- .../scene/default/synesthesia/space.test.ts | 80 +++++--- .../src/scene/default/synesthesia/space.ts | 71 ++++--- 7 files changed, 528 insertions(+), 87 deletions(-) create mode 100644 packages/player-web/src/scene/default/synesthesia/boids.test.ts create mode 100644 packages/player-web/src/scene/default/synesthesia/boids.ts diff --git a/packages/player-web/src/scene/default/synesthesia/boids.test.ts b/packages/player-web/src/scene/default/synesthesia/boids.test.ts new file mode 100644 index 00000000..0a6cf585 --- /dev/null +++ b/packages/player-web/src/scene/default/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/scene/default/synesthesia/boids.ts b/packages/player-web/src/scene/default/synesthesia/boids.ts new file mode 100644 index 00000000..c7e31723 --- /dev/null +++ b/packages/player-web/src/scene/default/synesthesia/boids.ts @@ -0,0 +1,176 @@ +import { hash01 } from '../phantom-style.ts'; + +/** + * 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/scene/default/synesthesia/draw.ts b/packages/player-web/src/scene/default/synesthesia/draw.ts index d13a5e7f..4abef4f4 100644 --- a/packages/player-web/src/scene/default/synesthesia/draw.ts +++ b/packages/player-web/src/scene/default/synesthesia/draw.ts @@ -1,6 +1,18 @@ import type { Graphics } from 'pixi.js'; -import { projectPoint, rotateX, rotateY, viewPoint, type CameraPose, type CloudPoint } from './space.ts'; -import { SYN_GLASS, SYN_WHITE } from './style.ts'; +import { hash01 } from '../phantom-style.ts'; +import type { Flock } from './boids.ts'; +import { + emberColor, + projectPoint, + rotateX, + rotateY, + viewPoint, + type CameraPose, + type CloudPoint, + type Projected, + type Vec3, +} from './space.ts'; +import { SYN_CYAN, SYN_GLASS, SYN_MAGENTA, SYN_WHITE } from './style.ts'; /** * Shared Synesthesia drawing: hairline frames with lock-on corners (Rez's targeting reticle) and projected point @@ -141,3 +153,61 @@ export function drawPointCloud( .fill({ color, alpha: Math.min(1, alpha) }); } } + +/** + * 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; + 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; + graphics + .moveTo(tail.x, tail.y) + .lineTo(mx, my) + .stroke({ color, width: 0.6 + 1 * nearness, alpha: alpha * 0.55 }) + .moveTo(mx, my) + .lineTo(head.x, head.y) + .stroke({ color, width: 1 + 2 * nearness, alpha }); + const size = 1.2 + 2.2 * nearness; + graphics + .rect(head.x - size / 2, head.y - size / 2, size, size) + .fill({ color: heat > 0.8 ? SYN_WHITE : color, alpha: Math.min(1, alpha * 1.3) }); + } +} diff --git a/packages/player-web/src/scene/default/synesthesia/gameplay-chrome.ts b/packages/player-web/src/scene/default/synesthesia/gameplay-chrome.ts index ed2fc16d..7feb77ee 100644 --- a/packages/player-web/src/scene/default/synesthesia/gameplay-chrome.ts +++ b/packages/player-web/src/scene/default/synesthesia/gameplay-chrome.ts @@ -7,9 +7,9 @@ import { addHudNumber, addHudText, type HudTextOptions } from '../hud-text.ts'; import { comboTier, effectProfile, impulse, punchScale } from '../moments.ts'; import { drawSynesthesiaMoments } from './moments.ts'; import { hash01 } from '../phantom-style.ts'; -import { drawFrame, drawPointCloud, drawReticle } from './draw.ts'; +import { createFlock, stepFlock, type Flock } from './boids.ts'; +import { drawFrame, drawPointCloud, drawReticle, drawSchool } from './draw.ts'; import { - cameraShot, emberColor, hsvToHex, mixCamera, @@ -18,6 +18,7 @@ import { pointCloudPyramid, projectPoint, REST_CAMERA, + roamingCamera, starfieldPoint, vanishingPoint, viewPoint, @@ -46,19 +47,42 @@ 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; +/** 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 SHOT_HOLD_S = 9; +const SCHOOL_SIZE = 70; /** - * Camera shots the background flies between (floor frame: eye at the origin, floor 154 below): a low glide banking - * right, a high sweep looking down from the left, a skim just over the floor, and a high centre shot. Each holds for - * {@link SHOT_HOLD_S} then flies to the next over {@link SHOT_MOVE_S}. + * 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 GAMEPLAY_SHOTS: readonly CameraPose[] = [ - REST_CAMERA, - { x: 170, y: 64, z: 0, yaw: 0.28, pitch: -0.05 }, - { x: -210, y: -110, z: 0, yaw: -0.32, pitch: 0.15 }, - { x: 30, y: 92, z: 0, yaw: 0.12, pitch: -0.09 }, - { x: 80, y: -150, z: 0, yaw: -0.14, pitch: 0.21 }, -]; -const SHOT_HOLD_S = 9; +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; +} const SHOT_MOVE_S = 3.5; const FIGURE = pointCloudHumanoid(11, 1400); const PYRAMIDS = [pointCloudPyramid(3, 520), pointCloudPyramid(8, 420), pointCloudPyramid(5, 700)]; @@ -94,9 +118,9 @@ export function renderSynesthesiaChrome({ // Input reaction: every press surges the dust and streaks 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 periodically flies to a new shot, reframing floor, rivers and warp. Reduced effects halve - // the moves; effects off keep the camera at rest. - const camera = mixCamera(REST_CAMERA, cameraShot(seconds, GAMEPLAY_SHOTS, SHOT_HOLD_S, SHOT_MOVE_S), effects.amount); + // Camera: the background roams between random shots (flying, sometimes hard-cutting) with a handheld drift, + // reframing floor, rivers, schools and warp. 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, 320, FLOOR.horizon, FLOOR.focal).y; drawGround(space, pulse, hasBga, tier, hit, hitColor, horizon); @@ -109,6 +133,27 @@ export function renderSynesthesiaChrome({ for (let river = 0; river < rivers; river += 1) { drawRiver(light, seconds, river, hasBga, tier, camera); } + 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: pulse * 0.6, + scatter: impulse(runtime.impulseAtMs, runtime.nowMs ?? 0, 520) * effects.amount, + }); + const shown = effects.screenWide ? schools.length : 1; + for (let school = 0; school < shown; school += 1) { + drawSchool( + light, + schools[school]!, + (point) => projectPoint(viewPoint(point, camera), 320, 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'; diff --git a/packages/player-web/src/scene/default/synesthesia/select.ts b/packages/player-web/src/scene/default/synesthesia/select.ts index 0e77476b..e772cd90 100644 --- a/packages/player-web/src/scene/default/synesthesia/select.ts +++ b/packages/player-web/src/scene/default/synesthesia/select.ts @@ -9,9 +9,9 @@ import type { import { easeOutCubic, stageProgress } from '../phantom-style.ts'; import { addHitArea, addSkinText, formatPlayVariantLabel, type SkinTextOptions } from '../skin-text.ts'; import { hash01 } from '../phantom-style.ts'; -import { drawPointCloud, drawReticle } from './draw.ts'; +import { createFlock, stepFlock } from './boids.ts'; +import { drawPointCloud, drawReticle, drawSchool } from './draw.ts'; import { - cameraShot, emberColor, hsvToHex, mixCamera, @@ -20,10 +20,10 @@ import { pointCloudPyramid, projectPoint, REST_CAMERA, + roamingCamera, starfieldPoint, vanishingPoint, viewPoint, - type CameraPose, } from './space.ts'; import { SYN_AMBER, @@ -51,18 +51,16 @@ const STAR_COUNT = 320; const STREAK_COUNT = 26; const RIVER_PARTICLES = 340; const FIGURE = pointCloudHumanoid(23, 1600); -/** - * Camera shots the select backdrop flies between, circling the pyramids: rest, a low pass banking right, a high - * sweep from the right looking down, and a skim just over the floor. - */ -const SELECT_SHOTS: readonly CameraPose[] = [ - REST_CAMERA, - { x: -190, y: 40, z: 0, yaw: 0.34, pitch: -0.04 }, - { x: 230, y: -120, z: 0, yaw: -0.3, pitch: 0.15 }, - { x: 0, y: 70, z: 0, yaw: -0.12, pitch: -0.08 }, -]; -const SHOT_HOLD_S = 7; -const SHOT_MOVE_S = 3; +/** 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)]; @@ -98,6 +96,7 @@ class SynesthesiaSelectRenderer implements BeMusicSelectRenderer { private readonly lock = new Graphics(); private readonly cursorGlow = new Sprite(); private cursorChangedAt = Number.NEGATIVE_INFINITY; + private readonly flocks = SCHOOL_SPECS.map((spec) => createFlock(spec.seed, SCHOOL_SIZE, spec.bounds)); private built = false; private designWidth = 640; private designHeight = 480; @@ -186,10 +185,11 @@ class SynesthesiaSelectRenderer implements BeMusicSelectRenderer { const hue = sceneHue(seconds, beatPhase); const accent = hsvToHex(hue, 0.6, 1); - // Camera: the backdrop periodically flies to a new shot. Reduced effects halve the moves; off keeps it at rest. + // 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. const camera = mixCamera( REST_CAMERA, - cameraShot(seconds, SELECT_SHOTS, SHOT_HOLD_S, SHOT_MOVE_S), + roamingCamera(seconds, 13, CAMERA_RANGE, CAMERA_CYCLE_S), this.effects === 'off' ? 0 : this.effects === 'reduced' ? 0.5 : 1, ); // Data dust pouring out of the vanishing point — speed follows the focused chart's tempo. @@ -298,6 +298,20 @@ class SynesthesiaSelectRenderer implements BeMusicSelectRenderer { alpha: Math.sin(progress * Math.PI) * (0.35 + 0.4 * warp), }); } + // Schools of light fish sweeping through the pyramids: tight on the beat, scattering when the cursor moves. + if (rate > 0) { + const scatter = Math.max(0, 1 - (nowMs - this.cursorChangedAt) / 600) ** 2 + 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: pulse * 0.6, 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 }, + ); + } + } // The figure, drifting behind the list — it turns to face you and breathes on the beat. const bob = Math.sin(seconds * 0.8) * 8; drawPointCloud( diff --git a/packages/player-web/src/scene/default/synesthesia/space.test.ts b/packages/player-web/src/scene/default/synesthesia/space.test.ts index be614297..3e1e7d65 100644 --- a/packages/player-web/src/scene/default/synesthesia/space.test.ts +++ b/packages/player-web/src/scene/default/synesthesia/space.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it } from 'vite-plus/test'; import { burstParticlePosition, burstParticles, - cameraShot, emberColor, hsvToHex, mixCamera, @@ -10,7 +9,9 @@ import { pointCloudHumanoid, pointCloudPyramid, projectPoint, + randomShot, REST_CAMERA, + roamingCamera, rotateX, rotateY, starfieldPoint, @@ -184,35 +185,6 @@ describe('particleRiverPoint', () => { }); }); -describe('cameraShot', () => { - const shots = [ - { x: 0, y: 0, z: 0, yaw: 0, pitch: 0 }, - { x: 100, y: -40, z: 0, yaw: 0.3, pitch: 0.1 }, - ]; - - it('holds each shot, then eases to the next and wraps around', () => { - expect(cameraShot(5, shots, 10, 2)).toEqual(shots[0]); - expect(cameraShot(11, shots, 10, 2).x).toBeCloseTo(50, 9); - expect(cameraShot(12, shots, 10, 2).x).toBeCloseTo(100, 9); - expect(cameraShot(20, shots, 10, 2)).toEqual(shots[1]); - // Shot 1 flies back to shot 0. - expect(cameraShot(24, shots, 10, 2).x).toBeCloseTo(0, 9); - }); - - it('eases in and out of each move', () => { - const early = cameraShot(10.2, shots, 10, 2).x; - const late = cameraShot(11.8, shots, 10, 2).x; - expect(early).toBeLessThan(10); - expect(late).toBeGreaterThan(90); - }); - - it('falls back to the rest pose for empty sequences and bad time', () => { - expect(cameraShot(3, [], 10, 2)).toEqual(REST_CAMERA); - expect(cameraShot(Number.NaN, shots, 10, 2)).toEqual(shots[0]); - expect(cameraShot(-1, shots, 10, 2).x).toBeCloseTo(cameraShot(23, shots, 10, 2).x, 9); - }); -}); - 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); @@ -263,3 +235,51 @@ describe('vanishingPoint', () => { 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); + }); +}); diff --git a/packages/player-web/src/scene/default/synesthesia/space.ts b/packages/player-web/src/scene/default/synesthesia/space.ts index 582bc13c..52354a6c 100644 --- a/packages/player-web/src/scene/default/synesthesia/space.ts +++ b/packages/player-web/src/scene/default/synesthesia/space.ts @@ -304,28 +304,6 @@ export interface CameraPose { export const REST_CAMERA: CameraPose = { x: 0, y: 0, z: 0, yaw: 0, pitch: 0 }; -/** - * The camera at `seconds` for a looping sequence of `shots`: each shot holds for `holdSeconds`, then the camera flies - * to the next one over `moveSeconds` with a smoothstep ease, wrapping back to the first shot at the end. - */ -export function cameraShot( - seconds: number, - shots: readonly CameraPose[], - holdSeconds: number, - moveSeconds: number, -): CameraPose { - if (shots.length === 0) return REST_CAMERA; - const time = Number.isFinite(seconds) ? seconds : 0; - const cycle = holdSeconds + moveSeconds; - const step = Math.floor(time / cycle); - const local = time - step * cycle; - const index = ((step % shots.length) + shots.length) % shots.length; - const from = shots[index]!; - const to = shots[(index + 1) % shots.length]!; - const raw = local <= holdSeconds || moveSeconds <= 0 ? 0 : (local - holdSeconds) / moveSeconds; - return mixCamera(from, to, raw * raw * (3 - 2 * raw)); -} - /** 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; @@ -354,3 +332,52 @@ export function vanishingPoint(camera: CameraPose, cx: number, cy: number, focal 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, + }; +} From be29e99eac65ba435b74eb4df42e4231881daadd Mon Sep 17 00:00:00 2001 From: Seiya KONNO Date: Tue, 29 Sep 2026 02:32:43 +0900 Subject: [PATCH 05/65] feat(player-web): add the Lattice built-in skin A typographic paper skin with a needle field, text scramble, odometer counters, spring-set letters, Verlet pendulums, bounce drops, and tile-flip transitions. Text helpers accept the 200 weight for thin display type, and the demo loads Inter. --- .changeset/be-music-skins.md | 5 +- packages/player-web-demo/index.html | 2 +- .../src/scene/default/built-in-skins.ts | 3 +- .../player-web/src/scene/default/hud-text.ts | 2 +- .../player-web/src/scene/default/index.ts | 3 +- .../src/scene/default/lattice/draw.ts | 224 +++++ .../src/scene/default/lattice/field.test.ts | 178 ++++ .../src/scene/default/lattice/field.ts | 176 ++++ .../scene/default/lattice/gameplay-chrome.ts | 835 ++++++++++++++++++ .../src/scene/default/lattice/index.ts | 41 + .../src/scene/default/lattice/moments.ts | 276 ++++++ .../src/scene/default/lattice/physics.test.ts | 68 ++ .../src/scene/default/lattice/physics.ts | 101 +++ .../src/scene/default/lattice/playfield.ts | 207 +++++ .../src/scene/default/lattice/result.ts | 250 ++++++ .../src/scene/default/lattice/select.ts | 490 ++++++++++ .../src/scene/default/lattice/style.ts | 20 + .../player-web/src/scene/default/skin-text.ts | 2 +- 18 files changed, 2876 insertions(+), 7 deletions(-) create mode 100644 packages/player-web/src/scene/default/lattice/draw.ts create mode 100644 packages/player-web/src/scene/default/lattice/field.test.ts create mode 100644 packages/player-web/src/scene/default/lattice/field.ts create mode 100644 packages/player-web/src/scene/default/lattice/gameplay-chrome.ts create mode 100644 packages/player-web/src/scene/default/lattice/index.ts create mode 100644 packages/player-web/src/scene/default/lattice/moments.ts create mode 100644 packages/player-web/src/scene/default/lattice/physics.test.ts create mode 100644 packages/player-web/src/scene/default/lattice/physics.ts create mode 100644 packages/player-web/src/scene/default/lattice/playfield.ts create mode 100644 packages/player-web/src/scene/default/lattice/result.ts create mode 100644 packages/player-web/src/scene/default/lattice/select.ts create mode 100644 packages/player-web/src/scene/default/lattice/style.ts diff --git a/.changeset/be-music-skins.md b/.changeset/be-music-skins.md index 3af2c761..e2590564 100644 --- a/.changeset/be-music-skins.md +++ b/.changeset/be-music-skins.md @@ -6,9 +6,10 @@ 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). -- New exports: `BeMusicSkin` and its context types, `createBeMusicSkinRegistry`, `resolveBeMusicLaneKind`, `resolveSelectListWindow`, `phantomSkin`, `synesthesiaSkin`, and `BUILT_IN_BE_MUSIC_SKINS`. +- New exports: `BeMusicSkin` and its context types, `createBeMusicSkinRegistry`, `resolveBeMusicLaneKind`, `resolveSelectListWindow`, `phantomSkin`, `synesthesiaSkin`, `latticeSkin`, 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, after Rez Infinite's Area X: a black void lit by an ember horizon, a world built from fine ember / gold particles with electric blue and magenta accents, data dust and speed streaks pouring out of the vanishing point, a floor of light points, rivers of particles, point-cloud pyramids, and a floating point-cloud figure. The 3D backdrop periodically flies the camera to a new shot (height, heading, and tilt), reframing the whole space, and the idle BGA monitor orbits the figure. Hits detonate as perspective-projected 3D particle fountains with voxel sparks, motion trails, tilted shockwave rings, 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 a 3D particle ring that bursts as the rank ignites. It draws with Michroma and M PLUS 1p, which hosts should load. +- Synesthesia is inspired by the sound-and-light games of Tetsuya Mizuguchi, after Rez Infinite's Area X: a black void lit by an ember horizon, a world built from fine ember / gold particles with electric blue and magenta accents, data dust and speed streaks pouring out of the vanishing point, a floor of light points, rivers of particles, point-cloud pyramids, and a floating point-cloud figure. 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 orbits the figure. Hits detonate as perspective-projected 3D particle fountains with voxel sparks, motion trails, tilted shockwave rings, 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 a 3D particle ring that bursts 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. - 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 detonates a ring of light), a cut-in every 100 combo, a flourish when the gauge crosses the clear line, and a full-combo finale (Phantom's turning burst with confetti, Synesthesia's 3D spark sphere and prism rings). Phantom's result screen opens with an ink wipe carrying a giant RESULT slug. 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/src/scene/default/built-in-skins.ts b/packages/player-web/src/scene/default/built-in-skins.ts index 8bd1b177..15d19f83 100644 --- a/packages/player-web/src/scene/default/built-in-skins.ts +++ b/packages/player-web/src/scene/default/built-in-skins.ts @@ -1,6 +1,7 @@ import type { BeMusicSkin } from '../../skin/be-music/types.ts'; +import { latticeSkin } from './lattice/index.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]; +export const BUILT_IN_BE_MUSIC_SKINS: readonly BeMusicSkin[] = [phantomSkin, synesthesiaSkin, latticeSkin]; diff --git a/packages/player-web/src/scene/default/hud-text.ts b/packages/player-web/src/scene/default/hud-text.ts index 2409d797..1faed641 100644 --- a/packages/player-web/src/scene/default/hud-text.ts +++ b/packages/player-web/src/scene/default/hud-text.ts @@ -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; diff --git a/packages/player-web/src/scene/default/index.ts b/packages/player-web/src/scene/default/index.ts index e78cff60..13f75106 100644 --- a/packages/player-web/src/scene/default/index.ts +++ b/packages/player-web/src/scene/default/index.ts @@ -3,7 +3,7 @@ * 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 @@ -13,6 +13,7 @@ export * from './gameplay-render.ts'; export * from './gameplay.ts'; export * from './result.ts'; export * from './select.ts'; +export { latticeSkin } from './lattice/index.ts'; export { phantomSkin } from './phantom/index.ts'; export { synesthesiaSkin } from './synesthesia/index.ts'; export { BUILT_IN_BE_MUSIC_SKINS } from './built-in-skins.ts'; diff --git a/packages/player-web/src/scene/default/lattice/draw.ts b/packages/player-web/src/scene/default/lattice/draw.ts new file mode 100644 index 00000000..68e5531c --- /dev/null +++ b/packages/player-web/src/scene/default/lattice/draw.ts @@ -0,0 +1,224 @@ +import type { Container, Graphics } from 'pixi.js'; +import type { ChildPool } from '../../pixi-utils.ts'; +import { addHudText, type HudTextOptions } from '../hud-text.ts'; +import { addSkinText, type SkinTextOptions } from '../skin-text.ts'; +import { digitTransitions, needleAngle, 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'; + +/** + * 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 stroke 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. 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 } = {}, +): void { + const pitch = options.pitch ?? LAT_GRID / 2; + const length = options.length ?? 4.5; + const accentPath: number[] = []; + const x0 = Math.ceil(area.x / pitch) * pitch; + const y0 = Math.ceil(area.y / pitch) * pitch; + let inkCount = 0; + for (let y = y0; y <= area.y + area.h; y += pitch) { + for (let x = x0; x <= area.x + area.w; x += pitch) { + if (options.skip?.(x, y)) continue; + const angle = needleAngle(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 (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; + } + } + } + const alpha = options.alpha ?? 0.5; + if (inkCount > 0) graphics.stroke({ color: LAT_INK, width: 0.75, alpha: alpha * 0.55 }); + 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: Math.min(1, alpha * 1.6) }); +} + +/** 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 = [...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 = [...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/scene/default/lattice/field.test.ts b/packages/player-web/src/scene/default/lattice/field.test.ts new file mode 100644 index 00000000..c676e875 --- /dev/null +++ b/packages/player-web/src/scene/default/lattice/field.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, it } from 'vite-plus/test'; +import { + RIPPLE_LIFE_MS, + countInStep, + digitTransitions, + mixLineAngle, + needleAngle, + 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('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([...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([...scrambled]).toHaveLength(5); + expect([...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); + }); +}); diff --git a/packages/player-web/src/scene/default/lattice/field.ts b/packages/player-web/src/scene/default/lattice/field.ts new file mode 100644 index 00000000..39f7141c --- /dev/null +++ b/packages/player-web/src/scene/default/lattice/field.ts @@ -0,0 +1,176 @@ +import { hash01 } from '../phantom-style.ts'; + +/** + * 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; +} + +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 { + const s = input.seconds; + let angle = REST_ANGLE + input.flow * (0.5 * Math.sin(x * 0.017 + s * 0.6) + 0.5 * Math.cos(y * 0.023 - s * 0.45)); + 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); + } + for (const ripple of input.ripples ?? []) { + if (ripple.ageMs < 0 || ripple.ageMs >= RIPPLE_LIFE_MS) continue; + const r = Math.hypot(x - ripple.x, y - ripple.y); + const front = ripple.ageMs * 0.5; + 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 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 [...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 = [...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/scene/default/lattice/gameplay-chrome.ts b/packages/player-web/src/scene/default/lattice/gameplay-chrome.ts new file mode 100644 index 00000000..182fddad --- /dev/null +++ b/packages/player-web/src/scene/default/lattice/gameplay-chrome.ts @@ -0,0 +1,835 @@ +import type { Container, Graphics } 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 } from '../hud-text.ts'; +import { comboTier, effectProfile, impulse, momentProgress, trackMoments, type MomentState } from '../moments.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'; + +type Runtime = SkinlessGameplayChromeRuntime; + +/** + * 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); +} + +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 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, +}: SkinlessGameplayChromeRenderContext): void { + const nowMs = runtime.nowMs ?? 0; + const seconds = nowMs / 1000; + const beatPhase = runtime.beatPhase ?? 0; + const hasBga = runtime.hasBga === true; + const playfieldRight = resolvePlayfieldRight(runtime); + const effects = effectProfile(runtime.effects); + const tier = effects.enabled ? Math.min(comboTier(runtime.combo ?? 0), effects.screenWide ? 4 : 2) : 0; + 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, + }); + + const paper = layerPool.acquireGraphics(); + paper.label = 'lattice-gameplay/paper'; + paper.blendMode = 'normal'; + fillAroundBga(paper, 0, 0, DESIGN_WIDTH, DESIGN_HEIGHT, LAT_PAPER, hasBga); + drawPaperGrid(paper, { x: 0, y: 0, w: DESIGN_WIDTH, h: DESIGN_HEIGHT }, 0.45, hasBga ? BGA : undefined); + + 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: PLAYFIELD.x - 20, y: 0, w: playfieldRight - PLAYFIELD.x + 26, h: 346 }, + { 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: BGA.x + BGA.w + 8, y: BGA.y - 12, w: DESIGN_WIDTH, h: 232 }, + ]; + const pendulums = 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, playfieldRight), + { + alpha: 0.55, + skip: (x, y) => clean.some((rect) => inside(rect, x, y, 0)), + }, + ); + + const panels = layerPool.acquireGraphics(); + panels.label = 'lattice-gameplay/panels'; + panels.blendMode = 'normal'; + drawPlayfieldFrame(panels, playfieldRight, runtime.progressRatio); + if (pendulums) { + drawPendulums(panels, layer, runtime, (playfieldRight + BGA.x - 5) / 2, effects.amount, layerPool); + } + if (playfieldRight + 14 <= BGA.x) { + drawBgaFrame(panels, layer, hasBga, seconds, beatPhase, effects.amount, layerPool); + } + drawGauge(panels, layer, runtime, moments, layerPool); + drawSongPlate(panels, layer, runtime, layerPool); + drawScorePanel(panels, layer, runtime, layerPool); + const tallyX = BGA.x + BGA.w + 14; + if (playfieldRight + 14 <= tallyX) { + drawJudgeTally(panels, layer, runtime, tallyX, 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); + drawJudgements(overlayLayer, runtime, playfieldRight, effects.amount, overlayLayerPool); + } + drawLatticeMoments(layer, overlayLayer, runtime, moments, (PLAYFIELD.x + playfieldRight) / 2, overlayLayerPool); +} + +const IMPULSE_HISTORY = new WeakMap(); + +interface PendulumState { + chains: Chain[]; + lastMs: number; + lastImpulseAt: number | undefined; + lastBeat: number; + beats: number; +} +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, + 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, + }; + 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; + 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, a swirl on a full combo, and + * jitter on a miss. + */ +function resolveFieldInput( + key: object, + runtime: Runtime, + moments: MomentState, + tier: number, + amount: number, + playfieldRight: number, +): NeedleFieldInput { + const nowMs = runtime.nowMs ?? 0; + 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 = (PLAYFIELD.x + playfieldRight) / 2; + const ripples = + amount > 0 + ? history.map((atMs) => ({ x: originX, y: PLAYFIELD.judgementY, ageMs: nowMs - atMs, strength: amount })) + : []; + const milestone = momentProgress(moments.milestone?.atMs, nowMs, MILESTONE_MS); + const fullCombo = momentProgress(moments.fullComboAtMs, nowMs, FULL_COMBO_MS); + const miss = runtime.lastJudge === 'POOR' || runtime.lastJudge === 'BAD' ? impulse(runtime.judgeAtMs, nowMs, 260) : 0; + 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, + beatWave: (0.45 + 0.12 * tier) * amount, + ripples, + attractor: { x: BGA.x + BGA.w / 2, y: BGA.y + BGA.h / 2, strength: envelope(milestone) * amount }, + swirl: { x: DESIGN_WIDTH / 2, y: DESIGN_HEIGHT / 2, strength: envelope(fullCombo) * amount }, + jitter: miss * amount, + tremble: 0.07 * amount, + }; +} + +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 inside(rect: Rect, x: number, y: number, margin: number): boolean { + return x > rect.x - margin && x < rect.x + rect.w + margin && y > rect.y - margin && y < rect.y + rect.h + margin; +} + +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, right: number, progressRatio: number | undefined): void { + const left = PLAYFIELD.x; + 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, + 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; + for (let row = 0; row < rows; 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 }); + // STANDBY re-decodes every few seconds while the monitor idles. + const idle = amount > 0 ? (seconds % 5) / 0.7 : 1; + addHudText( + layer, + scrambleText('STANDBY', 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. + const cx = 20; + 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), + 36, + 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), + DESIGN_WIDTH - 12, + 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(GROOVE.x - 12, GROOVE.y - 24, GROOVE.w + 24, 1).fill(LAT_INK); + addHudText( + layer, + sc(`${(runtime.gaugeLabel ?? 'GROOVE').toUpperCase()} GAUGE`, 240, 5), + GROOVE.x - 12, + 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, pool: ChildPool): void { + const y = BGA.y - 5; + const w = DESIGN_WIDTH - x - 8; + 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, + ); + } +} + +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, +}; + +/** + * Judgement word dropping in letter by letter on a spring, with the combo as an odometer whose changed wheels roll up. + * Misses knock the word sideways. + */ +function drawJudgements( + layer: Container, + runtime: Runtime, + playfieldRight: number, + 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, playfieldRight)) { + const color = JUDGE_COLORS[display.judge] ?? LAT_INK; + const miss = display.judge === 'POOR' || display.judge === 'BAD'; + const knock = miss ? Math.sin(age / 18) * 6 * impulse(runtime.judgeAtMs, nowMs, 220) * amount : 0; + addStaggeredHudText( + layer, + amount > 0 ? scrambleText(display.judge, age / 90, scrambleTick(nowMs, 25), display.judge.length) : display.judge, + display.x + knock, + 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, + playfieldRight: number, +): Array<{ judge: string; combo: number | undefined; x: 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 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 })); + } + 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/scene/default/lattice/index.ts b/packages/player-web/src/scene/default/lattice/index.ts new file mode 100644 index 00000000..73046ec1 --- /dev/null +++ b/packages/player-web/src/scene/default/lattice/index.ts @@ -0,0 +1,41 @@ +import type { BeMusicSkin } from '../../../skin/be-music/types.ts'; +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'; + +/** + * Lattice — a skin in the manner of precise, typographic interaction design (the Japanese web / broadcast design + * school of UNIQLOCK and "design ah"): 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 = { + id: 'lattice', + label: 'Lattice', + 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"', + ], + 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/scene/default/lattice/moments.ts b/packages/player-web/src/scene/default/lattice/moments.ts new file mode 100644 index 00000000..c15687da --- /dev/null +++ b/packages/player-web/src/scene/default/lattice/moments.ts @@ -0,0 +1,276 @@ +import type { Container, Graphics } from 'pixi.js'; +import { BGA, DESIGN_HEIGHT, DESIGN_WIDTH, GROOVE, PLAYFIELD } from '../../gameplay-constants.ts'; +import type { SkinlessGameplayChromeRuntime } from '../../gameplay-chrome.ts'; +import type { ChildPool } from '../../pixi-utils.ts'; +import { addHudText } from '../hud-text.ts'; +import { effectProfile, impulse, momentProgress, type MomentState } from '../moments.ts'; +import { easeOutCubic, hash01 } from '../phantom-style.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_SIGNAL, LAT_WHITE } from './style.ts'; + +export const MILESTONE_MS = 1400; +export const FULL_COMBO_MS = 3200; +const CLEAR_MS = 1000; +const BREAK_MS = 900; + +/** + * 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 knocks the HUD sideways in hard 2 px steps; a broken combo's digits fall away under gravity. + */ +export function drawLatticeMoments( + chromeLayer: Container, + layer: Container, + runtime: SkinlessGameplayChromeRuntime, + moments: MomentState, + playfieldCenterX: number, + pool: ChildPool, +): 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'; + + const miss = runtime.lastJudge === 'POOR' || runtime.lastJudge === 'BAD' ? impulse(runtime.judgeAtMs, nowMs, 220) : 0; + if (miss > 0 && effects.screenWide) { + // Quantized knock: the HUD jumps in whole 2 px steps, like a mechanism slipping a tooth. + const knock = Math.round((Math.sin(nowMs / 14) * 3 * miss) / 2) * 2; + chromeLayer.position.set(knock, 0); + layer.position.set(knock, 0); + } + const comboBreak = momentProgress(moments.comboBreak?.atMs, nowMs, BREAK_MS); + if (comboBreak !== undefined && moments.comboBreak) { + drawComboBreak(layer, moments.comboBreak.combo, comboBreak, playfieldCenterX, pool); + } + 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, 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, + pool: ChildPool, +): void { + const cx = BGA.x + BGA.w / 2; + const cy = hasBga ? BGA.y + 36 : BGA.y + BGA.h / 2 - 12; + const out = Math.max(0, (t - 0.78) / 0.22); + const size = hasBga ? 32 : 64; + if (hasBga) { + // A paper label over the live video. + const w = 170 * easeOutCubic(Math.min(1, t / 0.2)); + graphics.rect(cx - w / 2, cy - 26, w, 62).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', 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); +} + +/** The broken combo's digits fall off under gravity, each with its own spin and drift. */ +function drawComboBreak(layer: Container, combo: number, t: number, cx: number, pool: ChildPool): void { + const digits = [...String(combo)]; + const cell = 18; + const left = cx - (digits.length * cell) / 2; + const seconds = t * (BREAK_MS / 1000); + digits.forEach((char, index) => { + const drift = (hash01(combo * 13 + index) - 0.5) * 70; + const spin = (hash01(combo * 7 + index * 3) - 0.5) * 6; + const x = left + cell * (index + 0.5) + drift * seconds; + const y = 256 - 60 * seconds + 0.5 * 900 * seconds * seconds; + if (y > PLAYFIELD.judgementY + 60) return; + const node = addHudText( + layer, + char, + x, + y, + { ...displayStyle(30, LAT_SIGNAL, '200'), anchorX: 0.5, anchorY: 0.5 }, + pool, + ); + node.rotation = spin * seconds; + node.alpha = 1 - t; + }); +} diff --git a/packages/player-web/src/scene/default/lattice/physics.test.ts b/packages/player-web/src/scene/default/lattice/physics.test.ts new file mode 100644 index 00000000..3242fc6d --- /dev/null +++ b/packages/player-web/src/scene/default/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/scene/default/lattice/physics.ts b/packages/player-web/src/scene/default/lattice/physics.ts new file mode 100644 index 00000000..fcaba4b2 --- /dev/null +++ b/packages/player-web/src/scene/default/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/scene/default/lattice/playfield.ts b/packages/player-web/src/scene/default/lattice/playfield.ts new file mode 100644 index 00000000..5d71d481 --- /dev/null +++ b/packages/player-web/src/scene/default/lattice/playfield.ts @@ -0,0 +1,207 @@ +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 { comboTier, effectProfile } from '../moments.ts'; +import { easeOutCubic, hash01 } from '../phantom-style.ts'; +import { springEase } from './field.ts'; +import { LAT_ACCENT, LAT_INK, LAT_RULE, LAT_SIGNAL } from './style.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, +}; + +const NOTE_HEIGHT = 6; +const LANE_BED = 0xfbfaf7; +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 }: BeMusicLanesContext): 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); + graphics.rect(x, lane.top, w, height).fill({ color: LANE_BED, alpha: 0.94 }); + if (lane.beam > 0) { + const beamHeight = Math.min(height, 200); + const strength = lane.beam * lane.beam * (3 - 2 * lane.beam); + graphics + .rect(x, lane.bottom - beamHeight, w, beamHeight) + .fill({ fill: resolveWash(LANE_COLORS[lane.kind]), alpha: strength * 0.5 }); + } + // 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); + } + graphics.moveTo(right - 0.5, top).lineTo(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. + const lineWidth = 2 + (tier >= 2 ? 1 : 0); + graphics.rect(left, bottom - lineWidth, right - left, lineWidth).fill(LAT_INK); + for (let x = left; x <= right + 1e-6; x += 4) { + const long = Math.round(x - 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 tick to tick across the line, one lane per beat. + const span = right - left; + const steps = Math.max(1, lanes.length); + const beat = Math.floor(beatPhase * steps); + const within = beatPhase * steps - beat; + const markerX = left + ((beat + springEase(Math.min(1, within * 2), 2.4, 0.5)) / steps) * span; + graphics.rect(Math.min(right - 6, markerX), bottom - lineWidth - 3, 6, 3).fill(LAT_ACCENT); +} + +export function renderLatticeNote({ graphics, kind, x, w, y }: BeMusicNoteContext): void { + graphics.rect(x + 1, y - NOTE_HEIGHT, Math.max(3, w - 2), NOTE_HEIGHT).fill(LANE_COLORS[kind]); +} + +/** Long note: an outlined column ruled with fine horizontal hatching, capped by solid heads. */ +export function renderLatticeLongNote({ graphics, kind, x, w, top, bottom }: BeMusicLongNoteContext): void { + const color = LANE_COLORS[kind]; + const bodyX = x + 3; + const bodyW = Math.max(3, w - 6); + const bodyTop = top - NOTE_HEIGHT; + const bodyBottom = bottom - NOTE_HEIGHT; + if (bodyBottom > bodyTop) { + graphics.rect(bodyX, bodyTop, bodyW, bodyBottom - bodyTop).fill({ color, alpha: 0.08 }); + // Hatching on an absolute 3 px pitch, so it reads as a printed pattern rather than scrolling stripes. + for (let y = Math.ceil(bodyTop / 3) * 3; y < bodyBottom; y += 3) { + graphics.moveTo(bodyX, y + 0.5).lineTo(bodyX + bodyW, y + 0.5); + } + graphics.stroke({ color, width: 1, alpha: 0.35 }); + graphics.rect(bodyX + 0.5, bodyTop, bodyW - 1, bodyBottom - bodyTop).stroke({ color, width: 1, 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 }: BeMusicBombsContext): 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 }); + } +} + +const WASHES = new Map(); + +/** Vertical wash for a key beam: transparent at the top, the lane colour toward the judgement line. */ +function resolveWash(color: number): FillGradient { + let gradient = WASHES.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.7, color: rgb.setAlpha(0.12).toRgbaString() }, + { offset: 1, color: rgb.setAlpha(0.4).toRgbaString() }, + ], + }); + WASHES.set(color, gradient); + } + return gradient; +} diff --git a/packages/player-web/src/scene/default/lattice/result.ts b/packages/player-web/src/scene/default/lattice/result.ts new file mode 100644 index 00000000..0d89c8fb --- /dev/null +++ b/packages/player-web/src/scene/default/lattice/result.ts @@ -0,0 +1,250 @@ +import { Graphics } from 'pixi.js'; +import type { BeMusicResultFrame, BeMusicResultSkin } from '../../../skin/be-music/types.ts'; +import { easeOutCubic, rollUpValue, stageProgress } from '../phantom-style.ts'; +import { addSkinText, type SkinTextOptions } from '../skin-text.ts'; +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'; + +const ROLL_DELAY_MS = 600; +const ROLL_MS = 1100; +const RANK_DELAY_MS = 1500; + +export const latticeResultSkin: BeMusicResultSkin = { 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: BeMusicResultFrame): void { + const { result, designWidth, designHeight, nowMs, rankLabel, layer } = frame; + const elapsed = frame.effects === 'off' ? Number.POSITIVE_INFINITY : frame.elapsedMs; + 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); + + g.rect(0, 0, designWidth, designHeight).fill(LAT_PAPER); + drawPaperGrid(g, { x: 0, y: 0, w: designWidth, h: designHeight }, 0.45); + const rankCx = 112; + const rankCy = 156; + const rankIn = stageProgress(elapsed, RANK_DELAY_MS, 600); + drawNeedleField( + g, + { 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, + skip: (x, y) => (x > 14 && x < 626 && y > 56 && y < 430) || y > designHeight - 50, + }, + ); + + // 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) }; + }, + ); + text( + decode(`${result.song.title}${result.song.artist ? ` / ${result.song.artist}` : ''}`, 280, 2, 600), + designWidth - 20, + 16, + { + size: 12, + weight: '500', + fill: LAT_INK, + fontFamily: LAT_TEXT_FONT, + anchorX: 1, + maxWidth: 340, + alpha: fadeIn(300), + }, + ); + + // 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) }); + const letters = [...rankLabel]; + addStaggeredSkinText( + layer, + rankLabel, + rankCx, + rankCy, + { + ...display(letters.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 = [ + [224, 66, '02 SCORE', String(rollUpValue(result.score.score, roll)), LAT_INK], + [224, 122, '03 EX SCORE', `${rollUpValue(result.score.exScore, roll)} / ${exMax}`, LAT_INK], + [224, 178, '04 MAX COMBO', String(rollUpValue(result.maxCombo, roll)), LAT_ACCENT], + [430, 66, '05 GAUGE', `${rollUpValue(Math.round(result.gauge), roll)}%`, cleared ? LAT_ACCENT : LAT_SIGNAL], + [430, 122, '06 PLAY TIME', `${(result.playSeconds * easeOutCubic(roll)).toFixed(1)}s`, LAT_INK], + [430, 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, 190, delay); + text(decode(name, delay, 10 + index), x, y + 8, { ...mono(), alpha: fadeIn(delay) }); + text(value, x + 190, 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) => { + const y = 276 + index * 28; + 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(330, 244, 290, 600); + text(decode('09 GROOVE GAUGE', 600, 5), 330, 252, { ...mono(), alpha: fadeIn(600) }); + text(decode('10 EX SCORE', 640, 6), 330, 340, { ...mono(), alpha: fadeIn(640) }); + const draw = easeOutCubic(stageProgress(elapsed, ROLL_DELAY_MS + 150, ROLL_MS + 300)); + plot( + g, + 330, + 266, + 290, + 62, + result.gaugeHistory.map((s) => ({ x: s.progress, y: s.value / 100 })), + cleared ? LAT_ACCENT : LAT_SIGNAL, + draw, + true, + ); + plot( + g, + 330, + 354, + 290, + 62, + result.scoreHistory.map((s) => ({ x: s.progress, y: exMax > 0 ? s.exScore / exMax : 0 })), + LAT_INK, + draw, + false, + ); + + // 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); +} diff --git a/packages/player-web/src/scene/default/lattice/select.ts b/packages/player-web/src/scene/default/lattice/select.ts new file mode 100644 index 00000000..615265ce --- /dev/null +++ b/packages/player-web/src/scene/default/lattice/select.ts @@ -0,0 +1,490 @@ +import { Container, Graphics, type Text } 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 { 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'; + +const LAYOUT: BeMusicSelectLayout = { listX: 322, listTop: 56, listBottomInset: 28, rowHeight: 28 }; +const CURSOR_MS = 360; +const TITLE_MS = 420; +const OUTRO_MS = 650; +const INTRO_STAGGER_MS = 40; +const OUTRO_COLUMNS = 16; +const OUTRO_ROWS = 12; + +export const latticeSelectSkin: BeMusicSelectSkin = { + 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 BeMusicSelectRenderer { + public readonly backLayer = new Container(); + public readonly frontLayer = new Container(); + public readonly outroMs = OUTRO_MS; + private readonly field = new Graphics(); + private built = false; + private designWidth = 640; + private designHeight = 480; + private effects: BeMusicSelectFrame['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 cursorText: Text | undefined; + + public constructor() { + this.backLayer.label = 'lattice-select/paper'; + this.frontLayer.label = 'lattice-select/front'; + } + + public render(input: BeMusicSelectFrame): 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): void { + const rate = this.effects === 'off' ? 0 : this.effects === 'reduced' ? 0.5 : 1; + 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: target && rate > 0 ? [{ x: target.x, y: target.y, ageMs: cursorAge, strength: 0.8 }] : [], + attractor: target ? { x: target.x, y: target.y, strength: 0.55 } : undefined, + tremble: 0.06 * rate, + }, + { + alpha: 0.5, + skip: (x, y) => + this.occupied.some((rect) => x > rect.x && x < rect.x + rect.w && y > rect.y && y < rect.y + rect.h), + }, + ); + } + + 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)); + } + + private renderChrome(frame: BeMusicSelectFrame): 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(layer, 14, 296, 182, 40, 'pointer', frame.actions.play); + addHitArea(layer, 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(layer, 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 }, + { + 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: BeMusicSelectFrame): 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: BeMusicSelectFrame, + 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, + }); + 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, + scrambleText(meta, rowScramble, tick, entryIndex + 500), + listX + listWidth - 10, + midY, + { + ...mono(active ? LAT_WHITE : LAT_GRAPHITE), + 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, + 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, listX + listWidth - x - 72 - metaWidth), + }, + ); + title.label = `fallback-title[idx=${entryIndex}]`; + 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: BeMusicSelectFrame): 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/scene/default/lattice/style.ts b/packages/player-web/src/scene/default/lattice/style.ts new file mode 100644 index 00000000..0450784d --- /dev/null +++ b/packages/player-web/src/scene/default/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/skin-text.ts b/packages/player-web/src/scene/default/skin-text.ts index 5c7eb9ea..004a5fe4 100644 --- a/packages/player-web/src/scene/default/skin-text.ts +++ b/packages/player-web/src/scene/default/skin-text.ts @@ -6,7 +6,7 @@ import { DEFAULT_TEXT_FONT } from './fonts.ts'; export interface SkinTextOptions { size?: number; - weight?: '300' | '400' | '500' | '600' | '700' | '800' | '900'; + weight?: '200' | '300' | '400' | '500' | '600' | '700' | '800' | '900'; fill?: number | Color; fontFamily?: string; letterSpacing?: number; From 8456a76fa56704c5d62f6dd768ddbd2a62d8518b Mon Sep 17 00:00:00 2001 From: Seiya KONNO Date: Tue, 29 Sep 2026 02:43:05 +0900 Subject: [PATCH 06/65] refactor(player-web): clear lint warnings in the Lattice and Synesthesia skins --- packages/player-web/src/scene/default/lattice/draw.ts | 4 ++-- .../player-web/src/scene/default/lattice/field.test.ts | 8 ++++---- packages/player-web/src/scene/default/lattice/field.ts | 4 ++-- packages/player-web/src/scene/default/lattice/moments.ts | 2 +- .../player-web/src/scene/default/lattice/playfield.ts | 1 - packages/player-web/src/scene/default/lattice/result.ts | 3 +-- packages/player-web/src/scene/default/lattice/select.ts | 1 - .../src/scene/default/synesthesia/gameplay-chrome.ts | 2 -- 8 files changed, 10 insertions(+), 15 deletions(-) diff --git a/packages/player-web/src/scene/default/lattice/draw.ts b/packages/player-web/src/scene/default/lattice/draw.ts index 68e5531c..e2b7c04d 100644 --- a/packages/player-web/src/scene/default/lattice/draw.ts +++ b/packages/player-web/src/scene/default/lattice/draw.ts @@ -125,7 +125,7 @@ export function addStaggeredHudText( pool: ChildPool, motion: LetterMotion, ): void { - const chars = [...text]; + const chars = Array.from(text); const nodes = chars.map((char) => addHudText(layer, char, 0, y, { ...options, anchorX: 0, maxWidth: undefined, scale: undefined }, pool), ); @@ -204,7 +204,7 @@ export function addStaggeredSkinText( options: SkinTextOptions, motion: LetterMotion, ): void { - const chars = [...text]; + 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); diff --git a/packages/player-web/src/scene/default/lattice/field.test.ts b/packages/player-web/src/scene/default/lattice/field.test.ts index c676e875..01cfd8c1 100644 --- a/packages/player-web/src/scene/default/lattice/field.test.ts +++ b/packages/player-web/src/scene/default/lattice/field.test.ts @@ -135,7 +135,7 @@ 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([...middle]).toHaveLength(7); + 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'); @@ -149,9 +149,9 @@ describe('scrambleText', () => { it('keeps spaces and line length, scrambling wide characters through katakana', () => { const scrambled = scrambleText('曲 名です', 0.5, 2); - expect([...scrambled]).toHaveLength(5); - expect([...scrambled][1]).toBe(' '); - for (const char of [...scrambled]) { + 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); } }); diff --git a/packages/player-web/src/scene/default/lattice/field.ts b/packages/player-web/src/scene/default/lattice/field.ts index 39f7141c..4f25a9e2 100644 --- a/packages/player-web/src/scene/default/lattice/field.ts +++ b/packages/player-web/src/scene/default/lattice/field.ts @@ -98,7 +98,7 @@ export function digitTransitions(previous: number, next: number): DigitTransitio 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 [...after].map((char, index) => { + return Array.from(after).map((char, index) => { const prior = index - pad >= 0 ? (before[index - pad] ?? '') : ''; return { char, previous: prior, changed: prior !== char }; }); @@ -142,7 +142,7 @@ const SCRAMBLE_WIDE = 'アイウエオカキクケコサシスセソタチツテ * 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 = [...target]; + const chars = Array.from(target); if (!(progress < 1)) return target; const count = chars.length; if (count === 0) return target; diff --git a/packages/player-web/src/scene/default/lattice/moments.ts b/packages/player-web/src/scene/default/lattice/moments.ts index c15687da..db0d07f7 100644 --- a/packages/player-web/src/scene/default/lattice/moments.ts +++ b/packages/player-web/src/scene/default/lattice/moments.ts @@ -252,7 +252,7 @@ function drawFullCombo(graphics: Graphics, layer: Container, t: number, screenWi /** The broken combo's digits fall off under gravity, each with its own spin and drift. */ function drawComboBreak(layer: Container, combo: number, t: number, cx: number, pool: ChildPool): void { - const digits = [...String(combo)]; + const digits = Array.from(String(combo)); const cell = 18; const left = cx - (digits.length * cell) / 2; const seconds = t * (BREAK_MS / 1000); diff --git a/packages/player-web/src/scene/default/lattice/playfield.ts b/packages/player-web/src/scene/default/lattice/playfield.ts index 5d71d481..36a159d4 100644 --- a/packages/player-web/src/scene/default/lattice/playfield.ts +++ b/packages/player-web/src/scene/default/lattice/playfield.ts @@ -7,7 +7,6 @@ import type { BeMusicLongNoteContext, BeMusicNoteContext, } from '../../../skin/be-music/types.ts'; -import type { ChildPool } from '../../pixi-utils.ts'; import { comboTier, effectProfile } from '../moments.ts'; import { easeOutCubic, hash01 } from '../phantom-style.ts'; import { springEase } from './field.ts'; diff --git a/packages/player-web/src/scene/default/lattice/result.ts b/packages/player-web/src/scene/default/lattice/result.ts index 0d89c8fb..eaf4e4b5 100644 --- a/packages/player-web/src/scene/default/lattice/result.ts +++ b/packages/player-web/src/scene/default/lattice/result.ts @@ -113,14 +113,13 @@ export function renderLatticeResult(frame: BeMusicResultFrame): void { 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) }); - const letters = [...rankLabel]; addStaggeredSkinText( layer, rankLabel, rankCx, rankCy, { - ...display(letters.length >= 3 ? 64 : 92, rankLabel.startsWith('AA') ? LAT_ACCENT : LAT_INK, '200'), + ...display(rankLabel.length >= 3 ? 64 : 92, rankLabel.startsWith('AA') ? LAT_ACCENT : LAT_INK, '200'), anchorX: 0.5, anchorY: 0.5, maxWidth: 170, diff --git a/packages/player-web/src/scene/default/lattice/select.ts b/packages/player-web/src/scene/default/lattice/select.ts index 615265ce..03dfcd91 100644 --- a/packages/player-web/src/scene/default/lattice/select.ts +++ b/packages/player-web/src/scene/default/lattice/select.ts @@ -25,7 +25,6 @@ import { const LAYOUT: BeMusicSelectLayout = { listX: 322, listTop: 56, listBottomInset: 28, rowHeight: 28 }; const CURSOR_MS = 360; -const TITLE_MS = 420; const OUTRO_MS = 650; const INTRO_STAGGER_MS = 40; const OUTRO_COLUMNS = 16; diff --git a/packages/player-web/src/scene/default/synesthesia/gameplay-chrome.ts b/packages/player-web/src/scene/default/synesthesia/gameplay-chrome.ts index 7feb77ee..ef560a24 100644 --- a/packages/player-web/src/scene/default/synesthesia/gameplay-chrome.ts +++ b/packages/player-web/src/scene/default/synesthesia/gameplay-chrome.ts @@ -50,7 +50,6 @@ 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 SHOT_HOLD_S = 9; const SCHOOL_SIZE = 70; /** * Three schools, each with its own swimming box (floor frame, floor at y 154), light, and seed — their leaders wander @@ -83,7 +82,6 @@ function advanceSchools( state.lastMs = nowMs; return state.flocks; } -const SHOT_MOVE_S = 3.5; const FIGURE = pointCloudHumanoid(11, 1400); const PYRAMIDS = [pointCloudPyramid(3, 520), pointCloudPyramid(8, 420), pointCloudPyramid(5, 700)]; From f3e5978d34d986de6a933216ef6066e74e59ddef Mon Sep 17 00:00:00 2001 From: Seiya KONNO Date: Tue, 29 Sep 2026 02:43:05 +0900 Subject: [PATCH 07/65] feat(player-web): cut Phantom showpieces in on torn-paper strips with ransom-note lettering Count-in, combo milestones, and the full combo rip in on torn strips with ransom-note text; the launch outro becomes a torn split screen between a red bokeh half and a starburst half; the result wipe tears along a ripped edge. Judgement type is held back while the full combo covers the page. --- .changeset/be-music-skins.md | 2 +- .../src/scene/default/gameplay-render.ts | 7 +- .../src/scene/default/phantom-style.test.ts | 64 ++++++ .../src/scene/default/phantom-style.ts | 105 +++++++++ .../src/scene/default/phantom/moments.ts | 205 ++++++++---------- .../src/scene/default/phantom/result.ts | 34 ++- .../src/scene/default/phantom/select.ts | 121 ++++++++--- .../src/scene/default/phantom/tear.test.ts | 27 +++ .../src/scene/default/phantom/tear.ts | 168 ++++++++++++++ 9 files changed, 575 insertions(+), 158 deletions(-) create mode 100644 packages/player-web/src/scene/default/phantom/tear.test.ts create mode 100644 packages/player-web/src/scene/default/phantom/tear.ts diff --git a/.changeset/be-music-skins.md b/.changeset/be-music-skins.md index e2590564..eb9d466c 100644 --- a/.changeset/be-music-skins.md +++ b/.changeset/be-music-skins.md @@ -7,7 +7,7 @@ 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). - New exports: `BeMusicSkin` and its context types, `createBeMusicSkinRegistry`, `resolveBeMusicLaneKind`, `resolveSelectListWindow`, `phantomSkin`, `synesthesiaSkin`, `latticeSkin`, and `BUILT_IN_BE_MUSIC_SKINS`. -- Phantom is the existing poster skin, unchanged in look and still the default. +- Phantom is the existing poster skin and still the default. 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 half and a black-and-white starburst half; and the result wipe tears away along a ripped edge with RESULT cut from magazines. - Synesthesia is inspired by the sound-and-light games of Tetsuya Mizuguchi, after Rez Infinite's Area X: a black void lit by an ember horizon, a world built from fine ember / gold particles with electric blue and magenta accents, data dust and speed streaks pouring out of the vanishing point, a floor of light points, rivers of particles, point-cloud pyramids, and a floating point-cloud figure. 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 orbits the figure. Hits detonate as perspective-projected 3D particle fountains with voxel sparks, motion trails, tilted shockwave rings, 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 a 3D particle ring that bursts 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. - 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. diff --git a/packages/player-web/src/scene/default/gameplay-render.ts b/packages/player-web/src/scene/default/gameplay-render.ts index 65a4831d..82569c02 100644 --- a/packages/player-web/src/scene/default/gameplay-render.ts +++ b/packages/player-web/src/scene/default/gameplay-render.ts @@ -116,9 +116,10 @@ export function renderDefaultGameplayFrame( frontLayer.addChild(front); } drawStatusBar(front, frontLayer, runtime, frontPool); - drawJudgements(frontLayer, runtime, playfield, frontPool, effects.amount); - // Showpieces (count-in, combo milestones, clear line, full combo) sit above everything else. - drawPhantomMoments(layer, frontLayer, runtime, 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); + if (!covered) drawJudgements(frontLayer, runtime, playfield, frontPool, effects.amount); } /** diff --git a/packages/player-web/src/scene/default/phantom-style.test.ts b/packages/player-web/src/scene/default/phantom-style.test.ts index e653cf6d..295498b1 100644 --- a/packages/player-web/src/scene/default/phantom-style.test.ts +++ b/packages/player-web/src/scene/default/phantom-style.test.ts @@ -6,9 +6,12 @@ import { hash01, layoutTabularRun, parallelogramPoints, + ransomLayout, rollUpValue, stageProgress, starburstPoints, + tornBandPoints, + tornEdgePoints, } from './phantom-style.ts'; describe('parallelogramPoints', () => { @@ -158,3 +161,64 @@ describe('rollUpValue', () => { expect(rollUpValue(Number.NaN, 1)).toBe(0); }); }); + +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 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('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('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('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); + } + }); + + 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('is deterministic for a seed', () => { + expect(ransomLayout('GO', 1)).toEqual(ransomLayout('GO', 1)); + }); +}); diff --git a/packages/player-web/src/scene/default/phantom-style.ts b/packages/player-web/src/scene/default/phantom-style.ts index 8836a420..b1560090 100644 --- a/packages/player-web/src/scene/default/phantom-style.ts +++ b/packages/player-web/src/scene/default/phantom-style.ts @@ -246,3 +246,108 @@ export function drawStaves( if (start < x1) graphics.rect(start, lineY, x1 - start, 1).fill({ color, alpha }); } } + +/** + * 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/scene/default/phantom/moments.ts b/packages/player-web/src/scene/default/phantom/moments.ts index d39b6bb2..caa7705e 100644 --- a/packages/player-web/src/scene/default/phantom/moments.ts +++ b/packages/player-web/src/scene/default/phantom/moments.ts @@ -5,6 +5,7 @@ import type { ChildPool } from '../../pixi-utils.ts'; import { DEFAULT_DISPLAY_FONT } from '../fonts.ts'; import { addHudText } from '../hud-text.ts'; import { effectProfile, impulse, momentProgress, trackMoments } from '../moments.ts'; +import { addRansomText, drawTearStrip, stripPoint, type GlyphFactory, type TearStrip } from './tear.ts'; import { PHANTOM_GOLD, PHANTOM_INK, @@ -28,19 +29,21 @@ const SKEW = -0.18; /** * Phantom showpieces, painted on the front layer above the HUD: * - * - the count-in: an ink slab slashes across the screen with "READY?", flips red and slams "GO!!", then tears away - * to the right as the first beat lands; - * - every 100 combo: a red banner with a gold burst cuts in over the BGA side (never over the lanes); + * - 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! tag pops off the gauge with a flash; - * - a full combo: a white flash, a giant turning burst, confetti, and FULL COMBO stamped across the screen (the chart - * is over, so it may cover the playfield). + * - 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: SkinlessGameplayChromeRuntime, pool: ChildPool | undefined, -): void { +): boolean { const nowMs = runtime.nowMs ?? 0; const moments = trackMoments(chromeLayer, { nowMs, @@ -55,7 +58,7 @@ export function drawPhantomMoments( poor: runtime.poor ?? 0, }); const effects = effectProfile(runtime.effects); - if (!effects.enabled) return; + if (!effects.enabled) return false; const graphics = pool?.acquireGraphics() ?? new Graphics(); graphics.label = 'phantom/moments'; if (!pool) layer.addChild(graphics); @@ -80,7 +83,7 @@ export function drawPhantomMoments( } 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); + drawMilestone(graphics, layer, moments.milestone.value, milestone, runtime.hasBga === true, pool, nowMs); } const clear = momentProgress(moments.clearAtMs, nowMs, CLEAR_MS); if (clear !== undefined) { @@ -90,6 +93,7 @@ export function drawPhantomMoments( if (fullCombo !== undefined) { drawFullCombo(graphics, layer, fullCombo, nowMs, effects.screenWide, pool); } + return fullCombo !== undefined && fullCombo > 0.04 && fullCombo < 0.9; } const BREAK_MS = 700; @@ -153,55 +157,38 @@ function drawComboBreak( function drawCountIn(graphics: Graphics, layer: Container, chartMs: number, pool: ChildPool | undefined): void { if (chartMs < -2800 || chartMs > 450) return; const cy = 236; - const enter = easeOutCubic(stageProgress(chartMs, -2800, 320)); - const exit = easeOutCubic(stageProgress(chartMs, 0, 420)); - const go = chartMs >= -650; - const shift = (1 - enter) * -DESIGN_WIDTH * 1.2 + exit * DESIGN_WIDTH * 1.3; - const alpha = 1 - exit; - // Shadow slab, main slab, and paper cut lines. - graphics - .poly(parallelogramPoints(shift - 60, cy - 62, DESIGN_WIDTH + 120, 124, 40)) - .fill({ color: go ? PHANTOM_INK : PHANTOM_RED, alpha: 0.9 * alpha }); - graphics - .poly(parallelogramPoints(shift - 60, cy - 52, DESIGN_WIDTH + 120, 104, 40)) - .fill({ color: go ? PHANTOM_RED : PHANTOM_INK, alpha: alpha }); - graphics - .poly(parallelogramPoints(shift - 60, cy - 56, DESIGN_WIDTH + 120, 3, 40)) - .fill({ color: PHANTOM_WHITE, alpha }); - graphics - .poly(parallelogramPoints(shift - 60, cy + 50, DESIGN_WIDTH + 120, 3, 40)) - .fill({ color: PHANTOM_WHITE, alpha }); - if (!go) { + 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.6; - addHudText( - layer, - 'READY?', - DESIGN_WIDTH / 2 + shift + shake, - cy, - { - size: 64, - fill: PHANTOM_WHITE, - fontFamily: DEFAULT_DISPLAY_FONT, - letterSpacing: 6, - anchorX: 0.5, - anchorY: 0.5, - skewX: SKEW, - dropShadow: { color: PHANTOM_RED, alpha: 1, blur: 0, distance: 6, angle: Math.PI / 4 }, - }, - pool, - ); - return; + 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; + const scale = (2.2 - 1.2 * slam) * Math.min(1, goOpen); graphics - .poly(starburstPoints(DESIGN_WIDTH / 2 + shift, cy, 150 * scale, 96 * scale, 16, chartMs / 900, 0.18, 7)) - .fill({ color: PHANTOM_INK, alpha: 0.55 * alpha }); + .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 + shift, + DESIGN_WIDTH / 2, cy, { size: 88, @@ -211,16 +198,29 @@ function drawCountIn(graphics: Graphics, layer: Container, chartMs: number, pool 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); - text.alpha = alpha; } -/** "100 COMBO!!" banner cutting in over the BGA side. */ +/** 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, @@ -228,54 +228,55 @@ function drawMilestone( t: number, hasBga: boolean, pool: ChildPool | undefined, + nowMs: number, ): void { - const inT = easeOutCubic(Math.min(1, t / 0.14)); - const outT = easeOutCubic(Math.max(0, (t - 0.8) / 0.2)); - const x = 300 + (1 - inT) * 380 + outT * 380; - // Over a live BGA the banner rides the monitor's bottom edge instead of covering the middle of the video. - const y = hasBga ? BGA.y + BGA.h - 22 : 196; - graphics.poly(parallelogramPoints(x + 8, y + 8, 360, 58, 18)).fill(PHANTOM_INK); - graphics - .poly(parallelogramPoints(x, y, 360, 58, 18)) - .fill(PHANTOM_RED) - .stroke({ color: PHANTOM_WHITE, width: 3 }); - const burstX = x + 58; + // Over a live BGA the strip rides the monitor's bottom edge instead of covering the middle of the video. + const strip: TearStrip = hasBga + ? { cx: BGA.x + BGA.w / 2 + 40, cy: BGA.y + BGA.h - 14, angle: -0.07, length: 440, thickness: 54, seed: value } + : { cx: 462, cy: 200, angle: -0.12, length: 440, thickness: 86, seed: value }; + const open = drawTearStrip(graphics, strip, t, nowMs); + if (open <= 0.05) return; + const size = Math.min(1, open); + const burst = stripPoint(strip, -128, 0); graphics - .poly(starburstPoints(burstX, y + 29, 50, 32, 12, t * 3, 0.22, value)) + .poly( + starburstPoints( + burst.x, + burst.y, + 50 * size * (hasBga ? 0.8 : 1), + 32 * size * (hasBga ? 0.8 : 1), + 12, + t * 3, + 0.22, + value, + ), + ) .fill(PHANTOM_GOLD) .stroke({ color: PHANTOM_INK, width: 3 }); addHudText( layer, String(value), - burstX + 2, - y + 29, + burst.x + 2, + burst.y, { - size: 34, + size: hasBga ? 28 : 34, fill: PHANTOM_INK, fontFamily: DEFAULT_DISPLAY_FONT, anchorX: 0.5, anchorY: 0.5, skewX: SKEW, + rotation: strip.angle, maxWidth: 70, }, pool, - ); - addHudText( - layer, - 'COMBO!!', - x + 118, - y + 29, - { - size: 40, - fill: PHANTOM_WHITE, - fontFamily: DEFAULT_DISPLAY_FONT, - letterSpacing: 3, - anchorY: 0.5, - skewX: SKEW, - dropShadow: { color: PHANTOM_INK, alpha: 1, blur: 0, distance: 4, angle: Math.PI / 4 }, - }, - pool, - ); + ).scale.y *= size; + const word = stripPoint(strip, 48, 0); + addRansomText(graphics, hudGlyphs(layer, pool), 'COMBO!!', word.x, word.y, { + size: (hasBga ? 30 : 40) * 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! tag popping off it. */ @@ -349,31 +350,17 @@ function drawFullCombo( 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 }); } - const slam = easeOutBack(stageProgress(t, 0.06, 0.12)); - graphics - .poly(parallelogramPoints(-40, cy - 46, DESIGN_WIDTH + 80, 92, 30)) - .fill({ color: PHANTOM_INK, alpha: 0.8 * fadeOut * Math.min(1, slam) }); - const text = addHudText( - layer, - 'FULL COMBO', - cx, - cy, - { - size: 72, - fill: PHANTOM_GOLD, - fontFamily: DEFAULT_DISPLAY_FONT, - letterSpacing: 4, - anchorX: 0.5, - anchorY: 0.5, - skewX: SKEW, - stroke: { color: PHANTOM_INK, width: 8, alignment: 0.5, join: 'miter' }, - dropShadow: { color: PHANTOM_RED, alpha: 1, blur: 0, distance: 7, angle: Math.PI / 4 }, - maxWidth: DESIGN_WIDTH - 60, - }, - pool, - ); - text.scale.set(Math.max(0.01, 2.4 - 1.4 * slam)); - text.alpha = fadeOut * Math.min(1, slam * 1.5); + // 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[] { diff --git a/packages/player-web/src/scene/default/phantom/result.ts b/packages/player-web/src/scene/default/phantom/result.ts index 43ef8fa0..1bab9b48 100644 --- a/packages/player-web/src/scene/default/phantom/result.ts +++ b/packages/player-web/src/scene/default/phantom/result.ts @@ -23,7 +23,9 @@ import { rollUpValue, stageProgress, starburstPoints, + tornEdgePoints, } from '../phantom-style.ts'; +import { addRansomText, type GlyphFactory } from './tear.ts'; /** Default-family result entrance timeline (ms from scene start): counters roll up, then the rank badge lands. */ const RESULT_ROLL_DELAY_MS = 760; @@ -370,23 +372,31 @@ export function renderPhantomResult(frame: BeMusicResultFrame): void { }); slideIn(footer.root, 420, 0, 50); - // Entrance wipe: the screen opens behind an ink slab carrying a giant RESULT slug that tears away to the right with a - // red leading edge, uncovering the panels as they fly in. + // 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); - wipe.g - .poly([edge + 120, 0, designWidth + 200, 0, designWidth + 200, designHeight, edge, designHeight]) - .fill(PHANTOM_INK); - wipe.g.poly([edge + 80, 0, edge + 128, 0, edge + 8, designHeight, edge - 40, designHeight]).fill(PHANTOM_RED); - wipe.g.poly([edge + 136, 0, edge + 142, 0, edge + 22, designHeight, edge + 16, designHeight]).fill(PHANTOM_WHITE); - addText(wipe.root, 'RESULT', edge + 150 + designWidth / 2, designHeight / 2, { - ...display(120, PHANTOM_WHITE), - letterSpacing: 8, - anchorX: 0.5, - anchorY: 0.5, + 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, }); } } diff --git a/packages/player-web/src/scene/default/phantom/select.ts b/packages/player-web/src/scene/default/phantom/select.ts index f79f2bd5..af475679 100644 --- a/packages/player-web/src/scene/default/phantom/select.ts +++ b/packages/player-web/src/scene/default/phantom/select.ts @@ -26,12 +26,14 @@ import { parallelogramPoints, stageProgress, starburstPoints, + tornEdgePoints, } from '../phantom-style.ts'; import { addHitArea, addSkinText, formatPlayVariantLabel, type SkinTextOptions } from '../skin-text.ts'; +import { addRansomText, type GlyphFactory } from './tear.ts'; const LAYOUT: BeMusicSelectLayout = { listX: 320, listTop: 54, listBottomInset: 26, rowHeight: 28 }; const SLIDE_MS = 240; -const OUTRO_MS = 620; +const OUTRO_MS = 860; const INTRO_STAGGER_MS = 40; const KICKER_PITCH = 20; const GLINT_W = 14; @@ -111,50 +113,103 @@ class PhantomSelectRenderer implements BeMusicSelectRenderer { } /** - * Launch outro: an ink slab and a red slab slash in from the right to cover the screen, the chosen title slams onto - * the ink, and LET'S GO! punches in beneath it — the handoff into the gameplay count-in. + * Launch outro — the split screen: a blood-red half full of out-of-focus bokeh slides in from the left while a + * black-and-white starburst half 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: BeMusicSelectFrame): void { const t = Math.min(1, (frame.nowMs - (frame.launchAt ?? frame.nowMs)) / OUTRO_MS); - const { designWidth, designHeight, layer } = frame; + const { designWidth: w, designHeight: h, layer, nowMs } = frame; const g = new Graphics(); g.label = 'default-select/outro'; - const cover = easeOutCubic(Math.min(1, t / 0.45)); - const redEdge = designWidth + 80 - cover * (designWidth + 260); - g.poly([redEdge, 0, designWidth + 200, 0, designWidth + 200, designHeight, redEdge - 140, designHeight]).fill( - PHANTOM_RED, - ); - const inkEdge = redEdge + 70; - g.poly([inkEdge, 0, designWidth + 200, 0, designWidth + 200, designHeight, inkEdge - 140, designHeight]).fill( - PHANTOM_INK, - ); - g.poly([inkEdge - 6, 0, inkEdge, 0, inkEdge - 140, designHeight, inkEdge - 146, designHeight]).fill(PHANTOM_WHITE); layer.addChild(g); - const slam = easeOutBack(stageProgress(t, 0.35, 0.3)); - if (slam > 0) { - const title = addSkinText(layer, frame.focusedSong?.title ?? '', designWidth / 2 + 30, designHeight / 2 - 26, { - size: 30, + const meet = easeOutCubic(Math.min(1, t / 0.32)); + // The seam: a torn line leaning right, from (seamTop, 0) to (seamBottom, h). + const seamTop = w * 0.6; + const seamBottom = w * 0.44; + 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.84 + 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.72; + 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: designWidth - 120, - alpha: Math.min(1, slam * 2), - }); - title.scale.set(title.scale.x * (1.6 - 0.6 * slam), 1.6 - 0.6 * slam); - const go = addSkinText(layer, "LET'S GO!", designWidth / 2 + 30, designHeight / 2 + 32, { + 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.7, h * 0.56, { size: 44, - fill: PHANTOM_GOLD, - fontFamily: DEFAULT_DISPLAY_FONT, - letterSpacing: 3, - skewX: -0.18, - anchorX: 0.5, - anchorY: 0.5, - stroke: { color: PHANTOM_INK, width: 6, alignment: 0.5, join: 'miter' }, - dropShadow: { color: PHANTOM_RED, distance: 5 }, - alpha: Math.min(1, slam * 2), + seed: 17, + angle: -0.1, + appear: (index) => stageProgress(t, 0.38 + index * 0.035, 0.12), }); - go.scale.set(2 - slam); + } + // 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); } } diff --git a/packages/player-web/src/scene/default/phantom/tear.test.ts b/packages/player-web/src/scene/default/phantom/tear.test.ts new file mode 100644 index 00000000..c2febccc --- /dev/null +++ b/packages/player-web/src/scene/default/phantom/tear.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vite-plus/test'; +import { stripPoint, tearOpenness } 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); + }); +}); diff --git a/packages/player-web/src/scene/default/phantom/tear.ts b/packages/player-web/src/scene/default/phantom/tear.ts new file mode 100644 index 00000000..0bc35cfe --- /dev/null +++ b/packages/player-web/src/scene/default/phantom/tear.ts @@ -0,0 +1,168 @@ +import type { Graphics, Text } from 'pixi.js'; +import { DEFAULT_DISPLAY_FONT, DEFAULT_HEADLINE_FONT, DEFAULT_TEXT_FONT } from '../fonts.ts'; +import { + PHANTOM_INK, + PHANTOM_PAPER, + PHANTOM_RED, + PHANTOM_WHITE, + easeOutBack, + easeOutCubic, + hash01, + ransomLayout, + tornBandPoints, + type RansomPaper, +} from '../phantom-style.ts'; + +/** + * 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): 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; + const slide = (1 - enter) * -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 + 200; + 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, u + 40 + 60 * hash01(line + 5), 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`. + */ +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 }, +): 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: 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 + 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(CARD_FILL[entry.paper]); + 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); + }); +} From 8e5594b4b865cc2d1f85a34843e85ca929c6c249 Mon Sep 17 00:00:00 2001 From: Seiya KONNO Date: Tue, 29 Sep 2026 02:48:08 +0900 Subject: [PATCH 08/65] fix(player-web): centre built-in skin text on its capitals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pixi measures a line box from the ink of '|ÉqÅM', so top-heavy faces such as Anton and Michroma rendered noticeably below their anchor. Measure each face's cap-centre offset once and cancel it through the text pivot, which also survives per-letter repositioning, rotation, and scaling. --- .changeset/be-music-skins.md | 1 + .../player-web/src/scene/default/hud-text.ts | 2 + .../src/scene/default/phantom/result.ts | 4 + .../player-web/src/scene/default/skin-text.ts | 2 + .../src/scene/default/text-metrics.test.ts | 36 +++++++++ .../src/scene/default/text-metrics.ts | 79 +++++++++++++++++++ 6 files changed, 124 insertions(+) create mode 100644 packages/player-web/src/scene/default/text-metrics.test.ts create mode 100644 packages/player-web/src/scene/default/text-metrics.ts diff --git a/.changeset/be-music-skins.md b/.changeset/be-music-skins.md index eb9d466c..b9b5f37a 100644 --- a/.changeset/be-music-skins.md +++ b/.changeset/be-music-skins.md @@ -10,6 +10,7 @@ The default (skinless) family now renders through a `BeMusicSkin`: one object bu - Phantom is the existing poster skin and still the default. 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 half and a black-and-white starburst half; and the result wipe tears away along a ripped edge with RESULT cut from magazines. - Synesthesia is inspired by the sound-and-light games of Tetsuya Mizuguchi, after Rez Infinite's Area X: a black void lit by an ember horizon, a world built from fine ember / gold particles with electric blue and magenta accents, data dust and speed streaks pouring out of the vanishing point, a floor of light points, rivers of particles, point-cloud pyramids, and a floating point-cloud figure. 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 orbits the figure. Hits detonate as perspective-projected 3D particle fountains with voxel sparks, motion trails, tilted shockwave rings, 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 a 3D particle ring that bursts 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. +- 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 detonates a ring of light), a cut-in every 100 combo, a flourish when the gauge crosses the clear line, and a full-combo finale (Phantom's turning burst with confetti, Synesthesia's 3D spark sphere and prism rings). Phantom's result screen opens with an ink wipe carrying a giant RESULT slug. diff --git a/packages/player-web/src/scene/default/hud-text.ts b/packages/player-web/src/scene/default/hud-text.ts index 1faed641..2d2ba6d8 100644 --- a/packages/player-web/src/scene/default/hud-text.ts +++ b/packages/player-web/src/scene/default/hud-text.ts @@ -3,6 +3,7 @@ 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 { 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 @@ -50,6 +51,7 @@ export function addHudText( node.style = style; } 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. diff --git a/packages/player-web/src/scene/default/phantom/result.ts b/packages/player-web/src/scene/default/phantom/result.ts index 1bab9b48..3e007d47 100644 --- a/packages/player-web/src/scene/default/phantom/result.ts +++ b/packages/player-web/src/scene/default/phantom/result.ts @@ -25,6 +25,7 @@ import { starburstPoints, tornEdgePoints, } from '../phantom-style.ts'; +import { alignCapCenter } from '../text-metrics.ts'; import { addRansomText, type GlyphFactory } from './tear.ts'; /** Default-family result entrance timeline (ms from scene start): counters roll up, then the rank badge lands. */ @@ -85,6 +86,7 @@ export function renderPhantomResult(frame: BeMusicResultFrame): void { }), }); 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) { @@ -425,6 +427,7 @@ function renderMetric( }), }); labelText.skew.set(-0.18, 0); + alignCapCenter(labelText, DEFAULT_DISPLAY_FONT, '400', 10); labelText.position.set(x + 12, y + 5); target.addChild(labelText); @@ -438,6 +441,7 @@ function renderMetric( }); valueText.skew.set(-0.18, 0); valueText.anchor.set(1, 0.5); + alignCapCenter(valueText, DEFAULT_DISPLAY_FONT, '400', 22); valueText.position.set(x + 166, y + 19); if (valueText.width > 100) { valueText.scale.x = 100 / valueText.width; diff --git a/packages/player-web/src/scene/default/skin-text.ts b/packages/player-web/src/scene/default/skin-text.ts index 004a5fe4..e08deeb5 100644 --- a/packages/player-web/src/scene/default/skin-text.ts +++ b/packages/player-web/src/scene/default/skin-text.ts @@ -3,6 +3,7 @@ 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'; +import { alignCapCenter } from './text-metrics.ts'; export interface SkinTextOptions { size?: number; @@ -53,6 +54,7 @@ export function addSkinText(layer: Container, text: string, x: number, y: number }), }); 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; diff --git a/packages/player-web/src/scene/default/text-metrics.test.ts b/packages/player-web/src/scene/default/text-metrics.test.ts new file mode 100644 index 00000000..1c4b5004 --- /dev/null +++ b/packages/player-web/src/scene/default/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/scene/default/text-metrics.ts b/packages/player-web/src/scene/default/text-metrics.ts new file mode 100644 index 00000000..fa4e5e65 --- /dev/null +++ b/packages/player-web/src/scene/default/text-metrics.ts @@ -0,0 +1,79 @@ +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(); +let context: CanvasRenderingContext2D | null | undefined; + +/** + * 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; + if (typeof document === 'undefined') return 0; + 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); + 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; +} From ec0bb2d5d1e8bba7eaccb3cc4e3d459d193dbf7c Mon Sep 17 00:00:00 2001 From: Seiya KONNO Date: Tue, 29 Sep 2026 14:38:42 +0900 Subject: [PATCH 09/65] feat(player-web): pass live audio analysis to be-music skins Tap the gameplay bus output and the select BGM / chart preview with an AnalyserNode and hand skins a per-frame BeMusicAudioFrame: loudness, bass / mid / high energy, a 16-band log spectrum, and spectral-flux onsets. audioDrive scales it by the effects level for built-in skins. --- .../src/runtime/audio-analysis.test.ts | 88 +++++++ .../player-web/src/runtime/audio-analysis.ts | 223 ++++++++++++++++++ .../player-web/src/scene/core/gameplay.ts | 15 ++ packages/player-web/src/scene/core/select.ts | 10 +- .../src/scene/default/audio-drive.test.ts | 70 ++++++ .../src/scene/default/audio-drive.ts | 59 +++++ .../player-web/src/scene/gameplay-chrome.ts | 3 + .../player-web/src/skin/be-music/types.ts | 15 +- 8 files changed, 481 insertions(+), 2 deletions(-) create mode 100644 packages/player-web/src/runtime/audio-analysis.test.ts create mode 100644 packages/player-web/src/runtime/audio-analysis.ts create mode 100644 packages/player-web/src/scene/default/audio-drive.test.ts create mode 100644 packages/player-web/src/scene/default/audio-drive.ts 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..11681e0d --- /dev/null +++ b/packages/player-web/src/runtime/audio-analysis.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vite-plus/test'; +import { + AUDIO_BAND_COUNT, + SILENT_AUDIO, + analyzeAudioFrame, + createAudioAnalysisState, + logBandBins, + 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); + }); +}); 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..9ad31d44 --- /dev/null +++ b/packages/player-web/src/runtime/audio-analysis.ts @@ -0,0 +1,223 @@ +/** + * 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. + */ + +/** Number of log-spaced spectrum bands in {@link AudioFeatures.bands}. */ +export const AUDIO_BAND_COUNT = 16; + +export interface AudioFeatures { + /** 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; +} + +export const SILENT_AUDIO: AudioFeatures = 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, +}); + +/** 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; +} + +export interface AudioAnalysisState { + bands: number[]; + 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), + 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); + for (let band = 0; band < AUDIO_BAND_COUNT; band += 1) { + state.bands[band] = follow(state.bands[band]!, raw[band]!, 0.18); + } + state.bass = follow(state.bass, average(20, 150), 0.2); + state.mid = follow(state.mid, average(150, 2000), 0.2); + state.high = follow(state.high, average(2000, 12000), 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 2e03f6fc..44beef92 100644 --- a/packages/player-web/src/scene/core/gameplay.ts +++ b/packages/player-web/src/scene/core/gameplay.ts @@ -75,6 +75,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'; @@ -725,6 +726,10 @@ export class CoreGameplayView` declares the chart's master volume scaling (100 = unity, 80 = 80 % loud, > 100 @@ -3025,6 +3036,7 @@ export class CoreGameplayView this.renderThemeLayer(DESIGN_WIDTH, DESIGN_HEIGHT)); this.perf.time('renderBga', () => this.renderBga(seconds)); this.perf.time('renderLanes', () => this.renderLanes(DESIGN_WIDTH, DESIGN_HEIGHT)); @@ -3180,6 +3192,7 @@ export class CoreGameplayView this.render()); } if (this.beMusicBackHolder.visible) { - this.beMusicRenderer?.tick(now, this.focusedSong(), this.launchAt); + this.beMusicRenderer?.tick(now, this.focusedSong(), this.launchAt, this.selectAudioAnalyzer?.sample(now)); } } const report = this.perf.endFrame(() => ({ @@ -1352,6 +1355,8 @@ export class CoreSongSelectView undefined); this.selectBgmContext = undefined; this.selectBgmGain = undefined; @@ -1654,6 +1659,8 @@ export class CoreSongSelectView { diff --git a/packages/player-web/src/scene/default/audio-drive.test.ts b/packages/player-web/src/scene/default/audio-drive.test.ts new file mode 100644 index 00000000..2fa4976a --- /dev/null +++ b/packages/player-web/src/scene/default/audio-drive.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vite-plus/test'; +import { SILENT_AUDIO } from '../../runtime/audio-analysis.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('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/player-web/src/scene/default/audio-drive.ts b/packages/player-web/src/scene/default/audio-drive.ts new file mode 100644 index 00000000..357c3fce --- /dev/null +++ b/packages/player-web/src/scene/default/audio-drive.ts @@ -0,0 +1,59 @@ +import { AUDIO_BAND_COUNT, SILENT_AUDIO, type AudioFeatures } from '../../runtime/audio-analysis.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: AudioFeatures | 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 treble tilt (music carries far less energy + * up top, so higher columns are boosted) and the floor / ceiling stretched so bars use their full height. + */ +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.9 * position); + return Math.max(0, Math.min(1, (tilted - 0.2) / 0.72)); +} diff --git a/packages/player-web/src/scene/gameplay-chrome.ts b/packages/player-web/src/scene/gameplay-chrome.ts index c1cd445d..99a64a83 100644 --- a/packages/player-web/src/scene/gameplay-chrome.ts +++ b/packages/player-web/src/scene/gameplay-chrome.ts @@ -1,5 +1,6 @@ import type { ChartPlayVariant } from '@be-music/player/core/lane-layout'; import type { Container } from 'pixi.js'; +import type { AudioFeatures } from '../runtime/audio-analysis.ts'; import type { ChildPool } from './pixi-utils.ts'; /** @@ -73,6 +74,8 @@ export interface SkinlessGameplayChromeRuntime { impulseKind?: 'white' | 'black' | 'scratch'; /** Showmanship level the chrome should render at (defaults to `'full'`). */ effects?: 'full' | 'reduced' | 'off'; + /** Live analysis of the mix for audio-reactive chrome (loudness, spectrum, onsets); absent without Web Audio. */ + audio?: AudioFeatures; } export interface SkinlessGameplayChromeRenderContext { diff --git a/packages/player-web/src/skin/be-music/types.ts b/packages/player-web/src/skin/be-music/types.ts index ed9f6d7b..b1bc8003 100644 --- a/packages/player-web/src/skin/be-music/types.ts +++ b/packages/player-web/src/skin/be-music/types.ts @@ -3,6 +3,7 @@ import type { BrowserBrowseEntry, BrowserSongEntry } from '../../collection/type 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'; +import type { AudioFeatures } from '../../runtime/audio-analysis.ts'; /** * be-music skin format — the code-defined skins the built-in (default) family renders with when no LR2 / beatoraja @@ -38,6 +39,14 @@ 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'; +/** + * Live analysis of what is playing, sampled once per frame: loudness (`level` / `peak` / `db`), `bass` / `mid` / + * `high` energy, a 16-band log-spaced spectrum (`bands`, ≈ 30 Hz → 14 kHz), and an `onset` envelope that jumps to 1 + * on each detected transient. Gameplay taps the whole mix; select taps the BGM and chart preview. Absent when the + * host has no Web Audio. + */ +export type BeMusicAudioFrame = AudioFeatures; + export interface BeMusicGameplaySkin { /** HUD chrome around the playfield (header, gauge, score, BGA frame, judgement / combo text). */ readonly renderChrome: SkinlessGameplayChromeRenderer; @@ -74,6 +83,8 @@ export interface BeMusicLanesContext { /** Current combo — lets skins escalate the playfield as a run builds. */ combo?: number; effects?: BeMusicEffectLevel; + /** What is playing right now (see {@link BeMusicAudioFrame}). */ + audio?: BeMusicAudioFrame; } export interface BeMusicNoteContext { @@ -119,6 +130,8 @@ export interface BeMusicBombsContext { /** Current combo — lets skins escalate hit effects as a run builds. */ combo?: number; effects?: BeMusicEffectLevel; + /** What is playing right now (see {@link BeMusicAudioFrame}). */ + audio?: BeMusicAudioFrame; } /** Song-list geometry shared by the select renderer (drawing) and the scene (row hit-testing). */ @@ -184,7 +197,7 @@ export interface BeMusicSelectRenderer { */ render(frame: BeMusicSelectFrame): boolean; /** Per-frame, transform-only animation of the persistent layers. */ - tick(nowMs: number, focusedSong: BrowserSongEntry | undefined, launchAt?: number): void; + tick(nowMs: number, focusedSong: BrowserSongEntry | undefined, launchAt?: number, audio?: BeMusicAudioFrame): void; dispose(): void; } From f4f853fff249de805d7990defe05e5f4ecd59005 Mon Sep 17 00:00:00 2001 From: Seiya KONNO Date: Tue, 29 Sep 2026 14:38:42 +0900 Subject: [PATCH 10/65] feat(player-web): make the built-in skins react to the music Phantom pumps its halftone and bursts on the bass and adds spectrum strips; Synesthesia drives dust, haze, floor, rivers, and fish schools from the mix and rays a spectrum halo round the figure; Lattice turns its needle field and idle monitor into equalizers, ripples and strikes pendulums on onsets, and shows a live spectrum with a dB readout. --- .changeset/be-music-skins.md | 1 + .../src/scene/default/gameplay-render.ts | 54 +++++++-- .../src/scene/default/lattice/field.test.ts | 19 ++++ .../src/scene/default/lattice/field.ts | 20 ++++ .../scene/default/lattice/gameplay-chrome.ts | 105 ++++++++++++++++-- .../src/scene/default/lattice/select.ts | 40 ++++++- .../src/scene/default/phantom/select.ts | 55 ++++++++- .../src/scene/default/synesthesia/draw.ts | 44 ++++++++ .../default/synesthesia/gameplay-chrome.ts | 45 +++++--- .../scene/default/synesthesia/playfield.ts | 6 +- .../src/scene/default/synesthesia/select.ts | 48 ++++++-- 11 files changed, 384 insertions(+), 53 deletions(-) diff --git a/.changeset/be-music-skins.md b/.changeset/be-music-skins.md index b9b5f37a..ee26653e 100644 --- a/.changeset/be-music-skins.md +++ b/.changeset/be-music-skins.md @@ -10,6 +10,7 @@ The default (skinless) family now renders through a `BeMusicSkin`: one object bu - Phantom is the existing poster skin and still the default. 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 half and a black-and-white starburst half; and the result wipe tears away along a ripped edge with RESULT cut from magazines. - Synesthesia is inspired by the sound-and-light games of Tetsuya Mizuguchi, after Rez Infinite's Area X: a black void lit by an ember horizon, a world built from fine ember / gold particles with electric blue and magenta accents, data dust and speed streaks pouring out of the vanishing point, a floor of light points, rivers of particles, point-cloud pyramids, and a floating point-cloud figure. 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 orbits the figure. Hits detonate as perspective-projected 3D particle fountains with voxel sparks, motion trails, tilted shockwave rings, 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 a 3D particle ring that bursts 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 `renderChrome` (as `runtime.audio`), `renderLanes` and `renderBombs` (as `audio`), and the select renderer's `tick` (fourth argument). Gameplay analyses the whole mix off the audio bus output; select analyses the BGM and chart preview. 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, with a spectrum halo around the figure; 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. - 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. diff --git a/packages/player-web/src/scene/default/gameplay-render.ts b/packages/player-web/src/scene/default/gameplay-render.ts index 82569c02..c82b34bc 100644 --- a/packages/player-web/src/scene/default/gameplay-render.ts +++ b/packages/player-web/src/scene/default/gameplay-render.ts @@ -29,6 +29,7 @@ import { starburstPoints, } from './phantom-style.ts'; import type { ChildPool } from '../pixi-utils.ts'; +import { audioDrive, bandLevel, type AudioDrive } from './audio-drive.ts'; import { drawPhantomMoments } from './phantom/moments.ts'; import { effectProfile, impulse, punchScale } from './moments.ts'; import { addHudNumber as addNumber, addHudText as addText, type HudTextOptions as TextOptions } from './hud-text.ts'; @@ -84,7 +85,9 @@ export function renderDefaultGameplayFrame( const hasBga = runtime.hasBga === true; const playfield = resolveFallbackPlayfieldLayout(runtime.laneChannels, runtime.laneCount, runtime.playVariant); 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); + drawBackground(frame, hasBga, drive); const effects = effectProfile(runtime.effects); drawPlayfield( @@ -95,11 +98,12 @@ export function renderDefaultGameplayFrame( ); // A double-play field reaches into the monitor's column; the frame and its idle screen would sit on the 2P lanes. if (playfield.right + 24 <= BGA.x) { - drawBgaFrame(frame, layer, hasBga, runtime.nowMs, layerPool); + drawBgaFrame(frame, layer, hasBga, runtime.nowMs, drive, layerPool); + drawSpectrumStrip(frame, drive); } drawGauge(frame, layer, runtime, layerPool); drawSongPlate(frame, layer, runtime, layerPool); - drawScorePlate(frame, layer, runtime, layerPool); + drawScorePlate(frame, layer, runtime, drive, layerPool); const tallyX = resolveJudgeTallyX(playfield); if (tallyX !== undefined) { drawJudgeTally(frame, layer, runtime, tallyX, layerPool); @@ -136,7 +140,7 @@ export const renderFallbackLr2Frame: typeof renderDefaultGameplayFrame = renderD * 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 { +function drawBackground(frame: Graphics, hasBga: boolean, drive: AudioDrive): void { fillRectAroundHole(frame, 0, 0, DESIGN_WIDTH, DESIGN_HEIGHT, PHANTOM_BLACK, hasBga); // Faint diagonal pinstripes over the lower-left quadrant — gives the ink ground a printed texture. for (let stripe = 0; stripe < 9; stripe += 1) { @@ -158,7 +162,7 @@ function drawBackground(frame: Graphics, hasBga: boolean): void { w: DESIGN_WIDTH - 300, h: DESIGN_HEIGHT - 340, pitch: 9, - maxRadius: 4.2, + maxRadius: 4.2 * (1 + 0.45 * drive.bass), direction: { x: 1, y: 1 }, })) { if (isInsideFloorWedge(dot.x, dot.y)) { @@ -331,6 +335,7 @@ function drawBgaFrame( layer: Container, hasBga: boolean, nowMs: number | undefined, + drive: AudioDrive, pool: ChildPool | undefined, ): void { const slant = 6; @@ -356,17 +361,21 @@ function drawBgaFrame( w: BGA.w - 8, h: BGA.h - 8, pitch: 12, - maxRadius: 5, + maxRadius: 5 * (1 + 0.5 * drive.level), 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; + // It pumps on the bass and jolts a notch round on every onset. + const spin = (nowMs !== undefined ? nowMs / 6000 : 0) + 0.1 * drive.onset; + const pump = 1 + 0.2 * drive.bass + 0.1 * drive.onset; 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(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); frame.poly(parallelogramPoints(cx - 70, cy - 15, 132, 30, 8)).fill(PHANTOM_WHITE); addText( layer, @@ -378,6 +387,28 @@ function drawBgaFrame( ); } +/** + * Spectrum strip under the monitor: 16 slanted red bars rising off an ink rule, white-capped, hot bands turning gold — + * the poster's equalizer. Silent (or effects off) leaves just the rule. + */ +function drawSpectrumStrip(frame: Graphics, drive: AudioDrive): void { + const baseY = 348; + const maxH = 22; + const count = 16; + const pitch = BGA.w / count; + frame.rect(BGA.x - 6, baseY, BGA.w + 12, 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 x = BGA.x + bar * pitch + 1; + const w = pitch - 5; + frame.poly(parallelogramPoints(x, baseY - h, w, h, 4)).fill(value > 0.82 ? PHANTOM_GOLD : PHANTOM_RED); + // A 3 px paper cap riding the bar's slanted top. + frame.poly(parallelogramPoints(x + 4 * (1 - 3 / h), baseY - h, w, 3, (4 * 3) / h)).fill(PHANTOM_WHITE); + } +} + /** * 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. @@ -542,6 +573,7 @@ function drawScorePlate( frame: Graphics, layer: Container, runtime: FallbackGameplayRuntime, + drive: AudioDrive, pool: ChildPool | undefined, ): void { const { x, y, w, h } = SCORE_PANEL; @@ -626,7 +658,9 @@ 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.16 * 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) diff --git a/packages/player-web/src/scene/default/lattice/field.test.ts b/packages/player-web/src/scene/default/lattice/field.test.ts index 01cfd8c1..bdebe3b2 100644 --- a/packages/player-web/src/scene/default/lattice/field.test.ts +++ b/packages/player-web/src/scene/default/lattice/field.test.ts @@ -54,6 +54,25 @@ describe('needleAngle', () => { } }); + 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)); diff --git a/packages/player-web/src/scene/default/lattice/field.ts b/packages/player-web/src/scene/default/lattice/field.ts index 4f25a9e2..def884f2 100644 --- a/packages/player-web/src/scene/default/lattice/field.ts +++ b/packages/player-web/src/scene/default/lattice/field.ts @@ -24,8 +24,16 @@ export interface NeedleFieldInput { 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; @@ -54,6 +62,18 @@ export function needleAngle(x: number, y: number, input: NeedleFieldInput): numb 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; diff --git a/packages/player-web/src/scene/default/lattice/gameplay-chrome.ts b/packages/player-web/src/scene/default/lattice/gameplay-chrome.ts index 182fddad..48686217 100644 --- a/packages/player-web/src/scene/default/lattice/gameplay-chrome.ts +++ b/packages/player-web/src/scene/default/lattice/gameplay-chrome.ts @@ -18,6 +18,7 @@ import { 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 { audioDrive, bandLevel, type AudioDrive } from '../audio-drive.ts'; import { LAT_ACCENT, LAT_GRAPHITE, @@ -67,6 +68,9 @@ export function renderLatticeChrome({ const playfieldRight = resolvePlayfieldRight(runtime); 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, @@ -96,14 +100,14 @@ export function renderLatticeChrome({ { 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: BGA.x + BGA.w + 8, y: BGA.y - 12, w: DESIGN_WIDTH, h: 232 }, + { x: BGA.x + BGA.w + 8, y: BGA.y - 12, w: DESIGN_WIDTH, h: 252 }, ]; const pendulums = 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, playfieldRight), + resolveFieldInput(layer, runtime, moments, tier, effects.amount, playfieldRight, drive), { alpha: 0.55, skip: (x, y) => clean.some((rect) => inside(rect, x, y, 0)), @@ -115,17 +119,17 @@ export function renderLatticeChrome({ panels.blendMode = 'normal'; drawPlayfieldFrame(panels, playfieldRight, runtime.progressRatio); if (pendulums) { - drawPendulums(panels, layer, runtime, (playfieldRight + BGA.x - 5) / 2, effects.amount, layerPool); + drawPendulums(panels, layer, runtime, (playfieldRight + BGA.x - 5) / 2, effects.amount, drive, layerPool); } if (playfieldRight + 14 <= BGA.x) { - drawBgaFrame(panels, layer, hasBga, seconds, beatPhase, effects.amount, layerPool); + 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); const tallyX = BGA.x + BGA.w + 14; if (playfieldRight + 14 <= tallyX) { - drawJudgeTally(panels, layer, runtime, tallyX, layerPool); + drawJudgeTally(panels, layer, runtime, tallyX, drive, layerPool); } const front = overlayLayerPool.acquireGraphics(); @@ -142,6 +146,7 @@ export function renderLatticeChrome({ } const IMPULSE_HISTORY = new WeakMap(); +const ONSET_HISTORY = new WeakMap(); interface PendulumState { chains: Chain[]; @@ -149,6 +154,7 @@ interface PendulumState { 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. */ @@ -168,6 +174,7 @@ function drawPendulums( runtime: Runtime, centerX: number, amount: number, + drive: AudioDrive, pool: ChildPool, ): void { const nowMs = runtime.nowMs ?? 0; @@ -180,6 +187,7 @@ function drawPendulums( lastImpulseAt: runtime.impulseAtMs, lastBeat: runtime.beatPhase ?? 0, beats: 0, + lastOnsetAt: drive.onsetAtMs, }; PENDULUMS.set(layer, state); } @@ -198,6 +206,14 @@ function drawPendulums( 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 }); @@ -246,8 +262,18 @@ function resolveFieldInput( tier: number, amount: number, playfieldRight: number, + 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 = []; @@ -260,7 +286,16 @@ function resolveFieldInput( const originX = (PLAYFIELD.x + playfieldRight) / 2; const ripples = amount > 0 - ? history.map((atMs) => ({ x: originX, y: PLAYFIELD.judgementY, ageMs: nowMs - atMs, strength: amount })) + ? [ + ...history.map((atMs) => ({ x: originX, y: 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); @@ -269,8 +304,8 @@ function resolveFieldInput( return { seconds: (nowMs / 1000) * (amount > 0 ? 1 : 0), beatPhase: runtime.beatPhase ?? 0, - flow: (0.35 + 0.12 * tier) * amount, - beatWave: (0.45 + 0.12 * tier) * amount, + flow: (0.35 + 0.12 * tier) * amount + 0.35 * drive.level, + beatWave: (0.45 + 0.12 * tier) * amount + 0.4 * drive.bass, ripples, attractor: { x: BGA.x + BGA.w / 2, y: BGA.y + BGA.h / 2, strength: envelope(milestone) * amount }, swirl: { x: DESIGN_WIDTH / 2, y: DESIGN_HEIGHT / 2, strength: envelope(fullCombo) * amount }, @@ -358,6 +393,7 @@ function drawBgaFrame( seconds: number, beatPhase: number, amount: number, + drive: AudioDrive, pool: ChildPool, ): void { const margin = 5; @@ -382,7 +418,27 @@ function drawBgaFrame( const tile = pitch - 4; const top = BGA.y + (BGA.h - 22 - rows * pitch) / 2; const wave = amount > 0 ? beatPhase * 1.6 : 1; - for (let row = 0; row < rows; row += 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. @@ -654,7 +710,14 @@ const TALLY: ReadonlyArray 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; diff --git a/packages/player-web/src/scene/default/lattice/select.ts b/packages/player-web/src/scene/default/lattice/select.ts index 03dfcd91..9381e614 100644 --- a/packages/player-web/src/scene/default/lattice/select.ts +++ b/packages/player-web/src/scene/default/lattice/select.ts @@ -11,6 +11,8 @@ import { addHitArea, addSkinText, formatPlayVariantLabel, type SkinTextOptions } import { addStaggeredSkinText, drawNeedleField, drawPaperGrid, type Rect } from './draw.ts'; import { scrambleText, scrambleTick, springEase, tileFlipPhase } from './field.ts'; import { bounceHeight } from './physics.ts'; +import type { BeMusicAudioFrame } from '../../../skin/be-music/types.ts'; +import { audioDrive, bandLevel } from '../audio-drive.ts'; import { LAT_ACCENT, LAT_DISPLAY_FONT, @@ -69,6 +71,9 @@ class LatticeSelectRenderer implements BeMusicSelectRenderer { 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() { @@ -97,8 +102,23 @@ class LatticeSelectRenderer implements BeMusicSelectRenderer { return needsFrame; } - public tick(nowMs: number, focusedSong: BrowserSongEntry | undefined): void { + 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; @@ -121,9 +141,22 @@ class LatticeSelectRenderer implements BeMusicSelectRenderer { beatPhase: (seconds * beatsPerSecond) % 1, flow: 0.3 * rate, beatWave: 0.4 * rate, - ripples: target && rate > 0 ? [{ x: target.x, y: target.y, ageMs: cursorAge, strength: 0.8 }] : [], + 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, + 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, @@ -150,6 +183,7 @@ class LatticeSelectRenderer implements BeMusicSelectRenderer { // 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: BeMusicSelectFrame): boolean { diff --git a/packages/player-web/src/scene/default/phantom/select.ts b/packages/player-web/src/scene/default/phantom/select.ts index af475679..ec98a265 100644 --- a/packages/player-web/src/scene/default/phantom/select.ts +++ b/packages/player-web/src/scene/default/phantom/select.ts @@ -29,6 +29,8 @@ import { tornEdgePoints, } from '../phantom-style.ts'; import { addHitArea, addSkinText, formatPlayVariantLabel, type SkinTextOptions } from '../skin-text.ts'; +import type { BeMusicAudioFrame } from '../../../skin/be-music/types.ts'; +import { audioDrive, bandLevel } from '../audio-drive.ts'; import { addRansomText, type GlyphFactory } from './tear.ts'; const LAYOUT: BeMusicSelectLayout = { listX: 320, listTop: 54, listBottomInset: 26, rowHeight: 28 }; @@ -71,6 +73,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; @@ -213,19 +219,27 @@ class PhantomSelectRenderer implements BeMusicSelectRenderer { } } - public tick(nowMs: number, focusedSong: BrowserSongEntry | undefined): void { + 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(1 + 0.06 * beatPulse + 0.24 * drive.bass + 0.1 * drive.onset); } this.kicker.x = -((seconds * 36) % KICKER_PITCH); const card = this.activeCard; @@ -242,11 +256,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, + }); } } @@ -263,6 +304,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); @@ -299,10 +341,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]; diff --git a/packages/player-web/src/scene/default/synesthesia/draw.ts b/packages/player-web/src/scene/default/synesthesia/draw.ts index 4abef4f4..3d22957b 100644 --- a/packages/player-web/src/scene/default/synesthesia/draw.ts +++ b/packages/player-web/src/scene/default/synesthesia/draw.ts @@ -1,5 +1,7 @@ import type { Graphics } from 'pixi.js'; import { hash01 } from '../phantom-style.ts'; +import type { AudioDrive } from '../audio-drive.ts'; +import { bandLevel } from '../audio-drive.ts'; import type { Flock } from './boids.ts'; import { emberColor, @@ -211,3 +213,45 @@ export function drawSchool( .fill({ color: heat > 0.8 ? SYN_WHITE : color, alpha: Math.min(1, alpha * 1.3) }); } } + +const HALO_RAYS = 48; + +/** + * Spectrum halo: {@link HALO_RAYS} light rays radiating from a ring of `radius` around `(cx, cy)`, each as long as its + * band (up to `reach`), mirrored left / right so the low end sits at the top and bottom. The ring itself breathes on + * the bass. Draws nothing in silence beyond a faint ring. `skip` drops rays (e.g. outside a monitor). + */ +export function drawSpectrumHalo( + graphics: Graphics, + cx: number, + cy: number, + radius: number, + reach: number, + drive: AudioDrive, + skip?: (x: number, y: number) => boolean, +): void { + const ring = radius * (1 + 0.12 * drive.bass); + graphics.circle(cx, cy, ring).stroke({ color: emberColor(0.7), width: 1, alpha: 0.18 + 0.4 * drive.level }); + const half = HALO_RAYS / 2; + for (let ray = 0; ray < HALO_RAYS; ray += 1) { + const column = ray < half ? ray : HALO_RAYS - 1 - ray; + const value = bandLevel(drive.bands, column, half); + if (value < 0.04) continue; + const angle = -Math.PI / 2 + (ray / HALO_RAYS) * Math.PI * 2; + const cos = Math.cos(angle); + const sin = Math.sin(angle); + const x0 = cx + cos * (ring + 2); + const y0 = cy + sin * (ring + 2); + const x1 = cx + cos * (ring + 2 + reach * value); + const y1 = cy + sin * (ring + 2 + reach * value); + if (skip && (skip(x0, y0) || skip(x1, y1))) continue; + graphics + .moveTo(x0, y0) + .lineTo(x1, y1) + .stroke({ + color: ray % 8 === 0 ? SYN_CYAN : emberColor(0.45 + 0.55 * value), + width: 1.6, + alpha: 0.35 + 0.6 * value, + }); + } +} diff --git a/packages/player-web/src/scene/default/synesthesia/gameplay-chrome.ts b/packages/player-web/src/scene/default/synesthesia/gameplay-chrome.ts index ef560a24..56a0a308 100644 --- a/packages/player-web/src/scene/default/synesthesia/gameplay-chrome.ts +++ b/packages/player-web/src/scene/default/synesthesia/gameplay-chrome.ts @@ -7,8 +7,9 @@ import { addHudNumber, addHudText, type HudTextOptions } from '../hud-text.ts'; import { comboTier, effectProfile, impulse, punchScale } from '../moments.ts'; import { drawSynesthesiaMoments } from './moments.ts'; import { hash01 } from '../phantom-style.ts'; +import { audioDrive, type AudioDrive } from '../audio-drive.ts'; import { createFlock, stepFlock, type Flock } from './boids.ts'; -import { drawFrame, drawPointCloud, drawReticle, drawSchool } from './draw.ts'; +import { drawFrame, drawPointCloud, drawReticle, drawSchool, drawSpectrumHalo } from './draw.ts'; import { emberColor, hsvToHex, @@ -120,22 +121,25 @@ export function renderSynesthesiaChrome({ // reframing floor, rivers, schools and warp. 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, 320, FLOOR.horizon, FLOOR.focal).y; - drawGround(space, pulse, hasBga, tier, hit, hitColor, horizon); + // The space listens to the mix: the ember haze and floor swell on the bass, dust speeds with loudness, onsets fire + // warp streaks, rivers weave with the mids, the schools tighten on bass hits and scatter on transients. + const drive = audioDrive(runtime.audio, runtime.effects); + drawGround(space, pulse, hasBga, tier, hit, hitColor, horizon, drive); const light = layerPool.acquireGraphics(); light.label = 'synesthesia-gameplay/light'; light.blendMode = 'add'; - drawDust(light, seconds, pulse, hasBga, tier, hit, camera); - drawFloor(light, seconds, pulse, tier, beatPhase, hit, camera); + drawDust(light, seconds, pulse, hasBga, tier, hit, camera, drive); + drawFloor(light, seconds, pulse, tier, beatPhase, 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); + 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: pulse * 0.6, - scatter: impulse(runtime.impulseAtMs, runtime.nowMs ?? 0, 520) * effects.amount, + 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) { @@ -162,7 +166,7 @@ export function renderSynesthesiaChrome({ const standby = layerPool.acquireGraphics(); standby.label = 'synesthesia-gameplay/standby'; standby.blendMode = 'add'; - drawBgaFrame(panels, standby, layer, hasBga, seconds, pulse, hue, camera, layerPool); + drawBgaFrame(panels, standby, layer, hasBga, seconds, pulse, hue, camera, drive, layerPool); } drawGauge(panels, layer, runtime, accent, layerPool); drawSongPlate(panels, layer, runtime, accent, layerPool); @@ -217,6 +221,7 @@ function drawGround( hit: number, hitColor: number, horizon: number, + drive: AudioDrive, ): void { const bands: ReadonlyArray = [ [0x090302, 140], @@ -232,7 +237,7 @@ function drawGround( if (hit > 0) { fillAroundBga(graphics, 0, 0, DESIGN_WIDTH, horizon, hitColor, hasBga, 0.06 * hit); } - const haze = (0.05 + 0.04 * pulse) * (1 + 0.35 * tier); + const haze = (0.05 + 0.04 * pulse) * (1 + 0.35 * tier) * (1 + 1.4 * drive.bass); for (let band = 0; band < 12; band += 1) { const falloff = (1 - band / 12) ** 2; const h = 6; @@ -254,9 +259,10 @@ function drawDust( tier: number, hit: number, camera: CameraPose, + drive: AudioDrive, ): void { const count = 220 + 50 * tier; - const speed = (120 + 40 * pulse) * (1 + 0.45 * tier) * (1 + 2.2 * hit); + const speed = (120 + 40 * pulse) * (1 + 0.45 * tier) * (1 + 2.2 * hit) * (1 + 1.2 * drive.level); for (let index = 0; index < count; index += 1) { const point = starfieldPoint(index, seconds, { spread: 520, near: 20, far: 900, speed }); const projected = projectPoint(viewPoint(point, camera), VANISH.x, VANISH.y, 180); @@ -278,7 +284,7 @@ function drawDust( .fill({ color, alpha: 0.3 + 0.6 * nearness }); } const vanish = vanishingPoint(camera, VANISH.x, VANISH.y, 180); - const streaks = Math.round(8 + 12 * tier + 14 * hit); + const streaks = Math.round(8 + 12 * tier + 14 * hit + 18 * drive.onset); const streakSpeed = (0.45 + 0.3 * tier) * (1 + 1.5 * hit); for (let index = 0; index < streaks; index += 1) { const angle = hash01(index * 5 + 1) * Math.PI * 2; @@ -313,9 +319,10 @@ function drawFloor( beatPhase: number, hit: number, camera: CameraPose, + drive: AudioDrive, ): void { const { horizon, height, focal } = FLOOR; - const glow = 1 + 0.3 * tier + 0.8 * hit; + const glow = 1 + 0.3 * tier + 0.8 * hit + 1.2 * drive.bass; const project = (x: number, z: number) => projectPoint(viewPoint({ x, y: height, z }, camera), 320, horizon, focal); const vanish = vanishingPoint(camera, 320, horizon, focal); for (let x = -1500; x <= 1500; x += 100) { @@ -362,8 +369,15 @@ function drawRiver( hasBga: boolean, tier: number, camera: CameraPose, + drive: AudioDrive, ): void { - const options = { length: 1500, speed: 260 + 60 * tier, amplitude: 28, width: 16, seed: river + 1 }; + 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; @@ -449,6 +463,7 @@ function drawBgaFrame( pulse: number, hue: number, camera: CameraPose, + drive: AudioDrive, pool: ChildPool, ): void { const color = hsvToHex(hue, 0.6, 1); @@ -533,7 +548,7 @@ function drawBgaFrame( } // The figure: drifting, slowly turning, breathing on the beat, a hot core at the chest. const bob = Math.sin(seconds * 0.9) * 6; - const figureScale = 104 * (1 + 0.03 * pulse); + const figureScale = 104 * (1 + 0.03 * pulse + 0.05 * drive.bass); drawPointCloud( light, FIGURE, @@ -559,6 +574,8 @@ function drawBgaFrame( for (let ring = 4; ring >= 1; ring -= 1) { light.circle(core.x, core.y, ring * 5 * (1 + 0.3 * pulse)).fill({ color: SYN_AMBER, alpha: 0.06 + 0.02 * pulse }); } + // A halo of spectrum rays around the figure's core, mirrored left / right. + drawSpectrumHalo(light, core.x, core.y, 40, 34, drive, (px, py) => inside(px, py)); // Lock-on reticle on the figure. const lock = 34 + 6 * pulse; drawReticle(light, core.x - lock, core.y - lock, lock * 2, lock * 2, SYN_FLARE, 0.55, { arm: 8, cross: true }); diff --git a/packages/player-web/src/scene/default/synesthesia/playfield.ts b/packages/player-web/src/scene/default/synesthesia/playfield.ts index da698540..57a90037 100644 --- a/packages/player-web/src/scene/default/synesthesia/playfield.ts +++ b/packages/player-web/src/scene/default/synesthesia/playfield.ts @@ -20,6 +20,7 @@ import { } from './space.ts'; import { drawReticle } from './draw.ts'; import { SYN_GLOW_SIZE, synGlowTexture } from './style.ts'; +import { audioDrive } from '../audio-drive.ts'; import { comboTier, effectProfile } from '../moments.ts'; /** Per-lane-class light: note body, glow hue (0..1), and the colour the lane beam / key cap light up in. */ @@ -47,11 +48,14 @@ export function renderSynesthesiaLanes({ nowMs, combo, effects, + audio, }: BeMusicLanesContext): 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; - const heat = 1 + 0.3 * tier; + // ...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; diff --git a/packages/player-web/src/scene/default/synesthesia/select.ts b/packages/player-web/src/scene/default/synesthesia/select.ts index e772cd90..89f97aa8 100644 --- a/packages/player-web/src/scene/default/synesthesia/select.ts +++ b/packages/player-web/src/scene/default/synesthesia/select.ts @@ -9,8 +9,10 @@ import type { import { easeOutCubic, stageProgress } from '../phantom-style.ts'; import { addHitArea, addSkinText, formatPlayVariantLabel, type SkinTextOptions } from '../skin-text.ts'; import { hash01 } from '../phantom-style.ts'; +import type { BeMusicAudioFrame } from '../../../skin/be-music/types.ts'; +import { audioDrive } from '../audio-drive.ts'; import { createFlock, stepFlock } from './boids.ts'; -import { drawPointCloud, drawReticle, drawSchool } from './draw.ts'; +import { drawPointCloud, drawReticle, drawSchool, drawSpectrumHalo } from './draw.ts'; import { emberColor, hsvToHex, @@ -171,9 +173,17 @@ class SynesthesiaSelectRenderer implements BeMusicSelectRenderer { }); } - public tick(nowMs: number, focusedSong: BrowserSongEntry | undefined, launchAt?: number): void { + 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 horizon and floor swell on the bass, + // the schools pulse, and a spectrum halo rays out of the figure. + 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; @@ -196,7 +206,7 @@ class SynesthesiaSelectRenderer implements BeMusicSelectRenderer { const cx = this.designWidth * 0.58; const cy = this.designHeight * 0.44; const dustVanish = vanishingPoint(camera, cx, cy, 200); - const speed = (80 + beatsPerSecond * 55) * (1 + 16 * warp * warp); + const speed = (80 + beatsPerSecond * 55) * (1 + 16 * warp * warp) * (1 + 1.2 * drive.level); this.travel += dt * speed * (warp > 0 ? 1 : rate); for (let index = 0; index < this.stars.length; index += 1) { const star = this.stars[index]!; @@ -238,7 +248,7 @@ class SynesthesiaSelectRenderer implements BeMusicSelectRenderer { // Ember haze along the horizon. for (let band = 0; band < 14; band += 1) { const falloff = (1 - band / 14) ** 2; - const alpha = (0.05 + 0.04 * pulse) * falloff; + const alpha = (0.05 + 0.04 * pulse) * falloff * (1 + 1.4 * drive.bass); world.rect(0, horizon - (band + 1) * 7, this.designWidth, 7).fill({ color: SYN_EMBER, alpha }); world.rect(0, horizon + band * 4, this.designWidth, 4).fill({ color: SYN_EMBER, alpha: alpha * 0.8 }); } @@ -252,7 +262,7 @@ class SynesthesiaSelectRenderer implements BeMusicSelectRenderer { const nearness = 1 - z / 2600; const size = 0.6 + 1.5 * nearness * nearness; const color = emberColor(0.3 + 0.65 * nearness); - const alpha = (0.12 + 0.7 * nearness * nearness) * (0.7 + 0.3 * pulse); + 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 = project(x, z); if (!point.visible || point.x < -4 || point.x > this.designWidth + 4 || point.y > this.designHeight + 4) { @@ -262,7 +272,13 @@ class SynesthesiaSelectRenderer implements BeMusicSelectRenderer { } } // A golden river of particles sweeping across the floor. - const riverOptions = { length: 1800, speed: 120 + beatsPerSecond * 60, amplitude: 36, width: 20, seed: 2 }; + 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) { @@ -300,10 +316,14 @@ class SynesthesiaSelectRenderer implements BeMusicSelectRenderer { } // Schools of light fish sweeping through the pyramids: tight on the beat, scattering when the cursor moves. if (rate > 0) { - const scatter = Math.max(0, 1 - (nowMs - this.cursorChangedAt) / 600) ** 2 + warp; + 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: pulse * 0.6, scatter }); + 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]!, @@ -318,7 +338,7 @@ class SynesthesiaSelectRenderer implements BeMusicSelectRenderer { world, FIGURE, { - scale: 120 * (1 + 0.03 * pulse), + scale: 120 * (1 + 0.03 * pulse + 0.05 * drive.bass), x: 90, y: -40 + bob, z: 260, @@ -337,6 +357,16 @@ class SynesthesiaSelectRenderer implements BeMusicSelectRenderer { }, ); + // Spectrum halo around the figure's chest. + const figureCamera = { ...camera, x: camera.x * 0.3, y: camera.y * 0.3 }; + const chest = projectPoint( + viewPoint({ x: 90, y: -40 + bob - 0.5 * 120, z: 260 }, figureCamera, 260), + cx, + floorY - 120, + 300, + ); + if (chest.visible && warp < 1) drawSpectrumHalo(world, chest.x, chest.y, 30, 40, drive); + // Lock-on reticle snapping onto the focused card, plus a breathing glow under it. const card = this.activeCard; this.lock.clear(); From 46fb772033892fda7bed1af2d4d90b6b6c68bb38 Mon Sep 17 00:00:00 2001 From: Seiya KONNO Date: Tue, 29 Sep 2026 15:29:38 +0900 Subject: [PATCH 11/65] feat(player-web): replace the Synesthesia figure with a roaming visualizer-style audio orb A hollow shell of violet / blue / pink sparks, brightest at its silhouette and bristling with fibres, swells and bursts with the spectrum and onsets while it wanders the space close to the camera; a black moon revolves round it and reflects its light. Dense backdrop shapes are now batched into a handful of draw instructions per frame. --- .changeset/be-music-skins.md | 4 +- .../src/scene/default/synesthesia/draw.ts | 436 +++++++++++++++--- .../default/synesthesia/gameplay-chrome.ts | 70 +-- .../src/scene/default/synesthesia/select.ts | 136 +++--- .../scene/default/synesthesia/space.test.ts | 116 ++++- .../src/scene/default/synesthesia/space.ts | 161 +++++-- 6 files changed, 716 insertions(+), 207 deletions(-) diff --git a/.changeset/be-music-skins.md b/.changeset/be-music-skins.md index ee26653e..4e099f66 100644 --- a/.changeset/be-music-skins.md +++ b/.changeset/be-music-skins.md @@ -8,9 +8,9 @@ The default (skinless) family now renders through a `BeMusicSkin`: one object bu - New exports: `BeMusicSkin` and its context types, `createBeMusicSkinRegistry`, `resolveBeMusicLaneKind`, `resolveSelectListWindow`, `phantomSkin`, `synesthesiaSkin`, `latticeSkin`, and `BUILT_IN_BE_MUSIC_SKINS`. - Phantom is the existing poster skin and still the default. 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 half and a black-and-white starburst half; and the result wipe tears away along a ripped edge with RESULT cut from magazines. -- Synesthesia is inspired by the sound-and-light games of Tetsuya Mizuguchi, after Rez Infinite's Area X: a black void lit by an ember horizon, a world built from fine ember / gold particles with electric blue and magenta accents, data dust and speed streaks pouring out of the vanishing point, a floor of light points, rivers of particles, point-cloud pyramids, and a floating point-cloud figure. 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 orbits the figure. Hits detonate as perspective-projected 3D particle fountains with voxel sparks, motion trails, tilted shockwave rings, 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 a 3D particle ring that bursts as the rank ignites. It draws with Michroma and M PLUS 1p, which hosts should load. +- Synesthesia is inspired by the sound-and-light games of Tetsuya Mizuguchi, after Rez Infinite's Area X: a black void lit by an ember horizon, a world built from fine ember / gold particles with electric blue and magenta accents, data dust and speed streaks pouring out of the vanishing point, a floor of light points, rivers of particles, point-cloud pyramids, and a roaming audio orb after flight404's Magnetosphere — 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 particle fountains with voxel sparks, motion trails, tilted shockwave rings, 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 a 3D particle ring that bursts 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 `renderChrome` (as `runtime.audio`), `renderLanes` and `renderBombs` (as `audio`), and the select renderer's `tick` (fourth argument). Gameplay analyses the whole mix off the audio bus output; select analyses the BGM and chart preview. 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, with a spectrum halo around the figure; 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. +- 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 `renderChrome` (as `runtime.audio`), `renderLanes` and `renderBombs` (as `audio`), and the select renderer's `tick` (fourth argument). Gameplay analyses the whole mix off the audio bus output; select analyses the BGM and chart preview. 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. - 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. diff --git a/packages/player-web/src/scene/default/synesthesia/draw.ts b/packages/player-web/src/scene/default/synesthesia/draw.ts index 3d22957b..56545fb0 100644 --- a/packages/player-web/src/scene/default/synesthesia/draw.ts +++ b/packages/player-web/src/scene/default/synesthesia/draw.ts @@ -1,26 +1,114 @@ -import type { Graphics } from 'pixi.js'; +import type { Graphics, Sprite } from 'pixi.js'; import { hash01 } from '../phantom-style.ts'; import type { AudioDrive } from '../audio-drive.ts'; import { bandLevel } from '../audio-drive.ts'; 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 } from './style.ts'; +import { SYN_CYAN, SYN_GLASS, SYN_MAGENTA, SYN_WHITE, synGlowTexture } from './style.ts'; /** * Shared Synesthesia drawing: hairline frames with lock-on corners (Rez's 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 { + private readonly rects = new Map(); + private readonly lines = new Map< + string, + { graphics: Graphics; color: number; alpha: number; width: number; data: number[] } + >(); + private readonly ids = new Map(); + + private id(graphics: Graphics): number { + let id = this.ids.get(graphics); + if (id === undefined) { + id = this.ids.size; + this.ids.set(graphics, id); + } + return id; + } + + public rect(graphics: Graphics, rawColor: number, alpha: number, x: number, y: number, w: number, h: number): void { + const a = Math.round(Math.min(1, alpha) * 8) / 8; + if (a <= 0) return; + const color = quantizeColor(rawColor); + const key = `${this.id(graphics)}|${color}|${a}`; + let bucket = this.rects.get(key); + if (!bucket) { + bucket = { graphics, color, alpha: a, 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 a = Math.round(Math.min(1, alpha) * 8) / 8; + if (a <= 0) return; + const w = Math.max(0.5, Math.round(width * 2) / 2); + const color = quantizeColor(rawColor); + const key = `${this.id(graphics)}|${color}|${a}|${w}`; + let bucket = this.lines.get(key); + if (!bucket) { + bucket = { graphics, color, alpha: a, width: w, 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, graphics } = bucket; + 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 }); + } + for (const bucket of this.lines.values()) { + const { data, graphics } = bucket; + 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 }); + } + this.rects.clear(); + this.lines.clear(); + } +} + +/** `color` snapped to 16 levels per channel (bucket-friendly, visually identical for light). */ +function quantizeColor(color: number): number { + return (color & 0xf0f0f0) | 0x080808; +} + /** * Rez lock-on reticle: four rounded corner brackets around `(x, y, w, h)` and an optional centre cross. `arm` is the * bracket length. @@ -124,6 +212,7 @@ export function drawPointCloud( const reference = style.referenceScale ?? 1; const seconds = style.seconds ?? 0; const shimmer = style.shimmer ?? 0; + const batch = new ShapeBatch(); 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 }; @@ -146,14 +235,19 @@ export function drawPointCloud( const color = style.colorOf(point, index); if (style.bokeh && point.weight > 0.75) { const halo = size * style.bokeh; - graphics - .rect(projected.x - halo / 2, projected.y - halo / 2, halo, halo) - .fill({ color, alpha: Math.min(1, alpha) * 0.12 }); + batch.rect( + graphics, + color, + Math.min(1, alpha) * 0.12, + projected.x - halo / 2, + projected.y - halo / 2, + halo, + halo, + ); } - graphics - .rect(projected.x - size / 2, projected.y - size / 2, size, size) - .fill({ color, alpha: Math.min(1, alpha) }); + batch.rect(graphics, color, Math.min(1, alpha), projected.x - size / 2, projected.y - size / 2, size, size); } + batch.flush(); } /** @@ -169,6 +263,7 @@ export function drawSchool( ): void { const palette = style.palette ?? 'ember'; const { position: p, velocity: v } = flock; + const batch = new ShapeBatch(); for (let index = 0; index < flock.count; index += 1) { const x = p[index * 3]!; const y = p[index * 3 + 1]!; @@ -200,58 +295,293 @@ export function drawSchool( const alpha = style.alpha * (0.35 + 0.65 * nearness); const mx = (head.x + tail.x) / 2; const my = (head.y + tail.y) / 2; - graphics - .moveTo(tail.x, tail.y) - .lineTo(mx, my) - .stroke({ color, width: 0.6 + 1 * nearness, alpha: alpha * 0.55 }) - .moveTo(mx, my) - .lineTo(head.x, head.y) - .stroke({ color, width: 1 + 2 * nearness, alpha }); + 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; - graphics - .rect(head.x - size / 2, head.y - size / 2, size, size) - .fill({ color: heat > 0.8 ? SYN_WHITE : color, alpha: Math.min(1, alpha * 1.3) }); + 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(); } -const HALO_RAYS = 48; +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; /** - * Spectrum halo: {@link HALO_RAYS} light rays radiating from a ring of `radius` around `(cx, cy)`, each as long as its - * band (up to `reach`), mirrored left / right so the low end sits at the top and bottom. The ring itself breathes on - * the bass. Draws nothing in silence beyond a faint ring. `skip` drops rays (e.g. outside a monitor). + * A Magnetosphere-style orb (after flight404's iTunes visualizer): 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 drawSpectrumHalo( - graphics: Graphics, - cx: number, - cy: number, - radius: number, - reach: number, - drive: AudioDrive, - skip?: (x: number, y: number) => boolean, -): void { - const ring = radius * (1 + 0.12 * drive.bass); - graphics.circle(cx, cy, ring).stroke({ color: emberColor(0.7), width: 1, alpha: 0.18 + 0.4 * drive.level }); - const half = HALO_RAYS / 2; - for (let ray = 0; ray < HALO_RAYS; ray += 1) { - const column = ray < half ? ray : HALO_RAYS - 1 - ray; - const value = bandLevel(drive.bands, column, half); - if (value < 0.04) continue; - const angle = -Math.PI / 2 + (ray / HALO_RAYS) * Math.PI * 2; - const cos = Math.cos(angle); - const sin = Math.sin(angle); - const x0 = cx + cos * (ring + 2); - const y0 = cy + sin * (ring + 2); - const x1 = cx + cos * (ring + 2 + reach * value); - const y1 = cy + sin * (ring + 2 + reach * value); - if (skip && (skip(x0, y0) || skip(x1, y1))) continue; - graphics - .moveTo(x0, y0) - .lineTo(x1, y1) - .stroke({ - color: ray % 8 === 0 ? SYN_CYAN : emberColor(0.45 + 0.55 * value), - width: 1.6, - alpha: 0.35 + 0.6 * value, +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 * (1 + 0.12 * drive.bass); + 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 = new ShapeBatch(); + 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/scene/default/synesthesia/gameplay-chrome.ts b/packages/player-web/src/scene/default/synesthesia/gameplay-chrome.ts index 56a0a308..bbb5c9d0 100644 --- a/packages/player-web/src/scene/default/synesthesia/gameplay-chrome.ts +++ b/packages/player-web/src/scene/default/synesthesia/gameplay-chrome.ts @@ -9,13 +9,14 @@ import { drawSynesthesiaMoments } from './moments.ts'; import { hash01 } from '../phantom-style.ts'; import { audioDrive, type AudioDrive } from '../audio-drive.ts'; import { createFlock, stepFlock, type Flock } from './boids.ts'; -import { drawFrame, drawPointCloud, drawReticle, drawSchool, drawSpectrumHalo } from './draw.ts'; +import { drawFrame, drawMagnetoOrb, drawPointCloud, drawReticle, drawSchool } from './draw.ts'; import { emberColor, hsvToHex, mixCamera, particleRiverPoint, - pointCloudHumanoid, + fibonacciSphere, + orbitParticles, pointCloudPyramid, projectPoint, REST_CAMERA, @@ -23,6 +24,7 @@ import { starfieldPoint, vanishingPoint, viewPoint, + wanderPoint, type CameraPose, } from './space.ts'; import { @@ -83,14 +85,15 @@ function advanceSchools( state.lastMs = nowMs; return state.flocks; } -const FIGURE = pointCloudHumanoid(11, 1400); +const ORB_SHELL = fibonacciSphere(800, 11); +const ORB = orbitParticles(11, 22); const PYRAMIDS = [pointCloudPyramid(3, 520), pointCloudPyramid(8, 420), pointCloudPyramid(5, 700)]; /** * Synesthesia gameplay HUD after Rez Infinite's Area X: a black void lit by an ember horizon, data dust and speed * streaks 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 - * point-cloud figure in front of particle pyramids. Colour drifts through the ember band and swells on every beat. + * audio orb roaming in front of particle pyramids. Colour drifts through the ember band and swells on every beat. */ export function renderSynesthesiaChrome({ layer, @@ -451,7 +454,7 @@ function drawPlayfieldWell(graphics: Graphics, right: number, progressRatio: num /** * Monitor frame: a warm hairline with lock-on corners. With no BGA the screen idles on a miniature Area X — a floating - * point-cloud figure turning slowly in front of particle pyramids on an ember horizon, streaks pouring past — the + * Magnetosphere-style orb roaming in front of particle pyramids on an ember horizon, streaks pouring past — the * "nothing is playing, but the space is alive" state. The idle scene draws into `light` (additive). */ function drawBgaFrame( @@ -479,7 +482,7 @@ function drawBgaFrame( 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 figure: the shot's turn is exaggerated into an orbit, its travel damped. + // 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, @@ -546,39 +549,36 @@ function drawBgaFrame( .lineTo(x1, y1) .stroke({ color: index % 5 === 0 ? SYN_CYAN : SYN_AMBER, width: 1, alpha: Math.sin(progress * Math.PI) * 0.5 }); } - // The figure: drifting, slowly turning, breathing on the beat, a hot core at the chest. - const bob = Math.sin(seconds * 0.9) * 6; - const figureScale = 104 * (1 + 0.03 * pulse + 0.05 * drive.bass); - drawPointCloud( - light, - FIGURE, - { scale: figureScale, x: 0, y: -72 + bob, z: 60, yaw: Math.sin(seconds * 0.35) * 0.9, pitch: 0.12 }, - view, + // The audio orb roams the monitor's little world close to the camera: a Magnetosphere-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, { - colorOf: (point, index) => (index % 17 === 0 ? SYN_CYAN : emberColor(0.45 + 0.55 * point.weight)), - alpha: 0.8, - size: 1, + ...roam, + radius: 24 * (1 + 0.03 * pulse), + view, + drive, seconds, - shimmer: 0.35, - referenceScale: 220 / 280, - bokeh: 3.5, - skip: (px, py) => inside(px, py), + 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 }, }, ); - const core = projectPoint( - viewPoint({ x: 0, y: -72 + bob - 0.5 * figureScale, z: 60 }, orbitCamera, orbit), - cx, - baseHorizon, - 220, - ); - for (let ring = 4; ring >= 1; ring -= 1) { - light.circle(core.x, core.y, ring * 5 * (1 + 0.3 * pulse)).fill({ color: SYN_AMBER, alpha: 0.06 + 0.02 * pulse }); - } - // A halo of spectrum rays around the figure's core, mirrored left / right. - drawSpectrumHalo(light, core.x, core.y, 40, 34, drive, (px, py) => inside(px, py)); - // Lock-on reticle on the figure. - const lock = 34 + 6 * pulse; - drawReticle(light, core.x - lock, core.y - lock, lock * 2, lock * 2, SYN_FLARE, 0.55, { arm: 8, cross: true }); + 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 }); + } addHudText( layer, 'STANDBY', diff --git a/packages/player-web/src/scene/default/synesthesia/select.ts b/packages/player-web/src/scene/default/synesthesia/select.ts index 89f97aa8..288ce2f0 100644 --- a/packages/player-web/src/scene/default/synesthesia/select.ts +++ b/packages/player-web/src/scene/default/synesthesia/select.ts @@ -11,14 +11,16 @@ import { addHitArea, addSkinText, formatPlayVariantLabel, type SkinTextOptions } import { hash01 } from '../phantom-style.ts'; import type { BeMusicAudioFrame } from '../../../skin/be-music/types.ts'; import { audioDrive } from '../audio-drive.ts'; +import { ChildPool } from '../../pixi-utils.ts'; import { createFlock, stepFlock } from './boids.ts'; -import { drawPointCloud, drawReticle, drawSchool, drawSpectrumHalo } from './draw.ts'; +import { ShapeBatch, drawMagnetoOrb, drawPointCloud, drawReticle, drawSchool } from './draw.ts'; import { emberColor, hsvToHex, mixCamera, particleRiverPoint, - pointCloudHumanoid, + fibonacciSphere, + orbitParticles, pointCloudPyramid, projectPoint, REST_CAMERA, @@ -26,6 +28,7 @@ import { starfieldPoint, vanishingPoint, viewPoint, + wanderPoint, } from './space.ts'; import { SYN_AMBER, @@ -52,7 +55,10 @@ const INTRO_STAGGER_MS = 45; const STAR_COUNT = 320; const STREAK_COUNT = 26; const RIVER_PARTICLES = 340; -const FIGURE = pointCloudHumanoid(23, 1600); +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; @@ -85,19 +91,25 @@ function framePanel(graphics: Graphics, x: number, y: number, w: number, h: numb /** * Synesthesia song select, after Rez Infinite's Area X. The persistent back layer is a particle world — data dust and * speed streaks pouring out of the vanishing point at the focused chart's tempo, point-cloud pyramids on an ember - * horizon, a floor of light points, a golden river of particles, and a floating point-cloud figure — redrawn cheaply + * horizon, a floor of light points, a golden river of particles, and a roaming Magnetosphere-style audio orb — redrawn cheaply * in `tick`. The front layer snaps a lock-on reticle onto the focused card. */ class SynesthesiaSelectRenderer implements BeMusicSelectRenderer { public readonly backLayer = new Container(); public readonly frontLayer = new Container(); private readonly ground = new Graphics(); - /** Additive particle world: floor, pyramids, river, streaks, and the figure. */ + /** Additive particle world: floor, pyramids, river, streaks, and the orb's tails. */ private readonly world = new Graphics(); private readonly stars: Sprite[] = []; 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; @@ -182,7 +194,7 @@ class SynesthesiaSelectRenderer implements BeMusicSelectRenderer { // 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 horizon and floor swell on the bass, - // the schools pulse, and a spectrum halo rays out of the figure. + // 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); @@ -258,6 +270,8 @@ class SynesthesiaSelectRenderer implements BeMusicSelectRenderer { projectPoint(viewPoint({ x, y: 150, z }, camera, WORLD_ORBIT), cx, floorY, 200); const spacing = 100; const offset = this.travel % spacing; + // Floor points, river, and streaks go out as a few batched instructions. + const batch = new ShapeBatch(); for (let z = spacing - offset; z < 2600; z += spacing) { const nearness = 1 - z / 2600; const size = 0.6 + 1.5 * nearness * nearness; @@ -268,7 +282,7 @@ class SynesthesiaSelectRenderer implements BeMusicSelectRenderer { if (!point.visible || point.x < -4 || point.x > this.designWidth + 4 || point.y > this.designHeight + 4) { continue; } - world.rect(point.x - size / 2, point.y - size / 2, size, size).fill({ color, alpha }); + batch.rect(world, color, alpha, point.x - size / 2, point.y - size / 2, size, size); } } // A golden river of particles sweeping across the floor. @@ -287,14 +301,16 @@ class SynesthesiaSelectRenderer implements BeMusicSelectRenderer { const tail = projectPoint(riverWorld({ ...local, x: local.x - 30 }), cx, floorY, 200); if (!head.visible || !tail.visible) continue; const heat = hash01(index * 3 + 7); - world - .moveTo(tail.x, tail.y) - .lineTo(head.x, head.y) - .stroke({ - color: heat > 0.85 ? SYN_WHITE : emberColor(0.5 + 0.5 * heat), - width: 0.4 + 1 * Math.min(1, head.scale) * (0.5 + heat), - alpha: (0.25 + 0.55 * heat) * (1 - warp), - }); + 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, + ); } // Speed streaks — Rez's warp lines — thicken into a tunnel on launch. const streakSpeed = (0.35 + beatsPerSecond * 0.12) * (1 + 6 * warp); @@ -305,15 +321,18 @@ class SynesthesiaSelectRenderer implements BeMusicSelectRenderer { const outer = inner + (14 + 90 * progress) * (1 + 4 * warp); const cos = Math.cos(angle); const sin = Math.sin(angle) * 0.72; - world - .moveTo(dustVanish.x + cos * inner, dustVanish.y + sin * inner) - .lineTo(dustVanish.x + cos * outer, dustVanish.y + sin * outer) - .stroke({ - color: index % 6 === 0 ? SYN_CYAN : emberColor(0.55 + 0.4 * hash01(index * 5 + 4)), - width: 0.8 + progress * 1.6, - alpha: Math.sin(progress * Math.PI) * (0.35 + 0.4 * warp), - }); + batch.line( + world, + index % 6 === 0 ? SYN_CYAN : emberColor(0.55 + 0.4 * hash01(index * 5 + 4)), + Math.sin(progress * Math.PI) * (0.35 + 0.4 * warp), + 0.8 + progress * 1.6, + dustVanish.x + cos * inner, + dustVanish.y + sin * inner, + dustVanish.x + cos * outer, + dustVanish.y + sin * outer, + ); } + 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; @@ -332,40 +351,41 @@ class SynesthesiaSelectRenderer implements BeMusicSelectRenderer { ); } } - // The figure, drifting behind the list — it turns to face you and breathes on the beat. - const bob = Math.sin(seconds * 0.8) * 8; - drawPointCloud( - world, - FIGURE, - { - scale: 120 * (1 + 0.03 * pulse + 0.05 * drive.bass), - x: 90, - y: -40 + bob, - z: 260, - yaw: Math.sin(seconds * 0.3) * 0.8, - pitch: 0.1, - }, - { cx, cy: floorY - 120, focal: 300, camera: { ...camera, x: camera.x * 0.3, y: camera.y * 0.3 }, orbit: 260 }, - { - colorOf: (point, index) => (index % 17 === 0 ? SYN_CYAN : emberColor(0.45 + 0.55 * point.weight)), - alpha: 0.55 * (1 - warp), - size: 0.9, + // Two audio orbs roam the space — the big one and its black moon right up by the camera — Magnetosphere-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, - shimmer: 0.35, - referenceScale: 300 / 560, - bokeh: 2.5, - }, - ); - - // Spectrum halo around the figure's chest. - const figureCamera = { ...camera, x: camera.x * 0.3, y: camera.y * 0.3 }; - const chest = projectPoint( - viewPoint({ x: 90, y: -40 + bob - 0.5 * 120, z: 260 }, figureCamera, 260), - cx, - floorY - 120, - 300, - ); - if (chest.visible && warp < 1) drawSpectrumHalo(world, chest.x, chest.y, 30, 40, drive); + 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; @@ -429,7 +449,9 @@ class SynesthesiaSelectRenderer implements BeMusicSelectRenderer { this.stars.push(star); } this.world.blendMode = 'add'; - this.backLayer.addChild(this.ground, this.world, starLayer); + this.orbHost.blendMode = 'add'; + this.orbFront.blendMode = 'add'; + this.backLayer.addChild(this.ground, this.world, starLayer, this.orbMoon, this.orbFront, this.orbHost); this.cursorGlow.texture = glow; this.cursorGlow.anchor.set(0.5); diff --git a/packages/player-web/src/scene/default/synesthesia/space.test.ts b/packages/player-web/src/scene/default/synesthesia/space.test.ts index 3e1e7d65..c4db43e5 100644 --- a/packages/player-web/src/scene/default/synesthesia/space.test.ts +++ b/packages/player-web/src/scene/default/synesthesia/space.test.ts @@ -3,13 +3,17 @@ import { burstParticlePosition, burstParticles, emberColor, + fibonacciSphere, + limbGlow, hsvToHex, mixCamera, + orbitParticles, + orbitPosition, particleRiverPoint, - pointCloudHumanoid, pointCloudPyramid, projectPoint, randomShot, + reflectedLight, REST_CAMERA, roamingCamera, rotateX, @@ -17,6 +21,7 @@ import { starfieldPoint, vanishingPoint, viewPoint, + wanderPoint, } from './space.ts'; describe('projectPoint', () => { @@ -147,22 +152,60 @@ describe('pointCloudPyramid', () => { }); }); -describe('pointCloudHumanoid', () => { - it('stands about two units tall, head up', () => { - const points = pointCloudHumanoid(1, 600); - expect(points).toHaveLength(600); - const ys = points.map((point) => point.y); - expect(Math.min(...ys)).toBeGreaterThan(-1.05); - expect(Math.min(...ys)).toBeLessThan(-0.9); - expect(Math.max(...ys)).toBeLessThan(1.05); - expect(Math.max(...ys)).toBeGreaterThan(0.85); - // Arms spread wider than the torso. - const xs = points.map((point) => Math.abs(point.x)); - expect(Math.max(...xs)).toBeGreaterThan(0.6); +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); }); +}); - it('is deterministic for a seed', () => { - expect(pointCloudHumanoid(4, 30)).toEqual(pointCloudHumanoid(4, 30)); +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); }); }); @@ -283,3 +326,46 @@ describe('roamingCamera', () => { 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); + }); +}); diff --git a/packages/player-web/src/scene/default/synesthesia/space.ts b/packages/player-web/src/scene/default/synesthesia/space.ts index 52354a6c..8223c67a 100644 --- a/packages/player-web/src/scene/default/synesthesia/space.ts +++ b/packages/player-web/src/scene/default/synesthesia/space.ts @@ -2,7 +2,7 @@ import { hash01 } from '../phantom-style.ts'; /** * Pure 3D helpers for the Synesthesia skin: a pinhole projection, deterministic particle bursts, a looping starfield, - * point-cloud shapes (pyramids, a human figure), particle rivers, and ember / hue-based colour. Everything is a + * 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. */ @@ -212,59 +212,82 @@ export function pointCloudPyramid(seed: number, count: number): CloudPoint[] { } /** - * A floating human figure as a point cloud — Rez's particle avatar. Roughly 2 units tall (head top at `y ≈ -1`, - * feet at `y ≈ 1`), arms spread slightly down and out, legs trailing as if drifting. Points scatter inside capsules - * along each limb, weighted by limb volume. Deterministic for `seed`. + * `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 pointCloudHumanoid(seed: number, count: number): CloudPoint[] { +export function fibonacciSphere(count: number, seed: number): CloudPoint[] { const points: CloudPoint[] = []; - const totalWeight = HUMANOID_LIMBS.reduce((sum, limb) => sum + limb.volume, 0); + const golden = Math.PI * (3 - Math.sqrt(5)); for (let index = 0; index < count; index += 1) { - const base = seed * 151 + index * 19; - let pick = hash01(base + 1) * totalWeight; - let limb = HUMANOID_LIMBS[HUMANOID_LIMBS.length - 1]!; - for (const candidate of HUMANOID_LIMBS) { - pick -= candidate.volume; - if (pick <= 0) { - limb = candidate; - break; - } - } - const centre = lerp3(limb.from, limb.to, hash01(base + 2)); - // Random direction inside the capsule; sqrt biases toward the surface so the body reads as a shell of light. - const cosTheta = 1 - 2 * hash01(base + 3); - const sinTheta = Math.sqrt(Math.max(0, 1 - cosTheta * cosTheta)); - const phi = Math.PI * 2 * hash01(base + 4); - const r = limb.radius * Math.sqrt(hash01(base + 5)); + 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: centre.x + sinTheta * Math.cos(phi) * r, - y: centre.y + cosTheta * r, - z: centre.z + sinTheta * Math.sin(phi) * r, - weight: 0.3 + 0.7 * hash01(base + 6), + x: Math.cos(theta) * ring, + y, + z: Math.sin(theta) * ring, + weight: 0.3 + 0.7 * hash01(seed * 173 + index), }); } return points; } -const HUMANOID_LIMBS: ReadonlyArray<{ from: Vec3; to: Vec3; radius: number; volume: number }> = ( - [ - // [from, to, radius] - [{ x: 0, y: -0.86, z: 0 }, { x: 0, y: -0.8, z: 0 }, 0.13], // head - [{ x: 0, y: -0.66, z: 0 }, { x: 0, y: -0.12, z: 0.02 }, 0.17], // torso - [{ x: -0.1, y: -0.12, z: 0 }, { x: 0.1, y: -0.12, z: 0 }, 0.13], // hips - [{ x: -0.2, y: -0.62, z: 0 }, { x: -0.46, y: -0.32, z: 0.05 }, 0.06], // left upper arm - [{ x: -0.46, y: -0.32, z: 0.05 }, { x: -0.66, y: -0.02, z: 0.12 }, 0.05], // left forearm - [{ x: 0.2, y: -0.62, z: 0 }, { x: 0.46, y: -0.34, z: -0.04 }, 0.06], // right upper arm - [{ x: 0.46, y: -0.34, z: -0.04 }, { x: 0.7, y: -0.1, z: -0.1 }, 0.05], // right forearm - [{ x: -0.1, y: -0.06, z: 0 }, { x: -0.18, y: 0.42, z: 0.1 }, 0.08], // left thigh - [{ x: -0.18, y: 0.42, z: 0.1 }, { x: -0.22, y: 0.94, z: 0.24 }, 0.06], // left shin - [{ x: 0.1, y: -0.06, z: 0 }, { x: 0.14, y: 0.4, z: -0.08 }, 0.08], // right thigh - [{ x: 0.14, y: 0.4, z: -0.08 }, { x: 0.2, y: 0.9, z: 0.06 }, 0.06], // right shin - ] as const -).map(([from, to, radius]) => { - const length = Math.hypot(to.x - from.x, to.y - from.y, to.z - from.z); - return { from, to, radius, volume: (length + radius) * radius * radius }; -}); +/** + * 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 a Magnetosphere-style 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 @@ -381,3 +404,51 @@ export function roamingCamera( 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 }; +} From 82063b16ef0791c83a80e7803ec24065ac5eca2c Mon Sep 17 00:00:00 2001 From: Seiya KONNO Date: Tue, 29 Sep 2026 15:39:22 +0900 Subject: [PATCH 12/65] docs(player-web): describe built-in skin inspirations generically [ci skip] Replace references to specific people, games, and visualizers in skin comments and the changeset with generic descriptions. --- .changeset/be-music-skins.md | 2 +- .../player-web/src/scene/default/lattice/index.ts | 4 ++-- .../src/scene/default/synesthesia/draw.ts | 8 ++++---- .../scene/default/synesthesia/gameplay-chrome.ts | 14 +++++++------- .../src/scene/default/synesthesia/index.ts | 2 +- .../src/scene/default/synesthesia/moments.ts | 2 +- .../src/scene/default/synesthesia/playfield.ts | 6 +++--- .../src/scene/default/synesthesia/result.ts | 2 +- .../src/scene/default/synesthesia/select.ts | 8 ++++---- .../src/scene/default/synesthesia/space.ts | 4 ++-- .../src/scene/default/synesthesia/style.ts | 4 ++-- 11 files changed, 28 insertions(+), 28 deletions(-) diff --git a/.changeset/be-music-skins.md b/.changeset/be-music-skins.md index 4e099f66..e8bb05a5 100644 --- a/.changeset/be-music-skins.md +++ b/.changeset/be-music-skins.md @@ -8,7 +8,7 @@ The default (skinless) family now renders through a `BeMusicSkin`: one object bu - New exports: `BeMusicSkin` and its context types, `createBeMusicSkinRegistry`, `resolveBeMusicLaneKind`, `resolveSelectListWindow`, `phantomSkin`, `synesthesiaSkin`, `latticeSkin`, and `BUILT_IN_BE_MUSIC_SKINS`. - Phantom is the existing poster skin and still the default. 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 half and a black-and-white starburst half; and the result wipe tears away along a ripped edge with RESULT cut from magazines. -- Synesthesia is inspired by the sound-and-light games of Tetsuya Mizuguchi, after Rez Infinite's Area X: a black void lit by an ember horizon, a world built from fine ember / gold particles with electric blue and magenta accents, data dust and speed streaks pouring out of the vanishing point, a floor of light points, rivers of particles, point-cloud pyramids, and a roaming audio orb after flight404's Magnetosphere — 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 particle fountains with voxel sparks, motion trails, tilted shockwave rings, 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 a 3D particle ring that bursts as the rank ignites. It draws with Michroma and M PLUS 1p, which hosts should load. +- Synesthesia is inspired by synaesthetic sound-and-light rhythm shooters: a black void lit by an ember horizon, a world built from fine ember / gold particles with electric blue and magenta accents, data dust and speed streaks pouring out of the vanishing point, 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 particle fountains with voxel sparks, motion trails, tilted shockwave rings, 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 a 3D particle ring that bursts 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 `renderChrome` (as `runtime.audio`), `renderLanes` and `renderBombs` (as `audio`), and the select renderer's `tick` (fourth argument). Gameplay analyses the whole mix off the audio bus output; select analyses the BGM and chart preview. 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. - 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. diff --git a/packages/player-web/src/scene/default/lattice/index.ts b/packages/player-web/src/scene/default/lattice/index.ts index 73046ec1..d96f136c 100644 --- a/packages/player-web/src/scene/default/lattice/index.ts +++ b/packages/player-web/src/scene/default/lattice/index.ts @@ -11,8 +11,8 @@ import { latticeResultSkin } from './result.ts'; import { latticeSelectSkin } from './select.ts'; /** - * Lattice — a skin in the manner of precise, typographic interaction design (the Japanese web / broadcast design - * school of UNIQLOCK and "design ah"): warm paper and graph-paper rules, ink with one cobalt accent, a field of + * 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. */ diff --git a/packages/player-web/src/scene/default/synesthesia/draw.ts b/packages/player-web/src/scene/default/synesthesia/draw.ts index 56545fb0..4a809081 100644 --- a/packages/player-web/src/scene/default/synesthesia/draw.ts +++ b/packages/player-web/src/scene/default/synesthesia/draw.ts @@ -21,7 +21,7 @@ import { import { SYN_CYAN, SYN_GLASS, SYN_MAGENTA, SYN_WHITE, synGlowTexture } from './style.ts'; /** - * Shared Synesthesia drawing: hairline frames with lock-on corners (Rez's targeting reticle) and projected point + * 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. */ @@ -110,7 +110,7 @@ function quantizeColor(color: number): number { } /** - * Rez lock-on reticle: four rounded corner brackets around `(x, y, w, h)` and an optional centre cross. `arm` is the + * 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( @@ -159,7 +159,7 @@ export function drawReticle( } } -/** A near-black frame with a warm hairline rim and bright lock-on corners — the Rez take on a HUD panel. */ +/** 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, @@ -353,7 +353,7 @@ const SHELL_COLORS = [ORB_VIOLET, ORB_BLUE, ORB_WHITE, ORB_VIOLET, ORB_PINK, ORB const TRAIL_STEPS = 12; /** - * A Magnetosphere-style orb (after flight404's iTunes visualizer): a hollow shell of countless violet / blue / pink / + * 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), diff --git a/packages/player-web/src/scene/default/synesthesia/gameplay-chrome.ts b/packages/player-web/src/scene/default/synesthesia/gameplay-chrome.ts index bbb5c9d0..ed2c9b05 100644 --- a/packages/player-web/src/scene/default/synesthesia/gameplay-chrome.ts +++ b/packages/player-web/src/scene/default/synesthesia/gameplay-chrome.ts @@ -90,7 +90,7 @@ const ORB = orbitParticles(11, 22); const PYRAMIDS = [pointCloudPyramid(3, 520), pointCloudPyramid(8, 420), pointCloudPyramid(5, 700)]; /** - * Synesthesia gameplay HUD after Rez Infinite's Area X: a black void lit by an ember horizon, data dust and speed + * Synesthesia gameplay HUD after a cosmic particle world: a black void lit by an ember horizon, data dust and speed * streaks 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. @@ -252,7 +252,7 @@ function drawGround( /** * Data dust (tiny warm squares, the odd blue / magenta mote) flying out of the vanishing point, and speed streaks — - * Rez's warp lines — that multiply with the combo tier and surge on key presses. + * warp lines — that multiply with the combo tier and surge on key presses. */ function drawDust( graphics: Graphics, @@ -311,7 +311,7 @@ function drawDust( } /** - * Area X floor: a lattice of light points scrolling toward the player on the beat, over faint radial guide lines, with + * Particle-world floor: a lattice of light points scrolling toward the player on the beat, over faint radial guide lines, with * beat rings rolling out of the vanishing point once the run is deep in the zone. */ function drawFloor( @@ -362,7 +362,7 @@ function drawFloor( const RIVER_PARTICLES = 280; /** - * A river of light particles streaming low over the floor, receding diagonally into the distance — Area X's golden + * 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( @@ -453,8 +453,8 @@ function drawPlayfieldWell(graphics: Graphics, right: number, progressRatio: num } /** - * Monitor frame: a warm hairline with lock-on corners. With no BGA the screen idles on a miniature Area X — a floating - * Magnetosphere-style orb roaming in front of particle pyramids on an ember horizon, streaks pouring past — the + * 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, streaks pouring past — the * "nothing is playing, but the space is alive" state. The idle scene draws into `light` (additive). */ function drawBgaFrame( @@ -549,7 +549,7 @@ function drawBgaFrame( .lineTo(x1, y1) .stroke({ color: index % 5 === 0 ? SYN_CYAN : SYN_AMBER, width: 1, alpha: Math.sin(progress * Math.PI) * 0.5 }); } - // The audio orb roams the monitor's little world close to the camera: a Magnetosphere-style shell of sparks and + // 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(); diff --git a/packages/player-web/src/scene/default/synesthesia/index.ts b/packages/player-web/src/scene/default/synesthesia/index.ts index 8a49a5c7..d91da475 100644 --- a/packages/player-web/src/scene/default/synesthesia/index.ts +++ b/packages/player-web/src/scene/default/synesthesia/index.ts @@ -11,7 +11,7 @@ import { synesthesiaResultSkin } from './result.ts'; import { synesthesiaSelectSkin } from './select.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. */ diff --git a/packages/player-web/src/scene/default/synesthesia/moments.ts b/packages/player-web/src/scene/default/synesthesia/moments.ts index ae19c91b..89787c78 100644 --- a/packages/player-web/src/scene/default/synesthesia/moments.ts +++ b/packages/player-web/src/scene/default/synesthesia/moments.ts @@ -183,7 +183,7 @@ function drawMilestone( hasBga: boolean, pool: ChildPool, ): void { - // Each hundred takes the next Rez light in turn: gold, electric blue, magenta. + // 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]!; const wave = easeOutCubic(Math.min(1, t / 0.7)); const radius = 30 + 700 * wave; diff --git a/packages/player-web/src/scene/default/synesthesia/playfield.ts b/packages/player-web/src/scene/default/synesthesia/playfield.ts index 57a90037..8b2b5517 100644 --- a/packages/player-web/src/scene/default/synesthesia/playfield.ts +++ b/packages/player-web/src/scene/default/synesthesia/playfield.ts @@ -30,7 +30,7 @@ interface LaneLight { hue: number; } -// Readable first, in Rez Infinite's palette: white keys burn warm white-gold with an ember halo, black keys run +// 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. const LIGHTS: Record = { white: { body: 0xfff1dc, glow: 0xff7a1e, hue: 0.07 }, @@ -160,9 +160,9 @@ function cachedBurst(seed: number): BurstParticle[] { * 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, - * 6. a Rez lock-on reticle that snaps shut on the hit and fades. + * 6. a lock-on reticle that snaps shut on the hit and fades. * - * The smaller sparks fly as square voxels rather than round glows, like Rez's shattering wireframes. + * 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 }: BeMusicBombsContext): void { diff --git a/packages/player-web/src/scene/default/synesthesia/result.ts b/packages/player-web/src/scene/default/synesthesia/result.ts index 3c055903..4baaf95a 100644 --- a/packages/player-web/src/scene/default/synesthesia/result.ts +++ b/packages/player-web/src/scene/default/synesthesia/result.ts @@ -39,7 +39,7 @@ const RANK_BURST = burstParticles(29, 260); export const synesthesiaResultSkin: BeMusicResultSkin = { render: (frame) => renderSynesthesiaResult(frame) }; /** - * Synesthesia result, in Rez Infinite's Area X light: a black void with ember dust, speed streaks and a floor of light + * Synesthesia result, in the light of a cosmic particle world: a black void with ember dust, speed streaks 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 inside a spinning 3D ring of particles that bursts * outward the moment it lands — a lock-on reticle snapping shut on it. diff --git a/packages/player-web/src/scene/default/synesthesia/select.ts b/packages/player-web/src/scene/default/synesthesia/select.ts index 288ce2f0..e18aaaff 100644 --- a/packages/player-web/src/scene/default/synesthesia/select.ts +++ b/packages/player-web/src/scene/default/synesthesia/select.ts @@ -89,9 +89,9 @@ function framePanel(graphics: Graphics, x: number, y: number, w: number, h: numb } /** - * Synesthesia song select, after Rez Infinite's Area X. The persistent back layer is a particle world — data dust and + * Synesthesia song select, set in a cosmic particle world. The persistent back layer is a particle world — data dust and * speed streaks pouring out of the vanishing point at the focused chart's tempo, point-cloud pyramids on an ember - * horizon, a floor of light points, a golden river of particles, and a roaming Magnetosphere-style audio orb — redrawn cheaply + * horizon, 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 BeMusicSelectRenderer { @@ -312,7 +312,7 @@ class SynesthesiaSelectRenderer implements BeMusicSelectRenderer { head.y, ); } - // Speed streaks — Rez's warp lines — thicken into a tunnel on launch. + // Speed streaks — warp lines — thicken into a tunnel on launch. const streakSpeed = (0.35 + beatsPerSecond * 0.12) * (1 + 6 * warp); for (let index = 0; index < STREAK_COUNT; index += 1) { const angle = hash01(index * 5 + 1) * Math.PI * 2; @@ -351,7 +351,7 @@ class SynesthesiaSelectRenderer implements BeMusicSelectRenderer { ); } } - // Two audio orbs roam the space — the big one and its black moon right up by the camera — Magnetosphere-style shells of sparks and fibres, the big one + // 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(); diff --git a/packages/player-web/src/scene/default/synesthesia/space.ts b/packages/player-web/src/scene/default/synesthesia/space.ts index 8223c67a..ed790a5d 100644 --- a/packages/player-web/src/scene/default/synesthesia/space.ts +++ b/packages/player-web/src/scene/default/synesthesia/space.ts @@ -147,7 +147,7 @@ export interface CloudPoint extends Vec3 { } /** - * Ember → gold → white-hot ramp for `t` in 0..1 (clamped) — the warm light that dominates Rez Infinite's particle + * 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 { @@ -243,7 +243,7 @@ export function limbGlow(normalZ: number, floor = 0.25): number { } /** - * One particle of a Magnetosphere-style orb: a light riding a tilted circular orbit around the core, tied to a + * One particle of the audio orb: a light riding a tilted circular orbit around the core, tied to a * spectrum band. */ export interface OrbitParticle { diff --git a/packages/player-web/src/scene/default/synesthesia/style.ts b/packages/player-web/src/scene/default/synesthesia/style.ts index d7ff4cb4..ffe48e87 100644 --- a/packages/player-web/src/scene/default/synesthesia/style.ts +++ b/packages/player-web/src/scene/default/synesthesia/style.ts @@ -1,7 +1,7 @@ import { Texture } from 'pixi.js'; /** - * Synesthesia skin tokens, after Rez Infinite's Area X: a true-black void, a world built from ember / gold particles, + * 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. */ @@ -26,7 +26,7 @@ export const SYN_DISPLAY_FONT = 'Michroma, Exo 2, M PLUS 1p, LINE Seed JP, sans- 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 red-orange → amber band (Area X's ember light), + * 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 { From b19343f3fde2ac9dd08d4769a59a86e017eef291 Mon Sep 17 00:00:00 2001 From: Seiya KONNO Date: Tue, 29 Sep 2026 19:59:57 +0900 Subject: [PATCH 13/65] feat(player-web): pack skinless lanes edge to edge and draw notes full lane width Drop the 2 px gap between lanes on a side (the playfield keeps its width, so each lane absorbs its share; the DP side gap stays), and stop the built-in skins from insetting notes and long notes inside the lane. --- .changeset/be-music-skins.md | 1 + packages/player-web/src/scene/default/lattice/playfield.ts | 6 +++--- packages/player-web/src/scene/default/phantom/playfield.ts | 6 +++--- .../player-web/src/scene/default/synesthesia/playfield.ts | 6 +++--- packages/player-web/src/scene/gameplay-lanes.ts | 3 ++- packages/player-web/src/scene/lr2/gameplay-lanes.test.ts | 4 ++-- 6 files changed, 14 insertions(+), 12 deletions(-) diff --git a/.changeset/be-music-skins.md b/.changeset/be-music-skins.md index e8bb05a5..61003ca5 100644 --- a/.changeset/be-music-skins.md +++ b/.changeset/be-music-skins.md @@ -11,6 +11,7 @@ The default (skinless) family now renders through a `BeMusicSkin`: one object bu - Synesthesia is inspired by synaesthetic sound-and-light rhythm shooters: a black void lit by an ember horizon, a world built from fine ember / gold particles with electric blue and magenta accents, data dust and speed streaks pouring out of the vanishing point, 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 particle fountains with voxel sparks, motion trails, tilted shockwave rings, 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 a 3D particle ring that bursts 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 `renderChrome` (as `runtime.audio`), `renderLanes` and `renderBombs` (as `audio`), and the select renderer's `tick` (fourth argument). Gameplay analyses the whole mix off the audio bus output; select analyses the BGM and chart preview. 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. +- Skinless lanes now sit edge to edge (the 2 px gaps between lanes are gone; the playfield keeps its width and the DP side gap stays), and every built-in skin draws notes and long notes across the full lane width. - 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. diff --git a/packages/player-web/src/scene/default/lattice/playfield.ts b/packages/player-web/src/scene/default/lattice/playfield.ts index 36a159d4..52c4019e 100644 --- a/packages/player-web/src/scene/default/lattice/playfield.ts +++ b/packages/player-web/src/scene/default/lattice/playfield.ts @@ -85,14 +85,14 @@ export function renderLatticeLanes({ graphics, lanes, beatPhase, combo, effects } export function renderLatticeNote({ graphics, kind, x, w, y }: BeMusicNoteContext): void { - graphics.rect(x + 1, y - NOTE_HEIGHT, Math.max(3, w - 2), NOTE_HEIGHT).fill(LANE_COLORS[kind]); + graphics.rect(x, y - NOTE_HEIGHT, Math.max(3, w), NOTE_HEIGHT).fill(LANE_COLORS[kind]); } /** Long note: an outlined column ruled with fine horizontal hatching, capped by solid heads. */ export function renderLatticeLongNote({ graphics, kind, x, w, top, bottom }: BeMusicLongNoteContext): void { const color = LANE_COLORS[kind]; - const bodyX = x + 3; - const bodyW = Math.max(3, w - 6); + const bodyX = x; + const bodyW = Math.max(3, w); const bodyTop = top - NOTE_HEIGHT; const bodyBottom = bottom - NOTE_HEIGHT; if (bodyBottom > bodyTop) { diff --git a/packages/player-web/src/scene/default/phantom/playfield.ts b/packages/player-web/src/scene/default/phantom/playfield.ts index 1e72459b..3af00b47 100644 --- a/packages/player-web/src/scene/default/phantom/playfield.ts +++ b/packages/player-web/src/scene/default/phantom/playfield.ts @@ -80,13 +80,13 @@ export function renderPhantomLanes({ graphics, lanes, beatPhase }: BeMusicLanesC } export function renderPhantomNote({ graphics, kind, x, w, y }: BeMusicNoteContext): void { - drawNoteBody(graphics, x + 1, y, Math.max(4, w - 2), TONES[kind]); + drawNoteBody(graphics, x, y, Math.max(4, w), TONES[kind]); } export function renderPhantomLongNote({ graphics, kind, x, w, top, bottom }: BeMusicLongNoteContext): 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. diff --git a/packages/player-web/src/scene/default/synesthesia/playfield.ts b/packages/player-web/src/scene/default/synesthesia/playfield.ts index 8b2b5517..dfcf81b7 100644 --- a/packages/player-web/src/scene/default/synesthesia/playfield.ts +++ b/packages/player-web/src/scene/default/synesthesia/playfield.ts @@ -107,13 +107,13 @@ export function renderSynesthesiaLanes({ } export function renderSynesthesiaNote({ graphics, kind, x, w, y }: BeMusicNoteContext): void { - drawNote(graphics, x + 2, y, Math.max(4, w - 4), LIGHTS[kind]); + drawNote(graphics, x, y, Math.max(4, w), 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 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: soft outer glow, translucent core, bright centre filament. diff --git a/packages/player-web/src/scene/gameplay-lanes.ts b/packages/player-web/src/scene/gameplay-lanes.ts index 3b66ff09..d31e48a3 100644 --- a/packages/player-web/src/scene/gameplay-lanes.ts +++ b/packages/player-web/src/scene/gameplay-lanes.ts @@ -11,7 +11,8 @@ export { resolveSideRelativeLaneIndex, } from '@be-music/player/core/lane-layout'; -const FALLBACK_LANE_GAP = 2; +/** Lanes on one side sit edge to edge; only the DP side gap separates the 1P and 2P banks. */ +const FALLBACK_LANE_GAP = 0; const FALLBACK_DP_SIDE_GAP = 14; const FALLBACK_SCRATCH_LANE_WEIGHT = 1.55; 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..452a866a 100644 --- a/packages/player-web/src/scene/lr2/gameplay-lanes.test.ts +++ b/packages/player-web/src/scene/lr2/gameplay-lanes.test.ts @@ -17,7 +17,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 +28,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); }); From f47d4067d6186751f3d999203954ce5d84f6f75a Mon Sep 17 00:00:00 2001 From: Seiya KONNO Date: Tue, 29 Sep 2026 20:02:40 +0900 Subject: [PATCH 14/65] feat(player-web): butt the DP 1P and 2P lane banks together Drop the default 14 px side gap between the double-play banks so skinless lanes run edge to edge across both sides; callers can still pass sideGap. --- .changeset/be-music-skins.md | 2 +- packages/player-web/src/scene/gameplay-lanes.ts | 4 ++-- .../player-web/src/scene/lr2/gameplay-lanes.test.ts | 10 +++++----- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.changeset/be-music-skins.md b/.changeset/be-music-skins.md index 61003ca5..4c438206 100644 --- a/.changeset/be-music-skins.md +++ b/.changeset/be-music-skins.md @@ -11,7 +11,7 @@ The default (skinless) family now renders through a `BeMusicSkin`: one object bu - Synesthesia is inspired by synaesthetic sound-and-light rhythm shooters: a black void lit by an ember horizon, a world built from fine ember / gold particles with electric blue and magenta accents, data dust and speed streaks pouring out of the vanishing point, 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 particle fountains with voxel sparks, motion trails, tilted shockwave rings, 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 a 3D particle ring that bursts 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 `renderChrome` (as `runtime.audio`), `renderLanes` and `renderBombs` (as `audio`), and the select renderer's `tick` (fourth argument). Gameplay analyses the whole mix off the audio bus output; select analyses the BGM and chart preview. 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. -- Skinless lanes now sit edge to edge (the 2 px gaps between lanes are gone; the playfield keeps its width and the DP side gap stays), and every built-in skin draws notes and long notes across the full lane width. +- Skinless lanes now sit edge to edge: the 2 px gaps between lanes and the 14 px gap between the DP 1P / 2P banks are gone (the playfield keeps its width, and `sideGap` can still request a gap), and every built-in skin draws notes and long notes across the full lane width. - 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. diff --git a/packages/player-web/src/scene/gameplay-lanes.ts b/packages/player-web/src/scene/gameplay-lanes.ts index d31e48a3..b5e53be9 100644 --- a/packages/player-web/src/scene/gameplay-lanes.ts +++ b/packages/player-web/src/scene/gameplay-lanes.ts @@ -11,9 +11,9 @@ export { resolveSideRelativeLaneIndex, } from '@be-music/player/core/lane-layout'; -/** Lanes on one side sit edge to edge; only the DP side gap separates the 1P and 2P banks. */ +/** 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 = 14; +const FALLBACK_DP_SIDE_GAP = 0; const FALLBACK_SCRATCH_LANE_WEIGHT = 1.55; export interface FallbackLaneLayoutRect { 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 452a866a..be0e673d 100644 --- a/packages/player-web/src/scene/lr2/gameplay-lanes.test.ts +++ b/packages/player-web/src/scene/lr2/gameplay-lanes.test.ts @@ -61,7 +61,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 +74,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 +135,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 +147,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', () => { From 55ca45dc443d2e236ab766fb512c6f2e540da662 Mon Sep 17 00:00:00 2001 From: Seiya KONNO Date: Sun, 4 Oct 2026 20:50:05 +0900 Subject: [PATCH 15/65] feat(player-web): widen the skinless 24 KEY and 48 KEY lanes Keyboard modes squeezed 24 lanes per side into the 7K playfield (8 px each). 24 KEY now fills the column left of the BGA monitor and 48 KEY spreads both banks across the page. resolveSkinlessLaneLayout bundles the span, DP side preservation and bounds so the core renderer and the built-in skins share one layout. --- .../player-web/src/scene/core/gameplay.ts | 18 +++---- .../player-web/src/scene/gameplay-lanes.ts | 50 +++++++++++++++++++ .../src/scene/lr2/gameplay-lanes.test.ts | 48 ++++++++++++++++++ 3 files changed, 105 insertions(+), 11 deletions(-) diff --git a/packages/player-web/src/scene/core/gameplay.ts b/packages/player-web/src/scene/core/gameplay.ts index 44beef92..0bf25001 100644 --- a/packages/player-web/src/scene/core/gameplay.ts +++ b/packages/player-web/src/scene/core/gameplay.ts @@ -116,11 +116,10 @@ import { } from './gameplay-bga.ts'; import { isPlayableInputChannel, - resolveFallbackLaneLayout, resolveLaneChannels, resolveLr2LaneIndex, resolveSideRelativeLaneIndex, - shouldPreserveFallbackSideWidth, + resolveSkinlessLaneLayout, } from '../gameplay-lanes.ts'; import type { SkinlessGameplayChromeRenderer, @@ -3519,21 +3518,18 @@ 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; diff --git a/packages/player-web/src/scene/gameplay-lanes.ts b/packages/player-web/src/scene/gameplay-lanes.ts index b5e53be9..228f1140 100644 --- a/packages/player-web/src/scene/gameplay-lanes.ts +++ b/packages/player-web/src/scene/gameplay-lanes.ts @@ -16,6 +16,18 @@ const FALLBACK_LANE_GAP = 0; const FALLBACK_DP_SIDE_GAP = 0; const FALLBACK_SCRATCH_LANE_WEIGHT = 1.55; +/** 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; @@ -38,6 +50,44 @@ export interface ResolveFallbackLaneLayoutOptions { preserveSideWidth?: boolean; } +/** + * 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 lanes = resolveFallbackLaneLayout({ + channels, + laneCount, + playVariant, + x: span.x, + w: span.w, + preserveSideWidth: shouldPreserveFallbackSideWidth(channels, playVariant), + }); + const right = Math.max(span.x + span.w, ...lanes.map((lane) => lane.x + lane.w)); + return { lanes, left: span.x, right }; +} + export function isScratchLaneForVariant(channel: string, playVariant?: ChartPlayVariant): boolean { return resolveCoreSideKeySlot(channel, playVariant) === 0; } 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 be0e673d..ebd3651f 100644 --- a/packages/player-web/src/scene/lr2/gameplay-lanes.test.ts +++ b/packages/player-web/src/scene/lr2/gameplay-lanes.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from 'vite-plus/test'; import { isScratchLaneForVariant, resolveFallbackLaneLayout, + resolveFallbackPlayfieldSpan, + resolveSkinlessLaneLayout, shouldPreserveFallbackSideWidth, } from '../gameplay-lanes.ts'; @@ -175,3 +177,49 @@ 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); + }); +}); From af63ae28f580e1ce40261ff17c671cd0dc5fd35b Mon Sep 17 00:00:00 2001 From: Seiya KONNO Date: Sun, 4 Oct 2026 20:50:05 +0900 Subject: [PATCH 16/65] fix(player-web): keep built-in skin showpieces and type off the notes - Place the 100-combo cut-in beside the lanes, or below them when a DP field fills that column, in every built-in skin. - Print PERFECT as a colour-cycling GREAT. - Drop every BAD / POOR effect (shake, vignette, glitch, jitter, combo break shatter) so the playfield stays readable. - Lay the skins out on the shared skinless lane layout. - Keep Phantom's launch outro red strip to the left quarter. - Fix type colliding with rules and frames across the three skins. --- .changeset/be-music-skins.md | 11 +- .../src/scene/default/gameplay-render.ts | 42 +++--- .../src/scene/default/judge-word.test.ts | 36 +++++ .../src/scene/default/judge-word.ts | 24 ++++ .../scene/default/lattice/gameplay-chrome.ts | 97 +++++++------ .../src/scene/default/lattice/moments.ts | 84 +++++------- .../src/scene/default/lattice/select.ts | 2 + .../src/scene/default/moments.test.ts | 24 ++++ .../player-web/src/scene/default/moments.ts | 26 ++++ .../src/scene/default/phantom/moments.ts | 129 ++++++------------ .../src/scene/default/phantom/select.ts | 17 +-- .../src/scene/default/phantom/tear.ts | 18 ++- .../default/synesthesia/gameplay-chrome.ts | 85 +++++++----- .../src/scene/default/synesthesia/moments.ts | 99 ++++---------- .../src/scene/default/synesthesia/select.ts | 12 +- 15 files changed, 368 insertions(+), 338 deletions(-) create mode 100644 packages/player-web/src/scene/default/judge-word.test.ts create mode 100644 packages/player-web/src/scene/default/judge-word.ts diff --git a/.changeset/be-music-skins.md b/.changeset/be-music-skins.md index 4c438206..90d42f51 100644 --- a/.changeset/be-music-skins.md +++ b/.changeset/be-music-skins.md @@ -7,18 +7,21 @@ 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). - New exports: `BeMusicSkin` and its context types, `createBeMusicSkinRegistry`, `resolveBeMusicLaneKind`, `resolveSelectListWindow`, `phantomSkin`, `synesthesiaSkin`, `latticeSkin`, and `BUILT_IN_BE_MUSIC_SKINS`. -- Phantom is the existing poster skin and still the default. 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 half and a black-and-white starburst half; and the result wipe tears away along a ripped edge with RESULT cut from magazines. +- Phantom is the existing poster skin and still the default. 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 lit by an ember horizon, a world built from fine ember / gold particles with electric blue and magenta accents, data dust and speed streaks pouring out of the vanishing point, 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 particle fountains with voxel sparks, motion trails, tilted shockwave rings, 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 a 3D particle ring that bursts 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 `renderChrome` (as `runtime.audio`), `renderLanes` and `renderBombs` (as `audio`), and the select renderer's `tick` (fourth argument). Gameplay analyses the whole mix off the audio bus output; select analyses the BGM and chart preview. 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 the whole page (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. - Skinless lanes now sit edge to edge: the 2 px gaps between lanes and the 14 px gap between the DP 1P / 2P banks are gone (the playfield keeps its width, and `sideGap` can still request a gap), and every built-in skin draws notes and long notes across the full lane width. - 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 detonates a ring of light), a cut-in every 100 combo, a flourish when the gauge crosses the clear line, and a full-combo finale (Phantom's turning burst with confetti, Synesthesia's 3D spark sphere and prism rings). 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, speed streaks thicken, the floor burns hotter, more particle rivers and beat rings 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 miss shakes the screen with a red vignette (Phantom) or an RGB glitch (Synesthesia), and breaking a combo of 20 or more shatters the counter. +- 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. +- 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 warp into a white-out) 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. -- New `beMusicEffects` option (`'full'` / `'reduced'` / `'off'`) on the default gameplay, select, and result views tones down or disables screen shake, flashes, particles, and animation for comfort or low-end devices; select renderers can declare an `outroMs` and receive `effects` and `launchAt` in their frame. +- 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. +- 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, STANDBY gets a slip over the monitor's horizon, 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. - `SkinlessGameplayChromeRuntime` gains `totalNotes`, `chartMs`, `judgeAtMs`, `impulseAtMs`, `impulseKind`, and `effects`, and the be-music lane and bomb contexts gain `combo` and `effects`. diff --git a/packages/player-web/src/scene/default/gameplay-render.ts b/packages/player-web/src/scene/default/gameplay-render.ts index c82b34bc..707c4b10 100644 --- a/packages/player-web/src/scene/default/gameplay-render.ts +++ b/packages/player-web/src/scene/default/gameplay-render.ts @@ -1,11 +1,7 @@ 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 { resolveSkinlessLaneLayout, type FallbackLaneLayoutRect } from '../gameplay-lanes.ts'; import type { SkinlessGameplayChromeRuntime } from '../gameplay-chrome.ts'; import { DEFAULT_DISPLAY_FONT, DEFAULT_HEADLINE_FONT } from './fonts.ts'; import { @@ -32,6 +28,7 @@ import type { ChildPool } from '../pixi-utils.ts'; import { audioDrive, bandLevel, type AudioDrive } from './audio-drive.ts'; import { drawPhantomMoments } from './phantom/moments.ts'; import { effectProfile, impulse, punchScale } from './moments.ts'; +import { flashingGreatColor, isFlashingGreat, judgeDisplayWord } from './judge-word.ts'; import { addHudNumber as addNumber, addHudText as addText, type HudTextOptions as TextOptions } from './hud-text.ts'; const DISPLAY_FONT = DEFAULT_DISPLAY_FONT; @@ -122,7 +119,7 @@ export function renderDefaultGameplayFrame( drawStatusBar(front, frontLayer, runtime, 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); + const covered = drawPhantomMoments(layer, frontLayer, runtime, frontPool, playfield.right); if (!covered) drawJudgements(frontLayer, runtime, playfield, frontPool, effects.amount); } @@ -219,16 +216,8 @@ function resolveFallbackPlayfieldLayout( 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 { lanes, left, right } = resolveSkinlessLaneLayout(laneChannels, laneCount, playVariant); + const w = Math.max(1, right - left); const sideBounds = resolveSideBounds(lanes); const sideCenters: Partial> = {}; if (sideBounds['1P']) { @@ -239,9 +228,9 @@ function resolveFallbackPlayfieldLayout( } return { lanes, - x: PLAYFIELD.x, + x: left, w, - centerX: PLAYFIELD.x + w / 2, + centerX: left + w / 2, right, sideCenters, sideGap: @@ -700,7 +689,8 @@ function drawJudgeTally( x: number, pool?: ChildPool, ): void { - const y = BGA.y - 12; + // Top-aligned with the BGA so the header chip clears the status bar's beat-driven teeth. + const y = BGA.y; const w = DESIGN_WIDTH - x - 6; const rowH = 24; const h = 22 + JUDGE_TALLY_ROWS.length * rowH + 46; @@ -789,19 +779,18 @@ function drawJudgements( for (const display of resolveJudgeDisplays(runtime, playfield)) { const combo = resolveVisibleCombo(display.judge, display.combo); const style = judgeStyle(display.judge); - // Punch: every judgement lands with a quick overshoot; misses also rattle sideways. - const judgeScale = punchScale(runtime.judgeAtMs, nowMs, 120, 0.24 * amount); + // Punch: every judgement lands with a quick overshoot (a miss lands plainly, with no rattle). const miss = display.judge === 'POOR' || display.judge === 'BAD'; - const rattle = miss ? Math.sin(nowMs / 14) * 4 * impulse(runtime.judgeAtMs, nowMs, 220) * amount : 0; + const judgeScale = miss ? 1 : punchScale(runtime.judgeAtMs, nowMs, 120, 0.24 * amount); addText( layer, - display.judge, - display.x + rattle, + judgeDisplayWord(display.judge), + display.x, 238, { scale: judgeScale, size: 30, - fill: style.fill, + fill: isFlashingGreat(display.judge) ? flashingGreatColor(nowMs, PHANTOM_FLASHING_GREAT) : style.fill, fontFamily: DISPLAY_FONT, letterSpacing: 1, anchorX: 0.5, @@ -913,6 +902,9 @@ function clampPercent(value: number): number { return Math.max(0, Math.min(100, value)); } +/** 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/judge-word.test.ts b/packages/player-web/src/scene/default/judge-word.test.ts new file mode 100644 index 00000000..deeeb77c --- /dev/null +++ b/packages/player-web/src/scene/default/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/player-web/src/scene/default/judge-word.ts b/packages/player-web/src/scene/default/judge-word.ts new file mode 100644 index 00000000..c5b8ba36 --- /dev/null +++ b/packages/player-web/src/scene/default/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/player-web/src/scene/default/lattice/gameplay-chrome.ts b/packages/player-web/src/scene/default/lattice/gameplay-chrome.ts index 48686217..4b665c94 100644 --- a/packages/player-web/src/scene/default/lattice/gameplay-chrome.ts +++ b/packages/player-web/src/scene/default/lattice/gameplay-chrome.ts @@ -1,10 +1,17 @@ import type { Container, Graphics } 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 { resolveSkinlessLaneLayout } from '../../gameplay-lanes.ts'; import type { ChildPool } from '../../pixi-utils.ts'; import { addHudNumber, addHudText } from '../hud-text.ts'; -import { comboTier, effectProfile, impulse, momentProgress, trackMoments, type MomentState } from '../moments.ts'; +import { + comboTier, + effectProfile, + momentProgress, + resolveMilestoneArea, + trackMoments, + type MomentState, +} from '../moments.ts'; import { addOdometer, addStaggeredHudText, @@ -17,6 +24,7 @@ import { } from './draw.ts'; import { scrambleText, scrambleTick, springEase, tileFlipPhase, type NeedleFieldInput } from './field.ts'; import { drawLatticeMoments, FULL_COMBO_MS, MILESTONE_MS } from './moments.ts'; +import { flashingGreatColor, isFlashingGreat, judgeDisplayWord } from '../judge-word.ts'; import { createChain, kickChain, stepChain, type Chain } from './physics.ts'; import { audioDrive, bandLevel, type AudioDrive } from '../audio-drive.ts'; import { @@ -65,7 +73,11 @@ export function renderLatticeChrome({ const seconds = nowMs / 1000; const beatPhase = runtime.beatPhase ?? 0; const hasBga = runtime.hasBga === true; - const playfieldRight = resolvePlayfieldRight(runtime); + const { left: playfieldLeft, right: playfieldRight } = resolveSkinlessLaneLayout( + runtime.laneChannels, + runtime.laneCount, + runtime.playVariant, + ); 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 @@ -95,7 +107,7 @@ export function renderLatticeChrome({ field.blendMode = 'normal'; // The field fills the paper around the type: the playfield and every HUD block stay clean. const clean: Rect[] = [ - { x: PLAYFIELD.x - 20, y: 0, w: playfieldRight - PLAYFIELD.x + 26, h: 346 }, + { x: playfieldLeft - 20, y: 0, w: playfieldRight - playfieldLeft + 26, h: 346 }, { 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 }, @@ -107,7 +119,7 @@ export function renderLatticeChrome({ drawNeedleField( field, { x: 0, y: HEADER_H, w: DESIGN_WIDTH, h: DESIGN_HEIGHT - HEADER_H }, - resolveFieldInput(layer, runtime, moments, tier, effects.amount, playfieldRight, drive), + resolveFieldInput(layer, runtime, moments, tier, effects.amount, playfieldLeft, playfieldRight, drive), { alpha: 0.55, skip: (x, y) => clean.some((rect) => inside(rect, x, y, 0)), @@ -117,7 +129,7 @@ export function renderLatticeChrome({ const panels = layerPool.acquireGraphics(); panels.label = 'lattice-gameplay/panels'; panels.blendMode = 'normal'; - drawPlayfieldFrame(panels, playfieldRight, runtime.progressRatio); + drawPlayfieldFrame(panels, playfieldLeft, playfieldRight, runtime.progressRatio); if (pendulums) { drawPendulums(panels, layer, runtime, (playfieldRight + BGA.x - 5) / 2, effects.amount, drive, layerPool); } @@ -142,7 +154,14 @@ export function renderLatticeChrome({ drawHeader(front, overlayLayer, runtime, beatPhase, overlayLayerPool); drawJudgements(overlayLayer, runtime, playfieldRight, effects.amount, overlayLayerPool); } - drawLatticeMoments(layer, overlayLayer, runtime, moments, (PLAYFIELD.x + playfieldRight) / 2, overlayLayerPool); + drawLatticeMoments( + overlayLayer, + runtime, + moments, + (playfieldLeft + playfieldRight) / 2, + overlayLayerPool, + playfieldRight, + ); } const IMPULSE_HISTORY = new WeakMap(); @@ -252,8 +271,8 @@ function drawPendulums( /** * 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, a swirl on a full combo, and - * jitter on a miss. + * 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, @@ -261,6 +280,7 @@ function resolveFieldInput( moments: MomentState, tier: number, amount: number, + playfieldLeft: number, playfieldRight: number, drive: AudioDrive, ): NeedleFieldInput { @@ -283,7 +303,7 @@ function resolveFieldInput( history.push(runtime.impulseAtMs); if (history.length > 6) history.shift(); } - const originX = (PLAYFIELD.x + playfieldRight) / 2; + const originX = (playfieldLeft + playfieldRight) / 2; const ripples = amount > 0 ? [ @@ -299,7 +319,6 @@ function resolveFieldInput( : []; const milestone = momentProgress(moments.milestone?.atMs, nowMs, MILESTONE_MS); const fullCombo = momentProgress(moments.fullComboAtMs, nowMs, FULL_COMBO_MS); - const miss = runtime.lastJudge === 'POOR' || runtime.lastJudge === 'BAD' ? impulse(runtime.judgeAtMs, nowMs, 260) : 0; const envelope = (t: number | undefined) => (t === undefined ? 0 : Math.sin(Math.min(1, t) * Math.PI)); return { seconds: (nowMs / 1000) * (amount > 0 ? 1 : 0), @@ -307,25 +326,19 @@ function resolveFieldInput( flow: (0.35 + 0.12 * tier) * amount + 0.35 * drive.level, beatWave: (0.45 + 0.12 * tier) * amount + 0.4 * drive.bass, ripples, - attractor: { x: BGA.x + BGA.w / 2, y: BGA.y + BGA.h / 2, strength: envelope(milestone) * amount }, + attractor: (() => { + // Needles swing toward wherever the milestone plays (beside or below the lanes). + const area = resolveMilestoneArea(playfieldRight); + 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: miss * amount, + jitter: 0, tremble: 0.07 * amount, }; } -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 inside(rect: Rect, x: number, y: number, margin: number): boolean { return x > rect.x - margin && x < rect.x + rect.w + margin && y > rect.y - margin && y < rect.y + rect.h + margin; } @@ -362,8 +375,7 @@ function cropMark(graphics: Graphics, x: number, y: number, dx: 1 | -1, dy: 1 | .lineTo(x, y - dy * 14); } -function drawPlayfieldFrame(graphics: Graphics, right: number, progressRatio: number | undefined): void { - const left = PLAYFIELD.x; +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); @@ -790,9 +802,12 @@ const JUDGE_COLORS: Record = { 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. - * Misses knock the word sideways. + * PERFECT prints as a GREAT flashing through the inks; a miss lands still. */ function drawJudgements( layer: Container, @@ -804,13 +819,14 @@ function drawJudgements( const nowMs = runtime.nowMs ?? 0; const age = runtime.judgeAtMs !== undefined ? nowMs - runtime.judgeAtMs : Number.POSITIVE_INFINITY; for (const display of resolveJudgeDisplays(runtime, playfieldRight)) { - const color = JUDGE_COLORS[display.judge] ?? LAT_INK; - const miss = display.judge === 'POOR' || display.judge === 'BAD'; - const knock = miss ? Math.sin(age / 18) * 6 * impulse(runtime.judgeAtMs, nowMs, 220) * amount : 0; + 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(display.judge, age / 90, scrambleTick(nowMs, 25), display.judge.length) : display.judge, - display.x + knock, + 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, @@ -847,20 +863,17 @@ function resolveJudgeDisplays( runtime: Runtime, playfieldRight: number, ): Array<{ judge: string; combo: number | undefined; x: 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 { lanes, left: playfieldLeft } = resolveSkinlessLaneLayout( + runtime.laneChannels, + runtime.laneCount, + 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 fallbackX = (PLAYFIELD.x + playfieldRight) / 2; + const fallbackX = (playfieldLeft + 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 })); diff --git a/packages/player-web/src/scene/default/lattice/moments.ts b/packages/player-web/src/scene/default/lattice/moments.ts index db0d07f7..df15835a 100644 --- a/packages/player-web/src/scene/default/lattice/moments.ts +++ b/packages/player-web/src/scene/default/lattice/moments.ts @@ -1,18 +1,23 @@ import type { Container, Graphics } from 'pixi.js'; -import { BGA, DESIGN_HEIGHT, DESIGN_WIDTH, GROOVE, PLAYFIELD } from '../../gameplay-constants.ts'; +import { BGA, DESIGN_HEIGHT, DESIGN_WIDTH, GROOVE } from '../../gameplay-constants.ts'; import type { SkinlessGameplayChromeRuntime } from '../../gameplay-chrome.ts'; import type { ChildPool } from '../../pixi-utils.ts'; import { addHudText } from '../hud-text.ts'; -import { effectProfile, impulse, momentProgress, type MomentState } from '../moments.ts'; +import { + effectProfile, + momentProgress, + type MomentState, + resolveMilestoneArea, + type MilestoneArea, +} from '../moments.ts'; import { easeOutCubic, hash01 } from '../phantom-style.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_SIGNAL, LAT_WHITE } from './style.ts'; +import { LAT_ACCENT, LAT_INK, LAT_PAPER, LAT_WHITE } from './style.ts'; export const MILESTONE_MS = 1400; export const FULL_COMBO_MS = 3200; const CLEAR_MS = 1000; -const BREAK_MS = 900; /** * Lattice showpieces, set as kinetic type on the front layer (the needle field reacts to the same moments in the @@ -23,15 +28,15 @@ const BREAK_MS = 900; * - 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 knocks the HUD sideways in hard 2 px steps; a broken combo's digits fall away under gravity. + * - a miss or a broken combo draws nothing, so the notes stay readable. */ export function drawLatticeMoments( - chromeLayer: Container, layer: Container, runtime: SkinlessGameplayChromeRuntime, moments: MomentState, playfieldCenterX: number, pool: ChildPool, + playfieldRight: number, ): void { const nowMs = runtime.nowMs ?? 0; const effects = effectProfile(runtime.effects); @@ -40,23 +45,21 @@ export function drawLatticeMoments( graphics.label = 'lattice/moments'; graphics.blendMode = 'normal'; - const miss = runtime.lastJudge === 'POOR' || runtime.lastJudge === 'BAD' ? impulse(runtime.judgeAtMs, nowMs, 220) : 0; - if (miss > 0 && effects.screenWide) { - // Quantized knock: the HUD jumps in whole 2 px steps, like a mechanism slipping a tooth. - const knock = Math.round((Math.sin(nowMs / 14) * 3 * miss) / 2) * 2; - chromeLayer.position.set(knock, 0); - layer.position.set(knock, 0); - } - const comboBreak = momentProgress(moments.comboBreak?.atMs, nowMs, BREAK_MS); - if (comboBreak !== undefined && moments.comboBreak) { - drawComboBreak(layer, moments.comboBreak.combo, comboBreak, playfieldCenterX, pool); - } + // 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, pool); + drawMilestone( + graphics, + layer, + moments.milestone.value, + milestone, + runtime.hasBga === true, + resolveMilestoneArea(playfieldRight), + pool, + ); } const clear = momentProgress(moments.clearAtMs, nowMs, CLEAR_MS); if (clear !== undefined) { @@ -136,17 +139,21 @@ function drawMilestone( value: number, t: number, hasBga: boolean, + area: MilestoneArea, pool: ChildPool, ): void { - const cx = BGA.x + BGA.w / 2; - const cy = hasBga ? BGA.y + 36 : BGA.y + BGA.h / 2 - 12; + // Over the monitor beside the lanes, or in the band below them in double play — never over the notes. + const below = area.mode === 'below'; + 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 = hasBga ? 32 : 64; - if (hasBga) { - // A paper label over the live video. - const w = 170 * easeOutCubic(Math.min(1, t / 0.2)); - graphics.rect(cx - w / 2, cy - 26, w, 62).fill({ color: LAT_PAPER, alpha: 0.95 * (1 - out) }); - } + 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), @@ -249,28 +256,3 @@ function drawFullCombo(graphics: Graphics, layer: Container, t: number, screenWi ); label.alpha = Math.min(1, Math.max(0, (t - 0.45) / 0.1)) * (1 - flipOut); } - -/** The broken combo's digits fall off under gravity, each with its own spin and drift. */ -function drawComboBreak(layer: Container, combo: number, t: number, cx: number, pool: ChildPool): void { - const digits = Array.from(String(combo)); - const cell = 18; - const left = cx - (digits.length * cell) / 2; - const seconds = t * (BREAK_MS / 1000); - digits.forEach((char, index) => { - const drift = (hash01(combo * 13 + index) - 0.5) * 70; - const spin = (hash01(combo * 7 + index * 3) - 0.5) * 6; - const x = left + cell * (index + 0.5) + drift * seconds; - const y = 256 - 60 * seconds + 0.5 * 900 * seconds * seconds; - if (y > PLAYFIELD.judgementY + 60) return; - const node = addHudText( - layer, - char, - x, - y, - { ...displayStyle(30, LAT_SIGNAL, '200'), anchorX: 0.5, anchorY: 0.5 }, - pool, - ); - node.rotation = spin * seconds; - node.alpha = 1 - t; - }); -} diff --git a/packages/player-web/src/scene/default/lattice/select.ts b/packages/player-web/src/scene/default/lattice/select.ts index 9381e614..da3e8c8b 100644 --- a/packages/player-web/src/scene/default/lattice/select.ts +++ b/packages/player-web/src/scene/default/lattice/select.ts @@ -346,6 +346,8 @@ class LatticeSelectRenderer implements BeMusicSelectRenderer { { 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, diff --git a/packages/player-web/src/scene/default/moments.test.ts b/packages/player-web/src/scene/default/moments.test.ts index 3d0e253c..de08a83d 100644 --- a/packages/player-web/src/scene/default/moments.test.ts +++ b/packages/player-web/src/scene/default/moments.test.ts @@ -8,6 +8,8 @@ import { punchScale, updateMoments, type MomentInput, + MILESTONE_MIN_WIDTH, + resolveMilestoneArea, } from './moments.ts'; const base: MomentInput = { @@ -119,3 +121,25 @@ describe('effectProfile', () => { 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(640); + expect(area.y + area.h).toBeLessThanOrEqual(346); + }); + + it('drops below the lanes when they leave too little room (double play)', () => { + const area = resolveMilestoneArea(547); + expect(area.mode).toBe('below'); + expect(area.y).toBeGreaterThanOrEqual(346); + expect(area.y + area.h).toBeLessThanOrEqual(480); + }); + + it('switches exactly at the minimum width', () => { + expect(resolveMilestoneArea(640 - 16 - MILESTONE_MIN_WIDTH).mode).toBe('beside'); + expect(resolveMilestoneArea(640 - 16 - MILESTONE_MIN_WIDTH + 1).mode).toBe('below'); + }); +}); diff --git a/packages/player-web/src/scene/default/moments.ts b/packages/player-web/src/scene/default/moments.ts index 3239039b..fb2b6367 100644 --- a/packages/player-web/src/scene/default/moments.ts +++ b/packages/player-web/src/scene/default/moments.ts @@ -131,3 +131,29 @@ export function effectProfile(level: 'full' | 'reduced' | 'off' | undefined): { 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 640×480 design space. + */ +export function resolveMilestoneArea(playfieldRight: number, lanesBottom = 346): MilestoneArea { + const free = 640 - 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: 624, h: 480 - lanesBottom - 8, mode: 'below' }; +} diff --git a/packages/player-web/src/scene/default/phantom/moments.ts b/packages/player-web/src/scene/default/phantom/moments.ts index caa7705e..26c9b5e8 100644 --- a/packages/player-web/src/scene/default/phantom/moments.ts +++ b/packages/player-web/src/scene/default/phantom/moments.ts @@ -1,10 +1,10 @@ import { Graphics, type Container } from 'pixi.js'; -import { BGA, DESIGN_HEIGHT, DESIGN_WIDTH, GROOVE, PLAYFIELD } from '../../gameplay-constants.ts'; +import { BGA, DESIGN_HEIGHT, DESIGN_WIDTH, GROOVE } from '../../gameplay-constants.ts'; import type { SkinlessGameplayChromeRuntime } from '../../gameplay-chrome.ts'; import type { ChildPool } from '../../pixi-utils.ts'; import { DEFAULT_DISPLAY_FONT } from '../fonts.ts'; import { addHudText } from '../hud-text.ts'; -import { effectProfile, impulse, momentProgress, trackMoments } from '../moments.ts'; +import { effectProfile, momentProgress, resolveMilestoneArea, trackMoments } from '../moments.ts'; import { addRansomText, drawTearStrip, stripPoint, type GlyphFactory, type TearStrip } from './tear.ts'; import { PHANTOM_GOLD, @@ -43,6 +43,7 @@ export function drawPhantomMoments( layer: Container, runtime: SkinlessGameplayChromeRuntime, pool: ChildPool | undefined, + playfieldRight: number, ): boolean { const nowMs = runtime.nowMs ?? 0; const moments = trackMoments(chromeLayer, { @@ -63,27 +64,23 @@ export function drawPhantomMoments( graphics.label = 'phantom/moments'; if (!pool) layer.addChild(graphics); - // Misses hit back: a red vignette, and (full effects) a short sideways shake of the whole HUD. - const miss = runtime.lastJudge === 'POOR' || runtime.lastJudge === 'BAD' ? impulse(runtime.judgeAtMs, nowMs, 260) : 0; - if (miss > 0) { - drawMissVignette(graphics, miss); - if (effects.screenWide) { - const shake = Math.sin(nowMs / 11) * 5 * miss; - chromeLayer.position.set(shake, 0); - layer.position.set(shake, 0); - } - } - const comboBreak = momentProgress(moments.comboBreak?.atMs, nowMs, BREAK_MS); - if (comboBreak !== undefined && moments.comboBreak) { - drawComboBreak(graphics, layer, moments.comboBreak.combo, comboBreak, pool); - } + // 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); + drawMilestone( + graphics, + layer, + moments.milestone.value, + milestone, + runtime.hasBga === true, + pool, + nowMs, + playfieldRight, + ); } const clear = momentProgress(moments.clearAtMs, nowMs, CLEAR_MS); if (clear !== undefined) { @@ -96,63 +93,6 @@ export function drawPhantomMoments( return fullCombo !== undefined && fullCombo > 0.04 && fullCombo < 0.9; } -const BREAK_MS = 700; - -/** Red edges bleeding in from the screen border on a BAD / POOR. */ -function drawMissVignette(graphics: Graphics, strength: number): void { - const depth = 34; - for (let band = 0; band < 3; band += 1) { - const inset = band * (depth / 3); - const alpha = 0.22 * strength * (1 - band / 3); - const w = depth / 3; - graphics.rect(inset, 0, w, DESIGN_HEIGHT).fill({ color: PHANTOM_RED, alpha }); - graphics.rect(DESIGN_WIDTH - inset - w, 0, w, DESIGN_HEIGHT).fill({ color: PHANTOM_RED, alpha }); - graphics.rect(0, inset, DESIGN_WIDTH, w).fill({ color: PHANTOM_RED, alpha }); - graphics.rect(0, DESIGN_HEIGHT - inset - w, DESIGN_WIDTH, w).fill({ color: PHANTOM_RED, alpha }); - } -} - -/** A broken combo shatters: the old count drops and fades in red while ink / red shards fly off it. */ -function drawComboBreak( - graphics: Graphics, - layer: Container, - combo: number, - t: number, - pool: ChildPool | undefined, -): void { - const cx = PLAYFIELD.x + PLAYFIELD.w / 2; - const cy = 270; - const fall = easeOutCubic(t); - for (let shard = 0; shard < 10; shard += 1) { - const angle = -Math.PI / 2 + (hash01(shard + combo) - 0.5) * 2.6; - const speed = 40 + hash01(shard * 3 + 1) * 70; - const x = cx + Math.cos(angle) * speed * fall; - const y = cy + Math.sin(angle) * speed * fall + 90 * t * t; - const size = 5 + hash01(shard + 9) * 7; - graphics - .poly(rotatedRect(x, y, size, size * 0.6, t * 8 + shard)) - .fill({ color: shard % 2 === 0 ? PHANTOM_RED_HOT : PHANTOM_INK, alpha: 1 - t }); - } - const text = addHudText( - layer, - String(combo), - cx, - cy + 30 * t * t, - { - size: 24, - fill: PHANTOM_RED_HOT, - fontFamily: DEFAULT_DISPLAY_FONT, - anchorX: 0.5, - anchorY: 0.5, - skewX: SKEW, - stroke: { color: PHANTOM_INK, width: 4, alignment: 0.5, join: 'miter' }, - }, - pool, - ); - text.rotation = 0.35 * t; - text.alpha = 1 - t; -} - /** 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; @@ -229,22 +169,37 @@ function drawMilestone( hasBga: boolean, pool: ChildPool | undefined, nowMs: number, + playfieldRight: number, ): void { - // Over a live BGA the strip rides the monitor's bottom edge instead of covering the middle of the video. - const strip: TearStrip = hasBga - ? { cx: BGA.x + BGA.w / 2 + 40, cy: BGA.y + BGA.h - 14, angle: -0.07, length: 440, thickness: 54, seed: value } - : { cx: 462, cy: 200, angle: -0.12, length: 440, thickness: 86, seed: value }; - const open = drawTearStrip(graphics, strip, t, nowMs); + // 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(playfieldRight); + 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.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); - const burst = stripPoint(strip, -128, 0); + // 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 * (hasBga ? 0.8 : 1), - 32 * size * (hasBga ? 0.8 : 1), + 50 * size * (compact ? 0.8 : 1), + 32 * size * (compact ? 0.8 : 1), 12, t * 3, 0.22, @@ -259,7 +214,7 @@ function drawMilestone( burst.x + 2, burst.y, { - size: hasBga ? 28 : 34, + size: compact ? 28 : 34, fill: PHANTOM_INK, fontFamily: DEFAULT_DISPLAY_FONT, anchorX: 0.5, @@ -270,9 +225,13 @@ function drawMilestone( }, pool, ).scale.y *= size; - const word = stripPoint(strip, 48, 0); + 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: (hasBga ? 30 : 40) * size, + size: wordSize * size, seed: value + 3, angle: strip.angle, appear: (index) => Math.max(0, (t - 0.04 - index * 0.025) / 0.08), diff --git a/packages/player-web/src/scene/default/phantom/select.ts b/packages/player-web/src/scene/default/phantom/select.ts index ec98a265..5e9e986e 100644 --- a/packages/player-web/src/scene/default/phantom/select.ts +++ b/packages/player-web/src/scene/default/phantom/select.ts @@ -119,8 +119,8 @@ class PhantomSelectRenderer implements BeMusicSelectRenderer { } /** - * Launch outro — the split screen: a blood-red half full of out-of-focus bokeh slides in from the left while a - * black-and-white starburst half slams in from the right; they meet on a torn diagonal seam, the chosen title lands on + * 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: BeMusicSelectFrame): void { @@ -130,9 +130,10 @@ class PhantomSelectRenderer implements BeMusicSelectRenderer { 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). - const seamTop = w * 0.6; - const seamBottom = w * 0.44; + // 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); @@ -144,7 +145,7 @@ class PhantomSelectRenderer implements BeMusicSelectRenderer { 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.84 + burstShift; + const rayX = w * 0.66 + burstShift; const rayY = h * 0.42; const spin = t * 0.5; for (let ray = 0; ray < 18; ray += 1) { @@ -182,7 +183,7 @@ class PhantomSelectRenderer implements BeMusicSelectRenderer { // 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.72; + const labelX = w * 0.62; const labelY = h * 0.3; const pop = 1.5 - 0.5 * easeOutBack(land, 2); const title = frame.focusedSong?.title ?? ''; @@ -203,7 +204,7 @@ class PhantomSelectRenderer implements BeMusicSelectRenderer { fill: options.fill, fontFamily: options.fontFamily, }); - addRansomText(g, glyph, "LET'S GO!", w * 0.7, h * 0.56, { + addRansomText(g, glyph, "LET'S GO!", w * 0.62, h * 0.56, { size: 44, seed: 17, angle: -0.1, diff --git a/packages/player-web/src/scene/default/phantom/tear.ts b/packages/player-web/src/scene/default/phantom/tear.ts index 0bc35cfe..45460647 100644 --- a/packages/player-web/src/scene/default/phantom/tear.ts +++ b/packages/player-web/src/scene/default/phantom/tear.ts @@ -49,12 +49,20 @@ export function stripPoint(strip: TearStrip, u: number, v: number): { x: number; * 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): number { +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; - const slide = (1 - enter) * -strip.length * 0.25 + exit * strip.length * 0.3; + // 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; @@ -77,10 +85,12 @@ export function drawTearStrip(graphics: Graphics, strip: TearStrip, t: number, n 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 + 200; + 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, u + 40 + 60 * hash01(line + 5), 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 }); diff --git a/packages/player-web/src/scene/default/synesthesia/gameplay-chrome.ts b/packages/player-web/src/scene/default/synesthesia/gameplay-chrome.ts index ed2c9b05..3f51e5da 100644 --- a/packages/player-web/src/scene/default/synesthesia/gameplay-chrome.ts +++ b/packages/player-web/src/scene/default/synesthesia/gameplay-chrome.ts @@ -1,11 +1,12 @@ import { Graphics, type Container } from 'pixi.js'; -import { BGA, DESIGN_HEIGHT, DESIGN_WIDTH, GROOVE, PLAYFIELD } from '../../gameplay-constants.ts'; +import { BGA, DESIGN_HEIGHT, DESIGN_WIDTH, GROOVE } from '../../gameplay-constants.ts'; import type { SkinlessGameplayChromeRenderContext, SkinlessGameplayChromeRuntime } from '../../gameplay-chrome.ts'; -import { resolveFallbackLaneLayout, shouldPreserveFallbackSideWidth } from '../../gameplay-lanes.ts'; +import { resolveSkinlessLaneLayout } from '../../gameplay-lanes.ts'; import type { ChildPool } from '../../pixi-utils.ts'; import { addHudNumber, addHudText, type HudTextOptions } from '../hud-text.ts'; import { comboTier, effectProfile, impulse, punchScale } from '../moments.ts'; import { drawSynesthesiaMoments } from './moments.ts'; +import { flashingGreatColor, isFlashingGreat, judgeDisplayWord } from '../judge-word.ts'; import { hash01 } from '../phantom-style.ts'; import { audioDrive, type AudioDrive } from '../audio-drive.ts'; import { createFlock, stepFlock, type Flock } from './boids.ts'; @@ -30,6 +31,7 @@ import { import { SYN_AMBER, SYN_CYAN, + SYN_GREEN, SYN_DIM, SYN_DISPLAY_FONT, SYN_EMBER, @@ -108,7 +110,11 @@ export function renderSynesthesiaChrome({ const hue = sceneHue(seconds, beatPhase); const accent = hsvToHex(hue, 0.62, 1); const hasBga = runtime.hasBga === true; - const playfieldRight = resolvePlayfieldRight(runtime); + const { left: playfieldLeft, right: playfieldRight } = resolveSkinlessLaneLayout( + runtime.laneChannels, + runtime.laneCount, + runtime.playVariant, + ); const space = layerPool.acquireGraphics(); space.label = 'synesthesia-gameplay/space'; @@ -163,7 +169,7 @@ export function renderSynesthesiaChrome({ const panels = layerPool.acquireGraphics(); panels.label = 'synesthesia-gameplay/panels'; panels.blendMode = 'normal'; - drawPlayfieldWell(panels, playfieldRight, runtime.progressRatio, accent); + drawPlayfieldWell(panels, playfieldLeft, playfieldRight, runtime.progressRatio, accent); // A double-play field reaches into the monitor's column; the frame and its idle screen would sit on the 2P lanes. if (playfieldRight + 14 <= BGA.x) { const standby = layerPool.acquireGraphics(); @@ -184,19 +190,15 @@ export function renderSynesthesiaChrome({ front.label = 'synesthesia-gameplay/header'; drawHeader(front, overlayLayer, runtime, accent, pulse, overlayLayerPool); drawJudgements(overlayLayer, runtime, playfieldRight, seconds, overlayLayerPool); - drawSynesthesiaMoments(layer, overlayLayer, runtime, (PLAYFIELD.x + playfieldRight) / 2, hue, 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)); + drawSynesthesiaMoments( + layer, + overlayLayer, + runtime, + (playfieldLeft + playfieldRight) / 2, + hue, + overlayLayerPool, + playfieldRight, + ); } function insideBga(x: number, y: number, margin = 0): boolean { @@ -430,8 +432,14 @@ function fillAroundBga( if (bottom > holeBottom) graphics.rect(x, holeBottom, w, bottom - holeBottom).fill(fill); } -function drawPlayfieldWell(graphics: Graphics, right: number, progressRatio: number | undefined, accent: number): void { - const left = PLAYFIELD.x - 4; +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. @@ -579,6 +587,11 @@ function drawBgaFrame( 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 - 56, BGA.y + BGA.h - 26, 112, 17).fill({ color: SYN_VOID, alpha: 0.9 }); addHudText( layer, 'STANDBY', @@ -598,7 +611,8 @@ function drawHeader( pool: ChildPool, ): void { const autoplay = runtime.autoplay === true; - graphics.rect(0, 0, DESIGN_WIDTH, 34).fill({ color: SYN_VOID, alpha: 0.55 }); + // 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(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 }); @@ -854,9 +868,12 @@ const JUDGE_COLORS: Record = { 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 cycles through the hue wheel - * like a prism so a clean run literally shimmers. + * 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, @@ -870,13 +887,12 @@ function drawJudgements( const amount = effectProfile(runtime.effects).amount; for (const display of displays) { const judgeScale = punchScale(runtime.judgeAtMs, nowMs, 120, 0.22 * amount); - const color = - display.judge === 'PERFECT' - ? emberColor(0.82 + 0.18 * Math.sin(seconds * 9 + display.x / 60)) - : (JUDGE_COLORS[display.judge] ?? SYN_WHITE); + const color = isFlashingGreat(display.judge) + ? flashingGreatColor(nowMs, FLASHING_GREAT) + : (JUDGE_COLORS[display.judge] ?? SYN_WHITE); addHudText( layer, - display.judge, + judgeDisplayWord(display.judge), display.x, 236, { @@ -915,14 +931,11 @@ 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 { lanes, left: playfieldLeft } = resolveSkinlessLaneLayout( + runtime.laneChannels, + runtime.laneCount, + runtime.playVariant, + ); const centerOf = (side: '1P' | '2P'): number | undefined => { const own = lanes.filter((lane) => lane.side === side); if (own.length === 0) return undefined; @@ -930,7 +943,7 @@ function resolveJudgeDisplays( }; const doublePlay = centerOf('2P') !== undefined; const maxWidth = doublePlay ? 122 : 180; - const fallbackX = (PLAYFIELD.x + playfieldRight) / 2; + const fallbackX = (playfieldLeft + playfieldRight) / 2; const sides = runtime.judgeSides?.filter((state) => typeof state.judge === 'string' && state.judge.length > 0); if (sides?.length) { return sides.map((state) => ({ diff --git a/packages/player-web/src/scene/default/synesthesia/moments.ts b/packages/player-web/src/scene/default/synesthesia/moments.ts index 89787c78..9a7c8e0b 100644 --- a/packages/player-web/src/scene/default/synesthesia/moments.ts +++ b/packages/player-web/src/scene/default/synesthesia/moments.ts @@ -3,8 +3,8 @@ import { BGA, DESIGN_HEIGHT, DESIGN_WIDTH, GROOVE, PLAYFIELD } from '../../gamep import type { SkinlessGameplayChromeRuntime } from '../../gameplay-chrome.ts'; import type { ChildPool } from '../../pixi-utils.ts'; import { addHudText } from '../hud-text.ts'; -import { effectProfile, impulse, momentProgress, trackMoments } from '../moments.ts'; -import { easeOutCubic, hash01, stageProgress } from '../phantom-style.ts'; +import { effectProfile, momentProgress, trackMoments, resolveMilestoneArea, type MilestoneArea } from '../moments.ts'; +import { easeOutCubic, stageProgress } from '../phantom-style.ts'; import { drawReticle } from './draw.ts'; import { burstParticlePosition, @@ -43,6 +43,7 @@ export function drawSynesthesiaMoments( playfieldCenterX: number, hue: number, pool: ChildPool, + playfieldRight: number, ): void { const nowMs = runtime.nowMs ?? 0; const moments = trackMoments(chromeLayer, { @@ -63,20 +64,7 @@ export function drawSynesthesiaMoments( graphics.label = 'synesthesia/moments'; graphics.blendMode = 'add'; - // Misses glitch the signal: red scanline tears and edge bleed, plus (full effects) a jolt of the whole HUD. - const miss = runtime.lastJudge === 'POOR' || runtime.lastJudge === 'BAD' ? impulse(runtime.judgeAtMs, nowMs, 240) : 0; - if (miss > 0) { - drawGlitch(graphics, miss, nowMs, runtime.hasBga === true); - if (effects.screenWide) { - const jolt = Math.sin(nowMs / 9) * 4 * miss; - chromeLayer.position.set(jolt, 0); - layer.position.set(jolt, 0); - } - } - const comboBreak = momentProgress(moments.comboBreak?.atMs, nowMs, BREAK_MS); - if (comboBreak !== undefined && moments.comboBreak) { - drawComboBreak(graphics, moments.comboBreak.combo, comboBreak, playfieldCenterX); - } + // 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, hue, pool); @@ -88,8 +76,7 @@ export function drawSynesthesiaMoments( layer, moments.milestone.value, milestone, - playfieldCenterX, - hue, + resolveMilestoneArea(playfieldRight), runtime.hasBga === true, pool, ); @@ -178,32 +165,32 @@ function drawMilestone( layer: Container, value: number, t: number, - playfieldCenterX: number, - hue: number, + area: MilestoneArea, 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. + const below = area.mode === 'below'; + 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; + // A shockwave of light rolls out from the count, kept inside the area. const wave = easeOutCubic(Math.min(1, t / 0.7)); - const radius = 30 + 700 * wave; - graphics - .ellipse(playfieldCenterX, JUDGE_Y, radius, radius * 0.42) - .stroke({ color, width: 2 + 8 * (1 - wave), alpha: 0.7 * (1 - wave) }); - graphics - .ellipse(playfieldCenterX, JUDGE_Y, radius * 0.8, radius * 0.34) - .stroke({ color: SYN_WHITE, width: 1.5, alpha: 0.5 * (1 - wave) }); + const rx = 20 + (area.w / 2 - 20) * wave; + const ry = Math.min(rx * 0.42, area.h / 2); + graphics.ellipse(cx, cy, rx, ry).stroke({ color, width: 2 + 8 * (1 - wave), alpha: 0.7 * (1 - wave) }); + graphics.ellipse(cx, cy, rx * 0.8, ry * 0.8).stroke({ color: SYN_WHITE, width: 1.5, alpha: 0.5 * (1 - wave) }); const alpha = Math.min(1, t / 0.12) * (1 - Math.max(0, (t - 0.7) / 0.3)); - // Over a live BGA the count sits small on the monitor's top edge instead of the middle of the video. - const cx = BGA.x + BGA.w / 2; - const cy = hasBga ? BGA.y + 30 : BGA.y + BGA.h / 2 - 10; const count = addHudText( layer, String(value), cx, cy, { - size: hasBga ? 30 : 52, + size: compact ? 30 : 52, fill: SYN_WHITE, fontFamily: SYN_DISPLAY_FONT, letterSpacing: 4, @@ -216,9 +203,9 @@ function drawMilestone( 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 = (hasBga ? 120 : 190) * (1.6 - 0.6 * snap); - const lockH = (hasBga ? 74 : 110) * (1.6 - 0.6 * snap); - drawReticle(graphics, cx - lockW / 2, cy + (hasBga ? 14 : 22) - lockH / 2, lockW, lockH, color, 0.9 * alpha, { + 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, }); @@ -226,7 +213,7 @@ function drawMilestone( layer, 'COMBO', cx, - cy + (hasBga ? 28 : 44), + cy + (compact ? 28 : 44), { size: 11, fill: SYN_WHITE, @@ -252,7 +239,8 @@ function drawClear(graphics: Graphics, layer: Container, t: number, pool: ChildP layer, 'CLEAR', GROOVE.x + GROOVE.w - 8, - GROOVE.y - 38, + // Between the playfield frame's bottom edge and the gauge panel, touching neither. + GROOVE.y - 34, { size: 12, fill: SYN_WHITE, @@ -334,42 +322,3 @@ function drawFullCombo( ); text.alpha = form * fadeOut; } - -const BREAK_MS = 800; - -/** Red scanline tears across the screen and a bleed at the edges; tears skip a live BGA rect. */ -function drawGlitch(graphics: Graphics, strength: number, nowMs: number, hasBga: boolean): void { - const frame = Math.floor(nowMs / 40); - for (let tear = 0; tear < 7; tear += 1) { - const y = hash01(frame * 7 + tear) * DESIGN_HEIGHT; - const h = 2 + hash01(frame * 13 + tear) * 6; - const offset = (hash01(frame * 3 + tear) - 0.5) * 60; - if (hasBga && y > BGA.y - 4 && y < BGA.y + BGA.h + 4) { - graphics.rect(offset, y, BGA.x - 8, h).fill({ color: 0xff3a6a, alpha: 0.35 * strength }); - graphics.rect(BGA.x + BGA.w + 8, y, DESIGN_WIDTH, h).fill({ color: 0xff3a6a, alpha: 0.35 * strength }); - } else { - graphics.rect(offset, y, DESIGN_WIDTH, h).fill({ color: 0xff3a6a, alpha: 0.35 * strength }); - } - } - for (let band = 0; band < 3; band += 1) { - const w = 10; - const alpha = 0.18 * strength * (1 - band / 3); - graphics.rect(band * w, 0, w, DESIGN_HEIGHT).fill({ color: 0xff3a6a, alpha }); - graphics.rect(DESIGN_WIDTH - (band + 1) * w, 0, w, DESIGN_HEIGHT).fill({ color: 0xff3a6a, alpha }); - } -} - -/** A broken combo disperses into red sparks that drift up and fade from the combo readout. */ -function drawComboBreak(graphics: Graphics, combo: number, t: number, cx: number): void { - const cy = 264; - for (let spark = 0; spark < 36; spark += 1) { - const angle = hash01(spark + combo * 3) * Math.PI * 2; - const speed = 30 + hash01(spark * 5 + 1) * 90; - const eased = easeOutCubic(t); - const x = cx + Math.cos(angle) * speed * eased; - const y = cy + Math.sin(angle) * speed * eased * 0.6 - 40 * eased; - const size = 1 + hash01(spark + 11) * 2; - graphics.circle(x, y, size * 3).fill({ color: 0xff3a6a, alpha: 0.12 * (1 - t) }); - graphics.circle(x, y, size).fill({ color: 0xffd0dc, alpha: 1 - t }); - } -} diff --git a/packages/player-web/src/scene/default/synesthesia/select.ts b/packages/player-web/src/scene/default/synesthesia/select.ts index e18aaaff..b80b92b4 100644 --- a/packages/player-web/src/scene/default/synesthesia/select.ts +++ b/packages/player-web/src/scene/default/synesthesia/select.ts @@ -403,11 +403,6 @@ class SynesthesiaSelectRenderer implements BeMusicSelectRenderer { 0.5 + 0.5 * snap, { arm: 9, width: 1.75 }, ); - // Target marker leading the card. - const markerX = card.x - 16; - const markerY = card.y + card.h / 2; - this.lock.circle(markerX, markerY, 3 + 2 * pulse).stroke({ color: accent, width: 1.25, alpha: 0.9 }); - this.lock.circle(markerX, markerY, 1.2).fill({ color: SYN_WHITE, alpha: 1 }); 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; @@ -596,13 +591,14 @@ class SynesthesiaSelectRenderer implements BeMusicSelectRenderer { // Search and library. framePanel(chrome, 14, 372, 290, 30, accent); - addText('SEARCH', 30, 384, labelStyle(accent)); - addText(frame.searchQuery || 'Title / artist / genre', 100, 380, { + 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: 200, + maxWidth: 180, }); addHitArea(layer, 14, 372, 290, 30, 'text', frame.actions.activateSearch); addText('LIBRARY', 30, 422, labelStyle()); From eeb6a8e95dc40a474ffe2dbc3652ef492eb3d94f Mon Sep 17 00:00:00 2001 From: Seiya KONNO Date: Sun, 4 Oct 2026 21:04:12 +0900 Subject: [PATCH 17/65] feat(player-web): fix IIDX lane widths across 5 / 7 / 10 / 14 KEY Skinless lanes shared the 194 px span out by weight, so a 5K chart drew wider keys than 7K. The IIDX families now use fixed widths in the arcade cabinet's proportions (41 px scratch, 24 px white keys, 19 px black keys, a 7K side still 194 px), and the playfield narrows or widens instead. --- .../player-web/src/scene/gameplay-lanes.ts | 48 +++++++++++++++- .../src/scene/lr2/gameplay-lanes.test.ts | 55 +++++++++++++++++++ 2 files changed, 101 insertions(+), 2 deletions(-) diff --git a/packages/player-web/src/scene/gameplay-lanes.ts b/packages/player-web/src/scene/gameplay-lanes.ts index 228f1140..374e42a6 100644 --- a/packages/player-web/src/scene/gameplay-lanes.ts +++ b/packages/player-web/src/scene/gameplay-lanes.ts @@ -16,6 +16,22 @@ 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; + +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; /** @@ -48,6 +64,11 @@ export interface ResolveFallbackLaneLayoutOptions { * 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; } /** @@ -76,6 +97,7 @@ export function resolveSkinlessLaneLayout( playVariant?: ChartPlayVariant, ): SkinlessLaneLayout { const span = resolveFallbackPlayfieldSpan(playVariant); + const fixedWidths = usesIidxLaneWidths(playVariant) ? IIDX_LANE_WIDTHS : undefined; const lanes = resolveFallbackLaneLayout({ channels, laneCount, @@ -83,11 +105,19 @@ export function resolveSkinlessLaneLayout( x: span.x, w: span.w, preserveSideWidth: shouldPreserveFallbackSideWidth(channels, playVariant), + fixedWidths, }); - const right = Math.max(span.x + span.w, ...lanes.map((lane) => lane.x + lane.w)); + 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; } @@ -130,12 +160,15 @@ export function resolveFallbackLaneLayout(options: ResolveFallbackLaneLayoutOpti 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 = unit * laneWeights[index]!; + 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; @@ -143,6 +176,17 @@ export function resolveFallbackLaneLayout(options: ResolveFallbackLaneLayoutOpti 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'; } 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 ebd3651f..9cfdb4d7 100644 --- a/packages/player-web/src/scene/lr2/gameplay-lanes.test.ts +++ b/packages/player-web/src/scene/lr2/gameplay-lanes.test.ts @@ -5,6 +5,7 @@ import { resolveFallbackPlayfieldSpan, resolveSkinlessLaneLayout, shouldPreserveFallbackSideWidth, + usesIidxLaneWidths, } from '../gameplay-lanes.ts'; describe('isScratchLaneForVariant', () => { @@ -223,3 +224,57 @@ describe('resolveSkinlessLaneLayout', () => { 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); + expect(fourteenKey.widths['29']).toBe(24); + expect(fourteenKey.widths['28']).toBe(19); + expect(fourteenKey.layout.right).toBe(33 + 194 * 2); + }); + + 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); + } + }); +}); From f2b80839824d8702e00ab465dd94b389ca2be066 Mon Sep 17 00:00:00 2001 From: Seiya KONNO Date: Sun, 4 Oct 2026 21:04:12 +0900 Subject: [PATCH 18/65] feat(player-web): give white-key lanes their own background White-key lanes now sit on their own tone in every built-in skin, and the scratch lane shares the black keys' background. --- .changeset/be-music-skins.md | 1 + .../player-web/src/scene/default/lattice/playfield.ts | 6 +++++- .../player-web/src/scene/default/phantom/playfield.ts | 9 +++++++-- .../src/scene/default/synesthesia/playfield.ts | 7 +++++-- 4 files changed, 18 insertions(+), 5 deletions(-) diff --git a/.changeset/be-music-skins.md b/.changeset/be-music-skins.md index 90d42f51..847a1f42 100644 --- a/.changeset/be-music-skins.md +++ b/.changeset/be-music-skins.md @@ -12,6 +12,7 @@ The default (skinless) family now renders through a `BeMusicSkin`: one object bu - 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 `renderChrome` (as `runtime.audio`), `renderLanes` and `renderBombs` (as `audio`), and the select renderer's `tick` (fourth argument). Gameplay analyses the whole mix off the audio bus output; select analyses the BGM and chart preview. 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 the whole page (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. +- 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 and the 14 px gap between the DP 1P / 2P banks are gone (the playfield keeps its width, and `sideGap` can still request a gap), and every built-in skin draws notes and long notes across the full lane width. - 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. diff --git a/packages/player-web/src/scene/default/lattice/playfield.ts b/packages/player-web/src/scene/default/lattice/playfield.ts index 52c4019e..162b16b9 100644 --- a/packages/player-web/src/scene/default/lattice/playfield.ts +++ b/packages/player-web/src/scene/default/lattice/playfield.ts @@ -21,6 +21,7 @@ const LANE_COLORS: Record = { const NOTE_HEIGHT = 6; const LANE_BED = 0xfbfaf7; +const LANE_BED_DEEP = 0xe6e3dc; export const LATTICE_BOMB_DURATION_MS = 380; /** @@ -43,7 +44,10 @@ export function renderLatticeLanes({ graphics, lanes, beatPhase, combo, effects for (const lane of lanes) { const { x, w } = lane; const height = Math.max(1, lane.bottom - lane.top); - graphics.rect(x, lane.top, w, height).fill({ color: LANE_BED, alpha: 0.94 }); + // 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 }); if (lane.beam > 0) { const beamHeight = Math.min(height, 200); const strength = lane.beam * lane.beam * (3 - 2 * lane.beam); diff --git a/packages/player-web/src/scene/default/phantom/playfield.ts b/packages/player-web/src/scene/default/phantom/playfield.ts index 3af00b47..df14b578 100644 --- a/packages/player-web/src/scene/default/phantom/playfield.ts +++ b/packages/player-web/src/scene/default/phantom/playfield.ts @@ -27,6 +27,9 @@ const TONES: Record = { const NOTE_HEIGHT = 10; export const PHANTOM_BOMB_DURATION_MS = 150; +/** 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 }: BeMusicLanesContext): void { const beatDecay = 1 - beatPhase; let gridTop = Number.POSITIVE_INFINITY; @@ -40,8 +43,10 @@ export function renderPhantomLanes({ graphics, lanes, beatPhase }: BeMusicLanesC 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 diff --git a/packages/player-web/src/scene/default/synesthesia/playfield.ts b/packages/player-web/src/scene/default/synesthesia/playfield.ts index dfcf81b7..912ca3e5 100644 --- a/packages/player-web/src/scene/default/synesthesia/playfield.ts +++ b/packages/player-web/src/scene/default/synesthesia/playfield.ts @@ -70,8 +70,11 @@ export function renderSynesthesiaLanes({ gridLeft = Math.min(gridLeft, x); gridRight = Math.max(gridRight, x + w); - // Near-black bed, so the particle world behind the playfield reads only as a faint depth cue. - graphics.rect(x, top, w, laneHeight).fill({ color: 0x050201, alpha: 0.8 }); + // 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 }); if (lane.beam > 0) { From 8040fe82e53a1c8addab707bbc426cfc099b3a2a Mon Sep 17 00:00:00 2001 From: Seiya KONNO Date: Sun, 4 Oct 2026 21:09:23 +0900 Subject: [PATCH 19/65] feat(player-web): set Phantom judgements as ransom notes Judgements now use the count-in's magazine cut-out lettering: a fresh ransom note per hit whose cards pop in quickly with a slight overshoot, with the judgement colour on the letters of the dark cards (the flashing GREAT cycles through them). addRansomText gains accent and pop options, and its cards fade in with their letters. --- .changeset/be-music-skins.md | 1 + .../src/scene/default/gameplay-render.ts | 53 +++++++++++-------- .../src/scene/default/phantom/tear.test.ts | 38 ++++++++++++- .../src/scene/default/phantom/tear.ts | 19 +++++-- 4 files changed, 83 insertions(+), 28 deletions(-) diff --git a/.changeset/be-music-skins.md b/.changeset/be-music-skins.md index 847a1f42..9a11ea69 100644 --- a/.changeset/be-music-skins.md +++ b/.changeset/be-music-skins.md @@ -20,6 +20,7 @@ The default (skinless) family now renders through a `BeMusicSkin`: one object bu - Showpieces for both built-in skins: a count-in before the first beat (Phantom slashes READY? into GO!!, Synesthesia condenses READY and detonates a ring of light), a cut-in every 100 combo, a flourish when the gauge crosses the clear line, and a full-combo finale (Phantom's turning burst with confetti, Synesthesia's 3D spark sphere and prism rings). 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, speed streaks thicken, the floor burns hotter, more particle rivers and beat rings 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 judgements are cut out of magazines like its READY? / GO!! count-in: each hit sets the word as a fresh ransom note whose cards pop in one after another, with the judgement colour on the letters of the dark cards. - 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 warp into a white-out) 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. diff --git a/packages/player-web/src/scene/default/gameplay-render.ts b/packages/player-web/src/scene/default/gameplay-render.ts index 707c4b10..77edfe09 100644 --- a/packages/player-web/src/scene/default/gameplay-render.ts +++ b/packages/player-web/src/scene/default/gameplay-render.ts @@ -27,6 +27,7 @@ import { import type { ChildPool } from '../pixi-utils.ts'; import { audioDrive, bandLevel, type AudioDrive } from './audio-drive.ts'; import { drawPhantomMoments } from './phantom/moments.ts'; +import { addRansomText, type GlyphFactory } from './phantom/tear.ts'; import { effectProfile, impulse, punchScale } from './moments.ts'; import { flashingGreatColor, isFlashingGreat, judgeDisplayWord } from './judge-word.ts'; import { addHudNumber as addNumber, addHudText as addText, type HudTextOptions as TextOptions } from './hud-text.ts'; @@ -779,29 +780,24 @@ function drawJudgements( for (const display of resolveJudgeDisplays(runtime, playfield)) { const combo = resolveVisibleCombo(display.judge, display.combo); const style = judgeStyle(display.judge); - // Punch: every judgement lands with a quick overshoot (a miss lands plainly, with no rattle). + const word = judgeDisplayWord(display.judge); + // The judgement is 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. const miss = display.judge === 'POOR' || display.judge === 'BAD'; - const judgeScale = miss ? 1 : punchScale(runtime.judgeAtMs, nowMs, 120, 0.24 * amount); - addText( - layer, - judgeDisplayWord(display.judge), - display.x, - 238, - { - scale: judgeScale, - size: 30, - fill: isFlashingGreat(display.judge) ? flashingGreatColor(nowMs, PHANTOM_FLASHING_GREAT) : 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, - }, - pool, - ); + 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); + addRansomText(cards, hudGlyphs(layer, pool), word, display.x, 238, { + size: Math.min(30, 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, + // Fast enough to finish between hits in a dense passage, and only a slight overshoot so it never covers lanes. + appear: (index) => (miss || amount <= 0 ? 1 : (age - index * 10) / 60), + pop: 0.22, + }); if (combo > 0) { addNumber( layer, @@ -822,6 +818,19 @@ function drawJudgements( } } +/** 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, + char, + 0, + 0, + { size: options.size, weight: options.weight, fill: options.fill, fontFamily: options.fontFamily }, + pool, + ); +} + function resolveJudgeDisplays( runtime: FallbackGameplayRuntime, playfield: FallbackPlayfieldLayout, diff --git a/packages/player-web/src/scene/default/phantom/tear.test.ts b/packages/player-web/src/scene/default/phantom/tear.test.ts index c2febccc..3fabb531 100644 --- a/packages/player-web/src/scene/default/phantom/tear.test.ts +++ b/packages/player-web/src/scene/default/phantom/tear.test.ts @@ -1,5 +1,7 @@ +import type { Graphics, Text } from 'pixi.js'; import { describe, expect, it } from 'vite-plus/test'; -import { stripPoint, tearOpenness } from './tear.ts'; +import { PHANTOM_INK, ransomLayout } from '../phantom-style.ts'; +import { addRansomText, stripPoint, tearOpenness, type GlyphFactory } from './tear.ts'; describe('tearOpenness', () => { it('is shut before and after the cut-in', () => { @@ -25,3 +27,37 @@ describe('stripPoint', () => { 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/scene/default/phantom/tear.ts b/packages/player-web/src/scene/default/phantom/tear.ts index 45460647..b4773627 100644 --- a/packages/player-web/src/scene/default/phantom/tear.ts +++ b/packages/player-web/src/scene/default/phantom/tear.ts @@ -116,7 +116,8 @@ const LETTER_FILL: Record = { ink: PHANTOM_WHITE, paper: PH /** * 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`. + * 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, @@ -124,7 +125,15 @@ export function addRansomText( text: string, x: number, y: number, - options: { size: number; seed: number; angle?: number; appear?: (index: number, count: number) => 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; @@ -134,7 +143,7 @@ export function addRansomText( return glyph(entry.char, { size: Math.round(options.size * entry.scale * font.tighten), weight: font.weight, - fill: LETTER_FILL[entry.paper], + fill: options.accent !== undefined && entry.paper !== 'paper' ? options.accent : LETTER_FILL[entry.paper], fontFamily: font.family, }); }); @@ -156,7 +165,7 @@ export function addRansomText( node.visible = false; return; } - const pop = 1 + 0.9 * (1 - easeOutBack(appear, 2.4)); + 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; @@ -168,7 +177,7 @@ export function addRansomText( 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(CARD_FILL[entry.paper]); + .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; From 69c60cbd482f4c3b288a8098cd6ed18cfd9490a4 Mon Sep 17 00:00:00 2001 From: Seiya KONNO Date: Sun, 4 Oct 2026 21:15:31 +0900 Subject: [PATCH 20/65] feat(player-web): set the Phantom combo as a ransom note and shrink both The combo count now uses the same magazine cut-out lettering as the judgement (gold from 200), and both are set smaller so the notes falling through them stay readable. --- .changeset/be-music-skins.md | 2 +- .../src/scene/default/gameplay-render.ts | 48 ++++++++++--------- 2 files changed, 26 insertions(+), 24 deletions(-) diff --git a/.changeset/be-music-skins.md b/.changeset/be-music-skins.md index 9a11ea69..b0152381 100644 --- a/.changeset/be-music-skins.md +++ b/.changeset/be-music-skins.md @@ -20,7 +20,7 @@ The default (skinless) family now renders through a `BeMusicSkin`: one object bu - Showpieces for both built-in skins: a count-in before the first beat (Phantom slashes READY? into GO!!, Synesthesia condenses READY and detonates a ring of light), a cut-in every 100 combo, a flourish when the gauge crosses the clear line, and a full-combo finale (Phantom's turning burst with confetti, Synesthesia's 3D spark sphere and prism rings). 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, speed streaks thicken, the floor burns hotter, more particle rivers and beat rings 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 judgements are cut out of magazines like its READY? / GO!! count-in: each hit sets the word as a fresh ransom note whose cards pop in one after another, with the judgement colour on the letters of the dark cards. +- 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 warp into a white-out) 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. diff --git a/packages/player-web/src/scene/default/gameplay-render.ts b/packages/player-web/src/scene/default/gameplay-render.ts index 77edfe09..844ea8d1 100644 --- a/packages/player-web/src/scene/default/gameplay-render.ts +++ b/packages/player-web/src/scene/default/gameplay-render.ts @@ -28,7 +28,7 @@ import type { ChildPool } from '../pixi-utils.ts'; import { audioDrive, bandLevel, type AudioDrive } from './audio-drive.ts'; import { drawPhantomMoments } from './phantom/moments.ts'; import { addRansomText, type GlyphFactory } from './phantom/tear.ts'; -import { effectProfile, impulse, punchScale } from './moments.ts'; +import { effectProfile, impulse } from './moments.ts'; import { flashingGreatColor, isFlashingGreat, judgeDisplayWord } from './judge-word.ts'; import { addHudNumber as addNumber, addHudText as addText, type HudTextOptions as TextOptions } from './hud-text.ts'; @@ -781,39 +781,37 @@ function drawJudgements( const combo = resolveVisibleCombo(display.judge, display.combo); const style = judgeStyle(display.judge); const word = judgeDisplayWord(display.judge); - // The judgement is 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 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); - addRansomText(cards, hudGlyphs(layer, pool), word, display.x, 238, { - size: Math.min(30, display.maxWidth / (word.length * 0.95)), + 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, - // Fast enough to finish between hits in a dense passage, and only a slight overshoot so it never covers lanes. - appear: (index) => (miss || amount <= 0 ? 1 : (age - index * 10) / 60), + appear: appear(0), pop: 0.22, }); if (combo > 0) { - addNumber( - layer, - formatCount(combo), - display.x, - 270, - { - scale: punchScale(runtime.judgeAtMs, nowMs, 150, (combo % 100 === 0 ? 0.5 : 0.16) * amount), - ...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, - ); + 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, + }); } } } @@ -911,6 +909,10 @@ 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; From a9b930d10443a5b995626967094a06029044ca24 Mon Sep 17 00:00:00 2001 From: Seiya KONNO Date: Sun, 4 Oct 2026 21:24:41 +0900 Subject: [PATCH 21/65] feat(player-web): make key beams read clearly in the built-in skins A pressed lane's beam now rises taller and lights crisp rails along both lane edges, brightest at the judgement line, while the middle of the lane keeps only a faint wash (dimmer behind white notes in Phantom) so notes keep their contrast. The rails are a shared helper. --- .changeset/be-music-skins.md | 1 + .../src/scene/default/key-beam.test.ts | 14 +++++ .../player-web/src/scene/default/key-beam.ts | 57 +++++++++++++++++++ .../src/scene/default/lattice/playfield.ts | 8 ++- .../src/scene/default/phantom/playfield.ts | 14 +++-- .../scene/default/synesthesia/playfield.ts | 8 ++- 6 files changed, 93 insertions(+), 9 deletions(-) create mode 100644 packages/player-web/src/scene/default/key-beam.test.ts create mode 100644 packages/player-web/src/scene/default/key-beam.ts diff --git a/.changeset/be-music-skins.md b/.changeset/be-music-skins.md index b0152381..d39d795d 100644 --- a/.changeset/be-music-skins.md +++ b/.changeset/be-music-skins.md @@ -12,6 +12,7 @@ The default (skinless) family now renders through a `BeMusicSkin`: one object bu - 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 `renderChrome` (as `runtime.audio`), `renderLanes` and `renderBombs` (as `audio`), and the select renderer's `tick` (fourth argument). Gameplay analyses the whole mix off the audio bus output; select analyses the BGM and chart preview. 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 the whole page (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 rises taller and lights crisp rails along both of its edges, brightest at the judgement line, while the middle of the lane keeps only a faint wash (dimmer still behind white notes) so the notes keep their contrast. - 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 and the 14 px gap between the DP 1P / 2P banks are gone (the playfield keeps its width, and `sideGap` can still request a gap), and every built-in skin draws notes and long notes across the full lane width. - 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. diff --git a/packages/player-web/src/scene/default/key-beam.test.ts b/packages/player-web/src/scene/default/key-beam.test.ts new file mode 100644 index 00000000..5dca11c2 --- /dev/null +++ b/packages/player-web/src/scene/default/key-beam.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vite-plus/test'; +import { keyBeamEdgeWidth } from './key-beam.ts'; + +describe('keyBeamEdgeWidth', () => { + it('uses 2 px rails on lanes wide enough to keep a clear middle', () => { + expect(keyBeamEdgeWidth(18)).toBe(2); + expect(keyBeamEdgeWidth(41)).toBe(2); + }); + + it('falls back to 1 px rails on narrow lanes', () => { + expect(keyBeamEdgeWidth(17)).toBe(1); + expect(keyBeamEdgeWidth(10.5)).toBe(1); + }); +}); diff --git a/packages/player-web/src/scene/default/key-beam.ts b/packages/player-web/src/scene/default/key-beam.ts new file mode 100644 index 00000000..26d9039f --- /dev/null +++ b/packages/player-web/src/scene/default/key-beam.ts @@ -0,0 +1,57 @@ +import { Color, FillGradient, type Graphics } from 'pixi.js'; + +/** + * Key-beam edges shared by the built-in skins. A pressed lane lights two crisp rails along its own edges, brightest at + * the judgement line and fading out toward the top, so the beam reads clearly at a glance while the middle of the lane + * — where the notes fall — keeps only a faint wash and the notes keep their contrast. + */ + +const EDGE_GRADIENTS = new Map(); + +function edgeGradient(color: number): FillGradient { + let gradient = EDGE_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.4, color: rgb.setAlpha(0.3).toRgbaString() }, + { offset: 0.8, color: rgb.setAlpha(0.8).toRgbaString() }, + { offset: 1, color: rgb.setAlpha(1).toRgbaString() }, + ], + }); + EDGE_GRADIENTS.set(color, gradient); + } + return gradient; +} + +/** Edge rail width for a lane `w` px wide: 2 px once the lane is wide enough to keep a clear middle, else 1 px. */ +export function keyBeamEdgeWidth(w: number): number { + return w >= 18 ? 2 : 1; +} + +/** + * Draws the two edge rails of a key beam over `[bottom - height, bottom]` inside the lane `x..x + w` (inset by `inset` + * px from each edge), in `color` at `alpha`. + */ +export function drawKeyBeamEdges( + graphics: Graphics, + x: number, + w: number, + bottom: number, + height: number, + color: number, + alpha: number, + inset = 1, +): void { + if (alpha <= 0 || height <= 0) return; + const edge = keyBeamEdgeWidth(w); + const top = bottom - height; + const fill = { fill: edgeGradient(color), alpha }; + graphics.rect(x + inset, top, edge, height).fill(fill); + graphics.rect(x + w - inset - edge, top, edge, height).fill(fill); +} diff --git a/packages/player-web/src/scene/default/lattice/playfield.ts b/packages/player-web/src/scene/default/lattice/playfield.ts index 162b16b9..9f8201f0 100644 --- a/packages/player-web/src/scene/default/lattice/playfield.ts +++ b/packages/player-web/src/scene/default/lattice/playfield.ts @@ -7,6 +7,7 @@ import type { BeMusicLongNoteContext, BeMusicNoteContext, } from '../../../skin/be-music/types.ts'; +import { drawKeyBeamEdges } from '../key-beam.ts'; import { comboTier, effectProfile } from '../moments.ts'; import { easeOutCubic, hash01 } from '../phantom-style.ts'; import { springEase } from './field.ts'; @@ -48,12 +49,15 @@ export function renderLatticeLanes({ graphics, lanes, beatPhase, combo, effects graphics .rect(x, lane.top, w, height) .fill({ color: lane.kind === 'white' ? LANE_BED : LANE_BED_DEEP, alpha: 0.94 }); + // Key beam: a soft wash of the lane colour with crisp ink-coloured rails along both edges — clear at a glance, while + // the paper in the middle stays light enough for the notes. if (lane.beam > 0) { - const beamHeight = Math.min(height, 200); + const beamHeight = Math.min(height, 260); const strength = lane.beam * lane.beam * (3 - 2 * lane.beam); graphics .rect(x, lane.bottom - beamHeight, w, beamHeight) - .fill({ fill: resolveWash(LANE_COLORS[lane.kind]), alpha: strength * 0.5 }); + .fill({ fill: resolveWash(LANE_COLORS[lane.kind]), alpha: strength * 0.55 }); + drawKeyBeamEdges(graphics, x, w, lane.bottom, beamHeight, LANE_COLORS[lane.kind], strength, 1); } // Key cap: an outlined square that fills with the lane colour while held. const cap = Math.min(10, w - 4); diff --git a/packages/player-web/src/scene/default/phantom/playfield.ts b/packages/player-web/src/scene/default/phantom/playfield.ts index df14b578..67c669a7 100644 --- a/packages/player-web/src/scene/default/phantom/playfield.ts +++ b/packages/player-web/src/scene/default/phantom/playfield.ts @@ -6,6 +6,7 @@ import type { BeMusicLongNoteContext, BeMusicNoteContext, } from '../../../skin/be-music/types.ts'; +import { drawKeyBeamEdges } from '../key-beam.ts'; /** Colours for one lane class: note highlight / body / shade, and the key cap at rest / pressed. */ interface LaneTone { @@ -49,17 +50,20 @@ export function renderPhantomLanes({ graphics, lanes, beatPhase }: BeMusicLanesC 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 + // Key beam — a tall, soft gradient in the lane's colour with crisp bright rails along both lane edges and a white + // base, so a press reads at a glance while the middle of the lane stays dim enough for the notes. 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, 280); 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 body stays dimmer; the rails carry the press. + .fill({ fill: resolveBeamGradient(tone.body), alpha: beamAlpha * (lane.kind === 'white' ? 0.55 : 0.85) }); + drawKeyBeamEdges(graphics, x, w, bottom, beamHeight, tone.top, beamAlpha); graphics - .rect(x + 1, bottom - 22, w - 2, 22) - .fill({ fill: resolveBeamGradient(0xffffff), alpha: beamAlpha * 0.7 }); + .rect(x + 1, bottom - 26, w - 2, 26) + .fill({ fill: resolveBeamGradient(0xffffff), alpha: beamAlpha * 0.8 }); } // Judgement line — blood red with a paper-white edge, its glow flaring on each downbeat. diff --git a/packages/player-web/src/scene/default/synesthesia/playfield.ts b/packages/player-web/src/scene/default/synesthesia/playfield.ts index 912ca3e5..d8b6d2c2 100644 --- a/packages/player-web/src/scene/default/synesthesia/playfield.ts +++ b/packages/player-web/src/scene/default/synesthesia/playfield.ts @@ -7,6 +7,7 @@ import type { BeMusicLongNoteContext, BeMusicNoteContext, } from '../../../skin/be-music/types.ts'; +import { drawKeyBeamEdges } from '../key-beam.ts'; import type { ChildPool } from '../../pixi-utils.ts'; import { burstParticlePosition, @@ -77,12 +78,15 @@ export function renderSynesthesiaLanes({ .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 faint column of the lane's light with bright rails along both edges, so a press reads clearly while + // the notes falling down the middle keep their contrast. if (lane.beam > 0) { - const beamHeight = Math.min(laneHeight, 220); + const beamHeight = Math.min(laneHeight, 280); 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 }); + .fill({ fill: resolveColumnGradient(light.glow), alpha: beamAlpha * 0.5 }); + drawKeyBeamEdges(graphics, x, w, bottom, beamHeight, light.glow, beamAlpha); graphics .rect(x + 1, bottom - 26, w - 2, 26) .fill({ fill: resolveColumnGradient(0xffffff), alpha: beamAlpha * 0.25 }); From 58ddce0771b996013353cdbc04fbd78dfce743df Mon Sep 17 00:00:00 2001 From: Seiya KONNO Date: Sun, 4 Oct 2026 21:31:16 +0900 Subject: [PATCH 22/65] feat(player-web): draw key beams as colour lasers instead of edge rails Drop the edge rails. A pressed lane now lights a laser in its colour from a shared gradient 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. Each skin picks its own beam colours. --- .changeset/be-music-skins.md | 2 +- .../src/scene/default/key-beam.test.ts | 24 ++++--- .../player-web/src/scene/default/key-beam.ts | 64 +++++++------------ .../src/scene/default/lattice/playfield.ts | 39 +++-------- .../src/scene/default/phantom/playfield.ts | 21 +++--- .../scene/default/synesthesia/playfield.ts | 11 ++-- 6 files changed, 65 insertions(+), 96 deletions(-) diff --git a/.changeset/be-music-skins.md b/.changeset/be-music-skins.md index d39d795d..e4c0ac9f 100644 --- a/.changeset/be-music-skins.md +++ b/.changeset/be-music-skins.md @@ -12,7 +12,7 @@ The default (skinless) family now renders through a `BeMusicSkin`: one object bu - 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 `renderChrome` (as `runtime.audio`), `renderLanes` and `renderBombs` (as `audio`), and the select renderer's `tick` (fourth argument). Gameplay analyses the whole mix off the audio bus output; select analyses the BGM and chart preview. 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 the whole page (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 rises taller and lights crisp rails along both of its edges, brightest at the judgement line, while the middle of the lane keeps only a faint wash (dimmer still behind white notes) so the notes keep their contrast. +- 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 and the 14 px gap between the DP 1P / 2P banks are gone (the playfield keeps its width, and `sideGap` can still request a gap), and every built-in skin draws notes and long notes across the full lane width. - 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. diff --git a/packages/player-web/src/scene/default/key-beam.test.ts b/packages/player-web/src/scene/default/key-beam.test.ts index 5dca11c2..90708549 100644 --- a/packages/player-web/src/scene/default/key-beam.test.ts +++ b/packages/player-web/src/scene/default/key-beam.test.ts @@ -1,14 +1,22 @@ import { describe, expect, it } from 'vite-plus/test'; -import { keyBeamEdgeWidth } from './key-beam.ts'; +import { KEY_BEAM_STOPS } from './key-beam.ts'; -describe('keyBeamEdgeWidth', () => { - it('uses 2 px rails on lanes wide enough to keep a clear middle', () => { - expect(keyBeamEdgeWidth(18)).toBe(2); - expect(keyBeamEdgeWidth(41)).toBe(2); +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('falls back to 1 px rails on narrow lanes', () => { - expect(keyBeamEdgeWidth(17)).toBe(1); - expect(keyBeamEdgeWidth(10.5)).toBe(1); + 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/scene/default/key-beam.ts b/packages/player-web/src/scene/default/key-beam.ts index 26d9039f..cf887bbb 100644 --- a/packages/player-web/src/scene/default/key-beam.ts +++ b/packages/player-web/src/scene/default/key-beam.ts @@ -1,15 +1,26 @@ -import { Color, FillGradient, type Graphics } from 'pixi.js'; +import { Color, FillGradient } from 'pixi.js'; /** - * Key-beam edges shared by the built-in skins. A pressed lane lights two crisp rails along its own edges, brightest at - * the judgement line and fading out toward the top, so the beam reads clearly at a glance while the middle of the lane - * — where the notes fall — keeps only a faint wash and the notes keep their contrast. + * 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. */ -const EDGE_GRADIENTS = new Map(); +/** 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 }, +]; -function edgeGradient(color: number): FillGradient { - let gradient = EDGE_GRADIENTS.get(color); +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({ @@ -17,41 +28,12 @@ function edgeGradient(color: number): FillGradient { start: { x: 0, y: 0 }, end: { x: 0, y: 1 }, textureSpace: 'local', - colorStops: [ - { offset: 0, color: rgb.setAlpha(0).toRgbaString() }, - { offset: 0.4, color: rgb.setAlpha(0.3).toRgbaString() }, - { offset: 0.8, color: rgb.setAlpha(0.8).toRgbaString() }, - { offset: 1, color: rgb.setAlpha(1).toRgbaString() }, - ], + colorStops: KEY_BEAM_STOPS.map((stop) => ({ + offset: stop.offset, + color: rgb.setAlpha(stop.alpha).toRgbaString(), + })), }); - EDGE_GRADIENTS.set(color, gradient); + GRADIENTS.set(color, gradient); } return gradient; } - -/** Edge rail width for a lane `w` px wide: 2 px once the lane is wide enough to keep a clear middle, else 1 px. */ -export function keyBeamEdgeWidth(w: number): number { - return w >= 18 ? 2 : 1; -} - -/** - * Draws the two edge rails of a key beam over `[bottom - height, bottom]` inside the lane `x..x + w` (inset by `inset` - * px from each edge), in `color` at `alpha`. - */ -export function drawKeyBeamEdges( - graphics: Graphics, - x: number, - w: number, - bottom: number, - height: number, - color: number, - alpha: number, - inset = 1, -): void { - if (alpha <= 0 || height <= 0) return; - const edge = keyBeamEdgeWidth(w); - const top = bottom - height; - const fill = { fill: edgeGradient(color), alpha }; - graphics.rect(x + inset, top, edge, height).fill(fill); - graphics.rect(x + w - inset - edge, top, edge, height).fill(fill); -} diff --git a/packages/player-web/src/scene/default/lattice/playfield.ts b/packages/player-web/src/scene/default/lattice/playfield.ts index 9f8201f0..505a9f40 100644 --- a/packages/player-web/src/scene/default/lattice/playfield.ts +++ b/packages/player-web/src/scene/default/lattice/playfield.ts @@ -1,4 +1,4 @@ -import { Color, FillGradient, type Graphics } from 'pixi.js'; +import type { Graphics } from 'pixi.js'; import type { BeMusicBomb, BeMusicBombsContext, @@ -7,7 +7,7 @@ import type { BeMusicLongNoteContext, BeMusicNoteContext, } from '../../../skin/be-music/types.ts'; -import { drawKeyBeamEdges } from '../key-beam.ts'; +import { keyBeamGradient } from '../key-beam.ts'; import { comboTier, effectProfile } from '../moments.ts'; import { easeOutCubic, hash01 } from '../phantom-style.ts'; import { springEase } from './field.ts'; @@ -20,6 +20,9 @@ const LANE_COLORS: Record = { 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; @@ -49,15 +52,14 @@ export function renderLatticeLanes({ graphics, lanes, beatPhase, combo, effects graphics .rect(x, lane.top, w, height) .fill({ color: lane.kind === 'white' ? LANE_BED : LANE_BED_DEEP, alpha: 0.94 }); - // Key beam: a soft wash of the lane colour with crisp ink-coloured rails along both edges — clear at a glance, while - // the paper in the middle stays light enough for the notes. + // 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, 260); + 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: resolveWash(LANE_COLORS[lane.kind]), alpha: strength * 0.55 }); - drawKeyBeamEdges(graphics, x, w, lane.bottom, beamHeight, LANE_COLORS[lane.kind], strength, 1); + .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); @@ -189,26 +191,3 @@ function renderBomb(graphics: Graphics, bomb: BeMusicBomb, tier: number, enabled graphics.stroke({ color, width: 1, alpha: 0.7 * fade }); } } - -const WASHES = new Map(); - -/** Vertical wash for a key beam: transparent at the top, the lane colour toward the judgement line. */ -function resolveWash(color: number): FillGradient { - let gradient = WASHES.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.7, color: rgb.setAlpha(0.12).toRgbaString() }, - { offset: 1, color: rgb.setAlpha(0.4).toRgbaString() }, - ], - }); - WASHES.set(color, gradient); - } - return gradient; -} diff --git a/packages/player-web/src/scene/default/phantom/playfield.ts b/packages/player-web/src/scene/default/phantom/playfield.ts index 67c669a7..98496673 100644 --- a/packages/player-web/src/scene/default/phantom/playfield.ts +++ b/packages/player-web/src/scene/default/phantom/playfield.ts @@ -6,7 +6,7 @@ import type { BeMusicLongNoteContext, BeMusicNoteContext, } from '../../../skin/be-music/types.ts'; -import { drawKeyBeamEdges } from '../key-beam.ts'; +import { keyBeamGradient } from '../key-beam.ts'; /** Colours for one lane class: note highlight / body / shade, and the key cap at rest / pressed. */ interface LaneTone { @@ -28,6 +28,9 @@ const TONES: Record = { const NOTE_HEIGHT = 10; export const PHANTOM_BOMB_DURATION_MS = 150; +/** 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; @@ -50,20 +53,18 @@ export function renderPhantomLanes({ graphics, lanes, beatPhase }: BeMusicLanesC 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 tall, soft gradient in the lane's colour with crisp bright rails along both lane edges and a white - // base, so a press reads at a glance while the middle of the lane stays dim enough for the notes. 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, 280); + 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) - // White notes fall through white beams, so that body stays dimmer; the rails carry the press. - .fill({ fill: resolveBeamGradient(tone.body), alpha: beamAlpha * (lane.kind === 'white' ? 0.55 : 0.85) }); - drawKeyBeamEdges(graphics, x, w, bottom, beamHeight, tone.top, 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 - 26, w - 2, 26) - .fill({ fill: resolveBeamGradient(0xffffff), alpha: beamAlpha * 0.8 }); + .rect(x + 1, bottom - 24, w - 2, 24) + .fill({ fill: resolveBeamGradient(0xffffff), alpha: beamAlpha * 0.7 }); } // Judgement line — blood red with a paper-white edge, its glow flaring on each downbeat. diff --git a/packages/player-web/src/scene/default/synesthesia/playfield.ts b/packages/player-web/src/scene/default/synesthesia/playfield.ts index d8b6d2c2..da2aa657 100644 --- a/packages/player-web/src/scene/default/synesthesia/playfield.ts +++ b/packages/player-web/src/scene/default/synesthesia/playfield.ts @@ -7,7 +7,7 @@ import type { BeMusicLongNoteContext, BeMusicNoteContext, } from '../../../skin/be-music/types.ts'; -import { drawKeyBeamEdges } from '../key-beam.ts'; +import { keyBeamGradient } from '../key-beam.ts'; import type { ChildPool } from '../../pixi-utils.ts'; import { burstParticlePosition, @@ -78,15 +78,14 @@ export function renderSynesthesiaLanes({ .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 faint column of the lane's light with bright rails along both edges, so a press reads clearly while - // the notes falling down the middle keep their contrast. + // 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, 280); + 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: resolveColumnGradient(light.glow), alpha: beamAlpha * 0.5 }); - drawKeyBeamEdges(graphics, x, w, bottom, beamHeight, light.glow, beamAlpha); + .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 }); From f778ca95c5f002da18eea29197add432a0516600 Mon Sep 17 00:00:00 2001 From: Seiya KONNO Date: Mon, 5 Oct 2026 14:15:28 +0900 Subject: [PATCH 23/65] feat(player-web): add per-bus volume and compressor tuning to the audio bus The audio bus now carries a user volume per source bus (keysounds and BGM), applied at each mixer ahead of the compressor stack, and lets the key, BGM, master, and legacy compressors be retuned live within the Web Audio ranges. Gameplay views expose both as options and live setters, and the beatoraja gameplay prep accepts the same initial bus state. --- .../audio-bus-volume-compressor-tuning.md | 9 ++ .../player-web/src/chart/beatoraja/prep.ts | 11 +- .../player-web/src/runtime/audio-bus.test.ts | 133 ++++++++++++++++++ packages/player-web/src/runtime/audio-bus.ts | 118 +++++++++++++++- .../player-web/src/scene/core/gameplay.ts | 44 ++++++ 5 files changed, 306 insertions(+), 9 deletions(-) create mode 100644 .changeset/audio-bus-volume-compressor-tuning.md diff --git a/.changeset/audio-bus-volume-compressor-tuning.md b/.changeset/audio-bus-volume-compressor-tuning.md new file mode 100644 index 00000000..e4e676b2 --- /dev/null +++ b/.changeset/audio-bus-volume-compressor-tuning.md @@ -0,0 +1,9 @@ +--- +'@be-music/player-web': minor +--- + +Let hosts balance keysounds against BGM and tune each compressor. + +- The audio bus gains per-bus user volume (`setBusVolume` / `getBusVolume`, `initialVolumes`) applied at each source mixer ahead of the compressor stack, and live compressor tuning (`setCompressorParams` / `getCompressorParams`, `initialCompressorParams`) for the key, BGM, master, and legacy compressors, clamped to the Web Audio ranges. +- New `@be-music/player-web/runtime` exports: `AudioBusChannel`, `TunableCompressor`, `MAX_BUS_VOLUME`, `sanitizeBusVolume`, `DEFAULT_COMPRESSOR_PARAMS`, `COMPRESSOR_PARAM_RANGES`, and `mergeCompressorParams`. +- Gameplay views take `audioVolumes` and `audioCompressorParams` options and expose `setAudioVolume` / `setAudioCompressorParams` to change them live; the beatoraja gameplay prep accepts `audioBusOptions` for the same initial state. diff --git a/packages/player-web/src/chart/beatoraja/prep.ts b/packages/player-web/src/chart/beatoraja/prep.ts index 3573e177..0d08b251 100644 --- a/packages/player-web/src/chart/beatoraja/prep.ts +++ b/packages/player-web/src/chart/beatoraja/prep.ts @@ -49,7 +49,12 @@ import { resolveBmsBase, type BeMusicEvent, type BeMusicJson } from '@be-music/j import { resolveBmsControlFlow } from '@be-music/parser'; import { createBmsonSamplePlaybackMap, createTimingResolver } from '@be-music/audio-renderer/triggers'; import { buildBgaTimelines, type BgaTimelines } from '@be-music/player/core/bga-timeline'; -import { buildAudioBus, type AudioBusHandle, type CompressorMode } from '../../runtime/audio-bus.ts'; +import { + buildAudioBus, + type AudioBusHandle, + type BuildAudioBusOptions, + type CompressorMode, +} from '../../runtime/audio-bus.ts'; import type { EngineDriverAudioContext } from '../../runtime/engine-driver.ts'; import { loadAssetBytes, resolveChartAudioAsset } from '../../collection/collection.ts'; import { loadVideoTextureFromBytes } from '../../media/textures.ts'; @@ -72,6 +77,8 @@ export interface PrepareBeatorajaGameplayChartOptions { * with a master compressor fronting both). Pass `'off'` to bypass every compressor stage. */ audioCompressorMode?: CompressorMode; + /** Initial audio-bus state (stage toggles, keysound / BGM volumes, compressor tuning) from the host's UI. */ + audioBusOptions?: BuildAudioBusOptions; /** * Pre-resolved chart from a prior `resolveBmsControlFlow(song.chart)` call. When set, the prep * skips the resolve step and uses this chart verbatim. Required when the host already resolved @@ -127,7 +134,7 @@ export async function prepareBeatorajaGameplayChart( // Pre-warm the context — most browsers ship it in `suspended` state until the first user gesture and // the first `node.start()` would otherwise pay a one-time ramp-up tax. void audioContext.resume().catch(() => undefined); - const audioBus = buildAudioBus(audioContext, options.audioCompressorMode ?? 'split'); + const audioBus = buildAudioBus(audioContext, options.audioCompressorMode ?? 'split', options.audioBusOptions); // `#VOLWAV` master volume scaling; charts that omit the directive stay at unity. const volWav = chart.bms.volWav; if (typeof volWav === 'number' && Number.isFinite(volWav) && volWav >= 0) { diff --git a/packages/player-web/src/runtime/audio-bus.test.ts b/packages/player-web/src/runtime/audio-bus.test.ts index ae9ed6c5..fb0294cd 100644 --- a/packages/player-web/src/runtime/audio-bus.test.ts +++ b/packages/player-web/src/runtime/audio-bus.test.ts @@ -2,12 +2,18 @@ import { describe, expect, it } from 'vite-plus/test'; import { BGM_BUS_COMPRESSOR_PARAMS, COMPRESSOR_INPUT_TRIM_GAIN_LINEAR, + COMPRESSOR_PARAM_RANGES, + DEFAULT_COMPRESSOR_PARAMS, KEY_BUS_COMPRESSOR_PARAMS, LEGACY_COMPRESSOR_PARAMS, MASTER_BUS_COMPRESSOR_PARAMS, MASTER_MAKEUP_GAIN_LINEAR, + MAX_BUS_VOLUME, + MIXER_HEADROOM_GAIN_LINEAR, buildAudioBus, + mergeCompressorParams, parseCompressorMode, + sanitizeBusVolume, type CompressorMode, } from './audio-bus.ts'; @@ -521,6 +527,133 @@ describe('buildAudioBus master gain (#VOLWAV)', () => { }); }); +describe('sanitizeBusVolume', () => { + it('passes volumes in range through', () => { + expect(sanitizeBusVolume(0)).toBe(0); + expect(sanitizeBusVolume(0.35)).toBe(0.35); + expect(sanitizeBusVolume(1)).toBe(1); + }); + + it('clamps negatives to silence and caps boosts', () => { + expect(sanitizeBusVolume(-0.5)).toBe(0); + expect(sanitizeBusVolume(9)).toBe(MAX_BUS_VOLUME); + }); + + it('treats non-finite input as unity', () => { + expect(sanitizeBusVolume(Number.NaN)).toBe(1); + expect(sanitizeBusVolume(Number.POSITIVE_INFINITY)).toBe(1); + }); +}); + +describe('buildAudioBus per-bus volume', () => { + // The user's key / BGM balance rides on each source mixer's gain, on top of the fixed headroom trim, so it applies + // ahead of every compressor stage in every routing mode. + const mixerGain = (node: GainNode) => (node as unknown as FakeNode).gain!.value; + + it('starts both buses at unity (the headroom trim alone)', () => { + const { context } = createFakeAudioContext(); + const bus = buildAudioBus(context, 'split'); + expect(bus.getBusVolume('key')).toBe(1); + expect(bus.getBusVolume('bgm')).toBe(1); + expect(mixerGain(bus.keyMixer)).toBeCloseTo(MIXER_HEADROOM_GAIN_LINEAR); + expect(mixerGain(bus.bgmMixer)).toBeCloseTo(MIXER_HEADROOM_GAIN_LINEAR); + }); + + it('seeds the volumes from initialVolumes', () => { + const { context } = createFakeAudioContext(); + const bus = buildAudioBus(context, 'split', { initialVolumes: { key: 0.5, bgm: 1.5 } }); + expect(mixerGain(bus.keyMixer)).toBeCloseTo(MIXER_HEADROOM_GAIN_LINEAR * 0.5); + expect(mixerGain(bus.bgmMixer)).toBeCloseTo(MIXER_HEADROOM_GAIN_LINEAR * 1.5); + }); + + it('sets each bus independently and keeps the value across mode changes', () => { + const { context } = createFakeAudioContext(); + const bus = buildAudioBus(context, 'split'); + bus.setBusVolume('key', 0.25); + expect(bus.getBusVolume('key')).toBe(0.25); + expect(bus.getBusVolume('bgm')).toBe(1); + expect(mixerGain(bus.keyMixer)).toBeCloseTo(MIXER_HEADROOM_GAIN_LINEAR * 0.25); + expect(mixerGain(bus.bgmMixer)).toBeCloseTo(MIXER_HEADROOM_GAIN_LINEAR); + for (const mode of ['legacy', 'off', 'split'] as const) { + bus.setMode(mode); + expect(mixerGain(bus.keyMixer)).toBeCloseTo(MIXER_HEADROOM_GAIN_LINEAR * 0.25); + } + }); + + it('sanitizes the volume it applies', () => { + const { context } = createFakeAudioContext(); + const bus = buildAudioBus(context, 'split'); + bus.setBusVolume('bgm', -3); + expect(bus.getBusVolume('bgm')).toBe(0); + expect(mixerGain(bus.bgmMixer)).toBe(0); + }); +}); + +describe('mergeCompressorParams', () => { + it('overrides only the given fields', () => { + expect(mergeCompressorParams(KEY_BUS_COMPRESSOR_PARAMS, { threshold: -18, ratio: 6 })).toEqual({ + ...KEY_BUS_COMPRESSOR_PARAMS, + threshold: -18, + ratio: 6, + }); + }); + + it('clamps each field into its Web Audio range', () => { + const merged = mergeCompressorParams(KEY_BUS_COMPRESSOR_PARAMS, { + threshold: 12, + ratio: 0.5, + attack: 3, + release: -1, + knee: 99, + }); + expect(merged).toEqual({ + threshold: COMPRESSOR_PARAM_RANGES.threshold.max, + ratio: COMPRESSOR_PARAM_RANGES.ratio.min, + attack: COMPRESSOR_PARAM_RANGES.attack.max, + release: COMPRESSOR_PARAM_RANGES.release.min, + knee: COMPRESSOR_PARAM_RANGES.knee.max, + }); + }); + + it('keeps the base value for non-finite input', () => { + expect(mergeCompressorParams(BGM_BUS_COMPRESSOR_PARAMS, { threshold: Number.NaN })).toEqual( + BGM_BUS_COMPRESSOR_PARAMS, + ); + }); +}); + +describe('buildAudioBus compressor tuning', () => { + const splitKeyCompressor = (bus: ReturnType) => { + const keyTrim = [...(bus.keyMixer as unknown as FakeNode).outgoing][0]!; + return [...keyTrim.outgoing][0]!; + }; + + it('starts every compressor at its factory parameters', () => { + const { context } = createFakeAudioContext(); + const bus = buildAudioBus(context, 'split'); + for (const compressor of ['key', 'bgm', 'master', 'legacy'] as const) { + expect(bus.getCompressorParams(compressor)).toEqual(DEFAULT_COMPRESSOR_PARAMS[compressor]); + } + expect(splitKeyCompressor(bus).threshold!.value).toBe(KEY_BUS_COMPRESSOR_PARAMS.threshold); + }); + + it('seeds overrides from initialCompressorParams', () => { + const { context } = createFakeAudioContext(); + const bus = buildAudioBus(context, 'split', { initialCompressorParams: { key: { threshold: -20 } } }); + expect(bus.getCompressorParams('key').threshold).toBe(-20); + expect(splitKeyCompressor(bus).threshold!.value).toBe(-20); + }); + + it('retunes a compressor live, leaving the others alone', () => { + const { context } = createFakeAudioContext(); + const bus = buildAudioBus(context, 'split'); + bus.setCompressorParams('key', { threshold: -24, knee: 3 }); + expect(bus.getCompressorParams('key')).toEqual({ ...KEY_BUS_COMPRESSOR_PARAMS, threshold: -24, knee: 3 }); + expect(splitKeyCompressor(bus).threshold!.value).toBe(-24); + expect(bus.getCompressorParams('bgm')).toEqual(BGM_BUS_COMPRESSOR_PARAMS); + }); +}); + // -- Exit fade (post-tap) ------------------------------------------------- describe('buildAudioBus exit-fade gain', () => { diff --git a/packages/player-web/src/runtime/audio-bus.ts b/packages/player-web/src/runtime/audio-bus.ts index 2a406103..05db0976 100644 --- a/packages/player-web/src/runtime/audio-bus.ts +++ b/packages/player-web/src/runtime/audio-bus.ts @@ -157,6 +157,56 @@ export const MIXER_HEADROOM_GAIN_LINEAR = 0.5; */ export type CompressorStage = 'key' | 'bgm' | 'master'; +/** Compressor whose parameters can be tuned: the three split-mode stages, or the single `'legacy'` compressor. */ +export type TunableCompressor = CompressorStage | 'legacy'; + +/** Factory parameters of every tunable compressor. */ +export const DEFAULT_COMPRESSOR_PARAMS: Readonly>> = { + key: KEY_BUS_COMPRESSOR_PARAMS, + bgm: BGM_BUS_COMPRESSOR_PARAMS, + master: MASTER_BUS_COMPRESSOR_PARAMS, + legacy: LEGACY_COMPRESSOR_PARAMS, +}; + +/** Web Audio `DynamicsCompressorNode` ranges each parameter is clamped to. */ +export const COMPRESSOR_PARAM_RANGES: Readonly> = { + threshold: { min: -100, max: 0 }, + ratio: { min: 1, max: 20 }, + attack: { min: 0, max: 1 }, + release: { min: 0, max: 1 }, + knee: { min: 0, max: 40 }, +}; + +/** + * Merges `patch` over `base`, clamping each value into its {@link COMPRESSOR_PARAM_RANGES} range; non-finite values + * keep the base value. + */ +export function mergeCompressorParams(base: CompressorParams, patch: Partial = {}): CompressorParams { + const merged = { ...base }; + for (const key of Object.keys(COMPRESSOR_PARAM_RANGES) as Array) { + const value = patch[key]; + if (value === undefined || !Number.isFinite(value)) continue; + const { min, max } = COMPRESSOR_PARAM_RANGES[key]; + merged[key] = Math.min(max, Math.max(min, value)); + } + return merged; +} + +/** Source bus a user volume applies to: `'key'` (player keysounds) or `'bgm'` (auto-triggered BGM). */ +export type AudioBusChannel = 'key' | 'bgm'; + +/** Upper bound for a user bus volume (2 = +6 dB). */ +export const MAX_BUS_VOLUME = 2; + +/** + * Sanitizes a user bus volume (linear, 1 = unity): non-finite → unity, negative → 0, and clamped to + * {@link MAX_BUS_VOLUME} so a stray value can't blow the mix past the compressor stack's headroom. + */ +export function sanitizeBusVolume(value: number): number { + if (!Number.isFinite(value)) return 1; + return Math.min(MAX_BUS_VOLUME, Math.max(0, value)); +} + /** Per-stage on/off state. Defaults to all `true` (every stage engaged). */ export interface CompressorStages { key: boolean; @@ -204,6 +254,21 @@ export interface AudioBusHandle { * checkbox can round-trip its value through the bus without losing it on a temporary mode change). */ getStageEnabled(stage: CompressorStage): boolean; + /** + * Sets the user volume of one source bus (linear, 1 = unity; see {@link sanitizeBusVolume}). Applied at the bus's + * mixer on top of the headroom trim, ahead of every compressor stage, so it balances keysounds against BGM in every + * routing mode and the recording tap captures the balance the user hears. + */ + setBusVolume(channel: AudioBusChannel, value: number): void; + /** Returns the current user volume of one source bus (default `1`). */ + getBusVolume(channel: AudioBusChannel): number; + /** + * Retunes one compressor live (see {@link mergeCompressorParams}); unspecified fields keep their current value. Works + * whether or not the compressor is currently routed, so a later mode / stage switch picks the tuning up. + */ + setCompressorParams(compressor: TunableCompressor, params: Partial): void; + /** Returns the current parameters of one compressor. */ + getCompressorParams(compressor: TunableCompressor): CompressorParams; /** * Sets the chart-level master gain (BMS `#VOLWAV`). The value is a linear multiplier applied at a dedicated stage * that sits BEFORE the recording tap, so a chart authored with `#VOLWAV 80` runs at 80 % through every routing mode @@ -241,6 +306,10 @@ export interface BuildAudioBusOptions { * surface a UI for per-stage bypass and want the bus's state to come up matching the pre-mount UI selection. */ initialStages?: Partial; + /** Initial user volume per source bus (linear, 1 = unity). Missing fields default to `1`. */ + initialVolumes?: Partial>; + /** Initial parameter overrides per compressor, merged over {@link DEFAULT_COMPRESSOR_PARAMS}. */ + initialCompressorParams?: Partial>>; } /** @@ -260,8 +329,13 @@ export function buildAudioBus( const bgmMixer = audioContext.createGain(); // Sub-unity mixer gain so multiple simultaneous samples don't sum past full scale before the compressor stack has a // chance to react. See `MIXER_HEADROOM_GAIN_LINEAR` for why ~−6 dB. - keyMixer.gain.value = MIXER_HEADROOM_GAIN_LINEAR; - bgmMixer.gain.value = MIXER_HEADROOM_GAIN_LINEAR; + // The user's per-bus volume rides on the same gain, so it sits ahead of every compressor stage. + const volumes: Record = { + key: sanitizeBusVolume(options.initialVolumes?.key ?? 1), + bgm: sanitizeBusVolume(options.initialVolumes?.bgm ?? 1), + }; + keyMixer.gain.value = MIXER_HEADROOM_GAIN_LINEAR * volumes.key; + bgmMixer.gain.value = MIXER_HEADROOM_GAIN_LINEAR * volumes.bgm; const keyCompressorInput = audioContext.createGain(); const bgmCompressorInput = audioContext.createGain(); keyCompressorInput.gain.value = COMPRESSOR_INPUT_TRIM_GAIN_LINEAR; @@ -270,12 +344,24 @@ export function buildAudioBus( // doesn't use them so the next `setMode` call can splice them back in without re-creating Web Audio nodes (cheap, but // recreating would also reset their internal envelope state which wastes any "warm" gain reduction the mode switch // could otherwise preserve). - const keyComp = createCompressor(audioContext, KEY_BUS_COMPRESSOR_PARAMS); - const bgmComp = createCompressor(audioContext, BGM_BUS_COMPRESSOR_PARAMS); - const masterComp = createCompressor(audioContext, MASTER_BUS_COMPRESSOR_PARAMS); + const compressorParams: Record = { + key: mergeCompressorParams(KEY_BUS_COMPRESSOR_PARAMS, options.initialCompressorParams?.key), + bgm: mergeCompressorParams(BGM_BUS_COMPRESSOR_PARAMS, options.initialCompressorParams?.bgm), + master: mergeCompressorParams(MASTER_BUS_COMPRESSOR_PARAMS, options.initialCompressorParams?.master), + legacy: mergeCompressorParams(LEGACY_COMPRESSOR_PARAMS, options.initialCompressorParams?.legacy), + }; + const keyComp = createCompressor(audioContext, compressorParams.key); + const bgmComp = createCompressor(audioContext, compressorParams.bgm); + const masterComp = createCompressor(audioContext, compressorParams.master); // Legacy mode reuses a single compressor with the original pre-split params. Distinct node from the master so // swapping modes doesn't have to mutate AudioParam values mid-chart. - const legacyComp = createCompressor(audioContext, LEGACY_COMPRESSOR_PARAMS); + const legacyComp = createCompressor(audioContext, compressorParams.legacy); + const compressorNodes: Record = { + key: keyComp, + bgm: bgmComp, + master: masterComp, + legacy: legacyComp, + }; const makeup = audioContext.createGain(); makeup.gain.value = MASTER_MAKEUP_GAIN_LINEAR; // BMS spec — `#VOLWAV <0..ZZ>` declares the chart's master volume scaling (100 = unity). Implemented as a dedicated @@ -397,6 +483,20 @@ export function buildAudioBus( getStageEnabled(stage: CompressorStage): boolean { return stages[stage]; }, + setBusVolume(channel: AudioBusChannel, value: number): void { + volumes[channel] = sanitizeBusVolume(value); + (channel === 'key' ? keyMixer : bgmMixer).gain.value = MIXER_HEADROOM_GAIN_LINEAR * volumes[channel]; + }, + getBusVolume(channel: AudioBusChannel): number { + return volumes[channel]; + }, + setCompressorParams(compressor: TunableCompressor, params: Partial): void { + compressorParams[compressor] = mergeCompressorParams(compressorParams[compressor], params); + applyCompressorParams(compressorNodes[compressor], compressorParams[compressor]); + }, + getCompressorParams(compressor: TunableCompressor): CompressorParams { + return { ...compressorParams[compressor] }; + }, setMasterGain(value: number): void { // Sanitize pathological inputs — `#VOLWAV` is documented as `0..ZZ` (linear-scale BMS units, 100 = unity, > 100 // boosts the chart above unity), but a malformed chart could emit NaN / Infinity / negative numbers that would @@ -465,12 +565,16 @@ export function buildAudioBus( function createCompressor(audioContext: AudioContext, params: CompressorParams): DynamicsCompressorNode { const node = audioContext.createDynamicsCompressor(); + applyCompressorParams(node, params); + return node; +} + +function applyCompressorParams(node: DynamicsCompressorNode, params: CompressorParams): void { node.threshold.value = params.threshold; node.ratio.value = params.ratio; node.attack.value = params.attack; node.release.value = params.release; node.knee.value = params.knee; - return node; } /** diff --git a/packages/player-web/src/scene/core/gameplay.ts b/packages/player-web/src/scene/core/gameplay.ts index 0bf25001..d65a7c9b 100644 --- a/packages/player-web/src/scene/core/gameplay.ts +++ b/packages/player-web/src/scene/core/gameplay.ts @@ -66,10 +66,14 @@ import { } from '../../collection/collection.ts'; import { loadTextureFromBytes, loadVideoTextureFromBytes } from '../../media/textures.ts'; import { + type AudioBusChannel, type AudioBusHandle, type CompressorMode, + type CompressorParams, + type TunableCompressor, type CompressorStage, buildAudioBus, + sanitizeBusVolume, } from '../../runtime/audio-bus.ts'; import { GameplayRecorder, type GameplayRecorderResult } from '../../recording/gameplay-recorder.ts'; import { PerfTracker } from '../perf.ts'; @@ -458,6 +462,17 @@ export interface CoreGameplayViewOptions { * re-mount. */ audioCompressorStages?: { key?: boolean; bgm?: boolean; master?: boolean }; + /** + * Initial user volume per source bus — `key` (player keysounds) and `bgm` (auto-triggered BGM) — as linear gains + * (1 = unity, clamped to `0..2`). Hosts with volume sliders pass the current UI values so a re-mounted gameplay keeps + * the user's balance; {@link CoreGameplayView.setAudioVolume} changes them live. + */ + audioVolumes?: { key?: number; bgm?: number }; + /** + * Parameter overrides per compressor (`key` / `bgm` / `master` in split mode, `legacy`), merged over the factory + * tuning. {@link CoreGameplayView.setAudioCompressorParams} retunes them live. + */ + audioCompressorParams?: Partial>>; /** * When set to a positive integer, BGA videos that need the ffmpeg.wasm fallback (legacy `.mpg` / `.wmv` / `.avi` / * unsupported codecs) are downscaled during transcode so neither edge exceeds this many pixels. Aspect ratio is @@ -735,6 +750,10 @@ export class CoreGameplayView>> = {}; + /** User keysound / BGM bus volumes, seeded from the options and kept across audio-bus rebuilds. */ + private audioVolumes: Record = { key: 1, bgm: 1 }; /** * Active recorder (canvas video + audio bus tap → WebM blob) when the host has started a recording session via {@link * startRecording}. `undefined` while idle. We hold the instance across the play session so `stopRecording` / @@ -1023,6 +1042,11 @@ export class CoreGameplayView): void { + this.audioCompressorParams[compressor] = { ...this.audioCompressorParams[compressor], ...params }; + this.audioBus?.setCompressorParams(compressor, params); + } + /** * Begins recording the play scene (canvas video + bus audio mix) into a WebM Blob. Throws when `prepareAudio` hasn't * finished setting up the audio context yet, or when the browser doesn't expose `MediaRecorder` / @@ -2105,6 +2147,8 @@ export class CoreGameplayView Date: Mon, 5 Oct 2026 14:15:28 +0900 Subject: [PATCH 24/65] feat(player-web-demo): add volume and compressor sections to the Debug Menu The Debug Menu gains a Volume section with key sound and BGM sliders and a Compressor section holding the on/off switch, the stage toggles, and one folder per compressor with threshold, knee, ratio, attack, and release sliders plus a reset. Changes apply live to the running chart and persist across reloads. The workspace test config aliases the @be-music/player-web/runtime subpath so demo tests can import it. --- .../src/compressor-tuning.test.ts | 55 ++++++++ .../player-web-demo/src/compressor-tuning.ts | 99 +++++++++++++++ packages/player-web-demo/src/main.ts | 120 +++++++++++++++++- packages/player-web-demo/src/types.ts | 4 + vite.config.ts | 1 + 5 files changed, 276 insertions(+), 3 deletions(-) create mode 100644 packages/player-web-demo/src/compressor-tuning.test.ts create mode 100644 packages/player-web-demo/src/compressor-tuning.ts diff --git a/packages/player-web-demo/src/compressor-tuning.test.ts b/packages/player-web-demo/src/compressor-tuning.test.ts new file mode 100644 index 00000000..55e034d7 --- /dev/null +++ b/packages/player-web-demo/src/compressor-tuning.test.ts @@ -0,0 +1,55 @@ +import { DEFAULT_COMPRESSOR_PARAMS, KEY_BUS_COMPRESSOR_PARAMS } from '@be-music/player-web/runtime'; +import { describe, expect, it } from 'vite-plus/test'; +import { + defaultCompressorTunings, + paramsFromTuning, + parseCompressorTunings, + tuningFromParams, +} from './compressor-tuning.ts'; + +describe('tuningFromParams / paramsFromTuning', () => { + it('shows attack and release in milliseconds', () => { + expect(tuningFromParams(KEY_BUS_COMPRESSOR_PARAMS)).toEqual({ + thresholdDb: KEY_BUS_COMPRESSOR_PARAMS.threshold, + kneeDb: KEY_BUS_COMPRESSOR_PARAMS.knee, + ratio: KEY_BUS_COMPRESSOR_PARAMS.ratio, + attackMs: KEY_BUS_COMPRESSOR_PARAMS.attack * 1000, + releaseMs: KEY_BUS_COMPRESSOR_PARAMS.release * 1000, + }); + }); + + it('round-trips back to the bus parameters', () => { + const params = paramsFromTuning(tuningFromParams(KEY_BUS_COMPRESSOR_PARAMS), KEY_BUS_COMPRESSOR_PARAMS); + expect(params.threshold).toBe(KEY_BUS_COMPRESSOR_PARAMS.threshold); + expect(params.attack).toBeCloseTo(KEY_BUS_COMPRESSOR_PARAMS.attack); + expect(params.release).toBeCloseTo(KEY_BUS_COMPRESSOR_PARAMS.release); + }); + + it('clamps slider values into the Web Audio ranges', () => { + const params = paramsFromTuning( + { thresholdDb: 5, kneeDb: 80, ratio: 50, attackMs: 4000, releaseMs: -10 }, + KEY_BUS_COMPRESSOR_PARAMS, + ); + expect(params).toEqual({ threshold: 0, knee: 40, ratio: 20, attack: 1, release: 0 }); + }); +}); + +describe('parseCompressorTunings', () => { + it('falls back to the factory tuning for missing or unreadable storage', () => { + expect(parseCompressorTunings(null)).toEqual(defaultCompressorTunings()); + expect(parseCompressorTunings('not json')).toEqual(defaultCompressorTunings()); + expect(parseCompressorTunings('42')).toEqual(defaultCompressorTunings()); + }); + + it('merges saved values over the factory tuning per compressor', () => { + const tunings = parseCompressorTunings(JSON.stringify({ key: { thresholdDb: -20, ratio: 'x' } })); + expect(tunings.key.thresholdDb).toBe(-20); + expect(tunings.key.ratio).toBe(DEFAULT_COMPRESSOR_PARAMS.key.ratio); + expect(tunings.bgm).toEqual(defaultCompressorTunings().bgm); + }); + + it('clamps out-of-range saved values', () => { + const tunings = parseCompressorTunings(JSON.stringify({ master: { kneeDb: 99 } })); + expect(tunings.master.kneeDb).toBe(40); + }); +}); diff --git a/packages/player-web-demo/src/compressor-tuning.ts b/packages/player-web-demo/src/compressor-tuning.ts new file mode 100644 index 00000000..5075ce7e --- /dev/null +++ b/packages/player-web-demo/src/compressor-tuning.ts @@ -0,0 +1,99 @@ +// Debug Menu state for the audio bus's compressor tuning: the Web Audio parameters in units that read well on a slider +// (dB and milliseconds), conversion to and from the bus's `CompressorParams`, and persistence across reloads. + +import { + DEFAULT_COMPRESSOR_PARAMS, + mergeCompressorParams, + type CompressorParams, + type TunableCompressor, +} from '@be-music/player-web/runtime'; + +/** One compressor's parameters as the sliders show them. */ +export interface CompressorTuning { + thresholdDb: number; + kneeDb: number; + ratio: number; + attackMs: number; + releaseMs: number; +} + +export const TUNABLE_COMPRESSORS: readonly TunableCompressor[] = ['key', 'bgm', 'master', 'legacy']; + +const STORAGE_KEY = 'be-music-demo.compressor-tuning'; + +/** Slider units for a bus `CompressorParams` (seconds → milliseconds). */ +export function tuningFromParams(params: CompressorParams): CompressorTuning { + return { + thresholdDb: params.threshold, + kneeDb: params.knee, + ratio: params.ratio, + attackMs: params.attack * 1000, + releaseMs: params.release * 1000, + }; +} + +/** Bus `CompressorParams` for slider values (milliseconds → seconds), clamped to the Web Audio ranges. */ +export function paramsFromTuning(tuning: CompressorTuning, base: CompressorParams): CompressorParams { + return mergeCompressorParams(base, { + threshold: tuning.thresholdDb, + knee: tuning.kneeDb, + ratio: tuning.ratio, + attack: tuning.attackMs / 1000, + release: tuning.releaseMs / 1000, + }); +} + +/** Factory tuning of every compressor, in slider units. */ +export function defaultCompressorTunings(): Record { + return Object.fromEntries( + TUNABLE_COMPRESSORS.map((compressor) => [compressor, tuningFromParams(DEFAULT_COMPRESSOR_PARAMS[compressor])]), + ) as Record; +} + +/** + * Parses stored tunings: each compressor's saved values are merged over its factory parameters (so a partial or stale + * entry can't produce an out-of-range node), and anything unreadable falls back to the factory tuning. + */ +export function parseCompressorTunings(raw: string | null | undefined): Record { + const tunings = defaultCompressorTunings(); + if (!raw) return tunings; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return tunings; + } + if (typeof parsed !== 'object' || parsed === null) return tunings; + for (const compressor of TUNABLE_COMPRESSORS) { + const entry = (parsed as Record)[compressor]; + if (typeof entry !== 'object' || entry === null) continue; + const saved = { ...tunings[compressor], ...pickNumbers(entry as Record) }; + tunings[compressor] = tuningFromParams(paramsFromTuning(saved, DEFAULT_COMPRESSOR_PARAMS[compressor])); + } + return tunings; +} + +function pickNumbers(entry: Record): Partial { + const picked: Partial = {}; + for (const key of ['thresholdDb', 'kneeDb', 'ratio', 'attackMs', 'releaseMs'] as const) { + const value = entry[key]; + if (typeof value === 'number' && Number.isFinite(value)) picked[key] = value; + } + return picked; +} + +export function readStoredCompressorTunings(): Record { + try { + return parseCompressorTunings(window.localStorage.getItem(STORAGE_KEY)); + } catch { + return defaultCompressorTunings(); + } +} + +export function storeCompressorTunings(tunings: Record): void { + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(tunings)); + } catch { + // Private windows / blocked storage: the tuning still applies for this session. + } +} diff --git a/packages/player-web-demo/src/main.ts b/packages/player-web-demo/src/main.ts index 89571d59..fcbbd51a 100644 --- a/packages/player-web-demo/src/main.ts +++ b/packages/player-web-demo/src/main.ts @@ -59,9 +59,20 @@ import { resolvePlaylogFilename, serializePlaylog, PLAYLOG_FILE_SUFFIX, + DEFAULT_COMPRESSOR_PARAMS, type BeMusicPlaylog, type CompressorMode, + type CompressorParams, + type TunableCompressor, } from '@be-music/player-web/runtime'; +import { + TUNABLE_COMPRESSORS, + defaultCompressorTunings, + paramsFromTuning, + readStoredCompressorTunings, + storeCompressorTunings, + type CompressorTuning, +} from './compressor-tuning.ts'; import { logger } from '@be-music/player-web'; import { discoverLr2Themes, @@ -159,6 +170,31 @@ function storeBuiltInSkinId(id: string): void { } } +const VOLUME_STORAGE_KEY = 'be-music-demo.volumes'; + +/** Keysound / BGM volumes in percent, as last set in the Debug Menu (100 / 100 when nothing is stored). */ +function readStoredVolumes(): { keyVolume: number; bgmVolume: number } { + const fallback = { keyVolume: 100, bgmVolume: 100 }; + try { + const raw = window.localStorage.getItem(VOLUME_STORAGE_KEY); + if (!raw) return fallback; + const parsed = JSON.parse(raw) as Partial>; + const percent = (value: unknown, otherwise: number) => + typeof value === 'number' && Number.isFinite(value) ? Math.min(100, Math.max(0, value)) : otherwise; + return { keyVolume: percent(parsed.keyVolume, 100), bgmVolume: percent(parsed.bgmVolume, 100) }; + } catch { + return fallback; + } +} + +function storeVolumes(volumes: { keyVolume: number; bgmVolume: number }): void { + try { + window.localStorage.setItem(VOLUME_STORAGE_KEY, JSON.stringify(volumes)); + } catch { + // Private windows / blocked storage: the volumes still apply for this session. + } +} + const DEFAULT_UI_FONT_LOADS: readonly string[] = [ '400 22px "LINE Seed JP"', '700 18px "LINE Seed JP"', @@ -410,6 +446,8 @@ class PlayerWebDemoApp { */ private gui: GUI | undefined; private compressorStageFolder: GUI | undefined; + /** Compressor tuning in slider units, restored from the last visit; seeds every gameplay mount. */ + private readonly compressorTunings: Record = readStoredCompressorTunings(); private recordController: Controller | undefined; /** * "Auto-save play history" checkbox controller. Held so the play-start path can `disable()` it for the duration @@ -471,6 +509,7 @@ class PlayerWebDemoApp { // digital- clip at the destination. The `MIXER_HEADROOM_GAIN_LINEAR` attenuation in `audio-bus.ts` buys a little // headroom but the master limiter is what reliably prevents audible clipping on dense charts. Power users wanting // an unprocessed signal path can still flip it via `?compressor=off` or the GUI. + ...readStoredVolumes(), compressor: true, compressorKey: true, compressorBgm: true, @@ -705,11 +744,68 @@ class PlayerWebDemoApp { * lil-gui's `show(false)` collapses the folder out of the panel entirely, matching the previous `display: none` * behavior. */ + /** Bus `CompressorParams` for every compressor, from the Debug Menu's tuning. */ + private resolveCompressorParamOverrides(): Record { + return Object.fromEntries( + TUNABLE_COMPRESSORS.map((compressor) => [ + compressor, + paramsFromTuning(this.compressorTunings[compressor], DEFAULT_COMPRESSOR_PARAMS[compressor]), + ]), + ) as Record; + } + private refreshCompressorStageVisibility(): void { const visible = this.guiState.compressor && this.compressorMode === 'split'; this.compressorStageFolder?.show(visible); } + /** + * One folder per compressor (key / BGM / master in split mode; the single legacy compressor only when the + * `?compressor=legacy` architecture is active) with threshold, knee, ratio, attack, and release sliders and a reset. + * Changes retune a running chart live and are remembered for the next visit. + */ + private buildCompressorTuningGui(parent: GUI): void { + const labels: Record = { + key: 'Key compressor', + bgm: 'BGM compressor', + master: 'Master compressor', + legacy: 'Legacy compressor', + }; + const compressors = TUNABLE_COMPRESSORS.filter((compressor) => + this.compressorMode === 'legacy' ? compressor === 'legacy' : compressor !== 'legacy', + ); + for (const compressor of compressors) { + const tuning = this.compressorTunings[compressor]; + const folder = parent.addFolder(labels[compressor]).close(); + const apply = () => { + const params = paramsFromTuning(tuning, DEFAULT_COMPRESSOR_PARAMS[compressor]); + this.gameplayView?.setAudioCompressorParams(compressor, params); + this.beatorajaGameplayPrep?.audioBus.setCompressorParams(compressor, params); + storeCompressorTunings(this.compressorTunings); + }; + const controllers = [ + folder.add(tuning, 'thresholdDb', -60, 0, 0.5).name('Threshold (dB)'), + folder.add(tuning, 'kneeDb', 0, 40, 0.5).name('Knee (dB)'), + folder.add(tuning, 'ratio', 1, 20, 0.1).name('Ratio (x:1)'), + folder.add(tuning, 'attackMs', 0, 200, 0.5).name('Attack (ms)'), + folder.add(tuning, 'releaseMs', 0, 1000, 5).name('Release (ms)'), + ]; + for (const controller of controllers) controller.onChange(apply); + folder + .add( + { + reset: () => { + Object.assign(tuning, defaultCompressorTunings()[compressor]); + for (const controller of controllers) controller.updateDisplay(); + apply(); + }, + }, + 'reset', + ) + .name('Reset to default'); + } + } + /** * Builds the floating lil-gui control panel and wires every controller to the gameplay / scene state. Centralizing * this in one method lets us bind handles to specific controllers (`recordController`, `compressorStageFolder`) @@ -779,14 +875,25 @@ class PlayerWebDemoApp { // without forcing the user to restart the song. this.gameplayView?.setAutoPauseOnBlur(value); }); - gui + // Keysound / BGM balance. Both sliders push live into a running chart and are remembered for the next visit. + const volume = gui.addFolder('Volume'); + const onVolumeChange = (channel: 'key' | 'bgm') => (value: number) => { + this.gameplayView?.setAudioVolume(channel, value / 100); + this.beatorajaGameplayPrep?.audioBus.setBusVolume(channel, value / 100); + storeVolumes({ keyVolume: this.guiState.keyVolume, bgmVolume: this.guiState.bgmVolume }); + }; + volume.add(this.guiState, 'keyVolume', 0, 100, 1).name('Key sound (%)').onChange(onVolumeChange('key')); + volume.add(this.guiState, 'bgmVolume', 0, 100, 1).name('BGM (%)').onChange(onVolumeChange('bgm')); + // Compressor: the master switch, the split-mode stage toggles, and every compressor's parameters. + const compressor = gui.addFolder('Compressor'); + compressor .add(this.guiState, 'compressor') - .name('Compressor') + .name('Enabled') .onChange((value: boolean) => { this.gameplayView?.setAudioCompressor(value); this.refreshCompressorStageVisibility(); }); - const stages = gui.addFolder('Compressor stages'); + const stages = compressor.addFolder('Stages'); this.compressorStageFolder = stages; stages .add(this.guiState, 'compressorKey') @@ -806,6 +913,7 @@ class PlayerWebDemoApp { .onChange((value: boolean) => { this.gameplayView?.setAudioCompressorStageEnabled('master', value); }); + this.buildCompressorTuningGui(compressor); // BGA video transcode controls. Both settings are seeded into the next `PixiGameplayView` constructor (see // `preloadGameplay` / `playSong` for the wiring), so changing them mid-session takes effect on the next chart mount // — no need to rebuild gameplay if the user is between songs. We don't push live into the running gameplay because @@ -1780,6 +1888,10 @@ class PlayerWebDemoApp { song, source, audioCompressorMode: this.guiState.compressor === false ? 'off' : this.compressorMode, + audioBusOptions: { + initialVolumes: { key: this.guiState.keyVolume / 100, bgm: this.guiState.bgmVolume / 100 }, + initialCompressorParams: this.resolveCompressorParamOverrides(), + }, preResolvedChart, }); } catch (error) { @@ -3174,6 +3286,8 @@ class PlayerWebDemoApp { ...(replay === undefined ? { judgeRuleset: this.guiState.judgeRuleset } : {}), ...(overrides.chartSha256 !== undefined ? { chartSha256: overrides.chartSha256 } : {}), replay, + audioVolumes: { key: this.guiState.keyVolume / 100, bgm: this.guiState.bgmVolume / 100 }, + audioCompressorParams: this.resolveCompressorParamOverrides(), audioCompressor: this.guiState.compressor, audioCompressorMode: this.compressorMode, audioCompressorStages: { diff --git a/packages/player-web-demo/src/types.ts b/packages/player-web-demo/src/types.ts index df5fd8cb..6ba45e3f 100644 --- a/packages/player-web-demo/src/types.ts +++ b/packages/player-web-demo/src/types.ts @@ -61,6 +61,10 @@ export interface DemoGuiState { * holds focus. */ autoPauseOnBlur: boolean; + /** Keysound bus volume in percent (0..100), pushed live to gameplay and remembered across reloads. */ + keyVolume: number; + /** BGM bus volume in percent (0..100), pushed live to gameplay and remembered across reloads. */ + bgmVolume: number; compressor: boolean; compressorKey: boolean; compressorBgm: boolean; diff --git a/vite.config.ts b/vite.config.ts index d7d0cdd6..c2ca73e1 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -37,6 +37,7 @@ const workspaceAliases = [ { find: '@be-music/player/state-signals', replacement: resolve(rootDir, 'packages/player/src/state-signals.ts') }, { find: '@be-music/player/utils', replacement: resolve(rootDir, 'packages/player/src/utils.ts') }, { find: '@be-music/player/core', replacement: resolve(rootDir, 'packages/player/src/core') }, + { find: '@be-music/player-web/runtime', replacement: resolve(rootDir, 'packages/player-web/src/runtime/index.ts') }, { find: '@be-music/utils/core', replacement: resolve(rootDir, 'packages/utils/src/core.ts') }, { find: '@be-music/utils/cli-path', replacement: resolve(rootDir, 'packages/utils/src/cli-path.ts') }, { find: '@be-music/utils/log', replacement: resolve(rootDir, 'packages/utils/src/log.ts') }, From 7ee6803f616cdbcec7076675890eef04f0ffa6b6 Mon Sep 17 00:00:00 2001 From: Seiya KONNO Date: Thu, 8 Oct 2026 00:27:12 +0900 Subject: [PATCH 25/65] feat(player-web): turn Synesthesia bomb sparks into fine powder Bursts now throw more, much smaller grains: most sparks are sub-2 px squares, only the coarsest few keep a short hairline trail or a small glow, so a hit reads as a puff of powder rather than a spray of streaks. --- .changeset/be-music-skins.md | 2 +- .../scene/default/synesthesia/playfield.ts | 21 ++++++++++++------- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/.changeset/be-music-skins.md b/.changeset/be-music-skins.md index e4c0ac9f..fe0d80df 100644 --- a/.changeset/be-music-skins.md +++ b/.changeset/be-music-skins.md @@ -8,7 +8,7 @@ The default (skinless) family now renders through a `BeMusicSkin`: one object bu - New exports: `BeMusicSkin` and its context types, `createBeMusicSkinRegistry`, `resolveBeMusicLaneKind`, `resolveSelectListWindow`, `phantomSkin`, `synesthesiaSkin`, `latticeSkin`, and `BUILT_IN_BE_MUSIC_SKINS`. - Phantom is the existing poster skin and still the default. 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 lit by an ember horizon, a world built from fine ember / gold particles with electric blue and magenta accents, data dust and speed streaks pouring out of the vanishing point, 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 particle fountains with voxel sparks, motion trails, tilted shockwave rings, 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 a 3D particle ring that bursts as the rank ignites. It draws with Michroma and M PLUS 1p, which hosts should load. +- Synesthesia is inspired by synaesthetic sound-and-light rhythm shooters: a black void lit by an ember horizon, a world built from fine ember / gold particles with electric blue and magenta accents, data dust and speed streaks pouring out of the vanishing point, 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 — tilted shockwave rings, 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 a 3D particle ring that bursts 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 `renderChrome` (as `runtime.audio`), `renderLanes` and `renderBombs` (as `audio`), and the select renderer's `tick` (fourth argument). Gameplay analyses the whole mix off the audio bus output; select analyses the BGM and chart preview. 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 the whole page (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. diff --git a/packages/player-web/src/scene/default/synesthesia/playfield.ts b/packages/player-web/src/scene/default/synesthesia/playfield.ts index da2aa657..ab1de5d6 100644 --- a/packages/player-web/src/scene/default/synesthesia/playfield.ts +++ b/packages/player-web/src/scene/default/synesthesia/playfield.ts @@ -138,10 +138,13 @@ function drawNote(graphics: Graphics, x: number, y: number, w: number, light: La } /** Sparks per hit at combo tier 0; each tier adds {@link BURST_PARTICLES_PER_TIER}. */ -const BURST_PARTICLES = 110; -const BURST_PARTICLES_PER_TIER = 16; +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[] { @@ -276,15 +279,16 @@ function renderBomb(pool: ChildPool, bomb: BeMusicBomb, tier: number, crowd: num 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; + // 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))); 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), + width: Math.max(0.5, 1 * head.scale * particle.size), alpha: trailAlpha, }); } @@ -295,8 +299,9 @@ function renderBomb(pool: ChildPool, bomb: BeMusicBomb, tier: number, crowd: num for (const spark of sparks) { const tint = hsvToHex(light.hue + spark.particle.hueShift, Math.min(0.9, 0.45 + spark.life * 1.8), 1); const sparkAlpha = (1 - spark.life) ** 0.8 * (0.65 + 0.35 * crowd); - if (spark.particle.size < 0.55) { - const side = Math.max(1, (1 + 2.2 * spark.particle.size) * spark.scale * (1 - spark.life * 0.6)); + // Powder: most grains are tiny squares; only the coarsest few carry a soft glint. + if (spark.particle.size < POWDER_GLINT_SIZE) { + const side = Math.max(0.7, (0.6 + 1 * spark.particle.size) * spark.scale * (1 - spark.life * 0.5)); effects.rect(spark.x - side / 2, spark.y - side / 2, side, side).fill({ color: tint, alpha: sparkAlpha }); continue; } @@ -304,7 +309,7 @@ function renderBomb(pool: ChildPool, bomb: BeMusicBomb, tier: number, crowd: num sprite.texture = glow; sprite.anchor.set(0.5); sprite.blendMode = 'add'; - const size = (3 + 6 * spark.particle.size) * spark.scale * Math.sqrt(1 - spark.life); + const size = (1.6 + 2.4 * spark.particle.size) * spark.scale * Math.sqrt(1 - spark.life); sprite.width = size; sprite.height = size; sprite.position.set(spark.x, spark.y); From c21dd958bec88d7fc951d53b116b4b87a991b13e Mon Sep 17 00:00:00 2001 From: Seiya KONNO Date: Thu, 8 Oct 2026 00:44:19 +0900 Subject: [PATCH 26/65] feat(player-web): set the built-in skin screens on a layout grid Select, gameplay, and result screens of every built-in skin now follow a fixed page margin and gutter: blocks share their top, bottom, and side lines across columns, rows inside each panel run on one pitch, score panels pair their columns on shared rows, and the result judgement rows end on the graphs' baseline. --- .changeset/be-music-skins.md | 1 + .../src/scene/default/gameplay-render.ts | 79 ++++----- .../scene/default/lattice/gameplay-chrome.ts | 25 +-- .../src/scene/default/lattice/result.ts | 39 ++--- .../src/scene/default/phantom/result.ts | 152 +++++++++++------- .../src/scene/default/phantom/select.ts | 50 +++--- .../default/synesthesia/gameplay-chrome.ts | 53 +++--- .../src/scene/default/synesthesia/result.ts | 104 +++++++----- .../src/scene/default/synesthesia/select.ts | 11 +- 9 files changed, 305 insertions(+), 209 deletions(-) diff --git a/.changeset/be-music-skins.md b/.changeset/be-music-skins.md index fe0d80df..79da9fbd 100644 --- a/.changeset/be-music-skins.md +++ b/.changeset/be-music-skins.md @@ -25,6 +25,7 @@ The default (skinless) family now renders through a `BeMusicSkin`: one object bu - 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 warp into a white-out) 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. +- 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, STANDBY gets a slip over the monitor's horizon, 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. - `SkinlessGameplayChromeRuntime` gains `totalNotes`, `chartMs`, `judgeAtMs`, `impulseAtMs`, `impulseKind`, and `effects`, and the be-music lane and bomb contexts gain `combo` and `effects`. diff --git a/packages/player-web/src/scene/default/gameplay-render.ts b/packages/player-web/src/scene/default/gameplay-render.ts index 844ea8d1..adb76b05 100644 --- a/packages/player-web/src/scene/default/gameplay-render.ts +++ b/packages/player-web/src/scene/default/gameplay-render.ts @@ -35,7 +35,10 @@ import { addHudNumber as addNumber, addHudText as addText, type HudTextOptions a 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; +/** 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: 376, y: 365, w: 232, h: 99 } as const; const SONG_PLATE = { x: 16, y: 420, w: 340, 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]; @@ -312,8 +315,8 @@ function drawPlayfield( /** 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; + const x = BGA.x + BGA.w + 18; + return playfield.right + 14 <= x && x + 56 <= DESIGN_WIDTH - HUD_MARGIN ? x : undefined; } /** @@ -422,12 +425,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, @@ -441,20 +444,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); - - // The skin's mark, between the tempo readouts and the ruleset tag. - drawNoteEmblem(status, 350, 25, 22, PHANTOM_WHITE, PHANTOM_RED); - 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); + 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); + + // 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, @@ -482,7 +486,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, @@ -490,13 +494,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, @@ -572,8 +574,9 @@ 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 }); + // 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, 116, 1).fill({ color: PHANTOM_SLATE }); + frame.rect(x + 14, y + 64, 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); addText(layer, 'SCORE', x + 14, y + 12, tagLabelStyle(PHANTOM_RED), pool); @@ -585,21 +588,21 @@ function drawScorePlate( { ...displayStyle(26, PHANTOM_WHITE), anchorX: 1, maxWidth: 80 }, 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, + y + 39, { ...displayStyle(14, PHANTOM_PAPER), anchorX: 1, maxWidth: 66 }, 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, + y + 67, { ...displayStyle(14, PHANTOM_PAPER), anchorX: 1, maxWidth: 74 }, pool, ); @@ -614,30 +617,30 @@ function drawScorePlate( 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. + addText(layer, 'COMBO', x + 160, y + 12, tagLabelStyle(PHANTOM_RED), pool); addNumber( layer, formatCount(runtime.combo), x + w - 12, - y + 20, + y + 5, { ...displayStyle(22, PHANTOM_WHITE), anchorX: 1, maxWidth: 60 }, pool, ); - addText(layer, 'MAX', x + 160, y + 55, tagLabelStyle(PHANTOM_ASH), pool); + addText(layer, 'MAX', x + 160, 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, ); @@ -655,7 +658,7 @@ function drawScorePlate( .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 + 160, y + 72, tagLabelStyle(PHANTOM_ASH), pool); addText( layer, rank, @@ -692,7 +695,7 @@ function drawJudgeTally( ): void { // Top-aligned with the BGA so the header chip clears the status bar's beat-driven teeth. const y = BGA.y; - const w = DESIGN_WIDTH - x - 6; + 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 }); diff --git a/packages/player-web/src/scene/default/lattice/gameplay-chrome.ts b/packages/player-web/src/scene/default/lattice/gameplay-chrome.ts index 4b665c94..204ad784 100644 --- a/packages/player-web/src/scene/default/lattice/gameplay-chrome.ts +++ b/packages/player-web/src/scene/default/lattice/gameplay-chrome.ts @@ -52,8 +52,15 @@ function hudScramble(runtime: Runtime): (value: string, delayMs: number, seed: n scrambleText(value, Math.max(0, Math.min(1, (chartMs + 2700 - delayMs) / 520)), tick, seed); } -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; +/** + * 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; +const SCORE_PANEL = { x: 384, y: GROOVE.y - 24, w: HUD_RIGHT - 384, h: 466 - (GROOVE.y - 24) } as const; +const SONG_PLATE = { x: HUD_LEFT, y: 420, w: 360 - HUD_LEFT, h: 46 } as const; const HEADER_H = 30; /** @@ -496,8 +503,8 @@ function drawHeader(graphics: Graphics, layer: Container, runtime: Runtime, beat 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. - const cx = 20; + // 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) { @@ -518,7 +525,7 @@ function drawHeader(graphics: Graphics, layer: Container, runtime: Runtime, beat addHudText( layer, sc(autoplay ? 'AUTO PLAY' : 'PLAY', 0, 1), - 36, + 40, 10, { ...monoStyle(LAT_INK), letterSpacing: 1.5 }, pool, @@ -549,7 +556,7 @@ function drawHeader(graphics: Graphics, layer: Container, runtime: Runtime, beat addHudText( layer, sc(formatRuleset(runtime.rulesetLabel), 180, 4), - DESIGN_WIDTH - 12, + HUD_RIGHT, 11, { ...monoStyle(LAT_INK), letterSpacing: 1.5, anchorX: 1 }, pool, @@ -569,11 +576,11 @@ function drawGauge( const survival = runtime.gaugeSurvival === true || clear <= 0; const nowMs = runtime.nowMs ?? 0; const sc = hudScramble(runtime); - graphics.rect(GROOVE.x - 12, GROOVE.y - 24, GROOVE.w + 24, 1).fill(LAT_INK); + graphics.rect(HUD_LEFT, GROOVE.y - 24, GROOVE.w + 24, 1).fill(LAT_INK); addHudText( layer, sc(`${(runtime.gaugeLabel ?? 'GROOVE').toUpperCase()} GAUGE`, 240, 5), - GROOVE.x - 12, + HUD_LEFT, GROOVE.y - 18, { ...monoStyle(), maxWidth: 130 }, pool, @@ -731,7 +738,7 @@ function drawJudgeTally( pool: ChildPool, ): void { const y = BGA.y - 5; - const w = DESIGN_WIDTH - x - 8; + 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); diff --git a/packages/player-web/src/scene/default/lattice/result.ts b/packages/player-web/src/scene/default/lattice/result.ts index eaf4e4b5..e929b0a3 100644 --- a/packages/player-web/src/scene/default/lattice/result.ts +++ b/packages/player-web/src/scene/default/lattice/result.ts @@ -140,18 +140,18 @@ export function renderLatticeResult(frame: BeMusicResultFrame): void { // Figures. const metrics: ReadonlyArray = [ - [224, 66, '02 SCORE', String(rollUpValue(result.score.score, roll)), LAT_INK], - [224, 122, '03 EX SCORE', `${rollUpValue(result.score.exScore, roll)} / ${exMax}`, LAT_INK], - [224, 178, '04 MAX COMBO', String(rollUpValue(result.maxCombo, roll)), LAT_ACCENT], - [430, 66, '05 GAUGE', `${rollUpValue(Math.round(result.gauge), roll)}%`, cleared ? LAT_ACCENT : LAT_SIGNAL], - [430, 122, '06 PLAY TIME', `${(result.playSeconds * easeOutCubic(roll)).toFixed(1)}s`, LAT_INK], - [430, 178, '07 NOTES', String(rollUpValue(result.score.total, roll)), LAT_INK], + [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, 190, delay); + rule(x, y, 192, delay); text(decode(name, delay, 10 + index), x, y + 8, { ...mono(), alpha: fadeIn(delay) }); - text(value, x + 190, y + 20, { ...display(22, fill, '200'), anchorX: 1, maxWidth: 150, alpha: fadeIn(delay + 60) }); + text(value, x + 192, y + 20, { ...display(22, fill, '200'), anchorX: 1, maxWidth: 150, alpha: fadeIn(delay + 60) }); }); // Judgement bars. @@ -166,7 +166,8 @@ export function renderLatticeResult(frame: BeMusicResultFrame): void { ]; const judgeTotal = Math.max(1, result.score.total); rows.forEach(([name, count, color], index) => { - const y = 276 + index * 28; + // 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 }); @@ -176,16 +177,16 @@ export function renderLatticeResult(frame: BeMusicResultFrame): void { }); // Graphs as ruled lines. - rule(330, 244, 290, 600); - text(decode('09 GROOVE GAUGE', 600, 5), 330, 252, { ...mono(), alpha: fadeIn(600) }); - text(decode('10 EX SCORE', 640, 6), 330, 340, { ...mono(), alpha: fadeIn(640) }); + 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, - 330, + 326, 266, - 290, - 62, + 294, + 58, result.gaugeHistory.map((s) => ({ x: s.progress, y: s.value / 100 })), cleared ? LAT_ACCENT : LAT_SIGNAL, draw, @@ -193,10 +194,10 @@ export function renderLatticeResult(frame: BeMusicResultFrame): void { ); plot( g, - 330, - 354, - 290, - 62, + 326, + 352, + 294, + 58, result.scoreHistory.map((s) => ({ x: s.progress, y: exMax > 0 ? s.exScore / exMax : 0 })), LAT_INK, draw, diff --git a/packages/player-web/src/scene/default/phantom/result.ts b/packages/player-web/src/scene/default/phantom/result.ts index 3e007d47..c1935684 100644 --- a/packages/player-web/src/scene/default/phantom/result.ts +++ b/packages/player-web/src/scene/default/phantom/result.ts @@ -33,6 +33,25 @@ const RESULT_ROLL_DELAY_MS = 760; const RESULT_ROLL_MS = 1000; const RESULT_RANK_DELAY_MS = 1500; +/** + * 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; + export const phantomResultSkin: BeMusicResultSkin = { render: (frame) => renderPhantomResult(frame) }; /** @@ -185,7 +204,7 @@ export function renderPhantomResult(frame: BeMusicResultFrame): void { addText( title.root, `${result.song.title}${result.song.artist ? ` / ${result.song.artist}` : ''}`, - designWidth - 18, + designWidth - RESULT_MARGIN, 16, { size: 13, @@ -197,9 +216,9 @@ export function renderPhantomResult(frame: BeMusicResultFrame): void { ); 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, @@ -209,50 +228,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 }); } - drawNoteEmblem(verdict.g, 190, 29, 24, PHANTOM_WHITE, PHANTOM_RED); - 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, @@ -261,12 +282,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}`); @@ -277,11 +310,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], @@ -292,65 +329,72 @@ 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, @@ -361,14 +405,14 @@ export function renderPhantomResult(frame: BeMusicResultFrame): void { 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, }); @@ -412,9 +456,9 @@ 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({ @@ -428,7 +472,7 @@ function renderMetric( }); labelText.skew.set(-0.18, 0); alignCapCenter(labelText, DEFAULT_DISPLAY_FONT, '400', 10); - labelText.position.set(x + 12, y + 5); + labelText.position.set(x + 14, y + 5); target.addChild(labelText); const valueText = new Text({ @@ -442,7 +486,7 @@ function renderMetric( valueText.skew.set(-0.18, 0); valueText.anchor.set(1, 0.5); alignCapCenter(valueText, DEFAULT_DISPLAY_FONT, '400', 22); - valueText.position.set(x + 166, y + 19); + 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/scene/default/phantom/select.ts index 5e9e986e..3bea1c78 100644 --- a/packages/player-web/src/scene/default/phantom/select.ts +++ b/packages/player-web/src/scene/default/phantom/select.ts @@ -33,7 +33,7 @@ import type { BeMusicAudioFrame } from '../../../skin/be-music/types.ts'; import { audioDrive, bandLevel } from '../audio-drive.ts'; import { addRansomText, type GlyphFactory } from './tear.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; @@ -402,9 +402,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); @@ -461,13 +463,13 @@ class PhantomSelectRenderer implements BeMusicSelectRenderer { } // 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, @@ -476,7 +478,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, @@ -486,7 +488,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, @@ -495,34 +497,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(layer, 12, 368, 292, 28, 'text', frame.actions.activateSearch); + addHitArea(layer, 24, 298, 176, 36, 'pointer', frame.actions.play); + addHitArea(layer, 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, diff --git a/packages/player-web/src/scene/default/synesthesia/gameplay-chrome.ts b/packages/player-web/src/scene/default/synesthesia/gameplay-chrome.ts index 3f51e5da..ab617a48 100644 --- a/packages/player-web/src/scene/default/synesthesia/gameplay-chrome.ts +++ b/packages/player-web/src/scene/default/synesthesia/gameplay-chrome.ts @@ -47,8 +47,16 @@ import { 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; +/** + * 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: 256, h: 46 } as const; +const SCORE_PANEL = { x: 284, y: GAUGE_FRAME.y, w: DESIGN_WIDTH - HUD_MARGIN - 284, 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; @@ -181,7 +189,7 @@ export function renderSynesthesiaChrome({ drawSongPlate(panels, layer, runtime, accent, layerPool); drawScorePanel(panels, layer, runtime, accent, layerPool); // Tucked close to the monitor frame so the counts get room for four digits. - const tallyX = BGA.x + BGA.w + 14; + const tallyX = BGA.x + BGA.w + 12; if (playfieldRight + 14 <= tallyX) { drawJudgeTally(panels, layer, runtime, tallyX, layerPool); } @@ -614,24 +622,25 @@ function drawHeader( // 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(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 }); + 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', - 34, + HUD_MARGIN + 22, 12, { ...displayStyle(10, SYN_WHITE), letterSpacing: 3 }, pool, ); - addHudText(layer, 'BPM', 150, 14, labelStyle(), pool); - addHudNumber(layer, formatBpm(runtime.bpm), 190, 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); + // 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), - 506, + DESIGN_WIDTH - HUD_MARGIN, 12, { ...displayStyle(9, accent), letterSpacing: 3, anchorX: 1, maxWidth: 110 }, pool, @@ -643,7 +652,7 @@ function drawGauge(graphics: Graphics, layer: Container, runtime: Runtime, accen const clear = clampPercent(runtime.clearThreshold ?? 80); const survival = runtime.gaugeSurvival === true || clear <= 0; const cleared = survival ? gauge > 0 : gauge >= clear; - drawFrame(graphics, GROOVE.x - 14, GROOVE.y - 26, GROOVE.w + 28, 48, accent); + drawFrame(graphics, GAUGE_FRAME.x, GAUGE_FRAME.y, GAUGE_FRAME.w, GAUGE_FRAME.h, accent); addHudText( layer, `${(runtime.gaugeLabel ?? 'GROOVE').toUpperCase()} GAUGE`, @@ -655,7 +664,7 @@ function drawGauge(graphics: Graphics, layer: Container, runtime: Runtime, accen addHudNumber( layer, `${Math.round(gauge)}%`, - GROOVE.x + GROOVE.w + 4, + GAUGE_FRAME.x + GAUGE_FRAME.w - 14, GROOVE.y - 22, { ...displayStyle(12, cleared ? SYN_AMBER : SYN_WHITE), anchorX: 1 }, pool, @@ -727,12 +736,12 @@ function drawSongPlate(graphics: Graphics, layer: Container, runtime: Runtime, a 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 + 146, y + 14, 1, h - 28).fill({ color: accent, alpha: 0.3 }); + 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 + 136, + x + SCORE_DIVIDER - 14, y + 22, { ...displayStyle(17, SYN_WHITE), @@ -746,7 +755,7 @@ function drawScorePanel(graphics: Graphics, layer: Container, runtime: Runtime, addHudNumber( layer, `${formatCount(runtime.exScore)}/${formatCount(runtime.exScoreMax)}`, - x + 136, + x + SCORE_DIVIDER - 14, y + 52, { ...displayStyle(8, SYN_MIST), anchorX: 1, maxWidth: 46 }, pool, @@ -755,7 +764,7 @@ function drawScorePanel(graphics: Graphics, layer: Container, runtime: Runtime, addHudNumber( layer, formatExRate(runtime.exScore, runtime.exScoreMax), - x + 136, + x + SCORE_DIVIDER - 14, y + 74, { ...displayStyle(8, SYN_MIST), anchorX: 1, maxWidth: 46 }, pool, @@ -766,7 +775,7 @@ function drawScorePanel(graphics: Graphics, layer: Container, runtime: Runtime, ? Math.max(0, Math.min(1, runtime.exScore / runtime.exScoreMax)) : 0; const meterX = x + 14; - const meterW = 122; + 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 }); @@ -777,7 +786,7 @@ function drawScorePanel(graphics: Graphics, layer: Container, runtime: Runtime, .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); + addHudText(layer, 'COMBO', x + SCORE_DIVIDER + 14, y + 12, labelStyle(), pool); addHudNumber( layer, formatCount(runtime.combo), @@ -786,7 +795,7 @@ function drawScorePanel(graphics: Graphics, layer: Container, runtime: Runtime, { ...displayStyle(14, SYN_WHITE), anchorX: 1, maxWidth: 62 }, pool, ); - addHudText(layer, 'MAX', x + 158, y + 54, labelStyle(), pool); + addHudText(layer, 'MAX', x + SCORE_DIVIDER + 14, y + 54, labelStyle(), pool); addHudNumber( layer, formatCount(runtime.maxCombo), @@ -796,7 +805,7 @@ function drawScorePanel(graphics: Graphics, layer: Container, runtime: Runtime, pool, ); const rank = runtime.rank && runtime.rank !== '-' ? runtime.rank : 'F'; - addHudText(layer, 'RANK', x + 158, y + 76, labelStyle(), pool); + addHudText(layer, 'RANK', x + SCORE_DIVIDER + 14, y + 76, labelStyle(), pool); addHudText( layer, rank, @@ -822,7 +831,7 @@ const TALLY: ReadonlyArray renderSynesthesiaResult(frame) }; @@ -152,7 +170,7 @@ export function renderSynesthesiaResult(frame: BeMusicResultFrame): void { 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, { + text(verdict.root, cleared ? 'STAGE CLEAR' : 'FAILED', MARGIN, 14, { ...display(15, SYN_WHITE), letterSpacing: 6 + 22 * (1 - verdictT), alpha: verdictT, @@ -161,7 +179,7 @@ export function renderSynesthesiaResult(frame: BeMusicResultFrame): void { text( verdict.root, `${result.song.title}${result.song.artist ? ` / ${result.song.artist}` : ''}`, - designWidth - 20, + designWidth - MARGIN, 17, { size: 12, @@ -176,12 +194,12 @@ export function renderSynesthesiaResult(frame: BeMusicResultFrame): void { // Rank: a spinning 3D particle ring that bursts outward as the letter ignites. const rank = group('rank'); - const rankCx = 110; - const rankCy = 150; + const rankCx = MARGIN + 88; + const rankCy = TOP + 68; 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); + glass(rank.g, MARGIN, TOP, 176, TOP_ROW_H, rankColor); + label('DJ LEVEL', MARGIN + 14, TOP + 12, 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; @@ -242,7 +260,7 @@ export function renderSynesthesiaResult(frame: BeMusicResultFrame): void { 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, { + text(rank.root, `${rate.toFixed(2)}%`, rankCx, TOP + TOP_ROW_H - 14, { ...display(10, SYN_MIST), anchorX: 0.5, anchorY: 0.5, @@ -252,18 +270,24 @@ export function renderSynesthesiaResult(frame: BeMusicResultFrame): void { // 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_AMBER : 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], + [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, 196, 46, accent); + glass(panel.g, x, y, METRIC_W, METRIC_H, accent); label(name, x + 14, y + 10, panel.root); - text(panel.root, value, x + 182, y + 30, { + text(panel.root, value, x + METRIC_W - 14, y + 29, { ...display(14, fill), anchorX: 1, anchorY: 0.5, @@ -275,8 +299,8 @@ export function renderSynesthesiaResult(frame: BeMusicResultFrame): void { // Judgement rows. const judges = group('judgement'); - glass(judges.g, 20, 244, 290, 176, SYN_EMBER); - label('JUDGEMENT', 34, 256, judges.root, SYN_EMBER); + 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], @@ -286,18 +310,18 @@ export function renderSynesthesiaResult(frame: BeMusicResultFrame): void { ]; const judgeTotal = Math.max(1, result.score.total); rows.forEach(([name, count, color], index) => { - const y = 278 + index * 27; + const y = ROWS_TOP + index * 32; 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; + 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(118, y + 4, barW, 5).fill({ color, alpha: 0.18 }); - judges.g.rect(118, y + 6, barW, 1.5).fill({ color, alpha: 0.95 }); + 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)), 294, y + 6, { + text(judges.root, String(rollUpValue(count, roll)), MARGIN + 292 - 14, y + 6, { ...display(11, SYN_WHITE), anchorX: 1, anchorY: 0.5, @@ -307,26 +331,30 @@ export function renderSynesthesiaResult(frame: BeMusicResultFrame): void { // 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); + // The two filaments share the judgement rows' top and bottom lines. + const graphX = GRAPHS_X + 14; + const graphW = designWidth - MARGIN - 14 - graphX; + const graphH = (ROWS_BOTTOM - ROWS_TOP - 24) / 2; + glass(graphs.g, GRAPHS_X, BOTTOM_TOP, designWidth - MARGIN - 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, - 342, - 270, - 262, - 56, + 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, - 342, - 352, - 262, - 56, + graphX, + ROWS_BOTTOM - graphH, + graphW, + graphH, result.scoreHistory.map((s) => ({ x: s.progress, y: exMax > 0 ? s.exScore / exMax : 0 })), SYN_CYAN, draw, @@ -336,7 +364,7 @@ export function renderSynesthesiaResult(frame: BeMusicResultFrame): void { // 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); + 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, diff --git a/packages/player-web/src/scene/default/synesthesia/select.ts b/packages/player-web/src/scene/default/synesthesia/select.ts index b80b92b4..fd6959a1 100644 --- a/packages/player-web/src/scene/default/synesthesia/select.ts +++ b/packages/player-web/src/scene/default/synesthesia/select.ts @@ -576,9 +576,10 @@ class SynesthesiaSelectRenderer implements BeMusicSelectRenderer { anchorX: 0.5, anchorY: 0.5, }); - chrome.rect(204, 303, 86, 26).stroke({ color: SYN_MIST, width: 1, alpha: 0.45 }); - drawReticle(chrome, 204, 303, 86, 26, SYN_MIST, 0.8, { arm: 5, width: 1 }); - addText('AUTO', 247, 316, { + // 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, @@ -587,7 +588,7 @@ class SynesthesiaSelectRenderer implements BeMusicSelectRenderer { anchorY: 0.5, }); addHitArea(layer, 26, 296, 172, 40, 'pointer', frame.actions.play); - addHitArea(layer, 202, 300, 92, 32, 'pointer', frame.actions.autoPlay); + addHitArea(layer, 200, 300, 90, 32, 'pointer', frame.actions.autoPlay); // Search and library. framePanel(chrome, 14, 372, 290, 30, accent); @@ -610,7 +611,7 @@ class SynesthesiaSelectRenderer implements BeMusicSelectRenderer { }); addText( frame.searchQuery ? 'SEARCH RESULTS' : frame.folderLabel ? 'CHARTS' : 'FOLDERS', - designWidth - 18, + designWidth - 14, designHeight - 20, { ...labelStyle(), anchorX: 1 }, ); From bc98bbb94903314a2fba063f0cb03cfdb3e0dd87 Mon Sep 17 00:00:00 2001 From: Seiya KONNO Date: Thu, 8 Oct 2026 18:07:38 +0900 Subject: [PATCH 27/65] perf(player-web): draw Synesthesia light points as GPU particles Square light points (floor lattice, dust, powder, point clouds, orb shells) now render as particles on a layer that rides along with their pooled graphics instead of Graphics rects rebuilt every frame. The remaining shapes are merged into one fill per colour bucket through a shared, allocation-free ShapeBatch, colour maths no longer allocates, overlapping bombs share a per-frame spark budget, and the idle monitor's lattice and orb are a little sparser. --- .../src/scene/default/particle-layer.ts | 110 ++++++++++++++++++ .../default/synesthesia/burst-budget.test.ts | 20 ++++ .../scene/default/synesthesia/burst-budget.ts | 24 ++++ .../scene/default/synesthesia/draw.test.ts | 77 ++++++++++++ .../src/scene/default/synesthesia/draw.ts | 95 +++++++++++---- .../default/synesthesia/gameplay-chrome.ts | 58 +++++---- .../scene/default/synesthesia/playfield.ts | 72 +++++++----- .../src/scene/default/synesthesia/select.ts | 4 +- .../src/scene/default/synesthesia/space.ts | 75 ++++++++---- 9 files changed, 432 insertions(+), 103 deletions(-) create mode 100644 packages/player-web/src/scene/default/particle-layer.ts create mode 100644 packages/player-web/src/scene/default/synesthesia/burst-budget.test.ts create mode 100644 packages/player-web/src/scene/default/synesthesia/burst-budget.ts create mode 100644 packages/player-web/src/scene/default/synesthesia/draw.test.ts diff --git a/packages/player-web/src/scene/default/particle-layer.ts b/packages/player-web/src/scene/default/particle-layer.ts new file mode 100644 index 00000000..fa5892e4 --- /dev/null +++ b/packages/player-web/src/scene/default/particle-layer.ts @@ -0,0 +1,110 @@ +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; + + public constructor(private readonly anchor: Graphics) { + 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/scene/default/synesthesia/burst-budget.test.ts b/packages/player-web/src/scene/default/synesthesia/burst-budget.test.ts new file mode 100644 index 00000000..040cac00 --- /dev/null +++ b/packages/player-web/src/scene/default/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/scene/default/synesthesia/burst-budget.ts b/packages/player-web/src/scene/default/synesthesia/burst-budget.ts new file mode 100644 index 00000000..c4c19915 --- /dev/null +++ b/packages/player-web/src/scene/default/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/scene/default/synesthesia/draw.test.ts b/packages/player-web/src/scene/default/synesthesia/draw.test.ts new file mode 100644 index 00000000..a4cac6b9 --- /dev/null +++ b/packages/player-web/src/scene/default/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/scene/default/synesthesia/draw.ts b/packages/player-web/src/scene/default/synesthesia/draw.ts index 4a809081..d15e3597 100644 --- a/packages/player-web/src/scene/default/synesthesia/draw.ts +++ b/packages/player-web/src/scene/default/synesthesia/draw.ts @@ -1,4 +1,5 @@ import type { Graphics, Sprite } from 'pixi.js'; +import { pointLayerFor } from '../particle-layer.ts'; import { hash01 } from '../phantom-style.ts'; import type { AudioDrive } from '../audio-drive.ts'; import { bandLevel } from '../audio-drive.ts'; @@ -32,30 +33,41 @@ import { SYN_CYAN, SYN_GLASS, SYN_MAGENTA, SYN_WHITE, synGlowTexture } from './s * (additive light). */ export class ShapeBatch { - private readonly rects = new Map(); - private readonly lines = new Map< - string, - { graphics: Graphics; color: number; alpha: number; width: number; data: number[] } - >(); + /** + * @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. + */ + public constructor(private readonly points = false) {} + + // 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.ids.size; + 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 a = Math.round(Math.min(1, alpha) * 8) / 8; - if (a <= 0) return; - const color = quantizeColor(rawColor); - const key = `${this.id(graphics)}|${color}|${a}`; + 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 = { graphics, color, alpha: a, data: [] }; + 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); @@ -71,14 +83,19 @@ export class ShapeBatch { x1: number, y1: number, ): void { - const a = Math.round(Math.min(1, alpha) * 8) / 8; - if (a <= 0) return; - const w = Math.max(0.5, Math.round(width * 2) / 2); - const color = quantizeColor(rawColor); - const key = `${this.id(graphics)}|${color}|${a}|${w}`; + 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 = { graphics, color, alpha: a, width: w, data: [] }; + 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); @@ -86,24 +103,54 @@ export class ShapeBatch { public flush(): void { for (const bucket of this.rects.values()) { - const { data, graphics } = bucket; + 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, graphics } = bucket; + 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(); } - 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; @@ -212,7 +259,7 @@ export function drawPointCloud( const reference = style.referenceScale ?? 1; const seconds = style.seconds ?? 0; const shimmer = style.shimmer ?? 0; - const batch = new ShapeBatch(); + 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 }; @@ -263,7 +310,7 @@ export function drawSchool( ): void { const palette = style.palette ?? 'ember'; const { position: p, velocity: v } = flock; - const batch = new ShapeBatch(); + const batch = sharedShapeBatch; for (let index = 0; index < flock.count; index += 1) { const x = p[index * 3]!; const y = p[index * 3 + 1]!; @@ -395,7 +442,7 @@ export function drawMagnetoOrb( const tilt = options.tilt ?? 0.35; // Thousands of dots, fibres, and tail segments go out as a handful of batched instructions. - const batch = new ShapeBatch(); + 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 = ( diff --git a/packages/player-web/src/scene/default/synesthesia/gameplay-chrome.ts b/packages/player-web/src/scene/default/synesthesia/gameplay-chrome.ts index ab617a48..57e81251 100644 --- a/packages/player-web/src/scene/default/synesthesia/gameplay-chrome.ts +++ b/packages/player-web/src/scene/default/synesthesia/gameplay-chrome.ts @@ -10,7 +10,7 @@ import { flashingGreatColor, isFlashingGreat, judgeDisplayWord } from '../judge- import { hash01 } from '../phantom-style.ts'; import { audioDrive, type AudioDrive } from '../audio-drive.ts'; import { createFlock, stepFlock, type Flock } from './boids.ts'; -import { drawFrame, drawMagnetoOrb, drawPointCloud, drawReticle, drawSchool } from './draw.ts'; +import { drawFrame, drawMagnetoOrb, drawPointCloud, drawReticle, drawSchool, sharedShapeBatch } from './draw.ts'; import { emberColor, hsvToHex, @@ -95,7 +95,8 @@ function advanceSchools( state.lastMs = nowMs; return state.flocks; } -const ORB_SHELL = fibonacciSphere(800, 11); +/** 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)]; @@ -274,6 +275,7 @@ function drawDust( 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); for (let index = 0; index < count; index += 1) { @@ -288,13 +290,9 @@ function drawDust( if (nearness > 0.45) { // Near motes get a soft bokeh square around them. const bokeh = size * 3.2; - graphics - .rect(projected.x - bokeh / 2, projected.y - bokeh / 2, bokeh, bokeh) - .fill({ color, alpha: 0.05 * nearness }); + batch.rect(graphics, color, 0.05 * nearness, projected.x - bokeh / 2, projected.y - bokeh / 2, bokeh, bokeh); } - graphics - .rect(projected.x - size / 2, projected.y - size / 2, size, size) - .fill({ color, alpha: 0.3 + 0.6 * nearness }); + batch.rect(graphics, color, 0.3 + 0.6 * nearness, projected.x - size / 2, projected.y - size / 2, size, size); } const vanish = vanishingPoint(camera, VANISH.x, VANISH.y, 180); const streaks = Math.round(8 + 12 * tier + 14 * hit + 18 * drive.onset); @@ -313,11 +311,9 @@ function drawDust( if (hasBga && (insideBga(x0, y0, 6) || insideBga(x1, y1, 6))) continue; const alpha = Math.sin(progress * Math.PI) * (0.35 + 0.1 * tier); const color = index % 6 === 0 ? SYN_CYAN : emberColor(0.55 + 0.4 * hash01(index * 5 + 4)); - graphics - .moveTo(x0, y0) - .lineTo(x1, y1) - .stroke({ color, width: 0.8 + progress * 1.4, alpha }); + batch.line(graphics, color, alpha, 0.8 + progress * 1.4, x0, y0, x1, y1); } + batch.flush(); } /** @@ -355,9 +351,10 @@ function drawFloor( for (let x = -1500; x <= 1500; x += 50) { const point = project(x, z); if (!point.visible || point.x < -4 || point.x > DESIGN_WIDTH + 4 || point.y > DESIGN_HEIGHT + 4) continue; - graphics.rect(point.x - size / 2, point.y - size / 2, size, size).fill({ color, alpha }); + sharedShapeBatch.rect(graphics, color, alpha, point.x - size / 2, point.y - size / 2, size, size); } } + sharedShapeBatch.flush(); graphics.rect(0, vanish.y - 1, DESIGN_WIDTH, 2).fill({ color: SYN_AMBER, alpha: (0.22 + 0.2 * pulse) * glow }); if (tier >= 3) { for (const phase of [beatPhase, (beatPhase + 0.5) % 1]) { @@ -406,11 +403,18 @@ function drawRiver( 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); - graphics - .moveTo(tail.x, tail.y) - .lineTo(head.x, head.y) - .stroke({ color, width: 0.4 + 0.9 * nearness * (0.5 + heat), alpha: 0.25 + 0.55 * heat }); + 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( @@ -537,18 +541,26 @@ function drawBgaFrame( }); } // Floor points scrolling in. - const offset = (seconds * 90) % 60; - for (let z = 60 - offset; z < 1400; z += 60) { + // 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 += 30) { + 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; - light - .rect(point.x - size / 2, point.y - size / 2, size, size) - .fill({ color: emberColor(0.3 + 0.6 * nearness), alpha: 0.2 + 0.6 * 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(); // Streaks pouring past from the vanishing point. for (let index = 0; index < 18; index += 1) { const angle = hash01(index * 7 + 1) * Math.PI * 2; diff --git a/packages/player-web/src/scene/default/synesthesia/playfield.ts b/packages/player-web/src/scene/default/synesthesia/playfield.ts index ab1de5d6..0f946579 100644 --- a/packages/player-web/src/scene/default/synesthesia/playfield.ts +++ b/packages/player-web/src/scene/default/synesthesia/playfield.ts @@ -19,7 +19,8 @@ import { type BurstParticle, type Vec3, } from './space.ts'; -import { drawReticle } from './draw.ts'; +import { burstGrainCount } from './burst-budget.ts'; +import { drawReticle, sharedShapeBatch } from './draw.ts'; import { SYN_GLOW_SIZE, synGlowTexture } from './style.ts'; import { audioDrive } from '../audio-drive.ts'; import { comboTier, effectProfile } from '../moments.ts'; @@ -186,7 +187,8 @@ export function renderSynesthesiaBombs({ pool, bombs, combo, effects }: BeMusicB // 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) { - renderBomb(pool, bomb, tier, crowd, profile.amount); + const grains = burstGrainCount(BURST_PARTICLES, BURST_PARTICLES_PER_TIER, tier, profile.amount, bombs.length); + renderBomb(pool, bomb, tier, crowd, profile.amount, grains); } } @@ -208,7 +210,14 @@ function renderPlainFlashes(pool: ChildPool, bombs: readonly BeMusicBomb[]): voi } } -function renderBomb(pool: ChildPool, bomb: BeMusicBomb, tier: number, crowd: number, amount: number): void { +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]; @@ -255,14 +264,13 @@ function renderBomb(pool: ChildPool, bomb: BeMusicBomb, tier: number, crowd: num }); } - // 3 + 4. Sparks: compute every projected position once, draw trails, then depth-sorted glow sprites. - const particles = cachedBurst(bomb.seed).slice( - 0, - Math.round((BURST_PARTICLES + BURST_PARTICLES_PER_TIER * tier) * amount), - ); + // 3 + 4. 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; - 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 => { @@ -273,50 +281,50 @@ function renderBomb(pool: ChildPool, bomb: BeMusicBomb, tier: number, crowd: num z: position.z * 0.8, }; }; - for (const particle of particles) { + 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 position = fountain(particle, life); - const head = project(position); + 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))); - 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.5, 1 * head.scale * particle.size), - alpha: trailAlpha, - }); + 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, + ); } - 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 tint = hsvToHex(light.hue + spark.particle.hueShift, Math.min(0.9, 0.45 + spark.life * 1.8), 1); - const sparkAlpha = (1 - spark.life) ** 0.8 * (0.65 + 0.35 * crowd); + 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 (spark.particle.size < POWDER_GLINT_SIZE) { - const side = Math.max(0.7, (0.6 + 1 * spark.particle.size) * spark.scale * (1 - spark.life * 0.5)); - effects.rect(spark.x - side / 2, spark.y - side / 2, side, side).fill({ color: tint, alpha: sparkAlpha }); + 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 * spark.particle.size) * spark.scale * Math.sqrt(1 - spark.life); + const size = (1.6 + 2.4 * particle.size) * head.scale * Math.sqrt(1 - life); sprite.width = size; sprite.height = size; - sprite.position.set(spark.x, spark.y); + 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(); // 5. Core flash + anamorphic streak. const flash = Math.max(0, 1 - t * 5); diff --git a/packages/player-web/src/scene/default/synesthesia/select.ts b/packages/player-web/src/scene/default/synesthesia/select.ts index fd6959a1..9bed314a 100644 --- a/packages/player-web/src/scene/default/synesthesia/select.ts +++ b/packages/player-web/src/scene/default/synesthesia/select.ts @@ -13,7 +13,7 @@ import type { BeMusicAudioFrame } from '../../../skin/be-music/types.ts'; import { audioDrive } from '../audio-drive.ts'; import { ChildPool } from '../../pixi-utils.ts'; import { createFlock, stepFlock } from './boids.ts'; -import { ShapeBatch, drawMagnetoOrb, drawPointCloud, drawReticle, drawSchool } from './draw.ts'; +import { drawMagnetoOrb, drawPointCloud, drawReticle, drawSchool, sharedShapeBatch } from './draw.ts'; import { emberColor, hsvToHex, @@ -271,7 +271,7 @@ class SynesthesiaSelectRenderer implements BeMusicSelectRenderer { const spacing = 100; const offset = this.travel % spacing; // Floor points, river, and streaks go out as a few batched instructions. - const batch = new ShapeBatch(); + 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; diff --git a/packages/player-web/src/scene/default/synesthesia/space.ts b/packages/player-web/src/scene/default/synesthesia/space.ts index ed790a5d..3f4058b8 100644 --- a/packages/player-web/src/scene/default/synesthesia/space.ts +++ b/packages/player-web/src/scene/default/synesthesia/space.ts @@ -130,15 +130,42 @@ export function hsvToHex(hue: number, saturation: number, value: number): number 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); + // 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]. */ @@ -146,26 +173,30 @@ 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 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], - ]; const clamped = Math.max(0, Math.min(1, Number.isFinite(t) ? t : 0)); let index = 1; - while (index < stops.length - 1 && clamped > stops[index]![0]) index += 1; - const [t0, r0, g0, b0] = stops[index - 1]!; - const [t1, r1, g1, b1] = stops[index]!; - const f = (clamped - t0) / (t1 - t0); - const mix = (a: number, b: number) => Math.round(a + (b - a) * f); - return (mix(r0, r1) << 16) | (mix(g0, g1) << 8) | mix(b0, b1); + 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; } /** From 3035099a85e90df345af733aca5d72c8abb6a5bb Mon Sep 17 00:00:00 2001 From: Seiya KONNO Date: Thu, 8 Oct 2026 18:07:38 +0900 Subject: [PATCH 28/65] perf(player-web): draw the Lattice needle field as particles The needle field renders as rotated particles on persistent and pooled graphics (the per-frame result screen keeps strokes), and the gameplay paper grid is drawn once instead of every frame. --- .../src/scene/default/lattice/draw.ts | 33 ++++++++++++++----- .../scene/default/lattice/gameplay-chrome.ts | 31 +++++++++++++---- .../src/scene/default/lattice/select.ts | 1 + 3 files changed, 51 insertions(+), 14 deletions(-) diff --git a/packages/player-web/src/scene/default/lattice/draw.ts b/packages/player-web/src/scene/default/lattice/draw.ts index e2b7c04d..c07444fb 100644 --- a/packages/player-web/src/scene/default/lattice/draw.ts +++ b/packages/player-web/src/scene/default/lattice/draw.ts @@ -1,6 +1,7 @@ import type { Container, Graphics } from 'pixi.js'; import type { ChildPool } from '../../pixi-utils.ts'; import { addHudText, type HudTextOptions } from '../hud-text.ts'; +import { pointLayerFor } from '../particle-layer.ts'; import { addSkinText, type SkinTextOptions } from '../skin-text.ts'; import { digitTransitions, needleAngle, 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'; @@ -47,21 +48,35 @@ export function drawPaperGrid(graphics: Graphics, area: Rect, alpha: number, hol } /** - * The needle field: one short stroke 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. Two strokes total. + * 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 } = {}, + options: { + pitch?: number; + length?: number; + alpha?: number; + skip?: (x: number, y: number) => boolean; + 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; - let inkCount = 0; for (let y = y0; y <= area.y + area.h; y += pitch) { for (let x = x0; x <= area.x + area.w; x += pitch) { if (options.skip?.(x, y)) continue; @@ -71,7 +86,10 @@ export function drawNeedleField( const half = (length * (1 + 0.7 * turn)) / 2; const dx = Math.cos(angle) * half; const dy = Math.sin(angle) * half; - if (turn > 0.45) { + 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); @@ -79,12 +97,11 @@ export function drawNeedleField( } } } - const alpha = options.alpha ?? 0.5; - if (inkCount > 0) graphics.stroke({ color: LAT_INK, width: 0.75, alpha: alpha * 0.55 }); + 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: Math.min(1, alpha * 1.6) }); + if (accentPath.length > 0) graphics.stroke({ color: LAT_ACCENT, width: 1, alpha: accentAlpha }); } /** Ruler ticks along a horizontal edge: a long tick every `major` px, short ones every `minor`. */ diff --git a/packages/player-web/src/scene/default/lattice/gameplay-chrome.ts b/packages/player-web/src/scene/default/lattice/gameplay-chrome.ts index 204ad784..d6e59972 100644 --- a/packages/player-web/src/scene/default/lattice/gameplay-chrome.ts +++ b/packages/player-web/src/scene/default/lattice/gameplay-chrome.ts @@ -1,4 +1,4 @@ -import type { Container, Graphics } from 'pixi.js'; +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 { resolveSkinlessLaneLayout } from '../../gameplay-lanes.ts'; @@ -103,11 +103,7 @@ export function renderLatticeChrome({ poor: runtime.poor ?? 0, }); - const paper = layerPool.acquireGraphics(); - paper.label = 'lattice-gameplay/paper'; - paper.blendMode = 'normal'; - fillAroundBga(paper, 0, 0, DESIGN_WIDTH, DESIGN_HEIGHT, LAT_PAPER, hasBga); - drawPaperGrid(paper, { x: 0, y: 0, w: DESIGN_WIDTH, h: DESIGN_HEIGHT }, 0.45, hasBga ? BGA : undefined); + ensureStaticPaper(layer, hasBga); const field = layerPool.acquireGraphics(); field.label = 'lattice-gameplay/field'; @@ -130,6 +126,7 @@ export function renderLatticeChrome({ { alpha: 0.55, skip: (x, y) => clean.some((rect) => inside(rect, x, y, 0)), + particles: true, }, ); @@ -346,6 +343,28 @@ function resolveFieldInput( }; } +/** + * 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 { + let entry = STATIC_PAPER.get(layer); + if (!entry) { + const graphics = new Graphics(); + graphics.label = 'lattice-gameplay/paper'; + layer.addChildAt(graphics, 0); + entry = { graphics, hasBga: !hasBga }; + STATIC_PAPER.set(layer, entry); + } + if (entry.hasBga === hasBga) return; + entry.hasBga = hasBga; + 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 inside(rect: Rect, x: number, y: number, margin: number): boolean { return x > rect.x - margin && x < rect.x + rect.w + margin && y > rect.y - margin && y < rect.y + rect.h + margin; } diff --git a/packages/player-web/src/scene/default/lattice/select.ts b/packages/player-web/src/scene/default/lattice/select.ts index da3e8c8b..61a3ae10 100644 --- a/packages/player-web/src/scene/default/lattice/select.ts +++ b/packages/player-web/src/scene/default/lattice/select.ts @@ -162,6 +162,7 @@ class LatticeSelectRenderer implements BeMusicSelectRenderer { alpha: 0.5, skip: (x, y) => this.occupied.some((rect) => x > rect.x && x < rect.x + rect.w && y > rect.y && y < rect.y + rect.h), + particles: true, }, ); } From 0ff9da33c6981468e7e4b3fcded0fafc68ea6237 Mon Sep 17 00:00:00 2001 From: Seiya KONNO Date: Thu, 8 Oct 2026 18:07:38 +0900 Subject: [PATCH 29/65] perf(player-web): stop re-rasterizing built-in skin text every frame HUD text takes a plain fill from its tint, so colour changes reuse the glyph texture; cap-height alignment caches provisional measurements while a face is loading instead of re-measuring per node per frame; skin text reuses one TextStyle per look; and the rebuilt-every-frame result layer retires its nodes one frame late so unchanged labels keep their textures. Phantom's halftone fields fill in one pass, and its combo value moves under its label so long combos no longer collide with it. --- .changeset/be-music-skins.md | 1 + packages/player-web/src/scene/core/result.ts | 8 ++- .../src/scene/default/gameplay-render.ts | 12 +++-- .../player-web/src/scene/default/hud-text.ts | 20 +++++-- .../src/scene/default/phantom/result.ts | 19 +++---- .../player-web/src/scene/default/skin-text.ts | 54 ++++++++++++++----- .../src/scene/default/text-metrics.ts | 17 ++++++ .../player-web/src/scene/pixi-utils.test.ts | 39 +++++++++++++- packages/player-web/src/scene/pixi-utils.ts | 29 ++++++++++ 9 files changed, 161 insertions(+), 38 deletions(-) diff --git a/.changeset/be-music-skins.md b/.changeset/be-music-skins.md index 79da9fbd..3973b7eb 100644 --- a/.changeset/be-music-skins.md +++ b/.changeset/be-music-skins.md @@ -25,6 +25,7 @@ The default (skinless) family now renders through a `BeMusicSkin`: one object bu - 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 warp into a white-out) 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, STANDBY gets a slip over the monitor's horizon, 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. diff --git a/packages/player-web/src/scene/core/result.ts b/packages/player-web/src/scene/core/result.ts index d8434170..9dd0fc83 100644 --- a/packages/player-web/src/scene/core/result.ts +++ b/packages/player-web/src/scene/core/result.ts @@ -1,7 +1,7 @@ import { Application, Color, Container, Graphics } from 'pixi.js'; import { computeScoreRate, resolveIidxRankLabel } from '@be-music/player/core/scoring'; import { type PixiSceneHost } from '../host.ts'; -import { disposeChildren } from '../pixi-utils.ts'; +import { LaggedDisposer, disposeChildren } from '../pixi-utils.ts'; import { logger } from '../../logger.ts'; import type { BrowserSongCollection } from '../../collection/types.ts'; import type { PixiGameplayResultData } from './result-data.ts'; @@ -106,6 +106,7 @@ export class CoreResultView { private readonly timeoutHandles = new Set(); /** Fallback summary panel (used when no theme paints the frame). */ private readonly fallbackLayer = new Container(); + private readonly fallbackDisposer = new LaggedDisposer(); /** Clip mask for the design rect — keeps theme artwork from bleeding into the letterbox bars. */ private readonly designClipMask = new Graphics(); /** @@ -310,6 +311,7 @@ export class CoreResultView { log.warn('texture cleanup threw', error); } try { + this.fallbackDisposer.flush(); this.sceneRoot.destroy({ children: true, context: true }); } catch (error) { log.warn('sceneRoot.destroy threw', error); @@ -371,7 +373,9 @@ 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); + // The built-in result is rebuilt every frame; retiring its nodes one frame late lets unchanged labels keep their + // text textures (see `LaggedDisposer`). + this.fallbackDisposer.cycle(this.fallbackLayer); if (this.renderTheme()) { // No empty-state hint here — the theme's own artwork covers the whole canvas. return; diff --git a/packages/player-web/src/scene/default/gameplay-render.ts b/packages/player-web/src/scene/default/gameplay-render.ts index adb76b05..57c09d35 100644 --- a/packages/player-web/src/scene/default/gameplay-render.ts +++ b/packages/player-web/src/scene/default/gameplay-render.ts @@ -166,10 +166,10 @@ function drawBackground(frame: Graphics, hasBga: boolean, drive: AudioDrive): vo maxRadius: 4.2 * (1 + 0.45 * drive.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)) frame.circle(dot.x, dot.y, dot.r); } + // One fill for the whole field instead of one per dot. + frame.fill({ color: PHANTOM_INK, alpha: 0.55 }); // Sheet music on the floor: two ink staves ruled across the wedge, following its slanted top edge. const wedgeLeftAt = (lineY: number): number => FLOOR_WEDGE[0]! + @@ -357,8 +357,9 @@ function drawBgaFrame( maxRadius: 5 * (1 + 0.5 * drive.level), direction: { x: -0.6, y: 1 }, })) { - frame.circle(dot.x, dot.y, dot.r).fill({ color: PHANTOM_RED, alpha: 0.75 }); + frame.circle(dot.x, dot.y, dot.r); } + frame.fill({ color: PHANTOM_RED, alpha: 0.75 }); // Slow-turning starburst behind the slug — the idle screen is alive, not a hole in the cabinet. // It pumps on the bass and jolts a notch round on every onset. const spin = (nowMs !== undefined ? nowMs / 6000 : 0) + 0.1 * drive.onset; @@ -626,12 +627,13 @@ function drawScorePlate( } // 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 + 160, y + 12, tagLabelStyle(PHANTOM_RED), pool); addNumber( layer, formatCount(runtime.combo), x + w - 12, - y + 5, + y + 15, { ...displayStyle(22, PHANTOM_WHITE), anchorX: 1, maxWidth: 60 }, pool, ); diff --git a/packages/player-web/src/scene/default/hud-text.ts b/packages/player-web/src/scene/default/hud-text.ts index 2d2ba6d8..46c82d8b 100644 --- a/packages/player-web/src/scene/default/hud-text.ts +++ b/packages/player-web/src/scene/default/hud-text.ts @@ -45,11 +45,15 @@ export function addHudText( 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); @@ -82,7 +86,7 @@ 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 scale = opts.scale ?? 1; @@ -118,11 +122,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, @@ -140,7 +150,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, diff --git a/packages/player-web/src/scene/default/phantom/result.ts b/packages/player-web/src/scene/default/phantom/result.ts index c1935684..fdb55d2d 100644 --- a/packages/player-web/src/scene/default/phantom/result.ts +++ b/packages/player-web/src/scene/default/phantom/result.ts @@ -1,4 +1,5 @@ -import { Container, Graphics, Text, TextStyle, type Color } from 'pixi.js'; +import { Container, Graphics, Text, type Color } from 'pixi.js'; +import { resolveSkinTextStyle } from '../skin-text.ts'; import type { BeMusicResultFrame, BeMusicResultSkin } from '../../../skin/be-music/types.ts'; import { DEFAULT_DISPLAY_FONT, DEFAULT_HEADLINE_FONT, DEFAULT_TEXT_FONT } from '../fonts.ts'; import { @@ -95,10 +96,10 @@ 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, @@ -463,9 +464,9 @@ function renderMetric( .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, }), @@ -477,11 +478,7 @@ function renderMetric( 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); diff --git a/packages/player-web/src/scene/default/skin-text.ts b/packages/player-web/src/scene/default/skin-text.ts index e08deeb5..92e27316 100644 --- a/packages/player-web/src/scene/default/skin-text.ts +++ b/packages/player-web/src/scene/default/skin-text.ts @@ -26,13 +26,47 @@ export interface SkinTextOptions { * `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, + 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, @@ -51,18 +85,10 @@ export function addSkinText(layer: Container, text: string, x: number, y: number }, } : {}), - }), - }); - 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; + }); + SKIN_TEXT_STYLES.set(key, style); } - layer.addChild(node); - return node; + return style; } /** `'7K KEYS'`-style mode label for a song's play variant. */ diff --git a/packages/player-web/src/scene/default/text-metrics.ts b/packages/player-web/src/scene/default/text-metrics.ts index fa4e5e65..7b74201d 100644 --- a/packages/player-web/src/scene/default/text-metrics.ts +++ b/packages/player-web/src/scene/default/text-metrics.ts @@ -47,7 +47,15 @@ export function fontShorthand(weight: string, sizePx: number, fontFamily: string } 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; @@ -57,7 +65,15 @@ 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); @@ -70,6 +86,7 @@ export function capCenterOffsetEm(fontFamily: string, weight: string): number { 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; } 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..87814ff2 100644 --- a/packages/player-web/src/scene/pixi-utils.ts +++ b/packages/player-web/src/scene/pixi-utils.ts @@ -29,6 +29,35 @@ export function disposeChildren(container: Container): void { } } +/** + * 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. * From da1c7a84a62e89423bbd01bd995475d595d2bb39 Mon Sep 17 00:00:00 2001 From: Seiya KONNO Date: Thu, 8 Oct 2026 18:10:03 +0900 Subject: [PATCH 30/65] test(player-web): add benchmark cases for the audio bus helpers sanitizeBusVolume and mergeCompressorParams are public exports, so the exports benchmark needs a case for each. --- packages/player-web/scripts/exports-cases.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/player-web/scripts/exports-cases.ts b/packages/player-web/scripts/exports-cases.ts index dc9050f0..659dcdb6 100644 --- a/packages/player-web/scripts/exports-cases.ts +++ b/packages/player-web/scripts/exports-cases.ts @@ -455,6 +455,19 @@ export function registerPlayerWebCoreExportsCases(define: DefineBenchmarkCase): playerWebCoreApi.parseCompressorMode('split'); }, }); + define('player-web.sanitizeBusVolume', { + run: () => { + playerWebCoreApi.sanitizeBusVolume(0.75); + }, + }); + define('player-web.mergeCompressorParams', { + run: () => { + playerWebCoreApi.mergeCompressorParams(playerWebCoreApi.KEY_BUS_COMPRESSOR_PARAMS, { + threshold: -12, + attack: 0.02, + }); + }, + }); define('player-web.parsePlaylog', { run: () => { playerWebCoreApi.parsePlaylog(BENCH_PLAYLOG_JSON); From c3034380e317c359c326c440df247660ad021fcc Mon Sep 17 00:00:00 2001 From: Seiya KONNO Date: Thu, 8 Oct 2026 18:10:03 +0900 Subject: [PATCH 31/65] fix(player-web): avoid parameter properties in the particle helpers Node's type stripping (used by the benchmark runner) rejects TypeScript parameter properties, so ShapeBatch and PointLayer declare their fields explicitly. --- packages/player-web/src/scene/default/particle-layer.ts | 5 ++++- packages/player-web/src/scene/default/synesthesia/draw.ts | 6 +++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/player-web/src/scene/default/particle-layer.ts b/packages/player-web/src/scene/default/particle-layer.ts index fa5892e4..345a19b2 100644 --- a/packages/player-web/src/scene/default/particle-layer.ts +++ b/packages/player-web/src/scene/default/particle-layer.ts @@ -20,7 +20,10 @@ export class PointLayer { private touched = false; private shown = 0; - public constructor(private readonly anchor: Graphics) { + 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 }, }); diff --git a/packages/player-web/src/scene/default/synesthesia/draw.ts b/packages/player-web/src/scene/default/synesthesia/draw.ts index d15e3597..bcb45bf3 100644 --- a/packages/player-web/src/scene/default/synesthesia/draw.ts +++ b/packages/player-web/src/scene/default/synesthesia/draw.ts @@ -37,7 +37,11 @@ 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. */ - public constructor(private readonly points = false) {} + 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. From b024e4ceec1c8a37a20026d918c82e3cabf91caa Mon Sep 17 00:00:00 2001 From: Seiya KONNO Date: Thu, 8 Oct 2026 18:23:01 +0900 Subject: [PATCH 32/65] perf(player-web): skip the compressor merge pass when nothing overrides it buildAudioBus merged every compressor's parameters even with no overrides, and each merge listed the parameter keys afresh, which the exports benchmark flagged as a 25% slowdown. Compressors without overrides now start from a copy of their factory tuning, and the merge walks a constant key list. --- packages/player-web/src/runtime/audio-bus.ts | 22 +++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/packages/player-web/src/runtime/audio-bus.ts b/packages/player-web/src/runtime/audio-bus.ts index f4aeae1f..c2cd4e82 100644 --- a/packages/player-web/src/runtime/audio-bus.ts +++ b/packages/player-web/src/runtime/audio-bus.ts @@ -180,13 +180,21 @@ export const COMPRESSOR_PARAM_RANGES: Readonly = [ + 'threshold', + 'ratio', + 'attack', + 'release', + 'knee', +]; + /** * Merges `patch` over `base`, clamping each value into its {@link COMPRESSOR_PARAM_RANGES} range; non-finite values * keep the base value. */ export function mergeCompressorParams(base: CompressorParams, patch: Partial = {}): CompressorParams { const merged = { ...base }; - for (const key of Object.keys(COMPRESSOR_PARAM_RANGES) as Array) { + for (const key of COMPRESSOR_PARAM_KEYS) { const value = patch[key]; if (value === undefined || !Number.isFinite(value)) continue; const { min, max } = COMPRESSOR_PARAM_RANGES[key]; @@ -347,11 +355,15 @@ export function buildAudioBus( // doesn't use them so the next `setMode` call can splice them back in without re-creating Web Audio nodes (cheap, but // recreating would also reset their internal envelope state which wastes any "warm" gain reduction the mode switch // could otherwise preserve). + // Without overrides each compressor just starts from a copy of its factory tuning (no merge pass). + const overrides = options.initialCompressorParams; + const initialParams = (base: CompressorParams, patch: Partial | undefined): CompressorParams => + patch ? mergeCompressorParams(base, patch) : { ...base }; const compressorParams: Record = { - key: mergeCompressorParams(KEY_BUS_COMPRESSOR_PARAMS, options.initialCompressorParams?.key), - bgm: mergeCompressorParams(BGM_BUS_COMPRESSOR_PARAMS, options.initialCompressorParams?.bgm), - master: mergeCompressorParams(MASTER_BUS_COMPRESSOR_PARAMS, options.initialCompressorParams?.master), - legacy: mergeCompressorParams(LEGACY_COMPRESSOR_PARAMS, options.initialCompressorParams?.legacy), + key: initialParams(KEY_BUS_COMPRESSOR_PARAMS, overrides?.key), + bgm: initialParams(BGM_BUS_COMPRESSOR_PARAMS, overrides?.bgm), + master: initialParams(MASTER_BUS_COMPRESSOR_PARAMS, overrides?.master), + legacy: initialParams(LEGACY_COMPRESSOR_PARAMS, overrides?.legacy), }; const keyComp = createCompressor(audioContext, compressorParams.key); const bgmComp = createCompressor(audioContext, compressorParams.bgm); From fc16c4ee1c22057064e2674c44373614f17d3013 Mon Sep 17 00:00:00 2001 From: Seiya KONNO Date: Fri, 9 Oct 2026 13:03:30 +0900 Subject: [PATCH 33/65] feat(player-web): show pre-play chart facts in the built-in select lists List rows now print the artist after the title, gimmick tags (LN, SOFLAN, STOP, MINE), the note count, the play length, and the tempo range in fixed columns. --- .../src/scene/default/lattice/select.ts | 70 +++++++++++- .../src/scene/default/phantom/select.ts | 74 ++++++++++-- .../src/scene/default/song-stats.test.ts | 82 ++++++++++++++ .../src/scene/default/song-stats.ts | 106 ++++++++++++++++++ .../src/scene/default/synesthesia/select.ts | 70 +++++++++++- 5 files changed, 383 insertions(+), 19 deletions(-) create mode 100644 packages/player-web/src/scene/default/song-stats.test.ts create mode 100644 packages/player-web/src/scene/default/song-stats.ts diff --git a/packages/player-web/src/scene/default/lattice/select.ts b/packages/player-web/src/scene/default/lattice/select.ts index 61a3ae10..1e69cf19 100644 --- a/packages/player-web/src/scene/default/lattice/select.ts +++ b/packages/player-web/src/scene/default/lattice/select.ts @@ -24,8 +24,13 @@ import { LAT_TEXT_FONT, LAT_WHITE, } from './style.ts'; +import { resolveSongRowFacts } from '../song-stats.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 = 52; const CURSOR_MS = 360; const OUTRO_MS = 650; const INTRO_STAGGER_MS = 40; @@ -448,8 +453,11 @@ class LatticeSelectRenderer implements BeMusicSelectRenderer { alpha, maxWidth: 20, }); - const meta = song - ? `${song.bpm ? `${Math.round(song.bpm)} BPM` : ''}` + // 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, @@ -461,11 +469,49 @@ class LatticeSelectRenderer implements BeMusicSelectRenderer { anchorX: 1, anchorY: 0.5, alpha, - maxWidth: 70, + maxWidth: facts ? ROW_BPM_W : 70, }, ); metaNode.label = `fallback-meta[idx=${entryIndex}]`; - const metaWidth = meta ? Math.min(70, metaNode.width) + 12 : 0; + 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} N`, 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), @@ -478,10 +524,24 @@ class LatticeSelectRenderer implements BeMusicSelectRenderer { fontFamily: LAT_TEXT_FONT, anchorY: 0.5, alpha, - maxWidth: Math.max(24, listX + listWidth - x - 72 - metaWidth), + 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; } diff --git a/packages/player-web/src/scene/default/phantom/select.ts b/packages/player-web/src/scene/default/phantom/select.ts index 3bea1c78..3a385ac9 100644 --- a/packages/player-web/src/scene/default/phantom/select.ts +++ b/packages/player-web/src/scene/default/phantom/select.ts @@ -32,11 +32,16 @@ import { addHitArea, addSkinText, formatPlayVariantLabel, type SkinTextOptions } import type { BeMusicAudioFrame } from '../../../skin/be-music/types.ts'; import { audioDrive, bandLevel } from '../audio-drive.ts'; import { addRansomText, type GlyphFactory } from './tear.ts'; +import { resolveSongRowFacts } from '../song-stats.ts'; 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 = 52; const KICKER_PITCH = 20; const GLINT_W = 14; /** Authored glint height; scaled to the focused card's height at tick time. */ @@ -550,11 +555,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) : '-'; @@ -570,7 +576,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); @@ -613,18 +618,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 - 16, rowMidY, { ...factStyle(ink), maxWidth: ROW_NOTES_W - 18 }); + addSkinText(frame.layer, 'N', 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/song-stats.test.ts b/packages/player-web/src/scene/default/song-stats.test.ts new file mode 100644 index 00000000..f7c7705b --- /dev/null +++ b/packages/player-web/src/scene/default/song-stats.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vite-plus/test'; +import { parseChart } from '@be-music/parser'; +import { + formatBpmRange, + 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: [] }); + }); +}); diff --git a/packages/player-web/src/scene/default/song-stats.ts b/packages/player-web/src/scene/default/song-stats.ts new file mode 100644 index 00000000..28e6f767 --- /dev/null +++ b/packages/player-web/src/scene/default/song-stats.ts @@ -0,0 +1,106 @@ +import type { BeMusicJson } from '@be-music/json'; +import { createTimingResolver } from '@be-music/audio-renderer/triggers'; +import { extractLandmineNotes, extractPlayableNotes } from '@be-music/player/playable-notes'; +import type { BrowserSongEntry } from '../../collection/types.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), + }; +} diff --git a/packages/player-web/src/scene/default/synesthesia/select.ts b/packages/player-web/src/scene/default/synesthesia/select.ts index 9bed314a..2ac49dc1 100644 --- a/packages/player-web/src/scene/default/synesthesia/select.ts +++ b/packages/player-web/src/scene/default/synesthesia/select.ts @@ -47,8 +47,13 @@ import { sceneHue, synGlowTexture, } from './style.ts'; +import { resolveSongRowFacts } from '../song-stats.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 = 50; const SLIDE_MS = 320; const OUTRO_MS = 700; const INTRO_STAGGER_MS = 45; @@ -666,8 +671,11 @@ class SynesthesiaSelectRenderer implements BeMusicSelectRenderer { alpha, maxWidth: 16, }); - const meta = song - ? `${song.bpm ? `${Math.round(song.bpm)} BPM` : ''}` + // 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, @@ -677,10 +685,48 @@ class SynesthesiaSelectRenderer implements BeMusicSelectRenderer { anchorX: 1, anchorY: 0.5, alpha, - maxWidth: 70, + maxWidth: facts ? ROW_BPM_W : 70, }); metaNode.label = `fallback-meta[idx=${entryIndex}]`; - const metaWidth = meta ? Math.min(70, metaNode.width) + 12 : 0; + 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, 'N', factsLeft, midY, { ...factStyle(SYN_DIM), size: 7 }); + addSkinText(frame.layer, facts.notes, factsLeft - 12, midY, { ...factStyle(ink), maxWidth: ROW_NOTES_W - 14 }); + 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', @@ -688,10 +734,24 @@ class SynesthesiaSelectRenderer implements BeMusicSelectRenderer { fontFamily: SYN_TEXT_FONT, anchorY: 0.5, alpha, - maxWidth: Math.max(24, rowW - 48 - metaWidth), + 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; } } From 2d77e3ef71ca7f6573f45e5b4bc71da0028039e4 Mon Sep 17 00:00:00 2001 From: Seiya KONNO Date: Fri, 9 Oct 2026 13:03:30 +0900 Subject: [PATCH 34/65] feat(player-web): lay the built-in skins out on a 16:9 stage The built-in skins declare an 854x480 BeMusicSkin.stage and the default scenes size their canvas from it; LR2 / beatoraja themes keep 640x480. The playfield keeps its 4:3 pixels and the extra width goes to a larger BGA (kept beside double-play fields), a right-hand judge column, and wider HUD plates. Result screens gain a track column with the chart facts and clear lamp, and the lanes show NOW LOADING while chart audio decodes or a BGA video transcodes. --- .changeset/be-music-skins.md | 10 +- .../player-web/src/scene/core/gameplay.ts | 126 ++++++++--- packages/player-web/src/scene/core/result.ts | 5 +- packages/player-web/src/scene/core/select.ts | 14 +- .../src/scene/default/gameplay-render.ts | 207 +++++++++++------- .../scene/default/lattice/gameplay-chrome.ts | 84 +++++-- .../src/scene/default/lattice/index.ts | 2 + .../src/scene/default/lattice/moments.ts | 15 +- .../src/scene/default/lattice/result.ts | 67 ++++-- .../src/scene/default/loading.test.ts | 17 ++ .../player-web/src/scene/default/loading.ts | 10 + .../src/scene/default/moments.test.ts | 22 +- .../player-web/src/scene/default/moments.ts | 15 +- .../src/scene/default/phantom/index.ts | 2 + .../src/scene/default/phantom/moments.ts | 7 +- .../src/scene/default/phantom/result.ts | 93 ++++++-- .../src/scene/default/result-track.test.ts | 58 +++++ .../src/scene/default/result-track.ts | 39 ++++ .../src/scene/default/stage.test.ts | 37 ++++ .../player-web/src/scene/default/stage.ts | 51 +++++ .../default/synesthesia/gameplay-chrome.ts | 131 ++++++++--- .../src/scene/default/synesthesia/index.ts | 2 + .../src/scene/default/synesthesia/moments.ts | 14 +- .../src/scene/default/synesthesia/result.ts | 76 +++++-- .../scene/default/synesthesia/space.test.ts | 35 +++ .../src/scene/default/synesthesia/space.ts | 61 ++++++ .../player-web/src/scene/gameplay-chrome.ts | 2 + packages/player-web/src/scene/lr2/gameplay.ts | 8 + .../player-web/src/skin/be-music/types.ts | 26 ++- 29 files changed, 1012 insertions(+), 224 deletions(-) create mode 100644 packages/player-web/src/scene/default/loading.test.ts create mode 100644 packages/player-web/src/scene/default/loading.ts create mode 100644 packages/player-web/src/scene/default/result-track.test.ts create mode 100644 packages/player-web/src/scene/default/result-track.ts create mode 100644 packages/player-web/src/scene/default/stage.test.ts create mode 100644 packages/player-web/src/scene/default/stage.ts diff --git a/.changeset/be-music-skins.md b/.changeset/be-music-skins.md index 3973b7eb..4cb975fa 100644 --- a/.changeset/be-music-skins.md +++ b/.changeset/be-music-skins.md @@ -4,14 +4,14 @@ 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`: one object bundling the HUD chrome, lane, note, long-note, bomb, select, and result renderers over a fixed design canvas (the skin's `stage`, 640x480 unless it declares another). 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). - New exports: `BeMusicSkin` and its context types, `createBeMusicSkinRegistry`, `resolveBeMusicLaneKind`, `resolveSelectListWindow`, `phantomSkin`, `synesthesiaSkin`, `latticeSkin`, and `BUILT_IN_BE_MUSIC_SKINS`. - Phantom is the existing poster skin and still the default. 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 lit by an ember horizon, a world built from fine ember / gold particles with electric blue and magenta accents, data dust and speed streaks pouring out of the vanishing point, 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 — tilted shockwave rings, 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 a 3D particle ring that bursts 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 `renderChrome` (as `runtime.audio`), `renderLanes` and `renderBombs` (as `audio`), and the select renderer's `tick` (fourth argument). Gameplay analyses the whole mix off the audio bus output; select analyses the BGM and chart preview. 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 the whole page (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. +- 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 and the 14 px gap between the DP 1P / 2P banks are gone (the playfield keeps its width, and `sideGap` can still request a gap), and every built-in skin draws notes and long notes across the full lane width. @@ -29,4 +29,8 @@ The default (skinless) family now renders through a `BeMusicSkin`: one object bu - 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, STANDBY gets a slip over the monitor's horizon, 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. -- `SkinlessGameplayChromeRuntime` gains `totalNotes`, `chartMs`, `judgeAtMs`, `impulseAtMs`, `impulseKind`, and `effects`, and the be-music lane and bomb contexts gain `combo` and `effects`. +- 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`. diff --git a/packages/player-web/src/scene/core/gameplay.ts b/packages/player-web/src/scene/core/gameplay.ts index d65a7c9b..231353f2 100644 --- a/packages/player-web/src/scene/core/gameplay.ts +++ b/packages/player-web/src/scene/core/gameplay.ts @@ -132,7 +132,13 @@ import type { } from '../gameplay-chrome.ts'; import { resolveGameplayAudioTailCleanupDelayMs, resolvePostChartResultDelayMs } from './gameplay-result-delay.ts'; import { CORE_TEXT_FONT } from './fonts.ts'; -import type { BeMusicBomb, BeMusicEffectLevel, BeMusicLaneFrame, BeMusicSkin } from '../../skin/be-music/types.ts'; +import type { + BeMusicBomb, + BeMusicEffectLevel, + 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'; @@ -205,20 +211,33 @@ 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 }; /** Lane rectangle in design pixels. `bottom` is the judgement line a note's bottom edge lands on. */ export interface GameplayLaneRect { @@ -577,6 +596,8 @@ 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(); @@ -1195,11 +1253,12 @@ export class CoreGameplayView { log.warn('BGA preload failed; continuing without it', error); }); + void this.bgaReadyPromise.then(() => { + this.assetsLoading = false; + }); } /** @@ -3066,7 +3135,8 @@ export class CoreGameplayView this.renderThemeLayer(DESIGN_WIDTH, DESIGN_HEIGHT)); + 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)); } /** @@ -3511,7 +3581,9 @@ export class CoreGameplayView 0, bga, drive); const effects = effectProfile(runtime.effects); drawPlayfield( @@ -97,18 +113,16 @@ export function renderDefaultGameplayFrame( runtime.progressRatio, impulse(runtime.impulseAtMs, runtime.nowMs ?? 0, 140) * effects.amount, ); - // A double-play field reaches into the monitor's column; the frame and its idle screen would sit on the 2P lanes. - if (playfield.right + 24 <= BGA.x) { - drawBgaFrame(frame, layer, hasBga, runtime.nowMs, drive, layerPool); - drawSpectrumStrip(frame, drive); + // 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) { + drawBgaFrame(frame, layer, bga, hasBga, runtime.nowMs, drive, layerPool); } drawGauge(frame, layer, runtime, layerPool); drawSongPlate(frame, layer, runtime, layerPool); drawScorePlate(frame, layer, runtime, drive, layerPool); - const tallyX = resolveJudgeTallyX(playfield); - if (tallyX !== undefined) { - drawJudgeTally(frame, layer, runtime, tallyX, 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); } @@ -124,7 +138,8 @@ export function renderDefaultGameplayFrame( // 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, playfield.right); - if (!covered) drawJudgements(frontLayer, runtime, playfield, frontPool, effects.amount); + if (runtime.loading) drawLoading(frontLayer, runtime.nowMs ?? 0, playfield, frontPool); + else if (!covered) drawJudgements(frontLayer, runtime, playfield, frontPool, effects.amount); } /** @@ -141,10 +156,10 @@ export const renderFallbackLr2Frame: typeof renderDefaultGameplayFrame = renderD * 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, drive: AudioDrive): void { - fillRectAroundHole(frame, 0, 0, DESIGN_WIDTH, DESIGN_HEIGHT, PHANTOM_BLACK, hasBga); +function drawBackground(frame: Graphics, hasBga: boolean, bga: BeMusicRect, drive: AudioDrive): void { + fillRectAroundHole(frame, 0, 0, DESIGN_WIDTH, DESIGN_HEIGHT, PHANTOM_BLACK, hasBga ? bga : undefined); // 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({ color: PHANTOM_CHARCOAL, @@ -154,14 +169,14 @@ function drawBackground(frame: Graphics, hasBga: boolean, drive: AudioDrive): vo // 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]) + .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); 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 * (1 + 0.45 * drive.bass), direction: { x: 1, y: 1 }, @@ -179,7 +194,7 @@ function drawBackground(frame: Graphics, hasBga: boolean, drive: AudioDrive): vo drawStaves(frame, 0, DESIGN_WIDTH, 446, 5, PHANTOM_INK, 0.55, wedgeLeftAt); // 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]) + .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 }); } @@ -189,7 +204,7 @@ function isInsideFloorWedge(x: number, y: number): boolean { 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, x: number, @@ -197,20 +212,20 @@ function fillRectAroundHole( 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); } @@ -313,12 +328,6 @@ function drawPlayfield( 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 + 18; - return playfield.right + 14 <= x && x + 56 <= DESIGN_WIDTH - HUD_MARGIN ? 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. @@ -326,6 +335,7 @@ function resolveJudgeTallyX(playfield: FallbackPlayfieldLayout): number | undefi function drawBgaFrame( frame: Graphics, layer: Container, + bga: BeMusicRect, hasBga: boolean, nowMs: number | undefined, drive: AudioDrive, @@ -333,26 +343,26 @@ function drawBgaFrame( ): 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', }); if (hasBga) return; - frame.rect(BGA.x, BGA.y, BGA.w, BGA.h).fill(PHANTOM_INK); + 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, + x: bga.x + 4, + y: bga.y + 4, + w: bga.w - 8, + h: bga.h - 8, pitch: 12, maxRadius: 5 * (1 + 0.5 * drive.level), direction: { x: -0.6, y: 1 }, @@ -361,11 +371,11 @@ function drawBgaFrame( } frame.fill({ color: PHANTOM_RED, alpha: 0.75 }); // Slow-turning starburst behind the slug — the idle screen is alive, not a hole in the cabinet. - // It pumps on the bass and jolts a notch round on every onset. + // 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.1 * drive.onset; - const pump = 1 + 0.2 * drive.bass + 0.1 * drive.onset; - const cx = BGA.x + BGA.w / 2; - const cy = BGA.y + BGA.h / 2; + const pump = (1 + 0.2 * drive.bass + 0.1 * 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 }); @@ -382,27 +392,40 @@ function drawBgaFrame( } /** - * Spectrum strip under the monitor: 16 slanted red bars rising off an ink rule, white-capped, hot bands turning gold — - * the poster's equalizer. Silent (or effects off) leaves just the rule. + * 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 drawSpectrumStrip(frame: Graphics, drive: AudioDrive): void { - const baseY = 348; - const maxH = 22; +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 = BGA.w / count; - frame.rect(BGA.x - 6, baseY, BGA.w + 12, 2).fill(PHANTOM_WHITE); + 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 x = BGA.x + bar * pitch + 1; - const w = pitch - 5; - frame.poly(parallelogramPoints(x, baseY - h, w, h, 4)).fill(value > 0.82 ? PHANTOM_GOLD : PHANTOM_RED); - // A 3 px paper cap riding the bar's slanted top. - frame.poly(parallelogramPoints(x + 4 * (1 - 3 / h), baseY - h, w, 3, (4 * 3) / h)).fill(PHANTOM_WHITE); + 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. @@ -576,35 +599,36 @@ function drawScorePlate( .fill(PHANTOM_BLACK) .stroke({ color: PHANTOM_WHITE, width: 2 }); // 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, 116, 1).fill({ color: PHANTOM_SLATE }); - frame.rect(x + 14, y + 64, 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); + 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 + 44, tagLabelStyle(PHANTOM_ASH), pool); addNumber( layer, `${formatCount(runtime.exScore)} / ${formatCount(runtime.exScoreMax)}`, - x + 134, + x + SCORE_LEFT_END, y + 39, - { ...displayStyle(14, PHANTOM_PAPER), anchorX: 1, maxWidth: 66 }, + { ...displayStyle(14, PHANTOM_PAPER), anchorX: 1, maxWidth: 76 }, pool, ); addText(layer, 'EX RATE', x + 14, y + 72, tagLabelStyle(PHANTOM_ASH), pool); addNumber( layer, formatExRate(runtime.exScore, runtime.exScoreMax), - x + 134, + x + SCORE_LEFT_END, y + 67, - { ...displayStyle(14, PHANTOM_PAPER), anchorX: 1, maxWidth: 74 }, + { ...displayStyle(14, PHANTOM_PAPER), anchorX: 1, maxWidth: 84 }, pool, ); @@ -614,7 +638,7 @@ 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; @@ -628,7 +652,7 @@ function drawScorePlate( // 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 + 160, y + 12, tagLabelStyle(PHANTOM_RED), pool); + addText(layer, 'COMBO', x + SCORE_RIGHT, y + 12, tagLabelStyle(PHANTOM_RED), pool); addNumber( layer, formatCount(runtime.combo), @@ -637,7 +661,7 @@ function drawScorePlate( { ...displayStyle(22, PHANTOM_WHITE), anchorX: 1, maxWidth: 60 }, pool, ); - addText(layer, 'MAX', x + 160, y + 44, tagLabelStyle(PHANTOM_ASH), pool); + addText(layer, 'MAX', x + SCORE_RIGHT, y + 44, tagLabelStyle(PHANTOM_ASH), pool); addNumber( layer, formatCount(runtime.maxCombo), @@ -660,7 +684,7 @@ function drawScorePlate( .poly(badge) .fill(topRank ? PHANTOM_GOLD : PHANTOM_RED) .stroke({ color: PHANTOM_WHITE, width: 1.5 }); - addText(layer, 'RANK', x + 160, y + 72, tagLabelStyle(PHANTOM_ASH), pool); + addText(layer, 'RANK', x + SCORE_RIGHT, y + 72, tagLabelStyle(PHANTOM_ASH), pool); addText( layer, rank, @@ -694,9 +718,9 @@ function drawJudgeTally( runtime: FallbackGameplayRuntime, x: number, pool?: ChildPool, -): void { - // Top-aligned with the BGA so the header chip clears the status bar's beat-driven teeth. - const y = BGA.y; +): 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; @@ -753,6 +777,7 @@ function drawJudgeTally( pool, ); } + return y + h; } const TALLY_JUDGE_NAMES = { @@ -821,6 +846,36 @@ function drawJudgements( } } +/** + * 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) => diff --git a/packages/player-web/src/scene/default/lattice/gameplay-chrome.ts b/packages/player-web/src/scene/default/lattice/gameplay-chrome.ts index d6e59972..fa2f0bf0 100644 --- a/packages/player-web/src/scene/default/lattice/gameplay-chrome.ts +++ b/packages/player-web/src/scene/default/lattice/gameplay-chrome.ts @@ -1,5 +1,14 @@ import { Graphics, type Container } from 'pixi.js'; -import { BGA, DESIGN_HEIGHT, DESIGN_WIDTH, GROOVE, PLAYFIELD } from '../../gameplay-constants.ts'; +import { GROOVE, PLAYFIELD } from '../../gameplay-constants.ts'; +import { LOADING_WORD, loadingDots } from '../loading.ts'; +import type { BeMusicRect } from '../../../skin/be-music/types.ts'; +import { + STAGE_BGA_BAND, + STAGE_HEIGHT as DESIGN_HEIGHT, + STAGE_SIDE_COLUMN, + STAGE_WIDTH as DESIGN_WIDTH, + resolveStageBgaRect, +} from '../stage.ts'; import type { SkinlessGameplayChromeRenderContext, SkinlessGameplayChromeRuntime } from '../../gameplay-chrome.ts'; import { resolveSkinlessLaneLayout } from '../../gameplay-lanes.ts'; import type { ChildPool } from '../../pixi-utils.ts'; @@ -59,8 +68,15 @@ function hudScramble(runtime: Runtime): (value: string, delayMs: number, seed: n */ const HUD_LEFT = 32; const HUD_RIGHT = DESIGN_WIDTH - 16; -const SCORE_PANEL = { x: 384, y: GROOVE.y - 24, w: HUD_RIGHT - 384, h: 466 - (GROOVE.y - 24) } as const; -const SONG_PLATE = { x: HUD_LEFT, y: 420, w: 360 - HUD_LEFT, h: 46 } as const; +/** 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 = { ...resolveStageBgaRect(227) }; const HEADER_H = 30; /** @@ -79,12 +95,14 @@ export function renderLatticeChrome({ const nowMs = runtime.nowMs ?? 0; const seconds = nowMs / 1000; const beatPhase = runtime.beatPhase ?? 0; - const hasBga = runtime.hasBga === true; const { left: playfieldLeft, right: playfieldRight } = resolveSkinlessLaneLayout( runtime.laneChannels, runtime.laneCount, runtime.playVariant, ); + Object.assign(BGA, resolveStageBgaRect(playfieldRight)); + 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 @@ -111,13 +129,14 @@ export function renderLatticeChrome({ // 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 }, - { x: BGA.x - 13, y: BGA.y - 13, w: BGA.w + 26, h: BGA.h + 26 }, + ...(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: BGA.x + BGA.w + 8, y: BGA.y - 12, w: DESIGN_WIDTH, h: 252 }, + { x: STAGE_SIDE_COLUMN.x - 8, y: STAGE_BGA_BAND.top - 12, w: DESIGN_WIDTH, h: 252 }, ]; - const pendulums = playfieldRight + 40 <= BGA.x; + // 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, @@ -137,16 +156,14 @@ export function renderLatticeChrome({ if (pendulums) { drawPendulums(panels, layer, runtime, (playfieldRight + BGA.x - 5) / 2, effects.amount, drive, layerPool); } - if (playfieldRight + 14 <= BGA.x) { + 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); - const tallyX = BGA.x + BGA.w + 14; - if (playfieldRight + 14 <= tallyX) { - drawJudgeTally(panels, layer, runtime, tallyX, drive, 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'; @@ -156,7 +173,11 @@ export function renderLatticeChrome({ const covered = effects.screenWide && fullCombo !== undefined && fullCombo > 0.25 && fullCombo < 0.8; if (!covered) { drawHeader(front, overlayLayer, runtime, beatPhase, overlayLayerPool); - drawJudgements(overlayLayer, runtime, playfieldRight, effects.amount, overlayLayerPool); + if (runtime.loading) { + drawLoading(overlayLayer, runtime, (playfieldLeft + playfieldRight) / 2, overlayLayerPool); + } else { + drawJudgements(overlayLayer, runtime, playfieldRight, effects.amount, overlayLayerPool); + } } drawLatticeMoments( overlayLayer, @@ -343,23 +364,50 @@ function resolveFieldInput( }; } +/** + * 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(); +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, hasBga: !hasBga }; + entry = { graphics, hole: '-' }; STATIC_PAPER.set(layer, entry); } - if (entry.hasBga === hasBga) return; - entry.hasBga = hasBga; + 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); @@ -756,7 +804,7 @@ function drawJudgeTally( drive: AudioDrive, pool: ChildPool, ): void { - const y = BGA.y - 5; + 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); diff --git a/packages/player-web/src/scene/default/lattice/index.ts b/packages/player-web/src/scene/default/lattice/index.ts index d96f136c..53dec6b1 100644 --- a/packages/player-web/src/scene/default/lattice/index.ts +++ b/packages/player-web/src/scene/default/lattice/index.ts @@ -9,6 +9,7 @@ import { } from './playfield.ts'; import { latticeResultSkin } from './result.ts'; import { latticeSelectSkin } from './select.ts'; +import { wideStage } from '../stage.ts'; /** * Lattice — a skin in the manner of precise, typographic interaction design (the Japanese web / broadcast interaction-design @@ -28,6 +29,7 @@ export const latticeSkin: BeMusicSkin = { '300 12px "M PLUS 1p"', '500 12px "M PLUS 1p"', ], + stage: wideStage, gameplay: { renderChrome: renderLatticeChrome, renderLanes: renderLatticeLanes, diff --git a/packages/player-web/src/scene/default/lattice/moments.ts b/packages/player-web/src/scene/default/lattice/moments.ts index df15835a..7cbe6a7c 100644 --- a/packages/player-web/src/scene/default/lattice/moments.ts +++ b/packages/player-web/src/scene/default/lattice/moments.ts @@ -1,5 +1,7 @@ import type { Container, Graphics } from 'pixi.js'; -import { BGA, DESIGN_HEIGHT, DESIGN_WIDTH, GROOVE } from '../../gameplay-constants.ts'; +import { GROOVE } from '../../gameplay-constants.ts'; +import type { BeMusicRect } from '../../../skin/be-music/types.ts'; +import { STAGE_HEIGHT as DESIGN_HEIGHT, STAGE_WIDTH as DESIGN_WIDTH, resolveStageBgaRect } from '../stage.ts'; import type { SkinlessGameplayChromeRuntime } from '../../gameplay-chrome.ts'; import type { ChildPool } from '../../pixi-utils.ts'; import { addHudText } from '../hud-text.ts'; @@ -58,6 +60,7 @@ export function drawLatticeMoments( milestone, runtime.hasBga === true, resolveMilestoneArea(playfieldRight), + resolveStageBgaRect(playfieldRight), pool, ); } @@ -140,13 +143,15 @@ function drawMilestone( t: number, hasBga: boolean, area: MilestoneArea, + bga: BeMusicRect, pool: ChildPool, ): void { - // Over the monitor beside the lanes, or in the band below them in double play — never over the notes. - const below = area.mode === 'below'; + // 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 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. diff --git a/packages/player-web/src/scene/default/lattice/result.ts b/packages/player-web/src/scene/default/lattice/result.ts index e929b0a3..3ca55062 100644 --- a/packages/player-web/src/scene/default/lattice/result.ts +++ b/packages/player-web/src/scene/default/lattice/result.ts @@ -5,6 +5,7 @@ import { addSkinText, type SkinTextOptions } from '../skin-text.ts'; import { addStaggeredSkinText, drawNeedleField, drawPaperGrid, drawRulerTicks } from './draw.ts'; import { scrambleText, scrambleTick, springEase } from './field.ts'; import { bounceHeight } from './physics.ts'; +import { resolveResultLamp, resolveResultTrackRows } from '../result-track.ts'; import { LAT_ACCENT, LAT_DISPLAY_FONT, @@ -76,7 +77,7 @@ export function renderLatticeResult(frame: BeMusicResultFrame): void { }, { alpha: 0.4, - skip: (x, y) => (x > 14 && x < 626 && y > 56 && y < 430) || y > designHeight - 50, + skip: (x, y) => (x > 14 && x < designWidth - 14 && y > 56 && y < 430) || y > designHeight - 50, }, ); @@ -93,20 +94,6 @@ export function renderLatticeResult(frame: BeMusicResultFrame): void { return { dy: -12 * (1 - springEase(Math.min(1, local))), alpha: Math.min(1, local * 3) }; }, ); - text( - decode(`${result.song.title}${result.song.artist ? ` / ${result.song.artist}` : ''}`, 280, 2, 600), - designWidth - 20, - 16, - { - size: 12, - weight: '500', - fill: LAT_INK, - fontFamily: LAT_TEXT_FONT, - anchorX: 1, - maxWidth: 340, - alpha: fadeIn(300), - }, - ); // Rank block with crop marks. const rankBox = { x: 20, y: 66, w: 184, h: 166 }; @@ -204,6 +191,56 @@ export function renderLatticeResult(frame: BeMusicResultFrame): void { 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) }); diff --git a/packages/player-web/src/scene/default/loading.test.ts b/packages/player-web/src/scene/default/loading.test.ts new file mode 100644 index 00000000..c34eae03 --- /dev/null +++ b/packages/player-web/src/scene/default/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/player-web/src/scene/default/loading.ts b/packages/player-web/src/scene/default/loading.ts new file mode 100644 index 00000000..864e8667 --- /dev/null +++ b/packages/player-web/src/scene/default/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/player-web/src/scene/default/moments.test.ts b/packages/player-web/src/scene/default/moments.test.ts index de08a83d..ab3109f2 100644 --- a/packages/player-web/src/scene/default/moments.test.ts +++ b/packages/player-web/src/scene/default/moments.test.ts @@ -127,19 +127,31 @@ describe('resolveMilestoneArea', () => { const area = resolveMilestoneArea(227); expect(area.mode).toBe('beside'); expect(area.x).toBeGreaterThan(227); - expect(area.x + area.w).toBeLessThanOrEqual(640); + expect(area.x + area.w).toBeLessThanOrEqual(854); expect(area.y + area.h).toBeLessThanOrEqual(346); }); - it('drops below the lanes when they leave too little room (double play)', () => { - const area = resolveMilestoneArea(547); + 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(640 - 16 - MILESTONE_MIN_WIDTH).mode).toBe('beside'); - expect(resolveMilestoneArea(640 - 16 - MILESTONE_MIN_WIDTH + 1).mode).toBe('below'); + 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/player-web/src/scene/default/moments.ts b/packages/player-web/src/scene/default/moments.ts index fb2b6367..b788a993 100644 --- a/packages/player-web/src/scene/default/moments.ts +++ b/packages/player-web/src/scene/default/moments.ts @@ -1,3 +1,5 @@ +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) @@ -148,12 +150,17 @@ 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 640×480 design space. + * 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): MilestoneArea { - const free = 640 - playfieldRight - 16; +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: 624, h: 480 - lanesBottom - 8, mode: 'below' }; + return { x: 8, y: lanesBottom + 4, w: stageWidth - 16, h: 480 - lanesBottom - 8, mode: 'below' }; } diff --git a/packages/player-web/src/scene/default/phantom/index.ts b/packages/player-web/src/scene/default/phantom/index.ts index 10caa084..51036342 100644 --- a/packages/player-web/src/scene/default/phantom/index.ts +++ b/packages/player-web/src/scene/default/phantom/index.ts @@ -9,6 +9,7 @@ import { } from './playfield.ts'; import { phantomResultSkin } from './result.ts'; import { phantomSelectSkin } from './select.ts'; +import { wideStage } from '../stage.ts'; /** * Phantom — the built-in poster skin: ink black, blood red, and paper white, with slanted plates, starbursts, @@ -18,6 +19,7 @@ export const phantomSkin: BeMusicSkin = { id: 'phantom', label: 'Phantom', 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 }), diff --git a/packages/player-web/src/scene/default/phantom/moments.ts b/packages/player-web/src/scene/default/phantom/moments.ts index 26c9b5e8..548a3749 100644 --- a/packages/player-web/src/scene/default/phantom/moments.ts +++ b/packages/player-web/src/scene/default/phantom/moments.ts @@ -1,5 +1,6 @@ import { Graphics, type Container } from 'pixi.js'; -import { BGA, DESIGN_HEIGHT, DESIGN_WIDTH, GROOVE } from '../../gameplay-constants.ts'; +import { GROOVE } from '../../gameplay-constants.ts'; +import { STAGE_HEIGHT as DESIGN_HEIGHT, STAGE_WIDTH as DESIGN_WIDTH, resolveStageBgaRect } from '../stage.ts'; import type { SkinlessGameplayChromeRuntime } from '../../gameplay-chrome.ts'; import type { ChildPool } from '../../pixi-utils.ts'; import { DEFAULT_DISPLAY_FONT } from '../fonts.ts'; @@ -174,13 +175,15 @@ function drawMilestone( // 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(playfieldRight); + const bga = resolveStageBgaRect(playfieldRight); + hasBga = hasBga && bga.w > 0; 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.y + BGA.h - 14 : area.y + 160, + cy: hasBga ? bga.y + bga.h - 14 : area.y + 160, angle: hasBga ? -0.07 : -0.1, length: area.w - 6, thickness: hasBga ? 54 : 80, diff --git a/packages/player-web/src/scene/default/phantom/result.ts b/packages/player-web/src/scene/default/phantom/result.ts index fdb55d2d..df99bbf5 100644 --- a/packages/player-web/src/scene/default/phantom/result.ts +++ b/packages/player-web/src/scene/default/phantom/result.ts @@ -3,6 +3,7 @@ import { resolveSkinTextStyle } from '../skin-text.ts'; import type { BeMusicResultFrame, BeMusicResultSkin } from '../../../skin/be-music/types.ts'; import { DEFAULT_DISPLAY_FONT, DEFAULT_HEADLINE_FONT, DEFAULT_TEXT_FONT } from '../fonts.ts'; import { + PHANTOM_ASH, PHANTOM_BLACK, PHANTOM_CHARCOAL, PHANTOM_CYAN, @@ -28,6 +29,7 @@ import { } from '../phantom-style.ts'; import { alignCapCenter } from '../text-metrics.ts'; import { addRansomText, type GlyphFactory } from './tear.ts'; +import { resolveResultLamp, resolveResultTrackRows } from '../result-track.ts'; /** Default-family result entrance timeline (ms from scene start): counters roll up, then the rank badge lands. */ const RESULT_ROLL_DELAY_MS = 760; @@ -52,6 +54,12 @@ 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: BeMusicResultSkin = { render: (frame) => renderPhantomResult(frame) }; @@ -201,21 +209,6 @@ 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 - RESULT_MARGIN, - 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(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); @@ -402,6 +395,76 @@ export function renderPhantomResult(frame: BeMusicResultFrame): void { ); 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); diff --git a/packages/player-web/src/scene/default/result-track.test.ts b/packages/player-web/src/scene/default/result-track.test.ts new file mode 100644 index 00000000..e9063b3e --- /dev/null +++ b/packages/player-web/src/scene/default/result-track.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vite-plus/test'; +import type { PixiGameplayResultData } from '../core/result-data.ts'; +import { resolveResultLamp, resolveResultTrackRows } from './result-track.ts'; + +function score(partial: Partial): PixiGameplayResultData['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 PixiGameplayResultData['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 PixiGameplayResultData['song']; + const rows = resolveResultTrackRows({ song: bare, score: score({}) }); + expect(rows[1]!.value).toBe('-'); + expect(rows[2]!.value).toBe('-'); + }); +}); diff --git a/packages/player-web/src/scene/default/result-track.ts b/packages/player-web/src/scene/default/result-track.ts new file mode 100644 index 00000000..d6e84eb2 --- /dev/null +++ b/packages/player-web/src/scene/default/result-track.ts @@ -0,0 +1,39 @@ +import type { PixiGameplayResultData } from '../core/result-data.ts'; +import { formatPlayVariantLabel } from './skin-text.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/player-web/src/scene/default/stage.test.ts b/packages/player-web/src/scene/default/stage.test.ts new file mode 100644 index 00000000..738658d2 --- /dev/null +++ b/packages/player-web/src/scene/default/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/player-web/src/scene/default/stage.ts b/packages/player-web/src/scene/default/stage.ts new file mode 100644 index 00000000..f6eef5de --- /dev/null +++ b/packages/player-web/src/scene/default/stage.ts @@ -0,0 +1,51 @@ +import type { BeMusicRect, BeMusicStage } from '../../skin/be-music/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/player-web/src/scene/default/synesthesia/gameplay-chrome.ts b/packages/player-web/src/scene/default/synesthesia/gameplay-chrome.ts index 57e81251..1925401c 100644 --- a/packages/player-web/src/scene/default/synesthesia/gameplay-chrome.ts +++ b/packages/player-web/src/scene/default/synesthesia/gameplay-chrome.ts @@ -1,5 +1,13 @@ import { Graphics, type Container } from 'pixi.js'; -import { BGA, DESIGN_HEIGHT, DESIGN_WIDTH, GROOVE } from '../../gameplay-constants.ts'; +import { GROOVE } from '../../gameplay-constants.ts'; +import { LOADING_WORD, loadingDots } from '../loading.ts'; +import type { BeMusicRect } from '../../../skin/be-music/types.ts'; +import { + STAGE_HEIGHT as DESIGN_HEIGHT, + STAGE_SIDE_COLUMN, + STAGE_WIDTH as DESIGN_WIDTH, + resolveStageBgaRect, +} from '../stage.ts'; import type { SkinlessGameplayChromeRenderContext, SkinlessGameplayChromeRuntime } from '../../gameplay-chrome.ts'; import { resolveSkinlessLaneLayout } from '../../gameplay-lanes.ts'; import type { ChildPool } from '../../pixi-utils.ts'; @@ -12,6 +20,7 @@ import { audioDrive, type AudioDrive } from '../audio-drive.ts'; import { createFlock, stepFlock, type Flock } from './boids.ts'; import { drawFrame, drawMagnetoOrb, drawPointCloud, drawReticle, drawSchool, sharedShapeBatch } from './draw.ts'; import { + clipSegmentOutsideRect, emberColor, hsvToHex, mixCamera, @@ -53,8 +62,9 @@ type Runtime = SkinlessGameplayChromeRuntime; */ 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: 256, h: 46 } as const; -const SCORE_PANEL = { x: 284, y: GAUGE_FRAME.y, w: DESIGN_WIDTH - HUD_MARGIN - 284, h: 466 - GAUGE_FRAME.y } 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; @@ -118,12 +128,13 @@ export function renderSynesthesiaChrome({ const pulse = (1 - beatPhase) ** 2; const hue = sceneHue(seconds, beatPhase); const accent = hsvToHex(hue, 0.62, 1); - const hasBga = runtime.hasBga === true; const { left: playfieldLeft, right: playfieldRight } = resolveSkinlessLaneLayout( runtime.laneChannels, runtime.laneCount, runtime.playVariant, ); + Object.assign(BGA, resolveStageBgaRect(playfieldRight)); + const hasBga = runtime.hasBga === true && BGA.w > 0; const space = layerPool.acquireGraphics(); space.label = 'synesthesia-gameplay/space'; @@ -138,7 +149,7 @@ export function renderSynesthesiaChrome({ // Camera: the background roams between random shots (flying, sometimes hard-cutting) with a handheld drift, // reframing floor, rivers, schools and warp. 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, 320, FLOOR.horizon, FLOOR.focal).y; + 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, onsets fire // warp streaks, rivers weave with the mids, the schools tighten on bass hits and scatter on transients. const drive = audioDrive(runtime.audio, runtime.effects); @@ -148,7 +159,7 @@ export function renderSynesthesiaChrome({ light.label = 'synesthesia-gameplay/light'; light.blendMode = 'add'; drawDust(light, seconds, pulse, hasBga, tier, hit, camera, drive); - drawFloor(light, seconds, pulse, tier, beatPhase, hit, camera, drive); + drawFloor(light, seconds, pulse, hasBga, tier, beatPhase, 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); @@ -164,7 +175,7 @@ export function renderSynesthesiaChrome({ drawSchool( light, schools[school]!, - (point) => projectPoint(viewPoint(point, camera), 320, FLOOR.horizon, FLOOR.focal), + (point) => projectPoint(viewPoint(point, camera), CENTER_X, FLOOR.horizon, FLOOR.focal), { alpha: 0.95, palette: SCHOOL_SPECS[school]!.palette, @@ -179,8 +190,8 @@ export function renderSynesthesiaChrome({ panels.label = 'synesthesia-gameplay/panels'; panels.blendMode = 'normal'; drawPlayfieldWell(panels, playfieldLeft, playfieldRight, runtime.progressRatio, accent); - // A double-play field reaches into the monitor's column; the frame and its idle screen would sit on the 2P lanes. - if (playfieldRight + 14 <= BGA.x) { + // 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'; @@ -189,16 +200,17 @@ export function renderSynesthesiaChrome({ drawGauge(panels, layer, runtime, accent, layerPool); drawSongPlate(panels, layer, runtime, accent, layerPool); drawScorePanel(panels, layer, runtime, accent, layerPool); - // Tucked close to the monitor frame so the counts get room for four digits. - const tallyX = BGA.x + BGA.w + 12; - if (playfieldRight + 14 <= tallyX) { - drawJudgeTally(panels, layer, runtime, tallyX, 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); - drawJudgements(overlayLayer, runtime, playfieldRight, seconds, overlayLayerPool); + if (runtime.loading) { + drawLoading(overlayLayer, runtime.nowMs ?? 0, (playfieldLeft + playfieldRight) / 2, accent, overlayLayerPool); + } else { + drawJudgements(overlayLayer, runtime, playfieldRight, seconds, overlayLayerPool); + } drawSynesthesiaMoments( layer, overlayLayer, @@ -210,10 +222,49 @@ export function renderSynesthesiaChrome({ ); } +/** 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 = { ...resolveStageBgaRect(227) }; + +/** 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, @@ -221,7 +272,7 @@ const IMPULSE_COLORS: Record<'white' | 'black' | 'scratch', number> = { }; /** Vanishing point the dust and streaks pour out of (behind the monitor). */ -const VANISH = { x: 320, y: FLOOR.horizon - 90 } as const; +const VANISH = { x: CENTER_X, y: FLOOR.horizon - 90 } as const; /** * Warm-black ground with an ember haze hugging the horizon (a stack of thin bands, so a live BGA stays untouched), plus @@ -279,7 +330,7 @@ function drawDust( const count = 220 + 50 * tier; const speed = (120 + 40 * pulse) * (1 + 0.45 * tier) * (1 + 2.2 * hit) * (1 + 1.2 * drive.level); for (let index = 0; index < count; index += 1) { - const point = starfieldPoint(index, seconds, { spread: 520, near: 20, far: 900, speed }); + const point = starfieldPoint(index, seconds, { spread: 640, near: 20, far: 900, speed }); const projected = projectPoint(viewPoint(point, camera), 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; @@ -324,6 +375,7 @@ function drawFloor( graphics: Graphics, seconds: number, pulse: number, + hasBga: boolean, tier: number, beatPhase: number, hit: number, @@ -332,13 +384,21 @@ function drawFloor( ): void { const { horizon, height, focal } = FLOOR; const glow = 1 + 0.3 * tier + 0.8 * hit + 1.2 * drive.bass; - const project = (x: number, z: number) => projectPoint(viewPoint({ x, y: height, z }, camera), 320, horizon, focal); - const vanish = vanishingPoint(camera, 320, horizon, focal); + const project = (x: number, z: number) => + projectPoint(viewPoint({ x, y: height, z }, camera), CENTER_X, horizon, focal); + const vanish = vanishingPoint(camera, CENTER_X, horizon, focal); + // 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 = project(x, 0); const far = project(x, 2400); if (!near.visible || !far.visible) continue; - graphics.moveTo(near.x, near.y).lineTo(far.x, far.y); + 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; @@ -351,21 +411,40 @@ function drawFloor( for (let x = -1500; x <= 1500; x += 50) { const point = project(x, z); 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(); - graphics.rect(0, vanish.y - 1, DESIGN_WIDTH, 2).fill({ color: SYN_AMBER, alpha: (0.22 + 0.2 * pulse) * glow }); + fillAroundBga(graphics, 0, vanish.y - 1, DESIGN_WIDTH, 2, SYN_AMBER, hasBga, (0.22 + 0.2 * pulse) * glow); if (tier >= 3) { for (const phase of [beatPhase, (beatPhase + 0.5) % 1]) { const rx = 30 + phase * 520; - graphics - .ellipse(vanish.x, vanish.y + 4 + phase * 60, rx, rx * 0.16) - .stroke({ color: SYN_AMBER, width: 1 + (1 - phase) * 2, alpha: 0.4 * (1 - phase) }); + const cy = vanish.y + 4 + phase * 60; + pieces.length = 0; + // The ring as a polyline, so the arcs crossing a live BGA can be dropped. + for (let step = 0; step < RING_STEPS; step += 1) { + const a0 = (step / RING_STEPS) * Math.PI * 2; + const a1 = ((step + 1) / RING_STEPS) * Math.PI * 2; + const x0 = vanish.x + Math.cos(a0) * rx; + const y0 = cy + Math.sin(a0) * rx * 0.16; + const x1 = vanish.x + Math.cos(a1) * rx; + const y1 = cy + Math.sin(a1) * rx * 0.16; + if (hasBga) clipSegmentOutsideRect(x0, y0, x1, y1, BGA, pieces); + else pieces.push(x0, y0, x1, y1); + } + 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_AMBER, width: 1 + (1 - phase) * 2, alpha: 0.4 * (1 - phase) }); } } } +/** Scratch buffer for clipped floor lines (`x0, y0, x1, y1` quads), reused every frame. */ +const FLOOR_PIECES: number[] = []; +const RING_STEPS = 48; + const RIVER_PARTICLES = 280; /** @@ -395,8 +474,8 @@ function drawRiver( 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), 320, FLOOR.horizon, FLOOR.focal); - const tail = projectPoint(toWorld({ ...local, x: local.x - 26 }), 320, FLOOR.horizon, FLOOR.focal); + 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; diff --git a/packages/player-web/src/scene/default/synesthesia/index.ts b/packages/player-web/src/scene/default/synesthesia/index.ts index d91da475..5e4ce04f 100644 --- a/packages/player-web/src/scene/default/synesthesia/index.ts +++ b/packages/player-web/src/scene/default/synesthesia/index.ts @@ -9,6 +9,7 @@ import { } from './playfield.ts'; import { synesthesiaResultSkin } from './result.ts'; import { synesthesiaSelectSkin } from './select.ts'; +import { wideStage } from '../stage.ts'; /** * Synesthesia — a skin after synaesthetic sound-and-light rhythm games: deep space, @@ -19,6 +20,7 @@ export const synesthesiaSkin: BeMusicSkin = { id: 'synesthesia', label: 'Synesthesia', fontLoads: ['400 16px "Michroma"', '300 12px "M PLUS 1p"', '500 12px "M PLUS 1p"'], + stage: wideStage, gameplay: { renderChrome: renderSynesthesiaChrome, renderLanes: renderSynesthesiaLanes, diff --git a/packages/player-web/src/scene/default/synesthesia/moments.ts b/packages/player-web/src/scene/default/synesthesia/moments.ts index 9a7c8e0b..8467c82b 100644 --- a/packages/player-web/src/scene/default/synesthesia/moments.ts +++ b/packages/player-web/src/scene/default/synesthesia/moments.ts @@ -1,5 +1,7 @@ import { Graphics, type Container } from 'pixi.js'; -import { BGA, DESIGN_HEIGHT, DESIGN_WIDTH, GROOVE, PLAYFIELD } from '../../gameplay-constants.ts'; +import { GROOVE, PLAYFIELD } from '../../gameplay-constants.ts'; +import type { BeMusicRect } from '../../../skin/be-music/types.ts'; +import { STAGE_HEIGHT as DESIGN_HEIGHT, STAGE_WIDTH as DESIGN_WIDTH, resolveStageBgaRect } from '../stage.ts'; import type { SkinlessGameplayChromeRuntime } from '../../gameplay-chrome.ts'; import type { ChildPool } from '../../pixi-utils.ts'; import { addHudText } from '../hud-text.ts'; @@ -77,6 +79,7 @@ export function drawSynesthesiaMoments( moments.milestone.value, milestone, resolveMilestoneArea(playfieldRight), + resolveStageBgaRect(playfieldRight), runtime.hasBga === true, pool, ); @@ -166,6 +169,7 @@ function drawMilestone( value: number, t: number, area: MilestoneArea, + bga: BeMusicRect, hasBga: boolean, pool: ChildPool, ): void { @@ -173,10 +177,12 @@ function drawMilestone( 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. - const below = area.mode === 'below'; + // 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 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; // A shockwave of light rolls out from the count, kept inside the area. const wave = easeOutCubic(Math.min(1, t / 0.7)); const rx = 20 + (area.w / 2 - 20) * wave; diff --git a/packages/player-web/src/scene/default/synesthesia/result.ts b/packages/player-web/src/scene/default/synesthesia/result.ts index 7b48a09c..08461a41 100644 --- a/packages/player-web/src/scene/default/synesthesia/result.ts +++ b/packages/player-web/src/scene/default/synesthesia/result.ts @@ -4,6 +4,7 @@ import { easeOutBack, easeOutCubic, rollUpValue, stageProgress } from '../phanto import { addSkinText, type SkinTextOptions } from '../skin-text.ts'; import { hash01 } from '../phantom-style.ts'; import { drawFrame, drawReticle } from './draw.ts'; +import { resolveResultLamp, resolveResultTrackRows } from '../result-track.ts'; import { burstParticlePosition, burstParticles, @@ -50,6 +51,9 @@ 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); @@ -176,21 +180,6 @@ export function renderSynesthesiaResult(frame: BeMusicResultFrame): void { alpha: verdictT, dropShadow: { color: verdictColor, distance: 0, blur: 12, alpha: 1 }, }); - text( - verdict.root, - `${result.song.title}${result.song.artist ? ` / ${result.song.artist}` : ''}`, - designWidth - MARGIN, - 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'); @@ -333,9 +322,9 @@ export function renderSynesthesiaResult(frame: BeMusicResultFrame): void { const graphs = group('graphs'); // The two filaments share the judgement rows' top and bottom lines. const graphX = GRAPHS_X + 14; - const graphW = designWidth - MARGIN - 14 - graphX; + const graphW = GRID_RIGHT - 14 - graphX; const graphH = (ROWS_BOTTOM - ROWS_TOP - 24) / 2; - glass(graphs.g, GRAPHS_X, BOTTOM_TOP, designWidth - MARGIN - GRAPHS_X, BOTTOM_H, accent); + 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)); @@ -361,6 +350,59 @@ export function renderSynesthesiaResult(frame: BeMusicResultFrame): void { ); 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 }); diff --git a/packages/player-web/src/scene/default/synesthesia/space.test.ts b/packages/player-web/src/scene/default/synesthesia/space.test.ts index c4db43e5..321274f3 100644 --- a/packages/player-web/src/scene/default/synesthesia/space.test.ts +++ b/packages/player-web/src/scene/default/synesthesia/space.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vite-plus/test'; import { burstParticlePosition, + clipSegmentOutsideRect, burstParticles, emberColor, fibonacciSphere, @@ -369,3 +370,37 @@ describe('reflectedLight', () => { 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]); + }); +}); diff --git a/packages/player-web/src/scene/default/synesthesia/space.ts b/packages/player-web/src/scene/default/synesthesia/space.ts index 3f4058b8..999685aa 100644 --- a/packages/player-web/src/scene/default/synesthesia/space.ts +++ b/packages/player-web/src/scene/default/synesthesia/space.ts @@ -483,3 +483,64 @@ export function reflectedLight( 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/gameplay-chrome.ts b/packages/player-web/src/scene/gameplay-chrome.ts index 99a64a83..ab639111 100644 --- a/packages/player-web/src/scene/gameplay-chrome.ts +++ b/packages/player-web/src/scene/gameplay-chrome.ts @@ -67,6 +67,8 @@ export interface SkinlessGameplayChromeRuntime { * 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. */ 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/skin/be-music/types.ts b/packages/player-web/src/skin/be-music/types.ts index b1bc8003..047f9ec2 100644 --- a/packages/player-web/src/skin/be-music/types.ts +++ b/packages/player-web/src/skin/be-music/types.ts @@ -8,7 +8,7 @@ import type { AudioFeatures } from '../../runtime/audio-analysis.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 + * renderer functions over a fixed design canvas ({@link BeMusicSkin.stage}, 640x480 unless the skin declares another): 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 @@ -24,11 +24,35 @@ export interface BeMusicSkin { * 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. Omitted: the LR2-compatible 640x480 canvas with + * the fixed 256 px BGA square. + */ + readonly stage?: BeMusicStage; readonly gameplay: BeMusicGameplaySkin; readonly select: BeMusicSelectSkin; readonly result: BeMusicResultSkin; } +/** 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 From c6a38b8dd7014b15afe95ce80ed01d50113c92bf Mon Sep 17 00:00:00 2001 From: Seiya KONNO Date: Fri, 9 Oct 2026 13:31:28 +0900 Subject: [PATCH 35/65] feat(player-web): build the built-in skins on a public skin SDK Add the @be-music/player-web/skin-sdk subpath with the skin types, defineBeMusicSkin, layout, text, particle, moment, audio, song-fact, and motion helpers. Phantom, Synesthesia, and Lattice move to src/skins and import only the SDK, Pixi, and their own files, enforced by a boundary test. Skins declare an apiVersion, their own semantic version, and author metadata, the registry skips skins it cannot run, and renderChrome receives the host-resolved layout. --- .changeset/be-music-skins.md | 3 + packages/player-web/package.json | 5 + .../player-web/src/scene/core/gameplay.ts | 36 ++++- packages/player-web/src/scene/core/result.ts | 2 +- packages/player-web/src/scene/core/select.ts | 2 +- .../player-web/src/scene/default/gameplay.ts | 2 +- .../player-web/src/scene/default/index.ts | 10 +- .../default => skin-sdk}/audio-drive.test.ts | 2 +- .../default => skin-sdk}/audio-drive.ts | 2 +- .../player-web/src/skin-sdk/define.test.ts | 72 +++++++++ packages/player-web/src/skin-sdk/define.ts | 78 ++++++++++ packages/player-web/src/skin-sdk/fonts.ts | 5 + .../{scene/default => skin-sdk}/hud-text.ts | 6 +- packages/player-web/src/skin-sdk/index.ts | 44 ++++++ .../default => skin-sdk}/judge-word.test.ts | 0 .../{scene/default => skin-sdk}/judge-word.ts | 0 .../default => skin-sdk}/key-beam.test.ts | 0 .../{scene/default => skin-sdk}/key-beam.ts | 0 .../player-web/src/skin-sdk/layout.test.ts | 43 +++++ packages/player-web/src/skin-sdk/layout.ts | 56 +++++++ .../default => skin-sdk}/loading.test.ts | 0 .../{scene/default => skin-sdk}/loading.ts | 0 .../default => skin-sdk}/moments.test.ts | 0 .../{scene/default => skin-sdk}/moments.ts | 0 .../player-web/src/skin-sdk/motion.test.ts | 56 +++++++ packages/player-web/src/skin-sdk/motion.ts | 37 +++++ .../default => skin-sdk}/particle-layer.ts | 0 .../default => skin-sdk}/result-track.test.ts | 2 +- .../default => skin-sdk}/result-track.ts | 2 +- .../{scene/default => skin-sdk}/skin-text.ts | 6 +- .../default => skin-sdk}/song-stats.test.ts | 0 .../{scene/default => skin-sdk}/song-stats.ts | 2 +- .../{scene/default => skin-sdk}/stage.test.ts | 0 .../src/{scene/default => skin-sdk}/stage.ts | 2 +- .../player-web/src/skin-sdk/tabular.test.ts | 29 ++++ packages/player-web/src/skin-sdk/tabular.ts | 33 ++++ .../default => skin-sdk}/text-metrics.test.ts | 0 .../default => skin-sdk}/text-metrics.ts | 0 .../src/skin/be-music/registry.test.ts | 25 ++- .../player-web/src/skin/be-music/registry.ts | 34 +++- .../player-web/src/skin/be-music/types.ts | 67 +++++++- .../player-web/src/skins/boundary.test.ts | 49 ++++++ .../built-in-skins.ts => skins/built-in.ts} | 2 +- .../{scene/default => skins}/lattice/draw.ts | 12 +- .../default => skins}/lattice/field.test.ts | 0 .../{scene/default => skins}/lattice/field.ts | 2 +- .../lattice/gameplay-chrome.ts | 112 +++++++------ .../{scene/default => skins}/lattice/index.ts | 14 +- .../player-web/src/skins/lattice/layout.ts | 4 + .../default => skins}/lattice/moments.ts | 36 +++-- .../default => skins}/lattice/physics.test.ts | 0 .../default => skins}/lattice/physics.ts | 0 .../default => skins}/lattice/playfield.ts | 24 +-- .../default => skins}/lattice/result.ts | 15 +- .../default => skins}/lattice/select.ts | 30 ++-- .../{scene/default => skins}/lattice/style.ts | 0 .../phantom/chrome.ts} | 147 ++++++++++-------- .../{scene/default => skins/phantom}/fonts.ts | 5 +- .../{scene/default => skins}/phantom/index.ts | 19 ++- .../player-web/src/skins/phantom/layout.ts | 4 + .../default => skins}/phantom/moments.ts | 50 +++--- .../default => skins}/phantom/playfield.ts | 16 +- .../default => skins}/phantom/result.ts | 27 ++-- .../default => skins}/phantom/select.ts | 39 +++-- .../phantom/style.test.ts} | 89 +---------- .../phantom/style.ts} | 69 +------- .../default => skins}/phantom/tear.test.ts | 2 +- .../{scene/default => skins}/phantom/tear.ts | 8 +- .../synesthesia/boids.test.ts | 0 .../default => skins}/synesthesia/boids.ts | 2 +- .../synesthesia/burst-budget.test.ts | 0 .../synesthesia/burst-budget.ts | 0 .../synesthesia/draw.test.ts | 0 .../default => skins}/synesthesia/draw.ts | 5 +- .../synesthesia/gameplay-chrome.ts | 80 +++++----- .../default => skins}/synesthesia/index.ts | 14 +- .../src/skins/synesthesia/layout.ts | 4 + .../default => skins}/synesthesia/moments.ts | 40 +++-- .../synesthesia/playfield.ts | 25 +-- .../default => skins}/synesthesia/result.ts | 18 ++- .../default => skins}/synesthesia/select.ts | 33 ++-- .../synesthesia/space.test.ts | 0 .../default => skins}/synesthesia/space.ts | 2 +- .../default => skins}/synesthesia/style.ts | 0 packages/player-web/vite.config.ts | 2 + 85 files changed, 1125 insertions(+), 538 deletions(-) rename packages/player-web/src/{scene/default => skin-sdk}/audio-drive.test.ts (96%) rename packages/player-web/src/{scene/default => skin-sdk}/audio-drive.ts (98%) create mode 100644 packages/player-web/src/skin-sdk/define.test.ts create mode 100644 packages/player-web/src/skin-sdk/define.ts create mode 100644 packages/player-web/src/skin-sdk/fonts.ts rename packages/player-web/src/{scene/default => skin-sdk}/hud-text.ts (97%) create mode 100644 packages/player-web/src/skin-sdk/index.ts rename packages/player-web/src/{scene/default => skin-sdk}/judge-word.test.ts (100%) rename packages/player-web/src/{scene/default => skin-sdk}/judge-word.ts (100%) rename packages/player-web/src/{scene/default => skin-sdk}/key-beam.test.ts (100%) rename packages/player-web/src/{scene/default => skin-sdk}/key-beam.ts (100%) create mode 100644 packages/player-web/src/skin-sdk/layout.test.ts create mode 100644 packages/player-web/src/skin-sdk/layout.ts rename packages/player-web/src/{scene/default => skin-sdk}/loading.test.ts (100%) rename packages/player-web/src/{scene/default => skin-sdk}/loading.ts (100%) rename packages/player-web/src/{scene/default => skin-sdk}/moments.test.ts (100%) rename packages/player-web/src/{scene/default => skin-sdk}/moments.ts (100%) create mode 100644 packages/player-web/src/skin-sdk/motion.test.ts create mode 100644 packages/player-web/src/skin-sdk/motion.ts rename packages/player-web/src/{scene/default => skin-sdk}/particle-layer.ts (100%) rename packages/player-web/src/{scene/default => skin-sdk}/result-track.test.ts (96%) rename packages/player-web/src/{scene/default => skin-sdk}/result-track.ts (95%) rename packages/player-web/src/{scene/default => skin-sdk}/skin-text.ts (95%) rename packages/player-web/src/{scene/default => skin-sdk}/song-stats.test.ts (100%) rename packages/player-web/src/{scene/default => skin-sdk}/song-stats.ts (98%) rename packages/player-web/src/{scene/default => skin-sdk}/stage.test.ts (100%) rename packages/player-web/src/{scene/default => skin-sdk}/stage.ts (96%) create mode 100644 packages/player-web/src/skin-sdk/tabular.test.ts create mode 100644 packages/player-web/src/skin-sdk/tabular.ts rename packages/player-web/src/{scene/default => skin-sdk}/text-metrics.test.ts (100%) rename packages/player-web/src/{scene/default => skin-sdk}/text-metrics.ts (100%) create mode 100644 packages/player-web/src/skins/boundary.test.ts rename packages/player-web/src/{scene/default/built-in-skins.ts => skins/built-in.ts} (84%) rename packages/player-web/src/{scene/default => skins}/lattice/draw.ts (97%) rename packages/player-web/src/{scene/default => skins}/lattice/field.test.ts (100%) rename packages/player-web/src/{scene/default => skins}/lattice/field.ts (99%) rename packages/player-web/src/{scene/default => skins}/lattice/gameplay-chrome.ts (94%) rename packages/player-web/src/{scene/default => skins}/lattice/index.ts (70%) create mode 100644 packages/player-web/src/skins/lattice/layout.ts rename packages/player-web/src/{scene/default => skins}/lattice/moments.ts (93%) rename packages/player-web/src/{scene/default => skins}/lattice/physics.test.ts (100%) rename packages/player-web/src/{scene/default => skins}/lattice/physics.ts (100%) rename packages/player-web/src/{scene/default => skins}/lattice/playfield.ts (96%) rename packages/player-web/src/{scene/default => skins}/lattice/result.ts (97%) rename packages/player-web/src/{scene/default => skins}/lattice/select.ts (97%) rename packages/player-web/src/{scene/default => skins}/lattice/style.ts (100%) rename packages/player-web/src/{scene/default/gameplay-render.ts => skins/phantom/chrome.ts} (91%) rename packages/player-web/src/{scene/default => skins/phantom}/fonts.ts (65%) rename packages/player-web/src/{scene/default => skins}/phantom/index.ts (58%) create mode 100644 packages/player-web/src/skins/phantom/layout.ts rename packages/player-web/src/{scene/default => skins}/phantom/moments.ts (92%) rename packages/player-web/src/{scene/default => skins}/phantom/playfield.ts (97%) rename packages/player-web/src/{scene/default => skins}/phantom/result.ts (98%) rename packages/player-web/src/{scene/default => skins}/phantom/select.ts (97%) rename packages/player-web/src/{scene/default/phantom-style.test.ts => skins/phantom/style.test.ts} (65%) rename packages/player-web/src/{scene/default/phantom-style.ts => skins/phantom/style.ts} (79%) rename packages/player-web/src/{scene/default => skins}/phantom/tear.test.ts (97%) rename packages/player-web/src/{scene/default => skins}/phantom/tear.ts (97%) rename packages/player-web/src/{scene/default => skins}/synesthesia/boids.test.ts (100%) rename packages/player-web/src/{scene/default => skins}/synesthesia/boids.ts (99%) rename packages/player-web/src/{scene/default => skins}/synesthesia/burst-budget.test.ts (100%) rename packages/player-web/src/{scene/default => skins}/synesthesia/burst-budget.ts (100%) rename packages/player-web/src/{scene/default => skins}/synesthesia/draw.test.ts (100%) rename packages/player-web/src/{scene/default => skins}/synesthesia/draw.ts (99%) rename packages/player-web/src/{scene/default => skins}/synesthesia/gameplay-chrome.ts (95%) rename packages/player-web/src/{scene/default => skins}/synesthesia/index.ts (66%) create mode 100644 packages/player-web/src/skins/synesthesia/layout.ts rename packages/player-web/src/{scene/default => skins}/synesthesia/moments.ts (92%) rename packages/player-web/src/{scene/default => skins}/synesthesia/playfield.ts (97%) rename packages/player-web/src/{scene/default => skins}/synesthesia/result.ts (97%) rename packages/player-web/src/{scene/default => skins}/synesthesia/select.ts (97%) rename packages/player-web/src/{scene/default => skins}/synesthesia/space.test.ts (100%) rename packages/player-web/src/{scene/default => skins}/synesthesia/space.ts (99%) rename packages/player-web/src/{scene/default => skins}/synesthesia/style.ts (100%) diff --git a/.changeset/be-music-skins.md b/.changeset/be-music-skins.md index 4cb975fa..af4b2e84 100644 --- a/.changeset/be-music-skins.md +++ b/.changeset/be-music-skins.md @@ -7,6 +7,9 @@ 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 a fixed design canvas (the skin's `stage`, 640x480 unless it declares another). 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). - New exports: `BeMusicSkin` and its context types, `createBeMusicSkinRegistry`, `resolveBeMusicLaneKind`, `resolveSelectListWindow`, `phantomSkin`, `synesthesiaSkin`, `latticeSkin`, and `BUILT_IN_BE_MUSIC_SKINS`. +- Skins can be made outside the player: the new `@be-music/player-web/skin-sdk` subpath carries everything a skin is written against — the skin types, `defineBeMusicSkin`, the stage and layout helpers, pooled HUD text, GPU point particles, judgement words, moments, the loading caption, audio drive, song facts, the result lamp, and motion helpers. The three built-in skins now live under `src/skins/` and import nothing but this SDK, Pixi, and their own files (a test enforces the boundary), so a third-party skin can do anything they do. +- 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. +- A skin's `renderChrome` now receives the frame's `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 and still the default. 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 lit by an ember horizon, a world built from fine ember / gold particles with electric blue and magenta accents, data dust and speed streaks pouring out of the vanishing point, 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 — tilted shockwave rings, 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 a 3D particle ring that bursts 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. diff --git a/packages/player-web/package.json b/packages/player-web/package.json index a73b8d1e..5444e4c1 100644 --- a/packages/player-web/package.json +++ b/packages/player-web/package.json @@ -39,6 +39,11 @@ "types": "./dist/runtime.d.ts", "source": "./src/runtime/index.ts", "default": "./dist/runtime.js" + }, + "./skin-sdk": { + "types": "./dist/skin-sdk.d.ts", + "source": "./src/skin-sdk/index.ts", + "default": "./dist/skin-sdk.js" } }, "scripts": { diff --git a/packages/player-web/src/scene/core/gameplay.ts b/packages/player-web/src/scene/core/gameplay.ts index 231353f2..75b96307 100644 --- a/packages/player-web/src/scene/core/gameplay.ts +++ b/packages/player-web/src/scene/core/gameplay.ts @@ -135,12 +135,14 @@ import { CORE_TEXT_FONT } from './fonts.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 { resolveGameplayLayout } from '../../skin-sdk/layout.ts'; +import { phantomSkin } from '../../skins/phantom/index.ts'; import { resolveDesignTextResolution, resolveScaledViewport, setDesignTextResolution } from './viewport.ts'; import type { PixiGameplayResultData } from './result-data.ts'; import { logger } from '../../logger.ts'; @@ -238,6 +240,8 @@ export interface GameplayStageSize { /** 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 { @@ -596,6 +600,10 @@ export class CoreGameplayView = {}): 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: [], + gameplay: {} as BeMusicSkin['gameplay'], + select: {} as BeMusicSkin['select'], + result: {} as BeMusicSkin['result'], + ...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 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/player-web/src/skin-sdk/define.ts b/packages/player-web/src/skin-sdk/define.ts new file mode 100644 index 00000000..43924162 --- /dev/null +++ b/packages/player-web/src/skin-sdk/define.ts @@ -0,0 +1,78 @@ +import type { BeMusicSkin } from '../skin/be-music/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 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`); + } + 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: [], + * stage: wideStage, + * gameplay, select, result, + * }); + * ``` + */ +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/player-web/src/skin-sdk/fonts.ts b/packages/player-web/src/skin-sdk/fonts.ts new file mode 100644 index 00000000..b2da9f6c --- /dev/null +++ b/packages/player-web/src/skin-sdk/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/player-web/src/scene/default/hud-text.ts b/packages/player-web/src/skin-sdk/hud-text.ts similarity index 97% rename from packages/player-web/src/scene/default/hud-text.ts rename to packages/player-web/src/skin-sdk/hud-text.ts index 46c82d8b..9b6b7261 100644 --- a/packages/player-web/src/scene/default/hud-text.ts +++ b/packages/player-web/src/skin-sdk/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 type { ChildPool } from '../scene/pixi-utils.ts'; +import { getDesignTextResolution } from '../scene/core/viewport.ts'; import { DEFAULT_TEXT_FONT } from './fonts.ts'; -import { layoutTabularRun } from './phantom-style.ts'; +import { layoutTabularRun } from './tabular.ts'; import { alignCapCenter } from './text-metrics.ts'; /** diff --git a/packages/player-web/src/skin-sdk/index.ts b/packages/player-web/src/skin-sdk/index.ts new file mode 100644 index 00000000..e4f79c42 --- /dev/null +++ b/packages/player-web/src/skin-sdk/index.ts @@ -0,0 +1,44 @@ +/** + * `@be-music/player-web/skin-sdk` — everything a be-music skin is written against. The built-in skins (Phantom, + * Synesthesia, Lattice) import nothing else from the player, so a third-party skin can do whatever they do. + * + * - 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) and the `BeMusicGameplayLayout` each chrome + * frame receives, plus {@link resolveMilestoneArea} for placing cut-ins clear of the lanes. + * - Drawing: pooled HUD text (`addHudText` / `addHudNumber`), skin text and hit areas (`addSkinText`, `addHitArea`), + * cap-height alignment, GPU point particles (`pointLayerFor`), and key-beam gradients. + * - 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. + * - Motion: easing, staged timelines, roll-up counters, and a deterministic hash for jitter. + * + * Skins draw with Pixi (`pixi.js` is a peer dependency of the player). + */ +export type * from '../skin/be-music/types.ts'; +export type { BrowserBrowseEntry, BrowserFolderNode, BrowserSongEntry } from '../collection/types.ts'; +export type { PixiGameplayResultData } from '../scene/core/result-data.ts'; +export type { + SkinlessGameplayChromeRenderContext, + SkinlessGameplayChromeRuntime, + SkinlessGameplayJudgeState, +} from '../scene/gameplay-chrome.ts'; +export { ChildPool } from '../scene/pixi-utils.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 './hud-text.ts'; +export * from './skin-text.ts'; +export * from './text-metrics.ts'; +export * from './particle-layer.ts'; +export * from './key-beam.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/player-web/src/scene/default/judge-word.test.ts b/packages/player-web/src/skin-sdk/judge-word.test.ts similarity index 100% rename from packages/player-web/src/scene/default/judge-word.test.ts rename to packages/player-web/src/skin-sdk/judge-word.test.ts diff --git a/packages/player-web/src/scene/default/judge-word.ts b/packages/player-web/src/skin-sdk/judge-word.ts similarity index 100% rename from packages/player-web/src/scene/default/judge-word.ts rename to packages/player-web/src/skin-sdk/judge-word.ts diff --git a/packages/player-web/src/scene/default/key-beam.test.ts b/packages/player-web/src/skin-sdk/key-beam.test.ts similarity index 100% rename from packages/player-web/src/scene/default/key-beam.test.ts rename to packages/player-web/src/skin-sdk/key-beam.test.ts diff --git a/packages/player-web/src/scene/default/key-beam.ts b/packages/player-web/src/skin-sdk/key-beam.ts similarity index 100% rename from packages/player-web/src/scene/default/key-beam.ts rename to packages/player-web/src/skin-sdk/key-beam.ts diff --git a/packages/player-web/src/skin-sdk/layout.test.ts b/packages/player-web/src/skin-sdk/layout.test.ts new file mode 100644 index 00000000..c09d0b77 --- /dev/null +++ b/packages/player-web/src/skin-sdk/layout.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vite-plus/test'; +import { resolveGameplayLayout } 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(); + }); +}); diff --git a/packages/player-web/src/skin-sdk/layout.ts b/packages/player-web/src/skin-sdk/layout.ts new file mode 100644 index 00000000..dec7301b --- /dev/null +++ b/packages/player-web/src/skin-sdk/layout.ts @@ -0,0 +1,56 @@ +import type { ChartPlayVariant } from '@be-music/player/core/lane-layout'; +import { PLAYFIELD } from '../scene/gameplay-constants.ts'; +import { resolveLr2LaneIndex, resolveSkinlessLaneLayout } from '../scene/gameplay-lanes.ts'; +import { resolveBeMusicLaneKind } from '../skin/be-music/registry.ts'; +import type { BeMusicGameplayLayout, BeMusicStage } from '../skin/be-music/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, + }; +} diff --git a/packages/player-web/src/scene/default/loading.test.ts b/packages/player-web/src/skin-sdk/loading.test.ts similarity index 100% rename from packages/player-web/src/scene/default/loading.test.ts rename to packages/player-web/src/skin-sdk/loading.test.ts diff --git a/packages/player-web/src/scene/default/loading.ts b/packages/player-web/src/skin-sdk/loading.ts similarity index 100% rename from packages/player-web/src/scene/default/loading.ts rename to packages/player-web/src/skin-sdk/loading.ts diff --git a/packages/player-web/src/scene/default/moments.test.ts b/packages/player-web/src/skin-sdk/moments.test.ts similarity index 100% rename from packages/player-web/src/scene/default/moments.test.ts rename to packages/player-web/src/skin-sdk/moments.test.ts diff --git a/packages/player-web/src/scene/default/moments.ts b/packages/player-web/src/skin-sdk/moments.ts similarity index 100% rename from packages/player-web/src/scene/default/moments.ts rename to packages/player-web/src/skin-sdk/moments.ts diff --git a/packages/player-web/src/skin-sdk/motion.test.ts b/packages/player-web/src/skin-sdk/motion.test.ts new file mode 100644 index 00000000..84a83bdd --- /dev/null +++ b/packages/player-web/src/skin-sdk/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/player-web/src/skin-sdk/motion.ts b/packages/player-web/src/skin-sdk/motion.ts new file mode 100644 index 00000000..a6492ae9 --- /dev/null +++ b/packages/player-web/src/skin-sdk/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/player-web/src/scene/default/particle-layer.ts b/packages/player-web/src/skin-sdk/particle-layer.ts similarity index 100% rename from packages/player-web/src/scene/default/particle-layer.ts rename to packages/player-web/src/skin-sdk/particle-layer.ts diff --git a/packages/player-web/src/scene/default/result-track.test.ts b/packages/player-web/src/skin-sdk/result-track.test.ts similarity index 96% rename from packages/player-web/src/scene/default/result-track.test.ts rename to packages/player-web/src/skin-sdk/result-track.test.ts index e9063b3e..3ea7e59b 100644 --- a/packages/player-web/src/scene/default/result-track.test.ts +++ b/packages/player-web/src/skin-sdk/result-track.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vite-plus/test'; -import type { PixiGameplayResultData } from '../core/result-data.ts'; +import type { PixiGameplayResultData } from '../scene/core/result-data.ts'; import { resolveResultLamp, resolveResultTrackRows } from './result-track.ts'; function score(partial: Partial): PixiGameplayResultData['score'] { diff --git a/packages/player-web/src/scene/default/result-track.ts b/packages/player-web/src/skin-sdk/result-track.ts similarity index 95% rename from packages/player-web/src/scene/default/result-track.ts rename to packages/player-web/src/skin-sdk/result-track.ts index d6e84eb2..aa0a86f0 100644 --- a/packages/player-web/src/scene/default/result-track.ts +++ b/packages/player-web/src/skin-sdk/result-track.ts @@ -1,4 +1,4 @@ -import type { PixiGameplayResultData } from '../core/result-data.ts'; +import type { PixiGameplayResultData } from '../scene/core/result-data.ts'; import { formatPlayVariantLabel } from './skin-text.ts'; /** diff --git a/packages/player-web/src/scene/default/skin-text.ts b/packages/player-web/src/skin-sdk/skin-text.ts similarity index 95% rename from packages/player-web/src/scene/default/skin-text.ts rename to packages/player-web/src/skin-sdk/skin-text.ts index 92e27316..5f611999 100644 --- a/packages/player-web/src/scene/default/skin-text.ts +++ b/packages/player-web/src/skin-sdk/skin-text.ts @@ -1,7 +1,7 @@ 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 { resolveChartPlayVariant } from '../collection/collection.ts'; +import type { BrowserSongEntry } from '../collection/types.ts'; +import { getDesignTextResolution } from '../scene/core/viewport.ts'; import { DEFAULT_TEXT_FONT } from './fonts.ts'; import { alignCapCenter } from './text-metrics.ts'; diff --git a/packages/player-web/src/scene/default/song-stats.test.ts b/packages/player-web/src/skin-sdk/song-stats.test.ts similarity index 100% rename from packages/player-web/src/scene/default/song-stats.test.ts rename to packages/player-web/src/skin-sdk/song-stats.test.ts diff --git a/packages/player-web/src/scene/default/song-stats.ts b/packages/player-web/src/skin-sdk/song-stats.ts similarity index 98% rename from packages/player-web/src/scene/default/song-stats.ts rename to packages/player-web/src/skin-sdk/song-stats.ts index 28e6f767..d67a51e6 100644 --- a/packages/player-web/src/scene/default/song-stats.ts +++ b/packages/player-web/src/skin-sdk/song-stats.ts @@ -1,7 +1,7 @@ import type { BeMusicJson } from '@be-music/json'; import { createTimingResolver } from '@be-music/audio-renderer/triggers'; import { extractLandmineNotes, extractPlayableNotes } from '@be-music/player/playable-notes'; -import type { BrowserSongEntry } from '../../collection/types.ts'; +import type { BrowserSongEntry } from '../collection/types.ts'; /** * What a player wants to know about a chart before starting it, beyond its title and level: how long it runs, the diff --git a/packages/player-web/src/scene/default/stage.test.ts b/packages/player-web/src/skin-sdk/stage.test.ts similarity index 100% rename from packages/player-web/src/scene/default/stage.test.ts rename to packages/player-web/src/skin-sdk/stage.test.ts diff --git a/packages/player-web/src/scene/default/stage.ts b/packages/player-web/src/skin-sdk/stage.ts similarity index 96% rename from packages/player-web/src/scene/default/stage.ts rename to packages/player-web/src/skin-sdk/stage.ts index f6eef5de..5323c9a2 100644 --- a/packages/player-web/src/scene/default/stage.ts +++ b/packages/player-web/src/skin-sdk/stage.ts @@ -1,4 +1,4 @@ -import type { BeMusicRect, BeMusicStage } from '../../skin/be-music/types.ts'; +import type { BeMusicRect, BeMusicStage } from '../skin/be-music/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 diff --git a/packages/player-web/src/skin-sdk/tabular.test.ts b/packages/player-web/src/skin-sdk/tabular.test.ts new file mode 100644 index 00000000..8e7f13aa --- /dev/null +++ b/packages/player-web/src/skin-sdk/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/player-web/src/skin-sdk/tabular.ts b/packages/player-web/src/skin-sdk/tabular.ts new file mode 100644 index 00000000..4229b5c6 --- /dev/null +++ b/packages/player-web/src/skin-sdk/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/player-web/src/scene/default/text-metrics.test.ts b/packages/player-web/src/skin-sdk/text-metrics.test.ts similarity index 100% rename from packages/player-web/src/scene/default/text-metrics.test.ts rename to packages/player-web/src/skin-sdk/text-metrics.test.ts diff --git a/packages/player-web/src/scene/default/text-metrics.ts b/packages/player-web/src/skin-sdk/text-metrics.ts similarity index 100% rename from packages/player-web/src/scene/default/text-metrics.ts rename to packages/player-web/src/skin-sdk/text-metrics.ts 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..a2ee35d3 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,14 @@ 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' }, + } as unknown as BeMusicSkin; } describe('createBeMusicSkinRegistry', () => { @@ -18,6 +24,21 @@ 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('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..f63c2b63 100644 --- a/packages/player-web/src/skin/be-music/registry.ts +++ b/packages/player-web/src/skin/be-music/registry.ts @@ -1,6 +1,10 @@ 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 } from '../../skin-sdk/define.ts'; +import { logger } from '../../logger.ts'; + +const log = logger('be-music-skin'); export interface BeMusicSkinRegistry { readonly skins: readonly BeMusicSkin[]; @@ -8,11 +12,35 @@ export interface BeMusicSkinRegistry { resolve(id: string | undefined): BeMusicSkin; } -/** Registry over a fixed, non-empty skin list. The first entry is the fallback for unknown ids. */ -export function createBeMusicSkinRegistry(skins: readonly BeMusicSkin[]): BeMusicSkinRegistry { +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 skin list. 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) { diff --git a/packages/player-web/src/skin/be-music/types.ts b/packages/player-web/src/skin/be-music/types.ts index 047f9ec2..fbdec4c4 100644 --- a/packages/player-web/src/skin/be-music/types.ts +++ b/packages/player-web/src/skin/be-music/types.ts @@ -1,6 +1,6 @@ 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 { SkinlessGameplayChromeRenderContext } from '../../scene/gameplay-chrome.ts'; import type { PixiGameplayResultData } from '../../scene/core/result-data.ts'; import type { ChildPool } from '../../scene/pixi-utils.ts'; import type { AudioFeatures } from '../../runtime/audio-analysis.ts'; @@ -13,12 +13,30 @@ import type { AudioFeatures } from '../../runtime/audio-analysis.ts'; * * 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. + * + * Skins are written against the public `@be-music/player-web/skin-sdk` subpath (the built-in skins included) and + * declared with its `defineBeMusicSkin`, which checks {@link BeMusicSkin.apiVersion} and the metadata. */ 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. @@ -34,6 +52,13 @@ export interface BeMusicSkin { 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; +} + /** Axis-aligned rectangle in design pixels. */ export interface BeMusicRect { x: number; @@ -71,9 +96,47 @@ export type BeMusicLaneKind = 'white' | 'black' | 'scratch'; */ export type BeMusicAudioFrame = AudioFeatures; +/** 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; +} + +/** What {@link BeMusicGameplaySkin.renderChrome} receives: the layers and runtime values, plus the frame's layout. */ +export interface BeMusicChromeContext extends SkinlessGameplayChromeRenderContext { + layout: BeMusicGameplayLayout; +} + export interface BeMusicGameplaySkin { /** HUD chrome around the playfield (header, gauge, score, BGA frame, judgement / combo text). */ - readonly renderChrome: SkinlessGameplayChromeRenderer; + readonly renderChrome: (context: BeMusicChromeContext) => void; /** 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. */ 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..139b58a7 --- /dev/null +++ b/packages/player-web/src/skins/boundary.test.ts @@ -0,0 +1,49 @@ +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 barrel, Pixi, and + * files inside its own folder (tests may also use the test runner and Node built-ins). This keeps the SDK honest — if a + * built-in skin needs something, the SDK has to offer it. + */ +const SKINS_DIR = import.meta.dirname; +const SDK_BARREL = resolve(SKINS_DIR, '../skin-sdk/index.ts'); +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]!; + if (specifier === 'pixi.js') continue; + if (isTest && (specifier === 'vite-plus/test' || specifier.startsWith('node:'))) continue; + if (specifier.startsWith('.')) { + const target = resolve(dirname(file), specifier); + if (target === SDK_BARREL || target.startsWith(skinDir + sep)) 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', 'synesthesia']); + }); + + it.each(skins.map((entry) => entry.name))('%s imports only the skin SDK, pixi.js, and its own files', (name) => { + expect(listFiles(join(SKINS_DIR, name)).flatMap(violations)).toEqual([]); + }); +}); diff --git a/packages/player-web/src/scene/default/built-in-skins.ts b/packages/player-web/src/skins/built-in.ts similarity index 84% rename from packages/player-web/src/scene/default/built-in-skins.ts rename to packages/player-web/src/skins/built-in.ts index 15d19f83..63449eb4 100644 --- a/packages/player-web/src/scene/default/built-in-skins.ts +++ b/packages/player-web/src/skins/built-in.ts @@ -1,7 +1,7 @@ -import type { BeMusicSkin } from '../../skin/be-music/types.ts'; import { latticeSkin } from './lattice/index.ts'; import { phantomSkin } from './phantom/index.ts'; import { synesthesiaSkin } from './synesthesia/index.ts'; +import type { BeMusicSkin } from '../skin-sdk/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, latticeSkin]; diff --git a/packages/player-web/src/scene/default/lattice/draw.ts b/packages/player-web/src/skins/lattice/draw.ts similarity index 97% rename from packages/player-web/src/scene/default/lattice/draw.ts rename to packages/player-web/src/skins/lattice/draw.ts index c07444fb..c2d6ed96 100644 --- a/packages/player-web/src/scene/default/lattice/draw.ts +++ b/packages/player-web/src/skins/lattice/draw.ts @@ -1,10 +1,14 @@ import type { Container, Graphics } from 'pixi.js'; -import type { ChildPool } from '../../pixi-utils.ts'; -import { addHudText, type HudTextOptions } from '../hud-text.ts'; -import { pointLayerFor } from '../particle-layer.ts'; -import { addSkinText, type SkinTextOptions } from '../skin-text.ts'; import { digitTransitions, needleAngle, 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 '../../skin-sdk/index.ts'; /** * Shared Lattice drawing: the graph-paper grid, the needle field, ruler ticks, per-letter staggered type, and the diff --git a/packages/player-web/src/scene/default/lattice/field.test.ts b/packages/player-web/src/skins/lattice/field.test.ts similarity index 100% rename from packages/player-web/src/scene/default/lattice/field.test.ts rename to packages/player-web/src/skins/lattice/field.test.ts diff --git a/packages/player-web/src/scene/default/lattice/field.ts b/packages/player-web/src/skins/lattice/field.ts similarity index 99% rename from packages/player-web/src/scene/default/lattice/field.ts rename to packages/player-web/src/skins/lattice/field.ts index def884f2..3cae8b19 100644 --- a/packages/player-web/src/scene/default/lattice/field.ts +++ b/packages/player-web/src/skins/lattice/field.ts @@ -1,4 +1,4 @@ -import { hash01 } from '../phantom-style.ts'; +import { hash01 } from '../../skin-sdk/index.ts'; /** * Pure motion helpers for the Lattice skin: the needle field (short strokes on a grid that turn like iron filings in diff --git a/packages/player-web/src/scene/default/lattice/gameplay-chrome.ts b/packages/player-web/src/skins/lattice/gameplay-chrome.ts similarity index 94% rename from packages/player-web/src/scene/default/lattice/gameplay-chrome.ts rename to packages/player-web/src/skins/lattice/gameplay-chrome.ts index fa2f0bf0..2506f484 100644 --- a/packages/player-web/src/scene/default/lattice/gameplay-chrome.ts +++ b/packages/player-web/src/skins/lattice/gameplay-chrome.ts @@ -1,26 +1,5 @@ import { Graphics, type Container } from 'pixi.js'; -import { GROOVE, PLAYFIELD } from '../../gameplay-constants.ts'; -import { LOADING_WORD, loadingDots } from '../loading.ts'; -import type { BeMusicRect } from '../../../skin/be-music/types.ts'; -import { - STAGE_BGA_BAND, - STAGE_HEIGHT as DESIGN_HEIGHT, - STAGE_SIDE_COLUMN, - STAGE_WIDTH as DESIGN_WIDTH, - resolveStageBgaRect, -} from '../stage.ts'; -import type { SkinlessGameplayChromeRenderContext, SkinlessGameplayChromeRuntime } from '../../gameplay-chrome.ts'; -import { resolveSkinlessLaneLayout } from '../../gameplay-lanes.ts'; -import type { ChildPool } from '../../pixi-utils.ts'; -import { addHudNumber, addHudText } from '../hud-text.ts'; -import { - comboTier, - effectProfile, - momentProgress, - resolveMilestoneArea, - trackMoments, - type MomentState, -} from '../moments.ts'; +import { GROOVE } from './layout.ts'; import { addOdometer, addStaggeredHudText, @@ -33,9 +12,7 @@ import { } from './draw.ts'; import { scrambleText, scrambleTick, springEase, tileFlipPhase, type NeedleFieldInput } from './field.ts'; import { drawLatticeMoments, FULL_COMBO_MS, MILESTONE_MS } from './moments.ts'; -import { flashingGreatColor, isFlashingGreat, judgeDisplayWord } from '../judge-word.ts'; import { createChain, kickChain, stepChain, type Chain } from './physics.ts'; -import { audioDrive, bandLevel, type AudioDrive } from '../audio-drive.ts'; import { LAT_ACCENT, LAT_GRAPHITE, @@ -46,6 +23,33 @@ import { LAT_TEXT_FONT, LAT_WHITE, } from './style.ts'; +import { + addHudNumber, + addHudText, + audioDrive, + type AudioDrive, + bandLevel, + type BeMusicRect, + type ChildPool, + comboTier, + effectProfile, + flashingGreatColor, + isFlashingGreat, + judgeDisplayWord, + LOADING_WORD, + loadingDots, + momentProgress, + type MomentState, + trackMoments, + resolveMilestoneArea, + type BeMusicChromeContext, + type BeMusicGameplayLayout, + type SkinlessGameplayChromeRuntime, + STAGE_BGA_BAND, + STAGE_HEIGHT as DESIGN_HEIGHT, + STAGE_SIDE_COLUMN, + STAGE_WIDTH as DESIGN_WIDTH, +} from '../../skin-sdk/index.ts'; type Runtime = SkinlessGameplayChromeRuntime; @@ -76,7 +80,9 @@ const SONG_PLATE = { x: HUD_LEFT, y: 420, w: HUD_RIGHT - 240 - 24 - HUD_LEFT, h: * 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 = { ...resolveStageBgaRect(227) }; +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; /** @@ -91,16 +97,13 @@ export function renderLatticeChrome({ layerPool, overlayLayerPool, runtime, -}: SkinlessGameplayChromeRenderContext): void { + layout, +}: BeMusicChromeContext): void { const nowMs = runtime.nowMs ?? 0; const seconds = nowMs / 1000; const beatPhase = runtime.beatPhase ?? 0; - const { left: playfieldLeft, right: playfieldRight } = resolveSkinlessLaneLayout( - runtime.laneChannels, - runtime.laneCount, - runtime.playVariant, - ); - Object.assign(BGA, resolveStageBgaRect(playfieldRight)); + 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); @@ -141,7 +144,7 @@ export function renderLatticeChrome({ drawNeedleField( field, { x: 0, y: HEADER_H, w: DESIGN_WIDTH, h: DESIGN_HEIGHT - HEADER_H }, - resolveFieldInput(layer, runtime, moments, tier, effects.amount, playfieldLeft, playfieldRight, drive), + resolveFieldInput(layer, runtime, moments, tier, effects.amount, layout, drive), { alpha: 0.55, skip: (x, y) => clean.some((rect) => inside(rect, x, y, 0)), @@ -176,17 +179,10 @@ export function renderLatticeChrome({ if (runtime.loading) { drawLoading(overlayLayer, runtime, (playfieldLeft + playfieldRight) / 2, overlayLayerPool); } else { - drawJudgements(overlayLayer, runtime, playfieldRight, effects.amount, overlayLayerPool); + drawJudgements(overlayLayer, runtime, layout, effects.amount, overlayLayerPool); } } - drawLatticeMoments( - overlayLayer, - runtime, - moments, - (playfieldLeft + playfieldRight) / 2, - overlayLayerPool, - playfieldRight, - ); + drawLatticeMoments(overlayLayer, runtime, moments, (playfieldLeft + playfieldRight) / 2, overlayLayerPool, layout); } const IMPULSE_HISTORY = new WeakMap(); @@ -305,8 +301,7 @@ function resolveFieldInput( moments: MomentState, tier: number, amount: number, - playfieldLeft: number, - playfieldRight: number, + layout: BeMusicGameplayLayout, drive: AudioDrive, ): NeedleFieldInput { const nowMs = runtime.nowMs ?? 0; @@ -328,11 +323,16 @@ function resolveFieldInput( history.push(runtime.impulseAtMs); if (history.length > 6) history.shift(); } - const originX = (playfieldLeft + playfieldRight) / 2; + const originX = layout.playfield.centerX; const ripples = amount > 0 ? [ - ...history.map((atMs) => ({ x: originX, y: PLAYFIELD.judgementY, ageMs: nowMs - atMs, strength: amount })), + ...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, @@ -353,7 +353,7 @@ function resolveFieldInput( ripples, attractor: (() => { // Needles swing toward wherever the milestone plays (beside or below the lanes). - const area = resolveMilestoneArea(playfieldRight); + 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 }; @@ -886,13 +886,13 @@ const FLASHING_GREAT = [LAT_ACCENT, LAT_SIGNAL, 0x00a37a, 0xb02cff, 0xe0a100] as function drawJudgements( layer: Container, runtime: Runtime, - playfieldRight: number, + 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, playfieldRight)) { + for (const display of resolveJudgeDisplays(runtime, layout)) { const word = judgeDisplayWord(display.judge); const color = isFlashingGreat(display.judge) ? flashingGreatColor(nowMs, FLASHING_GREAT) @@ -935,19 +935,13 @@ function drawJudgements( function resolveJudgeDisplays( runtime: Runtime, - playfieldRight: number, + layout: BeMusicGameplayLayout, ): Array<{ judge: string; combo: number | undefined; x: number }> { - const { lanes, left: playfieldLeft } = resolveSkinlessLaneLayout( - runtime.laneChannels, - runtime.laneCount, - 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 bounds = layout.playfield.sides[side]; + return bounds ? (bounds.left + bounds.right) / 2 : undefined; }; - const fallbackX = (playfieldLeft + playfieldRight) / 2; + 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 })); diff --git a/packages/player-web/src/scene/default/lattice/index.ts b/packages/player-web/src/skins/lattice/index.ts similarity index 70% rename from packages/player-web/src/scene/default/lattice/index.ts rename to packages/player-web/src/skins/lattice/index.ts index 53dec6b1..cd456a4c 100644 --- a/packages/player-web/src/scene/default/lattice/index.ts +++ b/packages/player-web/src/skins/lattice/index.ts @@ -1,4 +1,3 @@ -import type { BeMusicSkin } from '../../../skin/be-music/types.ts'; import { renderLatticeChrome } from './gameplay-chrome.ts'; import { LATTICE_BOMB_DURATION_MS, @@ -9,7 +8,7 @@ import { } from './playfield.ts'; import { latticeResultSkin } from './result.ts'; import { latticeSelectSkin } from './select.ts'; -import { wideStage } from '../stage.ts'; +import { type BeMusicSkin, wideStage, BE_MUSIC_SKIN_API_VERSION, defineBeMusicSkin } from '../../skin-sdk/index.ts'; /** * Lattice — a skin in the manner of precise, typographic interaction design (the Japanese web / broadcast interaction-design @@ -17,9 +16,16 @@ import { wideStage } from '../stage.ts'; * 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 = { +export const latticeSkin: BeMusicSkin = defineBeMusicSkin({ + 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"', @@ -40,4 +46,4 @@ export const latticeSkin: BeMusicSkin = { }, 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/scene/default/lattice/moments.ts b/packages/player-web/src/skins/lattice/moments.ts similarity index 93% rename from packages/player-web/src/scene/default/lattice/moments.ts rename to packages/player-web/src/skins/lattice/moments.ts index 7cbe6a7c..95f26222 100644 --- a/packages/player-web/src/scene/default/lattice/moments.ts +++ b/packages/player-web/src/skins/lattice/moments.ts @@ -1,21 +1,27 @@ import type { Container, Graphics } from 'pixi.js'; -import { GROOVE } from '../../gameplay-constants.ts'; -import type { BeMusicRect } from '../../../skin/be-music/types.ts'; -import { STAGE_HEIGHT as DESIGN_HEIGHT, STAGE_WIDTH as DESIGN_WIDTH, resolveStageBgaRect } from '../stage.ts'; -import type { SkinlessGameplayChromeRuntime } from '../../gameplay-chrome.ts'; -import type { ChildPool } from '../../pixi-utils.ts'; -import { addHudText } from '../hud-text.ts'; +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 { + addHudText, + easeOutCubic, + type BeMusicRect, + type ChildPool, effectProfile, + hash01, + type MilestoneArea, momentProgress, type MomentState, resolveMilestoneArea, - type MilestoneArea, -} from '../moments.ts'; -import { easeOutCubic, hash01 } from '../phantom-style.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'; + type BeMusicGameplayLayout, + type SkinlessGameplayChromeRuntime, + STAGE_HEIGHT as DESIGN_HEIGHT, + STAGE_WIDTH as DESIGN_WIDTH, +} from '../../skin-sdk/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 = 3200; @@ -38,7 +44,7 @@ export function drawLatticeMoments( moments: MomentState, playfieldCenterX: number, pool: ChildPool, - playfieldRight: number, + layout: BeMusicGameplayLayout, ): void { const nowMs = runtime.nowMs ?? 0; const effects = effectProfile(runtime.effects); @@ -59,8 +65,8 @@ export function drawLatticeMoments( moments.milestone.value, milestone, runtime.hasBga === true, - resolveMilestoneArea(playfieldRight), - resolveStageBgaRect(playfieldRight), + resolveMilestoneArea(layout.playfield.right, 346, layout.stage.width), + layout.bga ?? NO_MONITOR, pool, ); } diff --git a/packages/player-web/src/scene/default/lattice/physics.test.ts b/packages/player-web/src/skins/lattice/physics.test.ts similarity index 100% rename from packages/player-web/src/scene/default/lattice/physics.test.ts rename to packages/player-web/src/skins/lattice/physics.test.ts diff --git a/packages/player-web/src/scene/default/lattice/physics.ts b/packages/player-web/src/skins/lattice/physics.ts similarity index 100% rename from packages/player-web/src/scene/default/lattice/physics.ts rename to packages/player-web/src/skins/lattice/physics.ts diff --git a/packages/player-web/src/scene/default/lattice/playfield.ts b/packages/player-web/src/skins/lattice/playfield.ts similarity index 96% rename from packages/player-web/src/scene/default/lattice/playfield.ts rename to packages/player-web/src/skins/lattice/playfield.ts index 505a9f40..9b41231a 100644 --- a/packages/player-web/src/scene/default/lattice/playfield.ts +++ b/packages/player-web/src/skins/lattice/playfield.ts @@ -1,17 +1,19 @@ import type { Graphics } from 'pixi.js'; -import type { - BeMusicBomb, - BeMusicBombsContext, - BeMusicLaneKind, - BeMusicLanesContext, - BeMusicLongNoteContext, - BeMusicNoteContext, -} from '../../../skin/be-music/types.ts'; -import { keyBeamGradient } from '../key-beam.ts'; -import { comboTier, effectProfile } from '../moments.ts'; -import { easeOutCubic, hash01 } from '../phantom-style.ts'; import { springEase } from './field.ts'; import { LAT_ACCENT, LAT_INK, LAT_RULE, LAT_SIGNAL } from './style.ts'; +import { + easeOutCubic, + type BeMusicBomb, + type BeMusicBombsContext, + type BeMusicLaneKind, + type BeMusicLanesContext, + type BeMusicLongNoteContext, + type BeMusicNoteContext, + comboTier, + effectProfile, + hash01, + keyBeamGradient, +} from '../../skin-sdk/index.ts'; /** Lane-class colour: white keys print in ink, black keys in cobalt, the scratch in vermilion. */ const LANE_COLORS: Record = { diff --git a/packages/player-web/src/scene/default/lattice/result.ts b/packages/player-web/src/skins/lattice/result.ts similarity index 97% rename from packages/player-web/src/scene/default/lattice/result.ts rename to packages/player-web/src/skins/lattice/result.ts index 3ca55062..76d849a5 100644 --- a/packages/player-web/src/scene/default/lattice/result.ts +++ b/packages/player-web/src/skins/lattice/result.ts @@ -1,11 +1,7 @@ import { Graphics } from 'pixi.js'; -import type { BeMusicResultFrame, BeMusicResultSkin } from '../../../skin/be-music/types.ts'; -import { easeOutCubic, rollUpValue, stageProgress } from '../phantom-style.ts'; -import { addSkinText, type SkinTextOptions } from '../skin-text.ts'; import { addStaggeredSkinText, drawNeedleField, drawPaperGrid, drawRulerTicks } from './draw.ts'; import { scrambleText, scrambleTick, springEase } from './field.ts'; import { bounceHeight } from './physics.ts'; -import { resolveResultLamp, resolveResultTrackRows } from '../result-track.ts'; import { LAT_ACCENT, LAT_DISPLAY_FONT, @@ -17,6 +13,17 @@ import { LAT_SIGNAL, LAT_TEXT_FONT, } from './style.ts'; +import { + addSkinText, + easeOutCubic, + type BeMusicResultFrame, + type BeMusicResultSkin, + resolveResultLamp, + resolveResultTrackRows, + rollUpValue, + type SkinTextOptions, + stageProgress, +} from '../../skin-sdk/index.ts'; const ROLL_DELAY_MS = 600; const ROLL_MS = 1100; diff --git a/packages/player-web/src/scene/default/lattice/select.ts b/packages/player-web/src/skins/lattice/select.ts similarity index 97% rename from packages/player-web/src/scene/default/lattice/select.ts rename to packages/player-web/src/skins/lattice/select.ts index 1e69cf19..d801852d 100644 --- a/packages/player-web/src/scene/default/lattice/select.ts +++ b/packages/player-web/src/skins/lattice/select.ts @@ -1,18 +1,7 @@ import { Container, Graphics, type Text } 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 { addStaggeredSkinText, drawNeedleField, drawPaperGrid, type Rect } from './draw.ts'; import { scrambleText, scrambleTick, springEase, tileFlipPhase } from './field.ts'; import { bounceHeight } from './physics.ts'; -import type { BeMusicAudioFrame } from '../../../skin/be-music/types.ts'; -import { audioDrive, bandLevel } from '../audio-drive.ts'; import { LAT_ACCENT, LAT_DISPLAY_FONT, @@ -24,7 +13,24 @@ import { LAT_TEXT_FONT, LAT_WHITE, } from './style.ts'; -import { resolveSongRowFacts } from '../song-stats.ts'; +import { + addHitArea, + addSkinText, + easeOutCubic, + audioDrive, + bandLevel, + type BeMusicAudioFrame, + type BeMusicSelectFrame, + type BeMusicSelectLayout, + type BeMusicSelectRenderer, + type BeMusicSelectSkin, + type BrowserBrowseEntry, + type BrowserSongEntry, + formatPlayVariantLabel, + resolveSongRowFacts, + type SkinTextOptions, + stageProgress, +} from '../../skin-sdk/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. */ diff --git a/packages/player-web/src/scene/default/lattice/style.ts b/packages/player-web/src/skins/lattice/style.ts similarity index 100% rename from packages/player-web/src/scene/default/lattice/style.ts rename to packages/player-web/src/skins/lattice/style.ts diff --git a/packages/player-web/src/scene/default/gameplay-render.ts b/packages/player-web/src/skins/phantom/chrome.ts similarity index 91% rename from packages/player-web/src/scene/default/gameplay-render.ts rename to packages/player-web/src/skins/phantom/chrome.ts index 4bbcbe1b..0d992c2c 100644 --- a/packages/player-web/src/scene/default/gameplay-render.ts +++ b/packages/player-web/src/skins/phantom/chrome.ts @@ -1,16 +1,5 @@ import { Container, Graphics } from 'pixi.js'; -import type { ChartPlayVariant } from '@be-music/player/core/lane-layout'; -import { GROOVE, PLAYFIELD } from '../gameplay-constants.ts'; -import type { BeMusicRect } from '../../skin/be-music/types.ts'; -import { - STAGE_BGA_BAND, - STAGE_HEIGHT as DESIGN_HEIGHT, - STAGE_SIDE_COLUMN, - STAGE_WIDTH as DESIGN_WIDTH, - resolveStageBgaRect, -} from './stage.ts'; -import { resolveSkinlessLaneLayout, type FallbackLaneLayoutRect } from '../gameplay-lanes.ts'; -import type { SkinlessGameplayChromeRuntime } from '../gameplay-chrome.ts'; +import { GROOVE } from './layout.ts'; import { DEFAULT_DISPLAY_FONT, DEFAULT_HEADLINE_FONT } from './fonts.ts'; import { PHANTOM_ASH, @@ -31,15 +20,35 @@ import { halftoneField, parallelogramPoints, starburstPoints, -} from './phantom-style.ts'; -import type { ChildPool } from '../pixi-utils.ts'; -import { audioDrive, bandLevel, type AudioDrive } from './audio-drive.ts'; -import { drawPhantomMoments } from './phantom/moments.ts'; -import { addRansomText, type GlyphFactory } from './phantom/tear.ts'; -import { loadingDots } from './loading.ts'; -import { effectProfile, impulse } from './moments.ts'; -import { flashingGreatColor, isFlashingGreat, judgeDisplayWord } from './judge-word.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 { + addHudNumber as addNumber, + addHudText as addText, + audioDrive, + type AudioDrive, + bandLevel, + type BeMusicRect, + type ChildPool, + effectProfile, + flashingGreatColor, + type HudTextOptions as TextOptions, + impulse, + isFlashingGreat, + judgeDisplayWord, + loadingDots, + resolveGameplayLayout, + type SkinlessGameplayChromeRuntime, + type BeMusicChromeContext, + type BeMusicGameplayLayout, + type BeMusicLayoutLane, + wideStage, + STAGE_BGA_BAND, + STAGE_HEIGHT as DESIGN_HEIGHT, + STAGE_SIDE_COLUMN, + STAGE_WIDTH as DESIGN_WIDTH, +} from '../../skin-sdk/index.ts'; const DISPLAY_FONT = DEFAULT_DISPLAY_FONT; /** Italic lean applied to every display-face text node — the whole HUD reads as moving forward. */ @@ -75,7 +84,8 @@ export interface FallbackGameplayRenderOptions { } interface FallbackPlayfieldLayout { - lanes: FallbackLaneLayoutRect[]; + lanes: readonly BeMusicLayoutLane[]; + top: number; x: number; w: number; centerX: number; @@ -84,27 +94,49 @@ 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, +}: BeMusicChromeContext): 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); - // The scene composites the BGA into the same rect (the stage's), sized to the room the playfield leaves. - const bga = resolveStageBgaRect(playfield.right); + 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'; // 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); - drawBackground(frame, hasBga && bga.w > 0, bga, drive); + drawBackground(frame, hasBga ? bga : undefined, drive); const effects = effectProfile(runtime.effects); drawPlayfield( @@ -114,7 +146,7 @@ export function renderDefaultGameplayFrame( 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.w > 0) { + if (bga) { drawBgaFrame(frame, layer, bga, hasBga, runtime.nowMs, drive, layerPool); } drawGauge(frame, layer, runtime, layerPool); @@ -137,7 +169,7 @@ export function renderDefaultGameplayFrame( drawStatusBar(front, frontLayer, runtime, 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, playfield.right); + 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); } @@ -156,8 +188,8 @@ export const renderFallbackLr2Frame: typeof renderDefaultGameplayFrame = renderD * 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, bga: BeMusicRect, drive: AudioDrive): void { - fillRectAroundHole(frame, 0, 0, DESIGN_WIDTH, DESIGN_HEIGHT, PHANTOM_BLACK, hasBga ? bga : undefined); +function drawBackground(frame: Graphics, hole: BeMusicRect | undefined, drive: AudioDrive): void { + fillRectAroundHole(frame, 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 < 12; stripe += 1) { const x0 = -120 + stripe * 44; @@ -230,50 +262,27 @@ function fillRectAroundHole( 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, left, right } = resolveSkinlessLaneLayout(laneChannels, laneCount, playVariant); - const w = Math.max(1, right - left); - 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, + lanes: layout.lanes, + top: layout.playfield.top, x: left, - w, - centerX: left + w / 2, + 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; @@ -283,7 +292,7 @@ function drawPlayfield( progressRatio: number | undefined, hit: number, ): void { - const wellTop = PLAYFIELD.y; + const wellTop = playfield.top; const wellBottom = PLAYFIELD_FRAME_BOTTOM; const wellHeight = wellBottom - wellTop; const railW = 8; 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 58% rename from packages/player-web/src/scene/default/phantom/index.ts rename to packages/player-web/src/skins/phantom/index.ts index 51036342..aa1da9df 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,20 +8,26 @@ import { } from './playfield.ts'; import { phantomResultSkin } from './result.ts'; import { phantomSelectSkin } from './select.ts'; -import { wideStage } from '../stage.ts'; +import { type BeMusicSkin, wideStage, BE_MUSIC_SKIN_API_VERSION, defineBeMusicSkin } from '../../skin-sdk/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 = defineBeMusicSkin({ + 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, @@ -31,4 +36,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/scene/default/phantom/moments.ts b/packages/player-web/src/skins/phantom/moments.ts similarity index 92% rename from packages/player-web/src/scene/default/phantom/moments.ts rename to packages/player-web/src/skins/phantom/moments.ts index 548a3749..365297ad 100644 --- a/packages/player-web/src/scene/default/phantom/moments.ts +++ b/packages/player-web/src/skins/phantom/moments.ts @@ -1,11 +1,6 @@ import { Graphics, type Container } from 'pixi.js'; -import { GROOVE } from '../../gameplay-constants.ts'; -import { STAGE_HEIGHT as DESIGN_HEIGHT, STAGE_WIDTH as DESIGN_WIDTH, resolveStageBgaRect } from '../stage.ts'; -import type { SkinlessGameplayChromeRuntime } from '../../gameplay-chrome.ts'; -import type { ChildPool } from '../../pixi-utils.ts'; -import { DEFAULT_DISPLAY_FONT } from '../fonts.ts'; -import { addHudText } from '../hud-text.ts'; -import { effectProfile, momentProgress, resolveMilestoneArea, trackMoments } from '../moments.ts'; +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, @@ -14,13 +9,25 @@ import { PHANTOM_RED, PHANTOM_RED_HOT, PHANTOM_WHITE, + parallelogramPoints, + starburstPoints, +} from './style.ts'; +import { + addHudText, easeOutBack, easeOutCubic, + type ChildPool, + effectProfile, hash01, - parallelogramPoints, + momentProgress, + trackMoments, + resolveMilestoneArea, + type BeMusicGameplayLayout, + type SkinlessGameplayChromeRuntime, + STAGE_HEIGHT as DESIGN_HEIGHT, + STAGE_WIDTH as DESIGN_WIDTH, stageProgress, - starburstPoints, -} from '../phantom-style.ts'; +} from '../../skin-sdk/index.ts'; const MILESTONE_MS = 1100; const CLEAR_MS = 1000; @@ -44,7 +51,7 @@ export function drawPhantomMoments( layer: Container, runtime: SkinlessGameplayChromeRuntime, pool: ChildPool | undefined, - playfieldRight: number, + layout: BeMusicGameplayLayout, ): boolean { const nowMs = runtime.nowMs ?? 0; const moments = trackMoments(chromeLayer, { @@ -72,16 +79,7 @@ export function drawPhantomMoments( } 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, - playfieldRight, - ); + drawMilestone(graphics, layer, moments.milestone.value, milestone, runtime.hasBga === true, pool, nowMs, layout); } const clear = momentProgress(moments.clearAtMs, nowMs, CLEAR_MS); if (clear !== undefined) { @@ -170,20 +168,20 @@ function drawMilestone( hasBga: boolean, pool: ChildPool | undefined, nowMs: number, - playfieldRight: 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(playfieldRight); - const bga = resolveStageBgaRect(playfieldRight); - hasBga = hasBga && bga.w > 0; + 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.y + bga.h - 14 : area.y + 160, + 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, diff --git a/packages/player-web/src/scene/default/phantom/playfield.ts b/packages/player-web/src/skins/phantom/playfield.ts similarity index 97% rename from packages/player-web/src/scene/default/phantom/playfield.ts rename to packages/player-web/src/skins/phantom/playfield.ts index 98496673..71891886 100644 --- a/packages/player-web/src/scene/default/phantom/playfield.ts +++ b/packages/player-web/src/skins/phantom/playfield.ts @@ -1,12 +1,12 @@ import { Color, FillGradient, type Graphics } from 'pixi.js'; -import type { - BeMusicBombsContext, - BeMusicLaneKind, - BeMusicLanesContext, - BeMusicLongNoteContext, - BeMusicNoteContext, -} from '../../../skin/be-music/types.ts'; -import { keyBeamGradient } from '../key-beam.ts'; +import { + type BeMusicBombsContext, + type BeMusicLaneKind, + type BeMusicLanesContext, + type BeMusicLongNoteContext, + type BeMusicNoteContext, + keyBeamGradient, +} from '../../skin-sdk/index.ts'; /** Colours for one lane class: note highlight / body / shade, and the key cap at rest / pressed. */ interface LaneTone { diff --git a/packages/player-web/src/scene/default/phantom/result.ts b/packages/player-web/src/skins/phantom/result.ts similarity index 98% rename from packages/player-web/src/scene/default/phantom/result.ts rename to packages/player-web/src/skins/phantom/result.ts index df99bbf5..9fba423e 100644 --- a/packages/player-web/src/scene/default/phantom/result.ts +++ b/packages/player-web/src/skins/phantom/result.ts @@ -1,7 +1,5 @@ import { Container, Graphics, Text, type Color } from 'pixi.js'; -import { resolveSkinTextStyle } from '../skin-text.ts'; -import type { BeMusicResultFrame, BeMusicResultSkin } from '../../../skin/be-music/types.ts'; -import { DEFAULT_DISPLAY_FONT, DEFAULT_HEADLINE_FONT, DEFAULT_TEXT_FONT } from '../fonts.ts'; +import { DEFAULT_DISPLAY_FONT, DEFAULT_HEADLINE_FONT } from './fonts.ts'; import { PHANTOM_ASH, PHANTOM_BLACK, @@ -17,19 +15,26 @@ import { PHANTOM_WHITE, drawNoteEmblem, drawStaves, - easeOutBack, - easeOutCubic, halftoneField, - hash01, parallelogramPoints, - rollUpValue, - stageProgress, starburstPoints, tornEdgePoints, -} from '../phantom-style.ts'; -import { alignCapCenter } from '../text-metrics.ts'; +} from './style.ts'; import { addRansomText, type GlyphFactory } from './tear.ts'; -import { resolveResultLamp, resolveResultTrackRows } from '../result-track.ts'; +import { + alignCapCenter, + easeOutBack, + easeOutCubic, + type BeMusicResultFrame, + type BeMusicResultSkin, + DEFAULT_TEXT_FONT, + hash01, + resolveResultLamp, + resolveResultTrackRows, + resolveSkinTextStyle, + rollUpValue, + stageProgress, +} from '../../skin-sdk/index.ts'; /** Default-family result entrance timeline (ms from scene start): counters roll up, then the rank badge lands. */ const RESULT_ROLL_DELAY_MS = 760; diff --git a/packages/player-web/src/scene/default/phantom/select.ts b/packages/player-web/src/skins/phantom/select.ts similarity index 97% rename from packages/player-web/src/scene/default/phantom/select.ts rename to packages/player-web/src/skins/phantom/select.ts index 3a385ac9..e65d7fd6 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, @@ -19,20 +12,32 @@ import { PHANTOM_SLATE, PHANTOM_WHITE, drawNoteEmblem, - easeOutBack, - easeOutCubic, halftoneField, - hash01, parallelogramPoints, - stageProgress, starburstPoints, tornEdgePoints, -} from '../phantom-style.ts'; -import { addHitArea, addSkinText, formatPlayVariantLabel, type SkinTextOptions } from '../skin-text.ts'; -import type { BeMusicAudioFrame } from '../../../skin/be-music/types.ts'; -import { audioDrive, bandLevel } from '../audio-drive.ts'; +} from './style.ts'; import { addRansomText, type GlyphFactory } from './tear.ts'; -import { resolveSongRowFacts } from '../song-stats.ts'; +import { + addHitArea, + addSkinText, + easeOutBack, + easeOutCubic, + audioDrive, + bandLevel, + type BeMusicAudioFrame, + type BeMusicSelectFrame, + type BeMusicSelectLayout, + type BeMusicSelectRenderer, + type BeMusicSelectSkin, + type BrowserBrowseEntry, + type BrowserSongEntry, + formatPlayVariantLabel, + hash01, + resolveSongRowFacts, + type SkinTextOptions, + stageProgress, +} from '../../skin-sdk/index.ts'; const LAYOUT: BeMusicSelectLayout = { listX: 320, listTop: 60, listBottomInset: 26, rowHeight: 28 }; const SLIDE_MS = 240; 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 65% rename from packages/player-web/src/scene/default/phantom-style.test.ts rename to packages/player-web/src/skins/phantom/style.test.ts index 295498b1..276c302a 100644 --- a/packages/player-web/src/scene/default/phantom-style.test.ts +++ b/packages/player-web/src/skins/phantom/style.test.ts @@ -1,18 +1,12 @@ import { describe, expect, it } from 'vite-plus/test'; import { - easeOutBack, - easeOutCubic, halftoneField, - hash01, - layoutTabularRun, parallelogramPoints, ransomLayout, - rollUpValue, - stageProgress, starburstPoints, tornBandPoints, tornEdgePoints, -} from './phantom-style.ts'; +} from './style.ts'; describe('parallelogramPoints', () => { it('shifts the top edge right for a positive slant', () => { @@ -81,87 +75,6 @@ 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); - }); - - 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); - }); -}); - -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); - }); -}); - describe('tornEdgePoints', () => { it('starts and ends exactly on the line', () => { const points = tornEdgePoints(0, 0, 200, 0, 6, 3); diff --git a/packages/player-web/src/scene/default/phantom-style.ts b/packages/player-web/src/skins/phantom/style.ts similarity index 79% rename from packages/player-web/src/scene/default/phantom-style.ts rename to packages/player-web/src/skins/phantom/style.ts index b1560090..246fa1e3 100644 --- a/packages/player-web/src/scene/default/phantom-style.ts +++ b/packages/player-web/src/skins/phantom/style.ts @@ -1,3 +1,4 @@ +import { hash01 } from '../../skin-sdk/index.ts'; import type { Graphics } from 'pixi.js'; /** @@ -114,74 +115,6 @@ export function halftoneField(options: HalftoneFieldOptions): HalftoneDot[] { 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)); -} - /** * 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. diff --git a/packages/player-web/src/scene/default/phantom/tear.test.ts b/packages/player-web/src/skins/phantom/tear.test.ts similarity index 97% rename from packages/player-web/src/scene/default/phantom/tear.test.ts rename to packages/player-web/src/skins/phantom/tear.test.ts index 3fabb531..4d32bf56 100644 --- a/packages/player-web/src/scene/default/phantom/tear.test.ts +++ b/packages/player-web/src/skins/phantom/tear.test.ts @@ -1,6 +1,6 @@ import type { Graphics, Text } from 'pixi.js'; import { describe, expect, it } from 'vite-plus/test'; -import { PHANTOM_INK, ransomLayout } from '../phantom-style.ts'; +import { PHANTOM_INK, ransomLayout } from './style.ts'; import { addRansomText, stripPoint, tearOpenness, type GlyphFactory } from './tear.ts'; describe('tearOpenness', () => { diff --git a/packages/player-web/src/scene/default/phantom/tear.ts b/packages/player-web/src/skins/phantom/tear.ts similarity index 97% rename from packages/player-web/src/scene/default/phantom/tear.ts rename to packages/player-web/src/skins/phantom/tear.ts index b4773627..8e77ff02 100644 --- a/packages/player-web/src/scene/default/phantom/tear.ts +++ b/packages/player-web/src/skins/phantom/tear.ts @@ -1,17 +1,15 @@ import type { Graphics, Text } from 'pixi.js'; -import { DEFAULT_DISPLAY_FONT, DEFAULT_HEADLINE_FONT, DEFAULT_TEXT_FONT } from '../fonts.ts'; +import { DEFAULT_DISPLAY_FONT, DEFAULT_HEADLINE_FONT } from './fonts.ts'; import { PHANTOM_INK, PHANTOM_PAPER, PHANTOM_RED, PHANTOM_WHITE, - easeOutBack, - easeOutCubic, - hash01, ransomLayout, tornBandPoints, type RansomPaper, -} from '../phantom-style.ts'; +} from './style.ts'; +import { easeOutBack, easeOutCubic, DEFAULT_TEXT_FONT, hash01 } from '../../skin-sdk/index.ts'; /** * Phantom cut-in pieces: the torn-paper strip that rips across the screen (red splash behind a white rim around an ink diff --git a/packages/player-web/src/scene/default/synesthesia/boids.test.ts b/packages/player-web/src/skins/synesthesia/boids.test.ts similarity index 100% rename from packages/player-web/src/scene/default/synesthesia/boids.test.ts rename to packages/player-web/src/skins/synesthesia/boids.test.ts diff --git a/packages/player-web/src/scene/default/synesthesia/boids.ts b/packages/player-web/src/skins/synesthesia/boids.ts similarity index 99% rename from packages/player-web/src/scene/default/synesthesia/boids.ts rename to packages/player-web/src/skins/synesthesia/boids.ts index c7e31723..82050ef2 100644 --- a/packages/player-web/src/scene/default/synesthesia/boids.ts +++ b/packages/player-web/src/skins/synesthesia/boids.ts @@ -1,4 +1,4 @@ -import { hash01 } from '../phantom-style.ts'; +import { hash01 } from '../../skin-sdk/index.ts'; /** * A 3D school of boids (Reynolds' separation / alignment / cohesion) for the Synesthesia backdrop: fish of light that diff --git a/packages/player-web/src/scene/default/synesthesia/burst-budget.test.ts b/packages/player-web/src/skins/synesthesia/burst-budget.test.ts similarity index 100% rename from packages/player-web/src/scene/default/synesthesia/burst-budget.test.ts rename to packages/player-web/src/skins/synesthesia/burst-budget.test.ts diff --git a/packages/player-web/src/scene/default/synesthesia/burst-budget.ts b/packages/player-web/src/skins/synesthesia/burst-budget.ts similarity index 100% rename from packages/player-web/src/scene/default/synesthesia/burst-budget.ts rename to packages/player-web/src/skins/synesthesia/burst-budget.ts diff --git a/packages/player-web/src/scene/default/synesthesia/draw.test.ts b/packages/player-web/src/skins/synesthesia/draw.test.ts similarity index 100% rename from packages/player-web/src/scene/default/synesthesia/draw.test.ts rename to packages/player-web/src/skins/synesthesia/draw.test.ts diff --git a/packages/player-web/src/scene/default/synesthesia/draw.ts b/packages/player-web/src/skins/synesthesia/draw.ts similarity index 99% rename from packages/player-web/src/scene/default/synesthesia/draw.ts rename to packages/player-web/src/skins/synesthesia/draw.ts index bcb45bf3..04340e55 100644 --- a/packages/player-web/src/scene/default/synesthesia/draw.ts +++ b/packages/player-web/src/skins/synesthesia/draw.ts @@ -1,8 +1,4 @@ import type { Graphics, Sprite } from 'pixi.js'; -import { pointLayerFor } from '../particle-layer.ts'; -import { hash01 } from '../phantom-style.ts'; -import type { AudioDrive } from '../audio-drive.ts'; -import { bandLevel } from '../audio-drive.ts'; import type { Flock } from './boids.ts'; import { emberColor, @@ -20,6 +16,7 @@ import { type Vec3, } from './space.ts'; import { SYN_CYAN, SYN_GLASS, SYN_MAGENTA, SYN_WHITE, synGlowTexture } from './style.ts'; +import { type AudioDrive, bandLevel, hash01, pointLayerFor } from '../../skin-sdk/index.ts'; /** * Shared Synesthesia drawing: hairline frames with lock-on corners (a rhythm-shooter targeting reticle) and projected point diff --git a/packages/player-web/src/scene/default/synesthesia/gameplay-chrome.ts b/packages/player-web/src/skins/synesthesia/gameplay-chrome.ts similarity index 95% rename from packages/player-web/src/scene/default/synesthesia/gameplay-chrome.ts rename to packages/player-web/src/skins/synesthesia/gameplay-chrome.ts index 1925401c..11831e86 100644 --- a/packages/player-web/src/scene/default/synesthesia/gameplay-chrome.ts +++ b/packages/player-web/src/skins/synesthesia/gameplay-chrome.ts @@ -1,22 +1,6 @@ import { Graphics, type Container } from 'pixi.js'; -import { GROOVE } from '../../gameplay-constants.ts'; -import { LOADING_WORD, loadingDots } from '../loading.ts'; -import type { BeMusicRect } from '../../../skin/be-music/types.ts'; -import { - STAGE_HEIGHT as DESIGN_HEIGHT, - STAGE_SIDE_COLUMN, - STAGE_WIDTH as DESIGN_WIDTH, - resolveStageBgaRect, -} from '../stage.ts'; -import type { SkinlessGameplayChromeRenderContext, SkinlessGameplayChromeRuntime } from '../../gameplay-chrome.ts'; -import { resolveSkinlessLaneLayout } from '../../gameplay-lanes.ts'; -import type { ChildPool } from '../../pixi-utils.ts'; -import { addHudNumber, addHudText, type HudTextOptions } from '../hud-text.ts'; -import { comboTier, effectProfile, impulse, punchScale } from '../moments.ts'; +import { GROOVE } from './layout.ts'; import { drawSynesthesiaMoments } from './moments.ts'; -import { flashingGreatColor, isFlashingGreat, judgeDisplayWord } from '../judge-word.ts'; -import { hash01 } from '../phantom-style.ts'; -import { audioDrive, type AudioDrive } from '../audio-drive.ts'; import { createFlock, stepFlock, type Flock } from './boids.ts'; import { drawFrame, drawMagnetoOrb, drawPointCloud, drawReticle, drawSchool, sharedShapeBatch } from './draw.ts'; import { @@ -53,6 +37,31 @@ import { SYN_WHITE, sceneHue, } from './style.ts'; +import { + addHudNumber, + addHudText, + audioDrive, + type AudioDrive, + type BeMusicRect, + type ChildPool, + comboTier, + effectProfile, + flashingGreatColor, + hash01, + type HudTextOptions, + impulse, + isFlashingGreat, + judgeDisplayWord, + LOADING_WORD, + loadingDots, + type BeMusicChromeContext, + type BeMusicGameplayLayout, + type SkinlessGameplayChromeRuntime, + STAGE_HEIGHT as DESIGN_HEIGHT, + STAGE_SIDE_COLUMN, + STAGE_WIDTH as DESIGN_WIDTH, + punchScale, +} from '../../skin-sdk/index.ts'; type Runtime = SkinlessGameplayChromeRuntime; @@ -122,18 +131,15 @@ export function renderSynesthesiaChrome({ layerPool, overlayLayerPool, runtime, -}: SkinlessGameplayChromeRenderContext): void { + layout, +}: BeMusicChromeContext): 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 } = resolveSkinlessLaneLayout( - runtime.laneChannels, - runtime.laneCount, - runtime.playVariant, - ); - Object.assign(BGA, resolveStageBgaRect(playfieldRight)); + 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(); @@ -209,7 +215,7 @@ export function renderSynesthesiaChrome({ if (runtime.loading) { drawLoading(overlayLayer, runtime.nowMs ?? 0, (playfieldLeft + playfieldRight) / 2, accent, overlayLayerPool); } else { - drawJudgements(overlayLayer, runtime, playfieldRight, seconds, overlayLayerPool); + drawJudgements(overlayLayer, runtime, layout, seconds, overlayLayerPool); } drawSynesthesiaMoments( layer, @@ -218,7 +224,7 @@ export function renderSynesthesiaChrome({ (playfieldLeft + playfieldRight) / 2, hue, overlayLayerPool, - playfieldRight, + layout, ); } @@ -260,7 +266,9 @@ function insideBga(x: number, y: number, margin = 0): boolean { * 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 = { ...resolveStageBgaRect(227) }; +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; @@ -978,11 +986,11 @@ const FLASHING_GREAT = [SYN_CYAN, SYN_WHITE, SYN_AMBER, SYN_MAGENTA, SYN_GREEN] function drawJudgements( layer: Container, runtime: Runtime, - playfieldRight: number, + layout: BeMusicGameplayLayout, seconds: number, pool: ChildPool, ): void { - const displays = resolveJudgeDisplays(runtime, playfieldRight); + const displays = resolveJudgeDisplays(runtime, layout); const nowMs = runtime.nowMs ?? 0; const amount = effectProfile(runtime.effects).amount; for (const display of displays) { @@ -1029,21 +1037,15 @@ function drawJudgements( function resolveJudgeDisplays( runtime: Runtime, - playfieldRight: number, + layout: BeMusicGameplayLayout, ): Array<{ judge: string; combo: number | undefined; x: number; maxWidth: number }> { - const { lanes, left: playfieldLeft } = resolveSkinlessLaneLayout( - runtime.laneChannels, - runtime.laneCount, - 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 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 = (playfieldLeft + playfieldRight) / 2; + 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) => ({ diff --git a/packages/player-web/src/scene/default/synesthesia/index.ts b/packages/player-web/src/skins/synesthesia/index.ts similarity index 66% rename from packages/player-web/src/scene/default/synesthesia/index.ts rename to packages/player-web/src/skins/synesthesia/index.ts index 5e4ce04f..600cc0d7 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,23 @@ import { } from './playfield.ts'; import { synesthesiaResultSkin } from './result.ts'; import { synesthesiaSelectSkin } from './select.ts'; -import { wideStage } from '../stage.ts'; +import { type BeMusicSkin, wideStage, BE_MUSIC_SKIN_API_VERSION, defineBeMusicSkin } from '../../skin-sdk/index.ts'; /** * 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 = defineBeMusicSkin({ + 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: { @@ -31,4 +37,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/scene/default/synesthesia/moments.ts b/packages/player-web/src/skins/synesthesia/moments.ts similarity index 92% rename from packages/player-web/src/scene/default/synesthesia/moments.ts rename to packages/player-web/src/skins/synesthesia/moments.ts index 8467c82b..5cd3659d 100644 --- a/packages/player-web/src/scene/default/synesthesia/moments.ts +++ b/packages/player-web/src/skins/synesthesia/moments.ts @@ -1,12 +1,5 @@ import { Graphics, type Container } from 'pixi.js'; -import { GROOVE, PLAYFIELD } from '../../gameplay-constants.ts'; -import type { BeMusicRect } from '../../../skin/be-music/types.ts'; -import { STAGE_HEIGHT as DESIGN_HEIGHT, STAGE_WIDTH as DESIGN_WIDTH, resolveStageBgaRect } from '../stage.ts'; -import type { SkinlessGameplayChromeRuntime } from '../../gameplay-chrome.ts'; -import type { ChildPool } from '../../pixi-utils.ts'; -import { addHudText } from '../hud-text.ts'; -import { effectProfile, momentProgress, trackMoments, resolveMilestoneArea, type MilestoneArea } from '../moments.ts'; -import { easeOutCubic, stageProgress } from '../phantom-style.ts'; +import { GROOVE } from './layout.ts'; import { drawReticle } from './draw.ts'; import { burstParticlePosition, @@ -18,12 +11,30 @@ import { rotateY, } from './space.ts'; import { SYN_AMBER, SYN_CYAN, SYN_DISPLAY_FONT, SYN_EMBER, SYN_FLARE, SYN_MAGENTA, SYN_WHITE } from './style.ts'; +import { + addHudText, + easeOutCubic, + type BeMusicRect, + type ChildPool, + effectProfile, + type MilestoneArea, + momentProgress, + trackMoments, + resolveMilestoneArea, + type BeMusicGameplayLayout, + type SkinlessGameplayChromeRuntime, + STAGE_HEIGHT as DESIGN_HEIGHT, + STAGE_WIDTH as DESIGN_WIDTH, + stageProgress, +} from '../../skin-sdk/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 = 3400; const FULL_COMBO_BURST = burstParticles(77, 640); -const JUDGE_Y = PLAYFIELD.judgementY; const MILESTONE_COLORS = [SYN_AMBER, SYN_CYAN, SYN_MAGENTA] as const; const RING_COLORS = [SYN_AMBER, SYN_EMBER, SYN_CYAN, SYN_FLARE, SYN_MAGENTA] as const; @@ -45,7 +56,7 @@ export function drawSynesthesiaMoments( playfieldCenterX: number, hue: number, pool: ChildPool, - playfieldRight: number, + layout: BeMusicGameplayLayout, ): void { const nowMs = runtime.nowMs ?? 0; const moments = trackMoments(chromeLayer, { @@ -69,7 +80,7 @@ export function drawSynesthesiaMoments( // 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, hue, pool); + 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) { @@ -78,8 +89,8 @@ export function drawSynesthesiaMoments( layer, moments.milestone.value, milestone, - resolveMilestoneArea(playfieldRight), - resolveStageBgaRect(playfieldRight), + resolveMilestoneArea(layout.playfield.right, 346, layout.stage.width), + layout.bga ?? NO_MONITOR, runtime.hasBga === true, pool, ); @@ -99,6 +110,7 @@ function drawCountIn( layer: Container, chartMs: number, playfieldCenterX: number, + judgeY: number, hue: number, pool: ChildPool, ): void { @@ -140,7 +152,7 @@ function drawCountIn( const t = Math.max(0, (start - delay) / (1 - delay)); if (t <= 0) continue; graphics - .ellipse(playfieldCenterX, JUDGE_Y, 20 + 560 * easeOutCubic(t), (20 + 560 * easeOutCubic(t)) * 0.3) + .ellipse(playfieldCenterX, judgeY, 20 + 560 * easeOutCubic(t), (20 + 560 * easeOutCubic(t)) * 0.3) .stroke({ color, width: 1 + 5 * (1 - t), alpha: 0.8 * strength * (1 - t) }); } const text = addHudText( diff --git a/packages/player-web/src/scene/default/synesthesia/playfield.ts b/packages/player-web/src/skins/synesthesia/playfield.ts similarity index 97% rename from packages/player-web/src/scene/default/synesthesia/playfield.ts rename to packages/player-web/src/skins/synesthesia/playfield.ts index 0f946579..38bd62c7 100644 --- a/packages/player-web/src/scene/default/synesthesia/playfield.ts +++ b/packages/player-web/src/skins/synesthesia/playfield.ts @@ -1,14 +1,4 @@ import { Color, FillGradient, type Graphics } from 'pixi.js'; -import type { - BeMusicBomb, - BeMusicBombsContext, - BeMusicLaneKind, - BeMusicLanesContext, - BeMusicLongNoteContext, - BeMusicNoteContext, -} from '../../../skin/be-music/types.ts'; -import { keyBeamGradient } from '../key-beam.ts'; -import type { ChildPool } from '../../pixi-utils.ts'; import { burstParticlePosition, burstParticles, @@ -22,8 +12,19 @@ import { import { burstGrainCount } from './burst-budget.ts'; import { drawReticle, sharedShapeBatch } from './draw.ts'; import { SYN_GLOW_SIZE, synGlowTexture } from './style.ts'; -import { audioDrive } from '../audio-drive.ts'; -import { comboTier, effectProfile } from '../moments.ts'; +import { + audioDrive, + type BeMusicBomb, + type BeMusicBombsContext, + type BeMusicLaneKind, + type BeMusicLanesContext, + type BeMusicLongNoteContext, + type BeMusicNoteContext, + type ChildPool, + comboTier, + effectProfile, + keyBeamGradient, +} from '../../skin-sdk/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 { diff --git a/packages/player-web/src/scene/default/synesthesia/result.ts b/packages/player-web/src/skins/synesthesia/result.ts similarity index 97% rename from packages/player-web/src/scene/default/synesthesia/result.ts rename to packages/player-web/src/skins/synesthesia/result.ts index 08461a41..5003be16 100644 --- a/packages/player-web/src/scene/default/synesthesia/result.ts +++ b/packages/player-web/src/skins/synesthesia/result.ts @@ -1,10 +1,5 @@ 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 { hash01 } from '../phantom-style.ts'; import { drawFrame, drawReticle } from './draw.ts'; -import { resolveResultLamp, resolveResultTrackRows } from '../result-track.ts'; import { burstParticlePosition, burstParticles, @@ -31,6 +26,19 @@ import { SYN_WHITE, sceneHue, } from './style.ts'; +import { + addSkinText, + easeOutBack, + easeOutCubic, + type BeMusicResultFrame, + type BeMusicResultSkin, + hash01, + resolveResultLamp, + resolveResultTrackRows, + rollUpValue, + type SkinTextOptions, + stageProgress, +} from '../../skin-sdk/index.ts'; const ROLL_DELAY_MS = 700; const ROLL_MS = 1100; diff --git a/packages/player-web/src/scene/default/synesthesia/select.ts b/packages/player-web/src/skins/synesthesia/select.ts similarity index 97% rename from packages/player-web/src/scene/default/synesthesia/select.ts rename to packages/player-web/src/skins/synesthesia/select.ts index 2ac49dc1..83cc1bdf 100644 --- a/packages/player-web/src/scene/default/synesthesia/select.ts +++ b/packages/player-web/src/skins/synesthesia/select.ts @@ -1,17 +1,4 @@ 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 { hash01 } from '../phantom-style.ts'; -import type { BeMusicAudioFrame } from '../../../skin/be-music/types.ts'; -import { audioDrive } from '../audio-drive.ts'; -import { ChildPool } from '../../pixi-utils.ts'; import { createFlock, stepFlock } from './boids.ts'; import { drawMagnetoOrb, drawPointCloud, drawReticle, drawSchool, sharedShapeBatch } from './draw.ts'; import { @@ -47,7 +34,25 @@ import { sceneHue, synGlowTexture, } from './style.ts'; -import { resolveSongRowFacts } from '../song-stats.ts'; +import { + addHitArea, + addSkinText, + easeOutCubic, + audioDrive, + type BeMusicAudioFrame, + type BeMusicSelectFrame, + type BeMusicSelectLayout, + type BeMusicSelectRenderer, + type BeMusicSelectSkin, + type BrowserBrowseEntry, + type BrowserSongEntry, + ChildPool, + formatPlayVariantLabel, + hash01, + resolveSongRowFacts, + type SkinTextOptions, + stageProgress, +} from '../../skin-sdk/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. */ diff --git a/packages/player-web/src/scene/default/synesthesia/space.test.ts b/packages/player-web/src/skins/synesthesia/space.test.ts similarity index 100% rename from packages/player-web/src/scene/default/synesthesia/space.test.ts rename to packages/player-web/src/skins/synesthesia/space.test.ts diff --git a/packages/player-web/src/scene/default/synesthesia/space.ts b/packages/player-web/src/skins/synesthesia/space.ts similarity index 99% rename from packages/player-web/src/scene/default/synesthesia/space.ts rename to packages/player-web/src/skins/synesthesia/space.ts index 999685aa..d65692c5 100644 --- a/packages/player-web/src/scene/default/synesthesia/space.ts +++ b/packages/player-web/src/skins/synesthesia/space.ts @@ -1,4 +1,4 @@ -import { hash01 } from '../phantom-style.ts'; +import { hash01 } from '../../skin-sdk/index.ts'; /** * Pure 3D helpers for the Synesthesia skin: a pinhole projection, deterministic particle bursts, a looping starfield, diff --git a/packages/player-web/src/scene/default/synesthesia/style.ts b/packages/player-web/src/skins/synesthesia/style.ts similarity index 100% rename from packages/player-web/src/scene/default/synesthesia/style.ts rename to packages/player-web/src/skins/synesthesia/style.ts diff --git a/packages/player-web/vite.config.ts b/packages/player-web/vite.config.ts index c49a045a..c775f4f0 100644 --- a/packages/player-web/vite.config.ts +++ b/packages/player-web/vite.config.ts @@ -15,6 +15,8 @@ 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). + 'skin-sdk': 'src/skin-sdk/index.ts', }, }), }); From c2f81e438c6b600b2180c5bb3a9b24043ac63e22 Mon Sep 17 00:00:00 2001 From: Seiya KONNO Date: Fri, 9 Oct 2026 13:43:26 +0900 Subject: [PATCH 36/65] fix(player-web): keep the Phantom chrome internals out of the public exports The default scene barrel re-exported the whole Phantom chrome module, which made the new renderPhantomChrome a public export without a benchmark case. Re-export only the long-standing renderDefaultGameplayFrame / renderFallbackLr2Frame entry points and their types. --- packages/player-web/src/scene/default/index.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/player-web/src/scene/default/index.ts b/packages/player-web/src/scene/default/index.ts index 6118a25b..5a2b39b7 100644 --- a/packages/player-web/src/scene/default/index.ts +++ b/packages/player-web/src/scene/default/index.ts @@ -9,7 +9,13 @@ * 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 '../../skins/phantom/chrome.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'; From 67ad752f480212cfa8d182aea20978e8c008fc7e Mon Sep 17 00:00:00 2001 From: Seiya KONNO Date: Fri, 9 Oct 2026 13:42:14 +0900 Subject: [PATCH 37/65] feat(player-web): add canvas skins and the Plain example skin defineCanvasSkin lets a skin draw each screen onto a plain canvas with a 2d, webgl, webgl2, or webgpu context instead of Pixi objects: the player collects the frame's layout, lanes, notes, hit effects, and HUD values and calls draw once before rendering. Plain, a minimal built-in skin drawn with the Canvas 2D API only, shows how to write one. --- .changeset/be-music-skins.md | 1 + .../player-web/src/scene/default/index.ts | 1 + .../player-web/src/skin-sdk/canvas-skin.ts | 339 ++++++++++++++++++ packages/player-web/src/skin-sdk/index.ts | 1 + .../player-web/src/skins/boundary.test.ts | 8 +- packages/player-web/src/skins/built-in.ts | 3 +- .../player-web/src/skins/plain/gameplay.ts | 181 ++++++++++ packages/player-web/src/skins/plain/index.ts | 24 ++ packages/player-web/src/skins/plain/result.ts | 71 ++++ packages/player-web/src/skins/plain/select.ts | 132 +++++++ packages/player-web/src/skins/plain/theme.ts | 49 +++ 11 files changed, 806 insertions(+), 4 deletions(-) create mode 100644 packages/player-web/src/skin-sdk/canvas-skin.ts create mode 100644 packages/player-web/src/skins/plain/gameplay.ts create mode 100644 packages/player-web/src/skins/plain/index.ts create mode 100644 packages/player-web/src/skins/plain/result.ts create mode 100644 packages/player-web/src/skins/plain/select.ts create mode 100644 packages/player-web/src/skins/plain/theme.ts diff --git a/.changeset/be-music-skins.md b/.changeset/be-music-skins.md index af4b2e84..ba850c4e 100644 --- a/.changeset/be-music-skins.md +++ b/.changeset/be-music-skins.md @@ -9,6 +9,7 @@ The default (skinless) family now renders through a `BeMusicSkin`: one object bu - New exports: `BeMusicSkin` and its context types, `createBeMusicSkinRegistry`, `resolveBeMusicLaneKind`, `resolveSelectListWindow`, `phantomSkin`, `synesthesiaSkin`, `latticeSkin`, and `BUILT_IN_BE_MUSIC_SKINS`. - Skins can be made outside the player: the new `@be-music/player-web/skin-sdk` subpath carries everything a skin is written against — the skin types, `defineBeMusicSkin`, the stage and layout helpers, pooled HUD text, GPU point particles, judgement words, moments, the loading caption, audio drive, song facts, the result lamp, and motion helpers. The three built-in skins now live under `src/skins/` and import nothing but this SDK, Pixi, and their own files (a test enforces the boundary), so a third-party skin can do anything they do. - 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. +- Skins can skip Pixi entirely: `defineCanvasSkin` draws every screen onto a plain canvas with the context the skin names (`'2d'`, `'webgl'`, `'webgl2'`, or `'webgpu'`, with an optional async `setup` for devices and pipelines). The player collects each frame's layout, lanes, notes, long notes, hit effects, and HUD values and calls the skin's `draw` once before rendering; `'2d'` canvases come cleared and scaled to design pixels. A new built-in example skin, Plain (`plainSkin`), is written this way with the Canvas 2D API only. - A skin's `renderChrome` now receives the frame's `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 and still the default. 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 lit by an ember horizon, a world built from fine ember / gold particles with electric blue and magenta accents, data dust and speed streaks pouring out of the vanishing point, 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 — tilted shockwave rings, 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 a 3D particle ring that bursts as the rank ignites. It draws with Michroma and M PLUS 1p, which hosts should load. diff --git a/packages/player-web/src/scene/default/index.ts b/packages/player-web/src/scene/default/index.ts index 5a2b39b7..b2486415 100644 --- a/packages/player-web/src/scene/default/index.ts +++ b/packages/player-web/src/scene/default/index.ts @@ -21,5 +21,6 @@ export * from './result.ts'; export * from './select.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/skin-sdk/canvas-skin.ts b/packages/player-web/src/skin-sdk/canvas-skin.ts new file mode 100644 index 00000000..030bdbdb --- /dev/null +++ b/packages/player-web/src/skin-sdk/canvas-skin.ts @@ -0,0 +1,339 @@ +import { CanvasSource, Container, Sprite, Texture } from 'pixi.js'; +import { getDesignTextResolution } from '../scene/core/viewport.ts'; +import type { + BeMusicAudioFrame, + BeMusicBomb, + BeMusicEffectLevel, + BeMusicGameplayLayout, + BeMusicLaneFrame, + BeMusicLaneKind, + BeMusicResultFrame, + BeMusicSelectFrame, + BeMusicSelectLayout, + BeMusicSkin, + BeMusicStage, +} from '../skin/be-music/types.ts'; +import type { SkinlessGameplayChromeRuntime } from '../scene/gameplay-chrome.ts'; +import { defineBeMusicSkin } from './define.ts'; +import { addHitArea } from './skin-text.ts'; +import { wideStage } from './stage.ts'; +import { logger } from '../logger.ts'; + +/** + * Canvas skins: draw every screen yourself onto a plain `` — with the 2D API, WebGL, or WebGPU — instead of + * building Pixi display objects. The player shows the canvas as one texture per screen, so a canvas skin needs no + * Pixi knowledge at all. + * + * Each frame the player collects what is on screen (layout, lanes, notes, long notes, hit effects, HUD values) and + * calls your `draw` once, right before it renders. With the `'2d'` context the canvas is already cleared and scaled, + * so you draw in design pixels (854×480 on the default stage); with `'webgl'` / `'webgl2'` / `'webgpu'` you own the + * whole canvas (`surface.pixelRatio` tells you its scale) and can prepare devices or pipelines in `setup`. + */ + +const log = logger('canvas-skin'); + +/** Canvas rendering contexts a canvas skin can draw with. */ +export interface CanvasContextMap { + '2d': CanvasRenderingContext2D; + webgl: WebGLRenderingContext; + webgl2: WebGL2RenderingContext; + webgpu: GPUCanvasContext; +} + +export type CanvasContextKind = keyof CanvasContextMap; + +/** The canvas a skin draws one screen on. */ +export interface CanvasSurface { + canvas: HTMLCanvasElement; + context: CanvasContextMap[K]; + /** Size in design pixels (the stage size). */ + width: number; + height: number; + /** Canvas pixels per design pixel. `'2d'` surfaces are pre-scaled by it; other contexts apply it themselves. */ + pixelRatio: number; +} + +/** A tap note as drawn this frame: its lane rect and the y its bottom edge sits at. */ +export interface CanvasNote { + kind: BeMusicLaneKind; + x: number; + w: number; + y: number; +} + +/** A long note as drawn this frame: its lane rect, tail (top) and head (bottom). */ +export interface CanvasLongNote { + kind: BeMusicLaneKind; + x: number; + w: number; + top: number; + bottom: number; +} + +/** Everything on the gameplay screen this frame. */ +export interface CanvasGameplayFrame { + nowMs: number; + /** HUD values: score, combo, gauge, judgements, song, loading, … */ + runtime: SkinlessGameplayChromeRuntime; + /** 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 CanvasNote[]; + longNotes: readonly CanvasLongNote[]; + /** Live hit effects with their age. */ + bombs: readonly BeMusicBomb[]; + /** Fractional beat position in [0, 1). */ + beatPhase: number; + effects: BeMusicEffectLevel; + audio: BeMusicAudioFrame | undefined; +} + +/** The select frame, plus a way to make parts of the canvas clickable. */ +export interface CanvasSelectFrame extends BeMusicSelectFrame { + /** Makes the rect (design pixels) run `action` when clicked — e.g. `frame.hit(x, y, w, h, frame.actions.play)`. */ + hit(x: number, y: number, w: number, h: number, action: () => void): void; +} + +export interface CanvasSkinDefinition extends Omit< + BeMusicSkin, + 'stage' | 'gameplay' | 'select' | 'result' +> { + /** Which context to draw with. */ + context: K; + /** Attributes passed to `canvas.getContext` (e.g. `{ alpha: true }`, `{ antialias: true }`). */ + contextAttributes?: CanvasRenderingContext2DSettings | WebGLContextAttributes; + /** Design canvas; defaults to the 16:9 {@link wideStage}. */ + stage?: BeMusicStage; + /** + * Called once for every new surface before its first draw — compile shaders, request a GPU device, configure a + * WebGPU context. Drawing waits until a returned promise settles. + */ + setup?: (surface: CanvasSurface) => void | Promise; + gameplay: { + draw(surface: CanvasSurface, frame: CanvasGameplayFrame): void; + /** How long a hit effect lives (ms). Default 300. */ + bombDurationMs?: number; + }; + select: { + /** Where the song list sits (the player hit-tests the rows with it). */ + layout: BeMusicSelectLayout; + /** Length of the launch outro (ms) the select screen keeps drawing after a chart is picked. Default 0. */ + outroMs?: number; + /** Return `true` while something is still animating, to be drawn again next frame. */ + draw(surface: CanvasSurface, frame: CanvasSelectFrame): boolean | void; + }; + result: { + draw(surface: CanvasSurface, frame: BeMusicResultFrame): void; + }; +} + +/** Largest canvas edge a surface allocates, whatever the window size. */ +const MAX_CANVAS_EDGE = 4096; + +/** One canvas + the texture showing it. Sized to the stage at the screen's current density. */ +class Surface { + private readonly canvas = document.createElement('canvas'); + private readonly source: CanvasSource; + readonly texture: Texture; + private context: CanvasContextMap[K] | undefined; + private state: 'new' | 'pending' | 'ready' | 'failed' = 'new'; + private pixelRatio = 1; + private readonly definition: CanvasSkinDefinition; + private readonly stage: BeMusicStage; + + constructor(definition: CanvasSkinDefinition, stage: BeMusicStage) { + this.definition = definition; + this.stage = stage; + this.source = new CanvasSource({ resource: this.canvas }); + this.texture = new Texture({ source: this.source }); + } + + /** Runs `paint` on the surface (once it is set up) and uploads the result. */ + draw(paint: (surface: CanvasSurface) => void): void { + this.resize(); + const context = this.ensureContext(); + if (!context || this.state !== 'ready') return; + const surface: CanvasSurface = { + canvas: this.canvas, + context, + width: this.stage.width, + height: this.stage.height, + pixelRatio: this.pixelRatio, + }; + if (this.definition.context === '2d') { + const ctx = 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); + } + paint(surface); + this.source.update(); + } + + destroy(): void { + this.texture.destroy(true); + } + + private resize(): void { + const { width, height } = this.stage; + const ratio = Math.min(Math.max(1, getDesignTextResolution()), MAX_CANVAS_EDGE / Math.max(width, height)); + if (ratio === this.pixelRatio && this.canvas.width > 0) return; + this.pixelRatio = ratio; + this.source.resize(width, height, ratio); + } + + private ensureContext(): CanvasContextMap[K] | undefined { + if (this.state === 'new') { + const context = this.canvas.getContext(this.definition.context, this.definition.contextAttributes) as + | CanvasContextMap[K] + | null; + if (!context) { + log.warn(`"${this.definition.id}": the browser offers no "${this.definition.context}" canvas context`); + this.state = 'failed'; + return undefined; + } + this.context = context; + this.state = 'pending'; + const setup = this.definition.setup?.({ + canvas: this.canvas, + context, + width: this.stage.width, + height: this.stage.height, + pixelRatio: this.pixelRatio, + }); + if (setup instanceof Promise) { + setup.then( + () => (this.state = 'ready'), + (error: unknown) => { + log.warn(`"${this.definition.id}": setup failed`, error); + this.state = 'failed'; + }, + ); + } else { + this.state = 'ready'; + } + } + return this.context; + } +} + +/** + * Turns a {@link CanvasSkinDefinition} into a regular `BeMusicSkin` (validated like {@link defineBeMusicSkin}). + * + * ```ts + * export default defineCanvasSkin({ + * apiVersion: BE_MUSIC_SKIN_API_VERSION, + * id: 'plain', label: 'Plain', version: '1.0.0', author: { name: 'Jane Doe' }, fontLoads: [], + * context: '2d', + * gameplay: { draw: ({ context: ctx }, frame) => { … } }, + * select: { layout, draw: ({ context: ctx }, frame) => { … } }, + * result: { draw: ({ context: ctx }, frame) => { … } }, + * }); + * ``` + */ +export function defineCanvasSkin(definition: CanvasSkinDefinition): BeMusicSkin { + const { + context: _context, + contextAttributes: _attributes, + setup: _setup, + gameplay, + select, + result, + ...meta + } = definition; + const stage = definition.stage ?? wideStage; + + // Gameplay: the hooks below only collect this frame's content; the canvas is drawn once, when Pixi renders it. + let gameplaySurface: Surface | undefined; + let frame: CanvasGameplayFrame | undefined; + let frameNumber = 0; + let drawnFrame = -1; + const drawGameplay = (): void => { + if (!frame || !gameplaySurface || drawnFrame === frameNumber) return; + drawnFrame = frameNumber; + const current = frame; + gameplaySurface.draw((surface) => gameplay.draw(surface, current)); + }; + + // Result: its frame layer is rebuilt every frame, so one surface is kept here and shown by a fresh sprite. + let resultSurface: Surface | undefined; + + return defineBeMusicSkin({ + ...meta, + stage, + gameplay: { + bombDurationMs: gameplay.bombDurationMs ?? 300, + renderChrome: ({ layerPool, runtime, layout }) => { + gameplaySurface ??= new Surface(definition, stage); + frameNumber += 1; + frame = { + nowMs: runtime.nowMs ?? 0, + runtime, + layout, + lanes: [], + notes: [], + longNotes: [], + bombs: [], + beatPhase: runtime.beatPhase ?? 0, + effects: runtime.effects ?? 'full', + audio: runtime.audio, + }; + const sprite = layerPool.acquireSprite(); + sprite.texture = gameplaySurface.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.onRender = drawGameplay; + }, + renderLanes: ({ lanes }) => { + if (frame) frame.lanes = lanes.map((lane) => ({ ...lane })); + }, + renderNote: ({ kind, x, w, y }) => { + (frame?.notes as CanvasNote[] | undefined)?.push({ kind, x, w, y }); + }, + renderLongNote: ({ kind, x, w, top, bottom }) => { + (frame?.longNotes as CanvasLongNote[] | undefined)?.push({ kind, x, w, top, bottom }); + }, + renderBombs: ({ bombs }) => { + if (frame) frame.bombs = bombs.map((bomb) => ({ ...bomb })); + }, + }, + select: { + layout: select.layout, + createRenderer: () => { + const surface = new Surface(definition, stage); + const backLayer = new Container(); + const sprite = new Sprite(surface.texture); + backLayer.addChild(sprite); + return { + outroMs: select.outroMs ?? 0, + backLayer, + frontLayer: new Container(), + render: (selectFrame) => { + const hits: Array<[number, number, number, number, () => void]> = []; + let animating = false; + surface.draw((canvas) => { + animating = select.draw(canvas, { ...selectFrame, hit: (...rect) => hits.push(rect) }) === true; + }); + for (const [x, y, w, h, action] of hits) addHitArea(selectFrame.layer, x, y, w, h, 'pointer', action); + return animating; + }, + tick: () => {}, + dispose: () => { + backLayer.destroy({ children: true }); + surface.destroy(); + }, + }; + }, + }, + result: { + render: (resultFrame) => { + resultSurface ??= new Surface(definition, stage); + const surface = resultSurface; + surface.draw((canvas) => result.draw(canvas, resultFrame)); + resultFrame.layer.addChild(new Sprite(surface.texture)); + }, + }, + }); +} diff --git a/packages/player-web/src/skin-sdk/index.ts b/packages/player-web/src/skin-sdk/index.ts index e4f79c42..525933f0 100644 --- a/packages/player-web/src/skin-sdk/index.ts +++ b/packages/player-web/src/skin-sdk/index.ts @@ -42,3 +42,4 @@ export * from './loading.ts'; export * from './audio-drive.ts'; export * from './song-stats.ts'; export * from './result-track.ts'; +export * from './canvas-skin.ts'; diff --git a/packages/player-web/src/skins/boundary.test.ts b/packages/player-web/src/skins/boundary.test.ts index 139b58a7..24e2a9d6 100644 --- a/packages/player-web/src/skins/boundary.test.ts +++ b/packages/player-web/src/skins/boundary.test.ts @@ -4,7 +4,8 @@ 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 barrel, Pixi, and - * files inside its own folder (tests may also use the test runner and Node built-ins). This keeps the SDK honest — if a + * files inside its own folder (tests may also use the test runner and Node built-ins); Plain, the Canvas 2D example, + * not even Pixi. This keeps the SDK honest — if a * built-in skin needs something, the SDK has to offer it. */ const SKINS_DIR = import.meta.dirname; @@ -25,7 +26,8 @@ function violations(file: string): string[] { const found: string[] = []; for (const match of readFileSync(file, 'utf8').matchAll(SPECIFIER)) { const specifier = match[1] ?? match[2]!; - if (specifier === 'pixi.js') continue; + // Plain is the Canvas 2D example: it must not need Pixi at all. + if (specifier === 'pixi.js' && !file.startsWith(join(SKINS_DIR, 'plain') + sep)) continue; if (isTest && (specifier === 'vite-plus/test' || specifier.startsWith('node:'))) continue; if (specifier.startsWith('.')) { const target = resolve(dirname(file), specifier); @@ -40,7 +42,7 @@ 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', 'synesthesia']); + expect(skins.map((entry) => entry.name).sort()).toEqual(['lattice', 'phantom', 'plain', 'synesthesia']); }); it.each(skins.map((entry) => entry.name))('%s imports only the skin SDK, pixi.js, and its own files', (name) => { diff --git a/packages/player-web/src/skins/built-in.ts b/packages/player-web/src/skins/built-in.ts index 63449eb4..490a38e2 100644 --- a/packages/player-web/src/skins/built-in.ts +++ b/packages/player-web/src/skins/built-in.ts @@ -1,7 +1,8 @@ import { latticeSkin } from './lattice/index.ts'; import { phantomSkin } from './phantom/index.ts'; +import { plainSkin } from './plain/index.ts'; import { synesthesiaSkin } from './synesthesia/index.ts'; import type { BeMusicSkin } from '../skin-sdk/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, latticeSkin]; +export const BUILT_IN_BE_MUSIC_SKINS: readonly BeMusicSkin[] = [phantomSkin, synesthesiaSkin, latticeSkin, plainSkin]; 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..82b6625d --- /dev/null +++ b/packages/player-web/src/skins/plain/gameplay.ts @@ -0,0 +1,181 @@ +import { + flashingGreatColor, + isFlashingGreat, + judgeDisplayWord, + loadingDots, + type CanvasGameplayFrame, + type CanvasSurface, +} from '../../skin-sdk/index.ts'; +import { + ACCENT, + DANGER, + FLASHING_GREAT, + INK, + LANE_FILL, + LINE, + MUTED, + NOTE_FILL, + PANEL, + TEXT, + css, + stat, + text, +} from './theme.ts'; + +const NOTE_HEIGHT = 6; +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 }: CanvasSurface<'2d'>, frame: CanvasGameplayFrame): 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, 'NO BGA', 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: CanvasGameplayFrame): 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) { + const beam = ctx.createLinearGradient(0, lane.top, 0, lane.bottom); + beam.addColorStop(0, 'rgba(61, 123, 255, 0)'); + beam.addColorStop(1, `rgba(61, 123, 255, ${0.45 * lane.beam})`); + ctx.fillStyle = beam; + ctx.fillRect(lane.x, lane.top, lane.w, lane.bottom - lane.top); + } + } + const { left, right, judgementY } = frame.layout.playfield; + ctx.fillStyle = DANGER; + ctx.fillRect(left, judgementY, right - left, 2); +} + +/** Tap notes as flat bars; long notes as a dimmer body between their head and tail. */ +function drawNotes(ctx: CanvasRenderingContext2D, frame: CanvasGameplayFrame): void { + for (const note of frame.longNotes) { + ctx.globalAlpha = 0.45; + ctx.fillStyle = NOTE_FILL[note.kind]; + ctx.fillRect(note.x + 3, note.top, note.w - 6, note.bottom - note.top); + ctx.globalAlpha = 1; + 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: CanvasGameplayFrame): 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: CanvasGameplayFrame): 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: CanvasGameplayFrame, width: number, height: number): void { + const { runtime, layout } = 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 = (layout.bga?.y ?? 60) + 10 + 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..b417ac1d --- /dev/null +++ b/packages/player-web/src/skins/plain/index.ts @@ -0,0 +1,24 @@ +import { BE_MUSIC_SKIN_API_VERSION, defineCanvasSkin, type BeMusicSkin } from '../../skin-sdk/index.ts'; +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 = defineCanvasSkin({ + 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..ff18b1bd --- /dev/null +++ b/packages/player-web/src/skins/plain/result.ts @@ -0,0 +1,71 @@ +import { + resolveResultLamp, + resolveResultTrackRows, + type BeMusicResultFrame, + type CanvasSurface, +} from '../../skin-sdk/index.ts'; +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 }: CanvasSurface<'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..444376fe --- /dev/null +++ b/packages/player-web/src/skins/plain/select.ts @@ -0,0 +1,132 @@ +import { + formatPlayVariantLabel, + resolveSongRowFacts, + type BeMusicSelectLayout, + type BrowserBrowseEntry, + type CanvasSelectFrame, + type CanvasSurface, +} from '../../skin-sdk/index.ts'; +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 }: CanvasSurface<'2d'>, frame: CanvasSelectFrame): boolean { + 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; keep asking for frames until it is black. + 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); + return t < 1; + } + return false; +} + +/** The left card: title, artist, the chart's key facts, and the PLAY / AUTO buttons. */ +function drawFocusedSong(ctx: CanvasRenderingContext2D, frame: CanvasSelectFrame): 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: CanvasSelectFrame, + 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: CanvasSelectFrame, 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} N ${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..bb584319 --- /dev/null +++ b/packages/player-web/src/skins/plain/theme.ts @@ -0,0 +1,49 @@ +/** 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'; + +/** A CSS font for `ctx.font`. */ +export function font(size: number, weight = 500): string { + return `${weight} ${size}px ${FONT}`; +} + +/** `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 { + ctx.font = font(options.size, options.weight); + 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 }); +} From 750c47d4b91b81c17da07ee741c2d4af621f61d5 Mon Sep 17 00:00:00 2001 From: Seiya KONNO Date: Fri, 9 Oct 2026 14:07:38 +0900 Subject: [PATCH 38/65] fix(player-web): lay out every lane of the chart's play variant Charts that only use a few lanes were laid out with just those lanes (a one-lane chart showed one lane). Add resolvePlayVariantLaneChannels to the player's lane layout and build the gameplay lanes from the variant's full set. --- .changeset/player-variant-lane-channels.md | 5 ++ .changeset/player-web-full-lanes.md | 5 ++ .../player-web/src/scene/core/gameplay.ts | 10 +-- .../player-web/src/scene/gameplay-lanes.ts | 1 + packages/player/src/core/lane-layout.test.ts | 76 +++++++++++++++++++ packages/player/src/core/lane-layout.ts | 51 +++++++++++-- 6 files changed, 136 insertions(+), 12 deletions(-) create mode 100644 .changeset/player-variant-lane-channels.md create mode 100644 .changeset/player-web-full-lanes.md 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/packages/player-web/src/scene/core/gameplay.ts b/packages/player-web/src/scene/core/gameplay.ts index 0a33907d..4d7b6b9a 100644 --- a/packages/player-web/src/scene/core/gameplay.ts +++ b/packages/player-web/src/scene/core/gameplay.ts @@ -120,7 +120,7 @@ import { } from './gameplay-bga.ts'; import { isPlayableInputChannel, - resolveLaneChannels, + resolvePlayVariantLaneChannels, resolveLr2LaneIndex, resolveSideRelativeLaneIndex, resolveSkinlessLaneLayout, @@ -1991,11 +1991,11 @@ export class CoreGameplayView { 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[] = [ From c6ce7d7ffa164390e3b5a56321d79f05f9f700eb Mon Sep 17 00:00:00 2001 From: Seiya KONNO Date: Fri, 9 Oct 2026 14:07:50 +0900 Subject: [PATCH 39/65] feat(player-web): split the DP banks and draw the judgement line per side IIDX double play now leaves a 60 px gap between the 1P and 2P banks, about the arcade cabinet's proportion. The skin SDK gains resolveLaneRuns, and every built-in skin draws its judgement line and lane grid once per side so nothing crosses the gap. --- .changeset/be-music-skins.md | 2 +- .../player-web/src/scene/gameplay-lanes.ts | 8 ++++ .../src/scene/lr2/gameplay-lanes.test.ts | 28 ++++++++++++- .../player-web/src/skin-sdk/layout.test.ts | 40 ++++++++++++++++++- packages/player-web/src/skin-sdk/layout.ts | 25 ++++++++++++ .../player-web/src/skins/lattice/playfield.ts | 32 +++++++++------ .../player-web/src/skins/phantom/playfield.ts | 9 ++--- .../player-web/src/skins/plain/gameplay.ts | 7 +++- .../src/skins/synesthesia/playfield.ts | 27 ++++++++----- 9 files changed, 144 insertions(+), 34 deletions(-) diff --git a/.changeset/be-music-skins.md b/.changeset/be-music-skins.md index ba850c4e..5024e8b9 100644 --- a/.changeset/be-music-skins.md +++ b/.changeset/be-music-skins.md @@ -18,7 +18,7 @@ The default (skinless) family now renders through a `BeMusicSkin`: one object bu - 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 and the 14 px gap between the DP 1P / 2P banks are gone (the playfield keeps its width, and `sideGap` can still request a gap), and every built-in skin draws notes and long notes across the full lane width. +- 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. diff --git a/packages/player-web/src/scene/gameplay-lanes.ts b/packages/player-web/src/scene/gameplay-lanes.ts index 22de11b8..921188a4 100644 --- a/packages/player-web/src/scene/gameplay-lanes.ts +++ b/packages/player-web/src/scene/gameplay-lanes.ts @@ -25,6 +25,13 @@ const FALLBACK_SCRATCH_LANE_WEIGHT = 1.55; */ 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). */ @@ -107,6 +114,7 @@ export function resolveSkinlessLaneLayout( 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. 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 9cfdb4d7..6b3e59d4 100644 --- a/packages/player-web/src/scene/lr2/gameplay-lanes.test.ts +++ b/packages/player-web/src/scene/lr2/gameplay-lanes.test.ts @@ -4,6 +4,7 @@ import { resolveFallbackLaneLayout, resolveFallbackPlayfieldSpan, resolveSkinlessLaneLayout, + IIDX_DP_SIDE_GAP, shouldPreserveFallbackSideWidth, usesIidxLaneWidths, } from '../gameplay-lanes.ts'; @@ -262,10 +263,33 @@ describe('resolveSkinlessLaneLayout fixed IIDX widths', () => { 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); + 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); + 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', () => { diff --git a/packages/player-web/src/skin-sdk/layout.test.ts b/packages/player-web/src/skin-sdk/layout.test.ts index c09d0b77..bd347714 100644 --- a/packages/player-web/src/skin-sdk/layout.test.ts +++ b/packages/player-web/src/skin-sdk/layout.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vite-plus/test'; -import { resolveGameplayLayout } from './layout.ts'; +import { resolveGameplayLayout, resolveLaneRuns } from './layout.ts'; import { wideStage } from './stage.ts'; const SP = ['16', '11', '12', '13', '14', '15', '18', '19']; @@ -41,3 +41,41 @@ describe('resolveGameplayLayout', () => { 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/player-web/src/skin-sdk/layout.ts b/packages/player-web/src/skin-sdk/layout.ts index dec7301b..1b91e5f5 100644 --- a/packages/player-web/src/skin-sdk/layout.ts +++ b/packages/player-web/src/skin-sdk/layout.ts @@ -54,3 +54,28 @@ export function resolveGameplayLayout(input: GameplayLayoutInput, stage: BeMusic 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/player-web/src/skins/lattice/playfield.ts b/packages/player-web/src/skins/lattice/playfield.ts index 9b41231a..8b5f1776 100644 --- a/packages/player-web/src/skins/lattice/playfield.ts +++ b/packages/player-web/src/skins/lattice/playfield.ts @@ -13,6 +13,7 @@ import { effectProfile, hash01, keyBeamGradient, + resolveLaneRuns, } from '../../skin-sdk/index.ts'; /** Lane-class colour: white keys print in ink, black keys in cobalt, the scratch in vermilion. */ @@ -76,24 +77,31 @@ export function renderLatticeLanes({ graphics, lanes, beatPhase, combo, effects for (const lane of lanes) { graphics.moveTo(lane.x + 0.5, lane.top).lineTo(lane.x + 0.5, lane.bottom); } - graphics.moveTo(right - 0.5, top).lineTo(right - 0.5, 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. + // 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); - graphics.rect(left, bottom - lineWidth, right - left, lineWidth).fill(LAT_INK); - for (let x = left; x <= right + 1e-6; x += 4) { - const long = Math.round(x - left) % 20 === 0; - graphics.moveTo(x, bottom).lineTo(x, bottom + (long ? 5 : 2)); + 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 tick to tick across the line, one lane per beat. - const span = right - left; - const steps = Math.max(1, lanes.length); - const beat = Math.floor(beatPhase * steps); + // 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 markerX = left + ((beat + springEase(Math.min(1, within * 2), 2.4, 0.5)) / steps) * span; - graphics.rect(Math.min(right - 6, markerX), bottom - lineWidth - 3, 6, 3).fill(LAT_ACCENT); + 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 }: BeMusicNoteContext): void { diff --git a/packages/player-web/src/skins/phantom/playfield.ts b/packages/player-web/src/skins/phantom/playfield.ts index 71891886..faaa0cce 100644 --- a/packages/player-web/src/skins/phantom/playfield.ts +++ b/packages/player-web/src/skins/phantom/playfield.ts @@ -6,6 +6,7 @@ import { type BeMusicLongNoteContext, type BeMusicNoteContext, keyBeamGradient, + resolveLaneRuns, } from '../../skin-sdk/index.ts'; /** Colours for one lane class: note highlight / body / shade, and the key cap at rest / pressed. */ @@ -38,14 +39,12 @@ export function renderPhantomLanes({ graphics, lanes, beatPhase }: BeMusicLanesC 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 — 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. @@ -83,9 +82,9 @@ 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 }); } } diff --git a/packages/player-web/src/skins/plain/gameplay.ts b/packages/player-web/src/skins/plain/gameplay.ts index 82b6625d..359414dc 100644 --- a/packages/player-web/src/skins/plain/gameplay.ts +++ b/packages/player-web/src/skins/plain/gameplay.ts @@ -3,6 +3,7 @@ import { isFlashingGreat, judgeDisplayWord, loadingDots, + resolveLaneRuns, type CanvasGameplayFrame, type CanvasSurface, } from '../../skin-sdk/index.ts'; @@ -65,9 +66,11 @@ function drawLanes(ctx: CanvasRenderingContext2D, frame: CanvasGameplayFrame): v ctx.fillRect(lane.x, lane.top, lane.w, lane.bottom - lane.top); } } - const { left, right, judgementY } = frame.layout.playfield; + // One judgement line per play side, so it never crosses the gap between the double-play banks. ctx.fillStyle = DANGER; - ctx.fillRect(left, judgementY, right - left, 2); + for (const run of resolveLaneRuns(frame.lanes)) { + ctx.fillRect(run.left, frame.layout.playfield.judgementY, run.right - run.left, 2); + } } /** Tap notes as flat bars; long notes as a dimmer body between their head and tail. */ diff --git a/packages/player-web/src/skins/synesthesia/playfield.ts b/packages/player-web/src/skins/synesthesia/playfield.ts index 38bd62c7..abaec3b5 100644 --- a/packages/player-web/src/skins/synesthesia/playfield.ts +++ b/packages/player-web/src/skins/synesthesia/playfield.ts @@ -24,6 +24,7 @@ import { comboTier, effectProfile, keyBeamGradient, + resolveLaneRuns, } from '../../skin-sdk/index.ts'; /** Per-lane-class light: note body, glow hue (0..1), and the colour the lane beam / key cap light up in. */ @@ -63,7 +64,6 @@ export function renderSynesthesiaLanes({ 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]; @@ -71,7 +71,6 @@ export function renderSynesthesiaLanes({ gridTop = Math.min(gridTop, top); gridBottom = Math.max(gridBottom, bottom); gridLeft = Math.min(gridLeft, x); - gridRight = Math.max(gridRight, x + w); // 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. @@ -101,17 +100,23 @@ export function renderSynesthesiaLanes({ 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: 0xff7a1e, alpha: 0.25 }); + // 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. - const lineW = gridRight - gridLeft; + // 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; - graphics.rect(gridLeft, y - 14, lineW, 22).fill({ color: lineColor, alpha: (0.05 + 0.08 * pulse) * heat }); - graphics - .rect(gridLeft, y - 6, lineW, 8) - .fill({ color: lineColor, alpha: Math.min(0.8, (0.16 + 0.14 * pulse) * heat) }); - graphics.rect(gridLeft, y - 3, lineW, 3).fill({ color: 0xffd08a, alpha: 0.85 }); - graphics.rect(gridLeft, y - 2, lineW, 1).fill(0xffffff); + 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 }: BeMusicNoteContext): void { From a53f3d9c3abbca250d65f442dab3c4841961dc92 Mon Sep 17 00:00:00 2001 From: Seiya KONNO Date: Fri, 9 Oct 2026 14:08:05 +0900 Subject: [PATCH 40/65] feat(player-web): drop the speed lines from Synesthesia Remove every streak radiating from the vanishing point: the warp lines in gameplay and the idle monitor, the select backdrop's streaks and the launch warp that stretched the stars into them, and the result backdrop's streaks. --- .changeset/be-music-skins.md | 4 +- .../src/skins/synesthesia/gameplay-chrome.ts | 54 ++++--------------- .../src/skins/synesthesia/result.ts | 21 +------- .../src/skins/synesthesia/select.ts | 37 +++---------- 4 files changed, 20 insertions(+), 96 deletions(-) diff --git a/.changeset/be-music-skins.md b/.changeset/be-music-skins.md index 5024e8b9..124c0112 100644 --- a/.changeset/be-music-skins.md +++ b/.changeset/be-music-skins.md @@ -12,7 +12,7 @@ The default (skinless) family now renders through a `BeMusicSkin`: one object bu - Skins can skip Pixi entirely: `defineCanvasSkin` draws every screen onto a plain canvas with the context the skin names (`'2d'`, `'webgl'`, `'webgl2'`, or `'webgpu'`, with an optional async `setup` for devices and pipelines). The player collects each frame's layout, lanes, notes, long notes, hit effects, and HUD values and calls the skin's `draw` once before rendering; `'2d'` canvases come cleared and scaled to design pixels. A new built-in example skin, Plain (`plainSkin`), is written this way with the Canvas 2D API only. - A skin's `renderChrome` now receives the frame's `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 and still the default. 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 lit by an ember horizon, a world built from fine ember / gold particles with electric blue and magenta accents, data dust and speed streaks pouring out of the vanishing point, 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 — tilted shockwave rings, 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 a 3D particle ring that bursts as the rank ignites. It draws with Michroma and M PLUS 1p, which hosts should load. +- Synesthesia is inspired by synaesthetic sound-and-light rhythm shooters: a black void lit by an ember horizon, 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 — tilted shockwave rings, 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 a 3D particle ring that bursts 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 `renderChrome` (as `runtime.audio`), `renderLanes` and `renderBombs` (as `audio`), and the select renderer's `tick` (fourth argument). Gameplay analyses the whole mix off the audio bus output; select analyses the BGM and chart preview. 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. @@ -23,7 +23,7 @@ The default (skinless) family now renders through a `BeMusicSkin`: one object bu - 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 detonates a ring of light), a cut-in every 100 combo, a flourish when the gauge crosses the clear line, and a full-combo finale (Phantom's turning burst with confetti, Synesthesia's 3D spark sphere and prism rings). 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, speed streaks thicken, the floor burns hotter, more particle rivers and beat rings appear, and hits throw more sparks further. Overlapping hits now dim their white flash so colour stays the hero. +- 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 and beat rings 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. diff --git a/packages/player-web/src/skins/synesthesia/gameplay-chrome.ts b/packages/player-web/src/skins/synesthesia/gameplay-chrome.ts index 11831e86..a15569e9 100644 --- a/packages/player-web/src/skins/synesthesia/gameplay-chrome.ts +++ b/packages/player-web/src/skins/synesthesia/gameplay-chrome.ts @@ -121,7 +121,7 @@ const PYRAMIDS = [pointCloudPyramid(3, 520), pointCloudPyramid(8, 420), pointClo /** * Synesthesia gameplay HUD after a cosmic particle world: a black void lit by an ember horizon, data dust and speed - * streaks pouring out of the vanishing point, a floor of light points scrolling toward the player, rivers of particles + * 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. */ @@ -145,19 +145,18 @@ export function renderSynesthesiaChrome({ const space = layerPool.acquireGraphics(); space.label = 'synesthesia-gameplay/space'; space.blendMode = 'normal'; - // Zone: the space intensifies with the combo (faster, denser dust, more streaks, a hotter floor, particle rivers). + // 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 streaks and tints the sky with the pressed lane's light. + // 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, schools and warp. Reduced effects halve the moves; effects off keep the camera at rest. + // 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, onsets fire - // warp streaks, rivers weave with the mids, the schools tighten on bass hits and scatter on transients. + // 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, pulse, hasBga, tier, hit, hitColor, horizon, drive); @@ -279,7 +278,7 @@ const IMPULSE_COLORS: Record<'white' | 'black' | 'scratch', number> = { scratch: SYN_MAGENTA, }; -/** Vanishing point the dust and streaks pour out of (behind the monitor). */ +/** Vanishing point the dust pours out of (behind the monitor). */ const VANISH = { x: CENTER_X, y: FLOOR.horizon - 90 } as const; /** @@ -321,8 +320,8 @@ function drawGround( } /** - * Data dust (tiny warm squares, the odd blue / magenta mote) flying out of the vanishing point, and speed streaks — - * warp lines — that multiply with the combo tier and surge on key presses. + * 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, @@ -353,25 +352,6 @@ function drawDust( } batch.rect(graphics, color, 0.3 + 0.6 * nearness, projected.x - size / 2, projected.y - size / 2, size, size); } - const vanish = vanishingPoint(camera, VANISH.x, VANISH.y, 180); - const streaks = Math.round(8 + 12 * tier + 14 * hit + 18 * drive.onset); - const streakSpeed = (0.45 + 0.3 * tier) * (1 + 1.5 * hit); - for (let index = 0; index < streaks; index += 1) { - const angle = hash01(index * 5 + 1) * Math.PI * 2; - const progress = (hash01(index * 5 + 2) + seconds * streakSpeed * (0.7 + 0.6 * hash01(index * 5 + 3))) % 1; - const inner = 40 + progress * progress * 520; - const outer = inner + (12 + 80 * progress) * (1 + 0.4 * tier); - const cos = Math.cos(angle); - const sin = Math.sin(angle) * 0.72; - const x0 = vanish.x + cos * inner; - const y0 = vanish.y + sin * inner; - const x1 = vanish.x + cos * outer; - const y1 = vanish.y + sin * outer; - if (hasBga && (insideBga(x0, y0, 6) || insideBga(x1, y1, 6))) continue; - const alpha = Math.sin(progress * Math.PI) * (0.35 + 0.1 * tier); - const color = index % 6 === 0 ? SYN_CYAN : emberColor(0.55 + 0.4 * hash01(index * 5 + 4)); - batch.line(graphics, color, alpha, 0.8 + progress * 1.4, x0, y0, x1, y1); - } batch.flush(); } @@ -561,7 +541,7 @@ function drawPlayfieldWell( /** * 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, streaks pouring past — the + * 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( @@ -648,22 +628,6 @@ function drawBgaFrame( } } sharedShapeBatch.flush(); - // Streaks pouring past from the vanishing point. - for (let index = 0; index < 18; index += 1) { - const angle = hash01(index * 7 + 1) * Math.PI * 2; - const progress = (hash01(index * 7 + 2) + seconds * 0.5) % 1; - const inner = 10 + progress * progress * 200; - const outer = inner + 6 + 40 * progress; - const x0 = vanish.x + Math.cos(angle) * inner; - const y0 = horizon - 40 + Math.sin(angle) * inner * 0.7; - const x1 = vanish.x + Math.cos(angle) * outer; - const y1 = horizon - 40 + Math.sin(angle) * outer * 0.7; - if (inside(x0, y0) || inside(x1, y1)) continue; - light - .moveTo(x0, y0) - .lineTo(x1, y1) - .stroke({ color: index % 5 === 0 ? SYN_CYAN : SYN_AMBER, width: 1, alpha: Math.sin(progress * Math.PI) * 0.5 }); - } // 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 }); diff --git a/packages/player-web/src/skins/synesthesia/result.ts b/packages/player-web/src/skins/synesthesia/result.ts index 5003be16..26badead 100644 --- a/packages/player-web/src/skins/synesthesia/result.ts +++ b/packages/player-web/src/skins/synesthesia/result.ts @@ -32,7 +32,6 @@ import { easeOutCubic, type BeMusicResultFrame, type BeMusicResultSkin, - hash01, resolveResultLamp, resolveResultTrackRows, rollUpValue, @@ -69,7 +68,7 @@ const RANK_BURST = burstParticles(29, 260); export const synesthesiaResultSkin: BeMusicResultSkin = { render: (frame) => renderSynesthesiaResult(frame) }; /** - * Synesthesia result, in the light of a cosmic particle world: a black void with ember dust, speed streaks and a floor of light + * 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 inside a spinning 3D ring of particles that bursts * outward the moment it lands — a lock-on reticle snapping shut on it. @@ -111,7 +110,7 @@ export function renderSynesthesiaResult(frame: BeMusicResultFrame): void { 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, an ember horizon, dust and streaks pouring out of the vanishing point, and a floor of points. + // Space: warm black, an ember horizon, dust pouring out of the vanishing point, and a floor of points. const space = group('space'); const bands: ReadonlyArray = [ [SYN_DEEP, 0.3], @@ -147,22 +146,6 @@ export function renderSynesthesiaResult(frame: BeMusicResultFrame): void { .rect(projected.x - size / 2, projected.y - size / 2, size, size) .fill({ color, alpha: 0.25 + 0.6 * nearness }); } - for (let index = 0; index < 18; index += 1) { - const angle = hash01(index * 5 + 1) * Math.PI * 2; - const progress = (hash01(index * 5 + 2) + seconds * 0.4 * (0.7 + 0.6 * hash01(index * 5 + 3))) % 1; - const inner = 40 + progress * progress * 520; - const outer = inner + 12 + 80 * progress; - const cos = Math.cos(angle); - const sin = Math.sin(angle) * 0.72; - light - .moveTo(vanishX + cos * inner, vanishY + sin * inner) - .lineTo(vanishX + cos * outer, vanishY + sin * outer) - .stroke({ - color: index % 6 === 0 ? SYN_CYAN : emberColor(0.55 + 0.4 * hash01(index * 5 + 4)), - width: 0.8 + progress * 1.4, - alpha: Math.sin(progress * Math.PI) * 0.35, - }); - } 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) { diff --git a/packages/player-web/src/skins/synesthesia/select.ts b/packages/player-web/src/skins/synesthesia/select.ts index 83cc1bdf..5b01fa85 100644 --- a/packages/player-web/src/skins/synesthesia/select.ts +++ b/packages/player-web/src/skins/synesthesia/select.ts @@ -63,7 +63,6 @@ const SLIDE_MS = 320; const OUTRO_MS = 700; const INTRO_STAGGER_MS = 45; const STAR_COUNT = 320; -const STREAK_COUNT = 26; const RIVER_PARTICLES = 340; const ORB_SHELL = fibonacciSphere(900, 23); const ORB = orbitParticles(23, 30); @@ -99,8 +98,8 @@ function framePanel(graphics: Graphics, x: number, y: number, w: number, h: numb } /** - * Synesthesia song select, set in a cosmic particle world. The persistent back layer is a particle world — data dust and - * speed streaks pouring out of the vanishing point at the focused chart's tempo, point-cloud pyramids on an ember + * 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 on an ember * horizon, 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. */ @@ -108,7 +107,7 @@ class SynesthesiaSelectRenderer implements BeMusicSelectRenderer { public readonly backLayer = new Container(); public readonly frontLayer = new Container(); private readonly ground = new Graphics(); - /** Additive particle world: floor, pyramids, river, streaks, and the orb's tails. */ + /** Additive particle world: floor, pyramids, river, and the orb's tails. */ private readonly world = new Graphics(); private readonly stars: Sprite[] = []; private readonly lock = new Graphics(); @@ -164,7 +163,7 @@ class SynesthesiaSelectRenderer implements BeMusicSelectRenderer { } /** - * Launch outro — the warp: the star tunnel accelerates into streaks (in `tick`), a white bloom opens from the + * Launch outro — the warp: the star field rushes forward (in `tick`), a white bloom opens from the * vanishing point with the chosen title in it, and the screen falls to black for the gameplay count-in. */ private renderOutro(frame: BeMusicSelectFrame): void { @@ -227,7 +226,6 @@ class SynesthesiaSelectRenderer implements BeMusicSelectRenderer { // Data dust pouring out of the vanishing point — speed follows the focused chart's tempo. const cx = this.designWidth * 0.58; const cy = this.designHeight * 0.44; - const dustVanish = vanishingPoint(camera, cx, cy, 200); const speed = (80 + beatsPerSecond * 55) * (1 + 16 * warp * warp) * (1 + 1.2 * drive.level); this.travel += dt * speed * (warp > 0 ? 1 : rate); for (let index = 0; index < this.stars.length; index += 1) { @@ -239,9 +237,8 @@ class SynesthesiaSelectRenderer implements BeMusicSelectRenderer { const nearness = Math.min(1, projected.scale); const size = 1 + 3 * nearness * nearness; star.position.set(projected.x, projected.y); - // During the warp motes stretch into streaks pointing out of the vanishing point. - star.rotation = Math.atan2(projected.y - dustVanish.y, projected.x - dustVanish.x); - star.width = size * (1 + 14 * warp); + star.rotation = 0; + star.width = size; star.height = size; star.alpha = 0.2 + 0.8 * nearness; star.tint = index % 9 === 0 ? SYN_CYAN : index % 13 === 0 ? SYN_MAGENTA : emberColor(0.4 + 0.6 * nearness); @@ -280,7 +277,7 @@ class SynesthesiaSelectRenderer implements BeMusicSelectRenderer { projectPoint(viewPoint({ x, y: 150, z }, camera, WORLD_ORBIT), cx, floorY, 200); const spacing = 100; const offset = this.travel % spacing; - // Floor points, river, and streaks go out as a few batched instructions. + // 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; @@ -322,26 +319,6 @@ class SynesthesiaSelectRenderer implements BeMusicSelectRenderer { head.y, ); } - // Speed streaks — warp lines — thicken into a tunnel on launch. - const streakSpeed = (0.35 + beatsPerSecond * 0.12) * (1 + 6 * warp); - for (let index = 0; index < STREAK_COUNT; index += 1) { - const angle = hash01(index * 5 + 1) * Math.PI * 2; - const progress = (hash01(index * 5 + 2) + seconds * streakSpeed * (0.7 + 0.6 * hash01(index * 5 + 3))) % 1; - const inner = 30 + progress * progress * 560; - const outer = inner + (14 + 90 * progress) * (1 + 4 * warp); - const cos = Math.cos(angle); - const sin = Math.sin(angle) * 0.72; - batch.line( - world, - index % 6 === 0 ? SYN_CYAN : emberColor(0.55 + 0.4 * hash01(index * 5 + 4)), - Math.sin(progress * Math.PI) * (0.35 + 0.4 * warp), - 0.8 + progress * 1.6, - dustVanish.x + cos * inner, - dustVanish.y + sin * inner, - dustVanish.x + cos * outer, - dustVanish.y + sin * outer, - ); - } batch.flush(); // Schools of light fish sweeping through the pyramids: tight on the beat, scattering when the cursor moves. if (rate > 0) { From ebe4476ac273edb870b211c55b50e78a30cebe05 Mon Sep 17 00:00:00 2001 From: Seiya KONNO Date: Fri, 9 Oct 2026 14:08:14 +0900 Subject: [PATCH 41/65] fix(player-web): show canvas skins dot by dot Canvas skin surfaces are now sized to the device pixels the stage covers on screen (viewport scale times devicePixelRatio, unrounded) and sampled nearest with rounded sprite positions, so Plain's canvas maps one pixel to one screen pixel instead of being resampled. --- .changeset/be-music-skins.md | 2 +- .../player-web/src/scene/core/gameplay.ts | 8 +++++++- packages/player-web/src/scene/core/result.ts | 8 +++++++- packages/player-web/src/scene/core/select.ts | 8 +++++++- .../player-web/src/scene/core/viewport.ts | 15 +++++++++++++++ .../player-web/src/skin-sdk/canvas-skin.ts | 19 +++++++++++++------ 6 files changed, 50 insertions(+), 10 deletions(-) diff --git a/.changeset/be-music-skins.md b/.changeset/be-music-skins.md index 124c0112..f2f18220 100644 --- a/.changeset/be-music-skins.md +++ b/.changeset/be-music-skins.md @@ -9,7 +9,7 @@ The default (skinless) family now renders through a `BeMusicSkin`: one object bu - New exports: `BeMusicSkin` and its context types, `createBeMusicSkinRegistry`, `resolveBeMusicLaneKind`, `resolveSelectListWindow`, `phantomSkin`, `synesthesiaSkin`, `latticeSkin`, and `BUILT_IN_BE_MUSIC_SKINS`. - Skins can be made outside the player: the new `@be-music/player-web/skin-sdk` subpath carries everything a skin is written against — the skin types, `defineBeMusicSkin`, the stage and layout helpers, pooled HUD text, GPU point particles, judgement words, moments, the loading caption, audio drive, song facts, the result lamp, and motion helpers. The three built-in skins now live under `src/skins/` and import nothing but this SDK, Pixi, and their own files (a test enforces the boundary), so a third-party skin can do anything they do. - 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. -- Skins can skip Pixi entirely: `defineCanvasSkin` draws every screen onto a plain canvas with the context the skin names (`'2d'`, `'webgl'`, `'webgl2'`, or `'webgpu'`, with an optional async `setup` for devices and pipelines). The player collects each frame's layout, lanes, notes, long notes, hit effects, and HUD values and calls the skin's `draw` once before rendering; `'2d'` canvases come cleared and scaled to design pixels. A new built-in example skin, Plain (`plainSkin`), is written this way with the Canvas 2D API only. +- Skins can skip Pixi entirely: `defineCanvasSkin` draws every screen onto a plain canvas with the context the skin names (`'2d'`, `'webgl'`, `'webgl2'`, or `'webgpu'`, with an optional async `setup` for devices and pipelines). The player collects each frame's layout, lanes, notes, long notes, hit effects, and HUD values and calls the skin's `draw` once before rendering; `'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 written this way with the Canvas 2D API only. - A skin's `renderChrome` now receives the frame's `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 and still the default. 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 lit by an ember horizon, 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 — tilted shockwave rings, 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 a 3D particle ring that bursts as the rank ignites. It draws with Michroma and M PLUS 1p, which hosts should load. diff --git a/packages/player-web/src/scene/core/gameplay.ts b/packages/player-web/src/scene/core/gameplay.ts index 4d7b6b9a..fe0df959 100644 --- a/packages/player-web/src/scene/core/gameplay.ts +++ b/packages/player-web/src/scene/core/gameplay.ts @@ -143,7 +143,12 @@ import type { import { resolveBeMusicLaneKind } from '../../skin/be-music/registry.ts'; import { resolveGameplayLayout } from '../../skin-sdk/layout.ts'; import { phantomSkin } from '../../skins/phantom/index.ts'; -import { resolveDesignTextResolution, resolveScaledViewport, setDesignTextResolution } from './viewport.ts'; +import { + resolveDesignTextResolution, + resolveScaledViewport, + setDesignPixelRatio, + setDesignTextResolution, +} from './viewport.ts'; import type { PixiGameplayResultData } from './result-data.ts'; import { logger } from '../../logger.ts'; @@ -3148,6 +3153,7 @@ export class CoreGameplayView 0 ? value : 1; +} + +export function getDesignPixelRatio(): number { + return designPixelRatio; +} diff --git a/packages/player-web/src/skin-sdk/canvas-skin.ts b/packages/player-web/src/skin-sdk/canvas-skin.ts index 030bdbdb..54da179e 100644 --- a/packages/player-web/src/skin-sdk/canvas-skin.ts +++ b/packages/player-web/src/skin-sdk/canvas-skin.ts @@ -1,5 +1,5 @@ import { CanvasSource, Container, Sprite, Texture } from 'pixi.js'; -import { getDesignTextResolution } from '../scene/core/viewport.ts'; +import { getDesignPixelRatio } from '../scene/core/viewport.ts'; import type { BeMusicAudioFrame, BeMusicBomb, @@ -131,7 +131,7 @@ export interface CanvasSkinDefinition extends Omit< /** Largest canvas edge a surface allocates, whatever the window size. */ const MAX_CANVAS_EDGE = 4096; -/** One canvas + the texture showing it. Sized to the stage at the screen's current density. */ +/** One canvas + the texture showing it, sized to the screen pixels the stage covers (dot by dot). */ class Surface { private readonly canvas = document.createElement('canvas'); private readonly source: CanvasSource; @@ -145,7 +145,8 @@ class Surface { constructor(definition: CanvasSkinDefinition, stage: BeMusicStage) { this.definition = definition; this.stage = stage; - this.source = new CanvasSource({ resource: this.canvas }); + // 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 }); } @@ -175,9 +176,14 @@ class Surface { this.texture.destroy(true); } + /** + * 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 ratio = Math.min(Math.max(1, getDesignTextResolution()), MAX_CANVAS_EDGE / Math.max(width, height)); + 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); @@ -284,6 +290,7 @@ export function defineCanvasSkin(definition: Canvas // 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 = drawGameplay; }, renderLanes: ({ lanes }) => { @@ -304,7 +311,7 @@ export function defineCanvasSkin(definition: Canvas createRenderer: () => { const surface = new Surface(definition, stage); const backLayer = new Container(); - const sprite = new Sprite(surface.texture); + const sprite = new Sprite({ texture: surface.texture, roundPixels: true }); backLayer.addChild(sprite); return { outroMs: select.outroMs ?? 0, @@ -332,7 +339,7 @@ export function defineCanvasSkin(definition: Canvas resultSurface ??= new Surface(definition, stage); const surface = resultSurface; surface.draw((canvas) => result.draw(canvas, resultFrame)); - resultFrame.layer.addChild(new Sprite(surface.texture)); + resultFrame.layer.addChild(new Sprite({ texture: surface.texture, roundPixels: true })); }, }, }); From bfb007af5d1ae4972afa8bd93f1614daa18250a0 Mon Sep 17 00:00:00 2001 From: Seiya KONNO Date: Fri, 9 Oct 2026 15:34:01 +0900 Subject: [PATCH 42/65] fix(player-web): sharpen Synesthesia notes and tidy the 16:9 layouts Synesthesia notes now differ in lightness as well as hue (white, deep blue, hot magenta) behind a dark rim, without the halo around them, so the lane kinds read apart. Its result ring no longer runs into the DJ LEVEL label, and Plain's judgement column stays level with the BGA band in double play. --- .../player-web/src/skins/plain/gameplay.ts | 6 ++++-- .../src/skins/synesthesia/playfield.ts | 20 +++++++++++-------- .../src/skins/synesthesia/result.ts | 5 +++-- 3 files changed, 19 insertions(+), 12 deletions(-) diff --git a/packages/player-web/src/skins/plain/gameplay.ts b/packages/player-web/src/skins/plain/gameplay.ts index 359414dc..dc4fb51b 100644 --- a/packages/player-web/src/skins/plain/gameplay.ts +++ b/packages/player-web/src/skins/plain/gameplay.ts @@ -24,6 +24,8 @@ import { } 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. */ @@ -129,7 +131,7 @@ function judgeColor(judge: string): string { /** Header (mode, tempo, speed), the gauge under the lanes, the track and the score figures along the bottom. */ function drawHud(ctx: CanvasRenderingContext2D, frame: CanvasGameplayFrame, width: number, height: number): void { - const { runtime, layout } = frame; + 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, { @@ -177,7 +179,7 @@ function drawHud(ctx: CanvasRenderingContext2D, frame: CanvasGameplayFrame, widt ]; const columnX = width - MARGIN - 80; counts.forEach(([label, count], index) => { - const y = (layout.bga?.y ?? 60) + 10 + index * 22; + 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/synesthesia/playfield.ts b/packages/player-web/src/skins/synesthesia/playfield.ts index abaec3b5..29414006 100644 --- a/packages/player-web/src/skins/synesthesia/playfield.ts +++ b/packages/player-web/src/skins/synesthesia/playfield.ts @@ -36,10 +36,14 @@ interface LaneLight { // 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: 0xfff1dc, glow: 0xff7a1e, hue: 0.07 }, - black: { body: 0xa6dcff, glow: 0x3fb4ff, hue: 0.57 }, - scratch: { body: 0xff9fd0, glow: 0xff3d9e, hue: 0.92 }, + 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; @@ -129,19 +133,19 @@ export function renderSynesthesiaLongNote({ graphics, kind, x, w, top, bottom }: 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: soft outer glow, translucent core, bright centre filament. - graphics.rect(bodyX - 2, bodyTop, bodyW + 4, bodyH).fill({ color: light.glow, alpha: 0.08 }); + // 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 same-hue halo — glowing, but still a plain rectangle to read. */ +/** 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 { - graphics.rect(x - 3, y - NOTE_HEIGHT - 3, w + 6, NOTE_HEIGHT + 6).fill({ color: light.glow, alpha: 0.14 }); + // 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.95 }); + 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}. */ diff --git a/packages/player-web/src/skins/synesthesia/result.ts b/packages/player-web/src/skins/synesthesia/result.ts index 26badead..4f3e1ec7 100644 --- a/packages/player-web/src/skins/synesthesia/result.ts +++ b/packages/player-web/src/skins/synesthesia/result.ts @@ -175,7 +175,8 @@ export function renderSynesthesiaResult(frame: BeMusicResultFrame): void { // Rank: a spinning 3D particle ring that bursts outward as the letter ignites. const rank = group('rank'); const rankCx = MARGIN + 88; - const rankCy = TOP + 68; + // The ring 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); @@ -183,7 +184,7 @@ export function renderSynesthesiaResult(frame: BeMusicResultFrame): void { 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 radius = 50 * ringIn; const point = projectPoint( rotateX( rotateY({ x: Math.cos(angle) * radius, y: 0, z: Math.sin(angle) * radius }, 0.2), From 955c1d32fc527c99bc521baa0ed807c3f713a577 Mon Sep 17 00:00:00 2001 From: Seiya KONNO Date: Fri, 9 Oct 2026 15:34:01 +0900 Subject: [PATCH 43/65] feat(player-web): let hosts add skins and stop loading Lattice by default BeMusicSkinRegistry gains add (validated, replacing a skin with the same id) and subscribe. BUILT_IN_BE_MUSIC_SKINS now loads Phantom, Synesthesia, and Plain; Lattice remains available as latticeSkin. The demo can add Lattice or a skin module from a URL, and re-adds Lattice when it was the last skin picked. --- .changeset/be-music-skins.md | 1 + packages/player-web-demo/src/main.ts | 69 ++++++++++++++++++- .../src/skin/be-music/registry.test.ts | 26 +++++++ .../player-web/src/skin/be-music/registry.ts | 31 ++++++++- packages/player-web/src/skins/built-in.ts | 8 ++- 5 files changed, 127 insertions(+), 8 deletions(-) diff --git a/.changeset/be-music-skins.md b/.changeset/be-music-skins.md index f2f18220..a0718999 100644 --- a/.changeset/be-music-skins.md +++ b/.changeset/be-music-skins.md @@ -8,6 +8,7 @@ The default (skinless) family now renders through a `BeMusicSkin`: one object bu - New exports: `BeMusicSkin` and its context types, `createBeMusicSkinRegistry`, `resolveBeMusicLaneKind`, `resolveSelectListWindow`, `phantomSkin`, `synesthesiaSkin`, `latticeSkin`, and `BUILT_IN_BE_MUSIC_SKINS`. - Skins can be made outside the player: the new `@be-music/player-web/skin-sdk` subpath carries everything a skin is written against — the skin types, `defineBeMusicSkin`, the stage and layout helpers, pooled HUD text, GPU point particles, judgement words, moments, the loading caption, audio drive, song facts, the result lamp, and motion helpers. The three built-in skins now live under `src/skins/` and import nothing but this SDK, Pixi, and their own files (a test enforces the boundary), so a third-party skin can do anything they do. +- 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 Phantom, Synesthesia, 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. - Skins can skip Pixi entirely: `defineCanvasSkin` draws every screen onto a plain canvas with the context the skin names (`'2d'`, `'webgl'`, `'webgl2'`, or `'webgpu'`, with an optional async `setup` for devices and pipelines). The player collects each frame's layout, lanes, notes, long notes, hit effects, and HUD values and calls the skin's `draw` once before rendering; `'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 written this way with the Canvas 2D API only. - A skin's `renderChrome` now receives the frame's `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. diff --git a/packages/player-web-demo/src/main.ts b/packages/player-web-demo/src/main.ts index b46c0570..47445c51 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 { @@ -846,12 +856,26 @@ 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') @@ -1718,6 +1742,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. 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 a2ee35d3..3b1d89b0 100644 --- a/packages/player-web/src/skin/be-music/registry.test.ts +++ b/packages/player-web/src/skin/be-music/registry.test.ts @@ -36,6 +36,32 @@ describe('createBeMusicSkinRegistry', () => { 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); }); diff --git a/packages/player-web/src/skin/be-music/registry.ts b/packages/player-web/src/skin/be-music/registry.ts index f63c2b63..bd72ea4c 100644 --- a/packages/player-web/src/skin/be-music/registry.ts +++ b/packages/player-web/src/skin/be-music/registry.ts @@ -7,9 +7,18 @@ import { logger } from '../../logger.ts'; 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 { @@ -21,7 +30,7 @@ export interface BeMusicSkinRegistryOptions { } /** - * Registry over a fixed skin list. Skins whose declaration doesn't validate (an unsupported `apiVersion`, a malformed + * 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. */ @@ -49,9 +58,25 @@ export function createBeMusicSkinRegistry( } 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); + }; + }, }; } diff --git a/packages/player-web/src/skins/built-in.ts b/packages/player-web/src/skins/built-in.ts index 490a38e2..dcafffa5 100644 --- a/packages/player-web/src/skins/built-in.ts +++ b/packages/player-web/src/skins/built-in.ts @@ -1,8 +1,10 @@ -import { latticeSkin } from './lattice/index.ts'; import { phantomSkin } from './phantom/index.ts'; import { plainSkin } from './plain/index.ts'; import { synesthesiaSkin } from './synesthesia/index.ts'; import type { BeMusicSkin } from '../skin-sdk/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, latticeSkin, plainSkin]; +/** + * 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[] = [phantomSkin, synesthesiaSkin, plainSkin]; From 0d66a74426445bf23b476d053c3f8fab3ec07a86 Mon Sep 17 00:00:00 2001 From: Seiya KONNO Date: Fri, 9 Oct 2026 15:42:53 +0900 Subject: [PATCH 44/65] feat(player-web): default to Synesthesia and preload every registered skin's fonts BUILT_IN_BE_MUSIC_SKINS now starts with Synesthesia, so a first visit opens on it. The demo preloads the faces of every registered skin, including a Lattice re-added at startup; without them Lattice's select screen kept re-measuring its type while the fonts were missing (about 17% more frame time). --- .changeset/be-music-skins.md | 4 ++-- packages/player-web-demo/src/main.ts | 5 +++-- packages/player-web/src/skins/built-in.ts | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/.changeset/be-music-skins.md b/.changeset/be-music-skins.md index a0718999..66631f52 100644 --- a/.changeset/be-music-skins.md +++ b/.changeset/be-music-skins.md @@ -8,11 +8,11 @@ The default (skinless) family now renders through a `BeMusicSkin`: one object bu - New exports: `BeMusicSkin` and its context types, `createBeMusicSkinRegistry`, `resolveBeMusicLaneKind`, `resolveSelectListWindow`, `phantomSkin`, `synesthesiaSkin`, `latticeSkin`, and `BUILT_IN_BE_MUSIC_SKINS`. - Skins can be made outside the player: the new `@be-music/player-web/skin-sdk` subpath carries everything a skin is written against — the skin types, `defineBeMusicSkin`, the stage and layout helpers, pooled HUD text, GPU point particles, judgement words, moments, the loading caption, audio drive, song facts, the result lamp, and motion helpers. The three built-in skins now live under `src/skins/` and import nothing but this SDK, Pixi, and their own files (a test enforces the boundary), so a third-party skin can do anything they do. -- 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 Phantom, Synesthesia, 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). +- 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. - Skins can skip Pixi entirely: `defineCanvasSkin` draws every screen onto a plain canvas with the context the skin names (`'2d'`, `'webgl'`, `'webgl2'`, or `'webgpu'`, with an optional async `setup` for devices and pipelines). The player collects each frame's layout, lanes, notes, long notes, hit effects, and HUD values and calls the skin's `draw` once before rendering; `'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 written this way with the Canvas 2D API only. - A skin's `renderChrome` now receives the frame's `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 and still the default. 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. +- 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 lit by an ember horizon, 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 — tilted shockwave rings, 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 a 3D particle ring that bursts 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 `renderChrome` (as `runtime.audio`), `renderLanes` and `renderBombs` (as `audio`), and the select renderer's `tick` (fourth argument). Gameplay analyses the whole mix off the audio bus output; select analyses the BGM and chart preview. 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. diff --git a/packages/player-web-demo/src/main.ts b/packages/player-web-demo/src/main.ts index 47445c51..bd904871 100644 --- a/packages/player-web-demo/src/main.ts +++ b/packages/player-web-demo/src/main.ts @@ -220,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 { diff --git a/packages/player-web/src/skins/built-in.ts b/packages/player-web/src/skins/built-in.ts index dcafffa5..63690c66 100644 --- a/packages/player-web/src/skins/built-in.ts +++ b/packages/player-web/src/skins/built-in.ts @@ -7,4 +7,4 @@ import type { BeMusicSkin } from '../skin-sdk/index.ts'; * 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[] = [phantomSkin, synesthesiaSkin, plainSkin]; +export const BUILT_IN_BE_MUSIC_SKINS: readonly BeMusicSkin[] = [synesthesiaSkin, phantomSkin, plainSkin]; From cf66d8023bbaa1975ac848b39ec982c67b8e7e66 Mon Sep 17 00:00:00 2001 From: Seiya KONNO Date: Fri, 9 Oct 2026 15:50:04 +0900 Subject: [PATCH 45/65] feat(player-web): let audio-reactive skin effects swing with the music Loud, compressed mixes kept the byte spectrum near full scale, so spectrum bars sat at the top and audio-driven sizes barely moved. The analysis now places each band and the bass / mid / high energies within the range they covered over the last few seconds (normalizeToRecentRange), keeping a share of the absolute level, and bandLevel is retuned for it. Phantom's halftone, bursts, and rank badge and Synesthesia's orb pump much wider. --- .changeset/be-music-skins.md | 2 +- .../src/runtime/audio-analysis.test.ts | 34 +++++++++++ .../player-web/src/runtime/audio-analysis.ts | 59 +++++++++++++++++-- .../src/skin-sdk/audio-drive.test.ts | 6 ++ .../player-web/src/skin-sdk/audio-drive.ts | 9 +-- .../player-web/src/skins/phantom/chrome.ts | 10 ++-- .../player-web/src/skins/phantom/select.ts | 2 +- .../player-web/src/skins/synesthesia/draw.ts | 2 +- 8 files changed, 108 insertions(+), 16 deletions(-) diff --git a/.changeset/be-music-skins.md b/.changeset/be-music-skins.md index 66631f52..c595218d 100644 --- a/.changeset/be-music-skins.md +++ b/.changeset/be-music-skins.md @@ -15,7 +15,7 @@ The default (skinless) family now renders through a `BeMusicSkin`: one object bu - 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 lit by an ember horizon, 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 — tilted shockwave rings, 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 a 3D particle ring that bursts 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 `renderChrome` (as `runtime.audio`), `renderLanes` and `renderBombs` (as `audio`), and the select renderer's `tick` (fourth argument). Gameplay analyses the whole mix off the audio bus output; select analyses the BGM and chart preview. 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. +- 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 `renderChrome` (as `runtime.audio`), `renderLanes` and `renderBombs` (as `audio`), and the select renderer's `tick` (fourth argument). 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. diff --git a/packages/player-web/src/runtime/audio-analysis.test.ts b/packages/player-web/src/runtime/audio-analysis.test.ts index 11681e0d..b4711d18 100644 --- a/packages/player-web/src/runtime/audio-analysis.test.ts +++ b/packages/player-web/src/runtime/audio-analysis.test.ts @@ -4,7 +4,9 @@ import { SILENT_AUDIO, analyzeAudioFrame, createAudioAnalysisState, + createRecentRange, logBandBins, + normalizeToRecentRange, type AudioFrameInput, } from './audio-analysis.ts'; @@ -86,3 +88,35 @@ describe('analyzeAudioFrame', () => { 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 index 9ad31d44..e3003854 100644 --- a/packages/player-web/src/runtime/audio-analysis.ts +++ b/packages/player-web/src/runtime/audio-analysis.ts @@ -59,8 +59,50 @@ export function logBandBins( 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; @@ -77,6 +119,10 @@ export interface AudioAnalysisState { 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, @@ -134,12 +180,17 @@ export function analyzeAudioFrame(state: AudioAnalysisState, input: AudioFrameIn 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]!, raw[band]!, 0.18); + state.bands[band] = follow(state.bands[band]!, gain(raw[band]!, state.bandRanges[band]!), 0.18); } - state.bass = follow(state.bass, average(20, 150), 0.2); - state.mid = follow(state.mid, average(150, 2000), 0.2); - state.high = follow(state.high, average(2000, 12000), 0.15); + 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)); diff --git a/packages/player-web/src/skin-sdk/audio-drive.test.ts b/packages/player-web/src/skin-sdk/audio-drive.test.ts index 331e5543..8361aa8a 100644 --- a/packages/player-web/src/skin-sdk/audio-drive.test.ts +++ b/packages/player-web/src/skin-sdk/audio-drive.test.ts @@ -63,6 +63,12 @@ describe('bandLevel', () => { 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/player-web/src/skin-sdk/audio-drive.ts b/packages/player-web/src/skin-sdk/audio-drive.ts index 050faa52..8f094317 100644 --- a/packages/player-web/src/skin-sdk/audio-drive.ts +++ b/packages/player-web/src/skin-sdk/audio-drive.ts @@ -49,11 +49,12 @@ export function bandAt(bands: readonly number[], index: number, count: number): } /** - * Display level of column `index` of `count` (0..1): {@link bandAt} with a treble tilt (music carries far less energy - * up top, so higher columns are boosted) and the floor / ceiling stretched so bars use their full height. + * 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.9 * position); - return Math.max(0, Math.min(1, (tilted - 0.2) / 0.72)); + 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/player-web/src/skins/phantom/chrome.ts b/packages/player-web/src/skins/phantom/chrome.ts index 0d992c2c..2938ed1d 100644 --- a/packages/player-web/src/skins/phantom/chrome.ts +++ b/packages/player-web/src/skins/phantom/chrome.ts @@ -210,7 +210,7 @@ function drawBackground(frame: Graphics, hole: BeMusicRect | undefined, drive: A w: DESIGN_WIDTH - 300, h: DESIGN_HEIGHT - 352, pitch: 9, - maxRadius: 4.2 * (1 + 0.45 * drive.bass), + maxRadius: 4.2 * (1 + 1.2 * drive.bass), direction: { x: 1, y: 1 }, })) { if (isInsideFloorWedge(dot.x, dot.y)) frame.circle(dot.x, dot.y, dot.r); @@ -373,7 +373,7 @@ function drawBgaFrame( w: bga.w - 8, h: bga.h - 8, pitch: 12, - maxRadius: 5 * (1 + 0.5 * drive.level), + maxRadius: 5 * (1 + 1.1 * drive.level), direction: { x: -0.6, y: 1 }, })) { frame.circle(dot.x, dot.y, dot.r); @@ -381,8 +381,8 @@ function drawBgaFrame( frame.fill({ color: PHANTOM_RED, alpha: 0.75 }); // Slow-turning starburst behind the slug — the idle screen is alive, not a hole in the cabinet. // 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.1 * drive.onset; - const pump = (1 + 0.2 * drive.bass + 0.1 * drive.onset) * (bga.w / 256); + 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 @@ -687,7 +687,7 @@ function drawScorePlate( const badgeX = x + w - 10; const badgeY = y + h - 14; // The badge pumps with the bass. - const pump = 1 + 0.16 * drive.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) diff --git a/packages/player-web/src/skins/phantom/select.ts b/packages/player-web/src/skins/phantom/select.ts index e65d7fd6..c4426344 100644 --- a/packages/player-web/src/skins/phantom/select.ts +++ b/packages/player-web/src/skins/phantom/select.ts @@ -250,7 +250,7 @@ class PhantomSelectRenderer implements BeMusicSelectRenderer { const beatPulse = (1 - ((seconds * beatsPerSecond) % 1)) ** 3; if (this.burst) { this.burst.rotation = seconds * 0.12 + 0.12 * drive.onset; - this.burst.scale.set(1 + 0.06 * beatPulse + 0.24 * drive.bass + 0.1 * 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; diff --git a/packages/player-web/src/skins/synesthesia/draw.ts b/packages/player-web/src/skins/synesthesia/draw.ts index 04340e55..79772f7e 100644 --- a/packages/player-web/src/skins/synesthesia/draw.ts +++ b/packages/player-web/src/skins/synesthesia/draw.ts @@ -437,7 +437,7 @@ export function drawMagnetoOrb( 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 * (1 + 0.12 * drive.bass); + 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; From c54c4b4334a2254f9c0ec6ed0339414efc0e5f91 Mon Sep 17 00:00:00 2001 From: Seiya KONNO Date: Fri, 9 Oct 2026 15:53:17 +0900 Subject: [PATCH 46/65] docs: add a guide for writing be-music skins [ci skip] --- docs/README.ja.md | 1 + docs/README.md | 1 + docs/be-music-skin.ja.md | 273 +++++++++++++++++++++++++++++++++++++++ docs/be-music-skin.md | 273 +++++++++++++++++++++++++++++++++++++++ docs/player-web.ja.md | 3 + docs/player-web.md | 3 + 6 files changed, 554 insertions(+) create mode 100644 docs/be-music-skin.ja.md create mode 100644 docs/be-music-skin.md 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..b3071907 --- /dev/null +++ b/docs/be-music-skin.ja.md @@ -0,0 +1,273 @@ +[English version](./be-music-skin.md) + +# be-music スキンの作り方 + +このガイドは、ブラウザプレイヤーの組み込み(デフォルト)ファミリー向けにスキンを作りたい人のためのものです。スキンは `@be-music/player-web/skin-sdk` サブパスに対して書きます。組み込みスキンもまさにこのサブパスだけを使っており、Synesthesia・Phantom・Plain・Lattice はプレイヤーの他の部分を一切 import していません。組み込みスキンにできることは、あなたのスキンにもできます。 + +be-music スキンはデータファイルではなくコードです。LR2 / beatoraja テーマはシーンが解釈しますが、be-music スキンは描画関数のセットを提供します。それ以外はすべてプレイヤーの担当です。 + +- 入力、タイミング、オーディオ、判定 +- レーンのレイアウトとノーツの位置 +- 選曲リストの当たり判定 + +各フレームの見た目はスキンが決めます。 + +## 2 つの描き方 + +| | Canvas スキン(`defineCanvasSkin`) | Pixi スキン(`defineBeMusicSkin`) | +| ----------- | -------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | +| 描画 API | `'2d'` / `'webgl'` / `'webgl2'` / `'webgpu'` コンテキストの素の `` | PixiJS v8 の表示オブジェクト | +| 書くもの | 画面ごとに `draw` 関数を 1 つ | クローム、レーン、ノーツ、ロングノーツ、ヒットエフェクト、選曲、リザルトの各レンダラー | +| Pixi の知識 | 不要 | 必要 | +| 作例 | Plain(`packages/player-web/src/skins/plain/`) | Synesthesia、Phantom、Lattice(`packages/player-web/src/skins/`) | + +フィルター、ブレンドモード、多数のレイヤーにまたがる GPU パーティクルなど Pixi の機能が必要でなければ、Canvas スキンから始めてください。Plain は意図的に小さく読みやすく作ってあるので、出発点としてコピーして使えます。 + +## クイックスタート: Canvas スキン + +```ts +import { BE_MUSIC_SKIN_API_VERSION, defineCanvasSkin, resolveLaneRuns } from '@be-music/player-web/skin-sdk'; + +export default defineCanvasSkin({ + 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); + }, + }, +}); +``` + +### Canvas スキンの描画のしくみ + +- **`draw` が呼ばれるタイミング:** プレイヤーがレンダリング直前に 1 フレーム 1 回呼びます。`'2d'` ではキャンバスはクリア・スケール済みなので、デザインピクセル(デフォルトステージで 854×480)で描けます。 +- **その他のコンテキスト:** `'webgl'` / `'webgl2'` / `'webgpu'` ではキャンバス全体を自分で扱います。`surface.pixelRatio` がデザインピクセルあたりのキャンバスピクセル数です。 +- **`setup(surface)`:** 新しいサーフェスごとに、最初の描画前に 1 回呼ばれます。シェーダーのコンパイルや GPU デバイスの取得に使います。Promise を返すと、描画はその完了を待ちます。 +- **`contextAttributes`:** `canvas.getContext` にそのまま渡されます。 +- **ドットバイドット:** 各キャンバスは画面上でステージが覆うデバイスピクセル数(ビューポート倍率 × `devicePixelRatio`)で確保され、nearest サンプリングで表示されます。描いたピクセルは画面に 1 対 1 で対応します。 +- **ゲームプレイのフレーム(`CanvasGameplayFrame`):** + - `layout` + - `lanes`(キービーム強度つき) + - `notes`、`longNotes` + - `bombs`(経過時間つきのヒットエフェクト) + - `runtime`(HUD の値) + - `beatPhase`、`effects`、`audio` +- **選曲のフレーム(`CanvasSelectFrame`):** [選曲フレーム](#選曲bemusicselectskin)に、矩形をクリック可能にする `hit(x, y, w, h, action)` が加わったものです。アニメーション中は `select.draw` から `true` を返します。 +- **任意の設定:** + - `gameplay.bombDurationMs`(既定 300) + - `select.outroMs`(既定 0) + - `stage`(既定 `wideStage`) + +## スキンのメタデータ + +すべてのスキンが以下を宣言します。`defineBeMusicSkin` と `defineCanvasSkin` は内容を検査し、不正なら例外を投げます。壊れたスキンは定義した場所で失敗します。 + +| フィールド | 必須 | ルール | +| ------------- | ---- | -------------------------------------------------------------------------------------------------------------- | +| `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"'`)。空でも可。 | +| `stage` | — | デザインキャンバス。[ステージとレイアウト](#ステージとレイアウト)を参照。 | + +`validateBeMusicSkin(skin)` は、例外を投げずに同じ問題をメッセージの配列で返します。 + +## ステージとレイアウト + +- **ステージ:** スキンが描くデザインキャンバスです。`wideStage` は組み込みスキンが使う 16:9 のステージ(854×480)です。`stage` を宣言しないスキンは LR2 互換の 640×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 つ返します。判定ラインやレーングリッドはラン単位で描き、隙間をまたがないようにします。 +- **レーン幅:** レーン幅はプレイヤーが決めます。スキンはレーンに色を付けますが、大きさや位置は変えません。 + +## Pixi スキン + +`defineBeMusicSkin` は同じメタデータに 3 つの画面を加えて受け取ります。描画には `pixi.js`(プレイヤーの peer dependency)を使います。 + +### ゲームプレイ(`BeMusicGameplaySkin`) + +| メンバー | 受け取るもの | 描くもの | +| ------------------------- | ---------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | +| `renderChrome(context)` | `layer`、`overlayLayer`、それぞれの `ChildPool`、`runtime`、`layout` | プレイフィールドの周り全部: ヘッダー、ゲージ、スコア、BGA 枠、判定 / コンボ、演出 | +| `renderLanes(context)` | クリア済みの `Graphics` 1 つ、`lanes`、`beatPhase`、`nowMs`、`combo`、`effects`、`audio` | レーン下地、キービーム、判定ライン | +| `renderNote(context)` | 新しいプール済み `Graphics`、レーン `kind`、`x`、`w`、`y`(ノーツの下端) | タップノーツ 1 つ | +| `renderLongNote(context)` | 新しいプール済み `Graphics`、`kind`、`x`、`w`、`top`(終端)、`bottom`(始端) | ロングノーツ 1 つ | +| `renderBombs(context)` | `ChildPool`、`bombs`、`nowMs`、`combo`、`effects`、`audio` | 生きているヒットエフェクトすべて | +| `bombDurationMs` | — | ヒットエフェクトの寿命 | + +表示オブジェクトはフレームごとに生成せず、渡されたプールから取得してください(`layerPool.acquireGraphics()`、`acquireText()` など)。プールは前フレームのオブジェクトを再利用するので、フレーム時間が安定します。 + +### `runtime` の値 + +`runtime`(`SkinlessGameplayChromeRuntime`)は HUD の値を持ちます。 + +- **曲とスコア:** + - `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` + +### 選曲(`BeMusicSelectSkin`) + +- **`layout`:** `listX`、`listTop`、`listBottomInset`、`rowHeight`。シーンはこの値で行の当たり判定をするので、描く行と一致させてください。 +- **`createRenderer()`:** 選曲シーンごとに 1 回呼ばれ、次のメンバーを持つ `BeMusicSelectRenderer` を返します。 + - `backLayer` / `frontLayer`: 毎フレーム作り直すレイヤーの背面と前面にある永続レイヤー。 + - `render(frame)`: 入力に応じたクロームを `frame.layer` に作り直します。トランジション中は `true` を返します。 + - `tick(nowMs, focusedSong, launchAt, audio)`: 永続レイヤーの毎フレームのアニメーション(変形のみ)。 + - `outroMs`: 曲決定後のアウトロの長さ。終わるまでシーンは `frame.launchAt` を設定して描画を続け、その間の入力は無視します。 + - `dispose()` +- **フレーム(`BeMusicSelectFrame`):** + - `entries`、`selectedIndex`、`firstVisibleIndex`、`visibleRows`、`focusedSong` + - `folderLabel`、`searchQuery`、`totalCharts` + - `actions`(`play`、`autoPlay`、`activateSearch`) + - 登場やフォーカス移動のトランジション用のタイムスタンプ + - `effects`、`launchAt` +- **クリック領域:** Pixi のクロームをクリック可能にするには `addHitArea` を使います。 + +### リザルト(`BeMusicResultSkin`) + +- **`render(frame)`:** 毎フレーム `frame.layer` を作り直します。 +- **フレームの内容:** + - `result`(スコア、判定数、ゲージ推移など) + - `rankLabel`(IIDX の DJ LEVEL)、`ratePercent` + - `elapsedMs`(シーン開始からの経過。登場演出をスキップした後は `Infinity`) + - `effects` +- **ヘルパー:** `resolveResultLamp` と `resolveResultTrackRows` が、組み込みスキンの表示するクリアランプとトラック行を返します。 + +## SDK ヘルパー + +| 分野 | ヘルパー | +| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| ステージ | `wideStage`、`resolveStageBgaRect`、`STAGE_WIDTH`、`STAGE_HEIGHT`、`STAGE_MARGIN`、`STAGE_SIDE_COLUMN`、`STAGE_BGA_BAND` | +| レイアウト | `resolveGameplayLayout`、`resolveLaneRuns`、`resolveMilestoneArea` | +| テキスト(Pixi) | `addHudText`、`addHudNumber`(プール・tint・キャップハイト揃え)、`addSkinText`、`addHitArea`、`formatPlayVariantLabel`、`DEFAULT_TEXT_FONT` | +| パーティクル(Pixi) | `pointLayerFor(graphics)`: プール済み `Graphics` に追従する GPU ポイントパーティクル | +| キービーム(Pixi) | `keyBeamGradient(color)`、`KEY_BEAM_STOPS` | +| 判定 | `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` | +| モーション | `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)` は、組み込みスキンが使う量に変換します。この設定を必要とするプレイヤーがいるので、必ず尊重してください。 + +## フォント + +描画に使うフェイスはすべて `fontLoads` に挙げてください。ホストはマウント前に `document.fonts.load` で読み込むので、最初のフレームが代替フォントでラスタライズされることを防げます。フェイス自体をホストが入手できるようにもしてください(フォントサービス経由、またはスキンに同梱)。 + +## パッケージングと読み込み + +- **import の範囲:** スキンモジュールが import するのは `@be-music/player-web/skin-sdk`、`pixi.js`(Pixi スキンの場合)、自身のファイルだけです。組み込みスキンもテストでこのルールを守らされているので、組み込みスキンが使うものはすべて使えます。 +- **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` を通る。 +- [ ] クロームは `layout` から配置し、判定ラインはレーンランごとに描いている。 +- [ ] BGA の矩形が見えている(Canvas スキンは `runtime.hasBga` のとき `clearRect`)。 +- [ ] `runtime.loading` の間は NOW LOADING を表示している。 +- [ ] 選曲の行が `select.layout` と揃い、ボタンがクリックできる。 +- [ ] 音への反応は `audioDrive` を通し、`effects` を尊重している。 +- [ ] 使うフェイスをすべて `fontLoads` に挙げている。 +- [ ] 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..d195ecfd --- /dev/null +++ b/docs/be-music-skin.md @@ -0,0 +1,273 @@ +[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/player-web/skin-sdk` subpath. The built-in skins use exactly this subpath: Synesthesia, Phantom, Plain, and Lattice 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 instead supplies a set of drawing functions. The player keeps ownership of everything else: + +- input, timing, audio, and judging; +- the lane layout and note positions; +- select-list hit-testing. + +Your skin decides how every frame looks. + +## Two ways to draw + +| | Canvas skin (`defineCanvasSkin`) | Pixi skin (`defineBeMusicSkin`) | +| -------------- | ------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------- | +| Drawing API | A plain `` with a `'2d'`, `'webgl'`, `'webgl2'`, or `'webgpu'` context | PixiJS v8 display objects | +| What you write | One `draw` function per screen | Separate renderers for the chrome, lanes, notes, long notes, hit effects, select, and result | +| Pixi knowledge | None | Required | +| Example | Plain (`packages/player-web/src/skins/plain/`) | Synesthesia, Phantom, Lattice (`packages/player-web/src/skins/`) | + +Start with a canvas skin unless you need Pixi features such as filters, blend modes, or GPU particles across many layers. Plain is deliberately small and readable; copy it as a starting point. + +## Quick start: a canvas skin + +```ts +import { BE_MUSIC_SKIN_API_VERSION, defineCanvasSkin, resolveLaneRuns } from '@be-music/player-web/skin-sdk'; + +export default defineCanvasSkin({ + 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); + }, + }, +}); +``` + +### How canvas skins draw + +- **When `draw` runs:** the player calls it once per frame, right before rendering. With `'2d'`, the canvas is already cleared and scaled, so you draw in design pixels (854×480 on the default stage). +- **Other contexts:** with `'webgl'`, `'webgl2'`, or `'webgpu'`, you own the whole canvas. `surface.pixelRatio` gives the canvas pixels per design pixel. +- **`setup(surface)`:** runs once for each new surface before its first draw. Use it to compile shaders or request a GPU device. Drawing waits for a returned promise. +- **`contextAttributes`:** passed through to `canvas.getContext`. +- **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. +- **Gameplay frame (`CanvasGameplayFrame`):** + - `layout` + - `lanes` (each with a key-beam intensity) + - `notes` and `longNotes` + - `bombs` (live hit effects with their age) + - `runtime` (HUD values) + - `beatPhase`, `effects`, and `audio` +- **Select frame (`CanvasSelectFrame`):** the [select frame](#select-bemusicselectskin) plus `hit(x, y, w, h, action)`, which makes a rect clickable. Return `true` from `select.draw` while something is still animating. +- **Optional settings:** + - `gameplay.bombDurationMs` (default 300) + - `select.outroMs` (default 0) + - `stage` (default `wideStage`) + +## Skin metadata + +Every skin declares these fields. `defineBeMusicSkin` and `defineCanvasSkin` check them and throw 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. | +| `stage` | no | Design canvas; see [Stage and layout](#stage-and-layout). | + +`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). A skin that declares no `stage` gets the LR2-compatible 640×480 canvas. +- **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. + +## Pixi skins + +`defineBeMusicSkin` takes the same metadata plus three screens. Draw with `pixi.js`, a peer dependency of the player. + +### Gameplay (`BeMusicGameplaySkin`) + +| Member | Called with | Draws | +| ------------------------- | ---------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | +| `renderChrome(context)` | `layer`, `overlayLayer`, their `ChildPool`s, `runtime`, `layout` | Everything around the playfield: header, gauge, score, BGA frame, judgement / combo, moments | +| `renderLanes(context)` | one cleared `Graphics`, `lanes`, `beatPhase`, `nowMs`, `combo`, `effects`, `audio` | Lane beds, key beams, the judgement line | +| `renderNote(context)` | a fresh pooled `Graphics`, lane `kind`, `x`, `w`, `y` (the note's bottom edge) | One tap note | +| `renderLongNote(context)` | a fresh pooled `Graphics`, `kind`, `x`, `w`, `top` (tail), `bottom` (head) | One long note | +| `renderBombs(context)` | a `ChildPool`, `bombs`, `nowMs`, `combo`, `effects`, `audio` | Every live hit effect | +| `bombDurationMs` | — | How long a hit effect lives | + +Acquire display objects from the pools you are given (`layerPool.acquireGraphics()`, `acquireText()`, …) instead of creating them per frame. The pools reuse last frame's objects, which keeps frame time flat. + +### Values in `runtime` + +`runtime` (`SkinlessGameplayChromeRuntime`) carries the HUD values: + +- **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 (`BeMusicSelectSkin`) + +- **`layout`:** `listX`, `listTop`, `listBottomInset`, and `rowHeight`. The scene hit-tests rows with these numbers, so the rows you draw must match them. +- **`createRenderer()`:** called once per select scene. It returns a `BeMusicSelectRenderer` with: + - `backLayer` and `frontLayer`: persistent layers behind and in front of the rebuilt frame. + - `render(frame)`: rebuilds the input-driven chrome into `frame.layer`. Return `true` while a transition is still running. + - `tick(nowMs, focusedSong, launchAt, audio)`: per-frame, transform-only animation of the persistent layers. + - `outroMs`: length of the launch outro. The scene keeps rendering with `frame.launchAt` set until it ends, and ignores input meanwhile. + - `dispose()` +- **Frame (`BeMusicSelectFrame`):** + - `entries`, `selectedIndex`, `firstVisibleIndex`, `visibleRows`, `focusedSong` + - `folderLabel`, `searchQuery`, `totalCharts` + - `actions` (`play`, `autoPlay`, `activateSearch`) + - timestamps for entrance and focus transitions + - `effects`, `launchAt` +- **Clickable areas:** use `addHitArea` to make Pixi chrome clickable. + +### Result (`BeMusicResultSkin`) + +- **`render(frame)`:** rebuilds `frame.layer` every frame. +- **Frame contents:** + - `result` (scores, judgement counts, gauge history, …) + - `rankLabel` (IIDX DJ level) and `ratePercent` + - `elapsedMs` since the scene mounted, or `Infinity` once the player skipped the entrance + - `effects` +- **Helpers:** `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` | +| Text (Pixi) | `addHudText`, `addHudNumber` (pooled, tinted, cap-height aligned), `addSkinText`, `addHitArea`, `formatPlayVariantLabel`, `DEFAULT_TEXT_FONT` | +| Particles (Pixi) | `pointLayerFor(graphics)`: GPU point particles that ride along with a pooled `Graphics` | +| Key beams (Pixi) | `keyBeamGradient(color)`, `KEY_BEAM_STOPS` | +| 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` | +| 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. + +## 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 + +- **Imports:** a skin module imports only `@be-music/player-web/skin-sdk`, `pixi.js` (Pixi skins), and its own files. The built-in skins are held to this rule by a test, so anything they use 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`. +- [ ] Chrome is placed from `layout`, and the judgement line is drawn per lane run. +- [ ] The BGA rect is left visible (canvas skins: `clearRect` it when `runtime.hasBga`). +- [ ] NOW LOADING is shown while `runtime.loading`. +- [ ] Select rows line up with `select.layout`, and buttons are clickable. +- [ ] Audio reactions go through `audioDrive`, and `effects` is respected. +- [ ] Every face is listed in `fontLoads`. +- [ ] 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..018b8ea7 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/player-web/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..7f2f0ab8 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/player-web/skin-sdk` subpath. 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 From 82fbe02428a8fcc0f6a06f346033268699e15779 Mon Sep 17 00:00:00 2001 From: Seiya KONNO Date: Fri, 9 Oct 2026 16:33:22 +0900 Subject: [PATCH 47/65] feat(player-web): keep the skin SDK free of any rendering framework A be-music skin now draws every screen onto a canvas the player hands it, from plain per-frame data, with whatever renderer it brings: the Canvas 2D API, raw WebGL / WebGPU, or a framework such as PixiJS or three.js. Skins declare the context they want and may create their renderer in setup and release it in teardown; select frames carry a revision and a hit callback. The skin SDK no longer imports pixi.js, directly or through the types it re-exports, and a test walks its import graph to keep it that way. The player-side binding (canvas surfaces, texture upload, hit areas) moves into the host, and the Pixi helpers the built-in skins share move to skins/pixi-kit, whose definePixiSkin runs Phantom, Synesthesia, and Lattice on their own Pixi renderer. Themes still fill missing lanes and notes in the Phantom style. The skin guide describes the new contract. --- .changeset/be-music-skins.md | 12 +- docs/be-music-skin.ja.md | 228 +++++++----- docs/be-music-skin.md | 228 +++++++----- .../player-web/src/scene/core/gameplay.ts | 82 +++-- packages/player-web/src/scene/core/result.ts | 27 +- packages/player-web/src/scene/core/select.ts | 55 ++- .../player-web/src/scene/gameplay-chrome.ts | 82 +---- .../src/scene/gameplay-constants.ts | 6 +- .../player-web/src/scene/gameplay-geometry.ts | 6 + packages/player-web/src/scene/pixi-utils.ts | 294 +-------------- .../player-web/src/skin-sdk/canvas-skin.ts | 346 ------------------ .../player-web/src/skin-sdk/define.test.ts | 19 +- packages/player-web/src/skin-sdk/define.ts | 16 +- .../src/skin-sdk/framework-free.test.ts | 35 ++ packages/player-web/src/skin-sdk/index.ts | 29 +- packages/player-web/src/skin-sdk/layout.ts | 2 +- .../player-web/src/skin-sdk/result-track.ts | 2 +- .../src/skin-sdk/song-stats.test.ts | 9 + .../player-web/src/skin-sdk/song-stats.ts | 6 + .../player-web/src/skin/be-music/binding.ts | 329 +++++++++++++++++ .../src/skin/be-music/registry.test.ts | 7 +- .../player-web/src/skin/be-music/types.ts | 267 +++++++++----- .../player-web/src/skins/boundary.test.ts | 29 +- packages/player-web/src/skins/lattice/draw.ts | 2 +- .../src/skins/lattice/gameplay-chrome.ts | 11 +- .../player-web/src/skins/lattice/index.ts | 5 +- .../player-web/src/skins/lattice/moments.ts | 7 +- .../player-web/src/skins/lattice/playfield.ts | 20 +- .../player-web/src/skins/lattice/result.ts | 9 +- .../player-web/src/skins/lattice/select.ts | 38 +- .../player-web/src/skins/phantom/chrome.ts | 18 +- .../player-web/src/skins/phantom/index.ts | 5 +- .../player-web/src/skins/phantom/moments.ts | 7 +- .../player-web/src/skins/phantom/playfield.ts | 21 +- .../player-web/src/skins/phantom/result.ts | 9 +- .../player-web/src/skins/phantom/select.ts | 36 +- .../src/skins/pixi-kit/define-pixi-skin.ts | 263 +++++++++++++ .../{skin-sdk => skins/pixi-kit}/hud-text.ts | 9 +- .../player-web/src/skins/pixi-kit/index.ts | 14 + .../pixi-kit}/key-beam.test.ts | 0 .../{skin-sdk => skins/pixi-kit}/key-beam.ts | 0 .../pixi-kit}/particle-layer.ts | 0 .../player-web/src/skins/pixi-kit/pools.ts | 293 +++++++++++++++ .../src/skins/pixi-kit/resolution.test.ts | 15 + .../src/skins/pixi-kit/resolution.ts | 17 + .../src/skins/pixi-kit/skin-text.test.ts | 22 ++ .../{skin-sdk => skins/pixi-kit}/skin-text.ts | 58 +-- .../pixi-kit}/text-metrics.test.ts | 0 .../pixi-kit}/text-metrics.ts | 0 .../player-web/src/skins/pixi-kit/types.ts | 141 +++++++ .../player-web/src/skins/plain/gameplay.ts | 16 +- packages/player-web/src/skins/plain/index.ts | 4 +- packages/player-web/src/skins/plain/result.ts | 4 +- packages/player-web/src/skins/plain/select.ts | 16 +- .../player-web/src/skins/synesthesia/draw.ts | 3 +- .../src/skins/synesthesia/gameplay-chrome.ts | 18 +- .../player-web/src/skins/synesthesia/index.ts | 5 +- .../src/skins/synesthesia/moments.ts | 7 +- .../src/skins/synesthesia/playfield.ts | 22 +- .../src/skins/synesthesia/result.ts | 9 +- .../src/skins/synesthesia/select.ts | 38 +- 61 files changed, 1958 insertions(+), 1320 deletions(-) create mode 100644 packages/player-web/src/scene/gameplay-geometry.ts delete mode 100644 packages/player-web/src/skin-sdk/canvas-skin.ts create mode 100644 packages/player-web/src/skin-sdk/framework-free.test.ts create mode 100644 packages/player-web/src/skin/be-music/binding.ts create mode 100644 packages/player-web/src/skins/pixi-kit/define-pixi-skin.ts rename packages/player-web/src/{skin-sdk => skins/pixi-kit}/hud-text.ts (95%) create mode 100644 packages/player-web/src/skins/pixi-kit/index.ts rename packages/player-web/src/{skin-sdk => skins/pixi-kit}/key-beam.test.ts (100%) rename packages/player-web/src/{skin-sdk => skins/pixi-kit}/key-beam.ts (100%) rename packages/player-web/src/{skin-sdk => skins/pixi-kit}/particle-layer.ts (100%) create mode 100644 packages/player-web/src/skins/pixi-kit/pools.ts create mode 100644 packages/player-web/src/skins/pixi-kit/resolution.test.ts create mode 100644 packages/player-web/src/skins/pixi-kit/resolution.ts create mode 100644 packages/player-web/src/skins/pixi-kit/skin-text.test.ts rename packages/player-web/src/{skin-sdk => skins/pixi-kit}/skin-text.ts (73%) rename packages/player-web/src/{skin-sdk => skins/pixi-kit}/text-metrics.test.ts (100%) rename packages/player-web/src/{skin-sdk => skins/pixi-kit}/text-metrics.ts (100%) create mode 100644 packages/player-web/src/skins/pixi-kit/types.ts diff --git a/.changeset/be-music-skins.md b/.changeset/be-music-skins.md index c595218d..9304b045 100644 --- a/.changeset/be-music-skins.md +++ b/.changeset/be-music-skins.md @@ -4,18 +4,18 @@ 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 a fixed design canvas (the skin's `stage`, 640x480 unless it declares another). 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`, `latticeSkin`, and `BUILT_IN_BE_MUSIC_SKINS`. -- Skins can be made outside the player: the new `@be-music/player-web/skin-sdk` subpath carries everything a skin is written against — the skin types, `defineBeMusicSkin`, the stage and layout helpers, pooled HUD text, GPU point particles, judgement words, moments, the loading caption, audio drive, song facts, the result lamp, and motion helpers. The three built-in skins now live under `src/skins/` and import nothing but this SDK, Pixi, and their own files (a test enforces the boundary), so a third-party skin can do anything they do. +- 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 new `@be-music/player-web/skin-sdk` subpath carries everything a skin is written against — the skin and frame types, `defineBeMusicSkin`, the stage and layout helpers, judgement words, moments, the loading caption, audio drive, song facts, the result lamp, and motion helpers — and imports no rendering framework (a test enforces it). A skin names the canvas context it wants (`'2d'`, `'webgl'`, `'webgl2'`, or `'webgpu'`), 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 the SDK, their framework, and their own files (a test enforces the boundary). - 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. -- Skins can skip Pixi entirely: `defineCanvasSkin` draws every screen onto a plain canvas with the context the skin names (`'2d'`, `'webgl'`, `'webgl2'`, or `'webgpu'`, with an optional async `setup` for devices and pipelines). The player collects each frame's layout, lanes, notes, long notes, hit effects, and HUD values and calls the skin's `draw` once before rendering; `'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 written this way with the Canvas 2D API only. -- A skin's `renderChrome` now receives the frame's `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. +- 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 lit by an ember horizon, 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 — tilted shockwave rings, 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 a 3D particle ring that bursts 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 `renderChrome` (as `runtime.audio`), `renderLanes` and `renderBombs` (as `audio`), and the select renderer's `tick` (fourth argument). 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. +- 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. diff --git a/docs/be-music-skin.ja.md b/docs/be-music-skin.ja.md index b3071907..4c27319f 100644 --- a/docs/be-music-skin.ja.md +++ b/docs/be-music-skin.ja.md @@ -2,33 +2,34 @@ # be-music スキンの作り方 -このガイドは、ブラウザプレイヤーの組み込み(デフォルト)ファミリー向けにスキンを作りたい人のためのものです。スキンは `@be-music/player-web/skin-sdk` サブパスに対して書きます。組み込みスキンもまさにこのサブパスだけを使っており、Synesthesia・Phantom・Plain・Lattice はプレイヤーの他の部分を一切 import していません。組み込みスキンにできることは、あなたのスキンにもできます。 +このガイドは、ブラウザプレイヤーの組み込み(デフォルト)ファミリー向けにスキンを作りたい人のためのものです。スキンは `@be-music/player-web/skin-sdk` サブパスに対して書きます。組み込みスキンもまさにこのサブパスだけを使っており、Synesthesia・Phantom・Lattice・Plain はプレイヤーの他の部分を一切 import していません。組み込みスキンにできることは、あなたのスキンにもできます。 -be-music スキンはデータファイルではなくコードです。LR2 / beatoraja テーマはシーンが解釈しますが、be-music スキンは描画関数のセットを提供します。それ以外はすべてプレイヤーの担当です。 +be-music スキンはデータファイルではなくコードです。LR2 / beatoraja テーマはシーンが解釈しますが、be-music スキンはすべての画面を自分で描きます。それ以外はすべてプレイヤーの担当です。 - 入力、タイミング、オーディオ、判定 - レーンのレイアウトとノーツの位置 - 選曲リストの当たり判定 -各フレームの見た目はスキンが決めます。 +プレイヤーは毎フレーム、キャンバスとそのフレームの内容(プレーンなデータ)をスキンに渡します。見た目はスキンが決めます。 -## 2 つの描き方 +## 描画フレームワークはスキンが持ち込む -| | Canvas スキン(`defineCanvasSkin`) | Pixi スキン(`defineBeMusicSkin`) | -| ----------- | -------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | -| 描画 API | `'2d'` / `'webgl'` / `'webgl2'` / `'webgpu'` コンテキストの素の `` | PixiJS v8 の表示オブジェクト | -| 書くもの | 画面ごとに `draw` 関数を 1 つ | クローム、レーン、ノーツ、ロングノーツ、ヒットエフェクト、選曲、リザルトの各レンダラー | -| Pixi の知識 | 不要 | 必要 | -| 作例 | Plain(`packages/player-web/src/skins/plain/`) | Synesthesia、Phantom、Lattice(`packages/player-web/src/skins/`) | +SDK 自体は描画を行わず、描画フレームワークも import しません。キャンバスは、スキンに合ったもので描いてください。 -フィルター、ブレンドモード、多数のレイヤーにまたがる GPU パーティクルなど Pixi の機能が必要でなければ、Canvas スキンから始めてください。Plain は意図的に小さく読みやすく作ってあるので、出発点としてコピーして使えます。 +| 描画手段 | `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` で解放 | — | -## クイックスタート: Canvas スキン +フレームワークは、スキン自身の依存として同梱します。Plain は意図的に小さく読みやすく作ってあるので、出発点としてコピーして使えます。 + +## クイックスタート: Canvas 2D スキン ```ts -import { BE_MUSIC_SKIN_API_VERSION, defineCanvasSkin, resolveLaneRuns } from '@be-music/player-web/skin-sdk'; +import { BE_MUSIC_SKIN_API_VERSION, defineBeMusicSkin, resolveLaneRuns } from '@be-music/player-web/skin-sdk'; -export default defineCanvasSkin({ +export default defineBeMusicSkin({ apiVersion: BE_MUSIC_SKIN_API_VERSION, id: 'my-skin', label: 'My Skin', @@ -74,48 +75,86 @@ export default defineCanvasSkin({ }); ``` -### Canvas スキンの描画のしくみ +## フレームワークを使う -- **`draw` が呼ばれるタイミング:** プレイヤーがレンダリング直前に 1 フレーム 1 回呼びます。`'2d'` ではキャンバスはクリア・スケール済みなので、デザインピクセル(デフォルトステージで 854×480)で描けます。 -- **その他のコンテキスト:** `'webgl'` / `'webgl2'` / `'webgpu'` ではキャンバス全体を自分で扱います。`surface.pixelRatio` がデザインピクセルあたりのキャンバスピクセル数です。 -- **`setup(surface)`:** 新しいサーフェスごとに、最初の描画前に 1 回呼ばれます。シェーダーのコンパイルや GPU デバイスの取得に使います。Promise を返すと、描画はその完了を待ちます。 -- **`contextAttributes`:** `canvas.getContext` にそのまま渡されます。 +`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/player-web/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 で対応します。 -- **ゲームプレイのフレーム(`CanvasGameplayFrame`):** - - `layout` - - `lanes`(キービーム強度つき) - - `notes`、`longNotes` - - `bombs`(経過時間つきのヒットエフェクト) - - `runtime`(HUD の値) - - `beatPhase`、`effects`、`audio` -- **選曲のフレーム(`CanvasSelectFrame`):** [選曲フレーム](#選曲bemusicselectskin)に、矩形をクリック可能にする `hit(x, y, w, h, action)` が加わったものです。アニメーション中は `select.draw` から `true` を返します。 -- **任意の設定:** - - `gameplay.bombDurationMs`(既定 300) - - `select.outroMs`(既定 0) - - `stage`(既定 `wideStage`) +- **透過:** キャンバスは BGA 動画の上に合成されます。動画を見せたい場所では BGA の矩形を透明のまま残してください。 +- **`contextAttributes`:** `canvas.getContext` にそのまま渡されます。 ## スキンのメタデータ -すべてのスキンが以下を宣言します。`defineBeMusicSkin` と `defineCanvasSkin` は内容を検査し、不正なら例外を投げます。壊れたスキンは定義した場所で失敗します。 - -| フィールド | 必須 | ルール | -| ------------- | ---- | -------------------------------------------------------------------------------------------------------------- | -| `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"'`)。空でも可。 | -| `stage` | — | デザインキャンバス。[ステージとレイアウト](#ステージとレイアウト)を参照。 | +すべてのスキンが以下を宣言します。`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)です。`stage` を宣言しないスキンは LR2 互換の 640×480 になります。 +- **ステージ:** スキンが描くデザインキャンバスです。`wideStage` は組み込みスキンが使う 16:9 のステージ(854×480)で、既定値でもあります。 - **BGA の配置:** `BeMusicStage.resolveBgaRect(playfieldRight)` がゲームプレイの BGA の位置を決めます。プレイヤーはそこに動画を合成し、スキンは同じ矩形に枠を描きます。`wideStage` は SP では BGA を大きく取り、幅の広い DP や鍵盤モードの横では縮めます。 - **フレームごとのレイアウト:** ゲームプレイの各フレームには、ホストが解決した `layout`(`BeMusicGameplayLayout`)が付きます。 - `stage`: 幅と高さ。 @@ -126,26 +165,23 @@ export default defineCanvasSkin({ - **DP の隙間:** 1P と 2P のバンクは 60 px 離れています。`resolveLaneRuns(lanes)` は連続したバンクごとに `{ left, right }` を 1 つ返します。判定ラインやレーングリッドはラン単位で描き、隙間をまたがないようにします。 - **レーン幅:** レーン幅はプレイヤーが決めます。スキンはレーンに色を付けますが、大きさや位置は変えません。 -## Pixi スキン - -`defineBeMusicSkin` は同じメタデータに 3 つの画面を加えて受け取ります。描画には `pixi.js`(プレイヤーの peer dependency)を使います。 +## 画面 -### ゲームプレイ(`BeMusicGameplaySkin`) +### ゲームプレイ -| メンバー | 受け取るもの | 描くもの | -| ------------------------- | ---------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | -| `renderChrome(context)` | `layer`、`overlayLayer`、それぞれの `ChildPool`、`runtime`、`layout` | プレイフィールドの周り全部: ヘッダー、ゲージ、スコア、BGA 枠、判定 / コンボ、演出 | -| `renderLanes(context)` | クリア済みの `Graphics` 1 つ、`lanes`、`beatPhase`、`nowMs`、`combo`、`effects`、`audio` | レーン下地、キービーム、判定ライン | -| `renderNote(context)` | 新しいプール済み `Graphics`、レーン `kind`、`x`、`w`、`y`(ノーツの下端) | タップノーツ 1 つ | -| `renderLongNote(context)` | 新しいプール済み `Graphics`、`kind`、`x`、`w`、`top`(終端)、`bottom`(始端) | ロングノーツ 1 つ | -| `renderBombs(context)` | `ChildPool`、`bombs`、`nowMs`、`combo`、`effects`、`audio` | 生きているヒットエフェクトすべて | -| `bombDurationMs` | — | ヒットエフェクトの寿命 | +`gameplay.draw(surface, frame)` は `BeMusicGameplayFrame` を受け取ります。 -表示オブジェクトはフレームごとに生成せず、渡されたプールから取得してください(`layerPool.acquireGraphics()`、`acquireText()` など)。プールは前フレームのオブジェクトを再利用するので、フレーム時間が安定します。 +- `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`。 -### `runtime` の値 +`gameplay.bombDurationMs`(既定 300)でヒットエフェクトの寿命を指定します。 -`runtime`(`SkinlessGameplayChromeRuntime`)は HUD の値を持ちます。 +`runtime`(`BeMusicGameplayRuntime`)の値: - **曲とスコア:** - `songTitle`、`songArtist`、`bpm`、`hiSpeed` @@ -158,49 +194,44 @@ export default defineCanvasSkin({ - **イベント時刻:** `judgeAtMs`、`impulseAtMs`、`impulseKind`(直近の判定とキー入力。パンチやインパルスの演出用) - **快適性とサウンド:** `effects`、`audio` -### 選曲(`BeMusicSelectSkin`) +### 選曲 -- **`layout`:** `listX`、`listTop`、`listBottomInset`、`rowHeight`。シーンはこの値で行の当たり判定をするので、描く行と一致させてください。 -- **`createRenderer()`:** 選曲シーンごとに 1 回呼ばれ、次のメンバーを持つ `BeMusicSelectRenderer` を返します。 - - `backLayer` / `frontLayer`: 毎フレーム作り直すレイヤーの背面と前面にある永続レイヤー。 - - `render(frame)`: 入力に応じたクロームを `frame.layer` に作り直します。トランジション中は `true` を返します。 - - `tick(nowMs, focusedSong, launchAt, audio)`: 永続レイヤーの毎フレームのアニメーション(変形のみ)。 - - `outroMs`: 曲決定後のアウトロの長さ。終わるまでシーンは `frame.launchAt` を設定して描画を続け、その間の入力は無視します。 - - `dispose()` -- **フレーム(`BeMusicSelectFrame`):** +- **`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`) - - 登場やフォーカス移動のトランジション用のタイムスタンプ - - `effects`、`launchAt` -- **クリック領域:** Pixi のクロームをクリック可能にするには `addHitArea` を使います。 + - `revision`: 選曲の状態(カーソル、フォルダ、検索、リスト内容)が変わるたびに増えます。保持型のシーングラフは、これが変わったときだけ作り直せば済みます。 + - 登場やフォーカス移動のトランジション用の `sceneStartedAt`、`cursorChangedAt` + - `nowMs`、`effects`、`launchAt`、`audio` + - `hit(x, y, w, h, action, cursor?)`: 矩形をそのフレームの間だけクリック可能にします。クリック領域は描画のたびに宣言してください。 + +### リザルト + +`result.draw(surface, frame)` は `BeMusicResultFrame` を受け取ります。 -### リザルト(`BeMusicResultSkin`) +- `result`(`BeMusicResultData`): スコア、判定数、ゲージとスコアの推移、プレイログ。 +- `rankLabel`(IIDX の DJ LEVEL)、`ratePercent`。 +- `elapsedMs`: シーン開始からの経過。登場演出をスキップした後は `Infinity`。 +- `nowMs`、`effects`。 -- **`render(frame)`:** 毎フレーム `frame.layer` を作り直します。 -- **フレームの内容:** - - `result`(スコア、判定数、ゲージ推移など) - - `rankLabel`(IIDX の DJ LEVEL)、`ratePercent` - - `elapsedMs`(シーン開始からの経過。登場演出をスキップした後は `Infinity`) - - `effects` -- **ヘルパー:** `resolveResultLamp` と `resolveResultTrackRows` が、組み込みスキンの表示するクリアランプとトラック行を返します。 +`resolveResultLamp` と `resolveResultTrackRows` が、組み込みスキンの表示するクリアランプとトラック行を返します。 ## SDK ヘルパー -| 分野 | ヘルパー | -| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| ステージ | `wideStage`、`resolveStageBgaRect`、`STAGE_WIDTH`、`STAGE_HEIGHT`、`STAGE_MARGIN`、`STAGE_SIDE_COLUMN`、`STAGE_BGA_BAND` | -| レイアウト | `resolveGameplayLayout`、`resolveLaneRuns`、`resolveMilestoneArea` | -| テキスト(Pixi) | `addHudText`、`addHudNumber`(プール・tint・キャップハイト揃え)、`addSkinText`、`addHitArea`、`formatPlayVariantLabel`、`DEFAULT_TEXT_FONT` | -| パーティクル(Pixi) | `pointLayerFor(graphics)`: プール済み `Graphics` に追従する GPU ポイントパーティクル | -| キービーム(Pixi) | `keyBeamGradient(color)`、`KEY_BEAM_STOPS` | -| 判定 | `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` | -| モーション | `easeOutCubic`、`easeOutBack`、`stageProgress`、`rollUpValue`、`hash01`(決定的なゆらぎ) | +| 分野 | ヘルパー | +| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| ステージ | `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`(決定的なゆらぎ) | ## 音楽に反応させる @@ -233,7 +264,7 @@ export default defineCanvasSkin({ ## パッケージングと読み込み -- **import の範囲:** スキンモジュールが import するのは `@be-music/player-web/skin-sdk`、`pixi.js`(Pixi スキンの場合)、自身のファイルだけです。組み込みスキンもテストでこのルールを守らされているので、組み込みスキンが使うものはすべて使えます。 +- **import の範囲:** スキンモジュールが import するのは `@be-music/player-web/skin-sdk`、選んだ描画フレームワーク、自身のファイルです。組み込みスキンもテストでこのルールを守らされているので、組み込みスキンがプレイヤーから使っているものはすべて使えます。 - **export:** スキンはモジュールの default export にします。 - **ホスト側:** ホストはスキンをレジストリに登録します。 @@ -264,10 +295,11 @@ export default defineCanvasSkin({ ## チェックリスト - [ ] メタデータが `validateBeMusicSkin` を通る。 +- [ ] `setup` で作ったフレームワークのリソースを `teardown` で解放している。 - [ ] クロームは `layout` から配置し、判定ラインはレーンランごとに描いている。 -- [ ] BGA の矩形が見えている(Canvas スキンは `runtime.hasBga` のとき `clearRect`)。 +- [ ] `runtime.hasBga` のとき BGA の矩形を透明のまま残している。 - [ ] `runtime.loading` の間は NOW LOADING を表示している。 -- [ ] 選曲の行が `select.layout` と揃い、ボタンがクリックできる。 +- [ ] 選曲の行が `select.layout` と揃い、ボタンは描画のたびに `frame.hit` で宣言している。 - [ ] 音への反応は `audioDrive` を通し、`effects` を尊重している。 - [ ] 使うフェイスをすべて `fontLoads` に挙げている。 - [ ] 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 index d195ecfd..16852136 100644 --- a/docs/be-music-skin.md +++ b/docs/be-music-skin.md @@ -2,33 +2,34 @@ # 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/player-web/skin-sdk` subpath. The built-in skins use exactly this subpath: Synesthesia, Phantom, Plain, and Lattice import nothing else from the player. Anything they do, your skin can do too. +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/player-web/skin-sdk` subpath. The built-in skins use exactly this subpath: 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 instead supplies a set of drawing functions. The player keeps ownership of everything else: +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. -Your skin decides how every frame looks. +Each frame, the player hands your skin a canvas and the frame's content as plain data. Your skin decides how it looks. -## Two ways to draw +## Bring your own renderer -| | Canvas skin (`defineCanvasSkin`) | Pixi skin (`defineBeMusicSkin`) | -| -------------- | ------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------- | -| Drawing API | A plain `` with a `'2d'`, `'webgl'`, `'webgl2'`, or `'webgpu'` context | PixiJS v8 display objects | -| What you write | One `draw` function per screen | Separate renderers for the chrome, lanes, notes, long notes, hit effects, select, and result | -| Pixi knowledge | None | Required | -| Example | Plain (`packages/player-web/src/skins/plain/`) | Synesthesia, Phantom, Lattice (`packages/player-web/src/skins/`) | +The SDK does not draw and imports no rendering framework. Draw the canvas with whatever suits your skin: -Start with a canvas skin unless you need Pixi features such as filters, blend modes, or GPU particles across many layers. Plain is deliberately small and readable; copy it as a starting point. +| 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` | — | -## Quick start: a canvas skin +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, defineCanvasSkin, resolveLaneRuns } from '@be-music/player-web/skin-sdk'; +import { BE_MUSIC_SKIN_API_VERSION, defineBeMusicSkin, resolveLaneRuns } from '@be-music/player-web/skin-sdk'; -export default defineCanvasSkin({ +export default defineBeMusicSkin({ apiVersion: BE_MUSIC_SKIN_API_VERSION, id: 'my-skin', label: 'My Skin', @@ -74,48 +75,86 @@ export default defineCanvasSkin({ }); ``` -### How canvas skins draw +## Using a framework -- **When `draw` runs:** the player calls it once per frame, right before rendering. With `'2d'`, the canvas is already cleared and scaled, so you draw in design pixels (854×480 on the default stage). -- **Other contexts:** with `'webgl'`, `'webgl2'`, or `'webgpu'`, you own the whole canvas. `surface.pixelRatio` gives the canvas pixels per design pixel. -- **`setup(surface)`:** runs once for each new surface before its first draw. Use it to compile shaders or request a GPU device. Drawing waits for a returned promise. -- **`contextAttributes`:** passed through to `canvas.getContext`. +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/player-web/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. -- **Gameplay frame (`CanvasGameplayFrame`):** - - `layout` - - `lanes` (each with a key-beam intensity) - - `notes` and `longNotes` - - `bombs` (live hit effects with their age) - - `runtime` (HUD values) - - `beatPhase`, `effects`, and `audio` -- **Select frame (`CanvasSelectFrame`):** the [select frame](#select-bemusicselectskin) plus `hit(x, y, w, h, action)`, which makes a rect clickable. Return `true` from `select.draw` while something is still animating. -- **Optional settings:** - - `gameplay.bombDurationMs` (default 300) - - `select.outroMs` (default 0) - - `stage` (default `wideStage`) +- **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` and `defineCanvasSkin` check them and throw 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. | -| `stage` | no | Design canvas; see [Stage and layout](#stage-and-layout). | +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). A skin that declares no `stage` gets the LR2-compatible 640×480 canvas. +- **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. @@ -126,26 +165,23 @@ Every skin declares these fields. `defineBeMusicSkin` and `defineCanvasSkin` che - **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. -## Pixi skins - -`defineBeMusicSkin` takes the same metadata plus three screens. Draw with `pixi.js`, a peer dependency of the player. +## Screens -### Gameplay (`BeMusicGameplaySkin`) +### Gameplay -| Member | Called with | Draws | -| ------------------------- | ---------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | -| `renderChrome(context)` | `layer`, `overlayLayer`, their `ChildPool`s, `runtime`, `layout` | Everything around the playfield: header, gauge, score, BGA frame, judgement / combo, moments | -| `renderLanes(context)` | one cleared `Graphics`, `lanes`, `beatPhase`, `nowMs`, `combo`, `effects`, `audio` | Lane beds, key beams, the judgement line | -| `renderNote(context)` | a fresh pooled `Graphics`, lane `kind`, `x`, `w`, `y` (the note's bottom edge) | One tap note | -| `renderLongNote(context)` | a fresh pooled `Graphics`, `kind`, `x`, `w`, `top` (tail), `bottom` (head) | One long note | -| `renderBombs(context)` | a `ChildPool`, `bombs`, `nowMs`, `combo`, `effects`, `audio` | Every live hit effect | -| `bombDurationMs` | — | How long a hit effect lives | +`gameplay.draw(surface, frame)` receives a `BeMusicGameplayFrame`: -Acquire display objects from the pools you are given (`layerPool.acquireGraphics()`, `acquireText()`, …) instead of creating them per frame. The pools reuse last frame's objects, which keeps frame time flat. +- `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`. -### Values in `runtime` +`gameplay.bombDurationMs` (default 300) sets how long a hit effect lives. -`runtime` (`SkinlessGameplayChromeRuntime`) carries the HUD values: +`runtime` (`BeMusicGameplayRuntime`) carries: - **Song and score:** - `songTitle`, `songArtist`, `bpm`, `hiSpeed` @@ -158,49 +194,44 @@ Acquire display objects from the pools you are given (`layerPool.acquireGraphics - **Event timestamps:** `judgeAtMs`, `impulseAtMs`, `impulseKind` (the last judgement and key press, for punch and impulse effects) - **Comfort and sound:** `effects`, `audio` -### Select (`BeMusicSelectSkin`) +### Select -- **`layout`:** `listX`, `listTop`, `listBottomInset`, and `rowHeight`. The scene hit-tests rows with these numbers, so the rows you draw must match them. -- **`createRenderer()`:** called once per select scene. It returns a `BeMusicSelectRenderer` with: - - `backLayer` and `frontLayer`: persistent layers behind and in front of the rebuilt frame. - - `render(frame)`: rebuilds the input-driven chrome into `frame.layer`. Return `true` while a transition is still running. - - `tick(nowMs, focusedSong, launchAt, audio)`: per-frame, transform-only animation of the persistent layers. - - `outroMs`: length of the launch outro. The scene keeps rendering with `frame.launchAt` set until it ends, and ignores input meanwhile. - - `dispose()` -- **Frame (`BeMusicSelectFrame`):** +- **`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`) - - timestamps for entrance and focus transitions - - `effects`, `launchAt` -- **Clickable areas:** use `addHitArea` to make Pixi chrome clickable. + - `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 (`BeMusicResultSkin`) +- `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`. -- **`render(frame)`:** rebuilds `frame.layer` every frame. -- **Frame contents:** - - `result` (scores, judgement counts, gauge history, …) - - `rankLabel` (IIDX DJ level) and `ratePercent` - - `elapsedMs` since the scene mounted, or `Infinity` once the player skipped the entrance - - `effects` -- **Helpers:** `resolveResultLamp` and `resolveResultTrackRows` give the clear lamp and track rows the built-in skins show. +`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` | -| Text (Pixi) | `addHudText`, `addHudNumber` (pooled, tinted, cap-height aligned), `addSkinText`, `addHitArea`, `formatPlayVariantLabel`, `DEFAULT_TEXT_FONT` | -| Particles (Pixi) | `pointLayerFor(graphics)`: GPU point particles that ride along with a pooled `Graphics` | -| Key beams (Pixi) | `keyBeamGradient(color)`, `KEY_BEAM_STOPS` | -| 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` | -| Motion | `easeOutCubic`, `easeOutBack`, `stageProgress`, `rollUpValue`, `hash01` (deterministic jitter) | +| 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 @@ -233,7 +264,7 @@ List every face you draw with in `fontLoads`. The host loads them with `document ## Packaging and loading -- **Imports:** a skin module imports only `@be-music/player-web/skin-sdk`, `pixi.js` (Pixi skins), and its own files. The built-in skins are held to this rule by a test, so anything they use is available to you. +- **Imports:** a skin module imports `@be-music/player-web/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: @@ -264,10 +295,11 @@ List every face you draw with in `fontLoads`. The host loads them with `document ## 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 visible (canvas skins: `clearRect` it when `runtime.hasBga`). +- [ ] 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 clickable. +- [ ] 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`. - [ ] Checked in single play, double play, 5 / 7 / 9 / 10 / 14 / 24 / 48 KEY, and with and without a BGA. diff --git a/packages/player-web/src/scene/core/gameplay.ts b/packages/player-web/src/scene/core/gameplay.ts index fe0df959..abb946d1 100644 --- a/packages/player-web/src/scene/core/gameplay.ts +++ b/packages/player-web/src/scene/core/gameplay.ts @@ -142,7 +142,14 @@ import type { } from '../../skin/be-music/types.ts'; import { resolveBeMusicLaneKind } from '../../skin/be-music/registry.ts'; import { resolveGameplayLayout } from '../../skin-sdk/layout.ts'; -import { phantomSkin } from '../../skins/phantom/index.ts'; +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, @@ -1164,7 +1171,8 @@ 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), @@ -4035,16 +4064,15 @@ export class CoreGameplayView(); /** Fallback summary panel (used when no theme paints the frame). */ private readonly fallbackLayer = new Container(); - private readonly fallbackDisposer = new LaggedDisposer(); + /** 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(); /** @@ -317,7 +319,8 @@ export class CoreResultView { log.warn('texture cleanup threw', error); } try { - this.fallbackDisposer.flush(); + this.skinBinding?.dispose(); + this.skinBinding = undefined; this.sceneRoot.destroy({ children: true, context: true }); } catch (error) { log.warn('sceneRoot.destroy threw', error); @@ -357,9 +360,9 @@ 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 stage = (this.options.beMusicSkin ?? phantomSkin).stage; - const designWidth = themeSize ? themeSize.width : (stage?.width ?? FALLBACK_DESIGN_WIDTH); - const designHeight = themeSize ? themeSize.height : (stage?.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); @@ -381,13 +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); - // The built-in result is rebuilt every frame; retiring its nodes one frame late lets unchanged labels keep their - // text textures (see `LaggedDisposer`). - this.fallbackDisposer.cycle(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); } @@ -398,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, diff --git a/packages/player-web/src/scene/core/select.ts b/packages/player-web/src/scene/core/select.ts index 8d032779..55dac37d 100644 --- a/packages/player-web/src/scene/core/select.ts +++ b/packages/player-web/src/scene/core/select.ts @@ -15,7 +15,8 @@ import type { BrowserSongEntry, } from '../../collection/types.ts'; import { CORE_TEXT_FONT } from './fonts.ts'; -import type { BeMusicEffectLevel, 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 '../../skins/phantom/index.ts'; import { @@ -42,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. @@ -616,7 +615,7 @@ export class CoreSongSelectView(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-sdk/canvas-skin.ts b/packages/player-web/src/skin-sdk/canvas-skin.ts deleted file mode 100644 index 54da179e..00000000 --- a/packages/player-web/src/skin-sdk/canvas-skin.ts +++ /dev/null @@ -1,346 +0,0 @@ -import { CanvasSource, Container, Sprite, Texture } from 'pixi.js'; -import { getDesignPixelRatio } from '../scene/core/viewport.ts'; -import type { - BeMusicAudioFrame, - BeMusicBomb, - BeMusicEffectLevel, - BeMusicGameplayLayout, - BeMusicLaneFrame, - BeMusicLaneKind, - BeMusicResultFrame, - BeMusicSelectFrame, - BeMusicSelectLayout, - BeMusicSkin, - BeMusicStage, -} from '../skin/be-music/types.ts'; -import type { SkinlessGameplayChromeRuntime } from '../scene/gameplay-chrome.ts'; -import { defineBeMusicSkin } from './define.ts'; -import { addHitArea } from './skin-text.ts'; -import { wideStage } from './stage.ts'; -import { logger } from '../logger.ts'; - -/** - * Canvas skins: draw every screen yourself onto a plain `` — with the 2D API, WebGL, or WebGPU — instead of - * building Pixi display objects. The player shows the canvas as one texture per screen, so a canvas skin needs no - * Pixi knowledge at all. - * - * Each frame the player collects what is on screen (layout, lanes, notes, long notes, hit effects, HUD values) and - * calls your `draw` once, right before it renders. With the `'2d'` context the canvas is already cleared and scaled, - * so you draw in design pixels (854×480 on the default stage); with `'webgl'` / `'webgl2'` / `'webgpu'` you own the - * whole canvas (`surface.pixelRatio` tells you its scale) and can prepare devices or pipelines in `setup`. - */ - -const log = logger('canvas-skin'); - -/** Canvas rendering contexts a canvas skin can draw with. */ -export interface CanvasContextMap { - '2d': CanvasRenderingContext2D; - webgl: WebGLRenderingContext; - webgl2: WebGL2RenderingContext; - webgpu: GPUCanvasContext; -} - -export type CanvasContextKind = keyof CanvasContextMap; - -/** The canvas a skin draws one screen on. */ -export interface CanvasSurface { - canvas: HTMLCanvasElement; - context: CanvasContextMap[K]; - /** Size in design pixels (the stage size). */ - width: number; - height: number; - /** Canvas pixels per design pixel. `'2d'` surfaces are pre-scaled by it; other contexts apply it themselves. */ - pixelRatio: number; -} - -/** A tap note as drawn this frame: its lane rect and the y its bottom edge sits at. */ -export interface CanvasNote { - kind: BeMusicLaneKind; - x: number; - w: number; - y: number; -} - -/** A long note as drawn this frame: its lane rect, tail (top) and head (bottom). */ -export interface CanvasLongNote { - kind: BeMusicLaneKind; - x: number; - w: number; - top: number; - bottom: number; -} - -/** Everything on the gameplay screen this frame. */ -export interface CanvasGameplayFrame { - nowMs: number; - /** HUD values: score, combo, gauge, judgements, song, loading, … */ - runtime: SkinlessGameplayChromeRuntime; - /** 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 CanvasNote[]; - longNotes: readonly CanvasLongNote[]; - /** Live hit effects with their age. */ - bombs: readonly BeMusicBomb[]; - /** Fractional beat position in [0, 1). */ - beatPhase: number; - effects: BeMusicEffectLevel; - audio: BeMusicAudioFrame | undefined; -} - -/** The select frame, plus a way to make parts of the canvas clickable. */ -export interface CanvasSelectFrame extends BeMusicSelectFrame { - /** Makes the rect (design pixels) run `action` when clicked — e.g. `frame.hit(x, y, w, h, frame.actions.play)`. */ - hit(x: number, y: number, w: number, h: number, action: () => void): void; -} - -export interface CanvasSkinDefinition extends Omit< - BeMusicSkin, - 'stage' | 'gameplay' | 'select' | 'result' -> { - /** Which context to draw with. */ - context: K; - /** Attributes passed to `canvas.getContext` (e.g. `{ alpha: true }`, `{ antialias: true }`). */ - contextAttributes?: CanvasRenderingContext2DSettings | WebGLContextAttributes; - /** Design canvas; defaults to the 16:9 {@link wideStage}. */ - stage?: BeMusicStage; - /** - * Called once for every new surface before its first draw — compile shaders, request a GPU device, configure a - * WebGPU context. Drawing waits until a returned promise settles. - */ - setup?: (surface: CanvasSurface) => void | Promise; - gameplay: { - draw(surface: CanvasSurface, frame: CanvasGameplayFrame): void; - /** How long a hit effect lives (ms). Default 300. */ - bombDurationMs?: number; - }; - select: { - /** Where the song list sits (the player hit-tests the rows with it). */ - layout: BeMusicSelectLayout; - /** Length of the launch outro (ms) the select screen keeps drawing after a chart is picked. Default 0. */ - outroMs?: number; - /** Return `true` while something is still animating, to be drawn again next frame. */ - draw(surface: CanvasSurface, frame: CanvasSelectFrame): boolean | void; - }; - result: { - draw(surface: CanvasSurface, frame: BeMusicResultFrame): void; - }; -} - -/** Largest canvas edge a surface allocates, whatever the window size. */ -const MAX_CANVAS_EDGE = 4096; - -/** One canvas + the texture showing it, sized to the screen pixels the stage covers (dot by dot). */ -class Surface { - private readonly canvas = document.createElement('canvas'); - private readonly source: CanvasSource; - readonly texture: Texture; - private context: CanvasContextMap[K] | undefined; - private state: 'new' | 'pending' | 'ready' | 'failed' = 'new'; - private pixelRatio = 1; - private readonly definition: CanvasSkinDefinition; - private readonly stage: BeMusicStage; - - constructor(definition: CanvasSkinDefinition, stage: BeMusicStage) { - this.definition = definition; - this.stage = stage; - // 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: CanvasSurface) => void): void { - this.resize(); - const context = this.ensureContext(); - if (!context || this.state !== 'ready') return; - const surface: CanvasSurface = { - canvas: this.canvas, - context, - width: this.stage.width, - height: this.stage.height, - pixelRatio: this.pixelRatio, - }; - if (this.definition.context === '2d') { - const ctx = 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); - } - paint(surface); - this.source.update(); - } - - destroy(): void { - this.texture.destroy(true); - } - - /** - * 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(): CanvasContextMap[K] | undefined { - if (this.state === 'new') { - const context = this.canvas.getContext(this.definition.context, this.definition.contextAttributes) as - | CanvasContextMap[K] - | null; - if (!context) { - log.warn(`"${this.definition.id}": the browser offers no "${this.definition.context}" canvas context`); - this.state = 'failed'; - return undefined; - } - this.context = context; - this.state = 'pending'; - const setup = this.definition.setup?.({ - canvas: this.canvas, - context, - width: this.stage.width, - height: this.stage.height, - pixelRatio: this.pixelRatio, - }); - if (setup instanceof Promise) { - setup.then( - () => (this.state = 'ready'), - (error: unknown) => { - log.warn(`"${this.definition.id}": setup failed`, error); - this.state = 'failed'; - }, - ); - } else { - this.state = 'ready'; - } - } - return this.context; - } -} - -/** - * Turns a {@link CanvasSkinDefinition} into a regular `BeMusicSkin` (validated like {@link defineBeMusicSkin}). - * - * ```ts - * export default defineCanvasSkin({ - * apiVersion: BE_MUSIC_SKIN_API_VERSION, - * id: 'plain', label: 'Plain', version: '1.0.0', author: { name: 'Jane Doe' }, fontLoads: [], - * context: '2d', - * gameplay: { draw: ({ context: ctx }, frame) => { … } }, - * select: { layout, draw: ({ context: ctx }, frame) => { … } }, - * result: { draw: ({ context: ctx }, frame) => { … } }, - * }); - * ``` - */ -export function defineCanvasSkin(definition: CanvasSkinDefinition): BeMusicSkin { - const { - context: _context, - contextAttributes: _attributes, - setup: _setup, - gameplay, - select, - result, - ...meta - } = definition; - const stage = definition.stage ?? wideStage; - - // Gameplay: the hooks below only collect this frame's content; the canvas is drawn once, when Pixi renders it. - let gameplaySurface: Surface | undefined; - let frame: CanvasGameplayFrame | undefined; - let frameNumber = 0; - let drawnFrame = -1; - const drawGameplay = (): void => { - if (!frame || !gameplaySurface || drawnFrame === frameNumber) return; - drawnFrame = frameNumber; - const current = frame; - gameplaySurface.draw((surface) => gameplay.draw(surface, current)); - }; - - // Result: its frame layer is rebuilt every frame, so one surface is kept here and shown by a fresh sprite. - let resultSurface: Surface | undefined; - - return defineBeMusicSkin({ - ...meta, - stage, - gameplay: { - bombDurationMs: gameplay.bombDurationMs ?? 300, - renderChrome: ({ layerPool, runtime, layout }) => { - gameplaySurface ??= new Surface(definition, stage); - frameNumber += 1; - frame = { - nowMs: runtime.nowMs ?? 0, - runtime, - layout, - lanes: [], - notes: [], - longNotes: [], - bombs: [], - beatPhase: runtime.beatPhase ?? 0, - effects: runtime.effects ?? 'full', - audio: runtime.audio, - }; - const sprite = layerPool.acquireSprite(); - sprite.texture = gameplaySurface.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 = drawGameplay; - }, - renderLanes: ({ lanes }) => { - if (frame) frame.lanes = lanes.map((lane) => ({ ...lane })); - }, - renderNote: ({ kind, x, w, y }) => { - (frame?.notes as CanvasNote[] | undefined)?.push({ kind, x, w, y }); - }, - renderLongNote: ({ kind, x, w, top, bottom }) => { - (frame?.longNotes as CanvasLongNote[] | undefined)?.push({ kind, x, w, top, bottom }); - }, - renderBombs: ({ bombs }) => { - if (frame) frame.bombs = bombs.map((bomb) => ({ ...bomb })); - }, - }, - select: { - layout: select.layout, - createRenderer: () => { - const surface = new Surface(definition, stage); - const backLayer = new Container(); - const sprite = new Sprite({ texture: surface.texture, roundPixels: true }); - backLayer.addChild(sprite); - return { - outroMs: select.outroMs ?? 0, - backLayer, - frontLayer: new Container(), - render: (selectFrame) => { - const hits: Array<[number, number, number, number, () => void]> = []; - let animating = false; - surface.draw((canvas) => { - animating = select.draw(canvas, { ...selectFrame, hit: (...rect) => hits.push(rect) }) === true; - }); - for (const [x, y, w, h, action] of hits) addHitArea(selectFrame.layer, x, y, w, h, 'pointer', action); - return animating; - }, - tick: () => {}, - dispose: () => { - backLayer.destroy({ children: true }); - surface.destroy(); - }, - }; - }, - }, - result: { - render: (resultFrame) => { - resultSurface ??= new Surface(definition, stage); - const surface = resultSurface; - surface.draw((canvas) => result.draw(canvas, resultFrame)); - resultFrame.layer.addChild(new Sprite({ texture: surface.texture, roundPixels: true })); - }, - }, - }); -} diff --git a/packages/player-web/src/skin-sdk/define.test.ts b/packages/player-web/src/skin-sdk/define.test.ts index 16b30d9a..d38ac798 100644 --- a/packages/player-web/src/skin-sdk/define.test.ts +++ b/packages/player-web/src/skin-sdk/define.test.ts @@ -15,9 +15,10 @@ function skin(overrides: Partial = {}): BeMusicSkin { version: '1.2.0', author: { name: 'Jane Doe', url: 'https://example.com/jane' }, fontLoads: [], - gameplay: {} as BeMusicSkin['gameplay'], - select: {} as BeMusicSkin['select'], - result: {} as BeMusicSkin['result'], + context: '2d', + gameplay: { draw: () => {} }, + select: { layout: { listX: 0, listTop: 0, listBottomInset: 0, rowHeight: 20 }, draw: () => {} }, + result: { draw: () => {} }, ...overrides, }; } @@ -36,6 +37,18 @@ describe('validateBeMusicSkin', () => { 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); }); diff --git a/packages/player-web/src/skin-sdk/define.ts b/packages/player-web/src/skin-sdk/define.ts index 43924162..a9f4acbc 100644 --- a/packages/player-web/src/skin-sdk/define.ts +++ b/packages/player-web/src/skin-sdk/define.ts @@ -1,4 +1,4 @@ -import type { BeMusicSkin } from '../skin/be-music/types.ts'; +import type { BeMusicSkin, BeMusicSurfaceContextKind } from '../skin/be-music/types.ts'; /** * The skin API revision this build of the player implements. Skins record the revision they were written against in @@ -14,6 +14,8 @@ 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 = @@ -49,6 +51,12 @@ export function validateBeMusicSkin(skin: BeMusicSkin): string[] { 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; } @@ -64,8 +72,10 @@ export function validateBeMusicSkin(skin: BeMusicSkin): string[] { * version: '1.0.0', * author: { name: 'Jane Doe', url: 'https://example.com' }, * fontLoads: [], - * stage: wideStage, - * gameplay, select, result, + * context: '2d', + * gameplay: { draw: ({ context: ctx }, frame) => { … } }, + * select: { layout, draw: ({ context: ctx }, frame) => { … } }, + * result: { draw: ({ context: ctx }, frame) => { … } }, * }); * ``` */ diff --git a/packages/player-web/src/skin-sdk/framework-free.test.ts b/packages/player-web/src/skin-sdk/framework-free.test.ts new file mode 100644 index 00000000..020d4b4e --- /dev/null +++ b/packages/player-web/src/skin-sdk/framework-free.test.ts @@ -0,0 +1,35 @@ +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 its only package imports are the repository's own `@be-music/*` packages. + */ +const SDK_BARREL = resolve(import.meta.dirname, 'index.ts'); +const SRC_DIR = resolve(import.meta.dirname, '..'); +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('.')) pending.push(resolve(dirname(file), specifier)); + else if (!specifier.startsWith('@be-music/')) found.push(`${relative(SRC_DIR, file)} imports ${specifier}`); + } + } + return found; +} + +describe('skin SDK', () => { + it('reaches no package outside @be-music, so no rendering framework', () => { + expect(packageImports(SDK_BARREL)).toEqual([]); + }); +}); diff --git a/packages/player-web/src/skin-sdk/index.ts b/packages/player-web/src/skin-sdk/index.ts index 525933f0..4cac47c8 100644 --- a/packages/player-web/src/skin-sdk/index.ts +++ b/packages/player-web/src/skin-sdk/index.ts @@ -1,29 +1,20 @@ /** - * `@be-music/player-web/skin-sdk` — everything a be-music skin is written against. The built-in skins (Phantom, - * Synesthesia, Lattice) import nothing else from the player, so a third-party skin can do whatever they do. + * `@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) and the `BeMusicGameplayLayout` each chrome - * frame receives, plus {@link resolveMilestoneArea} for placing cut-ins clear of the lanes. - * - Drawing: pooled HUD text (`addHudText` / `addHudNumber`), skin text and hit areas (`addSkinText`, `addHitArea`), - * cap-height alignment, GPU point particles (`pointLayerFor`), and key-beam gradients. + * - 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. - * - Motion: easing, staged timelines, roll-up counters, and a deterministic hash for jitter. - * - * Skins draw with Pixi (`pixi.js` is a peer dependency of the player). + * - 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 '../skin/be-music/types.ts'; export type { BrowserBrowseEntry, BrowserFolderNode, BrowserSongEntry } from '../collection/types.ts'; -export type { PixiGameplayResultData } from '../scene/core/result-data.ts'; -export type { - SkinlessGameplayChromeRenderContext, - SkinlessGameplayChromeRuntime, - SkinlessGameplayJudgeState, -} from '../scene/gameplay-chrome.ts'; -export { ChildPool } from '../scene/pixi-utils.ts'; export * from './define.ts'; export * from './stage.ts'; @@ -31,15 +22,9 @@ export * from './layout.ts'; export * from './fonts.ts'; export * from './motion.ts'; export * from './tabular.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'; 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'; -export * from './canvas-skin.ts'; diff --git a/packages/player-web/src/skin-sdk/layout.ts b/packages/player-web/src/skin-sdk/layout.ts index 1b91e5f5..0aa41716 100644 --- a/packages/player-web/src/skin-sdk/layout.ts +++ b/packages/player-web/src/skin-sdk/layout.ts @@ -1,5 +1,5 @@ import type { ChartPlayVariant } from '@be-music/player/core/lane-layout'; -import { PLAYFIELD } from '../scene/gameplay-constants.ts'; +import { PLAYFIELD } from '../scene/gameplay-geometry.ts'; import { resolveLr2LaneIndex, resolveSkinlessLaneLayout } from '../scene/gameplay-lanes.ts'; import { resolveBeMusicLaneKind } from '../skin/be-music/registry.ts'; import type { BeMusicGameplayLayout, BeMusicStage } from '../skin/be-music/types.ts'; diff --git a/packages/player-web/src/skin-sdk/result-track.ts b/packages/player-web/src/skin-sdk/result-track.ts index aa0a86f0..a7bdbfcf 100644 --- a/packages/player-web/src/skin-sdk/result-track.ts +++ b/packages/player-web/src/skin-sdk/result-track.ts @@ -1,5 +1,5 @@ import type { PixiGameplayResultData } from '../scene/core/result-data.ts'; -import { formatPlayVariantLabel } from './skin-text.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 / diff --git a/packages/player-web/src/skin-sdk/song-stats.test.ts b/packages/player-web/src/skin-sdk/song-stats.test.ts index f7c7705b..bb39961b 100644 --- a/packages/player-web/src/skin-sdk/song-stats.test.ts +++ b/packages/player-web/src/skin-sdk/song-stats.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vite-plus/test'; import { parseChart } from '@be-music/parser'; import { formatBpmRange, + formatPlayVariantLabel, formatSongLength, resolveSongRowFacts, resolveSongStats, @@ -80,3 +81,11 @@ describe('resolveSongRowFacts', () => { 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/player-web/src/skin-sdk/song-stats.ts b/packages/player-web/src/skin-sdk/song-stats.ts index d67a51e6..d88ab9f1 100644 --- a/packages/player-web/src/skin-sdk/song-stats.ts +++ b/packages/player-web/src/skin-sdk/song-stats.ts @@ -1,5 +1,6 @@ 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 '../collection/types.ts'; @@ -104,3 +105,8 @@ export function resolveSongRowFacts(song: Pick): string { + return `${resolveChartPlayVariant({ chartPath: song.chartPath, events: song.chart.events, bms: song.chart.bms })} KEYS`; +} 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..547384d3 --- /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 '../../skin-sdk/stage.ts'; +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 3b1d89b0..a5964028 100644 --- a/packages/player-web/src/skin/be-music/registry.test.ts +++ b/packages/player-web/src/skin/be-music/registry.test.ts @@ -9,7 +9,12 @@ function stubSkin(id: string, apiVersion = 1): BeMusicSkin { label: id.toUpperCase(), version: '1.0.0', author: { name: 'be-music' }, - } as unknown as BeMusicSkin; + fontLoads: [], + context: '2d', + gameplay: { draw: () => {} }, + select: { layout: { listX: 0, listTop: 0, listBottomInset: 0, rowHeight: 20 }, draw: () => {} }, + result: { draw: () => {} }, + }; } describe('createBeMusicSkinRegistry', () => { diff --git a/packages/player-web/src/skin/be-music/types.ts b/packages/player-web/src/skin/be-music/types.ts index fbdec4c4..b4db3074 100644 --- a/packages/player-web/src/skin/be-music/types.ts +++ b/packages/player-web/src/skin/be-music/types.ts @@ -1,23 +1,21 @@ -import type { Container, Graphics } from 'pixi.js'; +import type { ChartPlayVariant } from '@be-music/player/core/lane-layout'; import type { BrowserBrowseEntry, BrowserSongEntry } from '../../collection/types.ts'; -import type { SkinlessGameplayChromeRenderContext } from '../../scene/gameplay-chrome.ts'; import type { PixiGameplayResultData } from '../../scene/core/result-data.ts'; -import type { ChildPool } from '../../scene/pixi-utils.ts'; import type { AudioFeatures } from '../../runtime/audio-analysis.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 design canvas ({@link BeMusicSkin.stage}, 640x480 unless the skin declares another): 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. + * 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. Swapping skins only needs the scenes to be rebuilt. - * - * Skins are written against the public `@be-music/player-web/skin-sdk` subpath (the built-in skins included) and - * declared with its `defineBeMusicSkin`, which checks {@link BeMusicSkin.apiVersion} and the metadata. + * scene classes via their `beMusicSkin` option. */ -export interface BeMusicSkin { +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. @@ -42,14 +40,22 @@ export interface BeMusicSkin { * 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; /** - * Design canvas the skin draws on and where its gameplay BGA sits. Omitted: the LR2-compatible 640x480 canvas with - * the fixed 256 px BGA square. + * 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. */ - readonly stage?: BeMusicStage; - readonly gameplay: BeMusicGameplaySkin; - readonly select: BeMusicSelectSkin; - readonly result: BeMusicResultSkin; + 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. */ @@ -59,6 +65,30 @@ export interface BeMusicSkinAuthor { 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; @@ -129,24 +159,77 @@ export interface BeMusicGameplayLayout { bga: BeMusicRect | undefined; } -/** What {@link BeMusicGameplaySkin.renderChrome} receives: the layers and runtime values, plus the frame's layout. */ -export interface BeMusicChromeContext extends SkinlessGameplayChromeRenderContext { - layout: BeMusicGameplayLayout; +/** 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; } -export interface BeMusicGameplaySkin { - /** HUD chrome around the playfield (header, gauge, score, BGA frame, judgement / combo text). */ - readonly renderChrome: (context: BeMusicChromeContext) => void; - /** 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; +/** 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 { @@ -161,40 +244,21 @@ export interface BeMusicLaneFrame { beam: number; } -export interface BeMusicLanesContext { - 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; - /** What is playing right now (see {@link BeMusicAudioFrame}). */ - audio?: BeMusicAudioFrame; -} - -export interface BeMusicNoteContext { - graphics: Graphics; +/** A tap note as drawn this frame: its lane rect and the y its bottom edge sits at. */ +export interface BeMusicNote { 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; +/** 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; - /** 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 { @@ -210,18 +274,33 @@ export interface BeMusicBomb { seed: number; } -export interface BeMusicBombsContext { - pool: ChildPool; - bombs: readonly BeMusicBomb[]; +/** Everything on the gameplay screen this frame. */ +export interface BeMusicGameplayFrame { nowMs: number; - /** Current combo — lets skins escalate hit effects as a run builds. */ - combo?: number; - effects?: BeMusicEffectLevel; - /** What is playing right now (see {@link BeMusicAudioFrame}). */ - audio?: BeMusicAudioFrame; + /** 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 select renderer (drawing) and the scene (row hit-testing). */ +/** Song-list geometry shared by the skin (drawing) and the scene (row hit-testing). */ export interface BeMusicSelectLayout { listX: number; listTop: number; @@ -230,24 +309,22 @@ export interface BeMusicSelectLayout { 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; } +/** Everything on the select screen this frame. */ export interface BeMusicSelectFrame { - /** Rebuilt every render: the renderer adds its chrome, rows, and hit areas here. */ - layer: Container; 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. */ @@ -266,34 +343,33 @@ export interface BeMusicSelectFrame { effects: BeMusicEffectLevel; /** `performance.now()` when a chart was launched and the skin's outro is playing, otherwise `undefined`. */ launchAt: number | undefined; -} - -export interface BeMusicSelectRenderer { + /** What is playing right now: the BGM or the chart preview (see {@link BeMusicAudioFrame}). */ + audio: BeMusicAudioFrame | undefined; /** - * Length (ms) of the outro the renderer plays after a chart is launched, before the scene hands off to gameplay. - * `0` / omitted launches immediately. The scene keeps rendering frames (with `frame.launchAt` set) until it elapses. + * 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; - /** 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, launchAt?: number, audio?: BeMusicAudioFrame): void; - dispose(): void; + /** Draws one select frame. Called every frame while the select screen is shown. */ + draw(surface: BeMusicSurface, frame: BeMusicSelectFrame): void; } +/** A finished play: song, score, judgement counts, gauge and score history, and the play log. */ +export type BeMusicResultData = PixiGameplayResultData; + +/** Everything on the result screen this frame. */ export interface BeMusicResultFrame { - /** Rebuilt every frame. */ - layer: Container; designWidth: number; designHeight: number; - result: PixiGameplayResultData; + result: BeMusicResultData; /** IIDX DJ level (`AAA` … `F`). */ rankLabel: string; /** EX-score rate in percent. */ @@ -304,6 +380,7 @@ export interface BeMusicResultFrame { effects: BeMusicEffectLevel; } -export interface BeMusicResultSkin { - render(frame: BeMusicResultFrame): void; +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/player-web/src/skins/boundary.test.ts b/packages/player-web/src/skins/boundary.test.ts index 24e2a9d6..ca0d8f49 100644 --- a/packages/player-web/src/skins/boundary.test.ts +++ b/packages/player-web/src/skins/boundary.test.ts @@ -3,13 +3,18 @@ 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 barrel, Pixi, and - * files inside its own folder (tests may also use the test runner and Node built-ins); Plain, the Canvas 2D example, - * not even Pixi. This keeps the SDK honest — if a - * built-in skin needs something, the SDK has to offer it. + * The built-in skins are written like third-party skins: each may import only the public skin SDK barrel, 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_BARREL = resolve(SKINS_DIR, '../skin-sdk/index.ts'); +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[] { @@ -26,12 +31,13 @@ function violations(file: string): string[] { const found: string[] = []; for (const match of readFileSync(file, 'utf8').matchAll(SPECIFIER)) { const specifier = match[1] ?? match[2]!; - // Plain is the Canvas 2D example: it must not need Pixi at all. - if (specifier === 'pixi.js' && !file.startsWith(join(SKINS_DIR, 'plain') + sep)) continue; + const usesPixi = !FRAMEWORK_FREE.has(relative(SKINS_DIR, skinDir)); + if (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 === SDK_BARREL || target.startsWith(skinDir + sep)) continue; + if (target === PIXI_KIT_BARREL && usesPixi && skinDir !== PIXI_KIT) continue; } found.push(`${relative(SKINS_DIR, file)} imports ${specifier}`); } @@ -42,10 +48,13 @@ 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', 'plain', 'synesthesia']); + 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, pixi.js, and its own files', (name) => { - expect(listFiles(join(SKINS_DIR, name)).flatMap(violations)).toEqual([]); - }); + 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/lattice/draw.ts b/packages/player-web/src/skins/lattice/draw.ts index c2d6ed96..abe92551 100644 --- a/packages/player-web/src/skins/lattice/draw.ts +++ b/packages/player-web/src/skins/lattice/draw.ts @@ -8,7 +8,7 @@ import { type HudTextOptions, pointLayerFor, type SkinTextOptions, -} from '../../skin-sdk/index.ts'; +} from '../pixi-kit/index.ts'; /** * Shared Lattice drawing: the graph-paper grid, the needle field, ruler ticks, per-letter staggered type, and the diff --git a/packages/player-web/src/skins/lattice/gameplay-chrome.ts b/packages/player-web/src/skins/lattice/gameplay-chrome.ts index 2506f484..52608b19 100644 --- a/packages/player-web/src/skins/lattice/gameplay-chrome.ts +++ b/packages/player-web/src/skins/lattice/gameplay-chrome.ts @@ -24,13 +24,10 @@ import { LAT_WHITE, } from './style.ts'; import { - addHudNumber, - addHudText, audioDrive, type AudioDrive, bandLevel, type BeMusicRect, - type ChildPool, comboTier, effectProfile, flashingGreatColor, @@ -42,16 +39,16 @@ import { type MomentState, trackMoments, resolveMilestoneArea, - type BeMusicChromeContext, type BeMusicGameplayLayout, - type SkinlessGameplayChromeRuntime, + type BeMusicGameplayRuntime, STAGE_BGA_BAND, STAGE_HEIGHT as DESIGN_HEIGHT, STAGE_SIDE_COLUMN, STAGE_WIDTH as DESIGN_WIDTH, } from '../../skin-sdk/index.ts'; +import { addHudNumber, addHudText, type ChildPool, type PixiChromeContext } from '../pixi-kit/index.ts'; -type Runtime = SkinlessGameplayChromeRuntime; +type Runtime = BeMusicGameplayRuntime; /** * HUD text scramble: during the count-in every label and the song title decode in, staggered by `delayMs`; once the @@ -98,7 +95,7 @@ export function renderLatticeChrome({ overlayLayerPool, runtime, layout, -}: BeMusicChromeContext): void { +}: PixiChromeContext): void { const nowMs = runtime.nowMs ?? 0; const seconds = nowMs / 1000; const beatPhase = runtime.beatPhase ?? 0; diff --git a/packages/player-web/src/skins/lattice/index.ts b/packages/player-web/src/skins/lattice/index.ts index cd456a4c..e260344f 100644 --- a/packages/player-web/src/skins/lattice/index.ts +++ b/packages/player-web/src/skins/lattice/index.ts @@ -8,7 +8,8 @@ import { } from './playfield.ts'; import { latticeResultSkin } from './result.ts'; import { latticeSelectSkin } from './select.ts'; -import { type BeMusicSkin, wideStage, BE_MUSIC_SKIN_API_VERSION, defineBeMusicSkin } from '../../skin-sdk/index.ts'; +import { BE_MUSIC_SKIN_API_VERSION, wideStage, type BeMusicSkin } from '../../skin-sdk/index.ts'; +import { definePixiSkin } from '../pixi-kit/index.ts'; /** * Lattice — a skin in the manner of precise, typographic interaction design (the Japanese web / broadcast interaction-design @@ -16,7 +17,7 @@ import { type BeMusicSkin, wideStage, BE_MUSIC_SKIN_API_VERSION, defineBeMusicSk * 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 = defineBeMusicSkin({ +export const latticeSkin: BeMusicSkin = definePixiSkin({ apiVersion: BE_MUSIC_SKIN_API_VERSION, id: 'lattice', label: 'Lattice', diff --git a/packages/player-web/src/skins/lattice/moments.ts b/packages/player-web/src/skins/lattice/moments.ts index 95f26222..c1836efe 100644 --- a/packages/player-web/src/skins/lattice/moments.ts +++ b/packages/player-web/src/skins/lattice/moments.ts @@ -4,10 +4,8 @@ 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 { - addHudText, easeOutCubic, type BeMusicRect, - type ChildPool, effectProfile, hash01, type MilestoneArea, @@ -15,10 +13,11 @@ import { type MomentState, resolveMilestoneArea, type BeMusicGameplayLayout, - type SkinlessGameplayChromeRuntime, + type BeMusicGameplayRuntime, STAGE_HEIGHT as DESIGN_HEIGHT, STAGE_WIDTH as DESIGN_WIDTH, } from '../../skin-sdk/index.ts'; +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 }; @@ -40,7 +39,7 @@ const CLEAR_MS = 1000; */ export function drawLatticeMoments( layer: Container, - runtime: SkinlessGameplayChromeRuntime, + runtime: BeMusicGameplayRuntime, moments: MomentState, playfieldCenterX: number, pool: ChildPool, diff --git a/packages/player-web/src/skins/lattice/playfield.ts b/packages/player-web/src/skins/lattice/playfield.ts index 8b5f1776..d9a1d436 100644 --- a/packages/player-web/src/skins/lattice/playfield.ts +++ b/packages/player-web/src/skins/lattice/playfield.ts @@ -4,17 +4,19 @@ import { LAT_ACCENT, LAT_INK, LAT_RULE, LAT_SIGNAL } from './style.ts'; import { easeOutCubic, type BeMusicBomb, - type BeMusicBombsContext, type BeMusicLaneKind, - type BeMusicLanesContext, - type BeMusicLongNoteContext, - type BeMusicNoteContext, comboTier, effectProfile, hash01, - keyBeamGradient, resolveLaneRuns, } from '../../skin-sdk/index.ts'; +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 = { @@ -35,7 +37,7 @@ 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 }: BeMusicLanesContext): void { +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; @@ -104,12 +106,12 @@ export function renderLatticeLanes({ graphics, lanes, beatPhase, combo, effects } } -export function renderLatticeNote({ graphics, kind, x, w, y }: BeMusicNoteContext): void { +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: an outlined column ruled with fine horizontal hatching, capped by solid heads. */ -export function renderLatticeLongNote({ graphics, kind, x, w, top, bottom }: BeMusicLongNoteContext): void { +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); @@ -133,7 +135,7 @@ export function renderLatticeLongNote({ graphics, kind, x, w, top, bottom }: BeM * 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 }: BeMusicBombsContext): void { +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; diff --git a/packages/player-web/src/skins/lattice/result.ts b/packages/player-web/src/skins/lattice/result.ts index 76d849a5..c42a2dd5 100644 --- a/packages/player-web/src/skins/lattice/result.ts +++ b/packages/player-web/src/skins/lattice/result.ts @@ -14,22 +14,19 @@ import { LAT_TEXT_FONT, } from './style.ts'; import { - addSkinText, easeOutCubic, - type BeMusicResultFrame, - type BeMusicResultSkin, resolveResultLamp, resolveResultTrackRows, rollUpValue, - type SkinTextOptions, stageProgress, } from '../../skin-sdk/index.ts'; +import { addSkinText, type PixiResultFrame, type PixiResultSkin, type SkinTextOptions } from '../pixi-kit/index.ts'; const ROLL_DELAY_MS = 600; const ROLL_MS = 1100; const RANK_DELAY_MS = 1500; -export const latticeResultSkin: BeMusicResultSkin = { render: (frame) => renderLatticeResult(frame) }; +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 }; @@ -44,7 +41,7 @@ function display(size: number, fill: number, weight: SkinTextOptions['weight'] = * 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: BeMusicResultFrame): void { +export function renderLatticeResult(frame: PixiResultFrame): void { const { result, designWidth, designHeight, nowMs, rankLabel, layer } = frame; const elapsed = frame.effects === 'off' ? Number.POSITIVE_INFINITY : frame.elapsedMs; const motion = frame.effects === 'off' ? 0 : frame.effects === 'reduced' ? 0.5 : 1; diff --git a/packages/player-web/src/skins/lattice/select.ts b/packages/player-web/src/skins/lattice/select.ts index d801852d..54c5fa45 100644 --- a/packages/player-web/src/skins/lattice/select.ts +++ b/packages/player-web/src/skins/lattice/select.ts @@ -14,23 +14,25 @@ import { LAT_WHITE, } from './style.ts'; import { - addHitArea, - addSkinText, easeOutCubic, audioDrive, bandLevel, type BeMusicAudioFrame, - type BeMusicSelectFrame, type BeMusicSelectLayout, - type BeMusicSelectRenderer, - type BeMusicSelectSkin, type BrowserBrowseEntry, type BrowserSongEntry, formatPlayVariantLabel, resolveSongRowFacts, - type SkinTextOptions, stageProgress, } from '../../skin-sdk/index.ts'; +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. */ @@ -43,7 +45,8 @@ const INTRO_STAGGER_MS = 40; const OUTRO_COLUMNS = 16; const OUTRO_ROWS = 12; -export const latticeSelectSkin: BeMusicSelectSkin = { +export const latticeSelectSkin: PixiSelectSkin = { + outroMs: OUTRO_MS, layout: LAYOUT, createRenderer: () => new LatticeSelectRenderer(), }; @@ -62,15 +65,14 @@ function display(size: number, fill: number, weight: SkinTextOptions['weight'] = * 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 BeMusicSelectRenderer { +class LatticeSelectRenderer implements PixiSelectRenderer { public readonly backLayer = new Container(); public readonly frontLayer = new Container(); - public readonly outroMs = OUTRO_MS; private readonly field = new Graphics(); private built = false; private designWidth = 640; private designHeight = 480; - private effects: BeMusicSelectFrame['effects'] = 'full'; + private effects: PixiSelectFrame['effects'] = 'full'; private selected: number | undefined; private previousSelected: number | undefined; private cursorChangedAt = Number.NEGATIVE_INFINITY; @@ -92,7 +94,7 @@ class LatticeSelectRenderer implements BeMusicSelectRenderer { this.frontLayer.label = 'lattice-select/front'; } - public render(input: BeMusicSelectFrame): boolean { + public render(input: PixiSelectFrame): boolean { this.effects = input.effects; const frame = input.effects === 'off' @@ -198,7 +200,7 @@ class LatticeSelectRenderer implements BeMusicSelectRenderer { this.levelText = addSkinText(this.frontLayer, '', 226, designHeight - 20, mono(LAT_GRAPHITE)); } - private renderChrome(frame: BeMusicSelectFrame): boolean { + private renderChrome(frame: PixiSelectFrame): boolean { const { layer, designWidth, designHeight, entries, focusedSong: song } = frame; const chrome = new Graphics(); chrome.label = 'lattice-select/chrome'; @@ -319,8 +321,8 @@ class LatticeSelectRenderer implements BeMusicSelectRenderer { anchorX: 0.5, anchorY: 0.5, }); - addHitArea(layer, 14, 296, 182, 40, 'pointer', frame.actions.play); - addHitArea(layer, 206, 296, 98, 40, 'pointer', frame.actions.autoPlay); + 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()); @@ -332,7 +334,7 @@ class LatticeSelectRenderer implements BeMusicSelectRenderer { maxWidth: 230, }); chrome.rect(14, 388, 290, 1).fill(LAT_INK); - addHitArea(layer, 14, 362, 290, 30, 'text', frame.actions.activateSearch); + 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, @@ -370,7 +372,7 @@ class LatticeSelectRenderer implements BeMusicSelectRenderer { return intro < 1 || titleAge < 1200 || sceneAge < 1400; } - private renderList(frame: BeMusicSelectFrame): boolean { + private renderList(frame: PixiSelectFrame): boolean { const { listX, listTop, rowHeight } = LAYOUT; const listWidth = frame.designWidth - listX - 14; const rows = new Graphics(); @@ -407,7 +409,7 @@ class LatticeSelectRenderer implements BeMusicSelectRenderer { } private renderRow( - frame: BeMusicSelectFrame, + frame: PixiSelectFrame, rows: Graphics, entry: BrowserBrowseEntry, entryIndex: number, @@ -552,7 +554,7 @@ class LatticeSelectRenderer implements BeMusicSelectRenderer { } /** Launch outro: ink tiles flip over the page in a diagonal wave and the chosen title sets itself in white. */ - private renderOutro(frame: BeMusicSelectFrame): void { + 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(); diff --git a/packages/player-web/src/skins/phantom/chrome.ts b/packages/player-web/src/skins/phantom/chrome.ts index 2938ed1d..84b86391 100644 --- a/packages/player-web/src/skins/phantom/chrome.ts +++ b/packages/player-web/src/skins/phantom/chrome.ts @@ -24,23 +24,18 @@ import { import { drawPhantomMoments } from './moments.ts'; import { addRansomText, type GlyphFactory } from './tear.ts'; import { - addHudNumber as addNumber, - addHudText as addText, audioDrive, type AudioDrive, bandLevel, type BeMusicRect, - type ChildPool, effectProfile, flashingGreatColor, - type HudTextOptions as TextOptions, impulse, isFlashingGreat, judgeDisplayWord, loadingDots, resolveGameplayLayout, - type SkinlessGameplayChromeRuntime, - type BeMusicChromeContext, + type BeMusicGameplayRuntime, type BeMusicGameplayLayout, type BeMusicLayoutLane, wideStage, @@ -49,6 +44,13 @@ import { STAGE_SIDE_COLUMN, STAGE_WIDTH as DESIGN_WIDTH, } from '../../skin-sdk/index.ts'; +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. */ @@ -71,7 +73,7 @@ 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 { /** @@ -102,7 +104,7 @@ export function renderPhantomChrome({ overlayLayerPool, runtime, layout, -}: BeMusicChromeContext): void { +}: PixiChromeContext): void { renderChromeFrame(layer, runtime, layout, { overlayLayer, layerPool, overlayLayerPool }); } diff --git a/packages/player-web/src/skins/phantom/index.ts b/packages/player-web/src/skins/phantom/index.ts index aa1da9df..4f7e6597 100644 --- a/packages/player-web/src/skins/phantom/index.ts +++ b/packages/player-web/src/skins/phantom/index.ts @@ -8,13 +8,14 @@ import { } from './playfield.ts'; import { phantomResultSkin } from './result.ts'; import { phantomSelectSkin } from './select.ts'; -import { type BeMusicSkin, wideStage, BE_MUSIC_SKIN_API_VERSION, defineBeMusicSkin } from '../../skin-sdk/index.ts'; +import { BE_MUSIC_SKIN_API_VERSION, wideStage, type BeMusicSkin } from '../../skin-sdk/index.ts'; +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 = defineBeMusicSkin({ +export const phantomSkin: BeMusicSkin = definePixiSkin({ apiVersion: BE_MUSIC_SKIN_API_VERSION, id: 'phantom', label: 'Phantom', diff --git a/packages/player-web/src/skins/phantom/moments.ts b/packages/player-web/src/skins/phantom/moments.ts index 365297ad..65063381 100644 --- a/packages/player-web/src/skins/phantom/moments.ts +++ b/packages/player-web/src/skins/phantom/moments.ts @@ -13,21 +13,20 @@ import { starburstPoints, } from './style.ts'; import { - addHudText, easeOutBack, easeOutCubic, - type ChildPool, effectProfile, hash01, momentProgress, trackMoments, resolveMilestoneArea, type BeMusicGameplayLayout, - type SkinlessGameplayChromeRuntime, + type BeMusicGameplayRuntime, STAGE_HEIGHT as DESIGN_HEIGHT, STAGE_WIDTH as DESIGN_WIDTH, stageProgress, } from '../../skin-sdk/index.ts'; +import { addHudText, type ChildPool } from '../pixi-kit/index.ts'; const MILESTONE_MS = 1100; const CLEAR_MS = 1000; @@ -49,7 +48,7 @@ const SKEW = -0.18; export function drawPhantomMoments( chromeLayer: Container, layer: Container, - runtime: SkinlessGameplayChromeRuntime, + runtime: BeMusicGameplayRuntime, pool: ChildPool | undefined, layout: BeMusicGameplayLayout, ): boolean { diff --git a/packages/player-web/src/skins/phantom/playfield.ts b/packages/player-web/src/skins/phantom/playfield.ts index faaa0cce..86f8824a 100644 --- a/packages/player-web/src/skins/phantom/playfield.ts +++ b/packages/player-web/src/skins/phantom/playfield.ts @@ -1,13 +1,12 @@ import { Color, FillGradient, type Graphics } from 'pixi.js'; +import { type BeMusicLaneKind, resolveLaneRuns } from '../../skin-sdk/index.ts'; import { - type BeMusicBombsContext, - type BeMusicLaneKind, - type BeMusicLanesContext, - type BeMusicLongNoteContext, - type BeMusicNoteContext, + type PixiBombsContext, + type PixiLanesContext, + type PixiLongNoteContext, + type PixiNoteContext, keyBeamGradient, - resolveLaneRuns, -} from '../../skin-sdk/index.ts'; +} from '../pixi-kit/index.ts'; /** Colours for one lane class: note highlight / body / shade, and the key cap at rest / pressed. */ interface LaneTone { @@ -35,7 +34,7 @@ const BEAM_COLORS: Record = { white: 0xe8ecff, black: 0 /** 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 }: BeMusicLanesContext): void { +export function renderPhantomLanes({ graphics, lanes, beatPhase }: PixiLanesContext): void { const beatDecay = 1 - beatPhase; let gridTop = Number.POSITIVE_INFINITY; let gridBottom = 0; @@ -88,11 +87,11 @@ export function renderPhantomLanes({ graphics, lanes, beatPhase }: BeMusicLanesC } } -export function renderPhantomNote({ graphics, kind, x, w, y }: BeMusicNoteContext): void { +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; const bodyW = Math.max(4, w); @@ -107,7 +106,7 @@ export function renderPhantomLongNote({ graphics, kind, x, w, top, bottom }: BeM } /** 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/skins/phantom/result.ts b/packages/player-web/src/skins/phantom/result.ts index 9fba423e..cd5419fc 100644 --- a/packages/player-web/src/skins/phantom/result.ts +++ b/packages/player-web/src/skins/phantom/result.ts @@ -22,19 +22,16 @@ import { } from './style.ts'; import { addRansomText, type GlyphFactory } from './tear.ts'; import { - alignCapCenter, easeOutBack, easeOutCubic, - type BeMusicResultFrame, - type BeMusicResultSkin, DEFAULT_TEXT_FONT, hash01, resolveResultLamp, resolveResultTrackRows, - resolveSkinTextStyle, rollUpValue, stageProgress, } from '../../skin-sdk/index.ts'; +import { alignCapCenter, type PixiResultFrame, type PixiResultSkin, resolveSkinTextStyle } from '../pixi-kit/index.ts'; /** Default-family result entrance timeline (ms from scene start): counters roll up, then the rank badge lands. */ const RESULT_ROLL_DELAY_MS = 760; @@ -66,13 +63,13 @@ const METRIC_ROWS = [RESULT_TOP, RESULT_TOP + 56, RESULT_TOP + 112] as const; const TRACK_X = 324 + 292 + 8 + 16; const TRACK_SLANT = 8; -export const phantomResultSkin: BeMusicResultSkin = { render: (frame) => renderPhantomResult(frame) }; +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 { +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; diff --git a/packages/player-web/src/skins/phantom/select.ts b/packages/player-web/src/skins/phantom/select.ts index c4426344..ce03ce34 100644 --- a/packages/player-web/src/skins/phantom/select.ts +++ b/packages/player-web/src/skins/phantom/select.ts @@ -19,25 +19,27 @@ import { } from './style.ts'; import { addRansomText, type GlyphFactory } from './tear.ts'; import { - addHitArea, - addSkinText, easeOutBack, easeOutCubic, audioDrive, bandLevel, type BeMusicAudioFrame, - type BeMusicSelectFrame, type BeMusicSelectLayout, - type BeMusicSelectRenderer, - type BeMusicSelectSkin, type BrowserBrowseEntry, type BrowserSongEntry, formatPlayVariantLabel, hash01, resolveSongRowFacts, - type SkinTextOptions, stageProgress, } from '../../skin-sdk/index.ts'; +import { + addHitArea, + addSkinText, + type PixiSelectFrame, + type PixiSelectRenderer, + type PixiSelectSkin, + type SkinTextOptions, +} from '../pixi-kit/index.ts'; const LAYOUT: BeMusicSelectLayout = { listX: 320, listTop: 60, listBottomInset: 26, rowHeight: 28 }; const SLIDE_MS = 240; @@ -63,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(), }; @@ -73,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(); @@ -98,10 +101,9 @@ class PhantomSelectRenderer implements BeMusicSelectRenderer { this.frontLayer.addChild(this.kicker, this.glint, this.pointer); } - public readonly outroMs = OUTRO_MS; - private effects: BeMusicSelectFrame['effects'] = 'full'; + private effects: PixiSelectFrame['effects'] = 'full'; - public render(input: BeMusicSelectFrame): boolean { + public render(input: PixiSelectFrame): boolean { this.effects = input.effects; // With effects off every entrance / focus transition renders settled. const frame = @@ -133,7 +135,7 @@ class PhantomSelectRenderer implements BeMusicSelectRenderer { * 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: BeMusicSelectFrame): void { + 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(); @@ -371,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'; @@ -527,9 +529,9 @@ class PhantomSelectRenderer implements BeMusicSelectRenderer { fill: frame.searchQuery ? PHANTOM_WHITE : PHANTOM_ASH, maxWidth: 210, }); - addHitArea(layer, 12, 368, 292, 28, 'text', frame.actions.activateSearch); - addHitArea(layer, 24, 298, 176, 36, 'pointer', frame.actions.play); - addHitArea(layer, 206, 302, 86, 30, 'pointer', frame.actions.autoPlay); + 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); @@ -546,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, 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..ec4757e5 --- /dev/null +++ b/packages/player-web/src/skins/pixi-kit/define-pixi-skin.ts @@ -0,0 +1,263 @@ +import { Container, Graphics, WebGLRenderer } from 'pixi.js'; +import { + defineBeMusicSkin, + type BeMusicGameplayFrame, + type BeMusicResultFrame, + type BeMusicSelectFrame, + type BeMusicSkin, + type BeMusicSurface, +} from '../../skin-sdk/index.ts'; +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 drawing buffer is preserved so the player can upload it whenever it renders. + contextAttributes: { + alpha: true, + premultipliedAlpha: true, + antialias: 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, + 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(); + 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(); + private readonly notes = new Container(); + 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 notePool = new ChildPool(this.notes); + 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, + }); + + this.notePool.begin(); + try { + for (const note of frame.longNotes) { + skin.renderLongNote({ graphics: this.notePool.acquireGraphics(), ...note, nowMs }); + } + for (const note of frame.notes) { + skin.renderNote({ graphics: this.notePool.acquireGraphics(), ...note, nowMs }); + } + } finally { + this.notePool.end(); + } + + 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.notePool, 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(); + 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; + for (const child of this.layer.removeChildren()) child.destroy({ children: true, context: true }); + // 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.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 layer = new Container(); + private readonly disposer = new LaggedDisposer(); + + constructor(definition: PixiSkinDefinition) { + this.definition = definition; + } + + build(frame: BeMusicResultFrame): Container { + this.disposer.cycle(this.layer); + this.definition.result.render({ ...frame, layer: this.layer }); + return this.layer; + } + + destroy(): void { + this.disposer.flush(); + this.layer.destroy({ children: true }); + } +} diff --git a/packages/player-web/src/skin-sdk/hud-text.ts b/packages/player-web/src/skins/pixi-kit/hud-text.ts similarity index 95% rename from packages/player-web/src/skin-sdk/hud-text.ts rename to packages/player-web/src/skins/pixi-kit/hud-text.ts index 9b6b7261..394772f1 100644 --- a/packages/player-web/src/skin-sdk/hud-text.ts +++ b/packages/player-web/src/skins/pixi-kit/hud-text.ts @@ -1,8 +1,7 @@ import { CanvasTextMetrics, type Container, Text, TextStyle } from 'pixi.js'; -import type { ChildPool } from '../scene/pixi-utils.ts'; -import { getDesignTextResolution } from '../scene/core/viewport.ts'; -import { DEFAULT_TEXT_FONT } from './fonts.ts'; -import { layoutTabularRun } from './tabular.ts'; +import { DEFAULT_TEXT_FONT, layoutTabularRun } from '../../skin-sdk/index.ts'; +import type { ChildPool } from './pools.ts'; +import { textResolution } from './resolution.ts'; import { alignCapCenter } from './text-metrics.ts'; /** @@ -41,7 +40,7 @@ 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; } 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/skin-sdk/key-beam.test.ts b/packages/player-web/src/skins/pixi-kit/key-beam.test.ts similarity index 100% rename from packages/player-web/src/skin-sdk/key-beam.test.ts rename to packages/player-web/src/skins/pixi-kit/key-beam.test.ts diff --git a/packages/player-web/src/skin-sdk/key-beam.ts b/packages/player-web/src/skins/pixi-kit/key-beam.ts similarity index 100% rename from packages/player-web/src/skin-sdk/key-beam.ts rename to packages/player-web/src/skins/pixi-kit/key-beam.ts diff --git a/packages/player-web/src/skin-sdk/particle-layer.ts b/packages/player-web/src/skins/pixi-kit/particle-layer.ts similarity index 100% rename from packages/player-web/src/skin-sdk/particle-layer.ts rename to packages/player-web/src/skins/pixi-kit/particle-layer.ts 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/skin-sdk/skin-text.ts b/packages/player-web/src/skins/pixi-kit/skin-text.ts similarity index 73% rename from packages/player-web/src/skin-sdk/skin-text.ts rename to packages/player-web/src/skins/pixi-kit/skin-text.ts index 5f611999..5201320b 100644 --- a/packages/player-web/src/skin-sdk/skin-text.ts +++ b/packages/player-web/src/skins/pixi-kit/skin-text.ts @@ -1,8 +1,6 @@ -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 '../scene/core/viewport.ts'; -import { DEFAULT_TEXT_FONT } from './fonts.ts'; +import { type Color, type Container, Text, TextStyle } from 'pixi.js'; +import { DEFAULT_TEXT_FONT } from '../../skin-sdk/index.ts'; +import { textResolution } from './resolution.ts'; import { alignCapCenter } from './text-metrics.ts'; export interface SkinTextOptions { @@ -29,7 +27,7 @@ export function addSkinText(layer: Container, text: string, x: number, y: number const node = new Text({ text, // Rasterize at the final device density so text stays crisp after the viewport magnifies the design canvas. - resolution: getDesignTextResolution(), + resolution: textResolution(), style: resolveSkinTextStyle(options), }); node.anchor.set(options.anchorX ?? 0, options.anchorY ?? 0); @@ -91,25 +89,35 @@ export function resolveSkinTextStyle(options: SkinTextOptions): TextStyle { return style; } -/** `'7K KEYS'`-style mode label for a song's play variant. */ -export function formatPlayVariantLabel(song: BrowserSongEntry): string { - return `${resolveChartPlayVariant(song)} KEYS`; +/** 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; } -/** 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); +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/skin-sdk/text-metrics.test.ts b/packages/player-web/src/skins/pixi-kit/text-metrics.test.ts similarity index 100% rename from packages/player-web/src/skin-sdk/text-metrics.test.ts rename to packages/player-web/src/skins/pixi-kit/text-metrics.test.ts diff --git a/packages/player-web/src/skin-sdk/text-metrics.ts b/packages/player-web/src/skins/pixi-kit/text-metrics.ts similarity index 100% rename from packages/player-web/src/skin-sdk/text-metrics.ts rename to packages/player-web/src/skins/pixi-kit/text-metrics.ts 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..8d5bd338 --- /dev/null +++ b/packages/player-web/src/skins/pixi-kit/types.ts @@ -0,0 +1,141 @@ +import type { Container, Graphics } from 'pixi.js'; +import type { + BeMusicAudioFrame, + BeMusicBomb, + BeMusicEffectLevel, + BeMusicGameplayLayout, + BeMusicGameplayRuntime, + BeMusicLaneFrame, + BeMusicLaneKind, + BeMusicResultFrame, + BeMusicSelectFrame, + BeMusicSelectLayout, + BeMusicSkin, + BrowserSongEntry, +} from '../../skin-sdk/index.ts'; +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; +} + +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 index dc4fb51b..b1615183 100644 --- a/packages/player-web/src/skins/plain/gameplay.ts +++ b/packages/player-web/src/skins/plain/gameplay.ts @@ -4,8 +4,8 @@ import { judgeDisplayWord, loadingDots, resolveLaneRuns, - type CanvasGameplayFrame, - type CanvasSurface, + type BeMusicGameplayFrame, + type BeMusicSurface, } from '../../skin-sdk/index.ts'; import { ACCENT, @@ -29,7 +29,7 @@ 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 }: CanvasSurface<'2d'>, frame: CanvasGameplayFrame): void { +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. @@ -56,7 +56,7 @@ export function drawGameplay({ context: ctx, width, height }: CanvasSurface<'2d' } /** Lane backgrounds, a light column for each held key, and the judgement line. */ -function drawLanes(ctx: CanvasRenderingContext2D, frame: CanvasGameplayFrame): void { +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); @@ -76,7 +76,7 @@ function drawLanes(ctx: CanvasRenderingContext2D, frame: CanvasGameplayFrame): v } /** Tap notes as flat bars; long notes as a dimmer body between their head and tail. */ -function drawNotes(ctx: CanvasRenderingContext2D, frame: CanvasGameplayFrame): void { +function drawNotes(ctx: CanvasRenderingContext2D, frame: BeMusicGameplayFrame): void { for (const note of frame.longNotes) { ctx.globalAlpha = 0.45; ctx.fillStyle = NOTE_FILL[note.kind]; @@ -92,7 +92,7 @@ function drawNotes(ctx: CanvasRenderingContext2D, frame: CanvasGameplayFrame): v } /** Each hit draws a ring that widens and fades over the effect's lifetime. */ -function drawBombs(ctx: CanvasRenderingContext2D, frame: CanvasGameplayFrame): void { +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})`; @@ -104,7 +104,7 @@ function drawBombs(ctx: CanvasRenderingContext2D, frame: CanvasGameplayFrame): v } /** The latest judgement and combo over each play side — or NOW LOADING while the chart loads. */ -function drawJudgement(ctx: CanvasRenderingContext2D, frame: CanvasGameplayFrame): void { +function drawJudgement(ctx: CanvasRenderingContext2D, frame: BeMusicGameplayFrame): void { const { runtime, layout, nowMs } = frame; const y = 220; if (runtime.loading) { @@ -130,7 +130,7 @@ function judgeColor(judge: string): string { } /** Header (mode, tempo, speed), the gauge under the lanes, the track and the score figures along the bottom. */ -function drawHud(ctx: CanvasRenderingContext2D, frame: CanvasGameplayFrame, width: number, height: number): void { +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 }); diff --git a/packages/player-web/src/skins/plain/index.ts b/packages/player-web/src/skins/plain/index.ts index b417ac1d..2194ca04 100644 --- a/packages/player-web/src/skins/plain/index.ts +++ b/packages/player-web/src/skins/plain/index.ts @@ -1,4 +1,4 @@ -import { BE_MUSIC_SKIN_API_VERSION, defineCanvasSkin, type BeMusicSkin } from '../../skin-sdk/index.ts'; +import { BE_MUSIC_SKIN_API_VERSION, defineBeMusicSkin, type BeMusicSkin } from '../../skin-sdk/index.ts'; import { drawGameplay } from './gameplay.ts'; import { drawResult } from './result.ts'; import { SELECT_LAYOUT, SELECT_OUTRO_MS, drawSelect } from './select.ts'; @@ -7,7 +7,7 @@ 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 = defineCanvasSkin({ +export const plainSkin: BeMusicSkin = defineBeMusicSkin({ apiVersion: BE_MUSIC_SKIN_API_VERSION, id: 'plain', label: 'Plain', diff --git a/packages/player-web/src/skins/plain/result.ts b/packages/player-web/src/skins/plain/result.ts index ff18b1bd..3ca11eb1 100644 --- a/packages/player-web/src/skins/plain/result.ts +++ b/packages/player-web/src/skins/plain/result.ts @@ -2,14 +2,14 @@ import { resolveResultLamp, resolveResultTrackRows, type BeMusicResultFrame, - type CanvasSurface, + type BeMusicSurface, } from '../../skin-sdk/index.ts'; 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 }: CanvasSurface<'2d'>, frame: BeMusicResultFrame): void { +export function drawResult({ context: ctx, width, height }: BeMusicSurface<'2d'>, frame: BeMusicResultFrame): void { const { result } = frame; ctx.fillStyle = INK; ctx.fillRect(0, 0, width, height); diff --git a/packages/player-web/src/skins/plain/select.ts b/packages/player-web/src/skins/plain/select.ts index 444376fe..245bf84d 100644 --- a/packages/player-web/src/skins/plain/select.ts +++ b/packages/player-web/src/skins/plain/select.ts @@ -3,8 +3,8 @@ import { resolveSongRowFacts, type BeMusicSelectLayout, type BrowserBrowseEntry, - type CanvasSelectFrame, - type CanvasSurface, + type BeMusicSelectFrame, + type BeMusicSurface, } from '../../skin-sdk/index.ts'; import { ACCENT, INK, LINE, MUTED, PANEL, TEXT, stat, text } from './theme.ts'; @@ -18,7 +18,7 @@ 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 }: CanvasSurface<'2d'>, frame: CanvasSelectFrame): boolean { +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 }); @@ -35,18 +35,16 @@ export function drawSelect({ context: ctx, width, height }: CanvasSurface<'2d'>, text(ctx, `SEARCH: ${frame.searchQuery}`, MARGIN, height - 20, { size: 11, color: MUTED }); } - // After a pick, darken the screen over the outro; keep asking for frames until it is black. + // 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); - return t < 1; } - return false; } /** The left card: title, artist, the chart's key facts, and the PLAY / AUTO buttons. */ -function drawFocusedSong(ctx: CanvasRenderingContext2D, frame: CanvasSelectFrame): void { +function drawFocusedSong(ctx: CanvasRenderingContext2D, frame: BeMusicSelectFrame): void { const song = frame.focusedSong; const x = MARGIN; const y = 56; @@ -73,7 +71,7 @@ function drawFocusedSong(ctx: CanvasRenderingContext2D, frame: CanvasSelectFrame function button( ctx: CanvasRenderingContext2D, - frame: CanvasSelectFrame, + frame: BeMusicSelectFrame, label: string, x: number, y: number, @@ -91,7 +89,7 @@ function button( } /** The visible window of the list: one row per entry, the selected one highlighted. */ -function drawList(ctx: CanvasRenderingContext2D, frame: CanvasSelectFrame, width: number): void { +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) { diff --git a/packages/player-web/src/skins/synesthesia/draw.ts b/packages/player-web/src/skins/synesthesia/draw.ts index 79772f7e..7c5be881 100644 --- a/packages/player-web/src/skins/synesthesia/draw.ts +++ b/packages/player-web/src/skins/synesthesia/draw.ts @@ -16,7 +16,8 @@ import { type Vec3, } from './space.ts'; import { SYN_CYAN, SYN_GLASS, SYN_MAGENTA, SYN_WHITE, synGlowTexture } from './style.ts'; -import { type AudioDrive, bandLevel, hash01, pointLayerFor } from '../../skin-sdk/index.ts'; +import { type AudioDrive, bandLevel, hash01 } from '../../skin-sdk/index.ts'; +import { pointLayerFor } from '../pixi-kit/index.ts'; /** * Shared Synesthesia drawing: hairline frames with lock-on corners (a rhythm-shooter targeting reticle) and projected point diff --git a/packages/player-web/src/skins/synesthesia/gameplay-chrome.ts b/packages/player-web/src/skins/synesthesia/gameplay-chrome.ts index a15569e9..89f4ccfe 100644 --- a/packages/player-web/src/skins/synesthesia/gameplay-chrome.ts +++ b/packages/player-web/src/skins/synesthesia/gameplay-chrome.ts @@ -38,32 +38,34 @@ import { sceneHue, } from './style.ts'; import { - addHudNumber, - addHudText, audioDrive, type AudioDrive, type BeMusicRect, - type ChildPool, comboTier, effectProfile, flashingGreatColor, hash01, - type HudTextOptions, impulse, isFlashingGreat, judgeDisplayWord, LOADING_WORD, loadingDots, - type BeMusicChromeContext, type BeMusicGameplayLayout, - type SkinlessGameplayChromeRuntime, + type BeMusicGameplayRuntime, STAGE_HEIGHT as DESIGN_HEIGHT, STAGE_SIDE_COLUMN, STAGE_WIDTH as DESIGN_WIDTH, punchScale, } from '../../skin-sdk/index.ts'; +import { + addHudNumber, + addHudText, + type ChildPool, + type HudTextOptions, + type PixiChromeContext, +} from '../pixi-kit/index.ts'; -type Runtime = SkinlessGameplayChromeRuntime; +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 @@ -132,7 +134,7 @@ export function renderSynesthesiaChrome({ overlayLayerPool, runtime, layout, -}: BeMusicChromeContext): void { +}: PixiChromeContext): void { const seconds = (runtime.nowMs ?? 0) / 1000; const beatPhase = runtime.beatPhase ?? 0; const pulse = (1 - beatPhase) ** 2; diff --git a/packages/player-web/src/skins/synesthesia/index.ts b/packages/player-web/src/skins/synesthesia/index.ts index 600cc0d7..bcfe3ffd 100644 --- a/packages/player-web/src/skins/synesthesia/index.ts +++ b/packages/player-web/src/skins/synesthesia/index.ts @@ -8,14 +8,15 @@ import { } from './playfield.ts'; import { synesthesiaResultSkin } from './result.ts'; import { synesthesiaSelectSkin } from './select.ts'; -import { type BeMusicSkin, wideStage, BE_MUSIC_SKIN_API_VERSION, defineBeMusicSkin } from '../../skin-sdk/index.ts'; +import { BE_MUSIC_SKIN_API_VERSION, wideStage, type BeMusicSkin } from '../../skin-sdk/index.ts'; +import { definePixiSkin } from '../pixi-kit/index.ts'; /** * 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 = defineBeMusicSkin({ +export const synesthesiaSkin: BeMusicSkin = definePixiSkin({ apiVersion: BE_MUSIC_SKIN_API_VERSION, id: 'synesthesia', label: 'Synesthesia', diff --git a/packages/player-web/src/skins/synesthesia/moments.ts b/packages/player-web/src/skins/synesthesia/moments.ts index 5cd3659d..fa2b96de 100644 --- a/packages/player-web/src/skins/synesthesia/moments.ts +++ b/packages/player-web/src/skins/synesthesia/moments.ts @@ -12,21 +12,20 @@ import { } from './space.ts'; import { SYN_AMBER, SYN_CYAN, SYN_DISPLAY_FONT, SYN_EMBER, SYN_FLARE, SYN_MAGENTA, SYN_WHITE } from './style.ts'; import { - addHudText, easeOutCubic, type BeMusicRect, - type ChildPool, effectProfile, type MilestoneArea, momentProgress, trackMoments, resolveMilestoneArea, type BeMusicGameplayLayout, - type SkinlessGameplayChromeRuntime, + type BeMusicGameplayRuntime, STAGE_HEIGHT as DESIGN_HEIGHT, STAGE_WIDTH as DESIGN_WIDTH, stageProgress, } from '../../skin-sdk/index.ts'; +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 }; @@ -52,7 +51,7 @@ const RING_COLORS = [SYN_AMBER, SYN_EMBER, SYN_CYAN, SYN_FLARE, SYN_MAGENTA] as export function drawSynesthesiaMoments( chromeLayer: Container, layer: Container, - runtime: SkinlessGameplayChromeRuntime, + runtime: BeMusicGameplayRuntime, playfieldCenterX: number, hue: number, pool: ChildPool, diff --git a/packages/player-web/src/skins/synesthesia/playfield.ts b/packages/player-web/src/skins/synesthesia/playfield.ts index 29414006..dbb59032 100644 --- a/packages/player-web/src/skins/synesthesia/playfield.ts +++ b/packages/player-web/src/skins/synesthesia/playfield.ts @@ -15,17 +15,19 @@ import { SYN_GLOW_SIZE, synGlowTexture } from './style.ts'; import { audioDrive, type BeMusicBomb, - type BeMusicBombsContext, type BeMusicLaneKind, - type BeMusicLanesContext, - type BeMusicLongNoteContext, - type BeMusicNoteContext, - type ChildPool, comboTier, effectProfile, - keyBeamGradient, resolveLaneRuns, } from '../../skin-sdk/index.ts'; +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 { @@ -57,7 +59,7 @@ export function renderSynesthesiaLanes({ combo, effects, audio, -}: BeMusicLanesContext): void { +}: 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. @@ -123,11 +125,11 @@ export function renderSynesthesiaLanes({ } } -export function renderSynesthesiaNote({ graphics, kind, x, w, y }: BeMusicNoteContext): void { +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 }: BeMusicLongNoteContext): void { +export function renderSynesthesiaLongNote({ graphics, kind, x, w, top, bottom }: PixiLongNoteContext): void { const light = LIGHTS[kind]; const bodyX = x; const bodyW = Math.max(4, w); @@ -185,7 +187,7 @@ function cachedBurst(seed: number): BurstParticle[] { * 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 }: BeMusicBombsContext): void { +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. diff --git a/packages/player-web/src/skins/synesthesia/result.ts b/packages/player-web/src/skins/synesthesia/result.ts index 4f3e1ec7..a24a10fd 100644 --- a/packages/player-web/src/skins/synesthesia/result.ts +++ b/packages/player-web/src/skins/synesthesia/result.ts @@ -27,17 +27,14 @@ import { sceneHue, } from './style.ts'; import { - addSkinText, easeOutBack, easeOutCubic, - type BeMusicResultFrame, - type BeMusicResultSkin, resolveResultLamp, resolveResultTrackRows, rollUpValue, - type SkinTextOptions, stageProgress, } from '../../skin-sdk/index.ts'; +import { addSkinText, type PixiResultFrame, type PixiResultSkin, type SkinTextOptions } from '../pixi-kit/index.ts'; const ROLL_DELAY_MS = 700; const ROLL_MS = 1100; @@ -65,7 +62,7 @@ const ROWS_TOP = BOTTOM_TOP + 34; const ROWS_BOTTOM = ROWS_TOP + 4 * 32 + 12; const RANK_BURST = burstParticles(29, 260); -export const synesthesiaResultSkin: BeMusicResultSkin = { render: (frame) => renderSynesthesiaResult(frame) }; +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 @@ -73,7 +70,7 @@ export const synesthesiaResultSkin: BeMusicResultSkin = { render: (frame) => ren * graphs draw as glowing filaments, and the rank letter ignites inside a spinning 3D ring of particles that bursts * outward the moment it lands — a lock-on reticle snapping shut on it. */ -export function renderSynesthesiaResult(frame: BeMusicResultFrame): void { +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; diff --git a/packages/player-web/src/skins/synesthesia/select.ts b/packages/player-web/src/skins/synesthesia/select.ts index 5b01fa85..4b7d6826 100644 --- a/packages/player-web/src/skins/synesthesia/select.ts +++ b/packages/player-web/src/skins/synesthesia/select.ts @@ -35,24 +35,26 @@ import { synGlowTexture, } from './style.ts'; import { - addHitArea, - addSkinText, easeOutCubic, audioDrive, type BeMusicAudioFrame, - type BeMusicSelectFrame, type BeMusicSelectLayout, - type BeMusicSelectRenderer, - type BeMusicSelectSkin, type BrowserBrowseEntry, type BrowserSongEntry, - ChildPool, formatPlayVariantLabel, hash01, resolveSongRowFacts, - type SkinTextOptions, stageProgress, } from '../../skin-sdk/index.ts'; +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. */ @@ -82,7 +84,8 @@ const SCHOOL_SPECS = [ const WORLD_ORBIT = 900; const PYRAMIDS = [pointCloudPyramid(12, 900), pointCloudPyramid(4, 560), pointCloudPyramid(9, 520)]; -export const synesthesiaSelectSkin: BeMusicSelectSkin = { +export const synesthesiaSelectSkin: PixiSelectSkin = { + outroMs: OUTRO_MS, layout: LAYOUT, createRenderer: () => new SynesthesiaSelectRenderer(), }; @@ -103,7 +106,7 @@ function framePanel(graphics: Graphics, x: number, y: number, w: number, h: numb * horizon, 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 BeMusicSelectRenderer { +class SynesthesiaSelectRenderer implements PixiSelectRenderer { public readonly backLayer = new Container(); public readonly frontLayer = new Container(); private readonly ground = new Graphics(); @@ -126,8 +129,7 @@ class SynesthesiaSelectRenderer implements BeMusicSelectRenderer { /** 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: BeMusicSelectFrame['effects'] = 'full'; - public readonly outroMs = OUTRO_MS; + private effects: PixiSelectFrame['effects'] = 'full'; private activeCard: { x: number; y: number; w: number; h: number } | undefined; public constructor() { @@ -135,7 +137,7 @@ class SynesthesiaSelectRenderer implements BeMusicSelectRenderer { this.frontLayer.label = 'synesthesia-select/front'; } - public render(input: BeMusicSelectFrame): boolean { + public render(input: PixiSelectFrame): boolean { this.effects = input.effects; const frame = input.effects === 'off' @@ -166,7 +168,7 @@ class SynesthesiaSelectRenderer implements BeMusicSelectRenderer { * Launch outro — the warp: the star field rushes forward (in `tick`), a white bloom opens from the * vanishing point with the chosen title in it, and the screen falls to black for the gameplay count-in. */ - private renderOutro(frame: BeMusicSelectFrame): void { + 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(); @@ -441,7 +443,7 @@ class SynesthesiaSelectRenderer implements BeMusicSelectRenderer { this.frontLayer.addChild(this.cursorGlow, this.lock); } - private renderChrome(frame: BeMusicSelectFrame): boolean { + private renderChrome(frame: PixiSelectFrame): boolean { const { layer, designWidth, designHeight, entries, focusedSong: song } = frame; const chrome = new Graphics(); chrome.label = 'synesthesia-select/chrome'; @@ -574,8 +576,8 @@ class SynesthesiaSelectRenderer implements BeMusicSelectRenderer { anchorX: 0.5, anchorY: 0.5, }); - addHitArea(layer, 26, 296, 172, 40, 'pointer', frame.actions.play); - addHitArea(layer, 200, 300, 90, 32, 'pointer', frame.actions.autoPlay); + 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); @@ -588,7 +590,7 @@ class SynesthesiaSelectRenderer implements BeMusicSelectRenderer { fontFamily: SYN_TEXT_FONT, maxWidth: 180, }); - addHitArea(layer, 14, 372, 290, 30, 'text', frame.actions.activateSearch); + 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, @@ -608,7 +610,7 @@ class SynesthesiaSelectRenderer implements BeMusicSelectRenderer { } private renderRow( - frame: BeMusicSelectFrame, + frame: PixiSelectFrame, entry: BrowserBrowseEntry, entryIndex: number, visibleIndex: number, From ae37305ce9baca11a5748aedb7531dca38e3ce2c Mon Sep 17 00:00:00 2001 From: Seiya KONNO Date: Fri, 9 Oct 2026 16:35:23 +0900 Subject: [PATCH 48/65] feat(player-web): give every built-in skin Synesthesia's long-note shape Long notes now share one shape across the built-in skins: a translucent beam in the lane colour with a centre filament between the head and tail bars. Phantom draws it in poster tones with a paper-white filament instead of side rails, Lattice prints a faint ink wash with a solid ink filament instead of hatching, and Plain gains the white centre line. --- .../player-web/src/skins/lattice/playfield.ts | 14 ++++++-------- .../player-web/src/skins/phantom/playfield.ts | 8 ++++---- packages/player-web/src/skins/plain/gameplay.ts | 15 ++++++++++++--- 3 files changed, 22 insertions(+), 15 deletions(-) diff --git a/packages/player-web/src/skins/lattice/playfield.ts b/packages/player-web/src/skins/lattice/playfield.ts index d9a1d436..6984de77 100644 --- a/packages/player-web/src/skins/lattice/playfield.ts +++ b/packages/player-web/src/skins/lattice/playfield.ts @@ -110,7 +110,10 @@ export function renderLatticeNote({ graphics, kind, x, w, y }: PixiNoteContext): graphics.rect(x, y - NOTE_HEIGHT, Math.max(3, w), NOTE_HEIGHT).fill(LANE_COLORS[kind]); } -/** Long note: an outlined column ruled with fine horizontal hatching, capped by solid heads. */ +/** + * 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; @@ -118,13 +121,8 @@ export function renderLatticeLongNote({ graphics, kind, x, w, top, bottom }: Pix const bodyTop = top - NOTE_HEIGHT; const bodyBottom = bottom - NOTE_HEIGHT; if (bodyBottom > bodyTop) { - graphics.rect(bodyX, bodyTop, bodyW, bodyBottom - bodyTop).fill({ color, alpha: 0.08 }); - // Hatching on an absolute 3 px pitch, so it reads as a printed pattern rather than scrolling stripes. - for (let y = Math.ceil(bodyTop / 3) * 3; y < bodyBottom; y += 3) { - graphics.moveTo(bodyX, y + 0.5).lineTo(bodyX + bodyW, y + 0.5); - } - graphics.stroke({ color, width: 1, alpha: 0.35 }); - graphics.rect(bodyX + 0.5, bodyTop, bodyW - 1, bodyBottom - bodyTop).stroke({ color, width: 1, alpha: 0.9 }); + 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 }); diff --git a/packages/player-web/src/skins/phantom/playfield.ts b/packages/player-web/src/skins/phantom/playfield.ts index 86f8824a..9af78229 100644 --- a/packages/player-web/src/skins/phantom/playfield.ts +++ b/packages/player-web/src/skins/phantom/playfield.ts @@ -97,10 +97,10 @@ export function renderPhantomLongNote({ graphics, kind, x, w, top, bottom }: Pix 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); } diff --git a/packages/player-web/src/skins/plain/gameplay.ts b/packages/player-web/src/skins/plain/gameplay.ts index b1615183..71e5eea9 100644 --- a/packages/player-web/src/skins/plain/gameplay.ts +++ b/packages/player-web/src/skins/plain/gameplay.ts @@ -75,13 +75,22 @@ function drawLanes(ctx: CanvasRenderingContext2D, frame: BeMusicGameplayFrame): } } -/** Tap notes as flat bars; long notes as a dimmer body between their head and tail. */ +/** + * 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) { - ctx.globalAlpha = 0.45; + const bodyTop = note.top - NOTE_HEIGHT; + const bodyHeight = Math.max(1, note.bottom - note.top); ctx.fillStyle = NOTE_FILL[note.kind]; - ctx.fillRect(note.x + 3, note.top, note.w - 6, note.bottom - note.top); + 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); } From 65e6577892a5dc33540f5e426903fca51fa3170f Mon Sep 17 00:00:00 2001 From: Seiya KONNO Date: Fri, 9 Oct 2026 16:38:07 +0900 Subject: [PATCH 49/65] feat(player-web): show SOUND ONLY on every skin's idle BGA monitor Without a BGA the built-in skins read SOUND ONLY instead of STAND BY, STANDBY, or NO BGA. Phantom cuts the words out of magazines as a ransom note over its turning starburst, re-cut every so often like NOW LOADING. The changeset also records the shared long-note shape. --- .changeset/be-music-skins.md | 4 +++- .../src/skins/lattice/gameplay-chrome.ts | 4 ++-- .../player-web/src/skins/phantom/chrome.ts | 21 ++++++++++--------- .../player-web/src/skins/plain/gameplay.ts | 2 +- .../src/skins/synesthesia/gameplay-chrome.ts | 4 ++-- 5 files changed, 19 insertions(+), 16 deletions(-) diff --git a/.changeset/be-music-skins.md b/.changeset/be-music-skins.md index 9304b045..9a71670a 100644 --- a/.changeset/be-music-skins.md +++ b/.changeset/be-music-skins.md @@ -32,10 +32,12 @@ The default (skinless) family now renders through a `BeMusicSkin`: a skin that d - 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, STANDBY gets a slip over the monitor's horizon, 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. +- 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 monitor's horizon, 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). diff --git a/packages/player-web/src/skins/lattice/gameplay-chrome.ts b/packages/player-web/src/skins/lattice/gameplay-chrome.ts index 52608b19..944bf9ff 100644 --- a/packages/player-web/src/skins/lattice/gameplay-chrome.ts +++ b/packages/player-web/src/skins/lattice/gameplay-chrome.ts @@ -540,11 +540,11 @@ function drawBgaFrame( } } graphics.rect(BGA.x, BGA.y + BGA.h - 22, BGA.w, 1).fill({ color: LAT_RULE, alpha: 1 }); - // STANDBY re-decodes every few seconds while the monitor idles. + // SOUND ONLY re-decodes every few seconds while the monitor idles. const idle = amount > 0 ? (seconds % 5) / 0.7 : 1; addHudText( layer, - scrambleText('STANDBY', idle, scrambleTick(seconds * 1000), 60), + scrambleText('SOUND ONLY', idle, scrambleTick(seconds * 1000), 60), BGA.x + 8, BGA.y + BGA.h - 16, monoStyle(LAT_INK), diff --git a/packages/player-web/src/skins/phantom/chrome.ts b/packages/player-web/src/skins/phantom/chrome.ts index 84b86391..2fc0f920 100644 --- a/packages/player-web/src/skins/phantom/chrome.ts +++ b/packages/player-web/src/skins/phantom/chrome.ts @@ -341,7 +341,8 @@ function drawPlayfield( /** * 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, @@ -391,15 +392,15 @@ function drawBgaFrame( .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); - 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, - ); + // 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 }); } /** diff --git a/packages/player-web/src/skins/plain/gameplay.ts b/packages/player-web/src/skins/plain/gameplay.ts index 71e5eea9..efb124f7 100644 --- a/packages/player-web/src/skins/plain/gameplay.ts +++ b/packages/player-web/src/skins/plain/gameplay.ts @@ -42,7 +42,7 @@ export function drawGameplay({ context: ctx, width, height }: BeMusicSurface<'2d } else { ctx.fillStyle = PANEL; ctx.fillRect(bga.x, bga.y, bga.w, bga.h); - text(ctx, 'NO BGA', bga.x + bga.w / 2, bga.y + bga.h / 2, { size: 12, color: MUTED, align: 'center' }); + 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); diff --git a/packages/player-web/src/skins/synesthesia/gameplay-chrome.ts b/packages/player-web/src/skins/synesthesia/gameplay-chrome.ts index 89f4ccfe..29c0958c 100644 --- a/packages/player-web/src/skins/synesthesia/gameplay-chrome.ts +++ b/packages/player-web/src/skins/synesthesia/gameplay-chrome.ts @@ -664,10 +664,10 @@ function drawBgaFrame( const slip = pool.acquireGraphics(); slip.label = 'synesthesia-gameplay/standby-slip'; slip.blendMode = 'normal'; - slip.rect(cx - 56, BGA.y + BGA.h - 26, 112, 17).fill({ color: SYN_VOID, alpha: 0.9 }); + slip.rect(cx - 76, BGA.y + BGA.h - 26, 152, 17).fill({ color: SYN_VOID, alpha: 0.9 }); addHudText( layer, - 'STANDBY', + 'SOUND ONLY', cx, BGA.y + BGA.h - 22, { ...displayStyle(9, SYN_MIST), letterSpacing: 6, anchorX: 0.5 }, From 872c26cfe26c8e8800fa6711f2496f105d430f0f Mon Sep 17 00:00:00 2001 From: Seiya KONNO Date: Fri, 9 Oct 2026 16:47:27 +0900 Subject: [PATCH 50/65] feat(skin-sdk): split the skin SDK into its own package The framework-free skin SDK moves from the player-web subpath to the new @be-music/skin-sdk package, so skins depend on the contract and helpers without pulling in the player or PixiJS. It takes along everything it was reaching into the player for: the skin contract types, the song entry and result data types, the audio frame type and constants, the playfield and lane geometry, and lane-kind resolution. player-web depends on the package and keeps re-exporting those from their old modules; the built-in skins import @be-music/skin-sdk. The package gets its own build, tests (including the framework-free import walk, now also checking it stays inside the package), and benchmark cases for every exported function, and is registered in the workspace, the demo aliases, the bench harness, and the READMEs. --- .changeset/be-music-skins.md | 2 +- .changeset/skin-sdk-package.md | 10 + README.ja.md | 8 + README.md | 8 + docs/be-music-skin.ja.md | 8 +- docs/be-music-skin.md | 8 +- docs/player-web.ja.md | 2 +- docs/player-web.md | 2 +- package.json | 1 + packages/player-web-demo/vite.config.ts | 1 + packages/player-web/package.json | 6 +- packages/player-web/src/collection/types.ts | 45 +- .../player-web/src/runtime/audio-analysis.ts | 35 +- .../player-web/src/scene/core/gameplay.ts | 2 +- .../player-web/src/scene/core/result-data.ts | 59 +-- .../player-web/src/scene/gameplay-geometry.ts | 7 +- .../player-web/src/scene/gameplay-lanes.ts | 245 +---------- .../player-web/src/skin/be-music/binding.ts | 2 +- .../player-web/src/skin/be-music/registry.ts | 28 +- .../player-web/src/skin/be-music/types.ts | 388 +----------------- .../player-web/src/skins/boundary.test.ts | 8 +- packages/player-web/src/skins/built-in.ts | 2 +- .../player-web/src/skins/lattice/field.ts | 2 +- .../src/skins/lattice/gameplay-chrome.ts | 2 +- .../player-web/src/skins/lattice/index.ts | 2 +- .../player-web/src/skins/lattice/moments.ts | 2 +- .../player-web/src/skins/lattice/playfield.ts | 2 +- .../player-web/src/skins/lattice/result.ts | 2 +- .../player-web/src/skins/lattice/select.ts | 2 +- .../player-web/src/skins/phantom/chrome.ts | 2 +- .../player-web/src/skins/phantom/index.ts | 2 +- .../player-web/src/skins/phantom/moments.ts | 2 +- .../player-web/src/skins/phantom/playfield.ts | 2 +- .../player-web/src/skins/phantom/result.ts | 2 +- .../player-web/src/skins/phantom/select.ts | 2 +- .../player-web/src/skins/phantom/style.ts | 2 +- packages/player-web/src/skins/phantom/tear.ts | 2 +- .../src/skins/pixi-kit/define-pixi-skin.ts | 2 +- .../player-web/src/skins/pixi-kit/hud-text.ts | 2 +- .../src/skins/pixi-kit/skin-text.ts | 2 +- .../player-web/src/skins/pixi-kit/types.ts | 2 +- .../player-web/src/skins/plain/gameplay.ts | 2 +- packages/player-web/src/skins/plain/index.ts | 2 +- packages/player-web/src/skins/plain/result.ts | 2 +- packages/player-web/src/skins/plain/select.ts | 2 +- .../player-web/src/skins/synesthesia/boids.ts | 2 +- .../player-web/src/skins/synesthesia/draw.ts | 2 +- .../src/skins/synesthesia/gameplay-chrome.ts | 2 +- .../player-web/src/skins/synesthesia/index.ts | 2 +- .../src/skins/synesthesia/moments.ts | 2 +- .../src/skins/synesthesia/playfield.ts | 2 +- .../src/skins/synesthesia/result.ts | 2 +- .../src/skins/synesthesia/select.ts | 2 +- .../player-web/src/skins/synesthesia/space.ts | 2 +- packages/player-web/vite.config.ts | 1 - packages/skin-sdk/LICENSE | 21 + packages/skin-sdk/package.json | 34 ++ packages/skin-sdk/scripts/bench.ts | 3 + packages/skin-sdk/scripts/exports-cases.ts | 302 ++++++++++++++ .../src}/audio-drive.test.ts | 2 +- .../skin-sdk => skin-sdk/src}/audio-drive.ts | 4 +- packages/skin-sdk/src/audio.ts | 38 ++ .../skin-sdk => skin-sdk/src}/define.test.ts | 2 +- .../src/skin-sdk => skin-sdk/src}/define.ts | 2 +- .../src/skin-sdk => skin-sdk/src}/fonts.ts | 0 .../src}/framework-free.test.ts | 18 +- .../src/skin-sdk => skin-sdk/src}/index.ts | 9 +- .../src}/judge-word.test.ts | 0 .../skin-sdk => skin-sdk/src}/judge-word.ts | 0 packages/skin-sdk/src/lane-geometry.ts | 254 ++++++++++++ .../skin-sdk => skin-sdk/src}/layout.test.ts | 0 .../src/skin-sdk => skin-sdk/src}/layout.ts | 8 +- .../skin-sdk => skin-sdk/src}/loading.test.ts | 0 .../src/skin-sdk => skin-sdk/src}/loading.ts | 0 .../skin-sdk => skin-sdk/src}/moments.test.ts | 0 .../src/skin-sdk => skin-sdk/src}/moments.ts | 0 .../skin-sdk => skin-sdk/src}/motion.test.ts | 0 .../src/skin-sdk => skin-sdk/src}/motion.ts | 0 packages/skin-sdk/src/playfield.ts | 6 + packages/skin-sdk/src/result-data.ts | 58 +++ .../src}/result-track.test.ts | 8 +- .../skin-sdk => skin-sdk/src}/result-track.ts | 6 +- .../src}/song-stats.test.ts | 0 .../skin-sdk => skin-sdk/src}/song-stats.ts | 2 +- packages/skin-sdk/src/song.ts | 41 ++ .../skin-sdk => skin-sdk/src}/stage.test.ts | 0 .../src/skin-sdk => skin-sdk/src}/stage.ts | 2 +- .../skin-sdk => skin-sdk/src}/tabular.test.ts | 0 .../src/skin-sdk => skin-sdk/src}/tabular.ts | 0 packages/skin-sdk/src/types.ts | 375 +++++++++++++++++ packages/skin-sdk/tsconfig.build.json | 11 + packages/skin-sdk/tsconfig.json | 4 + packages/skin-sdk/vite.config.ts | 11 + pnpm-lock.yaml | 25 ++ scripts/bench/aggregate-results.test.ts | 1 + scripts/bench/cli-utils.test.ts | 1 + scripts/bench/compare-results.test.ts | 1 + scripts/bench/exports.types.ts | 1 + scripts/bench/packages/index.ts | 2 + scripts/bench/packages/skin-sdk.ts | 8 + vite.config.ts | 1 + 101 files changed, 1352 insertions(+), 851 deletions(-) create mode 100644 .changeset/skin-sdk-package.md create mode 100644 packages/skin-sdk/LICENSE create mode 100644 packages/skin-sdk/package.json create mode 100644 packages/skin-sdk/scripts/bench.ts create mode 100644 packages/skin-sdk/scripts/exports-cases.ts rename packages/{player-web/src/skin-sdk => skin-sdk/src}/audio-drive.test.ts (97%) rename packages/{player-web/src/skin-sdk => skin-sdk/src}/audio-drive.ts (94%) create mode 100644 packages/skin-sdk/src/audio.ts rename packages/{player-web/src/skin-sdk => skin-sdk/src}/define.test.ts (97%) rename packages/{player-web/src/skin-sdk => skin-sdk/src}/define.ts (97%) rename packages/{player-web/src/skin-sdk => skin-sdk/src}/fonts.ts (100%) rename packages/{player-web/src/skin-sdk => skin-sdk/src}/framework-free.test.ts (54%) rename packages/{player-web/src/skin-sdk => skin-sdk/src}/index.ts (88%) rename packages/{player-web/src/skin-sdk => skin-sdk/src}/judge-word.test.ts (100%) rename packages/{player-web/src/skin-sdk => skin-sdk/src}/judge-word.ts (100%) create mode 100644 packages/skin-sdk/src/lane-geometry.ts rename packages/{player-web/src/skin-sdk => skin-sdk/src}/layout.test.ts (100%) rename packages/{player-web/src/skin-sdk => skin-sdk/src}/layout.ts (90%) rename packages/{player-web/src/skin-sdk => skin-sdk/src}/loading.test.ts (100%) rename packages/{player-web/src/skin-sdk => skin-sdk/src}/loading.ts (100%) rename packages/{player-web/src/skin-sdk => skin-sdk/src}/moments.test.ts (100%) rename packages/{player-web/src/skin-sdk => skin-sdk/src}/moments.ts (100%) rename packages/{player-web/src/skin-sdk => skin-sdk/src}/motion.test.ts (100%) rename packages/{player-web/src/skin-sdk => skin-sdk/src}/motion.ts (100%) create mode 100644 packages/skin-sdk/src/playfield.ts create mode 100644 packages/skin-sdk/src/result-data.ts rename packages/{player-web/src/skin-sdk => skin-sdk/src}/result-track.test.ts (89%) rename packages/{player-web/src/skin-sdk => skin-sdk/src}/result-track.ts (81%) rename packages/{player-web/src/skin-sdk => skin-sdk/src}/song-stats.test.ts (100%) rename packages/{player-web/src/skin-sdk => skin-sdk/src}/song-stats.ts (98%) create mode 100644 packages/skin-sdk/src/song.ts rename packages/{player-web/src/skin-sdk => skin-sdk/src}/stage.test.ts (100%) rename packages/{player-web/src/skin-sdk => skin-sdk/src}/stage.ts (96%) rename packages/{player-web/src/skin-sdk => skin-sdk/src}/tabular.test.ts (100%) rename packages/{player-web/src/skin-sdk => skin-sdk/src}/tabular.ts (100%) create mode 100644 packages/skin-sdk/src/types.ts create mode 100644 packages/skin-sdk/tsconfig.build.json create mode 100644 packages/skin-sdk/tsconfig.json create mode 100644 packages/skin-sdk/vite.config.ts create mode 100644 scripts/bench/packages/skin-sdk.ts diff --git a/.changeset/be-music-skins.md b/.changeset/be-music-skins.md index 9a71670a..1f4791fc 100644 --- a/.changeset/be-music-skins.md +++ b/.changeset/be-music-skins.md @@ -7,7 +7,7 @@ Add the be-music skin format and a second built-in skin, Synesthesia. 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 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 new `@be-music/player-web/skin-sdk` subpath carries everything a skin is written against — the skin and frame types, `defineBeMusicSkin`, the stage and layout helpers, judgement words, moments, the loading caption, audio drive, song facts, the result lamp, and motion helpers — and imports no rendering framework (a test enforces it). A skin names the canvas context it wants (`'2d'`, `'webgl'`, `'webgl2'`, or `'webgpu'`), 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 the SDK, their framework, and their own files (a test enforces the boundary). +- 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. 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/be-music-skin.ja.md b/docs/be-music-skin.ja.md index 4c27319f..b4cb1441 100644 --- a/docs/be-music-skin.ja.md +++ b/docs/be-music-skin.ja.md @@ -2,7 +2,7 @@ # be-music スキンの作り方 -このガイドは、ブラウザプレイヤーの組み込み(デフォルト)ファミリー向けにスキンを作りたい人のためのものです。スキンは `@be-music/player-web/skin-sdk` サブパスに対して書きます。組み込みスキンもまさにこのサブパスだけを使っており、Synesthesia・Phantom・Lattice・Plain はプレイヤーの他の部分を一切 import していません。組み込みスキンにできることは、あなたのスキンにもできます。 +このガイドは、ブラウザプレイヤーの組み込み(デフォルト)ファミリー向けにスキンを作りたい人のためのものです。スキンは `@be-music/skin-sdk` パッケージに対して書きます。組み込みスキンもまさにこのパッケージだけを使っており、Synesthesia・Phantom・Lattice・Plain はプレイヤーの他の部分を一切 import していません。組み込みスキンにできることは、あなたのスキンにもできます。 be-music スキンはデータファイルではなくコードです。LR2 / beatoraja テーマはシーンが解釈しますが、be-music スキンはすべての画面を自分で描きます。それ以外はすべてプレイヤーの担当です。 @@ -27,7 +27,7 @@ SDK 自体は描画を行わず、描画フレームワークも import しま ## クイックスタート: Canvas 2D スキン ```ts -import { BE_MUSIC_SKIN_API_VERSION, defineBeMusicSkin, resolveLaneRuns } from '@be-music/player-web/skin-sdk'; +import { BE_MUSIC_SKIN_API_VERSION, defineBeMusicSkin, resolveLaneRuns } from '@be-music/skin-sdk'; export default defineBeMusicSkin({ apiVersion: BE_MUSIC_SKIN_API_VERSION, @@ -81,7 +81,7 @@ export default defineBeMusicSkin({ ```ts import { WebGLRenderer, Container } from 'pixi.js'; -import { BE_MUSIC_SKIN_API_VERSION, defineBeMusicSkin } from '@be-music/player-web/skin-sdk'; +import { BE_MUSIC_SKIN_API_VERSION, defineBeMusicSkin } from '@be-music/skin-sdk'; const renderers = new WeakMap(); @@ -264,7 +264,7 @@ export default defineBeMusicSkin({ ## パッケージングと読み込み -- **import の範囲:** スキンモジュールが import するのは `@be-music/player-web/skin-sdk`、選んだ描画フレームワーク、自身のファイルです。組み込みスキンもテストでこのルールを守らされているので、組み込みスキンがプレイヤーから使っているものはすべて使えます。 +- **import の範囲:** スキンモジュールが import するのは `@be-music/skin-sdk`、選んだ描画フレームワーク、自身のファイルです。組み込みスキンもテストでこのルールを守らされているので、組み込みスキンがプレイヤーから使っているものはすべて使えます。 - **export:** スキンはモジュールの default export にします。 - **ホスト側:** ホストはスキンをレジストリに登録します。 diff --git a/docs/be-music-skin.md b/docs/be-music-skin.md index 16852136..c1de4cf3 100644 --- a/docs/be-music-skin.md +++ b/docs/be-music-skin.md @@ -2,7 +2,7 @@ # 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/player-web/skin-sdk` subpath. The built-in skins use exactly this subpath: Synesthesia, Phantom, Lattice, and Plain import nothing else from the player. Anything they do, your skin can do too. +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: @@ -27,7 +27,7 @@ Your skin bundles its own framework as a dependency. Plain is deliberately small ## Quick start: a Canvas 2D skin ```ts -import { BE_MUSIC_SKIN_API_VERSION, defineBeMusicSkin, resolveLaneRuns } from '@be-music/player-web/skin-sdk'; +import { BE_MUSIC_SKIN_API_VERSION, defineBeMusicSkin, resolveLaneRuns } from '@be-music/skin-sdk'; export default defineBeMusicSkin({ apiVersion: BE_MUSIC_SKIN_API_VERSION, @@ -81,7 +81,7 @@ Create the framework's renderer on the surface's canvas in `setup`, and release ```ts import { WebGLRenderer, Container } from 'pixi.js'; -import { BE_MUSIC_SKIN_API_VERSION, defineBeMusicSkin } from '@be-music/player-web/skin-sdk'; +import { BE_MUSIC_SKIN_API_VERSION, defineBeMusicSkin } from '@be-music/skin-sdk'; const renderers = new WeakMap(); @@ -264,7 +264,7 @@ List every face you draw with in `fontLoads`. The host loads them with `document ## Packaging and loading -- **Imports:** a skin module imports `@be-music/player-web/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. +- **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: diff --git a/docs/player-web.ja.md b/docs/player-web.ja.md index 018b8ea7..b3cd7791 100644 --- a/docs/player-web.ja.md +++ b/docs/player-web.ja.md @@ -92,7 +92,7 @@ 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/player-web/skin-sdk` 上に書かれたコード定義のスキン)で描きます。 +この 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 を共有します。 diff --git a/docs/player-web.md b/docs/player-web.md index 7f2f0ab8..7784d169 100644 --- a/docs/player-web.md +++ b/docs/player-web.md @@ -97,7 +97,7 @@ explicitly chooses the default family. It provides select, gameplay, and result files. The family draws every screen through a be-music skin, a code-defined skin built on the public -`@be-music/player-web/skin-sdk` subpath. See [Writing a be-music skin](./be-music-skin.md) to make one. +`@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 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/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 17f42f12..25e17bea 100644 --- a/packages/player-web/package.json +++ b/packages/player-web/package.json @@ -39,11 +39,6 @@ "types": "./dist/runtime.d.ts", "source": "./src/runtime/index.ts", "default": "./dist/runtime.js" - }, - "./skin-sdk": { - "types": "./dist/skin-sdk.d.ts", - "source": "./src/skin-sdk/index.ts", - "default": "./dist/skin-sdk.js" } }, "scripts": { @@ -59,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.ts b/packages/player-web/src/runtime/audio-analysis.ts index e3003854..65de12e3 100644 --- a/packages/player-web/src/runtime/audio-analysis.ts +++ b/packages/player-web/src/runtime/audio-analysis.ts @@ -4,39 +4,12 @@ * tested without Web Audio; {@link AudioAnalyzer} wraps an `AnalyserNode` and samples it once per rendered frame. */ -/** Number of log-spaced spectrum bands in {@link AudioFeatures.bands}. */ -export const AUDIO_BAND_COUNT = 16; +import { AUDIO_BAND_COUNT, SILENT_AUDIO, type BeMusicAudioFrame } from '@be-music/skin-sdk'; -export interface AudioFeatures { - /** 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; -} +export { AUDIO_BAND_COUNT, SILENT_AUDIO }; -export const SILENT_AUDIO: AudioFeatures = 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, -}); +/** 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( diff --git a/packages/player-web/src/scene/core/gameplay.ts b/packages/player-web/src/scene/core/gameplay.ts index abb946d1..f05e7825 100644 --- a/packages/player-web/src/scene/core/gameplay.ts +++ b/packages/player-web/src/scene/core/gameplay.ts @@ -141,7 +141,7 @@ import type { BeMusicStage, } from '../../skin/be-music/types.ts'; import { resolveBeMusicLaneKind } from '../../skin/be-music/registry.ts'; -import { resolveGameplayLayout } from '../../skin-sdk/layout.ts'; +import { resolveGameplayLayout } from '@be-music/skin-sdk'; import { BeMusicGameplayBinding, resolveBeMusicSkinStage } from '../../skin/be-music/binding.ts'; import { PHANTOM_BOMB_DURATION_MS, diff --git a/packages/player-web/src/scene/core/result-data.ts b/packages/player-web/src/scene/core/result-data.ts index 4a6e7e44..6b25b004 100644 --- a/packages/player-web/src/scene/core/result-data.ts +++ b/packages/player-web/src/scene/core/result-data.ts @@ -1,58 +1,9 @@ -import type { ScoreSummary } from '@be-music/player/core/scoring'; -import type { BeMusicPlaylog } from '@be-music/player/playlog'; -import type { BrowserSongEntry } from '../../collection/types.ts'; +import type { BeMusicResultData } from '@be-music/skin-sdk'; -/** - * 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; -} +export type { GaugeHistorySample, ScoreHistorySample } from '@be-music/skin-sdk'; /** - * 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. + * Snapshot of the play session, captured at chart-end and routed through the host into the result scene (LR2 skin or + * be-music skin). The type lives in `@be-music/skin-sdk` as `BeMusicResultData`. */ -export interface PixiGameplayResultData { - 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; -} +export type PixiGameplayResultData = BeMusicResultData; diff --git a/packages/player-web/src/scene/gameplay-geometry.ts b/packages/player-web/src/scene/gameplay-geometry.ts index 4d007f4e..eeefcd21 100644 --- a/packages/player-web/src/scene/gameplay-geometry.ts +++ b/packages/player-web/src/scene/gameplay-geometry.ts @@ -1,6 +1 @@ -/** - * 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; +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 921188a4..0278b11c 100644 --- a/packages/player-web/src/scene/gameplay-lanes.ts +++ b/packages/player-web/src/scene/gameplay-lanes.ts @@ -1,6 +1,18 @@ -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, @@ -11,230 +23,3 @@ export { resolveSideKeySlot, resolveSideRelativeLaneIndex, } from '@be-music/player/core/lane-layout'; - -/** 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); -} diff --git a/packages/player-web/src/skin/be-music/binding.ts b/packages/player-web/src/skin/be-music/binding.ts index 547384d3..d1c57279 100644 --- a/packages/player-web/src/skin/be-music/binding.ts +++ b/packages/player-web/src/skin/be-music/binding.ts @@ -1,7 +1,7 @@ import { CanvasSource, Container, Sprite, Texture } from 'pixi.js'; import { getDesignPixelRatio } from '../../scene/core/viewport.ts'; import { ChildPool } from '../../scene/pixi-utils.ts'; -import { wideStage } from '../../skin-sdk/stage.ts'; +import { wideStage } from '@be-music/skin-sdk'; import { logger } from '../../logger.ts'; import type { BeMusicBomb, diff --git a/packages/player-web/src/skin/be-music/registry.ts b/packages/player-web/src/skin/be-music/registry.ts index bd72ea4c..f2eee26a 100644 --- a/packages/player-web/src/skin/be-music/registry.ts +++ b/packages/player-web/src/skin/be-music/registry.ts @@ -1,9 +1,8 @@ -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 } from '../../skin-sdk/define.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 { @@ -80,27 +79,6 @@ export function createBeMusicSkinRegistry( }; } -/** 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 b4db3074..1e519886 100644 --- a/packages/player-web/src/skin/be-music/types.ts +++ b/packages/player-web/src/skin/be-music/types.ts @@ -1,386 +1,2 @@ -import type { ChartPlayVariant } from '@be-music/player/core/lane-layout'; -import type { BrowserBrowseEntry, BrowserSongEntry } from '../../collection/types.ts'; -import type { PixiGameplayResultData } from '../../scene/core/result-data.ts'; -import type { AudioFeatures } from '../../runtime/audio-analysis.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'; - -/** - * Live analysis of what is playing, sampled once per frame: loudness (`level` / `peak` / `db`), `bass` / `mid` / - * `high` energy, a 16-band log-spaced spectrum (`bands`, ≈ 30 Hz → 14 kHz), and an `onset` envelope that jumps to 1 - * on each detected transient. Gameplay taps the whole mix; select taps the BGM and chart preview. Absent when the - * host has no Web Audio. - */ -export type BeMusicAudioFrame = AudioFeatures; - -/** 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; -} - -/** A finished play: song, score, judgement counts, gauge and score history, and the play log. */ -export type BeMusicResultData = PixiGameplayResultData; - -/** 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; -} +/** 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 index ca0d8f49..03cd3695 100644 --- a/packages/player-web/src/skins/boundary.test.ts +++ b/packages/player-web/src/skins/boundary.test.ts @@ -3,14 +3,14 @@ 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 barrel, files inside + * 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_BARREL = resolve(SKINS_DIR, '../skin-sdk/index.ts'); +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. */ @@ -32,11 +32,11 @@ function violations(file: string): 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 === 'pixi.js' && usesPixi) continue; + 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 === SDK_BARREL || target.startsWith(skinDir + sep)) continue; + if (target.startsWith(skinDir + sep)) continue; if (target === PIXI_KIT_BARREL && usesPixi && skinDir !== PIXI_KIT) continue; } found.push(`${relative(SKINS_DIR, file)} imports ${specifier}`); diff --git a/packages/player-web/src/skins/built-in.ts b/packages/player-web/src/skins/built-in.ts index 63690c66..ec08247d 100644 --- a/packages/player-web/src/skins/built-in.ts +++ b/packages/player-web/src/skins/built-in.ts @@ -1,7 +1,7 @@ import { phantomSkin } from './phantom/index.ts'; import { plainSkin } from './plain/index.ts'; import { synesthesiaSkin } from './synesthesia/index.ts'; -import type { BeMusicSkin } from '../skin-sdk/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 diff --git a/packages/player-web/src/skins/lattice/field.ts b/packages/player-web/src/skins/lattice/field.ts index 3cae8b19..8f77e504 100644 --- a/packages/player-web/src/skins/lattice/field.ts +++ b/packages/player-web/src/skins/lattice/field.ts @@ -1,4 +1,4 @@ -import { hash01 } from '../../skin-sdk/index.ts'; +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 diff --git a/packages/player-web/src/skins/lattice/gameplay-chrome.ts b/packages/player-web/src/skins/lattice/gameplay-chrome.ts index 944bf9ff..3520cbb9 100644 --- a/packages/player-web/src/skins/lattice/gameplay-chrome.ts +++ b/packages/player-web/src/skins/lattice/gameplay-chrome.ts @@ -45,7 +45,7 @@ import { STAGE_HEIGHT as DESIGN_HEIGHT, STAGE_SIDE_COLUMN, STAGE_WIDTH as DESIGN_WIDTH, -} from '../../skin-sdk/index.ts'; +} from '@be-music/skin-sdk'; import { addHudNumber, addHudText, type ChildPool, type PixiChromeContext } from '../pixi-kit/index.ts'; type Runtime = BeMusicGameplayRuntime; diff --git a/packages/player-web/src/skins/lattice/index.ts b/packages/player-web/src/skins/lattice/index.ts index e260344f..767aeb66 100644 --- a/packages/player-web/src/skins/lattice/index.ts +++ b/packages/player-web/src/skins/lattice/index.ts @@ -8,7 +8,7 @@ import { } from './playfield.ts'; import { latticeResultSkin } from './result.ts'; import { latticeSelectSkin } from './select.ts'; -import { BE_MUSIC_SKIN_API_VERSION, wideStage, type BeMusicSkin } from '../../skin-sdk/index.ts'; +import { BE_MUSIC_SKIN_API_VERSION, wideStage, type BeMusicSkin } from '@be-music/skin-sdk'; import { definePixiSkin } from '../pixi-kit/index.ts'; /** diff --git a/packages/player-web/src/skins/lattice/moments.ts b/packages/player-web/src/skins/lattice/moments.ts index c1836efe..5503dda0 100644 --- a/packages/player-web/src/skins/lattice/moments.ts +++ b/packages/player-web/src/skins/lattice/moments.ts @@ -16,7 +16,7 @@ import { type BeMusicGameplayRuntime, STAGE_HEIGHT as DESIGN_HEIGHT, STAGE_WIDTH as DESIGN_WIDTH, -} from '../../skin-sdk/index.ts'; +} 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. */ diff --git a/packages/player-web/src/skins/lattice/playfield.ts b/packages/player-web/src/skins/lattice/playfield.ts index 6984de77..0529d86c 100644 --- a/packages/player-web/src/skins/lattice/playfield.ts +++ b/packages/player-web/src/skins/lattice/playfield.ts @@ -9,7 +9,7 @@ import { effectProfile, hash01, resolveLaneRuns, -} from '../../skin-sdk/index.ts'; +} from '@be-music/skin-sdk'; import { type PixiBombsContext, type PixiLanesContext, diff --git a/packages/player-web/src/skins/lattice/result.ts b/packages/player-web/src/skins/lattice/result.ts index c42a2dd5..f2e1add2 100644 --- a/packages/player-web/src/skins/lattice/result.ts +++ b/packages/player-web/src/skins/lattice/result.ts @@ -19,7 +19,7 @@ import { resolveResultTrackRows, rollUpValue, stageProgress, -} from '../../skin-sdk/index.ts'; +} from '@be-music/skin-sdk'; import { addSkinText, type PixiResultFrame, type PixiResultSkin, type SkinTextOptions } from '../pixi-kit/index.ts'; const ROLL_DELAY_MS = 600; diff --git a/packages/player-web/src/skins/lattice/select.ts b/packages/player-web/src/skins/lattice/select.ts index 54c5fa45..539f2db5 100644 --- a/packages/player-web/src/skins/lattice/select.ts +++ b/packages/player-web/src/skins/lattice/select.ts @@ -24,7 +24,7 @@ import { formatPlayVariantLabel, resolveSongRowFacts, stageProgress, -} from '../../skin-sdk/index.ts'; +} from '@be-music/skin-sdk'; import { addHitArea, addSkinText, diff --git a/packages/player-web/src/skins/phantom/chrome.ts b/packages/player-web/src/skins/phantom/chrome.ts index 2fc0f920..daf701bc 100644 --- a/packages/player-web/src/skins/phantom/chrome.ts +++ b/packages/player-web/src/skins/phantom/chrome.ts @@ -43,7 +43,7 @@ import { STAGE_HEIGHT as DESIGN_HEIGHT, STAGE_SIDE_COLUMN, STAGE_WIDTH as DESIGN_WIDTH, -} from '../../skin-sdk/index.ts'; +} from '@be-music/skin-sdk'; import { addHudNumber as addNumber, addHudText as addText, diff --git a/packages/player-web/src/skins/phantom/index.ts b/packages/player-web/src/skins/phantom/index.ts index 4f7e6597..234eb34d 100644 --- a/packages/player-web/src/skins/phantom/index.ts +++ b/packages/player-web/src/skins/phantom/index.ts @@ -8,7 +8,7 @@ import { } from './playfield.ts'; import { phantomResultSkin } from './result.ts'; import { phantomSelectSkin } from './select.ts'; -import { BE_MUSIC_SKIN_API_VERSION, wideStage, type BeMusicSkin } from '../../skin-sdk/index.ts'; +import { BE_MUSIC_SKIN_API_VERSION, wideStage, type BeMusicSkin } from '@be-music/skin-sdk'; import { definePixiSkin } from '../pixi-kit/index.ts'; /** diff --git a/packages/player-web/src/skins/phantom/moments.ts b/packages/player-web/src/skins/phantom/moments.ts index 65063381..bb1eb927 100644 --- a/packages/player-web/src/skins/phantom/moments.ts +++ b/packages/player-web/src/skins/phantom/moments.ts @@ -25,7 +25,7 @@ import { STAGE_HEIGHT as DESIGN_HEIGHT, STAGE_WIDTH as DESIGN_WIDTH, stageProgress, -} from '../../skin-sdk/index.ts'; +} from '@be-music/skin-sdk'; import { addHudText, type ChildPool } from '../pixi-kit/index.ts'; const MILESTONE_MS = 1100; diff --git a/packages/player-web/src/skins/phantom/playfield.ts b/packages/player-web/src/skins/phantom/playfield.ts index 9af78229..b8fca58e 100644 --- a/packages/player-web/src/skins/phantom/playfield.ts +++ b/packages/player-web/src/skins/phantom/playfield.ts @@ -1,5 +1,5 @@ import { Color, FillGradient, type Graphics } from 'pixi.js'; -import { type BeMusicLaneKind, resolveLaneRuns } from '../../skin-sdk/index.ts'; +import { type BeMusicLaneKind, resolveLaneRuns } from '@be-music/skin-sdk'; import { type PixiBombsContext, type PixiLanesContext, diff --git a/packages/player-web/src/skins/phantom/result.ts b/packages/player-web/src/skins/phantom/result.ts index cd5419fc..6533a615 100644 --- a/packages/player-web/src/skins/phantom/result.ts +++ b/packages/player-web/src/skins/phantom/result.ts @@ -30,7 +30,7 @@ import { resolveResultTrackRows, rollUpValue, stageProgress, -} from '../../skin-sdk/index.ts'; +} from '@be-music/skin-sdk'; import { alignCapCenter, type PixiResultFrame, type PixiResultSkin, resolveSkinTextStyle } from '../pixi-kit/index.ts'; /** Default-family result entrance timeline (ms from scene start): counters roll up, then the rank badge lands. */ diff --git a/packages/player-web/src/skins/phantom/select.ts b/packages/player-web/src/skins/phantom/select.ts index ce03ce34..bcbeb4ae 100644 --- a/packages/player-web/src/skins/phantom/select.ts +++ b/packages/player-web/src/skins/phantom/select.ts @@ -31,7 +31,7 @@ import { hash01, resolveSongRowFacts, stageProgress, -} from '../../skin-sdk/index.ts'; +} from '@be-music/skin-sdk'; import { addHitArea, addSkinText, diff --git a/packages/player-web/src/skins/phantom/style.ts b/packages/player-web/src/skins/phantom/style.ts index 246fa1e3..29503ada 100644 --- a/packages/player-web/src/skins/phantom/style.ts +++ b/packages/player-web/src/skins/phantom/style.ts @@ -1,4 +1,4 @@ -import { hash01 } from '../../skin-sdk/index.ts'; +import { hash01 } from '@be-music/skin-sdk'; import type { Graphics } from 'pixi.js'; /** diff --git a/packages/player-web/src/skins/phantom/tear.ts b/packages/player-web/src/skins/phantom/tear.ts index 8e77ff02..fea5d67a 100644 --- a/packages/player-web/src/skins/phantom/tear.ts +++ b/packages/player-web/src/skins/phantom/tear.ts @@ -9,7 +9,7 @@ import { tornBandPoints, type RansomPaper, } from './style.ts'; -import { easeOutBack, easeOutCubic, DEFAULT_TEXT_FONT, hash01 } from '../../skin-sdk/index.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 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 index ec4757e5..48510020 100644 --- a/packages/player-web/src/skins/pixi-kit/define-pixi-skin.ts +++ b/packages/player-web/src/skins/pixi-kit/define-pixi-skin.ts @@ -6,7 +6,7 @@ import { type BeMusicSelectFrame, type BeMusicSkin, type BeMusicSurface, -} from '../../skin-sdk/index.ts'; +} from '@be-music/skin-sdk'; import { ChildPool, LaggedDisposer } from './pools.ts'; import { setTextResolution } from './resolution.ts'; import { collectHitAreas, type PixiHitArea } from './skin-text.ts'; diff --git a/packages/player-web/src/skins/pixi-kit/hud-text.ts b/packages/player-web/src/skins/pixi-kit/hud-text.ts index 394772f1..2ef4b8ca 100644 --- a/packages/player-web/src/skins/pixi-kit/hud-text.ts +++ b/packages/player-web/src/skins/pixi-kit/hud-text.ts @@ -1,5 +1,5 @@ import { CanvasTextMetrics, type Container, Text, TextStyle } from 'pixi.js'; -import { DEFAULT_TEXT_FONT, layoutTabularRun } from '../../skin-sdk/index.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'; diff --git a/packages/player-web/src/skins/pixi-kit/skin-text.ts b/packages/player-web/src/skins/pixi-kit/skin-text.ts index 5201320b..21584d18 100644 --- a/packages/player-web/src/skins/pixi-kit/skin-text.ts +++ b/packages/player-web/src/skins/pixi-kit/skin-text.ts @@ -1,5 +1,5 @@ import { type Color, type Container, Text, TextStyle } from 'pixi.js'; -import { DEFAULT_TEXT_FONT } from '../../skin-sdk/index.ts'; +import { DEFAULT_TEXT_FONT } from '@be-music/skin-sdk'; import { textResolution } from './resolution.ts'; import { alignCapCenter } from './text-metrics.ts'; diff --git a/packages/player-web/src/skins/pixi-kit/types.ts b/packages/player-web/src/skins/pixi-kit/types.ts index 8d5bd338..1e9b7965 100644 --- a/packages/player-web/src/skins/pixi-kit/types.ts +++ b/packages/player-web/src/skins/pixi-kit/types.ts @@ -12,7 +12,7 @@ import type { BeMusicSelectLayout, BeMusicSkin, BrowserSongEntry, -} from '../../skin-sdk/index.ts'; +} from '@be-music/skin-sdk'; import type { ChildPool } from './pools.ts'; /** diff --git a/packages/player-web/src/skins/plain/gameplay.ts b/packages/player-web/src/skins/plain/gameplay.ts index efb124f7..460b0cf1 100644 --- a/packages/player-web/src/skins/plain/gameplay.ts +++ b/packages/player-web/src/skins/plain/gameplay.ts @@ -6,7 +6,7 @@ import { resolveLaneRuns, type BeMusicGameplayFrame, type BeMusicSurface, -} from '../../skin-sdk/index.ts'; +} from '@be-music/skin-sdk'; import { ACCENT, DANGER, diff --git a/packages/player-web/src/skins/plain/index.ts b/packages/player-web/src/skins/plain/index.ts index 2194ca04..c04c63af 100644 --- a/packages/player-web/src/skins/plain/index.ts +++ b/packages/player-web/src/skins/plain/index.ts @@ -1,4 +1,4 @@ -import { BE_MUSIC_SKIN_API_VERSION, defineBeMusicSkin, type BeMusicSkin } from '../../skin-sdk/index.ts'; +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'; diff --git a/packages/player-web/src/skins/plain/result.ts b/packages/player-web/src/skins/plain/result.ts index 3ca11eb1..1a3061ec 100644 --- a/packages/player-web/src/skins/plain/result.ts +++ b/packages/player-web/src/skins/plain/result.ts @@ -3,7 +3,7 @@ import { resolveResultTrackRows, type BeMusicResultFrame, type BeMusicSurface, -} from '../../skin-sdk/index.ts'; +} from '@be-music/skin-sdk'; import { ACCENT, DANGER, INK, MUTED, PANEL, stat, text } from './theme.ts'; const MARGIN = 16; diff --git a/packages/player-web/src/skins/plain/select.ts b/packages/player-web/src/skins/plain/select.ts index 245bf84d..6a56e076 100644 --- a/packages/player-web/src/skins/plain/select.ts +++ b/packages/player-web/src/skins/plain/select.ts @@ -5,7 +5,7 @@ import { type BrowserBrowseEntry, type BeMusicSelectFrame, type BeMusicSurface, -} from '../../skin-sdk/index.ts'; +} 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. */ diff --git a/packages/player-web/src/skins/synesthesia/boids.ts b/packages/player-web/src/skins/synesthesia/boids.ts index 82050ef2..36efaf20 100644 --- a/packages/player-web/src/skins/synesthesia/boids.ts +++ b/packages/player-web/src/skins/synesthesia/boids.ts @@ -1,4 +1,4 @@ -import { hash01 } from '../../skin-sdk/index.ts'; +import { hash01 } from '@be-music/skin-sdk'; /** * A 3D school of boids (Reynolds' separation / alignment / cohesion) for the Synesthesia backdrop: fish of light that diff --git a/packages/player-web/src/skins/synesthesia/draw.ts b/packages/player-web/src/skins/synesthesia/draw.ts index 7c5be881..1f0e9c5d 100644 --- a/packages/player-web/src/skins/synesthesia/draw.ts +++ b/packages/player-web/src/skins/synesthesia/draw.ts @@ -16,7 +16,7 @@ import { type Vec3, } from './space.ts'; import { SYN_CYAN, SYN_GLASS, SYN_MAGENTA, SYN_WHITE, synGlowTexture } from './style.ts'; -import { type AudioDrive, bandLevel, hash01 } from '../../skin-sdk/index.ts'; +import { type AudioDrive, bandLevel, hash01 } from '@be-music/skin-sdk'; import { pointLayerFor } from '../pixi-kit/index.ts'; /** diff --git a/packages/player-web/src/skins/synesthesia/gameplay-chrome.ts b/packages/player-web/src/skins/synesthesia/gameplay-chrome.ts index 29c0958c..beba7f25 100644 --- a/packages/player-web/src/skins/synesthesia/gameplay-chrome.ts +++ b/packages/player-web/src/skins/synesthesia/gameplay-chrome.ts @@ -56,7 +56,7 @@ import { STAGE_SIDE_COLUMN, STAGE_WIDTH as DESIGN_WIDTH, punchScale, -} from '../../skin-sdk/index.ts'; +} from '@be-music/skin-sdk'; import { addHudNumber, addHudText, diff --git a/packages/player-web/src/skins/synesthesia/index.ts b/packages/player-web/src/skins/synesthesia/index.ts index bcfe3ffd..f7f880c1 100644 --- a/packages/player-web/src/skins/synesthesia/index.ts +++ b/packages/player-web/src/skins/synesthesia/index.ts @@ -8,7 +8,7 @@ import { } from './playfield.ts'; import { synesthesiaResultSkin } from './result.ts'; import { synesthesiaSelectSkin } from './select.ts'; -import { BE_MUSIC_SKIN_API_VERSION, wideStage, type BeMusicSkin } from '../../skin-sdk/index.ts'; +import { BE_MUSIC_SKIN_API_VERSION, wideStage, type BeMusicSkin } from '@be-music/skin-sdk'; import { definePixiSkin } from '../pixi-kit/index.ts'; /** diff --git a/packages/player-web/src/skins/synesthesia/moments.ts b/packages/player-web/src/skins/synesthesia/moments.ts index fa2b96de..3398a9cc 100644 --- a/packages/player-web/src/skins/synesthesia/moments.ts +++ b/packages/player-web/src/skins/synesthesia/moments.ts @@ -24,7 +24,7 @@ import { STAGE_HEIGHT as DESIGN_HEIGHT, STAGE_WIDTH as DESIGN_WIDTH, stageProgress, -} from '../../skin-sdk/index.ts'; +} 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. */ diff --git a/packages/player-web/src/skins/synesthesia/playfield.ts b/packages/player-web/src/skins/synesthesia/playfield.ts index dbb59032..f054787e 100644 --- a/packages/player-web/src/skins/synesthesia/playfield.ts +++ b/packages/player-web/src/skins/synesthesia/playfield.ts @@ -19,7 +19,7 @@ import { comboTier, effectProfile, resolveLaneRuns, -} from '../../skin-sdk/index.ts'; +} from '@be-music/skin-sdk'; import { type PixiBombsContext, type PixiLanesContext, diff --git a/packages/player-web/src/skins/synesthesia/result.ts b/packages/player-web/src/skins/synesthesia/result.ts index a24a10fd..7ff66a6d 100644 --- a/packages/player-web/src/skins/synesthesia/result.ts +++ b/packages/player-web/src/skins/synesthesia/result.ts @@ -33,7 +33,7 @@ import { resolveResultTrackRows, rollUpValue, stageProgress, -} from '../../skin-sdk/index.ts'; +} from '@be-music/skin-sdk'; import { addSkinText, type PixiResultFrame, type PixiResultSkin, type SkinTextOptions } from '../pixi-kit/index.ts'; const ROLL_DELAY_MS = 700; diff --git a/packages/player-web/src/skins/synesthesia/select.ts b/packages/player-web/src/skins/synesthesia/select.ts index 4b7d6826..2972ce3d 100644 --- a/packages/player-web/src/skins/synesthesia/select.ts +++ b/packages/player-web/src/skins/synesthesia/select.ts @@ -45,7 +45,7 @@ import { hash01, resolveSongRowFacts, stageProgress, -} from '../../skin-sdk/index.ts'; +} from '@be-music/skin-sdk'; import { addHitArea, addSkinText, diff --git a/packages/player-web/src/skins/synesthesia/space.ts b/packages/player-web/src/skins/synesthesia/space.ts index d65692c5..a5be3f62 100644 --- a/packages/player-web/src/skins/synesthesia/space.ts +++ b/packages/player-web/src/skins/synesthesia/space.ts @@ -1,4 +1,4 @@ -import { hash01 } from '../../skin-sdk/index.ts'; +import { hash01 } from '@be-music/skin-sdk'; /** * Pure 3D helpers for the Synesthesia skin: a pinhole projection, deterministic particle bursts, a looping starfield, diff --git a/packages/player-web/vite.config.ts b/packages/player-web/vite.config.ts index c775f4f0..ab7fcd74 100644 --- a/packages/player-web/vite.config.ts +++ b/packages/player-web/vite.config.ts @@ -16,7 +16,6 @@ export default defineConfig({ 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). - 'skin-sdk': 'src/skin-sdk/index.ts', }, }), }); 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/player-web/src/skin-sdk/audio-drive.test.ts b/packages/skin-sdk/src/audio-drive.test.ts similarity index 97% rename from packages/player-web/src/skin-sdk/audio-drive.test.ts rename to packages/skin-sdk/src/audio-drive.test.ts index 8361aa8a..8c2f5247 100644 --- a/packages/player-web/src/skin-sdk/audio-drive.test.ts +++ b/packages/skin-sdk/src/audio-drive.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vite-plus/test'; -import { SILENT_AUDIO } from '../runtime/audio-analysis.ts'; +import { SILENT_AUDIO } from './audio.ts'; import { audioDrive, bandAt, bandLevel } from './audio-drive.ts'; const LOUD = { diff --git a/packages/player-web/src/skin-sdk/audio-drive.ts b/packages/skin-sdk/src/audio-drive.ts similarity index 94% rename from packages/player-web/src/skin-sdk/audio-drive.ts rename to packages/skin-sdk/src/audio-drive.ts index 8f094317..7cd3226e 100644 --- a/packages/player-web/src/skin-sdk/audio-drive.ts +++ b/packages/skin-sdk/src/audio-drive.ts @@ -1,4 +1,4 @@ -import { AUDIO_BAND_COUNT, SILENT_AUDIO, type AudioFeatures } from '../runtime/audio-analysis.ts'; +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 @@ -20,7 +20,7 @@ export interface AudioDrive { const SILENT_BANDS: readonly number[] = Object.freeze(Array.from({ length: AUDIO_BAND_COUNT }, () => 0)); export function audioDrive( - audio: AudioFeatures | undefined, + audio: BeMusicAudioFrame | undefined, effects: 'full' | 'reduced' | 'off' | undefined, ): AudioDrive { const amount = effects === 'off' ? 0 : effects === 'reduced' ? 0.5 : 1; 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/player-web/src/skin-sdk/define.test.ts b/packages/skin-sdk/src/define.test.ts similarity index 97% rename from packages/player-web/src/skin-sdk/define.test.ts rename to packages/skin-sdk/src/define.test.ts index d38ac798..f4f253e0 100644 --- a/packages/player-web/src/skin-sdk/define.test.ts +++ b/packages/skin-sdk/src/define.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vite-plus/test'; -import type { BeMusicSkin } from '../skin/be-music/types.ts'; +import type { BeMusicSkin } from './types.ts'; import { BE_MUSIC_SKIN_API_VERSION, defineBeMusicSkin, diff --git a/packages/player-web/src/skin-sdk/define.ts b/packages/skin-sdk/src/define.ts similarity index 97% rename from packages/player-web/src/skin-sdk/define.ts rename to packages/skin-sdk/src/define.ts index a9f4acbc..5d823e3d 100644 --- a/packages/player-web/src/skin-sdk/define.ts +++ b/packages/skin-sdk/src/define.ts @@ -1,4 +1,4 @@ -import type { BeMusicSkin, BeMusicSurfaceContextKind } from '../skin/be-music/types.ts'; +import type { BeMusicSkin, BeMusicSurfaceContextKind } from './types.ts'; /** * The skin API revision this build of the player implements. Skins record the revision they were written against in diff --git a/packages/player-web/src/skin-sdk/fonts.ts b/packages/skin-sdk/src/fonts.ts similarity index 100% rename from packages/player-web/src/skin-sdk/fonts.ts rename to packages/skin-sdk/src/fonts.ts diff --git a/packages/player-web/src/skin-sdk/framework-free.test.ts b/packages/skin-sdk/src/framework-free.test.ts similarity index 54% rename from packages/player-web/src/skin-sdk/framework-free.test.ts rename to packages/skin-sdk/src/framework-free.test.ts index 020d4b4e..f30c4c6c 100644 --- a/packages/player-web/src/skin-sdk/framework-free.test.ts +++ b/packages/skin-sdk/src/framework-free.test.ts @@ -5,10 +5,13 @@ 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 its only package imports are the repository's own `@be-music/*` packages. + * 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 = resolve(import.meta.dirname, '..'); +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[] { @@ -21,15 +24,20 @@ function packageImports(entry: string): string[] { seen.add(file); for (const match of readFileSync(file, 'utf8').matchAll(SPECIFIER)) { const specifier = match[1] ?? match[2]!; - if (specifier.startsWith('.')) pending.push(resolve(dirname(file), specifier)); - else if (!specifier.startsWith('@be-music/')) found.push(`${relative(SRC_DIR, file)} imports ${specifier}`); + 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('reaches no package outside @be-music, so no rendering framework', () => { + it('stays inside the package and reaches no rendering framework', () => { expect(packageImports(SDK_BARREL)).toEqual([]); }); }); diff --git a/packages/player-web/src/skin-sdk/index.ts b/packages/skin-sdk/src/index.ts similarity index 88% rename from packages/player-web/src/skin-sdk/index.ts rename to packages/skin-sdk/src/index.ts index 4cac47c8..db8e0e6d 100644 --- a/packages/player-web/src/skin-sdk/index.ts +++ b/packages/skin-sdk/src/index.ts @@ -13,8 +13,13 @@ * - 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 '../skin/be-music/types.ts'; -export type { BrowserBrowseEntry, BrowserFolderNode, BrowserSongEntry } from '../collection/types.ts'; +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'; diff --git a/packages/player-web/src/skin-sdk/judge-word.test.ts b/packages/skin-sdk/src/judge-word.test.ts similarity index 100% rename from packages/player-web/src/skin-sdk/judge-word.test.ts rename to packages/skin-sdk/src/judge-word.test.ts diff --git a/packages/player-web/src/skin-sdk/judge-word.ts b/packages/skin-sdk/src/judge-word.ts similarity index 100% rename from packages/player-web/src/skin-sdk/judge-word.ts rename to packages/skin-sdk/src/judge-word.ts 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/player-web/src/skin-sdk/layout.test.ts b/packages/skin-sdk/src/layout.test.ts similarity index 100% rename from packages/player-web/src/skin-sdk/layout.test.ts rename to packages/skin-sdk/src/layout.test.ts diff --git a/packages/player-web/src/skin-sdk/layout.ts b/packages/skin-sdk/src/layout.ts similarity index 90% rename from packages/player-web/src/skin-sdk/layout.ts rename to packages/skin-sdk/src/layout.ts index 0aa41716..0f1c0612 100644 --- a/packages/player-web/src/skin-sdk/layout.ts +++ b/packages/skin-sdk/src/layout.ts @@ -1,8 +1,8 @@ import type { ChartPlayVariant } from '@be-music/player/core/lane-layout'; -import { PLAYFIELD } from '../scene/gameplay-geometry.ts'; -import { resolveLr2LaneIndex, resolveSkinlessLaneLayout } from '../scene/gameplay-lanes.ts'; -import { resolveBeMusicLaneKind } from '../skin/be-music/registry.ts'; -import type { BeMusicGameplayLayout, BeMusicStage } from '../skin/be-music/types.ts'; +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 { diff --git a/packages/player-web/src/skin-sdk/loading.test.ts b/packages/skin-sdk/src/loading.test.ts similarity index 100% rename from packages/player-web/src/skin-sdk/loading.test.ts rename to packages/skin-sdk/src/loading.test.ts diff --git a/packages/player-web/src/skin-sdk/loading.ts b/packages/skin-sdk/src/loading.ts similarity index 100% rename from packages/player-web/src/skin-sdk/loading.ts rename to packages/skin-sdk/src/loading.ts diff --git a/packages/player-web/src/skin-sdk/moments.test.ts b/packages/skin-sdk/src/moments.test.ts similarity index 100% rename from packages/player-web/src/skin-sdk/moments.test.ts rename to packages/skin-sdk/src/moments.test.ts diff --git a/packages/player-web/src/skin-sdk/moments.ts b/packages/skin-sdk/src/moments.ts similarity index 100% rename from packages/player-web/src/skin-sdk/moments.ts rename to packages/skin-sdk/src/moments.ts diff --git a/packages/player-web/src/skin-sdk/motion.test.ts b/packages/skin-sdk/src/motion.test.ts similarity index 100% rename from packages/player-web/src/skin-sdk/motion.test.ts rename to packages/skin-sdk/src/motion.test.ts diff --git a/packages/player-web/src/skin-sdk/motion.ts b/packages/skin-sdk/src/motion.ts similarity index 100% rename from packages/player-web/src/skin-sdk/motion.ts rename to packages/skin-sdk/src/motion.ts 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/player-web/src/skin-sdk/result-track.test.ts b/packages/skin-sdk/src/result-track.test.ts similarity index 89% rename from packages/player-web/src/skin-sdk/result-track.test.ts rename to packages/skin-sdk/src/result-track.test.ts index 3ea7e59b..b36eadd9 100644 --- a/packages/player-web/src/skin-sdk/result-track.test.ts +++ b/packages/skin-sdk/src/result-track.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from 'vite-plus/test'; -import type { PixiGameplayResultData } from '../scene/core/result-data.ts'; +import type { BeMusicResultData } from './result-data.ts'; import { resolveResultLamp, resolveResultTrackRows } from './result-track.ts'; -function score(partial: Partial): PixiGameplayResultData['score'] { +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 }; } @@ -38,7 +38,7 @@ describe('resolveResultTrackRows', () => { totalNotes: 100, chartPath: 'song.bms', chart: { events: [{ measure: 0, channel: '11', position: [0, 1], value: '01' }], bms: { player: 1 } }, - } as unknown as PixiGameplayResultData['song']; + } as unknown as BeMusicResultData['song']; it('lists level, rounded tempo and the combo breaks', () => { const rows = resolveResultTrackRows({ song, score: score({ bad: 2, poor: 3 }) }); @@ -50,7 +50,7 @@ describe('resolveResultTrackRows', () => { }); it('dashes out a missing level or tempo', () => { - const bare = { ...song, playLevel: undefined, bpm: undefined } as PixiGameplayResultData['song']; + 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/player-web/src/skin-sdk/result-track.ts b/packages/skin-sdk/src/result-track.ts similarity index 81% rename from packages/player-web/src/skin-sdk/result-track.ts rename to packages/skin-sdk/src/result-track.ts index a7bdbfcf..2292b9ee 100644 --- a/packages/player-web/src/skin-sdk/result-track.ts +++ b/packages/skin-sdk/src/result-track.ts @@ -1,4 +1,4 @@ -import type { PixiGameplayResultData } from '../scene/core/result-data.ts'; +import type { BeMusicResultData } from './result-data.ts'; import { formatPlayVariantLabel } from './song-stats.ts'; /** @@ -7,7 +7,7 @@ import { formatPlayVariantLabel } from './song-stats.ts'; */ export type ResultLamp = 'PERFECT' | 'FULL COMBO' | 'CLEAR' | 'FAILED'; -export function resolveResultLamp(result: Pick): ResultLamp { +export function resolveResultLamp(result: Pick): ResultLamp { const { score } = result; if (!result.cleared) return 'FAILED'; if (score.total <= 0) return 'CLEAR'; @@ -25,7 +25,7 @@ export interface ResultTrackRow { * 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[] { +export function resolveResultTrackRows(result: Pick): ResultTrackRow[] { const { song, score } = result; return [ { label: 'MODE', value: formatPlayVariantLabel(song) }, diff --git a/packages/player-web/src/skin-sdk/song-stats.test.ts b/packages/skin-sdk/src/song-stats.test.ts similarity index 100% rename from packages/player-web/src/skin-sdk/song-stats.test.ts rename to packages/skin-sdk/src/song-stats.test.ts diff --git a/packages/player-web/src/skin-sdk/song-stats.ts b/packages/skin-sdk/src/song-stats.ts similarity index 98% rename from packages/player-web/src/skin-sdk/song-stats.ts rename to packages/skin-sdk/src/song-stats.ts index d88ab9f1..8810cfbc 100644 --- a/packages/player-web/src/skin-sdk/song-stats.ts +++ b/packages/skin-sdk/src/song-stats.ts @@ -2,7 +2,7 @@ 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 '../collection/types.ts'; +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 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/player-web/src/skin-sdk/stage.test.ts b/packages/skin-sdk/src/stage.test.ts similarity index 100% rename from packages/player-web/src/skin-sdk/stage.test.ts rename to packages/skin-sdk/src/stage.test.ts diff --git a/packages/player-web/src/skin-sdk/stage.ts b/packages/skin-sdk/src/stage.ts similarity index 96% rename from packages/player-web/src/skin-sdk/stage.ts rename to packages/skin-sdk/src/stage.ts index 5323c9a2..482139d2 100644 --- a/packages/player-web/src/skin-sdk/stage.ts +++ b/packages/skin-sdk/src/stage.ts @@ -1,4 +1,4 @@ -import type { BeMusicRect, BeMusicStage } from '../skin/be-music/types.ts'; +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 diff --git a/packages/player-web/src/skin-sdk/tabular.test.ts b/packages/skin-sdk/src/tabular.test.ts similarity index 100% rename from packages/player-web/src/skin-sdk/tabular.test.ts rename to packages/skin-sdk/src/tabular.test.ts diff --git a/packages/player-web/src/skin-sdk/tabular.ts b/packages/skin-sdk/src/tabular.ts similarity index 100% rename from packages/player-web/src/skin-sdk/tabular.ts rename to packages/skin-sdk/src/tabular.ts 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; From 51a02ffcc548393f81e275561a2e83b9c355d46b Mon Sep 17 00:00:00 2001 From: Seiya KONNO Date: Fri, 9 Oct 2026 16:48:56 +0900 Subject: [PATCH 51/65] docs: add rendering performance techniques to the skin guide [ci skip] Cover the frame budget, measuring, drawing only what changed, avoiding per-frame allocation, batching, text caching, effect budgets, fill rate at high density, and setup-time preparation, then Canvas 2D, WebGL, WebGPU, and PixiJS / three.js specifics. Also note installing @be-music/skin-sdk on its own. --- docs/be-music-skin.ja.md | 84 ++++++++++++++++++++++++++++++++++++++++ docs/be-music-skin.md | 84 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 168 insertions(+) diff --git a/docs/be-music-skin.ja.md b/docs/be-music-skin.ja.md index b4cb1441..5104cbf8 100644 --- a/docs/be-music-skin.ja.md +++ b/docs/be-music-skin.ja.md @@ -258,12 +258,95 @@ export default defineBeMusicSkin({ `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 にします。 - **ホスト側:** ホストはスキンをレジストリに登録します。 @@ -302,4 +385,5 @@ export default defineBeMusicSkin({ - [ ] 選曲の行が `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 index c1de4cf3..080c6831 100644 --- a/docs/be-music-skin.md +++ b/docs/be-music-skin.md @@ -258,12 +258,95 @@ Make reactions large enough to read: a pulse of a few percent is invisible at 60 `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: @@ -302,4 +385,5 @@ List every face you draw with in `fontLoads`. The host loads them with `document - [ ] 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. From 06c0450c0a25a98934cba82bbafb72f4b2858f93 Mon Sep 17 00:00:00 2001 From: Seiya KONNO Date: Fri, 9 Oct 2026 17:16:01 +0900 Subject: [PATCH 52/65] perf(player-web): lighten the Pixi skin kit's per-frame work Every note and long note now goes into one shared Graphics instead of a cleared pooled Graphics each; the renderers only add geometry, in the same order, so the picture is unchanged. The select screen retires its previous rebuild a frame late, so unchanged labels keep their text textures during slides and the outro. Skin canvases no longer preserve their drawing buffer, since the player uploads them in the frame they are drawn, and the kit's roots skip Pixi's hit-testing. Main-thread time drops by roughly a third in Phantom gameplay and result, a fifth in Synesthesia gameplay, and a quarter on Lattice's result screen. --- .../src/skins/pixi-kit/define-pixi-skin.ts | 44 ++++++++++++------- 1 file changed, 28 insertions(+), 16 deletions(-) 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 index 48510020..f617ae83 100644 --- a/packages/player-web/src/skins/pixi-kit/define-pixi-skin.ts +++ b/packages/player-web/src/skins/pixi-kit/define-pixi-skin.ts @@ -26,13 +26,14 @@ export function definePixiSkin(definition: PixiSkinDefinition): BeMusicSkin<'web ...meta, context: 'webgl2', // Pixi needs a stencil buffer for masks; the canvas is composited over the BGA, so it keeps (premultiplied) alpha. - // The drawing buffer is preserved so the player can upload it whenever it renders. + // 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: true, + preserveDrawingBuffer: false, }, async setup(surface) { const renderer = new WebGLRenderer(); @@ -102,6 +103,9 @@ class PixiSurface { 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 || @@ -125,12 +129,12 @@ class GameplayScreen { private readonly root = new Container(); private readonly layer = new Container(); private readonly lanes = new Graphics(); - private readonly notes = new Container(); + /** 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 notePool = new ChildPool(this.notes); private readonly bombPool = new ChildPool(this.bombs); constructor(definition: PixiSkinDefinition) { @@ -175,16 +179,21 @@ class GameplayScreen { audio, }); - this.notePool.begin(); - try { - for (const note of frame.longNotes) { - skin.renderLongNote({ graphics: this.notePool.acquireGraphics(), ...note, nowMs }); - } - for (const note of frame.notes) { - skin.renderNote({ graphics: this.notePool.acquireGraphics(), ...note, nowMs }); - } - } finally { - this.notePool.end(); + 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(); @@ -197,7 +206,7 @@ class GameplayScreen { } destroy(): void { - for (const pool of [this.layerPool, this.overlayLayerPool, this.notePool, this.bombPool]) pool.destroy(); + for (const pool of [this.layerPool, this.overlayLayerPool, this.bombPool]) pool.destroy(); this.root.destroy({ children: true }); } } @@ -210,6 +219,8 @@ 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[] = []; @@ -222,7 +233,7 @@ class SelectScreen { build(frame: BeMusicSelectFrame): Container { if (frame.revision !== this.revision || this.needsFrame || frame.launchAt !== undefined) { this.revision = frame.revision; - for (const child of this.layer.removeChildren()) child.destroy({ children: true, context: true }); + 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(() => { @@ -235,6 +246,7 @@ class SelectScreen { } destroy(): void { + this.disposer.flush(); this.renderer.dispose(); this.root.destroy({ children: true }); } From 69a5a436151bd234e716e58d50940efa35bd2744 Mon Sep 17 00:00:00 2001 From: Seiya KONNO Date: Fri, 9 Oct 2026 17:28:16 +0900 Subject: [PATCH 53/65] fix(player-web): label the gauge's clear-line moment CLEAR LINE Crossing the clear line mid-play flashed CLEAR (Phantom: CLEAR!), which read as the song being cleared. Phantom, Lattice, and Synesthesia now say CLEAR LINE; Phantom's tag widens to fit. --- packages/player-web/src/skins/lattice/moments.ts | 2 +- packages/player-web/src/skins/phantom/moments.ts | 13 +++++++------ .../player-web/src/skins/synesthesia/moments.ts | 2 +- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/player-web/src/skins/lattice/moments.ts b/packages/player-web/src/skins/lattice/moments.ts index 5503dda0..4331594d 100644 --- a/packages/player-web/src/skins/lattice/moments.ts +++ b/packages/player-web/src/skins/lattice/moments.ts @@ -200,7 +200,7 @@ function drawClear(graphics: Graphics, layer: Container, t: number, pool: ChildP graphics.rect(x - reach, y + 14, reach, 1).fill({ color: LAT_ACCENT, alpha: 1 - out }); addStaggeredHudText( layer, - scrambleText('CLEAR', t / 0.35, scrambleTick(t * CLEAR_MS, 35), 3), + scrambleText('CLEAR LINE', t / 0.35, scrambleTick(t * CLEAR_MS, 35), 3), x, y, { ...monoStyle(LAT_ACCENT), size: 11, letterSpacing: 3, anchorX: 1 }, diff --git a/packages/player-web/src/skins/phantom/moments.ts b/packages/player-web/src/skins/phantom/moments.ts index bb1eb927..dc0aeb4b 100644 --- a/packages/player-web/src/skins/phantom/moments.ts +++ b/packages/player-web/src/skins/phantom/moments.ts @@ -40,7 +40,8 @@ const SKEW = -0.18; * 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! tag pops off the gauge with a flash; + * - 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). */ @@ -238,7 +239,7 @@ function drawMilestone( }); } -/** Gauge crossing the clear line: a flash over the gauge and a CLEAR! tag popping off it. */ +/** 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 @@ -248,19 +249,19 @@ function drawClear(graphics: Graphics, layer: Container, t: number, pool: ChildP 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 + 62; + const cx = GROOVE.x + GROOVE.w + 80; const cy = GROOVE.y - 6 - lift; graphics - .poly(parallelogramPoints(cx - 44 * pop, cy - 14 * pop, 88 * pop, 28 * pop, 8 * pop)) + .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!', + 'CLEAR LINE', cx + 3, cy, { - size: 18, + size: 17, fill: PHANTOM_INK, fontFamily: DEFAULT_DISPLAY_FONT, letterSpacing: 2, diff --git a/packages/player-web/src/skins/synesthesia/moments.ts b/packages/player-web/src/skins/synesthesia/moments.ts index 3398a9cc..e24614b1 100644 --- a/packages/player-web/src/skins/synesthesia/moments.ts +++ b/packages/player-web/src/skins/synesthesia/moments.ts @@ -254,7 +254,7 @@ function drawClear(graphics: Graphics, layer: Container, t: number, pool: ChildP graphics.circle(head, GROOVE.y + GROOVE.h / 2, 14).fill({ color, alpha: 0.3 * fade }); const text = addHudText( layer, - 'CLEAR', + 'CLEAR LINE', GROOVE.x + GROOVE.w - 8, // Between the playfield frame's bottom edge and the gauge panel, touching neither. GROOVE.y - 34, From 9457eb8ad390f33d1325405dea76f56748635f27 Mon Sep 17 00:00:00 2001 From: Seiya KONNO Date: Fri, 9 Oct 2026 17:28:25 +0900 Subject: [PATCH 54/65] feat(player-web): spell out NOTES in the select list rows The note count in each select row was marked with a bare N, which was easy to misread. Every built-in skin now labels it NOTES, widening the column to fit. --- .../player-web/src/skins/lattice/select.ts | 22 ++++++++++++------- .../player-web/src/skins/phantom/select.ts | 6 ++--- packages/player-web/src/skins/plain/select.ts | 2 +- .../src/skins/synesthesia/select.ts | 6 ++--- 4 files changed, 21 insertions(+), 15 deletions(-) diff --git a/packages/player-web/src/skins/lattice/select.ts b/packages/player-web/src/skins/lattice/select.ts index 539f2db5..2133dbc1 100644 --- a/packages/player-web/src/skins/lattice/select.ts +++ b/packages/player-web/src/skins/lattice/select.ts @@ -38,7 +38,7 @@ const LAYOUT: BeMusicSelectLayout = { listX: 322, listTop: 56, listBottomInset: /** 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 = 52; +const ROW_NOTES_W = 76; const CURSOR_MS = 360; const OUTRO_MS = 650; const INTRO_STAGGER_MS = 40; @@ -492,13 +492,19 @@ class LatticeSelectRenderer implements PixiSelectRenderer { maxWidth: ROW_LENGTH_W, }); factsLeft -= ROW_LENGTH_W + 14; - addSkinText(frame.layer, scrambleText(`${facts.notes} N`, rowScramble, tick, entryIndex + 700), factsLeft, midY, { - ...mono(ink), - anchorX: 1, - anchorY: 0.5, - alpha, - maxWidth: ROW_NOTES_W, - }); + 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) { diff --git a/packages/player-web/src/skins/phantom/select.ts b/packages/player-web/src/skins/phantom/select.ts index bcbeb4ae..4dbb399c 100644 --- a/packages/player-web/src/skins/phantom/select.ts +++ b/packages/player-web/src/skins/phantom/select.ts @@ -48,7 +48,7 @@ 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 = 52; +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. */ @@ -641,8 +641,8 @@ class PhantomSelectRenderer implements PixiSelectRenderer { 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 - 16, rowMidY, { ...factStyle(ink), maxWidth: ROW_NOTES_W - 18 }); - addSkinText(frame.layer, 'N', factsLeft, rowMidY + 1, { + 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, }); diff --git a/packages/player-web/src/skins/plain/select.ts b/packages/player-web/src/skins/plain/select.ts index 6a56e076..16c1bc03 100644 --- a/packages/player-web/src/skins/plain/select.ts +++ b/packages/player-web/src/skins/plain/select.ts @@ -122,7 +122,7 @@ function drawRow( 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} N ${facts.length} ${facts.bpm} BPM`, x + w - 12, midY, { + 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/synesthesia/select.ts b/packages/player-web/src/skins/synesthesia/select.ts index 2972ce3d..8c504caf 100644 --- a/packages/player-web/src/skins/synesthesia/select.ts +++ b/packages/player-web/src/skins/synesthesia/select.ts @@ -60,7 +60,7 @@ const LAYOUT: BeMusicSelectLayout = { listX: 322, listTop: 56, listBottomInset: /** 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 = 50; +const ROW_NOTES_W = 84; const SLIDE_MS = 320; const OUTRO_MS = 700; const INTRO_STAGGER_MS = 45; @@ -686,8 +686,8 @@ class SynesthesiaSelectRenderer implements PixiSelectRenderer { 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, 'N', factsLeft, midY, { ...factStyle(SYN_DIM), size: 7 }); - addSkinText(frame.layer, facts.notes, factsLeft - 12, midY, { ...factStyle(ink), maxWidth: ROW_NOTES_W - 14 }); + 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) { From 69a0ff518de084f33b521c7d4da45fdd7f5a596d Mon Sep 17 00:00:00 2001 From: Seiya KONNO Date: Fri, 9 Oct 2026 17:28:38 +0900 Subject: [PATCH 55/65] feat(player-web): drop horizon lines and ellipse effects from the built-in skins Phantom loses the sheet-music staves ruled across its floor wedge and result slash. Synesthesia loses its horizon: the ember haze bands and the amber line across gameplay, the idle BGA monitor, select, and result. Synesthesia also drops every elliptical effect: the hit shockwave rings, the beat rings on the floor, the count-in ring, the 100-combo shockwave, the full-combo prism rings, and the particle ring around the result rank. --- .../player-web/src/skins/phantom/chrome.ts | 8 --- .../player-web/src/skins/phantom/result.ts | 5 -- .../player-web/src/skins/phantom/style.ts | 21 ------ .../src/skins/synesthesia/gameplay-chrome.ts | 68 ++----------------- .../src/skins/synesthesia/moments.ts | 42 ++---------- .../src/skins/synesthesia/playfield.ts | 40 ++--------- .../src/skins/synesthesia/result.ts | 33 ++------- .../src/skins/synesthesia/select.ts | 17 +---- 8 files changed, 28 insertions(+), 206 deletions(-) diff --git a/packages/player-web/src/skins/phantom/chrome.ts b/packages/player-web/src/skins/phantom/chrome.ts index daf701bc..6be7a906 100644 --- a/packages/player-web/src/skins/phantom/chrome.ts +++ b/packages/player-web/src/skins/phantom/chrome.ts @@ -16,7 +16,6 @@ import { PHANTOM_SLATE, PHANTOM_WHITE, drawNoteEmblem, - drawStaves, halftoneField, parallelogramPoints, starburstPoints, @@ -219,13 +218,6 @@ function drawBackground(frame: Graphics, hole: BeMusicRect | undefined, drive: A } // One fill for the whole field instead of one per dot. frame.fill({ color: PHANTOM_INK, alpha: 0.55 }); - // Sheet music on the floor: two ink staves ruled across the wedge, following its slanted top edge. - const wedgeLeftAt = (lineY: number): number => - FLOOR_WEDGE[0]! + - ((DESIGN_HEIGHT - lineY) * (DESIGN_WIDTH - FLOOR_WEDGE[0]!)) / (DESIGN_HEIGHT - FLOOR_WEDGE[3]!) + - 6; - drawStaves(frame, 0, DESIGN_WIDTH, 404, 5, PHANTOM_INK, 0.55, wedgeLeftAt); - drawStaves(frame, 0, DESIGN_WIDTH, 446, 5, PHANTOM_INK, 0.55, wedgeLeftAt); // Paper-white cut line tracing the wedge's leading edge. frame .poly([FLOOR_WEDGE[0]! + 26, DESIGN_HEIGHT, DESIGN_WIDTH, 360, DESIGN_WIDTH, 363, FLOOR_WEDGE[0]! + 32, 480]) diff --git a/packages/player-web/src/skins/phantom/result.ts b/packages/player-web/src/skins/phantom/result.ts index 6533a615..80144c41 100644 --- a/packages/player-web/src/skins/phantom/result.ts +++ b/packages/player-web/src/skins/phantom/result.ts @@ -14,7 +14,6 @@ import { PHANTOM_SLATE, PHANTOM_WHITE, drawNoteEmblem, - drawStaves, halftoneField, parallelogramPoints, starburstPoints, @@ -181,10 +180,6 @@ export function renderPhantomResult(frame: PixiResultFrame): void { } } slash.g.poly([380, 48, 386, 48, 190, designHeight, 184, designHeight]).fill(PHANTOM_WHITE); - // Sheet-music staves ruled across the slash, following its slanted left edge. - const slashLeftAt = (lineY: number): number => 392 - ((lineY - 48) * (392 - 196)) / (designHeight - 48) + 8; - drawStaves(slash.g, 0, designWidth, 226, 6, PHANTOM_INK, 0.5, slashLeftAt); - drawStaves(slash.g, 0, designWidth, 432, 6, PHANTOM_INK, 0.5, slashLeftAt); slideIn(slash.root, 0, 460, 0, 380); slash.root.alpha = 1; diff --git a/packages/player-web/src/skins/phantom/style.ts b/packages/player-web/src/skins/phantom/style.ts index 29503ada..a3b7a395 100644 --- a/packages/player-web/src/skins/phantom/style.ts +++ b/packages/player-web/src/skins/phantom/style.ts @@ -159,27 +159,6 @@ export function drawNoteEmblem( .fill(color); } -/** - * Five-line stave rows across `[x0, x1]` at `y`, each line `gap` apart — sheet music woven into the poster ground. - * `clipLeftAt(y)` optionally narrows a line's start (to follow a slanted edge). - */ -export function drawStaves( - graphics: Graphics, - x0: number, - x1: number, - y: number, - gap: number, - color: number, - alpha: number, - clipLeftAt?: (lineY: number) => number, -): void { - for (let line = 0; line < 5; line += 1) { - const lineY = y + line * gap; - const start = Math.max(x0, clipLeftAt ? clipLeftAt(lineY) : x0); - if (start < x1) graphics.rect(start, lineY, x1 - start, 1).fill({ color, alpha }); - } -} - /** * 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 diff --git a/packages/player-web/src/skins/synesthesia/gameplay-chrome.ts b/packages/player-web/src/skins/synesthesia/gameplay-chrome.ts index beba7f25..670313fd 100644 --- a/packages/player-web/src/skins/synesthesia/gameplay-chrome.ts +++ b/packages/player-web/src/skins/synesthesia/gameplay-chrome.ts @@ -160,13 +160,13 @@ export function renderSynesthesiaChrome({ 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, pulse, hasBga, tier, hit, hitColor, horizon, drive); + 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, beatPhase, 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); @@ -284,19 +284,10 @@ const IMPULSE_COLORS: Record<'white' | 'black' | 'scratch', number> = { const VANISH = { x: CENTER_X, y: FLOOR.horizon - 90 } as const; /** - * Warm-black ground with an ember haze hugging the horizon (a stack of thin bands, so a live BGA stays untouched), plus - * a brief tint of the pressed lane's light on each key press. `horizon` follows the camera's pitch. + * 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, - pulse: number, - hasBga: boolean, - tier: number, - hit: number, - hitColor: number, - horizon: number, - drive: AudioDrive, -): void { +function drawGround(graphics: Graphics, hasBga: boolean, hit: number, hitColor: number, horizon: number): void { const bands: ReadonlyArray = [ [0x090302, 140], [0x060201, 260], @@ -311,14 +302,6 @@ function drawGround( if (hit > 0) { fillAroundBga(graphics, 0, 0, DESIGN_WIDTH, horizon, hitColor, hasBga, 0.06 * hit); } - const haze = (0.05 + 0.04 * pulse) * (1 + 0.35 * tier) * (1 + 1.4 * drive.bass); - for (let band = 0; band < 12; band += 1) { - const falloff = (1 - band / 12) ** 2; - const h = 6; - // Above and below the horizon line, fading out with distance. - fillAroundBga(graphics, 0, horizon - (band + 1) * h, DESIGN_WIDTH, h, SYN_EMBER, hasBga, haze * falloff); - fillAroundBga(graphics, 0, horizon + band * h * 0.7, DESIGN_WIDTH, h * 0.7, SYN_EMBER, hasBga, haze * falloff); - } } /** @@ -357,17 +340,13 @@ function drawDust( batch.flush(); } -/** - * Particle-world floor: a lattice of light points scrolling toward the player on the beat, over faint radial guide lines, with - * beat rings rolling out of the vanishing point once the run is deep in the zone. - */ +/** 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, - beatPhase: number, hit: number, camera: CameraPose, drive: AudioDrive, @@ -376,7 +355,6 @@ function drawFloor( const glow = 1 + 0.3 * tier + 0.8 * hit + 1.2 * drive.bass; const project = (x: number, z: number) => projectPoint(viewPoint({ x, y: height, z }, camera), CENTER_X, horizon, focal); - const vanish = vanishingPoint(camera, CENTER_X, horizon, focal); // 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; @@ -406,34 +384,10 @@ function drawFloor( } } sharedShapeBatch.flush(); - fillAroundBga(graphics, 0, vanish.y - 1, DESIGN_WIDTH, 2, SYN_AMBER, hasBga, (0.22 + 0.2 * pulse) * glow); - if (tier >= 3) { - for (const phase of [beatPhase, (beatPhase + 0.5) % 1]) { - const rx = 30 + phase * 520; - const cy = vanish.y + 4 + phase * 60; - pieces.length = 0; - // The ring as a polyline, so the arcs crossing a live BGA can be dropped. - for (let step = 0; step < RING_STEPS; step += 1) { - const a0 = (step / RING_STEPS) * Math.PI * 2; - const a1 = ((step + 1) / RING_STEPS) * Math.PI * 2; - const x0 = vanish.x + Math.cos(a0) * rx; - const y0 = cy + Math.sin(a0) * rx * 0.16; - const x1 = vanish.x + Math.cos(a1) * rx; - const y1 = cy + Math.sin(a1) * rx * 0.16; - if (hasBga) clipSegmentOutsideRect(x0, y0, x1, y1, BGA, pieces); - else pieces.push(x0, y0, x1, y1); - } - 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_AMBER, width: 1 + (1 - phase) * 2, alpha: 0.4 * (1 - phase) }); - } - } } /** Scratch buffer for clipped floor lines (`x0, y0, x1, y1` quads), reused every frame. */ const FLOOR_PIECES: number[] = []; -const RING_STEPS = 48; const RIVER_PARTICLES = 280; @@ -582,16 +536,6 @@ function drawBgaFrame( }; const cx = BGA.x + BGA.w / 2; const baseHorizon = BGA.y + BGA.h * 0.66; - const vanish = vanishingPoint(orbitCamera, cx, baseHorizon, 220); - const horizon = vanish.y; - // Ember horizon glow. - for (let band = 0; band < 10; band += 1) { - const falloff = (1 - band / 10) ** 2; - const alpha = (0.07 + 0.05 * pulse) * falloff; - light.rect(BGA.x, horizon - (band + 1) * 5, BGA.w, 5).fill({ color: SYN_EMBER, alpha }); - light.rect(BGA.x, horizon + band * 3, BGA.w, 3).fill({ color: SYN_EMBER, alpha: alpha * 0.8 }); - } - light.rect(BGA.x, horizon - 0.5, BGA.w, 1).fill({ color: SYN_AMBER, alpha: 0.6 }); const view = { cx, cy: baseHorizon, focal: 220, camera: orbitCamera, orbit }; // Particle pyramids on the horizon. for (const [pyramid, x, z, scale, yaw] of [ diff --git a/packages/player-web/src/skins/synesthesia/moments.ts b/packages/player-web/src/skins/synesthesia/moments.ts index e24614b1..05020838 100644 --- a/packages/player-web/src/skins/synesthesia/moments.ts +++ b/packages/player-web/src/skins/synesthesia/moments.ts @@ -10,7 +10,7 @@ import { rotateX, rotateY, } from './space.ts'; -import { SYN_AMBER, SYN_CYAN, SYN_DISPLAY_FONT, SYN_EMBER, SYN_FLARE, SYN_MAGENTA, SYN_WHITE } from './style.ts'; +import { SYN_AMBER, SYN_CYAN, SYN_DISPLAY_FONT, SYN_FLARE, SYN_MAGENTA, SYN_WHITE } from './style.ts'; import { easeOutCubic, type BeMusicRect, @@ -35,18 +35,16 @@ const CLEAR_MS = 1100; const FULL_COMBO_MS = 3400; const FULL_COMBO_BURST = burstParticles(77, 640); const MILESTONE_COLORS = [SYN_AMBER, SYN_CYAN, SYN_MAGENTA] as const; -const RING_COLORS = [SYN_AMBER, SYN_EMBER, SYN_CYAN, SYN_FLARE, 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 the first beat detonates a - * ring of light from the judgement line; - * - every 100 combo: a shockwave of light rolls out from the playfield and the count glows over the BGA monitor, - * caught in a lock-on reticle that snaps shut on it; + * - 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, rings of ember / - * blue / magenta light, and FULL COMBO glowing gold. + * - 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, @@ -140,20 +138,10 @@ function drawCountIn( ); text.alpha = form * fade; } - // First beat: a ring of light detonates off the judgement line. + // First beat: START flares out. const start = stageProgress(chartMs, 0, 900); if (start > 0 && start < 1) { const eased = easeOutCubic(start); - for (const [delay, strength] of [ - [0, 1], - [0.12, 0.55], - ] as const) { - const t = Math.max(0, (start - delay) / (1 - delay)); - if (t <= 0) continue; - graphics - .ellipse(playfieldCenterX, judgeY, 20 + 560 * easeOutCubic(t), (20 + 560 * easeOutCubic(t)) * 0.3) - .stroke({ color, width: 1 + 5 * (1 - t), alpha: 0.8 * strength * (1 - t) }); - } const text = addHudText( layer, 'START', @@ -194,12 +182,6 @@ function drawMilestone( 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; - // A shockwave of light rolls out from the count, kept inside the area. - const wave = easeOutCubic(Math.min(1, t / 0.7)); - const rx = 20 + (area.w / 2 - 20) * wave; - const ry = Math.min(rx * 0.42, area.h / 2); - graphics.ellipse(cx, cy, rx, ry).stroke({ color, width: 2 + 8 * (1 - wave), alpha: 0.7 * (1 - wave) }); - graphics.ellipse(cx, cy, rx * 0.8, ry * 0.8).stroke({ color: SYN_WHITE, width: 1.5, alpha: 0.5 * (1 - wave) }); const alpha = Math.min(1, t / 0.12) * (1 - Math.max(0, (t - 0.7) / 0.3)); const count = addHudText( layer, @@ -308,16 +290,6 @@ function drawFullCombo( .rect(point.x - size, point.y - size, size * 2, size * 2) .fill({ color: life < 0.2 ? SYN_WHITE : color, alpha: (1 - life) * fadeOut }); } - // Prism rings expanding from the centre. - for (let ring = 0; ring < 5; ring += 1) { - const ringT = (t * 2.2 + ring * 0.18) % 1; - const radius = 40 + ringT * 420; - graphics.ellipse(cx, cy, radius, radius * 0.55).stroke({ - color: RING_COLORS[ring % RING_COLORS.length]!, - width: 2 + 4 * (1 - ringT), - alpha: 0.5 * (1 - ringT) * fadeOut, - }); - } const form = easeOutCubic(Math.min(1, t / 0.3)); const color = emberColor(0.6 + 0.3 * Math.sin(nowMs / 260)); const text = addHudText( diff --git a/packages/player-web/src/skins/synesthesia/playfield.ts b/packages/player-web/src/skins/synesthesia/playfield.ts index f054787e..ab10caff 100644 --- a/packages/player-web/src/skins/synesthesia/playfield.ts +++ b/packages/player-web/src/skins/synesthesia/playfield.ts @@ -178,11 +178,10 @@ function cachedBurst(seed: number): BurstParticle[] { * 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, - * 6. a lock-on reticle that snaps shut on the hit and fades. + * 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. @@ -251,32 +250,7 @@ function renderBomb( .rect(bomb.x + 1, cy - pillarH, bomb.w - 2, pillarH) .fill({ fill: resolveColumnGradient(light.glow), alpha: 0.55 * fade * fade * crowd }); - // 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) * (0.5 + 0.5 * crowd), - }); - } - - // 3 + 4. Sparks: hairline trails and powder grains go through the shared batch (one fill per colour bucket instead of + // 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); @@ -338,7 +312,7 @@ function renderBomb( } batch.flush(); - // 5. Core flash + anamorphic streak. + // 4. Core flash + anamorphic streak. const flash = Math.max(0, 1 - t * 5); if (flash > 0) { const core = pool.acquireSprite(); @@ -352,7 +326,7 @@ function renderBomb( core.tint = hsvToHex(light.hue, t * 2, 1); core.alpha = flash * 0.7 * crowd; } - // 6. Lock-on: corner brackets snap in from wide to tight around the hit, then hold and fade. + // 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); diff --git a/packages/player-web/src/skins/synesthesia/result.ts b/packages/player-web/src/skins/synesthesia/result.ts index 7ff66a6d..7332ce86 100644 --- a/packages/player-web/src/skins/synesthesia/result.ts +++ b/packages/player-web/src/skins/synesthesia/result.ts @@ -6,7 +6,6 @@ import { emberColor, hsvToHex, projectPoint, - rotateX, rotateY, starfieldPoint, } from './space.ts'; @@ -67,8 +66,8 @@ export const synesthesiaResultSkin: PixiResultSkin = { render: (frame) => render /** * 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 inside a spinning 3D ring of particles that bursts - * outward the moment it lands — a lock-on reticle snapping shut on it. + * 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; @@ -107,7 +106,7 @@ export function renderSynesthesiaResult(frame: PixiResultFrame): void { 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, an ember horizon, dust pouring out of the vanishing point, and a floor of points. + // 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], @@ -127,11 +126,6 @@ export function renderSynesthesiaResult(frame: PixiResultFrame): void { const horizon = designHeight * 0.74; const vanishX = designWidth / 2; const vanishY = designHeight * 0.45; - for (let band = 0; band < 14; band += 1) { - const falloff = (1 - band / 14) ** 2; - light.rect(0, horizon - (band + 1) * 7, designWidth, 7).fill({ color: SYN_EMBER, alpha: 0.06 * falloff }); - light.rect(0, horizon + band * 4, designWidth, 4).fill({ color: SYN_EMBER, alpha: 0.05 * falloff }); - } 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); @@ -169,32 +163,15 @@ export function renderSynesthesiaResult(frame: PixiResultFrame): void { dropShadow: { color: verdictColor, distance: 0, blur: 12, alpha: 1 }, }); - // Rank: a spinning 3D particle ring that bursts outward as the letter ignites. + // Rank: sparks burst outward as the letter ignites. const rank = group('rank'); const rankCx = MARGIN + 88; - // The ring sits between the DJ LEVEL label and the rate, clear of both. + // 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 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 = 50 * 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 = index % 10 === 0 ? SYN_CYAN : emberColor(0.5 + 0.45 * Math.sin(index * 0.7 + seconds * 2) ** 2); - 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) { diff --git a/packages/player-web/src/skins/synesthesia/select.ts b/packages/player-web/src/skins/synesthesia/select.ts index 8c504caf..4cc2cd94 100644 --- a/packages/player-web/src/skins/synesthesia/select.ts +++ b/packages/player-web/src/skins/synesthesia/select.ts @@ -13,7 +13,6 @@ import { REST_CAMERA, roamingCamera, starfieldPoint, - vanishingPoint, viewPoint, wanderPoint, } from './space.ts'; @@ -102,8 +101,7 @@ function framePanel(graphics: Graphics, x: number, y: number, w: number, h: numb /** * 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 on an ember - * horizon, a floor of light points, a golden river of particles, and a roaming visualizer-style audio orb — redrawn cheaply + * 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 { @@ -204,7 +202,7 @@ class SynesthesiaSelectRenderer implements PixiSelectRenderer { ): 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 horizon and floor swell on the bass, + // 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; @@ -249,8 +247,7 @@ class SynesthesiaSelectRenderer implements PixiSelectRenderer { const world = this.world; world.clear(); const floorY = this.designHeight * 0.7; - const horizon = vanishingPoint(camera, cx, floorY, 200).y; - // Point-cloud pyramids on the horizon, far to near. + // 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], @@ -266,14 +263,6 @@ class SynesthesiaSelectRenderer implements PixiSelectRenderer { referenceScale: 200 / (200 + z), }); } - // Ember haze along the horizon. - for (let band = 0; band < 14; band += 1) { - const falloff = (1 - band / 14) ** 2; - const alpha = (0.05 + 0.04 * pulse) * falloff * (1 + 1.4 * drive.bass); - world.rect(0, horizon - (band + 1) * 7, this.designWidth, 7).fill({ color: SYN_EMBER, alpha }); - world.rect(0, horizon + band * 4, this.designWidth, 4).fill({ color: SYN_EMBER, alpha: alpha * 0.8 }); - } - world.rect(0, horizon - 1, this.designWidth, 2).fill({ color: SYN_AMBER, alpha: 0.35 + 0.25 * pulse }); // Floor of light points scrolling toward the viewer. const project = (x: number, z: number) => projectPoint(viewPoint({ x, y: 150, z }, camera, WORLD_ORBIT), cx, floorY, 200); From 27fa63392a6e7d84c6a70e1f5c53201c7d22b9e2 Mon Sep 17 00:00:00 2001 From: Seiya KONNO Date: Fri, 9 Oct 2026 17:28:52 +0900 Subject: [PATCH 56/65] feat(player-web): dash through black space with the title centred on Synesthesia's launch Picking a chart now fades the list and panels away, settles the roaming camera, and steers the vanishing point to the screen centre, then floors the throttle so the camera dashes dead straight through a denser particle field in black space; near particles swell as they rush past and the orb, floor, and pyramids fall behind. The chosen title condenses at the centre out of wide-tracked light with its artist under it while a lock-on reticle snaps shut around it, replacing the off-centre title and the warm bloom that washed the screen. --- .../src/skins/synesthesia/select.ts | 98 +++++++++++++------ 1 file changed, 70 insertions(+), 28 deletions(-) diff --git a/packages/player-web/src/skins/synesthesia/select.ts b/packages/player-web/src/skins/synesthesia/select.ts index 4cc2cd94..3822874e 100644 --- a/packages/player-web/src/skins/synesthesia/select.ts +++ b/packages/player-web/src/skins/synesthesia/select.ts @@ -63,7 +63,9 @@ 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; +const WARP_STAR_COUNT = 900; const RIVER_PARTICLES = 340; const ORB_SHELL = fibonacciSphere(900, 23); const ORB = orbitParticles(23, 30); @@ -101,8 +103,8 @@ function framePanel(graphics: Graphics, x: number, y: number, w: number, h: numb /** * 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. + * 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(); @@ -111,6 +113,8 @@ class SynesthesiaSelectRenderer implements PixiSelectRenderer { /** 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(); private readonly lock = new Graphics(); private readonly cursorGlow = new Sprite(); private cursorChangedAt = Number.NEGATIVE_INFINITY; @@ -163,35 +167,58 @@ class SynesthesiaSelectRenderer implements PixiSelectRenderer { } /** - * Launch outro — the warp: the star field rushes forward (in `tick`), a white bloom opens from the - * vanishing point with the chosen title in it, and the screen falls to black for the gameplay count-in. + * 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 g = new Graphics(); - g.label = 'synesthesia-select/outro'; - const cx = designWidth * 0.58; - const cy = designHeight * 0.44; - const bloom = easeOutCubic(Math.min(1, t / 0.7)); - for (let ring = 5; ring >= 1; ring -= 1) { - g.circle(cx, cy, (40 + 520 * bloom) * (ring / 5)).fill({ color: SYN_FLARE, alpha: 0.1 * bloom }); - } + const song = frame.focusedSong; + const cx = designWidth / 2; + const cy = designHeight / 2; const dark = Math.max(0, (t - 0.72) / 0.28); - if (dark > 0) g.rect(0, 0, designWidth, designHeight).fill({ color: SYN_VOID, alpha: dark }); - layer.addChild(g); - addSkinText(layer, frame.focusedSong?.title ?? '', cx, cy, { - size: 22, + // 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 + 10 * bloom, + letterSpacing: 2 + 14 * (1 - form), anchorX: 0.5, anchorY: 0.5, - maxWidth: designWidth - 80, - alpha: Math.min(1, t * 3) * (1 - dark), + 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( @@ -218,32 +245,47 @@ class SynesthesiaSelectRenderer implements PixiSelectRenderer { // 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, + (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. - const cx = this.designWidth * 0.58; - const cy = this.designHeight * 0.44; - const speed = (80 + beatsPerSecond * 55) * (1 + 16 * warp * warp) * (1 + 1.2 * drive.level); + // 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 floors the throttle. + const cx = this.designWidth * (0.58 - 0.08 * settle); + const cy = this.designHeight * (0.44 + 0.06 * settle); + const speed = (80 + beatsPerSecond * 55) * (1 + 60 * warp * warp) * (1 + 1.2 * drive.level); this.travel += dt * speed * (warp > 0 ? 1 : rate); + 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 = starfieldPoint(index, this.travel, { spread: 560, near: 10, far: 1000, speed: 1 }); const projected = projectPoint(viewPoint(point, camera), cx, cy, 200); star.visible = projected.visible; if (!projected.visible) continue; const nearness = Math.min(1, projected.scale); - const size = 1 + 3 * nearness * nearness; + // 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 = 0.2 + 0.8 * nearness; + 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); } + // 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.ground) node.alpha = behind; + } const world = this.world; world.clear(); const floorY = this.designHeight * 0.7; @@ -412,9 +454,9 @@ class SynesthesiaSelectRenderer implements PixiSelectRenderer { top = bottom; } const glow = synGlowTexture(); - const starLayer = new Container(); + const starLayer = this.starLayer; starLayer.blendMode = 'add'; - for (let index = 0; index < STAR_COUNT; index += 1) { + for (let index = 0; index < WARP_STAR_COUNT; index += 1) { const star = new Sprite(glow); star.anchor.set(0.5); star.blendMode = 'add'; From 8647502d7a2bf4515c31a7ac0abdf39faa970493 Mon Sep 17 00:00:00 2001 From: Seiya KONNO Date: Fri, 9 Oct 2026 17:30:12 +0900 Subject: [PATCH 57/65] feat(player-web): trail the particles through Synesthesia's launch warp The warp streamed the field so fast that particles jumped across the screen between frames and read as scattering rather than moving forward. The peak speed now stays where the eye can follow it, and during the warp each particle draws a short trail back along its own path toward the vanishing point, so the dash through black space reads as forward motion. --- .../src/skins/synesthesia/select.ts | 42 ++++++++++++++++--- 1 file changed, 37 insertions(+), 5 deletions(-) diff --git a/packages/player-web/src/skins/synesthesia/select.ts b/packages/player-web/src/skins/synesthesia/select.ts index 3822874e..0ba36e97 100644 --- a/packages/player-web/src/skins/synesthesia/select.ts +++ b/packages/player-web/src/skins/synesthesia/select.ts @@ -65,6 +65,12 @@ 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); @@ -115,6 +121,8 @@ class SynesthesiaSelectRenderer implements PixiSelectRenderer { private readonly stars: Sprite[] = []; /** Holds {@link stars}; the one layer that stays lit through the launch warp. */ private readonly starLayer = new Container(); + /** 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; @@ -253,10 +261,15 @@ class SynesthesiaSelectRenderer implements PixiSelectRenderer { (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 floors the throttle. + // 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 + 60 * warp * warp) * (1 + 1.2 * drive.level); + 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 shownStars = Math.round(STAR_COUNT + (WARP_STAR_COUNT - STAR_COUNT) * settle); for (let index = 0; index < this.stars.length; index += 1) { @@ -265,7 +278,7 @@ class SynesthesiaSelectRenderer implements PixiSelectRenderer { star.visible = false; continue; } - const point = starfieldPoint(index, this.travel, { spread: 560, near: 10, far: 1000, speed: 1 }); + const point = starfieldPoint(index, this.travel, STARFIELD); const projected = projectPoint(viewPoint(point, camera), cx, cy, 200); star.visible = projected.visible; if (!projected.visible) continue; @@ -278,13 +291,31 @@ class SynesthesiaSelectRenderer implements PixiSelectRenderer { 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 = projectPoint(viewPoint({ x: point.x, y: point.y, z: tailZ }, camera), 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.ground) node.alpha = behind; + if (node !== this.starLayer && node !== this.trails && node !== this.ground) node.alpha = behind; } const world = this.world; world.clear(); @@ -466,7 +497,8 @@ class SynesthesiaSelectRenderer implements PixiSelectRenderer { this.world.blendMode = 'add'; this.orbHost.blendMode = 'add'; this.orbFront.blendMode = 'add'; - this.backLayer.addChild(this.ground, this.world, starLayer, this.orbMoon, this.orbFront, this.orbHost); + 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); From 3bda2c7e84e3928b9ba2e5226fcbb3e3b7443621 Mon Sep 17 00:00:00 2001 From: Seiya KONNO Date: Fri, 9 Oct 2026 17:30:33 +0900 Subject: [PATCH 58/65] chore: record the skin adjustments in the player-web changeset [ci skip] --- .changeset/be-music-skins.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/.changeset/be-music-skins.md b/.changeset/be-music-skins.md index 1f4791fc..529283df 100644 --- a/.changeset/be-music-skins.md +++ b/.changeset/be-music-skins.md @@ -13,7 +13,7 @@ The default (skinless) family now renders through a `BeMusicSkin`: a skin that d - 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 lit by an ember horizon, 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 — tilted shockwave rings, 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 a 3D particle ring that bursts as the rank ignites. It draws with Michroma and M PLUS 1p, which hosts should load. +- 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. @@ -23,16 +23,16 @@ The default (skinless) family now renders through a `BeMusicSkin`: a skin that d - 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 detonates a ring of light), a cut-in every 100 combo, a flourish when the gauge crosses the clear line, and a full-combo finale (Phantom's turning burst with confetti, Synesthesia's 3D spark sphere and prism rings). 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 and beat rings appear, and hits throw more sparks further. Overlapping hits now dim their white flash so colour stays the hero. +- 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 warp into a white-out) before gameplay begins; input is ignored while it plays. +- 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 monitor's horizon, 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. +- 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. @@ -41,3 +41,6 @@ The default (skinless) family now renders through a `BeMusicSkin`: a skin that d - `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 Pixi-based built-in skins do less work per frame: notes share one Graphics, the select screen keeps unchanged labels' text textures through its transitions, and skin canvases no longer preserve their drawing buffer. From 51ea831ef24288cfab3e1b182c62c6c39e42c069 Mon Sep 17 00:00:00 2001 From: Seiya KONNO Date: Fri, 9 Oct 2026 17:35:00 +0900 Subject: [PATCH 59/65] perf(player-web): cache Phantom's static ground and halftone fields The ink ground, pinstripes, floor wedge, and the idle monitor's field were re-tessellated every gameplay frame, including about 870 and 560 halftone dots. They now come from cached Graphics contexts under the pooled chrome, keyed by the BGA rect and by the bass / loudness level quantized to 1/32 (under 0.2 px of radius), so the picture is unchanged. Phantom gameplay main-thread time drops from about 200-290 to 125-145 ms per second. --- .../player-web/src/skins/phantom/chrome.ts | 155 ++++++++++++++---- 1 file changed, 125 insertions(+), 30 deletions(-) diff --git a/packages/player-web/src/skins/phantom/chrome.ts b/packages/player-web/src/skins/phantom/chrome.ts index 6be7a906..f7bbd9d3 100644 --- a/packages/player-web/src/skins/phantom/chrome.ts +++ b/packages/player-web/src/skins/phantom/chrome.ts @@ -1,4 +1,4 @@ -import { Container, Graphics } from 'pixi.js'; +import { Container, Graphics, GraphicsContext } from 'pixi.js'; import { GROOVE } from './layout.ts'; import { DEFAULT_DISPLAY_FONT, DEFAULT_HEADLINE_FONT } from './fonts.ts'; import { @@ -137,7 +137,16 @@ function renderChromeFrame( frame.label = 'default-gameplay/chrome'; // 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); - drawBackground(frame, hasBga ? bga : undefined, drive); + 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)); + } const effects = effectProfile(runtime.effects); drawPlayfield( @@ -148,6 +157,7 @@ function renderChromeFrame( ); // 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); @@ -184,46 +194,143 @@ function renderChromeFrame( */ 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, hole: BeMusicRect | undefined, drive: AudioDrive): void { - fillRectAroundHole(frame, 0, 0, DESIGN_WIDTH, DESIGN_HEIGHT, PHANTOM_BLACK, hole); +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 < 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 + // 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: 352, w: DESIGN_WIDTH - 300, h: DESIGN_HEIGHT - 352, pitch: 9, - maxRadius: 4.2 * (1 + 1.2 * drive.bass), + 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); + 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. - frame.fill({ color: PHANTOM_INK, alpha: 0.55 }); + context.fill({ color: PHANTOM_INK, alpha: 0.55 }); // Paper-white cut line tracing the wedge's leading edge. - frame + 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. @@ -232,7 +339,7 @@ function isInsideFloorWedge(x: number, y: number): boolean { /** 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, @@ -359,21 +466,9 @@ function drawBgaFrame( 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 * (1 + 1.1 * drive.level), - direction: { x: -0.6, y: 1 }, - })) { - frame.circle(dot.x, dot.y, dot.r); - } - frame.fill({ color: PHANTOM_RED, alpha: 0.75 }); // Slow-turning starburst behind the slug — the idle screen is alive, not a hole in the cabinet. // 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; From 89b97e864647e9884bc623032f0d5966e72fc647 Mon Sep 17 00:00:00 2001 From: Seiya KONNO Date: Fri, 9 Oct 2026 17:37:32 +0900 Subject: [PATCH 60/65] perf(player-web): rebuild less of the built-in result screens each frame Lattice's result paper and grid are drawn once into a persistent backdrop the Pixi kit now gives result renderers, and its needle field updates there in place as GPU particles instead of thousands of stroked needles rebuilt every frame. Phantom's result slash halftone and Synesthesia's result floor rows fill once instead of once per dot (the dots never overlap). The kit's text style caches are bounded, since effects that animate a style would otherwise grow them every frame. Synesthesia's result also loses the amber horizon line the horizon removal missed. --- .../player-web/src/skins/lattice/result.ts | 42 ++++++++++++++++--- .../player-web/src/skins/phantom/result.ts | 4 +- .../src/skins/pixi-kit/define-pixi-skin.ts | 9 ++-- .../src/skins/pixi-kit/hud-text.test.ts | 17 ++++++++ .../player-web/src/skins/pixi-kit/hud-text.ts | 16 ++++++- .../src/skins/pixi-kit/skin-text.ts | 3 +- .../player-web/src/skins/pixi-kit/types.ts | 5 +++ .../src/skins/synesthesia/result.ts | 5 ++- 8 files changed, 87 insertions(+), 14 deletions(-) create mode 100644 packages/player-web/src/skins/pixi-kit/hud-text.test.ts diff --git a/packages/player-web/src/skins/lattice/result.ts b/packages/player-web/src/skins/lattice/result.ts index f2e1add2..53cdd63b 100644 --- a/packages/player-web/src/skins/lattice/result.ts +++ b/packages/player-web/src/skins/lattice/result.ts @@ -1,4 +1,4 @@ -import { Graphics } from 'pixi.js'; +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'; @@ -42,7 +42,7 @@ function display(size: number, fill: number, weight: SkinTextOptions['weight'] = * 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 } = frame; + const { result, designWidth, designHeight, nowMs, rankLabel, layer, backdrop } = frame; const elapsed = frame.effects === 'off' ? Number.POSITIVE_INFINITY : frame.elapsedMs; const motion = frame.effects === 'off' ? 0 : frame.effects === 'reduced' ? 0.5 : 1; const cleared = result.cleared; @@ -64,13 +64,15 @@ export function renderLatticeResult(frame: PixiResultFrame): void { const decode = (value: string, delayMs: number, seed: number, durationMs = 460) => scrambleText(value, stageProgress(elapsed, delayMs, durationMs), tick, seed); - g.rect(0, 0, designWidth, designHeight).fill(LAT_PAPER); - drawPaperGrid(g, { x: 0, y: 0, w: designWidth, h: designHeight }, 0.45); + // 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( - g, + ground.field, { x: 0, y: 44, w: designWidth, h: designHeight - 44 }, { seconds: (nowMs / 1000) * motion, @@ -82,6 +84,7 @@ export function renderLatticeResult(frame: PixiResultFrame): void { { alpha: 0.4, skip: (x, y) => (x > 14 && x < designWidth - 14 && y > 56 && y < 430) || y > designHeight - 50, + particles: true, }, ); @@ -289,3 +292,32 @@ function plot( 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/phantom/result.ts b/packages/player-web/src/skins/phantom/result.ts index 80144c41..aff3ab4d 100644 --- a/packages/player-web/src/skins/phantom/result.ts +++ b/packages/player-web/src/skins/phantom/result.ts @@ -176,9 +176,11 @@ export function renderPhantomResult(frame: PixiResultFrame): 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; 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 index f617ae83..edc5df3a 100644 --- a/packages/player-web/src/skins/pixi-kit/define-pixi-skin.ts +++ b/packages/player-web/src/skins/pixi-kit/define-pixi-skin.ts @@ -255,21 +255,24 @@ class SelectScreen { /** 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 }); - return this.layer; + this.definition.result.render({ ...frame, layer: this.layer, backdrop: this.backdrop }); + return this.root; } destroy(): void { this.disposer.flush(); - this.layer.destroy({ children: true }); + 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/skins/pixi-kit/hud-text.ts b/packages/player-web/src/skins/pixi-kit/hud-text.ts index 2ef4b8ca..9c76b7c2 100644 --- a/packages/player-web/src/skins/pixi-kit/hud-text.ts +++ b/packages/player-web/src/skins/pixi-kit/hud-text.ts @@ -104,7 +104,7 @@ export function addHudNumber( } } -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); @@ -169,9 +169,21 @@ function resolveTextStyle(opts: HudTextOptions, whiteFill = false): 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/skin-text.ts b/packages/player-web/src/skins/pixi-kit/skin-text.ts index 21584d18..e709fb89 100644 --- a/packages/player-web/src/skins/pixi-kit/skin-text.ts +++ b/packages/player-web/src/skins/pixi-kit/skin-text.ts @@ -1,5 +1,6 @@ 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'; @@ -84,7 +85,7 @@ export function resolveSkinTextStyle(options: SkinTextOptions): TextStyle { } : {}), }); - SKIN_TEXT_STYLES.set(key, style); + rememberStyle(SKIN_TEXT_STYLES, key, style); } return style; } diff --git a/packages/player-web/src/skins/pixi-kit/types.ts b/packages/player-web/src/skins/pixi-kit/types.ts index 1e9b7965..2c7f1451 100644 --- a/packages/player-web/src/skins/pixi-kit/types.ts +++ b/packages/player-web/src/skins/pixi-kit/types.ts @@ -134,6 +134,11 @@ export interface PixiSelectSkin { 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 { diff --git a/packages/player-web/src/skins/synesthesia/result.ts b/packages/player-web/src/skins/synesthesia/result.ts index 7332ce86..793b86fd 100644 --- a/packages/player-web/src/skins/synesthesia/result.ts +++ b/packages/player-web/src/skins/synesthesia/result.ts @@ -147,10 +147,11 @@ export function renderSynesthesiaResult(frame: PixiResultFrame): void { 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).fill({ color, alpha }); + 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 }); } - light.rect(0, horizon - 1, designWidth, 2).fill({ color: SYN_AMBER, alpha: 0.4 }); // Verdict: tracking condenses from wide to settled while it fades in. const verdict = group('verdict'); From acd0a2c0566dd7a0a1d7726766904836dd72e973 Mon Sep 17 00:00:00 2001 From: Seiya KONNO Date: Fri, 9 Oct 2026 17:46:39 +0900 Subject: [PATCH 61/65] feat(player-web): smooth the built-in skins' screen transitions The play screen used to cut in at full brightness right after a select outro ended in black (a white flash on Lattice's paper), and cut straight to the result mid-way through the full-combo showpiece. With a be-music skin it now fades up from black over 0.4 s as it appears and fades to black, together with the audio, over 0.45 s before the result or select screen takes over. The full-combo showpieces are tightened (Phantom 3.0 to 2.4 s, Synesthesia 3.4 to 2.6 s, Lattice 3.2 to 2.6 s) so they close within the post-chart hold, and the result entrances play their timelines 1.3x faster so the cards settle sooner. --- .../player-web/src/scene/core/gameplay.ts | 26 +++++++++++++++++-- .../player-web/src/skins/lattice/moments.ts | 2 +- .../player-web/src/skins/lattice/result.ts | 5 +++- .../player-web/src/skins/phantom/moments.ts | 2 +- .../player-web/src/skins/phantom/result.ts | 5 +++- .../src/skins/synesthesia/moments.ts | 2 +- .../src/skins/synesthesia/result.ts | 5 +++- 7 files changed, 39 insertions(+), 8 deletions(-) diff --git a/packages/player-web/src/scene/core/gameplay.ts b/packages/player-web/src/scene/core/gameplay.ts index f05e7825..8864369d 100644 --- a/packages/player-web/src/scene/core/gameplay.ts +++ b/packages/player-web/src/scene/core/gameplay.ts @@ -312,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. @@ -1127,9 +1135,17 @@ export class CoreGameplayView renderPhan 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; + const elapsed = frame.effects === 'off' ? Number.POSITIVE_INFINITY : frame.elapsedMs * ENTRANCE_PACE; const rate = frame.ratePercent; const seconds = now / 1000; const layer = frame.layer; diff --git a/packages/player-web/src/skins/synesthesia/moments.ts b/packages/player-web/src/skins/synesthesia/moments.ts index 05020838..3b9c8308 100644 --- a/packages/player-web/src/skins/synesthesia/moments.ts +++ b/packages/player-web/src/skins/synesthesia/moments.ts @@ -32,7 +32,7 @@ const NO_MONITOR: BeMusicRect = { x: 0, y: 0, w: 0, h: 0 }; const MILESTONE_MS = 1300; const CLEAR_MS = 1100; -const FULL_COMBO_MS = 3400; +const FULL_COMBO_MS = 2600; const FULL_COMBO_BURST = burstParticles(77, 640); const MILESTONE_COLORS = [SYN_AMBER, SYN_CYAN, SYN_MAGENTA] as const; diff --git a/packages/player-web/src/skins/synesthesia/result.ts b/packages/player-web/src/skins/synesthesia/result.ts index 793b86fd..03242249 100644 --- a/packages/player-web/src/skins/synesthesia/result.ts +++ b/packages/player-web/src/skins/synesthesia/result.ts @@ -35,6 +35,9 @@ import { } 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; @@ -72,7 +75,7 @@ export const synesthesiaResultSkin: PixiResultSkin = { render: (frame) => render 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; + 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); From 4355869f37d7433eb0e2c75d7ad7884362d616a0 Mon Sep 17 00:00:00 2001 From: Seiya KONNO Date: Fri, 9 Oct 2026 18:05:52 +0900 Subject: [PATCH 62/65] perf(player-web): project Synesthesia's particle fields without allocating The dust, floor lattice, and star field projected each of their thousands of points through viewPoint / projectPoint / starfieldPoint, allocating several objects per point per frame. projectViewInto and starfieldInto do the same maths into reused scratch objects with the camera's trigonometry worked out once per frame, so those loops stop feeding the garbage collector. The projected positions are unchanged (covered by tests against the allocating helpers). --- .../src/skins/synesthesia/gameplay-chrome.ts | 25 ++++-- .../src/skins/synesthesia/select.ts | 21 +++-- .../src/skins/synesthesia/space.test.ts | 36 +++++++++ .../player-web/src/skins/synesthesia/space.ts | 80 +++++++++++++++++++ 4 files changed, 147 insertions(+), 15 deletions(-) diff --git a/packages/player-web/src/skins/synesthesia/gameplay-chrome.ts b/packages/player-web/src/skins/synesthesia/gameplay-chrome.ts index 670313fd..a1551d43 100644 --- a/packages/player-web/src/skins/synesthesia/gameplay-chrome.ts +++ b/packages/player-web/src/skins/synesthesia/gameplay-chrome.ts @@ -6,6 +6,7 @@ import { drawFrame, drawMagnetoOrb, drawPointCloud, drawReticle, drawSchool, sha import { clipSegmentOutsideRect, emberColor, + cameraBasis, hsvToHex, mixCamera, particleRiverPoint, @@ -13,9 +14,11 @@ import { orbitParticles, pointCloudPyramid, projectPoint, + projectViewInto, REST_CAMERA, roamingCamera, - starfieldPoint, + scratchProjected, + starfieldInto, vanishingPoint, viewPoint, wanderPoint, @@ -321,9 +324,11 @@ function drawDust( 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 = starfieldPoint(index, seconds, { spread: 640, near: 20, far: 900, speed }); - const projected = projectPoint(viewPoint(point, camera), VANISH.x, VANISH.y, 180); + 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; @@ -353,14 +358,13 @@ function drawFloor( ): void { const { horizon, height, focal } = FLOOR; const glow = 1 + 0.3 * tier + 0.8 * hit + 1.2 * drive.bass; - const project = (x: number, z: number) => - projectPoint(viewPoint({ x, y: height, z }, camera), CENTER_X, horizon, focal); + 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 = project(x, 0); - const far = project(x, 2400); + 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); @@ -377,7 +381,7 @@ function drawFloor( 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 = project(x, z); + 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); @@ -388,6 +392,11 @@ function drawFloor( /** 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; diff --git a/packages/player-web/src/skins/synesthesia/select.ts b/packages/player-web/src/skins/synesthesia/select.ts index 0ba36e97..4040e1c9 100644 --- a/packages/player-web/src/skins/synesthesia/select.ts +++ b/packages/player-web/src/skins/synesthesia/select.ts @@ -2,6 +2,7 @@ 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, @@ -10,9 +11,11 @@ import { orbitParticles, pointCloudPyramid, projectPoint, + projectViewInto, REST_CAMERA, roamingCamera, - starfieldPoint, + scratchProjected, + starfieldInto, viewPoint, wanderPoint, } from './space.ts'; @@ -121,6 +124,11 @@ class SynesthesiaSelectRenderer implements PixiSelectRenderer { 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(); @@ -271,6 +279,7 @@ class SynesthesiaSelectRenderer implements PixiSelectRenderer { 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]!; @@ -278,8 +287,8 @@ class SynesthesiaSelectRenderer implements PixiSelectRenderer { star.visible = false; continue; } - const point = starfieldPoint(index, this.travel, STARFIELD); - const projected = projectPoint(viewPoint(point, camera), cx, cy, 200); + 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); @@ -294,7 +303,7 @@ class SynesthesiaSelectRenderer implements PixiSelectRenderer { 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 = projectPoint(viewPoint({ x: point.x, y: point.y, z: tailZ }, camera), cx, cy, 200); + const tail = projectViewInto(this.scratchTail, point.x, point.y, tailZ, basis, cx, cy, 200); if (tail.visible) { sharedShapeBatch.line( trails, @@ -337,8 +346,6 @@ class SynesthesiaSelectRenderer implements PixiSelectRenderer { }); } // Floor of light points scrolling toward the viewer. - const project = (x: number, z: number) => - projectPoint(viewPoint({ x, y: 150, z }, camera, WORLD_ORBIT), cx, floorY, 200); const spacing = 100; const offset = this.travel % spacing; // Floor points and the river go out as a few batched instructions. @@ -349,7 +356,7 @@ class SynesthesiaSelectRenderer implements PixiSelectRenderer { 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 = project(x, z); + 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; } diff --git a/packages/player-web/src/skins/synesthesia/space.test.ts b/packages/player-web/src/skins/synesthesia/space.test.ts index 321274f3..cf7c44c3 100644 --- a/packages/player-web/src/skins/synesthesia/space.test.ts +++ b/packages/player-web/src/skins/synesthesia/space.test.ts @@ -3,6 +3,7 @@ import { burstParticlePosition, clipSegmentOutsideRect, burstParticles, + cameraBasis, emberColor, fibonacciSphere, limbGlow, @@ -13,12 +14,15 @@ import { particleRiverPoint, pointCloudPyramid, projectPoint, + projectViewInto, randomShot, reflectedLight, REST_CAMERA, roamingCamera, rotateX, rotateY, + scratchProjected, + starfieldInto, starfieldPoint, vanishingPoint, viewPoint, @@ -404,3 +408,35 @@ describe('clipSegmentOutsideRect', () => { 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 index a5be3f62..d8354da3 100644 --- a/packages/player-web/src/skins/synesthesia/space.ts +++ b/packages/player-web/src/skins/synesthesia/space.ts @@ -34,6 +34,71 @@ export function projectPoint(point: Vec3, cx: number, cy: number, focal: number) 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); @@ -105,6 +170,21 @@ export function burstParticlePosition(particle: BurstParticle, t: number, radius * 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, From d31bdf6acd26e41325de623aa1917367a6c70f2a Mon Sep 17 00:00:00 2001 From: Seiya KONNO Date: Fri, 9 Oct 2026 18:09:39 +0900 Subject: [PATCH 63/65] perf(player-web): work out Lattice's needle field by row and column The needle field's base angle splits into a column term (rest angle, flow, beat sweep) and a row term, now worked out once per column and row instead of once per needle; ripples skip the square root and exponential outside the band around their front (where they would turn a needle by under a micro-radian); and the clean areas are passed as rects checked per row instead of a closure tested for every needle. The needles' angles are unchanged. --- packages/player-web/src/skins/lattice/draw.ts | 38 +++++++++++++++++-- .../src/skins/lattice/field.test.ts | 25 ++++++++++++ .../player-web/src/skins/lattice/field.ts | 36 ++++++++++++++++-- .../src/skins/lattice/gameplay-chrome.ts | 6 +-- .../player-web/src/skins/lattice/result.ts | 8 +++- .../player-web/src/skins/lattice/select.ts | 3 +- 6 files changed, 102 insertions(+), 14 deletions(-) diff --git a/packages/player-web/src/skins/lattice/draw.ts b/packages/player-web/src/skins/lattice/draw.ts index abe92551..3fda64a7 100644 --- a/packages/player-web/src/skins/lattice/draw.ts +++ b/packages/player-web/src/skins/lattice/draw.ts @@ -1,5 +1,12 @@ import type { Container, Graphics } from 'pixi.js'; -import { digitTransitions, needleAngle, springEase, type NeedleFieldInput } from './field.ts'; +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, @@ -68,6 +75,8 @@ export function drawNeedleField( 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 { @@ -81,10 +90,22 @@ export function drawNeedleField( 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) { - for (let x = x0; x <= area.x + area.w; x += 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 = needleAngle(x, y, input); + 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; @@ -108,6 +129,17 @@ export function drawNeedleField( 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, diff --git a/packages/player-web/src/skins/lattice/field.test.ts b/packages/player-web/src/skins/lattice/field.test.ts index bdebe3b2..c9356512 100644 --- a/packages/player-web/src/skins/lattice/field.test.ts +++ b/packages/player-web/src/skins/lattice/field.test.ts @@ -4,7 +4,10 @@ import { countInStep, digitTransitions, mixLineAngle, + bendNeedle, needleAngle, + needleColumnAngle, + needleRowAngle, scrambleText, scrambleTick, springEase, @@ -195,3 +198,25 @@ describe('scrambleTick', () => { 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 index 8f77e504..32edaba6 100644 --- a/packages/player-web/src/skins/lattice/field.ts +++ b/packages/player-web/src/skins/lattice/field.ts @@ -39,18 +39,48 @@ 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 { - const s = input.seconds; - let angle = REST_ANGLE + input.flow * (0.5 * Math.sin(x * 0.017 + s * 0.6) + 0.5 * Math.cos(y * 0.023 - s * 0.45)); + 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 r = Math.hypot(x - ripple.x, y - ripple.y); + 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); } diff --git a/packages/player-web/src/skins/lattice/gameplay-chrome.ts b/packages/player-web/src/skins/lattice/gameplay-chrome.ts index 3520cbb9..5b00644a 100644 --- a/packages/player-web/src/skins/lattice/gameplay-chrome.ts +++ b/packages/player-web/src/skins/lattice/gameplay-chrome.ts @@ -144,7 +144,7 @@ export function renderLatticeChrome({ resolveFieldInput(layer, runtime, moments, tier, effects.amount, layout, drive), { alpha: 0.55, - skip: (x, y) => clean.some((rect) => inside(rect, x, y, 0)), + clean, particles: true, }, ); @@ -410,10 +410,6 @@ function ensureStaticPaper(layer: Container, hasBga: boolean): void { drawPaperGrid(entry.graphics, { x: 0, y: 0, w: DESIGN_WIDTH, h: DESIGN_HEIGHT }, 0.45, hasBga ? BGA : undefined); } -function inside(rect: Rect, x: number, y: number, margin: number): boolean { - return x > rect.x - margin && x < rect.x + rect.w + margin && y > rect.y - margin && y < rect.y + rect.h + margin; -} - function fillAroundBga( graphics: Graphics, x: number, diff --git a/packages/player-web/src/skins/lattice/result.ts b/packages/player-web/src/skins/lattice/result.ts index 11eaaca9..5ed2d068 100644 --- a/packages/player-web/src/skins/lattice/result.ts +++ b/packages/player-web/src/skins/lattice/result.ts @@ -25,6 +25,8 @@ import { addSkinText, type PixiResultFrame, type PixiResultSkin, type SkinTextOp /** 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; @@ -86,7 +88,11 @@ export function renderLatticeResult(frame: PixiResultFrame): void { }, { alpha: 0.4, - skip: (x, y) => (x > 14 && x < designWidth - 14 && y > 56 && y < 430) || y > designHeight - 50, + // 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, }, ); diff --git a/packages/player-web/src/skins/lattice/select.ts b/packages/player-web/src/skins/lattice/select.ts index 2133dbc1..f077d3c7 100644 --- a/packages/player-web/src/skins/lattice/select.ts +++ b/packages/player-web/src/skins/lattice/select.ts @@ -173,8 +173,7 @@ class LatticeSelectRenderer implements PixiSelectRenderer { }, { alpha: 0.5, - skip: (x, y) => - this.occupied.some((rect) => x > rect.x && x < rect.x + rect.w && y > rect.y && y < rect.y + rect.h), + clean: this.occupied, particles: true, }, ); From 2a3db9e3e515392a19afa020e24735c2052ffebd Mon Sep 17 00:00:00 2001 From: Seiya KONNO Date: Fri, 9 Oct 2026 18:10:47 +0900 Subject: [PATCH 64/65] perf(player-web): reuse Plain's beam gradient and font strings Plain created a gradient for every lit lane every frame and rebuilt and re-set the same font string for every label. It now keeps one beam gradient per context and lane height, scaling the beam with globalAlpha (the same colour as before), memoizes its font strings, and only assigns ctx.font when it changes. The code stays as plain as the example skin should be. --- .../player-web/src/skins/plain/gameplay.ts | 24 +++++++++++++++---- packages/player-web/src/skins/plain/theme.ts | 21 +++++++++++++--- 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/packages/player-web/src/skins/plain/gameplay.ts b/packages/player-web/src/skins/plain/gameplay.ts index 460b0cf1..bde42887 100644 --- a/packages/player-web/src/skins/plain/gameplay.ts +++ b/packages/player-web/src/skins/plain/gameplay.ts @@ -61,11 +61,11 @@ function drawLanes(ctx: CanvasRenderingContext2D, frame: BeMusicGameplayFrame): ctx.fillStyle = LANE_FILL[lane.kind]; ctx.fillRect(lane.x, lane.top, lane.w, lane.bottom - lane.top); if (lane.beam > 0) { - const beam = ctx.createLinearGradient(0, lane.top, 0, lane.bottom); - beam.addColorStop(0, 'rgba(61, 123, 255, 0)'); - beam.addColorStop(1, `rgba(61, 123, 255, ${0.45 * lane.beam})`); - ctx.fillStyle = beam; + // 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. @@ -75,6 +75,22 @@ function drawLanes(ctx: CanvasRenderingContext2D, frame: BeMusicGameplayFrame): } } +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. diff --git a/packages/player-web/src/skins/plain/theme.ts b/packages/player-web/src/skins/plain/theme.ts index bb584319..06b6a784 100644 --- a/packages/player-web/src/skins/plain/theme.ts +++ b/packages/player-web/src/skins/plain/theme.ts @@ -17,11 +17,22 @@ export const FLASHING_GREAT = [0xffffff, 0x5b8cff, 0xffd84d] as const; export const FONT = '"M PLUS 1p", system-ui, sans-serif'; -/** A CSS font for `ctx.font`. */ +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 { - return `${weight} ${size}px ${FONT}`; + 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')}`; @@ -35,7 +46,11 @@ export function text( y: number, options: { size: number; color?: string; weight?: number; align?: CanvasTextAlign; maxWidth?: number }, ): void { - ctx.font = font(options.size, options.weight); + 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'; From 5509c4810211e65bccf1f0efec43bd9680a1244a Mon Sep 17 00:00:00 2001 From: Seiya KONNO Date: Fri, 9 Oct 2026 18:10:48 +0900 Subject: [PATCH 65/65] chore: record the transition and performance work in the changeset [ci skip] --- .changeset/be-music-skins.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.changeset/be-music-skins.md b/.changeset/be-music-skins.md index 529283df..2db1bbbb 100644 --- a/.changeset/be-music-skins.md +++ b/.changeset/be-music-skins.md @@ -43,4 +43,5 @@ The default (skinless) family now renders through a `BeMusicSkin`: a skin that d - 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 Pixi-based built-in skins do less work per frame: notes share one Graphics, the select screen keeps unchanged labels' text textures through its transitions, and skin canvases no longer preserve their drawing buffer. +- 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.