diff --git a/.gitignore b/.gitignore index cac8fab..e667969 100644 --- a/.gitignore +++ b/.gitignore @@ -50,11 +50,34 @@ pnpm-debug.log* *.sublime-project *.sublime-workspace *.code-workspace +.vscode/* +!.vscode/settings.json +!.vscode/extensions.json # Testing coverage .nyc_output +# Security - never commit these +*.pem +*.key +*.cert +*.p12 +secrets.* + +# Database files +*.db +*.sqlite +*.sqlite3 + +# AI/Editor state +.windsurfrules +.cursor +.aider* +.continue + # Misc *.local .history +*.bak +*.tmp diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md new file mode 100644 index 0000000..42d2f83 --- /dev/null +++ b/DOCUMENTATION.md @@ -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; + finalAnswers: Record; + 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 + + +{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 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c13f991 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md index 75842c4..e29a8b8 100644 --- a/README.md +++ b/README.md @@ -1,38 +1,73 @@ -# sv +# Ultimate Gaming 🎮 -Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli). +A web-based platform for hosting trivia game shows, featuring **Kuldvillak** (Estonian Jeopardy). -## Creating a project +## Features -If you're seeing this, you've probably already done this step. Congrats! +- ✨ **Game Editor** - Create custom games with categories, questions, and daily doubles +- 🖥️ **Dual-Screen Setup** - Moderator controls + Projector display +- 🔄 **Real-time Sync** - Cross-tab synchronization via BroadcastChannel API +- 🎨 **Theme Customization** - Customize colors to match your brand +- 🌍 **Internationalization** - Estonian and English support +- 💾 **Persistent Storage** - Auto-save and load games from localStorage -```sh -# create a new project in the current directory -npx sv create +## Tech Stack -# create a new project in my-app -npx sv create my-app -``` +- **SvelteKit 2** + **Svelte 5** (Runes) +- **TailwindCSS 4** for styling +- **TypeScript** for type safety +- **Paraglide** for i18n -## Developing +## Getting Started -Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: +```bash +# Install dependencies +npm install -```sh +# Start development server npm run dev -# or start the server and open the app in a new browser tab +# Open in browser npm run dev -- --open ``` -## Building +## Usage + +1. **Create a Game**: Go to `/kuldvillak/edit` to create a new game +2. **Add Teams**: Configure 2-6 players/teams +3. **Fill Content**: Add categories, questions, and answers +4. **Start Playing**: Click "Start" to launch the game +5. **Open Projector**: Use "Open Projector" button for display screen + +## Project Structure -To create a production version of your app: +``` +src/ +├── lib/ +│ ├── components/ # Reusable UI components +│ ├── stores/ # Svelte stores (gameSession, theme, audio) +│ └── types/ # TypeScript interfaces +├── routes/ +│ ├── kuldvillak/ +│ │ ├── edit/ # Game editor +│ │ └── play/ # Game play views +│ └── +page.svelte # Home page +└── app.html +``` -```sh +## Documentation + +See [DOCUMENTATION.md](./DOCUMENTATION.md) for detailed technical documentation. + +## Building for Production + +```bash npm run build +npm run preview ``` -You can preview the production build with `npm run preview`. +## License + +MIT License - Feel free to use, modify, and distribute. -> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment. +*This is a fan-made project inspired by Jeopardy! and its Estonian counterpart Kuldvillak. Not affiliated with or endorsed by the original shows.* diff --git a/messages/en.json b/messages/en.json index 0b2727c..dd2cee4 100644 --- a/messages/en.json +++ b/messages/en.json @@ -18,6 +18,7 @@ "kv_error_404": "404", "kv_error_not_found": "Not Found", "kv_error_hint": "(pssst... make sure you're on the right page)", + "kv_edit_title": "Game Editor", "kv_edit_back": "Back", "kv_edit_game_name": "Game name...", "kv_edit_save": "Save", @@ -141,5 +142,29 @@ "kv_settings_save_exit": "Save and Exit", "kv_edit_image_link": "Image Link", "kv_edit_save_exit": "Save and Exit", - "kv_edit_final_enabled": "Final Round Enabled" + "kv_edit_final_enabled": "Final Round Enabled", + "kv_play_adjust_score": "Adjust score", + "kv_play_click_team_to_answer": "Click a team to select answerer", + "kv_play_final_scores": "Final Scores", + "kv_edit_dd_short": "DD", + "kv_play_timeout_reveal": "Timer ran out, nobody can answer. Revealing answer in {seconds} seconds...", + "kv_play_answer_revealed": "Answer revealed. Returning to board in {seconds} seconds...", + "kv_play_timer_paused": "Timer paused. {name} is answering.", + "kv_play_correct_return": "{name} answered correctly! Returning to board in {seconds} seconds...", + "kv_play_wrong_waiting": "{name} answered incorrectly. Waiting for next player...", + "kv_play_wrong_reveal": "{name} answered incorrectly. Revealing answer in {seconds} seconds...", + "kv_play_skip_reveal": "Skipping question entirely. Revealing answer in {seconds} seconds...", + "kv_play_click_team_to_judge": "Click a team to judge their answer", + "kv_play_judging": "Judging", + "kv_play_enter_wager": "Enter wager", + "kv_play_judged": "Judged", + "kv_play_finish": "Finish", + "kv_color_picker": "Color Picker", + "kv_done": "Done", + "kv_opacity": "Opacity", + "kv_confirm_close_title": "Discard Changes?", + "kv_confirm_close_message": "Are you sure you want to close? Any unsaved changes will be lost.", + "kv_confirm_discard": "Discard", + "kv_confirm_cancel": "Cancel", + "kv_final_round": "Final Round" } \ No newline at end of file diff --git a/messages/et.json b/messages/et.json index 7fd2116..a185d7c 100644 --- a/messages/et.json +++ b/messages/et.json @@ -18,6 +18,7 @@ "kv_error_404": "404", "kv_error_not_found": "Lehte ei leitud", "kv_error_hint": "(pssst... vaata, et oled ikka õigel lehel)", + "kv_edit_title": "Mängu redaktor", "kv_edit_back": "Tagasi", "kv_edit_game_name": "Mängu nimi...", "kv_edit_save": "Salvesta", @@ -141,5 +142,29 @@ "kv_settings_save_exit": "Salvesta ja välju", "kv_edit_image_link": "Pildi link", "kv_edit_save_exit": "Salvesta ja Välju", - "kv_edit_final_enabled": "Finaalvoor lubatud" + "kv_edit_final_enabled": "Finaalvoor lubatud", + "kv_play_adjust_score": "Muuda skoori", + "kv_play_click_team_to_answer": "Kliki meeskonnal vastajaks", + "kv_play_final_scores": "Lõpptulemused", + "kv_edit_dd_short": "HV", + "kv_play_timeout_reveal": "Aeg sai läbi, keegi ei saa vastata. Vastuse näitamine {seconds} sekundi pärast...", + "kv_play_answer_revealed": "Vastus näidatud. Tagasi mängulaudale {seconds} sekundi pärast...", + "kv_play_timer_paused": "Taimer peatatud. {name} vastab.", + "kv_play_correct_return": "{name} vastas õigesti! Tagasi mängulaudale {seconds} sekundi pärast...", + "kv_play_wrong_waiting": "{name} vastas valesti. Ootame järgmist mängijat...", + "kv_play_wrong_reveal": "{name} vastas valesti. Vastuse näitamine {seconds} sekundi pärast...", + "kv_play_skip_reveal": "Küsimus vahele jäetud. Vastuse näitamine {seconds} sekundi pärast...", + "kv_play_click_team_to_judge": "Kliki meeskonnal vastuse hindamiseks", + "kv_play_judging": "Hindamine", + "kv_play_enter_wager": "Sisesta panus", + "kv_play_judged": "Hinnatud", + "kv_play_finish": "Lõpeta", + "kv_color_picker": "Värvivalija", + "kv_done": "Valmis", + "kv_opacity": "Läbipaistvus", + "kv_confirm_close_title": "Loobuda muudatustest?", + "kv_confirm_close_message": "Kas oled kindel, et soovid sulgeda? Salvestamata muudatused lähevad kaotsi.", + "kv_confirm_discard": "Loobu", + "kv_confirm_cancel": "Tühista", + "kv_final_round": "Kuldvillak" } \ No newline at end of file diff --git a/src/lib/components/ColorPicker.svelte b/src/lib/components/ColorPicker.svelte new file mode 100644 index 0000000..d71538b --- /dev/null +++ b/src/lib/components/ColorPicker.svelte @@ -0,0 +1,821 @@ + + + + + + +{#if isOpen} +
+ +
e.key === "Escape" && closePicker()} + >
+ + +
+ +
+

+ {m.kv_color_picker()} +

+ +
+ + +
+ +
+
+ + +
+
+
+ + +
+
+ + {m.kv_opacity()} + + + {alpha}% + +
+
+
+
+
+ + +
+
+
+
+
+ HEX + 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" + /> +
+
+ + +
+ RGB +
+ + + +
+
+ + +
+ HSV +
+ + + +
+
+ + +
+ HSL +
+ + + +
+
+ + +
+ Grayscale + +
+ + + +
+ + + +
+{/if} diff --git a/src/lib/components/ConfirmDialog.svelte b/src/lib/components/ConfirmDialog.svelte new file mode 100644 index 0000000..93fb331 --- /dev/null +++ b/src/lib/components/ConfirmDialog.svelte @@ -0,0 +1,81 @@ + + + + +{#if open} + +
e.key === "Enter" && handleCancel()} + >
+ + +
+

+ {title} +

+

+ {message} +

+
+ + +
+
+{/if} diff --git a/src/lib/components/Settings.svelte b/src/lib/components/Settings.svelte index aaaed37..f209eea 100644 --- a/src/lib/components/Settings.svelte +++ b/src/lib/components/Settings.svelte @@ -1,5 +1,7 @@ {#if href && !disabled} diff --git a/src/lib/components/kuldvillak/ui/KvButtonSecondary.svelte b/src/lib/components/kuldvillak/ui/KvButtonSecondary.svelte index f7a28dd..90e81e4 100644 --- a/src/lib/components/kuldvillak/ui/KvButtonSecondary.svelte +++ b/src/lib/components/kuldvillak/ui/KvButtonSecondary.svelte @@ -18,7 +18,7 @@ }: Props = $props(); const baseClasses = - "inline-flex items-center justify-center px-6 py-4 bg-kv-yellow font-kv-body text-black text-2xl uppercase cursor-pointer transition-opacity hover:opacity-80 disabled:opacity-50 disabled:cursor-not-allowed kv-shadow-button"; + "inline-flex items-center justify-center px-6 py-4 bg-kv-yellow text-black kv-btn-text cursor-pointer transition-opacity hover:opacity-80 disabled:opacity-50 disabled:cursor-not-allowed kv-shadow-button border-4 border-black box-border"; {#if href && !disabled} diff --git a/src/lib/components/kuldvillak/ui/KvEditCard.svelte b/src/lib/components/kuldvillak/ui/KvEditCard.svelte new file mode 100644 index 0000000..750f894 --- /dev/null +++ b/src/lib/components/kuldvillak/ui/KvEditCard.svelte @@ -0,0 +1,208 @@ + + +
e.key === "Enter" && onSelect?.() + : undefined} + role={selectable && !finalJudged ? "button" : undefined} + tabindex={selectable && !finalJudged ? 0 : undefined} +> + + {#if selectable && !finalJudged} +
+ {/if} + + +
+ {name} + {score}€ + {#if finalJudged} + ✓ {m.kv_play_judged()} + {/if} +
+ + +
+ {#if finalMode && finalActive} + + {#if finalAnswerCorrect === null} +
+ + +
+ {:else} + +
+ + +
+ + {/if} + {:else if finalMode && !finalJudged && !finalActive} + +
+ + +
+ {:else if answering} + +
+ + +
+ {:else if !finalMode} + +
+ + +
+ {/if} +
+
diff --git a/src/lib/components/kuldvillak/ui/KvPlayerCard.svelte b/src/lib/components/kuldvillak/ui/KvPlayerCard.svelte deleted file mode 100644 index ed1ec51..0000000 --- a/src/lib/components/kuldvillak/ui/KvPlayerCard.svelte +++ /dev/null @@ -1,70 +0,0 @@ - - -
e.key === "Enter" && onclick?.()} -> - - - - - {#if onadjust} -
- - -
- {/if} -
diff --git a/src/lib/components/kuldvillak/ui/KvProjectorCard.svelte b/src/lib/components/kuldvillak/ui/KvProjectorCard.svelte deleted file mode 100644 index 79ed3cf..0000000 --- a/src/lib/components/kuldvillak/ui/KvProjectorCard.svelte +++ /dev/null @@ -1,67 +0,0 @@ - - -{#if variant === "category"} - -
- - {text || "Kategooria"} - -
-{:else if variant === "price"} - -
- {#if isRevealed} - {text} - {:else} - - {text} - - {/if} -
-{:else if variant === "player"} - -
- - {text} - - - {score}€ - -
-{/if} diff --git a/src/lib/components/kuldvillak/ui/index.ts b/src/lib/components/kuldvillak/ui/index.ts index 1a90a4d..3d616fa 100644 --- a/src/lib/components/kuldvillak/ui/index.ts +++ b/src/lib/components/kuldvillak/ui/index.ts @@ -8,8 +8,7 @@ export { default as KvButtonSecondary } from './KvButtonSecondary.svelte'; export { default as KvNumberInput } from './KvNumberInput.svelte'; // Cards -export { default as KvProjectorCard } from './KvProjectorCard.svelte'; -export { default as KvPlayerCard } from './KvPlayerCard.svelte'; +export { default as KvEditCard } from './KvEditCard.svelte'; // Branding export { default as KvLogo } from './KvGameLogo.svelte'; diff --git a/src/lib/index.ts b/src/lib/index.ts index 0fb289a..7406e16 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -5,8 +5,14 @@ // Kuldvillak (Jeopardy) Types export * from './types/kuldvillak'; -// Kuldvillak Store -export { kuldvillakStore } from './stores/kuldvillak.svelte'; +// Game Session Store (live game state) +export { gameSession } from './stores/gameSession.svelte'; + +// Theme Store +export { themeStore } from './stores/theme.svelte'; + +// Audio Store +export { audioStore } from './stores/audio.svelte'; // Persistence (Save/Load) export * from './stores/persistence'; diff --git a/src/lib/stores/audio.svelte.ts b/src/lib/stores/audio.svelte.ts index dae7f30..59ae8d5 100644 --- a/src/lib/stores/audio.svelte.ts +++ b/src/lib/stores/audio.svelte.ts @@ -4,16 +4,27 @@ class AudioStore { private audio: HTMLAudioElement | null = null; private initialized = false; + // Current values (live preview) musicVolume = $state(50); sfxVolume = $state(100); + // Saved values (persisted) + private savedMusicVolume = 50; + private savedSfxVolume = 100; + constructor() { if (browser) { // Load saved volumes const savedMusic = localStorage.getItem('kv_music_volume'); const savedSfx = localStorage.getItem('kv_sfx_volume'); - if (savedMusic) this.musicVolume = parseInt(savedMusic); - if (savedSfx) this.sfxVolume = parseInt(savedSfx); + if (savedMusic) { + this.musicVolume = parseInt(savedMusic); + this.savedMusicVolume = this.musicVolume; + } + if (savedSfx) { + this.sfxVolume = parseInt(savedSfx); + this.savedSfxVolume = this.sfxVolume; + } } } @@ -35,20 +46,34 @@ class AudioStore { }); } + // Preview changes (not saved yet) setMusicVolume(value: number) { this.musicVolume = value; if (this.audio) { this.audio.volume = value / 100; } - if (browser) { - localStorage.setItem('kv_music_volume', String(value)); - } } setSfxVolume(value: number) { this.sfxVolume = value; + } + + // Save current values to localStorage + save() { if (browser) { - localStorage.setItem('kv_sfx_volume', String(value)); + localStorage.setItem('kv_music_volume', String(this.musicVolume)); + localStorage.setItem('kv_sfx_volume', String(this.sfxVolume)); + } + this.savedMusicVolume = this.musicVolume; + this.savedSfxVolume = this.sfxVolume; + } + + // Revert to last saved values + revert() { + this.musicVolume = this.savedMusicVolume; + this.sfxVolume = this.savedSfxVolume; + if (this.audio) { + this.audio.volume = this.musicVolume / 100; } } diff --git a/src/lib/stores/gameSession.svelte.ts b/src/lib/stores/gameSession.svelte.ts index 7070365..186b330 100644 --- a/src/lib/stores/gameSession.svelte.ts +++ b/src/lib/stores/gameSession.svelte.ts @@ -45,6 +45,11 @@ export interface GameSessionState { timerSeconds: number; timerMax: number; + // Timeout countdowns (for displaying messages) + timeoutCountdown: number | null; // "Revealing answer in X seconds" + revealCountdown: number | null; // "Returning to board in X seconds" + skippingQuestion: boolean; // True when moderator clicked skip + // Question tracking questionsAnswered: number; // How many questions have been answered currentQuestionNumber: number; // Which question number is this (1-30) @@ -148,6 +153,9 @@ class GameSessionStore { timerRunning: false, timerSeconds: 0, timerMax: plainData.settings.defaultTimerSeconds ?? 10, + timeoutCountdown: null, + revealCountdown: null, + skippingQuestion: false, questionsAnswered: 0, currentQuestionNumber: 0, questionResults: [], @@ -195,8 +203,15 @@ class GameSessionStore { this.persist(); } - // End the game session + // Transition to finished phase (show Kuldvillak screen) endGame() { + if (!this.state) return; + this.state.phase = "finished"; + this.persist(); + } + + // Fully clear the game session + clearSession() { this.stopInternalTimer(); this.state = null; if (browser) { @@ -218,6 +233,8 @@ class GameSessionStore { this.state.currentQuestion = { roundIndex, categoryIndex, questionIndex }; this.state.wrongTeamIds = []; this.state.activeTeamId = null; + this.state.lastAnswerCorrect = null; + this.state.lastAnsweredTeamId = null; this.state.currentQuestionNumber = this.state.questionsAnswered + 1; if (question.isDailyDouble) { @@ -274,12 +291,10 @@ class GameSessionStore { this.state.lastAnsweredTeamId = teamId; this.state.lastAnswerCorrect = true; - // Show answer and close after configured delay + // Show answer and start reveal countdown this.state.showAnswer = true; + this.state.revealCountdown = this.state.settings.answerRevealSeconds ?? 5; this.persist(); - - const revealMs = (this.state.settings.answerRevealSeconds ?? 5) * 1000; - setTimeout(() => this.finalizeQuestion(), revealMs); } // Mark answer wrong - deducts points, adds to wrong list @@ -311,24 +326,25 @@ class GameSessionStore { // Check if all teams have answered wrong const allTeamsWrong = this.state.teams.every(t => this.state!.wrongTeamIds.includes(t.id)); if (allTeamsWrong) { - // Everyone wrong - show answer and close - this.state.showAnswer = true; + // Everyone wrong - start reveal countdown + this.state.timeoutCountdown = 5; // 5 seconds before showing answer this.persist(); - const revealMs = (this.state.settings.answerRevealSeconds ?? 5) * 1000; - setTimeout(() => this.finalizeQuestion(), revealMs); } else { this.persist(); } } - // Skip question - shows answer, closes after delay + // Skip question - 5 second delay, then shows answer skipQuestion() { if (!this.state || !this.state.currentQuestion) return; - this.state.showAnswer = true; + // Stop timer if running + this.state.timerRunning = false; + this.state.activeTeamId = null; + // Mark as skipping and start countdown + this.state.skippingQuestion = true; + this.state.timeoutCountdown = 5; this.persist(); - const revealMs = (this.state.settings.answerRevealSeconds ?? 5) * 1000; - setTimeout(() => this.finalizeQuestion(), revealMs); } // Actually close the question and return to board @@ -369,6 +385,9 @@ class GameSessionStore { this.state.wrongTeamIds = []; this.state.dailyDoubleWager = null; this.state.activeTeamId = null; + this.state.timeoutCountdown = null; + this.state.revealCountdown = null; + this.state.skippingQuestion = false; this.state.phase = "board"; // Check if round is complete @@ -376,17 +395,6 @@ class GameSessionStore { this.persist(); } - // Legacy method for compatibility - closeQuestion(correct: boolean | null, teamId?: string | null) { - if (correct === true && teamId) { - this.markCorrect(teamId); - } else if (correct === false && teamId) { - this.markWrong(teamId); - } else { - this.skipQuestion(); - } - } - private checkRoundComplete() { if (!this.state) return; @@ -414,6 +422,12 @@ class GameSessionStore { setActiveTeam(teamId: string | null) { if (!this.state) return; this.state.activeTeamId = teamId; + + // Pause timer when a team is selected to answer + if (teamId !== null && this.state.timerRunning) { + this.state.timerRunning = false; + } + this.persist(); } @@ -476,17 +490,14 @@ class GameSessionStore { // Final Round // ============================================ - setFinalWager(teamId: string, wager: number) { - if (!this.state) return; - this.state.finalWagers[teamId] = wager; - this.persist(); - } - showFinalQuestion() { if (!this.state) return; this.state.phase = "final-question"; this.state.timerMax = 30; // Set 30 second timer for final round this.state.timerSeconds = 30; + this.state.timerRunning = false; + this.state.activeTeamId = null; + this.state.finalRevealed = []; this.persist(); } @@ -496,17 +507,26 @@ class GameSessionStore { this.persist(); } - setFinalAnswer(teamId: string, answer: string) { + // Reveal the final answer (after all teams judged) + revealFinalAnswer() { if (!this.state) return; - this.state.finalAnswers[teamId] = answer; + this.state.showAnswer = true; + this.persist(); + } + + // Select a team to judge their final answer + selectFinalTeam(teamId: string) { + if (!this.state) return; + if (this.state.finalRevealed.includes(teamId)) return; // Already judged + this.state.activeTeamId = teamId; this.persist(); } - revealFinalAnswer(teamId: string, correct: boolean) { + // Judge final answer - applies wager to score + judgeFinalAnswer(teamId: string, correct: boolean, wager: number) { if (!this.state) return; const team = this.state.teams.find(t => t.id === teamId); - const wager = this.state.finalWagers[teamId] ?? 0; if (team) { if (correct) { @@ -516,12 +536,10 @@ class GameSessionStore { } } + // Store the wager for display purposes + this.state.finalWagers[teamId] = wager; this.state.finalRevealed.push(teamId); - - // Check if all revealed - if (this.state.finalRevealed.length === this.state.teams.length) { - this.state.phase = "finished"; - } + this.state.activeTeamId = null; this.persist(); } @@ -535,12 +553,71 @@ class GameSessionStore { if (this.timerInterval) return; this.timerInterval = setInterval(() => { - if (this.state?.timerRunning) { - if (this.state.timerSeconds > 0) { - this.state.timerSeconds--; + if (!this.state) return; + + // Handle timeout countdown (revealing answer) + if (this.state.timeoutCountdown !== null && this.state.timeoutCountdown > 0) { + this.state.timeoutCountdown--; + this.persist(); + + if (this.state.timeoutCountdown === 0) { + // Show answer and start reveal countdown + this.state.showAnswer = true; + this.state.timeoutCountdown = null; + this.state.revealCountdown = this.state.settings.answerRevealSeconds ?? 5; + this.persist(); + } + return; + } + + // Handle reveal countdown (returning to board) + if (this.state.revealCountdown !== null && this.state.revealCountdown > 0) { + this.state.revealCountdown--; + this.persist(); + + if (this.state.revealCountdown === 0) { + // Return to board + if (this.state.currentQuestion) { + const { roundIndex, categoryIndex, questionIndex } = this.state.currentQuestion; + const question = this.state.rounds[roundIndex]?.categories[categoryIndex]?.questions[questionIndex]; + if (question) { + question.isRevealed = true; + this.state.questionsAnswered++; + } + } + + // Reset state and return to board + this.state.currentQuestion = null; + this.state.showAnswer = false; + this.state.wrongTeamIds = []; + this.state.dailyDoubleWager = null; + this.state.activeTeamId = null; + this.state.timeoutCountdown = null; + this.state.revealCountdown = null; + this.state.skippingQuestion = false; + this.state.phase = "board"; + this.checkRoundComplete(); + this.persist(); + } + return; + } + + // Handle normal timer countdown + if (this.state.timerRunning && this.state.timerSeconds > 0) { + this.state.timerSeconds--; + this.persist(); + } else if (this.state.timerRunning && this.state.timerSeconds === 0) { + // Timer expired - start timeout countdown + this.state.timerRunning = false; + + // In question phase, handle timeout + if (this.state.phase === "question") { + // Clear active team (no one can answer now) + this.state.activeTeamId = null; + // Start 5 second countdown to answer reveal + this.state.timeoutCountdown = 5; this.persist(); } else { - this.state.timerRunning = false; this.persist(); } } @@ -578,15 +655,13 @@ class GameSessionStore { this.persist(); } - // Called externally - no longer needed but kept for compatibility - tickTimer() { - // Timer now runs internally, this is a no-op - } - resetTimer() { if (!this.state) return; this.state.timerSeconds = this.state.timerMax; this.state.timerRunning = false; + // Also clear any timeout countdowns + this.state.timeoutCountdown = null; + this.state.revealCountdown = null; this.persist(); } diff --git a/src/lib/stores/kuldvillak.svelte.ts b/src/lib/stores/kuldvillak.svelte.ts deleted file mode 100644 index 46fe143..0000000 --- a/src/lib/stores/kuldvillak.svelte.ts +++ /dev/null @@ -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(null); - savedGames = $state([]); - - // 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>): 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(); diff --git a/src/lib/stores/theme.svelte.ts b/src/lib/stores/theme.svelte.ts index a86515d..e1301f3 100644 --- a/src/lib/stores/theme.svelte.ts +++ b/src/lib/stores/theme.svelte.ts @@ -1,6 +1,13 @@ import { browser } from "$app/environment"; const THEME_STORAGE_KEY = "kuldvillak-theme"; +const THEME_CHANNEL_NAME = "kuldvillak-theme-sync"; + +// BroadcastChannel for syncing theme across windows +let channel: BroadcastChannel | null = null; +if (browser) { + channel = new BroadcastChannel(THEME_CHANNEL_NAME); +} // Default theme colors export const DEFAULT_THEME = { @@ -45,15 +52,32 @@ let savedSecondary = $state(initialTheme.secondary); let savedText = $state(initialTheme.text); let savedBackground = $state(initialTheme.background); -function applyTheme() { +function applyTheme(broadcast = true) { if (browser) { document.documentElement.style.setProperty("--kv-blue", primary); document.documentElement.style.setProperty("--kv-yellow", secondary); document.documentElement.style.setProperty("--kv-text", text); document.documentElement.style.setProperty("--kv-background", background); + + // Broadcast to other windows + if (broadcast && channel) { + channel.postMessage({ primary, secondary, text, background }); + } } } +// Listen for theme changes from other windows +if (browser && channel) { + channel.onmessage = (event) => { + const { primary: p, secondary: s, text: t, background: b } = event.data; + if (p) primary = p; + if (s) secondary = s; + if (t) text = t; + if (b) background = b; + applyTheme(false); // Don't re-broadcast + }; +} + // Save current values to localStorage function save() { if (browser) { diff --git a/src/lib/types/kuldvillak.ts b/src/lib/types/kuldvillak.ts index 2ca5bcb..60a18a8 100644 --- a/src/lib/types/kuldvillak.ts +++ b/src/lib/types/kuldvillak.ts @@ -53,6 +53,7 @@ export type GamePhase = | 'daily-double' | 'final-intro' // Final round intro (Kuldvillak screen) | 'final-category' // Reveal final round category + | 'final-wagers' // Collect wagers from each team | 'final-question' | 'final-reveal' | 'final-scores' diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index c2d3229..cf93364 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -25,6 +25,10 @@ } + + Ultimate Gaming + +
diff --git a/src/routes/kuldvillak/+page.svelte b/src/routes/kuldvillak/+page.svelte index b155263..c60b34c 100644 --- a/src/routes/kuldvillak/+page.svelte +++ b/src/routes/kuldvillak/+page.svelte @@ -31,6 +31,10 @@ } + + Kuldvillak - Ultimate Gaming + +
@@ -40,11 +44,14 @@
- +
(null); + let originalFinal = $state<{ + category: string; + question: string; + answer: string; + } | null>(null); + // Autosave to localStorage function autoSave() { if (!browser) return; @@ -69,15 +86,19 @@ const data = JSON.parse(saved); if (data.settings && data.teams && data.rounds) { gameName = data.name || ""; - const { teamColors, ...cleanSettings } = data.settings; - settings = { ...DEFAULT_SETTINGS, ...cleanSettings }; - teams = data.teams.map((t: any) => ({ + const { teamColors, ...cleanSettings } = + data.settings as Record; + settings = { + ...DEFAULT_SETTINGS, + ...cleanSettings, + } as GameSettings; + teams = (data.teams as Team[]).map((t) => ({ id: t.id, name: t.name, score: t.score ?? 0, })); - rounds = data.rounds; - finalRound = data.finalRound || { + rounds = data.rounds as Round[]; + finalRound = (data.finalRound as FinalRound) || { category: "", question: "", answer: "", @@ -102,7 +123,7 @@ } function generateId(): string { - return Math.random().toString(36).substring(2, 11); + return crypto.randomUUID(); } function createQuestion(points: number): Question { @@ -266,7 +287,6 @@ } function resetGame() { - if (!confirm(m.kv_edit_reset_confirm())) return; settings = { ...DEFAULT_SETTINGS, defaultTimerSeconds: 5, @@ -292,15 +312,19 @@ const data = JSON.parse(e.target?.result as string); if (data.settings && data.teams && data.rounds) { gameName = data.name || "Loaded Game"; - const { teamColors, ...cleanSettings } = data.settings; - settings = { ...DEFAULT_SETTINGS, ...cleanSettings }; - teams = data.teams.map((t: any) => ({ + const { teamColors, ...cleanSettings } = + data.settings as Record; + settings = { + ...DEFAULT_SETTINGS, + ...cleanSettings, + } as GameSettings; + teams = (data.teams as Team[]).map((t) => ({ id: t.id, name: t.name, score: t.score ?? 0, })); - rounds = data.rounds; - finalRound = data.finalRound || { + rounds = data.rounds as Round[]; + finalRound = (data.finalRound as FinalRound) || { category: "", question: "", answer: "", @@ -332,25 +356,75 @@ rounds = [...rounds]; } + function openQuestion( + roundIndex: number, + catIndex: number, + qIndex: number, + ) { + const q = rounds[roundIndex].categories[catIndex].questions[qIndex]; + originalQuestion = { + question: q.question, + answer: q.answer, + imageUrl: q.imageUrl, + isDailyDouble: q.isDailyDouble, + }; + editingQuestion = { roundIndex, catIndex, qIndex }; + } + function saveQuestion() { + originalQuestion = null; editingQuestion = null; } - function getEditingQuestion() { - if (!editingQuestion) return null; - return rounds[editingQuestion.roundIndex].categories[ - editingQuestion.catIndex - ].questions[editingQuestion.qIndex]; + function handleQuestionCloseClick() { + showQuestionCloseConfirm = true; } - function getEditingCategory() { - if (!editingQuestion) return null; - return rounds[editingQuestion.roundIndex].categories[ - editingQuestion.catIndex - ]; + function discardQuestionChanges() { + if (editingQuestion && originalQuestion) { + const q = + rounds[editingQuestion.roundIndex].categories[ + editingQuestion.catIndex + ].questions[editingQuestion.qIndex]; + q.question = originalQuestion.question; + q.answer = originalQuestion.answer; + q.imageUrl = originalQuestion.imageUrl; + q.isDailyDouble = originalQuestion.isDailyDouble; + rounds = [...rounds]; + } + showQuestionCloseConfirm = false; + originalQuestion = null; + editingQuestion = null; + } + + function openFinalQuestion() { + originalFinal = { ...finalRound }; + editingFinalQuestion = true; + } + + function saveFinalQuestion() { + originalFinal = null; + editingFinalQuestion = false; + } + + function handleFinalCloseClick() { + showFinalCloseConfirm = true; + } + + function discardFinalChanges() { + if (originalFinal) { + finalRound = { ...originalFinal }; + } + showFinalCloseConfirm = false; + originalFinal = null; + editingFinalQuestion = false; } + + {m.kv_edit_title()} - Kuldvillak + +
@@ -361,6 +435,7 @@ fileInput.click()} class="w-10 h-10 hover:opacity-80 transition-opacity bg-transparent border-none p-0 cursor-pointer text-kv-yellow" + aria-label={m.kv_edit_load()} >
-

+

{m.kv_edit_settings_teams()}

- - +
@@ -490,26 +558,22 @@
- {m.kv_edit_rounds()}
- {m.kv_play_timer()}
- {m.kv_play_timer_reveal()}
- {m.kv_edit_final_round()}
@@ -575,9 +639,9 @@
+ + + { + showQuestionCloseConfirm = false; + saveQuestion(); + }} + /> {/if} {#if editingFinalQuestion}
- e.target === e.currentTarget && (editingFinalQuestion = false)} - onkeydown={(e) => e.key === "Escape" && (editingFinalQuestion = false)} + onclick={(e) => e.target === e.currentTarget && handleFinalCloseClick()} + onkeydown={(e) => e.key === "Escape" && handleFinalCloseClick()} role="dialog" tabindex="-1" > @@ -915,8 +991,9 @@ {m.kv_edit_final_round()}
+ + + { + showFinalCloseConfirm = false; + saveFinalQuestion(); + }} + /> {/if} @@ -1005,3 +1096,12 @@

{/if} + + + diff --git a/src/routes/kuldvillak/play/+page.svelte b/src/routes/kuldvillak/play/+page.svelte index c173926..9ddd2ad 100644 --- a/src/routes/kuldvillak/play/+page.svelte +++ b/src/routes/kuldvillak/play/+page.svelte @@ -9,6 +9,10 @@ let view = $derived($page.url.searchParams.get("view") ?? "moderator"); + + {gameSession.state?.name ?? "Play"} - Kuldvillak + + {#if !gameSession.state}
diff --git a/src/routes/kuldvillak/play/ModeratorView.svelte b/src/routes/kuldvillak/play/ModeratorView.svelte index f18e450..b580983 100644 --- a/src/routes/kuldvillak/play/ModeratorView.svelte +++ b/src/routes/kuldvillak/play/ModeratorView.svelte @@ -5,26 +5,33 @@ import { KvButtonPrimary, KvButtonSecondary, - KvPlayerCard, KvNumberInput, + KvEditCard, } from "$lib/components/kuldvillak/ui"; - - // Standardized button base class (legacy, used in transition) - const btnBase = - "bg-kv-blue border-4 border-black font-kv-body text-lg px-4 py-2 cursor-pointer uppercase hover:opacity-80"; + import { Settings, ConfirmDialog } from "$lib/components"; // Only moderator controls the timer onMount(() => { gameSession.enableTimerControl(); }); + // Settings modal state + let settingsOpen = $state(false); + + // End game confirmation dialog + let showEndGameConfirm = $state(false); + function openProjector() { window.open("/kuldvillak/play?view=projector", "kuldvillak-projector"); } + function openSettings() { + settingsOpen = true; + } + // Local state let wagerInput = $state(0); - let scoreAdjustment = $state(100); + let scoreAdjustment = $state(10); // Derived state let session = $derived(gameSession.state); @@ -63,293 +70,316 @@ gameSession.skipQuestion(); } - // Check if team already answered wrong function isTeamWrong(teamId: string) { return session?.wrongTeamIds?.includes(teamId) ?? false; } - // Get available teams (not yet answered wrong) - let availableTeams = $derived( - session?.teams.filter((t) => !session?.wrongTeamIds?.includes(t.id)) ?? - [], - ); - function confirmDailyDoubleWager() { if (!session?.activeTeamId) return; gameSession.setDailyDoubleWager(session.activeTeamId, wagerInput); } + + function adjustScore(teamId: string, amount: number) { + gameSession.adjustScore(teamId, amount); + } + + function handleTeamClick(teamId: string) { + if ( + session?.phase === "question" && + !session.showAnswer && + !isTeamWrong(teamId) + ) { + gameSession.setActiveTeam(teamId); + } + } + + // Count daily doubles in a round + function countDailyDoubles(roundIndex: number) { + if (!session) return 0; + const round = session.rounds[roundIndex]; + return ( + round?.categories.reduce((count, cat) => { + return ( + count + cat.questions.filter((q) => q.isDailyDouble).length + ); + }, 0) ?? 0 + ); + } {#if session} -
- -
- -
- -
-

- {session.name} -

-
- {currentRound?.name || m.kv_play_round()} - {m.kv_play_round()} - {session.currentRoundIndex + 1} -
+
+ +
+ +
+

+ {session.name} +

+
+ {#if session.phase.startsWith("final")} + + {m.kv_final_round()} + + {:else} + + {session.currentRoundIndex === 0 + ? "Villak" + : "Topeltvillak"} + + + ({m.kv_edit_dd_short()} + {countDailyDoubles( + session.currentRoundIndex, + )}/{session.settings.dailyDoublesPerRound[ + session.currentRoundIndex + ] ?? 1}) + + {/if}
+
- -
-
+ +
+ +
+ + {m.kv_play_open_projector()} + + {#if session.settings.enableFinalRound && session.finalRound && session.phase === "board"} gameSession.goToFinalRound()} > - {m.kv_play_open_projector()} + {m.kv_play_go_to_final()} - {#if session.settings.enableFinalRound && session.finalRound && session.phase === "board"} - gameSession.goToFinalRound()} - class="!py-3 !px-6 !text-xl" - > - {m.kv_play_go_to_final()} - - {/if} - { - if (confirm(m.kv_play_end_game_confirm())) - gameSession.endGame(); - }} - class="!py-3 !px-6 !text-xl" - > - {m.kv_play_end_game()} - -
+ {/if} + (showEndGameConfirm = true)} + > + {m.kv_play_end_game()} + +
- -
- {#if session.lastAnsweredTeamId} - {@const lastTeam = session.teams.find( - (t) => t.id === session.lastAnsweredTeamId, - )} + +
+ {#if session.lastAnsweredTeamId} + {@const lastTeam = session.teams.find( + (t) => t.id === session.lastAnsweredTeamId, + )} + + {m.kv_play_last_answer()}: - {m.kv_play_last_answer()}: - - {lastTeam?.name} - {session.lastAnswerCorrect ? "✓" : "✗"} - + {lastTeam?.name} - {/if} -
- {m.kv_play_adjust_by()}: + {:else} + + {/if} + +
+ + {m.kv_play_adjust_score()}: + + + +
+ + +
+
- -
- {#each session.teams as team} - - gameSession.adjustScore(team.id, delta)} - onclick={() => - session.phase === "question" && - !session.showAnswer && - gameSession.setActiveTeam(team.id)} - disabled={isTeamWrong(team.id)} - /> - {/each} -
-
+ +
+ {#each session.teams as team (team.id)} + {@const isAnswering = + session.phase === "question" && + session.activeTeamId === team.id && + !session.showAnswer} + {@const canSelect = + (session.phase === "question" && + !session.showAnswer && + !isTeamWrong(team.id) && + session.activeTeamId !== team.id) || + (session.phase === "final-question" && + !session.finalRevealed.includes(team.id) && + session.activeTeamId !== team.id)} + {@const isFinalMode = session.phase === "final-question"} + {@const isFinalJudged = session.finalRevealed.includes(team.id)} + {@const isFinalActive = + session.phase === "final-question" && + session.activeTeamId === team.id} + + isFinalMode + ? gameSession.selectFinalTeam(team.id) + : gameSession.setActiveTeam(team.id)} + onAdd={() => adjustScore(team.id, scoreAdjustment)} + onRemove={() => adjustScore(team.id, -scoreAdjustment)} + onCorrect={markCorrect} + onWrong={markWrong} + onFinalJudge={(correct, wager) => + gameSession.judgeFinalAnswer(team.id, correct, wager)} + /> + {/each} +
- {#if session.phase === "intro"} - -
-
- KULDVILLAK -
-
- {m.kv_play_round()} - {session.currentRoundIndex + 1} -
-
- - -
-
- {:else if session.phase === "intro-categories"} - -
-
- {m.kv_play_introducing_categories()} -
-
- {session.introCategoryIndex + 1} / {currentRound?.categories - .length ?? 0} -
- {#if currentRound?.categories[session.introCategoryIndex]} -
- {currentRound.categories[session.introCategoryIndex] - .name} -
- - {:else} -
- -
- {/if} + {session.currentRoundIndex === 0 + ? "Villak" + : "Topeltvillak"} + +
+ gameSession.startCategoryIntro()} + > + {m.kv_play_introduce_categories()} + + gameSession.startBoard()} + > + {m.kv_play_skip_to_game()} + +
+ {:else if session.phase === "intro-categories"} + {#if currentRound?.categories[session.introCategoryIndex]} + + {currentRound.categories[ + session.introCategoryIndex + ].name} + + + {session.introCategoryIndex + 1} / {currentRound + .categories.length} + + {:else} + gameSession.startBoard()} + > + {m.kv_play_start_game()} + + {/if} + {/if} +
{:else if session.phase === "board"} - -
- -
+ +
+ +
{#each currentRound?.categories ?? [] as cat}
- {cat.name || "???"} + + {cat.name || "???"} +
{/each}
- -
- {#each currentRound?.categories ?? [] as cat, ci} -
- {#each cat.questions as q, qi} - {@const result = gameSession.getQuestionResult( - ci, - qi, - )} - - {/each} -
+ {q.points}€ + + + {/each} {/each}
{:else if session.phase === "daily-double"} - +
-

+

{m.kv_play_daily_double()}!

-
+ +
{#each session.teams as team} +
{/if}
{:else if session.phase === "question" && questionData} - -
-
-

- {questionData.category.name} - {questionData.question - .points}€ - {#if session.dailyDoubleWager} - (DD: {session.dailyDoubleWager}€) - {/if} -

-
- - {m.kv_play_question_number({ - current: session.currentQuestionNumber, - total: totalQuestions(), - })} - - {#if session.showAnswer} - {m.kv_play_showing_answer()} - {/if} -
-
- - -
- {#if session.wrongTeamIds.length > 0} - - ✗ {session.wrongTeamIds - .map( - (id) => - session.teams.find((t) => t.id === id) - ?.name, - ) - .join(", ")} - - {/if} - {#if session.activeTeamId && session.showAnswer} - {@const activeTeam = session.teams.find( - (t) => t.id === session.activeTeamId, - )} - {@const points = - session.dailyDoubleWager ?? - questionData.question.points} - {#if session.lastAnswerCorrect} - - ✓ {activeTeam?.name} +{points}€ - - {:else if session.lastAnswerCorrect === false} - - ✗ {activeTeam?.name} -{points}€ - - {/if} - {/if} -
- -
-
+
+ + {#if session.revealCountdown !== null && session.revealCountdown > 0 && session.lastAnswerCorrect === true && !session.skippingQuestion} + {@const lastTeamName = + session.teams.find( + (t) => t.id === session.lastAnsweredTeamId, + )?.name ?? ""} + + - {m.kv_play_question_short()}: - {questionData.question.question} -
-
+ {:else if session.revealCountdown !== null && session.revealCountdown > 0} + + - {m.kv_play_answer_short()}: - {questionData.question.answer} -
-
- - -
- {m.kv_play_answering()}: + {:else if session.skippingQuestion && session.timeoutCountdown !== null && session.timeoutCountdown > 0} + + - {#each session.teams as team} - {@const isWrong = isTeamWrong(team.id)} - - {/each} -
- - -
- - - -
+ {m.kv_play_wrong_waiting({ name: lastTeamName })} + + {:else if session.activeTeamId && !session.timerRunning && !session.showAnswer} + {@const activeTeamName = + session.teams.find((t) => t.id === session.activeTeamId) + ?.name ?? ""} + + + {m.kv_play_timer_paused({ name: activeTeamName })} + + {:else if !session.activeTeamId && !session.showAnswer && (session.timerRunning || session.timerSeconds > 0)} + + + {m.kv_play_click_team_to_answer()} + + {/if} - +
- {m.kv_play_timer()} - {session.timerSeconds}s - - gameSession.setTimerMax(session?.timerMax ?? 10)} - /> - {m.kv_play_seconds()} - {#if session.timerRunning} - - {:else} - - {/if} - + {questionData.question.question}
-
- {:else if session.phase === "final-intro"} - -
+ +
- {m.kv_play_final_round()} + {m.kv_play_answer_short()}: {questionData.question.answer}
- {#if session.finalCategoryRevealed} - -
- {session.finalRound?.category} + + +
+
+ + {m.kv_play_timer()} + +
+ + {session.timerSeconds} + +
+ + {m.kv_play_seconds()} +
-
+ +
+ {#if session.timerRunning} + + {:else} + + {/if} -
- {:else} - -
- {/if} +
- {:else if session.phase === "final-category"} - + {:else if session.phase === "final-intro" || session.phase === "final-category"} +
-
- {m.kv_play_introducing_categories()} -
-
- {session.finalRound?.category} -
+ {#if session.finalCategoryRevealed || session.phase === "final-category"} + + + {session.finalRound?.category} + + + {m.kv_play_final_round()} + + gameSession.showFinalQuestion()} + > + {m.kv_play_question_short()} + + {:else} + + + {m.kv_play_final_round()} + + gameSession.startFinalCategoryReveal()} + > + {m.kv_play_reveal_category()} + + {/if}
{:else if session.phase === "final-question"} - -
-

- {m.kv_play_final_round()} -

-
- {m.kv_edit_category()}: - {session.finalRound?.category} -
-
- {m.kv_play_question_short()}: + {@const activeTeam = session.teams.find( + (t) => t.id === session.activeTeamId, + )} + +
+ + {#if activeTeam} + + {m.kv_play_judging()}: {activeTeam.name} + + {:else} + + {m.kv_play_click_team_to_judge()} + + {/if} + + +
{session.finalRound?.question}
-
- {m.kv_play_answer_short()}: - {session.finalRound?.answer} -
- +
- {m.kv_play_timer()} - {session.timerSeconds}s - {#if session.timerRunning} - + + +
+
+ - {:else} + {m.kv_play_timer()} + +
+ + {session.timerSeconds} + +
+ + {m.kv_play_seconds()} + +
+ +
+ {#if session.timerRunning} + + {:else} + + {/if} gameSession.resetTimer()} > - {/if} - + {m.kv_play_reset()} + +
- - - - - + + {#if session.finalRevealed.length === session.teams.length} + {#if session.showAnswer} + gameSession.showFinalScores()} + > + {m.kv_play_show_scores()} + + {:else} + gameSession.revealFinalAnswer()} + > + {m.kv_play_reveal_answer()} + + {/if} + {/if}
{:else if session.phase === "final-scores"} - -
-

- {m.kv_play_scores()} -

- + {#each topRow as team, i} +
+ + {team.score}€ + + + {team.name} + + + #{i + 1} + +
+ {/each} +
+ + {#if bottomRowCount > 0} +
+ {#each bottomRow as team, i} +
+ + {team.score}€ + + + {team.name} + + + #{i + topRowCount + 1} + +
+ {/each} +
+ {/if} +
+ gameSession.endGame()}> + {m.kv_play_end_game()} + +
{:else if session.phase === "finished"} +
-

- {m.kv_play_game_over()}! -

- {m.kv_edit_back()} + {m.kv_play_game_over()}! + + gameSession.clearSession()}> + {m.kv_play_finish()} +
{/if}
+{:else} +
+ + {m.kv_play_loading()} + +
{/if} + + + + + + gameSession.endGame()} +/> diff --git a/src/routes/kuldvillak/play/ProjectorView.svelte b/src/routes/kuldvillak/play/ProjectorView.svelte index 04efc10..d3602d7 100644 --- a/src/routes/kuldvillak/play/ProjectorView.svelte +++ b/src/routes/kuldvillak/play/ProjectorView.svelte @@ -20,6 +20,9 @@ let animationPhase = $state<"waiting" | "expanding" | "shown" | "none">( "none", ); + let finalAnimPhase = $state<"waiting" | "expanding" | "shown" | "none">( + "none", + ); let prevPhase = $state(null); // Intro category animation state (used for both regular and final round) @@ -52,19 +55,17 @@ return { left: 50, top: 50, width: 16, height: 20 }; } - const gridRect = questionGridEl.getBoundingClientRect(); const cardRect = card.getBoundingClientRect(); + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + // Calculate center position relative to viewport const left = - ((cardRect.left - gridRect.left + cardRect.width / 2) / - gridRect.width) * - 100; + ((cardRect.left + cardRect.width / 2) / viewportWidth) * 100; const top = - ((cardRect.top - gridRect.top + cardRect.height / 2) / - gridRect.height) * - 100; - const width = (cardRect.width / gridRect.width) * 100; - const height = (cardRect.height / gridRect.height) * 100; + ((cardRect.top + cardRect.height / 2) / viewportHeight) * 100; + const width = (cardRect.width / viewportWidth) * 100; + const height = (cardRect.height / viewportHeight) * 100; return { left, top, width, height }; }); @@ -77,14 +78,30 @@ animationPhase = "waiting"; setTimeout(() => { animationPhase = "expanding"; - }, 100); + }, 1000); setTimeout(() => { animationPhase = "shown"; - }, 1100); + }, 2000); } else if (currentPhase !== "question") { animationPhase = "none"; } + // Final question animation - wait 1s on Kuldvillak, then expand from center + if ( + currentPhase === "final-question" && + prevPhase !== "final-question" + ) { + finalAnimPhase = "waiting"; + setTimeout(() => { + finalAnimPhase = "expanding"; + }, 1000); // Wait 1 second before expanding + setTimeout(() => { + finalAnimPhase = "shown"; + }, 2000); // 1s wait + 1s expand + } else if (currentPhase !== "final-question") { + finalAnimPhase = "none"; + } + prevPhase = currentPhase ?? null; }); @@ -137,25 +154,62 @@ boardRevealPhase = "revealing"; revealedPrices = new Set(); - // Stagger reveal each price cell (instant opacity, no transition) - const categories = currentRound?.categories ?? []; - const questionsPerCat = categories[0]?.questions.length ?? 5; - let delay = 0; - - for (let qi = 0; qi < questionsPerCat; qi++) { - for (let ci = 0; ci < categories.length; ci++) { - const key = `${ci}-${qi}`; - setTimeout(() => { - revealedPrices = new Set([...revealedPrices, key]); - }, delay); - delay += 50; // 50ms between each cell - } - } - - setTimeout(() => { - boardRevealPhase = "revealed"; - gameSession.markBoardRevealed(); // Mark as revealed so it won't animate again - }, delay + 100); + // Custom reveal order: [ci, qi, order] - order determines when cell appears + // Grid: 6 columns (C1-C6) × 5 rows (R1-R5) + const revealOrder: [number, number, number][] = [ + // Row 1 (qi=0): 01 02 15 11 13 08 + [0, 0, 1], + [1, 0, 2], + [2, 0, 15], + [3, 0, 11], + [4, 0, 13], + [5, 0, 8], + // Row 2 (qi=1): 25 04 28 24 05 07 + [0, 1, 25], + [1, 1, 4], + [2, 1, 28], + [3, 1, 24], + [4, 1, 5], + [5, 1, 7], + // Row 3 (qi=2): 20 16 09 10 18 26 + [0, 2, 20], + [1, 2, 16], + [2, 2, 9], + [3, 2, 10], + [4, 2, 18], + [5, 2, 26], + // Row 4 (qi=3): 12 27 06 23 21 30 + [0, 3, 12], + [1, 3, 27], + [2, 3, 6], + [3, 3, 23], + [4, 3, 21], + [5, 3, 30], + // Row 5 (qi=4): 19 22 03 14 17 29 + [0, 4, 19], + [1, 4, 22], + [2, 4, 3], + [3, 4, 14], + [4, 4, 17], + [5, 4, 29], + ]; + + // Sort by order and schedule reveals + const sorted = [...revealOrder].sort((a, b) => a[2] - b[2]); + sorted.forEach(([ci, qi, _order], idx) => { + const key = `${ci}-${qi}`; + setTimeout(() => { + revealedPrices = new Set([...revealedPrices, key]); + }, idx * 50); // 50ms between each cell + }); + + setTimeout( + () => { + boardRevealPhase = "revealed"; + gameSession.markBoardRevealed(); // Mark as revealed so it won't animate again + }, + sorted.length * 50 + 100, + ); } else if (currentPhase === "board" && alreadyRevealed) { // Already revealed - show all prices immediately boardRevealPhase = "revealed"; @@ -235,7 +289,7 @@ class="absolute inset-0 bg-kv-black p-8 intro-category {introAnimPhase}" >
{#each currentRound?.categories ?? [] as cat} - {@const roundName = + {@const logoVariant = session.currentRoundIndex === 0 - ? "VILLAK" - : "TOPELTVILLAK"} + ? "villak" + : "topeltvillak"}
- {session.boardRevealed - ? cat.name || "???" - : roundName} +
+ +
+ {cat.name || "???"} +
+
{/each}
@@ -316,44 +382,43 @@
- {#if session.phase === "question" && questionData && (animationPhase === "expanding" || animationPhase === "shown")} + {#if session.phase === "question" && questionData && animationPhase !== "none"} + {@const pos = startPosition()}
{#each session.teams as team} + {@const isAnswering = + session.activeTeamId === team.id}
-
{team.score}€ + {team.name} - {team.name} - {team.score}€ -
{/each}
{#if questionData.question.imageUrl} @@ -369,14 +434,15 @@ {:else}
{#if session.showAnswer}
{questionData.question.answer}
{:else} -
+
{questionData.question.question}
{/if} @@ -428,7 +494,7 @@ class="absolute inset-0 bg-kv-black p-8 intro-category {introAnimPhase}" >
{:else if session.phase === "final-question"} - -
- + +
+ +
+ + {#if finalAnimPhase !== "none"}
- {#each session.teams as team} -
+ +
+ {#each session.teams as team} + {@const isActive = session.activeTeamId === team.id}
{team.name} - {team.score}€ + {team.name}
-
- {/each} -
+ {/each} +
- -
+
- {#if session.showAnswer} -
- {session.finalRound?.answer} -
- {:else} -
- {session.finalRound?.question} -
- {/if} +
+ {#if session.showAnswer} +
+ {session.finalRound?.answer} +
+ {:else} +
+ {session.finalRound?.question} +
+ {/if} +
-
+ {/if} {:else if session.phase === "final-scores"} - -
+ + {@const sorted = gameSession.sortedTeams} + {@const count = sorted.length} + {@const topRowCount = count <= 3 ? count : 3} + {@const bottomRowCount = count > 3 ? count - 3 : 0} + {@const topRow = sorted.slice(0, topRowCount)} + {@const bottomRow = sorted.slice(topRowCount)} +
+
-
- {m.kv_play_scores()} -
-
- -
- {#each gameSession.sortedTeams as team, i} + {#each topRow as team, i}
-
- #{i + 1} - {team.name} -
-
{team.score}€ -
+ + + {team.name} + + + #{i + 1} +
{/each}
-
- {:else if session.phase === "finished"} - -
-
+ + {#if bottomRowCount > 0}
- {m.kv_play_game_over()}! -
- - {#if gameSession.sortedTeams[0]} -
- 🏆 {gameSession.sortedTeams[0].name} 🏆 -
- {/if} -
- -
- {#each gameSession.sortedTeams as team, i} -
-
- #{i + 1} - {team.name} -
+ {#each bottomRow as team, i}
- {team.score}€ + + {team.score}€ + + + {team.name} + + + #{i + topRowCount + 1} +
-
- {/each} -
+ {/each} +
+ {/if} +
+ {:else if session.phase === "finished"} + +
+
{/if}
@@ -622,16 +675,6 @@ background-size: 60px 60px; } - /* Category gradient background - radial vignette effect */ - .category-gradient-bg { - background: radial-gradient( - ellipse at center, - var(--kv-blue) 0%, - color-mix(in srgb, var(--kv-blue) 85%, black) 70%, - color-mix(in srgb, var(--kv-blue) 70%, black) 100% - ); - } - /* Intro category animation - 500ms dissolve ease-out */ .intro-category { opacity: 0; @@ -703,13 +746,195 @@ opacity: 0 !important; } - /* Question overlay animation - fade in */ + /* Question overlay animation - expand from card position */ .expand-overlay { + left: var(--start-left); + top: var(--start-top); + width: var(--start-width); + height: var(--start-height); + transform: translate(-50%, -50%); + transform-origin: center center; + overflow: hidden; + container-type: size; opacity: 0; - transition: opacity 500ms ease-out; + visibility: hidden; + } + .expand-overlay.expanding { + opacity: 1; + visibility: visible; + transition: + left 1s linear, + top 1s linear, + width 1s linear, + height 1s linear, + transform 1s linear; } .expand-overlay.expanding, .expand-overlay.shown { + left: 50%; + top: 50%; + width: 100%; + height: 100%; + transform: translate(-50%, -50%); + opacity: 1; + visibility: visible; + } + /* Scale all elements proportionally to container */ + .expand-overlay { + padding: clamp(4px, 3cqh, 32px); + gap: clamp(4px, 1.5cqh, 16px); + } + .expand-overlay .font-kv-question { + font-size: clamp(12px, 8cqh, 96px); + } + .expand-overlay .font-kv-body { + font-size: clamp(8px, 4cqh, 36px); + } + .expand-overlay .grid { + gap: clamp(2px, 1.5cqh, 16px); + } + .expand-overlay .grid span { + font-size: clamp(8px, 3.5cqh, 36px); + } + .expand-overlay .border-8 { + border-width: clamp(2px, 0.8cqh, 8px); + } + .expand-overlay .bg-kv-blue { + padding: clamp(4px, 1.5cqh, 16px); + } + .expand-overlay .px-2 { + padding-left: clamp(2px, 0.8cqw, 8px); + padding-right: clamp(2px, 0.8cqw, 8px); + } + .expand-overlay .py-4 { + padding-top: clamp(4px, 1.5cqh, 16px); + padding-bottom: clamp(4px, 1.5cqh, 16px); + } + + /* Final question overlay animation - expand from center */ + .expand-overlay-center { + left: 50%; + top: 50%; + width: 10%; + height: 10%; + transform: translate(-50%, -50%); + transform-origin: center center; + overflow: hidden; + container-type: size; + opacity: 0; + visibility: hidden; + } + .expand-overlay-center.waiting { + opacity: 0; + visibility: hidden; + } + .expand-overlay-center.expanding { + opacity: 1; + visibility: visible; + transition: + width 1s linear, + height 1s linear; + width: 100%; + height: 100%; + } + .expand-overlay-center.shown { + left: 50%; + top: 50%; + width: 100%; + height: 100%; + transform: translate(-50%, -50%); + opacity: 1; + visibility: visible; + } + /* Scale elements for center expand */ + .expand-overlay-center { + padding: clamp(4px, 3cqh, 32px); + gap: clamp(4px, 1.5cqh, 16px); + } + .expand-overlay-center .font-kv-question { + font-size: clamp(12px, 8cqh, 96px); + } + .expand-overlay-center .font-kv-body { + font-size: clamp(8px, 4cqh, 36px); + } + .expand-overlay-center .grid { + gap: clamp(2px, 1.5cqh, 16px); + } + .expand-overlay-center .grid span { + font-size: clamp(8px, 3.5cqh, 36px); + } + .expand-overlay-center .border-8 { + border-width: clamp(2px, 0.8cqh, 8px); + } + .expand-overlay-center .bg-kv-blue { + padding: clamp(4px, 1.5cqh, 16px); + } + .expand-overlay-center .px-2 { + padding-left: clamp(2px, 0.8cqw, 8px); + padding-right: clamp(2px, 0.8cqw, 8px); + } + .expand-overlay-center .py-4 { + padding-top: clamp(4px, 1.5cqh, 16px); + padding-bottom: clamp(4px, 1.5cqh, 16px); + } + + /* Team answering - flash 3 times then stay white */ + .team-answering { + animation: + border-flash 150ms ease-in-out 6, + border-stay 0ms 900ms forwards; + } + @keyframes border-flash { + 0%, + 100% { + border-color: transparent; + } + 50% { + border-color: white; + } + } + @keyframes border-stay { + to { + border-color: white; + } + } + + /* Category header transition - smooth crossfade between logo and name */ + .category-header { + min-height: 3em; + } + .category-content { + position: relative; + display: grid; + place-items: center; + } + .category-logo, + .category-name { + grid-area: 1 / 1; + display: flex; + align-items: center; + justify-content: center; + transition: + opacity 400ms ease-in-out, + transform 400ms ease-in-out; + } + .category-logo { + height: 1.2em; + } + .category-content.show-logo .category-logo { + opacity: 1; + transform: scale(1); + } + .category-content.show-logo .category-name { + opacity: 0; + transform: scale(0.9); + } + .category-content.show-name .category-logo { + opacity: 0; + transform: scale(1.1); + } + .category-content.show-name .category-name { opacity: 1; + transform: scale(1); } diff --git a/src/routes/layout.css b/src/routes/layout.css index 7937dea..3649c1a 100644 --- a/src/routes/layout.css +++ b/src/routes/layout.css @@ -9,7 +9,7 @@ --color-kv-blue: var(--kv-blue); --color-kv-yellow: var(--kv-yellow); --color-kv-green: #009900; - --color-kv-red: #990000; + --color-kv-red: #FF3333; --color-kv-black: var(--kv-background); --color-kv-white: var(--kv-text); /* Additional theme-aware colors */ @@ -70,8 +70,8 @@ @font-face { font-family: 'ITC Korinna'; - src: url('/fonts/ITC Korinna Std Bold.otf') format('opentype'); - font-weight: 700; + src: url('/fonts/ITC Korinna Regular.otf') format('opentype'); + font-weight: 400 500; font-style: normal; font-display: swap; } @@ -87,7 +87,7 @@ --kv-text: #FFFFFF; --kv-background: #000000; --kv-green: #009900; - --kv-red: #990000; + --kv-red: #FF3333; --kv-black: #000000; --kv-white: #FFFFFF; @@ -112,6 +112,89 @@ --kv-shadow-category: 6px 6px 4px rgba(0, 0, 0, 0.5); } +/* ============================================ + Kuldvillak Typography Classes + ============================================ */ + +/* Headings - Swiss 921 font, uppercase, with shadow */ +.kv-h1 { + font-family: var(--kv-font-body); + font-size: 48px; + line-height: 1.2; + text-transform: uppercase; + text-shadow: var(--kv-shadow-text); +} +.kv-h2 { + font-family: var(--kv-font-body); + font-size: 36px; + line-height: 1.2; + text-transform: uppercase; + text-shadow: var(--kv-shadow-text); +} +.kv-h3 { + font-family: var(--kv-font-body); + font-size: 28px; + line-height: 1.2; + text-transform: uppercase; + text-shadow: var(--kv-shadow-text); +} +.kv-h4 { + font-family: var(--kv-font-body); + font-size: 24px; + line-height: 1.2; + text-transform: uppercase; + text-shadow: var(--kv-shadow-text); +} +.kv-h5 { + font-family: var(--kv-font-body); + font-size: 20px; + line-height: 1.2; + text-transform: uppercase; + text-shadow: var(--kv-shadow-text); +} +.kv-p { + font-family: var(--kv-font-body); + font-size: 16px; + line-height: 1.4; + text-transform: uppercase; +} + +/* Title variant - for game logo text */ +.kv-title { + font-family: var(--kv-font-title); + text-transform: uppercase; + text-shadow: var(--kv-shadow-title); +} + +/* Button text - standardized */ +.kv-btn-text { + font-family: var(--kv-font-body); + font-size: 24px; + line-height: 1; + text-transform: uppercase; +} +.kv-btn-text-sm { + font-family: var(--kv-font-body); + font-size: 20px; + line-height: 1; + text-transform: uppercase; +} +.kv-btn-text-lg { + font-family: var(--kv-font-body); + font-size: 28px; + line-height: 1; + text-transform: uppercase; +} + +/* Label text - for form labels and small UI text */ +.kv-label { + font-family: var(--kv-font-body); + font-size: 20px; + line-height: 1.2; + text-transform: uppercase; + text-shadow: var(--kv-shadow-text); +} + /* ============================================ Global Styles ============================================ */ @@ -126,5 +209,6 @@ body { body { font-family: var(--kv-font-button); + font-size: 16px; color: var(--kv-text); } diff --git a/static/fonts/ITC Korinna Regular.otf b/static/fonts/ITC Korinna Regular.otf new file mode 100644 index 0000000..c6f8291 Binary files /dev/null and b/static/fonts/ITC Korinna Regular.otf differ diff --git a/static/fonts/ITC Korinna Std Bold.otf b/static/fonts/ITC Korinna Std Bold.otf deleted file mode 100644 index 694437d..0000000 Binary files a/static/fonts/ITC Korinna Std Bold.otf and /dev/null differ