added home and gameplay
This commit is contained in:
parent
825448c8f3
commit
2db2b42fd8
|
|
@ -1,7 +1,7 @@
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
import type { ServerWebSocket } from "bun";
|
import type { ServerWebSocket } from "bun";
|
||||||
import type { BunSQLiteDatabase } from "drizzle-orm/bun-sqlite";
|
import type { BunSQLiteDatabase } from "drizzle-orm/bun-sqlite";
|
||||||
import type { z } from "zod";
|
import type { z, ZodType } from "zod";
|
||||||
|
|
||||||
interface RequestContext {
|
interface RequestContext {
|
||||||
user?: string;
|
user?: string;
|
||||||
|
|
@ -9,15 +9,22 @@ interface RequestContext {
|
||||||
ws: ServerWebSocket<unknown>;
|
ws: ServerWebSocket<unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Endpoint<TInput, TResponse> = {
|
export type Endpoint<TInputSchema extends ZodType, TResponse> = {
|
||||||
validate: z.ZodType<TInput>;
|
validate: TInputSchema;
|
||||||
handler: (input: TInput, context: RequestContext) => Promise<TResponse>;
|
handler: (
|
||||||
|
input: z.infer<TInputSchema>,
|
||||||
|
context: RequestContext,
|
||||||
|
) => Promise<TResponse>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createEndpoint = <TInput, TResponse>(
|
export const createEndpoint = <
|
||||||
validate: z.ZodType<TInput>,
|
TInputSchema extends ZodType,
|
||||||
|
TResponse,
|
||||||
|
TInput = z.infer<TInputSchema>,
|
||||||
|
>(
|
||||||
|
validate: TInputSchema,
|
||||||
handler: (input: TInput, context: RequestContext) => Promise<TResponse>,
|
handler: (input: TInput, context: RequestContext) => Promise<TResponse>,
|
||||||
): Endpoint<TInput, TResponse> => {
|
): Endpoint<TInputSchema, TResponse> => {
|
||||||
return { validate, handler };
|
return { validate, handler };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { createController, createEndpoint } from "./controller";
|
||||||
import {
|
import {
|
||||||
getCurrentGame,
|
getCurrentGame,
|
||||||
getGame,
|
getGame,
|
||||||
|
parseGameState,
|
||||||
upsertGameState,
|
upsertGameState,
|
||||||
} from "../repositories/gameRepository";
|
} from "../repositories/gameRepository";
|
||||||
import {
|
import {
|
||||||
|
|
@ -18,7 +19,7 @@ import { emit } from "../events";
|
||||||
export const gameController = createController({
|
export const gameController = createController({
|
||||||
getGameState: createEndpoint(z.string(), async (uuid, ctx) => {
|
getGameState: createEndpoint(z.string(), async (uuid, ctx) => {
|
||||||
const game = await getGame(ctx.db, uuid);
|
const game = await getGame(ctx.db, uuid);
|
||||||
const parsed = JSON.parse(game.gameState);
|
const parsed = parseGameState(game.gameState);
|
||||||
const gameState = await serverGame.parseAsync(parsed);
|
const gameState = await serverGame.parseAsync(parsed);
|
||||||
if (game.finished) return gameState;
|
if (game.finished) return gameState;
|
||||||
return serverToClientGame(gameState);
|
return serverToClientGame(gameState);
|
||||||
|
|
@ -51,13 +52,79 @@ export const gameController = createController({
|
||||||
async ({ x, y }, { db, user }) => {
|
async ({ x, y }, { db, user }) => {
|
||||||
if (!user) throw new UnauthorizedError("Unauthorized");
|
if (!user) throw new UnauthorizedError("Unauthorized");
|
||||||
const dbGame = await getCurrentGame(db, user);
|
const dbGame = await getCurrentGame(db, user);
|
||||||
const serverGame = JSON.parse(dbGame.gameState);
|
const serverGame = parseGameState(dbGame.gameState);
|
||||||
game.reveal(serverGame, x, y);
|
const ts = serverGame.finished;
|
||||||
upsertGameState(db, serverGame);
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
placeFlag: 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);
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
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({
|
emit({
|
||||||
type: "updateGame",
|
type: "updateGame",
|
||||||
game: dbGame.uuid,
|
game: dbGame.uuid,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
clearTile: 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);
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
@ -1,8 +1,16 @@
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { createController, createEndpoint } from "./controller";
|
import { createController, createEndpoint } from "./controller";
|
||||||
import { loginUser, registerUser } from "../repositories/userRepository";
|
import {
|
||||||
|
getUserCount,
|
||||||
|
getUserSettings,
|
||||||
|
loginUser,
|
||||||
|
registerUser,
|
||||||
|
upsertUserSettings,
|
||||||
|
} from "../repositories/userRepository";
|
||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
import { resetSessionUser, setSessionUser } from "../router";
|
import { resetSessionUser, setSessionUser } from "../router";
|
||||||
|
import { userSettings } from "../../shared/user-settings";
|
||||||
|
import { UnauthorizedError } from "../errors/UnauthorizedError";
|
||||||
|
|
||||||
const secret = process.env.SECRET!;
|
const secret = process.env.SECRET!;
|
||||||
|
|
||||||
|
|
@ -63,4 +71,20 @@ export const userController = createController({
|
||||||
return { token: JSON.stringify({ session, sig }) };
|
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;
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
);
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"dialect": "sqlite",
|
"dialect": "sqlite",
|
||||||
"id": "af6e3102-34d0-4247-84ae-14f2d3d8fa4c",
|
"id": "2c470a78-d3d6-49b7-910c-eb8156e58a2c",
|
||||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||||
"tables": {
|
"tables": {
|
||||||
"games": {
|
"games": {
|
||||||
|
|
@ -23,7 +23,7 @@
|
||||||
},
|
},
|
||||||
"gameState": {
|
"gameState": {
|
||||||
"name": "gameState",
|
"name": "gameState",
|
||||||
"type": "text",
|
"type": "blob",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
|
|
@ -51,7 +51,39 @@
|
||||||
"autoincrement": false
|
"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": {
|
"foreignKeys": {
|
||||||
"games_user_users_name_fk": {
|
"games_user_users_name_fk": {
|
||||||
"name": "games_user_users_name_fk",
|
"name": "games_user_users_name_fk",
|
||||||
|
|
@ -92,6 +124,29 @@
|
||||||
"foreignKeys": {},
|
"foreignKeys": {},
|
||||||
"compositePrimaryKeys": {},
|
"compositePrimaryKeys": {},
|
||||||
"uniqueConstraints": {}
|
"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": {},
|
"enums": {},
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,8 @@
|
||||||
{
|
{
|
||||||
"idx": 0,
|
"idx": 0,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"when": 1726774158116,
|
"when": 1727551167145,
|
||||||
"tag": "0000_nostalgic_next_avengers",
|
"tag": "0000_gigantic_wasp",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,149 @@ const getNeighborFlagCount = (game: ServerGame, x: number, y: number) => {
|
||||||
return neighbors.filter((n) => n).length;
|
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 > 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),
|
||||||
|
);
|
||||||
|
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(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),
|
||||||
|
);
|
||||||
|
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 = {
|
export const game = {
|
||||||
createGame: (options: CreateGameOptions): ServerGame => {
|
createGame: (options: CreateGameOptions): ServerGame => {
|
||||||
const { uuid, user, width, height, mines } = options;
|
const { uuid, user, width, height, mines } = options;
|
||||||
|
|
@ -75,13 +218,19 @@ export const game = {
|
||||||
minesCount: mines,
|
minesCount: mines,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
reveal: (serverGame: ServerGame, x: number, y: number) => {
|
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 } =
|
const { mines, isRevealed, isFlagged, isQuestionMark, finished } =
|
||||||
serverGame;
|
serverGame;
|
||||||
if (finished) return;
|
if (finished) return;
|
||||||
|
if (!isValid(serverGame, x, y)) return;
|
||||||
if (isQuestionMark[x][y]) return;
|
if (isQuestionMark[x][y]) return;
|
||||||
if (isFlagged[x][y]) return;
|
if (isFlagged[x][y]) return;
|
||||||
if (!isValid(serverGame, x, y)) return;
|
|
||||||
serverGame.lastClick = [x, y];
|
serverGame.lastClick = [x, y];
|
||||||
|
|
||||||
if (mines[x][y]) {
|
if (mines[x][y]) {
|
||||||
|
|
@ -92,15 +241,15 @@ export const game = {
|
||||||
const value = getValue(serverGame.mines, x, y);
|
const value = getValue(serverGame.mines, x, y);
|
||||||
const neighborFlagCount = getNeighborFlagCount(serverGame, x, y);
|
const neighborFlagCount = getNeighborFlagCount(serverGame, x, y);
|
||||||
|
|
||||||
if (isRevealed[x][y] && value === neighborFlagCount) {
|
if (isRevealed[x][y] && value === neighborFlagCount && initial) {
|
||||||
if (!isFlagged[x - 1]?.[y]) game.reveal(serverGame, x - 1, y);
|
if (!isFlagged[x - 1]?.[y]) aux(serverGame, x - 1, y);
|
||||||
if (!isFlagged[x - 1]?.[y - 1]) game.reveal(serverGame, x - 1, y - 1);
|
if (!isFlagged[x - 1]?.[y - 1]) aux(serverGame, x - 1, y - 1);
|
||||||
if (!isFlagged[x - 1]?.[y + 1]) game.reveal(serverGame, x - 1, y + 1);
|
if (!isFlagged[x - 1]?.[y + 1]) aux(serverGame, x - 1, y + 1);
|
||||||
if (!isFlagged[x]?.[y - 1]) game.reveal(serverGame, x, y - 1);
|
if (!isFlagged[x]?.[y - 1]) aux(serverGame, x, y - 1);
|
||||||
if (!isFlagged[x]?.[y + 1]) game.reveal(serverGame, x, y + 1);
|
if (!isFlagged[x]?.[y + 1]) aux(serverGame, x, y + 1);
|
||||||
if (!isFlagged[x + 1]?.[y - 1]) game.reveal(serverGame, x + 1, y - 1);
|
if (!isFlagged[x + 1]?.[y - 1]) aux(serverGame, x + 1, y - 1);
|
||||||
if (!isFlagged[x + 1]?.[y]) game.reveal(serverGame, x + 1, y);
|
if (!isFlagged[x + 1]?.[y]) aux(serverGame, x + 1, y);
|
||||||
if (!isFlagged[x + 1]?.[y + 1]) game.reveal(serverGame, x + 1, y + 1);
|
if (!isFlagged[x + 1]?.[y + 1]) aux(serverGame, x + 1, y + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
serverGame.isRevealed[x][y] = true;
|
serverGame.isRevealed[x][y] = true;
|
||||||
|
|
@ -108,7 +257,7 @@ export const game = {
|
||||||
if (value === 0 && neighborFlagCount === 0) {
|
if (value === 0 && neighborFlagCount === 0) {
|
||||||
const revealNeighbors = (nx: number, ny: number) => {
|
const revealNeighbors = (nx: number, ny: number) => {
|
||||||
if (isValid(serverGame, nx, ny) && !isRevealed[nx]?.[ny]) {
|
if (isValid(serverGame, nx, ny) && !isRevealed[nx]?.[ny]) {
|
||||||
game.reveal(serverGame, nx, ny);
|
aux(serverGame, nx, ny);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -121,5 +270,38 @@ export const game = {
|
||||||
revealNeighbors(x, y + 1);
|
revealNeighbors(x, y + 1);
|
||||||
revealNeighbors(x + 1, 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.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);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import type { BunSQLiteDatabase } from "drizzle-orm/bun-sqlite";
|
||||||
import { Game, type GameType } from "../schema";
|
import { Game, type GameType } from "../schema";
|
||||||
import { eq, sql, desc, and, not } from "drizzle-orm";
|
import { eq, sql, desc, and, not } from "drizzle-orm";
|
||||||
import type { ServerGame } from "../../shared/game";
|
import type { ServerGame } from "../../shared/game";
|
||||||
|
import { decode, encode } from "@msgpack/msgpack";
|
||||||
|
|
||||||
export const getGame = async (db: BunSQLiteDatabase, uuid: string) => {
|
export const getGame = async (db: BunSQLiteDatabase, uuid: string) => {
|
||||||
return (await db.select().from(Game).where(eq(Game.uuid, uuid)))[0];
|
return (await db.select().from(Game).where(eq(Game.uuid, uuid)))[0];
|
||||||
|
|
@ -69,8 +70,12 @@ export const upsertGameState = async (
|
||||||
uuid,
|
uuid,
|
||||||
user,
|
user,
|
||||||
stage,
|
stage,
|
||||||
gameState: JSON.stringify(game),
|
gameState: Buffer.from(encode(game)),
|
||||||
finished,
|
finished,
|
||||||
started,
|
started,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const parseGameState = (gameState: Buffer) => {
|
||||||
|
return decode(gameState) as ServerGame;
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
import type { BunSQLiteDatabase } from "drizzle-orm/bun-sqlite";
|
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 { eq, sql } from "drizzle-orm";
|
||||||
|
import {
|
||||||
|
userSettings as userSettingsSchema,
|
||||||
|
type UserSettings as UserSettingsType,
|
||||||
|
} from "../../shared/user-settings";
|
||||||
|
|
||||||
export const registerUser = async (
|
export const registerUser = async (
|
||||||
db: BunSQLiteDatabase,
|
db: BunSQLiteDatabase,
|
||||||
|
|
@ -46,3 +50,44 @@ export const getUser = async (
|
||||||
.where(eq(sql`lower(${User.name})`, name.toLowerCase()));
|
.where(eq(sql`lower(${User.name})`, name.toLowerCase()));
|
||||||
return { ...user[0], password: undefined };
|
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;
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,12 @@ import { gameController } from "./controller/gameController";
|
||||||
import { db } from "./database/db";
|
import { db } from "./database/db";
|
||||||
import { userController } from "./controller/userController";
|
import { userController } from "./controller/userController";
|
||||||
import { ZodError } from "zod";
|
import { ZodError } from "zod";
|
||||||
|
import { scoreboardController } from "./controller/scoreboardController";
|
||||||
|
|
||||||
const controllers = {
|
const controllers = {
|
||||||
game: gameController,
|
game: gameController,
|
||||||
user: userController,
|
user: userController,
|
||||||
|
scoreboard: scoreboardController,
|
||||||
} satisfies Record<string, Controller<any>>;
|
} satisfies Record<string, Controller<any>>;
|
||||||
|
|
||||||
const userName = new WeakMap<ServerWebSocket<unknown>, string>();
|
const userName = new WeakMap<ServerWebSocket<unknown>, string>();
|
||||||
|
|
@ -47,8 +49,10 @@ export const handleRequest = async (
|
||||||
// @ts-expect-error controllers[controllerName] is a Controller
|
// @ts-expect-error controllers[controllerName] is a Controller
|
||||||
const endpoint = controllers[controllerName][action] as Endpoint<any, any>;
|
const endpoint = controllers[controllerName][action] as Endpoint<any, any>;
|
||||||
const input = endpoint.validate.parse(payload);
|
const input = endpoint.validate.parse(payload);
|
||||||
|
console.time(action);
|
||||||
const result = await endpoint.handler(input, ctx);
|
const result = await endpoint.handler(input, ctx);
|
||||||
ws.send(JSON.stringify({ id, payload: result }));
|
ws.send(JSON.stringify({ id, payload: result }));
|
||||||
|
console.timeEnd(action);
|
||||||
return;
|
return;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof ZodError) {
|
if (e instanceof ZodError) {
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,45 @@
|
||||||
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", {
|
export const User = sqliteTable("users", {
|
||||||
name: text("name").primaryKey().notNull(),
|
name: text("name").primaryKey().notNull(),
|
||||||
password: text("password").notNull(),
|
password: text("password").notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const Game = sqliteTable("games", {
|
export const Game = sqliteTable(
|
||||||
|
"games",
|
||||||
|
{
|
||||||
uuid: text("uuid").primaryKey().notNull(),
|
uuid: text("uuid").primaryKey().notNull(),
|
||||||
user: text("user")
|
user: text("user")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => User.name),
|
.references(() => User.name),
|
||||||
gameState: text("gameState").notNull(),
|
gameState: blob("gameState", { mode: "buffer" }).notNull(),
|
||||||
stage: integer("stage").notNull(),
|
stage: integer("stage").notNull(),
|
||||||
finished: integer("finished").notNull().default(0),
|
finished: integer("finished").notNull().default(0),
|
||||||
started: integer("timestamp").notNull(),
|
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 type UserType = Omit<typeof User.$inferSelect, "password"> & {
|
export type UserType = Omit<typeof User.$inferSelect, "password"> & {
|
||||||
password?: undefined;
|
password?: undefined;
|
||||||
};
|
};
|
||||||
export type GameType = typeof Game.$inferSelect;
|
export type GameType = typeof Game.$inferSelect;
|
||||||
|
export type UserSettingsType = typeof UserSettings.$inferSelect;
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@
|
||||||
"nukedb": "rm sqlite.db && bun run backend/migrate.ts"
|
"nukedb": "rm sqlite.db && bun run backend/migrate.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@msgpack/msgpack": "^3.0.0-beta2",
|
||||||
"@pixi/events": "^7.4.2",
|
"@pixi/events": "^7.4.2",
|
||||||
"@pixi/react": "^7.1.2",
|
"@pixi/react": "^7.1.2",
|
||||||
"@radix-ui/react-dialog": "^1.1.1",
|
"@radix-ui/react-dialog": "^1.1.1",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const userSettings = z.object({
|
||||||
|
placeQuestionMark: z.boolean().default(false),
|
||||||
|
longPressOnDesktop: z.boolean().default(false),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getDefaultSettings = () => {
|
||||||
|
return userSettings.parse({});
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UserSettings = z.infer<typeof userSettings>;
|
||||||
|
export type UserSettingsInput = z.input<typeof userSettings>;
|
||||||
|
|
@ -1,14 +1,7 @@
|
||||||
import { PropsWithChildren, useEffect, useRef, useState } from "react";
|
import { PropsWithChildren, useEffect, useRef, useState } from "react";
|
||||||
import { Button } from "./components/Button";
|
import { Button } from "./components/Button";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import {
|
import { GitBranch, History, Home, Menu, Play, Settings } from "lucide-react";
|
||||||
GitBranch,
|
|
||||||
History,
|
|
||||||
LayoutDashboard,
|
|
||||||
Menu,
|
|
||||||
Play,
|
|
||||||
Settings,
|
|
||||||
} from "lucide-react";
|
|
||||||
import Hr from "./components/Hr";
|
import Hr from "./components/Hr";
|
||||||
import NavLink from "./components/NavLink";
|
import NavLink from "./components/NavLink";
|
||||||
import { useMediaQuery } from "@uidotdev/usehooks";
|
import { useMediaQuery } from "@uidotdev/usehooks";
|
||||||
|
|
@ -48,7 +41,7 @@ const Shell: React.FC<PropsWithChildren> = ({ children }) => {
|
||||||
return (
|
return (
|
||||||
<div className="bg-black min-h-screen">
|
<div className="bg-black min-h-screen">
|
||||||
<motion.div
|
<motion.div
|
||||||
className="bg-black p-4 absolute h-screen w-64 flex border-white/10 border-1"
|
className="bg-black p-4 fixed h-screen w-64 flex border-white/10 border-1"
|
||||||
ref={drawerRef}
|
ref={drawerRef}
|
||||||
animate={{ x }}
|
animate={{ x }}
|
||||||
transition={{ type: "tween" }}
|
transition={{ type: "tween" }}
|
||||||
|
|
@ -61,8 +54,8 @@ const Shell: React.FC<PropsWithChildren> = ({ children }) => {
|
||||||
</h1>
|
</h1>
|
||||||
<Hr />
|
<Hr />
|
||||||
<NavLink href="/">
|
<NavLink href="/">
|
||||||
<LayoutDashboard />
|
<Home />
|
||||||
Dashboard
|
Home
|
||||||
</NavLink>
|
</NavLink>
|
||||||
<NavLink href="/play">
|
<NavLink href="/play">
|
||||||
<Play />
|
<Play />
|
||||||
|
|
@ -104,8 +97,6 @@ const Shell: React.FC<PropsWithChildren> = ({ children }) => {
|
||||||
<div className="flex flex-col justify-center gap-4 sm:mx-16 mt-16 sm:mt-2 mx-2">
|
<div className="flex flex-col justify-center gap-4 sm:mx-16 mt-16 sm:mt-2 mx-2">
|
||||||
<Header />
|
<Header />
|
||||||
{children}
|
{children}
|
||||||
{/* <div className="bg-gray-950 p-4 rounded-lg w-full"></div> */}
|
|
||||||
{/* <div className="bg-gray-950 p-4 rounded-lg w-full"></div> */}
|
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 382 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 537 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 570 KiB |
|
|
@ -6,3 +6,5 @@ export const loginTokenAtom = atomWithStorage<string | undefined>(
|
||||||
"loginToken",
|
"loginToken",
|
||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
|
export const cursorXAtom = atom(0);
|
||||||
|
export const cursorYAtom = atom(0);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,15 @@
|
||||||
import { ReactNode, useEffect, useRef, useState } from "react";
|
import {
|
||||||
|
ReactNode,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
import { LoadedTheme, Theme, useTheme } from "../themes/Theme";
|
import { LoadedTheme, Theme, useTheme } from "../themes/Theme";
|
||||||
import { Container, Sprite, Stage } from "@pixi/react";
|
import { Container, Sprite, Stage, useTick } from "@pixi/react";
|
||||||
import Viewport from "./pixi/PixiViewport";
|
import Viewport from "./pixi/PixiViewport";
|
||||||
|
import type { Viewport as PixiViewport } from "pixi-viewport";
|
||||||
import {
|
import {
|
||||||
ClientGame,
|
ClientGame,
|
||||||
getValue,
|
getValue,
|
||||||
|
|
@ -9,6 +17,15 @@ import {
|
||||||
ServerGame,
|
ServerGame,
|
||||||
} from "../../shared/game";
|
} from "../../shared/game";
|
||||||
import { useWSQuery } from "../hooks";
|
import { useWSQuery } from "../hooks";
|
||||||
|
import { Texture } from "pixi.js";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { cursorXAtom, cursorYAtom } from "../atoms";
|
||||||
|
import Coords from "./Coords";
|
||||||
|
import { cn } from "../lib/utils";
|
||||||
|
import { Button } from "./Button";
|
||||||
|
import { Maximize2, Minimize2 } from "lucide-react";
|
||||||
|
import useSound from "use-sound";
|
||||||
|
import explosion from "../sound/explosion.mp3";
|
||||||
|
|
||||||
interface BoardProps {
|
interface BoardProps {
|
||||||
theme: Theme;
|
theme: Theme;
|
||||||
|
|
@ -17,6 +34,22 @@ interface BoardProps {
|
||||||
onRightClick: (x: number, y: number) => void;
|
onRightClick: (x: number, y: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ViewportInfo {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toViewportInfo = (viewport: PixiViewport) => {
|
||||||
|
return {
|
||||||
|
x: -viewport.x / viewport.scaled,
|
||||||
|
y: -viewport.y / viewport.scaled,
|
||||||
|
width: viewport.screenWidth / viewport.scaled,
|
||||||
|
height: viewport.screenHeight / viewport.scaled,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const Board: React.FC<BoardProps> = (props) => {
|
const Board: React.FC<BoardProps> = (props) => {
|
||||||
const { game } = props;
|
const { game } = props;
|
||||||
const { data: user } = useWSQuery("user.getSelf", null);
|
const { data: user } = useWSQuery("user.getSelf", null);
|
||||||
|
|
@ -24,36 +57,100 @@ const Board: React.FC<BoardProps> = (props) => {
|
||||||
const [width, setWidth] = useState(0);
|
const [width, setWidth] = useState(0);
|
||||||
const [height, setHeight] = useState(0);
|
const [height, setHeight] = useState(0);
|
||||||
const showLastPos = game.user !== user || isServerGame(game);
|
const showLastPos = game.user !== user || isServerGame(game);
|
||||||
|
const [playSound] = useSound(explosion, {
|
||||||
|
volume: 0.5,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isServerGame(game) && game.finished > Date.now() - 100) {
|
||||||
|
playSound();
|
||||||
|
}
|
||||||
|
}, [game, playSound]);
|
||||||
|
|
||||||
|
const [viewport, setViewport] = useState<ViewportInfo>({
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const onViewportChange = useCallback((viewport: PixiViewport) => {
|
||||||
|
setViewport((v) => {
|
||||||
|
const { width, height, x, y } = toViewportInfo(viewport);
|
||||||
|
if (v.width !== width || v.height !== height) {
|
||||||
|
return { width, height, x, y };
|
||||||
|
}
|
||||||
|
if (Math.abs(v.x - x) > 16 || Math.abs(v.y - y) > 16) {
|
||||||
|
return { width, height, x, y };
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
useEffect(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (viewportRef.current) onViewportChange(viewportRef.current);
|
||||||
|
}, 200);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [game.width, game.height]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ref.current) return;
|
if (!ref.current) return;
|
||||||
setWidth(ref.current.clientWidth);
|
setWidth(ref.current.clientWidth);
|
||||||
setHeight(ref.current.clientHeight);
|
setHeight(ref.current.clientHeight);
|
||||||
|
if (viewportRef.current) onViewportChange(viewportRef.current);
|
||||||
const resizeObserver = new ResizeObserver(() => {
|
const resizeObserver = new ResizeObserver(() => {
|
||||||
if (ref.current) {
|
if (ref.current) {
|
||||||
setWidth(ref.current.clientWidth);
|
setWidth(ref.current.clientWidth);
|
||||||
setHeight(ref.current.clientHeight);
|
setHeight(ref.current.clientHeight);
|
||||||
|
if (viewportRef.current) onViewportChange(viewportRef.current);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
resizeObserver.observe(ref.current);
|
resizeObserver.observe(ref.current);
|
||||||
return () => resizeObserver.disconnect();
|
return () => resizeObserver.disconnect();
|
||||||
}, []);
|
}, [onViewportChange]);
|
||||||
const theme = useTheme(props.theme);
|
const theme = useTheme(props.theme);
|
||||||
const boardWidth = game.width * (theme?.size || 0);
|
const boardWidth = game.width * (theme?.size || 0);
|
||||||
const boardHeight = game.height * (theme?.size || 0);
|
const boardHeight = game.height * (theme?.size || 0);
|
||||||
|
|
||||||
|
const viewportRef = useRef<PixiViewport>(null);
|
||||||
|
const [zenMode, setZenMode] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className="flex flex-col w-full">
|
||||||
<div
|
<div
|
||||||
className="w-full h-[70vh] overflow-hidden border-red-500 border-2 flex select-none"
|
className={cn(
|
||||||
|
"w-full h-[70vh] overflow-hidden border-white/40 border-2 flex flex-col",
|
||||||
|
zenMode && "fixed top-0 left-0 z-50 right-0 bottom-0 h-[100vh]",
|
||||||
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
>
|
>
|
||||||
|
<div className="relative">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setZenMode(!zenMode)}
|
||||||
|
className="absolute right-4 top-4 text-white/70"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{!zenMode ? (
|
||||||
|
<Maximize2 className="size-4" />
|
||||||
|
) : (
|
||||||
|
<Minimize2 className="size-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
{zenMode && (
|
||||||
|
<div className="absolute top-4 left-4 w-full h-full text-white/70 font-mono text-lg">
|
||||||
|
{game.minesCount - game.isFlagged.flat().filter((f) => f).length}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{theme && (
|
{theme && (
|
||||||
<Stage
|
<Stage
|
||||||
options={{ hello: true, antialias: false }}
|
options={{ hello: true }}
|
||||||
width={width}
|
width={width}
|
||||||
height={height}
|
height={height}
|
||||||
|
className="select-none"
|
||||||
>
|
>
|
||||||
<Viewport
|
<Viewport
|
||||||
|
viewportRef={viewportRef}
|
||||||
worldWidth={boardWidth}
|
worldWidth={boardWidth}
|
||||||
worldHeight={boardHeight}
|
worldHeight={boardHeight}
|
||||||
width={width}
|
width={width}
|
||||||
|
|
@ -64,9 +161,23 @@ const Board: React.FC<BoardProps> = (props) => {
|
||||||
top: -theme.size,
|
top: -theme.size,
|
||||||
bottom: boardHeight + theme.size,
|
bottom: boardHeight + theme.size,
|
||||||
}}
|
}}
|
||||||
|
clampZoom={{
|
||||||
|
minScale: 1,
|
||||||
|
}}
|
||||||
|
onViewportChange={onViewportChange}
|
||||||
>
|
>
|
||||||
{game.isRevealed.map((_, i) => {
|
{Array.from({ length: game.width }).map((_, i) => {
|
||||||
return game.isRevealed[0].map((_, j) => {
|
return Array.from({ length: game.height }).map((_, j) => {
|
||||||
|
const tollerance = theme.size * 3;
|
||||||
|
if (i * theme.size > viewport.x + viewport.width + tollerance)
|
||||||
|
return null;
|
||||||
|
if (i * theme.size < viewport.x - tollerance) return null;
|
||||||
|
if (
|
||||||
|
j * theme.size >
|
||||||
|
viewport.y + viewport.height + tollerance
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
if (j * theme.size < viewport.y - tollerance) return null;
|
||||||
return (
|
return (
|
||||||
<Tile
|
<Tile
|
||||||
key={`${i},${j}`}
|
key={`${i},${j}`}
|
||||||
|
|
@ -85,6 +196,8 @@ const Board: React.FC<BoardProps> = (props) => {
|
||||||
</Stage>
|
</Stage>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<Coords />
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -120,26 +233,55 @@ const Tile = ({
|
||||||
const isFlagged = game.isFlagged[i][j];
|
const isFlagged = game.isFlagged[i][j];
|
||||||
const isQuestionMark = game.isQuestionMark[i][j];
|
const isQuestionMark = game.isQuestionMark[i][j];
|
||||||
const base = isRevealed ? (
|
const base = isRevealed ? (
|
||||||
<Sprite image={theme.revealed} />
|
<Sprite key="b" texture={theme.revealed} />
|
||||||
) : (
|
) : (
|
||||||
<Sprite image={theme.tile} />
|
<Sprite key="b" texture={theme.tile} />
|
||||||
);
|
);
|
||||||
let content: ReactNode = null;
|
const extra = isLastPos ? <Sprite key="e" texture={theme.lastPos} /> : null;
|
||||||
if (isMine) {
|
|
||||||
content = <Sprite image={theme.mine} />;
|
|
||||||
} else if (value !== -1 && isRevealed) {
|
|
||||||
const img = theme[value.toString() as keyof Theme] as string;
|
|
||||||
content = img ? <Sprite image={img} /> : null;
|
|
||||||
} else if (isFlagged) {
|
|
||||||
content = <Sprite image={theme.flag} />;
|
|
||||||
} else if (isQuestionMark) {
|
|
||||||
content = <Sprite image={theme.questionMark} />;
|
|
||||||
}
|
|
||||||
const extra = isLastPos ? <Sprite image={theme.lastPos} /> : null;
|
|
||||||
const touchStart = useRef<number>(0);
|
const touchStart = useRef<number>(0);
|
||||||
const isMove = useRef<boolean>(false);
|
const isMove = useRef<boolean>(false);
|
||||||
const startX = useRef<number>(0);
|
const startX = useRef<number>(0);
|
||||||
const startY = useRef<number>(0);
|
const startY = useRef<number>(0);
|
||||||
|
const oldState = useRef<string>(`${isRevealed},${isMine},${value}`);
|
||||||
|
const [scale, setScale] = useState(1);
|
||||||
|
const [doTick, setDoTick] = useState(true);
|
||||||
|
const frame = useRef<number>(0);
|
||||||
|
useEffect(() => {
|
||||||
|
if (oldState.current !== `${isRevealed},${isMine},${value}`) {
|
||||||
|
oldState.current = `${isRevealed},${isMine},${value}`;
|
||||||
|
frame.current = 0;
|
||||||
|
setDoTick(true);
|
||||||
|
}
|
||||||
|
}, [isMine, isRevealed, value]);
|
||||||
|
useTick((delta) => {
|
||||||
|
frame.current += delta * 0.1;
|
||||||
|
if (frame.current > 3) {
|
||||||
|
setDoTick(false);
|
||||||
|
}
|
||||||
|
setScale(Math.max(1, -2 * Math.pow(frame.current - 0.5, 2) + 1.2));
|
||||||
|
}, doTick);
|
||||||
|
const baseProps = useMemo(
|
||||||
|
() => ({
|
||||||
|
scale,
|
||||||
|
x: theme.size * 0.5,
|
||||||
|
y: theme.size * 0.5,
|
||||||
|
anchor: 0.5,
|
||||||
|
}),
|
||||||
|
[scale, theme.size],
|
||||||
|
);
|
||||||
|
let content: ReactNode = null;
|
||||||
|
if (isFlagged) {
|
||||||
|
content = <Sprite key="c" texture={theme.flag} {...baseProps} />;
|
||||||
|
} else if (isMine) {
|
||||||
|
content = <Sprite key="c" texture={theme.mine} {...baseProps} />;
|
||||||
|
} else if (value !== -1 && isRevealed) {
|
||||||
|
const img = theme[value.toString() as keyof Theme] as Texture;
|
||||||
|
content = img ? <Sprite key="c" texture={img} {...baseProps} /> : null;
|
||||||
|
} else if (isQuestionMark) {
|
||||||
|
content = <Sprite key="c" texture={theme.questionMark} {...baseProps} />;
|
||||||
|
}
|
||||||
|
const [, setCursorX] = useAtom(cursorXAtom);
|
||||||
|
const [, setCursorY] = useAtom(cursorYAtom);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container
|
<Container
|
||||||
|
|
@ -166,6 +308,10 @@ const Tile = ({
|
||||||
startX.current = e.global.x;
|
startX.current = e.global.x;
|
||||||
startY.current = e.global.y;
|
startY.current = e.global.y;
|
||||||
}}
|
}}
|
||||||
|
onpointerenter={() => {
|
||||||
|
setCursorX(i);
|
||||||
|
setCursorY(j);
|
||||||
|
}}
|
||||||
onpointermove={(e) => {
|
onpointermove={(e) => {
|
||||||
if (
|
if (
|
||||||
Math.abs(startX.current - e.global.x) > 10 ||
|
Math.abs(startX.current - e.global.x) > 10 ||
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { cursorXAtom, cursorYAtom } from "../atoms";
|
||||||
|
|
||||||
|
const Coords = () => {
|
||||||
|
const [cursorX] = useAtom(cursorXAtom);
|
||||||
|
const [cursorY] = useAtom(cursorYAtom);
|
||||||
|
return (
|
||||||
|
<div className="text-xs text-white/70">
|
||||||
|
{cursorX},{cursorY}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Coords;
|
||||||
|
|
@ -11,7 +11,6 @@ import { useLocation } from "wouter";
|
||||||
import LoginButton from "./Auth/LoginButton";
|
import LoginButton from "./Auth/LoginButton";
|
||||||
import { useWSMutation, useWSQuery } from "../hooks";
|
import { useWSMutation, useWSQuery } from "../hooks";
|
||||||
import RegisterButton from "./Auth/RegisterButton";
|
import RegisterButton from "./Auth/RegisterButton";
|
||||||
import banner from "../images/banner.png";
|
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
const Header = () => {
|
const Header = () => {
|
||||||
|
|
@ -25,8 +24,6 @@ const Header = () => {
|
||||||
return (
|
return (
|
||||||
<div className="w-full flex gap-4">
|
<div className="w-full flex gap-4">
|
||||||
<div className="grow" />
|
<div className="grow" />
|
||||||
<img src={banner} className="w-auto h-16 hidden sm:block" />
|
|
||||||
<div className="grow" />
|
|
||||||
|
|
||||||
{username ? (
|
{username ? (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
import { useWSQuery } from "../hooks";
|
||||||
|
import { Button } from "./Button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "./Dialog";
|
||||||
|
|
||||||
|
interface LeaderboardButtonProps {
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LeaderboardButton = ({
|
||||||
|
label = "View Full Leaderboard",
|
||||||
|
}: LeaderboardButtonProps) => {
|
||||||
|
const { data: leaderboard } = useWSQuery("scoreboard.getScoreBoard", 10);
|
||||||
|
return (
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button className="w-fit text-white/80 self-center" variant="outline">
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Leaderboard</DialogTitle>
|
||||||
|
<div className="grid grid-cols-[min-content_2fr_1fr] grid-border-b">
|
||||||
|
{leaderboard?.map((_, i) => (
|
||||||
|
<>
|
||||||
|
<div className="p-4 text-white/80 text-right">{i + 1}.</div>
|
||||||
|
<div className="p-4 text-white/90">
|
||||||
|
{leaderboard?.[i]?.user ?? "No User"}
|
||||||
|
</div>
|
||||||
|
<div className="p-4 text-white/90">
|
||||||
|
Stage {leaderboard?.[i]?.stage ?? 0}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</DialogHeader>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LeaderboardButton;
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
import { forwardRef } from "react";
|
||||||
|
import { cn } from "../lib/utils";
|
||||||
|
|
||||||
|
const tagVariants = cva("font-semibold py-2 px-4 rounded-md flex gap-2", {
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-gray-900 text-white/95",
|
||||||
|
ghost: "bg-transparent text-white/95 hover:bg-white/05",
|
||||||
|
outline:
|
||||||
|
"bg-transparent text-white/95 hover:bg-white/05 border-white/10 border-1",
|
||||||
|
outline2:
|
||||||
|
"bg-transparent [background:var(--bg-brand)] [-webkit-text-fill-color:transparent] [-webkit-background-clip:text!important] bg-white/05 border-primary border-1",
|
||||||
|
primary:
|
||||||
|
"[background:var(--bg-brand)] text-white/95 hover:bg-white/05 hover:animate-gradientmove",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-10 py-2 px-4",
|
||||||
|
sm: "h-9 px-3 rounded-md",
|
||||||
|
lg: "h-11 px-8 rounded-md",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ButtonProps = React.HTMLAttributes<HTMLDivElement> &
|
||||||
|
VariantProps<typeof tagVariants>;
|
||||||
|
|
||||||
|
export const Tag = forwardRef<HTMLDivElement, ButtonProps>(
|
||||||
|
({ className, variant, size, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(tagVariants({ variant, size, className }))}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import * as PIXI from "pixi.js";
|
import * as PIXI from "pixi.js";
|
||||||
import { Viewport as PixiViewport } from "pixi-viewport";
|
import { IClampZoomOptions, Viewport as PixiViewport } from "pixi-viewport";
|
||||||
import { PixiComponent, useApp } from "@pixi/react";
|
import { PixiComponent, useApp } from "@pixi/react";
|
||||||
import { BaseTexture, SCALE_MODES } from "pixi.js";
|
import { BaseTexture, SCALE_MODES } from "pixi.js";
|
||||||
BaseTexture.defaultOptions.scaleMode = SCALE_MODES.NEAREST;
|
BaseTexture.defaultOptions.scaleMode = SCALE_MODES.NEAREST;
|
||||||
|
|
@ -17,6 +17,9 @@ export interface ViewportProps {
|
||||||
top: number;
|
top: number;
|
||||||
bottom: number;
|
bottom: number;
|
||||||
};
|
};
|
||||||
|
clampZoom?: IClampZoomOptions;
|
||||||
|
onViewportChange?: (viewport: PixiViewport) => void;
|
||||||
|
viewportRef?: React.RefObject<PixiViewport>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PixiComponentViewportProps extends ViewportProps {
|
export interface PixiComponentViewportProps extends ViewportProps {
|
||||||
|
|
@ -45,6 +48,20 @@ const PixiComponentViewport = PixiComponent("Viewport", {
|
||||||
if (props.clamp) {
|
if (props.clamp) {
|
||||||
viewport.clamp(props.clamp);
|
viewport.clamp(props.clamp);
|
||||||
}
|
}
|
||||||
|
if (props.clampZoom) {
|
||||||
|
viewport.clampZoom(props.clampZoom);
|
||||||
|
}
|
||||||
|
viewport.on("moved", () => {
|
||||||
|
props.onViewportChange?.(viewport);
|
||||||
|
});
|
||||||
|
viewport.on("zoomed-end", () => {
|
||||||
|
props.onViewportChange?.(viewport);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (props.viewportRef) {
|
||||||
|
// @ts-expect-error We dont care since this is internal api
|
||||||
|
props.viewportRef.current = viewport;
|
||||||
|
}
|
||||||
|
|
||||||
return viewport;
|
return viewport;
|
||||||
},
|
},
|
||||||
|
|
@ -55,9 +72,16 @@ const PixiComponentViewport = PixiComponent("Viewport", {
|
||||||
) => {
|
) => {
|
||||||
if (
|
if (
|
||||||
oldProps.width !== newProps.width ||
|
oldProps.width !== newProps.width ||
|
||||||
oldProps.height !== newProps.height
|
oldProps.height !== newProps.height ||
|
||||||
|
oldProps.worldWidth !== newProps.worldWidth ||
|
||||||
|
oldProps.worldHeight !== newProps.worldHeight
|
||||||
) {
|
) {
|
||||||
viewport.resize(newProps.width, newProps.height);
|
viewport.resize(
|
||||||
|
newProps.width,
|
||||||
|
newProps.height,
|
||||||
|
newProps.worldWidth,
|
||||||
|
newProps.worldHeight,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (oldProps.clamp !== newProps.clamp) {
|
if (oldProps.clamp !== newProps.clamp) {
|
||||||
viewport.clamp(newProps.clamp);
|
viewport.clamp(newProps.clamp);
|
||||||
|
|
|
||||||
14
src/hooks.ts
14
src/hooks.ts
|
|
@ -1,11 +1,14 @@
|
||||||
import {
|
import {
|
||||||
|
keepPreviousData,
|
||||||
useMutation,
|
useMutation,
|
||||||
|
UseMutationResult,
|
||||||
useQuery,
|
useQuery,
|
||||||
useQueryClient,
|
useQueryClient,
|
||||||
UseQueryResult,
|
UseQueryResult,
|
||||||
} from "@tanstack/react-query";
|
} from "@tanstack/react-query";
|
||||||
import { Routes } from "../backend/router";
|
import { Routes } from "../backend/router";
|
||||||
import { wsClient } from "./wsClient";
|
import { wsClient } from "./wsClient";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
export const useWSQuery = <
|
export const useWSQuery = <
|
||||||
TController extends keyof Routes,
|
TController extends keyof Routes,
|
||||||
|
|
@ -13,7 +16,7 @@ export const useWSQuery = <
|
||||||
>(
|
>(
|
||||||
action: `${TController}.${TAction}`,
|
action: `${TController}.${TAction}`,
|
||||||
// @ts-expect-error We dont care since this is internal api
|
// @ts-expect-error We dont care since this is internal api
|
||||||
payload: Routes[TController][TAction]["validate"]["_input"],
|
payload: z.input<Routes[TController][TAction]["validate"]>,
|
||||||
enabled?: boolean,
|
enabled?: boolean,
|
||||||
): UseQueryResult<
|
): UseQueryResult<
|
||||||
// @ts-expect-error We dont care since this is internal api
|
// @ts-expect-error We dont care since this is internal api
|
||||||
|
|
@ -26,6 +29,7 @@ export const useWSQuery = <
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
enabled,
|
enabled,
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -40,7 +44,13 @@ export const useWSMutation = <
|
||||||
ReturnType<Routes[TController][TAction]["handler"]>
|
ReturnType<Routes[TController][TAction]["handler"]>
|
||||||
>,
|
>,
|
||||||
) => void,
|
) => void,
|
||||||
) => {
|
): UseMutationResult<
|
||||||
|
// @ts-expect-error We dont care since this is internal api
|
||||||
|
Awaited<ReturnType<Routes[TController][TAction]["handler"]>>,
|
||||||
|
unknown,
|
||||||
|
// @ts-expect-error We dont care since this is internal api
|
||||||
|
Routes[TController][TAction]["validate"]["_input"]
|
||||||
|
> => {
|
||||||
return useMutation({
|
return useMutation({
|
||||||
// @ts-expect-error We dont care since this is internal api
|
// @ts-expect-error We dont care since this is internal api
|
||||||
mutationFn: async (
|
mutationFn: async (
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
--color-primary: hotpink;
|
--color-primary: rgb(251, 21, 242);
|
||||||
--bg-brand: -webkit-linear-gradient(225deg, rgb(251, 175, 21), rgb(251, 21, 242),
|
--bg-brand: -webkit-linear-gradient(225deg, rgb(251, 175, 21), rgb(251, 21, 242),
|
||||||
rgb(21, 198, 251)) 0% 0% / 100% 300%;
|
rgb(21, 198, 251)) 0% 0% / 100% 300%;
|
||||||
--bg-secondary: linear-gradient(90deg, #D9AFD9 0%, #97D9E1 100%) 0% 0% / 100% 300%;
|
--bg-secondary: linear-gradient(90deg, #D9AFD9 0%, #97D9E1 100%) 0% 0% / 100% 300%;
|
||||||
|
|
@ -16,6 +16,10 @@ button {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.grid-border-b div:not(:nth-last-child(-n+3)) {
|
||||||
|
@apply border-b border-white/10;
|
||||||
|
}
|
||||||
|
|
||||||
/* .game-board { */
|
/* .game-board { */
|
||||||
/* display: grid; */
|
/* display: grid; */
|
||||||
/* gap: 2px; */
|
/* gap: 2px; */
|
||||||
|
|
|
||||||
14
src/main.tsx
14
src/main.tsx
|
|
@ -10,6 +10,7 @@ import { wsClient } from "./wsClient.ts";
|
||||||
import { Route, Switch } from "wouter";
|
import { Route, Switch } from "wouter";
|
||||||
import Endless from "./views/endless/Endless.tsx";
|
import Endless from "./views/endless/Endless.tsx";
|
||||||
import { queryClient } from "./queryClient.ts";
|
import { queryClient } from "./queryClient.ts";
|
||||||
|
import Home from "./views/home/Home.tsx";
|
||||||
|
|
||||||
connectWS();
|
connectWS();
|
||||||
|
|
||||||
|
|
@ -34,7 +35,20 @@ setup().then(() => {
|
||||||
<Toaster position="top-right" reverseOrder={false} />
|
<Toaster position="top-right" reverseOrder={false} />
|
||||||
<Shell>
|
<Shell>
|
||||||
<Switch>
|
<Switch>
|
||||||
|
<Route path="/" component={Home} />
|
||||||
<Route path="/play" component={Endless} />
|
<Route path="/play" component={Endless} />
|
||||||
|
<Route
|
||||||
|
path="/history"
|
||||||
|
component={() => (
|
||||||
|
<h2 className="text-white/80 text-2xl">Comming Soon</h2>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/settings"
|
||||||
|
component={() => (
|
||||||
|
<h2 className="text-white/80 text-2xl">Comming Soon</h2>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</Switch>
|
</Switch>
|
||||||
{/* <App /> */}
|
{/* <App /> */}
|
||||||
</Shell>
|
</Shell>
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { Assets, Texture } from "pixi.js";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
type Png = typeof import("*.png");
|
type Png = typeof import("*.png");
|
||||||
|
|
@ -21,7 +22,7 @@ export interface Theme {
|
||||||
8: LazySprite;
|
8: LazySprite;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LoadedTheme = Record<Exclude<keyof Theme, "size">, string> & {
|
export type LoadedTheme = Record<Exclude<keyof Theme, "size">, Texture> & {
|
||||||
size: number;
|
size: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -34,7 +35,9 @@ export const useTheme = (theme: Theme) => {
|
||||||
const loadedEntries = await Promise.all(
|
const loadedEntries = await Promise.all(
|
||||||
Object.entries(theme).map(async ([key, value]) => {
|
Object.entries(theme).map(async ([key, value]) => {
|
||||||
const loaded =
|
const loaded =
|
||||||
typeof value === "function" ? (await value()).default : value;
|
typeof value === "function"
|
||||||
|
? await Assets.load((await value()).default)
|
||||||
|
: value;
|
||||||
return [key, loaded] as const;
|
return [key, loaded] as const;
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,34 +1,42 @@
|
||||||
import { defaultTheme } from "../../themes/default";
|
import { defaultTheme } from "../../themes/default";
|
||||||
import Board from "../../components/Board";
|
import Board from "../../components/Board";
|
||||||
import toast from "react-hot-toast";
|
|
||||||
import { useWSMutation, useWSQuery } from "../../hooks";
|
import { useWSMutation, useWSQuery } from "../../hooks";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { gameIdAtom } from "../../atoms";
|
import { gameIdAtom } from "../../atoms";
|
||||||
import { Button } from "../../components/Button";
|
import { Button } from "../../components/Button";
|
||||||
|
import LeaderboardButton from "../../components/LeaderboardButton";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
const Endless = () => {
|
const Endless = () => {
|
||||||
const [gameId, setGameId] = useAtom(gameIdAtom);
|
const [gameId, setGameId] = useAtom(gameIdAtom);
|
||||||
const { data: game } = useWSQuery("game.getGameState", gameId!, !!gameId);
|
const { data: game } = useWSQuery("game.getGameState", gameId!, !!gameId);
|
||||||
|
const { data: settings } = useWSQuery("user.getSettings", null);
|
||||||
const startGame = useWSMutation("game.createGame");
|
const startGame = useWSMutation("game.createGame");
|
||||||
|
const { data: leaderboard } = useWSQuery("scoreboard.getScoreBoard", 10);
|
||||||
const reveal = useWSMutation("game.reveal");
|
const reveal = useWSMutation("game.reveal");
|
||||||
|
const placeFlag = useWSMutation("game.placeFlag");
|
||||||
|
const placeQuestionMark = useWSMutation("game.placeQuestionMark");
|
||||||
|
const clearTile = useWSMutation("game.clearTile");
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
setGameId(undefined);
|
||||||
|
};
|
||||||
|
}, [setGameId]);
|
||||||
|
|
||||||
return (
|
return game ? (
|
||||||
<>
|
<>
|
||||||
<div className="w-full flex flex-col text-white/90 gap-4">
|
<div className="w-full flex text-white/90 gap-4">
|
||||||
<h1>Endless</h1>
|
|
||||||
<p>A game where you have to click on the mines</p>
|
|
||||||
<div className="flex gap-4">
|
|
||||||
<Button
|
<Button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
const gameId = await startGame.mutateAsync(null);
|
const gameId = await startGame.mutateAsync(null);
|
||||||
setGameId(gameId.uuid);
|
setGameId(gameId.uuid);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Start
|
Restart
|
||||||
</Button>
|
</Button>
|
||||||
|
<div className="grow" />
|
||||||
|
<LeaderboardButton label="View Leaderboard" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
{game && (
|
|
||||||
<Board
|
<Board
|
||||||
key={game.uuid}
|
key={game.uuid}
|
||||||
theme={defaultTheme}
|
theme={defaultTheme}
|
||||||
|
|
@ -37,11 +45,67 @@ const Endless = () => {
|
||||||
reveal.mutateAsync({ x, y });
|
reveal.mutateAsync({ x, y });
|
||||||
}}
|
}}
|
||||||
onRightClick={(x, y) => {
|
onRightClick={(x, y) => {
|
||||||
toast.success(`Right click ${x},${y}`);
|
const isFlagged = game.isFlagged[x][y];
|
||||||
|
const isQuestionMark = game.isQuestionMark[x][y];
|
||||||
|
if (!isFlagged && !isQuestionMark) {
|
||||||
|
placeFlag.mutateAsync({ x, y });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isFlagged && settings?.placeQuestionMark) {
|
||||||
|
placeQuestionMark.mutateAsync({ x, y });
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
clearTile.mutateAsync({ x, y });
|
||||||
|
return;
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="w-full grid md:grid-cols-[350px_1fr]">
|
||||||
|
<div className="flex flex-col md:border-r-white/10 md:border-r-1 gap-8 pr-12">
|
||||||
|
<h2 className="text-white/90 text-xl">Minesweeper Endless</h2>
|
||||||
|
<Button
|
||||||
|
className="w-fit"
|
||||||
|
variant="primary"
|
||||||
|
onClick={async () => {
|
||||||
|
const gameId = await startGame.mutateAsync(null);
|
||||||
|
setGameId(gameId.uuid);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Start Game
|
||||||
|
</Button>
|
||||||
|
<h2 className="text-white/80 text-lg mt-8">How to play</h2>
|
||||||
|
<p className="text-white/90">
|
||||||
|
Endless minesweeper is just like regular minesweeper but you can't
|
||||||
|
win. Every time you clear the field you just proceed to the next
|
||||||
|
stage. Try to get as far as possible. You might be rewarded for great
|
||||||
|
performance!
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
Good luck!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-4 pl-12">
|
||||||
|
<h2 className="w-full text-center text-lg text-white/90">
|
||||||
|
Leaderboard
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-[min-content_2fr_1fr] grid-border-b">
|
||||||
|
{new Array(10).fill(0).map((_, i) => (
|
||||||
|
<>
|
||||||
|
<div className="p-4 text-white/80 text-right">{i + 1}.</div>
|
||||||
|
<div className="p-4 text-white/90">
|
||||||
|
{leaderboard?.[i]?.user ?? "No User"}
|
||||||
|
</div>
|
||||||
|
<div className="p-4 text-white/90">
|
||||||
|
Stage {leaderboard?.[i]?.stage ?? 0}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<LeaderboardButton />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
import { animate, motion, useMotionValue, useTransform } from "framer-motion";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useWSQuery } from "../../hooks";
|
||||||
|
import { Tag } from "../../components/Tag";
|
||||||
|
import RegisterButton from "../../components/Auth/RegisterButton";
|
||||||
|
import { Button } from "../../components/Button";
|
||||||
|
import defusing from "../../assets/illustrations/defusing.png";
|
||||||
|
import lootbox1 from "../../assets/illustrations/lootbox1.png";
|
||||||
|
import mine from "../../assets/illustrations/mine.png";
|
||||||
|
import Section from "./Section";
|
||||||
|
import Hr from "../../components/Hr";
|
||||||
|
import { Link } from "wouter";
|
||||||
|
|
||||||
|
const Home = () => {
|
||||||
|
const { data: userCount } = useWSQuery("user.getUserCount", null);
|
||||||
|
const { data: username } = useWSQuery("user.getSelf", null);
|
||||||
|
const from = (userCount ?? 0) / 2;
|
||||||
|
const to = userCount ?? 0;
|
||||||
|
|
||||||
|
const count = useMotionValue(from);
|
||||||
|
const rounded = useTransform(count, (latest) => Math.round(latest));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controls = animate(count, to, { duration: 1.5 });
|
||||||
|
return controls.stop;
|
||||||
|
}, [count, to]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-8 mb-32">
|
||||||
|
<div className="flex flex-col gap-8 items-center py-48">
|
||||||
|
<Tag variant="outline2">
|
||||||
|
<motion.span>{rounded}</motion.span> Users
|
||||||
|
</Tag>
|
||||||
|
<h1 className="text-white/80 font-black font-mono text-3xl md:text-6xl text-center">
|
||||||
|
Business Minesweeper
|
||||||
|
<br />
|
||||||
|
<span className="[background:var(--bg-brand)] [-webkit-text-fill-color:transparent] font-black [-webkit-background-clip:text!important] font-mono text-xl md:text-4xl text-center">
|
||||||
|
is the greatest experience
|
||||||
|
</span>
|
||||||
|
</h1>
|
||||||
|
<span className="flex gap-8 items-center">
|
||||||
|
<h2 className="text-white/80 font-black font-mono text-xl text-center">
|
||||||
|
Start now
|
||||||
|
</h2>
|
||||||
|
{username ? (
|
||||||
|
// @ts-expect-error We dont care since this is internal api
|
||||||
|
<Button variant="primary" as={Link} href="/play">
|
||||||
|
Play
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<RegisterButton />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Section
|
||||||
|
text="Be the one to find the mines and win the game. Score the highest stage and put yourself in the leaderboard."
|
||||||
|
image={defusing}
|
||||||
|
/>
|
||||||
|
<Hr />
|
||||||
|
<Section
|
||||||
|
text="Add friends to watch the game and play with them. You can also challenge your friends to a game and see who can go further in a limited ammount of time."
|
||||||
|
image={mine}
|
||||||
|
left
|
||||||
|
/>
|
||||||
|
<Hr />
|
||||||
|
<Section
|
||||||
|
text="Win games to collect gems so you can get loot to customize your board. Improve your score and your own game experience."
|
||||||
|
image={lootbox1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Home;
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
import {
|
||||||
|
easeInOut,
|
||||||
|
motion,
|
||||||
|
useMotionTemplate,
|
||||||
|
useScroll,
|
||||||
|
useTransform,
|
||||||
|
} from "framer-motion";
|
||||||
|
import { useRef } from "react";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
interface SectionProps {
|
||||||
|
text: string;
|
||||||
|
image: string;
|
||||||
|
left?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Section = ({ text, image, left }: SectionProps) => {
|
||||||
|
const ref = useRef<HTMLImageElement>(null);
|
||||||
|
const { scrollYProgress } = useScroll({
|
||||||
|
target: ref,
|
||||||
|
});
|
||||||
|
const transform = useTransform(scrollYProgress, [0, 1], [-50, 50], {
|
||||||
|
ease: easeInOut,
|
||||||
|
});
|
||||||
|
const translateY = useMotionTemplate`${transform}px`;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse md:flex-row gap-8 items-center mx-24",
|
||||||
|
left && "md:flex-row-reverse",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<p className="text-white/80 text-lg text-center md:w-[50%]">{text}</p>
|
||||||
|
<motion.div
|
||||||
|
className="md:w-[50%] h-90"
|
||||||
|
// float up and down
|
||||||
|
animate={{
|
||||||
|
translateY: [0, 10, 0],
|
||||||
|
translateX: [0, 5, 0],
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
repeat: Infinity,
|
||||||
|
duration: 4,
|
||||||
|
ease: "easeInOut",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<motion.img
|
||||||
|
ref={ref}
|
||||||
|
style={{
|
||||||
|
translateY,
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
type: "just",
|
||||||
|
delay: 0.5,
|
||||||
|
}}
|
||||||
|
src={image}
|
||||||
|
className="h-[80%]"
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Section;
|
||||||
|
|
@ -8,7 +8,6 @@ export const connectWS = () => {
|
||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
const data = JSON.parse(event.data);
|
const data = JSON.parse(event.data);
|
||||||
const name = localStorage.getItem("name");
|
const name = localStorage.getItem("name");
|
||||||
console.log(data);
|
|
||||||
if (data.user === name) {
|
if (data.user === name) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,10 +24,15 @@ const createWSClient = () => {
|
||||||
addMessageListener((event: MessageEvent) => {
|
addMessageListener((event: MessageEvent) => {
|
||||||
const data = JSON.parse(event.data) as Events;
|
const data = JSON.parse(event.data) as Events;
|
||||||
if (data.type === "updateGame") {
|
if (data.type === "updateGame") {
|
||||||
queryClient.invalidateQueries({
|
queryClient.refetchQueries({
|
||||||
queryKey: ["game.getGameState", data.game],
|
queryKey: ["game.getGameState", data.game],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (data.type === "loss") {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["scoreboard.getScoreBoard", 10],
|
||||||
|
});
|
||||||
|
}
|
||||||
console.log("Received message", data);
|
console.log("Received message", data);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue