diff --git a/assets/sprites/_enemies.png b/assets/sprites/_enemies.png new file mode 100644 index 0000000..410e792 Binary files /dev/null and b/assets/sprites/_enemies.png differ diff --git a/assets/sprites/_enemies.psd b/assets/sprites/_enemies.psd new file mode 100644 index 0000000..d78a92d Binary files /dev/null and b/assets/sprites/_enemies.psd differ diff --git a/assets/sprites/bonus.png b/assets/sprites/bonus.png new file mode 100644 index 0000000..2c82eb4 Binary files /dev/null and b/assets/sprites/bonus.png differ diff --git a/assets/sprites/bonus.psd b/assets/sprites/bonus.psd new file mode 100644 index 0000000..d10094b Binary files /dev/null and b/assets/sprites/bonus.psd differ diff --git a/assets/sprites/enemies.png b/assets/sprites/enemies.png index 410e792..dbd5db6 100644 Binary files a/assets/sprites/enemies.png and b/assets/sprites/enemies.png differ diff --git a/assets/sprites/enemies.psd b/assets/sprites/enemies.psd index d78a92d..53f6a44 100644 Binary files a/assets/sprites/enemies.psd and b/assets/sprites/enemies.psd differ diff --git a/js/entities/Player.js b/js/entities/Player.js index 26fa331..b8af9e4 100644 --- a/js/entities/Player.js +++ b/js/entities/Player.js @@ -30,11 +30,14 @@ export class Player { this._baseFireInterval = 250; // ms between shots this._isMoving = false; this._isShooting = false; + this.weaponMode = 'default'; // 'default' | 'shotgun' | 'rocket' + this.weaponTimeLeft = 0; // ms remaining for timed weapon this._createAnims(); this._buildSprite(x, y); this._setupKeys(); this.bullets = scene.add.group(); + this.rockets = scene.add.group(); } _createAnims() { @@ -76,6 +79,14 @@ export class Player { this._handleFire(delta); this._updateAnimation(); this._updateBullets(); + this._updateRockets(); + if (this.weaponTimeLeft > 0) { + this.weaponTimeLeft -= delta; + if (this.weaponTimeLeft <= 0) { + this.weaponTimeLeft = 0; + this.weaponMode = 'default'; + } + } } _move() { @@ -105,7 +116,9 @@ export class Player { this._isShooting = ptr.isDown; if (ptr.isDown && this._fireCooldown <= 0) { this._fireCooldown = this._baseFireInterval / this.stats.fireRate; - this._spawnBullet(); + if (this.weaponMode === 'shotgun') this._spawnShotgunBurst(); + else if (this.weaponMode === 'rocket') this._spawnRocket(); + else this._spawnBullet(); } } @@ -145,6 +158,53 @@ export class Player { }); } + _spawnShotgunBurst() { + this.scene.sound.play('sfx-shoot', { volume: 0.4 }); + [-20, -10, 0, 10, 20].forEach(deg => { + const angle = this.facing + Phaser.Math.DegToRad(deg); + const bx = this.x + Math.cos(angle) * GUN_TIP_DIST; + const by = this.y + Math.sin(angle) * GUN_TIP_DIST; + const bullet = this.scene.add.circle(bx, by, BULLET_SIZE, 0xffff00); + this.scene.physics.add.existing(bullet); + bullet.body.setVelocity(Math.cos(angle) * BULLET_SPEED, Math.sin(angle) * BULLET_SPEED); + bullet.damage = this.stats.damage; + this.bullets.add(bullet); + }); + } + + _spawnRocket() { + this.scene.sound.play('sfx-shoot', { volume: 0.5 }); + const bx = this.x + Math.cos(this.facing) * GUN_TIP_DIST; + const by = this.y + Math.sin(this.facing) * GUN_TIP_DIST; + const rocket = this.scene.add.circle(bx, by, 8, 0xff4400); + this.scene.physics.add.existing(rocket); + rocket.body.setVelocity(Math.cos(this.facing) * 400, Math.sin(this.facing) * 400); + rocket.damage = 80; + this.rockets.add(rocket); + } + + _updateRockets() { + const W = this.scene.scale.width; + const H = this.scene.scale.height; + this.rockets.getChildren().forEach(r => { + if (r.x < -20 || r.x > W + 20 || r.y < -20 || r.y > H + 20) { + r.destroy(); + } + }); + } + + applyBonus(type) { + if (type === 'medpack') { + this.hp = Math.min(this.hp + this.stats.maxHp * 0.5, this.stats.maxHp); + } else if (type === 'shotgun') { + this.weaponMode = 'shotgun'; + this.weaponTimeLeft = 20000; + } else if (type === 'rocket') { + this.weaponMode = 'rocket'; + this.weaponTimeLeft = 20000; + } + } + takeDamage(amount) { if (this.invincible) return; const reduced = amount * (1 - Math.min(this.stats.damageReduction, 0.9)); @@ -300,5 +360,6 @@ export class Player { destroy() { this.sprite.destroy(); this.bullets.clear(true, true); + this.rockets.clear(true, true); } } diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index 4d19afa..ab75a2e 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -6,6 +6,7 @@ import { HUD } from '../ui/HUD.js'; import { SkillTreeUI } from '../ui/SkillTreeUI.js'; import { Reticle } from '../ui/Reticle.js'; import { BarrierManager } from '../systems/BarrierManager.js'; +import { BonusManager } from '../systems/BonusManager.js'; const ZONE_PALETTE = [ null, // index 0 unused @@ -42,6 +43,7 @@ export class GameScene extends Phaser.Scene { 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'); + this.load.spritesheet('bonus', './assets/sprites/bonus.png', { frameWidth: 32, frameHeight: 32 }); } create() { @@ -64,6 +66,7 @@ export class GameScene extends Phaser.Scene { this.waveManager.load(this.cache.json.get('zones')); this.hud = new HUD(this, this.player, this.xpSystem); + this.bonusManager = new BonusManager(this, this.player); // Enemy projectile group for collision this._enemyProjectiles = []; @@ -238,7 +241,9 @@ export class GameScene extends Phaser.Scene { this.waveManager.update(delta); this.hud.update(); + this.bonusManager.update(delta); this._checkBulletHits(); + this._checkRocketHits(); this._checkEnemyProjectileHits(); this._pruneEnemyProjectiles(); this._updatePulseEffects(delta); @@ -317,6 +322,41 @@ export class GameScene extends Phaser.Scene { } } + _checkRocketHits() { + const rockets = this.player.rockets.getChildren(); + for (const rocket of rockets) { + if (!rocket.active) continue; + if (this.barrierManager.isPointBlocked(rocket.x, rocket.y)) { + this._explodeRocket(rocket.x, rocket.y, rocket.damage); + rocket.destroy(); + continue; + } + for (const enemy of this.waveManager.enemies) { + if (!enemy.active) continue; + if (Phaser.Math.Distance.Between(rocket.x, rocket.y, enemy.x, enemy.y) < enemy.radius + 8) { + this._explodeRocket(rocket.x, rocket.y, rocket.damage); + rocket.destroy(); + break; + } + } + } + } + + _explodeRocket(x, y, damage) { + const flash = this.add.circle(x, y, 10, 0xff6600, 0.9).setDepth(5); + this.tweens.add({ targets: flash, scaleX: 14, scaleY: 14, alpha: 0, duration: 320, onComplete: () => flash.destroy() }); + const ring = this.add.circle(x, y, 10, 0x000000, 0).setDepth(5); + ring.setStrokeStyle(4, 0xffcc00, 1); + this.tweens.add({ targets: ring, scaleX: 13, scaleY: 13, alpha: 0, duration: 400, onComplete: () => ring.destroy() }); + this.cameras.main.shake(130, 0.009); + const RADIUS = 120; + this.waveManager.enemies.forEach(e => { + if (!e.active) return; + const dist = Phaser.Math.Distance.Between(x, y, e.x, e.y); + if (dist < RADIUS) e.takeDamage(damage * (1 - dist / RADIUS)); + }); + } + _checkEnemyProjectileHits() { this._enemyProjectiles = this._enemyProjectiles.filter(p => p?.active); this._enemyProjectiles.forEach(proj => { @@ -532,6 +572,7 @@ export class GameScene extends Phaser.Scene { this.player?.destroy(); this.waveManager?.reset(); this.hud?.destroy(); + this.bonusManager?.destroy(); this._bgMusic?.stop(); this._bgMusic = null; this._enemyProjectiles = []; diff --git a/js/systems/BonusManager.js b/js/systems/BonusManager.js new file mode 100644 index 0000000..c56efc1 --- /dev/null +++ b/js/systems/BonusManager.js @@ -0,0 +1,117 @@ +const BONUS_LIFETIME_MS = 15000; +const BLINK_START_MS = 10000; // blink when < 5s left (at 10s elapsed of 15s) +const COOLDOWN_PICKUP = 20000; +const COOLDOWN_EXPIRE = 10000; +const FIRST_SPAWN_MS = 20000; +const PICKUP_RADIUS = 32; +const BONUS_TYPES = ['medpack', 'shotgun', 'rocket']; + +const FRAME_INDEX = { medpack: 0, shotgun: 1, rocket: 2 }; +const GLOW_COLOR = { medpack: 0x44ff44, shotgun: 0xff9900, rocket: 0xff3300 }; + +export class BonusManager { + constructor(scene, player) { + this._scene = scene; + this._player = player; + this._countdown = FIRST_SPAWN_MS; + this._active = null; // { sprite, glow, glowTween, blinkTween, type, elapsed } + } + + update(delta) { + if (!this._active) { + this._countdown -= delta; + if (this._countdown <= 0) this._spawn(); + return; + } + + const a = this._active; + a.elapsed += delta; + + // Pickup check + const dist = Phaser.Math.Distance.Between( + a.sprite.x, a.sprite.y, this._player.x, this._player.y + ); + if (dist < PICKUP_RADIUS) { + this._player.applyBonus(a.type); + this._clear(); + this._countdown = COOLDOWN_PICKUP; + return; + } + + // Start blinking for last 5 seconds + if (a.elapsed >= BLINK_START_MS && !a.blinkTween) { + a.blinkTween = this._scene.tweens.add({ + targets: [a.sprite, a.glow], + alpha: 0, + duration: 200, + yoyo: true, + repeat: -1, + }); + } + + // Expiry + if (a.elapsed >= BONUS_LIFETIME_MS) { + this._clear(); + this._countdown = COOLDOWN_EXPIRE; + } + } + + _spawn() { + const type = BONUS_TYPES[Phaser.Math.Between(0, 2)]; + const cell = this._findSpawnCell(); + if (!cell) { + // No valid cell found — retry in 2 seconds + this._countdown = 2000; + return; + } + + const { cx, cy } = cell; + const glow = this._scene.add.circle(cx, cy, 24, GLOW_COLOR[type], 0.4).setDepth(4); + const glowTween = this._scene.tweens.add({ + targets: glow, + scaleX: 1.4, scaleY: 1.4, + alpha: 0.1, + duration: 700, + yoyo: true, + repeat: -1, + }); + + const sprite = this._scene.add.image(cx, cy, 'bonus', FRAME_INDEX[type]).setDepth(5); + + this._active = { sprite, glow, glowTween, blinkTween: null, type, elapsed: 0 }; + } + + _findSpawnCell() { + const W = this._scene.scale.width; + const H = this._scene.scale.height; + const COLS = Math.floor(W / 80); + const ROWS = Math.floor(H / 80); + const candidates = []; + + for (let col = 1; col < COLS - 1; col++) { + for (let row = 1; row < ROWS - 1; row++) { + const cx = col * 80 + 40; + const cy = row * 80 + 40; + if (this._scene.barrierManager?.isPointBlocked(cx, cy)) continue; + if (Phaser.Math.Distance.Between(cx, cy, this._player.x, this._player.y) < 300) continue; + candidates.push({ cx, cy }); + } + } + + return candidates.length ? Phaser.Utils.Array.GetRandom(candidates) : null; + } + + _clear() { + if (!this._active) return; + const a = this._active; + a.blinkTween?.stop(); + a.glowTween?.stop(); + a.sprite?.destroy(); + a.glow?.destroy(); + this._active = null; + } + + destroy() { + this._clear(); + } +} diff --git a/js/ui/HUD.js b/js/ui/HUD.js index 384d06c..d135d1f 100644 --- a/js/ui/HUD.js +++ b/js/ui/HUD.js @@ -39,6 +39,12 @@ export class HUD { this._xpFill = scene.add.rectangle(PAD + XP_W / 2, xpY, XP_W, XP_H, 0x44ffaa).setScrollFactor(0).setDepth(10); this._xpLabel = scene.add.text(PAD, xpY + 8, 'Level 1', { fontSize: '12px', fill: '#aaffcc' }).setScrollFactor(0).setDepth(10); + // --- Weapon timer bar (top-center, hidden by default) --- + const W = scene.scale.width; + this._weaponBarBg = scene.add.rectangle(W / 2, 12, 200, 14, 0x333333).setScrollFactor(0).setDepth(10).setVisible(false); + this._weaponBarFill = scene.add.rectangle(W / 2 - 100, 12, 0, 14, 0xff9900).setScrollFactor(0).setDepth(11).setOrigin(0, 0.5).setVisible(false); + this._weaponLabel = scene.add.text(W / 2, 23, '', { fontSize: '11px', fill: '#ffffff' }).setScrollFactor(0).setDepth(10).setOrigin(0.5, 0).setVisible(false); + // --- Zone/Wave indicator --- this._zoneText = scene.add.text(0, PAD, 'Zone 1 — Wave 1/3', { fontSize: '14px', fill: '#ffffff' }) .setScrollFactor(0).setDepth(10).setOrigin(1, 0); @@ -69,12 +75,28 @@ export class HUD { this._xpFill.width = XP_W * xpRatio; this._xpFill.x = PAD + (XP_W * xpRatio) / 2; this._xpLabel.setText(`Level ${this.xpSystem.level}`); + + // Weapon timer bar + const hasWeapon = this.player.weaponMode !== 'default'; + this._weaponBarBg.setVisible(hasWeapon); + this._weaponBarFill.setVisible(hasWeapon); + this._weaponLabel.setVisible(hasWeapon); + if (hasWeapon) { + const ratio = Math.max(0, this.player.weaponTimeLeft / 20000); + this._weaponBarFill.width = 200 * ratio; + const color = this.player.weaponMode === 'rocket' ? 0xff3300 : 0xff9900; + this._weaponBarFill.setFillStyle(color); + this._weaponLabel.setText(this.player.weaponMode === 'rocket' ? 'ROCKET LAUNCHER' : 'SHOTGUN'); + } } destroy() { this.scene.events.off('wave-start'); this.scene.events.off('zone-clear'); - [this._hpLabel, this._hpBg, this._hpFill, this._xpBg, this._xpFill, this._xpLabel, this._zoneText, ...this._lifeIcons] + [this._hpLabel, this._hpBg, this._hpFill, + this._xpBg, this._xpFill, this._xpLabel, + this._weaponBarBg, this._weaponBarFill, this._weaponLabel, + this._zoneText, ...this._lifeIcons] .forEach(o => o?.destroy()); } }