mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-02 16:38:14 +00:00
ruler+hero: pools under everything & occupancy-gated; scattered spawn order
The contrast pools move to their own z-39 layer, so the hero wordmark's z-40 letters (and everything else — rail, section words) draw above them; their masks now complete the fade inside the pool's own bounds so the rectangle edge never reads as a cut. Pool presence follows real occupancy, driven from the frame loop: the top pool fades in as the wordmark settles into the stack, the bottom pool fades out as the last queued word climbs onto the rail. The rail frost's inner feather becomes a generated power-curve gradient easing in toward the outer edge, with Frost solid % and Frost ease knobs. And the galaxy's aligned-ring spawn is fixed: card index order is arm-aligned (round-robin deal), so the intro stagger and the governor's shed/regrow now walk a golden-ratio permutation instead — plus a 4s governor grace period over the load stutter, which was shedding the intro and regrowing it as visible rings. Claude-Session: https://claude.ai/code/session_01YBoaBEt3gynzTwogBFtn4B
This commit is contained in:
1 parent
81ee9a3533
commit
dfbc443c23
4 files changed
+136
-49
No files matched your search
@@ -453,6 +453,22 @@ function GalaxyScene({
|
||||
})),
|
||||
[layout],
|
||||
);
|
||||
// Index order is arm-aligned (arms deal round-robin), so anything that
|
||||
// reveals cards sequentially by index — the intro stagger, the governor
|
||||
// regrowing shed cards — would paint aligned rings. This golden-ratio
|
||||
// permutation scatters that order across arms and slots.
|
||||
const liveOrder = useMemo(() => {
|
||||
const idx = cards.map((_, i) => i);
|
||||
idx.sort((a, b) => ((a * 0.618034) % 1) - ((b * 0.618034) % 1));
|
||||
return idx;
|
||||
}, [cards]);
|
||||
const liveRank = useMemo(() => {
|
||||
const r = new Int32Array(cards.length);
|
||||
liveOrder.forEach((ci, k) => {
|
||||
r[ci] = k;
|
||||
});
|
||||
return r;
|
||||
}, [cards, liveOrder]);
|
||||
const materials = useMemo(
|
||||
() =>
|
||||
cards.map(
|
||||
@@ -577,24 +593,28 @@ function GalaxyScene({
|
||||
clipBest.fill(-1);
|
||||
|
||||
// Perf governor: EMA of the real frame time. Sustained drops below the
|
||||
// FPS floor shed the highest-index cards (and their decode pressure)
|
||||
// quickly; recovery regrows slowly so it never oscillates.
|
||||
// FPS floor shed cards (and their decode pressure) quickly; recovery
|
||||
// regrows slowly so it never oscillates. The first seconds are a grace
|
||||
// period — page load always stutters (shader compile, video priming)
|
||||
// and shedding the intro looks like a broken spawn.
|
||||
st.frameEma += (Math.min(delta, 0.25) - st.frameEma) * 0.05;
|
||||
if (mv.perfFloor > 0) {
|
||||
if (mv.perfFloor > 0 && t > 4) {
|
||||
const budget = 1 / mv.perfFloor;
|
||||
if (st.frameEma > budget) {
|
||||
st.perfScale = Math.max(0.35, st.perfScale - dt * 0.3);
|
||||
} else if (st.frameEma < budget * 0.7) {
|
||||
st.perfScale = Math.min(1, st.perfScale + dt * 0.05);
|
||||
}
|
||||
} else {
|
||||
} else if (mv.perfFloor === 0) {
|
||||
st.perfScale = 1;
|
||||
}
|
||||
const liveN = Math.max(6, Math.round(cards.length * st.perfScale));
|
||||
|
||||
// Pass 1: place every live card along its arm. All positions must be
|
||||
// known before any card can size itself against its neighbors.
|
||||
for (let i = 0; i < liveN; i++) {
|
||||
// Pass 1: place every live card along its arm (in permuted order — the
|
||||
// live set is the first liveN entries of liveOrder). All positions must
|
||||
// be known before any card can size itself against its neighbors.
|
||||
for (let k = 0; k < liveN; k++) {
|
||||
const i = liveOrder[k];
|
||||
const card = cards[i];
|
||||
const c = cycle(
|
||||
(card.slot + 0.5) / L.slotsPerArm + P + card.arm * L.armJitter,
|
||||
@@ -618,7 +638,7 @@ function GalaxyScene({
|
||||
for (let i = 0; i < cards.length; i++) {
|
||||
const mesh = cardRefs.current[i];
|
||||
if (!mesh) continue;
|
||||
if (i >= liveN) {
|
||||
if (liveRank[i] >= liveN) {
|
||||
mesh.visible = false;
|
||||
continue;
|
||||
}
|
||||
@@ -641,7 +661,8 @@ function GalaxyScene({
|
||||
// is eased so freed space fills organically; shrinking is instant so
|
||||
// the guarantee never breaks mid-frame.
|
||||
let sep = Infinity;
|
||||
for (let j = 0; j < liveN; j++) {
|
||||
for (let k = 0; k < liveN; k++) {
|
||||
const j = liveOrder[k];
|
||||
if (j === i) continue;
|
||||
const s = Math.max(
|
||||
(Math.abs(cardPos[j * 2] - x) * 9) / 16,
|
||||
@@ -673,7 +694,8 @@ function GalaxyScene({
|
||||
const lifecycle = clamp01(c / lk.fadeBand);
|
||||
const solid = lk.washInner + (1 - lk.washInner) * c;
|
||||
const intro = clamp01(
|
||||
(t - mv.introDelay - i * mv.introStagger) / Math.max(0.05, mv.introDur),
|
||||
(t - mv.introDelay - liveRank[i] * mv.introStagger) /
|
||||
Math.max(0.05, mv.introDur),
|
||||
);
|
||||
|
||||
const ready = videos[card.clip].readyState >= 2 ? 1 : 0;
|
||||
|
||||
@@ -91,6 +91,8 @@ export default function HeadingFlow({
|
||||
null,
|
||||
);
|
||||
const wordRefs = useRef<WordRefs[]>([]);
|
||||
const topPoolRef = useRef<HTMLDivElement>(null);
|
||||
const bottomPoolRef = useRef<HTMLDivElement>(null);
|
||||
const fs = useRef({
|
||||
lastY: 0,
|
||||
vel: 0,
|
||||
@@ -240,6 +242,26 @@ export default function HeadingFlow({
|
||||
else if (p.flipP > 0) flipShift += p.flipP;
|
||||
}
|
||||
|
||||
// Pool presence follows real occupancy: the top pool fades in as the
|
||||
// first word (the wordmark) settles into the stack; the bottom pool
|
||||
// fades out as the last queued word climbs onto the rail. The hero
|
||||
// never holds the bottom pool — it has no queue home.
|
||||
let topK = 0;
|
||||
let botK = 0;
|
||||
for (let wi = 0; wi < phases.length; wi++) {
|
||||
if (phases[wi].flipP > topK) topK = phases[wi].flipP;
|
||||
if (!sections[wi].isHero) {
|
||||
const q = 1 - phases[wi].detachP;
|
||||
if (q > botK) botK = q;
|
||||
}
|
||||
}
|
||||
if (topPoolRef.current) {
|
||||
topPoolRef.current.style.opacity = String(lk.poolAlpha * topK);
|
||||
}
|
||||
if (bottomPoolRef.current) {
|
||||
bottomPoolRef.current.style.opacity = String(lk.poolAlpha * botK);
|
||||
}
|
||||
|
||||
// Horizontal home for a word: letters run inward from the rail, with
|
||||
// the reading direction arranged so the word's tail sits nearest the
|
||||
// rail on a right-side ruler (and its head nearest on the left).
|
||||
@@ -594,46 +616,54 @@ export default function HeadingFlow({
|
||||
layout.headingPx +
|
||||
pools.poolPad;
|
||||
const bottomPoolH = layout.queuePad + n * layout.queueSlot + pools.poolPad;
|
||||
// Masks complete their fade INSIDE the pool's box (transparent by 97% of
|
||||
// the box, not of an oversized ellipse), so the rectangle bounds never
|
||||
// read as a cut.
|
||||
const poolMask = (cornerY: string) =>
|
||||
`radial-gradient(115% 115% at ${side === "right" ? "100%" : "0%"} ${cornerY}, black 45%, rgba(0,0,0,0.8) 62%, rgba(0,0,0,0.45) 78%, rgba(0,0,0,0.15) 91%, transparent 100%)`;
|
||||
`radial-gradient(100% 100% at ${side === "right" ? "100%" : "0%"} ${cornerY}, black 38%, rgba(0,0,0,0.82) 56%, rgba(0,0,0,0.48) 72%, rgba(0,0,0,0.2) 86%, rgba(0,0,0,0.05) 94%, transparent 97%)`;
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none fixed inset-0 z-40">
|
||||
<>
|
||||
{/* Contrast pools: frosted page-bg fades pinned to the rail's top and
|
||||
bottom corners, so the top stack and the bottom queue always read
|
||||
over whatever content scrolls beneath them. Blur level matches the
|
||||
rail frost, so a letter flying between rail and stack stays in one
|
||||
continuous frosted world. */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute top-0"
|
||||
style={{
|
||||
...(side === "right" ? { right: 0 } : { left: 0 }),
|
||||
width: poolW,
|
||||
height: topPoolH,
|
||||
opacity: pools.poolAlpha,
|
||||
backdropFilter: "blur(9px)",
|
||||
WebkitBackdropFilter: "blur(9px)",
|
||||
backgroundColor: "color-mix(in srgb, var(--bg) 80%, transparent)",
|
||||
maskImage: poolMask("0%"),
|
||||
WebkitMaskImage: poolMask("0%"),
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute bottom-0"
|
||||
style={{
|
||||
...(side === "right" ? { right: 0 } : { left: 0 }),
|
||||
width: poolW,
|
||||
height: bottomPoolH,
|
||||
opacity: pools.poolAlpha,
|
||||
backdropFilter: "blur(9px)",
|
||||
WebkitBackdropFilter: "blur(9px)",
|
||||
backgroundColor: "color-mix(in srgb, var(--bg) 80%, transparent)",
|
||||
maskImage: poolMask("100%"),
|
||||
WebkitMaskImage: poolMask("100%"),
|
||||
}}
|
||||
/>
|
||||
continuous frosted world. Below z-40: everything — rail, section
|
||||
letters, AND the hero wordmark's z-40 spans — draws above them.
|
||||
Opacity is occupancy-driven from the frame loop (starts empty). */}
|
||||
<div aria-hidden className="pointer-events-none fixed inset-0 z-[39]">
|
||||
<div
|
||||
ref={topPoolRef}
|
||||
className="absolute top-0"
|
||||
style={{
|
||||
...(side === "right" ? { right: 0 } : { left: 0 }),
|
||||
width: poolW,
|
||||
height: topPoolH,
|
||||
opacity: 0,
|
||||
backdropFilter: "blur(9px)",
|
||||
WebkitBackdropFilter: "blur(9px)",
|
||||
backgroundColor: "color-mix(in srgb, var(--bg) 80%, transparent)",
|
||||
maskImage: poolMask("0%"),
|
||||
WebkitMaskImage: poolMask("0%"),
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
ref={bottomPoolRef}
|
||||
className="absolute bottom-0"
|
||||
style={{
|
||||
...(side === "right" ? { right: 0 } : { left: 0 }),
|
||||
width: poolW,
|
||||
height: bottomPoolH,
|
||||
opacity: 0,
|
||||
backdropFilter: "blur(9px)",
|
||||
WebkitBackdropFilter: "blur(9px)",
|
||||
backgroundColor: "color-mix(in srgb, var(--bg) 80%, transparent)",
|
||||
maskImage: poolMask("100%"),
|
||||
WebkitMaskImage: poolMask("100%"),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="pointer-events-none fixed inset-0 z-40">
|
||||
{sections.map((s, wi) => {
|
||||
const refs = (wordRefs.current[wi] ??= {
|
||||
letters: [],
|
||||
@@ -675,7 +705,8 @@ export default function HeadingFlow({
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -363,6 +363,22 @@ export const rulerLookTuner = defineTunables("rulerLook", "Ruler look", {
|
||||
default: 1,
|
||||
info: "Strength of the page-bg contrast pools behind the top stack and the bottom queue (0 = none).",
|
||||
},
|
||||
frostSolid: {
|
||||
label: "Frost solid %",
|
||||
min: 0,
|
||||
max: 80,
|
||||
step: 5,
|
||||
default: 40,
|
||||
info: "Portion of the rail (from the outer edge) where the frost is at full strength before the feather begins.",
|
||||
},
|
||||
frostGamma: {
|
||||
label: "Frost ease",
|
||||
min: 0.5,
|
||||
max: 4,
|
||||
step: 0.1,
|
||||
default: 2.2,
|
||||
info: "Falloff curve of the frost's inner feather — higher eases in longer toward the page (a slower, less visible tail).",
|
||||
},
|
||||
poolPad: {
|
||||
label: "Pool pad px",
|
||||
min: 0,
|
||||
|
||||
@@ -550,9 +550,9 @@ export default function ScrollRuler() {
|
||||
>
|
||||
{/* Frost underlay: content flows under the rail (no reserved
|
||||
gutter); this pane blurs and tints whatever passes beneath so
|
||||
the instrumentation always reads. The inner edge feathers on a
|
||||
smoothstep-like curve — soft enough that letters flying in and
|
||||
out never read a seam. */}
|
||||
the instrumentation always reads. The inner feather follows a
|
||||
tunable power curve easing in toward the outer edge, so no seam
|
||||
reads while letters fly in and out. */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute inset-0"
|
||||
@@ -560,8 +560,8 @@ export default function ScrollRuler() {
|
||||
backdropFilter: "blur(9px)",
|
||||
WebkitBackdropFilter: "blur(9px)",
|
||||
backgroundColor: "color-mix(in srgb, var(--bg) 55%, transparent)",
|
||||
maskImage: `linear-gradient(${side === "right" ? "to left" : "to right"}, black 50%, rgba(0,0,0,0.86) 64%, rgba(0,0,0,0.6) 76%, rgba(0,0,0,0.32) 86%, rgba(0,0,0,0.1) 94%, transparent 100%)`,
|
||||
WebkitMaskImage: `linear-gradient(${side === "right" ? "to left" : "to right"}, black 50%, rgba(0,0,0,0.86) 64%, rgba(0,0,0,0.6) 76%, rgba(0,0,0,0.32) 86%, rgba(0,0,0,0.1) 94%, transparent 100%)`,
|
||||
maskImage: frostMask(side, look.frostSolid, look.frostGamma),
|
||||
WebkitMaskImage: frostMask(side, look.frostSolid, look.frostGamma),
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -720,3 +720,21 @@ export default function ScrollRuler() {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// The frost's inner feather: full strength for the first `solid` percent
|
||||
// from the outer edge, then a power-curve falloff — alpha = (1 - x)^gamma
|
||||
// over the remaining span, sampled into gradient stops. Higher gamma spends
|
||||
// its fade early and approaches the page on a long, invisible tail.
|
||||
function frostMask(side: RulerSide, solid: number, gamma: number): string {
|
||||
const dir = side === "right" ? "to left" : "to right";
|
||||
const stops = [`black ${solid.toFixed(0)}%`];
|
||||
const steps = 8;
|
||||
for (let k = 1; k < steps; k++) {
|
||||
const x = k / steps;
|
||||
const a = Math.pow(1 - x, gamma);
|
||||
const pos = solid + x * (100 - solid);
|
||||
stops.push(`rgba(0,0,0,${a.toFixed(3)}) ${pos.toFixed(1)}%`);
|
||||
}
|
||||
stops.push("transparent 100%");
|
||||
return `linear-gradient(${dir}, ${stops.join(", ")})`;
|
||||
}
|
||||
Reference in new issue
Block a user