Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 17 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand Down
Binary file modified docs/screenshots/desktop.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/mobile.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/title.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
64 changes: 10 additions & 54 deletions src/core/shape.js
Original file line number Diff line number Diff line change
@@ -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;
2 changes: 1 addition & 1 deletion src/core/tuning.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

Expand Down
36 changes: 15 additions & 21 deletions src/render/egg.js
Original file line number Diff line number Diff line change
@@ -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. */
Expand Down
54 changes: 54 additions & 0 deletions src/render/environment.js
Original file line number Diff line number Diff line change
@@ -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;
}
16 changes: 12 additions & 4 deletions src/render/scene.js
Original file line number Diff line number Diff line change
@@ -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' });
Expand All @@ -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;
Expand All @@ -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();
}

Expand All @@ -41,6 +48,7 @@ export function createScene(canvas) {
renderer,
scene,
camera,
studio,
resize,
tick(dt) { rain?.update(dt); },
render() { renderer.render(scene, camera); },
Expand Down
39 changes: 39 additions & 0 deletions src/render/shell.js
Original file line number Diff line number Diff line change
@@ -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 };
}
58 changes: 58 additions & 0 deletions src/render/skin.js
Original file line number Diff line number Diff line change
@@ -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 };
}
Loading