parent
facb36a07f
commit
0955d6ca65
31 changed files with 3386 additions and 1391 deletions
@ -0,0 +1,541 @@ |
||||
# Kuldvillak/Jeopardy Game Platform - Technical Documentation |
||||
|
||||
## Table of Contents |
||||
- [Overview](#overview) |
||||
- [Tech Stack](#tech-stack) |
||||
- [Architecture](#architecture) |
||||
- [Directory Structure](#directory-structure) |
||||
- [Game Flow](#game-flow) |
||||
- [State Management](#state-management) |
||||
- [Components](#components) |
||||
- [Data Types](#data-types) |
||||
- [Features](#features) |
||||
- [Multi-Window Sync](#multi-window-sync) |
||||
|
||||
--- |
||||
|
||||
## Overview |
||||
|
||||
**Ultimate Gaming** is a web-based platform for hosting trivia game shows, currently featuring **Kuldvillak** - a Jeopardy-style quiz game. The platform supports: |
||||
|
||||
- Full game editor with customizable settings |
||||
- Dual-screen setup (Moderator + Projector views) |
||||
- Real-time cross-tab synchronization |
||||
- Theme customization |
||||
- Internationalization (Estonian/English) |
||||
- Audio management |
||||
- Persistent game storage |
||||
|
||||
--- |
||||
|
||||
## Tech Stack |
||||
|
||||
| Technology | Version | Purpose | |
||||
|------------|---------|---------| |
||||
| **SvelteKit** | 2.48.5 | Full-stack framework | |
||||
| **Svelte 5** | 5.43.8 | UI with Runes reactivity | |
||||
| **TailwindCSS** | 4.1.17 | Utility-first styling | |
||||
| **TypeScript** | 5.9.3 | Type safety | |
||||
| **Paraglide** | 2.5.0 | i18n (internationalization) | |
||||
| **Vite** | 7.2.2 | Build tool | |
||||
|
||||
--- |
||||
|
||||
## Architecture |
||||
|
||||
``` |
||||
┌─────────────────────────────────────────────────────────────┐ |
||||
│ Ultimate Gaming │ |
||||
├─────────────────────────────────────────────────────────────┤ |
||||
│ Routes │ |
||||
│ ├── / (Home - Game Selection) │ |
||||
│ ├── /kuldvillak (Game Menu) │ |
||||
│ ├── /kuldvillak/edit (Game Editor) │ |
||||
│ └── /kuldvillak/play (Game Play) │ |
||||
│ ├── ModeratorView (Controls) │ |
||||
│ └── ProjectorView (Display) │ |
||||
├─────────────────────────────────────────────────────────────┤ |
||||
│ State Management (Svelte 5 Runes) │ |
||||
│ ├── gameSession.svelte.ts (Live game state + BroadcastAPI) │ |
||||
│ ├── kuldvillak.svelte.ts (Game store - editor) │ |
||||
│ ├── theme.svelte.ts (Color theming) │ |
||||
│ ├── audio.svelte.ts (Music/SFX volumes) │ |
||||
│ └── persistence.ts (localStorage utilities) │ |
||||
├─────────────────────────────────────────────────────────────┤ |
||||
│ Components │ |
||||
│ ├── Shared (Settings, ColorPicker, Toast, etc.) │ |
||||
│ └── Kuldvillak UI (Buttons, Cards, Logo, etc.) │ |
||||
└─────────────────────────────────────────────────────────────┘ |
||||
``` |
||||
|
||||
--- |
||||
|
||||
## Directory Structure |
||||
|
||||
``` |
||||
src/ |
||||
├── lib/ |
||||
│ ├── assets/ # Static assets (favicon) |
||||
│ ├── components/ |
||||
│ │ ├── kuldvillak/ |
||||
│ │ │ └── ui/ # Game-specific UI components |
||||
│ │ │ ├── KvButtonPrimary.svelte |
||||
│ │ │ ├── KvButtonSecondary.svelte |
||||
│ │ │ ├── KvEditCard.svelte |
||||
│ │ │ ├── KvGameLogo.svelte |
||||
│ │ │ ├── KvNumberInput.svelte |
||||
│ │ │ ├── KvPlayerCard.svelte |
||||
│ │ │ └── KvProjectorCard.svelte |
||||
│ │ ├── ColorPicker.svelte |
||||
│ │ ├── ConfirmDialog.svelte |
||||
│ │ ├── LanguageSwitcher.svelte |
||||
│ │ ├── Settings.svelte |
||||
│ │ ├── Slider.svelte |
||||
│ │ └── Toast.svelte |
||||
│ ├── paraglide/ # Generated i18n code |
||||
│ ├── stores/ |
||||
│ │ ├── audio.svelte.ts |
||||
│ │ ├── gameSession.svelte.ts |
||||
│ │ ├── kuldvillak.svelte.ts |
||||
│ │ ├── persistence.ts |
||||
│ │ └── theme.svelte.ts |
||||
│ ├── types/ |
||||
│ │ └── kuldvillak.ts # TypeScript interfaces |
||||
│ └── index.ts # Library exports |
||||
├── routes/ |
||||
│ ├── kuldvillak/ |
||||
│ │ ├── edit/ |
||||
│ │ │ └── +page.svelte # Game editor |
||||
│ │ ├── play/ |
||||
│ │ │ ├── +page.svelte # Route handler |
||||
│ │ │ ├── ModeratorView.svelte |
||||
│ │ │ └── ProjectorView.svelte |
||||
│ │ ├── +layout.svelte |
||||
│ │ └── +page.svelte # Game menu |
||||
│ ├── +error.svelte |
||||
│ ├── +layout.svelte # Root layout |
||||
│ ├── +page.svelte # Home page |
||||
│ └── layout.css # Global styles + Tailwind |
||||
├── app.html |
||||
├── app.d.ts |
||||
├── hooks.server.ts |
||||
└── hooks.ts |
||||
``` |
||||
|
||||
--- |
||||
|
||||
## Game Flow |
||||
|
||||
### 1. Game Creation (Editor) |
||||
``` |
||||
/kuldvillak/edit |
||||
│ |
||||
├── Configure Settings |
||||
│ ├── Number of rounds (1-2) |
||||
│ ├── Timer durations |
||||
│ ├── Point values (preset or custom) |
||||
│ ├── Daily doubles per round |
||||
│ └── Enable/disable final round |
||||
│ |
||||
├── Add Teams (2-6 players) |
||||
│ |
||||
├── Fill Categories & Questions |
||||
│ ├── 6 categories per round |
||||
│ ├── 5 questions per category |
||||
│ └── Mark daily doubles |
||||
│ |
||||
├── Final Round Question (optional) |
||||
│ |
||||
└── Start Game → Opens Moderator + Projector views |
||||
``` |
||||
|
||||
### 2. Game Phases |
||||
``` |
||||
┌─────────────┐ |
||||
│ intro │ ← Kuldvillak logo screen |
||||
└──────┬──────┘ |
||||
│ |
||||
┌──────▼──────────────┐ |
||||
│ intro-categories │ ← Category reveal animation |
||||
└──────┬──────────────┘ |
||||
│ |
||||
┌──────▼──────┐ |
||||
│ board │ ← Question selection grid |
||||
└──────┬──────┘ |
||||
│ |
||||
┌──────▼──────────────┐ |
||||
│ daily-double (if) │ ← Wager selection |
||||
└──────┬──────────────┘ |
||||
│ |
||||
┌──────▼──────┐ |
||||
│ question │ ← Display question, teams answer |
||||
└──────┬──────┘ |
||||
│ |
||||
├── Correct → Award points |
||||
├── Wrong → Deduct points, next team |
||||
└── Skip → Reveal answer |
||||
│ |
||||
└──────► Back to board |
||||
│ |
||||
│ (After all questions) |
||||
│ |
||||
┌──────▼────────────────┐ |
||||
│ final-intro │ ← Final round logo |
||||
├───────────────────────┤ |
||||
│ final-category │ ← Reveal final category |
||||
├───────────────────────┤ |
||||
│ final-question │ ← Teams write answers + wagers |
||||
├───────────────────────┤ |
||||
│ final-scores │ ← Final standings |
||||
└───────────────────────┘ |
||||
│ |
||||
┌──────▼──────┐ |
||||
│ finished │ |
||||
└─────────────┘ |
||||
``` |
||||
|
||||
### 3. Question Flow |
||||
``` |
||||
Question Selected |
||||
│ |
||||
├── Is Daily Double? |
||||
│ │ |
||||
│ ├── YES → Select team → Enter wager → Show question |
||||
│ └── NO → Show question directly |
||||
│ |
||||
▼ |
||||
Display Question |
||||
│ |
||||
├── Timer starts |
||||
│ |
||||
├── Team clicks to answer |
||||
│ │ |
||||
│ ├── Timer pauses |
||||
│ │ |
||||
│ ├── Moderator marks Correct/Wrong |
||||
│ │ │ |
||||
│ │ ├── Correct → +points → Show answer → Return to board |
||||
│ │ └── Wrong → -points → Continue (others can try) |
||||
│ │ |
||||
│ └── All teams wrong → Auto-reveal answer |
||||
│ |
||||
└── Timer expires → Auto-reveal answer |
||||
``` |
||||
|
||||
--- |
||||
|
||||
## State Management |
||||
|
||||
### GameSessionStore (`gameSession.svelte.ts`) |
||||
|
||||
The heart of the game - manages live game state with cross-tab synchronization. |
||||
|
||||
**Key Features:** |
||||
- Uses `BroadcastChannel` API for real-time sync between Moderator and Projector |
||||
- Persists to `localStorage` for session recovery |
||||
- Single internal timer managed by moderator view |
||||
|
||||
**State Interface:** |
||||
```typescript |
||||
interface GameSessionState { |
||||
// Game data |
||||
name: string; |
||||
settings: GameSettings; |
||||
teams: Team[]; |
||||
rounds: Round[]; |
||||
finalRound: FinalRound | null; |
||||
|
||||
// Game phase |
||||
phase: GamePhase; |
||||
currentRoundIndex: number; |
||||
activeTeamId: string | null; |
||||
|
||||
// Animation state |
||||
introCategoryIndex: number; |
||||
categoriesIntroduced: boolean; |
||||
boardRevealed: boolean; |
||||
|
||||
// Question state |
||||
currentQuestion: { roundIndex, categoryIndex, questionIndex } | null; |
||||
showAnswer: boolean; |
||||
wrongTeamIds: string[]; |
||||
lastAnsweredTeamId: string | null; |
||||
lastAnswerCorrect: boolean | null; |
||||
|
||||
// Daily Double |
||||
dailyDoubleWager: number | null; |
||||
|
||||
// Final Round |
||||
finalCategoryRevealed: boolean; |
||||
finalWagers: Record<string, number>; |
||||
finalAnswers: Record<string, string>; |
||||
finalRevealed: string[]; |
||||
|
||||
// Timer |
||||
timerRunning: boolean; |
||||
timerSeconds: number; |
||||
timerMax: number; |
||||
timeoutCountdown: number | null; |
||||
revealCountdown: number | null; |
||||
skippingQuestion: boolean; |
||||
|
||||
// Tracking |
||||
questionsAnswered: number; |
||||
currentQuestionNumber: number; |
||||
questionResults: QuestionResult[]; |
||||
} |
||||
``` |
||||
|
||||
### ThemeStore (`theme.svelte.ts`) |
||||
|
||||
Manages color theming with cross-window sync. |
||||
|
||||
**CSS Variables controlled:** |
||||
- `--kv-blue` (primary) |
||||
- `--kv-yellow` (secondary) |
||||
- `--kv-text` |
||||
- `--kv-background` |
||||
|
||||
### AudioStore (`audio.svelte.ts`) |
||||
|
||||
Manages background music and sound effects volumes. |
||||
|
||||
--- |
||||
|
||||
## Components |
||||
|
||||
### Shared Components |
||||
|
||||
| Component | Purpose | |
||||
|-----------|---------| |
||||
| `Settings.svelte` | Modal with audio, language, and color settings | |
||||
| `ColorPicker.svelte` | Full HSL color picker with swatches | |
||||
| `ConfirmDialog.svelte` | Confirmation modal | |
||||
| `Toast.svelte` | Notification messages | |
||||
| `Slider.svelte` | Volume control slider | |
||||
| `LanguageSwitcher.svelte` | ET/EN language toggle | |
||||
|
||||
### Kuldvillak UI Components |
||||
|
||||
| Component | Purpose | |
||||
|-----------|---------| |
||||
| `KvButtonPrimary.svelte` | Yellow primary action button | |
||||
| `KvButtonSecondary.svelte` | Blue secondary action button | |
||||
| `KvEditCard.svelte` | Team card for moderator (score adjust, correct/wrong) | |
||||
| `KvPlayerCard.svelte` | Basic player display card | |
||||
| `KvProjectorCard.svelte` | Player card for projector view | |
||||
| `KvNumberInput.svelte` | Styled number input with +/- buttons | |
||||
| `KvGameLogo.svelte` | SVG logo component with variants | |
||||
|
||||
--- |
||||
|
||||
## Data Types |
||||
|
||||
### Core Types (`types/kuldvillak.ts`) |
||||
|
||||
```typescript |
||||
interface Question { |
||||
id: string; |
||||
question: string; |
||||
answer: string; |
||||
points: number; |
||||
isDailyDouble: boolean; |
||||
isRevealed: boolean; |
||||
imageUrl?: string; |
||||
} |
||||
|
||||
interface Category { |
||||
id: string; |
||||
name: string; |
||||
questions: Question[]; |
||||
} |
||||
|
||||
interface Round { |
||||
id: string; |
||||
name: string; |
||||
categories: Category[]; |
||||
pointMultiplier: number; |
||||
} |
||||
|
||||
interface Team { |
||||
id: string; |
||||
name: string; |
||||
score: number; |
||||
} |
||||
|
||||
interface GameSettings { |
||||
numberOfRounds: 1 | 2; |
||||
pointValuePreset: 'round1' | 'round2' | 'custom' | 'multiplier'; |
||||
pointValues: number[]; |
||||
basePointValue: number; |
||||
categoriesPerRound: number; // Default: 6 |
||||
questionsPerCategory: number; // Default: 5 |
||||
dailyDoublesPerRound: number[]; |
||||
enableFinalRound: boolean; |
||||
enableSoundEffects: boolean; |
||||
allowNegativeScores: boolean; |
||||
maxTeams: number; // Max: 6 |
||||
defaultTimerSeconds: number; |
||||
answerRevealSeconds: number; |
||||
} |
||||
|
||||
type GamePhase = |
||||
| 'intro' // Logo screen |
||||
| 'intro-categories' // Category reveal |
||||
| 'lobby' |
||||
| 'board' // Question grid |
||||
| 'question' |
||||
| 'answer' |
||||
| 'daily-double' |
||||
| 'final-intro' |
||||
| 'final-category' |
||||
| 'final-wagers' |
||||
| 'final-question' |
||||
| 'final-reveal' |
||||
| 'final-scores' |
||||
| 'finished'; |
||||
``` |
||||
|
||||
--- |
||||
|
||||
## Features |
||||
|
||||
### 1. Game Editor |
||||
- Auto-save to localStorage |
||||
- Import/Export JSON game files |
||||
- Visual daily double indicators |
||||
- Point value presets or custom |
||||
- Final round configuration |
||||
|
||||
### 2. Moderator View |
||||
- Full game control |
||||
- Score adjustments |
||||
- Timer control (start/stop/reset) |
||||
- Question skip functionality |
||||
- Open projector window button |
||||
|
||||
### 3. Projector View |
||||
- Full-screen presentation |
||||
- Animated category introductions |
||||
- Staggered board reveal animation |
||||
- Question expand animation |
||||
- Team score display during questions |
||||
- Final scores display |
||||
|
||||
### 4. Animations (ProjectorView) |
||||
- **Category Intro**: 500ms fade-in → 1500ms shown → 500ms push-out |
||||
- **Board Reveal**: Staggered 50ms per cell |
||||
- **Question Expand**: From grid cell position to fullscreen |
||||
|
||||
### 5. Cross-Tab Sync |
||||
- Real-time state sync via BroadcastChannel |
||||
- localStorage persistence for recovery |
||||
- Single timer owner (moderator) |
||||
|
||||
--- |
||||
|
||||
## Multi-Window Sync |
||||
|
||||
``` |
||||
┌─────────────────┐ BroadcastChannel ┌─────────────────┐ |
||||
│ Moderator Tab │ ◄─────────────────────────────► │ Projector Tab │ |
||||
│ │ │ │ |
||||
│ - Controls │ STATE_UPDATE │ - Display │ |
||||
│ - Timer owner │ ─────────────────────────────────►│ - Animations │ |
||||
│ - Score adjust │ │ - Read-only │ |
||||
└────────┬────────┘ └─────────────────┘ |
||||
│ |
||||
│ localStorage |
||||
▼ |
||||
┌─────────────────┐ |
||||
│ Session Store │ |
||||
│ (persistence) │ |
||||
└─────────────────┘ |
||||
``` |
||||
|
||||
--- |
||||
|
||||
## Styling |
||||
|
||||
### Design Tokens (CSS Variables) |
||||
|
||||
```css |
||||
:root { |
||||
/* Colors */ |
||||
--kv-blue: #003B9B; /* Primary */ |
||||
--kv-yellow: #FFAB00; /* Secondary/Accent */ |
||||
--kv-text: #FFFFFF; |
||||
--kv-background: #000000; |
||||
--kv-green: #009900; /* Correct */ |
||||
--kv-red: #FF3333; /* Wrong */ |
||||
|
||||
/* Fonts */ |
||||
--kv-font-title: 'Swiss 921'; |
||||
--kv-font-body: 'Swiss 921'; |
||||
--kv-font-price: 'Bebas Neue'; |
||||
--kv-font-question: 'ITC Korinna'; |
||||
|
||||
/* Shadows */ |
||||
--kv-shadow-text: 6px 6px 4px rgba(0,0,0,0.5); |
||||
--kv-shadow-price: 8px 8px 4px rgba(0,0,0,0.5); |
||||
--kv-shadow-button: 0 4px 4px rgba(0,0,0,0.25), 8px 8px 4px rgba(0,0,0,0.5); |
||||
} |
||||
``` |
||||
|
||||
### Tailwind Theme Extensions |
||||
|
||||
Custom Tailwind classes prefixed with `kv-`: |
||||
- Colors: `bg-kv-blue`, `text-kv-yellow`, etc. |
||||
- Fonts: `font-kv-body`, `font-kv-price`, etc. |
||||
|
||||
--- |
||||
|
||||
## i18n (Internationalization) |
||||
|
||||
Using **Paraglide** for type-safe translations. |
||||
|
||||
**Supported Languages:** |
||||
- Estonian (et) - Primary |
||||
- English (en) |
||||
|
||||
**Message Files:** |
||||
- `messages/et.json` |
||||
- `messages/en.json` |
||||
|
||||
**Usage:** |
||||
```svelte |
||||
<script> |
||||
import * as m from "$lib/paraglide/messages"; |
||||
</script> |
||||
|
||||
{m.kv_play_daily_double()} |
||||
``` |
||||
|
||||
--- |
||||
|
||||
## Development |
||||
|
||||
```bash |
||||
# Install dependencies |
||||
npm install |
||||
|
||||
# Start dev server |
||||
npm run dev |
||||
|
||||
# Build for production |
||||
npm run build |
||||
|
||||
# Type check |
||||
npm run check |
||||
``` |
||||
|
||||
--- |
||||
|
||||
## Future Considerations |
||||
|
||||
1. **Database Backend**: Replace localStorage with proper database for persistence |
||||
2. **WebSocket**: Real-time sync without BroadcastChannel limitations |
||||
3. **Sound Effects**: Implement actual SFX playback |
||||
4. **More Game Modes**: "Rooside Sõda" (Family Feud) placeholder exists |
||||
5. **User Accounts**: Save games to cloud |
||||
6. **Mobile Buzzer**: Player buzzer app for team selection |
||||
@ -0,0 +1,21 @@ |
||||
MIT License |
||||
|
||||
Copyright (c) 2024 |
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy |
||||
of this software and associated documentation files (the "Software"), to deal |
||||
in the Software without restriction, including without limitation the rights |
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell |
||||
copies of the Software, and to permit persons to whom the Software is |
||||
furnished to do so, subject to the following conditions: |
||||
|
||||
The above copyright notice and this permission notice shall be included in all |
||||
copies or substantial portions of the Software. |
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR |
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, |
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE |
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER |
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, |
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE |
||||
SOFTWARE. |
||||
@ -0,0 +1,821 @@ |
||||
<script lang="ts"> |
||||
import * as m from "$lib/paraglide/messages"; |
||||
import ConfirmDialog from "./ConfirmDialog.svelte"; |
||||
|
||||
interface ColorPickerProps { |
||||
value: string; |
||||
onchange?: (color: string) => void; |
||||
} |
||||
|
||||
let { value = $bindable("#ffffff"), onchange }: ColorPickerProps = $props(); |
||||
|
||||
let isOpen = $state(false); |
||||
let hexInput = $state(value); |
||||
|
||||
// RGB values |
||||
let r = $state(255); |
||||
let g = $state(255); |
||||
let b = $state(255); |
||||
|
||||
// Alpha/Opacity (0-100%) |
||||
let alpha = $state(100); |
||||
|
||||
// HSV values |
||||
let hsvH = $state(0); |
||||
let hsvS = $state(0); |
||||
let hsvV = $state(100); |
||||
|
||||
// HSL values |
||||
let hslH = $state(0); |
||||
let hslS = $state(0); |
||||
let hslL = $state(100); |
||||
|
||||
// Grayscale |
||||
let gray = $state(255); |
||||
|
||||
// Parse hex input - supports 2, 3, 6, and 8 character formats |
||||
function parseHexInput(input: string): string | null { |
||||
// Remove # and whitespace |
||||
let hex = input.replace(/^#/, "").trim().toLowerCase(); |
||||
|
||||
// 2 characters: repeat 3 times (e.g., "AB" → "ABABAB") |
||||
if (/^[a-f\d]{2}$/i.test(hex)) { |
||||
hex = hex + hex + hex; |
||||
} |
||||
// 3 characters: double each (e.g., "ABC" → "AABBCC") |
||||
else if (/^[a-f\d]{3}$/i.test(hex)) { |
||||
hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2]; |
||||
} |
||||
// 4 characters: double each with alpha (e.g., "ABCD" → "AABBCCDD") |
||||
else if (/^[a-f\d]{4}$/i.test(hex)) { |
||||
hex = |
||||
hex[0] + |
||||
hex[0] + |
||||
hex[1] + |
||||
hex[1] + |
||||
hex[2] + |
||||
hex[2] + |
||||
hex[3] + |
||||
hex[3]; |
||||
} |
||||
// 6 characters: full hex |
||||
else if (/^[a-f\d]{6}$/i.test(hex)) { |
||||
// Already valid |
||||
} |
||||
// 8 characters: full hex with alpha |
||||
else if (/^[a-f\d]{8}$/i.test(hex)) { |
||||
// Valid with alpha |
||||
} else { |
||||
return null; // Invalid |
||||
} |
||||
|
||||
return "#" + hex; |
||||
} |
||||
|
||||
// Convert hex to RGB (supports 6 and 8 character hex) |
||||
function hexToRgb(hex: string): { |
||||
r: number; |
||||
g: number; |
||||
b: number; |
||||
a: number; |
||||
} { |
||||
const parsed = parseHexInput(hex); |
||||
if (!parsed) return { r: 255, g: 255, b: 255, a: 100 }; |
||||
|
||||
const clean = parsed.replace("#", ""); |
||||
const r = parseInt(clean.substring(0, 2), 16); |
||||
const g = parseInt(clean.substring(2, 4), 16); |
||||
const b = parseInt(clean.substring(4, 6), 16); |
||||
// Alpha from hex (8 chars) or default to 100% |
||||
const a = |
||||
clean.length === 8 |
||||
? Math.round((parseInt(clean.substring(6, 8), 16) / 255) * 100) |
||||
: 100; |
||||
|
||||
return { r, g, b, a }; |
||||
} |
||||
|
||||
// Convert RGB to hex (always outputs 6 characters, alpha stored separately) |
||||
function rgbToHex(r: number, g: number, b: number): string { |
||||
return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`; |
||||
} |
||||
|
||||
// Convert RGB to HSV |
||||
function rgbToHsv( |
||||
r: number, |
||||
g: number, |
||||
b: number, |
||||
): { h: number; s: number; v: number } { |
||||
r /= 255; |
||||
g /= 255; |
||||
b /= 255; |
||||
const max = Math.max(r, g, b), |
||||
min = Math.min(r, g, b); |
||||
const v = max; |
||||
const d = max - min; |
||||
const s = max === 0 ? 0 : d / max; |
||||
let h = 0; |
||||
if (max !== min) { |
||||
switch (max) { |
||||
case r: |
||||
h = ((g - b) / d + (g < b ? 6 : 0)) / 6; |
||||
break; |
||||
case g: |
||||
h = ((b - r) / d + 2) / 6; |
||||
break; |
||||
case b: |
||||
h = ((r - g) / d + 4) / 6; |
||||
break; |
||||
} |
||||
} |
||||
return { |
||||
h: Math.round(h * 360), |
||||
s: Math.round(s * 100), |
||||
v: Math.round(v * 100), |
||||
}; |
||||
} |
||||
|
||||
// Convert HSV to RGB |
||||
function hsvToRgb( |
||||
h: number, |
||||
s: number, |
||||
v: number, |
||||
): { r: number; g: number; b: number } { |
||||
s /= 100; |
||||
v /= 100; |
||||
const c = v * s; |
||||
const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); |
||||
const m = v - c; |
||||
let r1 = 0, |
||||
g1 = 0, |
||||
b1 = 0; |
||||
if (h < 60) { |
||||
r1 = c; |
||||
g1 = x; |
||||
} else if (h < 120) { |
||||
r1 = x; |
||||
g1 = c; |
||||
} else if (h < 180) { |
||||
g1 = c; |
||||
b1 = x; |
||||
} else if (h < 240) { |
||||
g1 = x; |
||||
b1 = c; |
||||
} else if (h < 300) { |
||||
r1 = x; |
||||
b1 = c; |
||||
} else { |
||||
r1 = c; |
||||
b1 = x; |
||||
} |
||||
return { |
||||
r: Math.round((r1 + m) * 255), |
||||
g: Math.round((g1 + m) * 255), |
||||
b: Math.round((b1 + m) * 255), |
||||
}; |
||||
} |
||||
|
||||
// Convert RGB to HSL |
||||
function rgbToHsl( |
||||
r: number, |
||||
g: number, |
||||
b: number, |
||||
): { h: number; s: number; l: number } { |
||||
r /= 255; |
||||
g /= 255; |
||||
b /= 255; |
||||
const max = Math.max(r, g, b), |
||||
min = Math.min(r, g, b); |
||||
const l = (max + min) / 2; |
||||
let h = 0, |
||||
s = 0; |
||||
if (max !== min) { |
||||
const d = max - min; |
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min); |
||||
switch (max) { |
||||
case r: |
||||
h = ((g - b) / d + (g < b ? 6 : 0)) / 6; |
||||
break; |
||||
case g: |
||||
h = ((b - r) / d + 2) / 6; |
||||
break; |
||||
case b: |
||||
h = ((r - g) / d + 4) / 6; |
||||
break; |
||||
} |
||||
} |
||||
return { |
||||
h: Math.round(h * 360), |
||||
s: Math.round(s * 100), |
||||
l: Math.round(l * 100), |
||||
}; |
||||
} |
||||
|
||||
// Convert HSL to RGB |
||||
function hslToRgb( |
||||
h: number, |
||||
s: number, |
||||
l: number, |
||||
): { r: number; g: number; b: number } { |
||||
s /= 100; |
||||
l /= 100; |
||||
const c = (1 - Math.abs(2 * l - 1)) * s; |
||||
const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); |
||||
const m = l - c / 2; |
||||
let r1 = 0, |
||||
g1 = 0, |
||||
b1 = 0; |
||||
if (h < 60) { |
||||
r1 = c; |
||||
g1 = x; |
||||
} else if (h < 120) { |
||||
r1 = x; |
||||
g1 = c; |
||||
} else if (h < 180) { |
||||
g1 = c; |
||||
b1 = x; |
||||
} else if (h < 240) { |
||||
g1 = x; |
||||
b1 = c; |
||||
} else if (h < 300) { |
||||
r1 = x; |
||||
b1 = c; |
||||
} else { |
||||
r1 = c; |
||||
b1 = x; |
||||
} |
||||
return { |
||||
r: Math.round((r1 + m) * 255), |
||||
g: Math.round((g1 + m) * 255), |
||||
b: Math.round((b1 + m) * 255), |
||||
}; |
||||
} |
||||
|
||||
// Sync all values from hex |
||||
function syncFromHex(hex: string) { |
||||
const rgba = hexToRgb(hex); |
||||
r = rgba.r; |
||||
g = rgba.g; |
||||
b = rgba.b; |
||||
alpha = rgba.a; |
||||
const hsv = rgbToHsv(r, g, b); |
||||
hsvH = hsv.h; |
||||
hsvS = hsv.s; |
||||
hsvV = hsv.v; |
||||
const hsl = rgbToHsl(r, g, b); |
||||
hslH = hsl.h; |
||||
hslS = hsl.s; |
||||
hslL = hsl.l; |
||||
gray = Math.round((r + g + b) / 3); |
||||
hexInput = rgbToHex(r, g, b); |
||||
} |
||||
|
||||
// Initialize from value |
||||
$effect(() => { |
||||
if (!isOpen) { |
||||
syncFromHex(value); |
||||
} |
||||
}); |
||||
|
||||
// Get output color with alpha if not 100% |
||||
function getOutputColor(): string { |
||||
const hex = rgbToHex(r, g, b); |
||||
if (alpha < 100) { |
||||
const alphaHex = Math.round((alpha / 100) * 255) |
||||
.toString(16) |
||||
.padStart(2, "0"); |
||||
return hex + alphaHex; |
||||
} |
||||
return hex; |
||||
} |
||||
|
||||
function applyColor(hex: string, updateAlphaFromHex = true) { |
||||
const rgba = hexToRgb(hex); |
||||
r = rgba.r; |
||||
g = rgba.g; |
||||
b = rgba.b; |
||||
// Update alpha from hex if it's 8 chars |
||||
if (updateAlphaFromHex && hex.replace("#", "").length === 8) { |
||||
alpha = rgba.a; |
||||
} |
||||
const hsv = rgbToHsv(r, g, b); |
||||
hsvH = hsv.h; |
||||
hsvS = hsv.s; |
||||
hsvV = hsv.v; |
||||
const hsl = rgbToHsl(r, g, b); |
||||
hslH = hsl.h; |
||||
hslS = hsl.s; |
||||
hslL = hsl.l; |
||||
gray = Math.round((r + g + b) / 3); |
||||
// Update output value with alpha |
||||
value = getOutputColor(); |
||||
hexInput = rgbToHex(r, g, b); |
||||
onchange?.(value); |
||||
} |
||||
|
||||
function updateFromHex() { |
||||
const parsed = parseHexInput(hexInput); |
||||
if (parsed) { |
||||
applyColor(parsed, true); |
||||
} |
||||
} |
||||
|
||||
function updateFromRgb() { |
||||
r = Math.max(0, Math.min(255, r)); |
||||
g = Math.max(0, Math.min(255, g)); |
||||
b = Math.max(0, Math.min(255, b)); |
||||
applyColor(rgbToHex(r, g, b), false); |
||||
} |
||||
|
||||
function updateFromHsv() { |
||||
const rgb = hsvToRgb(hsvH, hsvS, hsvV); |
||||
applyColor(rgbToHex(rgb.r, rgb.g, rgb.b), false); |
||||
} |
||||
|
||||
function updateFromHsl() { |
||||
const rgb = hslToRgb(hslH, hslS, hslL); |
||||
applyColor(rgbToHex(rgb.r, rgb.g, rgb.b), false); |
||||
} |
||||
|
||||
function updateFromAlpha() { |
||||
value = getOutputColor(); |
||||
onchange?.(value); |
||||
} |
||||
|
||||
function updateFromGray() { |
||||
const v = Math.max(0, Math.min(255, gray)); |
||||
applyColor(rgbToHex(v, v, v)); |
||||
} |
||||
|
||||
// Handle saturation/value picker click (HSV-based) |
||||
function handleSVPick(e: MouseEvent) { |
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); |
||||
hsvS = Math.round( |
||||
Math.max( |
||||
0, |
||||
Math.min(100, ((e.clientX - rect.left) / rect.width) * 100), |
||||
), |
||||
); |
||||
hsvV = Math.round( |
||||
Math.max( |
||||
0, |
||||
Math.min( |
||||
100, |
||||
100 - ((e.clientY - rect.top) / rect.height) * 100, |
||||
), |
||||
), |
||||
); |
||||
updateFromHsv(); |
||||
} |
||||
|
||||
// Slider refs for global drag tracking |
||||
let hueSliderRect: DOMRect | null = null; |
||||
let opacitySliderRect: DOMRect | null = null; |
||||
let svPickerRect: DOMRect | null = null; |
||||
let dragging: "hue" | "opacity" | "sv" | null = null; |
||||
|
||||
// Calculate hue from mouse position |
||||
function calcHue(clientX: number, rect: DOMRect) { |
||||
const padding = rect.width * 0.02; |
||||
const usableWidth = rect.width * 0.96; |
||||
const relativeX = clientX - rect.left - padding; |
||||
return Math.round( |
||||
Math.max(0, Math.min(359, (relativeX / usableWidth) * 359)), |
||||
); |
||||
} |
||||
|
||||
// Calculate opacity from mouse position |
||||
function calcOpacity(clientX: number, rect: DOMRect) { |
||||
const padding = rect.width * 0.02; |
||||
const usableWidth = rect.width * 0.96; |
||||
const relativeX = clientX - rect.left - padding; |
||||
return Math.round( |
||||
Math.max(0, Math.min(100, (relativeX / usableWidth) * 100)), |
||||
); |
||||
} |
||||
|
||||
// Handle hue slider |
||||
function handleHuePick(e: MouseEvent) { |
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); |
||||
hueSliderRect = rect; |
||||
dragging = "hue"; |
||||
hsvH = calcHue(e.clientX, rect); |
||||
updateFromHsv(); |
||||
} |
||||
|
||||
// Handle opacity slider |
||||
function handleOpacityPick(e: MouseEvent) { |
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); |
||||
opacitySliderRect = rect; |
||||
dragging = "opacity"; |
||||
alpha = calcOpacity(e.clientX, rect); |
||||
updateFromAlpha(); |
||||
} |
||||
|
||||
// Handle SV picker |
||||
function handleSVPickStart(e: MouseEvent) { |
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); |
||||
svPickerRect = rect; |
||||
dragging = "sv"; |
||||
handleSVPick(e); |
||||
} |
||||
|
||||
// Global mouse move handler |
||||
function handleGlobalMouseMove(e: MouseEvent) { |
||||
if (!dragging) return; |
||||
|
||||
if (dragging === "hue" && hueSliderRect) { |
||||
hsvH = calcHue(e.clientX, hueSliderRect); |
||||
updateFromHsv(); |
||||
} else if (dragging === "opacity" && opacitySliderRect) { |
||||
alpha = calcOpacity(e.clientX, opacitySliderRect); |
||||
updateFromAlpha(); |
||||
} else if (dragging === "sv" && svPickerRect) { |
||||
const rect = svPickerRect; |
||||
hsvS = Math.round( |
||||
Math.max( |
||||
0, |
||||
Math.min(100, ((e.clientX - rect.left) / rect.width) * 100), |
||||
), |
||||
); |
||||
hsvV = Math.round( |
||||
Math.max( |
||||
0, |
||||
Math.min( |
||||
100, |
||||
100 - ((e.clientY - rect.top) / rect.height) * 100, |
||||
), |
||||
), |
||||
); |
||||
updateFromHsv(); |
||||
} |
||||
} |
||||
|
||||
// Global mouse up handler |
||||
function handleGlobalMouseUp() { |
||||
dragging = null; |
||||
hueSliderRect = null; |
||||
opacitySliderRect = null; |
||||
svPickerRect = null; |
||||
} |
||||
|
||||
// Add/remove global listeners when modal opens/closes |
||||
$effect(() => { |
||||
if (isOpen) { |
||||
document.addEventListener("mousemove", handleGlobalMouseMove); |
||||
document.addEventListener("mouseup", handleGlobalMouseUp); |
||||
} |
||||
return () => { |
||||
document.removeEventListener("mousemove", handleGlobalMouseMove); |
||||
document.removeEventListener("mouseup", handleGlobalMouseUp); |
||||
}; |
||||
}); |
||||
|
||||
// Confirmation dialog state |
||||
let showConfirmClose = $state(false); |
||||
let originalValue = $state(value); |
||||
|
||||
function togglePicker() { |
||||
if (!isOpen) { |
||||
originalValue = value; // Store original when opening |
||||
} |
||||
isOpen = !isOpen; |
||||
} |
||||
|
||||
function handleCloseClick() { |
||||
// Only show confirmation if value changed |
||||
if (value !== originalValue) { |
||||
showConfirmClose = true; |
||||
} else { |
||||
closePicker(); |
||||
} |
||||
} |
||||
|
||||
function confirmClose() { |
||||
showConfirmClose = false; |
||||
// Revert to original value |
||||
value = originalValue; |
||||
syncFromHex(originalValue); |
||||
onchange?.(originalValue); |
||||
isOpen = false; |
||||
} |
||||
|
||||
function cancelClose() { |
||||
showConfirmClose = false; |
||||
} |
||||
|
||||
function closePicker() { |
||||
isOpen = false; |
||||
} |
||||
|
||||
// Portal action - moves element to body |
||||
function portal(node: HTMLElement) { |
||||
document.body.appendChild(node); |
||||
return { |
||||
destroy() { |
||||
if (node.parentNode) { |
||||
node.parentNode.removeChild(node); |
||||
} |
||||
}, |
||||
}; |
||||
} |
||||
</script> |
||||
|
||||
<!-- Color Swatch Button --> |
||||
<button |
||||
class="w-10 h-10 border-4 border-black cursor-pointer" |
||||
style="background-color: {value};" |
||||
onclick={togglePicker} |
||||
type="button" |
||||
aria-label="Pick color" |
||||
></button> |
||||
|
||||
<!-- Picker Modal - rendered via portal to body --> |
||||
{#if isOpen} |
||||
<div use:portal> |
||||
<!-- Backdrop --> |
||||
<div |
||||
class="fixed inset-0 bg-kv-background/50 z-[60]" |
||||
onclick={closePicker} |
||||
role="button" |
||||
tabindex="-1" |
||||
onkeydown={(e) => e.key === "Escape" && closePicker()} |
||||
></div> |
||||
|
||||
<!-- Modal centered on screen --> |
||||
<div |
||||
class="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-[70] |
||||
bg-kv-blue border-4 md:border-8 lg:border-[16px] border-kv-black |
||||
p-3 md:p-6 lg:p-8 w-[95vw] max-w-[320px] md:max-w-[400px] lg:max-w-[450px] |
||||
flex flex-col gap-3 md:gap-4 lg:gap-6 |
||||
max-h-[90vh] overflow-y-auto" |
||||
> |
||||
<!-- Header with Title and Close Button --> |
||||
<div class="flex items-start justify-between w-full"> |
||||
<h2 |
||||
class="text-xl md:text-[28px] text-kv-white uppercase font-kv-body kv-shadow-text" |
||||
> |
||||
{m.kv_color_picker()} |
||||
</h2> |
||||
<button |
||||
onclick={handleCloseClick} |
||||
class="w-6 h-6 cursor-pointer bg-transparent border-none p-0 hover:opacity-70" |
||||
aria-label="Close" |
||||
> |
||||
<svg |
||||
viewBox="0 0 24 24" |
||||
fill="none" |
||||
stroke="currentColor" |
||||
stroke-width="2" |
||||
class="w-full h-full text-kv-yellow" |
||||
> |
||||
<path d="M18 6L6 18M6 6l12 12" /> |
||||
</svg> |
||||
</button> |
||||
</div> |
||||
|
||||
<!-- Saturation/Value Picker (HSV-based) --> |
||||
<div |
||||
class="relative w-full h-32 md:h-48 lg:h-56 cursor-crosshair border-2 md:border-4 border-black shrink-0 select-none" |
||||
style="background: linear-gradient(to top, black, transparent), |
||||
linear-gradient(to right, white, hsl({hsvH}, 100%, 50%));" |
||||
onmousedown={handleSVPickStart} |
||||
role="slider" |
||||
tabindex="0" |
||||
aria-label="Saturation and Value" |
||||
aria-valuenow={hsvS} |
||||
> |
||||
<!-- Picker Indicator --> |
||||
<div |
||||
class="absolute w-5 h-5 border-2 border-white rounded-full -translate-x-1/2 -translate-y-1/2 pointer-events-none" |
||||
style="left: {hsvS}%; top: {100 - |
||||
hsvV}%; box-shadow: 0 0 3px black;" |
||||
></div> |
||||
</div> |
||||
|
||||
<!-- Hue Slider --> |
||||
<div |
||||
class="relative w-full h-6 md:h-8 cursor-pointer border-2 md:border-4 border-black shrink-0 select-none" |
||||
style="background: linear-gradient(to right, hsl(0,100%,50%), hsl(60,100%,50%), hsl(120,100%,50%), hsl(180,100%,50%), hsl(240,100%,50%), hsl(300,100%,50%), hsl(359,100%,50%));" |
||||
onmousedown={handleHuePick} |
||||
role="slider" |
||||
tabindex="0" |
||||
aria-label="Hue" |
||||
aria-valuenow={hsvH} |
||||
> |
||||
<div |
||||
class="absolute top-0 bottom-0 w-2 md:w-3 border-2 border-white -translate-x-1/2 pointer-events-none" |
||||
style="left: calc(2% + {(hsvH / 359) * |
||||
96}%); box-shadow: 0 0 3px black;" |
||||
></div> |
||||
</div> |
||||
|
||||
<!-- Opacity Slider --> |
||||
<div> |
||||
<div class="flex justify-between items-center mb-1"> |
||||
<span |
||||
class="text-sm md:text-base text-kv-white uppercase font-kv-body kv-shadow-text" |
||||
> |
||||
{m.kv_opacity()} |
||||
</span> |
||||
<span |
||||
class="text-sm md:text-base text-kv-white font-kv-body" |
||||
> |
||||
{alpha}% |
||||
</span> |
||||
</div> |
||||
<div |
||||
class="relative w-full h-6 md:h-8 cursor-pointer border-2 md:border-4 border-black shrink-0 select-none" |
||||
style="background: linear-gradient(to right, transparent, {rgbToHex( |
||||
r, |
||||
g, |
||||
b, |
||||
)}), |
||||
repeating-conic-gradient(#808080 0% 25%, #fff 0% 50%) 50% / 16px 16px;" |
||||
onmousedown={handleOpacityPick} |
||||
role="slider" |
||||
tabindex="0" |
||||
aria-label="Opacity" |
||||
aria-valuenow={alpha} |
||||
> |
||||
<div |
||||
class="absolute top-0 bottom-0 w-2 md:w-3 border-2 border-white -translate-x-1/2 pointer-events-none" |
||||
style="left: calc(2% + {alpha * |
||||
0.96}%); box-shadow: 0 0 3px black;" |
||||
></div> |
||||
</div> |
||||
</div> |
||||
|
||||
<!-- Preview & HEX --> |
||||
<div class="flex gap-2 md:gap-4 items-center"> |
||||
<div |
||||
class="w-12 h-12 md:w-16 md:h-16 border-2 md:border-4 border-black shrink-0" |
||||
style="background-color: rgba({r}, {g}, {b}, {alpha / |
||||
100}); |
||||
background-image: repeating-conic-gradient(#808080 0% 25%, #fff 0% 50%); |
||||
background-size: 8px 8px; |
||||
background-blend-mode: normal;" |
||||
> |
||||
<div |
||||
class="w-full h-full" |
||||
style="background-color: rgba({r}, {g}, {b}, {alpha / |
||||
100});" |
||||
></div> |
||||
</div> |
||||
<div class="flex-1"> |
||||
<span |
||||
class="text-sm md:text-base text-kv-white uppercase font-kv-body kv-shadow-text" |
||||
>HEX</span |
||||
> |
||||
<input |
||||
type="text" |
||||
bind:value={hexInput} |
||||
oninput={updateFromHex} |
||||
onblur={updateFromHex} |
||||
onkeydown={(e) => e.key === "Enter" && updateFromHex()} |
||||
class="w-full bg-kv-black border-2 md:border-4 border-black px-2 md:px-3 py-1.5 md:py-2 text-kv-white font-kv-body text-base md:text-lg uppercase" |
||||
maxlength="9" |
||||
placeholder="#RRGGBB" |
||||
/> |
||||
</div> |
||||
</div> |
||||
|
||||
<!-- RGB --> |
||||
<div> |
||||
<span |
||||
class="text-sm md:text-base text-kv-white uppercase font-kv-body kv-shadow-text" |
||||
>RGB</span |
||||
> |
||||
<div class="grid grid-cols-3 gap-1 md:gap-2"> |
||||
<input |
||||
type="number" |
||||
bind:value={r} |
||||
oninput={updateFromRgb} |
||||
min="0" |
||||
max="255" |
||||
class="w-full bg-kv-black border-2 md:border-4 border-black px-1 md:px-2 py-1 md:py-2 text-kv-white font-kv-body text-sm md:text-base text-center" |
||||
/> |
||||
<input |
||||
type="number" |
||||
bind:value={g} |
||||
oninput={updateFromRgb} |
||||
min="0" |
||||
max="255" |
||||
class="w-full bg-kv-black border-2 md:border-4 border-black px-1 md:px-2 py-1 md:py-2 text-kv-white font-kv-body text-sm md:text-base text-center" |
||||
/> |
||||
<input |
||||
type="number" |
||||
bind:value={b} |
||||
oninput={updateFromRgb} |
||||
min="0" |
||||
max="255" |
||||
class="w-full bg-kv-black border-2 md:border-4 border-black px-1 md:px-2 py-1 md:py-2 text-kv-white font-kv-body text-sm md:text-base text-center" |
||||
/> |
||||
</div> |
||||
</div> |
||||
|
||||
<!-- HSV --> |
||||
<div> |
||||
<span |
||||
class="text-sm md:text-base text-kv-white uppercase font-kv-body kv-shadow-text" |
||||
>HSV</span |
||||
> |
||||
<div class="grid grid-cols-3 gap-1 md:gap-2"> |
||||
<input |
||||
type="number" |
||||
bind:value={hsvH} |
||||
oninput={updateFromHsv} |
||||
min="0" |
||||
max="360" |
||||
class="w-full bg-kv-black border-2 md:border-4 border-black px-1 md:px-2 py-1 md:py-2 text-kv-white font-kv-body text-sm md:text-base text-center" |
||||
/> |
||||
<input |
||||
type="number" |
||||
bind:value={hsvS} |
||||
oninput={updateFromHsv} |
||||
min="0" |
||||
max="100" |
||||
class="w-full bg-kv-black border-2 md:border-4 border-black px-1 md:px-2 py-1 md:py-2 text-kv-white font-kv-body text-sm md:text-base text-center" |
||||
/> |
||||
<input |
||||
type="number" |
||||
bind:value={hsvV} |
||||
oninput={updateFromHsv} |
||||
min="0" |
||||
max="100" |
||||
class="w-full bg-kv-black border-2 md:border-4 border-black px-1 md:px-2 py-1 md:py-2 text-kv-white font-kv-body text-sm md:text-base text-center" |
||||
/> |
||||
</div> |
||||
</div> |
||||
|
||||
<!-- HSL --> |
||||
<div> |
||||
<span |
||||
class="text-sm md:text-base text-kv-white uppercase font-kv-body kv-shadow-text" |
||||
>HSL</span |
||||
> |
||||
<div class="grid grid-cols-3 gap-1 md:gap-2"> |
||||
<input |
||||
type="number" |
||||
bind:value={hslH} |
||||
oninput={updateFromHsl} |
||||
min="0" |
||||
max="360" |
||||
class="w-full bg-kv-black border-2 md:border-4 border-black px-1 md:px-2 py-1 md:py-2 text-kv-white font-kv-body text-sm md:text-base text-center" |
||||
/> |
||||
<input |
||||
type="number" |
||||
bind:value={hslS} |
||||
oninput={updateFromHsl} |
||||
min="0" |
||||
max="100" |
||||
class="w-full bg-kv-black border-2 md:border-4 border-black px-1 md:px-2 py-1 md:py-2 text-kv-white font-kv-body text-sm md:text-base text-center" |
||||
/> |
||||
<input |
||||
type="number" |
||||
bind:value={hslL} |
||||
oninput={updateFromHsl} |
||||
min="0" |
||||
max="100" |
||||
class="w-full bg-kv-black border-2 md:border-4 border-black px-1 md:px-2 py-1 md:py-2 text-kv-white font-kv-body text-sm md:text-base text-center" |
||||
/> |
||||
</div> |
||||
</div> |
||||
|
||||
<!-- Grayscale --> |
||||
<div> |
||||
<span |
||||
class="text-sm md:text-base text-kv-white uppercase font-kv-body kv-shadow-text" |
||||
>Grayscale</span |
||||
> |
||||
<input |
||||
type="number" |
||||
bind:value={gray} |
||||
oninput={updateFromGray} |
||||
min="0" |
||||
max="255" |
||||
class="w-full bg-kv-black border-2 md:border-4 border-black px-1 md:px-2 py-1 md:py-2 text-kv-white font-kv-body text-sm md:text-base text-center" |
||||
/> |
||||
</div> |
||||
|
||||
<!-- Done Button --> |
||||
<button |
||||
onclick={closePicker} |
||||
class="bg-kv-yellow border-4 border-black font-kv-body text-black uppercase px-6 py-3 cursor-pointer hover:opacity-80 text-xl kv-shadow-button" |
||||
> |
||||
{m.kv_done()} |
||||
</button> |
||||
</div> |
||||
|
||||
<!-- Confirmation Dialog --> |
||||
<ConfirmDialog |
||||
bind:open={showConfirmClose} |
||||
title={m.kv_confirm_close_title()} |
||||
message={m.kv_confirm_close_message()} |
||||
confirmText={m.kv_confirm_discard()} |
||||
cancelText={m.kv_confirm_cancel()} |
||||
onconfirm={confirmClose} |
||||
oncancel={cancelClose} |
||||
/> |
||||
</div> |
||||
{/if} |
||||
@ -0,0 +1,81 @@ |
||||
<script lang="ts"> |
||||
import * as m from "$lib/paraglide/messages"; |
||||
|
||||
interface ConfirmDialogProps { |
||||
open?: boolean; |
||||
title?: string; |
||||
message?: string; |
||||
confirmText?: string; |
||||
cancelText?: string; |
||||
onconfirm?: () => void; |
||||
oncancel?: () => void; |
||||
} |
||||
|
||||
let { |
||||
open = $bindable(false), |
||||
title = m.kv_confirm_close_title(), |
||||
message = m.kv_confirm_close_message(), |
||||
confirmText = m.kv_confirm_discard(), |
||||
cancelText = m.kv_confirm_cancel(), |
||||
onconfirm, |
||||
oncancel, |
||||
}: ConfirmDialogProps = $props(); |
||||
|
||||
function handleConfirm() { |
||||
open = false; |
||||
onconfirm?.(); |
||||
} |
||||
|
||||
function handleCancel() { |
||||
open = false; |
||||
oncancel?.(); |
||||
} |
||||
|
||||
function handleKeydown(e: KeyboardEvent) { |
||||
if (e.key === "Escape") handleCancel(); |
||||
} |
||||
</script> |
||||
|
||||
<svelte:window onkeydown={open ? handleKeydown : undefined} /> |
||||
|
||||
{#if open} |
||||
<!-- Backdrop --> |
||||
<div |
||||
class="fixed inset-0 bg-kv-background/70 z-[100]" |
||||
onclick={handleCancel} |
||||
role="button" |
||||
tabindex="-1" |
||||
onkeydown={(e) => e.key === "Enter" && handleCancel()} |
||||
></div> |
||||
|
||||
<!-- Dialog --> |
||||
<div |
||||
class="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-[110] |
||||
bg-kv-blue border-4 md:border-8 border-kv-black |
||||
p-4 md:p-6 w-[90vw] max-w-[380px] |
||||
flex flex-col gap-4 items-center text-center" |
||||
> |
||||
<h3 |
||||
class="text-xl md:text-2xl text-kv-white uppercase font-kv-body kv-shadow-text" |
||||
> |
||||
{title} |
||||
</h3> |
||||
<p class="text-sm md:text-base text-kv-white font-kv-body"> |
||||
{message} |
||||
</p> |
||||
<div class="flex gap-3 w-full"> |
||||
<button |
||||
onclick={handleCancel} |
||||
class="flex-1 bg-kv-blue border-4 border-black font-kv-body text-kv-white uppercase px-4 py-3 cursor-pointer hover:opacity-80 kv-shadow-button" |
||||
> |
||||
<span class="kv-shadow-text">{cancelText}</span> |
||||
</button> |
||||
<button |
||||
onclick={handleConfirm} |
||||
class="flex-1 bg-kv-yellow border-4 border-black font-kv-body text-black uppercase px-4 py-3 cursor-pointer hover:opacity-80 kv-shadow-button" |
||||
> |
||||
{confirmText} |
||||
</button> |
||||
</div> |
||||
</div> |
||||
{/if} |
||||
@ -0,0 +1,208 @@ |
||||
<script lang="ts"> |
||||
import * as m from "$lib/paraglide/messages"; |
||||
import KvNumberInput from "./KvNumberInput.svelte"; |
||||
|
||||
interface Props { |
||||
name: string; |
||||
score: number; |
||||
scoreAdjustment?: number; |
||||
answering?: boolean; |
||||
selectable?: boolean; |
||||
// Final round mode |
||||
finalMode?: boolean; |
||||
finalJudged?: boolean; |
||||
finalActive?: boolean; |
||||
onFinalJudge?: (correct: boolean, wager: number) => void; |
||||
// Event handlers |
||||
onSelect?: () => void; |
||||
onAdd?: () => void; |
||||
onRemove?: () => void; |
||||
onCorrect?: () => void; |
||||
onWrong?: () => void; |
||||
} |
||||
|
||||
let { |
||||
name, |
||||
score, |
||||
scoreAdjustment = 100, |
||||
answering = false, |
||||
selectable = false, |
||||
finalMode = false, |
||||
finalJudged = false, |
||||
finalActive = false, |
||||
onFinalJudge, |
||||
onSelect, |
||||
onAdd, |
||||
onRemove, |
||||
onCorrect, |
||||
onWrong, |
||||
}: Props = $props(); |
||||
|
||||
// Final round state |
||||
let finalAnswerCorrect = $state<boolean | null>(null); |
||||
let finalWagerInput = $state(0); |
||||
</script> |
||||
|
||||
<div |
||||
class="group relative bg-kv-blue flex flex-col gap-4 items-center justify-center p-4 flex-1 min-w-0 box-border |
||||
{answering || finalActive |
||||
? 'border-8 border-kv-yellow' |
||||
: 'border-8 border-transparent'} |
||||
{selectable && !finalJudged ? 'cursor-pointer' : ''} |
||||
{finalJudged ? 'opacity-60' : ''}" |
||||
onclick={selectable && !finalJudged ? onSelect : undefined} |
||||
onkeydown={selectable && !finalJudged |
||||
? (e) => e.key === "Enter" && onSelect?.() |
||||
: undefined} |
||||
role={selectable && !finalJudged ? "button" : undefined} |
||||
tabindex={selectable && !finalJudged ? 0 : undefined} |
||||
> |
||||
<!-- Hover overlay - darkens background only, extends to cover border --> |
||||
{#if selectable && !finalJudged} |
||||
<div |
||||
class="absolute -inset-2 bg-black opacity-0 group-hover:opacity-20 transition-opacity pointer-events-none z-0" |
||||
></div> |
||||
{/if} |
||||
|
||||
<!-- Name and Score --> |
||||
<div |
||||
class="relative z-10 flex flex-col gap-4 items-center font-kv-body text-kv-white text-center uppercase w-full |
||||
{selectable && !finalJudged |
||||
? 'group-hover:text-kv-yellow transition-colors' |
||||
: ''}" |
||||
> |
||||
<span class="text-2xl md:text-4xl kv-shadow-text">{name}</span> |
||||
<span |
||||
class="text-2xl md:text-4xl kv-shadow-text {score < 0 |
||||
? 'text-kv-red' |
||||
: ''}">{score}€</span |
||||
> |
||||
{#if finalJudged} |
||||
<span class="text-sm text-kv-green">✓ {m.kv_play_judged()}</span> |
||||
{/if} |
||||
</div> |
||||
|
||||
<!-- Action Buttons --> |
||||
<div class="relative z-10 flex flex-col gap-2 items-center justify-center"> |
||||
{#if finalMode && finalActive} |
||||
<!-- Final round judging UI --> |
||||
{#if finalAnswerCorrect === null} |
||||
<div class="flex gap-2"> |
||||
<button |
||||
onclick={() => (finalAnswerCorrect = true)} |
||||
class="bg-kv-green border-4 border-black box-border flex items-center justify-center p-2 cursor-pointer hover:opacity-80" |
||||
> |
||||
<span |
||||
class="font-kv-body text-xl md:text-[28px] text-kv-white uppercase kv-shadow-text" |
||||
> |
||||
{m.kv_play_correct()} |
||||
</span> |
||||
</button> |
||||
<button |
||||
onclick={() => (finalAnswerCorrect = false)} |
||||
class="bg-kv-red border-4 border-black box-border flex items-center justify-center p-2 cursor-pointer hover:opacity-80" |
||||
> |
||||
<span |
||||
class="font-kv-body text-xl md:text-[28px] text-kv-white uppercase kv-shadow-text" |
||||
> |
||||
{m.kv_play_wrong()} |
||||
</span> |
||||
</button> |
||||
</div> |
||||
{:else} |
||||
<!-- Wager input and confirm --> |
||||
<div class="flex items-center gap-2"> |
||||
<KvNumberInput |
||||
bind:value={finalWagerInput} |
||||
min={0} |
||||
max={Math.max(score, 0)} |
||||
step={100} |
||||
/> |
||||
<span class="font-kv-body text-lg text-kv-white">€</span> |
||||
</div> |
||||
<button |
||||
onclick={() => { |
||||
onFinalJudge?.(finalAnswerCorrect!, finalWagerInput); |
||||
finalAnswerCorrect = null; |
||||
finalWagerInput = 0; |
||||
}} |
||||
class="bg-kv-yellow border-4 border-black box-border font-kv-body text-lg text-black uppercase px-4 py-1 cursor-pointer hover:opacity-80" |
||||
> |
||||
{m.kv_play_confirm()} |
||||
</button> |
||||
{/if} |
||||
{:else if finalMode && !finalJudged && !finalActive} |
||||
<!-- Final mode but not active - show score adjustment buttons --> |
||||
<div class="flex gap-4"> |
||||
<button |
||||
onclick={onAdd} |
||||
class="bg-kv-green border-4 border-black box-border flex items-center justify-center p-2 cursor-pointer hover:opacity-80" |
||||
> |
||||
<span |
||||
class="font-kv-body text-xl md:text-[28px] text-kv-white uppercase kv-shadow-text" |
||||
> |
||||
+{scoreAdjustment} |
||||
</span> |
||||
</button> |
||||
<button |
||||
onclick={onRemove} |
||||
class="bg-kv-red border-4 border-black box-border flex items-center justify-center p-2 cursor-pointer hover:opacity-80" |
||||
> |
||||
<span |
||||
class="font-kv-body text-xl md:text-[28px] text-kv-white uppercase kv-shadow-text" |
||||
> |
||||
-{scoreAdjustment} |
||||
</span> |
||||
</button> |
||||
</div> |
||||
{:else if answering} |
||||
<!-- Correct/Wrong buttons when answering --> |
||||
<div class="flex gap-4"> |
||||
<button |
||||
onclick={onCorrect} |
||||
class="bg-kv-green border-4 border-black box-border flex items-center justify-center p-2 cursor-pointer hover:opacity-80" |
||||
> |
||||
<span |
||||
class="font-kv-body text-xl md:text-[28px] text-kv-white uppercase kv-shadow-text" |
||||
> |
||||
{m.kv_play_correct()} |
||||
</span> |
||||
</button> |
||||
<button |
||||
onclick={onWrong} |
||||
class="bg-kv-red border-4 border-black box-border flex items-center justify-center p-2 cursor-pointer hover:opacity-80" |
||||
> |
||||
<span |
||||
class="font-kv-body text-xl md:text-[28px] text-kv-white uppercase kv-shadow-text" |
||||
> |
||||
{m.kv_play_wrong()} |
||||
</span> |
||||
</button> |
||||
</div> |
||||
{:else if !finalMode} |
||||
<!-- Add/Remove score buttons in normal mode --> |
||||
<div class="flex gap-4"> |
||||
<button |
||||
onclick={onAdd} |
||||
class="bg-kv-green border-4 border-black box-border flex items-center justify-center p-2 cursor-pointer hover:opacity-80" |
||||
> |
||||
<span |
||||
class="font-kv-body text-xl md:text-[28px] text-kv-white uppercase kv-shadow-text" |
||||
> |
||||
+{scoreAdjustment} |
||||
</span> |
||||
</button> |
||||
<button |
||||
onclick={onRemove} |
||||
class="bg-kv-red border-4 border-black box-border flex items-center justify-center p-2 cursor-pointer hover:opacity-80" |
||||
> |
||||
<span |
||||
class="font-kv-body text-xl md:text-[28px] text-kv-white uppercase kv-shadow-text" |
||||
> |
||||
-{scoreAdjustment} |
||||
</span> |
||||
</button> |
||||
</div> |
||||
{/if} |
||||
</div> |
||||
</div> |
||||
@ -1,70 +0,0 @@ |
||||
<script lang="ts"> |
||||
import type { Team } from "$lib/types/kuldvillak"; |
||||
|
||||
interface Props { |
||||
team: Team; |
||||
isActive?: boolean; |
||||
isAnswering?: boolean; |
||||
scoreAdjustment?: number; |
||||
onadjust?: (delta: number) => void; |
||||
onclick?: () => void; |
||||
disabled?: boolean; |
||||
class?: string; |
||||
} |
||||
|
||||
let { |
||||
team, |
||||
isActive = false, |
||||
isAnswering = false, |
||||
scoreAdjustment = 100, |
||||
onadjust, |
||||
onclick, |
||||
disabled = false, |
||||
class: className = "", |
||||
}: Props = $props(); |
||||
</script> |
||||
|
||||
<div |
||||
class="flex flex-col gap-4 items-center justify-center p-4 bg-kv-board transition-all |
||||
{isActive ? 'ring-4 ring-kv-yellow' : ''} |
||||
{isAnswering ? 'ring-4 ring-kv-green' : ''} |
||||
{disabled ? 'opacity-50' : ''} |
||||
{className}" |
||||
role={onclick ? "button" : undefined} |
||||
tabindex={onclick ? 0 : undefined} |
||||
{onclick} |
||||
onkeydown={(e) => e.key === "Enter" && onclick?.()} |
||||
> |
||||
<!-- Name and Score --> |
||||
<button |
||||
class="flex flex-col gap-2 items-center font-kv-body text-kv-white uppercase text-center w-full cursor-pointer hover:brightness-110 transition-all |
||||
{disabled ? 'cursor-not-allowed' : ''}" |
||||
{onclick} |
||||
{disabled} |
||||
> |
||||
<span class="text-3xl kv-shadow-text">{team.name}</span> |
||||
<span |
||||
class="text-3xl kv-shadow-text {team.score < 0 |
||||
? 'text-kv-red' |
||||
: ''}">{team.score}€</span |
||||
> |
||||
</button> |
||||
|
||||
<!-- Score Adjustment Buttons --> |
||||
{#if onadjust} |
||||
<div class="flex gap-4 items-center justify-center"> |
||||
<button |
||||
class="flex-1 min-w-[70px] h-10 bg-kv-green border-4 border-black flex items-center justify-center font-kv-body text-xl text-kv-white uppercase cursor-pointer hover:brightness-110 transition-all" |
||||
onclick={() => onadjust(scoreAdjustment)} |
||||
> |
||||
<span class="kv-shadow-text">+{scoreAdjustment}</span> |
||||
</button> |
||||
<button |
||||
class="flex-1 min-w-[70px] h-10 bg-kv-red border-4 border-black flex items-center justify-center font-kv-body text-xl text-kv-white uppercase cursor-pointer hover:brightness-110 transition-all" |
||||
onclick={() => onadjust(-scoreAdjustment)} |
||||
> |
||||
<span class="kv-shadow-text">-{scoreAdjustment}</span> |
||||
</button> |
||||
</div> |
||||
{/if} |
||||
</div> |
||||
@ -1,67 +0,0 @@ |
||||
<script lang="ts"> |
||||
interface Props { |
||||
variant: "category" | "price" | "player"; |
||||
text?: string; |
||||
score?: number; |
||||
isRevealed?: boolean; |
||||
isActive?: boolean; |
||||
class?: string; |
||||
} |
||||
|
||||
let { |
||||
variant, |
||||
text = "", |
||||
score = 0, |
||||
isRevealed = false, |
||||
isActive = false, |
||||
class: className = "", |
||||
}: Props = $props(); |
||||
</script> |
||||
|
||||
{#if variant === "category"} |
||||
<!-- Category Card for Projector --> |
||||
<div |
||||
class="bg-kv-board px-4 py-8 flex items-center justify-center overflow-hidden {className}" |
||||
> |
||||
<span |
||||
class="font-kv-body text-kv-white text-5xl text-center uppercase leading-tight kv-shadow-text" |
||||
> |
||||
{text || "Kategooria"} |
||||
</span> |
||||
</div> |
||||
{:else if variant === "price"} |
||||
<!-- Price Card for Projector --> |
||||
<div |
||||
class="bg-kv-board flex items-center justify-center overflow-hidden {className}" |
||||
> |
||||
{#if isRevealed} |
||||
<span class="opacity-0">{text}</span> |
||||
{:else} |
||||
<span |
||||
class="font-kv-price text-kv-yellow text-7xl text-center kv-shadow-price" |
||||
> |
||||
{text} |
||||
</span> |
||||
{/if} |
||||
</div> |
||||
{:else if variant === "player"} |
||||
<!-- Player Score Card for Projector --> |
||||
<div |
||||
class="bg-kv-board px-4 py-4 flex flex-col items-center justify-center gap-1 overflow-hidden |
||||
{isActive ? 'ring-4 ring-kv-yellow' : ''} |
||||
{className}" |
||||
> |
||||
<span |
||||
class="font-kv-body text-kv-white text-3xl text-center uppercase leading-tight kv-shadow-text" |
||||
> |
||||
{text} |
||||
</span> |
||||
<span |
||||
class="font-kv-body text-3xl text-center kv-shadow-text {score < 0 |
||||
? 'text-red-500' |
||||
: 'text-kv-white'}" |
||||
> |
||||
{score}€ |
||||
</span> |
||||
</div> |
||||
{/if} |
||||
@ -1,363 +0,0 @@ |
||||
// ============================================
|
||||
// Kuldvillak Game State Store (Svelte 5 Runes)
|
||||
// ============================================
|
||||
|
||||
import { |
||||
type KuldvillakGame, |
||||
type Team, |
||||
type Question, |
||||
type GamePhase, |
||||
type Round, |
||||
type Category, |
||||
DEFAULT_SETTINGS, |
||||
DEFAULT_STATE |
||||
} from '$lib/types/kuldvillak'; |
||||
|
||||
// ============================================
|
||||
// Utility Functions
|
||||
// ============================================
|
||||
|
||||
function generateId(): string { |
||||
return crypto.randomUUID(); |
||||
} |
||||
|
||||
function createEmptyGame(name: string = 'New Game'): KuldvillakGame { |
||||
const now = new Date().toISOString(); |
||||
return { |
||||
id: generateId(), |
||||
name, |
||||
createdAt: now, |
||||
updatedAt: now, |
||||
settings: { ...DEFAULT_SETTINGS }, |
||||
teams: [], |
||||
rounds: [], |
||||
finalRound: null, |
||||
state: { ...DEFAULT_STATE } |
||||
}; |
||||
} |
||||
|
||||
function createEmptyRound(name: string, multiplier: number, settings: typeof DEFAULT_SETTINGS): Round { |
||||
const categories: Category[] = []; |
||||
for (let i = 0; i < settings.categoriesPerRound; i++) { |
||||
const questions: Question[] = settings.pointValues.map((points) => ({ |
||||
id: generateId(), |
||||
question: '', |
||||
answer: '', |
||||
points: points * multiplier, |
||||
isDailyDouble: false, |
||||
isRevealed: false |
||||
})); |
||||
categories.push({ |
||||
id: generateId(), |
||||
name: `Category ${i + 1}`, |
||||
questions |
||||
}); |
||||
} |
||||
return { |
||||
id: generateId(), |
||||
name, |
||||
categories, |
||||
pointMultiplier: multiplier |
||||
}; |
||||
} |
||||
|
||||
// ============================================
|
||||
// Game Store Class
|
||||
// ============================================
|
||||
|
||||
class KuldvillakStore { |
||||
// Reactive state using Svelte 5 runes
|
||||
game = $state<KuldvillakGame | null>(null); |
||||
savedGames = $state<KuldvillakGame[]>([]); |
||||
|
||||
// Derived values
|
||||
get currentRound(): Round | null { |
||||
if (!this.game) return null; |
||||
return this.game.rounds[this.game.state.currentRoundIndex] ?? null; |
||||
} |
||||
|
||||
get currentQuestion(): Question | null { |
||||
if (!this.game || !this.currentRound || !this.game.state.currentQuestionId) return null; |
||||
for (const category of this.currentRound.categories) { |
||||
const question = category.questions.find((q) => q.id === this.game!.state.currentQuestionId); |
||||
if (question) return question; |
||||
} |
||||
return null; |
||||
} |
||||
|
||||
get currentCategory(): Category | null { |
||||
if (!this.game || !this.currentRound || !this.game.state.currentCategoryId) return null; |
||||
return this.currentRound.categories.find((c) => c.id === this.game!.state.currentCategoryId) ?? null; |
||||
} |
||||
|
||||
get activeTeam(): Team | null { |
||||
if (!this.game || !this.game.state.activeTeamId) return null; |
||||
return this.game.teams.find((t) => t.id === this.game!.state.activeTeamId) ?? null; |
||||
} |
||||
|
||||
get isRoundComplete(): boolean { |
||||
if (!this.currentRound) return false; |
||||
return this.currentRound.categories.every((c) => c.questions.every((q) => q.isRevealed)); |
||||
} |
||||
|
||||
get isGameComplete(): boolean { |
||||
if (!this.game) return false; |
||||
const allRoundsComplete = this.game.rounds.every((round) => |
||||
round.categories.every((c) => c.questions.every((q) => q.isRevealed)) |
||||
); |
||||
if (!this.game.settings.enableFinalRound) return allRoundsComplete; |
||||
return allRoundsComplete && this.game.state.phase === 'finished'; |
||||
} |
||||
|
||||
// ============================================
|
||||
// Game Lifecycle
|
||||
// ============================================
|
||||
|
||||
newGame(name: string = 'New Game'): void { |
||||
this.game = createEmptyGame(name); |
||||
// Create default 2 rounds (Jeopardy + Double Jeopardy)
|
||||
this.game.rounds = [ |
||||
createEmptyRound('Round 1', 1, this.game.settings), |
||||
createEmptyRound('Round 2', 2, this.game.settings) |
||||
]; |
||||
} |
||||
|
||||
loadGame(gameData: KuldvillakGame): void { |
||||
this.game = gameData; |
||||
} |
||||
|
||||
resetGame(): void { |
||||
if (!this.game) return; |
||||
// Reset all questions to unrevealed
|
||||
for (const round of this.game.rounds) { |
||||
for (const category of round.categories) { |
||||
for (const question of category.questions) { |
||||
question.isRevealed = false; |
||||
} |
||||
} |
||||
} |
||||
// Reset teams scores
|
||||
for (const team of this.game.teams) { |
||||
team.score = 0; |
||||
} |
||||
// Reset state
|
||||
this.game.state = { ...DEFAULT_STATE }; |
||||
} |
||||
|
||||
closeGame(): void { |
||||
this.game = null; |
||||
} |
||||
|
||||
// ============================================
|
||||
// Team Management
|
||||
// ============================================
|
||||
|
||||
addTeam(name: string): void { |
||||
if (!this.game) return; |
||||
this.game.teams.push({ |
||||
id: generateId(), |
||||
name, |
||||
score: 0 |
||||
}); |
||||
} |
||||
|
||||
removeTeam(teamId: string): void { |
||||
if (!this.game) return; |
||||
this.game.teams = this.game.teams.filter((t) => t.id !== teamId); |
||||
} |
||||
|
||||
updateTeamName(teamId: string, name: string): void { |
||||
if (!this.game) return; |
||||
const team = this.game.teams.find((t) => t.id === teamId); |
||||
if (team) team.name = name; |
||||
} |
||||
|
||||
updateTeamScore(teamId: string, score: number): void { |
||||
if (!this.game) return; |
||||
const team = this.game.teams.find((t) => t.id === teamId); |
||||
if (team) team.score = score; |
||||
} |
||||
|
||||
adjustTeamScore(teamId: string, delta: number): void { |
||||
if (!this.game) return; |
||||
const team = this.game.teams.find((t) => t.id === teamId); |
||||
if (team) team.score += delta; |
||||
} |
||||
|
||||
setActiveTeam(teamId: string | null): void { |
||||
if (!this.game) return; |
||||
this.game.state.activeTeamId = teamId; |
||||
} |
||||
|
||||
// ============================================
|
||||
// Game Flow Control
|
||||
// ============================================
|
||||
|
||||
setPhase(phase: GamePhase): void { |
||||
if (!this.game) return; |
||||
this.game.state.phase = phase; |
||||
} |
||||
|
||||
startGame(): void { |
||||
if (!this.game || this.game.teams.length === 0) return; |
||||
this.game.state.phase = 'board'; |
||||
this.game.state.currentRoundIndex = 0; |
||||
} |
||||
|
||||
selectQuestion(categoryId: string, questionId: string): void { |
||||
if (!this.game) return; |
||||
const question = this.findQuestion(questionId); |
||||
if (!question || question.isRevealed) return; |
||||
|
||||
this.game.state.currentCategoryId = categoryId; |
||||
this.game.state.currentQuestionId = questionId; |
||||
|
||||
if (question.isDailyDouble) { |
||||
this.game.state.phase = 'daily-double'; |
||||
} else { |
||||
this.game.state.phase = 'question'; |
||||
} |
||||
} |
||||
|
||||
setDailyDoubleWager(wager: number): void { |
||||
if (!this.game) return; |
||||
this.game.state.dailyDoubleWager = wager; |
||||
this.game.state.phase = 'question'; |
||||
} |
||||
|
||||
revealAnswer(): void { |
||||
if (!this.game) return; |
||||
this.game.state.phase = 'answer'; |
||||
} |
||||
|
||||
markCorrect(teamId: string): void { |
||||
if (!this.game || !this.currentQuestion) return; |
||||
const points = this.game.state.dailyDoubleWager ?? this.currentQuestion.points; |
||||
this.adjustTeamScore(teamId, points); |
||||
this.finishQuestion(); |
||||
} |
||||
|
||||
markIncorrect(teamId: string): void { |
||||
if (!this.game || !this.currentQuestion) return; |
||||
const points = this.game.state.dailyDoubleWager ?? this.currentQuestion.points; |
||||
this.adjustTeamScore(teamId, -points); |
||||
} |
||||
|
||||
finishQuestion(): void { |
||||
if (!this.game || !this.game.state.currentQuestionId) return; |
||||
const question = this.findQuestion(this.game.state.currentQuestionId); |
||||
if (question) question.isRevealed = true; |
||||
|
||||
this.game.state.currentQuestionId = null; |
||||
this.game.state.currentCategoryId = null; |
||||
this.game.state.dailyDoubleWager = null; |
||||
this.game.state.activeTeamId = null; |
||||
|
||||
// Check if round is complete
|
||||
if (this.isRoundComplete) { |
||||
this.advanceRound(); |
||||
} else { |
||||
this.game.state.phase = 'board'; |
||||
} |
||||
} |
||||
|
||||
advanceRound(): void { |
||||
if (!this.game) return; |
||||
const nextIndex = this.game.state.currentRoundIndex + 1; |
||||
if (nextIndex < this.game.rounds.length) { |
||||
this.game.state.currentRoundIndex = nextIndex; |
||||
this.game.state.phase = 'board'; |
||||
} else if (this.game.settings.enableFinalRound && this.game.finalRound) { |
||||
this.game.state.phase = 'final-category'; |
||||
} else { |
||||
this.game.state.phase = 'finished'; |
||||
} |
||||
} |
||||
|
||||
// Final Round
|
||||
startFinalRound(): void { |
||||
if (!this.game) return; |
||||
this.game.state.phase = 'final-question'; |
||||
} |
||||
|
||||
setFinalWager(teamId: string, wager: number): void { |
||||
if (!this.game) return; |
||||
this.game.state.finalWagers[teamId] = wager; |
||||
} |
||||
|
||||
setFinalAnswer(teamId: string, answer: string): void { |
||||
if (!this.game) return; |
||||
this.game.state.finalAnswers[teamId] = answer; |
||||
} |
||||
|
||||
scoreFinalAnswer(teamId: string, correct: boolean): void { |
||||
if (!this.game) return; |
||||
const wager = this.game.state.finalWagers[teamId] ?? 0; |
||||
this.adjustTeamScore(teamId, correct ? wager : -wager); |
||||
} |
||||
|
||||
finishGame(): void { |
||||
if (!this.game) return; |
||||
this.game.state.phase = 'finished'; |
||||
} |
||||
|
||||
// ============================================
|
||||
// Editor Functions
|
||||
// ============================================
|
||||
|
||||
updateGameName(name: string): void { |
||||
if (!this.game) return; |
||||
this.game.name = name; |
||||
this.game.updatedAt = new Date().toISOString(); |
||||
} |
||||
|
||||
updateCategoryName(roundIndex: number, categoryId: string, name: string): void { |
||||
if (!this.game) return; |
||||
const category = this.game.rounds[roundIndex]?.categories.find((c) => c.id === categoryId); |
||||
if (category) category.name = name; |
||||
} |
||||
|
||||
updateQuestion(questionId: string, updates: Partial<Pick<Question, 'question' | 'answer' | 'isDailyDouble'>>): void { |
||||
if (!this.game) return; |
||||
const question = this.findQuestion(questionId); |
||||
if (question) { |
||||
if (updates.question !== undefined) question.question = updates.question; |
||||
if (updates.answer !== undefined) question.answer = updates.answer; |
||||
if (updates.isDailyDouble !== undefined) question.isDailyDouble = updates.isDailyDouble; |
||||
} |
||||
} |
||||
|
||||
updateFinalRound(category: string, question: string, answer: string): void { |
||||
if (!this.game) return; |
||||
this.game.finalRound = { category, question, answer }; |
||||
} |
||||
|
||||
addRound(): void { |
||||
if (!this.game) return; |
||||
const multiplier = this.game.rounds.length + 1; |
||||
this.game.rounds.push(createEmptyRound(`Round ${multiplier}`, multiplier, this.game.settings)); |
||||
} |
||||
|
||||
removeRound(roundIndex: number): void { |
||||
if (!this.game || this.game.rounds.length <= 1) return; |
||||
this.game.rounds.splice(roundIndex, 1); |
||||
} |
||||
|
||||
// ============================================
|
||||
// Helper Functions
|
||||
// ============================================
|
||||
|
||||
private findQuestion(questionId: string): Question | null { |
||||
if (!this.game) return null; |
||||
for (const round of this.game.rounds) { |
||||
for (const category of round.categories) { |
||||
const question = category.questions.find((q) => q.id === questionId); |
||||
if (question) return question; |
||||
} |
||||
} |
||||
return null; |
||||
} |
||||
} |
||||
|
||||
// Export singleton instance
|
||||
export const kuldvillakStore = new KuldvillakStore(); |
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
Loading…
Reference in new issue