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.

This commit is contained in:
AlacrisDevs
2025-12-08 12:32:43 +02:00
parent 0955d6ca65
commit 4e7ecb5397
45 changed files with 3662 additions and 208 deletions

View File

@@ -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

View File

@@ -41,7 +41,7 @@
{#if open}
<!-- Backdrop -->
<div
class="fixed inset-0 bg-kv-background/70 z-[100]"
class="fixed inset-0 bg-kv-background/50 z-[100]"
onclick={handleCancel}
role="button"
tabindex="-1"
@@ -60,7 +60,9 @@
>
{title}
</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}
</p>
<div class="flex gap-3 w-full">

View File

@@ -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}

View File

@@ -149,30 +149,59 @@
</span>
<div class="flex gap-4">
<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'
? 'opacity-100'
: 'opacity-60 hover:opacity-80'}"
onclick={() => switchLanguage("et")}
aria-label="Eesti"
>
<img
src="/icons/et.svg"
alt="Eesti"
class="w-full h-full object-cover"
/>
<svg
viewBox="0 0 36 36"
class="w-full h-full"
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
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'
? 'opacity-100'
: 'opacity-60 hover:opacity-80'}"
onclick={() => switchLanguage("en")}
aria-label="English"
>
<img
src="/icons/en.svg"
alt="English"
class="w-full h-full object-cover"
/>
<svg
viewBox="0 0 36 36"
class="w-full h-full"
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>
</div>
</div>

View File

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

View File

@@ -7,6 +7,8 @@
onclick?: () => void;
children: Snippet;
class?: string;
reload?: boolean;
ariaLabel?: string;
}
let {
@@ -15,18 +17,32 @@
onclick,
children,
class: className = "",
reload = false,
ariaLabel,
}: Props = $props();
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>
{#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>
</a>
{: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>
</button>
{/if}

View File

@@ -7,6 +7,8 @@
onclick?: () => void;
children: Snippet;
class?: string;
reload?: boolean;
ariaLabel?: string;
}
let {
@@ -15,18 +17,32 @@
onclick,
children,
class: className = "",
reload = false,
ariaLabel,
}: Props = $props();
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>
{#if href && !disabled}
<a {href} class="{baseClasses} {className}">
{@render children()}
<a
{href}
class="{baseClasses} {className}"
data-sveltekit-reload={reload ? "" : undefined}
aria-label={ariaLabel}
role="button"
>
<span class="kv-shadow-text">{@render children()}</span>
</a>
{:else}
<button class="{baseClasses} {className}" {disabled} {onclick}>
{@render children()}
<button
class="{baseClasses} {className}"
{disabled}
{onclick}
aria-label={ariaLabel}
aria-disabled={disabled}
>
<span class="kv-shadow-text">{@render children()}</span>
</button>
{/if}

View File

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

View File

@@ -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>

View File

@@ -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}

View File

@@ -11,5 +11,9 @@ export { default as KvNumberInput } from './KvNumberInput.svelte';
export { default as KvEditCard } from './KvEditCard.svelte';
// Branding
export { default as KvLogo } 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';

View File

@@ -1,7 +1,7 @@
{
"name": "9. Klassi Viktoriini",
"name": "9. Klassi Viktoriin",
"settings": {
"numberOfRounds": 1,
"numberOfRounds": 2,
"pointValuePreset": "round1",
"pointValues": [
10,
@@ -14,24 +14,30 @@
"categoriesPerRound": 6,
"questionsPerCategory": 5,
"dailyDoublesPerRound": [
1
1,
2
],
"enableFinalRound": true,
"enableSoundEffects": true,
"allowNegativeScores": true,
"maxTeams": 6,
"defaultTimerSeconds": 15,
"defaultTimerSeconds": 5,
"answerRevealSeconds": 5
},
"teams": [
{
"id": "r5f48jjat",
"name": "Tiim 1",
"name": "Teet",
"score": 0
},
{
"id": "lnbeg51uo",
"name": "Tiim 2",
"name": "Kristjan",
"score": 0
},
{
"id": "x7k2mq9pf",
"name": "Eeva",
"score": 0
}
],
@@ -46,40 +52,40 @@
"questions": [
{
"id": "a11mxf6ra",
"question": "Mis aastal kuulutati välja Eesti Vabariik?",
"answer": "1918",
"question": "Sellel aastal kuulutati Pärnus välja Eesti Vabariigi iseseisvus",
"answer": "Mis on 1918?",
"points": 10,
"isDailyDouble": false,
"isRevealed": false
},
{
"id": "j7ztvxf7l",
"question": "Kes oli Eesti Vabariigi esimene riigivanem?",
"answer": "Konstantin Päts",
"question": "See mees oli Eesti Vabariigi esimene riigivanem ja hilisem president",
"answer": "Kes on Konstantin Päts?",
"points": 20,
"isDailyDouble": false,
"isRevealed": false
},
{
"id": "cwcx4jf7m",
"question": "Mis aastal toimus Laulev revolutsioon?",
"answer": "1988",
"question": "Sellel aastal toimus Laulev revolutsioon, mis viis Eesti taasiseseisvumiseni",
"answer": "Mis on 1988?",
"points": 30,
"isDailyDouble": false,
"isRevealed": false
},
{
"id": "7m6e1qdwx",
"question": "Mis oli Balti keti kuupäev 1989. aastal?",
"answer": "23. august",
"question": "Sellel kuupäeval 1989. aastal moodustasid eestlased, lätlased ja leedulased inimketi Tallinnast Vilniuseni",
"answer": "Mis on 23. august?",
"points": 40,
"isDailyDouble": false,
"isRevealed": false
},
{
"id": "mmudm4kjd",
"question": "Mis aastal toimus Jüriöö ülestõus?",
"answer": "1343",
"question": "Sellel aastal toimus Jüriöö ülestõus, kus eestlased tõusid Taani ülemvõimu vastu",
"answer": "Mis on 1343?",
"points": 50,
"isDailyDouble": false,
"isRevealed": false
@@ -92,40 +98,40 @@
"questions": [
{
"id": "zfg5pe8pl",
"question": "Mis on ruutjuur 144-st?",
"answer": "12",
"question": "Täpselt nii suur arv saadakse, kui võtta ruutjuur arvust 144",
"answer": "Mis on 12?",
"points": 10,
"isDailyDouble": false,
"isRevealed": false
},
{
"id": "ds2bmll5z",
"question": "Mis on Pythagorase teoreemi valem?",
"answer": "a² + b² = c²",
"question": "See valem kirjeldab täisnurkse kolmnurga külgede vahelist seost Pythagorase teoreemis",
"answer": "Mis on a² + b² = c²?",
"points": 20,
"isDailyDouble": false,
"isRevealed": false
},
{
"id": "6jkk4qkwo",
"question": "Mis on arvu pi (π) väärtus kahe komakohani?",
"answer": "3,14",
"question": "Täpselt selline on arvu pi (π) väärtus kahe komakohani ümardatuna",
"answer": "Mis on 3,14?",
"points": 30,
"isDailyDouble": false,
"isRevealed": false
},
{
"id": "h32bk4gll",
"question": "Kui kolmnurga alus on 8 cm ja kõrgus 6 cm, mis on pindala?",
"answer": "24 cm²",
"question": "Täpselt nii suur on kolmnurga pindala ruutsentimeetrites, kui selle alus on 8 cm ja kõrgus 6 cm",
"answer": "Mis on 24 cm²?",
"points": 40,
"isDailyDouble": true,
"isRevealed": false
},
{
"id": "a2vjrjbuo",
"question": "Lahenda võrrand: 3x + 7 = 22",
"answer": "x = 5",
"question": "Täpselt selline on x-i väärtus võrrandis 3x + 7 = 22",
"answer": "Mis on x = 5?",
"points": 50,
"isDailyDouble": false,
"isRevealed": false
@@ -138,40 +144,40 @@
"questions": [
{
"id": "a0tz6k2a5",
"question": "Mis on vee keemik valem?",
"answer": "H₂O",
"question": "See keemiline valem tähistab vett, mis koosneb kahest vesiniku ja ühest hapniku aatomist",
"answer": "Mis on H₂O?",
"points": 10,
"isDailyDouble": false,
"isRevealed": false
},
{
"id": "1kjss32t3",
"question": "Mitu planeeti on meie päikesesüsteemis?",
"answer": "8",
"question": "Täpselt nii palju planeete tiirleb meie päikesesüsteemis ümber Päikese",
"answer": "Mis on 8?",
"points": 20,
"isDailyDouble": false,
"isRevealed": false
},
{
"id": "4acp3zwgh",
"question": "Mis on fotosünteesi põhiprodukt?",
"answer": "Glükoos (suhkur) ja hapnik",
"question": "Need kaks ainet on fotosünteesi põhiproduktid, mida taimed valgusel toodavad",
"answer": "Mis on glükoos ja hapnik?",
"points": 30,
"isDailyDouble": false,
"isRevealed": false
},
{
"id": "fh1jn2z6f",
"question": "Mis element on perioodilisustabelis tähisega Fe?",
"answer": "Raud",
"question": "See keemiline element kannab perioodilisustabelis tähist Fe ja on üks levinumaid metalle",
"answer": "Mis on raud?",
"points": 40,
"isDailyDouble": false,
"isRevealed": false
},
{
"id": "wd96bi1d5",
"question": "Mis on DNA täisnimi?",
"answer": "Desoksüribonukleiinhape",
"question": "See on DNA täisnimi ehk molekul, mis kannab pärilikku informatsiooni",
"answer": "Mis on desoksüribonukleiinhape?",
"points": 50,
"isDailyDouble": false,
"isRevealed": false
@@ -184,40 +190,40 @@
"questions": [
{
"id": "he7in5nor",
"question": "Kes kirjutas eepose 'Kalevipoeg'?",
"answer": "Friedrich Reinhold Kreutzwald",
"question": "See Eesti arst ja kirjanik pani kokku rahvuseepose 'Kalevipoeg' rahvaluule põhjal",
"answer": "Kes on Friedrich Reinhold Kreutzwald?",
"points": 10,
"isDailyDouble": false,
"isRevealed": false
},
{
"id": "pafvsp6t9",
"question": "Mis on A. H. Tammsaare tuntuim romaan?",
"answer": "Tõde ja õigus",
"question": "See viieosaline romaan on A. H. Tammsaare tuntuim teos ja Eesti kirjanduse tippude hulgas",
"answer": "Mis on 'Tõde ja õigus'?",
"points": 20,
"isDailyDouble": false,
"isRevealed": false
},
{
"id": "rjjp0h8s4",
"question": "Kes kirjutas luuletuse 'Mu isamaa on minu arm'?",
"answer": "Lydia Koidula",
"question": "See Eesti luuletaja, keda kutsutakse 'koidulaulikuks', kirjutas luuletuse 'Mu isamaa on minu arm'",
"answer": "Kes on Lydia Koidula?",
"points": 30,
"isDailyDouble": false,
"isRevealed": false
},
{
"id": "n7o3vkkcn",
"question": "Mis on soneti traditsiooniline värsside arv?",
"answer": "14 rida",
"question": "Täpselt nii palju värsse ehk ridu on traditsioonilises sonetis",
"answer": "Mis on 14 rida?",
"points": 40,
"isDailyDouble": false,
"isRevealed": false
},
{
"id": "4hxuoa1fy",
"question": "Kes kirjutas romaani 'Kevade'?",
"answer": "Oskar Luts",
"question": "See Eesti kirjanik lõi armastatud romaani 'Kevade', mis räägib Paunvere koolipoistest",
"answer": "Kes on Oskar Luts?",
"points": 50,
"isDailyDouble": false,
"isRevealed": false
@@ -230,40 +236,40 @@
"questions": [
{
"id": "4qkzwwe7r",
"question": "Mis on Eesti pealinn?",
"answer": "Tallinn",
"question": "See linn on Eesti pealinn ja suurim linn, mis asub Soome lahe kaldal",
"answer": "Mis on Tallinn?",
"points": 10,
"isDailyDouble": false,
"isRevealed": false
},
{
"id": "7z3113es0",
"question": "Mis on Eesti kõrgeim mägi?",
"answer": "Suur Munamägi",
"question": "See mägi Võrumaal on Eesti ja kogu Baltimaade kõrgeim punkt",
"answer": "Mis on Suur Munamägi?",
"points": 20,
"isDailyDouble": false,
"isRevealed": false
},
{
"id": "so7tqkk16",
"question": "Mis on maailma suurim ookean?",
"answer": "Vaikne ookean",
"question": "See ookean on maailma suurim ja katab rohkem kui kolmandiku Maa pinnast",
"answer": "Mis on Vaikne ookean?",
"points": 30,
"isDailyDouble": false,
"isRevealed": false
},
{
"id": "h3gyk1yi3",
"question": "Mis riik on pindalalt maailma suurim?",
"answer": "Venemaa",
"question": "See riik on pindalalt maailma suurim, ulatudes Euroopast Aasiani",
"answer": "Mis on Venemaa?",
"points": 40,
"isDailyDouble": false,
"isRevealed": false
},
{
"id": "385aprc7p",
"question": "Mis on Eesti suurim saar?",
"answer": "Saaremaa",
"question": "See saar on Eesti suurim ja asub Lääne-Eesti saarestikus",
"answer": "Mis on Saaremaa?",
"points": 50,
"isDailyDouble": false,
"isRevealed": false
@@ -276,40 +282,40 @@
"questions": [
{
"id": "jzcjmb4ef",
"question": "Mis värvid on Eesti lipul?",
"answer": "Sinine, must, valge",
"question": "Need kolm värvi moodustavad Eesti lipu trikoloori, mis kehtestati juba 19. sajandil",
"answer": "Mis on sinine, must ja valge?",
"points": 10,
"isDailyDouble": false,
"isRevealed": false
},
{
"id": "n86mk1kg0",
"question": "Mis on Eesti rahvuslind?",
"answer": "Suitsupääsuke",
"question": "See väike lind kahestunud sabaga on Eesti rahvuslind ja sümboliseerib õnne",
"answer": "Mis on suitsupääsuke?",
"points": 20,
"isDailyDouble": false,
"isRevealed": false
},
{
"id": "04e5zactm",
"question": "Mis aastal liitus Eesti Euroopa Liiduga?",
"answer": "2004",
"question": "Sellel aastal liitus Eesti koos üheksa teise riigiga Euroopa Liiduga",
"answer": "Mis on 2004?",
"points": 30,
"isDailyDouble": false,
"isRevealed": false
},
{
"id": "xwhxh99j6",
"question": "Mis on Eesti rahvuslill?",
"answer": "Rukkilill",
"question": "See sinine põllulill on Eesti rahvuslill ja sümboliseerib igapäevast leiba",
"answer": "Mis on rukkilill?",
"points": 40,
"isDailyDouble": false,
"isRevealed": false
},
{
"id": "n54fmwmg8",
"question": "Mis aastal võttis Eesti kasutusele euro?",
"answer": "2011",
"question": "Sellel aastal võttis Eesti kasutusele euro, olles 17. euroala riik",
"answer": "Mis on 2011?",
"points": 50,
"isDailyDouble": false,
"isRevealed": false
@@ -318,11 +324,294 @@
}
],
"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": {
"category": "EESTI KULTUUR",
"question": "Mis aastal võitis Eesti esimest korda Eurovisiooni lauluvõistluse ja mis laul see oli?",
"answer": "2001. aastal lauluga 'Everybody' (Tanel Padar, Dave Benton ja 2XL)"
"question": "Aasta ja laul, millega Eesti võitis esimest korda Eurovisiooni lauluvõistluse",
"answer": "Mis on 2001 ja 'Everybody' (Tanel Padar, Dave Benton ja 2XL)?"
}
}

View File

@@ -63,6 +63,7 @@ class GameSessionStore {
private channel: BroadcastChannel | null = null;
private timerInterval: ReturnType<typeof setInterval> | null = null;
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);
constructor() {
@@ -217,6 +218,18 @@ class GameSessionStore {
if (browser) {
localStorage.removeItem(STORAGE_KEY);
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() {
if (!this.state || !this.state.currentQuestion) return;
// Stop timer if running
this.state.timerRunning = false;
this.state.activeTeamId = null;
// Mark as skipping and start countdown
// Mark as skipping and immediately show answer
this.state.skippingQuestion = true;
this.state.timeoutCountdown = 5;
this.state.showAnswer = true;
this.state.revealCountdown = this.state.settings.answerRevealSeconds ?? 5;
this.persist();
}

View File

@@ -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();
});
});
});

View File

@@ -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);
});
});
});

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
<script lang="ts">
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";
let settingsOpen = $state(false);
@@ -33,6 +33,7 @@
<svelte:head>
<title>Kuldvillak - Ultimate Gaming</title>
<link rel="icon" href="/kuldvillak_favicon.svg" type="image/svg+xml" />
</svelte:head>
<div
@@ -44,7 +45,7 @@
<!-- Content -->
<div class="relative z-10 flex flex-col items-center gap-8 md:gap-16 p-4">
<!-- Kuldvillak Logo -->
<KvLogo
<KvGameLogo
size="lg"
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
href="/"
reload
class="w-full !text-lg md:!text-2xl !py-3 md:!py-4"
>
{m.kv_exit()}

View File

@@ -3,7 +3,13 @@
import { onMount } from "svelte";
import { browser } from "$app/environment";
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 { getLocale } from "$lib/paraglide/runtime";
import { gameSession } from "$lib/stores/gameSession.svelte";
import type {
GameSettings,
@@ -26,10 +32,11 @@
});
let teams = $state<Team[]>([
{ id: generateId(), name: "Mängija 1", score: 0 },
{ id: generateId(), name: "Mängija 2", score: 0 },
]);
let rounds = $state<Round[]>([
createRound("Villak", 1, DEFAULT_SETTINGS),
createRound("Topeltvillak", 2, DEFAULT_SETTINGS),
createRound(m.kv_edit_r1(), 1, DEFAULT_SETTINGS),
createRound(m.kv_edit_r2(), 2, DEFAULT_SETTINGS),
]);
let finalRound = $state<FinalRound>({
category: "",
@@ -58,6 +65,49 @@
let showQuestionCloseConfirm = $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
let originalQuestion = $state<{
question: string;
@@ -203,7 +253,7 @@
rounds = [rounds[0]];
settings.dailyDoublesPerRound = [settings.dailyDoublesPerRound[0]];
} 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[0],
2,
@@ -221,7 +271,7 @@
}
function removeTeam(id: string) {
if (teams.length <= 1) return;
if (teams.length <= 2) return;
teams = teams.filter((t) => t.id !== id);
}
@@ -264,7 +314,7 @@
rounds,
finalRound: settings.enableFinalRound ? finalRound : null,
});
window.open("/kuldvillak/play?view=projector", "_blank");
gameSession.openProjector();
await goto("/kuldvillak/play");
} catch (err) {
showToast("Failed to start game: " + err, "error");
@@ -292,10 +342,13 @@
defaultTimerSeconds: 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 = [
createRound("Villak", 1, DEFAULT_SETTINGS),
createRound("Topeltvillak", 2, DEFAULT_SETTINGS),
createRound(m.kv_edit_r1(), 1, DEFAULT_SETTINGS),
createRound(m.kv_edit_r2(), 2, DEFAULT_SETTINGS),
];
finalRound = { category: "", question: "", answer: "" };
gameName = "";
@@ -423,6 +476,7 @@
<svelte:head>
<title>{m.kv_edit_title()} - Kuldvillak</title>
<link rel="icon" href="/kuldvillak_favicon.svg" type="image/svg+xml" />
</svelte:head>
<!-- Main Layout -->
@@ -547,8 +601,13 @@
<h2 class="kv-h3 text-kv-white m-0">
{m.kv_edit_settings_teams()}
</h2>
<div class="flex gap-2">
<!-- ... (no changes) -->
<div class="flex flex-wrap gap-2">
<KvButtonSecondary onclick={() => (showRulesModal = true)}>
{m.kv_edit_rules()}
</KvButtonSecondary>
<KvButtonSecondary onclick={() => (showHowToModal = true)}>
{m.kv_edit_how_to()}
</KvButtonSecondary>
</div>
</div>
@@ -804,7 +863,7 @@
class="font-kv-body text-[28px] uppercase kv-shadow-text m-0"
>
<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"
>({m.kv_edit_dd_count()}
@@ -867,7 +926,7 @@
settings.dailyDoublesPerRound[editingQuestion.roundIndex] ?? 1}
{@const currentDD = countDailyDoubles(editingQuestion.roundIndex)}
<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 && handleQuestionCloseClick()}
onkeydown={(e) => e.key === "Escape" && handleQuestionCloseClick()}
@@ -975,7 +1034,7 @@
<!-- Final Question Modal -->
{#if editingFinalQuestion}
<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()}
onkeydown={(e) => e.key === "Escape" && handleFinalCloseClick()}
role="dialog"
@@ -1105,3 +1164,16 @@
confirmText={m.kv_edit_reset()}
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}
/>

View File

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

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import { gameSession } from "$lib/stores/gameSession.svelte";
import { goto } from "$app/navigation";
import { onMount } from "svelte";
import * as m from "$lib/paraglide/messages";
import {
@@ -22,7 +23,7 @@
let showEndGameConfirm = $state(false);
function openProjector() {
window.open("/kuldvillak/play?view=projector", "kuldvillak-projector");
gameSession.openProjector();
}
function openSettings() {
@@ -30,11 +31,31 @@
}
// Local state
let wagerInput = $state(0);
let wagerInput = $state(5);
let scoreAdjustment = $state(10);
let introDelayComplete = $state(false);
let prevIntroPhase = $state<string | null>(null);
// Derived 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 questionData = $derived(gameSession.currentQuestionData);
@@ -150,12 +171,22 @@
<KvButtonPrimary onclick={openProjector}>
{m.kv_play_open_projector()}
</KvButtonPrimary>
{#if session.settings.enableFinalRound && session.finalRound && session.phase === "board"}
<KvButtonPrimary
onclick={() => gameSession.goToFinalRound()}
>
{m.kv_play_go_to_final()}
</KvButtonPrimary>
{#if session.phase === "board"}
{#if session.rounds.length > 1 && session.currentRoundIndex === 0}
<!-- Two rounds and on first round: show Next Round button -->
<KvButtonPrimary
onclick={() => gameSession.nextRound()}
>
{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}
<KvButtonSecondary
onclick={() => (showEndGameConfirm = true)}
@@ -273,11 +304,9 @@
>
{#if session.phase === "intro"}
<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
? "Villak"
: "Topeltvillak"}
Kuldvillak
</span>
<div class="flex gap-4">
<KvButtonPrimary
@@ -292,7 +321,16 @@
</KvButtonSecondary>
</div>
{: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
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
{q.isRevealed ? 'opacity-50' : ''}
{q.isDailyDouble && !q.isRevealed
? 'border-4 border-kv-yellow'
: 'border-4 border-transparent'}"
? 'ring-4 ring-inset ring-kv-yellow'
: ''}"
disabled={q.isRevealed}
onclick={() => selectQuestion(ci, qi)}
>
@@ -377,7 +415,7 @@
<div class="flex flex-wrap gap-4 justify-center">
{#each session.teams as team}
<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
? 'border-kv-yellow text-kv-yellow'
: 'text-kv-white'}"
@@ -392,6 +430,18 @@
{@const activeTeam = session.teams.find(
(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">
<span
class="font-kv-body text-xl md:text-2xl text-kv-white uppercase kv-shadow-text"
@@ -400,15 +450,17 @@
</span>
<input
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"
max={Math.max(
activeTeam?.score ?? 0,
questionData?.question.points ?? 500,
)}
max={maxWager}
step="5"
bind:value={wagerInput}
/>
<KvButtonPrimary onclick={confirmDailyDoubleWager}>
<KvButtonPrimary
onclick={confirmDailyDoubleWager}
disabled={!isValidWager}
>
{m.kv_play_confirm()}
</KvButtonPrimary>
</div>
@@ -803,7 +855,12 @@
>
{m.kv_play_game_over()}!
</span>
<KvButtonPrimary onclick={() => gameSession.clearSession()}>
<KvButtonPrimary
onclick={() => {
gameSession.clearSession();
goto("/kuldvillak/edit");
}}
>
{m.kv_play_finish()}
</KvButtonPrimary>
</div>

View File

@@ -23,6 +23,13 @@
let finalAnimPhase = $state<"waiting" | "expanding" | "shown" | "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);
// Intro category animation state (used for both regular and final round)
@@ -75,15 +82,32 @@
const currentPhase = session?.phase;
if (currentPhase === "question" && prevPhase !== "question") {
animationPhase = "waiting";
setTimeout(() => {
animationPhase = "expanding";
}, 1000);
setTimeout(() => {
animationPhase = "shown";
}, 2000);
// Check if coming from daily-double phase
if (prevPhase === "daily-double") {
isDailyDoubleQuestion = true;
ddQuestionAnimPhase = "waiting";
setTimeout(() => {
ddQuestionAnimPhase = "expanding";
}, 1000);
setTimeout(() => {
ddQuestionAnimPhase = "shown";
}, 2000);
} else {
isDailyDoubleQuestion = false;
animationPhase = "waiting";
setTimeout(() => {
animationPhase = "expanding";
}, 1000);
setTimeout(() => {
animationPhase = "shown";
}, 2000);
}
} else if (currentPhase !== "question") {
animationPhase = "none";
ddQuestionAnimPhase = "none";
if (currentPhase !== "daily-double") {
isDailyDoubleQuestion = false;
}
}
// Final question animation - wait 1s on Kuldvillak, then expand from center
@@ -102,34 +126,53 @@
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;
});
// 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(() => {
const catIndex = session?.introCategoryIndex ?? -1;
const isFirstCategory = catIndex === 0 && prevIntroCatIndex === -1;
if (
session?.phase === "intro-categories" &&
catIndex >= 0 &&
catIndex !== prevIntroCatIndex
) {
// Initial 3 second delay for first category only
const initialDelay = isFirstCategory ? 3000 : 0;
// Start animation sequence for new category
introAnimPhase = "villak"; // Stay on Villak for 500ms
setTimeout(() => {
introAnimPhase = "villak"; // Stay on Villak for 500ms
}, initialDelay);
setTimeout(() => {
introAnimPhase = "fade-in"; // 500ms dissolve
}, 500); // Start fade after 500ms on villak
}, initialDelay + 500); // Start fade after 500ms on villak
setTimeout(() => {
introAnimPhase = "shown";
}, 1000); // 500ms villak + 500ms fade
}, initialDelay + 1000); // 500ms villak + 500ms fade
setTimeout(() => {
introAnimPhase = "push-out"; // 500ms push left
}, 2500); // 500ms villak + 500ms fade + 1500ms shown
}, initialDelay + 2500); // 500ms villak + 500ms fade + 1500ms shown
setTimeout(() => {
introAnimPhase = "none";
gameSession.nextIntroCategory();
}, 3000); // Total: 500ms + 500ms + 1500ms + 500ms = 3000ms
}, initialDelay + 3000); // Total: 500ms + 500ms + 1500ms + 500ms = 3000ms
} else if (
session?.phase !== "intro-categories" &&
session?.phase !== "final-category"
@@ -255,7 +298,10 @@
>
{#if session.phase === "intro"}
<!-- Home Screen with grid background - show Villak if categories introduced -->
<div class="flex-1 flex items-center justify-center intro-grid-bg">
<div
class="flex-1 flex items-center justify-center intro-grid-bg"
class:animated={!session.categoriesIntroduced}
>
{#if session.categoriesIntroduced}
<KvGameLogo
variant={roundVariant}
@@ -381,8 +427,8 @@
</div>
</div>
<!-- Question Overlay - Full screen over board -->
{#if session.phase === "question" && questionData && animationPhase !== "none"}
<!-- Question Overlay - Full screen over board (normal questions) -->
{#if session.phase === "question" && questionData && animationPhase !== "none" && !isDailyDoubleQuestion}
{@const pos = startPosition()}
<div
class="absolute bg-kv-black flex flex-col expand-overlay {animationPhase}"
@@ -423,12 +469,12 @@
{#if questionData.question.imageUrl}
<!-- Image question - show only image -->
<div
class="h-full max-w-full flex items-center justify-center"
class="w-full h-full flex items-center justify-center"
>
<img
src={questionData.question.imageUrl}
alt="Question"
class="max-h-full max-w-full object-contain"
class="w-full h-full object-contain"
/>
</div>
{:else}
@@ -451,23 +497,156 @@
</div>
</div>
{/if}
{:else if session.phase === "daily-double"}
<!-- Daily Double (Hõbevillak) Splash - Grid background with logo -->
<div
class="flex-1 flex flex-col items-center justify-center intro-grid-bg gap-8"
>
<KvGameLogo
variant="hobevillak"
class="w-[60vw] max-w-[885px] h-auto drop-shadow-[6px_6px_4px_rgba(0,0,0,0.5)] animate-pulse"
/>
{#if session.dailyDoubleWager}
<!-- Daily Double Question Overlay - Hõbevillak background with center expand -->
{#if session.phase === "question" && questionData && isDailyDoubleQuestion}
<!-- Hõbevillak background -->
<div
class="absolute inset-0 flex flex-col items-center justify-center intro-grid-bg"
>
<KvGameLogo
variant="hobevillak"
class="w-[60vw] max-w-[885px] h-auto drop-shadow-[0_0_20px_rgba(192,192,192,0.8)]"
/>
</div>
<!-- Question overlay expanding from center -->
{#if ddQuestionAnimPhase !== "none"}
<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>
{/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>
<!-- 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"}
<!-- Final Round Intro - KULDVILLAK screen -->
<div class="flex-1 flex items-center justify-center intro-grid-bg">
@@ -649,7 +828,9 @@
</div>
{:else if session.phase === "finished"}
<!-- 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
variant="kuldvillak"
class="w-[80vw] max-w-[1536px] h-auto drop-shadow-[6px_6px_4px_rgba(0,0,0,0.5)]"
@@ -660,21 +841,27 @@
{/if}
<style>
/* Intro grid background - matches homepage pattern */
/* Intro grid background - flat scrolling pattern */
.intro-grid-bg {
background-color: var(--kv-blue);
background-image: linear-gradient(
color-mix(in srgb, var(--kv-background) 50%, transparent) 2px,
transparent 2px
),
linear-gradient(
90deg,
color-mix(in srgb, var(--kv-background) 50%, transparent) 2px,
transparent 2px
);
background-image: linear-gradient(#001a45 2px, transparent 2px),
linear-gradient(90deg, #001a45 2px, transparent 2px);
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 {
opacity: 0;
@@ -878,6 +1065,42 @@
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 {
animation: