diff --git a/package-lock.json b/package-lock.json index bbd22e7..739e023 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@fortawesome/free-solid-svg-icons": "^6.7.2", "@fortawesome/react-fontawesome": "^0.2.2", "next": "15.4.5", + "phaser": "^3.90.0", "react": "19.1.0", "react-dom": "19.1.0", "react-ga4": "^2.1.0" @@ -3060,6 +3061,12 @@ "node": ">=0.10.0" } }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -4739,6 +4746,15 @@ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "dev": true }, + "node_modules/phaser": { + "version": "3.90.0", + "resolved": "https://registry.npmjs.org/phaser/-/phaser-3.90.0.tgz", + "integrity": "sha512-/cziz/5ZIn02uDkC9RzN8VF9x3Gs3XdFFf9nkiMEQT3p7hQlWuyjy4QWosU802qqno2YSLn2BfqwOKLv/sSVfQ==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^5.0.1" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", diff --git a/package.json b/package.json index 076e540..d95fb9c 100644 --- a/package.json +++ b/package.json @@ -15,21 +15,22 @@ "@fortawesome/free-regular-svg-icons": "^6.7.2", "@fortawesome/free-solid-svg-icons": "^6.7.2", "@fortawesome/react-fontawesome": "^0.2.2", + "next": "15.4.5", + "phaser": "^3.90.0", "react": "19.1.0", "react-dom": "19.1.0", - "react-ga4": "^2.1.0", - "next": "15.4.5" + "react-ga4": "^2.1.0" }, "devDependencies": { - "typescript": "^5", + "@eslint/eslintrc": "^3", + "@tailwindcss/postcss": "^4", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", - "@tailwindcss/postcss": "^4", - "tailwindcss": "^4", "eslint": "^9", "eslint-config-next": "15.4.5", - "@eslint/eslintrc": "^3", - "next-sitemap": "^4.2.3" + "next-sitemap": "^4.2.3", + "tailwindcss": "^4", + "typescript": "^5" } } diff --git a/public/images/games/paddle-battle.webp b/public/images/games/paddle-battle.webp new file mode 100644 index 0000000..77ecb54 Binary files /dev/null and b/public/images/games/paddle-battle.webp differ diff --git a/src/app/games/[...slug]/page.tsx b/src/app/games/[...slug]/page.tsx new file mode 100644 index 0000000..c22c154 --- /dev/null +++ b/src/app/games/[...slug]/page.tsx @@ -0,0 +1,42 @@ +import type { Metadata } from 'next'; +import { getGameDetailsBySlug, getAllGameSlugs } from '@/games/game-meta-loader'; +import { generateMetadata as generatePageMetadata } from '@/utils/metadata'; +import GameClient from '@/components/games/GameClient'; + +interface GamePageProps { + params: Promise<{ slug: string[] }>; +} + +export async function generateMetadata({ params }: GamePageProps): Promise { + const { slug } = await params; + const gameSlug = slug[slug.length - 1]; + const fullPath = slug.join('/'); + const gameDetails = getGameDetailsBySlug(gameSlug); + + return generatePageMetadata({ + title: gameDetails.title, + description: gameDetails.metaDescription, + urlPath: `/games/${fullPath}`, + }); +} + +export async function generateStaticParams() { + return getAllGameSlugs(); +} + +export default async function GamePage({ params }: GamePageProps) { + const { slug } = await params; + const gameSlug = slug[slug.length - 1]; + const gameDetails = getGameDetailsBySlug(gameSlug); + + return ( + + ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 7af3e11..5687ba1 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -38,12 +38,14 @@ export default function HomePage() { {/* Originals Section */} - + {originalGamesData.length > 0 && ( + + )} {/* Arcade Section */} (null); + + // The main effect to manage the game's lifecycle + useEffect(() => { + // Ensure this only runs in the browser + if (typeof window !== 'undefined') { + // Initialize the Phaser game when the component mounts + gameRef.current = new Phaser.Game({ + ...gameConfig, + parent: 'game-container', // Tell Phaser where to inject the canvas + }); + } + + // The cleanup function is critical for SPAs + return () => { + // Destroy the game instance when the component unmounts + gameRef.current?.destroy(true); + gameRef.current = null; + }; + }, [gameConfig]); // Re-run the effect if the gameConfig prop changes + + // This div is the target where Phaser will inject the game canvas + return
; +} diff --git a/src/components/games/GameClient.tsx b/src/components/games/GameClient.tsx new file mode 100644 index 0000000..7b69eaa --- /dev/null +++ b/src/components/games/GameClient.tsx @@ -0,0 +1,125 @@ +'use client'; + +import { useState, useEffect, Suspense, useCallback } from 'react'; +import dynamic from 'next/dynamic'; +import ArcadeButton from '@/components/ArcadeButton'; +import type { IGameConfig } from '@/games/game-loader'; +import type { GameStat } from '@/types'; +import * as statsManager from '@/utils/statsManager'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faDesktop } from '@fortawesome/free-solid-svg-icons'; + +// A responsive placeholder that maintains the correct aspect ratio +const GameLoadingSkeleton = () => ( +
+); + +const GameCanvas = dynamic(() => import('@/components/games/GameCanvas'), { + ssr: false, + loading: () => , +}); + +interface GameClientProps { + slug: string; + title: string; + description: string; + controls: string[]; + stats: GameStat[]; + isDesktopOnly: boolean; +} + +export default function GameClient({ + slug, + title, + description, + controls, + stats, + isDesktopOnly, +}: GameClientProps) { + const [gameConfig, setGameConfig] = useState(null); + const [playerStats, setPlayerStats] = useState>({}); + + const refreshStats = useCallback(() => { + const allStats = statsManager.getAllStats(slug); + setPlayerStats(allStats); + }, [slug]); + + useEffect(() => { + import('@/games/game-loader').then(({ getGameConfigBySlug }) => { + const config = getGameConfigBySlug(slug); + setGameConfig(config); + }); + + refreshStats(); + window.addEventListener('statsUpdated', refreshStats); + return () => { + window.removeEventListener('statsUpdated', refreshStats); + }; + }, [slug, refreshStats]); + + return ( +
+
+

{title}

+ +
+
+
+ }> + {gameConfig ? : } + +
+
+ + {isDesktopOnly && ( +
+ +

Desktop Recommended

+

+ This game is controlled by the keyboard and is best experienced on a desktop + computer. +

+
+ )} +
+ +
+
+

+ Controls & Objective +

+ {controls.map((control, index) => ( +

+ ))} +

{description}

+
+ + {stats && stats.length > 0 && ( +
+

Player Stats

+
+ {stats.map((stat) => ( +
+
{stat.label}
+
+ {playerStats[stat.key] || 0} +
+
+ ))} +
+
+ )} +
+ +
+ + Back to Arcade + +
+
+
+ ); +} diff --git a/src/data/arcadeGames.json b/src/data/arcadeGames.json index 420b827..b8ed65d 100644 --- a/src/data/arcadeGames.json +++ b/src/data/arcadeGames.json @@ -1,34 +1,66 @@ [ + { + "title": "Paddle Battle", + "imageUrl": "/images/games/paddle-battle.webp", + "linkUrl": "/games/arcade/paddle-battle", + "metaDescription": "Play Paddle Battle, a Buffalo-themed tribute to the original paddle-and-ball arcade games. Features unique snowflake physics and full stat tracking against an AI opponent.", + "isDesktopOnly": true, + "isNew": true, + "tags": ["arcade", "classic"], + "releaseDate": "2025-08-05", + "popularity": 95, + "description": "First to 10 points wins!", + "controls": [ + "Use the UP and DOWN arrow keys to move your paddle.", + "Press P to pause the game.", + "Press SPACE to restart after game over." + ], + "stats": [ + { "key": "gamesPlayed", "label": "Games Played" }, + { "key": "playerWins", "label": "Wins" }, + { "key": "playerLosses", "label": "Losses" }, + { "key": "longestRally", "label": "Longest Rally" }, + { "key": "totalRallies", "label": "Total Paddle Hits" }, + { "key": "totalPlayTime", "label": "Play Time (s)" } + ] + }, { "title": "Galaxy Invaders", "imageUrl": "https://placehold.co/300x200/e7042d/003091?text=Arcade+Classic", - "linkUrl": "#", + "linkUrl": "/games/arcade/galaxy-invaders", + "metaDescription": "", + "isDesktopOnly": true, + "isComingSoon": true, "tags": ["arcade", "shooter"], "releaseDate": "2024-10-25", - "popularity": 90 + "popularity": 90, + "description": "", + "controls": [] }, { "title": "Block Breaker", "imageUrl": "https://placehold.co/300x200/e7042d/003091?text=Arcade+Classic", "linkUrl": "#", + "metaDescription": "", + "isDesktopOnly": true, + "isComingSoon": true, "tags": ["arcade", "puzzle"], "releaseDate": "2024-09-11", - "popularity": 88 - }, - { - "title": "Road Racer", - "imageUrl": "https://placehold.co/300x200/e7042d/003091?text=Arcade+Classic", - "linkUrl": "#", - "tags": ["arcade", "racing"], - "releaseDate": "2024-11-05", - "popularity": 75 + "popularity": 88, + "description": "", + "controls": [] }, { "title": "Maze Mania", "imageUrl": "https://placehold.co/300x200/e7042d/003091?text=Arcade+Classic", "linkUrl": "#", + "metaDescription": "", + "isDesktopOnly": true, + "isComingSoon": true, "tags": ["arcade", "puzzle", "strategy"], "releaseDate": "2024-08-19", - "popularity": 82 + "popularity": 82, + "description": "", + "controls": [] } ] diff --git a/src/data/originalGames.json b/src/data/originalGames.json index 10a4518..fe51488 100644 --- a/src/data/originalGames.json +++ b/src/data/originalGames.json @@ -1,36 +1 @@ -[ - { - "title": "Pixel Puzzler", - "imageUrl": "https://placehold.co/300x200/003091/e7042d?text=Original+Game", - "linkUrl": "#", - "isNew": true, - "tags": ["originals", "puzzle", "strategy"], - "releaseDate": "2025-07-20", - "popularity": 85 - }, - { - "title": "Cyber Runner", - "imageUrl": "https://placehold.co/300x200/003091/e7042d?text=Original+Game", - "linkUrl": "#", - "tags": ["originals", "action", "platformer"], - "releaseDate": "2025-06-15", - "popularity": 92 - }, - { - "title": "Starship Defender", - "imageUrl": "https://placehold.co/300x200/003091/e7042d?text=Original+Game", - "linkUrl": "#", - "tags": ["originals", "shooter", "sci-fi"], - "releaseDate": "2025-05-01", - "popularity": 78 - }, - { - "title": "Project Chimera", - "imageUrl": "https://placehold.co/300x200/003091/e7042d?text=Coming+Soon", - "linkUrl": "#", - "isComingSoon": true, - "tags": ["originals", "rpg", "adventure"], - "releaseDate": "2025-12-31", - "popularity": 95 - } -] +[] diff --git a/src/games/game-loader.ts b/src/games/game-loader.ts new file mode 100644 index 0000000..83e6192 --- /dev/null +++ b/src/games/game-loader.ts @@ -0,0 +1,44 @@ +import * as Phaser from 'phaser'; +import { paddleBattleConfig } from './paddle-battle/config'; + +// This interface extends the base Phaser GameConfig to include a title +export interface IGameConfig extends Phaser.Types.Core.GameConfig { + title: string; +} + +// --- CLIENT-SIDE ONLY LOGIC --- +// This map contains the actual Phaser game configurations. +const gameConfigs: { [key: string]: IGameConfig } = { + 'paddle-battle': { + ...paddleBattleConfig, + title: 'Paddle Battle', + }, +}; + +// A default config for games that are not yet implemented +const comingSoonConfig: IGameConfig = { + type: Phaser.AUTO, + width: 800, + height: 600, + backgroundColor: '#010123', + title: 'Coming Soon', + scene: { + create: function () { + this.add + .text(400, 300, 'Coming Soon!', { + font: '48px "Press Start 2P"', + color: '#e7042d', + }) + .setOrigin(0.5); + }, + }, +}; + +/** + * [CLIENT-SIDE] Retrieves a game's full Phaser configuration. + * This should only be used in Client Components. + */ +export function getGameConfigBySlug(slug: string): IGameConfig { + const title = slug.replace(/-/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase()); + return gameConfigs[slug] || { ...comingSoonConfig, title: title }; +} diff --git a/src/games/game-meta-loader.ts b/src/games/game-meta-loader.ts new file mode 100644 index 0000000..d408a63 --- /dev/null +++ b/src/games/game-meta-loader.ts @@ -0,0 +1,38 @@ +import type { Game } from '@/types'; +import originalGamesData from '@/data/originalGames.json'; +import arcadeGamesData from '@/data/arcadeGames.json'; + +const allGames: Game[] = [...originalGamesData, ...arcadeGamesData]; + +/** + * [SERVER-SAFE] Retrieves basic game details using the final slug from the path. + */ +export function getGameDetailsBySlug(slug: string) { + const game = allGames.find((g) => g.linkUrl.endsWith(slug)); + const title = game + ? game.title + : slug.replace(/-/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase()); + + return { + title: title, + metaDescription: + game?.metaDescription || + `Play ${title} on One Buffalo Games, a classic arcade-style game for your browser.`, + description: game?.description || 'No description available.', + controls: game?.controls || ['No controls specified.'], + stats: game?.stats || [], + isDesktopOnly: game?.isDesktopOnly ?? false, // Pass the flag, defaulting to false + }; +} + +/** + * [SERVER-SAFE] Gets all game slugs for static generation, formatted for catch-all routes. + */ +export function getAllGameSlugs() { + return allGames + .map((game) => { + const slugParts = game.linkUrl.replace('/games/', '').split('/'); + return { slug: slugParts }; + }) + .filter((item) => item.slug.length > 0 && !item.slug[0].startsWith('#')); +} diff --git a/src/games/paddle-battle/config.ts b/src/games/paddle-battle/config.ts new file mode 100644 index 0000000..85d4bd1 --- /dev/null +++ b/src/games/paddle-battle/config.ts @@ -0,0 +1,19 @@ +import * as Phaser from 'phaser'; +import { StartScene } from './scenes/StartScene'; +import { SettingsScene } from './scenes/SettingsScene'; +import { MainScene } from './scenes/MainScene'; + +// This is the configuration for our Paddle Battle game +export const paddleBattleConfig: Phaser.Types.Core.GameConfig = { + type: Phaser.AUTO, + width: 800, + height: 600, + backgroundColor: '#010123', // OBL Dark Blue + physics: { + default: 'arcade', + arcade: { + gravity: { x: 0, y: 0 }, + }, + }, + scene: [StartScene, SettingsScene, MainScene], +}; diff --git a/src/games/paddle-battle/prefabs/Ball.ts b/src/games/paddle-battle/prefabs/Ball.ts new file mode 100644 index 0000000..bcd6e02 --- /dev/null +++ b/src/games/paddle-battle/prefabs/Ball.ts @@ -0,0 +1,24 @@ +import * as Phaser from 'phaser'; + +export class Ball extends Phaser.Physics.Arcade.Sprite { + constructor(scene: Phaser.Scene, x: number, y: number) { + super(scene, x, y, 'snowflake_ball'); + + scene.add.existing(this); + scene.physics.add.existing(this); + + this.setCollideWorldBounds(true); + this.setBounce(1, 1); + } + + // Resets the ball to the center and launches it + public reset() { + this.setPosition(this.scene.cameras.main.centerX, this.scene.cameras.main.centerY); + const angle = + Phaser.Math.Between(0, 1) > 0.5 + ? Phaser.Math.Between(-30, 30) + : Phaser.Math.Between(150, 210); + const vec = this.scene.physics.velocityFromAngle(angle, 400); + this.setVelocity(vec.x, vec.y); + } +} diff --git a/src/games/paddle-battle/prefabs/Paddle.ts b/src/games/paddle-battle/prefabs/Paddle.ts new file mode 100644 index 0000000..4d8e5dd --- /dev/null +++ b/src/games/paddle-battle/prefabs/Paddle.ts @@ -0,0 +1,37 @@ +import * as Phaser from 'phaser'; + +export class Paddle extends Phaser.Physics.Arcade.Sprite { + private aiSpeed: number; + + constructor(scene: Phaser.Scene, x: number, y: number, textureKey: string, aiSpeed = 250) { + super(scene, x, y, textureKey); + this.aiSpeed = aiSpeed; + + scene.add.existing(this); + scene.physics.add.existing(this); + + this.setImmovable(true); + this.setCollideWorldBounds(true); + } + + handlePlayerMovement(cursors: Phaser.Types.Input.Keyboard.CursorKeys) { + if (cursors.up.isDown) { + this.setVelocityY(-350); // Player speed is constant + } else if (cursors.down.isDown) { + this.setVelocityY(350); + } else { + this.setVelocityY(0); + } + } + + handleAiMovement(ball: Phaser.Physics.Arcade.Sprite) { + // Use the speed set by the difficulty + if (this.y < ball.y) { + this.setVelocityY(this.aiSpeed); + } else if (this.y > ball.y) { + this.setVelocityY(-this.aiSpeed); + } else { + this.setVelocityY(0); + } + } +} diff --git a/src/games/paddle-battle/scenes/MainScene.ts b/src/games/paddle-battle/scenes/MainScene.ts new file mode 100644 index 0000000..6c1f4d1 --- /dev/null +++ b/src/games/paddle-battle/scenes/MainScene.ts @@ -0,0 +1,244 @@ +import * as Phaser from 'phaser'; +import { Paddle } from '../prefabs/Paddle'; +import { Ball } from '../prefabs/Ball'; +import * as statsManager from '@/utils/statsManager'; +import { Difficulty } from './StartScene'; + +const WINNING_SCORE = 10; +const GAME_ID = 'paddle-battle'; + +const DIFFICULTY_SETTINGS = { + easy: { opponentSpeed: 150 }, + normal: { opponentSpeed: 250 }, + hard: { opponentSpeed: 350 }, +}; + +export class MainScene extends Phaser.Scene { + private player!: Paddle; + private opponent!: Paddle; + private ball!: Ball; + private cursors!: Phaser.Types.Input.Keyboard.CursorKeys; + private playerScoreText!: Phaser.GameObjects.Text; + private opponentScoreText!: Phaser.GameObjects.Text; + private spaceKey!: Phaser.Input.Keyboard.Key; + private pauseKey!: Phaser.Input.Keyboard.Key; + private pauseText!: Phaser.GameObjects.Text; + + private playerScore = 0; + private opponentScore = 0; + private currentRally = 0; + private totalPlayTime = 0; + private isGameOver = false; + private isPaused = false; + private difficulty: Difficulty = 'normal'; + + constructor() { + super({ key: 'MainScene' }); + } + + init(data: { difficulty: Difficulty }) { + // Receive the difficulty from the StartScene + this.difficulty = data.difficulty || 'normal'; + } + + create() { + statsManager.incrementStat(GAME_ID, 'gamesPlayed'); + this.createThemedTextures(); + this.drawCenterLine(); + + this.physics.world.setBoundsCollision(false, false, true, true); + + this.player = new Paddle(this, 50, this.cameras.main.centerY, 'paddle_blue'); + // Pass the opponent speed from settings to the paddle + const opponentSpeed = DIFFICULTY_SETTINGS[this.difficulty].opponentSpeed; + this.opponent = new Paddle( + this, + this.cameras.main.width - 50, + this.cameras.main.centerY, + 'paddle_red', + opponentSpeed + ); + + this.ball = new Ball(this, this.cameras.main.centerX, this.cameras.main.centerY); + + this.physics.add.collider( + this.ball, + this.player, + this.handlePaddleBallCollision, + undefined, + this + ); + this.physics.add.collider( + this.ball, + this.opponent, + this.handlePaddleBallCollision, + undefined, + this + ); + + this.cursors = this.input.keyboard!.createCursorKeys(); + this.spaceKey = this.input.keyboard!.addKey(Phaser.Input.Keyboard.KeyCodes.SPACE); + this.pauseKey = this.input.keyboard!.addKey(Phaser.Input.Keyboard.KeyCodes.P); + + this.createScoreboard(); + this.createPauseText(); + this.ball.reset(); + } + + update(time: number, delta: number) { + if (Phaser.Input.Keyboard.JustDown(this.pauseKey)) this.togglePause(); + if (this.isPaused || this.isGameOver) return; + this.totalPlayTime += delta; + this.player.handlePlayerMovement(this.cursors); + this.opponent.handleAiMovement(this.ball); + this.checkScoring(); + } + + private togglePause() { + if (this.isGameOver) return; + this.isPaused = !this.isPaused; + if (this.isPaused) { + this.physics.pause(); + this.pauseText.setVisible(true); + } else { + this.physics.resume(); + this.pauseText.setVisible(false); + } + } + + private handlePaddleBallCollision() { + this.currentRally++; + statsManager.incrementStat(GAME_ID, 'totalRallies'); + } + + private checkScoring() { + if (this.ball.x < 0) { + this.opponentScore++; + this.opponentScoreText.setText(this.opponentScore.toString()); + this.endRally(); + this.checkWinCondition(); + } else if (this.ball.x > this.cameras.main.width) { + this.playerScore++; + this.playerScoreText.setText(this.playerScore.toString()); + statsManager.incrementStat(GAME_ID, 'totalPointsScored'); + this.endRally(); + this.checkWinCondition(); + } + } + + private endRally() { + statsManager.updateHighestStat(GAME_ID, 'longestRally', this.currentRally); + this.currentRally = 0; + if (!this.isGameOver) this.ball.reset(); + } + + private checkWinCondition() { + if (this.playerScore >= WINNING_SCORE) { + this.endGame('You Win!'); + statsManager.incrementStat(GAME_ID, 'playerWins'); + } else if (this.opponentScore >= WINNING_SCORE) { + this.endGame('You Lose!'); + statsManager.incrementStat(GAME_ID, 'playerLosses'); + } + } + + private endGame(message: string) { + this.isGameOver = true; + this.physics.pause(); + this.ball.setVisible(false); + statsManager.incrementStat(GAME_ID, 'totalPlayTime', Math.round(this.totalPlayTime / 1000)); + this.add + .text(this.cameras.main.centerX, this.cameras.main.centerY, message, { + font: '64px "Press Start 2P"', + color: '#e7042d', + }) + .setOrigin(0.5); + this.add + .text( + this.cameras.main.centerX, + this.cameras.main.centerY + 80, + 'Click or Press Space to Restart', + { font: '24px "Press Start 2P"', color: '#ffffff' } + ) + .setOrigin(0.5); + this.input.once('pointerdown', this.restartGame, this); + this.spaceKey.once('down', this.restartGame, this); + } + + private restartGame() { + this.playerScore = 0; + this.opponentScore = 0; + this.isGameOver = false; + this.isPaused = false; + this.totalPlayTime = 0; + this.scene.restart({ difficulty: this.difficulty }); + } + + private createScoreboard() { + this.playerScoreText = this.add + .text(this.cameras.main.centerX - 50, 50, '0', { + font: '48px "Press Start 2P"', + color: '#ffffff', + }) + .setOrigin(0.5); + this.opponentScoreText = this.add + .text(this.cameras.main.centerX + 50, 50, '0', { + font: '48px "Press Start 2P"', + color: '#ffffff', + }) + .setOrigin(0.5); + } + + private createPauseText() { + this.pauseText = this.add + .text(this.cameras.main.centerX, this.cameras.main.centerY, 'PAUSED', { + font: '64px "Press Start 2P"', + color: '#ffffff', + }) + .setOrigin(0.5) + .setVisible(false) + .setDepth(1); + } + + private drawCenterLine() { + const graphics = this.make.graphics(); + graphics.lineStyle(5, 0xffffff, 0.5); + for (let i = 0; i < this.cameras.main.height; i += 30) { + graphics.lineBetween(this.cameras.main.centerX, i, this.cameras.main.centerX, i + 15); + } + graphics.generateTexture('center_line', this.cameras.main.width, this.cameras.main.height); + this.add.image(this.cameras.main.centerX, this.cameras.main.centerY, 'center_line'); + graphics.destroy(); + } + + private createThemedTextures() { + const graphics = this.make.graphics(); + + graphics.fillStyle(0x003091); + graphics.fillRect(0, 0, 20, 100); + graphics.generateTexture('paddle_blue', 20, 100); + graphics.clear(); + + graphics.fillStyle(0xe7042d); + graphics.fillRect(0, 0, 20, 100); + graphics.generateTexture('paddle_red', 20, 100); + graphics.clear(); + + graphics.fillStyle(0xffffff); + const snowflakePixels = [ + { x: 3, y: 0, w: 1, h: 7 }, + { x: 0, y: 3, w: 7, h: 1 }, + { x: 1, y: 1, w: 1, h: 1 }, + { x: 5, y: 1, w: 1, h: 1 }, + { x: 1, y: 5, w: 1, h: 1 }, + { x: 5, y: 5, w: 1, h: 1 }, + ]; + const scale = 3; + snowflakePixels.forEach((p) => { + graphics.fillRect(p.x * scale, p.y * scale, p.w * scale, p.h * scale); + }); + graphics.generateTexture('snowflake_ball', 7 * scale, 7 * scale); + + graphics.destroy(); + } +} diff --git a/src/games/paddle-battle/scenes/SettingsScene.ts b/src/games/paddle-battle/scenes/SettingsScene.ts new file mode 100644 index 0000000..faeb347 --- /dev/null +++ b/src/games/paddle-battle/scenes/SettingsScene.ts @@ -0,0 +1,83 @@ +import * as Phaser from 'phaser'; +import * as settingsManager from '@/utils/settingsManager'; +import { Difficulty } from './StartScene'; + +const GAME_ID = 'paddle-battle'; + +export class SettingsScene extends Phaser.Scene { + private selectedDifficulty!: Difficulty; + private difficultyLabels!: Phaser.GameObjects.Text[]; + + constructor() { + super({ key: 'SettingsScene' }); + } + + create() { + this.selectedDifficulty = settingsManager.getSetting(GAME_ID, 'difficulty', 'normal'); + + this.add + .text(this.cameras.main.centerX, 100, 'Settings', { + font: '64px "Press Start 2P"', + color: '#e7042d', + }) + .setOrigin(0.5); + + this.add + .text(this.cameras.main.centerX, 250, 'Difficulty', { + font: '40px "Press Start 2P"', + color: '#ffffff', + }) + .setOrigin(0.5); + + this.createDifficultySelector(); + + const backButton = this.add + .text(this.cameras.main.centerX, 500, 'Back', { + font: '32px "Press Start 2P"', + color: '#ffffff', + }) + .setOrigin(0.5) + .setInteractive({ useHandCursor: true }); + + backButton.on('pointerover', () => backButton.setColor('#e7042d')); + backButton.on('pointerout', () => backButton.setColor('#ffffff')); + backButton.on('pointerdown', () => { + this.scene.start('StartScene'); + }); + } + + private createDifficultySelector() { + const difficulties: Difficulty[] = ['easy', 'normal', 'hard']; + const spacing = 220; // The space between each option + + this.difficultyLabels = difficulties.map((d, index) => { + // Calculate the position for each label + const xPos = this.cameras.main.centerX + (index - 1) * spacing; + const yPos = 320; + + const text = this.add + .text(xPos, yPos, d.toUpperCase(), { + font: '32px "Press Start 2P"', + color: '#ffffff', + }) + .setOrigin(0.5) + .setInteractive({ useHandCursor: true }); + + text.on('pointerdown', () => { + this.selectedDifficulty = d; + settingsManager.setSetting(GAME_ID, 'difficulty', d); + this.updateSelectorUI(); + }); + + return text; + }); + + this.updateSelectorUI(); + } + + private updateSelectorUI() { + this.difficultyLabels.forEach((label) => { + label.setColor(label.text.toLowerCase() === this.selectedDifficulty ? '#e7042d' : '#ffffff'); + }); + } +} diff --git a/src/games/paddle-battle/scenes/StartScene.ts b/src/games/paddle-battle/scenes/StartScene.ts new file mode 100644 index 0000000..0ac85ec --- /dev/null +++ b/src/games/paddle-battle/scenes/StartScene.ts @@ -0,0 +1,77 @@ +import * as Phaser from 'phaser'; +import * as settingsManager from '@/utils/settingsManager'; + +export type Difficulty = 'easy' | 'normal' | 'hard'; +const GAME_ID = 'paddle-battle'; + +export class StartScene extends Phaser.Scene { + private currentDifficulty!: Difficulty; + + constructor() { + super({ key: 'StartScene' }); + } + + create() { + this.currentDifficulty = settingsManager.getSetting(GAME_ID, 'difficulty', 'normal'); + + // --- Primary Action: Start Game --- + const startButton = this.add + .text( + this.cameras.main.centerX, + this.cameras.main.centerY - 40, // Positioned slightly above center + 'Click or Press Space to Start', + { + font: '32px "Press Start 2P"', + color: '#ffffff', + align: 'center', + } + ) + .setOrigin(0.5) + .setInteractive({ useHandCursor: true }); + + // --- Secondary Action: Settings --- + const settingsButton = this.add + .text( + this.cameras.main.centerX, + this.cameras.main.centerY + 40, // Positioned below the start button + 'Settings', + { + font: '24px "Press Start 2P"', + color: '#ffffff', + } + ) + .setOrigin(0.5) + .setInteractive({ useHandCursor: true }); + + // --- Tertiary Info: Current Difficulty --- + this.add + .text( + this.cameras.main.centerX, + this.cameras.main.height - 40, // Positioned at the bottom + `Difficulty: ${this.currentDifficulty.toUpperCase()}`, + { + font: '16px "Press Start 2P"', + color: 'rgba(255, 255, 255, 0.7)', // Muted color + } + ) + .setOrigin(0.5); + + // --- Event Listeners --- + settingsButton.on('pointerover', () => settingsButton.setColor('#e7042d')); + settingsButton.on('pointerout', () => settingsButton.setColor('#ffffff')); + settingsButton.on('pointerdown', () => this.scene.start('SettingsScene')); + + startButton.on('pointerover', () => startButton.setColor('#e7042d')); + startButton.on('pointerout', () => startButton.setColor('#ffffff')); + startButton.on('pointerdown', () => this.startGame()); + + this.input.keyboard!.once('keydown-SPACE', (event: KeyboardEvent) => { + event.preventDefault(); + this.startGame(); + }); + } + + private startGame() { + this.scene.start('MainScene', { difficulty: this.currentDifficulty }); + } +} diff --git a/src/types/index.ts b/src/types/index.ts index adcc7ab..416b869 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -14,6 +14,14 @@ export interface Hub { popularity: number; } +/** + * Defines a single stat to be displayed for a game. + */ +export interface GameStat { + key: string; // The key used in local storage (e.g., 'playerWins') + label: string; // The display label for the stat (e.g., 'Player Wins') +} + /** * Defines the structure for a Game object. */ @@ -21,11 +29,16 @@ export interface Game { title: string; imageUrl: string; linkUrl: string; + metaDescription: string; + isDesktopOnly?: boolean; isNew?: boolean; isComingSoon?: boolean; tags: string[]; releaseDate: string; popularity: number; + description: string; + controls: string[]; + stats?: GameStat[]; } /** diff --git a/src/utils/settingsManager.ts b/src/utils/settingsManager.ts new file mode 100644 index 0000000..2e59738 --- /dev/null +++ b/src/utils/settingsManager.ts @@ -0,0 +1,59 @@ +/** + * A generic utility for managing game settings in local storage. + */ + +// Use a more specific type for setting values instead of 'any'. +type SettingValue = string | number | boolean; +type GameSettings = Record; + +// --- Private Helper Functions --- + +function getSettingsKey(gameId: string): string { + return `oneBuffaloGames_${gameId}_settings`; +} + +function getSettings(gameId: string): GameSettings { + if (typeof window === 'undefined') { + return {}; + } + const key = getSettingsKey(gameId); + const settings = localStorage.getItem(key); + return settings ? JSON.parse(settings) : {}; +} + +function saveSettings(gameId: string, settings: GameSettings) { + if (typeof window === 'undefined') return; + const key = getSettingsKey(gameId); + localStorage.setItem(key, JSON.stringify(settings)); +} + +// --- Public API for Settings Management --- + +/** + * Sets a specific setting for a given game. + * @param gameId The unique identifier for the game (e.g., 'paddle-battle'). + * @param settingName The name of the setting to save (e.g., 'difficulty'). + * @param value The value to save. + */ +export function setSetting(gameId: string, settingName: string, value: SettingValue) { + const settings = getSettings(gameId); + settings[settingName] = value; + saveSettings(gameId, settings); +} + +/** + * Gets a specific setting for a given game. + * @param gameId The unique identifier for the game. + * @param settingName The name of the setting to retrieve. + * @param defaultValue The value to return if the setting is not found. + * @returns The saved setting value or the default value. + */ +export function getSetting( + gameId: string, + settingName: string, + defaultValue: T +): T { + const settings = getSettings(gameId); + // The type assertion ensures the return value matches the generic type T. + return (settings[settingName] !== undefined ? settings[settingName] : defaultValue) as T; +} diff --git a/src/utils/statsManager.ts b/src/utils/statsManager.ts new file mode 100644 index 0000000..54e1669 --- /dev/null +++ b/src/utils/statsManager.ts @@ -0,0 +1,50 @@ +/** + * A generic utility for managing game statistics in local storage. + * Each game's stats are stored under a unique key. + */ + +type GameStats = Record; + +// --- Private Helper Functions --- + +function getStatsKey(gameId: string): string { + return `oneBuffaloGames_${gameId}_stats`; +} + +export function getAllStats(gameId: string): GameStats { + if (typeof window === 'undefined') { + return {}; + } + const key = getStatsKey(gameId); + const stats = localStorage.getItem(key); + return stats ? JSON.parse(stats) : {}; +} + +function saveStats(gameId: string, stats: GameStats) { + if (typeof window === 'undefined') return; + const key = getStatsKey(gameId); + localStorage.setItem(key, JSON.stringify(stats)); + // Dispatch a custom event to notify the UI of the change. + window.dispatchEvent(new CustomEvent('statsUpdated')); +} + +// --- Public API for Stats Management --- + +export function incrementStat(gameId: string, statName: string, amount = 1) { + const stats = getAllStats(gameId); + stats[statName] = (stats[statName] || 0) + amount; + saveStats(gameId, stats); +} + +export function updateHighestStat(gameId: string, statName: string, newValue: number) { + const stats = getAllStats(gameId); + if (newValue > (stats[statName] || 0)) { + stats[statName] = newValue; + saveStats(gameId, stats); + } +} + +export function getStat(gameId: string, statName: string): number { + const stats = getAllStats(gameId); + return stats[statName] || 0; +}