astara / src /drained.js
system's picture
system HF Staff
268252f: Wrong doors wake the owner, and a note on the tree says the rule
9986f05 verified
Raw History Blame Contribute Delete
32.1 kB
// One Drained, and the five states it can be in.
//
// THE ARGUMENT THE WHOLE LEVEL MAKES LIVES IN THIS FILE. A Drained that has
// never been lit never attacks — it stands, and the prince can walk straight
// past it. The only thing that ever makes one hostile is the player choosing to
// see it. So one hundred percent of the combat in this level is caused by
// looking.
//
// WANDER stands exactly where it stopped. Harmless, and it does not move
// at all — not a step, not a sway. Being looked at is the only
// thing that has ever made one of them do anything.
// STEP_DOWN revealed, and stepping out of the back lane onto his road.
// Cannot be hit and cannot hurt: it is the telegraph. The only
// moment one of them ever changes lane on its own.
// RUSH comes for him. Reached by stepping down, and never left.
// STAGGER knocked back, cannot act.
// DOWNED lies where it fell. Cannot be revealed. Cannot rush.
// RISING getting up. Can be knocked back down, for ever.
// A body woken by a WRONG pin rises into RUSH too, and comes for him
// from wherever it fell — off screen included. See wake().
// HOMING walking back to its own door, because somebody gave it its name.
// Harmless, never hurries, and the only thing in this game that
// goes somewhere on purpose.
// HOME standing in that doorway, facing out. Never moves again.
//
// THE LANE IS THE STATE, SEEN FROM OUTSIDE. Everything that has never been lit
// stands in the back lane, beside the road; everything else is on the road, on
// his line. `lane` is 1 in the back and 0 on the road, and it is only ever in
// between during STEP_DOWN. It moves nothing in the world — x is x in both —
// and laneLook() turns it into what the renderer and the torch need.
import { TUNING } from './tuning.js';
import { createPlayer } from './anim.js';
export const WANDER = 'WANDER';
export const STEP_DOWN = 'STEP_DOWN';
export const RUSH = 'RUSH';
export const STAGGER = 'STAGGER';
export const DOWNED = 'DOWNED';
export const RISING = 'RISING';
// The two the walk-home addendum adds. Everything before this point is a thing
// that happens TO one of them; these two are the only states it enters because
// the player gave something back.
export const HOMING = 'HOMING';
export const HOME = 'HOME';
/**
* What a lane position looks like: how big, how far up the frame, how light.
*
* ONE PLACE, because three things have to agree about it — the renderer draws
* the figure with it, the torch decides whether the figure is lit with it, and
* the checks measure it. A figure the torch thinks is somewhere it is not drawn
* is a reveal nobody can predict.
*/
export function laneLook(lane) {
const l = Math.max(0, Math.min(1, lane || 0));
return {
lane: l,
scale: 1 - (1 - TUNING.BACK_LANE_SCALE) * l,
rise: TUNING.BACK_LANE_RISE * l,
value: TUNING.BACK_LANE_VALUE * l,
};
}
/**
* The height the torch has to reach to have seen this figure: the middle of
* the body, not of the canvas. The loops sit on a taller canvas to give the
* swing room, so half the canvas is above the head. 175 is half of a standing
* figure, and in the back lane the whole figure is smaller and higher.
*/
export function figureMidY(groundY, lane) {
const L = laneLook(lane);
return groundY - L.rise - 175 * L.scale;
}
// Who each of them was. The object is the only thing that says it, and it only
// says it inside the torch.
// INDEX ORDER IS HOUSE ORDER: forge, bakehouse, well house, home, gatehouse.
// One fragment per house. scene_level.js builds the houses straight off this
// list, so a fragment's house and a plot on the map are the same number and
// cannot drift apart.
//
// The weaver is in here and is never carried by anything. The loom in Home is
// the only place a weaver appears at all, and that is the finding.
// INDEX ORDER IS HOUSE ORDER: forge, bakehouse, well house, home, gatehouse.
// Which house a fragment answers. INTERNAL ONLY — nothing a player sees ever
// says it, because the whole point of a fragment is that the words decide.
// It survives as a key for the band text and for the debug readout.
//
// Home has a fragment written for it and nothing in the forest carries it.
// That is the residue.
export const HOUSE_OF = [
'the forge', 'the bakehouse', 'the well house', 'home', 'the gatehouse',
];
/**
* Where the kept object lands when this one goes down, as an offset from the
* body's pivot. The downed clip declares a point past each end of the pose; we
* take the one on the side he walks off towards, so it is in his path and never
* behind him. Which end of the body that is — the head or the boots — depends
* on which way this one happened to fall.
*/
export function keptDropOffset(clip, facing, side) {
const face = facing === (clip.authoredFacing ?? 1) ? 1 : -1;
const drops = clip.keptDrop;
let p = drops[0];
for (let i = 0; i < drops.length; i++) {
if (Math.sign((drops[i][0] - clip.pivotX) * face) === side) { p = drops[i]; break; }
}
return [(p[0] - clip.pivotX) * face, p[1] - clip.pivotY];
}
/**
* @param opts.standing it does not walk. Village shapes stand at their work
* and loop, and the loop is the thing you read them by.
* @param opts.kept which object it carries, or null for nobody's. Eight of
* the village's twelve carry nothing, and a fight that
* drops nothing is the negative result — it needs no
* marking because the absence IS the feedback.
* @param opts.stayDown once down, down for good. A body is the only record of
* which shapes you already checked, and twelve near
* identical outlines with no memory of what you tried is
* precisely the tedium the village exists to measure.
*/
export function createDrained(clips, spawnX0, dir, keptIndex = 0, opts = {}) {
let spawnX = spawnX0;
const { standing = true, stayDown = false, loop = null, post = false,
keptMaxX = Infinity } = opts;
let roam = opts.roam ?? 0;
// THE STILL POSE, and it is the watcher art at full size in pure black.
//
// Nothing wanders any more. A Drained that has not been lit does not walk the
// road, does not mark time on the spot and does not sway: it stands exactly
// where it stopped, and the only thing that moves it is being looked at. That
// is what the level has always argued and it was being undercut by the
// animation — a walk cycle played at a quarter speed reads as a man walking
// on the spot, which looks like a bug rather than like a person who stopped.
//
// The art for it already existed. The background crowd is these same four
// poses at 0.44 and tinted; up close, at full size and pure black, they are
// what a person standing still looks like.
//
// V4 REPLACES THIS WITH TRADE LOOPS, and that is not a contradiction. A
// blacksmith miming a hammer swing is a loop that READS — it is the first
// skill the search teaches. A walk cycle at quarter speed reads as nothing.
const stillClip = clips.drained_watcher;
// THE TRADE LOOP, and it is the first skill the village teaches. Something
//
// A DECOY DOES THE SAME LOOP AS ITS CARRIER. One carrier to two decoys, so
// watching loops alone never finishes the job — if only carriers worked, the
// search would resolve on sight and there would be no search.
// THE GATEKEEPER. Everything about hits, stagger, going down and getting up
// is the shared machinery — consistency beats making him special. What he has
// instead of a rush is a thrust, and what he has instead of a kill is a shove.
const postClip = post ? clips.drained_thrust ?? null : null;
// THE LOOP, and it is the first skill the village teaches. Something is
// swinging. It narrows twelve shapes to three without saying a word, and
// reading which of the three has a page-shaped break in its outline picks
// the one.
//
// IT IS A MOTION AND IT IS NOT A TRADE. Nobody out there is a blacksmith.
// Once the fragment stopped being a tool, the loop stopped being able to name
// a house — and which house a fragment answers is decided by reading it.
const loopClip = loop ? clips[`loop_${loop}`] ?? null : null;
const loopPlayer = loopClip ? createPlayer(loopClip) : null;
const kept = opts.kept === undefined ? keptIndex % HOUSE_OF.length : opts.kept;
const players = {
[WANDER]: createPlayer(clips.drained_walk),
// THE STEP DOWN IS THE WALK, PLAYED FOR ONE STRIDE. The spec's first try,
// before anyone draws it: if it reads as sliding it needs three or four
// frames of its own, and a `drained_step` clip in the manifest is used
// with no code change.
[STEP_DOWN]: createPlayer(clips.drained_step ?? clips.drained_walk),
[RUSH]: createPlayer(clips.drained_rush),
[STAGGER]: createPlayer(clips.drained_stagger),
[DOWNED]: createPlayer(clips.drained_downed),
[RISING]: createPlayer(clips.drained_rising),
// It walks home on the same cycle the two wanderers pace on. There is no
// separate going-home drawing and there should not be: a walk is a walk,
// and what makes this one different is where it ends.
[HOMING]: createPlayer(clips.drained_walk),
[HOME]: createPlayer(clips.drained_walk),
};
const d = {
x: spawnX, prevX: spawnX,
dir, // fixed at spawn, often toward the wall
facing: dir,
// Until he hits it: it walks at him, so he arrives from the other side.
keptSide: -dir,
keptDropped: false, // once true it never goes back on the body
keptSeen: false, // was the object ever actually inside the torch?
keptTaken: false, // he picked it up; it is out of the world now
keptX: 0, keptY: 0,
state: WANDER,
timer: 0,
hits: 0,
dwell: 0, // how long it has been covered by the light
revealed: false, // latches, and never unlatches
// 1 in the back lane, 0 on the road. See laneLook.
lane: post ? 0 : 1,
// Turned to face him. Hysteresis between WATCH_NEAR and WATCH_FAR.
watching: false,
// How far along a stare is, 0..1. Zero when the light is off it.
stir: 0,
knockVel: 0,
clip: clips.drained_walk,
frame: 0,
spawnX,
standing,
stayDown,
loop,
post,
// 0 ready, 1 windup, 2 thrust, 3 recovery — and the timer that drives them.
phase: 0,
phaseMs: 0,
kept,
// Which house this answers. The player never sees it.
answers: kept === null ? null : HOUSE_OF[kept],
// WALKING HOME. `homeX` is the door it is walking to, in world space.
// `homing` is what makes RISING come up into a walk rather than into a
// rush, and it is the one flag in here that the player's own act sets.
homing: false,
homeX: null,
// WOKEN BY A WRONG PIN. Someone put its last words on a stranger's house,
// and it is coming for him — from wherever it fell, so this is also what
// keeps it out of the distance cull and moving while he cannot see it.
// Cleared the moment it goes down again or is sent home.
woken: false,
};
/**
* Which drawing this one is showing. Split out because a standing Drained in
* WANDER is not playing a clip at all — it is holding one pose — and three
* places needed to agree about that.
*/
function applyPose() {
// At his post, the pose is the attack phase. Not a clip played back at a
// frame rate — the phases have different lengths and the timer owns them.
if (postClip && (d.state === WANDER || d.state === RUSH)) {
d.clip = postClip;
d.frame = d.phase;
return;
}
if (d.state === WANDER && loopClip) {
d.clip = loopClip;
d.frame = loopPlayer.index;
return;
}
// HOME IS THE STILL POSE, the same one it held in the street before any of
// this started. It is standing in a doorway now instead of nowhere, and
// that is the entire difference — nothing in this game gets better.
// AND A WANDERER THAT HAS STOPPED TO WATCH HIM. It does not walk while it
// turns to follow him, or it moonwalks; it stands, which is the pose a
// person who stopped already has.
if ((d.state === HOME || (d.state === WANDER && (d.standing || d.watching || d.dwell > 0))) && stillClip) {
d.clip = stillClip;
d.frame = keptIndex % stillClip.count;
return;
}
d.clip = players[d.state].clip;
d.frame = players[d.state].index;
}
function enter(next) {
// Going down is when the object leaves it. Frozen here, in world space, so
// it stays on the ground where it fell however far the body is shoved
// afterwards — and so it is still lying there when this one gets up, which
// is the whole point of it having fallen out.
if (next === DOWNED && !d.keptDropped && d.kept !== null) {
// WHICH SIDE IT FALLS ON. Normally the side the prince walks off towards,
// so the object is in his path and never behind him.
//
// The gatekeeper is the one case where that is exactly wrong. He does not
// get walked past — there is a shut gate behind him, and the prince stops
// 150px short of him and can never get closer. His spear landed 375px
// beyond the stop line against a 120px pickup reach, so the run could be
// finished with three objects and no way to reach the fourth.
//
// At a post it falls on the side the prince is STANDING on, which is
// what his facing already points at.
const side = post ? (Math.sign(d.facing) || -1) : (d.keptSide || 1);
const off = keptDropOffset(clips.drained_downed, d.facing, side);
d.keptDropped = true;
// AND IT NEVER LANDS WHERE HE CANNOT WALK. Belt and braces on top of the
// side choice: whatever the pose and the facing conspire to do, an object
// outside the walkable range is a run that cannot be finished, and that
// is the worst bug this level can have. Clamped HERE rather than by the
// caller watching for the transition, because a post goes straight to
// DOWNED inside takeHit and the caller never sees a transition at all.
d.keptX = Math.max(60, Math.min(keptMaxX, d.x + off[0]));
d.keptY = off[1];
}
d.state = next;
d.timer = 0;
// Back lane until it has been seen, road for everything after. STEP_DOWN
// starts in the back and walks the number down itself; nothing else ever
// moves it, so a state that is not these two is simply on the road.
d.lane = (next === WANDER || next === STEP_DOWN) && !post ? 1 : 0;
if (next !== WANDER) d.watching = false;
// Only rising, rushing and reeling are part of coming for him.
if (next !== RISING && next !== RUSH && next !== STAGGER) d.woken = false;
players[next].reset();
applyPose();
}
/**
* Re-dealt. The BODY is built once with its loop fixed — a loop is a clip and
* a player, and swapping those per run would be rebuilding the level. What a
* run seed moves is which slot this body stands in and what it is carrying.
*/
d.placeAt = (x, roamHalfWidth = roam) => {
// BOTH, AND THE ROAM WITH THEM. The closure drives d.reset() and the pacing
// bounds; the property is what the checks read. Updating only the closure
// left every check measuring drift against the slot this body was
// CONSTRUCTED in rather than the one it was dealt into.
//
// And how far it may pace is a property of the SLOT, not of the body — a
// slot near a door has less room. Carrying the old allowance into a new
// slot walked a wanderer into a doorway, which is the one thing the whole
// placement was built to prevent.
spawnX = x;
d.spawnX = x;
d.x = d.prevX = x;
roam = roamHalfWidth;
};
d.setKept = (k) => {
d.kept = k;
d.answers = k === null ? null : HOUSE_OF[k];
};
d.reset = () => {
d.x = d.prevX = spawnX;
d.dir = d.facing = dir;
d.keptSide = -dir;
d.keptDropped = false;
d.keptSeen = false;
d.keptTaken = false;
d.hits = 0;
d.dwell = 0;
d.revealed = false;
d.knockVel = 0;
d.homing = false;
d.homeX = null;
d.woken = false;
d.watching = false;
d.stir = 0;
// THE GATEKEEPER'S CYCLE, and it was surviving a reset.
//
// His four phases are a timer, not a clip, so they were never touched here
// — which meant a restarted level dropped the prince in front of a man who
// might be a tenth of a second from a thrust or most of a second from one.
// The same three-hit fight measured 1.3s and shoved nobody, or 1.9s and
// shoved twice, entirely on what the previous run happened to leave behind.
//
// Found because it made a standing check flicker, which is the only reason
// anyone would ever have found it: to a player it is just a gatekeeper who
// is sometimes harder.
d.phase = 0;
d.phaseMs = 0;
enter(WANDER);
};
/**
* Somebody put this one's own words back on its own wall, so it goes home.
*
* FROM ANY STATE, and that is deliberate rather than tidy. Today a taken
* fragment implies a downed body, because `stayDown` is true in the bowl —
* but that is an assumption living in a tuning value, and one change to
* DOWN_MS would leave a body flagged and standing in the street for ever
* with nothing to say why. So DOWNED gets up first and everything else
* simply turns and walks.
*/
d.sendHome = (x) => {
if (d.state === HOMING || d.state === HOME) return;
d.homeX = x;
d.homing = true;
d.woken = false;
enter(d.state === DOWNED ? RISING : HOMING);
};
/**
* Somebody put this one's words on the WRONG wall, so it gets up and comes
* for him. The same rise as going home; RISING already forks on `homing`,
* so this is that flag held false and the rise started.
*
* A NORMAL REVEALED DRAINED from here: RUSH, three hits to put down. It is
* already on the road where it fell, so there is no step down. `revealed`
* is set because the gatekeeper — and anything downed without a look — was
* never revealed, and the guard pose and the fight count read that flag.
*
* Returns whether it actually woke. One already up is left as it is: it is
* coming anyway. One going home, or home, was filed right and is finished.
*/
d.wake = () => {
if (d.state === HOMING || d.state === HOME) return false;
d.homing = false;
d.homeX = null;
d.revealed = true;
if (d.state === DOWNED) {
enter(RISING);
d.woken = true;
return true;
}
if (d.state === RISING) { d.woken = true; return true; }
return false;
};
/** A connected sword hit. Returns true if it did anything at all. */
d.takeHit = (fromX) => {
// Hitting a DOWNED one does nothing. No knockback, no reaction, no counter.
// The player presses it three or four times and stops, which teaches "he
// cannot kill it" in two seconds with nothing on screen.
// And one that is on its way home, or already in its door. It is not a
// fight any more; the same two seconds of pressing that teach "he cannot
// kill it" over a body teach it again here, with nothing on screen.
//
// And one stepping down. The step IS the warning; a hit that landed in it
// would turn the telegraph into a free first strike.
if (d.state === DOWNED || d.state === STAGGER || d.state === STEP_DOWN ||
d.state === HOMING || d.state === HOME) return false;
// Which way he will walk off from here. He is swinging at it, so he is
// facing it, so he carries on past it this way. Frozen on the last hit that
// lands and read only once it is down, which is what keeps the dropped
// object from jumping across the body every time he turns around.
d.keptSide = Math.sign(d.x - fromX) || 1;
if (d.state === RISING) {
// Straight back down, timer reset. Allowed to happen for ever.
enter(DOWNED);
return true;
}
// HE DOES NOT FLINCH, and that is what makes him a fight.
//
// STAGGER_MS is 500 and his windup is 500, so a hit every half second held
// him in stagger for ever: you could stand at the wall, mash, and put him
// down in four swings without him once getting his spear out. Braced
// against a gate he has held for sixty years, he takes the hit and carries
// on, so mashing inside his reach gets you shoved back out of it.
if (post) {
d.hits++;
if (d.hits >= TUNING.HITS_TO_DOWN) enter(DOWNED);
else if (d.state === WANDER) enter(RUSH);
return true;
}
d.hits++;
d.knockVel = Math.sign(d.x - fromX) || 1;
enter(STAGGER);
return true;
};
/**
* Shoved off without counting a hit. Written for the old get-up-where-you-
* fell recovery, which death replaced with a level restart. Kept because it
* is the only way to move one of these without hurting it, and the ending
* will want that.
*/
d.shoveOff = (fromX, push) => {
if (!post) d.x += Math.sign(d.x - fromX || 1) * push;
d.prevX = d.x;
if (d.state === RUSH || d.state === WANDER) {
d.knockVel = Math.sign(d.x - fromX) || 1;
enter(STAGGER);
}
};
/**
* @param ctx { princeX, torch, groundY, inFrame }
*/
d.update = (dt, ctx) => {
d.prevX = d.x;
const T = TUNING;
// --- being seen ------------------------------------------------------
// Only WANDER and RISING can be revealed. A DOWNED one cannot, which is
// what makes it the only safe thing in the level to look at, and the torch
// over it must never punish the player for staying.
if (!d.revealed && (d.state === WANDER || d.state === RISING)) {
// Off the FIGURE, not the canvas, and off the figure WHERE IT IS DRAWN.
// In the back lane that is smaller and higher, which puts it nearer the
// flame; see figureMidY.
const midY = figureMidY(ctx.groundY, d.lane);
if (ctx.torch.covers(d.x, midY)) {
d.dwell += dt * 1000;
if (d.dwell >= T.REVEAL_MS) {
d.revealed = true;
// The gatekeeper is already on the road. He is the one Drained in
// the level that is actually in the way, and his windup is his
// telegraph.
if (d.state === WANDER) enter(post ? RUSH : STEP_DOWN);
}
} else {
d.dwell = 0;
}
}
d.stir = d.revealed ? 0 : Math.min(1, d.dwell / T.REVEAL_MS);
// --- knockback, which runs in any state -------------------------------
// Except at a post. He is braced against a gate he has been holding for
// sixty years, and being knocked off it cost two things at once: the fight
// drifted away from the gate, and the first hit pushed him out of sword
// range so the second never landed.
if (d.knockVel !== 0 && !post) {
const push = T.KNOCKBACK_PX / (T.STAGGER_MS / 1000);
d.x += d.knockVel * push * dt;
}
switch (d.state) {
case WANDER: {
// IT WATCHES HIM PASS. This used to be "facing was fixed at spawn and
// nothing turns to look", the contract the background watchers keep —
// and walking through one produced no reaction at all, so a player who
// tried it got no evidence it was safe.
//
// Now: inside WATCH_NEAR the whole body turns to face him — a head
// turn is invisible in silhouette — and follows him as he goes by.
// Past WATCH_FAR it goes back to its loop. In between it holds
// whatever it was doing, so standing on the boundary cannot flicker.
//
// Not the gatekeeper. He is on the road, facing the only way anyone
// ever comes at him.
if (!post) {
const gap = ctx.princeX - d.x;
if (Math.abs(gap) <= T.WATCH_NEAR) d.watching = true;
else if (Math.abs(gap) >= T.WATCH_FAR) d.watching = false;
// A small dead band over his own x, so a prince standing on top of
// it does not spin it on a rounding error.
if (d.watching && Math.abs(gap) > 8) d.facing = Math.sign(gap);
} else {
d.watching = false;
}
if (!d.watching) d.facing = d.dir;
// LIT, IT STOPS AND TURNS TO THE LIGHT. Whatever it was doing, it is not
// doing it now: the loop holds its frame and a wanderer stands still.
// The torch reaches a little past WATCH_NEAR, so this turns it even
// where the watch has not.
const lit = d.dwell > 0;
if (lit && !post) d.facing = Math.sign(ctx.princeX - d.x) || d.facing;
if (!d.standing && !d.watching && !lit) {
// A WANDERER IS BOUNDED. Two shapes doing nothing anybody wrote down
// are the negative control for the loop skill — without something it
// can correctly rule OUT, every shape is worth a fight and watching
// is decoration. They have to move, because standing still reads as
// the gatekeeper or as a watcher.
//
// But they may not drift. Let one walk into a door's clear pocket and
// lighting that door wakes it, which is the one guarantee the whole
// placement was built to buy.
if (ctx.inFrame) {
d.x += d.dir * T.DRAINED_SPEED * dt;
if (roam) {
if (d.x <= spawnX - roam) { d.x = spawnX - roam; d.dir = 1; }
else if (d.x >= spawnX + roam) { d.x = spawnX + roam; d.dir = -1; }
}
}
players[WANDER].advance(dt * (T.DRAINED_SPEED / (d.clip.groundSpeed || T.DRAINED_SPEED)));
} else if (loopPlayer && ctx.inFrame && !lit) {
// Only while he can see it. A loop nobody is watching is a timer.
//
// AND IT KEEPS GOING WHILE IT WATCHES HIM. The loop is the first
// skill the village teaches, and it has to be readable from where
// the torch is: stopping it inside WATCH_NEAR would stop it at the
// exact distance a player stands to look.
loopPlayer.advance(dt);
}
break;
}
case STEP_DOWN: {
// OFF THE BACK LANE AND ONTO HIS. Scale, height and value all come
// from `lane`, eased, and x does not move: it steps toward the camera,
// not along the road. It faces him the whole way, because the moment
// it was lit it stopped doing anything else.
d.timer += dt * 1000;
const t = Math.min(1, d.timer / Math.max(1, T.STEP_MS));
d.lane = 1 - t * t * (3 - 2 * t);
d.facing = Math.sign(ctx.princeX - d.x) || d.facing;
// One stride over the step, whatever the clip's own rate.
const stride = (players[STEP_DOWN].clip.count / 2) / players[STEP_DOWN].clip.fps;
players[STEP_DOWN].advance(dt * stride / Math.max(0.001, T.STEP_MS / 1000));
if (t >= 1) enter(RUSH);
break;
}
case RUSH: {
const toward = Math.sign(ctx.princeX - d.x) || -1;
d.facing = toward;
// Off screen it waits — unless a wrong pin woke it, in which case it is
// coming for him and does not need to see him to do it. The gatekeeper
// waits either way: he is angry at the gate, not chasing anyone.
if (!ctx.inFrame && (!d.woken || postClip)) break;
if (postClip) {
// HE DOES NOT CLOSE THE DISTANCE. Sixty years after anyone relieved
// him, he is still keeping people away from a gate — so he stands
// where he stands and the fight is about his timing, not his feet.
d.phaseMs += dt * 1000;
const len = [T.POST_READY_MS, T.POST_WINDUP_MS, T.POST_THRUST_MS, T.POST_RECOVER_MS][d.phase];
if (d.phase === 0 && Math.abs(ctx.princeX - d.x) > T.POST_REACH + 160) {
d.phaseMs = 0; // out of range: he simply waits
} else if (d.phaseMs >= len) {
d.phaseMs = 0;
d.phase = (d.phase + 1) % 4;
}
break;
}
// Everything else comes for him, and only while they are in the same
// frame. Off screen it stops and waits — it stays hostile, because
// revealed is permanent, but a thing that chases you across half a
// level you cannot see is an off-screen timer, not a presence.
d.x += toward * T.RUSH_SPEED * dt;
players[RUSH].advance(dt * (T.RUSH_SPEED / (d.clip.groundSpeed || T.RUSH_SPEED)));
break;
}
case STAGGER:
d.timer += dt * 1000;
players[STAGGER].advance(dt);
if (d.timer >= T.STAGGER_MS) {
d.knockVel = 0;
enter(d.hits >= T.HITS_TO_DOWN ? DOWNED : RUSH);
}
break;
case DOWNED:
d.timer += dt * 1000;
// In the village it never gets up, and nothing cleans it up either.
// Besides being the player's record of what they already checked, it is
// the best image this level produces for free: brute-force the bowl and
// you walk out through twelve people you knocked over to find four
// objects. On the road DOWN_MS still applies, unchanged.
if (!d.stayDown && d.timer >= T.DOWN_MS) enter(RISING);
break;
case HOMING: {
// IT WALKS OFF SCREEN AND KEEPS WALKING. Everything else in this file
// stops when it cannot see him, because a thing that chases you across
// half a level you cannot see is a timer rather than a presence. This
// one is going somewhere that is not him, so `inFrame` is none of its
// business — gate it and it would freeze at the edge of the view and
// never arrive.
const toward = Math.sign(d.homeX - d.x);
if (toward === 0 || Math.abs(d.homeX - d.x) <= T.HOME_SPEED * dt) {
d.x = d.homeX;
// Turning in the doorway to face the street it walked up. The last
// thing it does is look back the way it came.
d.facing = -(Math.sign(d.homeX - d.prevX) || d.facing);
// The journey is over, so the scene may stop paying for it. `homing`
// is what kept this one out of the distance cull; HOME is a state
// that does nothing and can be culled like any other.
d.homing = false;
enter(HOME);
break;
}
d.facing = toward;
d.x += toward * T.HOME_SPEED * dt;
players[HOMING].advance(dt * (T.HOME_SPEED / (d.clip.groundSpeed || T.HOME_SPEED)));
break;
}
case HOME:
// Nothing. Not a timer, not a sway, not a look. That is the point of
// the loop stopping.
break;
case RISING:
d.timer += dt * 1000;
players[RISING].advance(dt);
if (d.timer >= T.RISE_MS) {
d.hits = 0; // it got up. The count starts again.
// The one fork in this whole file that the player wrote.
enter(d.homing ? HOMING : RUSH);
}
break;
default:
}
if (d.state !== STAGGER) d.knockVel = 0;
applyPose();
};
/**
* Contact with the prince. Only a RUSH contact ends the run — and the
* gatekeeper's never does.
*
* He is dangerous only during the 150ms his spear is out, at spear range
* rather than body range, and what he does is SHOVE. You lose ground, not the
* run. A gatekeeper is keeping people away from a gate, not killing them.
*/
d.contactKind = (princeX, reach) => {
if (post) {
if (d.state !== RUSH || d.phase !== 2) return null;
return Math.abs(d.x - princeX) <= TUNING.POST_REACH ? 'shove' : null;
}
if (Math.abs(d.x - princeX) > reach) return null;
return d.state === RUSH ? 'fatal' : 'harmless';
};
enter(WANDER);
return d;
}