mirror of
https://github.com/Pandipipas/scoreko-dev.git
synced 2026-06-06 03:32:06 +00:00
Merge pull request #46 from Pandipipas/add-character-selection-to-scoreboard-panel
Codex-generated pull request
This commit is contained in:
@@ -35,6 +35,14 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"default": ""
|
"default": ""
|
||||||
},
|
},
|
||||||
|
"leftCharacter": {
|
||||||
|
"type": "string",
|
||||||
|
"default": ""
|
||||||
|
},
|
||||||
|
"rightCharacter": {
|
||||||
|
"type": "string",
|
||||||
|
"default": ""
|
||||||
|
},
|
||||||
"leftScore": {
|
"leftScore": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"default": 0,
|
"default": 0,
|
||||||
@@ -63,6 +71,8 @@
|
|||||||
"rightTeamOverride",
|
"rightTeamOverride",
|
||||||
"leftCountryOverride",
|
"leftCountryOverride",
|
||||||
"rightCountryOverride",
|
"rightCountryOverride",
|
||||||
|
"leftCharacter",
|
||||||
|
"rightCharacter",
|
||||||
"leftScore",
|
"leftScore",
|
||||||
"rightScore",
|
"rightScore",
|
||||||
"round",
|
"round",
|
||||||
@@ -77,6 +87,8 @@
|
|||||||
"rightTeamOverride": "",
|
"rightTeamOverride": "",
|
||||||
"leftCountryOverride": "",
|
"leftCountryOverride": "",
|
||||||
"rightCountryOverride": "",
|
"rightCountryOverride": "",
|
||||||
|
"leftCharacter": "",
|
||||||
|
"rightCharacter": "",
|
||||||
"leftScore": 0,
|
"leftScore": 0,
|
||||||
"rightScore": 0,
|
"rightScore": 0,
|
||||||
"round": "",
|
"round": "",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch, watchEffect, type Ref } from 'vue';
|
import { computed, ref, watch, watchEffect, type Ref } from 'vue';
|
||||||
import { countryOptions, getCountryLabel } from '../../../shared/countries';
|
import { countryOptions, getCountryLabel } from '../../../shared/countries';
|
||||||
|
import { getCharactersByGame } from '../../../shared/fighting-characters';
|
||||||
import type { Schemas } from '../../../types';
|
import type { Schemas } from '../../../types';
|
||||||
import { usePlayersStore } from '../stores/players';
|
import { usePlayersStore } from '../stores/players';
|
||||||
import { useScoreboardStore } from '../stores/scoreboard';
|
import { useScoreboardStore } from '../stores/scoreboard';
|
||||||
@@ -23,6 +24,9 @@ const rightCountryInput = ref('');
|
|||||||
const leftCountryOptions = ref(countryOptions);
|
const leftCountryOptions = ref(countryOptions);
|
||||||
const rightCountryOptions = ref(countryOptions);
|
const rightCountryOptions = ref(countryOptions);
|
||||||
|
|
||||||
|
const leftCharacterInput = ref('');
|
||||||
|
const rightCharacterInput = ref('');
|
||||||
|
|
||||||
const fightingGameOptions = [
|
const fightingGameOptions = [
|
||||||
'Street Fighter 6',
|
'Street Fighter 6',
|
||||||
'TEKKEN 8',
|
'TEKKEN 8',
|
||||||
@@ -36,6 +40,19 @@ const fightingGameOptions = [
|
|||||||
value: game,
|
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 normalizeName = (value: string) => value.trim().toLowerCase();
|
||||||
|
|
||||||
const filterOptions = (
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -619,6 +673,34 @@ watchEffect(() => {
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</QSelect>
|
</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
|
<QInput
|
||||||
v-model="scoreboardStore.scoreboard.leftTeamOverride"
|
v-model="scoreboardStore.scoreboard.leftTeamOverride"
|
||||||
label="Team"
|
label="Team"
|
||||||
@@ -762,6 +844,34 @@ watchEffect(() => {
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</QSelect>
|
</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
|
<QInput
|
||||||
v-model="scoreboardStore.scoreboard.rightTeamOverride"
|
v-model="scoreboardStore.scoreboard.rightTeamOverride"
|
||||||
label="Team"
|
label="Team"
|
||||||
@@ -847,6 +957,21 @@ watchEffect(() => {
|
|||||||
min-width: 0;
|
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) {
|
@media (min-width: 1024px) {
|
||||||
.scoreboard-grid {
|
.scoreboard-grid {
|
||||||
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ const defaultScoreboard: Scoreboard = {
|
|||||||
rightTeamOverride: '',
|
rightTeamOverride: '',
|
||||||
leftCountryOverride: '',
|
leftCountryOverride: '',
|
||||||
rightCountryOverride: '',
|
rightCountryOverride: '',
|
||||||
|
leftCharacter: '',
|
||||||
|
rightCharacter: '',
|
||||||
leftScore: 0,
|
leftScore: 0,
|
||||||
rightScore: 0,
|
rightScore: 0,
|
||||||
round: '',
|
round: '',
|
||||||
@@ -33,6 +35,8 @@ const normalizeScoreboard = (input: unknown): Scoreboard => {
|
|||||||
rightTeamOverride: typeof candidate.rightTeamOverride === 'string' ? candidate.rightTeamOverride : '',
|
rightTeamOverride: typeof candidate.rightTeamOverride === 'string' ? candidate.rightTeamOverride : '',
|
||||||
leftCountryOverride: typeof candidate.leftCountryOverride === 'string' ? candidate.leftCountryOverride : '',
|
leftCountryOverride: typeof candidate.leftCountryOverride === 'string' ? candidate.leftCountryOverride : '',
|
||||||
rightCountryOverride: typeof candidate.rightCountryOverride === 'string' ? candidate.rightCountryOverride : '',
|
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,
|
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,
|
rightScore: typeof candidate.rightScore === 'number' ? Math.max(0, Math.floor(candidate.rightScore)) : 0,
|
||||||
round: typeof candidate.round === 'string' ? candidate.round : '',
|
round: typeof candidate.round === 'string' ? candidate.round : '',
|
||||||
@@ -119,6 +123,8 @@ export const useScoreboardStore = defineStore('scoreboard', () => {
|
|||||||
rightTeamOverride: scoreboard.value.leftTeamOverride,
|
rightTeamOverride: scoreboard.value.leftTeamOverride,
|
||||||
leftCountryOverride: scoreboard.value.rightCountryOverride,
|
leftCountryOverride: scoreboard.value.rightCountryOverride,
|
||||||
rightCountryOverride: scoreboard.value.leftCountryOverride,
|
rightCountryOverride: scoreboard.value.leftCountryOverride,
|
||||||
|
leftCharacter: scoreboard.value.rightCharacter,
|
||||||
|
rightCharacter: scoreboard.value.leftCharacter,
|
||||||
leftScore: scoreboard.value.rightScore,
|
leftScore: scoreboard.value.rightScore,
|
||||||
rightScore: scoreboard.value.leftScore,
|
rightScore: scoreboard.value.leftScore,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ const defaultScoreboard: Schemas.Scoreboard = {
|
|||||||
rightTeamOverride: '',
|
rightTeamOverride: '',
|
||||||
leftCountryOverride: '',
|
leftCountryOverride: '',
|
||||||
rightCountryOverride: '',
|
rightCountryOverride: '',
|
||||||
|
leftCharacter: '',
|
||||||
|
rightCharacter: '',
|
||||||
leftScore: 0,
|
leftScore: 0,
|
||||||
rightScore: 0,
|
rightScore: 0,
|
||||||
round: '',
|
round: '',
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
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 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 = `
|
||||||
|
<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());
|
||||||
|
};
|
||||||
|
|
||||||
|
const characterImageModules = import.meta.glob('/src/shared/character-images/**/*.{png,jpg,jpeg,webp,avif,svg}', {
|
||||||
|
eager: true,
|
||||||
|
import: 'default',
|
||||||
|
query: '?url',
|
||||||
|
}) as Record<string, string>;
|
||||||
|
|
||||||
|
const characterImageByKey = Object.entries(characterImageModules).reduce<Record<string, string>>((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<string, FightingCharacterOption[]> = 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] ?? [];
|
||||||
Vendored
+2
@@ -15,6 +15,8 @@ export interface Scoreboard {
|
|||||||
rightTeamOverride: string;
|
rightTeamOverride: string;
|
||||||
leftCountryOverride: string;
|
leftCountryOverride: string;
|
||||||
rightCountryOverride: string;
|
rightCountryOverride: string;
|
||||||
|
leftCharacter: string;
|
||||||
|
rightCharacter: string;
|
||||||
leftScore: number;
|
leftScore: number;
|
||||||
rightScore: number;
|
rightScore: number;
|
||||||
round: string;
|
round: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user