added home and gameplay
This commit is contained in:
parent
825448c8f3
commit
2db2b42fd8
|
|
@ -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<unknown>;
|
||||
}
|
||||
|
||||
export type Endpoint<TInput, TResponse> = {
|
||||
validate: z.ZodType<TInput>;
|
||||
handler: (input: TInput, context: RequestContext) => Promise<TResponse>;
|
||||
export type Endpoint<TInputSchema extends ZodType, TResponse> = {
|
||||
validate: TInputSchema;
|
||||
handler: (
|
||||
input: z.infer<TInputSchema>,
|
||||
context: RequestContext,
|
||||
) => Promise<TResponse>;
|
||||
};
|
||||
|
||||
export const createEndpoint = <TInput, TResponse>(
|
||||
validate: z.ZodType<TInput>,
|
||||
export const createEndpoint = <
|
||||
TInputSchema extends ZodType,
|
||||
TResponse,
|
||||
TInput = z.infer<TInputSchema>,
|
||||
>(
|
||||
validate: TInputSchema,
|
||||
handler: (input: TInput, context: RequestContext) => Promise<TResponse>,
|
||||
): Endpoint<TInput, TResponse> => {
|
||||
): Endpoint<TInputSchema, TResponse> => {
|
||||
return { validate, handler };
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
import { z } from "zod";
|
||||
import { createController, createEndpoint } from "./controller";
|
||||
import { getScoreBoard } from "../repositories/scoreRepository";
|
||||
|
||||
export const scoreboardController = createController({
|
||||
getScoreBoard: createEndpoint(z.number(), async (limit, { db }) => {
|
||||
return (await getScoreBoard(db)).slice(0, limit);
|
||||
}),
|
||||
});
|
||||
|
|
@ -1,8 +1,16 @@
|
|||
import { z } from "zod";
|
||||
import { 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;
|
||||
}),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
CREATE TABLE `games` (
|
||||
`uuid` text PRIMARY KEY NOT NULL,
|
||||
`user` text NOT NULL,
|
||||
`gameState` blob NOT NULL,
|
||||
`stage` integer NOT NULL,
|
||||
`finished` integer DEFAULT 0 NOT NULL,
|
||||
`timestamp` integer NOT NULL,
|
||||
FOREIGN KEY (`user`) REFERENCES `users`(`name`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `users` (
|
||||
`name` text PRIMARY KEY NOT NULL,
|
||||
`password` text NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `userSettings` (
|
||||
`user` text PRIMARY KEY NOT NULL,
|
||||
`settings` text NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `user_idx` ON `games` (`user`);--> statement-breakpoint
|
||||
CREATE INDEX `started_idx` ON `games` (`timestamp`);--> statement-breakpoint
|
||||
CREATE INDEX `user_started_idx` ON `games` (`user`,`timestamp`);--> statement-breakpoint
|
||||
CREATE INDEX `full_idx` ON `games` (`user`,`timestamp`,`uuid`);
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
CREATE TABLE `games` (
|
||||
`uuid` text PRIMARY KEY NOT NULL,
|
||||
`user` text NOT NULL,
|
||||
`gameState` text NOT NULL,
|
||||
`stage` integer NOT NULL,
|
||||
`finished` integer DEFAULT 0 NOT NULL,
|
||||
`timestamp` integer NOT NULL,
|
||||
FOREIGN KEY (`user`) REFERENCES `users`(`name`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `users` (
|
||||
`name` text PRIMARY KEY NOT NULL,
|
||||
`password` text NOT NULL
|
||||
);
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"version": "6",
|
||||
"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": {},
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@
|
|||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1726774158116,
|
||||
"tag": "0000_nostalgic_next_avengers",
|
||||
"when": 1727551167145,
|
||||
"tag": "0000_gigantic_wasp",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
import type { BunSQLiteDatabase } from "drizzle-orm/bun-sqlite";
|
||||
import { User, type UserType } from "../schema";
|
||||
import { User, UserSettings, type UserType } from "../schema";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import {
|
||||
userSettings as userSettingsSchema,
|
||||
type UserSettings as UserSettingsType,
|
||||
} from "../../shared/user-settings";
|
||||
|
||||
export const registerUser = async (
|
||||
db: BunSQLiteDatabase,
|
||||
|
|
@ -46,3 +50,44 @@ export const getUser = async (
|
|||
.where(eq(sql`lower(${User.name})`, name.toLowerCase()));
|
||||
return { ...user[0], password: undefined };
|
||||
};
|
||||
|
||||
export const getUserSettings = async (
|
||||
db: BunSQLiteDatabase,
|
||||
user: string,
|
||||
): Promise<UserSettingsType | undefined> => {
|
||||
const userSettings = await db
|
||||
.select()
|
||||
.from(UserSettings)
|
||||
.where(eq(UserSettings.user, user));
|
||||
const settings = userSettings[0]?.settings || "{}";
|
||||
return userSettingsSchema.parse(JSON.parse(settings));
|
||||
};
|
||||
|
||||
export const upsertUserSettings = async (
|
||||
db: BunSQLiteDatabase,
|
||||
user: string,
|
||||
settings: UserSettingsType,
|
||||
) => {
|
||||
const dbSettings = await db
|
||||
.select()
|
||||
.from(UserSettings)
|
||||
.where(eq(UserSettings.user, user));
|
||||
if (dbSettings.length > 0) {
|
||||
await db
|
||||
.update(UserSettings)
|
||||
.set({
|
||||
settings: JSON.stringify(settings),
|
||||
})
|
||||
.where(eq(UserSettings.user, user));
|
||||
} else {
|
||||
await db.insert(UserSettings).values({
|
||||
user,
|
||||
settings: JSON.stringify(settings),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const getUserCount = async (db: BunSQLiteDatabase) => {
|
||||
return (await db.select({ count: sql<number>`count(*)` }).from(User))[0]
|
||||
.count;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<string, Controller<any>>;
|
||||
|
||||
const userName = new WeakMap<ServerWebSocket<unknown>, string>();
|
||||
|
|
@ -47,8 +49,10 @@ export const handleRequest = async (
|
|||
// @ts-expect-error controllers[controllerName] is a Controller
|
||||
const endpoint = controllers[controllerName][action] as Endpoint<any, any>;
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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<typeof User.$inferSelect, "password"> & {
|
||||
password?: undefined;
|
||||
};
|
||||
export type GameType = typeof Game.$inferSelect;
|
||||
export type UserSettingsType = typeof UserSettings.$inferSelect;
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
"nukedb": "rm sqlite.db && bun run backend/migrate.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@msgpack/msgpack": "^3.0.0-beta2",
|
||||
"@pixi/events": "^7.4.2",
|
||||
"@pixi/react": "^7.1.2",
|
||||
"@radix-ui/react-dialog": "^1.1.1",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
import { z } from "zod";
|
||||
|
||||
export const userSettings = z.object({
|
||||
placeQuestionMark: z.boolean().default(false),
|
||||
longPressOnDesktop: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export const getDefaultSettings = () => {
|
||||
return userSettings.parse({});
|
||||
};
|
||||
|
||||
export type UserSettings = z.infer<typeof userSettings>;
|
||||
export type UserSettingsInput = z.input<typeof userSettings>;
|
||||
|
|
@ -1,14 +1,7 @@
|
|||
import { PropsWithChildren, useEffect, useRef, useState } from "react";
|
||||
import { 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<PropsWithChildren> = ({ children }) => {
|
|||
return (
|
||||
<div className="bg-black min-h-screen">
|
||||
<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}
|
||||
animate={{ x }}
|
||||
transition={{ type: "tween" }}
|
||||
|
|
@ -61,8 +54,8 @@ const Shell: React.FC<PropsWithChildren> = ({ children }) => {
|
|||
</h1>
|
||||
<Hr />
|
||||
<NavLink href="/">
|
||||
<LayoutDashboard />
|
||||
Dashboard
|
||||
<Home />
|
||||
Home
|
||||
</NavLink>
|
||||
<NavLink href="/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">
|
||||
<Header />
|
||||
{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>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 382 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 537 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 570 KiB |
|
|
@ -6,3 +6,5 @@ export const loginTokenAtom = atomWithStorage<string | undefined>(
|
|||
"loginToken",
|
||||
undefined,
|
||||
);
|
||||
export const cursorXAtom = atom(0);
|
||||
export const cursorYAtom = atom(0);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,15 @@
|
|||
import { ReactNode, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { LoadedTheme, Theme, useTheme } from "../themes/Theme";
|
||||
import { 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<BoardProps> = (props) => {
|
||||
const { game } = props;
|
||||
const { data: user } = useWSQuery("user.getSelf", null);
|
||||
|
|
@ -24,66 +57,146 @@ const Board: React.FC<BoardProps> = (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<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(() => {
|
||||
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<PixiViewport>(null);
|
||||
const [zenMode, setZenMode] = useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-full h-[70vh] overflow-hidden border-red-500 border-2 flex select-none"
|
||||
ref={ref}
|
||||
>
|
||||
{theme && (
|
||||
<Stage
|
||||
options={{ hello: true, antialias: false }}
|
||||
width={width}
|
||||
height={height}
|
||||
>
|
||||
<Viewport
|
||||
worldWidth={boardWidth}
|
||||
worldHeight={boardHeight}
|
||||
<div className="flex flex-col w-full">
|
||||
<div
|
||||
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}
|
||||
>
|
||||
<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 && (
|
||||
<Stage
|
||||
options={{ hello: true }}
|
||||
width={width}
|
||||
height={height}
|
||||
clamp={{
|
||||
left: -theme.size,
|
||||
right: boardWidth + theme.size,
|
||||
top: -theme.size,
|
||||
bottom: boardHeight + theme.size,
|
||||
}}
|
||||
className="select-none"
|
||||
>
|
||||
{game.isRevealed.map((_, i) => {
|
||||
return game.isRevealed[0].map((_, j) => {
|
||||
return (
|
||||
<Tile
|
||||
key={`${i},${j}`}
|
||||
x={i}
|
||||
y={j}
|
||||
game={game}
|
||||
theme={theme}
|
||||
showLastPos={showLastPos}
|
||||
onLeftClick={props.onLeftClick}
|
||||
onRightClick={props.onRightClick}
|
||||
/>
|
||||
);
|
||||
});
|
||||
})}
|
||||
</Viewport>
|
||||
</Stage>
|
||||
)}
|
||||
<Viewport
|
||||
viewportRef={viewportRef}
|
||||
worldWidth={boardWidth}
|
||||
worldHeight={boardHeight}
|
||||
width={width}
|
||||
height={height}
|
||||
clamp={{
|
||||
left: -theme.size,
|
||||
right: boardWidth + theme.size,
|
||||
top: -theme.size,
|
||||
bottom: boardHeight + theme.size,
|
||||
}}
|
||||
clampZoom={{
|
||||
minScale: 1,
|
||||
}}
|
||||
onViewportChange={onViewportChange}
|
||||
>
|
||||
{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 (
|
||||
<Tile
|
||||
key={`${i},${j}`}
|
||||
x={i}
|
||||
y={j}
|
||||
game={game}
|
||||
theme={theme}
|
||||
showLastPos={showLastPos}
|
||||
onLeftClick={props.onLeftClick}
|
||||
onRightClick={props.onRightClick}
|
||||
/>
|
||||
);
|
||||
});
|
||||
})}
|
||||
</Viewport>
|
||||
</Stage>
|
||||
)}
|
||||
</div>
|
||||
<Coords />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -120,26 +233,55 @@ const Tile = ({
|
|||
const isFlagged = game.isFlagged[i][j];
|
||||
const isQuestionMark = game.isQuestionMark[i][j];
|
||||
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;
|
||||
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 extra = isLastPos ? <Sprite key="e" texture={theme.lastPos} /> : null;
|
||||
const touchStart = useRef<number>(0);
|
||||
const isMove = useRef<boolean>(false);
|
||||
const startX = 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 (
|
||||
<Container
|
||||
|
|
@ -166,6 +308,10 @@ const Tile = ({
|
|||
startX.current = e.global.x;
|
||||
startY.current = e.global.y;
|
||||
}}
|
||||
onpointerenter={() => {
|
||||
setCursorX(i);
|
||||
setCursorY(j);
|
||||
}}
|
||||
onpointermove={(e) => {
|
||||
if (
|
||||
Math.abs(startX.current - e.global.x) > 10 ||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
import { useAtom } from "jotai";
|
||||
import { cursorXAtom, cursorYAtom } from "../atoms";
|
||||
|
||||
const Coords = () => {
|
||||
const [cursorX] = useAtom(cursorXAtom);
|
||||
const [cursorY] = useAtom(cursorYAtom);
|
||||
return (
|
||||
<div className="text-xs text-white/70">
|
||||
{cursorX},{cursorY}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Coords;
|
||||
|
|
@ -11,7 +11,6 @@ import { useLocation } from "wouter";
|
|||
import LoginButton from "./Auth/LoginButton";
|
||||
import { 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 (
|
||||
<div className="w-full flex gap-4">
|
||||
<div className="grow" />
|
||||
<img src={banner} className="w-auto h-16 hidden sm:block" />
|
||||
<div className="grow" />
|
||||
|
||||
{username ? (
|
||||
<DropdownMenu>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,48 @@
|
|||
import { useWSQuery } from "../hooks";
|
||||
import { Button } from "./Button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "./Dialog";
|
||||
|
||||
interface LeaderboardButtonProps {
|
||||
label?: string;
|
||||
}
|
||||
|
||||
const LeaderboardButton = ({
|
||||
label = "View Full Leaderboard",
|
||||
}: LeaderboardButtonProps) => {
|
||||
const { data: leaderboard } = useWSQuery("scoreboard.getScoreBoard", 10);
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="w-fit text-white/80 self-center" variant="outline">
|
||||
{label}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Leaderboard</DialogTitle>
|
||||
<div className="grid grid-cols-[min-content_2fr_1fr] grid-border-b">
|
||||
{leaderboard?.map((_, i) => (
|
||||
<>
|
||||
<div className="p-4 text-white/80 text-right">{i + 1}.</div>
|
||||
<div className="p-4 text-white/90">
|
||||
{leaderboard?.[i]?.user ?? "No User"}
|
||||
</div>
|
||||
<div className="p-4 text-white/90">
|
||||
Stage {leaderboard?.[i]?.stage ?? 0}
|
||||
</div>
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
</DialogHeader>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default LeaderboardButton;
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { forwardRef } from "react";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
const tagVariants = cva("font-semibold py-2 px-4 rounded-md flex gap-2", {
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-gray-900 text-white/95",
|
||||
ghost: "bg-transparent text-white/95 hover:bg-white/05",
|
||||
outline:
|
||||
"bg-transparent text-white/95 hover:bg-white/05 border-white/10 border-1",
|
||||
outline2:
|
||||
"bg-transparent [background:var(--bg-brand)] [-webkit-text-fill-color:transparent] [-webkit-background-clip:text!important] bg-white/05 border-primary border-1",
|
||||
primary:
|
||||
"[background:var(--bg-brand)] text-white/95 hover:bg-white/05 hover:animate-gradientmove",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 py-2 px-4",
|
||||
sm: "h-9 px-3 rounded-md",
|
||||
lg: "h-11 px-8 rounded-md",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
});
|
||||
|
||||
export type ButtonProps = React.HTMLAttributes<HTMLDivElement> &
|
||||
VariantProps<typeof tagVariants>;
|
||||
|
||||
export const Tag = forwardRef<HTMLDivElement, ButtonProps>(
|
||||
({ className, variant, size, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(tagVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import React from "react";
|
||||
import * 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<PixiViewport>;
|
||||
}
|
||||
|
||||
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);
|
||||
|
|
|
|||
14
src/hooks.ts
14
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<Routes[TController][TAction]["validate"]>,
|
||||
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<Routes[TController][TAction]["handler"]>
|
||||
>,
|
||||
) => 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({
|
||||
// @ts-expect-error We dont care since this is internal api
|
||||
mutationFn: async (
|
||||
|
|
|
|||
|
|
@ -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; */
|
||||
|
|
|
|||
14
src/main.tsx
14
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(() => {
|
|||
<Toaster position="top-right" reverseOrder={false} />
|
||||
<Shell>
|
||||
<Switch>
|
||||
<Route path="/" component={Home} />
|
||||
<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>
|
||||
{/* <App /> */}
|
||||
</Shell>
|
||||
|
|
|
|||
|
|
@ -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<Exclude<keyof Theme, "size">, string> & {
|
||||
export type LoadedTheme = Record<Exclude<keyof Theme, "size">, 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;
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
<>
|
||||
<div className="w-full flex flex-col 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
|
||||
onClick={async () => {
|
||||
const gameId = await startGame.mutateAsync(null);
|
||||
setGameId(gameId.uuid);
|
||||
}}
|
||||
>
|
||||
Start
|
||||
</Button>
|
||||
</div>
|
||||
<div className="w-full flex text-white/90 gap-4">
|
||||
<Button
|
||||
onClick={async () => {
|
||||
const gameId = await startGame.mutateAsync(null);
|
||||
setGameId(gameId.uuid);
|
||||
}}
|
||||
>
|
||||
Restart
|
||||
</Button>
|
||||
<div className="grow" />
|
||||
<LeaderboardButton label="View Leaderboard" />
|
||||
</div>
|
||||
{game && (
|
||||
<Board
|
||||
key={game.uuid}
|
||||
theme={defaultTheme}
|
||||
game={game}
|
||||
onLeftClick={(x, y) => {
|
||||
reveal.mutateAsync({ x, y });
|
||||
}}
|
||||
onRightClick={(x, y) => {
|
||||
toast.success(`Right click ${x},${y}`);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Board
|
||||
key={game.uuid}
|
||||
theme={defaultTheme}
|
||||
game={game}
|
||||
onLeftClick={(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;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="w-full grid md:grid-cols-[350px_1fr]">
|
||||
<div className="flex flex-col md:border-r-white/10 md:border-r-1 gap-8 pr-12">
|
||||
<h2 className="text-white/90 text-xl">Minesweeper Endless</h2>
|
||||
<Button
|
||||
className="w-fit"
|
||||
variant="primary"
|
||||
onClick={async () => {
|
||||
const gameId = await startGame.mutateAsync(null);
|
||||
setGameId(gameId.uuid);
|
||||
}}
|
||||
>
|
||||
Start Game
|
||||
</Button>
|
||||
<h2 className="text-white/80 text-lg mt-8">How to play</h2>
|
||||
<p className="text-white/90">
|
||||
Endless minesweeper is just like regular minesweeper but you can't
|
||||
win. Every time you clear the field you just proceed to the next
|
||||
stage. Try to get as far as possible. You might be rewarded for great
|
||||
performance!
|
||||
<br />
|
||||
<br />
|
||||
Good luck!
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 pl-12">
|
||||
<h2 className="w-full text-center text-lg text-white/90">
|
||||
Leaderboard
|
||||
</h2>
|
||||
<div className="grid grid-cols-[min-content_2fr_1fr] grid-border-b">
|
||||
{new Array(10).fill(0).map((_, i) => (
|
||||
<>
|
||||
<div className="p-4 text-white/80 text-right">{i + 1}.</div>
|
||||
<div className="p-4 text-white/90">
|
||||
{leaderboard?.[i]?.user ?? "No User"}
|
||||
</div>
|
||||
<div className="p-4 text-white/90">
|
||||
Stage {leaderboard?.[i]?.stage ?? 0}
|
||||
</div>
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
<LeaderboardButton />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,74 @@
|
|||
import { animate, motion, useMotionValue, useTransform } from "framer-motion";
|
||||
import { useEffect } from "react";
|
||||
import { useWSQuery } from "../../hooks";
|
||||
import { Tag } from "../../components/Tag";
|
||||
import RegisterButton from "../../components/Auth/RegisterButton";
|
||||
import { Button } from "../../components/Button";
|
||||
import defusing from "../../assets/illustrations/defusing.png";
|
||||
import lootbox1 from "../../assets/illustrations/lootbox1.png";
|
||||
import mine from "../../assets/illustrations/mine.png";
|
||||
import Section from "./Section";
|
||||
import Hr from "../../components/Hr";
|
||||
import { Link } from "wouter";
|
||||
|
||||
const Home = () => {
|
||||
const { data: userCount } = useWSQuery("user.getUserCount", null);
|
||||
const { data: username } = useWSQuery("user.getSelf", null);
|
||||
const from = (userCount ?? 0) / 2;
|
||||
const to = userCount ?? 0;
|
||||
|
||||
const count = useMotionValue(from);
|
||||
const rounded = useTransform(count, (latest) => Math.round(latest));
|
||||
|
||||
useEffect(() => {
|
||||
const controls = animate(count, to, { duration: 1.5 });
|
||||
return controls.stop;
|
||||
}, [count, to]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8 mb-32">
|
||||
<div className="flex flex-col gap-8 items-center py-48">
|
||||
<Tag variant="outline2">
|
||||
<motion.span>{rounded}</motion.span> Users
|
||||
</Tag>
|
||||
<h1 className="text-white/80 font-black font-mono text-3xl md:text-6xl text-center">
|
||||
Business Minesweeper
|
||||
<br />
|
||||
<span className="[background:var(--bg-brand)] [-webkit-text-fill-color:transparent] font-black [-webkit-background-clip:text!important] font-mono text-xl md:text-4xl text-center">
|
||||
is the greatest experience
|
||||
</span>
|
||||
</h1>
|
||||
<span className="flex gap-8 items-center">
|
||||
<h2 className="text-white/80 font-black font-mono text-xl text-center">
|
||||
Start now
|
||||
</h2>
|
||||
{username ? (
|
||||
// @ts-expect-error We dont care since this is internal api
|
||||
<Button variant="primary" as={Link} href="/play">
|
||||
Play
|
||||
</Button>
|
||||
) : (
|
||||
<RegisterButton />
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<Section
|
||||
text="Be the one to find the mines and win the game. Score the highest stage and put yourself in the leaderboard."
|
||||
image={defusing}
|
||||
/>
|
||||
<Hr />
|
||||
<Section
|
||||
text="Add friends to watch the game and play with them. You can also challenge your friends to a game and see who can go further in a limited ammount of time."
|
||||
image={mine}
|
||||
left
|
||||
/>
|
||||
<Hr />
|
||||
<Section
|
||||
text="Win games to collect gems so you can get loot to customize your board. Improve your score and your own game experience."
|
||||
image={lootbox1}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Home;
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
import {
|
||||
easeInOut,
|
||||
motion,
|
||||
useMotionTemplate,
|
||||
useScroll,
|
||||
useTransform,
|
||||
} from "framer-motion";
|
||||
import { useRef } from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
interface SectionProps {
|
||||
text: string;
|
||||
image: string;
|
||||
left?: boolean;
|
||||
}
|
||||
|
||||
const Section = ({ text, image, left }: SectionProps) => {
|
||||
const ref = useRef<HTMLImageElement>(null);
|
||||
const { scrollYProgress } = useScroll({
|
||||
target: ref,
|
||||
});
|
||||
const transform = useTransform(scrollYProgress, [0, 1], [-50, 50], {
|
||||
ease: easeInOut,
|
||||
});
|
||||
const translateY = useMotionTemplate`${transform}px`;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse md:flex-row gap-8 items-center mx-24",
|
||||
left && "md:flex-row-reverse",
|
||||
)}
|
||||
>
|
||||
<p className="text-white/80 text-lg text-center md:w-[50%]">{text}</p>
|
||||
<motion.div
|
||||
className="md:w-[50%] h-90"
|
||||
// float up and down
|
||||
animate={{
|
||||
translateY: [0, 10, 0],
|
||||
translateX: [0, 5, 0],
|
||||
}}
|
||||
transition={{
|
||||
repeat: Infinity,
|
||||
duration: 4,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
>
|
||||
<motion.img
|
||||
ref={ref}
|
||||
style={{
|
||||
translateY,
|
||||
}}
|
||||
transition={{
|
||||
type: "just",
|
||||
delay: 0.5,
|
||||
}}
|
||||
src={image}
|
||||
className="h-[80%]"
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Section;
|
||||
|
|
@ -8,7 +8,6 @@ export const connectWS = () => {
|
|||
ws.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
const name = localStorage.getItem("name");
|
||||
console.log(data);
|
||||
if (data.user === name) {
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue