Added quality of life changes, improved animations, added basic tutorials (need to add more pages), MVP is robust and only needs sound effects and music pretty much, besides a lot of user testing.

master
AlacrisDevs 2 weeks ago
parent 0955d6ca65
commit 4e7ecb5397
  1. 17
      messages/en.json
  2. 17
      messages/et.json
  3. 2021
      package-lock.json
  4. 9
      package.json
  5. 8
      src/lib/assets/favicon-kuldvillak.svg
  6. 6
      src/lib/components/ConfirmDialog.svelte
  7. 104
      src/lib/components/ErrorBoundary.svelte
  8. 53
      src/lib/components/Settings.svelte
  9. 1
      src/lib/components/index.ts
  10. 22
      src/lib/components/kuldvillak/ui/KvButtonPrimary.svelte
  11. 26
      src/lib/components/kuldvillak/ui/KvButtonSecondary.svelte
  12. 6
      src/lib/components/kuldvillak/ui/KvGameLogo.svelte
  13. 36
      src/lib/components/kuldvillak/ui/KvSpinner.svelte
  14. 172
      src/lib/components/kuldvillak/ui/TutorialModal.svelte
  15. 6
      src/lib/components/kuldvillak/ui/index.ts
  16. 425
      src/lib/example_testing_game.json
  17. 20
      src/lib/stores/gameSession.svelte.ts
  18. 209
      src/lib/stores/persistence.test.ts
  19. 145
      src/lib/types/kuldvillak.test.ts
  20. 17
      src/routes/+layout.svelte
  21. 4
      src/routes/+page.svelte
  22. 5
      src/routes/kuldvillak/+layout.svelte
  23. 6
      src/routes/kuldvillak/+page.svelte
  24. 98
      src/routes/kuldvillak/edit/+page.svelte
  25. 67
      src/routes/kuldvillak/play/+page.svelte
  26. 103
      src/routes/kuldvillak/play/ModeratorView.svelte
  27. 305
      src/routes/kuldvillak/play/ProjectorView.svelte
  28. 3
      static/kuldvillak_favicon.svg
  29. 0
      static/tutorials/en/.gitkeep
  30. BIN
      static/tutorials/en/howto-1.png
  31. BIN
      static/tutorials/en/howto-2.png
  32. BIN
      static/tutorials/en/howto-3.png
  33. BIN
      static/tutorials/en/howto-4.png
  34. BIN
      static/tutorials/en/howto-5.png
  35. BIN
      static/tutorials/en/howto-6.png
  36. BIN
      static/tutorials/en/howto-7.png
  37. 0
      static/tutorials/et/.gitkeep
  38. BIN
      static/tutorials/et/howto-1.png
  39. BIN
      static/tutorials/et/howto-2.png
  40. BIN
      static/tutorials/et/howto-3.png
  41. BIN
      static/tutorials/et/howto-4.png
  42. BIN
      static/tutorials/et/howto-5.png
  43. BIN
      static/tutorials/et/howto-6.png
  44. BIN
      static/tutorials/et/howto-7.png
  45. 13
      vitest.config.ts

@ -17,7 +17,7 @@
"kv_settings_close": "Close", "kv_settings_close": "Close",
"kv_error_404": "404", "kv_error_404": "404",
"kv_error_not_found": "Not Found", "kv_error_not_found": "Not Found",
"kv_error_hint": "(pssst... make sure you're on the right page)", "kv_error_hint": "(pssst... have you tried restarting your computer?)",
"kv_edit_title": "Game Editor", "kv_edit_title": "Game Editor",
"kv_edit_back": "Back", "kv_edit_back": "Back",
"kv_edit_game_name": "Game name...", "kv_edit_game_name": "Game name...",
@ -166,5 +166,18 @@
"kv_confirm_close_message": "Are you sure you want to close? Any unsaved changes will be lost.", "kv_confirm_close_message": "Are you sure you want to close? Any unsaved changes will be lost.",
"kv_confirm_discard": "Discard", "kv_confirm_discard": "Discard",
"kv_confirm_cancel": "Cancel", "kv_confirm_cancel": "Cancel",
"kv_final_round": "Final Round" "kv_final_round": "Final Round",
"kv_tutorial_rules_placeholder": "Placeholder: Add your rules explanation here",
"kv_tutorial_howto_1": "Hello! Let's do a quick introduction on how you can start playing Jeopardy.\nI'll try to be as detailed as possible to cover potential questions, but hopefully the platform is simple enough that not much explanation is needed.\nYou can come back here at any time.",
"kv_tutorial_howto_2": "This is your game creation screen.\nHere you can set the game length, points, additional rules, players, questions, and much more that goes into organizing a Jeopardy game.",
"kv_tutorial_howto_3": "Back arrow - back to home page\nGame name - you can name your game (changes file name)\nOpen game - open a previously created game\nSave game - save the current game as a file to your computer for later use\nreset - reset current work\nsettings - all necessary settings from sound to colors. Go try it!\nStart - starts the game",
"kv_tutorial_howto_4": "Rounds - either 1 round (Jeopardy) or 2 rounds (Jeopardy + Double Jeopardy). 2 rounds is the default setting\nTime to answer - set how long players can answer in seconds. 5 seconds is the default setting\nAnswer reveal - set how long the game shows the answer on screen. 5 seconds is the default setting.\nFinal round - edit the final round question. You can choose whether the final round comes at the end of the game or not.\n\nPoints - normal is 10-50, double jeopardy 20-100. custom lets you set jeopardy round points that double in double jeopardy.\nNegative points - set whether player scores can go negative. negative points are the default setting.\nplayers - add players. player count is 2-6.",
"kv_tutorial_howto_5": "Each round has 6 categories and each category has 5 questions. All categories and questions must be filled to start the game.\nTo edit, click on the category or question tile.\n\nJeopardy round has 1 Daily Double, Double Jeopardy round has 2 Daily Doubles. For each question, you can choose whether it's a Daily Double or not.\nYou can find the Daily Double explanation in the Jeopardy rules.",
"kv_tutorial_howto_6": "For each question, you can see the category and how many points it's worth.\nTo complete a question, you need to fill in the question and answer.\nYou can also add an image to the question, which means that when showing the question, only the image will appear on screen, not the question text. The game host can read the question from their own screen.\n\nAdditionally, you can set whether the question is a Daily Double or not and see how many Daily Doubles are already set in that round.",
"kv_tutorial_howto_7": "Music - change the music volume.\nSound effects - change the sound effects volume.\nLanguage - currently Estonian and English are supported.\n\nDon't like the standard Jeopardy blue-gold style? You can change every color as much as you want and the whole game will share your style! You won't know unless you try!",
"error_title": "Something went wrong",
"error_description": "An unexpected error occurred. Please try again.",
"error_details": "Technical details",
"error_retry": "Try Again",
"error_go_home": "Go Home"
} }

@ -17,7 +17,7 @@
"kv_settings_close": "Välju", "kv_settings_close": "Välju",
"kv_error_404": "404", "kv_error_404": "404",
"kv_error_not_found": "Lehte ei leitud", "kv_error_not_found": "Lehte ei leitud",
"kv_error_hint": "(pssst... vaata, et oled ikka õigel lehel)", "kv_error_hint": "(pssst... oled proovinud arvuti taaskäivitamist?)",
"kv_edit_title": "Mängu redaktor", "kv_edit_title": "Mängu redaktor",
"kv_edit_back": "Tagasi", "kv_edit_back": "Tagasi",
"kv_edit_game_name": "Mängu nimi...", "kv_edit_game_name": "Mängu nimi...",
@ -166,5 +166,18 @@
"kv_confirm_close_message": "Kas oled kindel, et soovid sulgeda? Salvestamata muudatused lähevad kaotsi.", "kv_confirm_close_message": "Kas oled kindel, et soovid sulgeda? Salvestamata muudatused lähevad kaotsi.",
"kv_confirm_discard": "Loobu", "kv_confirm_discard": "Loobu",
"kv_confirm_cancel": "Tühista", "kv_confirm_cancel": "Tühista",
"kv_final_round": "Kuldvillak" "kv_final_round": "Kuldvillak",
"kv_tutorial_rules_placeholder": "Platvormikoht: Lisa siia reeglite seletus",
"kv_tutorial_howto_1": "Tervist! Teeme kerge sissejuhatuse, kuidas sa saad hakata kuldvillakut mängima.\nProovin olla võimalikult detailne, et katta võimalikud küsimused ära, aga loodetavasti on platvormi kasutada piisavalt lihtne, et palju seletust pole vaja.\nSul on võimalik tulla siia tagasi igal ajal.",
"kv_tutorial_howto_2": "See on sinu mänguloomise ekraan.\nSiin on sul võimalik seada mängu pikkust, punkte, lisareegleid, mängijaid, küsimusi ja palju muud, mis läheb ühe kuldvillaku korraldamisse.",
"kv_tutorial_howto_3": "Tagasi nool - tagasi kodulehele\nMängu nimi - saad panna enda mängule nime (muudab failinime)\nAva mäng - ava varasemalt tehtud mäng\nSalvesta mäng - salvesta praegune mäng failina arvutisse hiljem kasutamiseks\nreset - lähtesta senine töö\nseaded - kõik vajalikud seaded helist kuni värvideni välja. Mine proovi!\nAlusta - alustab mängu",
"kv_tutorial_howto_4": "voorude arv - kas 1 voor (villak) või 2 vooru (villak + Topeltvillak). 2 vooru on originaalne formaat\nVastamisaeg - määra, kui kaua mängijad vastata saavad sekundites. 5 sekundit on originaalne formaat\nVastuse näitamine - määra, kui kaua mäng näitab vastust ekraanil. 5 sekundit on originaalne formaat.\nFinaalvoor - muuda finaalvooru küsimust. Saad valida, kas finaalvoor tuleb mängu lõpus või ei.\n\nPunktid - tavaline on 10-50, topeltvillakus 20-100. kohandatud laseb sul panna villaku vooru punktid, mis duubelduvad topeltvillakus.\nNegatiivsed punktid - määra, kas mängijate skoorid saavad minna negatiivseks. negatiivsed punktid on sees originaalses formaadis.\nmängijad - lisa mängijad. mängijate arv on 2-6.",
"kv_tutorial_howto_5": "Igas voorus on 6 kategooriat ja igas kategoorias on 5 küsimust. Kõik kategooriad ja küsimused peavad olema täidetud selleks, et mängu alustada.\nMuutmiseks kliki kategooria või küsimuse ruudu peale.\n\nVillaku voorus on 1 hõbevillak, topeltvillaku voorus 2 hõbevillakut. Iga küsimuse juures on võimalik valida, kas küsimus on hõbevillak või mitte.\nHõbevillaku seletuse leiad kuldvillaku reeglitest.",
"kv_tutorial_howto_6": "Iga küsimuse juures on võimalik näha kategooriat ja mitu punkti saab.\nSelleks, et küsimus oleks täidetud, on vaja panna küsimus ja vastus kirja.\nKüsimuse juurde on võimalik panna ka pilt, mis tähendab seda, et küsimuse näitamisel tuleb ekraanile ainult pilt, mitte küsimus. Mängu läbiviijal on võimalik küsimust oma ekraanilt lugeda.\n\nLisaks on võimalik määrata, kas küsimus on hõbevillak või mitte ja näha, mitu hõbevillakut selles voorus juba on määratud.",
"kv_tutorial_howto_7": "Muusika - muuda muusika helitugevust.\nHeliefektid - muuda heliefektide helitugevust.\nKeel - praegu on toetatud eesti ja inglise keel.\n\nEi meeldi tavaline kuldvillaku sinine-koldne stiil? Võid muuta igat värvi nii palju kui tahad ja kogu mäng jagab sinu stiili! Muudmoodi teada ei saa, kui ei proovi!",
"error_title": "Midagi läks valesti",
"error_description": "Tekkis ootamatu viga. Palun proovi uuesti.",
"error_details": "Tehnilised detailid",
"error_retry": "Proovi uuesti",
"error_go_home": "Mine avalehele"
} }

2021
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -9,7 +9,10 @@
"preview": "vite preview", "preview": "vite preview",
"prepare": "svelte-kit sync || echo ''", "prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage"
}, },
"devDependencies": { "devDependencies": {
"@inlang/paraglide-js": "^2.5.0", "@inlang/paraglide-js": "^2.5.0",
@ -17,10 +20,12 @@
"@sveltejs/kit": "^2.48.5", "@sveltejs/kit": "^2.48.5",
"@sveltejs/vite-plugin-svelte": "^6.2.1", "@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tailwindcss/vite": "^4.1.17", "@tailwindcss/vite": "^4.1.17",
"jsdom": "^27.2.0",
"svelte": "^5.43.8", "svelte": "^5.43.8",
"svelte-check": "^4.3.4", "svelte-check": "^4.3.4",
"tailwindcss": "^4.1.17", "tailwindcss": "^4.1.17",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vite": "^7.2.2" "vite": "^7.2.2",
"vitest": "^2.1.0"
} }
} }

@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<rect width="128" height="128" fill="#060ce9"/>
<path
fill="#ffcc00"
d="M24 16L27.3 27V110L24 121H62L58.7 110V68.2L98 121H136L83.3 53.5L131.6 0H93.7L58.7 45V27L62 16H24Z"
transform="translate(-8, 4) scale(0.9)"
/>
</svg>

After

Width:  |  Height:  |  Size: 302 B

@ -41,7 +41,7 @@
{#if open} {#if open}
<!-- Backdrop --> <!-- Backdrop -->
<div <div
class="fixed inset-0 bg-kv-background/70 z-[100]" class="fixed inset-0 bg-kv-background/50 z-[100]"
onclick={handleCancel} onclick={handleCancel}
role="button" role="button"
tabindex="-1" tabindex="-1"
@ -60,7 +60,9 @@
> >
{title} {title}
</h3> </h3>
<p class="text-sm md:text-base text-kv-white font-kv-body"> <p
class="text-sm md:text-base text-kv-white font-kv-body uppercase kv-shadow-text"
>
{message} {message}
</p> </p>
<div class="flex gap-3 w-full"> <div class="flex gap-3 w-full">

@ -0,0 +1,104 @@
<script lang="ts">
import { onMount } from "svelte";
import type { Snippet } from "svelte";
import * as m from "$lib/paraglide/messages";
interface Props {
children: Snippet;
fallback?: Snippet<[Error]>;
}
let { children, fallback }: Props = $props();
let error = $state<Error | null>(null);
let hasError = $state(false);
onMount(() => {
// Catch unhandled errors
const handleError = (event: ErrorEvent) => {
console.error("ErrorBoundary caught:", event.error);
error = event.error;
hasError = true;
event.preventDefault();
};
// Catch unhandled promise rejections
const handleRejection = (event: PromiseRejectionEvent) => {
console.error("ErrorBoundary caught rejection:", event.reason);
error =
event.reason instanceof Error
? event.reason
: new Error(String(event.reason));
hasError = true;
event.preventDefault();
};
window.addEventListener("error", handleError);
window.addEventListener("unhandledrejection", handleRejection);
return () => {
window.removeEventListener("error", handleError);
window.removeEventListener("unhandledrejection", handleRejection);
};
});
function retry() {
hasError = false;
error = null;
}
function goHome() {
window.location.href = "/";
}
</script>
{#if hasError && error}
{#if fallback}
{@render fallback(error)}
{:else}
<div
class="min-h-screen flex items-center justify-center bg-gray-900 p-4"
role="alert"
aria-live="assertive"
>
<div class="bg-gray-800 rounded-lg p-8 max-w-md w-full text-center">
<div class="text-red-500 text-6xl mb-4" aria-hidden="true">
</div>
<h1 class="text-white text-2xl font-bold mb-2">
{m.error_title?.() ?? "Something went wrong"}
</h1>
<p class="text-gray-400 mb-4">
{m.error_description?.() ??
"An unexpected error occurred. Please try again."}
</p>
<details class="text-left mb-4">
<summary
class="text-gray-500 cursor-pointer hover:text-gray-400"
>
{m.error_details?.() ?? "Technical details"}
</summary>
<pre
class="mt-2 p-2 bg-gray-900 rounded text-red-400 text-xs overflow-auto max-h-32">{error.message}
{error.stack}</pre>
</details>
<div class="flex gap-4 justify-center">
<button
onclick={retry}
class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
>
{m.error_retry?.() ?? "Try Again"}
</button>
<button
onclick={goHome}
class="px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700 transition-colors"
>
{m.error_go_home?.() ?? "Go Home"}
</button>
</div>
</div>
</div>
{/if}
{:else}
{@render children()}
{/if}

@ -149,30 +149,59 @@
</span> </span>
<div class="flex gap-4"> <div class="flex gap-4">
<button <button
class="w-8 h-8 cursor-pointer transition-all overflow-hidden {getLocale() === class="w-8 h-8 cursor-pointer transition-all overflow-hidden bg-transparent border-none p-0 {getLocale() ===
'et' 'et'
? 'opacity-100' ? 'opacity-100'
: 'opacity-60 hover:opacity-80'}" : 'opacity-60 hover:opacity-80'}"
onclick={() => switchLanguage("et")} onclick={() => switchLanguage("et")}
aria-label="Eesti"
> >
<img <svg
src="/icons/et.svg" viewBox="0 0 36 36"
alt="Eesti" class="w-full h-full"
class="w-full h-full object-cover" aria-hidden="true"
/> >
<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>
</button> </button>
<button <button
class="w-8 h-8 cursor-pointer transition-all overflow-hidden {getLocale() === class="w-8 h-8 cursor-pointer transition-all overflow-hidden bg-transparent border-none p-0 {getLocale() ===
'en' 'en'
? 'opacity-100' ? 'opacity-100'
: 'opacity-60 hover:opacity-80'}" : 'opacity-60 hover:opacity-80'}"
onclick={() => switchLanguage("en")} onclick={() => switchLanguage("en")}
aria-label="English"
> >
<img <svg
src="/icons/en.svg" viewBox="0 0 36 36"
alt="English" class="w-full h-full"
class="w-full h-full object-cover" aria-hidden="true"
/> >
<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>
</button> </button>
</div> </div>
</div> </div>

@ -5,6 +5,7 @@ export { default as LanguageSwitcher } from './LanguageSwitcher.svelte';
export { default as Toast } from './Toast.svelte'; export { default as Toast } from './Toast.svelte';
export { default as ConfirmDialog } from './ConfirmDialog.svelte'; export { default as ConfirmDialog } from './ConfirmDialog.svelte';
export { default as ColorPicker } from './ColorPicker.svelte'; export { default as ColorPicker } from './ColorPicker.svelte';
export { default as ErrorBoundary } from './ErrorBoundary.svelte';
// Kuldvillak Components // Kuldvillak Components
export * from './kuldvillak'; export * from './kuldvillak';

@ -7,6 +7,8 @@
onclick?: () => void; onclick?: () => void;
children: Snippet; children: Snippet;
class?: string; class?: string;
reload?: boolean;
ariaLabel?: string;
} }
let { let {
@ -15,18 +17,32 @@
onclick, onclick,
children, children,
class: className = "", class: className = "",
reload = false,
ariaLabel,
}: Props = $props(); }: Props = $props();
const baseClasses = const baseClasses =
"inline-flex items-center justify-center px-6 py-4 bg-kv-blue text-kv-white kv-btn-text cursor-pointer transition-opacity hover:opacity-80 disabled:opacity-50 disabled:cursor-not-allowed kv-shadow-button border-4 border-black box-border"; "inline-flex items-center justify-center px-6 py-4 bg-kv-blue text-kv-white kv-btn-text cursor-pointer transition-opacity hover:opacity-80 disabled:opacity-50 disabled:cursor-not-allowed kv-shadow-button border-4 border-black box-border focus:outline-none focus:ring-2 focus:ring-kv-yellow focus:ring-offset-2 focus:ring-offset-black";
</script> </script>
{#if href && !disabled} {#if href && !disabled}
<a {href} class="{baseClasses} {className}"> <a
{href}
class="{baseClasses} {className}"
data-sveltekit-reload={reload ? "" : undefined}
aria-label={ariaLabel}
role="button"
>
<span class="kv-shadow-text">{@render children()}</span> <span class="kv-shadow-text">{@render children()}</span>
</a> </a>
{:else} {:else}
<button class="{baseClasses} {className}" {disabled} {onclick}> <button
class="{baseClasses} {className}"
{disabled}
{onclick}
aria-label={ariaLabel}
aria-disabled={disabled}
>
<span class="kv-shadow-text">{@render children()}</span> <span class="kv-shadow-text">{@render children()}</span>
</button> </button>
{/if} {/if}

@ -7,6 +7,8 @@
onclick?: () => void; onclick?: () => void;
children: Snippet; children: Snippet;
class?: string; class?: string;
reload?: boolean;
ariaLabel?: string;
} }
let { let {
@ -15,18 +17,32 @@
onclick, onclick,
children, children,
class: className = "", class: className = "",
reload = false,
ariaLabel,
}: Props = $props(); }: Props = $props();
const baseClasses = const baseClasses =
"inline-flex items-center justify-center px-6 py-4 bg-kv-yellow text-black kv-btn-text cursor-pointer transition-opacity hover:opacity-80 disabled:opacity-50 disabled:cursor-not-allowed kv-shadow-button border-4 border-black box-border"; "inline-flex items-center justify-center px-6 py-4 bg-kv-yellow text-black kv-btn-text cursor-pointer transition-opacity hover:opacity-80 disabled:opacity-50 disabled:cursor-not-allowed kv-shadow-button border-4 border-black box-border focus:outline-none focus:ring-2 focus:ring-kv-blue focus:ring-offset-2 focus:ring-offset-black";
</script> </script>
{#if href && !disabled} {#if href && !disabled}
<a {href} class="{baseClasses} {className}"> <a
{@render children()} {href}
class="{baseClasses} {className}"
data-sveltekit-reload={reload ? "" : undefined}
aria-label={ariaLabel}
role="button"
>
<span class="kv-shadow-text">{@render children()}</span>
</a> </a>
{:else} {:else}
<button class="{baseClasses} {className}" {disabled} {onclick}> <button
{@render children()} class="{baseClasses} {className}"
{disabled}
{onclick}
aria-label={ariaLabel}
aria-disabled={disabled}
>
<span class="kv-shadow-text">{@render children()}</span>
</button> </button>
{/if} {/if}

@ -90,12 +90,14 @@
{:else} {:else}
<!-- VILLAK / TOPELTVILLAK / HÕBEVILLAK - Generic logo --> <!-- VILLAK / TOPELTVILLAK / HÕBEVILLAK - Generic logo -->
<svg <svg
class="text-kv-yellow {sizeClasses[size]} {className}" class="{variant === 'hobevillak'
? 'text-[#c0c0c0]'
: 'text-kv-yellow'} {sizeClasses[size]} {className}"
viewBox="0 0 591 128" viewBox="0 0 591 128"
fill="currentColor" fill="currentColor"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
role="img" role="img"
aria-label="Villak" aria-label={variant === "hobevillak" ? "Hõbevillak" : "Villak"}
> >
<path <path
fill-rule="evenodd" fill-rule="evenodd"

@ -0,0 +1,36 @@
<script lang="ts">
interface Props {
class?: string;
}
let { class: className = "" }: Props = $props();
</script>
<svg
class="text-kv-yellow {className}"
viewBox="0 0 128 128"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M8.05957 1.5L11.3781 12.5V115.5L8.05957 126.5H45.9855L42.667 115.5V73.6719L82.0151 126.5H119.941L67.3188 59L115.674 1.50006H77.7485L42.667 50.5001V12.5L45.9855 1.50006L8.05957 1.5Z"
/>
</svg>
<style>
svg {
animation: spin-k 2s cubic-bezier(0.67, -0.42, 0.1, 1.29) infinite;
filter: drop-shadow(6px 6px 4px rgba(0, 0, 0, 0.5));
}
@keyframes spin-k {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>

@ -0,0 +1,172 @@
<script lang="ts">
import * as m from "$lib/paraglide/messages";
export interface TutorialSlide {
image: string;
text: string;
}
interface Props {
open: boolean;
title: string;
slides: TutorialSlide[];
onclose?: () => void;
}
let { open = $bindable(), title, slides, onclose }: Props = $props();
let currentIndex = $state(0);
function close() {
open = false;
currentIndex = 0;
onclose?.();
}
function prev() {
if (currentIndex > 0) {
currentIndex--;
}
}
function next() {
if (currentIndex < slides.length - 1) {
currentIndex++;
}
}
function handleKeydown(e: KeyboardEvent) {
if (!open) return;
if (e.key === "Escape") close();
if (e.key === "ArrowLeft") prev();
if (e.key === "ArrowRight") next();
}
// Reset index when modal opens
$effect(() => {
if (open) {
currentIndex = 0;
}
});
</script>
<svelte:window onkeydown={handleKeydown} />
{#if open}
<!-- Backdrop -->
<div
class="fixed inset-0 bg-kv-background/50 z-50 flex items-center justify-center p-4"
onclick={close}
onkeydown={(e) => e.key === "Enter" && close()}
role="button"
tabindex="-1"
aria-label="Close modal"
>
<!-- Modal -->
<div
class="bg-kv-blue border-[16px] border-kv-black w-full max-w-5xl max-h-[90vh] flex flex-col gap-6 md:gap-8 p-4 md:p-8 overflow-y-auto"
role="dialog"
aria-modal="true"
tabindex="-1"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
>
<!-- Header -->
<div class="flex items-start justify-between gap-4">
<h2
class="font-kv-body text-xl md:text-3xl text-kv-white uppercase kv-shadow-text"
>
{title}
</h2>
<button
class="text-kv-yellow hover:opacity-80 transition-opacity cursor-pointer bg-transparent border-none p-0"
onclick={close}
aria-label="Close"
>
<svg
class="w-6 h-6 md:w-8 md:h-8"
viewBox="0 0 24 24"
fill="currentColor"
>
<path
d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"
/>
</svg>
</button>
</div>
<!-- Image Carousel -->
{#if slides.length > 0}
<div class="flex items-center gap-2 md:gap-4">
<!-- Previous Button -->
<button
class="text-kv-yellow hover:opacity-80 transition-opacity cursor-pointer bg-transparent border-none p-0 disabled:opacity-30 disabled:cursor-not-allowed shrink-0"
onclick={prev}
disabled={currentIndex === 0}
aria-label="Previous"
>
<svg
class="w-8 h-8 md:w-12 md:h-12"
viewBox="0 0 48 48"
fill="currentColor"
>
<path
d="M29.5334 40L13.5334 24L29.5334 8L33.2668 11.7333L21.0001 24L33.2668 36.2667L29.5334 40Z"
/>
</svg>
</button>
<!-- Image -->
<div
class="flex-1 aspect-video bg-kv-black/30 overflow-hidden"
>
<img
src={slides[currentIndex].image}
alt="Tutorial step {currentIndex + 1}"
class="w-full h-full object-contain"
/>
</div>
<!-- Next Button -->
<button
class="text-kv-yellow hover:opacity-80 transition-opacity cursor-pointer bg-transparent border-none p-0 disabled:opacity-30 disabled:cursor-not-allowed shrink-0"
onclick={next}
disabled={currentIndex === slides.length - 1}
aria-label="Next"
>
<svg
class="w-8 h-8 md:w-12 md:h-12"
viewBox="0 0 48 48"
fill="currentColor"
>
<path
d="M18.4666 8L34.4666 24L18.4666 40L14.7332 36.2667L26.9999 24L14.7332 11.7333L18.4666 8Z"
/>
</svg>
</button>
</div>
<!-- Text Description -->
<p
class="font-kv-body text-sm md:text-base text-kv-white uppercase kv-shadow-text text-left whitespace-pre-line"
>
{slides[currentIndex].text}
</p>
<!-- Page Indicator -->
<div class="flex justify-center gap-2">
{#each slides as _, i}
<button
class="w-3 h-3 rounded-full border-2 border-kv-yellow transition-colors cursor-pointer {i ===
currentIndex
? 'bg-kv-yellow'
: 'bg-transparent'}"
onclick={() => (currentIndex = i)}
aria-label="Go to slide {i + 1}"
></button>
{/each}
</div>
{/if}
</div>
</div>
{/if}

@ -11,5 +11,9 @@ export { default as KvNumberInput } from './KvNumberInput.svelte';
export { default as KvEditCard } from './KvEditCard.svelte'; export { default as KvEditCard } from './KvEditCard.svelte';
// Branding // Branding
export { default as KvLogo } from './KvGameLogo.svelte';
export { default as KvGameLogo } from './KvGameLogo.svelte'; export { default as KvGameLogo } from './KvGameLogo.svelte';
export { default as KvSpinner } from './KvSpinner.svelte';
// Modals
export { default as TutorialModal } from './TutorialModal.svelte';
export type { TutorialSlide } from './TutorialModal.svelte';

@ -1,7 +1,7 @@
{ {
"name": "9. Klassi Viktoriini", "name": "9. Klassi Viktoriin",
"settings": { "settings": {
"numberOfRounds": 1, "numberOfRounds": 2,
"pointValuePreset": "round1", "pointValuePreset": "round1",
"pointValues": [ "pointValues": [
10, 10,
@ -14,24 +14,30 @@
"categoriesPerRound": 6, "categoriesPerRound": 6,
"questionsPerCategory": 5, "questionsPerCategory": 5,
"dailyDoublesPerRound": [ "dailyDoublesPerRound": [
1 1,
2
], ],
"enableFinalRound": true, "enableFinalRound": true,
"enableSoundEffects": true, "enableSoundEffects": true,
"allowNegativeScores": true, "allowNegativeScores": true,
"maxTeams": 6, "maxTeams": 6,
"defaultTimerSeconds": 15, "defaultTimerSeconds": 5,
"answerRevealSeconds": 5 "answerRevealSeconds": 5
}, },
"teams": [ "teams": [
{ {
"id": "r5f48jjat", "id": "r5f48jjat",
"name": "Tiim 1", "name": "Teet",
"score": 0 "score": 0
}, },
{ {
"id": "lnbeg51uo", "id": "lnbeg51uo",
"name": "Tiim 2", "name": "Kristjan",
"score": 0
},
{
"id": "x7k2mq9pf",
"name": "Eeva",
"score": 0 "score": 0
} }
], ],
@ -46,40 +52,40 @@
"questions": [ "questions": [
{ {
"id": "a11mxf6ra", "id": "a11mxf6ra",
"question": "Mis aastal kuulutati välja Eesti Vabariik?", "question": "Sellel aastal kuulutati Pärnus välja Eesti Vabariigi iseseisvus",
"answer": "1918", "answer": "Mis on 1918?",
"points": 10, "points": 10,
"isDailyDouble": false, "isDailyDouble": false,
"isRevealed": false "isRevealed": false
}, },
{ {
"id": "j7ztvxf7l", "id": "j7ztvxf7l",
"question": "Kes oli Eesti Vabariigi esimene riigivanem?", "question": "See mees oli Eesti Vabariigi esimene riigivanem ja hilisem president",
"answer": "Konstantin Päts", "answer": "Kes on Konstantin Päts?",
"points": 20, "points": 20,
"isDailyDouble": false, "isDailyDouble": false,
"isRevealed": false "isRevealed": false
}, },
{ {
"id": "cwcx4jf7m", "id": "cwcx4jf7m",
"question": "Mis aastal toimus Laulev revolutsioon?", "question": "Sellel aastal toimus Laulev revolutsioon, mis viis Eesti taasiseseisvumiseni",
"answer": "1988", "answer": "Mis on 1988?",
"points": 30, "points": 30,
"isDailyDouble": false, "isDailyDouble": false,
"isRevealed": false "isRevealed": false
}, },
{ {
"id": "7m6e1qdwx", "id": "7m6e1qdwx",
"question": "Mis oli Balti keti kuupäev 1989. aastal?", "question": "Sellel kuupäeval 1989. aastal moodustasid eestlased, lätlased ja leedulased inimketi Tallinnast Vilniuseni",
"answer": "23. august", "answer": "Mis on 23. august?",
"points": 40, "points": 40,
"isDailyDouble": false, "isDailyDouble": false,
"isRevealed": false "isRevealed": false
}, },
{ {
"id": "mmudm4kjd", "id": "mmudm4kjd",
"question": "Mis aastal toimus Jüriöö ülestõus?", "question": "Sellel aastal toimus Jüriöö ülestõus, kus eestlased tõusid Taani ülemvõimu vastu",
"answer": "1343", "answer": "Mis on 1343?",
"points": 50, "points": 50,
"isDailyDouble": false, "isDailyDouble": false,
"isRevealed": false "isRevealed": false
@ -92,40 +98,40 @@
"questions": [ "questions": [
{ {
"id": "zfg5pe8pl", "id": "zfg5pe8pl",
"question": "Mis on ruutjuur 144-st?", "question": "Täpselt nii suur arv saadakse, kui võtta ruutjuur arvust 144",
"answer": "12", "answer": "Mis on 12?",
"points": 10, "points": 10,
"isDailyDouble": false, "isDailyDouble": false,
"isRevealed": false "isRevealed": false
}, },
{ {
"id": "ds2bmll5z", "id": "ds2bmll5z",
"question": "Mis on Pythagorase teoreemi valem?", "question": "See valem kirjeldab täisnurkse kolmnurga külgede vahelist seost Pythagorase teoreemis",
"answer": "a² + b² = c²", "answer": "Mis on a² + b² = c²?",
"points": 20, "points": 20,
"isDailyDouble": false, "isDailyDouble": false,
"isRevealed": false "isRevealed": false
}, },
{ {
"id": "6jkk4qkwo", "id": "6jkk4qkwo",
"question": "Mis on arvu pi (π) väärtus kahe komakohani?", "question": "Täpselt selline on arvu pi (π) väärtus kahe komakohani ümardatuna",
"answer": "3,14", "answer": "Mis on 3,14?",
"points": 30, "points": 30,
"isDailyDouble": false, "isDailyDouble": false,
"isRevealed": false "isRevealed": false
}, },
{ {
"id": "h32bk4gll", "id": "h32bk4gll",
"question": "Kui kolmnurga alus on 8 cm ja kõrgus 6 cm, mis on pindala?", "question": "Täpselt nii suur on kolmnurga pindala ruutsentimeetrites, kui selle alus on 8 cm ja kõrgus 6 cm",
"answer": "24 cm²", "answer": "Mis on 24 cm²?",
"points": 40, "points": 40,
"isDailyDouble": true, "isDailyDouble": true,
"isRevealed": false "isRevealed": false
}, },
{ {
"id": "a2vjrjbuo", "id": "a2vjrjbuo",
"question": "Lahenda võrrand: 3x + 7 = 22", "question": "Täpselt selline on x-i väärtus võrrandis 3x + 7 = 22",
"answer": "x = 5", "answer": "Mis on x = 5?",
"points": 50, "points": 50,
"isDailyDouble": false, "isDailyDouble": false,
"isRevealed": false "isRevealed": false
@ -138,40 +144,40 @@
"questions": [ "questions": [
{ {
"id": "a0tz6k2a5", "id": "a0tz6k2a5",
"question": "Mis on vee keemik valem?", "question": "See keemiline valem tähistab vett, mis koosneb kahest vesiniku ja ühest hapniku aatomist",
"answer": "H₂O", "answer": "Mis on H₂O?",
"points": 10, "points": 10,
"isDailyDouble": false, "isDailyDouble": false,
"isRevealed": false "isRevealed": false
}, },
{ {
"id": "1kjss32t3", "id": "1kjss32t3",
"question": "Mitu planeeti on meie päikesesüsteemis?", "question": "Täpselt nii palju planeete tiirleb meie päikesesüsteemis ümber Päikese",
"answer": "8", "answer": "Mis on 8?",
"points": 20, "points": 20,
"isDailyDouble": false, "isDailyDouble": false,
"isRevealed": false "isRevealed": false
}, },
{ {
"id": "4acp3zwgh", "id": "4acp3zwgh",
"question": "Mis on fotosünteesi põhiprodukt?", "question": "Need kaks ainet on fotosünteesi põhiproduktid, mida taimed valgusel toodavad",
"answer": "Glükoos (suhkur) ja hapnik", "answer": "Mis on glükoos ja hapnik?",
"points": 30, "points": 30,
"isDailyDouble": false, "isDailyDouble": false,
"isRevealed": false "isRevealed": false
}, },
{ {
"id": "fh1jn2z6f", "id": "fh1jn2z6f",
"question": "Mis element on perioodilisustabelis tähisega Fe?", "question": "See keemiline element kannab perioodilisustabelis tähist Fe ja on üks levinumaid metalle",
"answer": "Raud", "answer": "Mis on raud?",
"points": 40, "points": 40,
"isDailyDouble": false, "isDailyDouble": false,
"isRevealed": false "isRevealed": false
}, },
{ {
"id": "wd96bi1d5", "id": "wd96bi1d5",
"question": "Mis on DNA täisnimi?", "question": "See on DNA täisnimi ehk molekul, mis kannab pärilikku informatsiooni",
"answer": "Desoksüribonukleiinhape", "answer": "Mis on desoksüribonukleiinhape?",
"points": 50, "points": 50,
"isDailyDouble": false, "isDailyDouble": false,
"isRevealed": false "isRevealed": false
@ -184,40 +190,40 @@
"questions": [ "questions": [
{ {
"id": "he7in5nor", "id": "he7in5nor",
"question": "Kes kirjutas eepose 'Kalevipoeg'?", "question": "See Eesti arst ja kirjanik pani kokku rahvuseepose 'Kalevipoeg' rahvaluule põhjal",
"answer": "Friedrich Reinhold Kreutzwald", "answer": "Kes on Friedrich Reinhold Kreutzwald?",
"points": 10, "points": 10,
"isDailyDouble": false, "isDailyDouble": false,
"isRevealed": false "isRevealed": false
}, },
{ {
"id": "pafvsp6t9", "id": "pafvsp6t9",
"question": "Mis on A. H. Tammsaare tuntuim romaan?", "question": "See viieosaline romaan on A. H. Tammsaare tuntuim teos ja Eesti kirjanduse tippude hulgas",
"answer": "Tõde ja õigus", "answer": "Mis on 'Tõde ja õigus'?",
"points": 20, "points": 20,
"isDailyDouble": false, "isDailyDouble": false,
"isRevealed": false "isRevealed": false
}, },
{ {
"id": "rjjp0h8s4", "id": "rjjp0h8s4",
"question": "Kes kirjutas luuletuse 'Mu isamaa on minu arm'?", "question": "See Eesti luuletaja, keda kutsutakse 'koidulaulikuks', kirjutas luuletuse 'Mu isamaa on minu arm'",
"answer": "Lydia Koidula", "answer": "Kes on Lydia Koidula?",
"points": 30, "points": 30,
"isDailyDouble": false, "isDailyDouble": false,
"isRevealed": false "isRevealed": false
}, },
{ {
"id": "n7o3vkkcn", "id": "n7o3vkkcn",
"question": "Mis on soneti traditsiooniline värsside arv?", "question": "Täpselt nii palju värsse ehk ridu on traditsioonilises sonetis",
"answer": "14 rida", "answer": "Mis on 14 rida?",
"points": 40, "points": 40,
"isDailyDouble": false, "isDailyDouble": false,
"isRevealed": false "isRevealed": false
}, },
{ {
"id": "4hxuoa1fy", "id": "4hxuoa1fy",
"question": "Kes kirjutas romaani 'Kevade'?", "question": "See Eesti kirjanik lõi armastatud romaani 'Kevade', mis räägib Paunvere koolipoistest",
"answer": "Oskar Luts", "answer": "Kes on Oskar Luts?",
"points": 50, "points": 50,
"isDailyDouble": false, "isDailyDouble": false,
"isRevealed": false "isRevealed": false
@ -230,40 +236,40 @@
"questions": [ "questions": [
{ {
"id": "4qkzwwe7r", "id": "4qkzwwe7r",
"question": "Mis on Eesti pealinn?", "question": "See linn on Eesti pealinn ja suurim linn, mis asub Soome lahe kaldal",
"answer": "Tallinn", "answer": "Mis on Tallinn?",
"points": 10, "points": 10,
"isDailyDouble": false, "isDailyDouble": false,
"isRevealed": false "isRevealed": false
}, },
{ {
"id": "7z3113es0", "id": "7z3113es0",
"question": "Mis on Eesti kõrgeim mägi?", "question": "See mägi Võrumaal on Eesti ja kogu Baltimaade kõrgeim punkt",
"answer": "Suur Munamägi", "answer": "Mis on Suur Munamägi?",
"points": 20, "points": 20,
"isDailyDouble": false, "isDailyDouble": false,
"isRevealed": false "isRevealed": false
}, },
{ {
"id": "so7tqkk16", "id": "so7tqkk16",
"question": "Mis on maailma suurim ookean?", "question": "See ookean on maailma suurim ja katab rohkem kui kolmandiku Maa pinnast",
"answer": "Vaikne ookean", "answer": "Mis on Vaikne ookean?",
"points": 30, "points": 30,
"isDailyDouble": false, "isDailyDouble": false,
"isRevealed": false "isRevealed": false
}, },
{ {
"id": "h3gyk1yi3", "id": "h3gyk1yi3",
"question": "Mis riik on pindalalt maailma suurim?", "question": "See riik on pindalalt maailma suurim, ulatudes Euroopast Aasiani",
"answer": "Venemaa", "answer": "Mis on Venemaa?",
"points": 40, "points": 40,
"isDailyDouble": false, "isDailyDouble": false,
"isRevealed": false "isRevealed": false
}, },
{ {
"id": "385aprc7p", "id": "385aprc7p",
"question": "Mis on Eesti suurim saar?", "question": "See saar on Eesti suurim ja asub Lääne-Eesti saarestikus",
"answer": "Saaremaa", "answer": "Mis on Saaremaa?",
"points": 50, "points": 50,
"isDailyDouble": false, "isDailyDouble": false,
"isRevealed": false "isRevealed": false
@ -276,40 +282,40 @@
"questions": [ "questions": [
{ {
"id": "jzcjmb4ef", "id": "jzcjmb4ef",
"question": "Mis värvid on Eesti lipul?", "question": "Need kolm värvi moodustavad Eesti lipu trikoloori, mis kehtestati juba 19. sajandil",
"answer": "Sinine, must, valge", "answer": "Mis on sinine, must ja valge?",
"points": 10, "points": 10,
"isDailyDouble": false, "isDailyDouble": false,
"isRevealed": false "isRevealed": false
}, },
{ {
"id": "n86mk1kg0", "id": "n86mk1kg0",
"question": "Mis on Eesti rahvuslind?", "question": "See väike lind kahestunud sabaga on Eesti rahvuslind ja sümboliseerib õnne",
"answer": "Suitsupääsuke", "answer": "Mis on suitsupääsuke?",
"points": 20, "points": 20,
"isDailyDouble": false, "isDailyDouble": false,
"isRevealed": false "isRevealed": false
}, },
{ {
"id": "04e5zactm", "id": "04e5zactm",
"question": "Mis aastal liitus Eesti Euroopa Liiduga?", "question": "Sellel aastal liitus Eesti koos üheksa teise riigiga Euroopa Liiduga",
"answer": "2004", "answer": "Mis on 2004?",
"points": 30, "points": 30,
"isDailyDouble": false, "isDailyDouble": false,
"isRevealed": false "isRevealed": false
}, },
{ {
"id": "xwhxh99j6", "id": "xwhxh99j6",
"question": "Mis on Eesti rahvuslill?", "question": "See sinine põllulill on Eesti rahvuslill ja sümboliseerib igapäevast leiba",
"answer": "Rukkilill", "answer": "Mis on rukkilill?",
"points": 40, "points": 40,
"isDailyDouble": false, "isDailyDouble": false,
"isRevealed": false "isRevealed": false
}, },
{ {
"id": "n54fmwmg8", "id": "n54fmwmg8",
"question": "Mis aastal võttis Eesti kasutusele euro?", "question": "Sellel aastal võttis Eesti kasutusele euro, olles 17. euroala riik",
"answer": "2011", "answer": "Mis on 2011?",
"points": 50, "points": 50,
"isDailyDouble": false, "isDailyDouble": false,
"isRevealed": false "isRevealed": false
@ -318,11 +324,294 @@
} }
], ],
"pointMultiplier": 1 "pointMultiplier": 1
},
{
"id": "e84612e7-2694-4174-9a8f-a2741a02633b",
"name": "Double Jeopardy",
"categories": [
{
"id": "c57b9a5b-0e5d-4feb-ab1f-60bc9c88650a",
"name": "MAAILMA AJALUGU",
"questions": [
{
"id": "bbc398fa-8f8d-47ac-9cf7-eb980873acd5",
"question": "Sellel aastal algas Teine maailmasõda, kui Saksamaa tungis Poolasse",
"answer": "Mis on 1939?",
"points": 20,
"isDailyDouble": false,
"isRevealed": false
},
{
"id": "af4d9436-b532-4da4-acf3-d6ede530cea5",
"question": "See Vana-Egiptuse legendaarne kuninganna oli armastajaks nii Julius Caesarile kui ka Marcus Antoniusele",
"answer": "Kes on Kleopatra?",
"points": 40,
"isDailyDouble": false,
"isRevealed": false
},
{
"id": "f3c21526-4111-471f-9e59-54cb3180da04",
"question": "Sellel aastal langes Berliini müür ja Saksamaa taasühendamine sai võimalikuks",
"answer": "Mis on 1989?",
"points": 60,
"isDailyDouble": false,
"isRevealed": false
},
{
"id": "a7230d01-4d94-4b1e-b6e5-15bfb423c983",
"question": "See Prantsuse keiser vallutas suure osa Euroopast, kuid kaotas lõpuks 1815. aastal Waterloo lahingu",
"answer": "Kes on Napoleon Bonaparte?",
"points": 80,
"isDailyDouble": false,
"isRevealed": false
},
{
"id": "9e4b114a-9253-4778-9d69-251bca34ace3",
"question": "Sellel aastal jõudis Itaalia meresõitja Christopher Columbus esimest korda Ameerika mandritele",
"answer": "Mis on 1492?",
"points": 100,
"isDailyDouble": false,
"isRevealed": false
}
]
},
{
"id": "019af247-c1e2-4d63-b780-eb5570ee9481",
"name": "TEADUS JA TEHNIKA",
"questions": [
{
"id": "e7a227ed-e11f-4041-88c8-36fd9076e445",
"question": "Täpselt nii kiiresti liigub valgus vaakumis kilomeetrites sekundis - see on universumi kiireim võimalik kiirus",
"answer": "Mis on 300 000 km/s?",
"points": 20,
"isDailyDouble": false,
"isRevealed": false
},
{
"id": "1b0ce55c-d7c3-440d-8ed1-cb0b71388c60",
"question": "See Šoti teadlane avastas 1928. aastal juhuslikult penitsilliini, mis muutis meditsiini ajaloo",
"answer": "Kes on Alexander Fleming?",
"points": 40,
"isDailyDouble": false,
"isRevealed": false
},
{
"id": "6f553e0a-9e5a-478b-9efa-9602dc3494dc",
"question": "Sellel keemilisel elemendil on aatomnumber 79 ja see on üks kõige väärtuslikumaid metalleid maailmas",
"answer": "Mis on kuld?",
"points": 60,
"isDailyDouble": true,
"isRevealed": false
},
{
"id": "f533d224-b5e6-4bd5-99a0-3f20c7348e22",
"question": "See Ameerika astronaut oli esimene inimene, kes astus 1969. aastal Kuu pinnale ja lausus kuulsad sõnad",
"answer": "Kes on Neil Armstrong?",
"points": 80,
"isDailyDouble": false,
"isRevealed": false
},
{
"id": "4d2e4e3e-122f-419f-94e2-32e4d05f6724",
"question": "See Saksa päritolu füüsik sõnastas relatiivsuse teooria ja on tuntud oma valemi E=mc² poolest",
"answer": "Kes on Albert Einstein?",
"points": 100,
"isDailyDouble": false,
"isRevealed": false
}
]
},
{
"id": "d3b51154-f8e8-492c-8062-438f8508f199",
"name": "MUUSIKA",
"questions": [
{
"id": "abedd9e5-5d41-40be-8732-e3b4bae26734",
"question": "Sellest Inglismaa linnast pärineb legendaarne bänd The Beatles, kes muutis popmuusika ajalugu",
"answer": "Mis on Liverpool?",
"points": 20,
"isDailyDouble": false,
"isRevealed": false
},
{
"id": "7765f429-cf26-49cd-b224-476c6553dfbf",
"question": "See Austria päritolu helilooja oli üks Viini klassikutest ja lõi ooperi 'Võluflööt'",
"answer": "Kes on Wolfgang Amadeus Mozart?",
"points": 40,
"isDailyDouble": false,
"isRevealed": false
},
{
"id": "cb6c52c1-d1f4-4c6a-a1c7-ddfa49966da8",
"question": "See Eesti helilooja on maailmas üks enim esitatud elavaid heliloojaid ja lõi unikaalse 'tintinnabuli' tehnika",
"answer": "Kes on Arvo Pärt?",
"points": 60,
"isDailyDouble": false,
"isRevealed": false
},
{
"id": "f22016ba-ff28-4388-bbd5-05c98e96c05a",
"question": "Seda Ameerika lauljat kutsuti popikuningaks ja tema albumid 'Thriller' ning 'Bad' on ajaloo enimmüüdute seas",
"answer": "Kes on Michael Jackson?",
"points": 80,
"isDailyDouble": false,
"isRevealed": false
},
{
"id": "c368877a-3b6d-4d8b-b5e3-6ce65a009daf",
"question": "See Saksa helilooja kirjutas oma kuulsa 9. sümfoonia olles juba täielikult kurt",
"answer": "Kes on Ludwig van Beethoven?",
"points": 100,
"isDailyDouble": false,
"isRevealed": false
}
]
},
{
"id": "b1797b2c-85da-4ff9-8d32-b1b81d9943b9",
"name": "SPORT",
"questions": [
{
"id": "bb8f371e-d9c3-4b8b-90a7-9dbb1b35f94c",
"question": "Täpselt nii palju mängijaid viibib ühest jalgpallimeeskonnast korraga väljakul",
"answer": "Mis on 11?",
"points": 20,
"isDailyDouble": false,
"isRevealed": false
},
{
"id": "8646c184-764a-4d23-9687-87ce0ef9352c",
"question": "Selles riigis toimusid 2024. aasta suveolümpiamängud, mille avamine toimus Seine'i jõel",
"answer": "Mis on Prantsusmaa?",
"points": 40,
"isDailyDouble": false,
"isRevealed": false
},
{
"id": "86899b2b-9b58-4f71-beed-9a266f5e954a",
"question": "See Leedu kettaheitja võitis 2024. aasta Pariisi olümpial kuldmedali maailmarekordiga",
"answer": "Kes on Mykolas Alekna?",
"points": 60,
"isDailyDouble": false,
"isRevealed": false
},
{
"id": "ca2feea9-5045-489c-bde7-29961fae5793",
"question": "Sellel Serbia tennisistil on kõige rohkem Grand Slam'i tiitleid meeste üksikmängus ajaloo jooksul",
"answer": "Kes on Novak Djokovic?",
"points": 80,
"isDailyDouble": true,
"isRevealed": false
},
{
"id": "b3d3fc4f-a48d-42eb-9d2a-8e19e9a8a832",
"question": "See Lõuna-Ameerika riik on võitnud kõige rohkem jalgpalli maailmameistritiitleid - täpselt viis korda",
"answer": "Mis on Brasiilia?",
"points": 100,
"isDailyDouble": false,
"isRevealed": false
}
]
},
{
"id": "0df7359e-dee5-4633-8da4-d0e11d310673",
"name": "FILMID JA TV",
"questions": [
{
"id": "26c86d36-db24-461a-9f49-dab3d99dc563",
"question": "See Ameerika animatsioonistuudio lõi sellised hitid nagu 'Shrek', 'Kung Fu Panda' ja 'Kuidas taltsutada lohet'",
"answer": "Mis on DreamWorks?",
"points": 20,
"isDailyDouble": false,
"isRevealed": false
},
{
"id": "1a86912a-13a0-46c9-8354-31575c7b1511",
"question": "See Ameerika näitleja kehastab ekstsentrilist piraati Captain Jack Sparrow'd filmisarjas 'Kariibi mere piraadid'",
"answer": "Kes on Johnny Depp?",
"points": 40,
"isDailyDouble": false,
"isRevealed": false
},
{
"id": "38479718-87cf-4241-90c1-815abeb2c894",
"question": "See Ameerika kirjanik lõi fantaasiasarja 'Jää ja tule laul', mille põhjal valmis HBO menukas sari 'Game of Thrones'",
"answer": "Kes on George R. R. Martin?",
"points": 60,
"isDailyDouble": false,
"isRevealed": false
},
{
"id": "8215f3fa-a1a4-473a-8a8b-839dce4f1087",
"question": "See Kanada režissöör on loonud mõned ajaloo kallimad filmid, sealhulgas 'Titanic', 'Avatar' ja 'Terminator'",
"answer": "Kes on James Cameron?",
"points": 80,
"isDailyDouble": false,
"isRevealed": false
},
{
"id": "ee178b0f-d48e-4750-8677-8a0e57b3601f",
"question": "See Christopher Nolani film aatomipommi loomisest võitis 2024. aastal parima filmi Oscari",
"answer": "Mis on 'Oppenheimer'?",
"points": 100,
"isDailyDouble": false,
"isRevealed": false
}
]
},
{
"id": "748b9491-8f36-4071-bee8-23d24e59063c",
"name": "TOIT JA JOOK",
"questions": [
{
"id": "71a422b5-b63a-4b1f-a3ce-2f6b51e73814",
"question": "Sellest Aasia riigist pärineb sushi - roog, mis koosneb riisist ja toorast kalast",
"answer": "Mis on Jaapan?",
"points": 20,
"isDailyDouble": false,
"isRevealed": false
},
{
"id": "eb178502-6317-4d6b-b79e-6fff66aa690a",
"question": "Seda pehmet Itaalia juustu kasutatakse traditsioonilises Caprese salatis koos tomatite ja basiilikuga",
"answer": "Mis on mozzarella?",
"points": 40,
"isDailyDouble": false,
"isRevealed": false
},
{
"id": "e3f07655-7032-49ab-909b-10dd6c3c74c1",
"question": "See India köögist pärit vürts annab karriroogadele iseloomuliku kollase värvi ja maitse",
"answer": "Mis on kurkum?",
"points": 60,
"isDailyDouble": false,
"isRevealed": false
},
{
"id": "11f6ca47-e31e-4ea1-b44e-9c4fa7bb0382",
"question": "Seda Eesti traditsioonilist suppi valmistatakse hapendatud kapsast ja serveeritakse sageli hapukoore ning sealihaga",
"answer": "Mis on hapukapsasupp?",
"points": 80,
"isDailyDouble": false,
"isRevealed": false
},
{
"id": "d7b02c21-4337-4049-9a49-565d4cc2a318",
"question": "See Prantsuse magustoit koosneb vaniljekoorest, mille peale põletatakse kõva karamellikiht",
"answer": "Mis on crème brûlée?",
"points": 100,
"isDailyDouble": false,
"isRevealed": false
}
]
}
],
"pointMultiplier": 2
} }
], ],
"finalRound": { "finalRound": {
"category": "EESTI KULTUUR", "category": "EESTI KULTUUR",
"question": "Mis aastal võitis Eesti esimest korda Eurovisiooni lauluvõistluse ja mis laul see oli?", "question": "Aasta ja laul, millega Eesti võitis esimest korda Eurovisiooni lauluvõistluse",
"answer": "2001. aastal lauluga 'Everybody' (Tanel Padar, Dave Benton ja 2XL)" "answer": "Mis on 2001 ja 'Everybody' (Tanel Padar, Dave Benton ja 2XL)?"
} }
} }

@ -63,6 +63,7 @@ class GameSessionStore {
private channel: BroadcastChannel | null = null; private channel: BroadcastChannel | null = null;
private timerInterval: ReturnType<typeof setInterval> | null = null; private timerInterval: ReturnType<typeof setInterval> | null = null;
private isTimerOwner = false; // Only one tab should own the timer private isTimerOwner = false; // Only one tab should own the timer
private projectorWindow: Window | null = null; // Track projector window for cleanup
state = $state<GameSessionState | null>(null); state = $state<GameSessionState | null>(null);
constructor() { constructor() {
@ -217,6 +218,18 @@ class GameSessionStore {
if (browser) { if (browser) {
localStorage.removeItem(STORAGE_KEY); localStorage.removeItem(STORAGE_KEY);
this.broadcast("STATE_UPDATE", null as any); this.broadcast("STATE_UPDATE", null as any);
// Keep projectorWindow reference so we can detect if tab is still open
}
}
// Open or focus the projector window
openProjector() {
if (browser) {
// Reuse existing window if still open
if (this.projectorWindow && !this.projectorWindow.closed) {
return; // Already open, don't switch focus
}
this.projectorWindow = window.open("/kuldvillak/play?view=projector", "kuldvillak-projector");
} }
} }
@ -334,16 +347,17 @@ class GameSessionStore {
} }
} }
// Skip question - 5 second delay, then shows answer // Skip question - immediately reveal answer
skipQuestion() { skipQuestion() {
if (!this.state || !this.state.currentQuestion) return; if (!this.state || !this.state.currentQuestion) return;
// Stop timer if running // Stop timer if running
this.state.timerRunning = false; this.state.timerRunning = false;
this.state.activeTeamId = null; this.state.activeTeamId = null;
// Mark as skipping and start countdown // Mark as skipping and immediately show answer
this.state.skippingQuestion = true; this.state.skippingQuestion = true;
this.state.timeoutCountdown = 5; this.state.showAnswer = true;
this.state.revealCountdown = this.state.settings.answerRevealSeconds ?? 5;
this.persist(); this.persist();
} }

@ -0,0 +1,209 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import type { KuldvillakGame } from '$lib/types/kuldvillak';
import { DEFAULT_SETTINGS, DEFAULT_STATE } from '$lib/types/kuldvillak';
// Mock localStorage
const localStorageMock = (() => {
let store: Record<string, string> = {};
return {
getItem: vi.fn((key: string) => store[key] ?? null),
setItem: vi.fn((key: string, value: string) => { store[key] = value; }),
removeItem: vi.fn((key: string) => { delete store[key]; }),
clear: vi.fn(() => { store = {}; }),
get length() { return Object.keys(store).length; },
key: vi.fn((i: number) => Object.keys(store)[i] ?? null),
};
})();
Object.defineProperty(global, 'localStorage', { value: localStorageMock });
// Import after mocking localStorage
import {
getKuldvillakGamesList,
getAllKuldvillakGames,
loadKuldvillakGame,
saveKuldvillakGame,
deleteKuldvillakGame,
duplicateKuldvillakGame,
setActiveKuldvillakGame,
getActiveKuldvillakGameId,
} from './persistence';
// Helper to create a test game
function createTestGame(overrides: Partial<KuldvillakGame> = {}): KuldvillakGame {
return {
id: 'test-game-id',
name: 'Test Game',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
settings: { ...DEFAULT_SETTINGS },
teams: [
{ id: 'team-1', name: 'Team 1', score: 0 },
{ id: 'team-2', name: 'Team 2', score: 0 },
],
rounds: [{
id: 'round-1',
name: 'Round 1',
pointMultiplier: 1,
categories: [{
id: 'cat-1',
name: 'Category 1',
questions: [{
id: 'q-1',
question: 'Test Question',
answer: 'Test Answer',
points: 100,
isDailyDouble: false,
isRevealed: false,
}],
}],
}],
finalRound: null,
state: { ...DEFAULT_STATE },
...overrides,
};
}
describe('Persistence', () => {
beforeEach(() => {
localStorageMock.clear();
vi.clearAllMocks();
});
describe('saveKuldvillakGame', () => {
it('should save a new game', () => {
const game = createTestGame();
const result = saveKuldvillakGame(game);
expect(result).toBe(true);
expect(localStorageMock.setItem).toHaveBeenCalled();
});
it('should update existing game', () => {
const game = createTestGame();
saveKuldvillakGame(game);
game.name = 'Updated Name';
saveKuldvillakGame(game);
const games = getAllKuldvillakGames();
expect(games).toHaveLength(1);
expect(games[0].name).toBe('Updated Name');
});
it('should update the updatedAt timestamp', () => {
const game = createTestGame();
const originalUpdatedAt = game.updatedAt;
// Wait a tiny bit to ensure different timestamp
saveKuldvillakGame(game);
const games = getAllKuldvillakGames();
expect(games[0].updatedAt).not.toBe(originalUpdatedAt);
});
});
describe('loadKuldvillakGame', () => {
it('should return null for non-existent game', () => {
const result = loadKuldvillakGame('non-existent-id');
expect(result).toBeNull();
});
it('should load existing game', () => {
const game = createTestGame();
saveKuldvillakGame(game);
const loaded = loadKuldvillakGame(game.id);
expect(loaded).not.toBeNull();
expect(loaded?.name).toBe(game.name);
});
});
describe('deleteKuldvillakGame', () => {
it('should delete a game', () => {
const game = createTestGame();
saveKuldvillakGame(game);
const result = deleteKuldvillakGame(game.id);
expect(result).toBe(true);
expect(getAllKuldvillakGames()).toHaveLength(0);
});
it('should handle deleting non-existent game', () => {
const result = deleteKuldvillakGame('non-existent');
expect(result).toBe(true); // Should not fail
});
});
describe('duplicateKuldvillakGame', () => {
it('should create a duplicate with new ID', () => {
const original = createTestGame();
saveKuldvillakGame(original);
const duplicate = duplicateKuldvillakGame(original.id);
expect(duplicate).not.toBeNull();
expect(duplicate?.id).not.toBe(original.id);
expect(duplicate?.name).toBe(`${original.name} (Copy)`);
});
it('should reset game state in duplicate', () => {
const original = createTestGame();
original.state.phase = 'board';
original.teams[0].score = 500;
saveKuldvillakGame(original);
const duplicate = duplicateKuldvillakGame(original.id);
expect(duplicate?.state.phase).toBe('lobby');
expect(duplicate?.teams[0].score).toBe(0);
});
it('should reset revealed questions in duplicate', () => {
const original = createTestGame();
original.rounds[0].categories[0].questions[0].isRevealed = true;
saveKuldvillakGame(original);
const duplicate = duplicateKuldvillakGame(original.id);
expect(duplicate?.rounds[0].categories[0].questions[0].isRevealed).toBe(false);
});
it('should return null for non-existent game', () => {
const result = duplicateKuldvillakGame('non-existent');
expect(result).toBeNull();
});
});
describe('getKuldvillakGamesList', () => {
it('should return empty array when no games', () => {
const result = getKuldvillakGamesList();
expect(result).toEqual([]);
});
it('should return metadata for all games', () => {
saveKuldvillakGame(createTestGame({ id: 'game-1', name: 'Game 1' }));
saveKuldvillakGame(createTestGame({ id: 'game-2', name: 'Game 2' }));
const list = getKuldvillakGamesList();
expect(list).toHaveLength(2);
expect(list[0].teamCount).toBe(2);
expect(list[0].roundCount).toBe(1);
});
});
describe('Active Game', () => {
it('should set and get active game ID', () => {
setActiveKuldvillakGame('test-id');
expect(getActiveKuldvillakGameId()).toBe('test-id');
});
it('should clear active game when set to null', () => {
setActiveKuldvillakGame('test-id');
setActiveKuldvillakGame(null);
expect(getActiveKuldvillakGameId()).toBeNull();
});
});
});

@ -0,0 +1,145 @@
import { describe, it, expect } from 'vitest';
import { DEFAULT_SETTINGS, DEFAULT_STATE } from './kuldvillak';
import type { Team, Round, Question, GameSettings } from './kuldvillak';
describe('Kuldvillak Types', () => {
describe('DEFAULT_SETTINGS', () => {
it('should have correct number of rounds', () => {
expect(DEFAULT_SETTINGS.numberOfRounds).toBe(2);
});
it('should have 5 point values', () => {
expect(DEFAULT_SETTINGS.pointValues).toHaveLength(5);
expect(DEFAULT_SETTINGS.pointValues).toEqual([10, 20, 30, 40, 50]);
});
it('should have 6 categories per round', () => {
expect(DEFAULT_SETTINGS.categoriesPerRound).toBe(6);
});
it('should have 5 questions per category', () => {
expect(DEFAULT_SETTINGS.questionsPerCategory).toBe(5);
});
it('should have daily doubles configuration', () => {
expect(DEFAULT_SETTINGS.dailyDoublesPerRound).toEqual([1, 2]);
});
it('should enable final round by default', () => {
expect(DEFAULT_SETTINGS.enableFinalRound).toBe(true);
});
it('should allow negative scores by default', () => {
expect(DEFAULT_SETTINGS.allowNegativeScores).toBe(true);
});
it('should have 6 max teams', () => {
expect(DEFAULT_SETTINGS.maxTeams).toBe(6);
});
it('should have default timer of 5 seconds', () => {
expect(DEFAULT_SETTINGS.defaultTimerSeconds).toBe(5);
});
it('should have answer reveal of 5 seconds', () => {
expect(DEFAULT_SETTINGS.answerRevealSeconds).toBe(5);
});
});
describe('DEFAULT_STATE', () => {
it('should start in lobby phase', () => {
expect(DEFAULT_STATE.phase).toBe('lobby');
});
it('should start at round index 0', () => {
expect(DEFAULT_STATE.currentRoundIndex).toBe(0);
});
it('should have no active team', () => {
expect(DEFAULT_STATE.activeTeamId).toBeNull();
});
it('should have empty final round data', () => {
expect(DEFAULT_STATE.finalWagers).toEqual({});
expect(DEFAULT_STATE.finalAnswers).toEqual({});
});
});
describe('Type Validation Helpers', () => {
it('should validate team structure', () => {
const validTeam: Team = {
id: 'team-1',
name: 'Test Team',
score: 100,
};
expect(validTeam.id).toBeDefined();
expect(validTeam.name).toBeDefined();
expect(typeof validTeam.score).toBe('number');
});
it('should validate question structure', () => {
const validQuestion: Question = {
id: 'q-1',
question: 'What is 2+2?',
answer: 'What is 4?',
points: 100,
isDailyDouble: false,
isRevealed: false,
};
expect(validQuestion.id).toBeDefined();
expect(validQuestion.question).toBeDefined();
expect(validQuestion.answer).toBeDefined();
expect(typeof validQuestion.points).toBe('number');
expect(typeof validQuestion.isDailyDouble).toBe('boolean');
expect(typeof validQuestion.isRevealed).toBe('boolean');
});
it('should validate optional imageUrl in question', () => {
const questionWithImage: Question = {
id: 'q-1',
question: 'What is shown?',
answer: 'What is a cat?',
points: 200,
isDailyDouble: false,
isRevealed: false,
imageUrl: 'https://example.com/image.jpg',
};
expect(questionWithImage.imageUrl).toBeDefined();
});
it('should calculate point values correctly', () => {
const baseValue = 10;
const multiplier = 2;
const expectedValues = [20, 40, 60, 80, 100];
const calculatedValues = DEFAULT_SETTINGS.pointValues.map(
(v) => v * multiplier
);
expect(calculatedValues).toEqual(expectedValues);
});
it('should validate game phases', () => {
const validPhases = [
'lobby',
'intro',
'intro-categories',
'board',
'question',
'daily-double',
'final-intro',
'final-category',
'final-wager',
'final-question',
'final-reveal',
'final-scores',
'finished',
];
expect(validPhases).toContain(DEFAULT_STATE.phase);
});
});
});

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

@ -53,7 +53,7 @@
{#if game.available} {#if game.available}
<a <a
href={game.href} 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)]" class="block w-[240px] h-[240px] max-md:w-[180px] max-md:h-[180px] border-8 border-[#003B9B] overflow-hidden transition-all duration-200 hover:scale-105 hover:border-[#FFAB00]"
> >
<img <img
src={game.image} src={game.image}
@ -63,7 +63,7 @@
</a> </a>
{:else} {:else}
<div <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" class="relative w-[240px] h-[240px] max-md:w-[180px] max-md:h-[180px] border-8 border-[#003B9B] overflow-hidden opacity-50"
> >
<img <img
src={game.image} src={game.image}

@ -4,6 +4,7 @@
import { audioStore } from "$lib/stores/audio.svelte"; import { audioStore } from "$lib/stores/audio.svelte";
import { themeStore } from "$lib/stores/theme.svelte"; import { themeStore } from "$lib/stores/theme.svelte";
import type { Snippet } from "svelte"; import type { Snippet } from "svelte";
import faviconKuldvillak from "$lib/assets/favicon-kuldvillak.svg";
let { children }: { children: Snippet } = $props(); let { children }: { children: Snippet } = $props();
@ -19,4 +20,8 @@
}); });
</script> </script>
<svelte:head>
<link rel="icon" href={faviconKuldvillak} />
</svelte:head>
{@render children()} {@render children()}

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { Settings } from "$lib/components"; import { Settings } from "$lib/components";
import { KvButtonPrimary, KvLogo } from "$lib/components/kuldvillak/ui"; import { KvButtonPrimary, KvGameLogo } from "$lib/components/kuldvillak/ui";
import * as m from "$lib/paraglide/messages"; import * as m from "$lib/paraglide/messages";
let settingsOpen = $state(false); let settingsOpen = $state(false);
@ -33,6 +33,7 @@
<svelte:head> <svelte:head>
<title>Kuldvillak - Ultimate Gaming</title> <title>Kuldvillak - Ultimate Gaming</title>
<link rel="icon" href="/kuldvillak_favicon.svg" type="image/svg+xml" />
</svelte:head> </svelte:head>
<div <div
@ -44,7 +45,7 @@
<!-- Content --> <!-- Content -->
<div class="relative z-10 flex flex-col items-center gap-8 md:gap-16 p-4"> <div class="relative z-10 flex flex-col items-center gap-8 md:gap-16 p-4">
<!-- Kuldvillak Logo --> <!-- Kuldvillak Logo -->
<KvLogo <KvGameLogo
size="lg" size="lg"
class="md:h-48 md:max-w-[768px] drop-shadow-[6px_6px_4px_rgba(0,0,0,0.5)]" class="md:h-48 md:max-w-[768px] drop-shadow-[6px_6px_4px_rgba(0,0,0,0.5)]"
/> />
@ -80,6 +81,7 @@
</KvButtonPrimary> </KvButtonPrimary>
<KvButtonPrimary <KvButtonPrimary
href="/" href="/"
reload
class="w-full !text-lg md:!text-2xl !py-3 md:!py-4" class="w-full !text-lg md:!text-2xl !py-3 md:!py-4"
> >
{m.kv_exit()} {m.kv_exit()}

@ -3,7 +3,13 @@
import { onMount } from "svelte"; import { onMount } from "svelte";
import { browser } from "$app/environment"; import { browser } from "$app/environment";
import { Toast, Settings, ConfirmDialog } from "$lib/components"; import { Toast, Settings, ConfirmDialog } from "$lib/components";
import {
KvButtonSecondary,
TutorialModal,
type TutorialSlide,
} from "$lib/components/kuldvillak/ui";
import * as m from "$lib/paraglide/messages"; import * as m from "$lib/paraglide/messages";
import { getLocale } from "$lib/paraglide/runtime";
import { gameSession } from "$lib/stores/gameSession.svelte"; import { gameSession } from "$lib/stores/gameSession.svelte";
import type { import type {
GameSettings, GameSettings,
@ -26,10 +32,11 @@
}); });
let teams = $state<Team[]>([ let teams = $state<Team[]>([
{ id: generateId(), name: "Mängija 1", score: 0 }, { id: generateId(), name: "Mängija 1", score: 0 },
{ id: generateId(), name: "Mängija 2", score: 0 },
]); ]);
let rounds = $state<Round[]>([ let rounds = $state<Round[]>([
createRound("Villak", 1, DEFAULT_SETTINGS), createRound(m.kv_edit_r1(), 1, DEFAULT_SETTINGS),
createRound("Topeltvillak", 2, DEFAULT_SETTINGS), createRound(m.kv_edit_r2(), 2, DEFAULT_SETTINGS),
]); ]);
let finalRound = $state<FinalRound>({ let finalRound = $state<FinalRound>({
category: "", category: "",
@ -58,6 +65,49 @@
let showQuestionCloseConfirm = $state(false); let showQuestionCloseConfirm = $state(false);
let showFinalCloseConfirm = $state(false); let showFinalCloseConfirm = $state(false);
// Tutorial modal states
let showRulesModal = $state(false);
let showHowToModal = $state(false);
// Tutorial slides - using paraglide translations with localized images
const rulesSlides: TutorialSlide[] = $derived([
{
image: `/tutorials/${getLocale()}/rules-1.png`,
text: m.kv_tutorial_rules_placeholder(),
},
]);
const howToSlides: TutorialSlide[] = $derived([
{
image: `/tutorials/${getLocale()}/howto-1.png`,
text: m.kv_tutorial_howto_1(),
},
{
image: `/tutorials/${getLocale()}/howto-2.png`,
text: m.kv_tutorial_howto_2(),
},
{
image: `/tutorials/${getLocale()}/howto-3.png`,
text: m.kv_tutorial_howto_3(),
},
{
image: `/tutorials/${getLocale()}/howto-4.png`,
text: m.kv_tutorial_howto_4(),
},
{
image: `/tutorials/${getLocale()}/howto-5.png`,
text: m.kv_tutorial_howto_5(),
},
{
image: `/tutorials/${getLocale()}/howto-6.png`,
text: m.kv_tutorial_howto_6(),
},
{
image: `/tutorials/${getLocale()}/howto-7.png`,
text: m.kv_tutorial_howto_7(),
},
]);
// Original values for reverting // Original values for reverting
let originalQuestion = $state<{ let originalQuestion = $state<{
question: string; question: string;
@ -203,7 +253,7 @@
rounds = [rounds[0]]; rounds = [rounds[0]];
settings.dailyDoublesPerRound = [settings.dailyDoublesPerRound[0]]; settings.dailyDoublesPerRound = [settings.dailyDoublesPerRound[0]];
} else if (count === 2 && rounds.length === 1) { } else if (count === 2 && rounds.length === 1) {
rounds = [...rounds, createRound("Topeltvillak", 2, settings)]; rounds = [...rounds, createRound(m.kv_edit_r2(), 2, settings)];
settings.dailyDoublesPerRound = [ settings.dailyDoublesPerRound = [
settings.dailyDoublesPerRound[0], settings.dailyDoublesPerRound[0],
2, 2,
@ -221,7 +271,7 @@
} }
function removeTeam(id: string) { function removeTeam(id: string) {
if (teams.length <= 1) return; if (teams.length <= 2) return;
teams = teams.filter((t) => t.id !== id); teams = teams.filter((t) => t.id !== id);
} }
@ -264,7 +314,7 @@
rounds, rounds,
finalRound: settings.enableFinalRound ? finalRound : null, finalRound: settings.enableFinalRound ? finalRound : null,
}); });
window.open("/kuldvillak/play?view=projector", "_blank"); gameSession.openProjector();
await goto("/kuldvillak/play"); await goto("/kuldvillak/play");
} catch (err) { } catch (err) {
showToast("Failed to start game: " + err, "error"); showToast("Failed to start game: " + err, "error");
@ -292,10 +342,13 @@
defaultTimerSeconds: 5, defaultTimerSeconds: 5,
answerRevealSeconds: 5, answerRevealSeconds: 5,
}; };
teams = [{ id: generateId(), name: "Mängija 1", score: 0 }]; teams = [
{ id: generateId(), name: "Mängija 1", score: 0 },
{ id: generateId(), name: "Mängija 2", score: 0 },
];
rounds = [ rounds = [
createRound("Villak", 1, DEFAULT_SETTINGS), createRound(m.kv_edit_r1(), 1, DEFAULT_SETTINGS),
createRound("Topeltvillak", 2, DEFAULT_SETTINGS), createRound(m.kv_edit_r2(), 2, DEFAULT_SETTINGS),
]; ];
finalRound = { category: "", question: "", answer: "" }; finalRound = { category: "", question: "", answer: "" };
gameName = ""; gameName = "";
@ -423,6 +476,7 @@
<svelte:head> <svelte:head>
<title>{m.kv_edit_title()} - Kuldvillak</title> <title>{m.kv_edit_title()} - Kuldvillak</title>
<link rel="icon" href="/kuldvillak_favicon.svg" type="image/svg+xml" />
</svelte:head> </svelte:head>
<!-- Main Layout --> <!-- Main Layout -->
@ -547,8 +601,13 @@
<h2 class="kv-h3 text-kv-white m-0"> <h2 class="kv-h3 text-kv-white m-0">
{m.kv_edit_settings_teams()} {m.kv_edit_settings_teams()}
</h2> </h2>
<div class="flex gap-2"> <div class="flex flex-wrap gap-2">
<!-- ... (no changes) --> <KvButtonSecondary onclick={() => (showRulesModal = true)}>
{m.kv_edit_rules()}
</KvButtonSecondary>
<KvButtonSecondary onclick={() => (showHowToModal = true)}>
{m.kv_edit_how_to()}
</KvButtonSecondary>
</div> </div>
</div> </div>
@ -804,7 +863,7 @@
class="font-kv-body text-[28px] uppercase kv-shadow-text m-0" class="font-kv-body text-[28px] uppercase kv-shadow-text m-0"
> >
<span class="text-kv-white" <span class="text-kv-white"
>{ri === 0 ? "Villak" : "Topeltvillak"}</span >{ri === 0 ? m.kv_edit_r1() : m.kv_edit_r2()}</span
> >
<span class="text-kv-yellow text-xl ml-2" <span class="text-kv-yellow text-xl ml-2"
>({m.kv_edit_dd_count()} >({m.kv_edit_dd_count()}
@ -867,7 +926,7 @@
settings.dailyDoublesPerRound[editingQuestion.roundIndex] ?? 1} settings.dailyDoublesPerRound[editingQuestion.roundIndex] ?? 1}
{@const currentDD = countDailyDoubles(editingQuestion.roundIndex)} {@const currentDD = countDailyDoubles(editingQuestion.roundIndex)}
<div <div
class="fixed inset-0 bg-kv-black/80 flex items-center justify-center z-50 p-2 md:p-8" class="fixed inset-0 bg-kv-background/50 flex items-center justify-center z-50 p-2 md:p-8"
onclick={(e) => onclick={(e) =>
e.target === e.currentTarget && handleQuestionCloseClick()} e.target === e.currentTarget && handleQuestionCloseClick()}
onkeydown={(e) => e.key === "Escape" && handleQuestionCloseClick()} onkeydown={(e) => e.key === "Escape" && handleQuestionCloseClick()}
@ -975,7 +1034,7 @@
<!-- Final Question Modal --> <!-- Final Question Modal -->
{#if editingFinalQuestion} {#if editingFinalQuestion}
<div <div
class="fixed inset-0 bg-kv-black/80 flex items-center justify-center z-50 p-2 md:p-8" class="fixed inset-0 bg-kv-background/50 flex items-center justify-center z-50 p-2 md:p-8"
onclick={(e) => e.target === e.currentTarget && handleFinalCloseClick()} onclick={(e) => e.target === e.currentTarget && handleFinalCloseClick()}
onkeydown={(e) => e.key === "Escape" && handleFinalCloseClick()} onkeydown={(e) => e.key === "Escape" && handleFinalCloseClick()}
role="dialog" role="dialog"
@ -1105,3 +1164,16 @@
confirmText={m.kv_edit_reset()} confirmText={m.kv_edit_reset()}
onconfirm={resetGame} onconfirm={resetGame}
/> />
<!-- Tutorial Modals -->
<TutorialModal
bind:open={showRulesModal}
title={m.kv_edit_rules()}
slides={rulesSlides}
/>
<TutorialModal
bind:open={showHowToModal}
title={m.kv_edit_how_to()}
slides={howToSlides}
/>

@ -3,6 +3,10 @@
import { gameSession } from "$lib/stores/gameSession.svelte"; import { gameSession } from "$lib/stores/gameSession.svelte";
import ProjectorView from "./ProjectorView.svelte"; import ProjectorView from "./ProjectorView.svelte";
import ModeratorView from "./ModeratorView.svelte"; import ModeratorView from "./ModeratorView.svelte";
import {
KvButtonSecondary,
KvSpinner,
} from "$lib/components/kuldvillak/ui";
import * as m from "$lib/paraglide/messages"; import * as m from "$lib/paraglide/messages";
// Get view from URL query param // Get view from URL query param
@ -14,25 +18,54 @@
</svelte:head> </svelte:head>
{#if !gameSession.state} {#if !gameSession.state}
<div class="h-screen w-screen flex items-center justify-center bg-kv-black"> {#if view === "projector"}
<div class="text-center font-[family-name:var(--kv-font-button)]"> <!-- Projector Loading Screen -->
<div class="animate-pulse mb-4"> <div class="h-screen w-screen bg-kv-black p-4 md:p-8">
<div class="text-6xl">🎮</div> <div
</div> class="w-full h-full bg-kv-blue flex flex-col items-center justify-center gap-4 md:gap-8 overflow-hidden px-4"
<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()} <KvSpinner class="w-20 h-20 md:w-32 md:h-32" />
</a> <p
class="font-kv-body text-2xl md:text-5xl text-kv-white uppercase kv-shadow-text text-center"
>
{m.kv_play_loading()}
</p>
<p
class="font-kv-body text-sm md:text-xl text-kv-white uppercase kv-shadow-text text-center"
>
{m.kv_play_loading_hint()}
</p>
<a href="/kuldvillak/edit">
<KvButtonSecondary>
{m.kv_play_go_to_editor()}
</KvButtonSecondary>
</a>
</div>
</div>
{:else}
<!-- Moderator Loading Screen -->
<div
class="h-screen w-screen flex items-center justify-center bg-kv-black"
>
<div class="text-center">
<KvSpinner class="w-16 h-16 mx-auto mb-4" />
<p
class="font-kv-body text-kv-white text-2xl mb-4 uppercase kv-shadow-text"
>
{m.kv_play_loading()}
</p>
<p class="font-kv-body text-gray-400 text-sm mb-4 uppercase">
{m.kv_play_loading_hint()}
</p>
<a
href="/kuldvillak/edit"
class="text-kv-yellow underline uppercase font-kv-body"
>
{m.kv_play_go_to_editor()}
</a>
</div>
</div> </div>
</div> {/if}
{:else if view === "projector"} {:else if view === "projector"}
<ProjectorView /> <ProjectorView />
{:else} {:else}

@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { gameSession } from "$lib/stores/gameSession.svelte"; import { gameSession } from "$lib/stores/gameSession.svelte";
import { goto } from "$app/navigation";
import { onMount } from "svelte"; import { onMount } from "svelte";
import * as m from "$lib/paraglide/messages"; import * as m from "$lib/paraglide/messages";
import { import {
@ -22,7 +23,7 @@
let showEndGameConfirm = $state(false); let showEndGameConfirm = $state(false);
function openProjector() { function openProjector() {
window.open("/kuldvillak/play?view=projector", "kuldvillak-projector"); gameSession.openProjector();
} }
function openSettings() { function openSettings() {
@ -30,11 +31,31 @@
} }
// Local state // Local state
let wagerInput = $state(0); let wagerInput = $state(5);
let scoreAdjustment = $state(10); let scoreAdjustment = $state(10);
let introDelayComplete = $state(false);
let prevIntroPhase = $state<string | null>(null);
// Derived state // Derived state
let session = $derived(gameSession.state); let session = $derived(gameSession.state);
// Track intro-categories initial delay (3 seconds before first category shows)
$effect(() => {
const phase = session?.phase;
if (
phase === "intro-categories" &&
prevIntroPhase !== "intro-categories"
) {
// Just entered intro-categories, start 3s delay
introDelayComplete = false;
setTimeout(() => {
introDelayComplete = true;
}, 3000);
} else if (phase !== "intro-categories") {
introDelayComplete = false;
}
prevIntroPhase = phase ?? null;
});
let currentRound = $derived(gameSession.currentRound); let currentRound = $derived(gameSession.currentRound);
let questionData = $derived(gameSession.currentQuestionData); let questionData = $derived(gameSession.currentQuestionData);
@ -150,12 +171,22 @@
<KvButtonPrimary onclick={openProjector}> <KvButtonPrimary onclick={openProjector}>
{m.kv_play_open_projector()} {m.kv_play_open_projector()}
</KvButtonPrimary> </KvButtonPrimary>
{#if session.settings.enableFinalRound && session.finalRound && session.phase === "board"} {#if session.phase === "board"}
<KvButtonPrimary {#if session.rounds.length > 1 && session.currentRoundIndex === 0}
onclick={() => gameSession.goToFinalRound()} <!-- Two rounds and on first round: show Next Round button -->
> <KvButtonPrimary
{m.kv_play_go_to_final()} onclick={() => gameSession.nextRound()}
</KvButtonPrimary> >
{m.kv_play_next_round()}
</KvButtonPrimary>
{:else if session.settings.enableFinalRound && session.finalRound}
<!-- One round OR on second round: show Final Round button -->
<KvButtonPrimary
onclick={() => gameSession.goToFinalRound()}
>
{m.kv_play_go_to_final()}
</KvButtonPrimary>
{/if}
{/if} {/if}
<KvButtonSecondary <KvButtonSecondary
onclick={() => (showEndGameConfirm = true)} onclick={() => (showEndGameConfirm = true)}
@ -273,11 +304,9 @@
> >
{#if session.phase === "intro"} {#if session.phase === "intro"}
<span <span
class="font-kv-body text-4xl text-kv-yellow uppercase kv-shadow-text" class="font-kv-body text-4xl md:text-6xl text-kv-yellow uppercase kv-shadow-text"
> >
{session.currentRoundIndex === 0 Kuldvillak
? "Villak"
: "Topeltvillak"}
</span> </span>
<div class="flex gap-4"> <div class="flex gap-4">
<KvButtonPrimary <KvButtonPrimary
@ -292,7 +321,16 @@
</KvButtonSecondary> </KvButtonSecondary>
</div> </div>
{:else if session.phase === "intro-categories"} {:else if session.phase === "intro-categories"}
{#if currentRound?.categories[session.introCategoryIndex]} {#if !introDelayComplete && session.introCategoryIndex === 0}
<!-- Initial 3s delay - show Villak/Topeltvillak -->
<span
class="font-kv-body text-4xl md:text-6xl text-kv-yellow uppercase kv-shadow-text"
>
{session.currentRoundIndex === 0
? "Villak"
: "Topeltvillak"}
</span>
{:else if currentRound?.categories[session.introCategoryIndex]}
<span <span
class="font-kv-body text-4xl md:text-6xl text-kv-white uppercase kv-shadow-text" class="font-kv-body text-4xl md:text-6xl text-kv-white uppercase kv-shadow-text"
> >
@ -347,8 +385,8 @@
class="bg-kv-blue flex items-center justify-center p-2 cursor-pointer border-none transition-opacity hover:brightness-110 disabled:cursor-not-allowed box-border class="bg-kv-blue flex items-center justify-center p-2 cursor-pointer border-none transition-opacity hover:brightness-110 disabled:cursor-not-allowed box-border
{q.isRevealed ? 'opacity-50' : ''} {q.isRevealed ? 'opacity-50' : ''}
{q.isDailyDouble && !q.isRevealed {q.isDailyDouble && !q.isRevealed
? 'border-4 border-kv-yellow' ? 'ring-4 ring-inset ring-kv-yellow'
: 'border-4 border-transparent'}" : ''}"
disabled={q.isRevealed} disabled={q.isRevealed}
onclick={() => selectQuestion(ci, qi)} onclick={() => selectQuestion(ci, qi)}
> >
@ -377,7 +415,7 @@
<div class="flex flex-wrap gap-4 justify-center"> <div class="flex flex-wrap gap-4 justify-center">
{#each session.teams as team} {#each session.teams as team}
<button <button
class="bg-kv-blue border-4 border-black box-border font-kv-body text-xl md:text-2xl px-6 py-3 cursor-pointer uppercase hover:opacity-80 class="bg-kv-blue border-4 border-black box-border font-kv-body text-xl md:text-2xl px-6 py-3 cursor-pointer uppercase hover:opacity-80 kv-shadow-text kv-shadow-button
{session.activeTeamId === team.id {session.activeTeamId === team.id
? 'border-kv-yellow text-kv-yellow' ? 'border-kv-yellow text-kv-yellow'
: 'text-kv-white'}" : 'text-kv-white'}"
@ -392,6 +430,18 @@
{@const activeTeam = session.teams.find( {@const activeTeam = session.teams.find(
(t) => t.id === session.activeTeamId, (t) => t.id === session.activeTeamId,
)} )}
{@const maxWager = Math.max(
activeTeam?.score ?? 0,
Math.max(...(session?.settings.pointValues ?? [50])) *
(currentRound?.pointMultiplier ?? 1),
)}
{@const isValidWager =
wagerInput >= 5 && wagerInput <= maxWager}
<div
class="font-kv-body text-lg text-kv-yellow uppercase kv-shadow-text"
>
Min: 5€ — Max: {maxWager}
</div>
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<span <span
class="font-kv-body text-xl md:text-2xl text-kv-white uppercase kv-shadow-text" class="font-kv-body text-xl md:text-2xl text-kv-white uppercase kv-shadow-text"
@ -400,15 +450,17 @@
</span> </span>
<input <input
type="number" type="number"
class="bg-transparent border-4 border-black box-border font-kv-body text-xl md:text-2xl text-kv-white text-center px-4 py-2 w-32" class="bg-transparent border-4 border-black box-border font-kv-body text-xl md:text-2xl text-kv-white text-center px-4 py-2 w-32
{!isValidWager ? 'border-red-500' : ''}"
min="5" min="5"
max={Math.max( max={maxWager}
activeTeam?.score ?? 0, step="5"
questionData?.question.points ?? 500,
)}
bind:value={wagerInput} bind:value={wagerInput}
/> />
<KvButtonPrimary onclick={confirmDailyDoubleWager}> <KvButtonPrimary
onclick={confirmDailyDoubleWager}
disabled={!isValidWager}
>
{m.kv_play_confirm()} {m.kv_play_confirm()}
</KvButtonPrimary> </KvButtonPrimary>
</div> </div>
@ -803,7 +855,12 @@
> >
{m.kv_play_game_over()}! {m.kv_play_game_over()}!
</span> </span>
<KvButtonPrimary onclick={() => gameSession.clearSession()}> <KvButtonPrimary
onclick={() => {
gameSession.clearSession();
goto("/kuldvillak/edit");
}}
>
{m.kv_play_finish()} {m.kv_play_finish()}
</KvButtonPrimary> </KvButtonPrimary>
</div> </div>

@ -23,6 +23,13 @@
let finalAnimPhase = $state<"waiting" | "expanding" | "shown" | "none">( let finalAnimPhase = $state<"waiting" | "expanding" | "shown" | "none">(
"none", "none",
); );
let ddQuestionAnimPhase = $state<
"waiting" | "expanding" | "shown" | "none"
>("none");
let ddRevealAnimPhase = $state<"waiting" | "spinning" | "shown" | "none">(
"none",
);
let isDailyDoubleQuestion = $state(false);
let prevPhase = $state<string | null>(null); let prevPhase = $state<string | null>(null);
// Intro category animation state (used for both regular and final round) // Intro category animation state (used for both regular and final round)
@ -75,15 +82,32 @@
const currentPhase = session?.phase; const currentPhase = session?.phase;
if (currentPhase === "question" && prevPhase !== "question") { if (currentPhase === "question" && prevPhase !== "question") {
animationPhase = "waiting"; // Check if coming from daily-double phase
setTimeout(() => { if (prevPhase === "daily-double") {
animationPhase = "expanding"; isDailyDoubleQuestion = true;
}, 1000); ddQuestionAnimPhase = "waiting";
setTimeout(() => { setTimeout(() => {
animationPhase = "shown"; ddQuestionAnimPhase = "expanding";
}, 2000); }, 1000);
setTimeout(() => {
ddQuestionAnimPhase = "shown";
}, 2000);
} else {
isDailyDoubleQuestion = false;
animationPhase = "waiting";
setTimeout(() => {
animationPhase = "expanding";
}, 1000);
setTimeout(() => {
animationPhase = "shown";
}, 2000);
}
} else if (currentPhase !== "question") { } else if (currentPhase !== "question") {
animationPhase = "none"; animationPhase = "none";
ddQuestionAnimPhase = "none";
if (currentPhase !== "daily-double") {
isDailyDoubleQuestion = false;
}
} }
// Final question animation - wait 1s on Kuldvillak, then expand from center // Final question animation - wait 1s on Kuldvillak, then expand from center
@ -102,34 +126,53 @@
finalAnimPhase = "none"; finalAnimPhase = "none";
} }
// Daily Double reveal animation - wait 1s, then spin in from center
if (currentPhase === "daily-double" && prevPhase !== "daily-double") {
ddRevealAnimPhase = "waiting";
setTimeout(() => {
ddRevealAnimPhase = "spinning";
}, 1000); // Wait 1 second before spinning
setTimeout(() => {
ddRevealAnimPhase = "shown";
}, 2000); // 1s wait + 1s spin
} else if (currentPhase !== "daily-double") {
ddRevealAnimPhase = "none";
}
prevPhase = currentPhase ?? null; prevPhase = currentPhase ?? null;
}); });
// Watch for intro category changes to trigger animation sequence // Watch for intro category changes to trigger animation sequence
// Timing: 500ms on villak, 500ms dissolve ease-out, 1500ms shown, 500ms push left linear // Timing: 3s initial delay (first category only), then 500ms on villak, 500ms dissolve ease-out, 1500ms shown, 500ms push left linear
$effect(() => { $effect(() => {
const catIndex = session?.introCategoryIndex ?? -1; const catIndex = session?.introCategoryIndex ?? -1;
const isFirstCategory = catIndex === 0 && prevIntroCatIndex === -1;
if ( if (
session?.phase === "intro-categories" && session?.phase === "intro-categories" &&
catIndex >= 0 && catIndex >= 0 &&
catIndex !== prevIntroCatIndex catIndex !== prevIntroCatIndex
) { ) {
// Initial 3 second delay for first category only
const initialDelay = isFirstCategory ? 3000 : 0;
// Start animation sequence for new category // Start animation sequence for new category
introAnimPhase = "villak"; // Stay on Villak for 500ms setTimeout(() => {
introAnimPhase = "villak"; // Stay on Villak for 500ms
}, initialDelay);
setTimeout(() => { setTimeout(() => {
introAnimPhase = "fade-in"; // 500ms dissolve introAnimPhase = "fade-in"; // 500ms dissolve
}, 500); // Start fade after 500ms on villak }, initialDelay + 500); // Start fade after 500ms on villak
setTimeout(() => { setTimeout(() => {
introAnimPhase = "shown"; introAnimPhase = "shown";
}, 1000); // 500ms villak + 500ms fade }, initialDelay + 1000); // 500ms villak + 500ms fade
setTimeout(() => { setTimeout(() => {
introAnimPhase = "push-out"; // 500ms push left introAnimPhase = "push-out"; // 500ms push left
}, 2500); // 500ms villak + 500ms fade + 1500ms shown }, initialDelay + 2500); // 500ms villak + 500ms fade + 1500ms shown
setTimeout(() => { setTimeout(() => {
introAnimPhase = "none"; introAnimPhase = "none";
gameSession.nextIntroCategory(); gameSession.nextIntroCategory();
}, 3000); // Total: 500ms + 500ms + 1500ms + 500ms = 3000ms }, initialDelay + 3000); // Total: 500ms + 500ms + 1500ms + 500ms = 3000ms
} else if ( } else if (
session?.phase !== "intro-categories" && session?.phase !== "intro-categories" &&
session?.phase !== "final-category" session?.phase !== "final-category"
@ -255,7 +298,10 @@
> >
{#if session.phase === "intro"} {#if session.phase === "intro"}
<!-- Home Screen with grid background - show Villak if categories introduced --> <!-- Home Screen with grid background - show Villak if categories introduced -->
<div class="flex-1 flex items-center justify-center intro-grid-bg"> <div
class="flex-1 flex items-center justify-center intro-grid-bg"
class:animated={!session.categoriesIntroduced}
>
{#if session.categoriesIntroduced} {#if session.categoriesIntroduced}
<KvGameLogo <KvGameLogo
variant={roundVariant} variant={roundVariant}
@ -381,8 +427,8 @@
</div> </div>
</div> </div>
<!-- Question Overlay - Full screen over board --> <!-- Question Overlay - Full screen over board (normal questions) -->
{#if session.phase === "question" && questionData && animationPhase !== "none"} {#if session.phase === "question" && questionData && animationPhase !== "none" && !isDailyDoubleQuestion}
{@const pos = startPosition()} {@const pos = startPosition()}
<div <div
class="absolute bg-kv-black flex flex-col expand-overlay {animationPhase}" class="absolute bg-kv-black flex flex-col expand-overlay {animationPhase}"
@ -423,12 +469,12 @@
{#if questionData.question.imageUrl} {#if questionData.question.imageUrl}
<!-- Image question - show only image --> <!-- Image question - show only image -->
<div <div
class="h-full max-w-full flex items-center justify-center" class="w-full h-full flex items-center justify-center"
> >
<img <img
src={questionData.question.imageUrl} src={questionData.question.imageUrl}
alt="Question" alt="Question"
class="max-h-full max-w-full object-contain" class="w-full h-full object-contain"
/> />
</div> </div>
{:else} {:else}
@ -451,23 +497,156 @@
</div> </div>
</div> </div>
{/if} {/if}
{:else if session.phase === "daily-double"} <!-- Daily Double Question Overlay - Hõbevillak background with center expand -->
<!-- Daily Double (Hõbevillak) Splash - Grid background with logo --> {#if session.phase === "question" && questionData && isDailyDoubleQuestion}
<div <!-- Hõbevillak background -->
class="flex-1 flex flex-col items-center justify-center intro-grid-bg gap-8" <div
> class="absolute inset-0 flex flex-col items-center justify-center intro-grid-bg"
<KvGameLogo >
variant="hobevillak" <KvGameLogo
class="w-[60vw] max-w-[885px] h-auto drop-shadow-[6px_6px_4px_rgba(0,0,0,0.5)] animate-pulse" variant="hobevillak"
/> class="w-[60vw] max-w-[885px] h-auto drop-shadow-[0_0_20px_rgba(192,192,192,0.8)]"
{#if session.dailyDoubleWager} />
</div>
<!-- Question overlay expanding from center -->
{#if ddQuestionAnimPhase !== "none"}
<div <div
class="font-kv-body text-kv-white text-[clamp(32px,4vw,64px)] uppercase kv-shadow-text" class="absolute bg-kv-black flex flex-col expand-overlay-center {ddQuestionAnimPhase}"
> >
{m.kv_play_wager()}: {session.dailyDoubleWager} <!-- Players row - top -->
<div
class="grid gap-2 lg:gap-4 shrink-0"
style="grid-template-columns: repeat({session.teams
.length}, 1fr);"
>
{#each session.teams as team}
{@const isAnswering =
session.activeTeamId === team.id}
<div
class="bg-kv-blue px-2 py-4 lg:px-4 lg:py-6 flex flex-col items-center justify-between overflow-hidden font-kv-body text-kv-white uppercase text-center border-8 {isAnswering
? 'border-kv-yellow'
: 'border-transparent'}"
>
<span
class="text-[clamp(24px,2.5vw,36px)] kv-shadow-text {team.score <
0
? 'text-kv-red'
: ''}">{team.score}</span
>
<span
class="text-[clamp(24px,2.5vw,36px)] kv-shadow-text"
>{team.name}</span
>
</div>
{/each}
</div>
<!-- Question area - fills remaining space -->
<div
class="flex-1 flex items-center justify-center p-4 overflow-hidden bg-kv-blue"
>
{#if questionData.question.imageUrl}
<!-- Image question - show only image -->
<div
class="w-full h-full flex items-center justify-center"
>
<img
src={questionData.question.imageUrl}
alt="Question"
class="w-full h-full object-contain"
/>
</div>
{:else}
<!-- Text question -->
<div
class="font-kv-question text-center text-[clamp(48px,6vw,96px)] leading-[1] uppercase"
style="transform: scaleX(0.9225);"
>
{#if session.showAnswer}
<div
class="text-kv-yellow kv-shadow-text"
>
{questionData.question.answer}
</div>
{:else}
<div
class="text-kv-white kv-shadow-text"
>
{questionData.question.question}
</div>
{/if}
</div>
{/if}
</div>
</div> </div>
{/if} {/if}
{/if}
{:else if session.phase === "daily-double"}
<!-- Daily Double (Hõbevillak) - Game board background with spinning overlay -->
<!-- Game Board Background (same as board phase) -->
<div class="flex-1 flex flex-col p-4 lg:p-8 gap-4 h-full min-h-0">
<!-- Category Headers -->
<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}
<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"
>
{cat.name || "???"}
</div>
{/each}
</div>
<!-- Question Grid -->
<div
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};"
>
<span
class="font-kv-price text-kv-yellow text-[clamp(24px,6vw,128px)] kv-shadow-price
{q.isRevealed ? 'opacity-0' : ''}"
>
{q.points}<span class="text-[0.75em]"
>€</span
>
</span>
</div>
{/each}
{/each}
</div>
</div> </div>
<!-- Hõbevillak overlay spinning in from center -->
{#if ddRevealAnimPhase !== "none"}
<div
class="absolute inset-0 flex flex-col items-center justify-center intro-grid-bg dd-spin-overlay {ddRevealAnimPhase}"
>
<KvGameLogo
variant="hobevillak"
class="w-[60vw] max-w-[885px] h-auto drop-shadow-[0_0_20px_rgba(192,192,192,0.8)]"
/>
{#if session.dailyDoubleWager}
<div
class="font-kv-body text-kv-white text-[clamp(32px,4vw,64px)] uppercase kv-shadow-text mt-8"
>
{m.kv_play_wager()}: {session.dailyDoubleWager}
</div>
{/if}
</div>
{/if}
{:else if session.phase === "final-intro"} {:else if session.phase === "final-intro"}
<!-- Final Round Intro - KULDVILLAK screen --> <!-- Final Round Intro - KULDVILLAK screen -->
<div class="flex-1 flex items-center justify-center intro-grid-bg"> <div class="flex-1 flex items-center justify-center intro-grid-bg">
@ -649,7 +828,9 @@
</div> </div>
{:else if session.phase === "finished"} {:else if session.phase === "finished"}
<!-- Game Over - Back to Kuldvillak screen --> <!-- Game Over - Back to Kuldvillak screen -->
<div class="flex-1 flex items-center justify-center intro-grid-bg"> <div
class="flex-1 flex items-center justify-center intro-grid-bg animated"
>
<KvGameLogo <KvGameLogo
variant="kuldvillak" variant="kuldvillak"
class="w-[80vw] max-w-[1536px] h-auto drop-shadow-[6px_6px_4px_rgba(0,0,0,0.5)]" class="w-[80vw] max-w-[1536px] h-auto drop-shadow-[6px_6px_4px_rgba(0,0,0,0.5)]"
@ -660,21 +841,27 @@
{/if} {/if}
<style> <style>
/* Intro grid background - matches homepage pattern */ /* Intro grid background - flat scrolling pattern */
.intro-grid-bg { .intro-grid-bg {
background-color: var(--kv-blue); background-color: var(--kv-blue);
background-image: linear-gradient( background-image: linear-gradient(#001a45 2px, transparent 2px),
color-mix(in srgb, var(--kv-background) 50%, transparent) 2px, linear-gradient(90deg, #001a45 2px, transparent 2px);
transparent 2px
),
linear-gradient(
90deg,
color-mix(in srgb, var(--kv-background) 50%, transparent) 2px,
transparent 2px
);
background-size: 60px 60px; background-size: 60px 60px;
} }
.intro-grid-bg.animated {
animation: grid-scroll 2s linear infinite;
}
@keyframes grid-scroll {
from {
background-position: 0 0;
}
to {
background-position: -60px 0;
}
}
/* Intro category animation - 500ms dissolve ease-out */ /* Intro category animation - 500ms dissolve ease-out */
.intro-category { .intro-category {
opacity: 0; opacity: 0;
@ -878,6 +1065,42 @@
padding-bottom: clamp(4px, 1.5cqh, 16px); padding-bottom: clamp(4px, 1.5cqh, 16px);
} }
/* Daily Double spin overlay - starts flipped and spins while expanding */
.dd-spin-overlay {
transform: scale(0.1) rotateY(180deg);
transform-origin: center center;
opacity: 0;
visibility: hidden;
}
.dd-spin-overlay.waiting {
opacity: 0;
visibility: hidden;
transform: scale(0.1) rotateY(180deg);
}
.dd-spin-overlay.spinning {
opacity: 1;
visibility: visible;
animation: dd-spin-in 1s linear forwards;
}
.dd-spin-overlay.shown {
opacity: 1;
visibility: visible;
transform: scale(1) rotateY(0deg);
}
@keyframes dd-spin-in {
0% {
transform: scale(0.1) rotateY(180deg);
opacity: 0;
}
20% {
opacity: 1;
}
100% {
transform: scale(1) rotateY(0deg);
opacity: 1;
}
}
/* Team answering - flash 3 times then stay white */ /* Team answering - flash 3 times then stay white */
.team-answering { .team-answering {
animation: animation:

@ -0,0 +1,3 @@
<svg width="128" height="128" viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.05957 1.5L11.3781 12.5V115.5L8.05957 126.5H45.9855L42.667 115.5V73.6719L82.0151 126.5H119.941L67.3188 59L115.674 1.50006H77.7485L42.667 50.5001V12.5L45.9855 1.50006L8.05957 1.5Z" fill="#FFAB00"/>
</svg>

After

Width:  |  Height:  |  Size: 355 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 253 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 253 KiB

@ -0,0 +1,13 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['src/**/*.{test,spec}.{js,ts}'],
environment: 'jsdom',
globals: true,
alias: {
'$lib': '/src/lib',
'$app': '/src/app',
},
},
});
Loading…
Cancel
Save