diff --git a/.changeset/player-web-marquee-long-text.md b/.changeset/player-web-marquee-long-text.md new file mode 100644 index 00000000..6e070a05 --- /dev/null +++ b/.changeset/player-web-marquee-long-text.md @@ -0,0 +1,7 @@ +--- +'@be-music/player-web': patch +--- + +Long song titles, artists, genres, and stat values in the built-in skins no longer get squeezed until they are unreadable: text is narrowed to at most 85% width, and anything still too long is clipped to its box and scrolls through it like a marquee (rests at the start, scrolls, rests at the end, then returns), including on screens that are not redrawn every frame. Synesthesia, Lattice, and Phantom share this through their Pixi helpers, with unfocused list rows clipped at the start instead of scrolling; Plain does the same with its own Canvas 2D code. + +Also fixes text that ran outside its panel: the genre line on Synesthesia's result screen, and the BPM, gimmick, and other stat values (plus the list row facts and folder label) on Plain's screens. diff --git a/packages/player-web/src/skins/lattice/draw.ts b/packages/player-web/src/skins/lattice/draw.ts index 3fda64a7..2af95610 100644 --- a/packages/player-web/src/skins/lattice/draw.ts +++ b/packages/player-web/src/skins/lattice/draw.ts @@ -1,4 +1,4 @@ -import type { Container, Graphics } from 'pixi.js'; +import { Container, type Graphics } from 'pixi.js'; import { bendNeedle, digitTransitions, @@ -12,6 +12,8 @@ import { addHudText, addSkinText, type ChildPool, + resolveTextSqueeze, + startMarquee, type HudTextOptions, pointLayerFor, type SkinTextOptions, @@ -247,7 +249,8 @@ export function displayStyle( /** * Non-pooled twin of {@link addStaggeredHudText} for scenes rebuilt per render (select / result). A word wider than - * `options.maxWidth` is squeezed as a whole, letters and advances alike. + * `options.maxWidth` is squeezed as a whole, letters and advances alike, down to `MIN_TEXT_SQUEEZE`; past that the + * letters are grouped, clipped to the box, and scrolled as one marquee. */ export function addStaggeredSkinText( layer: Container, @@ -261,8 +264,12 @@ export function addStaggeredSkinText( const nodes = chars.map((char) => addSkinText(layer, char, 0, y, { ...options, anchorX: 0, maxWidth: undefined })); const widths = nodes.map((node) => node.width); const total = widths.reduce((sum, width) => sum + width, 0); - const squeeze = options.maxWidth !== undefined && total > options.maxWidth ? options.maxWidth / total : 1; - let cursor = x - total * squeeze * (options.anchorX ?? 0); + const maxWidth = options.maxWidth; + const squeeze = maxWidth !== undefined ? resolveTextSqueeze(total, maxWidth) : 1; + const overflow = maxWidth !== undefined ? total * squeeze - maxWidth : 0; + // An overflowing word moves as one group inside its clip box; the letters are laid out from the group's left edge. + const group = overflow > 0.5 ? new Container() : undefined; + let cursor = group ? 0 : x - total * squeeze * (options.anchorX ?? 0); const baseAlpha = options.alpha ?? 1; nodes.forEach((node, index) => { const move = motion(index, nodes.length); @@ -273,5 +280,20 @@ export function addStaggeredSkinText( node.scale.set(scale * squeeze, scale); node.alpha = baseAlpha * (move.alpha ?? 1); cursor += advance; + group?.addChild(node); }); + if (group && maxWidth !== undefined) { + layer.addChild(group); + const height = (options.size ?? 10) * 1.5; + const middle = y + (0.5 - (options.anchorY ?? 0)) * height; + const left = x - maxWidth * (options.anchorX ?? 0); + startMarquee( + group, + layer, + { left, top: middle - height * 1.5, width: maxWidth, height: height * 3 }, + overflow, + `${Math.round(maxWidth)}|${text}`, + options.marquee, + ); + } } diff --git a/packages/player-web/src/skins/lattice/select.ts b/packages/player-web/src/skins/lattice/select.ts index 52c6cd6e..6093d66f 100644 --- a/packages/player-web/src/skins/lattice/select.ts +++ b/packages/player-web/src/skins/lattice/select.ts @@ -486,6 +486,7 @@ class LatticeSelectRenderer implements PixiSelectRenderer { anchorY: 0.5, alpha, maxWidth: facts ? ROW_BPM_W : 70, + marquee: active, }, ); metaNode.label = `fallback-meta[idx=${entryIndex}]`; @@ -547,6 +548,8 @@ class LatticeSelectRenderer implements PixiSelectRenderer { anchorY: 0.5, alpha, maxWidth: Math.max(24, factsLeft - x - 62), + // Only the focused row scrolls a long title; the others clip it at its start. + marquee: active, }, ); title.label = `fallback-title[idx=${entryIndex}]`; @@ -562,6 +565,7 @@ class LatticeSelectRenderer implements PixiSelectRenderer { anchorY: 0.5, alpha, maxWidth: factsLeft - artistX, + marquee: active, }); } return introT < 1 || titleScramble < 1; diff --git a/packages/player-web/src/skins/phantom/result.ts b/packages/player-web/src/skins/phantom/result.ts index 5cd646b2..fc069455 100644 --- a/packages/player-web/src/skins/phantom/result.ts +++ b/packages/player-web/src/skins/phantom/result.ts @@ -30,7 +30,13 @@ import { rollUpValue, stageProgress, } from '@be-music/skin-sdk'; -import { alignCapCenter, type PixiResultFrame, type PixiResultSkin, resolveSkinTextStyle } from '../pixi-kit/index.ts'; +import { + alignCapCenter, + fitTextToWidth, + type PixiResultFrame, + type PixiResultSkin, + resolveSkinTextStyle, +} from '../pixi-kit/index.ts'; /** How fast the entrance timeline plays: the choreography below is authored at 1x and runs 1.3x, so the card settles * sooner without changing its order or easing. */ @@ -121,10 +127,14 @@ export function renderPhantomResult(frame: PixiResultFrame): void { alignCapCenter(node, options.fontFamily ?? DEFAULT_TEXT_FONT, options.weight ?? '500', options.size ?? 10); node.position.set(x, y); node.skew.set(options.skew ?? 0, 0); - if (options.maxWidth !== undefined && node.width > options.maxWidth) { - node.scale.x = options.maxWidth / node.width; - } target.addChild(node); + if (options.maxWidth !== undefined) { + fitTextToWidth(node, target, { + maxWidth: options.maxWidth, + anchorX: options.anchorX ?? 0, + anchorY: options.anchorY ?? 0, + }); + } return node; }; /** Slide a group in from an offset with an ease-out, fading as it lands. */ @@ -556,10 +566,8 @@ function renderMetric( valueText.anchor.set(1, 0.5); alignCapCenter(valueText, DEFAULT_DISPLAY_FONT, '400', 22); valueText.position.set(x + METRIC_W - 8, y + METRIC_H / 2 + 1); - if (valueText.width > 100) { - valueText.scale.x = 100 / valueText.width; - } target.addChild(valueText); + fitTextToWidth(valueText, target, { maxWidth: 100, anchorX: 1, anchorY: 0.5 }); } /** Polyline of `points` inside the rect, drawn up to `progress` (0..1) of the horizontal span. */ diff --git a/packages/player-web/src/skins/phantom/select.ts b/packages/player-web/src/skins/phantom/select.ts index 64224e3c..0f799480 100644 --- a/packages/player-web/src/skins/phantom/select.ts +++ b/packages/player-web/src/skins/phantom/select.ts @@ -637,6 +637,7 @@ class PhantomSelectRenderer implements PixiSelectRenderer { anchorX: 1, anchorY: 0.5, maxWidth: facts ? ROW_BPM_W : 64, + marquee: active, }); meta.label = `fallback-meta[idx=${entryIndex}]`; let factsLeft = rowX + rowW - 12 - (facts ? ROW_BPM_W : metaText ? Math.min(64, meta.width) : 0) - 10; @@ -685,6 +686,8 @@ class PhantomSelectRenderer implements PixiSelectRenderer { fill: active ? PHANTOM_INK : PHANTOM_WHITE, anchorY: 0.5, maxWidth: titleMax, + // Only the focused row scrolls a long title; the others clip it at its start. + marquee: active, }); title.label = `fallback-title[idx=${entryIndex}]`; // The artist trails the title in a quieter weight while there is room for it. @@ -697,6 +700,7 @@ class PhantomSelectRenderer implements PixiSelectRenderer { fill: active ? PHANTOM_RED : PHANTOM_ASH, anchorY: 0.5, maxWidth: factsLeft - artistX, + marquee: active, }); } return slide > 0 || intro > 0; diff --git a/packages/player-web/src/skins/pixi-kit/hud-text.ts b/packages/player-web/src/skins/pixi-kit/hud-text.ts index 9c76b7c2..1261e1c6 100644 --- a/packages/player-web/src/skins/pixi-kit/hud-text.ts +++ b/packages/player-web/src/skins/pixi-kit/hud-text.ts @@ -1,5 +1,6 @@ import { CanvasTextMetrics, type Container, Text, TextStyle } from 'pixi.js'; import { DEFAULT_TEXT_FONT, layoutTabularRun } from '@be-music/skin-sdk'; +import { fitTextToWidth, stopMarquee } from './marquee.ts'; import type { ChildPool } from './pools.ts'; import { textResolution } from './resolution.ts'; import { alignCapCenter } from './text-metrics.ts'; @@ -20,7 +21,10 @@ export interface HudTextOptions { letterSpacing?: number; anchorX?: number; anchorY?: number; + /** Width to fit the text into (squeezed a little, then scrolled as a marquee; see `fitTextToWidth`). */ maxWidth?: number; + /** `false` clips an overflowing text at its start instead of scrolling it. */ + marquee?: boolean; /** Horizontal skew in radians (negative leans the glyph tops right, like italic). */ skewX?: number; rotation?: number; @@ -60,16 +64,22 @@ export function addHudText( // Pooled texts keep their previous skew / rotation, so both are always written. node.skew.set(opts.skewX ?? 0, 0); node.rotation = opts.rotation ?? 0; - if (opts.maxWidth !== undefined && node.width > opts.maxWidth) { - node.scale.x = opts.maxWidth / node.width; - } - if (opts.scale !== undefined && opts.scale !== 1) { - node.scale.x *= opts.scale; - node.scale.y = opts.scale; - } if (!pool) { layer.addChild(node); } + if (opts.maxWidth !== undefined) { + // Pooled nodes live in the pool's text host; the clip box joins them there. + fitTextToWidth(node, node.parent ?? layer, { + maxWidth: opts.maxWidth, + anchorX: opts.anchorX ?? 0, + anchorY: opts.anchorY ?? 0, + scale: opts.scale, + scroll: opts.marquee, + }); + } else { + stopMarquee(node); + if (opts.scale !== undefined && opts.scale !== 1) node.scale.set(opts.scale, opts.scale); + } return node; } diff --git a/packages/player-web/src/skins/pixi-kit/index.ts b/packages/player-web/src/skins/pixi-kit/index.ts index 2959db56..84a11331 100644 --- a/packages/player-web/src/skins/pixi-kit/index.ts +++ b/packages/player-web/src/skins/pixi-kit/index.ts @@ -9,6 +9,7 @@ export * from './types.ts'; export * from './pools.ts'; export * from './hud-text.ts'; export * from './skin-text.ts'; +export * from './marquee.ts'; export * from './text-metrics.ts'; export * from './particle-layer.ts'; export * from './key-beam.ts'; diff --git a/packages/player-web/src/skins/pixi-kit/marquee.test.ts b/packages/player-web/src/skins/pixi-kit/marquee.test.ts new file mode 100644 index 00000000..b51fae4d --- /dev/null +++ b/packages/player-web/src/skins/pixi-kit/marquee.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vite-plus/test'; +import { + DEFAULT_MARQUEE_TIMING, + MIN_TEXT_SQUEEZE, + marqueeCycleMs, + resolveMarqueeAlpha, + resolveMarqueeOffset, + resolveTextSqueeze, + type MarqueeTiming, +} from './marquee.ts'; + +const timing: MarqueeTiming = { startHoldMs: 1000, speedPxPerSecond: 50, endHoldMs: 500, fadeMs: 200 }; + +describe('resolveTextSqueeze', () => { + it('leaves text that fits at full width', () => { + expect(resolveTextSqueeze(80, 100)).toBe(1); + expect(resolveTextSqueeze(100, 100)).toBe(1); + }); + + it('squeezes text that fits within the minimum squeeze', () => { + expect(resolveTextSqueeze(110, 100)).toBeCloseTo(100 / 110); + expect(resolveTextSqueeze(100 / MIN_TEXT_SQUEEZE, 100)).toBeCloseTo(MIN_TEXT_SQUEEZE); + }); + + it('stops at the minimum squeeze for text that still overflows', () => { + expect(resolveTextSqueeze(400, 100)).toBe(MIN_TEXT_SQUEEZE); + expect(resolveTextSqueeze(400, 100, 0.5)).toBe(0.5); + }); + + it('treats empty or degenerate widths as fitting', () => { + expect(resolveTextSqueeze(0, 100)).toBe(1); + expect(resolveTextSqueeze(Number.NaN, 100)).toBe(1); + expect(resolveTextSqueeze(50, 0)).toBe(MIN_TEXT_SQUEEZE); + }); +}); + +describe('marqueeCycleMs', () => { + it('adds the holds to the scroll time', () => { + // 100 px at 50 px/s scrolls for 2 s. + expect(marqueeCycleMs(100, timing)).toBe(1000 + 2000 + 500); + }); + + it('is zero without overflow', () => { + expect(marqueeCycleMs(0, timing)).toBe(0); + expect(marqueeCycleMs(-5, timing)).toBe(0); + }); +}); + +describe('resolveMarqueeOffset', () => { + it('holds at the start, scrolls at a constant speed, then holds at the end', () => { + expect(resolveMarqueeOffset(0, 100, timing)).toBe(0); + expect(resolveMarqueeOffset(999, 100, timing)).toBe(0); + expect(resolveMarqueeOffset(1500, 100, timing)).toBeCloseTo(25); + expect(resolveMarqueeOffset(2000, 100, timing)).toBeCloseTo(50); + expect(resolveMarqueeOffset(3000, 100, timing)).toBeCloseTo(100); + expect(resolveMarqueeOffset(3400, 100, timing)).toBe(100); + }); + + it('starts over after each cycle', () => { + expect(resolveMarqueeOffset(3500 + 500, 100, timing)).toBe(0); + expect(resolveMarqueeOffset(3500 * 2 + 1500, 100, timing)).toBeCloseTo(25); + }); + + it('never scrolls text that fits or before it appears', () => { + expect(resolveMarqueeOffset(2000, 0, timing)).toBe(0); + expect(resolveMarqueeOffset(-100, 100, timing)).toBe(0); + }); + + it('uses the default timing when none is given', () => { + const { startHoldMs, speedPxPerSecond } = DEFAULT_MARQUEE_TIMING; + expect(resolveMarqueeOffset(startHoldMs + 1000, 500)).toBeCloseTo(speedPxPerSecond); + }); +}); + +describe('resolveMarqueeAlpha', () => { + it('shows the first appearance without a fade in', () => { + expect(resolveMarqueeAlpha(0, 100, timing)).toBe(1); + expect(resolveMarqueeAlpha(50, 100, timing)).toBe(1); + }); + + it('fades out at the end of the end hold and back in on the next cycle', () => { + expect(resolveMarqueeAlpha(3200, 100, timing)).toBe(1); + expect(resolveMarqueeAlpha(3400, 100, timing)).toBeCloseTo(0.5); + expect(resolveMarqueeAlpha(3500 + 100, 100, timing)).toBeCloseTo(0.5); + expect(resolveMarqueeAlpha(3500 + 200, 100, timing)).toBe(1); + }); + + it('stays opaque for text that fits or without a fade', () => { + expect(resolveMarqueeAlpha(3400, 0, timing)).toBe(1); + expect(resolveMarqueeAlpha(3400, 100, { ...timing, fadeMs: 0 })).toBe(1); + }); +}); diff --git a/packages/player-web/src/skins/pixi-kit/marquee.ts b/packages/player-web/src/skins/pixi-kit/marquee.ts new file mode 100644 index 00000000..13bc3e06 --- /dev/null +++ b/packages/player-web/src/skins/pixi-kit/marquee.ts @@ -0,0 +1,271 @@ +import { type Container, type DestroyOptions, Graphics, GraphicsContext, type Text } from 'pixi.js'; + +/** + * Long-text fitting for built-in skin text: a string wider than its `maxWidth` is squeezed horizontally, but never + * below {@link MIN_TEXT_SQUEEZE}. Past that it stays at the minimum squeeze, is clipped to a box exactly `maxWidth` + * wide, and scrolls through the box like a marquee (see {@link resolveMarqueeOffset}). + */ + +/** The narrowest horizontal scale a fitted text is squeezed to before it scrolls instead. */ +export const MIN_TEXT_SQUEEZE = 0.85; + +export interface MarqueeTiming { + /** Time the text rests at its start, so the beginning reads. */ + startHoldMs: number; + /** Scroll speed, in design pixels per second. */ + speedPxPerSecond: number; + /** Time the text rests at its end before returning to the start. */ + endHoldMs: number; + /** Fade out at the end of the end hold and back in at the start of the next cycle (inside the holds). */ + fadeMs: number; +} + +export const DEFAULT_MARQUEE_TIMING: MarqueeTiming = { + startHoldMs: 1500, + speedPxPerSecond: 32, + endHoldMs: 1000, + fadeMs: 220, +}; + +/** + * Horizontal scale that fits a text `width` wide into `maxWidth`: 1 when it fits, the squeeze when it fits squeezed + * no further than `minSqueeze`, otherwise `minSqueeze` (the text then overflows and scrolls). + */ +export function resolveTextSqueeze(width: number, maxWidth: number, minSqueeze: number = MIN_TEXT_SQUEEZE): number { + if (!(width > maxWidth) || !(width > 0)) return 1; + return Math.max(minSqueeze, Math.max(0, maxWidth) / width); +} + +/** Length of one marquee cycle (start hold, scroll, end hold) for a text overflowing its box by `overflowPx`. */ +export function marqueeCycleMs(overflowPx: number, timing: MarqueeTiming = DEFAULT_MARQUEE_TIMING): number { + if (!(overflowPx > 0)) return 0; + return timing.startHoldMs + (overflowPx / timing.speedPxPerSecond) * 1000 + timing.endHoldMs; +} + +/** + * How far (design px, 0..`overflowPx`) a marquee text has scrolled left `elapsedMs` after it first appeared: it rests + * at 0 for the start hold, scrolls at a constant speed to `overflowPx`, rests there for the end hold, then starts over. + */ +export function resolveMarqueeOffset( + elapsedMs: number, + overflowPx: number, + timing: MarqueeTiming = DEFAULT_MARQUEE_TIMING, +): number { + if (!(overflowPx > 0) || !(elapsedMs > 0)) return 0; + const t = elapsedMs % marqueeCycleMs(overflowPx, timing); + if (t <= timing.startHoldMs) return 0; + return Math.min(overflowPx, ((t - timing.startHoldMs) * timing.speedPxPerSecond) / 1000); +} + +/** + * Opacity (0..1) of a marquee text `elapsedMs` after it first appeared: it fades out at the end of each end hold and + * back in as the next cycle starts at the beginning, so the return to the start reads as a cut rather than a jump. + * The first appearance is not faded in. + */ +export function resolveMarqueeAlpha( + elapsedMs: number, + overflowPx: number, + timing: MarqueeTiming = DEFAULT_MARQUEE_TIMING, +): number { + if (!(overflowPx > 0) || !(elapsedMs > 0) || !(timing.fadeMs > 0)) return 1; + const cycle = marqueeCycleMs(overflowPx, timing); + const t = elapsedMs % cycle; + const fadeOut = Math.min(timing.fadeMs, timing.endHoldMs); + if (t > cycle - fadeOut) return Math.max(0, (cycle - t) / fadeOut); + const fadeIn = Math.min(timing.fadeMs, timing.startHoldMs); + if (elapsedMs >= cycle && t < fadeIn) return t / fadeIn; + return 1; +} + +/** One rect (0,0)–(1,1) every clip box shares; each box only positions and scales it. */ +let unitRect: GraphicsContext | undefined; + +/** A marquee's clip box: a shared unit rect that never destroys the shared context it draws. */ +class MarqueeClip extends Graphics { + public constructor() { + unitRect ??= new GraphicsContext().rect(0, 0, 1, 1).fill(0xffffff); + super(unitRect); + this.label = 'marquee-clip'; + } + + public override destroy(options?: DestroyOptions): void { + // Layers are torn down with `context: true`; the shared rect must survive that. + super.destroy(typeof options === 'object' ? { ...options, context: false } : options === true ? {} : options); + } +} + +/** When a scrolling string first appeared, keyed by the string and its box, so rebuilt nodes keep scrolling on. */ +interface MarqueeClock { + startMs: number; + seenMs: number; +} + +const CLOCKS = new Map(); +/** A clock not rendered for this long restarts: the text left the screen, and should read from its start again. */ +const CLOCK_IDLE_MS = 400; +const CLOCK_PRUNE_SIZE = 64; + +function now(): number { + return typeof performance !== 'undefined' ? performance.now() : Date.now(); +} + +function clockFor(key: string, nowMs: number): MarqueeClock { + let clock = CLOCKS.get(key); + if (!clock) { + if (CLOCKS.size >= CLOCK_PRUNE_SIZE) { + for (const [stale, entry] of CLOCKS) if (nowMs - entry.seenMs > CLOCK_IDLE_MS) CLOCKS.delete(stale); + } + clock = { startMs: nowMs, seenMs: nowMs }; + CLOCKS.set(key, clock); + } else if (nowMs - clock.seenMs > CLOCK_IDLE_MS) { + clock.startMs = nowMs; + } + clock.seenMs = nowMs; + return clock; +} + +/** Per-node marquee state: kept on the node across pooled reuse, so a 60 fps HUD redraw allocates nothing. */ +class MarqueeState { + public readonly clip = new MarqueeClip(); + public boxLeft = 0; + public overflow = 0; + public scroll = true; + public key = ''; + public clock: MarqueeClock | undefined; + /** The alpha the owner gave the node, and the faded alpha last written over it. */ + private baseAlpha = 1; + private writtenAlpha = Number.NaN; + private readonly target: Container; + + public constructor(target: Container) { + this.target = target; + target.once('destroyed', () => { + if (!this.clip.destroyed) this.clip.destroy(); + STATES.delete(target); + }); + } + + /** Places the target for the current time (also its `onRender`, so a screen built once still scrolls). */ + public readonly update = (): void => { + const target = this.target; + const clock = this.clock; + if (!clock || target.destroyed) return; + const nowMs = now(); + clock.seenMs = nowMs; + const elapsed = this.scroll ? nowMs - clock.startMs : 0; + target.x = this.boxLeft - resolveMarqueeOffset(elapsed, this.overflow); + // Owners set `alpha` after building (fades, intros); treat any alpha we did not write as the new base. + if (target.alpha !== this.writtenAlpha) this.baseAlpha = target.alpha; + target.alpha = this.baseAlpha * resolveMarqueeAlpha(elapsed, this.overflow); + this.writtenAlpha = target.alpha; + }; + + public resetAlpha(): void { + this.writtenAlpha = Number.NaN; + } +} + +const STATES = new WeakMap(); + +/** The clip box of a marquee, in the coordinates of the target's parent. */ +export interface MarqueeBox { + left: number; + top: number; + width: number; + height: number; +} + +/** + * Clips `target` (a child of `parent` whose content starts at its own x = 0) to `box` with a rect mask added to + * `parent`, and scrolls it left by up to `overflow` px from its `onRender`, so it animates even on screens that are not + * rebuilt every frame. Targets showing the same `key` share one clock: a screen rebuilt every frame (new nodes each + * time) keeps scrolling smoothly. Calling it again on the same target (pooled HUD text) reuses its mask and callback. + */ +export function startMarquee( + target: Container, + parent: Container, + box: MarqueeBox, + overflow: number, + key: string, + scroll = true, +): void { + let marquee = STATES.get(target); + if (!marquee) { + marquee = new MarqueeState(target); + STATES.set(target, marquee); + } + marquee.boxLeft = box.left; + marquee.overflow = overflow; + marquee.scroll = scroll; + if (key !== marquee.key || !marquee.clock) { + marquee.key = key; + marquee.clock = clockFor(key, now()); + } + const clip = marquee.clip; + clip.position.set(box.left, box.top); + clip.scale.set(box.width, box.height); + clip.visible = true; + if (clip.parent !== parent) parent.addChild(clip); + target.mask = clip; + if (target.onRender !== marquee.update) { + marquee.resetAlpha(); + target.onRender = marquee.update; + } + marquee.update(); +} + +/** Drops `target`'s marquee (clip and scrolling), e.g. when a pooled node is reused for text that fits. */ +export function stopMarquee(target: Container): void { + const state = STATES.get(target); + if (!state) return; + if (target.onRender === state.update) target.onRender = null; + if (target.mask === state.clip) target.mask = null; + // Once no longer a mask the rect would render as a white box. + state.clip.visible = false; + state.key = ''; + state.clock = undefined; +} + +export interface FitTextOptions { + /** Box width; the text's anchor point sits `anchorX` of the way across it. */ + maxWidth: number; + anchorX: number; + anchorY: number; + /** Uniform scale applied on top of the fit (HUD "punch"); the box grows with it. */ + scale?: number; + /** `false` clips an overflowing text at its start without scrolling (e.g. an unfocused list row). */ + scroll?: boolean; +} + +/** + * Fits `node` (positioned at its anchor point, unscaled, already or about to be a child of `parent`) into + * `options.maxWidth`: squeezed down to {@link MIN_TEXT_SQUEEZE}, then clipped and scrolled (see {@link startMarquee}). + */ +export function fitTextToWidth(node: Text, parent: Container, options: FitTextOptions): void { + const scale = options.scale ?? 1; + const width = node.width; + const squeeze = resolveTextSqueeze(width, options.maxWidth); + const overflow = (width * squeeze - options.maxWidth) * scale; + // A rotated text cannot be clipped by an axis-aligned box; it keeps the plain squeeze. + if (!(overflow > 0.5) || node.rotation !== 0) { + stopMarquee(node); + node.scale.set((overflow > 0.5 ? options.maxWidth / width : squeeze) * scale, scale); + return; + } + node.scale.set(squeeze * scale, scale); + const boxWidth = options.maxWidth * scale; + const left = node.x - options.anchorX * boxWidth; + node.anchor.x = 0; + node.x = left; + // Clip vertically with room to spare (descenders, strokes, shadows); only the horizontal edges matter. + const height = node.height; + const middle = node.y + (0.5 - options.anchorY) * height - node.pivot.y * node.scale.y; + startMarquee( + node, + parent, + { left, top: middle - height * 1.5, width: boxWidth, height: height * 3 }, + overflow, + `${Math.round(options.maxWidth)}|${node.text}`, + options.scroll, + ); +} diff --git a/packages/player-web/src/skins/pixi-kit/skin-text.ts b/packages/player-web/src/skins/pixi-kit/skin-text.ts index e709fb89..e2a3cb04 100644 --- a/packages/player-web/src/skins/pixi-kit/skin-text.ts +++ b/packages/player-web/src/skins/pixi-kit/skin-text.ts @@ -1,6 +1,7 @@ import { type Color, type Container, Text, TextStyle } from 'pixi.js'; import { DEFAULT_TEXT_FONT } from '@be-music/skin-sdk'; import { rememberStyle } from './hud-text.ts'; +import { fitTextToWidth } from './marquee.ts'; import { textResolution } from './resolution.ts'; import { alignCapCenter } from './text-metrics.ts'; @@ -12,7 +13,13 @@ export interface SkinTextOptions { letterSpacing?: number; anchorX?: number; anchorY?: number; + /** + * Width to fit the text into: squeezed horizontally down to `MIN_TEXT_SQUEEZE`, then clipped to the box and scrolled + * like a marquee (see `fitTextToWidth`). + */ maxWidth?: number; + /** `false` clips an overflowing text at its start instead of scrolling it (e.g. unfocused list rows). */ + marquee?: boolean; skewX?: number; alpha?: number; stroke?: { color: number; width: number; alignment?: number; join?: 'round' | 'bevel' | 'miter' }; @@ -21,8 +28,8 @@ export interface SkinTextOptions { } /** - * One text node for built-in skin chrome that is rebuilt on input (select / result), squeezed horizontally to - * `maxWidth` when needed. Per-frame HUD text uses pooled nodes instead (see the gameplay chrome renderers). + * One text node for built-in skin chrome that is rebuilt on input (select / result), fitted to `maxWidth` when needed + * (squeezed a little, then scrolled as a marquee; see `fitTextToWidth`). Per-frame HUD text uses pooled nodes instead (see the gameplay chrome renderers). */ export function addSkinText(layer: Container, text: string, x: number, y: number, options: SkinTextOptions = {}): Text { const node = new Text({ @@ -36,10 +43,15 @@ export function addSkinText(layer: Container, text: string, x: number, y: number node.position.set(x, y); node.skew.set(options.skewX ?? 0, 0); node.alpha = options.alpha ?? 1; - if (options.maxWidth !== undefined && node.width > options.maxWidth) { - node.scale.x = options.maxWidth / node.width; - } layer.addChild(node); + if (options.maxWidth !== undefined) { + fitTextToWidth(node, layer, { + maxWidth: options.maxWidth, + anchorX: options.anchorX ?? 0, + anchorY: options.anchorY ?? 0, + scroll: options.marquee, + }); + } return node; } diff --git a/packages/player-web/src/skins/plain/gameplay.ts b/packages/player-web/src/skins/plain/gameplay.ts index 87ffda69..da9afc2b 100644 --- a/packages/player-web/src/skins/plain/gameplay.ts +++ b/packages/player-web/src/skins/plain/gameplay.ts @@ -156,7 +156,7 @@ function judgeColor(judge: string): string { /** Header (mode, tempo, speed), the gauge under the lanes, the track and the score figures along the bottom. */ function drawHud(ctx: CanvasRenderingContext2D, frame: BeMusicGameplayFrame, width: number, height: number): void { - const { runtime } = frame; + const { runtime, nowMs } = frame; text(ctx, runtime.autoplay ? 'AUTO PLAY' : 'PLAY', MARGIN, 18, { size: 12, weight: 800, color: ACCENT }); text(ctx, `BPM ${Math.round(runtime.bpm ?? 0)} SPEED x${(runtime.hiSpeed ?? 0).toFixed(1)}`, 110, 18, { @@ -181,9 +181,11 @@ function drawHud(ctx: CanvasRenderingContext2D, frame: BeMusicGameplayFrame, wid // Track, bottom left. // Genre, title, artist — the order every built-in skin uses. - if (runtime.songGenre) text(ctx, runtime.songGenre, MARGIN, height - 70, { size: 10, color: MUTED, maxWidth: 380 }); - text(ctx, runtime.songTitle ?? '', MARGIN, height - 50, { size: 16, weight: 700, maxWidth: 380 }); - text(ctx, runtime.songArtist ?? '', MARGIN, height - 28, { size: 11, color: MUTED, maxWidth: 380 }); + if (runtime.songGenre) { + text(ctx, runtime.songGenre, MARGIN, height - 70, { size: 10, color: MUTED, maxWidth: 380, nowMs }); + } + text(ctx, runtime.songTitle ?? '', MARGIN, height - 50, { size: 16, weight: 700, maxWidth: 380, nowMs }); + text(ctx, runtime.songArtist ?? '', MARGIN, height - 28, { size: 11, color: MUTED, maxWidth: 380, nowMs }); // Score figures, bottom right, in one row. const figures: Array<[string, string]> = [ @@ -193,7 +195,7 @@ function drawHud(ctx: CanvasRenderingContext2D, frame: BeMusicGameplayFrame, wid ['RANK', runtime.rank ?? '-'], ]; figures.forEach(([label, value], index) => { - stat(ctx, label, value, width - MARGIN - 400 + index * 106, height - 56); + stat(ctx, label, value, width - MARGIN - 400 + index * 106, height - 56, { maxWidth: 98, nowMs }); }); // Judgement counts down the right edge, beside the BGA. diff --git a/packages/player-web/src/skins/plain/result.ts b/packages/player-web/src/skins/plain/result.ts index fffb023a..0b73eb3d 100644 --- a/packages/player-web/src/skins/plain/result.ts +++ b/packages/player-web/src/skins/plain/result.ts @@ -10,7 +10,7 @@ const MARGIN = 16; /** The result screen: verdict and rank, the score figures, the judgement counts, and the chart's facts. */ export function drawResult({ context: ctx, width, height }: BeMusicSurface<'2d'>, frame: BeMusicResultFrame): void { - const { result } = frame; + const { result, nowMs } = frame; ctx.fillStyle = INK; ctx.fillRect(0, 0, width, height); @@ -36,7 +36,7 @@ export function drawResult({ context: ctx, width, height }: BeMusicSurface<'2d'> ['NOTES', String(result.score.total)], ]; figures.forEach(([label, value], index) => { - stat(ctx, label, value, 240 + (index % 3) * 130, 76 + Math.floor(index / 3) * 56); + stat(ctx, label, value, 240 + (index % 3) * 130, 76 + Math.floor(index / 3) * 56, { maxWidth: 120, nowMs }); }); // Judgement counts. @@ -59,13 +59,13 @@ export function drawResult({ context: ctx, width, height }: BeMusicSurface<'2d'> ctx.fillRect(x, 60, 220, 330); // Genre, title, artist — the order every built-in skin uses. const lift = result.song.genre ? 12 : 0; - if (result.song.genre) text(ctx, result.song.genre, x + 14, 78, { size: 10, color: MUTED, maxWidth: 192 }); - text(ctx, result.song.title, x + 14, 82 + lift, { size: 14, weight: 800, maxWidth: 192 }); - text(ctx, result.song.artist ?? '', x + 14, 102 + lift, { size: 11, color: MUTED, maxWidth: 192 }); + if (result.song.genre) text(ctx, result.song.genre, x + 14, 78, { size: 10, color: MUTED, maxWidth: 192, nowMs }); + text(ctx, result.song.title, x + 14, 82 + lift, { size: 14, weight: 800, maxWidth: 192, nowMs }); + text(ctx, result.song.artist ?? '', x + 14, 102 + lift, { size: 11, color: MUTED, maxWidth: 192, nowMs }); resolveResultTrackRows(result).forEach((row, index) => { const y = 140 + index * 34; text(ctx, row.label, x + 14, y, { size: 10, color: MUTED, weight: 700 }); - text(ctx, row.value, x + 206, y, { size: 14, align: 'right' }); + text(ctx, row.value, x + 206, y, { size: 14, align: 'right', maxWidth: 120, nowMs }); }); const lamp = resolveResultLamp(result); text(ctx, lamp, x + 110, 360, { size: 18, weight: 800, align: 'center', color: lamp === 'FAILED' ? DANGER : ACCENT }); diff --git a/packages/player-web/src/skins/plain/select.ts b/packages/player-web/src/skins/plain/select.ts index beed8070..8eabc9eb 100644 --- a/packages/player-web/src/skins/plain/select.ts +++ b/packages/player-web/src/skins/plain/select.ts @@ -26,6 +26,8 @@ export function drawSelect({ context: ctx, width, height }: BeMusicSurface<'2d'> size: 11, color: MUTED, align: 'right', + maxWidth: width - MARGIN * 2 - 140, + nowMs: frame.nowMs, }); drawFocusedSong(ctx, frame); @@ -55,18 +57,23 @@ function drawFocusedSong(ctx: CanvasRenderingContext2D, frame: BeMusicSelectFram return; } // Genre, title, artist — the order every built-in skin uses. + const { nowMs } = frame; const lift = song.genre ? 12 : 0; - if (song.genre) text(ctx, song.genre, x + 16, y + 22, { size: 10, color: MUTED, maxWidth: PANEL_W - 32 }); - text(ctx, song.title, x + 16, y + 28 + lift, { size: 18, weight: 800, maxWidth: PANEL_W - 32 }); - text(ctx, song.artist ?? '', x + 16, y + 52 + lift, { size: 11, color: MUTED, maxWidth: PANEL_W - 32 }); + const textW = PANEL_W - 32; + if (song.genre) text(ctx, song.genre, x + 16, y + 22, { size: 10, color: MUTED, maxWidth: textW, nowMs }); + text(ctx, song.title, x + 16, y + 28 + lift, { size: 18, weight: 800, maxWidth: textW, nowMs }); + text(ctx, song.artist ?? '', x + 16, y + 52 + lift, { size: 11, color: MUTED, maxWidth: textW, nowMs }); + // Three columns of facts; each value keeps to its column (a wide BPM range or gimmick list scrolls). const facts = resolveSongRowFacts(song); - stat(ctx, 'MODE', formatPlayVariantLabel(song), x + 16, y + 92); - stat(ctx, 'LEVEL', String(song.playLevel ?? '-'), x + 110, y + 92); - stat(ctx, 'BPM', facts.bpm, x + 190, y + 92); - stat(ctx, 'NOTES', facts.notes, x + 16, y + 140); - stat(ctx, 'LENGTH', facts.length, x + 110, y + 140); - stat(ctx, 'GIMMICKS', facts.tags.join(' ') || '-', x + 190, y + 140); + const narrow = { maxWidth: 86, nowMs }; + const wide = { maxWidth: 84, nowMs }; + stat(ctx, 'MODE', formatPlayVariantLabel(song), x + 16, y + 92, narrow); + stat(ctx, 'LEVEL', String(song.playLevel ?? '-'), x + 110, y + 92, { maxWidth: 72, nowMs }); + stat(ctx, 'BPM', facts.bpm, x + 190, y + 92, wide); + stat(ctx, 'NOTES', facts.notes, x + 16, y + 140, narrow); + stat(ctx, 'LENGTH', facts.length, x + 110, y + 140, { maxWidth: 72, nowMs }); + stat(ctx, 'GIMMICKS', facts.tags.join(' ') || '-', x + 190, y + 140, wide); button(ctx, frame, 'PLAY', x + 16, y + 236, 160, frame.actions.play, true); button(ctx, frame, 'AUTO', x + 186, y + 236, 88, frame.actions.autoPlay, false); @@ -103,7 +110,7 @@ function drawList(ctx: CanvasRenderingContext2D, frame: BeMusicSelectFrame, widt const selected = index === frame.selectedIndex; ctx.fillStyle = selected ? ACCENT : row % 2 === 0 ? PANEL : INK; ctx.fillRect(listX, y, rowW, rowHeight - 2); - drawRow(ctx, entry, listX, y + (rowHeight - 2) / 2, rowW, selected); + drawRow(ctx, entry, listX, y + (rowHeight - 2) / 2, rowW, selected ? frame.nowMs : undefined); } } @@ -113,21 +120,24 @@ function drawRow( x: number, midY: number, w: number, - selected: boolean, + /** The frame clock on the selected row, whose long texts scroll; other rows just clip theirs. */ + nowMs: number | undefined, ): void { - const dim = selected ? TEXT : MUTED; + const dim = nowMs !== undefined ? TEXT : MUTED; if (entry.kind === 'folder') { - text(ctx, `▸ ${entry.folder.label}`, x + 12, midY, { size: 12, weight: 700, maxWidth: w - 120 }); + text(ctx, `▸ ${entry.folder.label}`, x + 12, midY, { size: 12, weight: 700, maxWidth: w - 120, nowMs }); text(ctx, `${entry.folder.songs.length} charts`, x + w - 12, midY, { size: 11, color: dim, align: 'right' }); return; } const song = entry.song; const facts = resolveSongRowFacts(song); text(ctx, String(song.playLevel ?? '-'), x + 22, midY, { size: 12, weight: 800, align: 'center' }); - text(ctx, song.title, x + 44, midY, { size: 12, weight: 700, maxWidth: w - 300 }); + text(ctx, song.title, x + 44, midY, { size: 12, weight: 700, maxWidth: w - 300, nowMs }); text(ctx, `${facts.notes} NOTES ${facts.length} ${facts.bpm} BPM`, x + w - 12, midY, { size: 11, color: dim, align: 'right', + maxWidth: 236, + nowMs, }); } diff --git a/packages/player-web/src/skins/plain/theme.test.ts b/packages/player-web/src/skins/plain/theme.test.ts new file mode 100644 index 00000000..5a518b0f --- /dev/null +++ b/packages/player-web/src/skins/plain/theme.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vite-plus/test'; +import { marqueeOffset } from './theme.ts'; + +describe('marqueeOffset', () => { + it('rests at the start, scrolls at 32 px/s, rests at the end, then starts over', () => { + // 64 px of overflow: 1.5 s hold, 2 s scroll, 1 s hold — a 4.5 s cycle. + expect(marqueeOffset(0, 64)).toBe(0); + expect(marqueeOffset(1500, 64)).toBe(0); + expect(marqueeOffset(2500, 64)).toBeCloseTo(32); + expect(marqueeOffset(3500, 64)).toBeCloseTo(64); + expect(marqueeOffset(4400, 64)).toBe(64); + expect(marqueeOffset(4500 + 100, 64)).toBe(0); + }); + + it('never scrolls text that fits', () => { + expect(marqueeOffset(2500, 0)).toBe(0); + expect(marqueeOffset(2500, -10)).toBe(0); + }); +}); diff --git a/packages/player-web/src/skins/plain/theme.ts b/packages/player-web/src/skins/plain/theme.ts index 06b6a784..d3b9f855 100644 --- a/packages/player-web/src/skins/plain/theme.ts +++ b/packages/player-web/src/skins/plain/theme.ts @@ -38,14 +38,53 @@ export function css(color: number): string { return `#${color.toString(16).padStart(6, '0')}`; } +/** The narrowest a `maxWidth` text is squeezed; anything longer is clipped to the box and scrolls instead. */ +export const MIN_SQUEEZE = 0.85; +/** Marquee timing: rest at the start so it reads, scroll at a steady pace, rest at the end, then jump back. */ +const MARQUEE_START_HOLD_MS = 1500; +const MARQUEE_SPEED_PX_PER_S = 32; +const MARQUEE_END_HOLD_MS = 1000; + +/** How far (px) a text overflowing its box by `overflow` px has scrolled `elapsedMs` after it appeared. */ +export function marqueeOffset(elapsedMs: number, overflow: number): number { + if (!(overflow > 0) || !(elapsedMs > 0)) return 0; + const scrollMs = (overflow / MARQUEE_SPEED_PX_PER_S) * 1000; + const t = elapsedMs % (MARQUEE_START_HOLD_MS + scrollMs + MARQUEE_END_HOLD_MS); + if (t <= MARQUEE_START_HOLD_MS) return 0; + return Math.min(overflow, ((t - MARQUEE_START_HOLD_MS) * MARQUEE_SPEED_PX_PER_S) / 1000); +} + +/** When each scrolling string was first drawn, so it starts from its beginning; forgotten once it leaves the screen. */ +const MARQUEE_SINCE = new Map(); + +function marqueeElapsed(value: string, nowMs: number): number { + let since = MARQUEE_SINCE.get(value); + if (!since || nowMs - since.seenMs > 400) { + if (MARQUEE_SINCE.size > 64) { + for (const [key, entry] of MARQUEE_SINCE) if (nowMs - entry.seenMs > 400) MARQUEE_SINCE.delete(key); + } + since = { startMs: nowMs, seenMs: nowMs }; + MARQUEE_SINCE.set(value, since); + } + since.seenMs = nowMs; + return nowMs - since.startMs; +} + +export interface TextOptions { + size: number; + color?: string; + weight?: number; + align?: CanvasTextAlign; + /** + * Box the text must fit: squeezed down to {@link MIN_SQUEEZE}, then clipped to the box. With `nowMs` (the frame's + * clock) a clipped text scrolls through the box like a marquee; without it, it shows its start. + */ + maxWidth?: number; + nowMs?: number; +} + /** Draws `text` with its left edge (or `align` point) at `x` and its middle at `y`. */ -export function text( - ctx: CanvasRenderingContext2D, - value: string, - x: number, - y: number, - options: { size: number; color?: string; weight?: number; align?: CanvasTextAlign; maxWidth?: number }, -): void { +export function text(ctx: CanvasRenderingContext2D, value: string, x: number, y: number, options: TextOptions): void { const wanted = font(options.size, options.weight); if (CURRENT_FONTS.get(ctx) !== wanted) { ctx.font = wanted; @@ -54,11 +93,41 @@ export function text( ctx.fillStyle = options.color ?? TEXT; ctx.textAlign = options.align ?? 'left'; ctx.textBaseline = 'middle'; - ctx.fillText(value, x, y, options.maxWidth); + const { maxWidth } = options; + if (maxWidth === undefined) { + ctx.fillText(value, x, y); + return; + } + const overflow = ctx.measureText(value).width * MIN_SQUEEZE - maxWidth; + if (overflow <= 0.5) { + // Fits as it is, or squeezed a little: `fillText` squeezes to `maxWidth` by itself. + ctx.fillText(value, x, y, maxWidth); + return; + } + // Too long: draw it at the minimum squeeze, clipped to the box, scrolled by the time it has been on screen. + const align = options.align ?? 'left'; + const left = align === 'right' || align === 'end' ? x - maxWidth : align === 'center' ? x - maxWidth / 2 : x; + const offset = options.nowMs === undefined ? 0 : marqueeOffset(marqueeElapsed(value, options.nowMs), overflow); + ctx.save(); + ctx.beginPath(); + ctx.rect(left, y - options.size, maxWidth, options.size * 2); + ctx.clip(); + ctx.translate(left - offset, y); + ctx.scale(MIN_SQUEEZE, 1); + ctx.textAlign = 'left'; + ctx.fillText(value, 0, 0); + ctx.restore(); } -/** A label above a value: the small grey caption and the figure under it. */ -export function stat(ctx: CanvasRenderingContext2D, label: string, value: string, x: number, y: number): void { +/** A label above a value: the small grey caption and the figure under it, the value fitted to `maxWidth`. */ +export function stat( + ctx: CanvasRenderingContext2D, + label: string, + value: string, + x: number, + y: number, + fit?: { maxWidth: number; nowMs: number }, +): void { text(ctx, label, x, y, { size: 9, color: MUTED, weight: 700 }); - text(ctx, value, x, y + 16, { size: 16 }); + text(ctx, value, x, y + 16, { size: 16, maxWidth: fit?.maxWidth, nowMs: fit?.nowMs }); } diff --git a/packages/player-web/src/skins/synesthesia/result.ts b/packages/player-web/src/skins/synesthesia/result.ts index 45253779..60203686 100644 --- a/packages/player-web/src/skins/synesthesia/result.ts +++ b/packages/player-web/src/skins/synesthesia/result.ts @@ -106,8 +106,8 @@ export function renderSynesthesiaResult(frame: PixiResultFrame): void { fontFamily: SYN_DISPLAY_FONT, ...extra, }); - const label = (value: string, x: number, y: number, target: Container, fill: number = SYN_DIM) => - text(target, value, x, y, display(9, fill, { letterSpacing: 2 })); + const label = (value: string, x: number, y: number, target: Container, fill: number = SYN_DIM, maxWidth?: number) => + text(target, value, x, y, display(9, fill, { letterSpacing: 2, maxWidth })); // Space: warm black, dust pouring out of the vanishing point, and a floor of points. const space = group('space'); @@ -331,7 +331,7 @@ export function renderSynesthesiaResult(frame: PixiResultFrame): void { // Genre, title, artist — the order every built-in skin uses. const genreLift = result.song.genre ? 14 : 0; if (result.song.genre) { - label(result.song.genre.toUpperCase(), trackTextX, TOP + 30, track.root); + label(result.song.genre.toUpperCase(), trackTextX, TOP + 30, track.root, SYN_DIM, trackTextW); } text(track.root, result.song.title || 'Untitled chart', trackTextX, TOP + 30 + genreLift, { size: 15, diff --git a/packages/player-web/src/skins/synesthesia/select.ts b/packages/player-web/src/skins/synesthesia/select.ts index bf3da8f3..d0bfcd5b 100644 --- a/packages/player-web/src/skins/synesthesia/select.ts +++ b/packages/player-web/src/skins/synesthesia/select.ts @@ -752,6 +752,7 @@ class SynesthesiaSelectRenderer implements PixiSelectRenderer { anchorY: 0.5, alpha, maxWidth: facts ? ROW_BPM_W : 70, + marquee: active, }); metaNode.label = `fallback-meta[idx=${entryIndex}]`; let factsLeft = rowX + rowW - 14 - (facts ? ROW_BPM_W : meta ? Math.min(70, metaNode.width) : 0) - 12; @@ -801,6 +802,8 @@ class SynesthesiaSelectRenderer implements PixiSelectRenderer { anchorY: 0.5, alpha, maxWidth: Math.max(24, factsLeft - rowX - 34), + // Only the focused row scrolls a long title; the others clip it at its start. + marquee: active, ...(active ? { dropShadow: { color: accent, distance: 0, blur: 8, alpha: 0.9 } } : {}), }); title.label = `fallback-title[idx=${entryIndex}]`; @@ -816,6 +819,7 @@ class SynesthesiaSelectRenderer implements PixiSelectRenderer { anchorY: 0.5, alpha, maxWidth: factsLeft - artistX, + marquee: active, }); } return slide > 0 || intro > 0;