diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 681cdf4..ecef750 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -243,9 +243,10 @@ text no brighter or busier than before, and keep flicker slow. ## Collections and fixed looks -`collection` puts a wallpaper in one of the menu's groups: `Landscapes`, `Akira`, `Classics`, or a name of your own -for a new group. The menu's Collection row picks one, and shuffle stays within it. Without one, a wallpaper joins -Landscapes, or Classics if it has a fixed look. +`collection` puts a wallpaper in one of the menu's groups: `Landscapes`, `Akira`, `Classics`, `Arcade`, or a name of +your own for a new group. The menu's Collection row picks one, and shuffle stays within it. Without one, a wallpaper +joins Landscapes, or Classics if it has a fixed look. The time, season and weather rows follow `fixedTime`, not the +collection. A wallpaper that only makes sense at one time of day, such as an old screensaver, sets `fixedTime: "night"` (or `day` or `sunset`). It then always gets that time with no season or weather, whatever the settings, the menu hides its time, diff --git a/README.md b/README.md index 067701d..28956d9 100644 --- a/README.md +++ b/README.md @@ -23,10 +23,11 @@ Type `/wallpaper` to open the menu and pick a wallpaper; none shows until you do ![The wallpaper menu](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/menu.png) -- **Collection:** Landscapes, Akira, Classics or all: which wallpapers the menu and shuffle pick from. +- **Collection:** Landscapes, Akira, Classics, Arcade or all: which wallpapers the menu and shuffle pick from. - **Activity:** calm, lively or teeming: how much moves behind your work. -- **Time, season and weather:** for Landscapes. Time follows your clock or stays fixed, seasons follow the calendar, - and weather can be the real weather where you are (`/wallpaper location Berlin`). Akira and Classics keep one look. +- **Time, season and weather:** for Landscapes and Arcade. Time follows your clock or stays fixed, seasons follow the + calendar, and weather can be the real weather where you are (`/wallpaper location Berlin`). Akira and Classics keep + one look. - **Brightness, shuffle, per project and power:** subtle to vivid, a new wallpaper every hour, one per project, and slowing down when idle. @@ -38,6 +39,7 @@ an error. With mouse support, clicking empty background nudges the scene, and ea - **Landscapes:** `ocean` · `desert` · `jungle` · `space` · `farm` · `tundra` · `beach` · `city` · `zen` · `prehistoric` · `haunted-house` - **Akira:** `rider` · `megacity` · `dome` - **Classics:** `flowerbox` · `maze` · `pipes` · `mystify` · `flying-opencode` · `matrix` +- **Arcade:** `wings` · `wings-flight` · `wings-bridge` | Spring | Summer | Autumn | Winter | | --- | --- | --- | --- | @@ -109,6 +111,20 @@ At teeming; the GIFs play at 3x speed. | Flying OpenCode | ![Flying OpenCode](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/flying-opencode.png) | ![Flying OpenCode in motion](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/flying-opencode.gif) | | Matrix | ![Matrix](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/matrix.png) | ![Matrix in motion](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/matrix.gif) | +### Arcade + +| | Day | Sunset | Night | +| --- | --- | --- | --- | +| Wings | ![Wings by day](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/wings.png) | ![Wings at sunset](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/wings-sunset.png) | ![Wings at night](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/wings-night.png) | +| Wings: Flight | ![Wings: Flight by day](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/wings-flight.png) | ![Wings: Flight at sunset](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/wings-flight-sunset.png) | ![Wings: Flight at night](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/wings-flight-night.png) | +| Wings: Bridge | ![Wings: Bridge by day](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/wings-bridge.png) | ![Wings: Bridge at sunset](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/wings-bridge-sunset.png) | ![Wings: Bridge at night](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/wings-bridge-night.png) | + +![Wings in motion](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/wings.gif) + +![Wings: Flight in motion](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/wings-flight.gif) + +![Wings: Bridge in motion](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/wings-bridge.gif) + ## License [MIT](LICENSE) diff --git a/dev/gif.ts b/dev/gif.ts index d815653..4e481ab 100644 --- a/dev/gif.ts +++ b/dev/gif.ts @@ -15,7 +15,7 @@ const FPS = 12.5 const START = 45 const FADE = 1.5 // The time of day each wallpaper with times of day looks best at in the README; others use day. -const BEST: Record = { ocean: "day", desert: "sunset", jungle: "day", space: "sunset", farm: "sunset", tundra: "night", beach: "sunset", city: "night", zen: "sunset", prehistoric: "sunset", "haunted-house": "night" } +const BEST: Record = { ocean: "day", desert: "sunset", jungle: "day", space: "sunset", farm: "sunset", tundra: "night", beach: "sunset", city: "night", zen: "sunset", prehistoric: "sunset", "haunted-house": "night", wings: "sunset", "wings-flight": "sunset", "wings-bridge": "sunset" } // Wallpapers in the hero GIF at the top of the README, each shown for HERO_SECONDS. const HERO = ["beach", "tundra", "jungle", "desert", "space"] const HERO_SECONDS = 2 diff --git a/wallpapers/index.ts b/wallpapers/index.ts index 39ee648..06a1060 100644 --- a/wallpapers/index.ts +++ b/wallpapers/index.ts @@ -17,6 +17,9 @@ import { prehistoric } from "./prehistoric" import { rider } from "./rider" import { space } from "./space" import { tundra } from "./tundra" +import { wings } from "./wings" +import { wingsFlight } from "./wings-flight" +import { wingsBridge } from "./wings-bridge" import { zen } from "./zen" -export const WALLPAPERS = [ocean, desert, jungle, space, farm, tundra, beach, city, zen, prehistoric, hauntedHouse, rider, megacity, dome, flowerbox, maze, pipes, mystify, flyingOpenCode, matrix] +export const WALLPAPERS = [ocean, desert, jungle, space, farm, tundra, beach, city, zen, prehistoric, hauntedHouse, rider, megacity, dome, flowerbox, maze, pipes, mystify, flyingOpenCode, matrix, wings, wingsFlight, wingsBridge] diff --git a/wallpapers/wings-bridge.ts b/wallpapers/wings-bridge.ts new file mode 100644 index 0000000..de86a7e --- /dev/null +++ b/wallpapers/wings-bridge.ts @@ -0,0 +1,2050 @@ +import { Canvas } from "../src/canvas" +import { eggWait } from "../src/egg" +import { TAU, clamp, hash, hash2, lerp, rand, smoothstep, type RGB } from "../src/math" +import { makeStars, makeStorm, paintSky, paintStars, paintStorm, driftClouds, type Orb, type Star } from "../src/sky" +import type { Activity, Settings, Time, Wallpaper } from "../src/wallpaper" +import { WeatherLayer } from "../src/weather" + +// A carrier's bridge in Anomaly Arcade's Wings: overhead gear, a band of windows on the flight deck, the low-poly sea +// and a volcano island, and a console of instruments below, with a jet launching now and then. The ship, its deck and +// the bridge are one rigid frame; only the sea and the islands pitch, very slightly, against it. + +const TIME_SCALE = 0.35 +const CREATURE_SPEED = 0.25 +// The horizon through the windows, and how much smaller than in Wings the world outside appears. +const HORIZON = 0.37 +const WS = 0.6 +// The horizon the looks' sky stops were drawn for, as in Wings. +const H0 = 0.44 +// The sea's shading camera, as in Wings: its height over the water and focal length, in screen heights. +const CAMERA = 1 +const FOCAL = 1 +const ROW_RATE = 0.2 +// The deck in perspective from the bridge: the focal length, the camera's place across the deck (in units of its +// height over the deck, plus to starboard), and how far right of the middle the deck's lines vanish. +const F = 1.2 * WS +const CAMX = 0.9 +const VANISH = 0.18 +// The windows: a band between the overhead gear and the console, in screen heights over the middle pane. +const TOP = 0.2 +const SILL = 0.6 +// How far the sea and islands pitch against the window frames, in screen heights, in calm water and in the storm. +const PITCH = 0.0022 +const STORM_PITCH = 0.006 +// The wiper's length, from its pivot on the middle pane's sill. +const WIPER = 0.36 +const LAUNCH = 24 +const LANDING = 28 +const FLYOVER = 16 +const EGG = 10 + +type Point = readonly [number, number] + +interface Look { + sky: [number, RGB][] + orb: Orb + stars: number + cloud: [lit: RGB, shade: RGB] + sea: [deep: RGB, lit: RGB] + glitter: number + spread: readonly [number, number] + spec: RGB + tint: RGB + key: RGB + lamp: number + trail: RGB + foam: RGB + storm: RGB + // The bridge: its frames and console as dark silhouettes, the faint rim the window light puts on them, the + // instruments' backlight, and rain on the glass. + frame: RGB + rim: RGB + drop: RGB + // Inside: matte gray panels, lighter bezels, display backgrounds, the displays' main and accent glow (blue-green, + // red and amber in night mode), and the bridge lighting (red at night). + panel: RGB + bezel: RGB + screen: RGB + glow: RGB + glow2: RGB + lamplight: RGB + night: boolean +} + +const LOOKS: Record = { + day: { + sky: [ + [0, [0.07, 0.27, 0.72]], + [0.24, [0.2, 0.47, 0.88]], + [H0, [0.62, 0.82, 0.96]], + ], + orb: { x: 0, y: 0.14, r: 0.033, core: [5, 4.6, 3.8], glow: [1, 0.95, 0.8], near: 0.45, wide: 0.12 }, + stars: 0, + cloud: [ + [1.05, 1.07, 1.1], + [0.6, 0.7, 0.86], + ], + sea: [ + [0.02, 0.2, 0.42], + [0.06, 0.52, 0.62], + ], + glitter: 0.7, + spread: [0.04, 0.16], + spec: [1.2, 1.15, 1], + tint: [1, 1, 1], + key: [1, 0.97, 0.88], + lamp: 0.25, + trail: [0.95, 0.97, 1], + foam: [0.85, 0.95, 1], + storm: [0.1, 0.16, 0.15], + frame: [0.022, 0.027, 0.036], + rim: [0.1, 0.13, 0.18], + drop: [0.7, 0.82, 0.95], + panel: [0.045, 0.05, 0.058], + bezel: [0.1, 0.11, 0.122], + screen: [0.006, 0.03, 0.04], + glow: [0.12, 0.62, 0.6], + glow2: [0.25, 0.85, 0.35], + lamplight: [0.6, 0.8, 1], + night: false, + }, + sunset: { + sky: [ + [0, [0.12, 0.2, 0.55]], + [0.18, [0.3, 0.32, 0.72]], + [0.31, [0.75, 0.42, 0.62]], + [0.39, [1.1, 0.55, 0.5]], + [H0, [1.25, 0.72, 0.45]], + ], + orb: { x: 0, y: 0.415, r: 0.038, core: [3, 1.7, 1], glow: [1.1, 0.5, 0.35], near: 0.5, wide: 0.25 }, + stars: 10, + cloud: [ + [1.05, 0.58, 0.55], + [0.36, 0.27, 0.52], + ], + sea: [ + [0.06, 0.1, 0.32], + [0.2, 0.26, 0.62], + ], + glitter: 0.9, + spread: [0.04, 0.3], + spec: [1.3, 0.62, 0.42], + tint: [0.32, 0.24, 0.34], + key: [1, 0.55, 0.45], + lamp: 0.6, + trail: [1.1, 0.62, 0.6], + foam: [1, 0.78, 0.82], + storm: [0.12, 0.1, 0.14], + frame: [0.03, 0.018, 0.04], + rim: [0.22, 0.1, 0.12], + drop: [1, 0.6, 0.55], + panel: [0.05, 0.04, 0.054], + bezel: [0.11, 0.09, 0.11], + screen: [0.006, 0.028, 0.038], + glow: [0.12, 0.55, 0.55], + glow2: [0.25, 0.8, 0.35], + lamplight: [1, 0.6, 0.4], + night: false, + }, + night: { + sky: [ + [0, [0.004, 0.01, 0.04]], + [0.24, [0.015, 0.035, 0.11]], + [H0, [0.05, 0.11, 0.3]], + ], + orb: { x: 0, y: 0.15, r: 0.032, core: [1.25, 0.95, 0.42], glow: [0.5, 0.38, 0.15], near: 0.3, wide: 0.1, moon: true }, + stars: 150, + cloud: [ + [0.12, 0.18, 0.4], + [0.03, 0.05, 0.14], + ], + sea: [ + [0.012, 0.04, 0.13], + [0.06, 0.15, 0.45], + ], + glitter: 0.6, + spread: [0.035, 0.3], + spec: [1.1, 0.68, 0.1], + tint: [0.06, 0.08, 0.17], + key: [0.35, 0.5, 1.1], + lamp: 1, + trail: [0.2, 0.42, 0.8], + foam: [0.1, 0.5, 0.75], + storm: [0.02, 0.055, 0.05], + frame: [0.008, 0.005, 0.014], + rim: [0.07, 0.012, 0.01], + drop: [0.08, 0.22, 0.5], + panel: [0.02, 0.005, 0.006], + bezel: [0.045, 0.01, 0.01], + screen: [0.03, 0.006, 0.002], + glow: [0.6, 0.24, 0.02], + glow2: [0.75, 0.08, 0.02], + lamplight: [0.9, 0.06, 0.015], + night: true, + }, +} + +// Daytime colors, darkened for the time of day by tint. +const HULL: RGB = [0.36, 0.38, 0.42] +const DECK: RGB = [0.2, 0.21, 0.23] +const STRIPE: RGB = [0.85, 0.85, 0.8] +const YELLOW: RGB = [0.85, 0.66, 0.12] +const JET: RGB = [0.5, 0.53, 0.58] +const ROTOR: RGB = [0.3, 0.33, 0.36] +const FOREST: RGB = [0.1, 0.3, 0.1] +const ROCK: RGB = [0.3, 0.27, 0.22] +const WHALE: RGB = [0.16, 0.19, 0.24] +const AMBER: RGB = [2, 1.05, 0.3] +const BURNER: RGB = [2.6, 1.15, 0.35] +const VESTS: RGB[] = [ + [0.9, 0.75, 0.1], + [0.2, 0.62, 0.25], + [0.75, 0.15, 0.1], + [0.15, 0.3, 0.8], + [0.5, 0.2, 0.62], +] + +// The flight deck's outline in deck space: x across (plus to starboard) and z ahead, in units of the bridge's height +// over the deck; the angled landing deck's centerline, and the two bow catapults' tracks. +const OUTLINE: Point[] = [[0.5, 2], [0.5, 7.6], [0.3, 8.5], [-0.4, 9], [-2.8, 9], [-3.5, 8.3], [-3.8, 7], [-3.8, 5.8], [-4.8, 4.8], [-4.8, 2]] +const ANGLED: readonly [Point, Point] = [[-1.3, 2], [-3.75, 7]] +const CATS = [ + { x: -1.1, z0: 6.1 }, + { x: -2.4, z0: 6.4 }, +] +const PARKED: Point[] = [[-0.12, 5], [-0.12, 5.85], [-0.12, 6.7], [-0.05, 7.45]] + +interface Cloud { + x: number + y: number + size: number + speed: number + lumps: { x: number; y: number; r: number; seed: number }[] + sprite?: Sprite +} + +interface Sprite { + w: number + h: number + ox: number + oy: number + rgba: Float32Array +} + +// A layer painted once per size: premultiplied color and coverage per pixel, and the pixels it touches. +interface Layer { + rgba: Float32Array + idx: Int32Array +} + +interface Crew { + x: number + z: number + tx: number + tz: number + wait: number + vest: RGB + walks: boolean +} + +interface Drop { + x: number + y: number + r: number + age: number + slide: number +} + +class WingsBridge extends Canvas { + private time = 0 + private creatureTime = 0 + private readonly activity: Activity + private readonly look: Look + private readonly weather: WeatherLayer + private readonly raining: boolean + private orb: Orb + private stars: Star[] + private visibleStars: Star[] = [] + private clouds: Cloud[] + private storm = makeStorm() + private mesh = { X: new Float32Array(0), Z: new Float32Array(0), height: new Float32Array(0), tris: new Int32Array(0), colors: new Float32Array(0) } + private sea = { y0: 0, y1: 0, rows: new Float64Array(0), ids: new Int32Array(0), idA: new Int32Array(0), idB: new Int32Array(0), mix: new Float32Array(0), cov: new Float32Array(0), open: new Float32Array(0), sky: new Float32Array(0) } + private skyBuf = new Float32Array(0) + private deckLayer: Layer = { rgba: new Float32Array(0), idx: new Int32Array(0) } + private frameLayer: Layer = { rgba: new Float32Array(0), idx: new Int32Array(0) } + private lamps: { x: number; y: number; color: RGB; blink: number }[] = [] + private leds: { x: number; y: number; color: RGB; rate: number; phase: number }[] = [] + private inside: Spots | undefined + private crew: Crew[] = [] + private launch = -1 + private nextLaunch: number + private launchCat = 0 + private landing = -1 + private nextLanding = 45 + private flyover = -1 + private flash = 0 + private egg = { wait: eggWait() * TIME_SCALE, t: -1 } + private drops: Drop[] = [] + private wipe = { t: -1, wait: 6 } + private pitch = 0 + + constructor(settings: Settings) { + super() + this.supersample = 1 + this.activity = settings.activity + this.weather = new WeatherLayer(settings.weather ?? "clear", settings.time, HORIZON) + this.raining = settings.weather === "rain" + const look = LOOKS[settings.time] + this.look = this.weather.covered ? { ...look, glitter: look.glitter * 0.12 } : look + this.orb = look.orb + // Stars only show through the windows, so they're all put there. + this.stars = makeStars(look.stars * 0.6, 1).map((star) => ({ ...star, y: TOP - 0.01 + star.y * (HORIZON - 0.03 - TOP) })) + this.clouds = makeLowPolyClouds(7) + this.nextLaunch = settings.activity === "teeming" ? 4 : 20 + const crew = settings.activity === "calm" ? 1 : settings.activity === "lively" ? 2 : 3 + for (let i = 0; i < crew; i++) { + const [x, z] = crewSpot() + this.crew.push({ x, z, tx: x, tz: z, wait: rand(5, 30), vest: VESTS[i % VESTS.length], walks: settings.activity !== "calm" && i < 2 }) + } + for (let i = 0; i < 200; i++) this.drops.push({ x: Math.random(), y: rand(TOP, SILL), r: rand(0.0028, 0.006), age: rand(0, 20), slide: 0 }) + } + + step(dt: number) { + dt = clamp(dt, 0, 0.1) * TIME_SCALE + this.time += dt + this.creatureTime += dt * CREATURE_SPEED + for (const c of this.clouds) { + c.x += c.speed * dt + if (c.x > this.A + c.size) c.x = -c.size + } + if (this.gloom > 0) driftClouds(this.storm, this.A, dt) + this.stepGloom(dt) + this.weather.step(dt) + const t = this.time + this.pitch = (PITCH + STORM_PITCH * this.gloom) * (0.75 * Math.sin(t * 0.2) + 0.25 * Math.sin(t * 0.33 + 1)) + this.flash = Math.max(0, this.flash - dt * 0.6) + this.stepCrew(dt * CREATURE_SPEED) + this.stepFlights(dt) + this.stepRain(dt) + const e = this.egg + if (e.t >= 0) e.t = e.t + dt > EGG ? -1 : e.t + dt + if (e.t < 0) { + e.wait -= dt + if (e.wait <= 0) { + e.t = 0 + e.wait = eggWait(true) * TIME_SCALE + } + } + } + + // A click on the sky through the windows sends a jet down the catapult if none is launching; anywhere else it + // flashes the deck lights. + poke(_x: number, y: number) { + if (y > TOP && y < HORIZON && this.launch < 0) { + this.startLaunch() + return + } + this.flash = 1 + } + + protected override visit() { + if (this.flyover < 0) this.flyover = 0 + } + + render() { + this.restore(this.skyBuf) + this.drawOutside() + composite(this.hdr, this.deckLayer) + this.drawLamps() + this.drawCrew() + if (this.launch >= 0) this.drawLaunch() + if (this.landing >= 0) this.drawLanding() + this.weather.draw(this.hdr, this.W, this.H) + this.drawRain() + composite(this.hdr, this.frameLayer) + this.drawConsole() + this.finish() + } + + // The bridge, the deck and the sky are painted once per size into layers; render() lays them over the moving sea in + // the same order every frame, so the frame left after layout() matches an undisturbed render pixel for pixel. + protected override layout() { + const { A, look } = this + const ox = look.night ? Math.min(A / 2 + 0.38, A - 0.2) : look.orb.y > 0.3 ? Math.min(A / 2 + 0.3, A - 0.22) : Math.max(0.15, A / 2 - 0.36) + // The looks' skies squeezed into the window band. + const squeeze = (v: number) => TOP - 0.03 + (v * (HORIZON - TOP + 0.03)) / H0 + this.orb = { ...look.orb, x: ox / A, y: squeeze(look.orb.y), r: look.orb.r * 0.7 } + const sky = look.sky.map(([v, c]): [number, RGB] => [squeeze(v), c]) + this.skyBuf = this.fine(() => { + paintSky(this.hdr, this.W, this.H, sky, this.orb, this.px) + this.weather.cover(this.hdr, this.W, this.H) + }) + this.orbs.length = 0 + this.orbs.push(this.orb.x * this.W, this.orb.y * this.H, this.orb.r * this.H) + const deckColor = this.fine(() => this.drawDeck(false)) + const deckCover = this.fine(() => this.drawDeck(true)) + this.deckLayer = layer(deckColor, deckCover) + const frameColor = this.fine(() => this.drawFrame(false)) + const frameCover = this.fine(() => this.drawFrame(true)) + this.frameLayer = layer(frameColor, frameCover) + this.placeLamps() + this.placeConsole() + this.buildSea() + const { W, H } = this + this.visibleStars = this.stars.filter((s) => { + const i = Math.floor(s.y * H) * W + Math.floor(s.x * W) + return frameCover[i * 3] <= 0 && deckCover[i * 3] <= 0 + }) + for (const c of this.clouds) c.sprite = cloudSprite(c, H, this.weather.scud ?? look.cloud, this.orb.x * A - c.x) + // The still frame, drawn as render() draws it with nothing moving. + this.hdr.set(this.skyBuf) + this.drawOutside(true) + composite(this.hdr, this.deckLayer) + composite(this.hdr, this.frameLayer) + } + + // Paints with draw() at twice the size and returns it box-filtered to the real size, for anti-aliased layers. + private fine(draw: () => void) { + const { W, H, hdr } = this + const S = W * H * 4 <= 600_000 ? 2 : 1 + this.W = W * S + this.H = H * S + this.px = S + this.hdr = new Float32Array(this.W * this.H * 3) + draw() + const big = this.hdr + this.W = W + this.H = H + this.px = 1 + this.hdr = hdr + return S > 1 ? shrink2(big, W, H) : big + } + + private paint(c: RGB, k = 1): RGB { + const t = this.look.tint + return [c[0] * t[0] * k, c[1] * t[1] * k, c[2] * t[2] * k] + } + + // Deck space to screen pixels: x across, z ahead, h up off the deck. + private P(x: number, z: number, h = 0): Point { + const H = this.H + return [(this.A / 2 + VANISH + (F * (x - CAMX)) / z) * H, (HORIZON + (F * (1 - h)) / z) * H] + } + + // Everything beyond the glass that moves: stars, clouds and the storm, the sea and islands pitching on the swell, + // the flyover and the whale. still draws the sea and islands at rest, for layout(). + private drawOutside(still = false) { + const { W, H, look } = this + const dy = still ? 0 : this.pitch + if (!still) { + if (!this.weather.covered && look.stars) paintStars(this.hdr, W, H, this.visibleStars, this.time, 1, look.night ? 0.6 : 0.25, this.points) + this.drawClouds() + paintStorm(this.hdr, W, H, this.storm, look.cloud[0], look.cloud[1], this.gloom) + } + this.drawSea(dy) + this.drawIslands(dy) + if (this.activity === "teeming") this.drawEscorts(dy) + if (still) return + if (this.flyover >= 0) this.drawFlyover() + if (this.egg.t >= 0) this.drawWhale(dy) + } + + // ---- The sea, as in Wings: rows of jittered vertices sliding toward the viewer, stitched into facets. + + private buildSea() { + const { W, H } = this + const rows = [0] + while (rows[rows.length - 1] < (SILL + 0.05 - HORIZON) / WS) rows.push(rows[rows.length - 1] + rowHeight(rows[rows.length - 1])) + const y0 = Math.floor((HORIZON - PITCH - STORM_PITCH) * H) - 1 + const y1 = Math.min(H, Math.ceil((SILL + 0.01) * H)) + const n = W * (y1 - y0) + // What the sea mirrors: the sky's average color on each row above the horizon. + const sky = new Float32Array(H * 3) + for (let y = 0; y < Math.ceil(HORIZON * H); y++) + for (let x = 0; x < W; x += 2) for (let c = 0; c < 3; c++) sky[y * 3 + c] += this.skyBuf[(y * W + x) * 3 + c] / Math.ceil(W / 2) + // The sea shows wherever neither the deck nor the bridge covers it completely. + const open = new Float32Array(n) + const d = this.deckLayer.rgba + const f = this.frameLayer.rgba + for (let i = 0; i < n; i++) { + const p = (y0 * W + i) * 4 + open[i] = d[p + 3] >= 0.999 || f[p + 3] >= 0.999 ? 0 : 1 + } + this.sea = { y0, y1, rows: Float64Array.from(rows), ids: new Int32Array(n * 4), idA: new Int32Array(n), idB: new Int32Array(n), mix: new Float32Array(n), cov: new Float32Array(n), open, sky } + } + + private drawSea(dy: number) { + const { W, H, A, look } = this + const sea = this.sea + const m = this.mesh + const phase = this.time * ROW_RATE + const base = Math.floor(phase) + const f = phase - base + const table = sea.rows + const xs: number[] = [] + const ys: number[] = [] + const ds: number[] = [] + const ids: number[] = [] + const rows: number[][] = [] + for (let r = 0; r + 1 < table.length; r++) { + const d = table[r] + (table[r + 1] - table[r]) * f + const q = r - base + const h = rowHeight(d) + const w = Math.max(0.03, h * 2.3) + const row: number[] = [] + const i0 = Math.floor((-0.25 - A / 2 / WS) / w) + const i1 = Math.ceil((A / 2 / WS + 0.25) / w) + for (let i = i0; i <= i1; i++) { + row.push(xs.length) + xs.push((i + (hash2(i, q) - 0.5) * 0.7) * w) + ys.push(d + (hash2(i + 71, q) - 0.5) * 0.3 * h) + ds.push(d) + ids.push(i * 7919 + q * 104729) + } + rows.push(row) + } + const tris: number[] = [] + for (let k = 0; k + 1 < rows.length; k++) stitch(rows[k], rows[k + 1], xs, tris) + const n = xs.length + if (m.X.length < n) { + m.X = new Float32Array(n * 2) + m.Z = new Float32Array(n * 2) + m.height = new Float32Array(n * 2) + } + const t = this.time + for (let i = 0; i < n; i++) { + const z = (FOCAL * CAMERA) / Math.max(0.002, ys[i]) + m.Z[i] = z + m.X[i] = (xs[i] * z) / FOCAL + const amp = Math.max(0.03, rowHeight(ds[i]) * 2.3) * 0.45 * smoothstep(0.004, 0.08, ds[i]) + const id = ids[i] + m.height[i] = amp * ((hash(id) - 0.5) * 1.6 + 0.25 * Math.sin(t * (0.4 + hash(id + 0.5) * 0.5) + hash(id + 0.3) * TAU)) + } + m.tris = Int32Array.from(tris) + if (m.colors.length < tris.length) m.colors = new Float32Array(tris.length * 2) + const SW = W * 2 + const SH = (sea.y1 - sea.y0) * 2 + const top = (HORIZON + dy) * H - sea.y0 + const mid = (A / 2) * H + const k2 = WS * H * 2 + sea.ids.fill(-1) + for (let k = 0; k < tris.length; k += 3) { + const a = tris[k] + const b = tris[k + 1] + const c = tris[k + 2] + rasterTri(sea.ids, SW, SH, mid * 2 + xs[a] * k2, top * 2 + ys[a] * k2, mid * 2 + xs[b] * k2, top * 2 + ys[b] * k2, mid * 2 + xs[c] * k2, top * 2 + ys[c] * k2, k / 3) + } + resolveSamples(sea.ids, W, sea.y1 - sea.y0, sea.idA, sea.idB, sea.mix, sea.cov) + shadeFacets(m.X, m.Z, m.height, m.tris, m.colors, sea.sky, H, (this.orb.x * A - A / 2) / WS, (this.orb.y - HORIZON) / WS, look.sea[0], look.sea[1], look.spec, look.glitter * (1 - 0.8 * this.gloom), look.spread, look.storm, smoothstep(0, 1, this.gloom) * 0.7) + fillSea(this.hdr, W, sea.y0, sea.idA, sea.idB, sea.mix, sea.cov, sea.open, m.colors) + this.touch(0, sea.y0, W - 1, sea.y1 - 1) + } + + // ---- The horizon: a volcano on its forested island to port, forested islets and sea stacks, escorts at teeming. + + private drawIslands(dy: number) { + const { A, H } = this + // Drawn in Wings' units about each island's own middle, then scaled down by WS. + let anchor = 0 + const P = (x: number, y: number): Point => [(anchor + (x - anchor) * WS) * H, (HORIZON + (y - H0) * WS + dy) * H] + const vx = volcanoX(A) + anchor = vx + const base = H0 + 0.004 + const lightSide = this.orb.x * A < vx ? -1 : 1 + const hill: Point[] = [P(vx - 0.27, base), P(vx - 0.2, base - 0.02), P(vx - 0.1, base - 0.035), P(vx + 0.12, base - 0.035), P(vx + 0.22, base - 0.018), P(vx + 0.29, base)] + this.polygon(hill, this.facet(FOREST, 0.4)) + const peak = P(vx - 0.01, H0 - 0.17) + const feet = [-0.17, -0.1, -0.03, 0.05, 0.12, 0.18].map((dx) => P(vx + dx, base - 0.025)) + this.polygon([peak, ...feet], this.facet(ROCK, 0.5)) + for (let i = 0; i + 1 < feet.length; i++) { + const face = (i + 0.5) / (feet.length - 1) - 0.5 + this.polygon([peak, feet[i], feet[i + 1]], this.facet(ROCK, 0.5 + face * lightSide * 0.9)) + } + const slope = (dx: number) => base - 0.025 - (1 - Math.abs(dx + 0.01) / 0.175) * 0.17 + const forest: Point[] = [P(vx - 0.2, base)] + for (let i = 0; i <= 24; i++) { + const dx = -0.19 + (i / 24) * 0.39 + const top = Math.max(slope(dx) + 0.045, base - 0.075) - (i % 2 ? 0.006 : 0) - hash(i * 2.1) * 0.006 + forest.push(P(vx + dx, Math.min(top, base - 0.02))) + } + forest.push(P(vx + 0.21, base)) + this.polygon(forest, this.facet(FOREST, 0.5 - lightSide * 0.1)) + for (let i = 0; i < 18; i++) { + const dx = -0.22 + (i / 17) * 0.47 + const top = Math.max(slope(dx) + 0.05, base - 0.06) + hash(i * 5.3) * 0.02 + if (top > base - 0.012) continue + const s = 0.012 + hash(i * 1.9) * 0.008 + this.polygon([P(vx + dx, top), P(vx + dx - s * 0.7, top + s * 1.4), P(vx + dx + s * 0.7, top + s * 1.4)], this.facet(FOREST, hash(i) > 0.5 ? 0.75 : 0.3)) + } + this.polygon([P(vx - 0.022, H0 - 0.155), P(vx - 0.01, H0 - 0.17), P(vx + 0.004, H0 - 0.157), P(vx - 0.01, H0 - 0.15)], this.facet(ROCK, 0.15)) + // Two forested islets on the starboard bow. + const right = Math.min(A / 2 + 0.62, A - 0.2) + for (const [x, w, h] of [[right, 0.08, 0.045], [right + 0.1, 0.09, 0.05]] as const) { + anchor = x + const pts = [P(x - w, base), P(x - w * 0.55, base - h * 0.55), P(x - w * 0.15, base - h), P(x + w * 0.2, base - h * 0.85), P(x + w * 0.6, base - h * 0.4), P(x + w, base)] + this.polygon(pts, this.facet(FOREST, 0.5)) + for (let i = 0; i + 1 < pts.length; i++) this.polygon([P(x, base), pts[i], pts[i + 1]], this.facet(FOREST, 0.55 - ((i - 2) / 3) * lightSide * 0.5)) + for (let i = 0; i < 7; i++) { + const dx = (i / 6 - 0.5) * w * 1.4 + const top = base - h * (1 - Math.abs(dx) / w) * 0.95 + this.polygon([P(x + dx, top - 0.01), P(x + dx - 0.009, top + 0.008), P(x + dx + 0.009, top + 0.008)], this.facet(FOREST, hash(i + x) > 0.5 ? 0.7 : 0.35)) + } + } + // Sea stacks beyond the volcano, where there's room. + const rx = vx - 0.3 + anchor = rx + if (rx > 0.06) + for (const [dx, w, h] of [[-0.03, 0.012, 0.035], [-0.012, 0.009, 0.05], [0.004, 0.011, 0.042], [0.03, 0.008, 0.028]] as const) + this.polygon([P(rx + dx - w, base), P(rx + dx - w * 0.6, base - h), P(rx + dx + w * 0.3, base - h * 1.05), P(rx + dx + w, base)], this.facet(ROCK, 0.45 + lightSide * dx * 8)) + for (const [x, w] of [[vx + 0.22, 0.022], [vx + 0.27, 0.014]] as const) (anchor = x), this.polygon([P(x - w, base), P(x, base - 0.011), P(x + w, base)], this.facet(ROCK, 0.3)) + } + + private facet(c: RGB, lit: number): RGB { + const { look } = this + const k = clamp(lit, 0, 1) + if (!look.night && look.tint[0] === 1) return [c[0] * (0.45 + 0.75 * k), c[1] * (0.45 + 0.75 * k), c[2] * (0.45 + 0.75 * k)] + const t = look.tint + return [c[0] * t[0] * 2.4 + look.key[0] * 0.08 * k, c[1] * t[1] * 2.4 + look.key[1] * 0.08 * k, c[2] * t[2] * 2.4 + look.key[2] * 0.08 * k] + } + + private drawEscorts(dy: number) { + const { A, H } = this + const hull = this.paint(HULL) + for (const [x, s] of [[A / 2 + 0.1, 0.018], [A / 2 + 0.42, 0.024]] as const) { + const y = HORIZON + 0.003 + dy + this.polygon([[(x - s) * H, (y - s * 0.12) * H], [(x + s) * H, (y - s * 0.12) * H], [(x + s * 0.85) * H, y * H], [(x - s * 0.8) * H, y * H]], hull) + this.polygon([[(x - s * 0.25) * H, (y - s * 0.12) * H], [(x - s * 0.2) * H, (y - s * 0.4) * H], [(x + s * 0.15) * H, (y - s * 0.4) * H], [(x + s * 0.25) * H, (y - s * 0.12) * H]], hull) + this.polygon([[(x - s * 0.02) * H, (y - s * 0.4) * H], [(x - s * 0.01) * H, (y - s * 0.7) * H], [(x + s * 0.01) * H, (y - s * 0.7) * H], [(x + s * 0.02) * H, (y - s * 0.4) * H]], hull) + } + } + + private drawClouds() { + const { W, H, hdr } = this + for (const c of this.clouds) { + const s = c.sprite + if (!s) continue + const x = c.x * H - s.ox + const y = c.y * H - s.oy + blitSprite(hdr, W, H, s, x, y) + this.touch(x - 1, y - 1, x + s.w + 1, y + s.h + 1) + } + } + + // ---- The ship: the flight deck, the hull's starboard flank below it, markings, parked aircraft. + + private drawDeck(mask: boolean) { + const P = (x: number, z: number, h = 0) => this.P(x, z, h) + const c = (color: RGB, k = 1): RGB => (mask ? [1, 1, 1] : this.paint(color, k)) + // The hull's flank below the starboard deck edge, down to the waterline, with a band of foam along it. + const flank: Point[] = [P(0.5, 2), P(0.5, 7.6), P(0.3, 8.5), P(0.18, 8.4, -1), P(0.42, 7.4, -1), P(0.42, 2, -1)] + this.polygon(flank, c(HULL, 0.55)) + if (!mask) { + const foam = this.look.night ? ([this.look.foam[0] * 0.3, this.look.foam[1] * 0.3, this.look.foam[2] * 0.3] as RGB) : this.look.foam + this.polygon([P(0.18, 8.4, -1), P(0.42, 7.4, -1), P(0.42, 2, -1), P(0.62, 2, -1.02), P(0.62, 7.4, -1.02), P(0.36, 8.5, -1.02)], foam, 0.35) + } + this.polygon(OUTLINE.map(([x, z]) => P(x, z)), c(HULL, 0.6)) + this.polygon(OUTLINE.map(([x, z]) => P(x * 0.985 - 0.03, z - 0.04, 0.001)), c(DECK)) + if (mask) { + this.drawParked(true) + return + } + const line = (x0: number, z0: number, x1: number, z1: number, w: number, color: RGB, k = 1) => { + const len = Math.hypot(x1 - x0, z1 - z0) + const nx = ((z1 - z0) / len) * w + const nz = (-(x1 - x0) / len) * w + this.polygon([P(x0 - nx, z0 - nz), P(x0 + nx, z0 + nz), P(x1 + nx, z1 + nz), P(x1 - nx, z1 - nz)], c(color, k)) + } + // Panel seams across the deck, faint. + for (let z = 3; z < 9; z += 0.9) line(-3.7, z, 0.4, z, 0.008, DECK, 0.75) + // The angled landing deck: a dashed centerline, edge lines, a foul line and the arresting wires. + const [[ax, az], [bx, bz]] = ANGLED + const len = Math.hypot(bx - ax, bz - az) + const ux = (bx - ax) / len + const uz = (bz - az) / len + for (let s = 0.2; s < len - 0.2; s += 0.45) line(ax + ux * s, az + uz * s, ax + ux * (s + 0.25), az + uz * (s + 0.25), 0.03, STRIPE, 0.8) + for (const side of [-1, 1]) { + const ox = uz * 0.75 * side + const oz = -ux * 0.75 * side + line(ax + ox, az + oz, ax + ux * (len - 0.4) + ox, az + uz * (len - 0.4) + oz, 0.02, STRIPE, 0.7) + } + for (let s = 0.9; s < 2.1; s += 0.4) line(ax + ux * s - uz * 0.7, az + uz * s + ux * 0.7, ax + ux * s + uz * 0.7, az + uz * s - ux * 0.7, 0.008, [0.08, 0.08, 0.08]) + // The bow catapults: tracks, yellow lineup lines and the lowered blast deflectors. + for (const cat of CATS) { + line(cat.x, cat.z0 - 0.1, cat.x, 8.95, 0.018, [0.06, 0.06, 0.07]) + line(cat.x + 0.12, cat.z0 + 0.3, cat.x + 0.12, 8.6, 0.01, YELLOW, 0.8) + this.polygon([P(cat.x - 0.5, cat.z0 - 0.75), P(cat.x + 0.5, cat.z0 - 0.75), P(cat.x + 0.5, cat.z0 - 0.5), P(cat.x - 0.5, cat.z0 - 0.5)], c(HULL, 0.5)) + } + // Taxi lines curving from the landing deck to the catapults, and an elevator outline by the edge. + for (let i = 0; i < 12; i++) { + const t0 = i / 12 + const t1 = (i + 1) / 12 + const [x0, z0] = taxiPath(t0, CATS[0]) + const [x1, z1] = taxiPath(t1, CATS[0]) + line(x0, z0, x1, z1, 0.012, YELLOW, 0.85) + } + for (const [x0, z0, x1, z1] of [[-0.6, 3.9, 0.45, 3.9], [-0.6, 4.75, 0.45, 4.75], [-0.6, 3.9, -0.6, 4.75]] as const) line(x0, z0, x1, z1, 0.012, STRIPE, 0.45) + // A white bow line and edge lines down both sides. + line(-2.7, 8.9, -0.5, 8.9, 0.015, STRIPE, 0.6) + line(0.4, 4, 0.4, 7.5, 0.012, STRIPE, 0.55) + this.drawParked(false) + // At night the deck's floodlights leave soft amber pools. + if (this.look.night) + for (const [x, z] of [[-1.2, 4.6], [-2.2, 6.6], [-0.6, 7.6], [-1.6, 8.4]] as const) { + const [px, py] = P(x, z) + poolOn(this.hdr, this.W, this.H, px, py, (2.2 / z) * this.H * 0.35 * WS, (0.9 / z) * this.H * 0.35 * WS, [1, 0.55, 0.2], 2.6) + } + } + + // Jets parked along the starboard edge with their wings folded, a deck tractor, and a helicopter by the island. + private drawParked(mask: boolean) { + const tone = (c: RGB, k = 1): RGB => (mask ? [1, 1, 1] : this.paint(c, k)) + for (const [x, z] of PARKED) { + if (!mask) { + const [sx, sy] = this.P(x - 0.15, z + 0.05) + const w = (F * 0.55 * this.H) / z + this.ellipse(sx, sy, w * 0.6, w * 0.08, 0, 0, 0, 0.35) + } + this.aircraft(x, z, 0.06, -Math.PI / 2 + 0.4, 0.85, FOLDED, tone(JET), tone(JET, 0.7), mask, true) + } + this.box(-1.75, 5.3, 0.2, 0.12, 0.07, 0.5, tone(YELLOW, 0.8), mask) + this.box(-1.78, 5.29, 0.07, 0.1, 0.12, 0.5, tone(YELLOW, 0.6), mask) + this.helicopter(-0.1, 4.4, mask) + } + + // A deck box (a tractor's body) of size w x d x h, turned by angle, its top and the faces toward the bridge drawn. + private box(x: number, z: number, w: number, d: number, h: number, angle: number, color: RGB, mask: boolean) { + const ca = Math.cos(angle) + const sa = Math.sin(angle) + const corner = (u: number, v: number, k: number) => this.P(x + u * ca + v * sa, z - u * sa + v * ca, k) + const pts = [[-w / 2, -d / 2], [w / 2, -d / 2], [w / 2, d / 2], [-w / 2, d / 2]] as const + const shade = (k: number): RGB => (mask ? [1, 1, 1] : [color[0] * k, color[1] * k, color[2] * k]) + for (let i = 0; i < 4; i++) { + const [u0, v0] = pts[i] + const [u1, v1] = pts[(i + 1) % 4] + this.polygon([corner(u0, v0, 0), corner(u1, v1, 0), corner(u1, v1, h), corner(u0, v0, h)], shade(i % 2 ? 0.55 : 0.75)) + } + this.polygon(pts.map(([u, v]) => corner(u, v, h)), shade(1)) + } + + private helicopter(x: number, z: number, mask: boolean) { + const tone = (c: RGB, k = 1): RGB => (mask ? [1, 1, 1] : this.paint(c, k)) + const at = (a: number, b: number, h: number) => this.P(x + b, z + a, h) + // Nose ahead: a cabin, a tail boom with its fin, and four drooping blades at rest. + if (!mask) this.ellipse(...this.P(x, z), (F * 0.32 * this.H) / z, (F * 0.04 * this.H) / z, 0, 0, 0, 0.35) + this.polygon([at(0.35, -0.12, 0), at(0.35, 0.12, 0), at(-0.25, 0.12, 0), at(-0.25, -0.12, 0), at(-0.25, -0.12, 0.2), at(0.3, -0.12, 0.2)], tone(ROTOR, 0.7)) + this.polygon([at(0.38, -0.1, 0.17), at(0.38, 0.1, 0.17), at(0.25, 0.12, 0.24), at(-0.25, 0.12, 0.24), at(-0.25, -0.12, 0.24), at(0.25, -0.12, 0.24)], tone(ROTOR)) + this.polygon([at(-0.25, -0.04, 0.12), at(-0.25, 0.04, 0.12), at(-0.85, 0.02, 0.17), at(-0.85, -0.02, 0.17)], tone(ROTOR, 0.85)) + this.polygon([at(-0.8, -0.01, 0.15), at(-0.9, -0.01, 0.15), at(-0.95, -0.01, 0.38), at(-0.88, -0.01, 0.38)], tone(ROTOR, 0.6)) + for (let i = 0; i < 4; i++) { + const a = 0.4 + (i * TAU) / 4 + const ca = Math.cos(a) + const sa = Math.sin(a) + this.polygon([at(0, 0, 0.28), at(ca * 0.7 - sa * 0.03, sa * 0.7 + ca * 0.03, 0.25), at(ca * 0.7 + sa * 0.03, sa * 0.7 - ca * 0.03, 0.25)], tone(ROTOR, 0.5)) + } + } + + // An aircraft in deck space at (x, z), h up, nose along heading (0 ahead, plus toward starboard), length L, from a + // top-down form, with its twin tails standing up; folded adds the wing panels folded up at the hinges. + private aircraft(x: number, z: number, h: number, heading: number, L: number, form: readonly (readonly Point[])[], color: RGB, fin: RGB, mask = false, folded = false, alpha = 1) { + const fx = Math.sin(heading) + const fz = Math.cos(heading) + const at = (a: number, b: number, up = 0) => this.P(x + L * (a * fx + b * fz), z + L * (a * fz - b * fx), h + L * up) + for (const poly of form) this.polygon(poly.map(([a, b]) => at(a, b)), color, alpha) + if (!mask) this.polygon([at(0.32, -0.025, 0.04), at(0.32, 0.025, 0.04), at(0.12, 0.04, 0.06), at(0.12, -0.04, 0.06)], [fin[0] * 0.5, fin[1] * 0.55, fin[2] * 0.7], alpha) + for (const side of [-1, 1]) { + this.polygon([at(-0.5, side * 0.07, 0.02), at(-0.28, side * 0.07, 0.02), at(-0.42, side * 0.12, 0.3), at(-0.5, side * 0.13, 0.3)], fin, alpha) + if (folded) this.polygon([at(0.02, side * 0.24), at(-0.16, side * 0.24), at(-0.14, side * 0.23, 0.18), at(0.0, side * 0.23, 0.18)], fin, alpha) + } + } + + // ---- Lights. + + private placeLamps() { + this.lamps = [] + const lamp = (x: number, z: number, color: RGB, blink = 0) => { + const [px, py] = this.P(x, z) + if (py < SILL * this.H - 1) this.lamps.push({ x: px, y: py, color, blink }) + } + // Along the deck edges from the island to the bow, then the angled deck's centerline. + for (let i = 0; i + 1 < OUTLINE.length; i++) { + const [x0, z0] = OUTLINE[i] + const [x1, z1] = OUTLINE[i + 1] + const n = Math.max(1, Math.round(Math.hypot(x1 - x0, z1 - z0) / 0.7)) + for (let k = 0; k < n; k++) { + const z = lerp(z0, z1, k / n) + if (z > 3.4) lamp(lerp(x0, x1, k / n) * 0.985 - 0.03, z - 0.04, AMBER) + } + } + const [[ax, az], [bx, bz]] = ANGLED + for (let s = 0.1; s < 0.95; s += 0.09) lamp(lerp(ax, bx, s), lerp(az, bz, s), [1.6, 1.2, 0.5]) + lamp(-2.8, 9, AMBER, 0.5) + lamp(-0.4, 9, AMBER, 0.6) + lamp(CATS[0].x, 8.95, [0.3, 1.6, 0.5], 0.35) + } + + private drawLamps() { + const { look } = this + const t = this.time + const flicker = this.gloom > 0.3 && hash(Math.floor(t * 2)) > 0.85 ? 0.5 : 1 + for (const [i, l] of this.lamps.entries()) { + // By day only the blinking ones show, unless a click flashes the lot. + const base = l.blink ? 0.3 + 0.7 * smoothstep(0.3, 0.9, Math.sin(t * l.blink + i)) : look.lamp < 0.5 ? 0 : 1 + const k = (look.lamp * base * flicker + this.flash * 0.8) * (look.night ? 0.7 : 0.5) + if (k <= 0.01) continue + this.point(l.x, l.y, l.color[0] * k, l.color[1] * k, l.color[2] * k, l.blink ? 2 : 1) + } + } + + // ---- The crew: tiny figures in colored vests, walking slowly between spots now and then. + + private stepCrew(dt: number) { + for (const c of this.crew) { + if (!c.walks) continue + const dx = c.tx - c.x + const dz = c.tz - c.z + const d = Math.hypot(dx, dz) + if (d > 0.01) { + const s = Math.min(d, 0.35 * dt) + c.x += (dx / d) * s + c.z += (dz / d) * s + continue + } + c.wait -= dt + if (c.wait > 0) continue + ;[c.tx, c.tz] = crewSpot() + c.wait = rand(6, 18) + } + } + + private drawCrew() { + const { H, look } = this + for (const [i, c] of this.crew.entries()) { + const [x, y] = this.P(c.x, c.z) + const s = (F * H) / c.z + const w = Math.max(this.cells ? 1.6 : 0.8, s * 0.05) + const legs = this.paint([0.12, 0.12, 0.14], look.night ? 2.5 : 1) + const vest = this.paint(c.vest, look.night ? 3 : 1) + this.polygon([[x - w * 0.4, y], [x + w * 0.4, y], [x + w * 0.4, y - s * 0.05], [x - w * 0.4, y - s * 0.05]], legs) + this.polygon([[x - w * 0.5, y - s * 0.05], [x + w * 0.5, y - s * 0.05], [x + w * 0.45, y - s * 0.095], [x - w * 0.45, y - s * 0.095]], vest) + this.disc(x, y - s * 0.11, w * 0.4, vest[0], vest[1], vest[2], 1) + // At night each carries a lit wand. + if (look.night) this.splat(x + w * 0.7, y - s * 0.08, 0.9, 0.45 + 0.2 * (i % 2), 0.1) + } + } + + // ---- Launches and recoveries. + + private stepFlights(dt: number) { + if (this.launch >= 0) this.launch = this.launch + dt > LAUNCH ? -1 : this.launch + dt + if (this.landing >= 0) this.landing = this.landing + dt > LANDING ? -1 : this.landing + dt + if (this.flyover >= 0) this.flyover = this.flyover + dt > FLYOVER ? -1 : this.flyover + dt + if (this.activity === "calm") return + this.nextLaunch -= dt + if (this.nextLaunch <= 0 && this.launch < 0) { + this.startLaunch() + this.nextLaunch = this.activity === "teeming" ? rand(30, 45) : rand(55, 85) + } + if (this.activity !== "teeming") return + this.nextLanding -= dt + if (this.nextLanding <= 0 && this.landing < 0 && (this.launch < 0 || this.launch > 15)) { + this.landing = 0 + this.nextLanding = rand(70, 110) + } + } + + private startLaunch() { + this.launch = 0 + this.launchCat = 0 + } + + // A launch: the jet taxis from the landing deck to the catapult, the blast deflector rises behind it, it spools up + // with steam drifting off the track, then the catapult flings it off the bow and it climbs away, banking. + private drawLaunch() { + const t = this.launch + const cat = CATS[this.launchCat] + const deflector = smoothstep(6.5, 8.5, t) * (1 - smoothstep(15, 17, t)) + if (deflector > 0) { + const a = deflector * 1.05 + const hz = cat.z0 - 0.5 + const P = (x: number, z: number, h: number) => this.P(x, z, h) + const back = hz - 0.25 * Math.cos(a) + const up = 0.25 * Math.sin(a) + this.polygon([P(cat.x - 0.5, hz, 0), P(cat.x + 0.5, hz, 0), P(cat.x + 0.5, back, up), P(cat.x - 0.5, back, up)], this.paint(HULL, 0.85)) + } + let x: number + let z: number + let h = 0.06 + let heading = 0 + let burn = 0.15 + let alpha = 1 + if (t < 7) { + const u = smoothstep(0, 7, t) + ;[x, z] = taxiPath(u, cat) + const [nx, nz] = taxiPath(Math.min(1, u + 0.02), cat) + heading = u < 0.98 ? Math.atan2(nx - x, nz - z) : 0 + } else if (t < 12.5) { + x = cat.x + z = cat.z0 + burn = 0.15 + 0.85 * smoothstep(8, 11, t) + } else if (t < 14) { + x = cat.x + z = cat.z0 + (9.1 - cat.z0) * ((t - 12.5) / 1.5) ** 2 + burn = 1 + } else { + const c = t - 14 + x = cat.x - c * c * 0.12 + z = 9.1 + 4 * c + 0.6 * c * c + h = 0.06 + 0.12 * c * c + heading = -Math.min(0.5, c * c * 0.02) + burn = 1 + alpha = 1 - smoothstep(8, 10, c) + } + this.drawSteam(cat) + if (alpha <= 0) return + this.jet(x, z, h, heading, burn, alpha) + } + + // Steam: wisps off the catapult track while the jet waits, a cloud along it after the shot, drifting and thinning. + private drawSteam(cat: { x: number; z0: number }) { + const t = this.launch + const { look } = this + const color: RGB = look.night ? [0.08, 0.16, 0.32] : look.tint[0] < 1 ? [0.75, 0.5, 0.55] : [0.82, 0.85, 0.88] + for (let i = 0; i < 7; i++) { + const z = cat.z0 + 0.3 + i * 0.4 + const born = i < 2 ? 9 + i : 12.6 + i * 0.2 + const age = t - born + if (age < 0 || age > 6) continue + const k = smoothstep(0, 0.6, age) * (1 - smoothstep(2, 6, age)) * (i < 2 ? 0.25 : 0.4) + const [px, py] = this.P(cat.x + age * 0.06, z, 0.05 + age * 0.06) + const r = ((0.12 + age * 0.06) * F * this.H) / z + this.ellipse(px, py, r, r * 0.6, color[0], color[1], color[2], k) + } + } + + // A recovery: a jet comes in head-on over the bow down the angled deck, its landing light on, catches a wire and + // stops short, waits, then taxis off toward the island and out of view under the console. + private drawLanding() { + const t = this.landing + const [[ax, az], [bx, bz]] = ANGLED + const along = (z: number) => ax + ((bx - ax) * (z - az)) / (bz - az) + const toward = Math.atan2(ax - bx, az - bz) + let x: number + let z: number + let h = 0.06 + let heading = toward + let burn = 0.35 + let light = 1 + if (t < 12) { + z = lerp(70, 6.6, smoothstep(0, 12, t) * 0.3 + (t / 12) * 0.7) + x = along(z) + h = 0.06 + (z - 6.6) * 0.085 + } else if (t < 13.5) { + const u = (t - 12) / 1.5 + z = 6.6 - 1.7 * (1 - (1 - u) ** 3) + x = along(z) + } else if (t < 19) { + z = 4.9 + x = along(z) + burn = 0.15 + light = 0.6 + } else { + const u = smoothstep(19, LANDING, t) + const p0: Point = [along(4.9), 4.9] + const p1: Point = [-1.6, 3.9] + const p2: Point = [-0.8, 2.4] + ;[x, z] = bezier(p0, p1, p2, u) + const [nx, nz] = bezier(p0, p1, p2, Math.min(1, u + 0.02)) + heading = Math.atan2(nx - x, nz - z) + burn = 0.15 + light = 0.6 * (1 - u) + } + const alpha = smoothstep(0, 1.5, t) + this.jet(x, z, h, heading, burn, alpha, light) + } + + // A jet in deck space with its afterburners glowing by burn and, coming in, a landing light. + private jet(x: number, z: number, h: number, heading: number, burn: number, alpha: number, light = 0) { + const { look, H } = this + if (z < 2.2) return + const L = 0.85 + const s = (F * L * H) / z + const body = this.craft(JET) + const fin = this.craft([JET[0] * 0.7, JET[1] * 0.7, JET[2] * 0.75]) + if (s < 2) { + const [px, py] = this.P(x, z, h) + this.splat(px, py, body[0] * alpha, body[1] * alpha, body[2] * alpha) + if (burn > 0.5) this.splat(px, py, BURNER[0] * 0.3 * alpha, BURNER[1] * 0.3 * alpha, BURNER[2] * 0.3 * alpha) + return + } + // Its shadow on the deck while it's low over it. + if (h < 0.5 && z < 9.2) { + const [sx, sy] = this.P(x, z) + this.ellipse(sx, sy, s * 0.45, s * 0.07, 0, 0, 0, 0.3 * alpha * (1 - h * 2)) + } + this.aircraft(x, z, h, heading, L, JETFORM, body, fin, false, false, alpha) + const fx = Math.sin(heading) + const fz = Math.cos(heading) + const k = (0.3 + burn * 1.3) * alpha + for (const side of [-1, 1]) { + const [bx, by] = this.P(x + L * (-0.52 * fx + side * 0.04 * fz), z + L * (-0.52 * fz - side * 0.04 * fx), h + L * 0.03) + if (fz > -0.3) this.disc(bx, by, Math.max(1, 0.06 * s), BURNER[0] * 0.3 * k, BURNER[1] * 0.3 * k, BURNER[2] * 0.3 * k, 0.6) + if (fz > -0.3) this.disc(bx, by, Math.max(0.7, 0.025 * s), BURNER[0] * k, BURNER[1] * k, BURNER[2] * k, 1) + } + if (light > 0) { + const [lx, ly] = this.P(x + L * 0.35 * fx, z + L * 0.35 * fz, h - 0.02) + const b = light * alpha * (look.night ? 1 : 0.5) + this.disc(lx, ly, Math.max(1, 0.03 * s), AMBER[0] * b, AMBER[1] * b, AMBER[2] * b, 1) + } + } + + private craft(c: RGB): RGB { + return this.paint(c, this.look.night ? 3.2 : 1) + } + + // When the agent finishes: a vee of five jets crosses ahead of the bow, trailing contrails. + private drawFlyover() { + const { look, H } = this + const t = this.flyover + const lead = lerp(-26, 30, t / FLYOVER) + const fade = 1 - smoothstep(FLYOVER - 2, FLYOVER, t) + for (let i = 0; i < 5; i++) { + const rank = Math.ceil(i / 2) + const side = i % 2 ? -1 : 1 + const x = lead - rank * 1.6 + const z = 16 + side * rank * 1.1 + const [hx, hy] = this.P(x - 0.5, z, 3) + const [tx, ty] = this.P(x - 0.5 - Math.min(10, t * 2), z, 3) + trail(this.hdr, this.W, H, hx, hy, tx, ty, Math.max(1, 0.003 * H), look.trail, 0.35 * fade) + this.touch(Math.min(hx, tx) - 3, hy - 3, Math.max(hx, tx) + 3, hy + 3) + this.aircraft(x, z, 3, Math.PI / 2, 0.85, JETFORM, this.craft(JET), this.craft(JET), false, false, fade) + } + } + + // The easter egg: a humpback breaching off the starboard bow, rising nearly upright, twisting over and crashing back + // in a burst of spray. + private drawWhale(dy: number) { + const { A, H, look } = this + const p = this.egg.t / EGG + const wx = Math.min(A / 2 + VANISH + 0.25, A - 0.12) * H + const wy = (HORIZON + 0.035 + dy) * H + const s = 0.03 * H + const foam: RGB = look.night ? [look.foam[0] * 0.6, look.foam[1] * 0.6, look.foam[2] * 0.6] : look.foam + const breach = clamp((p - 0.08) / 0.6, 0, 1) + if (breach > 0 && breach < 1) { + const angle = lerp(1.35, 0.15, breach) + const rise = Math.sin(breach * Math.PI) * 1.3 + const cx = wx + breach * 0.5 * s + const cy = wy - rise * s + const ca = Math.cos(angle) + const sa = Math.sin(angle) + const Q = (u: number, v: number): Point => [cx + (u * ca + v * sa * 0.3) * s, Math.min(wy, cy - (u * sa - v * ca) * s)] + this.polygon(WHALE_BODY.map(([u, v]) => Q(u, v)), this.craft(WHALE)) + this.polygon(WHALE_BELLY.map(([u, v]) => Q(u, v)), this.paint(WHALE, look.night ? 5 : 2.2)) + this.polygon(WHALE_FIN.map(([u, v]) => Q(u, v)), this.paint(WHALE, look.night ? 4 : 1.8)) + } + // Spray as it breaks the surface and as it falls back. + for (const [start, k] of [[0.06, 0.6], [0.66, 1]] as const) { + const age = p - start + if (age < 0 || age > 0.3) continue + const f = age / 0.3 + for (let i = 0; i < 14; i++) { + const a = Math.PI * (0.1 + 0.8 * hash(i * 3.1 + start)) + const r = s * (0.4 + 1.4 * hash(i * 1.7 + start)) * Math.sqrt(f) + const up = Math.sin(f * Math.PI) * s * (0.6 + hash(i) * 0.8) * k + this.disc(wx + start * s + Math.cos(a) * r, wy - up * Math.sin(a), s * 0.12 * (1 - f * 0.5), foam[0], foam[1], foam[2], (1 - f) * 0.7) + } + this.ellipse(wx + start * s, wy, s * (0.6 + f * 1.4), s * 0.12, foam[0], foam[1], foam[2], (1 - f) * 0.45) + } + } + + // ---- The glass: rain beading and running down it, and the wiper sweeping the middle pane now and then. + + private stepRain(dt: number) { + const rain = this.rainAmount() + if (rain <= 0) return + const w = this.wipe + if (w.t >= 0) { + w.t += dt + if (w.t > 3) { + w.t = -1 + w.wait = rand(14, 22) + } + } else { + w.wait -= dt + if (w.wait <= 0 && rain > 0.3) w.t = 0 + } + const angle = this.wiperAngle() + const { A } = this + for (const d of this.drops) { + d.age += dt + if (d.slide > 0) { + d.y += d.slide * dt + if (d.y > SILL) Object.assign(d, { x: Math.random(), y: rand(TOP, SILL), age: 0, slide: 0 }) + continue + } + if (d.age > 6 && Math.random() < dt * 0.02 * rain) d.slide = rand(0.02, 0.05) + // The wiper clears what it sweeps over. + if (angle !== undefined) { + const dx = d.x * A - A / 2 + const dy = SILL + 0.005 - d.y + if (Math.hypot(dx, dy) < WIPER && Math.abs(Math.atan2(dx, dy) - angle) < 0.08) d.age = 0 + } + } + } + + private rainAmount() { + return Math.max(this.raining ? 1 : 0, smoothstep(0.2, 1, this.gloom)) + } + + // The wiper's angle from upright while it sweeps out and back, or undefined while it rests. + private wiperAngle() { + const t = this.wipe.t + if (t < 0) return undefined + return -1.25 + 2.5 * Math.sin((t / 3) * Math.PI) + } + + private drawRain() { + const rain = this.rainAmount() + if (rain <= 0) return + const { A, H, look } = this + const c = look.drop + const count = Math.min(this.drops.length, Math.round(35 * A * rain)) + for (let i = 0; i < count; i++) { + const d = this.drops[i] + const k = smoothstep(0, 4, d.age) * rain + if (k <= 0.02) continue + const x = d.x * A * H + const y = d.y * H + const r = Math.max(this.cells ? 1.2 : 0.6, d.r * H) + if (d.slide > 0) trail(this.hdr, this.W, H, x, y, x, y - 0.04 * H, r * 0.5, c, 0.25 * k) + this.disc(x, y + r * 0.25, r, c[0] * 0.15, c[1] * 0.15, c[2] * 0.15, 0.3 * k) + this.disc(x, y - r * 0.2, r * 0.7, c[0], c[1], c[2], 0.35 * k) + if (d.slide > 0) this.touch(x - r - 2, y - 0.04 * H - 2, x + r + 2, y + r + 2) + } + const angle = this.wiperAngle() + if (angle === undefined) return + const px = (A / 2) * H + const py = (SILL + 0.005) * H + const ux = Math.sin(angle) + const uy = -Math.cos(angle) + const len = WIPER * H + const w = this.thick(0.004, 1) + const arm = this.look.frame + this.polygon([[px - uy * w, py + ux * w], [px + uy * w, py - ux * w], [px + ux * len + uy * w, py + uy * len - ux * w], [px + ux * len - uy * w, py + uy * len + ux * w]], arm) + this.polygon([[px + ux * len * 0.3 - uy * w * 1.6, py + uy * len * 0.3 + ux * w * 1.6], [px + ux * len * 0.3 + uy * w * 1.6, py + uy * len * 0.3 - ux * w * 1.6], [px + ux * len + uy * w * 1.6, py + uy * len - ux * w * 1.6], [px + ux * len - uy * w * 1.6, py + uy * len + ux * w * 1.6]], arm) + } + + // ---- The bridge: the overhead with its gear, the window frames, and the console with its instruments. + + // The windows' top and bottom at x (screen heights): the side panes turn away, so their edges close in. + private paneEdges(x: number): [number, number] { + const side = Math.max(0, Math.abs(x - this.A / 2) - 0.5) + return [TOP + side * 0.035, SILL - side * 0.03] + } + + private mullions() { + const { A } = this + return [0.5, 1.15, 1.8, 2.45, 3.1].flatMap((d) => [-d, d]).filter((d) => Math.abs(d) < A / 2 + 0.1) + } + + private spots() { + return spotsFor(this.A) + } + + private drawFrame(mask: boolean) { + const { A, H, look } = this + const s = this.spots() + const col = (c: RGB, k = 1): RGB => (mask ? [1, 1, 1] : [c[0] * k, c[1] * k, c[2] * k]) + const P = (x: number, y: number): Point => [x * H, y * H] + const rect = (x0: number, y0: number, x1: number, y1: number, c: RGB) => this.polygon([P(x0, y0), P(x1, y0), P(x1, y1), P(x0, y1)], c) + const xs = [0, ...this.mullions().map((d) => A / 2 + d), A / 2, A].sort((a, b) => a - b) + const top = (x: number) => this.paneEdges(x)[0] + const sill = (x: number) => this.paneEdges(x)[1] + const band = (y0: (x: number) => number, y1: (x: number) => number, c: RGB) => this.polygon([...xs.map((x) => P(x, y0(x))), ...xs.slice().reverse().map((x) => P(x, y1(x)))], c) + // The bulkhead above the windows, and the ceiling with its seams, an air-conditioning vent, LED light strips and + // cable trays. + this.polygon([P(0, 0), P(A, 0), ...xs.slice().reverse().map((x) => P(x, top(x)))], col(look.panel)) + rect(0, 0, A, 0.04, col(look.frame, 1.3)) + if (!mask) { + for (let x = -0.3; x < A + 0.3; x += 0.24) this.polygon([P(x - 0.002, 0), P(x + 0.002, 0), P(A / 2 + (x - A / 2) * 0.88 + 0.002, 0.04), P(A / 2 + (x - A / 2) * 0.88 - 0.002, 0.04)], col(look.frame, 0.55)) + rect(0, 0.019, A, 0.022, col(look.frame, 0.6)) + rect(A / 2 - 0.09, 0.006, A / 2 + 0.09, 0.032, col(look.frame, 2.2)) + for (let i = 0; i < 6; i++) rect(A / 2 - 0.08, 0.01 + i * 0.0035, A / 2 + 0.08, 0.0115 + i * 0.0035, col(look.frame, 0.7)) + for (const x of [A / 2 - 0.6, A / 2 + 0.6]) { + rect(x - 0.08, 0.026, x + 0.08, 0.033, col(look.bezel, 1.2)) + rect(x - 0.075, 0.028, x + 0.075, 0.031, col(look.lamplight, look.night ? 0.5 : 0.9)) + poolOn(this.hdr, this.W, H, x * H, 0.06 * H, 0.32 * H, 0.1 * H, look.lamplight, look.night ? 0.8 : 0.5) + } + } + for (const y of [0.045, 0.062]) { + rect(0, y, A, y + 0.011, col(look.frame, 1.8)) + if (!mask) for (let k = 0; k < 3; k++) rect(0, y + 0.002 + k * 0.003, A, y + 0.0035 + k * 0.003, col(k === 1 ? look.bezel : look.frame, k === 1 ? 0.9 : 3)) + } + if (!mask) for (let x = 0.12; x < A; x += 0.33) rect(x, 0.04, x + 0.008, 0.078, col(look.bezel, 0.8)) + // The window header with its status light strip, the sill, and mullions leaning in as the glass slants out. + band((x) => top(x) - 0.022, top, col(look.frame, 1)) + band((x) => top(x) - 0.006, (x) => top(x) + 0.004, mask ? col(look.frame) : this.rimmed(1.6, 0.3)) + rect(A / 2 - 0.2, 0.176, A / 2 + 0.2, 0.19, col(look.bezel, 0.7)) + for (const d of this.mullions()) { + const x = A / 2 + d + const lean = Math.abs(d) > 0.6 ? 0.92 : 0.95 + const [t, b] = this.paneEdges(x) + const tx = A / 2 + d * lean + const inner = -Math.sign(d) + this.polygon([P(x - 0.026, b + 0.01), P(x + 0.026, b + 0.01), P(tx + 0.02, t - 0.02), P(tx - 0.02, t - 0.02)], col(look.frame)) + if (!mask) this.polygon([P(x + inner * 0.026, b), P(x + inner * 0.019, b), P(tx + inner * 0.014, t), P(tx + inner * 0.02, t)], this.rimmed(1.2, 0.35)) + } + this.drawOverhead(s, mask) + this.drawConsoleBody(s, mask, band, sill) + } + + // The overhead console: a comms rack of lit keys with a headset on its hook, three slim repeater screens (heading, + // speed and wind), a digital clock, and a row of switches under guards. + private drawOverhead(s: Spots, mask: boolean) { + const { H, look } = this + const col = (c: RGB, k = 1): RGB => (mask ? [1, 1, 1] : [c[0] * k, c[1] * k, c[2] * k]) + const P = (x: number, y: number): Point => [x * H, y * H] + const rect = (x0: number, y0: number, x1: number, y1: number, c: RGB) => this.polygon([P(x0, y0), P(x1, y0), P(x1, y1), P(x0, y1)], c) + const disc = (x: number, y: number, r: number, c: RGB, ry = r) => this.ellipse(x * H, y * H, r * H, ry * H, c[0], c[1], c[2], 1) + const g = look.glow + // The overhead console's slanted face runs the width of the bulkhead. + rect(0, 0.08, this.A, 0.172, col(look.panel, 1.25)) + rect(0, 0.08, this.A, 0.084, mask ? col(look.frame) : this.rimmed(1.6, 0.2)) + for (const r of [s.rack, s.rack2]) { + if (!r) continue + plate(r.x0, 0.088, r.x1, 0.166) + rect(r.x0 + 0.008, 0.093, r.x1 - 0.008, 0.103, col(look.screen, 1.4)) + if (!mask) rect(r.x0 + 0.012, 0.096, r.x0 + 0.012 + (r.x1 - r.x0) * 0.4, 0.1, col(g, 0.6)) + if (!mask) for (const [x, y] of keyGrid(r)) rect(x - 0.008, y - 0.006, x + 0.008, y + 0.006, col(look.bezel, 1.3)) + } + // The headset on its hook at the rack's side; its cord is drawn each frame. + const hx = s.rack.x1 - 0.022 + rect(hx - 0.003, 0.166, hx + 0.003, 0.178, col(look.bezel, 1.4)) + if (!mask) { + for (let i = 0; i < 7; i++) { + const a0 = Math.PI * (i / 7) + const a1 = Math.PI * ((i + 1) / 7) + const arc = (a: number, r: number): Point => P(hx - Math.cos(a) * r, 0.2 - Math.sin(a) * r * 0.9) + this.polygon([arc(a0, 0.026), arc(a1, 0.026), arc(a1, 0.021), arc(a0, 0.021)], col(look.frame, 3)) + } + } + for (const side of [-1, 1]) disc(hx + side * 0.024, 0.205, 0.009, col(look.frame, 3.4), 0.013) + if (!mask) this.polygon([P(hx - 0.03, 0.208), P(hx - 0.027, 0.206), P(hx - 0.005, 0.226), P(hx - 0.008, 0.229)], col(look.frame, 3.4)) + // The slim repeaters: a heading tape, a speed bar and a wind dial. + for (const [i, cx] of s.screens.entries()) { + const hw = s.sw / 2 + plate(cx - hw, 0.092, cx + hw, 0.158) + rect(cx - hw + 0.007, 0.099, cx + hw - 0.007, 0.151, col(look.screen)) + if (mask) continue + if (i === 0) { + for (let k = -4; k <= 4; k++) rect(cx + k * hw * 0.2 - 0.0012, k % 2 ? 0.118 : 0.112, cx + k * hw * 0.2 + 0.0012, 0.126, col(g, 0.6)) + rect(cx - hw + 0.012, 0.128, cx + hw - 0.012, 0.13, col(g, 0.5)) + this.polygon([P(cx - 0.006, 0.142), P(cx + 0.006, 0.142), P(cx, 0.132)], col(look.glow2)) + continue + } + if (i === 1) { + rect(cx - hw + 0.014, 0.115, cx + hw - 0.014, 0.133, col(g, 0.35)) + rect(cx - hw + 0.016, 0.117, cx - hw + 0.016 + (s.sw - 0.032) * 0.62, 0.131, col(g, 0.85)) + for (let k = 0; k <= 5; k++) rect(cx - hw + 0.014 + (k / 5) * (s.sw - 0.028) - 0.001, 0.136, cx - hw + 0.014 + (k / 5) * (s.sw - 0.028) + 0.001, 0.142, col(g, 0.5)) + continue + } + disc(cx, 0.125, 0.021, col(g, 0.5)) + disc(cx, 0.125, 0.018, col(look.screen)) + this.polygon([P(cx - 0.012, 0.132), P(cx + 0.011, 0.117), P(cx + 0.006, 0.124), P(cx + 0.015, 0.113), P(cx + 0.002, 0.12), P(cx + 0.008, 0.115)], col(look.glow2)) + } + // The clock: glowing seven-segment digits in a dark window; the colon blinks each frame. + const c = s.clock + plate(c.x0, 0.095, c.x1, 0.155) + rect(c.x0 + 0.008, 0.102, c.x1 - 0.008, 0.148, col(look.screen, 0.6)) + if (!mask) { + const dw = (c.x1 - c.x0 - 0.04) / 4 + for (let d = 0; d < 4; d++) { + const x = c.x0 + 0.014 + d * dw + (d > 1 ? 0.012 : 0) + sevenSegment((pts, color) => this.polygon(pts, color), x, 0.108, dw * 0.7, 0.034, DIGITS[d], col(g, 0.95), col(g, 0.12), H) + } + } + // The guarded switches: toggles behind hinged red covers. + const gd = s.guards + plate(gd.x0, 0.09, gd.x1, 0.166) + if (!mask) + for (const y of [0.104, 0.138]) + for (let x = gd.x0 + 0.018; x < gd.x1 - 0.012; x += 0.03) { + rect(x - 0.009, y - 0.004, x + 0.009, y + 0.018, col(look.night ? [0.2, 0.02, 0.01] : [0.32, 0.05, 0.04])) + rect(x - 0.006, y - 0.001, x + 0.006, y + 0.015, col(look.panel, 0.9)) + rect(x - 0.0015, y + 0.002, x + 0.0015, y + 0.012, col(look.bezel, 1.6)) + } + + function plate(x0: number, y0: number, x1: number, y1: number) { + rect(x0, y0, x1, y1, col(look.bezel, 0.75)) + rect(x0 + 0.003, y0 + 0.003, x1 - 0.003, y1 - 0.003, col(look.panel, 1.6)) + } + } + + // The console: its rounded top with key panels, a row of multifunction displays in touch bezels (radar, an + // electronic chart, the ship's status and the engines), and the desk in front with a keyboard and trackball, the + // helm joystick and the throttle quadrant; an officer's chair in the corner. + private drawConsoleBody(s: Spots, mask: boolean, band: (y0: (x: number) => number, y1: (x: number) => number, c: RGB) => void, sill: (x: number) => number) { + const { A, H, look } = this + const col = (c: RGB, k = 1): RGB => (mask ? [1, 1, 1] : [c[0] * k, c[1] * k, c[2] * k]) + const P = (x: number, y: number): Point => [x * H, y * H] + const rect = (x0: number, y0: number, x1: number, y1: number, c: RGB) => this.polygon([P(x0, y0), P(x1, y0), P(x1, y1), P(x0, y1)], c) + const rounded = (x0: number, y0: number, x1: number, y1: number, r: number, c: RGB) => this.polygon([P(x0 + r, y0), P(x1 - r, y0), P(x1, y0 + r), P(x1, y1 - r), P(x1 - r, y1), P(x0 + r, y1), P(x0, y1 - r), P(x0, y0 + r)], c) + const disc = (x: number, y: number, r: number, c: RGB, ry = r) => this.ellipse(x * H, y * H, r * H, ry * H, c[0], c[1], c[2], 1) + const g = look.glow + const g2 = look.glow2 + // The console's gray top with a rounded front edge, its face, and the desk below. + band((x) => sill(x) - 0.006, () => 0.7, col(look.panel, 1.6)) + rect(0, 0.7, A, 0.714, mask ? col(look.frame) : this.rimmed(2.6, 0.45)) + rect(0, 0.714, A, 0.9, col(look.panel, 0.9)) + rect(0, 0.9, A, 1, col(look.panel, 1.35)) + rect(0, 0.9, A, 0.908, mask ? col(look.frame) : this.rimmed(2.4, 0.4)) + // Key panels along the top; their lamps are lit each frame. + for (const [x0, x1] of s.keyPanels) { + rounded(x0, 0.618, x1, 0.69, 0.006, col(look.screen, 1.6)) + if (!mask) + for (let x = x0 + 0.015; x < x1 - 0.01; x += 0.03) { + rect(x - 0.009, 0.628, x + 0.009, 0.642, col(look.bezel, 1.1)) + rect(x - 0.009, 0.67, x + 0.009, 0.682, col(look.bezel, 0.9)) + } + } + // The displays. + const [y0, y1] = [DISPLAY_TOP, DISPLAY_BOTTOM] + for (const [i, dx] of s.displays.entries()) { + const x1 = dx + s.dw + rounded(dx - 0.01, y0 - 0.012, x1 + 0.01, y1 + 0.03, 0.012, col(look.bezel)) + rect(dx, y0, x1, y1, col(look.screen)) + // Soft keys along the bezel's foot. + if (!mask) for (let k = 0; k < 6; k++) rect(dx + 0.01 + (k * (s.dw - 0.02)) / 6, y1 + 0.009, dx + 0.01 + ((k + 0.7) * (s.dw - 0.02)) / 6, y1 + 0.019, col(look.bezel, 1.8)) + if (mask) continue + const cx = dx + s.dw / 2 + const cy = (y0 + y1) / 2 + if (i === 0) { + // Radar: range rings around the scope (the sweep is drawn each frame) and track tables beside it. + const r = s.scope.r + disc(cx, cy, r + 0.004, col(g, 0.35)) + disc(cx, cy, r, col(look.screen, 0.6)) + for (const side of [-1, 1]) + if (s.dw > 0.26) for (let k = 0; k < 5; k++) rect(cx + side * (r + 0.015), y0 + 0.02 + k * 0.022, cx + side * (r + 0.015) + side * (s.dw / 2 - r - 0.03), y0 + 0.027 + k * 0.022, col(g, 0.25 + 0.15 * (k % 2))) + continue + } + if (i === 1) { + // The electronic chart: land with its coastline, depth contours, the planned track and waypoints; own ship moves + // along the track each frame. + const land: Point[] = [P(dx, y0), P(x1, y0), P(x1, y0 + 0.02)] + for (let k = 8; k >= 0; k--) land.push(P(dx + (k / 8) * s.dw, y0 + 0.035 + Math.sin(k * 1.1) * 0.012 + hash(k * 2.9) * 0.015 - (k / 8) * 0.02)) + this.polygon(land, col(g, 0.22)) + for (let k = 0; k < 8; k++) { + const xa = dx + (k / 8) * s.dw + const xb = dx + ((k + 1) / 8) * s.dw + const ya = y0 + 0.035 + Math.sin(k * 1.1) * 0.012 + hash(k * 2.9) * 0.015 - (k / 8) * 0.02 + const yb = y0 + 0.035 + Math.sin((k + 1) * 1.1) * 0.012 + hash((k + 1) * 2.9) * 0.015 - ((k + 1) / 8) * 0.02 + this.polygon([P(xa, ya), P(xb, yb), P(xb, yb + 0.003), P(xa, ya + 0.003)], col(g, 0.7)) + for (const off of [0.03, 0.06]) if (k % 2 === 0) this.polygon([P(xa, ya + off), P(xb, yb + off), P(xb, yb + off + 0.0015), P(xa, ya + off + 0.0015)], col(g, 0.25)) + } + disc(dx + s.dw * 0.72, y0 + 0.07, 0.014, col(g, 0.22), 0.009) + const [ax, ay, bx, by] = s.track + for (let k = 0; k < 10; k += 2) { + const f0 = k / 10 + const f1 = (k + 1) / 10 + this.polygon([P(lerp(ax, bx, f0), lerp(ay, by, f0) - 0.001), P(lerp(ax, bx, f1), lerp(ay, by, f1) - 0.001), P(lerp(ax, bx, f1), lerp(ay, by, f1) + 0.001), P(lerp(ax, bx, f0), lerp(ay, by, f0) + 0.001)], col(g2, 0.8)) + } + for (const f of [0, 0.5, 1]) disc(lerp(ax, bx, f), lerp(ay, by, f), 0.004, col(g2, 0.6)) + continue + } + if (i === 2) { + // Ship status: the carrier's deck outline seen from above, with its deck spots marked. + const L = s.dw * 0.4 + const Q = (u: number, v: number): Point => P(cx + u * L, cy + v * L) + const deck: Point[] = [Q(-1, -0.12), Q(0.75, -0.16), Q(1, -0.08), Q(1, 0.12), Q(0.6, 0.2), Q(-0.3, 0.2), Q(-1, 0.32), Q(-1.02, 0.18)] + this.polygon(deck, col(g, 0.55)) + this.polygon(deck.map(([x, y]) => [cx * H + (x - cx * H) * 0.94, cy * H + (y - cy * H) * 0.85] as const), col(look.screen, 1.2)) + this.polygon([Q(-0.95, 0.24), Q(0.4, -0.02), Q(0.42, 0.01), Q(-0.92, 0.27)], col(g, 0.4)) + this.polygon([Q(0.05, -0.2), Q(0.25, -0.2), Q(0.25, -0.13), Q(0.05, -0.13)], col(g, 0.7)) + for (let k = 0; k < 4; k++) rect(cx + (0.3 + k * 0.32 - 0.8) * L - 0.003, y1 - 0.025, cx + (0.3 + k * 0.32 - 0.8) * L + 0.003, y1 - 0.019, col(g, 0.5)) + continue + } + // Engines and power: four bars with their levels, and a trend line above. + for (let k = 0; k < 4; k++) { + const bx = dx + 0.02 + (k * (s.dw - 0.04)) / 4 + const bw = (s.dw - 0.04) / 4 - 0.012 + const level = [0.72, 0.68, 0.8, 0.45][k] + rect(bx, y0 + 0.05, bx + bw, y1 - 0.012, col(g, 0.22)) + rect(bx + 0.002, y1 - 0.014 - (y1 - y0 - 0.064) * level, bx + bw - 0.002, y1 - 0.014, col(k === 3 ? g2 : g, 0.75)) + } + for (let k = 0; k < 10; k++) { + const xa = dx + 0.02 + (k / 10) * (s.dw - 0.04) + const xb = dx + 0.02 + ((k + 1) / 10) * (s.dw - 0.04) + const ya = y0 + 0.03 - Math.sin(k * 0.9) * 0.008 + const yb = y0 + 0.03 - Math.sin((k + 1) * 0.9) * 0.008 + this.polygon([P(xa, ya), P(xb, yb), P(xb, yb + 0.002), P(xa, ya + 0.002)], col(g, 0.6)) + } + } + // The keyboard tray and trackball. + const kb = s.keyboard + rounded(kb.x - kb.w / 2, 0.922, kb.x + kb.w / 2, 0.978, 0.008, col(look.panel, 2.2)) + if (!mask) + for (let row = 0; row < 3; row++) + for (let x = kb.x - kb.w / 2 + 0.012; x < kb.x + kb.w / 2 - 0.012; x += 0.016) rect(x, 0.93 + row * 0.015, x + 0.011, 0.94 + row * 0.015, col(look.panel, 3.4)) + disc(s.trackball, 0.95, 0.03, col(look.panel, 2.4), 0.02) + disc(s.trackball, 0.946, 0.014, col(look.screen, 4), 0.011) + // The helm: a compact steering joystick on its base. + const j = s.joystick + disc(j, 0.958, 0.04, col(look.panel, 2.6), 0.016) + this.polygon([P(j - 0.006, 0.958), P(j + 0.006, 0.958), P(j + 0.004, 0.9), P(j - 0.004, 0.9)], col(look.frame, 3.5)) + rounded(j - 0.013, 0.86, j + 0.013, 0.905, 0.008, col(look.panel, 3)) + disc(j, 0.868, 0.004, col(g2, 0.8)) + // The throttle quadrant: two sleek levers in their slots. + const t = s.throttle + rounded(t - 0.045, 0.925, t + 0.045, 0.975, 0.01, col(look.panel, 2.4)) + for (const dx of [-0.016, 0.016]) { + rect(t + dx - 0.003, 0.93, t + dx + 0.003, 0.97, col(look.frame, 2)) + this.polygon([P(t + dx - 0.004, 0.95), P(t + dx + 0.004, 0.95), P(t + dx + 0.002, 0.9), P(t + dx - 0.006, 0.9)], col(look.bezel, 1.4)) + rounded(t + dx - 0.012, 0.888, t + dx + 0.008, 0.904, 0.004, col(look.panel, 3.4)) + } + // An officer's chair in the corner: headrest, a contoured back and an armrest. + rounded(0.012, 0.74, 0.1, 0.795, 0.015, col(look.frame, 2.4)) + for (const x of [0.04, 0.072]) rect(x - 0.003, 0.795, x + 0.003, 0.812, col(look.bezel, 1)) + this.polygon([P(0, 0.81), P(0.11, 0.812), P(0.128, 0.84), P(0.13, 1), P(0, 1)], col(look.frame, 2)) + if (!mask) this.polygon([P(0.11, 0.812), P(0.128, 0.84), P(0.13, 1), P(0.124, 1), P(0.122, 0.842), P(0.106, 0.818)], this.rimmed(2.4, 0.35)) + rounded(0.06, 0.9, 0.2, 0.93, 0.012, col(look.frame, 2.8)) + // At night the bridge's red lighting pools on the console. + if (!mask && look.night) + for (const [x, y, rx] of [[A / 2, 0.66, 0.5], [s.joystick, 0.95, 0.15], [s.throttle, 0.95, 0.15], [A / 2, 0.95, 0.3]] as const) + poolOn(this.hdr, this.W, H, x * H, y * H, rx * H, Math.min(0.1, rx * 0.5) * H, look.lamplight, 0.8) + } + + // The faint light from the windows on a frame edge, mixed into the frame color. + private rimmed(k: number, rim: number): RGB { + const { frame, rim: r } = this.look + return [frame[0] * k + r[0] * rim, frame[1] * k + r[1] * rim, frame[2] * k + r[2] * rim] + } + + private placeConsole() { + const s = this.spots() + this.inside = s + const night = this.look.night + const colors: RGB[] = night + ? [ + [1.4, 0.12, 0.04], + [1.3, 0.55, 0.08], + [0.25, 0.8, 0.3], + ] + : [ + [0.3, 1.3, 0.5], + [1.4, 0.8, 0.15], + [0.3, 0.9, 1.4], + ] + this.leds = [] + let k = 0 + const led = (x: number, y: number) => { + const h = hash(k * 7.7) + this.leds.push({ x: x * this.H, y: y * this.H, color: colors[Math.floor(hash(k * 3.1) * 3)], rate: h > 0.8 ? 0.5 + hash(k) : 0, phase: hash(k * 1.3) * TAU }) + k++ + } + for (const [x0, x1] of s.keyPanels) for (let x = x0 + 0.015; x < x1 - 0.01; x += 0.03) led(x, 0.656) + for (const r of [s.rack, s.rack2]) if (r) for (const [x, y] of keyGrid(r)) if (hash(x * 13 + y * 7) > 0.45) led(x, y) + for (const dx of s.displays) led(dx + s.dw - 0.012, DISPLAY_BOTTOM + 0.014) + } + + // What moves inside: lit keys, the radar's sweep, own ship stepping along the chart, a deck spot blinking on the + // status display, the clock's colon, the status strip pulsing, and the headset's cord swaying with the swell. + private drawConsole() { + const { H, look } = this + const s = this.inside + if (!s) return + const t = this.time + const k = look.night ? 0.35 : 0.6 + for (const l of this.leds) { + const on = l.rate ? smoothstep(-0.3, 0.3, Math.sin(t * l.rate + l.phase)) : 1 + const b = (0.25 + 0.75 * on) * k + this.point(l.x, l.y, l.color[0] * b, l.color[1] * b, l.color[2] * b, 1) + } + const sc = s.scope + const box = radarScope(this.hdr, this.W, H, sc.x * H, sc.y * H, sc.r * H, sc.r * H, (t * TAU) / 3, look.night ? [1.1, 0.45, 0.05] : [0.2, 1, 0.9]) + this.touch(box[0], box[1], box[2], box[3]) + // Own ship advances along the chart's track in small steps, as the chart refreshes. + const [ax, ay, bx, by] = s.track + const f = 0.15 + ((Math.floor(t / 6) * 0.02) % 0.7) + const ox = lerp(ax, bx, f) + const oy = lerp(ay, by, f) + const g2 = look.glow2 + const ang = Math.atan2(by - ay, bx - ax) + const ca = Math.cos(ang) + const sa = Math.sin(ang) + const Q = (u: number, v: number): Point => [(ox + u * ca - v * sa) * H, (oy + u * sa + v * ca) * H] + this.polygon([Q(0.012, 0), Q(-0.007, -0.006), Q(-0.007, 0.006)], [g2[0] * 1.3, g2[1] * 1.3, g2[2] * 1.3]) + // A deck spot on the status display blinks slowly. + const st = s.displays[2] + s.dw / 2 + const on = smoothstep(-0.2, 0.2, Math.sin(t * 0.8)) + for (let i = 0; i < 5; i++) { + const L = s.dw * 0.4 + const x = st + (-0.7 + i * 0.32) * L + const y = (DISPLAY_TOP + DISPLAY_BOTTOM) / 2 + (i % 2 ? 0.02 : 0.05) * L * 4 + const c = i === 2 ? AMBER : g2 + const b = i === 2 ? 0.25 + 0.35 * on : 0.6 + this.ellipse(x * H, y * H, 0.004 * H, 0.004 * H, c[0] * b, c[1] * b, c[2] * b, 1) + } + // The clock's colon. + const c = s.clock + const cxm = (c.x0 + c.x1) / 2 + 0.003 + const colon = Math.floor(t) % 2 ? 0.95 : 0.2 + for (const y of [0.117, 0.133]) this.ellipse(cxm * H, y * H, 0.0025 * H, 0.0025 * H, look.glow[0] * colon, look.glow[1] * colon, look.glow[2] * colon, 1) + // The status strip: green, amber and red lights breathing gently; in a storm the red ones pulse harder. + for (let i = 0; i < STRIP.length; i++) { + const x = this.A / 2 - 0.18 + (i * 0.36) / (STRIP.length - 1) + const kind = STRIP[i] + const col: RGB = kind === 2 ? [1.6, 0.12, 0.05] : kind === 1 ? [1.4, 0.75, 0.1] : look.night ? [0.25, 0.7, 0.25] : [0.3, 1.2, 0.45] + const pulse = 0.85 + 0.15 * Math.sin(t * 0.6 + i) + (kind === 2 ? this.gloom * (0.6 + 0.6 * Math.sin(t * 2.5)) : 0) + const b = pulse * (look.night ? 0.3 : 0.45) + this.ellipse(x * H, 0.183 * H, 0.0045 * H, 0.0035 * H, col[0] * b, col[1] * b, col[2] * b, 1) + } + this.drawHeadsetCord(s.rack.x1 - 0.022 - 0.024, 0.218) + } + + // The headset's coiled cord hanging from its ear cup, swinging a little as the ship pitches. + private drawHeadsetCord(ax: number, ay: number) { + const { H, look } = this + const a = clamp((this.pitch / PITCH) * 0.08, -0.35, 0.35) + const ux = Math.sin(a) + const uy = Math.cos(a) + const cord: RGB = [look.frame[0] * 3.2, look.frame[1] * 3.2, look.frame[2] * 3.2] + const w = this.thick(0.0016, 0.7) + const len = 0.07 + let px = ax * H + let py = ay * H + for (let i = 1; i <= 11; i++) { + const f = i / 11 + const side = i === 11 ? 0 : (i % 2 ? 1 : -1) * 0.005 + const qx = (ax + ux * len * f + uy * side) * H + const qy = (ay + uy * len * f - ux * side) * H + this.polygon([[px - w, py], [px + w, py], [qx + w, qy], [qx - w, qy]], cord) + px = qx + py = qy + } + this.polygon([[px - w * 2, py], [px + w * 2, py], [px + w * 2, py + 0.012 * H], [px - w * 2, py + 0.012 * H]], cord) + } + +} + +// Where everything inside sits, measured from the edges and the middle so it fits narrow and wide screens alike. +function spotsFor(A: number) { + const sw = clamp((A - 0.68) / 3.3, 0.1, 0.17) + const left = A / 2 - 1.5 * sw - 0.025 + const right = A / 2 + 1.5 * sw + 0.025 + const rack = { x0: 0.035, x1: Math.min(0.3, left - 0.04) } + const clock = { x0: right + 0.035, x1: right + 0.035 + Math.min(0.13, A - right - 0.2) } + const guards = { x0: Math.max(clock.x1 + 0.03, A - 0.27), x1: A - 0.035 } + const rack2 = guards.x0 - clock.x1 - 0.08 > 0.12 ? { x0: clock.x1 + 0.04, x1: guards.x0 - 0.04 } : undefined + const dw = clamp((A - 0.31) / 4, 0.2, 0.36) + const dx0 = A / 2 - (4 * dw + 0.09) / 2 + const displays = [0, 1, 2, 3].map((i) => dx0 + i * (dw + 0.03)) + const narrow = A < 1.3 + const panels = Math.max(1, Math.round((Math.min(A - 0.05, A / 2 + 0.42) - Math.max(0.05, A / 2 - 0.42)) / 0.3)) + const p0 = Math.max(0.05, A / 2 - 0.42) + const pw = (Math.min(A - 0.05, A / 2 + 0.42) - p0) / panels + return { + screens: [-1, 0, 1].map((i) => A / 2 + i * (sw + 0.025)), + sw, + rack, + rack2, + clock, + guards, + keyPanels: Array.from({ length: panels }, (_, i) => [p0 + i * pw + 0.01, p0 + (i + 1) * pw - 0.01] as const), + displays, + dw, + scope: { x: displays[0] + dw / 2, y: (DISPLAY_TOP + DISPLAY_BOTTOM) / 2, r: Math.min(dw * 0.42, (DISPLAY_BOTTOM - DISPLAY_TOP) * 0.42) }, + // The chart's planned track, corner to corner below its coast. + track: [displays[1] + dw * 0.15, DISPLAY_BOTTOM - 0.02, displays[1] + dw * 0.88, DISPLAY_TOP + 0.075] as const, + keyboard: { x: A / 2, w: narrow ? 0.2 : 0.3 }, + trackball: A / 2 - (narrow ? 0.15 : 0.21), + joystick: A / 2 - (narrow ? 0.29 : 0.34), + throttle: A / 2 + (narrow ? 0.23 : 0.3), + } +} + +// Key positions across a comms rack: three rows under its little display. +function keyGrid(r: { x0: number; x1: number }) { + const keys: [number, number][] = [] + for (let row = 0; row < 3; row++) for (let x = r.x0 + 0.02; x < r.x1 - 0.012; x += 0.026) keys.push([x, 0.115 + row * 0.018]) + return keys +} + +// Draws a seven-segment digit from a mask of its segments (top, upper right, lower right, bottom, lower left, upper +// left, middle), lit segments in on and the rest faintly in off. +function sevenSegment(draw: (points: readonly (readonly [number, number])[], color: RGB) => void, x: number, y: number, w: number, h: number, mask: number, on: RGB, off: RGB, H: number) { + const t = Math.min(w, h) * 0.16 + const half = h / 2 + const bars: [number, number, number, number][] = [ + [x + t, y, x + w - t, y + t], + [x + w - t, y + t, x + w, y + half - t / 2], + [x + w - t, y + half + t / 2, x + w, y + h - t], + [x + t, y + h - t, x + w - t, y + h], + [x, y + half + t / 2, x + t, y + h - t], + [x, y + t, x + t, y + half - t / 2], + [x + t, y + half - t / 2, x + w - t, y + half + t / 2], + ] + for (const [i, [x0, y0, x1, y1]] of bars.entries()) + draw( + [ + [x0 * H, y0 * H], + [x1 * H, y0 * H], + [x1 * H, y1 * H], + [x0 * H, y1 * H], + ], + mask & (1 << i) ? on : off, + ) +} + +// Segment masks for the clock's four digits. +const DIGITS = [0b0000110, 0b1100110, 0b1001111, 0b0111111] +// The status strip's lights: 0 green, 1 amber, 2 red. +const STRIP = [0, 0, 0, 1, 0, 0, 2, 0, 0] +// The console displays' screens, top and bottom. +const DISPLAY_TOP = 0.728 +const DISPLAY_BOTTOM = 0.86 + +type Spots = ReturnType + +function crewSpot(): [number, number] { + return [rand(-2.4, -0.5), rand(4.6, 8.3)] +} + +// The taxi route from the landing deck onto a catapult, at u from 0 to 1. +function taxiPath(u: number, cat: { x: number; z0: number }): [number, number] { + return bezier([-2.3, 4.1], [cat.x, 4.8], [cat.x, cat.z0], u) +} + +function bezier(a: Point, b: Point, c: Point, u: number): [number, number] { + const v = 1 - u + return [v * v * a[0] + 2 * u * v * b[0] + u * u * c[0], v * v * a[1] + 2 * u * v * b[1] + u * u * c[1]] +} + +// The volcano stands to port of the bow in the middle pane, clear of the sun's and moon's paths to starboard. +function volcanoX(A: number) { + return Math.max(0.24, A / 2 - 0.25) +} + +// Top-down outlines, nose toward +a, span along b. +const JETFORM: Point[][] = [ + [[0.5, 0], [0.3, 0.05], [-0.5, 0.06], [-0.5, -0.06], [0.3, -0.05]], + [[0.12, 0.05], [-0.14, 0.42], [-0.24, 0.42], [-0.2, 0.05]], + [[0.12, -0.05], [-0.14, -0.42], [-0.24, -0.42], [-0.2, -0.05]], + [[-0.34, 0.05], [-0.48, 0.22], [-0.53, 0.22], [-0.5, 0.05]], + [[-0.34, -0.05], [-0.48, -0.22], [-0.53, -0.22], [-0.5, -0.05]], +] +const FOLDED: Point[][] = [ + [[0.5, 0], [0.3, 0.05], [-0.5, 0.06], [-0.5, -0.06], [0.3, -0.05]], + [[0.08, 0.05], [0.02, 0.24], [-0.16, 0.24], [-0.2, 0.05]], + [[0.08, -0.05], [0.02, -0.24], [-0.16, -0.24], [-0.2, -0.05]], + [[-0.34, 0.05], [-0.48, 0.22], [-0.53, 0.22], [-0.5, 0.05]], + [[-0.34, -0.05], [-0.48, -0.22], [-0.53, -0.22], [-0.5, -0.05]], +] +// A humpback side on, head toward +u: its body, the pale grooved belly and a long pectoral fin. +const WHALE_BODY: Point[] = [[1, 0], [0.7, 0.16], [0.1, 0.24], [-0.5, 0.16], [-0.85, 0.06], [-1.05, 0.22], [-1.2, 0.24], [-1.02, 0], [-1.2, -0.18], [-1.05, -0.16], [-0.85, -0.05], [-0.4, -0.18], [0.4, -0.2], [0.85, -0.1]] +const WHALE_BELLY: Point[] = [[0.95, -0.04], [0.85, -0.1], [0.4, -0.2], [-0.2, -0.17], [0.3, -0.1]] +const WHALE_FIN: Point[] = [[0.45, -0.08], [0.3, -0.12], [-0.05, -0.62], [0.05, -0.64]] + +function makeLowPolyClouds(count: number): Cloud[] { + return Array.from({ length: count }, (_, i) => { + const size = rand(0.06, 0.11) * WS + const n = 3 + Math.floor(Math.random() * 3) + const lumps = Array.from({ length: n }, (_, k) => { + const f = n === 1 ? 0 : k / (n - 1) - 0.5 + return { x: f * 1.6 + rand(-0.1, 0.1), y: 0, r: (0.55 - Math.abs(f) * 0.5) * rand(0.8, 1.2), seed: Math.random() * 100 } + }) + for (const l of lumps) l.y = -l.r * 0.6 + const y = i % 3 === 2 ? rand(0.32, 0.345) : rand(0.235, 0.29) + return { x: ((i + Math.random() * 0.8) / count) * 2.2, y, size: i % 3 === 2 ? size * 0.7 : size, speed: rand(0.003, 0.006), lumps } + }) +} + +function cloudSprite(c: Cloud, H: number, colors: [RGB, RGB], toLight: number): Sprite { + const S = 4 + const size = c.size * H + const w = Math.ceil(size * 2.4) + 4 + const h = Math.ceil(size * 1.3) + 4 + const ox = w / 2 + const oy = h - 2 + const fine = new Int32Array(w * S * h * S).fill(-1) + const tones: number[] = [] + const lx = clamp(toLight * 2, -0.8, 0.8) + const ly = -0.75 + const [lit, shade] = colors + for (const l of c.lumps) { + const cx = ox + l.x * size + const cy = oy + l.y * size + const r = l.r * size + const n = 7 + const pts = Array.from({ length: n }, (_, k) => { + const a = (k / n) * TAU + hash(l.seed + k) * 0.5 + const rr = r * (0.82 + hash(l.seed + k * 2.7) * 0.3) + return [cx + Math.cos(a) * rr, Math.min(oy, cy + Math.sin(a) * rr * 0.85)] as const + }) + const hub = [cx - lx * r * 0.25, cy - r * 0.2] as const + for (let k = 0; k < n; k++) { + const a = pts[k] + const b = pts[(k + 1) % n] + const mx = (a[0] + b[0]) / 2 - hub[0] + const my = (a[1] + b[1]) / 2 - hub[1] + const ml = Math.hypot(mx, my) || 1 + const tone = clamp(0.5 + 0.6 * ((mx / ml) * lx + (my / ml) * ly) + (hash(l.seed + k * 5.1) - 0.5) * 0.2, 0, 1) + tones.push(tone) + rasterTri(fine, w * S, h * S, hub[0] * S, hub[1] * S, a[0] * S, a[1] * S, b[0] * S, b[1] * S, tones.length - 1) + } + } + const rgba = new Float32Array(w * h * 4) + for (let y = 0; y < h; y++) + for (let x = 0; x < w; x++) { + let r = 0 + let g = 0 + let b = 0 + let a = 0 + for (let j = 0; j < S; j++) + for (let i = 0; i < S; i++) { + const id = fine[(y * S + j) * w * S + x * S + i] + if (id < 0) continue + const t = tones[id] + r += lerp(shade[0], lit[0], t) + g += lerp(shade[1], lit[1], t) + b += lerp(shade[2], lit[2], t) + a++ + } + const o = (y * w + x) * 4 + rgba[o] = r / (S * S) + rgba[o + 1] = g / (S * S) + rgba[o + 2] = b / (S * S) + rgba[o + 3] = a / (S * S) + } + return { w, h, ox, oy, rgba } +} + +function blitSprite(hdr: Float32Array, W: number, H: number, s: Sprite, x: number, y: number) { + const x0 = Math.floor(x) + const y0 = Math.floor(y) + const fx = x - x0 + const fy = y - y0 + const d = s.rgba + for (let j = 0; j <= s.h; j++) { + const py = y0 + j + if (py < 0 || py >= H) continue + for (let i = 0; i <= s.w; i++) { + const px = x0 + i + if (px < 0 || px >= W) continue + let r = 0 + let g = 0 + let b = 0 + let a = 0 + for (let q = 0; q < 4; q++) { + const sx = i - (q & 1) + const sy = j - (q >> 1) + if (sx < 0 || sy < 0 || sx >= s.w || sy >= s.h) continue + const k = (q & 1 ? fx : 1 - fx) * (q >> 1 ? fy : 1 - fy) + const o = (sy * s.w + sx) * 4 + r += d[o] * k + g += d[o + 1] * k + b += d[o + 2] * k + a += d[o + 3] * k + } + if (a <= 0) continue + const o = (py * W + px) * 3 + hdr[o] = hdr[o] * (1 - a) + r + hdr[o + 1] = hdr[o + 1] * (1 - a) + g + hdr[o + 2] = hdr[o + 2] * (1 - a) + b + } + } +} + +// Box-filters a buffer twice the size down to W x H. +function shrink2(big: Float32Array, W: number, H: number) { + const out = new Float32Array(W * H * 3) + const BW = W * 2 + for (let y = 0; y < H; y++) + for (let x = 0; x < W; x++) { + const a = (y * 2 * BW + x * 2) * 3 + const b = a + BW * 3 + const o = (y * W + x) * 3 + for (let c = 0; c < 3; c++) out[o + c] = (big[a + c] + big[a + 3 + c] + big[b + c] + big[b + 3 + c]) * 0.25 + } + return out +} + +// A layer from its color painted over black (so premultiplied) and its coverage painted in white. +function layer(color: Float32Array, cover: Float32Array): Layer { + const n = color.length / 3 + const rgba = new Float32Array(n * 4) + const idx: number[] = [] + for (let i = 0; i < n; i++) { + const a = clamp(cover[i * 3], 0, 1) + rgba[i * 4] = color[i * 3] + rgba[i * 4 + 1] = color[i * 3 + 1] + rgba[i * 4 + 2] = color[i * 3 + 2] + rgba[i * 4 + 3] = a + if (a > 0 || color[i * 3] + color[i * 3 + 1] + color[i * 3 + 2] > 0) idx.push(i) + } + return { rgba, idx: Int32Array.from(idx) } +} + +function composite(hdr: Float32Array, l: Layer) { + const d = l.rgba + const idx = l.idx + for (let k = 0; k < idx.length; k++) { + const i = idx[k] + const o = i * 3 + const q = i * 4 + const a = 1 - d[q + 3] + hdr[o] = hdr[o] * a + d[q] + hdr[o + 1] = hdr[o + 1] * a + d[q + 1] + hdr[o + 2] = hdr[o + 2] * a + d[q + 2] + } +} + +// A soft pool of light lifting what's painted (and only that, below row from) in its color. +function poolOn(hdr: Float32Array, W: number, H: number, cx: number, cy: number, rx: number, ry: number, color: RGB, k: number, from = 0) { + const x0 = Math.max(0, Math.floor(cx - rx)) + const x1 = Math.min(W - 1, Math.ceil(cx + rx)) + const y0 = Math.max(0, Math.floor(Math.max(from, cy - ry))) + const y1 = Math.min(H - 1, Math.ceil(cy + ry)) + for (let y = y0; y <= y1; y++) + for (let x = x0; x <= x1; x++) { + const d = 1 - ((x + 0.5 - cx) / rx) ** 2 - ((y + 0.5 - cy) / ry) ** 2 + if (d <= 0) continue + const o = (y * W + x) * 3 + if (hdr[o] + hdr[o + 1] + hdr[o + 2] <= 0) continue + const f = d * d * k + hdr[o] += color[0] * f * 0.1 + hdr[o + 1] += color[1] * f * 0.1 + hdr[o + 2] += color[2] * f * 0.1 + } +} + +// The radar scope's face: faint range rings and crosshairs, a sweep with a fading afterglow, and a few blips that +// light as it passes. Returns the box it drew in. +function radarScope(hdr: Float32Array, W: number, H: number, cx: number, cy: number, rx: number, ry: number, sweep: number, color: RGB) { + const x0 = Math.max(0, Math.floor(cx - rx)) + const x1 = Math.min(W - 1, Math.ceil(cx + rx)) + const y0 = Math.max(0, Math.floor(cy - ry)) + const y1 = Math.min(H - 1, Math.ceil(cy + ry)) + const s = sweep % TAU + for (let y = y0; y <= y1; y++) + for (let x = x0; x <= x1; x++) { + const u = (x + 0.5 - cx) / rx + const v = (y + 0.5 - cy) / ry + const r = Math.hypot(u, v) + if (r >= 1) continue + const edge = smoothstep(1, 0.9, r) + const a = Math.atan2(u, -v) + let behind = (s - a) % TAU + if (behind < 0) behind += TAU + // A soft leading edge and a long fading tail. + const glow = Math.exp(-behind / 0.9) * smoothstep(TAU, TAU - 0.15, behind) * 0.22 + const ring = Math.max(Math.exp(-(((r - 0.5) / 0.04) ** 2)), Math.exp(-(((r - 0.85) / 0.04) ** 2))) * 0.05 + let blip = 0 + for (let b = 0; b < BLIPS.length; b += 2) { + const d = Math.hypot(u - BLIPS[b], v - BLIPS[b + 1]) + if (d > 0.12) continue + let since = (s - Math.atan2(BLIPS[b], -BLIPS[b + 1])) % TAU + if (since < 0) since += TAU + blip = Math.max(blip, Math.exp(-((d / 0.07) ** 2)) * Math.exp(-since / 2.5) * 0.9) + } + const k = (glow + ring + blip) * edge + const o = (y * W + x) * 3 + hdr[o] += color[0] * k + hdr[o + 1] += color[1] * k + hdr[o + 2] += color[2] * k + } + return [x0, y0, x1, y1] +} + +const BLIPS = [0.4, -0.3, -0.55, 0.2, 0.15, 0.6, -0.2, -0.65] + +function rowHeight(d: number) { + return Math.max(0.005, d * 0.17 + 0.003) +} + +// For each pixel of a grid sampled two by two: its commonest triangle, the next, how much of the next among those +// that hit, and how much of the pixel any triangle covers (none above the horizon). +function resolveSamples(ids: Int32Array, W: number, H: number, idA: Int32Array, idB: Int32Array, mix: Float32Array, cov: Float32Array) { + const SW = W * 2 + for (let y = 0; y < H; y++) + for (let x = 0; x < W; x++) { + const o = y * 2 * SW + x * 2 + const s0 = ids[o] + const s1 = ids[o + 1] + const s2 = ids[o + SW] + const s3 = ids[o + SW + 1] + const i = y * W + x + const hit = (s0 >= 0 ? 1 : 0) + (s1 >= 0 ? 1 : 0) + (s2 >= 0 ? 1 : 0) + (s3 >= 0 ? 1 : 0) + cov[i] = hit / 4 + if (!hit) continue + const first = s0 >= 0 ? s0 : s1 >= 0 ? s1 : s2 >= 0 ? s2 : s3 + const second = s1 >= 0 && s1 !== first ? s1 : s2 >= 0 && s2 !== first ? s2 : s3 >= 0 && s3 !== first ? s3 : first + const n0 = (s0 === first ? 1 : 0) + (s1 === first ? 1 : 0) + (s2 === first ? 1 : 0) + (s3 === first ? 1 : 0) + const n1 = hit - n0 + idA[i] = n0 >= n1 ? first : second + idB[i] = n0 >= n1 ? second : first + mix[i] = Math.min(n0, n1) / hit + } +} + +function stitch(a: number[], b: number[], xs: number[], tris: number[]) { + let i = 0 + let j = 0 + while (i < a.length - 1 || j < b.length - 1) { + if (j >= b.length - 1 || (i < a.length - 1 && xs[a[i + 1]] < xs[b[j + 1]])) { + tris.push(a[i], a[i + 1], b[j]) + i++ + continue + } + tris.push(a[i], b[j + 1], b[j]) + j++ + } +} + +function rasterTri(ids: Int32Array, W: number, H: number, ax: number, ay: number, bx: number, by: number, cx: number, cy: number, id: number) { + const x0 = Math.max(0, Math.floor(Math.min(ax, bx, cx))) + const x1 = Math.min(W - 1, Math.ceil(Math.max(ax, bx, cx))) + const y0 = Math.max(0, Math.floor(Math.min(ay, by, cy))) + const y1 = Math.min(H - 1, Math.ceil(Math.max(ay, by, cy))) + const area = (bx - ax) * (cy - ay) - (by - ay) * (cx - ax) + if (area === 0) return + const sign = area > 0 ? 1 : -1 + for (let y = y0; y <= y1; y++) { + const py = y + 0.5 + for (let x = x0; x <= x1; x++) { + const px = x + 0.5 + const w0 = ((bx - ax) * (py - ay) - (by - ay) * (px - ax)) * sign + const w1 = ((cx - bx) * (py - by) - (cy - by) * (px - bx)) * sign + const w2 = ((ax - cx) * (py - cy) - (ay - cy) * (px - cx)) * sign + if (w0 >= 0 && w1 >= 0 && w2 >= 0) ids[y * W + x] = id + } + } +} + +// Shades every facet from its tilted normal: the water's own color, the sky it mirrors, glitter toward the sun or +// moon, and the storm's dark green-gray. +// The facets live in Wings' own units; the sky they mirror and the sun or moon they glitter toward are scaled by WS. +function shadeFacets(X: Float32Array, Z: Float32Array, Y: Float32Array, tris: Int32Array, colors: Float32Array, sky: Float32Array, H: number, ox: number, oy: number, deep: RGB, lit: RGB, spec: RGB, glitter: number, spread: readonly [number, number], storm: RGB, gloom: number) { + const top = Math.ceil(HORIZON * H) - 1 + for (let t = 0, f = 0; t < tris.length; t += 3, f += 3) { + const a = tris[t] + const b = tris[t + 1] + const c = tris[t + 2] + const e1x = X[b] - X[a] + const e1y = Y[b] - Y[a] + const e1z = Z[b] - Z[a] + const e2x = X[c] - X[a] + const e2y = Y[c] - Y[a] + const e2z = Z[c] - Z[a] + let nx = e1y * e2z - e1z * e2y + let ny = e1z * e2x - e1x * e2z + let nz = e1x * e2y - e1y * e2x + if (ny < 0) { + nx = -nx + ny = -ny + nz = -nz + } + const tilt = Math.hypot(nx, nz) / Math.max(1e-9, ny) + if (tilt > 0.7) { + nx *= 0.7 / tilt + nz *= 0.7 / tilt + } + const nl = Math.hypot(nx, ny, nz) || 1 + nx /= nl + ny /= nl + nz /= nl + const px = (X[a] + X[b] + X[c]) / 3 + const pz = (Z[a] + Z[b] + Z[c]) / 3 + let vx = -px + let vy = CAMERA + let vz = -pz + const vl = Math.hypot(vx, vy, vz) + vx /= vl + vy /= vl + vz /= vl + const ndv = Math.max(0, nx * vx + ny * vy + nz * vz) + const rx = 2 * ndv * nx - vx + const ry = 2 * ndv * ny - vy + const rz = 2 * ndv * nz - vz + const row = ry <= 0 || rz <= 0 ? top : clamp(Math.round((HORIZON - (WS * FOCAL * ry) / rz) * H), 0, top) + const fres = 0.12 + 0.88 * (1 - ndv) ** 3 + const gx = rz > 0.01 ? (FOCAL * rx) / rz - ox : 9 + const gy = rz > 0.01 ? -(FOCAL * ry) / rz - oy : 9 + const s = Math.exp(-((gx / spread[0]) ** 2) - (gy / spread[1]) ** 2) * glitter * (0.3 + 0.7 * smoothstep(2, 6, pz)) + const diffuse = clamp(0.5 + nx * 1.6 - nz * 0.8, 0, 1) + for (let k = 0; k < 3; k++) { + const body = deep[k] + (lit[k] - deep[k]) * diffuse + const v = (body * (1 - fres) + sky[row * 3 + k] * fres * 0.7) * (1 - Math.min(1, s) * 0.75) + spec[k] * s + colors[f + k] = v + (storm[k] - v) * gloom + } + } +} + +// Paints the sea's rows from the facet colors, as much of each pixel as the facets cover and the ship leaves open. +function fillSea(hdr: Float32Array, W: number, y0: number, idA: Int32Array, idB: Int32Array, mix: Float32Array, cov: Float32Array, open: Float32Array, colors: Float32Array) { + const base = y0 * W * 3 + for (let i = 0; i < idA.length; i++) { + const w = open[i] * cov[i] + if (w <= 0) continue + const a = idA[i] * 3 + const b = idB[i] * 3 + const k = mix[i] + const o = base + i * 3 + for (let c = 0; c < 3; c++) { + const v = colors[a + c] + (colors[b + c] - colors[a + c]) * k + hdr[o + c] += (v - hdr[o + c]) * w + } + } +} + +function trail(hdr: Float32Array, W: number, H: number, hx: number, hy: number, tx: number, ty: number, width: number, color: RGB, k: number) { + const dx = tx - hx + const dy = ty - hy + const len2 = dx * dx + dy * dy || 1 + const x0 = Math.max(0, Math.floor(Math.min(hx, tx) - width - 1)) + const x1 = Math.min(W - 1, Math.ceil(Math.max(hx, tx) + width + 1)) + const y0 = Math.max(0, Math.floor(Math.min(hy, ty) - width - 1)) + const y1 = Math.min(H - 1, Math.ceil(Math.max(hy, ty) + width + 1)) + for (let y = y0; y <= y1; y++) + for (let x = x0; x <= x1; x++) { + const px = x + 0.5 - hx + const py = y + 0.5 - hy + const t = clamp((px * dx + py * dy) / len2, 0, 1) + const d = Math.hypot(px - dx * t, py - dy * t) + const a = Math.max(0, 1 - d / (width * (1 + t))) * (1 - t) * k + if (a <= 0) continue + const o = (y * W + x) * 3 + hdr[o] += (color[0] - hdr[o]) * a + hdr[o + 1] += (color[1] - hdr[o + 1]) * a + hdr[o + 2] += (color[2] - hdr[o + 2]) * a + } +} + +export const wingsBridge: Wallpaper = { + id: "wings-bridge", + name: "Wings: Bridge", + description: "A modern carrier's bridge, its displays, comms and helm around windows on the flight deck and a low-poly sea, from Anomaly Arcade's Wings", + collection: "Arcade", + activity: { + calm: "The bridge's displays and lit keys, the radar sweep, a headset cord swaying, and the sea on a slow swell beyond the deck", + lively: "Adds deck crew walking and a jet launching off the bow now and then", + teeming: "Adds launches more often, a jet landing now and then and escort ships on the horizon", + }, + scrim: { + day: [ + [14, 26, 46], + [20, 44, 78], + [10, 12, 18], + ], + sunset: [ + [20, 16, 38], + [60, 34, 58], + [14, 10, 20], + ], + night: [ + [3, 4, 12], + [4, 8, 22], + [8, 3, 6], + ], + }, + create: (settings) => new WingsBridge(settings), +} diff --git a/wallpapers/wings-flight.ts b/wallpapers/wings-flight.ts new file mode 100644 index 0000000..2c2a344 --- /dev/null +++ b/wallpapers/wings-flight.ts @@ -0,0 +1,1635 @@ +import { Canvas } from "../src/canvas" +import { eggWait } from "../src/egg" +import { lightPool } from "../src/light" +import { TAU, clamp, hash, hash2, lerp, noise1, rand, smoothstep, type RGB } from "../src/math" +import { driftClouds, makeStars, makeStorm, paintSky, paintStars, paintStorm, type Orb, type Star } from "../src/sky" +import type { Activity, Settings, Time, Wallpaper } from "../src/wallpaper" +import { WeatherLayer } from "../src/weather" + +// An homage to Wings, Anomaly Arcade's flight game: a chase camera behind a fighter jet gliding low over the faceted +// sea, past volcano islands and now and then over the carrier. + +// The scene runs slower than real time, and the jets slower still, which keeps it calm behind text. +const TIME_SCALE = 0.35 +const CREATURE_SPEED = 0.25 +const HORIZON = 0.44 +// The sea's camera: its height over the water and focal length, in screen heights. +const CAMERA = 1 +const FOCAL = 1 +// How many of the sea's rows slide toward the camera per scene second, and how far the jet banks, at each activity. +const SPEED: Record = { calm: 0.13, lively: 0.16, teeming: 0.19 } +const BANK: Record = { calm: 0.04, lively: 0.09, teeming: 0.11 } +// How much of the jet's bank the horizon tilts the other way. +const TILT = 0.15 +// Islands' size per unit of their distance below the horizon. +const ISLAND = 16 +// The carrier in the sea camera's world units: half its beam, its length, its deck's height and how much its +// superstructure is squashed so the mast stays under the camera. +const BEAMW = 0.4 +const LENGTH = 3.2 +const DECK = 0.12 +const RISE = 0.28 +// The jet: its wingspan in screen heights, its screen position, and how steeply the camera looks down on it. +const SPAN = 0.42 +const JET_Y = 0.75 +const ELEVATION = 0.26 +// How long a click's roll, the victory barrel roll and the easter egg's B-2 pass last, in scene seconds. +const ROLL = 3 +const BARREL = 4 +const EGG = 12 +// Cloud sprites are drawn at this nearness and scaled down from it. +const CLOUD_E = 0.7 + +type Point = readonly [number, number] +type V3 = readonly [number, number, number] + +// What changes with the time of day. The sea gets its own colors; islands and the carrier one daytime color each, +// darkened by tint; the jets are lit by their own ambient and key lights. +interface Look { + sky: [number, RGB][] + orb: Orb + // The sun or moon's x, in screen heights from the middle. + orbX: number + stars: number + cloud: [lit: RGB, shade: RGB] + sea: [deep: RGB, lit: RGB] + // How much of the sky the sea mirrors at grazing angles. + mirror: number + glitter: number + spread: readonly [number, number] + spec: RGB + tint: RGB + key: RGB + lamp: number + foam: RGB + // Contrails high up: pale by day, pink at sunset, teal-blue at night. + trail: RGB + storm: RGB + ambient: RGB + light: RGB + toward: V3 + night: boolean +} + +const LOOKS: Record = { + day: { + sky: [ + [0, [0.07, 0.27, 0.72]], + [0.25, [0.2, 0.47, 0.88]], + [HORIZON, [0.62, 0.82, 0.96]], + ], + orb: { x: 0, y: 0.12, r: 0.035, core: [5, 4.6, 3.8], glow: [1, 0.95, 0.8], near: 0.45, wide: 0.12 }, + orbX: -0.55, + stars: 0, + cloud: [ + [1.05, 1.07, 1.1], + [0.6, 0.7, 0.86], + ], + sea: [ + [0.02, 0.2, 0.42], + [0.06, 0.52, 0.62], + ], + mirror: 0.7, + glitter: 0.7, + spread: [0.04, 0.16], + spec: [1.2, 1.15, 1], + tint: [1, 1, 1], + key: [1, 0.97, 0.88], + lamp: 0.25, + foam: [0.85, 0.95, 1], + trail: [0.95, 0.97, 1], + storm: [0.1, 0.16, 0.15], + ambient: [0.42, 0.46, 0.55], + light: [0.8, 0.77, 0.7], + toward: norm([-0.5, 0.75, 0.35]), + night: false, + }, + sunset: { + sky: [ + [0, [0.12, 0.2, 0.55]], + [0.18, [0.3, 0.32, 0.72]], + [0.3, [0.75, 0.42, 0.62]], + [0.38, [1.1, 0.55, 0.5]], + [HORIZON, [1.25, 0.72, 0.45]], + ], + orb: { x: 0, y: 0.42, r: 0.04, core: [3, 1.7, 1], glow: [1.1, 0.5, 0.35], near: 0.5, wide: 0.25 }, + orbX: -0.18, + stars: 10, + cloud: [ + [1.05, 0.58, 0.55], + [0.36, 0.27, 0.52], + ], + sea: [ + [0.06, 0.1, 0.32], + [0.2, 0.26, 0.62], + ], + mirror: 0.4, + glitter: 1.1, + spread: [0.05, 0.3], + spec: [1.3, 0.62, 0.42], + tint: [0.32, 0.24, 0.34], + key: [1, 0.55, 0.45], + lamp: 0.6, + foam: [1, 0.78, 0.82], + trail: [1.1, 0.62, 0.6], + storm: [0.12, 0.1, 0.14], + ambient: [0.2, 0.16, 0.3], + light: [1.1, 0.55, 0.38], + toward: norm([-0.3, 0.25, 0.9]), + night: false, + }, + night: { + sky: [ + [0, [0.004, 0.01, 0.04]], + [0.25, [0.015, 0.035, 0.11]], + [HORIZON, [0.05, 0.11, 0.3]], + ], + orb: { x: 0, y: 0.13, r: 0.034, core: [1.25, 0.95, 0.42], glow: [0.5, 0.38, 0.15], near: 0.3, wide: 0.1, moon: true }, + orbX: -0.3, + stars: 150, + cloud: [ + [0.12, 0.18, 0.4], + [0.03, 0.05, 0.14], + ], + sea: [ + [0.012, 0.04, 0.13], + [0.06, 0.15, 0.45], + ], + mirror: 0.7, + glitter: 0.6, + spread: [0.035, 0.3], + spec: [1.1, 0.68, 0.1], + tint: [0.06, 0.08, 0.17], + key: [0.35, 0.5, 1.1], + lamp: 1, + foam: [0.1, 0.5, 0.75], + trail: [0.2, 0.42, 0.8], + storm: [0.02, 0.055, 0.05], + ambient: [0.05, 0.08, 0.22], + light: [0.16, 0.24, 0.6], + toward: norm([-0.3, 0.55, 0.75]), + night: true, + }, +} + +// Daytime colors. +const HULL: RGB = [0.36, 0.38, 0.42] +const DECK_C: RGB = [0.2, 0.21, 0.23] +const STRIPE: RGB = [0.85, 0.85, 0.8] +const YELLOW: RGB = [0.85, 0.66, 0.12] +const JET: RGB = [0.5, 0.53, 0.58] +const SPIRIT: RGB = [0.13, 0.14, 0.17] +const WARTHOG: RGB = [0.24, 0.28, 0.26] +const HELO: RGB = [0.3, 0.34, 0.3] +const BIRD: RGB = [0.82, 0.84, 0.86] +const BOAT: RGB = [0.72, 0.72, 0.68] +const RED: RGB = [1.6, 0.22, 0.12] +const GREEN: RGB = [0.22, 1.4, 0.5] +const FOREST: RGB = [0.1, 0.3, 0.1] +const ROCK: RGB = [0.3, 0.27, 0.22] +const AMBER: RGB = [2, 1.05, 0.3] +const BURNER: RGB = [2.6, 1.15, 0.35] + +// A face of the jet in its own frame (x right, y up, z forward, in wingspans): its corners, its daytime color, and +// whether both sides show. +interface Face { + pts: V3[] + color: RGB + both: boolean +} + +interface Island { + kind: "volcano" | "islets" | "stacks" + // Its distance below the horizon, and how far to the side per unit of it. + d: number + x: number +} + +interface Cloud { + e: number + x: number + y: number + size: number + // The nearness its sprite is drawn at, and how opaque it is: wisps the jet flies through are faint. + top: number + alpha: number + lumps: { x: number; y: number; r: number; seed: number }[] + mips: Sprite[] +} + +interface Sprite { + w: number + h: number + ox: number + oy: number + rgba: Float32Array +} + +interface Flare { + x: number + y: number + r: number + vx: number + vy: number + age: number +} + +// Air traffic. Sky kinds cross the screen at a fixed height (x, y in screen heights); world kinds fly through the sea +// camera's world (x across, y their altitude, with the camera at 1) between two distances. +type Kind = "cross" | "four" | "tanker" | "e2" | "b2" | "a10" | "dogfight" | "overtake" | "oncoming" | "helo" | "flock" + +interface Mover { + kind: Kind + // Progress from 0 to 1 over span scene seconds. + t: number + span: number + dir: number + x: number + y: number + size: number + seed: number + // How many flare bursts it has fired. + fired: number +} + +// How long each kind waits between appearances at lively and teeming, in scene seconds, and how many fly at once at +// teeming; lively has about half as many at once. +const SCHEDULE: Record = { + cross: { lively: [4, 8], teeming: [2.5, 6], most: 5 }, + four: { lively: [30, 50], teeming: [15, 30], most: 2 }, + tanker: { lively: [65, 100], teeming: [40, 70], most: 1 }, + e2: { lively: [35, 65], teeming: [30, 55], most: 1 }, + b2: { lively: [80, 120], teeming: [55, 90], most: 1 }, + a10: { lively: [30, 50], teeming: [20, 40], most: 1 }, + dogfight: { lively: [25, 42], teeming: [10, 22], most: 1 }, + overtake: { lively: [10, 17], teeming: [6, 12], most: 2 }, + oncoming: { lively: [13, 22], teeming: [7, 12], most: 3 }, + helo: { lively: [13, 22], teeming: [8, 14], most: 3 }, + flock: { lively: [14, 25], teeming: [10, 18], most: 3 }, +} +// World kinds' distance at the start and end of their run. +const RUN: Partial> = { overtake: [2.8, 45], oncoming: [70, 3], helo: [40, 2.2], flock: [26, 1.15] } + +interface Ship { + kind: "destroyer" | "supply" | "boat" + z: number + x: number +} + +// Ships in the sea camera's world: length, half beam, deck height, how fast the camera closes on them (1 for still +// water), and superstructure blocks across (u0, u1, in half beams), along (v0, v1, from stern to bow) and up to a height. +const SHIPS: Record = { + destroyer: { length: 1.5, beam: 0.17, deck: 0.06, close: 0.6, color: HULL, blocks: [[-0.6, 0.6, 0.3, 0.62, 0.16], [-0.35, 0.35, 0.42, 0.55, 0.3], [-0.06, 0.06, 0.46, 0.5, 0.42]] }, + supply: { length: 2.1, beam: 0.25, deck: 0.08, close: 0.72, color: [0.4, 0.38, 0.34], blocks: [[-0.8, 0.8, 0.04, 0.24, 0.24], [-0.15, 0.15, 0.4, 0.46, 0.2], [-0.15, 0.15, 0.66, 0.72, 0.2]] }, + boat: { length: 0.28, beam: 0.07, deck: 0.025, close: 0.9, color: BOAT, blocks: [[-0.6, 0.6, 0.25, 0.6, 0.05]] }, +} + +// Formation slots for the wingmen: across (in fractions of the room beside the lead), up from the lead, and size. +const SLOTS: readonly (readonly [number, number, number])[] = [ + [-1, 0.07, 0.42], + [1, 0.07, 0.42], + [-0.55, 0.17, 0.3], + [0.55, 0.17, 0.3], + [-1.05, 0.21, 0.26], + [1.05, 0.21, 0.26], +] + +class WingsFlight extends Canvas { + private time = 0 + private creatureTime = 0 + // How many sea rows have slid by. + private flow = 0 + private readonly activity: Activity + private readonly look: Look + private readonly weather: WeatherLayer + private readonly speed: number + private readonly bank: number + private orb: Orb + private stars: Star[] + private clouds: Cloud[] + private storm = makeStorm() + private islands: Island[] + private nextIsland: number + // The carrier's stern distance in the sea camera's units, or negative while it's away. + private carrier = { z: 60, wait: 0 } + private movers: Mover[] = [] + private waits: Partial> = {} + private ships: Ship[] = [] + private nextShip: number + private wisps: Cloud[] = [] + private nextWisp = 0 + // Each wingman's formation slot, the slot it's moving to, and how far it has got. + private wingmen: { from: number; to: number; t: number }[] + private nextShift = 40 + private egg = { wait: eggWait() * TIME_SCALE, t: -1 } + private maneuver = { kind: "none" as "none" | "roll" | "barrel", t: 0, dir: 1 } + private flares: Flare[] = [] + private drops = new Float32Array(120 * 3) + // This frame's camera: its sideways drift in world units, the jet's bank, and the horizon's tilt. + private lateral = 0 + private roll = 0 + private tilt = 0 + // The sea: rows of jittered vertices between the horizon and the bottom, stitched into triangles each frame, and per + // pixel of the sea's rows the two triangles under it, how much of the second and how much sea covers it. + private mesh = { X: new Float32Array(0), Z: new Float32Array(0), height: new Float32Array(0), tris: new Int32Array(0), colors: new Float32Array(0) } + private sea = { y0: 0, rows: new Float64Array(0), ids: new Int32Array(0), idA: new Int32Array(0), idB: new Int32Array(0), mix: new Float32Array(0), cover: new Float32Array(0), sky: new Float32Array(0) } + + constructor(settings: Settings) { + super() + this.activity = settings.activity + this.speed = SPEED[settings.activity] + this.bank = BANK[settings.activity] + this.weather = new WeatherLayer(settings.weather ?? "clear", settings.time, HORIZON) + const look = LOOKS[settings.time] + this.look = this.weather.covered ? { ...look, glitter: look.glitter * 0.12 } : look + this.orb = look.orb + if (settings.time === "day") this.frame = 0.3 + this.stars = makeStars(look.stars, HORIZON - 0.08) + this.clouds = Array.from({ length: 7 }, (_, i) => makeCloud(lerp(0.03, 0.42, (i + Math.random() * 0.6) / 7))) + this.islands = [ + { kind: "volcano", d: 0.022, x: 6 }, + { kind: "islets", d: 0.034, x: -8 }, + { kind: "stacks", d: 0.012, x: 22 }, + ] + this.nextIsland = rand(30, 50) + this.nextShip = rand(5, 20) + this.wingmen = (settings.activity === "teeming" ? [0, 1, 2, 5] : settings.activity === "lively" ? [1, 2] : []).map((slot) => ({ from: slot, to: slot, t: 1 })) + // Traffic already underway when the scene starts, so a fresh screen isn't empty. + if (settings.activity !== "calm") this.ships.push(makeShip(rand(15, 40))) + if (settings.activity !== "calm") + for (const kind of Object.keys(SCHEDULE) as Kind[]) { + const range = SCHEDULE[kind][settings.activity] + if (!range) continue + this.waits[kind] = rand(0, range[0]) + if (Math.random() < (settings.activity === "teeming" ? 0.5 : 0.25)) this.movers.push({ ...this.spawn(kind), t: rand(0.1, 0.6) }) + } + for (let i = 0; i < this.drops.length; i += 3) { + this.drops[i] = Math.random() * TAU + this.drops[i + 1] = rand(0.02, 1.2) + this.drops[i + 2] = Math.random() + } + } + + step(dt: number) { + dt = clamp(dt, 0, 0.1) * TIME_SCALE + this.time += dt + this.creatureTime += dt * CREATURE_SPEED + const rows = dt * this.speed + this.flow += rows + for (const i of this.islands) i.d += rows * rowHeight(i.d) + this.islands = this.islands.filter((i) => i.d < 0.6 && Math.abs(i.x * i.d) < 2.5 + ISLAND * 0.3 * i.d) + this.nextIsland -= dt + if (this.nextIsland <= 0 && this.islands.length < 3) { + const kind = (["volcano", "islets", "stacks"] as const)[Math.floor(Math.random() * 3)] + const side = Math.random() < 0.5 ? -1 : 1 + this.islands.push({ kind, d: 0.006, x: side * (kind === "stacks" ? rand(12, 22) : rand(5, 9)) }) + this.nextIsland = this.activity === "calm" ? rand(70, 110) : this.activity === "lively" ? rand(40, 65) : rand(28, 48) + } + for (const c of this.clouds) { + c.e += rows * rowHeight(c.e) + const x = Math.abs(c.x * c.e) + if (HORIZON - c.y * c.e < -c.size * c.e * 1.4 || x > 3) Object.assign(c, makeCloud(rand(0.02, 0.03)), { mips: [] }) + } + this.clouds.sort((a, b) => a.e - b.e) + const ship = this.carrier + if (ship.z > 0) { + ship.z -= dt * this.speed * 0.24 * Math.max(ship.z, 4) * 0.8 + if (ship.z + LENGTH < 0.3) Object.assign(ship, { z: -1, wait: this.activity === "calm" ? rand(150, 300) : rand(60, 120) }) + } else if ((ship.wait -= dt) <= 0) ship.z = 120 + this.stepTraffic(dt, rows) + const m = this.maneuver + if (m.kind !== "none" && (m.t += dt) > (m.kind === "roll" ? ROLL : BARREL)) m.kind = "none" + const e = this.egg + if (e.t >= 0) e.t = e.t + dt > EGG ? -1 : e.t + dt + else if ((e.wait -= dt) <= 0) { + e.t = 0 + e.wait = eggWait(true) * TIME_SCALE + } + for (const f of this.flares) { + f.age += dt + f.x += f.vx * dt + f.y += f.vy * dt + f.vy += 0.03 * dt + } + this.flares = this.flares.filter((f) => f.age < 2.5) + if (this.gloom > 0) { + driftClouds(this.storm, this.A, dt) + for (let i = 0; i < this.drops.length; i += 3) { + this.drops[i + 1] *= Math.exp(dt * (2 + this.drops[i + 2] * 2)) + if (this.drops[i + 1] > 1.3) { + this.drops[i] = Math.random() * TAU + this.drops[i + 1] = rand(0.03, 0.12) + } + } + } + this.stepGloom(dt) + this.weather.step(dt) + } + + // A click rolls the jet toward its side and pops a pair of flares. + poke(x: number) { + const dir = x < this.A / 2 ? -1 : 1 + if (this.maneuver.kind === "none") this.maneuver = { kind: "roll", t: 0, dir } + for (let i = 0; i < 6; i++) { + const side = i % 2 ? -1 : 1 + this.flares.push({ x: this.A / 2 + side * 0.03, y: JET_Y + 0.02, r: 0.012, vx: side * rand(0.03, 0.08), vy: rand(-0.005, 0.02), age: -Math.floor(i / 2) * 0.25 }) + } + } + + // When the agent finishes: an afterburner surge and a victory barrel roll. + protected override visit() { + if (this.maneuver.kind !== "barrel") this.maneuver = { kind: "barrel", t: 0, dir: Math.random() < 0.5 ? -1 : 1 } + } + + render() { + this.restore() + const { W, H, look } = this + this.camera() + if (!this.weather.covered && look.stars) paintStars(this.hdr, W, H, this.stars, this.time, HORIZON - 0.08, look.night ? 0.55 : 0.25, this.points) + paintStorm(this.hdr, W, H, this.storm, look.cloud[0], look.cloud[1], this.gloom) + this.drawSea() + // Islands, clouds and the carrier from farthest to nearest. + const far: { depth: number; draw: () => void }[] = [ + ...this.islands.map((i) => ({ depth: i.d, draw: () => this.drawIsland(i) })), + ...this.clouds.map((c) => ({ depth: c.e, draw: () => this.drawCloud(c) })), + ] + if (this.carrier.z > 0) far.push({ depth: 1 / Math.max(0.1, this.carrier.z), draw: () => this.drawCarrier() }) + for (const ship of this.ships) far.push({ depth: 1 / Math.max(0.1, ship.z), draw: () => this.drawShip(ship) }) + for (const m of this.movers) if (RUN[m.kind]) far.push({ depth: 1 / this.distance(m), draw: () => this.drawWorldMover(m) }) + for (const w of this.wisps) far.push({ depth: w.e, draw: () => this.drawCloud(w) }) + for (const m of this.movers) if (!RUN[m.kind] && m.kind !== "a10") this.drawSkyMover(m) + for (const f of far.sort((a, b) => a.depth - b.depth)) f.draw() + for (const m of this.movers) if (m.kind === "a10") this.drawSkyMover(m) + if (this.egg.t >= 0) this.drawEgg() + this.drawJets() + this.drawFlares() + if (this.gloom > 0) this.drawRain() + this.weather.draw(this.hdr, W, H) + this.finish() + } + + // The sky never moves, so it's painted once per size; everything else moves with the camera. + protected override layout() { + const { W, H, A, look } = this + this.orb = { ...look.orb, x: (A / 2 + look.orbX) / A } + paintSky(this.hdr, W, H, look.sky, this.orb, this.px) + this.weather.cover(this.hdr, W, H) + // What the sea mirrors: the sky's average color on each row above the horizon. + const sky = new Float32Array(H * 3) + for (let y = 0; y < Math.ceil(HORIZON * H); y++) + for (let x = 0; x < W; x += 2) for (let c = 0; c < 3; c++) sky[y * 3 + c] += this.hdr[(y * W + x) * 3 + c] / Math.ceil(W / 2) + const rows = [0] + while (rows[rows.length - 1] < 1.15 - HORIZON) rows.push(rows[rows.length - 1] + rowHeight(rows[rows.length - 1])) + // The sea's buffers start high enough for the horizon at its steepest tilt. + const y0 = Math.max(0, Math.floor((HORIZON - (A / 2) * 0.06 - 0.01) * H)) + const n = W * (H - y0) + this.sea = { y0, rows: Float64Array.from(rows), ids: new Int32Array(n * 4), idA: new Int32Array(n), idB: new Int32Array(n), mix: new Float32Array(n), cover: new Float32Array(n), sky } + for (const c of [...this.clouds, ...this.wisps]) c.mips = [] + } + + private paint(c: RGB): RGB { + const t = this.look.tint + return [c[0] * t[0], c[1] * t[1], c[2] * t[2]] + } + + // The jet's bank and drift this frame, and the horizon tilting a little against the bank. + private camera() { + const t = this.creatureTime + const weave = 0.7 * Math.sin(t * 2.4) + 0.3 * Math.sin(t * 1.3 + 1) + this.lateral = -0.6 * this.bank * weave + const m = this.maneuver + const nudge = m.kind === "roll" ? m.dir * 0.45 * Math.sin((Math.PI * m.t) / ROLL) : 0 + this.roll = this.bank * weave + nudge + this.tilt = -TILT * this.roll + } + + // A point in the unrotated view, in pixels, turned by the horizon's tilt about the middle of the horizon. + private turn(x: number, y: number): Point { + const cx = (this.A / 2) * this.H + const cy = HORIZON * this.H + const c = Math.cos(this.tilt) + const s = Math.sin(this.tilt) + return [cx + (x - cx) * c - (y - cy) * s, cy + (x - cx) * s + (y - cy) * c] + } + + // The sea this frame. Rows of jittered vertices slide down the table of rows toward the camera as the jet flies on; + // each vertex keeps its identity (column, and row counted from when it rose at the horizon), so its jitter and swell + // travel with it, and its columns spread apart as it nears. The columns slide sideways as the jet drifts, more the + // nearer they are. Rows are zipped into triangles, tilted with the horizon and rasterized at two samples a pixel each + // way; each pixel keeps its two commonest triangles and how much of it they cover. + private drawSea() { + const { W, H, A, look } = this + const sea = this.sea + const m = this.mesh + const base = Math.floor(this.flow) + const f = this.flow - base + const table = sea.rows + const xs: number[] = [] + const ys: number[] = [] + const ds: number[] = [] + const ids: number[] = [] + const rows: number[][] = [] + for (let r = 0; r + 1 < table.length; r++) { + const d = table[r] + (table[r + 1] - table[r]) * f + const q = r - base + const h = rowHeight(d) + const w = Math.max(0.018, h * 2.3) + const shift = (this.lateral * d) / w + const row: number[] = [] + const i0 = Math.floor(shift + (-0.35 - A / 2) / w) + const i1 = Math.ceil(shift + (A / 2 + 0.35) / w) + for (let i = i0; i <= i1; i++) { + row.push(xs.length) + xs.push(A / 2 + (i + (hash2(i, q) - 0.5) * 0.7 - shift) * w) + ys.push(HORIZON + d + (hash2(i + 71, q) - 0.5) * 0.3 * h * smoothstep(0, 0.01, d)) + ds.push(d) + ids.push(i * 7919 + q * 104729) + } + rows.push(row) + } + const tris: number[] = [] + for (let k = 0; k + 1 < rows.length; k++) stitch(rows[k], rows[k + 1], xs, tris) + const n = xs.length + if (m.X.length < n) { + m.X = new Float32Array(n * 2) + m.Z = new Float32Array(n * 2) + m.height = new Float32Array(n * 2) + } + const t = this.time + for (let i = 0; i < n; i++) { + const z = (FOCAL * CAMERA) / Math.max(0.002, ys[i] - HORIZON) + m.Z[i] = z + m.X[i] = ((xs[i] - A / 2) * z) / FOCAL + // Calmer toward the horizon, where the facets get smaller than a cell. + const amp = Math.max(0.018, rowHeight(ds[i]) * 2.3) * 0.45 * smoothstep(0.004, 0.08, ds[i]) + const id = ids[i] + m.height[i] = amp * ((hash(id) - 0.5) * 1.6 + 0.3 * Math.sin(t * (0.5 + hash(id + 0.5) * 0.6) + hash(id + 0.3) * TAU)) + } + m.tris = Int32Array.from(tris) + if (m.colors.length < tris.length) m.colors = new Float32Array(tris.length * 2) + shadeFacets(m.X, m.Z, m.height, m.tris, m.colors, sea.sky, H, this.orb.x * A - A / 2, this.orb.y, look.sea[0], look.sea[1], look.mirror, look.spec, look.glitter * (1 - 0.8 * this.gloom), look.spread, look.storm, smoothstep(0, 1, this.gloom) * 0.7) + // Screen positions, tilted, in the sea buffer's doubled samples. + const y0 = sea.y0 + const sx = new Float32Array(n) + const sy = new Float32Array(n) + for (let i = 0; i < n; i++) { + const p = this.turn(xs[i] * H, ys[i] * H) + sx[i] = p[0] * 2 + sy[i] = (p[1] - y0) * 2 + } + const SW = W * 2 + const SH = (H - y0) * 2 + sea.ids.fill(-1) + for (let k = 0; k < tris.length; k += 3) rasterTri(sea.ids, SW, SH, sx[tris[k]], sy[tris[k]], sx[tris[k + 1]], sy[tris[k + 1]], sx[tris[k + 2]], sy[tris[k + 2]], k / 3) + const top = Math.max(y0, Math.floor(HORIZON * H - (A / 2) * H * Math.abs(Math.sin(this.tilt))) - 2) + resolveSamples(sea.ids, W, H - y0, top - y0, sea.idA, sea.idB, sea.mix, sea.cover) + fillSea(this.hdr, W, y0, (top - y0) * W, sea.idA, sea.idB, sea.mix, sea.cover, m.colors) + this.touch(0, top, W - 1, H - 1) + } + + // A flat face lit by how squarely it faces the light (0 away, 1 toward), through the island's own key light. + private facet(c: RGB, lit: number): RGB { + const { look } = this + const k = clamp(lit, 0, 1) + if (!look.night && look.tint[0] === 1) return [c[0] * (0.45 + 0.75 * k), c[1] * (0.45 + 0.75 * k), c[2] * (0.45 + 0.75 * k)] + const t = look.tint + return [c[0] * t[0] * 2.4 + look.key[0] * 0.08 * k, c[1] * t[1] * 2.4 + look.key[1] * 0.08 * k, c[2] * t[2] * 2.4 + look.key[2] * 0.08 * k] + } + + // An island on the sea at its distance, sliding out to its side as it nears: the volcano on its forested island, + // a pair of forested islets, or sea stacks on a reef. Shapes are in island units across and up from its middle. + private drawIsland(island: Island) { + this.drawIslandShape(island) + this.drawGulls(island) + } + + private drawIslandShape(island: Island) { + const { A, H } = this + const bx = A / 2 + (island.x - this.lateral) * island.d + const by = HORIZON + island.d + const g = ISLAND * island.d + if (bx + 0.32 * g < -0.05 || bx - 0.32 * g > A + 0.05) return + const P = (dx: number, up: number) => this.turn((bx + dx * g) * H, (by - up * g) * H) + const lightSide = this.orb.x * A < bx ? -1 : 1 + const color = (c: RGB, lit: number) => this.facet(c, lit) + if (island.kind === "volcano") { + this.polygon([P(-0.27, 0), P(-0.2, 0.02), P(-0.1, 0.035), P(0.12, 0.035), P(0.22, 0.018), P(0.29, 0)], color(FOREST, 0.4)) + const peak = P(-0.01, 0.174) + const feet = [-0.17, -0.1, -0.03, 0.05, 0.12, 0.18].map((dx) => P(dx, 0.029)) + this.polygon([peak, ...feet], color(ROCK, 0.5)) + for (let i = 0; i + 1 < feet.length; i++) { + const face = (i + 0.5) / (feet.length - 1) - 0.5 + this.polygon([peak, feet[i], feet[i + 1]], color(ROCK, 0.5 + face * lightSide * 0.9)) + } + // Forest climbing the lower slopes, ragged with treetops. + const slope = (dx: number) => 0.029 + (1 - Math.abs(dx + 0.01) / 0.175) * 0.17 + const forest: Point[] = [P(-0.2, 0)] + for (let i = 0; i <= 24; i++) { + const dx = -0.19 + (i / 24) * 0.39 + const up = Math.min(slope(dx) - 0.045, 0.075) + (i % 2 ? 0.006 : 0) + hash(i * 2.1) * 0.006 + forest.push(P(dx, Math.max(up, 0.02))) + } + forest.push(P(0.21, 0)) + this.polygon(forest, color(FOREST, 0.5 - lightSide * 0.1)) + if (g * H > 20) + for (let i = 0; i < 18; i++) { + const dx = -0.22 + (i / 17) * 0.47 + const up = Math.min(slope(dx) - 0.05, 0.06) - hash(i * 5.3) * 0.02 + if (up < 0.012) continue + const s = 0.012 + hash(i * 1.9) * 0.008 + this.polygon([P(dx, up), P(dx - s * 0.7, up - s * 1.4), P(dx + s * 0.7, up - s * 1.4)], color(FOREST, hash(i) > 0.5 ? 0.75 : 0.3)) + } + this.polygon([P(-0.022, 0.159), P(-0.01, 0.174), P(0.004, 0.161), P(-0.01, 0.154)], color(ROCK, 0.15)) + return + } + if (island.kind === "islets") { + for (const [x, w, h] of [[-0.07, 0.09, 0.05], [0.07, 0.08, 0.045]] as const) { + const pts = [P(x - w, 0), P(x - w * 0.55, h * 0.55), P(x - w * 0.15, h), P(x + w * 0.2, h * 0.85), P(x + w * 0.6, h * 0.4), P(x + w, 0)] + this.polygon(pts, color(FOREST, 0.5)) + for (let i = 0; i + 1 < pts.length; i++) this.polygon([P(x, 0), pts[i], pts[i + 1]], color(FOREST, 0.55 - ((i - 2) / 3) * lightSide * 0.5)) + if (g * H > 20) + for (let i = 0; i < 7; i++) { + const dx = (i / 6 - 0.5) * w * 1.4 + const up = h * (1 - Math.abs(dx) / w) * 0.95 + this.polygon([P(x + dx, up + 0.01), P(x + dx - 0.009, up - 0.008), P(x + dx + 0.009, up - 0.008)], color(FOREST, hash(i + x) > 0.5 ? 0.7 : 0.35)) + } + } + return + } + for (const [dx, w, h] of [[-0.03, 0.012, 0.035], [-0.012, 0.009, 0.05], [0.004, 0.011, 0.042], [0.03, 0.008, 0.028]] as const) + this.polygon([P(dx - w, 0), P(dx - w * 0.6, h), P(dx + w * 0.3, h * 1.05), P(dx + w, 0)], color(ROCK, 0.45 + lightSide * dx * 8)) + this.polygon([P(-0.07, 0), P(-0.05, 0.008), P(0.06, 0.008), P(0.09, 0)], color(FOREST, 0.4)) + } + + // A low-poly cloud sailing toward the camera overhead, growing as it nears and slipping out over the top. + private drawCloud(c: Cloud) { + const { A, H } = this + if (!c.mips.length) c.mips = cloudMips(c, H, this.weather.scud ?? this.look.cloud, this.orb.x * A - A / 2 - c.x * 0.15) + const [x, y] = this.turn((A / 2 + (c.x - this.lateral * 0.5) * c.e) * H, (HORIZON - c.y * c.e) * H) + const box = blitScaled(this.hdr, this.W, H, c.mips, x, y, c.e / c.top, smoothstep(0.02, 0.05, c.e) * c.alpha) + if (box) this.touch(box[0], box[1], box[2], box[3]) + } + + // The carrier sailing on ahead in the camera's world: u across its deck (-1 to 1), v from stern (0) to bow (1), h up + // off the deck, in half-beams. Each face is clipped against the camera's near plane, so it can pass under the jet. + private drawCarrier() { + const { A, H, look } = this + const zs = this.carrier.z + const world = (u: number, v: number, h: number): V3 => [u * BEAMW - this.lateral, zs + v * LENGTH, DECK + h * BEAMW * RISE] + const face = (pts: (readonly [number, number, number])[], color: RGB, alpha = 1) => this.face3(pts.map((p) => world(p[0], p[1], p[2])), color, alpha) + const c = (color: RGB, k = 1): RGB => this.craft([color[0] * k, color[1] * k, color[2] * k]) + // Its wake trailing back toward the camera, and the hull's stern and flanks under the deck edge. + const foam = look.night ? 0.04 : 0.12 + face([[-0.9, 0, -0.42], [0.9, 0, -0.42], [1.8, -1.2, -0.42], [-1.8, -1.2, -0.42]], look.foam, foam) + face([[-0.45, 0, -0.42], [0.45, 0, -0.42], [0.7, -0.7, -0.42], [-0.7, -0.7, -0.42]], look.foam, foam * 1.5) + face([[-0.92, 0, 0], [0.92, 0, 0], [0.82, 0, -0.42], [-0.82, 0, -0.42]], c(HULL, 0.8)) + face([[0.92, 0, 0], [0.95, 0.9, 0], [0.8, 0.9, -0.42], [0.82, 0, -0.42]], c(HULL, 0.55)) + face([[-0.92, 0, 0], [-0.95, 0.9, 0], [-0.8, 0.9, -0.42], [-0.82, 0, -0.42]], c(HULL, 0.45)) + face([[-1, 0, 0], [1, 0, 0], [1, 0.88, 0], [0.8, 1.02, 0], [-0.8, 1.02, 0], [-1, 0.88, 0]], c(HULL, 0.6)) + face([[-0.96, 0.01, 0], [0.96, 0.01, 0], [0.96, 0.87, 0], [0.77, 1, 0], [-0.77, 1, 0], [-0.96, 0.87, 0]], c(DECK_C)) + const near = zs < 60 + if (near) { + const line = (u0: number, v0: number, u1: number, v1: number, w: number, color: RGB, k = 1) => face([[u0 - w, v0, 0], [u0 + w, v0, 0], [u1 + w, v1, 0], [u1 - w, v1, 0]], c(color, k)) + line(-0.62, 0.05, -0.62, 0.97, 0.02, STRIPE, 0.8) + line(0.42, 0.05, 0.42, 0.97, 0.02, STRIPE, 0.8) + for (let v = 0.2; v < 0.95; v += 0.11) line(-0.1, v, -0.1, v + 0.06, 0.022, STRIPE, 0.9) + line(0.2, 0.18, 0.2, 0.96, 0.012, YELLOW) + line(-0.35, 0.3, -0.12, 0.96, 0.012, YELLOW, 0.9) + for (let i = 0; i < 8; i++) { + const u = -0.55 + i * 0.12 + face([[u, 0.06, 0], [u + 0.07, 0.06, 0], [u + 0.07, 0.12, 0], [u, 0.12, 0]], c(STRIPE, 0.85)) + } + } + // The island superstructure on the starboard side, its bridge windows and mast. + const k = (v: number) => c(HULL, v) + const [u0, u1, v0, v1, top] = [0.66, 0.96, 0.44, 0.68, 1] + face([[u0, v0, 0], [u0, v1, 0], [u0, v1, top], [u0, v0, top]], k(0.7)) + face([[u0, v0, 0], [u1, v0, 0], [u1, v0, top], [u0, v0, top]], k(0.9)) + face([[u0, v0, top], [u1, v0, top], [u1, v1, top], [u0, v1, top]], k(1.15)) + face([[0.72, 0.5, top], [0.72, 0.64, top], [0.72, 0.64, top + 0.4], [0.72, 0.5, top + 0.4]], k(0.62)) + face([[0.72, 0.5, top], [0.92, 0.5, top], [0.92, 0.5, top + 0.4], [0.72, 0.5, top + 0.4]], k(0.85)) + face([[0.72, 0.5, top + 0.4], [0.92, 0.5, top + 0.4], [0.92, 0.64, top + 0.4], [0.72, 0.64, top + 0.4]], k(1.1)) + face([[0.73, 0.5, top + 0.3], [0.91, 0.5, top + 0.3], [0.91, 0.5, top + 0.2], [0.73, 0.5, top + 0.2]], look.night ? [0.6, 0.3, 0.06] : k(0.3)) + face([[0.8, 0.57, top + 0.4], [0.84, 0.57, top + 0.4], [0.83, 0.57, 2.1], [0.81, 0.57, 2.1]], k(0.75)) + face([[0.74, 0.57, 1.82], [0.9, 0.57, 1.82], [0.9, 0.57, 1.76], [0.74, 0.57, 1.76]], k(0.75)) + // Deck lights at night, and its beacons always. + const lamp = (u: number, v: number, h: number, color: RGB, k: number, size: number) => this.lamp3(world(u, v, h), color, k, size) + const glow = look.lamp * 0.5 + if (look.lamp > 0.5) + for (let v = 0.04; v < 0.99; v += near ? 0.075 : 0.3) { + lamp(-0.97, v, 0, AMBER, glow, 1) + lamp(0.97, v, 0, AMBER, glow, 1) + } + const blink = 0.3 + 0.7 * Math.max(0, Math.sin(this.time * 2)) ** 4 + lamp(0.82, 0.57, 2.1, [2, 0.3, 0.15], blink * Math.max(0.4, glow), 2) + if (look.lamp > 0.5 && zs < 12) { + const [cx, cy] = this.turn((A / 2 - this.lateral / (zs + 1.5)) * H, (HORIZON + (CAMERA - DECK) / (zs + 1.5)) * H) + lightPool(this.hdr, this.W, H, cx, cy, (0.6 * H) / (zs + 1.5), (0.3 * H) / (zs + 1.5), [1, 0.6, 0.25], 1, 0.01) + } + } + + // The jets: the lead seen from behind near the bottom, and its wingmen holding formation off to the sides. + private drawJets() { + const { A, H } = this + const t = this.creatureTime + const m = this.maneuver + const barrel = m.kind === "barrel" ? m.dir * TAU * smoothstep(0.3, BARREL - 0.3, m.t) : 0 + const surge = m.kind === "barrel" ? Math.sin((Math.PI * m.t) / BARREL) : 0 + const shake = this.gloom * 0.008 + const dx = (-this.lateral * 0.05 + shake * (noise1(this.time * 4) - 0.5)) * H + const dy = (0.004 * Math.sin(t * 5.3) + shake * (noise1(this.time * 4, 3) - 0.5)) * H + // Clear of the lead's wingtips, and a little higher on narrow screens where they sit closer in. + const off = Math.min(0.6, A / 2 - 0.12) + const lift = Math.max(0, 0.5 - off) * 0.4 + const wing = this.wingmen.map((w, i) => { + const k = smoothstep(0, 1, w.t) + const a = SLOTS[w.from] + const b = SLOTS[w.to] + return { i, x: lerp(a[0], b[0], k), y: lerp(a[1], b[1], k), size: lerp(a[2], b[2], k), bank: Math.sign(b[0] - a[0]) * 0.3 * Math.sin(Math.PI * k) } + }) + // Farther slots first, so nearer jets draw over them. + for (const w of wing.sort((a, b) => a.size - b.size)) { + const x = (A / 2 + w.x * off + 0.025 * Math.sin(t * 2.3 + w.i * 1.7)) * H + const y = (JET_Y - w.y - lift + 0.012 * Math.sin(t * 3.1 + w.i * 2)) * H + this.drawJet(x, y, w.size * SPAN * H, this.roll + w.bank + 0.03 * Math.sin(t * 2.9 + w.i), 0.8) + } + this.drawJet((A / 2) * H + dx, JET_Y * H + dy, SPAN * H, this.roll + barrel, 1 + 0.9 * surge) + } + + // A jet from behind and a little above, banked by roll: faceted fuselage, swept wings, twin canted tails, and two + // nozzles glowing amber with a slow afterburner flicker. s is its wingspan in pixels. + private drawJet(cx: number, cy: number, s: number, roll: number, burn: number, front = false) { + const { look } = this + const cr = Math.cos(roll) + const sr = Math.sin(roll) + const ce = Math.cos(ELEVATION) + const se = Math.sin(ELEVATION) + const rot = (p: V3): V3 => [p[0] * cr + p[1] * sr, p[1] * cr - p[0] * sr, p[2]] + const project = (p: V3): Point => { + const depth = -p[1] * se + p[2] * ce + const k = s / (1 + 0.35 * depth) + return [cx + p[0] * k, cy - (p[1] * ce + p[2] * se) * k] + } + const drawn = FACES.flatMap((f) => { + const pts = f.pts.map(rot) + const [a, b, c] = pts + let n = norm(cross([b[0] - a[0], b[1] - a[1], b[2] - a[2]], [c[0] - a[0], c[1] - a[1], c[2] - a[2]])) + const facing = n[1] * se - n[2] * ce + if (facing <= 0 && !f.both) return [] + if (facing <= 0) n = [-n[0], -n[1], -n[2]] + const L = look.toward + const lit = Math.max(0, n[0] * L[0] + n[1] * L[1] + n[2] * L[2]) + const up = Math.max(0, n[1]) * 0.25 + const color: RGB = [0, 1, 2].map((i) => f.color[i] * (look.ambient[i] * (0.8 + up) + look.light[i] * lit)) as RGB + const depth = pts.reduce((sum, p) => sum + (-p[1] * se + p[2] * ce), 0) / pts.length + return [{ pts: pts.map(project), color, depth }] + }) + for (const f of drawn.sort((a, b) => b.depth - a.depth)) this.polygon(f.pts, f.color) + if (s < 6) return + // Seen from the front, only its wingtip lights show, the other way round. + if (front) { + for (const side of [-1, 1]) { + const [wx, wy] = project(rot([side * 0.5, -0.005, -0.27])) + this.navLight(wx, wy, side > 0 ? RED : GREEN, Math.max(0.8, 0.011 * s)) + } + return + } + // The nozzles, a slow shimmer in their glow, and at night a warm pool around them. + const flick = 1 + 0.12 * (noise1(this.time * 5 + cx) - 0.5) + const k = burn * flick * (look.night ? 1 : 0.8) + for (const side of [-1, 1]) { + const [x, y] = project(rot([side * 0.045, 0.005, -0.5])) + const [tx, ty] = project(rot([side * 0.045, 0.005, -0.62 - 0.1 * burn])) + trail(this.hdr, this.W, this.H, x, y, tx, ty, 0.03 * s, BURNER, 0.35 * Math.min(1, k)) + this.disc(x, y, 0.045 * s, BURNER[0] * 0.25 * k, BURNER[1] * 0.25 * k, BURNER[2] * 0.25 * k, 0.6) + this.disc(x, y, 0.024 * s, BURNER[0] * 0.8 * k, BURNER[1] * 0.8 * k, BURNER[2] * 0.8 * k, 1) + this.touch(Math.min(x, tx) - 0.06 * s, Math.min(y, ty) - 0.06 * s, Math.max(x, tx) + 0.06 * s, Math.max(y, ty) + 0.06 * s) + // Red to port, green to starboard. + const [wx, wy] = project(rot([side * 0.5, -0.005, -0.27])) + this.navLight(wx, wy, side < 0 ? RED : GREEN, Math.max(0.8, 0.011 * s)) + } + if (look.night) { + const [x, y] = project(rot([0, 0, -0.5])) + lightPool(this.hdr, this.W, this.H, x, y, 0.22 * s, 0.14 * s, [1, 0.5, 0.15], 0, 0.05 * k) + } + } + + // An aircraft's color: its daytime color darkened for the time of day, kept a little lighter than the sky at night so + // it reads as a silhouette. + private craft(c: RGB): RGB { + const k = this.look.night ? 3.2 : 1 + const p = this.paint(c) + return [p[0] * k, p[1] * k, p[2] * k] + } + + // Moves the air traffic, ships, wisps and wingmen along, and brings on new ones when their waits run out. + private stepTraffic(dt: number, rows: number) { + const level = this.activity + for (const m of this.movers) { + m.t += dt / m.span + this.fireFlares(m) + } + this.movers = this.movers.filter((m) => m.t < 1) + for (const ship of this.ships) ship.z -= rows * 0.24 * Math.max(ship.z, 4) * SHIPS[ship.kind].close + this.ships = this.ships.filter((ship) => ship.z + SHIPS[ship.kind].length > 0.4 && (ship.z > 20 || Math.abs((ship.x - this.lateral) / ship.z) < this.A / 2 + 0.6)) + for (const w of this.wisps) w.e += 3 * rows * rowHeight(w.e) + this.wisps = this.wisps.filter((w) => w.e < w.top && Math.abs(w.x * w.e) < this.A / 2 + 0.7) + for (const w of this.wingmen) w.t = Math.min(1, w.t + dt / 8) + if (level === "calm") return + for (const kind of Object.keys(SCHEDULE) as Kind[]) { + const range = SCHEDULE[kind][level] + if (!range) continue + const wait = (this.waits[kind] ?? 0) - dt + this.waits[kind] = wait + const most = level === "lively" ? Math.ceil(SCHEDULE[kind].most / 2) : SCHEDULE[kind].most + if (wait > 0 || this.movers.filter((m) => m.kind === kind).length >= most) continue + this.movers.push(this.spawn(kind)) + this.waits[kind] = rand(range[0], range[1]) + } + if ((this.nextShip -= dt) <= 0 && this.ships.length < 4) { + this.ships.push(makeShip(90)) + this.nextShip = level === "lively" ? rand(25, 45) : rand(10, 20) + } + if ((this.nextWisp -= dt) <= 0 && this.wisps.length < 2) { + this.wisps.push(makeWisp()) + this.nextWisp = level === "lively" ? rand(30, 60) : rand(14, 28) + } + // Now and then a wingman slides over to a free slot in the formation. + if ((this.nextShift -= dt) <= 0 && this.wingmen.every((w) => w.t >= 1)) { + const w = this.wingmen[Math.floor(Math.random() * this.wingmen.length)] + const free = SLOTS.map((_, i) => i).filter((i) => !this.wingmen.some((o) => o.to === i)) + Object.assign(w, { from: w.to, to: free[Math.floor(Math.random() * free.length)], t: 0 }) + this.nextShift = rand(30, 55) + } + } + + private spawn(kind: Kind): Mover { + const dir = Math.random() < 0.5 ? -1 : 1 + const base = { kind, t: 0, dir, x: 0, seed: Math.random() * 100, fired: 0 } + if (kind === "cross") { + const y = rand(0.05, 0.3) + const f = (y - 0.05) / 0.25 + return { ...base, y, size: lerp(0.014, 0.034, f), span: lerp(36, 20, f) } + } + if (kind === "four") return { ...base, y: rand(0.1, 0.24), size: 0.016, span: 42 } + if (kind === "tanker") return { ...base, y: rand(0.07, 0.15), size: 0.07, span: 62 } + if (kind === "e2") return { ...base, y: rand(0.1, 0.2), size: 0.05, span: 52 } + if (kind === "b2") return { ...base, y: rand(0.04, 0.1), size: 0.075, span: 60 } + if (kind === "a10") return { ...base, y: rand(0.53, 0.57), size: 0.06, span: 24 } + if (kind === "dogfight") return { ...base, y: rand(0.11, 0.2), size: 0.02, span: 48 } + if (kind === "overtake") return { ...base, x: dir * rand(1.4, 2.1), y: rand(0.55, 1.45), size: 1, span: 20 } + if (kind === "oncoming") return { ...base, x: dir * rand(3, 6), y: rand(1.2, 1.7), size: 1, span: 18 } + if (kind === "helo") return { ...base, x: dir * rand(1.6, 3.2), y: rand(0.12, 0.25), size: 1, span: 46 } + return { ...base, x: dir * rand(0.45, 1.3), y: rand(0.35, 0.6), size: 9 + Math.floor(Math.random() * 6), span: 30 } + } + + // A world kind's distance from the camera, closing or opening evenly in perspective. + private distance(m: Mover) { + const run = RUN[m.kind] ?? [1, 1] + return run[0] * (run[1] / run[0]) ** m.t + } + + // A point in the sea camera's world (x across, z ahead, altitude with the camera at 1) on screen, in pixels. + private world(x: number, z: number, alt: number): Point { + return this.turn((this.A / 2 + (x - this.lateral) / z) * this.H, (HORIZON + (CAMERA - alt) / z) * this.H) + } + + // A flat face in the camera's world, clipped against its near plane so it can pass under the camera. + private face3(pts: V3[], color: RGB, alpha = 1) { + const clipped = clipNear(pts, 0.35) + if (clipped.length < 3) return + this.polygon(clipped.map((p) => this.turn((this.A / 2 + p[0] / p[1]) * this.H, (HORIZON + (CAMERA - p[2]) / p[1]) * this.H)), color, alpha) + } + + // A light on something in the camera's world, gliding from cell to cell as it moves. + private lamp3(p: V3, color: RGB, k: number, size: number) { + if (p[1] < 0.6) return + const [x, y] = this.turn((this.A / 2 + p[0] / p[1]) * this.H, (HORIZON + (CAMERA - p[2]) / p[1]) * this.H) + if (this.cells) this.point(x, y, color[0] * k, color[1] * k, color[2] * k, size, true) + else this.disc(x, y, Math.max(0.6, (0.004 * this.H) / p[1]), color[0] * k, color[1] * k, color[2] * k, 1) + } + + // A wingtip light: bright at night, faint by day. + private navLight(x: number, y: number, color: RGB, r: number) { + const k = this.look.night ? 0.5 : this.look.lamp > 0.5 ? 0.35 : 0.2 + this.disc(x, y, r, color[0] * k, color[1] * k, color[2] * k, 1) + } + + // Wingtip lights and a blinking amber beacon on a small aircraft seen in planform, at sunset and night. + private navPlan(x: number, y: number, s: number, heading: number, squash: number, seed: number) { + if (this.look.lamp < 0.5) return + const k = this.look.night ? 0.6 : 0.35 + for (const b of [-0.42, 0.42]) { + const px = x + (-0.19 * Math.cos(heading) - b * Math.sin(heading)) * s + const py = y + (-0.19 * Math.sin(heading) + b * Math.cos(heading)) * squash * s + const c = b < 0 ? RED : GREEN + this.splat(px, py, c[0] * k, c[1] * k, c[2] * k) + } + if (Math.sin(this.time * 3 + seed) > 0.75) this.splat(x, y, AMBER[0] * k, AMBER[1] * k, AMBER[2] * k) + } + + // A dogfighting jet's place on its figure of eight, lag seconds behind the leader, and its heading. + private dogfighter(m: Mover, lag: number): readonly [number, number, number] { + const a = m.t * m.span * 0.3 - lag + const cx = m.dir < 0 ? 0.42 : this.A - 0.42 + return [cx + 0.24 * Math.sin(a), m.y + 0.06 * Math.sin(2 * a), Math.atan2((0.12 * Math.cos(2 * a)) / 0.45, 0.24 * Math.cos(a))] + } + + // The dogfighting leader pops flares every so often, and now and then a jet overtaking pops a pair. + private fireFlares(m: Mover) { + if (m.kind === "dogfight") { + const k = Math.floor((m.t * m.span) / 6) + if (k <= m.fired) return + m.fired = k + if (hash(m.seed + k) < 0.45) return + const [x, y] = this.dogfighter(m, 0) + for (let i = 0; i < 4; i++) this.flares.push({ x, y, r: 0.004, vx: rand(-0.02, 0.02), vy: rand(-0.004, 0.006), age: -i * 0.2 }) + return + } + if (m.kind !== "overtake" || m.fired || m.t < 0.3) return + m.fired = 1 + if (hash(m.seed) < 0.5) return + const z = this.distance(m) + const [x, y] = this.world(m.x, z, m.y) + for (let i = 0; i < 4; i++) this.flares.push({ x: x / this.H, y: y / this.H, r: 0.04 / z, vx: (i % 2 ? -1 : 1) * rand(0.01, 0.03), vy: rand(0, 0.01), age: -Math.floor(i / 2) * 0.3 }) + } + + // Aircraft crossing the sky at a fixed height: lone jets (with contrails when high), a formation of four, a tanker + // refueling two jets, an E-2 with its radar dish, a B-2, an A-10 low over the water, and a dogfighting pair. + private drawSkyMover(m: Mover) { + const { A, H, W, look } = this + const u = m.dir > 0 ? m.t : 1 - m.t + const heading = m.dir > 0 ? 0 : Math.PI + const s = m.size * H + const [x, y] = this.turn(lerp(-0.2, A + 0.2, u) * H, m.y * H) + const jet = this.craft(JET) + if (m.kind === "cross") { + if (m.y < 0.15) { + const [hx, hy] = this.turn(lerp(-0.2, A + 0.2, u) * H - m.dir * 0.45 * s, m.y * H) + const run = Math.min(0.8, m.t * (A + 0.4)) * H + const [tx, ty] = this.turn(lerp(-0.2, A + 0.2, u) * H - m.dir * (0.45 * s + run), m.y * H) + trail(this.hdr, W, H, hx, hy, tx, ty, Math.max(0.7, 0.0035 * H), look.trail, look.night ? 0.25 : 0.32) + this.touch(Math.min(hx, tx) - 3, Math.min(hy, ty) - 3, Math.max(hx, tx) + 3, Math.max(hy, ty) + 3) + } + this.planform(x, y, s, heading, 0.4, JETFORM, jet) + this.navPlan(x, y, s, heading, 0.4, m.seed) + return + } + if (m.kind === "four") { + for (const [a, b] of [[0, 0], [-1.4, -1], [-1.4, 1], [-2.8, 0]] as const) { + const px = x + m.dir * a * s + const py = y + b * s * 0.5 + this.planform(px, py, s, heading, 0.4, JETFORM, jet) + this.navPlan(px, py, s, heading, 0.4, m.seed + a + b) + } + return + } + if (m.kind === "tanker") { + const receivers = [[-0.85, 0.16], [-1.05, -0.12]] as const + const [bx, by] = [x - m.dir * 0.48 * s, y + 0.02 * s] + trail(this.hdr, W, H, bx, by, x + m.dir * (receivers[0][0] + 0.12) * s, y + receivers[0][1] * s, Math.max(0.6, 0.012 * s), jet, 0.8) + this.touch(Math.min(bx, x - m.dir * s) - 2, by - 0.2 * s, Math.max(bx, x - m.dir * s) + 2, by + 0.25 * s) + this.planform(x, y, s, heading, 0.4, TANKERFORM, this.craft([0.46, 0.48, 0.5])) + this.navPlan(x, y, s, heading, 0.4, m.seed) + for (const [a, b] of receivers) { + this.planform(x + m.dir * a * s, y + b * s, 0.32 * s, heading, 0.4, JETFORM, jet) + this.navPlan(x + m.dir * a * s, y + b * s, 0.32 * s, heading, 0.4, m.seed + a) + } + return + } + if (m.kind === "e2") { + const c = this.craft([0.46, 0.48, 0.5]) + this.planform(x, y, s, heading, 0.4, E2FORM, c) + this.ellipse(x - m.dir * 0.08 * s, y - 0.06 * s, 0.2 * s, 0.045 * s, c[0] * 0.7, c[1] * 0.7, c[2] * 0.7, 1) + this.navPlan(x, y, s, heading, 0.4, m.seed) + return + } + if (m.kind === "b2") { + this.planform(x, y, s, heading, 0.3, SPIRITFORM, this.craft(SPIRIT)) + this.navPlan(x, y, s * 0.8, heading, 0.3, m.seed) + return + } + if (m.kind === "a10") { + this.ellipse(x, y + 0.035 * H, s * 0.4, s * 0.05, 0, 0, 0, look.night ? 0.12 : 0.22) + this.planform(x, y, s, heading, 0.38, WARTHOGFORM, this.craft(WARTHOG)) + this.navPlan(x, y, s, heading, 0.38, m.seed) + return + } + const fade = smoothstep(0, 0.05, m.t) * (1 - smoothstep(0.95, 1, m.t)) + for (const lag of [0, 0.5]) { + const [dx, dy, h] = this.dogfighter(m, lag) + const [px, py] = this.turn(dx * H, dy * H) + this.planform(px, py, s, h, 0.45, JETFORM, jet, fade) + if (fade > 0.5) this.navPlan(px, py, s, h, 0.45, m.seed + lag) + } + } + + // Traffic in the camera's world: jets overtaking alongside and shrinking ahead, jets far off flying the other way, + // helicopters low over the water, and flocks of seabirds passing below. + private drawWorldMover(m: Mover) { + const { H } = this + const z = this.distance(m) + if (m.kind === "overtake" || m.kind === "oncoming") { + const [x, y] = this.world(m.x, z, m.y) + const s = (1.26 / z) * H + if (x < -s || x > this.W + s) return + if (m.kind === "overtake") this.drawJet(x, y, s, -m.dir * 0.12 + 0.05 * Math.sin(this.time * 0.5 + m.seed), 1) + else this.drawJet(x, y, s, m.dir * 0.1, 0, true) + return + } + if (m.kind === "helo") { + const [x, y] = this.world(m.x + m.dir * 0.6 * m.t, z, m.y) + this.drawHelicopter(x, y, (0.5 / z) * H, m.dir, m.seed) + return + } + for (let i = 0; i < m.size; i++) { + const dz = hash(m.seed + i * 1.7) * 0.8 + const [x, y] = this.world(m.x + (hash(m.seed + i) - 0.5) * 0.6, z + dz, m.y + (hash(m.seed + i * 2.3) - 0.5) * 0.1) + this.drawBird(x, y, (0.14 / (z + dz)) * H, this.creatureTime * 40 + i * 1.3) + } + } + + // A helicopter side on, nose toward dir: a rounded cabin, a tail boom with its fin, skids, a faint rotor disc rather + // than blades so it doesn't flicker, and a red beacon on its tail at night. + private drawHelicopter(x: number, y: number, s: number, dir: number, seed: number) { + const { look } = this + const body = this.craft(HELO) + if (s < 3) { + this.splat(x, y, body[0], body[1], body[2]) + return + } + const tilt = 0.05 * Math.sin(this.time * 0.21 + seed) + const ca = Math.cos(tilt) + const sa = Math.sin(tilt) + const f = -dir + const Q = (a: number, b: number): Point => [x + (a * f * ca - b * sa) * s, y + (a * f * sa + b * ca) * s] + this.ellipse(...Q(-0.05, 0), 0.22 * s, 0.12 * s, body[0], body[1], body[2], 1) + this.polygon([Q(0.1, -0.04), Q(0.55, -0.03), Q(0.55, 0), Q(0.1, 0.05)], body) + this.polygon([Q(0.5, -0.03), Q(0.58, -0.17), Q(0.62, -0.17), Q(0.58, 0)], body) + this.polygon([Q(-0.22, 0.17), Q(0.14, 0.17), Q(0.14, 0.19), Q(-0.22, 0.19)], body) + this.polygon([Q(-0.05, -0.12), Q(-0.03, -0.12), Q(-0.03, -0.15), Q(-0.05, -0.15)], body) + const blur = look.night ? look.key : look.sky[look.sky.length - 1][1] + this.ellipse(...Q(-0.04, -0.15), 0.5 * s, 0.035 * s, blur[0] * 0.4 + body[0] * 0.6, blur[1] * 0.4 + body[1] * 0.6, blur[2] * 0.4 + body[2] * 0.6, 0.45) + if (look.lamp > 0.5 && Math.sin(this.time * 3 + seed) > 0.6) this.splat(...Q(0.6, -0.1), RED[0] * 0.6, RED[1] * 0.6, RED[2] * 0.6) + } + + // A seabird seen from behind, wings beating now and then between glides; far off just a speck. + private drawBird(x: number, y: number, s: number, phase: number) { + const c = this.craft(BIRD) + if (s < 2) { + const k = Math.max(0.2, s / 2) * 0.5 + this.splat(x, y, c[0] * k, c[1] * k, c[2] * k) + return + } + const beat = Math.sin(phase) * (0.3 + 0.7 * smoothstep(0.35, 0.65, noise1(phase * 0.04))) + const tip = -beat * 0.35 * s + this.polygon([[x, y], [x - 0.5 * s, y + tip], [x - 0.15 * s, y + 0.1 * s]], c) + this.polygon([[x, y], [x + 0.15 * s, y + 0.1 * s], [x + 0.5 * s, y + tip]], c) + } + + // A few gulls wheeling over an island once it's near enough to see them. + private drawGulls(island: Island) { + if (this.activity === "calm") return + const { A, H } = this + const g = ISLAND * island.d + if (g * H < 100) return + const bx = A / 2 + (island.x - this.lateral) * island.d + const by = HORIZON + island.d + for (let k = 0; k < 3; k++) { + const a = this.creatureTime * 4 + k * 2.1 + island.x + const [x, y] = this.turn((bx + 0.16 * g * Math.cos(a)) * H, (by - (0.16 + 0.03 * Math.sin(a) + k * 0.02) * g) * H) + this.drawBird(x, y, 0.025 * g * H, this.creatureTime * 40 + k * 2) + } + } + + // A ship sailing on ahead, seen from behind: its wake, hull, deck and superstructure blocks, and its lights at night. + private drawShip(ship: Ship) { + const def = SHIPS[ship.kind] + const look = this.look + if (Math.abs((ship.x - this.lateral) / ship.z) > this.A / 2 + 0.5) return + const P = (u: number, v: number, h: number): V3 => [ship.x + u * def.beam - this.lateral, ship.z + v * def.length, h] + const face = (pts: (readonly [number, number, number])[], color: RGB, alpha = 1) => this.face3(pts.map((p) => P(p[0], p[1], p[2])), color, alpha) + const c = (k: number) => this.craft([def.color[0] * k, def.color[1] * k, def.color[2] * k]) + const foam = look.night ? 0.04 : 0.16 + const back = ship.kind === "boat" ? -3 : -1.2 + // Two arms of a V spreading back from the stern, and churned water between them. + for (const m of [-1, 1]) face([[m * 0.5, 0, 0], [m, 0, 0], [m * 5.5, back, 0], [m * 4.5, back, 0]], look.foam, foam) + face([[-0.6, 0, 0], [0.6, 0, 0], [1, back * 0.6, 0], [-1, back * 0.6, 0]], look.foam, foam * 0.7) + const d = def.deck + const side = ship.x - this.lateral > 0 ? -1 : 1 + face([[-1, 0, d], [1, 0, d], [0.8, 0, 0], [-0.8, 0, 0]], c(0.7)) + face([[side, 0, d], [side, 0.72, d], [0, 1, d], [0, 1, 0], [side * 0.8, 0.72, 0], [side * 0.8, 0, 0]], c(0.5)) + face([[-1, 0, d], [1, 0, d], [1, 0.72, d], [0, 1, d], [-1, 0.72, d]], c(0.9)) + if (ship.z > 70) return + for (const [u0, u1, v0, v1, h] of def.blocks) { + const top = d + h + const us = side < 0 ? u0 : u1 + face([[u0, v0, d], [u1, v0, d], [u1, v0, top], [u0, v0, top]], c(0.75)) + face([[us, v0, d], [us, v1, d], [us, v1, top], [us, v0, top]], c(0.55)) + face([[u0, v0, top], [u1, v0, top], [u1, v1, top], [u0, v1, top]], c(1.05)) + } + if (look.lamp < 0.5) return + const b = def.blocks[def.blocks.length - 1] + this.lamp3(P(0, (b[2] + b[3]) / 2, d + b[4] + 0.01), AMBER, 0.5, 1) + this.lamp3(P(0, 0, d), AMBER, 0.3, 1) + } + + // An aircraft's outline seen from above or below, nose along heading, flattened by the view. + private planform(x: number, y: number, s: number, heading: number, squash: number, form: readonly (readonly Point[])[], color: RGB, alpha = 1) { + const fx = Math.cos(heading) + const fy = Math.sin(heading) * squash + const sx = -Math.sin(heading) + const sy = Math.cos(heading) * squash + for (const poly of form) this.polygon(poly.map(([a, b]) => [x + (a * fx + b * sx) * s, y + (a * fy + b * sy) * s] as const), color, alpha) + } + + // The easter egg: a B-2 overtakes from behind the camera, high overhead, and glides on out toward the horizon. + private drawEgg() { + const { A, H } = this + const t = this.egg.t / EGG + const z = lerp(0.3, 6, t ** 1.4) + const x = (A / 2 + 0.05 / z) * H + const s = (0.5 / z) * H + const fade = 1 - smoothstep(0.85, 1, t) + const y = (HORIZON - 0.1 - 0.45 / z) * H + if (y + s * 0.3 < 0) return + if (this.look.night) this.planform(x, y - 0.004 * H, s * 1.04, Math.PI / 2, 0.3, SPIRITFORM, [this.look.key[0] * 0.25, this.look.key[1] * 0.25, this.look.key[2] * 0.25], fade) + this.planform(x, y, s, Math.PI / 2, 0.3, SPIRITFORM, this.craft(SPIRIT), fade) + } + + // Flares popped by a click, arcing out and down and burning out. + private drawFlares() { + const H = this.H + for (const f of this.flares) { + if (f.age < 0) continue + const k = (1 - f.age / 2.5) ** 1.5 + this.disc(f.x * H, f.y * H, 0.012 * H, AMBER[0] * 0.5, AMBER[1] * 0.5, AMBER[2] * 0.5, 0.5 * k) + this.splat(f.x * H, f.y * H, BURNER[0] * k, BURNER[1] * k, BURNER[2] * k) + } + } + + // Rain in the storm after an error, streaking past the camera out from the middle of the horizon. + private drawRain() { + const { W, H, A, look } = this + const k = smoothstep(0.2, 1, this.gloom) + const color: RGB = look.night ? [0.08, 0.2, 0.5] : [0.35, 0.42, 0.45] + const d = this.drops + const cx = (A / 2) * H + const cy = HORIZON * H + for (let i = 0; i < d.length; i += 3) { + const r = d[i + 1] * H + const ax = Math.cos(d[i]) * 1.3 + const ay = Math.sin(d[i]) * 0.8 + trail(this.hdr, W, H, cx + ax * r, cy + ay * r, cx + ax * r * 0.82, cy + ay * r * 0.82, 0.6, color, k * (0.25 + d[i + 2] * 0.4) * smoothstep(0.05, 0.25, d[i + 1])) + } + this.touch(0, 0, W - 1, H - 1) + } +} + +function norm(v: V3): V3 { + const l = Math.hypot(v[0], v[1], v[2]) || 1 + return [v[0] / l, v[1] / l, v[2] / l] +} + +function cross(a: V3, b: V3): V3 { + return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]] +} + +// The jet's faces, wound so their normals point outward. +const FACES: Face[] = (() => { + const faces: Face[] = [] + const body = JET + const dark: RGB = [JET[0] * 0.6, JET[1] * 0.6, JET[2] * 0.65] + // Fuselage rings from the exhaust to the cockpit, closing to the nose. + const ring = (z: number, w: number, top: number, bottom: number): V3[] => [ + [-w, 0, z], + [-w * 0.7, top, z], + [w * 0.7, top, z], + [w, 0, z], + [w * 0.7, -bottom, z], + [-w * 0.7, -bottom, z], + ] + const rings = [ring(-0.5, 0.1, 0.05, 0.04), ring(-0.05, 0.09, 0.065, 0.045), ring(0.3, 0.05, 0.05, 0.04)] + const nose: V3 = [0, 0, 0.62] + for (let r = 0; r + 1 < rings.length; r++) + for (let i = 0; i < 6; i++) { + const j = (i + 1) % 6 + faces.push({ pts: [rings[r][i], rings[r + 1][i], rings[r + 1][j], rings[r][j]], color: body, both: false }) + } + for (let i = 0; i < 6; i++) faces.push({ pts: [rings[2][i], nose, rings[2][(i + 1) % 6]], color: body, both: false }) + faces.push({ pts: [...rings[0]].reverse(), color: dark, both: false }) + // Swept wings, tailplanes and canted fins, mirrored. + for (const m of [1, -1]) { + const M = (pts: V3[]): V3[] => (m > 0 ? pts : pts.map((p) => [-p[0], p[1], p[2]] as V3).reverse()) + faces.push({ pts: M([[0.08, 0, 0.14], [0.09, 0, -0.32], [0.5, -0.012, -0.3], [0.5, -0.012, -0.22]]), color: body, both: true }) + faces.push({ pts: M([[0.07, 0, -0.34], [0.07, 0, -0.55], [0.24, -0.01, -0.53], [0.24, -0.01, -0.46]]), color: body, both: true }) + faces.push({ pts: M([[0.06, 0.05, -0.2], [0.11, 0.22, -0.4], [0.11, 0.22, -0.52], [0.06, 0.05, -0.46]]), color: [JET[0] * 0.8, JET[1] * 0.8, JET[2] * 0.85], both: true }) + } + // The canopy, a dark glass wedge on the spine. + const glass: RGB = [0.08, 0.12, 0.2] + faces.push({ pts: [[-0.028, 0.06, 0.1], [0.028, 0.06, 0.1], [0, 0.095, 0.17]], color: glass, both: false }) + faces.push({ pts: [[0.028, 0.06, 0.1], [0.022, 0.055, 0.36], [0, 0.095, 0.17]], color: glass, both: false }) + faces.push({ pts: [[-0.028, 0.06, 0.1], [0, 0.095, 0.17], [-0.022, 0.055, 0.36]], color: glass, both: false }) + return faces +})() + +// Top-down outlines, nose toward +a, span along b, for planform(). +const JETFORM: Point[][] = [ + [[0.5, 0], [0.3, 0.05], [-0.5, 0.06], [-0.5, -0.06], [0.3, -0.05]], + [[0.12, 0.05], [-0.14, 0.42], [-0.24, 0.42], [-0.2, 0.05]], + [[0.12, -0.05], [-0.14, -0.42], [-0.24, -0.42], [-0.2, -0.05]], + [[-0.34, 0.05], [-0.48, 0.22], [-0.53, 0.22], [-0.5, 0.05]], + [[-0.34, -0.05], [-0.48, -0.22], [-0.53, -0.22], [-0.5, -0.05]], +] +const WARTHOGFORM: Point[][] = [ + [[0.5, 0], [0.42, 0.05], [-0.4, 0.04], [-0.4, -0.04], [0.42, -0.05]], + [[0.08, 0.04], [0.06, 0.52], [-0.06, 0.5], [-0.08, 0.04]], + [[0.08, -0.04], [0.06, -0.52], [-0.06, -0.5], [-0.08, -0.04]], + [[-0.14, 0.04], [-0.14, 0.15], [-0.32, 0.15], [-0.32, 0.04]], + [[-0.14, -0.04], [-0.14, -0.15], [-0.32, -0.15], [-0.32, -0.04]], + [[-0.38, 0.2], [-0.38, -0.2], [-0.47, -0.2], [-0.47, 0.2]], +] +const mirror = (forms: Point[][]): Point[][] => forms.flatMap((f) => [f, f.map(([a, b]) => [a, -b] as const)]) +// A tanker with swept wings and four engine pods, and an E-2 with straight wings and a wide tailplane. +const TANKERFORM: Point[][] = [ + [[0.5, 0], [0.42, 0.04], [-0.45, 0.035], [-0.5, 0], [-0.45, -0.035], [0.42, -0.04]], + ...mirror([ + [[0.12, 0.04], [-0.12, 0.48], [-0.2, 0.48], [-0.08, 0.04]], + [[-0.36, 0.03], [-0.46, 0.17], [-0.5, 0.17], [-0.47, 0.03]], + [[0.06, 0.16], [0.06, 0.2], [-0.06, 0.2], [-0.06, 0.16]], + [[-0.03, 0.3], [-0.03, 0.34], [-0.15, 0.34], [-0.15, 0.3]], + ]), +] +const E2FORM: Point[][] = [ + [[0.5, 0], [0.4, 0.05], [-0.42, 0.035], [-0.42, -0.035], [0.4, -0.05]], + ...mirror([ + [[0.12, 0.04], [0.1, 0.5], [0.02, 0.5], [0, 0.04]], + [[-0.34, 0.03], [-0.34, 0.2], [-0.42, 0.2], [-0.42, 0.03]], + ]), +] +const SPIRITFORM: Point[][] = [ + [[0.3, 0], [-0.08, 0.55], [-0.2, 0.36], [-0.1, 0.22], [-0.22, 0.1], [-0.12, 0], [-0.22, -0.1], [-0.1, -0.22], [-0.2, -0.36], [-0.08, -0.55]], +] + +// A cloud at nearness e: lumps heaped along a flat base, its height over the camera and its side in world units. +function makeCloud(e: number): Cloud { + const n = 3 + Math.floor(Math.random() * 3) + const lumps = Array.from({ length: n }, (_, k) => { + const f = k / (n - 1) - 0.5 + return { x: f * 1.6 + rand(-0.1, 0.1), y: 0, r: (0.55 - Math.abs(f) * 0.5) * rand(0.8, 1.2), seed: Math.random() * 100 } + }) + for (const l of lumps) l.y = -l.r * 0.6 + const side = Math.random() < 0.5 ? -1 : 1 + return { e, x: side * rand(0.5, 5), y: rand(0.6, 1.1), size: rand(0.18, 0.32), top: CLOUD_E, alpha: 1, lumps, mips: [] } +} + +// A faint, flat wisp of cloud near the camera's height, off to one side, that sweeps past the jet. +function makeWisp(): Cloud { + const c = makeCloud(0) + for (const l of c.lumps) l.r *= 0.7 + const x = (Math.random() < 0.5 ? -1 : 1) * rand(1.2, 2) + return { ...c, e: 0.5 / Math.abs(x), x, y: rand(-0.1, 0.12), size: 0.3, top: 1.6, alpha: 0.4 } +} + +// A ship far ahead off to one side: mostly small boats, then destroyers, now and then a supply ship. +function makeShip(z: number): Ship { + const r = Math.random() + const kind = r < 0.45 ? "boat" : r < 0.8 ? "destroyer" : "supply" + const side = Math.random() < 0.5 ? -1 : 1 + return { kind, z, x: side * (kind === "boat" ? rand(0.5, 2.5) : rand(1.3, 4)) } +} + +// Renders a cloud's lumps as faceted polygons into a premultiplied RGBA sprite at its size when CLOUD_E near, and +// halves it a few times for when it's farther: each lump a fan of triangles around a hub, every facet lit by how +// squarely it faces the light. +function cloudMips(c: Cloud, H: number, colors: [RGB, RGB], toLight: number): Sprite[] { + const S = 4 + const size = c.size * CLOUD_E * H + const w = Math.ceil(size * 2.4) + 4 + const h = Math.ceil(size * 1.3) + 4 + const ox = w / 2 + const oy = h - 2 + const fine = new Int32Array(w * S * h * S).fill(-1) + const tones: number[] = [] + const lx = clamp(toLight * 2, -0.8, 0.8) + const ly = -0.75 + const [lit, shade] = colors + for (const l of c.lumps) { + const cx = ox + l.x * size + const cy = oy + l.y * size + const r = l.r * size + const n = 7 + const pts = Array.from({ length: n }, (_, k) => { + const a = (k / n) * TAU + hash(l.seed + k) * 0.5 + const rr = r * (0.82 + hash(l.seed + k * 2.7) * 0.3) + return [cx + Math.cos(a) * rr, Math.min(oy, cy + Math.sin(a) * rr * 0.85)] as const + }) + const hub = [cx - lx * r * 0.25, cy - r * 0.2] as const + for (let k = 0; k < n; k++) { + const a = pts[k] + const b = pts[(k + 1) % n] + const mx = (a[0] + b[0]) / 2 - hub[0] + const my = (a[1] + b[1]) / 2 - hub[1] + const ml = Math.hypot(mx, my) || 1 + tones.push(clamp(0.5 + 0.6 * ((mx / ml) * lx + (my / ml) * ly) + (hash(l.seed + k * 5.1) - 0.5) * 0.2, 0, 1)) + rasterTri(fine, w * S, h * S, hub[0] * S, hub[1] * S, a[0] * S, a[1] * S, b[0] * S, b[1] * S, tones.length - 1) + } + } + const rgba = new Float32Array(w * h * 4) + for (let y = 0; y < h; y++) + for (let x = 0; x < w; x++) { + let a = 0 + const o = (y * w + x) * 4 + for (let j = 0; j < S; j++) + for (let i = 0; i < S; i++) { + const id = fine[(y * S + j) * w * S + x * S + i] + if (id < 0) continue + const t = tones[id] + for (let k = 0; k < 3; k++) rgba[o + k] += lerp(shade[k], lit[k], t) / (S * S) + a++ + } + rgba[o + 3] = a / (S * S) + } + const mips: Sprite[] = [{ w, h, ox, oy, rgba }] + while (mips.length < 6) { + const p = mips[mips.length - 1] + const mw = Math.ceil(p.w / 2) + const mh = Math.ceil(p.h / 2) + if (mw < 2 || mh < 2) break + const m = new Float32Array(mw * mh * 4) + for (let y = 0; y < mh; y++) + for (let x = 0; x < mw; x++) + for (let k = 0; k < 4; k++) { + let sum = 0 + for (let j = 0; j < 2; j++) for (let i = 0; i < 2; i++) sum += 2 * x + i < p.w && 2 * y + j < p.h ? p.rgba[((2 * y + j) * p.w + 2 * x + i) * 4 + k] : 0 + m[(y * mw + x) * 4 + k] = sum / 4 + } + mips.push({ w: mw, h: mh, ox: p.ox / 2, oy: p.oy / 2, rgba: m }) + } + return mips +} + +// Composites a cloud scaled by scale (at most 1) with its anchor at (cx, cy), from the mip level nearest above that +// size, sampled bilinearly so it glides as it grows; returns the box it drew in. +function blitScaled(hdr: Float32Array, W: number, H: number, mips: Sprite[], cx: number, cy: number, scale: number, alpha: number) { + if (alpha <= 0 || scale <= 0) return undefined + const level = clamp(Math.floor(Math.log2(1 / Math.min(1, scale))), 0, mips.length - 1) + const s = mips[level] + const k = Math.min(1, scale) * 2 ** level + const x0 = Math.max(0, Math.floor(cx - s.ox * k) - 1) + const x1 = Math.min(W - 1, Math.ceil(cx + (s.w - s.ox) * k) + 1) + const y0 = Math.max(0, Math.floor(cy - s.oy * k) - 1) + const y1 = Math.min(H - 1, Math.ceil(cy + (s.h - s.oy) * k) + 1) + if (x0 > x1 || y0 > y1) return undefined + const d = s.rgba + for (let y = y0; y <= y1; y++) { + const sy = (y + 0.5 - cy) / k + s.oy - 0.5 + const iy = Math.floor(sy) + const fy = sy - iy + if (iy < -1 || iy >= s.h) continue + for (let x = x0; x <= x1; x++) { + const sx = (x + 0.5 - cx) / k + s.ox - 0.5 + const ix = Math.floor(sx) + if (ix < -1 || ix >= s.w) continue + const fx = sx - ix + let r = 0 + let g = 0 + let b = 0 + let a = 0 + for (let q = 0; q < 4; q++) { + const u = ix + (q & 1) + const v = iy + (q >> 1) + if (u < 0 || v < 0 || u >= s.w || v >= s.h) continue + const wgt = (q & 1 ? fx : 1 - fx) * (q >> 1 ? fy : 1 - fy) * alpha + const o = (v * s.w + u) * 4 + r += d[o] * wgt + g += d[o + 1] * wgt + b += d[o + 2] * wgt + a += d[o + 3] * wgt + } + if (a <= 0) continue + const o = (y * W + x) * 3 + hdr[o] = hdr[o] * (1 - a) + r + hdr[o + 1] = hdr[o + 1] * (1 - a) + g + hdr[o + 2] = hdr[o + 2] * (1 - a) + b + } + } + return [x0, y0, x1, y1] as const +} + +// Clips a polygon in the camera's world (x, distance, height) to distances of at least near. +function clipNear(pts: V3[], near: number): V3[] { + const out: V3[] = [] + for (let i = 0; i < pts.length; i++) { + const a = pts[i] + const b = pts[(i + 1) % pts.length] + if (a[1] >= near) out.push(a) + if (a[1] >= near !== b[1] >= near) { + const t = (near - a[1]) / (b[1] - a[1]) + out.push([a[0] + (b[0] - a[0]) * t, near, a[2] + (b[2] - a[2]) * t]) + } + } + return out +} + +// The height of the sea's row d below the horizon: rows grow toward the viewer, though never thinner than a sliver. +function rowHeight(d: number) { + return Math.max(0.0035, d * 0.24 + 0.003) +} + +// For each pixel of a W x H grid sampled two by two, from row y0 down: its commonest triangle, the next, how much of +// the next, and how many of its samples any triangle covers. +function resolveSamples(ids: Int32Array, W: number, H: number, y0: number, idA: Int32Array, idB: Int32Array, mix: Float32Array, cover: Float32Array) { + const SW = W * 2 + for (let y = Math.max(0, y0); y < H; y++) + for (let x = 0; x < W; x++) { + const o = y * 2 * SW + x * 2 + const r0 = ids[o] + const r1 = ids[o + 1] + const r2 = ids[o + SW] + const r3 = ids[o + SW + 1] + const i = y * W + x + const n = (r0 >= 0 ? 1 : 0) + (r1 >= 0 ? 1 : 0) + (r2 >= 0 ? 1 : 0) + (r3 >= 0 ? 1 : 0) + cover[i] = n / 4 + if (!n) continue + const s0 = r0 >= 0 ? r0 : r1 >= 0 ? r1 : r2 >= 0 ? r2 : r3 + const s1 = r1 >= 0 ? r1 : s0 + const s2 = r2 >= 0 ? r2 : s0 + const s3 = r3 >= 0 ? r3 : s0 + const second = s1 !== s0 ? s1 : s2 !== s0 ? s2 : s3 + const n0 = 1 + (s1 === s0 ? 1 : 0) + (s2 === s0 ? 1 : 0) + (s3 === s0 ? 1 : 0) + const n1 = second === s0 ? 0 : (s1 === second ? 1 : 0) + (s2 === second ? 1 : 0) + (s3 === second ? 1 : 0) + idA[i] = n0 >= n1 ? s0 : second + idB[i] = n0 >= n1 ? second : s0 + mix[i] = Math.min(n0, n1) / 4 + } +} + +// Zips two rows of vertex indices, each sorted by x, into a strip of triangles. +function stitch(a: number[], b: number[], xs: number[], tris: number[]) { + let i = 0 + let j = 0 + while (i < a.length - 1 || j < b.length - 1) { + if (j >= b.length - 1 || (i < a.length - 1 && xs[a[i + 1]] < xs[b[j + 1]])) { + tris.push(a[i], a[i + 1], b[j]) + i++ + continue + } + tris.push(a[i], b[j + 1], b[j]) + j++ + } +} + +// Writes id into every sample of a W x H grid whose center lies inside the triangle. +function rasterTri(ids: Int32Array, W: number, H: number, ax: number, ay: number, bx: number, by: number, cx: number, cy: number, id: number) { + const x0 = Math.max(0, Math.floor(Math.min(ax, bx, cx))) + const x1 = Math.min(W - 1, Math.ceil(Math.max(ax, bx, cx))) + const y0 = Math.max(0, Math.floor(Math.min(ay, by, cy))) + const y1 = Math.min(H - 1, Math.ceil(Math.max(ay, by, cy))) + const area = (bx - ax) * (cy - ay) - (by - ay) * (cx - ax) + if (area === 0) return + const sign = area > 0 ? 1 : -1 + for (let y = y0; y <= y1; y++) { + const py = y + 0.5 + for (let x = x0; x <= x1; x++) { + const px = x + 0.5 + const w0 = ((bx - ax) * (py - ay) - (by - ay) * (px - ax)) * sign + const w1 = ((cx - bx) * (py - by) - (cy - by) * (px - bx)) * sign + const w2 = ((ax - cx) * (py - cy) - (ay - cy) * (px - cx)) * sign + if (w0 >= 0 && w1 >= 0 && w2 >= 0) ids[y * W + x] = id + } + } +} + +// Shades every facet from its tilted normal: the sea's own color lit from above, the sky it mirrors (more at grazing +// angles), the sun or moon glittering in facets tilted just right, and the storm's dark green-gray after an error. +function shadeFacets(X: Float32Array, Z: Float32Array, Y: Float32Array, tris: Int32Array, colors: Float32Array, sky: Float32Array, H: number, ox: number, oy: number, deep: RGB, lit: RGB, mirror: number, spec: RGB, glitter: number, spread: readonly [number, number], storm: RGB, gloom: number) { + const top = Math.ceil(HORIZON * H) - 1 + for (let t = 0; t < tris.length; t += 3) { + const a = tris[t] + const b = tris[t + 1] + const c = tris[t + 2] + const e1x = X[b] - X[a] + const e1y = Y[b] - Y[a] + const e1z = Z[b] - Z[a] + const e2x = X[c] - X[a] + const e2y = Y[c] - Y[a] + const e2z = Z[c] - Z[a] + let nx = e1y * e2z - e1z * e2y + let ny = e1z * e2x - e1x * e2z + let nz = e1x * e2y - e1y * e2x + if (ny < 0) { + nx = -nx + ny = -ny + nz = -nz + } + // Slivers where rows of different spacing meet can come out nearly vertical; no facet tilts more than a wave would. + const tilt = Math.hypot(nx, nz) / Math.max(1e-9, ny) + if (tilt > 0.7) { + nx *= 0.7 / tilt + nz *= 0.7 / tilt + } + const nl = Math.hypot(nx, ny, nz) || 1 + nx /= nl + ny /= nl + nz /= nl + const px = (X[a] + X[b] + X[c]) / 3 + const pz = (Z[a] + Z[b] + Z[c]) / 3 + let vx = -px + let vy = CAMERA + let vz = -pz + const vl = Math.hypot(vx, vy, vz) + vx /= vl + vy /= vl + vz /= vl + const ndv = Math.max(0, nx * vx + ny * vy + nz * vz) + const rx = 2 * ndv * nx - vx + const ry = 2 * ndv * ny - vy + const rz = 2 * ndv * nz - vz + const row = ry <= 0 || rz <= 0 ? top : clamp(Math.round((HORIZON - (FOCAL * ry) / rz) * H), 0, top) + const fres = 0.12 + 0.88 * (1 - ndv) ** 3 + // Glitter where the reflected ray meets the sky near the sun or moon, lined up into a path down the water. + const gx = rz > 0.01 ? (FOCAL * rx) / rz - ox : 9 + const gy = rz > 0.01 ? HORIZON - (FOCAL * ry) / rz - oy : 9 + // Big facets near the viewer flash less, so they don't flicker whole cells. + const s = Math.exp(-((gx / spread[0]) ** 2) - (gy / spread[1]) ** 2) * glitter * (0.3 + 0.7 * smoothstep(2, 6, pz)) + const diffuse = clamp(0.5 + nx * 1.6 - nz * 0.8, 0, 1) + for (let k = 0; k < 3; k++) { + const body = deep[k] + (lit[k] - deep[k]) * diffuse + const v = (body * (1 - fres) + sky[row * 3 + k] * fres * mirror) * (1 - Math.min(1, s) * 0.75) + spec[k] * s + colors[t + k] = v + (storm[k] - v) * gloom + } + } +} + +// Paints the sea's rows from the facet colors, from index start of the sea buffer on: each pixel mixes its two +// triangles over whatever is behind it by how much of it they cover. +function fillSea(hdr: Float32Array, W: number, y0: number, start: number, idA: Int32Array, idB: Int32Array, mix: Float32Array, cover: Float32Array, colors: Float32Array) { + const base = y0 * W * 3 + for (let i = Math.max(0, start); i < idA.length; i++) { + const w = cover[i] + if (w <= 0) continue + const a = idA[i] * 3 + const b = idB[i] * 3 + const k = mix[i] + const o = base + i * 3 + for (let c = 0; c < 3; c++) { + const v = colors[a + c] + (colors[b + c] - colors[a + c]) * k + hdr[o + c] += (v - hdr[o + c]) * w + } + } +} + +// A soft line from head to tail, fading toward the tail, mixed over the scene; for exhaust and rain. +function trail(hdr: Float32Array, W: number, H: number, hx: number, hy: number, tx: number, ty: number, width: number, color: RGB, k: number) { + const dx = tx - hx + const dy = ty - hy + const len2 = dx * dx + dy * dy || 1 + const x0 = Math.max(0, Math.floor(Math.min(hx, tx) - width - 1)) + const x1 = Math.min(W - 1, Math.ceil(Math.max(hx, tx) + width + 1)) + const y0 = Math.max(0, Math.floor(Math.min(hy, ty) - width - 1)) + const y1 = Math.min(H - 1, Math.ceil(Math.max(hy, ty) + width + 1)) + for (let y = y0; y <= y1; y++) + for (let x = x0; x <= x1; x++) { + const px = x + 0.5 - hx + const py = y + 0.5 - hy + const t = clamp((px * dx + py * dy) / len2, 0, 1) + const d = Math.hypot(px - dx * t, py - dy * t) + const a = Math.max(0, 1 - d / (width * (1 + t))) * (1 - t) * k + if (a <= 0) continue + const o = (y * W + x) * 3 + hdr[o] += (color[0] - hdr[o]) * a + hdr[o + 1] += (color[1] - hdr[o + 1]) * a + hdr[o + 2] += (color[2] - hdr[o + 2]) * a + } +} + +export const wingsFlight: Wallpaper = { + id: "wings-flight", + name: "Wings: Flight", + description: "A chase camera behind a fighter jet gliding over a low-poly sea, from Anomaly Arcade's Wings", + collection: "Arcade", + activity: { + calm: "The jet cruising low over faceted swells, past volcano islands and now and then over the carrier", + lively: "A little faster, with gentle banking turns and a wingman in formation", + teeming: "Two wingmen, and now and then another jet crossing far ahead", + }, + scrim: { + day: [ + [16, 40, 84], + [22, 52, 86], + [8, 36, 66], + ], + sunset: [ + [26, 28, 70], + [66, 36, 60], + [18, 22, 56], + ], + night: [ + [3, 6, 18], + [4, 9, 24], + [3, 6, 18], + ], + }, + create: (settings) => new WingsFlight(settings), +} diff --git a/wallpapers/wings.ts b/wallpapers/wings.ts new file mode 100644 index 0000000..f0b2e9f --- /dev/null +++ b/wallpapers/wings.ts @@ -0,0 +1,1946 @@ +import { Canvas, cap, type Lighting, type Part } from "../src/canvas" +import { eggWait } from "../src/egg" +import { lightPool } from "../src/light" +import { TAU, clamp, hash, hash2, lerp, rand, smoothstep, type RGB } from "../src/math" +import { makeStars, makeStorm, paintSky, paintStars, paintStorm, driftClouds, type Orb, type Star } from "../src/sky" +import type { Activity, Settings, Time, Wallpaper } from "../src/wallpaper" +import { WeatherLayer } from "../src/weather" + +// An homage to Wings, Anomaly Arcade's flight game: a carrier on a low-poly sea below a volcano island, with jets +// launching off its deck. + +// The scene runs slower than real time, which keeps it calm behind text. +const TIME_SCALE = 0.35 +const HORIZON = 0.47 +// The sea's camera: its height over the water and focal length, in screen heights. +const CAMERA = 1 +const FOCAL = 1 +// The carrier's deck in its own perspective: the stern's screen row and half-width, the bow's distance relative to the +// stern's, and the row everything on it vanishes toward. +const STERN = 0.87 +const BEAM = 0.115 +const BOW = 0.45 +const VANISH = 0.02 +// How long a jet launch, a landing, the A-10 and B-2 passes and the flyover last, in scene seconds. +const LAUNCH = 7 +const LANDING = 8 +const SPARKLE = 2.2 +// The two bow catapults, across the deck. +const CATAPULTS = [-0.1, 0.08] +// How many of the sea's rows slide toward the viewer per scene second. +const ROW_RATE = 0.16 +// The hull's waterline below the deck, in deck heights. +const WATERLINE = -0.42 + +type Point = readonly [number, number] + +// What changes with the time of day. The sea gets its own colors; aircraft and the carrier one daytime color each, +// darkened by tint. +interface Look { + sky: [number, RGB][] + orb: Orb + // The sun or moon's x, in screen heights from the middle. + orbX: number + stars: number + cloud: [lit: RGB, shade: RGB] + sea: [deep: RGB, lit: RGB] + glitter: number + // How far from the sun or moon, across and up and down in screen heights, a facet's reflection still glitters. + spread: readonly [number, number] + spec: RGB + tint: RGB + // Faces of the islands and the carrier turned toward the light, and away from it. + key: RGB + lamp: number + trail: RGB + // The wake's churned water: pale by day, tinted teal at night. + foam: RGB + storm: RGB + night: boolean +} + +const LOOKS: Record = { + day: { + sky: [ + [0, [0.07, 0.27, 0.72]], + [0.25, [0.2, 0.47, 0.88]], + [HORIZON, [0.62, 0.82, 0.96]], + ], + orb: { x: 0, y: 0.13, r: 0.035, core: [5, 4.6, 3.8], glow: [1, 0.95, 0.8], near: 0.45, wide: 0.12 }, + orbX: -0.55, + stars: 0, + cloud: [ + [1.05, 1.07, 1.1], + [0.6, 0.7, 0.86], + ], + sea: [ + [0.02, 0.2, 0.42], + [0.06, 0.52, 0.62], + ], + glitter: 0.7, + spread: [0.04, 0.16], + spec: [1.2, 1.15, 1], + tint: [1, 1, 1], + key: [1, 0.97, 0.88], + lamp: 0.25, + trail: [0.95, 0.97, 1], + foam: [0.85, 0.95, 1], + storm: [0.1, 0.16, 0.15], + night: false, + }, + sunset: { + sky: [ + [0, [0.12, 0.2, 0.55]], + [0.2, [0.3, 0.32, 0.72]], + [0.34, [0.75, 0.42, 0.62]], + [0.42, [1.1, 0.55, 0.5]], + [HORIZON, [1.25, 0.72, 0.45]], + ], + orb: { x: 0, y: 0.45, r: 0.04, core: [3, 1.7, 1], glow: [1.1, 0.5, 0.35], near: 0.5, wide: 0.25 }, + orbX: -0.32, + stars: 10, + cloud: [ + [1.05, 0.58, 0.55], + [0.36, 0.27, 0.52], + ], + sea: [ + [0.06, 0.1, 0.32], + [0.2, 0.26, 0.62], + ], + glitter: 0.9, + spread: [0.04, 0.3], + spec: [1.3, 0.62, 0.42], + tint: [0.32, 0.24, 0.34], + key: [1, 0.55, 0.45], + lamp: 0.6, + trail: [1.1, 0.62, 0.6], + foam: [1, 0.78, 0.82], + storm: [0.12, 0.1, 0.14], + night: false, + }, + night: { + sky: [ + [0, [0.004, 0.01, 0.04]], + [0.25, [0.015, 0.035, 0.11]], + [HORIZON, [0.05, 0.11, 0.3]], + ], + orb: { x: 0, y: 0.13, r: 0.034, core: [1.25, 0.95, 0.42], glow: [0.5, 0.38, 0.15], near: 0.3, wide: 0.1, moon: true }, + orbX: -0.3, + stars: 150, + cloud: [ + [0.12, 0.18, 0.4], + [0.03, 0.05, 0.14], + ], + sea: [ + [0.012, 0.04, 0.13], + [0.06, 0.15, 0.45], + ], + glitter: 0.6, + spread: [0.035, 0.3], + spec: [1.1, 0.68, 0.1], + tint: [0.06, 0.08, 0.17], + key: [0.35, 0.5, 1.1], + lamp: 1, + trail: [0.2, 0.42, 0.8], + foam: [0.1, 0.5, 0.75], + storm: [0.02, 0.055, 0.05], + night: true, + }, +} + +// Daytime colors. +const HULL: RGB = [0.36, 0.38, 0.42] +const DECK: RGB = [0.2, 0.21, 0.23] +const STRIPE: RGB = [0.85, 0.85, 0.8] +const YELLOW: RGB = [0.85, 0.66, 0.12] +const JET: RGB = [0.5, 0.53, 0.58] +const WARTHOG: RGB = [0.24, 0.28, 0.26] +const SPIRIT: RGB = [0.13, 0.14, 0.17] +const FOREST: RGB = [0.1, 0.3, 0.1] +const ROCK: RGB = [0.3, 0.27, 0.22] +const AMBER: RGB = [2, 1.05, 0.3] +const BURNER: RGB = [2.6, 1.15, 0.35] + +// A cloud: lumps heaped along a flat base, in units of its size. +interface Cloud { + x: number + y: number + size: number + speed: number + lumps: { x: number; y: number; r: number; seed: number }[] + sprite?: Sprite +} + +interface Sprite { + w: number + h: number + ox: number + oy: number + rgba: Float32Array +} + +// A premultiplied RGBA sprite cropped to a box at (x, y) on screen. +interface Hull { + x: number + y: number + w: number + h: number + rgba: Float32Array +} + +// A launch or landing: its age in scene seconds, which way it banks away, and the catapult it launches from (u). +interface Jet { + kind: "launch" | "landing" + t: number + side: number + cat: number +} + +// Air traffic crossing the screen: progress from 0 to 1 over span scene seconds, its row, which way it flies, its size +// in screen heights, and how many fly together. +interface Traffic { + kind: "formation" | "break" | "cargo" | "tanker" | "helo" | "flock" + t: number + span: number + y: number + dir: number + size: number + n: number + seed: number +} + +// A boat at rest on the water, which the sea carries toward the viewer as the carrier sails on: its distance below +// the horizon (d, screen heights) and its offset across the water. +interface Boat { + d: number + x: number + seed: number +} + +// A crossing: progress from 0 to 1 (negative while waiting), how many scene seconds it takes, and its row. +interface Flier { + t: number + wait: number + span: number + y: number + dir: number +} + +class Wings extends Canvas { + private time = 0 + private readonly activity: Activity + private readonly look: Look + private readonly weather: WeatherLayer + private orb: Orb + private stars: Star[] + private clouds: Cloud[] + private storm = makeStorm() + // The sea: a mesh of rows of jittered vertices between the horizon and the bottom, stitched into triangles; each + // vertex's ground position, wave amplitude and rate; and, per pixel of the sea's rows, the two triangles under its + // corners, how much of the second, and how much sea shows past the islands and the carrier. + private mesh = { X: new Float32Array(0), Z: new Float32Array(0), height: new Float32Array(0), tris: new Int32Array(0), colors: new Float32Array(0) } + private sea: { y0: number; rows: Float64Array; ids: Int32Array; idA: Int32Array; idB: Int32Array; mix: Float32Array; open: Float32Array; glow: Float32Array; sky: Float32Array } = { y0: 0, rows: new Float64Array(0), ids: new Int32Array(0), idA: new Int32Array(0), idB: new Int32Array(0), mix: new Float32Array(0), open: new Float32Array(0), glow: new Float32Array(0), sky: new Float32Array(0) } + private jets: Jet[] = [] + private nextLaunch: number + private nextLanding = 12 + private warthog: Flier = { t: -1, wait: 11, span: 13, y: 0.57, dir: 1 } + private spirit: Flier = { t: -1, wait: 3, span: 40, y: 0.11, dir: -1 } + private flyover = { t: -1, dir: 1, y: 0.16 } + private egg = { wait: eggWait() * TIME_SCALE, t: -1 } + private sparkles: { x: number; y: number; age: number; seed: number }[] = [] + private drops = new Float32Array(160 * 3) + // Deck and island lights: positions in pixels, colors, and which blink. + private lamps: { x: number; y: number; color: RGB; blink: number; size: number }[] = [] + // The carrier, painted once per size into a sprite that each frame bobs, rolls and pitches about its middle and + // drifts slowly side to side as it steers. hull is from the supersampled pass, waiting for the real one. + private hull: Hull | undefined + private hullFine: Hull | undefined + private ship = { x: 0, y: 0, dx: 0, dy: 0, roll: 0, pitch: 0 } + private traffic: Traffic[] = [] + private nextTraffic = 2 + private boats: Boat[] = [] + // A dolphin leaping beside the carrier (t from 0 to 1 over a leap) and a whale's spout far off, each waiting between. + private dolphin = { t: -1, wait: 4, x: 0, d: 0, dir: 1 } + private spout = { t: -1, wait: 10, x: 0, d: 0 } + // A jet taxiing from where they park to a catapult, which then launches it. + private taxi = { t: 0, wait: 0 } + + constructor(settings: Settings) { + super() + this.activity = settings.activity + this.weather = new WeatherLayer(settings.weather ?? "clear", settings.time, HORIZON) + const look = LOOKS[settings.time] + this.look = this.weather.covered ? { ...look, glitter: look.glitter * 0.12 } : look + this.orb = look.orb + if (settings.time === "day") this.frame = 0.3 + this.stars = makeStars(look.stars, HORIZON - 0.04) + this.clouds = makeLowPolyClouds(settings.time === "day" ? 8 : 7) + this.nextLaunch = settings.activity === "teeming" ? 1 : 5 + // The scene starts with its traffic already about, rather than an empty sky filling up. + const crossing = { calm: 0, lively: 5, teeming: 12 }[settings.activity] + for (let i = 0; i < crossing; i++) this.traffic.push(this.makeTraffic(rand(0.1, 0.8))) + const boats = { calm: 0, lively: 2, teeming: 4 }[settings.activity] + for (let i = 0; i < boats; i++) this.boats.push(makeBoat(rand(0.02, 0.25))) + for (let i = 0; i < this.drops.length; i += 3) { + this.drops[i] = Math.random() + this.drops[i + 1] = Math.random() + this.drops[i + 2] = Math.random() + } + } + + step(dt: number) { + dt = clamp(dt, 0, 0.1) * TIME_SCALE + this.time += dt + for (const c of this.clouds) { + c.x += c.speed * dt + if (c.x > this.A + c.size) c.x = -c.size + } + if (this.gloom > 0) driftClouds(this.storm, this.A, dt) + this.stepGloom(dt) + this.weather.step(dt) + this.stepShip() + this.stepJets(dt) + const teeming = this.activity === "teeming" + this.stepFlier(this.warthog, dt, this.activity !== "calm", teeming ? 18 : 45, teeming ? 30 : 70, () => this.openRow(0.53, 0.6)) + this.stepFlier(this.spirit, dt, teeming, 30, 50, () => rand(0.07, 0.14)) + this.stepTraffic(dt) + this.stepSea(dt) + this.stepTaxi(dt) + if (this.flyover.t >= 0) this.flyover.t = this.flyover.t + dt > 14 ? -1 : this.flyover.t + dt + const e = this.egg + if (e.t >= 0) e.t = e.t + dt > 12 ? -1 : e.t + dt + if (e.t < 0) { + e.wait -= dt + if (e.wait <= 0) { + e.t = 0 + e.wait = eggWait(true) * TIME_SCALE + } + } + for (const s of this.sparkles) s.age += dt + this.sparkles = this.sparkles.filter((s) => s.age < SPARKLE) + if (this.gloom > 0) + for (let i = 0; i < this.drops.length; i += 3) { + this.drops[i + 1] += (1.3 + this.drops[i + 2]) * dt + this.drops[i] += 0.1 * dt + if (this.drops[i + 1] > 1.05) { + this.drops[i + 1] -= 1.1 + this.drops[i] = Math.random() + } + } + } + + // A click in the sky sends a jet off the deck; on the water it sets off a burst of sparkles. + poke(x: number, y: number) { + if (y > HORIZON + 0.02) { + this.sparkles.push({ x, y, age: 0, seed: Math.random() * 100 }) + return + } + this.launch() + } + + protected override visit() { + if (this.flyover.t < 0) this.flyover = { t: 0, dir: Math.random() < 0.5 ? 1 : -1, y: rand(0.1, 0.17) } + } + + render() { + this.restore() + const { W, H, look } = this + if (!this.weather.covered && look.stars) paintStars(this.hdr, W, H, this.stars, this.time, HORIZON - 0.04, look.night ? 0.55 : 0.25, this.points) + this.drawClouds() + paintStorm(this.hdr, W, H, this.storm, look.cloud[0], look.cloud[1], this.gloom) + if (this.spirit.t >= 0) this.drawSpirit(this.spirit) + if (this.flyover.t >= 0) this.drawFlyover() + this.drawSea() + this.drawWake() + if (this.activity !== "calm") this.drawSeaTraffic() + this.drawSparkles() + if (this.warthog.t >= 0) this.drawWarthog(this.warthog) + this.drawHull() + this.drawLamps() + if (this.activity !== "calm") this.drawDeck() + if (this.activity !== "calm") this.drawHelicopters() + if (this.activity !== "calm") this.drawGulls() + for (const j of this.jets) this.drawJet(j) + if (this.activity !== "calm") this.drawCircling() + for (const c of this.traffic) this.drawTraffic(c) + if (this.egg.t >= 0) this.drawEgg() + if (this.gloom > 0) this.drawRain() + this.weather.draw(this.hdr, W, H) + this.finish() + } + + // The sky and islands never move, so they're painted once per size; the sea is drawn over them each frame wherever it + // shows, and the carrier, painted once into a sprite, over the sea. + protected override layout() { + const { W, H, A, look } = this + this.orb = { ...look.orb, x: (A / 2 + look.orbX) / A } + paintSky(this.hdr, W, H, look.sky, this.orb, this.px) + this.weather.cover(this.hdr, W, H) + // What the sea mirrors: the sky's average color on each row above the horizon. + const sky = new Float32Array(H * 3) + for (let y = 0; y < Math.ceil(HORIZON * H); y++) + for (let x = 0; x < W; x += 2) for (let c = 0; c < 3; c++) sky[y * 3 + c] += this.hdr[(y * W + x) * 3 + c] / Math.ceil(W / 2) + this.buildSea(sky) + // The sea at rest under everything that stands in it, so their edges blend into water. + this.shadeSea() + fillSea(this.hdr, W, this.sea.y0, this.sea.idA, this.sea.idB, this.sea.mix, undefined, this.mesh.colors, undefined) + this.drawIslands() + if (this.activity === "teeming") this.drawEscorts() + this.paintHull() + this.placeLamps() + // How much sea shows in each pixel: everything above drawn again, flat, into a blank buffer. + const hdr = this.hdr + this.hdr = new Float32Array(W * H * 3) + this.drawIslands(true) + if (this.activity === "teeming") this.drawEscorts(true) + const open = new Float32Array(this.sea.idA.length) + for (let i = 0; i < open.length; i++) open[i] = 1 - clamp(this.hdr[(this.sea.y0 * W + i) * 3], 0, 1) + this.hdr = hdr + this.sea.open = open + // At night the deck lights spill a warm pool onto the water around the hull. + const glow = new Float32Array(open.length * 3) + if (look.lamp > 0.5) { + const cx = (A / 2) * H + for (let i = 0; i < open.length; i++) { + const x = i % W + const y = this.sea.y0 + Math.floor(i / W) + const d = ((x - cx) / (0.3 * H)) ** 2 + ((y - (STERN - 0.1) * H) / (0.17 * H)) ** 2 + const k = Math.max(0, 1 - d) ** 2 * 0.05 * look.lamp + glow[i * 3] = AMBER[0] * k + glow[i * 3 + 1] = AMBER[1] * k + glow[i * 3 + 2] = AMBER[2] * k + } + } + this.sea.glow = glow + this.sea.sky = sky + for (const c of this.clouds) c.sprite = cloudSprite(c, H, this.weather.scud ?? look.cloud, this.orb.x * A - c.x) + } + + private paint(c: RGB): RGB { + const t = this.look.tint + return [c[0] * t[0], c[1] * t[1], c[2] * t[2]] + } + + // The sea's rows: from the horizon down, each row a little taller than the last, as a table of row heights below the + // horizon (d) and the pixel buffers the facets are drawn through. + private buildSea(sky: Float32Array) { + const { W, H } = this + const rows = [0] + while (rows[rows.length - 1] < 1.1 - HORIZON) rows.push(rows[rows.length - 1] + rowHeight(rows[rows.length - 1])) + const y0 = Math.floor(HORIZON * H) + const n = W * (H - y0) + this.sea = { ...this.sea, y0, rows: Float64Array.from(rows), ids: new Int32Array(n * 4), idA: new Int32Array(n), idB: new Int32Array(n), mix: new Float32Array(n), sky } + } + + // The mesh this frame. Rows of jittered vertices slide down the table of rows toward the viewer, a row at a time, as + // the carrier sails on: each vertex keeps its identity (column, and row counted from when it rose at the horizon), so + // its jitter and swell travel with it, and its columns spread apart as it nears, as on water seen in perspective. + // Neighboring rows are zipped into triangles and rasterized at two samples a pixel each way; each pixel keeps its two + // commonest triangles. + private meshSea() { + const { W, H, A } = this + const sea = this.sea + const m = this.mesh + const phase = this.time * ROW_RATE + const base = Math.floor(phase) + const f = phase - base + const table = sea.rows + const xs: number[] = [] + const ys: number[] = [] + const ds: number[] = [] + const ids: number[] = [] + const rows: number[][] = [] + for (let r = 0; r + 1 < table.length; r++) { + const d = table[r] + (table[r + 1] - table[r]) * f + const q = r - base + const h = rowHeight(d) + const w = Math.max(0.018, h * 2.3) + const row: number[] = [] + const i0 = Math.floor((-0.25 - A / 2) / w) + const i1 = Math.ceil((A / 2 + 0.25) / w) + for (let i = i0; i <= i1; i++) { + row.push(xs.length) + xs.push(A / 2 + (i + (hash2(i, q) - 0.5) * 0.7) * w) + ys.push(HORIZON + d + (hash2(i + 71, q) - 0.5) * 0.3 * h) + ds.push(d) + ids.push(i * 7919 + q * 104729) + } + rows.push(row) + } + const tris: number[] = [] + for (let k = 0; k + 1 < rows.length; k++) stitch(rows[k], rows[k + 1], xs, tris) + const n = xs.length + if (m.X.length < n) { + m.X = new Float32Array(n * 2) + m.Z = new Float32Array(n * 2) + m.height = new Float32Array(n * 2) + } + const t = this.time + for (let i = 0; i < n; i++) { + const z = (FOCAL * CAMERA) / Math.max(0.002, ys[i] - HORIZON) + m.Z[i] = z + m.X[i] = ((xs[i] - A / 2) * z) / FOCAL + // Calmer toward the horizon, where the facets get smaller than a cell. + const amp = Math.max(0.018, rowHeight(ds[i]) * 2.3) * 0.45 * smoothstep(0.004, 0.08, ds[i]) + const id = ids[i] + m.height[i] = amp * ((hash(id) - 0.5) * 1.6 + 0.3 * Math.sin(t * (0.5 + hash(id + 0.5) * 0.6) + hash(id + 0.3) * TAU)) + } + m.tris = Int32Array.from(tris) + if (m.colors.length < tris.length) m.colors = new Float32Array(tris.length * 2) + const y0 = sea.y0 + const SW = W * 2 + const SH = (H - y0) * 2 + sea.ids.fill(-1) + for (let k = 0; k < tris.length; k += 3) { + const a = tris[k] + const b = tris[k + 1] + const c = tris[k + 2] + rasterTri(sea.ids, SW, SH, xs[a] * H * 2, (ys[a] * H - y0) * 2, xs[b] * H * 2, (ys[b] * H - y0) * 2, xs[c] * H * 2, (ys[c] * H - y0) * 2, k / 3) + } + resolveSamples(sea.ids, W, H - y0, sea.idA, sea.idB, sea.mix) + } + + // Each facet's color this frame: the sea's own color lit from above, the sky it mirrors (more at grazing angles), the + // sun or moon glittering in facets tilted just right, and the storm's dark green-gray after an error. + private shadeSea() { + const { H, A, look } = this + const m = this.mesh + this.meshSea() + shadeFacets(m.X, m.Z, m.height, m.tris, m.colors, this.sea.sky, H, this.orb.x * A - A / 2, this.orb.y, look.sea[0], look.sea[1], look.spec, look.glitter * (1 - 0.8 * this.gloom), look.spread, look.storm, smoothstep(0, 1, this.gloom) * 0.7) + } + + private drawSea() { + this.shadeSea() + const s = this.sea + fillSea(this.hdr, this.W, s.y0, s.idA, s.idB, s.mix, s.open, this.mesh.colors, s.glow) + this.touch(0, s.y0, this.W - 1, this.H - 1) + } + + // A volcano on its forested island behind the carrier, forested islets to the left and sea stacks to the right. + private drawIslands(mask = false) { + const { A, H } = this + const color = (c: RGB, lit: number): RGB => (mask ? [1, 1, 1] : this.facet(c, lit)) + const P = (x: number, y: number): Point => [x * H, y * H] + const vx = volcanoX(A) + const base = HORIZON + 0.004 + // The island under the volcano, a long low hill. + const hill: Point[] = [P(vx - 0.27, base), P(vx - 0.2, base - 0.02), P(vx - 0.1, base - 0.035), P(vx + 0.12, base - 0.035), P(vx + 0.22, base - 0.018), P(vx + 0.29, base)] + this.polygon(hill, color(FOREST, 0.4)) + // The cone in faceted slices, lit on the side facing the light. + const peak = P(vx - 0.01, HORIZON - 0.17) + const feet = [-0.17, -0.1, -0.03, 0.05, 0.12, 0.18].map((dx) => P(vx + dx, base - 0.025)) + const lightSide = this.orb.x * A < vx ? -1 : 1 + // Faces drawn over a silhouette in their middle tone, so no sky shows through the seams between them. + this.polygon([peak, ...feet], color(ROCK, 0.5)) + for (let i = 0; i + 1 < feet.length; i++) { + const face = (i + 0.5) / (feet.length - 1) - 0.5 + this.polygon([peak, feet[i], feet[i + 1]], color(ROCK, 0.5 + face * lightSide * 0.9)) + } + // Forest climbing the lower slopes, ragged with treetops. + const slope = (dx: number) => base - 0.025 - (1 - Math.abs(dx + 0.01) / 0.175) * 0.17 + const forest: Point[] = [P(vx - 0.2, base)] + for (let i = 0; i <= 24; i++) { + const dx = -0.19 + (i / 24) * 0.39 + const top = Math.max(slope(dx) + 0.045, base - 0.075) - (i % 2 ? 0.006 : 0) - hash(i * 2.1) * 0.006 + forest.push(P(vx + dx, Math.min(top, base - 0.02))) + } + forest.push(P(vx + 0.21, base)) + this.polygon(forest, color(FOREST, 0.5 - lightSide * 0.1)) + for (let i = 0; i < 18; i++) { + const dx = -0.22 + (i / 17) * 0.47 + const top = Math.max(slope(dx) + 0.05, base - 0.06) + hash(i * 5.3) * 0.02 + if (top > base - 0.012) continue + const s = 0.012 + hash(i * 1.9) * 0.008 + this.polygon([P(vx + dx, top), P(vx + dx - s * 0.7, top + s * 1.4), P(vx + dx + s * 0.7, top + s * 1.4)], color(FOREST, hash(i) > 0.5 ? 0.75 : 0.3)) + } + // A crater notch and a wisp of steam. + this.polygon([P(vx - 0.022, HORIZON - 0.155), P(vx - 0.01, HORIZON - 0.17), P(vx + 0.004, HORIZON - 0.157), P(vx - 0.01, HORIZON - 0.15)], color(ROCK, 0.15)) + // Two forested islets on the left. + for (const [x, w, h] of [[0.14, 0.09, 0.05], [0.27, 0.08, 0.045]] as const) { + const pts = [P(x - w, base), P(x - w * 0.55, base - h * 0.55), P(x - w * 0.15, base - h), P(x + w * 0.2, base - h * 0.85), P(x + w * 0.6, base - h * 0.4), P(x + w, base)] + this.polygon(pts, color(FOREST, 0.5)) + for (let i = 0; i + 1 < pts.length; i++) this.polygon([P(x, base), pts[i], pts[i + 1]], color(FOREST, 0.55 - ((i - 2) / 3) * lightSide * 0.5)) + for (let i = 0; i < 7; i++) { + const dx = (i / 6 - 0.5) * w * 1.4 + const top = base - h * (1 - Math.abs(dx) / w) * 0.95 + this.polygon([P(x + dx, top - 0.01), P(x + dx - 0.009, top + 0.008), P(x + dx + 0.009, top + 0.008)], color(FOREST, hash(i + x) > 0.5 ? 0.7 : 0.35)) + } + } + // Sea stacks and a low reef on the right. + const rx = A - 0.18 + for (const [dx, w, h] of [[-0.03, 0.012, 0.035], [-0.012, 0.009, 0.05], [0.004, 0.011, 0.042], [0.03, 0.008, 0.028]] as const) { + this.polygon([P(rx + dx - w, base), P(rx + dx - w * 0.6, base - h), P(rx + dx + w * 0.3, base - h * 1.05), P(rx + dx + w, base)], color(ROCK, 0.45 + lightSide * dx * 8)) + } + this.polygon([P(rx - 0.07, base), P(rx - 0.05, base - 0.008), P(rx + 0.06, base - 0.008), P(rx + 0.09, base)], color(FOREST, 0.4)) + // Far specks of islands near the volcano. + for (const [x, w] of [[vx + 0.33, 0.025], [vx + 0.4, 0.015], [vx - 0.36, 0.02]] as const) this.polygon([P(x - w, base), P(x, base - 0.012), P(x + w, base)], color(ROCK, 0.3)) + } + + // A flat face lit by how squarely it faces the light (0 away, 1 toward), through the island's own key light. + private facet(c: RGB, lit: number): RGB { + const { look } = this + const k = clamp(lit, 0, 1) + if (!look.night && look.tint[0] === 1) return [c[0] * (0.45 + 0.75 * k), c[1] * (0.45 + 0.75 * k), c[2] * (0.45 + 0.75 * k)] + const t = look.tint + return [c[0] * t[0] * 2.4 + look.key[0] * 0.08 * k, c[1] * t[1] * 2.4 + look.key[1] * 0.08 * k, c[2] * t[2] * 2.4 + look.key[2] * 0.08 * k] + } + + // Escort ships on the horizon, to either side. + private drawEscorts(mask = false) { + const { A, H } = this + for (const [x, s] of [[A / 2 - 0.36, 0.05], [A / 2 - 0.2, 0.035], [A - 0.05, 0.04]] as const) { + const y = HORIZON + 0.006 + const hull = mask ? ([1, 1, 1] as RGB) : this.paint(HULL) + this.polygon([[(x - s) * H, (y - s * 0.12) * H], [(x + s) * H, (y - s * 0.12) * H], [(x + s * 0.85) * H, y * H], [(x - s * 0.8) * H, y * H]], hull) + this.polygon([[(x - s * 0.25) * H, (y - s * 0.12) * H], [(x - s * 0.2) * H, (y - s * 0.4) * H], [(x + s * 0.15) * H, (y - s * 0.4) * H], [(x + s * 0.25) * H, (y - s * 0.12) * H]], hull) + this.polygon([[(x - s * 0.02) * H, (y - s * 0.4) * H], [(x - s * 0.01) * H, (y - s * 0.7) * H], [(x + s * 0.01) * H, (y - s * 0.7) * H], [(x + s * 0.02) * H, (y - s * 0.4) * H]], hull) + } + } + + // Deck space to screen pixels: u across the deck (-1 to 1), v from stern (0) to bow (1), h up off the deck. + private deck(u: number, v: number, h = 0): Point { + const H = this.H + const z = 1 + BOW * v + return [(this.A / 2 + (u * BEAM) / z) * H, (VANISH + (STERN - VANISH) / z - (h * BEAM) / z) * H] + } + + // The carrier from behind: its stern under the deck, the flight deck with its runway markings, and the island on the + // starboard side with its mast. Its lights are drawn each frame. + private drawCarrier(mask = false) { + const D = (u: number, v: number, h = 0) => this.deck(u, v, h) + const c = (color: RGB, k = 1): RGB => (mask ? [1, 1, 1] : this.paint([color[0] * k, color[1] * k, color[2] * k])) + // The stern and the hull's starboard flank below the deck edge. + this.polygon([D(-0.92, 0, 0), D(0.92, 0, 0), D(0.82, 0, -0.42), D(-0.82, 0, -0.42)], c(HULL, 0.8)) + this.polygon([D(0.92, 0, 0), D(0.95, 0.9, 0), D(0.8, 0.9, -0.42), D(0.82, 0, -0.42)], c(HULL, 0.55)) + this.polygon([D(-0.92, 0, 0), D(-0.95, 0.9, 0), D(-0.8, 0.9, -0.42), D(-0.82, 0, -0.42)], c(HULL, 0.45)) + // The flight deck, tapering a little at the bow, with a darker deck-edge rim. + const outline = [D(-1, 0), D(1, 0), D(1, 0.88), D(0.8, 1.02), D(-0.8, 1.02), D(-1, 0.88)] + this.polygon(outline, c(HULL, 0.6)) + this.polygon([D(-0.96, 0.01), D(0.96, 0.01), D(0.96, 0.87), D(0.77, 1.0), D(-0.77, 1.0), D(-0.96, 0.87)], c(DECK)) + if (mask) { + this.drawIsland(true) + return + } + // Runway: side lines, a dashed centerline, a yellow lineup line, and the stern's landing stripes. + const line = (u0: number, v0: number, u1: number, v1: number, w: number, color: RGB, k = 1) => { + this.polygon([D(u0 - w, v0), D(u0 + w, v0), D(u1 + w, v1), D(u1 - w, v1)], c(color, k)) + } + line(-0.62, 0.05, -0.62, 0.97, 0.02, STRIPE, 0.8) + line(0.42, 0.05, 0.42, 0.97, 0.02, STRIPE, 0.8) + for (let v = 0.2; v < 0.95; v += 0.11) line(-0.1, v, -0.1, v + 0.06, 0.022, STRIPE, 0.9) + line(0.2, 0.18, 0.2, 0.96, 0.012, YELLOW) + line(-0.35, 0.3, -0.12, 0.96, 0.012, YELLOW, 0.9) + for (let i = 0; i < 8; i++) { + const u = -0.55 + i * 0.12 + this.polygon([D(u, 0.06), D(u + 0.07, 0.06), D(u + 0.07, 0.12), D(u, 0.12)], c(STRIPE, 0.85)) + } + // The catapult tracks at the bow, and an elevator outline on the port side. + line(-0.1, 0.62, -0.1, 0.98, 0.006, [0.1, 0.1, 0.1]) + line(0.08, 0.66, 0.08, 0.98, 0.006, [0.1, 0.1, 0.1]) + this.polygon([D(-0.98, 0.3), D(-0.8, 0.3), D(-0.8, 0.42), D(-0.98, 0.42)], c(DECK, 0.75)) + // The stern's hangar opening. + this.polygon([D(-0.25, 0, -0.08), D(0.25, 0, -0.08), D(0.25, 0, -0.2), D(-0.25, 0, -0.2)], this.look.night ? [0.25, 0.12, 0.03] : c([0.05, 0.05, 0.06])) + this.drawIsland(false) + // At night the deck lights wash the deck. + if (this.look.lamp > 0.5) { + const [cx, cy] = D(0, 0.45) + lightPool(this.hdr, this.W, this.H, cx, cy, 0.18 * this.H, 0.12 * this.H, [1, 0.6, 0.25], 1.2, 0.01) + } + } + + // Lights: amber edge lights down both sides, centerline lights, stern lights, the island's windows and mast beacons, + // in real pixels on the carrier at rest. + private placeLamps() { + const D = (u: number, v: number, h = 0) => this.deck(u, v, h) + this.lamps = [] + const lamp = (p: Point, color: RGB, blink = 0, size = 1) => this.lamps.push({ x: p[0] / this.px, y: p[1] / this.px, color, blink, size }) + for (let v = 0.04; v < 0.99; v += 0.075) { + lamp(D(-0.97, v), AMBER, 0, 1) + lamp(D(0.97, v), AMBER, 0, 1) + } + for (let v = 0.16; v < 0.98; v += 0.1) lamp(D(-0.1, v + 0.03), [1.6, 1.2, 0.5], 0, 1) + for (const u of [-0.7, -0.45, 0.45, 0.7]) lamp(D(u, 0, -0.05), AMBER, 0, 2) + lamp(D(-0.97, 1.0), [0.3, 1.6, 0.5], 0.7, 2) + lamp(D(0.97, 1.0), [1.8, 0.25, 0.15], 0.9, 2) + lamp(D(0.82, 0.57, 2.1), [2, 0.3, 0.15], 1, 2) + lamp(D(0.74, 0.44, 0.6), AMBER, 0, 1) + lamp(D(0.88, 0.44, 0.6), AMBER, 0, 1) + if (this.activity !== "calm") lamp(D(-0.4, 0.26), [0.3, 1.8, 0.6], 1.5, 2) + } + + // Paints the carrier, and the jets parked on it, into the hull sprite: colors over black, so they come out + // premultiplied, and the silhouette in white for coverage. The supersampled pass keeps its sprite, shrunk, for the + // real pass to use. + private paintHull() { + const { W, H } = this + if (this.px === 1 && this.hullFine) { + this.hull = this.hullFine + this.hullFine = undefined + return + } + const hdr = this.hdr + this.hdr = new Float32Array(W * H * 3) + this.drawCarrier() + if (this.activity !== "calm") this.drawParked() + let color = this.hdr + this.hdr = new Float32Array(W * H * 3) + this.drawCarrier(true) + let cover = this.hdr + this.hdr = hdr + if (this.px > 1) { + color = this.shrink(color) + cover = this.shrink(cover) + } + const w = W / this.px + const h = H / this.px + let [x0, y0, x1, y1] = [w, h, -1, -1] + for (let y = 0; y < h; y++) + for (let x = 0; x < w; x++) { + const o = (y * w + x) * 3 + if (cover[o] <= 0 && color[o] + color[o + 1] + color[o + 2] <= 0) continue + x0 = Math.min(x0, x) + y0 = Math.min(y0, y) + x1 = Math.max(x1, x) + y1 = Math.max(y1, y) + } + const sw = x1 - x0 + 1 + const sh = y1 - y0 + 1 + const rgba = new Float32Array(Math.max(0, sw * sh * 4)) + for (let y = 0; y < sh; y++) + for (let x = 0; x < sw; x++) { + const o = ((y + y0) * w + x + x0) * 3 + const d = (y * sw + x) * 4 + rgba[d] = color[o] + rgba[d + 1] = color[o + 1] + rgba[d + 2] = color[o + 2] + rgba[d + 3] = clamp(cover[o], 0, 1) + } + const hull = { x: x0, y: y0, w: Math.max(0, sw), h: Math.max(0, sh), rgba } + if (this.px > 1) this.hullFine = hull + else this.hull = hull + } + + // The carrier underway: a slow bob, roll and pitch on the swells, and a long, slow weave as it steers. + private stepShip() { + const t = this.time + const H = this.H + const [x, y] = this.deck(0, 0.45) + this.ship = { + x: x, + y: y, + dx: 0.008 * Math.sin(t * 0.04) * H, + dy: (0.0015 * Math.sin(t * 0.6) + 0.0008 * Math.sin(t * 1.03 + 2)) * H, + roll: 0.007 * Math.sin(t * 0.5 + 0.5), + pitch: 0.006 * Math.sin(t * 0.55 + 1.7), + } + } + + // Where a point on the carrier at rest is now, as it rolls, pitches, bobs and weaves. + private onShip(p: Point): Point { + const s = this.ship + const qx = p[0] - s.x + const qy = (p[1] - s.y) * (1 + s.pitch) + const c = Math.cos(s.roll) + const n = Math.sin(s.roll) + return [s.x + qx * c - qy * n + s.dx, s.y + qx * n + qy * c + s.dy] + } + + private drawHull() { + const h = this.hull + if (!h || !h.w) return + const s = this.ship + const box = blitHull(this.hdr, this.W, this.H, h, s.x, s.y, s.dx, s.dy, s.roll, 1 + s.pitch) + this.touch(box[0], box[1], box[2], box[3]) + } + + // The wake: water churned white (teal at night) along the hull, flaring off the bow, and trailing back from the + // stern toward the viewer, streaming past at the sea's pace. + private drawWake() { + const { W, H, A, look } = this + const s = this.ship + const [, bowY] = this.deck(0, 1.2, WATERLINE) + const x0 = Math.floor((A / 2) * H - 0.4 * H + s.dx) + const x1 = Math.ceil((A / 2) * H + 0.4 * H + s.dx) + const y0 = Math.floor(bowY + s.dy - 2) + const box = wake(this.hdr, W, H, A, x0, x1, y0, (A / 2) * H + s.dx, s.dy, this.time * ROW_RATE * 0.17, look.foam, look.night ? 0.6 : 0.55) + this.touch(box[0], box[1], box[2], box[3]) + } + + // The island superstructure: a block with its stern face and inboard side showing, a bridge window band, and a mast + // with a radar bar. + private drawIsland(mask: boolean) { + const D = (u: number, v: number, h = 0) => this.deck(u, v, h) + const c = (k: number): RGB => (mask ? [1, 1, 1] : this.paint([HULL[0] * k, HULL[1] * k, HULL[2] * k])) + const [u0, u1, v0, v1, top] = [0.66, 0.96, 0.44, 0.68, 1] + this.polygon([D(u0, v0, top), D(u1, v0, top), D(u1, v1, top), D(u0, v1, top)], c(1.15)) + this.polygon([D(u0, v0), D(u0, v1), D(u0, v1, top), D(u0, v0, top)], c(0.7)) + this.polygon([D(u0, v0), D(u1, v0), D(u1, v0, top), D(u0, v0, top)], c(0.9)) + // A second, narrower tier. + // A narrower bridge tier with its window band. + this.polygon([D(0.72, 0.5, top), D(0.92, 0.5, top), D(0.92, 0.5, top + 0.4), D(0.72, 0.5, top + 0.4)], c(0.85)) + this.polygon([D(0.72, 0.5, top), D(0.72, 0.64, top), D(0.72, 0.64, top + 0.4), D(0.72, 0.5, top + 0.4)], c(0.62)) + this.polygon([D(0.72, 0.5, top + 0.4), D(0.92, 0.5, top + 0.4), D(0.92, 0.64, top + 0.4), D(0.72, 0.64, top + 0.4)], c(1.1)) + if (!mask) this.polygon([D(0.73, 0.5, top + 0.3), D(0.91, 0.5, top + 0.3), D(0.91, 0.5, top + 0.2), D(0.73, 0.5, top + 0.2)], this.look.night ? [0.6, 0.3, 0.06] : c(0.3)) + // The mast and its radar crossbar. + const w = 0.018 + this.polygon([D(0.82 - w, 0.57, top + 0.4), D(0.82 + w, 0.57, top + 0.4), D(0.82 + w * 0.5, 0.57, 2.1), D(0.82 - w * 0.5, 0.57, 2.1)], c(0.75)) + this.polygon([D(0.74, 0.57, 1.82), D(0.9, 0.57, 1.82), D(0.9, 0.57, 1.76), D(0.74, 0.57, 1.76)], c(0.75)) + } + + // Jets parked by the island. + private drawParked() { + for (const [u, v] of [[0.55, 0.36], [0.6, 0.72], [0.55, 0.82]] as const) { + const [x, y] = this.deck(u, v, 0.08) + const s = (0.13 * BEAM * this.H) / (1 + BOW * v) + this.planform(x, y, s, -Math.PI / 2, 0.8, JETFORM, this.paint(JET)) + } + } + + private drawLamps() { + const look = this.look + const flicker = this.gloom > 0.2 ? 1 - 0.6 * this.gloom * (hash(Math.floor(this.time * 6)) > 0.75 ? 1 : 0) : 1 + for (const [i, l] of this.lamps.entries()) { + // By day only the beacons show. + if (look.lamp < 0.5 && !l.blink) continue + const pulse = l.blink ? 0.25 + 0.75 * Math.max(0, Math.sin(this.time * l.blink * 2 + i)) ** 4 : 0.85 + 0.15 * Math.sin(this.time * 0.6 + i * 1.3) + const k = look.lamp * pulse * flicker * 0.55 + const [x, y] = this.onShip([l.x, l.y]) + // Lights glide with the carrier, so on cells they fade from cell to cell rather than hopping. + if (this.cells) this.point(x, y, l.color[0] * k, l.color[1] * k, l.color[2] * k, l.size, true) + else this.disc(x, y, (0.0018 + l.size * 0.0007) * this.H, l.color[0] * k, l.color[1] * k, l.color[2] * k, 1) + } + } + + private drawClouds() { + const { W, H, hdr } = this + for (const c of this.clouds) { + const s = c.sprite + if (!s) continue + const x = c.x * H - s.ox + const y = c.y * H - s.oy + blitSprite(hdr, W, H, s, x, y) + this.touch(x - 1, y - 1, x + s.w + 1, y + s.h + 1) + } + } + + // Jets launch off the bow now and then and climb away, and others come in over the stern to land. At teeming the + // two catapults take turns every few seconds. + private stepJets(dt: number) { + for (const j of this.jets) j.t += dt + this.jets = this.jets.filter((j) => j.t < (j.kind === "launch" ? LAUNCH : LANDING)) + if (this.activity === "calm") return + const teeming = this.activity === "teeming" + this.nextLaunch -= dt + if (this.nextLaunch <= 0 && this.launch()) this.nextLaunch = teeming ? rand(1.5, 3) : rand(4, 7) + this.nextLanding -= dt + if (this.nextLanding <= 0 && !this.jets.some((j) => j.kind === "landing")) { + this.jets.push({ kind: "landing", t: 0, side: 0, cat: 0 }) + this.nextLanding = teeming ? rand(6, 10) : rand(14, 22) + } + } + + // Sends a jet off whichever catapult is clear, if either is. + private launch() { + const cat = CATAPULTS.find((c) => !this.jets.some((j) => j.kind === "launch" && j.cat === c && j.t < 4)) + if (cat === undefined) return false + this.jets.push({ kind: "launch", t: 0, side: Math.random() < 0.5 ? -1 : 1, cat }) + return true + } + + // Where a jet is in deck space, how much it banks, and how hard its afterburners glow. + private jetPath(j: Jet): { u: number; v: number; h: number; roll: number; burn: number; alpha: number } { + const t = j.t + if (j.kind === "launch") { + // Spooling up on the catapult, shot down the deck, then climbing steeply away and banking off to one side. + const spool = smoothstep(0, 1.5, t) + const run = smoothstep(1.5, 3.5, t) ** 2 + const c = Math.max(0, t - 3.5) + const v = lerp(0.22, 1.05, run) + c * 0.35 + c * c * 0.03 + const h = 0.08 + c * 0.8 + c * c * 0.9 + const u = j.cat + j.side * c * c * 0.3 + return { u, v, h, roll: j.side * smoothstep(0.3, 2, c) * 0.6, burn: spool, alpha: 1 - smoothstep(LAUNCH - 1, LAUNCH, t) } + } + // Gliding in from overhead behind the stern, touching down and rolling out, then taxiing away. + const glide = smoothstep(0, 5, t) + const roll = smoothstep(5, 7, t) + const v = lerp(-0.9, 0.2, glide) + roll * 0.3 + const h = 0.08 + (1 - glide) ** 1.5 * 6.4 + return { u: lerp(0.6, -0.1, glide), v, h, roll: (1 - glide) * -0.3, burn: 0.15, alpha: smoothstep(0, 1, t) * (1 - smoothstep(LANDING - 1, LANDING, t)) } + } + + // An aircraft's color: its daytime color darkened for the time of day, kept a little lighter than the sky at night so + // it reads as a silhouette. + private craft(c: RGB): RGB { + // By day a little darker than life, so aircraft read as shapes against the bright sky. + const k = this.look.night ? 3.2 : this.look.tint[0] === 1 ? 0.6 : 1 + const p = this.paint(c) + return [p[0] * k, p[1] * k, p[2] * k] + } + + // A jet seen from behind: twin canted tails, swept wings, wingtip lights, and two afterburners glowing amber. + private drawJet(j: Jet) { + const p = this.jetPath(j) + const z = 1 + BOW * p.v + const [x, y] = this.onShip(this.deck(p.u, p.v, p.h)) + const s = (BEAM * this.H) / z + if (p.alpha <= 0 || y < -s || y > this.H + s) return + const H = this.H + const k = (0.35 + p.burn * 1.4) * p.alpha + // The afterburners light the deck while the jet is on it, and glow around it in the air at night. + if (p.h < 0.3 && p.burn > 0.3) lightPool(this.hdr, this.W, H, x, y + s * 0.1, s * 1.2, s * 0.45, [1, 0.5, 0.15], 2.2 * p.burn, 0.03 * p.burn) + else if (this.look.lamp > 0.5) lightPool(this.hdr, this.W, H, x, y, s * 0.35, s * 0.3, [1, 0.5, 0.15], 0, 0.06 * k) + if (s < 3) { + this.splat(x, y, BURNER[0] * k * 0.5, BURNER[1] * k * 0.5, BURNER[2] * k * 0.5) + return + } + // Climbing, it leaves a short glowing exhaust streak behind it. + if (j.kind === "launch" && j.t > 3) { + const q = this.jetPath({ ...j, t: j.t - 0.35 }) + const [tx, ty] = this.onShip(this.deck(q.u, q.v, q.h)) + const sq = lerp(0.55, 0.3, clamp(p.h / 4, 0, 1)) + trail(this.hdr, this.W, H, x, y + 0.5 * sq * s, tx, ty + 0.5 * sq * s, Math.max(0.8, 0.05 * s), this.look.lamp > 0.5 ? BURNER : this.look.trail, 0.5 * p.alpha) + this.touch(Math.min(x, tx) - s * 0.1 - 2, Math.min(y, ty) - 2, Math.max(x, tx) + s * 0.1 + 2, Math.max(y, ty) + s * 0.3 + 2) + } + // Seen from above and behind, flatter the farther it climbs; its tails stand up off the airframe. + const squash = lerp(0.55, 0.3, clamp(p.h / 4, 0, 1)) + this.planform(x, y, s, -Math.PI / 2, squash, JETFORM, this.craft(JET), p.alpha, p.roll) + const ca = Math.cos(p.roll) + const sa = Math.sin(p.roll) + const Q = (a: number, b: number): Point => [x + (a * ca - b * sa) * s, y + (a * sa + b * ca) * s] + const fin = this.craft([JET[0] * 0.7, JET[1] * 0.7, JET[2] * 0.75]) + const tail = 0.45 * squash + for (const side of [-1, 1]) this.polygon([Q(side * 0.05, tail), Q(side * 0.06, tail - 0.08 * squash), Q(side * 0.12, tail - 0.24), Q(side * 0.1, tail - 0.25)], fin, p.alpha) + for (const side of [-1, 1]) { + const [bx, by] = Q(side * 0.035, 0.5 * squash) + this.disc(bx, by, Math.max(1.2, 0.09 * s), BURNER[0] * 0.3 * k, BURNER[1] * 0.3 * k, BURNER[2] * 0.3 * k, 0.6) + this.disc(bx, by, Math.max(0.8, 0.035 * s), BURNER[0] * k, BURNER[1] * k, BURNER[2] * k, 1) + // Red to port, green to starboard. + const [wx, wy] = Q(side * 0.42, 0.19 * squash) + const nav = this.look.lamp * p.alpha * 0.7 + this.disc(wx, wy, Math.max(0.6, 0.014 * s), side < 0 ? 1.8 * nav : 0.2 * nav, side < 0 ? 0.25 * nav : 1.6 * nav, side < 0 ? 0.15 * nav : 0.5 * nav, 1) + } + } + + // An aircraft's outline seen from above or below, nose along heading, flattened by the view. + private planform(x: number, y: number, s: number, heading: number, squash: number, form: readonly (readonly Point[])[], color: RGB, alpha = 1, roll = 0) { + const cr = Math.cos(roll) + const sr = Math.sin(roll) + const fx = Math.cos(heading) + const fy = Math.sin(heading) * squash + const sx = -Math.sin(heading) + const sy = Math.cos(heading) * squash + for (const poly of form) + this.polygon( + poly.map(([a, b]) => { + const px = (a * fx + b * sx) * s + const py = (a * fy + b * sy) * s + return [x + px * cr - py * sr, y + px * sr + py * cr] as const + }), + color, + alpha, + ) + } + + // Jets holding in wide, slow loops at several heights, clear of the text in the middle: farther ones higher and + // smaller, the highest trailing contrails. At teeming an E-2 circles high with its radar dome turning. + private drawCircling() { + const { A, H, look } = this + const loops = this.activity === "teeming" ? LOOPS : LOOPS.slice(0, 6) + for (const [i, l] of loops.entries()) { + const cx = l.cx < 0 ? A + l.cx : l.cx === 0 ? volcanoX(A) : l.cx + const a = this.time * l.rate + l.phase + const x = (cx + Math.cos(a) * l.rx) * H + const y = (l.cy + Math.sin(a) * l.ry) * H + const heading = Math.atan2(Math.cos(a) * l.ry * l.rate, -Math.sin(a) * l.rx * l.rate) + const s = l.size * (1 + 0.25 * Math.sin(a)) * H + if (l.cy < 0.1) { + const b = a - Math.sign(l.rate) * 0.35 + const tx = (cx + Math.cos(b) * l.rx) * H + const ty = (l.cy + Math.sin(b) * l.ry) * H + trail(this.hdr, this.W, H, x, y, tx, ty, Math.max(0.7, 0.003 * H), look.trail, 0.3) + this.touch(Math.min(x, tx) - 3, Math.min(y, ty) - 3, Math.max(x, tx) + 3, Math.max(y, ty) + 3) + } + this.planform(x, y, s, heading, 0.45, JETFORM, this.craft(JET), 1, -Math.sign(l.rate) * 0.25) + this.navLights(x, y, s, heading, i) + } + if (this.activity !== "teeming") return + // The E-2 Hawkeye: a slow, wide loop high over the left. + const a = this.time * 0.04 + 2 + const x = (0.4 + Math.cos(a) * 0.3) * H + const y = (0.085 + Math.sin(a) * 0.02) * H + const heading = Math.atan2(Math.cos(a) * 0.02, -Math.sin(a) * 0.3) + const s = 0.065 * H + this.planform(x, y, s, heading, 0.4, CARGOFORM, this.craft(HAWKEYE)) + const dome = this.craft([HAWKEYE[0] * 0.8, HAWKEYE[1] * 0.8, HAWKEYE[2] * 0.85]) + this.ellipse(x - Math.cos(heading) * s * 0.05, y - 0.06 * s, 0.18 * s, 0.045 * s, dome[0], dome[1], dome[2], 1) + this.navLights(x, y, s, heading, 11) + } + + // Amber and red blinking lights on an aircraft at night, and a faint glow from its engines. + private navLights(x: number, y: number, s: number, heading: number, seed: number) { + const k = this.look.lamp + if (k < 0.5) return + const blink = Math.sin(this.time * 4 + seed * 1.7) > 0.6 ? 0.8 : 0.15 + const bx = x - Math.cos(heading) * s * 0.5 + const by = y - Math.sin(heading) * s * 0.2 + this.splat(bx, by, BURNER[0] * 0.25, BURNER[1] * 0.25, BURNER[2] * 0.25) + this.splat(x, y, 1.6 * blink, 0.3 * blink, 0.12 * blink) + } + + // A crossing, picked by how busy the scene is, starting t of the way across. + private makeTraffic(t: number): Traffic { + const kinds: Traffic["kind"][] = this.activity === "teeming" ? ["formation", "formation", "formation", "break", "break", "cargo", "tanker", "helo", "flock", "flock"] : ["formation", "formation", "break", "cargo", "flock", "helo"] + const kind = kinds[Math.floor(Math.random() * kinds.length)] + const dir = Math.random() < 0.5 ? 1 : -1 + const seed = Math.random() * 100 + if (kind === "formation") { + const y = rand(0.05, 0.22) + return { kind, t, span: rand(12, 18), y, dir, size: lerp(0.026, 0.06, (y - 0.05) / 0.17), n: 2 + Math.floor(Math.random() * 3), seed } + } + if (kind === "break") return { kind, t, span: rand(11, 15), y: rand(0.16, 0.26), dir, size: 0.065, n: 1, seed } + if (kind === "cargo") return { kind, t, span: rand(34, 44), y: rand(0.12, 0.22), dir, size: 0.075, n: 1, seed } + if (kind === "tanker") return { kind, t, span: rand(50, 60), y: rand(0.06, 0.1), dir, size: 0.06, n: 1, seed } + if (kind === "helo") return { kind, t, span: rand(30, 40), y: this.openRow(0.5, 0.58), dir, size: 0.06, n: 1, seed } + return { kind, t, span: rand(26, 34), y: rand(0.12, 0.3), dir, size: 0.016, n: 7 + Math.floor(Math.random() * 5), seed } + } + + private stepTraffic(dt: number) { + for (const c of this.traffic) c.t += dt / c.span + this.traffic = this.traffic.filter((c) => c.t < 1) + if (this.activity === "calm") return + const teeming = this.activity === "teeming" + this.nextTraffic -= dt + if (this.nextTraffic > 0 || this.traffic.length >= (teeming ? 14 : 6)) return + this.traffic.push(this.makeTraffic(0)) + this.nextTraffic = teeming ? rand(0.6, 1.5) : rand(2, 4) + } + + private drawTraffic(c: Traffic) { + const { A, H, look } = this + const u = c.dir > 0 ? c.t : 1 - c.t + const x = lerp(-0.3, A + 0.3, u) * H + const y = c.y * H + const heading = c.dir > 0 ? 0 : Math.PI + const s = c.size * H + if (c.kind === "formation") { + // An echelon of two to four, each a step behind and to the side of the one ahead; high ones leave contrails. + for (let i = 0; i < c.n; i++) { + const jx = x - c.dir * i * s * 1.3 + const jy = y + (i % 2 ? 1 : -1) * Math.ceil(i / 2) * s * 0.5 + Math.sin(this.time * 0.7 + i + c.seed) * s * 0.04 + if (c.y < 0.12) { + trail(this.hdr, this.W, H, jx - c.dir * s * 0.5, jy, jx - c.dir * s * 7, jy, Math.max(0.7, 0.0028 * H), look.trail, 0.3) + this.touch(Math.min(jx, jx - c.dir * s * 7) - 3, jy - 3, Math.max(jx, jx - c.dir * s * 7) + 3, jy + 3) + } + this.planform(jx, jy, s, heading, 0.4, JETFORM, this.craft(JET)) + this.navLights(jx, jy, s, heading, i + c.seed) + } + return + } + if (c.kind === "break") { + // A jet breaking off: it dives toward the sea, rolls, and climbs back out with its afterburner lit. + const dip = Math.sin(Math.PI * c.t) * 0.08 + const by = y + dip * H + const climb = Math.cos(Math.PI * c.t) * c.dir + const h = heading - climb * 0.25 * c.dir + this.planform(x, by, s, h, 0.45, JETFORM, this.craft(JET), 1, Math.sin(TAU * c.t) * 0.5) + const k = smoothstep(0.5, 0.7, c.t) * 1.2 + 0.2 + this.splat(x - Math.cos(h) * s * 0.55, by - Math.sin(h) * s * 0.25, BURNER[0] * 0.4 * k, BURNER[1] * 0.4 * k, BURNER[2] * 0.4 * k) + this.navLights(x, by, s, h, c.seed) + return + } + if (c.kind === "cargo") { + this.planform(x, y, s, heading, 0.4, CARGOFORM, this.craft(CARGO)) + this.navLights(x, y, s, heading, c.seed) + return + } + if (c.kind === "tanker") { + // A tanker far off with two jets on its hoses, one to each side behind it. + this.planform(x, y, s, heading, 0.4, TANKERFORM, this.craft(CARGO)) + this.navLights(x, y, s, heading, c.seed) + for (const side of [-1, 1]) { + const hx = x - c.dir * s * 0.45 + const hy = y + side * s * 0.12 + const jx = x - c.dir * s * 0.95 + const jy = y + side * s * 0.22 + s * 0.08 + trail(this.hdr, this.W, H, hx, hy, jx + c.dir * s * 0.2, jy, 0.5, this.craft([0.5, 0.5, 0.5]), 0.6) + this.touch(Math.min(hx, jx) - 2, Math.min(hy, jy) - 2, Math.max(hx, jx) + 2, Math.max(hy, jy) + 2) + this.planform(jx, jy, s * 0.42, heading, 0.4, JETFORM, this.craft(JET)) + this.navLights(jx, jy, s * 0.42, heading, side + c.seed) + } + return + } + if (c.kind === "helo") { + this.drawHelo(x, y, s, 0.12 * -c.dir, c.dir > 0 ? -1 : 1, c.seed) + return + } + // A flock of seabirds in a loose vee, flapping out of step. + for (let i = 0; i < c.n; i++) { + const rank = Math.ceil(i / 2) + const side = i % 2 ? 1 : -1 + const bx = x - c.dir * rank * s * 2.2 + Math.sin(this.time * 0.6 + i * 2.1) * s * 0.4 + const by = y + side * rank * s * 1.1 + Math.sin(this.time * 0.8 + i) * s * 0.3 + this.bird(bx, by, s, Math.sin(this.time * 5 + i * 1.7 + c.seed)) + } + } + + // A bird with its wings beating: flap runs from -1 (down) to 1 (up). + private bird(x: number, y: number, s: number, flap: number) { + const color = this.craft(this.look.night ? [0.3, 0.32, 0.36] : [0.8, 0.82, 0.86]) + const w = this.thick(0.001, 0.6) + const up = flap * s * 0.45 + const parts: Part[] = [] + for (const side of [-1, 1]) { + parts.push(cap(x, y, x + side * s * 0.5, y - up, w, w * 0.8, color), cap(x + side * s * 0.5, y - up, x + side * s, y - up * 0.3 + s * 0.15, w * 0.8, w * 0.5, color)) + } + this.shape(parts, FLAT, 0) + } + + // Gulls wheeling around the carrier's island. + private drawGulls() { + const H = this.H + const [ix, iy] = this.onShip(this.deck(0.8, 0.55, 2)) + const n = this.activity === "teeming" ? 12 : 7 + for (let i = 0; i < n; i++) { + const a = this.time * (0.12 + hash(i) * 0.08) * (i % 2 ? 1 : -1) + i * 1.9 + const rx = (0.06 + hash(i + 3) * 0.1) * H + const x = ix + Math.cos(a) * rx + const y = iy - (0.01 + hash(i + 7) * 0.05) * H + Math.sin(a) * rx * 0.25 + this.bird(x, y, 0.015 * H, Math.sin(this.time * 4 + i * 2.3)) + } + } + + // The sea's traffic: boats at rest drifting toward the viewer, a dolphin leaping and a whale spouting. + private stepSea(dt: number) { + if (this.activity === "calm") return + const teeming = this.activity === "teeming" + for (const b of this.boats) b.d += ROW_RATE * rowHeight(b.d) * dt + this.boats = this.boats.filter((b) => b.d < 0.4 && Math.abs(b.x * b.d) < this.A / 2 + 0.1) + if (this.boats.length < (teeming ? 4 : 2)) this.boats.push(makeBoat(0.015)) + const d = this.dolphin + if (d.t >= 0) d.t = d.t + dt / 2.2 > 1 ? -1 : d.t + dt / 2.2 + else if (teeming && (d.wait -= dt) <= 0) { + d.t = 0 + d.wait = rand(4, 9) + d.dir = Math.random() < 0.5 ? 1 : -1 + d.d = rand(0.22, 0.32) + d.x = (Math.random() < 0.5 ? -1 : 1) * rand(1.6, 2.6) + } + const w = this.spout + if (w.t >= 0) w.t = w.t + dt / 4 > 1 ? -1 : w.t + dt / 4 + else if (teeming && (w.wait -= dt) <= 0) { + w.t = 0 + w.wait = rand(20, 35) + w.d = rand(0.03, 0.06) + w.x = (Math.random() < 0.5 ? -1 : 1) * rand(6, 10) + } + } + + private drawSeaTraffic() { + const { A, H, look } = this + const teeming = this.activity === "teeming" + const at = (x: number, d: number): Point => [(A / 2 + x * d) * H, (HORIZON + d) * H] + // Escorts keeping station with the carrier, their wakes streaming back; at teeming a supply ship alongside too. + const escorts: [number, number, number][] = teeming ? [[-7, 0.05, 0.8], [6.5, 0.07, 0.9], [-4.2, 0.075, 0.9], [4.2, 0.035, 0.7]] : [[6.5, 0.07, 0.9]] + for (const [i, [x, d, beam]] of escorts.entries()) this.drawEscort(...at(x, d), beam * d * H, i === 2 ? "supply" : i % 2 ? "destroyer" : "frigate", i) + for (const b of this.boats) { + const [x, y] = at(b.x, b.d) + const s = 0.22 * b.d * H + const bob = Math.sin(this.time * 1.1 + b.seed) * s * 0.06 + const hull = this.paint([0.55, 0.32, 0.22]) + this.polygon([[x - s, y - s * 0.25 + bob], [x + s, y - s * 0.25 + bob], [x + s * 0.7, y + bob], [x - s * 0.7, y + bob]], hull) + this.polygon([[x - s * 0.3, y - s * 0.25 + bob], [x - s * 0.3, y - s * 0.7 + bob], [x + s * 0.2, y - s * 0.7 + bob], [x + s * 0.2, y - s * 0.25 + bob]], this.paint([0.75, 0.75, 0.7])) + if (look.lamp > 0.5) this.splat(x, y - s * 0.8 + bob, AMBER[0] * 0.4, AMBER[1] * 0.4, AMBER[2] * 0.4) + } + const d = this.dolphin + if (d.t >= 0) { + const [x0, y0] = at(d.x, d.d) + const s = 0.5 * d.d * H + const x = x0 + d.dir * (d.t - 0.5) * s * 1.6 + const y = y0 - Math.sin(Math.PI * d.t) * s * 0.5 + const ang = Math.atan2(-Math.cos(Math.PI * d.t) * 0.5, d.dir * 1.6) + const ca = Math.cos(ang) + const sa = Math.sin(ang) + const P = (u: number, v: number): Point => [x + (u * ca - v * sa) * s * d.dir, y + (u * sa * d.dir + v * ca) * s] + const body = this.craft([0.4, 0.48, 0.56]) + this.shape([cap(...P(-0.25, 0), ...P(0.25, 0), 0.05 * s, 0.07 * s, body), cap(...P(0, -0.05), ...P(-0.06, -0.13), 0.03 * s, 0.01 * s, body)], FLAT, 0) + for (const e of [0, 1]) { + const k = Math.max(0, 1 - Math.abs(d.t - e) * 5) + if (k > 0) for (let i = 0; i < 5; i++) this.splat(x0 + d.dir * (e - 0.5) * s * 1.6 + (i - 2) * s * 0.06, y0 - k * s * 0.1 * (1 + hash(i)), look.foam[0] * k, look.foam[1] * k, look.foam[2] * k) + } + } + const w = this.spout + if (w.t >= 0) { + const [x, y] = at(w.x, w.d) + const s = 0.6 * w.d * H + const hump = this.craft([0.2, 0.24, 0.28]) + this.ellipse(x, y, s * 0.5, s * 0.1, hump[0], hump[1], hump[2], smoothstep(0, 0.2, w.t) * (1 - smoothstep(0.8, 1, w.t))) + const k = Math.sin(Math.PI * w.t) * 0.6 + for (let i = 0; i < 9; i++) { + const f = ((w.t * 3 + i / 9) % 1) + this.splat(x + (hash(i) - 0.5) * s * 0.4 * f, y - s * 0.9 * Math.sin(Math.PI * f * 0.8), look.foam[0] * k * (1 - f), look.foam[1] * k * (1 - f), look.foam[2] * k * (1 - f)) + } + } + } + + // A warship from astern, keeping station: hull, superstructure and mast, with foam streaming back from its stern. + private drawEscort(x: number, y: number, beam: number, kind: "frigate" | "destroyer" | "supply", seed: number) { + const { look } = this + const bob = Math.sin(this.time * 0.8 + seed * 2) * beam * 0.04 + const roll = Math.sin(this.time * 0.6 + seed) * 0.03 + const ca = Math.cos(roll) + const sa = Math.sin(roll) + const Q = (a: number, b: number): Point => [x + (a * ca - b * sa) * beam, y + bob + (a * sa + b * ca) * beam] + // Foam streaming back from the stern in two arms. + for (let i = 0; i < 10; i++) { + const f = (this.time * 0.3 + i / 10 + seed * 0.13) % 1 + const side = i % 2 ? 1 : -1 + const k = (1 - f) * 0.6 + this.splat(x + side * beam * (0.4 + f * 0.5), y + beam * (0.05 + f * 1.6), look.foam[0] * k, look.foam[1] * k, look.foam[2] * k) + } + const hull = this.paint(HULL) + const top = this.paint([HULL[0] * 1.15, HULL[1] * 1.15, HULL[2] * 1.15]) + this.polygon([Q(-0.55, -0.35), Q(0.55, -0.35), Q(0.42, 0), Q(-0.42, 0)], hull) + if (kind === "supply") { + this.polygon([Q(-0.4, -0.35), Q(0.4, -0.35), Q(0.4, -0.75), Q(-0.4, -0.75)], top) + for (const k of [-0.3, 0, 0.3]) this.polygon([Q(k - 0.02, -0.75), Q(k + 0.02, -0.75), Q(k + 0.02, -1.25), Q(k - 0.02, -1.25)], hull) + this.polygon([Q(-0.35, -1.15), Q(0.35, -1.15), Q(0.35, -1.2), Q(-0.35, -1.2)], hull) + } + if (kind !== "supply") { + this.polygon([Q(-0.3, -0.35), Q(0.3, -0.35), Q(0.24, -0.85), Q(-0.24, -0.85)], top) + this.polygon([Q(-0.03, -0.85), Q(0.03, -0.85), Q(0.015, kind === "destroyer" ? -1.45 : -1.25), Q(-0.015, kind === "destroyer" ? -1.45 : -1.25)], hull) + } + if (look.lamp > 0.5) { + const blink = Math.sin(this.time * 3 + seed * 1.3) > 0.5 ? 0.6 : 0.15 + this.splat(...Q(0, kind === "supply" ? -1.25 : kind === "destroyer" ? -1.45 : -1.25), 1.6 * blink, 0.3 * blink, 0.12 * blink) + this.splat(...Q(-0.5, -0.33), 0.3 * 0.5, 1.6 * 0.5, 0.5 * 0.5) + this.splat(...Q(0.5, -0.33), 1.8 * 0.5, 0.25 * 0.5, 0.15 * 0.5) + } + } + + // A jet taxiing from the parking area to the waist catapult, which then launches it. + private stepTaxi(dt: number) { + if (this.activity === "calm") return + const t = this.taxi + if (t.wait > 0) { + t.wait -= dt + return + } + t.t += dt / 9 + if (t.t < 1) return + t.t = 0 + t.wait = this.activity === "teeming" ? rand(3, 6) : rand(10, 16) + this.launch() + } + + // Life on deck: crew in colored jerseys crossing to and fro, a tow tractor, and a jet taxiing to a catapult. + private drawDeck() { + const { H, look } = this + const t = this.time + const crew = this.activity === "teeming" ? CREW : CREW.slice(0, 6) + for (const [i, [u0, v0, u1, v1, color]] of crew.entries()) { + const f = 0.5 - 0.5 * Math.cos(t * (0.15 + hash(i) * 0.1) + i * 2.1) + const [x, y] = this.onShip(this.deck(lerp(u0, u1, f), lerp(v0, v1, f))) + const [, top] = this.onShip(this.deck(lerp(u0, u1, f), lerp(v0, v1, f), 0.1)) + const c = this.craft(color) + this.shape([cap(x, y, x, top, this.thick(0.0012, 0.6), this.thick(0.0012, 0.6), look.night ? [c[0] * 1.2, c[1] * 1.2, c[2] * 1.2] : c)], FLAT, 0) + // At night the marshals' wands glow. + if (look.lamp > 0.5 && i % 3 === 0) this.splat(x + Math.sin(t * 2 + i) * 1.5, top, AMBER[0] * 0.35, AMBER[1] * 0.35, AMBER[2] * 0.35) + } + // The tow tractor, trundling up and down beside the island. + const f = 0.5 - 0.5 * Math.cos(t * 0.06) + const [tx, ty] = this.onShip(this.deck(0.5, lerp(0.15, 0.4, f), 0.02)) + const ts = (0.05 * BEAM * H) / (1 + BOW * lerp(0.15, 0.4, f)) + const tug = this.craft(YELLOW) + this.polygon([[tx - ts, ty - ts * 0.6], [tx + ts, ty - ts * 0.6], [tx + ts, ty + ts * 0.3], [tx - ts, ty + ts * 0.3]], tug) + if (look.lamp > 0.5) this.splat(tx, ty - ts * 0.6, AMBER[0] * 0.4 * (Math.sin(t * 5) > 0 ? 1 : 0.2), AMBER[1] * 0.4 * (Math.sin(t * 5) > 0 ? 1 : 0.2), AMBER[2] * 0.2) + // The taxiing jet, along a curve from the parking area to the catapult. + const tx2 = this.taxi + if (tx2.wait > 0) return + const q = smoothstep(0, 1, tx2.t) + const u = lerp(0.55, CATAPULTS[0], q) + Math.sin(Math.PI * q) * -0.2 + const v = lerp(0.85, 0.24, q) + const [jx, jy] = this.onShip(this.deck(u, v, 0.08)) + const s = (0.5 * BEAM * H) / (1 + BOW * v) + // Nosing toward the bow, turning in as it swings across the deck. + this.planform(jx, jy, s, -Math.PI / 2 - Math.cos(Math.PI * q) * 0.5, 0.55, JETFORM, this.craft(JET)) + if (look.lamp > 0.5) this.splat(jx, jy, BURNER[0] * 0.25, BURNER[1] * 0.25, BURNER[2] * 0.25) + } + + // Helicopters: one hovering off the island, and at teeming a rescue helicopter holding off the port quarter, its + // downwash ruffling the water below it. + private drawHelicopters() { + const { H, look } = this + const t = this.time + const [x, y] = this.onShip(this.deck(1.6 + 0.08 * Math.sin(t * 0.21), 0.35, 1.7 + 0.12 * Math.sin(t * 0.33))) + this.drawHelo(x, y, 0.065 * H, 0.06 * Math.sin(t * 0.21 + 1.2), 1, 3) + if (this.activity !== "teeming") return + const [rx, ry] = this.onShip(this.deck(-1.9 + 0.1 * Math.sin(t * 0.17), 0.05, 1.9 + 0.15 * Math.sin(t * 0.29))) + const [, wy] = this.onShip(this.deck(-1.9, 0.05, WATERLINE)) + for (let i = 0; i < 3; i++) { + const f = (t * 0.6 + i / 3) % 1 + const k = (1 - f) * 0.35 + this.ellipse(rx, wy, (0.02 + f * 0.05) * H, (0.004 + f * 0.01) * H, look.foam[0], look.foam[1], look.foam[2], k * 0.5) + } + this.drawHelo(rx, ry, 0.075 * H, 0.05 * Math.sin(t * 0.19), -1, 5) + } + + // A helicopter side on, nose toward face (1 left, -1 right): a rounded cabin, a tail boom with its fin, skids, and its + // rotor a faint blur rather than blades, so it doesn't flicker. + private drawHelo(x: number, y: number, s: number, tilt: number, face: number, seed: number) { + const look = this.look + const body = this.craft([0.3, 0.34, 0.3]) + const ca = Math.cos(tilt) + const sa = Math.sin(tilt) + const Q = (a: number, b: number): Point => [x + (a * face * ca - b * sa) * s, y + (a * face * sa + b * ca) * s] + this.ellipse(...Q(-0.05, 0), 0.22 * s, 0.12 * s, body[0], body[1], body[2], 1) + this.polygon([Q(0.1, -0.04), Q(0.55, -0.03), Q(0.55, 0.0), Q(0.1, 0.05)], body) + this.polygon([Q(0.5, -0.03), Q(0.58, -0.17), Q(0.62, -0.17), Q(0.58, 0.0)], body) + this.polygon([Q(-0.22, 0.17), Q(0.14, 0.17), Q(0.14, 0.19), Q(-0.22, 0.19)], body) + this.polygon([Q(-0.24, -0.15), Q(0.16, -0.15), Q(0.16, -0.13), Q(-0.24, -0.13)], body) + const blur = look.night ? look.key : look.sky[look.sky.length - 1][1] + this.ellipse(...Q(-0.04, -0.15), 0.5 * s, 0.035 * s, blur[0] * 0.5 + body[0] * 0.5, blur[1] * 0.5 + body[1] * 0.5, blur[2] * 0.5 + body[2] * 0.5, 0.45) + this.navLights(...Q(0.6, -0.1), s, Math.PI, seed) + } + + private stepFlier(f: Flier, dt: number, on: boolean, min: number, max: number, row: () => number) { + if (f.t >= 0) { + f.t += dt / f.span + if (f.t > 1) { + f.t = -1 + f.wait = rand(min, max) + } + return + } + if (!on) return + f.wait -= dt + if (f.wait > 0) return + f.t = 0 + f.dir = Math.random() < 0.5 ? 1 : -1 + f.y = row() + } + + // An A-10 Warthog skimming the water across the screen, straight-winged, its twin engines high on the tail. + private drawWarthog(f: Flier) { + f.t = Math.min(f.t, 1) + const { A, H } = this + const u = f.dir > 0 ? f.t : 1 - f.t + const x = lerp(-0.2, A + 0.2, u) * H + const y = f.y * H + const s = 0.08 * H + // Its shadow racing along the sea below it. + this.ellipse(x, y + 0.025 * H, s * 0.4, s * 0.06, 0, 0, 0, this.look.night ? 0.15 : 0.25) + this.planform(x, y, s, f.dir > 0 ? 0 : Math.PI, 0.38, WARTHOGFORM, this.craft(WARTHOG)) + } + + // A B-2 Spirit gliding high across the sky: a dark flying wing with a sawtooth trailing edge. + private drawSpirit(f: Flier) { + f.t = Math.min(f.t, 1) + const { A, H } = this + const u = f.dir > 0 ? f.t : 1 - f.t + this.planform(lerp(-0.2, A + 0.2, u) * H, f.y * H, 0.09 * H, f.dir > 0 ? 0 : Math.PI, 0.32, SPIRITFORM, this.craft(SPIRIT)) + } + + // When the agent finishes: a vee of five jets sweeps across the sky trailing contrails. + private drawFlyover() { + const { A, H, look } = this + const f = this.flyover + const u = f.t / 14 + const lead = lerp(-0.4, A + 0.9, f.dir > 0 ? u : 1 - u) + const heading = f.dir > 0 ? 0 : Math.PI + for (let i = 0; i < 5; i++) { + const rank = Math.ceil(i / 2) + const side = i % 2 ? -1 : 1 + const x = (lead - f.dir * rank * 0.09) * H + const y = (f.y + side * rank * 0.035) * H + const len = Math.min(0.5, f.t * 0.12) * H + const fade = 1 - smoothstep(10, 14, f.t) + trail(this.hdr, this.W, H, x - f.dir * 0.02 * H, y, x - f.dir * len, y, Math.max(1, 0.004 * H), look.trail, 0.35 * fade) + this.touch(Math.min(x, x - f.dir * len) - 3, y - 3, Math.max(x, x - f.dir * len) + 3, y + 3) + this.planform(x, y, 0.05 * H, heading, 0.4, JETFORM, this.craft(JET)) + const k = look.lamp + this.splat(x - f.dir * 0.018 * H, y, BURNER[0] * 0.3 * k, BURNER[1] * 0.3 * k, BURNER[2] * 0.3 * k) + } + } + + // The easter egg: a B-2 passes low overhead, from behind the viewer out toward the volcano, its shadow sweeping + // across the sea and the deck below it. + private drawEgg() { + const { A, H } = this + const t = this.egg.t / 12 + const z = lerp(0.3, 6, t ** 1.4) + const x = (A / 2 + 0.05 / z) * H + const s = (0.5 / z) * H + const fade = 1 - smoothstep(0.85, 1, t) + const shadow = (HORIZON + 0.5 / z) * H + if (shadow - s * 0.3 < H) this.planform(x + 0.08 * H, shadow, s, Math.PI / 2, 0.3, SPIRITFORM, [0, 0, 0], 0.3 * fade) + const y = (HORIZON - 0.12 - 0.45 / z) * H + if (y + s * 0.3 < 0) return + // At night the moon catches its edges, so it reads against the dark sky. + if (this.look.night) this.planform(x, y - 0.004 * H, s * 1.03, Math.PI / 2, 0.3, SPIRITFORM, [this.look.key[0] * 0.1, this.look.key[1] * 0.1, this.look.key[2] * 0.1], fade) + this.planform(x, y, s, Math.PI / 2, 0.3, SPIRITFORM, this.craft(SPIRIT), fade) + } + + // Sparkles bursting from a click on the water, spreading and twinkling out, each a little four-pointed glint. + private drawSparkles() { + const { H, look } = this + const c = look.night ? look.spec : [1.2, 1.2, 1.1] + const arm = Math.max(1, 0.004 * H) + for (const s of this.sparkles) { + const f = s.age / SPARKLE + for (let i = 0; i < 28; i++) { + const a = hash(s.seed + i) * TAU + const r = (0.02 + hash(s.seed + i * 3.1) * 0.09) * Math.sqrt(f) + const tw = Math.max(0, Math.sin(s.age * 7 + i * 2.3)) + const k = (1 - f) * tw * 1.6 + if (k <= 0.02) continue + const x = (s.x + Math.cos(a) * r) * H + const y = (s.y + Math.sin(a) * r * 0.35 - f * 0.01) * H + this.splat(x, y, c[0] * k, c[1] * k, c[2] * k) + for (const [dx, dy] of [[arm, 0], [-arm, 0], [0, arm], [0, -arm]]) this.splat(x + dx, y + dy, c[0] * k * 0.3, c[1] * k * 0.3, c[2] * k * 0.3) + } + } + } + + // Rain slanting down in the storm after an error. + private drawRain() { + const { W, H, look } = this + const k = smoothstep(0.2, 1, this.gloom) + const color: RGB = look.night ? [0.08, 0.2, 0.5] : [0.35, 0.42, 0.45] + const d = this.drops + for (let i = 0; i < d.length; i += 3) { + const z = d[i + 2] + const x = d[i] * W + const y = d[i + 1] * H + const len = (0.03 + z * 0.04) * H + trail(this.hdr, W, H, x, y, x - len * 0.15, y - len, 0.6, color, k * (0.25 + z * 0.4)) + } + this.touch(0, 0, W - 1, H - 1) + } +} + +// The volcano stands right of the middle, clear of the logo, but not so far right that it crowds the sea stacks. +function volcanoX(A: number) { + return Math.min(A / 2 + 0.32, A - 0.45) +} + +// Flat color, for small and thin things. +const FLAT: Lighting = { style: "front", dir: [0, 0, 1] } + +// Loops jets hold in, in screen heights: the middle (cx 0 for over the volcano, negative from the right edge), radii, +// size, turn rate in radians per scene second and starting angle. Lively flies the first four. +const LOOPS = [ + { cx: 0, cy: 0.2, rx: 0.36, ry: 0.04, size: 0.05, rate: 0.13, phase: 0 }, + { cx: 0, cy: 0.22, rx: 0.36, ry: 0.04, size: 0.05, rate: 0.13, phase: Math.PI }, + { cx: 0.32, cy: 0.15, rx: 0.24, ry: 0.05, size: 0.07, rate: -0.14, phase: 1 }, + { cx: 0.5, cy: 0.07, rx: 0.4, ry: 0.015, size: 0.028, rate: 0.1, phase: 2.5 }, + { cx: -0.3, cy: 0.31, rx: 0.2, ry: 0.03, size: 0.065, rate: 0.16, phase: 4 }, + { cx: 0.32, cy: 0.14, rx: 0.24, ry: 0.05, size: 0.07, rate: -0.14, phase: 1.5 }, + { cx: -0.35, cy: 0.06, rx: 0.3, ry: 0.012, size: 0.026, rate: -0.11, phase: 0.4 }, + { cx: 0, cy: 0.12, rx: 0.5, ry: 0.025, size: 0.036, rate: -0.09, phase: 3.3 }, + { cx: 0.25, cy: 0.3, rx: 0.18, ry: 0.03, size: 0.06, rate: -0.15, phase: 5.2 }, + { cx: -0.45, cy: 0.18, rx: 0.3, ry: 0.04, size: 0.05, rate: 0.12, phase: 2 }, + { cx: 0, cy: 0.05, rx: 0.6, ry: 0.01, size: 0.02, rate: 0.07, phase: 1.1 }, +] + +// Deck crew walking between two spots (u0, v0, u1, v1) in the color of their jobs. +const CREW: [number, number, number, number, RGB][] = [ + [-0.5, 0.3, -0.3, 0.5, [0.9, 0.75, 0.1]], + [0.3, 0.7, 0.45, 0.9, [0.2, 0.6, 0.2]], + [-0.2, 0.15, 0.1, 0.25, [0.8, 0.15, 0.1]], + [0.5, 0.2, 0.55, 0.45, [0.5, 0.25, 0.6]], + [-0.6, 0.75, -0.3, 0.85, [0.85, 0.85, 0.8]], + [0.2, 0.4, 0.35, 0.3, [0.9, 0.75, 0.1]], + [-0.4, 0.55, -0.15, 0.6, [0.2, 0.6, 0.2]], + [0.35, 0.55, 0.5, 0.65, [0.5, 0.35, 0.2]], + [-0.75, 0.2, -0.75, 0.6, [0.15, 0.35, 0.8]], +] + +const HAWKEYE: RGB = [0.55, 0.57, 0.6] +const CARGO: RGB = [0.48, 0.5, 0.52] + +function makeBoat(d: number): Boat { + return { d, x: (Math.random() < 0.5 ? -1 : 1) * rand(2.2, 9), seed: Math.random() * 100 } +} + +// Both halves of an outline drawn on one side of the centerline. +function sym(...polys: Point[][]): Point[][] { + return polys.flatMap((p) => [p, p.map(([a, b]) => [a, -b] as const)]) +} + +// Top-down outlines, nose toward +a, span along b, for planform(). +const JETFORM: Point[][] = [ + [[0.5, 0], [0.3, 0.05], [-0.5, 0.06], [-0.5, -0.06], [0.3, -0.05]], + [[0.12, 0.05], [-0.14, 0.42], [-0.24, 0.42], [-0.2, 0.05]], + [[0.12, -0.05], [-0.14, -0.42], [-0.24, -0.42], [-0.2, -0.05]], + [[-0.34, 0.05], [-0.48, 0.22], [-0.53, 0.22], [-0.5, 0.05]], + [[-0.34, -0.05], [-0.48, -0.22], [-0.53, -0.22], [-0.5, -0.05]], +] +const WARTHOGFORM: Point[][] = [ + [[0.5, 0], [0.42, 0.05], [-0.4, 0.04], [-0.4, -0.04], [0.42, -0.05]], + [[0.08, 0.04], [0.06, 0.52], [-0.06, 0.5], [-0.08, 0.04]], + [[0.08, -0.04], [0.06, -0.52], [-0.06, -0.5], [-0.08, -0.04]], + [[-0.14, 0.04], [-0.14, 0.15], [-0.32, 0.15], [-0.32, 0.04]], + [[-0.14, -0.04], [-0.14, -0.15], [-0.32, -0.15], [-0.32, -0.04]], + [[-0.38, 0.2], [-0.38, -0.2], [-0.47, -0.2], [-0.47, 0.2]], +] +// A twin-turboprop with a straight high wing and a broad twin-finned tail, for the C-2 and the E-2. +const CARGOFORM: Point[][] = [ + [[0.5, 0], [0.4, 0.06], [-0.45, 0.05], [-0.45, -0.05], [0.4, -0.06]], + ...sym([[0.12, 0.05], [0.1, 0.55], [0.0, 0.55], [0.0, 0.05]], [[-0.36, 0.04], [-0.38, 0.2], [-0.46, 0.2], [-0.45, 0.04]], [[0.24, 0.2], [0.24, 0.26], [0.0, 0.26], [0.0, 0.2]]), +] +// A tanker: a long fuselage with swept wings. +const TANKERFORM: Point[][] = [[[0.5, 0], [0.42, 0.05], [-0.45, 0.04], [-0.45, -0.04], [0.42, -0.05]], ...sym([[0.15, 0.05], [-0.15, 0.5], [-0.22, 0.5], [-0.08, 0.05]], [[-0.34, 0.04], [-0.46, 0.18], [-0.5, 0.18], [-0.46, 0.04]])] +const SPIRITFORM: Point[][] = [ + [[0.3, 0], [-0.08, 0.55], [-0.2, 0.36], [-0.1, 0.22], [-0.22, 0.1], [-0.12, 0], [-0.22, -0.1], [-0.1, -0.22], [-0.2, -0.36], [-0.08, -0.55]], +] + +function makeLowPolyClouds(count: number): Cloud[] { + return Array.from({ length: count }, (_, i) => { + const size = rand(0.06, 0.12) + const n = 3 + Math.floor(Math.random() * 3) + const lumps = Array.from({ length: n }, (_, k) => { + const f = n === 1 ? 0 : k / (n - 1) - 0.5 + return { x: f * 1.6 + rand(-0.1, 0.1), y: 0, r: (0.55 - Math.abs(f) * 0.5) * rand(0.8, 1.2), seed: Math.random() * 100 } + }) + for (const l of lumps) l.y = -l.r * 0.6 + // The clouds keep to the top of the sky and the band just over the horizon, clear of the middle. + const y = i % 3 === 2 ? rand(0.36, 0.42) : rand(0.06, 0.24) + return { x: ((i + Math.random() * 0.8) / count) * 2.2, y, size: i % 3 === 2 ? size * 0.7 : size, speed: rand(0.004, 0.008), lumps } + }) +} + +// Renders a cloud's lumps as faceted polygons into a premultiplied RGBA sprite: each lump a fan of triangles around a +// hub, every facet lit by how squarely it faces the light (toward the sun's side), with a flat shadowed base. +function cloudSprite(c: Cloud, H: number, colors: [RGB, RGB], toLight: number): Sprite { + const S = 4 + const size = c.size * H + const w = Math.ceil(size * 2.4) + 4 + const h = Math.ceil(size * 1.3) + 4 + const ox = w / 2 + const oy = h - 2 + const fine = new Int32Array(w * S * h * S).fill(-1) + const tones: number[] = [] + const lx = clamp(toLight * 2, -0.8, 0.8) + const ly = -0.75 + const [lit, shade] = colors + for (const l of c.lumps) { + const cx = ox + l.x * size + const cy = oy + l.y * size + const r = l.r * size + const n = 7 + const pts = Array.from({ length: n }, (_, k) => { + const a = (k / n) * TAU + hash(l.seed + k) * 0.5 + const rr = r * (0.82 + hash(l.seed + k * 2.7) * 0.3) + return [cx + Math.cos(a) * rr, Math.min(oy, cy + Math.sin(a) * rr * 0.85)] as const + }) + const hub = [cx - lx * r * 0.25, cy - r * 0.2] as const + for (let k = 0; k < n; k++) { + const a = pts[k] + const b = pts[(k + 1) % n] + const mx = (a[0] + b[0]) / 2 - hub[0] + const my = (a[1] + b[1]) / 2 - hub[1] + const ml = Math.hypot(mx, my) || 1 + const tone = clamp(0.5 + 0.6 * ((mx / ml) * lx + (my / ml) * ly) + (hash(l.seed + k * 5.1) - 0.5) * 0.2, 0, 1) + tones.push(tone) + rasterTri(fine, w * S, h * S, hub[0] * S, hub[1] * S, a[0] * S, a[1] * S, b[0] * S, b[1] * S, tones.length - 1) + } + } + const rgba = new Float32Array(w * h * 4) + for (let y = 0; y < h; y++) + for (let x = 0; x < w; x++) { + let r = 0 + let g = 0 + let b = 0 + let a = 0 + for (let j = 0; j < S; j++) + for (let i = 0; i < S; i++) { + const id = fine[(y * S + j) * w * S + x * S + i] + if (id < 0) continue + const t = tones[id] + r += lerp(shade[0], lit[0], t) + g += lerp(shade[1], lit[1], t) + b += lerp(shade[2], lit[2], t) + a++ + } + const o = (y * w + x) * 4 + rgba[o] = r / (S * S) + rgba[o + 1] = g / (S * S) + rgba[o + 2] = b / (S * S) + rgba[o + 3] = a / (S * S) + } + return { w, h, ox, oy, rgba } +} + +// Composites a premultiplied sprite at a fractional position, bilinearly, so it glides as it drifts. +function blitSprite(hdr: Float32Array, W: number, H: number, s: Sprite, x: number, y: number) { + const x0 = Math.floor(x) + const y0 = Math.floor(y) + const fx = x - x0 + const fy = y - y0 + const d = s.rgba + for (let j = 0; j <= s.h; j++) { + const py = y0 + j + if (py < 0 || py >= H) continue + for (let i = 0; i <= s.w; i++) { + const px = x0 + i + if (px < 0 || px >= W) continue + let r = 0 + let g = 0 + let b = 0 + let a = 0 + for (let q = 0; q < 4; q++) { + const sx = i - (q & 1) + const sy = j - (q >> 1) + if (sx < 0 || sy < 0 || sx >= s.w || sy >= s.h) continue + const k = (q & 1 ? fx : 1 - fx) * (q >> 1 ? fy : 1 - fy) + const o = (sy * s.w + sx) * 4 + r += d[o] * k + g += d[o + 1] * k + b += d[o + 2] * k + a += d[o + 3] * k + } + if (a <= 0) continue + const o = (py * W + px) * 3 + hdr[o] = hdr[o] * (1 - a) + r + hdr[o + 1] = hdr[o + 1] * (1 - a) + g + hdr[o + 2] = hdr[o + 2] * (1 - a) + b + } + } +} + +// Composites the hull sprite turned by roll, stretched by pitch and moved by (dx, dy) about the pivot (px, py), +// sampling it bilinearly so the carrier glides; returns the box it drew in. +function blitHull(hdr: Float32Array, W: number, H: number, h: Hull, px: number, py: number, dx: number, dy: number, roll: number, sy: number) { + const c = Math.cos(roll) + const n = Math.sin(roll) + let x0 = Infinity + let y0 = Infinity + let x1 = -Infinity + let y1 = -Infinity + for (const [cx, cy] of [[h.x, h.y], [h.x + h.w, h.y], [h.x, h.y + h.h], [h.x + h.w, h.y + h.h]]) { + const qx = cx - px + const qy = (cy - py) * sy + const X = px + qx * c - qy * n + dx + const Y = py + qx * n + qy * c + dy + x0 = Math.min(x0, X) + y0 = Math.min(y0, Y) + x1 = Math.max(x1, X) + y1 = Math.max(y1, Y) + } + const bx0 = Math.max(0, Math.floor(x0) - 1) + const by0 = Math.max(0, Math.floor(y0) - 1) + const bx1 = Math.min(W - 1, Math.ceil(x1) + 1) + const by1 = Math.min(H - 1, Math.ceil(y1) + 1) + const d = h.rgba + for (let y = by0; y <= by1; y++) + for (let x = bx0; x <= bx1; x++) { + // Back from the screen to the sprite at rest. + const qx = x + 0.5 - px - dx + const qy = y + 0.5 - py - dy + const sx = px + qx * c + qy * n - h.x - 0.5 + const sY = py + (-qx * n + qy * c) / sy - h.y - 0.5 + const ix = Math.floor(sx) + const iy = Math.floor(sY) + if (ix < -1 || iy < -1 || ix >= h.w || iy >= h.h) continue + const fx = sx - ix + const fy = sY - iy + let r = 0 + let g = 0 + let b = 0 + let a = 0 + for (let q = 0; q < 4; q++) { + const u = ix + (q & 1) + const v = iy + (q >> 1) + if (u < 0 || v < 0 || u >= h.w || v >= h.h) continue + const k = (q & 1 ? fx : 1 - fx) * (q >> 1 ? fy : 1 - fy) + const o = (v * h.w + u) * 4 + r += d[o] * k + g += d[o + 1] * k + b += d[o + 2] * k + a += d[o + 3] * k + } + if (a <= 0 && r + g + b <= 0) continue + const o = (y * W + x) * 3 + hdr[o] = hdr[o] * (1 - a) + r + hdr[o + 1] = hdr[o + 1] * (1 - a) + g + hdr[o + 2] = hdr[o + 2] * (1 - a) + b + } + return [bx0, by0, bx1, by1] +} + +// Churned water around the carrier, from row y0 down to the bottom between x0 and x1. Its shape comes from mapping each +// pixel onto the water around the hull (u across, in half-beams from its middle at cx; v along, 0 at the stern and 1 at +// the bow): foam runs out from under the deck edges, flares off the bow and spreads back from the stern in a churned +// trail and two arms. Its texture is pinned to the sea's own ground (see buildSea), so it streams back past the ship at +// the sea's pace. Returns the box it drew in. +function wake(hdr: Float32Array, W: number, H: number, A: number, x0: number, x1: number, y0: number, cx: number, dy: number, flow: number, foam: RGB, strength: number) { + const bx0 = Math.max(0, x0) + const bx1 = Math.min(W - 1, x1) + const by0 = Math.max(0, y0) + const depth = STERN - VANISH - WATERLINE * BEAM + for (let y = by0; y < H; y++) { + const z = depth / Math.max(0.01, (y + 0.5 - dy) / H - VANISH) + const v = (z - 1) / BOW + if (v > 1.2) continue + // The sea's rows slide toward the viewer at a pace proportional to their distance, so the foam is pinned to the + // log of the distance, slid along by flow. + const ground = (CAMERA * FOCAL) / Math.max(0.01, (y + 0.5) / H - HORIZON) + const tv = (Math.log(ground) + flow) * 66 + for (let x = bx0; x <= bx1; x++) { + const u = (((x + 0.5 - cx) / H) * z) / BEAM + const au = Math.abs(u) + // Along the sides, out past the deck's overhang, widening toward the stern. + const outer = 1.15 + 0.3 * clamp(1 - v, 0, 1.3) + const side = v < 1.02 ? smoothstep(0.84, 0.95, au) * smoothstep(outer, outer - 0.2, au) * (0.55 + 0.45 * smoothstep(0.6, 0, v)) : 0 + // The bow wave curling out from the stem. + const bow = Math.exp(-(((v - 1.04) / 0.07) ** 2)) * smoothstep(0.3, 0.75, au) * smoothstep(1.55, 1.1, au) + // Behind the stern: churned water across the beam, and two arms spreading outward. + const churn = smoothstep(0.02, -0.04, v) * smoothstep(1 - v * 0.8, 0.5, au) + const arms = v < 0.1 ? Math.exp(-(((au - (1 - v * 1.8)) / 0.14) ** 2)) : 0 + const f = Math.max(side, bow, churn, arms) + if (f <= 0.01) continue + const tex = foamAt(((x + 0.5) / H - A / 2) * ground * 90, tv) + const a = clamp(f * (0.15 + 1.4 * smoothstep(0.35, 0.6, tex)), 0, 1) * strength + const o = (y * W + x) * 3 + hdr[o] += (foam[0] - hdr[o]) * a + hdr[o + 1] += (foam[1] - hdr[o + 1]) * a + hdr[o + 2] += (foam[2] - hdr[o + 2]) * a + } + } + return [bx0, by0, bx1, H - 1] +} + +// A wrapping tile of foam blobs, sampled bilinearly. +const FOAM_N = 64 +const FOAM = (() => { + const tex = new Float32Array(FOAM_N * FOAM_N) + // Soft blobs of many sizes, measured with wrapped distances so the tile repeats seamlessly. + for (let k = 0; k < 70; k++) { + const cx = hash(k * 3.7) * FOAM_N + const cy = hash(k * 5.3 + 1) * FOAM_N + const r = 2 + hash(k * 7.1 + 2) * 6 + for (let y = 0; y < FOAM_N; y++) + for (let x = 0; x < FOAM_N; x++) { + const dx = Math.min(Math.abs(x - cx), FOAM_N - Math.abs(x - cx)) + const dy = Math.min(Math.abs(y - cy), FOAM_N - Math.abs(y - cy)) + tex[y * FOAM_N + x] += Math.exp(-(dx * dx + dy * dy) / (r * r)) + } + } + let lo = Infinity + let hi = -Infinity + for (const v of tex) { + lo = Math.min(lo, v) + hi = Math.max(hi, v) + } + return tex.map((v) => (v - lo) / (hi - lo)) +})() + +function foamAt(x: number, y: number) { + const ix = Math.floor(x) + const iy = Math.floor(y) + const fx = x - ix + const fy = y - iy + const x0 = ix & (FOAM_N - 1) + const y0 = iy & (FOAM_N - 1) + const x1 = (x0 + 1) & (FOAM_N - 1) + const y1 = (y0 + 1) & (FOAM_N - 1) + const top = FOAM[y0 * FOAM_N + x0] + (FOAM[y0 * FOAM_N + x1] - FOAM[y0 * FOAM_N + x0]) * fx + const bottom = FOAM[y1 * FOAM_N + x0] + (FOAM[y1 * FOAM_N + x1] - FOAM[y1 * FOAM_N + x0]) * fx + return top + (bottom - top) * fy +} + +// The height of the sea's row d below the horizon: rows grow toward the viewer, though never thinner than a sliver. +function rowHeight(d: number) { + return Math.max(0.0035, d * 0.17 + 0.003) +} + +// For each pixel of a W x H grid sampled two by two, its commonest triangle, the next, and how much of the next. +function resolveSamples(ids: Int32Array, W: number, H: number, idA: Int32Array, idB: Int32Array, mix: Float32Array) { + const SW = W * 2 + for (let y = 0; y < H; y++) + for (let x = 0; x < W; x++) { + const o = y * 2 * SW + x * 2 + const s0 = Math.max(0, ids[o]) + const s1 = Math.max(0, ids[o + 1]) + const s2 = Math.max(0, ids[o + SW]) + const s3 = Math.max(0, ids[o + SW + 1]) + const second = s1 !== s0 ? s1 : s2 !== s0 ? s2 : s3 + const n0 = 1 + (s1 === s0 ? 1 : 0) + (s2 === s0 ? 1 : 0) + (s3 === s0 ? 1 : 0) + const n1 = second === s0 ? 0 : (s1 === second ? 1 : 0) + (s2 === second ? 1 : 0) + (s3 === second ? 1 : 0) + const i = y * W + x + idA[i] = n0 >= n1 ? s0 : second + idB[i] = n0 >= n1 ? second : s0 + mix[i] = Math.min(n0, n1) / 4 + } +} + +// Zips two rows of vertex indices, each sorted by x, into a strip of triangles. +function stitch(a: number[], b: number[], xs: number[], tris: number[]) { + let i = 0 + let j = 0 + while (i < a.length - 1 || j < b.length - 1) { + if (j >= b.length - 1 || (i < a.length - 1 && xs[a[i + 1]] < xs[b[j + 1]])) { + tris.push(a[i], a[i + 1], b[j]) + i++ + continue + } + tris.push(a[i], b[j + 1], b[j]) + j++ + } +} + +// Writes id into every sample of a W x H grid whose center lies inside the triangle. +function rasterTri(ids: Int32Array, W: number, H: number, ax: number, ay: number, bx: number, by: number, cx: number, cy: number, id: number) { + const x0 = Math.max(0, Math.floor(Math.min(ax, bx, cx))) + const x1 = Math.min(W - 1, Math.ceil(Math.max(ax, bx, cx))) + const y0 = Math.max(0, Math.floor(Math.min(ay, by, cy))) + const y1 = Math.min(H - 1, Math.ceil(Math.max(ay, by, cy))) + const area = (bx - ax) * (cy - ay) - (by - ay) * (cx - ax) + if (area === 0) return + const sign = area > 0 ? 1 : -1 + for (let y = y0; y <= y1; y++) { + const py = y + 0.5 + for (let x = x0; x <= x1; x++) { + const px = x + 0.5 + const w0 = ((bx - ax) * (py - ay) - (by - ay) * (px - ax)) * sign + const w1 = ((cx - bx) * (py - by) - (cy - by) * (px - bx)) * sign + const w2 = ((ax - cx) * (py - cy) - (ay - cy) * (px - cx)) * sign + if (w0 >= 0 && w1 >= 0 && w2 >= 0) ids[y * W + x] = id + } + } +} + +// Shades every facet from its tilted normal (see shadeSea), into colors. +function shadeFacets(X: Float32Array, Z: Float32Array, Y: Float32Array, tris: Int32Array, colors: Float32Array, sky: Float32Array, H: number, ox: number, oy: number, deep: RGB, lit: RGB, spec: RGB, glitter: number, spread: readonly [number, number], storm: RGB, gloom: number) { + const top = Math.ceil(HORIZON * H) - 1 + for (let t = 0, f = 0; t < tris.length; t += 3, f += 3) { + const a = tris[t] + const b = tris[t + 1] + const c = tris[t + 2] + const e1x = X[b] - X[a] + const e1y = Y[b] - Y[a] + const e1z = Z[b] - Z[a] + const e2x = X[c] - X[a] + const e2y = Y[c] - Y[a] + const e2z = Z[c] - Z[a] + let nx = e1y * e2z - e1z * e2y + let ny = e1z * e2x - e1x * e2z + let nz = e1x * e2y - e1y * e2x + if (ny < 0) { + nx = -nx + ny = -ny + nz = -nz + } + // Slivers where rows of different spacing meet can come out nearly vertical; no facet tilts more than a wave would. + const tilt = Math.hypot(nx, nz) / Math.max(1e-9, ny) + if (tilt > 0.7) { + nx *= 0.7 / tilt + nz *= 0.7 / tilt + } + const nl = Math.hypot(nx, ny, nz) || 1 + nx /= nl + ny /= nl + nz /= nl + const px = (X[a] + X[b] + X[c]) / 3 + const pz = (Z[a] + Z[b] + Z[c]) / 3 + let vx = -px + let vy = CAMERA + let vz = -pz + const vl = Math.hypot(vx, vy, vz) + vx /= vl + vy /= vl + vz /= vl + const ndv = Math.max(0, nx * vx + ny * vy + nz * vz) + const rx = 2 * ndv * nx - vx + const ry = 2 * ndv * ny - vy + const rz = 2 * ndv * nz - vz + // The sky the facet mirrors, looked up by the row the reflected ray meets. + const row = ry <= 0 || rz <= 0 ? top : clamp(Math.round((HORIZON - (FOCAL * ry) / rz) * H), 0, top) + const fres = 0.12 + 0.88 * (1 - ndv) ** 3 + // Glitter where the reflected ray meets the sky near the sun or moon: a narrow lobe across and a long one up and + // down, so the facets catching it line up into a path down the water. + const gx = rz > 0.01 ? (FOCAL * rx) / rz - ox : 9 + const gy = rz > 0.01 ? HORIZON - (FOCAL * ry) / rz - oy : 9 + // Big facets near the viewer flash less, so they don't flicker whole cells. + const s = Math.exp(-((gx / spread[0]) ** 2) - (gy / spread[1]) ** 2) * glitter * (0.3 + 0.7 * smoothstep(2, 6, pz)) + const diffuse = clamp(0.5 + nx * 1.6 - nz * 0.8, 0, 1) + for (let k = 0; k < 3; k++) { + const body = deep[k] + (lit[k] - deep[k]) * diffuse + // Glitter replaces the water's own color rather than adding to it, so the path keeps the sun or moon's hue. + const v = (body * (1 - fres) + sky[row * 3 + k] * fres * 0.7) * (1 - Math.min(1, s) * 0.75) + spec[k] * s + colors[f + k] = v + (storm[k] - v) * gloom + } + } +} + +// Paints the sea's rows from the facet colors: each pixel mixes its two triangles, shows only as much as isn't covered +// by what stands in the sea, and adds any still glow. +function fillSea(hdr: Float32Array, W: number, y0: number, idA: Int32Array, idB: Int32Array, mix: Float32Array, open: Float32Array | undefined, colors: Float32Array, glow: Float32Array | undefined) { + const base = y0 * W * 3 + for (let i = 0; i < idA.length; i++) { + const w = open ? open[i] : 1 + if (w <= 0) continue + const a = idA[i] * 3 + const b = idB[i] * 3 + const k = mix[i] + const o = base + i * 3 + for (let c = 0; c < 3; c++) { + const v = colors[a + c] + (colors[b + c] - colors[a + c]) * k + (glow ? glow[i * 3 + c] : 0) + hdr[o + c] += (v - hdr[o + c]) * w + } + } +} + +// A soft line from head to tail, fading toward the tail, mixed over the scene; for contrails and rain. +function trail(hdr: Float32Array, W: number, H: number, hx: number, hy: number, tx: number, ty: number, width: number, color: RGB, k: number) { + const dx = tx - hx + const dy = ty - hy + const len2 = dx * dx + dy * dy || 1 + const x0 = Math.max(0, Math.floor(Math.min(hx, tx) - width - 1)) + const x1 = Math.min(W - 1, Math.ceil(Math.max(hx, tx) + width + 1)) + const y0 = Math.max(0, Math.floor(Math.min(hy, ty) - width - 1)) + const y1 = Math.min(H - 1, Math.ceil(Math.max(hy, ty) + width + 1)) + for (let y = y0; y <= y1; y++) + for (let x = x0; x <= x1; x++) { + const px = x + 0.5 - hx + const py = y + 0.5 - hy + const t = clamp((px * dx + py * dy) / len2, 0, 1) + const d = Math.hypot(px - dx * t, py - dy * t) + const a = Math.max(0, 1 - d / (width * (1 + t))) * (1 - t) * k + if (a <= 0) continue + const o = (y * W + x) * 3 + hdr[o] += (color[0] - hdr[o]) * a + hdr[o + 1] += (color[1] - hdr[o + 1]) * a + hdr[o + 2] += (color[2] - hdr[o + 2]) * a + } +} + +export const wings: Wallpaper = { + id: "wings", + name: "Wings", + description: "An aircraft carrier sailing a low-poly sea below a volcano island, from Anomaly Arcade's Wings", + collection: "Arcade", + activity: { + calm: "The carrier underway on faceted swells, its wake, glittering water, deck lights and drifting clouds", + lively: "Adds launches and landings, jets circling and crossing in formation, a helicopter, deck crew, gulls, boats and an escort", + teeming: "A busy sky and sea: launches every few seconds, formations, an E-2, a tanker, cargo planes, helicopters, the A-10 and B-2, birds, escorts, dolphins and a whale", + }, + scrim: { + day: [ + [16, 40, 84], + [22, 52, 86], + [8, 36, 66], + ], + sunset: [ + [26, 28, 70], + [66, 36, 60], + [18, 22, 56], + ], + night: [ + [3, 6, 18], + [4, 9, 24], + [3, 6, 18], + ], + }, + create: (settings) => new Wings(settings), +}