Basic Jeopardy page, working on revamping designs.

This commit is contained in:
AlacrisDevs
2025-12-07 14:10:09 +02:00
commit facb36a07f
59 changed files with 8663 additions and 0 deletions

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 25 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

15
src/lib/index.ts Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

167
src/lib/types/kuldvillak.ts Normal file
View File

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