overrun/js/entities/Player.js

303 lines
8.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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, // 01 additive
maxHp: 100,
};
this.hp = this.stats.maxHp;
this.lives = 3;
this.invincible = false;
this._dead = 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) {
if (this._dead) return;
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() {
this.scene.sound.play('sfx-shoot', { volume: 0.4 });
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._hitFlash(true);
this._loseLife();
} else {
this._hitFlash(false);
}
}
_hitFlash(isLifeLost) {
this.scene.sound.play('sfx-take-damage', { volume: 0.5 });
const scene = this.scene;
const W = scene.scale.width;
const H = scene.scale.height;
scene.cameras.main.shake(isLifeLost ? 300 : 80, isLifeLost ? 0.016 : 0.005);
const alpha = isLifeLost ? 0.55 : 0.28;
const duration = isLifeLost ? 400 : 220;
const flash = scene.add.rectangle(W / 2, H / 2, W, H, 0xff0000, alpha).setDepth(49);
scene.tweens.add({ targets: flash, alpha: 0, duration, onComplete: () => flash.destroy() });
if (!isLifeLost) {
this.sprite.setTint(0xff4444);
scene.time.delayedCall(100, () => { if (this.sprite?.active) this.sprite.clearTint(); });
}
}
_loseLife() {
const livesBeforeDeath = this.lives;
this.lives--;
if (this.lives <= 0) {
this.scene.events.emit('game-over');
return;
}
this.scene.sound.play('sfx-death', { volume: 0.6 });
this.hp = this.stats.maxHp;
this.invincible = true;
this._dead = true;
this.sprite.body.setVelocity(0, 0);
this.sprite.anims.stop();
// Spin and fade out
this.scene.tweens.add({
targets: this.sprite,
angle: '+=720',
alpha: 0,
duration: 600,
ease: 'Power2',
onComplete: () => {
const W = this.scene.scale.width;
const H = this.scene.scale.height;
this.sprite.setPosition(W / 2, H / 2);
this.sprite.setAngle(0);
this.sprite.setAlpha(1);
this._dead = false;
this.scene.sound.play('sfx-new-life', { volume: 0.6 });
this._startInvincibility();
},
});
this._showLivesOverlay(livesBeforeDeath, this.lives);
}
_startInvincibility() {
this.invincible = true;
// Blink for ~5 seconds: 17 cycles × 300ms = 5.1s
this.scene.tweens.add({
targets: this.sprite,
alpha: 0,
duration: 150,
yoyo: true,
repeat: 16,
onComplete: () => {
this.sprite.setAlpha(1);
this.invincible = false;
},
});
}
_showLivesOverlay(oldLives, newLives) {
const scene = this.scene;
const W = scene.scale.width;
const H = scene.scale.height;
const cx = W / 2;
const cy = H * 0.3;
const baseStyle = {
fontFamily: 'FutureImperfect',
fontSize: '52px',
stroke: '#000000', strokeThickness: 6,
};
const label = scene.add.text(cx, cy, 'Lives: ', { ...baseStyle, fill: '#ffffff' })
.setOrigin(1, 0.5).setDepth(60);
const oldText = scene.add.text(cx, cy, String(oldLives), { ...baseStyle, fill: '#ff4444' })
.setOrigin(0, 0.5).setDepth(60);
// Shake old count, then fade it out, then show new count
scene.tweens.add({
targets: oldText,
x: oldText.x + 12,
duration: 50,
yoyo: true,
repeat: 7,
delay: 400,
onComplete: () => {
scene.tweens.add({
targets: oldText,
alpha: 0,
duration: 180,
onComplete: () => {
oldText.destroy();
const newText = scene.add.text(cx, cy, String(newLives), { ...baseStyle, fill: '#44ff88' })
.setOrigin(0, 0.5).setDepth(60).setAlpha(0);
scene.tweens.add({
targets: newText,
alpha: 1,
duration: 200,
onComplete: () => {
scene.time.delayedCall(900, () => {
scene.tweens.add({
targets: [label, newText],
alpha: 0,
duration: 1200,
onComplete: () => { label.destroy(); newText.destroy(); },
});
});
},
});
},
});
},
});
}
respawn(x, y) {
this.sprite.setPosition(x, y);
this.hp = this.stats.maxHp;
this._startInvincibility();
}
destroy() {
this.sprite.destroy();
this.bullets.clear(true, true);
}
}