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
18 changes: 16 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,17 +34,22 @@ 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
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.

<p align="center">
<img src="docs/screenshots/mobile.png" alt="Eggscape on a phone" width="300">
</p>
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion index.html
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ <h1>Eggscape<em>the matrix</em></h1>
<ul id="keys">
<li><kbd>A</kbd><kbd>D</kbd> lane</li>
<li><kbd>W</kbd> jump, twice to flip</li>
<li><kbd>S</kbd> slam</li>
<li><kbd>S</kbd> slam — onto an agent to break it</li>
<li><kbd>M</kbd> audio</li>
<li class="touch">swipe to move · tap to jump</li>
</ul>
Expand Down
5 changes: 2 additions & 3 deletions src/core/course.js
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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;
Expand Down
108 changes: 90 additions & 18 deletions src/core/game.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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();
Expand All @@ -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() {
Expand All @@ -52,22 +79,27 @@ export function createGame({ seed = 1, lives = LIVES } = {}) {
course,
lives: livesLeft,
bits: taken,
agents: smashed,
distance,
score: score(),
invulnerable,
elapsed,
};
}

/** 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() {
Expand All @@ -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;
Expand All @@ -104,25 +159,33 @@ 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();

if (moved.fell) {
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');
}
}

Expand All @@ -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();
},
Expand All @@ -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;
}
Expand Down
14 changes: 10 additions & 4 deletions src/core/player.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand All @@ -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;

/**
Expand All @@ -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. */
Expand Down
29 changes: 26 additions & 3 deletions src/core/tuning.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}

/**
Expand Down Expand Up @@ -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;
Expand Down
13 changes: 12 additions & 1 deletion src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,19 +33,30 @@ 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);
sound.play('over');
});

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 });
Expand Down
Loading