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:
parent
ea501968e3
commit
7da6459abe
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue