Spaces:
Running
Running
File size: 32,072 Bytes
71eae78 2428d78 71eae78 2428d78 d16591c 71eae78 9986f05 835b445 d16591c 71eae78 d16591c 71eae78 835b445 71eae78 d16591c 71eae78 2428d78 7d93bf0 2428d78 7d93bf0 2428d78 71eae78 2428d78 7d93bf0 2428d78 7d93bf0 2428d78 7d93bf0 71eae78 d16591c 71eae78 835b445 71eae78 d16591c 71eae78 2428d78 7d93bf0 2428d78 7d93bf0 835b445 9986f05 71eae78 2428d78 7d93bf0 2428d78 835b445 d16591c 2428d78 71eae78 2428d78 71eae78 2428d78 71eae78 d16591c 9986f05 71eae78 2428d78 71eae78 7d93bf0 71eae78 835b445 9986f05 d16591c 835b445 71eae78 835b445 9986f05 835b445 9986f05 71eae78 835b445 d16591c 835b445 71eae78 2428d78 71eae78 44bae7c 71eae78 2428d78 71eae78 d16591c 71eae78 d16591c 71eae78 d16591c 71eae78 2428d78 71eae78 d16591c 7d93bf0 2428d78 d16591c 2428d78 d16591c 7d93bf0 2428d78 71eae78 d16591c 71eae78 9986f05 2428d78 71eae78 2428d78 71eae78 2428d78 71eae78 835b445 71eae78 835b445 71eae78 2428d78 71eae78 2428d78 71eae78 2428d78 71eae78 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 | // 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;
}
|