/// import { logger } from "./utils/Logger"; import * as firebase from "./firebase"; import * as firestore from "firebase/firestore"; import * as common from "../common/src"; import * as client from "./client"; import { createPlay, getPlay, selectPlayer, startPlay, playPlay, saveState } from "./client/api/plays"; import * as utils from "./utils"; import { start, GameController, ChangeNameEvent, GameState, ChangeCharacterEvent, GameEvent, MediaEvent, } from "./game"; import { FirebaseHolder } from "./types"; import { initMapEditorPage, setObjectModalImageCharacter } from "./modules/mapEditorPage"; import { handleGame } from "./modules/game"; import { MapEditor, SpriteSheetData } from "./modules/mapEditor"; import { ObjectChipSelector } from "./modules/objectChipSelector"; import { DumpedPlaylog } from "@akashic/akashic-gameview-web/lib/GameLoader"; import { TimeKeeper } from "./TimeKeeper"; import * as bootstrap from "bootstrap"; import { appConfig } from "./config"; import { mediaData, VideoMedia, AudioMedia, MediaItem, MediaSet } from "./media"; import { HowlAudioController } from "./audios/HowlAudioController"; import { HowlAudioSource } from "./audios/HowlAudioSource"; declare var $: any; export interface Snapshot { idx?: number; state: GameState; } export interface PlayGameParams { myPlayerId: string; game: common.Game; play: common.GamePlay; content: common.Content; playToken: common.PlayToken; replayData?: DumpedPlaylog; replayPlayerId?: string; replayOriginDate?: number; replayTargetTimeFunc?: () => number; gameConfig: { bgmVolume: number; seVolume: number; snapshot?: Snapshot }; } export class App extends EventTarget { readonly firebase: FirebaseHolder; apiClient: client.Client; hintContents: [string, string[]][]; keys = ["A", "B", "C", "D"]; isWaitingForSkipToEnd = false; idPlayerIdMap: { [key: string]: string | null } = { A: null, B: null, C: null, D: null, }; constructor() { super(); this.firebase = { instance: firebase.initialize({ firestore: true, auth: true, }) as Required, user: undefined, idToken: undefined, }; if (utils.isDebugMode()) { appConfig.baseUrl = "http://localhost:5001/scrap-conan-online/asia-northeast1/api"; } this.apiClient = new client.Client({ baseUrl: appConfig.baseUrl, apiKey: appConfig.apiKey, }); this.hintContents = []; } onReady() { this.dispatchEvent(new Event("ready")); } enableAuth() { firebase.onAuthStateChanged(this.firebase.instance.auth, async (auth) => { if (auth == null) { firebase.signInAnonymously(this.firebase.instance.auth); return; } this.firebase.user = auth; this.firebase.idToken = await auth.getIdToken(); this.onReady(); }); return this; } indexPage() { const startButton = document.querySelector("#btnStart"); if (startButton == null) { throw new Error("開始ボタンがありません。"); } const continueButton = document.querySelector("#btnContinue"); if (continueButton == null) { throw new Error("開始ボタンがありません。"); } const agreeCheckbox = document.querySelector("#chkAgreement"); if (agreeCheckbox == null) { throw new Error("利用規約の同意欄がありません"); } window.addEventListener("pageshow", function (event) { agreeCheckbox.checked = false; }); startButton.disabled = agreeCheckbox.checked === false; continueButton.disabled = agreeCheckbox.checked === false; agreeCheckbox.addEventListener("change", async (e) => { startButton.disabled = (await agreeCheckbox.checked) === false; continueButton.disabled = (await agreeCheckbox.checked) === false; }); startButton.addEventListener("click", () => { utils.moveTo("number.html"); }); continueButton.addEventListener("click", () => { // TODO implement this utils.moveTo("continue.html"); }); this.tryActivateMemberCheck(); } mapEditorPage() { const targetGameUrl = "https://contents.akashic.shinonomekazan.com/contents/akashic-field-editor/2023-09-19_001/content.json"; const gameUrl = utils.getGameURL() ?? targetGameUrl; const controller = handleGame(gameUrl); initMapEditorPage(controller); const canvas = utils.qsThrowIfNotExists(document, "#chipCanvas"); const objectCanvas = utils.qsThrowIfNotExists(document, "#objectChipCanvas"); const spriteSheetData: SpriteSheetData[] = [ { src: "/assets/mapImages/wall.png", characters: ["1"], sheetType: "autoTile" }, { src: "/assets/mapImages/ice_road.png", characters: ["3"], sheetType: "autoTile" }, { src: "/assets/mapImages/map.png", characters: [ "", "", "0", "W", "4", "", "6", "9", "黒", "", "", "", "", "", "", "", "", "", "", "", "", "", "黒道", "", "-", "", "", "", "", "", "A2", "B2", "C2", "D2", "A3", "B3", "C3", "D3", ], sheetType: "normal", }, ]; const objectSpriteSheetData: SpriteSheetData[] = [ { src: "/assets/mapImages/map.png", characters: ["-"], sheetType: "single", chipPos: 24, defaultChipPos: 24, }, { src: "/assets/mapImages/map.png", characters: [""], sheetType: "single", chipPos: 23, }, { src: "/assets/mapImages/door.png", characters: ["10"], sheetType: "autoTile" }, { src: "/assets/mapImages/map.png", characters: [ "", "", "0", "", "4", "-4", "6", "9", "黒", "エ", "5", "7", "8", "A", "B", "C", "D", "A魔", "B魔", "C魔", "D魔", "柵", "", "", "", "水", "溶", "木", "灰", "穴", "", "", "", "", "", "", "", "", ], sheetType: "normal", }, { src: "/assets/mapImages/warp.png", characters: ["W1", "W2", "W3", "W4", "W5"], sheetType: "normal", }, { src: "/assets/mapImages/cbuttons.png", characters: ["A1"], sheetType: "single", chipPos: 0, }, { src: "/assets/mapImages/cbuttons.png", characters: ["B1"], sheetType: "single", chipPos: 3, }, { src: "/assets/mapImages/cbuttons.png", characters: ["C1"], sheetType: "single", chipPos: 6, }, { src: "/assets/mapImages/cbuttons.png", characters: ["D1"], sheetType: "single", chipPos: 9, }, { src: "/assets/mapImages/ice_block.png", characters: ["IceBlock"], sheetType: "single", chipPos: 0, }, { src: "/assets/mapImages/levers.png", characters: ["AL"], sheetType: "single", chipPos: 0, }, { src: "/assets/mapImages/levers.png", characters: ["BL"], sheetType: "single", chipPos: 2, }, { src: "/assets/mapImages/levers.png", characters: ["CL"], sheetType: "single", chipPos: 4, }, { src: "/assets/mapImages/levers.png", characters: ["DL"], sheetType: "single", chipPos: 6, }, { src: "/assets/mapImages/gates.png", characters: ["GA"], sheetType: "single", chipPos: 0, }, { src: "/assets/mapImages/gates.png", characters: ["GB"], sheetType: "single", chipPos: 5, }, { src: "/assets/mapImages/gates.png", characters: ["GC"], sheetType: "single", chipPos: 10, }, { src: "/assets/mapImages/gates.png", characters: ["GD"], sheetType: "single", chipPos: 15, }, { src: "/assets/mapImages/kanban.png", characters: ["黒看板"], sheetType: "single", chipPos: 0, }, { src: "/assets/mapImages/kanban.png", characters: ["白看板"], sheetType: "single", chipPos: 5, }, { src: "/assets/mapImages/kanban.png", characters: ["赤看板"], sheetType: "single", chipPos: 1, }, { src: "/assets/mapImages/kanban.png", characters: ["青看板"], sheetType: "single", chipPos: 2, }, { src: "/assets/mapImages/kanban.png", characters: ["黄看板"], sheetType: "single", chipPos: 3, }, { src: "/assets/mapImages/kanban.png", characters: ["緑看板"], sheetType: "single", chipPos: 4, }, { src: "/assets/mapImages/walls.png", characters: ["2"], sheetType: "single", chipPos: 0, }, { src: "/assets/mapImages/walls.png", characters: ["MO"], sheetType: "single", chipPos: 1, }, { src: "/assets/mapImages/walls.png", characters: ["MP"], sheetType: "single", chipPos: 2, }, { src: "/assets/mapImages/walls.png", characters: ["MM"], sheetType: "single", chipPos: 3, }, { src: "/assets/mapImages/walls.png", characters: ["MB"], sheetType: "single", chipPos: 4, }, { src: "/assets/mapImages/letters.png", characters: ["赤"], sheetType: "single", chipPos: 0, }, { src: "/assets/mapImages/letters.png", characters: ["青"], sheetType: "single", chipPos: 1, }, { src: "/assets/mapImages/letters.png", characters: ["黄"], sheetType: "single", chipPos: 2, }, { src: "/assets/mapImages/letters.png", characters: ["緑"], sheetType: "single", chipPos: 3, }, { src: "/assets/mapImages/buttons.png", characters: ["FB"], sheetType: "single", chipPos: 12, }, { src: "/assets/mapImages/sbuttons.png", characters: ["FB2"], sheetType: "single", chipPos: 0, }, { src: "/assets/mapImages/木の人形.png", characters: ["木の人形"], sheetType: "single", chipPos: 0, }, { src: "/assets/mapImages/Coin.png", characters: ["Coin"], sheetType: "single", chipPos: 0, }, { src: "/assets/mapImages/treasure.png", characters: ["宝"], sheetType: "single", chipPos: 1, }, ]; new MapEditor(spriteSheetData, 32, controller, canvas); const objectChipSelector = new ObjectChipSelector(objectSpriteSheetData, 32, controller, objectCanvas); objectChipSelector.setObjectChipEvent(setObjectModalImageCharacter); controller.setObjectChipSelector(objectChipSelector); } errorPage() { const infoNow = utils.qsThrowIfNotExists(document, "#infoNow"); const infoUid = utils.qsThrowIfNotExists(document, "#infoUid"); const infoToken = utils.qsThrowIfNotExists(document, "#infoToken"); const infoPlayId = utils.qsThrowIfNotExists(document, "#infoPlayId"); const infoUa = utils.qsThrowIfNotExists(document, "#infoUa"); const infoUaBrands = utils.qsThrowIfNotExists(document, "#infoUaBrands"); const infoDisplay = utils.qsThrowIfNotExists(document, "#infoDisplay"); function refreshScreenInfo() { infoUa.textContent = navigator.userAgent; if (navigator.userAgentData == null) { infoUaBrands.textContent = "userAgentData無し"; } else if (navigator.userAgentData.brands == null) { infoUaBrands.textContent = "brands無し"; } else if (Array.isArray(navigator.userAgentData.brands)) { if (navigator.userAgentData.brands.length === 0) { infoUaBrands.textContent = "空配列"; } else { infoUaBrands.textContent = navigator.userAgentData.brands .map((brand) => `${brand.brand},${brand.version}`) .join(", "); } } else { infoUaBrands.textContent = typeof navigator.userAgentData.brands; } const orientation = (screen.orientation ?? {}).type; const infoScreens = [ // 基本 `${screen.width}x${screen.height}/${window.devicePixelRatio}`, // mac用 `${screen.availWidth}/${screen.availHeight}`, // スマホ画面サイズ取得用 `${window.innerWidth}x${window.innerHeight}`, // スマホ向き `${orientation}`, ]; infoDisplay.textContent = infoScreens.join(", "); } const now = new Date(); const ticketCode = utils.getTicketCode(); infoNow.textContent = now.toLocaleDateString(); infoUid.textContent = "-"; infoToken.textContent = ticketCode ?? "-"; infoPlayId.textContent = this.getPlayId() ?? "-"; refreshScreenInfo(); this.addEventListener("ready", () => { infoUid.textContent = this.firebase.user?.uid ?? "取得に失敗"; }); window.addEventListener("resize", () => { refreshScreenInfo(); }); window.addEventListener("orientationchange", () => { refreshScreenInfo(); }); } openingPage() { const playId = this.getPlayId(); if (playId == null) { utils.moveTo("/"); return; } const nextButton = document.querySelector("#btnNext"); if (nextButton == null) { throw new Error("次へボタンがありません"); } nextButton.addEventListener("click", async () => { utils.moveTo(`/plays/${playId}/game.html`); }); } endingPage() { const playId = this.getPlayId(); if (playId == null) { utils.moveTo("/"); return; } const nextButton = document.querySelector("#btnNext"); if (nextButton == null) { throw new Error("次へボタンがありません"); } nextButton.addEventListener("click", async () => { utils.moveTo(`/plays/${playId}/result.html`); }); } resultPage() { this.addEventListener( "ready", () => { const clearImage = new Image(); clearImage.addEventListener( "load", () => { this.resultPageReady(clearImage); }, { once: true } ); clearImage.src = "/static/assets/img/clear.webp"; }, { once: true } ); } resultPageReady(clearImage: HTMLImageElement) { const playId = this.getPlayId(); const dlImage = utils.qsThrowIfNotExists(document, "#dlImage"); const dlLink = utils.qsThrowIfNotExists(document, "#dlWrap__dl"); const context = dlImage.getContext("2d")!; const docRef = firestore.doc(this.firebase.instance.firestore, `/plays/${playId}`); this.activateCustomModal(); const sizeRatio = dlImage.width / clearImage.width; const fontSize = 72 * sizeRatio; this.tryActivateMemberCheck(); firestore.onSnapshot(docRef, (snapshotRef) => { if (snapshotRef.exists() !== true) { this.showCustomModal("#errorModal", "データの取得に失敗しました"); return; } const data = snapshotRef.data() as common.GamePlay; context.save(); context.drawImage(clearImage, 0, 0, dlImage.width, dlImage.height); context.font = `${fontSize}px jfdot`; context.textAlign = "center"; context.fillStyle = "#ffffff"; data.players.forEach((player: common.GamePlayer, index) => { const playerName = player.initialPlayerName ?? "名無し"; context.fillText(playerName, (590 + 522 * index) * sizeRatio, 1350 * sizeRatio); }); context.restore(); dlLink.href = dlImage.toDataURL(); }); } async memberPage() { this.activateCustomModal(); const url = localStorage.getItem("memberContentUrl"); if (!url) { this.showCustomModal("#errorModal", "団員情報が取得できません。"); return; } this.inGameStandalone(url); } async memberCheckPage() { this.activateCustomModal(); const urlObj = new URL(window.location.href); const mode = urlObj.searchParams.get("openid.mode"); // 連携承認応答時 if (mode === "id_res") { // return_toの検証 const returnToUrl = urlObj.searchParams.get("openid.return_to"); if (returnToUrl === null) { throw new Error("コールバックURLが不正です"); } const returnToUrlObj = new URL(returnToUrl); if (urlObj.origin !== returnToUrlObj.origin || urlObj.pathname !== returnToUrlObj.pathname) { throw new Error("コールバックURLが不正です"); } returnToUrlObj.searchParams.forEach((value, key) => { if (!urlObj.searchParams.has(key) || urlObj.searchParams.get(key) !== value) { throw new Error("コールバックURLが不正です"); } }); // 連携承認応答の検証 const checkResultResponse = await client.api.members.checkMemberLogin(this.apiClient, urlObj.href); if (checkResultResponse.data.result !== true) { this.showCustomModal("#errorModal", "団員判定に失敗しました。"); return; } const contentUrl = checkResultResponse.data.contentUrl; if (contentUrl == null) { this.showCustomModal( "#errorModal", "コンテンツURLの取得に失敗しました。
管理者に連絡してください。" ); return; } localStorage.setItem("memberContentUrl", contentUrl); utils.moveTo("./members.html"); return; } const urlData = await client.api.members.checkMember(this.apiClient); const loginUrl = urlData.data.url; location.href = loginUrl; } getPlayId(): string | undefined { const url = new URL(location.href); const paths = url.pathname.split("/"); return paths[2]; } beginPlay(gameCode?: string) { const form = document.querySelector("#createPlayForm"); if (form == null) { throw new Error("Invalid form"); } const submitButton = utils.qsThrowIfNotExists(form, "button[type=submit]"); const loadingButton = utils.qsThrowIfNotExists(form, "#loadingBtn"); const roomIdInput = utils.qsThrowIfNotExists(form, "#roomId"); const loadingRoomId = utils.qsThrowIfNotExists(form, "#loadingRoomId"); const generateIdBtn = document.querySelector(".generate-btn"); submitButton.disabled = true; const copyButton = utils.qsThrowIfNotExists(form, "#copyBtn"); const backButton = utils.qsThrowIfNotExists(document, "#btnBack"); const params = new URL(window.location.href).searchParams; backButton.addEventListener("click", () => { if (params.get("backTo") === "number") utils.moveTo("number.html"); else utils.moveTo("delegate.html"); }); copyButton.addEventListener("click", async () => { if (roomIdInput.value != null) { try { navigator.clipboard.writeText(roomIdInput.value); } catch (e) { roomIdInput.select(); document.execCommand("copy"); } } this.showCustomModal("#errorModal", `ルームコード「${roomIdInput.value}」をコピーしました`); }); const ticketCode = utils.getTicketCode(); generateIdBtn?.addEventListener("click", async () => { if (ticketCode == null) { this.showCustomModal("#errorModal", "無効なURLです。"); return; } loadingRoomId.classList.remove("d-none"); const idToken = await this.tryRefreshIdToken(); const playIdResult = await client.api.playIds.create(this.apiClient, idToken); submitButton.disabled = false; if (playIdResult) { loadingRoomId.classList.add("d-none"); copyButton.disabled = false; } roomIdInput.value = playIdResult.data.playId; roomIdInput.classList.remove("d-none"); generateIdBtn.disabled = true; }); form.addEventListener("submit", async (e) => { e.preventDefault(); submitButton.disabled = true; submitButton.classList.add("d-none"); loadingButton.classList.remove("d-none"); if (ticketCode == null) { this.showCustomModal("#errorModal", "無効なURLです。"); throw new Error("無効なURLです。"); } try { const idToken = await this.tryRefreshIdToken(); const play = await createPlay(this.apiClient, { gameCode: gameCode ?? "test", idToken, roomId: roomIdInput.value, contentCode: utils.getContentCode(), contentVersion: utils.getContentVersion(), ticketCode: ticketCode, }); utils.moveTo(`/plays/${play.data.id}/select.html`); } catch (error) { const errorResponse = error as client.CallApiError; const errorMessage = errorResponse.response.meta?.message; if (errorMessage != null) { submitButton.disabled = false; submitButton.classList.remove("d-none"); loadingButton.classList.add("d-none"); // 40051: トークンが無効です。 // 40901: そのルームコードはすでに利用されています。 // 40902: こちらのURLはすでに別のユーザーによって利用されています。 // 40903: すでにこちらのURLを利用して作成した部屋が存在します。 this.showCustomModal("#errorModal", errorMessage); } else { console.error("undefined error", error); this.showCustomModal("#errorModal", (error as Error).message); } } }); } async join(roomId: string, submitButton: HTMLButtonElement, loadingButton: HTMLButtonElement) { submitButton.disabled = true; submitButton.classList.add("d-none"); loadingButton.classList.remove("d-none"); try { const play = await client.api.plays.getPlay(this.apiClient, roomId); const playState = play.data.play.state; if (play == null) throw new Error("not found"); if (playState !== "standby") { this.showCustomModal( "#errorModal", "そのルームコードは、ページを戻り「つづきから」で入力してください。" ); submitButton.disabled = false; submitButton.classList.remove("d-none"); loadingButton.classList.add("d-none"); return; } utils.moveTo(`/plays/${roomId}/select.html`); } catch (error) { const callApiError = error as client.CallApiError; if (callApiError.response?.meta?.errorCode === 40905) { this.showCustomModal("#errorModal", "代表者が入室するのをお待ちください。"); } else { this.showCustomModal("#errorModal", "正しいルームコードを入力してください"); } submitButton.disabled = false; submitButton.classList.remove("d-none"); loadingButton.classList.add("d-none"); } } joinPlay(gameCode?: string) { const form = document.querySelector("#joinPlayForm"); if (form == null) { throw new Error("Invalid form"); } const submitButton = form.querySelector("button[type=submit]"); const loadingButton = form.querySelector("#loadingBtn"); const roomIdInput = form.querySelector("#roomId"); if (submitButton == null || loadingButton == null) { throw new Error("HTMLにButton要素がありません"); } if (roomIdInput == null) { throw new Error("HTMLにroomIdというIDの要素がありません"); } const backButton = document.querySelector("#btnBack"); if (backButton == null) { throw new Error("Backボタンがありません。"); } backButton.addEventListener("click", () => { utils.moveTo("delegate.html"); }); submitButton.disabled = true; this.activateCustomModal(); this.addEventListener("ready", () => { submitButton.disabled = false; }); form.addEventListener("submit", async (e) => { e.preventDefault(); if (roomIdInput.value == "") { this.showCustomModal("#errorModal", "ルームコードを入力してください"); return; } if (this.firebase.idToken == null) { this.showCustomModal("#errorModal", "準備中です"); return; } this.join(roomIdInput.value, submitButton, loadingButton); }); } continuePlay(gameCode?: string) { const form = document.querySelector("#continuePlayForm"); if (form == null) { throw new Error("Invalid form"); } const submitButton = utils.qsThrowIfNotExists(document, "button[type=submit]"); const loadingButton = utils.qsThrowIfNotExists(document, "#loadingBtn"); const roomIdInput = utils.qsThrowIfNotExists(document, "#roomId"); const backButton = utils.qsThrowIfNotExists(document, "#btnBack"); backButton.addEventListener("click", () => { utils.moveTo("index.html"); }); submitButton.disabled = true; this.activateCustomModal(); this.addEventListener("ready", () => { submitButton.disabled = false; }); form.addEventListener("submit", async (e) => { e.preventDefault(); const roomId = roomIdInput.value; if (roomId === "") { this.showCustomModal("#errorModal", "ルームコードを入力してください"); return; } if (this.firebase.idToken == null) { this.showCustomModal("#errorModal", "準備中です"); return; } submitButton.disabled = true; submitButton.classList.add("d-none"); loadingButton.classList.remove("d-none"); try { const idToken = await this.tryRefreshIdToken(); const result = await client.api.plays.resumePlay(this.apiClient, roomId, idToken); utils.moveTo(`/plays/${result.data.id}/game.html`); } catch (error) { const apiError = error as client.CallApiError; if (apiError.response?.meta?.errorCode === 40001) { // すでに開始されているなら次へ utils.moveTo(`/plays/${roomId}/game.html`); return; } else if (apiError.response?.meta?.status === 404) { this.showCustomModal("#errorModal", "ルームコードが間違っています"); } else if (apiError.response?.meta?.status === 403) { this.showCustomModal( "#errorModal", "このルームIDは既に使用されています。
代表者に共有された正しいルームIDを入力してください。" ); } else if (apiError.response?.meta?.status === 400) { this.showCustomModal("#errorModal", "再開できません"); } else { console.error(error); this.showCustomModal("#errorModal", `エラーです: ${(error as Error).message}`); } submitButton.disabled = false; submitButton.classList.remove("d-none"); loadingButton.classList.add("d-none"); } }); } delegate() { const backButton = document.querySelector("#btnBack"); if (backButton == null) { throw new Error("Backボタンがありません。"); } const delegateButton = document.querySelector("#btnDelegate"); if (delegateButton == null) { throw new Error("代表者の方はこちらボタンがありません。"); } const otherButton = document.querySelector("#btnOther"); if (otherButton == null) { throw new Error("代表者以外の方はこちらボタンがありません。"); } backButton.addEventListener("click", () => { utils.moveTo("number.html"); }); delegateButton.addEventListener("click", () => { utils.moveTo("create.html"); }); otherButton.addEventListener("click", () => { utils.moveTo("enterRoom.html"); }); } number() { const backButton = document.querySelector("#btnBack"); if (backButton == null) { throw new Error("Backボタンがありません。"); } const manyButton = document.querySelector("#btnMany"); if (manyButton == null) { throw new Error("2~4人ボタンがありません。"); } const oneButton = document.querySelector("#btnOne"); if (oneButton == null) { throw new Error("1人ボタンがありません。"); } backButton.addEventListener("click", () => { utils.moveTo("index.html"); }); manyButton.addEventListener("click", () => { utils.moveTo("delegate.html"); }); oneButton.addEventListener("click", () => { utils.moveTo("create.html?backTo=number"); }); } playerNumber() { const backButton = document.querySelector("#btnBack"); if (backButton == null) { throw new Error("backボタンがありません。"); } const multiButton = document.querySelector("#btnMulti"); if (multiButton == null) { throw new Error("2~4人ボタンがありません。"); } const oneButton = document.querySelector("#btnOne"); if (oneButton == null) { throw new Error("1人ボタンがありません。"); } backButton.addEventListener("click", () => { utils.moveTo("create.html"); }); multiButton.addEventListener("click", () => { utils.moveTo("delegate.html"); }); oneButton.addEventListener("click", () => { utils.moveTo("create.html"); }); } memberUniquePass() { const inputButton = document.querySelector("#inputBtn"); if (inputButton == null) { throw new Error("開始ボタンがありません。"); } document.querySelector("#continuePlayForm")!.addEventListener("submit", (e) => { e.preventDefault(); }); inputButton.addEventListener("click", () => { utils.moveTo("create.html"); }); } async selectPlayer() { const playId = this.getPlayId(); if (playId == null) { utils.moveTo("/"); return; } this.activateCustomModal(); const selectForm = document.querySelector("#selectPlayerForm"); if (!selectForm) { throw new Error("プレイヤー選択フォームがありません。"); } const startForm = document.querySelector("#startPlayForm"); if (!startForm) { throw new Error("プレイ開始フォームがありません。"); } const submitButton = startForm.querySelector("button[type=submit]"); if (submitButton) { submitButton.disabled = true; } else { throw new Error("開始ボタンがありません。"); } const ticketCode = utils.getTicketCode(); if (ticketCode == null) { this.showCustomModal("#errorModal", "無効なURLです。"); throw new Error("無効なURLです。"); } const myPlayerIndex = utils.qsThrowIfNotExists(selectForm, "#myPlayerIndex"); this.addEventListener("ready", () => { selectForm.querySelectorAll("button").forEach((button) => { button.disabled = false; }); }); function getPlayerIndex(players: common.GamePlayer[], playerId: string) { let index = 0; const alreadyPlayerId: { [key: string]: boolean } = {}; for (let i = 0; i < players.length; i++) { const targetPlayerId = players[i].playerId; if (targetPlayerId != null) { if (alreadyPlayerId[targetPlayerId] == null) { alreadyPlayerId[targetPlayerId] = true; ++index; } } if (targetPlayerId === playerId) { return index; } } return -1; } const changePlayers = (players: common.GamePlayer[]) => { myPlayerIndex.innerHTML = `まだキャラクターが選択されていません。`; let playerCount = 0; players.forEach((player, i) => { const characterContainer = selectForm!.querySelector(`#c${player.characterId}`); const playerIdElement = characterContainer?.querySelector(".playerId"); const openModalBtn = selectForm!.querySelectorAll(".open-modal-btn"); const playerIndex = player.playerId == null ? -1 : getPlayerIndex(players, player.playerId); const playerCaption = playerIndex === -1 ? "-" : `プレイヤー${playerIndex}`; playerIdElement!.textContent = playerCaption; // palyerIdも表示したいならこっち使う // playerIdElement!.textContent = playerCaption == "-" ? playerCaption : `${playerCaption}:${player.playerId}`; if (player.playerId === this.firebase.user?.uid) { myPlayerIndex.innerHTML = `あなたは「${playerCaption}」です。`; } if (player.playerId != null) { openModalBtn[i]?.classList.add("selected"); ++playerCount; } else { openModalBtn[i]?.classList.remove("selected"); } }); if (submitButton) { submitButton.disabled = playerCount !== players.length; } }; const confirmModals = document.querySelectorAll(".confirm-modal"); const cancelBtns = document.querySelectorAll(".cancel-btn"); const modalMask = utils.qsThrowIfNotExists(selectForm, ".confirm-modal-mask"); function closeModal() { confirmModals.forEach((modal) => { modal.classList.add("d-none"); }); modalMask.classList.add("d-none"); } modalMask.addEventListener("click", () => { closeModal(); }); const docRef = firestore.doc(this.firebase.instance.firestore, `/plays/${playId}`); let initialized = false; const res = await getPlay(this.apiClient, playId); let playing = false; firestore.onSnapshot(docRef, (snapshotRef) => { if (snapshotRef.exists() !== true) { this.showCustomModal("#errorModal", "データの取得に失敗しました"); return; } const data = snapshotRef.data() as common.GamePlay; if (data.state == "playing") { // API呼び出し中はそっちに処理を任せる if (playing) return; const playingPath = `/plays/${playId}/opening.html`; if (location.pathname != playingPath) { utils.moveTo(playingPath); } return; } if (initialized === false) { const selector = selectForm.querySelector("#playerSelector"); if (selector == null) { throw new Error("Invalid select form"); } data.players.forEach((player: common.GamePlayer) => { const character = res.data.game.characters.find( (character: { id: string }) => character.id === player.characterId ); if (character == null) { throw new Error("不正なキャラクターIDが含まれています"); } const container = document.createElement("div"); container.classList.add("col-6", "col-lg-3", "p-0"); container.id = `c${player.characterId}`; container.innerHTML = `
${character.name}
-
`; container.querySelector("button")!.addEventListener("click", async () => { if (this.firebase.idToken == null || this.firebase.user == null) { this.showCustomModal("#errorModal", "準備中です"); return; } const characterModal = utils.qsThrowIfNotExists( selectForm, `#${character.id}modal` ); characterModal.classList.remove("d-none"); modalMask.classList.remove("d-none"); }); selector.appendChild(container); }); const selectBtns = selectForm.querySelectorAll(".player-select-btn"); cancelBtns.forEach((button, i) => { button.addEventListener("click", async () => { if (this.firebase.idToken == null || this.firebase.user == null) { return; } closeModal(); const idToken = await this.tryRefreshIdToken(); await client.api.plays.unselectPlayer( this.apiClient, playId, idToken, data.players[i].characterId ); }); }); selectBtns.forEach((button, i) => { button.addEventListener("click", async () => { if (this.firebase.idToken == null || this.firebase.user == null) { return; } closeModal(); try { const idToken = await this.tryRefreshIdToken(); await selectPlayer( this.apiClient, playId, idToken, ticketCode, data.players[i].characterId ); } catch (error) { const errorResponse = error as client.CallApiError; const errorMessage = errorResponse.response.meta?.message; // 40051: トークンが無効です。 if (errorMessage != null) { this.showCustomModal("#errorModal", errorMessage); } else { console.error("undefined error", error); this.showCustomModal("#errorModal", (error as Error).message); } } }); }); initialized = true; } changePlayers(data.players); }); selectForm.addEventListener("submit", (e) => { e.preventDefault(); }); startForm.addEventListener("submit", async (e) => { e.preventDefault(); document.querySelectorAll("button").forEach((button) => { button.disabled = true; }); document.querySelector("#submitBtn")?.classList.add("d-none"); document.querySelector("#loadingBtn")?.classList.remove("d-none"); try { playing = true; const idToken = await this.tryRefreshIdToken(); const result = await startPlay(this.apiClient, playId, idToken!); if (result.data == null || result.data.id == null) { this.showCustomModal("#errorModal", "開始に失敗しました。"); // TODO reset d-none & etc. document.querySelectorAll("button").forEach((button) => { button.disabled = false; }); playing = false; return; } } catch (error) { if (error instanceof client.CallApiError) { if (error.response.meta?.errorCode === 50303) { this.showCustomModal( "#errorModal", "現在混みあっており入室することができません。\nしばらく経ってから再度お試しください。" ); } else { this.showCustomModal("#errorModal", error.response.meta.message ?? "不明なエラーです"); } return; } this.showCustomModal("#errorModal", (error as any).message ?? "不明なエラーです"); document.querySelectorAll("button").forEach((button) => { button.disabled = false; }); playing = false; return; } utils.moveTo(`/plays/${playId}/opening.html`); }); } isMyCharacter(play: common.GamePlay, characterId: string) { const character = play.players.find((player: any) => player.characterId === characterId); if (character == null) { return false; } return character.playerId === this.firebase.user!.uid; } loadGameConfig(): { bgm: number; se: number } { const savedConfigStr = localStorage.getItem("quarteConfig"); try { if (savedConfigStr != null) { const savedConfig = JSON.parse(savedConfigStr); if (savedConfig.bgm == null || typeof savedConfig.bgm !== "number") throw new Error("bgm is null"); if (savedConfig.se == null || typeof savedConfig.se !== "number") throw new Error("se is null"); return savedConfig; } } catch { console.warn("保存されたボリューム情報が不正です", savedConfigStr); } return { bgm: 0.15, se: 0.15, }; } activateHintContent(selectHintForm: HTMLElement, id: string) { const content = this.hintContents.find((content) => content[0] === id); if (content == null) return; const elementId = `hint${content[0]}`; if (selectHintForm.querySelector(`#${elementId}`) != null) return; const formItem = document.createElement("option"); formItem.value = `${content[0]}`; formItem.textContent = `${content[0]}`; formItem.id = elementId; selectHintForm.appendChild(formItem); } toggleMenu(gameUrl: string) { const soundMenu = utils.qsThrowIfNotExists(document, "#soundMenu"); const soundMenuBtn = utils.qsThrowIfNotExists(document, "#showSoundMenu"); const soundMenuCloseBtn = utils.qsThrowIfNotExists(document, "#closeSoundMenu"); const soundMenuMask = utils.qsThrowIfNotExists(document, "#soundMenuMask"); const hintMenu = document.querySelector("#hintMenu"); const hintMenuBtn = document.querySelector("#showHintMenu"); const hintMenuCloseBtn = document.querySelector("#closeHintMenu"); const hintMenuMask = document.querySelector("#hintMenuMask"); const manualModalElement = document.querySelector("#ManualModal"); // ゲームコードの取得 const pathArr = gameUrl.split("/"); const gameCode = pathArr[4]; if (manualModalElement != null) { const manualModal = new bootstrap.Modal(manualModalElement); if (gameCode === "akashic-field-conan") { manualModal.show(); } } //音量メニューの表示/非表示 function toggleSoundMenu() { if (hintMenu?.classList.contains("d-block")) { toggleHintMenu(); } soundMenuBtn.classList.toggle("active"); soundMenu.classList.toggle("d-none"); soundMenu.classList.toggle("d-block"); soundMenuMask.classList.toggle("d-none"); } soundMenuBtn.addEventListener("click", () => { toggleSoundMenu(); }); soundMenuCloseBtn.addEventListener("click", () => { toggleSoundMenu(); }); soundMenuMask.addEventListener("click", () => { toggleSoundMenu(); }); // ヒントメニューの表示/非表示 function toggleHintMenu() { if (hintMenu == null) return; if (soundMenu.classList.contains("d-block")) { toggleSoundMenu(); } hintMenuBtn!.classList.toggle("active"); hintMenu.classList.toggle("d-none"); hintMenu.classList.toggle("d-block"); hintMenuMask?.classList.toggle("d-none"); } if (hintMenu != null) { hintMenuBtn!.addEventListener("click", () => { toggleHintMenu(); }); hintMenuCloseBtn!.addEventListener("click", () => { toggleHintMenu(); }); hintMenuMask?.addEventListener("click", () => { toggleHintMenu(); }); const selectHintForm = utils.qsThrowIfNotExists(document, "#selectHint"); const hintTitle = utils.qsThrowIfNotExists(document, ".hint-title"); const hintContainer = utils.qsThrowIfNotExists(document, ".carousel-inner"); const indicatorContainer = utils.qsThrowIfNotExists(document, ".carousel-indicators"); this.hintContents = [ ["ヒント", ["hint_hyoushi.png"]], ["2F", ["2F_hint_01.png"]], ["3F", ["3F_hint_01.png"]], ["4F", ["4F_hint_01.png", "4F_hint_02.png"]], ["5F", ["5F_hint_01.png", "5F_hint_02.png"]], ["6F", ["6F_hint_01.png", "6F_hint_02.png", "6F_hint_03.png", "6F_hint_04.png"]], ["7F", ["7F_hint_01.png", "7F_hint_02.png"]], ["8F", ["8F_hint_01.png", "8F_hint_02.png"]], ["9F", ["9F_hint_01.png", "9F_hint_02.png"]], ["10F", ["10F_hint_01.png", "10F_hint_02.png", "10F_hint_03.png", "10F_hint_04.png"]], ["11F", ["11F_hint_01.png", "11F_hint_02.png", "11F_hint_03.png"]], ["12F", ["12F_hint_01.png"]], ["13F", ["13F_hint_01.png"]], ["14F", ["14F_hint_01.png", "14F_hint_02.png"]], ["15F", ["15F_hint_01.png", "15F_hint_02.png", "15F_hint_03.png"]], ["16F", ["16F_hint_01.png", "16F_hint_02.png", "16F_hint_03.png", "16F_hint_04.png"]], ["17F", ["17F_hint_01.png", "17F_hint_02.png", "17F_hint_03.png", "17F_hint_04.png"]], ["18F", ["18F_hint_01.png", "18F_hint_02.png", "18F_hint_03.png"]], ["19F", ["19F_hint_01.png", "19F_hint_02.png", "19F_hint_03.png"]], ["20F", ["20F_hint_01.png", "20F_hint_02.png", "20F_hint_03.png", "20F_hint_04.png"]], ["21F", ["21F_hint_01.png", "21F_hint_02.png"]], ["22F", ["22F_hint_01.png", "22F_hint_02.png", "22F_hint_03.png"]], ["23F", ["23F_hint_01.png", "23F_hint_02.png"]], ["24F", ["24F_hint_01.png", "24F_hint_02.png", "24F_hint_03.png", "24F_hint_04.png"]], ["25F", ["25F_hint_01.png", "25F_hint_02.png", "25F_hint_03.png"]], ["26F", ["26F_hint_01.png", "26F_hint_02.png"]], ["27F", ["27F_hint_01.png", "27F_hint_02.png", "27F_hint_03.png", "27F_hint_04.png"]], ["28F", ["28F_hint_01.png", "28F_hint_02.png"]], ["29F", ["29F_hint_01.png", "29F_hint_02.png", "29F_hint_03.png", "29F_hint_04.png"]], ["30F", ["30F_hint_01.png", "30F_hint_02.png", "30F_hint_03.png"]], [ "31F", [ "31F_hint_01.png", "31F_hint_02.png", "31F_hint_03.png", "31F_hint_04.png", "31F_hint_05.png", "31F_hint_06.png", ], ], ["32F", ["32F_hint_01.png"]], [ "33F", [ "33F_hint_01.png", "33F_hint_02.png", "33F_hint_03.png", "33F_hint_04.png", "33F_hint_05.png", "33F_hint_06.png", "33F_hint_07.png", "33F_hint_08.png", "33F_hint_09.png", "33F_hint_10.png", "33F_hint_11.png", "33F_hint_12.png", "33F_hint_13.png", "33F_hint_14.png", "33F_hint_15.png", "33F_hint_16.png", ], ], [ "34F", [ "34F_hint_01.png", "34F_hint_02.png", "34F_hint_03.png", "34F_hint_04.png", "34F_hint_05.png", "34F_hint_06.png", "34F_hint_07.png", "34F_hint_08.png", "34F_hint_09.png", ], ], ]; let initialEnabledHintCount = 2; // ヒント内容の切り替え if (gameCode === "akashic-field-members") { this.hintContents = [ ["ヒント", ["hint_hyoushi.png"]], [ "ブロックのパズル", ["団員ヒント_1.png", "団員ヒント_2.png", "団員ヒント_3.png", "団員ヒント_4.png"], ], ["氷のパズル", ["団員ヒント_5.png", "団員ヒント_6.png", "団員ヒント_7.png", "団員ヒント_8.png"]], [ "溶岩のパズル", ["団員ヒント_9.png", "団員ヒント_10.png", "団員ヒント_11.png", "団員ヒント_12.png"], ], [ "3つのパズルが解けたあと", ["団員ヒント_13.png", "団員ヒント_14.png", "団員ヒント_15.png", "団員ヒント_16.png"], ], ]; initialEnabledHintCount = this.hintContents.length; } for (let i = 0; i < initialEnabledHintCount; i++) { this.activateHintContent(selectHintForm, this.hintContents[i][0]); } const changeHintContent = () => { const selectedFloor = selectHintForm.value; if (selectedFloor === "ヒント") { hintTitle.textContent = "ヒント"; } else { hintTitle.textContent = `${selectedFloor}のヒント`; } hintContainer.innerHTML = ""; indicatorContainer.innerHTML = ""; this.hintContents.forEach((content) => { if (content[0] === selectedFloor) { const hintImgs = content[1]; if (hintImgs.length === 1) { utils .qsThrowIfNotExists(document, ".carousel-control-prev") .classList.add("d-none"); utils .qsThrowIfNotExists(document, ".carousel-control-next") .classList.add("d-none"); indicatorContainer.classList.add("d-none"); } else { utils .qsThrowIfNotExists(document, ".carousel-control-prev") .classList.remove("d-none"); utils .qsThrowIfNotExists(document, ".carousel-control-next") .classList.remove("d-none"); indicatorContainer.classList.remove("d-none"); } for (let i = 0; i < hintImgs.length; i++) { const hintImg = hintImgs[i]; const carouselItem = document.createElement("div"); const carouselIndicator = document.createElement("button"); if (i === 0) { carouselItem.classList.add("active"); carouselIndicator.classList.add("active"); } carouselItem.classList.add("carousel-item"); carouselItem.innerHTML = `
...
`; carouselIndicator.setAttribute("type", "button"); carouselIndicator.setAttribute("data-bs-target", "#carouselIndicators"); carouselIndicator.setAttribute("data-bs-slide-to", `${i}`); carouselIndicator.setAttribute("aria-current", "true"); carouselIndicator.setAttribute("aria-label", `slide ${i}`); hintContainer.appendChild(carouselItem); indicatorContainer.appendChild(carouselIndicator); } } }); }; changeHintContent(); selectHintForm.addEventListener("change", () => { changeHintContent(); }); } } leaveMenu(playId: string) { const modal = new bootstrap.Modal(utils.qsThrowIfNotExists(document, "#leaveModal")); const leaveButton = document.querySelector("#leaveSubmitButton"); if (leaveButton == null) return; const roomCode = document.querySelector("#roomCode"); if (roomCode != null) { roomCode.textContent = playId; } leaveButton.addEventListener("click", async () => { modal.hide(); const idToken = await this.tryRefreshIdToken(); await client.api.plays.pausePlay(this.apiClient, playId, idToken); // この後の処理はスナップショット側で実施するのでここでは書かない }); } soundMenu(controller: GameController, config: { bgm: number; se: number }) { function changeConfig() { const bgmVoluem = bgmMuteSwitch.checked ? bgmVolumeSlider.valueAsNumber / 100 : 0; const seVolume = seMuteSwitch.checked ? seVolumeSlider.valueAsNumber / 100 : 0; const currentConfig = { bgm: bgmVoluem, se: seVolume, }; controller.doConfigEvent(currentConfig); localStorage.setItem("quarteConfig", JSON.stringify(currentConfig)); } function adjustSliderStyle(slider: HTMLInputElement) { const value = slider.value; const color = `linear-gradient(90deg, #F2F2F2 ${value}%, #333 ${value}%)`; slider.style.background = color; } // 音量メニューのスライダー const bgmVolumeSlider = utils.qsThrowIfNotExists(document, "#bgmVolumeSlider"); const seVolumeSlider = utils.qsThrowIfNotExists(document, "#seVolumeSlider"); bgmVolumeSlider.value = `${config.bgm * 100}`; seVolumeSlider.value = `${config.se * 100}`; const sliders = [bgmVolumeSlider, seVolumeSlider]; sliders.forEach((slider, i) => { adjustSliderStyle(slider); slider.addEventListener("input", (e) => { muteSwitches[i].checked = true; adjustSliderStyle(e.target as HTMLInputElement); changeConfig(); }); }); const bgmMuteSwitch = utils.qsThrowIfNotExists(document, ".bgm-mute-switch"); const seMuteSwitch = utils.qsThrowIfNotExists(document, ".se-mute-switch"); const muteSwitches = [bgmMuteSwitch, seMuteSwitch]; muteSwitches.forEach((muteSwitch) => { muteSwitch.addEventListener("change", () => { changeConfig(); }); }); } async replayGame(targetGameUrl?: string) { utils.qsThrowIfNotExists(document, "#playlog").value = localStorage.getItem("playlog") ?? ""; utils.qsThrowIfNotExists(document, "#playerId").value = localStorage.getItem("playerId") ?? ""; this.addEventListener( "ready", () => { this.replayGameStandaloneReady(targetGameUrl); }, { once: true } ); } async replayGameStandaloneReady(targetGameUrl?: string) { const button = utils.qsThrowIfNotExists(document, "#btnReplay"); const gameUrl = utils.getGameURL() ?? targetGameUrl; const snapshots = utils.qsThrowIfNotExists(document, "#snapshots"); const playParams: Partial = {}; const timeKeeper = new TimeKeeper(Date.now(), 30, () => false); timeKeeper.start(); this.toggleMenu(gameUrl!); const createSnapshotRow = (startPoint: any) => { const tr = document.createElement("tr"); const currentFloor = startPoint.data.gameSnapshot?.state?.currentFloor as number | undefined; const note = currentFloor ? `${currentFloor}F` : "-"; tr.innerHTML = `${startPoint.frame}${note}`; tr.addEventListener("click", () => { // ts = startPoint.timestamp + 1; timeKeeper.jumpByFrame(startPoint.frame); }); return tr; }; button.addEventListener("click", async () => { if (button.textContent === "停止") { timeKeeper.isPaused = true; button.textContent = "再開"; return; } else { timeKeeper.isPaused = false; button.textContent = "停止"; if (playParams.myPlayerId != null) return; } const playlog = utils.qsThrowIfNotExists(document, "#playlog").value; const playerId = utils.qsThrowIfNotExists(document, "#playerId").value; if (!playlog) { alert("Playlogを指定してください"); return; } if (!playerId) { alert("playerIdを指定してください"); return; } if (gameUrl == null) { alert("プレーするゲームのURLを指定してください"); return; } localStorage.setItem("playlog", playlog); localStorage.setItem("playerId", playerId); playParams.myPlayerId = playerId; const gameCode = "akashic-field-conan"; playParams.play = { akashicPlayId: "replay", gameCode, ownerUserId: playParams.myPlayerId, players: ["A", "B", "C", "D"].map((key) => ({ characterId: key, playerId: playParams.myPlayerId!, playerName: "", })), state: "playing", createdAt: firestore.Timestamp.now(), }; playParams.game = { characters: [], contentCode: gameCode, }; playParams.playToken = undefined; playParams.content = { code: gameCode, url: gameUrl, }; const replayData = JSON.parse(playlog) as DumpedPlaylog; playParams.replayData = replayData; playParams.replayPlayerId = playerId; timeKeeper.reset(replayData.startPoints[0]?.timestamp ?? Date.now()); playParams.replayOriginDate = replayData.startPoints[0]?.timestamp ?? Date.now(); playParams.replayTargetTimeFunc = () => { return timeKeeper.ts; }; replayData.startPoints.forEach((startPoint) => { snapshots.appendChild(createSnapshotRow(startPoint)); }); const container = document.querySelector("#gameContainer") as HTMLDivElement; if (container == null) { throw new Error("ゲーム表示欄がありません"); } const gameConfig = this.loadGameConfig(); playParams.gameConfig = { bgmVolume: gameConfig.bgm, seVolume: gameConfig.se, snapshot: undefined, }; const controller = start(container, playParams as PlayGameParams); timeKeeper.skippingFunc = () => { return controller!.isSkipping; }; if (controller == null) { alert("ゲームが実行できませんでした"); return; } this.soundMenu(controller, gameConfig); }); } async inGameStandalone(targetGameUrl?: string) { this.addEventListener( "ready", () => { this.handleInGameStandaloneReady(targetGameUrl); }, { once: true } ); } async handleInGameStandaloneReady(targetGameUrl?: string) { const playId = "dummy"; const playParams: Partial = {}; if (this.firebase.user == null) { alert("不明なエラーが発生しました"); return; } playParams.myPlayerId = this.firebase.user.uid; const gameCode = "akashic-field-conan"; const gameUrl = utils.getGameURL() ?? targetGameUrl; if (gameUrl == null) { alert("プレーするゲームのURLを指定してください"); return; } playParams.play = { akashicPlayId: playId, gameCode, ownerUserId: playParams.myPlayerId, players: ["A", "B", "C", "D"].map((key) => ({ characterId: key, playerId: playParams.myPlayerId!, playerName: "", })), state: "playing", createdAt: firestore.Timestamp.now(), }; playParams.game = { characters: [], contentCode: gameCode, }; playParams.playToken = undefined; playParams.content = { code: gameCode, url: gameUrl, }; const container = document.querySelector("#gameContainer") as HTMLDivElement; if (container == null) { throw new Error("ゲーム表示欄がありません"); } const gameConfig = this.loadGameConfig(); playParams.gameConfig = { bgmVolume: gameConfig.bgm, seVolume: gameConfig.se, snapshot: undefined, }; if (utils.isStandaloneSaveMode()) { const savedState = localStorage.getItem("conan-online-state"); if (savedState != null) { try { playParams.gameConfig.snapshot = { idx: undefined, state: JSON.parse(savedState), }; console.log(playParams.gameConfig.snapshot); } catch (error) { console.warn("snapshot復元に失敗", error); } } } const controller = start(container, playParams as PlayGameParams); if (controller == null) { alert("ゲームが実行できませんでした"); return; } if (playParams.gameConfig.snapshot == null) { const startEvent = { type: "start", parameters: { players: [ { characterId: "A", playerId: playParams.myPlayerId, playerName: "", }, { characterId: "B", playerId: playParams.myPlayerId, playerName: "", }, { characterId: "C", playerId: playParams.myPlayerId, playerName: "", }, { characterId: "D", playerId: playParams.myPlayerId, playerName: "", }, ], }, }; const initialEventSender = () => { if (!controller.alive) { setTimeout(initialEventSender, 20); return; } controller.sendMessageEvent(startEvent); }; setTimeout(initialEventSender, 50); } this.toggleMenu(gameUrl); this.soundMenu(controller, gameConfig); this.handleCharacterEvent(playParams as PlayGameParams, controller); this.handleWarpEvent(controller); this.handleClearEvent(playParams as PlayGameParams, controller, true); this.handleGameEvent(playParams as PlayGameParams, controller, true); this.handleMediaEvent(playParams as PlayGameParams, controller); this.tryHandleChangeNameEvent(playParams as PlayGameParams, controller, true); this.handleResetModal(this.createResetConditionFunctionByGameUrl(controller, gameUrl)); } createResetConditionFunctionByGameUrl(controller: GameController, gameUrl: string) { const conditionalResetMode = gameUrl.includes("akashic-field-conan"); if (conditionalResetMode) { return () => { if (controller.clearFloors?.includes(4)) return true; return false; }; } return undefined; } // game.html async inGame() { this.addEventListener( "ready", () => { this.handleInGameReady(); }, { once: true } ); } async handleInGameReady() { const playId = this.getPlayId(); if (playId == null) { utils.moveTo("/"); return; } const ticketCode = utils.getTicketCode(); if (ticketCode == null) { alert("無効なURLです。"); throw new Error("無効なURLです。"); } const playParams: Partial = {}; if (this.firebase.user == null) { alert("不明なエラーが発生しました"); return; } playParams.myPlayerId = this.firebase.user.uid; try { const playWithGame = await playPlay(this.apiClient, playId, this.firebase.idToken!, ticketCode); playParams.game = playWithGame.data.game; playParams.play = playWithGame.data.play; playParams.content = playWithGame.data.content; playParams.playToken = playWithGame.data.playToken; } catch (error) { const errorResponse = error as client.CallApiError; const errorMessage = errorResponse.response.meta?.message; const errorCode = errorResponse.response.meta?.errorCode; // 40051: トークンが無効です。 // 40902: こちらのURLはすでに別のユーザーによって利用されています。 // 40904: トークンを発行できない状態 // 40314: スクチケのチケット検証失敗 // 40316: 参加していないルームです。 if (errorCode === 40315) { alert(`中断されています。 再開するには以下のルームコードを入力し、「つづきから」を実行してください。 ${playId}`); utils.moveTo("../../"); return; } if (errorCode === 40316) { alert(`プレイヤーとして参加していないルームには参加できません。`); utils.moveTo("../../"); return; } if (errorCode === 40904) { // 基本的にこっちのパスは無いはずだが、時間差で来るかも alert(`中断されています。 再開するには以下のルームコードを入力し、「つづきから」を実行してください。 ${playId} 「つづきから」で再開ができない場合、管理者に連絡してください。`); utils.moveTo("../../"); return; } if (errorMessage != null) { alert(errorMessage); } else { console.error("undefined error", error); alert((error as Error).message); } if (errorCode === 40051 || errorCode === 40902) { return; } } if (playParams.playToken == null) { logger.error("invalid playToken"); } // this.attachProfile(playParams as PlayGameParams); const container = document.querySelector("#gameContainer") as HTMLDivElement; if (container == null) { throw new Error("ゲーム表示欄がありません"); } const gameConfig = this.loadGameConfig(); playParams.gameConfig = { bgmVolume: gameConfig.bgm, seVolume: gameConfig.se, }; const docRef = firestore.doc(this.firebase.instance.firestore, `/plays/${this.getPlayId()}`); let unsubscribe = firestore.onSnapshot(docRef, (snapshotRef) => { if (snapshotRef.exists() !== true) { console.error("プレイデータが消えています"); return; } const data = snapshotRef.data() as common.GamePlay; const form = utils.qsThrowIfNotExists(document, "#leaveComplete"); const roomCodeInput = utils.qsThrowIfNotExists(form, ".room-code input"); const copyButton = utils.qsThrowIfNotExists(form, ".room-code button"); function copyRoomCode() { copyButton.addEventListener("click", function () { roomCodeInput.select(); document.execCommand("copy"); }); } if (data.state === "terminated") { function leaveComplete() { return new Promise((resolve) => { roomCodeInput.setAttribute("value", `${playId}`); form.classList.remove("d-none"); copyRoomCode(); form.addEventListener("submit", function (e) { e.preventDefault(); resolve(); }); }); } leaveComplete().then(function () { unsubscribe(); utils.moveTo("../../"); }); } }); const controller = start(container, playParams as PlayGameParams); if (controller == null) { location.reload(); return; } this.toggleMenu(playParams.content!.url); this.leaveMenu(playId); this.soundMenu(controller, gameConfig); this.handleGameError(controller); this.handleCharacterEvent(playParams as PlayGameParams, controller); this.handleWarpEvent(controller); this.handleClearEvent(playParams as PlayGameParams, controller); this.handleGameEvent(playParams as PlayGameParams, controller); this.handleMediaEvent(playParams as PlayGameParams, controller); this.tryHandleChangeNameEvent(playParams as PlayGameParams, controller); this.handleResetModal(this.createResetConditionFunctionByGameUrl(controller, playParams.content!.url)); } attachProfile(playParams: PlayGameParams) { const profileDropdown = document.querySelector("#profile-dropdown-menu")!; playParams.game.characters.forEach((character: { id: string | undefined; name: string | null }) => { const profileDropdownHtml = document.createElement("li"); const aTag = document.createElement("a"); aTag.classList.add("dropdown-item"); aTag.dataset.characterId = character.id; aTag.textContent = character.name; const playerId = playParams.play.players.find( (players: any) => players.characterId === character.id )?.playerId; if (playerId != playParams.myPlayerId) { aTag.classList.add("disabled"); } else { aTag.classList.add("showProfile"); } profileDropdownHtml.append(aTag); profileDropdown.appendChild(profileDropdownHtml); }); const showProfileList = document.querySelectorAll(".showProfile"); showProfileList.forEach((item) => { item.addEventListener("click", (e) => { e.preventDefault(); const target = e.target as Element; const modalId = target.getAttribute("data-character-id"); const modal = new bootstrap.Modal(utils.qsThrowIfNotExists(document, `#profileModal${modalId}`)); modal.show(); }); }); } isOwner(params: PlayGameParams) { return params.play.ownerUserId === params.myPlayerId; } handleCharacterEvent(params: PlayGameParams, controller: GameController) { const updateButtons = () => { this.keys.forEach((key) => { const playerId = this.idPlayerIdMap[key]; if (playerId === params.myPlayerId) { idButtonMap[key].classList.add("active"); } else { idButtonMap[key].classList.remove("active"); // 青実戦など、他人が選択可能なキャラクターのスタイルはこれを使う if (this.idPlayerIdMap[key] == null) { idButtonMap[key].classList.remove("otherActive"); } else { idButtonMap[key].classList.add("otherActive"); } } }); }; const tryChangeCharacter = async (key: string) => { const alert = utils.qsThrowIfNotExists(document, ".select-alert"); const alertText = utils.qsThrowIfNotExists(document, ".select-alert-text"); const alertMask = utils.qsThrowIfNotExists(document, ".select-alert-mask"); const alertYesBtn = utils.qsThrowIfNotExists(document, ".yes-btn"); const alertNoBtn = utils.qsThrowIfNotExists(document, ".no-btn"); if (this.idPlayerIdMap[key] === params.myPlayerId) { // キャラ変更無し return; } let confirmed = false; async function alertModal(state: string): Promise { alertText.innerHTML = `このキャラは他のプレイヤーが${state}です。
選択してもよいですか?`; alert.classList.add("active"); alertMask.classList.add("active"); return new Promise((resolve) => { function result(result: boolean) { alert.classList.remove("active"); alertMask.classList.remove("active"); confirmed = result; alertYesBtn.removeEventListener("click", confirm); alertNoBtn.removeEventListener("click", cansel); alertMask.removeEventListener("click", cansel); resolve(result); } const confirm = () => { result(true); }; const cansel = () => { result(false); }; alertYesBtn.addEventListener("click", confirm); alertNoBtn.addEventListener("click", cansel); alertMask.addEventListener("click", cansel); }); } if (idButtonMap[key].classList.contains("myCharacterButton")) { confirmed = true; } else if (idButtonMap[key].classList.contains("otherActive")) { // confirmed = window.confirm("他の人が操作中です。\nこのキャラクターを操作しますか?"); confirmed = await alertModal("操作中"); } else { confirmed = await alertModal("選択中"); } if (!confirmed) return; const index = idIndexMap[key]; controller.doChangeCharacter(index); }; const characBtn = utils.qsThrowIfNotExists(document, ".character-btn"); const characPackage = utils.qsThrowIfNotExists(document, ".character-package"); // 最初から開くならこれ(多分こうなる気がする) // characPackage.classList.add("active"); characBtn.addEventListener("click", () => { characPackage.classList.toggle("active"); }); const idIndexMap: { [key: string]: number } = { A: 0, B: 1, C: 2, D: 3, }; const idButtonMap: { [key: string]: HTMLButtonElement } = { A: utils.qsThrowIfNotExists(document, ".btn-change-character-1"), B: utils.qsThrowIfNotExists(document, ".btn-change-character-2"), C: utils.qsThrowIfNotExists(document, ".btn-change-character-3"), D: utils.qsThrowIfNotExists(document, ".btn-change-character-4"), }; this.keys.forEach((key) => { idButtonMap[key].addEventListener("click", () => { tryChangeCharacter(key); controller.focus(); }); }); params.play.players.forEach((player) => { if (player.characterId == null) return; if (this.idPlayerIdMap[player.characterId] === undefined) { console.error("非対応なキャラクターIDが検出されました", player.characterId); return; } this.idPlayerIdMap[player.characterId] = this.keys.some( (key) => this.idPlayerIdMap[key] === player.playerId ) ? null : player.playerId; if (player.playerId === params.myPlayerId) { // 赤点線など、自分が選択可能なキャラクターのスタイルはこのクラスを使う idButtonMap[player.characterId].classList.add("myCharacterButton"); } }); updateButtons(); controller.addEventListener("character", (e: Event) => { const event = e as ChangeCharacterEvent; this.keys.forEach((key) => { if (this.idPlayerIdMap[key] === event.playerId) { this.idPlayerIdMap[key] = null; } }); this.idPlayerIdMap[event.characterKey] = event.playerId; this.playUnplayedMediaOnSkipEnd(params, controller); updateButtons(); }); } handleWarpEvent(controller: GameController) { const modalElement = utils.qsThrowIfNotExists(document, "#warpModal"); const modal = new bootstrap.Modal(modalElement); const form = utils.qsThrowIfNotExists(document, "#warpForm"); const warpInfo = utils.qsThrowIfNotExists(form, "#warpInfo"); const selector = utils.qsThrowIfNotExists(form, "#warpSelector"); const submitHandler = (e: SubmitEvent) => { e.preventDefault(); const checkedFloor = form.querySelector("input[name=warpFloor]:checked"); if (checkedFloor == null) { warpInfo.textContent = "行き先を選択せよ。"; form.addEventListener("submit", submitHandler, { once: true }); return; } controller.doWarp(parseInt(checkedFloor.value, 10)); modal.hide(); }; controller.addEventListener("warp", () => { warpInfo.textContent = ""; modal.show(); selector.innerHTML = controller.clearFloors .sort((a, b) => a - b) .map((floor) => { const html = `
`; return html; }) .join("\n"); form.addEventListener("submit", submitHandler, { once: true }); }); } handleResetModal(resetCondition?: () => boolean) { document.querySelector("#resetModalButton")?.addEventListener("click", () => { if (resetCondition != null) { if (resetCondition() === false) { const canNotResetModal = new bootstrap.Modal( utils.qsThrowIfNotExists(document, "#canNotResetModal") ); canNotResetModal.show(); return; } } const modal = new bootstrap.Modal(utils.qsThrowIfNotExists(document, "#resetModal")); modal.show(); }); } handleClearEvent(params: PlayGameParams, controller: GameController, isStandalone?: boolean) { controller.addEventListener("clear", async (e) => { const gameState = await controller.getGameState(); const hintForm = document.querySelector("#selectHint"); if (hintForm != null) { gameState.clearFloors.forEach((clearFloor) => { this.activateHintContent(hintForm, `${clearFloor + 1}F`); }); } if (isStandalone) { localStorage.setItem("conan-online-state", JSON.stringify(gameState)); return; } // プレイ作成者以外はセーブしない if (params.play.ownerUserId !== params.myPlayerId) { return; } const playId = this.getPlayId(); const ticketCode = utils.getTicketCode(); if (playId == null || ticketCode == null) { throw new Error("Invalid call handleClearEvent"); } try { const idToken = await this.tryRefreshIdToken(); await saveState(this.apiClient, playId, idToken, ticketCode, JSON.stringify(gameState)); } catch (error) { // TODO: このエラー無視して動くでいいか要確認 console.error("ゲーム外セーブに失敗", error); } }); } handleGameEvent(params: PlayGameParams, controller: GameController, isStandalone?: boolean) { controller.addEventListener("event", async (e) => { const event = e as GameEvent; if (event.kind === "ending") { if (isStandalone) { const indexWrap = document.querySelector("#indexWrap"); if (indexWrap != null) { indexWrap.scrollIntoView(); } else { console.log("スクロールをしたいが要素が見つからない"); } } else { utils.moveTo("ending.html"); } } else { console.warn("未定義のイベントを受信しました", event); } }); } loadMediaItems(): MediaItem[] { const savedMediaItems: MediaItem[] = JSON.parse(localStorage.getItem("mediaItems") || "[]"); return savedMediaItems; } saveMediaItems(mediaItems: MediaItem[]): void { localStorage.setItem("mediaItems", JSON.stringify(mediaItems)); } handleMediaEvent(params: PlayGameParams, controller: GameController): void { controller.addEventListener("media", (e) => { const event = e as MediaEvent; const mediaItem: MediaItem = { name: event.mediaName, isPlayed: false, targetCharacterKeys: event.targetCharacterKeys, }; const currentMediaItems = this.loadMediaItems(); const isAlreadySaved = currentMediaItems.some((item: MediaItem) => item.name === mediaItem.name); if (!isAlreadySaved) { currentMediaItems.push(mediaItem); this.saveMediaItems(currentMediaItems); } if (controller.isSkipping) { // スキップ中は連続再生 if (isAlreadySaved) return; this.playUnplayedMediaOnSkipEnd(params, controller); } else { // 非スキップ中は単一再生 const playerCharacterKey = this.getPlayerCharacterKey(params); if (!playerCharacterKey) return; if (this.isItemTargetCharacter(mediaItem, playerCharacterKey)) { this.playSingleMedia(mediaItem); } } }); } async playUnplayedMediaOnSkipEnd(params: PlayGameParams, controller: GameController) { if (this.isWaitingForSkipToEnd) return; this.isWaitingForSkipToEnd = true; while (controller.isSkipping) { await new Promise((resolve) => setTimeout(resolve, 100)); } this.isWaitingForSkipToEnd = false; const storedMediaItems: MediaItem[] = JSON.parse(localStorage.getItem("mediaItems") || "[]"); const playerCharacterKey = this.getPlayerCharacterKey(params); if (!playerCharacterKey) return; const mediaSets: MediaSet[] = storedMediaItems.reduce((acc, item) => { if (!item.isPlayed && this.isItemTargetCharacter(item, playerCharacterKey)) { const media = this.findMediaByName(item.name); if (media) { acc.push({ item, media }); } } return acc; }, []); if (mediaSets.length === 0) return; this.playSequentialMedia(mediaSets, params); } getPlayerCharacterKey(params: PlayGameParams): string | undefined { return Object.keys(this.idPlayerIdMap).find((key) => this.idPlayerIdMap[key] === params.myPlayerId); } isItemTargetCharacter(mediaItem: MediaItem, characterKey: string): boolean { return mediaItem.targetCharacterKeys.length === 0 || mediaItem.targetCharacterKeys.includes(characterKey); } findMediaByName(name: string): VideoMedia | AudioMedia | null { const foundMedia = mediaData.find((media) => media.name === name); return foundMedia || null; } playSingleMedia(mediaItem: MediaItem, callback?: () => void): void { const media = this.findMediaByName(mediaItem.name); const modalElement = utils.qsThrowIfNotExists(document, "#videoModal"); const iframeElement = utils.qsThrowIfNotExists(modalElement, "iframe"); if (media) { if (media.type === "video") { iframeElement.src = media.url; const modal = new bootstrap.Modal(modalElement); modal.show(); if (callback != null) modalElement.addEventListener("hidden.bs.modal", callback); } else if (media.type === "audio") { const source = new HowlAudioSource(media.path, media.extensions); const audio = new HowlAudioController(source); audio.play(); if (callback != null) source.onEnded = callback; } } // 保存している該当メディア情報を再生済みに書き換える const currentMediaItems = this.loadMediaItems(); const playedItemIndex = currentMediaItems.findIndex( (savedItem: MediaItem) => savedItem.name === mediaItem.name ); if (playedItemIndex !== -1) { currentMediaItems[playedItemIndex].isPlayed = true; this.saveMediaItems(currentMediaItems); } } async playSequentialMedia(remainingMediaSets: MediaSet[], params: PlayGameParams): Promise { const set = remainingMediaSets.shift(); if (set == null) return; await this.playSingleMedia(set.item, () => { this.playSequentialMedia(remainingMediaSets, params); }); } handleGameError(controller: GameController) { controller.addEventListener("error", (event) => { const error = event as ErrorEvent; const cause = error.error as Error; if (cause.name === "PlaylogError") { alert("通信エラーが発生しました。\nブラウザをリロードしてください。"); return; } else { alert(`不明なエラーが発生しました(${error.message})。 ブラウザをリロードしてください。`); } }); } tryHandleChangeNameEvent(params: PlayGameParams, controller: GameController, standalone?: boolean) { const playId = this.getPlayId(); const ticketCode = utils.getTicketCode(); if (standalone !== true) { if (playId == null || ticketCode == null) { throw new Error("Invalid call handleChangeNameEvent"); } } const modalElement = document.querySelector("#nameModal"); if (modalElement == null) { // 名前入力モーダルのない画面は設定しない return; } const modal = new bootstrap.Modal(modalElement); const changeNameForm = utils.qsThrowIfNotExists(modalElement, "#nameForm"); const changeNameInput = utils.qsThrowIfNotExists(changeNameForm, "#changeName"); let target = { currentName: "", key: "", playerId: "", }; modalElement.addEventListener("shown.bs.modal", () => { changeNameInput.focus(); }); modalElement.addEventListener("show.bs.modal", () => { controller.detachKeyboardHandler(window); }); modalElement.addEventListener("hide.bs.modal", () => { controller.attachKeyboardHandler(window); }); changeNameForm.addEventListener("submit", async (e) => { e.preventDefault(); changeNameForm.querySelectorAll("button").forEach((button) => { button.disabled = true; }); const name = changeNameInput.value; if (!name) { modal.hide(); return; } if (name === target.currentName) { modal.hide(); return; } // const testResult = name.match(/^[\u30A0-\u30FF]{1,4}$/) // A0: =や、FF: ヿなどはカタカナとはしない、という調整を入れる const testResult = name.match(/^[\u30A1-\u30F4ー〜\-−–ー~]{1,4}$/); if (testResult == null) { // TODO: alertとか使わない window.alert("全角カタカナ4文字までで入力してください。"); changeNameForm.querySelectorAll("button").forEach((button) => { button.disabled = false; }); return; } if (standalone === true) { controller.doChangeCharacterName(target.key, name); modal.hide(); return; } try { const idToken = await this.tryRefreshIdToken(); await selectPlayer(this.apiClient, playId!, idToken, ticketCode!, target.key, name); } catch (error) { // TODO: このエラー無視して動くでいいか要確認 console.error("キャラクター名変更に失敗", error); } controller.doChangeCharacterName(target.key, name); modal.hide(); }); controller.addEventListener("name", async (e) => { const event = e as ChangeNameEvent; target.currentName = event.currentName; target.key = event.key; target.playerId = event.playerId; changeNameInput.value = target.currentName; changeNameForm.querySelectorAll("button").forEach((button) => { button.disabled = false; }); modal.show(); }); } async tryRefreshIdToken() { this.firebase.idToken = await this.firebase.user!.getIdToken(); return this.firebase.idToken; } activateCustomModal() { $('[data-modal="close"]').on("click", function () { $(".modal__about").fadeOut(); $(".modal__about").css("overflow-y", "scroll"); $("html,body").css("overflow-y", "auto"); }); $('[data-modal="bg"]').on("click", function () { $(".modal__about").fadeOut(); $(".modal__about").css("overflow-y", "scroll"); $("html,body").css("overflow-y", "auto"); }); } showCustomModal(selector: string, innerHTML?: string) { $(selector).fadeIn().css("display", "flex"); $(selector).css("overflow-y", "scroll"); if (innerHTML != null) { $(`${selector} .modal__about__col__txt`).html(innerHTML); } $("html,body").css("overflow-y", "hidden"); } tryActivateMemberCheck() { const nextStage = document.querySelector(".nextstage"); if (nextStage == null) return; nextStage.href = utils.url("/member_check.html"); } }