import * as agv from "@akashic/akashic-gameview"; import * as agvWeb from "@akashic/akashic-gameview-web"; import { PlayGameParams } from "."; import * as common from "../common/src"; import { logger } from "./utils/Logger"; export class FireshotEnabledEvent extends Event { constructor() { super("fireshotEnabled"); } } export class ChangeNameEvent extends Event { readonly playerId: string; readonly currentName: string; readonly key: string; constructor(playerId: string, key: string, currentName: string) { super("name"); this.playerId = playerId; this.key = key; this.currentName = currentName; } } export class ChangeCharacterEvent extends Event { readonly characterKey: string; readonly characterName: string; readonly playerId: string; constructor(playerId: string, characterKey: string, characterName: string) { super("character"); this.playerId = playerId; this.characterKey = characterKey; this.characterName = characterName; } } export class ClearFloorEvent extends Event { readonly floor: number; constructor(floor: number) { super("clear"); this.floor = floor; } } export class WarpEvent extends Event { constructor() { super("warp"); } } export class MediaEvent extends Event { readonly mediaName: string; readonly targetCharacterKeys: string[]; readonly actionCharacterKey: string; constructor(mediaName: string, targetCharacterKeys: string[], actionCharacterKey: string) { super("media"); this.mediaName = mediaName; this.targetCharacterKeys = targetCharacterKeys; this.actionCharacterKey = actionCharacterKey; } } export class GameEvent extends Event { readonly kind: string; readonly vars?: string; constructor(kind: string, vars?: string) { super("event"); this.kind = kind; this.vars = vars; } } type DirectionType = "left" | "up" | "right" | "down"; export namespace Const { export const MovingCodeMap = { left: 1, up: 2, right: 4, down: 8, }; } export type ClearFloors = number[]; export interface GameState { currentFloor: number; latestDoorObjectNumber?: number; clearFloors: number[]; openDoors: number[]; openTreasures: number[]; fireshotEnabled: boolean; playerIdMap: { [playerId: string]: string }; } export class GameController extends EventTarget { content: agvWeb.GameContent; playerId: string; alive: boolean; fireshotEnabled: boolean; lrImage?: HTMLImageElement; udImage?: HTMLImageElement; moveState: { [id: number]: number }; downState: { [id: number]: boolean }; moveControllerCanvases: HTMLCanvasElement[]; latestMoveStateId: number; currentCharacterKey?: string; isSkipping: boolean; clearFloors: ClearFloors; gameStartedAge: number | null = null; gameStartedDate: number | null = null; keydownHandler: (event: KeyboardEvent) => void; keyupHandler: (event: KeyboardEvent) => void; constructor(playerId: string, content: agvWeb.GameContent) { super(); this.playerId = playerId; this.content = content; this.content.addSkippingListener(this); this.isSkipping = false; this.alive = false; this.fireshotEnabled = true; this.currentCharacterKey = undefined; this.clearFloors = []; this.content.addErrorListener({ onError: (e) => { this.handleError(e); }, }); this.content.addContentLoadListener({ onLoad: () => { this.handleLoad(); }, }); this.moveState = {}; // キーボードだけは絶対いるものとして最初に作っておく this.moveState[-1] = 0; this.downState = {}; this.udImage = undefined; this.lrImage = undefined; Promise.all([ this.loadUiImage("/assets/game-ui/cross-lr.png"), this.loadUiImage("/assets/game-ui/cross-ud.png"), ]).then((images) => { this.lrImage = images[0]; this.udImage = images[1]; this.onReady(); }); this.moveControllerCanvases = []; this.latestMoveStateId = -1; window.addEventListener("mouseup", () => { this.downState[0] = false; this.resetMove(0, 0); }); this.keydownHandler = (e) => { if (this.isAlive() === false) return; switch (e.code) { case "ArrowLeft": this.startMove(-1, "left"); e.preventDefault(); break; case "ArrowUp": this.startMove(-1, "up"); e.preventDefault(); break; case "ArrowRight": this.startMove(-1, "right"); e.preventDefault(); break; case "ArrowDown": this.startMove(-1, "down"); e.preventDefault(); break; case "KeyZ": case "Enter": this.doAction(); e.preventDefault(); break; case "KeyX": if (this.fireshotEnabled) this.doFireAction(); e.preventDefault(); break; } }; this.keyupHandler = (e) => { if (this.isAlive() === false) return; switch (e.code) { case "ArrowLeft": this.stopMove(-1, "left"); e.preventDefault(); break; case "ArrowUp": this.stopMove(-1, "up"); e.preventDefault(); break; case "ArrowRight": this.stopMove(-1, "right"); e.preventDefault(); break; case "ArrowDown": this.stopMove(-1, "down"); e.preventDefault(); break; } }; } onSkip(isStart: boolean) { this.isSkipping = isStart; if (isStart === false) { console.log("スキップ終了"); } } attachPointerHandler(container: HTMLButtonElement, type: string) { container.addEventListener("pointerdown", (e) => { this.startAction(type); }); } detachKeyboardHandler(container: Window) { container.removeEventListener("keydown", this.keydownHandler); container.removeEventListener("keyup", this.keyupHandler); } attachKeyboardHandler(container: Window) { container.addEventListener("keydown", this.keydownHandler); container.addEventListener("keyup", this.keyupHandler); } loadUiImage(src: string) { const buttonImage = document.createElement("img"); return new Promise((resolve, reject) => { buttonImage.src = src; buttonImage.onload = () => { resolve(buttonImage); }; buttonImage.onerror = (error) => { reject(error); }; }); } onReady() { this.drawMoveControllers(); } drawMoveControllers(type?: "left" | "up" | "right" | "down") { this.moveControllerCanvases.forEach((canvas) => { this.drawMoveController(canvas, type); }); } capture() { if (this.content == null) return undefined; const element = this.content._element; if (element == null) return undefined; const canvas = element._innerHtmlElement?.querySelector("canvas"); if (canvas == null) return undefined; return canvas?.toDataURL("image/png"); } drawMoveController(node: HTMLCanvasElement, type?: "left" | "up" | "right" | "down") { if (this.lrImage == null || this.udImage == null) return; const context = node.getContext("2d")!; const buttonWidthLR = 60; const buttonHeightLR = 62; const buttonWidthUD = 62; const buttonHeightUD = 60; const buttonSpaceX = 70; const buttonSpaceY = 70; const lrud = { left: (this.moveState[this.latestMoveStateId] & Const.MovingCodeMap.left) === Const.MovingCodeMap.left, right: (this.moveState[this.latestMoveStateId] & Const.MovingCodeMap.right) === Const.MovingCodeMap.right, up: (this.moveState[this.latestMoveStateId] & Const.MovingCodeMap.up) === Const.MovingCodeMap.up, down: (this.moveState[this.latestMoveStateId] & Const.MovingCodeMap.down) === Const.MovingCodeMap.down, }; context.save(); if (type == null) { context.clearRect(0, 0, node.width, node.height); } if (type == null || type == "left") { context.drawImage( this.lrImage, buttonWidthLR * (lrud.left ? 1 : 0), 0, buttonWidthLR, buttonHeightLR, buttonSpaceX * 0 + (buttonSpaceX - buttonWidthLR) / 2, buttonSpaceY * 1 + (buttonSpaceY - buttonHeightLR) / 2, buttonWidthLR, buttonHeightLR ); } if (type == null || type == "right") { context.drawImage( this.lrImage, buttonWidthLR * (lrud.right ? 3 : 2), 0, buttonWidthLR, buttonHeightLR, buttonSpaceX * 2 + (buttonSpaceX - buttonWidthLR) / 2, buttonSpaceY * 1 + (buttonSpaceY - buttonHeightLR) / 2, buttonWidthLR, buttonHeightLR ); } if (type == null || type == "up") { context.drawImage( this.udImage, buttonWidthUD * (lrud.up ? 1 : 0), 0, buttonWidthUD, buttonHeightUD, buttonSpaceX * 1 + (buttonSpaceX - buttonWidthUD) / 2, buttonSpaceY * 0 + (buttonSpaceY - buttonHeightUD) / 2, buttonWidthUD, buttonHeightUD ); } if (type == null || type == "down") { context.drawImage( this.udImage, buttonWidthUD * (lrud.down ? 3 : 2), 0, buttonWidthUD, buttonHeightUD, buttonSpaceX * 1 + (buttonSpaceX - buttonWidthUD) / 2, buttonSpaceY * 2 + (buttonSpaceY - buttonHeightUD) / 2, buttonWidthUD, buttonHeightUD ); } context.restore(); } async attachMoveController(node: HTMLCanvasElement) { this.moveControllerCanvases.push(node); const onMoved = (e: { offsetX: number; offsetY: number; pointerId: number }) => { const state = this.moveState[e.pointerId]; if (state == null) { this.moveState[e.pointerId] = 0; } const { offsetX, offsetY } = e; const offsetXRatio = offsetX / node.offsetWidth; const offsetYRatio = offsetY / node.offsetHeight; if (Math.abs(offsetXRatio - 0.5) > Math.abs(offsetYRatio - 0.5)) { if (offsetXRatio > 0.54) { this.startMove(e.pointerId, "right"); } else if (offsetXRatio < 0.46) { this.startMove(e.pointerId, "left"); } else { this.resetMove(e.pointerId, 0); } } else { if (offsetYRatio > 0.54) { this.startMove(e.pointerId, "down"); } else if (offsetYRatio < 0.46) { this.startMove(e.pointerId, "up"); } else { this.resetMove(e.pointerId, 0); } } }; node.addEventListener("mousedown", (e) => { this.downState[0] = true; onMoved({ offsetX: e.offsetX, offsetY: e.offsetY, pointerId: 0, }); }); node.addEventListener("touchstart", (e) => { e.preventDefault(); for (let i = 0; i < e.touches.length; i++) { const item = e.touches.item(i)!; this.downState[item.identifier] = true; const rect = node.getBoundingClientRect(); const offsetX = item.pageX - window.pageXOffset - rect.left; const offsetY = item.pageY - window.pageYOffset - rect.top; onMoved({ offsetX, offsetY, pointerId: item.identifier, }); } }); node.addEventListener("mousemove", (e) => { if (this.downState[0]) { onMoved({ offsetX: e.offsetX, offsetY: e.offsetY, pointerId: 0, }); } }); node.addEventListener("touchmove", (e) => { e.preventDefault(); for (let i = 0; i < e.changedTouches.length; i++) { const item = e.changedTouches.item(i)!; if (this.downState[item.identifier]) { const rect = node.getBoundingClientRect(); const offsetX = item.pageX - window.pageXOffset - rect.left; const offsetY = item.pageY - window.pageYOffset - rect.top; onMoved({ offsetX, offsetY, pointerId: item.identifier, }); } } }); node.addEventListener("touchend", (e) => { for (let i = 0; i < e.changedTouches.length; i++) { const item = e.changedTouches.item(i)!; this.downState[item.identifier] = false; this.resetMove(item.identifier, 0); } }); this.drawMoveController(node); } handleLoad() { const gameDriver = this.content.getGameDriver(); if (gameDriver == null || gameDriver._platform == null) { console.error("invalid gameDriver", gameDriver); return; } gameDriver._platform.sendToExternal = (playId: string, data: any) => { this.handleSendToExternal(data); }; this.alive = true; } handleError(error: Error) { console.error("GameContent#error", error); this.dispatchEvent( new ErrorEvent("error", { error, }) ); } handleSendToExternal(data: any) { console.log("external event", data); if (data.type === "fireshotEnabled") { this.fireshotEnabled = true; this.dispatchEvent(new FireshotEnabledEvent()); return; } else if (data.type === "name") { this.dispatchEvent(new ChangeNameEvent(data.playerId, data.key, data.currentName)); return; } else if (data.type === "character") { const changeCharacterEvent = new ChangeCharacterEvent(data.id, data.key, data.name); this.dispatchEvent(changeCharacterEvent); if (changeCharacterEvent.playerId === this.playerId) { this.currentCharacterKey = changeCharacterEvent.characterKey; } return; } else if (data.type === "warp") { this.dispatchEvent(new WarpEvent()); return; } else if (data.type === "clear") { const clearFloor = data.floor as number; if (this.clearFloors.includes(clearFloor) !== true) { this.clearFloors.push(clearFloor); } this.dispatchEvent(new ClearFloorEvent(clearFloor)); return; } else if (data.type === "event") { this.dispatchEvent(new GameEvent(data.kind as string, data.vars as string | undefined)); } else if (data.type === "media") { // 変更が元のdataにまで反映されてしまわないようコピー const targetCharacterKeysCopy = data.targetCharacterKeys ? [...data.targetCharacterKeys] : []; // "ActionCharacter" があれば、アクションしたキャラクターのkeyに置き換える const index = targetCharacterKeysCopy.findIndex((key: string) => key === "ActionCharacter"); if (index !== -1) { targetCharacterKeysCopy[index] = data.actionCharacterKey; } const mediaEvent = new MediaEvent(data.name, targetCharacterKeysCopy, data.actionCharacterKey); this.dispatchEvent(mediaEvent); } else if (data.type === "gameStartedAge") { this.gameStartedAge = data.age; this.gameStartedDate = data.date; } } trySetMove(id: number, type: DirectionType) { this.latestMoveStateId = id; const movingCode = Const.MovingCodeMap[type]; if ((this.moveState[id] & movingCode) !== movingCode) { this.moveState[id] |= movingCode; return true; } return false; } startMove(id: number, type: DirectionType) { switch (type) { case "up": if (this.trySetMove(id, type)) { this.latestMoveStateId = id; this.resetMove(id, Const.MovingCodeMap.up); this.drawMoveControllers("up"); this.moveUp(); } break; case "down": if (this.trySetMove(id, type)) { this.latestMoveStateId = id; this.resetMove(id, Const.MovingCodeMap.down); this.drawMoveControllers("down"); this.moveDown(); } break; case "left": if (this.trySetMove(id, type)) { this.latestMoveStateId = id; this.resetMove(id, Const.MovingCodeMap.left); this.drawMoveControllers("left"); this.moveLeft(); } break; case "right": if (this.trySetMove(id, type)) { this.latestMoveStateId = id; this.resetMove(id, Const.MovingCodeMap.right); this.drawMoveControllers("right"); this.moveRight(); } break; } } startAction(type: string) { switch (type) { case "fire": return this.doFireAction(); case "reset": return this.doReset(); case "action": return this.doAction(); default: console.warn("Controller#action detected unknown action type", type); } } resetMove(id: number, without: number, dontSend?: boolean) { if (this.moveState[id] == null) this.moveState[id] = 0; const canSend = this.moveState[id] !== 0 && dontSend !== true; let types: DirectionType[] = []; (["left", "right", "down", "up"] as DirectionType[]).forEach((code) => { const movingCode = Const.MovingCodeMap[code]; if ((without & movingCode) === movingCode) return; if ((this.moveState[id] & movingCode) === movingCode) { this.moveState[id] ^= movingCode; types.push(code); } }); if (canSend && this.moveState[id] === 0) { this.moveStop(); } types.forEach((type) => { this.drawMoveControllers(type); }); } stopMove(id: number, type: DirectionType) { switch (type) { case "up": case "down": case "left": case "right": if ((this.moveState[id] & Const.MovingCodeMap[type]) === Const.MovingCodeMap[type]) { this.moveState[id] ^= Const.MovingCodeMap[type]; this.drawMoveControllers(type); if (this.moveState[id] === 0) { this.moveStop(); } } break; default: console.warn("Controller#action detected unknown action type", type); } } moveUp() { this.sendMessageEvent({ type: "move", angle: "up", }); } moveDown() { this.sendMessageEvent({ type: "move", angle: "down", }); } moveLeft() { this.sendMessageEvent({ type: "move", angle: "left", }); } moveRight() { this.sendMessageEvent({ type: "move", angle: "right", }); } moveStop() { this.sendMessageEvent({ type: "stop", }); } doWarp(floor: number) { this.sendMessageEvent({ type: "warp", floor, }); } doReset(floor?: number) { this.sendMessageEvent({ type: "reset", floor, }); } doAction() { this.sendLocalMessageEvent({ type: "action", }); } doFireAction() { this.sendMessageEvent({ type: "fire", }); } doConfigEvent(config: { bgm: number; se: number }) { this.sendLocalMessageEvent({ type: "config", bgmVolume: config.bgm, seVolume: config.se, }); } doChangeCharacter(newIndex: number) { this.sendMessageEvent({ type: "changeCharacter", index: newIndex, }); } doChangeCharacterName(key: string, name: string) { this.sendMessageEvent({ type: "name", key, name, }); } sendLocalMessageEvent(data: object) { const gameDriver = this.content.getGameDriver(); if (gameDriver == null || gameDriver._eventBuffer == null) { console.error("invalid gameDriver", gameDriver); return; } gameDriver._eventBuffer.onEvent([32, null, this.playerId, data, true]); } sendMessageEvent(data: object) { this.content.sendEvents([[32, null, this.playerId, data]]); } isAlive() { return this.alive; } getGameState() { return new Promise((resolve) => { this.content.getGameVars("gameState", (gameState: GameState) => { resolve(gameState); }); }); } focus() { const inputHandler = this.content._element?._innerHtmlElement?.querySelector(".input-handler"); if (inputHandler != null) { inputHandler.focus(); return true; } return false; } } export function start(container: HTMLElement, playParams: PlayGameParams) { if (playParams.game == null || playParams.play == null || playParams.content == null) { logger.error("lacking game"); return; } if (playParams.play.akashicPlayId == null) { logger.error("lacking game"); return; } logger.log("game.start", playParams); const contentUrl = playParams.content.url; if (playParams.myPlayerId == null) { throw new Error("empty playerId"); } const gamePlayer = playParams.play.players.find( (player: common.GamePlayer) => player.playerId == playParams.myPlayerId ); if (!gamePlayer) { logger.warn("player not found", gamePlayer); // readonly memberをいったん許容 // return; } if (gamePlayer != null) { const gameCharacter = playParams.game.characters.find( (character: common.GameCharacter) => character.id == gamePlayer.characterId ); if (!gameCharacter) { logger.warn("gameCharacter not found", gameCharacter); // readonly memberをいったん許容 // return; } } const playConfig: agvWeb.PlayConfig = playParams.playToken == null ? { playId: "dummy", executionMode: agv.ExecutionMode.Active, } : ({ playId: playParams.play.akashicPlayId, playlogServerUrl: playParams.playToken.serverUrl, playToken: playParams.playToken.token, protocol: agv.ProtocolType.WebSocket, executionMode: agv.ExecutionMode.Passive, } as agv.PlaylogConfig); if (playParams.replayData != null && playParams.playToken == null) { playConfig.replayData = playParams.replayData; playConfig.executionMode = agv.ExecutionMode.Replay; if (playParams.replayOriginDate != null) { playConfig.replayOriginDate = playParams.replayOriginDate; } if (playParams.replayTargetTimeFunc != null) { playConfig.replayTargetTimeFunc = playParams.replayTargetTimeFunc; } } const initialArgs = playParams.play.initialArgs; if (initialArgs != null) { const snapshot = { state: JSON.parse(initialArgs), }; playParams.gameConfig.snapshot = snapshot; } const gameContentConfig: agv.GameConfig = { contentUrl, player: { id: playParams.replayPlayerId ?? playParams.myPlayerId, }, playConfig, argument: playParams.gameConfig, }; const content = new agvWeb.GameContent(gameContentConfig); const getClientWidth = () => container.clientWidth - 2; const getClientHeight = () => container.clientWidth - 2; const controller = new GameController(playParams.myPlayerId, content); controller.attachKeyboardHandler(window); controller.addEventListener("fireshotEnabled", () => { document.querySelectorAll(".button-fire").forEach((btn) => { btn.classList.add("active"); }); }); const gameview = new agvWeb.AkashicGameView({ container, width: getClientWidth(), height: getClientHeight(), untrustedFrameUrl: playParams.play.gameCode, // "dummy" trustedChildOrigin: /.*/, }); gameview.addContent(content); window.addEventListener("resize", () => { gameview.setViewSize(getClientWidth(), getClientHeight()); content.setContentArea({ x: 0, y: 0, width: getClientWidth(), height: getClientHeight(), }); }); const buttons: { [key: string]: NodeListOf } = { action: document.querySelectorAll(".button-action"), fire: document.querySelectorAll(".button-fire"), reset: document.querySelectorAll("#doResetButton"), }; Object.keys(buttons).forEach((key) => { buttons[key].forEach((button) => { controller.attachPointerHandler(button, key); }); }); document.querySelectorAll(".moveController").forEach((moveController) => { controller.attachMoveController(moveController); }); const jumpButton = document.querySelector("#jumpButton"); const jumpSelect = document.querySelector("#jumpFloor"); if (jumpButton != null && jumpSelect != null) { jumpSelect.addEventListener("keydown", (e) => { // ゲームのイベントハンドラに渡さず、キーボードでの移動をできるようにする e.stopPropagation(); }); jumpButton.addEventListener("click", () => { if (jumpSelect.value == null || jumpSelect.value === "") return; const floorCaption = jumpSelect.selectedOptions[0].text; if (!confirm(`本当に${floorCaption}にジャンプしますか?`)) return; controller.doReset(parseInt(jumpSelect.value, 10)); }); } return controller; }