added first backend draft
This commit is contained in:
parent
3494a10fc3
commit
edafa021ee
|
|
@ -11,8 +11,10 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-node@v4
|
- uses: oven-sh/setup-bun@v2
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
- run: npm ci
|
- run: bun install
|
||||||
- run: npm run build
|
- run: bun run build
|
||||||
|
- run: bun test
|
||||||
|
- run: bun lint
|
||||||
|
|
@ -6,6 +6,7 @@ yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
pnpm-debug.log*
|
pnpm-debug.log*
|
||||||
lerna-debug.log*
|
lerna-debug.log*
|
||||||
|
tsconfig.app.tsbuildinfo
|
||||||
|
|
||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
|
|
|
||||||
|
|
@ -3,3 +3,9 @@
|
||||||
A simple version of minesweeper built with react in about 1h.
|
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>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<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>
|
<title>Minesweeper</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
28
package.json
28
package.json
|
|
@ -7,27 +7,35 @@
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "eslint .",
|
"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": {
|
"dependencies": {
|
||||||
|
"@uidotdev/usehooks": "^2.4.1",
|
||||||
|
"drizzle-orm": "^0.33.0",
|
||||||
"lucide-react": "^0.441.0",
|
"lucide-react": "^0.441.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-confetti-boom": "^1.0.0",
|
"react-confetti-boom": "^1.0.0",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
"react-hot-toast": "^2.4.1",
|
||||||
"use-sound": "^4.0.3",
|
"use-sound": "^4.0.3",
|
||||||
"zustand": "^4.5.5"
|
"zustand": "^4.5.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.9.0",
|
"@eslint/js": "^9.10.0",
|
||||||
"@types/react": "^18.3.3",
|
"@types/bun": "latest",
|
||||||
|
"@types/react": "^18.3.8",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"@vitejs/plugin-react-swc": "^3.5.0",
|
"@vitejs/plugin-react-swc": "^3.7.0",
|
||||||
"eslint": "^9.9.0",
|
"drizzle-kit": "^0.24.2",
|
||||||
|
"eslint": "^9.10.0",
|
||||||
"eslint-plugin-react-hooks": "^5.1.0-rc.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",
|
"globals": "^15.9.0",
|
||||||
"typescript": "^5.5.3",
|
"typescript": "^5.6.2",
|
||||||
"typescript-eslint": "^8.0.1",
|
"typescript-eslint": "^8.6.0",
|
||||||
"vite": "^5.4.1"
|
"vite": "^5.4.6"
|
||||||
}
|
},
|
||||||
|
"module": "index.ts"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
32
src/App.tsx
32
src/App.tsx
|
|
@ -4,16 +4,27 @@ import explosion from "./sound/explosion.mp3";
|
||||||
import useGameStore from "./GameState";
|
import useGameStore from "./GameState";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import useSound from "use-sound";
|
import useSound from "use-sound";
|
||||||
|
import { loseGame } from "./ws";
|
||||||
|
import toast, { useToasterStore } from "react-hot-toast";
|
||||||
|
|
||||||
interface Score {
|
interface Score {
|
||||||
user: string;
|
user: string;
|
||||||
stage: number;
|
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() {
|
function App() {
|
||||||
const game = useGameStore();
|
const game = useGameStore();
|
||||||
const [scores, setScores] = useState<Score[]>([]);
|
const [scores, setScores] = useState<Score[]>([]);
|
||||||
const [showScores, setShowScores] = useState(false);
|
|
||||||
const [playSound] = useSound(explosion, {
|
const [playSound] = useSound(explosion, {
|
||||||
volume: 0.5,
|
volume: 0.5,
|
||||||
});
|
});
|
||||||
|
|
@ -21,6 +32,7 @@ function App() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (game.isGameOver) {
|
if (game.isGameOver) {
|
||||||
playSound();
|
playSound();
|
||||||
|
loseGame(game.name, game.stage);
|
||||||
}
|
}
|
||||||
}, [game.isGameOver]);
|
}, [game.isGameOver]);
|
||||||
|
|
||||||
|
|
@ -44,8 +56,15 @@ function App() {
|
||||||
game.resetGame(4, 4, 2);
|
game.resetGame(4, 4, 2);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useMaxToasts(5);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="App">
|
<div className="App">
|
||||||
|
{import.meta.env.DEV && (
|
||||||
|
<button onClick={() => game.expandBoard()}>Expand</button>
|
||||||
|
)}
|
||||||
|
<div className="header">
|
||||||
|
<div>
|
||||||
<h1>
|
<h1>
|
||||||
Minesweeper Endless{" "}
|
Minesweeper Endless{" "}
|
||||||
<button onClick={() => game.resetGame(4, 4, 2)}>Reset</button>
|
<button onClick={() => game.resetGame(4, 4, 2)}>Reset</button>
|
||||||
|
|
@ -57,18 +76,15 @@ function App() {
|
||||||
onChange={(e) => game.setName(e.target.value)}
|
onChange={(e) => game.setName(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</p>
|
</p>
|
||||||
<button onClick={() => setShowScores(!showScores)}>
|
</div>
|
||||||
{showScores ? "Hide" : "Show"} Scores
|
<div className="scores">
|
||||||
</button>
|
|
||||||
{showScores && (
|
|
||||||
<div>
|
|
||||||
{scores.slice(0, 10).map((score) => (
|
{scores.slice(0, 10).map((score) => (
|
||||||
<p key={score.user}>
|
<p key={score.user}>
|
||||||
{score.user} - {score.stage}
|
{score.user} - {score.stage}
|
||||||
</p>
|
</p>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
<div className="game-wrapper">
|
<div className="game-wrapper">
|
||||||
<div>
|
<div>
|
||||||
<Timer />
|
<Timer />
|
||||||
|
|
@ -88,7 +104,7 @@ function App() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="footer">
|
<div className="footer">
|
||||||
<pre>Version: 1.1.3</pre>
|
<pre>Version: 1.1.6</pre>
|
||||||
<pre>
|
<pre>
|
||||||
Made by MasterGordon -{" "}
|
Made by MasterGordon -{" "}
|
||||||
<a target="_blank" href="https://github.com/MasterGordon/minesweeper">
|
<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 { Bomb, Flag } from "lucide-react";
|
||||||
import useGameStore from "./GameState";
|
import useGameStore from "./GameState";
|
||||||
|
import { useLongPress } from "@uidotdev/usehooks";
|
||||||
|
|
||||||
interface ButtonProps {
|
interface ButtonProps {
|
||||||
x: number;
|
x: number;
|
||||||
|
|
@ -19,75 +20,98 @@ export const colorMap: Record<string, string> = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Button = ({ x, y }: ButtonProps) => {
|
export const Button = ({ x, y }: ButtonProps) => {
|
||||||
const game = useGameStore();
|
const {
|
||||||
|
isRevealed,
|
||||||
|
isFlagged,
|
||||||
|
isMine,
|
||||||
|
getValue,
|
||||||
|
reveal,
|
||||||
|
flag,
|
||||||
|
getNeighborFlags,
|
||||||
|
isGameOver,
|
||||||
|
getHasWon,
|
||||||
|
} = useGameStore();
|
||||||
|
|
||||||
let content: ReactNode = "";
|
let content: ReactNode = "";
|
||||||
|
|
||||||
if (game.isRevealed[x][y]) {
|
if (isRevealed[x][y]) {
|
||||||
content = game.isMine(x, y) ? <Bomb /> : game.getValue(x, y).toString();
|
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" />;
|
content = <Flag fill="red" />;
|
||||||
}
|
}
|
||||||
if (content === "0") content = "";
|
if (content === "0") content = "";
|
||||||
if (
|
if (
|
||||||
|
import.meta.env.DEV &&
|
||||||
window.location.href.includes("xray") &&
|
window.location.href.includes("xray") &&
|
||||||
game.isMine(x, y) &&
|
isMine(x, y) &&
|
||||||
!game.isFlagged[x][y]
|
!isFlagged[x][y]
|
||||||
)
|
)
|
||||||
content = <Bomb />;
|
content = <Bomb />;
|
||||||
|
|
||||||
|
const touchStart = useRef<number>(0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="mine-button"
|
className="mine-button"
|
||||||
|
{...attrs}
|
||||||
style={{
|
style={{
|
||||||
background: game.isRevealed[x][y] ? "#444" : undefined,
|
background: isRevealed[x][y] ? "#444" : undefined,
|
||||||
borderRight: !game.isRevealed[x][y] ? "3px solid black" : undefined,
|
borderRight: !isRevealed[x][y] ? "3px solid black" : undefined,
|
||||||
borderTop: !game.isRevealed[x][y] ? "3px solid #999" : undefined,
|
borderTop: !isRevealed[x][y] ? "3px solid #999" : undefined,
|
||||||
borderLeft: !game.isRevealed[x][y] ? "3px solid #999" : undefined,
|
borderLeft: !isRevealed[x][y] ? "3px solid #999" : undefined,
|
||||||
borderBottom: !game.isRevealed[x][y] ? "3px solid black" : undefined,
|
borderBottom: !isRevealed[x][y] ? "3px solid black" : undefined,
|
||||||
color: game.isRevealed[x][y]
|
color: isRevealed[x][y]
|
||||||
? colorMap[String(content)] ?? "#eee"
|
? colorMap[String(content)] ?? "#eee"
|
||||||
: undefined,
|
: undefined,
|
||||||
fontSize: Number(content) > 0 ? "1.75rem" : 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) => {
|
onMouseUp={(e) => {
|
||||||
if (game.getHasWon() || game.isGameOver) {
|
if (Date.now() - touchStart.current > 400 && !isRevealed[x][y]) {
|
||||||
|
flag(x, y);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (getHasWon() || isGameOver) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (e.button === 0) {
|
if (e.button === 0) {
|
||||||
// Left click
|
// Left click
|
||||||
if (!game.isRevealed[x][y] && !game.isFlagged[x][y]) {
|
if (isFlagged[x][y]) return;
|
||||||
game.reveal(x, y);
|
if (!isRevealed[x][y]) {
|
||||||
|
reveal(x, y);
|
||||||
} else {
|
} else {
|
||||||
const neighborFlagCount = game
|
const neighborFlagCount = getNeighborFlags(x, y).filter(
|
||||||
.getNeighborFlags(x, y)
|
(n) => n,
|
||||||
.filter((n) => n).length;
|
).length;
|
||||||
const value = game.getValue(x, y);
|
const value = getValue(x, y);
|
||||||
if (neighborFlagCount === value) {
|
if (neighborFlagCount === value) {
|
||||||
const currentStage = game.stage;
|
if (!isFlagged[x - 1]?.[y]) if (reveal(x - 1, y)) return;
|
||||||
if (!game.isFlagged[x - 1]?.[y] && currentStage == game.stage)
|
if (!isFlagged[x - 1]?.[y - 1]) if (reveal(x - 1, y - 1)) return;
|
||||||
game.reveal(x - 1, y);
|
if (!isFlagged[x - 1]?.[y + 1]) if (reveal(x - 1, y + 1)) return;
|
||||||
if (!game.isFlagged[x - 1]?.[y - 1] && currentStage == game.stage)
|
if (!isFlagged[x]?.[y - 1]) if (reveal(x, y - 1)) return;
|
||||||
game.reveal(x - 1, y - 1);
|
if (!isFlagged[x]?.[y + 1]) if (reveal(x, y + 1)) return;
|
||||||
if (!game.isFlagged[x - 1]?.[y + 1] && currentStage == game.stage)
|
if (!isFlagged[x + 1]?.[y - 1]) if (reveal(x + 1, y - 1)) return;
|
||||||
game.reveal(x - 1, y + 1);
|
if (!isFlagged[x + 1]?.[y]) if (reveal(x + 1, y)) return;
|
||||||
if (!game.isFlagged[x]?.[y - 1] && currentStage == game.stage)
|
if (!isFlagged[x + 1]?.[y + 1]) if (reveal(x + 1, y + 1)) return;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (e.button === 2 && !game.isRevealed[x][y]) {
|
} else if (e.button === 2 && !isRevealed[x][y]) {
|
||||||
game.flag(x, y);
|
flag(x, y);
|
||||||
}
|
}
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
|
import { newGame } from "./ws";
|
||||||
|
|
||||||
interface GameState {
|
interface GameState {
|
||||||
mines: boolean[][];
|
mines: boolean[][];
|
||||||
|
|
@ -14,7 +15,7 @@ interface GameState {
|
||||||
|
|
||||||
initializeGame: (width: number, height: number, mines: number) => void;
|
initializeGame: (width: number, height: number, mines: number) => void;
|
||||||
flag: (x: number, y: 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;
|
getValue: (x: number, y: number) => number;
|
||||||
getHasWon: () => boolean;
|
getHasWon: () => boolean;
|
||||||
getMinesLeft: () => number;
|
getMinesLeft: () => number;
|
||||||
|
|
@ -27,7 +28,7 @@ interface GameState {
|
||||||
getWidth: () => number;
|
getWidth: () => number;
|
||||||
getHeight: () => number;
|
getHeight: () => number;
|
||||||
isTouched: () => boolean;
|
isTouched: () => boolean;
|
||||||
triggerPostGame: () => void;
|
triggerPostGame: () => boolean;
|
||||||
expandBoard: () => void;
|
expandBoard: () => void;
|
||||||
setName: (name: string) => void;
|
setName: (name: string) => void;
|
||||||
}
|
}
|
||||||
|
|
@ -91,13 +92,14 @@ const useGameStore = create<GameState>((set, get) => ({
|
||||||
|
|
||||||
reveal: (x, y) => {
|
reveal: (x, y) => {
|
||||||
const { mines, isRevealed, isGameOver, getValue, triggerPostGame } = get();
|
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];
|
const newRevealed = [...isRevealed];
|
||||||
newRevealed[x][y] = true;
|
newRevealed[x][y] = true;
|
||||||
|
|
||||||
if (mines[x][y]) {
|
if (mines[x][y]) {
|
||||||
set({ isGameOver: true, isRevealed: newRevealed });
|
set({ isGameOver: true, isRevealed: newRevealed });
|
||||||
|
return true;
|
||||||
} else {
|
} else {
|
||||||
set({ isRevealed: newRevealed });
|
set({ isRevealed: newRevealed });
|
||||||
const value = getValue(x, y);
|
const value = getValue(x, y);
|
||||||
|
|
@ -121,7 +123,7 @@ const useGameStore = create<GameState>((set, get) => ({
|
||||||
revealNeighbors(x + 1, y + 1);
|
revealNeighbors(x + 1, y + 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
triggerPostGame();
|
return triggerPostGame();
|
||||||
},
|
},
|
||||||
|
|
||||||
getValue: (x, y) => {
|
getValue: (x, y) => {
|
||||||
|
|
@ -176,6 +178,8 @@ const useGameStore = create<GameState>((set, get) => ({
|
||||||
return x >= 0 && x < width && y >= 0 && y < height;
|
return x >= 0 && x < width && y >= 0 && y < height;
|
||||||
},
|
},
|
||||||
resetGame: (width: number, height: number, mines: number) => {
|
resetGame: (width: number, height: number, mines: number) => {
|
||||||
|
const { name } = get();
|
||||||
|
newGame(name);
|
||||||
if (mines > width * height) {
|
if (mines > width * height) {
|
||||||
throw new Error("Too many mines");
|
throw new Error("Too many mines");
|
||||||
}
|
}
|
||||||
|
|
@ -263,11 +267,16 @@ const useGameStore = create<GameState>((set, get) => ({
|
||||||
const { getHasWon, expandBoard } = get();
|
const { getHasWon, expandBoard } = get();
|
||||||
if (getHasWon()) {
|
if (getHasWon()) {
|
||||||
expandBoard();
|
expandBoard();
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
return false;
|
||||||
},
|
},
|
||||||
expandBoard: () => {
|
expandBoard: () => {
|
||||||
const { width, height, stage, mines, isFlagged, isRevealed } = get();
|
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
|
// Expand the board by the current board size 8x8 -> 16x8
|
||||||
if (dir === "down") {
|
if (dir === "down") {
|
||||||
const newHeight = Math.floor(height * 1.5);
|
const newHeight = Math.floor(height * 1.5);
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ body {
|
||||||
margin: auto;
|
margin: auto;
|
||||||
max-width: 1400px;
|
max-width: 1400px;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
|
font-family: monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
.timer {
|
.timer {
|
||||||
|
|
@ -63,3 +64,53 @@ pre {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-family: monospace;
|
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 { createRoot } from "react-dom/client";
|
||||||
import App from "./App.tsx";
|
import App from "./App.tsx";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
import { connectWS } from "./ws.ts";
|
||||||
|
import { Toaster } from "react-hot-toast";
|
||||||
|
|
||||||
document.addEventListener("contextmenu", (event) => {
|
document.addEventListener("contextmenu", (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
connectWS();
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
|
<Toaster position="top-right" reverseOrder={false} />
|
||||||
<App />
|
<App />
|
||||||
</StrictMode>,
|
</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": {
|
"compilerOptions": {
|
||||||
"target": "ES2022",
|
// Enable latest features
|
||||||
"lib": ["ES2023"],
|
"lib": ["ESNext", "DOM"],
|
||||||
|
"target": "ESNext",
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"skipLibCheck": true,
|
"moduleDetection": "force",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"allowJs": true,
|
||||||
|
|
||||||
/* Bundler mode */
|
// Bundler mode
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"allowImportingTsExtensions": true,
|
"allowImportingTsExtensions": true,
|
||||||
"isolatedModules": true,
|
"verbatimModuleSyntax": true,
|
||||||
"moduleDetection": "force",
|
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
|
|
||||||
/* Linting */
|
// Best practices
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noUnusedLocals": true,
|
"skipLibCheck": true,
|
||||||
"noUnusedParameters": true,
|
"noFallthroughCasesInSwitch": 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