Add per-game character selection to scoreboard panel

This commit is contained in:
Pandipipas
2026-02-12 01:54:32 +01:00
parent ea92c08d7b
commit f38d15c681
6 changed files with 216 additions and 0 deletions
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, ref, watch, watchEffect, type Ref } from 'vue';
import { countryOptions, getCountryLabel } from '../../../shared/countries';
import { getCharactersByGame } from '../../../shared/fighting-characters';
import type { Schemas } from '../../../types';
import { usePlayersStore } from '../stores/players';
import { useScoreboardStore } from '../stores/scoreboard';
@@ -23,6 +24,9 @@ const rightCountryInput = ref('');
const leftCountryOptions = ref(countryOptions);
const rightCountryOptions = ref(countryOptions);
const leftCharacterInput = ref('');
const rightCharacterInput = ref('');
const fightingGameOptions = [
'Street Fighter 6',
'TEKKEN 8',
@@ -36,6 +40,19 @@ const fightingGameOptions = [
value: game,
}));
const characterOptions = computed(() => getCharactersByGame(scoreboardStore.scoreboard.game));
const leftCharacterImage = computed(() => {
const match = characterOptions.value.find((option) => option.value === scoreboardStore.scoreboard.leftCharacter);
return match?.image ?? '';
});
const rightCharacterImage = computed(() => {
const match = characterOptions.value.find((option) => option.value === scoreboardStore.scoreboard.rightCharacter);
return match?.image ?? '';
});
const normalizeName = (value: string) => value.trim().toLowerCase();
const filterOptions = (
@@ -566,6 +583,43 @@ watchEffect(() => {
}
});
watch(
() => scoreboardStore.scoreboard.game,
() => {
const options = getCharactersByGame(scoreboardStore.scoreboard.game);
const allowed = new Set(options.map((option) => option.value));
if (!allowed.has(scoreboardStore.scoreboard.leftCharacter)) {
scoreboardStore.scoreboard.leftCharacter = '';
leftCharacterInput.value = '';
}
if (!allowed.has(scoreboardStore.scoreboard.rightCharacter)) {
scoreboardStore.scoreboard.rightCharacter = '';
rightCharacterInput.value = '';
}
},
{ immediate: true },
);
watch(
() => scoreboardStore.scoreboard.leftCharacter,
(value) => {
const match = characterOptions.value.find((option) => option.value === value);
leftCharacterInput.value = match?.label ?? '';
},
{ immediate: true },
);
watch(
() => scoreboardStore.scoreboard.rightCharacter,
(value) => {
const match = characterOptions.value.find((option) => option.value === value);
rightCharacterInput.value = match?.label ?? '';
},
{ immediate: true },
);
</script>
<template>
@@ -619,6 +673,34 @@ watchEffect(() => {
/>
</template>
</QSelect>
<QSelect
v-model="scoreboardStore.scoreboard.leftCharacter"
v-model:input-value="leftCharacterInput"
:options="characterOptions"
option-value="value"
option-label="label"
emit-value
map-options
label="Character"
dense
outlined
use-input
input-debounce="0"
hide-selected
fill-input
clearable
class="q-mt-sm"
:disable="!scoreboardStore.scoreboard.game"
/>
<div
v-if="leftCharacterImage"
class="character-preview q-mt-sm"
>
<img
:src="leftCharacterImage"
alt="Left character preview"
>
</div>
<QInput
v-model="scoreboardStore.scoreboard.leftTeamOverride"
label="Team"
@@ -762,6 +844,34 @@ watchEffect(() => {
/>
</template>
</QSelect>
<QSelect
v-model="scoreboardStore.scoreboard.rightCharacter"
v-model:input-value="rightCharacterInput"
:options="characterOptions"
option-value="value"
option-label="label"
emit-value
map-options
label="Character"
dense
outlined
use-input
input-debounce="0"
hide-selected
fill-input
clearable
class="q-mt-sm"
:disable="!scoreboardStore.scoreboard.game"
/>
<div
v-if="rightCharacterImage"
class="character-preview q-mt-sm"
>
<img
:src="rightCharacterImage"
alt="Right character preview"
>
</div>
<QInput
v-model="scoreboardStore.scoreboard.rightTeamOverride"
label="Team"
@@ -847,6 +957,21 @@ watchEffect(() => {
min-width: 0;
}
.character-preview {
width: 100%;
border-radius: 8px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(0, 0, 0, 0.25);
}
.character-preview img {
display: block;
width: 100%;
height: 88px;
object-fit: cover;
}
@media (min-width: 1024px) {
.scoreboard-grid {
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
@@ -16,6 +16,8 @@ const defaultScoreboard: Scoreboard = {
rightTeamOverride: '',
leftCountryOverride: '',
rightCountryOverride: '',
leftCharacter: '',
rightCharacter: '',
leftScore: 0,
rightScore: 0,
round: '',
@@ -33,6 +35,8 @@ const normalizeScoreboard = (input: unknown): Scoreboard => {
rightTeamOverride: typeof candidate.rightTeamOverride === 'string' ? candidate.rightTeamOverride : '',
leftCountryOverride: typeof candidate.leftCountryOverride === 'string' ? candidate.leftCountryOverride : '',
rightCountryOverride: typeof candidate.rightCountryOverride === 'string' ? candidate.rightCountryOverride : '',
leftCharacter: typeof candidate.leftCharacter === 'string' ? candidate.leftCharacter : '',
rightCharacter: typeof candidate.rightCharacter === 'string' ? candidate.rightCharacter : '',
leftScore: typeof candidate.leftScore === 'number' ? Math.max(0, Math.floor(candidate.leftScore)) : 0,
rightScore: typeof candidate.rightScore === 'number' ? Math.max(0, Math.floor(candidate.rightScore)) : 0,
round: typeof candidate.round === 'string' ? candidate.round : '',
@@ -119,6 +123,8 @@ export const useScoreboardStore = defineStore('scoreboard', () => {
rightTeamOverride: scoreboard.value.leftTeamOverride,
leftCountryOverride: scoreboard.value.rightCountryOverride,
rightCountryOverride: scoreboard.value.leftCountryOverride,
leftCharacter: scoreboard.value.rightCharacter,
rightCharacter: scoreboard.value.leftCharacter,
leftScore: scoreboard.value.rightScore,
rightScore: scoreboard.value.leftScore,
};
+2
View File
@@ -16,6 +16,8 @@ const defaultScoreboard: Schemas.Scoreboard = {
rightTeamOverride: '',
leftCountryOverride: '',
rightCountryOverride: '',
leftCharacter: '',
rightCharacter: '',
leftScore: 0,
rightScore: 0,
round: '',
+69
View File
@@ -0,0 +1,69 @@
export interface FightingCharacterOption {
label: string;
value: string;
image: string;
}
const characterNamesByGame: Record<string, string[]> = {
'Street Fighter 6': ['Ryu', 'Ken', 'Chun-Li', 'Luke', 'Juri', 'Cammy'],
'TEKKEN 8': ['Jin', 'Kazuya', 'Nina', 'King', 'Asuka', 'Reina'],
'Guilty Gear -Strive-': ['Sol Badguy', 'Ky Kiske', 'May', 'Zato-1', 'I-No', 'Baiken'],
'Mortal Kombat 1': ['Scorpion', 'Sub-Zero', 'Raiden', 'Liu Kang', 'Kitana', 'Mileena'],
'The King of Fighters XV': ['Kyo Kusanagi', 'Iori Yagami', 'Terry Bogard', 'Mai Shiranui', 'Leona', 'Athena'],
'Granblue Fantasy Versus: Rising': ['Gran', 'Djeeta', 'Lancelot', 'Narmaya', 'Vira', 'Belial'],
'2XKO': ['Ahri', 'Darius', 'Ekko', 'Yasuo', 'Illaoi', 'Jinx'],
};
const paletteByGame: Record<string, [string, string]> = {
'Street Fighter 6': ['#f97316', '#b91c1c'],
'TEKKEN 8': ['#2563eb', '#111827'],
'Guilty Gear -Strive-': ['#facc15', '#9333ea'],
'Mortal Kombat 1': ['#84cc16', '#1f2937'],
'The King of Fighters XV': ['#38bdf8', '#1d4ed8'],
'Granblue Fantasy Versus: Rising': ['#60a5fa', '#7c3aed'],
'2XKO': ['#22d3ee', '#0f766e'],
};
const toCharacterValue = (character: string) => character.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
const toDataUrl = (svg: string) => `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
const buildCharacterImage = (game: string, character: string) => {
const [startColor, endColor] = paletteByGame[game] ?? ['#334155', '#0f172a'];
const initials = character
.split(/\s+/)
.map((part) => part[0])
.join('')
.slice(0, 2)
.toUpperCase();
const svg = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 220" role="img" aria-label="${character}">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="${startColor}"/>
<stop offset="100%" stop-color="${endColor}"/>
</linearGradient>
</defs>
<rect width="480" height="220" fill="url(#bg)" rx="18"/>
<circle cx="90" cy="110" r="64" fill="rgba(255,255,255,0.13)"/>
<text x="90" y="130" text-anchor="middle" fill="#ffffff" font-family="Arial, sans-serif" font-size="56" font-weight="700">${initials}</text>
<text x="170" y="96" fill="#e2e8f0" font-family="Arial, sans-serif" font-size="20" font-weight="700">${game}</text>
<text x="170" y="145" fill="#ffffff" font-family="Arial, sans-serif" font-size="38" font-weight="700">${character}</text>
</svg>`;
return toDataUrl(svg.trim());
};
export const fightingCharactersByGame: Record<string, FightingCharacterOption[]> = Object.fromEntries(
Object.entries(characterNamesByGame).map(([game, characterNames]) => [
game,
characterNames.map((character) => ({
label: character,
value: toCharacterValue(character),
image: buildCharacterImage(game, character),
})),
]),
);
export const getCharactersByGame = (game: string) => fightingCharactersByGame[game] ?? [];
+2
View File
@@ -15,6 +15,8 @@ export interface Scoreboard {
rightTeamOverride: string;
leftCountryOverride: string;
rightCountryOverride: string;
leftCharacter: string;
rightCharacter: string;
leftScore: number;
rightScore: number;
round: string;