Skip to content

src/models.ts

168 lines · 6.1 KB · TypeScript
Raw
import * as THREE from 'three'

export type EnemyKind = 'rowboat' | 'shark' | 'galleon'

export type EnemyStats = {
  radius: number
  hp: number
  speed: number
  score: number
  burstColor: number
}

export function getEnemyStats(kind: EnemyKind): EnemyStats {
  switch (kind) {
    case 'rowboat':
      return { radius: 0.8, hp: 1, speed: 6, score: 100, burstColor: 0xf59e0b }
    case 'shark':
      return { radius: 0.8, hp: 2, speed: 4.5, score: 150, burstColor: 0xe0f2fe }
    case 'galleon':
      return { radius: 1.3, hp: 5, speed: 2.2, score: 300, burstColor: 0xf97316 }
    default: {
      const unknownKind: never = kind
      throw new Error(`Unknown enemy kind: ${String(unknownKind)}`)
    }
  }
}

type Vector3Tuple = [x: number, y: number, z: number]

type Part = {
  geometry: THREE.BufferGeometry
  color: number
  position?: Vector3Tuple
  rotation?: Vector3Tuple
  scale?: Vector3Tuple
}

export type Model = {
  object: THREE.Group
  materials: THREE.MeshStandardMaterial[]
}

/** Models face +y (the direction of travel) with their decks facing +z (the camera). */
function buildModel(parts: Part[]): Model {
  const object = new THREE.Group()
  const materials = parts.map(({ geometry, color, position, rotation, scale }) => {
    const material = new THREE.MeshStandardMaterial({
      color,
      emissive: 0xffffff,
      emissiveIntensity: 0,
      flatShading: true,
      roughness: 0.7,
    })
    const mesh = new THREE.Mesh(geometry, material)
    if (position) mesh.position.set(...position)
    if (rotation) mesh.rotation.set(...rotation)
    if (scale) mesh.scale.set(...scale)
    object.add(mesh)
    return material
  })
  return { object, materials }
}

/** A four-sided pyramid pointing +y whose square base lines up with a box of the given width. */
function createBowGeometry(width: number, length: number) {
  return new THREE.ConeGeometry(width / Math.SQRT2, length, 4).rotateY(Math.PI / 4)
}

const WOOD = 0x7c4a1e
const DARK_WOOD = 0x5c3415
const DECK = 0xb07a45
const CANVAS = 0xf5ecd7
const NAVY = 0x1e3a5f
// Sails are tilted back so their faces point at the overhead camera instead of edge-on.
const SAIL_TILT: Vector3Tuple = [-1, 0, 0]

const mastGeometry = new THREE.CylinderGeometry(0.08, 0.1, 2.4, 6).rotateX(Math.PI / 2)
const flagGeometry = new THREE.BoxGeometry(0.5, 0.04, 0.3)

const geometries = {
  playerHull: new THREE.BoxGeometry(1.3, 2.4, 0.6),
  playerBow: createBowGeometry(1.3, 1),
  playerDeck: new THREE.BoxGeometry(1.1, 2.2, 0.1),
  playerCabin: new THREE.BoxGeometry(1.1, 0.6, 0.4),
  playerSail: new THREE.BoxGeometry(2.2, 0.12, 1.4),
  wake: new THREE.ConeGeometry(0.55, 1.4, 8),
  rowboatHull: new THREE.BoxGeometry(0.8, 1.5, 0.35),
  rowboatBow: createBowGeometry(0.8, 0.6),
  rowboatSeat: new THREE.BoxGeometry(0.8, 0.15, 0.05),
  oars: new THREE.BoxGeometry(2, 0.08, 0.06),
  sailor: new THREE.SphereGeometry(0.18, 8, 6),
  sharkFin: new THREE.ConeGeometry(0.3, 0.8, 3).rotateX(Math.PI / 2),
  sharkBody: new THREE.SphereGeometry(0.6, 10, 6),
  galleonHull: new THREE.BoxGeometry(1.6, 3, 0.7),
  galleonBow: createBowGeometry(1.6, 1),
  galleonDeck: new THREE.BoxGeometry(1.4, 2.8, 0.1),
  galleonCabin: new THREE.BoxGeometry(1.4, 0.7, 0.5),
  galleonSail: new THREE.BoxGeometry(2.2, 0.12, 1.2),
}

export function createEnemyModel(kind: EnemyKind): Model {
  switch (kind) {
    case 'rowboat':
      return buildModel([
        { geometry: geometries.rowboatHull, color: WOOD },
        { geometry: geometries.rowboatBow, color: WOOD, position: [0, 1.05, 0], scale: [1, 1, 0.44] },
        { geometry: geometries.rowboatSeat, color: DECK, position: [0, 0.1, 0.2] },
        { geometry: geometries.oars, color: 0xd6b48a, position: [0, 0.1, 0.2] },
        { geometry: geometries.sailor, color: 0xdc2626, position: [0, -0.3, 0.4] },
      ])
    case 'shark':
      return buildModel([
        { geometry: geometries.sharkBody, color: 0x475569, position: [0, 0, -0.12], scale: [0.5, 1.4, 0.12] },
        { geometry: geometries.sharkFin, color: 0xcbd5e1, position: [0, 0, 0.25], scale: [0.4, 1.5, 1.2] },
      ])
    case 'galleon':
      return buildModel([
        { geometry: geometries.galleonHull, color: NAVY },
        { geometry: geometries.galleonBow, color: NAVY, position: [0, 2, 0], scale: [1, 1, 0.44] },
        { geometry: geometries.galleonDeck, color: DECK, position: [0, 0, 0.38] },
        { geometry: geometries.galleonCabin, color: 0x172554, position: [0, -1.2, 0.6] },
        { geometry: mastGeometry, color: DARK_WOOD, position: [0, 0.8, 1.5] },
        { geometry: mastGeometry, color: DARK_WOOD, position: [0, -0.4, 1.5] },
        { geometry: geometries.galleonSail, color: 0xf8fafc, position: [0, 0.9, 1.6], rotation: SAIL_TILT },
        { geometry: geometries.galleonSail, color: 0xf8fafc, position: [0, -0.3, 1.6], rotation: SAIL_TILT },
        { geometry: flagGeometry, color: 0xdc2626, position: [0.25, 0.8, 2.75] },
      ])
    default: {
      const unknownKind: never = kind
      throw new Error(`Unknown enemy kind: ${String(unknownKind)}`)
    }
  }
}

export const cannonballGeometry = new THREE.SphereGeometry(0.22, 10, 8)
export const cannonballMaterial = new THREE.MeshStandardMaterial({
  color: 0x1f2937,
  metalness: 0.6,
  roughness: 0.35,
})

export type PlayerShip = {
  group: THREE.Group
  wake: THREE.Mesh
}

export function createPlayerShip(): PlayerShip {
  const { object: group } = buildModel([
    { geometry: geometries.playerHull, color: WOOD },
    { geometry: geometries.playerBow, color: WOOD, position: [0, 1.7, 0], scale: [1, 1, 0.46] },
    { geometry: geometries.playerDeck, color: DECK, position: [0, 0, 0.32] },
    { geometry: geometries.playerCabin, color: DARK_WOOD, position: [0, -1, 0.5] },
    { geometry: mastGeometry, color: DARK_WOOD, position: [0, 0.2, 1.5] },
    { geometry: geometries.playerSail, color: CANVAS, position: [0, 0.3, 1.6], rotation: SAIL_TILT },
    { geometry: flagGeometry, color: 0x111827, position: [0.25, 0.2, 2.75] },
  ])

  const wake = new THREE.Mesh(
    geometries.wake,
    new THREE.MeshBasicMaterial({ color: 0xe0f2fe, transparent: true, opacity: 0.6 }),
  )
  wake.rotation.z = Math.PI
  wake.position.set(0, -1.9, -0.2)
  wake.scale.z = 0.2
  group.add(wake)

  return { group, wake }
}