|
|
@ -1,4 +1,5 @@
|
|||
# Logs
|
||||
.env
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
|
@ -24,3 +25,6 @@ temp_dbs
|
|||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
deploy.sh
|
||||
sqlite.db
|
||||
|
|
|
|||
29
README.md
|
|
@ -1,11 +1,32 @@
|
|||
# Minesweeper
|
||||
# 💣 Business Minesweeper
|
||||
|
||||
A simple version of minesweeper built with react in about 1h.
|
||||
This is a version of minesweeper with a expanding board after each stage. This also includes a account system with match history, spectating live matches and collectables.
|
||||
|
||||

|
||||
## 🚀 Local Development
|
||||
|
||||
## Ideas
|
||||
For local development you are required to have [bun](https://bun.sh/) installed.
|
||||
|
||||
```bash
|
||||
# Create a .env file for token signing
|
||||
echo "SECRET=SOME_RANDOM_STRING" > .env
|
||||
bun install
|
||||
bun run drizzle:migrate
|
||||
bun dev
|
||||
```
|
||||
|
||||
## 📦 Used Libraries
|
||||
|
||||
- [Pixi.js](https://github.com/pixijs/pixi-react)
|
||||
- [PixiViewport](https://github.com/davidfig/pixi-viewport)
|
||||
- [Tanstack Query](https://github.com/TanStack/query)
|
||||
- [Zod](https://github.com/colinhacks/zod)
|
||||
- [Drizzle ORM](https://github.com/drizzle-team/drizzle-orm)
|
||||
- [Tailwind CSS v4](https://github.com/tailwindlabs/tailwindcss)
|
||||
- [React](https://github.com/facebook/react)
|
||||
|
||||
## 📋 Ideas
|
||||
|
||||
- Add global big board
|
||||
- Questinmark after flag
|
||||
- Earn points for wins
|
||||
- Powerups
|
||||
|
|
|
|||
|
|
@ -1,27 +1,30 @@
|
|||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import type { ServerWebSocket } from "bun";
|
||||
import type { BunSQLiteDatabase } from "drizzle-orm/bun-sqlite";
|
||||
import type { z } from "zod";
|
||||
import type { z, ZodType } from "zod";
|
||||
|
||||
interface RequestContext {
|
||||
user?: string;
|
||||
db: BunSQLiteDatabase;
|
||||
ws: ServerWebSocket<unknown>;
|
||||
}
|
||||
|
||||
export type Endpoint<TInput, TResponse> = {
|
||||
validate: z.ZodType<TInput>;
|
||||
handler: (input: TInput, context: RequestContext) => Promise<TResponse>;
|
||||
export type Endpoint<TInputSchema extends ZodType, TResponse> = {
|
||||
validate: TInputSchema;
|
||||
handler: (
|
||||
input: z.infer<TInputSchema>,
|
||||
context: RequestContext,
|
||||
) => Promise<TResponse>;
|
||||
};
|
||||
|
||||
export type Request<TEndpoint extends Endpoint<any, any>> = {
|
||||
method: "POST";
|
||||
url: string;
|
||||
body: z.infer<TEndpoint["validate"]>;
|
||||
};
|
||||
|
||||
export const createEndpoint = <TInput, TResponse>(
|
||||
validate: z.ZodType<TInput>,
|
||||
export const createEndpoint = <
|
||||
TInputSchema extends ZodType,
|
||||
TResponse,
|
||||
TInput = z.infer<TInputSchema>,
|
||||
>(
|
||||
validate: TInputSchema,
|
||||
handler: (input: TInput, context: RequestContext) => Promise<TResponse>,
|
||||
): Endpoint<TInput, TResponse> => {
|
||||
): Endpoint<TInputSchema, TResponse> => {
|
||||
return { validate, handler };
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,33 +1,42 @@
|
|||
import { z } from "zod";
|
||||
import { createController, createEndpoint } from "./controller";
|
||||
import { getGame, upsertGameState } from "../repositories/gameRepository";
|
||||
import {
|
||||
serverGame,
|
||||
serverToClientGame,
|
||||
type ServerGame,
|
||||
} from "../../shared/game";
|
||||
getCurrentGame,
|
||||
getGame,
|
||||
getGames,
|
||||
getTotalGamesPlayed,
|
||||
parseGameState,
|
||||
upsertGameState,
|
||||
} from "../repositories/gameRepository";
|
||||
import { serverToClientGame, type ServerGame } from "../../shared/game";
|
||||
import crypto from "crypto";
|
||||
import { game } from "../entities/game";
|
||||
import { UnauthorizedError } from "../errors/UnauthorizedError";
|
||||
import { emit } from "../events";
|
||||
import { emit, emitToWS } from "../events";
|
||||
import { serverGame } from "../../shared/gameType";
|
||||
import { pickRandom } from "../../shared/utils";
|
||||
import { addGems } from "../repositories/gemsRepository";
|
||||
import { getCollection } from "../repositories/collectionRepository";
|
||||
|
||||
export const gameController = createController({
|
||||
getGameState: createEndpoint(z.string(), async (uuid, ctx) => {
|
||||
const game = await getGame(ctx.db, uuid);
|
||||
const parsed = JSON.parse(game.gameState);
|
||||
const parsed = parseGameState(game.gameState);
|
||||
const gameState = await serverGame.parseAsync(parsed);
|
||||
if (game.finished) return gameState;
|
||||
return serverToClientGame(gameState);
|
||||
}),
|
||||
createGame: createEndpoint(z.undefined(), async (_, { user, db }) => {
|
||||
createGame: createEndpoint(z.null(), async (_, { user, db }) => {
|
||||
if (!user) throw new UnauthorizedError("Unauthorized");
|
||||
const uuid = crypto.randomUUID() as string;
|
||||
const collection = await getCollection(db, user);
|
||||
const newGame: ServerGame = game.createGame({
|
||||
uuid,
|
||||
user: user,
|
||||
mines: 2,
|
||||
width: 4,
|
||||
height: 4,
|
||||
theme: pickRandom(collection.entries.filter((e) => e.selected)).id,
|
||||
});
|
||||
upsertGameState(db, newGame);
|
||||
emit({
|
||||
|
|
@ -42,4 +51,145 @@ export const gameController = createController({
|
|||
});
|
||||
return newGame;
|
||||
}),
|
||||
reveal: createEndpoint(
|
||||
z.object({ x: z.number(), y: z.number() }),
|
||||
async ({ x, y }, { db, user, ws }) => {
|
||||
if (!user) throw new UnauthorizedError("Unauthorized");
|
||||
const dbGame = await getCurrentGame(db, user);
|
||||
const serverGame = parseGameState(dbGame.gameState);
|
||||
const ts = serverGame.finished;
|
||||
game.reveal(serverGame, x, y, true);
|
||||
await upsertGameState(db, serverGame);
|
||||
emit({
|
||||
type: "updateGame",
|
||||
game: dbGame.uuid,
|
||||
});
|
||||
if (ts === 0 && serverGame.finished !== 0) {
|
||||
emit({
|
||||
type: "loss",
|
||||
stage: serverGame.stage,
|
||||
user,
|
||||
time: serverGame.finished - serverGame.started,
|
||||
});
|
||||
const reward = game.getRewards(serverGame);
|
||||
emitToWS(
|
||||
{
|
||||
type: "gemsRewarded",
|
||||
stage: serverGame.stage,
|
||||
gems: reward,
|
||||
},
|
||||
ws,
|
||||
);
|
||||
await addGems(db, user, reward);
|
||||
}
|
||||
},
|
||||
),
|
||||
placeFlag: createEndpoint(
|
||||
z.object({ x: z.number(), y: z.number() }),
|
||||
async ({ x, y }, { db, user, ws }) => {
|
||||
if (!user) throw new UnauthorizedError("Unauthorized");
|
||||
const dbGame = await getCurrentGame(db, user);
|
||||
const serverGame = parseGameState(dbGame.gameState);
|
||||
const ts = serverGame.finished;
|
||||
game.placeFlag(serverGame, x, y);
|
||||
await upsertGameState(db, serverGame);
|
||||
emit({
|
||||
type: "updateGame",
|
||||
game: dbGame.uuid,
|
||||
});
|
||||
if (ts === 0 && serverGame.finished !== 0) {
|
||||
emit({
|
||||
type: "loss",
|
||||
stage: serverGame.stage,
|
||||
user,
|
||||
time: serverGame.finished - serverGame.started,
|
||||
});
|
||||
const reward = game.getRewards(serverGame);
|
||||
emitToWS(
|
||||
{
|
||||
type: "gemsRewarded",
|
||||
stage: serverGame.stage,
|
||||
gems: reward,
|
||||
},
|
||||
ws,
|
||||
);
|
||||
await addGems(db, user, reward);
|
||||
}
|
||||
},
|
||||
),
|
||||
placeQuestionMark: createEndpoint(
|
||||
z.object({ x: z.number(), y: z.number() }),
|
||||
async ({ x, y }, { db, user }) => {
|
||||
if (!user) throw new UnauthorizedError("Unauthorized");
|
||||
const dbGame = await getCurrentGame(db, user);
|
||||
const serverGame = parseGameState(dbGame.gameState);
|
||||
game.placeQuestionMark(serverGame, x, y);
|
||||
await upsertGameState(db, serverGame);
|
||||
emit({
|
||||
type: "updateGame",
|
||||
game: dbGame.uuid,
|
||||
});
|
||||
},
|
||||
),
|
||||
clearTile: createEndpoint(
|
||||
z.object({ x: z.number(), y: z.number() }),
|
||||
async ({ x, y }, { db, user, ws }) => {
|
||||
if (!user) throw new UnauthorizedError("Unauthorized");
|
||||
const dbGame = await getCurrentGame(db, user);
|
||||
const serverGame = parseGameState(dbGame.gameState);
|
||||
const ts = serverGame.finished;
|
||||
game.clearTile(serverGame, x, y);
|
||||
upsertGameState(db, serverGame);
|
||||
emit({
|
||||
type: "updateGame",
|
||||
game: dbGame.uuid,
|
||||
});
|
||||
if (ts === 0 && serverGame.finished !== 0) {
|
||||
emit({
|
||||
type: "loss",
|
||||
stage: serverGame.stage,
|
||||
user,
|
||||
time: serverGame.finished - serverGame.started,
|
||||
});
|
||||
const reward = game.getRewards(serverGame);
|
||||
emitToWS(
|
||||
{
|
||||
type: "gemsRewarded",
|
||||
stage: serverGame.stage,
|
||||
gems: reward,
|
||||
},
|
||||
ws,
|
||||
);
|
||||
await addGems(db, user, reward);
|
||||
}
|
||||
},
|
||||
),
|
||||
getGames: createEndpoint(
|
||||
z.object({
|
||||
page: z.number().default(0),
|
||||
user: z.string(),
|
||||
}),
|
||||
async ({ page, user }, { db }) => {
|
||||
const perPage = 20;
|
||||
const offset = page * perPage;
|
||||
const games = await getGames(db, user);
|
||||
const parsedGames = games
|
||||
.slice(offset, offset + perPage)
|
||||
.map((game) => parseGameState(game.gameState));
|
||||
const isLastPage = games.length <= offset + perPage;
|
||||
return {
|
||||
data: parsedGames,
|
||||
nextPage: isLastPage ? undefined : page + 1,
|
||||
};
|
||||
},
|
||||
),
|
||||
getTotalGamesPlayed: createEndpoint(
|
||||
z.object({
|
||||
user: z.string().optional(),
|
||||
}),
|
||||
async ({ user }, { db }) => {
|
||||
const total = await getTotalGamesPlayed(db, user);
|
||||
return total;
|
||||
},
|
||||
),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
import { z } from "zod";
|
||||
import { createController, createEndpoint } from "./controller";
|
||||
import { getScoreBoard } from "../repositories/scoreRepository";
|
||||
|
||||
export const scoreboardController = createController({
|
||||
getScoreBoard: createEndpoint(z.number(), async (limit, { db }) => {
|
||||
return (await getScoreBoard(db)).slice(0, limit);
|
||||
}),
|
||||
});
|
||||
|
|
@ -0,0 +1,170 @@
|
|||
import { z } from "zod";
|
||||
import { createController, createEndpoint } from "./controller";
|
||||
import {
|
||||
getUserCount,
|
||||
getUserSettings,
|
||||
loginUser,
|
||||
registerUser,
|
||||
upsertUserSettings,
|
||||
} from "../repositories/userRepository";
|
||||
import crypto from "crypto";
|
||||
import { resetSessionUser, setSessionUser } from "../router";
|
||||
import { userSettings } from "../../shared/user-settings";
|
||||
import { UnauthorizedError } from "../errors/UnauthorizedError";
|
||||
import { getGems, removeGems } from "../repositories/gemsRepository";
|
||||
import {
|
||||
getCollection,
|
||||
upsertCollection,
|
||||
} from "../repositories/collectionRepository";
|
||||
import { getWeight, lootboxes } from "../../shared/lootboxes";
|
||||
import { weightedPickRandom } from "../../shared/utils";
|
||||
import { emit } from "../events";
|
||||
|
||||
const secret = process.env.SECRET!;
|
||||
|
||||
const signString = (payload: string) => {
|
||||
return crypto.createHmac("sha256", secret).update(payload).digest("hex");
|
||||
};
|
||||
|
||||
export const userController = createController({
|
||||
getSelf: createEndpoint(z.null(), async (_, { user }) => {
|
||||
return user || null;
|
||||
}),
|
||||
login: createEndpoint(
|
||||
z.object({ username: z.string(), password: z.string() }),
|
||||
async (input, { db, ws }) => {
|
||||
const { name: user } = await loginUser(
|
||||
db,
|
||||
input.username,
|
||||
input.password,
|
||||
);
|
||||
const session = { user, expires: Date.now() + 1000 * 60 * 60 * 24 * 14 };
|
||||
const sig = signString(JSON.stringify(session));
|
||||
setSessionUser(ws, user);
|
||||
return { token: JSON.stringify({ session, sig }) };
|
||||
},
|
||||
),
|
||||
loginWithToken: createEndpoint(
|
||||
z.object({ token: z.string() }),
|
||||
async (input, { ws }) => {
|
||||
const { session, sig } = JSON.parse(input.token);
|
||||
const { user } = session;
|
||||
if (sig !== signString(JSON.stringify(session))) {
|
||||
return { success: false };
|
||||
}
|
||||
if (Date.now() > session.expires) {
|
||||
return { success: false };
|
||||
}
|
||||
setSessionUser(ws, user);
|
||||
return { success: true };
|
||||
},
|
||||
),
|
||||
logout: createEndpoint(z.null(), async (_, { ws }) => {
|
||||
resetSessionUser(ws);
|
||||
}),
|
||||
register: createEndpoint(
|
||||
z.object({
|
||||
username: z
|
||||
.string()
|
||||
.min(3, "Username must be at least 3 characters")
|
||||
.max(15, "Username cannot be longer than 15 characters"),
|
||||
password: z.string().min(6, "Password must be at least 6 characters"),
|
||||
}),
|
||||
async (input, { db, ws }) => {
|
||||
await registerUser(db, input.username, input.password);
|
||||
const user = input.username;
|
||||
const session = { user, expires: Date.now() + 1000 * 60 * 60 * 24 * 14 };
|
||||
const sig = signString(JSON.stringify(session));
|
||||
setSessionUser(ws, user);
|
||||
return { token: JSON.stringify({ session, sig }) };
|
||||
},
|
||||
),
|
||||
getSettings: createEndpoint(z.null(), async (_, { db, user }) => {
|
||||
if (!user) throw new UnauthorizedError("Unauthorized");
|
||||
const settings = await getUserSettings(db, user);
|
||||
return settings;
|
||||
}),
|
||||
updateSettings: createEndpoint(userSettings, async (input, { db, user }) => {
|
||||
if (!user) throw new UnauthorizedError("Unauthorized");
|
||||
const settings = await getUserSettings(db, user);
|
||||
const newSettings = { ...settings, ...input };
|
||||
await upsertUserSettings(db, user, input);
|
||||
return newSettings;
|
||||
}),
|
||||
getUserCount: createEndpoint(z.null(), async (_, { db }) => {
|
||||
const count = await getUserCount(db);
|
||||
return count;
|
||||
}),
|
||||
getOwnGems: createEndpoint(z.null(), async (_, { db, user }) => {
|
||||
if (!user) throw new UnauthorizedError("Unauthorized");
|
||||
const gems = await getGems(db, user);
|
||||
return gems;
|
||||
}),
|
||||
getOwnCollection: createEndpoint(z.null(), async (_, { db, user }) => {
|
||||
if (!user) throw new UnauthorizedError("Unauthorized");
|
||||
const collection = await getCollection(db, user);
|
||||
return collection;
|
||||
}),
|
||||
selectCollectionEntry: createEndpoint(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
async ({ id }, { db, user }) => {
|
||||
if (!user) throw new UnauthorizedError("Unauthorized");
|
||||
const collection = await getCollection(db, user);
|
||||
if (!collection.entries.some((e) => e.id === id)) {
|
||||
throw new Error("Entry not found");
|
||||
}
|
||||
for (const entry of collection.entries) {
|
||||
entry.selected = entry.id === id;
|
||||
}
|
||||
await upsertCollection(db, user, collection);
|
||||
},
|
||||
),
|
||||
addCollectionEntryToShuffle: createEndpoint(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
async ({ id }, { db, user }) => {
|
||||
if (!user) throw new UnauthorizedError("Unauthorized");
|
||||
const collection = await getCollection(db, user);
|
||||
const entry = collection.entries.find((e) => e.id === id);
|
||||
if (!entry) {
|
||||
throw new Error("Entry not found");
|
||||
}
|
||||
entry.selected = true;
|
||||
await upsertCollection(db, user, collection);
|
||||
},
|
||||
),
|
||||
openLootbox: createEndpoint(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
async ({ id }, { db, user }) => {
|
||||
if (!user) throw new UnauthorizedError("Unauthorized");
|
||||
const collection = await getCollection(db, user);
|
||||
const lootbox = lootboxes.find((l) => l.id === id);
|
||||
if (!lootbox) {
|
||||
throw new Error("Lootbox not found");
|
||||
}
|
||||
await removeGems(db, user, lootbox.price);
|
||||
const result = weightedPickRandom(lootbox.items, (i) =>
|
||||
getWeight(i.rarity),
|
||||
);
|
||||
console.log(result);
|
||||
collection.entries.push({
|
||||
id: result.id,
|
||||
aquired: Date.now(),
|
||||
selected: false,
|
||||
});
|
||||
await upsertCollection(db, user, collection);
|
||||
emit({
|
||||
type: "lootboxPurchased",
|
||||
user,
|
||||
lootbox: lootbox.id,
|
||||
reward: result.id,
|
||||
rarity: result.rarity,
|
||||
});
|
||||
},
|
||||
),
|
||||
});
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
CREATE TABLE `games` (
|
||||
`uuid` text PRIMARY KEY NOT NULL,
|
||||
`user` text NOT NULL,
|
||||
`gameState` blob 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
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `userSettings` (
|
||||
`user` text PRIMARY KEY NOT NULL,
|
||||
`settings` text NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `user_idx` ON `games` (`user`);--> statement-breakpoint
|
||||
CREATE INDEX `started_idx` ON `games` (`timestamp`);--> statement-breakpoint
|
||||
CREATE INDEX `user_started_idx` ON `games` (`user`,`timestamp`);--> statement-breakpoint
|
||||
CREATE INDEX `full_idx` ON `games` (`user`,`timestamp`,`uuid`);
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
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,10 @@
|
|||
CREATE TABLE `collection` (
|
||||
`user` text PRIMARY KEY NOT NULL,
|
||||
`collection` blob NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `gems` (
|
||||
`user` text PRIMARY KEY NOT NULL,
|
||||
`count` integer NOT NULL,
|
||||
`totalCount` integer NOT NULL
|
||||
);
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "af6e3102-34d0-4247-84ae-14f2d3d8fa4c",
|
||||
"id": "2c470a78-d3d6-49b7-910c-eb8156e58a2c",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"tables": {
|
||||
"games": {
|
||||
|
|
@ -23,7 +23,7 @@
|
|||
},
|
||||
"gameState": {
|
||||
"name": "gameState",
|
||||
"type": "text",
|
||||
"type": "blob",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
|
|
@ -51,7 +51,39 @@
|
|||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"indexes": {
|
||||
"user_idx": {
|
||||
"name": "user_idx",
|
||||
"columns": [
|
||||
"user"
|
||||
],
|
||||
"isUnique": false
|
||||
},
|
||||
"started_idx": {
|
||||
"name": "started_idx",
|
||||
"columns": [
|
||||
"timestamp"
|
||||
],
|
||||
"isUnique": false
|
||||
},
|
||||
"user_started_idx": {
|
||||
"name": "user_started_idx",
|
||||
"columns": [
|
||||
"user",
|
||||
"timestamp"
|
||||
],
|
||||
"isUnique": false
|
||||
},
|
||||
"full_idx": {
|
||||
"name": "full_idx",
|
||||
"columns": [
|
||||
"user",
|
||||
"timestamp",
|
||||
"uuid"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"games_user_users_name_fk": {
|
||||
"name": "games_user_users_name_fk",
|
||||
|
|
@ -92,6 +124,29 @@
|
|||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"userSettings": {
|
||||
"name": "userSettings",
|
||||
"columns": {
|
||||
"user": {
|
||||
"name": "user",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"settings": {
|
||||
"name": "settings",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {}
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,214 @@
|
|||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "7347c405-254d-4a1f-9196-47b2935f1733",
|
||||
"prevId": "2c470a78-d3d6-49b7-910c-eb8156e58a2c",
|
||||
"tables": {
|
||||
"collection": {
|
||||
"name": "collection",
|
||||
"columns": {
|
||||
"user": {
|
||||
"name": "user",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"collection": {
|
||||
"name": "collection",
|
||||
"type": "blob",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"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": "blob",
|
||||
"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": {
|
||||
"user_idx": {
|
||||
"name": "user_idx",
|
||||
"columns": [
|
||||
"user"
|
||||
],
|
||||
"isUnique": false
|
||||
},
|
||||
"started_idx": {
|
||||
"name": "started_idx",
|
||||
"columns": [
|
||||
"timestamp"
|
||||
],
|
||||
"isUnique": false
|
||||
},
|
||||
"user_started_idx": {
|
||||
"name": "user_started_idx",
|
||||
"columns": [
|
||||
"user",
|
||||
"timestamp"
|
||||
],
|
||||
"isUnique": false
|
||||
},
|
||||
"full_idx": {
|
||||
"name": "full_idx",
|
||||
"columns": [
|
||||
"user",
|
||||
"timestamp",
|
||||
"uuid"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"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": {}
|
||||
},
|
||||
"gems": {
|
||||
"name": "gems",
|
||||
"columns": {
|
||||
"user": {
|
||||
"name": "user",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"count": {
|
||||
"name": "count",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"totalCount": {
|
||||
"name": "totalCount",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"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": {}
|
||||
},
|
||||
"userSettings": {
|
||||
"name": "userSettings",
|
||||
"columns": {
|
||||
"user": {
|
||||
"name": "user",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"settings": {
|
||||
"name": "settings",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {}
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
|
|
@ -5,8 +5,15 @@
|
|||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1726774158116,
|
||||
"tag": "0000_nostalgic_next_avengers",
|
||||
"when": 1727551167145,
|
||||
"tag": "0000_gigantic_wasp",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "6",
|
||||
"when": 1728834181598,
|
||||
"tag": "0001_breezy_martin_li",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { getValue } from "../../shared/game";
|
||||
import type { ServerGame } from "../../shared/game";
|
||||
|
||||
interface CreateGameOptions {
|
||||
|
|
@ -6,8 +7,172 @@ interface CreateGameOptions {
|
|||
width: number;
|
||||
height: number;
|
||||
mines: number;
|
||||
theme: string;
|
||||
}
|
||||
|
||||
const isValid = (game: ServerGame, x: number, y: number) => {
|
||||
const { width, height } = game;
|
||||
return x >= 0 && x < width && y >= 0 && y < height;
|
||||
};
|
||||
|
||||
const getNeighborFlagCount = (game: ServerGame, x: number, y: number) => {
|
||||
const { isFlagged } = game;
|
||||
const neighbors = [
|
||||
isFlagged[x - 1]?.[y - 1],
|
||||
isFlagged[x]?.[y - 1],
|
||||
isFlagged[x + 1]?.[y - 1],
|
||||
isFlagged[x - 1]?.[y],
|
||||
isFlagged[x + 1]?.[y],
|
||||
isFlagged[x - 1]?.[y + 1],
|
||||
isFlagged[x]?.[y + 1],
|
||||
isFlagged[x + 1]?.[y + 1],
|
||||
];
|
||||
return neighbors.filter((n) => n).length;
|
||||
};
|
||||
|
||||
const hasWon = (serverGame: ServerGame) => {
|
||||
const { mines, isRevealed, isFlagged, finished, width, height } = serverGame;
|
||||
if (finished) return false;
|
||||
|
||||
for (let i = 0; i < width; i++) {
|
||||
for (let j = 0; j < height; j++) {
|
||||
if (!isRevealed[i][j] && !isFlagged[i][j]) return false;
|
||||
if (mines[i][j] && !isFlagged[i][j]) return false;
|
||||
if (isFlagged[i][j] && !mines[i][j]) return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const expandBoard = (serverGame: ServerGame) => {
|
||||
const { width, height, stage, mines, isFlagged, isRevealed, isQuestionMark } =
|
||||
serverGame;
|
||||
let dir = stage % 2 === 0 ? "down" : "right";
|
||||
if (stage > 13) {
|
||||
dir = "down";
|
||||
}
|
||||
// Expand the board by the current board size 8x8 -> 16x8
|
||||
if (dir === "down") {
|
||||
const newHeight = Math.floor(Math.min(height + 7, height * 1.5));
|
||||
const newWidth = width;
|
||||
const newMinesCount = Math.floor(
|
||||
width * height * 0.5 * (0.2 + 0.0015 * stage),
|
||||
);
|
||||
// expand mines array
|
||||
const newMines = Array.from({ length: newWidth }, () =>
|
||||
new Array(newHeight).fill(false),
|
||||
);
|
||||
const newIsRevealed = Array.from({ length: newWidth }, () =>
|
||||
new Array(newHeight).fill(false),
|
||||
);
|
||||
const newIsFlagged = Array.from({ length: newWidth }, () =>
|
||||
new Array(newHeight).fill(false),
|
||||
);
|
||||
const newIsQuestionMark = Array.from({ length: newWidth }, () =>
|
||||
new Array(newHeight).fill(false),
|
||||
);
|
||||
for (let i = 0; i < newWidth; i++) {
|
||||
for (let j = 0; j < newHeight; j++) {
|
||||
const x = i;
|
||||
const y = j;
|
||||
if (mines[x]?.[y]) {
|
||||
newMines[i][j] = true;
|
||||
}
|
||||
if (isRevealed[x]?.[y]) {
|
||||
newIsRevealed[i][j] = true;
|
||||
}
|
||||
if (isFlagged[x]?.[y]) {
|
||||
newIsFlagged[i][j] = true;
|
||||
}
|
||||
if (isQuestionMark[x]?.[y]) {
|
||||
newIsQuestionMark[i][j] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
// generate new mines
|
||||
let remainingMines = newMinesCount;
|
||||
while (remainingMines > 0) {
|
||||
const x = Math.floor(Math.random() * width);
|
||||
const y = height + Math.floor(Math.random() * (newHeight - height));
|
||||
if (!newMines[x][y]) {
|
||||
newMines[x][y] = true;
|
||||
remainingMines--;
|
||||
}
|
||||
}
|
||||
Object.assign(serverGame, {
|
||||
width: newWidth,
|
||||
height: newHeight,
|
||||
mines: newMines,
|
||||
minesCount: newMinesCount,
|
||||
stage: stage + 1,
|
||||
isRevealed: newIsRevealed,
|
||||
isFlagged: newIsFlagged,
|
||||
isQuestionMark: newIsQuestionMark,
|
||||
});
|
||||
}
|
||||
if (dir === "right") {
|
||||
const newWidth = Math.floor(Math.min(width + 7, width * 1.5));
|
||||
const newHeight = height;
|
||||
const newMinesCount = Math.floor(
|
||||
width * height * 0.5 * (0.2 + 0.0015 * stage),
|
||||
);
|
||||
// expand mines array
|
||||
const newMines = Array.from({ length: newWidth }, () =>
|
||||
new Array(newHeight).fill(false),
|
||||
);
|
||||
const newIsRevealed = Array.from({ length: newWidth }, () =>
|
||||
new Array(newHeight).fill(false),
|
||||
);
|
||||
const newIsFlagged = Array.from({ length: newWidth }, () =>
|
||||
new Array(newHeight).fill(false),
|
||||
);
|
||||
const newIsQuestionMark = Array.from({ length: newWidth }, () =>
|
||||
new Array(newHeight).fill(false),
|
||||
);
|
||||
for (let i = 0; i < newWidth; i++) {
|
||||
for (let j = 0; j < newHeight; j++) {
|
||||
const x = i;
|
||||
const y = j;
|
||||
if (mines[x]?.[y]) {
|
||||
newMines[i][j] = true;
|
||||
}
|
||||
if (isRevealed[x]?.[y]) {
|
||||
newIsRevealed[i][j] = true;
|
||||
}
|
||||
if (isFlagged[x]?.[y]) {
|
||||
newIsFlagged[i][j] = true;
|
||||
}
|
||||
if (isQuestionMark[x]?.[y]) {
|
||||
newIsQuestionMark[i][j] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
// generate new mines
|
||||
let remainingMines = newMinesCount;
|
||||
while (remainingMines > 0) {
|
||||
const x = width + Math.floor(Math.random() * (newWidth - width));
|
||||
const y = Math.floor(Math.random() * height);
|
||||
if (!newMines[x][y]) {
|
||||
newMines[x][y] = true;
|
||||
remainingMines--;
|
||||
}
|
||||
}
|
||||
Object.assign(serverGame, {
|
||||
width: newWidth,
|
||||
height: newHeight,
|
||||
mines: newMines,
|
||||
minesCount: newMinesCount,
|
||||
stage: stage + 1,
|
||||
isRevealed: newIsRevealed,
|
||||
isFlagged: newIsFlagged,
|
||||
isQuestionMark: newIsQuestionMark,
|
||||
});
|
||||
}
|
||||
const newMinesCount = serverGame.mines.flat().filter((m) => m).length;
|
||||
Object.assign(serverGame, { minesCount: newMinesCount });
|
||||
};
|
||||
|
||||
export const game = {
|
||||
createGame: (options: CreateGameOptions): ServerGame => {
|
||||
const { uuid, user, width, height, mines } = options;
|
||||
|
|
@ -24,6 +189,9 @@ export const game = {
|
|||
const isFlaggedArray = Array.from({ length: width }, () =>
|
||||
new Array(height).fill(false),
|
||||
);
|
||||
const isQuestionMarkArray = Array.from({ length: width }, () =>
|
||||
new Array(height).fill(false),
|
||||
);
|
||||
|
||||
let remainingMines = mines;
|
||||
while (remainingMines > 0) {
|
||||
|
|
@ -45,9 +213,104 @@ export const game = {
|
|||
mines: minesArray,
|
||||
isRevealed: isRevealedArray,
|
||||
isFlagged: isFlaggedArray,
|
||||
isQuestionMark: isQuestionMarkArray,
|
||||
stage: 1,
|
||||
lastClick: [-1, -1],
|
||||
minesCount: mines,
|
||||
theme: options.theme,
|
||||
};
|
||||
},
|
||||
reveal: (serverGame: ServerGame, x: number, y: number, initial = false) => {
|
||||
const aux = (
|
||||
serverGame: ServerGame,
|
||||
x: number,
|
||||
y: number,
|
||||
initial: boolean = false,
|
||||
) => {
|
||||
const { mines, isRevealed, isFlagged, isQuestionMark, finished } =
|
||||
serverGame;
|
||||
if (finished) return;
|
||||
if (!isValid(serverGame, x, y)) return;
|
||||
if (isQuestionMark[x][y]) return;
|
||||
if (isFlagged[x][y]) return;
|
||||
serverGame.lastClick = [x, y];
|
||||
|
||||
if (mines[x][y]) {
|
||||
serverGame.finished = Date.now();
|
||||
return;
|
||||
}
|
||||
|
||||
const value = getValue(serverGame.mines, x, y);
|
||||
const neighborFlagCount = getNeighborFlagCount(serverGame, x, y);
|
||||
|
||||
if (isRevealed[x][y] && value === neighborFlagCount && initial) {
|
||||
if (!isFlagged[x - 1]?.[y]) aux(serverGame, x - 1, y);
|
||||
if (!isFlagged[x - 1]?.[y - 1]) aux(serverGame, x - 1, y - 1);
|
||||
if (!isFlagged[x - 1]?.[y + 1]) aux(serverGame, x - 1, y + 1);
|
||||
if (!isFlagged[x]?.[y - 1]) aux(serverGame, x, y - 1);
|
||||
if (!isFlagged[x]?.[y + 1]) aux(serverGame, x, y + 1);
|
||||
if (!isFlagged[x + 1]?.[y - 1]) aux(serverGame, x + 1, y - 1);
|
||||
if (!isFlagged[x + 1]?.[y]) aux(serverGame, x + 1, y);
|
||||
if (!isFlagged[x + 1]?.[y + 1]) aux(serverGame, x + 1, y + 1);
|
||||
}
|
||||
|
||||
serverGame.isRevealed[x][y] = true;
|
||||
|
||||
if (value === 0 && neighborFlagCount === 0) {
|
||||
const revealNeighbors = (nx: number, ny: number) => {
|
||||
if (isValid(serverGame, nx, ny) && !isRevealed[nx]?.[ny]) {
|
||||
aux(serverGame, nx, ny);
|
||||
}
|
||||
};
|
||||
|
||||
revealNeighbors(x - 1, y - 1);
|
||||
revealNeighbors(x, y - 1);
|
||||
revealNeighbors(x + 1, y - 1);
|
||||
revealNeighbors(x - 1, y);
|
||||
revealNeighbors(x + 1, y);
|
||||
revealNeighbors(x - 1, y + 1);
|
||||
revealNeighbors(x, y + 1);
|
||||
revealNeighbors(x + 1, y + 1);
|
||||
}
|
||||
};
|
||||
aux(serverGame, x, y, initial);
|
||||
if (hasWon(serverGame)) {
|
||||
expandBoard(serverGame);
|
||||
}
|
||||
},
|
||||
placeFlag: (serverGame: ServerGame, x: number, y: number) => {
|
||||
const { isRevealed, finished } = serverGame;
|
||||
if (finished) return;
|
||||
if (!isValid(serverGame, x, y)) return;
|
||||
if (isRevealed[x][y]) return;
|
||||
serverGame.isFlagged[x][y] = true;
|
||||
if (hasWon(serverGame)) {
|
||||
expandBoard(serverGame);
|
||||
}
|
||||
},
|
||||
placeQuestionMark: (serverGame: ServerGame, x: number, y: number) => {
|
||||
const { isRevealed, finished } = serverGame;
|
||||
if (finished) return;
|
||||
if (!isValid(serverGame, x, y)) return;
|
||||
if (isRevealed[x][y]) return;
|
||||
serverGame.isFlagged[x][y] = false;
|
||||
serverGame.isQuestionMark[x][y] = true;
|
||||
},
|
||||
clearTile: (serverGame: ServerGame, x: number, y: number) => {
|
||||
const { isRevealed, finished } = serverGame;
|
||||
if (finished) return;
|
||||
if (!isValid(serverGame, x, y)) return;
|
||||
if (isRevealed[x][y]) return;
|
||||
serverGame.isFlagged[x][y] = false;
|
||||
serverGame.isQuestionMark[x][y] = false;
|
||||
if (hasWon(serverGame)) {
|
||||
expandBoard(serverGame);
|
||||
}
|
||||
},
|
||||
getRewards: (serverGame: ServerGame) => {
|
||||
const { finished, stage } = serverGame;
|
||||
if (finished == 0) return 0;
|
||||
if (stage < 2) return 0;
|
||||
return Math.floor(Math.pow(2, stage * 0.93) + stage * 4 + 5);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,28 +1,5 @@
|
|||
import type { ClientGame } from "../shared/game";
|
||||
|
||||
export type EventType = "new" | "finished" | "updateGame" | "updateStage";
|
||||
|
||||
type Events =
|
||||
| {
|
||||
type: "new";
|
||||
user: string;
|
||||
}
|
||||
| {
|
||||
type: "loss";
|
||||
user: string;
|
||||
stage: number;
|
||||
}
|
||||
| {
|
||||
type: "updateGame";
|
||||
game: string;
|
||||
data: ClientGame;
|
||||
}
|
||||
| {
|
||||
type: "updateStage";
|
||||
game: string;
|
||||
stage: number;
|
||||
started: number;
|
||||
};
|
||||
import type { ServerWebSocket } from "bun";
|
||||
import type { Events } from "../shared/events";
|
||||
|
||||
const listeners = new Set<(event: Events) => void>();
|
||||
|
||||
|
|
@ -37,3 +14,7 @@ export const off = (listener: (event: Events) => void) => {
|
|||
export const emit = (event: Events) => {
|
||||
listeners.forEach((listener) => listener(event));
|
||||
};
|
||||
|
||||
export const emitToWS = (event: Events, ws: ServerWebSocket<unknown>) => {
|
||||
ws.send(JSON.stringify(event));
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { ServerWebSocket } from "bun";
|
||||
import { on } from "./events";
|
||||
import { handleRequest } from "./router";
|
||||
|
||||
const allowCors = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
|
|
@ -6,7 +7,6 @@ const allowCors = {
|
|||
"Access-Control-Allow-Headers": "Content-Type",
|
||||
};
|
||||
|
||||
const userName = new WeakMap<ServerWebSocket<unknown>, string>();
|
||||
const server = Bun.serve({
|
||||
async fetch(request: Request) {
|
||||
if (request.method === "OPTIONS") {
|
||||
|
|
@ -22,10 +22,10 @@ const server = Bun.serve({
|
|||
if (typeof message !== "string") {
|
||||
return;
|
||||
}
|
||||
const user = userName.get(ws);
|
||||
try {
|
||||
const msg = JSON.parse(message);
|
||||
console.log(msg);
|
||||
console.log("Received message", msg);
|
||||
handleRequest(msg, ws);
|
||||
} catch (e) {
|
||||
console.error("Faulty request", message, e);
|
||||
return;
|
||||
|
|
@ -35,5 +35,10 @@ const server = Bun.serve({
|
|||
ws.subscribe("minesweeper-global");
|
||||
},
|
||||
},
|
||||
port: 8076,
|
||||
port: 8072,
|
||||
});
|
||||
on((event) => {
|
||||
server.publish("minesweeper-global", JSON.stringify(event));
|
||||
});
|
||||
|
||||
console.log("Listening on port 8072");
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
import { eq } from "drizzle-orm";
|
||||
import { Collection, type CollectionType } from "../schema";
|
||||
import type { BunSQLiteDatabase } from "drizzle-orm/bun-sqlite";
|
||||
import { decode, encode } from "@msgpack/msgpack";
|
||||
import type { UserCollection } from "../../shared/gameType";
|
||||
|
||||
export const getCollection = async (
|
||||
db: BunSQLiteDatabase,
|
||||
user: string,
|
||||
): Promise<UserCollection> => {
|
||||
const res = (
|
||||
await db.select().from(Collection).where(eq(Collection.user, user))
|
||||
)[0];
|
||||
if (res) return parseCollection(res);
|
||||
return {
|
||||
entries: [
|
||||
{
|
||||
id: "default",
|
||||
aquired: Date.now(),
|
||||
selected: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
export const upsertCollection = async (
|
||||
db: BunSQLiteDatabase,
|
||||
user: string,
|
||||
collection: UserCollection,
|
||||
) => {
|
||||
const dbCollection = await db
|
||||
.select()
|
||||
.from(Collection)
|
||||
.where(eq(Collection.user, user));
|
||||
if (dbCollection.length > 0) {
|
||||
await db
|
||||
.update(Collection)
|
||||
.set({
|
||||
collection: Buffer.from(encode(collection)),
|
||||
})
|
||||
.where(eq(Collection.user, user));
|
||||
} else {
|
||||
await db.insert(Collection).values({
|
||||
user,
|
||||
collection: Buffer.from(encode(collection)),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const parseCollection = (collection: CollectionType) => {
|
||||
return decode(collection.collection) as UserCollection;
|
||||
};
|
||||
|
|
@ -16,7 +16,7 @@ describe("GameRepository", () => {
|
|||
uuid: "TestUuid",
|
||||
user: "TestUser",
|
||||
stage: 1,
|
||||
gameState: "ANY",
|
||||
gameState: Buffer.from("ANY"),
|
||||
finished: 1,
|
||||
started,
|
||||
});
|
||||
|
|
@ -25,7 +25,7 @@ describe("GameRepository", () => {
|
|||
uuid: "TestUuid",
|
||||
user: "TestUser",
|
||||
stage: 1,
|
||||
gameState: "ANY",
|
||||
gameState: Buffer.from("ANY"),
|
||||
finished: 1,
|
||||
started,
|
||||
});
|
||||
|
|
@ -44,7 +44,7 @@ describe("GameRepository", () => {
|
|||
uuid: "TestUuid",
|
||||
user: "TestUser",
|
||||
stage: 1,
|
||||
gameState: "ANY",
|
||||
gameState: Buffer.from("ANY"),
|
||||
finished: 1,
|
||||
started,
|
||||
});
|
||||
|
|
@ -52,7 +52,7 @@ describe("GameRepository", () => {
|
|||
uuid: "TestUuid2",
|
||||
user: "TestUser",
|
||||
stage: 2,
|
||||
gameState: "ANY",
|
||||
gameState: Buffer.from("ANY"),
|
||||
finished: 1,
|
||||
started: started + 1,
|
||||
});
|
||||
|
|
@ -61,7 +61,7 @@ describe("GameRepository", () => {
|
|||
uuid: "TestUuid2",
|
||||
user: "TestUser",
|
||||
stage: 2,
|
||||
gameState: "ANY",
|
||||
gameState: Buffer.from("ANY"),
|
||||
finished: 1,
|
||||
started: started + 1,
|
||||
});
|
||||
|
|
@ -76,7 +76,7 @@ describe("GameRepository", () => {
|
|||
uuid,
|
||||
user: "TestUser",
|
||||
stage: 1,
|
||||
gameState: "ANY",
|
||||
gameState: Buffer.from("ANY"),
|
||||
finished: 1,
|
||||
started,
|
||||
});
|
||||
|
|
@ -92,7 +92,7 @@ describe("GameRepository", () => {
|
|||
uuid: "TestUuid",
|
||||
user: "TestUser",
|
||||
stage: 1,
|
||||
gameState: "ANY",
|
||||
gameState: Buffer.from("ANY"),
|
||||
finished: 1,
|
||||
started,
|
||||
});
|
||||
|
|
@ -101,7 +101,7 @@ describe("GameRepository", () => {
|
|||
uuid: "TestUuid",
|
||||
user: "TestUser",
|
||||
stage: 1,
|
||||
gameState: "ANY",
|
||||
gameState: Buffer.from("ANY"),
|
||||
finished: 1,
|
||||
started,
|
||||
});
|
||||
|
|
@ -114,7 +114,7 @@ describe("GameRepository", () => {
|
|||
uuid: "TestUuid",
|
||||
user: "TestUser",
|
||||
stage: 1,
|
||||
gameState: "ANY",
|
||||
gameState: Buffer.from("ANY"),
|
||||
finished: 1,
|
||||
started,
|
||||
});
|
||||
|
|
@ -122,7 +122,7 @@ describe("GameRepository", () => {
|
|||
uuid: "TestUuid",
|
||||
user: "TestUser",
|
||||
stage: 2,
|
||||
gameState: "ANY",
|
||||
gameState: Buffer.from("ANY"),
|
||||
finished: 1,
|
||||
started: started + 1,
|
||||
});
|
||||
|
|
@ -131,7 +131,7 @@ describe("GameRepository", () => {
|
|||
uuid: "TestUuid",
|
||||
user: "TestUser",
|
||||
stage: 2,
|
||||
gameState: "ANY",
|
||||
gameState: Buffer.from("ANY"),
|
||||
finished: 1,
|
||||
started: started + 1,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import type { BunSQLiteDatabase } from "drizzle-orm/bun-sqlite";
|
|||
import { Game, type GameType } from "../schema";
|
||||
import { eq, sql, desc, and, not } from "drizzle-orm";
|
||||
import type { ServerGame } from "../../shared/game";
|
||||
import { decode, encode } from "@msgpack/msgpack";
|
||||
|
||||
export const getGame = async (db: BunSQLiteDatabase, uuid: string) => {
|
||||
return (await db.select().from(Game).where(eq(Game.uuid, uuid)))[0];
|
||||
|
|
@ -12,7 +13,7 @@ export const getGames = async (db: BunSQLiteDatabase, user: string) => {
|
|||
.select()
|
||||
.from(Game)
|
||||
.where(and(eq(Game.user, user), not(eq(Game.finished, 0))))
|
||||
.orderBy(Game.started, sql`desc`);
|
||||
.orderBy(desc(Game.started));
|
||||
};
|
||||
|
||||
export const getCurrentGame = async (db: BunSQLiteDatabase, user: string) => {
|
||||
|
|
@ -69,8 +70,31 @@ export const upsertGameState = async (
|
|||
uuid,
|
||||
user,
|
||||
stage,
|
||||
gameState: JSON.stringify(game),
|
||||
gameState: Buffer.from(encode(game)),
|
||||
finished,
|
||||
started,
|
||||
});
|
||||
};
|
||||
|
||||
export const getTotalGamesPlayed = async (
|
||||
db: BunSQLiteDatabase,
|
||||
user?: string,
|
||||
) => {
|
||||
if (user)
|
||||
return (
|
||||
await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(Game)
|
||||
.where(and(eq(Game.user, user), not(eq(Game.finished, 0))))
|
||||
)[0].count;
|
||||
return (
|
||||
await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(Game)
|
||||
.where(not(eq(Game.finished, 0)))
|
||||
)[0].count;
|
||||
};
|
||||
|
||||
export const parseGameState = (gameState: Buffer) => {
|
||||
return decode(gameState) as ServerGame;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,41 @@
|
|||
import { eq } from "drizzle-orm";
|
||||
import { Gems } from "../schema";
|
||||
import type { BunSQLiteDatabase } from "drizzle-orm/bun-sqlite";
|
||||
|
||||
export const getGems = async (db: BunSQLiteDatabase, user: string) => {
|
||||
const res = (await db.select().from(Gems).where(eq(Gems.user, user)))[0];
|
||||
const count = res?.count ?? 0;
|
||||
const totalCount = res?.totalCount ?? 0;
|
||||
return { count, totalCount };
|
||||
};
|
||||
|
||||
export const addGems = async (
|
||||
db: BunSQLiteDatabase,
|
||||
user: string,
|
||||
gems: number,
|
||||
) => {
|
||||
const { count, totalCount } = await getGems(db, user);
|
||||
if ((await db.select().from(Gems).where(eq(Gems.user, user))).length === 0) {
|
||||
await db
|
||||
.insert(Gems)
|
||||
.values({ user, count: count + gems, totalCount: totalCount + gems });
|
||||
return;
|
||||
}
|
||||
await db
|
||||
.update(Gems)
|
||||
.set({ count: count + gems, totalCount: totalCount + gems })
|
||||
.where(eq(Gems.user, user));
|
||||
};
|
||||
|
||||
export const removeGems = async (
|
||||
db: BunSQLiteDatabase,
|
||||
user: string,
|
||||
gems: number,
|
||||
) => {
|
||||
const { count, totalCount } = await getGems(db, user);
|
||||
if (count - gems < 0) throw new Error("Not enough gems");
|
||||
await db
|
||||
.update(Gems)
|
||||
.set({ count: count - gems, totalCount: totalCount - gems })
|
||||
.where(eq(Gems.user, user));
|
||||
};
|
||||
|
|
@ -14,7 +14,7 @@ describe("ScoreRepository", () => {
|
|||
user: "TestUser",
|
||||
uuid: crypto.randomUUID(),
|
||||
stage: 1,
|
||||
gameState: "ANY",
|
||||
gameState: Buffer.from("ANY"),
|
||||
finished: 1,
|
||||
started: Date.now(),
|
||||
});
|
||||
|
|
@ -22,7 +22,7 @@ describe("ScoreRepository", () => {
|
|||
user: "TestUser",
|
||||
uuid: crypto.randomUUID(),
|
||||
stage: 10,
|
||||
gameState: "ANY",
|
||||
gameState: Buffer.from("ANY"),
|
||||
finished: 1,
|
||||
started: Date.now(),
|
||||
});
|
||||
|
|
@ -30,7 +30,7 @@ describe("ScoreRepository", () => {
|
|||
user: "TestUser",
|
||||
uuid: crypto.randomUUID(),
|
||||
stage: 20,
|
||||
gameState: "ANY",
|
||||
gameState: Buffer.from("ANY"),
|
||||
finished: 0,
|
||||
started: Date.now(),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,9 +3,11 @@ import { Game } from "../schema";
|
|||
import type { BunSQLiteDatabase } from "drizzle-orm/bun-sqlite";
|
||||
|
||||
export const getScoreBoard = async (db: BunSQLiteDatabase) => {
|
||||
return await db
|
||||
return (
|
||||
await db
|
||||
.select({ stage: sql<number>`max(${Game.stage})`, user: Game.user })
|
||||
.from(Game)
|
||||
.where(not(eq(Game.finished, 0)))
|
||||
.groupBy(Game.user);
|
||||
.groupBy(Game.user)
|
||||
).sort((a, b) => b.stage - a.stage);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
import type { BunSQLiteDatabase } from "drizzle-orm/bun-sqlite";
|
||||
import { User, type UserType } from "../schema";
|
||||
import { User, UserSettings, type UserType } from "../schema";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import {
|
||||
userSettings as userSettingsSchema,
|
||||
type UserSettings as UserSettingsType,
|
||||
} from "../../shared/user-settings";
|
||||
|
||||
export const registerUser = async (
|
||||
db: BunSQLiteDatabase,
|
||||
|
|
@ -46,3 +50,44 @@ export const getUser = async (
|
|||
.where(eq(sql`lower(${User.name})`, name.toLowerCase()));
|
||||
return { ...user[0], password: undefined };
|
||||
};
|
||||
|
||||
export const getUserSettings = async (
|
||||
db: BunSQLiteDatabase,
|
||||
user: string,
|
||||
): Promise<UserSettingsType | undefined> => {
|
||||
const userSettings = await db
|
||||
.select()
|
||||
.from(UserSettings)
|
||||
.where(eq(UserSettings.user, user));
|
||||
const settings = userSettings[0]?.settings || "{}";
|
||||
return userSettingsSchema.parse(JSON.parse(settings));
|
||||
};
|
||||
|
||||
export const upsertUserSettings = async (
|
||||
db: BunSQLiteDatabase,
|
||||
user: string,
|
||||
settings: UserSettingsType,
|
||||
) => {
|
||||
const dbSettings = await db
|
||||
.select()
|
||||
.from(UserSettings)
|
||||
.where(eq(UserSettings.user, user));
|
||||
if (dbSettings.length > 0) {
|
||||
await db
|
||||
.update(UserSettings)
|
||||
.set({
|
||||
settings: JSON.stringify(settings),
|
||||
})
|
||||
.where(eq(UserSettings.user, user));
|
||||
} else {
|
||||
await db.insert(UserSettings).values({
|
||||
user,
|
||||
settings: JSON.stringify(settings),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const getUserCount = async (db: BunSQLiteDatabase) => {
|
||||
return (await db.select({ count: sql<number>`count(*)` }).from(User))[0]
|
||||
.count;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,37 +1,71 @@
|
|||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import type { ServerWebSocket } from "bun";
|
||||
import type { Controller, Endpoint } from "./controller/controller";
|
||||
import { gameController } from "./controller/gameController";
|
||||
import { db } from "./database/db";
|
||||
import { BadRequestError } from "./errors/BadRequestError";
|
||||
import { userController } from "./controller/userController";
|
||||
import { ZodError } from "zod";
|
||||
import { scoreboardController } from "./controller/scoreboardController";
|
||||
|
||||
const controllers = {
|
||||
game: gameController,
|
||||
user: userController,
|
||||
scoreboard: scoreboardController,
|
||||
} satisfies Record<string, Controller<any>>;
|
||||
|
||||
export const handleRequest = (message: unknown, sessionUser?: string) => {
|
||||
const userName = new WeakMap<ServerWebSocket<unknown>, string>();
|
||||
|
||||
export const setSessionUser = (ws: ServerWebSocket<unknown>, user: string) => {
|
||||
userName.set(ws, user);
|
||||
};
|
||||
|
||||
export const resetSessionUser = (ws: ServerWebSocket<unknown>) => {
|
||||
userName.delete(ws);
|
||||
};
|
||||
|
||||
export const handleRequest = async (
|
||||
message: unknown,
|
||||
ws: ServerWebSocket<unknown>,
|
||||
) => {
|
||||
const sessionUser = userName.get(ws) || undefined;
|
||||
const ctx = {
|
||||
user: sessionUser,
|
||||
db,
|
||||
ws,
|
||||
};
|
||||
if (
|
||||
!message ||
|
||||
!(typeof message === "object") ||
|
||||
!("type" in message) ||
|
||||
!("payload" in message)
|
||||
!("payload" in message) ||
|
||||
!("id" in message)
|
||||
)
|
||||
return;
|
||||
const { type, payload } = message;
|
||||
const { type, payload, id } = message;
|
||||
if (!(typeof type === "string")) return;
|
||||
const [controllerName, action] = type.split(".");
|
||||
if (!(controllerName in controllers)) return;
|
||||
try {
|
||||
// @ts-expect-error controllers[controllerName] is a Controller
|
||||
const endpoint = controllers[controllerName][action] as Endpoint<any, any>;
|
||||
const input = endpoint.validate.safeParse(payload);
|
||||
if (input.success) {
|
||||
const result = endpoint.handler(input.data, ctx);
|
||||
return result;
|
||||
const input = endpoint.validate.parse(payload);
|
||||
console.time(action);
|
||||
const result = await endpoint.handler(input, ctx);
|
||||
ws.send(JSON.stringify({ id, payload: result }));
|
||||
console.timeEnd(action);
|
||||
return;
|
||||
} catch (e) {
|
||||
if (e instanceof ZodError) {
|
||||
ws.send(
|
||||
JSON.stringify({ id, error: e.issues[0].message, type: message.type }),
|
||||
);
|
||||
} else if (e instanceof Error) {
|
||||
ws.send(JSON.stringify({ id, error: e.message, type: message.type }));
|
||||
} else {
|
||||
ws.send(JSON.stringify({ id, error: "Bad Request", type: message.type }));
|
||||
}
|
||||
console.error(e);
|
||||
}
|
||||
throw new BadRequestError(input.error.message);
|
||||
};
|
||||
|
||||
export type Routes = typeof controllers;
|
||||
|
|
|
|||
|
|
@ -1,22 +1,58 @@
|
|||
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
|
||||
import {
|
||||
sqliteTable,
|
||||
text,
|
||||
integer,
|
||||
index,
|
||||
blob,
|
||||
} from "drizzle-orm/sqlite-core";
|
||||
|
||||
export const User = sqliteTable("users", {
|
||||
name: text("name").primaryKey().notNull(),
|
||||
password: text("password").notNull(),
|
||||
});
|
||||
|
||||
export const Game = sqliteTable("games", {
|
||||
export const Game = sqliteTable(
|
||||
"games",
|
||||
{
|
||||
uuid: text("uuid").primaryKey().notNull(),
|
||||
user: text("user")
|
||||
.notNull()
|
||||
.references(() => User.name),
|
||||
gameState: text("gameState").notNull(),
|
||||
gameState: blob("gameState", { mode: "buffer" }).notNull(),
|
||||
stage: integer("stage").notNull(),
|
||||
finished: integer("finished").notNull().default(0),
|
||||
started: integer("timestamp").notNull(),
|
||||
},
|
||||
(table) => {
|
||||
return {
|
||||
userIdx: index("user_idx").on(table.user),
|
||||
startedIdx: index("started_idx").on(table.started),
|
||||
userStartedIdx: index("user_started_idx").on(table.user, table.started),
|
||||
fullIdx: index("full_idx").on(table.user, table.started, table.uuid),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
export const UserSettings = sqliteTable("userSettings", {
|
||||
user: text("user").primaryKey().notNull(),
|
||||
settings: text("settings").notNull(),
|
||||
});
|
||||
|
||||
export const Gems = sqliteTable("gems", {
|
||||
user: text("user").primaryKey().notNull(),
|
||||
count: integer("count").notNull(),
|
||||
totalCount: integer("totalCount").notNull(),
|
||||
});
|
||||
|
||||
export const Collection = sqliteTable("collection", {
|
||||
user: text("user").primaryKey().notNull(),
|
||||
collection: blob("collection", { mode: "buffer" }).notNull(),
|
||||
});
|
||||
|
||||
export type UserType = Omit<typeof User.$inferSelect, "password"> & {
|
||||
password?: undefined;
|
||||
};
|
||||
export type GameType = typeof Game.$inferSelect;
|
||||
export type UserSettingsType = typeof UserSettings.$inferSelect;
|
||||
export type GemsType = typeof Gems.$inferSelect;
|
||||
export type CollectionType = typeof Collection.$inferSelect;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
import { $ } from "bun";
|
||||
|
||||
await Promise.all([$`bun run dev:backend`, $`bun run dev:client`]);
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import { defineConfig } from "drizzle-kit";
|
||||
export default defineConfig({
|
||||
dialect: "sqlite",
|
||||
schema: "./backend/schema.ts",
|
||||
out: "./backend/drizzle",
|
||||
dbCredentials: {
|
||||
url: "file:./sqlite.db",
|
||||
},
|
||||
});
|
||||
|
|
@ -1,28 +1,39 @@
|
|||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import globals from "globals";
|
||||
import pluginJs from "@eslint/js";
|
||||
import tseslint from "typescript-eslint";
|
||||
import pluginReact from "eslint-plugin-react";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist'] },
|
||||
export default [
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
ignores: ["dist/", "node_modules/"],
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
pluginJs.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
...pluginReact.configs.flat.recommended,
|
||||
settings: {
|
||||
react: {
|
||||
version: "detect",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
plugins: { "react-hooks": reactHooks },
|
||||
rules: reactHooks.configs.recommended.rules,
|
||||
},
|
||||
{
|
||||
files: ["**/*.{js,mjs,cjs,ts,jsx,tsx}"],
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
"react/react-in-jsx-scope": "off",
|
||||
"react/prop-types": "off",
|
||||
"react/display-name": "off",
|
||||
},
|
||||
},
|
||||
)
|
||||
{
|
||||
languageOptions: {
|
||||
...pluginReact.configs.flat.recommended.languageOptions,
|
||||
globals: { ...globals.browser, ...globals.node },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -3,8 +3,10 @@
|
|||
<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"/>
|
||||
<meta name="darkreader-lock">
|
||||
<meta name="description" content="Minesweeper Endless is a game where you have to clear the board without getting hit by mines. You can win the game by getting the highest score.">
|
||||
<title>Minesweeper</title>
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
|||
73
package.json
|
|
@ -4,39 +4,66 @@
|
|||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"dev": "bun run dev.ts",
|
||||
"dev:backend": "bun run backend/index.ts --watch --hot",
|
||||
"dev:client": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"lint": "eslint",
|
||||
"preview": "vite preview",
|
||||
"drizzle:schema": "drizzle-kit generate --dialect sqlite --schema ./backend/schema.ts --out ./backend/drizzle",
|
||||
"drizzle:migrate": "bun run backend/migrate.ts"
|
||||
"drizzle:schema": "drizzle-kit generate",
|
||||
"drizzle:migrate": "bun run backend/migrate.ts",
|
||||
"nukedb": "rm sqlite.db && bun run backend/migrate.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@msgpack/msgpack": "^3.0.0-beta2",
|
||||
"@pixi/canvas-display": "^7.4.2",
|
||||
"@pixi/canvas-renderer": "^7.4.2",
|
||||
"@pixi/react": "^7.1.2",
|
||||
"@radix-ui/react-dialog": "^1.1.2",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.2",
|
||||
"@radix-ui/react-popover": "^1.1.2",
|
||||
"@radix-ui/react-switch": "^1.1.1",
|
||||
"@tanstack/react-query": "^5.59.11",
|
||||
"@tanstack/react-query-devtools": "^5.59.11",
|
||||
"@tsparticles/engine": "^3.5.0",
|
||||
"@tsparticles/preset-sea-anemone": "^3.1.0",
|
||||
"@tsparticles/react": "^3.0.0",
|
||||
"@tsparticles/slim": "^3.5.0",
|
||||
"@uidotdev/usehooks": "^2.4.1",
|
||||
"drizzle-orm": "^0.33.0",
|
||||
"lucide-react": "^0.441.0",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"drizzle-orm": "0.33.0",
|
||||
"framer-motion": "^11.11.8",
|
||||
"jotai": "^2.10.0",
|
||||
"lucide-react": "^0.452.0",
|
||||
"pixi-viewport": "^5.0.3",
|
||||
"pixi.js": "^7.0.0",
|
||||
"pixi.js-legacy": "^7.4.2",
|
||||
"react": "^18.3.1",
|
||||
"react-confetti-boom": "^1.0.0",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"tailwind-merge": "^2.5.3",
|
||||
"use-sound": "^4.0.3",
|
||||
"zod": "^3.23.8",
|
||||
"zustand": "^4.5.5"
|
||||
"wouter": "^3.3.5",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.10.0",
|
||||
"vite-imagetools": "^7.0.4",
|
||||
"tailwindcss": "^4.0.0-alpha.26",
|
||||
"@eslint/compat": "^1.2.0",
|
||||
"@eslint/js": "^9.12.0",
|
||||
"@types/bun": "latest",
|
||||
"@types/react": "^18.3.8",
|
||||
"@types/react-dom": "^18.3.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.12",
|
||||
"globals": "^15.9.0",
|
||||
"typescript": "^5.6.2",
|
||||
"typescript-eslint": "^8.6.0",
|
||||
"vite": "^5.4.6"
|
||||
},
|
||||
"module": "index.ts"
|
||||
"@types/react": "^18.3.11",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react-swc": "^3.7.1",
|
||||
"drizzle-kit": "0.24.2",
|
||||
"eslint": "^9.12.0",
|
||||
"eslint-plugin-react": "^7.37.1",
|
||||
"eslint-plugin-react-hooks": "5.0.0",
|
||||
"globals": "^15.11.0",
|
||||
"typescript": "^5.6.3",
|
||||
"typescript-eslint": "^8.8.1",
|
||||
"vite": "^5.4.8",
|
||||
"@tailwindcss/vite": "next"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
User-agent: *
|
||||
Disallow: /
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import type { Rarity } from "../shared/lootboxes";
|
||||
export type EventType = "new" | "finished" | "updateGame" | "updateStage";
|
||||
|
||||
export type Events =
|
||||
| {
|
||||
type: "new";
|
||||
user: string;
|
||||
}
|
||||
| {
|
||||
type: "loss";
|
||||
user: string;
|
||||
stage: number;
|
||||
time: number;
|
||||
}
|
||||
| {
|
||||
type: "updateGame";
|
||||
game: string;
|
||||
}
|
||||
| {
|
||||
type: "updateStage";
|
||||
game: string;
|
||||
stage: number;
|
||||
started: number;
|
||||
}
|
||||
| {
|
||||
type: "gemsRewarded";
|
||||
stage: number;
|
||||
gems: number;
|
||||
}
|
||||
| {
|
||||
type: "lootboxPurchased";
|
||||
lootbox: string;
|
||||
reward: string;
|
||||
user: string;
|
||||
rarity: Rarity;
|
||||
};
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, it, expect } from "bun:test";
|
||||
import { getValue, serverToClientGame } from "./game";
|
||||
import { getValue, ServerGame, serverToClientGame } from "./game";
|
||||
|
||||
describe("Game", () => {
|
||||
it("should get value", () => {
|
||||
|
|
@ -16,7 +16,8 @@ describe("Game", () => {
|
|||
});
|
||||
|
||||
it("should convert server to client game", () => {
|
||||
const serverGame = {
|
||||
const serverGame: ServerGame = {
|
||||
theme: "default",
|
||||
mines: [
|
||||
[false, false, true, true, true],
|
||||
[true, false, true, false, true],
|
||||
|
|
@ -36,15 +37,23 @@ describe("Game", () => {
|
|||
[false, false, true, false, true],
|
||||
[true, false, false, false, false],
|
||||
],
|
||||
isGameOver: false,
|
||||
started: 1679599200000,
|
||||
finished: 0,
|
||||
lastClick: [0, 0] satisfies [number, number],
|
||||
uuid: "C270D7CD-AF97-42CE-A6C9-CB765102CA17",
|
||||
width: 5,
|
||||
height: 4,
|
||||
user: "TestUser",
|
||||
stage: 1,
|
||||
isQuestionMark: [
|
||||
[false, false, true, false, true],
|
||||
[true, false, true, false, true],
|
||||
[false, false, true, false, true],
|
||||
[false, false, false, false, false],
|
||||
],
|
||||
};
|
||||
expect(serverToClientGame(serverGame)).toEqual({
|
||||
theme: "default",
|
||||
minesCount: 4,
|
||||
isRevealed: [
|
||||
[false, false, true, false, true],
|
||||
|
|
@ -69,6 +78,14 @@ describe("Game", () => {
|
|||
uuid: "C270D7CD-AF97-42CE-A6C9-CB765102CA17",
|
||||
width: 5,
|
||||
height: 4,
|
||||
user: "TestUser",
|
||||
stage: 1,
|
||||
isQuestionMark: [
|
||||
[false, false, true, false, true],
|
||||
[true, false, true, false, true],
|
||||
[false, false, true, false, true],
|
||||
[false, false, false, false, false],
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,36 +1,10 @@
|
|||
import { z } from "zod";
|
||||
import type { ServerGame, ClientGame } from "./gameType";
|
||||
export type { ServerGame, ClientGame } from "./gameType";
|
||||
|
||||
export const clientGame = z.object({
|
||||
user: z.string(),
|
||||
uuid: z.string(),
|
||||
width: z.number(),
|
||||
height: z.number(),
|
||||
isRevealed: z.array(z.array(z.boolean())),
|
||||
isFlagged: z.array(z.array(z.boolean())),
|
||||
values: z.array(z.array(z.number())),
|
||||
minesCount: z.number(),
|
||||
lastClick: z.tuple([z.number(), z.number()]),
|
||||
started: z.number(),
|
||||
stage: z.number(),
|
||||
});
|
||||
|
||||
export const serverGame = z.object({
|
||||
user: z.string(),
|
||||
uuid: z.string(),
|
||||
width: z.number(),
|
||||
height: z.number(),
|
||||
isRevealed: z.array(z.array(z.boolean())),
|
||||
isFlagged: z.array(z.array(z.boolean())),
|
||||
mines: z.array(z.array(z.boolean())),
|
||||
minesCount: z.number(),
|
||||
lastClick: z.tuple([z.number(), z.number()]),
|
||||
started: z.number(),
|
||||
finished: z.number().default(0),
|
||||
stage: z.number(),
|
||||
});
|
||||
|
||||
export type ClientGame = z.infer<typeof clientGame>;
|
||||
export type ServerGame = z.infer<typeof serverGame>;
|
||||
export const isServerGame = (game: ServerGame | ClientGame) => "mines" in game;
|
||||
export const isClientGame = (
|
||||
game: ServerGame | ClientGame,
|
||||
): game is ClientGame => !("mines" in game);
|
||||
|
||||
export const getValue = (mines: boolean[][], x: number, y: number) => {
|
||||
const neighbors = [
|
||||
|
|
@ -54,6 +28,7 @@ export const serverToClientGame = (game: ServerGame): ClientGame => {
|
|||
height: game.height,
|
||||
isRevealed: game.isRevealed,
|
||||
isFlagged: game.isFlagged,
|
||||
isQuestionMark: game.isQuestionMark,
|
||||
minesCount: game.minesCount,
|
||||
values: game.mines.map((_, i) =>
|
||||
game.mines[0].map((_, j) => {
|
||||
|
|
@ -64,5 +39,6 @@ export const serverToClientGame = (game: ServerGame): ClientGame => {
|
|||
lastClick: game.lastClick,
|
||||
started: game.started,
|
||||
stage: game.stage,
|
||||
theme: game.theme,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,47 @@
|
|||
import { z } from "zod";
|
||||
|
||||
export const clientGame = z.object({
|
||||
user: z.string(),
|
||||
uuid: z.string(),
|
||||
width: z.number(),
|
||||
height: z.number(),
|
||||
isRevealed: z.array(z.array(z.boolean())),
|
||||
isFlagged: z.array(z.array(z.boolean())),
|
||||
isQuestionMark: z.array(z.array(z.boolean())),
|
||||
values: z.array(z.array(z.number())),
|
||||
minesCount: z.number(),
|
||||
lastClick: z.tuple([z.number(), z.number()]),
|
||||
started: z.number(),
|
||||
stage: z.number(),
|
||||
theme: z.string().default("default"),
|
||||
});
|
||||
|
||||
export const serverGame = z.object({
|
||||
user: z.string(),
|
||||
uuid: z.string(),
|
||||
width: z.number(),
|
||||
height: z.number(),
|
||||
isRevealed: z.array(z.array(z.boolean())),
|
||||
isFlagged: z.array(z.array(z.boolean())),
|
||||
isQuestionMark: z.array(z.array(z.boolean())),
|
||||
mines: z.array(z.array(z.boolean())),
|
||||
minesCount: z.number(),
|
||||
lastClick: z.tuple([z.number(), z.number()]),
|
||||
started: z.number(),
|
||||
finished: z.number().default(0),
|
||||
stage: z.number(),
|
||||
theme: z.string().default("default"),
|
||||
});
|
||||
|
||||
export type ClientGame = z.infer<typeof clientGame>;
|
||||
export type ServerGame = z.infer<typeof serverGame>;
|
||||
|
||||
export interface UserCollectionEntry {
|
||||
id: string;
|
||||
aquired: number;
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
export interface UserCollection {
|
||||
entries: UserCollectionEntry[];
|
||||
}
|
||||
|
|
@ -0,0 +1,211 @@
|
|||
import type { themes } from "../src/themes";
|
||||
import lootbox1 from "../src/assets/illustrations/lootbox1.png?w=360&inline";
|
||||
|
||||
export const rarities = [
|
||||
{
|
||||
name: "Common",
|
||||
id: "common",
|
||||
weight: 1,
|
||||
},
|
||||
{
|
||||
name: "Uncommon",
|
||||
id: "uncommon",
|
||||
weight: 0.5,
|
||||
},
|
||||
{
|
||||
name: "Rare",
|
||||
id: "rare",
|
||||
weight: 0.25,
|
||||
},
|
||||
{
|
||||
name: "Legendary",
|
||||
id: "legendary",
|
||||
weight: 0.1,
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const getWeight = (rarity: Rarity) =>
|
||||
rarities.find((r) => r.id === rarity)?.weight ?? 0;
|
||||
|
||||
export type Rarity = (typeof rarities)[number]["id"];
|
||||
type ThemeId = (typeof themes)[number]["id"];
|
||||
|
||||
interface Lootbox {
|
||||
name: string;
|
||||
id: string;
|
||||
price: number;
|
||||
priceText: string;
|
||||
image: string;
|
||||
items: {
|
||||
id: ThemeId;
|
||||
rarity: Rarity;
|
||||
}[];
|
||||
}
|
||||
|
||||
export const series1: Lootbox = {
|
||||
name: "Series 1",
|
||||
id: "series1",
|
||||
price: 5000,
|
||||
priceText: "5.000",
|
||||
image: lootbox1,
|
||||
items: [
|
||||
{
|
||||
id: "basic",
|
||||
rarity: "common",
|
||||
},
|
||||
{
|
||||
id: "black-and-white",
|
||||
rarity: "common",
|
||||
},
|
||||
{
|
||||
id: "blue",
|
||||
rarity: "common",
|
||||
},
|
||||
{
|
||||
id: "green",
|
||||
rarity: "common",
|
||||
},
|
||||
{
|
||||
id: "orange",
|
||||
rarity: "common",
|
||||
},
|
||||
{
|
||||
id: "pink",
|
||||
rarity: "common",
|
||||
},
|
||||
{
|
||||
id: "purple",
|
||||
rarity: "common",
|
||||
},
|
||||
{
|
||||
id: "red",
|
||||
rarity: "common",
|
||||
},
|
||||
{
|
||||
id: "turquoise",
|
||||
rarity: "common",
|
||||
},
|
||||
{
|
||||
id: "yellow",
|
||||
rarity: "common",
|
||||
},
|
||||
{
|
||||
id: "nautical",
|
||||
rarity: "uncommon",
|
||||
},
|
||||
{
|
||||
id: "up-in-smoke",
|
||||
rarity: "uncommon",
|
||||
},
|
||||
{
|
||||
id: "shadow-warrior",
|
||||
rarity: "uncommon",
|
||||
},
|
||||
{
|
||||
id: "crimson",
|
||||
rarity: "uncommon",
|
||||
},
|
||||
{
|
||||
id: "romance",
|
||||
rarity: "uncommon",
|
||||
},
|
||||
{
|
||||
id: "flowers",
|
||||
rarity: "rare",
|
||||
},
|
||||
{
|
||||
id: "dinos",
|
||||
rarity: "rare",
|
||||
},
|
||||
{
|
||||
id: "cats",
|
||||
rarity: "rare",
|
||||
},
|
||||
{
|
||||
id: "mine-dogs",
|
||||
rarity: "rare",
|
||||
},
|
||||
{
|
||||
id: "tron-blue",
|
||||
rarity: "rare",
|
||||
},
|
||||
{
|
||||
id: "tron-orange",
|
||||
rarity: "rare",
|
||||
},
|
||||
{
|
||||
id: "circuit",
|
||||
rarity: "rare",
|
||||
},
|
||||
{
|
||||
id: "circuit-binary",
|
||||
rarity: "rare",
|
||||
},
|
||||
{
|
||||
id: "farm",
|
||||
rarity: "rare",
|
||||
},
|
||||
{
|
||||
id: "halli-galli",
|
||||
rarity: "rare",
|
||||
},
|
||||
{
|
||||
id: "insects",
|
||||
rarity: "rare",
|
||||
},
|
||||
{
|
||||
id: "poop",
|
||||
rarity: "rare",
|
||||
},
|
||||
{
|
||||
id: "underwater",
|
||||
rarity: "rare",
|
||||
},
|
||||
{
|
||||
id: "retro-wave",
|
||||
rarity: "legendary",
|
||||
},
|
||||
{
|
||||
id: "elden-ring",
|
||||
rarity: "legendary",
|
||||
},
|
||||
{
|
||||
id: "janitor-tresh",
|
||||
rarity: "legendary",
|
||||
},
|
||||
{
|
||||
id: "teemo",
|
||||
rarity: "legendary",
|
||||
},
|
||||
{
|
||||
id: "ziggs",
|
||||
rarity: "legendary",
|
||||
},
|
||||
{
|
||||
id: "minecraft-nether",
|
||||
rarity: "legendary",
|
||||
},
|
||||
{
|
||||
id: "minecraft-overworld",
|
||||
rarity: "legendary",
|
||||
},
|
||||
{
|
||||
id: "techies-dire",
|
||||
rarity: "legendary",
|
||||
},
|
||||
{
|
||||
id: "techies-radiant",
|
||||
rarity: "legendary",
|
||||
},
|
||||
{
|
||||
id: "isaac",
|
||||
rarity: "legendary",
|
||||
},
|
||||
{
|
||||
id: "mlg",
|
||||
rarity: "legendary",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const lootboxes = [series1];
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
import { ServerGame } from "./gameType";
|
||||
|
||||
const rotate = (arr: boolean[][]) => {
|
||||
return arr[0].map((_, colIndex) => arr.map((row) => row[colIndex]));
|
||||
};
|
||||
|
||||
export const testBoard: (theme: string) => ServerGame = (theme: string) => ({
|
||||
user: "TestUser",
|
||||
uuid: "C270D7CD-AF97-42CE-A6C9-CB765102CA17",
|
||||
width: 11,
|
||||
height: 4,
|
||||
isRevealed: rotate([
|
||||
[false, false, false, false, false, ...Array<boolean>(6).fill(true)],
|
||||
[...Array<boolean>(11).fill(true)],
|
||||
[...Array<boolean>(11).fill(true)],
|
||||
[...Array<boolean>(6).fill(true), ...Array<boolean>(5).fill(false)],
|
||||
]),
|
||||
isFlagged: rotate([
|
||||
[true, ...Array<boolean>(10).fill(false)],
|
||||
[...Array<boolean>(11).fill(false)],
|
||||
[...Array<boolean>(11).fill(false)],
|
||||
[...Array<boolean>(11).fill(false)],
|
||||
]),
|
||||
finished: 1,
|
||||
started: 1,
|
||||
stage: 420,
|
||||
lastClick: [2, 2],
|
||||
mines: rotate([
|
||||
[false, false, false, false, false, ...Array<boolean>(6).fill(true)],
|
||||
[...Array<boolean>(8).fill(false), true, false, true],
|
||||
[false, false, ...Array<boolean>(9).fill(true)],
|
||||
[...Array<boolean>(11).fill(false)],
|
||||
]),
|
||||
minesCount: 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8,
|
||||
isQuestionMark: rotate([
|
||||
[false, true, ...Array<boolean>(9).fill(false)],
|
||||
[...Array<boolean>(11).fill(false)],
|
||||
[...Array<boolean>(11).fill(false)],
|
||||
[...Array<boolean>(11).fill(false)],
|
||||
]),
|
||||
theme,
|
||||
});
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
export const formatTimeSpan = (timespan: number) => {
|
||||
const days = Math.floor(timespan / (1000 * 60 * 60 * 24));
|
||||
const hours = Math.floor(
|
||||
(timespan % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60),
|
||||
);
|
||||
const minutes = Math.floor((timespan % (1000 * 60 * 60)) / (1000 * 60));
|
||||
const seconds = Math.floor((timespan % (1000 * 60)) / 1000);
|
||||
|
||||
const result = [];
|
||||
|
||||
if (days > 0) {
|
||||
result.push(`${days}d`);
|
||||
}
|
||||
|
||||
if (hours > 0) {
|
||||
result.push(`${hours}h`);
|
||||
}
|
||||
|
||||
if (minutes > 0) {
|
||||
result.push(`${minutes}m`);
|
||||
}
|
||||
|
||||
if (seconds > 0) {
|
||||
result.push(`${seconds}s`);
|
||||
}
|
||||
|
||||
if (result.length === 0) {
|
||||
return timespan + "ms";
|
||||
}
|
||||
|
||||
return result.join(" ");
|
||||
};
|
||||
|
||||
const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
|
||||
|
||||
export const formatRelativeTime = (date: number) => {
|
||||
const now = Date.now();
|
||||
const diff = date - now;
|
||||
const days = Math.ceil(diff / (1000 * 60 * 60 * 24));
|
||||
const hours = Math.ceil((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
|
||||
const minutes = Math.ceil((diff % (1000 * 60 * 60)) / (1000 * 60));
|
||||
const seconds = Math.floor((diff % (1000 * 60)) / 1000);
|
||||
|
||||
if (days <= -1) {
|
||||
return rtf.format(days, "day");
|
||||
}
|
||||
|
||||
if (hours <= -1) {
|
||||
return rtf.format(hours, "hour");
|
||||
}
|
||||
|
||||
if (minutes <= -1) {
|
||||
return rtf.format(minutes, "minute");
|
||||
}
|
||||
|
||||
if (seconds <= -1) {
|
||||
return rtf.format(seconds, "second");
|
||||
}
|
||||
return "just now";
|
||||
};
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import { z } from "zod";
|
||||
|
||||
export const userSettings = z.object({
|
||||
placeQuestionMark: z.boolean().default(false),
|
||||
longPressOnDesktop: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export type UserSettings = z.infer<typeof userSettings>;
|
||||
export type UserSettingsInput = z.input<typeof userSettings>;
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
export const pickRandom = <T>(arr: T[]) => {
|
||||
const index = Math.floor(Math.random() * arr.length);
|
||||
return arr[index];
|
||||
};
|
||||
|
||||
export const hashStr = (str: string) => {
|
||||
return [...str].reduce(
|
||||
(hash, c) => (Math.imul(31, hash) + c.charCodeAt(0)) | 0,
|
||||
0,
|
||||
);
|
||||
};
|
||||
|
||||
export const weightedPickRandom = <T>(
|
||||
arr: T[],
|
||||
getWeight: (item: T) => number = () => 1,
|
||||
getRandom: (tw: number) => number = (totalWeight) =>
|
||||
Math.random() * totalWeight,
|
||||
): T => {
|
||||
const totalWeight = arr.reduce((acc, cur) => acc + getWeight(cur), 0);
|
||||
const random = getRandom(totalWeight);
|
||||
let currentWeight = 0;
|
||||
for (const entry of arr) {
|
||||
currentWeight += getWeight(entry);
|
||||
if (random < currentWeight) {
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
return arr[arr.length - 1];
|
||||
};
|
||||
130
src/App.tsx
|
|
@ -1,130 +0,0 @@
|
|||
import { Button } from "./Button";
|
||||
import Timer from "./Timer";
|
||||
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 [playSound] = useSound(explosion, {
|
||||
volume: 0.5,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (game.isGameOver) {
|
||||
playSound();
|
||||
loseGame(game.name, game.stage);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [game.isGameOver]);
|
||||
useEffect(() => {
|
||||
game.resetGame(4, 4, 2);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("https://mb.gordon.business")
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
setScores(data);
|
||||
});
|
||||
const i = setInterval(() => {
|
||||
fetch("https://mb.gordon.business")
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
setScores(data);
|
||||
});
|
||||
}, 2000);
|
||||
return () => clearInterval(i);
|
||||
}, []);
|
||||
|
||||
useMaxToasts(5);
|
||||
|
||||
return (
|
||||
<div className="App">
|
||||
{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>
|
||||
<p>
|
||||
Feed:{" "}
|
||||
<button
|
||||
onClick={() => game.setShowFeed(!game.showFeed)}
|
||||
style={{ padding: "0.5rem" }}
|
||||
>
|
||||
{game.showFeed ? "Shown" : "Hidden"}
|
||||
</button>
|
||||
</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 />
|
||||
<div
|
||||
className="game-board"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${game.getWidth()}, 1fr)`,
|
||||
gridTemplateRows: `repeat(${game.getHeight()}, 1fr)`,
|
||||
}}
|
||||
>
|
||||
{game.mines[0].map((_, y) =>
|
||||
game.mines.map((_, x) => (
|
||||
<Button key={`${x},${y}`} x={x} y={y} />
|
||||
)),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="footer">
|
||||
<pre>Version: 1.1.6</pre>
|
||||
<pre>
|
||||
Made by MasterGordon -{" "}
|
||||
<a target="_blank" href="https://github.com/MasterGordon/minesweeper">
|
||||
Source Code
|
||||
</a>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
123
src/Button.tsx
|
|
@ -1,123 +0,0 @@
|
|||
import { ReactNode, useRef } from "react";
|
||||
import { Bomb, Flag } from "lucide-react";
|
||||
import useGameStore from "./GameState";
|
||||
import { useLongPress } from "@uidotdev/usehooks";
|
||||
|
||||
interface ButtonProps {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const colorMap: Record<string, string> = {
|
||||
"1": "#049494",
|
||||
"2": "#8c9440",
|
||||
"3": "#cc6666",
|
||||
"4": "#b294bb",
|
||||
"5": "#f7c530",
|
||||
"6": "#81a2be",
|
||||
"7": "#707880",
|
||||
"8": "#b5bd68",
|
||||
};
|
||||
|
||||
export const Button = ({ x, y }: ButtonProps) => {
|
||||
const {
|
||||
isRevealed,
|
||||
isFlagged,
|
||||
isMine,
|
||||
getValue,
|
||||
reveal,
|
||||
flag,
|
||||
getNeighborFlags,
|
||||
isGameOver,
|
||||
getHasWon,
|
||||
} = useGameStore();
|
||||
|
||||
let content: ReactNode = "";
|
||||
|
||||
if (isRevealed[x][y]) {
|
||||
content = isMine(x, y) ? <Bomb /> : getValue(x, y).toString();
|
||||
}
|
||||
|
||||
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") &&
|
||||
isMine(x, y) &&
|
||||
!isFlagged[x][y]
|
||||
)
|
||||
content = <Bomb />;
|
||||
|
||||
const touchStart = useRef<number>(0);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="mine-button"
|
||||
{...attrs}
|
||||
style={{
|
||||
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: isRevealed[x][y] ? "default" : "pointer",
|
||||
}}
|
||||
onMouseDown={() => {
|
||||
touchStart.current = Date.now();
|
||||
}}
|
||||
onMouseUp={(e) => {
|
||||
if (Date.now() - touchStart.current > 400 && !isRevealed[x][y]) {
|
||||
flag(x, y);
|
||||
return;
|
||||
}
|
||||
if (getHasWon() || isGameOver) {
|
||||
return;
|
||||
}
|
||||
if (e.button === 0) {
|
||||
// Left click
|
||||
if (isFlagged[x][y]) return;
|
||||
if (!isRevealed[x][y]) {
|
||||
reveal(x, y);
|
||||
} else {
|
||||
const neighborFlagCount = getNeighborFlags(x, y).filter(
|
||||
(n) => n,
|
||||
).length;
|
||||
const value = getValue(x, y);
|
||||
if (neighborFlagCount === value) {
|
||||
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 && !isRevealed[x][y]) {
|
||||
flag(x, y);
|
||||
}
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
144
src/Game.ts
|
|
@ -1,144 +0,0 @@
|
|||
export class Game {
|
||||
mines: boolean[][] = [];
|
||||
minesCount: number = 0;
|
||||
isRevealed: boolean[][] = [];
|
||||
isFlagged: boolean[][] = [];
|
||||
isGameOver: boolean = false;
|
||||
startTime: number = Date.now();
|
||||
|
||||
constructor(width: number, height: number, mines: number) {
|
||||
if (mines > width * height) {
|
||||
throw new Error("Too many mines");
|
||||
}
|
||||
this.minesCount = mines;
|
||||
for (let i = 0; i < width; i++) {
|
||||
this.mines.push(new Array(height).fill(false));
|
||||
this.isRevealed.push(new Array(height).fill(false));
|
||||
this.isFlagged.push(new Array(height).fill(false));
|
||||
}
|
||||
while (mines > 0) {
|
||||
const x = Math.floor(Math.random() * width);
|
||||
const y = Math.floor(Math.random() * height);
|
||||
if (!this.mines[x][y]) {
|
||||
this.mines[x][y] = true;
|
||||
mines--;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getWidth() {
|
||||
return this.mines.length;
|
||||
}
|
||||
|
||||
getHeight() {
|
||||
return this.mines[0].length;
|
||||
}
|
||||
|
||||
isMine(x: number, y: number) {
|
||||
return this.mines[x][y];
|
||||
}
|
||||
|
||||
flag(x: number, y: number) {
|
||||
this.isFlagged[x][y] = !this.isFlagged[x][y];
|
||||
}
|
||||
|
||||
isValid(x: number, y: number) {
|
||||
return x >= 0 && x < this.getWidth() && y >= 0 && y < this.getHeight();
|
||||
}
|
||||
|
||||
reveal(x: number, y: number) {
|
||||
if (!this.isValid(x, y)) return;
|
||||
this.isRevealed[x][y] = true;
|
||||
if (this.isMine(x, y)) {
|
||||
this.isGameOver = true;
|
||||
return;
|
||||
}
|
||||
const value = this.getValue(x, y);
|
||||
if (value === 0) {
|
||||
if (this.isValid(x - 1, y - 1) && !this.isRevealed[x - 1]?.[y - 1])
|
||||
this.reveal(x - 1, y - 1);
|
||||
if (this.isValid(x, y - 1) && !this.isRevealed[x]?.[y - 1])
|
||||
this.reveal(x, y - 1);
|
||||
if (this.isValid(x + 1, y - 1) && !this.isRevealed[x + 1]?.[y - 1])
|
||||
this.reveal(x + 1, y - 1);
|
||||
if (this.isValid(x - 1, y) && !this.isRevealed[x - 1]?.[y])
|
||||
this.reveal(x - 1, y);
|
||||
if (this.isValid(x + 1, y) && !this.isRevealed[x + 1]?.[y])
|
||||
this.reveal(x + 1, y);
|
||||
if (this.isValid(x - 1, y + 1) && !this.isRevealed[x - 1]?.[y + 1])
|
||||
this.reveal(x - 1, y + 1);
|
||||
if (this.isValid(x, y + 1) && !this.isRevealed[x]?.[y + 1])
|
||||
this.reveal(x, y + 1);
|
||||
if (this.isValid(x + 1, y + 1) && !this.isRevealed[x + 1]?.[y + 1])
|
||||
this.reveal(x + 1, y + 1);
|
||||
}
|
||||
}
|
||||
|
||||
getHasWon() {
|
||||
if (this.isGameOver) {
|
||||
return false;
|
||||
}
|
||||
for (let i = 0; i < this.getWidth(); i++) {
|
||||
for (let j = 0; j < this.getHeight(); j++) {
|
||||
if (!this.isRevealed[i][j] && !this.isFlagged[i][j]) {
|
||||
return false;
|
||||
}
|
||||
if (this.isMine(i, j) && !this.isFlagged[i][j]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
getMinesLeft() {
|
||||
return this.minesCount - this.isFlagged.flat().filter((m) => m).length;
|
||||
}
|
||||
|
||||
getNeighborFlags(x: number, y: number) {
|
||||
const neighbors = [
|
||||
this.isFlagged[x - 1]?.[y - 1],
|
||||
this.isFlagged[x]?.[y - 1],
|
||||
this.isFlagged[x + 1]?.[y - 1],
|
||||
this.isFlagged[x - 1]?.[y],
|
||||
this.isFlagged[x + 1]?.[y],
|
||||
this.isFlagged[x - 1]?.[y + 1],
|
||||
this.isFlagged[x]?.[y + 1],
|
||||
this.isFlagged[x + 1]?.[y + 1],
|
||||
];
|
||||
return neighbors;
|
||||
}
|
||||
|
||||
getNeighborMines(x: number, y: number) {
|
||||
const neighbors = [
|
||||
this.mines[x - 1]?.[y - 1],
|
||||
this.mines[x]?.[y - 1],
|
||||
this.mines[x + 1]?.[y - 1],
|
||||
this.mines[x - 1]?.[y],
|
||||
this.mines[x + 1]?.[y],
|
||||
this.mines[x - 1]?.[y + 1],
|
||||
this.mines[x]?.[y + 1],
|
||||
this.mines[x + 1]?.[y + 1],
|
||||
];
|
||||
return neighbors;
|
||||
}
|
||||
|
||||
getValue(x: number, y: number) {
|
||||
const neighbors = this.getNeighborMines(x, y);
|
||||
const mines = neighbors.filter((n) => n).length;
|
||||
return mines;
|
||||
}
|
||||
|
||||
quickStart() {
|
||||
for (let i = 0; i < this.getWidth(); i++) {
|
||||
for (let j = 0; j < this.getHeight(); j++) {
|
||||
const value = this.getValue(i, j);
|
||||
const isMine = this.isMine(i, j);
|
||||
if (value === 0 && !isMine) {
|
||||
this.reveal(i, j);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
367
src/GameState.ts
|
|
@ -1,367 +0,0 @@
|
|||
import { create } from "zustand";
|
||||
import { newGame } from "./ws";
|
||||
|
||||
interface GameState {
|
||||
showFeed: boolean;
|
||||
mines: boolean[][];
|
||||
minesCount: number;
|
||||
isRevealed: boolean[][];
|
||||
isFlagged: boolean[][];
|
||||
isGameOver: boolean;
|
||||
startTime: number;
|
||||
width: number;
|
||||
height: number;
|
||||
stage: number;
|
||||
name: string;
|
||||
|
||||
flag: (x: number, y: number) => void;
|
||||
reveal: (x: number, y: number) => boolean;
|
||||
getValue: (x: number, y: number) => number;
|
||||
getHasWon: () => boolean;
|
||||
getMinesLeft: () => number;
|
||||
quickStart: () => void;
|
||||
isValid: (x: number, y: number) => boolean;
|
||||
resetGame: (width: number, height: number, mines: number) => void;
|
||||
isMine: (x: number, y: number) => boolean;
|
||||
getNeighborMines: (x: number, y: number) => boolean[];
|
||||
getNeighborFlags: (x: number, y: number) => boolean[];
|
||||
getWidth: () => number;
|
||||
getHeight: () => number;
|
||||
isTouched: () => boolean;
|
||||
triggerPostGame: () => boolean;
|
||||
expandBoard: () => void;
|
||||
setName: (name: string) => void;
|
||||
setShowFeed: (showFeed: boolean) => void;
|
||||
}
|
||||
|
||||
const useGameStore = create<GameState>((set, get) => ({
|
||||
mines: [[]],
|
||||
minesCount: 0,
|
||||
isRevealed: [[]],
|
||||
isFlagged: [[]],
|
||||
isGameOver: false,
|
||||
startTime: Date.now(),
|
||||
width: 0,
|
||||
height: 0,
|
||||
stage: 1,
|
||||
name: localStorage.getItem("name") || "No Name",
|
||||
showFeed: !localStorage.getItem("showFeed")
|
||||
? true
|
||||
: localStorage.getItem("showFeed") === "true",
|
||||
|
||||
flag: (x, y) => {
|
||||
set((state) => {
|
||||
const isFlagged = [...state.isFlagged];
|
||||
isFlagged[x][y] = !isFlagged[x][y];
|
||||
return { isFlagged };
|
||||
});
|
||||
const { triggerPostGame } = get();
|
||||
triggerPostGame();
|
||||
},
|
||||
|
||||
reveal: (x, y) => {
|
||||
const { mines, isRevealed, isGameOver, getValue, triggerPostGame } = get();
|
||||
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);
|
||||
const neighborFlagCount = get()
|
||||
.getNeighborFlags(x, y)
|
||||
.filter((n) => n).length;
|
||||
if (value === 0 && neighborFlagCount === 0) {
|
||||
const revealNeighbors = (nx: number, ny: number) => {
|
||||
if (get().isValid(nx, ny) && !isRevealed[nx]?.[ny]) {
|
||||
get().reveal(nx, ny);
|
||||
}
|
||||
};
|
||||
|
||||
revealNeighbors(x - 1, y - 1);
|
||||
revealNeighbors(x, y - 1);
|
||||
revealNeighbors(x + 1, y - 1);
|
||||
revealNeighbors(x - 1, y);
|
||||
revealNeighbors(x + 1, y);
|
||||
revealNeighbors(x - 1, y + 1);
|
||||
revealNeighbors(x, y + 1);
|
||||
revealNeighbors(x + 1, y + 1);
|
||||
}
|
||||
}
|
||||
return triggerPostGame();
|
||||
},
|
||||
|
||||
getValue: (x, y) => {
|
||||
const { mines } = get();
|
||||
const neighbors = [
|
||||
mines[x - 1]?.[y - 1],
|
||||
mines[x]?.[y - 1],
|
||||
mines[x + 1]?.[y - 1],
|
||||
mines[x - 1]?.[y],
|
||||
mines[x + 1]?.[y],
|
||||
mines[x - 1]?.[y + 1],
|
||||
mines[x]?.[y + 1],
|
||||
mines[x + 1]?.[y + 1],
|
||||
];
|
||||
return neighbors.filter((n) => n).length;
|
||||
},
|
||||
|
||||
getHasWon: () => {
|
||||
const { mines, isRevealed, isFlagged, isGameOver, width, height } = get();
|
||||
if (isGameOver) return false;
|
||||
|
||||
for (let i = 0; i < width; i++) {
|
||||
for (let j = 0; j < height; j++) {
|
||||
if (!isRevealed[i][j] && !isFlagged[i][j]) return false;
|
||||
if (mines[i][j] && !isFlagged[i][j]) return false;
|
||||
if (isFlagged[i][j] && !mines[i][j]) return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
|
||||
getMinesLeft: () => {
|
||||
const { minesCount, isFlagged } = get();
|
||||
return minesCount - isFlagged.flat().filter((flag) => flag).length;
|
||||
},
|
||||
|
||||
quickStart: () => {
|
||||
const { width, height, mines, getValue, reveal } = get();
|
||||
for (let i = 0; i < width; i++) {
|
||||
for (let j = 0; j < height; j++) {
|
||||
const value = getValue(i, j);
|
||||
if (value === 0 && !mines[i][j]) {
|
||||
reveal(i, j);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
isValid: (x: number, y: number) => {
|
||||
const { width, height } = 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");
|
||||
}
|
||||
|
||||
const minesArray = Array.from({ length: width }, () =>
|
||||
new Array(height).fill(false),
|
||||
);
|
||||
const isRevealedArray = Array.from({ length: width }, () =>
|
||||
new Array(height).fill(false),
|
||||
);
|
||||
const isFlaggedArray = Array.from({ length: width }, () =>
|
||||
new Array(height).fill(false),
|
||||
);
|
||||
|
||||
let remainingMines = mines;
|
||||
while (remainingMines > 0) {
|
||||
const x = Math.floor(Math.random() * width);
|
||||
const y = Math.floor(Math.random() * height);
|
||||
if (!minesArray[x][y]) {
|
||||
minesArray[x][y] = true;
|
||||
remainingMines--;
|
||||
}
|
||||
}
|
||||
|
||||
set({
|
||||
width,
|
||||
height,
|
||||
mines: minesArray,
|
||||
isRevealed: isRevealedArray,
|
||||
isFlagged: isFlaggedArray,
|
||||
minesCount: mines,
|
||||
isGameOver: false,
|
||||
startTime: Date.now(),
|
||||
stage: 1,
|
||||
});
|
||||
},
|
||||
isMine: (x: number, y: number) => {
|
||||
const { mines } = get();
|
||||
return mines[x][y];
|
||||
},
|
||||
getNeighborMines: (x: number, y: number) => {
|
||||
const { mines } = get();
|
||||
const neighbors = [
|
||||
mines[x - 1]?.[y - 1],
|
||||
mines[x]?.[y - 1],
|
||||
mines[x + 1]?.[y - 1],
|
||||
mines[x - 1]?.[y],
|
||||
mines[x + 1]?.[y],
|
||||
mines[x - 1]?.[y + 1],
|
||||
mines[x]?.[y + 1],
|
||||
mines[x + 1]?.[y + 1],
|
||||
];
|
||||
return neighbors;
|
||||
},
|
||||
getNeighborFlags: (x: number, y: number) => {
|
||||
const { isFlagged } = get();
|
||||
const neighbors = [
|
||||
isFlagged[x - 1]?.[y - 1],
|
||||
isFlagged[x]?.[y - 1],
|
||||
isFlagged[x + 1]?.[y - 1],
|
||||
isFlagged[x - 1]?.[y],
|
||||
isFlagged[x + 1]?.[y],
|
||||
isFlagged[x - 1]?.[y + 1],
|
||||
isFlagged[x]?.[y + 1],
|
||||
isFlagged[x + 1]?.[y + 1],
|
||||
];
|
||||
return neighbors;
|
||||
},
|
||||
getWidth: () => {
|
||||
const { width } = get();
|
||||
return width;
|
||||
},
|
||||
getHeight: () => {
|
||||
const { height } = get();
|
||||
return height;
|
||||
},
|
||||
isTouched: () => {
|
||||
const { isRevealed, isFlagged } = get();
|
||||
return (
|
||||
isRevealed.flat().filter((flag) => flag).length > 0 ||
|
||||
isFlagged.flat().filter((flag) => flag).length > 0
|
||||
);
|
||||
},
|
||||
triggerPostGame: () => {
|
||||
const { getHasWon, expandBoard } = get();
|
||||
if (getHasWon()) {
|
||||
expandBoard();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
expandBoard: () => {
|
||||
const { width, height, stage, mines, isFlagged, isRevealed } = get();
|
||||
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);
|
||||
const newWidth = width;
|
||||
const newMinesCount = Math.floor(
|
||||
width * height * 0.5 * (0.2 + 0.003 * stage),
|
||||
);
|
||||
// expand mines array
|
||||
const newMines = Array.from({ length: newWidth }, () =>
|
||||
new Array(newHeight).fill(false),
|
||||
);
|
||||
const newIsRevealed = Array.from({ length: newWidth }, () =>
|
||||
new Array(newHeight).fill(false),
|
||||
);
|
||||
const newIsFlagged = Array.from({ length: newWidth }, () =>
|
||||
new Array(newHeight).fill(false),
|
||||
);
|
||||
for (let i = 0; i < newWidth; i++) {
|
||||
for (let j = 0; j < newHeight; j++) {
|
||||
const x = i;
|
||||
const y = j;
|
||||
if (mines[x]?.[y]) {
|
||||
newMines[i][j] = true;
|
||||
}
|
||||
if (isRevealed[x]?.[y]) {
|
||||
newIsRevealed[i][j] = true;
|
||||
}
|
||||
if (isFlagged[x]?.[y]) {
|
||||
newIsFlagged[i][j] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
// generate new mines
|
||||
let remainingMines = newMinesCount;
|
||||
while (remainingMines > 0) {
|
||||
const x = Math.floor(Math.random() * width);
|
||||
const y = height + Math.floor(Math.random() * (newHeight - height));
|
||||
if (!newMines[x][y]) {
|
||||
newMines[x][y] = true;
|
||||
remainingMines--;
|
||||
}
|
||||
}
|
||||
set({
|
||||
width: newWidth,
|
||||
height: newHeight,
|
||||
mines: newMines,
|
||||
minesCount: newMinesCount,
|
||||
stage: stage + 1,
|
||||
isRevealed: newIsRevealed,
|
||||
isFlagged: newIsFlagged,
|
||||
});
|
||||
}
|
||||
if (dir === "right") {
|
||||
const newWidth = Math.floor(width * 1.5);
|
||||
const newHeight = height;
|
||||
const newMinesCount = Math.floor(
|
||||
width * height * 0.5 * (0.2 + 0.003 * stage),
|
||||
);
|
||||
// expand mines array
|
||||
const newMines = Array.from({ length: newWidth }, () =>
|
||||
new Array(newHeight).fill(false),
|
||||
);
|
||||
const newIsRevealed = Array.from({ length: newWidth }, () =>
|
||||
new Array(newHeight).fill(false),
|
||||
);
|
||||
const newIsFlagged = Array.from({ length: newWidth }, () =>
|
||||
new Array(newHeight).fill(false),
|
||||
);
|
||||
for (let i = 0; i < newWidth; i++) {
|
||||
for (let j = 0; j < newHeight; j++) {
|
||||
const x = i;
|
||||
const y = j;
|
||||
if (mines[x]?.[y]) {
|
||||
newMines[i][j] = true;
|
||||
}
|
||||
if (isRevealed[x]?.[y]) {
|
||||
newIsRevealed[i][j] = true;
|
||||
}
|
||||
if (isFlagged[x]?.[y]) {
|
||||
newIsFlagged[i][j] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
// generate new mines
|
||||
let remainingMines = newMinesCount;
|
||||
while (remainingMines > 0) {
|
||||
const x = width + Math.floor(Math.random() * (newWidth - width));
|
||||
const y = Math.floor(Math.random() * height);
|
||||
if (!newMines[x][y]) {
|
||||
newMines[x][y] = true;
|
||||
remainingMines--;
|
||||
}
|
||||
}
|
||||
set({
|
||||
width: newWidth,
|
||||
height: newHeight,
|
||||
mines: newMines,
|
||||
minesCount: newMinesCount,
|
||||
stage: stage + 1,
|
||||
isRevealed: newIsRevealed,
|
||||
isFlagged: newIsFlagged,
|
||||
});
|
||||
}
|
||||
const newMinesCount = get()
|
||||
.mines.flat()
|
||||
.filter((m) => m).length;
|
||||
set({ minesCount: newMinesCount });
|
||||
},
|
||||
setName: (name) => {
|
||||
localStorage.setItem("name", name);
|
||||
set({ name });
|
||||
},
|
||||
setShowFeed: (showFeed) => {
|
||||
localStorage.setItem("showFeed", showFeed.toString());
|
||||
set({ showFeed });
|
||||
},
|
||||
}));
|
||||
|
||||
export default useGameStore;
|
||||
|
|
@ -1,96 +0,0 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import useGameStore from "./GameState";
|
||||
|
||||
const presets = {
|
||||
Easy: { width: 10, height: 10, mines: 20 },
|
||||
Medium: { width: 16, height: 16, mines: 32 },
|
||||
Expert: { width: 30, height: 16, mines: 99 },
|
||||
"Max Mode": { width: 40, height: 40, mines: 350 },
|
||||
} as const;
|
||||
|
||||
function Options() {
|
||||
const game = useGameStore();
|
||||
const [width, setWidth] = useState(16);
|
||||
const [height, setHeight] = useState(16);
|
||||
const [mines, setMines] = useState(32);
|
||||
const [showOptions, setShowOptions] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fixWidth = Math.min(40, width);
|
||||
const fixHeight = Math.min(40, height);
|
||||
setWidth(fixWidth);
|
||||
setHeight(fixHeight);
|
||||
}, [width, height]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!game.isTouched()) {
|
||||
if (width <= 0 || height <= 0 || mines <= 0) {
|
||||
return;
|
||||
}
|
||||
game.resetGame(width, height, mines);
|
||||
}
|
||||
}, [width, height, mines, game]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => setShowOptions(!showOptions)}>
|
||||
{showOptions ? "Hide" : "Show"} Options
|
||||
</button>
|
||||
{showOptions && (
|
||||
<>
|
||||
<p>
|
||||
Presets:{" "}
|
||||
{(Object.keys(presets) as Array<keyof typeof presets>).map(
|
||||
(key) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => {
|
||||
const { width, height, mines } = presets[key];
|
||||
setWidth(width);
|
||||
setHeight(height);
|
||||
setMines(mines);
|
||||
}}
|
||||
>
|
||||
{key}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
Width:{" "}
|
||||
<input
|
||||
type="number"
|
||||
value={width}
|
||||
onChange={(e) => setWidth(Number(e.target.value))}
|
||||
/>
|
||||
</p>
|
||||
<p>
|
||||
Height:{" "}
|
||||
<input
|
||||
type="number"
|
||||
value={height}
|
||||
onChange={(e) => setHeight(Number(e.target.value))}
|
||||
/>
|
||||
</p>
|
||||
<p>
|
||||
Mines:{" "}
|
||||
<input
|
||||
type="number"
|
||||
value={mines}
|
||||
onChange={(e) => setMines(Number(e.target.value))}
|
||||
/>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
game.resetGame(width, height, mines);
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Options;
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
import { PropsWithChildren, useEffect, useRef, useState } from "react";
|
||||
import { Button } from "./components/Button";
|
||||
import { motion } from "framer-motion";
|
||||
import {
|
||||
GitBranch,
|
||||
History,
|
||||
Home,
|
||||
Library,
|
||||
Menu,
|
||||
Play,
|
||||
Settings,
|
||||
Store,
|
||||
} from "lucide-react";
|
||||
import Hr from "./components/Hr";
|
||||
import NavLink from "./components/NavLink";
|
||||
import { useMediaQuery } from "@uidotdev/usehooks";
|
||||
import Header from "./components/Header";
|
||||
import { Tag } from "./components/Tag";
|
||||
import Feed from "./components/Feed/Feed";
|
||||
|
||||
const drawerWidth = 256;
|
||||
const drawerWidthWithPadding = drawerWidth;
|
||||
|
||||
const Shell: React.FC<PropsWithChildren> = ({ children }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const drawerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const x = isOpen ? 0 : -drawerWidthWithPadding;
|
||||
const width = isOpen ? drawerWidthWithPadding : 0;
|
||||
const isMobile = useMediaQuery("(max-width: 768px)");
|
||||
useEffect(() => {
|
||||
setIsOpen(!isMobile);
|
||||
}, [isMobile]);
|
||||
useEffect(() => {
|
||||
const onOutsideClick = (e: MouseEvent) => {
|
||||
if (
|
||||
drawerRef.current &&
|
||||
!drawerRef.current.contains(e.target as Node) &&
|
||||
isMobile
|
||||
) {
|
||||
setIsOpen(false);
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
document.addEventListener("click", onOutsideClick);
|
||||
return () => {
|
||||
document.removeEventListener("click", onOutsideClick);
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="bg-black min-h-screen">
|
||||
<motion.div
|
||||
className="bg-black p-4 fixed h-screen w-64 flex border-white/10 border-1"
|
||||
ref={drawerRef}
|
||||
animate={{ x }}
|
||||
transition={{ type: "tween" }}
|
||||
>
|
||||
<div className="w-full p-2 flex flex-col gap-6">
|
||||
<h2 className="[background:var(--bg-brand)] [-webkit-text-fill-color:transparent] font-black [-webkit-background-clip:text!important] font-mono text-3xl">
|
||||
Business
|
||||
<br />
|
||||
Minesweeper
|
||||
</h2>
|
||||
<Hr />
|
||||
<NavLink href="/">
|
||||
<Home />
|
||||
Home
|
||||
</NavLink>
|
||||
<NavLink href="/play">
|
||||
<Play />
|
||||
Play
|
||||
</NavLink>
|
||||
<NavLink href="/history">
|
||||
<History />
|
||||
History
|
||||
</NavLink>
|
||||
<NavLink href="/store">
|
||||
<Store />
|
||||
Store
|
||||
</NavLink>
|
||||
<NavLink href="/collection">
|
||||
<Library />
|
||||
Collection <Tag size="sm">NEW</Tag>
|
||||
</NavLink>
|
||||
<NavLink href="/settings">
|
||||
<Settings />
|
||||
Settings
|
||||
</NavLink>
|
||||
<Hr />
|
||||
<Feed />
|
||||
{/* <Hr /> */}
|
||||
<div className="grow" />
|
||||
<NavLink href="https://github.com/MasterGordon/minesweeper" external>
|
||||
<GitBranch />
|
||||
Source
|
||||
</NavLink>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Button
|
||||
className="absolute left-4 bg-black border-white/10 border-y-1 border-r-1 rounded-l-none"
|
||||
variant="ghost"
|
||||
onClick={() => setIsOpen((isOpen) => !isOpen)}
|
||||
aria-label="Menu"
|
||||
>
|
||||
<Menu />
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
<motion.div className="flex max-w-[100vw]">
|
||||
<motion.div
|
||||
className="hidden md:block"
|
||||
animate={{ width: width }}
|
||||
transition={{ type: "tween" }}
|
||||
layout
|
||||
/>
|
||||
<motion.div className="flex flex-col gap-4 grow max-w-7xl mx-auto w-[calc(100vw-256px)]">
|
||||
<div className="flex flex-col justify-center gap-4 sm:mx-16 mt-16 sm:mt-2 mx-2">
|
||||
<Header />
|
||||
{children}
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Shell;
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import Confetti from "react-confetti-boom";
|
||||
import useGameStore from "./GameState";
|
||||
|
||||
const emoteByStage = [
|
||||
"😐",
|
||||
"😐",
|
||||
"🙂",
|
||||
"🤔",
|
||||
"👀",
|
||||
"😎",
|
||||
"💀",
|
||||
"🤯",
|
||||
"🐐",
|
||||
"⚡",
|
||||
"🦸",
|
||||
"🔥",
|
||||
"💥",
|
||||
"🐶",
|
||||
"🦉",
|
||||
"🚀",
|
||||
"👾",
|
||||
];
|
||||
|
||||
const Timer = () => {
|
||||
const game = useGameStore();
|
||||
const [currentTime, setCurrentTime] = useState(Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
if (game.isGameOver || game.getHasWon()) {
|
||||
if (game.stage === 1) return;
|
||||
const name = game.name;
|
||||
if (name) {
|
||||
fetch("https://mb.gordon.business/submit", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
user: name,
|
||||
stage: game.stage,
|
||||
}),
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
const interval = setInterval(() => {
|
||||
setCurrentTime(Date.now());
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [game, game.isGameOver]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="stage">
|
||||
<p>
|
||||
Stage: {game.stage} ({game.getWidth()}x{game.getHeight()})
|
||||
</p>
|
||||
</div>
|
||||
<div className="timer">
|
||||
<p style={{ width: "100px" }}>{game.getMinesLeft()}</p>
|
||||
<p
|
||||
style={{
|
||||
fontSize: "2rem",
|
||||
}}
|
||||
>
|
||||
{game.getHasWon()
|
||||
? "😎"
|
||||
: game.isGameOver
|
||||
? "😢"
|
||||
: emoteByStage[game.stage] || "😐"}
|
||||
{game.stage > 1 && (
|
||||
<Confetti
|
||||
mode="boom"
|
||||
particleCount={20 * game.stage}
|
||||
key={game.stage}
|
||||
/>
|
||||
)}
|
||||
</p>
|
||||
<p style={{ width: "100px", textAlign: "right" }}>
|
||||
{Math.max(
|
||||
0,
|
||||
Math.floor((currentTime - (game.startTime || 0)) / 1000),
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Timer;
|
||||
|
After Width: | Height: | Size: 582 KiB |
|
After Width: | Height: | Size: 382 KiB |
|
After Width: | Height: | Size: 537 KiB |
|
After Width: | Height: | Size: 570 KiB |
|
After Width: | Height: | Size: 580 B |
|
After Width: | Height: | Size: 684 B |
|
After Width: | Height: | Size: 728 B |
|
After Width: | Height: | Size: 600 B |
|
After Width: | Height: | Size: 686 B |
|
After Width: | Height: | Size: 702 B |
|
After Width: | Height: | Size: 574 B |
|
After Width: | Height: | Size: 778 B |
|
After Width: | Height: | Size: 544 B |
|
After Width: | Height: | Size: 539 B |
|
After Width: | Height: | Size: 484 B |
|
After Width: | Height: | Size: 453 B |
|
After Width: | Height: | Size: 569 B |
|
After Width: | Height: | Size: 442 B |
|
After Width: | Height: | Size: 146 B |
|
After Width: | Height: | Size: 288 B |
|
After Width: | Height: | Size: 211 B |
|
After Width: | Height: | Size: 242 B |
|
After Width: | Height: | Size: 261 B |
|
After Width: | Height: | Size: 203 B |
|
After Width: | Height: | Size: 229 B |
|
After Width: | Height: | Size: 243 B |
|
After Width: | Height: | Size: 225 B |
|
After Width: | Height: | Size: 212 B |
|
After Width: | Height: | Size: 290 B |
|
After Width: | Height: | Size: 207 B |
|
After Width: | Height: | Size: 338 B |
|
After Width: | Height: | Size: 248 B |
|
After Width: | Height: | Size: 118 B |
|
After Width: | Height: | Size: 198 B |
|
After Width: | Height: | Size: 250 B |
|
After Width: | Height: | Size: 336 B |
|
After Width: | Height: | Size: 397 B |
|
After Width: | Height: | Size: 267 B |
|
After Width: | Height: | Size: 319 B |
|
After Width: | Height: | Size: 360 B |
|
After Width: | Height: | Size: 273 B |
|
After Width: | Height: | Size: 353 B |
|
After Width: | Height: | Size: 417 B |
|
After Width: | Height: | Size: 351 B |
|
After Width: | Height: | Size: 576 B |
|
After Width: | Height: | Size: 397 B |
|
After Width: | Height: | Size: 147 B |