Spaces:
Running
Running
Health and level restart: six hits, no bar, death takes the map with it
Browse files- assets/prince/hurt/lit_00.svg +25 -0
- assets/prince/hurt/lit_01.svg +25 -0
- assets/prince/hurt/lit_02.svg +25 -0
- assets/prince/hurt/lit_03.svg +25 -0
- manifest.json +8 -0
- src/checks.js +93 -2
- src/drained.js +4 -2
- src/render.js +18 -3
- src/scene_level.js +108 -32
- src/silhouette.js +1 -1
- src/tuning.js +42 -10
assets/prince/hurt/lit_00.svg
ADDED
|
|
assets/prince/hurt/lit_01.svg
ADDED
|
|
assets/prince/hurt/lit_02.svg
ADDED
|
|
assets/prince/hurt/lit_03.svg
ADDED
|
|
manifest.json
CHANGED
|
@@ -72,6 +72,14 @@
|
|
| 72 |
"handPivot": [166, 58],
|
| 73 |
"silhouette": "auto"
|
| 74 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
"prince_map": {
|
| 76 |
"_note": "The torch pose with the other hand out in front. Holding the map raises the torch — one button, one cost. The SHEET is a separate clip anchored at mapPivot, the same way the flame is anchored at handPivot, because that is what lets the sheet be lit while he stays a silhouette inside his own torchlight.",
|
| 77 |
"path": "assets/prince/map/", "file": "lit_{nn}.svg",
|
|
|
|
| 72 |
"handPivot": [166, 58],
|
| 73 |
"silhouette": "auto"
|
| 74 |
},
|
| 75 |
+
"prince_hurt": {
|
| 76 |
+
"_note": "THERE IS NO HEALTH BAR. He holds his side, leans away from it and his head comes down, and that is how much is left. The only new drawing health needed — the limp is the run frames on an uneven cadence, and the slowed swing is a number.",
|
| 77 |
+
"path": "assets/prince/hurt/", "file": "lit_{nn}.svg",
|
| 78 |
+
"frames": 4, "fps": 2, "variants": true,
|
| 79 |
+
"_frames_note": "Two stages of two. Frames 0-1 are hurt, 2-3 are spent, and the scene picks the pair off how much is left — two frames alone read as 'he is hurt' and said nothing about how close he was.",
|
| 80 |
+
"native": [220, 340], "pivot": [110, 340],
|
| 81 |
+
"silhouette": "auto"
|
| 82 |
+
},
|
| 83 |
"prince_map": {
|
| 84 |
"_note": "The torch pose with the other hand out in front. Holding the map raises the torch — one button, one cost. The SHEET is a separate clip anchored at mapPivot, the same way the flame is anchored at handPivot, because that is what lets the sheet be lit while he stays a silhouette inside his own torchlight.",
|
| 85 |
"path": "assets/prince/map/", "file": "lit_{nn}.svg",
|
src/checks.js
CHANGED
|
@@ -821,15 +821,106 @@ export function createChecks({ renderer, scene, canvas }) {
|
|
| 821 |
};
|
| 822 |
}
|
| 823 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 824 |
function all() {
|
| 825 |
const out = [figureContrast(), watcherContrast(), brightMass(), skyWeave(),
|
| 826 |
bowlLayout(), bodiesPersist(), wholeLevelWalkable(), stillUntilLit(),
|
| 827 |
-
doorsRead(), mapRecords(), populationReads(), nothingOutOfReach(),
|
|
|
|
| 828 |
return { pass: out.every((r) => r.pass), results: out };
|
| 829 |
}
|
| 830 |
|
| 831 |
return { figureContrast, watcherContrast, brightMass, skyWeave,
|
| 832 |
bowlLayout, bodiesPersist, wholeLevelWalkable, stillUntilLit, doorsRead,
|
| 833 |
-
mapRecords, populationReads, nothingOutOfReach, theGate,
|
| 834 |
all, frameAt, frameAtX };
|
| 835 |
}
|
|
|
|
| 821 |
};
|
| 822 |
}
|
| 823 |
|
| 824 |
+
/**
|
| 825 |
+
* A FIGHT NOW COSTS SOMETHING.
|
| 826 |
+
*
|
| 827 |
+
* Village one played like a beat 'em up because beating up every Drained cost
|
| 828 |
+
* nothing: three hits, no health, no death. The optimal play was to hit
|
| 829 |
+
* everything and the game agreed. At zero risk there is no search at all,
|
| 830 |
+
* just a sweep — and a sweep cannot be tedious, because it is not looking for
|
| 831 |
+
* anything.
|
| 832 |
+
*
|
| 833 |
+
* What this asserts is the shape of the cost, not that it is fun:
|
| 834 |
+
*
|
| 835 |
+
* - A man standing in the dark doing nothing is still harmless. The level's
|
| 836 |
+
* whole argument is that nothing is hostile until it is looked at, and
|
| 837 |
+
* health must not quietly take that away.
|
| 838 |
+
* - Six catches, not five and not seven, and the sixth restarts the LEVEL.
|
| 839 |
+
* - The restart takes the findings with it. A checkpoint that keeps the map
|
| 840 |
+
* is too forgiving to change how anybody plays.
|
| 841 |
+
* - Grace: one contact costs one hit, not one per frame of touching.
|
| 842 |
+
* - It shows on his body — the swing slows and the stance changes — and
|
| 843 |
+
* nowhere else. There is no bar.
|
| 844 |
+
*/
|
| 845 |
+
function healthCosts() {
|
| 846 |
+
scene.reset();
|
| 847 |
+
const v = s.drained[0];
|
| 848 |
+
|
| 849 |
+
// 1. unlit, and standing right on top of it
|
| 850 |
+
s.prince.x = s.prince.prevX = v.x - 40;
|
| 851 |
+
s.camera.reset(s.prince.x);
|
| 852 |
+
drive(2);
|
| 853 |
+
const harmless = s.prince.hits === 0 && v.state === 'WANDER';
|
| 854 |
+
|
| 855 |
+
// 2. GRACE: one contact is one hit, not one per frame of touching.
|
| 856 |
+
//
|
| 857 |
+
// `v.takeHit` hurts the DRAINED — it is what wakes this one up. What hurts
|
| 858 |
+
// the prince is being in contact with it once it is rushing.
|
| 859 |
+
v.takeHit(s.prince.x);
|
| 860 |
+
let n = 0;
|
| 861 |
+
while (s.prince.hits === 0 && n < 60 * 8) { scene.update(1 / 60, { ...NONE }); n++; }
|
| 862 |
+
const first = s.prince.hits;
|
| 863 |
+
// Then pinned against it, well inside the grace window.
|
| 864 |
+
const held = Math.round((TUNING.HURT_GRACE_MS / 1000) * 60 * 0.6);
|
| 865 |
+
for (let i = 0; i < held; i++) {
|
| 866 |
+
s.prince.x = s.prince.prevX = v.x - 30;
|
| 867 |
+
scene.update(1 / 60, { ...NONE });
|
| 868 |
+
}
|
| 869 |
+
const oneContact = first === 1 && s.prince.hits === 1;
|
| 870 |
+
|
| 871 |
+
// 3. it shows on him
|
| 872 |
+
const fresh = TUNING.SWING_SLOW[0];
|
| 873 |
+
const worn = TUNING.SWING_SLOW[TUNING.SWING_SLOW.length - 1];
|
| 874 |
+
s.prince.hits = TUNING.LIMP_FROM;
|
| 875 |
+
s.prince.x = s.prince.prevX = BOWL.X0 + 200;
|
| 876 |
+
drive(0.5);
|
| 877 |
+
const stance = s.prince.clip?.name === 'prince_hurt';
|
| 878 |
+
|
| 879 |
+
// 4. six, and the level starts again with the findings gone
|
| 880 |
+
scene.reset();
|
| 881 |
+
s.houses[0].seen = true;
|
| 882 |
+
s.prince.inventory.push({ kept: 0, identity: 'a blacksmith', seen: true });
|
| 883 |
+
s.prince.x = s.prince.prevX = v.x - 30;
|
| 884 |
+
s.camera.reset(s.prince.x);
|
| 885 |
+
let caught = 0;
|
| 886 |
+
for (let k = 0; k < 14 && s.run.deaths === 0; k++) {
|
| 887 |
+
s.prince.x = s.prince.prevX = v.x - 30;
|
| 888 |
+
s.camera.reset(s.prince.x);
|
| 889 |
+
drive(1.5, { torch: true });
|
| 890 |
+
drive(2.2);
|
| 891 |
+
caught = Math.max(caught, s.prince.hits);
|
| 892 |
+
}
|
| 893 |
+
const died = {
|
| 894 |
+
tookSix: caught === TUNING.PRINCE_HITS,
|
| 895 |
+
deaths: s.run.deaths,
|
| 896 |
+
backToTheStart: s.prince.x < 1000,
|
| 897 |
+
lostTheMap: !s.houses[0].seen && s.prince.inventory.length === 0,
|
| 898 |
+
hitsReset: s.prince.hits === 0,
|
| 899 |
+
};
|
| 900 |
+
|
| 901 |
+
return {
|
| 902 |
+
name: 'health costs',
|
| 903 |
+
pass: harmless && oneContact && stance && worn > fresh * 1.5 &&
|
| 904 |
+
died.tookSix && died.deaths === 1 && died.backToTheStart &&
|
| 905 |
+
died.lostTheMap && died.hitsReset,
|
| 906 |
+
unlitIsStillHarmless: harmless,
|
| 907 |
+
graceHoldsForOneHit: oneContact,
|
| 908 |
+
holdsHisSide: stance,
|
| 909 |
+
swingSlowsBy: +(worn / fresh).toFixed(2),
|
| 910 |
+
died,
|
| 911 |
+
};
|
| 912 |
+
}
|
| 913 |
+
|
| 914 |
function all() {
|
| 915 |
const out = [figureContrast(), watcherContrast(), brightMass(), skyWeave(),
|
| 916 |
bowlLayout(), bodiesPersist(), wholeLevelWalkable(), stillUntilLit(),
|
| 917 |
+
doorsRead(), mapRecords(), populationReads(), nothingOutOfReach(),
|
| 918 |
+
healthCosts(), theGate()];
|
| 919 |
return { pass: out.every((r) => r.pass), results: out };
|
| 920 |
}
|
| 921 |
|
| 922 |
return { figureContrast, watcherContrast, brightMass, skyWeave,
|
| 923 |
bowlLayout, bodiesPersist, wholeLevelWalkable, stillUntilLit, doorsRead,
|
| 924 |
+
mapRecords, populationReads, nothingOutOfReach, healthCosts, theGate,
|
| 925 |
all, frameAt, frameAtX };
|
| 926 |
}
|
src/drained.js
CHANGED
|
@@ -257,8 +257,10 @@ export function createDrained(clips, spawnX, dir, keptIndex = 0, opts = {}) {
|
|
| 257 |
};
|
| 258 |
|
| 259 |
/**
|
| 260 |
-
* Shoved off
|
| 261 |
-
*
|
|
|
|
|
|
|
| 262 |
*/
|
| 263 |
d.shoveOff = (fromX, push) => {
|
| 264 |
if (!post) d.x += Math.sign(d.x - fromX || 1) * push;
|
|
|
|
| 257 |
};
|
| 258 |
|
| 259 |
/**
|
| 260 |
+
* Shoved off without counting a hit. Written for the old get-up-where-you-
|
| 261 |
+
* fell recovery, which death replaced with a level restart. Kept because it
|
| 262 |
+
* is the only way to move one of these without hurting it, and the ending
|
| 263 |
+
* will want that.
|
| 264 |
*/
|
| 265 |
d.shoveOff = (fromX, push) => {
|
| 266 |
if (!post) d.x += Math.sign(d.x - fromX || 1) * push;
|
src/render.js
CHANGED
|
@@ -115,7 +115,7 @@ export function createRenderer(ctx) {
|
|
| 115 |
// drew the Drained backwards every time it moved. The manifest says which way
|
| 116 |
// each clip was authored.
|
| 117 |
//
|
| 118 |
-
// ART_HANDOFF.md used to promise nothing was mirrored in code. That stopped
|
| 119 |
// being true the moment the prince got a facing and could be attacked from
|
| 120 |
// either side. The pivot stays on the anchor through the flip.
|
| 121 |
function drawClip(c, clip, index, mode, worldX, worldY, camX = 0, facing = 1) {
|
|
@@ -535,6 +535,13 @@ export function createRenderer(ctx) {
|
|
| 535 |
const handY = lerp(s.torch.prevHandY, s.torch.handY, alpha);
|
| 536 |
const face = s.prince.facing;
|
| 537 |
const torchUp = !s.prince.hasSword;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 538 |
|
| 539 |
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
| 540 |
|
|
@@ -561,7 +568,7 @@ export function createRenderer(ctx) {
|
|
| 561 |
drawClip(ctx, d.clip, d.frame, 'dark', dx2, GROUND_Y, camX, d.facing);
|
| 562 |
drawKept(ctx, s.kept, d, 'dark', dx2, camX, s.clock);
|
| 563 |
}
|
| 564 |
-
drawClip(ctx, s.prince.clip, s.prince.frame, 'dark', px, GROUND_Y, camX, face);
|
| 565 |
drawMap(ctx, s, 'dark', px, camX, face);
|
| 566 |
if (torchUp) drawClip(ctx, s.flame.clip, s.flame.frame, 'dark', handX, handY, camX, face);
|
| 567 |
stats.bgDrawn += s.background.drawNear(ctx, camX, W, H, 'dark', px);
|
|
@@ -608,7 +615,7 @@ export function createRenderer(ctx) {
|
|
| 608 |
drawClip(octx, d.clip, d.frame, 'lit', dx2, GROUND_Y, camX, d.facing);
|
| 609 |
drawKept(octx, s.kept, d, 'lit', dx2, camX, s.clock);
|
| 610 |
}
|
| 611 |
-
drawClip(octx, s.prince.clip, s.prince.frame, 'dark', px, GROUND_Y, camX, face);
|
| 612 |
// He is never lit by his own torch. The SHEET is — it is the one thing
|
| 613 |
// he carries that the light is for.
|
| 614 |
drawMap(octx, s, 'lit', px, camX, face);
|
|
@@ -695,6 +702,14 @@ export function createRenderer(ctx) {
|
|
| 695 |
` village ${TUNING.BOWL_W} (${(TUNING.BOWL_W / W).toFixed(1)} screens, ` +
|
| 696 |
`${(TUNING.BOWL_W / TUNING.WALK_SPEED).toFixed(0)}s across)` +
|
| 697 |
` shapes ${standing}/${s.village ? s.village.length : 0} standing, ${s.villageGap ?? 0}px apart`,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 698 |
`gate ${s.gate ? `${s.gate.filled}/${TUNING.GATE_WANTS}` : '—'}` +
|
| 699 |
(s.gate?.open >= 1 ? ' OPEN' : ' shut') +
|
| 700 |
(s.gate?.through ? ' — through' : '') +
|
|
|
|
| 115 |
// drew the Drained backwards every time it moved. The manifest says which way
|
| 116 |
// each clip was authored.
|
| 117 |
//
|
| 118 |
+
// docs/ART_HANDOFF.md used to promise nothing was mirrored in code. That stopped
|
| 119 |
// being true the moment the prince got a facing and could be attacked from
|
| 120 |
// either side. The pivot stays on the anchor through the flip.
|
| 121 |
function drawClip(c, clip, index, mode, worldX, worldY, camX = 0, facing = 1) {
|
|
|
|
| 535 |
const handY = lerp(s.torch.prevHandY, s.torch.handY, alpha);
|
| 536 |
const face = s.prince.facing;
|
| 537 |
const torchUp = !s.prince.hasSword;
|
| 538 |
+
// The weight going out of the bad leg. Half a limp is the rhythm, which the
|
| 539 |
+
// scene does by hurrying one half of the cycle; the other half is this —
|
| 540 |
+
// he drops a few px while that foot is down.
|
| 541 |
+
const hurt = Math.max(0, s.prince.hits - TUNING.LIMP_FROM);
|
| 542 |
+
const limpDip = (hurt > 0 && s.prince.moving &&
|
| 543 |
+
s.prince.frame >= s.prince.clip.count / 2)
|
| 544 |
+
? Math.min(TUNING.LIMP_DIP_PX, TUNING.LIMP_DIP_PX * (hurt / 3)) : 0;
|
| 545 |
|
| 546 |
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
| 547 |
|
|
|
|
| 568 |
drawClip(ctx, d.clip, d.frame, 'dark', dx2, GROUND_Y, camX, d.facing);
|
| 569 |
drawKept(ctx, s.kept, d, 'dark', dx2, camX, s.clock);
|
| 570 |
}
|
| 571 |
+
drawClip(ctx, s.prince.clip, s.prince.frame, 'dark', px, GROUND_Y + limpDip, camX, face);
|
| 572 |
drawMap(ctx, s, 'dark', px, camX, face);
|
| 573 |
if (torchUp) drawClip(ctx, s.flame.clip, s.flame.frame, 'dark', handX, handY, camX, face);
|
| 574 |
stats.bgDrawn += s.background.drawNear(ctx, camX, W, H, 'dark', px);
|
|
|
|
| 615 |
drawClip(octx, d.clip, d.frame, 'lit', dx2, GROUND_Y, camX, d.facing);
|
| 616 |
drawKept(octx, s.kept, d, 'lit', dx2, camX, s.clock);
|
| 617 |
}
|
| 618 |
+
drawClip(octx, s.prince.clip, s.prince.frame, 'dark', px, GROUND_Y + limpDip, camX, face);
|
| 619 |
// He is never lit by his own torch. The SHEET is — it is the one thing
|
| 620 |
// he carries that the light is for.
|
| 621 |
drawMap(octx, s, 'lit', px, camX, face);
|
|
|
|
| 702 |
` village ${TUNING.BOWL_W} (${(TUNING.BOWL_W / W).toFixed(1)} screens, ` +
|
| 703 |
`${(TUNING.BOWL_W / TUNING.WALK_SPEED).toFixed(0)}s across)` +
|
| 704 |
` shapes ${standing}/${s.village ? s.village.length : 0} standing, ${s.villageGap ?? 0}px apart`,
|
| 705 |
+
// NO HEALTH BAR ON SCREEN. This line is for judging the build, not for
|
| 706 |
+
// playing it — the player is meant to read it off how he is standing.
|
| 707 |
+
`hits ${s.prince.hits}/${TUNING.PRINCE_HITS}` +
|
| 708 |
+
(s.prince.hits >= TUNING.LIMP_FROM ? ' limping' : '') +
|
| 709 |
+
` swing x${TUNING.SWING_SLOW[Math.min(s.prince.hits, TUNING.SWING_SLOW.length - 1)].toFixed(2)}` +
|
| 710 |
+
(s.run ? ` run ${s.run.seconds.toFixed(0)}s fights ${s.run.fights}` +
|
| 711 |
+
` took ${s.run.taken} hit ${s.run.hitsTaken} deaths ${s.run.deaths}` +
|
| 712 |
+
` WATCHING ${s.run.watching.toFixed(1)}s` : ''),
|
| 713 |
`gate ${s.gate ? `${s.gate.filled}/${TUNING.GATE_WANTS}` : '—'}` +
|
| 714 |
(s.gate?.open >= 1 ? ' OPEN' : ' shut') +
|
| 715 |
(s.gate?.through ? ' — through' : '') +
|
src/scene_level.js
CHANGED
|
@@ -26,6 +26,7 @@ export async function createLevelScene(clips, { audio = null } = {}) {
|
|
| 26 |
const torchClip = clips.prince_torch;
|
| 27 |
const guardClip = clips.prince_guard;
|
| 28 |
const mapClip = clips.prince_map;
|
|
|
|
| 29 |
const sheetClip = clips.map_sheet;
|
| 30 |
|
| 31 |
// The hand the torch is held in, published by the manifest in sprite
|
|
@@ -66,6 +67,9 @@ export async function createLevelScene(clips, { audio = null } = {}) {
|
|
| 66 |
torchPlayer: createPlayer(torchClip),
|
| 67 |
guard: createPlayer(guardClip),
|
| 68 |
mapPlayer: mapClip ? createPlayer(mapClip) : null,
|
|
|
|
|
|
|
|
|
|
| 69 |
attacking: false,
|
| 70 |
attackTime: 0,
|
| 71 |
attackConnected: false,
|
|
@@ -385,9 +389,40 @@ export async function createLevelScene(clips, { audio = null } = {}) {
|
|
| 385 |
|
| 386 |
// The loss beat. No text, no shake, no sting — the light going out is the
|
| 387 |
// whole thing.
|
|
|
|
|
|
|
| 388 |
const loss = { active: false, timer: 0, torchWasUp: false, x: 0, y: 0, vx: 0, r: 0, by: null };
|
| 389 |
let grace = 0;
|
| 390 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 391 |
// THE MAP. Not a screen — a sheet he holds up in his own torchlight. It
|
| 392 |
// records and it never reasons: it knows what he has looked at and nothing
|
| 393 |
// else, and the player never does a moment's bookkeeping.
|
|
@@ -398,6 +433,20 @@ export async function createLevelScene(clips, { audio = null } = {}) {
|
|
| 398 |
// He has to look. That is the level.
|
| 399 |
const gate = { filled: 0, open: 0, through: false };
|
| 400 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 401 |
const map = {
|
| 402 |
up: false,
|
| 403 |
// How long the sheet has been held at full raise. Reading it is looking, so
|
|
@@ -412,7 +461,7 @@ export async function createLevelScene(clips, { audio = null } = {}) {
|
|
| 412 |
audio,
|
| 413 |
ground: clips.ground,
|
| 414 |
kept: clips.kept,
|
| 415 |
-
map, sheetClip, mapClip,
|
| 416 |
houses: HOUSES, houseTint: PALETTE.WATCHERS,
|
| 417 |
houseClip, houseArt, doorPoint,
|
| 418 |
village, gateX: GATE_X, gateStopX: GATE_STOP_X,
|
|
@@ -428,6 +477,7 @@ export async function createLevelScene(clips, { audio = null } = {}) {
|
|
| 428 |
prince.facing = 1;
|
| 429 |
prince.hasSword = true;
|
| 430 |
prince.moving = false;
|
|
|
|
| 431 |
prince.run.reset();
|
| 432 |
prince.idle.reset();
|
| 433 |
torch.reset();
|
|
@@ -447,6 +497,9 @@ export async function createLevelScene(clips, { audio = null } = {}) {
|
|
| 447 |
gate.open = 0;
|
| 448 |
gate.through = false;
|
| 449 |
state.fade = 0;
|
|
|
|
|
|
|
|
|
|
| 450 |
lastRunFrame = -1;
|
| 451 |
prince.inventory.length = 0;
|
| 452 |
drained.forEach((d) => d.reset());
|
|
@@ -478,19 +531,13 @@ export async function createLevelScene(clips, { audio = null } = {}) {
|
|
| 478 |
torch.cy = loss.y;
|
| 479 |
torch.radius = loss.r;
|
| 480 |
}
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
prince.down.reset();
|
| 489 |
-
grace = TUNING.RECOVER_GRACE_MS;
|
| 490 |
-
if (loss.by) loss.by.shoveOff(prince.x, TUNING.RECOVER_PUSH_PX);
|
| 491 |
-
torch.reset();
|
| 492 |
-
prince.attacking = false;
|
| 493 |
-
}
|
| 494 |
return;
|
| 495 |
}
|
| 496 |
|
|
@@ -537,7 +584,10 @@ export async function createLevelScene(clips, { audio = null } = {}) {
|
|
| 537 |
}
|
| 538 |
}
|
| 539 |
if (prince.attacking) {
|
| 540 |
-
|
|
|
|
|
|
|
|
|
|
| 541 |
const ms = prince.attackTime * 1000;
|
| 542 |
const active = ms >= TUNING.ATTACK_WINDUP_MS &&
|
| 543 |
ms < TUNING.ATTACK_WINDUP_MS + TUNING.ATTACK_ACTIVE_MS;
|
|
@@ -573,25 +623,16 @@ export async function createLevelScene(clips, { audio = null } = {}) {
|
|
| 573 |
// Contact with a WANDER one is not a loss. He walks into someone standing
|
| 574 |
// in the dark and nothing happens at all, which is worse.
|
| 575 |
if (kind === 'shove' && grace <= 0) {
|
| 576 |
-
//
|
| 577 |
-
//
|
| 578 |
-
|
| 579 |
-
|
| 580 |
-
|
| 581 |
-
prince.
|
| 582 |
-
audio?.bodyFall();
|
| 583 |
}
|
| 584 |
if (kind === 'fatal' && !loss.active) {
|
| 585 |
-
loss.active = true;
|
| 586 |
-
loss.timer = 0;
|
| 587 |
loss.by = d;
|
| 588 |
-
|
| 589 |
-
prince.down.reset();
|
| 590 |
-
loss.torchWasUp = torch.raise > 0.01;
|
| 591 |
-
loss.x = torch.handX;
|
| 592 |
-
loss.y = torch.handY;
|
| 593 |
-
loss.vx = prince.facing * 120;
|
| 594 |
-
loss.r = torch.radius;
|
| 595 |
}
|
| 596 |
}
|
| 597 |
|
|
@@ -643,11 +684,21 @@ export async function createLevelScene(clips, { audio = null } = {}) {
|
|
| 643 |
}
|
| 644 |
if (wantsPickup && nearest && !prince.attacking) {
|
| 645 |
nearest.keptTaken = true;
|
|
|
|
| 646 |
prince.inventory.push({
|
| 647 |
kept: nearest.kept, identity: nearest.identity, seen: nearest.keptSeen,
|
| 648 |
});
|
| 649 |
}
|
| 650 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 651 |
// --- the gate -----------------------------------------------------------
|
| 652 |
// It counts what he is CARRYING and has looked at, and it counts live: put
|
| 653 |
// one down and a mark goes out again. The map keeps score and the score is
|
|
@@ -680,7 +731,21 @@ export async function createLevelScene(clips, { audio = null } = {}) {
|
|
| 680 |
state.flame.frame = state.flame.player.index;
|
| 681 |
} else if (prince.moving) {
|
| 682 |
const rate = runClip.groundSpeed ? TUNING.WALK_SPEED / runClip.groundSpeed : 1;
|
| 683 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 684 |
prince.clip = runClip;
|
| 685 |
prince.frame = prince.run.index;
|
| 686 |
// A footstep when the boot ARRIVES, not on a timer. runClip.footFrames is
|
|
@@ -689,6 +754,17 @@ export async function createLevelScene(clips, { audio = null } = {}) {
|
|
| 689 |
if (runClip.footFrames?.includes(prince.frame)) audio?.footstep();
|
| 690 |
lastRunFrame = prince.frame;
|
| 691 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 692 |
} else if (drained.some((d) => d.revealed && d.state !== DOWNED && Math.abs(d.x - prince.x) < W)) {
|
| 693 |
// Once one has been seen and is coming, he stands ready.
|
| 694 |
prince.guard.advance(dt);
|
|
|
|
| 26 |
const torchClip = clips.prince_torch;
|
| 27 |
const guardClip = clips.prince_guard;
|
| 28 |
const mapClip = clips.prince_map;
|
| 29 |
+
const hurtClip = clips.prince_hurt;
|
| 30 |
const sheetClip = clips.map_sheet;
|
| 31 |
|
| 32 |
// The hand the torch is held in, published by the manifest in sprite
|
|
|
|
| 67 |
torchPlayer: createPlayer(torchClip),
|
| 68 |
guard: createPlayer(guardClip),
|
| 69 |
mapPlayer: mapClip ? createPlayer(mapClip) : null,
|
| 70 |
+
hurtPlayer: hurtClip ? createPlayer(hurtClip) : null,
|
| 71 |
+
// Hits TAKEN, not left. There is no bar; it is read off his body.
|
| 72 |
+
hits: 0,
|
| 73 |
attacking: false,
|
| 74 |
attackTime: 0,
|
| 75 |
attackConnected: false,
|
|
|
|
| 389 |
|
| 390 |
// The loss beat. No text, no shake, no sting — the light going out is the
|
| 391 |
// whole thing.
|
| 392 |
+
// The DEATH beat now, not the loss beat. Being caught costs a hit; the sixth
|
| 393 |
+
// one is this, and it restarts the level.
|
| 394 |
const loss = { active: false, timer: 0, torchWasUp: false, x: 0, y: 0, vx: 0, r: 0, by: null };
|
| 395 |
let grace = 0;
|
| 396 |
|
| 397 |
+
/**
|
| 398 |
+
* Caught. Knocked back, a moment to get clear, and one less.
|
| 399 |
+
*
|
| 400 |
+
* WANDER contact is still harmless — the whole level argues that nothing is
|
| 401 |
+
* hostile until it is looked at, and a man standing in the dark doing nothing
|
| 402 |
+
* has to stay a man standing in the dark doing nothing.
|
| 403 |
+
*/
|
| 404 |
+
function takeHit(fromX, knock) {
|
| 405 |
+
if (grace > 0 || loss.active) return;
|
| 406 |
+
prince.hits++;
|
| 407 |
+
run.hitsTaken++;
|
| 408 |
+
prince.x += (Math.sign(prince.x - fromX) || -1) * knock;
|
| 409 |
+
prince.prevX = prince.x;
|
| 410 |
+
prince.attacking = false;
|
| 411 |
+
grace = TUNING.HURT_GRACE_MS;
|
| 412 |
+
audio?.hit();
|
| 413 |
+
if (prince.hits < TUNING.PRINCE_HITS) return;
|
| 414 |
+
// The sixth. The light goes out and the level starts again.
|
| 415 |
+
loss.active = true;
|
| 416 |
+
loss.timer = 0;
|
| 417 |
+
prince.down.reset();
|
| 418 |
+
loss.torchWasUp = torch.raise > 0.01;
|
| 419 |
+
loss.x = torch.handX;
|
| 420 |
+
loss.y = torch.handY;
|
| 421 |
+
loss.vx = prince.facing * 120;
|
| 422 |
+
loss.r = torch.radius;
|
| 423 |
+
audio?.bodyFall();
|
| 424 |
+
}
|
| 425 |
+
|
| 426 |
// THE MAP. Not a screen — a sheet he holds up in his own torchlight. It
|
| 427 |
// records and it never reasons: it knows what he has looked at and nothing
|
| 428 |
// else, and the player never does a moment's bookkeeping.
|
|
|
|
| 433 |
// He has to look. That is the level.
|
| 434 |
const gate = { filled: 0, open: 0, through: false };
|
| 435 |
|
| 436 |
+
/**
|
| 437 |
+
* WHAT THIS BUILD IS FOR, counted.
|
| 438 |
+
*
|
| 439 |
+
* The question health is being added to answer is one thing: does he start
|
| 440 |
+
* standing still and looking at something before walking up to it? So the
|
| 441 |
+
* run keeps score of the only number that answers it — seconds spent with
|
| 442 |
+
* the torch up, standing still, with nothing hostile near enough to reach
|
| 443 |
+
* him. Watching from safety.
|
| 444 |
+
*
|
| 445 |
+
* Everything else here is context for that one figure.
|
| 446 |
+
*/
|
| 447 |
+
const run = { seconds: 0, fights: 0, hitsTaken: 0, deaths: 0, watching: 0, taken: 0 };
|
| 448 |
+
const WATCH_CLEAR_PX = 600;
|
| 449 |
+
|
| 450 |
const map = {
|
| 451 |
up: false,
|
| 452 |
// How long the sheet has been held at full raise. Reading it is looking, so
|
|
|
|
| 461 |
audio,
|
| 462 |
ground: clips.ground,
|
| 463 |
kept: clips.kept,
|
| 464 |
+
map, sheetClip, mapClip, hurtClip, run,
|
| 465 |
houses: HOUSES, houseTint: PALETTE.WATCHERS,
|
| 466 |
houseClip, houseArt, doorPoint,
|
| 467 |
village, gateX: GATE_X, gateStopX: GATE_STOP_X,
|
|
|
|
| 477 |
prince.facing = 1;
|
| 478 |
prince.hasSword = true;
|
| 479 |
prince.moving = false;
|
| 480 |
+
prince.hits = 0;
|
| 481 |
prince.run.reset();
|
| 482 |
prince.idle.reset();
|
| 483 |
torch.reset();
|
|
|
|
| 497 |
gate.open = 0;
|
| 498 |
gate.through = false;
|
| 499 |
state.fade = 0;
|
| 500 |
+
// Deaths survive a restart. Everything else about the run does not — that
|
| 501 |
+
// is what "the findings are at stake" means.
|
| 502 |
+
run.seconds = 0; run.fights = 0; run.hitsTaken = 0; run.watching = 0; run.taken = 0;
|
| 503 |
lastRunFrame = -1;
|
| 504 |
prince.inventory.length = 0;
|
| 505 |
drained.forEach((d) => d.reset());
|
|
|
|
| 531 |
torch.cy = loss.y;
|
| 532 |
torch.radius = loss.r;
|
| 533 |
}
|
| 534 |
+
// THE LEVEL RESTARTS. He does not get up where he fell any more.
|
| 535 |
+
//
|
| 536 |
+
// The spec's checkpoint at the mouth keeps the map, and a checkpoint that
|
| 537 |
+
// keeps the findings is too forgiving to change how anybody plays. The
|
| 538 |
+
// findings have to be at stake or the fights are not a cost.
|
| 539 |
+
state.fade = Math.min(1, (loss.timer - TUNING.LOSS_HOLD_MS) / TUNING.DEATH_FADE_MS);
|
| 540 |
+
if (loss.timer >= TUNING.LOSS_HOLD_MS + TUNING.DEATH_FADE_MS) { run.deaths++; reset(); }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 541 |
return;
|
| 542 |
}
|
| 543 |
|
|
|
|
| 584 |
}
|
| 585 |
}
|
| 586 |
if (prince.attacking) {
|
| 587 |
+
// THE SWING SLOWS. A cost he feels in the one thing he was using to make
|
| 588 |
+
// the cost not matter.
|
| 589 |
+
const slow = TUNING.SWING_SLOW[Math.min(prince.hits, TUNING.SWING_SLOW.length - 1)];
|
| 590 |
+
prince.attackTime += dt / slow;
|
| 591 |
const ms = prince.attackTime * 1000;
|
| 592 |
const active = ms >= TUNING.ATTACK_WINDUP_MS &&
|
| 593 |
ms < TUNING.ATTACK_WINDUP_MS + TUNING.ATTACK_ACTIVE_MS;
|
|
|
|
| 623 |
// Contact with a WANDER one is not a loss. He walks into someone standing
|
| 624 |
// in the dark and nothing happens at all, which is worse.
|
| 625 |
if (kind === 'shove' && grace <= 0) {
|
| 626 |
+
// He loses ground AND one of his six. He was the only thing in the
|
| 627 |
+
// village that could not hurt you, which made the hard fight free.
|
| 628 |
+
loss.by = d;
|
| 629 |
+
const back = d.x + Math.sign(prince.x - d.x || -1) * (TUNING.POST_REACH + TUNING.POST_SHOVE_PX);
|
| 630 |
+
takeHit(d.x, 0);
|
| 631 |
+
if (!loss.active) { prince.x = back; prince.prevX = back; }
|
|
|
|
| 632 |
}
|
| 633 |
if (kind === 'fatal' && !loss.active) {
|
|
|
|
|
|
|
| 634 |
loss.by = d;
|
| 635 |
+
takeHit(d.x, TUNING.HURT_KNOCK_PX);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 636 |
}
|
| 637 |
}
|
| 638 |
|
|
|
|
| 684 |
}
|
| 685 |
if (wantsPickup && nearest && !prince.attacking) {
|
| 686 |
nearest.keptTaken = true;
|
| 687 |
+
run.taken++;
|
| 688 |
prince.inventory.push({
|
| 689 |
kept: nearest.kept, identity: nearest.identity, seen: nearest.keptSeen,
|
| 690 |
});
|
| 691 |
}
|
| 692 |
|
| 693 |
+
// --- the score ----------------------------------------------------------
|
| 694 |
+
run.seconds += dt;
|
| 695 |
+
run.fights = drained.filter((d) => d.revealed).length;
|
| 696 |
+
if (torch.raise > 0.9 && !prince.moving) {
|
| 697 |
+
const near = drained.some((d) => d.state !== DOWNED &&
|
| 698 |
+
Math.abs(d.x - prince.x) < WATCH_CLEAR_PX);
|
| 699 |
+
if (!near) run.watching += dt;
|
| 700 |
+
}
|
| 701 |
+
|
| 702 |
// --- the gate -----------------------------------------------------------
|
| 703 |
// It counts what he is CARRYING and has looked at, and it counts live: put
|
| 704 |
// one down and a mark goes out again. The map keeps score and the score is
|
|
|
|
| 731 |
state.flame.frame = state.flame.player.index;
|
| 732 |
} else if (prince.moving) {
|
| 733 |
const rate = runClip.groundSpeed ? TUNING.WALK_SPEED / runClip.groundSpeed : 1;
|
| 734 |
+
// THE LIMP, AND IT IS HER OWN RUN FRAMES.
|
| 735 |
+
//
|
| 736 |
+
// A limp is an asymmetric rhythm, not a different drawing: the good foot
|
| 737 |
+
// carries longer and the bad one is hurried off. So the cycle is slowed
|
| 738 |
+
// through its second half and rushed through the first, by an amount that
|
| 739 |
+
// grows with the hits he has taken. Nothing was redrawn for it, and it
|
| 740 |
+
// reads because it is what a limp actually is.
|
| 741 |
+
const hurt = Math.max(0, prince.hits - TUNING.LIMP_FROM);
|
| 742 |
+
if (hurt > 0) {
|
| 743 |
+
const half = prince.run.index >= runClip.count / 2;
|
| 744 |
+
const hitch = 1 - Math.min(0.8, TUNING.LIMP_HITCH * (hurt / 3)) * (half ? 1 : -0.5);
|
| 745 |
+
prince.run.advance(dt * rate / hitch);
|
| 746 |
+
} else {
|
| 747 |
+
prince.run.advance(dt * rate);
|
| 748 |
+
}
|
| 749 |
prince.clip = runClip;
|
| 750 |
prince.frame = prince.run.index;
|
| 751 |
// A footstep when the boot ARRIVES, not on a timer. runClip.footFrames is
|
|
|
|
| 754 |
if (runClip.footFrames?.includes(prince.frame)) audio?.footstep();
|
| 755 |
lastRunFrame = prince.frame;
|
| 756 |
}
|
| 757 |
+
} else if (prince.hits >= TUNING.LIMP_FROM && hurtClip) {
|
| 758 |
+
// Standing still with something left in him, he holds his side. This is
|
| 759 |
+
// the whole health readout: no bar, no number, just how he is standing.
|
| 760 |
+
//
|
| 761 |
+
// Two stages. Which pair of frames he is in is how close he is — the
|
| 762 |
+
// first is hurt, the second is spent, and the switch is at the halfway
|
| 763 |
+
// point of what he can take.
|
| 764 |
+
prince.hurtPlayer.advance(dt);
|
| 765 |
+
const spent = prince.hits >= Math.ceil(TUNING.PRINCE_HITS / 2) + 1;
|
| 766 |
+
prince.clip = hurtClip;
|
| 767 |
+
prince.frame = (spent ? 2 : 0) + (prince.hurtPlayer.index % 2);
|
| 768 |
} else if (drained.some((d) => d.revealed && d.state !== DOWNED && Math.abs(d.x - prince.x) < W)) {
|
| 769 |
// Once one has been seen and is coming, he stands ready.
|
| 770 |
prince.guard.advance(dt);
|
src/silhouette.js
CHANGED
|
@@ -19,7 +19,7 @@
|
|
| 19 |
// sealing: 2.43x, and it reads correctly — body solid, fingers separate, boots
|
| 20 |
// intact.
|
| 21 |
//
|
| 22 |
-
// See FINDINGS.md for the full write-up.
|
| 23 |
|
| 24 |
const INF = 1 << 28;
|
| 25 |
|
|
|
|
| 19 |
// sealing: 2.43x, and it reads correctly — body solid, fingers separate, boots
|
| 20 |
// intact.
|
| 21 |
//
|
| 22 |
+
// See docs/FINDINGS.md for the full write-up.
|
| 23 |
|
| 24 |
const INF = 1 << 28;
|
| 25 |
|
src/tuning.js
CHANGED
|
@@ -257,17 +257,49 @@ export const TUNING = {
|
|
| 257 |
ATTACK_ACTIVE_MS: 80,
|
| 258 |
ATTACK_RECOVERY_MS: 220,
|
| 259 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 260 |
LOSS_HOLD_MS: 800,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 261 |
|
| 262 |
-
//
|
| 263 |
-
//
|
| 264 |
-
//
|
| 265 |
-
// him again on the frame he stands up.
|
| 266 |
-
RECOVER_GRACE_MS: 1200,
|
| 267 |
-
// 190 left a 98px gap against a 110px contact reach, so it grabbed him again
|
| 268 |
-
// the instant the grace ran out. It also kept walking in during the grace,
|
| 269 |
-
// which is why it is staggered as well as pushed.
|
| 270 |
-
RECOVER_PUSH_PX: 420,
|
| 271 |
|
| 272 |
// How close he has to be to take the object off the ground. The drop sits
|
| 273 |
// 225px out from the body, and he stops about 170px short of it when he
|
|
@@ -408,7 +440,7 @@ export const TONE = {
|
|
| 408 |
|
| 409 |
// --- the depth experiment ---------------------------------------------------
|
| 410 |
// ALL OFF BY DEFAULT. Nothing here is a feature yet; see the spec and the table
|
| 411 |
-
// in FINDINGS.md. Toggled from the address bar, and X cycles them one at a time
|
| 412 |
// because judging these by eye needs fast A/B and typing five URLs is not fast.
|
| 413 |
export const DEPTH = {
|
| 414 |
OCC: 1, // near-layer density multiplier. 1 leaves it alone
|
|
|
|
| 257 |
ATTACK_ACTIVE_MS: 80,
|
| 258 |
ATTACK_RECOVERY_MS: 220,
|
| 259 |
|
| 260 |
+
// --- HEALTH, and the reason it is here ------------------------------------
|
| 261 |
+
//
|
| 262 |
+
// Village one played like a beat 'em up, and it played that way because
|
| 263 |
+
// beating up every Drained cost nothing: three hits, no health, no death. So
|
| 264 |
+
// the optimal play was to hit everything, and the game agreed.
|
| 265 |
+
//
|
| 266 |
+
// The spec's reasoning for leaving health out was wrong, and its author said
|
| 267 |
+
// so: the question was whether the SEARCH is tedious, and tedium measures
|
| 268 |
+
// fine at zero risk — but at zero risk there is no search at all, just a
|
| 269 |
+
// sweep. A sweep cannot be tedious because it is not looking for anything.
|
| 270 |
+
//
|
| 271 |
+
// SIX, not three. Three plus a ten-screen restart means one mistake costs
|
| 272 |
+
// fifteen minutes and people reload the page instead of playing on. Six makes
|
| 273 |
+
// a death a run of bad decisions rather than one bad moment.
|
| 274 |
+
//
|
| 275 |
+
// It does not come back. Six is six for the whole run, and a restart is the
|
| 276 |
+
// only thing that resets it.
|
| 277 |
+
PRINCE_HITS: 6,
|
| 278 |
+
HURT_GRACE_MS: 900, // caught once, then a moment to get clear
|
| 279 |
+
HURT_KNOCK_PX: 260,
|
| 280 |
+
|
| 281 |
+
// NO HEALTH BAR. It is read off his body, the same rule as everything else:
|
| 282 |
+
// he limps, he holds his side, and the swing slows.
|
| 283 |
+
//
|
| 284 |
+
// The limp is HER run frames on an uneven cadence with a dip on the bad side,
|
| 285 |
+
// not a new drawing. A limp is an asymmetric rhythm, so this is the truer
|
| 286 |
+
// version as well as the cheaper one.
|
| 287 |
+
LIMP_FROM: 2, // hits taken before the walk goes uneven
|
| 288 |
+
LIMP_HITCH: 0.42, // how much slower the bad foot's half of the cycle is
|
| 289 |
+
LIMP_DIP_PX: 7,
|
| 290 |
+
// The swing slows as he goes, by hits taken. A cost he can feel in the one
|
| 291 |
+
// thing he was using to make the cost not matter.
|
| 292 |
+
SWING_SLOW: [1, 1, 1.1, 1.24, 1.42, 1.66],
|
| 293 |
+
|
| 294 |
LOSS_HOLD_MS: 800,
|
| 295 |
+
// Death restarts the LEVEL. The spec's checkpoint at the mouth keeps the map,
|
| 296 |
+
// and a checkpoint that keeps the findings is too forgiving to change how
|
| 297 |
+
// anybody plays. The findings have to be at stake.
|
| 298 |
+
DEATH_FADE_MS: 1600,
|
| 299 |
|
| 300 |
+
// RECOVER_GRACE_MS and RECOVER_PUSH_PX lived here and are gone. They were for
|
| 301 |
+
// getting up where he fell, which is not what happens any more: being caught
|
| 302 |
+
// costs one of six, and the sixth restarts the level. See PRINCE_HITS.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 303 |
|
| 304 |
// How close he has to be to take the object off the ground. The drop sits
|
| 305 |
// 225px out from the body, and he stops about 170px short of it when he
|
|
|
|
| 440 |
|
| 441 |
// --- the depth experiment ---------------------------------------------------
|
| 442 |
// ALL OFF BY DEFAULT. Nothing here is a feature yet; see the spec and the table
|
| 443 |
+
// in docs/FINDINGS.md. Toggled from the address bar, and X cycles them one at a time
|
| 444 |
// because judging these by eye needs fast A/B and typing five URLs is not fast.
|
| 445 |
export const DEPTH = {
|
| 446 |
OCC: 1, // near-layer density multiplier. 1 leaves it alone
|