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._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); } }