443 lines
17 KiB
JavaScript
443 lines
17 KiB
JavaScript
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();
|
||
}
|
||
}
|