updated themes, added gem gain, added feed

This commit is contained in:
MasterGordon 2024-10-13 21:23:31 +02:00
parent d9ff1a9ffc
commit ebbc8d0f29
34 changed files with 1004 additions and 51 deletions

View File

@ -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

View File

@ -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);
}
},
),

View File

@ -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);
},
),
});

View File

@ -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
);

View File

@ -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": {}
}
}

View File

@ -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
}
]
}

View File

@ -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));
},
};

View File

@ -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));
};

View File

@ -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;
};

View File

@ -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));
};

View File

@ -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;

BIN
bun.lockb

Binary file not shown.

9
drizzle.config.js Normal file
View File

@ -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",
},
});

View File

@ -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",

View File

@ -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;
};

View File

@ -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[];
}

197
shared/lootboxes.ts Normal file
View File

@ -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",
},
],
};

33
shared/utils.ts Normal file
View File

@ -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;
};

View File

@ -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 />

BIN
src/assets/gem.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 582 KiB

View File

@ -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[]>([]);

View File

@ -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,

View File

@ -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}

View File

@ -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;

View File

@ -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;

View File

@ -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;

17
src/components/Gems.tsx Normal file
View File

@ -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;

View File

@ -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>

View File

@ -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>

View File

@ -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"),

View File

@ -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[];

View File

@ -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>
);

5
src/vite-env.d.ts vendored
View File

@ -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;

View File

@ -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);
}