diff --git a/README.md b/README.md index 87f5abc..fd204df 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,17 @@ The egg got out. Everything on the other side is wireframe and green. A 3D runner-platformer in the browser: roll east down a course that builds -itself, jump the holes, weave the agents, take the bits. The egg is the one -from [Marc](https://github.com/h1ddenpr0cess20/marc) — same shell profile, -drawn in lines instead of ceramic. +itself, jump the holes, weave the agents, take the bits. + +The egg is [Marc's](https://github.com/h1ddenpr0cess20/marc), as he is — the +same 128×96 shell through the same `shapeEgg` profile, the same 900-speckle +cream skin, the same physical material with its clearcoat and sheen, lit by his +studio rig. He is the only thing in here that is not made of lines, which is the +point. + +He stays upright, too. The silhouette — fat end down, narrow end up — is the +asset, so he rocks and turns on the spot instead of tumbling end over end, and +the squash spring is allowed to flatten him but barely to stretch him. ![Eggscape the Matrix in a desktop browser](docs/screenshots/desktop.png) @@ -58,14 +66,17 @@ src/ course.js The course, laid a pattern at a time, ahead of the egg player.js Gravity, lanes, jump, coyote time, landings tuning.js Every number the run is tuned by — the course reads it too - shape.js Marc's egg profile, and the wireframe drawn from it + shape.js Marc's egg profile, verbatim rng.js A seeded stream, so a seed is a course motion.js The spring and the chase everything eases on emitter.js render/ three.js. Reads snapshots, owns no game state - scene.js Renderer, camera, fog, backdrop. No lights — it is all lines + scene.js Renderer, camera, fog, backdrop, and the light for the egg view.js Snapshot → scene graph, and the chase camera - egg.js The shell: a black fill, and the lines over it + egg.js Marc, fitted to the collider + shell.js His geometry and material, carried over as they are + skin.js His speckled cream, painted onto a canvas + environment.js His studio, turned down and turned green props.js Slabs, agents, bits, and the grid under the void rain.js The backdrop, painted on a canvas materials.js Four shared line materials diff --git a/docs/screenshots/desktop.png b/docs/screenshots/desktop.png index 7f2f878..13a22c5 100644 Binary files a/docs/screenshots/desktop.png and b/docs/screenshots/desktop.png differ diff --git a/docs/screenshots/mobile.png b/docs/screenshots/mobile.png index 8df03e1..400b8d4 100644 Binary files a/docs/screenshots/mobile.png and b/docs/screenshots/mobile.png differ diff --git a/docs/screenshots/title.png b/docs/screenshots/title.png index 9632963..af29158 100644 Binary files a/docs/screenshots/title.png and b/docs/screenshots/title.png differ diff --git a/src/core/shape.js b/src/core/shape.js index 17b4e6b..4909e83 100644 --- a/src/core/shape.js +++ b/src/core/shape.js @@ -1,63 +1,19 @@ /** - * The egg profile, lifted from Marc (github.com/h1ddenpr0cess20/marc): a unit - * sphere's vertices pushed into a shell — narrower at the top, a touch longer - * than it is wide. + * The egg profile, verbatim from Marc (github.com/h1ddenpr0cess20/marc, + * src/client/egg/shell.js): a unit sphere's vertices pushed into a shell — + * narrower at the top, a touch longer than it is wide. Every egg in here is + * this function's output; nothing redraws it by hand. */ -export const MARC_PROFILE = { taper: 0.075, curve: 0.055, girth: 0.84, stretch: 1.03, lift: 0.01 }; - -/** - * The same shell, pushed. Marc's profile is subtle because his shading sells - * it; a wireframe has no shading, and a subtle egg in wireframe is a globe. - */ -export const WIRE_PROFILE = { taper: 0.19, curve: 0.1, girth: 0.76, stretch: 1.2, lift: 0.01 }; - -export function shapeEgg(positions, profile = MARC_PROFILE) { - const { taper, curve, girth, stretch, lift } = profile; +export function shapeEgg(positions) { for (let i = 0; i < positions.length; i += 3) { const y = positions[i + 1]; - const narrow = girth * (1 - taper * y - curve * y * y); - positions[i] *= narrow; - positions[i + 2] *= narrow; - positions[i + 1] = y * stretch + lift; + const taper = 1 - 0.075 * y - 0.055 * y * y; + positions[i] *= 0.84 * taper; + positions[i + 2] *= 0.84 * taper; + positions[i + 1] = y * 1.03 + 0.01; } return positions; } -/** A point on the unit sphere: u around, v from pole to pole, both 0..1. */ -export function spherePoint(u, v) { - const phi = u * Math.PI * 2; - const theta = v * Math.PI; - const r = Math.sin(theta); - return [r * Math.cos(phi), Math.cos(theta), r * Math.sin(phi)]; -} - -/** - * The egg as line segments — meridians and latitude rings, drawn on purpose - * rather than handed to a wireframe helper, because a helper triangulates and - * what comes back is a mesh of diagonals instead of a shell. - */ -export function eggWireframe({ meridians = 12, rings = 5, steps = 24, arc = 28, profile = WIRE_PROFILE } = {}) { - const points = []; - const push = (a, b) => points.push(a[0], a[1], a[2], b[0], b[1], b[2]); - - for (let m = 0; m < meridians; m++) { - const u = m / meridians; - for (let s = 0; s < steps; s++) { - push(spherePoint(u, s / steps), spherePoint(u, (s + 1) / steps)); - } - } - - for (let r = 1; r <= rings; r++) { - const v = r / (rings + 1); - for (let s = 0; s < arc; s++) { - push(spherePoint(s / arc, v), spherePoint((s + 1) / arc, v)); - } - } - - return shapeEgg(new Float32Array(points), profile); -} - /** How tall the shaped shell stands, for fitting it to the collider. */ -export function eggHeight(profile = WIRE_PROFILE) { - return 2 * profile.stretch; -} +export const EGG_HEIGHT = 2 * 1.03; diff --git a/src/core/tuning.js b/src/core/tuning.js index edda00d..cfdde6a 100644 --- a/src/core/tuning.js +++ b/src/core/tuning.js @@ -36,7 +36,7 @@ export const COYOTE = 0.1; export const JUMP_BUFFER = 0.12; export const LANE_CHASE = 12; -export const PLAYER = { radius: 0.44, height: 1.12 }; +export const PLAYER = { radius: 0.5, height: 1.4 }; export const HAZARD = { halfWidth: 0.55, halfDepth: 0.45, height: 0.95 }; export const BIT = { reach: 0.95, lift: 0.75 }; diff --git a/src/render/egg.js b/src/render/egg.js index 7248c2f..e3ae4e0 100644 --- a/src/render/egg.js +++ b/src/render/egg.js @@ -1,40 +1,34 @@ import * as THREE from 'three'; -import { eggHeight, eggWireframe, shapeEgg, WIRE_PROFILE } from '../core/shape.js'; +import { EGG_HEIGHT } from '../core/shape.js'; import { PLAYER } from '../core/tuning.js'; -import { fill, segments, wire } from './materials.js'; +import { segments, wire } from './materials.js'; +import { createShell } from './shell.js'; import { THEME } from './theme.js'; /** The shaped shell has its own height; the egg has to fit the collider. */ -export const EGG_SCALE = PLAYER.height / eggHeight(); -const WIRE = { meridians: 13, rings: 5, steps: 26, arc: 30 }; - +export const EGG_SCALE = PLAYER.height / EGG_HEIGHT; + +/** + * Marc, as he is, in a world that is not. The shell is the asset — same + * geometry, same speckled skin, same physical material — and the only thing + * added is a rim of green light around it, because the lighting in here is + * green and the shell should admit it. + */ export function createEgg() { const egg = new THREE.Group(); egg.name = 'egg'; - const solid = new THREE.SphereGeometry(0.97, 20, 14); - shapeEgg(solid.attributes.position.array, WIRE_PROFILE); - solid.computeVertexNormals(); - const shell = new THREE.Mesh(solid, fill(THEME.void)); - shell.name = 'shell'; - /** The fill goes down first and writes depth; the lines, which do not, - * then draw over the front of it and get depth-tested off the back. */ - shell.renderOrder = -1; - - const lines = segments(eggWireframe(WIRE), wire(THEME.wire)); - lines.name = 'wireframe'; - - const halo = segments(eggWireframe({ meridians: 7, rings: 2, steps: 16, arc: 20 }), wire(THEME.glow, 0.3)); - halo.scale.setScalar(1.06); + const shell = createShell(); const body = new THREE.Group(); - body.add(shell, lines, halo); body.name = 'body'; + body.add(shell.mesh); + egg.add(body); egg.scale.setScalar(EGG_SCALE); - return { object: egg, body, lines, halo }; + return { object: egg, body, shell }; } /** The ring the egg casts on whatever it is standing over. */ diff --git a/src/render/environment.js b/src/render/environment.js new file mode 100644 index 0000000..99cce37 --- /dev/null +++ b/src/render/environment.js @@ -0,0 +1,54 @@ +import * as THREE from 'three'; + +/** + * Marc's studio, verbatim from src/client/egg/environment.js — a warm sky, a + * dark ground, and one soft window in it. The world outside is green; the + * shell is lit by the room it was made in, because that is what makes it read + * as ceramic instead of as a pale blob. + */ +export function buildEnvironment(scene, renderer) { + try { + const c = document.createElement('canvas'); + c.width = 64; c.height = 32; + const ctx = c.getContext('2d'); + const g = ctx.createLinearGradient(0, 0, 0, 32); + g.addColorStop(0, '#fff6e8'); g.addColorStop(0.5, '#9aa0ad'); + g.addColorStop(0.56, '#33302c'); g.addColorStop(1, '#14120f'); + ctx.fillStyle = g; ctx.fillRect(0, 0, 64, 32); + ctx.fillStyle = 'rgba(255,247,232,0.95)'; ctx.beginPath(); + ctx.ellipse(20, 6, 12, 5, 0, 0, Math.PI * 2); ctx.fill(); + const tex = new THREE.Texture(c); + tex.mapping = THREE.EquirectangularReflectionMapping; + tex.colorSpace = THREE.SRGBColorSpace; + tex.needsUpdate = true; + const pmrem = new THREE.PMREMGenerator(renderer); + scene.environment = pmrem.fromEquirectangular(tex).texture; + pmrem.dispose(); tex.dispose(); + } catch { + } +} + +/** + * The neutral studio the stage lights Marc with, at the same intensities and + * from the same directions (three-d-stage.js `_boot`), minus the shadow map — + * a green ring on the slab does that job here, and reads better at speed. + * + * The rig travels with the egg, so the light on the shell is the same at 400 + * metres as it is at the start line. + */ +export function buildLights(scene) { + const rig = new THREE.Group(); + rig.name = 'studio'; + + const hemi = new THREE.HemisphereLight(0xffffff, 0xd8d2c4, 1.0); + + const key = new THREE.DirectionalLight(0xffffff, 2.2); + key.position.set(4, 7, 5); + + const fill = new THREE.DirectionalLight(0xfff4e6, 0.5); + fill.position.set(-5, 3, -4); + + rig.add(hemi, key, key.target, fill, fill.target); + scene.add(rig); + return rig; +} diff --git a/src/render/scene.js b/src/render/scene.js index e29a442..5991dfa 100644 --- a/src/render/scene.js +++ b/src/render/scene.js @@ -1,11 +1,13 @@ import * as THREE from 'three'; +import { buildEnvironment, buildLights } from './environment.js'; import { createRain } from './rain.js'; import { THEME } from './theme.js'; /** - * Renderer, camera, fog, backdrop. No lights: everything in here is either a - * line or a black fill, so there is nothing for a light to do. + * Renderer, camera, fog, backdrop — and the one studio's worth of light in + * here, which exists for the egg alone. Everything else is a line or a black + * fill and would look the same in the dark. */ export function createScene(canvas) { const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, powerPreference: 'high-performance' }); @@ -15,9 +17,14 @@ export function createScene(canvas) { const scene = new THREE.Scene(); scene.fog = new THREE.Fog(THEME.void, 26, 108); - const camera = new THREE.PerspectiveCamera(64, 1, 0.1, 260); + /** 52°, not the 64° this started on: a wide lens stretches whatever sits + * away from the middle of the frame, and what sits there is the egg. */ + const camera = new THREE.PerspectiveCamera(52, 1, 0.1, 260); camera.position.set(0, 3.2, -8.4); + const studio = buildLights(scene); + buildEnvironment(scene, renderer); + const rain = createRain(); if (rain) { scene.background = rain.texture; @@ -30,7 +37,7 @@ export function createScene(canvas) { renderer.setSize(w, h, false); camera.aspect = w / h; /** A tall window needs a taller lens, or the course shrinks to a thread. */ - camera.fov = camera.aspect < 1 ? 76 : 64; + camera.fov = camera.aspect < 1 ? 64 : 52; camera.updateProjectionMatrix(); } @@ -41,6 +48,7 @@ export function createScene(canvas) { renderer, scene, camera, + studio, resize, tick(dt) { rain?.update(dt); }, render() { renderer.render(scene, camera); }, diff --git a/src/render/shell.js b/src/render/shell.js new file mode 100644 index 0000000..2467ff3 --- /dev/null +++ b/src/render/shell.js @@ -0,0 +1,39 @@ +import * as THREE from 'three'; + +import { shapeEgg } from '../core/shape.js'; +import { createShellSkin } from './skin.js'; + +/** + * Marc's shell, verbatim from src/client/egg/shell.js: the same 128×96 sphere + * pushed through the same profile, wearing the same physical material — + * speckled cream, a bump off the speckles, clearcoat and sheen on top. + * + * It is the one thing in this world that is not made of lines, which is the + * point: the egg is the thing that does not belong here. + */ +export function createShell() { + const skin = createShellSkin(THREE); + + const geometry = new THREE.SphereGeometry(1, 128, 96); + shapeEgg(geometry.attributes.position.array); + geometry.computeVertexNormals(); + + const material = new THREE.MeshPhysicalMaterial({ + name: 'eggshell', + color: new THREE.Color(skin.map ? '#ffffff' : '#f0e3cd'), + map: skin.map, + bumpMap: skin.bumpMap, + bumpScale: 0.7, + roughness: 0.52, + metalness: 0, + clearcoat: 0.35, + clearcoatRoughness: 0.6, + sheen: 0.4, + sheenColor: new THREE.Color('#fff2dd'), + }); + + const mesh = new THREE.Mesh(geometry, material); + mesh.name = 'shell'; + + return { mesh, geometry, material }; +} diff --git a/src/render/skin.js b/src/render/skin.js new file mode 100644 index 0000000..f406a78 --- /dev/null +++ b/src/render/skin.js @@ -0,0 +1,58 @@ +/** + * The speckled cream skin, verbatim from Marc (src/client/egg/skin.js): + * painted once onto a canvas, a colour map and a bump map off the same + * speckles. + */ +const WIDTH = 2048; +const HEIGHT = 1024; +const SPECKLES = 900; + +const INERT = { map: null, bumpMap: null }; + +function canvas2d(width, height) { + if (typeof document === 'undefined') return null; + const el = document.createElement('canvas'); + el.width = width; + el.height = height; + const ctx = el.getContext('2d'); + return ctx ? { el, ctx } : null; +} + +export function createShellSkin(THREE, { width = WIDTH, height = HEIGHT, random = Math.random } = {}) { + const colour = canvas2d(width, height); + const bump = canvas2d(width, height); + if (!colour || !bump) return INERT; + + const g = colour.ctx; + g.fillStyle = '#f0e3cd'; + g.fillRect(0, 0, width, height); + + const b = bump.ctx; + b.fillStyle = '#808080'; + b.fillRect(0, 0, width, height); + + for (let i = 0; i < SPECKLES; i++) { + const x = random() * width; + const y = random() * height; + const r = 1.5 + random() * 5.5; + const a = 0.05 + random() * 0.16; + const warm = random() < 0.5; + + g.fillStyle = warm ? `rgba(168,132,86,${a})` : `rgba(120,104,88,${a * 0.8})`; + g.beginPath(); + g.ellipse(x, y, r, r * 0.8, 0, 0, Math.PI * 2); + g.fill(); + + b.fillStyle = `rgba(90,90,90,${a * 1.6})`; + b.beginPath(); + b.ellipse(x, y, r, r * 0.8, 0, 0, Math.PI * 2); + b.fill(); + } + + const map = new THREE.CanvasTexture(colour.el); + map.colorSpace = THREE.SRGBColorSpace; + map.anisotropy = 4; + const bumpMap = new THREE.CanvasTexture(bump.el); + + return { map, bumpMap }; +} diff --git a/src/render/view.js b/src/render/view.js index 92db416..67fd5e6 100644 --- a/src/render/view.js +++ b/src/render/view.js @@ -14,7 +14,17 @@ const WIDE = { back: 6.8, up: 2.45, lead: 8, aim: 1.1 }; const TALL = { back: 5.8, up: 3.3, lead: 5, aim: 0.2 }; const CHASE = 6; const DRAW = { behind: 12, ahead: 130 }; -const ROLL_RADIUS = 0.5; +/** How hard the shell rocks while it runs, and how fast. */ +const ROCK = { amount: 0.075, speed: 11, lean: 0.12, spin: 0.5 }; + +/** + * The squash spring, and the step it is integrated at. It is explicit Euler, + * so a long frame does not slow it down — it blows it up: one 250ms hitch and + * the shell pins at its limits and stays there, wobbling between a pancake and + * a capsule. Sub-stepping is the whole fix, and it matters because the + * silhouette is the asset. + */ +const SQUASH = { k: 190, c: 11, step: 1 / 120, max: 0.4, stretch: -0.08 }; function createPool(scene, make) { const items = []; @@ -42,7 +52,7 @@ function createPool(scene, make) { * draws is derived from a snapshot — it holds no state the run depends on, so * a restart is a `reset()` and nothing more. */ -export function createView({ scene, camera }) { +export function createView({ scene, camera, studio }) { const egg = createEgg(); const shadow = createShadow(); const grid = createUnderGrid(); @@ -103,25 +113,43 @@ export function createView({ scene, camera }) { function syncEgg(snapshot, dt, time) { const { player, state, invulnerable } = snapshot; - spring(squash, 190, 11, dt, 0); - const s = clamp(squash.p, -0.45, 0.45); + for (let left = Math.min(dt, 0.25); left > 0; left -= SQUASH.step) { + spring(squash, SQUASH.k, SQUASH.c, Math.min(SQUASH.step, left), 0); + } + /** + * Squash freely, stretch barely. The rebound of a landing used to pull the + * shell into a capsule, and a stretched egg is not an egg — the silhouette + * is the asset, so the spring is only allowed to flatten it. + */ + const s = clamp(squash.p, SQUASH.stretch, SQUASH.max); egg.object.position.set(player.x, player.y + PLAYER.height / 2, player.z); + /** The studio travels with the shell, so the light on it never drifts. */ + studio?.position.set(player.x, player.y, player.z); egg.object.scale.set(1 + s * 0.4, 1 - s, 1 + s * 0.4); egg.object.scale.multiplyScalar(EGG_SCALE); - egg.object.rotation.z = clamp((laneX(player.lane) - player.x) * -0.5, -0.45, 0.45); - egg.body.rotation.x = player.z / ROLL_RADIUS; - egg.halo.rotation.y = time * 0.6; + /** + * The shell stays upright. An egg tumbling end over end is a shape you + * cannot read — half the time it is pointing at you — and the silhouette, + * fat end down, is the whole asset. So it rocks and it turns on the spot, + * the way Marc does, and leans into the lane it is moving to. + */ + const running = state === 'running'; + const rock = running && player.grounded ? Math.sin(time * ROCK.speed) * ROCK.amount : 0; + const drift = clamp((laneX(player.lane) - player.x) * -0.4, -0.35, 0.35); + + egg.object.rotation.set( + running ? ROCK.lean + (player.grounded ? 0 : -0.1) : 0, + egg.object.rotation.y + (running ? dt * ROCK.spin : 0), + rock + drift, + ); if (state === 'ready') { /** Nothing is running yet, so the egg does what Marc does: it rocks. */ egg.object.position.y += Math.sin(time * 1.4) * 0.05; egg.object.rotation.z = Math.sin(time * 0.9) * 0.09; - egg.body.rotation.x = Math.sin(time * 0.5) * 0.25; egg.object.rotation.y = time * 0.35; - } else { - egg.object.rotation.y = 0; } if (state === 'over') { diff --git a/src/styles.css b/src/styles.css index edf0a77..9396a2d 100644 --- a/src/styles.css +++ b/src/styles.css @@ -37,7 +37,7 @@ body::after { pointer-events: none; z-index: 3; background: - repeating-linear-gradient(rgba(0, 0, 0, 0) 0 2px, rgba(0, 0, 0, 0.28) 2px 4px), + repeating-linear-gradient(rgba(0, 0, 0, 0) 0 2px, rgba(0, 0, 0, 0.16) 2px 4px), radial-gradient(ellipse at center, rgba(0, 0, 0, 0) 45%, rgba(0, 0, 0, 0.75) 100%); mix-blend-mode: multiply; } diff --git a/test/shape.test.js b/test/shape.test.js index 752a690..1ec56f6 100644 --- a/test/shape.test.js +++ b/test/shape.test.js @@ -1,7 +1,20 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; -import { eggHeight, eggWireframe, MARC_PROFILE, shapeEgg, spherePoint, WIRE_PROFILE } from '../src/core/shape.js'; +import { EGG_HEIGHT, shapeEgg } from '../src/core/shape.js'; + +/** The same sphere the shell is built from, as a bare position array. */ +function sphere(segments = 24, rings = 18) { + const points = []; + for (let r = 0; r <= rings; r++) { + const theta = (r / rings) * Math.PI; + for (let s = 0; s < segments; s++) { + const phi = (s / segments) * Math.PI * 2; + points.push(Math.sin(theta) * Math.cos(phi), Math.cos(theta), Math.sin(theta) * Math.sin(phi)); + } + } + return new Float32Array(points); +} function bounds(positions) { const box = { minX: Infinity, maxX: -Infinity, minY: Infinity, maxY: -Infinity }; @@ -29,44 +42,32 @@ function waist(positions) { } describe('shape', () => { - it('puts a sphere point where the sphere is', () => { - assert.deepEqual(spherePoint(0, 0).map(Math.round), [0, 1, 0]); - assert.deepEqual(spherePoint(0, 1).map(Math.round), [0, -1, 0]); - const side = spherePoint(0, 0.5); - assert.ok(Math.abs(Math.hypot(side[0], side[1], side[2]) - 1) < 1e-9); + it('is Marc\'s profile, to the number', () => { + const point = shapeEgg(new Float32Array([1, 0.5, 1])); + const taper = 1 - 0.075 * 0.5 - 0.055 * 0.25; + assert.ok(Math.abs(point[0] - 0.84 * taper) < 1e-6); + assert.ok(Math.abs(point[2] - 0.84 * taper) < 1e-6); + assert.ok(Math.abs(point[1] - (0.5 * 1.03 + 0.01)) < 1e-6); }); - it('turns a sphere into something taller than it is wide', () => { - const sphere = new Float32Array([1, 0, 0, 0, 1, 0, 0, -1, 0, 0, 0, 1]); - const box = bounds(shapeEgg(sphere.slice())); - assert.ok(box.maxY - box.minY > (box.maxX - box.minX) * 2, 'the shell came out round'); + it('turns a sphere into a shell taller than it is wide', () => { + const box = bounds(shapeEgg(sphere())); + assert.ok(box.maxY - box.minY > box.maxX - box.minX, 'the shell came out round'); }); it('keeps the fat end at the bottom, which is what makes it an egg', () => { - for (const profile of [MARC_PROFILE, WIRE_PROFILE]) { - const { at } = waist(eggWireframe({ profile })); - assert.ok(at < 0, `the widest ring sat at ${at.toFixed(3)}, at or above the middle`); - } - }); - - it('pushes the wireframe profile further than the one Marc wears', () => { - const marc = waist(eggWireframe({ profile: MARC_PROFILE })); - const wire = waist(eggWireframe({ profile: WIRE_PROFILE })); - assert.ok(wire.at < marc.at, 'the wireframe waist should sit lower'); - assert.ok(wire.widest < marc.widest, 'and the wireframe shell should be slimmer'); + const { at } = waist(shapeEgg(sphere(64, 48))); + assert.ok(at < 0, `the widest ring sat at ${at.toFixed(3)}, at or above the middle`); }); it('reports the height the renderer scales the egg by', () => { - for (const profile of [MARC_PROFILE, WIRE_PROFILE]) { - const box = bounds(eggWireframe({ profile })); - assert.ok(Math.abs((box.maxY - box.minY) - eggHeight(profile)) < 1e-6); - } + const box = bounds(shapeEgg(sphere(32, 96))); + assert.ok(Math.abs((box.maxY - box.minY) - EGG_HEIGHT) < 1e-3); }); - it('draws lines in pairs, and only lines', () => { - const positions = eggWireframe({ meridians: 4, rings: 2, steps: 6, arc: 8 }); - assert.equal(positions.length % 6, 0, 'a segment needs two ends'); - assert.equal(positions.length / 6, 4 * 6 + 2 * 8); - assert.ok(positions.every(Number.isFinite)); + it('leaves the array it was handed, and hands it back', () => { + const positions = sphere(8, 6); + assert.equal(shapeEgg(positions), positions); + assert.ok([...positions].every(Number.isFinite)); }); });