aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--.gitignore1
-rw-r--r--backend/admin/handler.go22
-rw-r--r--backend/admin/templates/audio.html14
-rw-r--r--backend/admin/templates/dashboard.html3
-rw-r--r--frontend/app/.client/audio/AudioController.ts94
-rw-r--r--frontend/app/.client/audio/SoundEffect.ts47
-rw-r--r--frontend/app/components/GolfWatchApp.client.tsx16
-rw-r--r--frontend/app/components/GolfWatchAppWithAudioPlayRequest.client.tsx36
-rw-r--r--frontend/app/routes/golf.$gameId.watch.tsx6
-rwxr-xr-xscripts/copy-external-assets.sh16
10 files changed, 248 insertions, 7 deletions
diff --git a/.gitignore b/.gitignore
index f10862a..34c09ea 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1 +1,2 @@
/.env
+/.external-assets
diff --git a/backend/admin/handler.go b/backend/admin/handler.go
index 17341d5..a685d16 100644
--- a/backend/admin/handler.go
+++ b/backend/admin/handler.go
@@ -69,6 +69,7 @@ func (h *Handler) RegisterHandlers(g *echo.Group) {
g.GET("/games", h.getGames)
g.GET("/games/:gameID", h.getGameEdit)
g.POST("/games/:gameID", h.postGameEdit)
+ g.GET("/audio", h.getAudioTest)
}
func (h *Handler) getDashboard(c echo.Context) error {
@@ -289,3 +290,24 @@ func (h *Handler) postGameEdit(c echo.Context) error {
return c.Redirect(http.StatusSeeOther, basePath+"/admin/games")
}
+
+func (h *Handler) getAudioTest(c echo.Context) error {
+ return c.Render(http.StatusOK, "audio", echo.Map{
+ "BasePath": basePath,
+ "Title": "Audio Test",
+ "Audio": []echo.Map{
+ {"FileName": "EX_33.wav", "Label": "終了"},
+ {"FileName": "EX_34.wav", "Label": "勝敗1"},
+ {"FileName": "EX_35.wav", "Label": "勝敗2"},
+ {"FileName": "EX_36.wav", "Label": "グッド1"},
+ {"FileName": "EX_37.wav", "Label": "グッド2"},
+ {"FileName": "EX_38.wav", "Label": "グッド3"},
+ {"FileName": "EX_39.wav", "Label": "グッド4"},
+ {"FileName": "EX_40.wav", "Label": "スコア更新1"},
+ {"FileName": "EX_41.wav", "Label": "スコア更新2"},
+ {"FileName": "EX_42.wav", "Label": "スコア更新3"},
+ {"FileName": "EX_43.wav", "Label": "コンパイルエラー1"},
+ {"FileName": "EX_44.wav", "Label": "コンパイルエラー2"},
+ },
+ })
+}
diff --git a/backend/admin/templates/audio.html b/backend/admin/templates/audio.html
new file mode 100644
index 0000000..21ec463
--- /dev/null
+++ b/backend/admin/templates/audio.html
@@ -0,0 +1,14 @@
+{{ template "base.html" . }}
+
+{{ define "breadcrumb" }}
+<a href="{{ .BasePath }}/admin/dashboard">Dashboard</a>
+{{ end }}
+
+{{ define "content" }}
+ {{ range .Audio }}
+ <figure>
+ <figcaption>{{ .Label }}</figcaption>
+ <audio controls src="{{ $.BasePath }}/files/audio/{{ .FileName }}"></audio>
+ </figure>
+ {{ end }}
+{{ end }}
diff --git a/backend/admin/templates/dashboard.html b/backend/admin/templates/dashboard.html
index 15b10ff..0f1fbaf 100644
--- a/backend/admin/templates/dashboard.html
+++ b/backend/admin/templates/dashboard.html
@@ -7,6 +7,9 @@
<p>
<a href="{{ .BasePath }}/admin/games">Games</a>
</p>
+<p>
+ <a href="{{ .BasePath }}/admin/audio">Audio Test</a>
+</p>
<form method="post" action="{{ .BasePath }}/logout">
<button type="submit">Logout</button>
</form>
diff --git a/frontend/app/.client/audio/AudioController.ts b/frontend/app/.client/audio/AudioController.ts
new file mode 100644
index 0000000..6ed6180
--- /dev/null
+++ b/frontend/app/.client/audio/AudioController.ts
@@ -0,0 +1,94 @@
+import { SoundEffect, getFileUrl } from "./SoundEffect";
+
+export class AudioController {
+ audioElements: Record<SoundEffect, HTMLAudioElement | null>;
+
+ constructor() {
+ this.audioElements = {
+ finish: null,
+ winner_1: null,
+ winner_2: null,
+ good_1: null,
+ good_2: null,
+ good_3: null,
+ good_4: null,
+ new_score_1: null,
+ new_score_2: null,
+ new_score_3: null,
+ compile_error_1: null,
+ compile_error_2: null,
+ };
+ }
+
+ loadAll(): Promise<void> {
+ return new Promise((resolve) => {
+ const files = Object.keys(this.audioElements).map(
+ (se) => [se as SoundEffect, getFileUrl(se as SoundEffect)] as const,
+ );
+ const totalCount = files.length;
+ let loadedCount = 0;
+
+ files.forEach(([se, fileUrl]) => {
+ const audio = new Audio(fileUrl);
+
+ audio.addEventListener(
+ "canplaythrough",
+ () => {
+ loadedCount++;
+ this.audioElements[se] = audio;
+ if (loadedCount === totalCount) {
+ resolve();
+ }
+ },
+ { once: true },
+ );
+
+ audio.addEventListener("error", () => {
+ console.log(`Failed to load audio file: ${fileUrl}`);
+ // Ignore the error and continue loading other files.
+ });
+ });
+ });
+ }
+
+ async playSoundEffect(soundEffect: SoundEffect): Promise<void> {
+ const audio = this.audioElements[soundEffect];
+ if (!audio) {
+ return;
+ }
+ audio.currentTime = 0;
+ await audio.play();
+ }
+
+ async playSoundEffectFinish(): Promise<void> {
+ await this.playSoundEffect("finish");
+ }
+
+ async playSoundEffectWinner(winner: 1 | 2): Promise<void> {
+ await this.playSoundEffect(`winner_${winner}`);
+ }
+
+ async playSoundEffectGood(): Promise<void> {
+ const variant = Math.floor(Math.random() * 4) + 1;
+ if (variant !== 1 && variant !== 2 && variant !== 3 && variant !== 4) {
+ return; // unreachable
+ }
+ return await this.playSoundEffect(`good_${variant}`);
+ }
+
+ async playSoundEffectNewScore(): Promise<void> {
+ const variant = Math.floor(Math.random() * 3) + 1;
+ if (variant !== 1 && variant !== 2 && variant !== 3) {
+ return; // unreachable
+ }
+ return await this.playSoundEffect(`new_score_${variant}`);
+ }
+
+ async playSoundEffectCompileError(): Promise<void> {
+ const variant = Math.floor(Math.random() * 2) + 1;
+ if (variant !== 1 && variant !== 2) {
+ return; // unreachable
+ }
+ return await this.playSoundEffect(`compile_error_${variant}`);
+ }
+}
diff --git a/frontend/app/.client/audio/SoundEffect.ts b/frontend/app/.client/audio/SoundEffect.ts
new file mode 100644
index 0000000..7e40da6
--- /dev/null
+++ b/frontend/app/.client/audio/SoundEffect.ts
@@ -0,0 +1,47 @@
+export type SoundEffect =
+ | "finish"
+ | "winner_1"
+ | "winner_2"
+ | "good_1"
+ | "good_2"
+ | "good_3"
+ | "good_4"
+ | "new_score_1"
+ | "new_score_2"
+ | "new_score_3"
+ | "compile_error_1"
+ | "compile_error_2";
+
+const BASE_URL =
+ process.env.NODE_ENV === "development"
+ ? `http://localhost:8002/iosdc-japan/2024/code-battle/files/audio`
+ : `/iosdc-japan/2024/code-battle/files/audio`;
+
+export function getFileUrl(soundEffect: SoundEffect): string {
+ switch (soundEffect) {
+ case "finish":
+ return `${BASE_URL}/EX_33.wav`;
+ case "winner_1":
+ return `${BASE_URL}/EX_34.wav`;
+ case "winner_2":
+ return `${BASE_URL}/EX_35.wav`;
+ case "good_1":
+ return `${BASE_URL}/EX_36.wav`;
+ case "good_2":
+ return `${BASE_URL}/EX_37.wav`;
+ case "good_3":
+ return `${BASE_URL}/EX_38.wav`;
+ case "good_4":
+ return `${BASE_URL}/EX_39.wav`;
+ case "new_score_1":
+ return `${BASE_URL}/EX_40.wav`;
+ case "new_score_2":
+ return `${BASE_URL}/EX_41.wav`;
+ case "new_score_3":
+ return `${BASE_URL}/EX_42.wav`;
+ case "compile_error_1":
+ return `${BASE_URL}/EX_43.wav`;
+ case "compile_error_2":
+ return `${BASE_URL}/EX_44.wav`;
+ }
+}
diff --git a/frontend/app/components/GolfWatchApp.client.tsx b/frontend/app/components/GolfWatchApp.client.tsx
index 22f87fa..d09a4ae 100644
--- a/frontend/app/components/GolfWatchApp.client.tsx
+++ b/frontend/app/components/GolfWatchApp.client.tsx
@@ -1,4 +1,5 @@
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";
@@ -14,13 +15,17 @@ type Game = components["schemas"]["Game"];
type GameState = "connecting" | "waiting" | "starting" | "gaming" | "finished";
+export type Props = {
+ game: Game;
+ sockToken: string;
+ audioController: AudioController;
+};
+
export default function GolfWatchApp({
game,
sockToken,
-}: {
- game: Game;
- sockToken: string;
-}) {
+ audioController,
+}: Props) {
const socketUrl =
process.env.NODE_ENV === "development"
? `ws://localhost:8002/iosdc-japan/2024/code-battle/sock/golf/${game.game_id}/watch?token=${sockToken}`
@@ -53,6 +58,7 @@ export default function GolfWatchApp({
if (nowSec >= finishedAt) {
clearInterval(timer);
setGameState("finished");
+ audioController.playSoundEffectFinish();
} else {
setGameState("gaming");
}
@@ -65,7 +71,7 @@ export default function GolfWatchApp({
clearInterval(timer);
};
}
- }, [gameState, startedAt, game.duration_seconds]);
+ }, [gameState, startedAt, game.duration_seconds, audioController]);
const playerA = game.players[0];
const playerB = game.players[1];
diff --git a/frontend/app/components/GolfWatchAppWithAudioPlayRequest.client.tsx b/frontend/app/components/GolfWatchAppWithAudioPlayRequest.client.tsx
new file mode 100644
index 0000000..e299f4b
--- /dev/null
+++ b/frontend/app/components/GolfWatchAppWithAudioPlayRequest.client.tsx
@@ -0,0 +1,36 @@
+import { useState } from "react";
+import { AudioController } from "../.client/audio/AudioController";
+import GolfWatchApp, { type Props } from "./GolfWatchApp.client";
+
+export default function GolfWatchAppWithAudioPlayRequest({
+ game,
+ sockToken,
+}: Omit<Props, "audioController">) {
+ const [audioController, setAudioController] =
+ useState<AudioController | null>(null);
+ const audioPlayPermitted = audioController !== null;
+
+ if (audioPlayPermitted) {
+ return (
+ <GolfWatchApp
+ game={game}
+ sockToken={sockToken}
+ audioController={audioController}
+ />
+ );
+ } else {
+ return (
+ <div>
+ <button
+ onClick={async () => {
+ const audioController = new AudioController();
+ await audioController.loadAll();
+ setAudioController(audioController);
+ }}
+ >
+ Enable Audio Play
+ </button>
+ </div>
+ );
+ }
+}
diff --git a/frontend/app/routes/golf.$gameId.watch.tsx b/frontend/app/routes/golf.$gameId.watch.tsx
index 5edf92f..7e90b2d 100644
--- a/frontend/app/routes/golf.$gameId.watch.tsx
+++ b/frontend/app/routes/golf.$gameId.watch.tsx
@@ -3,7 +3,7 @@ import { useLoaderData } from "@remix-run/react";
import { ClientOnly } from "remix-utils/client-only";
import { apiGetGame, apiGetToken } from "../.server/api/client";
import { ensureUserLoggedIn } from "../.server/auth";
-import GolfWatchApp from "../components/GolfWatchApp.client";
+import GolfWatchAppWithAudioPlayRequest from "../components/GolfWatchAppWithAudioPlayRequest.client";
import GolfWatchAppConnecting from "../components/GolfWatchApps/GolfWatchAppConnecting";
export const meta: MetaFunction<typeof loader> = ({ data }) => [
@@ -41,7 +41,9 @@ export default function GolfWatch() {
return (
<ClientOnly fallback={<GolfWatchAppConnecting />}>
- {() => <GolfWatchApp game={game} sockToken={sockToken} />}
+ {() => (
+ <GolfWatchAppWithAudioPlayRequest game={game} sockToken={sockToken} />
+ )}
</ClientOnly>
);
}
diff --git a/scripts/copy-external-assets.sh b/scripts/copy-external-assets.sh
new file mode 100755
index 0000000..6ec8c65
--- /dev/null
+++ b/scripts/copy-external-assets.sh
@@ -0,0 +1,16 @@
+container_name=$1
+
+docker container exec $container_name mkdir -p /data/files/audio
+
+docker container cp .external-assets/EX_33.wav $container_name:/data/files/audio
+docker container cp .external-assets/EX_34.wav $container_name:/data/files/audio
+docker container cp .external-assets/EX_35.wav $container_name:/data/files/audio
+docker container cp .external-assets/EX_36.wav $container_name:/data/files/audio
+docker container cp .external-assets/EX_37.wav $container_name:/data/files/audio
+docker container cp .external-assets/EX_38.wav $container_name:/data/files/audio
+docker container cp .external-assets/EX_39.wav $container_name:/data/files/audio
+docker container cp .external-assets/EX_40.wav $container_name:/data/files/audio
+docker container cp .external-assets/EX_41.wav $container_name:/data/files/audio
+docker container cp .external-assets/EX_42.wav $container_name:/data/files/audio
+docker container cp .external-assets/EX_43.wav $container_name:/data/files/audio
+docker container cp .external-assets/EX_44.wav $container_name:/data/files/audio