Add bonus system with medpacks, shotgun, and rocket launcher power-ups

This commit is contained in:
Brian Fertig 2026-03-08 18:57:55 -06:00
parent 840f6788ab
commit 145341d992
10 changed files with 243 additions and 2 deletions

BIN
assets/sprites/_enemies.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

BIN
assets/sprites/_enemies.psd Normal file

Binary file not shown.

BIN
assets/sprites/bonus.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

BIN
assets/sprites/bonus.psd Normal file

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

View File

@ -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);
}
}

View File

@ -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 = [];

117
js/systems/BonusManager.js Normal file
View File

@ -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();
}
}

View File

@ -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());
}
}