airship-phaser/src/game-v2.js

410 lines
14 KiB
JavaScript

import Phaser from 'phaser';
import './style.css';
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 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.pirateHull = PIRATE_MAX_HULL;
this.gameState = 'playing';
this.lastPlayerShot = 0;
this.lastPirateShot = 0;
}
create() {
this.physics.world.setBounds(0, 0, WORLD_SIZE, WORLD_SIZE);
this.drawWorld();
this.createTextures();
this.player = this.physics.add.sprite(1200, 1550, 'player-ship');
this.player.setDepth(6).setCollideWorldBounds(true);
this.player.body.setAllowGravity(false);
this.player.body.setSize(42, 56, true);
this.pirate = this.physics.add.image(1200, 800, 'pirate-ship');
this.pirate.setDepth(6).setCollideWorldBounds(true);
this.pirate.body.setAllowGravity(false);
this.pirate.body.setSize(46, 58, true);
this.pirate.angle = 180;
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.pirate, 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, 'CUTLASS PIRATE', {
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 });
}
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 makeShip = (key, hullColor, balloonColor) => {
const g = this.make.graphics({ x: 0, y: 0, add: false });
g.fillStyle(hullColor, 1);
g.fillTriangle(32, 2, 8, 64, 56, 64);
g.fillStyle(0x715438, 1);
g.fillRect(17, 31, 30, 20);
g.fillStyle(balloonColor, 1);
g.fillEllipse(32, 23, 42, 16);
g.lineStyle(2, 0x201914, 0.9);
g.strokeTriangle(32, 2, 8, 64, 56, 64);
g.generateTexture(key, 64, 68);
g.destroy();
};
makeShip('player-ship', 0xd8a65f, 0xe8dfc7);
makeShip('pirate-ship', 0x9f3d3d, 0x5c2020);
const playerShot = this.make.graphics({ x: 0, y: 0, add: false });
playerShot.fillStyle(0xffd36a, 1);
playerShot.fillCircle(5, 5, 5);
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.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.updatePirate(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);
}
updatePirate(dt) {
if (!this.pirate.active) return;
const dx = this.player.x - this.pirate.x;
const dy = this.player.y - this.pirate.y;
const distance = Math.hypot(dx, dy);
const bearing = bearingTo(this.pirate, this.player);
let desiredHeading = bearing;
if (distance < 360) {
const optionA = normalizeDegrees(bearing + 90);
const optionB = normalizeDegrees(bearing - 90);
desiredHeading = Math.abs(signedAngleDelta(optionA, this.pirate.angle)) < Math.abs(signedAngleDelta(optionB, this.pirate.angle)) ? optionA : optionB;
}
const delta = signedAngleDelta(desiredHeading, this.pirate.angle);
this.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(this.pirate);
this.pirate.setVelocity(Math.cos(h) * speed, Math.sin(h) * speed);
const relative = signedAngleDelta(bearing, this.pirate.angle);
const broadsideError = Math.abs(Math.abs(relative) - 90);
if (distance < 420 && broadsideError < 38 && this.time.now - this.lastPirateShot > 1650) {
const side = relative >= 0 ? 1 : -1;
this.fireBroadside(this.pirate, side, this.pirateShots, 'pirate-shot', 12, 0.9);
this.lastPirateShot = 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) {
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) {
if (!shot.active || this.gameState !== 'playing') return;
const damage = shot.getData('damage') || 10;
shot.destroy();
this.pirateHull = Math.max(0, this.pirateHull - damage);
this.flashShip(this.pirate, 0xffcccc);
}
hitPlayer(shot) {
if (!shot.active || this.gameState !== 'playing') return;
const damage = shot.getData('damage') || 10;
shot.destroy();
this.playerHull = Math.max(0, this.playerHull - damage);
this.flashShip(this.player, 0xffcccc);
}
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();
}
}
drawHud() {
const heading = normalizeDegrees(this.player.angle);
const range = Math.hypot(this.player.x - this.pirate.x, this.player.y - this.pirate.y);
this.hud.setText([
`speed ${Math.abs(this.playerSpeed).toFixed(0).padStart(3, ' ')} ${this.playerSpeed < 0 ? 'REV' : 'FWD'}`,
`heading ${heading.toFixed(0).padStart(3, ' ')}°`,
`pirate ${range.toFixed(0).padStart(4, ' ')}m`,
'',
'↑ / ↓ throttle / reverse',
'← / → steer',
'Q port broadside',
'E starboard broadside',
'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);
this.healthGraphics.fillStyle(0xd65757, 1);
this.healthGraphics.fillRect(w - 18 - barWidth, y, barWidth * (this.pirateHull / PIRATE_MAX_HULL), barHeight);
this.healthLabels.player.setText(`SPARROW ${this.playerHull}/${PLAYER_MAX_HULL}`);
this.healthLabels.pirate.setText(`CUTLASS PIRATE ${this.pirateHull}/${PIRATE_MAX_HULL}`);
}
checkGameState() {
if (this.pirateHull <= 0) this.finishGame('won');
else if (this.playerHull <= 0) this.finishGame('lost');
}
finishGame(result) {
this.gameState = result;
this.player.setVelocity(0, 0);
this.pirate.setVelocity(0, 0);
this.playerShots.clear(true, true);
this.pirateShots.clear(true, true);
if (result === 'won') {
this.pirate.setTint(0x555555);
this.status.setText('PIRATE DISABLED\nYou win\n\nPress R to restart');
} else {
this.player.setTint(0x555555);
this.status.setText('YOUR SHIP IS DISABLED\nYou lose\n\nPress R to 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,
});