Every speed in config.js is in pixels per second, so the same numbers cross a small screen in less time than a big one: on 1280x800 the frame covers the width in two thirds of the time it takes on 1920x1080, and the player reads as hurried. One factor now sits behind every animation clock — tickSeconds() for the frame loops, `speed` for the corners timer, and a --motion-stretch custom property for the two CSS fades — so the Speed switch on the queue page slows the drift, the glides, the pong rally, the drag beats, the wash lap, the screenshot intervals and the fades together. It is read per frame rather than baked into the constants, so flipping the switch lands on the next frame: no animation restarts, and one part-way through a beat keeps its place. The trail's stills now age on animation time rather than wall-clock time, so a still fades at the rate the frame that dropped it is moving. The audio crossfade is deliberately left out, being tied to the track that is ending. The flag is saved with the queue, unlike `paused`: it describes the screen the player is on, which a restart does not change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011vCaS2whbFjExShoPNnS3A
360 lines
8.1 KiB
CSS
360 lines
8.1 KiB
CSS
:root {
|
|
--accent: #ff4fc3;
|
|
--ink: #14060f;
|
|
/* How much longer than written every animated duration below runs. Set by
|
|
motion.js from the speed the player is on: 1 normally, and above 1 on a
|
|
low-res screen, where everything is slowed down together. */
|
|
--motion-stretch: 1;
|
|
}
|
|
|
|
* {
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
html,
|
|
body {
|
|
margin: 0;
|
|
height: 100%;
|
|
overflow: hidden;
|
|
background: var(--ink);
|
|
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
cursor: none;
|
|
}
|
|
|
|
/* --- background ------------------------------------------------------- */
|
|
|
|
#backgrounds {
|
|
position: fixed;
|
|
inset: 0;
|
|
}
|
|
|
|
/* Two canvases, cross-faded. The zoom and the crop are baked into the pixels
|
|
by backdrop.js, so these are always exactly screen-sized. */
|
|
#backgrounds canvas {
|
|
position: absolute;
|
|
inset: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
opacity: 0;
|
|
transition: opacity calc(900ms * var(--motion-stretch)) ease;
|
|
/* Knock the poster back so the video frame stays the focal point — the
|
|
posters are pale and read far brighter on a projector than on a desk. */
|
|
filter: brightness(0.45) saturate(1.15);
|
|
}
|
|
|
|
#backgrounds canvas.active {
|
|
opacity: 1;
|
|
}
|
|
|
|
/* The logo sits on the poster, not on the video: it shares the backdrop's
|
|
layer so the frame and the stills still pass in front of it. Its width comes
|
|
from LOGO_WIDTH, written onto the element by backdrop.js. */
|
|
#logo {
|
|
position: fixed;
|
|
top: 50%;
|
|
left: 50%;
|
|
/* Centred, then nudged sideways by LOGO_OFFSET, which backdrop.js writes
|
|
onto the element. */
|
|
--logo-shift: 0%;
|
|
translate: calc(-50% + var(--logo-shift)) -50%;
|
|
height: auto;
|
|
/* Only bites on a screen too short for the width above — on the projector
|
|
the width is what decides. */
|
|
max-height: 70vh;
|
|
object-fit: contain;
|
|
pointer-events: none;
|
|
z-index: 0;
|
|
}
|
|
|
|
/* Built and removed by the wash animation. Sits between the poster and the
|
|
stills, and blends rather than covers, so the poster stays legible through
|
|
whatever colour is passing over it. The gradient itself is written per frame
|
|
from JS. */
|
|
#wash {
|
|
position: fixed;
|
|
inset: 0;
|
|
z-index: 0;
|
|
pointer-events: none;
|
|
/* Takes the poster's luminance and only replaces its hue, so the wash reads
|
|
as a colour change rather than as a sheet of colour laid over the top. */
|
|
mix-blend-mode: color;
|
|
}
|
|
|
|
/* --- frozen screenshots ------------------------------------------------ */
|
|
|
|
#stills {
|
|
position: fixed;
|
|
inset: 0;
|
|
pointer-events: none;
|
|
z-index: 1;
|
|
}
|
|
|
|
#stills img {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
object-fit: cover;
|
|
opacity: 0;
|
|
/* Matches #frameSkin, so a still reads as a frozen copy of the frame. */
|
|
border-radius: 10px;
|
|
border: 2px solid rgba(255, 255, 255, 0.85);
|
|
box-shadow:
|
|
0 0 0 4px rgba(255, 79, 195, 0.55),
|
|
0 24px 70px rgba(0, 0, 0, 0.7);
|
|
transition: opacity calc(500ms * var(--motion-stretch)) ease;
|
|
will-change: transform;
|
|
}
|
|
|
|
#stills img.visible {
|
|
opacity: 1;
|
|
}
|
|
|
|
/* The trail ramps opacity frame-by-frame as a still ages, so the CSS transition
|
|
above would only fight it. */
|
|
#stills img.trail {
|
|
transition: none;
|
|
}
|
|
|
|
/* --- the moving frame -------------------------------------------------- */
|
|
|
|
#frameMover {
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
/* Above the stills, so the live video always wins if anything ever overlaps. */
|
|
z-index: 2;
|
|
will-change: transform;
|
|
}
|
|
|
|
#frameSkin {
|
|
position: relative;
|
|
width: 100%;
|
|
height: 100%;
|
|
overflow: hidden;
|
|
border-radius: 10px;
|
|
border: 2px solid rgba(255, 255, 255, 0.85);
|
|
box-shadow:
|
|
0 0 0 4px rgba(255, 79, 195, 0.55),
|
|
0 24px 70px rgba(0, 0, 0, 0.7);
|
|
background: #000;
|
|
}
|
|
|
|
/* Lift the frame while the drag animation is holding it, the way a dragged
|
|
window separates from the desktop. */
|
|
#frameMover.dragging #frameSkin {
|
|
box-shadow:
|
|
0 0 0 4px rgba(255, 79, 195, 0.75),
|
|
0 40px 100px rgba(0, 0, 0, 0.85);
|
|
}
|
|
|
|
/* The wash animation hands the frame the colour currently passing behind it. */
|
|
#frameMover.washing #frameSkin {
|
|
box-shadow:
|
|
0 0 0 4px var(--wash, rgba(255, 79, 195, 0.55)),
|
|
0 24px 70px rgba(0, 0, 0, 0.7);
|
|
}
|
|
|
|
/* --- the drawn pointer -------------------------------------------------- */
|
|
|
|
/* Built and removed by the drag animation; nothing else on the page has one. */
|
|
#pointer {
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
z-index: 3;
|
|
pointer-events: none;
|
|
will-change: transform;
|
|
filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.6));
|
|
}
|
|
|
|
#pointer svg {
|
|
display: block;
|
|
transition: transform 90ms ease;
|
|
}
|
|
|
|
/* The click itself: the arrow dips, so a grab is visible even mid-pause. */
|
|
#pointer.down svg {
|
|
transform: scale(0.82);
|
|
}
|
|
|
|
/* --- pong --------------------------------------------------------------- */
|
|
|
|
/* Built and removed by the pong animation. Above the frame, so the ball is
|
|
never hidden behind the paddle it is bouncing off. */
|
|
#pong {
|
|
position: fixed;
|
|
inset: 0;
|
|
z-index: 3;
|
|
pointer-events: none;
|
|
}
|
|
|
|
#pongBall {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
background: #fff;
|
|
/* Square, and no smoothing: the original had no room for a round ball. */
|
|
box-shadow: 0 0 18px rgba(255, 79, 195, 0.7);
|
|
will-change: transform;
|
|
}
|
|
|
|
/* Out of play between a goal and the next serve. */
|
|
#pongBall.out {
|
|
opacity: 0;
|
|
}
|
|
|
|
#pongScore {
|
|
position: absolute;
|
|
top: clamp(16px, 4vh, 56px);
|
|
right: clamp(16px, 4vw, 72px);
|
|
display: flex;
|
|
gap: 0.35em;
|
|
font-size: clamp(2.5rem, 7vh, 6rem);
|
|
}
|
|
|
|
#pongScore .digit {
|
|
display: block;
|
|
height: 1em;
|
|
/* The viewBox is 24 wide by 44 tall. */
|
|
width: calc(1em * 24 / 44);
|
|
}
|
|
|
|
/* An unlit segment stays visible, just barely — that faint ghost of the whole
|
|
figure eight is what makes the display read as a seven-segment one. */
|
|
#pongScore .segment {
|
|
fill: rgba(255, 255, 255, 0.07);
|
|
}
|
|
|
|
#pongScore .segment.lit {
|
|
fill: #fff;
|
|
filter: drop-shadow(0 0 10px rgba(255, 79, 195, 0.8));
|
|
}
|
|
|
|
/* The paddle: a hard-edged slab, so the ball has something square to hit. */
|
|
#frameMover.paddle #frameSkin {
|
|
border-radius: 0;
|
|
box-shadow:
|
|
0 0 0 3px #fff,
|
|
0 0 34px rgba(255, 79, 195, 0.6);
|
|
}
|
|
|
|
.deck {
|
|
position: absolute;
|
|
inset: 0;
|
|
opacity: 0;
|
|
/* Opacity is driven frame-by-frame during a crossfade, so no CSS transition
|
|
here — it would fight the JS ramp. */
|
|
}
|
|
|
|
.deck iframe {
|
|
width: 100%;
|
|
height: 100%;
|
|
border: 0;
|
|
/* The iframe would otherwise swallow nothing useful, but keeping it inert
|
|
avoids stray clicks pausing playback. */
|
|
pointer-events: none;
|
|
}
|
|
|
|
/* The screen-capture stream's sink. Has to be in the document to keep getting
|
|
frames (see capture.js), but must not be visible or catch clicks. */
|
|
#captureSink {
|
|
position: fixed;
|
|
left: 0;
|
|
top: 0;
|
|
width: 1px;
|
|
height: 1px;
|
|
opacity: 0.01;
|
|
pointer-events: none;
|
|
z-index: -1;
|
|
}
|
|
|
|
#probe {
|
|
position: fixed;
|
|
left: -400px;
|
|
top: -400px;
|
|
width: 320px;
|
|
height: 180px;
|
|
opacity: 0;
|
|
pointer-events: none;
|
|
}
|
|
|
|
/* --- start gate -------------------------------------------------------- */
|
|
|
|
#gate {
|
|
position: fixed;
|
|
inset: 0;
|
|
display: grid;
|
|
place-items: center;
|
|
background: rgba(20, 6, 15, 0.92);
|
|
backdrop-filter: blur(6px);
|
|
z-index: 10;
|
|
cursor: pointer;
|
|
transition: opacity 400ms ease;
|
|
}
|
|
|
|
#gate.hidden {
|
|
opacity: 0;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.gate-inner {
|
|
text-align: center;
|
|
color: #fff;
|
|
}
|
|
|
|
.gate-inner h1 {
|
|
font-size: clamp(2rem, 7vw, 5rem);
|
|
margin: 0 0 0.3em;
|
|
letter-spacing: -0.03em;
|
|
color: var(--accent);
|
|
}
|
|
|
|
.gate-inner p {
|
|
margin: 0 0 2em;
|
|
opacity: 0.7;
|
|
font-size: 1.05rem;
|
|
}
|
|
|
|
#start {
|
|
font: inherit;
|
|
font-size: 1.3rem;
|
|
padding: 0.7em 3em;
|
|
border: 0;
|
|
border-radius: 999px;
|
|
background: var(--accent);
|
|
color: var(--ink);
|
|
font-weight: 700;
|
|
cursor: pointer;
|
|
}
|
|
|
|
#start:disabled {
|
|
opacity: 0.35;
|
|
cursor: default;
|
|
}
|
|
|
|
#status {
|
|
position: fixed;
|
|
bottom: 12px;
|
|
right: 16px;
|
|
z-index: 20;
|
|
color: var(--accent);
|
|
font-size: 0.8rem;
|
|
letter-spacing: 0.08em;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
/* Bottom left, opposite #status, so the two never overlap. */
|
|
#notice {
|
|
position: fixed;
|
|
bottom: 12px;
|
|
left: 16px;
|
|
z-index: 20;
|
|
max-width: min(46ch, 60vw);
|
|
padding: 8px 12px;
|
|
border-radius: 6px;
|
|
background: rgb(0 0 0 / 0.55);
|
|
color: var(--accent);
|
|
font-size: 0.8rem;
|
|
line-height: 1.35;
|
|
}
|