← Public packages
@kentcdodds/pirate-shooter
Vertical scrolling pirate ship shooter on the open sea, built with Three.js for phone or desktop
src/models.ts
168 lines · 6.1 KB · TypeScriptimport * 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 }
}