From 825448c8f300c0bc182710110c29963f0989ab21 Mon Sep 17 00:00:00 2001 From: MasterGordon Date: Sat, 28 Sep 2024 03:17:36 +0200 Subject: [PATCH] added reveal and create game --- backend/controller/gameController.ts | 20 ++- backend/controller/userController.ts | 10 +- backend/entities/game.ts | 72 ++++++++ backend/events.ts | 26 +-- backend/index.ts | 4 + backend/router.ts | 17 +- bun.lockb | Bin 142813 -> 171201 bytes package.json | 7 +- shared/events.ts | 22 +++ shared/game.ts | 8 + sqlite.db | Bin 28672 -> 45056 bytes src/Shell.tsx | 35 +++- src/assets/themes/default/1.png | Bin 0 -> 195 bytes src/assets/themes/default/2.png | Bin 0 -> 247 bytes src/assets/themes/default/3.png | Bin 0 -> 281 bytes src/assets/themes/default/4.png | Bin 0 -> 208 bytes src/assets/themes/default/5.png | Bin 0 -> 228 bytes src/assets/themes/default/6.png | Bin 0 -> 247 bytes src/assets/themes/default/7.png | Bin 0 -> 220 bytes src/assets/themes/default/8.png | Bin 0 -> 244 bytes src/assets/themes/default/flag.png | Bin 0 -> 302 bytes src/assets/themes/default/last-pos.png | Bin 0 -> 264 bytes src/assets/themes/default/mine.png | Bin 0 -> 458 bytes src/assets/themes/default/question-mark.png | Bin 0 -> 286 bytes src/assets/themes/default/revealed.png | Bin 0 -> 146 bytes src/assets/themes/default/tile.png | Bin 0 -> 288 bytes src/assets/themes/devart/flag.png | Bin 0 -> 157 bytes src/assets/themes/devart/last-pos.png | Bin 0 -> 119 bytes src/assets/themes/devart/mine.png | Bin 0 -> 158 bytes src/assets/themes/devart/question-mark.png | Bin 0 -> 136 bytes src/assets/themes/devart/revealed.png | Bin 0 -> 119 bytes src/assets/themes/devart/tile.png | Bin 0 -> 129 bytes src/atoms.ts | 4 +- src/components/Auth/LoginButton.tsx | 34 +++- src/components/Auth/PasswordInput.tsx | 32 ++++ src/components/Auth/RegisterButton.tsx | 34 +++- src/components/Board.tsx | 185 ++++++++++++++++++++ src/components/Dialog.tsx | 2 +- src/components/Header.tsx | 20 ++- src/components/pixi/PixiViewport.tsx | 73 ++++++++ src/hooks.ts | 2 + src/main.tsx | 58 +++--- src/queryClient.ts | 9 + src/themes/Theme.ts | 46 +++++ src/themes/default.ts | 19 ++ src/views/endless/Endless.tsx | 48 +++++ src/wsClient.ts | 11 +- 47 files changed, 708 insertions(+), 90 deletions(-) create mode 100644 shared/events.ts create mode 100644 src/assets/themes/default/1.png create mode 100644 src/assets/themes/default/2.png create mode 100644 src/assets/themes/default/3.png create mode 100644 src/assets/themes/default/4.png create mode 100644 src/assets/themes/default/5.png create mode 100644 src/assets/themes/default/6.png create mode 100644 src/assets/themes/default/7.png create mode 100644 src/assets/themes/default/8.png create mode 100644 src/assets/themes/default/flag.png create mode 100644 src/assets/themes/default/last-pos.png create mode 100644 src/assets/themes/default/mine.png create mode 100644 src/assets/themes/default/question-mark.png create mode 100644 src/assets/themes/default/revealed.png create mode 100644 src/assets/themes/default/tile.png create mode 100644 src/assets/themes/devart/flag.png create mode 100644 src/assets/themes/devart/last-pos.png create mode 100644 src/assets/themes/devart/mine.png create mode 100644 src/assets/themes/devart/question-mark.png create mode 100644 src/assets/themes/devart/revealed.png create mode 100644 src/assets/themes/devart/tile.png create mode 100644 src/components/Auth/PasswordInput.tsx create mode 100644 src/components/Board.tsx create mode 100644 src/components/pixi/PixiViewport.tsx create mode 100644 src/queryClient.ts create mode 100644 src/themes/Theme.ts create mode 100644 src/themes/default.ts create mode 100644 src/views/endless/Endless.tsx diff --git a/backend/controller/gameController.ts b/backend/controller/gameController.ts index e12b83b..edf0574 100644 --- a/backend/controller/gameController.ts +++ b/backend/controller/gameController.ts @@ -1,6 +1,10 @@ import { z } from "zod"; import { createController, createEndpoint } from "./controller"; -import { getGame, upsertGameState } from "../repositories/gameRepository"; +import { + getCurrentGame, + getGame, + upsertGameState, +} from "../repositories/gameRepository"; import { serverGame, serverToClientGame, @@ -42,4 +46,18 @@ export const gameController = createController({ }); return newGame; }), + reveal: createEndpoint( + z.object({ x: z.number(), y: z.number() }), + async ({ x, y }, { db, user }) => { + if (!user) throw new UnauthorizedError("Unauthorized"); + const dbGame = await getCurrentGame(db, user); + const serverGame = JSON.parse(dbGame.gameState); + game.reveal(serverGame, x, y); + upsertGameState(db, serverGame); + emit({ + type: "updateGame", + game: dbGame.uuid, + }); + }, + ), }); diff --git a/backend/controller/userController.ts b/backend/controller/userController.ts index 433df26..5adbddc 100644 --- a/backend/controller/userController.ts +++ b/backend/controller/userController.ts @@ -12,7 +12,7 @@ const signString = (payload: string) => { export const userController = createController({ getSelf: createEndpoint(z.null(), async (_, { user }) => { - return user; + return user || null; }), login: createEndpoint( z.object({ username: z.string(), password: z.string() }), @@ -47,7 +47,13 @@ export const userController = createController({ resetSessionUser(ws); }), register: createEndpoint( - z.object({ username: z.string().max(15), password: z.string().min(6) }), + z.object({ + username: z + .string() + .min(3, "Username must be at least 3 characters") + .max(15, "Username cannot be longer than 15 characters"), + password: z.string().min(6, "Password must be at least 6 characters"), + }), async (input, { db, ws }) => { await registerUser(db, input.username, input.password); const user = input.username; diff --git a/backend/entities/game.ts b/backend/entities/game.ts index 783cfb7..9022755 100644 --- a/backend/entities/game.ts +++ b/backend/entities/game.ts @@ -1,3 +1,4 @@ +import { getValue } from "../../shared/game"; import type { ServerGame } from "../../shared/game"; interface CreateGameOptions { @@ -8,6 +9,26 @@ interface CreateGameOptions { mines: number; } +const isValid = (game: ServerGame, x: number, y: number) => { + const { width, height } = game; + return x >= 0 && x < width && y >= 0 && y < height; +}; + +const getNeighborFlagCount = (game: ServerGame, x: number, y: number) => { + const { isFlagged } = game; + const neighbors = [ + isFlagged[x - 1]?.[y - 1], + isFlagged[x]?.[y - 1], + isFlagged[x + 1]?.[y - 1], + isFlagged[x - 1]?.[y], + isFlagged[x + 1]?.[y], + isFlagged[x - 1]?.[y + 1], + isFlagged[x]?.[y + 1], + isFlagged[x + 1]?.[y + 1], + ]; + return neighbors.filter((n) => n).length; +}; + export const game = { createGame: (options: CreateGameOptions): ServerGame => { const { uuid, user, width, height, mines } = options; @@ -24,6 +45,9 @@ export const game = { const isFlaggedArray = Array.from({ length: width }, () => new Array(height).fill(false), ); + const isQuestionMarkArray = Array.from({ length: width }, () => + new Array(height).fill(false), + ); let remainingMines = mines; while (remainingMines > 0) { @@ -45,9 +69,57 @@ export const game = { mines: minesArray, isRevealed: isRevealedArray, isFlagged: isFlaggedArray, + isQuestionMark: isQuestionMarkArray, stage: 1, lastClick: [-1, -1], minesCount: mines, }; }, + reveal: (serverGame: ServerGame, x: number, y: number) => { + const { mines, isRevealed, isFlagged, isQuestionMark, finished } = + serverGame; + if (finished) return; + if (isQuestionMark[x][y]) return; + if (isFlagged[x][y]) return; + if (!isValid(serverGame, x, y)) return; + serverGame.lastClick = [x, y]; + + if (mines[x][y]) { + serverGame.finished = Date.now(); + return; + } + + const value = getValue(serverGame.mines, x, y); + const neighborFlagCount = getNeighborFlagCount(serverGame, x, y); + + if (isRevealed[x][y] && value === neighborFlagCount) { + if (!isFlagged[x - 1]?.[y]) game.reveal(serverGame, x - 1, y); + if (!isFlagged[x - 1]?.[y - 1]) game.reveal(serverGame, x - 1, y - 1); + if (!isFlagged[x - 1]?.[y + 1]) game.reveal(serverGame, x - 1, y + 1); + if (!isFlagged[x]?.[y - 1]) game.reveal(serverGame, x, y - 1); + if (!isFlagged[x]?.[y + 1]) game.reveal(serverGame, x, y + 1); + if (!isFlagged[x + 1]?.[y - 1]) game.reveal(serverGame, x + 1, y - 1); + if (!isFlagged[x + 1]?.[y]) game.reveal(serverGame, x + 1, y); + if (!isFlagged[x + 1]?.[y + 1]) game.reveal(serverGame, x + 1, y + 1); + } + + serverGame.isRevealed[x][y] = true; + + if (value === 0 && neighborFlagCount === 0) { + const revealNeighbors = (nx: number, ny: number) => { + if (isValid(serverGame, nx, ny) && !isRevealed[nx]?.[ny]) { + game.reveal(serverGame, nx, ny); + } + }; + + revealNeighbors(x - 1, y - 1); + revealNeighbors(x, y - 1); + revealNeighbors(x + 1, y - 1); + revealNeighbors(x - 1, y); + revealNeighbors(x + 1, y); + revealNeighbors(x - 1, y + 1); + revealNeighbors(x, y + 1); + revealNeighbors(x + 1, y + 1); + } + }, }; diff --git a/backend/events.ts b/backend/events.ts index a64480d..68b0ac6 100644 --- a/backend/events.ts +++ b/backend/events.ts @@ -1,28 +1,4 @@ -import type { ClientGame } from "../shared/game"; - -export type EventType = "new" | "finished" | "updateGame" | "updateStage"; - -type Events = - | { - type: "new"; - user: string; - } - | { - type: "loss"; - user: string; - stage: number; - } - | { - type: "updateGame"; - game: string; - data: ClientGame; - } - | { - type: "updateStage"; - game: string; - stage: number; - started: number; - }; +import type { Events } from "../shared/events"; const listeners = new Set<(event: Events) => void>(); diff --git a/backend/index.ts b/backend/index.ts index 9560b57..1cc5ba1 100644 --- a/backend/index.ts +++ b/backend/index.ts @@ -1,3 +1,4 @@ +import { on } from "./events"; import { handleRequest } from "./router"; const allowCors = { @@ -36,5 +37,8 @@ const server = Bun.serve({ }, port: 8076, }); +on((event) => { + server.publish("minesweeper-global", JSON.stringify(event)); +}); console.log("Listening on port 8076"); diff --git a/backend/router.ts b/backend/router.ts index 1d0524a..73b2323 100644 --- a/backend/router.ts +++ b/backend/router.ts @@ -4,6 +4,7 @@ import type { Controller, Endpoint } from "./controller/controller"; import { gameController } from "./controller/gameController"; import { db } from "./database/db"; import { userController } from "./controller/userController"; +import { ZodError } from "zod"; const controllers = { game: gameController, @@ -24,8 +25,7 @@ export const handleRequest = async ( message: unknown, ws: ServerWebSocket, ) => { - // TODO: Remove this - const sessionUser = userName.get(ws) || "Gordon"; + const sessionUser = userName.get(ws) || undefined; const ctx = { user: sessionUser, db, @@ -50,8 +50,17 @@ export const handleRequest = async ( const result = await endpoint.handler(input, ctx); ws.send(JSON.stringify({ id, payload: result })); return; - } catch (_) { - ws.send(JSON.stringify({ id, error: "Bad Request" })); + } catch (e) { + if (e instanceof ZodError) { + ws.send( + JSON.stringify({ id, error: e.issues[0].message, type: message.type }), + ); + } else if (e instanceof Error) { + ws.send(JSON.stringify({ id, error: e.message, type: message.type })); + } else { + ws.send(JSON.stringify({ id, error: "Bad Request", type: message.type })); + } + console.error(e); } }; diff --git a/bun.lockb b/bun.lockb index bc27cb34d91f7b3b464662bbfacdb662227d8d90..69c5b87991afbf5c605bfc3ec33a6627ba239d23 100755 GIT binary patch delta 44118 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 export type ServerGame = z.infer; +export const isServerGame = (game: ServerGame | ClientGame) => "mines" in game; +export const isClientGame = ( + game: ServerGame | ClientGame, +): game is ClientGame => !("mines" in game); + export const getValue = (mines: boolean[][], x: number, y: number) => { const neighbors = [ mines[x - 1]?.[y - 1], @@ -54,6 +61,7 @@ export const serverToClientGame = (game: ServerGame): ClientGame => { height: game.height, isRevealed: game.isRevealed, isFlagged: game.isFlagged, + isQuestionMark: game.isQuestionMark, minesCount: game.minesCount, values: game.mines.map((_, i) => game.mines[0].map((_, j) => { diff --git a/sqlite.db b/sqlite.db index 06eaec2d84faeda9c139b306981c5497622028fa..3668c433560fe116e7b399950706344ab99f09c8 100644 GIT binary patch literal 45056 zcmeI5ZEPGz8OQJ9_m}gfO&~;2^>VRV2X4{M%+9_91=WquZK&g>PE09LsRz%u(Y*=**%LLrmMOvC>%_#b@w zV5L9!0)Mli^-inPna^K&E|>o>GdO-DlkdyFntO5Lo$(vF$2(=fbW{KpKm||%Q~(t~ z1yBK002M$5c0+;HfuTc(4rQ-tHPc#kx2o=D)n5#rTqsYUD;Lg9-#=F_T=df}7N$l= zF0Pwv?!`jQeX>?KvvBJ4^ul9>hsuu?<{v&+m|vWmD}l)+vs%5nvFU7^1)D~X9UB}x zbmCApQ(1T1C##oNq1&}&Zq+vY?@N9mOT_;(^v(PF{F*(xFIJJn3=J5noAVX>35HM15RSWsiJty(jeA-%F*bC=!Cw&V0F>y_$?3xx(-XUenF zi*x4+a+_VP0+p|sYnNJvp%P{vUMQb>VBS}8%2)d0vBE-mw!BcDKUqE-h(Fa({#fDR z`NHCv8F6yfHwva*t8A=`w3+f;Ih+P+_wS^(FWeIG{|sC^mK{38dG^|tulCZCvsroS zsa1Drt+KpniXy5>Y5UsFMKQ^wD4>B)#VMREFPxg53sVF7!e_;-t~7uOsRHTtrVFRU zS%Oaa`qrAeS+Rxru?O}I9y-rEL6lU7C6bgX`trl#H zDu4>00;m8gfC``jr~oRkcNBOiJCM1S8&C|*Qm927+3{qPIu@r)RW+{avg5i2F_~w` z#CBAh*s9Bkta8&Js%P0M*O;kiUl@Dy2a}_4T-iL_o4>Jl4iIaC3ZMe004jhApaQ4> zDu4>00;m8gfC``jH&21SzRbt~?5;2WzS!wl{$Kg)uzH{P()m+Y2Zt9Ae|uzPc)%oT zeM)?Ley#D1*v#XSY|5IXXoe!0u1zJA5J%N*hf=OS08hqjtbe^I9#<(IFTxJGW+|46 z;>i`TZe)hZ+0 zt+MV`i^tEO_srF*TZ%0&l)7%IZEm$?ge=jd#(pncC>1N!1^0?;u8R7dZ`k?9%|(#A znNSjIk!mzIc1-t+nq4)Qmm4L;4s5$h-48F8Gg4+~d*c8f*@EYL;31jQ=H?S0Pub9} zyH*zt*nc<)PJ5}iYF29}S1YzCpOj?S=AY-28(Zse+Ue8T7u36d{8(Kb2*3qOh@%6b zmTb9_;+a4xXG|it?kSFJD3+&3P)(DiS5!5H!DshSZ3ybq17ECuK)8s^wnPGAVHrj?80XI#S zUcps#jT?No!ENCS;I?3Gkhslb4BV!#^c`v7O;e>N{YFB+gND09CksxNmS_^IhnHWj zt9^l!wPa0Ywqr=#vn@$+U0c$1@U)5zF4hEKbw`V^nkGxnta3{9U1zn0CpuZtwAzzx zgN{!2Q@@AS!M*@o&r@wvXGGF%LpWK*kt}GYO0LZqG%YFNJPK}_EWLrN>9V4!yld;A z1zww9?0z+lW{hwfb9~{3Y-nSTrN(l0D;E7N_}fn4@;8pY^z0Y10Pb@Kpmo5Zt3@>K zNK9A3;}VlghOB84;g04I!Yt^5MLcerEIon?9Y3y;kL__2_>3lPw*fk{9%~)M@awvZ z&O7hC#V__&fScSUYNH1__M`h@F(Rh+4-bm_{|Cq4&WvBry_Nf3{=&p}CO(mWXJR>b zdE)02U(74Hzvjns59Ys>yLC$A-HF%I2t8SW~{ zFpQ)r+~GFV1fIseLl|;Lho`f-4fjV)=%-tnEmE7zzP& zt|)NSRE48fOiAY&l_({$?ddY5%*+Wxro)gIqq^iNx(tRMl?;^`lI3{JVV>fuo--~C zb>cBxL2Cig62xblcF)HKdq#WN&gnsA1hY)J+I>2=Su zDW@J~lno0*$8!wo!YH(odDQtbpST{=M~*RPzSD_?ok zrqlCJo#dA)Tk|Vt+%Io@^~}aXW!AYW&zxTP-1_B*c=?H|lU48W`|q!~SI<;twYixq zr|Icak512Ac4vsSxL9*%tMV*;{E?NlIrCIw{{Qt%{`zgHFdh^tfC``jr~oQ}3ZMe0 z04jhApaQ4>D)4`-z)0WWk;bTlxc@&0Q~q;rj*SeT8TjG8zjT}b-!2=@|KFv#so|L_ z{=65lh)kAhrQfm{CzUGB|8Is2=xw5uuHgKC5h3GNOqAMl*tX5l3J0#h`Tq^S=li;_ zQ}owAUW}Ed|7^qxXzXzhPV3uqwBr2##7l7!KrL?Ltj2J@y=J5CIcz&RS)Bh55gWQV zKAis_4t20wztj{nxI?W2oc}LEjUc!|#FZZD`RR*!(f=RVr)P57=(mU7>c0W2_(27_ zDo~#u6X9|Wj0_BVnA|P(cxtn>Ee^M8fZ7cr^j~I za|Dvu9=1cD`RT2VT}GX-PYqFXSSc~0UNQSO2nc!PAFtGBMg_x%f#EcB(;^I~$&!j; zMI#1;c>>=Zj$;Z8bA{ccIHqmfbnA7rjJJOfJu@6Dp);*J2lhjZJ7aw0{qG8l-}`la zW+cEk&CJON#%Z#o!&rgnQZbAb!=Ml=GMy_<;---nW3qFbFW3^x62mxZ(e(cvH^eH~ z%+hHK4$KD;PD!#iKJso>VEma6>Yp73jGw;@am>=p>y2QXCd-Z(Cq$AqAeI`1^qnI~ zr-G$rv_o$tAUDQRFC#~ONv-0VfVR?`Yk^m8CN!d@BC=hOz59dx_1PiebMJztLz=ne z5m?h?*%8+5Q0Fk&-7p})d8Fx}7=-FNq~8Q^Lo6neE7(cg;AiXw+)zmkXq)Kvz6B8u z5SBZHL;v%aL{RiU{k_@$FOPm>==J^&vH#z->x%}UK>fsi;Q+@(-zh~jlZXRMlO=@% zW}#klX~0v_5!lgVcOW=aru zzXQ|lcG&D}e}##h+*3OGyEg>8znZF-Cj`5n26ofLeTuM~CQB-IRdBt~v5&J$IiuZt zZwR9hj!lq4X+1UrTRYyZ#-}J@hl-*PZu#KESMePE#WRBA*C*?*oLrbwmm~|g*GPwElhW-%ipb{{*MLQo1NR6lT$x~ z_U7b057x_f3XsLvRhqc!5s=elNd>YFan=*MN(5fI$GYC}50UthdZR6<13wwt`4q1cuRKkm~cYXk% oL0uG*MCaPg0VetI|FuJI9SHXSo3BG>$xVu*?|)Ko{NBs|1*=c_EC2ui delta 966 zcma)*!E4k&6o-?|Zj!BRXV=|ySw-9Kq4dzPnMpFq7>cqNQ4f16h?h2-Nk9}^gq{|$ z;);r}RK}}P1uyo}(vt_N6>pyGKVVOaH+vL>I#at)+Dndk^Ir13?>AZA%`NY$D`yL) zqA13Tu}iACF~2%JQj(AQwgP^E9q^&lJvC$Q>)RU-*Yv0t8s=={X|-q;e2=6_91|F) ztOMNu2QY9Q3L{KY%mRXw6kS=k#}@8(n@dZ#So2~t!wCQO{yg8ZL6K00V2T66$Dfb$ zPmfRY%l#5xe_i1F;RIiNa*ofTVZPY^&R1qGq}H8Re|w=mU<(|Zc+9t7!(2!) zOJGcKO5((J61Q`VEz1a>dp9*iDE2V%Y~u4b;dMbZdV;3*<;GesEDUInFT9{weBQ;v zl1F>>U$B6Sm67?F`6tRl16C49&(V6`D!RNP4M++!+e^L^7f+^U+Hg2JxT3z z`)e=E4}e^s+DSwRL|$x*=#m%)L5hW_I1Pk!Mo&bSVdO}3$oE8i^f0ow^Jl diff --git a/src/Shell.tsx b/src/Shell.tsx index 0fff75e..131bc8b 100644 --- a/src/Shell.tsx +++ b/src/Shell.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { PropsWithChildren, useEffect, useRef, useState } from "react"; import { Button } from "./components/Button"; import { motion } from "framer-motion"; import { @@ -17,8 +17,9 @@ import Header from "./components/Header"; const drawerWidth = 256; const drawerWidthWithPadding = drawerWidth; -const Shell: React.FC = () => { +const Shell: React.FC = ({ children }) => { const [isOpen, setIsOpen] = useState(false); + const drawerRef = useRef(null); const x = isOpen ? 0 : -drawerWidthWithPadding; const width = isOpen ? drawerWidthWithPadding : 0; @@ -26,11 +27,29 @@ const Shell: React.FC = () => { useEffect(() => { setIsOpen(!isMobile); }, [isMobile]); + useEffect(() => { + const onOutsideClick = (e: MouseEvent) => { + if ( + drawerRef.current && + !drawerRef.current.contains(e.target as Node) && + isMobile + ) { + setIsOpen(false); + e.stopPropagation(); + e.preventDefault(); + } + }; + document.addEventListener("click", onOutsideClick); + return () => { + document.removeEventListener("click", onOutsideClick); + }; + }); return ( -
+
@@ -74,17 +93,19 @@ const Shell: React.FC = () => {
- + - +
-
-
+ {children} + {/*
*/} + {/*
*/}
diff --git a/src/assets/themes/default/1.png b/src/assets/themes/default/1.png new file mode 100644 index 0000000000000000000000000000000000000000..2550fec26a44c2cc86546975794a9bacf006c7b0 GIT binary patch literal 195 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1|*BCs=fdz#^NA%Cx&(BWL^R}wVp1HArY-_ zrv-8~81OhRulWD_#nWB-2m4p~cJP@r{_R;?nWGb%JpPGiOT9IcoZA$}0)(tH)Sv z3ffuJ1R9%#qE}=D&U^nasEmEzW__h1&HJ)JvxV$qBYYV}^p06~Ikex%X1L>bk>@=_ z_=2smTV+4DT#w_;)A8|R7qVYf!){_>@OzJg&^%#<1CN@GsY`_2j literal 0 HcmV?d00001 diff --git a/src/assets/themes/default/4.png b/src/assets/themes/default/4.png new file mode 100644 index 0000000000000000000000000000000000000000..467624818f9cdb6979560cfae1507604cd7e0494 GIT binary patch literal 208 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1|*BCs=fdz#^NA%Cx&(BWL^R}U7jwEArY-_ zr<~?(P!MooPqx37=sqpM{Ys*1)f53v>y$$M&weUzCT*Y3z#-`K=urP}+jNHTtozId zZr0X6Wcj1W^6FKcxW><{{~?W*SB05W>#p*1{YVm+alLM_LudYPc9uVeA&(84zV%Do zYbpHJYD@<);T3K F0RR|EOvC^H literal 0 HcmV?d00001 diff --git a/src/assets/themes/default/5.png b/src/assets/themes/default/5.png new file mode 100644 index 0000000000000000000000000000000000000000..0f25d5b2b38c4093985f5c2166a763576658aee5 GIT binary patch literal 228 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1|*BCs=fdz#^NA%Cx&(BWL^R}^E_P~Ln2z= zPP@q4pupqe|Cq_|6j$s)Pf5o5zY1S;R9P;4_cSQK@r{9_>7>jVHAb)g4wI5|7avKj zYP=XTt^bn`v*ZEiZ+wT}3srQ@=Bd~`kFikBd+WR~N5$&Y2+kIhby6>8Rvu&dI$Nl6 z*6vNqp0bI$ym_O(;EKMQN86(@S>%UCQ9;>gTe~DWM4f6ros4 literal 0 HcmV?d00001 diff --git a/src/assets/themes/default/6.png b/src/assets/themes/default/6.png new file mode 100644 index 0000000000000000000000000000000000000000..601f9123e37ad19f9885272d6efcefb375ef6fa5 GIT binary patch literal 247 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1|*BCs=fdz#^NA%Cx&(BWL^R}8$DedLn2z= zPP@z7pup3zx{yEZwOY=h9G@nCJBjYSW`A4`PygX|t9c%~OZU+fvx?N7qcOH_TmRi# zYssOuK)HRF)2-Tb%&Z<~gAeUpoFMOb_?aDJZzR(jCaLtMJdJ#31=h~0J_eC{*O?nQ z_BYI#KcD6EYL055xU)+glJrEHR!(`(l9lar@~<`{yv+vicS;eWMiUcm_{bKbLh*2~7aWQDXl9 literal 0 HcmV?d00001 diff --git a/src/assets/themes/default/7.png b/src/assets/themes/default/7.png new file mode 100644 index 0000000000000000000000000000000000000000..d0660b7be9a4ab8d35f01210f9102363baf63bf5 GIT binary patch literal 220 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1|*BCs=fdz#^NA%Cx&(BWL^R}(>z@qLn2z= zPV?q#P~dTKf6Q6+-}oEXrb5S_hvvy$6Su2cyDYl%{!Yt;%F%HENvrLzB_<&ypWc?Ln2z= zPC3ZipuppLu_Ap>-hqIe0~J0E>iS-9^N%)6tk3cI&;53Nad7b5tr>Fd0$jh$j&Xc# zV>Edt#ldkPsn4l{VNT__pFyARtyVPSY%uoMjGZME_)x)OtysgXpRs!_9lU$_1ES6H z40SUb)6yc>Irk@Lv_6xrolhnNu}+s p`-Q(EtCO5vKJ@5LobzjW*`oKUceJO<0v*o4;OXk;vd$@?2>{Q5UD^Nu literal 0 HcmV?d00001 diff --git a/src/assets/themes/default/flag.png b/src/assets/themes/default/flag.png new file mode 100644 index 0000000000000000000000000000000000000000..f3958718bf32a6f49b56acf3d2bf444b3e145db3 GIT binary patch literal 302 zcmV+}0nz@6P)Px#=t)FDR9J=Wmcb2!AQXllqz40cZ|=bYPS6!hhv+P>;2zFmOnOoBPS8W6p|;is zN_!~Z2?BZl7a#yYB9S0c3jwtdZ^s%bZ6l>E%hLMRJyC)I04l9tLZ|l4dgsyw)`Ith zdI`h|CJ7w1%s~Q;lr~nV3tt;Jk0a}hfzFuU2mkIBvP?pY}r~M+5KE<(R3-<1;Qw(Iz zdazbm)qR+&z`)Yi_74bWqpwakOo|>a3!Mk2g=r8||^)blys^9_Ske MPgg&ebxsLQ0Gd#2tpET3 literal 0 HcmV?d00001 diff --git a/src/assets/themes/default/mine.png b/src/assets/themes/default/mine.png new file mode 100644 index 0000000000000000000000000000000000000000..a0de10e2e4d904cad1cf09cc6b9cbc671c4b326e GIT binary patch literal 458 zcmV;*0X6=KP)Px$YDq*vR9J=Wmc6coAP|Me8%StuC~15;U&9yi>1?#Ku_3|4Ta6iJ`5AWi<~E*W z6J>?-F$@C$j~;y>YwhKnyFMA?=UXs;0#KTvwe|u4@ZR6gT+X>G_aCaO0RR9PV_;p^ zj<)5r?|X;l-utMn8K7jSl#1TZ^DF|r4ZtCkQZMEdfP&P_MF41yrqo<(eLsf~k{~sR zrd@#MTI<_$#004JA$pUa%4j>f=K=FLYCPyg&CFTIK zM9k&@c90}eO43~*sslI>&a5QKH(~%91xX}YYXCrF9iR}YGR(h`s7ObTR8;CrpynK) z?|WF5r9B67=Q*12pZ7j_5+26^!!XDT5el~z! zdh`oat^^@|GDejE000hUSV?A0O#mtY000O800000007cclK=n!07*qoM6N<$f-W$% A#{d8T literal 0 HcmV?d00001 diff --git a/src/assets/themes/default/question-mark.png b/src/assets/themes/default/question-mark.png new file mode 100644 index 0000000000000000000000000000000000000000..ce1c637d72b49c9aed55fa3fe92f0ee1951b5a89 GIT binary patch literal 286 zcmV+(0pb3MP)Px#*hxe|R9J=WmO%={APhz05HFC0Tkn4?FW_2Ga8@nqP&KAQq4>61|4WEcfX(J% z6lb%wHuu(@bFA}N0@m6bW9;tHTC*jlHn-s^rD`1!mnCq#r6#5HBhX547@C&VAK~8n z>N_IJhrWUwJb(au2sba`3yr0EO0pdd)c^nh07*qoM6N<$f-3fR4FCWD literal 0 HcmV?d00001 diff --git a/src/assets/themes/default/revealed.png b/src/assets/themes/default/revealed.png new file mode 100644 index 0000000000000000000000000000000000000000..2cdf0f795d9e4c45155ebcd4137b74029017b5ee GIT binary patch literal 146 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1|*BCs=fdz#^NA%Cx&(BWL^R}KAtX)ArY-_ zuP+omV8FwCF+0AqvHa|}CQa=Z42C literal 0 HcmV?d00001 diff --git a/src/assets/themes/default/tile.png b/src/assets/themes/default/tile.png new file mode 100644 index 0000000000000000000000000000000000000000..1ded5456c355427b64dee4a672c1f5015bfbc3bb GIT binary patch literal 288 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1|*BCs=fdz#^NA%Cx&(BWL^R}w>(`OLn2z= zUf;-dSV4g8!Tw9l(P!TM_OFzYSM)aGDztdmR;F?32n*9u5BC4QEdTwNe>UuUeA=>o zicnPB%!hm13(qA+iWwAt{!}?HF~}>U^TxDpj(H3d1n>R6&8W!I!QsNfVAs&2Va($^ z^-^hU`RkZ{%y$A~=7l>k905fBmz9TzUW+;+;pk$Z dd-}XUzHdZ@bLvqpE1)kKJYD@<);T3K0RSF$aR2}S literal 0 HcmV?d00001 diff --git a/src/assets/themes/devart/flag.png b/src/assets/themes/devart/flag.png new file mode 100644 index 0000000000000000000000000000000000000000..13d8f43f818220d9e4e8b3118ada514cdaf1a7fa GIT binary patch literal 157 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`jKx9jP7LeL$-D$|!aZFaLo9le z6BejOB&U3-@11zDz9uW8AXDXx)t#pSjBnQ^of*Th>igB z2dM)s{*xpOKCmTl$2v70>*#jQ$}l!E2sp?s!pv~>pm^cNBQvFeRxo(F`njxgN@xNA Dme(;^ literal 0 HcmV?d00001 diff --git a/src/assets/themes/devart/last-pos.png b/src/assets/themes/devart/last-pos.png new file mode 100644 index 0000000000000000000000000000000000000000..51e3964ddc3175af02d966960dd1bd760d7dd314 GIT binary patch literal 119 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`jKx9jP7LeL$-D$|3_V>OLo9le zW0F(8%xC0sIyW~tMS)4-wd2zX9M>cy|E4R2f7Xy-ZRX*rh)Y;vV$IBO(@!Mv0l&~K Ppk@Y7S3j3^P6l{R|De znG@22!Y?_hm9O~UJHdplt9{1XNAIMz$1ghc_k2kYWB+Ucp>uy!w*l>7@O1TaS?83{ F1OV@yIT!!{ literal 0 HcmV?d00001 diff --git a/src/assets/themes/devart/question-mark.png b/src/assets/themes/devart/question-mark.png new file mode 100644 index 0000000000000000000000000000000000000000..0a4449155076dda0b93a3170c6812a646e24840e GIT binary patch literal 136 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`jKx9jP7LeL$-D$|oIG6|Lo9le z6BY;9I=b0 j0_F$d_(-=Hm{an^LB{Ts5lyxp9 literal 0 HcmV?d00001 diff --git a/src/assets/themes/devart/revealed.png b/src/assets/themes/devart/revealed.png new file mode 100644 index 0000000000000000000000000000000000000000..c925de7c4023be3dc2d75ddc1003eb0ffda15c94 GIT binary patch literal 119 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`jKx9jP7LeL$-D$|3_V>OLo9le zMFa(Zo_AnONK8mbm@s*=viIl3?)?%oG&as{Y)rh;5XkJt+rZ@Jwb_^<>H_O@5xdU{ PK+O!Eu6{1-oD!M<;r=2L literal 0 HcmV?d00001 diff --git a/src/assets/themes/devart/tile.png b/src/assets/themes/devart/tile.png new file mode 100644 index 0000000000000000000000000000000000000000..648e90eb0a82c36fdcee40e771aba7038b1ce377 GIT binary patch literal 129 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`jKx9jP7LeL$-D$|tUX;ELo9le zWA;>htY_n55LTU~?mO$q0j9@utV+97&Z_&(S#XDSF(undefined); -export const loginToken = atomWithStorage( +export const gameIdAtom = atom(undefined); +export const loginTokenAtom = atomWithStorage( "loginToken", undefined, ); diff --git a/src/components/Auth/LoginButton.tsx b/src/components/Auth/LoginButton.tsx index 3bc122d..0be97c4 100644 --- a/src/components/Auth/LoginButton.tsx +++ b/src/components/Auth/LoginButton.tsx @@ -8,11 +8,20 @@ import { DialogTitle, DialogTrigger, } from "../Dialog"; +import { useQueryClient } from "@tanstack/react-query"; +import { useWSMutation } from "../../hooks"; +import { useAtom } from "jotai"; +import { loginTokenAtom } from "../../atoms"; +import PasswordInput from "./PasswordInput"; const LoginButton = () => { const [isOpen, setIsOpen] = useState(false); const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + const queryClient = useQueryClient(); + const login = useWSMutation("user.login"); + const [, setToken] = useAtom(loginTokenAtom); useEffect(() => { setUsername(""); @@ -36,18 +45,29 @@ const LoginButton = () => { onChange={(e) => setUsername(e.target.value)} /> - setPassword(e.target.value)} - /> +
+ {error &&

{error}

} - + diff --git a/src/components/Auth/PasswordInput.tsx b/src/components/Auth/PasswordInput.tsx new file mode 100644 index 0000000..595d7e8 --- /dev/null +++ b/src/components/Auth/PasswordInput.tsx @@ -0,0 +1,32 @@ +import { Eye, EyeOff } from "lucide-react"; +import { useState } from "react"; + +interface PasswordInputProps { + value: string; + onChange: (value: string) => void; +} + +const PasswordInput = ({ value, onChange }: PasswordInputProps) => { + const [show, setShow] = useState(false); + return ( +
+ onChange(e.target.value)} + className="w-full p-2 border-white/10 border-1 rounded-md" + /> +
+ +
+
+ ); +}; + +export default PasswordInput; diff --git a/src/components/Auth/RegisterButton.tsx b/src/components/Auth/RegisterButton.tsx index 27f7f33..ceacb32 100644 --- a/src/components/Auth/RegisterButton.tsx +++ b/src/components/Auth/RegisterButton.tsx @@ -8,11 +8,20 @@ import { DialogTitle, DialogTrigger, } from "../Dialog"; +import { useWSMutation } from "../../hooks"; +import { useAtom } from "jotai"; +import { loginTokenAtom } from "../../atoms"; +import { useQueryClient } from "@tanstack/react-query"; +import PasswordInput from "./PasswordInput"; const RegisterButton = () => { const [isOpen, setIsOpen] = useState(false); const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + const queryClient = useQueryClient(); + const register = useWSMutation("user.register"); + const [, setToken] = useAtom(loginTokenAtom); useEffect(() => { setUsername(""); @@ -36,18 +45,29 @@ const RegisterButton = () => { onChange={(e) => setUsername(e.target.value)} /> - setPassword(e.target.value)} - /> + + {error &&

{error}

} - + diff --git a/src/components/Board.tsx b/src/components/Board.tsx new file mode 100644 index 0000000..5d3d59b --- /dev/null +++ b/src/components/Board.tsx @@ -0,0 +1,185 @@ +import { ReactNode, useEffect, useRef, useState } from "react"; +import { LoadedTheme, Theme, useTheme } from "../themes/Theme"; +import { Container, Sprite, Stage } from "@pixi/react"; +import Viewport from "./pixi/PixiViewport"; +import { + ClientGame, + getValue, + isServerGame, + ServerGame, +} from "../../shared/game"; +import { useWSQuery } from "../hooks"; + +interface BoardProps { + theme: Theme; + game: ServerGame | ClientGame; + onLeftClick: (x: number, y: number) => void; + onRightClick: (x: number, y: number) => void; +} + +const Board: React.FC = (props) => { + const { game } = props; + const { data: user } = useWSQuery("user.getSelf", null); + const ref = useRef(null); + const [width, setWidth] = useState(0); + const [height, setHeight] = useState(0); + const showLastPos = game.user !== user || isServerGame(game); + + useEffect(() => { + if (!ref.current) return; + setWidth(ref.current.clientWidth); + setHeight(ref.current.clientHeight); + const resizeObserver = new ResizeObserver(() => { + if (ref.current) { + setWidth(ref.current.clientWidth); + setHeight(ref.current.clientHeight); + } + }); + resizeObserver.observe(ref.current); + return () => resizeObserver.disconnect(); + }, []); + const theme = useTheme(props.theme); + const boardWidth = game.width * (theme?.size || 0); + const boardHeight = game.height * (theme?.size || 0); + + return ( +
+ {theme && ( + + + {game.isRevealed.map((_, i) => { + return game.isRevealed[0].map((_, j) => { + return ( + + ); + }); + })} + + + )} +
+ ); +}; + +interface TileProps { + x: number; + y: number; + game: ServerGame | ClientGame; + theme: LoadedTheme; + showLastPos: boolean; + onLeftClick: (x: number, y: number) => void; + onRightClick: (x: number, y: number) => void; +} + +const Tile = ({ + game, + x, + y, + theme, + showLastPos, + onRightClick, + onLeftClick, +}: TileProps) => { + const i = x; + const j = y; + const isRevealed = game.isRevealed[i][j]; + const value = isServerGame(game) + ? getValue(game.mines, i, j) + : game.values[i][j]; + const isMine = isServerGame(game) ? game.mines[i][j] : false; + const isLastPos = showLastPos + ? game.lastClick[0] === i && game.lastClick[1] === j + : false; + const isFlagged = game.isFlagged[i][j]; + const isQuestionMark = game.isQuestionMark[i][j]; + const base = isRevealed ? ( + + ) : ( + + ); + let content: ReactNode = null; + if (isMine) { + content = ; + } else if (value !== -1 && isRevealed) { + const img = theme[value.toString() as keyof Theme] as string; + content = img ? : null; + } else if (isFlagged) { + content = ; + } else if (isQuestionMark) { + content = ; + } + const extra = isLastPos ? : null; + const touchStart = useRef(0); + const isMove = useRef(false); + const startX = useRef(0); + const startY = useRef(0); + + return ( + { + onRightClick(i, j); + }} + onpointerup={(e) => { + if (e.button !== 0) return; + if (isMove.current) return; + if (Date.now() - touchStart.current > 300) { + onRightClick(i, j); + } else { + onLeftClick(i, j); + } + }} + onpointerdown={(e) => { + isMove.current = false; + touchStart.current = Date.now(); + startX.current = e.global.x; + startY.current = e.global.y; + }} + onpointermove={(e) => { + if ( + Math.abs(startX.current - e.global.x) > 10 || + Math.abs(startY.current - e.global.y) > 10 + ) { + isMove.current = true; + } + }} + > + {base} + {content} + {extra} + + ); +}; + +export default Board; diff --git a/src/components/Dialog.tsx b/src/components/Dialog.tsx index 65e3747..8aa2f6c 100644 --- a/src/components/Dialog.tsx +++ b/src/components/Dialog.tsx @@ -35,7 +35,7 @@ const DialogContent = React.forwardRef< { const [, setLocation] = useLocation(); const { data: username } = useWSQuery("user.getSelf", null); + const queryClient = useQueryClient(); + const logout = useWSMutation("user.logout", () => { + queryClient.invalidateQueries(); + }); + return (
- - +
+ {username ? ( @@ -42,7 +44,9 @@ const Header = () => { Settings - Logout + logout.mutate(null)}> + Logout + ) : ( diff --git a/src/components/pixi/PixiViewport.tsx b/src/components/pixi/PixiViewport.tsx new file mode 100644 index 0000000..ce55e7e --- /dev/null +++ b/src/components/pixi/PixiViewport.tsx @@ -0,0 +1,73 @@ +import React from "react"; +import * as PIXI from "pixi.js"; +import { Viewport as PixiViewport } from "pixi-viewport"; +import { PixiComponent, useApp } from "@pixi/react"; +import { BaseTexture, SCALE_MODES } from "pixi.js"; +BaseTexture.defaultOptions.scaleMode = SCALE_MODES.NEAREST; + +export interface ViewportProps { + width: number; + height: number; + worldWidth: number; + worldHeight: number; + children?: React.ReactNode; + clamp?: { + left: number; + right: number; + top: number; + bottom: number; + }; +} + +export interface PixiComponentViewportProps extends ViewportProps { + app: PIXI.Application; +} + +const PixiComponentViewport = PixiComponent("Viewport", { + create: (props: PixiComponentViewportProps) => { + const viewport = new PixiViewport({ + screenWidth: props.width, + screenHeight: props.height, + worldWidth: props.worldWidth, + worldHeight: props.worldHeight, + ticker: props.app.ticker, + events: props.app.renderer.events, + disableOnContextMenu: true, + allowPreserveDragOutside: true, + }); + viewport + .drag({ + ignoreKeyToPressOnTouch: true, + mouseButtons: "middle", + }) + .pinch() + .wheel(); + if (props.clamp) { + viewport.clamp(props.clamp); + } + + return viewport; + }, + applyProps: ( + viewport: PixiViewport, + oldProps: ViewportProps, + newProps: ViewportProps, + ) => { + if ( + oldProps.width !== newProps.width || + oldProps.height !== newProps.height + ) { + viewport.resize(newProps.width, newProps.height); + } + if (oldProps.clamp !== newProps.clamp) { + viewport.clamp(newProps.clamp); + } + }, +}); + +const Viewport = (props: ViewportProps) => { + const app = useApp(); + return ; +}; + +export default Viewport; diff --git a/src/hooks.ts b/src/hooks.ts index 83df5fe..e5f9f15 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -14,6 +14,7 @@ export const useWSQuery = < action: `${TController}.${TAction}`, // @ts-expect-error We dont care since this is internal api payload: Routes[TController][TAction]["validate"]["_input"], + enabled?: boolean, ): UseQueryResult< // @ts-expect-error We dont care since this is internal api Awaited> @@ -24,6 +25,7 @@ export const useWSQuery = < const result = await wsClient.dispatch(action, payload); return result; }, + enabled, }); }; diff --git a/src/main.tsx b/src/main.tsx index 5b0c196..8885830 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,34 +1,46 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; -import App from "./App.tsx"; import "./index.css"; import { connectWS } from "./ws.ts"; import { Toaster } from "react-hot-toast"; -import { - QueryCache, - QueryClient, - QueryClientProvider, -} from "@tanstack/react-query"; +import { QueryClientProvider } from "@tanstack/react-query"; import Shell from "./Shell.tsx"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; - -document.addEventListener("contextmenu", (event) => { - event.preventDefault(); -}); +import { wsClient } from "./wsClient.ts"; +import { Route, Switch } from "wouter"; +import Endless from "./views/endless/Endless.tsx"; +import { queryClient } from "./queryClient.ts"; connectWS(); -const queryClient = new QueryClient({ - queryCache: new QueryCache(), -}); +const setup = async () => { + const token = localStorage.getItem("loginToken"); -createRoot(document.getElementById("root")!).render( - - - - - {/* */} - - - , -); + if (token) { + try { + await wsClient.dispatch("user.loginWithToken", { + token: JSON.parse(token), + }); + } catch (e) { + console.error(e); + } + } +}; + +setup().then(() => { + createRoot(document.getElementById("root")!).render( + + + + + + + + {/* */} + + {/* */} + + + , + ); +}); diff --git a/src/queryClient.ts b/src/queryClient.ts new file mode 100644 index 0000000..af1fcfe --- /dev/null +++ b/src/queryClient.ts @@ -0,0 +1,9 @@ +import { QueryClient } from "@tanstack/react-query"; + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: true, + }, + }, +}); diff --git a/src/themes/Theme.ts b/src/themes/Theme.ts new file mode 100644 index 0000000..b838e2c --- /dev/null +++ b/src/themes/Theme.ts @@ -0,0 +1,46 @@ +import { useEffect, useState } from "react"; + +type Png = typeof import("*.png"); +type LazySprite = () => Promise; + +export interface Theme { + size: number; + mine: LazySprite; + tile: LazySprite; + revealed: LazySprite; + flag: LazySprite; + questionMark: LazySprite; + lastPos: LazySprite; + 1: LazySprite; + 2: LazySprite; + 3: LazySprite; + 4: LazySprite; + 5: LazySprite; + 6: LazySprite; + 7: LazySprite; + 8: LazySprite; +} + +export type LoadedTheme = Record, string> & { + size: number; +}; + +export const useTheme = (theme: Theme) => { + const [loadedTheme, setLoadedTheme] = useState( + undefined, + ); + useEffect(() => { + const loadTheme = async () => { + const loadedEntries = await Promise.all( + Object.entries(theme).map(async ([key, value]) => { + const loaded = + typeof value === "function" ? (await value()).default : value; + return [key, loaded] as const; + }), + ); + setLoadedTheme(Object.fromEntries(loadedEntries) as LoadedTheme); + }; + loadTheme(); + }, [theme]); + return loadedTheme; +}; diff --git a/src/themes/default.ts b/src/themes/default.ts new file mode 100644 index 0000000..e56766e --- /dev/null +++ b/src/themes/default.ts @@ -0,0 +1,19 @@ +import { Theme } from "./Theme"; + +export const defaultTheme: Theme = { + size: 32, + mine: () => import("../assets/themes/default/mine.png"), + tile: () => import("../assets/themes/default/tile.png"), + revealed: () => import("../assets/themes/default/revealed.png"), + flag: () => import("../assets/themes/default/flag.png"), + questionMark: () => import("../assets/themes/default/question-mark.png"), + lastPos: () => import("../assets/themes/default/last-pos.png"), + 1: () => import("../assets/themes/default/1.png"), + 2: () => import("../assets/themes/default/2.png"), + 3: () => import("../assets/themes/default/3.png"), + 4: () => import("../assets/themes/default/4.png"), + 5: () => import("../assets/themes/default/5.png"), + 6: () => import("../assets/themes/default/6.png"), + 7: () => import("../assets/themes/default/7.png"), + 8: () => import("../assets/themes/default/8.png"), +}; diff --git a/src/views/endless/Endless.tsx b/src/views/endless/Endless.tsx new file mode 100644 index 0000000..44a75dc --- /dev/null +++ b/src/views/endless/Endless.tsx @@ -0,0 +1,48 @@ +import { defaultTheme } from "../../themes/default"; +import Board from "../../components/Board"; +import toast from "react-hot-toast"; +import { useWSMutation, useWSQuery } from "../../hooks"; +import { useAtom } from "jotai"; +import { gameIdAtom } from "../../atoms"; +import { Button } from "../../components/Button"; + +const Endless = () => { + const [gameId, setGameId] = useAtom(gameIdAtom); + const { data: game } = useWSQuery("game.getGameState", gameId!, !!gameId); + const startGame = useWSMutation("game.createGame"); + const reveal = useWSMutation("game.reveal"); + + return ( + <> +
+

Endless

+

A game where you have to click on the mines

+
+ +
+
+ {game && ( + { + reveal.mutateAsync({ x, y }); + }} + onRightClick={(x, y) => { + toast.success(`Right click ${x},${y}`); + }} + /> + )} + + ); +}; + +export default Endless; diff --git a/src/wsClient.ts b/src/wsClient.ts index 0c04087..22c7d4a 100644 --- a/src/wsClient.ts +++ b/src/wsClient.ts @@ -1,4 +1,6 @@ import type { Routes } from "../backend/router"; +import { Events } from "../shared/events"; +import { queryClient } from "./queryClient"; const connectionString = import.meta.env.DEV ? "ws://localhost:8076/ws" @@ -20,8 +22,13 @@ const createWSClient = () => { const ws = new WebSocket(connectionString); ws.onmessage = emitMessage; addMessageListener((event: MessageEvent) => { - const data = JSON.parse(event.data); - console.log(data); + const data = JSON.parse(event.data) as Events; + if (data.type === "updateGame") { + queryClient.invalidateQueries({ + queryKey: ["game.getGameState", data.game], + }); + } + console.log("Received message", data); }); const dispatch = async <