From ad46f1e28bfe2de8969b46d2a24ae2b24b0658a7 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Thu, 22 Aug 2024 00:06:43 +0900 Subject: feat(frontend): add jotai --- frontend/package-lock.json | 36 +++++++++++++++++++++++++++++++++--- frontend/package.json | 2 ++ 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ad96c48..6953cef 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -14,6 +14,8 @@ "@remix-run/serve": "^2.10.3", "cookie": "^0.6.0", "isbot": "^5.1.13", + "jotai": "^2.9.3", + "jotai-effect": "^1.0.0", "jwt-decode": "^4.0.0", "openapi-fetch": "^0.10.2", "prismjs": "^1.29.0", @@ -2300,13 +2302,13 @@ "version": "15.7.12", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz", "integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==", - "dev": true + "devOptional": true }, "node_modules/@types/react": { "version": "18.3.3", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.3.tgz", "integrity": "sha512-hti/R0pS0q1/xx+TsI73XIqk26eBsISZ2R0wUijXIngRK9R/e7Xw/cXVxQK7R5JjW+SV4zGcn5hXjudkN/pLIw==", - "dev": true, + "devOptional": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -3682,7 +3684,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true + "devOptional": true }, "node_modules/damerau-levenshtein": { "version": "1.0.8", @@ -6503,6 +6505,34 @@ "jiti": "bin/jiti.js" } }, + "node_modules/jotai": { + "version": "2.9.3", + "resolved": "https://registry.npmjs.org/jotai/-/jotai-2.9.3.tgz", + "integrity": "sha512-IqMWKoXuEzWSShjd9UhalNsRGbdju5G2FrqNLQJT+Ih6p41VNYe2sav5hnwQx4HJr25jq9wRqvGSWGviGG6Gjw==", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=17.0.0", + "react": ">=17.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/jotai-effect": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/jotai-effect/-/jotai-effect-1.0.0.tgz", + "integrity": "sha512-eCgKKG4BACDzuJGYTu0xZRk1C1MEOvbAhC3L8w7YufQ2lSLORwNX/WFnCuZxLFX0sDLkTUeoUzOYaw8wnXY+UQ==", + "peerDependencies": { + "jotai": ">=2.5.0" + } + }, "node_modules/js-levenshtein": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz", diff --git a/frontend/package.json b/frontend/package.json index 5022494..1238349 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -22,6 +22,8 @@ "@remix-run/serve": "^2.10.3", "cookie": "^0.6.0", "isbot": "^5.1.13", + "jotai": "^2.9.3", + "jotai-effect": "^1.0.0", "jwt-decode": "^4.0.0", "openapi-fetch": "^0.10.2", "prismjs": "^1.29.0", -- cgit v1.2.3-70-g09d2 From 43d40d375c355837de83501c3f1c122b2bf589bb Mon Sep 17 00:00:00 2001 From: nsfisis Date: Thu, 22 Aug 2024 00:19:58 +0900 Subject: feat(frontend): remove spectrum analyzer in waiting page --- .../components/GolfPlayApps/GolfPlayAppWaiting.tsx | 587 +-------------------- 1 file changed, 6 insertions(+), 581 deletions(-) diff --git a/frontend/app/components/GolfPlayApps/GolfPlayAppWaiting.tsx b/frontend/app/components/GolfPlayApps/GolfPlayAppWaiting.tsx index bbef43e..a31e5f4 100644 --- a/frontend/app/components/GolfPlayApps/GolfPlayAppWaiting.tsx +++ b/frontend/app/components/GolfPlayApps/GolfPlayAppWaiting.tsx @@ -11,588 +11,13 @@ export default function GolfPlayAppWaiting({ playerInfo, }: Props) { return ( - <> -
-
-
{gameDisplayName}
-
-
- -
+
+
+
{gameDisplayName}
- -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
- +
); } -- cgit v1.2.3-70-g09d2 From 922bc6a1f52d8f01600e9a61ce31963075ec59a5 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Thu, 22 Aug 2024 00:46:06 +0900 Subject: refactor(frontend): organize PlayerInfo --- .../components/Gaming/ExecStatusIndicatorIcon.tsx | 2 +- frontend/app/components/Gaming/SubmitResult.tsx | 2 +- frontend/app/components/GolfPlayApp.client.tsx | 20 +++++---- .../components/GolfPlayApps/GolfPlayAppGaming.tsx | 16 ++++---- .../components/GolfPlayApps/GolfPlayAppWaiting.tsx | 10 ++--- frontend/app/components/GolfWatchApp.client.tsx | 48 +++++++++++++--------- .../GolfWatchApps/GolfWatchAppGaming.tsx | 38 ++++++++--------- .../GolfWatchApps/GolfWatchAppWaiting.tsx | 18 ++++---- frontend/app/components/PlayerNameAndIcon.tsx | 25 +++++++++++ frontend/app/components/PlayerProfile.tsx | 27 ------------ frontend/app/components/SubmitStatusLabel.tsx | 2 +- frontend/app/models/ExecResult.ts | 18 -------- frontend/app/models/PlayerInfo.ts | 9 ---- frontend/app/models/SubmitResult.ts | 16 -------- frontend/app/types/ExecResult.ts | 18 ++++++++ frontend/app/types/PlayerInfo.ts | 7 ++++ frontend/app/types/PlayerProfile.ts | 4 ++ frontend/app/types/PlayerState.ts | 7 ++++ frontend/app/types/SubmitResult.ts | 16 ++++++++ 19 files changed, 162 insertions(+), 141 deletions(-) create mode 100644 frontend/app/components/PlayerNameAndIcon.tsx delete mode 100644 frontend/app/components/PlayerProfile.tsx delete mode 100644 frontend/app/models/ExecResult.ts delete mode 100644 frontend/app/models/PlayerInfo.ts delete mode 100644 frontend/app/models/SubmitResult.ts create mode 100644 frontend/app/types/ExecResult.ts create mode 100644 frontend/app/types/PlayerInfo.ts create mode 100644 frontend/app/types/PlayerProfile.ts create mode 100644 frontend/app/types/PlayerState.ts create mode 100644 frontend/app/types/SubmitResult.ts diff --git a/frontend/app/components/Gaming/ExecStatusIndicatorIcon.tsx b/frontend/app/components/Gaming/ExecStatusIndicatorIcon.tsx index b611c5d..a717a48 100644 --- a/frontend/app/components/Gaming/ExecStatusIndicatorIcon.tsx +++ b/frontend/app/components/Gaming/ExecStatusIndicatorIcon.tsx @@ -6,7 +6,7 @@ import { faRotate, } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import type { ExecResultStatus } from "../../models/ExecResult"; +import type { ExecResultStatus } from "../../types/ExecResult"; type Props = { status: ExecResultStatus; diff --git a/frontend/app/components/Gaming/SubmitResult.tsx b/frontend/app/components/Gaming/SubmitResult.tsx index 93e08a7..c626910 100644 --- a/frontend/app/components/Gaming/SubmitResult.tsx +++ b/frontend/app/components/Gaming/SubmitResult.tsx @@ -1,5 +1,5 @@ import React from "react"; -import type { SubmitResult } from "../../models/SubmitResult"; +import type { SubmitResult } from "../../types/SubmitResult"; import BorderedContainer from "../BorderedContainer"; import SubmitStatusLabel from "../SubmitStatusLabel"; import ExecStatusIndicatorIcon from "./ExecStatusIndicatorIcon"; diff --git a/frontend/app/components/GolfPlayApp.client.tsx b/frontend/app/components/GolfPlayApp.client.tsx index 42f0250..80cfc40 100644 --- a/frontend/app/components/GolfPlayApp.client.tsx +++ b/frontend/app/components/GolfPlayApp.client.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react"; import { useDebouncedCallback } from "use-debounce"; import type { components } from "../.server/api/schema"; import useWebSocket, { ReadyState } from "../hooks/useWebSocket"; -import type { PlayerInfo } from "../models/PlayerInfo"; +import type { PlayerState } from "../types/PlayerState"; import GolfPlayAppConnecting from "./GolfPlayApps/GolfPlayAppConnecting"; import GolfPlayAppFinished from "./GolfPlayApps/GolfPlayAppFinished"; import GolfPlayAppGaming from "./GolfPlayApps/GolfPlayAppGaming"; @@ -72,9 +72,12 @@ export default function GolfPlayApp({ } }, [gameState, startedAt, game.duration_seconds]); - const [playerInfo, setPlayerInfo] = useState>({ + const playerProfile = { displayName: player.display_name, iconPath: player.icon_path ?? null, + }; + const [playerState, setPlayerState] = useState({ + code: "", score: null, submitResult: { status: "waiting_submission", @@ -105,7 +108,7 @@ export default function GolfPlayApp({ type: "player:c2s:submit", data: { code }, }); - setPlayerInfo((prev) => ({ + setPlayerState((prev) => ({ ...prev, submitResult: { status: "running", @@ -147,7 +150,7 @@ export default function GolfPlayApp({ } } else if (lastJsonMessage.type === "player:s2c:execresult") { const { testcase_id, status, stdout, stderr } = lastJsonMessage.data; - setPlayerInfo((prev) => { + setPlayerState((prev) => { const ret = { ...prev }; ret.submitResult = { ...prev.submitResult, @@ -166,7 +169,7 @@ export default function GolfPlayApp({ }); } else if (lastJsonMessage.type === "player:s2c:submitresult") { const { status, score } = lastJsonMessage.data; - setPlayerInfo((prev) => { + setPlayerState((prev) => { const ret = { ...prev }; ret.submitResult = { ...prev.submitResult, @@ -228,7 +231,7 @@ export default function GolfPlayApp({ return ( ); } else if (gameState === "starting") { @@ -244,7 +247,10 @@ export default function GolfPlayApp({ gameDisplayName={game.display_name} gameDurationSeconds={game.duration_seconds} leftTimeSeconds={leftTimeSeconds!} - playerInfo={playerInfo} + playerInfo={{ + profile: playerProfile, + state: playerState, + }} problemTitle={game.problem.title} problemDescription={game.problem.description} onCodeChange={onCodeChange} diff --git a/frontend/app/components/GolfPlayApps/GolfPlayAppGaming.tsx b/frontend/app/components/GolfPlayApps/GolfPlayAppGaming.tsx index e6cb7e9..38516bc 100644 --- a/frontend/app/components/GolfPlayApps/GolfPlayAppGaming.tsx +++ b/frontend/app/components/GolfPlayApps/GolfPlayAppGaming.tsx @@ -1,7 +1,7 @@ import { Link } from "@remix-run/react"; import React, { useRef } from "react"; import SubmitButton from "../../components/SubmitButton"; -import type { PlayerInfo } from "../../models/PlayerInfo"; +import type { PlayerInfo } from "../../types/PlayerInfo"; import BorderedContainer from "../BorderedContainer"; import SubmitResult from "../Gaming/SubmitResult"; import UserIcon from "../UserIcon"; @@ -10,7 +10,7 @@ type Props = { gameDisplayName: string; gameDurationSeconds: number; leftTimeSeconds: number; - playerInfo: Omit; + playerInfo: PlayerInfo; problemTitle: string; problemDescription: string; onCodeChange: (code: string) => void; @@ -55,15 +55,15 @@ export default function GolfPlayAppGaming({
-
{playerInfo.score}
+
{playerInfo.state.score}
Player 1
-
{playerInfo.displayName}
+
{playerInfo.profile.displayName}
- {playerInfo.iconPath && ( + {playerInfo.profile.iconPath && ( )} @@ -88,7 +88,7 @@ export default function GolfPlayAppGaming({
提出 diff --git a/frontend/app/components/GolfPlayApps/GolfPlayAppWaiting.tsx b/frontend/app/components/GolfPlayApps/GolfPlayAppWaiting.tsx index a31e5f4..706dc8f 100644 --- a/frontend/app/components/GolfPlayApps/GolfPlayAppWaiting.tsx +++ b/frontend/app/components/GolfPlayApps/GolfPlayAppWaiting.tsx @@ -1,14 +1,14 @@ -import { PlayerInfo } from "../../models/PlayerInfo"; -import PlayerProfile from "../PlayerProfile"; +import type { PlayerProfile } from "../../types/PlayerProfile"; +import PlayerNameAndIcon from "../PlayerNameAndIcon"; type Props = { gameDisplayName: string; - playerInfo: Omit; + playerProfile: PlayerProfile; }; export default function GolfPlayAppWaiting({ gameDisplayName, - playerInfo, + playerProfile, }: Props) { return (
@@ -16,7 +16,7 @@ export default function GolfPlayAppWaiting({
{gameDisplayName}
- +
); diff --git a/frontend/app/components/GolfWatchApp.client.tsx b/frontend/app/components/GolfWatchApp.client.tsx index d09a4ae..9eacb2d 100644 --- a/frontend/app/components/GolfWatchApp.client.tsx +++ b/frontend/app/components/GolfWatchApp.client.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react"; import { AudioController } from "../.client/audio/AudioController"; import type { components } from "../.server/api/schema"; import useWebSocket, { ReadyState } from "../hooks/useWebSocket"; -import type { PlayerInfo } from "../models/PlayerInfo"; +import type { PlayerState } from "../types/PlayerState"; import GolfWatchAppConnecting from "./GolfWatchApps/GolfWatchAppConnecting"; import GolfWatchAppGaming from "./GolfWatchApps/GolfWatchAppGaming"; import GolfWatchAppStarting from "./GolfWatchApps/GolfWatchAppStarting"; @@ -73,12 +73,14 @@ export default function GolfWatchApp({ } }, [gameState, startedAt, game.duration_seconds, audioController]); - const playerA = game.players[0]; - const playerB = game.players[1]; + const playerA = game.players[0]!; + const playerB = game.players[1]!; - const [playerInfoA, setPlayerInfoA] = useState({ - displayName: playerA?.display_name ?? null, - iconPath: playerA?.icon_path ?? null, + const playerProfileA = { + displayName: playerA.display_name, + iconPath: playerA.icon_path ?? null, + }; + const [playerStateA, setPlayerStateA] = useState({ score: null, code: "", submitResult: { @@ -92,9 +94,11 @@ export default function GolfWatchApp({ })), }, }); - const [playerInfoB, setPlayerInfoB] = useState({ - displayName: playerB?.display_name ?? null, - iconPath: playerB?.icon_path ?? null, + const playerProfileB = { + displayName: playerB.display_name, + iconPath: playerB.icon_path ?? null, + }; + const [playerStateB, setPlayerStateB] = useState({ score: null, code: "", submitResult: { @@ -138,12 +142,12 @@ export default function GolfWatchApp({ } else if (lastJsonMessage.type === "watcher:s2c:code") { const { player_id, code } = lastJsonMessage.data; const setter = - player_id === playerA?.user_id ? setPlayerInfoA : setPlayerInfoB; + player_id === playerA.user_id ? setPlayerStateA : setPlayerStateB; setter((prev) => ({ ...prev, code })); } else if (lastJsonMessage.type === "watcher:s2c:submit") { const { player_id } = lastJsonMessage.data; const setter = - player_id === playerA?.user_id ? setPlayerInfoA : setPlayerInfoB; + player_id === playerA.user_id ? setPlayerStateA : setPlayerStateB; setter((prev) => ({ ...prev, submitResult: { @@ -160,7 +164,7 @@ export default function GolfWatchApp({ const { player_id, testcase_id, status, stdout, stderr } = lastJsonMessage.data; const setter = - player_id === playerA?.user_id ? setPlayerInfoA : setPlayerInfoB; + player_id === playerA.user_id ? setPlayerStateA : setPlayerStateB; setter((prev) => { const ret = { ...prev }; ret.submitResult = { @@ -181,7 +185,7 @@ export default function GolfWatchApp({ } else if (lastJsonMessage.type === "watcher:s2c:submitresult") { const { player_id, status, score } = lastJsonMessage.data; const setter = - player_id === playerA?.user_id ? setPlayerInfoA : setPlayerInfoB; + player_id === playerA.user_id ? setPlayerStateA : setPlayerStateB; setter((prev) => { const ret = { ...prev }; ret.submitResult = { @@ -235,8 +239,8 @@ export default function GolfWatchApp({ lastJsonMessage, readyState, gameState, - playerA?.user_id, - playerB?.user_id, + playerA.user_id, + playerB.user_id, ]); if (gameState === "connecting") { @@ -245,8 +249,8 @@ export default function GolfWatchApp({ return ( ); } else if (gameState === "starting") { @@ -262,8 +266,14 @@ export default function GolfWatchApp({ gameDisplayName={game.display_name} gameDurationSeconds={game.duration_seconds} leftTimeSeconds={leftTimeSeconds!} - playerInfoA={playerInfoA} - playerInfoB={playerInfoB} + playerInfoA={{ + profile: playerProfileA, + state: playerStateA, + }} + playerInfoB={{ + profile: playerProfileB, + state: playerStateB, + }} problemTitle={game.problem.title} problemDescription={game.problem.description} gameResult={null /* TODO */} diff --git a/frontend/app/components/GolfWatchApps/GolfWatchAppGaming.tsx b/frontend/app/components/GolfWatchApps/GolfWatchAppGaming.tsx index 28babff..a23a972 100644 --- a/frontend/app/components/GolfWatchApps/GolfWatchAppGaming.tsx +++ b/frontend/app/components/GolfWatchApps/GolfWatchAppGaming.tsx @@ -1,4 +1,4 @@ -import { PlayerInfo } from "../../models/PlayerInfo"; +import type { PlayerInfo } from "../../types/PlayerInfo"; import BorderedContainer from "../BorderedContainer"; import CodeBlock from "../Gaming/CodeBlock"; import ScoreBar from "../Gaming/ScoreBar"; @@ -49,43 +49,43 @@ export default function GolfWatchAppGaming({
- {playerInfoA.iconPath && ( + {playerInfoA.profile.iconPath && ( )}
Player 1
-
{playerInfoA.displayName}
+
{playerInfoA.profile.displayName}
-
{playerInfoA.score}
+
{playerInfoA.state.score}
{gameDisplayName}
{gameResult ? gameResult === "winA" - ? `勝者 ${playerInfoA.displayName}` + ? `勝者 ${playerInfoA.profile.displayName}` : gameResult === "winB" - ? `勝者 ${playerInfoB.displayName}` + ? `勝者 ${playerInfoB.profile.displayName}` : "引き分け" : leftTime}
-
{playerInfoB.score}
+
{playerInfoB.state.score}
Player 2
-
{playerInfoB.displayName}
+
{playerInfoB.profile.displayName}
- {playerInfoB.iconPath && ( + {playerInfoB.profile.iconPath && ( )} @@ -93,17 +93,17 @@ export default function GolfWatchAppGaming({
- +
- - + +
@@ -112,7 +112,7 @@ export default function GolfWatchAppGaming({ {problemDescription}
- +
); diff --git a/frontend/app/components/GolfWatchApps/GolfWatchAppWaiting.tsx b/frontend/app/components/GolfWatchApps/GolfWatchAppWaiting.tsx index faa9485..0e964e3 100644 --- a/frontend/app/components/GolfWatchApps/GolfWatchAppWaiting.tsx +++ b/frontend/app/components/GolfWatchApps/GolfWatchAppWaiting.tsx @@ -1,18 +1,16 @@ -import { PlayerInfo as FullPlayerInfo } from "../../models/PlayerInfo"; -import PlayerProfile from "../PlayerProfile"; - -type PlayerInfo = Pick; +import type { PlayerProfile } from "../../types/PlayerProfile"; +import PlayerNameAndIcon from "../PlayerNameAndIcon"; type Props = { gameDisplayName: string; - playerInfoA: PlayerInfo; - playerInfoB: PlayerInfo; + playerProfileA: PlayerProfile; + playerProfileB: PlayerProfile; }; export default function GolfWatchAppWaiting({ gameDisplayName, - playerInfoA, - playerInfoB, + playerProfileA, + playerProfileB, }: Props) { return (
@@ -20,9 +18,9 @@ export default function GolfWatchAppWaiting({
{gameDisplayName}
- +
vs.
- +
); diff --git a/frontend/app/components/PlayerNameAndIcon.tsx b/frontend/app/components/PlayerNameAndIcon.tsx new file mode 100644 index 0000000..e9536e3 --- /dev/null +++ b/frontend/app/components/PlayerNameAndIcon.tsx @@ -0,0 +1,25 @@ +import { PlayerProfile } from "../types/PlayerProfile"; +import UserIcon from "./UserIcon"; + +type Props = { + label: string; + profile: PlayerProfile; +}; + +export default function PlayerNameAndIcon({ label, profile }: Props) { + return ( +
+
+
{label}
+
{profile.displayName}
+
+ {profile.iconPath && ( + + )} +
+ ); +} diff --git a/frontend/app/components/PlayerProfile.tsx b/frontend/app/components/PlayerProfile.tsx deleted file mode 100644 index 675d77b..0000000 --- a/frontend/app/components/PlayerProfile.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { PlayerInfo as FullPlayerInfo } from "../models/PlayerInfo"; -import UserIcon from "./UserIcon"; - -type PlayerInfo = Pick; - -type Props = { - playerInfo: PlayerInfo; - label: string; -}; - -export default function PlayerProfile({ playerInfo, label }: Props) { - return ( -
-
-
{label}
-
{playerInfo.displayName}
-
- {playerInfo.iconPath && ( - - )} -
- ); -} diff --git a/frontend/app/components/SubmitStatusLabel.tsx b/frontend/app/components/SubmitStatusLabel.tsx index 0e13c1e..d1dc89c 100644 --- a/frontend/app/components/SubmitStatusLabel.tsx +++ b/frontend/app/components/SubmitStatusLabel.tsx @@ -1,4 +1,4 @@ -import type { SubmitResultStatus } from "../models/SubmitResult"; +import type { SubmitResultStatus } from "../types/SubmitResult"; type Props = { status: SubmitResultStatus; diff --git a/frontend/app/models/ExecResult.ts b/frontend/app/models/ExecResult.ts deleted file mode 100644 index e0b6bb4..0000000 --- a/frontend/app/models/ExecResult.ts +++ /dev/null @@ -1,18 +0,0 @@ -export type ExecResultStatus = - | "waiting_submission" - | "running" - | "success" - | "wrong_answer" - | "timeout" - | "compile_error" - | "runtime_error" - | "internal_error" - | "canceled"; - -export type ExecResult = { - testcase_id: number | null; - status: ExecResultStatus; - label: string; - stdout: string; - stderr: string; -}; diff --git a/frontend/app/models/PlayerInfo.ts b/frontend/app/models/PlayerInfo.ts deleted file mode 100644 index 8092ab3..0000000 --- a/frontend/app/models/PlayerInfo.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { SubmitResult } from "./SubmitResult"; - -export type PlayerInfo = { - displayName: string | null; - iconPath: string | null; - score: number | null; - code: string | null; - submitResult: SubmitResult; -}; diff --git a/frontend/app/models/SubmitResult.ts b/frontend/app/models/SubmitResult.ts deleted file mode 100644 index 6df00b6..0000000 --- a/frontend/app/models/SubmitResult.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { ExecResult } from "./ExecResult"; - -export type SubmitResultStatus = - | "waiting_submission" - | "running" - | "success" - | "wrong_answer" - | "timeout" - | "compile_error" - | "runtime_error" - | "internal_error"; - -export type SubmitResult = { - status: SubmitResultStatus; - execResults: ExecResult[]; -}; diff --git a/frontend/app/types/ExecResult.ts b/frontend/app/types/ExecResult.ts new file mode 100644 index 0000000..e0b6bb4 --- /dev/null +++ b/frontend/app/types/ExecResult.ts @@ -0,0 +1,18 @@ +export type ExecResultStatus = + | "waiting_submission" + | "running" + | "success" + | "wrong_answer" + | "timeout" + | "compile_error" + | "runtime_error" + | "internal_error" + | "canceled"; + +export type ExecResult = { + testcase_id: number | null; + status: ExecResultStatus; + label: string; + stdout: string; + stderr: string; +}; diff --git a/frontend/app/types/PlayerInfo.ts b/frontend/app/types/PlayerInfo.ts new file mode 100644 index 0000000..e282ba9 --- /dev/null +++ b/frontend/app/types/PlayerInfo.ts @@ -0,0 +1,7 @@ +import type { PlayerProfile } from "./PlayerProfile"; +import type { PlayerState } from "./PlayerState"; + +export type PlayerInfo = { + profile: PlayerProfile; + state: PlayerState; +}; diff --git a/frontend/app/types/PlayerProfile.ts b/frontend/app/types/PlayerProfile.ts new file mode 100644 index 0000000..42bdcb8 --- /dev/null +++ b/frontend/app/types/PlayerProfile.ts @@ -0,0 +1,4 @@ +export type PlayerProfile = { + displayName: string; + iconPath: string | null; +}; diff --git a/frontend/app/types/PlayerState.ts b/frontend/app/types/PlayerState.ts new file mode 100644 index 0000000..e2a2da9 --- /dev/null +++ b/frontend/app/types/PlayerState.ts @@ -0,0 +1,7 @@ +import type { SubmitResult } from "./SubmitResult"; + +export type PlayerState = { + score: number | null; + code: string; + submitResult: SubmitResult; +}; diff --git a/frontend/app/types/SubmitResult.ts b/frontend/app/types/SubmitResult.ts new file mode 100644 index 0000000..6df00b6 --- /dev/null +++ b/frontend/app/types/SubmitResult.ts @@ -0,0 +1,16 @@ +import type { ExecResult } from "./ExecResult"; + +export type SubmitResultStatus = + | "waiting_submission" + | "running" + | "success" + | "wrong_answer" + | "timeout" + | "compile_error" + | "runtime_error" + | "internal_error"; + +export type SubmitResult = { + status: SubmitResultStatus; + execResults: ExecResult[]; +}; -- cgit v1.2.3-70-g09d2 From a2b6ed9cd67f1406a6656bce9b3d51b55378ac1e Mon Sep 17 00:00:00 2001 From: nsfisis Date: Thu, 22 Aug 2024 03:57:05 +0900 Subject: feat(frontend): jotai for play app --- frontend/app/components/GolfPlayApp.client.tsx | 246 +++++++-------------- .../GolfPlayApps/GolfPlayAppFinished.tsx | 2 +- .../components/GolfPlayApps/GolfPlayAppGaming.tsx | 42 ++-- .../GolfPlayApps/GolfPlayAppStarting.tsx | 13 +- frontend/app/routes/golf.$gameId.play.tsx | 97 +++++++- frontend/app/states/play.ts | 185 ++++++++++++++++ frontend/app/states/watch.ts | 0 frontend/package-lock.json | 22 ++ frontend/package.json | 1 + 9 files changed, 416 insertions(+), 192 deletions(-) create mode 100644 frontend/app/states/play.ts create mode 100644 frontend/app/states/watch.ts diff --git a/frontend/app/components/GolfPlayApp.client.tsx b/frontend/app/components/GolfPlayApp.client.tsx index 80cfc40..0230426 100644 --- a/frontend/app/components/GolfPlayApp.client.tsx +++ b/frontend/app/components/GolfPlayApp.client.tsx @@ -1,8 +1,20 @@ -import { useEffect, useState } from "react"; +import { useAtomValue, useSetAtom } from "jotai"; +import { useEffect } from "react"; +import { useTimer } from "react-use-precision-timer"; import { useDebouncedCallback } from "use-debounce"; import type { components } from "../.server/api/schema"; import useWebSocket, { ReadyState } from "../hooks/useWebSocket"; -import type { PlayerState } from "../types/PlayerState"; +import { + gameStartAtom, + gameStateKindAtom, + handleSubmitCodeAtom, + handleWsConnectionClosedAtom, + handleWsExecResultMessageAtom, + handleWsSubmitResultMessageAtom, + setCurrentTimestampAtom, + setGameStateConnectingAtom, + setGameStateWaitingAtom, +} from "../states/play"; import GolfPlayAppConnecting from "./GolfPlayApps/GolfPlayAppConnecting"; import GolfPlayAppFinished from "./GolfPlayApps/GolfPlayAppFinished"; import GolfPlayAppGaming from "./GolfPlayApps/GolfPlayAppGaming"; @@ -15,81 +27,47 @@ type GamePlayerMessageC2S = components["schemas"]["GamePlayerMessageC2S"]; type Game = components["schemas"]["Game"]; type User = components["schemas"]["User"]; -type GameState = "connecting" | "waiting" | "starting" | "gaming" | "finished"; +type Props = { + game: Game; + player: User; + initialCode: string; + sockToken: string; +}; export default function GolfPlayApp({ game, player, + initialCode, sockToken, -}: { - game: Game; - player: User; - sockToken: string; -}) { +}: Props) { const socketUrl = process.env.NODE_ENV === "development" ? `ws://localhost:8002/iosdc-japan/2024/code-battle/sock/golf/${game.game_id}/play?token=${sockToken}` : `wss://t.nil.ninja/iosdc-japan/2024/code-battle/sock/golf/${game.game_id}/play?token=${sockToken}`; + const gameStateKind = useAtomValue(gameStateKindAtom); + const setCurrentTimestamp = useSetAtom(setCurrentTimestampAtom); + const gameStart = useSetAtom(gameStartAtom); + const setGameStateConnecting = useSetAtom(setGameStateConnectingAtom); + const setGameStateWaiting = useSetAtom(setGameStateWaitingAtom); + const handleWsConnectionClosed = useSetAtom(handleWsConnectionClosedAtom); + const handleWsExecResultMessage = useSetAtom(handleWsExecResultMessageAtom); + const handleWsSubmitResultMessage = useSetAtom( + handleWsSubmitResultMessageAtom, + ); + const handleSubmitCode = useSetAtom(handleSubmitCodeAtom); + + useTimer({ delay: 1000, startImmediately: true }, setCurrentTimestamp); + const { sendJsonMessage, lastJsonMessage, readyState } = useWebSocket< GamePlayerMessageS2C, GamePlayerMessageC2S >(socketUrl); - const [gameState, setGameState] = useState("connecting"); - - const [startedAt, setStartedAt] = useState(null); - - const [leftTimeSeconds, setLeftTimeSeconds] = useState(null); - - useEffect(() => { - if ( - (gameState === "starting" || gameState === "gaming") && - startedAt !== null - ) { - const timer = setInterval(() => { - setLeftTimeSeconds((prev) => { - if (prev === null) { - return null; - } - if (prev <= 1) { - const nowSec = Math.floor(Date.now() / 1000); - const finishedAt = startedAt + game.duration_seconds; - if (nowSec >= finishedAt) { - clearInterval(timer); - setGameState("finished"); - } else { - setGameState("gaming"); - } - } - return prev - 1; - }); - }, 1000); - - return () => { - clearInterval(timer); - }; - } - }, [gameState, startedAt, game.duration_seconds]); - const playerProfile = { displayName: player.display_name, iconPath: player.icon_path ?? null, }; - const [playerState, setPlayerState] = useState({ - code: "", - score: null, - submitResult: { - status: "waiting_submission", - execResults: game.exec_steps.map((r) => ({ - testcase_id: r.testcase_id, - status: "waiting_submission", - label: r.label, - stdout: "", - stderr: "", - })), - }, - }); const onCodeChange = useDebouncedCallback((code: string) => { console.log("player:c2s:code"); @@ -97,6 +75,8 @@ export default function GolfPlayApp({ type: "player:c2s:code", data: { code }, }); + const baseKey = `playerState:${game.game_id}:${player.user_id}`; + window.localStorage.setItem(`${baseKey}:code`, code); }, 1000); const onCodeSubmit = useDebouncedCallback((code: string) => { @@ -108,18 +88,7 @@ export default function GolfPlayApp({ type: "player:c2s:submit", data: { code }, }); - setPlayerState((prev) => ({ - ...prev, - submitResult: { - status: "running", - execResults: prev.submitResult.execResults.map((r) => ({ - ...r, - status: "running", - stdout: "", - stderr: "", - })), - }, - })); + handleSubmitCode(); }, 1000); if (readyState === ReadyState.UNINSTANTIATED) { @@ -128,136 +97,89 @@ export default function GolfPlayApp({ useEffect(() => { if (readyState === ReadyState.CLOSING || readyState === ReadyState.CLOSED) { - if (gameState !== "finished") { - setGameState("connecting"); - } + handleWsConnectionClosed(); } else if (readyState === ReadyState.CONNECTING) { - setGameState("connecting"); + setGameStateConnecting(); } else if (readyState === ReadyState.OPEN) { if (lastJsonMessage !== null) { console.log(lastJsonMessage.type); 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); - setLeftTimeSeconds(start_at - nowSec); - setGameState("starting"); - } + const { start_at } = lastJsonMessage.data; + gameStart(start_at); } else if (lastJsonMessage.type === "player:s2c:execresult") { - const { testcase_id, status, stdout, stderr } = lastJsonMessage.data; - setPlayerState((prev) => { - const ret = { ...prev }; - ret.submitResult = { - ...prev.submitResult, - execResults: prev.submitResult.execResults.map((r) => - r.testcase_id === testcase_id && r.status === "running" - ? { - ...r, - status, - stdout, - stderr, - } - : r, - ), - }; - return ret; - }); + handleWsExecResultMessage( + lastJsonMessage.data, + (submissionResult) => { + const baseKey = `playerState:${game.game_id}:${player.user_id}`; + window.localStorage.setItem( + `${baseKey}:submissionResult`, + JSON.stringify(submissionResult), + ); + }, + ); } else if (lastJsonMessage.type === "player:s2c:submitresult") { - const { status, score } = lastJsonMessage.data; - setPlayerState((prev) => { - const ret = { ...prev }; - ret.submitResult = { - ...prev.submitResult, - status, - }; - if (status === "success") { - if (score) { - if (ret.score === null || score < ret.score) { - ret.score = score; - } - } - } else { - ret.submitResult.execResults = prev.submitResult.execResults.map( - (r) => - r.status === "running" ? { ...r, status: "canceled" } : r, + handleWsSubmitResultMessage( + lastJsonMessage.data, + (submissionResult, score) => { + const baseKey = `playerState:${game.game_id}:${player.user_id}`; + window.localStorage.setItem( + `${baseKey}:submissionResult`, + JSON.stringify(submissionResult), + ); + window.localStorage.setItem( + `${baseKey}:score`, + score === null ? "" : score.toString(), ); - } - return ret; - }); + }, + ); } } else { if (game.started_at) { - const nowSec = Math.floor(Date.now() / 1000); - if (game.started_at <= nowSec) { - // The game has already started. - if (gameState !== "gaming" && gameState !== "finished") { - setStartedAt(game.started_at); - setLeftTimeSeconds(game.started_at - nowSec); - setGameState("gaming"); - } - } else { - // The game is starting. - if ( - gameState !== "starting" && - gameState !== "gaming" && - gameState !== "finished" - ) { - setStartedAt(game.started_at); - setLeftTimeSeconds(game.started_at - nowSec); - setGameState("starting"); - } - } + gameStart(game.started_at); } else { - setGameState("waiting"); + setGameStateWaiting(); } } } }, [ + game.game_id, game.started_at, + player.user_id, sendJsonMessage, lastJsonMessage, readyState, - gameState, + gameStart, + handleWsConnectionClosed, + handleWsExecResultMessage, + handleWsSubmitResultMessage, + setGameStateConnecting, + setGameStateWaiting, ]); - if (gameState === "connecting") { + if (gameStateKind === "connecting") { return ; - } else if (gameState === "waiting") { + } else if (gameStateKind === "waiting") { return ( ); - } else if (gameState === "starting") { - return ( - - ); - } else if (gameState === "gaming") { + } else if (gameStateKind === "starting") { + return ; + } else if (gameStateKind === "gaming") { return ( ); - } else if (gameState === "finished") { + } else if (gameStateKind === "finished") { return ; } else { return null; diff --git a/frontend/app/components/GolfPlayApps/GolfPlayAppFinished.tsx b/frontend/app/components/GolfPlayApps/GolfPlayAppFinished.tsx index c3ef2d4..c218414 100644 --- a/frontend/app/components/GolfPlayApps/GolfPlayAppFinished.tsx +++ b/frontend/app/components/GolfPlayApps/GolfPlayAppFinished.tsx @@ -2,7 +2,7 @@ export default function GolfPlayAppFinished() { return (
-

Finished

+
終了
); diff --git a/frontend/app/components/GolfPlayApps/GolfPlayAppGaming.tsx b/frontend/app/components/GolfPlayApps/GolfPlayAppGaming.tsx index 38516bc..0aa6b3d 100644 --- a/frontend/app/components/GolfPlayApps/GolfPlayAppGaming.tsx +++ b/frontend/app/components/GolfPlayApps/GolfPlayAppGaming.tsx @@ -1,32 +1,40 @@ import { Link } from "@remix-run/react"; +import { useAtomValue } from "jotai"; import React, { useRef } from "react"; import SubmitButton from "../../components/SubmitButton"; -import type { PlayerInfo } from "../../types/PlayerInfo"; +import { + gamingLeftTimeSecondsAtom, + scoreAtom, + submitResultAtom, +} from "../../states/play"; +import type { PlayerProfile } from "../../types/PlayerProfile"; import BorderedContainer from "../BorderedContainer"; import SubmitResult from "../Gaming/SubmitResult"; import UserIcon from "../UserIcon"; type Props = { gameDisplayName: string; - gameDurationSeconds: number; - leftTimeSeconds: number; - playerInfo: PlayerInfo; + playerProfile: PlayerProfile; problemTitle: string; problemDescription: string; + initialCode: string; onCodeChange: (code: string) => void; onCodeSubmit: (code: string) => void; }; export default function GolfPlayAppGaming({ gameDisplayName, - gameDurationSeconds, - leftTimeSeconds, - playerInfo, + playerProfile, problemTitle, problemDescription, + initialCode, onCodeChange, onCodeSubmit, }: Props) { + const leftTimeSeconds = useAtomValue(gamingLeftTimeSecondsAtom)!; + const score = useAtomValue(scoreAtom); + const submitResult = useAtomValue(submitResultAtom); + const textareaRef = useRef(null); const handleTextChange = (e: React.ChangeEvent) => { @@ -40,9 +48,8 @@ export default function GolfPlayAppGaming({ }; const leftTime = (() => { - const k = gameDurationSeconds + leftTimeSeconds; - const m = Math.floor(k / 60); - const s = k % 60; + const m = Math.floor(leftTimeSeconds / 60); + const s = leftTimeSeconds % 60; return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`; })(); @@ -55,15 +62,15 @@ export default function GolfPlayAppGaming({
-
{playerInfo.state.score}
+
{score}
Player 1
-
{playerInfo.profile.displayName}
+
{playerProfile.displayName}
- {playerInfo.profile.iconPath && ( + {playerProfile.iconPath && ( )} @@ -82,13 +89,14 @@ export default function GolfPlayAppGaming({
+ />
提出 diff --git a/frontend/app/components/GolfPlayApps/GolfPlayAppStarting.tsx b/frontend/app/components/GolfPlayApps/GolfPlayAppStarting.tsx index a42c883..b41dfed 100644 --- a/frontend/app/components/GolfPlayApps/GolfPlayAppStarting.tsx +++ b/frontend/app/components/GolfPlayApps/GolfPlayAppStarting.tsx @@ -1,18 +1,19 @@ +import { useAtomValue } from "jotai"; +import { startingLeftTimeSecondsAtom } from "../../states/play"; + type Props = { gameDisplayName: string; - leftTimeSeconds: number; }; -export default function GolfPlayAppStarting({ - gameDisplayName, - leftTimeSeconds, -}: Props) { +export default function GolfPlayAppStarting({ gameDisplayName }: Props) { + const leftTimeSeconds = useAtomValue(startingLeftTimeSecondsAtom)!; + return (
{gameDisplayName}
-
+
{leftTimeSeconds}
diff --git a/frontend/app/routes/golf.$gameId.play.tsx b/frontend/app/routes/golf.$gameId.play.tsx index ea1b8fd..a2860dd 100644 --- a/frontend/app/routes/golf.$gameId.play.tsx +++ b/frontend/app/routes/golf.$gameId.play.tsx @@ -1,10 +1,17 @@ import type { LoaderFunctionArgs, MetaFunction } from "@remix-run/node"; -import { useLoaderData } from "@remix-run/react"; -import { ClientOnly } from "remix-utils/client-only"; +import { ClientLoaderFunctionArgs, useLoaderData } from "@remix-run/react"; +import { useHydrateAtoms } from "jotai/utils"; import { apiGetGame, apiGetToken } from "../.server/api/client"; import { ensureUserLoggedIn } from "../.server/auth"; import GolfPlayApp from "../components/GolfPlayApp.client"; import GolfPlayAppConnecting from "../components/GolfPlayApps/GolfPlayAppConnecting"; +import { + scoreAtom, + setCurrentTimestampAtom, + setDurationSecondsAtom, + submitResultAtom, +} from "../states/play"; +import { PlayerState } from "../types/PlayerState"; export const meta: MetaFunction = ({ data }) => [ { @@ -25,19 +32,97 @@ export async function loader({ params, request }: LoaderFunctionArgs) { }; const [game, sockToken] = await Promise.all([fetchGame(), fetchSockToken()]); + + const playerState: PlayerState = { + code: "", + score: null, + submitResult: { + status: "waiting_submission", + execResults: game.exec_steps.map((r) => ({ + testcase_id: r.testcase_id, + status: "waiting_submission", + label: r.label, + stdout: "", + stderr: "", + })), + }, + }; + return { game, player: user, sockToken, + playerState, }; } +export async function clientLoader({ serverLoader }: ClientLoaderFunctionArgs) { + const data = await serverLoader(); + const baseKey = `playerState:${data.game.game_id}:${data.player.user_id}`; + + const localCode = (() => { + const rawValue = window.localStorage.getItem(`${baseKey}:code`); + if (rawValue === null) { + return null; + } + return rawValue; + })(); + + const localScore = (() => { + const rawValue = window.localStorage.getItem(`${baseKey}:score`); + if (rawValue === null || rawValue === "") { + return null; + } + return Number(rawValue); + })(); + + const localSubmissionResult = (() => { + const rawValue = window.localStorage.getItem(`${baseKey}:submissionResult`); + if (rawValue === null) { + return null; + } + const parsed = JSON.parse(rawValue); + if (typeof parsed !== "object") { + return null; + } + return parsed; + })(); + + if (localCode !== null) { + data.playerState.code = localCode; + } + if (localScore !== null) { + data.playerState.score = localScore; + } + if (localSubmissionResult !== null) { + data.playerState.submitResult = localSubmissionResult; + } + + return data; +} +clientLoader.hydrate = true; + +export function HydrateFallback() { + return ; +} + export default function GolfPlay() { - const { game, player, sockToken } = useLoaderData(); + const { game, player, sockToken, playerState } = + useLoaderData(); + + useHydrateAtoms([ + [setCurrentTimestampAtom, undefined], + [setDurationSecondsAtom, game.duration_seconds], + [scoreAtom, playerState.score], + [submitResultAtom, playerState.submitResult], + ]); return ( - }> - {() => } - + ); } diff --git a/frontend/app/states/play.ts b/frontend/app/states/play.ts new file mode 100644 index 0000000..13bd39f --- /dev/null +++ b/frontend/app/states/play.ts @@ -0,0 +1,185 @@ +import { atom } from "jotai"; +import type { components } from "../.server/api/schema"; +import type { SubmitResult } from "../types/SubmitResult"; + +type RawGameState = + | { + kind: "connecting"; + startedAtTimestamp: null; + } + | { + kind: "waiting"; + startedAtTimestamp: null; + } + | { + kind: "starting"; + startedAtTimestamp: number; + }; + +const rawGameStateAtom = atom({ + kind: "connecting", + startedAtTimestamp: null, +}); + +export type GameStateKind = + | "connecting" + | "waiting" + | "starting" + | "gaming" + | "finished"; + +export const gameStateKindAtom = atom((get) => { + const { kind: rawKind, startedAtTimestamp } = get(rawGameStateAtom); + if (rawKind === "connecting" || rawKind === "waiting") { + return rawKind; + } else { + const durationSeconds = get(rawDurationSecondsAtom); + const finishedAtTimestamp = startedAtTimestamp + durationSeconds; + const currentTimestamp = get(rawCurrentTimestampAtom); + if (currentTimestamp < startedAtTimestamp) { + return "starting"; + } else if (currentTimestamp < finishedAtTimestamp) { + return "gaming"; + } else { + return "finished"; + } + } +}); + +export const gameStartAtom = atom(null, (get, set, value: number) => { + const { kind } = get(rawGameStateAtom); + if (kind === "starting") { + return; + } + set(rawGameStateAtom, { + kind: "starting", + startedAtTimestamp: value, + }); +}); +export const setGameStateConnectingAtom = atom(null, (_, set) => + set(rawGameStateAtom, { kind: "connecting", startedAtTimestamp: null }), +); +export const setGameStateWaitingAtom = atom(null, (_, set) => + set(rawGameStateAtom, { kind: "waiting", startedAtTimestamp: null }), +); + +const rawCurrentTimestampAtom = atom(0); +export const setCurrentTimestampAtom = atom(null, (_, set) => + set(rawCurrentTimestampAtom, Math.floor(Date.now() / 1000)), +); + +const rawDurationSecondsAtom = atom(0); +export const setDurationSecondsAtom = atom(null, (_, set, value: number) => + set(rawDurationSecondsAtom, value), +); + +export const startingLeftTimeSecondsAtom = atom((get) => { + const { startedAtTimestamp } = get(rawGameStateAtom); + if (startedAtTimestamp === null) { + return null; + } + const currentTimestamp = get(rawCurrentTimestampAtom); + return Math.max(0, startedAtTimestamp - currentTimestamp); +}); + +export const gamingLeftTimeSecondsAtom = atom((get) => { + const { startedAtTimestamp } = get(rawGameStateAtom); + if (startedAtTimestamp === null) { + return null; + } + const durationSeconds = get(rawDurationSecondsAtom); + const finishedAtTimestamp = startedAtTimestamp + durationSeconds; + const currentTimestamp = get(rawCurrentTimestampAtom); + return Math.min( + durationSeconds, + Math.max(0, finishedAtTimestamp - currentTimestamp), + ); +}); + +export const handleWsConnectionClosedAtom = atom(null, (get, set) => { + const kind = get(gameStateKindAtom); + if (kind !== "finished") { + set(setGameStateConnectingAtom); + } +}); + +export const scoreAtom = atom(null); +export const submitResultAtom = atom({ + status: "waiting_submission", + execResults: [], +}); + +export const handleSubmitCodeAtom = atom(null, (_, set) => { + set(submitResultAtom, (prev) => ({ + status: "running", + execResults: prev.execResults.map((r) => ({ + ...r, + status: "running", + stdout: "", + stderr: "", + })), + })); +}); + +type GamePlayerMessageS2CExecResultPayload = + components["schemas"]["GamePlayerMessageS2CExecResultPayload"]; +type GamePlayerMessageS2CSubmitResultPayload = + components["schemas"]["GamePlayerMessageS2CSubmitResultPayload"]; + +export const handleWsExecResultMessageAtom = atom( + null, + ( + get, + set, + data: GamePlayerMessageS2CExecResultPayload, + callback: (submissionResult: SubmitResult) => void, + ) => { + const { testcase_id, status, stdout, stderr } = data; + const prev = get(submitResultAtom); + const newResult = { + ...prev, + execResults: prev.execResults.map((r) => + r.testcase_id === testcase_id && r.status === "running" + ? { + ...r, + status, + stdout, + stderr, + } + : r, + ), + }; + set(submitResultAtom, newResult); + callback(newResult); + }, +); + +export const handleWsSubmitResultMessageAtom = atom( + null, + ( + get, + set, + data: GamePlayerMessageS2CSubmitResultPayload, + callback: (submissionResult: SubmitResult, score: number | null) => void, + ) => { + const { status, score } = data; + const prev = get(submitResultAtom); + const newResult = { + ...prev, + status, + }; + if (status !== "success") { + newResult.execResults = prev.execResults.map((r) => + r.status === "running" ? { ...r, status: "canceled" } : r, + ); + } + set(submitResultAtom, newResult); + if (status === "success" && score !== null) { + const currentScore = get(scoreAtom); + if (currentScore === null || score < currentScore) { + set(scoreAtom, score); + } + } + callback(newResult, score); + }, +); diff --git a/frontend/app/states/watch.ts b/frontend/app/states/watch.ts new file mode 100644 index 0000000..e69de29 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6953cef..538ceeb 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -21,6 +21,7 @@ "prismjs": "^1.29.0", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-use-precision-timer": "^3.5.5", "react-use-websocket": "^4.8.1", "remix-auth": "^3.7.0", "remix-auth-form": "^1.5.0", @@ -9232,6 +9233,27 @@ "react-dom": ">=16.8" } }, + "node_modules/react-sub-unsub": { + "version": "2.2.7", + "resolved": "https://registry.npmjs.org/react-sub-unsub/-/react-sub-unsub-2.2.7.tgz", + "integrity": "sha512-b2o0mIW8G4Yb3aaKxFB9iiCCHxCDGmogy+493oQpEJHjBy/hl6uf+6RhAinqKWRwi1fvO6mGIMVGsf2XYLL38g==", + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^20.0.0 || ^21.0.0 || ^22.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^20.0.0 || ^21.0.0 || ^22.0.0" + } + }, + "node_modules/react-use-precision-timer": { + "version": "3.5.5", + "resolved": "https://registry.npmjs.org/react-use-precision-timer/-/react-use-precision-timer-3.5.5.tgz", + "integrity": "sha512-fPf9d1fAb4CCJrJCnErvvB/GFVDm+bzb07WilkiW3hcJUjqS3ep6pCLKUguT76gpPvyOuKp9KSD8z06uM3LzAA==", + "dependencies": { + "react-sub-unsub": "^2.2.2" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^20.0.0 || ^21.0.0 || ^22.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^20.0.0 || ^21.0.0 || ^22.0.0" + } + }, "node_modules/react-use-websocket": { "version": "4.8.1", "resolved": "https://registry.npmjs.org/react-use-websocket/-/react-use-websocket-4.8.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 1238349..bb11725 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -29,6 +29,7 @@ "prismjs": "^1.29.0", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-use-precision-timer": "^3.5.5", "react-use-websocket": "^4.8.1", "remix-auth": "^3.7.0", "remix-auth-form": "^1.5.0", -- cgit v1.2.3-70-g09d2 From c6ab1db6688f26880503e59b165cba4849f924be Mon Sep 17 00:00:00 2001 From: nsfisis Date: Thu, 22 Aug 2024 12:09:04 +0900 Subject: feat(frontend): jotai for watch app --- frontend/app/components/GolfWatchApp.client.tsx | 295 ++++++++------------- .../GolfWatchApps/GolfWatchAppGaming.tsx | 76 +++--- .../GolfWatchApps/GolfWatchAppStarting.tsx | 13 +- frontend/app/routes/golf.$gameId.watch.tsx | 162 ++++++++++- frontend/app/states/watch.ts | 247 +++++++++++++++++ 5 files changed, 553 insertions(+), 240 deletions(-) diff --git a/frontend/app/components/GolfWatchApp.client.tsx b/frontend/app/components/GolfWatchApp.client.tsx index 9eacb2d..7ea2ced 100644 --- a/frontend/app/components/GolfWatchApp.client.tsx +++ b/frontend/app/components/GolfWatchApp.client.tsx @@ -1,8 +1,21 @@ -import { useEffect, useState } from "react"; +import { useAtomValue, useSetAtom } from "jotai"; +import { useCallback, useEffect } from "react"; +import { useTimer } from "react-use-precision-timer"; import { AudioController } from "../.client/audio/AudioController"; import type { components } from "../.server/api/schema"; import useWebSocket, { ReadyState } from "../hooks/useWebSocket"; -import type { PlayerState } from "../types/PlayerState"; +import { + gameStartAtom, + gameStateKindAtom, + handleWsCodeMessageAtom, + handleWsConnectionClosedAtom, + handleWsExecResultMessageAtom, + handleWsSubmitMessageAtom, + handleWsSubmitResultMessageAtom, + setCurrentTimestampAtom, + setGameStateConnectingAtom, + setGameStateWaitingAtom, +} from "../states/watch"; import GolfWatchAppConnecting from "./GolfWatchApps/GolfWatchAppConnecting"; import GolfWatchAppGaming from "./GolfWatchApps/GolfWatchAppGaming"; import GolfWatchAppStarting from "./GolfWatchApps/GolfWatchAppStarting"; @@ -13,8 +26,6 @@ type GameWatcherMessageC2S = never; type Game = components["schemas"]["Game"]; -type GameState = "connecting" | "waiting" | "starting" | "gaming" | "finished"; - export type Props = { game: Game; sockToken: string; @@ -31,87 +42,47 @@ export default function GolfWatchApp({ ? `ws://localhost:8002/iosdc-japan/2024/code-battle/sock/golf/${game.game_id}/watch?token=${sockToken}` : `wss://t.nil.ninja/iosdc-japan/2024/code-battle/sock/golf/${game.game_id}/watch?token=${sockToken}`; + const gameStateKind = useAtomValue(gameStateKindAtom); + const setCurrentTimestamp = useSetAtom(setCurrentTimestampAtom); + const gameStart = useSetAtom(gameStartAtom); + const setGameStateConnecting = useSetAtom(setGameStateConnectingAtom); + const setGameStateWaiting = useSetAtom(setGameStateWaitingAtom); + const handleWsConnectionClosed = useSetAtom(handleWsConnectionClosedAtom); + const handleWsCodeMessage = useSetAtom(handleWsCodeMessageAtom); + const handleWsSubmitMessage = useSetAtom(handleWsSubmitMessageAtom); + const handleWsExecResultMessage = useSetAtom(handleWsExecResultMessageAtom); + const handleWsSubmitResultMessage = useSetAtom( + handleWsSubmitResultMessageAtom, + ); + + useTimer({ delay: 1000, startImmediately: true }, setCurrentTimestamp); + const { lastJsonMessage, readyState } = useWebSocket< GameWatcherMessageS2C, GameWatcherMessageC2S >(socketUrl); - const [gameState, setGameState] = useState("connecting"); - - const [startedAt, setStartedAt] = useState(null); - - const [leftTimeSeconds, setLeftTimeSeconds] = useState(null); - - useEffect(() => { - if ( - (gameState === "starting" || gameState === "gaming") && - startedAt !== null - ) { - const timer = setInterval(() => { - setLeftTimeSeconds((prev) => { - if (prev === null) { - return null; - } - if (prev <= 1) { - const nowSec = Math.floor(Date.now() / 1000); - const finishedAt = startedAt + game.duration_seconds; - if (nowSec >= finishedAt) { - clearInterval(timer); - setGameState("finished"); - audioController.playSoundEffectFinish(); - } else { - setGameState("gaming"); - } - } - return prev - 1; - }); - }, 1000); - - return () => { - clearInterval(timer); - }; - } - }, [gameState, startedAt, game.duration_seconds, audioController]); - const playerA = game.players[0]!; const playerB = game.players[1]!; + const getTargetAtomByPlayerId: ( + player_id: number, + atomA: T, + atomB: T, + ) => T = useCallback( + (player_id, atomA, atomB) => + player_id === playerA.user_id ? atomA : atomB, + [playerA.user_id], + ); + const playerProfileA = { displayName: playerA.display_name, iconPath: playerA.icon_path ?? null, }; - const [playerStateA, setPlayerStateA] = useState({ - score: null, - code: "", - submitResult: { - status: "waiting_submission", - execResults: game.exec_steps.map((r) => ({ - testcase_id: r.testcase_id, - status: "waiting_submission", - label: r.label, - stdout: "", - stderr: "", - })), - }, - }); const playerProfileB = { displayName: playerB.display_name, iconPath: playerB.icon_path ?? null, }; - const [playerStateB, setPlayerStateB] = useState({ - score: null, - code: "", - submitResult: { - status: "waiting_submission", - execResults: game.exec_steps.map((r) => ({ - testcase_id: r.testcase_id, - status: "waiting_submission", - label: r.label, - stdout: "", - stderr: "", - })), - }, - }); if (readyState === ReadyState.UNINSTANTIATED) { throw new Error("WebSocket is not connected"); @@ -119,133 +90,92 @@ export default function GolfWatchApp({ useEffect(() => { if (readyState === ReadyState.CLOSING || readyState === ReadyState.CLOSED) { - if (gameState !== "finished") { - setGameState("connecting"); - } + handleWsConnectionClosed(); } else if (readyState === ReadyState.CONNECTING) { - setGameState("connecting"); + setGameStateConnecting(); } 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); - setLeftTimeSeconds(start_at - nowSec); - setGameState("starting"); - } + const { start_at } = lastJsonMessage.data; + gameStart(start_at); } else if (lastJsonMessage.type === "watcher:s2c:code") { - const { player_id, code } = lastJsonMessage.data; - const setter = - player_id === playerA.user_id ? setPlayerStateA : setPlayerStateB; - setter((prev) => ({ ...prev, code })); + handleWsCodeMessage( + lastJsonMessage.data, + getTargetAtomByPlayerId, + (player_id, code) => { + const baseKey = `watcherState:${game.game_id}:${player_id}`; + window.localStorage.setItem(`${baseKey}:code`, code); + }, + ); } else if (lastJsonMessage.type === "watcher:s2c:submit") { - const { player_id } = lastJsonMessage.data; - const setter = - player_id === playerA.user_id ? setPlayerStateA : setPlayerStateB; - setter((prev) => ({ - ...prev, - submitResult: { - status: "running", - execResults: prev.submitResult.execResults.map((r) => ({ - ...r, - status: "running", - stdout: "", - stderr: "", - })), + handleWsSubmitMessage( + lastJsonMessage.data, + getTargetAtomByPlayerId, + (player_id, submissionResult) => { + const baseKey = `watcherState:${game.game_id}:${player_id}`; + window.localStorage.setItem( + `${baseKey}:submissionResult`, + JSON.stringify(submissionResult), + ); }, - })); + ); } else if (lastJsonMessage.type === "watcher:s2c:execresult") { - const { player_id, testcase_id, status, stdout, stderr } = - lastJsonMessage.data; - const setter = - player_id === playerA.user_id ? setPlayerStateA : setPlayerStateB; - setter((prev) => { - const ret = { ...prev }; - ret.submitResult = { - ...prev.submitResult, - execResults: prev.submitResult.execResults.map((r) => - r.testcase_id === testcase_id && r.status === "running" - ? { - ...r, - status, - stdout, - stderr, - } - : r, - ), - }; - return ret; - }); + handleWsExecResultMessage( + lastJsonMessage.data, + getTargetAtomByPlayerId, + (player_id, submissionResult) => { + const baseKey = `watcherState:${game.game_id}:${player_id}`; + window.localStorage.setItem( + `${baseKey}:submissionResult`, + JSON.stringify(submissionResult), + ); + }, + ); } else if (lastJsonMessage.type === "watcher:s2c:submitresult") { - const { player_id, status, score } = lastJsonMessage.data; - const setter = - player_id === playerA.user_id ? setPlayerStateA : setPlayerStateB; - setter((prev) => { - const ret = { ...prev }; - ret.submitResult = { - ...prev.submitResult, - status, - }; - if (status === "success") { - if (score) { - if (ret.score === null || score < ret.score) { - ret.score = score; - } - } - } else { - ret.submitResult.execResults = prev.submitResult.execResults.map( - (r) => - r.status === "running" ? { ...r, status: "canceled" } : r, + handleWsSubmitResultMessage( + lastJsonMessage.data, + getTargetAtomByPlayerId, + (player_id, submissionResult, score) => { + const baseKey = `watcherState:${game.game_id}:${player_id}`; + window.localStorage.setItem( + `${baseKey}:submissionResult`, + JSON.stringify(submissionResult), ); - } - return ret; - }); + window.localStorage.setItem( + `${baseKey}:score`, + score === null ? "" : score.toString(), + ); + }, + ); } } else { if (game.started_at) { - const nowSec = Math.floor(Date.now() / 1000); - if (game.started_at <= nowSec) { - // The game has already started. - if (gameState !== "gaming" && gameState !== "finished") { - setStartedAt(game.started_at); - setLeftTimeSeconds(game.started_at - nowSec); - setGameState("gaming"); - } - } else { - // The game is starting. - if ( - gameState !== "starting" && - gameState !== "gaming" && - gameState !== "finished" - ) { - setStartedAt(game.started_at); - setLeftTimeSeconds(game.started_at - nowSec); - setGameState("starting"); - } - } + gameStart(game.started_at); } else { - setGameState("waiting"); + setGameStateWaiting(); } } } }, [ game.started_at, + game.game_id, lastJsonMessage, readyState, - gameState, - playerA.user_id, - playerB.user_id, + gameStart, + getTargetAtomByPlayerId, + handleWsCodeMessage, + handleWsConnectionClosed, + handleWsExecResultMessage, + handleWsSubmitMessage, + handleWsSubmitResultMessage, + setGameStateConnecting, + setGameStateWaiting, ]); - if (gameState === "connecting") { + if (gameStateKind === "connecting") { return ; - } else if (gameState === "waiting") { + } else if (gameStateKind === "waiting") { return ( ); - } else if (gameState === "starting") { - return ( - - ); - } else if (gameState === "gaming" || gameState === "finished") { + } else if (gameStateKind === "starting") { + return ; + } else if (gameStateKind === "gaming" || gameStateKind === "finished") { return ( { - const k = gameDurationSeconds + leftTimeSeconds; - if (k <= 0) { - return "00:00"; - } - const m = Math.floor(k / 60); - const s = k % 60; + const m = Math.floor(leftTimeSeconds / 60); + const s = leftTimeSeconds % 60; return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`; })(); @@ -49,43 +59,43 @@ export default function GolfWatchAppGaming({
- {playerInfoA.profile.iconPath && ( + {playerProfileA.iconPath && ( )}
Player 1
-
{playerInfoA.profile.displayName}
+
{playerProfileA.displayName}
-
{playerInfoA.state.score}
+
{scoreA}
{gameDisplayName}
{gameResult ? gameResult === "winA" - ? `勝者 ${playerInfoA.profile.displayName}` + ? `勝者 ${playerProfileA.displayName}` : gameResult === "winB" - ? `勝者 ${playerInfoB.profile.displayName}` + ? `勝者 ${playerProfileB.displayName}` : "引き分け" : leftTime}
-
{playerInfoB.state.score}
+
{scoreB}
Player 2
-
{playerInfoB.profile.displayName}
+
{playerProfileB.displayName}
- {playerInfoB.profile.iconPath && ( + {playerProfileB.iconPath && ( )} @@ -93,17 +103,17 @@ export default function GolfWatchAppGaming({
- +
- - + +
@@ -112,7 +122,7 @@ export default function GolfWatchAppGaming({ {problemDescription}
- +
); diff --git a/frontend/app/components/GolfWatchApps/GolfWatchAppStarting.tsx b/frontend/app/components/GolfWatchApps/GolfWatchAppStarting.tsx index cd4195d..684d2af 100644 --- a/frontend/app/components/GolfWatchApps/GolfWatchAppStarting.tsx +++ b/frontend/app/components/GolfWatchApps/GolfWatchAppStarting.tsx @@ -1,18 +1,19 @@ +import { useAtomValue } from "jotai"; +import { startingLeftTimeSecondsAtom } from "../../states/watch"; + type Props = { gameDisplayName: string; - leftTimeSeconds: number; }; -export default function GolfWatchAppStarting({ - gameDisplayName, - leftTimeSeconds, -}: Props) { +export default function GolfWatchAppStarting({ gameDisplayName }: Props) { + const leftTimeSeconds = useAtomValue(startingLeftTimeSecondsAtom)!; + return (
{gameDisplayName}
-
+
{leftTimeSeconds}
diff --git a/frontend/app/routes/golf.$gameId.watch.tsx b/frontend/app/routes/golf.$gameId.watch.tsx index 7e90b2d..f04f6b0 100644 --- a/frontend/app/routes/golf.$gameId.watch.tsx +++ b/frontend/app/routes/golf.$gameId.watch.tsx @@ -1,10 +1,21 @@ import type { LoaderFunctionArgs, MetaFunction } from "@remix-run/node"; -import { useLoaderData } from "@remix-run/react"; -import { ClientOnly } from "remix-utils/client-only"; +import { ClientLoaderFunctionArgs, useLoaderData } from "@remix-run/react"; +import { useHydrateAtoms } from "jotai/utils"; import { apiGetGame, apiGetToken } from "../.server/api/client"; import { ensureUserLoggedIn } from "../.server/auth"; import GolfWatchAppWithAudioPlayRequest from "../components/GolfWatchAppWithAudioPlayRequest.client"; import GolfWatchAppConnecting from "../components/GolfWatchApps/GolfWatchAppConnecting"; +import { + codeAAtom, + codeBAtom, + scoreAAtom, + scoreBAtom, + setCurrentTimestampAtom, + setDurationSecondsAtom, + submitResultAAtom, + submitResultBAtom, +} from "../states/watch"; +import { PlayerState } from "../types/PlayerState"; export const meta: MetaFunction = ({ data }) => [ { @@ -27,23 +38,150 @@ export async function loader({ params, request }: LoaderFunctionArgs) { const [game, sockToken] = await Promise.all([fetchGame(), fetchSockToken()]); if (game.game_type !== "1v1") { - return new Response("Not Found", { status: 404 }); + throw new Response("Not Found", { status: 404 }); } + const playerStateA: PlayerState = { + code: "", + score: null, + submitResult: { + status: "waiting_submission", + execResults: game.exec_steps.map((r) => ({ + testcase_id: r.testcase_id, + status: "waiting_submission", + label: r.label, + stdout: "", + stderr: "", + })), + }, + }; + const playerStateB = structuredClone(playerStateA); + return { game, sockToken, + playerStateA, + playerStateB, }; } +export async function clientLoader({ serverLoader }: ClientLoaderFunctionArgs) { + const data = await serverLoader(); + + const playerIdA = data.game.players[0]?.user_id; + const playerIdB = data.game.players[1]?.user_id; + + if (playerIdA !== null) { + const baseKeyA = `watcherState:${data.game.game_id}:${playerIdA}`; + + const localCodeA = (() => { + const rawValue = window.localStorage.getItem(`${baseKeyA}:code`); + + if (rawValue === null) { + return null; + } + return rawValue; + })(); + + const localScoreA = (() => { + const rawValue = window.localStorage.getItem(`${baseKeyA}:score`); + if (rawValue === null || rawValue === "") { + return null; + } + return Number(rawValue); + })(); + + const localSubmissionResultA = (() => { + const rawValue = window.localStorage.getItem( + `${baseKeyA}:submissionResult`, + ); + if (rawValue === null) { + return null; + } + const parsed = JSON.parse(rawValue); + if (typeof parsed !== "object") { + return null; + } + return parsed; + })(); + + if (localCodeA !== null) { + data.playerStateA.code = localCodeA; + } + if (localScoreA !== null) { + data.playerStateA.score = localScoreA; + } + if (localSubmissionResultA !== null) { + data.playerStateA.submitResult = localSubmissionResultA; + } + } + + if (playerIdB !== null) { + const baseKeyB = `watcherState:${data.game.game_id}:${playerIdB}`; + + const localCodeB = (() => { + const rawValue = window.localStorage.getItem(`${baseKeyB}:code`); + if (rawValue === null) { + return null; + } + return rawValue; + })(); + + const localScoreB = (() => { + const rawValue = window.localStorage.getItem(`${baseKeyB}:score`); + if (rawValue === null || rawValue === "") { + return null; + } + return Number(rawValue); + })(); + + const localSubmissionResultB = (() => { + const rawValue = window.localStorage.getItem( + `${baseKeyB}:submissionResult`, + ); + if (rawValue === null) { + return null; + } + const parsed = JSON.parse(rawValue); + if (typeof parsed !== "object") { + return null; + } + return parsed; + })(); + + if (localCodeB !== null) { + data.playerStateB.code = localCodeB; + } + if (localScoreB !== null) { + data.playerStateB.score = localScoreB; + } + if (localSubmissionResultB !== null) { + data.playerStateB.submitResult = localSubmissionResultB; + } + } + + return data; +} +clientLoader.hydrate = true; + +export function HydrateFallback() { + return ; +} + export default function GolfWatch() { - const { game, sockToken } = useLoaderData(); - - return ( - }> - {() => ( - - )} - - ); + const { game, sockToken, playerStateA, playerStateB } = + useLoaderData(); + + useHydrateAtoms([ + [setCurrentTimestampAtom, undefined], + [setDurationSecondsAtom, game.duration_seconds], + [codeAAtom, playerStateA.code], + [codeBAtom, playerStateB.code], + [scoreAAtom, playerStateA.score], + [scoreBAtom, playerStateB.score], + [submitResultAAtom, playerStateA.submitResult], + [submitResultBAtom, playerStateB.submitResult], + ]); + + return ; } diff --git a/frontend/app/states/watch.ts b/frontend/app/states/watch.ts index e69de29..3b80f2f 100644 --- a/frontend/app/states/watch.ts +++ b/frontend/app/states/watch.ts @@ -0,0 +1,247 @@ +import { atom } from "jotai"; +import type { components } from "../.server/api/schema"; +import type { SubmitResult } from "../types/SubmitResult"; + +type RawGameState = + | { + kind: "connecting"; + startedAtTimestamp: null; + } + | { + kind: "waiting"; + startedAtTimestamp: null; + } + | { + kind: "starting"; + startedAtTimestamp: number; + }; + +const rawGameStateAtom = atom({ + kind: "connecting", + startedAtTimestamp: null, +}); + +export type GameStateKind = + | "connecting" + | "waiting" + | "starting" + | "gaming" + | "finished"; + +export const gameStateKindAtom = atom((get) => { + const { kind: rawKind, startedAtTimestamp } = get(rawGameStateAtom); + if (rawKind === "connecting" || rawKind === "waiting") { + return rawKind; + } else { + const durationSeconds = get(rawDurationSecondsAtom); + const finishedAtTimestamp = startedAtTimestamp + durationSeconds; + const currentTimestamp = get(rawCurrentTimestampAtom); + if (currentTimestamp < startedAtTimestamp) { + return "starting"; + } else if (currentTimestamp < finishedAtTimestamp) { + return "gaming"; + } else { + return "finished"; + } + } +}); + +export const gameStartAtom = atom(null, (get, set, value: number) => { + const { kind } = get(rawGameStateAtom); + if (kind === "starting") { + return; + } + set(rawGameStateAtom, { + kind: "starting", + startedAtTimestamp: value, + }); +}); +export const setGameStateConnectingAtom = atom(null, (_, set) => + set(rawGameStateAtom, { kind: "connecting", startedAtTimestamp: null }), +); +export const setGameStateWaitingAtom = atom(null, (_, set) => + set(rawGameStateAtom, { kind: "waiting", startedAtTimestamp: null }), +); + +const rawCurrentTimestampAtom = atom(0); +export const setCurrentTimestampAtom = atom(null, (_, set) => + set(rawCurrentTimestampAtom, Math.floor(Date.now() / 1000)), +); + +const rawDurationSecondsAtom = atom(0); +export const setDurationSecondsAtom = atom(null, (_, set, value: number) => + set(rawDurationSecondsAtom, value), +); + +export const startingLeftTimeSecondsAtom = atom((get) => { + const { startedAtTimestamp } = get(rawGameStateAtom); + if (startedAtTimestamp === null) { + return null; + } + const currentTimestamp = get(rawCurrentTimestampAtom); + return Math.max(0, startedAtTimestamp - currentTimestamp); +}); + +export const gamingLeftTimeSecondsAtom = atom((get) => { + const { startedAtTimestamp } = get(rawGameStateAtom); + if (startedAtTimestamp === null) { + return null; + } + const durationSeconds = get(rawDurationSecondsAtom); + const finishedAtTimestamp = startedAtTimestamp + durationSeconds; + const currentTimestamp = get(rawCurrentTimestampAtom); + return Math.min( + durationSeconds, + Math.max(0, finishedAtTimestamp - currentTimestamp), + ); +}); + +export const handleWsConnectionClosedAtom = atom(null, (get, set) => { + const kind = get(gameStateKindAtom); + if (kind !== "finished") { + set(setGameStateConnectingAtom); + } +}); + +export const codeAAtom = atom(""); +export const codeBAtom = atom(""); +export const scoreAAtom = atom(null); +export const scoreBAtom = atom(null); +export const submitResultAAtom = atom({ + status: "waiting_submission", + execResults: [], +}); +export const submitResultBAtom = atom({ + status: "waiting_submission", + execResults: [], +}); + +type GameWatcherMessageS2CSubmitPayload = + components["schemas"]["GameWatcherMessageS2CSubmitPayload"]; +type GameWatcherMessageS2CCodePayload = + components["schemas"]["GameWatcherMessageS2CCodePayload"]; +type GameWatcherMessageS2CExecResultPayload = + components["schemas"]["GameWatcherMessageS2CExecResultPayload"]; +type GameWatcherMessageS2CSubmitResultPayload = + components["schemas"]["GameWatcherMessageS2CSubmitResultPayload"]; + +export const handleWsCodeMessageAtom = atom( + null, + ( + _, + set, + data: GameWatcherMessageS2CCodePayload, + getTarget: (player_id: number, atomA: T, atomB: T) => T, + callback: (player_id: number, code: string) => void, + ) => { + const { player_id, code } = data; + const codeAtom = getTarget(player_id, codeAAtom, codeBAtom); + set(codeAtom, code); + callback(player_id, code); + }, +); + +export const handleWsSubmitMessageAtom = atom( + null, + ( + get, + set, + data: GameWatcherMessageS2CSubmitPayload, + getTarget: (player_id: number, atomA: T, atomB: T) => T, + callback: (player_id: number, submissionResult: SubmitResult) => void, + ) => { + const { player_id } = data; + const submitResultAtom = getTarget( + player_id, + submitResultAAtom, + submitResultBAtom, + ); + const prev = get(submitResultAtom); + const newResult = { + status: "running" as const, + execResults: prev.execResults.map((r) => ({ + ...r, + status: "running" as const, + stdout: "", + stderr: "", + })), + }; + set(submitResultAtom, newResult); + callback(player_id, newResult); + }, +); + +export const handleWsExecResultMessageAtom = atom( + null, + ( + get, + set, + data: GameWatcherMessageS2CExecResultPayload, + getTarget: (player_id: number, atomA: T, atomB: T) => T, + callback: (player_id: number, submissionResult: SubmitResult) => void, + ) => { + const { player_id, testcase_id, status, stdout, stderr } = data; + const submitResultAtom = getTarget( + player_id, + submitResultAAtom, + submitResultBAtom, + ); + const prev = get(submitResultAtom); + const newResult = { + ...prev, + execResults: prev.execResults.map((r) => + r.testcase_id === testcase_id && r.status === "running" + ? { + ...r, + status, + stdout, + stderr, + } + : r, + ), + }; + set(submitResultAtom, newResult); + callback(player_id, newResult); + }, +); + +export const handleWsSubmitResultMessageAtom = atom( + null, + ( + get, + set, + data: GameWatcherMessageS2CSubmitResultPayload, + getTarget: (player_id: number, atomA: T, atomB: T) => T, + callback: ( + player_id: number, + submissionResult: SubmitResult, + score: number | null, + ) => void, + ) => { + const { player_id, status, score } = data; + const submitResultAtom = getTarget( + player_id, + submitResultAAtom, + submitResultBAtom, + ); + const scoreAtom = getTarget(player_id, scoreAAtom, scoreBAtom); + const prev = get(submitResultAtom); + const newResult = { + ...prev, + status, + }; + if (status !== "success") { + newResult.execResults = prev.execResults.map((r) => + r.status === "running" ? { ...r, status: "canceled" } : r, + ); + } + set(submitResultAtom, newResult); + if (status === "success" && score !== null) { + const currentScore = get(scoreAtom); + if (currentScore === null || score < currentScore) { + set(scoreAtom, score); + } + } + callback(player_id, newResult, score); + }, +); -- cgit v1.2.3-70-g09d2 From 7fda765d654a63945296d813c177aeb9e423f120 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Thu, 22 Aug 2024 13:02:58 +0900 Subject: feat(frontend): remove disused package --- frontend/package-lock.json | 67 ---------------------------------------------- frontend/package.json | 1 - 2 files changed, 68 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 538ceeb..6ba0394 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -25,7 +25,6 @@ "react-use-websocket": "^4.8.1", "remix-auth": "^3.7.0", "remix-auth-form": "^1.5.0", - "remix-utils": "^7.6.0", "use-debounce": "^10.0.1" }, "devDependencies": { @@ -9449,72 +9448,6 @@ "remix-auth": "^3.6.0" } }, - "node_modules/remix-utils": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/remix-utils/-/remix-utils-7.6.0.tgz", - "integrity": "sha512-BPhCUEy+nwrhDDDg2v3+LFSszV6tluMbeSkbffj2o4tqZxt5Kn69Y9sNpGxYLAj8gjqeYDuxjv55of+gYnnykA==", - "dependencies": { - "type-fest": "^4.3.3" - }, - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@remix-run/cloudflare": "^2.0.0", - "@remix-run/deno": "^2.0.0", - "@remix-run/node": "^2.0.0", - "@remix-run/react": "^2.0.0", - "@remix-run/router": "^1.7.2", - "crypto-js": "^4.1.1", - "intl-parse-accept-language": "^1.0.0", - "is-ip": "^5.0.1", - "react": "^18.0.0", - "zod": "^3.22.4" - }, - "peerDependenciesMeta": { - "@remix-run/cloudflare": { - "optional": true - }, - "@remix-run/deno": { - "optional": true - }, - "@remix-run/node": { - "optional": true - }, - "@remix-run/react": { - "optional": true - }, - "@remix-run/router": { - "optional": true - }, - "crypto-js": { - "optional": true - }, - "intl-parse-accept-language": { - "optional": true - }, - "is-ip": { - "optional": true - }, - "react": { - "optional": true - }, - "zod": { - "optional": true - } - } - }, - "node_modules/remix-utils/node_modules/type-fest": { - "version": "4.23.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.23.0.tgz", - "integrity": "sha512-ZiBujro2ohr5+Z/hZWHESLz3g08BBdrdLMieYFULJO+tWc437sn8kQsWLJoZErY8alNhxre9K4p3GURAG11n+w==", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index bb11725..ee06aea 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -33,7 +33,6 @@ "react-use-websocket": "^4.8.1", "remix-auth": "^3.7.0", "remix-auth-form": "^1.5.0", - "remix-utils": "^7.6.0", "use-debounce": "^10.0.1" }, "devDependencies": { -- cgit v1.2.3-70-g09d2 From dfd33e58a5e6f830d60e978afad7348f7a16068d Mon Sep 17 00:00:00 2001 From: nsfisis Date: Thu, 22 Aug 2024 13:27:44 +0900 Subject: feat(frontend): add minimal style to watch with audio play request --- frontend/app/.client/audio/AudioController.ts | 11 +++++++ frontend/app/components/GolfWatchApp.client.tsx | 8 +---- .../GolfWatchAppWithAudioPlayRequest.client.tsx | 38 ++++++++++------------ frontend/app/states/watch.ts | 3 ++ 4 files changed, 33 insertions(+), 27 deletions(-) diff --git a/frontend/app/.client/audio/AudioController.ts b/frontend/app/.client/audio/AudioController.ts index 6ed6180..296f685 100644 --- a/frontend/app/.client/audio/AudioController.ts +++ b/frontend/app/.client/audio/AudioController.ts @@ -51,6 +51,17 @@ export class AudioController { }); } + async playDummySoundEffect(): Promise { + const audio = this.audioElements["good_1"]; + if (!audio) { + return; + } + audio.muted = true; + audio.currentTime = 0; + await audio.play(); + audio.muted = false; + } + async playSoundEffect(soundEffect: SoundEffect): Promise { const audio = this.audioElements[soundEffect]; if (!audio) { diff --git a/frontend/app/components/GolfWatchApp.client.tsx b/frontend/app/components/GolfWatchApp.client.tsx index 7ea2ced..c8b1d53 100644 --- a/frontend/app/components/GolfWatchApp.client.tsx +++ b/frontend/app/components/GolfWatchApp.client.tsx @@ -1,7 +1,6 @@ import { useAtomValue, useSetAtom } from "jotai"; import { useCallback, useEffect } from "react"; import { useTimer } from "react-use-precision-timer"; -import { AudioController } from "../.client/audio/AudioController"; import type { components } from "../.server/api/schema"; import useWebSocket, { ReadyState } from "../hooks/useWebSocket"; import { @@ -29,14 +28,9 @@ type Game = components["schemas"]["Game"]; export type Props = { game: Game; sockToken: string; - audioController: AudioController; }; -export default function GolfWatchApp({ - game, - sockToken, - audioController, -}: Props) { +export default function GolfWatchApp({ game, sockToken }: Props) { const socketUrl = process.env.NODE_ENV === "development" ? `ws://localhost:8002/iosdc-japan/2024/code-battle/sock/golf/${game.game_id}/watch?token=${sockToken}` diff --git a/frontend/app/components/GolfWatchAppWithAudioPlayRequest.client.tsx b/frontend/app/components/GolfWatchAppWithAudioPlayRequest.client.tsx index e299f4b..ce5a59c 100644 --- a/frontend/app/components/GolfWatchAppWithAudioPlayRequest.client.tsx +++ b/frontend/app/components/GolfWatchAppWithAudioPlayRequest.client.tsx @@ -1,35 +1,33 @@ -import { useState } from "react"; +import { useAtom } from "jotai"; import { AudioController } from "../.client/audio/AudioController"; +import { audioControllerAtom } from "../states/watch"; import GolfWatchApp, { type Props } from "./GolfWatchApp.client"; +import SubmitButton from "./SubmitButton"; export default function GolfWatchAppWithAudioPlayRequest({ game, sockToken, }: Omit) { - const [audioController, setAudioController] = - useState(null); + const [audioController, setAudioController] = useAtom(audioControllerAtom); const audioPlayPermitted = audioController !== null; if (audioPlayPermitted) { - return ( - - ); + return ; } else { return ( -
- +
+
+ { + const audioController = new AudioController(); + await audioController.loadAll(); + await audioController.playDummySoundEffect(); + setAudioController(audioController); + }} + > + 開始 + +
); } diff --git a/frontend/app/states/watch.ts b/frontend/app/states/watch.ts index 3b80f2f..ba3dd2a 100644 --- a/frontend/app/states/watch.ts +++ b/frontend/app/states/watch.ts @@ -1,4 +1,5 @@ import { atom } from "jotai"; +import { AudioController } from "../.client/audio/AudioController"; import type { components } from "../.server/api/schema"; import type { SubmitResult } from "../types/SubmitResult"; @@ -245,3 +246,5 @@ export const handleWsSubmitResultMessageAtom = atom( callback(player_id, newResult, score); }, ); + +export const audioControllerAtom = atom(null); -- cgit v1.2.3-70-g09d2