Spaces:
Sleeping
Sleeping
| // Main game loop and state management | |
| import { GameState, KeyState, PlayerState, Enemy, Block } from './types'; | |
| import { Renderer } from './Renderer'; | |
| import { Physics } from './Physics'; | |
| import { Collision } from './Collision'; | |
| import { LevelManager, LevelTheme } from './LevelManager'; | |
| import { getAudioManager, SoundEffect } from './AudioManager'; | |
| import { GAME_CONFIG, KEYS } from '../constants'; | |
| const { CANVAS_WIDTH, CANVAS_HEIGHT, LEVEL_HEIGHT, PLAYER_WIDTH, PLAYER_HEIGHT } = GAME_CONFIG; | |
| export interface GameCallbacks { | |
| onGameOver?: (isWin: boolean, score: number) => void; | |
| onLevelComplete?: (levelNumber: number, nextLevel: number | null) => void; | |
| onGameComplete?: (totalScore: number) => void; | |
| } | |
| export class Game { | |
| private canvas: HTMLCanvasElement; | |
| private ctx: CanvasRenderingContext2D; | |
| private renderer: Renderer; | |
| private physics: Physics; | |
| private collision: Collision; | |
| private levelManager: LevelManager; | |
| private state: GameState; | |
| private keys: KeyState; | |
| private animationId: number | null = null; | |
| private callbacks: GameCallbacks = {}; | |
| private totalScore: number = 0; | |
| private currentTheme: LevelTheme = 'overworld'; | |
| private isMobile: boolean = false; | |
| constructor(canvas: HTMLCanvasElement, startLevel: number = 0, isMobile: boolean = false) { | |
| this.canvas = canvas; | |
| this.ctx = canvas.getContext('2d')!; | |
| this.renderer = new Renderer(this.ctx); | |
| this.physics = new Physics(); | |
| this.collision = new Collision(); | |
| this.levelManager = new LevelManager(); | |
| this.keys = { left: false, right: false, jump: false }; | |
| this.isMobile = isMobile; | |
| // Set mobile mode for physics | |
| this.physics.setMobileMode(isMobile); | |
| // Set starting level | |
| if (startLevel > 0) { | |
| this.levelManager.setLevel(startLevel); | |
| } | |
| this.state = this.createInitialState(); | |
| this.currentTheme = this.levelManager.getCurrentLevelInfo().theme; | |
| this.setupEventListeners(); | |
| } | |
| private createInitialState(): GameState { | |
| const level = this.levelManager.getCurrentLevel(); | |
| return { | |
| player: { | |
| x: level.startPosition.x, | |
| y: level.startPosition.y, | |
| vx: 0, | |
| vy: 0, | |
| width: PLAYER_WIDTH, | |
| height: PLAYER_HEIGHT, | |
| isJumping: false, | |
| isFalling: true, | |
| facingRight: true, | |
| isAlive: true, | |
| score: this.totalScore, | |
| coins: 0, | |
| }, | |
| enemies: level.enemies.map(e => ({ ...e })), | |
| blocks: level.blocks.map(b => ({ ...b })), | |
| camera: { x: 0, y: 0 }, | |
| isRunning: false, | |
| isGameOver: false, | |
| isWin: false, | |
| level, | |
| }; | |
| } | |
| private setupEventListeners(): void { | |
| const handleKeyDown = (e: KeyboardEvent) => { | |
| if (KEYS.LEFT.includes(e.code)) { | |
| this.keys.left = true; | |
| e.preventDefault(); | |
| } | |
| if (KEYS.RIGHT.includes(e.code)) { | |
| this.keys.right = true; | |
| e.preventDefault(); | |
| } | |
| if (KEYS.JUMP.includes(e.code)) { | |
| this.keys.jump = true; | |
| e.preventDefault(); | |
| // Play jump sound | |
| if (this.state.isRunning && !this.state.player.isJumping && !this.state.player.isFalling) { | |
| getAudioManager().play('jump'); | |
| } | |
| } | |
| }; | |
| const handleKeyUp = (e: KeyboardEvent) => { | |
| if (KEYS.LEFT.includes(e.code)) { | |
| this.keys.left = false; | |
| } | |
| if (KEYS.RIGHT.includes(e.code)) { | |
| this.keys.right = false; | |
| } | |
| if (KEYS.JUMP.includes(e.code)) { | |
| this.keys.jump = false; | |
| } | |
| }; | |
| window.addEventListener('keydown', handleKeyDown); | |
| window.addEventListener('keyup', handleKeyUp); | |
| } | |
| private updateCamera(): void { | |
| // Follow player with smooth scrolling | |
| const targetX = this.state.player.x - CANVAS_WIDTH / 3; | |
| this.state.camera.x = Math.max(0, Math.min(targetX, this.state.level.width - CANVAS_WIDTH)); | |
| } | |
| private update(): void { | |
| if (!this.state.isRunning || this.state.isGameOver) return; | |
| const prevY = this.state.player.y; | |
| // Update player physics | |
| this.physics.updatePlayerMovement(this.state.player, this.keys); | |
| // Keep player in bounds | |
| if (this.state.player.x < 0) this.state.player.x = 0; | |
| if (this.state.player.x > this.state.level.width - this.state.player.width) { | |
| this.state.player.x = this.state.level.width - this.state.player.width; | |
| } | |
| // Check block collisions | |
| const { grounded, hitBlock } = this.collision.checkPlayerBlockCollision( | |
| this.state.player, | |
| this.state.blocks, | |
| prevY | |
| ); | |
| // Handle question block hit | |
| if (hitBlock && hitBlock.hasCoin) { | |
| hitBlock.isHit = true; | |
| hitBlock.hasCoin = false; | |
| this.state.player.coins++; | |
| this.state.player.score += 100; | |
| getAudioManager().play('coin'); | |
| } | |
| // Check coin collection | |
| const collectedCoins = this.collision.checkCoinCollision(this.state.player, this.state.blocks); | |
| collectedCoins.forEach(coin => { | |
| const index = this.state.blocks.indexOf(coin); | |
| if (index > -1) { | |
| this.state.blocks.splice(index, 1); | |
| this.state.player.coins++; | |
| this.state.player.score += 50; | |
| getAudioManager().play('coin'); | |
| } | |
| }); | |
| // Update enemies | |
| this.state.enemies.forEach(enemy => { | |
| if (enemy.isAlive) { | |
| this.physics.updateEnemyMovement(enemy, this.state.level.width); | |
| this.collision.checkEnemyBlockCollision(enemy, this.state.blocks); | |
| // Check player-enemy collision | |
| const { killed, playerHit } = this.collision.checkPlayerEnemyCollision( | |
| this.state.player, | |
| enemy | |
| ); | |
| if (killed) { | |
| enemy.isAlive = false; | |
| this.state.player.vy = -8; // Bounce off enemy | |
| this.state.player.score += 200; | |
| getAudioManager().play('stomp'); | |
| } else if (playerHit) { | |
| this.gameOver(false); | |
| } | |
| } | |
| }); | |
| // Check flag collision (win) | |
| if (this.collision.checkFlagCollision(this.state.player, this.state.level.flagPosition)) { | |
| this.levelComplete(); | |
| } | |
| // Check fall death | |
| if (this.collision.checkFallDeath(this.state.player, this.state.level.height)) { | |
| this.gameOver(false); | |
| } | |
| // Update camera | |
| this.updateCamera(); | |
| } | |
| private render(): void { | |
| this.renderer.render(this.state, this.currentTheme, this.levelManager.getCurrentLevelNumber()); | |
| } | |
| private gameLoop = (): void => { | |
| this.update(); | |
| this.render(); | |
| if (this.state.isRunning && !this.state.isGameOver) { | |
| this.animationId = requestAnimationFrame(this.gameLoop); | |
| } | |
| }; | |
| private levelComplete(): void { | |
| this.state.isGameOver = true; | |
| this.state.isWin = true; | |
| this.state.player.score += 1000; // Level completion bonus | |
| this.totalScore = this.state.player.score; | |
| getAudioManager().play('levelup'); | |
| // Render final frame | |
| this.render(); | |
| const currentLevelNum = this.levelManager.getCurrentLevelNumber(); | |
| // Check if there are more levels | |
| if (!this.levelManager.isLastLevel()) { | |
| // Proceed to next level after delay | |
| setTimeout(() => { | |
| const nextLevel = this.levelManager.nextLevel(); | |
| if (nextLevel) { | |
| this.currentTheme = this.levelManager.getCurrentLevelInfo().theme; | |
| this.state = this.createInitialState(); | |
| this.state.isRunning = true; | |
| this.gameLoop(); | |
| if (this.callbacks.onLevelComplete) { | |
| this.callbacks.onLevelComplete(currentLevelNum, this.levelManager.getCurrentLevelNumber()); | |
| } | |
| } | |
| }, 2000); | |
| } else { | |
| // Game complete! | |
| setTimeout(() => { | |
| if (this.callbacks.onGameComplete) { | |
| this.callbacks.onGameComplete(this.totalScore); | |
| } else if (this.callbacks.onGameOver) { | |
| this.callbacks.onGameOver(true, this.totalScore); | |
| } | |
| }, 2000); | |
| } | |
| } | |
| private gameOver(isWin: boolean): void { | |
| this.state.isGameOver = true; | |
| this.state.isWin = isWin; | |
| this.state.player.isAlive = false; | |
| getAudioManager().play('death'); | |
| // Render final frame | |
| this.render(); | |
| if (this.callbacks.onGameOver) { | |
| setTimeout(() => { | |
| this.callbacks.onGameOver!(false, this.state.player.score); | |
| }, 1500); | |
| } | |
| } | |
| start(): void { | |
| getAudioManager().resume(); | |
| this.state = this.createInitialState(); | |
| this.state.isRunning = true; | |
| this.gameLoop(); | |
| } | |
| stop(): void { | |
| this.state.isRunning = false; | |
| if (this.animationId) { | |
| cancelAnimationFrame(this.animationId); | |
| this.animationId = null; | |
| } | |
| getAudioManager().stopBGM(); | |
| } | |
| reset(): void { | |
| this.stop(); | |
| this.totalScore = 0; | |
| this.levelManager.resetToFirstLevel(); | |
| this.currentTheme = this.levelManager.getCurrentLevelInfo().theme; | |
| this.state = this.createInitialState(); | |
| this.render(); | |
| } | |
| setCallbacks(callbacks: GameCallbacks): void { | |
| this.callbacks = callbacks; | |
| } | |
| // Legacy support | |
| setOnGameOver(callback: (isWin: boolean, score: number) => void): void { | |
| this.callbacks.onGameOver = callback; | |
| } | |
| getState(): GameState { | |
| return this.state; | |
| } | |
| getCurrentLevel(): number { | |
| return this.levelManager.getCurrentLevelNumber(); | |
| } | |
| getTotalLevels(): number { | |
| return this.levelManager.getLevelCount(); | |
| } | |
| // External touch/mobile control input | |
| setControls(controls: { left: boolean; right: boolean; jump: boolean }): void { | |
| const wasJumping = this.keys.jump; | |
| this.keys.left = controls.left; | |
| this.keys.right = controls.right; | |
| this.keys.jump = controls.jump; | |
| // Play jump sound when jump starts | |
| if (!wasJumping && controls.jump && this.state.isRunning && | |
| !this.state.player.isJumping && !this.state.player.isFalling) { | |
| getAudioManager().play('jump'); | |
| } | |
| } | |
| } | |