astara / src /tuning.js
system's picture
system HF Staff
cbe9c42: Every paper is read aloud, by whoever wrote it
7b6d5a9 verified
Raw History Blame Contribute Delete
52.9 kB
// Every tuning value in the project, in one place, read once at boot.
// Spec section 7: "Nothing hardcoded at a call site."
//
// A few of these decide how something FEELS rather than how it behaves, and a
// feel gate is worthless if trying a value is expensive. Those are listed in
// OVERRIDABLE and can be set from the URL:
//
// ?down_ms=4000&hits_to_down=2
//
// Defaults are unchanged when nothing is passed.
// --- the torch light, shared by the demo and the level --------------------
//
// THE MASK STOPS LIVE HERE AND NOWHERE ELSE. The renderer builds its gradient
// from this table and the reveal test solves its threshold from the same table.
//
// This matters because the two had already drifted apart once. The original
// spec asks for three stops β€” opaque to 0.65, linear to zero at 1.0 β€” which puts
// 50% opacity at 0.825r. During G3 three more stops were added to kill a visible
// ring edge at the falloff, which moves 50% opacity to 0.891r. The light was
// signed off with the extra stops in, and nobody noticed the spec no longer
// described the shipped gradient.
//
// Deriving the threshold instead of writing it down means changing the falloff
// moves the reveal with it, and there is no constant left to go stale.
export const MASK_STOPS = Object.freeze([
[0.00, 1.00],
[0.65, 1.00], // spec: full opacity to 65% of the radius
[0.78, 0.86],
[0.88, 0.55],
[0.95, 0.22],
[1.00, 0.00],
]);
/** Where along the radius the mask first falls below 50% opacity. */
export function halfOpacityPoint(stops) {
for (let i = 0; i < stops.length - 1; i++) {
const [a, oa] = stops[i];
const [b, ob] = stops[i + 1];
if (oa >= 0.5 && ob <= 0.5 && b > a) return a + (b - a) * ((oa - 0.5) / (oa - ob));
}
return 1;
}
export const REVEAL_T = halfOpacityPoint(MASK_STOPS);
export const TORCH = ({
RADIUS: 340,
RAISE_MS: 250,
LOWER_MS: 400,
FLICKER_AMOUNT: 0.04, // +/- 4%
FLICKER_HZ: 12,
// NOW MEASURED FROM HIM, not from his hand, because the hand moves. It was
// 160 ahead of a hand that sat 38 ahead of him, so 198 is the same pool in
// the same place -- and the reveal reaches RADIUS + this = 538, which is
// what it always reached. The old 500 was 160 + 340, and understated it.
LIGHT_AHEAD: 198, // px in the facing direction, from HIM
// POSITIVE, i.e. the lit pool sits BELOW the flame.
//
// The demo spec says -40, putting the circle above the hand. That was written
// when the prince held the torch low; re-posing him from the Prince of Persia
// reference raised the arm, and the light went up with it. The circle's lowest
// point ended up at y=851 with the ground at 880 β€” the torch did not reach the
// ground he was standing on, so nothing on it was ever revealed.
//
// A flame is the source but the pool it throws is below and ahead of it, so
// this is also the truer arrangement.
// 80, not 40. At 40 the ground sat at 0.71 of the radius β€” right where the
// mask starts fading β€” so the pool on the ground was dim and muddy. At 80 it
// falls inside the 0.65 full-opacity plateau and actually reads.
// Also from him now, and UP is positive. It was +80 below a hand at -110,
// so the centre sat 30px above the ground, and it still does.
LIGHT_LIFT: 30, // px above the ground line
GLOW_RGB: '255,180,90', // #ffb45a
GLOW_SPREAD: 1.35,
// Raising the torch dims everything outside the circle. This is where the
// torch gets its power back without darkening the world it has to be seen
// against β€” and it earns its keep twice, because looking hard at one thing
// costing you sight of everything else is exactly what this demo is about.
DIM: 0.42,
DIM_START: 0.55, // fraction of the radius where the dimming begins
DIM_RGB: '4,8,18',
// The kept object catches the light. The art brief calls it "the only thing
// in the lit version that is fully sharp" β€” black line work on warm ground
// was sharp and still got lost among the body's own lines, so it gets a halo
// behind it. It is the payload of the entire looking mechanic; it is allowed
// to be the brightest thing inside the circle.
// 0.62, not 1.0. At full strength the halo's centre clipped at 255 and the
// pulse could only move it 11 levels β€” a breath you could measure and not
// see. Additive light has to stay off the ceiling to have anywhere to go.
KEPT_GLOW: 0.62,
// The glow does not wait for the torch. Something fell out of a body and the
// ground is pure black, so a warm pulse out there is a thing you notice from
// across the screen β€” and it is the only reason to walk over and look. The
// torch still earns its keep: it is what turns the glowing dot into a
// blacksmith's hammer. Only ever drawn for an object that has been dropped;
// one still inside a walking Drained stays black, or the whole premise goes.
KEPT_GLOW_DARK: 1.1, // share of the lit halo's strength, out in the dark
KEPT_GLOW_R: 1.45, // multiples of the object's half-width. Tighter = hotter
KEPT_RGB: '255,214,150',
// The object keeps its BLACK line work. Recolouring it pale made it vanish
// into the glow β€” it needs the dark outline to have an edge at all. The
// colour goes in the halo behind it instead, so the object sits in its own
// hot pool and separates from the body's lines that way.
KEPT_RIM: '255,198,110',
// And the halo breathes. A steady glow reads as decoration; one that rises
// and falls reads as something that came loose and is still there, which is
// the whole point of it lying beside the feet rather than on the body.
KEPT_PULSE_HZ: 0.55,
KEPT_PULSE_DEPTH: 0.55,
})
// --- the lightning --------------------------------------------------------
//
// PIN A PERSON'S OWN WORDS BACK ON THEIR OWN DOOR AND THE SKY GOES OFF.
//
// It replaces a silence. A correct pin used to make the same small sound as a
// wrong one, and the payoff β€” a body getting up β€” happens somewhere else in
// the village, possibly off screen, up to a minute later. So the one right act
// in the level looked exactly like nothing.
//
// A FLASH RATHER THAN A SOUND, and that was the right call for two reasons.
// Sound on a phone is unreliable and often simply off. And this game is black
// shapes against a lit sky: a flash is the one effect that costs nothing to
// read, because the whole picture is already built to show a silhouette
// against light. For a quarter of a second the sky goes white and you see the
// entire street β€” every shape in it, with no torch. The one moment in this
// game you see without looking.
//
// IT LIGHTS THE WORLD, NOT THE FRAME. Drawn after the ground and the houses
// and BEFORE the people, so the sky and the hills go bright and every figure
// stays pure black. A wash over the finished frame would lift the silhouettes
// too and flatten the only thing that matters.
export const FLASH = Object.freeze({
// 560, NOT 900. At 900 the strike itself was right and the last third of it
// was a pale veil sitting over the background at 0.05-0.08 for half a second
// after the storm had finished β€” which is not lightning, it is a filter. The
// two strikes land in the first 135ms either way; this only cuts the haze.
MS: 560,
// Near white with the sky's own blue in it, so it reads as weather rather
// than as a screen effect.
RGB: '226,240,250',
// Lightning stutters. One clean spike reads as a camera flash; a spike, a
// dip and a second bigger strike reads as a storm. Same table idiom as
// MASK_STOPS: [fraction of MS, strength].
//
// THE FIRST 60ms ARE DELIBERATELY DARK. The bolt and the flash used to start
// together, which meant the bolt spent its opening frames being washed out
// by the thing it is supposed to have caused β€” visible only in the dip, for
// about two frames. The sky holds still while the bolt is drawn on it, and
// only then goes off. Cause, then effect.
STOPS: Object.freeze([
[0.000, 0.00],
[0.110, 0.00], // 62ms β€” the bolt draws on a dark sky
[0.160, 0.85], // 90ms β€” the first stroke
[0.250, 0.12], // 140ms β€” the dark between, and the bolt at its widest
[0.340, 1.00], // 190ms β€” the one that lands
[0.500, 0.28], // 280ms
[0.720, 0.06], // 403ms
[1.000, 0.00], // 560ms β€” out
]),
// THE BOLT ITSELF, eight frames of it, and it is the only thing in this game
// that is light rather than a shape blocking light. 25ms a frame puts the
// whole strike inside 200ms, so it is drawn, peaked and gone before the
// flash has finished β€” the bolt is the cause and the white sky is the effect.
BOLT_FRAME_MS: 25,
// How tall, in screen pixels. The source is 120px pixel art; at 480 it reads
// as a branched bolt and at anything smaller it disappears into a lit sky.
BOLT_H: 480,
// Where across the frame. He stands 300px left of a door to pin something,
// which puts the house on the right of the view and leaves open sky here.
BOLT_AT: 0.2,
// THE CRACK, 90ms behind the light.
//
// It used to be 320 for a far-off rumble. The recording is a CLOSE clap and
// it carries its own delay: the front rises over 300ms and peaks 0.40s into
// the clip. So 90 here puts the peak at half a second, which is the flash
// fading as the sound arrives. Light, then a beat, then it lands.
THUNDER_MS: 90,
});
/** Flash strength at a moment, off the table. 0 when nothing is happening. */
export function flashAt(ms) {
if (!(ms > 0) || ms >= FLASH.MS) return 0;
const u = ms / FLASH.MS;
const st = FLASH.STOPS;
for (let i = 0; i < st.length - 1; i++) {
const [a, va] = st[i];
const [b, vb] = st[i + 1];
if (u >= a && u <= b) return va + (vb - va) * ((u - a) / (b - a || 1));
}
return 0;
}
// --- the palette ----------------------------------------------------------
//
// THE TWO-SIDED RULE, measured off Blade Warrior rather than guessed at.
//
// Luminance histogram of a real gameplay frame, in buckets, because exact
// colour counts on a lossy video are meaningless β€” that frame reports 34,653
// distinct colours in what is a 16-colour image:
//
// under 30 30-90 90-160 over 160
// Blade W. 18.7% 1.1% 39.4% 40.7%
// this build 22.9% 30.4% 46.7% 0.0% <- before this change
//
// 1. NOTHING LIVES BETWEEN 30 AND 90. Blade Warrior goes from black straight to
// mid-tone and refuses a band 128 levels wide. The old treeline sat at
// luminance 58 and was 23.2% of the frame on its own. That was the mud.
//
// 2. SOMETHING SUBSTANTIAL HAS TO BE ABOVE 160. The old build had *zero* pixels
// up there. Silhouettes only read against light; there was no light.
//
// A REFINEMENT found while building it: the rule bans the 30-90 gap and
// requires a bright mass. It does NOT require every sky value to be bright.
// Blade Warrior's own sky spans the boundary β€” its darker reaches sit in the
// 90-160 band, which is why that game reads 39.4% mid against 40.7% bright. A
// sky that is uniformly above 160 came out at 63% bright and 16% mid, and read
// as washed out rather than as night.
//
// Every value below is annotated with its luminance so the rule stays checkable
// by eye as well as by the standing test.
export const PALETTE = Object.freeze({
CLOUD: '#b8d4e0', // lum 205 β€” the brightest thing on screen
SKY_HIGH: '#8aa8c0', // lum 162
SKY_LOW: '#63809c', // lum 122
HILLS: '#4e657e', // lum 97 β€” near the floor, with a little more margin
// The watchers are NOT the prince's black. Standing one beside him at the
// same value merged the two into a single mass, which is the one thing the
// whole silhouette read depends on not happening. This overrides the Blade
// Warrior amendment's "watchers pure black" line, decided by looking.
//
// There is exactly one place to put them. The hills are already at the floor
// of the forbidden zone and the zone owns everything from 30 to 90, so a
// value below the hills has to clear 30 from underneath. 26 is as far from
// black as the rule allows.
WATCHERS: '#131b24', // lum 26 β€” under the forbidden zone, and not black
BLACK: '#000000',
});
// WHY THE HILLS ARE AS DARK AS THEY LEGALLY CAN BE, AND NO DARKER.
//
// 65% of the torch circle sits over the hills band, not the sky β€” the light is
// at chest height and the sky is above it. So the hills are what the torch has
// to read against, and at luminance 123 they washed it out.
//
// Taking them to black fixed the torch and broke the prince. He stands entirely
// inside that band, so black hills left him black on black with nothing to read
// against. A worse problem than a weak torch.
//
// He and the light occupy the same stripe of screen, so no single background
// value can be bright for him and dark for it. The hills therefore sit at 92,
// the darkest the forbidden zone permits, and the rest of the contrast is taken
// out of the TORCH instead β€” see TORCH_DIM below.
// How bright the sky is, and where. The brightness is a horizontal band sitting
// just above the horizon, not a blob and not a top-to-bottom ramp β€” the figures
// stand against the horizon, so the light belongs behind them.
//
// PEAK is the one to touch. Raise it for a brighter night, lower it for a
// darker one. It feeds a dither between three palette entries, so at 1.0 the
// band's centre is solid CLOUD and at 0.5 it never reaches CLOUD at all.
export const SKY = Object.freeze({
BASE: 0.11, // the darkest reaches, top of frame and down at the horizon
PEAK: 0.72, // height of the band above BASE
CENTRE: 0.46, // where the band sits, as a fraction of frame height
SPREAD: 0.26, // how far it falls away, as a fraction of frame height
});
/** Rec.601 luminance, the same measure the standing test uses. */
export function luminance(hex) {
const n = parseInt(hex.slice(1), 16);
return 0.299 * ((n >> 16) & 255) + 0.587 * ((n >> 8) & 255) + 0.114 * (n & 255);
}
/**
* The same hue at a different luminance. The back lane is the watchers' blue
* taken darker, not a grey: a figure beside the road belongs to the same world
* as the crowd in the trees, only nearer.
*/
export function atLuminance(hex, target) {
const n = parseInt(hex.slice(1), 16);
const k = target / Math.max(1e-6, luminance(hex));
const c = (v) => Math.max(0, Math.min(255, Math.round(v * k))).toString(16).padStart(2, '0');
return '#' + c((n >> 16) & 255) + c((n >> 8) & 255) + c(n & 255);
}
// --- the level β€” spec section 7 -------------------------------------------
export const TUNING = {
// 430 because the ART says so, not by feel. Her run cycle implies 600 px/s
// (a 150px step, twice a cycle, 12fps). Playback is scaled by actual speed
// over that, so 220 played the run at 0.37x β€” a man sprinting in treacle.
// 430 gives 0.72x and a 0.70s cycle, which is a run.
//
// It costs pacing: the level crosses in 49s instead of 105s. The fix for that
// is a WALK cycle, not playing a run at a third speed.
WALK_SPEED: 430,
HITS_TO_DOWN: 3,
KNOCKBACK_PX: 160, // constant. Never scales with hit count
STAGGER_MS: 500,
// The spec called this "the most likely value to be wrong" and it was. At
// 8000 a downed Drained was back on its feet almost immediately and simply
// resumed the fight, which made putting it down feel like nothing happened.
// At 60000 it stays down long enough that you look at it, leave, and may meet
// it again much later β€” which is the beat the level is built on. 8000 made
// putting it down feel like nothing had happened; 30000 was still short.
DOWN_MS: 60000,
RISE_MS: 900,
DRAINED_SPEED: 55, // unchanged. It never hurries
RUSH_SPEED: 165,
// WALKING HOME, and it is its own number because the wander speed cannot
// carry it. At 55 a body at one end of the village walking to a house at the
// other takes four and a half minutes, and the player has to stand at that
// door and wait: filing happens AT the frame, so he is always the one waiting.
//
// 260 makes the longest walk about a minute, which is time he spends walking
// anyway, and the gatekeeper's β€” 870px, now that the gatehouse stands beside
// the gate β€” about three seconds, on screen, which is what makes him the
// tutorial rather than an off-screen event.
//
// AND IT IS STILL NOT A RUSH. The prince walks at 430 and the rush is 165 at
// a sprint cycle; this is 60% of the prince on a walk cycle, so he can always
// outpace them and they never read as coming for him. They are not.
HOME_SPEED: 260,
// HOW LONG THE TORCH CAN SIT ON SOMEONE BEFORE THEY COME FOR YOU, and it is
// the number that decides whether looking is a skill or a formality.
//
// 400 AND IT WAS A FORMALITY. The torch takes about 250ms to get bright
// enough to show the page a carrier is holding, and `covers()` starts this
// clock the moment the growing circle touches them β€” so the instant a shape
// was readable it had already woken. Measured: the shortest hold that shows
// you a shape and the shortest hold that wakes it were the same 0.25s.
//
// Which left no way at all to tell a carrier from a decoy without fighting
// it, so the only strategy was to hit all twelve β€” and a clean run only has
// six hits in it. The loops narrowed twelve to a group and nothing narrowed
// a group to a person.
//
// 800 opens a window of about 550ms: sweep the light across a shape, see
// whether it is carrying anything, and drop it before it comes. Looking is
// this game's one verb and this is the first place it pays.
REVEAL_MS: 800,
// THE TWO LANES. Every player who saw a Drained on the road fought it,
// because no side scroller lets you walk past an enemy on your own line β€”
// you beat it, jump it or go round it, and no hint overrides a genre. So an
// unlit Drained does not stand on his line. It stands beside the road, in a
// back lane, and the plane says the state: beside you is harmless, in front
// of you is in your way.
//
// A RENDER TRANSFORM, NOT A WORLD POSITION. Same x, same scroll rate as the
// road, no parallax of its own β€” a lane that drifted against the road would
// need reconciling every time something stepped down out of it.
//
// All three are start values from the spec, to be tuned by eye.
BACK_LANE_SCALE: 0.82,
// Feet this far above the road's ground line. MEASURED, because the spec
// feared it was the thing that broke the mechanic: raising a figure moves
// its middle UP, and a torch at hip height would stop reaching it. The torch
// is not at hip height any more β€” the flame is 317px up β€” so the rise moves
// them TOWARD the light. Lit from 339px ahead in the lane against 328 on the
// road.
BACK_LANE_RISE: 56,
// Luminance of the figure's fill. It has to sit between the prince and the
// watchers, as far from each as it can get, and under the no-mud band.
//
// MEASURED, NOT GUESSED, which the spec asked for. 14 was its starting
// value, from the palette's 0 for him and 26 for them. On screen he reads 2
// and a watcher reads 41, because the watchers are hazed toward the sky. The
// midpoint of what is actually drawn is 21: 19 clear of him, 20 clear of
// them. At 14 it was 12 clear of his black and 27 of theirs. See laneReads.
BACK_LANE_VALUE: 21,
// THE STEP DOWN. Reveal to rush, not hittable, and the only moment a Drained
// changes lane on its own. The stare wakes at 800 and this is 400 more, so
// it is 1.2s from "I am looking hard" to "it is on the road coming at me" β€”
// the warning the genre expects before an enemy engages.
STEP_MS: 400,
// THEY WATCH YOU PASS. Walking through one used to produce no reaction at
// all, so a player who tried it got no evidence it was safe. Inside NEAR it
// turns its whole body to face him and follows him past; outside FAR it goes
// back to what it was doing; in between it holds, so it cannot flicker.
WATCH_NEAR: 300,
WATCH_FAR: 600,
// THE STARE SHOWS WHILE IT BUILDS. On his road, a drained taught its own
// rule by being in the way; beside it, nothing said these people mattered
// or that a long look provokes them. So the light does something at once:
// the moment it lands, the figure stops what it was doing and turns to face
// it, and for the rest of REVEAL_MS it trembles harder and harder. A glance
// shows you a man who noticed; a stare shows you a man about to move.
STIR_TREMBLE_PX: 3,
// IN THAT ORDER. The gasp and the turn happen the instant the light lands;
// the tremble does not start until this share of REVEAL_MS has gone, so the
// three read as three beats β€” it noticed, it is working up to it, it woke β€”
// rather than one blur.
STIR_TREMBLE_FROM: 0.35,
// THE GATEKEEPER. He never leaves his post, so the fight is entirely about
// his timing rather than about position.
//
// The windup is long and loud because it has to be READ β€” a large outline
// change in flat black, from far enough away to do something about it. The
// recovery is the window, and it is measured against the prince's own swing:
// 420ms committed, so 700 fits one hit and the step back out, and only just.
//
// His reach out-ranges the sword on purpose (260 against 175). There is no
// safe distance to hit him from; you step inside it and back out again.
// A beat of ready between cycles, and only a beat. At 400 he spent a quarter
// of every cycle doing nothing and a player could stand at the wall, mash,
// and put him down in 1.9 seconds having taken one shove. 120 makes the
// cycle 1470ms, which is one hit per cycle and three cycles to down him.
POST_READY_MS: 120,
POST_WINDUP_MS: 500,
POST_THRUST_MS: 150,
POST_RECOVER_MS: 700,
POST_REACH: 260,
// He shoves, he does not kill. A gatekeeper is keeping people away from a
// gate, not killing them, and losing ground is the right cost for being late.
//
// JUST OUT OF REACH, and the number is derived from the window rather than
// picked. At 340 the shove put the prince 600px off; closing back to sword
// range takes 990ms and the recovery is 700, so even perfect play could never
// get back in β€” he went from beatable by mashing to unbeatable outright, two
// hits in forty seconds.
//
// At 20 he barely moved and mashing won in 1.7 seconds. At 120 the shove
// lands the prince 380 out: closing to sword range is 205px (477ms) and the
// swing is a committed 420, which is 897 against a 700ms window. So a shove
// costs you the rest of that cycle β€” you land the next hit during his windup
// instead, and being shoved is worth about a second and a half.
//
// WITHOUT HEALTH HE CANNOT BE MADE HARD, ONLY SLOW. The only currency in
// this build is time and ground, which is exactly what section 9 of the spec
// says and exactly what the V5 stop is for.
POST_SHOVE_PX: 120,
ATTACK_WINDUP_MS: 120,
ATTACK_ACTIVE_MS: 80,
ATTACK_RECOVERY_MS: 220,
// --- HEALTH, and the reason it is here ------------------------------------
//
// Village one played like a beat 'em up, and it played that way because
// beating up every Drained cost nothing: three hits, no health, no death. So
// the optimal play was to hit everything, and the game agreed.
//
// The spec's reasoning for leaving health out was wrong, and its author said
// so: the question was whether the SEARCH is tedious, and tedium measures
// fine at zero risk β€” but at zero risk there is no search at all, just a
// sweep. A sweep cannot be tedious because it is not looking for anything.
//
// SIX, not three. Three plus a ten-screen restart means one mistake costs
// fifteen minutes and people reload the page instead of playing on. Six makes
// a death a run of bad decisions rather than one bad moment.
//
// It does not come back. Six is six for the whole run, and a restart is the
// only thing that resets it.
PRINCE_HITS: 6,
// HOW FAR THE EYE DIMS ON THE LAST HIT. Not to black: a man one hit from the
// end still has something in his face, and going fully out is what dying
// looks like.
//
// 0.62 AND THE REASON IS THE FORBIDDEN ZONE. The eye is white over black, so
// it lands at 255 x (1 - dim): at 0.8 that is 51 and at 0.67 it is 84, both
// inside the 30-to-90 band this whole build refuses to put anything in. 0.62
// lands on 97, just clear of it, and 255 down to 97 is still most of the
// range. Dying takes it the rest of the way, and that is a transition rather
// than a state.
EYE_DIM_MAX: 0.62,
HURT_GRACE_MS: 900, // caught once, then a moment to get clear
HURT_KNOCK_PX: 260,
// NO HEALTH BAR. It is read off his body, the same rule as everything else:
// he limps, he holds his side, and the swing slows.
//
// The limp is HER run frames on an uneven cadence with a dip on the bad side,
// not a new drawing. A limp is an asymmetric rhythm, so this is the truer
// version as well as the cheaper one.
LIMP_FROM: 2, // hits taken before the walk goes uneven
LIMP_HITCH: 0.42, // how much slower the bad foot's half of the cycle is
LIMP_DIP_PX: 7,
// The swing slows as he goes, by hits taken. A cost he can feel in the one
// thing he was using to make the cost not matter.
SWING_SLOW: [1, 1, 1.1, 1.24, 1.42, 1.66],
// HIS ORDERS, AT THE FRONT, AND THEY WAIT FOR HIM. The page is up from the
// first frame and stays up until any input puts it down -- normally he
// would have to HOLD Q for that, and this is the one time he does not.
//
// This is an upper bound for anybody who wants one, and 0 means there is
// none: it reads at the player's speed rather than at a number's. The
// override key is lowercased -- ?order_hold_ms=1 dismisses it on the first
// frame, which is how you check the front of the game is otherwise
// unchanged.
ORDER_HOLD_MS: 0,
LOSS_HOLD_MS: 800,
// Death restarts the LEVEL. The spec's checkpoint at the mouth keeps the map,
// and a checkpoint that keeps the findings is too forgiving to change how
// anybody plays. The findings have to be at stake.
DEATH_FADE_MS: 1600,
// RECOVER_GRACE_MS and RECOVER_PUSH_PX lived here and are gone. They were for
// getting up where he fell, which is not what happens any more: being caught
// costs one of six, and the sixth restarts the level. See PRINCE_HITS.
// How close he has to be to take the object off the ground. The drop sits
// 225px out from the body, and he stops about 170px short of it when he
// fights, so this makes him walk the last stretch on purpose.
PICKUP_REACH: 120,
// How close he has to be to a door to read its letter or pin to its slot,
// once he has lit it. Wider than PICKUP_REACH because both are up on the
// wall rather than at his feet, and wide enough to cover anywhere he could
// have lit it from β€” he reads it standing about 300px off.
DOOR_REACH: 440,
// How long he has to stand still at a door he has lit before the band reads
// it to him again. The first read needs the torch; every read after that is
// free, or comparing a door with the page in his hand takes two buttons, and
// on a phone both are under one thumb. Standing still is what stops it
// opening at every door he walks past β€” and the wait is what stops it
// flashing half open between two swings of the sword.
READ_STILL_MS: 300,
// HOW LONG A PAPER HAS TO BE UP BEFORE IT IS READ ALOUD. Longer than a page
// turn's 260ms beat, so paging through the pile with an arrow held flips
// past every page in silence and only the one he stops on speaks.
VOICE_DELAY_MS: 400,
// THE OPENING'S FIRST PRESS STARTS HIS ORDERS BEING READ rather than putting
// them away -- a browser cannot make a sound before a press. If the voice
// has not started this long after that press, a press ends it as before.
VOICE_WAIT_MS: 2000,
// The gate wants four objects he has LOOKED at. Not four objects.
GATE_WANTS: 4,
GATE_OPEN_MS: 1400, // how long the leaf takes to lift once it does
// THE ROAD IS NOW AN APPROACH, NOT THE LEVEL. It was twelve screens, and the
// village had to grow to about ten (see BOWL_W), which together is a
// hundred-second walk to the gate. One of them had to give and it is this
// one: the road's job is to close the canopy over him, teach the fight and
// make arriving somewhere land. Four screens does all three.
LEVEL_LENGTH_PX: 8000, // about four screens
// Three, not five. Five encounters were spread over twelve screens; at four
// they land every 0.9 of a screen, which is the same crowding the village
// was failing on, in the place that is supposed to be the quiet part.
ROAD_ENCOUNTERS: 3,
// THE TREE WITH THE NOTE ON IT, as a share of the road. Two-thirds: past
// the first drained, so "they" has somebody to mean, and before the first
// house, so it is read before the first fight.
TREE_AT: 0.66,
// AND NOBODY STANDS NEAR IT. Placed first, the way the doors are, and every
// road drained kept this far off. Not a guess: he reads it from about 300px
// away, and the torch lights a figure up to 339 ahead of him and 214 behind β€”
// so lighting the note can never wake anybody, from either side.
TREE_CLEAR: 700,
// THE VILLAGE. The road ends and the bowl begins. This replaces the ending
// that used to be here β€” thirty-four watchers stepping onto the path until he
// could not get through. That was a placeholder from the first day.
//
// WIDTH IS THE V1 JUDGEMENT and nobody has measured it. The guess on record
// was two and a half screens, which at eleven shapes is one every 435px
// against a body 260px wide β€” the crowd wall again, in a different hat. Five
// screens puts them 870px apart and about two on screen at once, so you see a
// few and walk to them. Overridable from the address bar, because the whole
// point of V1 is to sweep it in one sitting.
//
// 18000, AND THE NUMBER CAME OUT OF THE TORCH.
//
// At 9600 every one of the five houses had a Drained inside the radius the
// torch reveals at β€” the forge had one 59px from the door. Holding the torch
// freezes him and takes his sword away, so looking at a single door woke
// something up with nowhere to back off to. You could not examine one house
// in the whole village.
//
// The arithmetic, which is what set this rather than feel: the torch reveals
// out to RADIUS + LIGHT_AHEAD = 538px (it read 500 while LIGHT_AHEAD was
// measured from his hand and the hand sat 38 ahead of him; the pool never
// moved, only the way it is written down), so a door needs 800px of clear ground
// either side to be lookable at. Five houses reserve 8000px. Eleven shapes
// want 750px each β€” the torch is 680 across, so closer than that and one
// raise wakes two β€” which is 8250. Plus margins, about 18000.
//
// 20000 rather than the 18000 that arithmetic first gave, and the extra came
// from measuring the built thing: at 18000 the shapes averaged 1399px apart
// and still bunched to 591 in one place, under the 680 the torch is WIDE β€”
// close enough that one raise wakes two. Spacing has to survive the jitter
// that stops the street looking like fence posts, so the free ground has to
// carry 11 x 900, not 11 x 765.
//
// At WALK_SPEED 430 this is a 46-second crossing and 140 seconds for three.
BOWL_W: 20000,
// No shape stands closer than this to a door. It is the whole reason the
// village is the size it is, so it is placed FIRST and the shapes go in what
// is left over β€” not scattered and then checked.
BOWL_CLEAR: 800,
// The forest's watchers thin out in the village, on top of the clearing's own
// reduction. Six on screen mid-village plus the shapes read as a crowd, and
// this place has been empty for sixty years. The crowd belongs to the trees.
BOWL_WATCHERS: 0.45,
// Twelve fightable shapes, four of them carrying, and the background watchers
// untouched behind them. Two populations, not one: the watchers are what make
// it feel like a village and they cost nothing, and the twelve are what you
// walk to. One carrier in three is the ratio the search hangs on β€” if only
// carriers did trade loops the loop skill would resolve the bowl instantly.
BOWL_CARRIERS: 4, // 3 out in the bowl + the gatekeeper at the gate
BOWL_DECOYS: 8,
// THE CLEARING, and it is not decoration. depthAt saturates at 1 past the end
// of the road, so without this the village sits under a fully closed canopy:
// 470px of sky eaten and the watcher layer at maximum density. V4 asks whether
// you can read a hammer-shaped bulge in an outline, and an outline needs
// something behind it. A village IS a clearing, so this is the right art and
// it is also the only way V4 is judgeable at all.
BOWL_CANOPY: 0.45, // canopy closure inside the bowl, against 1.0 outside
BOWL_OPEN: 0.2, // fraction of the bowl the clearing opens over
// Where he starts, as a fraction of the level. Only ever moved from 0 to look
// at something: the watchers do not begin until about a quarter in, and
// walking there takes a hundred seconds.
START_AT: 0,
WATCHER_DENSITY_START: 0,
WATCHER_DENSITY_END: 10,
// Camera: a dead zone across the middle third, no easing, no overshoot.
CAMERA_DEAD_ZONE: 1 / 3,
// The canopy closes over as he goes deeper, and the SAME curve drives watcher
// density. One curve, two systems. The torch stops being optional because the
// sky ran out, not because a rule made it required.
CANOPY_START: 0.12, // fraction of the level where the canopy first appears
CANOPY_FULL: 0.90, // where it has closed over
// How much sky it eats at full closure. At 900 it covered everything and the
// frame became a letterbox slit; the canopy is meant to close IN, not to be a
// black bar. This leaves a band of sky still visible at the deepest point, so
// there is something left to lose and the torch has something to beat.
CANOPY_MAX_DROP: 470,
};
// --- tone: haze and softness by distance ------------------------------------
// Limbo carries depth with atmosphere, not with parallax alone: the further a
// layer is, the more it fades toward the background and the softer its edge.
//
// THE TERRACES. Fields cut into the hillside behind the road, so the back lane
// is ground a Drained actually stands on and steps down from, rather than a
// height it floats at.
//
// Three flat steps run the length of the level: the first IS the back lane,
// and the third is where the crowd stands. Above them, rows of fields climb
// the lower slopes of the hills themselves, following the ridge and
// flattening as they come down. Nothing is ever drawn above the hills'
// outline β€” the hills stay the furthest thing, and the fields run up into
// them.
//
// ONE GRADIENT, from muted brown by the road to the hills' own colour. Tried
// on the hills' blue first and they read as waves on a lake; tried as their
// own hills and they buried the real ones; tried dark and his legs sank into
// them.
//
// AS DARK AS THE NO-MUD RULE ALLOWS, AND NO DARKER. The steps sit behind his
// legs, so they are exactly what figure contrast measures: 94 here reads 93
// against its floor of 90. A dark brown at 55 sat better with the road and
// failed that check outright β€” decided against, for now.
//
// NOT YELLOW. Played through, the light bands between the steps read as
// yellow: the field tops were an olive-khaki twenty levels over their walls.
// They are the walls' own brown now, only six levels lighter, and each step
// gets its edge from a thin dark line underneath instead β€” the one direction
// the no-mud floor leaves room to go. The brown itself moved redder: yellow is
// red and green close together, and this pulls green down.
export const TERRACE = Object.freeze({
WALL: '#6e584c', // the retaining walls: deep earth brown
FIELD: '#705c4c', // the field tops along each edge: the same earth
WALL_LUM: 94,
SHADOW_LUM: 91, // the line under each edge, right on the floor
// Near to far. `above` is px over the back lane's own edge, so the three
// move together if BACK_LANE_RISE is tuned. `lip` is how much brighter the
// field edge is than its wall; `blend` how far toward the hills' colour.
// The third one scrolls at the crowd's own rate, because they stand on it.
STEPS: Object.freeze([
Object.freeze({ above: 0, rate: 1.00, wallUp: 0, lip: 6, lipH: 6, blend: 0.00, tufts: true }),
Object.freeze({ above: 28, rate: 0.84, wallUp: 2, lip: 6, lipH: 5, blend: 0.14, wobble: 5, seg: 430 }),
Object.freeze({ above: 56, rate: null, wallUp: 4, lip: 6, lipH: 5, blend: 0.30 }),
]),
// The rows up the hills, as a share of the way from the third step to the
// ridge, top row first. Four, and only low down: nine thin lines all the
// way to the ridge read as an optical illusion.
ROWS: Object.freeze([0.46, 0.34, 0.22, 0.11]),
});
// All of this was BANNED until the palette rule changed. Haze puts a layer in
// the old forbidden zone by definition, and blurring anything black dragged an
// edge from 0 to 163 straight through it. With tone allowed in the background
// β€” actors, trunks and ground still pure black β€” both are now legal.
//
// The two things that keep it honest are checks.js: figure contrast (can the
// boy be seen) and watcher contrast (can a watcher be seen against the hills).
// Hazing the watchers is gated on the second one.
export const TONE = {
HILLS_HAZE: 0.34, // toward the sky. Lifts 97 and softens the ridge
HILLS_BLUR: 7, // measured free: 0.6 points of bright at radius 4
WATCHER_HAZE: 0.12, // gated on watcher contrast, which holds at 66
CANOPY_HAZE: 0, // newly legal
CANOPY_BLUR: 10, // newly legal, and free: it sits above the figure band
// Newly legal, and the ONE soft cue that is not free. Near trunks cross the
// middle of the frame at 1.8x, which is exactly where the prince stands, so
// their blurred edge fogs the thing he has to read against. Measured: radius
// 8 leaves figure contrast at 92, radius 14 drops it to 75, radius 20 to 65.
//
// Set to 4 by eye, not by the limit. 8 was where the TEST stopped allowing
// more, which is a different question from where it starts looking wrong β€”
// the constraint turned out never to be the binding one.
TRUNK_BLUR: 4,
TRUNK_WARM: 0.6, // 0..1 toward a warm brown. NEVER lighter, only hued
VIGNETTE: 0.55, // newly legal, and the most Limbo-defining of the lot
// --- three that are OFF, and are here to be judged by eye ----------------
//
// Same deal as the DEPTH block below: built, tunable from the address bar,
// and worth nothing until somebody looks at them side by side.
//
// GROUND MIST. Every figure in this game meets a hard ground line, and a low
// mist is the oldest trick there is for separating planes and hiding feet.
// Drawn IN FRONT of the actors, because fog behind everybody is a backdrop
// and fog in front of them is weather.
// ON, at a level where the ground's jagged edge still reads through it. The
// first pass was one flat linear gradient and it looked like the sky,
// because a smooth even band that never moves is shading, not weather. It is
// three banks of overlapping puffs now, drifting at 7, 16 and 34 px/s
// against the camera, spaced wider than they are round so the top edge dips
// between them. Above about 0.8 the ground line dissolves and it goes back
// to being a wash; see drawFog.
FOG: 0.6, // 0..1 density
FOG_H: 190, // how high it stands off the ground, in px
// THE SKY GOING OUT AS HE GOES IN. The canopy already closes on depthAt();
// the sky behind it does not change at all, so a hundred seconds of walking
// deeper never looks like deeper. This is how much light the sky loses by
// the time the canopy has shut.
SKY_SINK: 0, // 0..1
// A LIVER FLAME. TORCH.FLICKER_AMOUNT is 4% at 12Hz, which is a clean circle
// with a tremble. Fire is not clean. This adds a slow irregular wander on
// top, so the pool breathes rather than buzzes.
//
// ON. Judged against the flat version side by side and kept.
TORCH_LIFE: 1, // 0..1
};
// --- the depth experiment ---------------------------------------------------
// ALL OFF BY DEFAULT. Nothing here is a feature yet; see the spec and the table
// in docs/FINDINGS.md. Toggled from the address bar, and X cycles them one at a time
// because judging these by eye needs fast A/B and typing five URLs is not fast.
export const DEPTH = {
OCC: 1, // near-layer density multiplier. 1 leaves it alone
// ON. Dust in the air, judged side by side against the still version and
// kept. Nothing in this game moves except people, and it showed.
PART: 300, // total particles on screen, 0 = off
PART_HARD: 0, // 1 = integer-aligned rects (no antialiasing, no zone cost)
BLUR: 0, // px of blur on the HILLS. Never on the sky, never on black
BLUR_SKY: 0, // measured at 12.5 points of bright mass for no depth gained
GRAIN: 0, // alpha of the noise tile over the finished frame
SHAFT: 0, // 0..1 strength of the light shafts. Judged: no visible gain
MOTE: '#dbeef7', // far motes: ABOVE the sky's brightest, so the edge is free
};
// --- sound ------------------------------------------------------------------
// A departure: the demo spec says no audio, twice. Taken knowingly.
//
// There are no sound files, so these are generated the way the art is β€” rough
// placeholders made to be replaced. Volumes only; everything about how a sound
// is SHAPED lives in audio.js next to the code that makes it.
export const AUDIO = {
MASTER: 0.5,
FOOT: 0.55, // twice a stride, so it has to sit under everything else
// QUIETER THAN HIS OWN FEET, and not by taste. His boots are the thing you
// are always hearing; a drag out in the dark is a thing you notice. It also
// fires for anything walking within a screen and a half of him, which can be
// three bodies at once, so it has to sit well under a single pair of boots.
DRAINED_FOOT: 0.34,
// WALKING HOME, HEARD FROM OFF SCREEN. Played through a whole level, nobody
// could tell the drained were walking home: the flash says something
// happened, and the body getting up and walking to its door happens a
// screen or four away, silent. So one on its way home is heard wherever it
// is β€” full level within a screen of him, falling to FAR by REACH px away,
// and panned to the side it is on. Its door is usually where he is standing,
// so what he hears is someone coming toward him.
HOME_FOOT_FAR: 0.3,
HOME_FOOT_REACH: 3500,
// The breath a drained takes when the light first lands on it. Under the
// wake, which is the moment it is too late; this is the moment it is not.
STIR: 0.5,
// ONE PER KIND, and quiet on purpose. Up to three of them are on screen at
// once, each firing once a cycle at 6fps over eight frames β€” so about two a
// second between them, for the whole crossing. Loud enough to say where
// somebody is, never loud enough to be the thing you are listening to.
DRAINED_VOICE: 0.40,
// The torch coming up. It fires every time he raises it, which is constantly,
// so it has to sit under his own boots.
TORCH: 0.30,
// The instant a shape notices him. Louder than the voices on purpose: it is
// the moment looking costs him, and it happens about twelve times a run.
WAKE: 0.62,
// The village under everything. Quiet enough that you notice it when it
// stops, which is what a bed is for.
VILLAGE: 0.30,
SWING: 0.7,
HIT: 0.85,
FALL: 0.9,
// Quiet on purpose. A page turn fires while he is reading, several times in a
// few seconds, where every other sound in this game fires once.
PAGE: 0.45,
// Louder than a page turn, quieter than a hit. It fires four or five times in
// a whole level and it is the sound of him deciding something.
MARK: 0.7,
// Far off. It fires four times in a level and it is the sky, not an alarm β€”
// and the flash has already said everything it says, so this is the part you
// feel rather than the part you are told.
THUNDER: 0.55,
// THE VOICES. Every paper is read aloud by whoever wrote it. Each recording
// is measured when it loads and brought to VOICE_LUFS -- the takes came in
// 14 dB apart -- and VOICE is the level of all of them on top of that.
// -23 is where the loudest take still has 1.5 dB of room above it.
VOICE: 1.0,
VOICE_LUFS: -23,
// What the bed and the torch drop to while somebody is speaking. The words
// are the evidence; the crackle is only the room they are read in.
VOICE_DUCK: 0.45,
};
// --- the fifteen-second demo, kept runnable at ?mode=demo -----------------
// Its own numbers, deliberately separate, so tuning the level cannot silently
// change the thing that is deployed and being played.
export const DEMO = Object.freeze({
DRAINED_SPEED: 55,
DRAINED_RUSH_SPEED: 135,
DRAINED_RUSH_ACCEL: 190,
KNOCKBACK_SPEED: 850,
KNOCKBACK_DECEL: 2200,
STUN_TIME: 0.42,
PRINCE_RUN_SPEED: 420,
PRINCE_STOP_X: 700,
CONTACT_GAP: -40,
CONTACT_HOLD: 0.8,
FADE_TIME: 0.4,
});
// --- URL overrides ---------------------------------------------------------
// Only the values a feel decision hangs on. Everything else stays fixed, so a
// stray query string cannot quietly change how the level behaves.
const OVERRIDABLE = new Set([
'DOWN_MS', 'RISE_MS', 'STAGGER_MS', 'HITS_TO_DOWN',
'WALK_SPEED', 'DRAINED_SPEED', 'RUSH_SPEED', 'HOME_SPEED', 'REVEAL_MS', 'START_AT',
'ORDER_HOLD_MS',
// Letters stay on the door. How long standing still takes to read one again.
'READ_STILL_MS',
// The voices: how long a paper is up before it speaks, and how long the
// opening waits for its voice before a press ends it anyway.
'VOICE_DELAY_MS', 'VOICE_WAIT_MS',
// The two lanes. Every one of these is a start value that gets tuned by eye.
'BACK_LANE_SCALE', 'BACK_LANE_RISE', 'BACK_LANE_VALUE', 'STEP_MS', 'WATCH_NEAR', 'WATCH_FAR', 'STIR_TREMBLE_PX', 'STIR_TREMBLE_FROM',
// The V1 judgement. Sweeping it has to be a query string, not an edit.
'BOWL_W', 'BOWL_CANOPY', 'BOWL_CLEAR', 'BOWL_WATCHERS', 'ROAD_ENCOUNTERS',
]);
// How loud the dropped object is out in the dark, and how hard it breathes.
// These are looked at, not reasoned about, so they get the same treatment.
const OVERRIDABLE_TORCH = new Set([
'KEPT_GLOW_DARK', 'KEPT_GLOW', 'KEPT_GLOW_R', 'KEPT_PULSE_HZ', 'KEPT_PULSE_DEPTH',
]);
/** Mixing is done by ear, so the mix is tunable from the address bar too. */
const OVERRIDABLE_AUDIO = new Set(['MASTER', 'FOOT', 'SWING', 'HIT', 'FALL', 'DRAINED_FOOT', 'THUNDER', 'DRAINED_VOICE', 'TORCH', 'WAKE', 'VILLAGE', 'HOME_FOOT_FAR', 'HOME_FOOT_REACH', 'STIR', 'VOICE', 'VOICE_LUFS', 'VOICE_DUCK']);
/** The experiment's five cues, plus the particle draw mode. */
const OVERRIDABLE_TONE = new Set(['HILLS_HAZE', 'HILLS_BLUR', 'WATCHER_HAZE',
'CANOPY_HAZE', 'CANOPY_BLUR', 'TRUNK_BLUR', 'TRUNK_WARM', 'VIGNETTE',
'FOG', 'FOG_H', 'SKY_SINK', 'TORCH_LIFE']);
const OVERRIDABLE_DEPTH = new Set(['OCC', 'PART', 'PART_HARD', 'BLUR', 'BLUR_SKY', 'GRAIN', 'SHAFT']);
export const overrides = {};
if (typeof location !== 'undefined') {
const params = new URLSearchParams(location.search);
for (const key of OVERRIDABLE) {
const raw = params.get(key.toLowerCase());
if (raw === null) continue;
const value = Number(raw);
if (!Number.isFinite(value) || value < 0) continue;
overrides[key] = value;
TUNING[key] = value;
}
for (const key of OVERRIDABLE_TONE) {
const raw = params.get(key.toLowerCase());
if (raw === null) continue;
const value = Number(raw);
if (!Number.isFinite(value) || value < 0) continue;
overrides['TONE_' + key] = value;
TONE[key] = value;
}
for (const key of OVERRIDABLE_DEPTH) {
const raw = params.get(key.toLowerCase());
if (raw === null) continue;
const value = Number(raw);
if (!Number.isFinite(value) || value < 0) continue;
overrides['DEPTH_' + key] = value;
DEPTH[key] = value;
}
for (const key of OVERRIDABLE_AUDIO) {
const raw = params.get('audio_' + key.toLowerCase());
if (raw === null) continue;
const value = Number(raw);
if (!Number.isFinite(value) || value < 0) continue;
overrides['AUDIO_' + key] = value;
AUDIO[key] = value;
}
for (const key of OVERRIDABLE_TORCH) {
const raw = params.get(key.toLowerCase());
if (raw === null) continue;
const value = Number(raw);
if (!Number.isFinite(value) || value < 0) continue;
overrides[key] = value;
TORCH[key] = value;
}
}
// --- where the village is ---------------------------------------------------
// One world axis, no second scene. The mouth is a world x where the camera
// stops following and starts being bounded, and that is the entire transition β€”
// no loading, no fade, no gate. The houses are the only signal it happened.
//
// Derived here, AFTER the URL overrides have landed, so ?bowl_w= cannot leave
// the mouth and the gate disagreeing about where the village is.
export const BOWL = Object.freeze({
X0: TUNING.LEVEL_LENGTH_PX,
X1: TUNING.LEVEL_LENGTH_PX + TUNING.BOWL_W,
});
/**
* Where a run begins.
*
* ?at=bowl and ?at=gate exist because every gate from here on is judged on
* something at the far end of the level, and walking fifty-three seconds to
* look at it once is how a sweep stops happening. START_AT stays a fraction of
* the road for everything before the village.
*/
/**
* THE RUN SEED, and it is not the world seed.
*
* The world has been deterministic from a hashed position since the first spec
* and every check depends on it: the slots, the clear pockets round each door,
* the spacing floors. None of that may move.
*
* What varies per run is which ROLE stands in which slot and which body carries
* which fragment. So the placement maths never sees this number, and every
* guarantee V1 bought survives untouched while position memory stops working β€”
* four friends comparing notes cannot say "the blacksmith is third from left".
*
* It buys that and not a second puzzle. A player who remembers the forge letter
* subverts has spent the deduction; the shuffle makes them fight again, not
* think again.
*
* ?seed= fixes it, which is what every check uses.
*/
export function runSeed() {
const q = typeof location !== 'undefined'
? new URLSearchParams(location.search).get('seed') : null;
const n = q === null ? NaN : Number(q);
return Number.isFinite(n) ? (n >>> 0) : (Date.now() >>> 0);
}
/** Deterministic 0..1 stream from a seed. Same seed, same run. */
export function mulberry32(seed) {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
export function startX() {
const at = typeof location !== 'undefined'
? new URLSearchParams(location.search).get('at') : null;
if (at === 'bowl') return BOWL.X0 + 400;
if (at === 'gate') return BOWL.X1 - 1600;
if (TUNING.START_AT > 0) {
return Math.round(TUNING.LEVEL_LENGTH_PX * Math.min(TUNING.START_AT, 0.98));
}
return 300;
}
/**
* 0 at the start of the level, 1 once the canopy has closed β€” and back down
* again inside the village, which is a clearing.
*
* The road's curve is untouched, so the bright-mass check keeps its shape and
* its numbers. The bowl opens over its first fifth, holds, and closes again at
* the gate, so walking in is walking into light and walking out is not.
*/
export function depthAt(worldX) {
const t = (worldX / TUNING.LEVEL_LENGTH_PX - TUNING.CANOPY_START) /
(TUNING.CANOPY_FULL - TUNING.CANOPY_START);
const c = t < 0 ? 0 : t > 1 ? 1 : t;
const road = c * c * (3 - 2 * c); // eased, so the close creeps rather than ramps
if (worldX <= BOWL.X0) return road;
const u = Math.min(1, (worldX - BOWL.X0) / TUNING.BOWL_W);
const k = TUNING.BOWL_OPEN;
const o = Math.max(0, Math.min(1, u / k, (1 - u) / k));
const e = o * o * (3 - 2 * o);
return road - (road - TUNING.BOWL_CANOPY) * e;
}
Object.freeze(TUNING);
Object.freeze(TORCH);
Object.freeze(AUDIO);
// DEPTH is deliberately NOT frozen. The cycle key writes to it.