diff options
Diffstat (limited to 'frontend/app')
9 files changed, 266 insertions, 1 deletions
diff --git a/frontend/app/.server/api/schema.d.ts b/frontend/app/.server/api/schema.d.ts index 40a3347..1d3313e 100644 --- a/frontend/app/.server/api/schema.d.ts +++ b/frontend/app/.server/api/schema.d.ts @@ -261,6 +261,48 @@ export interface components { /** @example print('Hello, world!') */ code: string; }; + GameWatcherMessage: components["schemas"]["GameWatcherMessageS2C"]; + GameWatcherMessageS2C: components["schemas"]["GameWatcherMessageS2CStart"] | components["schemas"]["GameWatcherMessageS2CCode"] | components["schemas"]["GameWatcherMessageS2CExecResult"]; + GameWatcherMessageS2CStart: { + /** @constant */ + type: "watcher:s2c:start"; + data: components["schemas"]["GameWatcherMessageS2CStartPayload"]; + }; + GameWatcherMessageS2CStartPayload: { + /** @example 946684800 */ + start_at: number; + }; + GameWatcherMessageS2CCode: { + /** @constant */ + type: "watcher:s2c:code"; + data: components["schemas"]["GameWatcherMessageS2CCodePayload"]; + }; + GameWatcherMessageS2CCodePayload: { + /** @example 1 */ + player_id: number; + /** @example print('Hello, world!') */ + code: string; + }; + GameWatcherMessageS2CExecResult: { + /** @constant */ + type: "watcher:s2c:execresult"; + data: components["schemas"]["GameWatcherMessageS2CExecResultPayload"]; + }; + GameWatcherMessageS2CExecResultPayload: { + /** @example 1 */ + player_id: number; + /** + * @example success + * @enum {string} + */ + status: "success"; + /** @example 100 */ + score: number | null; + /** @example Hello, world! */ + stdout: string; + /** @example */ + stderr: string; + }; }; responses: never; parameters: never; diff --git a/frontend/app/components/GolfPlayApp.tsx b/frontend/app/components/GolfPlayApp.tsx index c6c20d4..13afb22 100644 --- a/frontend/app/components/GolfPlayApp.tsx +++ b/frontend/app/components/GolfPlayApp.tsx @@ -66,7 +66,6 @@ export default function GolfPlayApp({ game }: { game: Game }) { }, [gameState, startedAt, game.duration_seconds]); const [currentScore, setCurrentScore] = useState<number | null>(null); - void setCurrentScore; const onCodeChange = useDebouncedCallback((code: string) => { console.log("player:c2s:code"); diff --git a/frontend/app/components/GolfWatchApp.tsx b/frontend/app/components/GolfWatchApp.tsx new file mode 100644 index 0000000..bcd1f0f --- /dev/null +++ b/frontend/app/components/GolfWatchApp.tsx @@ -0,0 +1,136 @@ +import type { components } from "../.server/api/schema"; +import { useState, useEffect } from "react"; +import useWebSocket, { ReadyState } from "react-use-websocket"; +import GolfWatchAppConnecting from "./GolfWatchApps/GolfWatchAppConnecting"; +import GolfWatchAppWaiting from "./GolfWatchApps/GolfWatchAppWaiting"; +import GolfWatchAppStarting from "./GolfWatchApps/GolfWatchAppStarting"; +import GolfWatchAppGaming from "./GolfWatchApps/GolfWatchAppGaming"; +import GolfWatchAppFinished from "./GolfWatchApps/GolfWatchAppFinished"; + +type WebSocketMessage = components["schemas"]["GameWatcherMessageS2C"]; + +type Game = components["schemas"]["Game"]; +type Problem = components["schemas"]["Problem"]; + +type GameState = "connecting" | "waiting" | "starting" | "gaming" | "finished"; + +export default function GolfWatchApp({ 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 { 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 [scoreA, setScoreA] = useState<number | null>(null); + const [scoreB, setScoreB] = useState<number | null>(null); + const [codeA, setCodeA] = useState<string>(""); + const [codeB, setCodeB] = useState<string>(""); + + 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 === "watcher: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 if (lastJsonMessage.type === "watcher:s2c:code") { + const { player_id, code } = lastJsonMessage.data; + setCodeA(code); + } else if (lastJsonMessage.type === "watcher:s2c:execresult") { + const { score } = lastJsonMessage.data; + if (score !== null && (scoreA === null || score < scoreA)) { + setScoreA(score); + } + } + } else { + setGameState("waiting"); + } + } + }, [lastJsonMessage, readyState, gameState, scoreA]); + + if (gameState === "connecting") { + return <GolfWatchAppConnecting />; + } else if (gameState === "waiting") { + return <GolfWatchAppWaiting />; + } else if (gameState === "starting") { + return <GolfWatchAppStarting timeLeft={timeLeftSeconds!} />; + } else if (gameState === "gaming") { + return ( + <GolfWatchAppGaming + problem={problem!.description} + codeA={codeA} + scoreA={scoreA} + codeB={codeB} + scoreB={scoreB} + /> + ); + } else if (gameState === "finished") { + return <GolfWatchAppFinished />; + } else { + return null; + } +} diff --git a/frontend/app/components/GolfWatchApps/GolfWatchAppConnecting.tsx b/frontend/app/components/GolfWatchApps/GolfWatchAppConnecting.tsx new file mode 100644 index 0000000..07e359f --- /dev/null +++ b/frontend/app/components/GolfWatchApps/GolfWatchAppConnecting.tsx @@ -0,0 +1,3 @@ +export default function GolfWatchAppConnecting() { + return <div>Connecting...</div>; +} diff --git a/frontend/app/components/GolfWatchApps/GolfWatchAppFinished.tsx b/frontend/app/components/GolfWatchApps/GolfWatchAppFinished.tsx new file mode 100644 index 0000000..330d1a6 --- /dev/null +++ b/frontend/app/components/GolfWatchApps/GolfWatchAppFinished.tsx @@ -0,0 +1,3 @@ +export default function GolfWatchAppFinished() { + return <div>Finished</div>; +} diff --git a/frontend/app/components/GolfWatchApps/GolfWatchAppGaming.tsx b/frontend/app/components/GolfWatchApps/GolfWatchAppGaming.tsx new file mode 100644 index 0000000..d58a04f --- /dev/null +++ b/frontend/app/components/GolfWatchApps/GolfWatchAppGaming.tsx @@ -0,0 +1,39 @@ +export default function GolfWatchAppGaming({ + problem, + codeA, + scoreA, + codeB, + scoreB, +}: { + problem: string; + codeA: string; + scoreA: number | null; + codeB: string; + scoreB: number | null; +}) { + return ( + <div style={{ display: "flex", flexDirection: "column" }}> + <div style={{ display: "flex", flex: 1, justifyContent: "center" }}> + <div>{problem}</div> + </div> + <div style={{ display: "flex", flex: 3 }}> + <div style={{ display: "flex", flex: 3, flexDirection: "column" }}> + <div style={{ flex: 1, justifyContent: "center" }}>{scoreA}</div> + <div style={{ flex: 3 }}> + <pre> + <code>{codeA}</code> + </pre> + </div> + </div> + <div style={{ display: "flex", flex: 3, flexDirection: "column" }}> + <div style={{ flex: 1, justifyContent: "center" }}>{scoreB}</div> + <div style={{ flex: 3 }}> + <pre> + <code>{codeB}</code> + </pre> + </div> + </div> + </div> + </div> + ); +} diff --git a/frontend/app/components/GolfWatchApps/GolfWatchAppStarting.tsx b/frontend/app/components/GolfWatchApps/GolfWatchAppStarting.tsx new file mode 100644 index 0000000..643af93 --- /dev/null +++ b/frontend/app/components/GolfWatchApps/GolfWatchAppStarting.tsx @@ -0,0 +1,7 @@ +export default function GolfWatchAppStarting({ + timeLeft, +}: { + timeLeft: number; +}) { + return <div>Starting... ({timeLeft} s)</div>; +} diff --git a/frontend/app/components/GolfWatchApps/GolfWatchAppWaiting.tsx b/frontend/app/components/GolfWatchApps/GolfWatchAppWaiting.tsx new file mode 100644 index 0000000..6733b3b --- /dev/null +++ b/frontend/app/components/GolfWatchApps/GolfWatchAppWaiting.tsx @@ -0,0 +1,3 @@ +export default function GolfWatchAppWaiting() { + return <div>Waiting...</div>; +} diff --git a/frontend/app/routes/golf.$gameId.watch.tsx b/frontend/app/routes/golf.$gameId.watch.tsx new file mode 100644 index 0000000..e1cb5d7 --- /dev/null +++ b/frontend/app/routes/golf.$gameId.watch.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 GolfWatchApp from "../components/GolfWatchApp"; + +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 GolfWatch() { + const { game } = useLoaderData<typeof loader>(); + + return <GolfWatchApp game={game} />; +} |
