Compare commits

...

No commits in common. "master" and "sveltekit" have entirely different histories.

39 changed files with 8763 additions and 304 deletions

13
.eslintignore Normal file
View File

@ -0,0 +1,13 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
# Ignore files for PNPM, NPM and YARN
pnpm-lock.yaml
package-lock.json
yarn.lock

30
.eslintrc.cjs Normal file
View File

@ -0,0 +1,30 @@
module.exports = {
root: true,
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:svelte/recommended',
'prettier'
],
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint'],
parserOptions: {
sourceType: 'module',
ecmaVersion: 2020,
extraFileExtensions: ['.svelte']
},
env: {
browser: true,
es2017: true,
node: true
},
overrides: [
{
files: ['*.svelte'],
parser: 'svelte-eslint-parser',
parserOptions: {
parser: '@typescript-eslint/parser'
}
}
]
};

13
.gitignore vendored Normal file
View File

@ -0,0 +1,13 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
.idea
.vscode

2
.npmrc Normal file
View File

@ -0,0 +1,2 @@
engine-strict=true
resolution-mode=highest

13
.prettierignore Normal file
View File

@ -0,0 +1,13 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
# Ignore files for PNPM, NPM and YARN
pnpm-lock.yaml
package-lock.json
yarn.lock

9
.prettierrc Normal file
View File

@ -0,0 +1,9 @@
{
"useTabs": true,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte"],
"pluginSearchDirs": ["."],
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
}

BIN
BLANK.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

BIN
FLAG.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

BIN
MINE.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

38
README.md Normal file
View File

@ -0,0 +1,38 @@
# create-svelte
Everything you need to build a Svelte project, powered by [`create-svelte`](https://github.com/sveltejs/kit/tree/master/packages/create-svelte).
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```bash
# create a new project in the current directory
npm create svelte@latest
# create a new project in my-app
npm create svelte@latest my-app
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```bash
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:
```bash
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://kit.svelte.dev/docs/adapters) for your target environment.

267
client.js
View File

@ -1,267 +0,0 @@
let totalMines = 15;
let gridWidth = 10;
let gridHeight = 10;
let grid, hasLost = false, hasWon = false;
function resetGame() {
totalMines = parseInt(document.getElementById("tMines").value);
gridWidth = parseInt(document.getElementById("gWidth").value);
gridHeight = parseInt(document.getElementById("gHeight").value);
hasWon = false;
hasLost = false;
grid = startGame();
}
function revealRest() {
while (!hasWon) {
let hasClickedOnce = false;
for (let i = 0; i < gridHeight; i++) {
for (let j = 0; j < gridWidth; j++) {
if (!grid[i][j].isRevealed) {
let current = grid[i][j].element;
if (!grid[i][j].isFlagged && grid[i][j].isMine) {
rightClickFunc(current);
hasClickedOnce = true;
break;
} else if (!grid[i][j].isMine) {
reveal(current);
hasClickedOnce = true;
break;
}
}
}
if (hasClickedOnce) {
break;
}
}
checkWin();
}
}
function checkWin() {
for (let i = 0; i < gridHeight; i++) {
for (let j = 0; j < gridWidth; j++) {
if (!grid[i][j].isRevealed && !grid[i][j].isFlagged) {
return;
}
}
}
setTimeout(function(){
alert("Du hast gewonnen!!!");
}, 500);
hasWon = true;
}
function endGame() {
for (let i = 0; i < gridHeight; i++) {
for (let j = 0; j < gridWidth; j++) {
if (!grid[i][j].isFlagged) {
grid[i][j].isRevealed = true;
if (grid[i][j].isMine) {
grid[i][j].element.src = "MINE.png";
}
}
}
}
setTimeout(function(){
alert("Du hast verloren!!!");
}, 500);
hasLost = true;
}
function reveal(eventTarget) {
let et = eventTarget.parentElement;
if (et && et.value) {
let coords = et.value.split(' ');
let clickY = parseInt(coords[0]);
let clickX = parseInt(coords[1]);
if (!grid[clickY][clickX].isFlagged) {
let number, pDiv = document.createElement("div");
let image = document.createElement("img");
grid[clickY][clickX].isRevealed = true;
if (grid[clickY][clickX].isMine) {
image.src = "MINE.png";
endGame();
} else {
image.src = "BLANK.png";
let toBeRevealed = Array(), bombsNearby = 0;
for (let i = -1; i < 2; i++) {
for (let j = -1; j < 2; j++) {
if (!(i == 0 && j == 0)) {
const nY = clickY + i;
const nX = clickX + j;
if ((nX >= 0 && nX < gridWidth) && (nY >= 0 && nY < gridHeight)) {
let nE = grid[nY][nX];
if (!nE.isMine) {
if (!nE.isRevealed) {
toBeRevealed.push(nE.element);
}
} else {
bombsNearby++;
grid[clickY][clickX].isNumber = true;
}
}
}
}
}
if (bombsNearby == 0) {
toBeRevealed.forEach(e=>{
reveal(e);
});
} else {
number = document.createElement("a");
number.innerText = bombsNearby;
}
pDiv.appendChild(image);
if (number) {
pDiv.appendChild(number);
}
pDiv.ondblclick = doubleClickFunc;
et.replaceChildren(pDiv);
grid[clickY][clickX].element = image;
}
if (!hasLost) {
checkWin();
}
}
}
}
function doubleClickFunc(event) {
if (!hasLost) {
let et = event.target.parentElement.parentElement;
if (et && et.value) {
let coords = et.value.split(' ');
let clickY = parseInt(coords[0]);
let clickX = parseInt(coords[1]);
if (grid[clickY][clickX].isNumber) {
let toBeRevealed = Array();
for (let i = -1; i < 2; i++) {
for (let j = -1; j < 2; j++) {
if (!(i == 0 && j == 0)) {
const nY = clickY + i;
const nX = clickX + j;
if ((nX >= 0 && nX < gridWidth) && (nY >= 0 && nY < gridHeight)) {
let nE = grid[nY][nX];
if (nE.isMine && !nE.isFlagged) {
endGame();
break;
} else {
if (!nE.isRevealed) {
toBeRevealed.push(nE.element);
}
}
}
}
}
if (hasLost) {
break;
}
}
if (!hasLost) {
if (!checkWin()) {
toBeRevealed.forEach(e=>{
reveal(e);
});
}
}
}
}
}
}
function setFlag(eventTarget) {
let et = eventTarget.parentElement;
if (et && et.value) {
let coords = et.value.split(' ');
let clickY = parseInt(coords[0]);
let clickX = parseInt(coords[1]);
if (!grid[clickY][clickX].isRevealed) {
let image = grid[clickY][clickX].element;
if (!grid[clickY][clickX].isFlagged) {
image.src = "FLAG.png";
et.replaceChildren(image);
} else {
image.src = "PLATZHALTER.png";
et.replaceChildren(image);
}
grid[clickY][clickX].isFlagged = !grid[clickY][clickX].isFlagged;
}
}
}
function rightClickFunc(event) {
if (event.target) {
event.preventDefault();
if (!hasLost) {
setFlag(event.target);
}
} else {
setFlag(event);
}
}
function onClickFunc(event) {
if (!hasLost) {
reveal(event.target);
}
}
function startGame() {
let g = Array();
for (let i = 0; i < gridHeight; i++) {
g.push(Array(gridWidth));
}
let t = document.getElementById("Game");
t.innerHTML = "";
for (let i = 0; i < gridHeight; i++) {
let r = document.createElement("tr");
for (let j = 0; j < gridWidth; j++) {
let d = document.createElement("td");
let image = document.createElement("img");
image.src = "PLATZHALTER.png";
image.onclick = onClickFunc;
image.oncontextmenu = rightClickFunc;
d.appendChild(image);
d.value = i + " " + j;
r.appendChild(d);
g[i][j] = new Block(image);
}
t.appendChild(r);
}
let mC = 0;
while (mC < totalMines) {
let mX = Math.floor(Math.random()*gridWidth);
let mY = Math.floor(Math.random()*gridHeight);
if (!g[mY][mX].isMine) {
g[mY][mX].isMine=true;
mC++;
}
}
return g;
}
function init() {
grid = startGame();
document.getElementById("gWidth").oninput = function() {
let gHValue = document.getElementById("gHeight").value;
let value = Math.floor(parseInt(this.value) * parseInt(gHValue) * 0.1);
document.getElementById("tMines").max = value;
};
document.getElementById("gHeight").oninput = function() {
let gWValue = document.getElementById("gWidth").value;
let value = Math.floor(parseInt(this.value) * parseInt(gWValue) * 0.1) ;
document.getElementById("tMines").max = value+"";
};
}
class Block {
constructor(e_) {
this.element = e_;
this.isMine = false;
this.isRevealed = false;
this.isFlagged = false;
this.isNumber = false;
}
}

BIN
data/sessions.db Normal file

Binary file not shown.

View File

@ -1,37 +0,0 @@
<html>
<head>
<meta charset="UTF-8">
<script src="client.js"></script>
<style>
body {
font-family: arial;
}
div {
display: grid;
}
div > * {
grid-column-start: 1;
grid-row-start: 1;
}
img {
width: 50px;
height: 50px;
z-index: 0;
}
a {
z-index: 1;
margin: auto;
padding: auto;
font-size: 24px;
}
</style>
</head>
<body onload="init();">
<table id="Game"></table>
Minen: <input id="tMines" type="number" value="15" min="1" max="100"><br>
Breite: <input id="gWidth" type="number" value="10" min="5" max="100"><br>
Höhe: <input id="gHeight" type="number" value="10" min="5" max="100"><br>
<button onclick="resetGame();">Reset Game</button>
</body>
</html>

7692
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

45
package.json Normal file
View File

@ -0,0 +1,45 @@
{
"name": "minesweeper",
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "vite dev --host",
"build": "vite build",
"preview": "vite preview",
"test": "npm run test:integration && npm run test:unit",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --plugin-search-dir . --check . && eslint .",
"format": "prettier --plugin-search-dir . --write .",
"test:integration": "playwright test",
"test:unit": "vitest"
},
"devDependencies": {
"@playwright/test": "^1.28.1",
"@sveltejs/adapter-auto": "^2.0.0",
"@sveltejs/kit": "^1.20.4",
"@types/better-sqlite3": "^7.6.4",
"@types/uuid": "^9.0.2",
"@typescript-eslint/eslint-plugin": "^5.45.0",
"@typescript-eslint/parser": "^5.45.0",
"autoprefixer": "^10.4.14",
"eslint": "^8.28.0",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-svelte": "^2.30.0",
"postcss": "^8.4.27",
"prettier": "^2.8.0",
"prettier-plugin-svelte": "^2.10.1",
"svelte": "^4.0.5",
"svelte-check": "^3.4.3",
"tailwindcss": "^3.3.3",
"tslib": "^2.4.1",
"typescript": "^5.0.0",
"vite": "^4.4.2",
"vitest": "^0.32.2"
},
"type": "module",
"dependencies": {
"better-sqlite3": "^8.5.0",
"uuid": "^9.0.0"
}
}

12
playwright.config.ts Normal file
View File

@ -0,0 +1,12 @@
import type { PlaywrightTestConfig } from '@playwright/test';
const config: PlaywrightTestConfig = {
webServer: {
command: 'npm run build && npm run preview',
port: 4173
},
testDir: 'tests',
testMatch: /(.+\.)?(test|spec)\.[jt]s/
};
export default config;

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};

3
src/app.css Normal file
View File

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

12
src/app.d.ts vendored Normal file
View File

@ -0,0 +1,12 @@
// See https://kit.svelte.dev/docs/types#app
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface Platform {}
}
}
export {};

12
src/app.html Normal file
View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

27
src/global.d.ts vendored Normal file
View File

@ -0,0 +1,27 @@
// Interface to keep track of session data
interface Session {
id: string;
matrix?: boolean[][];
}
// Interface to store raw session in
interface RawSession {
id: string;
data: string;
}
// Interface to keep track of an individual cell
interface Cell {
x: number;
y: number;
mine: boolean;
neighbors: number;
}
// Interface to mimic a raw response object from http-requests
interface RawResponse {
success: boolean;
message: string;
cells?: cell[];
done?: boolean;
}

17
src/hooks.server.ts Normal file
View File

@ -0,0 +1,17 @@
import type { Handle } from '@sveltejs/kit';
import { createSession, getSession } from '$lib/server/database';
// Hook to check if session is being kept track of
export const handle = (async ({ event, resolve }) => {
const session_id: string | undefined = event.cookies.get('session_id');
let session =
session_id !== undefined ? (getSession(session_id) as Session | undefined) : undefined;
// If not, create new one and save in cookie
if (session === undefined) {
session = createSession();
event.cookies.set('session_id', session.id);
}
// Resolve actual page (get-request)
return await resolve(event);
}) satisfies Handle;

7
src/index.test.ts Normal file
View File

@ -0,0 +1,7 @@
import { describe, it, expect } from 'vitest';
describe('sum test', () => {
it('adds 1 + 2 to equal 3', () => {
expect(1 + 2).toBe(3);
});
});

32
src/lib/client/message.ts Normal file
View File

@ -0,0 +1,32 @@
// Display message on a small window with provided background color
// Color format: css-style; color name or rgb(x, x, x)
export function displayMessage(
elements: HTMLElement[],
message: string,
bgColor: string,
txtColor: string,
optFunc?: Function
) {
elements[0].classList.replace('hidden', 'flex');
elements[1].classList.replace('hidden', 'flex');
elements[2].style.backgroundColor = bgColor;
elements[2].style.color = txtColor;
elements[2].textContent = message;
elements[1].onclick = () => {
elements[0].classList.replace('flex', 'hidden');
elements[1].classList.replace('flex', 'hidden');
if (optFunc !== undefined && optFunc !== null) optFunc();
};
};
export function getElements(): HTMLElement[] {
const elements: (HTMLElement|null)[] = [];
elements[0] = document.getElementById('EMSG-background');
elements[1] = document.getElementById('EMSG-container');
elements[2] = document.getElementById('EMSG-text');
const filtered = elements.filter(e => e !== null) as HTMLElement[];
if (filtered.length !== 3) throw new Error("Could not find the desired Event-Message elements.");
return filtered;
}

View File

@ -0,0 +1,45 @@
import Database from 'better-sqlite3';
import { v4 as uuidv4 } from 'uuid';
// Connect to DB and create table if necessary
export const db = new Database('data/sessions.db');
db.exec('CREATE TABLE IF NOT EXISTS sessions (id TEXT PRIMARY KEY NOT NULL, data TEXT)');
// Create session in DB and return it
export function createSession(): Session {
const sessionID = uuidv4();
const session: Session = { id: sessionID };
db.prepare('INSERT INTO sessions (id, data) VALUES (?, ?)').run(sessionID, null);
return session;
}
// Check if session exists in DB
export function checkSession(session: Session): boolean {
const id: string = session.id;
const result: Session | undefined = getSession(id);
return result !== undefined;
}
// Parse raw session to real session
export function parseSession(session: RawSession): Session {
return { id: session.id, matrix: JSON.parse(session.data) } as Session;
}
// Get session from DB by id
export function getSession(id: string): Session | undefined {
const raw = db.prepare('SELECT id, data FROM sessions WHERE id = ?').get(id) as
| RawSession
| undefined;
if (raw === undefined) return;
return parseSession(raw);
}
// Save session into DB
export function saveSession(session: Session) {
if (session.matrix === null || session.matrix === undefined) return;
db.prepare('UPDATE sessions SET data = ? WHERE id = ?').run(
JSON.stringify(session.matrix),
session.id
);
}

View File

@ -0,0 +1,48 @@
// Generate new boolean matrix
export function generateMatrix(width: number, height: number): boolean[][] {
const grid: boolean[][] = [];
// Add rows to matrix
for (let j = 0; j < height; j++) {
// Create row, add cells
const next: boolean[] = [];
for (let i = 0; i < width; i++) next.push(false);
// Push row onto matrix
grid.push(next);
}
// Generate exactly 10% as many mines as there are cells
let count = 0;
const max: number = width * height * 0.1;
while (count < max) {
// Try setting random position to mine
const x: number = Math.floor(Math.random() * width);
const y: number = Math.floor(Math.random() * height);
if (grid[y][x]) continue;
grid[y][x] = true;
// Keep track of mine count
count++;
}
return grid;
}
// Count mines adjacent to given cell
export function countAdjacent(cell: Cell, matrix: boolean[][]): number {
let count = 0;
for (let dy = -1; dy < 2; dy++)
for (let dx = -1; dx < 2; dx++) {
// Ignore self
if (dx === 0 && dy === 0) continue;
const nx = cell.x + dx,
ny = cell.y + dy;
// Ignore out of bounds
if (ny < 0 || ny >= matrix.length) continue;
if (nx < 0 || nx >= matrix[ny].length) continue;
// Add mine to count
if (matrix[ny][nx]) count++;
}
return count;
}

View File

@ -0,0 +1,369 @@
<script lang="ts">
import { displayMessage, getElements } from '$lib/client/message';
import { onMount } from 'svelte';
// Keep track of dimensions and sizes
let width = 10,
height = 10;
let cellWidth: number,
cellHeight: number,
fontSize = 10;
// Function to calculate font size based on cell dimensions
function calculateFontSize() {
const cell = document.querySelector('td') as HTMLTableCellElement | null;
if (cell === null) return;
fontSize = cell.offsetHeight / 2;
const cOW = cell.offsetWidth / 2;
// Choose whichever is smaller
if (cOW < fontSize) fontSize = cOW;
console.log(`Font size: ${fontSize}px.`);
}
// Keep track of indecies in matrix and cells that have been revealed
let changed: HTMLTableCellElement[] = [];
let matrix: number[][] = [];
// Function to update matrix due to resize or reset
function updateMatrix(generate: boolean) {
// Save matrix dimensions
window.localStorage.setItem('width', width.toString());
window.localStorage.setItem('height', height.toString());
// Calculate percentual cell dimensions
cellWidth = 100 / width;
cellHeight = 100 / height;
// Try calculating font-size
calculateFontSize();
// Prepare URL search params for new dimensions
if (generate) {
const params: URLSearchParams = new URLSearchParams();
params.set('w', width.toString());
params.set('h', height.toString());
// Send get-request to set new matrix
fetch('/generate?' + params.toString())
// Parse response to JSON
.then((response: Response) => response.json())
// Error handling in case response is negative
.then((response: RawResponse) => {
const { success, message } = response;
if (!success) throw new Error(`Couldn't generate new matrix!\n${message}`);
console.log(message);
});
}
// Reset matrix
matrix = [];
for (let j = 0; j < height; j++) {
// Create row, add cells
let row: number[] = [];
for (let i = 0; i < width; i++) row.push(i + j * width);
// Push row onto matrix
matrix.push(row);
}
// If cells were changed, reset them and empty array
if (changed.length === 0) return;
changed.forEach((cell) => {
if (!cell.classList.contains('cursor-pointer'))
cell.classList.add('cursor-pointer');
cell.removeAttribute('data-clicked');
cell.removeAttribute('data-marked');
cell.style.backgroundColor = '';
cell.textContent = '';
});
changed = [];
}
// Get last matrix dimensions
function getDimensions(): boolean {
const lw: string | null = window.localStorage.getItem('width');
const lh: string | null = window.localStorage.getItem('height');
if (lw === null || lh === null) return false;
// Try parsing the dimensions
width = parseInt(lw);
height = parseInt(lh);
return true;
}
// Check if matrix was generated yet
async function requestMatrix(): Promise<boolean> {
const response: Response = await fetch('request');
if (response.status !== 200) return false;
const value: boolean = await response.json();
return value;
}
// Try not to 'eagerly' call http-requests
onMount(async () => {
const lcCheck: boolean = await getDimensions();
const dbCheck: boolean = await requestMatrix();
// Update matrix with dimensions
await updateMatrix(!(lcCheck && dbCheck));
// Artificial delay to calculate font size after render
// (i think that's the problem at least :^P)
await calculateFontSize();
});
// Display the reaveling of this cell
function displayChange(cell: HTMLTableCellElement, color: string, count: number) {
if (cell.dataset.marked === 'true') return;
if (count !== 0) cell.textContent = count.toString();
cell.classList.remove('cursor-pointer');
cell.style.backgroundColor = color;
cell.dataset.clicked = 'true';
changed.push(cell);
}
// Calculate coordinates from index
function toCoords(index: number) {
return {
x: index % width,
y: Math.floor(index / width)
} as { x: number; y: number };
}
// Call reaveal function on server, display reveal on client
function lmbclick(event: MouseEvent) {
// Get cell from event
const cell = event.target as HTMLTableCellElement;
// Try reaveling the element
reveal(cell);
}
function NodeListToArray(list: NodeListOf<Element>): Element[] {
return Array.prototype.slice.call(list);
}
async function checkWinCondition() {
// Get all HTML Table Cell Elements
const cells = document.querySelectorAll('td') as NodeListOf<HTMLTableCellElement>;
const array = NodeListToArray(cells) as HTMLTableCellElement[];
// Convert all HTML Table Cell Elements to Cells
const mines = array.filter(e => e.dataset.marked).map(e => {
// Get id from cell
if (e.dataset.id === undefined) return null;
const index: number = parseInt(e.dataset.id);
// Calculate coordinates from index
const { x, y } = toCoords(index);
return { x, y } as Cell;
}).filter(e => e !== null) as Cell[];
// Prepare URL serach param for cell array
const params: URLSearchParams = new URLSearchParams();
params.set('mines', JSON.stringify(mines));
// Send get-request to verify marked as mines
const response: Response = await fetch('/checkMarked?' + params.toString());
// Parse response to JSON
const data: RawResponse = await response.json();
// Get status for error handling and cells in case it works
const { success, message, done } = data;
if (done === undefined || !success)
throw new Error(`Couldn't verify marked cells!\n${message}`);
console.log(message);
// If all marked cells have been verified as mines
if (done) {
// Display an event message, reset matrix on click
const elements = getElements();
const onClickFunc = () => { updateMatrix(false); };
displayMessage(elements, 'You won!', 'white', 'black', onClickFunc);
}
}
async function reveal(cell: HTMLTableCellElement) {
if (cell.dataset.clicked) return;
// Get id from cell
if (cell.dataset.id === undefined) return;
const index: number = parseInt(cell.dataset.id);
// Calculate coordinates from index
const { x, y } = toCoords(index);
// Prepare URL serach params for coordinates
const params: URLSearchParams = new URLSearchParams();
params.set('x', x.toString());
params.set('y', y.toString());
// Send get-request to try reveal cell
const response: Response = await fetch('/reveal?' + params.toString());
// Parse response to JSON
const data: RawResponse = await response.json();
// Get status for error handling and cells in case it works
const { success, message, cells } = data;
if (cells === undefined || !success)
throw new Error(`Couldn't reveal next cell!\n${message}`);
console.log(message);
// Hit a mine, display the reveal
if (cells.length === 1 && cells[0].mine) {
displayChange(cell, 'red', 0);
// Display an event message, reset matrix on click
const elements = getElements();
const onClickFunc = () => { updateMatrix(false); };
displayMessage(elements, 'You lost!', 'white', 'black', onClickFunc);
return;
}
// Otherwise, diplay all revealed cells in array
for (const current of cells) {
// Deconstruct cell contents and calculate index
const { x, y, neighbors } = current as Cell,
index = x + y * width;
// Get cell on page by index
const element = document.querySelector(
`td[data-id='${index}']`
) as HTMLTableCellElement | null;
if (element === null) continue;
// Display the reveal
displayChange(element, 'lightgrey', neighbors);
}
// Check whether winning condition is met
checkWinCondition();
}
// Handle reveal logic on right click MouseEvent
function mark(event: MouseEvent) {
// Get cell from event
const cell = event.target as HTMLTableCellElement;
if (cell.dataset.marked) {
console.log('Removed mark from cell.');
// Remove mark from cell
cell.style.backgroundColor = '';
cell.removeAttribute('data-marked');
cell.removeAttribute('data-clicked');
// Remove cell from auto-reset
const index: number = changed.indexOf(cell);
changed.splice(index, 1);
return;
}
if (cell.dataset.clicked) return;
console.log('Marked as mine.');
// Mark cell as mine
cell.style.backgroundColor = 'rgb(50,50,50)';
cell.dataset.clicked = 'true';
cell.dataset.marked = 'true';
// Remember cell for auto-reset
changed.push(cell);
// Check whether winning condition is met
checkWinCondition();
}
// Handle reveal logic on double click MouseEvent
function dblclick(event: MouseEvent) {
// Get cell from event
const cell = event.target as HTMLTableCellElement;
if (!cell.dataset.clicked || cell.dataset.marked) return;
// Get id from cell
if (cell.dataset.id === undefined) return;
const index: number = parseInt(cell.dataset.id);
// Calculate coordinates from index
const { x, y } = toCoords(index);
// Reveal cells around the one that was clicked
console.log('Revealing adjacent cells . . . ');
for (let dy = -1; dy < 2; dy++)
for (let dx = -1; dx < 2; dx++) {
// Ignore self
if (dx === 0 && dy === 0) continue;
const nx = x + dx,
ny = y + dy;
// Ignore out of bounds
if (ny < 0 || ny >= matrix.length) continue;
if (nx < 0 || nx >= matrix[ny].length) continue;
// Get cell on page by index
const element = document.querySelector(
`td[data-id='${nx + ny * width}']`
) as HTMLTableCellElement | null;
if (element === null) continue;
// Try revealing the element
reveal(element);
}
}
</script>
<div class="table-container min-h-screen">
<!-- Matrix for game -->
<table class="table-fixed" style="font-size: {fontSize}px">
{#each matrix as row}
<tr>
{#each row as cell}
<td
data-id={cell}
style="width: {cellWidth}%; height: {cellHeight}%"
class="border border-black bg-gray-400 text-center cell cursor-pointer"
on:click|preventDefault={(event) => lmbclick(event)}
on:dblclick|preventDefault={(event) => dblclick(event)}
on:contextmenu|preventDefault={(event) => mark(event)}
/>
{/each}
</tr>
{/each}
</table>
<div class="p-4 bg-gray-100 flex justify-center items-center">
<!-- Width setting -->
<div class="mr-4">
<label for="width" class="mr-2">Width:</label>
<input
type="number"
id="width"
min="2"
max="50"
bind:value={width}
on:input={() => {
updateMatrix(true);
}}
class="border rounded px-2 py-1 w-20"
/>
</div>
<!-- Height setting -->
<div class="mr-4">
<label for="height" class="mr-2">Height:</label>
<input
type="number"
id="height"
min="2"
max="25"
bind:value={height}
on:input={() => {
updateMatrix(true);
}}
class="border rounded px-2 py-1 w-20"
/>
</div>
<!-- Matrix reset -->
<div>
<input
type="button"
id="reset"
value="Reset Matrix"
on:click={() => {
updateMatrix(true);
}}
class="bg-white hover:bg-gray-200 hover:border-black border rounded px-2 py-1 w-40 cursor-pointer"
/>
</div>
</div>
</div>
<!-- Additional CSS for details -->
<style lang="postcss">
.table-container {
display: flex;
flex-direction: column;
height: 100%;
}
table {
flex-grow: 1;
width: 100%;
}
</style>

View File

@ -0,0 +1,60 @@
import { parse } from 'cookie';
import { getSession, saveSession } from '$lib/server/database';
import { json, type RequestHandler } from '@sveltejs/kit';
import { generateMatrix } from '$lib/server/functions';
// http-get-request to handle generating a new matrix
export const GET = (async ({ request }) => {
// Assume default response data
const data: RawResponse = { success: false, message: 'Invalid input or session not found.', done: false };
// Try retrieving the cookies
const raw = request.headers.get('cookie');
if (raw == null) return json(data);
const cookies = parse(raw);
// Try retrieving session id from cookie
const session_id: string | undefined = cookies['session_id'];
if (session_id === undefined) return json(data);
const session = getSession(session_id) as Session | undefined;
if (session === undefined) return json(data);
// Return if matrix has not yet been generated
if (session.matrix === null || session.matrix === undefined) return json(data);
// Get URL search parameters for marked mines
const url = new URL(request.url);
const params = url.searchParams;
const strCheck = params.get('mines');
if (strCheck === null) return json(data);
const mines = JSON.parse(strCheck) as Cell[];
// Count and verify marked mines
let count = 0, isValid = true;
for (const cell of mines) {
const { x, y } = cell;
// Provided cell is not a mine.
if (!session.matrix[y][x]) {
isValid = false;
break;
}
count++;
}
const h = session.matrix.length;
const w = session.matrix[0].length;
// All mines have been marked
if (isValid && count === w * h * .1) {
data.done = true;
data.message = 'You have successfully marked all mines!';
// Generate and save matrix
session.matrix = generateMatrix(w, h);
saveSession(session);
// Some mines remain on matrix
} else
data.message = 'There are still some mines left!';
// Successfully parsed inputs
data.success = true;
return json(data);
}) satisfies RequestHandler;

View File

@ -0,0 +1,42 @@
import { parse } from 'cookie';
import { generateMatrix } from '$lib/server/functions';
import { getSession, saveSession } from '$lib/server/database';
import { json, type RequestHandler } from '@sveltejs/kit';
// http-get-request to handle generating a new matrix
export const GET = (async ({ request }) => {
// Assume default response data
const data: RawResponse = { success: false, message: 'Invalid input or session not found.' };
// Try retrieving the cookies
const raw = request.headers.get('cookie');
if (raw == null) return json(data);
const cookies = parse(raw);
// Try retrieving session id from cookie
const session_id: string | undefined = cookies['session_id'];
if (session_id === undefined) return json(data);
const session = getSession(session_id) as Session | undefined;
if (session === undefined) return json(data);
// Get URL search parameters for dimensions
const url = new URL(request.url);
const params = url.searchParams;
const dimensions = { w: -1, h: -1 };
const w = params.get('w'),
h = params.get('h');
// Try parsing parameters to integers
if (w != null) dimensions.w = parseInt(w);
if (h != null) dimensions.h = parseInt(h);
// Return if dimensions are invalid
if (dimensions.w <= 1 || dimensions.h <= 1) return json(data);
// Successfully parsed inputs
data.success = true;
data.message = 'Generated new matrix.';
// Generate and save matrix
session.matrix = generateMatrix(dimensions.w, dimensions.h);
saveSession(session);
return json(data);
}) satisfies RequestHandler;

View File

@ -0,0 +1,23 @@
import { parse } from 'cookie';
import { getSession } from '$lib/server/database';
import { json, type RequestHandler } from '@sveltejs/kit';
// http-get-request to check if a session is valid
export const GET = (async ({ request }) => {
let response = false;
// Try retrieving the cookies
const raw = request.headers.get('cookie');
if (raw == null) return json(response);
const cookies = parse(raw);
// Try retrieving session id from cookie
const session_id: string | undefined = cookies['session_id'];
if (session_id === undefined) return json(response);
const session = getSession(session_id) as Session | undefined;
if (session === undefined) return json(response);
// Return if matrix has not yet been generated
response = !(session.matrix === null || session.matrix === undefined);
return json(response);
}) satisfies RequestHandler;

View File

@ -0,0 +1,122 @@
import { parse } from 'cookie';
import { json, type RequestHandler } from '@sveltejs/kit';
import { getSession, saveSession } from '$lib/server/database';
import { countAdjacent, generateMatrix } from '$lib/server/functions';
// http-get-request to handle revealing a cell
export const GET = (async ({ request }) => {
// Assume default response data
const data: RawResponse = { success: false, message: 'Invalid input or session not found.' };
// Try retrieving the cookies
const raw = request.headers.get('cookie');
if (raw == null) return json(data);
const cookies = parse(raw);
// Try retrieving session id from cookie
const session_id: string | undefined = cookies['session_id'];
if (session_id === undefined) return json(data);
const session = getSession(session_id) as Session | undefined;
if (session === undefined) return json(data);
// Return if matrix has not yet been generated
if (session.matrix === null || session.matrix === undefined) return json(data);
// Get URL search parameters for coordinates
const url = new URL(request.url);
const params = url.searchParams;
const coords = { x: -1, y: -1 };
const raws = {
x: params.get('x'),
y: params.get('y')
};
// Try parsing parameters to integers
if (raws.x != null) coords.x = parseInt(raws.x);
if (raws.y != null) coords.y = parseInt(raws.y);
if (coords.x === -1 || coords.y === -1) return json(data);
// Return if coordinates are out of bounds
if (coords.y < 0 || coords.y >= session.matrix.length) return json(data);
if (coords.x < 0 || coords.x >= session.matrix[coords.y].length) return json(data);
// Successful because valid interaction
data.success = true;
// Check if cell is a mine
const state: boolean = session.matrix[coords.y][coords.x];
let current = { x: coords.x, y: coords.y, mine: state, neighbors: 0 } as Cell;
if (state) {
// Regenerate matrix
const h = session.matrix.length;
const w = session.matrix[0].length;
session.matrix = generateMatrix(w, h);
saveSession(session);
// Return message and only this cell
data.message = 'Hit a mine!';
data.cells = [current];
return json(data);
}
// Count amount of mines in adjacent cells
const count: number = countAdjacent(current, session.matrix);
if (count > 0) {
// Return messaage and only this cell
data.message = 'Calculated neighbor count.';
current.neighbors = count;
data.cells = [current];
return json(data);
}
// Calculate nearby empty cells until reaching the ones next to mines (flood fill/clear)
data.message = 'Revealed all adjacent empty cells.';
const openSet: Cell[] = [current];
data.cells = [];
while (openSet.length > 0) {
// Choose (and remove) cell from openSet at random
const index = Math.floor(Math.random() * openSet.length);
current = openSet.splice(index, 1)[0];
// Push cell onto closedSet
data.cells.push(current);
// Go through adjacent cells and reveal them
for (let dy = -1; dy < 2; dy++)
for (let dx = -1; dx < 2; dx++) {
// Ignore self
if (dx === 0 && dy === 0) continue;
const nx = current.x + dx,
ny = current.y + dy;
// Ignore out of bounds
if (ny < 0 || ny >= session.matrix.length) continue;
if (nx < 0 || nx >= session.matrix[ny].length) continue;
const next: Cell = { x: nx, y: ny, mine: session.matrix[ny][nx], neighbors: 0 } as Cell;
// Ignore previously calculated
let wasChecked = false;
// Ignore openSet
for (const cell of openSet)
if (cell.x === next.x && cell.y === next.y) {
wasChecked = true;
break;
}
// Ignore closedSet
for (const cell of data.cells)
if (cell.x === next.x && cell.y === next.y) {
wasChecked = true;
break;
}
if (wasChecked) continue;
// Count amount of mines of adjaacent cell
const nCount: number = countAdjacent(next, session.matrix);
if (nCount > 0) {
// Add to closedSet if next to a mine
next.neighbors = nCount;
data.cells.push(next);
continue;
}
// Otherwise add to openSet
openSet.push(next);
}
}
return json(data);
}) satisfies RequestHandler;

13
src/routes/+layout.svelte Normal file
View File

@ -0,0 +1,13 @@
<script>
import '../app.css';
</script>
<div
id="EMSG-background"
class="absolute items-center justify-center w-screen h-screen bg-gray-500 opacity-50 z-50 hidden"
/>
<div id="EMSG-container" class="absolute items-center justify-center w-screen h-screen z-50 hidden cursor-pointer">
<div id="EMSG-text" class="w-80 h-60 flex items-center justify-center rounded-2xl" />
</div>
<slot />

BIN
static/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

18
svelte.config.js Normal file
View File

@ -0,0 +1,18 @@
import adapter from '@sveltejs/adapter-auto';
import { vitePreprocess } from '@sveltejs/kit/vite';
/** @type {import('@sveltejs/kit').Config} */
const config = {
// Consult https://kit.svelte.dev/docs/integrations#preprocessors
// for more information about preprocessors
preprocess: vitePreprocess(),
kit: {
// adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list.
// If your environment is not supported or you settled on a specific environment, switch out the adapter.
// See https://kit.svelte.dev/docs/adapters for more information about adapters.
adapter: adapter()
}
};
export default config;

8
tailwind.config.js Normal file
View File

@ -0,0 +1,8 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./src/**/*.html', './src/**/*.js', './src/**/*.svelte', './src/**/*.ts'],
theme: {
extend: {}
},
plugins: []
};

6
tests/test.ts Normal file
View File

@ -0,0 +1,6 @@
import { expect, test } from '@playwright/test';
test('index page has expected h1', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('heading', { name: 'Welcome to SvelteKit' })).toBeVisible();
});

17
tsconfig.json Normal file
View File

@ -0,0 +1,17 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true
}
// Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias
//
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
// from the referenced tsconfig.json - TypeScript does not merge them in
}

9
vite.config.ts Normal file
View File

@ -0,0 +1,9 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vitest/config';
export default defineConfig({
plugins: [sveltekit()],
test: {
include: ['src/**/*.{test,spec}.{js,ts}']
}
});