feat: redesign match play and add broadcast tooling

This commit is contained in:
2026-09-29 02:52:56 +00:00
parent f417a831f7
commit 9b9aafefc1
348 changed files with 31588 additions and 11094 deletions

471
src/lib/broadcast/layout.ts Normal file
View File

@@ -0,0 +1,471 @@
/**
* Stream layouts: which predefined components sit where, per scene.
*
* A layout is plain JSON, stored on the broadcast channel and edited in the
* layout editor. Coordinates are pixels on a 1920×1080 stage; the overlay
* scales the stage to whatever size the browser source is.
*/
import type { MatchView, Phase } from '$lib/match-view';
export const STAGE = { width: 1920, height: 1080 } as const;
export type SceneId = 'idle' | 'lobby' | 'pickban' | 'gameplay' | 'results';
export const SCENES: { id: SceneId; label: string; description: string }[] = [
{
id: 'idle',
label: 'Starting soon',
description: 'No match to show: schedule, other live matches.'
},
{ id: 'lobby', label: 'Lobby', description: 'Players join and roll.' },
{ id: 'pickban', label: 'Pick & ban', description: 'Choosing, banning, picking.' },
{ id: 'gameplay', label: 'Gameplay', description: 'A map is being played.' },
{ id: 'results', label: 'Results', description: 'The match is decided.' }
];
const SCENE_BY_PHASE: Record<Phase, SceneId> = {
created: 'lobby',
waiting: 'lobby',
rolling: 'lobby',
choosing: 'pickban',
banning: 'pickban',
picking: 'pickban',
tb: 'pickban',
playing: 'gameplay',
closing: 'results',
finished: 'results',
cancelled: 'idle'
};
export function stageOf(layout: Pick<Layout, 'stage'>): { width: number; height: number } {
return layout.stage ?? STAGE;
}
export function sceneFor(view: MatchView | null): SceneId {
return view ? SCENE_BY_PHASE[view.phase] : 'idle';
}
export type ComponentType =
| 'scoreboard'
| 'livescore'
| 'nowplaying'
| 'mappool'
| 'turn'
| 'history'
| 'chat'
| 'predictions'
| 'players'
| 'winner'
| 'gameplay'
| 'ticker'
| 'schedule'
| 'text'
| 'image'
| 'clock';
export interface PlacedComponent {
id: string;
type: ComponentType;
x: number;
y: number;
w: number;
h: number;
props: Record<string, string | number | boolean>;
}
export interface SceneLayout {
/** What is behind the components: nothing (OBS shows what is under the browser source), the theme background, or an image. */
background: 'transparent' | 'solid' | 'image';
image?: string;
components: PlacedComponent[];
}
export interface Layout {
version: 1;
/** Stage size; 1920×1080 unless set (portrait channels use 1080×1920). */
stage?: { width: number; height: number };
theme: {
/** Accent color; empty uses the tournament's accent. */
accent: string;
background: string;
/** Corner radius of panels, px. */
radius: number;
};
scenes: Record<SceneId, SceneLayout>;
}
export interface PropSpec {
key: string;
label: string;
kind: 'text' | 'number' | 'boolean' | 'select' | 'url';
options?: string[];
default: string | number | boolean;
}
export const COMPONENTS: Record<
ComponentType,
{ label: string; description: string; size: [number, number]; props: PropSpec[] }
> = {
scoreboard: {
label: 'Scoreboard',
description: 'Team names, avatars and maps won.',
size: [1920, 130],
props: [
{ key: 'showRatings', label: 'Show ratings', kind: 'boolean', default: false },
{ key: 'showRound', label: 'Show round name', kind: 'boolean', default: true }
]
},
livescore: {
label: 'Live score',
description: 'Both sides’ scores on the current map, with the lead.',
size: [1920, 90],
props: [{ key: 'showPlayers', label: 'Show each player', kind: 'boolean', default: true }]
},
nowplaying: {
label: 'Now playing',
description: 'The map being played or just picked.',
size: [620, 150],
props: [
{ key: 'showStats', label: 'Show stars, BPM and length', kind: 'boolean', default: true }
]
},
mappool: {
label: 'Mappool',
description: 'Every map with its pick, ban and winner.',
size: [1800, 560],
props: [{ key: 'columns', label: 'Columns', kind: 'number', default: 5 }]
},
turn: {
label: 'Turn',
description: 'Who acts now, and the countdown.',
size: [900, 120],
props: []
},
history: {
label: 'Map history',
description: 'Maps played so far and who won them.',
size: [480, 520],
props: []
},
chat: {
label: 'Lobby chat',
description: 'The latest lines from the osu! lobby.',
size: [480, 420],
props: [
{ key: 'lines', label: 'Lines', kind: 'number', default: 10 },
{ key: 'showBancho', label: 'Show BanchoBot', kind: 'boolean', default: false }
]
},
predictions: {
label: 'Predictions',
description: 'How the crowd called it, and the rating odds.',
size: [620, 140],
props: [{ key: 'showOdds', label: 'Show rating odds', kind: 'boolean', default: true }]
},
players: {
label: 'Players',
description: 'Each side’s players with flags and ratings.',
size: [1600, 420],
props: []
},
winner: {
label: 'Winner',
description: 'The winning side, big.',
size: [1200, 420],
props: [{ key: 'headline', label: 'Headline', kind: 'text', default: 'WINNER' }]
},
gameplay: {
label: 'Gameplay window',
description: 'A see-through frame for the tourney client capture under the overlay.',
size: [1920, 860],
props: [
{ key: 'label', label: 'Label when empty', kind: 'text', default: '' },
{ key: 'border', label: 'Border', kind: 'boolean', default: false }
]
},
ticker: {
label: 'Live ticker',
description: 'Scores of every other live match, scrolling.',
size: [1920, 48],
props: [{ key: 'label', label: 'Label', kind: 'text', default: 'AROUND VASH' }]
},
schedule: {
label: 'Schedule',
description: 'Matches coming up.',
size: [720, 440],
props: [{ key: 'title', label: 'Title', kind: 'text', default: 'Up next' }]
},
text: {
label: 'Text',
description: 'Any text: a title, a sponsor line, a hashtag.',
size: [800, 120],
props: [
{ key: 'text', label: 'Text', kind: 'text', default: 'Vash Esports' },
{ key: 'size', label: 'Size (px)', kind: 'number', default: 64 },
{
key: 'align',
label: 'Align',
kind: 'select',
options: ['left', 'center', 'right'],
default: 'center'
},
{ key: 'accent', label: 'Accent color', kind: 'boolean', default: false }
]
},
image: {
label: 'Image',
description: 'A logo, banner or sponsor image.',
size: [300, 300],
props: [
{ key: 'src', label: 'Image URL', kind: 'url', default: '/logo.png' },
{
key: 'fit',
label: 'Fit',
kind: 'select',
options: ['contain', 'cover'],
default: 'contain'
}
]
},
clock: {
label: 'Clock',
description: 'The time, or a countdown to the next match.',
size: [360, 120],
props: [
{
key: 'mode',
label: 'Show',
kind: 'select',
options: ['countdown', 'time'],
default: 'countdown'
}
]
}
};
export function defaultProps(type: ComponentType): PlacedComponent['props'] {
return Object.fromEntries(COMPONENTS[type].props.map((prop) => [prop.key, prop.default]));
}
export function place(
type: ComponentType,
x: number,
y: number,
w?: number,
h?: number,
props: PlacedComponent['props'] = {}
): PlacedComponent {
const [dw, dh] = COMPONENTS[type].size;
return {
id: `${type}-${Math.random().toString(36).slice(2, 8)}`,
type,
x,
y,
w: w ?? dw,
h: h ?? dh,
props: { ...defaultProps(type), ...props }
};
}
// ── Presets ─────────────────────────────────────────────────────────────
const theme: Layout['theme'] = { accent: '', background: '#09090b', radius: 18 };
/** Full broadcast around a tourney client capture: the gameplay scene leaves a see-through window. */
function tourney(): Layout {
return {
version: 1,
theme,
scenes: {
idle: {
background: 'solid',
components: [
place('text', 160, 140, 1600, 140, { text: 'Starting soon', size: 110, align: 'left' }),
place('clock', 160, 300, 520, 140),
place('schedule', 160, 480, 820, 480),
place('image', 1360, 440, 400, 400),
place('ticker', 0, 1032)
]
},
lobby: {
background: 'solid',
components: [
place('scoreboard', 0, 0),
place('players', 160, 220, 1600, 520),
place('predictions', 650, 800),
place('ticker', 0, 1032)
]
},
pickban: {
background: 'solid',
components: [
place('scoreboard', 0, 0),
place('turn', 510, 150),
place('mappool', 60, 300, 1800, 560),
place('chat', 60, 880, 900, 140, { lines: 4 }),
place('predictions', 1240, 880)
]
},
gameplay: {
background: 'transparent',
components: [
place('scoreboard', 0, 0),
place('gameplay', 0, 130, 1920, 800),
place('livescore', 0, 930),
place('nowplaying', 0, 1020, 1920, 60)
]
},
results: {
background: 'solid',
components: [
place('winner', 360, 140),
place('history', 360, 600, 1200, 400),
place('ticker', 0, 1032)
]
}
}
};
}
/** No capture at all: everything from Vash's own data. What Vash TV streams. */
function vashTv(): Layout {
const layout = tourney();
layout.scenes.gameplay = {
background: 'solid',
components: [
place('scoreboard', 0, 0),
place('nowplaying', 160, 200, 1000, 220),
place('predictions', 1220, 200, 540, 220),
place('livescore', 0, 480, 1920, 200),
place('history', 160, 720, 800, 290),
place('chat', 1000, 720, 760, 290, { lines: 6 }),
place('ticker', 0, 1032)
]
};
return layout;
}
/** Just a slim scoreboard and live score over the capture; nothing else. */
function minimal(): Layout {
const empty = (): SceneLayout => ({ background: 'transparent', components: [] });
return {
version: 1,
theme,
scenes: {
idle: empty(),
lobby: { background: 'transparent', components: [place('scoreboard', 0, 0, 1920, 110)] },
pickban: {
background: 'transparent',
components: [place('scoreboard', 0, 0, 1920, 110), place('turn', 510, 950)]
},
gameplay: {
background: 'transparent',
components: [
place('scoreboard', 0, 0, 1920, 110),
place('livescore', 0, 1000, 1920, 80, { showPlayers: false })
]
},
results: { background: 'transparent', components: [place('winner', 360, 330)] }
}
};
}
/** Upright, for phones: TikTok, Instagram, YouTube Shorts. Everything stacked. */
function vertical(): Layout {
const W = 1080;
const scenes: Layout['scenes'] = {
idle: {
background: 'solid',
components: [
place('text', 80, 220, 920, 200, { text: 'Starting soon', size: 120, align: 'left' }),
place('clock', 80, 440, 920, 160),
place('schedule', 60, 660, 960, 700),
place('image', 340, 1420, 400, 400),
place('ticker', 0, 1872, W, 48)
]
},
lobby: {
background: 'solid',
components: [
place('scoreboard', 0, 0, W, 130),
place('players', 40, 200, 1000, 900),
place('predictions', 40, 1160, 1000, 180),
place('ticker', 0, 1872, W, 48)
]
},
pickban: {
background: 'solid',
components: [
place('scoreboard', 0, 0, W, 130),
place('turn', 40, 160, 1000, 120),
place('mappool', 40, 320, 1000, 1100, { columns: 3 }),
place('predictions', 40, 1450, 1000, 170),
place('chat', 40, 1640, 1000, 220, { lines: 5 })
]
},
gameplay: {
background: 'solid',
components: [
place('scoreboard', 0, 0, W, 130),
place('nowplaying', 40, 170, 1000, 220),
place('livescore', 0, 430, W, 240),
place('predictions', 40, 700, 1000, 180),
place('history', 40, 910, 1000, 520),
place('chat', 40, 1460, 1000, 380, { lines: 8 }),
place('ticker', 0, 1872, W, 48)
]
},
results: {
background: 'solid',
components: [
place('winner', 40, 280, 1000, 700),
place('history', 40, 1040, 1000, 760),
place('ticker', 0, 1872, W, 48)
]
}
};
return { version: 1, stage: { width: 1080, height: 1920 }, theme, scenes };
}
export const PRESETS: Record<
string,
{
label: string;
description: string;
when: string;
format: 'landscape' | 'portrait';
make: () => Layout;
}
> = {
tourney: {
label: 'Over osu! gameplay',
description:
'A full overlay with a see-through window in the middle for the players’ osu! screens.',
when: 'You stream from OBS with the osu! tourney client captured underneath.',
format: 'landscape',
make: tourney
},
vashtv: {
label: 'Vash only',
description:
'Everything comes from Vash: scoreboard, live scores, map pool, predictions, chat. No osu! needed.',
when: 'The automatic stream (Vash TV container), or any stream without an osu! capture.',
format: 'landscape',
make: vashTv
},
minimal: {
label: 'Scoreboard only',
description: 'A slim scoreboard and live score bar, nothing else.',
when: 'You already have your own OBS scenes and just want the score on top.',
format: 'landscape',
make: minimal
},
vertical: {
label: 'Phone (portrait)',
description:
'Everything stacked in a 9:16 frame: scoreboard, live score, pool, predictions, chat.',
when: 'TikTok, Instagram or YouTube Shorts. Makes a portrait channel.',
format: 'portrait',
make: vertical
}
};