aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2024-07-28 23:28:10 +0900
committernsfisis <nsfisis@gmail.com>2024-07-29 02:32:54 +0900
commitdf57e43059a230062d903f55f9af7339828875c3 (patch)
treebdce6908a7da4bbe5528e3fb3069eb489efa41d5
parentdaaf81ae931654e20f882fbc6bbc4a02cbfc0273 (diff)
downloadiosdc-japan-2024-albatross-df57e43059a230062d903f55f9af7339828875c3.tar.gz
iosdc-japan-2024-albatross-df57e43059a230062d903f55f9af7339828875c3.tar.zst
iosdc-japan-2024-albatross-df57e43059a230062d903f55f9af7339828875c3.zip
feat(frontend): partially implement gaming
-rw-r--r--frontend/app/.server/api/schema.d.ts68
-rw-r--r--frontend/app/components/GolfPlayApp.tsx135
-rw-r--r--frontend/app/components/GolfPlayApps/GolfPlayAppConnecting.tsx3
-rw-r--r--frontend/app/components/GolfPlayApps/GolfPlayAppFinished.tsx3
-rw-r--r--frontend/app/components/GolfPlayApps/GolfPlayAppGaming.tsx30
-rw-r--r--frontend/app/components/GolfPlayApps/GolfPlayAppStarting.tsx7
-rw-r--r--frontend/app/components/GolfPlayApps/GolfPlayAppWaiting.tsx3
-rw-r--r--frontend/app/routes/golf.$gameId.play.tsx33
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} />;
+}