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