From 621097b4b768154e730649dd72787f1378a7be24 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Tue, 7 Apr 2026 21:14:26 +0700 Subject: [PATCH] feat: implement GameScene with tile rendering, matching, and animations --- superpowers/src/phaser/scenes/GameScene.ts | 317 ++++++++++++++++++++- 1 file changed, 311 insertions(+), 6 deletions(-) diff --git a/superpowers/src/phaser/scenes/GameScene.ts b/superpowers/src/phaser/scenes/GameScene.ts index 327593e..4c788a5 100644 --- a/superpowers/src/phaser/scenes/GameScene.ts +++ b/superpowers/src/phaser/scenes/GameScene.ts @@ -1,16 +1,321 @@ import Phaser from "phaser"; +import { Board, Difficulty, Point } from "../../types"; +import { GameStateManager } from "../../game/state"; +import { createBoard, shuffleBoard, getRemainingTiles } from "../../game/board"; +import { findPath, hasAnyValidMove } from "../../game/pathfinder"; +import { calculateMatchScore } from "../../game/scoring"; +import { DIFFICULTY_CONFIGS } from "../../game/constants"; + +const TILE_SIZE = 56; +const TILE_GAP = 4; export class GameScene extends Phaser.Scene { + private board!: Board; + private difficulty!: Difficulty; + private stateManager!: GameStateManager; + private tileObjects: Map = new Map(); + private selectedTile: Point | null = null; + private selectedHighlight: Phaser.GameObjects.Rectangle | null = null; + private lineGraphics!: Phaser.GameObjects.Graphics; + private isProcessing = false; + private timerEvent: Phaser.Time.TimerEvent | null = null; + constructor() { super({ key: "GameScene" }); } create(): void { - this.add - .text(this.cameras.main.centerX, this.cameras.main.centerY, "Game Loading...", { - fontSize: "24px", - color: "#ffffff", - }) - .setOrigin(0.5); + this.difficulty = this.registry.get("difficulty") as Difficulty; + this.stateManager = this.registry.get("stateManager") as GameStateManager; + + this.board = createBoard(this.difficulty); + this.lineGraphics = this.add.graphics(); + + this.renderBoard(); + this.startTimer(); + + // Listen for external actions (hint, shuffle) + this.stateManager.on("hint", () => this.handleHint()); + this.stateManager.on("shuffle", () => this.handleShuffle()); + } + + private renderBoard(): void { + for (const [, obj] of this.tileObjects) { + obj.destroy(); + } + this.tileObjects.clear(); + + const config = DIFFICULTY_CONFIGS[this.difficulty]; + const totalWidth = config.cols * (TILE_SIZE + TILE_GAP) - TILE_GAP; + const totalHeight = config.rows * (TILE_SIZE + TILE_GAP) - TILE_GAP; + const offsetX = (this.cameras.main.width - totalWidth) / 2; + const offsetY = (this.cameras.main.height - totalHeight) / 2; + + for (let r = 0; r < this.board.length; r++) { + for (let c = 0; c < this.board[r].length; c++) { + const tile = this.board[r][c]; + if (tile === null) continue; + + const x = offsetX + c * (TILE_SIZE + TILE_GAP) + TILE_SIZE / 2; + const y = offsetY + r * (TILE_SIZE + TILE_GAP) + TILE_SIZE / 2; + + const bg = this.add.rectangle(0, 0, TILE_SIZE, TILE_SIZE, 0x0f3460); + bg.setStrokeStyle(2, 0x533483); + + const text = this.add + .text(0, 0, tile.emoji, { + fontSize: "28px", + }) + .setOrigin(0.5); + + const container = this.add.container(x, y, [bg, text]); + container.setSize(TILE_SIZE, TILE_SIZE); + container.setInteractive(); + container.setData("row", r); + container.setData("col", c); + + container.on("pointerdown", () => { + if (!this.isProcessing) { + this.onTileClick({ row: r, col: c }); + } + }); + + container.on("pointerover", () => { + bg.setFillStyle(0x1a1a5e); + }); + + container.on("pointerout", () => { + bg.setFillStyle(0x0f3460); + }); + + this.tileObjects.set(`${r},${c}`, container); + } + } + } + + private getTileScreenPos(point: Point): { x: number; y: number } { + const config = DIFFICULTY_CONFIGS[this.difficulty]; + const totalWidth = config.cols * (TILE_SIZE + TILE_GAP) - TILE_GAP; + const totalHeight = config.rows * (TILE_SIZE + TILE_GAP) - TILE_GAP; + const offsetX = (this.cameras.main.width - totalWidth) / 2; + const offsetY = (this.cameras.main.height - totalHeight) / 2; + return { + x: offsetX + point.col * (TILE_SIZE + TILE_GAP) + TILE_SIZE / 2, + y: offsetY + point.row * (TILE_SIZE + TILE_GAP) + TILE_SIZE / 2, + }; + } + + private onTileClick(pos: Point): void { + const tile = this.board[pos.row][pos.col]; + if (!tile) return; + + if (!this.selectedTile) { + this.selectedTile = pos; + this.highlightTile(pos); + return; + } + + if ( + this.selectedTile.row === pos.row && + this.selectedTile.col === pos.col + ) { + this.clearSelection(); + return; + } + + const path = findPath(this.board, this.selectedTile, pos); + if (path) { + this.isProcessing = true; + this.handleMatch(this.selectedTile, pos, path); + } else { + this.handleMismatch(pos); + } + } + + private highlightTile(pos: Point): void { + this.clearHighlight(); + const screenPos = this.getTileScreenPos(pos); + this.selectedHighlight = this.add.rectangle( + screenPos.x, + screenPos.y, + TILE_SIZE + 4, + TILE_SIZE + 4, + ); + this.selectedHighlight.setStrokeStyle(3, 0xe94560); + this.selectedHighlight.setFillStyle(0xe94560, 0.15); + } + + private clearHighlight(): void { + if (this.selectedHighlight) { + this.selectedHighlight.destroy(); + this.selectedHighlight = null; + } + } + + private clearSelection(): void { + this.selectedTile = null; + this.clearHighlight(); + } + + private handleMatch(a: Point, b: Point, path: Point[]): void { + this.drawPath(path); + + const state = this.stateManager.getState(); + const msSinceLastMatch = Date.now() - state.lastMatchTime; + const score = calculateMatchScore(msSinceLastMatch, state.combo); + + this.stateManager.addScore(score); + this.stateManager.incrementCombo(); + this.stateManager.setLastMatchTime(Date.now()); + + this.time.delayedCall(300, () => { + this.removeTile(a); + this.removeTile(b); + this.lineGraphics.clear(); + this.clearSelection(); + this.isProcessing = false; + + const remaining = getRemainingTiles(this.board); + if (remaining.length === 0) { + this.stateManager.setStatus("won"); + return; + } + + if (!hasAnyValidMove(this.board)) { + this.autoShuffle(); + } + }); + } + + private handleMismatch(clickedPos: Point): void { + this.stateManager.resetCombo(); + + const key = `${clickedPos.row},${clickedPos.col}`; + const container = this.tileObjects.get(key); + if (container) { + this.tweens.add({ + targets: container, + x: container.x - 5, + duration: 50, + yoyo: true, + repeat: 3, + }); + } + + this.clearSelection(); + this.selectedTile = clickedPos; + this.highlightTile(clickedPos); + } + + private removeTile(pos: Point): void { + this.board[pos.row][pos.col] = null; + const key = `${pos.row},${pos.col}`; + const container = this.tileObjects.get(key); + if (container) { + this.tweens.add({ + targets: container, + scale: 0, + alpha: 0, + duration: 200, + onComplete: () => container.destroy(), + }); + this.tileObjects.delete(key); + } + } + + private drawPath(path: Point[]): void { + this.lineGraphics.clear(); + this.lineGraphics.lineStyle(4, 0xe94560, 0.8); + this.lineGraphics.beginPath(); + + for (let i = 0; i < path.length; i++) { + const screenPos = this.getTileScreenPos(path[i]); + if (i === 0) { + this.lineGraphics.moveTo(screenPos.x, screenPos.y); + } else { + this.lineGraphics.lineTo(screenPos.x, screenPos.y); + } + } + + this.lineGraphics.strokePath(); + } + + private startTimer(): void { + this.timerEvent = this.time.addEvent({ + delay: 1000, + callback: () => { + const state = this.stateManager.getState(); + if (state.status === "playing") { + this.stateManager.tick(); + } + }, + loop: true, + }); + } + + handleHint(): void { + if (!this.stateManager.useHint()) return; + + const remaining = getRemainingTiles(this.board); + for (let i = 0; i < remaining.length; i++) { + for (let j = i + 1; j < remaining.length; j++) { + if (remaining[i].tile.emoji === remaining[j].tile.emoji) { + const path = findPath( + this.board, + remaining[i].pos, + remaining[j].pos, + ); + if (path) { + this.pulseHint(remaining[i].pos); + this.pulseHint(remaining[j].pos); + return; + } + } + } + } + } + + private pulseHint(pos: Point): void { + const key = `${pos.row},${pos.col}`; + const container = this.tileObjects.get(key); + if (container) { + this.tweens.add({ + targets: container, + scale: 1.2, + duration: 300, + yoyo: true, + repeat: 2, + ease: "Sine.easeInOut", + }); + } + } + + handleShuffle(): void { + if (!this.stateManager.useShuffle()) return; + this.performShuffle(); + } + + private autoShuffle(): void { + this.performShuffle(); + this.stateManager.emit("autoShuffle"); + } + + private performShuffle(): void { + this.clearSelection(); + this.board = shuffleBoard(this.board); + this.renderBoard(); + + if ( + getRemainingTiles(this.board).length > 0 && + !hasAnyValidMove(this.board) + ) { + this.performShuffle(); + } + } + + destroy(): void { + if (this.timerEvent) { + this.timerEvent.destroy(); + } } }