Skip to content

src/game.ts

581 lines · 16.8 KB · TypeScript
Raw
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: 14, halfHeight: 7.5 }
const PLAYER_MIN_X = -FIELD.halfWidth + 1.5
const PLAYER_MAX_X = FIELD.halfWidth * 0.4
const PLAYER_MARGIN_Y = 0.9
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-side-scroller: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
  baseY: 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, 16 / 9, 0.1, 200)
  private readonly cameraBase = new THREE.Vector3(0, -9, 19.5)
  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, PLAYER_MIN_X, PLAYER_MAX_X)
    position.y = clamp(
      position.y + velocityY * dt,
      -FIELD.halfHeight + PLAYER_MARGIN_Y,
      FIELD.halfHeight - PLAYER_MARGIN_Y,
    )

    const bank = clamp(-velocityY / PLAYER_SPEED, -1, 1) * 0.6
    group.rotation.x = THREE.MathUtils.lerp(group.rotation.x, 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 + 2, y, 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.halfWidth + 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, y?: number, x = FIELD.halfWidth + 2) {
    const stats = getEnemyStats(kind)
    const model = createEnemyModel(kind)
    const margin = kind === 'shark' ? SHARK_AMPLITUDE + 0.8 : stats.radius + 0.5
    const spawnY = y ?? random(-FIELD.halfHeight + margin, FIELD.halfHeight - margin)
    model.object.position.set(x, spawnY, 0)
    model.object.rotation.z = Math.PI / 2
    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,
      baseY: spawnY,
      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.x -= enemy.speed * dt

      // Enemies face left (rotation.z), so rotation.x rolls them around their keel.
      switch (enemy.kind) {
        case 'rowboat':
          object.rotation.x = Math.sin(enemy.age * 3 + enemy.phase) * 0.12
          break
        case 'galleon':
          object.rotation.x = Math.sin(enemy.age * 1.5 + enemy.phase) * 0.08
          break
        case 'shark': {
          const sway = enemy.age * 2.2 + enemy.phase
          position.y = enemy.baseY + Math.sin(sway) * SHARK_AMPLITUDE
          const velocityY = Math.cos(sway) * 2.2 * SHARK_AMPLITUDE
          object.rotation.z = Math.atan2(enemy.speed, velocityY)
          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.x < -FIELD.halfWidth - 3) enemy.dead = true
    }
  }

  private updateBullets(dt: number) {
    for (const bullet of this.bullets) {
      bullet.mesh.position.x += PLAYER_BULLET_SPEED * dt
      if (bullet.mesh.position.x > FIELD.halfWidth + 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(-FIELD.halfWidth + 4, 0, 0)
    group.rotation.set(0, 0, 0)
    group.visible = true
  }

  private clampToField(target: THREE.Vector2) {
    return new THREE.Vector2(
      clamp(target.x, PLAYER_MIN_X, PLAYER_MAX_X),
      clamp(target.y, -FIELD.halfHeight + PLAYER_MARGIN_Y, FIELD.halfHeight - PLAYER_MARGIN_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()
  }
}