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

View File

@@ -0,0 +1,147 @@
<script lang="ts">
import { slide } from 'svelte/transition';
import { SvelteSet } from 'svelte/reactivity';
import type { MatchLive } from './live.svelte';
import { SIDE_COLORS, formatScore, modColor } from './theme';
import ChevronDown from '@lucide/svelte/icons/chevron-down';
const {
live,
isReferee,
embedded = false
}: { live: MatchLive; isReferee: boolean; embedded?: boolean } = $props();
const view = $derived(live.view);
const open = new SvelteSet<number>();
function name(playerId: string) {
for (const side of view.sides) {
const player = side.players.find((each) => each.id === playerId);
if (player) return player.osuName ?? player.name;
}
return 'Unknown';
}
</script>
{#if view.maps.length}
<section class={embedded ? 'p-2' : 'rounded-2xl border border-white/8 bg-zinc-950/60 p-3 sm:p-4'}>
<h2 class="mb-3 text-sm font-semibold tracking-wide text-zinc-200 uppercase">Maps played</h2>
<ol class="space-y-2">
{#each [...view.maps].reverse() as map (map.index)}
{@const slot = live.slot(map.slotId)}
{@const winner = live.side(map.winner)}
{@const red = map.totals[view.sides[0].id] ?? 0}
{@const blue = map.totals[view.sides[1].id] ?? 0}
{@const share = red + blue > 0 ? red / (red + blue) : 0.5}
<li
class="overflow-hidden rounded-xl border border-l-[3px] border-white/8 bg-white/[0.03]"
style="border-left-color:{slot ? modColor(slot.category) : '#71717a'}"
>
<button
type="button"
class="w-full p-3 text-left transition hover:bg-white/[0.04]"
aria-expanded={open.has(map.index)}
onclick={() => (open.has(map.index) ? open.delete(map.index) : open.add(map.index))}
>
<div class="flex min-w-0 items-center gap-2">
<span class="w-4 shrink-0 text-center text-[11px] text-zinc-500 tabular-nums"
>{map.index + 1}</span
>
<span
class="shrink-0 font-mono text-xs font-black"
style="color:{slot ? modColor(slot.category) : '#fff'}">{slot?.label ?? 'Map'}</span
>
<span class="min-w-0 flex-1 truncate text-sm font-medium"
>{slot?.title ?? `Beatmap #${slot?.beatmapId ?? ''}`}</span
>
<ChevronDown
class="size-4 shrink-0 text-zinc-500 transition {open.has(map.index)
? 'rotate-180'
: ''}"
/>
</div>
<div class="mt-2 flex items-center justify-between gap-2 pl-6 text-xs">
<span
class="min-w-0 truncate font-semibold"
style="color:{winner ? SIDE_COLORS[winner.color].text : '#a1a1aa'}"
>{winner?.name ?? 'Draw'}{winner ? ' won' : ''}</span
>
<span class="shrink-0 font-mono text-[11px] tabular-nums"
><span style="color:{SIDE_COLORS.red.text}"
>{formatScore(red, view.rules.winCondition)}</span
><span class="px-1 text-zinc-600">–</span><span
style="color:{SIDE_COLORS.blue.text}"
>{formatScore(blue, view.rules.winCondition)}</span
></span
>
</div>
<div class="mt-2 ml-6 flex h-1.5 overflow-hidden rounded-full bg-white/5">
<div style="width:{share * 100}%;background:{SIDE_COLORS.red.solid}"></div>
<div class="flex-1" style="background:{SIDE_COLORS.blue.solid}"></div>
</div>
</button>
{#if open.has(map.index)}
<div transition:slide={{ duration: 180 }} class="border-t border-white/5 px-3 py-2">
{#if map.scores.length}
<p class="mb-1 text-[10px] font-semibold tracking-wide text-zinc-500 uppercase">
Player scores
</p>
{#each map.scores as score (score.playerId)}
{@const side = live.side(score.side)}
<div class="flex items-center gap-2 border-t border-white/5 py-1.5 text-xs">
<span
class="size-2 shrink-0 rounded-full"
style="background:{side ? SIDE_COLORS[side.color].solid : '#71717a'}"
></span>
<div class="min-w-0 flex-1">
<a
href="/users/{score.userId}"
class="block truncate font-medium hover:underline">{name(score.playerId)}</a
><span class="text-[10px] text-zinc-500"
>{score.accuracy ? `${score.accuracy.toFixed(2)}%` : '–'} · {score.combo
? `${score.combo}x`
: '–'} · {score.mods.filter((mod) => mod !== 'NF').join('') ||
'NM'}{#if !score.passed}
· <span class="text-red-400">FAIL</span>{/if}</span
>
</div>
<span class="shrink-0 font-mono font-semibold tabular-nums"
>{score.score.toLocaleString('en-US')}</span
>
</div>
{/each}
{:else}
<p class="text-xs text-zinc-500">
No per-player scores were recorded for this map.
</p>
{/if}
{#if isReferee}
<div
class="mt-2 flex flex-wrap items-center gap-1.5 border-t border-white/5 pt-2 text-xs"
>
<span class="w-full text-zinc-500">Correct winner:</span>
{#each view.sides as side (side.id)}
<button
type="button"
class="rounded border border-white/10 px-2 py-0.5 hover:bg-white/10"
style="color:{SIDE_COLORS[side.color].text}"
onclick={() =>
live.send({ type: 'correct', mapIndex: map.index, winner: side.id })}
>{side.name}</button
>
{/each}
<button
type="button"
class="rounded border border-white/10 px-2 py-0.5 text-zinc-400 hover:bg-white/10"
onclick={() =>
live.send({ type: 'correct', mapIndex: map.index, winner: null })}
>Draw</button
>
</div>
{/if}
</div>
{/if}
</li>
{/each}
</ol>
</section>
{/if}