diff --git a/apps/backend/src/magic.ts b/apps/backend/src/magic.ts index c2aeb89..906df9a 100644 --- a/apps/backend/src/magic.ts +++ b/apps/backend/src/magic.ts @@ -4,8 +4,6 @@ import { RoomMatch, type RoomState } from "./room"; // --- Game-specific Types --- -let timeout: ReturnType; - export type Operation = "add" | "sub"; export type MoveAction = { @@ -19,19 +17,36 @@ export type MoveAction = { export type Rule = | { rule: "negativeDisabled"; state: boolean } | { rule: "boardSize"; state: number } - | { rule: "timeLimit"; state: number }; + | { rule: "timeLimit"; state: number } + | { rule: "cpu"; state: number }; + +export type LastGameResult = { + players: { + id: string; + name: string; + }[]; + winners: string[] | null; + winnersAry: { [playerId: string]: (true | false)[][] }; + board: (number | null)[][]; +}; // GameState extends RoomState to include game-specific properties export type GameState = RoomState & { + rules: { + negativeDisabled: boolean; + boardSize: number; + timeLimit: number; + cpu: number; + }; round: number; - turn: number; + currentPlayerIndex: number; // index in players, including spectators board: (number | null)[][]; - winners: string[] | null; - winnersAry: { [playerId: string]: (true | false)[][] }; + lastGameResult: LastGameResult | null; gameId: string; hands: { [playerId: string]: number[] }; missions: { [playerId: string]: { id: string; mission: Mission } }; timeLimitUnix: number; + timeoutId?: ReturnType; }; // Combined message types for both room and game actions @@ -64,6 +79,9 @@ export class Magic extends RoomMatch { const { type, payload } = JSON.parse( message.data as string, ) as MessageType; + if (!this.state) { + throw new Error("Game state is not initialized"); + } switch (type) { // Game actions case "makeMove": @@ -81,7 +99,7 @@ export class Magic extends RoomMatch { break; // Room actions (from base class) case "setReady": - await this.setReady(playerId); + await this.setReady(playerId, this.state.rules.cpu); break; case "cancelReady": await this.cancelReady(playerId); @@ -117,17 +135,17 @@ export class Magic extends RoomMatch { players: [], playerStatus: {}, names: {}, + // GameState specific properties rules: { negativeDisabled: false, boardSize: DEFAULT_BOARD_SIZE, timeLimit: DEFAULT_TIME_LIMIT_MS / 1000, + cpu: 0, }, - // GameState specific properties round: 0, - turn: 0, + currentPlayerIndex: 0, board: [], - winners: null, - winnersAry: {}, + lastGameResult: null, gameId: this.ctx.id.toString(), hands: {}, missions: {}, @@ -140,70 +158,108 @@ export class Magic extends RoomMatch { async changeRule(payload: Rule) { if (!this.state || this.state.status !== "preparing") return; - if (payload.rule === "negativeDisabled") { - this.state.rules.negativeDisabled = payload.state; - } else if (payload.rule === "boardSize") { - this.state.rules.boardSize = payload.state; - this.state.board = Array(payload.state) - .fill(null) - .map(() => Array(payload.state).fill(null)); - } else if (payload.rule === "timeLimit") { - this.state.rules.timeLimit = payload.state; + switch (payload.rule) { + case "negativeDisabled": + this.state.rules.negativeDisabled = payload.state; + break; + case "boardSize": { + const size = payload.state; + if (size < 1 || size > 6) { + console.error("Invalid board size:", size); + return; + } + this.state.rules.boardSize = size; + this.state.board = Array(size) + .fill(null) + .map(() => Array(size).fill(null)); + break; + } + case "timeLimit": { + const timeLimit = payload.state; + if (timeLimit < 1) { + console.error("Invalid time limit:", timeLimit); + return; + } + this.state.rules.timeLimit = timeLimit; + break; + } + case "cpu": + this.state.rules.cpu = payload.state; + break; + default: + payload satisfies never; } + await this.ctx.storage.put("gameState", this.state); this.broadcast({ type: "state", payload: this.state }); } override async startGame(): Promise { if (!this.state || this.state.status !== "preparing") return; - // The original implementation called a method to clear parts of the state. - // We will replicate that behavior by resetting the game-specific state here. const size = this.state.rules.boardSize; this.state.board = Array(size) .fill(null) .map(() => Array(size).fill(null)); this.state.round = 0; - this.state.winners = null; - this.state.winnersAry = {}; + this.state.lastGameResult = null; this.state.hands = {}; this.state.missions = {}; - for (const playerId of this.state.players) { + for (let i = 0; i < this.state.rules.cpu; i++) { + const cpuId = `cpu-${i + 1}-${crypto.randomUUID()}`; + this.state.players.push({ + id: cpuId, + type: "cpu", + }); + this.state.names[cpuId] = `CPU ${i + 1}`; + this.state.playerStatus[cpuId] = "ready"; + } + + for (const id of this.state.players.map((p) => p.id)) { + const player = this.state.players.find((p) => p.id === id); + if (!player) throw new Error(`Player not found: ${id}`); + if ( - this.state.playerStatus[playerId] !== "ready" && - this.state.playerStatus[playerId] !== "spectatingReady" + this.state.playerStatus[id] !== "ready" && + this.state.playerStatus[id] !== "spectatingReady" ) { - console.error("one of the players not ready:", playerId); + console.error("one of the players not ready:", id); return; } - this.state.playerStatus[playerId] = - this.state.playerStatus[playerId] === "ready" - ? "playing" - : "spectating"; - - if (this.state.playerStatus[playerId] === "playing") { - if (this.state.hands[playerId]) { - console.error("player already has a hand:", playerId); - return; - } - this.state.hands[playerId] = this.drawInitialHand(); - if (this.state.missions[playerId]) { - console.error("player already has a mission:", playerId); - return; - } - this.state.missions[playerId] = this.getRandomMission(); + switch (this.state.playerStatus[id]) { + case "ready": + this.state.playerStatus[id] = "playing"; + if (player.type !== "cpu") player.type = "player"; + if (this.state.hands[id]) { + console.error("player already has a hand:", id); + return; + } + this.state.hands[id] = this.drawInitialHand(); + + if (this.state.missions[id]) { + console.error("player already has a mission:", id); + return; + } + this.state.missions[id] = this.getRandomMission(); + break; + case "spectatingReady": + this.state.playerStatus[id] = "spectating"; + player.type = "spectator"; + break; + default: + this.state.playerStatus[id] satisfies never; } } const firstPlayingIndex = this.state.players.findIndex( - (p) => this.state?.playerStatus[p] === "playing", + (p) => this.state?.playerStatus[p.id] === "playing", ); - this.state.turn = firstPlayingIndex; + this.state.currentPlayerIndex = firstPlayingIndex; this.state.status = "playing"; this.state.timeLimitUnix = Date.now() + this.state.rules.timeLimit * 1000; - clearTimeout(timeout); - timeout = setTimeout(() => { + clearTimeout(this.state.timeoutId); + this.state.timeoutId = setTimeout(() => { this.pass(); }, this.state.rules.timeLimit * 1000); @@ -215,7 +271,7 @@ export class Magic extends RoomMatch { drawInitialHand() { if (!this.state) return []; - const hand = new Array(3); // TODO: 変更可能にする + const hand: number[] = new Array(3); // TODO: 変更可能にする for (let i = 0; i < hand.length; i++) { hand[i] = this.drawCard(); } @@ -244,58 +300,55 @@ export class Magic extends RoomMatch { return { id: randomKey, mission: missions[randomKey] }; } - advanceTurnAndRound() { + async advanceTurnAndRound() { if (!this.state) return; const players = this.state.players; - const playerStatuses = this.state.playerStatus; - const currentTurn = this.state.turn; + const currentPlayerIndex = this.state.currentPlayerIndex; - const activePlayerIds = players.filter( - (p) => playerStatuses[p] === "playing", + const activePlayers = players.filter( + (p) => p.type === "player" || p.type === "cpu", ); - if (activePlayerIds.length === 0) { - this.state.status = "paused"; - return; // No one to advance turn to. - } - const currentPlayerId = players[currentTurn]; + if (activePlayers.length === 0) + throw new Error("No active players to advance turn to"); - // Find the index of the current player in the list of *active* players. - // If the current player is not active (e.g., a spectator), this will be -1. - const currentPlayerActiveIndex = activePlayerIds.indexOf(currentPlayerId); + const currentActivePlayerIndex = activePlayers.findIndex( + (p) => p.id === players[currentPlayerIndex].id, + ); - let nextPlayerId: string | null = null; + if (currentActivePlayerIndex === -1) + throw new Error("Current active player not found"); - if (currentPlayerActiveIndex === -1) { - // The turn was on an inactive player. Find the first active player after the current one. - let nextTurn = currentTurn; - for (let i = 0; i < players.length; i++) { - nextTurn = (nextTurn + 1) % players.length; - if (playerStatuses[players[nextTurn]] === "playing") { - nextPlayerId = players[nextTurn]; - break; - } - } - if (!nextPlayerId) { - // Should be unreachable due to activePlayerIds.length check - this.state.status = "paused"; - return; - } - } else { - // The current player is active. Find the next one in the active list. - const nextPlayerActiveIndex = - (currentPlayerActiveIndex + 1) % activePlayerIds.length; - nextPlayerId = activePlayerIds[nextPlayerActiveIndex]; + const nextActivePlayerIndex = + (currentActivePlayerIndex + 1) % activePlayers.length; + + const nextActivePlayer = activePlayers[nextActivePlayerIndex]; + + if (nextActivePlayer.type === "spectator") + throw new Error("Next active player cannot be a spectator"); + const nextPlayerIndex = players.findIndex( + (p) => p.id === nextActivePlayer.id, + ); + + if (nextActivePlayer.id !== players[nextPlayerIndex].id) + throw new Error("Turn did not advance correctly"); + + this.state.currentPlayerIndex = nextPlayerIndex; + + if (nextActivePlayerIndex === 0) { // If we wrapped around the active players list, increment the round. - if (nextPlayerActiveIndex === 0) { - this.state.round += 1; - } + this.state.round += 1; } - if (nextPlayerId) { - this.state.turn = players.indexOf(nextPlayerId); + await this.ctx.storage.put("gameState", this.state); + this.broadcast({ type: "state", payload: this.state }); + + if (nextActivePlayer.type === "cpu") { + this.cpuMakeMove(nextActivePlayer.id).catch((err) => { + console.error("CPU Error:", err); + }); } } @@ -307,7 +360,7 @@ export class Magic extends RoomMatch { operation: Operation, numIndex: number, ) { - if (!this.state || this.state.winners) return; + if (!this.state || this.state.lastGameResult) return; if (!this.isValidMove(player, x, y, num)) { console.error("Invalid move attempted:", player, x, y, num); @@ -318,59 +371,151 @@ export class Magic extends RoomMatch { this.state.board[y][x] = this.computeCellResult(x, y, num, operation); + clearTimeout(this.state.timeoutId); + this.advanceTurnAndRound(); const prevHand = this.state.hands[player]; this.state.hands[player] = prevHand.toSpliced(numIndex, 1, this.drawCard()); - for (const id of this.state.players) { + for (const id of this.state.players.map((p) => p.id)) { if (this.state.missions[id]) { const winary = this.isVictory(this.state.missions[id].mission); if (winary.some((row) => row.includes(true))) { if (!this.state) throw new Error("Game state is not initialized"); - this.state.winnersAry[id] = winary; - if (!this.state.winners) { - this.state.winners = [id]; - } else if (!this.state.winners.includes(id)) { - this.state.winners.push(id); + if (!this.state.lastGameResult) { + const names = this.state.names; + this.state.lastGameResult = { + players: this.state.players.map((p) => ({ + id: p.id, + name: names[p.id], + })), + winners: [id], + winnersAry: { [id]: winary }, + board: this.state.board, + }; + } else if (!this.state.lastGameResult.winners?.includes(id)) { + this.state.lastGameResult.winners?.push(id); } + this.state.lastGameResult.winnersAry[id] = winary; console.log("winary", winary); - console.log("this.state.winnersAry", this.state.winnersAry); + console.log( + "this.state.winnersAry", + this.state.lastGameResult.winnersAry, + ); } } } - if (this.state.winners) { + // someone won + if (this.state.lastGameResult) { this.state.status = "preparing"; Object.keys(this.state.playerStatus).forEach((playerId) => { if (!this.state) throw new Error("Game state is not initialized"); this.state.playerStatus[playerId] = "finished"; }); + this.state.players = this.state.players.filter((p) => { + if (p.type !== "cpu") return true; + delete this.state?.playerStatus[p.id]; + return false; + }); } + this.state.timeLimitUnix = Date.now() + this.state.rules.timeLimit * 1000; - clearTimeout(timeout); - if (!this.state.winners) - timeout = setTimeout(() => { + if (!this.state.lastGameResult) + this.state.timeoutId = setTimeout(() => { this.pass(); }, this.state.rules.timeLimit * 1000); await this.ctx.storage.put("gameState", this.state); this.broadcast({ type: "state", payload: this.state }); } + async cpuMakeMove(cpuId: string) { + await new Promise((resolve) => setTimeout(resolve, 1000)); // Simulate thinking time + + console.log("CPU making move:", cpuId); + if (!this.state) return; + + const hand = this.state.hands[cpuId]; + if (!hand || hand.length === 0) return; + + const randomChoice = this.cpuRandomChoice(this.state.board, hand); + + if (!randomChoice) { + this.pass(); + return; + } + + await this.makeMove( + cpuId, + randomChoice.x, + randomChoice.y, + hand[randomChoice.handIndex], + randomChoice.operation, + randomChoice.handIndex, + ); + + // random + // const size = this.state.rules.boardSize; + // let targetX = -1; + // let targetY = -1; + + // for (let y = 0; y < size; y++) { + // for (let x = 0; x < size; x++) { + // if (this.state.board[y][x] === null) { + // targetX = x; + // targetY = y; + // break; + // } + // } + // if (targetX !== -1) break; + // } + + // if (targetX === -1) { + // await this.pass(); + // return; + // } + + // const numIndex = 0; + // const num = hand[numIndex]; + // const operation: Operation = "add"; + + // await this.makeMove(cpuId, targetX, targetY, num, operation, numIndex); + } + + cpuRandomChoice(board: (number | null)[][], hand: number[]) { + if (Math.random() < 0.1) return null; + + const size = board.length; + const [randomX, randomY] = [ + Math.floor(Math.random() * size), + Math.floor(Math.random() * size), + ]; + const randomHandIndex = Math.floor(Math.random() * hand.length); + const randomOperation: Operation = Math.random() < 0.8 ? "add" : "sub"; + + return { + x: randomX, + y: randomY, + handIndex: randomHandIndex, + operation: randomOperation, + }; + } + async pass() { if (!this.state) return; this.advanceTurnAndRound(); this.state.timeLimitUnix = Date.now() + this.state.rules.timeLimit * 1000; - clearTimeout(timeout); - timeout = setTimeout(() => { + clearTimeout(this.state.timeoutId); + this.state.timeoutId = setTimeout(() => { this.pass(); }, this.state.rules.timeLimit * 1000); await this.ctx.storage.put("gameState", this.state); this.broadcast({ type: "state", payload: this.state }); } - isValidMove(player: string, x: number, y: number, num: number) { + isValidMove(playerId: string, x: number, y: number, num: number) { if (!this.state) throw new Error("Game state is not initialized"); // TODO: 調整可能にする @@ -379,13 +524,13 @@ export class Magic extends RoomMatch { return false; } - const currentPlayer = this.state.players[this.state.turn]; - if (currentPlayer !== player) { - console.error("Not your turn:", player); + const currentPlayer = this.state.players[this.state.currentPlayerIndex]; + if (currentPlayer.id !== playerId) { + console.error("Not your turn:", playerId); return false; } - const currentHand = this.state.hands[currentPlayer]; + const currentHand = this.state.hands[currentPlayer.id]; if (!currentHand || currentHand.length === 0) { console.error("Invalid hand:", currentPlayer); return false; @@ -427,8 +572,9 @@ export class Magic extends RoomMatch { isVictory(mission: Mission) { if (!this.state) throw new Error("Game state is not initialized"); - const matrix = Array.from({ length: this.state.rules.boardSize }, () => - Array(this.state?.rules.boardSize).fill(false), + const matrix: boolean[][] = Array.from( + { length: this.state.rules.boardSize }, + () => Array(this.state?.rules.boardSize).fill(false), ); if (mission.target === "column" || mission.target === "allDirection") { for (let i = 0; i < this.state.rules.boardSize; i++) { diff --git a/apps/backend/src/memory.ts b/apps/backend/src/memory.ts index 92864fd..d8a94bc 100644 --- a/apps/backend/src/memory.ts +++ b/apps/backend/src/memory.ts @@ -5,8 +5,6 @@ import { RoomMatch, type RoomState } from "./room"; // --- Game-specific Types --- -let timeout: ReturnType; - type ReserveMemoryAction = { memoryCardId: string; x: number; @@ -63,8 +61,12 @@ export type EventCard = { // GameState extends RoomState to include game-specific properties export type GameState = RoomState & { + rules: { + boardSize: number; + timeLimit: number; + }; round: number; - turn: number; + currentPlayerIndex: number; // index in players, including spectators board: CellState[][]; winners: string[] | null; gameId: string; @@ -75,6 +77,7 @@ export type GameState = RoomState & { points: { [playerId: string]: number }; colors: { [playerId: string]: string }; timeLimitUnix: number; + timeoutId?: ReturnType; }; // Combined message types for both room and game actions @@ -176,13 +179,12 @@ export class Memory extends RoomMatch { playerStatus: {}, names: {}, rules: { - negativeDisabled: false, boardSize: DEFAULT_BOARD_SIZE, timeLimit: DEFAULT_TIME_LIMIT_MS / 1000, }, // GameState specific properties round: 0, - turn: 0, + currentPlayerIndex: 0, board: [], winners: null, gameId: this.ctx.id.toString(), @@ -215,43 +217,70 @@ export class Memory extends RoomMatch { override async startGame(): Promise { if (!this.state || this.state.status !== "preparing") return; - // The original implementation called a method to clear parts of the state. - // We will replicate that behavior by resetting the game-specific state here. const size = this.state.rules.boardSize; this.state.board = Array(size) .fill(null) .map(() => Array(size).fill({ status: "free" })); this.state.round = 0; - this.state.turn = 0; this.state.winners = null; this.state.hands = {}; this.state.clocks = {}; this.state.points = {}; - for (const playerId of this.state.players) { - if (this.state.playerStatus[playerId] !== "ready") { - console.error("one of the players not ready:", playerId); - return; - } - this.state.playerStatus[playerId] = "playing"; + // for (let i = 0; i < this.state.rules.cpu; i++) { + // const cpuId = `cpu-${i + 1}-${crypto.randomUUID()}`; + // this.state.players.push({ + // id: cpuId, + // type: "cpu", + // }); + // this.state.names[cpuId] = `CPU ${i + 1}`; + // this.state.playerStatus[cpuId] = "ready"; + // } - if (this.state.hands[playerId]) { - console.error("player already has a hand:", playerId); + for (const id of this.state.players.map((p) => p.id)) { + const player = this.state.players.find((p) => p.id === id); + if (!player) throw new Error(`Player not found: ${id}`); + + if ( + this.state.playerStatus[id] !== "ready" && + this.state.playerStatus[id] !== "spectatingReady" + ) { + console.error("one of the players not ready:", id); return; } - this.state.hands[playerId] = this.drawInitialHand(); - if (this.state.colors[playerId]) { - console.error("player already has a color:", playerId); - return; + switch (this.state.playerStatus[id]) { + case "ready": + this.state.playerStatus[id] = "playing"; + if (player.type !== "cpu") player.type = "player"; + if (this.state.hands[id]) { + console.error("player already has a hand:", id); + return; + } + this.state.hands[id] = this.drawInitialHand(); + if (this.state.colors[id]) { + console.error("player already has a color:", id); + return; + } + this.state.colors[id] = COLOR_PALETTE[this.state.players.indexOf(id)]; + break; + case "spectatingReady": + this.state.playerStatus[id] = "spectating"; + player.type = "spectator"; + break; + default: + this.state.playerStatus[id] satisfies never; } - this.state.colors[playerId] = - COLOR_PALETTE[this.state.players.indexOf(playerId)]; } + + const firstPlayingIndex = this.state.players.findIndex( + (p) => this.state?.playerStatus[p.id] === "playing", + ); + this.state.currentPlayerIndex = firstPlayingIndex; this.state.status = "playing"; this.state.timeLimitUnix = Date.now() + this.state.rules.timeLimit * 1000; - clearTimeout(timeout); - timeout = setTimeout(() => { + clearTimeout(this.state.timeoutId); + this.state.timeoutId = setTimeout(() => { this.pass(); }, this.state.rules.timeLimit * 1000); @@ -299,59 +328,56 @@ export class Memory extends RoomMatch { }; } - advanceTurnAndRound() { + async advanceTurnAndRound() { if (!this.state) return; const players = this.state.players; - const playerStatuses = this.state.playerStatus; - const currentTurn = this.state.turn; + const currentPlayerIndex = this.state.currentPlayerIndex; - const activePlayerIds = players.filter( - (p) => playerStatuses[p] === "playing", + const activePlayers = players.filter( + (p) => p.type === "player" || p.type === "cpu", ); - if (activePlayerIds.length === 0) { - this.state.status = "paused"; - return; // No one to advance turn to. - } - const currentPlayerId = players[currentTurn]; + if (activePlayers.length === 0) + throw new Error("No active players to advance turn to"); - // Find the index of the current player in the list of *active* players. - // If the current player is not active (e.g., a watcher), this will be -1. - const currentPlayerActiveIndex = activePlayerIds.indexOf(currentPlayerId); + const currentActivePlayerIndex = activePlayers.findIndex( + (p) => p.id === players[currentPlayerIndex].id, + ); - let nextPlayerId: string | null = null; + if (currentActivePlayerIndex === -1) + throw new Error("Current active player not found"); - if (currentPlayerActiveIndex === -1) { - // The turn was on an inactive player. Find the first active player after the current one. - let nextTurn = currentTurn; - for (let i = 0; i < players.length; i++) { - nextTurn = (nextTurn + 1) % players.length; - if (playerStatuses[players[nextTurn]] === "playing") { - nextPlayerId = players[nextTurn]; - break; - } - } - if (!nextPlayerId) { - // Should be unreachable due to activePlayerIds.length check - this.state.status = "paused"; - return; - } - } else { - // The current player is active. Find the next one in the active list. - const nextPlayerActiveIndex = - (currentPlayerActiveIndex + 1) % activePlayerIds.length; - nextPlayerId = activePlayerIds[nextPlayerActiveIndex]; + const nextActivePlayerIndex = + (currentActivePlayerIndex + 1) % activePlayers.length; + + const nextActivePlayer = activePlayers[nextActivePlayerIndex]; + + if (nextActivePlayer.type === "spectator") + throw new Error("Next active player cannot be a spectator"); + + const nextPlayerIndex = players.findIndex( + (p) => p.id === nextActivePlayer.id, + ); + if (nextActivePlayer.id !== players[nextPlayerIndex].id) + throw new Error("Turn did not advance correctly"); + + this.state.currentPlayerIndex = nextPlayerIndex; + + if (nextActivePlayerIndex === 0) { // If we wrapped around the active players list, increment the round. - if (nextPlayerActiveIndex === 0) { - this.state.round += 1; - } + this.state.round += 1; } - if (nextPlayerId) { - this.state.turn = players.indexOf(nextPlayerId); - } + await this.ctx.storage.put("gameState", this.state); + this.broadcast({ type: "state", payload: this.state }); + + // if (nextActivePlayer.type === "cpu") { + // this.cpuMakeMove(nextActivePlayer.id).catch((err) => { + // console.error("CPU Error:", err); + // }); + // } } async reserveMemory( @@ -420,9 +446,9 @@ export class Memory extends RoomMatch { }); } this.state.timeLimitUnix = Date.now() + this.state.rules.timeLimit * 1000; - clearTimeout(timeout); + clearTimeout(this.state.timeoutId); if (!this.state.winners) - timeout = setTimeout(() => { + this.state.timeoutId = setTimeout(() => { this.pass(); }, this.state.rules.timeLimit * 1000); await this.ctx.storage.put("gameState", this.state); @@ -460,8 +486,8 @@ export class Memory extends RoomMatch { this.state.points[playerId] += card.cost; this.state.timeLimitUnix = Date.now() + this.state.rules.timeLimit * 1000; - clearTimeout(timeout); - timeout = setTimeout(() => { + clearTimeout(this.state.timeoutId); + this.state.timeoutId = setTimeout(() => { this.pass(); }, this.state.rules.timeLimit * 1000); @@ -477,8 +503,8 @@ export class Memory extends RoomMatch { if (!this.state) return; this.advanceTurnAndRound(); this.state.timeLimitUnix = Date.now() + this.state.rules.timeLimit * 1000; - clearTimeout(timeout); - timeout = setTimeout(() => { + clearTimeout(this.state.timeoutId); + this.state.timeoutId = setTimeout(() => { this.pass(); }, this.state.rules.timeLimit * 1000); await this.ctx.storage.put("gameState", this.state); @@ -494,7 +520,7 @@ export class Memory extends RoomMatch { ): boolean { if (!this.state) throw new Error("Game state is not initialized"); - const currentPlayer = this.state.players[this.state.turn]; + const currentPlayer = this.state.players[this.state.currentPlayerIndex].id; if (currentPlayer !== player) { console.error("Not your turn:", player); return false; diff --git a/apps/backend/src/room.ts b/apps/backend/src/room.ts index 7924ab5..305982f 100644 --- a/apps/backend/src/room.ts +++ b/apps/backend/src/room.ts @@ -18,14 +18,12 @@ export interface Session { export type RoomState = { status: RoomStatus; - players: string[]; + players: { + type: "player" | "spectator" | "cpu"; + id: string; + }[]; // playerStatus: { [playerId: string]: PlayerStatus }; names: { [playerId: string]: string }; - rules: { - negativeDisabled: boolean; - boardSize: number; - timeLimit: number; - }; }; export abstract class RoomMatch extends DurableObject { @@ -105,10 +103,13 @@ export abstract class RoomMatch extends DurableObject { if (!this.state) return; // New player - if (!this.state.players.includes(playerId)) { + if (!this.state.players.some((p) => p.id === playerId)) { switch (this.state.status) { case "preparing": - this.state.players.push(playerId); + this.state.players.push({ + id: playerId, + type: "player", + }); this.state.names[playerId] = playerName; this.state.playerStatus[playerId] = "preparing"; @@ -116,34 +117,21 @@ export abstract class RoomMatch extends DurableObject { this.broadcast({ type: "state", payload: this.state }); break; case "playing": - if (!this.state.players.includes(playerId)) { - this.state.players.push(playerId); - this.state.names[playerId] = playerName; - this.state.playerStatus[playerId] = "spectating"; + this.state.players.push({ + id: playerId, + type: "spectator", + }); + this.state.names[playerId] = playerName; + this.state.playerStatus[playerId] = "spectating"; - await this.ctx.storage.put("gameState", this.state); - this.broadcast({ type: "state", payload: this.state }); - } + await this.ctx.storage.put("gameState", this.state); + this.broadcast({ type: "state", payload: this.state }); break; case "paused": - // if (this.state.players.includes(playerId)) { - // this.state.playerStatus[playerId] = "playing"; - // if ( - // Object.values(this.state.playerStatus).every( - // (status) => status === "playing", - // ) - // ) { - // console.log("All players reconnected, resuming game."); - // this.state.status = "playing"; - // } else { - // console.log("Waiting for other players to reconnect."); - // } - // await this.ctx.storage.put("gameState", this.state); - // this.broadcast({ type: "state", payload: this.state }); - // } else { - // console.error("Game already started, cannot join now."); - // } - this.state.players.push(playerId); + this.state.players.push({ + id: playerId, + type: "spectator", + }); this.state.names[playerId] = playerName; this.state.playerStatus[playerId] = "spectating"; @@ -192,7 +180,7 @@ export abstract class RoomMatch extends DurableObject { async removePlayer(playerId: string) { if (!this.state) return; - this.state.players = this.state.players.filter((p) => p !== playerId); + this.state.players = this.state.players.filter((p) => p.id !== playerId); delete this.state.playerStatus[playerId]; delete this.state.names[playerId]; @@ -206,7 +194,8 @@ export abstract class RoomMatch extends DurableObject { } async updateDisconnectedPlayer(playerId: string) { - if (!this.state || !this.state.players.includes(playerId)) return; + if (!this.state || !this.state.players.some((p) => p.id === playerId)) + return; if (!this.sessions.some((s) => s.playerId === playerId)) { if (this.state.status === "preparing") { @@ -220,13 +209,15 @@ export abstract class RoomMatch extends DurableObject { } } - async setReady(playerId: string) { + async setReady(playerId: string, cpu?: number) { if (!this.state || this.state.status !== "preparing") return; this.state.playerStatus[playerId] = "ready"; if ( Object.values(this.state.playerStatus).filter((s) => s === "ready") - .length >= 2 && + .length + + (cpu || 0) >= + 2 && Object.values(this.state.playerStatus).every( (s) => s === "ready" || s === "spectatingReady", ) diff --git a/apps/frontend/app/routes/magic-square/room.$roomId.tsx b/apps/frontend/app/routes/magic-square/room.$roomId.tsx index 33a4638..f9041e5 100644 --- a/apps/frontend/app/routes/magic-square/room.$roomId.tsx +++ b/apps/frontend/app/routes/magic-square/room.$roomId.tsx @@ -192,27 +192,47 @@ function TurnDisplay({ currentPlayerId, currentPlayerName, myId, + isCpuTurn, remainingTime, }: { round: number; currentPlayerId: string; currentPlayerName: string; myId: string; + isCpuTurn: boolean; remainingTime: number; }) { const isMyTurn = currentPlayerId === myId; + // 共通のスタイルを定数として定義 + const baseTurnDisplayClasses = + "h-12 flex items-center justify-center text-lg font-bold p-2 rounded-md transition-all duration-300"; + return (

Round

{round + 1}

+
- {isMyTurn ? "Your Turn" : `${currentPlayerName}'s Turn`} + {isMyTurn && "Your Turn"} + {isCpuTurn && ( +
+
+ {currentPlayerName} is thinking... +
+ )} + {!isMyTurn && !isCpuTurn && `${currentPlayerName}'s Turn`}
+

Time

@@ -241,13 +261,15 @@ export default function RoomPage() { const activePlayerIds = user ? (gameState?.players.filter( - (p) => gameState?.playerStatus[p] === "playing", + (p) => p.type === "player" || p.type === "cpu", ) ?? null) : null; const opponentIds = user - ? (activePlayerIds?.filter((p) => p !== user.id) ?? null) + ? (activePlayerIds?.filter((p) => p.id !== user.id) ?? null) : null; - const currentPlayerId = gameState?.players[gameState.turn] ?? null; + const currentPlayer = + gameState?.players[gameState.currentPlayerIndex] ?? null; + const isCPUTurn = currentPlayer?.type === "cpu"; const [selectedNumIndex, setSelectedNumIndex] = useState(null); const [selectedOperation, setSelectedOperation] = useState("add"); @@ -396,7 +418,7 @@ export default function RoomPage() { console.log( "Loading or waiting for game state...", gameState, - currentPlayerId, + currentPlayer?.id, ); return (

@@ -406,12 +428,12 @@ export default function RoomPage() { } if (myStatus === "spectating") { - if (!currentPlayerId) { - throw new Error("Current player ID is missing"); + if (!currentPlayer) { + throw new Error("Current player is missing"); } const playingPlayers = gameState.players.filter( - (p) => gameState.playerStatus[p] === "playing", + (p) => p.type === "player" || p.type === "cpu", ); const spectatedPlayer = spectatedPlayerId @@ -436,14 +458,14 @@ export default function RoomPage() { > Overview - {playingPlayers.map((pId) => ( + {playingPlayers.map((p) => ( ))}
@@ -453,14 +475,14 @@ export default function RoomPage() { {spectatedPlayer ? // Single player perspective playingPlayers - .filter((pId) => pId !== spectatedPlayer.id) - .map((opponentId) => - gameState.missions[opponentId] ? ( + .filter((p) => p.id !== spectatedPlayer.id) + .map((opponent) => + gameState.missions[opponent.id] ? ( ) : null, @@ -472,9 +494,10 @@ export default function RoomPage() {
{}} /> @@ -498,21 +521,21 @@ export default function RoomPage() {
{ // Overview perspective - playingPlayers.map((playerId) => - gameState.missions[playerId] ? ( -
+ playingPlayers.map((p) => + gameState.missions[p.id] ? ( +
{}} selectedNumIndex={null} /> @@ -548,14 +571,14 @@ export default function RoomPage() {
    - {gameState.players.map((playerId) => ( + {gameState.players.map((p) => (
  • - {gameState.names[playerId]} - {playerId === roomHost && ( + {gameState.names[p.id]} + {p.id === roomHost && ( Host @@ -563,20 +586,20 @@ export default function RoomPage() { - {gameState.playerStatus[playerId] === "ready" + {gameState.playerStatus[p.id] === "ready" ? "Ready!" - : gameState.playerStatus[playerId] === "spectatingReady" + : gameState.playerStatus[p.id] === "spectatingReady" ? "Spectator" - : gameState.playerStatus[playerId] === "error" + : gameState.playerStatus[p.id] === "error" ? "Error" : "Preparing..."} @@ -647,6 +670,27 @@ export default function RoomPage() {
+
+ +

GAME SET

- {gameState.winners && ( + {gameState.lastGameResult.winners && (
- {gameState.winners.map((winnersId) => ( + {gameState.lastGameResult.winners.map((winnersId) => (

{gameState.names[winnersId]}

@@ -706,21 +754,22 @@ export default function RoomPage() {
); } - if (winnerDisplay === gameState.winners.length) { + if (winnerDisplay === gameState.lastGameResult.winners.length) { return (

- Result {winnerDisplay}/{gameState.winners.length} + Result {winnerDisplay}/{gameState.lastGameResult.winners.length}

@@ -728,7 +777,9 @@ export default function RoomPage() {
@@ -755,16 +806,17 @@ export default function RoomPage() {

- Result {winnerDisplay}/{gameState.winners.length} + Result {winnerDisplay}/{gameState.lastGameResult.winners.length}

@@ -772,7 +824,9 @@ export default function RoomPage() {
@@ -797,21 +851,22 @@ export default function RoomPage() { } if (myStatus === "playing") { - if (!currentPlayerId) { - throw new Error("Current player ID is missing"); + if (!currentPlayer) { + throw new Error("Current player is missing"); } + const isCpuTurn = currentPlayer.type === "cpu"; return (
Password:{roomSecret}
{/* Opponent's Info */} {opponentIds && (
- {opponentIds.map((opponentId) => ( + {opponentIds.map((opponent) => ( ))} @@ -821,12 +876,19 @@ export default function RoomPage() {
- +
+ +
{/* Player's Info */}
@@ -836,7 +898,11 @@ export default function RoomPage() { description={gameState?.missions[user.id]?.mission.description} /> )} -
+
{gameState.hands[user.id] && ( - // {playingPlayers.map((pId) => ( + // {playingPlayers.map((p) => ( // // ))} //
@@ -497,14 +498,14 @@ export default function RoomPage() { // {spectatedPlayer // ? // Single player perspective // playingPlayers - // .filter((pId) => pId !== spectatedPlayer.id) - // .map((opponentId) => - // gameState.missions[opponentId] ? ( + // .filter((p) => p.id !== spectatedPlayer.id) + // .map((opponent) => + // gameState.missions[opponent.id] ? ( // // ) : null, @@ -515,8 +516,8 @@ export default function RoomPage() { //
// @@ -540,21 +541,21 @@ export default function RoomPage() { //
// { // // Overview perspective - // playingPlayers.map((playerId) => - // gameState.missions[playerId] ? ( - //
+ // playingPlayers.map((player) => + // gameState.missions[player.id] ? ( + //
//
// //
//
// {}} // selectedNumIndex={null} // /> @@ -586,14 +587,14 @@ export default function RoomPage() {
    - {gameState.players.map((playerId) => ( + {gameState.players.map((player) => (
  • - {gameState.names?.[playerId] ?? playerId} - {playerId === roomHost && ( + {gameState.names[player.id]} + {player.id === roomHost && ( Host @@ -601,16 +602,16 @@ export default function RoomPage() { - {gameState.playerStatus?.[playerId] === "ready" + {gameState.playerStatus[player.id] === "ready" ? "Ready!" - : gameState.playerStatus?.[playerId] === "error" + : gameState.playerStatus[player.id] === "error" ? "Error" : "Preparing..."} @@ -639,7 +640,7 @@ export default function RoomPage() {
-
+ {/*
-
+
*/}