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: hull } = 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 hull.add(wake) // The hull faces right; the outer group stays unrotated so the game can roll // the ship around its keel with rotation.x. hull.rotation.z = -Math.PI / 2 const group = new THREE.Group() group.add(hull) return { group, wake } }