Basic Jeopardy page, working on revamping designs.

master
AlacrisDevs 2 weeks ago
commit facb36a07f
  1. 60
      .gitignore
  2. 1
      .npmrc
  3. 5
      .vscode/settings.json
  4. 38
      README.md
  5. 145
      messages/en.json
  6. 145
      messages/et.json
  7. 2438
      package-lock.json
  8. 26
      package.json
  9. 1
      project.inlang/project_id
  10. 15
      project.inlang/settings.json
  11. 13
      src/app.d.ts
  12. 11
      src/app.html
  13. 12
      src/hooks.server.ts
  14. 3
      src/hooks.ts
  15. 46
      src/lib/assets/favicon.svg
  16. 38
      src/lib/components/LanguageSwitcher.svelte
  17. 240
      src/lib/components/Settings.svelte
  18. 94
      src/lib/components/Slider.svelte
  19. 40
      src/lib/components/Toast.svelte
  20. 8
      src/lib/components/index.ts
  21. 2
      src/lib/components/kuldvillak/index.ts
  22. 32
      src/lib/components/kuldvillak/ui/KvButtonPrimary.svelte
  23. 32
      src/lib/components/kuldvillak/ui/KvButtonSecondary.svelte
  24. 133
      src/lib/components/kuldvillak/ui/KvGameLogo.svelte
  25. 44
      src/lib/components/kuldvillak/ui/KvNumberInput.svelte
  26. 70
      src/lib/components/kuldvillak/ui/KvPlayerCard.svelte
  27. 67
      src/lib/components/kuldvillak/ui/KvProjectorCard.svelte
  28. 16
      src/lib/components/kuldvillak/ui/index.ts
  29. 328
      src/lib/example_testing_game.json
  30. 15
      src/lib/index.ts
  31. 72
      src/lib/stores/audio.svelte.ts
  32. 623
      src/lib/stores/gameSession.svelte.ts
  33. 363
      src/lib/stores/kuldvillak.svelte.ts
  34. 259
      src/lib/stores/persistence.ts
  35. 112
      src/lib/stores/theme.svelte.ts
  36. 167
      src/lib/types/kuldvillak.ts
  37. 31
      src/routes/+error.svelte
  38. 19
      src/routes/+layout.svelte
  39. 80
      src/routes/+page.svelte
  40. 22
      src/routes/kuldvillak/+layout.svelte
  41. 106
      src/routes/kuldvillak/+page.svelte
  42. 1007
      src/routes/kuldvillak/edit/+page.svelte
  43. 36
      src/routes/kuldvillak/play/+page.svelte
  44. 700
      src/routes/kuldvillak/play/ModeratorView.svelte
  45. 715
      src/routes/kuldvillak/play/ProjectorView.svelte
  46. 130
      src/routes/layout.css
  47. BIN
      static/audio/kuldvillak_teema.mp3
  48. 46
      static/favicon.svg
  49. BIN
      static/fonts/BebasNeue-Regular.ttf
  50. BIN
      static/fonts/ITC Korinna Std Bold.otf
  51. BIN
      static/fonts/Swiss 921 Regular.otf
  52. 2
      static/icons/en.svg
  53. 2
      static/icons/et.svg
  54. BIN
      static/images/kuldvillak-cover.jpg
  55. BIN
      static/images/rooside-soda-cover.jpg
  56. 3
      static/robots.txt
  57. 15
      svelte.config.js
  58. 20
      tsconfig.json
  59. 15
      vite.config.ts

60
.gitignore vendored

@ -0,0 +1,60 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
/dist
# OS
.DS_Store
Thumbs.db
Desktop.ini
*.swp
*.swo
*~
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
# Paraglide / Inlang
src/lib/paraglide
project.inlang/cache
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Cache
.cache
.parcel-cache
.turbo
*.tsbuildinfo
# IDE
.idea
*.sublime-project
*.sublime-workspace
*.code-workspace
# Testing
coverage
.nyc_output
# Misc
*.local
.history

@ -0,0 +1 @@
engine-strict=true

@ -0,0 +1,5 @@
{
"files.associations": {
"*.css": "tailwindcss"
}
}

@ -0,0 +1,38 @@
# sv
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```sh
# create a new project in the current directory
npx sv create
# create a new project in my-app
npx sv create my-app
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```sh
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```sh
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.

@ -0,0 +1,145 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"app_title": "Ultimate Gaming",
"app_version": "V0.01 Student Edition",
"coming_soon": "Coming Soon",
"game_kuldvillak": "Jeopardy",
"game_rooside_soda": "Family Feud",
"kv_title": "JEOPARDY",
"kv_new_game": "New Game",
"kv_load_game": "Load Game",
"kv_settings": "Settings",
"kv_exit": "Exit",
"kv_settings_title": "Settings",
"kv_settings_music": "Music",
"kv_settings_sfx": "Sound Effects",
"kv_settings_language": "Language",
"kv_settings_close": "Close",
"kv_error_404": "404",
"kv_error_not_found": "Not Found",
"kv_error_hint": "(pssst... make sure you're on the right page)",
"kv_edit_back": "Back",
"kv_edit_game_name": "Game name...",
"kv_edit_save": "Save",
"kv_edit_load": "Load",
"kv_edit_reset": "Reset",
"kv_edit_start": "Start",
"kv_edit_settings_teams": "Game Setup",
"kv_edit_rounds": "Rounds",
"kv_play_timer": "Time to Answer",
"kv_play_timer_reveal": "Answer Reveal",
"kv_play_seconds": "seconds",
"kv_edit_final_round_toggle": "Final Round",
"kv_edit_final_question": "Final Question",
"kv_edit_edit": "Edit",
"kv_edit_teams": "Points & Teams",
"kv_edit_points": "Points",
"kv_edit_preset_normal": "Normal",
"kv_edit_preset_double": "Double",
"kv_edit_x_base": "Multiplier",
"kv_edit_custom": "Custom",
"kv_edit_base": "Base",
"kv_edit_negative_scores": "Negative Scores",
"kv_edit_daily_doubles": "Daily Doubles",
"kv_edit_r1": "Jeopardy",
"kv_edit_r2": "Double Jeopardy",
"kv_edit_teams_label": "Players",
"kv_edit_round_1": "Jeopardy",
"kv_edit_round_2": "Double Jeopardy",
"kv_edit_dd_count": "Daily Double",
"kv_edit_category": "Category",
"kv_edit_dd": "★",
"kv_edit_no_category": "(No Category Yet)",
"kv_edit_question": "Question",
"kv_edit_answer": "Answer",
"kv_edit_daily_double": "Daily Double",
"kv_edit_starting_game": "Starting Game...",
"kv_edit_opening_projector": "Opening projector view",
"kv_error_min_players": "At least 2 players required",
"kv_error_no_questions": "Round {round} has no questions filled",
"kv_error_no_final": "Final round question is empty",
"kv_toast_game_saved": "Game saved!",
"kv_toast_game_loaded": "Game loaded!",
"kv_toast_invalid_file": "Invalid game file",
"kv_edit_reset_confirm": "Are you sure you want to reset all fields to default? This will clear all your work.",
"kv_edit_reset_success": "Game reset to defaults",
"kv_edit_categories_questions": "Categories & Questions",
"kv_edit_daily_doubles_count": "Daily Doubles",
"kv_edit_category_placeholder": "Category name",
"kv_edit_empty": "Empty",
"kv_edit_question_edit": "Edit Question",
"kv_edit_question_placeholder": "Enter the question...",
"kv_edit_answer_placeholder": "Enter the answer...",
"kv_edit_final_round": "Final Round",
"kv_edit_final_disabled": "Final Round is disabled in settings.",
"kv_edit_category_name_placeholder": "Enter category name...",
"kv_edit_final_question_placeholder": "Enter the final question...",
"kv_edit_game_settings": "Game Settings",
"kv_edit_num_rounds": "Number of Rounds",
"kv_edit_1_round": "1 Round",
"kv_edit_2_rounds": "2 Rounds",
"kv_edit_point_values": "Point Values",
"kv_edit_standard": "Standard (100-500)",
"kv_edit_double": "Double (200-1000)",
"kv_edit_multiplier": "Multiplier",
"kv_edit_base_value": "Base Value",
"kv_edit_default_timer": "Default Timer (seconds)",
"kv_edit_enable_final": "Enable Final Round",
"kv_edit_allow_negative": "Allow Negative Scores",
"kv_edit_teams_title": "Teams",
"kv_edit_team_name_placeholder": "Team name...",
"kv_edit_add_team": "Add Team",
"kv_edit_remove_team": "Remove",
"kv_play_loading": "Loading game...",
"kv_play_loading_hint": "If this takes too long, the game may not have been started.",
"kv_play_go_to_editor": "Go to Editor",
"kv_play_round": "Round",
"kv_play_phase": "Phase",
"kv_play_last_answer": "Last",
"kv_play_introduce_categories": "Introduce Categories",
"kv_play_skip_to_game": "Skip to Game",
"kv_play_introducing_categories": "Introducing Categories...",
"kv_play_start_game": "Start Game",
"kv_play_daily_double": "Daily Double",
"kv_play_wager": "Wager",
"kv_play_confirm": "Confirm",
"kv_play_question_number": "Question {current}/{total}",
"kv_play_showing_answer": "Showing Answer...",
"kv_play_question_short": "Q",
"kv_play_answer_short": "A",
"kv_play_answering": "Answering",
"kv_play_correct": "Correct",
"kv_play_wrong": "Wrong",
"kv_play_skip": "Skip / No Answer",
"kv_play_start": "Start",
"kv_play_stop": "Stop",
"kv_play_reset": "Reset",
"kv_play_final_round": "Final Round",
"kv_play_reveal_category": "Reveal Category",
"kv_play_reveal_answer": "Reveal Answer",
"kv_play_show_scores": "Show Final Scores",
"kv_play_scores": "Scores",
"kv_play_adjust_by": "Adjust by",
"kv_play_game_controls": "Game Controls",
"kv_play_next_round": "Next Round",
"kv_play_go_to_final": "Go to Final Round",
"kv_play_end_game": "End Game",
"kv_play_end_game_confirm": "End the game?",
"kv_play_open_projector": "Open Projector",
"kv_play_projector_url": "Projector",
"kv_play_game_over": "Game Over",
"kv_edit_values": "Values",
"kv_edit_disabled": "Disabled",
"kv_edit_rules": "Jeopardy Rules",
"kv_edit_how_to": "How to Play?",
"kv_settings_colors": "Colors",
"kv_settings_primary": "Primary",
"kv_settings_secondary": "Secondary",
"kv_settings_text_color": "Text",
"kv_settings_background": "Background",
"kv_settings_reset": "Reset Settings",
"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"
}

@ -0,0 +1,145 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"app_title": "Sassi Mängukoobas",
"app_version": "V0.01 Tudengite Eri",
"coming_soon": "Tulekul",
"game_kuldvillak": "Kuldvillak",
"game_rooside_soda": "Rooside Sõda",
"kv_title": "KULDVILLAK",
"kv_new_game": "Uus mäng",
"kv_load_game": "Lae mäng",
"kv_settings": "Seaded",
"kv_exit": "Välju",
"kv_settings_title": "Seaded",
"kv_settings_music": "Muusika",
"kv_settings_sfx": "Heliefektid",
"kv_settings_language": "Keel",
"kv_settings_close": "Välju",
"kv_error_404": "404",
"kv_error_not_found": "Lehte ei leitud",
"kv_error_hint": "(pssst... vaata, et oled ikka õigel lehel)",
"kv_edit_back": "Tagasi",
"kv_edit_game_name": "Mängu nimi...",
"kv_edit_save": "Salvesta",
"kv_edit_load": "Lae",
"kv_edit_reset": "Lähtesta",
"kv_edit_start": "Alusta",
"kv_edit_settings_teams": "Mängu seadistus",
"kv_edit_rounds": "Voorude arv",
"kv_play_timer": "Vastamisaeg",
"kv_play_timer_reveal": "Vastuse näitamine",
"kv_play_seconds": "sekundit",
"kv_edit_final_round_toggle": "Finaalvoor",
"kv_edit_final_question": "Finaalküsimus",
"kv_edit_edit": "Muuda",
"kv_edit_teams": "Punktid & Tiimid",
"kv_edit_points": "Punktid",
"kv_edit_preset_normal": "Tavaline",
"kv_edit_preset_double": "Duubel",
"kv_edit_x_base": "Kordaja",
"kv_edit_custom": "Kohandatud",
"kv_edit_base": "Baas",
"kv_edit_negative_scores": "Negatiivsed punktid",
"kv_edit_daily_doubles": "Hõbevillak",
"kv_edit_r1": "Villak",
"kv_edit_r2": "Topeltvillak",
"kv_edit_teams_label": "Mängijad",
"kv_edit_round_1": "Villak",
"kv_edit_round_2": "Topeltvillak",
"kv_edit_dd_count": "Hõbevillak",
"kv_edit_category": "Kategooria",
"kv_edit_dd": "★",
"kv_edit_no_category": "(Kategooria puudub)",
"kv_edit_question": "Küsimus",
"kv_edit_answer": "Vastus",
"kv_edit_daily_double": "Hõbevillak",
"kv_edit_starting_game": "Alustan mängu...",
"kv_edit_opening_projector": "Avan projektori vaate",
"kv_error_min_players": "Vaja on vähemalt 2 mängijat",
"kv_error_no_questions": "Voorul {round} pole küsimusi",
"kv_error_no_final": "Finaalvooru küsimus on tühi",
"kv_toast_game_saved": "Mäng salvestatud!",
"kv_toast_game_loaded": "Mäng laetud!",
"kv_toast_invalid_file": "Vigane mängufail",
"kv_edit_reset_confirm": "Kas oled kindel, et soovid kõik väljad vaikeväärtustele lähtestada? See kustutab kogu sinu töö.",
"kv_edit_reset_success": "Mäng lähtestatud",
"kv_edit_categories_questions": "Kategooriad & Küsimused",
"kv_edit_daily_doubles_count": "Hõbevillakud",
"kv_edit_category_placeholder": "Kategooria nimi",
"kv_edit_empty": "Tühi",
"kv_edit_question_edit": "Muuda küsimust",
"kv_edit_question_placeholder": "Sisesta küsimus...",
"kv_edit_answer_placeholder": "Sisesta vastus...",
"kv_edit_final_round": "Finaalvoor",
"kv_edit_final_disabled": "Finaalvoor on seadetes keelatud.",
"kv_edit_category_name_placeholder": "Sisesta kategooria nimi...",
"kv_edit_final_question_placeholder": "Sisesta lõppküsimus...",
"kv_edit_game_settings": "Mängu seaded",
"kv_edit_num_rounds": "Voorude arv",
"kv_edit_1_round": "1 voor",
"kv_edit_2_rounds": "2 vooru",
"kv_edit_point_values": "Punktiväärtused",
"kv_edit_standard": "Tavaline (100-500)",
"kv_edit_double": "Topelt (200-1000)",
"kv_edit_multiplier": "Kordaja",
"kv_edit_base_value": "Baasväärtus",
"kv_edit_default_timer": "Vaikimisi taimer (sekundid)",
"kv_edit_enable_final": "Luba finaalvoor",
"kv_edit_allow_negative": "Luba negatiivsed punktid",
"kv_edit_teams_title": "Tiimid",
"kv_edit_team_name_placeholder": "Tiimi nimi...",
"kv_edit_add_team": "Lisa tiim",
"kv_edit_remove_team": "Eemalda",
"kv_play_loading": "Laen mängu...",
"kv_play_loading_hint": "Kui see võtab liiga kaua, siis mängu pole alustatud.",
"kv_play_go_to_editor": "Mine redaktorisse",
"kv_play_round": "Voor",
"kv_play_phase": "Hetkeseis",
"kv_play_last_answer": "Viimane",
"kv_play_introduce_categories": "Tutvusta kategooriaid",
"kv_play_skip_to_game": "Jäta vahele",
"kv_play_introducing_categories": "Tutvustan kategooriaid...",
"kv_play_start_game": "Alusta mängu",
"kv_play_daily_double": "Hõbevillak",
"kv_play_wager": "Panus",
"kv_play_confirm": "Kinnita",
"kv_play_question_number": "Küsimus {current}/{total}",
"kv_play_showing_answer": "Näitan vastust...",
"kv_play_question_short": "Küsimus",
"kv_play_answer_short": "Vastus",
"kv_play_answering": "Vastab",
"kv_play_correct": "Õige",
"kv_play_wrong": "Vale",
"kv_play_skip": "Jäta vahele",
"kv_play_start": "Alusta",
"kv_play_stop": "Lõpeta",
"kv_play_reset": "Lähtesta",
"kv_play_final_round": "Finaalvoor",
"kv_play_reveal_category": "Näita kategooriat",
"kv_play_reveal_answer": "Näita vastust",
"kv_play_show_scores": "Näita lõpptulemusi",
"kv_play_scores": "Tulemused",
"kv_play_adjust_by": "Muuda",
"kv_play_game_controls": "Mängu juhtimine",
"kv_play_next_round": "Järgmine voor",
"kv_play_go_to_final": "Finaalvooru",
"kv_play_end_game": "Lõpeta mäng",
"kv_play_end_game_confirm": "Lõpeta mäng?",
"kv_play_open_projector": "Ava projektor",
"kv_play_projector_url": "Projektor",
"kv_play_game_over": "Mäng läbi",
"kv_edit_values": "Väärtused",
"kv_edit_disabled": "Keelatud",
"kv_edit_rules": "Kuldvillaku reeglid",
"kv_edit_how_to": "Kuidas mängida?",
"kv_settings_colors": "Värvid",
"kv_settings_primary": "Primaarne",
"kv_settings_secondary": "Sekundaarne",
"kv_settings_text_color": "Tekst",
"kv_settings_background": "Taust",
"kv_settings_reset": "Lähtesta seaded",
"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"
}

2438
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -0,0 +1,26 @@
{
"name": "myapp",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
},
"devDependencies": {
"@inlang/paraglide-js": "^2.5.0",
"@sveltejs/adapter-auto": "^7.0.0",
"@sveltejs/kit": "^2.48.5",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tailwindcss/vite": "^4.1.17",
"svelte": "^5.43.8",
"svelte-check": "^4.3.4",
"tailwindcss": "^4.1.17",
"typescript": "^5.9.3",
"vite": "^7.2.2"
}
}

@ -0,0 +1 @@
0vGuuiWc0Pt5x0peOL

@ -0,0 +1,15 @@
{
"$schema": "https://inlang.com/schema/project-settings",
"modules": [
"https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@4/dist/index.js",
"https://cdn.jsdelivr.net/npm/@inlang/plugin-m-function-matcher@2/dist/index.js"
],
"plugin.inlang.messageFormat": {
"pathPattern": "./messages/{locale}.json"
},
"baseLocale": "et",
"locales": [
"et",
"en"
]
}

13
src/app.d.ts vendored

@ -0,0 +1,13 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

@ -0,0 +1,11 @@
<!doctype html>
<html lang="%paraglide.lang%">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

@ -0,0 +1,12 @@
import type { Handle } from '@sveltejs/kit';
import { paraglideMiddleware } from '$lib/paraglide/server';
const handleParaglide: Handle = ({ event, resolve }) => paraglideMiddleware(event.request, ({ request, locale }) => {
event.request = request;
return resolve(event, {
transformPageChunk: ({ html }) => html.replace('%paraglide.lang%', locale)
});
});
export const handle: Handle = handleParaglide;

@ -0,0 +1,3 @@
import { deLocalizeUrl } from '$lib/paraglide/runtime';
export const reroute = (request) => deLocalizeUrl(request.url).pathname;

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 25 KiB

@ -0,0 +1,38 @@
<script lang="ts">
import { getLocale, setLocale } from "$lib/paraglide/runtime";
interface Props {
inline?: boolean;
}
let { inline = false }: Props = $props();
function getOtherLocale(): string {
const current = getLocale();
return current === "et" ? "en" : "et";
}
function switchLanguage() {
const newLocale = getOtherLocale();
setLocale(newLocale as "et" | "en");
}
$effect(() => {
getLocale();
});
</script>
<button
onclick={switchLanguage}
class="{inline
? ''
: 'fixed top-4 right-4 z-[100]'} bg-transparent border-none p-1 cursor-pointer transition-transform duration-200 hover:scale-110"
aria-label="Switch language"
title="Switch to {getOtherLocale() === 'en' ? 'English' : 'Eesti'}"
>
<img
src="/icons/{getOtherLocale()}.svg"
alt={getOtherLocale() === "en" ? "English" : "Eesti"}
class="block w-8 h-5 object-cover"
/>
</button>

@ -0,0 +1,240 @@
<script lang="ts">
import Slider from "./Slider.svelte";
import {
KvButtonPrimary,
KvButtonSecondary,
} from "$lib/components/kuldvillak/ui";
import * as m from "$lib/paraglide/messages";
import { audioStore } from "$lib/stores/audio.svelte";
import { themeStore } from "$lib/stores/theme.svelte";
import { getLocale, setLocale } from "$lib/paraglide/runtime";
interface SettingsProps {
open?: boolean;
onclose?: () => void;
}
let { open = $bindable(false), onclose }: SettingsProps = $props();
// Close without saving (revert colors)
function handleCancel() {
themeStore.revert();
open = false;
onclose?.();
}
// Save and close
function handleSaveAndExit() {
themeStore.save();
open = false;
onclose?.();
}
// Reset colors to defaults (preview only, not saved until Save is clicked)
function handleResetSettings() {
themeStore.resetToDefaults();
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === "Escape") handleCancel();
}
function handleMusicChange(value: number) {
audioStore.setMusicVolume(value);
}
function handleSfxChange(value: number) {
audioStore.setSfxVolume(value);
}
function switchLanguage(lang: "et" | "en") {
setLocale(lang);
}
</script>
<svelte:window onkeydown={handleKeydown} />
{#if open}
<!-- Backdrop - clicking outside cancels without saving -->
<div
class="fixed inset-0 bg-kv-background/50 z-40"
onclick={handleCancel}
role="button"
tabindex="-1"
onkeydown={(e) => e.key === "Enter" && handleCancel()}
></div>
<!-- Modal -->
<div
class="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-50
bg-kv-blue border-8 md:border-[16px] border-kv-black
p-4 md:p-8 w-[95vw] md:min-w-[420px] md:w-auto max-w-[500px]
flex flex-col gap-4 md:gap-8 items-center max-h-[90vh] overflow-y-auto"
role="dialog"
aria-modal="true"
aria-labelledby="settings-title"
>
<!-- Header with Title and Close Button -->
<div class="flex items-start justify-between w-full">
<h2
id="settings-title"
class="text-[28px] text-kv-white uppercase font-kv-body kv-shadow-text"
>
{m.kv_settings_title()}
</h2>
<button
onclick={handleCancel}
class="w-6 h-6 cursor-pointer bg-transparent border-none p-0 hover:opacity-70"
aria-label="Close"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class="w-full h-full text-kv-yellow"
>
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
<!-- Audio & Language Settings -->
<div class="flex flex-col gap-4 w-full">
<Slider
label={m.kv_settings_music()}
bind:value={audioStore.musicVolume}
onchange={handleMusicChange}
/>
<Slider
label={m.kv_settings_sfx()}
bind:value={audioStore.sfxVolume}
onchange={handleSfxChange}
/>
<!-- Language -->
<div class="flex items-center justify-between w-full gap-4">
<span
class="text-2xl text-kv-white uppercase font-kv-body kv-shadow-text"
>
{m.kv_settings_language()}
</span>
<div class="flex gap-4">
<button
class="w-8 h-8 cursor-pointer transition-all overflow-hidden {getLocale() ===
'et'
? 'opacity-100'
: 'opacity-60 hover:opacity-80'}"
onclick={() => switchLanguage("et")}
>
<img
src="/icons/et.svg"
alt="Eesti"
class="w-full h-full object-cover"
/>
</button>
<button
class="w-8 h-8 cursor-pointer transition-all overflow-hidden {getLocale() ===
'en'
? 'opacity-100'
: 'opacity-60 hover:opacity-80'}"
onclick={() => switchLanguage("en")}
>
<img
src="/icons/en.svg"
alt="English"
class="w-full h-full object-cover"
/>
</button>
</div>
</div>
</div>
<!-- Colors Section Title -->
<div class="flex items-center w-full">
<h3
class="text-[28px] text-kv-white uppercase font-kv-body kv-shadow-text"
>
{m.kv_settings_colors()}
</h3>
</div>
<!-- Color Swatches -->
<div class="flex flex-col gap-4 w-full">
<!-- Primary Color -->
<div class="flex items-center justify-between w-full">
<span
class="text-2xl text-kv-white uppercase font-kv-body kv-shadow-text"
>
{m.kv_settings_primary()}
</span>
<input
type="color"
value={themeStore.primary}
oninput={(e) =>
(themeStore.primary = e.currentTarget.value)}
class="w-8 h-8 border-4 border-black cursor-pointer bg-transparent"
/>
</div>
<!-- Secondary Color -->
<div class="flex items-center justify-between w-full">
<span
class="text-2xl text-kv-white uppercase font-kv-body kv-shadow-text"
>
{m.kv_settings_secondary()}
</span>
<input
type="color"
value={themeStore.secondary}
oninput={(e) =>
(themeStore.secondary = e.currentTarget.value)}
class="w-8 h-8 border-4 border-black cursor-pointer bg-transparent"
/>
</div>
<!-- Text Color -->
<div class="flex items-center justify-between w-full">
<span
class="text-2xl text-kv-white uppercase font-kv-body kv-shadow-text"
>
{m.kv_settings_text_color()}
</span>
<input
type="color"
value={themeStore.text}
oninput={(e) => (themeStore.text = e.currentTarget.value)}
class="w-8 h-8 border-4 border-black cursor-pointer bg-transparent"
/>
</div>
<!-- Background Color -->
<div class="flex items-center justify-between w-full">
<span
class="text-2xl text-kv-white uppercase font-kv-body kv-shadow-text"
>
{m.kv_settings_background()}
</span>
<input
type="color"
value={themeStore.background}
oninput={(e) =>
(themeStore.background = e.currentTarget.value)}
class="w-8 h-8 border-4 border-black cursor-pointer bg-transparent"
/>
</div>
</div>
<!-- Reset Settings Button -->
<KvButtonPrimary
onclick={handleResetSettings}
class="!text-2xl !py-4 !px-4"
>
{m.kv_settings_reset()}
</KvButtonPrimary>
<!-- Save and Exit Button -->
<KvButtonSecondary
onclick={handleSaveAndExit}
class="!text-2xl !py-4 !px-4"
>
{m.kv_settings_save_exit()}
</KvButtonSecondary>
</div>
{/if}

@ -0,0 +1,94 @@
<script lang="ts">
interface SliderProps {
label: string;
value?: number;
min?: number;
max?: number;
step?: number;
onchange?: (value: number) => void;
}
let {
label,
value = $bindable(100),
min = 0,
max = 100,
step = 5,
onchange,
}: SliderProps = $props();
function handleInput(e: Event) {
const target = e.target as HTMLInputElement;
value = Number(target.value);
onchange?.(value);
}
</script>
<div class="flex items-center justify-between w-full gap-4">
<span class="text-2xl text-kv-white uppercase font-kv-body kv-shadow-text">
{label}
</span>
<div class="flex items-center gap-4">
<span
class="text-2xl text-kv-yellow uppercase font-kv-body min-w-[60px] text-right kv-shadow-text"
>
{value}%
</span>
<div class="relative w-[150px] h-[24px] flex items-center">
<input
type="range"
{min}
{max}
{step}
{value}
oninput={handleInput}
class="slider-input w-full"
/>
</div>
</div>
</div>
<style>
.slider-input {
-webkit-appearance: none;
appearance: none;
background: transparent;
cursor: pointer;
height: 4px;
}
.slider-input::-webkit-slider-runnable-track {
background: var(--kv-yellow);
height: 4px;
border-radius: 2px;
}
.slider-input::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 20px;
height: 20px;
border-radius: 50%;
background: white;
border: 3px solid var(--kv-yellow);
margin-top: -8px;
cursor: pointer;
}
.slider-input::-moz-range-track {
background: var(--kv-yellow);
height: 4px;
border-radius: 2px;
}
.slider-input::-moz-range-thumb {
width: 20px;
height: 20px;
border-radius: 50%;
background: white;
border: 3px solid var(--kv-yellow);
cursor: pointer;
}
</style>

@ -0,0 +1,40 @@
<script lang="ts">
interface ToastProps {
message: string;
type?: "error" | "success";
visible?: boolean;
duration?: number;
onclose?: () => void;
}
let {
message,
type = "error",
visible = $bindable(false),
duration = 3000,
onclose,
}: ToastProps = $props();
$effect(() => {
if (visible && duration > 0) {
const timer = setTimeout(() => {
visible = false;
onclose?.();
}, duration);
return () => clearTimeout(timer);
}
});
</script>
{#if visible}
<div
class="fixed bottom-8 left-1/2 -translate-x-1/2 z-[200] px-6 py-4 rounded-lg shadow-lg
font-[family-name:var(--kv-font-button)] text-lg uppercase
{type === 'error'
? 'bg-red-600 text-kv-white'
: 'bg-green-600 text-kv-white'}"
role="alert"
>
{message}
</div>
{/if}

@ -0,0 +1,8 @@
// Shared Components
export { default as Slider } from './Slider.svelte';
export { default as Settings } from './Settings.svelte';
export { default as LanguageSwitcher } from './LanguageSwitcher.svelte';
export { default as Toast } from './Toast.svelte';
// Kuldvillak Components
export * from './kuldvillak';

@ -0,0 +1,2 @@
// Kuldvillak UI Components
export * from './ui';

@ -0,0 +1,32 @@
<script lang="ts">
import type { Snippet } from "svelte";
interface Props {
href?: string;
disabled?: boolean;
onclick?: () => void;
children: Snippet;
class?: string;
}
let {
href,
disabled = false,
onclick,
children,
class: className = "",
}: Props = $props();
const baseClasses =
"inline-flex items-center justify-center px-6 py-4 bg-kv-blue font-kv-body text-kv-white text-2xl uppercase cursor-pointer transition-opacity hover:opacity-80 disabled:opacity-50 disabled:cursor-not-allowed kv-shadow-button";
</script>
{#if href && !disabled}
<a {href} class="{baseClasses} {className}">
<span class="kv-shadow-text">{@render children()}</span>
</a>
{:else}
<button class="{baseClasses} {className}" {disabled} {onclick}>
<span class="kv-shadow-text">{@render children()}</span>
</button>
{/if}

@ -0,0 +1,32 @@
<script lang="ts">
import type { Snippet } from "svelte";
interface Props {
href?: string;
disabled?: boolean;
onclick?: () => void;
children: Snippet;
class?: string;
}
let {
href,
disabled = false,
onclick,
children,
class: className = "",
}: 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";
</script>
{#if href && !disabled}
<a {href} class="{baseClasses} {className}">
{@render children()}
</a>
{:else}
<button class="{baseClasses} {className}" {disabled} {onclick}>
{@render children()}
</button>
{/if}

@ -0,0 +1,133 @@
<script lang="ts">
interface Props {
variant?: "kuldvillak" | "villak" | "topeltvillak" | "hobevillak";
size?: "sm" | "md" | "lg" | "xl" | "auto";
class?: string;
}
let {
variant = "kuldvillak",
size = "auto",
class: className = "",
}: Props = $props();
const sizeClasses = {
sm: "h-16 max-w-[256px]",
md: "h-24 max-w-[384px]",
lg: "h-32 max-w-[512px]",
xl: "h-48 max-w-[768px]",
auto: "", // No size constraints, controlled by parent
};
</script>
{#if variant === "kuldvillak"}
<!-- KULDVILLAK - Full logo with all letters -->
<svg
class="text-kv-yellow {sizeClasses[size]} {className}"
viewBox="0 0 1024 128"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
role="img"
aria-label="Kuldvillak"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M912.118 0L915.437 11V114L912.118 125H950.044L946.726 114V72.1719L986.074 125H1024L971.377 57.5L1019.73 0H981.807L946.726 49V11L950.044 0H912.118Z"
fill="currentColor"
/>
<path
d="M830.841 0L787.603 114L779.26 125L813.772 125L823.706 94.086H861.678L871.611 125L906.124 125L897.78 114L854.544 0H830.841ZM842.693 35L854.93 73.086H830.454L842.693 35Z"
fill="currentColor"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M707.371 0L710.69 11V114L707.371 125H770.423L780.853 95C780.853 95 766.952 103 741.978 103V11L745.297 0H707.371Z"
fill="currentColor"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M623.724 0L627.042 11V114L623.724 125H686.775L697.205 95C697.205 95 683.305 103 658.331 103V11L661.65 0H623.724Z"
fill="currentColor"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M568.046 0L571.364 11V114L568.046 125H605.972L602.653 114V11L605.972 0H568.046Z"
fill="currentColor"
/>
<path
d="M560.535 0L552.191 11L508.956 125H485.252L442.017 11L433.673 0H468.185L497.104 90L526.023 0H560.535Z"
fill="currentColor"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M336.276 0L339.595 11V114L336.276 125H374.202C377.363 125 380.52 124.787 383.649 124.363C386.776 123.94 389.868 123.307 392.9 122.469C395.933 121.63 398.899 120.588 401.774 119.352C404.649 118.115 407.427 116.687 410.086 115.078C412.744 113.469 415.277 111.683 417.665 109.734C420.054 107.785 422.292 105.677 424.362 103.428C426.432 101.178 428.328 98.7928 430.037 96.2891C431.746 93.7854 433.262 91.17 434.575 88.4629C435.887 85.7555 436.993 82.9632 437.884 80.1074C438.774 77.252 439.447 74.3402 439.897 71.3945C440.347 68.4486 440.573 65.4761 440.573 62.5C440.573 61.508 440.548 60.5162 440.498 59.5254C440.448 58.5349 440.373 57.5457 440.272 56.5586C440.172 55.5715 440.047 54.5869 439.896 53.6054C439.747 52.6238 439.572 51.6457 439.372 50.6719C439.173 49.6983 438.95 48.7292 438.702 47.7656C438.453 46.8013 438.181 45.8427 437.883 44.8906C437.587 43.9396 437.267 42.9953 436.922 42.0586C436.578 41.1214 436.21 40.1921 435.819 39.2715C435.427 38.3509 435.012 37.4391 434.574 36.5371C434.137 35.635 433.677 34.7428 433.195 33.8613C432.712 32.9797 432.208 32.109 431.682 31.25C431.155 30.3908 430.607 29.5435 430.037 28.7089C429.468 27.8749 428.878 27.0537 428.267 26.2461C427.655 25.4384 427.024 24.6446 426.372 23.8652C425.721 23.0853 425.051 22.3201 424.361 21.5703C423.672 20.8208 422.964 20.0869 422.237 19.3691C421.511 18.6514 420.766 17.95 420.004 17.2656C419.241 16.5816 418.461 15.9147 417.665 15.2656C416.869 14.616 416.057 13.9843 415.23 13.3711C414.402 12.7579 413.558 12.1633 412.7 11.5879C411.843 11.0131 410.971 10.4575 410.085 9.92184C409.199 9.38548 408.299 8.86906 407.387 8.373C406.475 7.87712 405.551 7.4017 404.615 6.94725C403.679 6.49314 402.732 6.06008 401.774 5.64841C400.816 5.23614 399.848 4.84542 398.87 4.47654C397.892 4.10804 396.905 3.76165 395.909 3.43748C394.914 3.11282 393.911 2.81078 392.9 2.53121C391.89 2.2519 390.873 1.99539 389.85 1.76171C388.827 1.52775 387.797 1.31669 386.763 1.12888C385.729 0.941039 384.691 0.776271 383.648 0.634727C382.606 0.494129 381.56 0.376955 380.511 0.283222C379.463 0.188734 378.413 0.117689 377.361 0.0704451C376.309 0.023201 375.255 -0.000233775 374.202 0.000144178L336.276 0ZM370.884 20.25C371.649 20.2502 372.413 20.2764 373.176 20.332C373.939 20.3857 374.701 20.4665 375.46 20.5742C376.218 20.6827 376.974 20.8184 377.724 20.9805C378.475 21.1423 379.221 21.3305 379.962 21.545C380.702 21.7604 381.436 22.0021 382.163 22.2696C382.891 22.5364 383.611 22.8287 384.323 23.1465C385.033 23.4648 385.735 23.8079 386.426 24.1758C387.118 24.5436 387.799 24.9356 388.469 25.3516C389.139 25.7682 389.797 26.2086 390.443 26.6719C391.089 27.1343 391.722 27.6196 392.341 28.127C392.959 28.6354 393.563 29.1656 394.152 29.7169C394.741 30.2673 395.315 30.8386 395.873 31.4298C396.43 32.0211 396.971 32.632 397.495 33.2618C398.018 33.8914 398.524 34.5396 399.012 35.2051C399.498 35.8713 399.966 36.5546 400.415 37.2539C400.865 37.9527 401.296 38.6672 401.706 39.3965C402.115 40.126 402.505 40.8699 402.873 41.627C403.241 42.384 403.588 43.154 403.913 43.9356C404.239 44.7176 404.543 45.5109 404.825 46.3145C405.106 47.1176 405.364 47.9305 405.6 48.752C405.837 49.5743 406.051 50.4048 406.241 51.2422C406.431 52.0786 406.598 52.9214 406.741 53.7696C406.884 54.6186 407.005 55.4726 407.1 56.3301C407.196 57.1874 407.267 58.0479 407.315 58.9102C407.363 59.7724 407.387 60.6361 407.387 61.5001C407.388 63.9011 407.203 66.2976 406.834 68.6622C406.464 71.027 405.913 73.351 405.186 75.6075C404.459 77.8643 403.559 80.0451 402.497 82.1251C401.434 84.2049 400.213 86.176 398.847 88.0157C397.481 89.855 395.976 91.5562 394.349 93.0996C392.72 94.6428 390.976 96.0224 389.136 97.2227C387.295 98.4234 385.366 99.4403 383.369 100.262C381.372 101.083 379.315 101.706 377.223 102.123C375.13 102.54 373.009 102.75 370.884 102.75V20.25Z"
fill="currentColor"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M252.629 0L255.947 11V114L252.629 125H315.681L326.11 95C326.11 95 312.21 103 287.236 103V11L290.555 0H252.629Z"
fill="currentColor"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M122.048 0.00245102L125.366 11.0022V84.2509C125.366 86.7978 125.577 89.34 125.996 91.8484C126.415 94.3561 127.041 96.8205 127.866 99.2135C128.693 101.607 129.716 103.92 130.924 106.125C132.131 108.331 133.518 110.421 135.07 112.371C136.623 114.322 138.333 116.127 140.183 117.764C142.034 119.401 144.016 120.865 146.108 122.139C147.973 123.25 149.918 124.206 151.924 125C152.168 125.123 152.413 125.243 152.659 125.361C154.929 126.233 157.266 126.893 159.645 127.336C162.023 127.778 164.434 128 166.849 128C169.263 128 171.673 127.778 174.051 127.336C176.429 126.893 178.766 126.233 181.036 125.361C183.305 124.49 185.498 123.412 187.59 122.139C189.681 120.865 191.662 119.401 193.512 117.764C194.447 116.864 195.342 115.92 196.195 114.934L193.158 125H231.085L227.766 114V11.0022L231.085 0.00245102H193.158L196.477 11.0022V82.0009V82.0772H196.469C196.467 83.6241 196.302 85.166 195.979 86.6748C195.647 88.2074 195.153 89.6959 194.506 91.1122C193.86 92.5292 193.064 93.865 192.134 95.0946C191.204 96.3237 190.146 97.4387 188.98 98.4187C187.815 99.399 186.549 100.238 185.206 100.921C183.863 101.603 182.451 102.124 180.997 102.473C179.543 102.824 178.057 103 176.565 103.001C175.075 103 173.589 102.823 172.136 102.473C170.682 102.124 169.27 101.603 167.926 100.921C166.583 100.238 165.317 99.399 164.152 98.4187C162.986 97.4387 161.928 96.3237 160.998 95.0946C160.069 93.865 159.273 92.5292 158.626 91.1122C157.979 89.6959 157.486 88.2074 157.154 86.6748C156.83 85.1661 156.664 83.6242 156.661 82.0772H156.656V82.0187L156.654 81.9998L156.656 81.9809V10.9998L159.974 0L122.048 0.00245102Z"
fill="currentColor"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M0 0L3.31851 11V114L0 125H37.9259L34.6074 114V72.1719L73.9556 125H111.882L59.2593 57.5L107.615 5.7671e-05H69.6889L34.6074 49.0001V11L37.9259 5.7671e-05L0 0Z"
fill="currentColor"
/>
</svg>
{:else}
<!-- VILLAK / TOPELTVILLAK / HÕBEVILLAK - Generic logo -->
<svg
class="text-kv-yellow {sizeClasses[size]} {className}"
viewBox="0 0 591 128"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
role="img"
aria-label="Villak"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M478.446 0L481.765 11V114L478.446 125H516.372L513.054 114V72.1719L552.402 125H590.328L537.706 57.5L586.061 0H548.135L513.054 49V11L516.372 0H478.446Z"
fill="currentColor"
/>
<path
d="M397.169 0L353.932 114L345.588 125L380.1 125L390.034 94.086H428.006L437.939 125L472.452 125L464.108 114L420.873 0H397.169ZM409.021 35L421.258 73.086H396.782L409.021 35Z"
fill="currentColor"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M273.699 0L277.018 11V114L273.699 125H336.751L347.181 95C347.181 95 333.281 103 308.307 103V11L311.625 0H273.699Z"
fill="currentColor"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M190.051 0L193.369 11V114L190.051 125H253.103L263.532 95C263.532 95 249.632 103 224.658 103V11L227.977 0H190.051Z"
fill="currentColor"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M134.374 0L137.693 11V114L134.374 125H172.3L168.981 114V11L172.3 0H134.374Z"
fill="currentColor"
/>
<path
d="M126.862 0L118.519 11L75.283 125H51.5793L8.3437 11L0 0H34.5126L63.4311 90L92.3497 0H126.862Z"
fill="currentColor"
/>
</svg>
{/if}

@ -0,0 +1,44 @@
<script lang="ts">
interface Props {
value: number;
min?: number;
max?: number;
step?: number;
disabled?: boolean;
size?: "sm" | "md";
class?: string;
}
let {
value = $bindable(0),
min = 0,
max = 9999,
step = 1,
disabled = false,
size = "md",
class: className = "",
}: Props = $props();
const sizeClasses = {
sm: "w-12 h-10 text-lg",
md: "w-16 h-12 text-xl",
};
</script>
<div
class="inline-flex items-center justify-center border-4 border-black bg-kv-blue
{sizeClasses[size]}
{className}"
>
<input
type="number"
bind:value
{min}
{max}
{step}
{disabled}
class="w-full h-full bg-transparent text-kv-white font-kv-body text-center border-none outline-none
[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none
{disabled ? 'opacity-50 cursor-not-allowed' : ''}"
/>
</div>

@ -0,0 +1,70 @@
<script lang="ts">
import type { Team } from "$lib/types/kuldvillak";
interface Props {
team: Team;
isActive?: boolean;
isAnswering?: boolean;
scoreAdjustment?: number;
onadjust?: (delta: number) => void;
onclick?: () => void;
disabled?: boolean;
class?: string;
}
let {
team,
isActive = false,
isAnswering = false,
scoreAdjustment = 100,
onadjust,
onclick,
disabled = false,
class: className = "",
}: Props = $props();
</script>
<div
class="flex flex-col gap-4 items-center justify-center p-4 bg-kv-board transition-all
{isActive ? 'ring-4 ring-kv-yellow' : ''}
{isAnswering ? 'ring-4 ring-kv-green' : ''}
{disabled ? 'opacity-50' : ''}
{className}"
role={onclick ? "button" : undefined}
tabindex={onclick ? 0 : undefined}
{onclick}
onkeydown={(e) => e.key === "Enter" && onclick?.()}
>
<!-- Name and Score -->
<button
class="flex flex-col gap-2 items-center font-kv-body text-kv-white uppercase text-center w-full cursor-pointer hover:brightness-110 transition-all
{disabled ? 'cursor-not-allowed' : ''}"
{onclick}
{disabled}
>
<span class="text-3xl kv-shadow-text">{team.name}</span>
<span
class="text-3xl kv-shadow-text {team.score < 0
? 'text-kv-red'
: ''}">{team.score}</span
>
</button>
<!-- Score Adjustment Buttons -->
{#if onadjust}
<div class="flex gap-4 items-center justify-center">
<button
class="flex-1 min-w-[70px] h-10 bg-kv-green border-4 border-black flex items-center justify-center font-kv-body text-xl text-kv-white uppercase cursor-pointer hover:brightness-110 transition-all"
onclick={() => onadjust(scoreAdjustment)}
>
<span class="kv-shadow-text">+{scoreAdjustment}</span>
</button>
<button
class="flex-1 min-w-[70px] h-10 bg-kv-red border-4 border-black flex items-center justify-center font-kv-body text-xl text-kv-white uppercase cursor-pointer hover:brightness-110 transition-all"
onclick={() => onadjust(-scoreAdjustment)}
>
<span class="kv-shadow-text">-{scoreAdjustment}</span>
</button>
</div>
{/if}
</div>

@ -0,0 +1,67 @@
<script lang="ts">
interface Props {
variant: "category" | "price" | "player";
text?: string;
score?: number;
isRevealed?: boolean;
isActive?: boolean;
class?: string;
}
let {
variant,
text = "",
score = 0,
isRevealed = false,
isActive = false,
class: className = "",
}: Props = $props();
</script>
{#if variant === "category"}
<!-- Category Card for Projector -->
<div
class="bg-kv-board px-4 py-8 flex items-center justify-center overflow-hidden {className}"
>
<span
class="font-kv-body text-kv-white text-5xl text-center uppercase leading-tight kv-shadow-text"
>
{text || "Kategooria"}
</span>
</div>
{:else if variant === "price"}
<!-- Price Card for Projector -->
<div
class="bg-kv-board flex items-center justify-center overflow-hidden {className}"
>
{#if isRevealed}
<span class="opacity-0">{text}</span>
{:else}
<span
class="font-kv-price text-kv-yellow text-7xl text-center kv-shadow-price"
>
{text}
</span>
{/if}
</div>
{:else if variant === "player"}
<!-- Player Score Card for Projector -->
<div
class="bg-kv-board px-4 py-4 flex flex-col items-center justify-center gap-1 overflow-hidden
{isActive ? 'ring-4 ring-kv-yellow' : ''}
{className}"
>
<span
class="font-kv-body text-kv-white text-3xl text-center uppercase leading-tight kv-shadow-text"
>
{text}
</span>
<span
class="font-kv-body text-3xl text-center kv-shadow-text {score < 0
? 'text-red-500'
: 'text-kv-white'}"
>
{score}
</span>
</div>
{/if}

@ -0,0 +1,16 @@
// Kuldvillak UI Components
// Buttons
export { default as KvButtonPrimary } from './KvButtonPrimary.svelte';
export { default as KvButtonSecondary } from './KvButtonSecondary.svelte';
// Form Controls
export { default as KvNumberInput } from './KvNumberInput.svelte';
// Cards
export { default as KvProjectorCard } from './KvProjectorCard.svelte';
export { default as KvPlayerCard } from './KvPlayerCard.svelte';
// Branding
export { default as KvLogo } from './KvGameLogo.svelte';
export { default as KvGameLogo } from './KvGameLogo.svelte';

@ -0,0 +1,328 @@
{
"name": "9. Klassi Viktoriini",
"settings": {
"numberOfRounds": 1,
"pointValuePreset": "round1",
"pointValues": [
10,
20,
30,
40,
50
],
"basePointValue": 10,
"categoriesPerRound": 6,
"questionsPerCategory": 5,
"dailyDoublesPerRound": [
1
],
"enableFinalRound": true,
"enableSoundEffects": true,
"allowNegativeScores": true,
"maxTeams": 6,
"defaultTimerSeconds": 15,
"answerRevealSeconds": 5
},
"teams": [
{
"id": "r5f48jjat",
"name": "Tiim 1",
"score": 0
},
{
"id": "lnbeg51uo",
"name": "Tiim 2",
"score": 0
}
],
"rounds": [
{
"id": "28mrdbeas",
"name": "Villak",
"categories": [
{
"id": "lsc3pphby",
"name": "EESTI AJALUGU",
"questions": [
{
"id": "a11mxf6ra",
"question": "Mis aastal kuulutati välja Eesti Vabariik?",
"answer": "1918",
"points": 10,
"isDailyDouble": false,
"isRevealed": false
},
{
"id": "j7ztvxf7l",
"question": "Kes oli Eesti Vabariigi esimene riigivanem?",
"answer": "Konstantin Päts",
"points": 20,
"isDailyDouble": false,
"isRevealed": false
},
{
"id": "cwcx4jf7m",
"question": "Mis aastal toimus Laulev revolutsioon?",
"answer": "1988",
"points": 30,
"isDailyDouble": false,
"isRevealed": false
},
{
"id": "7m6e1qdwx",
"question": "Mis oli Balti keti kuupäev 1989. aastal?",
"answer": "23. august",
"points": 40,
"isDailyDouble": false,
"isRevealed": false
},
{
"id": "mmudm4kjd",
"question": "Mis aastal toimus Jüriöö ülestõus?",
"answer": "1343",
"points": 50,
"isDailyDouble": false,
"isRevealed": false
}
]
},
{
"id": "sfkprzqyz",
"name": "MATEMAATIKA",
"questions": [
{
"id": "zfg5pe8pl",
"question": "Mis on ruutjuur 144-st?",
"answer": "12",
"points": 10,
"isDailyDouble": false,
"isRevealed": false
},
{
"id": "ds2bmll5z",
"question": "Mis on Pythagorase teoreemi valem?",
"answer": "a² + b² = c²",
"points": 20,
"isDailyDouble": false,
"isRevealed": false
},
{
"id": "6jkk4qkwo",
"question": "Mis on arvu pi (π) väärtus kahe komakohani?",
"answer": "3,14",
"points": 30,
"isDailyDouble": false,
"isRevealed": false
},
{
"id": "h32bk4gll",
"question": "Kui kolmnurga alus on 8 cm ja kõrgus 6 cm, mis on pindala?",
"answer": "24 cm²",
"points": 40,
"isDailyDouble": true,
"isRevealed": false
},
{
"id": "a2vjrjbuo",
"question": "Lahenda võrrand: 3x + 7 = 22",
"answer": "x = 5",
"points": 50,
"isDailyDouble": false,
"isRevealed": false
}
]
},
{
"id": "resy4vkyc",
"name": "LOODUS",
"questions": [
{
"id": "a0tz6k2a5",
"question": "Mis on vee keemik valem?",
"answer": "H₂O",
"points": 10,
"isDailyDouble": false,
"isRevealed": false
},
{
"id": "1kjss32t3",
"question": "Mitu planeeti on meie päikesesüsteemis?",
"answer": "8",
"points": 20,
"isDailyDouble": false,
"isRevealed": false
},
{
"id": "4acp3zwgh",
"question": "Mis on fotosünteesi põhiprodukt?",
"answer": "Glükoos (suhkur) ja hapnik",
"points": 30,
"isDailyDouble": false,
"isRevealed": false
},
{
"id": "fh1jn2z6f",
"question": "Mis element on perioodilisustabelis tähisega Fe?",
"answer": "Raud",
"points": 40,
"isDailyDouble": false,
"isRevealed": false
},
{
"id": "wd96bi1d5",
"question": "Mis on DNA täisnimi?",
"answer": "Desoksüribonukleiinhape",
"points": 50,
"isDailyDouble": false,
"isRevealed": false
}
]
},
{
"id": "iy06ybvmy",
"name": "KIRJANDUS",
"questions": [
{
"id": "he7in5nor",
"question": "Kes kirjutas eepose 'Kalevipoeg'?",
"answer": "Friedrich Reinhold Kreutzwald",
"points": 10,
"isDailyDouble": false,
"isRevealed": false
},
{
"id": "pafvsp6t9",
"question": "Mis on A. H. Tammsaare tuntuim romaan?",
"answer": "Tõde ja õigus",
"points": 20,
"isDailyDouble": false,
"isRevealed": false
},
{
"id": "rjjp0h8s4",
"question": "Kes kirjutas luuletuse 'Mu isamaa on minu arm'?",
"answer": "Lydia Koidula",
"points": 30,
"isDailyDouble": false,
"isRevealed": false
},
{
"id": "n7o3vkkcn",
"question": "Mis on soneti traditsiooniline värsside arv?",
"answer": "14 rida",
"points": 40,
"isDailyDouble": false,
"isRevealed": false
},
{
"id": "4hxuoa1fy",
"question": "Kes kirjutas romaani 'Kevade'?",
"answer": "Oskar Luts",
"points": 50,
"isDailyDouble": false,
"isRevealed": false
}
]
},
{
"id": "g9r2whn2u",
"name": "GEOGRAAFIA",
"questions": [
{
"id": "4qkzwwe7r",
"question": "Mis on Eesti pealinn?",
"answer": "Tallinn",
"points": 10,
"isDailyDouble": false,
"isRevealed": false
},
{
"id": "7z3113es0",
"question": "Mis on Eesti kõrgeim mägi?",
"answer": "Suur Munamägi",
"points": 20,
"isDailyDouble": false,
"isRevealed": false
},
{
"id": "so7tqkk16",
"question": "Mis on maailma suurim ookean?",
"answer": "Vaikne ookean",
"points": 30,
"isDailyDouble": false,
"isRevealed": false
},
{
"id": "h3gyk1yi3",
"question": "Mis riik on pindalalt maailma suurim?",
"answer": "Venemaa",
"points": 40,
"isDailyDouble": false,
"isRevealed": false
},
{
"id": "385aprc7p",
"question": "Mis on Eesti suurim saar?",
"answer": "Saaremaa",
"points": 50,
"isDailyDouble": false,
"isRevealed": false
}
]
},
{
"id": "4y809i7nr",
"name": "ÜLDTEADMISED",
"questions": [
{
"id": "jzcjmb4ef",
"question": "Mis värvid on Eesti lipul?",
"answer": "Sinine, must, valge",
"points": 10,
"isDailyDouble": false,
"isRevealed": false
},
{
"id": "n86mk1kg0",
"question": "Mis on Eesti rahvuslind?",
"answer": "Suitsupääsuke",
"points": 20,
"isDailyDouble": false,
"isRevealed": false
},
{
"id": "04e5zactm",
"question": "Mis aastal liitus Eesti Euroopa Liiduga?",
"answer": "2004",
"points": 30,
"isDailyDouble": false,
"isRevealed": false
},
{
"id": "xwhxh99j6",
"question": "Mis on Eesti rahvuslill?",
"answer": "Rukkilill",
"points": 40,
"isDailyDouble": false,
"isRevealed": false
},
{
"id": "n54fmwmg8",
"question": "Mis aastal võttis Eesti kasutusele euro?",
"answer": "2011",
"points": 50,
"isDailyDouble": false,
"isRevealed": false
}
]
}
],
"pointMultiplier": 1
}
],
"finalRound": {
"category": "EESTI KULTUUR",
"question": "Mis aastal võitis Eesti esimest korda Eurovisiooni lauluvõistluse ja mis laul see oli?",
"answer": "2001. aastal lauluga 'Everybody' (Tanel Padar, Dave Benton ja 2XL)"
}
}

@ -0,0 +1,15 @@
// ============================================
// Ultimate Gaming - Library Exports
// ============================================
// Kuldvillak (Jeopardy) Types
export * from './types/kuldvillak';
// Kuldvillak Store
export { kuldvillakStore } from './stores/kuldvillak.svelte';
// Persistence (Save/Load)
export * from './stores/persistence';
// UI Components
export * from './components';

@ -0,0 +1,72 @@
import { browser } from '$app/environment';
class AudioStore {
private audio: HTMLAudioElement | null = null;
private initialized = false;
musicVolume = $state(50);
sfxVolume = $state(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);
}
}
initMusic(src: string) {
if (!browser || this.initialized) return;
this.audio = new Audio(src);
this.audio.loop = true;
this.audio.volume = this.musicVolume / 100;
this.initialized = true;
// Try to play
this.audio.play().catch(() => {
const playOnInteraction = () => {
this.audio?.play();
document.removeEventListener('click', playOnInteraction);
};
document.addEventListener('click', playOnInteraction);
});
}
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;
if (browser) {
localStorage.setItem('kv_sfx_volume', String(value));
}
}
stopMusic() {
if (this.audio) {
this.audio.pause();
this.audio.currentTime = 0;
}
}
destroy() {
if (this.audio) {
this.audio.pause();
this.audio.src = '';
this.audio = null;
this.initialized = false;
}
}
}
export const audioStore = new AudioStore();

@ -0,0 +1,623 @@
import { browser } from "$app/environment";
import type { Team, Round, FinalRound, GameSettings, GamePhase, QuestionResult } from "$lib/types/kuldvillak";
// Game session state that syncs across tabs
export interface GameSessionState {
// Game data
name: string;
settings: GameSettings;
teams: Team[];
rounds: Round[];
finalRound: FinalRound | null;
// Current game state
phase: GamePhase;
currentRoundIndex: number;
activeTeamId: string | null;
// Intro animation state
introCategoryIndex: number; // Which category is being shown during intro
categoriesIntroduced: boolean; // Have all categories been introduced for this round
boardRevealed: boolean; // Has the board been revealed (prices faded in) for this round
// Question state
currentQuestion: {
roundIndex: number;
categoryIndex: number;
questionIndex: number;
} | null;
showAnswer: boolean;
wrongTeamIds: string[]; // Teams that answered wrong for current question
lastAnsweredTeamId: string | null; // Track who answered last
lastAnswerCorrect: boolean | null; // Was it correct or wrong
// Daily Double
dailyDoubleWager: number | null;
// Final Round
finalCategoryRevealed: boolean; // Has the final category been revealed
finalWagers: Record<string, number>;
finalAnswers: Record<string, string>;
finalRevealed: string[]; // Team IDs that have been revealed
// Timer
timerRunning: boolean;
timerSeconds: number;
timerMax: number;
// Question tracking
questionsAnswered: number; // How many questions have been answered
currentQuestionNumber: number; // Which question number is this (1-30)
questionResults: QuestionResult[]; // Results of answered questions
}
const CHANNEL_NAME = "kuldvillak-game-session";
const STORAGE_KEY = "kuldvillak-game-session";
class GameSessionStore {
private channel: BroadcastChannel | null = null;
private timerInterval: ReturnType<typeof setInterval> | null = null;
private isTimerOwner = false; // Only one tab should own the timer
state = $state<GameSessionState | null>(null);
constructor() {
if (browser) {
// Setup broadcast channel for cross-tab sync
this.channel = new BroadcastChannel(CHANNEL_NAME);
this.channel.onmessage = (event) => {
if (event.data.type === "STATE_UPDATE") {
this.state = event.data.state;
} else if (event.data.type === "REQUEST_STATE") {
// Another tab is requesting the current state
if (this.state) {
this.broadcast("STATE_UPDATE", this.state);
}
} else if (event.data.type === "TIMER_OWNER_CHECK") {
// Another tab is checking who owns the timer
if (this.isTimerOwner) {
this.channel?.postMessage({ type: "TIMER_OWNER_EXISTS" });
}
} else if (event.data.type === "TIMER_OWNER_EXISTS") {
// Another tab owns the timer, don't start ours
this.isTimerOwner = false;
}
};
// Try to load from localStorage
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
try {
this.state = JSON.parse(saved);
// Timer will be started by moderator view via enableTimerControl()
} catch {
// Invalid data
}
}
// Request state from other tabs
this.channel.postMessage({ type: "REQUEST_STATE" });
}
}
private broadcast(type: string, state: GameSessionState) {
if (this.channel) {
// Use $state.snapshot to get plain object from Proxy
const plainState = $state.snapshot(state);
this.channel.postMessage({ type, state: plainState });
}
}
private persist() {
if (browser && this.state) {
// Use $state.snapshot to get plain object from Proxy
const plainState = $state.snapshot(this.state);
localStorage.setItem(STORAGE_KEY, JSON.stringify(plainState));
this.broadcast("STATE_UPDATE", this.state);
}
}
// Initialize a new game session
startGame(data: {
name: string;
settings: GameSettings;
teams: Team[];
rounds: Round[];
finalRound: FinalRound | null;
}) {
// Deep clone the data to remove any Proxy objects
const plainData = JSON.parse(JSON.stringify(data));
this.state = {
...plainData,
phase: "intro" as const,
currentRoundIndex: 0,
activeTeamId: null,
introCategoryIndex: -1,
categoriesIntroduced: false,
boardRevealed: false,
currentQuestion: null,
showAnswer: false,
wrongTeamIds: [],
lastAnsweredTeamId: null,
lastAnswerCorrect: null,
dailyDoubleWager: null,
finalCategoryRevealed: false,
finalWagers: {},
finalAnswers: {},
finalRevealed: [],
timerRunning: false,
timerSeconds: 0,
timerMax: plainData.settings.defaultTimerSeconds ?? 10,
questionsAnswered: 0,
currentQuestionNumber: 0,
questionResults: [],
};
// Timer will be started by moderator view via enableTimerControl()
this.persist();
}
// ============================================
// Intro Phase Management
// ============================================
startCategoryIntro() {
if (!this.state) return;
this.state.phase = "intro-categories";
this.state.introCategoryIndex = 0;
this.persist();
}
nextIntroCategory() {
if (!this.state) return;
const currentRound = this.state.rounds[this.state.currentRoundIndex];
if (!currentRound) return;
this.state.introCategoryIndex++;
// Check if we've shown all categories - stay on villak screen
if (this.state.introCategoryIndex >= currentRound.categories.length) {
this.state.phase = "intro";
this.state.introCategoryIndex = -1;
this.state.categoriesIntroduced = true; // Mark categories as introduced
}
this.persist();
}
startBoard() {
if (!this.state) return;
this.state.phase = "board";
this.persist();
}
markBoardRevealed() {
if (!this.state) return;
this.state.boardRevealed = true;
this.persist();
}
// End the game session
endGame() {
this.stopInternalTimer();
this.state = null;
if (browser) {
localStorage.removeItem(STORAGE_KEY);
this.broadcast("STATE_UPDATE", null as any);
}
}
// ============================================
// Question Management
// ============================================
selectQuestion(roundIndex: number, categoryIndex: number, questionIndex: number) {
if (!this.state) return;
const question = this.state.rounds[roundIndex]?.categories[categoryIndex]?.questions[questionIndex];
if (!question || question.isRevealed) return;
this.state.currentQuestion = { roundIndex, categoryIndex, questionIndex };
this.state.wrongTeamIds = [];
this.state.activeTeamId = null;
this.state.currentQuestionNumber = this.state.questionsAnswered + 1;
if (question.isDailyDouble) {
this.state.phase = "daily-double";
this.state.dailyDoubleWager = null;
} else {
this.state.phase = "question";
}
this.state.showAnswer = false;
// Reset timer
this.state.timerSeconds = this.state.timerMax;
this.state.timerRunning = false;
this.persist();
}
setDailyDoubleWager(teamId: string, wager: number) {
if (!this.state) return;
this.state.activeTeamId = teamId;
this.state.dailyDoubleWager = wager;
this.state.phase = "question";
this.persist();
}
toggleAnswer() {
if (!this.state) return;
this.state.showAnswer = !this.state.showAnswer;
this.persist();
}
revealAnswer() {
if (!this.state) return;
this.state.showAnswer = true;
this.persist();
}
// Mark answer correct - awards points, shows answer, closes after delay
markCorrect(teamId: string) {
if (!this.state || !this.state.currentQuestion) return;
const { roundIndex, categoryIndex, questionIndex } = this.state.currentQuestion;
const question = this.state.rounds[roundIndex].categories[categoryIndex].questions[questionIndex];
// Award points
const team = this.state.teams.find(t => t.id === teamId);
if (team) {
const points = this.state.dailyDoubleWager ?? question.points;
team.score += points;
}
// Track last answer
this.state.lastAnsweredTeamId = teamId;
this.state.lastAnswerCorrect = true;
// Show answer and close after configured delay
this.state.showAnswer = true;
this.persist();
const revealMs = (this.state.settings.answerRevealSeconds ?? 5) * 1000;
setTimeout(() => this.finalizeQuestion(), revealMs);
}
// Mark answer wrong - deducts points, adds to wrong list
markWrong(teamId: string) {
if (!this.state || !this.state.currentQuestion) return;
const { roundIndex, categoryIndex, questionIndex } = this.state.currentQuestion;
const question = this.state.rounds[roundIndex].categories[categoryIndex].questions[questionIndex];
// Deduct points if allowed
const team = this.state.teams.find(t => t.id === teamId);
if (team && this.state.settings.allowNegativeScores) {
const points = this.state.dailyDoubleWager ?? question.points;
team.score -= points;
}
// Track last answer
this.state.lastAnsweredTeamId = teamId;
this.state.lastAnswerCorrect = false;
// Add to wrong list
if (!this.state.wrongTeamIds.includes(teamId)) {
this.state.wrongTeamIds.push(teamId);
}
// Clear active team for next selection
this.state.activeTeamId = null;
// 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;
this.persist();
const revealMs = (this.state.settings.answerRevealSeconds ?? 5) * 1000;
setTimeout(() => this.finalizeQuestion(), revealMs);
} else {
this.persist();
}
}
// Skip question - shows answer, closes after delay
skipQuestion() {
if (!this.state || !this.state.currentQuestion) return;
this.state.showAnswer = true;
this.persist();
const revealMs = (this.state.settings.answerRevealSeconds ?? 5) * 1000;
setTimeout(() => this.finalizeQuestion(), revealMs);
}
// Actually close the question and return to board
private finalizeQuestion() {
if (!this.state || !this.state.currentQuestion) return;
const { roundIndex, categoryIndex, questionIndex } = this.state.currentQuestion;
const question = this.state.rounds[roundIndex].categories[categoryIndex].questions[questionIndex];
// Save question result before resetting state
const points = this.state.dailyDoubleWager ?? question.points;
let pointsChange = 0;
if (this.state.lastAnswerCorrect === true) {
pointsChange = points;
} else if (this.state.lastAnswerCorrect === false) {
pointsChange = -points;
}
this.state.questionResults.push({
categoryIndex,
questionIndex,
points: question.points,
teamId: this.state.lastAnsweredTeamId,
pointsChange,
isDailyDouble: question.isDailyDouble,
wager: this.state.dailyDoubleWager ?? undefined,
});
// Mark as revealed
question.isRevealed = true;
// Increment questions answered counter
this.state.questionsAnswered++;
// Reset state
this.state.currentQuestion = null;
this.state.showAnswer = false;
this.state.wrongTeamIds = [];
this.state.dailyDoubleWager = null;
this.state.activeTeamId = null;
this.state.phase = "board";
// Check if round is complete
this.checkRoundComplete();
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;
const currentRound = this.state.rounds[this.state.currentRoundIndex];
const allRevealed = currentRound.categories.every(cat =>
cat.questions.every(q => q.isRevealed)
);
if (allRevealed) {
// Move to next round or final
if (this.state.currentRoundIndex < this.state.rounds.length - 1) {
this.state.currentRoundIndex++;
} else if (this.state.settings.enableFinalRound && this.state.finalRound) {
this.state.phase = "final-category";
} else {
this.state.phase = "finished";
}
}
}
// ============================================
// Team & Score Management
// ============================================
setActiveTeam(teamId: string | null) {
if (!this.state) return;
this.state.activeTeamId = teamId;
this.persist();
}
adjustScore(teamId: string, delta: number) {
if (!this.state) return;
const team = this.state.teams.find(t => t.id === teamId);
if (team) {
team.score += delta;
this.persist();
}
}
setScore(teamId: string, score: number) {
if (!this.state) return;
const team = this.state.teams.find(t => t.id === teamId);
if (team) {
team.score = score;
this.persist();
}
}
// ============================================
// Round Management
// ============================================
nextRound() {
if (!this.state) return;
if (this.state.currentRoundIndex < this.state.rounds.length - 1) {
this.state.currentRoundIndex++;
this.state.phase = "intro";
this.state.introCategoryIndex = -1;
this.state.categoriesIntroduced = false; // Reset for new round
this.state.boardRevealed = false; // Reset for new round
this.state.questionResults = [];
this.persist();
}
}
goToFinalRound() {
if (!this.state || !this.state.finalRound) return;
this.state.phase = "final-intro";
this.persist();
}
startFinalCategoryReveal() {
if (!this.state) return;
this.state.phase = "final-category";
this.persist();
}
finishFinalCategoryReveal() {
if (!this.state) return;
// Go back to Kuldvillak screen, waiting for moderator to start question
this.state.phase = "final-intro";
this.state.finalCategoryRevealed = true;
this.persist();
}
// ============================================
// 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.persist();
}
showFinalScores() {
if (!this.state) return;
this.state.phase = "final-scores";
this.persist();
}
setFinalAnswer(teamId: string, answer: string) {
if (!this.state) return;
this.state.finalAnswers[teamId] = answer;
this.persist();
}
revealFinalAnswer(teamId: string, correct: boolean) {
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) {
team.score += wager;
} else {
team.score -= wager;
}
}
this.state.finalRevealed.push(teamId);
// Check if all revealed
if (this.state.finalRevealed.length === this.state.teams.length) {
this.state.phase = "finished";
}
this.persist();
}
// ============================================
// Timer
// ============================================
private startInternalTimer() {
// Only start if not already running
if (this.timerInterval) return;
this.timerInterval = setInterval(() => {
if (this.state?.timerRunning) {
if (this.state.timerSeconds > 0) {
this.state.timerSeconds--;
this.persist();
} else {
this.state.timerRunning = false;
this.persist();
}
}
}, 1000);
}
private stopInternalTimer() {
if (this.timerInterval) {
clearInterval(this.timerInterval);
this.timerInterval = null;
}
}
setTimerMax(seconds: number) {
if (!this.state) return;
this.state.timerMax = seconds;
this.persist();
}
// Call this from moderator view only
enableTimerControl() {
this.startInternalTimer();
}
startTimer() {
if (!this.state) return;
this.state.timerRunning = true;
this.state.timerSeconds = this.state.timerMax;
this.persist();
}
stopTimer() {
if (!this.state) return;
this.state.timerRunning = false;
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;
this.persist();
}
// ============================================
// Helpers
// ============================================
get currentRound(): Round | null {
if (!this.state) return null;
return this.state.rounds[this.state.currentRoundIndex] ?? null;
}
get currentQuestionData() {
if (!this.state?.currentQuestion) return null;
const { roundIndex, categoryIndex, questionIndex } = this.state.currentQuestion;
const category = this.state.rounds[roundIndex]?.categories[categoryIndex];
const question = category?.questions[questionIndex];
return question ? { category, question } : null;
}
get sortedTeams(): Team[] {
if (!this.state) return [];
return [...this.state.teams].sort((a, b) => b.score - a.score);
}
getQuestionResult(categoryIndex: number, questionIndex: number): QuestionResult | null {
if (!this.state) return null;
return this.state.questionResults.find(
r => r.categoryIndex === categoryIndex && r.questionIndex === questionIndex
) ?? null;
}
}
export const gameSession = new GameSessionStore();

@ -0,0 +1,363 @@
// ============================================
// Kuldvillak Game State Store (Svelte 5 Runes)
// ============================================
import {
type KuldvillakGame,
type Team,
type Question,
type GamePhase,
type Round,
type Category,
DEFAULT_SETTINGS,
DEFAULT_STATE
} from '$lib/types/kuldvillak';
// ============================================
// Utility Functions
// ============================================
function generateId(): string {
return crypto.randomUUID();
}
function createEmptyGame(name: string = 'New Game'): KuldvillakGame {
const now = new Date().toISOString();
return {
id: generateId(),
name,
createdAt: now,
updatedAt: now,
settings: { ...DEFAULT_SETTINGS },
teams: [],
rounds: [],
finalRound: null,
state: { ...DEFAULT_STATE }
};
}
function createEmptyRound(name: string, multiplier: number, settings: typeof DEFAULT_SETTINGS): Round {
const categories: Category[] = [];
for (let i = 0; i < settings.categoriesPerRound; i++) {
const questions: Question[] = settings.pointValues.map((points) => ({
id: generateId(),
question: '',
answer: '',
points: points * multiplier,
isDailyDouble: false,
isRevealed: false
}));
categories.push({
id: generateId(),
name: `Category ${i + 1}`,
questions
});
}
return {
id: generateId(),
name,
categories,
pointMultiplier: multiplier
};
}
// ============================================
// Game Store Class
// ============================================
class KuldvillakStore {
// Reactive state using Svelte 5 runes
game = $state<KuldvillakGame | null>(null);
savedGames = $state<KuldvillakGame[]>([]);
// Derived values
get currentRound(): Round | null {
if (!this.game) return null;
return this.game.rounds[this.game.state.currentRoundIndex] ?? null;
}
get currentQuestion(): Question | null {
if (!this.game || !this.currentRound || !this.game.state.currentQuestionId) return null;
for (const category of this.currentRound.categories) {
const question = category.questions.find((q) => q.id === this.game!.state.currentQuestionId);
if (question) return question;
}
return null;
}
get currentCategory(): Category | null {
if (!this.game || !this.currentRound || !this.game.state.currentCategoryId) return null;
return this.currentRound.categories.find((c) => c.id === this.game!.state.currentCategoryId) ?? null;
}
get activeTeam(): Team | null {
if (!this.game || !this.game.state.activeTeamId) return null;
return this.game.teams.find((t) => t.id === this.game!.state.activeTeamId) ?? null;
}
get isRoundComplete(): boolean {
if (!this.currentRound) return false;
return this.currentRound.categories.every((c) => c.questions.every((q) => q.isRevealed));
}
get isGameComplete(): boolean {
if (!this.game) return false;
const allRoundsComplete = this.game.rounds.every((round) =>
round.categories.every((c) => c.questions.every((q) => q.isRevealed))
);
if (!this.game.settings.enableFinalRound) return allRoundsComplete;
return allRoundsComplete && this.game.state.phase === 'finished';
}
// ============================================
// Game Lifecycle
// ============================================
newGame(name: string = 'New Game'): void {
this.game = createEmptyGame(name);
// Create default 2 rounds (Jeopardy + Double Jeopardy)
this.game.rounds = [
createEmptyRound('Round 1', 1, this.game.settings),
createEmptyRound('Round 2', 2, this.game.settings)
];
}
loadGame(gameData: KuldvillakGame): void {
this.game = gameData;
}
resetGame(): void {
if (!this.game) return;
// Reset all questions to unrevealed
for (const round of this.game.rounds) {
for (const category of round.categories) {
for (const question of category.questions) {
question.isRevealed = false;
}
}
}
// Reset teams scores
for (const team of this.game.teams) {
team.score = 0;
}
// Reset state
this.game.state = { ...DEFAULT_STATE };
}
closeGame(): void {
this.game = null;
}
// ============================================
// Team Management
// ============================================
addTeam(name: string): void {
if (!this.game) return;
this.game.teams.push({
id: generateId(),
name,
score: 0
});
}
removeTeam(teamId: string): void {
if (!this.game) return;
this.game.teams = this.game.teams.filter((t) => t.id !== teamId);
}
updateTeamName(teamId: string, name: string): void {
if (!this.game) return;
const team = this.game.teams.find((t) => t.id === teamId);
if (team) team.name = name;
}
updateTeamScore(teamId: string, score: number): void {
if (!this.game) return;
const team = this.game.teams.find((t) => t.id === teamId);
if (team) team.score = score;
}
adjustTeamScore(teamId: string, delta: number): void {
if (!this.game) return;
const team = this.game.teams.find((t) => t.id === teamId);
if (team) team.score += delta;
}
setActiveTeam(teamId: string | null): void {
if (!this.game) return;
this.game.state.activeTeamId = teamId;
}
// ============================================
// Game Flow Control
// ============================================
setPhase(phase: GamePhase): void {
if (!this.game) return;
this.game.state.phase = phase;
}
startGame(): void {
if (!this.game || this.game.teams.length === 0) return;
this.game.state.phase = 'board';
this.game.state.currentRoundIndex = 0;
}
selectQuestion(categoryId: string, questionId: string): void {
if (!this.game) return;
const question = this.findQuestion(questionId);
if (!question || question.isRevealed) return;
this.game.state.currentCategoryId = categoryId;
this.game.state.currentQuestionId = questionId;
if (question.isDailyDouble) {
this.game.state.phase = 'daily-double';
} else {
this.game.state.phase = 'question';
}
}
setDailyDoubleWager(wager: number): void {
if (!this.game) return;
this.game.state.dailyDoubleWager = wager;
this.game.state.phase = 'question';
}
revealAnswer(): void {
if (!this.game) return;
this.game.state.phase = 'answer';
}
markCorrect(teamId: string): void {
if (!this.game || !this.currentQuestion) return;
const points = this.game.state.dailyDoubleWager ?? this.currentQuestion.points;
this.adjustTeamScore(teamId, points);
this.finishQuestion();
}
markIncorrect(teamId: string): void {
if (!this.game || !this.currentQuestion) return;
const points = this.game.state.dailyDoubleWager ?? this.currentQuestion.points;
this.adjustTeamScore(teamId, -points);
}
finishQuestion(): void {
if (!this.game || !this.game.state.currentQuestionId) return;
const question = this.findQuestion(this.game.state.currentQuestionId);
if (question) question.isRevealed = true;
this.game.state.currentQuestionId = null;
this.game.state.currentCategoryId = null;
this.game.state.dailyDoubleWager = null;
this.game.state.activeTeamId = null;
// Check if round is complete
if (this.isRoundComplete) {
this.advanceRound();
} else {
this.game.state.phase = 'board';
}
}
advanceRound(): void {
if (!this.game) return;
const nextIndex = this.game.state.currentRoundIndex + 1;
if (nextIndex < this.game.rounds.length) {
this.game.state.currentRoundIndex = nextIndex;
this.game.state.phase = 'board';
} else if (this.game.settings.enableFinalRound && this.game.finalRound) {
this.game.state.phase = 'final-category';
} else {
this.game.state.phase = 'finished';
}
}
// Final Round
startFinalRound(): void {
if (!this.game) return;
this.game.state.phase = 'final-question';
}
setFinalWager(teamId: string, wager: number): void {
if (!this.game) return;
this.game.state.finalWagers[teamId] = wager;
}
setFinalAnswer(teamId: string, answer: string): void {
if (!this.game) return;
this.game.state.finalAnswers[teamId] = answer;
}
scoreFinalAnswer(teamId: string, correct: boolean): void {
if (!this.game) return;
const wager = this.game.state.finalWagers[teamId] ?? 0;
this.adjustTeamScore(teamId, correct ? wager : -wager);
}
finishGame(): void {
if (!this.game) return;
this.game.state.phase = 'finished';
}
// ============================================
// Editor Functions
// ============================================
updateGameName(name: string): void {
if (!this.game) return;
this.game.name = name;
this.game.updatedAt = new Date().toISOString();
}
updateCategoryName(roundIndex: number, categoryId: string, name: string): void {
if (!this.game) return;
const category = this.game.rounds[roundIndex]?.categories.find((c) => c.id === categoryId);
if (category) category.name = name;
}
updateQuestion(questionId: string, updates: Partial<Pick<Question, 'question' | 'answer' | 'isDailyDouble'>>): void {
if (!this.game) return;
const question = this.findQuestion(questionId);
if (question) {
if (updates.question !== undefined) question.question = updates.question;
if (updates.answer !== undefined) question.answer = updates.answer;
if (updates.isDailyDouble !== undefined) question.isDailyDouble = updates.isDailyDouble;
}
}
updateFinalRound(category: string, question: string, answer: string): void {
if (!this.game) return;
this.game.finalRound = { category, question, answer };
}
addRound(): void {
if (!this.game) return;
const multiplier = this.game.rounds.length + 1;
this.game.rounds.push(createEmptyRound(`Round ${multiplier}`, multiplier, this.game.settings));
}
removeRound(roundIndex: number): void {
if (!this.game || this.game.rounds.length <= 1) return;
this.game.rounds.splice(roundIndex, 1);
}
// ============================================
// Helper Functions
// ============================================
private findQuestion(questionId: string): Question | null {
if (!this.game) return null;
for (const round of this.game.rounds) {
for (const category of round.categories) {
const question = category.questions.find((q) => q.id === questionId);
if (question) return question;
}
}
return null;
}
}
// Export singleton instance
export const kuldvillakStore = new KuldvillakStore();

@ -0,0 +1,259 @@
// ============================================
// LocalStorage Persistence for Game Data
// ============================================
import type { KuldvillakGame, GameMetadata } from '$lib/types/kuldvillak';
const STORAGE_PREFIX = 'ultimate_gaming';
const KULDVILLAK_GAMES_KEY = `${STORAGE_PREFIX}_kuldvillak_games`;
const KULDVILLAK_ACTIVE_KEY = `${STORAGE_PREFIX}_kuldvillak_active`;
// ============================================
// Kuldvillak Save/Load Functions
// ============================================
/**
* Get all saved Kuldvillak games metadata (lightweight list)
*/
export function getKuldvillakGamesList(): GameMetadata[] {
if (typeof localStorage === 'undefined') return [];
try {
const data = localStorage.getItem(KULDVILLAK_GAMES_KEY);
if (!data) return [];
const games: KuldvillakGame[] = JSON.parse(data);
return games.map((game) => ({
id: game.id,
name: game.name,
createdAt: game.createdAt,
updatedAt: game.updatedAt,
teamCount: game.teams.length,
roundCount: game.rounds.length
}));
} catch (e) {
console.error('Failed to load games list:', e);
return [];
}
}
/**
* Get all saved Kuldvillak games (full data)
*/
export function getAllKuldvillakGames(): KuldvillakGame[] {
if (typeof localStorage === 'undefined') return [];
try {
const data = localStorage.getItem(KULDVILLAK_GAMES_KEY);
return data ? JSON.parse(data) : [];
} catch (e) {
console.error('Failed to load games:', e);
return [];
}
}
/**
* Load a specific Kuldvillak game by ID
*/
export function loadKuldvillakGame(gameId: string): KuldvillakGame | null {
const games = getAllKuldvillakGames();
return games.find((g) => g.id === gameId) ?? null;
}
/**
* Save a Kuldvillak game (creates new or updates existing)
*/
export function saveKuldvillakGame(game: KuldvillakGame): boolean {
if (typeof localStorage === 'undefined') return false;
try {
const games = getAllKuldvillakGames();
const existingIndex = games.findIndex((g) => g.id === game.id);
// Update timestamp
game.updatedAt = new Date().toISOString();
if (existingIndex >= 0) {
games[existingIndex] = game;
} else {
games.push(game);
}
localStorage.setItem(KULDVILLAK_GAMES_KEY, JSON.stringify(games));
return true;
} catch (e) {
console.error('Failed to save game:', e);
return false;
}
}
/**
* Delete a Kuldvillak game by ID
*/
export function deleteKuldvillakGame(gameId: string): boolean {
if (typeof localStorage === 'undefined') return false;
try {
const games = getAllKuldvillakGames();
const filtered = games.filter((g) => g.id !== gameId);
localStorage.setItem(KULDVILLAK_GAMES_KEY, JSON.stringify(filtered));
return true;
} catch (e) {
console.error('Failed to delete game:', e);
return false;
}
}
/**
* Duplicate a Kuldvillak game
*/
export function duplicateKuldvillakGame(gameId: string): KuldvillakGame | null {
const original = loadKuldvillakGame(gameId);
if (!original) return null;
const now = new Date().toISOString();
const duplicate: KuldvillakGame = {
...JSON.parse(JSON.stringify(original)), // Deep clone
id: crypto.randomUUID(),
name: `${original.name} (Copy)`,
createdAt: now,
updatedAt: now
};
// Reset game state
duplicate.state = {
phase: 'lobby',
currentRoundIndex: 0,
currentQuestionId: null,
currentCategoryId: null,
activeTeamId: null,
dailyDoubleWager: null,
finalWagers: {},
finalAnswers: {}
};
// Reset revealed questions and scores
for (const round of duplicate.rounds) {
for (const category of round.categories) {
for (const question of category.questions) {
question.isRevealed = false;
}
}
}
for (const team of duplicate.teams) {
team.score = 0;
}
if (saveKuldvillakGame(duplicate)) {
return duplicate;
}
return null;
}
// ============================================
// Active Game Session (for resuming)
// ============================================
/**
* Save reference to currently active game
*/
export function setActiveKuldvillakGame(gameId: string | null): void {
if (typeof localStorage === 'undefined') return;
if (gameId) {
localStorage.setItem(KULDVILLAK_ACTIVE_KEY, gameId);
} else {
localStorage.removeItem(KULDVILLAK_ACTIVE_KEY);
}
}
/**
* Get the ID of the last active game
*/
export function getActiveKuldvillakGameId(): string | null {
if (typeof localStorage === 'undefined') return null;
return localStorage.getItem(KULDVILLAK_ACTIVE_KEY);
}
/**
* Load the last active game
*/
export function loadActiveKuldvillakGame(): KuldvillakGame | null {
const activeId = getActiveKuldvillakGameId();
if (!activeId) return null;
return loadKuldvillakGame(activeId);
}
// ============================================
// Export/Import Functions
// ============================================
/**
* Export a game to a JSON file (triggers download)
*/
export function exportKuldvillakGame(game: KuldvillakGame): void {
const dataStr = JSON.stringify(game, null, 2);
const blob = new Blob([dataStr], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${game.name.replace(/[^a-z0-9]/gi, '_')}_kuldvillak.json`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
/**
* Import a game from a JSON file
*/
export async function importKuldvillakGame(file: File): Promise<KuldvillakGame | null> {
try {
const text = await file.text();
const game: KuldvillakGame = JSON.parse(text);
// Validate basic structure
if (!game.id || !game.name || !game.rounds || !game.settings) {
throw new Error('Invalid game file structure');
}
// Assign new ID to avoid conflicts
game.id = crypto.randomUUID();
game.createdAt = new Date().toISOString();
game.updatedAt = new Date().toISOString();
if (saveKuldvillakGame(game)) {
return game;
}
return null;
} catch (e) {
console.error('Failed to import game:', e);
return null;
}
}
// ============================================
// Storage Stats
// ============================================
/**
* Get approximate storage usage
*/
export function getStorageStats(): { used: number; available: number } {
if (typeof localStorage === 'undefined') {
return { used: 0, available: 0 };
}
let used = 0;
for (const key in localStorage) {
if (localStorage.hasOwnProperty(key)) {
used += localStorage.getItem(key)?.length ?? 0;
}
}
// localStorage typically has ~5MB limit
const available = 5 * 1024 * 1024 - used;
return { used, available };
}

@ -0,0 +1,112 @@
import { browser } from "$app/environment";
const THEME_STORAGE_KEY = "kuldvillak-theme";
// Default theme colors
export const DEFAULT_THEME = {
primary: "#003B9B",
secondary: "#FFAB00",
text: "#FFFFFF",
background: "#000000",
};
// Load initial values from localStorage
function getInitialTheme() {
if (browser) {
const saved = localStorage.getItem(THEME_STORAGE_KEY);
if (saved) {
try {
const theme = JSON.parse(saved);
return {
primary: theme.primary ?? DEFAULT_THEME.primary,
secondary: theme.secondary ?? DEFAULT_THEME.secondary,
text: theme.text ?? DEFAULT_THEME.text,
background: theme.background ?? DEFAULT_THEME.background,
};
} catch {
// Ignore parse errors
}
}
}
return { ...DEFAULT_THEME };
}
const initialTheme = getInitialTheme();
// Current applied values (what's visually shown)
let primary = $state(initialTheme.primary);
let secondary = $state(initialTheme.secondary);
let text = $state(initialTheme.text);
let background = $state(initialTheme.background);
// Saved values (what's persisted to localStorage)
let savedPrimary = $state(initialTheme.primary);
let savedSecondary = $state(initialTheme.secondary);
let savedText = $state(initialTheme.text);
let savedBackground = $state(initialTheme.background);
function applyTheme() {
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);
}
}
// Save current values to localStorage
function save() {
if (browser) {
localStorage.setItem(THEME_STORAGE_KEY, JSON.stringify({
primary,
secondary,
text,
background
}));
// Update saved state
savedPrimary = primary;
savedSecondary = secondary;
savedText = text;
savedBackground = background;
}
}
// Revert to last saved values (for cancel/close without saving)
function revert() {
primary = savedPrimary;
secondary = savedSecondary;
text = savedText;
background = savedBackground;
applyTheme();
}
// Reset to default values (applies immediately for preview)
function resetToDefaults() {
primary = DEFAULT_THEME.primary;
secondary = DEFAULT_THEME.secondary;
text = DEFAULT_THEME.text;
background = DEFAULT_THEME.background;
applyTheme();
}
// Reset and save (for full reset)
function reset() {
resetToDefaults();
save();
}
export const themeStore = {
get primary() { return primary; },
set primary(value: string) { primary = value; applyTheme(); },
get secondary() { return secondary; },
set secondary(value: string) { secondary = value; applyTheme(); },
get text() { return text; },
set text(value: string) { text = value; applyTheme(); },
get background() { return background; },
set background(value: string) { background = value; applyTheme(); },
applyTheme,
save,
revert,
reset,
resetToDefaults,
};

@ -0,0 +1,167 @@
// ============================================
// Kuldvillak (Jeopardy) Type Definitions
// ============================================
/** A single question/answer pair on the board */
export interface Question {
id: string;
question: string;
answer: string;
points: number;
isDailyDouble: boolean;
isRevealed: boolean;
imageUrl?: string;
}
/** A category column containing questions */
export interface Category {
id: string;
name: string;
questions: Question[];
}
/** A game round (e.g., Jeopardy, Double Jeopardy) */
export interface Round {
id: string;
name: string;
categories: Category[];
pointMultiplier: number;
}
/** Final Jeopardy round structure */
export interface FinalRound {
category: string;
question: string;
answer: string;
}
/** A competing team */
export interface Team {
id: string;
name: string;
score: number;
}
/** Current game phase */
export type GamePhase =
| 'intro' // Show Kuldvillak home screen (round start)
| 'intro-categories' // Animating category introductions
| 'lobby'
| 'board'
| 'question'
| 'answer'
| 'daily-double'
| 'final-intro' // Final round intro (Kuldvillak screen)
| 'final-category' // Reveal final round category
| 'final-question'
| 'final-reveal'
| 'final-scores'
| 'finished';
/** Result of a question for tracking */
export interface QuestionResult {
categoryIndex: number;
questionIndex: number;
points: number;
teamId: string | null; // Who won/lost points (null if skipped)
pointsChange: number; // Positive for correct, negative for wrong, 0 for skip
isDailyDouble: boolean;
wager?: number; // DD wager if applicable
}
/** Current state during gameplay */
export interface GameState {
phase: GamePhase;
currentRoundIndex: number;
currentQuestionId: string | null;
currentCategoryId: string | null;
activeTeamId: string | null;
dailyDoubleWager: number | null;
finalWagers: Record<string, number>;
finalAnswers: Record<string, string>;
}
/** Point value preset types */
export type PointValuePreset = 'round1' | 'round2' | 'custom' | 'multiplier';
/** Configurable game settings */
export interface GameSettings {
numberOfRounds: 1 | 2;
pointValuePreset: PointValuePreset;
pointValues: number[];
basePointValue: number; // For multiplier preset (e.g., 100 → 100,200,300,400,500)
categoriesPerRound: number;
questionsPerCategory: number;
dailyDoublesPerRound: number[];
enableFinalRound: boolean;
enableSoundEffects: boolean;
allowNegativeScores: boolean;
maxTeams: number;
defaultTimerSeconds: number;
answerRevealSeconds: number;
}
/** Point value presets */
export const POINT_PRESETS = {
round1: [100, 200, 300, 400, 500],
round2: [200, 400, 600, 800, 1000]
} as const;
/** Complete game configuration (saveable/loadable) */
export interface KuldvillakGame {
id: string;
name: string;
createdAt: string;
updatedAt: string;
settings: GameSettings;
teams: Team[];
rounds: Round[];
finalRound: FinalRound | null;
state: GameState;
}
/** Default settings for new games */
export const DEFAULT_SETTINGS: GameSettings = {
numberOfRounds: 2,
pointValuePreset: 'round1',
pointValues: [10, 20, 30, 40, 50],
basePointValue: 10,
categoriesPerRound: 6,
questionsPerCategory: 5,
dailyDoublesPerRound: [1, 2],
enableFinalRound: true,
enableSoundEffects: true,
allowNegativeScores: true,
maxTeams: 6,
defaultTimerSeconds: 5,
answerRevealSeconds: 5
};
/** Default initial game state */
export const DEFAULT_STATE: GameState = {
phase: 'lobby',
currentRoundIndex: 0,
currentQuestionId: null,
currentCategoryId: null,
activeTeamId: null,
dailyDoubleWager: null,
finalWagers: {},
finalAnswers: {}
};
// ============================================
// Helper Types for Editor/UI
// ============================================
/** Saved game metadata for game list */
export interface GameMetadata {
id: string;
name: string;
createdAt: string;
updatedAt: string;
teamCount: number;
roundCount: number;
}
/** View mode for dual-screen setup */
export type ViewMode = 'projector' | 'moderator';

@ -0,0 +1,31 @@
<script lang="ts">
import { page } from "$app/stores";
import * as m from "$lib/paraglide/messages";
import { LanguageSwitcher } from "$lib/components";
</script>
<div class="min-h-screen flex items-center justify-center bg-[#0a121f]">
<div class="flex flex-col items-center gap-16">
<div
class="flex flex-col items-center text-center text-white font-['Comic_Sans_MS',cursive,sans-serif] font-bold italic capitalize"
>
<h1 class="text-[128px] md:text-[80px] m-0 leading-none">
{$page.status}
</h1>
<p class="text-[48px] md:text-[32px] m-0">
{#if $page.status === 404}
{m.kv_error_not_found()}
{:else}
{$page.error?.message ?? "Error"}
{/if}
</p>
</div>
<p
class="font-['Comic_Sans_MS',cursive,sans-serif] font-bold italic text-[32px] md:text-[20px] text-white/50 text-center m-0 px-4"
>
{m.kv_error_hint()}
</p>
</div>
</div>
<LanguageSwitcher />

@ -0,0 +1,19 @@
<script lang="ts">
import './layout.css';
import favicon from '$lib/assets/favicon.svg';
import { themeStore } from '$lib/stores/theme.svelte';
import { onMount } from 'svelte';
let { children } = $props();
// Apply saved theme colors on mount
onMount(() => {
themeStore.applyTheme();
});
</script>
<svelte:head>
<link rel="icon" href={favicon} />
</svelte:head>
{@render children()}

@ -0,0 +1,80 @@
<script lang="ts">
import * as m from "$lib/paraglide/messages";
import { LanguageSwitcher } from "$lib/components";
// Game data
const games = [
{
nameKey: "kuldvillak" as const,
href: "/kuldvillak",
image: "/images/kuldvillak-cover.jpg",
available: true,
},
{
nameKey: "rooside_soda" as const,
href: "/rooside-soda",
image: "/images/rooside-soda-cover.jpg",
available: false,
},
];
function getGameName(key: "kuldvillak" | "rooside_soda"): string {
return key === "kuldvillak"
? m.game_kuldvillak()
: m.game_rooside_soda();
}
</script>
<LanguageSwitcher />
<div class="min-h-screen flex items-center justify-center bg-[#0a121f]">
<div class="flex flex-col items-center gap-16">
<!-- Title -->
<div
class="flex flex-col items-center text-center text-white font-['Comic_Sans_MS',cursive,sans-serif] font-bold italic capitalize"
>
<h1 class="text-[64px] max-md:text-[40px] m-0">{m.app_title()}</h1>
<p class="text-[48px] max-md:text-[28px] m-0">{m.app_version()}</p>
</div>
<!-- Games Grid -->
<div class="flex gap-8 flex-wrap justify-center">
{#each games as game}
<div class="flex flex-col items-center gap-4">
<span
class="font-['Comic_Sans_MS',cursive,sans-serif] font-bold italic text-2xl text-white capitalize"
>
{getGameName(game.nameKey)}
</span>
{#if game.available}
<a
href={game.href}
class="block w-[240px] h-[240px] max-md:w-[180px] max-md:h-[180px] border-8 border-[var(--kv-blue)] overflow-hidden transition-all duration-200 hover:scale-105 hover:border-[var(--kv-golden)]"
>
<img
src={game.image}
alt={getGameName(game.nameKey)}
class="w-full h-full object-cover"
/>
</a>
{:else}
<div
class="relative w-[240px] h-[240px] max-md:w-[180px] max-md:h-[180px] border-8 border-[var(--kv-blue)] overflow-hidden opacity-50"
>
<img
src={game.image}
alt={getGameName(game.nameKey)}
class="w-full h-full object-cover"
/>
<span
class="absolute inset-0 flex items-center justify-center bg-black/70 text-white font-['Comic_Sans_MS',cursive,sans-serif] font-bold text-2xl uppercase"
>
{m.coming_soon()}
</span>
</div>
{/if}
</div>
{/each}
</div>
</div>
</div>

@ -0,0 +1,22 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import { browser } from "$app/environment";
import { audioStore } from "$lib/stores/audio.svelte";
import { themeStore } from "$lib/stores/theme.svelte";
import type { Snippet } from "svelte";
let { children }: { children: Snippet } = $props();
onMount(() => {
if (browser) {
audioStore.initMusic("/audio/kuldvillak_teema.mp3");
themeStore.applyTheme();
}
});
onDestroy(() => {
// Don't destroy on navigation within kuldvillak - only when leaving
});
</script>
{@render children()}

@ -0,0 +1,106 @@
<script lang="ts">
import { Settings } from "$lib/components";
import { KvButtonPrimary, KvLogo } from "$lib/components/kuldvillak/ui";
import * as m from "$lib/paraglide/messages";
let settingsOpen = $state(false);
let fileInput: HTMLInputElement;
function openSettings() {
settingsOpen = true;
}
function handleLoadGame(event: Event) {
const file = (event.target as HTMLInputElement).files?.[0];
if (!file) return;
// Store file data and navigate to editor
const reader = new FileReader();
reader.onload = (e) => {
try {
const data = JSON.parse(e.target?.result as string);
localStorage.setItem(
"kuldvillak-editor-autosave",
JSON.stringify(data),
);
window.location.href = "/kuldvillak/edit";
} catch {
console.error("Invalid game file");
}
};
reader.readAsText(file);
}
</script>
<div
class="relative min-h-screen flex items-center justify-center overflow-hidden"
>
<!-- Background Grid -->
<div class="kv-grid-bg absolute inset-0 z-0"></div>
<!-- Content -->
<div class="relative z-10 flex flex-col items-center gap-8 md:gap-16 p-4">
<!-- Kuldvillak Logo -->
<KvLogo size="lg" class="md:h-48 md:max-w-[768px]" />
<!-- Menu Buttons -->
<div
class="flex flex-col gap-2 p-2 w-56 md:w-64 bg-kv-black border-4 border-kv-black"
>
<KvButtonPrimary
href="/kuldvillak/edit"
class="w-full !text-lg md:!text-2xl !py-3 md:!py-4"
>
{m.kv_new_game()}
</KvButtonPrimary>
<KvButtonPrimary
onclick={() => fileInput.click()}
class="w-full !text-lg md:!text-2xl !py-3 md:!py-4"
>
{m.kv_load_game()}
</KvButtonPrimary>
<input
type="file"
accept=".json"
class="hidden"
bind:this={fileInput}
onchange={handleLoadGame}
/>
<KvButtonPrimary
onclick={openSettings}
class="w-full !text-lg md:!text-2xl !py-3 md:!py-4"
>
{m.kv_settings()}
</KvButtonPrimary>
<KvButtonPrimary
href="/"
class="w-full !text-lg md:!text-2xl !py-3 md:!py-4"
>
{m.kv_exit()}
</KvButtonPrimary>
</div>
</div>
</div>
<Settings bind:open={settingsOpen} />
<style>
.kv-grid-bg {
background-color: var(--kv-blue);
background-image: linear-gradient(
color-mix(in srgb, var(--kv-background) 50%, transparent) 2px,
transparent 2px
),
linear-gradient(
90deg,
color-mix(in srgb, var(--kv-background) 50%, transparent) 2px,
transparent 2px
);
background-size: 60px 60px;
}
@media (max-width: 768px) {
.kv-grid-bg {
background-size: 40px 40px;
}
}
</style>

File diff suppressed because it is too large Load Diff

@ -0,0 +1,36 @@
<script lang="ts">
import { page } from "$app/stores";
import { gameSession } from "$lib/stores/gameSession.svelte";
import ProjectorView from "./ProjectorView.svelte";
import ModeratorView from "./ModeratorView.svelte";
import * as m from "$lib/paraglide/messages";
// Get view from URL query param
let view = $derived($page.url.searchParams.get("view") ?? "moderator");
</script>
{#if !gameSession.state}
<div class="h-screen w-screen flex items-center justify-center bg-kv-black">
<div class="text-center font-[family-name:var(--kv-font-button)]">
<div class="animate-pulse mb-4">
<div class="text-6xl">🎮</div>
</div>
<p class="text-kv-white text-2xl mb-4 uppercase">
{m.kv_play_loading()}
</p>
<p class="text-gray-400 text-sm mb-4">
{m.kv_play_loading_hint()}
</p>
<a
href="/kuldvillak/edit"
class="text-[var(--kv-golden)] underline uppercase"
>
{m.kv_play_go_to_editor()}
</a>
</div>
</div>
{:else if view === "projector"}
<ProjectorView />
{:else}
<ModeratorView />
{/if}

@ -0,0 +1,700 @@
<script lang="ts">
import { gameSession } from "$lib/stores/gameSession.svelte";
import { onMount } from "svelte";
import * as m from "$lib/paraglide/messages";
import {
KvButtonPrimary,
KvButtonSecondary,
KvPlayerCard,
KvNumberInput,
} 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";
// Only moderator controls the timer
onMount(() => {
gameSession.enableTimerControl();
});
function openProjector() {
window.open("/kuldvillak/play?view=projector", "kuldvillak-projector");
}
// Local state
let wagerInput = $state(0);
let scoreAdjustment = $state(100);
// Derived state
let session = $derived(gameSession.state);
let currentRound = $derived(gameSession.currentRound);
let questionData = $derived(gameSession.currentQuestionData);
// Calculate total questions in all rounds
let totalQuestions = $derived(() => {
if (!session) return 30;
return session.rounds.reduce((total, round) => {
return (
total +
round.categories.reduce((catTotal, cat) => {
return catTotal + cat.questions.length;
}, 0)
);
}, 0);
});
function selectQuestion(catIndex: number, qIndex: number) {
if (!session) return;
gameSession.selectQuestion(session.currentRoundIndex, catIndex, qIndex);
}
function markCorrect() {
if (!session?.activeTeamId) return;
gameSession.markCorrect(session.activeTeamId);
}
function markWrong() {
if (!session?.activeTeamId) return;
gameSession.markWrong(session.activeTeamId);
}
function skipQuestion() {
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);
}
</script>
{#if session}
<div class="min-h-screen bg-kv-black flex flex-col p-4 gap-4">
<!-- Header + Players Section -->
<section class="bg-kv-blue p-4">
<!-- Header Row -->
<div class="flex justify-between items-start mb-4">
<!-- Left: Game Info -->
<div>
<h1
class="font-kv-title text-kv-yellow text-5xl uppercase m-0 kv-shadow-text"
>
{session.name}
</h1>
<div
class="font-kv-body text-kv-yellow text-xl uppercase mt-2 kv-shadow-text"
>
{currentRound?.name || m.kv_play_round()} - {m.kv_play_round()}
{session.currentRoundIndex + 1}
</div>
</div>
<!-- Right: Action Buttons -->
<div class="flex flex-col gap-2 items-end">
<div class="flex gap-4">
<KvButtonPrimary
onclick={openProjector}
class="!py-3 !px-6 !text-xl"
>
{m.kv_play_open_projector()}
</KvButtonPrimary>
{#if session.settings.enableFinalRound && session.finalRound && session.phase === "board"}
<KvButtonPrimary
onclick={() => gameSession.goToFinalRound()}
class="!py-3 !px-6 !text-xl"
>
{m.kv_play_go_to_final()}
</KvButtonPrimary>
{/if}
<KvButtonSecondary
onclick={() => {
if (confirm(m.kv_play_end_game_confirm()))
gameSession.endGame();
}}
class="!py-3 !px-6 !text-xl"
>
{m.kv_play_end_game()}
</KvButtonSecondary>
</div>
<!-- Last answerer + Score adjustment -->
<div class="flex items-center gap-6">
{#if session.lastAnsweredTeamId}
{@const lastTeam = session.teams.find(
(t) => t.id === session.lastAnsweredTeamId,
)}
<span
class="font-kv-body text-xl text-kv-white uppercase kv-shadow-text"
>
{m.kv_play_last_answer()}:
<span
class={session.lastAnswerCorrect
? "text-kv-green"
: "text-kv-red"}
>
{lastTeam?.name}
{session.lastAnswerCorrect ? "✓" : "✗"}
</span>
</span>
{/if}
<div class="flex items-center gap-2">
<span
class="font-kv-body text-xl text-kv-white uppercase"
>{m.kv_play_adjust_by()}:</span
>
<KvNumberInput
bind:value={scoreAdjustment}
min={50}
step={50}
/>
</div>
</div>
</div>
</div>
<!-- Player Cards Row -->
<div class="grid grid-cols-6 gap-4">
{#each session.teams as team}
<KvPlayerCard
{team}
isActive={session.activeTeamId === team.id}
isAnswering={session.phase === "question" &&
session.activeTeamId === team.id}
{scoreAdjustment}
onadjust={(delta) =>
gameSession.adjustScore(team.id, delta)}
onclick={() =>
session.phase === "question" &&
!session.showAnswer &&
gameSession.setActiveTeam(team.id)}
disabled={isTeamWrong(team.id)}
/>
{/each}
</div>
</section>
{#if session.phase === "intro"}
<!-- Intro Phase Controls -->
<div
class="bg-kv-blue p-8 flex-1 flex flex-col items-center justify-center gap-6"
>
<div class="font-kv-title text-kv-yellow text-6xl uppercase">
KULDVILLAK
</div>
<div class="font-kv-body text-kv-white text-xl uppercase">
{m.kv_play_round()}
{session.currentRoundIndex + 1}
</div>
<div class="flex gap-4 mt-8">
<button
class="{btnBase} text-kv-yellow text-2xl px-8 py-4"
onclick={() => gameSession.startCategoryIntro()}
>
{m.kv_play_introduce_categories()}
</button>
<button
class="{btnBase} text-kv-white text-xl px-6 py-4"
onclick={() => gameSession.startBoard()}
>
{m.kv_play_skip_to_game()}
</button>
</div>
</div>
{:else if session.phase === "intro-categories"}
<!-- Category Introduction Progress -->
<div
class="bg-kv-blue p-8 flex-1 flex flex-col items-center justify-center gap-6"
>
<div class="font-kv-body text-kv-yellow text-3xl uppercase">
{m.kv_play_introducing_categories()}
</div>
<div class="font-kv-body text-kv-white text-xl">
{session.introCategoryIndex + 1} / {currentRound?.categories
.length ?? 0}
</div>
{#if currentRound?.categories[session.introCategoryIndex]}
<div
class="font-kv-body text-kv-white text-4xl uppercase mt-4"
>
{currentRound.categories[session.introCategoryIndex]
.name}
</div>
<button
class="{btnBase} text-kv-white/70 text-lg px-4 py-2 mt-4"
onclick={() => gameSession.startBoard()}
>
{m.kv_play_skip_to_game()}
</button>
{:else}
<div class="flex gap-4 mt-8">
<button
class="{btnBase} text-kv-yellow text-2xl px-8 py-4"
onclick={() => gameSession.startBoard()}
>
{m.kv_play_start_game()}
</button>
</div>
{/if}
</div>
{:else if session.phase === "board"}
<!-- Mini Game Board for selection (like edit page) -->
<div class="bg-kv-black p-0.5 flex-1 flex flex-col gap-0.5">
<!-- Category Headers Row - fixed height -->
<div class="grid grid-cols-6 gap-0.5">
{#each currentRound?.categories ?? [] as cat}
<div
class="bg-kv-blue font-kv-body text-kv-white text-center uppercase py-2 px-1 text-[clamp(8px,1.2vw,16px)] min-h-[40px] flex items-center justify-center"
style="text-shadow: var(--kv-shadow-category);"
>
{cat.name || "???"}
</div>
{/each}
</div>
<!-- Questions Grid -->
<div class="grid grid-cols-6 gap-0.5 flex-1">
{#each currentRound?.categories ?? [] as cat, ci}
<div class="flex flex-col gap-0.5 min-w-0">
{#each cat.questions as q, qi}
{@const result = gameSession.getQuestionResult(
ci,
qi,
)}
<button
class="bg-kv-blue border-none py-1 px-0.5 cursor-pointer flex-1 flex flex-col items-center justify-center relative hover:brightness-110 disabled:cursor-not-allowed {q.isRevealed
? 'opacity-70'
: ''} {q.isDailyDouble && !q.isRevealed
? 'ring-2 ring-inset ring-kv-yellow'
: ''}"
disabled={q.isRevealed}
onclick={() => selectQuestion(ci, qi)}
>
{#if q.isRevealed && result}
<!-- Show result stats -->
{@const team = result.teamId
? session.teams.find(
(t) => t.id === result.teamId,
)
: null}
<div
class="font-kv-body text-center text-[clamp(8px,1vw,14px)] leading-tight"
>
{#if result.pointsChange > 0}
<div class="text-kv-green">
+{result.pointsChange}
</div>
<div
class="text-kv-white/60 truncate max-w-full"
>
{team?.name}
</div>
{:else if result.pointsChange < 0}
<div class="text-kv-red">
{result.pointsChange}
</div>
<div
class="text-kv-white/60 truncate max-w-full"
>
{team?.name}
</div>
{:else}
<div class="text-kv-white/40">
</div>
{/if}
{#if result.isDailyDouble && result.wager}
<div
class="text-kv-yellow text-[clamp(6px,0.8vw,10px)]"
>
DD
</div>
{/if}
</div>
{:else}
<span
class="font-kv-body text-kv-yellow text-[clamp(10px,1.5vw,24px)]"
style="text-shadow: var(--kv-shadow-price);"
>
{q.points}
</span>
{/if}
</button>
{/each}
</div>
{/each}
</div>
</div>
{:else if session.phase === "daily-double"}
<!-- Daily Double Wager -->
<div
class="bg-kv-blue p-4 flex-1 flex flex-col items-center justify-center gap-4"
>
<h2 class="font-kv-body text-kv-yellow text-3xl uppercase">
{m.kv_play_daily_double()}!
</h2>
<div class="flex gap-2 flex-wrap justify-center">
{#each session.teams as team}
<button
class="{btnBase} text-xl px-6 py-3 {session.activeTeamId ===
team.id
? 'ring-2 ring-kv-yellow ' + 'text-kv-yellow'
: 'text-kv-white'}"
onclick={() => gameSession.setActiveTeam(team.id)}
>
{team.name} ({team.score}€)
</button>
{/each}
</div>
{#if session.activeTeamId}
{@const activeTeam = session.teams.find(
(t) => t.id === session.activeTeamId,
)}
<div class="flex items-center gap-4 mt-4">
<span
class="text-kv-white text-xl font-kv-body uppercase"
>{m.kv_play_wager()}:</span
>
<input
type="number"
class="bg-kv-blue border-2 border-black text-kv-white font-kv-body text-xl px-4 py-2 w-32 text-center"
min="5"
max={Math.max(
activeTeam?.score ?? 0,
questionData?.question.points ?? 500,
)}
bind:value={wagerInput}
/>
<button
class="{btnBase} text-kv-yellow text-xl px-6 py-3"
onclick={confirmDailyDoubleWager}
>
{m.kv_play_confirm()}
</button>
</div>
{/if}
</div>
{:else if session.phase === "question" && questionData}
<!-- Question View with Answer -->
<div class="bg-kv-blue p-4 flex-1 flex flex-col gap-4">
<div class="flex justify-between items-center">
<h2 class="font-kv-body text-kv-white text-2xl uppercase">
{questionData.category.name} - {questionData.question
.points}€
{#if session.dailyDoubleWager}
<span class="text-kv-yellow"
>(DD: {session.dailyDoubleWager}€)</span
>
{/if}
</h2>
<div class="flex items-center gap-4">
<span class="font-kv-body text-kv-white/70 text-lg">
{m.kv_play_question_number({
current: session.currentQuestionNumber,
total: totalQuestions(),
})}
</span>
{#if session.showAnswer}
<span
class="font-kv-body text-kv-yellow text-xl uppercase"
>{m.kv_play_showing_answer()}</span
>
{/if}
</div>
</div>
<!-- Question Status Tracking -->
<div class="flex gap-4 text-sm font-kv-body">
{#if session.wrongTeamIds.length > 0}
<span class="text-kv-red">
{session.wrongTeamIds
.map(
(id) =>
session.teams.find((t) => t.id === id)
?.name,
)
.join(", ")}
</span>
{/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}
<span class="text-kv-green">
{activeTeam?.name} +{points}
</span>
{:else if session.lastAnswerCorrect === false}
<span class="text-kv-red">
{activeTeam?.name} -{points}
</span>
{/if}
{/if}
</div>
<div class="bg-black/30 p-6 flex-1">
<div
class="text-kv-white text-2xl mb-6 font-kv-body uppercase"
>
<strong>{m.kv_play_question_short()}:</strong>
{questionData.question.question}
</div>
<div
class="text-kv-yellow text-3xl font-bold font-kv-body uppercase"
>
<strong>{m.kv_play_answer_short()}:</strong>
{questionData.question.answer}
</div>
</div>
<!-- Team Selection -->
<div class="flex gap-3 flex-wrap items-center">
<span class="text-kv-white text-xl font-kv-body uppercase"
>{m.kv_play_answering()}:</span
>
{#each session.teams as team}
{@const isWrong = isTeamWrong(team.id)}
<button
class="{btnBase} text-xl px-6 py-3 {isWrong
? 'opacity-30 line-through'
: ''} {session.activeTeamId === team.id
? 'ring-2 ring-kv-yellow ' + 'text-kv-yellow'
: 'text-kv-white'}"
onclick={() => gameSession.setActiveTeam(team.id)}
disabled={isWrong || session.showAnswer}
>
{team.name}
</button>
{/each}
</div>
<!-- Answer Controls -->
<div class="flex gap-4 justify-center">
<button
class="bg-kv-green border-2 border-black font-kv-body text-kv-white text-2xl px-10 py-4 cursor-pointer uppercase hover:opacity-80 disabled:opacity-50 disabled:cursor-not-allowed"
disabled={!session.activeTeamId || session.showAnswer}
onclick={markCorrect}
>
{m.kv_play_correct()}
</button>
<button
class="bg-kv-red border-2 border-black font-kv-body text-kv-white text-2xl px-10 py-4 cursor-pointer uppercase hover:opacity-80 disabled:opacity-50 disabled:cursor-not-allowed"
disabled={!session.activeTeamId || session.showAnswer}
onclick={markWrong}
>
{m.kv_play_wrong()}
</button>
<button
class="{btnBase} text-kv-white text-xl px-6 py-4"
onclick={skipQuestion}
disabled={session.showAnswer}
>
{m.kv_play_skip()}
</button>
</div>
<!-- Timer -->
<div
class="flex items-center gap-3 justify-center font-kv-body"
>
<span class="text-kv-yellow text-xl uppercase"
>{m.kv_play_timer()}</span
>
<span class="text-kv-white text-xl"
>{session.timerSeconds}s</span
>
<input
type="number"
class="bg-kv-blue border-2 border-black text-kv-white w-20 px-2 py-2 text-center text-xl"
min="5"
max="120"
bind:value={session.timerMax}
onchange={() =>
gameSession.setTimerMax(session?.timerMax ?? 10)}
/>
<span class="text-kv-white text-xl"
>{m.kv_play_seconds()}</span
>
{#if session.timerRunning}
<button
class="{btnBase} text-kv-red text-xl px-4 py-2"
onclick={() => gameSession.stopTimer()}
>{m.kv_play_stop()}</button
>
{:else}
<button
class="{btnBase} text-kv-green text-xl px-4 py-2"
onclick={() => gameSession.startTimer()}
>{m.kv_play_start()}</button
>
{/if}
<button
class="{btnBase} text-kv-white text-xl px-4 py-2"
onclick={() => gameSession.resetTimer()}
>{m.kv_play_reset()}</button
>
</div>
</div>
{:else if session.phase === "final-intro"}
<!-- Final Round Intro Controls -->
<div
class="bg-kv-blue p-8 flex-1 flex flex-col items-center justify-center gap-6"
>
<div
class="text-kv-yellow text-6xl uppercase"
style="font-family: var(--kv-font-title);"
>
{m.kv_play_final_round()}
</div>
{#if session.finalCategoryRevealed}
<!-- Category has been revealed, show question button -->
<div
class="font-kv-body text-kv-white text-2xl uppercase mt-4"
>
{session.finalRound?.category}
</div>
<div class="flex gap-4 mt-8">
<button
class="{btnBase} text-kv-yellow text-2xl px-8 py-4"
onclick={() => gameSession.showFinalQuestion()}
>
{m.kv_play_question_short()}
</button>
</div>
{:else}
<!-- Category not revealed yet -->
<div class="flex gap-4 mt-8">
<button
class="{btnBase} text-kv-yellow text-2xl px-8 py-4"
onclick={() =>
gameSession.startFinalCategoryReveal()}
>
{m.kv_play_reveal_category()}
</button>
</div>
{/if}
</div>
{:else if session.phase === "final-category"}
<!-- Final Category Animation in progress -->
<div
class="bg-kv-blue p-8 flex-1 flex flex-col items-center justify-center gap-6"
>
<div class="font-kv-body text-kv-yellow text-3xl uppercase">
{m.kv_play_introducing_categories()}
</div>
<div class="font-kv-body text-kv-white text-4xl uppercase mt-4">
{session.finalRound?.category}
</div>
</div>
{:else if session.phase === "final-question"}
<!-- Final Round Controls -->
<div class="bg-kv-blue p-4 flex-1 flex flex-col gap-4">
<h2 class="font-kv-body text-kv-yellow text-3xl uppercase">
{m.kv_play_final_round()}
</h2>
<div class="text-kv-white text-xl font-kv-body uppercase">
<strong>{m.kv_edit_category()}:</strong>
{session.finalRound?.category}
</div>
<div class="text-kv-white text-xl font-kv-body uppercase">
<strong>{m.kv_play_question_short()}:</strong>
{session.finalRound?.question}
</div>
<div class="text-kv-yellow text-2xl font-kv-body uppercase">
<strong>{m.kv_play_answer_short()}:</strong>
{session.finalRound?.answer}
</div>
<!-- Timer for final question -->
<div
class="flex items-center gap-3 justify-center font-kv-body"
>
<span class="text-kv-yellow text-xl uppercase"
>{m.kv_play_timer()}</span
>
<span class="text-kv-white text-xl"
>{session.timerSeconds}s</span
>
{#if session.timerRunning}
<button
class="{btnBase} text-kv-red text-xl px-4 py-2"
onclick={() => gameSession.stopTimer()}
>{m.kv_play_stop()}</button
>
{:else}
<button
class="{btnBase} text-kv-green text-xl px-4 py-2"
onclick={() => gameSession.startTimer()}
>{m.kv_play_start()}</button
>
{/if}
<button
class="{btnBase} text-kv-white text-xl px-4 py-2"
onclick={() => gameSession.resetTimer()}
>{m.kv_play_reset()}</button
>
</div>
<!-- Reveal Answer -->
<button
class="{btnBase} text-kv-yellow text-xl px-6 py-3"
onclick={() => gameSession.revealAnswer()}
disabled={session.showAnswer}
>
{m.kv_play_reveal_answer()}
</button>
<!-- Show Final Scores -->
<button
class="{btnBase} text-kv-white text-xl px-6 py-3"
onclick={() => gameSession.showFinalScores()}
>
{m.kv_play_show_scores()}
</button>
</div>
{:else if session.phase === "final-scores"}
<!-- Final Scores Display Controls -->
<div
class="bg-kv-blue p-4 flex-1 flex flex-col items-center justify-center gap-4"
>
<h2 class="font-kv-body text-kv-yellow text-3xl uppercase">
{m.kv_play_scores()}
</h2>
<button
class="{btnBase} text-kv-red text-xl px-6 py-3"
onclick={() => gameSession.endGame()}
>
{m.kv_play_end_game()}
</button>
</div>
{:else if session.phase === "finished"}
<div
class="bg-kv-blue p-4 flex-1 flex flex-col items-center justify-center gap-4"
>
<h2 class="font-kv-body text-kv-yellow text-3xl uppercase">
{m.kv_play_game_over()}!
</h2>
<a
href="/kuldvillak"
class="{btnBase} text-kv-yellow text-xl px-6 py-3"
>{m.kv_edit_back()}</a
>
</div>
{/if}
</div>
{/if}

@ -0,0 +1,715 @@
<script lang="ts">
import { gameSession } from "$lib/stores/gameSession.svelte";
import * as m from "$lib/paraglide/messages";
import { KvGameLogo } from "$lib/components/kuldvillak/ui";
// Derived state
let session = $derived(gameSession.state);
let currentRound = $derived(gameSession.currentRound);
let questionData = $derived(gameSession.currentQuestionData);
// Get round variant for logo display
let roundVariant = $derived.by(() => {
const roundIndex = session?.currentRoundIndex ?? 0;
if (roundIndex === 0) return "villak" as const;
if (roundIndex === 1) return "topeltvillak" as const;
return "hobevillak" as const;
});
// Animation state for question expand
let animationPhase = $state<"waiting" | "expanding" | "shown" | "none">(
"none",
);
let prevPhase = $state<string | null>(null);
// Intro category animation state (used for both regular and final round)
// Timing: 500ms dissolve ease-out, 1500ms shown, 500ms push left
let introAnimPhase = $state<
"villak" | "fade-in" | "shown" | "push-out" | "none"
>("none");
let prevIntroCatIndex = $state(-1);
let finalCategoryAnimStarted = $state(false);
// Game board reveal animation state
let boardRevealPhase = $state<"hidden" | "revealing" | "revealed">(
"hidden",
);
let revealedPrices = $state<Set<string>>(new Set());
let prevBoardPhase = $state<string | null>(null);
// Calculate starting position for the expanding overlay using actual DOM element
let questionGridEl = $state<HTMLElement | null>(null);
let startPosition = $derived(() => {
if (!session?.currentQuestion || !currentRound || !questionGridEl) {
return { left: 50, top: 50, width: 16, height: 20 };
}
const { categoryIndex, questionIndex } = session.currentQuestion;
const card = questionGridEl.querySelector(
`[data-cat="${categoryIndex}"][data-q="${questionIndex}"]`,
) as HTMLElement | null;
if (!card) {
return { left: 50, top: 50, width: 16, height: 20 };
}
const gridRect = questionGridEl.getBoundingClientRect();
const cardRect = card.getBoundingClientRect();
const left =
((cardRect.left - gridRect.left + cardRect.width / 2) /
gridRect.width) *
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;
return { left, top, width, height };
});
// Watch for phase changes to trigger animation
$effect(() => {
const currentPhase = session?.phase;
if (currentPhase === "question" && prevPhase !== "question") {
animationPhase = "waiting";
setTimeout(() => {
animationPhase = "expanding";
}, 100);
setTimeout(() => {
animationPhase = "shown";
}, 1100);
} else if (currentPhase !== "question") {
animationPhase = "none";
}
prevPhase = currentPhase ?? null;
});
// Watch for intro category changes to trigger animation sequence
// Timing: 500ms on villak, 500ms dissolve ease-out, 1500ms shown, 500ms push left linear
$effect(() => {
const catIndex = session?.introCategoryIndex ?? -1;
if (
session?.phase === "intro-categories" &&
catIndex >= 0 &&
catIndex !== prevIntroCatIndex
) {
// Start animation sequence for new category
introAnimPhase = "villak"; // Stay on Villak for 500ms
setTimeout(() => {
introAnimPhase = "fade-in"; // 500ms dissolve
}, 500); // Start fade after 500ms on villak
setTimeout(() => {
introAnimPhase = "shown";
}, 1000); // 500ms villak + 500ms fade
setTimeout(() => {
introAnimPhase = "push-out"; // 500ms push left
}, 2500); // 500ms villak + 500ms fade + 1500ms shown
setTimeout(() => {
introAnimPhase = "none";
gameSession.nextIntroCategory();
}, 3000); // Total: 500ms + 500ms + 1500ms + 500ms = 3000ms
} else if (
session?.phase !== "intro-categories" &&
session?.phase !== "final-category"
) {
introAnimPhase = "none";
}
prevIntroCatIndex = catIndex;
});
// Watch for board phase to trigger staggered price reveal (only once per round)
$effect(() => {
const currentPhase = session?.phase;
const alreadyRevealed = session?.boardRevealed ?? false;
if (
currentPhase === "board" &&
prevBoardPhase !== "board" &&
!alreadyRevealed
) {
// First time entering board - do the reveal animation
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);
} else if (currentPhase === "board" && alreadyRevealed) {
// Already revealed - show all prices immediately
boardRevealPhase = "revealed";
}
prevBoardPhase = currentPhase ?? null;
});
// Watch for final category phase to trigger the same animation
$effect(() => {
if (session?.phase === "final-category" && !finalCategoryAnimStarted) {
finalCategoryAnimStarted = true;
// Same timing as regular category intro
introAnimPhase = "villak";
setTimeout(() => {
introAnimPhase = "fade-in";
}, 500);
setTimeout(() => {
introAnimPhase = "shown";
}, 1000);
setTimeout(() => {
introAnimPhase = "push-out";
}, 2500);
setTimeout(() => {
introAnimPhase = "none";
gameSession.finishFinalCategoryReveal();
}, 3000);
} else if (session?.phase !== "final-category") {
finalCategoryAnimStarted = false;
}
});
// Helper to check if a price cell should be visible
function isPriceRevealed(ci: number, qi: number): boolean {
if (boardRevealPhase === "revealed") return true;
return revealedPrices.has(`${ci}-${qi}`);
}
</script>
{#if session}
<div
class="h-screen w-screen bg-kv-black flex flex-col overflow-hidden relative"
>
{#if session.phase === "intro"}
<!-- Home Screen with grid background - show Villak if categories introduced -->
<div class="flex-1 flex items-center justify-center intro-grid-bg">
{#if session.categoriesIntroduced}
<KvGameLogo
variant={roundVariant}
class="w-[60vw] max-w-[885px] h-auto drop-shadow-[6px_6px_4px_rgba(0,0,0,0.5)]"
/>
{:else}
<KvGameLogo
variant="kuldvillak"
class="w-[80vw] max-w-[1536px] h-auto drop-shadow-[6px_6px_4px_rgba(0,0,0,0.5)]"
/>
{/if}
</div>
{:else if session.phase === "intro-categories"}
<!-- Category Introduction Animation -->
{@const currentCat =
currentRound?.categories[session.introCategoryIndex]}
<div class="flex-1 relative overflow-hidden">
<!-- VILLAK background - always visible as base layer -->
<div
class="absolute inset-0 flex items-center justify-center intro-grid-bg"
>
<KvGameLogo
variant={roundVariant}
class="w-[60vw] max-w-[885px] h-auto drop-shadow-[6px_6px_4px_rgba(0,0,0,0.5)]"
/>
</div>
<!-- Category reveal - fades in on TOP of Villak, then pushed out -->
{#if currentCat}
<div
class="absolute inset-0 bg-kv-black p-8 intro-category {introAnimPhase}"
>
<div
class="w-full h-full flex items-center justify-center category-gradient-bg overflow-hidden p-2"
>
<div
class="font-kv-body text-kv-white text-[clamp(48px,12vw,192px)] uppercase text-center leading-normal kv-shadow-text"
>
{currentCat.name}
</div>
</div>
</div>
{/if}
<!-- Villak pusher - slides in from right to push category out -->
<div
class="absolute inset-0 flex items-center justify-center intro-grid-bg intro-pusher {introAnimPhase}"
>
<KvGameLogo
variant={roundVariant}
class="w-[60vw] max-w-[885px] h-auto drop-shadow-[6px_6px_4px_rgba(0,0,0,0.5)]"
/>
</div>
</div>
{:else if session.phase === "board" || session.phase === "question"}
<!-- Game Board - Full screen responsive grid -->
<div class="flex-1 flex flex-col p-4 lg:p-8 gap-4 h-full min-h-0">
<!-- Category Headers - show round name until board is revealed -->
<div
class="grid gap-2 lg:gap-4 shrink-0"
style="grid-template-columns: repeat({currentRound
?.categories.length ?? 6}, 1fr);"
>
{#each currentRound?.categories ?? [] as cat}
{@const roundName =
session.currentRoundIndex === 0
? "VILLAK"
: "TOPELTVILLAK"}
<div
class="bg-kv-blue font-kv-body text-kv-white text-center uppercase px-2 py-4 lg:px-4 lg:py-6 text-[clamp(12px,2.5vw,48px)] leading-tight flex items-center justify-center kv-shadow-text overflow-hidden"
>
{session.boardRevealed
? cat.name || "???"
: roundName}
</div>
{/each}
</div>
<!-- Question Grid - fills remaining space -->
<div
bind:this={questionGridEl}
class="flex-1 grid gap-2 lg:gap-4 min-h-0"
style="grid-template-columns: repeat({currentRound
?.categories.length ??
6}, 1fr); grid-template-rows: repeat({currentRound
?.categories[0]?.questions.length ?? 5}, 1fr);"
>
{#each currentRound?.categories ?? [] as cat, ci}
{#each cat.questions as q, qi}
<div
class="bg-kv-blue flex items-center justify-center question-card overflow-hidden"
style="grid-column: {ci + 1}; grid-row: {qi +
1};"
data-cat={ci}
data-q={qi}
>
<span
class="font-kv-price text-kv-yellow text-[clamp(24px,6vw,128px)] kv-shadow-price price-reveal
{q.isRevealed ? 'opacity-0' : ''}
{isPriceRevealed(ci, qi)
? 'revealed'
: ''}"
>
{q.points}<span class="text-[0.75em]"
>€</span
>
</span>
</div>
{/each}
{/each}
</div>
</div>
<!-- Question Overlay - Full screen over board -->
{#if session.phase === "question" && questionData && (animationPhase === "expanding" || animationPhase === "shown")}
<div
class="absolute inset-0 bg-kv-black p-8 flex flex-col gap-8 expand-overlay {animationPhase}"
>
<!-- Players row - top -->
<div
class="grid gap-4 h-32"
style="grid-template-columns: repeat({session.teams
.length}, 1fr);"
>
{#each session.teams as team}
<div
class="bg-kv-blue p-4 flex items-center justify-center overflow-hidden {session.activeTeamId ===
team.id
? 'ring-4 ring-kv-yellow'
: ''}"
>
<div
class="flex flex-col items-center font-kv-body text-kv-white uppercase text-center"
>
<span
class="text-[clamp(24px,2.5vw,36px)] kv-shadow-text"
>{team.name}</span
>
<span
class="text-[clamp(32px,3vw,48px)] kv-shadow-text {team.score <
0
? 'text-kv-red'
: ''}">{team.score}</span
>
</div>
</div>
{/each}
</div>
<!-- Question area - fills remaining space -->
<div
class="flex-1 flex items-center justify-center p-4 overflow-hidden"
>
{#if questionData.question.imageUrl}
<!-- Image question - show only image -->
<div
class="h-full max-w-full flex items-center justify-center"
>
<img
src={questionData.question.imageUrl}
alt="Question"
class="max-h-full max-w-full object-contain"
/>
</div>
{:else}
<!-- Text question -->
<div
class="font-kv-question text-center text-[clamp(48px,6vw,96px)] leading-normal uppercase"
>
{#if session.showAnswer}
<div class="text-kv-yellow kv-shadow-text">
{questionData.question.answer}
</div>
{:else}
<div class="text-kv-white">
{questionData.question.question}
</div>
{/if}
</div>
{/if}
</div>
</div>
{/if}
{:else if session.phase === "daily-double"}
<!-- Daily Double (Hõbevillak) Splash - Grid background with logo -->
<div
class="flex-1 flex flex-col items-center justify-center intro-grid-bg gap-8"
>
<KvGameLogo
variant="hobevillak"
class="w-[60vw] max-w-[885px] h-auto drop-shadow-[6px_6px_4px_rgba(0,0,0,0.5)] animate-pulse"
/>
{#if session.dailyDoubleWager}
<div
class="font-kv-body text-kv-white text-[clamp(32px,4vw,64px)] uppercase kv-shadow-text"
>
{m.kv_play_wager()}: {session.dailyDoubleWager}
</div>
{/if}
</div>
{:else if session.phase === "final-intro"}
<!-- Final Round Intro - KULDVILLAK screen -->
<div class="flex-1 flex items-center justify-center intro-grid-bg">
<KvGameLogo
variant="kuldvillak"
class="w-[80vw] max-w-[1536px] h-auto drop-shadow-[6px_6px_4px_rgba(0,0,0,0.5)]"
/>
</div>
{:else if session.phase === "final-category"}
<!-- Final Round Category - Animated reveal -->
<div class="flex-1 relative overflow-hidden">
<!-- KULDVILLAK background - always visible as base layer -->
<div
class="absolute inset-0 flex items-center justify-center intro-grid-bg"
>
<KvGameLogo
variant="kuldvillak"
class="w-[80vw] max-w-[1536px] h-auto drop-shadow-[6px_6px_4px_rgba(0,0,0,0.5)]"
/>
</div>
<!-- Final category reveal - fades in on TOP of KULDVILLAK, then pushed out -->
<div
class="absolute inset-0 bg-kv-black p-8 intro-category {introAnimPhase}"
>
<div
class="w-full h-full flex items-center justify-center category-gradient-bg overflow-hidden p-2"
>
<div
class="font-kv-body text-kv-white text-[clamp(48px,12vw,192px)] uppercase text-center leading-normal kv-shadow-text"
>
{session.finalRound?.category || "???"}
</div>
</div>
</div>
<!-- KULDVILLAK pusher - slides in from right to push category out -->
<div
class="absolute inset-0 flex items-center justify-center intro-grid-bg intro-pusher {introAnimPhase}"
>
<KvGameLogo
variant="kuldvillak"
class="w-[80vw] max-w-[1536px] h-auto drop-shadow-[6px_6px_4px_rgba(0,0,0,0.5)]"
/>
</div>
</div>
{:else if session.phase === "final-question"}
<!-- Final Round Question - Full screen question overlay -->
<div class="flex-1 flex flex-col p-8 gap-8">
<!-- Players row - top -->
<div
class="grid gap-4 h-32"
style="grid-template-columns: repeat({session.teams
.length}, 1fr);"
>
{#each session.teams as team}
<div
class="bg-kv-blue p-4 flex items-center justify-center overflow-hidden"
>
<div
class="flex flex-col items-center font-kv-body text-kv-white uppercase text-center"
>
<span
class="text-[clamp(24px,2.5vw,36px)] kv-shadow-text"
>{team.name}</span
>
<span
class="text-[clamp(32px,3vw,48px)] kv-shadow-text {team.score <
0
? 'text-kv-red'
: ''}">{team.score}</span
>
</div>
</div>
{/each}
</div>
<!-- Question area - fills remaining space -->
<div
class="flex-1 flex items-center justify-center p-4 overflow-hidden"
>
<div
class="font-kv-question text-center text-[clamp(48px,6vw,96px)] leading-normal uppercase"
>
{#if session.showAnswer}
<div class="text-kv-yellow kv-shadow-text">
{session.finalRound?.answer}
</div>
{:else}
<div class="text-kv-white">
{session.finalRound?.question}
</div>
{/if}
</div>
</div>
</div>
{:else if session.phase === "final-scores"}
<!-- Final Scores Display - Before ending game -->
<div
class="flex-1 flex flex-col p-[clamp(4px,0.5vw,8px)] gap-[clamp(8px,1vw,16px)]"
>
<div
class="bg-kv-blue flex-1 flex flex-col items-center justify-center"
>
<div
class="font-kv-body text-kv-yellow text-[clamp(48px,8vw,120px)] uppercase tracking-wide"
style="text-shadow: var(--kv-shadow-title);"
>
{m.kv_play_scores()}
</div>
</div>
<!-- Final Scoreboard -->
<div
class="grid gap-[clamp(4px,0.5vw,8px)]"
style="grid-template-columns: repeat({session.teams
.length}, 1fr);"
>
{#each gameSession.sortedTeams as team, i}
<div
class="bg-kv-blue px-4 py-3 text-center min-h-[clamp(80px,12vh,167px)] flex flex-col items-center justify-center {i ===
0
? 'ring-4 ring-kv-yellow'
: ''}"
>
<div
class="font-kv-body text-kv-white text-[clamp(14px,2vw,52px)] uppercase leading-tight"
style="text-shadow: var(--kv-shadow-category);"
>
#{i + 1}
{team.name}
</div>
<div
class="font-kv-body text-[clamp(18px,3vw,48px)] {i ===
0
? 'text-kv-yellow'
: 'text-kv-white'}"
style="text-shadow: var(--kv-shadow-price);"
>
{team.score}
</div>
</div>
{/each}
</div>
</div>
{:else if session.phase === "finished"}
<!-- Game Over / Results - Title font, winner highlighted -->
<div
class="flex-1 flex flex-col p-[clamp(4px,0.5vw,8px)] gap-[clamp(8px,1vw,16px)]"
>
<div
class="bg-kv-blue flex-1 flex flex-col items-center justify-center"
>
<div
class="font-[family-name:var(--kv-font-title)] text-kv-yellow text-[clamp(48px,10vw,160px)] uppercase tracking-wide mb-8"
style="text-shadow: var(--kv-shadow-title);"
>
{m.kv_play_game_over()}!
</div>
<!-- Winner announcement -->
{#if gameSession.sortedTeams[0]}
<div
class="font-kv-body text-kv-white text-[clamp(24px,4vw,64px)] uppercase"
>
🏆 {gameSession.sortedTeams[0].name} 🏆
</div>
{/if}
</div>
<!-- Final Scoreboard -->
<div
class="grid gap-[clamp(4px,0.5vw,8px)]"
style="grid-template-columns: repeat({session.teams
.length}, 1fr);"
>
{#each gameSession.sortedTeams as team, i}
<div
class="bg-kv-blue px-4 py-3 text-center min-h-[clamp(80px,12vh,167px)] flex flex-col items-center justify-center {i ===
0
? 'ring-4 ring-kv-yellow'
: ''}"
>
<div
class="font-kv-body text-kv-white text-[clamp(14px,2vw,52px)] uppercase leading-tight"
style="text-shadow: var(--kv-shadow-category);"
>
#{i + 1}
{team.name}
</div>
<div
class="font-kv-body text-[clamp(18px,3vw,48px)] {i ===
0
? 'text-kv-yellow'
: 'text-kv-white'}"
style="text-shadow: var(--kv-shadow-price);"
>
{team.score}
</div>
</div>
{/each}
</div>
</div>
{/if}
</div>
{/if}
<style>
/* Intro grid background - matches homepage pattern */
.intro-grid-bg {
background-color: var(--kv-blue);
background-image: linear-gradient(
color-mix(in srgb, var(--kv-background) 50%, transparent) 2px,
transparent 2px
),
linear-gradient(
90deg,
color-mix(in srgb, var(--kv-background) 50%, transparent) 2px,
transparent 2px
);
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;
z-index: 5;
}
.intro-category.villak {
opacity: 0;
}
.intro-category.fade-in {
animation: fade-in 500ms ease-out forwards;
}
.intro-category.shown {
opacity: 1;
}
.intro-category.push-out {
opacity: 1;
animation: slide-out-left 500ms linear forwards;
}
/* Intro pusher - Villak that slides in from right - 500ms linear */
.intro-pusher {
transform: translateX(100%);
z-index: 10;
}
.intro-pusher.villak,
.intro-pusher.fade-in,
.intro-pusher.shown {
transform: translateX(100%);
}
.intro-pusher.push-out {
animation: slide-in-from-right 500ms linear forwards;
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slide-in-from-right {
from {
transform: translateX(100%);
}
to {
transform: translateX(0);
}
}
@keyframes slide-out-left {
from {
transform: translateX(0);
}
to {
transform: translateX(-100%);
}
}
/* Price reveal animation - instant staggered appearance */
.price-reveal {
opacity: 0;
}
.price-reveal.revealed {
opacity: 1;
}
.price-reveal.opacity-0 {
opacity: 0 !important;
}
/* Question overlay animation - fade in */
.expand-overlay {
opacity: 0;
transition: opacity 500ms ease-out;
}
.expand-overlay.expanding,
.expand-overlay.shown {
opacity: 1;
}
</style>

@ -0,0 +1,130 @@
@import 'tailwindcss';
/* ============================================
Tailwind Theme Extensions for Kuldvillak
============================================ */
@theme {
/* Colors - Reference CSS variables for dynamic theming */
--color-kv-blue: var(--kv-blue);
--color-kv-yellow: var(--kv-yellow);
--color-kv-green: #009900;
--color-kv-red: #990000;
--color-kv-black: var(--kv-background);
--color-kv-white: var(--kv-text);
/* Additional theme-aware colors */
--color-kv-text: var(--kv-text);
--color-kv-background: var(--kv-background);
/* Fixed game board color - not affected by theme */
--color-kv-board: #003B9B;
/* Font Families */
--font-kv-title: 'Swiss 921', sans-serif;
--font-kv-body: 'Swiss 921', sans-serif;
--font-kv-price: 'Bebas Neue', sans-serif;
--font-kv-question: 'ITC Korinna', serif;
}
/* Text shadow utilities - Updated for new design */
.kv-shadow-text {
text-shadow: 6px 6px 4px rgba(0, 0, 0, 0.5);
}
.kv-shadow-title {
text-shadow: 6px 6px 4px rgba(0, 0, 0, 0.5);
}
.kv-shadow-price {
text-shadow: 8px 8px 4px rgba(0, 0, 0, 0.5), 0 4px 4px rgba(0, 0, 0, 0.25);
}
.kv-shadow-question {
text-shadow: 6px 6px 4px rgba(0, 0, 0, 0.5);
}
.kv-shadow-button {
box-shadow: 0 4px 4px rgba(0, 0, 0, 0.25), 8px 8px 4px rgba(0, 0, 0, 0.5);
}
/* Icon styling - uses secondary color */
.kv-icon {
color: var(--kv-yellow);
}
/* ============================================
Kuldvillak Custom Fonts
============================================ */
/* Bebas Neue for prices - local font */
@font-face {
font-family: 'Bebas Neue';
src: url('/fonts/BebasNeue-Regular.ttf') format('truetype');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Swiss 921';
src: url('/fonts/Swiss 921 Regular.otf') format('opentype');
font-weight: 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'ITC Korinna';
src: url('/fonts/ITC Korinna Std Bold.otf') format('opentype');
font-weight: 700;
font-style: normal;
font-display: swap;
}
/* ============================================
Kuldvillak Design Tokens (CSS Variables)
============================================ */
:root {
/* Colors - New Figma Design (these are overridden by theme.svelte.ts) */
--kv-blue: #003B9B;
--kv-yellow: #FFAB00;
--kv-text: #FFFFFF;
--kv-background: #000000;
--kv-green: #009900;
--kv-red: #990000;
--kv-black: #000000;
--kv-white: #FFFFFF;
/* Legacy alias for compatibility */
--kv-golden: var(--kv-yellow);
/* Fonts */
--kv-font-title: 'Swiss 921', sans-serif;
--kv-font-body: 'Swiss 921', sans-serif;
--kv-font-price: 'Bebas Neue', sans-serif;
--kv-font-question: 'ITC Korinna', serif;
/* Legacy alias */
--kv-font-button: var(--kv-font-body);
--kv-font-category: var(--kv-font-body);
/* Shadows - New Figma Design */
--kv-shadow-text: 6px 6px 4px rgba(0, 0, 0, 0.5);
--kv-shadow-title: 6px 6px 4px rgba(0, 0, 0, 0.5);
--kv-shadow-price: 8px 8px 4px rgba(0, 0, 0, 0.5), 0 4px 4px rgba(0, 0, 0, 0.25);
--kv-shadow-button: 0 4px 4px rgba(0, 0, 0, 0.25), 8px 8px 4px rgba(0, 0, 0, 0.5);
--kv-shadow-question: 6px 6px 4px rgba(0, 0, 0, 0.5);
--kv-shadow-category: 6px 6px 4px rgba(0, 0, 0, 0.5);
}
/* ============================================
Global Styles
============================================ */
html,
body {
height: 100%;
margin: 0;
padding: 0;
background-color: var(--kv-background);
}
body {
font-family: var(--kv-font-button);
color: var(--kv-text);
}

Binary file not shown.

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 25 KiB

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 36 36" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--twemoji" preserveAspectRatio="xMidYMid meet"><path fill="#00247D" d="M0 9.059V13h5.628zM4.664 31H13v-5.837zM23 25.164V31h8.335zM0 23v3.941L5.63 23zM31.337 5H23v5.837zM36 26.942V23h-5.631zM36 13V9.059L30.371 13zM13 5H4.664L13 10.837z"></path><path fill="#CF1B2B" d="M25.14 23l9.712 6.801a3.977 3.977 0 0 0 .99-1.749L28.627 23H25.14zM13 23h-2.141l-9.711 6.8c.521.53 1.189.909 1.938 1.085L13 23.943V23zm10-10h2.141l9.711-6.8a3.988 3.988 0 0 0-1.937-1.085L23 12.057V13zm-12.141 0L1.148 6.2a3.994 3.994 0 0 0-.991 1.749L7.372 13h3.487z"></path><path fill="#EEE" d="M36 21H21v10h2v-5.836L31.335 31H32a3.99 3.99 0 0 0 2.852-1.199L25.14 23h3.487l7.215 5.052c.093-.337.158-.686.158-1.052v-.058L30.369 23H36v-2zM0 21v2h5.63L0 26.941V27c0 1.091.439 2.078 1.148 2.8l9.711-6.8H13v.943l-9.914 6.941c.294.07.598.116.914.116h.664L13 25.163V31h2V21H0zM36 9a3.983 3.983 0 0 0-1.148-2.8L25.141 13H23v-.943l9.915-6.942A4.001 4.001 0 0 0 32 5h-.663L23 10.837V5h-2v10h15v-2h-5.629L36 9.059V9zM13 5v5.837L4.664 5H4a3.985 3.985 0 0 0-2.852 1.2l9.711 6.8H7.372L.157 7.949A3.968 3.968 0 0 0 0 9v.059L5.628 13H0v2h15V5h-2z"></path><path fill="#CF1B2B" d="M21 15V5h-6v10H0v6h15v10h6V21h15v-6z"></path></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 36 36" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--twemoji" preserveAspectRatio="xMidYMid meet"><path fill="#141414" d="M0 14h36v9H0z"></path><path fill="#4891D9" d="M32 5H4a4 4 0 0 0-4 4v5h36V9a4 4 0 0 0-4-4z"></path><path fill="#EEE" d="M32 31H4a4 4 0 0 1-4-4v-4h36v4a4 4 0 0 1-4 4z"></path></svg>

After

Width:  |  Height:  |  Size: 553 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

@ -0,0 +1,3 @@
# allow crawling everything by default
User-agent: *
Disallow:

@ -0,0 +1,15 @@
import adapter from '@sveltejs/adapter-auto';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
// Consult https://svelte.dev/docs/kit/integrations
// for more information about preprocessors
preprocess: vitePreprocess(),
kit: { // adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
adapter: adapter() }
};
export default config;

@ -0,0 +1,20 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"rewriteRelativeImportExtensions": true,
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
// To make changes to top-level options such as include and exclude, we recommend extending
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
}

@ -0,0 +1,15 @@
import { paraglideVitePlugin } from '@inlang/paraglide-js';
import tailwindcss from '@tailwindcss/vite';
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [
tailwindcss(),
sveltekit(),
paraglideVitePlugin({
project: './project.inlang',
outdir: './src/lib/paraglide'
})
]
});
Loading…
Cancel
Save