diff --git a/schemas/scoreboard.json b/schemas/scoreboard.json index 03e66ce..dfaccaf 100644 --- a/schemas/scoreboard.json +++ b/schemas/scoreboard.json @@ -35,6 +35,14 @@ "type": "string", "default": "" }, + "leftCharacter": { + "type": "string", + "default": "" + }, + "rightCharacter": { + "type": "string", + "default": "" + }, "leftScore": { "type": "integer", "default": 0, @@ -63,6 +71,8 @@ "rightTeamOverride", "leftCountryOverride", "rightCountryOverride", + "leftCharacter", + "rightCharacter", "leftScore", "rightScore", "round", @@ -77,6 +87,8 @@ "rightTeamOverride": "", "leftCountryOverride": "", "rightCountryOverride": "", + "leftCharacter": "", + "rightCharacter": "", "leftScore": 0, "rightScore": 0, "round": "", diff --git a/src/dashboard/example/components/ScoreboardPanel.vue b/src/dashboard/example/components/ScoreboardPanel.vue index c99bba6..ebf0e56 100644 --- a/src/dashboard/example/components/ScoreboardPanel.vue +++ b/src/dashboard/example/components/ScoreboardPanel.vue @@ -1,6 +1,7 @@ + +
+ Left character preview +
{ />
+ +
+ Right character preview +
{ 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); diff --git a/src/dashboard/example/stores/scoreboard.ts b/src/dashboard/example/stores/scoreboard.ts index 8620322..031f5fd 100644 --- a/src/dashboard/example/stores/scoreboard.ts +++ b/src/dashboard/example/stores/scoreboard.ts @@ -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, }; diff --git a/src/graphics/scoreboard/main.vue b/src/graphics/scoreboard/main.vue index 26cf39d..a17d9f4 100644 --- a/src/graphics/scoreboard/main.vue +++ b/src/graphics/scoreboard/main.vue @@ -16,6 +16,8 @@ const defaultScoreboard: Schemas.Scoreboard = { rightTeamOverride: '', leftCountryOverride: '', rightCountryOverride: '', + leftCharacter: '', + rightCharacter: '', leftScore: 0, rightScore: 0, round: '', diff --git a/src/shared/character-images/README.md b/src/shared/character-images/README.md new file mode 100644 index 0000000..e890076 --- /dev/null +++ b/src/shared/character-images/README.md @@ -0,0 +1,21 @@ +# Character image catalog + +Put custom character images here using this structure: + +```text +src/shared/character-images/ + street-fighter-6/ + ryu.png + ken.png + tekken-8/ + jin.png +``` + +Rules: + +- Folder name = game slug (`toLowerCase`, replace non alphanumeric with `-`). + - Example: `Guilty Gear -Strive-` -> `guilty-gear-strive` +- File name = character slug with the same rule. + - Example: `Chun-Li` -> `chun-li` +- Supported extensions: `.png`, `.jpg`, `.jpeg`, `.webp`, `.avif`, `.svg`. +- If an image is missing, the dashboard shows a generated placeholder preview. diff --git a/src/shared/fighting-characters.ts b/src/shared/fighting-characters.ts new file mode 100644 index 0000000..2eebfdb --- /dev/null +++ b/src/shared/fighting-characters.ts @@ -0,0 +1,97 @@ +export interface FightingCharacterOption { + label: string; + value: string; + image: string; +} + +const characterNamesByGame: Record = { + '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 = { + '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 toSlug = (value: string) => value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, ''); + +const toDataUrl = (svg: string) => `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; + +const buildCharacterPlaceholder = (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 = ` + + + + + + + + + + ${initials} + ${game} + ${character} +`; + + return toDataUrl(svg.trim()); +}; + +const characterImageModules = import.meta.glob('/src/shared/character-images/**/*.{png,jpg,jpeg,webp,avif,svg}', { + eager: true, + import: 'default', + query: '?url', +}) as Record; + +const characterImageByKey = Object.entries(characterImageModules).reduce>((acc, [path, url]) => { + const segments = path.split('/'); + const gameFolder = segments.at(-2); + const filename = segments.at(-1); + if (!gameFolder || !filename) { + return acc; + } + + const characterSlug = filename.replace(/\.[^.]+$/, ''); + acc[`${gameFolder}/${characterSlug}`] = url; + return acc; +}, {}); + +const getCharacterImage = (game: string, character: string, characterValue: string) => { + const gameSlug = toSlug(game); + const key = `${gameSlug}/${characterValue}`; + return characterImageByKey[key] ?? buildCharacterPlaceholder(game, character); +}; + +export const fightingCharactersByGame: Record = Object.fromEntries( + Object.entries(characterNamesByGame).map(([game, characterNames]) => [ + game, + characterNames.map((character) => { + const value = toSlug(character); + return { + label: character, + value, + image: getCharacterImage(game, character, value), + }; + }), + ]), +); + +export const getCharactersByGame = (game: string) => fightingCharactersByGame[game] ?? []; diff --git a/src/types/schemas/scoreboard.d.ts b/src/types/schemas/scoreboard.d.ts index db38b47..7c43f23 100644 --- a/src/types/schemas/scoreboard.d.ts +++ b/src/types/schemas/scoreboard.d.ts @@ -15,6 +15,8 @@ export interface Scoreboard { rightTeamOverride: string; leftCountryOverride: string; rightCountryOverride: string; + leftCharacter: string; + rightCharacter: string; leftScore: number; rightScore: number; round: string;