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).