refactor(enemies): replace geometric bodies with sprite-based rendering

Replace simple circle graphics with sprite-based enemies using a shared spritesheet. This change introduces:
- A `frameOffset` config to select specific enemy types from the spritesheet.
- An animation system for walking cycles (2-frame loop) and a static death frame.
- A refined hitbox setup using `setCircle` centered within 48x48 frames.
- A visual death sequence: stop movement, show death frame, wait 2s, then fade out.
- HP bar positioning adjusted to account for the new sprite height.

Additionally:
- Added a new `Reticle` UI component that tracks the mouse cursor with rotating rings and hides the OS cursor.
- Updated `GameScene` to load the enemy spritesheet and manage the reticle lifecycle.
This commit is contained in:
Brian Fertig 2026-03-07 15:55:49 -07:00
parent ea501968e3
commit 7da6459abe
6 changed files with 150 additions and 30 deletions

View File

@ -1,15 +1,8 @@
const HP_BAR_WIDTH = 30;
const HP_BAR_HEIGHT = 4;
const HP_BAR_OFFSET_Y = -20;
const HP_BAR_OFFSET_Y = -30;
export class BaseEnemy {
/**
* @param {Phaser.Scene} scene
* @param {number} x
* @param {number} y
* @param {object} player - Player instance
* @param {object} config - { color, radius, hp, speed, xp, contactDamage }
*/
constructor(scene, x, y, player, config) {
this.scene = scene;
this.player = player;
@ -20,23 +13,50 @@ export class BaseEnemy {
this.xp = config.xp;
this.contactDamage = config.contactDamage ?? 10;
this.radius = config.radius ?? 14;
this.frameOffset = config.frameOffset ?? 0;
this.body = scene.add.circle(x, y, this.radius, config.color ?? 0xff4444);
scene.physics.add.existing(this.body);
this.body.body.setCircle(this.radius);
// HP bar
this._hpBg = scene.add.rectangle(x, y + HP_BAR_OFFSET_Y, HP_BAR_WIDTH, HP_BAR_HEIGHT, 0x333333);
this._hpFill = scene.add.rectangle(x, y + HP_BAR_OFFSET_Y, HP_BAR_WIDTH, HP_BAR_HEIGHT, 0x00ff44);
this._dying = false;
this._contactTimer = 0;
this._createAnims();
this._buildSprite(x, y);
this._buildHpBar(x, y);
}
get x() { return this.body.x; }
get y() { return this.body.y; }
get active() { return this.body?.active ?? false; }
_createAnims() {
const anims = this.scene.anims;
const key = `enemy-walk-${this.frameOffset}`;
if (!anims.exists(key)) {
anims.create({
key,
frames: anims.generateFrameNumbers('enemies', { frames: [this.frameOffset, this.frameOffset + 1] }),
frameRate: 6,
repeat: -1,
});
}
}
_buildSprite(x, y) {
this.sprite = this.scene.add.sprite(x, y, 'enemies', this.frameOffset);
this.scene.physics.add.existing(this.sprite);
// Circle hitbox centered in the 48×48 frame
const offset = 24 - this.radius;
this.sprite.body.setCircle(this.radius, offset, offset);
this.sprite.play(`enemy-walk-${this.frameOffset}`);
}
_buildHpBar(x, y) {
this._hpBg = this.scene.add.rectangle(x, y + HP_BAR_OFFSET_Y, HP_BAR_WIDTH, HP_BAR_HEIGHT, 0x333333);
this._hpFill = this.scene.add.rectangle(x, y + HP_BAR_OFFSET_Y, HP_BAR_WIDTH, HP_BAR_HEIGHT, 0x00ff44);
}
get x() { return this.sprite.x; }
get y() { return this.sprite.y; }
/** False while dying so WaveManager stops tracking and wave-clear can fire. */
get active() { return !this._dying && (this.sprite?.active ?? false); }
takeDamage(amount) {
if (this._dying) return;
this.hp -= amount;
this._updateHpBar();
if (this.hp <= 0) this._die();
@ -57,11 +77,32 @@ export class BaseEnemy {
}
_die() {
this._dying = true;
this.scene.events.emit('enemy-killed', { xp: this.xp, x: this.x, y: this.y });
this.destroy();
// Stop movement and hide HP bar immediately
this.sprite.body.setVelocity(0, 0);
this._hpBg.destroy();
this._hpFill.destroy();
this._hpBg = null;
this._hpFill = null;
// Show death frame
this.sprite.anims.stop();
this.sprite.setFrame(this.frameOffset + 2);
// After 2 seconds, fade out and destroy
this.scene.time.delayedCall(2000, () => {
if (!this.sprite?.active) return;
this.scene.tweens.add({
targets: this.sprite,
alpha: 0,
duration: 400,
onComplete: () => this.sprite?.destroy(),
});
});
}
/** Check contact damage with player. Call from subclass update(). */
_checkContact(delta) {
this._contactTimer -= delta;
if (this._contactTimer > 0) return;
@ -69,17 +110,18 @@ export class BaseEnemy {
const dist = Phaser.Math.Distance.Between(this.x, this.y, this.player.x, this.player.y);
if (dist < this.radius + 16) {
this.player.takeDamage(this.contactDamage);
this._contactTimer = 800; // ms between contact hits
this._contactTimer = 800;
}
}
update(delta) {
if (this._dying) return;
this._syncBarPosition();
this._checkContact(delta);
}
destroy() {
this.body?.destroy();
this.sprite?.destroy();
this._hpBg?.destroy();
this._hpFill?.destroy();
}

View File

@ -3,7 +3,7 @@ import { BaseEnemy } from './BaseEnemy.js';
export class ChaseEnemy extends BaseEnemy {
constructor(scene, x, y, player) {
super(scene, x, y, player, {
color: 0xff4444,
frameOffset: 0,
radius: 14,
hp: 40,
speed: 80,
@ -15,7 +15,7 @@ export class ChaseEnemy extends BaseEnemy {
update(delta) {
super.update(delta);
const angle = Phaser.Math.Angle.Between(this.x, this.y, this.player.x, this.player.y);
this.body.body.setVelocity(
this.sprite.body.setVelocity(
Math.cos(angle) * this.speed,
Math.sin(angle) * this.speed
);

View File

@ -9,7 +9,7 @@ const PROJECTILE_COLOR = 0xff00ff;
export class ShooterEnemy extends BaseEnemy {
constructor(scene, x, y, player) {
super(scene, x, y, player, {
color: 0xaa44ff,
frameOffset: 6,
radius: 16,
hp: 60,
speed: 50,
@ -46,7 +46,7 @@ export class ShooterEnemy extends BaseEnemy {
vx = Math.cos(perp) * this.speed;
vy = Math.sin(perp) * this.speed;
}
this.body.body.setVelocity(vx, vy);
this.sprite.body.setVelocity(vx, vy);
}
_handleShoot(delta) {

View File

@ -3,7 +3,7 @@ import { BaseEnemy } from './BaseEnemy.js';
export class SwarmerEnemy extends BaseEnemy {
constructor(scene, x, y, player) {
super(scene, x, y, player, {
color: 0xff9900,
frameOffset: 3,
radius: 8,
hp: 15,
speed: 160,
@ -20,7 +20,7 @@ export class SwarmerEnemy extends BaseEnemy {
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 * 3 + this._offsetAngle) * this._wobble;
this.body.body.setVelocity(
this.sprite.body.setVelocity(
Math.cos(angle) * this.speed,
Math.sin(angle) * this.speed
);

View File

@ -4,6 +4,7 @@ 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';
export class GameScene extends Phaser.Scene {
constructor() {
@ -13,7 +14,8 @@ export class GameScene extends Phaser.Scene {
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('player', './assets/sprites/player.png', { frameWidth: 48, frameHeight: 48 });
this.load.spritesheet('enemies', './assets/sprites/enemies.png', { frameWidth: 48, frameHeight: 48 });
}
create() {
@ -53,6 +55,8 @@ export class GameScene extends Phaser.Scene {
this.events.on('zone-waves-complete', () => this._startZoneExit());
this.reticle = new Reticle(this);
this.waveManager.start();
}
@ -72,6 +76,8 @@ export class GameScene extends Phaser.Scene {
}
update(time, delta) {
this.reticle?.update(delta);
if (!this.player || this._frozen) return;
this.player.update(delta);
@ -283,6 +289,7 @@ export class GameScene extends Phaser.Scene {
shutdown() {
this.events.removeAllListeners();
this.reticle?.destroy();
this._hideExitArrows();
this._waitingForExit = false;
this.player?.destroy();

71
js/ui/Reticle.js Normal file
View File

@ -0,0 +1,71 @@
// Ring configs: drawn once, rotated each frame
const RINGS = [
{ radius: 34, segments: 4, gapDeg: 10, lineWidth: 2, color: 0x00ffff, alpha: 0.65, speed: 0.40 },
{ radius: 22, segments: 3, gapDeg: 18, lineWidth: 1.5, color: 0x44aaff, alpha: 0.80, speed: -0.85 },
{ radius: 12, segments: 6, gapDeg: 8, lineWidth: 1.5, color: 0xffffff, alpha: 0.90, speed: 1.70 },
];
export class Reticle {
constructor(scene) {
this.scene = scene;
// Hide the native OS cursor over the canvas
scene.game.canvas.style.cursor = 'none';
this._angles = RINGS.map(() => 0);
// Container holds all rings; drawn in screen-space at high depth
this._container = scene.add.container(640, 360).setDepth(200);
// Build one Graphics object per ring, drawn once
this._ringGfx = RINGS.map(cfg => {
const g = scene.add.graphics();
this._drawRing(g, cfg);
this._container.add(g);
return g;
});
// Center: small dot + short crosshair lines (fixed, not rotating)
const center = scene.add.graphics();
center.fillStyle(0xffffff, 1);
center.fillCircle(0, 0, 2);
center.lineStyle(1, 0xffffff, 0.7);
const L = 7, GAP = 4;
[[-L, 0, -GAP, 0], [GAP, 0, L, 0], [0, -L, 0, -GAP], [0, GAP, 0, L]].forEach(([x1, y1, x2, y2]) => {
center.beginPath();
center.moveTo(x1, y1);
center.lineTo(x2, y2);
center.strokePath();
});
this._container.add(center);
}
_drawRing(g, { radius, segments, gapDeg, lineWidth, color, alpha }) {
g.lineStyle(lineWidth, color, alpha);
const gapAngle = Phaser.Math.DegToRad(gapDeg);
const arcSpan = (Math.PI * 2 - segments * gapAngle) / segments;
for (let i = 0; i < segments; i++) {
const start = i * (arcSpan + gapAngle);
g.beginPath();
g.arc(0, 0, radius, start, start + arcSpan, false);
g.strokePath();
}
}
/** Call every frame from GameScene.update(), even while frozen. */
update(delta) {
const ptr = this.scene.input.activePointer;
this._container.setPosition(ptr.x, ptr.y);
const dt = delta / 1000;
RINGS.forEach((cfg, i) => {
this._angles[i] += cfg.speed * dt;
this._ringGfx[i].setRotation(this._angles[i]);
});
}
destroy() {
this._container.destroy();
this.scene.game.canvas.style.cursor = 'default';
}
}