Added quality of life changes, improved animations, added basic tutorials (need to add more pages), MVP is robust and only needs sound effects and music pretty much, besides a lot of user testing.
|
After Width: | Height: | Size: 302 B |
@ -0,0 +1,36 @@ |
||||
<script lang="ts"> |
||||
interface Props { |
||||
class?: string; |
||||
} |
||||
|
||||
let { class: className = "" }: Props = $props(); |
||||
</script> |
||||
|
||||
<svg |
||||
class="text-kv-yellow {className}" |
||||
viewBox="0 0 128 128" |
||||
fill="currentColor" |
||||
xmlns="http://www.w3.org/2000/svg" |
||||
> |
||||
<path |
||||
fill-rule="evenodd" |
||||
clip-rule="evenodd" |
||||
d="M8.05957 1.5L11.3781 12.5V115.5L8.05957 126.5H45.9855L42.667 115.5V73.6719L82.0151 126.5H119.941L67.3188 59L115.674 1.50006H77.7485L42.667 50.5001V12.5L45.9855 1.50006L8.05957 1.5Z" |
||||
/> |
||||
</svg> |
||||
|
||||
<style> |
||||
svg { |
||||
animation: spin-k 2s cubic-bezier(0.67, -0.42, 0.1, 1.29) infinite; |
||||
filter: drop-shadow(6px 6px 4px rgba(0, 0, 0, 0.5)); |
||||
} |
||||
|
||||
@keyframes spin-k { |
||||
from { |
||||
transform: rotate(0deg); |
||||
} |
||||
to { |
||||
transform: rotate(360deg); |
||||
} |
||||
} |
||||
</style> |
||||
@ -0,0 +1,172 @@ |
||||
<script lang="ts"> |
||||
import * as m from "$lib/paraglide/messages"; |
||||
|
||||
export interface TutorialSlide { |
||||
image: string; |
||||
text: string; |
||||
} |
||||
|
||||
interface Props { |
||||
open: boolean; |
||||
title: string; |
||||
slides: TutorialSlide[]; |
||||
onclose?: () => void; |
||||
} |
||||
|
||||
let { open = $bindable(), title, slides, onclose }: Props = $props(); |
||||
|
||||
let currentIndex = $state(0); |
||||
|
||||
function close() { |
||||
open = false; |
||||
currentIndex = 0; |
||||
onclose?.(); |
||||
} |
||||
|
||||
function prev() { |
||||
if (currentIndex > 0) { |
||||
currentIndex--; |
||||
} |
||||
} |
||||
|
||||
function next() { |
||||
if (currentIndex < slides.length - 1) { |
||||
currentIndex++; |
||||
} |
||||
} |
||||
|
||||
function handleKeydown(e: KeyboardEvent) { |
||||
if (!open) return; |
||||
if (e.key === "Escape") close(); |
||||
if (e.key === "ArrowLeft") prev(); |
||||
if (e.key === "ArrowRight") next(); |
||||
} |
||||
|
||||
// Reset index when modal opens |
||||
$effect(() => { |
||||
if (open) { |
||||
currentIndex = 0; |
||||
} |
||||
}); |
||||
</script> |
||||
|
||||
<svelte:window onkeydown={handleKeydown} /> |
||||
|
||||
{#if open} |
||||
<!-- Backdrop --> |
||||
<div |
||||
class="fixed inset-0 bg-kv-background/50 z-50 flex items-center justify-center p-4" |
||||
onclick={close} |
||||
onkeydown={(e) => e.key === "Enter" && close()} |
||||
role="button" |
||||
tabindex="-1" |
||||
aria-label="Close modal" |
||||
> |
||||
<!-- Modal --> |
||||
<div |
||||
class="bg-kv-blue border-[16px] border-kv-black w-full max-w-5xl max-h-[90vh] flex flex-col gap-6 md:gap-8 p-4 md:p-8 overflow-y-auto" |
||||
role="dialog" |
||||
aria-modal="true" |
||||
tabindex="-1" |
||||
onclick={(e) => e.stopPropagation()} |
||||
onkeydown={(e) => e.stopPropagation()} |
||||
> |
||||
<!-- Header --> |
||||
<div class="flex items-start justify-between gap-4"> |
||||
<h2 |
||||
class="font-kv-body text-xl md:text-3xl text-kv-white uppercase kv-shadow-text" |
||||
> |
||||
{title} |
||||
</h2> |
||||
<button |
||||
class="text-kv-yellow hover:opacity-80 transition-opacity cursor-pointer bg-transparent border-none p-0" |
||||
onclick={close} |
||||
aria-label="Close" |
||||
> |
||||
<svg |
||||
class="w-6 h-6 md:w-8 md:h-8" |
||||
viewBox="0 0 24 24" |
||||
fill="currentColor" |
||||
> |
||||
<path |
||||
d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" |
||||
/> |
||||
</svg> |
||||
</button> |
||||
</div> |
||||
|
||||
<!-- Image Carousel --> |
||||
{#if slides.length > 0} |
||||
<div class="flex items-center gap-2 md:gap-4"> |
||||
<!-- Previous Button --> |
||||
<button |
||||
class="text-kv-yellow hover:opacity-80 transition-opacity cursor-pointer bg-transparent border-none p-0 disabled:opacity-30 disabled:cursor-not-allowed shrink-0" |
||||
onclick={prev} |
||||
disabled={currentIndex === 0} |
||||
aria-label="Previous" |
||||
> |
||||
<svg |
||||
class="w-8 h-8 md:w-12 md:h-12" |
||||
viewBox="0 0 48 48" |
||||
fill="currentColor" |
||||
> |
||||
<path |
||||
d="M29.5334 40L13.5334 24L29.5334 8L33.2668 11.7333L21.0001 24L33.2668 36.2667L29.5334 40Z" |
||||
/> |
||||
</svg> |
||||
</button> |
||||
|
||||
<!-- Image --> |
||||
<div |
||||
class="flex-1 aspect-video bg-kv-black/30 overflow-hidden" |
||||
> |
||||
<img |
||||
src={slides[currentIndex].image} |
||||
alt="Tutorial step {currentIndex + 1}" |
||||
class="w-full h-full object-contain" |
||||
/> |
||||
</div> |
||||
|
||||
<!-- Next Button --> |
||||
<button |
||||
class="text-kv-yellow hover:opacity-80 transition-opacity cursor-pointer bg-transparent border-none p-0 disabled:opacity-30 disabled:cursor-not-allowed shrink-0" |
||||
onclick={next} |
||||
disabled={currentIndex === slides.length - 1} |
||||
aria-label="Next" |
||||
> |
||||
<svg |
||||
class="w-8 h-8 md:w-12 md:h-12" |
||||
viewBox="0 0 48 48" |
||||
fill="currentColor" |
||||
> |
||||
<path |
||||
d="M18.4666 8L34.4666 24L18.4666 40L14.7332 36.2667L26.9999 24L14.7332 11.7333L18.4666 8Z" |
||||
/> |
||||
</svg> |
||||
</button> |
||||
</div> |
||||
|
||||
<!-- Text Description --> |
||||
<p |
||||
class="font-kv-body text-sm md:text-base text-kv-white uppercase kv-shadow-text text-left whitespace-pre-line" |
||||
> |
||||
{slides[currentIndex].text} |
||||
</p> |
||||
|
||||
<!-- Page Indicator --> |
||||
<div class="flex justify-center gap-2"> |
||||
{#each slides as _, i} |
||||
<button |
||||
class="w-3 h-3 rounded-full border-2 border-kv-yellow transition-colors cursor-pointer {i === |
||||
currentIndex |
||||
? 'bg-kv-yellow' |
||||
: 'bg-transparent'}" |
||||
onclick={() => (currentIndex = i)} |
||||
aria-label="Go to slide {i + 1}" |
||||
></button> |
||||
{/each} |
||||
</div> |
||||
{/if} |
||||
</div> |
||||
</div> |
||||
{/if} |
||||
@ -0,0 +1,209 @@ |
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'; |
||||
import type { KuldvillakGame } from '$lib/types/kuldvillak'; |
||||
import { DEFAULT_SETTINGS, DEFAULT_STATE } from '$lib/types/kuldvillak'; |
||||
|
||||
// Mock localStorage
|
||||
const localStorageMock = (() => { |
||||
let store: Record<string, string> = {}; |
||||
return { |
||||
getItem: vi.fn((key: string) => store[key] ?? null), |
||||
setItem: vi.fn((key: string, value: string) => { store[key] = value; }), |
||||
removeItem: vi.fn((key: string) => { delete store[key]; }), |
||||
clear: vi.fn(() => { store = {}; }), |
||||
get length() { return Object.keys(store).length; }, |
||||
key: vi.fn((i: number) => Object.keys(store)[i] ?? null), |
||||
}; |
||||
})(); |
||||
|
||||
Object.defineProperty(global, 'localStorage', { value: localStorageMock }); |
||||
|
||||
// Import after mocking localStorage
|
||||
import { |
||||
getKuldvillakGamesList, |
||||
getAllKuldvillakGames, |
||||
loadKuldvillakGame, |
||||
saveKuldvillakGame, |
||||
deleteKuldvillakGame, |
||||
duplicateKuldvillakGame, |
||||
setActiveKuldvillakGame, |
||||
getActiveKuldvillakGameId, |
||||
} from './persistence'; |
||||
|
||||
// Helper to create a test game
|
||||
function createTestGame(overrides: Partial<KuldvillakGame> = {}): KuldvillakGame { |
||||
return { |
||||
id: 'test-game-id', |
||||
name: 'Test Game', |
||||
createdAt: '2024-01-01T00:00:00.000Z', |
||||
updatedAt: '2024-01-01T00:00:00.000Z', |
||||
settings: { ...DEFAULT_SETTINGS }, |
||||
teams: [ |
||||
{ id: 'team-1', name: 'Team 1', score: 0 }, |
||||
{ id: 'team-2', name: 'Team 2', score: 0 }, |
||||
], |
||||
rounds: [{ |
||||
id: 'round-1', |
||||
name: 'Round 1', |
||||
pointMultiplier: 1, |
||||
categories: [{ |
||||
id: 'cat-1', |
||||
name: 'Category 1', |
||||
questions: [{ |
||||
id: 'q-1', |
||||
question: 'Test Question', |
||||
answer: 'Test Answer', |
||||
points: 100, |
||||
isDailyDouble: false, |
||||
isRevealed: false, |
||||
}], |
||||
}], |
||||
}], |
||||
finalRound: null, |
||||
state: { ...DEFAULT_STATE }, |
||||
...overrides, |
||||
}; |
||||
} |
||||
|
||||
describe('Persistence', () => { |
||||
beforeEach(() => { |
||||
localStorageMock.clear(); |
||||
vi.clearAllMocks(); |
||||
}); |
||||
|
||||
describe('saveKuldvillakGame', () => { |
||||
it('should save a new game', () => { |
||||
const game = createTestGame(); |
||||
const result = saveKuldvillakGame(game); |
||||
|
||||
expect(result).toBe(true); |
||||
expect(localStorageMock.setItem).toHaveBeenCalled(); |
||||
}); |
||||
|
||||
it('should update existing game', () => { |
||||
const game = createTestGame(); |
||||
saveKuldvillakGame(game); |
||||
|
||||
game.name = 'Updated Name'; |
||||
saveKuldvillakGame(game); |
||||
|
||||
const games = getAllKuldvillakGames(); |
||||
expect(games).toHaveLength(1); |
||||
expect(games[0].name).toBe('Updated Name'); |
||||
}); |
||||
|
||||
it('should update the updatedAt timestamp', () => { |
||||
const game = createTestGame(); |
||||
const originalUpdatedAt = game.updatedAt; |
||||
|
||||
// Wait a tiny bit to ensure different timestamp
|
||||
saveKuldvillakGame(game); |
||||
|
||||
const games = getAllKuldvillakGames(); |
||||
expect(games[0].updatedAt).not.toBe(originalUpdatedAt); |
||||
}); |
||||
}); |
||||
|
||||
describe('loadKuldvillakGame', () => { |
||||
it('should return null for non-existent game', () => { |
||||
const result = loadKuldvillakGame('non-existent-id'); |
||||
expect(result).toBeNull(); |
||||
}); |
||||
|
||||
it('should load existing game', () => { |
||||
const game = createTestGame(); |
||||
saveKuldvillakGame(game); |
||||
|
||||
const loaded = loadKuldvillakGame(game.id); |
||||
expect(loaded).not.toBeNull(); |
||||
expect(loaded?.name).toBe(game.name); |
||||
}); |
||||
}); |
||||
|
||||
describe('deleteKuldvillakGame', () => { |
||||
it('should delete a game', () => { |
||||
const game = createTestGame(); |
||||
saveKuldvillakGame(game); |
||||
|
||||
const result = deleteKuldvillakGame(game.id); |
||||
|
||||
expect(result).toBe(true); |
||||
expect(getAllKuldvillakGames()).toHaveLength(0); |
||||
}); |
||||
|
||||
it('should handle deleting non-existent game', () => { |
||||
const result = deleteKuldvillakGame('non-existent'); |
||||
expect(result).toBe(true); // Should not fail
|
||||
}); |
||||
}); |
||||
|
||||
describe('duplicateKuldvillakGame', () => { |
||||
it('should create a duplicate with new ID', () => { |
||||
const original = createTestGame(); |
||||
saveKuldvillakGame(original); |
||||
|
||||
const duplicate = duplicateKuldvillakGame(original.id); |
||||
|
||||
expect(duplicate).not.toBeNull(); |
||||
expect(duplicate?.id).not.toBe(original.id); |
||||
expect(duplicate?.name).toBe(`${original.name} (Copy)`); |
||||
}); |
||||
|
||||
it('should reset game state in duplicate', () => { |
||||
const original = createTestGame(); |
||||
original.state.phase = 'board'; |
||||
original.teams[0].score = 500; |
||||
saveKuldvillakGame(original); |
||||
|
||||
const duplicate = duplicateKuldvillakGame(original.id); |
||||
|
||||
expect(duplicate?.state.phase).toBe('lobby'); |
||||
expect(duplicate?.teams[0].score).toBe(0); |
||||
}); |
||||
|
||||
it('should reset revealed questions in duplicate', () => { |
||||
const original = createTestGame(); |
||||
original.rounds[0].categories[0].questions[0].isRevealed = true; |
||||
saveKuldvillakGame(original); |
||||
|
||||
const duplicate = duplicateKuldvillakGame(original.id); |
||||
|
||||
expect(duplicate?.rounds[0].categories[0].questions[0].isRevealed).toBe(false); |
||||
}); |
||||
|
||||
it('should return null for non-existent game', () => { |
||||
const result = duplicateKuldvillakGame('non-existent'); |
||||
expect(result).toBeNull(); |
||||
}); |
||||
}); |
||||
|
||||
describe('getKuldvillakGamesList', () => { |
||||
it('should return empty array when no games', () => { |
||||
const result = getKuldvillakGamesList(); |
||||
expect(result).toEqual([]); |
||||
}); |
||||
|
||||
it('should return metadata for all games', () => { |
||||
saveKuldvillakGame(createTestGame({ id: 'game-1', name: 'Game 1' })); |
||||
saveKuldvillakGame(createTestGame({ id: 'game-2', name: 'Game 2' })); |
||||
|
||||
const list = getKuldvillakGamesList(); |
||||
|
||||
expect(list).toHaveLength(2); |
||||
expect(list[0].teamCount).toBe(2); |
||||
expect(list[0].roundCount).toBe(1); |
||||
}); |
||||
}); |
||||
|
||||
describe('Active Game', () => { |
||||
it('should set and get active game ID', () => { |
||||
setActiveKuldvillakGame('test-id'); |
||||
expect(getActiveKuldvillakGameId()).toBe('test-id'); |
||||
}); |
||||
|
||||
it('should clear active game when set to null', () => { |
||||
setActiveKuldvillakGame('test-id'); |
||||
setActiveKuldvillakGame(null); |
||||
expect(getActiveKuldvillakGameId()).toBeNull(); |
||||
}); |
||||
}); |
||||
}); |
||||
@ -0,0 +1,145 @@ |
||||
import { describe, it, expect } from 'vitest'; |
||||
import { DEFAULT_SETTINGS, DEFAULT_STATE } from './kuldvillak'; |
||||
import type { Team, Round, Question, GameSettings } from './kuldvillak'; |
||||
|
||||
describe('Kuldvillak Types', () => { |
||||
describe('DEFAULT_SETTINGS', () => { |
||||
it('should have correct number of rounds', () => { |
||||
expect(DEFAULT_SETTINGS.numberOfRounds).toBe(2); |
||||
}); |
||||
|
||||
it('should have 5 point values', () => { |
||||
expect(DEFAULT_SETTINGS.pointValues).toHaveLength(5); |
||||
expect(DEFAULT_SETTINGS.pointValues).toEqual([10, 20, 30, 40, 50]); |
||||
}); |
||||
|
||||
it('should have 6 categories per round', () => { |
||||
expect(DEFAULT_SETTINGS.categoriesPerRound).toBe(6); |
||||
}); |
||||
|
||||
it('should have 5 questions per category', () => { |
||||
expect(DEFAULT_SETTINGS.questionsPerCategory).toBe(5); |
||||
}); |
||||
|
||||
it('should have daily doubles configuration', () => { |
||||
expect(DEFAULT_SETTINGS.dailyDoublesPerRound).toEqual([1, 2]); |
||||
}); |
||||
|
||||
it('should enable final round by default', () => { |
||||
expect(DEFAULT_SETTINGS.enableFinalRound).toBe(true); |
||||
}); |
||||
|
||||
it('should allow negative scores by default', () => { |
||||
expect(DEFAULT_SETTINGS.allowNegativeScores).toBe(true); |
||||
}); |
||||
|
||||
it('should have 6 max teams', () => { |
||||
expect(DEFAULT_SETTINGS.maxTeams).toBe(6); |
||||
}); |
||||
|
||||
it('should have default timer of 5 seconds', () => { |
||||
expect(DEFAULT_SETTINGS.defaultTimerSeconds).toBe(5); |
||||
}); |
||||
|
||||
it('should have answer reveal of 5 seconds', () => { |
||||
expect(DEFAULT_SETTINGS.answerRevealSeconds).toBe(5); |
||||
}); |
||||
}); |
||||
|
||||
describe('DEFAULT_STATE', () => { |
||||
it('should start in lobby phase', () => { |
||||
expect(DEFAULT_STATE.phase).toBe('lobby'); |
||||
}); |
||||
|
||||
it('should start at round index 0', () => { |
||||
expect(DEFAULT_STATE.currentRoundIndex).toBe(0); |
||||
}); |
||||
|
||||
it('should have no active team', () => { |
||||
expect(DEFAULT_STATE.activeTeamId).toBeNull(); |
||||
}); |
||||
|
||||
it('should have empty final round data', () => { |
||||
expect(DEFAULT_STATE.finalWagers).toEqual({}); |
||||
expect(DEFAULT_STATE.finalAnswers).toEqual({}); |
||||
}); |
||||
}); |
||||
|
||||
describe('Type Validation Helpers', () => { |
||||
it('should validate team structure', () => { |
||||
const validTeam: Team = { |
||||
id: 'team-1', |
||||
name: 'Test Team', |
||||
score: 100, |
||||
}; |
||||
|
||||
expect(validTeam.id).toBeDefined(); |
||||
expect(validTeam.name).toBeDefined(); |
||||
expect(typeof validTeam.score).toBe('number'); |
||||
}); |
||||
|
||||
it('should validate question structure', () => { |
||||
const validQuestion: Question = { |
||||
id: 'q-1', |
||||
question: 'What is 2+2?', |
||||
answer: 'What is 4?', |
||||
points: 100, |
||||
isDailyDouble: false, |
||||
isRevealed: false, |
||||
}; |
||||
|
||||
expect(validQuestion.id).toBeDefined(); |
||||
expect(validQuestion.question).toBeDefined(); |
||||
expect(validQuestion.answer).toBeDefined(); |
||||
expect(typeof validQuestion.points).toBe('number'); |
||||
expect(typeof validQuestion.isDailyDouble).toBe('boolean'); |
||||
expect(typeof validQuestion.isRevealed).toBe('boolean'); |
||||
}); |
||||
|
||||
it('should validate optional imageUrl in question', () => { |
||||
const questionWithImage: Question = { |
||||
id: 'q-1', |
||||
question: 'What is shown?', |
||||
answer: 'What is a cat?', |
||||
points: 200, |
||||
isDailyDouble: false, |
||||
isRevealed: false, |
||||
imageUrl: 'https://example.com/image.jpg', |
||||
}; |
||||
|
||||
expect(questionWithImage.imageUrl).toBeDefined(); |
||||
}); |
||||
|
||||
it('should calculate point values correctly', () => { |
||||
const baseValue = 10; |
||||
const multiplier = 2; |
||||
const expectedValues = [20, 40, 60, 80, 100]; |
||||
|
||||
const calculatedValues = DEFAULT_SETTINGS.pointValues.map( |
||||
(v) => v * multiplier |
||||
); |
||||
|
||||
expect(calculatedValues).toEqual(expectedValues); |
||||
}); |
||||
|
||||
it('should validate game phases', () => { |
||||
const validPhases = [ |
||||
'lobby', |
||||
'intro', |
||||
'intro-categories', |
||||
'board', |
||||
'question', |
||||
'daily-double', |
||||
'final-intro', |
||||
'final-category', |
||||
'final-wager', |
||||
'final-question', |
||||
'final-reveal', |
||||
'final-scores', |
||||
'finished', |
||||
]; |
||||
|
||||
expect(validPhases).toContain(DEFAULT_STATE.phase); |
||||
}); |
||||
}); |
||||
}); |
||||
|
After Width: | Height: | Size: 355 B |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 223 KiB |
|
After Width: | Height: | Size: 223 KiB |
|
After Width: | Height: | Size: 223 KiB |
|
After Width: | Height: | Size: 223 KiB |
|
After Width: | Height: | Size: 196 KiB |
|
After Width: | Height: | Size: 253 KiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 233 KiB |
|
After Width: | Height: | Size: 233 KiB |
|
After Width: | Height: | Size: 233 KiB |
|
After Width: | Height: | Size: 233 KiB |
|
After Width: | Height: | Size: 201 KiB |
|
After Width: | Height: | Size: 253 KiB |
@ -0,0 +1,13 @@ |
||||
import { defineConfig } from 'vitest/config'; |
||||
|
||||
export default defineConfig({ |
||||
test: { |
||||
include: ['src/**/*.{test,spec}.{js,ts}'], |
||||
environment: 'jsdom', |
||||
globals: true, |
||||
alias: { |
||||
'$lib': '/src/lib', |
||||
'$app': '/src/app', |
||||
}, |
||||
}, |
||||
}); |
||||