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'; 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.events.on('wave-start', () => this.sound.play('sfx-enemy-wave', { volume: 0.5 })); this.events.on('zone-waves-complete', () => this._startZoneExit()); 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) { // Dark background this.add.rectangle(W / 2, H / 2, W, H, 0x111118); // Grid lines for depth cue const g = this.add.graphics(); g.lineStyle(1, 0x222233, 0.5); for (let x = 0; x <= W; x += 80) { g.lineBetween(x, 0, x, H); } for (let y = 0; y <= H; y += 80) { g.lineBetween(0, y, W, y); } // Arena border g.lineStyle(3, 0x334466, 1); g.strokeRect(2, 2, W - 4, H - 4); } 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); 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; }); } _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(); } // ── 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() { this.events.removeAllListeners(); 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 = []; } }