From 0955d6ca650061d6760108ec8dd6840d95be5535 Mon Sep 17 00:00:00 2001 From: AlacrisDevs Date: Mon, 8 Dec 2025 00:51:27 +0200 Subject: [PATCH] Jeopardy MVP is ready --- .gitignore | 23 + DOCUMENTATION.md | 541 ++++++++ LICENSE | 21 + README.md | 73 +- messages/en.json | 27 +- messages/et.json | 27 +- src/lib/components/ColorPicker.svelte | 821 ++++++++++++ src/lib/components/ConfirmDialog.svelte | 81 ++ src/lib/components/Settings.svelte | 83 +- src/lib/components/index.ts | 2 + .../kuldvillak/ui/KvButtonPrimary.svelte | 2 +- .../kuldvillak/ui/KvButtonSecondary.svelte | 2 +- .../kuldvillak/ui/KvEditCard.svelte | 208 +++ .../kuldvillak/ui/KvPlayerCard.svelte | 70 - .../kuldvillak/ui/KvProjectorCard.svelte | 67 - src/lib/components/kuldvillak/ui/index.ts | 3 +- src/lib/index.ts | 10 +- src/lib/stores/audio.svelte.ts | 37 +- src/lib/stores/gameSession.svelte.ts | 171 ++- src/lib/stores/kuldvillak.svelte.ts | 363 ----- src/lib/stores/theme.svelte.ts | 26 +- src/lib/types/kuldvillak.ts | 1 + src/routes/+page.svelte | 4 + src/routes/kuldvillak/+page.svelte | 11 +- src/routes/kuldvillak/edit/+page.svelte | 230 +++- src/routes/kuldvillak/play/+page.svelte | 4 + .../kuldvillak/play/ModeratorView.svelte | 1166 +++++++++-------- .../kuldvillak/play/ProjectorView.svelte | 611 ++++++--- src/routes/layout.css | 92 +- static/fonts/ITC Korinna Regular.otf | Bin 0 -> 33436 bytes static/fonts/ITC Korinna Std Bold.otf | Bin 28824 -> 0 bytes 31 files changed, 3386 insertions(+), 1391 deletions(-) create mode 100644 DOCUMENTATION.md create mode 100644 LICENSE create mode 100644 src/lib/components/ColorPicker.svelte create mode 100644 src/lib/components/ConfirmDialog.svelte create mode 100644 src/lib/components/kuldvillak/ui/KvEditCard.svelte delete mode 100644 src/lib/components/kuldvillak/ui/KvPlayerCard.svelte delete mode 100644 src/lib/components/kuldvillak/ui/KvProjectorCard.svelte delete mode 100644 src/lib/stores/kuldvillak.svelte.ts create mode 100644 static/fonts/ITC Korinna Regular.otf delete mode 100644 static/fonts/ITC Korinna Std Bold.otf 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 0000000000000000000000000000000000000000..c6f8291c4bc2e20291a185bb52cc9aec4ec0690d GIT binary patch literal 33436 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 literal 0 HcmV?d00001 diff --git a/static/fonts/ITC Korinna Std Bold.otf b/static/fonts/ITC Korinna Std Bold.otf deleted file mode 100644 index 694437ddfc8345111aaf6d1c2568a7c8256b01c2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 28824 zcmbrm2Urx>*El@OGBY@9Hp(~#cby$I@4Yt#0lQQM3sw+8P*y-x z#DWrg?*=$nq=O=%SNUs?jS{tFPPx!IN5zbsf6VVDXP*aDAZ7tVhr}@B2G&(lMuuGc!*)iO{`!HsK>K0&NZO*Ur_W`d?|)Jh&I9k1k&%PZ zaf+sljWUhg@O|p%r^17>Rft$@%o-chjFFLXIz?5Fq0FaXowmQR6UI%%Ihqrr&tu^n z{=fpmoGHaaqf3S|${1fdE7YHJF+Iyrh?n91HNJgL8(}@)FKH#Ep#s08jj2vl#+Nir z4Wc%DNi*2?;+Hf_O{4C8Nw=fEQS|(hR#9CP!@s1}R8Pe$gpb0w9gbX~NcxgiP&y;? zFKHvn#Awu)w2~TRH0ev)80#$hlBTI3qrxv~=0EaTs!)jw+}3|P%1Zf99TjD(JohDE zO*ts#@sUx2iOm_>u0zRG2HF#`lf+cZ|<5L8|NP$Ywj4~ z8yOQ78RH%2?`LIhJ~k}Oe5Rp&tocm;SpS#=%ypbK-h66gOmIYmx4CPa-?+#yzX4V@ z1BMPaEYR>TC0NkkQ1V|Yn;Y_+W=%5x+``$_9H(GqV`Dpfd}NHjA=hf)fZ^fZq5hF^ z0ajtbJ_D=r*KIKH6QOC6CWEH6c-mY%-T9J zG11D~03s{j$Z+d8?BM@eU>)Wk=pAMi6c-*wji(~1C@PtXp@I>+f~Yve%{_PxpzNuk zlpWRY-{~R$PTOOdA>Wp=!QTK(jivmsA0Nse)2`TCEcWA1h2ztla-<>sJ6Rz zTbUnag=L18VfZtrX8wD8Lz|h{Iu@@ODgp0?x{kOf#$(CUwmyQf&j`vJ-(9gcKWZH2 zhT(Gn)-=p$C^Z~=o`CriG4=m$^SMR;f4BMHOPz4sNjT1b_Uw$Knt%Cr!+16biQ#RK zjKSIdQ`d?bh^>Yr6uhZW%#XyG2H^a{@JoH#`Wu9A0}#drIRD?iFSrechA%br9f)l| z!`ATThq-Z>?~N$~w7oGMig}SZ&S$tARw1ShQgPTavMptR_AFeZC@c%WHoo{Y|1$gk zvqGP1TOs@mqm08k5jc80_F`B+!|Kn$v~OEW!?^J{ugEt1iNl_UQP%iNZ2MVZi+`}g zstpsuu`KS(3=A0e|E+~JLeKySZ+u#{jTnw8!yl3w!=>o{g}QY$P?)xNO2M?%_ zFE#!n6SItzNYL4!cATpHSL&}jbTs8V37x+&>!Q(i6?Kw%x9&Z9_Oh_--RIlB{rX#3 z+YGQBILL1BkfHX&hL0FIYV??tapNaUoHW_Namv(b(`Pt2yUcW*HG9rnw|VmyEOhtq zr1n+suRYpu`oiUlm#$v9cKz0ko43EebGPZbr!M=L zne$=Wub=!1U%!3x^5?8Pd2PXBuTAg$fB7*aDY>Pp=49^r$Th)6yHR(h``6t~y9?bl-F4mjc6aFR)!o0>C1UjV-#C&vj?f!N@Wv5#Ql&V; zSj9|*7mkpMBb>t#?tK}7H#alyYHr>(g8lz6LPs2-%fCnP!x0pJ|NZwfYAjw|@tXJd zNs9X0>+^E{J%ghDx{X(VJjk8?9Q~)|=X{EK>iTrbQ`@J~(-BWzK1qDi_~h8*SC5}O zMjqwy%g1*f-+X-b@t()|kMkb(rNjOkhRj3^VH73`4xj&V8TwYJ6&)0v6v(gq&wmQT zY5(8<0&!my;~rc_?WMA)5NZfN^)M2Wg+Cm+rPEg0Fvk$1R{+}i`Hh$^6- zQIDx7)KlsQs)Tw@WmEZ7F14O&q?)Po)Cbr9$4fBXUf=kXG4IBapsLMXK$A(2hh1F2xzH zMR@PPZ>^#ZA>Icn5+?YE#d$l04!#ps?JIp%@>nHg~$9soGM8>r} z!~A1o+Y*5>{@8cS=gi1Oc(D6pXEwIu?F|o`u@elBfn&eC*?0#82M-=#JGAY6;JERh z2e$h^eA9pBn#|kSCfomwYxEdSXTGFoj+)qlyO;tcD><2g7OPo+>i zWug&dmf`wrMch4uc=(R`4VS;Y!cO6$@KZced{BHe3O0&1N;g_|TscKKQ|YM;RYoZjlrRZV6)Iu*Yfc=(J z1+a&gWrcfbT4QE%DzdV(xu`x=Rdwi4P?fiLP>{EGRnQ?xCC^;UkzNd0-1>8R;A!Y%rc(Hd%ATSsf|wF(GDJhvx5zX|*TJ z!=N7toXe4D7(n_$v$;S3HDXa_0iEr4~jD!Hh%9lJtdI@)h&qd!XJz4-L#G zI{1prLn>l{Aa^H&fF01}sv}Bf!Q#RzF;WOqPDayE&8*)=yS^O-ouIo0q95G@qfakJ zlF)2jHt92f=oSv%5Nn3D)R4p07arb!OnYbB+!+#y?2a(8;vhBU<=>QCx#Djsb^YSM zsUwZlOT{DT3fBt6!!S}HeDkB3Fi;CU!Tvf7li*OR2ce*!=Fam?XHNZ`G$C#EvK1M+ zeR05ipnn=~evp`Gi9q_zA^jy%BF}*?#6>f>chH3CmV56PZP;G8Sr@j4RsU}>W1RdM5eivmk4aiuLm60>F6* zr-Huon7K=%mdw|x0u5h3TvVMnnYRf)tLHq{bq!Ni|<6MHg} zcoGj7PDX);q^dcPv!O-;(-77_)i-v&)^5%!T%@d<8sMYhfU?Cu%ZWu$?1Y>Iai}HeF|Mw zrmw*2@4ti7DX`?9@;6i|O}+_G)wHGwqMK3@O;z#)p)b=qIbKwgsAl;mz3O&`h&`LC zpxrnm)27p0VrK=Zvh=d{I#{br*Mp4;%KEUxmz2?SSyk%ZV@Z1L-r~{<)hm*w2Pc(a zFVARZRm2uC>uVU1tJfi&B*x9&&W@vZ%5~F4cS&r?>j$u97Xm&08 zlS!#bc{|f~ecCy;o7OI?-Cx`QZV(s79pi4OMysZ)oK*>`pWBabKdF6k`;_+QzZ&<| zxvwsMb>*w~YDV2h?W*=u$Ef4g7uC=e)4rymrV*y~e1BfgpWwgiWZJ1ur$wF4bowYP>^!8ieP{hQ-M%?zHrQ;E*#fg5 zvm~=MX79RKcNyEIQDdXA)ud`lHMN>cn&+Bdw7hnLcDr_;_PF+p_Nn%VuIjGlU2VFK z?>fJ0Y}bmeHC<1O6U05@_qs8-c%$xi_duNY!Z8U--CwC1y|j7M7=s^e`rz1kyys z>}`7qGrXAC|H>>PCNybh{~HsMB|#6>|nm|xumTZHTeqW*gcc4J+p(^=82}w z(EjmFV4*$l1p8aU1)@x|b|jt1R@n?X5GT#Zk?s?nhu40;WBvAxn{`oph-vS$JQud6wJYa?W|gxiXIWuxp*j$vYMO z2-_d%Dj79#lAnVvE724N-Qc~Tlk6aj;dFelfpyrJpSNzk&Qnz9iT6RNloe#5VE=o6 z@P#fK=<*ZzHUp*e*@r7b+L6v=J{hjTQ?4_a2PTBmVJldS%}%vU5t_|^LR#2GL%Q@Q zzV?LD9So+y1ll13Fdv3%@GR^M^N0yxD|r(rKO)-f_+2UM;Hq zTH3U)81$|~yY>%&SU10FgwE1)qT#2hKnN22=p$|y_%mMfN-~(fHWDltoNQY%G^~dKMU;!Qi9rKBk?VzQ|z;=+- zSv4*82!wKEvfmN^O?O>}lkOyDPEW!~v!>;_uNi8>zmoUBuzAePC1IKKwQ9uJea$fMrRk-H*we5T zdJ;Tq_>Z86KZJWtK7D)JdsxpJzx8~QG>>#;5}O**ZfcRm8+{dqy>Mw7R3!0l@01)p z{!jzokRFE#HGWJ|@Dv?UpJp#^zE*r%d*(*;A zOCD&UFN}PLxFHecCnCDJEcOYO4BZ}j36o!%9>y)V9eVa^;AMCs8wro=r;N7uavW&4 z;8OFG#`8}kB3>3Q9)e!bPse{cbYang4N?PiVK%$X-7rT>mf-H0M#9L9XT%l)CG4aZ zwEGGALDwGtB3-6UOqkI zI<6q|Ou%+81Jjzo@evHs?LM`&Ci9X>Yd)h+c?Px5VBs^<23$B6Ik;%(M2y-Na1I0s zkE$;}e{^)}sFCgtLx*`^zNh0~XM?N2e;gP+b5cJIPOjrbWP^J`rzc?bV)|XXZMtlh ze|xK>uCYmjJ(<;;cPCwDldrI$W5cAYsXG2GvR+GtD@Q{oPn{d+Jk@>wl`F?8FCgfe zVc|>I`w~Uqo`i0I8#swiaIK}I@bK8wQDbLK8#~(b@|}AJEo1nQDjPqPxU@!t;!=A-zcFH+`Y=#rqYfE2JExbHnlE zf{+LfeoNq5jg75vGq2#_F<6;C0INwi_$VA`q`+4o`yMn0S@?6Z&k!+J1Y?*Y$OFkV z`VLE8Kp%JkeKFZ;$Wi~&e7AX>_#R%Z7F%Z!o7JK^SCn@_q0l-IrqSbActQG*7o;yH zWkZho6;9$5On{=7rj2J#f)(Lkpzza`|AYS>+37jLi<1nPL#JzC^S%FFm!r}Vm+{-j zX&tAeEt)2gA!pdDTff_PRr~nSEZcs4B1cLd*);!0Lu=ihm+tL$ypU!bTAV(3$+Q++e5TOGYItn z+~k@$cMk2}d3v9evxdgTQ&#OutS3Thy2!v4|fPw)a0#DkLuZS?nd}h$*lf?znuTV-4Vbnc1bw2kD z3ZH@7vw#}Yf1zBk5xQCzWfThjI(BTrfXD7%e446Xu3n2Y6`c~h~ zQjP%$cb++YUjvH%=dEjT*11H}2ubZT?XK#ndwgSmz|KEpX~ zZK!VG-$P7`i2%7wREpk zQgIZw(XU9{<=R_X5mXEJUCV z!~vS>9SPAdgtX9>owqjI)Yl11xaK8y9jj!2I=|gGgwihc!~2pV>n8Z z@OXzoY#$C%;0#GIsBJu#AtjBgVFz<$5UaKk5l3IjZ)+4Xiw*K&yny?&X%q-ruz3Fv z`bu&Kd$E;g$AwQD>OA}Kg_4ciwrC@bKn@{Nx3zP(QV-hK*jkc>9tf6ZNaCXY%tf zQO;-LIfH~t;w(iq{Dxz!hZ(X5OjOGDvWZ|0uW1q$z|^eDjb9l&p>>PdO8?AMuMCp_ zm{G$uY`#ZiN6*pT%rOIGcfBxCcM zg6-97O=?Ioi))6%z4{=x`=A`lse2i4Kz?pO0E2+L-he6))nbyu{p2bnbL0`j*Y_45 zA@3)qy!`k~W?!1t26Lq%Cy54P|5-seIc0YA zY^i>xr+$ofL10R>FH$mjmyO`T#OEwZir4aTQgXq@4C&a#+9oj83=;3(+);fVkCU+d z$6_0`Wo2tO?2>RJ)xfT2ummx=0yofGh?aE%ECCgJZ_UGKYt5iBQKNNW>L|S4akc54 z;S{DXlJ+`682%9=?abdq}O+=Hn*ljhhyQPaw9kN7}xX z<%icIN+bucRVxa@HikoJ>+fdJoq-_v_YLLitE+Rd)ETv$YnBJ6hncA50iU~C2NO~8 z5eD<1hfje}jMV;DfLzE;b;1H6mJa4ePRgU2(-hq<2ncAdtCR;w$|fS0iez zN7Twms9CfNnc@RxFbo#cmgXfD# zu0}Uk`eiOmM>Do}bQxFw4(*owTG+Wp%iVm9dXV8Uz( z^;sK~$4x#JBmR7yRij>~8FVkGIK3nP5A%w=^D2Ci>85_gB%JSpDMQ>_=sta-ww%~yDk0gsBY~}L2RS{9BXBc26 z&-pUL?^*uS^9R3>v6~VL(>#}U34uU8o49r<(sX1wwwv+tt4F0f%XaJYy66!N6SMR! zUG<8)s1S!aNT5c9FvlWAwLEbdhdMq~mAZ?ax*{TaPZ#+|gT-;&thu_gVXixzwKJTf z-NO)QSP>z{*TXEVa&H`m;8IWK)T_iMnQC63H@%3xBqB0Df+=l!XJ@f>e|HY}LoDtY z?F-cA@^6=lxfJw!~XM4SvmAikB`%Qgbp#|AI<2oKN>4SIb0*OSkHmcZ+w zK>QivHG~XCIl5zW5BTOt!`8j16dl(c)!g!a1A6F@1Ig0Ek04sVT%cc{Se9st-w9@V zzMxk26so3( z)=;u+S4w65sxEr6FofNmnYXAQ9(uP9F+)ub^pS@Yl&srSy4J9m%Q@&z%nZGmZi6ZJ zz!r}l9yfM-$PgZoi~tat;g1g{xx*osE?u^0Z@pGe zoI}|1m1~z4MDK7in?!sD!;Uriiz}+i{3ouE>R> z#18%V7w7K_smV*?V^^Al;FeCrr@;L{Ot(j8uZ&rNsOhF>BiE&EODQ3p zV3rww=XdD7wRrokJ%*7+hp@X=_J~d?QBHbGHZsKMy7Y*@ed+eVJwgp&ea?zsdn;N9nB=?UL1uEKv4rK}ak7SC!*$36xxN^>2dA+D@rlAmJ|W`OOA{dFK{z>&SZS$}sM z_BysGweGA2{MldqPZHZ@Sm}D=w!n!RHQ3xwx@USzUdem$#d0n*3QtH)*M)@*WRpdJW(wnB}QGh{1mKE z>1Yn;1h3WsnLe~zW=Y^l?a7kr6PZ%xK`+^$bPPW_L|m5us%H4uY!9g+_ooK_ zG2E5^6390hcHK!Q4f*C@L;{Y|$GntP zsjE_T2bNp|bNcjjmxDvKL~Y%dd=+?$?G?CimA;7sm0*fo>E7um8yH1udj2|cY@4rqq8&8CU+udUz0 zAv`t!t_rBiVVf+j95%s0Z?{~yZHmJ3 zpax&0sG-o_sv+_ve+ACVtiV6J7y*o@y~ZavEFxAnxsgoR_eoPLP;Zn}!bY&rXrrs2Y_LpuJ_^^2Yp*CTr@FbADy zZXK`fNxqs-zH;6dcqClnUpNKMb{R`NWqK+3M&sld?S>;*%=@l6N>aaj3frD4KK=;n zepLPj{e=d%ni@K~=0M63?U5s!ckh>K0#2L=sG&>uZ>~D3X-Kb)Jfy3sb~_mmK$rRZ zZFbi#^hk^JmjY_$&8-fkBYo5T=W7-e29){fz+t&C(fRD{?@yn*d;7#x2ZvdXC?gXm zgW~@EFG^4w)b?t9j(GP5SRju#-oWF5gB}^ZOzt}525=|HyZ}y3d{4;cXqFR|w+xwI z-f!bO_(OJpsJ?^TlvLKS4Z<^$!o4rr<(N`rzUVuF0jCv5iBZ3vrE z5v~nMj0z1YjmPRQ_MIx*Tq&*cNcM^fz|-Z3ZPxg2kyI=XA-tQg<&YH(4oWijw|y>aJzw=~Z1; z8f48(QMY|x*>^RX>gb(8!O`(S{-txa2kD}@bpPeisc|N1PmV;@w7jlE5f{!pJN^s> z+zX4jUZhJ9u_E69H3q)UlUiNa(5whgR2y{uj=W-jR`RB5??DLmV`iDHNYel<-b=@aC|<+kl7g$$(;RFc0xe-hdi5#gC}!oBa9e z6a~Xm5;%C)`CAchR@@&QQ^kvIhMC)Fo2mSn*HW;e%{){5k9nrpMxNM&W||3OTDvb1 zUy+QLPy`cBp{fvt>Yq?+XY%5~8H3JF?hGdc+qni4S|y9jzeMfkYxoLTZ1G3;2WNDDsBjQt#pUw3yFVo=ZdF5BHQK}@ zgr2$ArhL?XymsYdjugmDNt+fiLpx(y#g!$7eJvwXkK1&Z5-n0)`f}Mpm$Ux6~Bu)mBxf`Q}Lc!GTLj z#}uN_IGpgga&Y&i@(SHM)MOfL z>_w8!I{MK4*{cU>(%ay-qMjkPjB2?i3}cx_seMbY&T;Ra+A%> z1rX@eLx)$@X%~v1n}nWG+;p-XQr`^S_~HGhdj>y3ZKp;OV>X$P*AtuITvp za!luPH1hU&MLIh>$9sG08okDDoT{DTn7(MbM2z)pL-Ezk4cg1+BSuLhRy$-nCYbQQ zjg6f>dzhxzOV?MA?i{*ySU2LvmGDp6OP9CqJ|lrDkabIRikPGAG4;i}l@fA5-Zup@ zpE;d-{KRSPsY=hexsrM=>KagVm4gyt;eU}{QL0?Ge5rZABsX+EqVAucajODxfnM|xTw9{Qu3_$72^*bVrb&hkLuRAk;^3HKuTxw%Nk^ZDVDuO8JfA?m^nPZa6*(Z_BTKKgZlmpZC2k{)DcCMv?6;&c7cYED+G4m8_h4o_=pu5Je>%R z$P;`PpRMHtwt4%D*N5t&-F0?EwRXIQKl&~B?#qNsyR>ZVu!Ky_rF#m>hTDz9tO8gGRWEP+-DJM0JdS22421H}W?GS%Ql#=lP|?F;~E@%uA8TEwC`TvjvK{$ptW zzRl3y9$hj%8`|63llClZA>Xg%P`O_4tnRb-*m^Y6M#=Rl9O>Fkh?~llXo}gNDrnTQ z_hzT7d~^~lc2?hj+JB~W=?M>k;tG)`FjodIPWH?ed1wulZ{ZptZ*Z{r zATkKB3amOkMYI2EkwlX{$RFG?SOlHO_?CbDBO4D92>N?`WbIp5e@Uqa$z$(;)g48H z;jV;_a+AA~bwjT?JZ`>z?XhlLrr;SA>g5?)d01DMsjsTj9!ca3Im7?pExO7Y@G(b3JndhE)o{6;dGH4|Mtc_t|!!AhPx+C%f2ks&uGs#f_hw+ zpXYFBGi?3|t^3=-YR~g##qKwtd;9t=@bkp{W?_veC5kzmM7o2u5RwYHCXGvQMhPYtL@WT|Ak|8wiET%&iCj`#a4)=QS`p25f4=$7@IA%*{43DK>zs0Ig8oT30YxEHd zXy9L_ zM{yG0l-}(-G8a;(>O99ebLFRL%wM*@e&uoPY|)&9Q7!ovhUe~NwS-vUb5+Z3E76BLlOY~}p1VWEYBbLM-VI$nTK3}LfAzsCzqN$)=6twEhJMgh zn174KJzIXCCJq&0r2MlZ*Fz-!;ET&4c84M>@w1bDx_G?JOP`Dj6?>ye?It?vUqna! zoBc)pU+-ZTE{YffzP-6oB63GQ7NWu3I$cD6>%K577Pba|JBIE))6Kd}-Q8YYj~={X)-lfUo3B&*CSFS zpg7r-PBgFk0jiySCOLDvPZ*G8MM>$JUE2BLC=Tu#_Yy~ozseo0#2~}Bq06vksE7_j z=rzpC>3j=?{^xS~1d;!pf(x?Hjl)JEaJM%%OC;mv6bo^#I6O zVU%Pe6yP$K9~{W_d?% z4pqG9Vg5zS`w6wYZ(X?$Fw#QA0^CfiI6T_L@u&g*1LNqiM)pY1m-zEZ1yi4;|0Uydtd}y<$XP4KX?vaq;NM@+0NC)ZKK-q9qv# z+VI7@uVhO6wY!-Q*Im(7?9R#CF7X?;>?k^wUuU9DeF>u;oPSW02v#o@Z;|-iYEa6P zARCqXu54yeW>Jv_W}0LptpH2}3|qwRNZ0ZK#r<$2`_$iN{hmEirPw`5^|Sf&QsQ z@Tv%2?o?DkUwH@gQ?~pJ?}ZI4c$4Y4;gaw-jSNG#7BBQoABJ*e7ifRxhdVlFX3Ug+ z^Sfz#+tyv1BCX6{RZyS-Pc}CZhL! z#f@fi|H|v*x`X$(ynn2@oc3rhzUw{CT?)cS!PymI9bL#>;7*1BNXdaPA1pJi{a zTmPm&rv}UZzqSlHsHl*elroZeA!t2KI?hX5*;`BYFpvqql9_Oih8fJ`Z5feUrL8fW zeKyXoDll7dcloQ#9VV^Yf*DxaYE4#?*K~-yi$$ds`Z0|A7N z7iBQ+kCn%fIksDT-qgdg)+>89qnpZ)z}$wAn~H|J@~ykdmO^L{ew}c`TGu~&j)RAr z$&Fbvj}6!M9zCvG_Xp#jKRxx`wNsMyt#c6EH{$F5!0s%MkP#y_G?nxgMQyKZ2@-++mk zg5|pODdBP1*Mqeofzd(PQb@Kws!j{kJ78owNlKKv(jYD`t|;EMdfEYPMfr}}{a!ml zB>w3D!i;GBx(HpF7-)Yl>-)NUSxu(3@>brB50cl(LBfKt$VeY;aKh#ibRu!v77?`5 zP3t(_-8ob`zK}jpzNh+_wyJzl@*at=h&r|>rCxjfWZk7|X+G&AEC`K`^3n#x>@bYv zwly-icrG^eaKfhUbZvP>&2jC%-HVedCB88F$lk^E+KVR+UqqP#r_!?tx;H6K%FC7V zG`U=uQ8t^XXi_#BRx;B_cbZHd4VH{o-wV;FKutqM6IsJthVC>tHlgwbJ6L+(v{7E( zn*NY)lW5rm`PDC&G%q4eQ?Di|(L;JV4mGX4x zA$W&-`_9oihSh^cdcuDC>GE@Eueld|W2BGIBF`Y*f>~)!WPk>31;bpngkC#LPiFgX z4K54UMV0PPIjjBg%CVTx?VAy^Z z1|U~sIPp>HwW1vcU-F7RiXcUtVyPleaZPa-|BZ>M(YHpUja-caj4l{GP;$yH7Qf~_|d_1B%MaDpmXUB^fnB(JWAiDo9Rys$4HDVGm)9fIALg|8xz7TX405t zOaTT~Ze>cCa;Ao^rVgnplnOlT8jU_#R^MO9y}kLberB2ikXXVEc4&i7HZJ4BzUR@Ch8GvCv`aDbjDo^zY=r_z&CmZrHVd{bwuQsnyBui^I^zHoy#Zi0O;>tlhcC;E9wJ!0ugDGH-LV ziOS#vL+nDBgBWdR!x6>VS?`bEuc+UP5w9IPLq9ze8X^wCc)GX*!968xg0t2?x}tiS zsyK`p20$sjmfZI%sXJuG<*}lDpPx&-#tkvyYSDUa? z+cqCbz0eR!i0thmjUY9WW2ahQ1!Gs7_RAZC71l9BE|6Amoev|=j3 zpa$!n_{&p+-s|_G9)onM#*~|~=}pv~cfwGaPD(jmULaIEPux0LYfZlLF_*|-IMG1V z8n-D)KE7kOez!hnU0#9Cf5%8d(R0r~j=!({4Zf*EuO4J9PR#wNsXbL#UUhMa`-(No z*R0U(PoXg?6K9QqnSb!D$Qb{DftlI)`Puo}@;x#5k06pFW0DI?R!B2b={XxtrXdzy z+;eqzQh8jVv|&+VUXnIGQ`KrZss@W_L;B(>q!J<{d{TWfOoG|+yz>0AO(vw4^<9tOLh6jD~{}~ zF;N+!HRC36s;rmt?w5*N4f14U9G@)aS_{e82Iktjs?8U)FpFvJ*s7=P z*#f=|VP`CHUhbx~VK8#?M_R=+W^Y|nIMW23=T*C6=+PXF#sw7{l@+35r;4fpWYxE@ ziY{52P-}2JnH$1ZWUh@}ACG~H=vH1lM&!Lxxhyv>phk6X-^b(s(!8`fIB>y?^f+(b z!2Rs;&DF(?+J*&j(y2y; zc8vx`g~2*K(ILL)5YZ6bI58gAX{7j1(2oj!3*}V%^(#HrN*I+nO0R136;=sv$by5V z^I_a^80S+$t5A9E$Km#9hS@iNZo+U7ITQhe+G+TsS%t2|XI`p|k8W;6&f$k$gGUk7 z%%P-1%HrI-6yzGB(RsRt!41_F_0$ez68XxJ43V5wsG&1>jKUyQ$S@|Yp>p{V?Q2lh zerOChGIJyPd#hST%eRDA(}~eVVys0^bPLj#_^398&o7P8hQ=m^L=?yD)gRljc`qW{ zk$@^s&wwCz&zgY4hYb<>YIB2;=?nC11Tst)RCO2`EPN}fUXH>~3eO4; z!1Kag&w6I3WDpYEz(1K!( zVWA5W{&2*wP;Iw`6M9Pyvs6MeYN9bP2$vpGvsCmOMnx$;;r}-ogV!v|-N=MWF-oU~ z;bp7TQ^S<_Uyv02CAw$DjI2w~q81oen0h=3<7BA}E&0tDHb z1jMa1!~jt?NpP(ea;es(bt!tab-}vTrSAT<+FDz;y4J1kwzh7x+N#B(z1E)pofE=h zYoFf#KL35n^vleAGvCZN-+Z$qeS8OeQm#k+7wK0h6=Ds2jB*CAH)uX68uS9FJE%V> z6qEpp0Ll3JiBtyC6(BtyE2wDn6D@d5IS5WteSpapj&vZ(dVxZa?gJuv^b{y4d%+Fx z6jZP<4fP*WGQ`2C7c8uxf*{`uMEdkXECYE9Ybh~eiJ*>h2R{HPE(!D;?jGWv$QSVA z52aECEc#SHZ-1&sbKDI!@kZJY!~o-^JMF_MkPbw;6R^(02p>fB>Oj)4Nf6|N5PKru z1#OFu6pnAjziw|h14)nFrP7Dssgf@b-Z;Ltb57?0KAm<5x@N1SObEF5)m*){5`6D7eWAzu}Gd!W)o^&X8 z9dv-CZN5TuZ%e$tSbc@~irqsouph7rw@@B} z!Dw$Vu=@sM9TA!UKABgnQPK|TQ}iv=L!ZRQcckOHX_yNdW#@9*^8p#_L7XLwFn^8eS@fq#gdbdLcCth(zx; zx3Ri8-rQWi3rI&-s2^$akK54`u)z4ffHi527c}!X@a>A|jr4ZVMqVq5vgf%%dLg}( zUf9f&H1p=sGg^4IdQ<4J^o(a@U!e==iWVMClaWY3BA~VjsC4Kwtc6nsPA1wH&=GV5 zN(d|_kD*AXAeV?V8HEFC&~y|ip$$hs2kx6m9ftF&*4R*)62GWu{pVIRw5oXT=o)Q2IA0bLT zfwM$nZ$7VY&g+pUZMXo_%34Zd!lkKTzE^9hm@jolO;YL(nb$!?XP_1!PyEmgB_!v; zf07mmrV#n`;y+XQ&m`0&67XR@pTmE;;?o|Io~RoDEWad-jm(bg?|3wHC7$k%64I`q zxh`Ny;j5u7YlV1~byc9B(SUVCYgXHn8Mk`96nI^d5lsUoPci?%?`QsLH3!hW;r)+Mk zwU}GL&EjT}G*{ouK@7j7II=fW+%@!%8xD8IrnR+J+fQy0x2SFYucmFI;!bc&xFtl2 zJ8hLEF>mc_JoiWBaSFlm&&-OoHeY8cvE5()!-{P8$F0U(%qFZ>J8+Bn{CoW8C$PgK zl%C^Gb886a|Mu77{{%lz0@S=1svnR%<=6xKkq-cxV;FGSB3nvQfv7i#%BP0l6e|NN zAVDR3A9#A3fK7OSItaAD>)1&=AS=P{6oeo^3t`wnWe6g)6p5aq5$w@d49>KE2#)A8 z4(NTU2sC=o;DjH9AVE+0SOr56ocJDT^f?Qyk-nVy-ki`w1mB+o9A~uUg3t+jmMdiZ z5V~M2{!~|tCID@bzG=R1A>X$h-?u&X^(53)AUFd7Rf#rI5L|hk9B_+C!-Pr@~Dpdcm(`-MATpeAAVF~YAAv) zKQ;#-JI;WHa}fNfxd<-6VXQ;@^AQ|i>&+-7Bew^d<8jnEf#3`z$J5aB4EA?_pg5j~ zRu^#h@dQ%iRp{^)kQ_VmmJ$QE@dm%+kUIf6pG4?EU>s>EuPiUc{iONcM{Zc;>;eij zEJ$;CNLxANbmdPquVBPpJeTrfSc{jx*db(U+SEtRw7sC-;^hpMgMg-GF07MSVl5Ha z*-`8Wv9!0C7SWa%0WGRmvpH;N8;<|GU8?EJ1!Hn6?QduuS{*L~e@~ z8qOD|14c-!OBuLr^38gS>onFP_z2WP;Fj)ty5B6zVE8J1d*XP|1^287l2yMxLCk%+V?p zp-iy8S8NPpYs%UO+ohv(Yz!MM0~|K_8`9C4jbdeNOhjaKY;^39zyAPgmbJAxZAS@f z-hkbw3u;E@>`QT|XQdvh{3}!_cV4@88-d5flWTU;`AZ+hxKM{Qr-_sd@$!p>}ueMcaKL$_5AE+K?o zx?GopNr&cnOmTO6GvL_SZJxb{HYjd5j$gWRO5+;)$}ZmyzIUwl`t-Vt%UQa6+g3hF z8ITj@xU3?t>CKQ=JD#|y|73#E-W0xhe(;Q+e^|Hn^zh@-BW@0}Pi)&1ykP%?UbXYC zaJ!0<(pJxLyYY$c@nX%*w@#T}c>HupcI}4TQ%~LXde$NmuomIydZB<82;3wSONW&g zyp_Fc|I_4eynL2Cqw(M02jIl;*@%dK?VY%9ixbz|7Mb*Si>!HhIzD%K#d8nV~i-}KU{KE8+MKG?Bz(%fz*zT>WE-T!sd{QRBzuv>37_3}Ah@n+7x9~SIR zyu7DJLsIU!rq_NF-=4Y1U)CseJ)5yOFyxA#-)u|c=6h>0uY0YzHv8P6;?H#XYb(z_ zX4A4S_9@ph7pEVwO!{o#N5Kbbif`YtByLEFtN-PNzQ;3mYmBr z+^s&;>CyhknwrBQ^Pjx>QrxsTUYC!BSN(Kv^sNDFN9;+W*JZC8`rZoxvSl9LznGSH zvCI_p{ys&JQ98S8PWb$RA>+lh<^gkOho;K-rL>}ECtI_FO%zM8$J^Q2)3gY?9?RPD zEIl=_8KJ&hZ>k7yHz|bE$7I%-t4yo|CZ)3{76HX7NRkl^V97i?j+mN8PkpSU(KH*v z2HP4I&>j5$Y_?fX(n=4J6Dzfe2qml|VY-S$LV)kF8v(ns@~>w zXWK_buYK12huJC4Uv$dc)A-6nhK=wko;h!K#G?Ce?Tq&p2Te*4h^dv`=GA&i4=lWy zIOu>~kA_W~+()gw8GrP|yl-5rL1Hqkj5xh%!zYLA`g6*=`x-6< zo;so5=&{3%B7daDXeu^TnwVrc zIgGH5BiT0VID%wt>)1l){l6iK!)JA{>Z+^-MjHgHNEyZ?A)(8 zvyPj3Ge|P$)c721LPyU{A8~q(syr>JE0-`d-yR&)_m+1|mjIL}XW5i=840ZZiDi#47puau&yt)gukvfg_EWF8$GiKkF>xQNTg8p1=F z%<`%-vkqG284cq?u^ln7QEXJCG*=@>J=;?p~_U@njS7CwX;-Nsd0=pP08eHluULCk}3_~ zoGL|?EY~U-WHnl~Dp{K`n88a*>B?j+qs=BF(mbVFg~yk&+SFHNXE8Zyd9qfOtb`mI z$W&%&@x~45LZ#8f{`AuBl} zS3z275Xn+$Re8-a&E{sZF)B5qkY~zMm6~uyqf|=C zILVexI#nn!DH$4AI=LL6`ekr=-09kRcAFP<%F4@#TNN4&BQ@5AAU9)W6jYfFCaGaO zOoukRkDo}wim?OXzh|xk z3b#175BKLpbQb0ZH3ND-FuvS-zRRW&bawR*!Png|26d1 z%mZ((-H}&Up`SW&7tG^X74Tb6^J%oTUu2sT0%iHE>sQL-VwoASIZ4{CG1 z9KZdH`lT_A7e;M-b~-IIwd&~Chf=y+UOmgnN6#+vTel#q|4{$m9JqP)zAtxl^`!$> zyM90QY~rfeW9g-VpP4=g6DAxRc;%JbaS86rW@x6paq~iP<3nE$@%(ofr;uSQg(Gfr z2ggYF8v3RFw)?9e)ALq*UVG%;fiF^KeHTA;tIvrCp6QnN(jAUYxhnZ~|fMPZ*dj&)||e_U`x z(y;8>(f;?lF1coJ+$fYCEV#93PR&=d5=)lWZ!8EGUJ$ZBdVfFr`qj^V?i^6Hj_LD{ mdD3q0?78{t)9C3Z4-`Kdy>&&2>1*SzBSQu(96d8--~R#_?%;#~