192 lines
5.8 KiB
JavaScript
192 lines
5.8 KiB
JavaScript
const TURN_RATE_DEG = 4; // degrees per frame at 60fps
|
||
const BULLET_SPEED = 700;
|
||
const BULLET_DAMAGE = 20;
|
||
const BULLET_SIZE = 5;
|
||
const PLAYER_RADIUS = 16;
|
||
// Sprite faces DOWN at rotation=0; Phaser's 0° is RIGHT, so offset by -90°
|
||
const SPRITE_ROTATION_OFFSET = -Math.PI / 2;
|
||
// Gun tip distance from sprite center (in pixels)
|
||
const GUN_TIP_DIST = 24;
|
||
|
||
export class Player {
|
||
constructor(scene, x, y) {
|
||
this.scene = scene;
|
||
this.facing = 0; // radians
|
||
|
||
// Mutable stat block — modified by skill tree
|
||
this.stats = {
|
||
speed: 200,
|
||
damage: BULLET_DAMAGE,
|
||
fireRate: 1, // multiplier applied to cooldown
|
||
damageReduction: 0, // 0–1 additive
|
||
maxHp: 100,
|
||
};
|
||
|
||
this.hp = this.stats.maxHp;
|
||
this.lives = 3;
|
||
this.invincible = false;
|
||
this._fireCooldown = 0;
|
||
this._baseFireInterval = 250; // ms between shots
|
||
this._isMoving = false;
|
||
this._isShooting = false;
|
||
|
||
this._createAnims();
|
||
this._buildSprite(x, y);
|
||
this._setupKeys();
|
||
this.bullets = scene.add.group();
|
||
}
|
||
|
||
_createAnims() {
|
||
const anims = this.scene.anims;
|
||
if (anims.exists('player-idle')) return; // already created
|
||
|
||
anims.create({ key: 'player-idle', frames: [{ key: 'player', frame: 0 }], frameRate: 1, repeat: -1 });
|
||
anims.create({ key: 'player-walk', frames: anims.generateFrameNumbers('player', { frames: [1, 2] }), frameRate: 8, repeat: -1 });
|
||
anims.create({ key: 'player-idle-gun', frames: [{ key: 'player', frame: 3 }], frameRate: 1, repeat: -1 });
|
||
anims.create({ key: 'player-walk-gun', frames: anims.generateFrameNumbers('player', { frames: [4, 5] }), frameRate: 8, repeat: -1 });
|
||
}
|
||
|
||
_buildSprite(x, y) {
|
||
this.sprite = this.scene.add.sprite(x, y, 'player').setDepth(6);
|
||
this.scene.physics.add.existing(this.sprite);
|
||
this.sprite.body.setCollideWorldBounds(true);
|
||
// Circle hitbox centered in the 48×48 sprite: radius 16, offset 8 from top-left
|
||
this.sprite.body.setCircle(PLAYER_RADIUS, 8, 8);
|
||
this.sprite.play('player-idle');
|
||
}
|
||
|
||
_setupKeys() {
|
||
this.keys = this.scene.input.keyboard.addKeys({
|
||
up: Phaser.Input.Keyboard.KeyCodes.W,
|
||
down: Phaser.Input.Keyboard.KeyCodes.S,
|
||
left: Phaser.Input.Keyboard.KeyCodes.A,
|
||
right: Phaser.Input.Keyboard.KeyCodes.D,
|
||
});
|
||
}
|
||
|
||
get x() { return this.sprite.x; }
|
||
get y() { return this.sprite.y; }
|
||
get active() { return this.sprite?.active ?? false; }
|
||
|
||
update(delta) {
|
||
this._move();
|
||
this._rotateFacing(delta);
|
||
this._handleFire(delta);
|
||
this._updateAnimation();
|
||
this._updateBullets();
|
||
}
|
||
|
||
_move() {
|
||
let dx = 0, dy = 0;
|
||
if (this.keys.left.isDown) dx -= 1;
|
||
if (this.keys.right.isDown) dx += 1;
|
||
if (this.keys.up.isDown) dy -= 1;
|
||
if (this.keys.down.isDown) dy += 1;
|
||
|
||
this._isMoving = dx !== 0 || dy !== 0;
|
||
if (dx !== 0 && dy !== 0) { dx *= 0.707; dy *= 0.707; }
|
||
this.sprite.body.setVelocity(dx * this.stats.speed, dy * this.stats.speed);
|
||
}
|
||
|
||
_rotateFacing(delta) {
|
||
const ptr = this.scene.input.activePointer;
|
||
const targetAngle = Phaser.Math.Angle.Between(this.x, this.y, ptr.worldX, ptr.worldY);
|
||
const maxTurn = Phaser.Math.DegToRad(TURN_RATE_DEG) * (delta / (1000 / 60));
|
||
this.facing = Phaser.Math.Angle.RotateTo(this.facing, targetAngle, maxTurn);
|
||
// Sprite art faces DOWN; offset by -90° so it faces RIGHT at facing=0
|
||
this.sprite.setRotation(this.facing + SPRITE_ROTATION_OFFSET);
|
||
}
|
||
|
||
_handleFire(delta) {
|
||
this._fireCooldown -= delta;
|
||
const ptr = this.scene.input.activePointer;
|
||
this._isShooting = ptr.isDown;
|
||
if (ptr.isDown && this._fireCooldown <= 0) {
|
||
this._fireCooldown = this._baseFireInterval / this.stats.fireRate;
|
||
this._spawnBullet();
|
||
}
|
||
}
|
||
|
||
_updateAnimation() {
|
||
let key;
|
||
if (this._isMoving && this._isShooting) key = 'player-walk-gun';
|
||
else if (this._isMoving) key = 'player-walk';
|
||
else if (this._isShooting) key = 'player-idle-gun';
|
||
else key = 'player-idle';
|
||
|
||
if (this.sprite.anims.currentAnim?.key !== key) {
|
||
this.sprite.play(key);
|
||
}
|
||
}
|
||
|
||
_spawnBullet() {
|
||
const bx = this.x + Math.cos(this.facing) * GUN_TIP_DIST;
|
||
const by = this.y + Math.sin(this.facing) * 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(this.facing) * BULLET_SPEED,
|
||
Math.sin(this.facing) * BULLET_SPEED
|
||
);
|
||
bullet.damage = this.stats.damage;
|
||
this.bullets.add(bullet);
|
||
}
|
||
|
||
_updateBullets() {
|
||
const W = this.scene.scale.width;
|
||
const H = this.scene.scale.height;
|
||
this.bullets.getChildren().forEach(b => {
|
||
if (b.x < -20 || b.x > W + 20 || b.y < -20 || b.y > H + 20) {
|
||
b.destroy();
|
||
}
|
||
});
|
||
}
|
||
|
||
takeDamage(amount) {
|
||
if (this.invincible) return;
|
||
const reduced = amount * (1 - Math.min(this.stats.damageReduction, 0.9));
|
||
this.hp -= reduced;
|
||
|
||
if (this.hp <= 0) {
|
||
this.hp = 0;
|
||
this._loseLife();
|
||
}
|
||
}
|
||
|
||
_loseLife() {
|
||
this.lives--;
|
||
if (this.lives <= 0) {
|
||
this.scene.events.emit('game-over');
|
||
return;
|
||
}
|
||
this.hp = this.stats.maxHp;
|
||
this._startInvincibility();
|
||
}
|
||
|
||
_startInvincibility() {
|
||
this.invincible = true;
|
||
this.scene.tweens.add({
|
||
targets: this.sprite,
|
||
alpha: 0,
|
||
duration: 150,
|
||
yoyo: true,
|
||
repeat: 4,
|
||
onComplete: () => {
|
||
this.sprite.setAlpha(1);
|
||
this.invincible = false;
|
||
}
|
||
});
|
||
}
|
||
|
||
respawn(x, y) {
|
||
this.sprite.setPosition(x, y);
|
||
this.hp = this.stats.maxHp;
|
||
this._startInvincibility();
|
||
}
|
||
|
||
destroy() {
|
||
this.sprite.destroy();
|
||
this.bullets.clear(true, true);
|
||
}
|
||
}
|