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
|
||||
echo "SECRET=SOME_RANDOM_STRING" > .env
|
||||
bun install
|
||||
bun run dev
|
||||
bun run drizzle:migrate
|
||||
bun dev
|
||||
```
|
||||
|
||||
## 📦 Used Libraries
|
||||
|
|
|
|||
|
|
@ -12,8 +12,11 @@ import { serverToClientGame, type ServerGame } from "../../shared/game";
|
|||
import crypto from "crypto";
|
||||
import { game } from "../entities/game";
|
||||
import { UnauthorizedError } from "../errors/UnauthorizedError";
|
||||
import { emit } from "../events";
|
||||
import { emit, emitToWS } from "../events";
|
||||
import { serverGame } from "../../shared/gameType";
|
||||
import { pickRandom } from "../../shared/utils";
|
||||
import { addGems } from "../repositories/gemsRepository";
|
||||
import { getCollection } from "../repositories/collectionRepository";
|
||||
|
||||
export const gameController = createController({
|
||||
getGameState: createEndpoint(z.string(), async (uuid, ctx) => {
|
||||
|
|
@ -26,12 +29,14 @@ export const gameController = createController({
|
|||
createGame: createEndpoint(z.null(), async (_, { user, db }) => {
|
||||
if (!user) throw new UnauthorizedError("Unauthorized");
|
||||
const uuid = crypto.randomUUID() as string;
|
||||
const collection = await getCollection(db, user);
|
||||
const newGame: ServerGame = game.createGame({
|
||||
uuid,
|
||||
user: user,
|
||||
mines: 2,
|
||||
width: 4,
|
||||
height: 4,
|
||||
theme: pickRandom(collection.entries.filter((e) => e.selected)).id,
|
||||
});
|
||||
upsertGameState(db, newGame);
|
||||
emit({
|
||||
|
|
@ -48,7 +53,7 @@ export const gameController = createController({
|
|||
}),
|
||||
reveal: createEndpoint(
|
||||
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");
|
||||
const dbGame = await getCurrentGame(db, user);
|
||||
const serverGame = parseGameState(dbGame.gameState);
|
||||
|
|
@ -64,13 +69,24 @@ export const gameController = createController({
|
|||
type: "loss",
|
||||
stage: serverGame.stage,
|
||||
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(
|
||||
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");
|
||||
const dbGame = await getCurrentGame(db, user);
|
||||
const serverGame = parseGameState(dbGame.gameState);
|
||||
|
|
@ -86,7 +102,18 @@ export const gameController = createController({
|
|||
type: "loss",
|
||||
stage: serverGame.stage,
|
||||
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(
|
||||
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");
|
||||
const dbGame = await getCurrentGame(db, user);
|
||||
const serverGame = parseGameState(dbGame.gameState);
|
||||
|
|
@ -122,7 +149,18 @@ export const gameController = createController({
|
|||
type: "loss",
|
||||
stage: serverGame.stage,
|
||||
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 { userSettings } from "../../shared/user-settings";
|
||||
import { UnauthorizedError } from "../errors/UnauthorizedError";
|
||||
import { getGems } from "../repositories/gemsRepository";
|
||||
import {
|
||||
getCollection,
|
||||
upsertCollection,
|
||||
} from "../repositories/collectionRepository";
|
||||
|
||||
const secret = process.env.SECRET!;
|
||||
|
||||
|
|
@ -87,4 +92,45 @@ export const userController = createController({
|
|||
const count = await getUserCount(db);
|
||||
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,
|
||||
"tag": "0000_gigantic_wasp",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "6",
|
||||
"when": 1728834181598,
|
||||
"tag": "0001_breezy_martin_li",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ interface CreateGameOptions {
|
|||
width: number;
|
||||
height: number;
|
||||
mines: number;
|
||||
theme: string;
|
||||
}
|
||||
|
||||
const isValid = (game: ServerGame, x: number, y: number) => {
|
||||
|
|
@ -48,15 +49,15 @@ const expandBoard = (serverGame: ServerGame) => {
|
|||
const { width, height, stage, mines, isFlagged, isRevealed, isQuestionMark } =
|
||||
serverGame;
|
||||
let dir = stage % 2 === 0 ? "down" : "right";
|
||||
if (stage > 11) {
|
||||
if (stage > 13) {
|
||||
dir = "down";
|
||||
}
|
||||
// Expand the board by the current board size 8x8 -> 16x8
|
||||
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 newMinesCount = Math.floor(
|
||||
width * height * 0.5 * (0.2 + 0.003 * stage),
|
||||
width * height * 0.5 * (0.2 + 0.0015 * stage),
|
||||
);
|
||||
// expand mines array
|
||||
const newMines = Array.from({ length: newWidth }, () =>
|
||||
|
|
@ -111,10 +112,10 @@ const expandBoard = (serverGame: ServerGame) => {
|
|||
});
|
||||
}
|
||||
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 newMinesCount = Math.floor(
|
||||
width * height * 0.5 * (0.2 + 0.003 * stage),
|
||||
width * height * 0.5 * (0.2 + 0.0015 * stage),
|
||||
);
|
||||
// expand mines array
|
||||
const newMines = Array.from({ length: newWidth }, () =>
|
||||
|
|
@ -216,6 +217,7 @@ export const game = {
|
|||
stage: 1,
|
||||
lastClick: [-1, -1],
|
||||
minesCount: mines,
|
||||
theme: options.theme,
|
||||
};
|
||||
},
|
||||
reveal: (serverGame: ServerGame, x: number, y: number, initial = false) => {
|
||||
|
|
@ -305,4 +307,10 @@ export const game = {
|
|||
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";
|
||||
|
||||
const listeners = new Set<(event: Events) => void>();
|
||||
|
|
@ -13,3 +14,7 @@ export const off = (listener: (event: Events) => void) => {
|
|||
export const emit = (event: Events) => {
|
||||
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(),
|
||||
});
|
||||
|
||||
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"> & {
|
||||
password?: undefined;
|
||||
};
|
||||
export type GameType = typeof Game.$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",
|
||||
"lint": "eslint",
|
||||
"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",
|
||||
"nukedb": "rm sqlite.db && bun run backend/migrate.ts"
|
||||
},
|
||||
|
|
@ -28,7 +28,7 @@
|
|||
"@uidotdev/usehooks": "^2.4.1",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"drizzle-orm": "^0.34.1",
|
||||
"drizzle-orm": "0.33.0",
|
||||
"framer-motion": "^11.11.8",
|
||||
"jotai": "^2.10.0",
|
||||
"lucide-react": "^0.452.0",
|
||||
|
|
@ -52,7 +52,7 @@
|
|||
"@types/react": "^18.3.11",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react-swc": "^3.7.1",
|
||||
"drizzle-kit": "^0.25.0",
|
||||
"drizzle-kit": "0.24.2",
|
||||
"eslint": "^9.12.0",
|
||||
"eslint-plugin-react": "^7.37.1",
|
||||
"eslint-plugin-react-hooks": "5.0.0",
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ export type Events =
|
|||
type: "loss";
|
||||
user: string;
|
||||
stage: number;
|
||||
time: number;
|
||||
}
|
||||
| {
|
||||
type: "updateGame";
|
||||
|
|
@ -19,4 +20,9 @@ export type Events =
|
|||
game: string;
|
||||
stage: 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()]),
|
||||
started: z.number(),
|
||||
stage: z.number(),
|
||||
theme: z.string().default("default"),
|
||||
});
|
||||
|
||||
export const serverGame = z.object({
|
||||
|
|
@ -29,7 +30,18 @@ export const serverGame = z.object({
|
|||
started: z.number(),
|
||||
finished: z.number().default(0),
|
||||
stage: z.number(),
|
||||
theme: z.string().default("default"),
|
||||
});
|
||||
|
||||
export type ClientGame = z.infer<typeof clientGame>;
|
||||
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 { Button } from "./components/Button";
|
||||
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 NavLink from "./components/NavLink";
|
||||
import { useMediaQuery } from "@uidotdev/usehooks";
|
||||
import Header from "./components/Header";
|
||||
import { Tag } from "./components/Tag";
|
||||
import Feed from "./components/Feed/Feed";
|
||||
|
||||
const drawerWidth = 256;
|
||||
const drawerWidthWithPadding = drawerWidth;
|
||||
|
|
@ -66,11 +75,17 @@ const Shell: React.FC<PropsWithChildren> = ({ children }) => {
|
|||
<History />
|
||||
History
|
||||
</NavLink>
|
||||
<NavLink href="/collection">
|
||||
<Library />
|
||||
Collection <Tag size="sm">NEW</Tag>
|
||||
</NavLink>
|
||||
<NavLink href="/settings">
|
||||
<Settings />
|
||||
Settings <Tag size="sm">NEW</Tag>
|
||||
Settings
|
||||
</NavLink>
|
||||
<Hr />
|
||||
<Feed />
|
||||
<Hr />
|
||||
<div className="grow" />
|
||||
<NavLink href="https://github.com/MasterGordon/minesweeper" external>
|
||||
<GitBranch />
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 582 KiB |
|
|
@ -1,5 +1,6 @@
|
|||
import { atom } from "jotai";
|
||||
import { atomWithStorage } from "jotai/utils";
|
||||
import { FeedItem } from "./components/Feed/FeedItem";
|
||||
|
||||
export const gameIdAtom = atom<string | undefined>(undefined);
|
||||
export const loginTokenAtom = atomWithStorage<string | undefined>(
|
||||
|
|
@ -8,3 +9,4 @@ export const loginTokenAtom = atomWithStorage<string | undefined>(
|
|||
);
|
||||
export const cursorXAtom = atom(0);
|
||||
export const cursorYAtom = atom(0);
|
||||
export const feedItemsAtom = atom<FeedItem[]>([]);
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ import "@pixi/canvas-sprite";
|
|||
import "@pixi/canvas-text";
|
||||
|
||||
interface BoardProps {
|
||||
className?: string;
|
||||
theme: Theme;
|
||||
game: ServerGame | ClientGame;
|
||||
onLeftClick: (x: number, y: number) => void;
|
||||
|
|
@ -139,6 +140,7 @@ const Board: React.FC<BoardProps> = (props) => {
|
|||
className={cn(
|
||||
"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]",
|
||||
props.className,
|
||||
)}
|
||||
style={{
|
||||
width: props.width ? `${props.width}px` : undefined,
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ const DropdownMenuContent = React.forwardRef<
|
|||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
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,
|
||||
)}
|
||||
{...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 { useAtom } from "jotai";
|
||||
import { loginTokenAtom } from "../atoms";
|
||||
import Gems from "./Gems";
|
||||
|
||||
const Header = () => {
|
||||
const [, setLocation] = useLocation();
|
||||
|
|
@ -24,6 +25,7 @@ const Header = () => {
|
|||
setToken(undefined);
|
||||
queryClient.resetQueries();
|
||||
});
|
||||
const { data: gems } = useWSQuery("user.getOwnGems", null);
|
||||
|
||||
return (
|
||||
<div className="w-full flex gap-4">
|
||||
|
|
@ -31,13 +33,14 @@ const Header = () => {
|
|||
|
||||
{username ? (
|
||||
<DropdownMenu>
|
||||
{typeof gems?.count === "number" && <Gems count={gems?.count ?? 0} />}
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline">
|
||||
<UserRound className="text-white/70" />
|
||||
<p className="text-white/70 font-bold">{username}</p>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="text-white/70 w-auto mt-2 bg-black">
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem onClick={() => setLocation("/profile")}>
|
||||
Profile
|
||||
</DropdownMenuItem>
|
||||
|
|
|
|||
21
src/main.tsx
21
src/main.tsx
|
|
@ -12,6 +12,7 @@ import Home from "./views/home/Home.tsx";
|
|||
import Settings from "./views/settings/Settings.tsx";
|
||||
import MatchHistory from "./views/match-history/MatchHistory.tsx";
|
||||
import Collection from "./views/collection/Collection.tsx";
|
||||
import { AnimatePresence } from "framer-motion";
|
||||
|
||||
const setup = async () => {
|
||||
const token = localStorage.getItem("loginToken");
|
||||
|
|
@ -32,15 +33,17 @@ setup().then(() => {
|
|||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Shell>
|
||||
<Switch>
|
||||
<Route path="/" component={Home} />
|
||||
<Route path="/play/:gameId?">
|
||||
{(params) => <Endless gameId={params.gameId} />}
|
||||
</Route>
|
||||
<Route path="/history" component={MatchHistory} />
|
||||
<Route path="/settings" component={Settings} />
|
||||
<Route path="/collection" component={Collection} />
|
||||
</Switch>
|
||||
<AnimatePresence mode="wait">
|
||||
<Switch>
|
||||
<Route path="/" component={Home} />
|
||||
<Route path="/play/:gameId?">
|
||||
{(params) => <Endless gameId={params.gameId} />}
|
||||
</Route>
|
||||
<Route path="/history" component={MatchHistory} />
|
||||
<Route path="/settings" component={Settings} />
|
||||
<Route path="/collection" component={Collection} />
|
||||
</Switch>
|
||||
</AnimatePresence>
|
||||
</Shell>
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</QueryClientProvider>
|
||||
|
|
|
|||
|
|
@ -1,14 +1,16 @@
|
|||
import { Theme } from "../Theme";
|
||||
|
||||
export const dwarfFortressTheme: Theme = {
|
||||
export const crimson: Theme = {
|
||||
size: 32,
|
||||
mine: () => import("../../assets/themes/color-palettes/crimson/mine.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"),
|
||||
questionMark: () =>
|
||||
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"),
|
||||
2: () => import("../../assets/themes/color-palettes/crimson/2.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 { circuitTheme } from "./circuit";
|
||||
import { circuitBinaryTheme } from "./circuit-binary";
|
||||
import { dwarfFortressTheme } from "./color-palettes/crimson";
|
||||
import { crimson } from "./color-palettes/crimson";
|
||||
import { nauticalTheme } from "./color-palettes/nautical";
|
||||
import { shadowWarriorTheme } from "./color-palettes/shadow-warrior";
|
||||
import { upInSmokeTheme } from "./color-palettes/up-in-smoke";
|
||||
|
|
@ -48,7 +48,7 @@ interface ThemeEntry {
|
|||
theme: Theme;
|
||||
}
|
||||
|
||||
export const themes: ThemeEntry[] = [
|
||||
export const themes = [
|
||||
{
|
||||
name: "Default",
|
||||
tags: ["Simple"],
|
||||
|
|
@ -220,7 +220,7 @@ export const themes: ThemeEntry[] = [
|
|||
{
|
||||
name: "Circuit Binary",
|
||||
tags: ["No Numbers"],
|
||||
id: "circuit-biinary",
|
||||
id: "circuit-binary",
|
||||
theme: circuitBinaryTheme,
|
||||
},
|
||||
{
|
||||
|
|
@ -287,6 +287,6 @@ export const themes: ThemeEntry[] = [
|
|||
name: "Crimson",
|
||||
tags: [],
|
||||
id: "crimson",
|
||||
theme: dwarfFortressTheme,
|
||||
theme: crimson,
|
||||
},
|
||||
];
|
||||
] as const satisfies ThemeEntry[];
|
||||
|
|
|
|||
|
|
@ -1,26 +1,87 @@
|
|||
import { Ellipsis } from "lucide-react";
|
||||
import { testBoard } from "../../../shared/testBoard";
|
||||
import Board from "../../components/Board";
|
||||
import { Button } from "../../components/Button";
|
||||
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 { data: collection, refetch } = useWSQuery(
|
||||
"user.getOwnCollection",
|
||||
null,
|
||||
);
|
||||
const mutateSelected = useWSMutation("user.selectCollectionEntry");
|
||||
const mutateShuffle = useWSMutation("user.addCollectionEntryToShuffle");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<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">
|
||||
{themes.map((theme) => (
|
||||
<div key={theme.id}>
|
||||
<h3 className="text-white/90 text-lg">{theme.name}</h3>
|
||||
<Board
|
||||
game={testBoard}
|
||||
theme={theme.theme}
|
||||
onLeftClick={() => {}}
|
||||
restartGame={() => {}}
|
||||
onRightClick={() => {}}
|
||||
width={11 * 32}
|
||||
height={4 * 32}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex flex-row gap-y-6 gap-x-8 items-center w-full flex-wrap justify-center mb-10">
|
||||
{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 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
|
||||
game={testBoard}
|
||||
theme={theme.theme}
|
||||
onLeftClick={() => {}}
|
||||
restartGame={() => {}}
|
||||
onRightClick={() => {}}
|
||||
width={11 * 32}
|
||||
height={4 * 32}
|
||||
className={cn(
|
||||
selected && "outline-primary outline-4 rounded-md",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -21,6 +21,11 @@ declare module "*&as=metadata" {
|
|||
export default outputs;
|
||||
}
|
||||
|
||||
declare module "*&inline" {
|
||||
const outputs: string;
|
||||
export default outputs;
|
||||
}
|
||||
|
||||
declare module "*?as=metadata" {
|
||||
const outputs: OutputMetadata[];
|
||||
export default outputs;
|
||||
|
|
|
|||
|
|
@ -7,10 +7,12 @@ const connectionString = import.meta.env.DEV
|
|||
: "wss://mbv2.gordon.business/ws";
|
||||
|
||||
const messageListeners = new Set<(event: MessageEvent) => void>();
|
||||
const addMessageListener = (listener: (event: MessageEvent) => void) => {
|
||||
export const addMessageListener = (listener: (event: MessageEvent) => void) => {
|
||||
messageListeners.add(listener);
|
||||
};
|
||||
const removeMessageListener = (listener: (event: MessageEvent) => void) => {
|
||||
export const removeMessageListener = (
|
||||
listener: (event: MessageEvent) => void,
|
||||
) => {
|
||||
messageListeners.delete(listener);
|
||||
};
|
||||
|
||||
|
|
@ -33,6 +35,11 @@ const createWSClient = () => {
|
|||
queryKey: ["scoreboard.getScoreBoard", 10],
|
||||
});
|
||||
}
|
||||
if (data.type === "gemsRewarded") {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["user.getOwnGems", null],
|
||||
});
|
||||
}
|
||||
if (import.meta.env.DEV) {
|
||||
console.log("Received message", data);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue