added home and gameplay

This commit is contained in:
MasterGordon 2024-09-29 13:58:54 +02:00
parent 825448c8f3
commit 2db2b42fd8
37 changed files with 1140 additions and 194 deletions

View File

@ -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 };
}; };

View File

@ -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,
});
}
},
),
}); });

View File

@ -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);
}),
});

View File

@ -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;
}),
}); });

View File

@ -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`);

View File

@ -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
);

View File

@ -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": {},

View File

@ -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
} }
] ]

View File

@ -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);
}
}, },
}; };

View File

@ -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;
};

View File

@ -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;
};

View File

@ -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) {

View File

@ -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;

BIN
bun.lockb

Binary file not shown.

View File

@ -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",

13
shared/user-settings.ts Normal file
View File

@ -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>;

BIN
sqlite.db

Binary file not shown.

View File

@ -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

View File

@ -6,3 +6,5 @@ export const loginTokenAtom = atomWithStorage<string | undefined>(
"loginToken", "loginToken",
undefined, undefined,
); );
export const cursorXAtom = atom(0);
export const cursorYAtom = atom(0);

View File

@ -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 ||

14
src/components/Coords.tsx Normal file
View File

@ -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;

View File

@ -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>

View File

@ -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;

42
src/components/Tag.tsx Normal file
View File

@ -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}
/>
);
},
);

View File

@ -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);

View File

@ -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 (

View File

@ -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; */

View File

@ -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>

View File

@ -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;
}), }),
); );

View File

@ -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>
); );
}; };

74
src/views/home/Home.tsx Normal file
View File

@ -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;

View File

@ -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;

View File

@ -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;
} }

View File

@ -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);
}); });