// 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; }