From 7b874ad525b8d2bf916c3aee89ba58c0d86e376a Mon Sep 17 00:00:00 2001 From: Bana0516 Date: Tue, 5 Aug 2025 13:58:06 -0400 Subject: [PATCH 01/19] [Issue-10] Implement Reusable Component for Scalable Game Integration #10 --- package-lock.json | 16 ++++ package.json | 15 ++-- src/app/games/[slug]/page.tsx | 36 +++++++++ src/components/games/GameCanvas.tsx | 40 ++++++++++ src/components/games/GameClient.tsx | 60 +++++++++++++++ src/data/arcadeGames.json | 25 ++++--- src/games/game-loader.ts | 44 +++++++++++ src/games/game-meta-loader.ts | 28 +++++++ src/games/paddle-battle/config.ts | 17 +++++ src/games/paddle-battle/prefabs/Ball.ts | 25 +++++++ src/games/paddle-battle/prefabs/Paddle.ts | 36 +++++++++ src/games/paddle-battle/scenes/MainScene.ts | 83 +++++++++++++++++++++ 12 files changed, 407 insertions(+), 18 deletions(-) create mode 100644 src/app/games/[slug]/page.tsx create mode 100644 src/components/games/GameCanvas.tsx create mode 100644 src/components/games/GameClient.tsx create mode 100644 src/games/game-loader.ts create mode 100644 src/games/game-meta-loader.ts create mode 100644 src/games/paddle-battle/config.ts create mode 100644 src/games/paddle-battle/prefabs/Ball.ts create mode 100644 src/games/paddle-battle/prefabs/Paddle.ts create mode 100644 src/games/paddle-battle/scenes/MainScene.ts 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/src/app/games/[slug]/page.tsx b/src/app/games/[slug]/page.tsx new file mode 100644 index 0000000..9d80d75 --- /dev/null +++ b/src/app/games/[slug]/page.tsx @@ -0,0 +1,36 @@ +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 }>; +} + +// This function is now 100% server-safe. +export async function generateMetadata({ params }: GamePageProps): Promise { + const { slug } = await params; + + const gameDetails = getGameDetailsBySlug(slug); + + return generatePageMetadata({ + title: gameDetails.title, + description: `Play ${gameDetails.title} on One Buffalo Games. A classic arcade-style game for your browser.`, + urlPath: `/games/${slug}`, + }); +} + +// This function is also server-safe. +export async function generateStaticParams() { + return getAllGameSlugs(); +} + +/** + * The dynamic page component remains a Server Component. + * It passes the slug to the GameClient, which will handle all client-side logic. + */ +export default async function GamePage({ params }: GamePageProps) { + const { slug } = await params; + + return ; +} diff --git a/src/components/games/GameCanvas.tsx b/src/components/games/GameCanvas.tsx new file mode 100644 index 0000000..d861885 --- /dev/null +++ b/src/components/games/GameCanvas.tsx @@ -0,0 +1,40 @@ +'use client'; + +import { useEffect, useRef } from 'react'; +import * as Phaser from 'phaser'; + +// Define the props for the component +interface GameCanvasProps { + gameConfig: Phaser.Types.Core.GameConfig; +} + +/** + * A reusable React component to host and manage a Phaser game instance. + * It handles the creation and destruction of the game, preventing memory leaks. + */ +export default function GameCanvas({ gameConfig }: GameCanvasProps) { + // Use a ref to hold the Phaser game instance + const gameRef = useRef(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..ec7887f --- /dev/null +++ b/src/components/games/GameClient.tsx @@ -0,0 +1,60 @@ +'use client'; // This directive makes this component a Client Component. + +import { Suspense } from 'react'; +import dynamic from 'next/dynamic'; +import { getGameConfigBySlug } from '@/games/game-loader'; +import ArcadeButton from '@/components/ArcadeButton'; + +// The dynamic import with ssr: false is now correctly placed in a Client Component. +// This ensures Phaser is never loaded on the server. +const GameCanvas = dynamic(() => import('@/components/games/GameCanvas'), { + ssr: false, + loading: () =>
, +}); + +interface GameClientProps { + slug: string; +} + +/** + * This component acts as the client-side wrapper for a game page. + * It handles the dynamic loading of the game canvas and renders the UI. + */ +export default function GameClient({ slug }: GameClientProps) { + // This logic now runs safely on the client + const gameConfig = getGameConfigBySlug(slug); + + return ( +
+
+

+ {gameConfig.title} +

+ +
+ + }> + + +
+ +
+

Controls

+

+ Use the UP and{' '} + DOWN arrow keys to move your paddle. +

+

First to 10 points wins!

+
+ +
+ + Back to Arcade + +
+
+
+ ); +} diff --git a/src/data/arcadeGames.json b/src/data/arcadeGames.json index 420b827..f502491 100644 --- a/src/data/arcadeGames.json +++ b/src/data/arcadeGames.json @@ -1,8 +1,17 @@ [ + { + "title": "Paddle Battle", + "imageUrl": "https://placehold.co/300x200/e7042d/003091?text=Paddle+Battle", + "linkUrl": "/games/paddle-battle", + "tags": ["arcade", "classic"], + "releaseDate": "2025-08-05", + "popularity": 95 + }, { "title": "Galaxy Invaders", "imageUrl": "https://placehold.co/300x200/e7042d/003091?text=Arcade+Classic", - "linkUrl": "#", + "linkUrl": "/games/galaxy-invaders", + "isComingSoon": true, "tags": ["arcade", "shooter"], "releaseDate": "2024-10-25", "popularity": 90 @@ -10,23 +19,17 @@ { "title": "Block Breaker", "imageUrl": "https://placehold.co/300x200/e7042d/003091?text=Arcade+Classic", - "linkUrl": "#", + "linkUrl": "/games/block-breaker", + "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 - }, { "title": "Maze Mania", "imageUrl": "https://placehold.co/300x200/e7042d/003091?text=Arcade+Classic", - "linkUrl": "#", + "linkUrl": "/games/maze-mania", + "isComingSoon": true, "tags": ["arcade", "puzzle", "strategy"], "releaseDate": "2024-08-19", "popularity": 82 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..4945fc7 --- /dev/null +++ b/src/games/game-meta-loader.ts @@ -0,0 +1,28 @@ +import originalGamesData from '@/data/originalGames.json'; +import arcadeGamesData from '@/data/arcadeGames.json'; + +// --- SERVER-SIDE SAFE LOGIC --- +const allGames = [...originalGamesData, ...arcadeGamesData]; + +/** + * [SERVER-SAFE] Retrieves basic game details (like title) without importing Phaser. + * This is safe to use in Server Components for generating metadata. + */ +export function getGameDetailsBySlug(slug: string): { title: 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 }; +} + +/** + * [SERVER-SAFE] Gets all game slugs for static generation. + */ +export function getAllGameSlugs() { + return allGames + .map((game) => ({ + slug: game.linkUrl.split('/').pop() || '', + })) + .filter((item) => item.slug && !item.slug.startsWith('#')); +} diff --git a/src/games/paddle-battle/config.ts b/src/games/paddle-battle/config.ts new file mode 100644 index 0000000..e6e5831 --- /dev/null +++ b/src/games/paddle-battle/config.ts @@ -0,0 +1,17 @@ +import * as Phaser from 'phaser'; +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: [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..6928106 --- /dev/null +++ b/src/games/paddle-battle/prefabs/Ball.ts @@ -0,0 +1,25 @@ +import * as Phaser from 'phaser'; + +export class Ball extends Phaser.Physics.Arcade.Sprite { + constructor(scene: Phaser.Scene, x: number, y: number) { + // The 'ball' texture is now expected to already exist. + super(scene, x, y, '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..71c0a4e --- /dev/null +++ b/src/games/paddle-battle/prefabs/Paddle.ts @@ -0,0 +1,36 @@ +import * as Phaser from 'phaser'; + +export class Paddle extends Phaser.Physics.Arcade.Sprite { + constructor(scene: Phaser.Scene, x: number, y: number) { + // The 'paddle' texture is now expected to already exist. + super(scene, x, y, 'paddle'); + + scene.add.existing(this); + scene.physics.add.existing(this); + + this.setImmovable(true); + this.setCollideWorldBounds(true); + } + + // Handle player-specific movement + handlePlayerMovement(cursors: Phaser.Types.Input.Keyboard.CursorKeys) { + if (cursors.up.isDown) { + this.setVelocityY(-300); + } else if (cursors.down.isDown) { + this.setVelocityY(300); + } else { + this.setVelocityY(0); + } + } + + // Handle AI-specific movement + handleAiMovement(ball: Phaser.Physics.Arcade.Sprite) { + if (this.y < ball.y) { + this.setVelocityY(200); + } else if (this.y > ball.y) { + this.setVelocityY(-200); + } 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..7efa52b --- /dev/null +++ b/src/games/paddle-battle/scenes/MainScene.ts @@ -0,0 +1,83 @@ +import * as Phaser from 'phaser'; +import { Paddle } from '../prefabs/Paddle'; +import { Ball } from '../prefabs/Ball'; + +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 playerScore = 0; + private opponentScore = 0; + + constructor() { + super({ key: 'MainScene' }); + } + + create() { + // --- Create Textures Once --- + this.createPaddleTexture(); + this.createBallTexture(); + + this.physics.world.setBoundsCollision(false, false, true, true); + + // Now, create the game objects. They will find their textures by key. + this.player = new Paddle(this, 50, this.cameras.main.centerY); + this.opponent = new Paddle(this, this.cameras.main.width - 50, this.cameras.main.centerY); + this.ball = new Ball(this, this.cameras.main.centerX, this.cameras.main.centerY); + + this.physics.add.collider(this.ball, this.player); + this.physics.add.collider(this.ball, this.opponent); + + this.cursors = this.input.keyboard!.createCursorKeys(); + + 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); + + this.ball.reset(); + } + + update() { + this.player.handlePlayerMovement(this.cursors); + this.opponent.handleAiMovement(this.ball); + + if (this.ball.x < 0) { + this.opponentScore++; + this.opponentScoreText.setText(this.opponentScore.toString()); + this.ball.reset(); + } else if (this.ball.x > this.cameras.main.width) { + this.playerScore++; + this.playerScoreText.setText(this.playerScore.toString()); + this.ball.reset(); + } + } + + // --- Texture Creation Methods --- + private createPaddleTexture() { + const graphics = this.make.graphics(); + graphics.fillStyle(0xffffff); + graphics.fillRect(0, 0, 20, 100); + graphics.generateTexture('paddle', 20, 100); + graphics.destroy(); + } + + private createBallTexture() { + const graphics = this.make.graphics(); + graphics.fillStyle(0xffffff); + graphics.fillCircle(10, 10, 10); + graphics.generateTexture('ball', 20, 20); + graphics.destroy(); + } +} From 395c4d10beff3ee88ee7b6799dd061dc0393ecac Mon Sep 17 00:00:00 2001 From: Bana0516 Date: Tue, 5 Aug 2025 14:22:01 -0400 Subject: [PATCH 02/19] [Issue-16] Paddle Battle: Added local storage to store stats --- src/app/games/[slug]/page.tsx | 16 ++- src/components/games/GameClient.tsx | 36 ++++-- src/games/paddle-battle/scenes/MainScene.ts | 131 ++++++++++++++++---- src/utils/statsManager.ts | 71 +++++++++++ 4 files changed, 208 insertions(+), 46 deletions(-) create mode 100644 src/utils/statsManager.ts diff --git a/src/app/games/[slug]/page.tsx b/src/app/games/[slug]/page.tsx index 9d80d75..2068147 100644 --- a/src/app/games/[slug]/page.tsx +++ b/src/app/games/[slug]/page.tsx @@ -1,13 +1,13 @@ 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'; +import GameClient from '@/components/games/GameClient'; // Import the client component interface GamePageProps { params: Promise<{ slug: string }>; } -// This function is now 100% server-safe. +// This function runs on the server and is safe. export async function generateMetadata({ params }: GamePageProps): Promise { const { slug } = await params; @@ -20,17 +20,21 @@ export async function generateMetadata({ params }: GamePageProps): Promise; + // Fetch the game details on the server. + const gameDetails = getGameDetailsBySlug(slug); + + // Render the client component, passing down the necessary props. + return ; } diff --git a/src/components/games/GameClient.tsx b/src/components/games/GameClient.tsx index ec7887f..a170271 100644 --- a/src/components/games/GameClient.tsx +++ b/src/components/games/GameClient.tsx @@ -1,12 +1,11 @@ -'use client'; // This directive makes this component a Client Component. +'use client'; -import { Suspense } from 'react'; +import { useState, useEffect, Suspense } from 'react'; import dynamic from 'next/dynamic'; -import { getGameConfigBySlug } from '@/games/game-loader'; import ArcadeButton from '@/components/ArcadeButton'; +import type { IGameConfig } from '@/games/game-loader'; -// The dynamic import with ssr: false is now correctly placed in a Client Component. -// This ensures Phaser is never loaded on the server. +// Dynamically import the GameCanvas component with SSR turned off. const GameCanvas = dynamic(() => import('@/components/games/GameCanvas'), { ssr: false, loading: () =>
, @@ -14,29 +13,40 @@ const GameCanvas = dynamic(() => import('@/components/games/GameCanvas'), { interface GameClientProps { slug: string; + title: string; } /** * This component acts as the client-side wrapper for a game page. - * It handles the dynamic loading of the game canvas and renders the UI. + * It handles the dynamic loading of the game canvas and its configuration. */ -export default function GameClient({ slug }: GameClientProps) { - // This logic now runs safely on the client - const gameConfig = getGameConfigBySlug(slug); +export default function GameClient({ slug, title }: GameClientProps) { + // State to hold the game config, which will be loaded only on the client. + const [gameConfig, setGameConfig] = useState(null); + + useEffect(() => { + import('@/games/game-loader').then(({ getGameConfigBySlug }) => { + const config = getGameConfigBySlug(slug); + setGameConfig(config); + }); + }, [slug]); // Re-run this effect if the game slug changes. return (
-

- {gameConfig.title} -

+

{title}

}> - + {/* Only render the GameCanvas when the config has been loaded into state. */} + {gameConfig ? ( + + ) : ( +
+ )}
diff --git a/src/games/paddle-battle/scenes/MainScene.ts b/src/games/paddle-battle/scenes/MainScene.ts index 7efa52b..72b5b86 100644 --- a/src/games/paddle-battle/scenes/MainScene.ts +++ b/src/games/paddle-battle/scenes/MainScene.ts @@ -1,6 +1,11 @@ import * as Phaser from 'phaser'; import { Paddle } from '../prefabs/Paddle'; import { Ball } from '../prefabs/Ball'; +// Import the generic stats manager +import * as statsManager from '@/utils/statsManager'; + +const WINNING_SCORE = 10; +const GAME_ID = 'paddle-battle'; // Define a unique ID for this game export class MainScene extends Phaser.Scene { private player!: Paddle; @@ -9,72 +14,144 @@ export class MainScene extends Phaser.Scene { private cursors!: Phaser.Types.Input.Keyboard.CursorKeys; private playerScoreText!: Phaser.GameObjects.Text; private opponentScoreText!: Phaser.GameObjects.Text; + private playerScore = 0; private opponentScore = 0; + private currentRally = 0; + private isGameOver = false; constructor() { super({ key: 'MainScene' }); } create() { - // --- Create Textures Once --- - this.createPaddleTexture(); - this.createBallTexture(); - + this.createTextures(); this.physics.world.setBoundsCollision(false, false, true, true); - // Now, create the game objects. They will find their textures by key. this.player = new Paddle(this, 50, this.cameras.main.centerY); this.opponent = new Paddle(this, this.cameras.main.width - 50, this.cameras.main.centerY); this.ball = new Ball(this, this.cameras.main.centerX, this.cameras.main.centerY); - this.physics.add.collider(this.ball, this.player); - this.physics.add.collider(this.ball, this.opponent); + 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.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); + this.createScoreboard(); this.ball.reset(); } update() { + if (this.isGameOver) { + return; + } + this.player.handlePlayerMovement(this.cursors); this.opponent.handleAiMovement(this.ball); + this.checkScoring(); + } + + private handlePaddleBallCollision() { + this.currentRally++; + } + private checkScoring() { if (this.ball.x < 0) { this.opponentScore++; this.opponentScoreText.setText(this.opponentScore.toString()); - this.ball.reset(); + this.endRally(); + this.checkWinCondition(); } else if (this.ball.x > this.cameras.main.width) { this.playerScore++; this.playerScoreText.setText(this.playerScore.toString()); + // Use the new generic stat function + statsManager.incrementStat(GAME_ID, 'totalPointsScored'); + this.endRally(); + this.checkWinCondition(); + } + } + + private endRally() { + // Use the new generic stat function for highest value + statsManager.updateHighestStat(GAME_ID, 'longestRally', this.currentRally); + this.currentRally = 0; + if (!this.isGameOver) { this.ball.reset(); } } - // --- Texture Creation Methods --- - private createPaddleTexture() { + private checkWinCondition() { + if (this.playerScore >= WINNING_SCORE) { + this.endGame('You Win!'); + // Use the new generic stat function + statsManager.incrementStat(GAME_ID, 'playerWins'); + } else if (this.opponentScore >= WINNING_SCORE) { + this.endGame('You Lose!'); + } + } + + private endGame(message: string) { + this.isGameOver = true; + this.physics.pause(); + this.ball.setVisible(false); + + 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 to Restart', { + font: '24px "Press Start 2P"', + color: '#ffffff', + }) + .setOrigin(0.5); + + this.input.once('pointerdown', () => { + this.playerScore = 0; + this.opponentScore = 0; + this.isGameOver = false; + this.scene.restart(); + }); + } + + 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 createTextures() { const graphics = this.make.graphics(); graphics.fillStyle(0xffffff); graphics.fillRect(0, 0, 20, 100); graphics.generateTexture('paddle', 20, 100); - graphics.destroy(); - } - private createBallTexture() { - const graphics = this.make.graphics(); + graphics.clear(); + graphics.fillStyle(0xffffff); graphics.fillCircle(10, 10, 10); graphics.generateTexture('ball', 20, 20); diff --git a/src/utils/statsManager.ts b/src/utils/statsManager.ts new file mode 100644 index 0000000..3bc6e9b --- /dev/null +++ b/src/utils/statsManager.ts @@ -0,0 +1,71 @@ +/** + * A generic utility for managing game statistics in local storage. + * Each game's stats are stored under a unique key. + */ + +// A generic type for any game's stats object. +type GameStats = Record; + +// --- Private Helper Functions --- + +// Generates a unique local storage key for a given game ID. +function getStatsKey(gameId: string): string { + return `oneBuffaloGames_${gameId}_stats`; +} + +// Gets the stats for a specific game, or returns an empty object. +function getStats(gameId: string): GameStats { + if (typeof window === 'undefined') { + return {}; + } + const key = getStatsKey(gameId); + const stats = localStorage.getItem(key); + return stats ? JSON.parse(stats) : {}; +} + +// Saves the stats for a specific game. +function saveStats(gameId: string, stats: GameStats) { + if (typeof window === 'undefined') return; + const key = getStatsKey(gameId); + localStorage.setItem(key, JSON.stringify(stats)); +} + +// --- Public API for Stats Management --- + +/** + * Increments a specific stat for a given game. + * @param gameId The unique identifier for the game (e.g., 'paddle-battle'). + * @param statName The name of the stat to increment (e.g., 'playerWins'). + * @param amount The amount to increment by (defaults to 1). + */ +export function incrementStat(gameId: string, statName: string, amount = 1) { + const stats = getStats(gameId); + stats[statName] = (stats[statName] || 0) + amount; + saveStats(gameId, stats); +} + +/** + * Updates a stat only if the new value is higher than the existing one. + * Useful for tracking high scores or longest rallies. + * @param gameId The unique identifier for the game. + * @param statName The name of the stat to update (e.g., 'longestRally'). + * @param newValue The new potential highest value. + */ +export function updateHighestStat(gameId: string, statName: string, newValue: number) { + const stats = getStats(gameId); + if (newValue > (stats[statName] || 0)) { + stats[statName] = newValue; + saveStats(gameId, stats); + } +} + +/** + * Retrieves a specific stat for a given game. + * @param gameId The unique identifier for the game. + * @param statName The name of the stat to retrieve. + * @returns The value of the stat, or 0 if it doesn't exist. + */ +export function getStat(gameId: string, statName: string): number { + const stats = getStats(gameId); + return stats[statName] || 0; +} From e69240756ac62e7b41d0cad67fb96cb96df957a4 Mon Sep 17 00:00:00 2001 From: Bana0516 Date: Tue, 5 Aug 2025 14:25:00 -0400 Subject: [PATCH 03/19] [Issue-16] Paddle Battle: Game can be restarted with spacebar now --- src/games/paddle-battle/scenes/MainScene.ts | 41 +++++++++++++-------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/src/games/paddle-battle/scenes/MainScene.ts b/src/games/paddle-battle/scenes/MainScene.ts index 72b5b86..7a732c2 100644 --- a/src/games/paddle-battle/scenes/MainScene.ts +++ b/src/games/paddle-battle/scenes/MainScene.ts @@ -1,11 +1,10 @@ import * as Phaser from 'phaser'; import { Paddle } from '../prefabs/Paddle'; import { Ball } from '../prefabs/Ball'; -// Import the generic stats manager import * as statsManager from '@/utils/statsManager'; const WINNING_SCORE = 10; -const GAME_ID = 'paddle-battle'; // Define a unique ID for this game +const GAME_ID = 'paddle-battle'; export class MainScene extends Phaser.Scene { private player!: Paddle; @@ -14,6 +13,7 @@ export class MainScene extends Phaser.Scene { private cursors!: Phaser.Types.Input.Keyboard.CursorKeys; private playerScoreText!: Phaser.GameObjects.Text; private opponentScoreText!: Phaser.GameObjects.Text; + private spaceKey!: Phaser.Input.Keyboard.Key; private playerScore = 0; private opponentScore = 0; @@ -48,8 +48,10 @@ export class MainScene extends Phaser.Scene { ); this.cursors = this.input.keyboard!.createCursorKeys(); - this.createScoreboard(); + // Add a listener for the spacebar + this.spaceKey = this.input.keyboard!.addKey(Phaser.Input.Keyboard.KeyCodes.SPACE); + this.createScoreboard(); this.ball.reset(); } @@ -76,7 +78,6 @@ export class MainScene extends Phaser.Scene { } else if (this.ball.x > this.cameras.main.width) { this.playerScore++; this.playerScoreText.setText(this.playerScore.toString()); - // Use the new generic stat function statsManager.incrementStat(GAME_ID, 'totalPointsScored'); this.endRally(); this.checkWinCondition(); @@ -84,7 +85,6 @@ export class MainScene extends Phaser.Scene { } private endRally() { - // Use the new generic stat function for highest value statsManager.updateHighestStat(GAME_ID, 'longestRally', this.currentRally); this.currentRally = 0; if (!this.isGameOver) { @@ -95,7 +95,6 @@ export class MainScene extends Phaser.Scene { private checkWinCondition() { if (this.playerScore >= WINNING_SCORE) { this.endGame('You Win!'); - // Use the new generic stat function statsManager.incrementStat(GAME_ID, 'playerWins'); } else if (this.opponentScore >= WINNING_SCORE) { this.endGame('You Lose!'); @@ -114,19 +113,29 @@ export class MainScene extends Phaser.Scene { }) .setOrigin(0.5); + // Updated text to include the spacebar option this.add - .text(this.cameras.main.centerX, this.cameras.main.centerY + 80, 'Click to Restart', { - font: '24px "Press Start 2P"', - color: '#ffffff', - }) + .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.playerScore = 0; - this.opponentScore = 0; - this.isGameOver = false; - this.scene.restart(); - }); + // Set up a single listener for both click and spacebar + 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.scene.restart(); } private createScoreboard() { From d0ff5cfd12d41cfaaf4f223bdac04a196b02a3c9 Mon Sep 17 00:00:00 2001 From: Bana0516 Date: Tue, 5 Aug 2025 14:27:24 -0400 Subject: [PATCH 04/19] [Issue-16] Paddle Battle: Implemented pause functionality --- src/games/paddle-battle/scenes/MainScene.ts | 46 +++++++++++++++++++-- 1 file changed, 42 insertions(+), 4 deletions(-) diff --git a/src/games/paddle-battle/scenes/MainScene.ts b/src/games/paddle-battle/scenes/MainScene.ts index 7a732c2..82e8790 100644 --- a/src/games/paddle-battle/scenes/MainScene.ts +++ b/src/games/paddle-battle/scenes/MainScene.ts @@ -14,11 +14,14 @@ export class MainScene extends Phaser.Scene { 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 isGameOver = false; + private isPaused = false; constructor() { super({ key: 'MainScene' }); @@ -47,16 +50,24 @@ export class MainScene extends Phaser.Scene { this ); + // Input setup this.cursors = this.input.keyboard!.createCursorKeys(); - // Add a listener for the spacebar 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() { - if (this.isGameOver) { + // Handle the pause key input first, so it works even when paused or game over. + if (Phaser.Input.Keyboard.JustDown(this.pauseKey)) { + this.togglePause(); + } + + // If the game is paused or over, do not run the main game logic. + if (this.isPaused || this.isGameOver) { return; } @@ -65,6 +76,23 @@ export class MainScene extends Phaser.Scene { this.checkScoring(); } + private togglePause() { + // Don't allow pausing if the game is already over + 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++; } @@ -113,7 +141,6 @@ export class MainScene extends Phaser.Scene { }) .setOrigin(0.5); - // Updated text to include the spacebar option this.add .text( this.cameras.main.centerX, @@ -126,7 +153,6 @@ export class MainScene extends Phaser.Scene { ) .setOrigin(0.5); - // Set up a single listener for both click and spacebar this.input.once('pointerdown', this.restartGame, this); this.spaceKey.once('down', this.restartGame, this); } @@ -135,6 +161,7 @@ export class MainScene extends Phaser.Scene { this.playerScore = 0; this.opponentScore = 0; this.isGameOver = false; + this.isPaused = false; // Ensure pause state is reset this.scene.restart(); } @@ -153,6 +180,17 @@ export class MainScene extends Phaser.Scene { .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 createTextures() { const graphics = this.make.graphics(); graphics.fillStyle(0xffffff); From ace5d0e87e248756afa9f9cf9a446c5afc6f10d7 Mon Sep 17 00:00:00 2001 From: Bana0516 Date: Tue, 5 Aug 2025 14:44:14 -0400 Subject: [PATCH 05/19] [Issue-16] Paddle Battle: Implemented new stats gamesPlayed, playerLosses, totalRallies, totalPlayTime --- src/games/paddle-battle/scenes/MainScene.ts | 33 ++++++++++++--------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/src/games/paddle-battle/scenes/MainScene.ts b/src/games/paddle-battle/scenes/MainScene.ts index 82e8790..3c3486f 100644 --- a/src/games/paddle-battle/scenes/MainScene.ts +++ b/src/games/paddle-battle/scenes/MainScene.ts @@ -20,6 +20,7 @@ export class MainScene extends Phaser.Scene { private playerScore = 0; private opponentScore = 0; private currentRally = 0; + private totalPlayTime = 0; // Tracks play time for the current session in milliseconds private isGameOver = false; private isPaused = false; @@ -28,6 +29,9 @@ export class MainScene extends Phaser.Scene { } create() { + // Increment gamesPlayed at the start of a new game session. + statsManager.incrementStat(GAME_ID, 'gamesPlayed'); + this.createTextures(); this.physics.world.setBoundsCollision(false, false, true, true); @@ -50,7 +54,6 @@ export class MainScene extends Phaser.Scene { this ); - // Input setup 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); @@ -60,30 +63,26 @@ export class MainScene extends Phaser.Scene { this.ball.reset(); } - update() { - // Handle the pause key input first, so it works even when paused or game over. + update(time: number, delta: number) { if (Phaser.Input.Keyboard.JustDown(this.pauseKey)) { this.togglePause(); } - // If the game is paused or over, do not run the main game logic. if (this.isPaused || this.isGameOver) { return; } + // Accumulate play time every frame the game is active. + this.totalPlayTime += delta; + this.player.handlePlayerMovement(this.cursors); this.opponent.handleAiMovement(this.ball); this.checkScoring(); } private togglePause() { - // Don't allow pausing if the game is already over - if (this.isGameOver) { - return; - } - + if (this.isGameOver) return; this.isPaused = !this.isPaused; - if (this.isPaused) { this.physics.pause(); this.pauseText.setVisible(true); @@ -95,6 +94,8 @@ export class MainScene extends Phaser.Scene { private handlePaddleBallCollision() { this.currentRally++; + // Correctly increment total paddle hits here. + statsManager.incrementStat(GAME_ID, 'totalRallies'); } private checkScoring() { @@ -126,6 +127,8 @@ export class MainScene extends Phaser.Scene { statsManager.incrementStat(GAME_ID, 'playerWins'); } else if (this.opponentScore >= WINNING_SCORE) { this.endGame('You Lose!'); + // Track player losses + statsManager.incrementStat(GAME_ID, 'playerLosses'); } } @@ -134,6 +137,9 @@ export class MainScene extends Phaser.Scene { this.physics.pause(); this.ball.setVisible(false); + // Save the total play time in seconds for this session + 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"', @@ -145,7 +151,7 @@ export class MainScene extends Phaser.Scene { .text( this.cameras.main.centerX, this.cameras.main.centerY + 80, - 'Click or Press Space to Restart', + 'Click Here or Press Space to Restart', { font: '24px "Press Start 2P"', color: '#ffffff', @@ -161,7 +167,8 @@ export class MainScene extends Phaser.Scene { this.playerScore = 0; this.opponentScore = 0; this.isGameOver = false; - this.isPaused = false; // Ensure pause state is reset + this.isPaused = false; + this.totalPlayTime = 0; // Reset play time for the new game this.scene.restart(); } @@ -196,9 +203,7 @@ export class MainScene extends Phaser.Scene { graphics.fillStyle(0xffffff); graphics.fillRect(0, 0, 20, 100); graphics.generateTexture('paddle', 20, 100); - graphics.clear(); - graphics.fillStyle(0xffffff); graphics.fillCircle(10, 10, 10); graphics.generateTexture('ball', 20, 20); From 78b530049fa3d359b50ceb2f248872d5c54b76ef Mon Sep 17 00:00:00 2001 From: Bana0516 Date: Tue, 5 Aug 2025 14:58:43 -0400 Subject: [PATCH 06/19] [Issue-10] Added border to games and made the controls and objectives dynamic --- src/app/games/[slug]/page.tsx | 9 +++++- src/components/games/GameClient.tsx | 50 ++++++++++++++--------------- src/data/arcadeGames.json | 20 +++++++++--- src/data/originalGames.json | 16 ++++++--- src/games/game-meta-loader.ts | 15 ++++++--- src/types/index.ts | 2 ++ 6 files changed, 72 insertions(+), 40 deletions(-) diff --git a/src/app/games/[slug]/page.tsx b/src/app/games/[slug]/page.tsx index 2068147..34eee4d 100644 --- a/src/app/games/[slug]/page.tsx +++ b/src/app/games/[slug]/page.tsx @@ -36,5 +36,12 @@ export default async function GamePage({ params }: GamePageProps) { const gameDetails = getGameDetailsBySlug(slug); // Render the client component, passing down the necessary props. - return ; + return ( + + ); } diff --git a/src/components/games/GameClient.tsx b/src/components/games/GameClient.tsx index a170271..5fabbcf 100644 --- a/src/components/games/GameClient.tsx +++ b/src/components/games/GameClient.tsx @@ -5,7 +5,6 @@ import dynamic from 'next/dynamic'; import ArcadeButton from '@/components/ArcadeButton'; import type { IGameConfig } from '@/games/game-loader'; -// Dynamically import the GameCanvas component with SSR turned off. const GameCanvas = dynamic(() => import('@/components/games/GameCanvas'), { ssr: false, loading: () =>
, @@ -14,14 +13,11 @@ const GameCanvas = dynamic(() => import('@/components/games/GameCanvas'), { interface GameClientProps { slug: string; title: string; + description: string; + controls: string[]; } -/** - * This component acts as the client-side wrapper for a game page. - * It handles the dynamic loading of the game canvas and its configuration. - */ -export default function GameClient({ slug, title }: GameClientProps) { - // State to hold the game config, which will be loaded only on the client. +export default function GameClient({ slug, title, description, controls }: GameClientProps) { const [gameConfig, setGameConfig] = useState(null); useEffect(() => { @@ -29,34 +25,36 @@ export default function GameClient({ slug, title }: GameClientProps) { const config = getGameConfigBySlug(slug); setGameConfig(config); }); - }, [slug]); // Re-run this effect if the game slug changes. + }, [slug]); return (

{title}

-
- - }> - {/* Only render the GameCanvas when the config has been loaded into state. */} - {gameConfig ? ( - - ) : ( -
- )} - +
+
+ + }> + {gameConfig ? ( + + ) : ( +
+ )} + +
-

Controls

-

- Use the UP and{' '} - DOWN arrow keys to move your paddle. -

-

First to 10 points wins!

+

Controls & Objective

+ {/* Render controls dynamically */} + {controls.map((control, index) => ( +

+ ))} + {/* Render description dynamically */} +

{description}

diff --git a/src/data/arcadeGames.json b/src/data/arcadeGames.json index f502491..af42bd5 100644 --- a/src/data/arcadeGames.json +++ b/src/data/arcadeGames.json @@ -5,7 +5,13 @@ "linkUrl": "/games/paddle-battle", "tags": ["arcade", "classic"], "releaseDate": "2025-08-05", - "popularity": 95 + "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." + ] }, { "title": "Galaxy Invaders", @@ -14,7 +20,9 @@ "isComingSoon": true, "tags": ["arcade", "shooter"], "releaseDate": "2024-10-25", - "popularity": 90 + "popularity": 90, + "description": "", + "controls": [] }, { "title": "Block Breaker", @@ -23,7 +31,9 @@ "isComingSoon": true, "tags": ["arcade", "puzzle"], "releaseDate": "2024-09-11", - "popularity": 88 + "popularity": 88, + "description": "", + "controls": [] }, { "title": "Maze Mania", @@ -32,6 +42,8 @@ "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..bdb41cd 100644 --- a/src/data/originalGames.json +++ b/src/data/originalGames.json @@ -6,7 +6,9 @@ "isNew": true, "tags": ["originals", "puzzle", "strategy"], "releaseDate": "2025-07-20", - "popularity": 85 + "popularity": 85, + "description": "", + "controls": [] }, { "title": "Cyber Runner", @@ -14,7 +16,9 @@ "linkUrl": "#", "tags": ["originals", "action", "platformer"], "releaseDate": "2025-06-15", - "popularity": 92 + "popularity": 92, + "description": "", + "controls": [] }, { "title": "Starship Defender", @@ -22,7 +26,9 @@ "linkUrl": "#", "tags": ["originals", "shooter", "sci-fi"], "releaseDate": "2025-05-01", - "popularity": 78 + "popularity": 78, + "description": "", + "controls": [] }, { "title": "Project Chimera", @@ -31,6 +37,8 @@ "isComingSoon": true, "tags": ["originals", "rpg", "adventure"], "releaseDate": "2025-12-31", - "popularity": 95 + "popularity": 95, + "description": "", + "controls": [] } ] diff --git a/src/games/game-meta-loader.ts b/src/games/game-meta-loader.ts index 4945fc7..9b86b4a 100644 --- a/src/games/game-meta-loader.ts +++ b/src/games/game-meta-loader.ts @@ -1,19 +1,24 @@ +import type { Game } from '@/types'; import originalGamesData from '@/data/originalGames.json'; import arcadeGamesData from '@/data/arcadeGames.json'; // --- SERVER-SIDE SAFE LOGIC --- -const allGames = [...originalGamesData, ...arcadeGamesData]; +const allGames: Game[] = [...originalGamesData, ...arcadeGamesData]; /** - * [SERVER-SAFE] Retrieves basic game details (like title) without importing Phaser. - * This is safe to use in Server Components for generating metadata. + * [SERVER-SAFE] Retrieves basic game details without importing Phaser. */ -export function getGameDetailsBySlug(slug: string): { title: string } { +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 }; + + return { + title: title, + description: game?.description || 'No description available.', + controls: game?.controls || ['No controls specified.'], + }; } /** diff --git a/src/types/index.ts b/src/types/index.ts index adcc7ab..8e7701b 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -26,6 +26,8 @@ export interface Game { tags: string[]; releaseDate: string; popularity: number; + description: string; // Game's objective or win condition + controls: string[]; // An array of strings describing the controls } /** From c18c0023b83cb30d0c1bbda33d69b2aecc665769 Mon Sep 17 00:00:00 2001 From: Bana0516 Date: Tue, 5 Aug 2025 15:15:26 -0400 Subject: [PATCH 07/19] [Issue-10] Created a player stats section --- src/app/games/[slug]/page.tsx | 1 + src/components/games/GameClient.tsx | 53 ++++++++++++++++++++++------- src/data/arcadeGames.json | 8 +++++ src/games/game-meta-loader.ts | 4 +-- src/types/index.ts | 9 +++++ src/utils/statsManager.ts | 31 +++-------------- 6 files changed, 64 insertions(+), 42 deletions(-) diff --git a/src/app/games/[slug]/page.tsx b/src/app/games/[slug]/page.tsx index 34eee4d..f08663b 100644 --- a/src/app/games/[slug]/page.tsx +++ b/src/app/games/[slug]/page.tsx @@ -42,6 +42,7 @@ export default async function GamePage({ params }: GamePageProps) { title={gameDetails.title} description={gameDetails.description} controls={gameDetails.controls} + stats={gameDetails?.stats ?? []} /> ); } diff --git a/src/components/games/GameClient.tsx b/src/components/games/GameClient.tsx index 5fabbcf..0d7c2a5 100644 --- a/src/components/games/GameClient.tsx +++ b/src/components/games/GameClient.tsx @@ -4,6 +4,8 @@ import { useState, useEffect, Suspense } 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'; const GameCanvas = dynamic(() => import('@/components/games/GameCanvas'), { ssr: false, @@ -15,16 +17,23 @@ interface GameClientProps { title: string; description: string; controls: string[]; + stats: GameStat[]; // The array defining which stats to show } -export default function GameClient({ slug, title, description, controls }: GameClientProps) { +export default function GameClient({ slug, title, description, controls, stats }: GameClientProps) { const [gameConfig, setGameConfig] = useState(null); + const [playerStats, setPlayerStats] = useState>({}); useEffect(() => { + // Load the game config import('@/games/game-loader').then(({ getGameConfigBySlug }) => { const config = getGameConfigBySlug(slug); setGameConfig(config); }); + + // Load all stats for this game from local storage + const allStats = statsManager.getAllStats(slug); + setPlayerStats(allStats); }, [slug]); return ( @@ -35,26 +44,44 @@ export default function GameClient({ slug, title, description, controls }: GameC
- }> + fallback={
}> {gameConfig ? ( ) : ( -
+
)}
-
-

Controls & Objective

- {/* Render controls dynamically */} - {controls.map((control, index) => ( -

- ))} - {/* Render description dynamically */} -

{description}

+
+ {/* Controls & Objective Section */} +
+

+ Controls & Objective +

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

+ ))} +

{description}

+
+ + {/* Player Stats Section */} + {stats && stats.length > 0 && ( +
+

Player Stats

+
+ {stats.map((stat) => ( +
+
{stat.label}
+
+ {playerStats[stat.key] || 0} +
+
+ ))} +
+
+ )}
diff --git a/src/data/arcadeGames.json b/src/data/arcadeGames.json index af42bd5..ebebef7 100644 --- a/src/data/arcadeGames.json +++ b/src/data/arcadeGames.json @@ -11,6 +11,14 @@ "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)" } ] }, { diff --git a/src/games/game-meta-loader.ts b/src/games/game-meta-loader.ts index 9b86b4a..e1c65ef 100644 --- a/src/games/game-meta-loader.ts +++ b/src/games/game-meta-loader.ts @@ -1,8 +1,7 @@ -import type { Game } from '@/types'; +import type { Game, GameStat } from '@/types'; import originalGamesData from '@/data/originalGames.json'; import arcadeGamesData from '@/data/arcadeGames.json'; -// --- SERVER-SIDE SAFE LOGIC --- const allGames: Game[] = [...originalGamesData, ...arcadeGamesData]; /** @@ -18,6 +17,7 @@ export function getGameDetailsBySlug(slug: string) { title: title, description: game?.description || 'No description available.', controls: game?.controls || ['No controls specified.'], + stats: game?.stats || [], // Return the stats array or an empty one }; } diff --git a/src/types/index.ts b/src/types/index.ts index 8e7701b..9644657 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. */ @@ -28,6 +36,7 @@ export interface Game { popularity: number; description: string; // Game's objective or win condition controls: string[]; // An array of strings describing the controls + stats?: GameStat[]; // An optional array of stats to display } /** diff --git a/src/utils/statsManager.ts b/src/utils/statsManager.ts index 3bc6e9b..73323f6 100644 --- a/src/utils/statsManager.ts +++ b/src/utils/statsManager.ts @@ -3,18 +3,15 @@ * Each game's stats are stored under a unique key. */ -// A generic type for any game's stats object. type GameStats = Record; // --- Private Helper Functions --- -// Generates a unique local storage key for a given game ID. function getStatsKey(gameId: string): string { return `oneBuffaloGames_${gameId}_stats`; } -// Gets the stats for a specific game, or returns an empty object. -function getStats(gameId: string): GameStats { +export function getAllStats(gameId: string): GameStats { if (typeof window === 'undefined') { return {}; } @@ -23,7 +20,6 @@ function getStats(gameId: string): GameStats { return stats ? JSON.parse(stats) : {}; } -// Saves the stats for a specific game. function saveStats(gameId: string, stats: GameStats) { if (typeof window === 'undefined') return; const key = getStatsKey(gameId); @@ -32,40 +28,21 @@ function saveStats(gameId: string, stats: GameStats) { // --- Public API for Stats Management --- -/** - * Increments a specific stat for a given game. - * @param gameId The unique identifier for the game (e.g., 'paddle-battle'). - * @param statName The name of the stat to increment (e.g., 'playerWins'). - * @param amount The amount to increment by (defaults to 1). - */ export function incrementStat(gameId: string, statName: string, amount = 1) { - const stats = getStats(gameId); + const stats = getAllStats(gameId); stats[statName] = (stats[statName] || 0) + amount; saveStats(gameId, stats); } -/** - * Updates a stat only if the new value is higher than the existing one. - * Useful for tracking high scores or longest rallies. - * @param gameId The unique identifier for the game. - * @param statName The name of the stat to update (e.g., 'longestRally'). - * @param newValue The new potential highest value. - */ export function updateHighestStat(gameId: string, statName: string, newValue: number) { - const stats = getStats(gameId); + const stats = getAllStats(gameId); if (newValue > (stats[statName] || 0)) { stats[statName] = newValue; saveStats(gameId, stats); } } -/** - * Retrieves a specific stat for a given game. - * @param gameId The unique identifier for the game. - * @param statName The name of the stat to retrieve. - * @returns The value of the stat, or 0 if it doesn't exist. - */ export function getStat(gameId: string, statName: string): number { - const stats = getStats(gameId); + const stats = getAllStats(gameId); return stats[statName] || 0; } From 33c990b67f830c927730be88a5ee242550ab1cc8 Mon Sep 17 00:00:00 2001 From: Bana0516 Date: Tue, 5 Aug 2025 15:22:47 -0400 Subject: [PATCH 08/19] [Issue-10] Stats section now updates when stats do --- src/components/games/GameClient.tsx | 27 +++++++++++++++++++-------- src/utils/statsManager.ts | 2 ++ 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/src/components/games/GameClient.tsx b/src/components/games/GameClient.tsx index 0d7c2a5..2705f8c 100644 --- a/src/components/games/GameClient.tsx +++ b/src/components/games/GameClient.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect, Suspense } from 'react'; +import { useState, useEffect, Suspense, useCallback } from 'react'; import dynamic from 'next/dynamic'; import ArcadeButton from '@/components/ArcadeButton'; import type { IGameConfig } from '@/games/game-loader'; @@ -17,13 +17,19 @@ interface GameClientProps { title: string; description: string; controls: string[]; - stats: GameStat[]; // The array defining which stats to show + stats: GameStat[]; } export default function GameClient({ slug, title, description, controls, stats }: GameClientProps) { const [gameConfig, setGameConfig] = useState(null); const [playerStats, setPlayerStats] = useState>({}); + // useCallback ensures the function identity is stable across re-renders + const refreshStats = useCallback(() => { + const allStats = statsManager.getAllStats(slug); + setPlayerStats(allStats); + }, [slug]); + useEffect(() => { // Load the game config import('@/games/game-loader').then(({ getGameConfigBySlug }) => { @@ -31,10 +37,17 @@ export default function GameClient({ slug, title, description, controls, stats } setGameConfig(config); }); - // Load all stats for this game from local storage - const allStats = statsManager.getAllStats(slug); - setPlayerStats(allStats); - }, [slug]); + // Initial load of stats + refreshStats(); + + // Add an event listener to update stats in real-time + window.addEventListener('statsUpdated', refreshStats); + + // Cleanup the event listener when the component unmounts + return () => { + window.removeEventListener('statsUpdated', refreshStats); + }; + }, [slug, refreshStats]); return (
@@ -55,7 +68,6 @@ export default function GameClient({ slug, title, description, controls, stats }
- {/* Controls & Objective Section */}

Controls & Objective @@ -66,7 +78,6 @@ export default function GameClient({ slug, title, description, controls, stats }

{description}

- {/* Player Stats Section */} {stats && stats.length > 0 && (

Player Stats

diff --git a/src/utils/statsManager.ts b/src/utils/statsManager.ts index 73323f6..54e1669 100644 --- a/src/utils/statsManager.ts +++ b/src/utils/statsManager.ts @@ -24,6 +24,8 @@ 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 --- From cb629dd6fd826a592d4155d0c93e0a292a32e547 Mon Sep 17 00:00:00 2001 From: Bana0516 Date: Tue, 5 Aug 2025 15:47:16 -0400 Subject: [PATCH 09/19] [Issue-16] Paddle Battle: Added a start screen --- src/games/paddle-battle/config.ts | 3 +- src/games/paddle-battle/scenes/StartScene.ts | 37 ++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 src/games/paddle-battle/scenes/StartScene.ts diff --git a/src/games/paddle-battle/config.ts b/src/games/paddle-battle/config.ts index e6e5831..95d6527 100644 --- a/src/games/paddle-battle/config.ts +++ b/src/games/paddle-battle/config.ts @@ -1,4 +1,5 @@ import * as Phaser from 'phaser'; +import { StartScene } from './scenes/StartScene'; import { MainScene } from './scenes/MainScene'; // This is the configuration for our Paddle Battle game @@ -13,5 +14,5 @@ export const paddleBattleConfig: Phaser.Types.Core.GameConfig = { gravity: { x: 0, y: 0 }, }, }, - scene: [MainScene], + scene: [StartScene, MainScene], }; diff --git a/src/games/paddle-battle/scenes/StartScene.ts b/src/games/paddle-battle/scenes/StartScene.ts new file mode 100644 index 0000000..9dbe075 --- /dev/null +++ b/src/games/paddle-battle/scenes/StartScene.ts @@ -0,0 +1,37 @@ +import * as Phaser from 'phaser'; + +export class StartScene extends Phaser.Scene { + constructor() { + super({ key: 'StartScene' }); + } + + create() { + this.add + .text(this.cameras.main.centerX, this.cameras.main.centerY - 100, 'Paddle Battle', { + font: '80px "Press Start 2P"', + color: '#e7042d', + }) + .setOrigin(0.5); + + this.add + .text( + this.cameras.main.centerX, + this.cameras.main.centerY + 50, + 'Click or Press Space to Start', + { + font: '24px "Press Start 2P"', + color: '#ffffff', + } + ) + .setOrigin(0.5); + + // Listen for a click to start the main game scene + this.input.once('pointerdown', () => this.scene.start('MainScene')); + + // Updated keyboard listener to prevent default browser action + this.input.keyboard!.once('keydown-SPACE', (event: KeyboardEvent) => { + event.preventDefault(); // This stops the browser from scrolling + this.scene.start('MainScene'); + }); + } +} From 0a466bacacb561cd1797dc5fca42bb462b1fe5a8 Mon Sep 17 00:00:00 2001 From: Bana0516 Date: Tue, 5 Aug 2025 16:13:27 -0400 Subject: [PATCH 10/19] [Issue-16] Paddle Battle: Converted the game to a buffalo theme --- src/games/paddle-battle/prefabs/Ball.ts | 3 +- src/games/paddle-battle/prefabs/Paddle.ts | 7 +- src/games/paddle-battle/scenes/MainScene.ts | 89 +++++++++++++-------- 3 files changed, 60 insertions(+), 39 deletions(-) diff --git a/src/games/paddle-battle/prefabs/Ball.ts b/src/games/paddle-battle/prefabs/Ball.ts index 6928106..bcd6e02 100644 --- a/src/games/paddle-battle/prefabs/Ball.ts +++ b/src/games/paddle-battle/prefabs/Ball.ts @@ -2,8 +2,7 @@ import * as Phaser from 'phaser'; export class Ball extends Phaser.Physics.Arcade.Sprite { constructor(scene: Phaser.Scene, x: number, y: number) { - // The 'ball' texture is now expected to already exist. - super(scene, x, y, 'ball'); + super(scene, x, y, 'snowflake_ball'); scene.add.existing(this); scene.physics.add.existing(this); diff --git a/src/games/paddle-battle/prefabs/Paddle.ts b/src/games/paddle-battle/prefabs/Paddle.ts index 71c0a4e..bbb9a8b 100644 --- a/src/games/paddle-battle/prefabs/Paddle.ts +++ b/src/games/paddle-battle/prefabs/Paddle.ts @@ -1,9 +1,10 @@ import * as Phaser from 'phaser'; export class Paddle extends Phaser.Physics.Arcade.Sprite { - constructor(scene: Phaser.Scene, x: number, y: number) { - // The 'paddle' texture is now expected to already exist. - super(scene, x, y, 'paddle'); + // The constructor now accepts a textureKey + constructor(scene: Phaser.Scene, x: number, y: number, textureKey: string) { + // Use the provided texture key + super(scene, x, y, textureKey); scene.add.existing(this); scene.physics.add.existing(this); diff --git a/src/games/paddle-battle/scenes/MainScene.ts b/src/games/paddle-battle/scenes/MainScene.ts index 3c3486f..2a72eae 100644 --- a/src/games/paddle-battle/scenes/MainScene.ts +++ b/src/games/paddle-battle/scenes/MainScene.ts @@ -20,7 +20,7 @@ export class MainScene extends Phaser.Scene { private playerScore = 0; private opponentScore = 0; private currentRally = 0; - private totalPlayTime = 0; // Tracks play time for the current session in milliseconds + private totalPlayTime = 0; private isGameOver = false; private isPaused = false; @@ -29,14 +29,22 @@ export class MainScene extends Phaser.Scene { } create() { - // Increment gamesPlayed at the start of a new game session. statsManager.incrementStat(GAME_ID, 'gamesPlayed'); + this.createThemedTextures(); // Create our new Buffalo-themed assets + this.drawCenterLine(); // Draw the center line - this.createTextures(); this.physics.world.setBoundsCollision(false, false, true, true); - this.player = new Paddle(this, 50, this.cameras.main.centerY); - this.opponent = new Paddle(this, this.cameras.main.width - 50, this.cameras.main.centerY); + // Create paddles using their new, colored texture keys + this.player = new Paddle(this, 50, this.cameras.main.centerY, 'paddle_blue'); + this.opponent = new Paddle( + this, + this.cameras.main.width - 50, + this.cameras.main.centerY, + 'paddle_red' + ); + + // The ball prefab will now automatically use the 'snowflake_ball' texture this.ball = new Ball(this, this.cameras.main.centerX, this.cameras.main.centerY); this.physics.add.collider( @@ -67,14 +75,8 @@ export class MainScene extends Phaser.Scene { if (Phaser.Input.Keyboard.JustDown(this.pauseKey)) { this.togglePause(); } - - if (this.isPaused || this.isGameOver) { - return; - } - - // Accumulate play time every frame the game is active. + if (this.isPaused || this.isGameOver) return; this.totalPlayTime += delta; - this.player.handlePlayerMovement(this.cursors); this.opponent.handleAiMovement(this.ball); this.checkScoring(); @@ -94,7 +96,6 @@ export class MainScene extends Phaser.Scene { private handlePaddleBallCollision() { this.currentRally++; - // Correctly increment total paddle hits here. statsManager.incrementStat(GAME_ID, 'totalRallies'); } @@ -116,9 +117,7 @@ export class MainScene extends Phaser.Scene { private endRally() { statsManager.updateHighestStat(GAME_ID, 'longestRally', this.currentRally); this.currentRally = 0; - if (!this.isGameOver) { - this.ball.reset(); - } + if (!this.isGameOver) this.ball.reset(); } private checkWinCondition() { @@ -127,7 +126,6 @@ export class MainScene extends Phaser.Scene { statsManager.incrementStat(GAME_ID, 'playerWins'); } else if (this.opponentScore >= WINNING_SCORE) { this.endGame('You Lose!'); - // Track player losses statsManager.incrementStat(GAME_ID, 'playerLosses'); } } @@ -136,29 +134,21 @@ export class MainScene extends Phaser.Scene { this.isGameOver = true; this.physics.pause(); this.ball.setVisible(false); - - // Save the total play time in seconds for this session 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 Here or Press Space to Restart', - { - font: '24px "Press Start 2P"', - color: '#ffffff', - } + '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); } @@ -168,7 +158,7 @@ export class MainScene extends Phaser.Scene { this.opponentScore = 0; this.isGameOver = false; this.isPaused = false; - this.totalPlayTime = 0; // Reset play time for the new game + this.totalPlayTime = 0; this.scene.restart(); } @@ -198,15 +188,46 @@ export class MainScene extends Phaser.Scene { .setDepth(1); } - private createTextures() { + private drawCenterLine() { const graphics = this.make.graphics(); - graphics.fillStyle(0xffffff); + 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); // obl-blue graphics.fillRect(0, 0, 20, 100); - graphics.generateTexture('paddle', 20, 100); + graphics.generateTexture('paddle_blue', 20, 100); graphics.clear(); - graphics.fillStyle(0xffffff); - graphics.fillCircle(10, 10, 10); - graphics.generateTexture('ball', 20, 20); + + graphics.fillStyle(0xe7042d); // obl-red + graphics.fillRect(0, 0, 20, 100); + graphics.generateTexture('paddle_red', 20, 100); + graphics.clear(); + + // Snowflake Ball + graphics.fillStyle(0xffffff); // White + 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(); } } From edd6b7e6bd9ab98395d93207b77b2bd830bc1c07 Mon Sep 17 00:00:00 2001 From: Bana0516 Date: Tue, 5 Aug 2025 16:29:42 -0400 Subject: [PATCH 11/19] [Issue-10] updated slugs for original and arcade games --- src/app/games/{[slug] => [...slug]}/page.tsx | 18 ++++++++++-------- src/data/arcadeGames.json | 8 ++++---- src/games/game-meta-loader.ts | 18 ++++++++++-------- 3 files changed, 24 insertions(+), 20 deletions(-) rename src/app/games/{[slug] => [...slug]}/page.tsx (68%) diff --git a/src/app/games/[slug]/page.tsx b/src/app/games/[...slug]/page.tsx similarity index 68% rename from src/app/games/[slug]/page.tsx rename to src/app/games/[...slug]/page.tsx index f08663b..ac80554 100644 --- a/src/app/games/[slug]/page.tsx +++ b/src/app/games/[...slug]/page.tsx @@ -1,22 +1,24 @@ 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'; // Import the client component +import GameClient from '@/components/games/GameClient'; interface GamePageProps { - params: Promise<{ slug: string }>; + params: Promise<{ slug: string[] }>; } // This function runs on the server and is safe. export async function generateMetadata({ params }: GamePageProps): Promise { const { slug } = await params; + const gameSlug = slug[slug.length - 1]; // The actual game slug is the last part + const fullPath = slug.join('/'); // Recreate the full path for the URL - const gameDetails = getGameDetailsBySlug(slug); + const gameDetails = getGameDetailsBySlug(gameSlug); return generatePageMetadata({ title: gameDetails.title, description: `Play ${gameDetails.title} on One Buffalo Games. A classic arcade-style game for your browser.`, - urlPath: `/games/${slug}`, + urlPath: `/games/${fullPath}`, }); } @@ -27,22 +29,22 @@ export async function generateStaticParams() { /** * The dynamic page component, which remains a Server Component. - * It fetches server-side data and passes it to the client boundary. */ export default async function GamePage({ params }: GamePageProps) { const { slug } = await params; + const gameSlug = slug[slug.length - 1]; // Get the actual game slug // Fetch the game details on the server. - const gameDetails = getGameDetailsBySlug(slug); + const gameDetails = getGameDetailsBySlug(gameSlug); // Render the client component, passing down the necessary props. return ( ); } diff --git a/src/data/arcadeGames.json b/src/data/arcadeGames.json index ebebef7..b388fd0 100644 --- a/src/data/arcadeGames.json +++ b/src/data/arcadeGames.json @@ -2,7 +2,7 @@ { "title": "Paddle Battle", "imageUrl": "https://placehold.co/300x200/e7042d/003091?text=Paddle+Battle", - "linkUrl": "/games/paddle-battle", + "linkUrl": "/games/arcade/paddle-battle", "tags": ["arcade", "classic"], "releaseDate": "2025-08-05", "popularity": 95, @@ -24,7 +24,7 @@ { "title": "Galaxy Invaders", "imageUrl": "https://placehold.co/300x200/e7042d/003091?text=Arcade+Classic", - "linkUrl": "/games/galaxy-invaders", + "linkUrl": "/games/arcade/galaxy-invaders", "isComingSoon": true, "tags": ["arcade", "shooter"], "releaseDate": "2024-10-25", @@ -35,7 +35,7 @@ { "title": "Block Breaker", "imageUrl": "https://placehold.co/300x200/e7042d/003091?text=Arcade+Classic", - "linkUrl": "/games/block-breaker", + "linkUrl": "#", "isComingSoon": true, "tags": ["arcade", "puzzle"], "releaseDate": "2024-09-11", @@ -46,7 +46,7 @@ { "title": "Maze Mania", "imageUrl": "https://placehold.co/300x200/e7042d/003091?text=Arcade+Classic", - "linkUrl": "/games/maze-mania", + "linkUrl": "#", "isComingSoon": true, "tags": ["arcade", "puzzle", "strategy"], "releaseDate": "2024-08-19", diff --git a/src/games/game-meta-loader.ts b/src/games/game-meta-loader.ts index e1c65ef..92ebe74 100644 --- a/src/games/game-meta-loader.ts +++ b/src/games/game-meta-loader.ts @@ -1,11 +1,11 @@ -import type { Game, GameStat } from '@/types'; +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 without importing Phaser. + * [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)); @@ -17,17 +17,19 @@ export function getGameDetailsBySlug(slug: string) { title: title, description: game?.description || 'No description available.', controls: game?.controls || ['No controls specified.'], - stats: game?.stats || [], // Return the stats array or an empty one + stats: game?.stats || [], }; } /** - * [SERVER-SAFE] Gets all game slugs for static generation. + * [SERVER-SAFE] Gets all game slugs for static generation, formatted for catch-all routes. */ export function getAllGameSlugs() { return allGames - .map((game) => ({ - slug: game.linkUrl.split('/').pop() || '', - })) - .filter((item) => item.slug && !item.slug.startsWith('#')); + .map((game) => { + // Remove the leading '/games/' and split the rest into an array + const slugParts = game.linkUrl.replace('/games/', '').split('/'); + return { slug: slugParts }; + }) + .filter((item) => item.slug.length > 0 && !item.slug[0].startsWith('#')); } From 1ff914a63b9d11567e5878bb6185d654b86eef63 Mon Sep 17 00:00:00 2001 From: Bana0516 Date: Tue, 5 Aug 2025 16:46:53 -0400 Subject: [PATCH 12/19] [Issue-10] Each game now has a custom meta description for better seo --- src/app/games/[...slug]/page.tsx | 12 +++++++----- src/data/arcadeGames.json | 4 ++++ src/data/originalGames.json | 4 ++++ src/games/game-meta-loader.ts | 3 +++ src/types/index.ts | 7 ++++--- 5 files changed, 22 insertions(+), 8 deletions(-) diff --git a/src/app/games/[...slug]/page.tsx b/src/app/games/[...slug]/page.tsx index ac80554..dae2f5b 100644 --- a/src/app/games/[...slug]/page.tsx +++ b/src/app/games/[...slug]/page.tsx @@ -1,7 +1,7 @@ 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'; +import GameClient from '@/components/games/GameClient'; // Import the client component interface GamePageProps { params: Promise<{ slug: string[] }>; @@ -10,14 +10,15 @@ interface GamePageProps { // This function runs on the server and is safe. export async function generateMetadata({ params }: GamePageProps): Promise { const { slug } = await params; - const gameSlug = slug[slug.length - 1]; // The actual game slug is the last part - const fullPath = slug.join('/'); // Recreate the full path for the URL + const gameSlug = slug[slug.length - 1]; + const fullPath = slug.join('/'); const gameDetails = getGameDetailsBySlug(gameSlug); return generatePageMetadata({ title: gameDetails.title, - description: `Play ${gameDetails.title} on One Buffalo Games. A classic arcade-style game for your browser.`, + // Use the new, specific metaDescription from our loader + description: gameDetails.metaDescription, urlPath: `/games/${fullPath}`, }); } @@ -29,10 +30,11 @@ export async function generateStaticParams() { /** * The dynamic page component, which remains a Server Component. + * It fetches server-side data and passes it to the client boundary. */ export default async function GamePage({ params }: GamePageProps) { const { slug } = await params; - const gameSlug = slug[slug.length - 1]; // Get the actual game slug + const gameSlug = slug[slug.length - 1]; // Fetch the game details on the server. const gameDetails = getGameDetailsBySlug(gameSlug); diff --git a/src/data/arcadeGames.json b/src/data/arcadeGames.json index b388fd0..bbcd62e 100644 --- a/src/data/arcadeGames.json +++ b/src/data/arcadeGames.json @@ -3,6 +3,7 @@ "title": "Paddle Battle", "imageUrl": "https://placehold.co/300x200/e7042d/003091?text=Paddle+Battle", "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.", "tags": ["arcade", "classic"], "releaseDate": "2025-08-05", "popularity": 95, @@ -25,6 +26,7 @@ "title": "Galaxy Invaders", "imageUrl": "https://placehold.co/300x200/e7042d/003091?text=Arcade+Classic", "linkUrl": "/games/arcade/galaxy-invaders", + "metaDescription": "", "isComingSoon": true, "tags": ["arcade", "shooter"], "releaseDate": "2024-10-25", @@ -36,6 +38,7 @@ "title": "Block Breaker", "imageUrl": "https://placehold.co/300x200/e7042d/003091?text=Arcade+Classic", "linkUrl": "#", + "metaDescription": "", "isComingSoon": true, "tags": ["arcade", "puzzle"], "releaseDate": "2024-09-11", @@ -47,6 +50,7 @@ "title": "Maze Mania", "imageUrl": "https://placehold.co/300x200/e7042d/003091?text=Arcade+Classic", "linkUrl": "#", + "metaDescription": "", "isComingSoon": true, "tags": ["arcade", "puzzle", "strategy"], "releaseDate": "2024-08-19", diff --git a/src/data/originalGames.json b/src/data/originalGames.json index bdb41cd..3f1da46 100644 --- a/src/data/originalGames.json +++ b/src/data/originalGames.json @@ -3,6 +3,7 @@ "title": "Pixel Puzzler", "imageUrl": "https://placehold.co/300x200/003091/e7042d?text=Original+Game", "linkUrl": "#", + "metaDescription": "", "isNew": true, "tags": ["originals", "puzzle", "strategy"], "releaseDate": "2025-07-20", @@ -14,6 +15,7 @@ "title": "Cyber Runner", "imageUrl": "https://placehold.co/300x200/003091/e7042d?text=Original+Game", "linkUrl": "#", + "metaDescription": "", "tags": ["originals", "action", "platformer"], "releaseDate": "2025-06-15", "popularity": 92, @@ -24,6 +26,7 @@ "title": "Starship Defender", "imageUrl": "https://placehold.co/300x200/003091/e7042d?text=Original+Game", "linkUrl": "#", + "metaDescription": "", "tags": ["originals", "shooter", "sci-fi"], "releaseDate": "2025-05-01", "popularity": 78, @@ -34,6 +37,7 @@ "title": "Project Chimera", "imageUrl": "https://placehold.co/300x200/003091/e7042d?text=Coming+Soon", "linkUrl": "#", + "metaDescription": "", "isComingSoon": true, "tags": ["originals", "rpg", "adventure"], "releaseDate": "2025-12-31", diff --git a/src/games/game-meta-loader.ts b/src/games/game-meta-loader.ts index 92ebe74..035ee31 100644 --- a/src/games/game-meta-loader.ts +++ b/src/games/game-meta-loader.ts @@ -15,6 +15,9 @@ export function getGameDetailsBySlug(slug: string) { 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 || [], diff --git a/src/types/index.ts b/src/types/index.ts index 9644657..0128a3f 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -29,14 +29,15 @@ export interface Game { title: string; imageUrl: string; linkUrl: string; + metaDescription: string; isNew?: boolean; isComingSoon?: boolean; tags: string[]; releaseDate: string; popularity: number; - description: string; // Game's objective or win condition - controls: string[]; // An array of strings describing the controls - stats?: GameStat[]; // An optional array of stats to display + description: string; + controls: string[]; + stats?: GameStat[]; } /** From d935d59a53aa7311b4bbb3d85985d50238392301 Mon Sep 17 00:00:00 2001 From: Bana0516 Date: Tue, 5 Aug 2025 17:10:17 -0400 Subject: [PATCH 13/19] [Issue-16] Paddle Battle: Added a difficulty setting --- src/games/paddle-battle/config.ts | 3 +- src/games/paddle-battle/prefabs/Paddle.ts | 18 ++-- src/games/paddle-battle/scenes/MainScene.ts | 37 +++++--- .../paddle-battle/scenes/SettingsScene.ts | 92 +++++++++++++++++++ src/games/paddle-battle/scenes/StartScene.ts | 52 +++++++++-- src/utils/settingsManager.ts | 52 +++++++++++ 6 files changed, 224 insertions(+), 30 deletions(-) create mode 100644 src/games/paddle-battle/scenes/SettingsScene.ts create mode 100644 src/utils/settingsManager.ts diff --git a/src/games/paddle-battle/config.ts b/src/games/paddle-battle/config.ts index 95d6527..85d4bd1 100644 --- a/src/games/paddle-battle/config.ts +++ b/src/games/paddle-battle/config.ts @@ -1,5 +1,6 @@ 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 @@ -14,5 +15,5 @@ export const paddleBattleConfig: Phaser.Types.Core.GameConfig = { gravity: { x: 0, y: 0 }, }, }, - scene: [StartScene, MainScene], + scene: [StartScene, SettingsScene, MainScene], }; diff --git a/src/games/paddle-battle/prefabs/Paddle.ts b/src/games/paddle-battle/prefabs/Paddle.ts index bbb9a8b..4d8e5dd 100644 --- a/src/games/paddle-battle/prefabs/Paddle.ts +++ b/src/games/paddle-battle/prefabs/Paddle.ts @@ -1,10 +1,11 @@ import * as Phaser from 'phaser'; export class Paddle extends Phaser.Physics.Arcade.Sprite { - // The constructor now accepts a textureKey - constructor(scene: Phaser.Scene, x: number, y: number, textureKey: string) { - // Use the provided texture key + 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); @@ -13,23 +14,22 @@ export class Paddle extends Phaser.Physics.Arcade.Sprite { this.setCollideWorldBounds(true); } - // Handle player-specific movement handlePlayerMovement(cursors: Phaser.Types.Input.Keyboard.CursorKeys) { if (cursors.up.isDown) { - this.setVelocityY(-300); + this.setVelocityY(-350); // Player speed is constant } else if (cursors.down.isDown) { - this.setVelocityY(300); + this.setVelocityY(350); } else { this.setVelocityY(0); } } - // Handle AI-specific movement handleAiMovement(ball: Phaser.Physics.Arcade.Sprite) { + // Use the speed set by the difficulty if (this.y < ball.y) { - this.setVelocityY(200); + this.setVelocityY(this.aiSpeed); } else if (this.y > ball.y) { - this.setVelocityY(-200); + 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 index 2a72eae..6c1f4d1 100644 --- a/src/games/paddle-battle/scenes/MainScene.ts +++ b/src/games/paddle-battle/scenes/MainScene.ts @@ -2,10 +2,17 @@ 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; @@ -23,28 +30,35 @@ export class MainScene extends Phaser.Scene { 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(); // Create our new Buffalo-themed assets - this.drawCenterLine(); // Draw the center line + this.createThemedTextures(); + this.drawCenterLine(); this.physics.world.setBoundsCollision(false, false, true, true); - // Create paddles using their new, colored texture keys 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' + 'paddle_red', + opponentSpeed ); - // The ball prefab will now automatically use the 'snowflake_ball' texture this.ball = new Ball(this, this.cameras.main.centerX, this.cameras.main.centerY); this.physics.add.collider( @@ -72,9 +86,7 @@ export class MainScene extends Phaser.Scene { } update(time: number, delta: number) { - if (Phaser.Input.Keyboard.JustDown(this.pauseKey)) { - this.togglePause(); - } + if (Phaser.Input.Keyboard.JustDown(this.pauseKey)) this.togglePause(); if (this.isPaused || this.isGameOver) return; this.totalPlayTime += delta; this.player.handlePlayerMovement(this.cursors); @@ -159,7 +171,7 @@ export class MainScene extends Phaser.Scene { this.isGameOver = false; this.isPaused = false; this.totalPlayTime = 0; - this.scene.restart(); + this.scene.restart({ difficulty: this.difficulty }); } private createScoreboard() { @@ -202,18 +214,17 @@ export class MainScene extends Phaser.Scene { private createThemedTextures() { const graphics = this.make.graphics(); - graphics.fillStyle(0x003091); // obl-blue + graphics.fillStyle(0x003091); graphics.fillRect(0, 0, 20, 100); graphics.generateTexture('paddle_blue', 20, 100); graphics.clear(); - graphics.fillStyle(0xe7042d); // obl-red + graphics.fillStyle(0xe7042d); graphics.fillRect(0, 0, 20, 100); graphics.generateTexture('paddle_red', 20, 100); graphics.clear(); - // Snowflake Ball - graphics.fillStyle(0xffffff); // White + graphics.fillStyle(0xffffff); const snowflakePixels = [ { x: 3, y: 0, w: 1, h: 7 }, { x: 0, y: 3, w: 7, h: 1 }, diff --git a/src/games/paddle-battle/scenes/SettingsScene.ts b/src/games/paddle-battle/scenes/SettingsScene.ts new file mode 100644 index 0000000..d9b8068 --- /dev/null +++ b/src/games/paddle-battle/scenes/SettingsScene.ts @@ -0,0 +1,92 @@ +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 cellWidth = 220; + const gridWidth = difficulties.length * cellWidth; + + this.difficultyLabels = difficulties.map((d) => { + const text = this.add + .text(0, 0, 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; + }); + + // Corrected GridAlign to properly center the elements + Phaser.Actions.GridAlign(this.difficultyLabels, { + width: 3, + height: 1, + cellWidth: cellWidth, + cellHeight: 50, + // The starting x position is the center of the screen minus half the total grid width, + // plus half a cell's width to center the items within their cells. + x: this.cameras.main.centerX - gridWidth / 2 + cellWidth / 2, + y: 320, + }); + + 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 index 9dbe075..985bd0b 100644 --- a/src/games/paddle-battle/scenes/StartScene.ts +++ b/src/games/paddle-battle/scenes/StartScene.ts @@ -1,19 +1,39 @@ 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'); + this.add - .text(this.cameras.main.centerX, this.cameras.main.centerY - 100, 'Paddle Battle', { + .text(this.cameras.main.centerX, this.cameras.main.centerY - 150, 'Paddle Battle', { font: '80px "Press Start 2P"', color: '#e7042d', }) .setOrigin(0.5); this.add + .text( + this.cameras.main.centerX, + this.cameras.main.centerY - 20, + `Difficulty: ${this.currentDifficulty.toUpperCase()}`, + { + font: '24px "Press Start 2P"', + color: '#ffffff', + } + ) + .setOrigin(0.5); + + const startButton = this.add .text( this.cameras.main.centerX, this.cameras.main.centerY + 50, @@ -23,15 +43,33 @@ export class StartScene extends Phaser.Scene { color: '#ffffff', } ) - .setOrigin(0.5); + .setOrigin(0.5) + .setInteractive({ useHandCursor: true }); - // Listen for a click to start the main game scene - this.input.once('pointerdown', () => this.scene.start('MainScene')); + const settingsButton = this.add + .text(this.cameras.main.centerX, this.cameras.main.centerY + 120, 'Settings', { + font: '32px "Press Start 2P"', + color: '#ffffff', + }) + .setOrigin(0.5) + .setInteractive({ useHandCursor: true }); + + // Event listeners are now specific to the buttons + 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()); - // Updated keyboard listener to prevent default browser action this.input.keyboard!.once('keydown-SPACE', (event: KeyboardEvent) => { - event.preventDefault(); // This stops the browser from scrolling - this.scene.start('MainScene'); + event.preventDefault(); + this.startGame(); }); } + + private startGame() { + this.scene.start('MainScene', { difficulty: this.currentDifficulty }); + } } diff --git a/src/utils/settingsManager.ts b/src/utils/settingsManager.ts new file mode 100644 index 0000000..165e1f8 --- /dev/null +++ b/src/utils/settingsManager.ts @@ -0,0 +1,52 @@ +/** + * A generic utility for managing game settings in local storage. + */ + +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: any) { + 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); + return settings[settingName] !== undefined ? settings[settingName] : defaultValue; +} From 11175ddc24d416fd2317d9481558f1c9c37ad586 Mon Sep 17 00:00:00 2001 From: Bana0516 Date: Tue, 5 Aug 2025 19:24:13 -0400 Subject: [PATCH 14/19] [Issue-16] Paddle Battle: centered difficulties --- .../paddle-battle/scenes/SettingsScene.ts | 23 ++++++------------- 1 file changed, 7 insertions(+), 16 deletions(-) diff --git a/src/games/paddle-battle/scenes/SettingsScene.ts b/src/games/paddle-battle/scenes/SettingsScene.ts index d9b8068..faeb347 100644 --- a/src/games/paddle-battle/scenes/SettingsScene.ts +++ b/src/games/paddle-battle/scenes/SettingsScene.ts @@ -48,12 +48,15 @@ export class SettingsScene extends Phaser.Scene { private createDifficultySelector() { const difficulties: Difficulty[] = ['easy', 'normal', 'hard']; - const cellWidth = 220; - const gridWidth = difficulties.length * cellWidth; + 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; - this.difficultyLabels = difficulties.map((d) => { const text = this.add - .text(0, 0, d.toUpperCase(), { + .text(xPos, yPos, d.toUpperCase(), { font: '32px "Press Start 2P"', color: '#ffffff', }) @@ -69,18 +72,6 @@ export class SettingsScene extends Phaser.Scene { return text; }); - // Corrected GridAlign to properly center the elements - Phaser.Actions.GridAlign(this.difficultyLabels, { - width: 3, - height: 1, - cellWidth: cellWidth, - cellHeight: 50, - // The starting x position is the center of the screen minus half the total grid width, - // plus half a cell's width to center the items within their cells. - x: this.cameras.main.centerX - gridWidth / 2 + cellWidth / 2, - y: 320, - }); - this.updateSelectorUI(); } From 73fed7acb40e0becd374b8742fd0a30c7546e071 Mon Sep 17 00:00:00 2001 From: Bana0516 Date: Tue, 5 Aug 2025 19:40:30 -0400 Subject: [PATCH 15/19] [Issue-16] Paddle Battle: Cleaned up start page --- src/games/paddle-battle/scenes/StartScene.ts | 48 ++++++++++---------- 1 file changed, 25 insertions(+), 23 deletions(-) diff --git a/src/games/paddle-battle/scenes/StartScene.ts b/src/games/paddle-battle/scenes/StartScene.ts index 985bd0b..0ac85ec 100644 --- a/src/games/paddle-battle/scenes/StartScene.ts +++ b/src/games/paddle-battle/scenes/StartScene.ts @@ -14,30 +14,27 @@ export class StartScene extends Phaser.Scene { create() { this.currentDifficulty = settingsManager.getSetting(GAME_ID, 'difficulty', 'normal'); - this.add - .text(this.cameras.main.centerX, this.cameras.main.centerY - 150, 'Paddle Battle', { - font: '80px "Press Start 2P"', - color: '#e7042d', - }) - .setOrigin(0.5); - - this.add + // --- Primary Action: Start Game --- + const startButton = this.add .text( this.cameras.main.centerX, - this.cameras.main.centerY - 20, - `Difficulty: ${this.currentDifficulty.toUpperCase()}`, + this.cameras.main.centerY - 40, // Positioned slightly above center + 'Click or Press Space to Start', { - font: '24px "Press Start 2P"', + font: '32px "Press Start 2P"', color: '#ffffff', + align: 'center', } ) - .setOrigin(0.5); + .setOrigin(0.5) + .setInteractive({ useHandCursor: true }); - const startButton = this.add + // --- Secondary Action: Settings --- + const settingsButton = this.add .text( this.cameras.main.centerX, - this.cameras.main.centerY + 50, - 'Click or Press Space to Start', + this.cameras.main.centerY + 40, // Positioned below the start button + 'Settings', { font: '24px "Press Start 2P"', color: '#ffffff', @@ -46,15 +43,20 @@ export class StartScene extends Phaser.Scene { .setOrigin(0.5) .setInteractive({ useHandCursor: true }); - const settingsButton = this.add - .text(this.cameras.main.centerX, this.cameras.main.centerY + 120, 'Settings', { - font: '32px "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 are now specific to the buttons + // --- Event Listeners --- settingsButton.on('pointerover', () => settingsButton.setColor('#e7042d')); settingsButton.on('pointerout', () => settingsButton.setColor('#ffffff')); settingsButton.on('pointerdown', () => this.scene.start('SettingsScene')); From c619cd739dd2b021eaa4339be8452325d91e1078 Mon Sep 17 00:00:00 2001 From: Bana0516 Date: Tue, 5 Aug 2025 20:05:07 -0400 Subject: [PATCH 16/19] [Issue-16] Fixed linting errors and setup a way not show games on mobile if they wont work --- src/app/games/[...slug]/page.tsx | 14 +------ src/components/games/GameClient.tsx | 58 ++++++++++++++++++++--------- src/data/arcadeGames.json | 5 +++ src/games/game-meta-loader.ts | 2 +- src/types/index.ts | 1 + src/utils/settingsManager.ts | 15 ++++++-- 6 files changed, 60 insertions(+), 35 deletions(-) diff --git a/src/app/games/[...slug]/page.tsx b/src/app/games/[...slug]/page.tsx index dae2f5b..c22c154 100644 --- a/src/app/games/[...slug]/page.tsx +++ b/src/app/games/[...slug]/page.tsx @@ -1,45 +1,34 @@ 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'; // Import the client component +import GameClient from '@/components/games/GameClient'; interface GamePageProps { params: Promise<{ slug: string[] }>; } -// This function runs on the server and is safe. 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, - // Use the new, specific metaDescription from our loader description: gameDetails.metaDescription, urlPath: `/games/${fullPath}`, }); } -// This function runs on the server and is safe. export async function generateStaticParams() { return getAllGameSlugs(); } -/** - * The dynamic page component, which remains a Server Component. - * It fetches server-side data and passes it to the client boundary. - */ export default async function GamePage({ params }: GamePageProps) { const { slug } = await params; const gameSlug = slug[slug.length - 1]; - - // Fetch the game details on the server. const gameDetails = getGameDetailsBySlug(gameSlug); - // Render the client component, passing down the necessary props. return ( ); } diff --git a/src/components/games/GameClient.tsx b/src/components/games/GameClient.tsx index 2705f8c..c007dc8 100644 --- a/src/components/games/GameClient.tsx +++ b/src/components/games/GameClient.tsx @@ -6,6 +6,8 @@ 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'; const GameCanvas = dynamic(() => import('@/components/games/GameCanvas'), { ssr: false, @@ -18,32 +20,33 @@ interface GameClientProps { description: string; controls: string[]; stats: GameStat[]; + isDesktopOnly: boolean; // New prop to control mobile visibility } -export default function GameClient({ slug, title, description, controls, stats }: GameClientProps) { +export default function GameClient({ + slug, + title, + description, + controls, + stats, + isDesktopOnly, +}: GameClientProps) { const [gameConfig, setGameConfig] = useState(null); const [playerStats, setPlayerStats] = useState>({}); - // useCallback ensures the function identity is stable across re-renders const refreshStats = useCallback(() => { const allStats = statsManager.getAllStats(slug); setPlayerStats(allStats); }, [slug]); useEffect(() => { - // Load the game config import('@/games/game-loader').then(({ getGameConfigBySlug }) => { const config = getGameConfigBySlug(slug); setGameConfig(config); }); - // Initial load of stats refreshStats(); - - // Add an event listener to update stats in real-time window.addEventListener('statsUpdated', refreshStats); - - // Cleanup the event listener when the component unmounts return () => { window.removeEventListener('statsUpdated', refreshStats); }; @@ -54,17 +57,36 @@ export default function GameClient({ slug, title, description, controls, stats }

{title}

-
-
- }> - {gameConfig ? ( - - ) : ( -
- )} - + {/* --- Game Canvas & Mobile Warning --- */} +
+ {/* Desktop View: Show the game canvas */} +
+
+ }> + {gameConfig ? ( + + ) : ( +
+ )} + +
+ + {/* Mobile Warning: Show only if the game is desktop-only */} + {isDesktopOnly && ( +
+ +

Desktop Recommended

+

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

+
+ )}
diff --git a/src/data/arcadeGames.json b/src/data/arcadeGames.json index bbcd62e..b4540a5 100644 --- a/src/data/arcadeGames.json +++ b/src/data/arcadeGames.json @@ -4,6 +4,8 @@ "imageUrl": "https://placehold.co/300x200/e7042d/003091?text=Paddle+Battle", "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, @@ -27,6 +29,7 @@ "imageUrl": "https://placehold.co/300x200/e7042d/003091?text=Arcade+Classic", "linkUrl": "/games/arcade/galaxy-invaders", "metaDescription": "", + "isDesktopOnly": true, "isComingSoon": true, "tags": ["arcade", "shooter"], "releaseDate": "2024-10-25", @@ -39,6 +42,7 @@ "imageUrl": "https://placehold.co/300x200/e7042d/003091?text=Arcade+Classic", "linkUrl": "#", "metaDescription": "", + "isDesktopOnly": true, "isComingSoon": true, "tags": ["arcade", "puzzle"], "releaseDate": "2024-09-11", @@ -51,6 +55,7 @@ "imageUrl": "https://placehold.co/300x200/e7042d/003091?text=Arcade+Classic", "linkUrl": "#", "metaDescription": "", + "isDesktopOnly": true, "isComingSoon": true, "tags": ["arcade", "puzzle", "strategy"], "releaseDate": "2024-08-19", diff --git a/src/games/game-meta-loader.ts b/src/games/game-meta-loader.ts index 035ee31..d408a63 100644 --- a/src/games/game-meta-loader.ts +++ b/src/games/game-meta-loader.ts @@ -21,6 +21,7 @@ export function getGameDetailsBySlug(slug: string) { 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 }; } @@ -30,7 +31,6 @@ export function getGameDetailsBySlug(slug: string) { export function getAllGameSlugs() { return allGames .map((game) => { - // Remove the leading '/games/' and split the rest into an array const slugParts = game.linkUrl.replace('/games/', '').split('/'); return { slug: slugParts }; }) diff --git a/src/types/index.ts b/src/types/index.ts index 0128a3f..416b869 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -30,6 +30,7 @@ export interface Game { imageUrl: string; linkUrl: string; metaDescription: string; + isDesktopOnly?: boolean; isNew?: boolean; isComingSoon?: boolean; tags: string[]; diff --git a/src/utils/settingsManager.ts b/src/utils/settingsManager.ts index 165e1f8..2e59738 100644 --- a/src/utils/settingsManager.ts +++ b/src/utils/settingsManager.ts @@ -2,7 +2,9 @@ * A generic utility for managing game settings in local storage. */ -type GameSettings = Record; +// Use a more specific type for setting values instead of 'any'. +type SettingValue = string | number | boolean; +type GameSettings = Record; // --- Private Helper Functions --- @@ -33,7 +35,7 @@ function saveSettings(gameId: string, settings: GameSettings) { * @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: any) { +export function setSetting(gameId: string, settingName: string, value: SettingValue) { const settings = getSettings(gameId); settings[settingName] = value; saveSettings(gameId, settings); @@ -46,7 +48,12 @@ export function setSetting(gameId: string, settingName: string, value: any) { * @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 { +export function getSetting( + gameId: string, + settingName: string, + defaultValue: T +): T { const settings = getSettings(gameId); - return settings[settingName] !== undefined ? settings[settingName] : defaultValue; + // The type assertion ensures the return value matches the generic type T. + return (settings[settingName] !== undefined ? settings[settingName] : defaultValue) as T; } From 2c835568dac1ef36db6ba5b534ffc87a88e6b749 Mon Sep 17 00:00:00 2001 From: Bana0516 Date: Tue, 5 Aug 2025 20:11:15 -0400 Subject: [PATCH 17/19] Emptied test data for obl original games --- src/app/page.tsx | 14 ++++++----- src/data/originalGames.json | 49 +------------------------------------ 2 files changed, 9 insertions(+), 54 deletions(-) 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 */} Date: Tue, 5 Aug 2025 20:18:06 -0400 Subject: [PATCH 18/19] [Issue-16] Paddle Battle: Updated image for listing --- public/images/games/paddle-battle.webp | Bin 0 -> 3028 bytes src/data/arcadeGames.json | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 public/images/games/paddle-battle.webp diff --git a/public/images/games/paddle-battle.webp b/public/images/games/paddle-battle.webp new file mode 100644 index 0000000000000000000000000000000000000000..77ecb54491f311bf7ba8072ed378e5feec748574 GIT binary patch literal 3028 zcmai!XH-+$7KK9*>CFTp5(N>Y1cHc))FeRY5I}m99u$=(O+dQv1St^^xRfZp2N0zf zY0?q6B2uL&y%&}82oM~1jCaS&pOu}Rv(H@LnsaL@p->$(0Dzu660M6COC*&}QcqV)6XX)YfxzIRbD?$*cX{K+Yszk0$&8@SekXIiDw+FZEazZM@Y0Y* z-`LkDavf@(*ofF*DsvhZs6O7J={XBj`$mjgWFT1hZhZg+?P2Lkzc-Mpn{W8`uqZx} zo}MnuTAgf(xG3qqI^}{8#q_>Gg|ms;&YVqHUEz#RbzMKmm#bWpY1CBjV}OtYbDX-3 zr#(04KNp%-dv2GIRJhFcWvVBMJCTLpv~0L{iWPrcx4qLiarFs(WSIfpDe_o_J|6S( z7xBd%^s?aU2F=>=7K*6rd+Q@m1=bS2X^uD(O55uHakR^9tLZC?CA1?cIVB?p+>aFW ze(t1!!y=ofwfm{xi;bu}gxl$)oynFLI(;DrVkaODOE2HXY45rg)U#09*3(T(rKGvj zO>Lw3K^_sSk60enctVkORXc?ZCJfW6D7)Wg#k^| z*}7}D)Q6j(I;VzsL|Sd<;73ZfOOIrvV-DBr(?}nqd#9dsk=3#s!H_3x5X5esW5if> zM1@DCNoRm&i39FC-jfPRt{*jseHQebgSfwAg9BJBzOBQ(z9 z=PKX(ttqP)^t=1IXlx_w7<2ThPH{aW(J*Rh!6KRGXj#k^*kJ$RCyv+DfV1?A(h1YZ zagOzKT^E4tu9I!$esTXi$|c}Dae|M>>1=?GuXkP_dHc}f^3!eno3yJ$Ly=SJeskbl z)L|ykJNtL`$>GuKpy-GxB6aN5U~A0y#}b>)#GCeC@C%Cq z{PCG_o{$l7!~W|sMEUfMie{G3lJ=4lo#HO@8dJxqCxl-|%Nm1^CEhXm__VIgQ|o3I z;8oVS|5eTWkTP+YnBFvmKpf%fIf|?k9^&>gUGV5WIiiCTEg(X5Uu`ROCxriB?tt&* zey8Vnu--g4mHp#86-2uj=Rx#>jkR*^dY?&c1GAHg=muvyLjLP6=hZ8YmPqze;6H!n zSPTaM_76T^Ke*>!s2?^w3Vd88)MN=8Dm4VW2xW(-1Cuw#9oV+YBo>+e<@0Fg{$n#= zTvWxTAb|JWJsl=r0J--5Kw$R)^Uo*02djV|+6T8ImhPrr$u70-0t-0y)v9OK9Sg9GCaYHI zjB8TCFq%ZB;lSvK1247nGfmsKR38Qrela_;Mu z%ni*$$u!FheJ`%CzwdTXtSQXCyiF&r)dBYE@~lYUVyf4rzN~$ay|+SBF`SZ28R3Jl zH>I-z@9DX9P!2m87e(^Ebv7|yD@Nz}5I2S9h4SQcNA26=r855_K5YpaY@ z8d%>uzb&5NEH7A6@1U4ZF3uP6`TezRD-Ur!NXeI_`O07{e?=|!T%psWQLT(93&(IE zed;JhsU1{CkxN3#3WTQ%=7&fz(qD$Dsb{62qcSya(@e|1t5dDP32|-r`q;s}m2btN zy&4jEn_I`vv;n#CnH2fC0+I9^yb1&Qkz*((6b=Bwp3OyE9z#b-c}zmi)KN45xn`6_ zAJ%W(4V(&X%te?t$QIU7>-BSCZ*nm_le-DPBLh97^?2S1c!?Uaxn%f9j{+(|ssNrS5m^VQi82@B(%K>wW}q+P0q%E>)*Y|B zpTWBn)#y7vF_xx_$&@y6_Q^v3hqIq6rb37hDe4-KO4@TBgk=p@9Pqj(NwwL|?yeBB z@+^d700CdzR(!QDc{8giG@Sp}&}`MV3Vacd*JDJLOn!%ptc39)86?!gTv(;(_NKSN zGif7^n5}XNMXQ4S5b@zxoVSDU<~Qz-XpdOF%eOo*^8yJ;=_S(zLmIrm+1XCXIN5R2 zr}rkP7DZ&ywz5C1x}+o}z74XCU6@O5aIW$5Zq$Rhw|b_|3wBDYbVFyo&SC8bdy;k) z79VXOh2-=7LQ2s>WMiNZef76BzB*=4w_+->d@B8K5J=m(g~I&jJyrC7D_E0gn)SAS zGQ)3Nai)8Zwlv?r7_6agnPzVJjNB+9DbS%K^iqWbvw?X6QzN}EXe9-`Rq8>$gEM6e z3dsC~36LrPE0iH%7~bCsUMSA{?JHvCpu)VfyCmo;bfi+kOT3&l$75i+DyHDlWXpX4 z2S@nbFk9ck7t+<3P$?%T%6}Q7a?XUi?;vOn%rJ0*#-g zYGT-Uy5dt?1*6$#NJzo8@L!Os8!J4p@K}_YT<$iSLoOjTyH~U&EpG3vci;fvR-;#P zo8K(<+%9!~iGTiA4@!j9`nA`fQ-87nS9k{nMeTYcj!R8WYAy%zq#D61$wJn z@{$F5Q}C)=5-sB6GngF`=&yDX8l|cwdH%n6oUeg|Uwb3@$2Oc-wy8PM{D!T_0*5Zz(Tq3)_&j% zKREb9NM4MB7cFHsGLT^QyT9d>3P#Ah_dF=Vl2ccU$M2#%f!t4*Dq;alkFr#m1{HhF zI;yn6!2-9+23djp6cI|gC_^B(KhrZTYY_k;jf2^*7&8kX=%3q)UCpYAYcoPz8bimf zd1K9%dwR}N1GyW0e!)6<8L|~fH|-TZWG+8Qjf%)pu{85&s>PFN!*;~pIKoB^mYRi` gnP+Hn5;pB*?hmBx73yNQstq-U{_iq4IvD`?FWE7&LjV8( literal 0 HcmV?d00001 diff --git a/src/data/arcadeGames.json b/src/data/arcadeGames.json index b4540a5..b8ed65d 100644 --- a/src/data/arcadeGames.json +++ b/src/data/arcadeGames.json @@ -1,7 +1,7 @@ [ { "title": "Paddle Battle", - "imageUrl": "https://placehold.co/300x200/e7042d/003091?text=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, From f4af25d1c0254e0d513b457dae83f8accf794223 Mon Sep 17 00:00:00 2001 From: Bana0516 Date: Tue, 5 Aug 2025 20:27:27 -0400 Subject: [PATCH 19/19] Fixed loading issue --- src/components/games/GameClient.tsx | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/src/components/games/GameClient.tsx b/src/components/games/GameClient.tsx index c007dc8..7b69eaa 100644 --- a/src/components/games/GameClient.tsx +++ b/src/components/games/GameClient.tsx @@ -9,9 +9,14 @@ 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: () =>
, + loading: () => , }); interface GameClientProps { @@ -20,7 +25,7 @@ interface GameClientProps { description: string; controls: string[]; stats: GameStat[]; - isDesktopOnly: boolean; // New prop to control mobile visibility + isDesktopOnly: boolean; } export default function GameClient({ @@ -57,26 +62,18 @@ export default function GameClient({

{title}

- {/* --- Game Canvas & Mobile Warning --- */}
- {/* Desktop View: Show the game canvas */}
+ } w-full max-w-[816px] border-4 border-obl-blue rounded-lg p-1 shadow-lg bg-black`}>
- }> - {gameConfig ? ( - - ) : ( -
- )} + }> + {gameConfig ? : }
- {/* Mobile Warning: Show only if the game is desktop-only */} {isDesktopOnly && (