diff --git a/.changeset/default-skin-facelift.md b/.changeset/default-skin-facelift.md
index 9768fabd..663b7f22 100644
--- a/.changeset/default-skin-facelift.md
+++ b/.changeset/default-skin-facelift.md
@@ -2,28 +2,11 @@
'@be-music/player-web': minor
---
-Redesign the default (skinless) gameplay chrome as a "night cabinet": near-black violet ground with cyan / magenta
-neon accents and gold money values.
+Restyle the default (skinless) select, gameplay, and result chrome as a cut-in comic: black / crimson / cream / gold plates that slam in with overshoot, diagonal slashes, and beat-synced impact frames.
-The built-in chrome was a set of flat panels: single-colour bars, flat note rectangles, a star-sparkle bomb, and an
-amber judgement strip. The redesign keeps the same geometry contract (LR2 default 7-keys lane positions, 640x480
-design canvas) and repaints everything on top of it:
+The 640x480 design canvas and LR2-default 7-keys playfield contract are unchanged. What changed is the motion language — notes, HUD stamps, and result rank letters throw in instead of fading in:
-- Notes are three-tone plastic keys (highlight / body / shade with an outline) in white / neon cyan / hot pink; long
- notes render as a translucent core with bright side rails and real head/tail caps.
-- The judgement line is neon magenta and breathes with the beat; an LR2-style keyboard of glassy key caps sits under
- it, blazing on press with an under-glow strip and a gradient key beam.
-- The groove gauge is LR2's 50-cell segmented bar — orange below the clear line, red-hot above it, flickering tip,
- threshold notch — labelled with the active ruleset's own gauge id, and survival gauges run the all-red scheme.
-- The header HUD carries a mode pill, LED-style BPM / HI-SPEED readouts, a beat-pulsed accent trim, and a ruleset
- chip (LR2 / BEATORAJA / IIDX) so the compat mode is visible at a glance.
-- The score panel shows a gold score headline and a DJ-level meter under EX RATE with the IIDX ninth-boundary ticks,
- so the distance to the next rank letter is legible mid-play.
-- A live judge tally column (PG/GR/GD/BD/PR plus FAST / SLOW) sits beside the BGA monitor, which idles as a hex
- reticle with a crosshair instead of a black hole.
-- The playfield gets dark-chrome side rails with a magenta song-progress pipe, and bombs are a two-stage burst:
- white-hot core, flame ring, rotating starburst spokes, ember dots, and a light pillar in the lane.
-- Combo readouts tier their colour (white → cyan at 50 → gold at 200) and judge text carries a same-hue glow.
-
-`SkinlessGameplayChromeRuntime` gains `nowMs`, `progressRatio`, `beatPhase`, `rulesetLabel`, `gaugeLabel`,
-`gaugeSurvival`, `fast`, and `slow`.
+- Notes are parallelogram cuts (cream / crimson / gold hazard) with matching slash bombs, lane beams, and a beating judgement line.
+- Gameplay chrome opens with a wipe, stamps PLAY / BPM / ruleset on a slant, and slams judgement + combo with a chromatic offset on each hit.
+- Song select slams the focused title and the active row; PLAY / AUTO are cut-in stamps on the same list geometry the click hit-test already used.
+- Result opens on a CLEARED / FAILED slash banner; the rank letter stays veiled until timer 151, then slams from 3x with speed lines, while judge bars stagger in.
diff --git a/docs/player-web.ja.md b/docs/player-web.ja.md
index c0cf6919..7ecb1358 100644
--- a/docs/player-web.ja.md
+++ b/docs/player-web.ja.md
@@ -98,7 +98,8 @@ LR2 gameplay scene は default renderer を import せず、共有 gameplay runt
default gameplay chrome は `scene/gameplay-lanes.ts` の共通 lane geometry helper を使います。
scratch lane は key lane より幅広く、2P scratch は右側に表示し、DP layout では両 side を SP 幅へ押し込んで全 lane を縮小するのではなく、片側分の lane 幅を保持します。
-default family は通常テキストに LINE Seed JP、判定と combo 表示に Azeret Mono を使います。
+default family はタイトルと日本語ラベルに Dela Gothic One、HUD の stamp / rank / 判定ポップに Staatliches を使います。
+chrome は静止した HUD ではなく、overshoot 付きの slam-in、斜めの cut、拍に同期した slash で入ります。
## beatoraja skin と theme support
diff --git a/docs/player-web.md b/docs/player-web.md
index 6c10ffce..ea20ae83 100644
--- a/docs/player-web.md
+++ b/docs/player-web.md
@@ -103,8 +103,9 @@ skin rendering remains tied to parsed `.lr2skin` data.
Default gameplay chrome uses the shared lane geometry helpers in `scene/gameplay-lanes.ts`. Scratch lanes are wider
than key lanes, 2P scratch lanes render on the right side, and DP layouts preserve the single-side lane width instead
-of shrinking every lane to fit both sides into the original SP footprint. The default family uses LINE Seed JP for
-general text and Azeret Mono for judgment and combo readouts.
+of shrinking every lane to fit both sides into the original SP footprint. The default family uses Dela Gothic One for
+titles and Japanese labels, and Staatliches for HUD stamps, ranks, and judgement popups. Chrome slams in with overshoot,
+diagonal cuts, and beat-synced slashes rather than sitting as a static HUD.
## beatoraja skin and theme support
diff --git a/packages/player-web-demo/index.html b/packages/player-web-demo/index.html
index 480ddaa5..acf9e683 100644
--- a/packages/player-web-demo/index.html
+++ b/packages/player-web-demo/index.html
@@ -7,7 +7,7 @@
be-music web player
diff --git a/packages/player-web-demo/src/main.ts b/packages/player-web-demo/src/main.ts
index 1f9d8faf..72b92e0c 100644
--- a/packages/player-web-demo/src/main.ts
+++ b/packages/player-web-demo/src/main.ts
@@ -123,7 +123,12 @@ if (!app) {
app.innerHTML = DEMO_APP_HTML;
-const DEFAULT_UI_FONT_LOADS = ['400 22px "LINE Seed JP"', '700 18px "LINE Seed JP"', '900 32px "Azeret Mono"'] as const;
+const DEFAULT_UI_FONT_LOADS = [
+ '400 22px "Dela Gothic One"',
+ '400 48px "Dela Gothic One"',
+ '400 32px "Staatliches"',
+ '400 22px "LINE Seed JP"',
+] as const;
async function waitForDefaultUiFonts(): Promise {
if (!('fonts' in document)) return;
diff --git a/packages/player-web/src/scene/default/chrome-text.ts b/packages/player-web/src/scene/default/chrome-text.ts
new file mode 100644
index 00000000..5e99c184
--- /dev/null
+++ b/packages/player-web/src/scene/default/chrome-text.ts
@@ -0,0 +1,105 @@
+import { Container, Text, TextStyle } from 'pixi.js';
+import type { ChildPool } from '../pixi-utils.ts';
+import { DEFAULT_TEXT_FONT } from './fonts.ts';
+import { DEFAULT_THEME } from './theme.ts';
+
+export type ChromeTextWeight = '400' | '500' | '600' | '700' | '800' | '900';
+
+export interface ChromeTextOptions {
+ size?: number;
+ weight?: ChromeTextWeight;
+ fill?: number;
+ fontFamily?: string;
+ letterSpacing?: number;
+ anchorX?: number;
+ anchorY?: number;
+ maxWidth?: number;
+ rotation?: number;
+ slam?: number;
+ offsetX?: number;
+ offsetY?: number;
+ alpha?: number;
+ stroke?: { color: number; width: number; alignment?: number; join?: 'round' | 'bevel' | 'miter' };
+ dropShadow?: { color: number; alpha: number; blur: number; distance: number };
+}
+
+const TEXT_STYLE_CACHE = new Map();
+
+/**
+ * Pooled (or one-shot) stamp text. Always writes scale / rotation / alpha so a recycled `Text` from {@link ChildPool}
+ * cannot leak the previous pass's slam.
+ */
+export function addChromeText(
+ layer: Container,
+ text: string,
+ x: number,
+ y: number,
+ opts: ChromeTextOptions = {},
+ pool?: ChildPool,
+): Text {
+ const node = pool?.acquireText() ?? new Text();
+ const style = resolveChromeTextStyle(opts);
+ node.text = text;
+ if (node.style !== style) {
+ node.style = style;
+ }
+ node.anchor.set(opts.anchorX ?? 0, opts.anchorY ?? 0);
+ node.position.set(x + (opts.offsetX ?? 0), y + (opts.offsetY ?? 0));
+ node.rotation = opts.rotation ?? 0;
+ node.alpha = opts.alpha ?? 1;
+ node.scale.set(1, 1);
+ let widthScale = 1;
+ if (opts.maxWidth !== undefined && node.width > opts.maxWidth) {
+ widthScale = opts.maxWidth / node.width;
+ }
+ const slam = opts.slam ?? 1;
+ node.scale.set(widthScale * slam, slam);
+ if (!pool) {
+ layer.addChild(node);
+ }
+ return node;
+}
+
+function resolveChromeTextStyle(opts: ChromeTextOptions): TextStyle {
+ const stroke = opts.stroke;
+ const shadow = opts.dropShadow;
+ const key = [
+ opts.fill ?? DEFAULT_THEME.paper,
+ opts.size ?? 10,
+ opts.weight ?? '400',
+ opts.fontFamily ?? DEFAULT_TEXT_FONT,
+ opts.letterSpacing ?? 0,
+ stroke?.color ?? '',
+ stroke?.width ?? '',
+ stroke?.alignment ?? '',
+ stroke?.join ?? '',
+ shadow?.color ?? '',
+ shadow?.alpha ?? '',
+ shadow?.blur ?? '',
+ shadow?.distance ?? '',
+ ].join('|');
+ let style = TEXT_STYLE_CACHE.get(key);
+ if (!style) {
+ style = new TextStyle({
+ fill: opts.fill ?? DEFAULT_THEME.paper,
+ fontSize: opts.size ?? 10,
+ fontWeight: opts.weight ?? '400',
+ fontFamily: opts.fontFamily ?? DEFAULT_TEXT_FONT,
+ letterSpacing: opts.letterSpacing ?? 0,
+ stroke: opts.stroke,
+ ...(shadow
+ ? {
+ dropShadow: {
+ color: shadow.color,
+ alpha: shadow.alpha,
+ blur: shadow.blur,
+ distance: shadow.distance,
+ angle: Math.PI / 2,
+ },
+ }
+ : {}),
+ });
+ TEXT_STYLE_CACHE.set(key, style);
+ }
+ return style;
+}
diff --git a/packages/player-web/src/scene/default/fonts.ts b/packages/player-web/src/scene/default/fonts.ts
index 13fe8694..6366d9c4 100644
--- a/packages/player-web/src/scene/default/fonts.ts
+++ b/packages/player-web/src/scene/default/fonts.ts
@@ -1,3 +1,5 @@
-export const DEFAULT_TEXT_FONT = 'LINE Seed JP, ui-sans-serif, system-ui, sans-serif';
-export const DEFAULT_NUMERIC_FONT = DEFAULT_TEXT_FONT;
-export const DEFAULT_JUDGE_FONT = 'Azeret Mono, ui-monospace, SFMono-Regular, Menlo, monospace';
+/** Display face for titles and Japanese labels. Single-weight (400) — avoid faux-bold in TextStyle. */
+export const DEFAULT_TEXT_FONT = 'Dela Gothic One, ui-sans-serif, system-ui, sans-serif';
+/** All-caps stamp face for HUD numbers, ranks, and judgement popups. */
+export const DEFAULT_NUMERIC_FONT = 'Staatliches, ui-sans-serif, system-ui, sans-serif';
+export const DEFAULT_JUDGE_FONT = DEFAULT_NUMERIC_FONT;
diff --git a/packages/player-web/src/scene/default/gameplay-render.ts b/packages/player-web/src/scene/default/gameplay-render.ts
index 61d0ea0b..e00126e4 100644
--- a/packages/player-web/src/scene/default/gameplay-render.ts
+++ b/packages/player-web/src/scene/default/gameplay-render.ts
@@ -1,4 +1,4 @@
-import { Container, Graphics, Text, TextStyle } from 'pixi.js';
+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 {
@@ -9,43 +9,27 @@ import {
import type { SkinlessGameplayChromeRuntime } from '../gameplay-chrome.ts';
import { DEFAULT_JUDGE_FONT, DEFAULT_NUMERIC_FONT, DEFAULT_TEXT_FONT } from './fonts.ts';
import type { ChildPool } from '../pixi-utils.ts';
+import { addChromeText } from './chrome-text.ts';
+import { beatImpulse, clamp01, impactOffset, slamOffset, slamScale, staggerProgress } from './motion.ts';
+import {
+ DEFAULT_BG_BANDS,
+ DEFAULT_THEME,
+ defaultJudgeColor,
+ fillParallelogram,
+ fillSlash,
+ strokeParallelogram,
+} from './theme.ts';
-// "Night cabinet" palette — near-black violet ground, cyan / magenta neon accents, gold for money values.
-const SURFACE = 0x0a0714;
-const PANEL = 0x100b1e;
-const PANEL_DARK = 0x060410;
-const LINE = 0x2a2342;
-const LANE = 0x030209;
-const TEXT = 0xf4f1fa;
-const MUTED = 0x9a94ac;
-const SUBTLE = 0x655f78;
-const CYAN = 0x3fe0ff;
-const MAGENTA = 0xff3d81;
-const GOLD = 0xffc93d;
-const RED = 0xff4d6a;
-const GREEN = 0x3ef08a;
-const BLUE = 0x4db2ff;
-const ORANGE = 0xff8a3d;
-// Background gradient bands, top to bottom. Drawn as flat rows because pixi FillGradients would allocate a texture
-// per frame under the pooled-Graphics repaint model.
-const BG_BANDS: ReadonlyArray = [
- [0x120b22, 90],
- [0x0d081a, 170],
- [0x080512, 260],
- [0x05030c, 360],
- [0x030208, 480],
-];
-// Side-rail shades for the playfield frame — dark chrome with neon piping.
-const RAIL_EDGE = 0x4a3f6e;
-const RAIL_BODY = 0x171128;
-const RAIL_DARK = 0x0a0716;
-const JUDGE_RED = 0xff2f6b;
-const FONT = DEFAULT_TEXT_FONT;
-const NUMERIC_FONT = DEFAULT_NUMERIC_FONT;
const SCORE_PANEL = { x: 384, y: 350, w: 238, h: 112 } as const;
+const PLAYFIELD_FRAME_BOTTOM = 344;
+const JUDGE_SLAM_MS = 180;
+const INTRO_MS = 520;
type PlaySide = '1P' | '2P';
+const judgeSlamAt = new Map();
+const judgeSlamKey = new Map();
+
/**
* Live runtime values painted into the built-in gameplay chrome.
*/
@@ -72,8 +56,8 @@ interface FallbackPlayfieldLayout {
}
/**
- * Built-in gameplay chrome for the default skin family. 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.
+ * Built-in gameplay chrome for the default skin family. Cut-in stamps, diagonal plates, and beat-synced slashes —
+ * the playfield geometry contract stays the LR2 default 7-keys rectangle.
*/
export function renderDefaultGameplayFrame(
layer: Container,
@@ -84,17 +68,19 @@ export function renderDefaultGameplayFrame(
const frame = layerPool?.acquireGraphics() ?? new Graphics();
const hasBga = runtime.hasBga === true;
const playfield = resolveFallbackPlayfieldLayout(runtime.laneChannels, runtime.laneCount, runtime.playVariant);
+ const nowMs = runtime.nowMs ?? 0;
+ const introT = staggerProgress(nowMs, 0, INTRO_MS);
+ const pulse = beatImpulse(runtime.beatPhase);
frame.label = 'default-gameplay/chrome';
drawBackground(frame, hasBga);
-
- drawPlayfield(frame, playfield, runtime.progressRatio);
- drawBgaFrame(frame, hasBga);
- drawGauge(frame, runtime.gauge, runtime.clearThreshold, runtime.gaugeSurvival === true, runtime.nowMs);
+ drawPlayfield(frame, playfield, runtime.progressRatio, pulse);
+ drawBgaFrame(frame, hasBga, pulse);
+ drawGauge(frame, runtime.gauge, runtime.clearThreshold, runtime.gaugeSurvival === true, nowMs, pulse);
drawSongPlate(frame);
drawScorePlate(frame, runtime.exScore, runtime.exScoreMax);
const tallyX = resolveJudgeTallyX(playfield);
if (tallyX !== undefined) {
- drawJudgeTally(frame, layer, runtime, tallyX, layerPool);
+ drawJudgeTally(frame, layer, runtime, tallyX, layerPool, introT);
}
if (!layerPool) {
layer.addChildAt(frame, 0);
@@ -102,46 +88,72 @@ export function renderDefaultGameplayFrame(
const frontLayer = options.overlayLayer && options.overlayLayerPool ? options.overlayLayer : layer;
const frontPool = frontLayer === layer ? layerPool : options.overlayLayerPool;
- drawStatusBar(frontLayer, runtime.autoplay === true, runtime.beatPhase, frontPool);
- addText(
+ drawStatusBar(frontLayer, runtime.autoplay === true, pulse, frontPool);
+ const headerSlam = slamScale(introT, 1.8);
+ const headerThrow = slamOffset(introT, -36);
+ addChromeText(
frontLayer,
- runtime.autoplay ? 'AUTO PLAY' : 'PLAY',
- 62,
- 14,
+ runtime.autoplay ? 'AUTO' : 'PLAY',
+ 58,
+ 10,
{
- size: 10,
- weight: '900',
- fill: runtime.autoplay ? GOLD : CYAN,
- letterSpacing: 1.4,
+ size: 18,
+ fill: runtime.autoplay ? DEFAULT_THEME.gold : DEFAULT_THEME.paper,
+ fontFamily: DEFAULT_NUMERIC_FONT,
+ letterSpacing: 1.6,
anchorX: 0.5,
+ rotation: -0.12,
+ slam: headerSlam,
+ offsetX: headerThrow,
+ stroke: { color: DEFAULT_THEME.ink, width: 4, alignment: 0.5, join: 'round' },
},
frontPool,
);
- addText(frontLayer, 'BPM', 128, 18, labelStyle(), frontPool);
- addText(
+ addChromeText(frontLayer, 'BPM', 118, 8, { ...stampLabel(), offsetX: slamOffset(introT, -18) }, frontPool);
+ addChromeText(
frontLayer,
formatBpmValue(runtime.bpm),
- 156,
- 12,
- { size: 15, weight: '900', fill: TEXT, fontFamily: NUMERIC_FONT },
+ 148,
+ 6,
+ {
+ size: 22,
+ fill: DEFAULT_THEME.paper,
+ fontFamily: DEFAULT_NUMERIC_FONT,
+ slam: slamScale(staggerProgress(nowMs, 40, 280), 1.7),
+ offsetX: slamOffset(staggerProgress(nowMs, 40, 280), -24),
+ },
frontPool,
);
- addText(frontLayer, 'HI-SPEED', 216, 18, labelStyle(), frontPool);
- addText(
+ addChromeText(frontLayer, 'HI-SPEED', 210, 8, stampLabel(), frontPool);
+ addChromeText(
frontLayer,
`x${formatHiSpeed(runtime.hiSpeed)}`,
268,
- 12,
- { size: 15, weight: '900', fill: TEXT, fontFamily: NUMERIC_FONT },
+ 6,
+ {
+ size: 22,
+ fill: DEFAULT_THEME.paper,
+ fontFamily: DEFAULT_NUMERIC_FONT,
+ slam: slamScale(staggerProgress(nowMs, 80, 280), 1.7),
+ },
frontPool,
);
- addText(frontLayer, 'RULESET', 404, 17, { size: 7, weight: '700', fill: SUBTLE, letterSpacing: 0.6 }, frontPool);
- addText(
+ addChromeText(frontLayer, 'RULESET', 400, 8, { ...stampLabel(), fill: DEFAULT_THEME.mute }, frontPool);
+ addChromeText(
frontLayer,
formatRulesetLabel(runtime.rulesetLabel),
502,
- 13,
- { size: 11, weight: '900', fill: MAGENTA, letterSpacing: 1, anchorX: 1, maxWidth: 62 },
+ 6,
+ {
+ size: 16,
+ fill: DEFAULT_THEME.crimson,
+ fontFamily: DEFAULT_NUMERIC_FONT,
+ letterSpacing: 1.2,
+ anchorX: 1,
+ maxWidth: 72,
+ rotation: -0.06,
+ slam: slamScale(staggerProgress(nowMs, 110, 260), 2.1),
+ },
frontPool,
);
@@ -149,159 +161,141 @@ export function renderDefaultGameplayFrame(
const clearLine = clampPercent(runtime.clearThreshold ?? 80);
const survivalGauge = runtime.gaugeSurvival === true || clearLine <= 0;
const gaugeCleared = survivalGauge ? gauge > 0 : gauge >= clearLine;
- addText(
+ addChromeText(
layer,
`${(runtime.gaugeLabel ?? 'GROOVE').toUpperCase()} GAUGE`,
GROOVE.x - 2,
- GROOVE.y - 17,
- { ...labelStyle(), maxWidth: 120 },
+ GROOVE.y - 18,
+ { ...stampLabel(), maxWidth: 120 },
layerPool,
);
- addText(
+ addChromeText(
layer,
`${Math.round(gauge)}%`,
GROOVE.x + GROOVE.w + 8,
- GROOVE.y - 23,
- metricStyle(15, survivalGauge ? (gaugeCleared ? JUDGE_RED : MUTED) : gaugeCleared ? JUDGE_RED : 0xff7a2f, 1),
+ GROOVE.y - 26,
+ {
+ size: 22,
+ fill: survivalGauge
+ ? gaugeCleared
+ ? DEFAULT_THEME.crimson
+ : DEFAULT_THEME.mute
+ : gaugeCleared
+ ? DEFAULT_THEME.crimson
+ : 0xff8a3d,
+ fontFamily: DEFAULT_NUMERIC_FONT,
+ slam: 1 + pulse * 0.08,
+ },
layerPool,
);
const title = runtime.songTitle?.trim() || 'Untitled chart';
- addText(
+ addChromeText(
layer,
title,
- 28,
- 416,
+ 30,
+ 414,
{
- size: 16,
- weight: '800',
- fill: TEXT,
- maxWidth: 324,
+ size: 15,
+ fill: DEFAULT_THEME.paper,
+ fontFamily: DEFAULT_TEXT_FONT,
+ maxWidth: 318,
+ slam: slamScale(staggerProgress(nowMs, 160, 320), 1.35),
+ offsetX: slamOffset(staggerProgress(nowMs, 160, 320), -28),
+ rotation: -0.02,
},
layerPool,
);
const artist = runtime.songArtist?.trim();
if (artist) {
- addText(
+ addChromeText(
layer,
artist,
- 28,
+ 30,
438,
{
size: 10,
- weight: '600',
- fill: MUTED,
- maxWidth: 324,
+ fill: DEFAULT_THEME.mute,
+ fontFamily: DEFAULT_TEXT_FONT,
+ maxWidth: 318,
},
layerPool,
);
}
const rank = runtime.rank && runtime.rank !== '-' ? runtime.rank : 'F';
- addText(layer, 'SCORE', SCORE_PANEL.x + 12, SCORE_PANEL.y + 16, labelStyle(), layerPool);
- addText(
+ addChromeText(layer, 'SCORE', SCORE_PANEL.x + 14, SCORE_PANEL.y + 12, stampLabel(), layerPool);
+ addChromeText(
layer,
formatCount(runtime.score),
SCORE_PANEL.x + 128,
- SCORE_PANEL.y + 20,
- {
- ...metricStyle(22, GOLD, 1),
- maxWidth: 112,
- },
+ SCORE_PANEL.y + 14,
+ { size: 24, fill: DEFAULT_THEME.gold, fontFamily: DEFAULT_NUMERIC_FONT, maxWidth: 112 },
layerPool,
);
- addText(layer, 'EX SCORE', SCORE_PANEL.x + 12, SCORE_PANEL.y + 46, labelStyle(), layerPool);
- addText(
+ addChromeText(layer, 'EX SCORE', SCORE_PANEL.x + 14, SCORE_PANEL.y + 44, stampLabel(), layerPool);
+ addChromeText(
layer,
`${formatCount(runtime.exScore)} / ${formatCount(runtime.exScoreMax)}`,
SCORE_PANEL.x + 128,
- SCORE_PANEL.y + 54,
- {
- ...metricStyle(12, TEXT, 1),
- maxWidth: 112,
- },
+ SCORE_PANEL.y + 48,
+ { size: 14, fill: DEFAULT_THEME.paper, fontFamily: DEFAULT_NUMERIC_FONT, maxWidth: 112 },
layerPool,
);
- addText(layer, 'EX RATE', SCORE_PANEL.x + 12, SCORE_PANEL.y + 76, labelStyle(), layerPool);
- addText(
+ addChromeText(layer, 'EX RATE', SCORE_PANEL.x + 14, SCORE_PANEL.y + 74, stampLabel(), layerPool);
+ addChromeText(
layer,
formatExRate(runtime.exScore, runtime.exScoreMax),
SCORE_PANEL.x + 128,
- SCORE_PANEL.y + 84,
- {
- ...metricStyle(12, TEXT, 1),
- maxWidth: 112,
- },
+ SCORE_PANEL.y + 78,
+ { size: 14, fill: DEFAULT_THEME.paper, fontFamily: DEFAULT_NUMERIC_FONT, maxWidth: 112 },
layerPool,
);
- // Right column rows share one baseline per row: labelTop = valueTop + 0.8 x (valueSize - labelSize).
- addText(layer, 'COMBO', SCORE_PANEL.x + 148, SCORE_PANEL.y + 21, labelStyle(), layerPool);
- addText(
+ addChromeText(layer, 'COMBO', SCORE_PANEL.x + 148, SCORE_PANEL.y + 16, stampLabel(), layerPool);
+ addChromeText(
layer,
formatCount(runtime.combo),
SCORE_PANEL.x + 226,
- SCORE_PANEL.y + 16,
+ SCORE_PANEL.y + 10,
{
- ...metricStyle(14, CYAN, 1),
+ size: 18,
+ fill: DEFAULT_THEME.crimson,
fontFamily: DEFAULT_JUDGE_FONT,
maxWidth: 40,
+ slam: 1 + pulse * 0.06,
},
layerPool,
);
- addText(layer, 'MAX', SCORE_PANEL.x + 148, SCORE_PANEL.y + 52, labelStyle(), layerPool);
- addText(
+ addChromeText(layer, 'MAX', SCORE_PANEL.x + 148, SCORE_PANEL.y + 48, stampLabel(), layerPool);
+ addChromeText(
layer,
formatCount(runtime.maxCombo),
SCORE_PANEL.x + 226,
- SCORE_PANEL.y + 48,
+ SCORE_PANEL.y + 42,
+ { size: 16, fill: DEFAULT_THEME.paper, fontFamily: DEFAULT_JUDGE_FONT, maxWidth: 40 },
+ layerPool,
+ );
+ addChromeText(layer, 'RANK', SCORE_PANEL.x + 148, SCORE_PANEL.y + 78, stampLabel(), layerPool);
+ addChromeText(
+ layer,
+ rank,
+ SCORE_PANEL.x + 226,
+ SCORE_PANEL.y + 64,
{
- ...metricStyle(13, TEXT, 1),
- fontFamily: DEFAULT_JUDGE_FONT,
- maxWidth: 40,
+ size: 26,
+ fill: DEFAULT_THEME.gold,
+ fontFamily: DEFAULT_NUMERIC_FONT,
+ maxWidth: 42,
+ rotation: -0.08,
},
layerPool,
);
- addText(layer, 'RANK', SCORE_PANEL.x + 148, SCORE_PANEL.y + 81, labelStyle(), layerPool);
- addText(layer, rank, SCORE_PANEL.x + 226, SCORE_PANEL.y + 70, { ...metricStyle(22, GOLD, 1), maxWidth: 42 }, layerPool);
+
+ drawBeatSlashes(frontLayer, pulse, frontPool);
+ drawIntroWipe(frontLayer, introT, frontPool);
for (const display of resolveJudgeDisplays(runtime, playfield)) {
- const combo = resolveVisibleCombo(display.judge, display.combo);
- addText(
- frontLayer,
- display.judge,
- display.x,
- 230,
- {
- size: 24,
- weight: '900',
- fill: judgeColor(display.judge),
- fontFamily: DEFAULT_JUDGE_FONT,
- anchorX: 0.5,
- stroke: { color: 0x0a0410, width: 5, alignment: 0.5, join: 'round' },
- dropShadow: { color: judgeColor(display.judge), alpha: 0.4, blur: 6, distance: 0 },
- maxWidth: display.maxWidth,
- },
- frontPool,
- );
- if (combo > 0) {
- addText(
- frontLayer,
- formatCount(combo),
- display.x,
- 258,
- {
- size: 19,
- weight: '900',
- // Combo tiers: white to start, cyan once it means something, gold once it is a run.
- fill: combo >= 200 ? GOLD : combo >= 50 ? CYAN : TEXT,
- fontFamily: DEFAULT_JUDGE_FONT,
- anchorX: 0.5,
- stroke: { color: 0x0a0410, width: 4, alignment: 0.5, join: 'round' },
- maxWidth: Math.max(72, display.maxWidth - 36),
- },
- frontPool,
- );
- }
+ paintJudgeStamp(frontLayer, display, nowMs, frontPool);
}
}
@@ -314,21 +308,16 @@ export function renderDefaultGameplayFrame(
*/
export const renderFallbackLr2Frame: typeof renderDefaultGameplayFrame = renderDefaultGameplayFrame;
-/**
- * Deep-navy vertical gradient plus an edge vignette. With a live BGA the bands leave a hole over the BGA rect —
- * the BGA layer renders BEHIND this chrome layer, so anything painted there would cover the video.
- */
function drawBackground(frame: Graphics, hasBga: boolean): void {
let bandTop = 0;
- for (const [color, bandBottom] of BG_BANDS) {
+ for (const [color, bandBottom] of DEFAULT_BG_BANDS) {
fillBandAroundHole(frame, bandTop, bandBottom, color, hasBga);
bandTop = bandBottom;
}
- // Vignette — kept outside the BGA rect (y < 56, y > 340, x < 24) so no hole logic is needed.
- frame.rect(0, 0, DESIGN_WIDTH, 6).fill({ color: 0x000000, alpha: 0.4 });
- frame.rect(0, DESIGN_HEIGHT - 14, DESIGN_WIDTH, 14).fill({ color: 0x000000, alpha: 0.3 });
- frame.rect(0, 0, 14, DESIGN_HEIGHT).fill({ color: 0x000000, alpha: 0.22 });
- frame.rect(DESIGN_WIDTH - 14, 0, 14, DESIGN_HEIGHT).fill({ color: 0x000000, alpha: 0.22 });
+ fillSlash(frame, -40, 8, 220, 18, 14, DEFAULT_THEME.crimson, 0.22);
+ fillSlash(frame, 420, 430, 280, 22, 16, DEFAULT_THEME.crimson, 0.16);
+ frame.rect(0, 0, DESIGN_WIDTH, 6).fill({ color: 0x000000, alpha: 0.45 });
+ frame.rect(0, DESIGN_HEIGHT - 14, DESIGN_WIDTH, 14).fill({ color: 0x000000, alpha: 0.35 });
}
function fillBandAroundHole(frame: Graphics, top: number, bottom: number, color: number, hasBga: boolean): void {
@@ -338,7 +327,6 @@ function fillBandAroundHole(frame: Graphics, top: number, bottom: number, color:
frame.rect(0, top, DESIGN_WIDTH, height).fill(color);
return;
}
- // Band overlaps the BGA rows: paint the row segments left / right of the hole, plus any sliver above / below it.
if (top < BGA.y) frame.rect(0, top, DESIGN_WIDTH, BGA.y - top).fill(color);
const overlapTop = Math.max(top, BGA.y);
const overlapBottom = Math.min(bottom, BGA.y + BGA.h);
@@ -399,10 +387,12 @@ function resolveSideBounds(
return bounds;
}
-/** Bottom edge of the playfield frame — leaves room for the key-cap strip renderLanes paints below the judge line. */
-const PLAYFIELD_FRAME_BOTTOM = 344;
-
-function drawPlayfield(frame: Graphics, playfield: FallbackPlayfieldLayout, progressRatio: number | undefined): void {
+function drawPlayfield(
+ frame: Graphics,
+ playfield: FallbackPlayfieldLayout,
+ progressRatio: number | undefined,
+ pulse: number,
+): void {
const wellTop = PLAYFIELD.y;
const wellBottom = PLAYFIELD_FRAME_BOTTOM;
const wellHeight = wellBottom - wellTop;
@@ -410,144 +400,128 @@ function drawPlayfield(frame: Graphics, playfield: FallbackPlayfieldLayout, prog
const leftRailX = playfield.x - railW - 2;
const rightRailX = playfield.right + 2;
- // Lane well — near-black with a faint depth gradient (darker at the top, where notes emerge).
- frame.rect(playfield.x - 2, wellTop, playfield.w + 4, wellHeight).fill(LANE);
- frame.rect(playfield.x - 2, wellTop, playfield.w + 4, 90).fill({ color: 0x000000, alpha: 0.45 });
- frame.rect(playfield.x - 2, wellTop + 90, playfield.w + 4, 90).fill({ color: 0x000000, alpha: 0.2 });
-
- // DP side gap — a dead column between the 1P / 2P halves.
+ frame.rect(playfield.x - 2, wellTop, playfield.w + 4, wellHeight).fill(DEFAULT_THEME.lane);
+ frame.rect(playfield.x - 2, wellTop, playfield.w + 4, 90).fill({ color: 0x000000, alpha: 0.5 });
if (playfield.sideGap) {
frame.rect(playfield.sideGap.x, wellTop, playfield.sideGap.w, wellHeight).fill({
- color: RAIL_DARK,
+ color: DEFAULT_THEME.inkDeep,
alpha: 0.96,
});
- frame.rect(playfield.sideGap.x + 1, wellTop, 1, wellHeight).fill({ color: RAIL_EDGE, alpha: 0.5 });
- frame.rect(playfield.sideGap.x + playfield.sideGap.w - 2, wellTop, 1, wellHeight).fill({
- color: RAIL_EDGE,
- alpha: 0.5,
- });
}
- // Metallic side rails: bright outer edge, brushed body, seated shadow against the lane well.
for (const railX of [leftRailX, rightRailX]) {
- frame.rect(railX, wellTop, railW, wellBottom).fill(RAIL_BODY);
- frame.rect(railX, wellTop, 1, wellBottom).fill({ color: RAIL_EDGE, alpha: 0.9 });
- frame.rect(railX + railW - 1, wellTop, 1, wellBottom).fill({ color: RAIL_EDGE, alpha: 0.9 });
- frame.rect(railX + 1, wellTop, railW - 2, 2).fill({ color: RAIL_EDGE, alpha: 0.7 });
- frame.rect(railX, wellBottom - 2, railW, 2).fill(RAIL_DARK);
+ frame.rect(railX, wellTop, railW, wellBottom).fill(DEFAULT_THEME.rail);
+ frame.rect(railX, wellTop, 1, wellBottom).fill({ color: DEFAULT_THEME.railEdge, alpha: 0.95 });
+ frame
+ .rect(railX + railW - 1, wellTop, 1, wellBottom)
+ .fill({ color: DEFAULT_THEME.crimson, alpha: 0.55 + 0.4 * pulse });
}
- // Song-progress track inside the left rail, filling bottom-up — the LR2 default skin's vertical progress bar.
const trackX = leftRailX + 2;
const trackTop = wellTop + 6;
const trackHeight = wellBottom - 12 - trackTop;
frame.rect(trackX, trackTop, railW - 4, trackHeight).fill({ color: 0x000000, alpha: 0.55 });
- const ratio = progressRatio !== undefined && Number.isFinite(progressRatio) ? Math.max(0, Math.min(1, progressRatio)) : 0;
+ const ratio =
+ progressRatio !== undefined && Number.isFinite(progressRatio) ? Math.max(0, Math.min(1, progressRatio)) : 0;
if (ratio > 0) {
const fillHeight = Math.max(2, Math.round(trackHeight * ratio));
- frame.rect(trackX, trackTop + trackHeight - fillHeight, railW - 4, fillHeight).fill({ color: MAGENTA, alpha: 0.85 });
- frame.rect(trackX, trackTop + trackHeight - fillHeight, railW - 4, 2).fill({ color: 0xffffff, alpha: 0.85 });
- }
-
- // Frame footer — a plated bar closing the well under the key caps.
- frame.rect(leftRailX, wellBottom, rightRailX + railW - leftRailX, 5).fill(RAIL_BODY);
- frame.rect(leftRailX, wellBottom, rightRailX + railW - leftRailX, 1).fill({ color: RAIL_EDGE, alpha: 0.8 });
- frame.rect(leftRailX, wellBottom + 5, rightRailX + railW - leftRailX, 2).fill({ color: 0x000000, alpha: 0.4 });
+ frame.rect(trackX, trackTop + trackHeight - fillHeight, railW - 4, fillHeight).fill({
+ color: DEFAULT_THEME.crimson,
+ alpha: 0.9,
+ });
+ frame
+ .rect(trackX, trackTop + trackHeight - fillHeight, railW - 4, 2)
+ .fill({ color: DEFAULT_THEME.paper, alpha: 0.95 });
+ }
+
+ fillParallelogram(frame, leftRailX, wellBottom, rightRailX + railW - leftRailX, 6, 8, DEFAULT_THEME.rail, 1);
+ fillSlash(
+ frame,
+ leftRailX - 4,
+ wellBottom - 2,
+ rightRailX + railW - leftRailX + 10,
+ 4,
+ 3,
+ DEFAULT_THEME.crimson,
+ 0.85,
+ );
}
-/** 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 + 13;
return playfield.right + 14 <= x && x + 76 <= DESIGN_WIDTH ? x : undefined;
}
-function drawBgaFrame(frame: Graphics, hasBga: boolean): void {
- if (!hasBga) {
- frame.roundRect(BGA.x - 10, BGA.y - 12, BGA.w + 20, BGA.h + 24, 4).fill({ color: PANEL, alpha: 0.7 });
- }
- frame.roundRect(BGA.x - 10, BGA.y - 12, BGA.w + 20, BGA.h + 24, 4).stroke({ color: LINE, width: 1 });
+function drawBgaFrame(frame: Graphics, hasBga: boolean, pulse: number): void {
+ const pad = 10;
+ fillParallelogram(
+ frame,
+ BGA.x - pad,
+ BGA.y - 12,
+ BGA.w + pad * 2,
+ BGA.h + 24,
+ 10,
+ DEFAULT_THEME.panel,
+ hasBga ? 0 : 0.88,
+ );
+ strokeParallelogram(
+ frame,
+ BGA.x - pad,
+ BGA.y - 12,
+ BGA.w + pad * 2,
+ BGA.h + 24,
+ 10,
+ DEFAULT_THEME.crimson,
+ 2,
+ 0.7 + 0.3 * pulse,
+ );
if (!hasBga) {
- frame.rect(BGA.x, BGA.y, BGA.w, BGA.h).fill({ color: PANEL_DARK, alpha: 0.95 });
- // Idle reticle — a hex targeting frame with a crosshair, so the empty monitor reads as a powered screen on
- // standby rather than a hole in the cabinet.
+ frame.rect(BGA.x, BGA.y, BGA.w, BGA.h).fill({ color: DEFAULT_THEME.inkDeep, alpha: 0.96 });
const cx = BGA.x + BGA.w / 2;
const cy = BGA.y + BGA.h / 2;
- for (const [radius, alpha] of [
- [92, 0.06],
- [64, 0.09],
- ] as const) {
- const points: number[] = [];
- for (let corner = 0; corner < 6; corner += 1) {
- const angle = -Math.PI / 2 + (Math.PI * 2 * corner) / 6;
- points.push(cx + Math.cos(angle) * radius, cy + Math.sin(angle) * radius);
- }
- frame.poly(points).stroke({ color: CYAN, width: 1, alpha });
- }
- frame.rect(cx - 16, cy, 10, 1).fill({ color: CYAN, alpha: 0.3 });
- frame.rect(cx + 6, cy, 10, 1).fill({ color: CYAN, alpha: 0.3 });
- frame.rect(cx, cy - 16, 1, 10).fill({ color: CYAN, alpha: 0.3 });
- frame.rect(cx, cy + 6, 1, 10).fill({ color: CYAN, alpha: 0.3 });
- frame.circle(cx, cy, 2).fill({ color: CYAN, alpha: 0.35 });
- }
- // Corner brackets over the monitor edges.
- const bracket = 14;
+ fillSlash(frame, cx - 90, cy - 10, 180, 8, 18, DEFAULT_THEME.crimson, 0.55);
+ fillSlash(frame, cx - 80, cy + 8, 160, 5, -14, DEFAULT_THEME.paper, 0.35);
+ }
for (const [bx, by, dx, dy] of [
[BGA.x, BGA.y, 1, 1],
[BGA.x + BGA.w, BGA.y, -1, 1],
[BGA.x, BGA.y + BGA.h, 1, -1],
[BGA.x + BGA.w, BGA.y + BGA.h, -1, -1],
] as const) {
- const horizontalX = dx > 0 ? bx : bx - bracket;
- const verticalY = dy > 0 ? by : by - bracket;
- frame.rect(horizontalX, by - (dy < 0 ? 2 : 0), bracket, 2).fill({ color: CYAN, alpha: 0.6 });
- frame.rect(bx - (dx < 0 ? 2 : 0), verticalY, 2, bracket).fill({ color: CYAN, alpha: 0.6 });
+ frame.rect(dx > 0 ? bx : bx - 16, by - (dy < 0 ? 2 : 0), 16, 3).fill({ color: DEFAULT_THEME.paper, alpha: 0.8 });
+ frame.rect(bx - (dx < 0 ? 2 : 0), dy > 0 ? by : by - 16, 3, 16).fill({ color: DEFAULT_THEME.paper, alpha: 0.8 });
}
}
-function drawStatusBar(layer: Container, autoplay: boolean, beatPhase: number | undefined, pool?: ChildPool): void {
+function drawStatusBar(layer: Container, autoplay: boolean, pulse: number, pool?: ChildPool): void {
const status = pool?.acquireGraphics() ?? new Graphics();
status.label = 'default-gameplay/status';
- status.rect(0, 0, DESIGN_WIDTH, 40).fill({ color: SURFACE, alpha: 1 });
- status.rect(0, 0, DESIGN_WIDTH, 12).fill({ color: 0x000000, alpha: 0.3 });
- // Accent trim — bright at the left, decaying to the right, and breathing with the beat so the whole cabinet
- // keeps time with the chart.
- status.rect(0, 39, DESIGN_WIDTH, 1).fill({ color: LINE, alpha: 0.9 });
- const accent = autoplay ? GOLD : CYAN;
- const pulse = beatPhase !== undefined ? 0.6 + 0.4 * (1 - beatPhase) : 1;
- for (const [x0, w, alpha] of [
- [0, 160, 0.9],
- [160, 160, 0.45],
- [320, 160, 0.2],
- [480, 160, 0.08],
- ] as const) {
- status.rect(x0, 38, w, 2).fill({ color: accent, alpha: alpha * pulse });
- }
- // Mode pill housing.
- status
- .roundRect(16, 10, 92, 20, 10)
- .fill({ color: PANEL_DARK, alpha: 0.9 })
- .stroke({ color: accent, width: 1, alpha: 0.75 });
- // Ruleset chip housing — the compat mode is a first-class fact of the run, so it lives in the header.
- status
- .roundRect(392, 10, 118, 20, 3)
- .fill({ color: PANEL_DARK, alpha: 0.9 })
- .stroke({ color: MAGENTA, width: 1, alpha: 0.6 });
- status.rect(396, 14, 2, 12).fill({ color: MAGENTA, alpha: 0.9 });
+ fillParallelogram(status, -12, 0, DESIGN_WIDTH + 24, 40, 18, DEFAULT_THEME.ink, 1);
+ fillSlash(
+ status,
+ -20,
+ 32,
+ DESIGN_WIDTH + 40,
+ 6,
+ 4,
+ autoplay ? DEFAULT_THEME.gold : DEFAULT_THEME.crimson,
+ 0.55 + 0.45 * pulse,
+ );
+ fillParallelogram(status, 12, 8, 92, 22, 10, DEFAULT_THEME.crimson, 0.95);
+ fillParallelogram(status, 392, 8, 118, 22, 8, DEFAULT_THEME.panel, 0.95);
+ strokeParallelogram(status, 392, 8, 118, 22, 8, DEFAULT_THEME.crimson, 1.5, 0.85);
if (!pool) {
layer.addChild(status);
}
}
-/**
- * LR2-style segmented groove gauge: 50 cells of 2 % each. Cells below the clear line burn orange, cells at/above it
- * burn red-hot, and the newest lit cell flickers. Survival gauges (clear threshold 0) run the all-red scheme.
- */
function drawGauge(
frame: Graphics,
value: number | undefined,
threshold: number | undefined,
survivalGauge: boolean,
nowMs?: number,
+ pulse = 0,
): void {
const gauge = clampPercent(value ?? 0);
const clear = clampPercent(threshold ?? 80);
@@ -558,65 +532,58 @@ function drawGauge(
const litCells = Math.round((gauge / 100) * cellCount);
const clearCell = Math.round((clear / 100) * cellCount);
- // Plated housing.
- frame
- .roundRect(GROOVE.x - 10, GROOVE.y - 26, GROOVE.w + 20, 50, 4)
- .fill(PANEL)
- .stroke({ color: LINE, width: 1 });
- frame.rect(GROOVE.x - 10, GROOVE.y - 26, GROOVE.w + 20, 1).fill({
- color: survival ? MAGENTA : CYAN,
- alpha: 0.35,
- });
- frame
- .rect(GROOVE.x - 4, GROOVE.y - 3, GROOVE.w + 8, GROOVE.h + 6)
- .fill(0x000000)
- .stroke({ color: LINE, width: 1 });
+ fillParallelogram(frame, GROOVE.x - 12, GROOVE.y - 26, GROOVE.w + 24, 50, 10, DEFAULT_THEME.panel, 1);
+ strokeParallelogram(frame, GROOVE.x - 12, GROOVE.y - 26, GROOVE.w + 24, 50, 10, DEFAULT_THEME.crimson, 1, 0.7);
+ frame.rect(GROOVE.x - 4, GROOVE.y - 3, GROOVE.w + 8, GROOVE.h + 6).fill(DEFAULT_THEME.inkDeep);
- const flicker = nowMs !== undefined ? 0.72 + 0.28 * Math.abs(Math.sin(nowMs / 90)) : 1;
+ const flicker = nowMs !== undefined ? 0.72 + 0.28 * Math.abs(Math.sin(nowMs / 70)) : 1;
for (let cell = 0; cell < litCells; cell += 1) {
const hot = survival || cell >= clearCell;
const isTip = cell === litCells - 1;
- const color = hot ? JUDGE_RED : 0xff7a2f;
+ const color = hot ? DEFAULT_THEME.crimson : 0xff8a3d;
frame.rect(GROOVE.x + cell * cellStride, GROOVE.y, cellW, GROOVE.h).fill({
- color: isTip ? 0xffffff : color,
- alpha: isTip ? flicker : hot ? 0.95 : 0.9,
+ color: isTip ? DEFAULT_THEME.paper : color,
+ alpha: isTip ? flicker : hot ? 0.96 : 0.9,
});
- // Cell top shine.
- frame.rect(GROOVE.x + cell * cellStride, GROOVE.y, cellW, 2).fill({ color: 0xffffff, alpha: isTip ? 0.5 : 0.25 });
}
for (let cell = litCells; cell < cellCount; cell += 1) {
- frame.rect(GROOVE.x + cell * cellStride, GROOVE.y, cellW, GROOVE.h).fill({ color: 0x1a2233, alpha: 0.9 });
+ frame.rect(GROOVE.x + cell * cellStride, GROOVE.y, cellW, GROOVE.h).fill({ color: 0x1a1010, alpha: 0.92 });
}
if (!survival) {
const clearX = GROOVE.x + Math.round(clearCell * cellStride) - 1;
- frame.rect(clearX, GROOVE.y - 5, 2, GROOVE.h + 10).fill({ color: TEXT, alpha: 0.95 });
- frame.poly([clearX - 3, GROOVE.y - 9, clearX + 5, GROOVE.y - 9, clearX + 1, GROOVE.y - 4]).fill({
- color: TEXT,
- alpha: 0.95,
- });
+ frame.rect(clearX, GROOVE.y - 6, 2, GROOVE.h + 12).fill({ color: DEFAULT_THEME.paper, alpha: 0.95 });
+ }
+ if (pulse > 0.4 && litCells > 0) {
+ fillSlash(frame, GROOVE.x - 6, GROOVE.y - 4, GROOVE.w + 16, 3, 2, DEFAULT_THEME.paper, 0.18 * pulse);
}
}
function drawSongPlate(frame: Graphics): void {
- frame.roundRect(18, 404, 352, 58, 4).fill(PANEL).stroke({ color: LINE, width: 1 });
- frame.rect(18, 404, 352, 1).fill({ color: CYAN, alpha: 0.3 });
- // Accent notch on the leading edge — makes the plate read as a marquee, not a form field.
- frame.rect(18, 410, 3, 46).fill({ color: MAGENTA, alpha: 0.9 });
+ fillParallelogram(frame, 14, 402, 356, 62, 14, DEFAULT_THEME.panel, 1);
+ strokeParallelogram(frame, 14, 402, 356, 62, 14, DEFAULT_THEME.line, 1, 0.9);
+ fillSlash(frame, 10, 408, 28, 50, 8, DEFAULT_THEME.crimson, 0.95);
}
function drawScorePlate(frame: Graphics, exScore: number | undefined, exScoreMax: number | undefined): void {
- frame.roundRect(SCORE_PANEL.x, SCORE_PANEL.y, SCORE_PANEL.w, SCORE_PANEL.h, 4).fill(PANEL).stroke({
- color: LINE,
- width: 1,
- });
- frame.rect(SCORE_PANEL.x, SCORE_PANEL.y, SCORE_PANEL.w, 1).fill({ color: CYAN, alpha: 0.3 });
- frame.rect(SCORE_PANEL.x, SCORE_PANEL.y + SCORE_PANEL.h - 3, SCORE_PANEL.w, 3).fill({ color: 0x000000, alpha: 0.35 });
- frame.rect(SCORE_PANEL.x + 12, SCORE_PANEL.y + 42, 128 - 12, 1).fill({ color: LINE, alpha: 0.45 });
- frame.rect(SCORE_PANEL.x + 12, SCORE_PANEL.y + 72, 128 - 12, 1).fill({ color: LINE, alpha: 0.45 });
- frame.rect(SCORE_PANEL.x + 140, SCORE_PANEL.y + 12, 1, SCORE_PANEL.h - 24).fill({ color: LINE, alpha: 0.45 });
-
- // DJ-level meter under the EX RATE row: the fill is the current rate, the ticks are the IIDX ninths that decide
- // the rank letter — the player can see exactly how far the next letter is.
+ fillParallelogram(frame, SCORE_PANEL.x, SCORE_PANEL.y, SCORE_PANEL.w, SCORE_PANEL.h, 12, DEFAULT_THEME.panel, 1);
+ strokeParallelogram(
+ frame,
+ SCORE_PANEL.x,
+ SCORE_PANEL.y,
+ SCORE_PANEL.w,
+ SCORE_PANEL.h,
+ 12,
+ DEFAULT_THEME.line,
+ 1,
+ 0.9,
+ );
+ fillSlash(frame, SCORE_PANEL.x - 4, SCORE_PANEL.y, SCORE_PANEL.w + 8, 5, 3, DEFAULT_THEME.crimson, 0.85);
+ frame.rect(SCORE_PANEL.x + 12, SCORE_PANEL.y + 40, 116, 1).fill({ color: DEFAULT_THEME.line, alpha: 0.6 });
+ frame.rect(SCORE_PANEL.x + 12, SCORE_PANEL.y + 70, 116, 1).fill({ color: DEFAULT_THEME.line, alpha: 0.6 });
+ frame
+ .rect(SCORE_PANEL.x + 140, SCORE_PANEL.y + 12, 1, SCORE_PANEL.h - 24)
+ .fill({ color: DEFAULT_THEME.line, alpha: 0.5 });
+
const meterX = SCORE_PANEL.x + 12;
const meterY = SCORE_PANEL.y + 100;
const meterW = 116;
@@ -627,14 +594,14 @@ function drawScorePlate(frame: Graphics, exScore: number | undefined, exScoreMax
frame.rect(meterX, meterY, meterW, 4).fill({ color: 0x000000, alpha: 0.6 });
if (rate > 0) {
frame.rect(meterX, meterY, Math.max(1, Math.round(meterW * rate)), 4).fill({
- color: rate >= 8 / 9 ? GOLD : CYAN,
- alpha: 0.9,
+ color: rate >= 8 / 9 ? DEFAULT_THEME.gold : DEFAULT_THEME.crimson,
+ alpha: 0.95,
});
}
for (let ninth = 1; ninth < 9; ninth += 1) {
frame.rect(meterX + Math.round((meterW * ninth) / 9), meterY - 1, 1, 6).fill({
- color: TEXT,
- alpha: ninth >= 6 ? 0.5 : 0.22,
+ color: DEFAULT_THEME.paper,
+ alpha: ninth >= 6 ? 0.55 : 0.22,
});
}
}
@@ -647,49 +614,74 @@ const JUDGE_TALLY_ROWS: ReadonlyArray = [
- ['FAST', CYAN, runtime.fast],
- ['SLOW', ORANGE, runtime.slow],
+ ['FAST', DEFAULT_THEME.paper, runtime.fast],
+ ['SLOW', DEFAULT_THEME.gold, runtime.slow],
];
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 + 14, rowY + 4, { size: 8, weight: '800', fill: color, letterSpacing: 0.5 }, pool);
- addText(layer, formatCount(count), x + w - 8, rowY + 1, { ...metricStyle(12, TEXT, 1), maxWidth: w - 48 }, pool);
+ addChromeText(
+ layer,
+ label,
+ x + 14,
+ rowY + 2,
+ { size: 10, fill: color, fontFamily: DEFAULT_NUMERIC_FONT, letterSpacing: 0.8 },
+ pool,
+ );
+ addChromeText(
+ layer,
+ formatCount(count),
+ x + w - 8,
+ rowY,
+ { size: 14, fill: DEFAULT_THEME.paper, fontFamily: DEFAULT_NUMERIC_FONT, maxWidth: w - 48 },
+ pool,
+ );
}
}
@@ -702,6 +694,7 @@ const TALLY_JUDGE_NAMES = {
} as const;
interface ResolvedJudgeDisplay {
+ side: PlaySide;
judge: string;
combo: number | undefined;
x: number;
@@ -717,6 +710,7 @@ function resolveJudgeDisplays(
const sideStates = runtime.judgeSides?.filter((state) => typeof state.judge === 'string' && state.judge.length > 0);
if (sideStates?.length) {
return sideStates.map((state) => ({
+ side: state.side,
judge: state.judge!,
combo: state.combo,
x: resolveJudgeDisplayX(playfield, state.side),
@@ -726,7 +720,15 @@ function resolveJudgeDisplays(
if (!runtime.lastJudge) {
return [];
}
- return [{ judge: runtime.lastJudge, combo: runtime.combo, x: resolveJudgeDisplayX(playfield, '1P'), maxWidth }];
+ return [
+ {
+ side: '1P',
+ judge: runtime.lastJudge,
+ combo: runtime.combo,
+ x: resolveJudgeDisplayX(playfield, '1P'),
+ maxWidth,
+ },
+ ];
}
function resolveJudgeDisplayX(playfield: FallbackPlayfieldLayout, side: PlaySide): number {
@@ -740,97 +742,142 @@ function resolveVisibleCombo(judge: string, combo: number | undefined): number {
return combo !== undefined && Number.isFinite(combo) ? Math.max(0, Math.floor(combo)) : 0;
}
-function addText(
- layer: Container,
- text: string,
- x: number,
- y: number,
- opts: {
- size?: number;
- weight?: '400' | '500' | '600' | '700' | '800' | '900';
- fill?: number;
- fontFamily?: string;
- letterSpacing?: number;
- anchorX?: number;
- anchorY?: number;
- maxWidth?: number;
- stroke?: { color: number; width: number; alignment?: number; join?: 'round' | 'bevel' | 'miter' };
- dropShadow?: { color: number; alpha: number; blur: number; distance: number };
- } = {},
- pool?: ChildPool,
-): Text {
- const node = pool?.acquireText() ?? new Text();
- const style = resolveTextStyle(opts);
- node.text = text;
- if (node.style !== style) {
- node.style = style;
- }
- node.anchor.set(opts.anchorX ?? 0, opts.anchorY ?? 0);
- node.position.set(x, y);
- node.scale.set(1, 1);
- if (opts.maxWidth !== undefined && node.width > opts.maxWidth) {
- node.scale.x = opts.maxWidth / node.width;
+function paintJudgeStamp(layer: Container, display: ResolvedJudgeDisplay, nowMs: number, pool?: ChildPool): void {
+ const slamT = noteJudgeSlam(display.side, display.judge, display.combo, nowMs);
+ const color = defaultJudgeColor(display.judge);
+ const jitter = impactOffset(slamT, 8);
+ const slam = slamScale(slamT, 2.6);
+ const throwX = slamOffset(slamT, -40);
+ if (slamT < 1) {
+ addChromeText(
+ layer,
+ display.judge,
+ display.x,
+ 228,
+ {
+ size: 28,
+ fill: DEFAULT_THEME.crimson,
+ fontFamily: DEFAULT_JUDGE_FONT,
+ anchorX: 0.5,
+ rotation: -0.1,
+ slam,
+ offsetX: throwX - jitter,
+ alpha: 0.55,
+ maxWidth: display.maxWidth,
+ },
+ pool,
+ );
+ addChromeText(
+ layer,
+ display.judge,
+ display.x,
+ 228,
+ {
+ size: 28,
+ fill: DEFAULT_THEME.cyanGhost,
+ fontFamily: DEFAULT_JUDGE_FONT,
+ anchorX: 0.5,
+ rotation: -0.1,
+ slam,
+ offsetX: throwX + jitter,
+ alpha: 0.4,
+ maxWidth: display.maxWidth,
+ },
+ pool,
+ );
}
- if (!pool) {
- layer.addChild(node);
+ addChromeText(
+ layer,
+ display.judge,
+ display.x,
+ 230,
+ {
+ size: 28,
+ fill: color,
+ fontFamily: DEFAULT_JUDGE_FONT,
+ anchorX: 0.5,
+ rotation: -0.08,
+ slam,
+ offsetX: throwX,
+ stroke: { color: DEFAULT_THEME.ink, width: 5, alignment: 0.5, join: 'round' },
+ dropShadow: { color, alpha: 0.45, blur: 6, distance: 0 },
+ maxWidth: display.maxWidth,
+ },
+ pool,
+ );
+ const combo = resolveVisibleCombo(display.judge, display.combo);
+ if (combo > 0) {
+ addChromeText(
+ layer,
+ formatCount(combo),
+ display.x,
+ 258,
+ {
+ size: 22,
+ fill: combo >= 200 ? DEFAULT_THEME.gold : combo >= 50 ? DEFAULT_THEME.crimson : DEFAULT_THEME.paper,
+ fontFamily: DEFAULT_JUDGE_FONT,
+ anchorX: 0.5,
+ rotation: -0.05,
+ slam: slamScale(slamT, 2.1),
+ offsetX: throwX,
+ stroke: { color: DEFAULT_THEME.ink, width: 4, alignment: 0.5, join: 'round' },
+ maxWidth: Math.max(72, display.maxWidth - 36),
+ },
+ pool,
+ );
}
- return node;
+ drawJudgeFlash(layer, display.x, slamT, pool);
}
-function resolveTextStyle(opts: {
- size?: number;
- weight?: '400' | '500' | '600' | '700' | '800' | '900';
- fill?: number;
- fontFamily?: string;
- letterSpacing?: number;
- stroke?: { color: number; width: number; alignment?: number; join?: 'round' | 'bevel' | 'miter' };
- dropShadow?: { color: number; alpha: number; blur: number; distance: number };
-}): TextStyle {
- const stroke = opts.stroke;
- const shadow = opts.dropShadow;
- const key = [
- opts.fill ?? TEXT,
- opts.size ?? 10,
- opts.weight ?? '500',
- opts.fontFamily ?? FONT,
- opts.letterSpacing ?? 0,
- stroke?.color ?? '',
- stroke?.width ?? '',
- stroke?.alignment ?? '',
- stroke?.join ?? '',
- shadow?.color ?? '',
- shadow?.alpha ?? '',
- shadow?.blur ?? '',
- shadow?.distance ?? '',
- ].join('|');
- let style = TEXT_STYLE_CACHE.get(key);
- if (!style) {
- style = new TextStyle({
- fill: opts.fill ?? TEXT,
- fontSize: opts.size ?? 10,
- fontWeight: opts.weight ?? '500',
- fontFamily: opts.fontFamily ?? FONT,
- letterSpacing: opts.letterSpacing ?? 0,
- stroke: opts.stroke,
- ...(shadow ? { dropShadow: { color: shadow.color, alpha: shadow.alpha, blur: shadow.blur, distance: shadow.distance, angle: Math.PI / 2 } } : {}),
- });
- TEXT_STYLE_CACHE.set(key, style);
+function noteJudgeSlam(side: string, judge: string, combo: number | undefined, nowMs: number): number {
+ const key = `${judge}:${combo ?? 0}`;
+ if (judgeSlamKey.get(side) !== key) {
+ judgeSlamKey.set(side, key);
+ judgeSlamAt.set(side, nowMs);
}
- return style;
+ const started = judgeSlamAt.get(side) ?? nowMs;
+ return clamp01((nowMs - started) / JUDGE_SLAM_MS);
}
-const TEXT_STYLE_CACHE = new Map();
+function drawJudgeFlash(layer: Container, x: number, slamT: number, pool?: ChildPool): void {
+ if (slamT >= 1) return;
+ const flash = pool?.acquireGraphics() ?? new Graphics();
+ flash.label = 'default-gameplay/judge-flash';
+ const alpha = (1 - slamT) * 0.45;
+ fillSlash(flash, x - 90, 210, 180, 18, 12, DEFAULT_THEME.paper, alpha);
+ fillSlash(flash, x - 70, 248, 140, 8, -8, DEFAULT_THEME.crimson, alpha * 0.8);
+ if (!pool) {
+ layer.addChild(flash);
+ }
+}
-function labelStyle(): { size: number; weight: '700'; fill: number; letterSpacing: number } {
- return { size: 8, weight: '700', fill: SUBTLE, letterSpacing: 0.8 };
+function drawBeatSlashes(layer: Container, pulse: number, pool?: ChildPool): void {
+ if (pulse <= 0.02) return;
+ const slashes = pool?.acquireGraphics() ?? new Graphics();
+ slashes.label = 'default-gameplay/beat-slash';
+ fillSlash(slashes, -30, 60, 180, 7, 22, DEFAULT_THEME.crimson, 0.28 * pulse);
+ fillSlash(slashes, 480, 300, 200, 6, -18, DEFAULT_THEME.paper, 0.16 * pulse);
+ fillSlash(slashes, 250, 8, 140, 4, 8, DEFAULT_THEME.gold, 0.22 * pulse);
+ if (!pool) {
+ layer.addChild(slashes);
+ }
}
-function metricStyle(
- size: number,
- fill: number,
- anchorX = 0,
-): { size: number; weight: '900'; fill: number; fontFamily: string; anchorX: number } {
- return { size, weight: '900', fill, fontFamily: NUMERIC_FONT, anchorX };
+function drawIntroWipe(layer: Container, introT: number, pool?: ChildPool): void {
+ if (introT >= 1) return;
+ const wipe = pool?.acquireGraphics() ?? new Graphics();
+ wipe.label = 'default-gameplay/intro-wipe';
+ const cover = 1 - introT;
+ fillParallelogram(wipe, -80, -20, DESIGN_WIDTH * cover + 120, DESIGN_HEIGHT + 40, 80, DEFAULT_THEME.ink, 0.96);
+ fillSlash(wipe, DESIGN_WIDTH * cover - 40, -10, 90, DESIGN_HEIGHT + 20, 40, DEFAULT_THEME.crimson, 0.9);
+ fillSlash(wipe, DESIGN_WIDTH * cover + 20, 20, 40, DESIGN_HEIGHT, 28, DEFAULT_THEME.paper, 0.55);
+ if (!pool) {
+ layer.addChild(wipe);
+ }
+}
+
+function stampLabel(): { size: number; fill: number; fontFamily: string; letterSpacing: number } {
+ return { size: 9, fill: DEFAULT_THEME.mute, fontFamily: DEFAULT_NUMERIC_FONT, letterSpacing: 1.1 };
}
function formatCount(value: number | undefined): string {
@@ -870,20 +917,3 @@ function clampPercent(value: number): number {
if (!Number.isFinite(value)) return 0;
return Math.max(0, Math.min(100, value));
}
-
-function judgeColor(judge: string): number {
- switch (judge) {
- case 'PERFECT':
- return 0xffd75e;
- case 'GREAT':
- return GREEN;
- case 'GOOD':
- return BLUE;
- case 'BAD':
- return ORANGE;
- case 'POOR':
- return RED;
- default:
- return TEXT;
- }
-}
diff --git a/packages/player-web/src/scene/default/motion.test.ts b/packages/player-web/src/scene/default/motion.test.ts
new file mode 100644
index 00000000..5d1f65e2
--- /dev/null
+++ b/packages/player-web/src/scene/default/motion.test.ts
@@ -0,0 +1,82 @@
+import { describe, expect, it } from 'vitest';
+import {
+ beatImpulse,
+ clamp01,
+ easeOutBack,
+ easeOutCubic,
+ easeOutExpo,
+ impactOffset,
+ slamOffset,
+ slamScale,
+ staggerProgress,
+} from './motion.ts';
+
+describe('clamp01', () => {
+ it('saturates outside [0, 1] and treats non-finite values as 0', () => {
+ expect(clamp01(-2)).toBe(0);
+ expect(clamp01(0.25)).toBe(0.25);
+ expect(clamp01(4)).toBe(1);
+ expect(clamp01(Number.NaN)).toBe(0);
+ });
+});
+
+describe('easing', () => {
+ it('pins endpoints for the slam family', () => {
+ expect(easeOutCubic(0)).toBe(0);
+ expect(easeOutCubic(1)).toBe(1);
+ expect(easeOutExpo(0)).toBe(0);
+ expect(easeOutExpo(1)).toBe(1);
+ expect(easeOutBack(0)).toBeCloseTo(0, 5);
+ expect(easeOutBack(1)).toBeCloseTo(1, 5);
+ });
+
+ it('overshoots above 1 in the middle of easeOutBack', () => {
+ expect(easeOutBack(0.7)).toBeGreaterThan(1);
+ });
+});
+
+describe('slamScale', () => {
+ it('starts at `from` and settles on 1', () => {
+ expect(slamScale(0, 3)).toBeCloseTo(3, 5);
+ expect(slamScale(1, 3)).toBeCloseTo(1, 5);
+ });
+
+ it('overshoots below 1 while landing from a large start', () => {
+ expect(slamScale(0.7, 2.4)).toBeLessThan(1);
+ });
+});
+
+describe('slamOffset', () => {
+ it('throws in from the left and lands on 0', () => {
+ expect(slamOffset(0, -40)).toBe(-40);
+ expect(slamOffset(1, -40)).toBeCloseTo(0, 5);
+ });
+});
+
+describe('impactOffset', () => {
+ it('is full magnitude on impact and gone when settled', () => {
+ expect(impactOffset(0, 8)).toBe(8);
+ expect(impactOffset(1, 8)).toBeCloseTo(0, 5);
+ });
+});
+
+describe('beatImpulse', () => {
+ it('peaks on the downbeat and is silent past the decay window', () => {
+ expect(beatImpulse(0, 0.25)).toBe(1);
+ expect(beatImpulse(0.125, 0.25)).toBeCloseTo(0.5, 5);
+ expect(beatImpulse(0.4, 0.25)).toBe(0);
+ expect(beatImpulse(undefined)).toBe(0);
+ });
+
+ it('wraps a negative fractional phase the same way a looping beat would', () => {
+ expect(beatImpulse(-0.01, 0.2)).toBeCloseTo(beatImpulse(0.99, 0.2), 5);
+ });
+});
+
+describe('staggerProgress', () => {
+ it('stays at 0 until the delay, then ramps to 1', () => {
+ expect(staggerProgress(40, 80, 100)).toBe(0);
+ expect(staggerProgress(130, 80, 100)).toBeCloseTo(0.5, 5);
+ expect(staggerProgress(220, 80, 100)).toBe(1);
+ });
+});
diff --git a/packages/player-web/src/scene/default/motion.ts b/packages/player-web/src/scene/default/motion.ts
new file mode 100644
index 00000000..edde43fa
--- /dev/null
+++ b/packages/player-web/src/scene/default/motion.ts
@@ -0,0 +1,66 @@
+/**
+ * Easing and slam helpers for the built-in default skin. Persona-5-grade chrome is all overshoot, impact, and
+ * staggered cuts — these stay allocation-free so the pooled gameplay HUD can call them every frame.
+ */
+
+export function clamp01(value: number): number {
+ if (!Number.isFinite(value)) return 0;
+ return Math.max(0, Math.min(1, value));
+}
+
+export function easeOutCubic(t: number): number {
+ const x = clamp01(t);
+ return 1 - (1 - x) ** 3;
+}
+
+export function easeOutExpo(t: number): number {
+ const x = clamp01(t);
+ return x >= 1 ? 1 : 1 - 2 ** (-10 * x);
+}
+
+export function easeOutBack(t: number, overshoot = 1.70158): number {
+ const x = clamp01(t);
+ const c1 = overshoot;
+ const c3 = c1 + 1;
+ return 1 + c3 * (x - 1) ** 3 + c1 * (x - 1) ** 2;
+}
+
+/**
+ * Slam-in scale: `from` at t=0, settling on 1 with a brief overshoot. t is unclamped; values outside [0, 1] saturate.
+ */
+export function slamScale(t: number, from = 2.4): number {
+ return from + (1 - from) * easeOutBack(t);
+}
+
+/**
+ * Horizontal throw-in in design pixels. Negative `from` enters from the left.
+ */
+export function slamOffset(t: number, from = -56): number {
+ return from * (1 - easeOutExpo(t));
+}
+
+/**
+ * Chromatic / impact jitter in design pixels. Full magnitude at t=0, gone by t=1.
+ */
+export function impactOffset(t: number, magnitude = 7): number {
+ return (1 - easeOutCubic(t)) * magnitude;
+}
+
+/**
+ * Downbeat flash. `beatPhase` is the fractional beat in [0, 1); 1 on the attack, 0 after `decay` of the beat.
+ */
+export function beatImpulse(beatPhase: number | undefined, decay = 0.22): number {
+ if (beatPhase === undefined || !Number.isFinite(beatPhase)) return 0;
+ const phase = ((beatPhase % 1) + 1) % 1;
+ if (phase >= decay || decay <= 0) return 0;
+ return 1 - phase / decay;
+}
+
+/**
+ * Staggered 0→1 envelope: silent until `delayMs`, then ramps over `durationMs`.
+ */
+export function staggerProgress(elapsedMs: number, delayMs: number, durationMs: number): number {
+ if (!Number.isFinite(elapsedMs) || !Number.isFinite(delayMs)) return 0;
+ const span = Number.isFinite(durationMs) && durationMs > 0 ? durationMs : 1;
+ return clamp01((elapsedMs - delayMs) / span);
+}
diff --git a/packages/player-web/src/scene/default/playfield.test.ts b/packages/player-web/src/scene/default/playfield.test.ts
new file mode 100644
index 00000000..930ff5ee
--- /dev/null
+++ b/packages/player-web/src/scene/default/playfield.test.ts
@@ -0,0 +1,32 @@
+import { describe, expect, it } from 'vitest';
+import { DEFAULT_THEME, defaultJudgeColor, parallelogramPoints } from './theme.ts';
+import { resolveDefaultLaneTone } from './playfield.ts';
+
+describe('parallelogramPoints', () => {
+ it('shifts only the top edge by the skew', () => {
+ expect(parallelogramPoints(10, 20, 30, 8, 4)).toEqual([14, 20, 44, 20, 40, 28, 10, 28]);
+ });
+});
+
+describe('resolveDefaultLaneTone', () => {
+ it('paints scratch lanes gold (hazard) for 7-key', () => {
+ const tone = resolveDefaultLaneTone('16', 0, '7');
+ expect(tone.body).toBe(DEFAULT_THEME.gold);
+ });
+
+ it('alternates cream and crimson on key lanes', () => {
+ const cream = resolveDefaultLaneTone('11', 1, '7');
+ const crimson = resolveDefaultLaneTone('12', 2, '7');
+ expect(cream.body).toBe(DEFAULT_THEME.paper);
+ expect(crimson.body).toBe(DEFAULT_THEME.crimson);
+ });
+});
+
+describe('defaultJudgeColor', () => {
+ it('maps each judgement name onto the cut-in palette', () => {
+ expect(defaultJudgeColor('PERFECT')).toBe(DEFAULT_THEME.judgePerfect);
+ expect(defaultJudgeColor('GREAT')).toBe(DEFAULT_THEME.judgeGreat);
+ expect(defaultJudgeColor('POOR')).toBe(DEFAULT_THEME.judgePoor);
+ expect(defaultJudgeColor('UNKNOWN')).toBe(DEFAULT_THEME.paper);
+ });
+});
diff --git a/packages/player-web/src/scene/default/playfield.ts b/packages/player-web/src/scene/default/playfield.ts
new file mode 100644
index 00000000..df8d39b4
--- /dev/null
+++ b/packages/player-web/src/scene/default/playfield.ts
@@ -0,0 +1,236 @@
+import type { Graphics } from 'pixi.js';
+import type { ChartPlayVariant } from '@be-music/player/core/lane-layout';
+import { isScratchLaneForVariant } from '../gameplay-lanes.ts';
+import { DEFAULT_THEME, fillParallelogram, fillSlash } from './theme.ts';
+
+export interface DefaultLaneTone {
+ top: number;
+ body: number;
+ bottom: number;
+ cap: number;
+ capLit: number;
+ outline: number;
+}
+
+const TONE_CREAM: DefaultLaneTone = {
+ top: 0xfff7ea,
+ body: DEFAULT_THEME.paper,
+ bottom: 0xb9a888,
+ cap: 0xc4b496,
+ capLit: 0xfff7ea,
+ outline: 0x090605,
+};
+
+const TONE_CRIMSON: DefaultLaneTone = {
+ top: 0xff6a5a,
+ body: DEFAULT_THEME.crimson,
+ bottom: DEFAULT_THEME.blood,
+ cap: 0x4a0a0c,
+ capLit: 0xff3a32,
+ outline: 0x090605,
+};
+
+const TONE_HAZARD: DefaultLaneTone = {
+ top: 0xfff3a8,
+ body: DEFAULT_THEME.gold,
+ bottom: 0xc9a010,
+ cap: 0x3a2a08,
+ capLit: 0xffe14a,
+ outline: 0x090605,
+};
+
+/** Height of a skinless note body in design pixels. Bottom edge sits on the just-timing line. */
+export const DEFAULT_NOTE_HEIGHT = 12;
+const NOTE_SKEW = 4;
+
+export function resolveDefaultLaneTone(
+ channel: string,
+ laneIndex: number,
+ playVariant: ChartPlayVariant | undefined,
+): DefaultLaneTone {
+ if (isScratchLaneForVariant(channel, playVariant)) return TONE_HAZARD;
+ const keyIndex = laneIndex % 10;
+ if (keyIndex % 2 === 0) return TONE_CRIMSON;
+ return TONE_CREAM;
+}
+
+/**
+ * One skinless note: a parallelogram cut with a cream/crimson/gold fill. `y` is the just-timing line.
+ */
+export function drawDefaultNoteBody(graphic: Graphics, x: number, y: number, w: number, tone: DefaultLaneTone): void {
+ const h = DEFAULT_NOTE_HEIGHT;
+ const top = y - h;
+ graphic
+ .poly(notePoints(x, top, w, h, NOTE_SKEW))
+ .fill(tone.body)
+ .stroke({
+ color: tone.outline,
+ width: 1,
+ alignment: 1,
+ });
+ graphic
+ .poly(notePoints(x + 1, top + 1, Math.max(1, w - 2), 2, NOTE_SKEW - 0.4))
+ .fill({ color: tone.top, alpha: 0.95 });
+ graphic.poly(notePoints(x + 1, y - 3, Math.max(1, w - 2), 2, 0.6)).fill({ color: tone.bottom, alpha: 0.9 });
+}
+
+export function drawDefaultLongNoteBody(
+ graphic: Graphics,
+ x: number,
+ top: number,
+ bottom: number,
+ w: number,
+ tone: DefaultLaneTone,
+): void {
+ const bodyTop = top - DEFAULT_NOTE_HEIGHT;
+ const bodyH = Math.max(1, bottom - top);
+ graphic.rect(x + 1, bodyTop, Math.max(1, w - 2), bodyH).fill({ color: tone.body, alpha: 0.28 });
+ graphic.rect(x, bodyTop, 2, bodyH).fill({ color: tone.body, alpha: 0.9 });
+ graphic.rect(x + w - 2, bodyTop, 2, bodyH).fill({ color: tone.body, alpha: 0.9 });
+ drawDefaultNoteBody(graphic, x, bottom, w, tone);
+ drawDefaultNoteBody(graphic, x, top, w, tone);
+}
+
+export function drawDefaultMine(graphic: Graphics, laneX: number, y: number, laneW: number): void {
+ const x = laneX + 2;
+ const w = Math.max(4, laneW - 4);
+ const h = DEFAULT_NOTE_HEIGHT;
+ graphic
+ .poly(notePoints(x, y - h, w, h, NOTE_SKEW))
+ .fill(DEFAULT_THEME.blood)
+ .stroke({
+ color: DEFAULT_THEME.gold,
+ width: 1,
+ alignment: 1,
+ });
+ const stripeStep = 7;
+ for (let sx = x - 10; sx < x + w; sx += stripeStep) {
+ graphic
+ .poly([sx, y - 1, sx + 3, y - 1, sx + 3 + 7, y - h + 1, sx + 7, y - h + 1])
+ .fill({ color: DEFAULT_THEME.gold, alpha: 0.7 });
+ }
+}
+
+export function paintDefaultLane(
+ layer: Graphics,
+ lane: { x: number; w: number; top: number; bottom: number },
+ options: {
+ tone: DefaultLaneTone;
+ scratch: boolean;
+ laserAlpha: number;
+ beatImpulse: number;
+ },
+): void {
+ const { x, w, top, bottom } = lane;
+ const laneHeight = Math.max(1, bottom - top);
+ const pulse = options.beatImpulse;
+
+ layer.rect(x, top, w, laneHeight).fill({
+ color: options.tone.body,
+ alpha: options.scratch ? 0.07 : 0.035,
+ });
+ layer.rect(x, top, 1, laneHeight).fill({ color: DEFAULT_THEME.railEdge, alpha: 0.55 });
+
+ const laserAlpha = options.laserAlpha;
+ if (laserAlpha > 0) {
+ const beamHeight = Math.min(laneHeight, 190);
+ const slices = 5;
+ for (let slice = 0; slice < slices; slice += 1) {
+ const sliceRatio = slice / slices;
+ const sliceH = beamHeight / slices;
+ const sliceY = bottom - beamHeight + sliceRatio * beamHeight;
+ fillParallelogram(
+ layer,
+ x + 1,
+ sliceY,
+ w - 2,
+ sliceH + 1,
+ 3 * (1 - sliceRatio),
+ options.tone.body,
+ (0.06 + 0.48 * sliceRatio * sliceRatio) * laserAlpha,
+ );
+ }
+ fillSlash(layer, x - 2, bottom - 28, w + 8, 10, 4, DEFAULT_THEME.white, 0.28 * laserAlpha);
+ }
+
+ layer.rect(x, bottom - 16, w, 12).fill({ color: DEFAULT_THEME.crimson, alpha: 0.1 + 0.16 * pulse });
+ fillSlash(layer, x - 4, bottom - 6, w + 10, 5, 2, DEFAULT_THEME.crimson, 0.45 + 0.4 * pulse);
+ layer.rect(x, bottom - 2, w, 2).fill({ color: DEFAULT_THEME.paper, alpha: 0.7 + 0.3 * pulse });
+ layer.rect(x, bottom, w, 1).fill({ color: DEFAULT_THEME.white, alpha: 0.85 + 0.15 * pulse });
+
+ const pressed = laserAlpha > 0.6;
+ const capTop = bottom + 3;
+ const capHeight = 14;
+ fillParallelogram(
+ layer,
+ x + 1,
+ capTop,
+ Math.max(2, w - 2),
+ capHeight,
+ pressed ? 3 : 1,
+ pressed ? options.tone.capLit : options.tone.cap,
+ pressed ? 1 : 0.92,
+ );
+ layer.rect(x + 1, capTop, Math.max(2, w - 2), 2).fill({ color: DEFAULT_THEME.white, alpha: pressed ? 0.8 : 0.12 });
+ if (pressed) {
+ fillSlash(layer, x, capTop - 4, w, 4, 1, options.tone.top, 0.95);
+ }
+}
+
+export function paintDefaultLaneGridRight(layer: Graphics, grid: { top: number; bottom: number; right: number }): void {
+ layer.rect(grid.right - 1, grid.top, 1, Math.max(1, grid.bottom - grid.top)).fill({
+ color: DEFAULT_THEME.railEdge,
+ alpha: 0.55,
+ });
+}
+
+/**
+ * Skinless bomb: an expanding X-slash with a white-hot diamond core. `progress` is 0 at trigger, 1 at cleanup.
+ */
+export function drawDefaultBomb(
+ graphic: Graphics,
+ lane: { x: number; w: number; bottom: number },
+ progress: number,
+): void {
+ const fade = 1 - progress;
+ const eased = 1 - (1 - progress) * (1 - progress);
+ const centerX = lane.x + lane.w / 2;
+ const centerY = lane.bottom - Math.max(5, lane.w * 0.18);
+ const span = Math.max(10, lane.w * (0.9 + 1.8 * eased));
+ const thick = Math.max(2, lane.w * (0.22 - progress * 0.08));
+
+ graphic
+ .rect(lane.x + lane.w * 0.12, centerY - span, lane.w * 0.76, span)
+ .fill({ color: DEFAULT_THEME.crimson, alpha: 0.12 * fade });
+
+ const slash = (angleSign: number, color: number, extra = 0): void => {
+ const dx = (span + extra) * 0.72;
+ const dy = (span + extra) * 0.42 * angleSign;
+ graphic
+ .moveTo(centerX - dx, centerY - dy)
+ .lineTo(centerX + dx, centerY + dy)
+ .stroke({ color, width: thick, alpha: 0.9 * fade, cap: 'square', alignment: 0.5 });
+ };
+ slash(1, DEFAULT_THEME.crimson, 2);
+ slash(-1, DEFAULT_THEME.crimson, 2);
+ slash(1, DEFAULT_THEME.paper, -span * 0.12);
+ slash(-1, DEFAULT_THEME.gold, -span * 0.18);
+
+ const diamond = Math.max(3, lane.w * (0.22 + 0.1 * eased));
+ graphic
+ .poly([
+ centerX,
+ centerY - diamond,
+ centerX + diamond * 0.7,
+ centerY,
+ centerX,
+ centerY + diamond,
+ centerX - diamond * 0.7,
+ centerY,
+ ])
+ .fill({ color: DEFAULT_THEME.white, alpha: 0.85 * fade });
+}
+
+function notePoints(x: number, y: number, w: number, h: number, skew: number): number[] {
+ return [x + skew, y, x + w + skew, y, x + w, y + h, x, y + h];
+}
diff --git a/packages/player-web/src/scene/default/result-render.test.ts b/packages/player-web/src/scene/default/result-render.test.ts
new file mode 100644
index 00000000..dae457d0
--- /dev/null
+++ b/packages/player-web/src/scene/default/result-render.test.ts
@@ -0,0 +1,29 @@
+import { describe, expect, it } from 'vitest';
+import { resultRankSlam } from './result-render.ts';
+import { beginDefaultSelectMotion } from './select-render.ts';
+
+describe('resultRankSlam', () => {
+ it('stays hidden until the rank timer has elapsed', () => {
+ expect(resultRankSlam(0).visible).toBe(false);
+ expect(resultRankSlam(-1).visible).toBe(false);
+ });
+
+ it('starts oversized and lands near 1', () => {
+ const start = resultRankSlam(1);
+ expect(start.visible).toBe(true);
+ expect(start.scale).toBeGreaterThan(2);
+ expect(resultRankSlam(420).scale).toBeCloseTo(1, 5);
+ expect(resultRankSlam(420).offsetX).toBeCloseTo(0, 5);
+ });
+});
+
+describe('beginDefaultSelectMotion', () => {
+ it('resets on a fresh scene and slams when the cursor moves', () => {
+ const intro = beginDefaultSelectMotion(0, 10);
+ expect(intro.selectedSlamT).toBe(1);
+ const moved = beginDefaultSelectMotion(3, 400);
+ expect(moved.selectedSlamT).toBe(0);
+ const later = beginDefaultSelectMotion(3, 620);
+ expect(later.selectedSlamT).toBe(1);
+ });
+});
diff --git a/packages/player-web/src/scene/default/result-render.ts b/packages/player-web/src/scene/default/result-render.ts
new file mode 100644
index 00000000..db568d16
--- /dev/null
+++ b/packages/player-web/src/scene/default/result-render.ts
@@ -0,0 +1,317 @@
+import { Container, Graphics } from 'pixi.js';
+import { addChromeText } from './chrome-text.ts';
+import { DEFAULT_JUDGE_FONT, DEFAULT_NUMERIC_FONT, DEFAULT_TEXT_FONT } from './fonts.ts';
+import { clamp01, slamOffset, slamScale, staggerProgress } from './motion.ts';
+import { DEFAULT_THEME, fillParallelogram, fillSlash, strokeParallelogram } from './theme.ts';
+
+export interface DefaultResultViewModel {
+ cleared: boolean;
+ title: string;
+ artist?: string;
+ rank: string;
+ rate: number;
+ score: number;
+ exScore: number;
+ exMax: number;
+ maxCombo: number;
+ gauge: number;
+ playSeconds: number;
+ notes: number;
+ perfect: number;
+ great: number;
+ good: number;
+ bad: number;
+ poor: number;
+ gaugeHistory: Array<{ progress: number; value: number }>;
+ scoreHistory: Array<{ progress: number; exScore: number }>;
+}
+
+export interface DefaultResultChromeInput {
+ designWidth: number;
+ designHeight: number;
+ sceneElapsedMs: number;
+ rankRevealed: boolean;
+ rankElapsedMs: number;
+ result: DefaultResultViewModel;
+}
+
+const RANK_SLAM_MS = 420;
+
+export function resultRankSlam(elapsedMs: number): { scale: number; offsetX: number; visible: boolean } {
+ if (!Number.isFinite(elapsedMs) || elapsedMs <= 0) {
+ return { scale: 1, offsetX: 0, visible: false };
+ }
+ const t = clamp01(elapsedMs / RANK_SLAM_MS);
+ return { scale: slamScale(t, 3.2), offsetX: slamOffset(t, -88), visible: true };
+}
+
+/**
+ * Skinless result chrome: a CLEARED/FAILED slash banner, slamming rank letter, and staggered judge bars.
+ */
+export function renderDefaultResultChrome(layer: Container, input: DefaultResultChromeInput): void {
+ const { designWidth, designHeight, sceneElapsedMs, result } = input;
+ const chrome = new Graphics();
+ chrome.label = 'default-result/chrome';
+ const intro = staggerProgress(sceneElapsedMs, 0, 380);
+ const statusColor = result.cleared ? DEFAULT_THEME.paper : DEFAULT_THEME.crimson;
+ const statusLabel = result.cleared ? 'CLEARED' : 'FAILED';
+
+ chrome.rect(0, 0, designWidth, designHeight).fill(DEFAULT_THEME.ink);
+ fillSlash(chrome, -100, -20, 420, 70, 36, result.cleared ? DEFAULT_THEME.crimson : DEFAULT_THEME.blood, 0.95);
+ fillSlash(chrome, 240, 8, 480, 16, 12, DEFAULT_THEME.paper, 0.12);
+ fillParallelogram(chrome, -20, 0, designWidth + 40, 50, 24, DEFAULT_THEME.inkDeep, 0.9);
+
+ addChromeText(layer, statusLabel, 20, 6, {
+ size: 28,
+ fill: statusColor,
+ fontFamily: DEFAULT_NUMERIC_FONT,
+ letterSpacing: 2.2,
+ rotation: -0.1,
+ slam: slamScale(intro, 2.8),
+ offsetX: slamOffset(intro, -90),
+ stroke: { color: DEFAULT_THEME.ink, width: 6, alignment: 0.5, join: 'round' },
+ });
+ addChromeText(layer, `${result.title}${result.artist ? ` / ${result.artist}` : ''}`, designWidth - 18, 14, {
+ size: 11,
+ fill: DEFAULT_THEME.paper,
+ fontFamily: DEFAULT_TEXT_FONT,
+ anchorX: 1,
+ maxWidth: 360,
+ slam: slamScale(staggerProgress(sceneElapsedMs, 80, 280), 1.4),
+ });
+
+ fillParallelogram(chrome, 14, 68, 180, 156, 14, DEFAULT_THEME.panel, 1);
+ strokeParallelogram(chrome, 14, 68, 180, 156, 14, DEFAULT_THEME.crimson, 2, 0.85);
+ fillParallelogram(chrome, 206, 68, 198, 156, 12, DEFAULT_THEME.panel, 1);
+ strokeParallelogram(chrome, 206, 68, 198, 156, 12, DEFAULT_THEME.line, 1, 0.85);
+ fillParallelogram(chrome, 416, 68, 206, 156, 12, DEFAULT_THEME.panel, 1);
+ strokeParallelogram(chrome, 416, 68, 206, 156, 12, DEFAULT_THEME.line, 1, 0.85);
+ fillParallelogram(chrome, 14, 240, 292, 188, 14, DEFAULT_THEME.panel, 1);
+ strokeParallelogram(chrome, 14, 240, 292, 188, 14, DEFAULT_THEME.line, 1, 0.85);
+ fillParallelogram(chrome, 318, 240, 306, 188, 12, DEFAULT_THEME.panel, 1);
+ strokeParallelogram(chrome, 318, 240, 306, 188, 12, DEFAULT_THEME.line, 1, 0.85);
+ fillParallelogram(chrome, -16, designHeight - 36, designWidth + 32, 40, 16, DEFAULT_THEME.inkDeep, 1);
+
+ addChromeText(layer, 'RANK', 36, 84, stampLabel());
+ const rankMotion = input.rankRevealed
+ ? resultRankSlam(input.rankElapsedMs)
+ : { scale: 1, offsetX: 0, visible: false };
+ if (rankMotion.visible) {
+ for (let line = 0; line < 6; line += 1) {
+ fillSlash(
+ chrome,
+ 28 + line * 18,
+ 118 + line * 4,
+ 140 - line * 8,
+ 3,
+ 6,
+ DEFAULT_THEME.paper,
+ (1 - clamp01(input.rankElapsedMs / 280)) * 0.35,
+ );
+ }
+ addChromeText(layer, result.rank, 104, 140, {
+ size: result.rank.length >= 3 ? 44 : 64,
+ fill: DEFAULT_THEME.gold,
+ fontFamily: DEFAULT_NUMERIC_FONT,
+ anchorX: 0.5,
+ anchorY: 0.5,
+ rotation: -0.12,
+ slam: rankMotion.scale,
+ offsetX: rankMotion.offsetX,
+ stroke: { color: DEFAULT_THEME.ink, width: 6, alignment: 0.5, join: 'round' },
+ maxWidth: 128,
+ });
+ } else {
+ fillSlash(chrome, 36, 136, 140, 18, 8, DEFAULT_THEME.inkDeep, 0.95);
+ addChromeText(layer, '???', 104, 140, {
+ size: 36,
+ fill: DEFAULT_THEME.mute,
+ fontFamily: DEFAULT_NUMERIC_FONT,
+ anchorX: 0.5,
+ anchorY: 0.5,
+ rotation: -0.08,
+ });
+ }
+ addChromeText(layer, `${result.rate.toFixed(1)}%`, 104, 188, {
+ size: 18,
+ fill: DEFAULT_THEME.paper,
+ fontFamily: DEFAULT_NUMERIC_FONT,
+ anchorX: 0.5,
+ slam: slamScale(staggerProgress(sceneElapsedMs, 120, 260), 1.6),
+ });
+
+ paintMetric(layer, chrome, 224, 86, 'SCORE', String(result.score), DEFAULT_THEME.gold, sceneElapsedMs, 80);
+ paintMetric(
+ layer,
+ chrome,
+ 224,
+ 132,
+ 'EX SCORE',
+ `${result.exScore} / ${result.exMax}`,
+ DEFAULT_THEME.paper,
+ sceneElapsedMs,
+ 110,
+ );
+ paintMetric(
+ layer,
+ chrome,
+ 224,
+ 178,
+ 'MAX COMBO',
+ String(result.maxCombo),
+ DEFAULT_THEME.crimson,
+ sceneElapsedMs,
+ 140,
+ );
+ paintMetric(layer, chrome, 434, 86, 'GAUGE', `${Math.round(result.gauge)}%`, statusColor, sceneElapsedMs, 100);
+ paintMetric(
+ layer,
+ chrome,
+ 434,
+ 132,
+ 'PLAY TIME',
+ `${result.playSeconds.toFixed(1)}s`,
+ DEFAULT_THEME.paper,
+ sceneElapsedMs,
+ 130,
+ );
+ paintMetric(layer, chrome, 434, 178, 'NOTES', String(result.notes), DEFAULT_THEME.paper, sceneElapsedMs, 160);
+
+ addChromeText(layer, 'JUDGEMENT', 32, 254, stampLabel());
+ const judges: Array = [
+ ['PGREAT', String(result.perfect), DEFAULT_THEME.gold, result.perfect],
+ ['GREAT', String(result.great), DEFAULT_THEME.paper, result.great],
+ ['GOOD', String(result.good), DEFAULT_THEME.paperDim, result.good],
+ ['BAD', String(result.bad), 0xff8a3d, result.bad],
+ ['POOR', String(result.poor), DEFAULT_THEME.crimson, result.poor],
+ ];
+ for (let i = 0; i < judges.length; i += 1) {
+ const jy = 280 + i * 24;
+ const [label, value, fill, count] = judges[i]!;
+ const barT = staggerProgress(sceneElapsedMs, 180 + i * 55, 280);
+ fillParallelogram(chrome, 32, jy, 254, 20, 6, DEFAULT_THEME.inkDeep, 1);
+ const barW = Math.min(248, count * 2) * barT;
+ if (barW > 0) {
+ fillSlash(chrome, 34, jy + 2, barW, 16, 3, fill, 0.28);
+ }
+ addChromeText(layer, label, 44, jy + 3, {
+ size: 11,
+ fill,
+ fontFamily: DEFAULT_JUDGE_FONT,
+ slam: slamScale(barT, 1.5),
+ offsetX: slamOffset(barT, -20),
+ });
+ addChromeText(layer, value, 270, jy + 1, {
+ size: 15,
+ fill: DEFAULT_THEME.paper,
+ fontFamily: DEFAULT_JUDGE_FONT,
+ anchorX: 1,
+ slam: slamScale(barT, 1.7),
+ });
+ }
+
+ addChromeText(layer, 'RUN', 336, 254, stampLabel());
+ chrome.rect(336, 286, 268, 48).fill(DEFAULT_THEME.inkDeep);
+ chrome.rect(336, 356, 268, 48).fill(DEFAULT_THEME.inkDeep);
+ addChromeText(layer, 'Gauge', 348, 298, { size: 9, fill: DEFAULT_THEME.mute, fontFamily: DEFAULT_NUMERIC_FONT });
+ addChromeText(layer, 'EX score', 348, 368, { size: 9, fill: DEFAULT_THEME.mute, fontFamily: DEFAULT_NUMERIC_FONT });
+ drawSeries(
+ chrome,
+ 420,
+ 294,
+ 168,
+ 32,
+ result.gaugeHistory.map((sample) => ({ x: sample.progress, y: sample.value / 100 })),
+ statusColor,
+ );
+ drawSeries(
+ chrome,
+ 420,
+ 364,
+ 168,
+ 32,
+ result.scoreHistory.map((sample) => ({
+ x: sample.progress,
+ y: result.exMax > 0 ? sample.exScore / result.exMax : 0,
+ })),
+ DEFAULT_THEME.gold,
+ );
+
+ addChromeText(layer, 'TOTAL SCORE', 18, designHeight - 24, stampLabel());
+ addChromeText(layer, String(result.score), 150, designHeight - 30, {
+ size: 22,
+ fill: DEFAULT_THEME.paper,
+ fontFamily: DEFAULT_NUMERIC_FONT,
+ slam: slamScale(staggerProgress(sceneElapsedMs, 200, 280), 1.8),
+ offsetX: slamOffset(staggerProgress(sceneElapsedMs, 200, 280), -24),
+ });
+
+ const wipeT = staggerProgress(sceneElapsedMs, 0, 360);
+ if (wipeT < 1) {
+ fillParallelogram(
+ chrome,
+ -80,
+ -20,
+ designWidth * (1 - wipeT) + 140,
+ designHeight + 40,
+ 70,
+ DEFAULT_THEME.ink,
+ 0.96,
+ );
+ fillSlash(chrome, designWidth * (1 - wipeT) - 30, -10, 80, designHeight + 20, 36, DEFAULT_THEME.crimson, 0.9);
+ }
+
+ layer.addChildAt(chrome, 0);
+}
+
+function paintMetric(
+ layer: Container,
+ chrome: Graphics,
+ x: number,
+ y: number,
+ label: string,
+ value: string,
+ fill: number,
+ elapsedMs: number,
+ delayMs: number,
+): void {
+ const t = staggerProgress(elapsedMs, delayMs, 240);
+ fillParallelogram(chrome, x, y, 160, 32, 8, DEFAULT_THEME.inkDeep, 1);
+ addChromeText(layer, label, x + 10, y + 4, stampLabel());
+ addChromeText(layer, value, x + 148, y + 12, {
+ size: 15,
+ fill,
+ fontFamily: DEFAULT_NUMERIC_FONT,
+ anchorX: 1,
+ maxWidth: 92,
+ slam: slamScale(t, 1.7),
+ offsetX: slamOffset(t, -16),
+ });
+}
+
+function drawSeries(
+ chrome: Graphics,
+ x: number,
+ y: number,
+ w: number,
+ h: number,
+ points: Array<{ x: number; y: number }>,
+ color: number,
+): void {
+ if (points.length === 0) return;
+ chrome.rect(x, y + h, w, 1).fill({ color: DEFAULT_THEME.paper, alpha: 0.16 });
+ const first = points[0]!;
+ chrome.moveTo(x + clamp01(first.x) * w, y + (1 - clamp01(first.y)) * h);
+ for (let i = 1; i < points.length; i += 1) {
+ const point = points[i]!;
+ chrome.lineTo(x + clamp01(point.x) * w, y + (1 - clamp01(point.y)) * h);
+ }
+ if (points.length === 1) {
+ chrome.lineTo(x + clamp01(first.x) * w + 0.5, y + (1 - clamp01(first.y)) * h);
+ }
+ chrome.stroke({ color, width: 2, alpha: 0.95, alignment: 0.5 });
+}
+
+function stampLabel(): { size: number; fill: number; fontFamily: string; letterSpacing: number } {
+ return { size: 9, fill: DEFAULT_THEME.mute, fontFamily: DEFAULT_NUMERIC_FONT, letterSpacing: 1.2 };
+}
diff --git a/packages/player-web/src/scene/default/select-layout.test.ts b/packages/player-web/src/scene/default/select-layout.test.ts
new file mode 100644
index 00000000..e1693dd1
--- /dev/null
+++ b/packages/player-web/src/scene/default/select-layout.test.ts
@@ -0,0 +1,44 @@
+import { describe, expect, it } from 'vitest';
+import {
+ DEFAULT_SELECT_LAYOUT,
+ defaultSelectEntryIndexAt,
+ defaultSelectListWidth,
+ isInsideDefaultSelectList,
+ resolveDefaultSelectVisibleWindow,
+} from './select-layout.ts';
+
+describe('resolveDefaultSelectVisibleWindow', () => {
+ it('keeps the selected row near the vertical center on the 640×480 canvas', () => {
+ const { start, visibleRows } = resolveDefaultSelectVisibleWindow(20, 80, 480);
+ expect(visibleRows).toBe(Math.floor((480 - 26 - 54) / 28));
+ expect(start).toBe(20 - Math.floor(visibleRows / 2));
+ });
+
+ it('clamps to the start of a short list', () => {
+ expect(resolveDefaultSelectVisibleWindow(0, 3, 480).start).toBe(0);
+ });
+});
+
+describe('isInsideDefaultSelectList', () => {
+ it('matches the historic fallback list rectangle', () => {
+ expect(isInsideDefaultSelectList(320, 54, 480)).toBe(true);
+ expect(isInsideDefaultSelectList(319, 54, 480)).toBe(false);
+ expect(isInsideDefaultSelectList(400, 53, 480)).toBe(false);
+ expect(isInsideDefaultSelectList(400, 454, 480)).toBe(true);
+ expect(isInsideDefaultSelectList(400, 455, 480)).toBe(false);
+ });
+});
+
+describe('defaultSelectEntryIndexAt', () => {
+ it('maps a y coordinate onto the visible window', () => {
+ expect(defaultSelectEntryIndexAt(54, 10, 40)).toBe(10);
+ expect(defaultSelectEntryIndexAt(54 + 28, 10, 40)).toBe(11);
+ expect(defaultSelectEntryIndexAt(54, 0, 0)).toBeUndefined();
+ });
+});
+
+describe('defaultSelectListWidth', () => {
+ it('leaves the same right gutter the fallback chrome used', () => {
+ expect(defaultSelectListWidth(640)).toBe(640 - DEFAULT_SELECT_LAYOUT.listX - DEFAULT_SELECT_LAYOUT.listRightInset);
+ });
+});
diff --git a/packages/player-web/src/scene/default/select-layout.ts b/packages/player-web/src/scene/default/select-layout.ts
new file mode 100644
index 00000000..3d9bad6a
--- /dev/null
+++ b/packages/player-web/src/scene/default/select-layout.ts
@@ -0,0 +1,56 @@
+/**
+ * Shared geometry for the skinless song-select list. Render and pointer hit-testing MUST read these numbers from the
+ * same object — a drift of even one pixel desyncs clicks from the visible rows.
+ */
+export const DEFAULT_SELECT_LAYOUT = {
+ listX: 320,
+ listTop: 54,
+ listBottomInset: 26,
+ rowHeight: 28,
+ listRightInset: 16,
+ play: { x: 24, y: 312, w: 82, h: 28 },
+ autoPlay: { x: 112, y: 312, w: 180, h: 28 },
+ search: { x: 12, y: 376, w: 292, h: 28 },
+} as const;
+
+export interface DefaultSelectVisibleWindow {
+ start: number;
+ visibleRows: number;
+}
+
+export function defaultSelectListWidth(designWidth: number): number {
+ return designWidth - DEFAULT_SELECT_LAYOUT.listX - DEFAULT_SELECT_LAYOUT.listRightInset;
+}
+
+export function defaultSelectListBottom(designHeight: number): number {
+ return designHeight - DEFAULT_SELECT_LAYOUT.listBottomInset;
+}
+
+export function resolveDefaultSelectVisibleWindow(
+ selectedIndex: number,
+ entryCount: number,
+ designHeight: number,
+): DefaultSelectVisibleWindow {
+ const listBottom = defaultSelectListBottom(designHeight);
+ const visibleRows = Math.max(
+ 1,
+ Math.floor((listBottom - DEFAULT_SELECT_LAYOUT.listTop) / DEFAULT_SELECT_LAYOUT.rowHeight),
+ );
+ const start = Math.max(
+ 0,
+ Math.min(selectedIndex - Math.floor(visibleRows / 2), Math.max(0, entryCount - visibleRows)),
+ );
+ return { start, visibleRows };
+}
+
+export function isInsideDefaultSelectList(x: number, y: number, designHeight: number): boolean {
+ const listBottom = defaultSelectListBottom(designHeight);
+ return x >= DEFAULT_SELECT_LAYOUT.listX && y >= DEFAULT_SELECT_LAYOUT.listTop && y <= listBottom;
+}
+
+export function defaultSelectEntryIndexAt(y: number, start: number, entryCount: number): number | undefined {
+ const visibleRow = Math.floor((y - DEFAULT_SELECT_LAYOUT.listTop) / DEFAULT_SELECT_LAYOUT.rowHeight);
+ const entryIndex = start + visibleRow;
+ if (entryIndex < 0 || entryIndex >= entryCount) return undefined;
+ return entryIndex;
+}
diff --git a/packages/player-web/src/scene/default/select-render.ts b/packages/player-web/src/scene/default/select-render.ts
new file mode 100644
index 00000000..9f71249f
--- /dev/null
+++ b/packages/player-web/src/scene/default/select-render.ts
@@ -0,0 +1,387 @@
+import { Container, Graphics } from 'pixi.js';
+import { resolveChartPlayVariant } from '../../collection/collection.ts';
+import type { BrowserBrowseEntry, BrowserSongEntry } from '../../collection/types.ts';
+import { addChromeText } from './chrome-text.ts';
+import { DEFAULT_NUMERIC_FONT, DEFAULT_TEXT_FONT } from './fonts.ts';
+import { slamOffset, slamScale, staggerProgress } from './motion.ts';
+import { DEFAULT_SELECT_LAYOUT } from './select-layout.ts';
+import { DEFAULT_THEME, fillParallelogram, fillSlash, strokeParallelogram } from './theme.ts';
+
+export interface DefaultSelectChromeInput {
+ designWidth: number;
+ designHeight: number;
+ elapsedMs: number;
+ selectedIndex: number;
+ entries: readonly BrowserBrowseEntry[];
+ searchQuery: string;
+ categoryName: string;
+ libraryShown: number;
+ libraryTotal: number;
+ song?: BrowserSongEntry;
+ onSearchActivate?: () => void;
+ onPlay?: () => void;
+ onAutoPlay?: () => void;
+}
+
+export interface DefaultSelectRowInput {
+ entry: BrowserBrowseEntry;
+ entryIndex: number;
+ visibleIndex: number;
+ selectedIndex: number;
+ elapsedMs: number;
+ listX: number;
+ listY: number;
+ listWidth: number;
+ rowHeight: number;
+ selectedSlamT: number;
+}
+
+let lastSelectedIndex = Number.NaN;
+let selectedSlamAtMs = 0;
+
+export function beginDefaultSelectMotion(selectedIndex: number, elapsedMs: number): { selectedSlamT: number } {
+ if (elapsedMs < 32) {
+ lastSelectedIndex = selectedIndex;
+ selectedSlamAtMs = elapsedMs - 1000;
+ return { selectedSlamT: 1 };
+ }
+ if (selectedIndex !== lastSelectedIndex) {
+ lastSelectedIndex = selectedIndex;
+ selectedSlamAtMs = elapsedMs;
+ }
+ return { selectedSlamT: staggerProgress(elapsedMs, selectedSlamAtMs, 220) };
+}
+
+/**
+ * Skinless song-select chrome: diagonal plates, slam-in title, and cut-in PLAY / AUTO stamps.
+ */
+export function renderDefaultSelectChrome(layer: Container, input: DefaultSelectChromeInput): void {
+ const { designWidth, designHeight, elapsedMs } = input;
+ const intro = staggerProgress(elapsedMs, 0, 420);
+ const chrome = new Graphics();
+ chrome.label = 'default-select/chrome';
+ chrome.rect(0, 0, designWidth, designHeight).fill(DEFAULT_THEME.ink);
+ fillSlash(chrome, -80, -10, 360, 48, 28, DEFAULT_THEME.crimson, 0.95);
+ fillSlash(chrome, 300, 8, 380, 18, 10, DEFAULT_THEME.paper, 0.18);
+ fillParallelogram(chrome, -16, 0, designWidth + 32, 42, 22, DEFAULT_THEME.inkDeep, 0.92);
+
+ const song = input.song;
+ const songTitle = song?.title ?? 'No chart selected';
+ const songArtist = song?.artist || song?.subtitle || '';
+ const playLevel = song?.playLevel !== undefined ? String(song.playLevel) : '-';
+ const playLevelNumber =
+ song?.playLevel !== undefined ? Number.parseFloat(String(song.playLevel).replace(/^[^\d.]+/u, '')) : NaN;
+ const songBpm = song?.bpm !== undefined ? String(Math.round(song.bpm)) : '-';
+ const fileLabel = song?.fileLabel ?? '';
+ const modeLabel = formatDefaultSelectModeLabel(song);
+ const selectedPosition =
+ input.entries.length > 0
+ ? `${Math.min(input.selectedIndex + 1, input.entries.length)} / ${input.entries.length}`
+ : '0 / 0';
+
+ addChromeText(layer, 'MUSIC SELECT', 18, 6, {
+ size: 22,
+ fill: DEFAULT_THEME.paper,
+ fontFamily: DEFAULT_NUMERIC_FONT,
+ letterSpacing: 2,
+ rotation: -0.08,
+ slam: slamScale(intro, 2.4),
+ offsetX: slamOffset(intro, -64),
+ stroke: { color: DEFAULT_THEME.ink, width: 5, alignment: 0.5, join: 'round' },
+ });
+ addChromeText(layer, input.categoryName, designWidth - 16, 10, {
+ size: 11,
+ fill: DEFAULT_THEME.paper,
+ fontFamily: DEFAULT_TEXT_FONT,
+ anchorX: 1,
+ maxWidth: 240,
+ slam: slamScale(staggerProgress(elapsedMs, 80, 280), 1.5),
+ });
+
+ fillParallelogram(chrome, 8, 50, 300, 314, 16, DEFAULT_THEME.panel, 1);
+ strokeParallelogram(chrome, 8, 50, 300, 314, 16, DEFAULT_THEME.crimson, 2, 0.85);
+ fillSlash(chrome, 4, 52, 40, 48, 10, DEFAULT_THEME.crimson, 1);
+ chrome.rect(22, 96, 268, 2).fill({ color: DEFAULT_THEME.crimson, alpha: 0.7 });
+
+ fillParallelogram(chrome, 20, 148, 80, 50, 8, DEFAULT_THEME.inkDeep, 1);
+ fillParallelogram(chrome, 108, 148, 80, 50, 8, DEFAULT_THEME.inkDeep, 1);
+ fillParallelogram(chrome, 196, 148, 96, 50, 8, DEFAULT_THEME.inkDeep, 1);
+ chrome.rect(22, 224, 268, 12).fill(DEFAULT_THEME.inkDeep);
+ if (Number.isFinite(playLevelNumber)) {
+ const levelRatio = Math.max(0.04, Math.min(1, playLevelNumber / 12));
+ fillSlash(chrome, 22, 224, Math.round(268 * levelRatio), 12, 3, DEFAULT_THEME.crimson, 0.95);
+ }
+
+ const play = DEFAULT_SELECT_LAYOUT.play;
+ const autoPlay = DEFAULT_SELECT_LAYOUT.autoPlay;
+ fillParallelogram(chrome, play.x, play.y, play.w, play.h, 8, DEFAULT_THEME.panelLift, 1);
+ strokeParallelogram(chrome, play.x, play.y, play.w, play.h, 8, DEFAULT_THEME.paper, 1, 0.7);
+ fillParallelogram(chrome, autoPlay.x, autoPlay.y, autoPlay.w, autoPlay.h, 10, DEFAULT_THEME.crimson, 1);
+
+ addChromeText(layer, 'SELECTED', 24, 62, stampLabel());
+ addChromeText(layer, songTitle, 24, 100, {
+ size: 17,
+ fill: DEFAULT_THEME.paper,
+ fontFamily: DEFAULT_TEXT_FONT,
+ maxWidth: 268,
+ slam: slamScale(staggerProgress(elapsedMs, 60, 280), 1.45),
+ offsetX: slamOffset(staggerProgress(elapsedMs, 60, 280), -36),
+ rotation: -0.02,
+ });
+ if (songArtist) {
+ addChromeText(layer, songArtist, 24, 126, {
+ size: 10,
+ fill: DEFAULT_THEME.mute,
+ fontFamily: DEFAULT_TEXT_FONT,
+ maxWidth: 268,
+ });
+ }
+ addChromeText(layer, 'MODE', 32, 154, stampLabel());
+ addChromeText(layer, modeLabel, 32, 170, {
+ size: 13,
+ fill: DEFAULT_THEME.paper,
+ fontFamily: DEFAULT_NUMERIC_FONT,
+ maxWidth: 60,
+ });
+ addChromeText(layer, 'BPM', 118, 154, stampLabel());
+ addChromeText(layer, songBpm, 118, 168, {
+ size: 18,
+ fill: DEFAULT_THEME.gold,
+ fontFamily: DEFAULT_NUMERIC_FONT,
+ });
+ addChromeText(layer, 'LEVEL', 208, 154, stampLabel());
+ addChromeText(layer, playLevel, 208, 164, {
+ size: 22,
+ fill: DEFAULT_THEME.paper,
+ fontFamily: DEFAULT_NUMERIC_FONT,
+ rotation: -0.06,
+ slam: slamScale(staggerProgress(elapsedMs, 90, 240), 2.2),
+ });
+ if (fileLabel) {
+ addChromeText(layer, fileLabel, 24, 248, {
+ size: 8,
+ fill: DEFAULT_THEME.mute,
+ fontFamily: DEFAULT_TEXT_FONT,
+ maxWidth: 268,
+ });
+ }
+ addChromeText(layer, 'PLAY', play.x + play.w / 2, play.y + 6, {
+ size: 14,
+ fill: DEFAULT_THEME.paper,
+ fontFamily: DEFAULT_NUMERIC_FONT,
+ anchorX: 0.5,
+ letterSpacing: 1.4,
+ slam: slamScale(staggerProgress(elapsedMs, 140, 240), 1.8),
+ });
+ addChromeText(layer, 'AUTO PLAY', autoPlay.x + autoPlay.w / 2, autoPlay.y + 6, {
+ size: 14,
+ fill: DEFAULT_THEME.ink,
+ fontFamily: DEFAULT_NUMERIC_FONT,
+ anchorX: 0.5,
+ letterSpacing: 1.2,
+ maxWidth: 158,
+ slam: slamScale(staggerProgress(elapsedMs, 170, 240), 1.8),
+ });
+ addChromeText(layer, selectedPosition, 24, 346, {
+ size: 12,
+ fill: DEFAULT_THEME.mute,
+ fontFamily: DEFAULT_NUMERIC_FONT,
+ });
+
+ fillParallelogram(
+ chrome,
+ DEFAULT_SELECT_LAYOUT.listX - 6,
+ 46,
+ designWidth - DEFAULT_SELECT_LAYOUT.listX - 8,
+ designHeight - 70,
+ 12,
+ DEFAULT_THEME.inkDeep,
+ 0.9,
+ );
+ strokeParallelogram(
+ chrome,
+ DEFAULT_SELECT_LAYOUT.listX - 6,
+ 46,
+ designWidth - DEFAULT_SELECT_LAYOUT.listX - 8,
+ designHeight - 70,
+ 12,
+ DEFAULT_THEME.line,
+ 1,
+ 0.85,
+ );
+ addChromeText(layer, input.searchQuery ? 'SEARCH RESULTS' : 'CHARTS', 328, 22, {
+ size: 11,
+ fill: DEFAULT_THEME.paper,
+ fontFamily: DEFAULT_NUMERIC_FONT,
+ letterSpacing: 1.4,
+ rotation: -0.05,
+ slam: slamScale(staggerProgress(elapsedMs, 50, 260), 1.7),
+ });
+
+ const search = DEFAULT_SELECT_LAYOUT.search;
+ fillParallelogram(chrome, search.x, search.y, search.w, search.h, 8, DEFAULT_THEME.inkDeep, 1);
+ strokeParallelogram(chrome, search.x, search.y, search.w, search.h, 8, DEFAULT_THEME.line, 1, 0.8);
+ addChromeText(layer, 'SEARCH', 24, 382, stampLabel());
+ addChromeText(layer, input.searchQuery || 'Title / artist / genre', 82, 380, {
+ size: 10,
+ fill: input.searchQuery ? DEFAULT_THEME.paper : DEFAULT_THEME.mute,
+ fontFamily: DEFAULT_TEXT_FONT,
+ maxWidth: 210,
+ });
+
+ fillParallelogram(chrome, 8, 416, 300, 48, 10, DEFAULT_THEME.panel, 1);
+ addChromeText(layer, 'LIBRARY', 24, 424, stampLabel());
+ addChromeText(layer, `${input.libraryShown} shown / ${input.libraryTotal} charts`, 24, 438, {
+ size: 11,
+ fill: DEFAULT_THEME.mute,
+ fontFamily: DEFAULT_TEXT_FONT,
+ });
+
+ const searchHit = new Graphics();
+ searchHit.rect(search.x, search.y, search.w, search.h).fill({ color: 0xffffff, alpha: 0.001 });
+ searchHit.eventMode = 'static';
+ searchHit.cursor = 'text';
+ searchHit.on('pointerdown', () => input.onSearchActivate?.());
+ layer.addChild(searchHit);
+
+ const playHit = new Graphics();
+ playHit.rect(play.x, play.y, play.w, play.h).fill({ color: 0xffffff, alpha: 0.001 });
+ playHit.eventMode = 'static';
+ playHit.cursor = 'pointer';
+ playHit.on('pointerdown', () => input.onPlay?.());
+ layer.addChild(playHit);
+
+ const autoPlayHit = new Graphics();
+ autoPlayHit.rect(autoPlay.x, autoPlay.y, autoPlay.w, autoPlay.h).fill({ color: 0xffffff, alpha: 0.001 });
+ autoPlayHit.eventMode = 'static';
+ autoPlayHit.cursor = 'pointer';
+ autoPlayHit.on('pointerdown', () => input.onAutoPlay?.());
+ layer.addChild(autoPlayHit);
+
+ layer.addChildAt(chrome, 0);
+}
+
+export function renderDefaultSelectEntryRow(layer: Container, input: DefaultSelectRowInput): void {
+ const {
+ entry,
+ entryIndex,
+ visibleIndex,
+ selectedIndex,
+ elapsedMs,
+ listX,
+ listY,
+ listWidth,
+ rowHeight,
+ selectedSlamT,
+ } = input;
+ const y = listY + visibleIndex * rowHeight;
+ const active = entryIndex === selectedIndex;
+ const introT = staggerProgress(elapsedMs, visibleIndex * 36, 260);
+ const rowSlam = active ? slamScale(selectedSlamT, 1.18) : slamScale(introT, 1.12);
+ const throwX = active ? slamOffset(selectedSlamT, -18) : slamOffset(introT, -22);
+ const row = new Graphics();
+ const song = entry.kind === 'song' ? entry.song : undefined;
+ const folder = entry.kind === 'folder' ? entry.folder : undefined;
+ const titleText = song?.title ?? folder?.label ?? '';
+ const keyText = song ? formatDefaultSelectModeLabel(song).replace(' KEYS', '') : 'DIR';
+ const metaText = song
+ ? [song.playLevel !== undefined ? `Lv ${song.playLevel}` : undefined, song.bpm ? `${song.bpm}BPM` : undefined]
+ .filter(Boolean)
+ .join(' · ')
+ : `${folder?.songs.length ?? 0} chart${folder?.songs.length === 1 ? '' : 's'}`;
+ const playLevelText =
+ song?.playLevel !== undefined ? String(song.playLevel) : folder ? String(folder.songs.length) : '-';
+ const keyPillX = listX + 6;
+ const keyPillW = 28;
+ const levelPillX = keyPillX + keyPillW + 4;
+ const levelPillW = 28;
+ const titleX = levelPillX + levelPillW + 8;
+ const textMaxWidth = Math.max(24, listWidth - (titleX - listX) - 8);
+ row.label = `fallback-row[idx=${entryIndex},kind=${entry.kind}${active ? ',active' : ''}]`;
+ if (active) {
+ fillParallelogram(row, listX - 8 + throwX, y - 2, listWidth + 16, rowHeight + 2, 10, DEFAULT_THEME.crimson, 0.96);
+ strokeParallelogram(
+ row,
+ listX - 8 + throwX,
+ y - 2,
+ listWidth + 16,
+ rowHeight + 2,
+ 10,
+ DEFAULT_THEME.paper,
+ 1.5,
+ 0.8,
+ );
+ fillSlash(row, listX - 16 + throwX, y + 4, 22, rowHeight - 8, 4, DEFAULT_THEME.paper, 0.9);
+ } else {
+ fillParallelogram(row, listX + throwX, y, listWidth, rowHeight - 3, 6, DEFAULT_THEME.panel, 0.82);
+ strokeParallelogram(row, listX + throwX, y, listWidth, rowHeight - 3, 6, DEFAULT_THEME.line, 1, 0.7);
+ }
+ layer.addChild(row);
+
+ fillParallelogram(
+ row,
+ keyPillX + throwX,
+ y + 5,
+ keyPillW,
+ rowHeight - 12,
+ 4,
+ active ? DEFAULT_THEME.ink : DEFAULT_THEME.panelLift,
+ 1,
+ );
+ fillParallelogram(
+ row,
+ levelPillX + throwX,
+ y + 5,
+ levelPillW,
+ rowHeight - 12,
+ 4,
+ active ? DEFAULT_THEME.ink : DEFAULT_THEME.inkDeep,
+ 1,
+ );
+
+ addChromeText(layer, keyText, keyPillX + keyPillW / 2 + throwX, y + rowHeight / 2 - 1, {
+ size: 10,
+ fill: active ? DEFAULT_THEME.paper : DEFAULT_THEME.mute,
+ fontFamily: DEFAULT_NUMERIC_FONT,
+ anchorX: 0.5,
+ anchorY: 0.5,
+ maxWidth: keyPillW - 4,
+ slam: rowSlam,
+ });
+ addChromeText(layer, playLevelText, levelPillX + levelPillW / 2 + throwX, y + rowHeight / 2 - 1, {
+ size: 12,
+ fill: active ? DEFAULT_THEME.gold : DEFAULT_THEME.paper,
+ fontFamily: DEFAULT_NUMERIC_FONT,
+ anchorX: 0.5,
+ anchorY: 0.5,
+ maxWidth: levelPillW - 4,
+ slam: rowSlam,
+ });
+
+ const title = addChromeText(layer, titleText, titleX + throwX, y + 3, {
+ size: 11,
+ fill: active ? DEFAULT_THEME.ink : DEFAULT_THEME.paper,
+ fontFamily: DEFAULT_TEXT_FONT,
+ maxWidth: textMaxWidth,
+ slam: rowSlam,
+ rotation: active ? -0.02 : 0,
+ });
+ title.label = `fallback-title[idx=${entryIndex}]`;
+
+ const meta = addChromeText(layer, metaText, titleX + throwX, y + 16, {
+ size: 8,
+ fill: active ? DEFAULT_THEME.blood : DEFAULT_THEME.mute,
+ fontFamily: DEFAULT_NUMERIC_FONT,
+ maxWidth: textMaxWidth,
+ });
+ meta.label = `fallback-meta[idx=${entryIndex}]`;
+}
+
+export function formatDefaultSelectModeLabel(song: BrowserSongEntry | undefined): string {
+ if (!song) return '- KEYS';
+ return `${resolveChartPlayVariant(song)} KEYS`;
+}
+
+function stampLabel(): { size: number; fill: number; fontFamily: string; letterSpacing: number } {
+ return { size: 9, fill: DEFAULT_THEME.mute, fontFamily: DEFAULT_NUMERIC_FONT, letterSpacing: 1.2 };
+}
diff --git a/packages/player-web/src/scene/default/theme.ts b/packages/player-web/src/scene/default/theme.ts
new file mode 100644
index 00000000..b2db904c
--- /dev/null
+++ b/packages/player-web/src/scene/default/theme.ts
@@ -0,0 +1,114 @@
+import type { Graphics } from 'pixi.js';
+
+/**
+ * Built-in default-skin palette. The shapes are original (no third-party assets) — black / crimson / cream / gold
+ * plus diagonal cuts so the chrome can slam like a cut-in comic, not a calm HUD.
+ */
+export const DEFAULT_THEME = {
+ ink: 0x080403,
+ inkDeep: 0x040201,
+ panel: 0x140a08,
+ panelLift: 0x1c100c,
+ paper: 0xf3e6c8,
+ paperDim: 0xc4b496,
+ mute: 0x8a7a68,
+ line: 0x3a241c,
+ crimson: 0xe10600,
+ blood: 0x7a0508,
+ gold: 0xffe14a,
+ white: 0xfff7ea,
+ cyanGhost: 0x3fe0ff,
+ lane: 0x060303,
+ rail: 0x1a0c0a,
+ railEdge: 0x5a2a22,
+ measure: 0xf3e6c8,
+ judgePerfect: 0xffe14a,
+ judgeGreat: 0xf3e6c8,
+ judgeGood: 0xc4b496,
+ judgeBad: 0xff8a3d,
+ judgePoor: 0xe10600,
+} as const;
+
+export const DEFAULT_BG_BANDS: ReadonlyArray = [
+ [0x1a0806, 70],
+ [0x120604, 150],
+ [0x0c0403, 260],
+ [0x080302, 360],
+ [0x040201, 480],
+];
+
+/** Axis-aligned parallelogram: top edge shifted by `skew` px. */
+export function parallelogramPoints(x: number, y: number, w: number, h: number, skew: number): number[] {
+ return [x + skew, y, x + w + skew, y, x + w, y + h, x, y + h];
+}
+
+export function fillParallelogram(
+ graphic: Graphics,
+ x: number,
+ y: number,
+ w: number,
+ h: number,
+ skew: number,
+ color: number,
+ alpha = 1,
+): void {
+ graphic.poly(parallelogramPoints(x, y, w, h, skew)).fill({ color, alpha });
+}
+
+export function strokeParallelogram(
+ graphic: Graphics,
+ x: number,
+ y: number,
+ w: number,
+ h: number,
+ skew: number,
+ color: number,
+ width = 1,
+ alpha = 1,
+): void {
+ graphic.poly(parallelogramPoints(x, y, w, h, skew)).stroke({ color, width, alpha, alignment: 0.5 });
+}
+
+/**
+ * A thick diagonal slash. `lean` > 0 leans down-right (the usual cut-in).
+ */
+export function fillSlash(
+ graphic: Graphics,
+ x: number,
+ y: number,
+ length: number,
+ thickness: number,
+ lean: number,
+ color: number,
+ alpha = 1,
+): void {
+ graphic
+ .poly([
+ x,
+ y,
+ x + length,
+ y + lean,
+ x + length - thickness * 0.35,
+ y + lean + thickness,
+ x - thickness * 0.35,
+ y + thickness,
+ ])
+ .fill({ color, alpha });
+}
+
+export function defaultJudgeColor(judge: string): number {
+ switch (judge) {
+ case 'PERFECT':
+ return DEFAULT_THEME.judgePerfect;
+ case 'GREAT':
+ return DEFAULT_THEME.judgeGreat;
+ case 'GOOD':
+ return DEFAULT_THEME.judgeGood;
+ case 'BAD':
+ return DEFAULT_THEME.judgeBad;
+ case 'POOR':
+ return DEFAULT_THEME.judgePoor;
+ default:
+ return DEFAULT_THEME.paper;
+ }
+}
diff --git a/packages/player-web/src/scene/lr2/gameplay.ts b/packages/player-web/src/scene/lr2/gameplay.ts
index 3d9b4231..5b5e0c39 100644
--- a/packages/player-web/src/scene/lr2/gameplay.ts
+++ b/packages/player-web/src/scene/lr2/gameplay.ts
@@ -25,7 +25,6 @@ import {
type PlayerSummary,
} from '@be-music/player/core/engine';
import type { GrooveGaugeType } from '@be-music/player/core/groove-gauge';
-import type { ChartPlayVariant } from '@be-music/player/core/lane-layout';
import type { PlayerInputSignalBus } from '@be-music/player/core/input-signal-bus';
import type { PlayerJudgeComboSignalState, PlayerStateSignals } from '@be-music/player/state-signals';
import type {
@@ -147,6 +146,16 @@ import type {
SkinlessGameplayChromeRuntime,
SkinlessGameplayJudgeState,
} from '../gameplay-chrome.ts';
+import {
+ drawDefaultBomb,
+ drawDefaultLongNoteBody,
+ drawDefaultMine,
+ drawDefaultNoteBody,
+ paintDefaultLane,
+ paintDefaultLaneGridRight,
+ resolveDefaultLaneTone,
+} from '../default/playfield.ts';
+import { DEFAULT_THEME } from '../default/theme.ts';
import { resolveGameplayAudioTailCleanupDelayMs, resolvePostChartResultDelayMs } from './gameplay-result-delay.ts';
import {
computeBombDurationsMs,
@@ -3893,61 +3902,11 @@ export class PixiGameplayView {
const elapsed = Math.max(0, now - startedAt);
const progress = Math.max(0, Math.min(1, elapsed / BOMB_CLEANUP_FALLBACK_MS));
- const fade = 1 - progress;
- const eased = 1 - (1 - progress) * (1 - progress);
- const centerX = lane.x + lane.w / 2;
- const centerY = lane.bottom - Math.max(5, lane.w * 0.18);
- const coreRadius = Math.max(3, lane.w * (0.28 + 0.12 * eased));
- const haloRadius = Math.max(7, lane.w * (0.58 + 1.05 * eased));
- const rayCount = 8;
- const rayInner = coreRadius * (0.74 + eased * 0.14);
- const rayOuter = haloRadius * (0.62 + eased * 0.08);
- const lineWidth = Math.max(1, lane.w * (0.09 - progress * 0.04));
const graphic = this.bombLayerPool.acquireGraphics();
graphic.label = `default-bomb[ch=${channel}]`;
graphic.blendMode = 'add';
graphic.alpha = 1;
-
- // Light pillar rising off the judgement line — sells the impact within the lane.
- graphic
- .rect(lane.x + lane.w * 0.14, centerY - haloRadius * 1.5, lane.w * 0.72, haloRadius * 1.5)
- .fill({ color: 0xffb44d, alpha: 0.1 * fade });
- // Flame ring + white-hot core with a warm mid layer.
- graphic.circle(centerX, centerY, haloRadius).stroke({
- color: 0xff7a2f,
- width: Math.max(1.5, lane.w * 0.1),
- alpha: 0.4 * fade,
- alignment: 0.5,
- });
- graphic.circle(centerX, centerY, coreRadius * 1.7).fill({ color: 0xff9b3d, alpha: 0.2 * fade });
- graphic.circle(centerX, centerY, coreRadius * 1.2).fill({ color: 0xffd166, alpha: 0.3 * fade });
- graphic.circle(centerX, centerY, coreRadius * 0.7).fill({ color: 0xffffff, alpha: 0.75 * fade });
- // Starburst: long cross spokes + short diagonals, rotating slightly as the burst expands.
- const spin = eased * 0.5;
- for (let index = 0; index < rayCount; index += 1) {
- const angle = spin + -Math.PI / 2 + (Math.PI * 2 * index) / rayCount;
- const longRay = index % 2 === 0;
- const outer = longRay ? rayOuter * 1.35 : rayOuter * 0.9;
- graphic
- .moveTo(centerX + Math.cos(angle) * rayInner, centerY + Math.sin(angle) * rayInner)
- .lineTo(centerX + Math.cos(angle) * outer, centerY + Math.sin(angle) * outer);
- }
- graphic.stroke({
- color: 0xffe08a,
- width: lineWidth,
- alpha: 0.75 * fade,
- cap: 'round',
- alignment: 0.5,
- });
- // Ember dots at the spoke tips for the tail end of the burst.
- if (progress > 0.35) {
- for (let index = 0; index < 4; index += 1) {
- const angle = spin + Math.PI / 4 + (Math.PI * 2 * index) / 4;
- graphic
- .circle(centerX + Math.cos(angle) * rayOuter * 1.15, centerY + Math.sin(angle) * rayOuter * 1.15, 1.4)
- .fill({ color: 0xffd166, alpha: 0.8 * fade });
- }
- }
+ drawDefaultBomb(graphic, lane, progress);
}
}
@@ -4994,57 +4953,19 @@ export class PixiGameplayView {
const scratchLane = fallbackLane?.isScratch ?? isScratchLaneForVariant(channel, this.chartPlayVariant);
const fallbackLaneIndex = resolveLr2LaneIndex(channel, this.chartPlayVariant);
- const tone = noteFallbackColor(channel, fallbackLaneIndex, this.chartPlayVariant);
- const laneHeight = Math.max(1, bottom - top);
-
- // Lane bed — a whisper of the lane's own colour so the column reads as "this key's territory".
- this.laneLayer.rect(x, top, w, laneHeight).fill({ color: tone.body, alpha: scratchLane ? 0.05 : 0.03 });
- // Lane separators — cool blue hairlines instead of white, so notes pop against them.
- this.laneLayer.rect(x, top, 1, laneHeight).fill({ color: 0x2a2440, alpha: 0.6 });
-
- // Key beam — a vertical light column that decays towards the top, plus a hot base above the line.
- const laserAlpha = this.resolveFallbackLaneLaserAlpha(channel);
- if (laserAlpha > 0) {
- const beamHeight = Math.min(laneHeight, 170);
- const slices = 6;
- for (let slice = 0; slice < slices; slice += 1) {
- const sliceRatio = slice / slices;
- const sliceH = beamHeight / slices;
- const sliceY = bottom - beamHeight + sliceRatio * beamHeight;
- this.laneLayer
- .rect(x + 1, sliceY, w - 2, sliceH + 1)
- .fill({ color: tone.body, alpha: (0.05 + 0.4 * sliceRatio * sliceRatio) * laserAlpha });
- }
- this.laneLayer.rect(x + 1, bottom - 16, w - 2, 16).fill({ color: 0xffffff, alpha: 0.32 * laserAlpha });
- }
-
- // Judgement line — neon magenta, breathing with the beat: the glow flares on the downbeat and decays into
- // the bar, so the line itself keeps time even before any note arrives.
+ const tone = resolveDefaultLaneTone(channel, fallbackLaneIndex, this.chartPlayVariant);
const beat = this.currentBeat(this.currentSeconds());
- const beatDecay = 1 - (beat - Math.floor(beat));
- this.laneLayer.rect(x, bottom - 14, w, 12).fill({ color: 0xff2f6b, alpha: 0.08 + 0.1 * beatDecay });
- this.laneLayer.rect(x, bottom - 5, w, 3).fill({ color: 0xff2f6b, alpha: 0.35 });
- this.laneLayer.rect(x, bottom - 2, w, 2).fill({ color: 0xff2f6b, alpha: 0.98 });
- this.laneLayer.rect(x, bottom, w, 1).fill({ color: 0xffffff, alpha: 0.95 });
-
- // Key caps under the line — glassy at rest, blazing on press with an under-glow strip toward the line.
- const pressed = laserAlpha > 0.6;
- const capTop = bottom + 3;
- const capHeight = 14;
- this.laneLayer
- .roundRect(x + 1, capTop, Math.max(2, w - 2), capHeight, 2)
- .fill({ color: pressed ? tone.capLit : tone.cap, alpha: pressed ? 1 : 0.92 });
- this.laneLayer
- .rect(x + 1, capTop, Math.max(2, w - 2), 2)
- .fill({ color: 0xffffff, alpha: pressed ? 0.7 : 0.14 });
- this.laneLayer
- .rect(x + 1, capTop + capHeight - 2, Math.max(2, w - 2), 2)
- .fill({ color: 0x000000, alpha: 0.35 });
- if (pressed) {
- // Under-glow bridging the cap to the judgement line — the "lit from within" press feedback.
- this.laneLayer.rect(x + 1, capTop - 2, Math.max(2, w - 2), 2).fill({ color: tone.top, alpha: 0.95 });
- this.laneLayer.rect(x + 1, capTop + capHeight, Math.max(2, w - 2), 2).fill({ color: tone.body, alpha: 0.45 });
- }
+ const beatPulse = 1 - (beat - Math.floor(beat));
+ paintDefaultLane(
+ this.laneLayer,
+ { x, w, top, bottom },
+ {
+ tone,
+ scratch: scratchLane,
+ laserAlpha: this.resolveFallbackLaneLaserAlpha(channel),
+ beatImpulse: beatPulse,
+ },
+ );
});
if (!skin && this.laneX.size > 0) {
@@ -5059,9 +4980,7 @@ export class PixiGameplayView {
gridBottom = Math.max(gridBottom, lane.bottom);
gridRight = Math.max(gridRight, lane.x + lane.w);
}
- this.laneLayer
- .rect(gridRight - 1, gridTop, 1, Math.max(1, gridBottom - gridTop))
- .fill({ color: 0x2a2440, alpha: 0.6 });
+ paintDefaultLaneGridRight(this.laneLayer, { top: gridTop, bottom: gridBottom, right: gridRight });
}
}
@@ -5258,20 +5177,7 @@ export class PixiGameplayView {
}
const graphic = this.noteLayerPool.acquireGraphics();
graphic.label = `mine-fallback[lane=${laneIndex},ch=${channel}]`;
- const mineX = lane.x + 2;
- const mineW = Math.max(4, lane.w - 4);
- graphic
- .roundRect(mineX, y - 12, mineW, 12, 3)
- .fill(0x6e1414)
- .stroke({ color: 0xffd166, width: 1, alignment: 1 });
- // Diagonal caution stripes.
- const stripeStep = 8;
- for (let sx = mineX - 12; sx < mineX + mineW; sx += stripeStep) {
- graphic
- .poly([sx, y - 1, sx + 4, y - 1, sx + 4 + 8, y - 11, sx + 8, y - 11])
- .fill({ color: 0xffd166, alpha: 0.55 });
- }
- graphic.rect(mineX, y - 12, mineW, 1).fill({ color: 0xff8a8a, alpha: 0.8 });
+ drawDefaultMine(graphic, lane.x, y, lane.w);
}
/**
@@ -5407,7 +5313,7 @@ export class PixiGameplayView {
if (y < top - 1 || y > bottom + 1) {
continue;
}
- graphic.rect(x0, Math.round(y), x1 - x0, 1).fill({ color: 0x9a8fd0, alpha: 0.28 });
+ graphic.rect(x0, Math.round(y), x1 - x0, 1).fill({ color: DEFAULT_THEME.measure, alpha: 0.32 });
}
}
@@ -5486,12 +5392,12 @@ export class PixiGameplayView {
}
const graphic = this.noteLayerPool.acquireGraphics();
graphic.label = `note-fallback[lane=${laneIndex},ch=${channel}]`;
- drawFallbackNoteBody(
+ drawDefaultNoteBody(
graphic,
lane.x + 1,
y,
Math.max(4, lane.w - 2),
- noteFallbackColor(channel, laneIndex, this.chartPlayVariant),
+ resolveDefaultLaneTone(channel, laneIndex, this.chartPlayVariant),
);
}
@@ -5537,18 +5443,14 @@ export class PixiGameplayView {
} else {
const graphic = this.noteLayerPool.acquireGraphics();
graphic.label = `ln-body-fallback[lane=${laneIndex},ch=${channel}]`;
- const tone = noteFallbackColor(channel, laneIndex, this.chartPlayVariant);
- const bodyX = lane.x + 1;
- const bodyW = Math.max(4, lane.w - 2);
- const bodyTop = top - FALLBACK_NOTE_HEIGHT;
- const bodyH = Math.max(1, bottom - top);
- // Translucent core with bright side rails — reads as "hold the lane", not a solid wall of colour.
- graphic.rect(bodyX + 1, bodyTop, bodyW - 2, bodyH).fill({ color: tone.body, alpha: 0.28 });
- graphic.rect(bodyX, bodyTop, 2, bodyH).fill({ color: tone.body, alpha: 0.85 });
- graphic.rect(bodyX + bodyW - 2, bodyTop, 2, bodyH).fill({ color: tone.body, alpha: 0.85 });
- // Head and tail caps as real notes so the hold's judgment edges stay legible.
- drawFallbackNoteBody(graphic, bodyX, bottom, bodyW, tone);
- drawFallbackNoteBody(graphic, bodyX, top, bodyW, tone);
+ drawDefaultLongNoteBody(
+ graphic,
+ lane.x + 1,
+ top,
+ bottom,
+ Math.max(4, lane.w - 2),
+ resolveDefaultLaneTone(channel, laneIndex, this.chartPlayVariant),
+ );
}
// LN_END at the top (yEnd), LN_START at the bottom (yStart).
if (endSrc) {
@@ -6269,78 +6171,6 @@ function shuffleArray(array: T[], rng: () => number): T[] {
return array;
}
-/**
- * Note color for the no-skin fallback path, mirroring the IIDX / LR2 default convention:
- *
- * - - Scratch (`16` / `26`) — red. - Odd-numbered keys (1 / 3 / 5 / 7) — white. - Even-numbered keys (2 / 4 / 6) —
- * blue.
- *
- * `laneIndex` is the LR2 lane id (`resolveLr2LaneIndex`-style): 0 / 10 = scratch, 1..9 = 1P-side keys, 11..19 = 2P-side
- * keys. `playVariant` matters because 9 KEY charts use channel 16 as a normal keyboard lane, not scratch.
- */
-interface FallbackLaneTone {
- /** Top-edge highlight of a note body. */
- top: number;
- /** Note body / beam / LN colour. */
- body: number;
- /** Bottom-edge shade of a note body. */
- bottom: number;
- /** Key cap at rest. */
- cap: number;
- /** Key cap while pressed. */
- capLit: number;
-}
-
-const FALLBACK_TONE_WHITE: FallbackLaneTone = {
- top: 0xffffff,
- body: 0xf2f4fa,
- bottom: 0xa3adc2,
- cap: 0xccd4e4,
- capLit: 0xffffff,
-};
-const FALLBACK_TONE_BLUE: FallbackLaneTone = {
- top: 0xbef4ff,
- body: 0x2fd4f6,
- bottom: 0x0e7fae,
- cap: 0x0e3c4e,
- capLit: 0x5fe6ff,
-};
-const FALLBACK_TONE_RED: FallbackLaneTone = {
- top: 0xffb1c9,
- body: 0xff3d6e,
- bottom: 0xb3134a,
- cap: 0x551228,
- capLit: 0xff6f9a,
-};
-
-/** Height of a fallback note body in design pixels. */
-const FALLBACK_NOTE_HEIGHT = 12;
-
-/**
- * One skinless note: rounded body with a bright top edge and a shaded bottom edge, so the sprite reads as a lit
- * plastic key instead of a flat rectangle. `y` is the just-timing line — the body's BOTTOM edge sits on it.
- */
-function drawFallbackNoteBody(graphic: Graphics, x: number, y: number, w: number, tone: FallbackLaneTone): void {
- const h = FALLBACK_NOTE_HEIGHT;
- graphic
- .roundRect(x, y - h, w, h, 2)
- .fill(tone.body)
- .stroke({ color: 0x05070d, width: 1, alignment: 1 });
- graphic.rect(x + 1, y - h + 1, w - 2, 2).fill({ color: tone.top, alpha: 0.9 });
- graphic.rect(x + 1, y - 3, w - 2, 2).fill({ color: tone.bottom, alpha: 0.9 });
-}
-
-function noteFallbackColor(
- channel: string,
- laneIndex: number,
- playVariant: ChartPlayVariant | undefined,
-): FallbackLaneTone {
- if (isScratchLaneForVariant(channel, playVariant)) return FALLBACK_TONE_RED;
- const keyIndex = laneIndex % 10;
- if (keyIndex % 2 === 0) return FALLBACK_TONE_BLUE;
- return FALLBACK_TONE_WHITE;
-}
-
// `clampSampleOffset` / `clampSampleDuration` / `startSampleNode` lived here while the gameplay view managed its
// own audio playback. With Phase 4b-i / 4b-ii routing every cue through {@link WebAudioSession}, the canonical
// implementations now live in `web-audio-session.ts` (re-exported there for tests) — this module no longer needs
diff --git a/packages/player-web/src/scene/lr2/result.ts b/packages/player-web/src/scene/lr2/result.ts
index 0ba95353..7b667c1e 100644
--- a/packages/player-web/src/scene/lr2/result.ts
+++ b/packages/player-web/src/scene/lr2/result.ts
@@ -1,4 +1,4 @@
-import { Application, Color, Container, Graphics, Text, TextStyle, Texture } from 'pixi.js';
+import { Application, Color, Container, Graphics, Texture } from 'pixi.js';
import { computeScoreRate, resolveIidxRankIndexFromScore, resolveIidxRankLabel } from '@be-music/player/core/scoring';
import type {
Lr2DestinationRect,
@@ -31,13 +31,9 @@ import type { Lr2LoadedFont } from '../../skin/lr2/bitmap-text.ts';
import { logger } from '../../logger.ts';
import type { BrowserSongCollection } from '../../collection/types.ts';
import type { PixiGameplayResultData } from './gameplay.ts';
-import { LR2_JUDGE_FALLBACK_FONT, LR2_NUMERIC_FALLBACK_FONT, LR2_TEXT_FALLBACK_FONT } from './fonts.ts';
+import { renderDefaultResultChrome } from '../default/result-render.ts';
const log = logger('result');
-const FALLBACK_SURFACE = 0x080d16;
-const FALLBACK_PANEL = 0x101827;
-const FALLBACK_PANEL_DARK = 0x030711;
-const FALLBACK_LINE = 0x2a3548;
/**
* Result scene. Shows the per-chart score breakdown (judge counts, EX score, rate, max combo, clear lamp / rank) and
@@ -69,8 +65,6 @@ const FALLBACK_LINE = 0x2a3548;
const FALLBACK_DESIGN_WIDTH = 640;
const FALLBACK_DESIGN_HEIGHT = 480;
const BG = new Color('#05070b');
-const TEXT = new Color('#f8fafc');
-const MUTED = new Color('#9aa6b2');
/**
* Approximate duration of the chart-draw animation when no skin-side `#SRC_SCORECHART` / `#SRC_GAUGECHART` is wired up.
@@ -848,221 +842,36 @@ export class PixiResultView {
private renderFallbackPanel(designWidth: number, designHeight: number): void {
const result = this.result;
if (!result) return;
- const chrome = new Graphics();
- chrome.label = 'default-result/chrome';
- const addText = (
- text: string,
- x: number,
- y: number,
- options: {
- size?: number;
- weight?: '400' | '500' | '600' | '700' | '800' | '900';
- fill?: number | Color;
- fontFamily?: string;
- letterSpacing?: number;
- anchorX?: number;
- anchorY?: number;
- maxWidth?: number;
- stroke?: { color: number; width: number; alignment?: number; join?: 'round' | 'bevel' | 'miter' };
- } = {},
- ): Text => {
- const node = new Text({
- text,
- style: new TextStyle({
- fill: options.fill ?? TEXT,
- fontSize: options.size ?? 10,
- fontWeight: options.weight ?? '500',
- fontFamily: options.fontFamily ?? LR2_TEXT_FALLBACK_FONT,
- letterSpacing: options.letterSpacing ?? 0,
- stroke: options.stroke,
- }),
- });
- node.anchor.set(options.anchorX ?? 0, options.anchorY ?? 0);
- node.position.set(x, y);
- if (options.maxWidth !== undefined && node.width > options.maxWidth) {
- node.scale.x = options.maxWidth / node.width;
- }
- this.fallbackLayer.addChild(node);
- return node;
- };
-
- const cleared = result.cleared;
const rate = computeScoreRate(result.score) * 100;
- const rankLabel = resolveRankLabel(result.score.exScore, result.score.total);
- const statusColor = cleared ? 0x4bd7c8 : 0xff5c5c;
- const exMax = Math.max(0, result.score.total * 2);
-
- chrome.rect(0, 0, designWidth, designHeight).fill(BG);
- chrome.rect(0, 0, designWidth, 48).fill(FALLBACK_SURFACE);
- chrome.rect(0, 47, designWidth, 1).fill({ color: FALLBACK_LINE, alpha: 0.9 });
- chrome.roundRect(18, 72, 172, 148, 6).fill(FALLBACK_PANEL).stroke({ color: FALLBACK_LINE, width: 1 });
- chrome.roundRect(210, 72, 194, 148, 6).fill(FALLBACK_PANEL).stroke({ color: FALLBACK_LINE, width: 1 });
- chrome.roundRect(424, 72, 198, 148, 6).fill(FALLBACK_PANEL).stroke({ color: FALLBACK_LINE, width: 1 });
- chrome.roundRect(18, 246, 286, 180, 6).fill(FALLBACK_PANEL).stroke({ color: FALLBACK_LINE, width: 1 });
- chrome.roundRect(324, 246, 298, 180, 6).fill(FALLBACK_PANEL).stroke({ color: FALLBACK_LINE, width: 1 });
- chrome
- .rect(0, designHeight - 34, designWidth, 34)
- .fill(FALLBACK_SURFACE)
- .stroke({ color: FALLBACK_LINE, width: 1 });
-
- addText(cleared ? 'CLEARED' : 'FAILED', 18, 14, {
- size: 14,
- weight: '900',
- fill: statusColor,
- letterSpacing: 1.4,
- });
- addText(`${result.song.title}${result.song.artist ? ` / ${result.song.artist}` : ''}`, designWidth - 18, 14, {
- size: 11,
- weight: '700',
- fill: MUTED,
- anchorX: 1,
- maxWidth: 430,
- });
-
- addText('RANK', 38, 92, { size: 9, weight: '800', fill: MUTED, letterSpacing: 1 });
- addText(rankLabel, 104, 144, {
- size: rankLabel.length >= 3 ? 40 : 54,
- weight: '900',
- fill: 0xffc857,
- anchorX: 0.5,
- anchorY: 0.5,
- stroke: { color: 0x000000, width: 4, alignment: 0.5, join: 'round' },
- maxWidth: 128,
- });
- addText(`${rate.toFixed(1)}%`, 104, 190, {
- size: 16,
- weight: '900',
- fill: TEXT,
- anchorX: 0.5,
- fontFamily: LR2_NUMERIC_FALLBACK_FONT,
- });
-
- this.renderResultMetric(chrome, 228, 92, 'SCORE', String(result.score.score), 0xffc857);
- this.renderResultMetric(chrome, 228, 138, 'EX SCORE', `${result.score.exScore} / ${exMax}`, 0xf6f2e8);
- this.renderResultMetric(chrome, 228, 184, 'MAX COMBO', String(result.maxCombo), 0x4bd7c8, LR2_JUDGE_FALLBACK_FONT);
- this.renderResultMetric(chrome, 442, 92, 'GAUGE', `${Math.round(result.gauge)}%`, statusColor);
- this.renderResultMetric(chrome, 442, 138, 'PLAY TIME', `${result.playSeconds.toFixed(1)}s`, 0xf6f2e8);
- this.renderResultMetric(chrome, 442, 184, 'NOTES', String(result.score.total), 0xf6f2e8);
-
- addText('JUDGEMENT', 36, 266, { size: 9, weight: '800', fill: MUTED, letterSpacing: 1 });
- const judges: Array = [
- ['PGREAT', String(result.score.perfect), 0xffc857],
- ['GREAT', String(result.score.great), 0x6ee07f],
- ['GOOD', String(result.score.good), 0x76a8ff],
- ['BAD', String(result.score.bad), 0xff9b54],
- ['POOR', String(result.score.poor), 0xff5c5c],
- ];
- for (let i = 0; i < judges.length; i += 1) {
- const jy = 292 + i * 24;
- const [label, value, fill] = judges[i]!;
- chrome.roundRect(36, jy, 246, 18, 4).fill(FALLBACK_PANEL_DARK).stroke({ color: FALLBACK_LINE, width: 1 });
- chrome.rect(38, jy, Math.min(242, Number(value) * 2), 18).fill({ color: fill, alpha: 0.16 });
- addText(label, 48, jy + 4, { size: 9, weight: '800', fill, fontFamily: LR2_JUDGE_FALLBACK_FONT });
- addText(value, 264, jy + 1, {
- size: 13,
- weight: '900',
- fill: TEXT,
- fontFamily: LR2_JUDGE_FALLBACK_FONT,
- anchorX: 1,
- });
- }
-
- addText('RUN', 342, 266, { size: 9, weight: '800', fill: MUTED, letterSpacing: 1 });
- chrome.rect(342, 292, 256, 46).fill(FALLBACK_PANEL_DARK).stroke({ color: FALLBACK_LINE, width: 1 });
- chrome.rect(342, 362, 256, 46).fill(FALLBACK_PANEL_DARK).stroke({ color: FALLBACK_LINE, width: 1 });
- addText('Gauge', 354, 304, { size: 9, weight: '700', fill: MUTED });
- addText('EX score', 354, 374, { size: 9, weight: '700', fill: MUTED });
- this.drawFallbackSeries(
- chrome,
- 424,
- 300,
- 160,
- 30,
- result.gaugeHistory.map((sample) => ({ x: sample.progress, y: sample.value / 100 })),
- statusColor,
- );
- this.drawFallbackSeries(
- chrome,
- 424,
- 370,
- 160,
- 30,
- result.scoreHistory.map((sample) => ({ x: sample.progress, y: exMax > 0 ? sample.exScore / exMax : 0 })),
- 0xffc857,
- );
-
- addText('TOTAL SCORE', 18, designHeight - 22, { size: 9, weight: '800', fill: MUTED, letterSpacing: 1 });
- addText(String(result.score.score), 150, designHeight - 27, {
- size: 18,
- weight: '900',
- fill: TEXT,
- fontFamily: LR2_NUMERIC_FALLBACK_FONT,
- });
-
- this.fallbackLayer.addChildAt(chrome, 0);
- }
-
- private renderResultMetric(
- chrome: Graphics,
- x: number,
- y: number,
- label: string,
- value: string,
- fill: number,
- valueFontFamily = LR2_NUMERIC_FALLBACK_FONT,
- ): void {
- chrome.rect(x, y, 156, 30).fill(FALLBACK_PANEL_DARK).stroke({ color: FALLBACK_LINE, width: 1 });
- const labelText = new Text({
- text: label,
- style: new TextStyle({
- fill: MUTED,
- fontSize: 8,
- fontWeight: '800',
- fontFamily: LR2_TEXT_FALLBACK_FONT,
- letterSpacing: 0.7,
- }),
- });
- labelText.position.set(x + 10, y + 5);
- this.fallbackLayer.addChild(labelText);
-
- const valueText = new Text({
- text: value,
- style: new TextStyle({
- fill,
- fontSize: 14,
- fontWeight: '900',
- fontFamily: valueFontFamily,
- }),
+ const rankLabel = result.score.total <= 0 ? 'AAA' : resolveIidxRankLabel(result.score.exScore, result.score.total);
+ renderDefaultResultChrome(this.fallbackLayer, {
+ designWidth,
+ designHeight,
+ sceneElapsedMs: Math.max(0, performance.now() - this.sceneStartedAt),
+ rankRevealed: this.timerStartedAt.has(151),
+ rankElapsedMs: this.elapsedSinceTimer(151),
+ result: {
+ cleared: result.cleared,
+ title: result.song.title,
+ artist: result.song.artist,
+ rank: rankLabel,
+ rate,
+ score: result.score.score,
+ exScore: result.score.exScore,
+ exMax: Math.max(0, result.score.total * 2),
+ maxCombo: result.maxCombo,
+ gauge: result.gauge,
+ playSeconds: result.playSeconds,
+ notes: result.score.total,
+ perfect: result.score.perfect,
+ great: result.score.great,
+ good: result.score.good,
+ bad: result.score.bad,
+ poor: result.score.poor,
+ gaugeHistory: result.gaugeHistory,
+ scoreHistory: result.scoreHistory,
+ },
});
- valueText.anchor.set(1, 0);
- valueText.position.set(x + 146, y + 13);
- if (valueText.width > 92) {
- valueText.scale.x = 92 / valueText.width;
- }
- this.fallbackLayer.addChild(valueText);
- }
-
- private drawFallbackSeries(
- chrome: Graphics,
- x: number,
- y: number,
- w: number,
- h: number,
- points: Array<{ x: number; y: number }>,
- color: number,
- ): void {
- if (points.length === 0) return;
- chrome.rect(x, y + h, w, 1).fill({ color: 0xffffff, alpha: 0.12 });
- const first = points[0]!;
- chrome.moveTo(x + clamp01(first.x) * w, y + (1 - clamp01(first.y)) * h);
- for (let i = 1; i < points.length; i += 1) {
- const point = points[i]!;
- chrome.lineTo(x + clamp01(point.x) * w, y + (1 - clamp01(point.y)) * h);
- }
- if (points.length === 1) {
- chrome.lineTo(x + clamp01(first.x) * w + 0.5, y + (1 - clamp01(first.y)) * h);
- }
- chrome.stroke({ color, width: 2, alpha: 0.95, alignment: 0.5 });
}
private evaluateElementDst(element: {
@@ -1557,14 +1366,5 @@ function resolveResultSliderValue(type: number, data: PixiGameplayResultData): n
return resolveResultBargraphValue(type, data);
}
-function resolveRankLabel(exScore: number, total: number): string {
- return total <= 0 ? 'AAA' : resolveIidxRankLabel(exScore, total);
-}
-
-function clamp01(value: number): number {
- if (!Number.isFinite(value)) return 0;
- return Math.max(0, Math.min(1, value));
-}
-
// Re-export this small LR2 geometry type so result-scene consumers do not have to import from `skin.ts` directly.
export type { Lr2ImageRect };
diff --git a/packages/player-web/src/scene/lr2/select.ts b/packages/player-web/src/scene/lr2/select.ts
index e5805301..84efe930 100644
--- a/packages/player-web/src/scene/lr2/select.ts
+++ b/packages/player-web/src/scene/lr2/select.ts
@@ -34,12 +34,7 @@ import {
import { PerfTracker } from '../perf.ts';
import { type PixiSceneHost } from '../host.ts';
import { disposeChildren } from '../pixi-utils.ts';
-import {
- groupSongsByFolder,
- loadAssetBytes,
- resolveChartPlayVariant,
- resolveSongSource,
-} from '../../collection/collection.ts';
+import { groupSongsByFolder, loadAssetBytes, resolveSongSource } from '../../collection/collection.ts';
import { dirname } from '@be-music/utils/core';
import { ChartPreviewEngine } from '../../chart/preview.ts';
import { computeSelectOps, resolveKeyModeOp, SELECT_KEYS_FILTER_TO_OP } from '../select-ops.ts';
@@ -64,20 +59,24 @@ import type {
BrowserSongCollection,
BrowserSongEntry,
} from '../../collection/types.ts';
-import { LR2_NUMERIC_FALLBACK_FONT, LR2_TEXT_FALLBACK_FONT } from './fonts.ts';
+import { LR2_TEXT_FALLBACK_FONT } from './fonts.ts';
+import {
+ DEFAULT_SELECT_LAYOUT,
+ defaultSelectEntryIndexAt,
+ defaultSelectListWidth,
+ isInsideDefaultSelectList,
+ resolveDefaultSelectVisibleWindow,
+} from '../default/select-layout.ts';
+import {
+ beginDefaultSelectMotion,
+ renderDefaultSelectChrome,
+ renderDefaultSelectEntryRow,
+} from '../default/select-render.ts';
const log = logger('select');
const BG = new Color('#050912');
-const PANEL = new Color('#101827');
-const ACTIVE = new Color('#4bd7c8');
const TEXT = new Color('#f6f2e8');
const MUTED = new Color('#a9a39a');
-const SURFACE = 0x080d16;
-const PANEL_DARK = 0x030711;
-const PANEL_SOFT = 0x0b1320;
-const PANEL_ACTION = 0x121a28;
-const PANEL_ACTION_ACTIVE = 0x162238;
-const LINE = 0x2a3548;
/**
* Design canvas the no-skin select scene renders into. 640×480 matches the LR2 default `select.lr2skin` (no
* `#RESOLUTION` declared, so the loader's seed at width=640 / height=480 wins).
@@ -2421,25 +2420,16 @@ export class PixiSongSelectView {
// Geometry must mirror the `render()` layout above: rows begin at `listTop` with `rowHeight` pitch, and the visible
// window centers on `selectedIndex` with the same `start` calculation. Anything outside the list rectangle is a
// no-op.
- const listX = 320;
- const listTop = 54;
- const listBottom = designHeight - 26;
- const rowHeight = 28;
- if (virtualX < listX || virtualY < listTop || virtualY > listBottom) {
+ if (!isInsideDefaultSelectList(virtualX, virtualY, designHeight)) {
return;
}
const fallbackEntries = this.currentEntries();
if (fallbackEntries.length === 0) {
return;
}
- const visibleRows = Math.max(1, Math.floor((listBottom - listTop) / rowHeight));
- const start = Math.max(
- 0,
- Math.min(this.selectedIndex - Math.floor(visibleRows / 2), Math.max(0, fallbackEntries.length - visibleRows)),
- );
- const visibleRow = Math.floor((virtualY - listTop) / rowHeight);
- const entryIndex = start + visibleRow;
- if (entryIndex < 0 || entryIndex >= fallbackEntries.length) {
+ const { start } = resolveDefaultSelectVisibleWindow(this.selectedIndex, fallbackEntries.length, designHeight);
+ const entryIndex = defaultSelectEntryIndexAt(virtualY, start, fallbackEntries.length);
+ if (entryIndex === undefined) {
return;
}
const entry = fallbackEntries[entryIndex];
@@ -2707,15 +2697,16 @@ export class PixiSongSelectView {
// Right-column song bar list. Keep the selected entry near the vertical center so keyboard / wheel navigation feels
// stable with or without a loaded external skin.
- const listX = 320;
- const listWidth = designWidth - listX - 16;
- const rowHeight = 28;
- const listTop = 54;
- const listBottom = designHeight - 26;
- const visibleRows = Math.max(1, Math.floor((listBottom - listTop) / rowHeight));
- const start = Math.max(
- 0,
- Math.min(this.selectedIndex - Math.floor(visibleRows / 2), Math.max(0, fallbackEntries.length - visibleRows)),
+ const listX = DEFAULT_SELECT_LAYOUT.listX;
+ const listWidth = defaultSelectListWidth(designWidth);
+ const rowHeight = DEFAULT_SELECT_LAYOUT.rowHeight;
+ const listTop = DEFAULT_SELECT_LAYOUT.listTop;
+ const elapsedMs = Math.max(0, performance.now() - this.sceneStartedAt);
+ const { selectedSlamT } = beginDefaultSelectMotion(this.selectedIndex, elapsedMs);
+ const { start, visibleRows } = resolveDefaultSelectVisibleWindow(
+ this.selectedIndex,
+ fallbackEntries.length,
+ designHeight,
);
for (let visibleIndex = 0; visibleIndex < visibleRows; visibleIndex += 1) {
const entryIndex = start + visibleIndex;
@@ -2723,7 +2714,17 @@ export class PixiSongSelectView {
if (!entry) {
break;
}
- this.drawFallbackEntryRow(entry, entryIndex, visibleIndex, listX, listTop, listWidth, rowHeight);
+ this.drawFallbackEntryRow(
+ entry,
+ entryIndex,
+ visibleIndex,
+ listX,
+ listTop,
+ listWidth,
+ rowHeight,
+ elapsedMs,
+ selectedSlamT,
+ );
}
this.renderReadTextOverlay(designWidth, designHeight);
}
@@ -2737,162 +2738,8 @@ export class PixiSongSelectView {
designHeight: number,
entries: readonly BrowserBrowseEntry[],
): void {
- const chrome = new Graphics();
- chrome.label = 'default-select/chrome';
- const addText = (
- text: string,
- x: number,
- y: number,
- options: {
- size?: number;
- weight?: '400' | '500' | '600' | '700' | '800' | '900';
- fill?: number | Color;
- fontFamily?: string;
- letterSpacing?: number;
- anchorX?: number;
- anchorY?: number;
- maxWidth?: number;
- } = {},
- ): Text => {
- const node = new Text({
- text,
- style: new TextStyle({
- fill: options.fill ?? TEXT,
- fontSize: options.size ?? 10,
- fontWeight: options.weight ?? '500',
- fontFamily: options.fontFamily ?? LR2_TEXT_FALLBACK_FONT,
- letterSpacing: options.letterSpacing ?? 0,
- }),
- });
- node.anchor.set(options.anchorX ?? 0, options.anchorY ?? 0);
- node.position.set(x, y);
- if (options.maxWidth !== undefined && node.width > options.maxWidth) {
- node.scale.x = options.maxWidth / node.width;
- }
- this.listLayer.addChild(node);
- return node;
- };
-
- chrome.rect(0, 0, designWidth, designHeight).fill(BG);
- chrome.rect(0, 0, designWidth, 40).fill(SURFACE);
- chrome.rect(0, 39, designWidth, 1).fill({ color: LINE, alpha: 0.9 });
-
- const song = this.focusedSong();
- const songTitle = song?.title ?? 'No chart selected';
- const songArtist = song?.artist || song?.subtitle || '';
- const playLevel = song?.playLevel !== undefined ? String(song.playLevel) : '-';
- const playLevelNumber =
- song?.playLevel !== undefined ? Number.parseFloat(String(song.playLevel).replace(/^[^\d.]+/u, '')) : NaN;
- const songBpm = song?.bpm !== undefined ? String(Math.round(song.bpm)) : '-';
- const fileLabel = song?.fileLabel ?? '';
- const modeLabel = song ? formatPlayVariantLabel(song) : '- KEYS';
const currentFolder = this.browseStack[this.browseStack.length - 1];
const categoryName = this.searchQuery ? `Search: ${this.searchQuery}` : (currentFolder?.label ?? 'Library');
- const selectedPosition =
- entries.length > 0 ? `${Math.min(this.selectedIndex + 1, entries.length)} / ${entries.length}` : '0 / 0';
-
- addText('MUSIC SELECT', 16, 12, { size: 12, weight: '900', fill: TEXT, letterSpacing: 1.4 });
- addText(categoryName, designWidth - 16, 12, {
- size: 10,
- weight: '700',
- fill: MUTED,
- anchorX: 1,
- maxWidth: 260,
- });
-
- chrome.roundRect(12, 54, 292, 306, 6).fill(PANEL).stroke({ color: LINE, width: 1 });
- chrome.rect(24, 94, 268, 1).fill({ color: LINE, alpha: 0.7 });
- chrome.roundRect(24, 150, 76, 48, 5).fill(PANEL_ACTION).stroke({ color: LINE, width: 1 });
- chrome.roundRect(112, 150, 76, 48, 5).fill(PANEL_ACTION).stroke({ color: LINE, width: 1 });
- chrome.roundRect(200, 150, 92, 48, 5).fill(PANEL_ACTION).stroke({ color: LINE, width: 1 });
- chrome.rect(24, 226, 268, 12).fill(PANEL_DARK);
- if (Number.isFinite(playLevelNumber)) {
- const levelRatio = Math.max(0.04, Math.min(1, playLevelNumber / 12));
- chrome.rect(24, 226, Math.round(268 * levelRatio), 12).fill(0x4bd7c8);
- }
- chrome.roundRect(24, 312, 82, 28, 5).fill(PANEL_ACTION).stroke({ color: LINE, width: 1 });
- chrome.roundRect(112, 312, 180, 28, 5).fill(PANEL_ACTION_ACTIVE).stroke({ color: 0xffc857, width: 1 });
-
- addText('SELECTED', 24, 70, { size: 8, weight: '800', fill: 0x6e685d, letterSpacing: 1 });
- addText(songTitle, 24, 104, { size: 18, weight: '900', fill: TEXT, maxWidth: 268 });
- if (songArtist) {
- addText(songArtist, 24, 128, { size: 10, weight: '600', fill: MUTED, maxWidth: 268 });
- }
- addText('MODE', 34, 160, { size: 8, weight: '800', fill: 0x6e685d });
- addText(modeLabel, 34, 174, {
- size: 12,
- weight: '900',
- fill: TEXT,
- fontFamily: LR2_NUMERIC_FALLBACK_FONT,
- maxWidth: 56,
- });
- addText('BPM', 122, 160, { size: 8, weight: '800', fill: 0x6e685d });
- addText(songBpm, 122, 173, {
- size: 15,
- weight: '900',
- fill: ACTIVE,
- fontFamily: LR2_NUMERIC_FALLBACK_FONT,
- });
- addText('LEVEL', 210, 160, { size: 8, weight: '800', fill: 0x6e685d });
- addText(playLevel, 210, 171, {
- size: 18,
- weight: '900',
- fill: 0xffc857,
- fontFamily: LR2_NUMERIC_FALLBACK_FONT,
- });
- if (fileLabel) {
- addText(fileLabel, 24, 250, {
- size: 8,
- weight: '600',
- fill: MUTED,
- fontFamily: LR2_TEXT_FALLBACK_FONT,
- maxWidth: 268,
- });
- }
- addText('PLAY', 65, 321, {
- size: 9,
- weight: '900',
- fill: TEXT,
- anchorX: 0.5,
- fontFamily: LR2_NUMERIC_FALLBACK_FONT,
- });
- addText('AUTO PLAY', 202, 321, {
- size: 9,
- weight: '900',
- fill: 0xffc857,
- anchorX: 0.5,
- fontFamily: LR2_NUMERIC_FALLBACK_FONT,
- maxWidth: 158,
- });
- addText(selectedPosition, 24, 348, { size: 10, weight: '700', fill: MUTED, fontFamily: LR2_NUMERIC_FALLBACK_FONT });
-
- chrome
- .roundRect(316, 50, designWidth - 332, designHeight - 76, 6)
- .fill({ color: PANEL_DARK, alpha: 0.85 })
- .stroke({ color: LINE, width: 1 });
- addText(this.searchQuery ? 'SEARCH RESULTS' : currentFolder ? 'CHARTS' : 'FOLDERS', 328, 28, {
- size: 8,
- weight: '800',
- fill: 0x6e685d,
- letterSpacing: 1,
- });
-
- chrome.roundRect(12, 376, 292, 28, 5).fill(PANEL_DARK).stroke({ color: LINE, width: 1 });
- addText('SEARCH', 24, 386, { size: 8, weight: '800', fill: 0x6e685d, letterSpacing: 1 });
- addText(this.searchQuery || 'Title / artist / genre', 82, 384, {
- size: 10,
- weight: '600',
- fill: this.searchQuery ? TEXT : 0x6e685d,
- maxWidth: 210,
- });
-
- const searchHit = new Graphics();
- searchHit.rect(12, 376, 292, 28).fill({ color: 0xffffff, alpha: 0.001 });
- searchHit.eventMode = 'static';
- searchHit.cursor = 'text';
- searchHit.on('pointerdown', () => this.options.onSearchActivate?.());
- this.listLayer.addChild(searchHit);
-
const launchFocused = (autoPlay: boolean): void => {
const focused = this.focusedSong();
if (!focused) return;
@@ -2906,29 +2753,21 @@ export class PixiSongSelectView {
}
this.options.onSongSelected?.(focused);
};
- const playHit = new Graphics();
- playHit.rect(24, 312, 82, 28).fill({ color: 0xffffff, alpha: 0.001 });
- playHit.eventMode = 'static';
- playHit.cursor = 'pointer';
- playHit.on('pointerdown', () => launchFocused(false));
- this.listLayer.addChild(playHit);
-
- const autoPlayHit = new Graphics();
- autoPlayHit.rect(112, 312, 180, 28).fill({ color: 0xffffff, alpha: 0.001 });
- autoPlayHit.eventMode = 'static';
- autoPlayHit.cursor = 'pointer';
- autoPlayHit.on('pointerdown', () => launchFocused(true));
- this.listLayer.addChild(autoPlayHit);
-
- chrome.roundRect(12, 420, 292, 42, 5).fill(PANEL).stroke({ color: LINE, width: 1 });
- addText('LIBRARY', 24, 432, { size: 8, weight: '800', fill: 0x6e685d, letterSpacing: 1 });
- addText(`${entries.length} shown / ${this.collection.songs.length} charts`, 24, 446, {
- size: 11,
- weight: '700',
- fill: MUTED,
+ renderDefaultSelectChrome(this.listLayer, {
+ designWidth,
+ designHeight,
+ elapsedMs: Math.max(0, performance.now() - this.sceneStartedAt),
+ selectedIndex: this.selectedIndex,
+ entries,
+ searchQuery: this.searchQuery,
+ categoryName,
+ libraryShown: entries.length,
+ libraryTotal: this.collection.songs.length,
+ song: this.focusedSong(),
+ onSearchActivate: () => this.options.onSearchActivate?.(),
+ onPlay: () => launchFocused(false),
+ onAutoPlay: () => launchFocused(true),
});
-
- this.listLayer.addChildAt(chrome, 0);
}
/**
@@ -3625,110 +3464,21 @@ export class PixiSongSelectView {
listY: number,
listWidth: number,
rowHeight: number,
+ elapsedMs: number,
+ selectedSlamT: number,
): void {
- const y = listY + visibleIndex * rowHeight;
- const row = new Graphics();
- const active = entryIndex === this.selectedIndex;
- const song = entry.kind === 'song' ? entry.song : undefined;
- const folder = entry.kind === 'folder' ? entry.folder : undefined;
- const titleText = song?.title ?? folder?.label ?? '';
- const keyText = song ? formatPlayVariantLabel(song).replace(' KEYS', '') : 'DIR';
- const metaText = song
- ? [song.playLevel !== undefined ? `Lv ${song.playLevel}` : undefined, song.bpm ? `${song.bpm}BPM` : undefined]
- .filter(Boolean)
- .join(' · ')
- : `${folder?.songs.length ?? 0} chart${folder?.songs.length === 1 ? '' : 's'}`;
- const playLevelText =
- song?.playLevel !== undefined ? String(song.playLevel) : folder ? String(folder.songs.length) : '-';
- const keyPillX = listX + 6;
- const keyPillW = 28;
- const levelPillX = keyPillX + keyPillW + 4;
- const levelPillW = 28;
- const titleX = levelPillX + levelPillW + 8;
- const textMaxWidth = Math.max(24, listWidth - (titleX - listX) - 8);
- row.label = `fallback-row[idx=${entryIndex},kind=${entry.kind}${active ? ',active' : ''}]`;
- if (active) {
- row
- .roundRect(listX - 4, y - 1, listWidth + 8, rowHeight, 5)
- .fill({ color: ACTIVE, alpha: 0.95 })
- .stroke({ color: 0xffffff, width: 1, alpha: 0.55 });
- } else {
- row
- .roundRect(listX, y, listWidth, rowHeight - 3, 4)
- .fill({ color: PANEL, alpha: 0.78 })
- .stroke({ color: LINE, width: 1, alpha: 0.75 });
- }
- this.listLayer.addChild(row);
-
- row.roundRect(keyPillX, y + 5, keyPillW, rowHeight - 12, 3).fill(active ? SURFACE : PANEL_SOFT);
-
- row
- .roundRect(levelPillX, y + 5, levelPillW, rowHeight - 12, 3)
- .fill(active ? SURFACE : PANEL_DARK)
- .stroke({ color: active ? 0xffffff : LINE, width: 1, alpha: active ? 0.35 : 0.8 });
-
- const keyLabel = new Text({
- text: keyText,
- style: new TextStyle({
- fill: active ? 0xffffff : MUTED,
- fontSize: 8,
- fontWeight: '900',
- fontFamily: LR2_NUMERIC_FALLBACK_FONT,
- }),
- });
- keyLabel.anchor.set(0.5, 0.5);
- keyLabel.position.set(keyPillX + keyPillW / 2, y + rowHeight / 2 - 1);
- if (keyLabel.width > keyPillW - 4) {
- keyLabel.scale.x = (keyPillW - 4) / keyLabel.width;
- }
- this.listLayer.addChild(keyLabel);
-
- const playLevel = new Text({
- text: playLevelText,
- style: new TextStyle({
- fill: active ? 0xffffff : 0xffc857,
- fontSize: 10,
- fontWeight: '900',
- fontFamily: LR2_NUMERIC_FALLBACK_FONT,
- }),
- });
- playLevel.anchor.set(0.5, 0.5);
- playLevel.position.set(levelPillX + levelPillW / 2, y + rowHeight / 2 - 1);
- if (playLevel.width > levelPillW - 4) {
- playLevel.scale.x = (levelPillW - 4) / playLevel.width;
- }
- this.listLayer.addChild(playLevel);
-
- const title = new Text({
- text: titleText,
- style: new TextStyle({
- fill: active ? 0x06101c : TEXT,
- fontSize: 11,
- fontWeight: '800',
- fontFamily: LR2_TEXT_FALLBACK_FONT,
- }),
- });
- title.label = `fallback-title[idx=${entryIndex}]`;
- title.position.set(titleX, y + 4);
- if (title.width > textMaxWidth) {
- title.scale.x = textMaxWidth / title.width;
- }
- this.listLayer.addChild(title);
-
- const meta = new Text({
- text: metaText,
- style: new TextStyle({
- fill: active ? 0x24403d : MUTED,
- fontSize: 8,
- fontFamily: LR2_NUMERIC_FALLBACK_FONT,
- }),
+ renderDefaultSelectEntryRow(this.listLayer, {
+ entry,
+ entryIndex,
+ visibleIndex,
+ selectedIndex: this.selectedIndex,
+ elapsedMs,
+ listX,
+ listY,
+ listWidth,
+ rowHeight,
+ selectedSlamT,
});
- meta.label = `fallback-meta[idx=${entryIndex}]`;
- meta.position.set(titleX, y + 17);
- if (meta.width > textMaxWidth) {
- meta.scale.x = textMaxWidth / meta.width;
- }
- this.listLayer.addChild(meta);
}
}
@@ -3737,16 +3487,6 @@ function clampSlot(value: number, slotCount: number): number {
return Math.min(slotCount - 1, Math.max(0, Math.trunc(value)));
}
-/**
- * Human-readable mode label for the focused song's play variant (`'5K' | '7K' | '9K' | '10K' | '14K' KEYS`). Used to
- * populate the `MODE` pill on the no-skin select banner so the user sees the same kind of badge the LR2 default skin
- * paints with its mode bitmap.
- */
-function formatPlayVariantLabel(song: BrowserSongEntry): string {
- const variant = resolveChartPlayVariant(song);
- return `${variant} KEYS`;
-}
-
/**
* Maps any integer (including negatives or values past the end) into `[0, count)` by modular arithmetic. Returns
* `undefined` when the list is empty so the caller can decide what to draw (or skip).