diff --git a/README.md b/README.md
index fd204df..b6b1a5a 100644
--- a/README.md
+++ b/README.md
@@ -34,7 +34,7 @@ No API keys, no server, no account. It is a static page and three.js.
|---|---|
| `A` `D` / `←` `→` | one lane, per press |
| `W` / `↑` / `space` | jump — again in the air for a flip |
-| `S` / `↓` | slam down |
+| `S` / `↓` | slam down — onto an agent to break it |
| `M` | audio |
On a phone: swipe for a lane, flick up to jump, flick down to slam, tap for a
@@ -42,9 +42,14 @@ jump.
Three shells. An agent costs one, so does the void, and either way the egg is
put back down and keeps going until the last one. A metre is a point, a bit is
-twenty-five, and speed climbs with distance — so the course gets harder because
+twenty-five, and an agent you come down on — in the air, slam on — is sixty and
+one fewer agent. Speed climbs with distance, so the course gets harder because
you are getting faster, and the gaps grow to match.
+The slam is the only move that answers back. Jump over an agent, put it on, and
+the landing goes through the deck and takes the agent in that lane with it —
+your lane only, and only a couple of metres of it, so it has to be aimed.
+
@@ -73,6 +78,7 @@ src/
render/ three.js. Reads snapshots, owns no game state
scene.js Renderer, camera, fog, backdrop, and the light for the egg
view.js Snapshot → scene graph, and the chase camera
+ rig.js Where that camera sits and what it looks at, as arithmetic
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
@@ -95,6 +101,14 @@ physics uses, and the tests check every seed against them. Slabs never overlap
in z, so there is no wall to run into — miss a jump and you meet the void,
which is a fair thing to lose to.
+One number in there is worth the warning it carries. `laneX` *descends* — lane
+0 sits at the highest x — because the egg runs towards +z and the camera chases
+it from behind, looking the same way, which mirrors the picture: world +x draws
+on the left of the screen. Written the intuitive way round, every lane control
+is backwards and nothing in the core notices. `test/rig.test.js` projects a
+lane through the real rig and checks which half of the frame it lands on, which
+is the only place the mistake is visible.
+
There is also an autopilot in `test/helpers/pilot.js`. It plays badly on
purpose — one frame of lookahead, no double jump — and the suite fails if it
cannot get a few hundred metres down a seed.
diff --git a/index.html b/index.html
index 1d2df72..2468029 100644
--- a/index.html
+++ b/index.html
@@ -29,7 +29,7 @@ Eggscapethe matrix
- AD lane
- W jump, twice to flip
- - S slam
+ - S slam — onto an agent to break it
- M audio
- swipe to move · tap to jump
diff --git a/src/core/course.js b/src/core/course.js
index a0da5f9..e7f32b7 100644
--- a/src/core/course.js
+++ b/src/core/course.js
@@ -1,5 +1,5 @@
import { createRng, intBelow, range } from './rng.js';
-import { AIRTIME, APEX, LANES, LANE_WIDTH, laneX, speedAt } from './tuning.js';
+import { AIRTIME, APEX, laneBounds, LANES, laneX, speedAt } from './tuning.js';
/** The runway starts behind the egg, so there is floor under it at t=0. */
const START_Z = -16;
@@ -55,8 +55,7 @@ export function createCourse({ seed = 1, difficultyRun = DIFFICULTY_RUN } = {})
y,
lane,
span,
- xMin: laneX(lane) - LANE_WIDTH / 2,
- xMax: laneX(lane + span - 1) + LANE_WIDTH / 2,
+ ...laneBounds(lane, span),
};
segments.push(seg);
cursor = seg.z1;
diff --git a/src/core/game.js b/src/core/game.js
index 3f15966..82f4156 100644
--- a/src/core/game.js
+++ b/src/core/game.js
@@ -8,6 +8,13 @@ import {
const STEP = 1 / 120;
const MAX_STEPS = 6;
+/**
+ * How far the landing of a slam reaches: a shock through the deck, not a
+ * blast. Narrower than a lane, so it only ever takes the agent the egg came
+ * down on — and short enough that it has to be aimed.
+ */
+const SMASH = { z: 2.2, x: 1.2, y: 1 };
+
function overlaps(player, hazard) {
return Math.abs(player.x - hazard.x) < PLAYER.radius + HAZARD.halfWidth
&& Math.abs(player.z - hazard.z) < PLAYER.radius + HAZARD.halfDepth
@@ -21,12 +28,28 @@ function within(player, bit) {
&& Math.abs(player.y + PLAYER.height / 2 - bit.y) < 1.15;
}
+/**
+ * Fold a frame's intent into whatever is still waiting to be spent. Lane
+ * presses add up, so a double tap crosses two lanes even when both taps landed
+ * inside one frame; a jump and a slam are flags, and pressing either twice
+ * before a tick is still one of it.
+ */
+function hold(into, intent) {
+ if (!intent) return into;
+ const out = into ?? { left: 0, right: 0, jump: false, dive: false };
+ out.left += Number(intent.left ?? 0);
+ out.right += Number(intent.right ?? 0);
+ out.jump = out.jump || Boolean(intent.jump);
+ out.dive = out.dive || Boolean(intent.dive);
+ return out;
+}
+
/**
* The run, with no pixels in it: course, egg, lives, score. Everything the
* renderer shows and the HUD reads is here, and nothing here knows either
* exists.
*
- * Events: 'start' 'jump' 'land' 'bit' 'hit' 'over'.
+ * Events: 'start' 'jump' 'land' 'bit' 'smash' 'hit' 'respawn' 'over'.
*/
export function createGame({ seed = 1, lives = LIVES } = {}) {
const emitter = createEmitter();
@@ -36,13 +59,17 @@ export function createGame({ seed = 1, lives = LIVES } = {}) {
let state = 'ready';
let livesLeft = lives;
let taken = 0;
+ let smashed = 0;
let distance = 0;
let invulnerable = 0;
let elapsed = 0;
let carry = 0;
+ let held = null;
function score() {
- return Math.floor(distance * SCORE.perMetre) + taken * SCORE.perBit;
+ return Math.floor(distance * SCORE.perMetre)
+ + taken * SCORE.perBit
+ + smashed * SCORE.perAgent;
}
function snapshot() {
@@ -52,6 +79,7 @@ export function createGame({ seed = 1, lives = LIVES } = {}) {
course,
lives: livesLeft,
bits: taken,
+ agents: smashed,
distance,
score: score(),
invulnerable,
@@ -59,15 +87,19 @@ export function createGame({ seed = 1, lives = LIVES } = {}) {
};
}
+ /** The only way a run ends. Setting `state` on its own left the page with
+ * no overlay, no best score, and an egg standing still. */
+ function finish() {
+ livesLeft = Math.max(0, livesLeft);
+ state = 'over';
+ emitter.emit('over', snapshot());
+ }
+
function damage(reason) {
livesLeft -= 1;
invulnerable = INVULNERABLE;
emitter.emit('hit', { reason, lives: livesLeft });
- if (livesLeft <= 0) {
- livesLeft = 0;
- state = 'over';
- emitter.emit('over', snapshot());
- }
+ if (livesLeft <= 0) finish();
}
function collect() {
@@ -82,6 +114,29 @@ export function createGame({ seed = 1, lives = LIVES } = {}) {
}
}
+ function shatter(hazard) {
+ hazard.hit = true;
+ smashed += 1;
+ emitter.emit('smash', { hazard, agents: smashed });
+ }
+
+ /**
+ * The landing of a slam, gone through the deck. Coming down at 24m/s crosses
+ * the whole of an agent in a couple of ticks, so catching one purely on the
+ * way through would be a window no hand can hit; the landing is what the
+ * player is aiming, so the landing is what breaks it.
+ */
+ function shockwave() {
+ for (const hazard of course.hazards) {
+ if (hazard.hit || hazard.z < player.z - SMASH.z) continue;
+ if (hazard.z > player.z + SMASH.z) break;
+ /** Its own lane, on its own slab — not one a storey up or down. */
+ if (Math.abs(hazard.x - player.x) > SMASH.x) continue;
+ if (Math.abs(hazard.y - player.y) > SMASH.y) continue;
+ shatter(hazard);
+ }
+ }
+
function struck() {
for (const hazard of course.hazards) {
if (hazard.hit || hazard.z < player.z - 2) continue;
@@ -104,6 +159,7 @@ export function createGame({ seed = 1, lives = LIVES } = {}) {
if (moved.jumped) emitter.emit('jump', { player });
if (moved.landed) emitter.emit('land', { player });
+ if (moved.slammed) shockwave();
collect();
@@ -111,18 +167,25 @@ export function createGame({ seed = 1, lives = LIVES } = {}) {
damage('void');
if (state === 'running') {
const seg = course.ensure(player.z + AHEAD).landingAfter(player.z);
- if (seg) respawn(player, seg);
- else state = 'over';
+ if (!seg) finish();
+ else {
+ respawn(player, seg);
+ emitter.emit('respawn', { player, seg });
+ }
}
return;
}
- if (invulnerable <= 0) {
- const hazard = struck();
- if (hazard) {
- hazard.hit = true;
- damage('agent');
- }
+ /**
+ * An agent met on the way down with the slam on is an agent broken, grace
+ * or no grace. That is what the slam is for, and the reason to spend a
+ * jump getting above one instead of weaving round it.
+ */
+ const hazard = invulnerable > 0 && !player.diving ? null : struck();
+ if (hazard && player.diving) shatter(hazard);
+ else if (hazard) {
+ hazard.hit = true;
+ damage('agent');
}
}
@@ -141,10 +204,13 @@ export function createGame({ seed = 1, lives = LIVES } = {}) {
state = 'running';
livesLeft = lives;
taken = 0;
+ smashed = 0;
distance = 0;
invulnerable = 0;
elapsed = 0;
carry = 0;
+ held = null;
+ tick.intent = null;
emitter.emit('start', snapshot());
return snapshot();
},
@@ -154,15 +220,21 @@ export function createGame({ seed = 1, lives = LIVES } = {}) {
* landing a landing whatever the display is doing; a tab that was in the
* background hands back a huge dt, and the clamp eats it rather than
* teleporting the egg through the floor.
+ *
+ * Intent is *held* until a tick spends it, which is not fussiness: a frame
+ * shorter than the step runs no tick at all, and a display faster than
+ * 120Hz has one of those every few frames. Handing intent straight to the
+ * first tick threw away a press each time — roughly one in six on a 144Hz
+ * screen, always the press you meant.
*/
advance(dt, intent) {
if (state !== 'running') return snapshot();
+ held = hold(held, intent);
carry = Math.min(carry + dt, STEP * MAX_STEPS);
- let first = intent;
while (carry >= STEP) {
carry -= STEP;
- tick.intent = first;
- first = null;
+ tick.intent = held;
+ held = null;
tick(STEP);
if (state !== 'running') break;
}
diff --git a/src/core/player.js b/src/core/player.js
index 72b1dd9..b847165 100644
--- a/src/core/player.js
+++ b/src/core/player.js
@@ -4,7 +4,7 @@ import {
LANES, LANE_CHASE, laneX, SNAP, speedAt, VOID_Y,
} from './tuning.js';
-const NONE = { left: false, right: false, jump: false, dive: false };
+const NONE = { left: 0, right: 0, jump: false, dive: false };
export function createPlayer({ z = 0, lane = 1, y = 0 } = {}) {
return {
@@ -37,8 +37,13 @@ export function advance(player, dt, intent, course) {
player.speed = speedAt(player.z);
player.z += player.speed * dt;
- if (act.left) player.lane = Math.max(0, player.lane - 1);
- if (act.right) player.lane = Math.min(LANES - 1, player.lane + 1);
+ /**
+ * Lanes move by however many presses arrived, not by one a tick: two taps
+ * inside a frame mean two lanes, which is what the hand that made them was
+ * asking for. A flag counts as the one press it is.
+ */
+ const step = Number(act.right ?? 0) - Number(act.left ?? 0);
+ if (step) player.lane = clamp(player.lane + step, 0, LANES - 1);
player.x = approach(player.x, laneX(player.lane), LANE_CHASE, dt);
player.buffer = act.jump ? JUMP_BUFFER : Math.max(0, player.buffer - dt);
@@ -65,6 +70,7 @@ export function advance(player, dt, intent, course) {
const ground = course.groundAt(player.x, player.z);
const was = player.grounded;
+ const slamming = player.diving;
let landed = false;
/**
@@ -83,7 +89,7 @@ export function advance(player, dt, intent, course) {
player.grounded = false;
}
- return { jumped, landed, fell: player.y < VOID_Y, ground };
+ return { jumped, landed, slammed: landed && slamming, fell: player.y < VOID_Y, ground };
}
/** Put a fallen egg back on the first slab still ahead of it. */
diff --git a/src/core/tuning.js b/src/core/tuning.js
index cfdde6a..915da20 100644
--- a/src/core/tuning.js
+++ b/src/core/tuning.js
@@ -7,9 +7,31 @@
export const LANES = 3;
export const LANE_WIDTH = 2;
-/** Lane index → world x. Lane 1 is the middle of a three-lane course. */
+/**
+ * Lane index → world x. Lane 1 is the middle of a three-lane course, and lane
+ * 0 is the one on the left of the screen.
+ *
+ * It descends, which looks wrong written down and is the only thing that plays
+ * right: the egg runs towards +z and the chase camera sits behind it looking
+ * the same way, so the view is mirrored — world +x draws on the left. An
+ * ascending mapping put lane 0 on the right, and `left` moved the egg right.
+ */
export function laneX(lane) {
- return (lane - (LANES - 1) / 2) * LANE_WIDTH;
+ return ((LANES - 1) / 2 - lane) * LANE_WIDTH;
+}
+
+/**
+ * The world x a run of lanes covers, low edge first. laneX descends, so the
+ * first lane holds the high edge and the two swap on the way out — everything
+ * that tests a point against a slab wants them the other way round.
+ */
+export function laneBounds(lane, span = 1) {
+ const near = laneX(lane);
+ const far = laneX(lane + span - 1);
+ return {
+ xMin: Math.min(near, far) - LANE_WIDTH / 2,
+ xMax: Math.max(near, far) + LANE_WIDTH / 2,
+ };
}
/**
@@ -48,7 +70,8 @@ export const SNAP = 0.55;
export const LIVES = 3;
export const INVULNERABLE = 1.6;
-export const SCORE = { perMetre: 1, perBit: 25 };
+/** A metre, a bit, and an agent brought down with a slam. */
+export const SCORE = { perMetre: 1, perBit: 25, perAgent: 60 };
/** How much course is kept live around the egg. */
export const AHEAD = 180;
diff --git a/src/main.js b/src/main.js
index fc00f24..d05e712 100644
--- a/src/main.js
+++ b/src/main.js
@@ -33,11 +33,19 @@ game.on('bit', () => {
view.kick(0.7);
sound.play('bit');
});
+game.on('smash', () => {
+ view.kick(2.2);
+ view.jolt(0.35);
+ sound.play('smash');
+});
game.on('hit', () => {
view.kick(2.6);
view.jolt(0.9);
sound.play('hit');
});
+/** The egg is put down metres further on, so the camera cuts rather than
+ * chases — it used to spend the landing somewhere behind, pointed at void. */
+game.on('respawn', () => view.snap());
game.on('over', (snapshot) => {
best = writeBest(snapshot.score);
hud.over(snapshot, best);
@@ -45,7 +53,10 @@ game.on('over', (snapshot) => {
});
function play() {
- if (game.state !== 'running') game.start(seed());
+ if (game.state === 'running') return;
+ game.start(seed());
+ /** The press that started the run is not also the run's first jump. */
+ input.take();
}
const input = createInput(window, { onConfirm: play });
diff --git a/src/render/rain.js b/src/render/rain.js
index e8ef4c0..93b3d18 100644
--- a/src/render/rain.js
+++ b/src/render/rain.js
@@ -39,7 +39,9 @@ export function createRain() {
update(dt) {
carry += dt;
if (carry < TICK) return;
- carry = 0;
+ /** Spend a tick, don't drop the remainder: zeroing it here cost the
+ * rain a fifth of its speed at 60fps, and more the faster the display. */
+ carry = Math.min(carry - TICK, TICK);
ctx.fillStyle = 'rgba(0, 0, 0, 0.16)';
ctx.fillRect(0, 0, SIZE, SIZE);
diff --git a/src/render/rig.js b/src/render/rig.js
new file mode 100644
index 0000000..b655f8d
--- /dev/null
+++ b/src/render/rig.js
@@ -0,0 +1,35 @@
+/**
+ * Where the chase camera sits and what it looks at, as plain arithmetic.
+ *
+ * It lives apart from the rest of the renderer so the framing can be asserted
+ * without a WebGL context, and there is one thing worth asserting: the camera
+ * sits *behind* the egg and looks the way the egg runs, down +z. That mirrors
+ * the view — world +x lands on the left of the screen — which is the whole
+ * reason `laneX` descends with the lane index.
+ */
+
+/**
+ * Two framings. A phone held upright has a narrow, tall window: the same
+ * camera puts half the screen in the sky, so it gets pulled in, lifted, and
+ * tilted down until the track fills the frame again.
+ */
+export const WIDE = { back: 6.8, up: 2.45, lead: 8, aim: 1.1 };
+export const TALL = { back: 5.8, up: 3.3, lead: 5, aim: 0.2 };
+
+export function rigFor(aspect) {
+ return aspect < 1 ? TALL : WIDE;
+}
+
+/** The seat: behind the egg, over it, leaning a little the way it is going. */
+export function seat(out, player, level, rig, shake = 0, rand = Math.random) {
+ return out.set(
+ player.x * 0.4 + (rand() - 0.5) * shake * 0.7,
+ Math.max(player.y, level - 0.8) + rig.up + (rand() - 0.5) * shake * 0.5,
+ player.z - rig.back,
+ );
+}
+
+/** And what it aims at: down the course, ahead of the egg. */
+export function focus(out, player, level, rig) {
+ return out.set(player.x * 0.55, level + rig.aim, player.z + rig.lead);
+}
diff --git a/src/render/view.js b/src/render/view.js
index 67fd5e6..2f265b6 100644
--- a/src/render/view.js
+++ b/src/render/view.js
@@ -4,14 +4,8 @@ import { approach, clamp, spring } from '../core/motion.js';
import { laneX, PLAYER } from '../core/tuning.js';
import { createEgg, createShadow, EGG_SCALE } from './egg.js';
import { createBit, createHazard, createSlab, createUnderGrid, disposeSlab } from './props.js';
+import { focus as aimAt, rigFor, seat } from './rig.js';
-/**
- * Two framings. A phone held upright has a narrow, tall window: the same
- * camera puts half the screen in the sky, so it gets pulled in, lifted, and
- * tilted down until the track fills the frame again.
- */
-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 };
/** How hard the shell rocks while it runs, and how fast. */
@@ -64,7 +58,7 @@ export function createView({ scene, camera, studio }) {
const squash = { p: 0, v: 0 };
const target = new THREE.Vector3();
- const focus = new THREE.Vector3();
+ const aim = new THREE.Vector3();
let level = 0;
let placed = false;
@@ -188,9 +182,18 @@ export function createView({ scene, camera, studio }) {
squash.v = 0;
tumble = 0;
shake = 0;
+ level = 0;
placed = false;
},
+ /**
+ * Cut to the egg rather than chase it. A respawn puts it down metres
+ * further on, and a camera that eases after it spends a second with the
+ * egg off the top of the frame — running blind, on a course that is not
+ * waiting.
+ */
+ snap() { placed = false; },
+
sync(snapshot, dt, time) {
const { course, player } = snapshot;
@@ -204,20 +207,15 @@ export function createView({ scene, camera, studio }) {
if (player.grounded) level = approach(level, player.y, 6, dt);
shake = approach(shake, 0, 6, dt);
- const rig = camera.aspect < 1 ? TALL : WIDE;
- target.set(
- player.x * 0.4 + (Math.random() - 0.5) * shake * 0.7,
- Math.max(player.y, level - 0.8) + rig.up + (Math.random() - 0.5) * shake * 0.5,
- player.z - rig.back,
- );
+ const rig = rigFor(camera.aspect);
+ seat(target, player, level, rig, shake);
if (placed) camera.position.lerp(target, 1 - Math.exp(-dt * CHASE));
else {
camera.position.copy(target);
placed = true;
}
- focus.set(player.x * 0.55, level + rig.aim, player.z + rig.lead);
- camera.lookAt(focus);
+ camera.lookAt(aimAt(aim, player, level, rig));
},
};
}
diff --git a/src/ui/hud.js b/src/ui/hud.js
index 23d47b5..1284d5d 100644
--- a/src/ui/hud.js
+++ b/src/ui/hud.js
@@ -34,12 +34,22 @@ export function createHud(doc = document, lives = 3) {
return PIP.repeat(Math.max(0, left)) + `${PIP.repeat(Math.max(0, lives - left))}`;
}
+ /** The readouts are written sixty times a second and change a handful of
+ * times a run, so each one only touches the DOM when its text moves. */
+ const shown = {};
+ function put(node, key, value, html = false) {
+ if (shown[key] === value) return;
+ shown[key] = value;
+ if (html) node.innerHTML = value;
+ else node.textContent = value;
+ }
+
return {
update(snapshot) {
- score.textContent = String(snapshot.score);
- depth.textContent = metres(snapshot.distance);
- bits.textContent = String(snapshot.bits);
- shells.innerHTML = pips(snapshot.lives);
+ put(score, 'score', String(snapshot.score));
+ put(depth, 'depth', metres(snapshot.distance));
+ put(bits, 'bits', String(snapshot.bits));
+ put(shells, 'shells', pips(snapshot.lives), true);
},
ready(best) {
@@ -62,6 +72,8 @@ export function createHud(doc = document, lives = 3) {
['score', snapshot.score],
['depth', metres(snapshot.distance)],
['bits', snapshot.bits],
+ /** Agents only earn a column on a run that broke one. */
+ ...(snapshot.agents > 0 ? [['agents', snapshot.agents]] : []),
['best', best],
].map(([label, value]) => stat(label, value)).join('');
play.textContent = 'again';
diff --git a/src/ui/input.js b/src/ui/input.js
index b652604..8d018c2 100644
--- a/src/ui/input.js
+++ b/src/ui/input.js
@@ -12,21 +12,42 @@ const KEYS = {
const SWIPE = 26;
+/** The page's own controls. A press that lands on one is theirs, not the run's. */
+const CHROME = 'button, a, input, select, textarea, [data-chrome]';
+
function blank() {
- return { left: false, right: false, jump: false, dive: false };
+ return { left: 0, right: 0, jump: false, dive: false };
+}
+
+/** Lane presses count; jump and slam are flags. Pressing jump twice inside a
+ * frame is still one jump, and pressing left twice is two lanes. */
+function record(frame, action) {
+ if (action === 'left' || action === 'right') frame[action] += 1;
+ else frame[action] = true;
+}
+
+/**
+ * Whether a pointer event belongs to the page rather than the course. Tapping
+ * `audio on` used to toggle the sound *and* jump the egg: `click` can be
+ * stopped from bubbling, but the pointer events under it are separate, and
+ * they reach the window either way.
+ */
+function onChrome(event) {
+ return Boolean(event.target?.closest?.(CHROME));
}
/**
* Intents are edges, not held keys: leaning on → moves one lane, and holding
- * jump does not hover. `take()` hands the frame's edges to the first physics
- * tick and clears them, so a press is spent exactly once.
+ * jump does not hover. `take()` hands the frame's edges over and clears them;
+ * the game holds them until a fixed tick spends them, so a press is spent
+ * exactly once and never simply dropped.
*/
export function createInput(target, { onConfirm = () => {} } = {}) {
let pending = blank();
let start = null;
function press(action) {
- if (action) pending[action] = true;
+ if (action) record(pending, action);
}
function onKeyDown(event) {
@@ -40,11 +61,17 @@ export function createInput(target, { onConfirm = () => {} } = {}) {
}
function onPointerDown(event) {
+ if (onChrome(event)) return;
start = { x: event.clientX, y: event.clientY, t: event.timeStamp };
}
function onPointerUp(event) {
if (!start) return;
+ /** A swipe that ended on a button is over, and is not also a jump. */
+ if (onChrome(event)) {
+ start = null;
+ return;
+ }
const dx = event.clientX - start.x;
const dy = event.clientY - start.y;
start = null;
@@ -57,10 +84,14 @@ export function createInput(target, { onConfirm = () => {} } = {}) {
onConfirm();
}
+ function onPointerCancel() {
+ start = null;
+ }
+
target.addEventListener('keydown', onKeyDown);
target.addEventListener('pointerdown', onPointerDown);
target.addEventListener('pointerup', onPointerUp);
- target.addEventListener('pointercancel', () => { start = null; });
+ target.addEventListener('pointercancel', onPointerCancel);
return {
take() {
@@ -73,6 +104,7 @@ export function createInput(target, { onConfirm = () => {} } = {}) {
target.removeEventListener('keydown', onKeyDown);
target.removeEventListener('pointerdown', onPointerDown);
target.removeEventListener('pointerup', onPointerUp);
+ target.removeEventListener('pointercancel', onPointerCancel);
},
};
}
diff --git a/src/ui/sound.js b/src/ui/sound.js
index 7a89433..34ea7ce 100644
--- a/src/ui/sound.js
+++ b/src/ui/sound.js
@@ -2,13 +2,14 @@ const VOICES = {
jump: { from: 420, to: 820, time: 0.12, type: 'square', gain: 0.05 },
land: { from: 220, to: 120, time: 0.08, type: 'triangle', gain: 0.04 },
bit: { from: 880, to: 1560, time: 0.09, type: 'square', gain: 0.05 },
+ smash: { from: 640, to: 90, time: 0.22, type: 'square', gain: 0.07 },
hit: { from: 300, to: 70, time: 0.3, type: 'sawtooth', gain: 0.09 },
over: { from: 260, to: 40, time: 0.9, type: 'sawtooth', gain: 0.1 },
start: { from: 180, to: 760, time: 0.3, type: 'square', gain: 0.05 },
};
-/** Four oscillators' worth of arcade, built on the first gesture because no
- * browser will start an AudioContext before one. */
+/** A handful of oscillators' worth of arcade, built on the first gesture
+ * because no browser will start an AudioContext before one. */
export function createSound() {
let ctx = null;
let muted = false;
diff --git a/test/course.test.js b/test/course.test.js
index d657089..e82c91e 100644
--- a/test/course.test.js
+++ b/test/course.test.js
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { createCourse } from '../src/core/course.js';
-import { APEX, AIRTIME, LANES, LANE_WIDTH, laneX, speedAt } from '../src/core/tuning.js';
+import { APEX, AIRTIME, laneBounds, LANES, LANE_WIDTH, laneX, speedAt } from '../src/core/tuning.js';
const SEEDS = [1, 2, 3, 7, 42, 1024, 65535];
@@ -70,8 +70,11 @@ describe('course', () => {
for (const seg of laidOut(seed).segments) {
assert.ok(seg.span >= 1 && seg.span <= LANES);
assert.ok(seg.lane >= 0 && seg.lane + seg.span <= LANES);
- assert.equal(seg.xMin, laneX(seg.lane) - LANE_WIDTH / 2);
- assert.equal(seg.xMax, laneX(seg.lane + seg.span - 1) + LANE_WIDTH / 2);
+ const { xMin, xMax } = laneBounds(seg.lane, seg.span);
+ assert.equal(seg.xMin, xMin);
+ assert.equal(seg.xMax, xMax);
+ assert.ok(seg.xMin < seg.xMax, 'a slab whose edges came out the wrong way round');
+ assert.equal(seg.xMax - seg.xMin, seg.span * LANE_WIDTH);
}
}
});
diff --git a/test/game.test.js b/test/game.test.js
index 283cfdf..71b1753 100644
--- a/test/game.test.js
+++ b/test/game.test.js
@@ -2,7 +2,9 @@ import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { createGame } from '../src/core/game.js';
-import { HAZARD, INVULNERABLE, LIVES, SCORE, VOID_Y } from '../src/core/tuning.js';
+import {
+ AIRTIME, HAZARD, INVULNERABLE, LANES, laneX, LIVES, SCORE, VOID_Y,
+} from '../src/core/tuning.js';
import { pilot, run } from './helpers/pilot.js';
const SEEDS = [1, 3, 9, 42, 77];
@@ -76,6 +78,136 @@ describe('game', () => {
assert.ok(game.snapshot().invulnerable <= INVULNERABLE);
});
+ it('holds a press until a tick can spend it', () => {
+ /**
+ * A frame shorter than the 1/120 physics step runs no tick at all — every
+ * few frames on a 144Hz display. The press made in that frame has to
+ * survive to the next one, or one lane change in six is simply lost.
+ */
+ const game = started(1);
+ const lane = game.player.lane;
+ game.advance(1 / 144, { left: 1, right: 0, jump: false, dive: false });
+ assert.equal(game.player.lane, lane, 'the frame was too short to tick');
+ game.advance(1 / 144, null);
+ assert.equal(game.player.lane, lane - 1, 'the press was dropped on the floor');
+ });
+
+ it('crosses two lanes for a double tap inside one frame', () => {
+ const game = started(1);
+ game.player.lane = 0;
+ play(game, 0.2, null);
+ game.advance(1 / 60, { left: 0, right: 2, jump: false, dive: false });
+ assert.equal(game.player.lane, 2);
+ game.advance(1 / 60, { left: 0, right: 2, jump: false, dive: false });
+ assert.equal(game.player.lane, LANES - 1, 'it walked off the side of the course');
+ });
+
+ it('starts a run with nothing left over from the last one', () => {
+ const game = started(1);
+ game.advance(1 / 144, { left: 1, right: 0, jump: true, dive: false });
+ game.start(1);
+ const lane = game.player.lane;
+ play(game, 0.3, null);
+ assert.equal(game.player.lane, lane, 'a press from the last run moved this one');
+ assert.ok(game.player.grounded, 'and jumped it');
+ });
+
+ /** One agent, parked on the opening runway in the egg's own lane. */
+ function ambush(game, ahead = 12) {
+ const hazard = {
+ z: game.player.z + ahead,
+ lane: game.player.lane,
+ x: game.player.x,
+ y: 0,
+ hit: false,
+ };
+ game.course.hazards.length = 0;
+ game.course.hazards.push(hazard);
+ return hazard;
+ }
+
+ it('breaks an agent slammed onto, and pays for it', () => {
+ const game = started(1);
+ const hazard = ambush(game);
+
+ const smashed = [];
+ game.on('smash', (event) => smashed.push(event));
+ game.on('hit', () => assert.fail('a slammed agent cost a shell'));
+
+ /** Jump in time to be over it, then put the slam on and ride it down. */
+ const reach = () => (hazard.z - game.player.z) / game.player.speed;
+ while (game.state === 'running' && reach() > AIRTIME * 0.75) game.advance(1 / 120, null);
+ game.advance(1 / 120, { left: 0, right: 0, jump: true, dive: false });
+ while (game.state === 'running' && !game.player.grounded) {
+ game.advance(1 / 120, { left: 0, right: 0, jump: false, dive: reach() < AIRTIME * 0.3 });
+ }
+
+ assert.ok(game.player.diving === false, 'the slam should have ended in a landing');
+ assert.equal(smashed.length, 1, 'the slam went straight past it');
+ assert.ok(hazard.hit);
+ assert.equal(game.snapshot().lives, LIVES);
+ assert.equal(game.snapshot().agents, 1);
+ assert.ok(game.score >= SCORE.perAgent, 'breaking an agent paid nothing');
+ });
+
+ it('still costs a shell to run into one on the floor', () => {
+ const game = started(1);
+ ambush(game);
+
+ const hits = [];
+ game.on('smash', () => assert.fail('a flat run-in broke an agent'));
+ game.on('hit', (hit) => hits.push(hit.reason));
+ /** Slam is an air move: held on the ground it does nothing at all. */
+ play(game, 1.5, { left: 0, right: 0, jump: false, dive: true });
+
+ assert.deepEqual(hits, ['agent'], 'holding slam on the ground walked through it');
+ });
+
+ it('only reaches the lane it came down in', () => {
+ const game = started(1);
+ const hazard = ambush(game);
+ hazard.lane += 1;
+ hazard.x = laneX(hazard.lane);
+
+ game.on('smash', () => assert.fail('the slam reached into the next lane'));
+ const reach = () => (hazard.z - game.player.z) / game.player.speed;
+ while (game.state === 'running' && reach() > AIRTIME * 0.75) game.advance(1 / 120, null);
+ game.advance(1 / 120, { left: 0, right: 0, jump: true, dive: false });
+ while (game.state === 'running' && !game.player.grounded) {
+ game.advance(1 / 120, { left: 0, right: 0, jump: false, dive: reach() < AIRTIME * 0.3 });
+ }
+ assert.ok(!hazard.hit);
+ });
+
+ it('says so when a run ends with nowhere left to land', () => {
+ const game = createGame({ seed: 1, lives: 1 });
+ game.start(1);
+ game.course.landingAfter = () => null;
+
+ const overs = [];
+ game.on('over', (snapshot) => overs.push(snapshot));
+ game.player.y = VOID_Y - 1;
+ game.advance(1 / 60, null);
+
+ assert.equal(game.state, 'over');
+ assert.equal(overs.length, 1, 'the run ended without telling anybody');
+ assert.equal(overs[0].state, 'over');
+ assert.equal(overs[0].lives, 0);
+ });
+
+ it('puts a fallen egg back down and says where', () => {
+ const game = started(4);
+ const moves = [];
+ game.on('respawn', (event) => moves.push(event));
+ game.player.y = VOID_Y - 1;
+ game.advance(1 / 60, null);
+
+ assert.equal(moves.length, 1);
+ assert.ok(moves[0].seg, 'the slab it was put on should come with the event');
+ assert.ok(game.player.z >= moves[0].seg.z0);
+ assert.equal(game.state, 'running');
+ });
+
it('lets a jump clear an agent', () => {
const game = started(1);
game.course.hazards.length = 0;
diff --git a/test/input.test.js b/test/input.test.js
index 590b1a1..4f75b1d 100644
--- a/test/input.test.js
+++ b/test/input.test.js
@@ -29,7 +29,7 @@ describe('input', () => {
['ArrowDown', 'dive'], ['KeyS', 'dive'],
]) {
h.key(code);
- assert.equal(h.input.take()[action], true, `${code} should mean ${action}`);
+ assert.ok(h.input.take()[action], `${code} should mean ${action}`);
}
h.close();
});
@@ -45,7 +45,38 @@ describe('input', () => {
it('ignores the key repeat a held key sends', () => {
const h = harness();
h.key('KeyD', { repeat: true });
- assert.equal(h.input.take().right, false);
+ assert.ok(!h.input.take().right);
+ h.close();
+ });
+
+ it('counts lane presses, so a double tap inside one frame is two lanes', () => {
+ const h = harness();
+ h.key('KeyA');
+ h.key('KeyA');
+ h.key('KeyW');
+ h.key('KeyW');
+ const frame = h.input.take();
+ assert.equal(frame.left, 2, 'the second tap was swallowed');
+ assert.equal(frame.right, 0);
+ assert.equal(frame.jump, true, 'two jumps in a frame are still one jump');
+ assert.equal(h.input.take().left, 0);
+ h.close();
+ });
+
+ it('leaves the page its own buttons', () => {
+ const h = harness();
+ const button = h.window.document.createElement('button');
+ h.window.document.body.append(button);
+
+ button.dispatchEvent(new h.window.MouseEvent('pointerdown', { bubbles: true, clientX: 10, clientY: 10 }));
+ button.dispatchEvent(new h.window.MouseEvent('pointerup', { bubbles: true, clientX: 11, clientY: 11 }));
+ assert.ok(!h.input.take().jump, 'tapping a button jumped the egg');
+ assert.equal(h.confirms.length, 0, 'and started a run behind the button');
+
+ /** A swipe that strays onto a button is abandoned, not read as a tap. */
+ h.window.dispatchEvent(new h.window.MouseEvent('pointerdown', { clientX: 200, clientY: 300 }));
+ button.dispatchEvent(new h.window.MouseEvent('pointerup', { bubbles: true, clientX: 100, clientY: 300 }));
+ assert.equal(h.input.take().left, 0);
h.close();
});
@@ -53,10 +84,10 @@ describe('input', () => {
const h = harness();
h.swipe([200, 300], [100, 305]);
- assert.equal(h.input.take().left, true);
+ assert.equal(h.input.take().left, 1);
h.swipe([100, 300], [220, 296]);
- assert.equal(h.input.take().right, true);
+ assert.equal(h.input.take().right, 1);
h.swipe([150, 300], [152, 240]);
assert.equal(h.input.take().jump, true);
@@ -87,7 +118,10 @@ describe('input', () => {
const h = harness();
h.input.dispose();
h.key('KeyW');
- assert.equal(h.input.take().jump, false);
+ h.swipe([200, 300], [100, 305]);
+ const frame = h.input.take();
+ assert.equal(frame.jump, false);
+ assert.equal(frame.left, 0);
h.window.close();
});
});
diff --git a/test/player.test.js b/test/player.test.js
index 0fb78de..2e374fe 100644
--- a/test/player.test.js
+++ b/test/player.test.js
@@ -3,7 +3,7 @@ import { describe, it } from 'node:test';
import { advance, createPlayer, respawn } from '../src/core/player.js';
import {
- AIRTIME, APEX, COYOTE, DIVE_SPEED, JUMP_BUFFER, LANES, LANE_WIDTH, laneX,
+ AIRTIME, APEX, COYOTE, DIVE_SPEED, JUMP_BUFFER, laneBounds, LANES, laneX,
PLAYER, RUN, SNAP, speedAt, VOID_Y,
} from '../src/core/tuning.js';
@@ -16,11 +16,7 @@ const DIVE = { left: false, right: false, jump: false, dive: true };
/** A course of exactly one slab, so the physics has something to stand on. */
function floor({ y = 0, z0 = -20, z1 = 500, lane = 0, span = LANES } = {}) {
- const seg = {
- id: 0, z0, z1, y, lane, span,
- xMin: laneX(lane) - LANE_WIDTH / 2,
- xMax: laneX(lane + span - 1) + LANE_WIDTH / 2,
- };
+ const seg = { id: 0, z0, z1, y, lane, span, ...laneBounds(lane, span) };
return {
seg,
groundAt(x, z) {
diff --git a/test/rig.test.js b/test/rig.test.js
new file mode 100644
index 0000000..68c1c4b
--- /dev/null
+++ b/test/rig.test.js
@@ -0,0 +1,62 @@
+import assert from 'node:assert/strict';
+import { describe, it } from 'node:test';
+import * as THREE from 'three';
+
+import { LANES, laneX } from '../src/core/tuning.js';
+import { focus, rigFor, seat, TALL, WIDE } from '../src/render/rig.js';
+
+/**
+ * The bug this file exists for: the chase camera sits behind the egg and looks
+ * the way the egg runs, which mirrors the picture — world +x comes out on the
+ * left of the screen. Get `laneX` the intuitive way round and every control is
+ * backwards, silently, with nothing in the core to catch it.
+ *
+ * So the assertion is made where it can be seen: a lane is projected through
+ * the real rig, and the side of the frame it lands on is checked.
+ */
+function ndcX(lane, { aspect = 16 / 9, z = 0, ahead = 0 } = {}) {
+ const player = { x: laneX(1), y: 0, z, lane: 1 };
+ const rig = rigFor(aspect);
+ const camera = new THREE.PerspectiveCamera(aspect < 1 ? 64 : 52, aspect, 0.1, 260);
+
+ camera.position.copy(seat(new THREE.Vector3(), player, 0, rig));
+ camera.lookAt(focus(new THREE.Vector3(), player, 0, rig));
+ camera.updateMatrixWorld(true);
+
+ return new THREE.Vector3(laneX(lane), 0.7, z + ahead).project(camera).x;
+}
+
+describe('the chase camera', () => {
+ it('draws lane 0 on the left of the screen and the last lane on the right', () => {
+ for (const aspect of [16 / 9, 4 / 3, 0.46]) {
+ for (const ahead of [0, 6, 20]) {
+ const left = ndcX(0, { aspect, ahead });
+ const right = ndcX(LANES - 1, { aspect, ahead });
+ assert.ok(left < 0, `lane 0 drew at ${left.toFixed(3)}, which is the right half`);
+ assert.ok(right > 0, `lane ${LANES - 1} drew at ${right.toFixed(3)}, which is the left half`);
+ assert.ok(Math.abs(ndcX(1, { aspect, ahead })) < 1e-9, 'lane 1 is the middle one');
+ }
+ }
+ });
+
+ it('puts the lanes across the screen in order, wherever the egg has got to', () => {
+ for (const z of [0, 250, 4000]) {
+ const across = [...Array(LANES).keys()].map((lane) => ndcX(lane, { z }));
+ assert.deepEqual(across, [...across].sort((a, b) => a - b), `out of order at ${z}m`);
+ }
+ });
+
+ it('sits behind the egg and looks past it, which is what does the mirroring', () => {
+ for (const rig of [WIDE, TALL]) {
+ const player = { x: 0, y: 0, z: 100 };
+ assert.ok(seat(new THREE.Vector3(), player, 0, rig).z < player.z, 'the camera got in front');
+ assert.ok(focus(new THREE.Vector3(), player, 0, rig).z > player.z, 'it is looking backwards');
+ }
+ });
+
+ it('lifts and pulls in for a window taller than it is wide', () => {
+ assert.equal(rigFor(16 / 9), WIDE);
+ assert.equal(rigFor(0.46), TALL);
+ assert.ok(TALL.up > WIDE.up && TALL.back < WIDE.back);
+ });
+});