overrun/js/entities/Player.js

405 lines
13 KiB
JavaScript
Raw Permalink 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.weaponMode = 'default'; // 'default' | 'shotgun' | 'rocket' | 'fourway' | 'stimulant'
this.weaponTimeLeft = 0; // ms remaining for timed weapon
this.weaponDuration = 0; // initial duration of current weapon (for HUD ratio)
this._stimulantTickTimer = 0;
this._createAnims();
this._buildSprite(x, y);
this._setupKeys();
this.bullets = scene.add.group();
this.rockets = 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();
this._updateRockets();
if (this.weaponTimeLeft > 0) {
this.weaponTimeLeft -= delta;
if (this.weaponMode === 'stimulant') {
this._stimulantTickTimer -= delta;
if (this._stimulantTickTimer <= 0) {
this._stimulantTickTimer = 3000;
this.hp = Math.min(this.hp + this.stats.maxHp * 0.1, this.stats.maxHp);
}
}
if (this.weaponTimeLeft <= 0) {
this.weaponTimeLeft = 0;
this.weaponMode = 'default';
}
}
}
_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;
if (this.weaponMode === 'shotgun') this._spawnShotgunBurst();
else if (this.weaponMode === 'rocket') this._spawnRocket();
else if (this.weaponMode === 'fourway') this._spawnFourWayBurst();
else 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();
}
});
}
_spawnShotgunBurst() {
this.scene.sound.play('sfx-shoot', { volume: 0.4 });
[-20, -10, 0, 10, 20].forEach(deg => {
const angle = this.facing + Phaser.Math.DegToRad(deg);
const bx = this.x + Math.cos(angle) * GUN_TIP_DIST;
const by = this.y + Math.sin(angle) * 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(angle) * BULLET_SPEED, Math.sin(angle) * BULLET_SPEED);
bullet.damage = this.stats.damage;
this.bullets.add(bullet);
});
}
_spawnFourWayBurst() {
this.scene.sound.play('sfx-shoot', { volume: 0.4 });
[0, Math.PI / 2, Math.PI, Math.PI * 1.5].forEach(offset => {
const angle = this.facing + offset;
const bx = this.x + Math.cos(angle) * GUN_TIP_DIST;
const by = this.y + Math.sin(angle) * 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(angle) * BULLET_SPEED, Math.sin(angle) * BULLET_SPEED);
bullet.damage = this.stats.damage;
this.bullets.add(bullet);
});
}
_spawnRocket() {
this.scene.sound.play('sfx-shoot', { volume: 0.5 });
const bx = this.x + Math.cos(this.facing) * GUN_TIP_DIST;
const by = this.y + Math.sin(this.facing) * GUN_TIP_DIST;
const rocket = this.scene.add.circle(bx, by, 8, 0xff4400);
this.scene.physics.add.existing(rocket);
rocket.body.setVelocity(Math.cos(this.facing) * 400, Math.sin(this.facing) * 400);
rocket.damage = 80;
this.rockets.add(rocket);
}
_updateRockets() {
const W = this.scene.scale.width;
const H = this.scene.scale.height;
this.rockets.getChildren().forEach(r => {
if (r.x < -20 || r.x > W + 20 || r.y < -20 || r.y > H + 20) {
r.destroy();
}
});
}
get xpMultiplier() {
return this.weaponMode === 'stimulant' ? 2 : 1;
}
applyBonus(type) {
if (type === 'medpack') {
this.hp = Math.min(this.hp + this.stats.maxHp * 0.5, this.stats.maxHp);
} else if (type === 'shotgun') {
this.weaponMode = 'shotgun';
this.weaponTimeLeft = 20000;
this.weaponDuration = 20000;
} else if (type === 'rocket') {
this.weaponMode = 'rocket';
this.weaponTimeLeft = 20000;
this.weaponDuration = 20000;
} else if (type === 'fourway') {
this.weaponMode = 'fourway';
this.weaponTimeLeft = 20000;
this.weaponDuration = 20000;
} else if (type === 'stimulant') {
this.weaponMode = 'stimulant';
this.weaponTimeLeft = 15000;
this.weaponDuration = 15000;
this._stimulantTickTimer = 3000;
}
}
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.scene.events.emit('player-died');
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;
this.scene.events.emit('player-respawned');
},
});
}
_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);
this.rockets.clear(true, true);
}
}