feat: first MVP

This commit is contained in:
2025-03-13 10:26:23 +01:00
parent 2b1b46b67a
commit c37a570836
14 changed files with 419 additions and 13 deletions

79
src/lib/Board.svelte Normal file
View File

@@ -0,0 +1,79 @@
<script lang="ts">
import JSConfetti from 'js-confetti';
import Cell from './Cell.svelte';
import ValueSelector from './ValueSelector.svelte';
import { Board, Difficulty } from './core/board';
import { onMount } from 'svelte';
import GameCommands from './GameCommands.svelte';
import SumIndicator from './SumIndicator.svelte';
let selectedI: number | undefined = undefined;
let selectedJ: number | undefined = undefined;
let status: 'incomplete' | 'error' | 'won' = 'incomplete';
const onSelect = (i: number, j: number) => () => {
if (i === selectedI && j === selectedJ) {
selectedI = undefined;
selectedJ = undefined;
} else {
selectedI = i;
selectedJ = j;
}
};
const onSetValue = (number: number) => {
if (selectedI !== undefined && selectedJ !== undefined) {
board.grid[selectedI][selectedJ] = number;
if (board.isComplete()) {
status = board.isValid() ? 'won' : 'error';
if (status === 'won') {
jsConfetti.addConfetti();
selectedI = undefined;
selectedJ = undefined;
}
}
}
};
const onGameCommand = (mode: string) => {
if (mode === 'reset') {
board.reset();
} else {
board = new Board();
if (mode === 'easy') board.prepare(Difficulty.Easy);
else if (mode === 'medium') board.prepare(Difficulty.Medium);
else if (mode === 'hard') board.prepare(Difficulty.Hard);
}
};
let board: Board;
let jsConfetti: JSConfetti;
onGameCommand('easy');
onMount(() => {
jsConfetti = new JSConfetti();
});
</script>
<GameCommands onClick={onGameCommand} />
<p class="py-4 text-center">STATUS: {status}</p>
<div class="mx-auto grid max-w-xl grid-cols-[1fr_1fr_1fr_auto] gap-4">
{#each board.grid as row, i (row)}
{#each row as cell, j}
<Cell
value={cell}
onclick={onSelect(i, j)}
selected={selectedI === i && selectedJ === j}
disabled={!board.lockedCells[i][j]}
/>
{/each}
<SumIndicator value={board.horizontalSum[i]} />
{/each}
{#each board.verticalSum as s}
<SumIndicator value={s} />
{/each}
</div>
<ValueSelector onclick={onSetValue} used={board.getPlacedNumbers()} />

14
src/lib/Cell.svelte Normal file
View File

@@ -0,0 +1,14 @@
<script lang="ts">
let { value, selected, disabled, onclick } = $props();
</script>
<button
class="aspect-square rounded-4xl border"
class:bg-amber-600={selected}
class:bg-gray-200={disabled}
class:cursor-pointer={!disabled}
{onclick}
{disabled}
>
{value || '-'}
</button>

View File

@@ -0,0 +1,22 @@
<script lang="ts">
const { onClick } = $props();
</script>
<div class="flex justify-center space-x-2">
<button
class="cursor-pointer rounded-full bg-cyan-500 px-4 py-2 text-sm text-white"
onclick={() => onClick('reset')}>Reset</button
>
<button
class="cursor-pointer rounded-full bg-cyan-500 px-4 py-2 text-sm text-white"
onclick={() => onClick('easy')}>Easy</button
>
<button
class="cursor-pointer rounded-full bg-cyan-500 px-4 py-2 text-sm text-white"
onclick={() => onClick('medium')}>Medium</button
>
<button
class="cursor-pointer rounded-full bg-cyan-500 px-4 py-2 text-sm text-white"
onclick={() => onClick('hard')}>Hard</button
>
</div>

View File

@@ -0,0 +1,9 @@
<script lang="ts">
const { value } = $props();
</script>
<div class="flex content-center items-center justify-center">
<div class="flex h-9 w-9 items-center justify-center rounded-full border border-sky-400">
{value}
</div>
</div>

View File

@@ -0,0 +1,23 @@
<script lang="ts">
const { onclick, used } = $props();
const numbers = Array.from({ length: 9 }, (_, i) => i + 1);
</script>
<div class="flex flex-wrap justify-center space-x-2 py-8">
{#each numbers as number (number)}
<button
class="mr-1 cursor-pointer rounded-full bg-emerald-300 px-4 py-2 disabled:bg-gray-200"
onclick={() => onclick(number)}
disabled={used.includes(number)}
>
{number}
</button>
{/each}
<button
class="mr-1 cursor-pointer rounded-full bg-emerald-300 px-4 py-2 disabled:bg-gray-200"
onclick={() => onclick()}
>
sup
</button>
</div>

View File

@@ -0,0 +1,58 @@
import '@testing-library/jest-dom/vitest';
import { describe, expect, test } from 'vitest';
import { Board, Difficulty } from './board';
describe('Board', () => {
test('shape', () => {
const board = new Board();
expect(board.grid.length).toBe(3);
expect(board.grid[0].length).toBe(3);
expect(board.horizontalSum.length).toBe(3);
expect(board.verticalSum.length).toBe(3);
});
test('isComplete', () => {
const board = new Board();
expect(board.isComplete()).toBe(true);
// remove one number
board.setByPos(3);
expect(board.isComplete()).toBe(false);
});
test('isValid', () => {
const board = new Board();
expect(board.isValid()).toBe(true);
});
test('isValid ko', () => {
const board = new Board();
board.horizontalSum[1] = -1;
expect(board.isValid()).toBe(false);
});
test('setByPos', () => {
const board = new Board();
// set board from 9 to 1
for (let index = 0; index < 9; index++) {
board.setByPos(index, 9 - index);
}
expect(board.grid[0][0]).toBe(9);
expect(board.grid[0][2]).toBe(7);
expect(board.grid[1][0]).toBe(6);
expect(board.grid[1][1]).toBe(5);
expect(board.grid[2][2]).toBe(1);
});
test('prepare', () => {
const board = new Board();
board.prepare(Difficulty.Easy);
let emptyCellNb = 0;
for (const cell of board) {
if (cell === undefined) emptyCellNb++;
}
expect(emptyCellNb).toBe(9 - 3);
});
});

130
src/lib/core/board.ts Normal file
View File

@@ -0,0 +1,130 @@
export enum Difficulty {
Easy,
Medium,
Hard,
Last,
Done
}
export type Case = number | undefined;
function shuffleArray<T>(array: Array<T>): void {
for (let i = array.length - 1; i >= 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[array[i], array[j]] = [array[j], array[i]];
}
}
function getNumberToKeepFromDifficulty(difficulty: Difficulty) {
const rules = {
// 0: 8,
0: 3,
1: 2,
2: 1,
3: 8,
4: 9
};
return rules[difficulty];
}
export class Board {
grid: Case[][];
verticalSum: number[];
horizontalSum: number[];
lockedCells: boolean[][];
constructor() {
const numbers = Array.from({ length: 9 }, (_, i) => i + 1);
shuffleArray(numbers);
this.grid = [];
this.verticalSum = new Array(3).fill(0);
this.horizontalSum = [];
this.lockedCells = [];
for (let i = 0; i < 3; i++) {
const row: number[] = [];
for (let j = 0; j < 3; j++) {
row[j] = numbers.pop() || 0;
this.verticalSum[j] += row[j];
}
this.grid.push(row);
this.horizontalSum[i] = row.reduce((partialSum, a) => partialSum + a, 0);
this.lockedCells[i] = [false, false, false];
}
}
[Symbol.iterator]() {
let index = 0;
return {
next: () => {
if (index < 9) {
const [row, col] = this.positionToRowCol(index++);
return { value: this.grid[row][col], done: false };
} else {
return { done: true };
}
}
};
}
isComplete(): boolean {
for (const cell of this) {
if (cell === undefined) return false;
}
return true;
}
isValid(): boolean {
for (let i = 0; i < 3; i++) {
const rowSum = this.grid[i].reduce((partialSum, a) => (partialSum || 0) + (a || 0), 0);
const colSum = (this.grid[0][i] || 0) + (this.grid[1][i] || 0) + (this.grid[2][i] || 0);
if (rowSum !== this.horizontalSum[i] || colSum !== this.verticalSum[i]) return false;
}
return true;
}
prepare(difficulty: Difficulty) {
const numbers = Array.from({ length: 9 }, (_, i) => i);
shuffleArray(numbers);
const numberToKeep = getNumberToKeepFromDifficulty(difficulty);
const posToHide = numbers.slice(numberToKeep, 9);
posToHide.forEach((pos) => {
this.setByPos(pos);
// lock visible cells
const [row, col] = this.positionToRowCol(pos);
this.lockedCells[row][col] = true;
});
}
reset() {
console.log('ici');
for (let i = 0; i < 3; i++) {
for (let j = 0; j < 3; j++) {
if (this.lockedCells[i][j]) this.grid[i][j] = undefined;
}
}
}
setByPos(pos: number, value: Case = undefined) {
if (pos < 0 || pos > 8) throw new Error('position must be between 0 and 8!');
if (value && (value < 1 || value > 9)) throw new Error('value must be between 1 and 9!');
const [row, col] = this.positionToRowCol(pos);
this.grid[row][col] = value;
}
positionToRowCol(position: number) {
return [Math.floor(position / 3), position % 3];
}
getPlacedNumbers() {
const res = [];
for (const pos of this) {
if (pos !== undefined) res.push(pos);
}
return res;
}
}

View File

@@ -1 +0,0 @@
// place files you want to import through the `$lib` alias in this folder.

View File

@@ -1,2 +1,7 @@
<h1>Welcome to SvelteKit</h1>
<p>Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation</p>
<script>
import Board from '$lib/Board.svelte';
</script>
<h1 class="mb-5 text-center text-6xl font-medium">Fubuki</h1>
<Board />