commit
facb36a07f
59 changed files with 8663 additions and 0 deletions
@ -0,0 +1,60 @@ |
||||
node_modules |
||||
|
||||
# Output |
||||
.output |
||||
.vercel |
||||
.netlify |
||||
.wrangler |
||||
/.svelte-kit |
||||
/build |
||||
/dist |
||||
|
||||
# OS |
||||
.DS_Store |
||||
Thumbs.db |
||||
Desktop.ini |
||||
*.swp |
||||
*.swo |
||||
*~ |
||||
|
||||
# Env |
||||
.env |
||||
.env.* |
||||
!.env.example |
||||
!.env.test |
||||
|
||||
# Vite |
||||
vite.config.js.timestamp-* |
||||
vite.config.ts.timestamp-* |
||||
|
||||
# Paraglide / Inlang |
||||
src/lib/paraglide |
||||
project.inlang/cache |
||||
|
||||
# Logs |
||||
logs |
||||
*.log |
||||
npm-debug.log* |
||||
yarn-debug.log* |
||||
yarn-error.log* |
||||
pnpm-debug.log* |
||||
|
||||
# Cache |
||||
.cache |
||||
.parcel-cache |
||||
.turbo |
||||
*.tsbuildinfo |
||||
|
||||
# IDE |
||||
.idea |
||||
*.sublime-project |
||||
*.sublime-workspace |
||||
*.code-workspace |
||||
|
||||
# Testing |
||||
coverage |
||||
.nyc_output |
||||
|
||||
# Misc |
||||
*.local |
||||
.history |
||||
@ -0,0 +1,5 @@ |
||||
{ |
||||
"files.associations": { |
||||
"*.css": "tailwindcss" |
||||
} |
||||
} |
||||
@ -0,0 +1,38 @@ |
||||
# sv |
||||
|
||||
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli). |
||||
|
||||
## Creating a project |
||||
|
||||
If you're seeing this, you've probably already done this step. Congrats! |
||||
|
||||
```sh |
||||
# create a new project in the current directory |
||||
npx sv create |
||||
|
||||
# create a new project in my-app |
||||
npx sv create my-app |
||||
``` |
||||
|
||||
## Developing |
||||
|
||||
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: |
||||
|
||||
```sh |
||||
npm run dev |
||||
|
||||
# or start the server and open the app in a new browser tab |
||||
npm run dev -- --open |
||||
``` |
||||
|
||||
## Building |
||||
|
||||
To create a production version of your app: |
||||
|
||||
```sh |
||||
npm run build |
||||
``` |
||||
|
||||
You can preview the production build with `npm run preview`. |
||||
|
||||
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment. |
||||
@ -0,0 +1,145 @@ |
||||
{ |
||||
"$schema": "https://inlang.com/schema/inlang-message-format", |
||||
"app_title": "Ultimate Gaming", |
||||
"app_version": "V0.01 Student Edition", |
||||
"coming_soon": "Coming Soon", |
||||
"game_kuldvillak": "Jeopardy", |
||||
"game_rooside_soda": "Family Feud", |
||||
"kv_title": "JEOPARDY", |
||||
"kv_new_game": "New Game", |
||||
"kv_load_game": "Load Game", |
||||
"kv_settings": "Settings", |
||||
"kv_exit": "Exit", |
||||
"kv_settings_title": "Settings", |
||||
"kv_settings_music": "Music", |
||||
"kv_settings_sfx": "Sound Effects", |
||||
"kv_settings_language": "Language", |
||||
"kv_settings_close": "Close", |
||||
"kv_error_404": "404", |
||||
"kv_error_not_found": "Not Found", |
||||
"kv_error_hint": "(pssst... make sure you're on the right page)", |
||||
"kv_edit_back": "Back", |
||||
"kv_edit_game_name": "Game name...", |
||||
"kv_edit_save": "Save", |
||||
"kv_edit_load": "Load", |
||||
"kv_edit_reset": "Reset", |
||||
"kv_edit_start": "Start", |
||||
"kv_edit_settings_teams": "Game Setup", |
||||
"kv_edit_rounds": "Rounds", |
||||
"kv_play_timer": "Time to Answer", |
||||
"kv_play_timer_reveal": "Answer Reveal", |
||||
"kv_play_seconds": "seconds", |
||||
"kv_edit_final_round_toggle": "Final Round", |
||||
"kv_edit_final_question": "Final Question", |
||||
"kv_edit_edit": "Edit", |
||||
"kv_edit_teams": "Points & Teams", |
||||
"kv_edit_points": "Points", |
||||
"kv_edit_preset_normal": "Normal", |
||||
"kv_edit_preset_double": "Double", |
||||
"kv_edit_x_base": "Multiplier", |
||||
"kv_edit_custom": "Custom", |
||||
"kv_edit_base": "Base", |
||||
"kv_edit_negative_scores": "Negative Scores", |
||||
"kv_edit_daily_doubles": "Daily Doubles", |
||||
"kv_edit_r1": "Jeopardy", |
||||
"kv_edit_r2": "Double Jeopardy", |
||||
"kv_edit_teams_label": "Players", |
||||
"kv_edit_round_1": "Jeopardy", |
||||
"kv_edit_round_2": "Double Jeopardy", |
||||
"kv_edit_dd_count": "Daily Double", |
||||
"kv_edit_category": "Category", |
||||
"kv_edit_dd": "★", |
||||
"kv_edit_no_category": "(No Category Yet)", |
||||
"kv_edit_question": "Question", |
||||
"kv_edit_answer": "Answer", |
||||
"kv_edit_daily_double": "Daily Double", |
||||
"kv_edit_starting_game": "Starting Game...", |
||||
"kv_edit_opening_projector": "Opening projector view", |
||||
"kv_error_min_players": "At least 2 players required", |
||||
"kv_error_no_questions": "Round {round} has no questions filled", |
||||
"kv_error_no_final": "Final round question is empty", |
||||
"kv_toast_game_saved": "Game saved!", |
||||
"kv_toast_game_loaded": "Game loaded!", |
||||
"kv_toast_invalid_file": "Invalid game file", |
||||
"kv_edit_reset_confirm": "Are you sure you want to reset all fields to default? This will clear all your work.", |
||||
"kv_edit_reset_success": "Game reset to defaults", |
||||
"kv_edit_categories_questions": "Categories & Questions", |
||||
"kv_edit_daily_doubles_count": "Daily Doubles", |
||||
"kv_edit_category_placeholder": "Category name", |
||||
"kv_edit_empty": "Empty", |
||||
"kv_edit_question_edit": "Edit Question", |
||||
"kv_edit_question_placeholder": "Enter the question...", |
||||
"kv_edit_answer_placeholder": "Enter the answer...", |
||||
"kv_edit_final_round": "Final Round", |
||||
"kv_edit_final_disabled": "Final Round is disabled in settings.", |
||||
"kv_edit_category_name_placeholder": "Enter category name...", |
||||
"kv_edit_final_question_placeholder": "Enter the final question...", |
||||
"kv_edit_game_settings": "Game Settings", |
||||
"kv_edit_num_rounds": "Number of Rounds", |
||||
"kv_edit_1_round": "1 Round", |
||||
"kv_edit_2_rounds": "2 Rounds", |
||||
"kv_edit_point_values": "Point Values", |
||||
"kv_edit_standard": "Standard (100-500)", |
||||
"kv_edit_double": "Double (200-1000)", |
||||
"kv_edit_multiplier": "Multiplier", |
||||
"kv_edit_base_value": "Base Value", |
||||
"kv_edit_default_timer": "Default Timer (seconds)", |
||||
"kv_edit_enable_final": "Enable Final Round", |
||||
"kv_edit_allow_negative": "Allow Negative Scores", |
||||
"kv_edit_teams_title": "Teams", |
||||
"kv_edit_team_name_placeholder": "Team name...", |
||||
"kv_edit_add_team": "Add Team", |
||||
"kv_edit_remove_team": "Remove", |
||||
"kv_play_loading": "Loading game...", |
||||
"kv_play_loading_hint": "If this takes too long, the game may not have been started.", |
||||
"kv_play_go_to_editor": "Go to Editor", |
||||
"kv_play_round": "Round", |
||||
"kv_play_phase": "Phase", |
||||
"kv_play_last_answer": "Last", |
||||
"kv_play_introduce_categories": "Introduce Categories", |
||||
"kv_play_skip_to_game": "Skip to Game", |
||||
"kv_play_introducing_categories": "Introducing Categories...", |
||||
"kv_play_start_game": "Start Game", |
||||
"kv_play_daily_double": "Daily Double", |
||||
"kv_play_wager": "Wager", |
||||
"kv_play_confirm": "Confirm", |
||||
"kv_play_question_number": "Question {current}/{total}", |
||||
"kv_play_showing_answer": "Showing Answer...", |
||||
"kv_play_question_short": "Q", |
||||
"kv_play_answer_short": "A", |
||||
"kv_play_answering": "Answering", |
||||
"kv_play_correct": "Correct", |
||||
"kv_play_wrong": "Wrong", |
||||
"kv_play_skip": "Skip / No Answer", |
||||
"kv_play_start": "Start", |
||||
"kv_play_stop": "Stop", |
||||
"kv_play_reset": "Reset", |
||||
"kv_play_final_round": "Final Round", |
||||
"kv_play_reveal_category": "Reveal Category", |
||||
"kv_play_reveal_answer": "Reveal Answer", |
||||
"kv_play_show_scores": "Show Final Scores", |
||||
"kv_play_scores": "Scores", |
||||
"kv_play_adjust_by": "Adjust by", |
||||
"kv_play_game_controls": "Game Controls", |
||||
"kv_play_next_round": "Next Round", |
||||
"kv_play_go_to_final": "Go to Final Round", |
||||
"kv_play_end_game": "End Game", |
||||
"kv_play_end_game_confirm": "End the game?", |
||||
"kv_play_open_projector": "Open Projector", |
||||
"kv_play_projector_url": "Projector", |
||||
"kv_play_game_over": "Game Over", |
||||
"kv_edit_values": "Values", |
||||
"kv_edit_disabled": "Disabled", |
||||
"kv_edit_rules": "Jeopardy Rules", |
||||
"kv_edit_how_to": "How to Play?", |
||||
"kv_settings_colors": "Colors", |
||||
"kv_settings_primary": "Primary", |
||||
"kv_settings_secondary": "Secondary", |
||||
"kv_settings_text_color": "Text", |
||||
"kv_settings_background": "Background", |
||||
"kv_settings_reset": "Reset Settings", |
||||
"kv_settings_save_exit": "Save and Exit", |
||||
"kv_edit_image_link": "Image Link", |
||||
"kv_edit_save_exit": "Save and Exit", |
||||
"kv_edit_final_enabled": "Final Round Enabled" |
||||
} |
||||
@ -0,0 +1,145 @@ |
||||
{ |
||||
"$schema": "https://inlang.com/schema/inlang-message-format", |
||||
"app_title": "Sassi Mängukoobas", |
||||
"app_version": "V0.01 Tudengite Eri", |
||||
"coming_soon": "Tulekul", |
||||
"game_kuldvillak": "Kuldvillak", |
||||
"game_rooside_soda": "Rooside Sõda", |
||||
"kv_title": "KULDVILLAK", |
||||
"kv_new_game": "Uus mäng", |
||||
"kv_load_game": "Lae mäng", |
||||
"kv_settings": "Seaded", |
||||
"kv_exit": "Välju", |
||||
"kv_settings_title": "Seaded", |
||||
"kv_settings_music": "Muusika", |
||||
"kv_settings_sfx": "Heliefektid", |
||||
"kv_settings_language": "Keel", |
||||
"kv_settings_close": "Välju", |
||||
"kv_error_404": "404", |
||||
"kv_error_not_found": "Lehte ei leitud", |
||||
"kv_error_hint": "(pssst... vaata, et oled ikka õigel lehel)", |
||||
"kv_edit_back": "Tagasi", |
||||
"kv_edit_game_name": "Mängu nimi...", |
||||
"kv_edit_save": "Salvesta", |
||||
"kv_edit_load": "Lae", |
||||
"kv_edit_reset": "Lähtesta", |
||||
"kv_edit_start": "Alusta", |
||||
"kv_edit_settings_teams": "Mängu seadistus", |
||||
"kv_edit_rounds": "Voorude arv", |
||||
"kv_play_timer": "Vastamisaeg", |
||||
"kv_play_timer_reveal": "Vastuse näitamine", |
||||
"kv_play_seconds": "sekundit", |
||||
"kv_edit_final_round_toggle": "Finaalvoor", |
||||
"kv_edit_final_question": "Finaalküsimus", |
||||
"kv_edit_edit": "Muuda", |
||||
"kv_edit_teams": "Punktid & Tiimid", |
||||
"kv_edit_points": "Punktid", |
||||
"kv_edit_preset_normal": "Tavaline", |
||||
"kv_edit_preset_double": "Duubel", |
||||
"kv_edit_x_base": "Kordaja", |
||||
"kv_edit_custom": "Kohandatud", |
||||
"kv_edit_base": "Baas", |
||||
"kv_edit_negative_scores": "Negatiivsed punktid", |
||||
"kv_edit_daily_doubles": "Hõbevillak", |
||||
"kv_edit_r1": "Villak", |
||||
"kv_edit_r2": "Topeltvillak", |
||||
"kv_edit_teams_label": "Mängijad", |
||||
"kv_edit_round_1": "Villak", |
||||
"kv_edit_round_2": "Topeltvillak", |
||||
"kv_edit_dd_count": "Hõbevillak", |
||||
"kv_edit_category": "Kategooria", |
||||
"kv_edit_dd": "★", |
||||
"kv_edit_no_category": "(Kategooria puudub)", |
||||
"kv_edit_question": "Küsimus", |
||||
"kv_edit_answer": "Vastus", |
||||
"kv_edit_daily_double": "Hõbevillak", |
||||
"kv_edit_starting_game": "Alustan mängu...", |
||||
"kv_edit_opening_projector": "Avan projektori vaate", |
||||
"kv_error_min_players": "Vaja on vähemalt 2 mängijat", |
||||
"kv_error_no_questions": "Voorul {round} pole küsimusi", |
||||
"kv_error_no_final": "Finaalvooru küsimus on tühi", |
||||
"kv_toast_game_saved": "Mäng salvestatud!", |
||||
"kv_toast_game_loaded": "Mäng laetud!", |
||||
"kv_toast_invalid_file": "Vigane mängufail", |
||||
"kv_edit_reset_confirm": "Kas oled kindel, et soovid kõik väljad vaikeväärtustele lähtestada? See kustutab kogu sinu töö.", |
||||
"kv_edit_reset_success": "Mäng lähtestatud", |
||||
"kv_edit_categories_questions": "Kategooriad & Küsimused", |
||||
"kv_edit_daily_doubles_count": "Hõbevillakud", |
||||
"kv_edit_category_placeholder": "Kategooria nimi", |
||||
"kv_edit_empty": "Tühi", |
||||
"kv_edit_question_edit": "Muuda küsimust", |
||||
"kv_edit_question_placeholder": "Sisesta küsimus...", |
||||
"kv_edit_answer_placeholder": "Sisesta vastus...", |
||||
"kv_edit_final_round": "Finaalvoor", |
||||
"kv_edit_final_disabled": "Finaalvoor on seadetes keelatud.", |
||||
"kv_edit_category_name_placeholder": "Sisesta kategooria nimi...", |
||||
"kv_edit_final_question_placeholder": "Sisesta lõppküsimus...", |
||||
"kv_edit_game_settings": "Mängu seaded", |
||||
"kv_edit_num_rounds": "Voorude arv", |
||||
"kv_edit_1_round": "1 voor", |
||||
"kv_edit_2_rounds": "2 vooru", |
||||
"kv_edit_point_values": "Punktiväärtused", |
||||
"kv_edit_standard": "Tavaline (100-500)", |
||||
"kv_edit_double": "Topelt (200-1000)", |
||||
"kv_edit_multiplier": "Kordaja", |
||||
"kv_edit_base_value": "Baasväärtus", |
||||
"kv_edit_default_timer": "Vaikimisi taimer (sekundid)", |
||||
"kv_edit_enable_final": "Luba finaalvoor", |
||||
"kv_edit_allow_negative": "Luba negatiivsed punktid", |
||||
"kv_edit_teams_title": "Tiimid", |
||||
"kv_edit_team_name_placeholder": "Tiimi nimi...", |
||||
"kv_edit_add_team": "Lisa tiim", |
||||
"kv_edit_remove_team": "Eemalda", |
||||
"kv_play_loading": "Laen mängu...", |
||||
"kv_play_loading_hint": "Kui see võtab liiga kaua, siis mängu pole alustatud.", |
||||
"kv_play_go_to_editor": "Mine redaktorisse", |
||||
"kv_play_round": "Voor", |
||||
"kv_play_phase": "Hetkeseis", |
||||
"kv_play_last_answer": "Viimane", |
||||
"kv_play_introduce_categories": "Tutvusta kategooriaid", |
||||
"kv_play_skip_to_game": "Jäta vahele", |
||||
"kv_play_introducing_categories": "Tutvustan kategooriaid...", |
||||
"kv_play_start_game": "Alusta mängu", |
||||
"kv_play_daily_double": "Hõbevillak", |
||||
"kv_play_wager": "Panus", |
||||
"kv_play_confirm": "Kinnita", |
||||
"kv_play_question_number": "Küsimus {current}/{total}", |
||||
"kv_play_showing_answer": "Näitan vastust...", |
||||
"kv_play_question_short": "Küsimus", |
||||
"kv_play_answer_short": "Vastus", |
||||
"kv_play_answering": "Vastab", |
||||
"kv_play_correct": "Õige", |
||||
"kv_play_wrong": "Vale", |
||||
"kv_play_skip": "Jäta vahele", |
||||
"kv_play_start": "Alusta", |
||||
"kv_play_stop": "Lõpeta", |
||||
"kv_play_reset": "Lähtesta", |
||||
"kv_play_final_round": "Finaalvoor", |
||||
"kv_play_reveal_category": "Näita kategooriat", |
||||
"kv_play_reveal_answer": "Näita vastust", |
||||
"kv_play_show_scores": "Näita lõpptulemusi", |
||||
"kv_play_scores": "Tulemused", |
||||
"kv_play_adjust_by": "Muuda", |
||||
"kv_play_game_controls": "Mängu juhtimine", |
||||
"kv_play_next_round": "Järgmine voor", |
||||
"kv_play_go_to_final": "Finaalvooru", |
||||
"kv_play_end_game": "Lõpeta mäng", |
||||
"kv_play_end_game_confirm": "Lõpeta mäng?", |
||||
"kv_play_open_projector": "Ava projektor", |
||||
"kv_play_projector_url": "Projektor", |
||||
"kv_play_game_over": "Mäng läbi", |
||||
"kv_edit_values": "Väärtused", |
||||
"kv_edit_disabled": "Keelatud", |
||||
"kv_edit_rules": "Kuldvillaku reeglid", |
||||
"kv_edit_how_to": "Kuidas mängida?", |
||||
"kv_settings_colors": "Värvid", |
||||
"kv_settings_primary": "Primaarne", |
||||
"kv_settings_secondary": "Sekundaarne", |
||||
"kv_settings_text_color": "Tekst", |
||||
"kv_settings_background": "Taust", |
||||
"kv_settings_reset": "Lähtesta seaded", |
||||
"kv_settings_save_exit": "Salvesta ja välju", |
||||
"kv_edit_image_link": "Pildi link", |
||||
"kv_edit_save_exit": "Salvesta ja Välju", |
||||
"kv_edit_final_enabled": "Finaalvoor lubatud" |
||||
} |
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,26 @@ |
||||
{ |
||||
"name": "myapp", |
||||
"private": true, |
||||
"version": "0.0.1", |
||||
"type": "module", |
||||
"scripts": { |
||||
"dev": "vite dev", |
||||
"build": "vite build", |
||||
"preview": "vite preview", |
||||
"prepare": "svelte-kit sync || echo ''", |
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", |
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" |
||||
}, |
||||
"devDependencies": { |
||||
"@inlang/paraglide-js": "^2.5.0", |
||||
"@sveltejs/adapter-auto": "^7.0.0", |
||||
"@sveltejs/kit": "^2.48.5", |
||||
"@sveltejs/vite-plugin-svelte": "^6.2.1", |
||||
"@tailwindcss/vite": "^4.1.17", |
||||
"svelte": "^5.43.8", |
||||
"svelte-check": "^4.3.4", |
||||
"tailwindcss": "^4.1.17", |
||||
"typescript": "^5.9.3", |
||||
"vite": "^7.2.2" |
||||
} |
||||
} |
||||
@ -0,0 +1 @@ |
||||
0vGuuiWc0Pt5x0peOL |
||||
@ -0,0 +1,15 @@ |
||||
{ |
||||
"$schema": "https://inlang.com/schema/project-settings", |
||||
"modules": [ |
||||
"https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@4/dist/index.js", |
||||
"https://cdn.jsdelivr.net/npm/@inlang/plugin-m-function-matcher@2/dist/index.js" |
||||
], |
||||
"plugin.inlang.messageFormat": { |
||||
"pathPattern": "./messages/{locale}.json" |
||||
}, |
||||
"baseLocale": "et", |
||||
"locales": [ |
||||
"et", |
||||
"en" |
||||
] |
||||
} |
||||
@ -0,0 +1,13 @@ |
||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
// for information about these interfaces
|
||||
declare global { |
||||
namespace App { |
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
} |
||||
} |
||||
|
||||
export {}; |
||||
@ -0,0 +1,11 @@ |
||||
<!doctype html> |
||||
<html lang="%paraglide.lang%"> |
||||
<head> |
||||
<meta charset="utf-8" /> |
||||
<meta name="viewport" content="width=device-width, initial-scale=1" /> |
||||
%sveltekit.head% |
||||
</head> |
||||
<body data-sveltekit-preload-data="hover"> |
||||
<div style="display: contents">%sveltekit.body%</div> |
||||
</body> |
||||
</html> |
||||
@ -0,0 +1,12 @@ |
||||
import type { Handle } from '@sveltejs/kit'; |
||||
import { paraglideMiddleware } from '$lib/paraglide/server'; |
||||
|
||||
const handleParaglide: Handle = ({ event, resolve }) => paraglideMiddleware(event.request, ({ request, locale }) => { |
||||
event.request = request; |
||||
|
||||
return resolve(event, { |
||||
transformPageChunk: ({ html }) => html.replace('%paraglide.lang%', locale) |
||||
}); |
||||
}); |
||||
|
||||
export const handle: Handle = handleParaglide; |
||||
@ -0,0 +1,3 @@ |
||||
import { deLocalizeUrl } from '$lib/paraglide/runtime'; |
||||
|
||||
export const reroute = (request) => deLocalizeUrl(request.url).pathname; |
||||
|
After Width: | Height: | Size: 25 KiB |
@ -0,0 +1,38 @@ |
||||
<script lang="ts"> |
||||
import { getLocale, setLocale } from "$lib/paraglide/runtime"; |
||||
|
||||
interface Props { |
||||
inline?: boolean; |
||||
} |
||||
|
||||
let { inline = false }: Props = $props(); |
||||
|
||||
function getOtherLocale(): string { |
||||
const current = getLocale(); |
||||
return current === "et" ? "en" : "et"; |
||||
} |
||||
|
||||
function switchLanguage() { |
||||
const newLocale = getOtherLocale(); |
||||
setLocale(newLocale as "et" | "en"); |
||||
} |
||||
|
||||
$effect(() => { |
||||
getLocale(); |
||||
}); |
||||
</script> |
||||
|
||||
<button |
||||
onclick={switchLanguage} |
||||
class="{inline |
||||
? '' |
||||
: 'fixed top-4 right-4 z-[100]'} bg-transparent border-none p-1 cursor-pointer transition-transform duration-200 hover:scale-110" |
||||
aria-label="Switch language" |
||||
title="Switch to {getOtherLocale() === 'en' ? 'English' : 'Eesti'}" |
||||
> |
||||
<img |
||||
src="/icons/{getOtherLocale()}.svg" |
||||
alt={getOtherLocale() === "en" ? "English" : "Eesti"} |
||||
class="block w-8 h-5 object-cover" |
||||
/> |
||||
</button> |
||||
@ -0,0 +1,240 @@ |
||||
<script lang="ts"> |
||||
import Slider from "./Slider.svelte"; |
||||
import { |
||||
KvButtonPrimary, |
||||
KvButtonSecondary, |
||||
} from "$lib/components/kuldvillak/ui"; |
||||
import * as m from "$lib/paraglide/messages"; |
||||
import { audioStore } from "$lib/stores/audio.svelte"; |
||||
import { themeStore } from "$lib/stores/theme.svelte"; |
||||
import { getLocale, setLocale } from "$lib/paraglide/runtime"; |
||||
|
||||
interface SettingsProps { |
||||
open?: boolean; |
||||
onclose?: () => void; |
||||
} |
||||
|
||||
let { open = $bindable(false), onclose }: SettingsProps = $props(); |
||||
|
||||
// Close without saving (revert colors) |
||||
function handleCancel() { |
||||
themeStore.revert(); |
||||
open = false; |
||||
onclose?.(); |
||||
} |
||||
|
||||
// Save and close |
||||
function handleSaveAndExit() { |
||||
themeStore.save(); |
||||
open = false; |
||||
onclose?.(); |
||||
} |
||||
|
||||
// Reset colors to defaults (preview only, not saved until Save is clicked) |
||||
function handleResetSettings() { |
||||
themeStore.resetToDefaults(); |
||||
} |
||||
|
||||
function handleKeydown(e: KeyboardEvent) { |
||||
if (e.key === "Escape") handleCancel(); |
||||
} |
||||
|
||||
function handleMusicChange(value: number) { |
||||
audioStore.setMusicVolume(value); |
||||
} |
||||
|
||||
function handleSfxChange(value: number) { |
||||
audioStore.setSfxVolume(value); |
||||
} |
||||
|
||||
function switchLanguage(lang: "et" | "en") { |
||||
setLocale(lang); |
||||
} |
||||
</script> |
||||
|
||||
<svelte:window onkeydown={handleKeydown} /> |
||||
|
||||
{#if open} |
||||
<!-- Backdrop - clicking outside cancels without saving --> |
||||
<div |
||||
class="fixed inset-0 bg-kv-background/50 z-40" |
||||
onclick={handleCancel} |
||||
role="button" |
||||
tabindex="-1" |
||||
onkeydown={(e) => e.key === "Enter" && handleCancel()} |
||||
></div> |
||||
|
||||
<!-- Modal --> |
||||
<div |
||||
class="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-50 |
||||
bg-kv-blue border-8 md:border-[16px] border-kv-black |
||||
p-4 md:p-8 w-[95vw] md:min-w-[420px] md:w-auto max-w-[500px] |
||||
flex flex-col gap-4 md:gap-8 items-center max-h-[90vh] overflow-y-auto" |
||||
role="dialog" |
||||
aria-modal="true" |
||||
aria-labelledby="settings-title" |
||||
> |
||||
<!-- Header with Title and Close Button --> |
||||
<div class="flex items-start justify-between w-full"> |
||||
<h2 |
||||
id="settings-title" |
||||
class="text-[28px] text-kv-white uppercase font-kv-body kv-shadow-text" |
||||
> |
||||
{m.kv_settings_title()} |
||||
</h2> |
||||
<button |
||||
onclick={handleCancel} |
||||
class="w-6 h-6 cursor-pointer bg-transparent border-none p-0 hover:opacity-70" |
||||
aria-label="Close" |
||||
> |
||||
<svg |
||||
viewBox="0 0 24 24" |
||||
fill="none" |
||||
stroke="currentColor" |
||||
stroke-width="2" |
||||
class="w-full h-full text-kv-yellow" |
||||
> |
||||
<path d="M18 6L6 18M6 6l12 12" /> |
||||
</svg> |
||||
</button> |
||||
</div> |
||||
|
||||
<!-- Audio & Language Settings --> |
||||
<div class="flex flex-col gap-4 w-full"> |
||||
<Slider |
||||
label={m.kv_settings_music()} |
||||
bind:value={audioStore.musicVolume} |
||||
onchange={handleMusicChange} |
||||
/> |
||||
<Slider |
||||
label={m.kv_settings_sfx()} |
||||
bind:value={audioStore.sfxVolume} |
||||
onchange={handleSfxChange} |
||||
/> |
||||
<!-- Language --> |
||||
<div class="flex items-center justify-between w-full gap-4"> |
||||
<span |
||||
class="text-2xl text-kv-white uppercase font-kv-body kv-shadow-text" |
||||
> |
||||
{m.kv_settings_language()} |
||||
</span> |
||||
<div class="flex gap-4"> |
||||
<button |
||||
class="w-8 h-8 cursor-pointer transition-all overflow-hidden {getLocale() === |
||||
'et' |
||||
? 'opacity-100' |
||||
: 'opacity-60 hover:opacity-80'}" |
||||
onclick={() => switchLanguage("et")} |
||||
> |
||||
<img |
||||
src="/icons/et.svg" |
||||
alt="Eesti" |
||||
class="w-full h-full object-cover" |
||||
/> |
||||
</button> |
||||
<button |
||||
class="w-8 h-8 cursor-pointer transition-all overflow-hidden {getLocale() === |
||||
'en' |
||||
? 'opacity-100' |
||||
: 'opacity-60 hover:opacity-80'}" |
||||
onclick={() => switchLanguage("en")} |
||||
> |
||||
<img |
||||
src="/icons/en.svg" |
||||
alt="English" |
||||
class="w-full h-full object-cover" |
||||
/> |
||||
</button> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
|
||||
<!-- Colors Section Title --> |
||||
<div class="flex items-center w-full"> |
||||
<h3 |
||||
class="text-[28px] text-kv-white uppercase font-kv-body kv-shadow-text" |
||||
> |
||||
{m.kv_settings_colors()} |
||||
</h3> |
||||
</div> |
||||
|
||||
<!-- Color Swatches --> |
||||
<div class="flex flex-col gap-4 w-full"> |
||||
<!-- Primary Color --> |
||||
<div class="flex items-center justify-between w-full"> |
||||
<span |
||||
class="text-2xl text-kv-white uppercase font-kv-body kv-shadow-text" |
||||
> |
||||
{m.kv_settings_primary()} |
||||
</span> |
||||
<input |
||||
type="color" |
||||
value={themeStore.primary} |
||||
oninput={(e) => |
||||
(themeStore.primary = e.currentTarget.value)} |
||||
class="w-8 h-8 border-4 border-black cursor-pointer bg-transparent" |
||||
/> |
||||
</div> |
||||
<!-- Secondary Color --> |
||||
<div class="flex items-center justify-between w-full"> |
||||
<span |
||||
class="text-2xl text-kv-white uppercase font-kv-body kv-shadow-text" |
||||
> |
||||
{m.kv_settings_secondary()} |
||||
</span> |
||||
<input |
||||
type="color" |
||||
value={themeStore.secondary} |
||||
oninput={(e) => |
||||
(themeStore.secondary = e.currentTarget.value)} |
||||
class="w-8 h-8 border-4 border-black cursor-pointer bg-transparent" |
||||
/> |
||||
</div> |
||||
<!-- Text Color --> |
||||
<div class="flex items-center justify-between w-full"> |
||||
<span |
||||
class="text-2xl text-kv-white uppercase font-kv-body kv-shadow-text" |
||||
> |
||||
{m.kv_settings_text_color()} |
||||
</span> |
||||
<input |
||||
type="color" |
||||
value={themeStore.text} |
||||
oninput={(e) => (themeStore.text = e.currentTarget.value)} |
||||
class="w-8 h-8 border-4 border-black cursor-pointer bg-transparent" |
||||
/> |
||||
</div> |
||||
<!-- Background Color --> |
||||
<div class="flex items-center justify-between w-full"> |
||||
<span |
||||
class="text-2xl text-kv-white uppercase font-kv-body kv-shadow-text" |
||||
> |
||||
{m.kv_settings_background()} |
||||
</span> |
||||
<input |
||||
type="color" |
||||
value={themeStore.background} |
||||
oninput={(e) => |
||||
(themeStore.background = e.currentTarget.value)} |
||||
class="w-8 h-8 border-4 border-black cursor-pointer bg-transparent" |
||||
/> |
||||
</div> |
||||
</div> |
||||
|
||||
<!-- Reset Settings Button --> |
||||
<KvButtonPrimary |
||||
onclick={handleResetSettings} |
||||
class="!text-2xl !py-4 !px-4" |
||||
> |
||||
{m.kv_settings_reset()} |
||||
</KvButtonPrimary> |
||||
|
||||
<!-- Save and Exit Button --> |
||||
<KvButtonSecondary |
||||
onclick={handleSaveAndExit} |
||||
class="!text-2xl !py-4 !px-4" |
||||
> |
||||
{m.kv_settings_save_exit()} |
||||
</KvButtonSecondary> |
||||
</div> |
||||
{/if} |
||||
@ -0,0 +1,94 @@ |
||||
<script lang="ts"> |
||||
interface SliderProps { |
||||
label: string; |
||||
value?: number; |
||||
min?: number; |
||||
max?: number; |
||||
step?: number; |
||||
onchange?: (value: number) => void; |
||||
} |
||||
|
||||
let { |
||||
label, |
||||
value = $bindable(100), |
||||
min = 0, |
||||
max = 100, |
||||
step = 5, |
||||
onchange, |
||||
}: SliderProps = $props(); |
||||
|
||||
function handleInput(e: Event) { |
||||
const target = e.target as HTMLInputElement; |
||||
value = Number(target.value); |
||||
onchange?.(value); |
||||
} |
||||
</script> |
||||
|
||||
<div class="flex items-center justify-between w-full gap-4"> |
||||
<span class="text-2xl text-kv-white uppercase font-kv-body kv-shadow-text"> |
||||
{label} |
||||
</span> |
||||
|
||||
<div class="flex items-center gap-4"> |
||||
<span |
||||
class="text-2xl text-kv-yellow uppercase font-kv-body min-w-[60px] text-right kv-shadow-text" |
||||
> |
||||
{value}% |
||||
</span> |
||||
|
||||
<div class="relative w-[150px] h-[24px] flex items-center"> |
||||
<input |
||||
type="range" |
||||
{min} |
||||
{max} |
||||
{step} |
||||
{value} |
||||
oninput={handleInput} |
||||
class="slider-input w-full" |
||||
/> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
|
||||
<style> |
||||
.slider-input { |
||||
-webkit-appearance: none; |
||||
appearance: none; |
||||
background: transparent; |
||||
cursor: pointer; |
||||
height: 4px; |
||||
} |
||||
|
||||
.slider-input::-webkit-slider-runnable-track { |
||||
background: var(--kv-yellow); |
||||
height: 4px; |
||||
border-radius: 2px; |
||||
} |
||||
|
||||
.slider-input::-webkit-slider-thumb { |
||||
-webkit-appearance: none; |
||||
appearance: none; |
||||
width: 20px; |
||||
height: 20px; |
||||
border-radius: 50%; |
||||
background: white; |
||||
border: 3px solid var(--kv-yellow); |
||||
margin-top: -8px; |
||||
cursor: pointer; |
||||
} |
||||
|
||||
.slider-input::-moz-range-track { |
||||
background: var(--kv-yellow); |
||||
height: 4px; |
||||
border-radius: 2px; |
||||
} |
||||
|
||||
.slider-input::-moz-range-thumb { |
||||
width: 20px; |
||||
height: 20px; |
||||
border-radius: 50%; |
||||
background: white; |
||||
border: 3px solid var(--kv-yellow); |
||||
cursor: pointer; |
||||
} |
||||
</style> |
||||
@ -0,0 +1,40 @@ |
||||
<script lang="ts"> |
||||
interface ToastProps { |
||||
message: string; |
||||
type?: "error" | "success"; |
||||
visible?: boolean; |
||||
duration?: number; |
||||
onclose?: () => void; |
||||
} |
||||
|
||||
let { |
||||
message, |
||||
type = "error", |
||||
visible = $bindable(false), |
||||
duration = 3000, |
||||
onclose, |
||||
}: ToastProps = $props(); |
||||
|
||||
$effect(() => { |
||||
if (visible && duration > 0) { |
||||
const timer = setTimeout(() => { |
||||
visible = false; |
||||
onclose?.(); |
||||
}, duration); |
||||
return () => clearTimeout(timer); |
||||
} |
||||
}); |
||||
</script> |
||||
|
||||
{#if visible} |
||||
<div |
||||
class="fixed bottom-8 left-1/2 -translate-x-1/2 z-[200] px-6 py-4 rounded-lg shadow-lg |
||||
font-[family-name:var(--kv-font-button)] text-lg uppercase |
||||
{type === 'error' |
||||
? 'bg-red-600 text-kv-white' |
||||
: 'bg-green-600 text-kv-white'}" |
||||
role="alert" |
||||
> |
||||
{message} |
||||
</div> |
||||
{/if} |
||||
@ -0,0 +1,8 @@ |
||||
// Shared Components
|
||||
export { default as Slider } from './Slider.svelte'; |
||||
export { default as Settings } from './Settings.svelte'; |
||||
export { default as LanguageSwitcher } from './LanguageSwitcher.svelte'; |
||||
export { default as Toast } from './Toast.svelte'; |
||||
|
||||
// Kuldvillak Components
|
||||
export * from './kuldvillak'; |
||||
@ -0,0 +1,2 @@ |
||||
// Kuldvillak UI Components
|
||||
export * from './ui'; |
||||
@ -0,0 +1,32 @@ |
||||
<script lang="ts"> |
||||
import type { Snippet } from "svelte"; |
||||
|
||||
interface Props { |
||||
href?: string; |
||||
disabled?: boolean; |
||||
onclick?: () => void; |
||||
children: Snippet; |
||||
class?: string; |
||||
} |
||||
|
||||
let { |
||||
href, |
||||
disabled = false, |
||||
onclick, |
||||
children, |
||||
class: className = "", |
||||
}: Props = $props(); |
||||
|
||||
const baseClasses = |
||||
"inline-flex items-center justify-center px-6 py-4 bg-kv-blue font-kv-body text-kv-white text-2xl uppercase cursor-pointer transition-opacity hover:opacity-80 disabled:opacity-50 disabled:cursor-not-allowed kv-shadow-button"; |
||||
</script> |
||||
|
||||
{#if href && !disabled} |
||||
<a {href} class="{baseClasses} {className}"> |
||||
<span class="kv-shadow-text">{@render children()}</span> |
||||
</a> |
||||
{:else} |
||||
<button class="{baseClasses} {className}" {disabled} {onclick}> |
||||
<span class="kv-shadow-text">{@render children()}</span> |
||||
</button> |
||||
{/if} |
||||
@ -0,0 +1,32 @@ |
||||
<script lang="ts"> |
||||
import type { Snippet } from "svelte"; |
||||
|
||||
interface Props { |
||||
href?: string; |
||||
disabled?: boolean; |
||||
onclick?: () => void; |
||||
children: Snippet; |
||||
class?: string; |
||||
} |
||||
|
||||
let { |
||||
href, |
||||
disabled = false, |
||||
onclick, |
||||
children, |
||||
class: className = "", |
||||
}: Props = $props(); |
||||
|
||||
const baseClasses = |
||||
"inline-flex items-center justify-center px-6 py-4 bg-kv-yellow font-kv-body text-black text-2xl uppercase cursor-pointer transition-opacity hover:opacity-80 disabled:opacity-50 disabled:cursor-not-allowed kv-shadow-button"; |
||||
</script> |
||||
|
||||
{#if href && !disabled} |
||||
<a {href} class="{baseClasses} {className}"> |
||||
{@render children()} |
||||
</a> |
||||
{:else} |
||||
<button class="{baseClasses} {className}" {disabled} {onclick}> |
||||
{@render children()} |
||||
</button> |
||||
{/if} |
||||
@ -0,0 +1,133 @@ |
||||
<script lang="ts"> |
||||
interface Props { |
||||
variant?: "kuldvillak" | "villak" | "topeltvillak" | "hobevillak"; |
||||
size?: "sm" | "md" | "lg" | "xl" | "auto"; |
||||
class?: string; |
||||
} |
||||
|
||||
let { |
||||
variant = "kuldvillak", |
||||
size = "auto", |
||||
class: className = "", |
||||
}: Props = $props(); |
||||
|
||||
const sizeClasses = { |
||||
sm: "h-16 max-w-[256px]", |
||||
md: "h-24 max-w-[384px]", |
||||
lg: "h-32 max-w-[512px]", |
||||
xl: "h-48 max-w-[768px]", |
||||
auto: "", // No size constraints, controlled by parent |
||||
}; |
||||
</script> |
||||
|
||||
{#if variant === "kuldvillak"} |
||||
<!-- KULDVILLAK - Full logo with all letters --> |
||||
<svg |
||||
class="text-kv-yellow {sizeClasses[size]} {className}" |
||||
viewBox="0 0 1024 128" |
||||
fill="currentColor" |
||||
xmlns="http://www.w3.org/2000/svg" |
||||
role="img" |
||||
aria-label="Kuldvillak" |
||||
> |
||||
<path |
||||
fill-rule="evenodd" |
||||
clip-rule="evenodd" |
||||
d="M912.118 0L915.437 11V114L912.118 125H950.044L946.726 114V72.1719L986.074 125H1024L971.377 57.5L1019.73 0H981.807L946.726 49V11L950.044 0H912.118Z" |
||||
fill="currentColor" |
||||
/> |
||||
<path |
||||
d="M830.841 0L787.603 114L779.26 125L813.772 125L823.706 94.086H861.678L871.611 125L906.124 125L897.78 114L854.544 0H830.841ZM842.693 35L854.93 73.086H830.454L842.693 35Z" |
||||
fill="currentColor" |
||||
/> |
||||
<path |
||||
fill-rule="evenodd" |
||||
clip-rule="evenodd" |
||||
d="M707.371 0L710.69 11V114L707.371 125H770.423L780.853 95C780.853 95 766.952 103 741.978 103V11L745.297 0H707.371Z" |
||||
fill="currentColor" |
||||
/> |
||||
<path |
||||
fill-rule="evenodd" |
||||
clip-rule="evenodd" |
||||
d="M623.724 0L627.042 11V114L623.724 125H686.775L697.205 95C697.205 95 683.305 103 658.331 103V11L661.65 0H623.724Z" |
||||
fill="currentColor" |
||||
/> |
||||
<path |
||||
fill-rule="evenodd" |
||||
clip-rule="evenodd" |
||||
d="M568.046 0L571.364 11V114L568.046 125H605.972L602.653 114V11L605.972 0H568.046Z" |
||||
fill="currentColor" |
||||
/> |
||||
<path |
||||
d="M560.535 0L552.191 11L508.956 125H485.252L442.017 11L433.673 0H468.185L497.104 90L526.023 0H560.535Z" |
||||
fill="currentColor" |
||||
/> |
||||
<path |
||||
fill-rule="evenodd" |
||||
clip-rule="evenodd" |
||||
d="M336.276 0L339.595 11V114L336.276 125H374.202C377.363 125 380.52 124.787 383.649 124.363C386.776 123.94 389.868 123.307 392.9 122.469C395.933 121.63 398.899 120.588 401.774 119.352C404.649 118.115 407.427 116.687 410.086 115.078C412.744 113.469 415.277 111.683 417.665 109.734C420.054 107.785 422.292 105.677 424.362 103.428C426.432 101.178 428.328 98.7928 430.037 96.2891C431.746 93.7854 433.262 91.17 434.575 88.4629C435.887 85.7555 436.993 82.9632 437.884 80.1074C438.774 77.252 439.447 74.3402 439.897 71.3945C440.347 68.4486 440.573 65.4761 440.573 62.5C440.573 61.508 440.548 60.5162 440.498 59.5254C440.448 58.5349 440.373 57.5457 440.272 56.5586C440.172 55.5715 440.047 54.5869 439.896 53.6054C439.747 52.6238 439.572 51.6457 439.372 50.6719C439.173 49.6983 438.95 48.7292 438.702 47.7656C438.453 46.8013 438.181 45.8427 437.883 44.8906C437.587 43.9396 437.267 42.9953 436.922 42.0586C436.578 41.1214 436.21 40.1921 435.819 39.2715C435.427 38.3509 435.012 37.4391 434.574 36.5371C434.137 35.635 433.677 34.7428 433.195 33.8613C432.712 32.9797 432.208 32.109 431.682 31.25C431.155 30.3908 430.607 29.5435 430.037 28.7089C429.468 27.8749 428.878 27.0537 428.267 26.2461C427.655 25.4384 427.024 24.6446 426.372 23.8652C425.721 23.0853 425.051 22.3201 424.361 21.5703C423.672 20.8208 422.964 20.0869 422.237 19.3691C421.511 18.6514 420.766 17.95 420.004 17.2656C419.241 16.5816 418.461 15.9147 417.665 15.2656C416.869 14.616 416.057 13.9843 415.23 13.3711C414.402 12.7579 413.558 12.1633 412.7 11.5879C411.843 11.0131 410.971 10.4575 410.085 9.92184C409.199 9.38548 408.299 8.86906 407.387 8.373C406.475 7.87712 405.551 7.4017 404.615 6.94725C403.679 6.49314 402.732 6.06008 401.774 5.64841C400.816 5.23614 399.848 4.84542 398.87 4.47654C397.892 4.10804 396.905 3.76165 395.909 3.43748C394.914 3.11282 393.911 2.81078 392.9 2.53121C391.89 2.2519 390.873 1.99539 389.85 1.76171C388.827 1.52775 387.797 1.31669 386.763 1.12888C385.729 0.941039 384.691 0.776271 383.648 0.634727C382.606 0.494129 381.56 0.376955 380.511 0.283222C379.463 0.188734 378.413 0.117689 377.361 0.0704451C376.309 0.023201 375.255 -0.000233775 374.202 0.000144178L336.276 0ZM370.884 20.25C371.649 20.2502 372.413 20.2764 373.176 20.332C373.939 20.3857 374.701 20.4665 375.46 20.5742C376.218 20.6827 376.974 20.8184 377.724 20.9805C378.475 21.1423 379.221 21.3305 379.962 21.545C380.702 21.7604 381.436 22.0021 382.163 22.2696C382.891 22.5364 383.611 22.8287 384.323 23.1465C385.033 23.4648 385.735 23.8079 386.426 24.1758C387.118 24.5436 387.799 24.9356 388.469 25.3516C389.139 25.7682 389.797 26.2086 390.443 26.6719C391.089 27.1343 391.722 27.6196 392.341 28.127C392.959 28.6354 393.563 29.1656 394.152 29.7169C394.741 30.2673 395.315 30.8386 395.873 31.4298C396.43 32.0211 396.971 32.632 397.495 33.2618C398.018 33.8914 398.524 34.5396 399.012 35.2051C399.498 35.8713 399.966 36.5546 400.415 37.2539C400.865 37.9527 401.296 38.6672 401.706 39.3965C402.115 40.126 402.505 40.8699 402.873 41.627C403.241 42.384 403.588 43.154 403.913 43.9356C404.239 44.7176 404.543 45.5109 404.825 46.3145C405.106 47.1176 405.364 47.9305 405.6 48.752C405.837 49.5743 406.051 50.4048 406.241 51.2422C406.431 52.0786 406.598 52.9214 406.741 53.7696C406.884 54.6186 407.005 55.4726 407.1 56.3301C407.196 57.1874 407.267 58.0479 407.315 58.9102C407.363 59.7724 407.387 60.6361 407.387 61.5001C407.388 63.9011 407.203 66.2976 406.834 68.6622C406.464 71.027 405.913 73.351 405.186 75.6075C404.459 77.8643 403.559 80.0451 402.497 82.1251C401.434 84.2049 400.213 86.176 398.847 88.0157C397.481 89.855 395.976 91.5562 394.349 93.0996C392.72 94.6428 390.976 96.0224 389.136 97.2227C387.295 98.4234 385.366 99.4403 383.369 100.262C381.372 101.083 379.315 101.706 377.223 102.123C375.13 102.54 373.009 102.75 370.884 102.75V20.25Z" |
||||
fill="currentColor" |
||||
/> |
||||
<path |
||||
fill-rule="evenodd" |
||||
clip-rule="evenodd" |
||||
d="M252.629 0L255.947 11V114L252.629 125H315.681L326.11 95C326.11 95 312.21 103 287.236 103V11L290.555 0H252.629Z" |
||||
fill="currentColor" |
||||
/> |
||||
<path |
||||
fill-rule="evenodd" |
||||
clip-rule="evenodd" |
||||
d="M122.048 0.00245102L125.366 11.0022V84.2509C125.366 86.7978 125.577 89.34 125.996 91.8484C126.415 94.3561 127.041 96.8205 127.866 99.2135C128.693 101.607 129.716 103.92 130.924 106.125C132.131 108.331 133.518 110.421 135.07 112.371C136.623 114.322 138.333 116.127 140.183 117.764C142.034 119.401 144.016 120.865 146.108 122.139C147.973 123.25 149.918 124.206 151.924 125C152.168 125.123 152.413 125.243 152.659 125.361C154.929 126.233 157.266 126.893 159.645 127.336C162.023 127.778 164.434 128 166.849 128C169.263 128 171.673 127.778 174.051 127.336C176.429 126.893 178.766 126.233 181.036 125.361C183.305 124.49 185.498 123.412 187.59 122.139C189.681 120.865 191.662 119.401 193.512 117.764C194.447 116.864 195.342 115.92 196.195 114.934L193.158 125H231.085L227.766 114V11.0022L231.085 0.00245102H193.158L196.477 11.0022V82.0009V82.0772H196.469C196.467 83.6241 196.302 85.166 195.979 86.6748C195.647 88.2074 195.153 89.6959 194.506 91.1122C193.86 92.5292 193.064 93.865 192.134 95.0946C191.204 96.3237 190.146 97.4387 188.98 98.4187C187.815 99.399 186.549 100.238 185.206 100.921C183.863 101.603 182.451 102.124 180.997 102.473C179.543 102.824 178.057 103 176.565 103.001C175.075 103 173.589 102.823 172.136 102.473C170.682 102.124 169.27 101.603 167.926 100.921C166.583 100.238 165.317 99.399 164.152 98.4187C162.986 97.4387 161.928 96.3237 160.998 95.0946C160.069 93.865 159.273 92.5292 158.626 91.1122C157.979 89.6959 157.486 88.2074 157.154 86.6748C156.83 85.1661 156.664 83.6242 156.661 82.0772H156.656V82.0187L156.654 81.9998L156.656 81.9809V10.9998L159.974 0L122.048 0.00245102Z" |
||||
fill="currentColor" |
||||
/> |
||||
<path |
||||
fill-rule="evenodd" |
||||
clip-rule="evenodd" |
||||
d="M0 0L3.31851 11V114L0 125H37.9259L34.6074 114V72.1719L73.9556 125H111.882L59.2593 57.5L107.615 5.7671e-05H69.6889L34.6074 49.0001V11L37.9259 5.7671e-05L0 0Z" |
||||
fill="currentColor" |
||||
/> |
||||
</svg> |
||||
{:else} |
||||
<!-- VILLAK / TOPELTVILLAK / HÕBEVILLAK - Generic logo --> |
||||
<svg |
||||
class="text-kv-yellow {sizeClasses[size]} {className}" |
||||
viewBox="0 0 591 128" |
||||
fill="currentColor" |
||||
xmlns="http://www.w3.org/2000/svg" |
||||
role="img" |
||||
aria-label="Villak" |
||||
> |
||||
<path |
||||
fill-rule="evenodd" |
||||
clip-rule="evenodd" |
||||
d="M478.446 0L481.765 11V114L478.446 125H516.372L513.054 114V72.1719L552.402 125H590.328L537.706 57.5L586.061 0H548.135L513.054 49V11L516.372 0H478.446Z" |
||||
fill="currentColor" |
||||
/> |
||||
<path |
||||
d="M397.169 0L353.932 114L345.588 125L380.1 125L390.034 94.086H428.006L437.939 125L472.452 125L464.108 114L420.873 0H397.169ZM409.021 35L421.258 73.086H396.782L409.021 35Z" |
||||
fill="currentColor" |
||||
/> |
||||
<path |
||||
fill-rule="evenodd" |
||||
clip-rule="evenodd" |
||||
d="M273.699 0L277.018 11V114L273.699 125H336.751L347.181 95C347.181 95 333.281 103 308.307 103V11L311.625 0H273.699Z" |
||||
fill="currentColor" |
||||
/> |
||||
<path |
||||
fill-rule="evenodd" |
||||
clip-rule="evenodd" |
||||
d="M190.051 0L193.369 11V114L190.051 125H253.103L263.532 95C263.532 95 249.632 103 224.658 103V11L227.977 0H190.051Z" |
||||
fill="currentColor" |
||||
/> |
||||
<path |
||||
fill-rule="evenodd" |
||||
clip-rule="evenodd" |
||||
d="M134.374 0L137.693 11V114L134.374 125H172.3L168.981 114V11L172.3 0H134.374Z" |
||||
fill="currentColor" |
||||
/> |
||||
<path |
||||
d="M126.862 0L118.519 11L75.283 125H51.5793L8.3437 11L0 0H34.5126L63.4311 90L92.3497 0H126.862Z" |
||||
fill="currentColor" |
||||
/> |
||||
</svg> |
||||
{/if} |
||||
@ -0,0 +1,44 @@ |
||||
<script lang="ts"> |
||||
interface Props { |
||||
value: number; |
||||
min?: number; |
||||
max?: number; |
||||
step?: number; |
||||
disabled?: boolean; |
||||
size?: "sm" | "md"; |
||||
class?: string; |
||||
} |
||||
|
||||
let { |
||||
value = $bindable(0), |
||||
min = 0, |
||||
max = 9999, |
||||
step = 1, |
||||
disabled = false, |
||||
size = "md", |
||||
class: className = "", |
||||
}: Props = $props(); |
||||
|
||||
const sizeClasses = { |
||||
sm: "w-12 h-10 text-lg", |
||||
md: "w-16 h-12 text-xl", |
||||
}; |
||||
</script> |
||||
|
||||
<div |
||||
class="inline-flex items-center justify-center border-4 border-black bg-kv-blue |
||||
{sizeClasses[size]} |
||||
{className}" |
||||
> |
||||
<input |
||||
type="number" |
||||
bind:value |
||||
{min} |
||||
{max} |
||||
{step} |
||||
{disabled} |
||||
class="w-full h-full bg-transparent text-kv-white font-kv-body text-center border-none outline-none |
||||
[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none |
||||
{disabled ? 'opacity-50 cursor-not-allowed' : ''}" |
||||
/> |
||||
</div> |
||||
@ -0,0 +1,70 @@ |
||||
<script lang="ts"> |
||||
import type { Team } from "$lib/types/kuldvillak"; |
||||
|
||||
interface Props { |
||||
team: Team; |
||||
isActive?: boolean; |
||||
isAnswering?: boolean; |
||||
scoreAdjustment?: number; |
||||
onadjust?: (delta: number) => void; |
||||
onclick?: () => void; |
||||
disabled?: boolean; |
||||
class?: string; |
||||
} |
||||
|
||||
let { |
||||
team, |
||||
isActive = false, |
||||
isAnswering = false, |
||||
scoreAdjustment = 100, |
||||
onadjust, |
||||
onclick, |
||||
disabled = false, |
||||
class: className = "", |
||||
}: Props = $props(); |
||||
</script> |
||||
|
||||
<div |
||||
class="flex flex-col gap-4 items-center justify-center p-4 bg-kv-board transition-all |
||||
{isActive ? 'ring-4 ring-kv-yellow' : ''} |
||||
{isAnswering ? 'ring-4 ring-kv-green' : ''} |
||||
{disabled ? 'opacity-50' : ''} |
||||
{className}" |
||||
role={onclick ? "button" : undefined} |
||||
tabindex={onclick ? 0 : undefined} |
||||
{onclick} |
||||
onkeydown={(e) => e.key === "Enter" && onclick?.()} |
||||
> |
||||
<!-- Name and Score --> |
||||
<button |
||||
class="flex flex-col gap-2 items-center font-kv-body text-kv-white uppercase text-center w-full cursor-pointer hover:brightness-110 transition-all |
||||
{disabled ? 'cursor-not-allowed' : ''}" |
||||
{onclick} |
||||
{disabled} |
||||
> |
||||
<span class="text-3xl kv-shadow-text">{team.name}</span> |
||||
<span |
||||
class="text-3xl kv-shadow-text {team.score < 0 |
||||
? 'text-kv-red' |
||||
: ''}">{team.score}€</span |
||||
> |
||||
</button> |
||||
|
||||
<!-- Score Adjustment Buttons --> |
||||
{#if onadjust} |
||||
<div class="flex gap-4 items-center justify-center"> |
||||
<button |
||||
class="flex-1 min-w-[70px] h-10 bg-kv-green border-4 border-black flex items-center justify-center font-kv-body text-xl text-kv-white uppercase cursor-pointer hover:brightness-110 transition-all" |
||||
onclick={() => onadjust(scoreAdjustment)} |
||||
> |
||||
<span class="kv-shadow-text">+{scoreAdjustment}</span> |
||||
</button> |
||||
<button |
||||
class="flex-1 min-w-[70px] h-10 bg-kv-red border-4 border-black flex items-center justify-center font-kv-body text-xl text-kv-white uppercase cursor-pointer hover:brightness-110 transition-all" |
||||
onclick={() => onadjust(-scoreAdjustment)} |
||||
> |
||||
<span class="kv-shadow-text">-{scoreAdjustment}</span> |
||||
</button> |
||||
</div> |
||||
{/if} |
||||
</div> |
||||
@ -0,0 +1,67 @@ |
||||
<script lang="ts"> |
||||
interface Props { |
||||
variant: "category" | "price" | "player"; |
||||
text?: string; |
||||
score?: number; |
||||
isRevealed?: boolean; |
||||
isActive?: boolean; |
||||
class?: string; |
||||
} |
||||
|
||||
let { |
||||
variant, |
||||
text = "", |
||||
score = 0, |
||||
isRevealed = false, |
||||
isActive = false, |
||||
class: className = "", |
||||
}: Props = $props(); |
||||
</script> |
||||
|
||||
{#if variant === "category"} |
||||
<!-- Category Card for Projector --> |
||||
<div |
||||
class="bg-kv-board px-4 py-8 flex items-center justify-center overflow-hidden {className}" |
||||
> |
||||
<span |
||||
class="font-kv-body text-kv-white text-5xl text-center uppercase leading-tight kv-shadow-text" |
||||
> |
||||
{text || "Kategooria"} |
||||
</span> |
||||
</div> |
||||
{:else if variant === "price"} |
||||
<!-- Price Card for Projector --> |
||||
<div |
||||
class="bg-kv-board flex items-center justify-center overflow-hidden {className}" |
||||
> |
||||
{#if isRevealed} |
||||
<span class="opacity-0">{text}</span> |
||||
{:else} |
||||
<span |
||||
class="font-kv-price text-kv-yellow text-7xl text-center kv-shadow-price" |
||||
> |
||||
{text} |
||||
</span> |
||||
{/if} |
||||
</div> |
||||
{:else if variant === "player"} |
||||
<!-- Player Score Card for Projector --> |
||||
<div |
||||
class="bg-kv-board px-4 py-4 flex flex-col items-center justify-center gap-1 overflow-hidden |
||||
{isActive ? 'ring-4 ring-kv-yellow' : ''} |
||||
{className}" |
||||
> |
||||
<span |
||||
class="font-kv-body text-kv-white text-3xl text-center uppercase leading-tight kv-shadow-text" |
||||
> |
||||
{text} |
||||
</span> |
||||
<span |
||||
class="font-kv-body text-3xl text-center kv-shadow-text {score < 0 |
||||
? 'text-red-500' |
||||
: 'text-kv-white'}" |
||||
> |
||||
{score}€ |
||||
</span> |
||||
</div> |
||||
{/if} |
||||
@ -0,0 +1,16 @@ |
||||
// Kuldvillak UI Components
|
||||
|
||||
// Buttons
|
||||
export { default as KvButtonPrimary } from './KvButtonPrimary.svelte'; |
||||
export { default as KvButtonSecondary } from './KvButtonSecondary.svelte'; |
||||
|
||||
// Form Controls
|
||||
export { default as KvNumberInput } from './KvNumberInput.svelte'; |
||||
|
||||
// Cards
|
||||
export { default as KvProjectorCard } from './KvProjectorCard.svelte'; |
||||
export { default as KvPlayerCard } from './KvPlayerCard.svelte'; |
||||
|
||||
// Branding
|
||||
export { default as KvLogo } from './KvGameLogo.svelte'; |
||||
export { default as KvGameLogo } from './KvGameLogo.svelte'; |
||||
@ -0,0 +1,328 @@ |
||||
{ |
||||
"name": "9. Klassi Viktoriini", |
||||
"settings": { |
||||
"numberOfRounds": 1, |
||||
"pointValuePreset": "round1", |
||||
"pointValues": [ |
||||
10, |
||||
20, |
||||
30, |
||||
40, |
||||
50 |
||||
], |
||||
"basePointValue": 10, |
||||
"categoriesPerRound": 6, |
||||
"questionsPerCategory": 5, |
||||
"dailyDoublesPerRound": [ |
||||
1 |
||||
], |
||||
"enableFinalRound": true, |
||||
"enableSoundEffects": true, |
||||
"allowNegativeScores": true, |
||||
"maxTeams": 6, |
||||
"defaultTimerSeconds": 15, |
||||
"answerRevealSeconds": 5 |
||||
}, |
||||
"teams": [ |
||||
{ |
||||
"id": "r5f48jjat", |
||||
"name": "Tiim 1", |
||||
"score": 0 |
||||
}, |
||||
{ |
||||
"id": "lnbeg51uo", |
||||
"name": "Tiim 2", |
||||
"score": 0 |
||||
} |
||||
], |
||||
"rounds": [ |
||||
{ |
||||
"id": "28mrdbeas", |
||||
"name": "Villak", |
||||
"categories": [ |
||||
{ |
||||
"id": "lsc3pphby", |
||||
"name": "EESTI AJALUGU", |
||||
"questions": [ |
||||
{ |
||||
"id": "a11mxf6ra", |
||||
"question": "Mis aastal kuulutati välja Eesti Vabariik?", |
||||
"answer": "1918", |
||||
"points": 10, |
||||
"isDailyDouble": false, |
||||
"isRevealed": false |
||||
}, |
||||
{ |
||||
"id": "j7ztvxf7l", |
||||
"question": "Kes oli Eesti Vabariigi esimene riigivanem?", |
||||
"answer": "Konstantin Päts", |
||||
"points": 20, |
||||
"isDailyDouble": false, |
||||
"isRevealed": false |
||||
}, |
||||
{ |
||||
"id": "cwcx4jf7m", |
||||
"question": "Mis aastal toimus Laulev revolutsioon?", |
||||
"answer": "1988", |
||||
"points": 30, |
||||
"isDailyDouble": false, |
||||
"isRevealed": false |
||||
}, |
||||
{ |
||||
"id": "7m6e1qdwx", |
||||
"question": "Mis oli Balti keti kuupäev 1989. aastal?", |
||||
"answer": "23. august", |
||||
"points": 40, |
||||
"isDailyDouble": false, |
||||
"isRevealed": false |
||||
}, |
||||
{ |
||||
"id": "mmudm4kjd", |
||||
"question": "Mis aastal toimus Jüriöö ülestõus?", |
||||
"answer": "1343", |
||||
"points": 50, |
||||
"isDailyDouble": false, |
||||
"isRevealed": false |
||||
} |
||||
] |
||||
}, |
||||
{ |
||||
"id": "sfkprzqyz", |
||||
"name": "MATEMAATIKA", |
||||
"questions": [ |
||||
{ |
||||
"id": "zfg5pe8pl", |
||||
"question": "Mis on ruutjuur 144-st?", |
||||
"answer": "12", |
||||
"points": 10, |
||||
"isDailyDouble": false, |
||||
"isRevealed": false |
||||
}, |
||||
{ |
||||
"id": "ds2bmll5z", |
||||
"question": "Mis on Pythagorase teoreemi valem?", |
||||
"answer": "a² + b² = c²", |
||||
"points": 20, |
||||
"isDailyDouble": false, |
||||
"isRevealed": false |
||||
}, |
||||
{ |
||||
"id": "6jkk4qkwo", |
||||
"question": "Mis on arvu pi (π) väärtus kahe komakohani?", |
||||
"answer": "3,14", |
||||
"points": 30, |
||||
"isDailyDouble": false, |
||||
"isRevealed": false |
||||
}, |
||||
{ |
||||
"id": "h32bk4gll", |
||||
"question": "Kui kolmnurga alus on 8 cm ja kõrgus 6 cm, mis on pindala?", |
||||
"answer": "24 cm²", |
||||
"points": 40, |
||||
"isDailyDouble": true, |
||||
"isRevealed": false |
||||
}, |
||||
{ |
||||
"id": "a2vjrjbuo", |
||||
"question": "Lahenda võrrand: 3x + 7 = 22", |
||||
"answer": "x = 5", |
||||
"points": 50, |
||||
"isDailyDouble": false, |
||||
"isRevealed": false |
||||
} |
||||
] |
||||
}, |
||||
{ |
||||
"id": "resy4vkyc", |
||||
"name": "LOODUS", |
||||
"questions": [ |
||||
{ |
||||
"id": "a0tz6k2a5", |
||||
"question": "Mis on vee keemik valem?", |
||||
"answer": "H₂O", |
||||
"points": 10, |
||||
"isDailyDouble": false, |
||||
"isRevealed": false |
||||
}, |
||||
{ |
||||
"id": "1kjss32t3", |
||||
"question": "Mitu planeeti on meie päikesesüsteemis?", |
||||
"answer": "8", |
||||
"points": 20, |
||||
"isDailyDouble": false, |
||||
"isRevealed": false |
||||
}, |
||||
{ |
||||
"id": "4acp3zwgh", |
||||
"question": "Mis on fotosünteesi põhiprodukt?", |
||||
"answer": "Glükoos (suhkur) ja hapnik", |
||||
"points": 30, |
||||
"isDailyDouble": false, |
||||
"isRevealed": false |
||||
}, |
||||
{ |
||||
"id": "fh1jn2z6f", |
||||
"question": "Mis element on perioodilisustabelis tähisega Fe?", |
||||
"answer": "Raud", |
||||
"points": 40, |
||||
"isDailyDouble": false, |
||||
"isRevealed": false |
||||
}, |
||||
{ |
||||
"id": "wd96bi1d5", |
||||
"question": "Mis on DNA täisnimi?", |
||||
"answer": "Desoksüribonukleiinhape", |
||||
"points": 50, |
||||
"isDailyDouble": false, |
||||
"isRevealed": false |
||||
} |
||||
] |
||||
}, |
||||
{ |
||||
"id": "iy06ybvmy", |
||||
"name": "KIRJANDUS", |
||||
"questions": [ |
||||
{ |
||||
"id": "he7in5nor", |
||||
"question": "Kes kirjutas eepose 'Kalevipoeg'?", |
||||
"answer": "Friedrich Reinhold Kreutzwald", |
||||
"points": 10, |
||||
"isDailyDouble": false, |
||||
"isRevealed": false |
||||
}, |
||||
{ |
||||
"id": "pafvsp6t9", |
||||
"question": "Mis on A. H. Tammsaare tuntuim romaan?", |
||||
"answer": "Tõde ja õigus", |
||||
"points": 20, |
||||
"isDailyDouble": false, |
||||
"isRevealed": false |
||||
}, |
||||
{ |
||||
"id": "rjjp0h8s4", |
||||
"question": "Kes kirjutas luuletuse 'Mu isamaa on minu arm'?", |
||||
"answer": "Lydia Koidula", |
||||
"points": 30, |
||||
"isDailyDouble": false, |
||||
"isRevealed": false |
||||
}, |
||||
{ |
||||
"id": "n7o3vkkcn", |
||||
"question": "Mis on soneti traditsiooniline värsside arv?", |
||||
"answer": "14 rida", |
||||
"points": 40, |
||||
"isDailyDouble": false, |
||||
"isRevealed": false |
||||
}, |
||||
{ |
||||
"id": "4hxuoa1fy", |
||||
"question": "Kes kirjutas romaani 'Kevade'?", |
||||
"answer": "Oskar Luts", |
||||
"points": 50, |
||||
"isDailyDouble": false, |
||||
"isRevealed": false |
||||
} |
||||
] |
||||
}, |
||||
{ |
||||
"id": "g9r2whn2u", |
||||
"name": "GEOGRAAFIA", |
||||
"questions": [ |
||||
{ |
||||
"id": "4qkzwwe7r", |
||||
"question": "Mis on Eesti pealinn?", |
||||
"answer": "Tallinn", |
||||
"points": 10, |
||||
"isDailyDouble": false, |
||||
"isRevealed": false |
||||
}, |
||||
{ |
||||
"id": "7z3113es0", |
||||
"question": "Mis on Eesti kõrgeim mägi?", |
||||
"answer": "Suur Munamägi", |
||||
"points": 20, |
||||
"isDailyDouble": false, |
||||
"isRevealed": false |
||||
}, |
||||
{ |
||||
"id": "so7tqkk16", |
||||
"question": "Mis on maailma suurim ookean?", |
||||
"answer": "Vaikne ookean", |
||||
"points": 30, |
||||
"isDailyDouble": false, |
||||
"isRevealed": false |
||||
}, |
||||
{ |
||||
"id": "h3gyk1yi3", |
||||
"question": "Mis riik on pindalalt maailma suurim?", |
||||
"answer": "Venemaa", |
||||
"points": 40, |
||||
"isDailyDouble": false, |
||||
"isRevealed": false |
||||
}, |
||||
{ |
||||
"id": "385aprc7p", |
||||
"question": "Mis on Eesti suurim saar?", |
||||
"answer": "Saaremaa", |
||||
"points": 50, |
||||
"isDailyDouble": false, |
||||
"isRevealed": false |
||||
} |
||||
] |
||||
}, |
||||
{ |
||||
"id": "4y809i7nr", |
||||
"name": "ÜLDTEADMISED", |
||||
"questions": [ |
||||
{ |
||||
"id": "jzcjmb4ef", |
||||
"question": "Mis värvid on Eesti lipul?", |
||||
"answer": "Sinine, must, valge", |
||||
"points": 10, |
||||
"isDailyDouble": false, |
||||
"isRevealed": false |
||||
}, |
||||
{ |
||||
"id": "n86mk1kg0", |
||||
"question": "Mis on Eesti rahvuslind?", |
||||
"answer": "Suitsupääsuke", |
||||
"points": 20, |
||||
"isDailyDouble": false, |
||||
"isRevealed": false |
||||
}, |
||||
{ |
||||
"id": "04e5zactm", |
||||
"question": "Mis aastal liitus Eesti Euroopa Liiduga?", |
||||
"answer": "2004", |
||||
"points": 30, |
||||
"isDailyDouble": false, |
||||
"isRevealed": false |
||||
}, |
||||
{ |
||||
"id": "xwhxh99j6", |
||||
"question": "Mis on Eesti rahvuslill?", |
||||
"answer": "Rukkilill", |
||||
"points": 40, |
||||
"isDailyDouble": false, |
||||
"isRevealed": false |
||||
}, |
||||
{ |
||||
"id": "n54fmwmg8", |
||||
"question": "Mis aastal võttis Eesti kasutusele euro?", |
||||
"answer": "2011", |
||||
"points": 50, |
||||
"isDailyDouble": false, |
||||
"isRevealed": false |
||||
} |
||||
] |
||||
} |
||||
], |
||||
"pointMultiplier": 1 |
||||
} |
||||
], |
||||
"finalRound": { |
||||
"category": "EESTI KULTUUR", |
||||
"question": "Mis aastal võitis Eesti esimest korda Eurovisiooni lauluvõistluse ja mis laul see oli?", |
||||
"answer": "2001. aastal lauluga 'Everybody' (Tanel Padar, Dave Benton ja 2XL)" |
||||
} |
||||
} |
||||
@ -0,0 +1,15 @@ |
||||
// ============================================
|
||||
// Ultimate Gaming - Library Exports
|
||||
// ============================================
|
||||
|
||||
// Kuldvillak (Jeopardy) Types
|
||||
export * from './types/kuldvillak'; |
||||
|
||||
// Kuldvillak Store
|
||||
export { kuldvillakStore } from './stores/kuldvillak.svelte'; |
||||
|
||||
// Persistence (Save/Load)
|
||||
export * from './stores/persistence'; |
||||
|
||||
// UI Components
|
||||
export * from './components'; |
||||
@ -0,0 +1,72 @@ |
||||
import { browser } from '$app/environment'; |
||||
|
||||
class AudioStore { |
||||
private audio: HTMLAudioElement | null = null; |
||||
private initialized = false; |
||||
|
||||
musicVolume = $state(50); |
||||
sfxVolume = $state(100); |
||||
|
||||
constructor() { |
||||
if (browser) { |
||||
// Load saved volumes
|
||||
const savedMusic = localStorage.getItem('kv_music_volume'); |
||||
const savedSfx = localStorage.getItem('kv_sfx_volume'); |
||||
if (savedMusic) this.musicVolume = parseInt(savedMusic); |
||||
if (savedSfx) this.sfxVolume = parseInt(savedSfx); |
||||
} |
||||
} |
||||
|
||||
initMusic(src: string) { |
||||
if (!browser || this.initialized) return; |
||||
|
||||
this.audio = new Audio(src); |
||||
this.audio.loop = true; |
||||
this.audio.volume = this.musicVolume / 100; |
||||
this.initialized = true; |
||||
|
||||
// Try to play
|
||||
this.audio.play().catch(() => { |
||||
const playOnInteraction = () => { |
||||
this.audio?.play(); |
||||
document.removeEventListener('click', playOnInteraction); |
||||
}; |
||||
document.addEventListener('click', playOnInteraction); |
||||
}); |
||||
} |
||||
|
||||
setMusicVolume(value: number) { |
||||
this.musicVolume = value; |
||||
if (this.audio) { |
||||
this.audio.volume = value / 100; |
||||
} |
||||
if (browser) { |
||||
localStorage.setItem('kv_music_volume', String(value)); |
||||
} |
||||
} |
||||
|
||||
setSfxVolume(value: number) { |
||||
this.sfxVolume = value; |
||||
if (browser) { |
||||
localStorage.setItem('kv_sfx_volume', String(value)); |
||||
} |
||||
} |
||||
|
||||
stopMusic() { |
||||
if (this.audio) { |
||||
this.audio.pause(); |
||||
this.audio.currentTime = 0; |
||||
} |
||||
} |
||||
|
||||
destroy() { |
||||
if (this.audio) { |
||||
this.audio.pause(); |
||||
this.audio.src = ''; |
||||
this.audio = null; |
||||
this.initialized = false; |
||||
} |
||||
} |
||||
} |
||||
|
||||
export const audioStore = new AudioStore(); |
||||
@ -0,0 +1,623 @@ |
||||
import { browser } from "$app/environment"; |
||||
import type { Team, Round, FinalRound, GameSettings, GamePhase, QuestionResult } from "$lib/types/kuldvillak"; |
||||
|
||||
// Game session state that syncs across tabs
|
||||
export interface GameSessionState { |
||||
// Game data
|
||||
name: string; |
||||
settings: GameSettings; |
||||
teams: Team[]; |
||||
rounds: Round[]; |
||||
finalRound: FinalRound | null; |
||||
|
||||
// Current game state
|
||||
phase: GamePhase; |
||||
currentRoundIndex: number; |
||||
activeTeamId: string | null; |
||||
|
||||
// Intro animation state
|
||||
introCategoryIndex: number; // Which category is being shown during intro
|
||||
categoriesIntroduced: boolean; // Have all categories been introduced for this round
|
||||
boardRevealed: boolean; // Has the board been revealed (prices faded in) for this round
|
||||
|
||||
// Question state
|
||||
currentQuestion: { |
||||
roundIndex: number; |
||||
categoryIndex: number; |
||||
questionIndex: number; |
||||
} | null; |
||||
showAnswer: boolean; |
||||
wrongTeamIds: string[]; // Teams that answered wrong for current question
|
||||
lastAnsweredTeamId: string | null; // Track who answered last
|
||||
lastAnswerCorrect: boolean | null; // Was it correct or wrong
|
||||
|
||||
// Daily Double
|
||||
dailyDoubleWager: number | null; |
||||
|
||||
// Final Round
|
||||
finalCategoryRevealed: boolean; // Has the final category been revealed
|
||||
finalWagers: Record<string, number>; |
||||
finalAnswers: Record<string, string>; |
||||
finalRevealed: string[]; // Team IDs that have been revealed
|
||||
|
||||
// Timer
|
||||
timerRunning: boolean; |
||||
timerSeconds: number; |
||||
timerMax: number; |
||||
|
||||
// Question tracking
|
||||
questionsAnswered: number; // How many questions have been answered
|
||||
currentQuestionNumber: number; // Which question number is this (1-30)
|
||||
questionResults: QuestionResult[]; // Results of answered questions
|
||||
} |
||||
|
||||
const CHANNEL_NAME = "kuldvillak-game-session"; |
||||
const STORAGE_KEY = "kuldvillak-game-session"; |
||||
|
||||
class GameSessionStore { |
||||
private channel: BroadcastChannel | null = null; |
||||
private timerInterval: ReturnType<typeof setInterval> | null = null; |
||||
private isTimerOwner = false; // Only one tab should own the timer
|
||||
state = $state<GameSessionState | null>(null); |
||||
|
||||
constructor() { |
||||
if (browser) { |
||||
// Setup broadcast channel for cross-tab sync
|
||||
this.channel = new BroadcastChannel(CHANNEL_NAME); |
||||
this.channel.onmessage = (event) => { |
||||
if (event.data.type === "STATE_UPDATE") { |
||||
this.state = event.data.state; |
||||
} else if (event.data.type === "REQUEST_STATE") { |
||||
// Another tab is requesting the current state
|
||||
if (this.state) { |
||||
this.broadcast("STATE_UPDATE", this.state); |
||||
} |
||||
} else if (event.data.type === "TIMER_OWNER_CHECK") { |
||||
// Another tab is checking who owns the timer
|
||||
if (this.isTimerOwner) { |
||||
this.channel?.postMessage({ type: "TIMER_OWNER_EXISTS" }); |
||||
} |
||||
} else if (event.data.type === "TIMER_OWNER_EXISTS") { |
||||
// Another tab owns the timer, don't start ours
|
||||
this.isTimerOwner = false; |
||||
} |
||||
}; |
||||
|
||||
// Try to load from localStorage
|
||||
const saved = localStorage.getItem(STORAGE_KEY); |
||||
if (saved) { |
||||
try { |
||||
this.state = JSON.parse(saved); |
||||
// Timer will be started by moderator view via enableTimerControl()
|
||||
} catch { |
||||
// Invalid data
|
||||
} |
||||
} |
||||
|
||||
// Request state from other tabs
|
||||
this.channel.postMessage({ type: "REQUEST_STATE" }); |
||||
} |
||||
} |
||||
|
||||
private broadcast(type: string, state: GameSessionState) { |
||||
if (this.channel) { |
||||
// Use $state.snapshot to get plain object from Proxy
|
||||
const plainState = $state.snapshot(state); |
||||
this.channel.postMessage({ type, state: plainState }); |
||||
} |
||||
} |
||||
|
||||
private persist() { |
||||
if (browser && this.state) { |
||||
// Use $state.snapshot to get plain object from Proxy
|
||||
const plainState = $state.snapshot(this.state); |
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(plainState)); |
||||
this.broadcast("STATE_UPDATE", this.state); |
||||
} |
||||
} |
||||
|
||||
// Initialize a new game session
|
||||
startGame(data: { |
||||
name: string; |
||||
settings: GameSettings; |
||||
teams: Team[]; |
||||
rounds: Round[]; |
||||
finalRound: FinalRound | null; |
||||
}) { |
||||
// Deep clone the data to remove any Proxy objects
|
||||
const plainData = JSON.parse(JSON.stringify(data)); |
||||
|
||||
this.state = { |
||||
...plainData, |
||||
phase: "intro" as const, |
||||
currentRoundIndex: 0, |
||||
activeTeamId: null, |
||||
introCategoryIndex: -1, |
||||
categoriesIntroduced: false, |
||||
boardRevealed: false, |
||||
currentQuestion: null, |
||||
showAnswer: false, |
||||
wrongTeamIds: [], |
||||
lastAnsweredTeamId: null, |
||||
lastAnswerCorrect: null, |
||||
dailyDoubleWager: null, |
||||
finalCategoryRevealed: false, |
||||
finalWagers: {}, |
||||
finalAnswers: {}, |
||||
finalRevealed: [], |
||||
timerRunning: false, |
||||
timerSeconds: 0, |
||||
timerMax: plainData.settings.defaultTimerSeconds ?? 10, |
||||
questionsAnswered: 0, |
||||
currentQuestionNumber: 0, |
||||
questionResults: [], |
||||
}; |
||||
// Timer will be started by moderator view via enableTimerControl()
|
||||
this.persist(); |
||||
} |
||||
|
||||
// ============================================
|
||||
// Intro Phase Management
|
||||
// ============================================
|
||||
|
||||
startCategoryIntro() { |
||||
if (!this.state) return; |
||||
this.state.phase = "intro-categories"; |
||||
this.state.introCategoryIndex = 0; |
||||
this.persist(); |
||||
} |
||||
|
||||
nextIntroCategory() { |
||||
if (!this.state) return; |
||||
const currentRound = this.state.rounds[this.state.currentRoundIndex]; |
||||
if (!currentRound) return; |
||||
|
||||
this.state.introCategoryIndex++; |
||||
|
||||
// Check if we've shown all categories - stay on villak screen
|
||||
if (this.state.introCategoryIndex >= currentRound.categories.length) { |
||||
this.state.phase = "intro"; |
||||
this.state.introCategoryIndex = -1; |
||||
this.state.categoriesIntroduced = true; // Mark categories as introduced
|
||||
} |
||||
this.persist(); |
||||
} |
||||
|
||||
startBoard() { |
||||
if (!this.state) return; |
||||
this.state.phase = "board"; |
||||
this.persist(); |
||||
} |
||||
|
||||
markBoardRevealed() { |
||||
if (!this.state) return; |
||||
this.state.boardRevealed = true; |
||||
this.persist(); |
||||
} |
||||
|
||||
// End the game session
|
||||
endGame() { |
||||
this.stopInternalTimer(); |
||||
this.state = null; |
||||
if (browser) { |
||||
localStorage.removeItem(STORAGE_KEY); |
||||
this.broadcast("STATE_UPDATE", null as any); |
||||
} |
||||
} |
||||
|
||||
// ============================================
|
||||
// Question Management
|
||||
// ============================================
|
||||
|
||||
selectQuestion(roundIndex: number, categoryIndex: number, questionIndex: number) { |
||||
if (!this.state) return; |
||||
|
||||
const question = this.state.rounds[roundIndex]?.categories[categoryIndex]?.questions[questionIndex]; |
||||
if (!question || question.isRevealed) return; |
||||
|
||||
this.state.currentQuestion = { roundIndex, categoryIndex, questionIndex }; |
||||
this.state.wrongTeamIds = []; |
||||
this.state.activeTeamId = null; |
||||
this.state.currentQuestionNumber = this.state.questionsAnswered + 1; |
||||
|
||||
if (question.isDailyDouble) { |
||||
this.state.phase = "daily-double"; |
||||
this.state.dailyDoubleWager = null; |
||||
} else { |
||||
this.state.phase = "question"; |
||||
} |
||||
|
||||
this.state.showAnswer = false; |
||||
|
||||
// Reset timer
|
||||
this.state.timerSeconds = this.state.timerMax; |
||||
this.state.timerRunning = false; |
||||
|
||||
this.persist(); |
||||
} |
||||
|
||||
setDailyDoubleWager(teamId: string, wager: number) { |
||||
if (!this.state) return; |
||||
this.state.activeTeamId = teamId; |
||||
this.state.dailyDoubleWager = wager; |
||||
this.state.phase = "question"; |
||||
this.persist(); |
||||
} |
||||
|
||||
toggleAnswer() { |
||||
if (!this.state) return; |
||||
this.state.showAnswer = !this.state.showAnswer; |
||||
this.persist(); |
||||
} |
||||
|
||||
revealAnswer() { |
||||
if (!this.state) return; |
||||
this.state.showAnswer = true; |
||||
this.persist(); |
||||
} |
||||
|
||||
// Mark answer correct - awards points, shows answer, closes after delay
|
||||
markCorrect(teamId: string) { |
||||
if (!this.state || !this.state.currentQuestion) return; |
||||
|
||||
const { roundIndex, categoryIndex, questionIndex } = this.state.currentQuestion; |
||||
const question = this.state.rounds[roundIndex].categories[categoryIndex].questions[questionIndex]; |
||||
|
||||
// Award points
|
||||
const team = this.state.teams.find(t => t.id === teamId); |
||||
if (team) { |
||||
const points = this.state.dailyDoubleWager ?? question.points; |
||||
team.score += points; |
||||
} |
||||
|
||||
// Track last answer
|
||||
this.state.lastAnsweredTeamId = teamId; |
||||
this.state.lastAnswerCorrect = true; |
||||
|
||||
// Show answer and close after configured delay
|
||||
this.state.showAnswer = true; |
||||
this.persist(); |
||||
|
||||
const revealMs = (this.state.settings.answerRevealSeconds ?? 5) * 1000; |
||||
setTimeout(() => this.finalizeQuestion(), revealMs); |
||||
} |
||||
|
||||
// Mark answer wrong - deducts points, adds to wrong list
|
||||
markWrong(teamId: string) { |
||||
if (!this.state || !this.state.currentQuestion) return; |
||||
|
||||
const { roundIndex, categoryIndex, questionIndex } = this.state.currentQuestion; |
||||
const question = this.state.rounds[roundIndex].categories[categoryIndex].questions[questionIndex]; |
||||
|
||||
// Deduct points if allowed
|
||||
const team = this.state.teams.find(t => t.id === teamId); |
||||
if (team && this.state.settings.allowNegativeScores) { |
||||
const points = this.state.dailyDoubleWager ?? question.points; |
||||
team.score -= points; |
||||
} |
||||
|
||||
// Track last answer
|
||||
this.state.lastAnsweredTeamId = teamId; |
||||
this.state.lastAnswerCorrect = false; |
||||
|
||||
// Add to wrong list
|
||||
if (!this.state.wrongTeamIds.includes(teamId)) { |
||||
this.state.wrongTeamIds.push(teamId); |
||||
} |
||||
|
||||
// Clear active team for next selection
|
||||
this.state.activeTeamId = null; |
||||
|
||||
// Check if all teams have answered wrong
|
||||
const allTeamsWrong = this.state.teams.every(t => this.state!.wrongTeamIds.includes(t.id)); |
||||
if (allTeamsWrong) { |
||||
// Everyone wrong - show answer and close
|
||||
this.state.showAnswer = true; |
||||
this.persist(); |
||||
const revealMs = (this.state.settings.answerRevealSeconds ?? 5) * 1000; |
||||
setTimeout(() => this.finalizeQuestion(), revealMs); |
||||
} else { |
||||
this.persist(); |
||||
} |
||||
} |
||||
|
||||
// Skip question - shows answer, closes after delay
|
||||
skipQuestion() { |
||||
if (!this.state || !this.state.currentQuestion) return; |
||||
|
||||
this.state.showAnswer = true; |
||||
this.persist(); |
||||
const revealMs = (this.state.settings.answerRevealSeconds ?? 5) * 1000; |
||||
setTimeout(() => this.finalizeQuestion(), revealMs); |
||||
} |
||||
|
||||
// Actually close the question and return to board
|
||||
private finalizeQuestion() { |
||||
if (!this.state || !this.state.currentQuestion) return; |
||||
|
||||
const { roundIndex, categoryIndex, questionIndex } = this.state.currentQuestion; |
||||
const question = this.state.rounds[roundIndex].categories[categoryIndex].questions[questionIndex]; |
||||
|
||||
// Save question result before resetting state
|
||||
const points = this.state.dailyDoubleWager ?? question.points; |
||||
let pointsChange = 0; |
||||
if (this.state.lastAnswerCorrect === true) { |
||||
pointsChange = points; |
||||
} else if (this.state.lastAnswerCorrect === false) { |
||||
pointsChange = -points; |
||||
} |
||||
|
||||
this.state.questionResults.push({ |
||||
categoryIndex, |
||||
questionIndex, |
||||
points: question.points, |
||||
teamId: this.state.lastAnsweredTeamId, |
||||
pointsChange, |
||||
isDailyDouble: question.isDailyDouble, |
||||
wager: this.state.dailyDoubleWager ?? undefined, |
||||
}); |
||||
|
||||
// Mark as revealed
|
||||
question.isRevealed = true; |
||||
|
||||
// Increment questions answered counter
|
||||
this.state.questionsAnswered++; |
||||
|
||||
// Reset state
|
||||
this.state.currentQuestion = null; |
||||
this.state.showAnswer = false; |
||||
this.state.wrongTeamIds = []; |
||||
this.state.dailyDoubleWager = null; |
||||
this.state.activeTeamId = null; |
||||
this.state.phase = "board"; |
||||
|
||||
// Check if round is complete
|
||||
this.checkRoundComplete(); |
||||
this.persist(); |
||||
} |
||||
|
||||
// Legacy method for compatibility
|
||||
closeQuestion(correct: boolean | null, teamId?: string | null) { |
||||
if (correct === true && teamId) { |
||||
this.markCorrect(teamId); |
||||
} else if (correct === false && teamId) { |
||||
this.markWrong(teamId); |
||||
} else { |
||||
this.skipQuestion(); |
||||
} |
||||
} |
||||
|
||||
private checkRoundComplete() { |
||||
if (!this.state) return; |
||||
|
||||
const currentRound = this.state.rounds[this.state.currentRoundIndex]; |
||||
const allRevealed = currentRound.categories.every(cat => |
||||
cat.questions.every(q => q.isRevealed) |
||||
); |
||||
|
||||
if (allRevealed) { |
||||
// Move to next round or final
|
||||
if (this.state.currentRoundIndex < this.state.rounds.length - 1) { |
||||
this.state.currentRoundIndex++; |
||||
} else if (this.state.settings.enableFinalRound && this.state.finalRound) { |
||||
this.state.phase = "final-category"; |
||||
} else { |
||||
this.state.phase = "finished"; |
||||
} |
||||
} |
||||
} |
||||
|
||||
// ============================================
|
||||
// Team & Score Management
|
||||
// ============================================
|
||||
|
||||
setActiveTeam(teamId: string | null) { |
||||
if (!this.state) return; |
||||
this.state.activeTeamId = teamId; |
||||
this.persist(); |
||||
} |
||||
|
||||
adjustScore(teamId: string, delta: number) { |
||||
if (!this.state) return; |
||||
const team = this.state.teams.find(t => t.id === teamId); |
||||
if (team) { |
||||
team.score += delta; |
||||
this.persist(); |
||||
} |
||||
} |
||||
|
||||
setScore(teamId: string, score: number) { |
||||
if (!this.state) return; |
||||
const team = this.state.teams.find(t => t.id === teamId); |
||||
if (team) { |
||||
team.score = score; |
||||
this.persist(); |
||||
} |
||||
} |
||||
|
||||
// ============================================
|
||||
// Round Management
|
||||
// ============================================
|
||||
|
||||
nextRound() { |
||||
if (!this.state) return; |
||||
if (this.state.currentRoundIndex < this.state.rounds.length - 1) { |
||||
this.state.currentRoundIndex++; |
||||
this.state.phase = "intro"; |
||||
this.state.introCategoryIndex = -1; |
||||
this.state.categoriesIntroduced = false; // Reset for new round
|
||||
this.state.boardRevealed = false; // Reset for new round
|
||||
this.state.questionResults = []; |
||||
this.persist(); |
||||
} |
||||
} |
||||
|
||||
goToFinalRound() { |
||||
if (!this.state || !this.state.finalRound) return; |
||||
this.state.phase = "final-intro"; |
||||
this.persist(); |
||||
} |
||||
|
||||
startFinalCategoryReveal() { |
||||
if (!this.state) return; |
||||
this.state.phase = "final-category"; |
||||
this.persist(); |
||||
} |
||||
|
||||
finishFinalCategoryReveal() { |
||||
if (!this.state) return; |
||||
// Go back to Kuldvillak screen, waiting for moderator to start question
|
||||
this.state.phase = "final-intro"; |
||||
this.state.finalCategoryRevealed = true; |
||||
this.persist(); |
||||
} |
||||
|
||||
// ============================================
|
||||
// Final Round
|
||||
// ============================================
|
||||
|
||||
setFinalWager(teamId: string, wager: number) { |
||||
if (!this.state) return; |
||||
this.state.finalWagers[teamId] = wager; |
||||
this.persist(); |
||||
} |
||||
|
||||
showFinalQuestion() { |
||||
if (!this.state) return; |
||||
this.state.phase = "final-question"; |
||||
this.state.timerMax = 30; // Set 30 second timer for final round
|
||||
this.state.timerSeconds = 30; |
||||
this.persist(); |
||||
} |
||||
|
||||
showFinalScores() { |
||||
if (!this.state) return; |
||||
this.state.phase = "final-scores"; |
||||
this.persist(); |
||||
} |
||||
|
||||
setFinalAnswer(teamId: string, answer: string) { |
||||
if (!this.state) return; |
||||
this.state.finalAnswers[teamId] = answer; |
||||
this.persist(); |
||||
} |
||||
|
||||
revealFinalAnswer(teamId: string, correct: boolean) { |
||||
if (!this.state) return; |
||||
|
||||
const team = this.state.teams.find(t => t.id === teamId); |
||||
const wager = this.state.finalWagers[teamId] ?? 0; |
||||
|
||||
if (team) { |
||||
if (correct) { |
||||
team.score += wager; |
||||
} else { |
||||
team.score -= wager; |
||||
} |
||||
} |
||||
|
||||
this.state.finalRevealed.push(teamId); |
||||
|
||||
// Check if all revealed
|
||||
if (this.state.finalRevealed.length === this.state.teams.length) { |
||||
this.state.phase = "finished"; |
||||
} |
||||
|
||||
this.persist(); |
||||
} |
||||
|
||||
// ============================================
|
||||
// Timer
|
||||
// ============================================
|
||||
|
||||
private startInternalTimer() { |
||||
// Only start if not already running
|
||||
if (this.timerInterval) return; |
||||
|
||||
this.timerInterval = setInterval(() => { |
||||
if (this.state?.timerRunning) { |
||||
if (this.state.timerSeconds > 0) { |
||||
this.state.timerSeconds--; |
||||
this.persist(); |
||||
} else { |
||||
this.state.timerRunning = false; |
||||
this.persist(); |
||||
} |
||||
} |
||||
}, 1000); |
||||
} |
||||
|
||||
private stopInternalTimer() { |
||||
if (this.timerInterval) { |
||||
clearInterval(this.timerInterval); |
||||
this.timerInterval = null; |
||||
} |
||||
} |
||||
|
||||
setTimerMax(seconds: number) { |
||||
if (!this.state) return; |
||||
this.state.timerMax = seconds; |
||||
this.persist(); |
||||
} |
||||
|
||||
// Call this from moderator view only
|
||||
enableTimerControl() { |
||||
this.startInternalTimer(); |
||||
} |
||||
|
||||
startTimer() { |
||||
if (!this.state) return; |
||||
this.state.timerRunning = true; |
||||
this.state.timerSeconds = this.state.timerMax; |
||||
this.persist(); |
||||
} |
||||
|
||||
stopTimer() { |
||||
if (!this.state) return; |
||||
this.state.timerRunning = false; |
||||
this.persist(); |
||||
} |
||||
|
||||
// Called externally - no longer needed but kept for compatibility
|
||||
tickTimer() { |
||||
// Timer now runs internally, this is a no-op
|
||||
} |
||||
|
||||
resetTimer() { |
||||
if (!this.state) return; |
||||
this.state.timerSeconds = this.state.timerMax; |
||||
this.state.timerRunning = false; |
||||
this.persist(); |
||||
} |
||||
|
||||
// ============================================
|
||||
// Helpers
|
||||
// ============================================
|
||||
|
||||
get currentRound(): Round | null { |
||||
if (!this.state) return null; |
||||
return this.state.rounds[this.state.currentRoundIndex] ?? null; |
||||
} |
||||
|
||||
get currentQuestionData() { |
||||
if (!this.state?.currentQuestion) return null; |
||||
const { roundIndex, categoryIndex, questionIndex } = this.state.currentQuestion; |
||||
const category = this.state.rounds[roundIndex]?.categories[categoryIndex]; |
||||
const question = category?.questions[questionIndex]; |
||||
return question ? { category, question } : null; |
||||
} |
||||
|
||||
get sortedTeams(): Team[] { |
||||
if (!this.state) return []; |
||||
return [...this.state.teams].sort((a, b) => b.score - a.score); |
||||
} |
||||
|
||||
getQuestionResult(categoryIndex: number, questionIndex: number): QuestionResult | null { |
||||
if (!this.state) return null; |
||||
return this.state.questionResults.find( |
||||
r => r.categoryIndex === categoryIndex && r.questionIndex === questionIndex |
||||
) ?? null; |
||||
} |
||||
} |
||||
|
||||
export const gameSession = new GameSessionStore(); |
||||
@ -0,0 +1,363 @@ |
||||
// ============================================
|
||||
// Kuldvillak Game State Store (Svelte 5 Runes)
|
||||
// ============================================
|
||||
|
||||
import { |
||||
type KuldvillakGame, |
||||
type Team, |
||||
type Question, |
||||
type GamePhase, |
||||
type Round, |
||||
type Category, |
||||
DEFAULT_SETTINGS, |
||||
DEFAULT_STATE |
||||
} from '$lib/types/kuldvillak'; |
||||
|
||||
// ============================================
|
||||
// Utility Functions
|
||||
// ============================================
|
||||
|
||||
function generateId(): string { |
||||
return crypto.randomUUID(); |
||||
} |
||||
|
||||
function createEmptyGame(name: string = 'New Game'): KuldvillakGame { |
||||
const now = new Date().toISOString(); |
||||
return { |
||||
id: generateId(), |
||||
name, |
||||
createdAt: now, |
||||
updatedAt: now, |
||||
settings: { ...DEFAULT_SETTINGS }, |
||||
teams: [], |
||||
rounds: [], |
||||
finalRound: null, |
||||
state: { ...DEFAULT_STATE } |
||||
}; |
||||
} |
||||
|
||||
function createEmptyRound(name: string, multiplier: number, settings: typeof DEFAULT_SETTINGS): Round { |
||||
const categories: Category[] = []; |
||||
for (let i = 0; i < settings.categoriesPerRound; i++) { |
||||
const questions: Question[] = settings.pointValues.map((points) => ({ |
||||
id: generateId(), |
||||
question: '', |
||||
answer: '', |
||||
points: points * multiplier, |
||||
isDailyDouble: false, |
||||
isRevealed: false |
||||
})); |
||||
categories.push({ |
||||
id: generateId(), |
||||
name: `Category ${i + 1}`, |
||||
questions |
||||
}); |
||||
} |
||||
return { |
||||
id: generateId(), |
||||
name, |
||||
categories, |
||||
pointMultiplier: multiplier |
||||
}; |
||||
} |
||||
|
||||
// ============================================
|
||||
// Game Store Class
|
||||
// ============================================
|
||||
|
||||
class KuldvillakStore { |
||||
// Reactive state using Svelte 5 runes
|
||||
game = $state<KuldvillakGame | null>(null); |
||||
savedGames = $state<KuldvillakGame[]>([]); |
||||
|
||||
// Derived values
|
||||
get currentRound(): Round | null { |
||||
if (!this.game) return null; |
||||
return this.game.rounds[this.game.state.currentRoundIndex] ?? null; |
||||
} |
||||
|
||||
get currentQuestion(): Question | null { |
||||
if (!this.game || !this.currentRound || !this.game.state.currentQuestionId) return null; |
||||
for (const category of this.currentRound.categories) { |
||||
const question = category.questions.find((q) => q.id === this.game!.state.currentQuestionId); |
||||
if (question) return question; |
||||
} |
||||
return null; |
||||
} |
||||
|
||||
get currentCategory(): Category | null { |
||||
if (!this.game || !this.currentRound || !this.game.state.currentCategoryId) return null; |
||||
return this.currentRound.categories.find((c) => c.id === this.game!.state.currentCategoryId) ?? null; |
||||
} |
||||
|
||||
get activeTeam(): Team | null { |
||||
if (!this.game || !this.game.state.activeTeamId) return null; |
||||
return this.game.teams.find((t) => t.id === this.game!.state.activeTeamId) ?? null; |
||||
} |
||||
|
||||
get isRoundComplete(): boolean { |
||||
if (!this.currentRound) return false; |
||||
return this.currentRound.categories.every((c) => c.questions.every((q) => q.isRevealed)); |
||||
} |
||||
|
||||
get isGameComplete(): boolean { |
||||
if (!this.game) return false; |
||||
const allRoundsComplete = this.game.rounds.every((round) => |
||||
round.categories.every((c) => c.questions.every((q) => q.isRevealed)) |
||||
); |
||||
if (!this.game.settings.enableFinalRound) return allRoundsComplete; |
||||
return allRoundsComplete && this.game.state.phase === 'finished'; |
||||
} |
||||
|
||||
// ============================================
|
||||
// Game Lifecycle
|
||||
// ============================================
|
||||
|
||||
newGame(name: string = 'New Game'): void { |
||||
this.game = createEmptyGame(name); |
||||
// Create default 2 rounds (Jeopardy + Double Jeopardy)
|
||||
this.game.rounds = [ |
||||
createEmptyRound('Round 1', 1, this.game.settings), |
||||
createEmptyRound('Round 2', 2, this.game.settings) |
||||
]; |
||||
} |
||||
|
||||
loadGame(gameData: KuldvillakGame): void { |
||||
this.game = gameData; |
||||
} |
||||
|
||||
resetGame(): void { |
||||
if (!this.game) return; |
||||
// Reset all questions to unrevealed
|
||||
for (const round of this.game.rounds) { |
||||
for (const category of round.categories) { |
||||
for (const question of category.questions) { |
||||
question.isRevealed = false; |
||||
} |
||||
} |
||||
} |
||||
// Reset teams scores
|
||||
for (const team of this.game.teams) { |
||||
team.score = 0; |
||||
} |
||||
// Reset state
|
||||
this.game.state = { ...DEFAULT_STATE }; |
||||
} |
||||
|
||||
closeGame(): void { |
||||
this.game = null; |
||||
} |
||||
|
||||
// ============================================
|
||||
// Team Management
|
||||
// ============================================
|
||||
|
||||
addTeam(name: string): void { |
||||
if (!this.game) return; |
||||
this.game.teams.push({ |
||||
id: generateId(), |
||||
name, |
||||
score: 0 |
||||
}); |
||||
} |
||||
|
||||
removeTeam(teamId: string): void { |
||||
if (!this.game) return; |
||||
this.game.teams = this.game.teams.filter((t) => t.id !== teamId); |
||||
} |
||||
|
||||
updateTeamName(teamId: string, name: string): void { |
||||
if (!this.game) return; |
||||
const team = this.game.teams.find((t) => t.id === teamId); |
||||
if (team) team.name = name; |
||||
} |
||||
|
||||
updateTeamScore(teamId: string, score: number): void { |
||||
if (!this.game) return; |
||||
const team = this.game.teams.find((t) => t.id === teamId); |
||||
if (team) team.score = score; |
||||
} |
||||
|
||||
adjustTeamScore(teamId: string, delta: number): void { |
||||
if (!this.game) return; |
||||
const team = this.game.teams.find((t) => t.id === teamId); |
||||
if (team) team.score += delta; |
||||
} |
||||
|
||||
setActiveTeam(teamId: string | null): void { |
||||
if (!this.game) return; |
||||
this.game.state.activeTeamId = teamId; |
||||
} |
||||
|
||||
// ============================================
|
||||
// Game Flow Control
|
||||
// ============================================
|
||||
|
||||
setPhase(phase: GamePhase): void { |
||||
if (!this.game) return; |
||||
this.game.state.phase = phase; |
||||
} |
||||
|
||||
startGame(): void { |
||||
if (!this.game || this.game.teams.length === 0) return; |
||||
this.game.state.phase = 'board'; |
||||
this.game.state.currentRoundIndex = 0; |
||||
} |
||||
|
||||
selectQuestion(categoryId: string, questionId: string): void { |
||||
if (!this.game) return; |
||||
const question = this.findQuestion(questionId); |
||||
if (!question || question.isRevealed) return; |
||||
|
||||
this.game.state.currentCategoryId = categoryId; |
||||
this.game.state.currentQuestionId = questionId; |
||||
|
||||
if (question.isDailyDouble) { |
||||
this.game.state.phase = 'daily-double'; |
||||
} else { |
||||
this.game.state.phase = 'question'; |
||||
} |
||||
} |
||||
|
||||
setDailyDoubleWager(wager: number): void { |
||||
if (!this.game) return; |
||||
this.game.state.dailyDoubleWager = wager; |
||||
this.game.state.phase = 'question'; |
||||
} |
||||
|
||||
revealAnswer(): void { |
||||
if (!this.game) return; |
||||
this.game.state.phase = 'answer'; |
||||
} |
||||
|
||||
markCorrect(teamId: string): void { |
||||
if (!this.game || !this.currentQuestion) return; |
||||
const points = this.game.state.dailyDoubleWager ?? this.currentQuestion.points; |
||||
this.adjustTeamScore(teamId, points); |
||||
this.finishQuestion(); |
||||
} |
||||
|
||||
markIncorrect(teamId: string): void { |
||||
if (!this.game || !this.currentQuestion) return; |
||||
const points = this.game.state.dailyDoubleWager ?? this.currentQuestion.points; |
||||
this.adjustTeamScore(teamId, -points); |
||||
} |
||||
|
||||
finishQuestion(): void { |
||||
if (!this.game || !this.game.state.currentQuestionId) return; |
||||
const question = this.findQuestion(this.game.state.currentQuestionId); |
||||
if (question) question.isRevealed = true; |
||||
|
||||
this.game.state.currentQuestionId = null; |
||||
this.game.state.currentCategoryId = null; |
||||
this.game.state.dailyDoubleWager = null; |
||||
this.game.state.activeTeamId = null; |
||||
|
||||
// Check if round is complete
|
||||
if (this.isRoundComplete) { |
||||
this.advanceRound(); |
||||
} else { |
||||
this.game.state.phase = 'board'; |
||||
} |
||||
} |
||||
|
||||
advanceRound(): void { |
||||
if (!this.game) return; |
||||
const nextIndex = this.game.state.currentRoundIndex + 1; |
||||
if (nextIndex < this.game.rounds.length) { |
||||
this.game.state.currentRoundIndex = nextIndex; |
||||
this.game.state.phase = 'board'; |
||||
} else if (this.game.settings.enableFinalRound && this.game.finalRound) { |
||||
this.game.state.phase = 'final-category'; |
||||
} else { |
||||
this.game.state.phase = 'finished'; |
||||
} |
||||
} |
||||
|
||||
// Final Round
|
||||
startFinalRound(): void { |
||||
if (!this.game) return; |
||||
this.game.state.phase = 'final-question'; |
||||
} |
||||
|
||||
setFinalWager(teamId: string, wager: number): void { |
||||
if (!this.game) return; |
||||
this.game.state.finalWagers[teamId] = wager; |
||||
} |
||||
|
||||
setFinalAnswer(teamId: string, answer: string): void { |
||||
if (!this.game) return; |
||||
this.game.state.finalAnswers[teamId] = answer; |
||||
} |
||||
|
||||
scoreFinalAnswer(teamId: string, correct: boolean): void { |
||||
if (!this.game) return; |
||||
const wager = this.game.state.finalWagers[teamId] ?? 0; |
||||
this.adjustTeamScore(teamId, correct ? wager : -wager); |
||||
} |
||||
|
||||
finishGame(): void { |
||||
if (!this.game) return; |
||||
this.game.state.phase = 'finished'; |
||||
} |
||||
|
||||
// ============================================
|
||||
// Editor Functions
|
||||
// ============================================
|
||||
|
||||
updateGameName(name: string): void { |
||||
if (!this.game) return; |
||||
this.game.name = name; |
||||
this.game.updatedAt = new Date().toISOString(); |
||||
} |
||||
|
||||
updateCategoryName(roundIndex: number, categoryId: string, name: string): void { |
||||
if (!this.game) return; |
||||
const category = this.game.rounds[roundIndex]?.categories.find((c) => c.id === categoryId); |
||||
if (category) category.name = name; |
||||
} |
||||
|
||||
updateQuestion(questionId: string, updates: Partial<Pick<Question, 'question' | 'answer' | 'isDailyDouble'>>): void { |
||||
if (!this.game) return; |
||||
const question = this.findQuestion(questionId); |
||||
if (question) { |
||||
if (updates.question !== undefined) question.question = updates.question; |
||||
if (updates.answer !== undefined) question.answer = updates.answer; |
||||
if (updates.isDailyDouble !== undefined) question.isDailyDouble = updates.isDailyDouble; |
||||
} |
||||
} |
||||
|
||||
updateFinalRound(category: string, question: string, answer: string): void { |
||||
if (!this.game) return; |
||||
this.game.finalRound = { category, question, answer }; |
||||
} |
||||
|
||||
addRound(): void { |
||||
if (!this.game) return; |
||||
const multiplier = this.game.rounds.length + 1; |
||||
this.game.rounds.push(createEmptyRound(`Round ${multiplier}`, multiplier, this.game.settings)); |
||||
} |
||||
|
||||
removeRound(roundIndex: number): void { |
||||
if (!this.game || this.game.rounds.length <= 1) return; |
||||
this.game.rounds.splice(roundIndex, 1); |
||||
} |
||||
|
||||
// ============================================
|
||||
// Helper Functions
|
||||
// ============================================
|
||||
|
||||
private findQuestion(questionId: string): Question | null { |
||||
if (!this.game) return null; |
||||
for (const round of this.game.rounds) { |
||||
for (const category of round.categories) { |
||||
const question = category.questions.find((q) => q.id === questionId); |
||||
if (question) return question; |
||||
} |
||||
} |
||||
return null; |
||||
} |
||||
} |
||||
|
||||
// Export singleton instance
|
||||
export const kuldvillakStore = new KuldvillakStore(); |
||||
@ -0,0 +1,259 @@ |
||||
// ============================================
|
||||
// LocalStorage Persistence for Game Data
|
||||
// ============================================
|
||||
|
||||
import type { KuldvillakGame, GameMetadata } from '$lib/types/kuldvillak'; |
||||
|
||||
const STORAGE_PREFIX = 'ultimate_gaming'; |
||||
const KULDVILLAK_GAMES_KEY = `${STORAGE_PREFIX}_kuldvillak_games`; |
||||
const KULDVILLAK_ACTIVE_KEY = `${STORAGE_PREFIX}_kuldvillak_active`; |
||||
|
||||
// ============================================
|
||||
// Kuldvillak Save/Load Functions
|
||||
// ============================================
|
||||
|
||||
/** |
||||
* Get all saved Kuldvillak games metadata (lightweight list) |
||||
*/ |
||||
export function getKuldvillakGamesList(): GameMetadata[] { |
||||
if (typeof localStorage === 'undefined') return []; |
||||
|
||||
try { |
||||
const data = localStorage.getItem(KULDVILLAK_GAMES_KEY); |
||||
if (!data) return []; |
||||
|
||||
const games: KuldvillakGame[] = JSON.parse(data); |
||||
return games.map((game) => ({ |
||||
id: game.id, |
||||
name: game.name, |
||||
createdAt: game.createdAt, |
||||
updatedAt: game.updatedAt, |
||||
teamCount: game.teams.length, |
||||
roundCount: game.rounds.length |
||||
})); |
||||
} catch (e) { |
||||
console.error('Failed to load games list:', e); |
||||
return []; |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Get all saved Kuldvillak games (full data) |
||||
*/ |
||||
export function getAllKuldvillakGames(): KuldvillakGame[] { |
||||
if (typeof localStorage === 'undefined') return []; |
||||
|
||||
try { |
||||
const data = localStorage.getItem(KULDVILLAK_GAMES_KEY); |
||||
return data ? JSON.parse(data) : []; |
||||
} catch (e) { |
||||
console.error('Failed to load games:', e); |
||||
return []; |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Load a specific Kuldvillak game by ID |
||||
*/ |
||||
export function loadKuldvillakGame(gameId: string): KuldvillakGame | null { |
||||
const games = getAllKuldvillakGames(); |
||||
return games.find((g) => g.id === gameId) ?? null; |
||||
} |
||||
|
||||
/** |
||||
* Save a Kuldvillak game (creates new or updates existing) |
||||
*/ |
||||
export function saveKuldvillakGame(game: KuldvillakGame): boolean { |
||||
if (typeof localStorage === 'undefined') return false; |
||||
|
||||
try { |
||||
const games = getAllKuldvillakGames(); |
||||
const existingIndex = games.findIndex((g) => g.id === game.id); |
||||
|
||||
// Update timestamp
|
||||
game.updatedAt = new Date().toISOString(); |
||||
|
||||
if (existingIndex >= 0) { |
||||
games[existingIndex] = game; |
||||
} else { |
||||
games.push(game); |
||||
} |
||||
|
||||
localStorage.setItem(KULDVILLAK_GAMES_KEY, JSON.stringify(games)); |
||||
return true; |
||||
} catch (e) { |
||||
console.error('Failed to save game:', e); |
||||
return false; |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Delete a Kuldvillak game by ID |
||||
*/ |
||||
export function deleteKuldvillakGame(gameId: string): boolean { |
||||
if (typeof localStorage === 'undefined') return false; |
||||
|
||||
try { |
||||
const games = getAllKuldvillakGames(); |
||||
const filtered = games.filter((g) => g.id !== gameId); |
||||
localStorage.setItem(KULDVILLAK_GAMES_KEY, JSON.stringify(filtered)); |
||||
return true; |
||||
} catch (e) { |
||||
console.error('Failed to delete game:', e); |
||||
return false; |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Duplicate a Kuldvillak game |
||||
*/ |
||||
export function duplicateKuldvillakGame(gameId: string): KuldvillakGame | null { |
||||
const original = loadKuldvillakGame(gameId); |
||||
if (!original) return null; |
||||
|
||||
const now = new Date().toISOString(); |
||||
const duplicate: KuldvillakGame = { |
||||
...JSON.parse(JSON.stringify(original)), // Deep clone
|
||||
id: crypto.randomUUID(), |
||||
name: `${original.name} (Copy)`, |
||||
createdAt: now, |
||||
updatedAt: now |
||||
}; |
||||
|
||||
// Reset game state
|
||||
duplicate.state = { |
||||
phase: 'lobby', |
||||
currentRoundIndex: 0, |
||||
currentQuestionId: null, |
||||
currentCategoryId: null, |
||||
activeTeamId: null, |
||||
dailyDoubleWager: null, |
||||
finalWagers: {}, |
||||
finalAnswers: {} |
||||
}; |
||||
|
||||
// Reset revealed questions and scores
|
||||
for (const round of duplicate.rounds) { |
||||
for (const category of round.categories) { |
||||
for (const question of category.questions) { |
||||
question.isRevealed = false; |
||||
} |
||||
} |
||||
} |
||||
for (const team of duplicate.teams) { |
||||
team.score = 0; |
||||
} |
||||
|
||||
if (saveKuldvillakGame(duplicate)) { |
||||
return duplicate; |
||||
} |
||||
return null; |
||||
} |
||||
|
||||
// ============================================
|
||||
// Active Game Session (for resuming)
|
||||
// ============================================
|
||||
|
||||
/** |
||||
* Save reference to currently active game |
||||
*/ |
||||
export function setActiveKuldvillakGame(gameId: string | null): void { |
||||
if (typeof localStorage === 'undefined') return; |
||||
|
||||
if (gameId) { |
||||
localStorage.setItem(KULDVILLAK_ACTIVE_KEY, gameId); |
||||
} else { |
||||
localStorage.removeItem(KULDVILLAK_ACTIVE_KEY); |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Get the ID of the last active game |
||||
*/ |
||||
export function getActiveKuldvillakGameId(): string | null { |
||||
if (typeof localStorage === 'undefined') return null; |
||||
return localStorage.getItem(KULDVILLAK_ACTIVE_KEY); |
||||
} |
||||
|
||||
/** |
||||
* Load the last active game |
||||
*/ |
||||
export function loadActiveKuldvillakGame(): KuldvillakGame | null { |
||||
const activeId = getActiveKuldvillakGameId(); |
||||
if (!activeId) return null; |
||||
return loadKuldvillakGame(activeId); |
||||
} |
||||
|
||||
// ============================================
|
||||
// Export/Import Functions
|
||||
// ============================================
|
||||
|
||||
/** |
||||
* Export a game to a JSON file (triggers download) |
||||
*/ |
||||
export function exportKuldvillakGame(game: KuldvillakGame): void { |
||||
const dataStr = JSON.stringify(game, null, 2); |
||||
const blob = new Blob([dataStr], { type: 'application/json' }); |
||||
const url = URL.createObjectURL(blob); |
||||
|
||||
const link = document.createElement('a'); |
||||
link.href = url; |
||||
link.download = `${game.name.replace(/[^a-z0-9]/gi, '_')}_kuldvillak.json`; |
||||
document.body.appendChild(link); |
||||
link.click(); |
||||
document.body.removeChild(link); |
||||
URL.revokeObjectURL(url); |
||||
} |
||||
|
||||
/** |
||||
* Import a game from a JSON file |
||||
*/ |
||||
export async function importKuldvillakGame(file: File): Promise<KuldvillakGame | null> { |
||||
try { |
||||
const text = await file.text(); |
||||
const game: KuldvillakGame = JSON.parse(text); |
||||
|
||||
// Validate basic structure
|
||||
if (!game.id || !game.name || !game.rounds || !game.settings) { |
||||
throw new Error('Invalid game file structure'); |
||||
} |
||||
|
||||
// Assign new ID to avoid conflicts
|
||||
game.id = crypto.randomUUID(); |
||||
game.createdAt = new Date().toISOString(); |
||||
game.updatedAt = new Date().toISOString(); |
||||
|
||||
if (saveKuldvillakGame(game)) { |
||||
return game; |
||||
} |
||||
return null; |
||||
} catch (e) { |
||||
console.error('Failed to import game:', e); |
||||
return null; |
||||
} |
||||
} |
||||
|
||||
// ============================================
|
||||
// Storage Stats
|
||||
// ============================================
|
||||
|
||||
/** |
||||
* Get approximate storage usage |
||||
*/ |
||||
export function getStorageStats(): { used: number; available: number } { |
||||
if (typeof localStorage === 'undefined') { |
||||
return { used: 0, available: 0 }; |
||||
} |
||||
|
||||
let used = 0; |
||||
for (const key in localStorage) { |
||||
if (localStorage.hasOwnProperty(key)) { |
||||
used += localStorage.getItem(key)?.length ?? 0; |
||||
} |
||||
} |
||||
|
||||
// localStorage typically has ~5MB limit
|
||||
const available = 5 * 1024 * 1024 - used; |
||||
|
||||
return { used, available }; |
||||
} |
||||
@ -0,0 +1,112 @@ |
||||
import { browser } from "$app/environment"; |
||||
|
||||
const THEME_STORAGE_KEY = "kuldvillak-theme"; |
||||
|
||||
// Default theme colors
|
||||
export const DEFAULT_THEME = { |
||||
primary: "#003B9B", |
||||
secondary: "#FFAB00", |
||||
text: "#FFFFFF", |
||||
background: "#000000", |
||||
}; |
||||
|
||||
// Load initial values from localStorage
|
||||
function getInitialTheme() { |
||||
if (browser) { |
||||
const saved = localStorage.getItem(THEME_STORAGE_KEY); |
||||
if (saved) { |
||||
try { |
||||
const theme = JSON.parse(saved); |
||||
return { |
||||
primary: theme.primary ?? DEFAULT_THEME.primary, |
||||
secondary: theme.secondary ?? DEFAULT_THEME.secondary, |
||||
text: theme.text ?? DEFAULT_THEME.text, |
||||
background: theme.background ?? DEFAULT_THEME.background, |
||||
}; |
||||
} catch { |
||||
// Ignore parse errors
|
||||
} |
||||
} |
||||
} |
||||
return { ...DEFAULT_THEME }; |
||||
} |
||||
|
||||
const initialTheme = getInitialTheme(); |
||||
|
||||
// Current applied values (what's visually shown)
|
||||
let primary = $state(initialTheme.primary); |
||||
let secondary = $state(initialTheme.secondary); |
||||
let text = $state(initialTheme.text); |
||||
let background = $state(initialTheme.background); |
||||
|
||||
// Saved values (what's persisted to localStorage)
|
||||
let savedPrimary = $state(initialTheme.primary); |
||||
let savedSecondary = $state(initialTheme.secondary); |
||||
let savedText = $state(initialTheme.text); |
||||
let savedBackground = $state(initialTheme.background); |
||||
|
||||
function applyTheme() { |
||||
if (browser) { |
||||
document.documentElement.style.setProperty("--kv-blue", primary); |
||||
document.documentElement.style.setProperty("--kv-yellow", secondary); |
||||
document.documentElement.style.setProperty("--kv-text", text); |
||||
document.documentElement.style.setProperty("--kv-background", background); |
||||
} |
||||
} |
||||
|
||||
// Save current values to localStorage
|
||||
function save() { |
||||
if (browser) { |
||||
localStorage.setItem(THEME_STORAGE_KEY, JSON.stringify({ |
||||
primary, |
||||
secondary, |
||||
text, |
||||
background |
||||
})); |
||||
// Update saved state
|
||||
savedPrimary = primary; |
||||
savedSecondary = secondary; |
||||
savedText = text; |
||||
savedBackground = background; |
||||
} |
||||
} |
||||
|
||||
// Revert to last saved values (for cancel/close without saving)
|
||||
function revert() { |
||||
primary = savedPrimary; |
||||
secondary = savedSecondary; |
||||
text = savedText; |
||||
background = savedBackground; |
||||
applyTheme(); |
||||
} |
||||
|
||||
// Reset to default values (applies immediately for preview)
|
||||
function resetToDefaults() { |
||||
primary = DEFAULT_THEME.primary; |
||||
secondary = DEFAULT_THEME.secondary; |
||||
text = DEFAULT_THEME.text; |
||||
background = DEFAULT_THEME.background; |
||||
applyTheme(); |
||||
} |
||||
|
||||
// Reset and save (for full reset)
|
||||
function reset() { |
||||
resetToDefaults(); |
||||
save(); |
||||
} |
||||
|
||||
export const themeStore = { |
||||
get primary() { return primary; }, |
||||
set primary(value: string) { primary = value; applyTheme(); }, |
||||
get secondary() { return secondary; }, |
||||
set secondary(value: string) { secondary = value; applyTheme(); }, |
||||
get text() { return text; }, |
||||
set text(value: string) { text = value; applyTheme(); }, |
||||
get background() { return background; }, |
||||
set background(value: string) { background = value; applyTheme(); }, |
||||
applyTheme, |
||||
save, |
||||
revert, |
||||
reset, |
||||
resetToDefaults, |
||||
}; |
||||
@ -0,0 +1,167 @@ |
||||
// ============================================
|
||||
// Kuldvillak (Jeopardy) Type Definitions
|
||||
// ============================================
|
||||
|
||||
/** A single question/answer pair on the board */ |
||||
export interface Question { |
||||
id: string; |
||||
question: string; |
||||
answer: string; |
||||
points: number; |
||||
isDailyDouble: boolean; |
||||
isRevealed: boolean; |
||||
imageUrl?: string; |
||||
} |
||||
|
||||
/** A category column containing questions */ |
||||
export interface Category { |
||||
id: string; |
||||
name: string; |
||||
questions: Question[]; |
||||
} |
||||
|
||||
/** A game round (e.g., Jeopardy, Double Jeopardy) */ |
||||
export interface Round { |
||||
id: string; |
||||
name: string; |
||||
categories: Category[]; |
||||
pointMultiplier: number; |
||||
} |
||||
|
||||
/** Final Jeopardy round structure */ |
||||
export interface FinalRound { |
||||
category: string; |
||||
question: string; |
||||
answer: string; |
||||
} |
||||
|
||||
/** A competing team */ |
||||
export interface Team { |
||||
id: string; |
||||
name: string; |
||||
score: number; |
||||
} |
||||
|
||||
/** Current game phase */ |
||||
export type GamePhase = |
||||
| 'intro' // Show Kuldvillak home screen (round start)
|
||||
| 'intro-categories' // Animating category introductions
|
||||
| 'lobby' |
||||
| 'board' |
||||
| 'question' |
||||
| 'answer' |
||||
| 'daily-double' |
||||
| 'final-intro' // Final round intro (Kuldvillak screen)
|
||||
| 'final-category' // Reveal final round category
|
||||
| 'final-question' |
||||
| 'final-reveal' |
||||
| 'final-scores' |
||||
| 'finished'; |
||||
|
||||
/** Result of a question for tracking */ |
||||
export interface QuestionResult { |
||||
categoryIndex: number; |
||||
questionIndex: number; |
||||
points: number; |
||||
teamId: string | null; // Who won/lost points (null if skipped)
|
||||
pointsChange: number; // Positive for correct, negative for wrong, 0 for skip
|
||||
isDailyDouble: boolean; |
||||
wager?: number; // DD wager if applicable
|
||||
} |
||||
|
||||
/** Current state during gameplay */ |
||||
export interface GameState { |
||||
phase: GamePhase; |
||||
currentRoundIndex: number; |
||||
currentQuestionId: string | null; |
||||
currentCategoryId: string | null; |
||||
activeTeamId: string | null; |
||||
dailyDoubleWager: number | null; |
||||
finalWagers: Record<string, number>; |
||||
finalAnswers: Record<string, string>; |
||||
} |
||||
|
||||
/** Point value preset types */ |
||||
export type PointValuePreset = 'round1' | 'round2' | 'custom' | 'multiplier'; |
||||
|
||||
/** Configurable game settings */ |
||||
export interface GameSettings { |
||||
numberOfRounds: 1 | 2; |
||||
pointValuePreset: PointValuePreset; |
||||
pointValues: number[]; |
||||
basePointValue: number; // For multiplier preset (e.g., 100 → 100,200,300,400,500)
|
||||
categoriesPerRound: number; |
||||
questionsPerCategory: number; |
||||
dailyDoublesPerRound: number[]; |
||||
enableFinalRound: boolean; |
||||
enableSoundEffects: boolean; |
||||
allowNegativeScores: boolean; |
||||
maxTeams: number; |
||||
defaultTimerSeconds: number; |
||||
answerRevealSeconds: number; |
||||
} |
||||
|
||||
/** Point value presets */ |
||||
export const POINT_PRESETS = { |
||||
round1: [100, 200, 300, 400, 500], |
||||
round2: [200, 400, 600, 800, 1000] |
||||
} as const; |
||||
|
||||
/** Complete game configuration (saveable/loadable) */ |
||||
export interface KuldvillakGame { |
||||
id: string; |
||||
name: string; |
||||
createdAt: string; |
||||
updatedAt: string; |
||||
settings: GameSettings; |
||||
teams: Team[]; |
||||
rounds: Round[]; |
||||
finalRound: FinalRound | null; |
||||
state: GameState; |
||||
} |
||||
|
||||
/** Default settings for new games */ |
||||
export const DEFAULT_SETTINGS: GameSettings = { |
||||
numberOfRounds: 2, |
||||
pointValuePreset: 'round1', |
||||
pointValues: [10, 20, 30, 40, 50], |
||||
basePointValue: 10, |
||||
categoriesPerRound: 6, |
||||
questionsPerCategory: 5, |
||||
dailyDoublesPerRound: [1, 2], |
||||
enableFinalRound: true, |
||||
enableSoundEffects: true, |
||||
allowNegativeScores: true, |
||||
maxTeams: 6, |
||||
defaultTimerSeconds: 5, |
||||
answerRevealSeconds: 5 |
||||
}; |
||||
|
||||
/** Default initial game state */ |
||||
export const DEFAULT_STATE: GameState = { |
||||
phase: 'lobby', |
||||
currentRoundIndex: 0, |
||||
currentQuestionId: null, |
||||
currentCategoryId: null, |
||||
activeTeamId: null, |
||||
dailyDoubleWager: null, |
||||
finalWagers: {}, |
||||
finalAnswers: {} |
||||
}; |
||||
|
||||
// ============================================
|
||||
// Helper Types for Editor/UI
|
||||
// ============================================
|
||||
|
||||
/** Saved game metadata for game list */ |
||||
export interface GameMetadata { |
||||
id: string; |
||||
name: string; |
||||
createdAt: string; |
||||
updatedAt: string; |
||||
teamCount: number; |
||||
roundCount: number; |
||||
} |
||||
|
||||
/** View mode for dual-screen setup */ |
||||
export type ViewMode = 'projector' | 'moderator'; |
||||
@ -0,0 +1,31 @@ |
||||
<script lang="ts"> |
||||
import { page } from "$app/stores"; |
||||
import * as m from "$lib/paraglide/messages"; |
||||
import { LanguageSwitcher } from "$lib/components"; |
||||
</script> |
||||
|
||||
<div class="min-h-screen flex items-center justify-center bg-[#0a121f]"> |
||||
<div class="flex flex-col items-center gap-16"> |
||||
<div |
||||
class="flex flex-col items-center text-center text-white font-['Comic_Sans_MS',cursive,sans-serif] font-bold italic capitalize" |
||||
> |
||||
<h1 class="text-[128px] md:text-[80px] m-0 leading-none"> |
||||
{$page.status} |
||||
</h1> |
||||
<p class="text-[48px] md:text-[32px] m-0"> |
||||
{#if $page.status === 404} |
||||
{m.kv_error_not_found()} |
||||
{:else} |
||||
{$page.error?.message ?? "Error"} |
||||
{/if} |
||||
</p> |
||||
</div> |
||||
<p |
||||
class="font-['Comic_Sans_MS',cursive,sans-serif] font-bold italic text-[32px] md:text-[20px] text-white/50 text-center m-0 px-4" |
||||
> |
||||
{m.kv_error_hint()} |
||||
</p> |
||||
</div> |
||||
</div> |
||||
|
||||
<LanguageSwitcher /> |
||||
@ -0,0 +1,19 @@ |
||||
<script lang="ts"> |
||||
import './layout.css'; |
||||
import favicon from '$lib/assets/favicon.svg'; |
||||
import { themeStore } from '$lib/stores/theme.svelte'; |
||||
import { onMount } from 'svelte'; |
||||
|
||||
let { children } = $props(); |
||||
|
||||
// Apply saved theme colors on mount |
||||
onMount(() => { |
||||
themeStore.applyTheme(); |
||||
}); |
||||
</script> |
||||
|
||||
<svelte:head> |
||||
<link rel="icon" href={favicon} /> |
||||
</svelte:head> |
||||
|
||||
{@render children()} |
||||
@ -0,0 +1,80 @@ |
||||
<script lang="ts"> |
||||
import * as m from "$lib/paraglide/messages"; |
||||
import { LanguageSwitcher } from "$lib/components"; |
||||
|
||||
// Game data |
||||
const games = [ |
||||
{ |
||||
nameKey: "kuldvillak" as const, |
||||
href: "/kuldvillak", |
||||
image: "/images/kuldvillak-cover.jpg", |
||||
available: true, |
||||
}, |
||||
{ |
||||
nameKey: "rooside_soda" as const, |
||||
href: "/rooside-soda", |
||||
image: "/images/rooside-soda-cover.jpg", |
||||
available: false, |
||||
}, |
||||
]; |
||||
|
||||
function getGameName(key: "kuldvillak" | "rooside_soda"): string { |
||||
return key === "kuldvillak" |
||||
? m.game_kuldvillak() |
||||
: m.game_rooside_soda(); |
||||
} |
||||
</script> |
||||
|
||||
<LanguageSwitcher /> |
||||
|
||||
<div class="min-h-screen flex items-center justify-center bg-[#0a121f]"> |
||||
<div class="flex flex-col items-center gap-16"> |
||||
<!-- Title --> |
||||
<div |
||||
class="flex flex-col items-center text-center text-white font-['Comic_Sans_MS',cursive,sans-serif] font-bold italic capitalize" |
||||
> |
||||
<h1 class="text-[64px] max-md:text-[40px] m-0">{m.app_title()}</h1> |
||||
<p class="text-[48px] max-md:text-[28px] m-0">{m.app_version()}</p> |
||||
</div> |
||||
|
||||
<!-- Games Grid --> |
||||
<div class="flex gap-8 flex-wrap justify-center"> |
||||
{#each games as game} |
||||
<div class="flex flex-col items-center gap-4"> |
||||
<span |
||||
class="font-['Comic_Sans_MS',cursive,sans-serif] font-bold italic text-2xl text-white capitalize" |
||||
> |
||||
{getGameName(game.nameKey)} |
||||
</span> |
||||
{#if game.available} |
||||
<a |
||||
href={game.href} |
||||
class="block w-[240px] h-[240px] max-md:w-[180px] max-md:h-[180px] border-8 border-[var(--kv-blue)] overflow-hidden transition-all duration-200 hover:scale-105 hover:border-[var(--kv-golden)]" |
||||
> |
||||
<img |
||||
src={game.image} |
||||
alt={getGameName(game.nameKey)} |
||||
class="w-full h-full object-cover" |
||||
/> |
||||
</a> |
||||
{:else} |
||||
<div |
||||
class="relative w-[240px] h-[240px] max-md:w-[180px] max-md:h-[180px] border-8 border-[var(--kv-blue)] overflow-hidden opacity-50" |
||||
> |
||||
<img |
||||
src={game.image} |
||||
alt={getGameName(game.nameKey)} |
||||
class="w-full h-full object-cover" |
||||
/> |
||||
<span |
||||
class="absolute inset-0 flex items-center justify-center bg-black/70 text-white font-['Comic_Sans_MS',cursive,sans-serif] font-bold text-2xl uppercase" |
||||
> |
||||
{m.coming_soon()} |
||||
</span> |
||||
</div> |
||||
{/if} |
||||
</div> |
||||
{/each} |
||||
</div> |
||||
</div> |
||||
</div> |
||||
@ -0,0 +1,22 @@ |
||||
<script lang="ts"> |
||||
import { onMount, onDestroy } from "svelte"; |
||||
import { browser } from "$app/environment"; |
||||
import { audioStore } from "$lib/stores/audio.svelte"; |
||||
import { themeStore } from "$lib/stores/theme.svelte"; |
||||
import type { Snippet } from "svelte"; |
||||
|
||||
let { children }: { children: Snippet } = $props(); |
||||
|
||||
onMount(() => { |
||||
if (browser) { |
||||
audioStore.initMusic("/audio/kuldvillak_teema.mp3"); |
||||
themeStore.applyTheme(); |
||||
} |
||||
}); |
||||
|
||||
onDestroy(() => { |
||||
// Don't destroy on navigation within kuldvillak - only when leaving |
||||
}); |
||||
</script> |
||||
|
||||
{@render children()} |
||||
@ -0,0 +1,106 @@ |
||||
<script lang="ts"> |
||||
import { Settings } from "$lib/components"; |
||||
import { KvButtonPrimary, KvLogo } from "$lib/components/kuldvillak/ui"; |
||||
import * as m from "$lib/paraglide/messages"; |
||||
|
||||
let settingsOpen = $state(false); |
||||
let fileInput: HTMLInputElement; |
||||
|
||||
function openSettings() { |
||||
settingsOpen = true; |
||||
} |
||||
|
||||
function handleLoadGame(event: Event) { |
||||
const file = (event.target as HTMLInputElement).files?.[0]; |
||||
if (!file) return; |
||||
// Store file data and navigate to editor |
||||
const reader = new FileReader(); |
||||
reader.onload = (e) => { |
||||
try { |
||||
const data = JSON.parse(e.target?.result as string); |
||||
localStorage.setItem( |
||||
"kuldvillak-editor-autosave", |
||||
JSON.stringify(data), |
||||
); |
||||
window.location.href = "/kuldvillak/edit"; |
||||
} catch { |
||||
console.error("Invalid game file"); |
||||
} |
||||
}; |
||||
reader.readAsText(file); |
||||
} |
||||
</script> |
||||
|
||||
<div |
||||
class="relative min-h-screen flex items-center justify-center overflow-hidden" |
||||
> |
||||
<!-- Background Grid --> |
||||
<div class="kv-grid-bg absolute inset-0 z-0"></div> |
||||
|
||||
<!-- Content --> |
||||
<div class="relative z-10 flex flex-col items-center gap-8 md:gap-16 p-4"> |
||||
<!-- Kuldvillak Logo --> |
||||
<KvLogo size="lg" class="md:h-48 md:max-w-[768px]" /> |
||||
|
||||
<!-- Menu Buttons --> |
||||
<div |
||||
class="flex flex-col gap-2 p-2 w-56 md:w-64 bg-kv-black border-4 border-kv-black" |
||||
> |
||||
<KvButtonPrimary |
||||
href="/kuldvillak/edit" |
||||
class="w-full !text-lg md:!text-2xl !py-3 md:!py-4" |
||||
> |
||||
{m.kv_new_game()} |
||||
</KvButtonPrimary> |
||||
<KvButtonPrimary |
||||
onclick={() => fileInput.click()} |
||||
class="w-full !text-lg md:!text-2xl !py-3 md:!py-4" |
||||
> |
||||
{m.kv_load_game()} |
||||
</KvButtonPrimary> |
||||
<input |
||||
type="file" |
||||
accept=".json" |
||||
class="hidden" |
||||
bind:this={fileInput} |
||||
onchange={handleLoadGame} |
||||
/> |
||||
<KvButtonPrimary |
||||
onclick={openSettings} |
||||
class="w-full !text-lg md:!text-2xl !py-3 md:!py-4" |
||||
> |
||||
{m.kv_settings()} |
||||
</KvButtonPrimary> |
||||
<KvButtonPrimary |
||||
href="/" |
||||
class="w-full !text-lg md:!text-2xl !py-3 md:!py-4" |
||||
> |
||||
{m.kv_exit()} |
||||
</KvButtonPrimary> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
|
||||
<Settings bind:open={settingsOpen} /> |
||||
|
||||
<style> |
||||
.kv-grid-bg { |
||||
background-color: var(--kv-blue); |
||||
background-image: linear-gradient( |
||||
color-mix(in srgb, var(--kv-background) 50%, transparent) 2px, |
||||
transparent 2px |
||||
), |
||||
linear-gradient( |
||||
90deg, |
||||
color-mix(in srgb, var(--kv-background) 50%, transparent) 2px, |
||||
transparent 2px |
||||
); |
||||
background-size: 60px 60px; |
||||
} |
||||
|
||||
@media (max-width: 768px) { |
||||
.kv-grid-bg { |
||||
background-size: 40px 40px; |
||||
} |
||||
} |
||||
</style> |
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,36 @@ |
||||
<script lang="ts"> |
||||
import { page } from "$app/stores"; |
||||
import { gameSession } from "$lib/stores/gameSession.svelte"; |
||||
import ProjectorView from "./ProjectorView.svelte"; |
||||
import ModeratorView from "./ModeratorView.svelte"; |
||||
import * as m from "$lib/paraglide/messages"; |
||||
|
||||
// Get view from URL query param |
||||
let view = $derived($page.url.searchParams.get("view") ?? "moderator"); |
||||
</script> |
||||
|
||||
{#if !gameSession.state} |
||||
<div class="h-screen w-screen flex items-center justify-center bg-kv-black"> |
||||
<div class="text-center font-[family-name:var(--kv-font-button)]"> |
||||
<div class="animate-pulse mb-4"> |
||||
<div class="text-6xl">🎮</div> |
||||
</div> |
||||
<p class="text-kv-white text-2xl mb-4 uppercase"> |
||||
{m.kv_play_loading()} |
||||
</p> |
||||
<p class="text-gray-400 text-sm mb-4"> |
||||
{m.kv_play_loading_hint()} |
||||
</p> |
||||
<a |
||||
href="/kuldvillak/edit" |
||||
class="text-[var(--kv-golden)] underline uppercase" |
||||
> |
||||
{m.kv_play_go_to_editor()} |
||||
</a> |
||||
</div> |
||||
</div> |
||||
{:else if view === "projector"} |
||||
<ProjectorView /> |
||||
{:else} |
||||
<ModeratorView /> |
||||
{/if} |
||||
@ -0,0 +1,700 @@ |
||||
<script lang="ts"> |
||||
import { gameSession } from "$lib/stores/gameSession.svelte"; |
||||
import { onMount } from "svelte"; |
||||
import * as m from "$lib/paraglide/messages"; |
||||
import { |
||||
KvButtonPrimary, |
||||
KvButtonSecondary, |
||||
KvPlayerCard, |
||||
KvNumberInput, |
||||
} from "$lib/components/kuldvillak/ui"; |
||||
|
||||
// Standardized button base class (legacy, used in transition) |
||||
const btnBase = |
||||
"bg-kv-blue border-4 border-black font-kv-body text-lg px-4 py-2 cursor-pointer uppercase hover:opacity-80"; |
||||
|
||||
// Only moderator controls the timer |
||||
onMount(() => { |
||||
gameSession.enableTimerControl(); |
||||
}); |
||||
|
||||
function openProjector() { |
||||
window.open("/kuldvillak/play?view=projector", "kuldvillak-projector"); |
||||
} |
||||
|
||||
// Local state |
||||
let wagerInput = $state(0); |
||||
let scoreAdjustment = $state(100); |
||||
|
||||
// Derived state |
||||
let session = $derived(gameSession.state); |
||||
let currentRound = $derived(gameSession.currentRound); |
||||
let questionData = $derived(gameSession.currentQuestionData); |
||||
|
||||
// Calculate total questions in all rounds |
||||
let totalQuestions = $derived(() => { |
||||
if (!session) return 30; |
||||
return session.rounds.reduce((total, round) => { |
||||
return ( |
||||
total + |
||||
round.categories.reduce((catTotal, cat) => { |
||||
return catTotal + cat.questions.length; |
||||
}, 0) |
||||
); |
||||
}, 0); |
||||
}); |
||||
|
||||
function selectQuestion(catIndex: number, qIndex: number) { |
||||
if (!session) return; |
||||
gameSession.selectQuestion(session.currentRoundIndex, catIndex, qIndex); |
||||
} |
||||
|
||||
function markCorrect() { |
||||
if (!session?.activeTeamId) return; |
||||
gameSession.markCorrect(session.activeTeamId); |
||||
} |
||||
|
||||
function markWrong() { |
||||
if (!session?.activeTeamId) return; |
||||
gameSession.markWrong(session.activeTeamId); |
||||
} |
||||
|
||||
function skipQuestion() { |
||||
gameSession.skipQuestion(); |
||||
} |
||||
|
||||
// Check if team already answered wrong |
||||
function isTeamWrong(teamId: string) { |
||||
return session?.wrongTeamIds?.includes(teamId) ?? false; |
||||
} |
||||
|
||||
// Get available teams (not yet answered wrong) |
||||
let availableTeams = $derived( |
||||
session?.teams.filter((t) => !session?.wrongTeamIds?.includes(t.id)) ?? |
||||
[], |
||||
); |
||||
|
||||
function confirmDailyDoubleWager() { |
||||
if (!session?.activeTeamId) return; |
||||
gameSession.setDailyDoubleWager(session.activeTeamId, wagerInput); |
||||
} |
||||
</script> |
||||
|
||||
{#if session} |
||||
<div class="min-h-screen bg-kv-black flex flex-col p-4 gap-4"> |
||||
<!-- Header + Players Section --> |
||||
<section class="bg-kv-blue p-4"> |
||||
<!-- Header Row --> |
||||
<div class="flex justify-between items-start mb-4"> |
||||
<!-- Left: Game Info --> |
||||
<div> |
||||
<h1 |
||||
class="font-kv-title text-kv-yellow text-5xl uppercase m-0 kv-shadow-text" |
||||
> |
||||
{session.name} |
||||
</h1> |
||||
<div |
||||
class="font-kv-body text-kv-yellow text-xl uppercase mt-2 kv-shadow-text" |
||||
> |
||||
{currentRound?.name || m.kv_play_round()} - {m.kv_play_round()} |
||||
{session.currentRoundIndex + 1} |
||||
</div> |
||||
</div> |
||||
|
||||
<!-- Right: Action Buttons --> |
||||
<div class="flex flex-col gap-2 items-end"> |
||||
<div class="flex gap-4"> |
||||
<KvButtonPrimary |
||||
onclick={openProjector} |
||||
class="!py-3 !px-6 !text-xl" |
||||
> |
||||
{m.kv_play_open_projector()} |
||||
</KvButtonPrimary> |
||||
{#if session.settings.enableFinalRound && session.finalRound && session.phase === "board"} |
||||
<KvButtonPrimary |
||||
onclick={() => gameSession.goToFinalRound()} |
||||
class="!py-3 !px-6 !text-xl" |
||||
> |
||||
{m.kv_play_go_to_final()} |
||||
</KvButtonPrimary> |
||||
{/if} |
||||
<KvButtonSecondary |
||||
onclick={() => { |
||||
if (confirm(m.kv_play_end_game_confirm())) |
||||
gameSession.endGame(); |
||||
}} |
||||
class="!py-3 !px-6 !text-xl" |
||||
> |
||||
{m.kv_play_end_game()} |
||||
</KvButtonSecondary> |
||||
</div> |
||||
|
||||
<!-- Last answerer + Score adjustment --> |
||||
<div class="flex items-center gap-6"> |
||||
{#if session.lastAnsweredTeamId} |
||||
{@const lastTeam = session.teams.find( |
||||
(t) => t.id === session.lastAnsweredTeamId, |
||||
)} |
||||
<span |
||||
class="font-kv-body text-xl text-kv-white uppercase kv-shadow-text" |
||||
> |
||||
{m.kv_play_last_answer()}: |
||||
<span |
||||
class={session.lastAnswerCorrect |
||||
? "text-kv-green" |
||||
: "text-kv-red"} |
||||
> |
||||
{lastTeam?.name} |
||||
{session.lastAnswerCorrect ? "✓" : "✗"} |
||||
</span> |
||||
</span> |
||||
{/if} |
||||
<div class="flex items-center gap-2"> |
||||
<span |
||||
class="font-kv-body text-xl text-kv-white uppercase" |
||||
>{m.kv_play_adjust_by()}:</span |
||||
> |
||||
<KvNumberInput |
||||
bind:value={scoreAdjustment} |
||||
min={50} |
||||
step={50} |
||||
/> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
|
||||
<!-- Player Cards Row --> |
||||
<div class="grid grid-cols-6 gap-4"> |
||||
{#each session.teams as team} |
||||
<KvPlayerCard |
||||
{team} |
||||
isActive={session.activeTeamId === team.id} |
||||
isAnswering={session.phase === "question" && |
||||
session.activeTeamId === team.id} |
||||
{scoreAdjustment} |
||||
onadjust={(delta) => |
||||
gameSession.adjustScore(team.id, delta)} |
||||
onclick={() => |
||||
session.phase === "question" && |
||||
!session.showAnswer && |
||||
gameSession.setActiveTeam(team.id)} |
||||
disabled={isTeamWrong(team.id)} |
||||
/> |
||||
{/each} |
||||
</div> |
||||
</section> |
||||
|
||||
{#if session.phase === "intro"} |
||||
<!-- Intro Phase Controls --> |
||||
<div |
||||
class="bg-kv-blue p-8 flex-1 flex flex-col items-center justify-center gap-6" |
||||
> |
||||
<div class="font-kv-title text-kv-yellow text-6xl uppercase"> |
||||
KULDVILLAK |
||||
</div> |
||||
<div class="font-kv-body text-kv-white text-xl uppercase"> |
||||
{m.kv_play_round()} |
||||
{session.currentRoundIndex + 1} |
||||
</div> |
||||
<div class="flex gap-4 mt-8"> |
||||
<button |
||||
class="{btnBase} text-kv-yellow text-2xl px-8 py-4" |
||||
onclick={() => gameSession.startCategoryIntro()} |
||||
> |
||||
{m.kv_play_introduce_categories()} |
||||
</button> |
||||
<button |
||||
class="{btnBase} text-kv-white text-xl px-6 py-4" |
||||
onclick={() => gameSession.startBoard()} |
||||
> |
||||
{m.kv_play_skip_to_game()} |
||||
</button> |
||||
</div> |
||||
</div> |
||||
{:else if session.phase === "intro-categories"} |
||||
<!-- Category Introduction Progress --> |
||||
<div |
||||
class="bg-kv-blue p-8 flex-1 flex flex-col items-center justify-center gap-6" |
||||
> |
||||
<div class="font-kv-body text-kv-yellow text-3xl uppercase"> |
||||
{m.kv_play_introducing_categories()} |
||||
</div> |
||||
<div class="font-kv-body text-kv-white text-xl"> |
||||
{session.introCategoryIndex + 1} / {currentRound?.categories |
||||
.length ?? 0} |
||||
</div> |
||||
{#if currentRound?.categories[session.introCategoryIndex]} |
||||
<div |
||||
class="font-kv-body text-kv-white text-4xl uppercase mt-4" |
||||
> |
||||
{currentRound.categories[session.introCategoryIndex] |
||||
.name} |
||||
</div> |
||||
<button |
||||
class="{btnBase} text-kv-white/70 text-lg px-4 py-2 mt-4" |
||||
onclick={() => gameSession.startBoard()} |
||||
> |
||||
{m.kv_play_skip_to_game()} |
||||
</button> |
||||
{:else} |
||||
<div class="flex gap-4 mt-8"> |
||||
<button |
||||
class="{btnBase} text-kv-yellow text-2xl px-8 py-4" |
||||
onclick={() => gameSession.startBoard()} |
||||
> |
||||
{m.kv_play_start_game()} |
||||
</button> |
||||
</div> |
||||
{/if} |
||||
</div> |
||||
{:else if session.phase === "board"} |
||||
<!-- Mini Game Board for selection (like edit page) --> |
||||
<div class="bg-kv-black p-0.5 flex-1 flex flex-col gap-0.5"> |
||||
<!-- Category Headers Row - fixed height --> |
||||
<div class="grid grid-cols-6 gap-0.5"> |
||||
{#each currentRound?.categories ?? [] as cat} |
||||
<div |
||||
class="bg-kv-blue font-kv-body text-kv-white text-center uppercase py-2 px-1 text-[clamp(8px,1.2vw,16px)] min-h-[40px] flex items-center justify-center" |
||||
style="text-shadow: var(--kv-shadow-category);" |
||||
> |
||||
{cat.name || "???"} |
||||
</div> |
||||
{/each} |
||||
</div> |
||||
<!-- Questions Grid --> |
||||
<div class="grid grid-cols-6 gap-0.5 flex-1"> |
||||
{#each currentRound?.categories ?? [] as cat, ci} |
||||
<div class="flex flex-col gap-0.5 min-w-0"> |
||||
{#each cat.questions as q, qi} |
||||
{@const result = gameSession.getQuestionResult( |
||||
ci, |
||||
qi, |
||||
)} |
||||
<button |
||||
class="bg-kv-blue border-none py-1 px-0.5 cursor-pointer flex-1 flex flex-col items-center justify-center relative hover:brightness-110 disabled:cursor-not-allowed {q.isRevealed |
||||
? 'opacity-70' |
||||
: ''} {q.isDailyDouble && !q.isRevealed |
||||
? 'ring-2 ring-inset ring-kv-yellow' |
||||
: ''}" |
||||
disabled={q.isRevealed} |
||||
onclick={() => selectQuestion(ci, qi)} |
||||
> |
||||
{#if q.isRevealed && result} |
||||
<!-- Show result stats --> |
||||
{@const team = result.teamId |
||||
? session.teams.find( |
||||
(t) => t.id === result.teamId, |
||||
) |
||||
: null} |
||||
<div |
||||
class="font-kv-body text-center text-[clamp(8px,1vw,14px)] leading-tight" |
||||
> |
||||
{#if result.pointsChange > 0} |
||||
<div class="text-kv-green"> |
||||
+{result.pointsChange}€ |
||||
</div> |
||||
<div |
||||
class="text-kv-white/60 truncate max-w-full" |
||||
> |
||||
{team?.name} |
||||
</div> |
||||
{:else if result.pointsChange < 0} |
||||
<div class="text-kv-red"> |
||||
{result.pointsChange}€ |
||||
</div> |
||||
<div |
||||
class="text-kv-white/60 truncate max-w-full" |
||||
> |
||||
{team?.name} |
||||
</div> |
||||
{:else} |
||||
<div class="text-kv-white/40"> |
||||
— |
||||
</div> |
||||
{/if} |
||||
{#if result.isDailyDouble && result.wager} |
||||
<div |
||||
class="text-kv-yellow text-[clamp(6px,0.8vw,10px)]" |
||||
> |
||||
DD |
||||
</div> |
||||
{/if} |
||||
</div> |
||||
{:else} |
||||
<span |
||||
class="font-kv-body text-kv-yellow text-[clamp(10px,1.5vw,24px)]" |
||||
style="text-shadow: var(--kv-shadow-price);" |
||||
> |
||||
{q.points}€ |
||||
</span> |
||||
{/if} |
||||
</button> |
||||
{/each} |
||||
</div> |
||||
{/each} |
||||
</div> |
||||
</div> |
||||
{:else if session.phase === "daily-double"} |
||||
<!-- Daily Double Wager --> |
||||
<div |
||||
class="bg-kv-blue p-4 flex-1 flex flex-col items-center justify-center gap-4" |
||||
> |
||||
<h2 class="font-kv-body text-kv-yellow text-3xl uppercase"> |
||||
{m.kv_play_daily_double()}! |
||||
</h2> |
||||
|
||||
<div class="flex gap-2 flex-wrap justify-center"> |
||||
{#each session.teams as team} |
||||
<button |
||||
class="{btnBase} text-xl px-6 py-3 {session.activeTeamId === |
||||
team.id |
||||
? 'ring-2 ring-kv-yellow ' + 'text-kv-yellow' |
||||
: 'text-kv-white'}" |
||||
onclick={() => gameSession.setActiveTeam(team.id)} |
||||
> |
||||
{team.name} ({team.score}€) |
||||
</button> |
||||
{/each} |
||||
</div> |
||||
|
||||
{#if session.activeTeamId} |
||||
{@const activeTeam = session.teams.find( |
||||
(t) => t.id === session.activeTeamId, |
||||
)} |
||||
<div class="flex items-center gap-4 mt-4"> |
||||
<span |
||||
class="text-kv-white text-xl font-kv-body uppercase" |
||||
>{m.kv_play_wager()}:</span |
||||
> |
||||
<input |
||||
type="number" |
||||
class="bg-kv-blue border-2 border-black text-kv-white font-kv-body text-xl px-4 py-2 w-32 text-center" |
||||
min="5" |
||||
max={Math.max( |
||||
activeTeam?.score ?? 0, |
||||
questionData?.question.points ?? 500, |
||||
)} |
||||
bind:value={wagerInput} |
||||
/> |
||||
<button |
||||
class="{btnBase} text-kv-yellow text-xl px-6 py-3" |
||||
onclick={confirmDailyDoubleWager} |
||||
> |
||||
{m.kv_play_confirm()} |
||||
</button> |
||||
</div> |
||||
{/if} |
||||
</div> |
||||
{:else if session.phase === "question" && questionData} |
||||
<!-- Question View with Answer --> |
||||
<div class="bg-kv-blue p-4 flex-1 flex flex-col gap-4"> |
||||
<div class="flex justify-between items-center"> |
||||
<h2 class="font-kv-body text-kv-white text-2xl uppercase"> |
||||
{questionData.category.name} - {questionData.question |
||||
.points}€ |
||||
{#if session.dailyDoubleWager} |
||||
<span class="text-kv-yellow" |
||||
>(DD: {session.dailyDoubleWager}€)</span |
||||
> |
||||
{/if} |
||||
</h2> |
||||
<div class="flex items-center gap-4"> |
||||
<span class="font-kv-body text-kv-white/70 text-lg"> |
||||
{m.kv_play_question_number({ |
||||
current: session.currentQuestionNumber, |
||||
total: totalQuestions(), |
||||
})} |
||||
</span> |
||||
{#if session.showAnswer} |
||||
<span |
||||
class="font-kv-body text-kv-yellow text-xl uppercase" |
||||
>{m.kv_play_showing_answer()}</span |
||||
> |
||||
{/if} |
||||
</div> |
||||
</div> |
||||
|
||||
<!-- Question Status Tracking --> |
||||
<div class="flex gap-4 text-sm font-kv-body"> |
||||
{#if session.wrongTeamIds.length > 0} |
||||
<span class="text-kv-red"> |
||||
✗ {session.wrongTeamIds |
||||
.map( |
||||
(id) => |
||||
session.teams.find((t) => t.id === id) |
||||
?.name, |
||||
) |
||||
.join(", ")} |
||||
</span> |
||||
{/if} |
||||
{#if session.activeTeamId && session.showAnswer} |
||||
{@const activeTeam = session.teams.find( |
||||
(t) => t.id === session.activeTeamId, |
||||
)} |
||||
{@const points = |
||||
session.dailyDoubleWager ?? |
||||
questionData.question.points} |
||||
{#if session.lastAnswerCorrect} |
||||
<span class="text-kv-green"> |
||||
✓ {activeTeam?.name} +{points}€ |
||||
</span> |
||||
{:else if session.lastAnswerCorrect === false} |
||||
<span class="text-kv-red"> |
||||
✗ {activeTeam?.name} -{points}€ |
||||
</span> |
||||
{/if} |
||||
{/if} |
||||
</div> |
||||
|
||||
<div class="bg-black/30 p-6 flex-1"> |
||||
<div |
||||
class="text-kv-white text-2xl mb-6 font-kv-body uppercase" |
||||
> |
||||
<strong>{m.kv_play_question_short()}:</strong> |
||||
{questionData.question.question} |
||||
</div> |
||||
<div |
||||
class="text-kv-yellow text-3xl font-bold font-kv-body uppercase" |
||||
> |
||||
<strong>{m.kv_play_answer_short()}:</strong> |
||||
{questionData.question.answer} |
||||
</div> |
||||
</div> |
||||
|
||||
<!-- Team Selection --> |
||||
<div class="flex gap-3 flex-wrap items-center"> |
||||
<span class="text-kv-white text-xl font-kv-body uppercase" |
||||
>{m.kv_play_answering()}:</span |
||||
> |
||||
{#each session.teams as team} |
||||
{@const isWrong = isTeamWrong(team.id)} |
||||
<button |
||||
class="{btnBase} text-xl px-6 py-3 {isWrong |
||||
? 'opacity-30 line-through' |
||||
: ''} {session.activeTeamId === team.id |
||||
? 'ring-2 ring-kv-yellow ' + 'text-kv-yellow' |
||||
: 'text-kv-white'}" |
||||
onclick={() => gameSession.setActiveTeam(team.id)} |
||||
disabled={isWrong || session.showAnswer} |
||||
> |
||||
{team.name} |
||||
</button> |
||||
{/each} |
||||
</div> |
||||
|
||||
<!-- Answer Controls --> |
||||
<div class="flex gap-4 justify-center"> |
||||
<button |
||||
class="bg-kv-green border-2 border-black font-kv-body text-kv-white text-2xl px-10 py-4 cursor-pointer uppercase hover:opacity-80 disabled:opacity-50 disabled:cursor-not-allowed" |
||||
disabled={!session.activeTeamId || session.showAnswer} |
||||
onclick={markCorrect} |
||||
> |
||||
✓ {m.kv_play_correct()} |
||||
</button> |
||||
<button |
||||
class="bg-kv-red border-2 border-black font-kv-body text-kv-white text-2xl px-10 py-4 cursor-pointer uppercase hover:opacity-80 disabled:opacity-50 disabled:cursor-not-allowed" |
||||
disabled={!session.activeTeamId || session.showAnswer} |
||||
onclick={markWrong} |
||||
> |
||||
✗ {m.kv_play_wrong()} |
||||
</button> |
||||
<button |
||||
class="{btnBase} text-kv-white text-xl px-6 py-4" |
||||
onclick={skipQuestion} |
||||
disabled={session.showAnswer} |
||||
> |
||||
{m.kv_play_skip()} |
||||
</button> |
||||
</div> |
||||
|
||||
<!-- Timer --> |
||||
<div |
||||
class="flex items-center gap-3 justify-center font-kv-body" |
||||
> |
||||
<span class="text-kv-yellow text-xl uppercase" |
||||
>{m.kv_play_timer()}</span |
||||
> |
||||
<span class="text-kv-white text-xl" |
||||
>{session.timerSeconds}s</span |
||||
> |
||||
<input |
||||
type="number" |
||||
class="bg-kv-blue border-2 border-black text-kv-white w-20 px-2 py-2 text-center text-xl" |
||||
min="5" |
||||
max="120" |
||||
bind:value={session.timerMax} |
||||
onchange={() => |
||||
gameSession.setTimerMax(session?.timerMax ?? 10)} |
||||
/> |
||||
<span class="text-kv-white text-xl" |
||||
>{m.kv_play_seconds()}</span |
||||
> |
||||
{#if session.timerRunning} |
||||
<button |
||||
class="{btnBase} text-kv-red text-xl px-4 py-2" |
||||
onclick={() => gameSession.stopTimer()} |
||||
>{m.kv_play_stop()}</button |
||||
> |
||||
{:else} |
||||
<button |
||||
class="{btnBase} text-kv-green text-xl px-4 py-2" |
||||
onclick={() => gameSession.startTimer()} |
||||
>{m.kv_play_start()}</button |
||||
> |
||||
{/if} |
||||
<button |
||||
class="{btnBase} text-kv-white text-xl px-4 py-2" |
||||
onclick={() => gameSession.resetTimer()} |
||||
>{m.kv_play_reset()}</button |
||||
> |
||||
</div> |
||||
</div> |
||||
{:else if session.phase === "final-intro"} |
||||
<!-- Final Round Intro Controls --> |
||||
<div |
||||
class="bg-kv-blue p-8 flex-1 flex flex-col items-center justify-center gap-6" |
||||
> |
||||
<div |
||||
class="text-kv-yellow text-6xl uppercase" |
||||
style="font-family: var(--kv-font-title);" |
||||
> |
||||
{m.kv_play_final_round()} |
||||
</div> |
||||
{#if session.finalCategoryRevealed} |
||||
<!-- Category has been revealed, show question button --> |
||||
<div |
||||
class="font-kv-body text-kv-white text-2xl uppercase mt-4" |
||||
> |
||||
{session.finalRound?.category} |
||||
</div> |
||||
<div class="flex gap-4 mt-8"> |
||||
<button |
||||
class="{btnBase} text-kv-yellow text-2xl px-8 py-4" |
||||
onclick={() => gameSession.showFinalQuestion()} |
||||
> |
||||
{m.kv_play_question_short()} |
||||
</button> |
||||
</div> |
||||
{:else} |
||||
<!-- Category not revealed yet --> |
||||
<div class="flex gap-4 mt-8"> |
||||
<button |
||||
class="{btnBase} text-kv-yellow text-2xl px-8 py-4" |
||||
onclick={() => |
||||
gameSession.startFinalCategoryReveal()} |
||||
> |
||||
{m.kv_play_reveal_category()} |
||||
</button> |
||||
</div> |
||||
{/if} |
||||
</div> |
||||
{:else if session.phase === "final-category"} |
||||
<!-- Final Category Animation in progress --> |
||||
<div |
||||
class="bg-kv-blue p-8 flex-1 flex flex-col items-center justify-center gap-6" |
||||
> |
||||
<div class="font-kv-body text-kv-yellow text-3xl uppercase"> |
||||
{m.kv_play_introducing_categories()} |
||||
</div> |
||||
<div class="font-kv-body text-kv-white text-4xl uppercase mt-4"> |
||||
{session.finalRound?.category} |
||||
</div> |
||||
</div> |
||||
{:else if session.phase === "final-question"} |
||||
<!-- Final Round Controls --> |
||||
<div class="bg-kv-blue p-4 flex-1 flex flex-col gap-4"> |
||||
<h2 class="font-kv-body text-kv-yellow text-3xl uppercase"> |
||||
{m.kv_play_final_round()} |
||||
</h2> |
||||
<div class="text-kv-white text-xl font-kv-body uppercase"> |
||||
<strong>{m.kv_edit_category()}:</strong> |
||||
{session.finalRound?.category} |
||||
</div> |
||||
<div class="text-kv-white text-xl font-kv-body uppercase"> |
||||
<strong>{m.kv_play_question_short()}:</strong> |
||||
{session.finalRound?.question} |
||||
</div> |
||||
<div class="text-kv-yellow text-2xl font-kv-body uppercase"> |
||||
<strong>{m.kv_play_answer_short()}:</strong> |
||||
{session.finalRound?.answer} |
||||
</div> |
||||
|
||||
<!-- Timer for final question --> |
||||
<div |
||||
class="flex items-center gap-3 justify-center font-kv-body" |
||||
> |
||||
<span class="text-kv-yellow text-xl uppercase" |
||||
>{m.kv_play_timer()}</span |
||||
> |
||||
<span class="text-kv-white text-xl" |
||||
>{session.timerSeconds}s</span |
||||
> |
||||
{#if session.timerRunning} |
||||
<button |
||||
class="{btnBase} text-kv-red text-xl px-4 py-2" |
||||
onclick={() => gameSession.stopTimer()} |
||||
>{m.kv_play_stop()}</button |
||||
> |
||||
{:else} |
||||
<button |
||||
class="{btnBase} text-kv-green text-xl px-4 py-2" |
||||
onclick={() => gameSession.startTimer()} |
||||
>{m.kv_play_start()}</button |
||||
> |
||||
{/if} |
||||
<button |
||||
class="{btnBase} text-kv-white text-xl px-4 py-2" |
||||
onclick={() => gameSession.resetTimer()} |
||||
>{m.kv_play_reset()}</button |
||||
> |
||||
</div> |
||||
|
||||
<!-- Reveal Answer --> |
||||
<button |
||||
class="{btnBase} text-kv-yellow text-xl px-6 py-3" |
||||
onclick={() => gameSession.revealAnswer()} |
||||
disabled={session.showAnswer} |
||||
> |
||||
{m.kv_play_reveal_answer()} |
||||
</button> |
||||
|
||||
<!-- Show Final Scores --> |
||||
<button |
||||
class="{btnBase} text-kv-white text-xl px-6 py-3" |
||||
onclick={() => gameSession.showFinalScores()} |
||||
> |
||||
{m.kv_play_show_scores()} |
||||
</button> |
||||
</div> |
||||
{:else if session.phase === "final-scores"} |
||||
<!-- Final Scores Display Controls --> |
||||
<div |
||||
class="bg-kv-blue p-4 flex-1 flex flex-col items-center justify-center gap-4" |
||||
> |
||||
<h2 class="font-kv-body text-kv-yellow text-3xl uppercase"> |
||||
{m.kv_play_scores()} |
||||
</h2> |
||||
<button |
||||
class="{btnBase} text-kv-red text-xl px-6 py-3" |
||||
onclick={() => gameSession.endGame()} |
||||
> |
||||
{m.kv_play_end_game()} |
||||
</button> |
||||
</div> |
||||
{:else if session.phase === "finished"} |
||||
<div |
||||
class="bg-kv-blue p-4 flex-1 flex flex-col items-center justify-center gap-4" |
||||
> |
||||
<h2 class="font-kv-body text-kv-yellow text-3xl uppercase"> |
||||
{m.kv_play_game_over()}! |
||||
</h2> |
||||
<a |
||||
href="/kuldvillak" |
||||
class="{btnBase} text-kv-yellow text-xl px-6 py-3" |
||||
>{m.kv_edit_back()}</a |
||||
> |
||||
</div> |
||||
{/if} |
||||
</div> |
||||
{/if} |
||||
@ -0,0 +1,715 @@ |
||||
<script lang="ts"> |
||||
import { gameSession } from "$lib/stores/gameSession.svelte"; |
||||
import * as m from "$lib/paraglide/messages"; |
||||
import { KvGameLogo } from "$lib/components/kuldvillak/ui"; |
||||
|
||||
// Derived state |
||||
let session = $derived(gameSession.state); |
||||
let currentRound = $derived(gameSession.currentRound); |
||||
let questionData = $derived(gameSession.currentQuestionData); |
||||
|
||||
// Get round variant for logo display |
||||
let roundVariant = $derived.by(() => { |
||||
const roundIndex = session?.currentRoundIndex ?? 0; |
||||
if (roundIndex === 0) return "villak" as const; |
||||
if (roundIndex === 1) return "topeltvillak" as const; |
||||
return "hobevillak" as const; |
||||
}); |
||||
|
||||
// Animation state for question expand |
||||
let animationPhase = $state<"waiting" | "expanding" | "shown" | "none">( |
||||
"none", |
||||
); |
||||
let prevPhase = $state<string | null>(null); |
||||
|
||||
// Intro category animation state (used for both regular and final round) |
||||
// Timing: 500ms dissolve ease-out, 1500ms shown, 500ms push left |
||||
let introAnimPhase = $state< |
||||
"villak" | "fade-in" | "shown" | "push-out" | "none" |
||||
>("none"); |
||||
let prevIntroCatIndex = $state(-1); |
||||
let finalCategoryAnimStarted = $state(false); |
||||
|
||||
// Game board reveal animation state |
||||
let boardRevealPhase = $state<"hidden" | "revealing" | "revealed">( |
||||
"hidden", |
||||
); |
||||
let revealedPrices = $state<Set<string>>(new Set()); |
||||
let prevBoardPhase = $state<string | null>(null); |
||||
|
||||
// Calculate starting position for the expanding overlay using actual DOM element |
||||
let questionGridEl = $state<HTMLElement | null>(null); |
||||
let startPosition = $derived(() => { |
||||
if (!session?.currentQuestion || !currentRound || !questionGridEl) { |
||||
return { left: 50, top: 50, width: 16, height: 20 }; |
||||
} |
||||
const { categoryIndex, questionIndex } = session.currentQuestion; |
||||
const card = questionGridEl.querySelector( |
||||
`[data-cat="${categoryIndex}"][data-q="${questionIndex}"]`, |
||||
) as HTMLElement | null; |
||||
|
||||
if (!card) { |
||||
return { left: 50, top: 50, width: 16, height: 20 }; |
||||
} |
||||
|
||||
const gridRect = questionGridEl.getBoundingClientRect(); |
||||
const cardRect = card.getBoundingClientRect(); |
||||
|
||||
const left = |
||||
((cardRect.left - gridRect.left + cardRect.width / 2) / |
||||
gridRect.width) * |
||||
100; |
||||
const top = |
||||
((cardRect.top - gridRect.top + cardRect.height / 2) / |
||||
gridRect.height) * |
||||
100; |
||||
const width = (cardRect.width / gridRect.width) * 100; |
||||
const height = (cardRect.height / gridRect.height) * 100; |
||||
|
||||
return { left, top, width, height }; |
||||
}); |
||||
|
||||
// Watch for phase changes to trigger animation |
||||
$effect(() => { |
||||
const currentPhase = session?.phase; |
||||
|
||||
if (currentPhase === "question" && prevPhase !== "question") { |
||||
animationPhase = "waiting"; |
||||
setTimeout(() => { |
||||
animationPhase = "expanding"; |
||||
}, 100); |
||||
setTimeout(() => { |
||||
animationPhase = "shown"; |
||||
}, 1100); |
||||
} else if (currentPhase !== "question") { |
||||
animationPhase = "none"; |
||||
} |
||||
|
||||
prevPhase = currentPhase ?? null; |
||||
}); |
||||
|
||||
// Watch for intro category changes to trigger animation sequence |
||||
// Timing: 500ms on villak, 500ms dissolve ease-out, 1500ms shown, 500ms push left linear |
||||
$effect(() => { |
||||
const catIndex = session?.introCategoryIndex ?? -1; |
||||
|
||||
if ( |
||||
session?.phase === "intro-categories" && |
||||
catIndex >= 0 && |
||||
catIndex !== prevIntroCatIndex |
||||
) { |
||||
// Start animation sequence for new category |
||||
introAnimPhase = "villak"; // Stay on Villak for 500ms |
||||
setTimeout(() => { |
||||
introAnimPhase = "fade-in"; // 500ms dissolve |
||||
}, 500); // Start fade after 500ms on villak |
||||
setTimeout(() => { |
||||
introAnimPhase = "shown"; |
||||
}, 1000); // 500ms villak + 500ms fade |
||||
setTimeout(() => { |
||||
introAnimPhase = "push-out"; // 500ms push left |
||||
}, 2500); // 500ms villak + 500ms fade + 1500ms shown |
||||
setTimeout(() => { |
||||
introAnimPhase = "none"; |
||||
gameSession.nextIntroCategory(); |
||||
}, 3000); // Total: 500ms + 500ms + 1500ms + 500ms = 3000ms |
||||
} else if ( |
||||
session?.phase !== "intro-categories" && |
||||
session?.phase !== "final-category" |
||||
) { |
||||
introAnimPhase = "none"; |
||||
} |
||||
|
||||
prevIntroCatIndex = catIndex; |
||||
}); |
||||
|
||||
// Watch for board phase to trigger staggered price reveal (only once per round) |
||||
$effect(() => { |
||||
const currentPhase = session?.phase; |
||||
const alreadyRevealed = session?.boardRevealed ?? false; |
||||
|
||||
if ( |
||||
currentPhase === "board" && |
||||
prevBoardPhase !== "board" && |
||||
!alreadyRevealed |
||||
) { |
||||
// First time entering board - do the reveal animation |
||||
boardRevealPhase = "revealing"; |
||||
revealedPrices = new Set(); |
||||
|
||||
// Stagger reveal each price cell (instant opacity, no transition) |
||||
const categories = currentRound?.categories ?? []; |
||||
const questionsPerCat = categories[0]?.questions.length ?? 5; |
||||
let delay = 0; |
||||
|
||||
for (let qi = 0; qi < questionsPerCat; qi++) { |
||||
for (let ci = 0; ci < categories.length; ci++) { |
||||
const key = `${ci}-${qi}`; |
||||
setTimeout(() => { |
||||
revealedPrices = new Set([...revealedPrices, key]); |
||||
}, delay); |
||||
delay += 50; // 50ms between each cell |
||||
} |
||||
} |
||||
|
||||
setTimeout(() => { |
||||
boardRevealPhase = "revealed"; |
||||
gameSession.markBoardRevealed(); // Mark as revealed so it won't animate again |
||||
}, delay + 100); |
||||
} else if (currentPhase === "board" && alreadyRevealed) { |
||||
// Already revealed - show all prices immediately |
||||
boardRevealPhase = "revealed"; |
||||
} |
||||
|
||||
prevBoardPhase = currentPhase ?? null; |
||||
}); |
||||
|
||||
// Watch for final category phase to trigger the same animation |
||||
$effect(() => { |
||||
if (session?.phase === "final-category" && !finalCategoryAnimStarted) { |
||||
finalCategoryAnimStarted = true; |
||||
// Same timing as regular category intro |
||||
introAnimPhase = "villak"; |
||||
setTimeout(() => { |
||||
introAnimPhase = "fade-in"; |
||||
}, 500); |
||||
setTimeout(() => { |
||||
introAnimPhase = "shown"; |
||||
}, 1000); |
||||
setTimeout(() => { |
||||
introAnimPhase = "push-out"; |
||||
}, 2500); |
||||
setTimeout(() => { |
||||
introAnimPhase = "none"; |
||||
gameSession.finishFinalCategoryReveal(); |
||||
}, 3000); |
||||
} else if (session?.phase !== "final-category") { |
||||
finalCategoryAnimStarted = false; |
||||
} |
||||
}); |
||||
|
||||
// Helper to check if a price cell should be visible |
||||
function isPriceRevealed(ci: number, qi: number): boolean { |
||||
if (boardRevealPhase === "revealed") return true; |
||||
return revealedPrices.has(`${ci}-${qi}`); |
||||
} |
||||
</script> |
||||
|
||||
{#if session} |
||||
<div |
||||
class="h-screen w-screen bg-kv-black flex flex-col overflow-hidden relative" |
||||
> |
||||
{#if session.phase === "intro"} |
||||
<!-- Home Screen with grid background - show Villak if categories introduced --> |
||||
<div class="flex-1 flex items-center justify-center intro-grid-bg"> |
||||
{#if session.categoriesIntroduced} |
||||
<KvGameLogo |
||||
variant={roundVariant} |
||||
class="w-[60vw] max-w-[885px] h-auto drop-shadow-[6px_6px_4px_rgba(0,0,0,0.5)]" |
||||
/> |
||||
{:else} |
||||
<KvGameLogo |
||||
variant="kuldvillak" |
||||
class="w-[80vw] max-w-[1536px] h-auto drop-shadow-[6px_6px_4px_rgba(0,0,0,0.5)]" |
||||
/> |
||||
{/if} |
||||
</div> |
||||
{:else if session.phase === "intro-categories"} |
||||
<!-- Category Introduction Animation --> |
||||
{@const currentCat = |
||||
currentRound?.categories[session.introCategoryIndex]} |
||||
<div class="flex-1 relative overflow-hidden"> |
||||
<!-- VILLAK background - always visible as base layer --> |
||||
<div |
||||
class="absolute inset-0 flex items-center justify-center intro-grid-bg" |
||||
> |
||||
<KvGameLogo |
||||
variant={roundVariant} |
||||
class="w-[60vw] max-w-[885px] h-auto drop-shadow-[6px_6px_4px_rgba(0,0,0,0.5)]" |
||||
/> |
||||
</div> |
||||
|
||||
<!-- Category reveal - fades in on TOP of Villak, then pushed out --> |
||||
{#if currentCat} |
||||
<div |
||||
class="absolute inset-0 bg-kv-black p-8 intro-category {introAnimPhase}" |
||||
> |
||||
<div |
||||
class="w-full h-full flex items-center justify-center category-gradient-bg overflow-hidden p-2" |
||||
> |
||||
<div |
||||
class="font-kv-body text-kv-white text-[clamp(48px,12vw,192px)] uppercase text-center leading-normal kv-shadow-text" |
||||
> |
||||
{currentCat.name} |
||||
</div> |
||||
</div> |
||||
</div> |
||||
{/if} |
||||
|
||||
<!-- Villak pusher - slides in from right to push category out --> |
||||
<div |
||||
class="absolute inset-0 flex items-center justify-center intro-grid-bg intro-pusher {introAnimPhase}" |
||||
> |
||||
<KvGameLogo |
||||
variant={roundVariant} |
||||
class="w-[60vw] max-w-[885px] h-auto drop-shadow-[6px_6px_4px_rgba(0,0,0,0.5)]" |
||||
/> |
||||
</div> |
||||
</div> |
||||
{:else if session.phase === "board" || session.phase === "question"} |
||||
<!-- Game Board - Full screen responsive grid --> |
||||
<div class="flex-1 flex flex-col p-4 lg:p-8 gap-4 h-full min-h-0"> |
||||
<!-- Category Headers - show round name until board is revealed --> |
||||
<div |
||||
class="grid gap-2 lg:gap-4 shrink-0" |
||||
style="grid-template-columns: repeat({currentRound |
||||
?.categories.length ?? 6}, 1fr);" |
||||
> |
||||
{#each currentRound?.categories ?? [] as cat} |
||||
{@const roundName = |
||||
session.currentRoundIndex === 0 |
||||
? "VILLAK" |
||||
: "TOPELTVILLAK"} |
||||
<div |
||||
class="bg-kv-blue font-kv-body text-kv-white text-center uppercase px-2 py-4 lg:px-4 lg:py-6 text-[clamp(12px,2.5vw,48px)] leading-tight flex items-center justify-center kv-shadow-text overflow-hidden" |
||||
> |
||||
{session.boardRevealed |
||||
? cat.name || "???" |
||||
: roundName} |
||||
</div> |
||||
{/each} |
||||
</div> |
||||
|
||||
<!-- Question Grid - fills remaining space --> |
||||
<div |
||||
bind:this={questionGridEl} |
||||
class="flex-1 grid gap-2 lg:gap-4 min-h-0" |
||||
style="grid-template-columns: repeat({currentRound |
||||
?.categories.length ?? |
||||
6}, 1fr); grid-template-rows: repeat({currentRound |
||||
?.categories[0]?.questions.length ?? 5}, 1fr);" |
||||
> |
||||
{#each currentRound?.categories ?? [] as cat, ci} |
||||
{#each cat.questions as q, qi} |
||||
<div |
||||
class="bg-kv-blue flex items-center justify-center question-card overflow-hidden" |
||||
style="grid-column: {ci + 1}; grid-row: {qi + |
||||
1};" |
||||
data-cat={ci} |
||||
data-q={qi} |
||||
> |
||||
<span |
||||
class="font-kv-price text-kv-yellow text-[clamp(24px,6vw,128px)] kv-shadow-price price-reveal |
||||
{q.isRevealed ? 'opacity-0' : ''} |
||||
{isPriceRevealed(ci, qi) |
||||
? 'revealed' |
||||
: ''}" |
||||
> |
||||
{q.points}<span class="text-[0.75em]" |
||||
>€</span |
||||
> |
||||
</span> |
||||
</div> |
||||
{/each} |
||||
{/each} |
||||
</div> |
||||
</div> |
||||
|
||||
<!-- Question Overlay - Full screen over board --> |
||||
{#if session.phase === "question" && questionData && (animationPhase === "expanding" || animationPhase === "shown")} |
||||
<div |
||||
class="absolute inset-0 bg-kv-black p-8 flex flex-col gap-8 expand-overlay {animationPhase}" |
||||
> |
||||
<!-- Players row - top --> |
||||
<div |
||||
class="grid gap-4 h-32" |
||||
style="grid-template-columns: repeat({session.teams |
||||
.length}, 1fr);" |
||||
> |
||||
{#each session.teams as team} |
||||
<div |
||||
class="bg-kv-blue p-4 flex items-center justify-center overflow-hidden {session.activeTeamId === |
||||
team.id |
||||
? 'ring-4 ring-kv-yellow' |
||||
: ''}" |
||||
> |
||||
<div |
||||
class="flex flex-col items-center font-kv-body text-kv-white uppercase text-center" |
||||
> |
||||
<span |
||||
class="text-[clamp(24px,2.5vw,36px)] kv-shadow-text" |
||||
>{team.name}</span |
||||
> |
||||
<span |
||||
class="text-[clamp(32px,3vw,48px)] kv-shadow-text {team.score < |
||||
0 |
||||
? 'text-kv-red' |
||||
: ''}">{team.score}€</span |
||||
> |
||||
</div> |
||||
</div> |
||||
{/each} |
||||
</div> |
||||
|
||||
<!-- Question area - fills remaining space --> |
||||
<div |
||||
class="flex-1 flex items-center justify-center p-4 overflow-hidden" |
||||
> |
||||
{#if questionData.question.imageUrl} |
||||
<!-- Image question - show only image --> |
||||
<div |
||||
class="h-full max-w-full flex items-center justify-center" |
||||
> |
||||
<img |
||||
src={questionData.question.imageUrl} |
||||
alt="Question" |
||||
class="max-h-full max-w-full object-contain" |
||||
/> |
||||
</div> |
||||
{:else} |
||||
<!-- Text question --> |
||||
<div |
||||
class="font-kv-question text-center text-[clamp(48px,6vw,96px)] leading-normal uppercase" |
||||
> |
||||
{#if session.showAnswer} |
||||
<div class="text-kv-yellow kv-shadow-text"> |
||||
{questionData.question.answer} |
||||
</div> |
||||
{:else} |
||||
<div class="text-kv-white"> |
||||
{questionData.question.question} |
||||
</div> |
||||
{/if} |
||||
</div> |
||||
{/if} |
||||
</div> |
||||
</div> |
||||
{/if} |
||||
{:else if session.phase === "daily-double"} |
||||
<!-- Daily Double (Hõbevillak) Splash - Grid background with logo --> |
||||
<div |
||||
class="flex-1 flex flex-col items-center justify-center intro-grid-bg gap-8" |
||||
> |
||||
<KvGameLogo |
||||
variant="hobevillak" |
||||
class="w-[60vw] max-w-[885px] h-auto drop-shadow-[6px_6px_4px_rgba(0,0,0,0.5)] animate-pulse" |
||||
/> |
||||
{#if session.dailyDoubleWager} |
||||
<div |
||||
class="font-kv-body text-kv-white text-[clamp(32px,4vw,64px)] uppercase kv-shadow-text" |
||||
> |
||||
{m.kv_play_wager()}: {session.dailyDoubleWager}€ |
||||
</div> |
||||
{/if} |
||||
</div> |
||||
{:else if session.phase === "final-intro"} |
||||
<!-- Final Round Intro - KULDVILLAK screen --> |
||||
<div class="flex-1 flex items-center justify-center intro-grid-bg"> |
||||
<KvGameLogo |
||||
variant="kuldvillak" |
||||
class="w-[80vw] max-w-[1536px] h-auto drop-shadow-[6px_6px_4px_rgba(0,0,0,0.5)]" |
||||
/> |
||||
</div> |
||||
{:else if session.phase === "final-category"} |
||||
<!-- Final Round Category - Animated reveal --> |
||||
<div class="flex-1 relative overflow-hidden"> |
||||
<!-- KULDVILLAK background - always visible as base layer --> |
||||
<div |
||||
class="absolute inset-0 flex items-center justify-center intro-grid-bg" |
||||
> |
||||
<KvGameLogo |
||||
variant="kuldvillak" |
||||
class="w-[80vw] max-w-[1536px] h-auto drop-shadow-[6px_6px_4px_rgba(0,0,0,0.5)]" |
||||
/> |
||||
</div> |
||||
|
||||
<!-- Final category reveal - fades in on TOP of KULDVILLAK, then pushed out --> |
||||
<div |
||||
class="absolute inset-0 bg-kv-black p-8 intro-category {introAnimPhase}" |
||||
> |
||||
<div |
||||
class="w-full h-full flex items-center justify-center category-gradient-bg overflow-hidden p-2" |
||||
> |
||||
<div |
||||
class="font-kv-body text-kv-white text-[clamp(48px,12vw,192px)] uppercase text-center leading-normal kv-shadow-text" |
||||
> |
||||
{session.finalRound?.category || "???"} |
||||
</div> |
||||
</div> |
||||
</div> |
||||
|
||||
<!-- KULDVILLAK pusher - slides in from right to push category out --> |
||||
<div |
||||
class="absolute inset-0 flex items-center justify-center intro-grid-bg intro-pusher {introAnimPhase}" |
||||
> |
||||
<KvGameLogo |
||||
variant="kuldvillak" |
||||
class="w-[80vw] max-w-[1536px] h-auto drop-shadow-[6px_6px_4px_rgba(0,0,0,0.5)]" |
||||
/> |
||||
</div> |
||||
</div> |
||||
{:else if session.phase === "final-question"} |
||||
<!-- Final Round Question - Full screen question overlay --> |
||||
<div class="flex-1 flex flex-col p-8 gap-8"> |
||||
<!-- Players row - top --> |
||||
<div |
||||
class="grid gap-4 h-32" |
||||
style="grid-template-columns: repeat({session.teams |
||||
.length}, 1fr);" |
||||
> |
||||
{#each session.teams as team} |
||||
<div |
||||
class="bg-kv-blue p-4 flex items-center justify-center overflow-hidden" |
||||
> |
||||
<div |
||||
class="flex flex-col items-center font-kv-body text-kv-white uppercase text-center" |
||||
> |
||||
<span |
||||
class="text-[clamp(24px,2.5vw,36px)] kv-shadow-text" |
||||
>{team.name}</span |
||||
> |
||||
<span |
||||
class="text-[clamp(32px,3vw,48px)] kv-shadow-text {team.score < |
||||
0 |
||||
? 'text-kv-red' |
||||
: ''}">{team.score}€</span |
||||
> |
||||
</div> |
||||
</div> |
||||
{/each} |
||||
</div> |
||||
|
||||
<!-- Question area - fills remaining space --> |
||||
<div |
||||
class="flex-1 flex items-center justify-center p-4 overflow-hidden" |
||||
> |
||||
<div |
||||
class="font-kv-question text-center text-[clamp(48px,6vw,96px)] leading-normal uppercase" |
||||
> |
||||
{#if session.showAnswer} |
||||
<div class="text-kv-yellow kv-shadow-text"> |
||||
{session.finalRound?.answer} |
||||
</div> |
||||
{:else} |
||||
<div class="text-kv-white"> |
||||
{session.finalRound?.question} |
||||
</div> |
||||
{/if} |
||||
</div> |
||||
</div> |
||||
</div> |
||||
{:else if session.phase === "final-scores"} |
||||
<!-- Final Scores Display - Before ending game --> |
||||
<div |
||||
class="flex-1 flex flex-col p-[clamp(4px,0.5vw,8px)] gap-[clamp(8px,1vw,16px)]" |
||||
> |
||||
<div |
||||
class="bg-kv-blue flex-1 flex flex-col items-center justify-center" |
||||
> |
||||
<div |
||||
class="font-kv-body text-kv-yellow text-[clamp(48px,8vw,120px)] uppercase tracking-wide" |
||||
style="text-shadow: var(--kv-shadow-title);" |
||||
> |
||||
{m.kv_play_scores()} |
||||
</div> |
||||
</div> |
||||
<!-- Final Scoreboard --> |
||||
<div |
||||
class="grid gap-[clamp(4px,0.5vw,8px)]" |
||||
style="grid-template-columns: repeat({session.teams |
||||
.length}, 1fr);" |
||||
> |
||||
{#each gameSession.sortedTeams as team, i} |
||||
<div |
||||
class="bg-kv-blue px-4 py-3 text-center min-h-[clamp(80px,12vh,167px)] flex flex-col items-center justify-center {i === |
||||
0 |
||||
? 'ring-4 ring-kv-yellow' |
||||
: ''}" |
||||
> |
||||
<div |
||||
class="font-kv-body text-kv-white text-[clamp(14px,2vw,52px)] uppercase leading-tight" |
||||
style="text-shadow: var(--kv-shadow-category);" |
||||
> |
||||
#{i + 1} |
||||
{team.name} |
||||
</div> |
||||
<div |
||||
class="font-kv-body text-[clamp(18px,3vw,48px)] {i === |
||||
0 |
||||
? 'text-kv-yellow' |
||||
: 'text-kv-white'}" |
||||
style="text-shadow: var(--kv-shadow-price);" |
||||
> |
||||
{team.score}€ |
||||
</div> |
||||
</div> |
||||
{/each} |
||||
</div> |
||||
</div> |
||||
{:else if session.phase === "finished"} |
||||
<!-- Game Over / Results - Title font, winner highlighted --> |
||||
<div |
||||
class="flex-1 flex flex-col p-[clamp(4px,0.5vw,8px)] gap-[clamp(8px,1vw,16px)]" |
||||
> |
||||
<div |
||||
class="bg-kv-blue flex-1 flex flex-col items-center justify-center" |
||||
> |
||||
<div |
||||
class="font-[family-name:var(--kv-font-title)] text-kv-yellow text-[clamp(48px,10vw,160px)] uppercase tracking-wide mb-8" |
||||
style="text-shadow: var(--kv-shadow-title);" |
||||
> |
||||
{m.kv_play_game_over()}! |
||||
</div> |
||||
<!-- Winner announcement --> |
||||
{#if gameSession.sortedTeams[0]} |
||||
<div |
||||
class="font-kv-body text-kv-white text-[clamp(24px,4vw,64px)] uppercase" |
||||
> |
||||
🏆 {gameSession.sortedTeams[0].name} 🏆 |
||||
</div> |
||||
{/if} |
||||
</div> |
||||
<!-- Final Scoreboard --> |
||||
<div |
||||
class="grid gap-[clamp(4px,0.5vw,8px)]" |
||||
style="grid-template-columns: repeat({session.teams |
||||
.length}, 1fr);" |
||||
> |
||||
{#each gameSession.sortedTeams as team, i} |
||||
<div |
||||
class="bg-kv-blue px-4 py-3 text-center min-h-[clamp(80px,12vh,167px)] flex flex-col items-center justify-center {i === |
||||
0 |
||||
? 'ring-4 ring-kv-yellow' |
||||
: ''}" |
||||
> |
||||
<div |
||||
class="font-kv-body text-kv-white text-[clamp(14px,2vw,52px)] uppercase leading-tight" |
||||
style="text-shadow: var(--kv-shadow-category);" |
||||
> |
||||
#{i + 1} |
||||
{team.name} |
||||
</div> |
||||
<div |
||||
class="font-kv-body text-[clamp(18px,3vw,48px)] {i === |
||||
0 |
||||
? 'text-kv-yellow' |
||||
: 'text-kv-white'}" |
||||
style="text-shadow: var(--kv-shadow-price);" |
||||
> |
||||
{team.score}€ |
||||
</div> |
||||
</div> |
||||
{/each} |
||||
</div> |
||||
</div> |
||||
{/if} |
||||
</div> |
||||
{/if} |
||||
|
||||
<style> |
||||
/* Intro grid background - matches homepage pattern */ |
||||
.intro-grid-bg { |
||||
background-color: var(--kv-blue); |
||||
background-image: linear-gradient( |
||||
color-mix(in srgb, var(--kv-background) 50%, transparent) 2px, |
||||
transparent 2px |
||||
), |
||||
linear-gradient( |
||||
90deg, |
||||
color-mix(in srgb, var(--kv-background) 50%, transparent) 2px, |
||||
transparent 2px |
||||
); |
||||
background-size: 60px 60px; |
||||
} |
||||
|
||||
/* Category gradient background - radial vignette effect */ |
||||
.category-gradient-bg { |
||||
background: radial-gradient( |
||||
ellipse at center, |
||||
var(--kv-blue) 0%, |
||||
color-mix(in srgb, var(--kv-blue) 85%, black) 70%, |
||||
color-mix(in srgb, var(--kv-blue) 70%, black) 100% |
||||
); |
||||
} |
||||
|
||||
/* Intro category animation - 500ms dissolve ease-out */ |
||||
.intro-category { |
||||
opacity: 0; |
||||
z-index: 5; |
||||
} |
||||
.intro-category.villak { |
||||
opacity: 0; |
||||
} |
||||
.intro-category.fade-in { |
||||
animation: fade-in 500ms ease-out forwards; |
||||
} |
||||
.intro-category.shown { |
||||
opacity: 1; |
||||
} |
||||
.intro-category.push-out { |
||||
opacity: 1; |
||||
animation: slide-out-left 500ms linear forwards; |
||||
} |
||||
|
||||
/* Intro pusher - Villak that slides in from right - 500ms linear */ |
||||
.intro-pusher { |
||||
transform: translateX(100%); |
||||
z-index: 10; |
||||
} |
||||
.intro-pusher.villak, |
||||
.intro-pusher.fade-in, |
||||
.intro-pusher.shown { |
||||
transform: translateX(100%); |
||||
} |
||||
.intro-pusher.push-out { |
||||
animation: slide-in-from-right 500ms linear forwards; |
||||
} |
||||
|
||||
@keyframes fade-in { |
||||
from { |
||||
opacity: 0; |
||||
} |
||||
to { |
||||
opacity: 1; |
||||
} |
||||
} |
||||
|
||||
@keyframes slide-in-from-right { |
||||
from { |
||||
transform: translateX(100%); |
||||
} |
||||
to { |
||||
transform: translateX(0); |
||||
} |
||||
} |
||||
|
||||
@keyframes slide-out-left { |
||||
from { |
||||
transform: translateX(0); |
||||
} |
||||
to { |
||||
transform: translateX(-100%); |
||||
} |
||||
} |
||||
|
||||
/* Price reveal animation - instant staggered appearance */ |
||||
.price-reveal { |
||||
opacity: 0; |
||||
} |
||||
.price-reveal.revealed { |
||||
opacity: 1; |
||||
} |
||||
.price-reveal.opacity-0 { |
||||
opacity: 0 !important; |
||||
} |
||||
|
||||
/* Question overlay animation - fade in */ |
||||
.expand-overlay { |
||||
opacity: 0; |
||||
transition: opacity 500ms ease-out; |
||||
} |
||||
.expand-overlay.expanding, |
||||
.expand-overlay.shown { |
||||
opacity: 1; |
||||
} |
||||
</style> |
||||
@ -0,0 +1,130 @@ |
||||
@import 'tailwindcss'; |
||||
|
||||
/* ============================================ |
||||
Tailwind Theme Extensions for Kuldvillak |
||||
============================================ */ |
||||
|
||||
@theme { |
||||
/* Colors - Reference CSS variables for dynamic theming */ |
||||
--color-kv-blue: var(--kv-blue); |
||||
--color-kv-yellow: var(--kv-yellow); |
||||
--color-kv-green: #009900; |
||||
--color-kv-red: #990000; |
||||
--color-kv-black: var(--kv-background); |
||||
--color-kv-white: var(--kv-text); |
||||
/* Additional theme-aware colors */ |
||||
--color-kv-text: var(--kv-text); |
||||
--color-kv-background: var(--kv-background); |
||||
/* Fixed game board color - not affected by theme */ |
||||
--color-kv-board: #003B9B; |
||||
|
||||
/* Font Families */ |
||||
--font-kv-title: 'Swiss 921', sans-serif; |
||||
--font-kv-body: 'Swiss 921', sans-serif; |
||||
--font-kv-price: 'Bebas Neue', sans-serif; |
||||
--font-kv-question: 'ITC Korinna', serif; |
||||
} |
||||
|
||||
/* Text shadow utilities - Updated for new design */ |
||||
.kv-shadow-text { |
||||
text-shadow: 6px 6px 4px rgba(0, 0, 0, 0.5); |
||||
} |
||||
.kv-shadow-title { |
||||
text-shadow: 6px 6px 4px rgba(0, 0, 0, 0.5); |
||||
} |
||||
.kv-shadow-price { |
||||
text-shadow: 8px 8px 4px rgba(0, 0, 0, 0.5), 0 4px 4px rgba(0, 0, 0, 0.25); |
||||
} |
||||
.kv-shadow-question { |
||||
text-shadow: 6px 6px 4px rgba(0, 0, 0, 0.5); |
||||
} |
||||
.kv-shadow-button { |
||||
box-shadow: 0 4px 4px rgba(0, 0, 0, 0.25), 8px 8px 4px rgba(0, 0, 0, 0.5); |
||||
} |
||||
|
||||
/* Icon styling - uses secondary color */ |
||||
.kv-icon { |
||||
color: var(--kv-yellow); |
||||
} |
||||
|
||||
/* ============================================ |
||||
Kuldvillak Custom Fonts |
||||
============================================ */ |
||||
|
||||
/* Bebas Neue for prices - local font */ |
||||
@font-face { |
||||
font-family: 'Bebas Neue'; |
||||
src: url('/fonts/BebasNeue-Regular.ttf') format('truetype'); |
||||
font-weight: 400; |
||||
font-style: normal; |
||||
font-display: swap; |
||||
} |
||||
|
||||
@font-face { |
||||
font-family: 'Swiss 921'; |
||||
src: url('/fonts/Swiss 921 Regular.otf') format('opentype'); |
||||
font-weight: 700; |
||||
font-style: normal; |
||||
font-display: swap; |
||||
} |
||||
|
||||
@font-face { |
||||
font-family: 'ITC Korinna'; |
||||
src: url('/fonts/ITC Korinna Std Bold.otf') format('opentype'); |
||||
font-weight: 700; |
||||
font-style: normal; |
||||
font-display: swap; |
||||
} |
||||
|
||||
/* ============================================ |
||||
Kuldvillak Design Tokens (CSS Variables) |
||||
============================================ */ |
||||
|
||||
:root { |
||||
/* Colors - New Figma Design (these are overridden by theme.svelte.ts) */ |
||||
--kv-blue: #003B9B; |
||||
--kv-yellow: #FFAB00; |
||||
--kv-text: #FFFFFF; |
||||
--kv-background: #000000; |
||||
--kv-green: #009900; |
||||
--kv-red: #990000; |
||||
--kv-black: #000000; |
||||
--kv-white: #FFFFFF; |
||||
|
||||
/* Legacy alias for compatibility */ |
||||
--kv-golden: var(--kv-yellow); |
||||
|
||||
/* Fonts */ |
||||
--kv-font-title: 'Swiss 921', sans-serif; |
||||
--kv-font-body: 'Swiss 921', sans-serif; |
||||
--kv-font-price: 'Bebas Neue', sans-serif; |
||||
--kv-font-question: 'ITC Korinna', serif; |
||||
/* Legacy alias */ |
||||
--kv-font-button: var(--kv-font-body); |
||||
--kv-font-category: var(--kv-font-body); |
||||
|
||||
/* Shadows - New Figma Design */ |
||||
--kv-shadow-text: 6px 6px 4px rgba(0, 0, 0, 0.5); |
||||
--kv-shadow-title: 6px 6px 4px rgba(0, 0, 0, 0.5); |
||||
--kv-shadow-price: 8px 8px 4px rgba(0, 0, 0, 0.5), 0 4px 4px rgba(0, 0, 0, 0.25); |
||||
--kv-shadow-button: 0 4px 4px rgba(0, 0, 0, 0.25), 8px 8px 4px rgba(0, 0, 0, 0.5); |
||||
--kv-shadow-question: 6px 6px 4px rgba(0, 0, 0, 0.5); |
||||
--kv-shadow-category: 6px 6px 4px rgba(0, 0, 0, 0.5); |
||||
} |
||||
|
||||
/* ============================================ |
||||
Global Styles |
||||
============================================ */ |
||||
|
||||
html, |
||||
body { |
||||
height: 100%; |
||||
margin: 0; |
||||
padding: 0; |
||||
background-color: var(--kv-background); |
||||
} |
||||
|
||||
body { |
||||
font-family: var(--kv-font-button); |
||||
color: var(--kv-text); |
||||
} |
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 553 B |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 115 KiB |
@ -0,0 +1,3 @@ |
||||
# allow crawling everything by default |
||||
User-agent: * |
||||
Disallow: |
||||
@ -0,0 +1,15 @@ |
||||
import adapter from '@sveltejs/adapter-auto'; |
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; |
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */ |
||||
const config = { |
||||
// Consult https://svelte.dev/docs/kit/integrations
|
||||
// for more information about preprocessors
|
||||
preprocess: vitePreprocess(), |
||||
kit: { // adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
|
||||
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
|
||||
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
|
||||
adapter: adapter() } |
||||
}; |
||||
|
||||
export default config; |
||||
@ -0,0 +1,20 @@ |
||||
{ |
||||
"extends": "./.svelte-kit/tsconfig.json", |
||||
"compilerOptions": { |
||||
"rewriteRelativeImportExtensions": true, |
||||
"allowJs": true, |
||||
"checkJs": true, |
||||
"esModuleInterop": true, |
||||
"forceConsistentCasingInFileNames": true, |
||||
"resolveJsonModule": true, |
||||
"skipLibCheck": true, |
||||
"sourceMap": true, |
||||
"strict": true, |
||||
"moduleResolution": "bundler" |
||||
} |
||||
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias |
||||
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files |
||||
// |
||||
// To make changes to top-level options such as include and exclude, we recommend extending |
||||
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript |
||||
} |
||||
@ -0,0 +1,15 @@ |
||||
import { paraglideVitePlugin } from '@inlang/paraglide-js'; |
||||
import tailwindcss from '@tailwindcss/vite'; |
||||
import { sveltekit } from '@sveltejs/kit/vite'; |
||||
import { defineConfig } from 'vite'; |
||||
|
||||
export default defineConfig({ |
||||
plugins: [ |
||||
tailwindcss(), |
||||
sveltekit(), |
||||
paraglideVitePlugin({ |
||||
project: './project.inlang', |
||||
outdir: './src/lib/paraglide' |
||||
}) |
||||
] |
||||
}); |
||||
Loading…
Reference in new issue