import { Player } from '../entities/Player.js'; import { WaveManager } from '../systems/WaveManager.js'; import { XPSystem } from '../systems/XPSystem.js'; import { SkillTree } from '../systems/SkillTree.js'; import { HUD } from '../ui/HUD.js'; import { SkillTreeUI } from '../ui/SkillTreeUI.js'; import { Reticle } from '../ui/Reticle.js'; import { BarrierManager } from '../systems/BarrierManager.js'; const ZONE_PALETTE = [ null, // index 0 unused { bg: 0x111118, grid: 0x222233, gridAlpha: 0.50, border: 0x334466, shimmer: null }, // zone 1 — dark blue-grey (unchanged) { bg: 0x080d28, grid: 0x1a3488, gridAlpha: 0.70, border: 0x2255dd, shimmer: 0x00eeff }, // zone 2 — deep navy { bg: 0x0e0828, grid: 0x2a1888, gridAlpha: 0.70, border: 0x6633cc, shimmer: 0x8833ff }, // zone 3 — indigo { bg: 0x160828, grid: 0x420aaa, gridAlpha: 0.70, border: 0x9922dd, shimmer: 0xff00ff }, // zone 4 — deep violet { bg: 0x1e0820, grid: 0x5a0e55, gridAlpha: 0.70, border: 0xcc22aa, shimmer: 0xff2266 }, // zone 5 — dark magenta { bg: 0x1e0e06, grid: 0x552208, gridAlpha: 0.70, border: 0xdd5511, shimmer: 0xff7700 }, // zone 6 — burnt amber ]; export class GameScene extends Phaser.Scene { constructor() { super({ key: 'GameScene' }); } preload() { this.load.json('zones', './js/data/zones.json'); this.load.json('skillTree', './js/data/skillTree.json'); this.load.spritesheet('player', './assets/sprites/player.png', { frameWidth: 48, frameHeight: 48 }); this.load.spritesheet('enemies', './assets/sprites/enemies.png', { frameWidth: 48, frameHeight: 48 }); this.load.audio('music-game', './assets/music/gameBackground.mp3'); this.load.audio('sfx-edge-remove', './assets/fx/EdgeRemove.mp3'); this.load.audio('sfx-arrow-ping', './assets/fx/ArrowPing.mp3'); this.load.audio('sfx-sprayer-shoot', './assets/fx/SprayerShoot.mp3'); this.load.audio('sfx-death', './assets/fx/Death.mp3'); this.load.audio('sfx-new-life', './assets/fx/NewLife.mp3'); this.load.audio('sfx-shoot', './assets/fx/Shoot.mp3'); this.load.audio('sfx-take-damage', './assets/fx/TakeDamage.mp3'); this.load.audio('sfx-shooter-shoot', './assets/fx/ShooterShoot.mp3'); this.load.audio('sfx-enemy-wave', './assets/fx/EnemyWave.mp3'); this.load.audio('sfx-death-chaser', './assets/fx/EnemyChaseDeath.mp3'); this.load.audio('sfx-death-swarmer', './assets/fx/EnemySwarmerDeath.mp3'); this.load.audio('sfx-death-shooter', './assets/fx/EnemyShooterDeath.mp3'); this.load.audio('sfx-death-sprayer', './assets/fx/EnemySprayerDeath.mp3'); this.load.audio('sfx-death-bomber', './assets/fx/EnemyBomberDeath.mp3'); } create() { const W = this.scale.width; const H = this.scale.height; this._drawArena(W, H); this.physics.world.setBounds(0, 0, W, H); // Core systems — data already loaded by preload() this.skillTree = new SkillTree(); this.skillTree.load(this.cache.json.get('skillTree')); this.player = new Player(this, W / 2, H / 2); this.xpSystem = new XPSystem(this); this.waveManager = new WaveManager(this, this.player); this.waveManager.load(this.cache.json.get('zones')); this.hud = new HUD(this, this.player, this.xpSystem); // Enemy projectile group for collision this._enemyProjectiles = []; // Event wiring this.events.on('enemy-killed', ({ xp }) => this.xpSystem.addXP(xp)); this.events.on('enemy-projectile-spawned', proj => this._enemyProjectiles.push(proj)); this.events.on('level-up', level => this._showLevelUp(level)); this.events.on('game-over', () => this._onGameOver()); this.events.on('victory', () => this._onVictory()); this._levelUpPending = false; this._frozen = false; this._waitingForExit = false; this._pulseEffects = []; this._shimmers = []; this._shimmerTimer = 0; this._shimmerSpawnMs = 1500; this.events.on('wave-start', () => this.sound.play('sfx-enemy-wave', { volume: 0.5 })); this.events.on('zone-waves-complete', () => this._startZoneExit()); this.events.on('player-died', () => this._onPlayerDied()); this.events.on('player-respawned', () => { this.waveManager.enemiesFrozen = false; }); this.reticle = new Reticle(this); this._setupBarriers(); this._bgMusic = this.sound.add('music-game', { loop: true, volume: 0.8 }); this._bgMusic.play(); this.waveManager.start(); } _setupBarriers() { // Remove old colliders before destroying the groups they reference if (this._barrierColliders) { this._barrierColliders.forEach(c => c.destroy()); } this.barrierManager?.destroy(); const zoneData = this.waveManager.currentZone ?? this.waveManager.zones[0]; const interiorCount = zoneData?.interiorBarriers ?? 0; this.barrierManager = new BarrierManager(this, interiorCount); // Player blocked by all barriers; enemies only blocked by interior ones this._barrierColliders = [ this.physics.add.collider(this.player.sprite, this.barrierManager.staticGroup), this.physics.add.collider(this.waveManager.spriteGroup, this.barrierManager.interiorStaticGroup), ]; this.barrierManager.activateEdgeBarriers(() => { const W = this.scale.width; const H = this.scale.height; if (this.barrierManager.isInsideEdgeBarrier(this.player.x, this.player.y)) { this.player.sprite.body.reset(W / 2, H / 2); this.player.takeDamage(this.player.hp); // lose a life, respawn at center } }); } _drawArena(W, H) { this._arenaBg = this.add.rectangle(W / 2, H / 2, W, H, 0x111118); this._arenaGrid = this.add.graphics(); const z1 = ZONE_PALETTE[1]; this._drawArenaGrid(z1.grid, z1.gridAlpha, z1.border); } _drawArenaGrid(gridColor, gridAlpha, borderColor) { const W = this.scale.width; const H = this.scale.height; this._arenaGrid.clear(); this._arenaGrid.lineStyle(1, gridColor, gridAlpha); for (let x = 0; x <= W; x += 80) { this._arenaGrid.lineBetween(x, 0, x, H); } for (let y = 0; y <= H; y += 80) { this._arenaGrid.lineBetween(0, y, W, y); } this._arenaGrid.lineStyle(3, borderColor, 1); this._arenaGrid.strokeRect(2, 2, W - 4, H - 4); } _updateArenaColors(zoneNum) { const palette = ZONE_PALETTE[zoneNum] ?? ZONE_PALETTE[ZONE_PALETTE.length - 1]; this._arenaBg.setFillStyle(palette.bg); this._drawArenaGrid(palette.grid, palette.gridAlpha, palette.border); } _spawnShimmer() { const zoneNum = this.waveManager.zoneNum; if (zoneNum < 2) return; if (this._shimmers.length >= 2) return; const palette = ZONE_PALETTE[Math.min(zoneNum, ZONE_PALETTE.length - 1)]; if (!palette?.shimmer) return; const COLS = Math.floor(this.scale.width / 80); const ROWS = Math.floor(this.scale.height / 80); const col = Phaser.Math.Between(0, COLS - 1); const row = Phaser.Math.Between(0, ROWS - 1); const rect = this.add.rectangle( col * 80 + 40, row * 80 + 40, 80, 80, palette.shimmer, 0 ).setDepth(1); this._shimmers.push({ rect, col, row, stepTimer: 0, stepMs: Phaser.Math.Between(250, 400), stepsLeft: Phaser.Math.Between(5, 9), phase: 'fadein', phaseTimer: 0, }); } _updateShimmers(delta) { if (this.waveManager.zoneNum < 2) return; this._shimmerTimer += delta; if (this._shimmerTimer >= this._shimmerSpawnMs) { this._shimmerTimer = 0; this._shimmerSpawnMs = Phaser.Math.Between(3000, 6000); this._spawnShimmer(); } const COLS = Math.floor(this.scale.width / 80); const ROWS = Math.floor(this.scale.height / 80); const TARGET_ALPHA = 0.22; const FADE_IN_MS = 150; const FADE_OUT_MS = 350; this._shimmers = this._shimmers.filter(s => { s.phaseTimer += delta; if (s.phase === 'fadein') { s.rect.fillAlpha = Math.min(1, s.phaseTimer / FADE_IN_MS) * TARGET_ALPHA; if (s.phaseTimer >= FADE_IN_MS) { s.phase = 'walk'; s.phaseTimer = 0; s.stepTimer = 0; } } else if (s.phase === 'walk') { s.stepTimer += delta; if (s.stepTimer >= s.stepMs) { s.stepTimer = 0; s.stepsLeft--; if (s.stepsLeft <= 0) { s.phase = 'fadeout'; s.phaseTimer = 0; } else { const dirs = [[-1, 0], [1, 0], [0, -1], [0, 1]]; const [dc, dr] = dirs[Phaser.Math.Between(0, 3)]; s.col = Phaser.Math.Clamp(s.col + dc, 0, COLS - 1); s.row = Phaser.Math.Clamp(s.row + dr, 0, ROWS - 1); s.rect.setPosition(s.col * 80 + 40, s.row * 80 + 40); s.stepMs = Phaser.Math.Between(250, 400); } } } else if (s.phase === 'fadeout') { s.rect.fillAlpha = Math.max(0, (1 - s.phaseTimer / FADE_OUT_MS)) * TARGET_ALPHA; if (s.phaseTimer >= FADE_OUT_MS) { s.rect.destroy(); return false; } } return true; }); } update(time, delta) { this.reticle?.update(delta); if (!this.player || this._frozen) return; this.player.update(delta); this.waveManager.update(delta); this.hud.update(); this._checkBulletHits(); this._checkEnemyProjectileHits(); this._pruneEnemyProjectiles(); this._updatePulseEffects(delta); this._updateShimmers(delta); if (this._waitingForExit) this._checkPlayerExit(); } _updatePulseEffects(delta) { this._pulseEffects = this._pulseEffects.filter(effect => { effect.elapsed += delta; if (!effect.adjSpawned && effect.elapsed >= effect.ADJ_MS) { effect.adjSpawned = true; effect.adjacents.forEach(t => { if (t?.active) t.setVisible(true); }); } if (effect.elapsed >= effect.FADE_MS) { const progress = Math.min(1, (effect.elapsed - effect.FADE_MS) / (effect.END_MS - effect.FADE_MS)); if (effect.center?.active) effect.center.alpha = Math.max(0, 0.55 * (1 - progress)); effect.adjacents.forEach(t => { if (t?.active) t.alpha = Math.max(0, 0.38 * (1 - progress)); }); } if (effect.elapsed >= effect.END_MS) { if (effect.center?.active) effect.center.destroy(); effect.adjacents.forEach(t => { if (t?.active) t.destroy(); }); return false; // remove from array } return true; }); } _onPlayerDied() { const W = this.scale.width; const H = this.scale.height; const cx = W / 2; const cy = H / 2; // Destroy all enemy projectiles this._enemyProjectiles.forEach(p => { if (p?.active) p.destroy(); }); this._enemyProjectiles = []; // Destroy enemies within 150px of screen centre this.waveManager.enemies.forEach(e => { if (e.active && Phaser.Math.Distance.Between(e.x, e.y, cx, cy) < 150) { e.takeDamage(99999); } }); // Freeze remaining enemies for the duration of invincibility this.waveManager.enemiesFrozen = true; } _checkBulletHits() { const bullets = this.player.bullets.getChildren(); const enemies = this.waveManager.enemies; for (const bullet of bullets) { if (!bullet.active) continue; // Destroy bullet if it hits a barrier if (this.barrierManager.isPointBlocked(bullet.x, bullet.y)) { bullet.destroy(); continue; } for (const enemy of enemies) { if (!enemy.active) continue; const dist = Phaser.Math.Distance.Between(bullet.x, bullet.y, enemy.x, enemy.y); if (dist < enemy.radius + 5) { enemy.takeDamage(bullet.damage); bullet.destroy(); break; } } } } _checkEnemyProjectileHits() { this._enemyProjectiles = this._enemyProjectiles.filter(p => p?.active); this._enemyProjectiles.forEach(proj => { if (!proj.active) return; const dist = Phaser.Math.Distance.Between(proj.x, proj.y, this.player.x, this.player.y); if (dist < 16 + 6) { this.player.takeDamage(proj.damage); proj.destroy(); } }); } _pruneEnemyProjectiles() { this._enemyProjectiles = this._enemyProjectiles.filter(p => p?.active); } // ── Zone exit (Smash TV style) ───────────────────────────────────────────── _startZoneExit() { this.sound.play('sfx-edge-remove', { volume: 0.6 }); // Explode edge barriers first, then allow exit this.barrierManager.explodeEdgeBarriers(() => { this._waitingForExit = true; this.player.sprite.body.setCollideWorldBounds(false); this._showExitArrows(); }); } _showExitArrows() { const W = this.scale.width; const H = this.scale.height; const g = this.add.graphics(); g.setDepth(15); this._exitArrowsGfx = g; const ARROW_W = 30; // triangle base half-width const ARROW_D = 28; // triangle depth (pointing direction) const MARGIN = 18; // gap from screen edge to arrow tip const drawUp = (cx, tipY) => g.fillTriangle(cx - ARROW_W, tipY + ARROW_D, cx + ARROW_W, tipY + ARROW_D, cx, tipY); const drawDown = (cx, tipY) => g.fillTriangle(cx - ARROW_W, tipY - ARROW_D, cx + ARROW_W, tipY - ARROW_D, cx, tipY); const drawLeft = (tipX, cy) => g.fillTriangle(tipX + ARROW_D, cy - ARROW_W, tipX + ARROW_D, cy + ARROW_W, tipX, cy); const drawRight = (tipX, cy) => g.fillTriangle(tipX - ARROW_D, cy - ARROW_W, tipX - ARROW_D, cy + ARROW_W, tipX, cy); // Three arrows per edge const xPositions = [W * 0.25, W * 0.5, W * 0.75]; const yPositions = [H * 0.25, H * 0.5, H * 0.75]; g.fillStyle(0xffffff, 1); xPositions.forEach(cx => { drawUp(cx, MARGIN); drawDown(cx, H - MARGIN); }); yPositions.forEach(cy => { drawLeft(MARGIN, cy); drawRight(W - MARGIN, cy); }); this._arrowPing = this.sound.add('sfx-arrow-ping', { loop: true, volume: 0.4 }); this._arrowPing.play(); // Blink the arrows this._exitArrowsTween = this.tweens.add({ targets: g, alpha: 0.1, duration: 350, yoyo: true, repeat: -1, ease: 'Sine.easeInOut', }); // "Zone cleared" prompt this._exitText = this.add.text(W / 2, 60, 'ZONE CLEARED — EXIT THROUGH ANY DOOR', { fontSize: '20px', fill: '#ffdd44', fontStyle: 'bold', stroke: '#000000', strokeThickness: 4, }).setOrigin(0.5).setDepth(15); this.tweens.add({ targets: this._exitText, alpha: 0, duration: 400, yoyo: true, repeat: -1, }); } _hideExitArrows() { this._exitArrowsTween?.stop(); this._exitArrowsGfx?.destroy(); this._exitText?.destroy(); this._arrowPing?.stop(); this._arrowPing = null; this._exitArrowsGfx = null; this._exitText = null; } _checkPlayerExit() { const W = this.scale.width; const H = this.scale.height; const px = this.player.x; const py = this.player.y; if (px < -24) this._doZoneTransition('left'); else if (px > W + 24) this._doZoneTransition('right'); else if (py < -24) this._doZoneTransition('top'); else if (py > H + 24) this._doZoneTransition('bottom'); } _doZoneTransition(direction) { this._waitingForExit = false; this._hideExitArrows(); const W = this.scale.width; const H = this.scale.height; // Respawn player on the opposite edge, same lateral position const px = Phaser.Math.Clamp(this.player.x, 48, W - 48); const py = Phaser.Math.Clamp(this.player.y, 48, H - 48); const spawnX = direction === 'left' ? W - 48 : direction === 'right' ? 48 : px; const spawnY = direction === 'top' ? H - 48 : direction === 'bottom' ? 48 : py; // White flash const flash = this.add.rectangle(W / 2, H / 2, W, H, 0xffffff).setDepth(50); this.tweens.add({ targets: flash, alpha: 0, duration: 300, onComplete: () => flash.destroy(), }); this.player.sprite.setPosition(spawnX, spawnY); this.player.sprite.body.setCollideWorldBounds(true); this.waveManager.startNextZone(); // Set up barriers AFTER zone index is incremented so we read the new zone's config if (this.waveManager.currentZone) this._setupBarriers(); this._updateArenaColors(this.waveManager.zoneNum); this._shimmers.forEach(s => s.rect?.destroy()); this._shimmers = []; this._shimmerTimer = 0; this._shimmerSpawnMs = 1500; } // ── Level up ─────────────────────────────────────────────────────────────── _showLevelUp(level) { if (this._levelUpPending) return; this._levelUpPending = true; // Only show if there are skills available const available = this.skillTree.getAvailable(); if (available.length === 0) { this._levelUpPending = false; return; } this._frozen = true; this.physics.world.pause(); new SkillTreeUI(this, this.skillTree, this.player, () => { this._levelUpPending = false; this._frozen = false; this.physics.world.resume(); }); } _onGameOver() { this._bgMusic?.stop(); this.scene.launch('GameOverScene'); this.scene.pause(); } _onVictory() { const W = this.scale.width; const H = this.scale.height; this.add.rectangle(W / 2, H / 2, W, H, 0x000000, 0.7).setDepth(30); this.add.text(W / 2, H / 2 - 40, 'VICTORY!', { fontFamily: 'FutureImperfect', fontSize: '72px', fill: '#ffdd00', }).setOrigin(0.5).setDepth(31); const prompt = this.add.text(W / 2, H / 2 + 60, 'Press R to return to menu', { fontSize: '22px', fill: '#ffffff' }).setOrigin(0.5).setDepth(31); this.tweens.add({ targets: prompt, alpha: 0, duration: 700, yoyo: true, repeat: -1 }); this.input.keyboard.once('keydown-R', () => { this.scene.start('IntroScene'); }); } shutdown() { // Remove only our custom listeners — removeAllListeners() would also strip // Phaser's internal scene lifecycle listeners and corrupt the next create(). this.events.off('enemy-killed'); this.events.off('enemy-projectile-spawned'); this.events.off('level-up'); this.events.off('game-over'); this.events.off('victory'); this.events.off('wave-start'); this.events.off('zone-waves-complete'); this.events.off('player-died'); this.events.off('player-respawned'); this._barrierColliders?.forEach(c => c.destroy()); this.reticle?.destroy(); this.barrierManager?.destroy(); this._hideExitArrows(); this._waitingForExit = false; this.player?.destroy(); this.waveManager?.reset(); this.hud?.destroy(); this._bgMusic?.stop(); this._bgMusic = null; this._enemyProjectiles = []; this._pulseEffects = []; this._shimmers?.forEach(s => s.rect?.destroy()); this._shimmers = []; } }