diff --git a/backend/controller/controller.ts b/backend/controller/controller.ts index 1548862..1e7bc93 100644 --- a/backend/controller/controller.ts +++ b/backend/controller/controller.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import type { ServerWebSocket } from "bun"; import type { BunSQLiteDatabase } from "drizzle-orm/bun-sqlite"; -import type { z } from "zod"; +import type { z, ZodType } from "zod"; interface RequestContext { user?: string; @@ -9,15 +9,22 @@ interface RequestContext { ws: ServerWebSocket; } -export type Endpoint = { - validate: z.ZodType; - handler: (input: TInput, context: RequestContext) => Promise; +export type Endpoint = { + validate: TInputSchema; + handler: ( + input: z.infer, + context: RequestContext, + ) => Promise; }; -export const createEndpoint = ( - validate: z.ZodType, +export const createEndpoint = < + TInputSchema extends ZodType, + TResponse, + TInput = z.infer, +>( + validate: TInputSchema, handler: (input: TInput, context: RequestContext) => Promise, -): Endpoint => { +): Endpoint => { return { validate, handler }; }; diff --git a/backend/controller/gameController.ts b/backend/controller/gameController.ts index edf0574..cb0856c 100644 --- a/backend/controller/gameController.ts +++ b/backend/controller/gameController.ts @@ -3,6 +3,7 @@ import { createController, createEndpoint } from "./controller"; import { getCurrentGame, getGame, + parseGameState, upsertGameState, } from "../repositories/gameRepository"; import { @@ -18,7 +19,7 @@ import { emit } from "../events"; export const gameController = createController({ getGameState: createEndpoint(z.string(), async (uuid, ctx) => { const game = await getGame(ctx.db, uuid); - const parsed = JSON.parse(game.gameState); + const parsed = parseGameState(game.gameState); const gameState = await serverGame.parseAsync(parsed); if (game.finished) return gameState; return serverToClientGame(gameState); @@ -51,13 +52,79 @@ export const gameController = createController({ async ({ x, y }, { db, user }) => { if (!user) throw new UnauthorizedError("Unauthorized"); const dbGame = await getCurrentGame(db, user); - const serverGame = JSON.parse(dbGame.gameState); - game.reveal(serverGame, x, y); - upsertGameState(db, serverGame); + const serverGame = parseGameState(dbGame.gameState); + const ts = serverGame.finished; + game.reveal(serverGame, x, y, true); + await upsertGameState(db, serverGame); + emit({ + type: "updateGame", + game: dbGame.uuid, + }); + if (ts === 0 && serverGame.finished !== 0) { + emit({ + type: "loss", + stage: serverGame.stage, + user, + }); + } + }, + ), + 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({ type: "updateGame", 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, + }); + } + }, + ), }); diff --git a/backend/controller/scoreboardController.ts b/backend/controller/scoreboardController.ts new file mode 100644 index 0000000..58550ec --- /dev/null +++ b/backend/controller/scoreboardController.ts @@ -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); + }), +}); diff --git a/backend/controller/userController.ts b/backend/controller/userController.ts index 5adbddc..639c790 100644 --- a/backend/controller/userController.ts +++ b/backend/controller/userController.ts @@ -1,8 +1,16 @@ import { z } from "zod"; 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 { resetSessionUser, setSessionUser } from "../router"; +import { userSettings } from "../../shared/user-settings"; +import { UnauthorizedError } from "../errors/UnauthorizedError"; const secret = process.env.SECRET!; @@ -63,4 +71,20 @@ export const userController = createController({ 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; + }), }); diff --git a/backend/drizzle/0000_gigantic_wasp.sql b/backend/drizzle/0000_gigantic_wasp.sql new file mode 100644 index 0000000..f389c82 --- /dev/null +++ b/backend/drizzle/0000_gigantic_wasp.sql @@ -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`); \ No newline at end of file diff --git a/backend/drizzle/0000_nostalgic_next_avengers.sql b/backend/drizzle/0000_nostalgic_next_avengers.sql deleted file mode 100644 index 4db83b8..0000000 --- a/backend/drizzle/0000_nostalgic_next_avengers.sql +++ /dev/null @@ -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 -); diff --git a/backend/drizzle/meta/0000_snapshot.json b/backend/drizzle/meta/0000_snapshot.json index 9bfef61..eb7b93b 100644 --- a/backend/drizzle/meta/0000_snapshot.json +++ b/backend/drizzle/meta/0000_snapshot.json @@ -1,7 +1,7 @@ { "version": "6", "dialect": "sqlite", - "id": "af6e3102-34d0-4247-84ae-14f2d3d8fa4c", + "id": "2c470a78-d3d6-49b7-910c-eb8156e58a2c", "prevId": "00000000-0000-0000-0000-000000000000", "tables": { "games": { @@ -23,7 +23,7 @@ }, "gameState": { "name": "gameState", - "type": "text", + "type": "blob", "primaryKey": false, "notNull": true, "autoincrement": false @@ -51,7 +51,39 @@ "autoincrement": false } }, - "indexes": {}, + "indexes": { + "user_idx": { + "name": "user_idx", + "columns": [ + "user" + ], + "isUnique": false + }, + "started_idx": { + "name": "started_idx", + "columns": [ + "timestamp" + ], + "isUnique": false + }, + "user_started_idx": { + "name": "user_started_idx", + "columns": [ + "user", + "timestamp" + ], + "isUnique": false + }, + "full_idx": { + "name": "full_idx", + "columns": [ + "user", + "timestamp", + "uuid" + ], + "isUnique": false + } + }, "foreignKeys": { "games_user_users_name_fk": { "name": "games_user_users_name_fk", @@ -92,6 +124,29 @@ "foreignKeys": {}, "compositePrimaryKeys": {}, "uniqueConstraints": {} + }, + "userSettings": { + "name": "userSettings", + "columns": { + "user": { + "name": "user", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "settings": { + "name": "settings", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} } }, "enums": {}, diff --git a/backend/drizzle/meta/_journal.json b/backend/drizzle/meta/_journal.json index 0976922..31c0a58 100644 --- a/backend/drizzle/meta/_journal.json +++ b/backend/drizzle/meta/_journal.json @@ -5,8 +5,8 @@ { "idx": 0, "version": "6", - "when": 1726774158116, - "tag": "0000_nostalgic_next_avengers", + "when": 1727551167145, + "tag": "0000_gigantic_wasp", "breakpoints": true } ] diff --git a/backend/entities/game.ts b/backend/entities/game.ts index 9022755..b5f4508 100644 --- a/backend/entities/game.ts +++ b/backend/entities/game.ts @@ -29,6 +29,149 @@ const getNeighborFlagCount = (game: ServerGame, x: number, y: number) => { 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 = { createGame: (options: CreateGameOptions): ServerGame => { const { uuid, user, width, height, mines } = options; @@ -75,51 +218,90 @@ export const game = { minesCount: mines, }; }, - reveal: (serverGame: ServerGame, x: number, y: number) => { - const { mines, isRevealed, isFlagged, isQuestionMark, finished } = - serverGame; + reveal: (serverGame: ServerGame, x: number, y: number, initial = false) => { + const aux = ( + serverGame: ServerGame, + x: number, + y: number, + initial: boolean = false, + ) => { + const { mines, isRevealed, isFlagged, isQuestionMark, finished } = + serverGame; + if (finished) return; + if (!isValid(serverGame, x, y)) return; + if (isQuestionMark[x][y]) return; + if (isFlagged[x][y]) return; + serverGame.lastClick = [x, y]; + + if (mines[x][y]) { + serverGame.finished = Date.now(); + return; + } + + const value = getValue(serverGame.mines, x, y); + const neighborFlagCount = getNeighborFlagCount(serverGame, x, y); + + if (isRevealed[x][y] && value === neighborFlagCount && initial) { + if (!isFlagged[x - 1]?.[y]) aux(serverGame, x - 1, y); + if (!isFlagged[x - 1]?.[y - 1]) aux(serverGame, x - 1, y - 1); + if (!isFlagged[x - 1]?.[y + 1]) aux(serverGame, x - 1, y + 1); + if (!isFlagged[x]?.[y - 1]) aux(serverGame, x, y - 1); + if (!isFlagged[x]?.[y + 1]) aux(serverGame, x, y + 1); + if (!isFlagged[x + 1]?.[y - 1]) aux(serverGame, x + 1, y - 1); + if (!isFlagged[x + 1]?.[y]) aux(serverGame, x + 1, y); + if (!isFlagged[x + 1]?.[y + 1]) aux(serverGame, x + 1, y + 1); + } + + serverGame.isRevealed[x][y] = true; + + if (value === 0 && neighborFlagCount === 0) { + const revealNeighbors = (nx: number, ny: number) => { + if (isValid(serverGame, nx, ny) && !isRevealed[nx]?.[ny]) { + aux(serverGame, nx, ny); + } + }; + + revealNeighbors(x - 1, y - 1); + revealNeighbors(x, y - 1); + revealNeighbors(x + 1, y - 1); + revealNeighbors(x - 1, y); + revealNeighbors(x + 1, y); + revealNeighbors(x - 1, y + 1); + revealNeighbors(x, y + 1); + revealNeighbors(x + 1, y + 1); + } + }; + aux(serverGame, x, y, initial); + if (hasWon(serverGame)) { + expandBoard(serverGame); + } + }, + placeFlag: (serverGame: ServerGame, x: number, y: number) => { + const { isRevealed, finished } = serverGame; if (finished) return; - if (isQuestionMark[x][y]) return; - if (isFlagged[x][y]) return; if (!isValid(serverGame, x, y)) return; - serverGame.lastClick = [x, y]; - - if (mines[x][y]) { - serverGame.finished = Date.now(); - return; + if (isRevealed[x][y]) return; + serverGame.isFlagged[x][y] = true; + if (hasWon(serverGame)) { + expandBoard(serverGame); } - - const value = getValue(serverGame.mines, x, y); - const neighborFlagCount = getNeighborFlagCount(serverGame, x, y); - - if (isRevealed[x][y] && value === neighborFlagCount) { - if (!isFlagged[x - 1]?.[y]) game.reveal(serverGame, x - 1, y); - if (!isFlagged[x - 1]?.[y - 1]) game.reveal(serverGame, x - 1, y - 1); - if (!isFlagged[x - 1]?.[y + 1]) game.reveal(serverGame, x - 1, y + 1); - if (!isFlagged[x]?.[y - 1]) game.reveal(serverGame, x, y - 1); - if (!isFlagged[x]?.[y + 1]) game.reveal(serverGame, x, y + 1); - if (!isFlagged[x + 1]?.[y - 1]) game.reveal(serverGame, x + 1, y - 1); - if (!isFlagged[x + 1]?.[y]) game.reveal(serverGame, x + 1, y); - if (!isFlagged[x + 1]?.[y + 1]) game.reveal(serverGame, x + 1, y + 1); - } - - serverGame.isRevealed[x][y] = true; - - if (value === 0 && neighborFlagCount === 0) { - const revealNeighbors = (nx: number, ny: number) => { - if (isValid(serverGame, nx, ny) && !isRevealed[nx]?.[ny]) { - game.reveal(serverGame, nx, ny); - } - }; - - revealNeighbors(x - 1, y - 1); - revealNeighbors(x, y - 1); - revealNeighbors(x + 1, y - 1); - revealNeighbors(x - 1, y); - revealNeighbors(x + 1, y); - revealNeighbors(x - 1, y + 1); - revealNeighbors(x, y + 1); - revealNeighbors(x + 1, y + 1); + }, + 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); } }, }; diff --git a/backend/repositories/gameRepository.ts b/backend/repositories/gameRepository.ts index 23cf456..53a1537 100644 --- a/backend/repositories/gameRepository.ts +++ b/backend/repositories/gameRepository.ts @@ -2,6 +2,7 @@ import type { BunSQLiteDatabase } from "drizzle-orm/bun-sqlite"; import { Game, type GameType } from "../schema"; import { eq, sql, desc, and, not } from "drizzle-orm"; import type { ServerGame } from "../../shared/game"; +import { decode, encode } from "@msgpack/msgpack"; export const getGame = async (db: BunSQLiteDatabase, uuid: string) => { return (await db.select().from(Game).where(eq(Game.uuid, uuid)))[0]; @@ -69,8 +70,12 @@ export const upsertGameState = async ( uuid, user, stage, - gameState: JSON.stringify(game), + gameState: Buffer.from(encode(game)), finished, started, }); }; + +export const parseGameState = (gameState: Buffer) => { + return decode(gameState) as ServerGame; +}; diff --git a/backend/repositories/userRepository.ts b/backend/repositories/userRepository.ts index 821480d..8269ceb 100644 --- a/backend/repositories/userRepository.ts +++ b/backend/repositories/userRepository.ts @@ -1,6 +1,10 @@ import type { BunSQLiteDatabase } from "drizzle-orm/bun-sqlite"; -import { User, type UserType } from "../schema"; +import { User, UserSettings, type UserType } from "../schema"; import { eq, sql } from "drizzle-orm"; +import { + userSettings as userSettingsSchema, + type UserSettings as UserSettingsType, +} from "../../shared/user-settings"; export const registerUser = async ( db: BunSQLiteDatabase, @@ -46,3 +50,44 @@ export const getUser = async ( .where(eq(sql`lower(${User.name})`, name.toLowerCase())); return { ...user[0], password: undefined }; }; + +export const getUserSettings = async ( + db: BunSQLiteDatabase, + user: string, +): Promise => { + 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`count(*)` }).from(User))[0] + .count; +}; diff --git a/backend/router.ts b/backend/router.ts index 73b2323..04ed1ef 100644 --- a/backend/router.ts +++ b/backend/router.ts @@ -5,10 +5,12 @@ import { gameController } from "./controller/gameController"; import { db } from "./database/db"; import { userController } from "./controller/userController"; import { ZodError } from "zod"; +import { scoreboardController } from "./controller/scoreboardController"; const controllers = { game: gameController, user: userController, + scoreboard: scoreboardController, } satisfies Record>; const userName = new WeakMap, string>(); @@ -47,8 +49,10 @@ export const handleRequest = async ( // @ts-expect-error controllers[controllerName] is a Controller const endpoint = controllers[controllerName][action] as Endpoint; const input = endpoint.validate.parse(payload); + console.time(action); const result = await endpoint.handler(input, ctx); ws.send(JSON.stringify({ id, payload: result })); + console.timeEnd(action); return; } catch (e) { if (e instanceof ZodError) { diff --git a/backend/schema.ts b/backend/schema.ts index 77bcd14..2a84e79 100644 --- a/backend/schema.ts +++ b/backend/schema.ts @@ -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", { name: text("name").primaryKey().notNull(), password: text("password").notNull(), }); -export const Game = sqliteTable("games", { - uuid: text("uuid").primaryKey().notNull(), - user: text("user") - .notNull() - .references(() => User.name), - gameState: text("gameState").notNull(), - stage: integer("stage").notNull(), - finished: integer("finished").notNull().default(0), - started: integer("timestamp").notNull(), +export const Game = sqliteTable( + "games", + { + uuid: text("uuid").primaryKey().notNull(), + user: text("user") + .notNull() + .references(() => User.name), + gameState: blob("gameState", { mode: "buffer" }).notNull(), + stage: integer("stage").notNull(), + finished: integer("finished").notNull().default(0), + started: integer("timestamp").notNull(), + }, + (table) => { + return { + userIdx: index("user_idx").on(table.user), + startedIdx: index("started_idx").on(table.started), + userStartedIdx: index("user_started_idx").on(table.user, table.started), + fullIdx: index("full_idx").on(table.user, table.started, table.uuid), + }; + }, +); + +export const UserSettings = sqliteTable("userSettings", { + user: text("user").primaryKey().notNull(), + settings: text("settings").notNull(), }); export type UserType = Omit & { password?: undefined; }; export type GameType = typeof Game.$inferSelect; +export type UserSettingsType = typeof UserSettings.$inferSelect; diff --git a/bun.lockb b/bun.lockb index 69c5b87..7959471 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 800007d..8d19e37 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "nukedb": "rm sqlite.db && bun run backend/migrate.ts" }, "dependencies": { + "@msgpack/msgpack": "^3.0.0-beta2", "@pixi/events": "^7.4.2", "@pixi/react": "^7.1.2", "@radix-ui/react-dialog": "^1.1.1", diff --git a/shared/user-settings.ts b/shared/user-settings.ts new file mode 100644 index 0000000..60e23ee --- /dev/null +++ b/shared/user-settings.ts @@ -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; +export type UserSettingsInput = z.input; diff --git a/sqlite.db b/sqlite.db index 3668c43..7fe0ee9 100644 Binary files a/sqlite.db and b/sqlite.db differ diff --git a/src/Shell.tsx b/src/Shell.tsx index 131bc8b..54f79fa 100644 --- a/src/Shell.tsx +++ b/src/Shell.tsx @@ -1,14 +1,7 @@ import { PropsWithChildren, useEffect, useRef, useState } from "react"; import { Button } from "./components/Button"; import { motion } from "framer-motion"; -import { - GitBranch, - History, - LayoutDashboard, - Menu, - Play, - Settings, -} from "lucide-react"; +import { GitBranch, History, Home, Menu, Play, Settings } from "lucide-react"; import Hr from "./components/Hr"; import NavLink from "./components/NavLink"; import { useMediaQuery } from "@uidotdev/usehooks"; @@ -48,7 +41,7 @@ const Shell: React.FC = ({ children }) => { return (
= ({ children }) => {
- - Dashboard + + Home @@ -104,8 +97,6 @@ const Shell: React.FC = ({ children }) => {
{children} - {/*
*/} - {/*
*/}
diff --git a/src/assets/illustrations/defusing.png b/src/assets/illustrations/defusing.png new file mode 100644 index 0000000..af1e28b Binary files /dev/null and b/src/assets/illustrations/defusing.png differ diff --git a/src/assets/illustrations/lootbox1.png b/src/assets/illustrations/lootbox1.png new file mode 100644 index 0000000..cb84a6e Binary files /dev/null and b/src/assets/illustrations/lootbox1.png differ diff --git a/src/assets/illustrations/mine.png b/src/assets/illustrations/mine.png new file mode 100644 index 0000000..12b27a9 Binary files /dev/null and b/src/assets/illustrations/mine.png differ diff --git a/src/atoms.ts b/src/atoms.ts index 0d94a76..1055cef 100644 --- a/src/atoms.ts +++ b/src/atoms.ts @@ -6,3 +6,5 @@ export const loginTokenAtom = atomWithStorage( "loginToken", undefined, ); +export const cursorXAtom = atom(0); +export const cursorYAtom = atom(0); diff --git a/src/components/Board.tsx b/src/components/Board.tsx index 5d3d59b..80c1431 100644 --- a/src/components/Board.tsx +++ b/src/components/Board.tsx @@ -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 { Container, Sprite, Stage } from "@pixi/react"; +import { Container, Sprite, Stage, useTick } from "@pixi/react"; import Viewport from "./pixi/PixiViewport"; +import type { Viewport as PixiViewport } from "pixi-viewport"; import { ClientGame, getValue, @@ -9,6 +17,15 @@ import { ServerGame, } from "../../shared/game"; 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 { theme: Theme; @@ -17,6 +34,22 @@ interface BoardProps { 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 = (props) => { const { game } = props; const { data: user } = useWSQuery("user.getSelf", null); @@ -24,66 +57,146 @@ const Board: React.FC = (props) => { const [width, setWidth] = useState(0); const [height, setHeight] = useState(0); 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({ + 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(() => { if (!ref.current) return; setWidth(ref.current.clientWidth); setHeight(ref.current.clientHeight); + if (viewportRef.current) onViewportChange(viewportRef.current); const resizeObserver = new ResizeObserver(() => { if (ref.current) { setWidth(ref.current.clientWidth); setHeight(ref.current.clientHeight); + if (viewportRef.current) onViewportChange(viewportRef.current); } }); resizeObserver.observe(ref.current); return () => resizeObserver.disconnect(); - }, []); + }, [onViewportChange]); const theme = useTheme(props.theme); const boardWidth = game.width * (theme?.size || 0); const boardHeight = game.height * (theme?.size || 0); + const viewportRef = useRef(null); + const [zenMode, setZenMode] = useState(false); + return ( -
- {theme && ( - - +
+
+ + {zenMode && ( +
+ {game.minesCount - game.isFlagged.flat().filter((f) => f).length} +
+ )} +
+ {theme && ( + - {game.isRevealed.map((_, i) => { - return game.isRevealed[0].map((_, j) => { - return ( - - ); - }); - })} - - - )} + + {Array.from({ length: game.width }).map((_, i) => { + 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 ( + + ); + }); + })} + + + )} +
+
); }; @@ -120,26 +233,55 @@ const Tile = ({ const isFlagged = game.isFlagged[i][j]; const isQuestionMark = game.isQuestionMark[i][j]; const base = isRevealed ? ( - + ) : ( - + ); - let content: ReactNode = null; - if (isMine) { - content = ; - } else if (value !== -1 && isRevealed) { - const img = theme[value.toString() as keyof Theme] as string; - content = img ? : null; - } else if (isFlagged) { - content = ; - } else if (isQuestionMark) { - content = ; - } - const extra = isLastPos ? : null; + const extra = isLastPos ? : null; const touchStart = useRef(0); const isMove = useRef(false); const startX = useRef(0); const startY = useRef(0); + const oldState = useRef(`${isRevealed},${isMine},${value}`); + const [scale, setScale] = useState(1); + const [doTick, setDoTick] = useState(true); + const frame = useRef(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 = ; + } else if (isMine) { + content = ; + } else if (value !== -1 && isRevealed) { + const img = theme[value.toString() as keyof Theme] as Texture; + content = img ? : null; + } else if (isQuestionMark) { + content = ; + } + const [, setCursorX] = useAtom(cursorXAtom); + const [, setCursorY] = useAtom(cursorYAtom); return ( { + setCursorX(i); + setCursorY(j); + }} onpointermove={(e) => { if ( Math.abs(startX.current - e.global.x) > 10 || diff --git a/src/components/Coords.tsx b/src/components/Coords.tsx new file mode 100644 index 0000000..7ae212b --- /dev/null +++ b/src/components/Coords.tsx @@ -0,0 +1,14 @@ +import { useAtom } from "jotai"; +import { cursorXAtom, cursorYAtom } from "../atoms"; + +const Coords = () => { + const [cursorX] = useAtom(cursorXAtom); + const [cursorY] = useAtom(cursorYAtom); + return ( +
+ {cursorX},{cursorY} +
+ ); +}; + +export default Coords; diff --git a/src/components/Header.tsx b/src/components/Header.tsx index e549e90..805b955 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -11,7 +11,6 @@ import { useLocation } from "wouter"; import LoginButton from "./Auth/LoginButton"; import { useWSMutation, useWSQuery } from "../hooks"; import RegisterButton from "./Auth/RegisterButton"; -import banner from "../images/banner.png"; import { useQueryClient } from "@tanstack/react-query"; const Header = () => { @@ -25,8 +24,6 @@ const Header = () => { return (
- -
{username ? ( diff --git a/src/components/LeaderboardButton.tsx b/src/components/LeaderboardButton.tsx new file mode 100644 index 0000000..c93599c --- /dev/null +++ b/src/components/LeaderboardButton.tsx @@ -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 ( + + + + + + + Leaderboard +
+ {leaderboard?.map((_, i) => ( + <> +
{i + 1}.
+
+ {leaderboard?.[i]?.user ?? "No User"} +
+
+ Stage {leaderboard?.[i]?.stage ?? 0} +
+ + ))} +
+
+
+
+ ); +}; + +export default LeaderboardButton; diff --git a/src/components/Tag.tsx b/src/components/Tag.tsx new file mode 100644 index 0000000..d88c5ea --- /dev/null +++ b/src/components/Tag.tsx @@ -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 & + VariantProps; + +export const Tag = forwardRef( + ({ className, variant, size, ...props }, ref) => { + return ( +
+ ); + }, +); diff --git a/src/components/pixi/PixiViewport.tsx b/src/components/pixi/PixiViewport.tsx index ce55e7e..6ca428a 100644 --- a/src/components/pixi/PixiViewport.tsx +++ b/src/components/pixi/PixiViewport.tsx @@ -1,6 +1,6 @@ import React from "react"; 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 { BaseTexture, SCALE_MODES } from "pixi.js"; BaseTexture.defaultOptions.scaleMode = SCALE_MODES.NEAREST; @@ -17,6 +17,9 @@ export interface ViewportProps { top: number; bottom: number; }; + clampZoom?: IClampZoomOptions; + onViewportChange?: (viewport: PixiViewport) => void; + viewportRef?: React.RefObject; } export interface PixiComponentViewportProps extends ViewportProps { @@ -45,6 +48,20 @@ const PixiComponentViewport = PixiComponent("Viewport", { if (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; }, @@ -55,9 +72,16 @@ const PixiComponentViewport = PixiComponent("Viewport", { ) => { if ( 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) { viewport.clamp(newProps.clamp); diff --git a/src/hooks.ts b/src/hooks.ts index e5f9f15..945ec6d 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -1,11 +1,14 @@ import { + keepPreviousData, useMutation, + UseMutationResult, useQuery, useQueryClient, UseQueryResult, } from "@tanstack/react-query"; import { Routes } from "../backend/router"; import { wsClient } from "./wsClient"; +import { z } from "zod"; export const useWSQuery = < TController extends keyof Routes, @@ -13,7 +16,7 @@ export const useWSQuery = < >( action: `${TController}.${TAction}`, // @ts-expect-error We dont care since this is internal api - payload: Routes[TController][TAction]["validate"]["_input"], + payload: z.input, enabled?: boolean, ): UseQueryResult< // @ts-expect-error We dont care since this is internal api @@ -26,6 +29,7 @@ export const useWSQuery = < return result; }, enabled, + placeholderData: keepPreviousData, }); }; @@ -40,7 +44,13 @@ export const useWSMutation = < ReturnType >, ) => void, -) => { +): UseMutationResult< + // @ts-expect-error We dont care since this is internal api + Awaited>, + unknown, + // @ts-expect-error We dont care since this is internal api + Routes[TController][TAction]["validate"]["_input"] +> => { return useMutation({ // @ts-expect-error We dont care since this is internal api mutationFn: async ( diff --git a/src/index.css b/src/index.css index c6022f6..413761b 100644 --- a/src/index.css +++ b/src/index.css @@ -1,7 +1,7 @@ @import "tailwindcss"; @theme { - --color-primary: hotpink; + --color-primary: 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%; --bg-secondary: linear-gradient(90deg, #D9AFD9 0%, #97D9E1 100%) 0% 0% / 100% 300%; @@ -16,6 +16,10 @@ button { cursor: pointer; } +.grid-border-b div:not(:nth-last-child(-n+3)) { + @apply border-b border-white/10; +} + /* .game-board { */ /* display: grid; */ /* gap: 2px; */ diff --git a/src/main.tsx b/src/main.tsx index 8885830..3521dd5 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -10,6 +10,7 @@ import { wsClient } from "./wsClient.ts"; import { Route, Switch } from "wouter"; import Endless from "./views/endless/Endless.tsx"; import { queryClient } from "./queryClient.ts"; +import Home from "./views/home/Home.tsx"; connectWS(); @@ -34,7 +35,20 @@ setup().then(() => { + + ( +

Comming Soon

+ )} + /> + ( +

Comming Soon

+ )} + />
{/* */}
diff --git a/src/themes/Theme.ts b/src/themes/Theme.ts index b838e2c..be87a12 100644 --- a/src/themes/Theme.ts +++ b/src/themes/Theme.ts @@ -1,3 +1,4 @@ +import { Assets, Texture } from "pixi.js"; import { useEffect, useState } from "react"; type Png = typeof import("*.png"); @@ -21,7 +22,7 @@ export interface Theme { 8: LazySprite; } -export type LoadedTheme = Record, string> & { +export type LoadedTheme = Record, Texture> & { size: number; }; @@ -34,7 +35,9 @@ export const useTheme = (theme: Theme) => { const loadedEntries = await Promise.all( Object.entries(theme).map(async ([key, value]) => { const loaded = - typeof value === "function" ? (await value()).default : value; + typeof value === "function" + ? await Assets.load((await value()).default) + : value; return [key, loaded] as const; }), ); diff --git a/src/views/endless/Endless.tsx b/src/views/endless/Endless.tsx index 44a75dc..80677d8 100644 --- a/src/views/endless/Endless.tsx +++ b/src/views/endless/Endless.tsx @@ -1,47 +1,111 @@ import { defaultTheme } from "../../themes/default"; import Board from "../../components/Board"; -import toast from "react-hot-toast"; import { useWSMutation, useWSQuery } from "../../hooks"; import { useAtom } from "jotai"; import { gameIdAtom } from "../../atoms"; import { Button } from "../../components/Button"; +import LeaderboardButton from "../../components/LeaderboardButton"; +import { useEffect } from "react"; const Endless = () => { const [gameId, setGameId] = useAtom(gameIdAtom); const { data: game } = useWSQuery("game.getGameState", gameId!, !!gameId); + const { data: settings } = useWSQuery("user.getSettings", null); const startGame = useWSMutation("game.createGame"); + const { data: leaderboard } = useWSQuery("scoreboard.getScoreBoard", 10); 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 ? ( <> -
-

Endless

-

A game where you have to click on the mines

-
- -
+
+ +
+
- {game && ( - { - reveal.mutateAsync({ x, y }); - }} - onRightClick={(x, y) => { - toast.success(`Right click ${x},${y}`); - }} - /> - )} + { + reveal.mutateAsync({ x, y }); + }} + onRightClick={(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; + } + }} + /> + ) : ( +
+
+

Minesweeper Endless

+ +

How to play

+

+ 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! +
+
+ Good luck! +

+
+
+

+ Leaderboard +

+
+ {new Array(10).fill(0).map((_, i) => ( + <> +
{i + 1}.
+
+ {leaderboard?.[i]?.user ?? "No User"} +
+
+ Stage {leaderboard?.[i]?.stage ?? 0} +
+ + ))} +
+ +
+
); }; diff --git a/src/views/home/Home.tsx b/src/views/home/Home.tsx new file mode 100644 index 0000000..97bdb6d --- /dev/null +++ b/src/views/home/Home.tsx @@ -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 ( +
+
+ + {rounded} Users + +

+ Business Minesweeper +
+ + is the greatest experience + +

+ +

+ Start now +

+ {username ? ( + // @ts-expect-error We dont care since this is internal api + + ) : ( + + )} +
+
+
+
+
+
+
+
+ ); +}; + +export default Home; diff --git a/src/views/home/Section.tsx b/src/views/home/Section.tsx new file mode 100644 index 0000000..8fa7eab --- /dev/null +++ b/src/views/home/Section.tsx @@ -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(null); + const { scrollYProgress } = useScroll({ + target: ref, + }); + const transform = useTransform(scrollYProgress, [0, 1], [-50, 50], { + ease: easeInOut, + }); + const translateY = useMotionTemplate`${transform}px`; + return ( +
+

{text}

+ + + +
+ ); +}; + +export default Section; diff --git a/src/ws.ts b/src/ws.ts index 3ec0d1b..ffa0dec 100644 --- a/src/ws.ts +++ b/src/ws.ts @@ -8,7 +8,6 @@ export const connectWS = () => { ws.onmessage = (event) => { const data = JSON.parse(event.data); const name = localStorage.getItem("name"); - console.log(data); if (data.user === name) { return; } diff --git a/src/wsClient.ts b/src/wsClient.ts index 22c7d4a..4fc8f83 100644 --- a/src/wsClient.ts +++ b/src/wsClient.ts @@ -24,10 +24,15 @@ const createWSClient = () => { addMessageListener((event: MessageEvent) => { const data = JSON.parse(event.data) as Events; if (data.type === "updateGame") { - queryClient.invalidateQueries({ + queryClient.refetchQueries({ queryKey: ["game.getGameState", data.game], }); } + if (data.type === "loss") { + queryClient.invalidateQueries({ + queryKey: ["scoreboard.getScoreBoard", 10], + }); + } console.log("Received message", data); });