updated themes, added gem gain, added feed
This commit is contained in:
parent
d9ff1a9ffc
commit
ebbc8d0f29
|
|
@ -10,7 +10,8 @@ For local development you are required to have [bun](https://bun.sh/) installed.
|
||||||
# Create a .env file for token signing
|
# Create a .env file for token signing
|
||||||
echo "SECRET=SOME_RANDOM_STRING" > .env
|
echo "SECRET=SOME_RANDOM_STRING" > .env
|
||||||
bun install
|
bun install
|
||||||
bun run dev
|
bun run drizzle:migrate
|
||||||
|
bun dev
|
||||||
```
|
```
|
||||||
|
|
||||||
## 📦 Used Libraries
|
## 📦 Used Libraries
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,11 @@ import { serverToClientGame, type ServerGame } from "../../shared/game";
|
||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
import { game } from "../entities/game";
|
import { game } from "../entities/game";
|
||||||
import { UnauthorizedError } from "../errors/UnauthorizedError";
|
import { UnauthorizedError } from "../errors/UnauthorizedError";
|
||||||
import { emit } from "../events";
|
import { emit, emitToWS } from "../events";
|
||||||
import { serverGame } from "../../shared/gameType";
|
import { serverGame } from "../../shared/gameType";
|
||||||
|
import { pickRandom } from "../../shared/utils";
|
||||||
|
import { addGems } from "../repositories/gemsRepository";
|
||||||
|
import { getCollection } from "../repositories/collectionRepository";
|
||||||
|
|
||||||
export const gameController = createController({
|
export const gameController = createController({
|
||||||
getGameState: createEndpoint(z.string(), async (uuid, ctx) => {
|
getGameState: createEndpoint(z.string(), async (uuid, ctx) => {
|
||||||
|
|
@ -26,12 +29,14 @@ export const gameController = createController({
|
||||||
createGame: createEndpoint(z.null(), async (_, { user, db }) => {
|
createGame: createEndpoint(z.null(), async (_, { user, db }) => {
|
||||||
if (!user) throw new UnauthorizedError("Unauthorized");
|
if (!user) throw new UnauthorizedError("Unauthorized");
|
||||||
const uuid = crypto.randomUUID() as string;
|
const uuid = crypto.randomUUID() as string;
|
||||||
|
const collection = await getCollection(db, user);
|
||||||
const newGame: ServerGame = game.createGame({
|
const newGame: ServerGame = game.createGame({
|
||||||
uuid,
|
uuid,
|
||||||
user: user,
|
user: user,
|
||||||
mines: 2,
|
mines: 2,
|
||||||
width: 4,
|
width: 4,
|
||||||
height: 4,
|
height: 4,
|
||||||
|
theme: pickRandom(collection.entries.filter((e) => e.selected)).id,
|
||||||
});
|
});
|
||||||
upsertGameState(db, newGame);
|
upsertGameState(db, newGame);
|
||||||
emit({
|
emit({
|
||||||
|
|
@ -48,7 +53,7 @@ export const gameController = createController({
|
||||||
}),
|
}),
|
||||||
reveal: createEndpoint(
|
reveal: createEndpoint(
|
||||||
z.object({ x: z.number(), y: z.number() }),
|
z.object({ x: z.number(), y: z.number() }),
|
||||||
async ({ x, y }, { db, user }) => {
|
async ({ x, y }, { db, user, ws }) => {
|
||||||
if (!user) throw new UnauthorizedError("Unauthorized");
|
if (!user) throw new UnauthorizedError("Unauthorized");
|
||||||
const dbGame = await getCurrentGame(db, user);
|
const dbGame = await getCurrentGame(db, user);
|
||||||
const serverGame = parseGameState(dbGame.gameState);
|
const serverGame = parseGameState(dbGame.gameState);
|
||||||
|
|
@ -64,13 +69,24 @@ export const gameController = createController({
|
||||||
type: "loss",
|
type: "loss",
|
||||||
stage: serverGame.stage,
|
stage: serverGame.stage,
|
||||||
user,
|
user,
|
||||||
|
time: serverGame.finished - serverGame.started,
|
||||||
});
|
});
|
||||||
|
const reward = game.getRewards(serverGame);
|
||||||
|
emitToWS(
|
||||||
|
{
|
||||||
|
type: "gemsRewarded",
|
||||||
|
stage: serverGame.stage,
|
||||||
|
gems: reward,
|
||||||
|
},
|
||||||
|
ws,
|
||||||
|
);
|
||||||
|
await addGems(db, user, reward);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
placeFlag: createEndpoint(
|
placeFlag: createEndpoint(
|
||||||
z.object({ x: z.number(), y: z.number() }),
|
z.object({ x: z.number(), y: z.number() }),
|
||||||
async ({ x, y }, { db, user }) => {
|
async ({ x, y }, { db, user, ws }) => {
|
||||||
if (!user) throw new UnauthorizedError("Unauthorized");
|
if (!user) throw new UnauthorizedError("Unauthorized");
|
||||||
const dbGame = await getCurrentGame(db, user);
|
const dbGame = await getCurrentGame(db, user);
|
||||||
const serverGame = parseGameState(dbGame.gameState);
|
const serverGame = parseGameState(dbGame.gameState);
|
||||||
|
|
@ -86,7 +102,18 @@ export const gameController = createController({
|
||||||
type: "loss",
|
type: "loss",
|
||||||
stage: serverGame.stage,
|
stage: serverGame.stage,
|
||||||
user,
|
user,
|
||||||
|
time: serverGame.finished - serverGame.started,
|
||||||
});
|
});
|
||||||
|
const reward = game.getRewards(serverGame);
|
||||||
|
emitToWS(
|
||||||
|
{
|
||||||
|
type: "gemsRewarded",
|
||||||
|
stage: serverGame.stage,
|
||||||
|
gems: reward,
|
||||||
|
},
|
||||||
|
ws,
|
||||||
|
);
|
||||||
|
await addGems(db, user, reward);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
@ -106,7 +133,7 @@ export const gameController = createController({
|
||||||
),
|
),
|
||||||
clearTile: createEndpoint(
|
clearTile: createEndpoint(
|
||||||
z.object({ x: z.number(), y: z.number() }),
|
z.object({ x: z.number(), y: z.number() }),
|
||||||
async ({ x, y }, { db, user }) => {
|
async ({ x, y }, { db, user, ws }) => {
|
||||||
if (!user) throw new UnauthorizedError("Unauthorized");
|
if (!user) throw new UnauthorizedError("Unauthorized");
|
||||||
const dbGame = await getCurrentGame(db, user);
|
const dbGame = await getCurrentGame(db, user);
|
||||||
const serverGame = parseGameState(dbGame.gameState);
|
const serverGame = parseGameState(dbGame.gameState);
|
||||||
|
|
@ -122,7 +149,18 @@ export const gameController = createController({
|
||||||
type: "loss",
|
type: "loss",
|
||||||
stage: serverGame.stage,
|
stage: serverGame.stage,
|
||||||
user,
|
user,
|
||||||
|
time: serverGame.finished - serverGame.started,
|
||||||
});
|
});
|
||||||
|
const reward = game.getRewards(serverGame);
|
||||||
|
emitToWS(
|
||||||
|
{
|
||||||
|
type: "gemsRewarded",
|
||||||
|
stage: serverGame.stage,
|
||||||
|
gems: reward,
|
||||||
|
},
|
||||||
|
ws,
|
||||||
|
);
|
||||||
|
await addGems(db, user, reward);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,11 @@ import crypto from "crypto";
|
||||||
import { resetSessionUser, setSessionUser } from "../router";
|
import { resetSessionUser, setSessionUser } from "../router";
|
||||||
import { userSettings } from "../../shared/user-settings";
|
import { userSettings } from "../../shared/user-settings";
|
||||||
import { UnauthorizedError } from "../errors/UnauthorizedError";
|
import { UnauthorizedError } from "../errors/UnauthorizedError";
|
||||||
|
import { getGems } from "../repositories/gemsRepository";
|
||||||
|
import {
|
||||||
|
getCollection,
|
||||||
|
upsertCollection,
|
||||||
|
} from "../repositories/collectionRepository";
|
||||||
|
|
||||||
const secret = process.env.SECRET!;
|
const secret = process.env.SECRET!;
|
||||||
|
|
||||||
|
|
@ -87,4 +92,45 @@ export const userController = createController({
|
||||||
const count = await getUserCount(db);
|
const count = await getUserCount(db);
|
||||||
return count;
|
return count;
|
||||||
}),
|
}),
|
||||||
|
getOwnGems: createEndpoint(z.null(), async (_, { db, user }) => {
|
||||||
|
if (!user) throw new UnauthorizedError("Unauthorized");
|
||||||
|
const gems = await getGems(db, user);
|
||||||
|
return gems;
|
||||||
|
}),
|
||||||
|
getOwnCollection: createEndpoint(z.null(), async (_, { db, user }) => {
|
||||||
|
if (!user) throw new UnauthorizedError("Unauthorized");
|
||||||
|
const collection = await getCollection(db, user);
|
||||||
|
return collection;
|
||||||
|
}),
|
||||||
|
selectCollectionEntry: createEndpoint(
|
||||||
|
z.object({
|
||||||
|
id: z.string(),
|
||||||
|
}),
|
||||||
|
async ({ id }, { db, user }) => {
|
||||||
|
if (!user) throw new UnauthorizedError("Unauthorized");
|
||||||
|
const collection = await getCollection(db, user);
|
||||||
|
if (!collection.entries.some((e) => e.id === id)) {
|
||||||
|
throw new Error("Entry not found");
|
||||||
|
}
|
||||||
|
for (const entry of collection.entries) {
|
||||||
|
entry.selected = entry.id === id;
|
||||||
|
}
|
||||||
|
await upsertCollection(db, user, collection);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
addCollectionEntryToShuffle: createEndpoint(
|
||||||
|
z.object({
|
||||||
|
id: z.string(),
|
||||||
|
}),
|
||||||
|
async ({ id }, { db, user }) => {
|
||||||
|
if (!user) throw new UnauthorizedError("Unauthorized");
|
||||||
|
const collection = await getCollection(db, user);
|
||||||
|
const entry = collection.entries.find((e) => e.id === id);
|
||||||
|
if (!entry) {
|
||||||
|
throw new Error("Entry not found");
|
||||||
|
}
|
||||||
|
entry.selected = true;
|
||||||
|
await upsertCollection(db, user, collection);
|
||||||
|
},
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
CREATE TABLE `collection` (
|
||||||
|
`user` text PRIMARY KEY NOT NULL,
|
||||||
|
`collection` blob NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `gems` (
|
||||||
|
`user` text PRIMARY KEY NOT NULL,
|
||||||
|
`count` integer NOT NULL,
|
||||||
|
`totalCount` integer NOT NULL
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,214 @@
|
||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "7347c405-254d-4a1f-9196-47b2935f1733",
|
||||||
|
"prevId": "2c470a78-d3d6-49b7-910c-eb8156e58a2c",
|
||||||
|
"tables": {
|
||||||
|
"collection": {
|
||||||
|
"name": "collection",
|
||||||
|
"columns": {
|
||||||
|
"user": {
|
||||||
|
"name": "user",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"collection": {
|
||||||
|
"name": "collection",
|
||||||
|
"type": "blob",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {}
|
||||||
|
},
|
||||||
|
"games": {
|
||||||
|
"name": "games",
|
||||||
|
"columns": {
|
||||||
|
"uuid": {
|
||||||
|
"name": "uuid",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
"name": "user",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"gameState": {
|
||||||
|
"name": "gameState",
|
||||||
|
"type": "blob",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"stage": {
|
||||||
|
"name": "stage",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"finished": {
|
||||||
|
"name": "finished",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"timestamp": {
|
||||||
|
"name": "timestamp",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"user_idx": {
|
||||||
|
"name": "user_idx",
|
||||||
|
"columns": [
|
||||||
|
"user"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
},
|
||||||
|
"started_idx": {
|
||||||
|
"name": "started_idx",
|
||||||
|
"columns": [
|
||||||
|
"timestamp"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
},
|
||||||
|
"user_started_idx": {
|
||||||
|
"name": "user_started_idx",
|
||||||
|
"columns": [
|
||||||
|
"user",
|
||||||
|
"timestamp"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
},
|
||||||
|
"full_idx": {
|
||||||
|
"name": "full_idx",
|
||||||
|
"columns": [
|
||||||
|
"user",
|
||||||
|
"timestamp",
|
||||||
|
"uuid"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"games_user_users_name_fk": {
|
||||||
|
"name": "games_user_users_name_fk",
|
||||||
|
"tableFrom": "games",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"name"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {}
|
||||||
|
},
|
||||||
|
"gems": {
|
||||||
|
"name": "gems",
|
||||||
|
"columns": {
|
||||||
|
"user": {
|
||||||
|
"name": "user",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"count": {
|
||||||
|
"name": "count",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"totalCount": {
|
||||||
|
"name": "totalCount",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {}
|
||||||
|
},
|
||||||
|
"users": {
|
||||||
|
"name": "users",
|
||||||
|
"columns": {
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"password": {
|
||||||
|
"name": "password",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {}
|
||||||
|
},
|
||||||
|
"userSettings": {
|
||||||
|
"name": "userSettings",
|
||||||
|
"columns": {
|
||||||
|
"user": {
|
||||||
|
"name": "user",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"name": "settings",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enums": {},
|
||||||
|
"_meta": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {},
|
||||||
|
"columns": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,13 @@
|
||||||
"when": 1727551167145,
|
"when": 1727551167145,
|
||||||
"tag": "0000_gigantic_wasp",
|
"tag": "0000_gigantic_wasp",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 1,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1728834181598,
|
||||||
|
"tag": "0001_breezy_martin_li",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -7,6 +7,7 @@ interface CreateGameOptions {
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
mines: number;
|
mines: number;
|
||||||
|
theme: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isValid = (game: ServerGame, x: number, y: number) => {
|
const isValid = (game: ServerGame, x: number, y: number) => {
|
||||||
|
|
@ -48,15 +49,15 @@ const expandBoard = (serverGame: ServerGame) => {
|
||||||
const { width, height, stage, mines, isFlagged, isRevealed, isQuestionMark } =
|
const { width, height, stage, mines, isFlagged, isRevealed, isQuestionMark } =
|
||||||
serverGame;
|
serverGame;
|
||||||
let dir = stage % 2 === 0 ? "down" : "right";
|
let dir = stage % 2 === 0 ? "down" : "right";
|
||||||
if (stage > 11) {
|
if (stage > 13) {
|
||||||
dir = "down";
|
dir = "down";
|
||||||
}
|
}
|
||||||
// Expand the board by the current board size 8x8 -> 16x8
|
// Expand the board by the current board size 8x8 -> 16x8
|
||||||
if (dir === "down") {
|
if (dir === "down") {
|
||||||
const newHeight = Math.floor(height * 1.5);
|
const newHeight = Math.floor(Math.min(height + 6, height * 1.5));
|
||||||
const newWidth = width;
|
const newWidth = width;
|
||||||
const newMinesCount = Math.floor(
|
const newMinesCount = Math.floor(
|
||||||
width * height * 0.5 * (0.2 + 0.003 * stage),
|
width * height * 0.5 * (0.2 + 0.0015 * stage),
|
||||||
);
|
);
|
||||||
// expand mines array
|
// expand mines array
|
||||||
const newMines = Array.from({ length: newWidth }, () =>
|
const newMines = Array.from({ length: newWidth }, () =>
|
||||||
|
|
@ -111,10 +112,10 @@ const expandBoard = (serverGame: ServerGame) => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (dir === "right") {
|
if (dir === "right") {
|
||||||
const newWidth = Math.floor(width * 1.5);
|
const newWidth = Math.floor(Math.min(width + 6, width * 1.5));
|
||||||
const newHeight = height;
|
const newHeight = height;
|
||||||
const newMinesCount = Math.floor(
|
const newMinesCount = Math.floor(
|
||||||
width * height * 0.5 * (0.2 + 0.003 * stage),
|
width * height * 0.5 * (0.2 + 0.0015 * stage),
|
||||||
);
|
);
|
||||||
// expand mines array
|
// expand mines array
|
||||||
const newMines = Array.from({ length: newWidth }, () =>
|
const newMines = Array.from({ length: newWidth }, () =>
|
||||||
|
|
@ -216,6 +217,7 @@ export const game = {
|
||||||
stage: 1,
|
stage: 1,
|
||||||
lastClick: [-1, -1],
|
lastClick: [-1, -1],
|
||||||
minesCount: mines,
|
minesCount: mines,
|
||||||
|
theme: options.theme,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
reveal: (serverGame: ServerGame, x: number, y: number, initial = false) => {
|
reveal: (serverGame: ServerGame, x: number, y: number, initial = false) => {
|
||||||
|
|
@ -305,4 +307,10 @@ export const game = {
|
||||||
expandBoard(serverGame);
|
expandBoard(serverGame);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
getRewards: (serverGame: ServerGame) => {
|
||||||
|
const { finished, stage } = serverGame;
|
||||||
|
if (finished == 0) return 0;
|
||||||
|
if (stage < 2) return 0;
|
||||||
|
return Math.floor(Math.pow(2, stage * 0.8));
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { ServerWebSocket } from "bun";
|
||||||
import type { Events } from "../shared/events";
|
import type { Events } from "../shared/events";
|
||||||
|
|
||||||
const listeners = new Set<(event: Events) => void>();
|
const listeners = new Set<(event: Events) => void>();
|
||||||
|
|
@ -13,3 +14,7 @@ export const off = (listener: (event: Events) => void) => {
|
||||||
export const emit = (event: Events) => {
|
export const emit = (event: Events) => {
|
||||||
listeners.forEach((listener) => listener(event));
|
listeners.forEach((listener) => listener(event));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const emitToWS = (event: Events, ws: ServerWebSocket<unknown>) => {
|
||||||
|
ws.send(JSON.stringify(event));
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { Collection, type CollectionType } from "../schema";
|
||||||
|
import type { BunSQLiteDatabase } from "drizzle-orm/bun-sqlite";
|
||||||
|
import { decode, encode } from "@msgpack/msgpack";
|
||||||
|
import type { UserCollection } from "../../shared/gameType";
|
||||||
|
|
||||||
|
export const getCollection = async (
|
||||||
|
db: BunSQLiteDatabase,
|
||||||
|
user: string,
|
||||||
|
): Promise<UserCollection> => {
|
||||||
|
const res = (
|
||||||
|
await db.select().from(Collection).where(eq(Collection.user, user))
|
||||||
|
)[0];
|
||||||
|
if (res) return parseCollection(res);
|
||||||
|
return {
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
id: "default",
|
||||||
|
aquired: Date.now(),
|
||||||
|
selected: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const upsertCollection = async (
|
||||||
|
db: BunSQLiteDatabase,
|
||||||
|
user: string,
|
||||||
|
collection: UserCollection,
|
||||||
|
) => {
|
||||||
|
const dbCollection = await db
|
||||||
|
.select()
|
||||||
|
.from(Collection)
|
||||||
|
.where(eq(Collection.user, user));
|
||||||
|
if (dbCollection.length > 0) {
|
||||||
|
await db
|
||||||
|
.update(Collection)
|
||||||
|
.set({
|
||||||
|
collection: Buffer.from(encode(collection)),
|
||||||
|
})
|
||||||
|
.where(eq(Collection.user, user));
|
||||||
|
} else {
|
||||||
|
await db.insert(Collection).values({
|
||||||
|
user,
|
||||||
|
collection: Buffer.from(encode(collection)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parseCollection = (collection: CollectionType) => {
|
||||||
|
return decode(collection.collection) as UserCollection;
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { Gems } from "../schema";
|
||||||
|
import type { BunSQLiteDatabase } from "drizzle-orm/bun-sqlite";
|
||||||
|
|
||||||
|
export const getGems = async (db: BunSQLiteDatabase, user: string) => {
|
||||||
|
const res = (await db.select().from(Gems).where(eq(Gems.user, user)))[0];
|
||||||
|
const count = res?.count ?? 0;
|
||||||
|
const totalCount = res?.totalCount ?? 0;
|
||||||
|
return { count, totalCount };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const addGems = async (
|
||||||
|
db: BunSQLiteDatabase,
|
||||||
|
user: string,
|
||||||
|
gems: number,
|
||||||
|
) => {
|
||||||
|
const { count, totalCount } = await getGems(db, user);
|
||||||
|
if ((await db.select().from(Gems).where(eq(Gems.user, user))).length === 0) {
|
||||||
|
await db
|
||||||
|
.insert(Gems)
|
||||||
|
.values({ user, count: count + gems, totalCount: totalCount + gems });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await db
|
||||||
|
.update(Gems)
|
||||||
|
.set({ count: count + gems, totalCount: totalCount + gems })
|
||||||
|
.where(eq(Gems.user, user));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const removeGems = async (
|
||||||
|
db: BunSQLiteDatabase,
|
||||||
|
user: string,
|
||||||
|
gems: number,
|
||||||
|
) => {
|
||||||
|
const { count, totalCount } = await getGems(db, user);
|
||||||
|
await db
|
||||||
|
.update(Gems)
|
||||||
|
.set({ count: count - gems, totalCount: totalCount - gems })
|
||||||
|
.where(eq(Gems.user, user));
|
||||||
|
};
|
||||||
|
|
@ -38,8 +38,21 @@ export const UserSettings = sqliteTable("userSettings", {
|
||||||
settings: text("settings").notNull(),
|
settings: text("settings").notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const Gems = sqliteTable("gems", {
|
||||||
|
user: text("user").primaryKey().notNull(),
|
||||||
|
count: integer("count").notNull(),
|
||||||
|
totalCount: integer("totalCount").notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Collection = sqliteTable("collection", {
|
||||||
|
user: text("user").primaryKey().notNull(),
|
||||||
|
collection: blob("collection", { mode: "buffer" }).notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
export type UserType = Omit<typeof User.$inferSelect, "password"> & {
|
export type UserType = Omit<typeof User.$inferSelect, "password"> & {
|
||||||
password?: undefined;
|
password?: undefined;
|
||||||
};
|
};
|
||||||
export type GameType = typeof Game.$inferSelect;
|
export type GameType = typeof Game.$inferSelect;
|
||||||
export type UserSettingsType = typeof UserSettings.$inferSelect;
|
export type UserSettingsType = typeof UserSettings.$inferSelect;
|
||||||
|
export type GemsType = typeof Gems.$inferSelect;
|
||||||
|
export type CollectionType = typeof Collection.$inferSelect;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { defineConfig } from "drizzle-kit";
|
||||||
|
export default defineConfig({
|
||||||
|
dialect: "sqlite",
|
||||||
|
schema: "./backend/schema.ts",
|
||||||
|
out: "./backend/drizzle",
|
||||||
|
dbCredentials: {
|
||||||
|
url: "file:./sqlite.db",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -10,7 +10,7 @@
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"drizzle:schema": "drizzle-kit generate --dialect sqlite --schema ./backend/schema.ts --out ./backend/drizzle",
|
"drizzle:schema": "drizzle-kit generate",
|
||||||
"drizzle:migrate": "bun run backend/migrate.ts",
|
"drizzle:migrate": "bun run backend/migrate.ts",
|
||||||
"nukedb": "rm sqlite.db && bun run backend/migrate.ts"
|
"nukedb": "rm sqlite.db && bun run backend/migrate.ts"
|
||||||
},
|
},
|
||||||
|
|
@ -28,7 +28,7 @@
|
||||||
"@uidotdev/usehooks": "^2.4.1",
|
"@uidotdev/usehooks": "^2.4.1",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"drizzle-orm": "^0.34.1",
|
"drizzle-orm": "0.33.0",
|
||||||
"framer-motion": "^11.11.8",
|
"framer-motion": "^11.11.8",
|
||||||
"jotai": "^2.10.0",
|
"jotai": "^2.10.0",
|
||||||
"lucide-react": "^0.452.0",
|
"lucide-react": "^0.452.0",
|
||||||
|
|
@ -52,7 +52,7 @@
|
||||||
"@types/react": "^18.3.11",
|
"@types/react": "^18.3.11",
|
||||||
"@types/react-dom": "^18.3.1",
|
"@types/react-dom": "^18.3.1",
|
||||||
"@vitejs/plugin-react-swc": "^3.7.1",
|
"@vitejs/plugin-react-swc": "^3.7.1",
|
||||||
"drizzle-kit": "^0.25.0",
|
"drizzle-kit": "0.24.2",
|
||||||
"eslint": "^9.12.0",
|
"eslint": "^9.12.0",
|
||||||
"eslint-plugin-react": "^7.37.1",
|
"eslint-plugin-react": "^7.37.1",
|
||||||
"eslint-plugin-react-hooks": "5.0.0",
|
"eslint-plugin-react-hooks": "5.0.0",
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ export type Events =
|
||||||
type: "loss";
|
type: "loss";
|
||||||
user: string;
|
user: string;
|
||||||
stage: number;
|
stage: number;
|
||||||
|
time: number;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: "updateGame";
|
type: "updateGame";
|
||||||
|
|
@ -19,4 +20,9 @@ export type Events =
|
||||||
game: string;
|
game: string;
|
||||||
stage: number;
|
stage: number;
|
||||||
started: number;
|
started: number;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "gemsRewarded";
|
||||||
|
stage: number;
|
||||||
|
gems: number;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ export const clientGame = z.object({
|
||||||
lastClick: z.tuple([z.number(), z.number()]),
|
lastClick: z.tuple([z.number(), z.number()]),
|
||||||
started: z.number(),
|
started: z.number(),
|
||||||
stage: z.number(),
|
stage: z.number(),
|
||||||
|
theme: z.string().default("default"),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const serverGame = z.object({
|
export const serverGame = z.object({
|
||||||
|
|
@ -29,7 +30,18 @@ export const serverGame = z.object({
|
||||||
started: z.number(),
|
started: z.number(),
|
||||||
finished: z.number().default(0),
|
finished: z.number().default(0),
|
||||||
stage: z.number(),
|
stage: z.number(),
|
||||||
|
theme: z.string().default("default"),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type ClientGame = z.infer<typeof clientGame>;
|
export type ClientGame = z.infer<typeof clientGame>;
|
||||||
export type ServerGame = z.infer<typeof serverGame>;
|
export type ServerGame = z.infer<typeof serverGame>;
|
||||||
|
|
||||||
|
export interface UserCollectionEntry {
|
||||||
|
id: string;
|
||||||
|
aquired: number;
|
||||||
|
selected: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserCollection {
|
||||||
|
entries: UserCollectionEntry[];
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,197 @@
|
||||||
|
import type { themes } from "../src/themes";
|
||||||
|
|
||||||
|
export const rarities = [
|
||||||
|
{
|
||||||
|
name: "Common",
|
||||||
|
id: "common",
|
||||||
|
weight: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Uncommon",
|
||||||
|
id: "uncommon",
|
||||||
|
weight: 0.5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Rare",
|
||||||
|
id: "rare",
|
||||||
|
weight: 0.25,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Legendary",
|
||||||
|
id: "legendary",
|
||||||
|
weight: 0.1,
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type Rarity = (typeof rarities)[number]["id"];
|
||||||
|
type ThemeId = (typeof themes)[number]["id"];
|
||||||
|
|
||||||
|
interface Lootbox {
|
||||||
|
name: string;
|
||||||
|
items: {
|
||||||
|
id: ThemeId;
|
||||||
|
rarity: Rarity;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const series1: Lootbox = {
|
||||||
|
name: "Series 1",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: "basic",
|
||||||
|
rarity: "common",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "black-and-white",
|
||||||
|
rarity: "common",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "blue",
|
||||||
|
rarity: "common",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "green",
|
||||||
|
rarity: "common",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "orange",
|
||||||
|
rarity: "common",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pink",
|
||||||
|
rarity: "common",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "purple",
|
||||||
|
rarity: "common",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "red",
|
||||||
|
rarity: "common",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "turquoise",
|
||||||
|
rarity: "common",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "yellow",
|
||||||
|
rarity: "common",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "nautical",
|
||||||
|
rarity: "uncommon",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "up-in-smoke",
|
||||||
|
rarity: "uncommon",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "shadow-warrior",
|
||||||
|
rarity: "uncommon",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "crimson",
|
||||||
|
rarity: "uncommon",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "romance",
|
||||||
|
rarity: "uncommon",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "flowers",
|
||||||
|
rarity: "rare",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "dinos",
|
||||||
|
rarity: "rare",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "cats",
|
||||||
|
rarity: "rare",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "mine-dogs",
|
||||||
|
rarity: "rare",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "tron-blue",
|
||||||
|
rarity: "rare",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "tron-orange",
|
||||||
|
rarity: "rare",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "circuit",
|
||||||
|
rarity: "rare",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "circuit-binary",
|
||||||
|
rarity: "rare",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "farm",
|
||||||
|
rarity: "rare",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "halli-galli",
|
||||||
|
rarity: "rare",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "insects",
|
||||||
|
rarity: "rare",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "poop",
|
||||||
|
rarity: "rare",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "underwater",
|
||||||
|
rarity: "rare",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "retro-wave",
|
||||||
|
rarity: "legendary",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "elden-ring",
|
||||||
|
rarity: "legendary",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "janitor-tresh",
|
||||||
|
rarity: "legendary",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "teemo",
|
||||||
|
rarity: "legendary",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ziggs",
|
||||||
|
rarity: "legendary",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "minecraft-nether",
|
||||||
|
rarity: "legendary",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "minecraft-overworld",
|
||||||
|
rarity: "legendary",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "techies-dire",
|
||||||
|
rarity: "legendary",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "techies-radiant",
|
||||||
|
rarity: "legendary",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "isaac",
|
||||||
|
rarity: "legendary",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "mlg",
|
||||||
|
rarity: "legendary",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
export const pickRandom = <T>(arr: T[]) => {
|
||||||
|
const index = Math.floor(Math.random() * arr.length);
|
||||||
|
return arr[index];
|
||||||
|
};
|
||||||
|
|
||||||
|
interface WeightedEntry<T> {
|
||||||
|
weight: number;
|
||||||
|
value: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const hashStr = (str: string) => {
|
||||||
|
return [...str].reduce(
|
||||||
|
(hash, c) => (Math.imul(31, hash) + c.charCodeAt(0)) | 0,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const weightedPickRandom = <T>(
|
||||||
|
arr: WeightedEntry<T>[],
|
||||||
|
getRandom: (tw: number) => number = (totalWeight) =>
|
||||||
|
Math.random() * totalWeight,
|
||||||
|
): T => {
|
||||||
|
const totalWeight = arr.reduce((acc, cur) => acc + cur.weight, 0);
|
||||||
|
const random = getRandom(totalWeight);
|
||||||
|
let currentWeight = 0;
|
||||||
|
for (const entry of arr) {
|
||||||
|
currentWeight += entry.weight;
|
||||||
|
if (random < currentWeight) {
|
||||||
|
return entry.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return arr[arr.length - 1].value;
|
||||||
|
};
|
||||||
|
|
@ -1,12 +1,21 @@
|
||||||
import { PropsWithChildren, useEffect, useRef, useState } from "react";
|
import { PropsWithChildren, useEffect, useRef, useState } from "react";
|
||||||
import { Button } from "./components/Button";
|
import { Button } from "./components/Button";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { GitBranch, History, Home, Menu, Play, Settings } from "lucide-react";
|
import {
|
||||||
|
GitBranch,
|
||||||
|
History,
|
||||||
|
Home,
|
||||||
|
Library,
|
||||||
|
Menu,
|
||||||
|
Play,
|
||||||
|
Settings,
|
||||||
|
} from "lucide-react";
|
||||||
import Hr from "./components/Hr";
|
import Hr from "./components/Hr";
|
||||||
import NavLink from "./components/NavLink";
|
import NavLink from "./components/NavLink";
|
||||||
import { useMediaQuery } from "@uidotdev/usehooks";
|
import { useMediaQuery } from "@uidotdev/usehooks";
|
||||||
import Header from "./components/Header";
|
import Header from "./components/Header";
|
||||||
import { Tag } from "./components/Tag";
|
import { Tag } from "./components/Tag";
|
||||||
|
import Feed from "./components/Feed/Feed";
|
||||||
|
|
||||||
const drawerWidth = 256;
|
const drawerWidth = 256;
|
||||||
const drawerWidthWithPadding = drawerWidth;
|
const drawerWidthWithPadding = drawerWidth;
|
||||||
|
|
@ -66,11 +75,17 @@ const Shell: React.FC<PropsWithChildren> = ({ children }) => {
|
||||||
<History />
|
<History />
|
||||||
History
|
History
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
<NavLink href="/collection">
|
||||||
|
<Library />
|
||||||
|
Collection <Tag size="sm">NEW</Tag>
|
||||||
|
</NavLink>
|
||||||
<NavLink href="/settings">
|
<NavLink href="/settings">
|
||||||
<Settings />
|
<Settings />
|
||||||
Settings <Tag size="sm">NEW</Tag>
|
Settings
|
||||||
</NavLink>
|
</NavLink>
|
||||||
<Hr />
|
<Hr />
|
||||||
|
<Feed />
|
||||||
|
<Hr />
|
||||||
<div className="grow" />
|
<div className="grow" />
|
||||||
<NavLink href="https://github.com/MasterGordon/minesweeper" external>
|
<NavLink href="https://github.com/MasterGordon/minesweeper" external>
|
||||||
<GitBranch />
|
<GitBranch />
|
||||||
|
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 582 KiB |
|
|
@ -1,5 +1,6 @@
|
||||||
import { atom } from "jotai";
|
import { atom } from "jotai";
|
||||||
import { atomWithStorage } from "jotai/utils";
|
import { atomWithStorage } from "jotai/utils";
|
||||||
|
import { FeedItem } from "./components/Feed/FeedItem";
|
||||||
|
|
||||||
export const gameIdAtom = atom<string | undefined>(undefined);
|
export const gameIdAtom = atom<string | undefined>(undefined);
|
||||||
export const loginTokenAtom = atomWithStorage<string | undefined>(
|
export const loginTokenAtom = atomWithStorage<string | undefined>(
|
||||||
|
|
@ -8,3 +9,4 @@ export const loginTokenAtom = atomWithStorage<string | undefined>(
|
||||||
);
|
);
|
||||||
export const cursorXAtom = atom(0);
|
export const cursorXAtom = atom(0);
|
||||||
export const cursorYAtom = atom(0);
|
export const cursorYAtom = atom(0);
|
||||||
|
export const feedItemsAtom = atom<FeedItem[]>([]);
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ import "@pixi/canvas-sprite";
|
||||||
import "@pixi/canvas-text";
|
import "@pixi/canvas-text";
|
||||||
|
|
||||||
interface BoardProps {
|
interface BoardProps {
|
||||||
|
className?: string;
|
||||||
theme: Theme;
|
theme: Theme;
|
||||||
game: ServerGame | ClientGame;
|
game: ServerGame | ClientGame;
|
||||||
onLeftClick: (x: number, y: number) => void;
|
onLeftClick: (x: number, y: number) => void;
|
||||||
|
|
@ -139,6 +140,7 @@ const Board: React.FC<BoardProps> = (props) => {
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full h-[70vh] overflow-hidden outline-white/40 outline-2 flex flex-col",
|
"w-full h-[70vh] overflow-hidden outline-white/40 outline-2 flex flex-col",
|
||||||
zenMode && "fixed top-0 left-0 z-50 right-0 bottom-0 h-[100vh]",
|
zenMode && "fixed top-0 left-0 z-50 right-0 bottom-0 h-[100vh]",
|
||||||
|
props.className,
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
width: props.width ? `${props.width}px` : undefined,
|
width: props.width ? `${props.width}px` : undefined,
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,7 @@ const DropdownMenuContent = React.forwardRef<
|
||||||
ref={ref}
|
ref={ref}
|
||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
className={cn(
|
className={cn(
|
||||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
"z-50 min-w-[8rem] overflow-hidden rounded-md border pover p-1 shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 text-white/70 w-auto mt-2 bg-black",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,73 @@
|
||||||
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { feedItemsAtom } from "../../atoms";
|
||||||
|
import FeedItemElement from "./FeedItem";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { addMessageListener, removeMessageListener } from "../../wsClient";
|
||||||
|
import type { Events } from "../../../shared/events";
|
||||||
|
import { useWSQuery } from "../../hooks";
|
||||||
|
|
||||||
|
const Feed: React.FC = () => {
|
||||||
|
const [items, setItems] = useAtom(feedItemsAtom);
|
||||||
|
const { data: user } = useWSQuery("user.getSelf", null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setItems((items) => items.filter((item) => item.decay > Date.now()));
|
||||||
|
}, 1000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [setItems]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const listener = (event: MessageEvent) => {
|
||||||
|
const data = JSON.parse(event.data) as Events;
|
||||||
|
const newItems = [...items];
|
||||||
|
if (data.type === "new" && data.user !== user) {
|
||||||
|
newItems.push({
|
||||||
|
type: "gameStarted",
|
||||||
|
user: data.user,
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
decay: Date.now() + 1000 * 3,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (data.type === "loss") {
|
||||||
|
newItems.push({
|
||||||
|
type: "gameFinished",
|
||||||
|
user: data.user,
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
decay: Date.now() + 1000 * 3 + data.stage * 500,
|
||||||
|
stage: data.stage,
|
||||||
|
time: data.time,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (data.type === "gemsRewarded" && data.gems > 0) {
|
||||||
|
newItems.push({
|
||||||
|
type: "gemsEarned",
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
decay: Date.now() + 1000 * 3 + data.gems * 500,
|
||||||
|
stage: data.stage,
|
||||||
|
gems: data.gems,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setItems(newItems);
|
||||||
|
};
|
||||||
|
addMessageListener(listener);
|
||||||
|
return () => removeMessageListener(listener);
|
||||||
|
}, [items, setItems, user]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4 w-full items-start h-[30%] overflow-y-hidden">
|
||||||
|
<div className="text-white relative">
|
||||||
|
<motion.div layout>
|
||||||
|
<AnimatePresence>
|
||||||
|
{items.map((item) => (
|
||||||
|
<FeedItemElement key={item.id} item={item} />
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Feed;
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { PropsWithChildren } from "react";
|
||||||
|
import { formatTimeSpan } from "../../../shared/time";
|
||||||
|
import GemsIcon from "../GemIcon";
|
||||||
|
|
||||||
|
interface BaseFeedItem {
|
||||||
|
decay: number;
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GameStartedItem extends BaseFeedItem {
|
||||||
|
type: "gameStarted";
|
||||||
|
user: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GameFinishedItem extends BaseFeedItem {
|
||||||
|
type: "gameFinished";
|
||||||
|
user: string;
|
||||||
|
stage: number;
|
||||||
|
time: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GemsEarnedItem extends BaseFeedItem {
|
||||||
|
type: "gemsEarned";
|
||||||
|
gems: number;
|
||||||
|
stage: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FeedItem = GameStartedItem | GameFinishedItem | GemsEarnedItem;
|
||||||
|
|
||||||
|
const FeedItemWrapper: React.FC<PropsWithChildren> = ({ children }) => {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
layout
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const FeedItemElement: React.FC<{ item: FeedItem }> = ({ item }) => {
|
||||||
|
switch (item.type) {
|
||||||
|
case "gameStarted":
|
||||||
|
return <FeedItemWrapper>{item.user} started a game</FeedItemWrapper>;
|
||||||
|
case "gameFinished":
|
||||||
|
return (
|
||||||
|
<FeedItemWrapper>
|
||||||
|
{item.user} finished in{" "}
|
||||||
|
<span className="whitespace-nowrap">stage {item.stage}</span> after{" "}
|
||||||
|
{formatTimeSpan(item.time)}
|
||||||
|
</FeedItemWrapper>
|
||||||
|
);
|
||||||
|
case "gemsEarned":
|
||||||
|
return (
|
||||||
|
<FeedItemWrapper>
|
||||||
|
You got {item.gems} <GemsIcon /> for <span>stage {item.stage}</span>
|
||||||
|
</FeedItemWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FeedItemElement;
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
import gem from "../assets/gem.png?w=20&h=20&inline";
|
||||||
|
|
||||||
|
const GemsIcon = () => {
|
||||||
|
return <img src={gem} className="size-5 inline" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GemsIcon;
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
import { Tag } from "./Tag";
|
||||||
|
import GemsIcon from "./GemIcon";
|
||||||
|
|
||||||
|
interface GemsProps {
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Gems: React.FC<GemsProps> = ({ count }) => {
|
||||||
|
return (
|
||||||
|
<Tag variant="outline2" className="flex gap-1 items-center">
|
||||||
|
<span className="text-white/90">{count}</span>
|
||||||
|
<GemsIcon />
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Gems;
|
||||||
|
|
@ -14,6 +14,7 @@ import RegisterButton from "./Auth/RegisterButton";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { loginTokenAtom } from "../atoms";
|
import { loginTokenAtom } from "../atoms";
|
||||||
|
import Gems from "./Gems";
|
||||||
|
|
||||||
const Header = () => {
|
const Header = () => {
|
||||||
const [, setLocation] = useLocation();
|
const [, setLocation] = useLocation();
|
||||||
|
|
@ -24,6 +25,7 @@ const Header = () => {
|
||||||
setToken(undefined);
|
setToken(undefined);
|
||||||
queryClient.resetQueries();
|
queryClient.resetQueries();
|
||||||
});
|
});
|
||||||
|
const { data: gems } = useWSQuery("user.getOwnGems", null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full flex gap-4">
|
<div className="w-full flex gap-4">
|
||||||
|
|
@ -31,13 +33,14 @@ const Header = () => {
|
||||||
|
|
||||||
{username ? (
|
{username ? (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
|
{typeof gems?.count === "number" && <Gems count={gems?.count ?? 0} />}
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="outline">
|
<Button variant="outline">
|
||||||
<UserRound className="text-white/70" />
|
<UserRound className="text-white/70" />
|
||||||
<p className="text-white/70 font-bold">{username}</p>
|
<p className="text-white/70 font-bold">{username}</p>
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent className="text-white/70 w-auto mt-2 bg-black">
|
<DropdownMenuContent>
|
||||||
<DropdownMenuItem onClick={() => setLocation("/profile")}>
|
<DropdownMenuItem onClick={() => setLocation("/profile")}>
|
||||||
Profile
|
Profile
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import Home from "./views/home/Home.tsx";
|
||||||
import Settings from "./views/settings/Settings.tsx";
|
import Settings from "./views/settings/Settings.tsx";
|
||||||
import MatchHistory from "./views/match-history/MatchHistory.tsx";
|
import MatchHistory from "./views/match-history/MatchHistory.tsx";
|
||||||
import Collection from "./views/collection/Collection.tsx";
|
import Collection from "./views/collection/Collection.tsx";
|
||||||
|
import { AnimatePresence } from "framer-motion";
|
||||||
|
|
||||||
const setup = async () => {
|
const setup = async () => {
|
||||||
const token = localStorage.getItem("loginToken");
|
const token = localStorage.getItem("loginToken");
|
||||||
|
|
@ -32,6 +33,7 @@ setup().then(() => {
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<Shell>
|
<Shell>
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route path="/" component={Home} />
|
<Route path="/" component={Home} />
|
||||||
<Route path="/play/:gameId?">
|
<Route path="/play/:gameId?">
|
||||||
|
|
@ -41,6 +43,7 @@ setup().then(() => {
|
||||||
<Route path="/settings" component={Settings} />
|
<Route path="/settings" component={Settings} />
|
||||||
<Route path="/collection" component={Collection} />
|
<Route path="/collection" component={Collection} />
|
||||||
</Switch>
|
</Switch>
|
||||||
|
</AnimatePresence>
|
||||||
</Shell>
|
</Shell>
|
||||||
<ReactQueryDevtools initialIsOpen={false} />
|
<ReactQueryDevtools initialIsOpen={false} />
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,16 @@
|
||||||
import { Theme } from "../Theme";
|
import { Theme } from "../Theme";
|
||||||
|
|
||||||
export const dwarfFortressTheme: Theme = {
|
export const crimson: Theme = {
|
||||||
size: 32,
|
size: 32,
|
||||||
mine: () => import("../../assets/themes/color-palettes/crimson/mine.png"),
|
mine: () => import("../../assets/themes/color-palettes/crimson/mine.png"),
|
||||||
tile: () => import("../../assets/themes/color-palettes/crimson/tile.png"),
|
tile: () => import("../../assets/themes/color-palettes/crimson/tile.png"),
|
||||||
revealed: () => import("../../assets/themes/color-palettes/crimson/revealed.png"),
|
revealed: () =>
|
||||||
|
import("../../assets/themes/color-palettes/crimson/revealed.png"),
|
||||||
flag: () => import("../../assets/themes/color-palettes/crimson/flag.png"),
|
flag: () => import("../../assets/themes/color-palettes/crimson/flag.png"),
|
||||||
questionMark: () =>
|
questionMark: () =>
|
||||||
import("../../assets/themes/color-palettes/crimson/question-mark.png"),
|
import("../../assets/themes/color-palettes/crimson/question-mark.png"),
|
||||||
lastPos: () => import("../../assets/themes/color-palettes/crimson/last-pos.png"),
|
lastPos: () =>
|
||||||
|
import("../../assets/themes/color-palettes/crimson/last-pos.png"),
|
||||||
1: () => import("../../assets/themes/color-palettes/crimson/1.png"),
|
1: () => import("../../assets/themes/color-palettes/crimson/1.png"),
|
||||||
2: () => import("../../assets/themes/color-palettes/crimson/2.png"),
|
2: () => import("../../assets/themes/color-palettes/crimson/2.png"),
|
||||||
3: () => import("../../assets/themes/color-palettes/crimson/3.png"),
|
3: () => import("../../assets/themes/color-palettes/crimson/3.png"),
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { blackAndWhiteTheme } from "./black-and-white";
|
||||||
import { catsTheme } from "./cats";
|
import { catsTheme } from "./cats";
|
||||||
import { circuitTheme } from "./circuit";
|
import { circuitTheme } from "./circuit";
|
||||||
import { circuitBinaryTheme } from "./circuit-binary";
|
import { circuitBinaryTheme } from "./circuit-binary";
|
||||||
import { dwarfFortressTheme } from "./color-palettes/crimson";
|
import { crimson } from "./color-palettes/crimson";
|
||||||
import { nauticalTheme } from "./color-palettes/nautical";
|
import { nauticalTheme } from "./color-palettes/nautical";
|
||||||
import { shadowWarriorTheme } from "./color-palettes/shadow-warrior";
|
import { shadowWarriorTheme } from "./color-palettes/shadow-warrior";
|
||||||
import { upInSmokeTheme } from "./color-palettes/up-in-smoke";
|
import { upInSmokeTheme } from "./color-palettes/up-in-smoke";
|
||||||
|
|
@ -48,7 +48,7 @@ interface ThemeEntry {
|
||||||
theme: Theme;
|
theme: Theme;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const themes: ThemeEntry[] = [
|
export const themes = [
|
||||||
{
|
{
|
||||||
name: "Default",
|
name: "Default",
|
||||||
tags: ["Simple"],
|
tags: ["Simple"],
|
||||||
|
|
@ -220,7 +220,7 @@ export const themes: ThemeEntry[] = [
|
||||||
{
|
{
|
||||||
name: "Circuit Binary",
|
name: "Circuit Binary",
|
||||||
tags: ["No Numbers"],
|
tags: ["No Numbers"],
|
||||||
id: "circuit-biinary",
|
id: "circuit-binary",
|
||||||
theme: circuitBinaryTheme,
|
theme: circuitBinaryTheme,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -287,6 +287,6 @@ export const themes: ThemeEntry[] = [
|
||||||
name: "Crimson",
|
name: "Crimson",
|
||||||
tags: [],
|
tags: [],
|
||||||
id: "crimson",
|
id: "crimson",
|
||||||
theme: dwarfFortressTheme,
|
theme: crimson,
|
||||||
},
|
},
|
||||||
];
|
] as const satisfies ThemeEntry[];
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,72 @@
|
||||||
|
import { Ellipsis } from "lucide-react";
|
||||||
import { testBoard } from "../../../shared/testBoard";
|
import { testBoard } from "../../../shared/testBoard";
|
||||||
import Board from "../../components/Board";
|
import Board from "../../components/Board";
|
||||||
|
import { Button } from "../../components/Button";
|
||||||
import { themes } from "../../themes";
|
import { themes } from "../../themes";
|
||||||
|
import {
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuContent,
|
||||||
|
} from "../../components/DropdownMenu";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
import { useWSMutation, useWSQuery } from "../../hooks";
|
||||||
|
|
||||||
const Collection = () => {
|
const Collection = () => {
|
||||||
|
const { data: collection, refetch } = useWSQuery(
|
||||||
|
"user.getOwnCollection",
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const mutateSelected = useWSMutation("user.selectCollectionEntry");
|
||||||
|
const mutateShuffle = useWSMutation("user.addCollectionEntryToShuffle");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 w-full">
|
<div className="flex flex-col gap-4 w-full">
|
||||||
<h2 className="text-white/90 text-xl">Collection</h2>
|
<h2 className="text-white/90 text-xl">Collection</h2>
|
||||||
<div className="flex flex-row gap-y-4 gap-x-8 items-center w-full flex-wrap justify-center mb-10">
|
<div className="flex flex-row gap-y-6 gap-x-8 items-center w-full flex-wrap justify-center mb-10">
|
||||||
{themes.map((theme) => (
|
{themes.map((theme) => {
|
||||||
|
const selected = collection?.entries.some(
|
||||||
|
(e) => e.id === theme.id && e.selected,
|
||||||
|
);
|
||||||
|
const owned = collection?.entries.some(
|
||||||
|
(e) => e.id === theme.id && e.selected,
|
||||||
|
);
|
||||||
|
return (
|
||||||
<div key={theme.id}>
|
<div key={theme.id}>
|
||||||
<h3 className="text-white/90 text-lg">{theme.name}</h3>
|
<div className="flex gap-4 justify-between">
|
||||||
|
<h3 className="text-white/90 text-lg">
|
||||||
|
{theme.name}
|
||||||
|
{owned && (
|
||||||
|
<span className="text-white/70 text-sm"> (Owned)</span>
|
||||||
|
)}
|
||||||
|
</h3>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="sm">
|
||||||
|
<Ellipsis className="size-5" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent sideOffset={-12}>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
mutateSelected
|
||||||
|
.mutateAsync({ id: theme.id })
|
||||||
|
.then(() => refetch());
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Select
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() =>
|
||||||
|
mutateShuffle.mutateAsync({ id: theme.id })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{" "}
|
||||||
|
Add to shuffle
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
<Board
|
<Board
|
||||||
game={testBoard}
|
game={testBoard}
|
||||||
theme={theme.theme}
|
theme={theme.theme}
|
||||||
|
|
@ -18,9 +75,13 @@ const Collection = () => {
|
||||||
onRightClick={() => {}}
|
onRightClick={() => {}}
|
||||||
width={11 * 32}
|
width={11 * 32}
|
||||||
height={4 * 32}
|
height={4 * 32}
|
||||||
|
className={cn(
|
||||||
|
selected && "outline-primary outline-4 rounded-md",
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,11 @@ declare module "*&as=metadata" {
|
||||||
export default outputs;
|
export default outputs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
declare module "*&inline" {
|
||||||
|
const outputs: string;
|
||||||
|
export default outputs;
|
||||||
|
}
|
||||||
|
|
||||||
declare module "*?as=metadata" {
|
declare module "*?as=metadata" {
|
||||||
const outputs: OutputMetadata[];
|
const outputs: OutputMetadata[];
|
||||||
export default outputs;
|
export default outputs;
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,12 @@ const connectionString = import.meta.env.DEV
|
||||||
: "wss://mbv2.gordon.business/ws";
|
: "wss://mbv2.gordon.business/ws";
|
||||||
|
|
||||||
const messageListeners = new Set<(event: MessageEvent) => void>();
|
const messageListeners = new Set<(event: MessageEvent) => void>();
|
||||||
const addMessageListener = (listener: (event: MessageEvent) => void) => {
|
export const addMessageListener = (listener: (event: MessageEvent) => void) => {
|
||||||
messageListeners.add(listener);
|
messageListeners.add(listener);
|
||||||
};
|
};
|
||||||
const removeMessageListener = (listener: (event: MessageEvent) => void) => {
|
export const removeMessageListener = (
|
||||||
|
listener: (event: MessageEvent) => void,
|
||||||
|
) => {
|
||||||
messageListeners.delete(listener);
|
messageListeners.delete(listener);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -33,6 +35,11 @@ const createWSClient = () => {
|
||||||
queryKey: ["scoreboard.getScoreBoard", 10],
|
queryKey: ["scoreboard.getScoreBoard", 10],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (data.type === "gemsRewarded") {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["user.getOwnGems", null],
|
||||||
|
});
|
||||||
|
}
|
||||||
if (import.meta.env.DEV) {
|
if (import.meta.env.DEV) {
|
||||||
console.log("Received message", data);
|
console.log("Received message", data);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue