Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 74 additions & 2 deletions src/projects/elemental-showdown/ElementalShowdown.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,17 @@ import type { NextMatchup } from "./matchup-selection";
import { REVEAL_LINGER_MS } from "./Reveal";
import type { ShowdownElement } from "./schema";
import type { RateWindow, VoteCast } from "./showdown-schema";
import { statsElementOf } from "./showdown-stats";
import { UNLOCK_MS } from "./UnlockMoment";

// The route's only contribution is the Matchup the loader drew and the two
// server functions, so the tests hand those in.
vi.mock("@tanstack/react-router", () => import("@/test/router-stub"));
// server functions, so the tests hand those in. `vi.mock` is hoisted above
// this declaration, so the factory has to read it lazily.
const navigate = vi.fn();
vi.mock("@tanstack/react-router", async () => ({
...(await import("@/test/router-stub")),
useNavigate: () => navigate,
}));

const element = (
id: number,
Expand Down Expand Up @@ -89,7 +96,9 @@ const cast = (value: number) => ({

afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
vi.useRealTimers();
navigate.mockClear();
});

describe("the Tug as a slider", () => {
Expand Down Expand Up @@ -259,6 +268,69 @@ describe("after a Vote", () => {
});
});

describe("the Vote that unlocks the Stats", () => {
const THE_ROSTER = [fire, water, plant].map(statsElementOf);

// The reveal is read out as ever; the moment comes after it, on the clock
// the draining bar runs on.
async function castTheUnlockingVote() {
vi.useFakeTimers();
showdown({
castVote: vi.fn<CastVote>(async () =>
reveal({ unlockedElements: THE_ROSTER }),
),
});
fireEvent.click(pill());
await act(async () => {});
await act(async () => void vi.advanceTimersByTime(REVEAL_LINGER_MS));
}

it("flips a tile to every Element and stamps the screen before the Stats", async () => {
await castTheUnlockingVote();

expect(screen.getByText("stats unlocked")).toBeInTheDocument();
for (const element of THE_ROSTER)
expect(screen.getByText(element.emoji)).toBeInTheDocument();
expect(navigate).not.toHaveBeenCalled();

await act(async () => void vi.advanceTimersByTime(UNLOCK_MS));

expect(navigate).toHaveBeenCalledWith({ to: "/elemental-showdown/stats" });
});

it("goes on showing Matchups when the Vote unlocked nothing", async () => {
vi.useFakeTimers();
showdown();
fireEvent.click(pill());
await act(async () => {});

await act(async () => void vi.advanceTimersByTime(REVEAL_LINGER_MS));

expect(screen.getByText("plant")).toBeInTheDocument();
expect(navigate).not.toHaveBeenCalled();
});

it("skips the wave for a Voter who asks for less motion", async () => {
vi.stubGlobal("matchMedia", (media: string) => ({
matches: media === "(prefers-reduced-motion: reduce)",
}));

await castTheUnlockingVote();

expect(screen.queryByText("stats unlocked")).toBeNull();
expect(navigate).toHaveBeenCalledWith({ to: "/elemental-showdown/stats" });
});

it("opens the Stats on a browser with no Vibration API", async () => {
expect(navigator.vibrate).toBeUndefined();

await castTheUnlockingVote();
await act(async () => void vi.advanceTimersByTime(UNLOCK_MS));

expect(navigate).toHaveBeenCalledWith({ to: "/elemental-showdown/stats" });
});
});

describe("a Vote the address has no room left for", () => {
it("says slow down and keeps the Matchup up", async () => {
const user = userEvent.setup();
Expand Down
28 changes: 26 additions & 2 deletions src/projects/elemental-showdown/ElementalShowdown.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
import { Link } from "@tanstack/react-router";
import { Link, useNavigate } from "@tanstack/react-router";
import { useCallback, useRef, useState } from "react";
import { INK, PAPER, textOn } from "./element-colour";
import type { VoteCastResult, VoteLimited, VoteReveal } from "./matchup-score";
import type { NextMatchup } from "./matchup-selection";
import { PrimaryLink } from "./PrimaryLink";
import { ShareButton } from "./ShareButton";
import type { VoteCast, VoteValue } from "./showdown-schema";
import type { StatsElement } from "./showdown-stats";
import { Tug } from "./Tug";
import { UnlockMoment } from "./UnlockMoment";

// The wave is motion a visitor can ask not to be shown; asked, the Stats take
// the screen at once instead. `matchMedia` belongs to a browser, and this is
// read only when a Vote lands in one.
const prefersReducedMotion = () =>
window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches === true;

// The whole screen: one Matchup at a time, in the Project's own full-height
// layout rather than the Portfolio's shell.
Expand All @@ -19,10 +27,13 @@ export function ElementalShowdown({
castVote: (options: { data: VoteCast }) => Promise<VoteCastResult>;
nextMatchup: () => Promise<NextMatchup>;
}) {
const navigate = useNavigate();
const [matchup, setMatchup] = useState(shown);
const [reveal, setReveal] = useState<VoteReveal | null>(null);
const [limited, setLimited] = useState<VoteLimited | null>(null);
const [votesCast, setVotesCast] = useState(0);
// The Elements of the moment being played, once a Vote has unlocked them.
const [unlocking, setUnlocking] = useState<StatsElement[] | null>(null);
// A ref for the guard: a second Enter can arrive before a re-render would
// have told it the first Vote is already on its way.
const casting = useRef(false);
Expand Down Expand Up @@ -58,8 +69,18 @@ export function ElementalShowdown({
});
}

const openStats = useCallback(() => {
void navigate({ to: "/elemental-showdown/stats" });
}, [navigate]);

// The reveal's own clock calls this, and so does a Voter who has read enough.
const advance = useCallback(() => {
// The Stats this Vote earned take the screen rather than the next Matchup.
if (reveal?.unlockedElements) {
if (prefersReducedMotion()) openStats();
else setUnlocking(reveal.unlockedElements);
return;
}
const drawn = drawing.current;
if (!drawn) return;
drawing.current = null;
Expand All @@ -73,7 +94,10 @@ export function ElementalShowdown({
// The reveal stays up rather than an empty screen taking its place.
console.error(new Error("drawing the next Matchup failed", { cause }));
});
}, []);
}, [reveal, openStats]);

if (unlocking)
return <UnlockMoment elements={unlocking} onDone={openStats} />;

return (
<main
Expand Down
107 changes: 107 additions & 0 deletions src/projects/elemental-showdown/UnlockMoment.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { useEffect, useState } from "react";
import { INK, NO_ELEMENT, PAPER } from "./element-colour";
import type { StatsElement } from "./showdown-stats";

// The whole moment: the wave crossing the mosaic, the stamp landing under it,
// and a beat to read it before the Stats take the screen.
export const UNLOCK_MS = 2600;

// How long one tile takes to flip, and how far behind the tile before it on
// the wave it starts.
const TILE_FLIP_MS = 350;
const WAVE_STEP_MS = 28;

// The stamp lands while the wave is still crossing.
const STAMP_DELAY_MS = 700;
const STAMP_MS = 450;

// Two short buzzes and a long one.
const UNLOCK_BUZZ = [30, 60, 120];

// The stamp stays one line whatever it lands on: Bowlby One sets these two
// words about nine times the font size wide, and the tilt and the outline add
// to that, so on a phone the size comes from the screen and 44px is the
// ceiling it is drawn at.
const STAMP_FONT_SIZE = "min(44px, 8vw)";

// The mosaic is this many tiles wide, so a tile's place gives its row and its
// column. The wave runs down the diagonal, a row counting for two columns so
// that it leans rather than falling at 45掳.
const MOSAIC_COLUMNS = 13;
const waveStepsTo = (place: number) =>
(place % MOSAIC_COLUMNS) + Math.floor(place / MOSAIC_COLUMNS) * 2;

// The Stats opening on the screen the Vote that opened them was cast on: the
// locked screen's blank mosaic, a tile per Active Element, flips to its own
// Element in a diagonal wave, and the stamp lands under it.
export function UnlockMoment({
elements,
onDone,
}: {
elements: StatsElement[];
onDone: () => void;
}) {
const [hasFlipped, setFlipped] = useState(false);

// A frame on the blank mosaic, so the wave is seen crossing it rather than
// having already crossed.
useEffect(() => {
const flipping = requestAnimationFrame(() => setFlipped(true));
return () => cancelAnimationFrame(flipping);
}, []);

useEffect(() => {
navigator.vibrate?.(UNLOCK_BUZZ);
const opening = setTimeout(onDone, UNLOCK_MS);
return () => clearTimeout(opening);
}, [onDone]);

return (
<main
className="grid h-dvh content-center gap-16 overflow-hidden px-4 font-showdown"
style={{ background: INK, color: PAPER }}
>
<div aria-hidden className="grid grid-cols-13 gap-[3px]">
{elements.map((element, place) => {
const flipDelay = `${waveStepsTo(place) * WAVE_STEP_MS}ms`;
return (
<span
key={element.id}
className="grid aspect-square place-items-center rounded-md text-[15px] leading-none transition-[background-color,scale] ease-spring"
style={{
transitionDuration: `${TILE_FLIP_MS}ms`,
transitionDelay: flipDelay,
background: hasFlipped ? element.colour : NO_ELEMENT,
scale: hasFlipped ? "1.08" : "1",
}}
>
<span
className="transition-opacity"
style={{
transitionDuration: `${TILE_FLIP_MS}ms`,
transitionDelay: flipDelay,
opacity: hasFlipped ? 1 : 0,
}}
>
{element.emoji}
</span>
</span>
);
})}
</div>
<p
className="mx-auto w-fit whitespace-nowrap rounded-2xl px-[22px] py-[10px] font-showdown-display"
style={{
fontSize: STAMP_FONT_SIZE,
rotate: "-7deg",
background: PAPER,
color: INK,
boxShadow: `0 0 0 5px ${INK}, 0 0 0 9px ${PAPER}`,
animation: `stats-unlocked-stamp ${STAMP_MS}ms ${STAMP_DELAY_MS}ms var(--ease-spring) both`,
}}
>
stats unlocked
</p>
</main>
);
}
Loading
Loading