Asteroids-2026/js/scenes/GameScene.js

443 lines
17 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import Player from '../entities/Player.js';
import Asteroid from '../entities/Asteroid.js';
import AlienShip from '../entities/AlienShip.js';
import Bullet from '../entities/Bullet.js';
const STARTING_LIVES = 3;
export default class GameScene extends Phaser.Scene {
constructor() {
super({ key: 'GameScene' });
}
create() {
// State
this.level = 1;
this.score = 0;
this.lives = STARTING_LIVES;
this.gameOver = false;
this.levelComplete = false;
this.levelSpeedMult = 1;
// Physics groups (used for overlap detection)
this.asteroidsGroup = this.physics.add.group();
this.aliensGroup = this.physics.add.group();
this.playerBulletsGroup = this.physics.add.group();
this.alienBulletsGroup = this.physics.add.group();
// Entity arrays for custom update logic
this.asteroids = [];
this.aliens = [];
this.playerBullets = [];
this.alienBullets = [];
this.player = new Player(this, 800, 450);
this.setupCollisions();
this.createUI();
this.startLevel();
this.alienTimer = this.time.addEvent({
delay: this.alienSpawnDelay(),
callback: this.spawnAlien,
callbackScope: this,
loop: true
});
// Apply CRT post-processing (scanlines + screen curvature)
if (this.renderer.type === Phaser.WEBGL) {
this.cameras.main.setPostPipeline('CRTPipeline');
}
// Stop level music when this scene shuts down (e.g. returning to menu)
this.events.once('shutdown', () => { if (this.music) this.music.stop(); });
// Custom reticle cursor hide the OS cursor and draw our own
this.game.canvas.style.cursor = 'none';
this.reticleOuter = this.add.image(800, 450, 'reticle_outer').setDepth(20);
this.reticleInner = this.add.image(800, 450, 'reticle_inner').setDepth(20);
this.tweens.add({ targets: this.reticleOuter, angle: 360, duration: 8000, repeat: -1, ease: 'Linear' });
this.tweens.add({ targets: this.reticleInner, angle: -360, duration: 12000, repeat: -1, ease: 'Linear' });
this.events.once('shutdown', () => {
this.game.canvas.style.cursor = 'default';
this.reticleOuter.destroy();
this.reticleInner.destroy();
});
}
// ─── Collision Setup ─────────────────────────────────────────────────────
setupCollisions() {
this.physics.add.overlap(
this.playerBulletsGroup, this.asteroidsGroup,
this.onPlayerBulletHitAsteroid, null, this
);
this.physics.add.overlap(
this.playerBulletsGroup, this.aliensGroup,
this.onPlayerBulletHitAlien, null, this
);
this.physics.add.overlap(
this.player.sprite, this.alienBulletsGroup,
this.onAlienBulletHitPlayer, null, this
);
this.physics.add.overlap(
this.player.sprite, this.asteroidsGroup,
this.onPlayerHitAsteroid, null, this
);
this.physics.add.overlap(
this.player.sprite, this.aliensGroup,
this.onPlayerHitAlien, null, this
);
}
// ─── UI ──────────────────────────────────────────────────────────────────
createUI() {
const mono = { fontFamily: 'monospace', fill: '#ffffff' };
this.levelText = this.add.text(800, 16, 'LEVEL 1', { ...mono, fontSize: '24px' })
.setOrigin(0.5, 0).setDepth(10);
this.scoreText = this.add.text(620, 50, 'SCORE: 0', { ...mono, fontSize: '20px' })
.setOrigin(0.5, 0).setDepth(10);
this.livesText = this.add.text(800, 50, 'LIVES: 3', { ...mono, fontSize: '20px' })
.setOrigin(0.5, 0).setDepth(10);
this.rocksText = this.add.text(980, 50, 'ROCKS: 0', { ...mono, fontSize: '20px' })
.setOrigin(0.5, 0).setDepth(10);
this.msgText = this.add.text(800, 420, '', { ...mono, fontSize: '52px', align: 'center' })
.setOrigin(0.5).setDepth(10).setVisible(false);
}
updateUI() {
this.scoreText.setText(`SCORE: ${this.score}`);
this.livesText.setText(`LIVES: ${this.lives}`);
this.levelText.setText(`LEVEL ${this.level}`);
this.rocksText.setText(`ROCKS: ${this.asteroids.length}`);
}
showMessage(text, autohideMs = 0) {
if (!text) { this.msgText.setVisible(false); return; }
this.msgText.setText(text).setVisible(true);
if (autohideMs > 0) {
this.time.delayedCall(autohideMs, () => this.msgText.setVisible(false));
}
}
// ─── Level Management ────────────────────────────────────────────────────
_playLevelMusic() {
if (this.music) { this.music.stop(); this.music.destroy(); this.music = null; }
const trackNum = String(this.level).padStart(2, '0');
const key = `track${trackNum}`;
// Fall back to the highest available track if this level doesn't have one yet
const resolvedKey = this.cache.audio.exists(key) ? key : 'track07';
this.music = this.sound.add(resolvedKey, { loop: true, volume: 0.7 });
this.music.play();
}
startLevel() {
this.levelComplete = false;
// Clear all entities
this.asteroids.forEach(a => a.destroy()); this.asteroids = [];
this.aliens.forEach(a => a.destroy()); this.aliens = [];
this.playerBullets.forEach(b => b.destroy()); this.playerBullets = [];
this.alienBullets.forEach(b => b.destroy()); this.alienBullets = [];
// Respawn player at center
this.player.respawn(800, 450);
// Progressive asteroid difficulty: more rocks, mixed sizes, faster per level
this.levelSpeedMult = Math.min(1 + (this.level - 1) * 0.12, 2.0);
const largeCount = Math.max(3, 2 + this.level);
const mediumCount = Math.floor(this.level / 2);
const smallCount = Math.floor(this.level / 3);
const spawnOne = (size) => {
const { x, y } = this.edgePosition();
this.asteroids.push(new Asteroid(this, x, y, size, this.asteroidsGroup, this.levelSpeedMult));
};
for (let i = 0; i < largeCount; i++) spawnOne('large');
for (let i = 0; i < mediumCount; i++) spawnOne('medium');
for (let i = 0; i < smallCount; i++) spawnOne('small');
this.updateUI();
this.showMessage(`LEVEL ${this.level}`, 2000);
this._playLevelMusic();
}
// Random position along any screen edge, away from center
edgePosition() {
const W = 1600, H = 900;
let x, y;
do {
const edge = Phaser.Math.Between(0, 3);
switch (edge) {
case 0: x = Phaser.Math.Between(0, W); y = 0; break;
case 1: x = Phaser.Math.Between(0, W); y = H; break;
case 2: x = 0; y = Phaser.Math.Between(0, H); break;
default: x = W; y = Phaser.Math.Between(0, H); break;
}
} while (Phaser.Math.Distance.Between(x, y, 800, 450) < 200);
return { x, y };
}
alienSpawnDelay() {
return Math.max(8000, 22000 - this.level * 1500);
}
// ─── Spawning ────────────────────────────────────────────────────────────
spawnAlien() {
if (this.gameOver || this.levelComplete) return;
if (this.aliens.length >= 2) return;
const { x, y } = this.edgePosition();
// Select alien type by level: missile aliens at 5+, spread at 3+
let type = 'basic';
if (this.level >= 5) {
const r = Math.random();
type = r < 0.40 ? 'missile' : r < 0.75 ? 'spread' : 'basic';
} else if (this.level >= 3) {
type = Math.random() < 0.5 ? 'spread' : 'basic';
}
this.aliens.push(new AlienShip(this, x, y, this.aliensGroup, type));
}
spawnPlayerBullet(x, y, angle) {
this.sound.play('sfx_player_shoot', { volume: 0.2 });
this.playerBullets.push(
new Bullet(this, x, y, angle, 'player', this.playerBulletsGroup)
);
}
spawnAlienBullet(x, y, angle, ownerShip = null) {
this.sound.play('sfx_alien_shoot', { volume: 0.45 });
this.alienBullets.push(
new Bullet(this, x, y, angle, 'alien', this.alienBulletsGroup, ownerShip)
);
}
spawnHomingMissile(x, y, ownerShip) {
this.sound.play('sfx_missle', { volume: 0.5 });
const player = this.player;
const angle = Phaser.Math.RadToDeg(
Math.atan2(player.sprite.y - y, player.sprite.x - x)
);
this.alienBullets.push(
new Bullet(this, x, y, angle, 'missile', this.alienBulletsGroup, ownerShip)
);
}
// ─── Collision Callbacks ─────────────────────────────────────────────────
onPlayerBulletHitAsteroid(bulletSprite, asteroidSprite) {
if (!bulletSprite.active || !asteroidSprite.active) return;
const bullet = bulletSprite.gameEntity;
const asteroid = asteroidSprite.gameEntity;
if (!bullet || !asteroid || !bullet.alive || !asteroid.alive) return;
const scoreTable = { large: 20, medium: 50, small: 100 };
this.addScore(scoreTable[asteroid.size] || 20);
this.spawnImpactSparks(bulletSprite.x, bulletSprite.y);
this.splitAsteroid(asteroid);
bullet.destroy();
this.playerBullets = this.playerBullets.filter(b => b !== bullet);
}
onPlayerBulletHitAlien(bulletSprite, alienSprite) {
if (!bulletSprite.active || !alienSprite.active) return;
const bullet = bulletSprite.gameEntity;
const alien = alienSprite.gameEntity;
if (!bullet || !alien || !bullet.alive || !alien.alive) return;
this.addScore(200);
this.spawnImpactSparks(bulletSprite.x, bulletSprite.y);
this.sound.play('sfx_alien_death', { volume: 0.65 });
alien.explode();
this.aliens = this.aliens.filter(a => a !== alien);
bullet.destroy();
this.playerBullets = this.playerBullets.filter(b => b !== bullet);
}
onAlienBulletHitPlayer(playerSprite, alienBulletSprite) {
if (!alienBulletSprite.active) return;
const bullet = alienBulletSprite.gameEntity;
if (!bullet || !bullet.alive) return;
const ownerShip = bullet.ownerEntity;
bullet.destroy();
this.alienBullets = this.alienBullets.filter(b => b !== bullet);
this.handlePlayerHit();
if (ownerShip && ownerShip.alive) {
ownerShip.warpOut();
this.aliens = this.aliens.filter(a => a !== ownerShip);
}
}
onPlayerHitAsteroid(playerSprite, asteroidSprite) {
if (!asteroidSprite.active) return;
const asteroid = asteroidSprite.gameEntity;
if (!asteroid || !asteroid.alive) return;
this.handlePlayerHit();
}
onPlayerHitAlien(playerSprite, alienSprite) {
if (!alienSprite.active) return;
const alien = alienSprite.gameEntity;
if (!alien || !alien.alive) return;
this.handlePlayerHit();
alien.warpOut();
this.aliens = this.aliens.filter(a => a !== alien);
}
handlePlayerHit() {
if (!this.player.hit()) return; // invincible ignore
this.sound.play('sfx_player_death', { volume: 0.7 });
this.player.die(); // hide ship and go invincible immediately
this.lives--;
this.updateUI();
if (this.lives <= 0) {
this.triggerGameOver();
} else {
// Brief pause so the destruction is visible before respawning
this.time.delayedCall(1500, () => {
if (!this.gameOver) this.player.respawn(800, 450);
});
}
}
// ─── Asteroid Splitting ───────────────────────────────────────────────────
splitAsteroid(asteroid) {
const { x, y } = asteroid.sprite;
asteroid.destroy();
this.asteroids = this.asteroids.filter(a => a !== asteroid);
if (asteroid.size === 'large') {
const count = Phaser.Math.Between(1, 3);
for (let i = 0; i < count; i++) {
const size = Math.random() < 0.5 ? 'medium' : 'small';
this.asteroids.push(new Asteroid(this, x, y, size, this.asteroidsGroup, this.levelSpeedMult));
}
} else if (asteroid.size === 'medium') {
for (let i = 0; i < 2; i++) {
this.asteroids.push(new Asteroid(this, x, y, 'small', this.asteroidsGroup, this.levelSpeedMult));
}
}
// small: fully destroyed no fragments
this.rocksText.setText(`ROCKS: ${this.asteroids.length}`);
}
// ─── Score / Game Over ────────────────────────────────────────────────────
addScore(pts) {
this.score += pts;
this.scoreText.setText(`SCORE: ${this.score}`);
}
spawnImpactSparks(x, y) {
this.sound.play('sfx_impact', { volume: 1.0 });
const count = Phaser.Math.Between(3, 5);
for (let i = 0; i < count; i++) {
const gfx = this.add.graphics();
gfx.setDepth(5);
gfx.fillStyle(0xffff00, 1);
gfx.fillCircle(0, 0, Phaser.Math.Between(2, 4));
gfx.x = x;
gfx.y = y;
const angle = Math.random() * Math.PI * 2;
const dist = Phaser.Math.Between(20, 50);
this.tweens.add({
targets: gfx,
x: x + Math.cos(angle) * dist,
y: y + Math.sin(angle) * dist,
alpha: 0,
duration: 250,
ease: 'Quad.Out',
onComplete: () => gfx.destroy()
});
}
}
triggerGameOver() {
this.gameOver = true;
this.player.alive = false;
this.player.sprite.setVisible(false);
this.showMessage(`GAME OVER\n\nSCORE: ${this.score}\n\nPress R to return to menu`);
this.input.keyboard.once('keydown-R', () => this.scene.start('MenuScene'));
}
// ─── Level Completion Check ───────────────────────────────────────────────
checkLevelComplete() {
if (this.levelComplete) return;
if (this.asteroids.length > 0 || this.aliens.length > 0) return;
this.levelComplete = true;
this.showMessage('LEVEL COMPLETE!', 2000);
this.time.delayedCall(2500, () => {
this.level++;
this.changeBgColor();
this.startLevel();
// Reset alien spawn timer for new level
this.alienTimer.remove();
this.alienTimer = this.time.addEvent({
delay: this.alienSpawnDelay(),
callback: this.spawnAlien,
callbackScope: this,
loop: true
});
});
}
// ─── Background Color ─────────────────────────────────────────────────────
changeBgColor() {
// Pick dim, randomised tint one channel slightly brighter for variety
const rgb = [
Phaser.Math.Between(0, 35),
Phaser.Math.Between(0, 35),
Phaser.Math.Between(0, 35)
];
const boost = Phaser.Math.Between(0, 2);
rgb[boost] = Phaser.Math.Between(45, 80);
this.cameras.main.setBackgroundColor(
Phaser.Display.Color.GetColor(rgb[0], rgb[1], rgb[2])
);
}
// ─── Main Update Loop ────────────────────────────────────────────────────
update(time, delta) {
// Track reticle to mouse pointer (always, even on game over)
const ptr = this.input.activePointer;
this.reticleOuter.setPosition(ptr.x, ptr.y);
this.reticleInner.setPosition(ptr.x, ptr.y);
if (this.gameOver) return;
this.player.update(time, delta);
this.asteroids.forEach(a => a.update());
this.aliens.forEach(a => a.update(time, this.player));
// Update bullets; keep only still-alive ones
this.playerBullets = this.playerBullets.filter(b => b.update(time));
this.alienBullets = this.alienBullets.filter(b => b.update(time));
this.checkLevelComplete();
}
}