import * as THREE from 'three' import { Sfx } from './audio' import { Explosions } from './explosions' import { Input } from './input' import { cannonballGeometry, cannonballMaterial, createEnemyModel, createPlayerShip, getEnemyStats, type EnemyKind, type Model, } from './models' import { Sea } from './sea' const FIELD = { halfWidth: 7.5, halfHeight: 14 } const PLAYER_MIN_Y = -FIELD.halfHeight + 1.5 const PLAYER_MAX_Y = FIELD.halfHeight * 0.5 const PLAYER_MARGIN_X = 0.8 const PLAYER_RADIUS = 0.7 const PLAYER_SPEED = 16 const PLAYER_BULLET_SPEED = 30 const PLAYER_BULLET_RADIUS = 0.3 const FIRE_INTERVAL = 0.14 const STARTING_LIVES = 3 const INVULNERABLE_SECONDS = 2 const SHARK_AMPLITUDE = 2.5 const MAX_FRAME_SECONDS = 1 / 20 const HIGH_SCORE_KEY = 'pirate-shooter:high-score' type GameState = 'title' | 'playing' | 'paused' | 'gameover' type Enemy = { kind: EnemyKind model: Model hp: number radius: number speed: number score: number burstColor: number age: number baseX: number phase: number flash: number dead: boolean } type Bullet = { mesh: THREE.Mesh dead: boolean } export type GameElements = { stage: HTMLElement canvas: HTMLCanvasElement score: HTMLElement highScore: HTMLElement lives: HTMLElement overlay: HTMLElement overlayTitle: HTMLElement overlayMessage: HTMLElement startButton: HTMLButtonElement pauseButton: HTMLButtonElement muteButton: HTMLButtonElement } const random = THREE.MathUtils.randFloat const clamp = THREE.MathUtils.clamp function readHighScore() { const value = Number(localStorage.getItem(HIGH_SCORE_KEY)) return Number.isFinite(value) ? value : 0 } function distanceSquared(a: THREE.Vector3, b: THREE.Vector3) { const dx = a.x - b.x const dy = a.y - b.y return dx * dx + dy * dy } export class Game { private readonly renderer: THREE.WebGLRenderer private readonly scene = new THREE.Scene() private readonly camera = new THREE.PerspectiveCamera(50, 9 / 16, 0.1, 200) private readonly cameraBase = new THREE.Vector3(0, -18, 34) private readonly raycaster = new THREE.Raycaster() private readonly playPlane = new THREE.Plane(new THREE.Vector3(0, 0, 1), 0) private readonly sfx = new Sfx() private readonly sea = new Sea() private readonly explosions: Explosions private readonly player = createPlayerShip() private readonly input: Input private enemies: Enemy[] = [] private bullets: Bullet[] = [] private state: GameState = 'title' private score = 0 private highScore = readHighScore() private highScoreAtStart = this.highScore private lives = STARTING_LIVES private elapsed = 0 private fireCooldown = 0 private spawnCooldown = 0 private formationCooldown = 0 private invulnerable = 0 private shake = 0 private lastFrameTime: number | null = null constructor(private readonly elements: GameElements) { this.renderer = new THREE.WebGLRenderer({ canvas: elements.canvas, antialias: true }) this.scene.background = new THREE.Color(0x0b4f7a) this.scene.fog = new THREE.Fog(0x0b4f7a, 45, 75) this.camera.position.copy(this.cameraBase) this.camera.lookAt(0, 0, 0) this.scene.add(new THREE.HemisphereLight(0xdbeafe, 0x0b3d5c, 1.1)) const sun = new THREE.DirectionalLight(0xfff1d6, 1.8) sun.position.set(8, -6, 20) this.scene.add(sun) this.scene.add(this.sea.object) this.explosions = new Explosions(this.scene) this.scene.add(this.player.group) this.resetPlayer() this.input = new Input({ element: elements.canvas, toWorld: (x, y) => this.toWorld(x, y), getShipPosition: () => new THREE.Vector2(this.player.group.position.x, this.player.group.position.y), clampTarget: (target) => this.clampToField(target), }) elements.startButton.addEventListener('click', () => this.handlePrimaryAction()) elements.pauseButton.addEventListener('click', () => this.togglePause()) elements.muteButton.addEventListener('click', () => this.toggleMute()) window.addEventListener('keydown', this.handleKeyDown) document.addEventListener('visibilitychange', () => { if (document.hidden) this.pause() }) window.addEventListener('blur', () => this.pause()) new ResizeObserver(() => this.resize()).observe(elements.stage) this.resize() this.updateHud() this.updateMuteButton() this.showOverlay( 'Pirate Shooter', 'Touch: drag anywhere to sail.\nDesktop: mouse, arrows, or WASD.\nYour cannons fire automatically.', 'Start', ) this.renderer.setAnimationLoop(this.frame) } private frame = (time: number) => { const dt = this.lastFrameTime === null ? 0 : (time - this.lastFrameTime) / 1000 this.lastFrameTime = time this.update(clamp(dt, 0, MAX_FRAME_SECONDS)) this.renderer.render(this.scene, this.camera) } private update(dt: number) { if (this.state === 'paused') return const playing = this.state === 'playing' this.sea.update(playing ? dt : dt * 0.35) this.explosions.update(dt) this.player.wake.scale.y = 0.85 + Math.random() * 0.3 this.updateCameraShake(dt) if (playing) { this.elapsed += dt this.updatePlayer(dt) this.updateFiring(dt) this.updateSpawning(dt) } if (playing || this.state === 'gameover') { this.updateEnemies(dt) this.updateBullets(dt) } if (playing) this.handleCollisions() this.sweep() } private get intensity() { return 1 + this.elapsed / 45 } private updatePlayer(dt: number) { const { group } = this.player const position = group.position let velocityX = 0 let velocityY = 0 const axis = this.input.keyAxis() const target = this.input.getTarget() if (axis.lengthSq() > 0) { axis.normalize().multiplyScalar(PLAYER_SPEED) velocityX = axis.x velocityY = axis.y } else if (target && dt > 0) { const dx = target.x - position.x const dy = target.y - position.y const distance = Math.hypot(dx, dy) if (distance > 0.001) { const step = Math.min(distance, PLAYER_SPEED * 1.6 * dt) velocityX = (dx / distance) * (step / dt) velocityY = (dy / distance) * (step / dt) } } position.x = clamp( position.x + velocityX * dt, -FIELD.halfWidth + PLAYER_MARGIN_X, FIELD.halfWidth - PLAYER_MARGIN_X, ) position.y = clamp(position.y + velocityY * dt, PLAYER_MIN_Y, PLAYER_MAX_Y) const bank = clamp(-velocityX / PLAYER_SPEED, -1, 1) * 0.6 group.rotation.y = THREE.MathUtils.lerp(group.rotation.y, bank, 1 - Math.exp(-10 * dt)) this.invulnerable = Math.max(0, this.invulnerable - dt) group.visible = this.invulnerable <= 0 || Math.floor(this.invulnerable * 12) % 2 === 0 } private updateFiring(dt: number) { this.fireCooldown -= dt if (this.fireCooldown > 0) return this.fireCooldown += FIRE_INTERVAL const { x, y } = this.player.group.position const mesh = new THREE.Mesh(cannonballGeometry, cannonballMaterial) mesh.position.set(x, y + 2, 0.2) this.scene.add(mesh) this.bullets.push({ mesh, dead: false }) this.sfx.shoot() } private updateSpawning(dt: number) { this.spawnCooldown -= dt if (this.spawnCooldown <= 0) { this.spawnCooldown = Math.max(0.28, 1.2 / this.intensity) * random(0.7, 1.3) this.spawnEnemy(this.pickEnemyKind()) } this.formationCooldown -= dt if (this.formationCooldown <= 0) { this.formationCooldown = random(10, 16) for (let i = -2; i <= 2; i++) { this.spawnEnemy('rowboat', i * 2, FIELD.halfHeight + 2 + Math.abs(i) * 1.2) } } } private pickEnemyKind(): EnemyKind { const roll = Math.random() if (this.elapsed > 25 && roll < 0.2) return 'galleon' if (this.elapsed > 10 && roll < 0.5) return 'shark' return 'rowboat' } private spawnEnemy(kind: EnemyKind, x?: number, y = FIELD.halfHeight + 2) { const stats = getEnemyStats(kind) const model = createEnemyModel(kind) const margin = kind === 'shark' ? SHARK_AMPLITUDE + 0.8 : stats.radius + 0.5 const spawnX = x ?? random(-FIELD.halfWidth + margin, FIELD.halfWidth - margin) model.object.position.set(spawnX, y, 0) model.object.rotation.z = Math.PI this.scene.add(model.object) this.enemies.push({ kind, model, hp: stats.hp, radius: stats.radius, speed: stats.speed * (1 + this.elapsed / 120), score: stats.score, burstColor: stats.burstColor, age: 0, baseX: spawnX, phase: random(0, Math.PI * 2), flash: 0, dead: false, }) } private updateEnemies(dt: number) { for (const enemy of this.enemies) { enemy.age += dt const { object } = enemy.model const position = object.position position.y -= enemy.speed * dt switch (enemy.kind) { case 'rowboat': object.rotation.y = Math.sin(enemy.age * 3 + enemy.phase) * 0.12 break case 'galleon': object.rotation.y = Math.sin(enemy.age * 1.5 + enemy.phase) * 0.08 break case 'shark': { const sway = enemy.age * 2.2 + enemy.phase position.x = enemy.baseX + Math.sin(sway) * SHARK_AMPLITUDE const velocityX = Math.cos(sway) * 2.2 * SHARK_AMPLITUDE object.rotation.z = Math.atan2(-velocityX, -enemy.speed) break } default: { const unknownKind: never = enemy.kind throw new Error(`Unknown enemy kind: ${String(unknownKind)}`) } } enemy.flash = Math.max(0, enemy.flash - dt * 6) for (const material of enemy.model.materials) { material.emissiveIntensity = enemy.flash * 0.8 } if (position.y < -FIELD.halfHeight - 3) enemy.dead = true } } private updateBullets(dt: number) { for (const bullet of this.bullets) { bullet.mesh.position.y += PLAYER_BULLET_SPEED * dt if (bullet.mesh.position.y > FIELD.halfHeight + 4) bullet.dead = true } } private handleCollisions() { for (const bullet of this.bullets) { if (bullet.dead) continue for (const enemy of this.enemies) { if (enemy.dead) continue const reach = enemy.radius + PLAYER_BULLET_RADIUS if (distanceSquared(bullet.mesh.position, enemy.model.object.position) < reach * reach) { bullet.dead = true this.damageEnemy(enemy) break } } } const playerPosition = this.player.group.position for (const enemy of this.enemies) { if (enemy.dead || this.invulnerable > 0 || this.state !== 'playing') continue const reach = enemy.radius + PLAYER_RADIUS if (distanceSquared(enemy.model.object.position, playerPosition) < reach * reach) { this.destroyEnemy(enemy) this.hitPlayer() } } } private damageEnemy(enemy: Enemy) { enemy.hp -= 1 enemy.flash = 1 if (enemy.hp <= 0) { this.destroyEnemy(enemy) } else { this.sfx.hit() } } private destroyEnemy(enemy: Enemy) { enemy.dead = true const big = enemy.kind === 'galleon' this.explosions.spawn( enemy.model.object.position, enemy.burstColor, big ? 40 : 22, big ? 9 : 7, ) this.sfx.explosion() this.shake = Math.max(this.shake, big ? 0.5 : 0.25) this.score += enemy.score this.highScore = Math.max(this.highScore, this.score) this.updateHud() } private hitPlayer() { this.lives -= 1 this.explosions.spawn(this.player.group.position, 0xf97316, 48, 10) this.sfx.playerHit() this.shake = 1 navigator.vibrate?.(80) this.updateHud() if (this.lives <= 0) { this.gameOver() return } this.invulnerable = INVULNERABLE_SECONDS } private updateCameraShake(dt: number) { this.shake = Math.max(0, this.shake - dt * 2.5) const amount = this.shake * this.shake * 0.8 this.camera.position.set( this.cameraBase.x + (Math.random() - 0.5) * amount, this.cameraBase.y + (Math.random() - 0.5) * amount, this.cameraBase.z, ) } private sweep() { this.enemies = this.enemies.filter((enemy) => { if (!enemy.dead) return true this.scene.remove(enemy.model.object) for (const material of enemy.model.materials) material.dispose() return false }) this.bullets = this.bullets.filter((bullet) => { if (!bullet.dead) return true this.scene.remove(bullet.mesh) return false }) } private startGame() { for (const enemy of this.enemies) enemy.dead = true for (const bullet of this.bullets) bullet.dead = true this.sweep() this.explosions.clear() this.input.reset() this.score = 0 this.highScoreAtStart = this.highScore this.lives = STARTING_LIVES this.elapsed = 0 this.fireCooldown = 0 this.spawnCooldown = 1 this.formationCooldown = 8 this.invulnerable = 0 this.resetPlayer() this.updateHud() this.sfx.unlock() this.setState('playing') } private gameOver() { this.player.group.visible = false localStorage.setItem(HIGH_SCORE_KEY, String(this.highScore)) const isNewHighScore = this.score > this.highScoreAtStart this.setState('gameover') this.showOverlay( 'Game Over', isNewHighScore ? `New high score: ${this.score.toLocaleString()}` : `Score: ${this.score.toLocaleString()}`, 'Play again', ) } private pause() { if (this.state !== 'playing') return this.setState('paused') this.showOverlay('Paused', 'Take a breather.', 'Resume') } private resume() { if (this.state !== 'paused') return this.sfx.unlock() this.setState('playing') } private togglePause() { if (this.state === 'playing') this.pause() else this.resume() } private handlePrimaryAction() { switch (this.state) { case 'title': case 'gameover': this.startGame() break case 'paused': this.resume() break case 'playing': break default: { const unknownState: never = this.state throw new Error(`Unknown game state: ${String(unknownState)}`) } } } private handleKeyDown = (event: KeyboardEvent) => { if (event.repeat) return switch (event.code) { case 'Space': case 'Enter': if (this.state !== 'playing') { event.preventDefault() this.handlePrimaryAction() } break case 'KeyP': case 'Escape': this.togglePause() break case 'KeyM': this.toggleMute() break } } private setState(state: GameState) { this.state = state const playing = state === 'playing' this.elements.stage.classList.toggle('playing', playing) this.elements.pauseButton.hidden = !playing if (playing) this.elements.overlay.hidden = true } private showOverlay(title: string, message: string, buttonLabel: string) { const { overlay, overlayTitle, overlayMessage, startButton } = this.elements overlayTitle.textContent = title overlayMessage.textContent = message startButton.textContent = buttonLabel overlay.hidden = false } private toggleMute() { this.sfx.toggleMuted() this.updateMuteButton() } private updateMuteButton() { const { muteButton } = this.elements muteButton.textContent = this.sfx.muted ? 'Sound: off' : 'Sound: on' muteButton.setAttribute('aria-pressed', String(!this.sfx.muted)) } private updateHud() { const { score, highScore, lives } = this.elements score.textContent = this.score.toLocaleString() highScore.textContent = `HI ${this.highScore.toLocaleString()}` lives.textContent = '♥'.repeat(Math.max(0, this.lives)) lives.setAttribute('aria-label', `${this.lives} lives`) } private resetPlayer() { const { group } = this.player group.position.set(0, -FIELD.halfHeight + 4, 0) group.rotation.set(0, 0, 0) group.visible = true } private clampToField(target: THREE.Vector2) { return new THREE.Vector2( clamp(target.x, -FIELD.halfWidth + PLAYER_MARGIN_X, FIELD.halfWidth - PLAYER_MARGIN_X), clamp(target.y, PLAYER_MIN_Y, PLAYER_MAX_Y), ) } private toWorld(clientX: number, clientY: number) { const rect = this.elements.canvas.getBoundingClientRect() const pointer = new THREE.Vector2( ((clientX - rect.left) / rect.width) * 2 - 1, -((clientY - rect.top) / rect.height) * 2 + 1, ) this.raycaster.setFromCamera(pointer, this.camera) const hit = this.raycaster.ray.intersectPlane(this.playPlane, new THREE.Vector3()) return hit ? new THREE.Vector2(hit.x, hit.y) : null } private resize() { const { clientWidth, clientHeight } = this.elements.stage if (!clientWidth || !clientHeight) return this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)) this.renderer.setSize(clientWidth, clientHeight, false) this.camera.aspect = clientWidth / clientHeight this.camera.updateProjectionMatrix() } }