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