added first backend draft
This commit is contained in:
parent
3494a10fc3
commit
edafa021ee
|
|
@ -11,8 +11,10 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
node-version: 22
|
||||
- run: npm ci
|
||||
- run: npm run build
|
||||
- run: bun install
|
||||
- run: bun run build
|
||||
- run: bun test
|
||||
- run: bun lint
|
||||
|
|
@ -6,6 +6,7 @@ yarn-debug.log*
|
|||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
tsconfig.app.tsbuildinfo
|
||||
|
||||
node_modules
|
||||
dist
|
||||
|
|
|
|||
|
|
@ -3,3 +3,9 @@
|
|||
A simple version of minesweeper built with react in about 1h.
|
||||
|
||||

|
||||
|
||||
## Ideas
|
||||
|
||||
- Add global big board
|
||||
- Questinmark after flag
|
||||
- Earn points for wins
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
import { getDb } from "./getDb";
|
||||
|
||||
export const db = getDb();
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import { drizzle } from "drizzle-orm/bun-sqlite";
|
||||
import { Database } from "bun:sqlite";
|
||||
|
||||
export const getDb = (filename: string = "sqlite.db") => {
|
||||
const sqlite = new Database(filename);
|
||||
const db = drizzle(sqlite);
|
||||
return db;
|
||||
};
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
|
||||
import crypto from "crypto";
|
||||
import { getDb } from "./getDb";
|
||||
import fs from "fs";
|
||||
|
||||
const dbs: string[] = [];
|
||||
|
||||
export const getTestDb = () => {
|
||||
const randomId = crypto.randomUUID();
|
||||
dbs.push(randomId);
|
||||
fs.existsSync("temp_dbs") || fs.mkdirSync("temp_dbs");
|
||||
const db = getDb(`temp_dbs/${randomId}.db`);
|
||||
migrate(db, { migrationsFolder: "./backend/drizzle" });
|
||||
return db;
|
||||
};
|
||||
|
||||
export const clearTestDbs = () => {
|
||||
dbs.forEach((db) => {
|
||||
const dbPath = `temp_dbs/${db}.db`;
|
||||
if (fs.existsSync(dbPath)) {
|
||||
fs.rmSync(dbPath);
|
||||
}
|
||||
});
|
||||
dbs.length = 0;
|
||||
};
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
CREATE TABLE `games` (
|
||||
`uuid` text PRIMARY KEY NOT NULL,
|
||||
`user` text NOT NULL,
|
||||
`gameState` text NOT NULL,
|
||||
`stage` integer NOT NULL,
|
||||
`finished` integer DEFAULT 0 NOT NULL,
|
||||
`timestamp` integer NOT NULL,
|
||||
FOREIGN KEY (`user`) REFERENCES `users`(`name`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `users` (
|
||||
`name` text PRIMARY KEY NOT NULL,
|
||||
`password` text NOT NULL
|
||||
);
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "af6e3102-34d0-4247-84ae-14f2d3d8fa4c",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"tables": {
|
||||
"games": {
|
||||
"name": "games",
|
||||
"columns": {
|
||||
"uuid": {
|
||||
"name": "uuid",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user": {
|
||||
"name": "user",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"gameState": {
|
||||
"name": "gameState",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"stage": {
|
||||
"name": "stage",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"finished": {
|
||||
"name": "finished",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"timestamp": {
|
||||
"name": "timestamp",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"games_user_users_name_fk": {
|
||||
"name": "games_user_users_name_fk",
|
||||
"tableFrom": "games",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user"
|
||||
],
|
||||
"columnsTo": [
|
||||
"name"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"users": {
|
||||
"name": "users",
|
||||
"columns": {
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"password": {
|
||||
"name": "password",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {}
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1726774158116,
|
||||
"tag": "0000_nostalgic_next_avengers",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
import type { ServerWebSocket } from "bun";
|
||||
|
||||
interface Scoreboard {
|
||||
stage: number;
|
||||
user: string;
|
||||
}
|
||||
|
||||
const loadScoreboard = async (): Promise<Scoreboard[]> => {
|
||||
try {
|
||||
const scoreboardFile = Bun.file("./scoreboard.json");
|
||||
const scoreboard = await scoreboardFile.json();
|
||||
return scoreboard;
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const allowCors = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type",
|
||||
};
|
||||
|
||||
const lastMessage = new WeakMap<ServerWebSocket<unknown>, number>();
|
||||
const server = Bun.serve({
|
||||
async fetch(request: Request) {
|
||||
if (request.method === "OPTIONS") {
|
||||
const res = new Response("Departed", { headers: allowCors });
|
||||
return res;
|
||||
}
|
||||
if (request.url.endsWith("ws")) {
|
||||
if (server.upgrade(request)) return new Response("ok");
|
||||
}
|
||||
if (new URL(request.url).pathname === "/submit") {
|
||||
const body = await request.text();
|
||||
const data = JSON.parse(body) as { stage: number; user: string };
|
||||
const scoreboardFile = Bun.file("./scoreboard.json");
|
||||
const scoreboard = await loadScoreboard();
|
||||
const currentScore = scoreboard.find((s) => s.user === data.user);
|
||||
if (currentScore) {
|
||||
if (currentScore.stage < data.stage) {
|
||||
currentScore.stage = data.stage;
|
||||
Bun.write(scoreboardFile, JSON.stringify(scoreboard));
|
||||
}
|
||||
return new Response(JSON.stringify(currentScore), {
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...allowCors,
|
||||
},
|
||||
});
|
||||
}
|
||||
scoreboard.push(data);
|
||||
Bun.write(scoreboardFile, JSON.stringify(scoreboard));
|
||||
return new Response(JSON.stringify(data), {
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...allowCors,
|
||||
},
|
||||
});
|
||||
}
|
||||
const scoreboard = await loadScoreboard();
|
||||
const sorted = scoreboard.sort((a, b) => b.stage - a.stage);
|
||||
return new Response(JSON.stringify(sorted), {
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...allowCors,
|
||||
},
|
||||
});
|
||||
},
|
||||
websocket: {
|
||||
message: (ws, message) => {
|
||||
if (typeof message !== "string") {
|
||||
return;
|
||||
}
|
||||
const msg = JSON.parse(message);
|
||||
const now = Date.now();
|
||||
if (lastMessage.has(ws) && now - lastMessage.get(ws)! < 200) {
|
||||
return;
|
||||
}
|
||||
lastMessage.set(ws, now);
|
||||
if (msg.type === "loss") {
|
||||
server.publish(
|
||||
"minesweeper",
|
||||
JSON.stringify({ type: "loss", user: msg.user, stage: msg.stage }),
|
||||
);
|
||||
} else if (msg.type === "new") {
|
||||
server.publish(
|
||||
"minesweeper",
|
||||
JSON.stringify({ type: "new", user: msg.user }),
|
||||
);
|
||||
}
|
||||
},
|
||||
open: async (ws) => {
|
||||
ws.subscribe("minesweeper");
|
||||
},
|
||||
},
|
||||
port: 8076,
|
||||
});
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
|
||||
|
||||
import { drizzle } from "drizzle-orm/bun-sqlite";
|
||||
import { Database } from "bun:sqlite";
|
||||
|
||||
const sqlite = new Database("sqlite.db");
|
||||
const db = drizzle(sqlite);
|
||||
migrate(db, { migrationsFolder: "./backend/drizzle" });
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
|
||||
|
||||
export const User = sqliteTable("users", {
|
||||
name: text("name").primaryKey().notNull(),
|
||||
password: text("password").notNull(),
|
||||
});
|
||||
|
||||
export const Game = sqliteTable("games", {
|
||||
uuid: text("uuid").primaryKey().notNull(),
|
||||
user: text("user")
|
||||
.notNull()
|
||||
.references(() => User.name),
|
||||
gameState: text("gameState").notNull(),
|
||||
stage: integer("stage").notNull(),
|
||||
finished: integer("finished").notNull().default(0),
|
||||
timestamp: integer("timestamp").notNull(),
|
||||
});
|
||||
|
||||
export type UserType = Omit<typeof User.$inferSelect, "password"> & {
|
||||
password?: undefined;
|
||||
};
|
||||
export type GameType = typeof Game.$inferSelect;
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
import { describe, it, expect } from "bun:test";
|
||||
import { getScoreBoard } from "./score.ts";
|
||||
import { getTestDb } from "../database/getTestDb.ts";
|
||||
import { Game, User } from "../schema.ts";
|
||||
|
||||
describe("Score", () => {
|
||||
it("should return the score board", async () => {
|
||||
const db = getTestDb();
|
||||
await db.insert(User).values({
|
||||
name: "TestUser",
|
||||
password: "test",
|
||||
});
|
||||
await db.insert(Game).values({
|
||||
user: "TestUser",
|
||||
uuid: crypto.randomUUID(),
|
||||
stage: 1,
|
||||
gameState: "ANY",
|
||||
finished: 1,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
await db.insert(Game).values({
|
||||
user: "TestUser",
|
||||
uuid: crypto.randomUUID(),
|
||||
stage: 10,
|
||||
gameState: "ANY",
|
||||
finished: 1,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
await db.insert(Game).values({
|
||||
user: "TestUser",
|
||||
uuid: crypto.randomUUID(),
|
||||
stage: 20,
|
||||
gameState: "ANY",
|
||||
finished: 0,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
const result = await getScoreBoard(db);
|
||||
expect(result).toEqual([{ stage: 10, user: "TestUser" }]);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
import { eq, sql } from "drizzle-orm";
|
||||
import { Game } from "../schema";
|
||||
import type { BunSQLiteDatabase } from "drizzle-orm/bun-sqlite";
|
||||
|
||||
export const getScoreBoard = async (db: BunSQLiteDatabase) => {
|
||||
return await db
|
||||
.select({ stage: sql<number>`max(${Game.stage})`, user: Game.user })
|
||||
.from(Game)
|
||||
.where(eq(Game.finished, 1))
|
||||
.groupBy(Game.user);
|
||||
};
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
import { describe, it, expect } from "bun:test";
|
||||
import { getTestDb } from "../database/getTestDb";
|
||||
import { getUser, loginUser, registerUser } from "./user";
|
||||
|
||||
describe("User", () => {
|
||||
it("should register a user", async () => {
|
||||
const db = getTestDb();
|
||||
await registerUser(db, "TestUser", "test");
|
||||
const user = await getUser(db, "TestUser");
|
||||
expect(user).toEqual({
|
||||
name: "TestUser",
|
||||
password: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw an error if user already exists register", async () => {
|
||||
const db = getTestDb();
|
||||
await registerUser(db, "TestUser", "test");
|
||||
expect(registerUser(db, "TestUser", "test")).rejects.toThrow(
|
||||
"User already exists",
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw an error if user already exists register case insensitive", async () => {
|
||||
const db = getTestDb();
|
||||
await registerUser(db, "TestUser", "test");
|
||||
expect(registerUser(db, "TESTUSER", "test")).rejects.toThrow(
|
||||
"User already exists",
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw an error if user does not exist on login", async () => {
|
||||
const db = getTestDb();
|
||||
expect(loginUser(db, "TestUser", "test")).rejects.toThrow(
|
||||
"User does not exist",
|
||||
);
|
||||
});
|
||||
|
||||
it("should login a user", async () => {
|
||||
const db = getTestDb();
|
||||
await registerUser(db, "TestUser", "test");
|
||||
const user = await loginUser(db, "TestUser", "test");
|
||||
expect(user).toEqual({
|
||||
name: "TestUser",
|
||||
password: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
import type { BunSQLiteDatabase } from "drizzle-orm/bun-sqlite";
|
||||
import { User, type UserType } from "../schema";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
|
||||
export const registerUser = async (
|
||||
db: BunSQLiteDatabase,
|
||||
name: string,
|
||||
password: string,
|
||||
) => {
|
||||
const user = await db
|
||||
.select()
|
||||
.from(User)
|
||||
.where(eq(sql`lower(${User.name})`, name.toLowerCase()));
|
||||
if (user.length > 0) {
|
||||
throw new Error("User already exists");
|
||||
}
|
||||
const hash = await Bun.password.hash(password + Bun.env.SALT ?? "");
|
||||
await db.insert(User).values({ name, password: hash });
|
||||
};
|
||||
|
||||
export const loginUser = async (
|
||||
db: BunSQLiteDatabase,
|
||||
name: string,
|
||||
password: string,
|
||||
) => {
|
||||
const user = await db
|
||||
.select()
|
||||
.from(User)
|
||||
.where(eq(sql`lower(${User.name})`, name.toLowerCase()));
|
||||
if (user.length === 0) {
|
||||
throw new Error("User does not exist");
|
||||
}
|
||||
if (
|
||||
!(await Bun.password.verify(
|
||||
password + Bun.env.SALT ?? "",
|
||||
user[0].password,
|
||||
))
|
||||
) {
|
||||
throw new Error("Incorrect password");
|
||||
}
|
||||
return { ...user[0], password: undefined };
|
||||
};
|
||||
|
||||
export const getUser = async (
|
||||
db: BunSQLiteDatabase,
|
||||
name: string,
|
||||
): Promise<UserType | undefined> => {
|
||||
const user = await db
|
||||
.select()
|
||||
.from(User)
|
||||
.where(eq(sql`lower(${User.name})`, name.toLowerCase()));
|
||||
return { ...user[0], password: undefined };
|
||||
};
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import { afterAll } from "bun:test";
|
||||
import { clearTestDbs } from "./database/getTestDb";
|
||||
|
||||
afterAll(() => {
|
||||
clearTestDbs();
|
||||
});
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
[test]
|
||||
preload = ["./backend/test-setup.ts"]
|
||||
|
|
@ -3,7 +3,8 @@
|
|||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<!-- <meta name="viewport" content="width=device-width, initial-scale=1.0" /> -->
|
||||
<meta name="format-detection" content="telephone=no"/>
|
||||
<title>Minesweeper</title>
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
28
package.json
28
package.json
|
|
@ -7,27 +7,35 @@
|
|||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"drizzle:schema": "drizzle-kit generate --dialect sqlite --schema ./backend/schema.ts --out ./backend/drizzle",
|
||||
"drizzle:migrate": "bun run backend/migrate.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uidotdev/usehooks": "^2.4.1",
|
||||
"drizzle-orm": "^0.33.0",
|
||||
"lucide-react": "^0.441.0",
|
||||
"react": "^18.3.1",
|
||||
"react-confetti-boom": "^1.0.0",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"use-sound": "^4.0.3",
|
||||
"zustand": "^4.5.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.9.0",
|
||||
"@types/react": "^18.3.3",
|
||||
"@eslint/js": "^9.10.0",
|
||||
"@types/bun": "latest",
|
||||
"@types/react": "^18.3.8",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react-swc": "^3.5.0",
|
||||
"eslint": "^9.9.0",
|
||||
"@vitejs/plugin-react-swc": "^3.7.0",
|
||||
"drizzle-kit": "^0.24.2",
|
||||
"eslint": "^9.10.0",
|
||||
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.9",
|
||||
"eslint-plugin-react-refresh": "^0.4.12",
|
||||
"globals": "^15.9.0",
|
||||
"typescript": "^5.5.3",
|
||||
"typescript-eslint": "^8.0.1",
|
||||
"vite": "^5.4.1"
|
||||
}
|
||||
"typescript": "^5.6.2",
|
||||
"typescript-eslint": "^8.6.0",
|
||||
"vite": "^5.4.6"
|
||||
},
|
||||
"module": "index.ts"
|
||||
}
|
||||
|
|
|
|||
52
src/App.tsx
52
src/App.tsx
|
|
@ -4,16 +4,27 @@ import explosion from "./sound/explosion.mp3";
|
|||
import useGameStore from "./GameState";
|
||||
import { useEffect, useState } from "react";
|
||||
import useSound from "use-sound";
|
||||
import { loseGame } from "./ws";
|
||||
import toast, { useToasterStore } from "react-hot-toast";
|
||||
|
||||
interface Score {
|
||||
user: string;
|
||||
stage: number;
|
||||
}
|
||||
|
||||
function useMaxToasts(max: number) {
|
||||
const { toasts } = useToasterStore();
|
||||
|
||||
useEffect(() => {
|
||||
toasts
|
||||
.filter((t) => t.visible) // Only consider visible toasts
|
||||
.filter((_, i) => i >= max) // Is toast index over limit?
|
||||
.forEach((t) => toast.dismiss(t.id)); // Dismiss – Use toast.remove(t.id) for no exit animation
|
||||
}, [toasts, max]);
|
||||
}
|
||||
function App() {
|
||||
const game = useGameStore();
|
||||
const [scores, setScores] = useState<Score[]>([]);
|
||||
const [showScores, setShowScores] = useState(false);
|
||||
const [playSound] = useSound(explosion, {
|
||||
volume: 0.5,
|
||||
});
|
||||
|
|
@ -21,6 +32,7 @@ function App() {
|
|||
useEffect(() => {
|
||||
if (game.isGameOver) {
|
||||
playSound();
|
||||
loseGame(game.name, game.stage);
|
||||
}
|
||||
}, [game.isGameOver]);
|
||||
|
||||
|
|
@ -44,31 +56,35 @@ function App() {
|
|||
game.resetGame(4, 4, 2);
|
||||
}, []);
|
||||
|
||||
useMaxToasts(5);
|
||||
|
||||
return (
|
||||
<div className="App">
|
||||
<h1>
|
||||
Minesweeper Endless{" "}
|
||||
<button onClick={() => game.resetGame(4, 4, 2)}>Reset</button>
|
||||
</h1>
|
||||
<p>
|
||||
Name:{" "}
|
||||
<input
|
||||
value={game.name}
|
||||
onChange={(e) => game.setName(e.target.value)}
|
||||
/>
|
||||
</p>
|
||||
<button onClick={() => setShowScores(!showScores)}>
|
||||
{showScores ? "Hide" : "Show"} Scores
|
||||
</button>
|
||||
{showScores && (
|
||||
{import.meta.env.DEV && (
|
||||
<button onClick={() => game.expandBoard()}>Expand</button>
|
||||
)}
|
||||
<div className="header">
|
||||
<div>
|
||||
<h1>
|
||||
Minesweeper Endless{" "}
|
||||
<button onClick={() => game.resetGame(4, 4, 2)}>Reset</button>
|
||||
</h1>
|
||||
<p>
|
||||
Name:{" "}
|
||||
<input
|
||||
value={game.name}
|
||||
onChange={(e) => game.setName(e.target.value)}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
<div className="scores">
|
||||
{scores.slice(0, 10).map((score) => (
|
||||
<p key={score.user}>
|
||||
{score.user} - {score.stage}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="game-wrapper">
|
||||
<div>
|
||||
<Timer />
|
||||
|
|
@ -88,7 +104,7 @@ function App() {
|
|||
</div>
|
||||
</div>
|
||||
<div className="footer">
|
||||
<pre>Version: 1.1.3</pre>
|
||||
<pre>Version: 1.1.6</pre>
|
||||
<pre>
|
||||
Made by MasterGordon -{" "}
|
||||
<a target="_blank" href="https://github.com/MasterGordon/minesweeper">
|
||||
|
|
|
|||
104
src/Button.tsx
104
src/Button.tsx
|
|
@ -1,6 +1,7 @@
|
|||
import { ReactNode } from "react";
|
||||
import { ReactNode, useRef } from "react";
|
||||
import { Bomb, Flag } from "lucide-react";
|
||||
import useGameStore from "./GameState";
|
||||
import { useLongPress } from "@uidotdev/usehooks";
|
||||
|
||||
interface ButtonProps {
|
||||
x: number;
|
||||
|
|
@ -19,75 +20,98 @@ export const colorMap: Record<string, string> = {
|
|||
};
|
||||
|
||||
export const Button = ({ x, y }: ButtonProps) => {
|
||||
const game = useGameStore();
|
||||
const {
|
||||
isRevealed,
|
||||
isFlagged,
|
||||
isMine,
|
||||
getValue,
|
||||
reveal,
|
||||
flag,
|
||||
getNeighborFlags,
|
||||
isGameOver,
|
||||
getHasWon,
|
||||
} = useGameStore();
|
||||
|
||||
let content: ReactNode = "";
|
||||
|
||||
if (game.isRevealed[x][y]) {
|
||||
content = game.isMine(x, y) ? <Bomb /> : game.getValue(x, y).toString();
|
||||
if (isRevealed[x][y]) {
|
||||
content = isMine(x, y) ? <Bomb /> : getValue(x, y).toString();
|
||||
}
|
||||
|
||||
if (game.isFlagged[x][y]) {
|
||||
const attrs = useLongPress(
|
||||
() => {
|
||||
if (isRevealed[x][y]) return;
|
||||
flag(x, y);
|
||||
},
|
||||
{
|
||||
threshold: 400,
|
||||
},
|
||||
);
|
||||
|
||||
if (isFlagged[x][y]) {
|
||||
content = <Flag fill="red" />;
|
||||
}
|
||||
if (content === "0") content = "";
|
||||
if (
|
||||
import.meta.env.DEV &&
|
||||
window.location.href.includes("xray") &&
|
||||
game.isMine(x, y) &&
|
||||
!game.isFlagged[x][y]
|
||||
isMine(x, y) &&
|
||||
!isFlagged[x][y]
|
||||
)
|
||||
content = <Bomb />;
|
||||
|
||||
const touchStart = useRef<number>(0);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="mine-button"
|
||||
{...attrs}
|
||||
style={{
|
||||
background: game.isRevealed[x][y] ? "#444" : undefined,
|
||||
borderRight: !game.isRevealed[x][y] ? "3px solid black" : undefined,
|
||||
borderTop: !game.isRevealed[x][y] ? "3px solid #999" : undefined,
|
||||
borderLeft: !game.isRevealed[x][y] ? "3px solid #999" : undefined,
|
||||
borderBottom: !game.isRevealed[x][y] ? "3px solid black" : undefined,
|
||||
color: game.isRevealed[x][y]
|
||||
background: isRevealed[x][y] ? "#444" : undefined,
|
||||
borderRight: !isRevealed[x][y] ? "3px solid black" : undefined,
|
||||
borderTop: !isRevealed[x][y] ? "3px solid #999" : undefined,
|
||||
borderLeft: !isRevealed[x][y] ? "3px solid #999" : undefined,
|
||||
borderBottom: !isRevealed[x][y] ? "3px solid black" : undefined,
|
||||
color: isRevealed[x][y]
|
||||
? colorMap[String(content)] ?? "#eee"
|
||||
: undefined,
|
||||
fontSize: Number(content) > 0 ? "1.75rem" : undefined,
|
||||
cursor: game.isRevealed[x][y] ? "default" : "pointer",
|
||||
cursor: isRevealed[x][y] ? "default" : "pointer",
|
||||
}}
|
||||
onMouseDown={() => {
|
||||
touchStart.current = Date.now();
|
||||
}}
|
||||
onMouseUp={(e) => {
|
||||
if (game.getHasWon() || game.isGameOver) {
|
||||
if (Date.now() - touchStart.current > 400 && !isRevealed[x][y]) {
|
||||
flag(x, y);
|
||||
return;
|
||||
}
|
||||
if (getHasWon() || isGameOver) {
|
||||
return;
|
||||
}
|
||||
if (e.button === 0) {
|
||||
// Left click
|
||||
if (!game.isRevealed[x][y] && !game.isFlagged[x][y]) {
|
||||
game.reveal(x, y);
|
||||
if (isFlagged[x][y]) return;
|
||||
if (!isRevealed[x][y]) {
|
||||
reveal(x, y);
|
||||
} else {
|
||||
const neighborFlagCount = game
|
||||
.getNeighborFlags(x, y)
|
||||
.filter((n) => n).length;
|
||||
const value = game.getValue(x, y);
|
||||
const neighborFlagCount = getNeighborFlags(x, y).filter(
|
||||
(n) => n,
|
||||
).length;
|
||||
const value = getValue(x, y);
|
||||
if (neighborFlagCount === value) {
|
||||
const currentStage = game.stage;
|
||||
if (!game.isFlagged[x - 1]?.[y] && currentStage == game.stage)
|
||||
game.reveal(x - 1, y);
|
||||
if (!game.isFlagged[x - 1]?.[y - 1] && currentStage == game.stage)
|
||||
game.reveal(x - 1, y - 1);
|
||||
if (!game.isFlagged[x - 1]?.[y + 1] && currentStage == game.stage)
|
||||
game.reveal(x - 1, y + 1);
|
||||
if (!game.isFlagged[x]?.[y - 1] && currentStage == game.stage)
|
||||
game.reveal(x, y - 1);
|
||||
if (!game.isFlagged[x]?.[y + 1] && currentStage == game.stage)
|
||||
game.reveal(x, y + 1);
|
||||
if (!game.isFlagged[x + 1]?.[y - 1] && currentStage == game.stage)
|
||||
game.reveal(x + 1, y - 1);
|
||||
if (!game.isFlagged[x + 1]?.[y] && currentStage == game.stage)
|
||||
game.reveal(x + 1, y);
|
||||
if (!game.isFlagged[x + 1]?.[y + 1] && currentStage == game.stage)
|
||||
game.reveal(x + 1, y + 1);
|
||||
if (!isFlagged[x - 1]?.[y]) if (reveal(x - 1, y)) return;
|
||||
if (!isFlagged[x - 1]?.[y - 1]) if (reveal(x - 1, y - 1)) return;
|
||||
if (!isFlagged[x - 1]?.[y + 1]) if (reveal(x - 1, y + 1)) return;
|
||||
if (!isFlagged[x]?.[y - 1]) if (reveal(x, y - 1)) return;
|
||||
if (!isFlagged[x]?.[y + 1]) if (reveal(x, y + 1)) return;
|
||||
if (!isFlagged[x + 1]?.[y - 1]) if (reveal(x + 1, y - 1)) return;
|
||||
if (!isFlagged[x + 1]?.[y]) if (reveal(x + 1, y)) return;
|
||||
if (!isFlagged[x + 1]?.[y + 1]) if (reveal(x + 1, y + 1)) return;
|
||||
}
|
||||
}
|
||||
} else if (e.button === 2 && !game.isRevealed[x][y]) {
|
||||
game.flag(x, y);
|
||||
} else if (e.button === 2 && !isRevealed[x][y]) {
|
||||
flag(x, y);
|
||||
}
|
||||
e.preventDefault();
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { create } from "zustand";
|
||||
import { newGame } from "./ws";
|
||||
|
||||
interface GameState {
|
||||
mines: boolean[][];
|
||||
|
|
@ -14,7 +15,7 @@ interface GameState {
|
|||
|
||||
initializeGame: (width: number, height: number, mines: number) => void;
|
||||
flag: (x: number, y: number) => void;
|
||||
reveal: (x: number, y: number) => void;
|
||||
reveal: (x: number, y: number) => boolean;
|
||||
getValue: (x: number, y: number) => number;
|
||||
getHasWon: () => boolean;
|
||||
getMinesLeft: () => number;
|
||||
|
|
@ -27,7 +28,7 @@ interface GameState {
|
|||
getWidth: () => number;
|
||||
getHeight: () => number;
|
||||
isTouched: () => boolean;
|
||||
triggerPostGame: () => void;
|
||||
triggerPostGame: () => boolean;
|
||||
expandBoard: () => void;
|
||||
setName: (name: string) => void;
|
||||
}
|
||||
|
|
@ -91,13 +92,14 @@ const useGameStore = create<GameState>((set, get) => ({
|
|||
|
||||
reveal: (x, y) => {
|
||||
const { mines, isRevealed, isGameOver, getValue, triggerPostGame } = get();
|
||||
if (isGameOver || !get().isValid(x, y) || isRevealed[x][y]) return;
|
||||
if (isGameOver || !get().isValid(x, y) || isRevealed[x][y]) return false;
|
||||
|
||||
const newRevealed = [...isRevealed];
|
||||
newRevealed[x][y] = true;
|
||||
|
||||
if (mines[x][y]) {
|
||||
set({ isGameOver: true, isRevealed: newRevealed });
|
||||
return true;
|
||||
} else {
|
||||
set({ isRevealed: newRevealed });
|
||||
const value = getValue(x, y);
|
||||
|
|
@ -121,7 +123,7 @@ const useGameStore = create<GameState>((set, get) => ({
|
|||
revealNeighbors(x + 1, y + 1);
|
||||
}
|
||||
}
|
||||
triggerPostGame();
|
||||
return triggerPostGame();
|
||||
},
|
||||
|
||||
getValue: (x, y) => {
|
||||
|
|
@ -176,6 +178,8 @@ const useGameStore = create<GameState>((set, get) => ({
|
|||
return x >= 0 && x < width && y >= 0 && y < height;
|
||||
},
|
||||
resetGame: (width: number, height: number, mines: number) => {
|
||||
const { name } = get();
|
||||
newGame(name);
|
||||
if (mines > width * height) {
|
||||
throw new Error("Too many mines");
|
||||
}
|
||||
|
|
@ -263,11 +267,16 @@ const useGameStore = create<GameState>((set, get) => ({
|
|||
const { getHasWon, expandBoard } = get();
|
||||
if (getHasWon()) {
|
||||
expandBoard();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
expandBoard: () => {
|
||||
const { width, height, stage, mines, isFlagged, isRevealed } = get();
|
||||
const dir = stage % 2 === 0 ? "down" : "right";
|
||||
let dir = stage % 2 === 0 ? "down" : "right";
|
||||
if (stage > 11) {
|
||||
dir = "down";
|
||||
}
|
||||
// Expand the board by the current board size 8x8 -> 16x8
|
||||
if (dir === "down") {
|
||||
const newHeight = Math.floor(height * 1.5);
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ body {
|
|||
margin: auto;
|
||||
max-width: 1400px;
|
||||
padding: 1rem;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.timer {
|
||||
|
|
@ -63,3 +64,53 @@ pre {
|
|||
font-size: 1rem;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
input {
|
||||
font-size: 14px;
|
||||
margin: 12px;
|
||||
padding: 6px 12px 6px 12px;
|
||||
border-radius: 0.7em;
|
||||
background: #333;
|
||||
color: #eee;
|
||||
border: 1px solid rgb(251, 21, 242);
|
||||
|
||||
}
|
||||
|
||||
button {
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
margin: 12px;
|
||||
padding: 6px 12px 6px 12px;
|
||||
border-radius: 0.7em;
|
||||
background: -webkit-linear-gradient(225deg, rgb(251, 175, 21), rgb(251, 21, 242),
|
||||
rgb(21, 198, 251)) 0% 0% / 300% 300%;
|
||||
background-size: 200% auto;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
animation: gradient_move 1s ease infinite;
|
||||
}
|
||||
|
||||
@keyframes gradient_move {
|
||||
0%{background-position: 0% 92%}
|
||||
50%{background-position: 100% 9%}
|
||||
100%{background-position: 0% 92%}
|
||||
}
|
||||
|
||||
/* .scores { */
|
||||
/* position: fixed; */
|
||||
/* top: 0; */
|
||||
/* left: 0; */
|
||||
/* padding: 1rem; */
|
||||
/* } */
|
||||
|
||||
.header {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.scores {
|
||||
text-align: right;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,13 +2,18 @@ import { StrictMode } from "react";
|
|||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App.tsx";
|
||||
import "./index.css";
|
||||
import { connectWS } from "./ws.ts";
|
||||
import { Toaster } from "react-hot-toast";
|
||||
|
||||
document.addEventListener("contextmenu", (event) => {
|
||||
event.preventDefault();
|
||||
});
|
||||
|
||||
connectWS();
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<Toaster position="top-right" reverseOrder={false} />
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,51 @@
|
|||
import toast from "react-hot-toast";
|
||||
|
||||
let ws: WebSocket;
|
||||
|
||||
export const connectWS = () => {
|
||||
ws = new WebSocket("wss://mb.gordon.business/ws");
|
||||
ws.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
const name = localStorage.getItem("name");
|
||||
console.log(data);
|
||||
if (data.user === name) {
|
||||
return;
|
||||
}
|
||||
switch (data.type) {
|
||||
case "new":
|
||||
toast(data.user + " started a new game", {
|
||||
icon: "🚀",
|
||||
style: {
|
||||
borderRadius: "10px",
|
||||
background: "#333",
|
||||
color: "#fff",
|
||||
},
|
||||
});
|
||||
break;
|
||||
case "loss":
|
||||
toast("Game over by " + data.user + " stage " + data.stage, {
|
||||
icon: "😢",
|
||||
style: {
|
||||
borderRadius: "10px",
|
||||
background: "#333",
|
||||
color: "#fff",
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const newGame = (user: string) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: "new", user }));
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
newGame(user);
|
||||
}, 100);
|
||||
}
|
||||
};
|
||||
|
||||
export const loseGame = (user: string, stage: number) => {
|
||||
ws.send(JSON.stringify({ type: "loss", user, stage }));
|
||||
};
|
||||
|
|
@ -1 +0,0 @@
|
|||
{"root":["./src/App.tsx","./src/Button.tsx","./src/Game.ts","./src/GameState.ts","./src/Options.tsx","./src/Timer.tsx","./src/main.tsx","./src/vite-env.d.ts"],"version":"5.6.2"}
|
||||
|
|
@ -1,22 +1,28 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
// Enable latest features
|
||||
"lib": ["ESNext", "DOM"],
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleDetection": "force",
|
||||
"jsx": "react-jsx",
|
||||
"allowJs": true,
|
||||
|
||||
/* Bundler mode */
|
||||
// Bundler mode
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
// Best practices
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
"skipLibCheck": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
|
||||
// Some stricter flags (disabled by default)
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noPropertyAccessFromIndexSignature": false
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
"include": ["vite.config.ts", "backend/**/*.ts"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
{"root":["./vite.config.ts"],"version":"5.6.2"}
|
||||
{"root":["./vite.config.ts","./backend/index.ts","./backend/migrate.ts","./backend/schema.ts","./backend/test-setup.ts","./backend/database/db.ts","./backend/database/getDb.ts","./backend/database/getTestDb.ts","./backend/services/score.test.ts","./backend/services/score.ts"],"version":"5.6.2"}
|
||||
Loading…
Reference in New Issue