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