From 04103573d0ef63c4d8d8f28b84b9caa5a645fc22 Mon Sep 17 00:00:00 2001 From: Admin Date: Thu, 17 Sep 2026 21:09:44 +0000 Subject: [PATCH] Add escalating pirate waves and auto reset --- src/game-v4.js | 520 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 520 insertions(+) create mode 100644 src/game-v4.js diff --git a/src/game-v4.js b/src/game-v4.js new file mode 100644 index 0000000..f108a08 --- /dev/null +++ b/src/game-v4.js @@ -0,0 +1,520 @@ +import Phaser from 'phaser'; +import './style.css'; +import airshipSheetUrl from './assets/airships.svg?url'; +import explosionSheetUrl from './assets/explosions.svg?url'; + +const WORLD_SIZE = 2400; +const PLAYER_MAX_SPEED = 180; +const PLAYER_REVERSE_SPEED = -55; +const PLAYER_ACCELERATION = 85; +const PLAYER_BRAKE = 110; +const PLAYER_TURN_RATE = 72; +const PIRATE_SPEED = 118; +const PIRATE_TURN_RATE = 52; +const SHOT_SPEED = 520; +const SHOT_LIFETIME = 900; +const PLAYER_MAX_HULL = 100; +const PIRATE_MAX_HULL = 120; +const MAX_PIRATES = 6; +const AUTO_RESET_MS = 2200; + +const clamp = (v, min, max) => Math.max(min, Math.min(max, v)); +const degToRad = (deg) => deg * Math.PI / 180; +const normalizeDegrees = (deg) => ((deg % 360) + 360) % 360; +const signedAngleDelta = (target, current) => { + let delta = normalizeDegrees(target) - normalizeDegrees(current); + if (delta > 180) delta -= 360; + if (delta < -180) delta += 360; + return delta; +}; +const bearingTo = (from, to) => normalizeDegrees(Math.atan2(to.y - from.y, to.x - from.x) * 180 / Math.PI + 90); +const headingRad = (ship) => degToRad(ship.angle - 90); + +class GameScene extends Phaser.Scene { + constructor() { + super('game'); + this.playerSpeed = 0; + this.playerHull = PLAYER_MAX_HULL; + this.gameState = 'playing'; + this.lastPlayerShot = 0; + this.wave = 1; + this.defeated = 0; + } + + preload() { + this.load.spritesheet('airships', airshipSheetUrl, { + frameWidth: 64, + frameHeight: 64, + }); + this.load.spritesheet('explosions', explosionSheetUrl, { + frameWidth: 64, + frameHeight: 64, + }); + } + + create() { + this.physics.world.setBounds(0, 0, WORLD_SIZE, WORLD_SIZE); + this.drawWorld(); + this.createTextures(); + this.createAnimations(); + + this.player = this.physics.add.sprite(1200, 1550, 'airships', 0); + this.player.setDepth(6).setCollideWorldBounds(true).setDisplaySize(74, 74); + this.player.body.setAllowGravity(false); + this.player.body.setSize(42, 56, true); + this.player.play('player-idle'); + + this.pirates = this.physics.add.group(); + this.playerShots = this.physics.add.group(); + this.pirateShots = this.physics.add.group(); + this.arcGraphics = this.add.graphics().setDepth(4); + + this.physics.add.overlap(this.playerShots, this.pirates, this.hitPirate, null, this); + this.physics.add.overlap(this.pirateShots, this.player, this.hitPlayer, null, this); + + this.cursors = this.input.keyboard.createCursorKeys(); + this.keys = this.input.keyboard.addKeys({ + firePort: Phaser.Input.Keyboard.KeyCodes.Q, + fireStarboard: Phaser.Input.Keyboard.KeyCodes.E, + stop: Phaser.Input.Keyboard.KeyCodes.SPACE, + restart: Phaser.Input.Keyboard.KeyCodes.R, + }); + + this.cameras.main.setBounds(0, 0, WORLD_SIZE, WORLD_SIZE); + this.cameras.main.startFollow(this.player, true, 0.08, 0.08); + this.cameras.main.setZoom(1.12); + + this.title = this.add.text(18, 16, 'ARROWSTACK AIRSHIP', { + fontFamily: 'monospace', + fontSize: '12px', + color: '#8cd3ff', + }).setScrollFactor(0).setDepth(30); + + this.hud = this.add.text(18, 42, '', { + fontFamily: 'monospace', + fontSize: '14px', + color: '#e8f2f7', + backgroundColor: '#071018cc', + padding: { x: 12, y: 10 }, + lineSpacing: 4, + }).setScrollFactor(0).setDepth(30); + + this.status = this.add.text(this.scale.width / 2, 22, '', { + fontFamily: 'monospace', + fontSize: '18px', + color: '#ffffff', + backgroundColor: '#071018dd', + padding: { x: 16, y: 10 }, + align: 'center', + }).setOrigin(0.5, 0).setScrollFactor(0).setDepth(31); + + this.healthGraphics = this.add.graphics().setScrollFactor(0).setDepth(30); + this.healthLabels = { + player: this.add.text(18, this.scale.height - 64, 'SPARROW', { + fontFamily: 'monospace', fontSize: '12px', color: '#d8ecff', + }).setScrollFactor(0).setDepth(31), + pirate: this.add.text(this.scale.width - 18, this.scale.height - 64, 'PIRATES', { + fontFamily: 'monospace', fontSize: '12px', color: '#ffd0d0', + }).setOrigin(1, 0).setScrollFactor(0).setDepth(31), + }; + + this.scale.on('resize', this.layoutHud, this); + this.layoutHud({ width: this.scale.width, height: this.scale.height }); + this.spawnWave(); + } + + createAnimations() { + if (!this.anims.exists('player-idle')) { + this.anims.create({ + key: 'player-idle', + frames: this.anims.generateFrameNumbers('airships', { start: 0, end: 3 }), + frameRate: 5, + repeat: -1, + }); + } + + if (!this.anims.exists('pirate-idle')) { + this.anims.create({ + key: 'pirate-idle', + frames: this.anims.generateFrameNumbers('airships', { start: 4, end: 7 }), + frameRate: 5, + repeat: -1, + }); + } + + if (!this.anims.exists('impact-explosion')) { + this.anims.create({ + key: 'impact-explosion', + frames: this.anims.generateFrameNumbers('explosions', { start: 0, end: 5 }), + frameRate: 18, + repeat: 0, + }); + } + } + + spawnWave() { + const count = Math.min(this.wave, MAX_PIRATES); + this.status.setText(`WAVE ${this.wave}\n${count} PIRATE${count === 1 ? '' : 'S'}`); + + for (let i = 0; i < count; i++) { + const angle = (Math.PI * 2 * i / count) + Phaser.Math.FloatBetween(-0.25, 0.25); + const radius = Phaser.Math.Between(500, 760); + const x = clamp(this.player.x + Math.cos(angle) * radius, 120, WORLD_SIZE - 120); + const y = clamp(this.player.y + Math.sin(angle) * radius, 120, WORLD_SIZE - 120); + this.spawnPirate(x, y, angle); + } + + this.time.delayedCall(900, () => { + if (this.gameState === 'playing') this.status.setText(''); + }); + } + + spawnPirate(x, y, angle) { + const pirate = this.pirates.create(x, y, 'airships', 4); + pirate.setDepth(6).setCollideWorldBounds(true).setDisplaySize(78, 78); + pirate.body.setAllowGravity(false); + pirate.body.setSize(46, 58, true); + pirate.angle = normalizeDegrees(Phaser.Math.RadToDeg(angle) + 90); + pirate.setData('hull', PIRATE_MAX_HULL); + pirate.setData('lastShot', this.time.now + Phaser.Math.Between(-700, 300)); + pirate.play('pirate-idle'); + return pirate; + } + + drawWorld() { + const bg = this.add.graphics(); + bg.fillStyle(0x132335, 1); + bg.fillRect(0, 0, WORLD_SIZE, WORLD_SIZE); + bg.lineStyle(1, 0x8db3cc, 0.08); + for (let x = 0; x <= WORLD_SIZE; x += 100) bg.lineBetween(x, 0, x, WORLD_SIZE); + for (let y = 0; y <= WORLD_SIZE; y += 100) bg.lineBetween(0, y, WORLD_SIZE, y); + + const clouds = this.add.graphics(); + for (let i = 0; i < 46; i++) { + const x = Phaser.Math.Between(80, WORLD_SIZE - 80); + const y = Phaser.Math.Between(80, WORLD_SIZE - 80); + const r = Phaser.Math.Between(24, 62); + clouds.fillStyle(0xbcd2df, Phaser.Math.FloatBetween(0.025, 0.08)); + clouds.fillCircle(x, y, r); + clouds.fillCircle(x + r * 0.55, y + 8, r * 0.7); + clouds.fillCircle(x - r * 0.45, y + 5, r * 0.6); + } + + const island = this.add.graphics(); + island.fillStyle(0x5d694e, 1); + island.fillEllipse(430, 480, 230, 150); + island.fillStyle(0xa18a62, 1); + island.fillEllipse(430, 470, 190, 105); + island.fillStyle(0x283827, 1); + island.fillCircle(390, 440, 18); + island.fillCircle(455, 450, 14); + island.fillCircle(490, 425, 12); + + const island2 = this.add.graphics(); + island2.fillStyle(0x5d694e, 1); + island2.fillEllipse(1940, 1840, 260, 165); + island2.fillStyle(0xa18a62, 1); + island2.fillEllipse(1940, 1830, 210, 112); + } + + createTextures() { + const playerShot = this.make.graphics({ x: 0, y: 0, add: false }); + playerShot.fillStyle(0xffd36a, 1); + playerShot.fillCircle(5, 5, 5); + playerShot.lineStyle(1, 0xfff0b0, 1); + playerShot.strokeCircle(5, 5, 4); + playerShot.generateTexture('player-shot', 10, 10); + playerShot.destroy(); + + const pirateShot = this.make.graphics({ x: 0, y: 0, add: false }); + pirateShot.fillStyle(0xff6b62, 1); + pirateShot.fillCircle(5, 5, 5); + pirateShot.lineStyle(1, 0xffb0aa, 1); + pirateShot.strokeCircle(5, 5, 4); + pirateShot.generateTexture('pirate-shot', 10, 10); + pirateShot.destroy(); + } + + update(_time, deltaMs) { + const dt = Math.min(deltaMs / 1000, 0.05); + + if (this.gameState !== 'playing') { + if (Phaser.Input.Keyboard.JustDown(this.keys.restart)) this.scene.restart(); + this.drawHud(); + return; + } + + this.updatePlayer(dt, deltaMs); + this.updatePirates(dt); + this.updateProjectiles(); + this.drawWeaponArcs(); + this.drawHud(); + this.checkGameState(); + } + + updatePlayer(dt, deltaMs) { + if (!this.player?.body) return; + + if (this.cursors.up.isDown) this.playerSpeed += PLAYER_ACCELERATION * dt; + if (this.cursors.down.isDown) this.playerSpeed -= PLAYER_BRAKE * dt; + if (this.keys.stop.isDown) this.playerSpeed *= Math.pow(0.025, dt); + + if (!this.cursors.up.isDown && !this.cursors.down.isDown && !this.keys.stop.isDown) { + this.playerSpeed *= Math.pow(0.985, deltaMs / 16.667); + } + + this.playerSpeed = clamp(this.playerSpeed, PLAYER_REVERSE_SPEED, PLAYER_MAX_SPEED); + const turnStrength = clamp(Math.abs(this.playerSpeed) / 100, 0.28, 1); + const directionSign = this.playerSpeed < 0 ? -1 : 1; + + if (this.cursors.left.isDown) this.player.angle -= PLAYER_TURN_RATE * turnStrength * directionSign * dt; + if (this.cursors.right.isDown) this.player.angle += PLAYER_TURN_RATE * turnStrength * directionSign * dt; + + const h = headingRad(this.player); + this.player.setVelocity(Math.cos(h) * this.playerSpeed, Math.sin(h) * this.playerSpeed); + + if (Phaser.Input.Keyboard.JustDown(this.keys.firePort)) this.playerBroadside(-1); + if (Phaser.Input.Keyboard.JustDown(this.keys.fireStarboard)) this.playerBroadside(1); + } + + updatePirates(dt) { + for (const pirate of this.pirates.getChildren()) { + if (!pirate.active || !pirate.body) continue; + + const dx = this.player.x - pirate.x; + const dy = this.player.y - pirate.y; + const distance = Math.hypot(dx, dy); + const bearing = bearingTo(pirate, this.player); + + let desiredHeading = bearing; + if (distance < 360) { + const optionA = normalizeDegrees(bearing + 90); + const optionB = normalizeDegrees(bearing - 90); + desiredHeading = Math.abs(signedAngleDelta(optionA, pirate.angle)) < Math.abs(signedAngleDelta(optionB, pirate.angle)) ? optionA : optionB; + } + + const delta = signedAngleDelta(desiredHeading, pirate.angle); + pirate.angle += clamp(delta, -PIRATE_TURN_RATE * dt, PIRATE_TURN_RATE * dt); + + const speed = distance > 430 ? PIRATE_SPEED : distance < 210 ? 72 : 96; + const h = headingRad(pirate); + pirate.setVelocity(Math.cos(h) * speed, Math.sin(h) * speed); + + const relative = signedAngleDelta(bearing, pirate.angle); + const broadsideError = Math.abs(Math.abs(relative) - 90); + const lastShot = pirate.getData('lastShot') || 0; + if (distance < 420 && broadsideError < 38 && this.time.now - lastShot > 1650) { + const side = relative >= 0 ? 1 : -1; + this.fireBroadside(pirate, side, this.pirateShots, 'pirate-shot', 12, 0.9); + pirate.setData('lastShot', this.time.now); + } + } + } + + playerBroadside(side) { + if (this.time.now - this.lastPlayerShot < 850) return; + this.fireBroadside(this.player, side, this.playerShots, 'player-shot', 16, 1.0); + this.lastPlayerShot = this.time.now; + } + + fireBroadside(ship, side, group, texture, damage, velocityScale) { + if (!ship?.body) return; + const base = headingRad(ship) + side * Math.PI / 2; + const offsets = [-7, 0, 7]; + + for (const offset of offsets) { + const angle = base + degToRad(offset); + const x = ship.x + Math.cos(base) * 34; + const y = ship.y + Math.sin(base) * 34; + const shot = group.create(x, y, texture); + shot.setDepth(8); + shot.body.setAllowGravity(false); + shot.setData('bornAt', this.time.now); + shot.setData('damage', damage); + shot.setVelocity( + Math.cos(angle) * SHOT_SPEED * velocityScale + ship.body.velocity.x * 0.25, + Math.sin(angle) * SHOT_SPEED * velocityScale + ship.body.velocity.y * 0.25, + ); + } + } + + hitPirate(shot, pirate) { + if (!shot?.active || !pirate?.active || this.gameState !== 'playing') return; + const damage = shot.getData('damage') || 10; + const { x, y } = shot; + shot.destroy(); + this.spawnExplosion(x, y, 0.7); + + const hull = Math.max(0, (pirate.getData('hull') || PIRATE_MAX_HULL) - damage); + pirate.setData('hull', hull); + + if (hull <= 0) { + this.spawnExplosion(pirate.x, pirate.y, 1.25); + pirate.destroy(); + this.defeated += 1; + + if (this.pirates.countActive(true) === 0) { + this.wave += 1; + this.playerHull = Math.min(PLAYER_MAX_HULL, this.playerHull + 15); + this.status.setText(`WAVE CLEARED\n+15 HULL`); + this.time.delayedCall(1100, () => { + if (this.gameState === 'playing' && this.pirates.countActive(true) === 0) this.spawnWave(); + }); + } + return; + } + + this.flashShip(pirate, 0xffcccc); + } + + hitPlayer(shot) { + if (!shot?.active || this.gameState !== 'playing') return; + const damage = shot.getData('damage') || 10; + const { x, y } = shot; + shot.destroy(); + this.spawnExplosion(x, y, 0.7); + this.playerHull = Math.max(0, this.playerHull - damage); + this.flashShip(this.player, 0xffcccc); + } + + spawnExplosion(x, y, scale = 1) { + const fx = this.add.sprite(x, y, 'explosions', 0).setDepth(20).setScale(scale); + fx.play('impact-explosion'); + fx.once(Phaser.Animations.Events.ANIMATION_COMPLETE, () => fx.destroy()); + } + + flashShip(ship, tint) { + ship.setTint(tint); + this.time.delayedCall(90, () => { + if (ship.active) ship.clearTint(); + }); + } + + updateProjectiles() { + for (const group of [this.playerShots, this.pirateShots]) { + for (const shot of group.getChildren()) { + if (!shot.active) continue; + if (this.time.now - shot.getData('bornAt') > SHOT_LIFETIME) shot.destroy(); + } + } + } + + drawWeaponArcs() { + this.arcGraphics.clear(); + if (this.gameState !== 'playing') return; + + const radius = 190; + const forward = headingRad(this.player); + this.arcGraphics.lineStyle(2, 0xffc857, 0.28); + + for (const side of [-1, 1]) { + const center = forward + side * Math.PI / 2; + this.arcGraphics.beginPath(); + this.arcGraphics.arc(this.player.x, this.player.y, radius, center - Math.PI / 4, center + Math.PI / 4); + this.arcGraphics.strokePath(); + } + } + + nearestPirate() { + let nearest = null; + let nearestRange = Infinity; + for (const pirate of this.pirates.getChildren()) { + if (!pirate.active) continue; + const range = Math.hypot(this.player.x - pirate.x, this.player.y - pirate.y); + if (range < nearestRange) { + nearest = pirate; + nearestRange = range; + } + } + return { pirate: nearest, range: nearestRange }; + } + + drawHud() { + const heading = normalizeDegrees(this.player.angle); + const activePirates = this.pirates?.countActive(true) || 0; + const { pirate: nearest, range } = this.nearestPirate(); + const rangeText = Number.isFinite(range) ? `${range.toFixed(0).padStart(4, ' ')}m` : ' --'; + + this.hud.setText([ + `speed ${Math.abs(this.playerSpeed).toFixed(0).padStart(3, ' ')} ${this.playerSpeed < 0 ? 'REV' : 'FWD'}`, + `heading ${heading.toFixed(0).padStart(3, ' ')}°`, + `wave ${String(this.wave).padStart(3, ' ')}`, + `pirates ${String(activePirates).padStart(3, ' ')} nearest ${rangeText}`, + `defeated ${String(this.defeated).padStart(3, ' ')}`, + '', + 'drag throttle + steer', + 'Q / E port / starboard', + 'SPACE brake', + ]); + + const w = this.scale.width; + const h = this.scale.height; + const barWidth = Math.min(280, w * 0.32); + const barHeight = 16; + const y = h - 38; + + this.healthGraphics.clear(); + this.healthGraphics.fillStyle(0x071018, 0.85); + this.healthGraphics.fillRect(18, y, barWidth, barHeight); + this.healthGraphics.fillRect(w - 18 - barWidth, y, barWidth, barHeight); + + this.healthGraphics.fillStyle(0x61c98b, 1); + this.healthGraphics.fillRect(18, y, barWidth * (this.playerHull / PLAYER_MAX_HULL), barHeight); + + const nearestHull = nearest ? (nearest.getData('hull') || 0) : 0; + this.healthGraphics.fillStyle(0xd65757, 1); + this.healthGraphics.fillRect(w - 18 - barWidth, y, barWidth * (nearestHull / PIRATE_MAX_HULL), barHeight); + + this.healthLabels.player.setText(`SPARROW ${this.playerHull}/${PLAYER_MAX_HULL}`); + this.healthLabels.pirate.setText(nearest ? `NEAREST PIRATE ${nearestHull}/${PIRATE_MAX_HULL}` : `WAVE ${this.wave} CLEAR`); + } + + checkGameState() { + if (this.playerHull <= 0) this.finishGame(); + } + + finishGame() { + if (this.gameState !== 'playing') return; + this.gameState = 'lost'; + this.player.setVelocity(0, 0); + this.playerShots.clear(true, true); + this.pirateShots.clear(true, true); + + for (const pirate of this.pirates.getChildren()) { + if (pirate?.body) pirate.setVelocity(0, 0); + } + + this.spawnExplosion(this.player.x, this.player.y, 1.35); + this.player.setTint(0x555555); + this.status.setText(`SHIP DISABLED\nRestarting...`); + + this.time.delayedCall(AUTO_RESET_MS, () => this.scene.restart()); + } + + layoutHud(gameSize) { + const width = gameSize.width; + const height = gameSize.height; + if (this.status) this.status.setPosition(width / 2, 22); + if (this.healthLabels) { + this.healthLabels.player.setPosition(18, height - 64); + this.healthLabels.pirate.setPosition(width - 18, height - 64); + } + } +} + +new Phaser.Game({ + type: Phaser.AUTO, + parent: 'game', + width: window.innerWidth, + height: window.innerHeight, + backgroundColor: '#132335', + physics: { + default: 'arcade', + arcade: { debug: false }, + }, + scale: { + mode: Phaser.Scale.RESIZE, + autoCenter: Phaser.Scale.CENTER_BOTH, + }, + scene: GameScene, +});