Spaces:
Running
Running
Download src/drained.js from cloudronin/astara: direct link, hf CLI and curl.
- Browser
- Download file 32.1 kB
-
https://huggingface.co/spaces/cloudronin/astara/resolve/main/src/drained.js
- Command line
-
hf download hf://spaces/cloudronin/astara/src/drained.js
-
curl -L -o drained.js https://huggingface.co/spaces/cloudronin/astara/resolve/main/src/drained.js
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; | |
| } | |