472 lines
13 KiB
TypeScript
472 lines
13 KiB
TypeScript
/**
|
||
* 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
|
||
}
|
||
};
|