diff --git a/assets/sprites/enemies.png b/assets/sprites/enemies.png index e6dfdf4..410e792 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 a9259bb..d78a92d 100644 Binary files a/assets/sprites/enemies.psd and b/assets/sprites/enemies.psd differ diff --git a/js/data/zones.json b/js/data/zones.json index c7cdc7f..d5236b4 100644 --- a/js/data/zones.json +++ b/js/data/zones.json @@ -17,5 +17,48 @@ { "enemies": [{ "type": "ChaseEnemy", "count": 5 }, { "type": "ShooterEnemy", "count": 3 }] }, { "enemies": [{ "type": "ChaseEnemy", "count": 6 }, { "type": "ShooterEnemy", "count": 4 }, { "type": "SwarmerEnemy", "count": 10 }] } ] + }, + { + "id": 3, + "interiorBarriers": 3, + "waves": [ + { "enemies": [{ "type": "ChaseEnemy", "count": 4 }, { "type": "SwarmerEnemy", "count": 6 }, { "type": "SprayerEnemy", "count": 2 }] }, + { "enemies": [{ "type": "ChaseEnemy", "count": 3 }, { "type": "SwarmerEnemy", "count": 8 }, { "type": "BomberEnemy", "count": 2 }] }, + { "enemies": [{ "type": "ChaseEnemy", "count": 4 }, { "type": "ShooterEnemy", "count": 2 }, { "type": "SprayerEnemy", "count": 2 }, { "type": "BomberEnemy", "count": 1 }] }, + { "enemies": [{ "type": "ChaseEnemy", "count": 5 }, { "type": "SprayerEnemy", "count": 3 }, { "type": "BomberEnemy", "count": 3 }, { "type": "SwarmerEnemy", "count": 8 }] } + ] + }, + { + "id": 4, + "interiorBarriers": 4, + "waves": [ + { "enemies": [{ "type": "ChaseEnemy", "count": 6 }, { "type": "SwarmerEnemy", "count": 10 }, { "type": "ShooterEnemy", "count": 2 }, { "type": "SprayerEnemy", "count": 2 }] }, + { "enemies": [{ "type": "ChaseEnemy", "count": 4 }, { "type": "ShooterEnemy", "count": 5 }, { "type": "BomberEnemy", "count": 3 }, { "type": "SwarmerEnemy", "count": 8 }] }, + { "enemies": [{ "type": "ChaseEnemy", "count": 8 }, { "type": "SprayerEnemy", "count": 3 }, { "type": "BomberEnemy", "count": 4 }] }, + { "enemies": [{ "type": "ShooterEnemy", "count": 5 }, { "type": "SprayerEnemy", "count": 4 }, { "type": "SwarmerEnemy", "count": 12 }, { "type": "ChaseEnemy", "count": 3 }] }, + { "enemies": [{ "type": "BomberEnemy", "count": 5 }, { "type": "SprayerEnemy", "count": 4 }, { "type": "ShooterEnemy", "count": 5 }, { "type": "ChaseEnemy", "count": 5 }] } + ] + }, + { + "id": 5, + "interiorBarriers": 5, + "waves": [ + { "enemies": [{ "type": "ChaseEnemy", "count": 8 }, { "type": "SwarmerEnemy", "count": 12 }, { "type": "ShooterEnemy", "count": 4 }, { "type": "SprayerEnemy", "count": 3 }] }, + { "enemies": [{ "type": "ShooterEnemy", "count": 5 }, { "type": "SprayerEnemy", "count": 5 }, { "type": "BomberEnemy", "count": 5 }, { "type": "SwarmerEnemy", "count": 10 }] }, + { "enemies": [{ "type": "ChaseEnemy", "count": 7 }, { "type": "SprayerEnemy", "count": 5 }, { "type": "BomberEnemy", "count": 4 }, { "type": "ShooterEnemy", "count": 3 }] }, + { "enemies": [{ "type": "ChaseEnemy", "count": 10 }, { "type": "ShooterEnemy", "count": 6 }, { "type": "SprayerEnemy", "count": 4 }, { "type": "BomberEnemy", "count": 5 }] }, + { "enemies": [{ "type": "BomberEnemy", "count": 6 }, { "type": "SprayerEnemy", "count": 6 }, { "type": "ShooterEnemy", "count": 5 }, { "type": "SwarmerEnemy", "count": 15 }] } + ] + }, + { + "id": 6, + "interiorBarriers": 5, + "waves": [ + { "enemies": [{ "type": "ChaseEnemy", "count": 10 }, { "type": "SwarmerEnemy", "count": 15 }, { "type": "ShooterEnemy", "count": 5 }, { "type": "SprayerEnemy", "count": 4 }] }, + { "enemies": [{ "type": "ShooterEnemy", "count": 8 }, { "type": "BomberEnemy", "count": 6 }, { "type": "SwarmerEnemy", "count": 15 }, { "type": "ChaseEnemy", "count": 5 }] }, + { "enemies": [{ "type": "SprayerEnemy", "count": 8 }, { "type": "BomberEnemy", "count": 6 }, { "type": "ChaseEnemy", "count": 8 }] }, + { "enemies": [{ "type": "ChaseEnemy", "count": 10 }, { "type": "ShooterEnemy", "count": 8 }, { "type": "SprayerEnemy", "count": 6 }, { "type": "BomberEnemy", "count": 5 }] }, + { "enemies": [{ "type": "BomberEnemy", "count": 8 }, { "type": "SprayerEnemy", "count": 8 }, { "type": "ShooterEnemy", "count": 8 }, { "type": "ChaseEnemy", "count": 8 }, { "type": "SwarmerEnemy", "count": 5 }] } + ] } ] diff --git a/js/entities/enemies/BomberEnemy.js b/js/entities/enemies/BomberEnemy.js new file mode 100644 index 0000000..89622af --- /dev/null +++ b/js/entities/enemies/BomberEnemy.js @@ -0,0 +1,142 @@ +import { BaseEnemy } from './BaseEnemy.js'; + +const EXPLODE_RADIUS = 180; // px — triggers explosion when this close to player +const SHARD_COUNT = 8; +const SHARD_SPEED = 360; // px/s — travels ~250px over SHARD_DURATION +const SHARD_DAMAGE = 15; +const SHARD_DURATION = 700; // ms until shards disappear +const SHARD_HIT_RADIUS = 18; // px — how close a shard must be to damage player +const TICK_MS = 16; + +export class BomberEnemy extends BaseEnemy { + constructor(scene, x, y, player) { + super(scene, x, y, player, { + frameOffset: 12, + radius: 10, + hp: 20, + speed: 130, + xp: 20, + contactDamage: 5, // low — explosion is the main attack + }); + this._offsetAngle = Math.random() * Math.PI * 2; + this._wobble = 0.3 + Math.random() * 0.4; + } + + update(delta) { + super.update(delta); + if (this._dying) return; + + // Swarm-like wobble movement + const t = this.scene.time.now * 0.001; + const baseAngle = Phaser.Math.Angle.Between(this.x, this.y, this.player.x, this.player.y); + const angle = baseAngle + Math.sin(t * 4 + this._offsetAngle) * this._wobble; + this.sprite.body.setVelocity(Math.cos(angle) * this.speed, Math.sin(angle) * this.speed); + + // Proximity check — explode when close enough + const dist = Phaser.Math.Distance.Between(this.x, this.y, this.player.x, this.player.y); + if (dist < EXPLODE_RADIUS) { + this._triggerExplosion(); + } + } + + _triggerExplosion() { + if (this._dying) return; + this._spawnShards(); + this._die(); + } + + _spawnShards() { + const scene = this.scene; + const ox = this.x; + const oy = this.y; + const player = this.player; + const barriers = scene.barrierManager; + + // Burst flash at explosion center + const flash = scene.add.circle(ox, oy, 36, 0xff6600, 0.9).setDepth(25); + scene.tweens.add({ + targets: flash, alpha: 0, scaleX: 2.5, scaleY: 2.5, + duration: 320, onComplete: () => flash.destroy(), + }); + const ring = scene.add.circle(ox, oy, 42, 0xffaa00, 0).setDepth(25) + .setStrokeStyle(3, 0xffaa00, 0.9); + scene.tweens.add({ + targets: ring, scaleX: 2.2, scaleY: 2.2, alpha: 0, + duration: 400, onComplete: () => ring.destroy(), + }); + + scene.cameras.main.shake(180, 0.01); + + // Build shards: each is a Phaser Triangle game object + const shards = []; + for (let i = 0; i < SHARD_COUNT; i++) { + const angle = (i / SHARD_COUNT) * Math.PI * 2; + // Triangle pointing "up" in local space: tip at top, base at bottom + const tri = scene.add.triangle( + ox, oy, + 0, -10, // tip + 7, 7, // base-right + -7, 7, // base-left + 0xff4400, + ).setDepth(22).setRotation(angle); // rotate to face travel direction + + // Add a neon outline + tri.setStrokeStyle(1, 0xffaa00); + + shards.push({ + obj: tri, + vx: Math.cos(angle) * SHARD_SPEED, + vy: Math.sin(angle) * SHARD_SPEED, + rotSpeed: (Math.random() > 0.5 ? 1 : -1) * (6 + Math.random() * 6), // rad/s + alive: true, + }); + } + + // Manual update loop — avoids tween onComplete reliability issues + let elapsed = 0; + const playerDamaged = new Set(); + + const ticker = scene.time.addEvent({ + delay: TICK_MS, + repeat: Math.ceil(SHARD_DURATION / TICK_MS), + callback: () => { + elapsed += TICK_MS; + const dt = TICK_MS / 1000; + const fade = Math.max(0, 1 - elapsed / SHARD_DURATION); + + for (const shard of shards) { + if (!shard.alive) continue; + + shard.obj.x += shard.vx * dt; + shard.obj.y += shard.vy * dt; + shard.obj.rotation += shard.rotSpeed * dt; + shard.obj.alpha = 0.4 + fade * 0.6; + + // Player hit + if (!playerDamaged.has(shard)) { + const d = Phaser.Math.Distance.Between(shard.obj.x, shard.obj.y, player.x, player.y); + if (d < SHARD_HIT_RADIUS + 16) { + player.takeDamage(SHARD_DAMAGE); + playerDamaged.add(shard); + shard.alive = false; + shard.obj.destroy(); + continue; + } + } + + // Interior barrier hit + if (barriers?.destroyInteriorBarrierAt(shard.obj.x, shard.obj.y)) { + shard.alive = false; + shard.obj.destroy(); + continue; + } + } + + if (elapsed >= SHARD_DURATION) { + shards.forEach(s => { if (s.alive) s.obj.destroy(); }); + ticker.remove(); + } + }, + }); + } +} diff --git a/js/entities/enemies/SprayerEnemy.js b/js/entities/enemies/SprayerEnemy.js new file mode 100644 index 0000000..b8b93b2 --- /dev/null +++ b/js/entities/enemies/SprayerEnemy.js @@ -0,0 +1,90 @@ +import { BaseEnemy } from './BaseEnemy.js'; + +const PREFERRED_DIST = 280; +const SHOOT_INTERVAL = 3000; // ms between bursts +const PROJECTILE_SPEED = 175; +const PROJECTILE_DAMAGE = 8; +const SPRAY_COUNT = 5; +const SPRAY_HALF_ARC = Math.PI / 6; // ±30° around aim direction + +export class SprayerEnemy extends BaseEnemy { + constructor(scene, x, y, player) { + super(scene, x, y, player, { + frameOffset: 9, + radius: 15, + hp: 50, + speed: 40, + xp: 25, + contactDamage: 8, + }); + this._shootTimer = SHOOT_INTERVAL * Math.random(); + this.projectiles = scene.add.group(); + } + + update(delta) { + super.update(delta); + if (this._dying) return; + this._strafe(); + this._handleShoot(delta); + this._updateProjectiles(); + } + + _strafe() { + const dist = Phaser.Math.Distance.Between(this.x, this.y, this.player.x, this.player.y); + const angle = Phaser.Math.Angle.Between(this.x, this.y, this.player.x, this.player.y); + let vx = 0, vy = 0; + + if (dist > PREFERRED_DIST + 30) { + vx = Math.cos(angle) * this.speed; + vy = Math.sin(angle) * this.speed; + } else if (dist < PREFERRED_DIST - 30) { + vx = -Math.cos(angle) * this.speed; + vy = -Math.sin(angle) * this.speed; + } else { + const perp = angle + Math.PI / 2; + vx = Math.cos(perp) * this.speed; + vy = Math.sin(perp) * this.speed; + } + this.sprite.body.setVelocity(vx, vy); + } + + _handleShoot(delta) { + this._shootTimer -= delta; + if (this._shootTimer <= 0) { + this._shootTimer = SHOOT_INTERVAL; + this._fireSpray(); + } + } + + _fireSpray() { + const baseAngle = Phaser.Math.Angle.Between(this.x, this.y, this.player.x, this.player.y); + for (let i = 0; i < SPRAY_COUNT; i++) { + // Spread bullets evenly across the arc: indices 0..4 map to -1..+1 + const t = SPRAY_COUNT === 1 ? 0 : (i / (SPRAY_COUNT - 1)) * 2 - 1; + const angle = baseAngle + t * SPRAY_HALF_ARC; + + const proj = this.scene.add.circle(this.x, this.y, 5, 0x00ffaa); + this.scene.physics.add.existing(proj); + proj.body.setVelocity( + Math.cos(angle) * PROJECTILE_SPEED, + Math.sin(angle) * PROJECTILE_SPEED, + ); + proj.damage = PROJECTILE_DAMAGE; + this.projectiles.add(proj); + this.scene.events.emit('enemy-projectile-spawned', proj); + } + } + + _updateProjectiles() { + const W = this.scene.scale.width; + const H = this.scene.scale.height; + this.projectiles.getChildren().forEach(p => { + if (p.x < -30 || p.x > W + 30 || p.y < -30 || p.y > H + 30) p.destroy(); + }); + } + + destroy() { + this.projectiles.clear(true, true); + super.destroy(); + } +} diff --git a/js/systems/BarrierManager.js b/js/systems/BarrierManager.js index 1ec70d9..28ec99e 100644 --- a/js/systems/BarrierManager.js +++ b/js/systems/BarrierManager.js @@ -105,6 +105,40 @@ export class BarrierManager { return false; } + /** + * Destroy the interior barrier at (x,y) if one exists. Returns true if destroyed. + * Used by BomberEnemy shards. + */ + destroyInteriorBarrierAt(x, y) { + for (let i = 0; i < this._interiorSegs.length; i++) { + const seg = this._interiorSegs[i]; + const r = seg.rect; + if (x >= r.x - r.w / 2 && x <= r.x + r.w / 2 && + y >= r.y - r.h / 2 && y <= r.y + r.h / 2) { + + // Visual: flash and shatter + if (seg.gfx?.active) { + this.scene.tweens.add({ + targets: seg.gfx, + alpha: 0, scaleX: 1.8, scaleY: 1.8, + duration: 250, + onComplete: () => seg.gfx.destroy(), + }); + } + seg.bodyImg?.destroy(); + seg.bodyImg2?.destroy(); + + this._interiorSegs.splice(i, 1); + this._allRects = this._allRects.filter( + rect => !(rect.x === r.x && rect.y === r.y), + ); + this._glitchTargets = this._glitchTargets.filter(g => g !== seg.gfx); + return true; + } + } + return false; + } + /** * Animate edge barriers blinking in over ~5s, then enable their physics. * @param {Function} onTrapped - called if player is inside an edge barrier when it solidifies @@ -240,10 +274,10 @@ export class BarrierManager { gfx.x = def.x; gfx.y = def.y; - this._makeBody(this._staticGroup, def.x, def.y, def.w, def.h); - this._makeBody(this._interiorStaticGroup, def.x, def.y, def.w, def.h); + const bodyImg = this._makeBody(this._staticGroup, def.x, def.y, def.w, def.h); + const bodyImg2 = this._makeBody(this._interiorStaticGroup, def.x, def.y, def.w, def.h); - this._interiorSegs.push({ gfx, rect: def }); + this._interiorSegs.push({ gfx, bodyImg, bodyImg2, rect: def }); this._allRects.push({ x: def.x, y: def.y, w: def.w, h: def.h }); this._glitchTargets.push(gfx); } diff --git a/js/systems/WaveManager.js b/js/systems/WaveManager.js index fabf26a..14ac5e2 100644 --- a/js/systems/WaveManager.js +++ b/js/systems/WaveManager.js @@ -1,8 +1,10 @@ import { ChaseEnemy } from '../entities/enemies/ChaseEnemy.js'; import { ShooterEnemy } from '../entities/enemies/ShooterEnemy.js'; import { SwarmerEnemy } from '../entities/enemies/SwarmerEnemy.js'; +import { SprayerEnemy } from '../entities/enemies/SprayerEnemy.js'; +import { BomberEnemy } from '../entities/enemies/BomberEnemy.js'; -const ENEMY_CLASSES = { ChaseEnemy, ShooterEnemy, SwarmerEnemy }; +const ENEMY_CLASSES = { ChaseEnemy, ShooterEnemy, SwarmerEnemy, SprayerEnemy, BomberEnemy }; const SPAWN_MARGIN = 40; export class WaveManager {