Add bonus system with medpacks, shotgun, and rocket launcher power-ups
This commit is contained in:
parent
840f6788ab
commit
145341d992
Binary file not shown.
|
After Width: | Height: | Size: 66 KiB |
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 4.5 KiB |
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 47 KiB |
Binary file not shown.
|
|
@ -30,11 +30,14 @@ export class Player {
|
||||||
this._baseFireInterval = 250; // ms between shots
|
this._baseFireInterval = 250; // ms between shots
|
||||||
this._isMoving = false;
|
this._isMoving = false;
|
||||||
this._isShooting = false;
|
this._isShooting = false;
|
||||||
|
this.weaponMode = 'default'; // 'default' | 'shotgun' | 'rocket'
|
||||||
|
this.weaponTimeLeft = 0; // ms remaining for timed weapon
|
||||||
|
|
||||||
this._createAnims();
|
this._createAnims();
|
||||||
this._buildSprite(x, y);
|
this._buildSprite(x, y);
|
||||||
this._setupKeys();
|
this._setupKeys();
|
||||||
this.bullets = scene.add.group();
|
this.bullets = scene.add.group();
|
||||||
|
this.rockets = scene.add.group();
|
||||||
}
|
}
|
||||||
|
|
||||||
_createAnims() {
|
_createAnims() {
|
||||||
|
|
@ -76,6 +79,14 @@ export class Player {
|
||||||
this._handleFire(delta);
|
this._handleFire(delta);
|
||||||
this._updateAnimation();
|
this._updateAnimation();
|
||||||
this._updateBullets();
|
this._updateBullets();
|
||||||
|
this._updateRockets();
|
||||||
|
if (this.weaponTimeLeft > 0) {
|
||||||
|
this.weaponTimeLeft -= delta;
|
||||||
|
if (this.weaponTimeLeft <= 0) {
|
||||||
|
this.weaponTimeLeft = 0;
|
||||||
|
this.weaponMode = 'default';
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_move() {
|
_move() {
|
||||||
|
|
@ -105,7 +116,9 @@ export class Player {
|
||||||
this._isShooting = ptr.isDown;
|
this._isShooting = ptr.isDown;
|
||||||
if (ptr.isDown && this._fireCooldown <= 0) {
|
if (ptr.isDown && this._fireCooldown <= 0) {
|
||||||
this._fireCooldown = this._baseFireInterval / this.stats.fireRate;
|
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) {
|
takeDamage(amount) {
|
||||||
if (this.invincible) return;
|
if (this.invincible) return;
|
||||||
const reduced = amount * (1 - Math.min(this.stats.damageReduction, 0.9));
|
const reduced = amount * (1 - Math.min(this.stats.damageReduction, 0.9));
|
||||||
|
|
@ -300,5 +360,6 @@ export class Player {
|
||||||
destroy() {
|
destroy() {
|
||||||
this.sprite.destroy();
|
this.sprite.destroy();
|
||||||
this.bullets.clear(true, true);
|
this.bullets.clear(true, true);
|
||||||
|
this.rockets.clear(true, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { HUD } from '../ui/HUD.js';
|
||||||
import { SkillTreeUI } from '../ui/SkillTreeUI.js';
|
import { SkillTreeUI } from '../ui/SkillTreeUI.js';
|
||||||
import { Reticle } from '../ui/Reticle.js';
|
import { Reticle } from '../ui/Reticle.js';
|
||||||
import { BarrierManager } from '../systems/BarrierManager.js';
|
import { BarrierManager } from '../systems/BarrierManager.js';
|
||||||
|
import { BonusManager } from '../systems/BonusManager.js';
|
||||||
|
|
||||||
const ZONE_PALETTE = [
|
const ZONE_PALETTE = [
|
||||||
null, // index 0 unused
|
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-shooter', './assets/fx/EnemyShooterDeath.mp3');
|
||||||
this.load.audio('sfx-death-sprayer', './assets/fx/EnemySprayerDeath.mp3');
|
this.load.audio('sfx-death-sprayer', './assets/fx/EnemySprayerDeath.mp3');
|
||||||
this.load.audio('sfx-death-bomber', './assets/fx/EnemyBomberDeath.mp3');
|
this.load.audio('sfx-death-bomber', './assets/fx/EnemyBomberDeath.mp3');
|
||||||
|
this.load.spritesheet('bonus', './assets/sprites/bonus.png', { frameWidth: 32, frameHeight: 32 });
|
||||||
}
|
}
|
||||||
|
|
||||||
create() {
|
create() {
|
||||||
|
|
@ -64,6 +66,7 @@ export class GameScene extends Phaser.Scene {
|
||||||
this.waveManager.load(this.cache.json.get('zones'));
|
this.waveManager.load(this.cache.json.get('zones'));
|
||||||
|
|
||||||
this.hud = new HUD(this, this.player, this.xpSystem);
|
this.hud = new HUD(this, this.player, this.xpSystem);
|
||||||
|
this.bonusManager = new BonusManager(this, this.player);
|
||||||
|
|
||||||
// Enemy projectile group for collision
|
// Enemy projectile group for collision
|
||||||
this._enemyProjectiles = [];
|
this._enemyProjectiles = [];
|
||||||
|
|
@ -238,7 +241,9 @@ export class GameScene extends Phaser.Scene {
|
||||||
this.waveManager.update(delta);
|
this.waveManager.update(delta);
|
||||||
this.hud.update();
|
this.hud.update();
|
||||||
|
|
||||||
|
this.bonusManager.update(delta);
|
||||||
this._checkBulletHits();
|
this._checkBulletHits();
|
||||||
|
this._checkRocketHits();
|
||||||
this._checkEnemyProjectileHits();
|
this._checkEnemyProjectileHits();
|
||||||
this._pruneEnemyProjectiles();
|
this._pruneEnemyProjectiles();
|
||||||
this._updatePulseEffects(delta);
|
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() {
|
_checkEnemyProjectileHits() {
|
||||||
this._enemyProjectiles = this._enemyProjectiles.filter(p => p?.active);
|
this._enemyProjectiles = this._enemyProjectiles.filter(p => p?.active);
|
||||||
this._enemyProjectiles.forEach(proj => {
|
this._enemyProjectiles.forEach(proj => {
|
||||||
|
|
@ -532,6 +572,7 @@ export class GameScene extends Phaser.Scene {
|
||||||
this.player?.destroy();
|
this.player?.destroy();
|
||||||
this.waveManager?.reset();
|
this.waveManager?.reset();
|
||||||
this.hud?.destroy();
|
this.hud?.destroy();
|
||||||
|
this.bonusManager?.destroy();
|
||||||
this._bgMusic?.stop();
|
this._bgMusic?.stop();
|
||||||
this._bgMusic = null;
|
this._bgMusic = null;
|
||||||
this._enemyProjectiles = [];
|
this._enemyProjectiles = [];
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
24
js/ui/HUD.js
24
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._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);
|
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 ---
|
// --- Zone/Wave indicator ---
|
||||||
this._zoneText = scene.add.text(0, PAD, 'Zone 1 — Wave 1/3', { fontSize: '14px', fill: '#ffffff' })
|
this._zoneText = scene.add.text(0, PAD, 'Zone 1 — Wave 1/3', { fontSize: '14px', fill: '#ffffff' })
|
||||||
.setScrollFactor(0).setDepth(10).setOrigin(1, 0);
|
.setScrollFactor(0).setDepth(10).setOrigin(1, 0);
|
||||||
|
|
@ -69,12 +75,28 @@ export class HUD {
|
||||||
this._xpFill.width = XP_W * xpRatio;
|
this._xpFill.width = XP_W * xpRatio;
|
||||||
this._xpFill.x = PAD + (XP_W * xpRatio) / 2;
|
this._xpFill.x = PAD + (XP_W * xpRatio) / 2;
|
||||||
this._xpLabel.setText(`Level ${this.xpSystem.level}`);
|
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() {
|
destroy() {
|
||||||
this.scene.events.off('wave-start');
|
this.scene.events.off('wave-start');
|
||||||
this.scene.events.off('zone-clear');
|
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());
|
.forEach(o => o?.destroy());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue