diff --git a/README.md b/README.md index 3ef6b22..4bff9bd 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/backend/controller/gameController.ts b/backend/controller/gameController.ts index e8e6714..8e073e3 100644 --- a/backend/controller/gameController.ts +++ b/backend/controller/gameController.ts @@ -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); } }, ), diff --git a/backend/controller/userController.ts b/backend/controller/userController.ts index 639c790..6bee4bd 100644 --- a/backend/controller/userController.ts +++ b/backend/controller/userController.ts @@ -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); + }, + ), }); diff --git a/backend/drizzle/0001_breezy_martin_li.sql b/backend/drizzle/0001_breezy_martin_li.sql new file mode 100644 index 0000000..300e988 --- /dev/null +++ b/backend/drizzle/0001_breezy_martin_li.sql @@ -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 +); diff --git a/backend/drizzle/meta/0001_snapshot.json b/backend/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..aff49f1 --- /dev/null +++ b/backend/drizzle/meta/0001_snapshot.json @@ -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": {} + } +} \ No newline at end of file diff --git a/backend/drizzle/meta/_journal.json b/backend/drizzle/meta/_journal.json index 31c0a58..a2ce872 100644 --- a/backend/drizzle/meta/_journal.json +++ b/backend/drizzle/meta/_journal.json @@ -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 } ] } \ No newline at end of file diff --git a/backend/entities/game.ts b/backend/entities/game.ts index d0850f6..e59386f 100644 --- a/backend/entities/game.ts +++ b/backend/entities/game.ts @@ -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)); + }, }; diff --git a/backend/events.ts b/backend/events.ts index 68b0ac6..cba963e 100644 --- a/backend/events.ts +++ b/backend/events.ts @@ -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) => { + ws.send(JSON.stringify(event)); +}; diff --git a/backend/repositories/collectionRepository.ts b/backend/repositories/collectionRepository.ts new file mode 100644 index 0000000..bc3bd5c --- /dev/null +++ b/backend/repositories/collectionRepository.ts @@ -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 => { + 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; +}; diff --git a/backend/repositories/gemsRepository.ts b/backend/repositories/gemsRepository.ts new file mode 100644 index 0000000..d4c254c --- /dev/null +++ b/backend/repositories/gemsRepository.ts @@ -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)); +}; diff --git a/backend/schema.ts b/backend/schema.ts index 2a84e79..ee89562 100644 --- a/backend/schema.ts +++ b/backend/schema.ts @@ -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 & { 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; diff --git a/bun.lockb b/bun.lockb index 0a3d743..352fdc6 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/drizzle.config.js b/drizzle.config.js new file mode 100644 index 0000000..ed626f7 --- /dev/null +++ b/drizzle.config.js @@ -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", + }, +}); diff --git a/package.json b/package.json index 49b3790..65d4d20 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/shared/events.ts b/shared/events.ts index 4042ef1..fe7dda5 100644 --- a/shared/events.ts +++ b/shared/events.ts @@ -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; }; diff --git a/shared/gameType.ts b/shared/gameType.ts index e92e27c..b7266ba 100644 --- a/shared/gameType.ts +++ b/shared/gameType.ts @@ -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; export type ServerGame = z.infer; + +export interface UserCollectionEntry { + id: string; + aquired: number; + selected: boolean; +} + +export interface UserCollection { + entries: UserCollectionEntry[]; +} diff --git a/shared/lootboxes.ts b/shared/lootboxes.ts new file mode 100644 index 0000000..cddfe4a --- /dev/null +++ b/shared/lootboxes.ts @@ -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", + }, + ], +}; diff --git a/shared/utils.ts b/shared/utils.ts new file mode 100644 index 0000000..794778b --- /dev/null +++ b/shared/utils.ts @@ -0,0 +1,33 @@ +export const pickRandom = (arr: T[]) => { + const index = Math.floor(Math.random() * arr.length); + return arr[index]; +}; + +interface WeightedEntry { + 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 = ( + arr: WeightedEntry[], + 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; +}; diff --git a/src/Shell.tsx b/src/Shell.tsx index c72aa08..39feadf 100644 --- a/src/Shell.tsx +++ b/src/Shell.tsx @@ -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 = ({ children }) => { History + + + Collection NEW + - Settings NEW + Settings
+ +
diff --git a/src/assets/gem.png b/src/assets/gem.png new file mode 100644 index 0000000..659e7de Binary files /dev/null and b/src/assets/gem.png differ diff --git a/src/atoms.ts b/src/atoms.ts index 1055cef..fcc2526 100644 --- a/src/atoms.ts +++ b/src/atoms.ts @@ -1,5 +1,6 @@ import { atom } from "jotai"; import { atomWithStorage } from "jotai/utils"; +import { FeedItem } from "./components/Feed/FeedItem"; export const gameIdAtom = atom(undefined); export const loginTokenAtom = atomWithStorage( @@ -8,3 +9,4 @@ export const loginTokenAtom = atomWithStorage( ); export const cursorXAtom = atom(0); export const cursorYAtom = atom(0); +export const feedItemsAtom = atom([]); diff --git a/src/components/Board.tsx b/src/components/Board.tsx index 553ff07..6653ecc 100644 --- a/src/components/Board.tsx +++ b/src/components/Board.tsx @@ -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 = (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, diff --git a/src/components/DropdownMenu.tsx b/src/components/DropdownMenu.tsx index 3aeb137..6f6adbd 100644 --- a/src/components/DropdownMenu.tsx +++ b/src/components/DropdownMenu.tsx @@ -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} diff --git a/src/components/Feed/Feed.tsx b/src/components/Feed/Feed.tsx new file mode 100644 index 0000000..f151c08 --- /dev/null +++ b/src/components/Feed/Feed.tsx @@ -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 ( +
+
+ + + {items.map((item) => ( + + ))} + + +
+
+ ); +}; + +export default Feed; diff --git a/src/components/Feed/FeedItem.tsx b/src/components/Feed/FeedItem.tsx new file mode 100644 index 0000000..a97a996 --- /dev/null +++ b/src/components/Feed/FeedItem.tsx @@ -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 = ({ children }) => { + return ( + + {children} + + ); +}; + +const FeedItemElement: React.FC<{ item: FeedItem }> = ({ item }) => { + switch (item.type) { + case "gameStarted": + return {item.user} started a game; + case "gameFinished": + return ( + + {item.user} finished in{" "} + stage {item.stage} after{" "} + {formatTimeSpan(item.time)} + + ); + case "gemsEarned": + return ( + + You got {item.gems} for stage {item.stage} + + ); + } +}; + +export default FeedItemElement; diff --git a/src/components/GemIcon.tsx b/src/components/GemIcon.tsx new file mode 100644 index 0000000..a3bb923 --- /dev/null +++ b/src/components/GemIcon.tsx @@ -0,0 +1,7 @@ +import gem from "../assets/gem.png?w=20&h=20&inline"; + +const GemsIcon = () => { + return ; +}; + +export default GemsIcon; diff --git a/src/components/Gems.tsx b/src/components/Gems.tsx new file mode 100644 index 0000000..449caff --- /dev/null +++ b/src/components/Gems.tsx @@ -0,0 +1,17 @@ +import { Tag } from "./Tag"; +import GemsIcon from "./GemIcon"; + +interface GemsProps { + count: number; +} + +const Gems: React.FC = ({ count }) => { + return ( + + {count} + + + ); +}; + +export default Gems; diff --git a/src/components/Header.tsx b/src/components/Header.tsx index e4fd70c..d706469 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -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 (
@@ -31,13 +33,14 @@ const Header = () => { {username ? ( + {typeof gems?.count === "number" && } - + setLocation("/profile")}> Profile diff --git a/src/main.tsx b/src/main.tsx index 9a15b60..3dc3760 100644 --- a/src/main.tsx +++ b/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(() => { - - - - {(params) => } - - - - - + + + + + {(params) => } + + + + + + diff --git a/src/themes/color-palettes/crimson.ts b/src/themes/color-palettes/crimson.ts index 133953d..9383914 100644 --- a/src/themes/color-palettes/crimson.ts +++ b/src/themes/color-palettes/crimson.ts @@ -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"), diff --git a/src/themes/index.ts b/src/themes/index.ts index 7a5fd71..cc9725f 100644 --- a/src/themes/index.ts +++ b/src/themes/index.ts @@ -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[]; diff --git a/src/views/collection/Collection.tsx b/src/views/collection/Collection.tsx index 0eec390..aad679b 100644 --- a/src/views/collection/Collection.tsx +++ b/src/views/collection/Collection.tsx @@ -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 (

Collection

-
- {themes.map((theme) => ( -
-

{theme.name}

- {}} - restartGame={() => {}} - onRightClick={() => {}} - width={11 * 32} - height={4 * 32} - /> -
- ))} +
+ {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 ( +
+
+

+ {theme.name} + {owned && ( + (Owned) + )} +

+ + + + + + { + mutateSelected + .mutateAsync({ id: theme.id }) + .then(() => refetch()); + }} + > + Select + + + mutateShuffle.mutateAsync({ id: theme.id }) + } + > + {" "} + Add to shuffle + + + +
+ {}} + restartGame={() => {}} + onRightClick={() => {}} + width={11 * 32} + height={4 * 32} + className={cn( + selected && "outline-primary outline-4 rounded-md", + )} + /> +
+ ); + })}
); diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 6a17aa3..6f1e8a5 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -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; diff --git a/src/wsClient.ts b/src/wsClient.ts index 08b72ce..bde07e7 100644 --- a/src/wsClient.ts +++ b/src/wsClient.ts @@ -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); }