Files
esports/src/lib/broadcast/layout.ts

472 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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
}
};