diff options
| author | nsfisis <nsfisis@gmail.com> | 2024-07-28 23:28:10 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2024-07-29 02:32:54 +0900 |
| commit | df57e43059a230062d903f55f9af7339828875c3 (patch) | |
| tree | bdce6908a7da4bbe5528e3fb3069eb489efa41d5 | |
| parent | daaf81ae931654e20f882fbc6bbc4a02cbfc0273 (diff) | |
| download | iosdc-japan-2024-albatross-df57e43059a230062d903f55f9af7339828875c3.tar.gz iosdc-japan-2024-albatross-df57e43059a230062d903f55f9af7339828875c3.tar.zst iosdc-japan-2024-albatross-df57e43059a230062d903f55f9af7339828875c3.zip | |
feat(frontend): partially implement gaming
8 files changed, 275 insertions, 7 deletions
diff --git a/frontend/app/.server/api/schema.d.ts b/frontend/app/.server/api/schema.d.ts index 541fb53..445281d 100644 --- a/frontend/app/.server/api/schema.d.ts +++ b/frontend/app/.server/api/schema.d.ts @@ -118,6 +118,58 @@ export interface paths { patch?: never; trace?: never; }; + [path: `/games/${integer}`]: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get a game */ + get: { + parameters: { + query?: never; + header: { + Authorization: string; + }; + path: { + game_id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description A game */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Game"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example Forbidden operation */ + message: string; + }; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record<string, never>; export interface components { @@ -163,17 +215,19 @@ export interface components { GamePlayerMessageS2CPrepare: { /** @constant */ type: "player:s2c:prepare"; - data: { - problem: components["schemas"]["Problem"]; - }; + data: components["schemas"]["GamePlayerMessageS2CPreparePayload"]; + }; + GamePlayerMessageS2CPreparePayload: { + problem: components["schemas"]["Problem"]; }; GamePlayerMessageS2CStart: { /** @constant */ type: "player:s2c:start"; - data: { - /** @example 946684800 */ - start_at: number; - }; + data: components["schemas"]["GamePlayerMessageS2CStartPayload"]; + }; + GamePlayerMessageS2CStartPayload: { + /** @example 946684800 */ + start_at: number; }; GamePlayerMessageC2S: components["schemas"]["GamePlayerMessageC2SEntry"] | components["schemas"]["GamePlayerMessageC2SReady"]; GamePlayerMessageC2SEntry: { diff --git a/frontend/app/components/GolfPlayApp.tsx b/frontend/app/components/GolfPlayApp.tsx new file mode 100644 index 0000000..31d1c44 --- /dev/null +++ b/frontend/app/components/GolfPlayApp.tsx @@ -0,0 +1,135 @@ +import type { components } from "../.server/api/schema"; +import { useState, useEffect } from "react"; +import useWebSocket, { ReadyState } from "react-use-websocket"; +import { useDebouncedCallback } from "use-debounce"; +import GolfPlayAppConnecting from "./GolfPlayApps/GolfPlayAppConnecting"; +import GolfPlayAppWaiting from "./GolfPlayApps/GolfPlayAppWaiting"; +import GolfPlayAppStarting from "./GolfPlayApps/GolfPlayAppStarting"; +import GolfPlayAppGaming from "./GolfPlayApps/GolfPlayAppGaming"; +import GolfPlayAppFinished from "./GolfPlayApps/GolfPlayAppFinished"; + +type WebSocketMessage = components["schemas"]["GamePlayerMessageS2C"]; + +type Game = components["schemas"]["Game"]; +type Problem = components["schemas"]["Problem"]; + +type GameState = "connecting" | "waiting" | "starting" | "gaming" | "finished"; + +export default function GolfPlayApp({ game }: { game: Game }) { + // const socketUrl = `wss://t.nil.ninja/iosdc/2024/sock/golf/${game.game_id}/play`; + const socketUrl = + process.env.NODE_ENV === "development" + ? `ws://localhost:8002/sock/golf/${game.game_id}/play` + : `ws://api-server/sock/golf/${game.game_id}/play`; + + const { sendJsonMessage, lastJsonMessage, readyState } = + useWebSocket<WebSocketMessage>(socketUrl, {}); + + const [gameState, setGameState] = useState<GameState>("connecting"); + + const [problem, setProblem] = useState<Problem | null>(null); + + const [startedAt, setStartedAt] = useState<number | null>(null); + + const [timeLeftSeconds, setTimeLeftSeconds] = useState<number | null>(null); + + useEffect(() => { + if (gameState === "starting" && startedAt !== null) { + const timer1 = setInterval(() => { + setTimeLeftSeconds((prev) => { + if (prev === null) { + return null; + } + if (prev <= 1) { + clearInterval(timer1); + setGameState("gaming"); + return 0; + } + return prev - 1; + }); + }, 1000); + + const timer2 = setInterval(() => { + const nowSec = Math.floor(Date.now() / 1000); + const finishedAt = startedAt + game.duration_seconds; + if (nowSec >= finishedAt) { + clearInterval(timer2); + setGameState("finished"); + } + }, 1000); + + return () => { + clearInterval(timer1); + clearInterval(timer2); + }; + } + }, [gameState, startedAt, game.duration_seconds]); + + const [currentScore, setCurrentScore] = useState<number | null>(null); + void setCurrentScore; + + const onCodeChange = useDebouncedCallback((code: string) => { + void code; + // sendJsonMessage({}); + }, 1000); + + if (readyState === ReadyState.UNINSTANTIATED) { + throw new Error("WebSocket is not connected"); + } + + useEffect(() => { + if (readyState === ReadyState.CLOSING || readyState === ReadyState.CLOSED) { + if (gameState !== "finished") { + setGameState("connecting"); + } + } else if (readyState === ReadyState.CONNECTING) { + setGameState("connecting"); + } else if (readyState === ReadyState.OPEN) { + if (lastJsonMessage !== null) { + console.log(lastJsonMessage.type); + if (lastJsonMessage.type === "player:s2c:prepare") { + const { problem } = lastJsonMessage.data; + setProblem(problem); + console.log("player:c2s:ready"); + sendJsonMessage({ type: "player:c2s:ready" }); + } else if (lastJsonMessage.type === "player:s2c:start") { + if ( + gameState !== "starting" && + gameState !== "gaming" && + gameState !== "finished" + ) { + const { start_at } = lastJsonMessage.data; + setStartedAt(start_at); + const nowSec = Math.floor(Date.now() / 1000); + setTimeLeftSeconds(start_at - nowSec); + setGameState("starting"); + } + } + } else { + setGameState("waiting"); + console.log("player:c2s:entry"); + sendJsonMessage({ type: "player:c2s:entry" }); + } + } + }, [sendJsonMessage, lastJsonMessage, readyState, gameState]); + + if (gameState === "connecting") { + return <GolfPlayAppConnecting />; + } else if (gameState === "waiting") { + return <GolfPlayAppWaiting />; + } else if (gameState === "starting") { + return <GolfPlayAppStarting timeLeft={timeLeftSeconds!} />; + } else if (gameState === "gaming") { + return ( + <GolfPlayAppGaming + problem={problem!.description} + onCodeChange={onCodeChange} + currentScore={currentScore} + /> + ); + } else if (gameState === "finished") { + return <GolfPlayAppFinished />; + } else { + return null; + } +} diff --git a/frontend/app/components/GolfPlayApps/GolfPlayAppConnecting.tsx b/frontend/app/components/GolfPlayApps/GolfPlayAppConnecting.tsx new file mode 100644 index 0000000..e92a8e0 --- /dev/null +++ b/frontend/app/components/GolfPlayApps/GolfPlayAppConnecting.tsx @@ -0,0 +1,3 @@ +export default function GolfPlayAppConnecting() { + return <div>Connecting...</div>; +} diff --git a/frontend/app/components/GolfPlayApps/GolfPlayAppFinished.tsx b/frontend/app/components/GolfPlayApps/GolfPlayAppFinished.tsx new file mode 100644 index 0000000..75ceb71 --- /dev/null +++ b/frontend/app/components/GolfPlayApps/GolfPlayAppFinished.tsx @@ -0,0 +1,3 @@ +export default function GolfPlayAppFinished() { + return <div>Finished</div>; +} diff --git a/frontend/app/components/GolfPlayApps/GolfPlayAppGaming.tsx b/frontend/app/components/GolfPlayApps/GolfPlayAppGaming.tsx new file mode 100644 index 0000000..332cb3c --- /dev/null +++ b/frontend/app/components/GolfPlayApps/GolfPlayAppGaming.tsx @@ -0,0 +1,30 @@ +export default function GolfPlayAppGaming({ + problem, + onCodeChange, + currentScore, +}: { + problem: string; + onCodeChange: (code: string) => void; + currentScore: number | null; +}) { + const handleTextChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => { + onCodeChange(e.target.value); + }; + + return ( + <div style={{ display: "flex" }}> + <div style={{ flex: 1, padding: "10px", borderRight: "1px solid #ccc" }}> + <div>{problem}</div> + <div> + {currentScore == null ? "Score: -" : `Score: ${currentScore}`} + </div> + </div> + <div style={{ flex: 1, padding: "10px" }}> + <textarea + style={{ width: "100%", height: "100%" }} + onChange={handleTextChange} + /> + </div> + </div> + ); +} diff --git a/frontend/app/components/GolfPlayApps/GolfPlayAppStarting.tsx b/frontend/app/components/GolfPlayApps/GolfPlayAppStarting.tsx new file mode 100644 index 0000000..bf45abb --- /dev/null +++ b/frontend/app/components/GolfPlayApps/GolfPlayAppStarting.tsx @@ -0,0 +1,7 @@ +export default function GolfPlayAppStarting({ + timeLeft, +}: { + timeLeft: number; +}) { + return <div>Starting... ({timeLeft} s)</div>; +} diff --git a/frontend/app/components/GolfPlayApps/GolfPlayAppWaiting.tsx b/frontend/app/components/GolfPlayApps/GolfPlayAppWaiting.tsx new file mode 100644 index 0000000..a0751e0 --- /dev/null +++ b/frontend/app/components/GolfPlayApps/GolfPlayAppWaiting.tsx @@ -0,0 +1,3 @@ +export default function GolfPlayAppWaiting() { + return <div>Waiting...</div>; +} diff --git a/frontend/app/routes/golf.$gameId.play.tsx b/frontend/app/routes/golf.$gameId.play.tsx new file mode 100644 index 0000000..bda563f --- /dev/null +++ b/frontend/app/routes/golf.$gameId.play.tsx @@ -0,0 +1,33 @@ +import type { LoaderFunctionArgs } from "@remix-run/node"; +import { isAuthenticated } from "../.server/auth"; +import { apiClient } from "../.server/api/client"; +import { useLoaderData } from "@remix-run/react"; +import GolfPlayApp from "../components/GolfPlayApp"; + +export async function loader({ params, request }: LoaderFunctionArgs) { + const { token } = await isAuthenticated(request, { + failureRedirect: "/login", + }); + const { data, error } = await apiClient.GET("/games/{game_id}", { + params: { + path: { + game_id: Number(params.gameId), + }, + header: { + Authorization: `Bearer ${token}`, + }, + }, + }); + if (error) { + throw new Error(error.message); + } + return { + game: data, + }; +} + +export default function GolfPlay() { + const { game } = useLoaderData<typeof loader>(); + + return <GolfPlayApp game={game} />; +} |
