feat: add character game management and player side functionality

- Implemented `useCharacterGame` composable to manage game selection and character state for both players.
- Added `useCountryFilter` composable for filtering country options based on locale.
- Created `usePlayerSide` composable to encapsulate state and handlers for player management on each side of the scoreboard.
- Introduced filtering and input synchronization for player names and countries.
- Enhanced player ID generation to avoid collisions and support custom player entries.
This commit is contained in:
2026-05-14 14:19:44 +02:00
parent 21d885f6e6
commit 4da00508d3
6 changed files with 1297 additions and 1288 deletions
@@ -0,0 +1,503 @@
<script setup lang="ts">
import { computed, inject } from 'vue';
import { useScoreboardStore } from '../stores/scoreboard';
import { usePlayerSide } from '../composables/usePlayerSide';
import { CHARACTER_GAME_KEY } from '../composables/useCharacterGame';
import { t } from '../i18n';
// ---------------------------------------------------------------------------
// Props
// ---------------------------------------------------------------------------
const props = defineProps<{
side: 'left' | 'right';
}>();
// ---------------------------------------------------------------------------
// Store & composables
// ---------------------------------------------------------------------------
const scoreboardStore = useScoreboardStore();
const player = usePlayerSide(props.side);
const {
leftCharacterOptions,
rightCharacterOptions,
leftCharacterInput,
rightCharacterInput,
leftCharacterImage,
rightCharacterImage,
onLeftCharacterFilter,
onRightCharacterFilter,
} = inject(CHARACTER_GAME_KEY)!;
// ---------------------------------------------------------------------------
// Side-specific derivations from the injected character game state
// ---------------------------------------------------------------------------
const isLeft = computed(() => props.side === 'left');
/**
* Character options for this side. Refs from the shared composable are
* accessed directly so Vue's reactivity tracks them correctly.
*/
const characterOptions = computed(() =>
isLeft.value ? leftCharacterOptions.value : rightCharacterOptions.value,
);
/**
* Two-way binding for QSelect's input-value (the display text).
* The setter writes back into the shared composable's ref so that watchers
* in useCharacterGame can update the cache correctly.
*/
const characterInputValue = computed({
get: () => (isLeft.value ? leftCharacterInput.value : rightCharacterInput.value),
set: (v) => {
if (isLeft.value) leftCharacterInput.value = v;
else rightCharacterInput.value = v;
},
});
const panelImage = computed(() =>
isLeft.value ? leftCharacterImage.value : rightCharacterImage.value,
);
const onCharacterFilter = computed(() =>
isLeft.value ? onLeftCharacterFilter : onRightCharacterFilter,
);
/**
* Two-way binding for the character value stored in the scoreboard.
*/
const character = computed({
get: () => (isLeft.value
? scoreboardStore.scoreboard.leftCharacter
: scoreboardStore.scoreboard.rightCharacter),
set: (v) => {
if (isLeft.value) scoreboardStore.scoreboard.leftCharacter = v;
else scoreboardStore.scoreboard.rightCharacter = v;
},
});
// ---------------------------------------------------------------------------
// i18n helpers resolved at runtime so the side label is correct
// ---------------------------------------------------------------------------
const sideLabel = computed(() => t(isLeft.value ? 'scoreboardLeft' : 'scoreboardRight'));
const sideImageLabel = computed(() => t(isLeft.value ? 'scoreboardLeftImage' : 'scoreboardRightImage'));
</script>
<template>
<!--
Left layout: [image-column | controls]
Right layout: [controls | image-column]
The DOM order differs between sides intentionally so that the character
image always appears on the outer edge and the controls face the center.
CSS column widths are flipped via .scoreboard-preview__side--right.
-->
<div
class="scoreboard-preview__side"
:class="{ 'scoreboard-preview__side--right': !isLeft }"
>
<div class="scoreboard-preview__side-inner">
<!-- LEFT: image first, then controls -->
<template v-if="isLeft">
<!-- Character image + character selector -->
<div class="scoreboard-preview__image-column">
<div class="scoreboard-preview__image-wrap">
<img
v-if="panelImage"
:src="panelImage"
:alt="`${player.displayName.value || sideLabel} ${t('scoreboardPreview')}`"
class="scoreboard-preview__image"
>
<div
v-else
class="scoreboard-preview__empty"
>
{{ sideImageLabel }}
</div>
</div>
<QSelect
v-model="character"
v-model:input-value="characterInputValue"
:options="characterOptions"
option-value="value"
option-label="label"
emit-value
map-options
:label="t('scoreboardLabelCharacter')"
dense
use-input
input-debounce="0"
hide-selected
fill-input
clearable
class="scoreboard-preview__field scoreboard-preview__character-field"
:disable="!scoreboardStore.scoreboard.game"
@filter="onCharacterFilter"
>
<template #prepend>
<QIcon name="sports_martial_arts" />
</template>
</QSelect>
</div>
<!-- Player / team / country controls -->
<div class="scoreboard-preview__controls">
<QSelect
v-model="player.playerId.value"
v-model:input-value="player.inputValue.value"
:options="player.playerOptions.value"
:label="t('scoreboardLabelPlayer')"
dense
emit-value
map-options
use-input
input-debounce="0"
hide-selected
fill-input
options-dense
class="scoreboard-preview__field"
@filter="player.onFilter"
@focus="player.onFocus"
@blur="player.onBlur"
@update:model-value="player.onSelect"
>
<template #prepend>
<QIcon name="person" />
</template>
<template #append>
<QBtn
v-if="player.showsNameSave.value"
flat
round
dense
icon="save"
color="primary"
@click.stop="player.onNameSave"
/>
</template>
</QSelect>
<QInput
v-model="player.teamOverride.value"
:label="t('scoreboardLabelTeam')"
dense
class="scoreboard-preview__field"
>
<template #prepend>
<QIcon name="groups" />
</template>
<template #append>
<QBtn
v-if="player.teamChanged.value"
flat
round
dense
icon="save"
color="primary"
@click.stop="player.saveTeamChange"
/>
</template>
</QInput>
<QSelect
v-model="player.countryOverride.value"
v-model:input-value="player.countryInput.value"
:options="player.filteredCountryOptions.value"
option-value="value"
option-label="label"
emit-value
map-options
use-input
input-debounce="0"
hide-selected
fill-input
clearable
:label="t('scoreboardLabelCountry')"
dense
class="scoreboard-preview__field"
@filter="player.onCountryFilter"
>
<template #prepend>
<QIcon name="flag" />
</template>
<template #append>
<QBtn
v-if="player.countryChanged.value"
flat
round
dense
icon="save"
color="primary"
@click.stop="player.saveCountryChange"
/>
</template>
</QSelect>
</div>
</template>
<!-- RIGHT: controls first, then image -->
<template v-else>
<!-- Player / team / country controls -->
<div class="scoreboard-preview__controls">
<QSelect
v-model="player.playerId.value"
v-model:input-value="player.inputValue.value"
:options="player.playerOptions.value"
:label="t('scoreboardLabelPlayer')"
dense
emit-value
map-options
use-input
input-debounce="0"
hide-selected
fill-input
options-dense
class="scoreboard-preview__field"
@filter="player.onFilter"
@focus="player.onFocus"
@blur="player.onBlur"
@update:model-value="player.onSelect"
>
<template #prepend>
<QIcon name="person" />
</template>
<template #append>
<QBtn
v-if="player.showsNameSave.value"
flat
round
dense
icon="save"
color="primary"
@click.stop="player.onNameSave"
/>
</template>
</QSelect>
<QInput
v-model="player.teamOverride.value"
:label="t('scoreboardLabelTeam')"
dense
class="scoreboard-preview__field"
>
<template #prepend>
<QIcon name="groups" />
</template>
<template #append>
<QBtn
v-if="player.teamChanged.value"
flat
round
dense
icon="save"
color="primary"
@click.stop="player.saveTeamChange"
/>
</template>
</QInput>
<QSelect
v-model="player.countryOverride.value"
v-model:input-value="player.countryInput.value"
:options="player.filteredCountryOptions.value"
option-value="value"
option-label="label"
emit-value
map-options
use-input
input-debounce="0"
hide-selected
fill-input
clearable
:label="t('scoreboardLabelCountry')"
dense
class="scoreboard-preview__field"
@filter="player.onCountryFilter"
>
<template #prepend>
<QIcon name="flag" />
</template>
<template #append>
<QBtn
v-if="player.countryChanged.value"
flat
round
dense
icon="save"
color="primary"
@click.stop="player.saveCountryChange"
/>
</template>
</QSelect>
</div>
<!-- Character image + character selector -->
<div class="scoreboard-preview__image-column">
<div class="scoreboard-preview__image-wrap">
<img
v-if="panelImage"
:src="panelImage"
:alt="`${player.displayName.value || sideLabel} ${t('scoreboardPreview')}`"
class="scoreboard-preview__image"
>
<div
v-else
class="scoreboard-preview__empty"
>
{{ sideImageLabel }}
</div>
</div>
<QSelect
v-model="character"
v-model:input-value="characterInputValue"
:options="characterOptions"
option-value="value"
option-label="label"
emit-value
map-options
:label="t('scoreboardLabelCharacter')"
dense
use-input
input-debounce="0"
hide-selected
fill-input
clearable
class="scoreboard-preview__field scoreboard-preview__character-field"
:disable="!scoreboardStore.scoreboard.game"
@filter="onCharacterFilter"
>
<template #prepend>
<QIcon name="sports_martial_arts" />
</template>
</QSelect>
</div>
</template>
</div>
</div>
</template>
<style scoped>
.scoreboard-preview__side {
display: flex;
align-items: center;
}
.scoreboard-preview__side-inner {
width: 100%;
display: grid;
grid-template-columns: minmax(220px, 320px) minmax(180px, 1fr);
align-items: center;
gap: 14px;
}
.scoreboard-preview__side--right {
text-align: right;
}
.scoreboard-preview__side--right .scoreboard-preview__side-inner {
grid-template-columns: minmax(180px, 1fr) minmax(220px, 320px);
}
.scoreboard-preview__image-column {
width: min(100%, 320px);
display: flex;
flex-direction: column;
gap: 8px;
}
.scoreboard-preview__side .scoreboard-preview__image-column {
justify-self: start;
}
.scoreboard-preview__side--right .scoreboard-preview__image-column {
justify-self: end;
}
.scoreboard-preview__image-wrap {
position: relative;
width: min(100%, 320px);
aspect-ratio: 4 / 4;
overflow: visible;
contain: layout;
}
.scoreboard-preview__image {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
object-fit: contain;
object-position: center;
transform: scale(1.5);
transform-origin: center center;
pointer-events: none;
}
.scoreboard-preview__empty {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: rgba(255, 255, 255, 0.65);
font-weight: 600;
}
.scoreboard-preview__controls {
width: min(100%, 260px);
justify-self: center;
display: flex;
flex-direction: column;
gap: 6px;
}
.scoreboard-preview__field {
margin: 0;
}
.scoreboard-preview__character-field {
margin-top: 2px;
}
.scoreboard-preview__field :deep(.q-field__control) {
min-height: 28px;
padding: 0;
background: transparent !important;
border-radius: 0;
}
.scoreboard-preview__field :deep(.q-field__control:before),
.scoreboard-preview__field :deep(.q-field__control:after) {
border: 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.34);
}
.scoreboard-preview__field :deep(.q-field__native),
.scoreboard-preview__field :deep(.q-field__input),
.scoreboard-preview__field :deep(.q-field__label) {
color: rgba(255, 255, 255, 0.92);
}
@media (max-width: 900px) {
.scoreboard-preview__image-wrap {
width: min(100%, 280px);
}
.scoreboard-preview__side-inner {
grid-template-columns: 1fr;
align-items: flex-start;
justify-items: flex-start;
}
.scoreboard-preview__side--right {
text-align: left;
}
.scoreboard-preview__side--right .scoreboard-preview__side-inner {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,191 @@
<script setup lang="ts">
import { inject } from 'vue';
import { useScoreboardStore } from '../stores/scoreboard';
import { CHARACTER_GAME_KEY } from '../composables/useCharacterGame';
import { t } from '../i18n';
const scoreboardStore = useScoreboardStore();
const { gameInput, fightingGameOptions, onGameFilter } = inject(CHARACTER_GAME_KEY)!;
const adjustLeftScore = (delta: number) => {
scoreboardStore.leftScore = Math.max(0, scoreboardStore.leftScore + delta);
};
const adjustRightScore = (delta: number) => {
scoreboardStore.rightScore = Math.max(0, scoreboardStore.rightScore + delta);
};
</script>
<template>
<div class="scoreboard-preview__center">
<QSelect
v-model="scoreboardStore.scoreboard.game"
v-model:input-value="gameInput"
:options="fightingGameOptions"
:label="t('scoreboardLabelGame')"
dense
emit-value
map-options
use-input
input-debounce="0"
hide-selected
fill-input
class="scoreboard-preview__field scoreboard-preview__game-field"
@filter="onGameFilter"
>
<template #prepend>
<QIcon name="sports_esports" />
</template>
</QSelect>
<div class="scoreboard-preview__score-controls">
<div class="scoreboard-preview__score-side">
<QBtn
flat
dense
round
size="sm"
icon="add"
@click="adjustLeftScore(1)"
/>
<span class="scoreboard-preview__score-value">
{{ scoreboardStore.scoreboard.leftScore }}
</span>
<QBtn
flat
dense
round
size="sm"
icon="remove"
@click="adjustLeftScore(-1)"
/>
</div>
<span class="scoreboard-preview__dash">-</span>
<div class="scoreboard-preview__score-side">
<QBtn
flat
dense
round
size="sm"
icon="add"
@click="adjustRightScore(1)"
/>
<span class="scoreboard-preview__score-value">
{{ scoreboardStore.scoreboard.rightScore }}
</span>
<QBtn
flat
dense
round
size="sm"
icon="remove"
@click="adjustRightScore(-1)"
/>
</div>
</div>
<div class="scoreboard-preview__actions">
<QBtn
flat
dense
icon="swap_horiz"
class="scoreboard-preview__action-btn"
@click="scoreboardStore.swapPlayers"
/>
<QBtn
flat
dense
icon="restart_alt"
class="scoreboard-preview__action-btn"
@click="scoreboardStore.resetScores"
/>
</div>
</div>
</template>
<style scoped>
.scoreboard-preview__center {
display: flex;
flex-direction: column;
align-items: center;
align-self: stretch;
justify-content: flex-start;
padding-top: 2px;
gap: 10px;
}
.scoreboard-preview__game-field {
width: min(100%, 240px);
margin-bottom: 56px;
}
.scoreboard-preview__score-controls {
display: flex;
align-items: center;
justify-content: center;
gap: 18px;
}
.scoreboard-preview__score-side {
display: inline-flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.scoreboard-preview__score-value {
min-width: 64px;
text-align: center;
font-size: clamp(4rem, 7vw, 5.6rem);
font-weight: 800;
line-height: 1;
}
.scoreboard-preview__dash {
opacity: 0.7;
font-size: clamp(3rem, 5vw, 4rem);
font-weight: 700;
}
.scoreboard-preview__actions {
display: flex;
align-items: center;
gap: 10px;
}
.scoreboard-preview__action-btn {
color: #fff;
opacity: 0.85;
}
.scoreboard-preview__action-btn:hover {
opacity: 1;
text-shadow: 0 0 10px rgba(255, 255, 255, 0.45);
}
/* Shared field styles (used by QSelect inside this panel) */
.scoreboard-preview__field {
margin: 0;
}
.scoreboard-preview__field :deep(.q-field__control) {
min-height: 28px;
padding: 0;
background: transparent !important;
border-radius: 0;
}
.scoreboard-preview__field :deep(.q-field__control:before),
.scoreboard-preview__field :deep(.q-field__control:after) {
border: 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.34);
}
.scoreboard-preview__field :deep(.q-field__native),
.scoreboard-preview__field :deep(.q-field__input),
.scoreboard-preview__field :deep(.q-field__label) {
color: rgba(255, 255, 255, 0.92);
}
</style>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,205 @@
import { computed, ref, watch, type InjectionKey, type Ref } from 'vue';
import { getCharactersByGame, getDefaultCharactersByGame } from '../../../shared/fighting-characters';
import { useScoreboardStore } from '../stores/scoreboard';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
export const ALL_FIGHTING_GAME_OPTIONS = [
'2XKO',
'Mortal Kombat 1',
'Street Fighter 6',
'TEKKEN 8',
'Guilty Gear -Strive-',
'THE KING OF FIGHTERS XV',
].map((game) => ({ label: game, value: game }));
export type CharacterOption = ReturnType<typeof getCharactersByGame>[number];
// ---------------------------------------------------------------------------
// Injection key (type-safe provide/inject)
// ---------------------------------------------------------------------------
export type CharacterGameContext = ReturnType<typeof useCharacterGame>;
export const CHARACTER_GAME_KEY: InjectionKey<CharacterGameContext> = Symbol('characterGame');
// ---------------------------------------------------------------------------
// Composable
// ---------------------------------------------------------------------------
/**
* Manages game selection and character state for both sides.
* Must be called ONCE in the parent (ScoreboardPanel) and provided via
* CHARACTER_GAME_KEY so both PlayerSidePanel instances share the same state.
*/
export function useCharacterGame() {
const scoreboardStore = useScoreboardStore();
// Game selector
const gameInput = ref('');
const fightingGameOptions = ref(ALL_FIGHTING_GAME_OPTIONS);
// Per-side character state
const characterOptions = computed(() => getCharactersByGame(scoreboardStore.scoreboard.game));
const leftCharacterOptions = ref<CharacterOption[]>([]);
const rightCharacterOptions = ref<CharacterOption[]>([]);
const leftCharacterInput = ref('');
const rightCharacterInput = ref('');
// Remembers selected characters per game so swapping games restores them
const charactersByGame = ref<Record<string, { leftCharacter: string; rightCharacter: string }>>({});
// Character images for preview
const leftCharacterImage = computed(() => {
const match = characterOptions.value.find(
(o) => o.value === scoreboardStore.scoreboard.leftCharacter,
);
return match?.image ?? '';
});
const rightCharacterImage = computed(() => {
const match = characterOptions.value.find(
(o) => o.value === scoreboardStore.scoreboard.rightCharacter,
);
return match?.image ?? '';
});
// ---------------------------------------------------------------------------
// Filter handlers
// ---------------------------------------------------------------------------
const onGameFilter = (value: string, update: (fn: () => void) => void) => {
update(() => {
const needle = value.toLowerCase().trim();
fightingGameOptions.value = needle
? ALL_FIGHTING_GAME_OPTIONS.filter((g) => g.label.toLowerCase().includes(needle))
: ALL_FIGHTING_GAME_OPTIONS;
});
};
const makeCharacterFilter = (target: Ref<CharacterOption[]>) =>
(value: string, update: (fn: () => void) => void) => {
update(() => {
const needle = value.toLowerCase().trim();
target.value = needle
? characterOptions.value.filter((c) => c.label.toLowerCase().includes(needle))
: characterOptions.value;
});
};
const onLeftCharacterFilter = makeCharacterFilter(leftCharacterOptions);
const onRightCharacterFilter = makeCharacterFilter(rightCharacterOptions);
// ---------------------------------------------------------------------------
// Watchers
// ---------------------------------------------------------------------------
// Keep gameInput display value in sync
watch(
() => scoreboardStore.scoreboard.game,
(value) => {
const match = ALL_FIGHTING_GAME_OPTIONS.find((o) => o.value === value);
gameInput.value = match?.label ?? '';
},
{ immediate: true },
);
// Handle game change: persist previous characters, restore or apply defaults
watch(
() => scoreboardStore.scoreboard.game,
(newGame, previousGame) => {
if (previousGame) {
charactersByGame.value[previousGame] = {
leftCharacter: scoreboardStore.scoreboard.leftCharacter,
rightCharacter: scoreboardStore.scoreboard.rightCharacter,
};
}
const options = getCharactersByGame(newGame);
leftCharacterOptions.value = options;
rightCharacterOptions.value = options;
const allowed = new Set(options.map((o) => o.value));
const saved = newGame ? charactersByGame.value[newGame] : undefined;
const { leftCharacter: curLeft, rightCharacter: curRight } = scoreboardStore.scoreboard;
let nextLeft = saved?.leftCharacter ?? curLeft;
let nextRight = saved?.rightCharacter ?? curRight;
if (!allowed.has(nextLeft)) nextLeft = '';
if (!allowed.has(nextRight)) nextRight = '';
// Apply defaults only when neither side had a character yet
if ((!nextLeft || !nextRight) && (!curLeft || !curRight)) {
const defaults = getDefaultCharactersByGame(newGame);
if (defaults) {
if (!nextLeft) nextLeft = allowed.has(defaults.leftCharacter) ? defaults.leftCharacter : '';
if (!nextRight) nextRight = allowed.has(defaults.rightCharacter) ? defaults.rightCharacter : '';
}
}
if (allowed.has(nextLeft)) {
scoreboardStore.scoreboard.leftCharacter = nextLeft;
} else if (!allowed.has(scoreboardStore.scoreboard.leftCharacter)) {
scoreboardStore.scoreboard.leftCharacter = '';
leftCharacterInput.value = '';
}
if (allowed.has(nextRight)) {
scoreboardStore.scoreboard.rightCharacter = nextRight;
} else if (!allowed.has(scoreboardStore.scoreboard.rightCharacter)) {
scoreboardStore.scoreboard.rightCharacter = '';
rightCharacterInput.value = '';
}
},
{ immediate: true },
);
// Keep left character display input and charactersByGame cache in sync
watch(
() => scoreboardStore.scoreboard.leftCharacter,
(value) => {
const match = characterOptions.value.find((o) => o.value === value);
leftCharacterInput.value = match?.label ?? '';
const game = scoreboardStore.scoreboard.game;
if (game) {
charactersByGame.value[game] = {
leftCharacter: value,
rightCharacter: scoreboardStore.scoreboard.rightCharacter,
};
}
},
{ immediate: true },
);
// Keep right character display input and charactersByGame cache in sync
watch(
() => scoreboardStore.scoreboard.rightCharacter,
(value) => {
const match = characterOptions.value.find((o) => o.value === value);
rightCharacterInput.value = match?.label ?? '';
const game = scoreboardStore.scoreboard.game;
if (game) {
charactersByGame.value[game] = {
leftCharacter: scoreboardStore.scoreboard.leftCharacter,
rightCharacter: value,
};
}
},
{ immediate: true },
);
return {
gameInput,
fightingGameOptions,
leftCharacterOptions,
rightCharacterOptions,
leftCharacterInput,
rightCharacterInput,
leftCharacterImage,
rightCharacterImage,
onGameFilter,
onLeftCharacterFilter,
onRightCharacterFilter,
};
}
@@ -0,0 +1,36 @@
import { computed, ref, watch } from 'vue';
import { getCountryLabel, getCountryOptions } from '../../../shared/countries';
import { locale } from '../i18n';
/**
* Manages filtered country options and the display input value
* for a single side of the scoreboard.
*
* @param getOverride - Getter returning the current country code override
*/
export function useCountryFilter(getOverride: () => string) {
const countryOptions = computed(() => getCountryOptions(locale.value));
const countryInput = ref('');
const filteredOptions = ref(countryOptions.value);
// Keep filtered list in sync when locale changes
watch(countryOptions, (opts) => {
filteredOptions.value = opts;
});
// Keep display input in sync with the stored country code
watch(getOverride, (value) => {
countryInput.value = getCountryLabel(value, locale.value);
}, { immediate: true });
const onFilter = (value: string, update: (fn: () => void) => void) => {
update(() => {
const needle = value.toLowerCase().trim();
filteredOptions.value = needle
? countryOptions.value.filter((c) => c.label.toLowerCase().includes(needle))
: countryOptions.value;
});
};
return { countryInput, filteredOptions, onFilter };
}
@@ -0,0 +1,346 @@
import { computed, ref, watch, watchEffect } from 'vue';
import { useScoreboardStore } from '../stores/scoreboard';
import { usePlayersStore } from '../stores/players';
import type { Schemas } from '../../../types';
import { t } from '../i18n';
import { useCountryFilter } from './useCountryFilter';
// ---------------------------------------------------------------------------
// Constants (exported so components can compare against them)
// ---------------------------------------------------------------------------
export const CUSTOM_LEFT_PLAYER_ID = '__custom_left_player__';
export const CUSTOM_RIGHT_PLAYER_ID = '__custom_right_player__';
// ---------------------------------------------------------------------------
// Pure helpers (no Vue reactivity)
// ---------------------------------------------------------------------------
const normalizeName = (value: string) => value.trim().toLowerCase();
/**
* Generates a unique slug-based player ID that does not collide with
* existing player keys in the store.
*/
const createPlayerId = (name: string, players: Schemas.Players): string => {
const base = name
.trim()
.toLowerCase()
.normalize('NFD')
.replace(/[^\w\s-]/g, '')
.replace(/[\u0300-\u036f]/g, '')
.replace(/\s+/g, '-') || 'player';
let index = 1;
let candidate = base;
while (players[candidate]) {
index += 1;
candidate = `${base}-${index}`;
}
return candidate;
};
// ---------------------------------------------------------------------------
// Composable
// ---------------------------------------------------------------------------
/**
* Encapsulates all reactive state and handlers for one side of the scoreboard
* (left or right). Call once per side inside the corresponding component.
*/
export function usePlayerSide(side: 'left' | 'right') {
const scoreboardStore = useScoreboardStore();
const playersStore = usePlayersStore();
const isLeft = side === 'left';
const CUSTOM_ID = isLeft ? CUSTOM_LEFT_PLAYER_ID : CUSTOM_RIGHT_PLAYER_ID;
// ---------------------------------------------------------------------------
// Two-way computed bindings to the store (avoids left/right if-chains in
// the template and keeps mutation contained to the composable)
// ---------------------------------------------------------------------------
const playerId = computed({
get: () => (isLeft ? scoreboardStore.scoreboard.leftPlayerId : scoreboardStore.scoreboard.rightPlayerId),
set: (v) => {
if (isLeft) scoreboardStore.scoreboard.leftPlayerId = v;
else scoreboardStore.scoreboard.rightPlayerId = v;
},
});
const nameOverride = computed({
get: () => (isLeft ? scoreboardStore.scoreboard.leftNameOverride : scoreboardStore.scoreboard.rightNameOverride),
set: (v) => {
if (isLeft) scoreboardStore.scoreboard.leftNameOverride = v;
else scoreboardStore.scoreboard.rightNameOverride = v;
},
});
const teamOverride = computed({
get: () => (isLeft ? scoreboardStore.scoreboard.leftTeamOverride : scoreboardStore.scoreboard.rightTeamOverride),
set: (v) => {
if (isLeft) scoreboardStore.scoreboard.leftTeamOverride = v;
else scoreboardStore.scoreboard.rightTeamOverride = v;
},
});
const countryOverride = computed({
get: () => (isLeft ? scoreboardStore.scoreboard.leftCountryOverride : scoreboardStore.scoreboard.rightCountryOverride),
set: (v) => {
if (isLeft) scoreboardStore.scoreboard.leftCountryOverride = v;
else scoreboardStore.scoreboard.rightCountryOverride = v;
},
});
// ---------------------------------------------------------------------------
// UI state
// ---------------------------------------------------------------------------
const filter = ref('');
const inputValue = ref('');
const focused = ref(false);
// Country filter (delegated to sub-composable)
const {
countryInput,
filteredOptions: filteredCountryOptions,
onFilter: onCountryFilter,
} = useCountryFilter(() => countryOverride.value);
// ---------------------------------------------------------------------------
// Player options
// ---------------------------------------------------------------------------
const allPlayerOptions = computed(() => {
const base = [{ label: t('scoreboardUnassigned'), value: '' }];
const entries = Object.entries(playersStore.players) as [string, Schemas.Players[string]][];
const mapped = entries.map(([id, player]) => ({
value: id,
label: player.gamertag || id,
}));
return base.concat(mapped);
});
/**
* Player options filtered by the current search input.
* Prepends the custom player entry when the user has typed a new name.
*/
const playerOptions = computed(() => {
const needle = filter.value.toLowerCase();
const options = needle
? allPlayerOptions.value.filter((o) => o.label.toLowerCase().includes(needle))
: allPlayerOptions.value;
if (playerId.value === CUSTOM_ID && nameOverride.value.trim()) {
return [{ value: CUSTOM_ID, label: nameOverride.value }, ...options];
}
return options;
});
const selectedPlayer = computed(() => playersStore.players[playerId.value]);
const getPlayerLabel = (id: string): string => {
if (id === CUSTOM_ID) return nameOverride.value;
return allPlayerOptions.value.find((o) => o.value === id)?.label ?? '';
};
const playerExistsByGamertag = (name: string): boolean => {
const normalized = normalizeName(name);
return Boolean(normalized)
&& Object.values(playersStore.players).some(
(p) => normalizeName(p.gamertag || '') === normalized,
);
};
// ---------------------------------------------------------------------------
// Derived state
// ---------------------------------------------------------------------------
const displayName = computed(
() => nameOverride.value || getPlayerLabel(playerId.value),
);
/** True when the typed name is new and can be saved as a new player. */
const canSave = computed(
() => Boolean(nameOverride.value.trim()) && !playerExistsByGamertag(nameOverride.value),
);
const teamChanged = computed(() => {
const player = selectedPlayer.value;
if (!player) return false;
return player.team !== teamOverride.value;
});
const countryChanged = computed(() => {
const player = selectedPlayer.value;
if (!player) return false;
return player.country !== countryOverride.value;
});
// Parentheses required: || and ?? cannot be mixed without them (TS5076)
const pendingGamertag = computed(
() => (nameOverride.value.trim() || selectedPlayer.value?.gamertag) ?? '',
);
const nameChanged = computed(() => {
const player = selectedPlayer.value;
if (!player) return false;
return player.gamertag !== pendingGamertag.value;
});
/** True when the name has changed and the new name doesn't collide. */
const canSaveNameChange = computed(
() => nameChanged.value && !playerExistsByGamertag(pendingGamertag.value),
);
/** Whether the save icon should appear in the player name field. */
const showsNameSave = computed(() => canSave.value || canSaveNameChange.value);
// ---------------------------------------------------------------------------
// Actions
// ---------------------------------------------------------------------------
const startCustomPlayer = () => {
const wasCustom = playerId.value === CUSTOM_ID;
playerId.value = CUSTOM_ID;
if (!wasCustom) {
teamOverride.value = '';
countryOverride.value = '';
}
};
const applyPlayerData = (id: string) => {
const player = playersStore.players[id];
if (!player) return;
teamOverride.value = player.team ?? '';
countryOverride.value = player.country ?? '';
};
const onFilter = (val: string, update: (fn: () => void) => void) => {
update(() => {
filter.value = val;
if (!focused.value) return;
// If the field is cleared while a custom player is selected, restore the name
if (!val.trim() && playerId.value === CUSTOM_ID) {
inputValue.value = nameOverride.value;
return;
}
inputValue.value = val;
nameOverride.value = val;
if (val.trim()) startCustomPlayer();
});
};
const onFocus = () => {
focused.value = true;
inputValue.value = displayName.value;
};
const onBlur = () => {
focused.value = false;
filter.value = '';
inputValue.value = displayName.value;
};
const onSelect = (id: string) => {
if (!id || !playersStore.players[id]) return;
focused.value = false;
nameOverride.value = '';
filter.value = '';
inputValue.value = getPlayerLabel(id);
applyPlayerData(id);
};
/** Save the typed name as a brand-new player entry. */
const savePlayer = () => {
const gamertag = nameOverride.value.trim();
if (!gamertag || playerExistsByGamertag(gamertag)) return;
const id = createPlayerId(gamertag, playersStore.players);
playersStore.upsertPlayer(id, {
gamertag,
name: '',
team: teamOverride.value,
country: countryOverride.value,
twitter: '',
});
playerId.value = id;
nameOverride.value = '';
inputValue.value = gamertag;
};
/** Persist a gamertag rename on an existing player. */
const saveNameChange = () => {
const player = selectedPlayer.value;
if (!player || !canSaveNameChange.value) return;
playersStore.upsertPlayer(playerId.value, { ...player, gamertag: pendingGamertag.value });
nameOverride.value = '';
};
const saveTeamChange = () => {
const player = selectedPlayer.value;
if (!player) return;
playersStore.upsertPlayer(playerId.value, { ...player, team: teamOverride.value });
};
const saveCountryChange = () => {
const player = selectedPlayer.value;
if (!player) return;
playersStore.upsertPlayer(playerId.value, { ...player, country: countryOverride.value });
};
/** Dispatches to savePlayer or saveNameChange depending on context. */
const onNameSave = () => {
if (canSave.value) {
savePlayer();
return;
}
saveNameChange();
};
// ---------------------------------------------------------------------------
// Watchers
// ---------------------------------------------------------------------------
// Sync team/country fields when player selection changes
watch(playerId, (id) => applyPlayerData(id), { immediate: true });
// Keep the search input display value in sync unless the field is focused
watchEffect(() => {
if (!focused.value) {
inputValue.value = displayName.value;
}
});
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
return {
// Store bindings (writable computed refs)
playerId,
nameOverride,
teamOverride,
countryOverride,
// UI state
inputValue,
countryInput,
filteredCountryOptions,
playerOptions,
// Derived state
displayName,
teamChanged,
countryChanged,
showsNameSave,
// Handlers
onFilter,
onFocus,
onBlur,
onSelect,
onNameSave,
saveTeamChange,
saveCountryChange,
onCountryFilter,
};
}