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:
8
src/lib/assets/favicon-kuldvillak.svg
Normal file
8
src/lib/assets/favicon-kuldvillak.svg
Normal 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 |
@@ -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">
|
||||
|
||||
104
src/lib/components/ErrorBoundary.svelte
Normal file
104
src/lib/components/ErrorBoundary.svelte
Normal 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}
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
36
src/lib/components/kuldvillak/ui/KvSpinner.svelte
Normal file
36
src/lib/components/kuldvillak/ui/KvSpinner.svelte
Normal 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>
|
||||
172
src/lib/components/kuldvillak/ui/TutorialModal.svelte
Normal file
172
src/lib/components/kuldvillak/ui/TutorialModal.svelte
Normal 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}
|
||||
@@ -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';
|
||||
|
||||
@@ -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)?"
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
209
src/lib/stores/persistence.test.ts
Normal file
209
src/lib/stores/persistence.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
145
src/lib/types/kuldvillak.test.ts
Normal file
145
src/lib/types/kuldvillak.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user