From c72ee930bf35523d684abb6cf62c56b893612ccc Mon Sep 17 00:00:00 2001
From: tresabhi <43380238+tresabhi@users.noreply.github.com>
Date: Thu, 12 Nov 2026 15:14:01 -0600
Subject: [PATCH] migrate guesser
---
packages/i18n/strings/en.json | 3 +-
.../components/AlertDialog/index.module.css | 48 ++-
.../src/components/AlertDialog/index.tsx | 8 +-
.../website/src/components/BaseStyles.astro | 3 +
.../src/components/Button/index.module.css | 6 +-
.../components/DropdownMenu/index.module.css | 2 +-
.../GuessBackground/index.module.css | 54 ---
.../src/components/GuessBackground/index.tsx | 31 --
.../website/src/components/GuessRenderer.tsx | 37 --
.../components/GuessRenderer/index.module.css | 0
.../src/components/GuessRenderer/index.tsx | 36 ++
.../src/components/GuessRendererLoader.tsx | 14 -
.../GuessRendererLoader/index.module.css | 7 +
.../components/GuessRendererLoader/index.tsx | 10 +
packages/website/src/components/Guesser.tsx | 358 -----------------
.../src/components/Guesser/index.module.css | 89 +++++
.../website/src/components/Guesser/index.tsx | 362 ++++++++++++++++++
.../components/IconButton/index.module.css | 2 +-
packages/website/src/components/Lighting.tsx | 12 +-
.../src/components/SceneProps/index.tsx | 8 +-
.../website/src/components/SmartCanvas.tsx | 20 -
.../components/SmartCanvas/index.module.css | 4 +
.../src/components/SmartCanvas/index.tsx | 21 +
.../src/components/Spinner/index.module.css | 18 +
.../website/src/components/Spinner/index.tsx | 4 +-
packages/website/src/components/TankModel.tsx | 54 ++-
.../components/TankopediaSandbox/index.tsx | 16 +-
.../src/components/TextField/index.module.css | 2 +
packages/website/src/hooks/useSilhouette.ts | 117 ++++++
.../[...locale]/guesser/_index.module.css | 4 +
.../[...locale]/guesser/_index.tsx | 81 +---
.../[...locale]/guesser/index.astro | 3 +
packages/website/src/stores/guess.ts | 3 +
33 files changed, 802 insertions(+), 635 deletions(-)
delete mode 100644 packages/website/src/components/GuessBackground/index.module.css
delete mode 100644 packages/website/src/components/GuessBackground/index.tsx
delete mode 100644 packages/website/src/components/GuessRenderer.tsx
create mode 100644 packages/website/src/components/GuessRenderer/index.module.css
create mode 100644 packages/website/src/components/GuessRenderer/index.tsx
delete mode 100644 packages/website/src/components/GuessRendererLoader.tsx
create mode 100644 packages/website/src/components/GuessRendererLoader/index.module.css
create mode 100644 packages/website/src/components/GuessRendererLoader/index.tsx
delete mode 100644 packages/website/src/components/Guesser.tsx
create mode 100644 packages/website/src/components/Guesser/index.module.css
create mode 100644 packages/website/src/components/Guesser/index.tsx
delete mode 100644 packages/website/src/components/SmartCanvas.tsx
create mode 100644 packages/website/src/components/SmartCanvas/index.module.css
create mode 100644 packages/website/src/components/SmartCanvas/index.tsx
create mode 100644 packages/website/src/hooks/useSilhouette.ts
create mode 100644 packages/website/src/pages/[...chunk_default]/[...locale]/guesser/_index.module.css
diff --git a/packages/i18n/strings/en.json b/packages/i18n/strings/en.json
index 21eb34910..8d26e139c 100644
--- a/packages/i18n/strings/en.json
+++ b/packages/i18n/strings/en.json
@@ -2,7 +2,7 @@
"guesser": {
"help": {
"title": "Partially reveal tank?",
- "description": "Reveals the tank without showing the name. This round will be considered incorrect and break your streak!",
+ "description": "Reveals the tank without showing the name. This round will be considered incorrect!",
"cancel": "Cancel",
"reveal": "Reveal"
},
@@ -14,6 +14,7 @@
},
"stats": "{correct} / {total} correct, {streak} streak",
+ "stats_untracked": "The score is not tracked when not all tiers are selected",
"tier": "Tier {tier}",
"select_all": "Select all",
"clear": "Clear"
diff --git a/packages/website/src/components/AlertDialog/index.module.css b/packages/website/src/components/AlertDialog/index.module.css
index 9af9f452c..00309474a 100644
--- a/packages/website/src/components/AlertDialog/index.module.css
+++ b/packages/website/src/components/AlertDialog/index.module.css
@@ -1,2 +1,48 @@
-.Trigger {
+.Overlay {
+ position: fixed;
+ inset: 0;
+ z-index: 1;
+
+ background-color: var(--black-a9);
+}
+
+.Content {
+ position: fixed;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ z-index: 2;
+
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-3);
+
+ box-sizing: border-box;
+ width: calc(100dvw - 2 * var(--space-4));
+ max-width: var(--alert-max-width);
+ padding: var(--space-4);
+
+ background-color: var(--gray-2);
+ box-shadow: var(--shadow-subtle);
+ border-radius: var(--space-4);
+ outline: var(--outline-interactive) !important;
+}
+
+.Title {
+ margin: 0;
+ font-size: var(--font-size-heading-4);
+ color: var(--gray-12);
+}
+
+.Description {
+ margin: 0;
+ color: var(--gray-11);
+}
+
+.actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: var(--space-3);
+
+ margin-top: var(--space-3);
}
diff --git a/packages/website/src/components/AlertDialog/index.tsx b/packages/website/src/components/AlertDialog/index.tsx
index c06176a16..0845f9395 100644
--- a/packages/website/src/components/AlertDialog/index.tsx
+++ b/packages/website/src/components/AlertDialog/index.tsx
@@ -1,5 +1,11 @@
import { AlertDialog as _ } from "radix-ui";
+import type { ComponentProps } from "react";
+import { classNames } from "../../ui/classNames";
import { composeRadixComponents } from "../../ui/compose";
import styles from "./index.module.css";
-export const AlertDialog = composeRadixComponents(_, styles);
+function Actions({ className, ...props }: ComponentProps<"div">) {
+ return
;
+}
+
+export const AlertDialog = { ...composeRadixComponents(_, styles), Actions };
diff --git a/packages/website/src/components/BaseStyles.astro b/packages/website/src/components/BaseStyles.astro
index 2cfaf75e1..00b37a559 100644
--- a/packages/website/src/components/BaseStyles.astro
+++ b/packages/website/src/components/BaseStyles.astro
@@ -91,8 +91,11 @@ import "@fontsource-variable/roboto-flex";
--duration-brief: 200ms;
--duration-subtle: 2s;
+ --duration-spinner: 1s;
--tank-card-min-width: 8rem;
+ --drop-down-min-width: 16rem;
+ --alert-max-width: 28rem;
}
diff --git a/packages/website/src/components/Button/index.module.css b/packages/website/src/components/Button/index.module.css
index 3dbfe4819..e9969a087 100644
--- a/packages/website/src/components/Button/index.module.css
+++ b/packages/website/src/components/Button/index.module.css
@@ -10,7 +10,7 @@
border: none;
font-family: var(--font-family-regular);
- font-size: 0.8em;
+ font-size: var(--font-size-regular);
color: var(--gray-1);
font-weight: var(--weight-medium);
@@ -21,7 +21,7 @@
}
&[data-size="minor"] {
- font-size: 0.7em;
+ font-size: var(--font-size-minor);
padding: var(--space-2) var(--space-3);
&[data-even-padding] {
@@ -30,7 +30,7 @@
}
&[data-size="major"] {
- font-size: 1.25em;
+ font-size: var(--font-size-major);
}
&[data-list-child="horizontal"]:not(:first-child),
diff --git a/packages/website/src/components/DropdownMenu/index.module.css b/packages/website/src/components/DropdownMenu/index.module.css
index 562fd585e..9e7821b7e 100644
--- a/packages/website/src/components/DropdownMenu/index.module.css
+++ b/packages/website/src/components/DropdownMenu/index.module.css
@@ -2,7 +2,7 @@
.SubContent {
--gutter: var(--space-5);
- min-width: min(calc(100dvw - 2 * var(--space-4)), 16rem);
+ min-width: min(calc(100dvw - 2 * var(--space-4)), var(--drop-down-width));
max-height: calc(
var(--radix-dropdown-menu-content-available-height) - var(--space-6)
);
diff --git a/packages/website/src/components/GuessBackground/index.module.css b/packages/website/src/components/GuessBackground/index.module.css
deleted file mode 100644
index deb5d3d75..000000000
--- a/packages/website/src/components/GuessBackground/index.module.css
+++ /dev/null
@@ -1,54 +0,0 @@
-.foreground {
- position: absolute;
- width: 100%;
- height: 100%;
- top: 0;
- left: 0;
-
- transition-duration: var(--duration-subtle);
- background-color: var(--accent-2);
-
- &[data-guess-state="correct"] {
- background-color: var(--jade-3);
- }
-
- &[data-guess-state="incorrect"] {
- background-color: var(--tomato-3);
- }
-
- .overlay {
- width: 100%;
- height: 100%;
- background: radial-gradient(circle, var(--gray-a9), var(--gray-3));
- }
-}
-
-.background {
- animation: moveBackground 180s linear infinite;
-
- position: absolute;
- width: 200%;
- height: 200%;
- top: 0;
- left: 0;
-
- background-image:
- radial-gradient(circle, var(--white-a1) 20%, transparent 20%),
- radial-gradient(circle at 0 0, var(--white-a1) 10%, transparent 10%),
- radial-gradient(circle at 100% 0, var(--white-a1) 10%, transparent 10%),
- radial-gradient(circle at 0 100%, var(--white-a1) 10%, transparent 10%),
- radial-gradient(circle at 100% 100%, var(--white-a1) 10%, transparent 10%);
-
- background-size: 4rem 4rem;
-}
-
-@keyframes moveBackground {
- from {
- top: 0;
- left: 0;
- }
- to {
- top: -100%;
- left: -100%;
- }
-}
diff --git a/packages/website/src/components/GuessBackground/index.tsx b/packages/website/src/components/GuessBackground/index.tsx
deleted file mode 100644
index 039dfb406..000000000
--- a/packages/website/src/components/GuessBackground/index.tsx
+++ /dev/null
@@ -1,31 +0,0 @@
-import { useEffect } from "react";
-import { api } from "../../api/dynamic";
-import { Guess } from "../../stores/guess";
-import { Tankopedia } from "../../stores/tankopedia";
-import { createTankState } from "../../tankopedia/createTankState";
-import styles from "./index.module.css";
-
-const [models, tanks] = await Promise.all([api.models(), api.tanks()]);
-
-export function GuessBackground() {
- const guessState = Guess.use((state) => state.guessState);
- const id = Guess.use((state) => state.tank);
-
- useEffect(() => {
- const tank = tanks.tanks[id];
-
- Tankopedia.mutate((state) => {
- state.protagonist = createTankState(tank);
- });
- }, [id]);
-
- return (
- <>
-
-
-
- >
- );
-}
diff --git a/packages/website/src/components/GuessRenderer.tsx b/packages/website/src/components/GuessRenderer.tsx
deleted file mode 100644
index 6bbbe3310..000000000
--- a/packages/website/src/components/GuessRenderer.tsx
+++ /dev/null
@@ -1,37 +0,0 @@
-import { useModel } from "../hooks/useModel";
-import { Guess, GuessState } from "../stores/guess";
-import { CanvasControl } from "./CanvasControl";
-import { Lighting } from "./Lighting";
-import { SceneProps } from "./SceneProps";
-import { SmartCanvas } from "./SmartCanvas";
-import { TankModel } from "./TankModel";
-
-const BRIGHTNESS = 2 ** -5.5;
-const CONTRAST = 2 ** 2;
-
-export function GuessRenderer() {
- const guessState = Guess.use((state) => state.guessState);
- const helpingReveal = Guess.use((state) => state.helpingReveal);
- const isRevealed = helpingReveal || guessState !== GuessState.NotGuessed;
-
- const id = Guess.use((state) => state.tank);
- const { hasPbr } = useModel(id);
-
- return (
-
-
-
-
-
-
- );
-}
diff --git a/packages/website/src/components/GuessRenderer/index.module.css b/packages/website/src/components/GuessRenderer/index.module.css
new file mode 100644
index 000000000..e69de29bb
diff --git a/packages/website/src/components/GuessRenderer/index.tsx b/packages/website/src/components/GuessRenderer/index.tsx
new file mode 100644
index 000000000..c1f72ac7d
--- /dev/null
+++ b/packages/website/src/components/GuessRenderer/index.tsx
@@ -0,0 +1,36 @@
+import { jadeDark, tomatoDark } from "@radix-ui/colors";
+import { useModel } from "../../hooks/useModel";
+import { Guess, GuessState } from "../../stores/guess";
+import { CanvasControl } from "../CanvasControl";
+import { Lighting } from "../Lighting";
+import { SceneProps } from "../SceneProps";
+import { SmartCanvas } from "../SmartCanvas";
+import { TankModel } from "../TankModel";
+
+export function GuessRenderer() {
+ const guessState = Guess.use((state) => state.guessState);
+ const helpingReveal = Guess.use((state) => state.helpingReveal);
+ const advancing = Guess.use((state) => state.advancing);
+ const isRevealed =
+ (helpingReveal || guessState !== GuessState.NotGuessed) && !advancing;
+
+ const id = Guess.use((state) => state.tank);
+
+ const groundColor =
+ guessState === GuessState.Correct
+ ? jadeDark.jade6
+ : guessState === GuessState.Incorrect
+ ? tomatoDark.tomato6
+ : undefined;
+
+ const { hasPbr } = useModel(id);
+
+ return (
+
+
+
+
+
+
+ );
+}
diff --git a/packages/website/src/components/GuessRendererLoader.tsx b/packages/website/src/components/GuessRendererLoader.tsx
deleted file mode 100644
index da9479e91..000000000
--- a/packages/website/src/components/GuessRendererLoader.tsx
+++ /dev/null
@@ -1,14 +0,0 @@
-import { Box, Spinner } from '@radix-ui/themes';
-
-export function GuessRendererLoader() {
- return (
-
-
-
- );
-}
diff --git a/packages/website/src/components/GuessRendererLoader/index.module.css b/packages/website/src/components/GuessRendererLoader/index.module.css
new file mode 100644
index 000000000..2fe3d1cd2
--- /dev/null
+++ b/packages/website/src/components/GuessRendererLoader/index.module.css
@@ -0,0 +1,7 @@
+.loader {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ font-size: 2rem;
+}
diff --git a/packages/website/src/components/GuessRendererLoader/index.tsx b/packages/website/src/components/GuessRendererLoader/index.tsx
new file mode 100644
index 000000000..cc9ffd80c
--- /dev/null
+++ b/packages/website/src/components/GuessRendererLoader/index.tsx
@@ -0,0 +1,10 @@
+import { Spinner } from "../Spinner";
+import styles from "./index.module.css";
+
+export function GuessRendererLoader() {
+ return (
+
+
+
+ );
+}
diff --git a/packages/website/src/components/Guesser.tsx b/packages/website/src/components/Guesser.tsx
deleted file mode 100644
index f2c2237a9..000000000
--- a/packages/website/src/components/Guesser.tsx
+++ /dev/null
@@ -1,358 +0,0 @@
-import { alias, romanize, SEARCH_KEYS } from "@blitzkit/core";
-import { literals } from "@blitzkit/i18n";
-import { TankDefinition } from "@blitzkit/protos";
-import {
- ArrowRightIcon,
- EyeOpenIcon,
- MagnifyingGlassIcon,
- MixerVerticalIcon,
- PaperPlaneIcon,
- StarFilledIcon,
- TrashIcon,
-} from "@radix-ui/react-icons";
-import fuzzysort from "fuzzysort";
-import { debounce } from "lodash-es";
-import { useCallback, useEffect, useRef, useState } from "react";
-import { api } from "../api/dynamic";
-import { awaitableTankNames } from "../core/awaitables/tankNames";
-import { awaitableTiers } from "../core/awaitables/tiers";
-import { useStrings } from "../hooks/useStrings";
-import { useUnwrapper } from "../hooks/useUnwrapper";
-import { Guess, GuessState } from "../stores/guess";
-import { AlertDialog } from "./AlertDialog";
-import { Button } from "./Button";
-import { ClassIcon } from "./ClassIcon";
-import { DropdownMenu } from "./DropdownMenu";
-import { IconButton } from "./IconButton";
-import { SearchResults } from "./SearchResults";
-import { Spinner } from "./Spinner";
-import { Text } from "./Text";
-import { TextField } from "./TextField";
-
-const { go } = fuzzysort;
-
-const [tankNames, tankDefinitions, TIERS] = await Promise.all([
- awaitableTankNames,
- api.tanks(),
- awaitableTiers,
-]);
-
-const ids = Object.keys(tankDefinitions.tanks);
-
-export function Guesser() {
- const strings = useStrings();
- const unwrap = useUnwrapper();
-
- const tank = Guess.use((state) => state.tank);
- const guessState = Guess.use((state) => state.guessState);
- const correctGuesses = Guess.use((state) => state.correctGuesses);
- const totalGuesses = Guess.use((state) => state.totalGuesses);
- const helpingReveal = Guess.use((state) => state.helpingReveal);
- const streak = Guess.use((state) => state.streak);
- const input = useRef(null);
- const [searching, setSearching] = useState(false);
- const [results, setResults] = useState(null);
- const [selected, setSelected] = useState(null);
- const tiers = Guess.use((state) => state.tiers);
- const status = useRef(null);
-
- const requestSearch = useCallback(() => {
- setSelected(null);
- setSearching(true);
- search();
- }, []);
-
- const search = useCallback(
- debounce(() => {
- if (!input.current) return;
-
- setSearching(false);
- const trimmed = input.current.value.trim();
-
- if (trimmed.length === 0) {
- setResults(null);
- return;
- }
-
- const searchResults = go(trimmed, tankNames, {
- keys: SEARCH_KEYS,
- limit: 6,
- });
-
- setResults(
- searchResults.map((result) => tankDefinitions.tanks[result.obj.id]),
- );
- }, 500),
- [],
- );
-
- useEffect(() => {
- if (guessState !== GuessState.NotGuessed) setSelected(null);
- }, [guessState]);
-
- useEffect(() => {
- const interval = setInterval(() => {
- if (!status.current) return;
-
- status.current.innerText = literals(strings.guesser.stats, {
- correct: correctGuesses,
- total: totalGuesses,
- streak: streak,
- });
- }, 1000 / 200);
-
- return () => clearInterval(interval);
- }, [correctGuesses, totalGuesses, streak]);
-
- return (
-
-
- {results !== null && (
-
-
- {results.length === 0 && (
-
- {strings.common.search.no_results}
-
- )}
-
-
- {results.map((result) => {
- return (
- {
- if (!input.current) return;
-
- input.current.value = unwrap(result.name!);
- setSelected(result);
- setResults(null);
- }}
- text={unwrap(result.name!)}
- prefix={
-
- }
- discriminator={
-
-
- {romanize(result.tier)}
-
- }
- />
- );
- })}
-
-
-
- )}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {TIERS.map((tier) => {
- const isSelected = tiers.includes(tier);
-
- return (
- {
- event.preventDefault();
-
- Guess.mutate((draft) => {
- if (isSelected) {
- if (draft.tiers.length === 1) return;
- draft.tiers = draft.tiers.filter((t) => t !== tier);
- } else {
- draft.tiers = [...draft.tiers, tier];
- }
-
- draft.streak = 0;
- });
- }}
- >
- {literals(strings.guesser.tier, {
- tier: romanize(tier),
- })}
-
- );
- })}
-
-
-
- {
- event.preventDefault();
-
- Guess.mutate((draft) => {
- draft.tiers = Guess.initial.tiers;
- });
- }}
- >
- {strings.guesser.select_all}
-
-
- {
- event.preventDefault();
-
- Guess.mutate((draft) => {
- draft.tiers = [];
- });
- }}
- >
- {strings.guesser.clear}
-
-
-
-
-
- {searching ? : }
-
-
-
-
-
-
-
-
-
-
- {strings.guesser.help.title}
-
- {strings.guesser.help.description}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
-}
diff --git a/packages/website/src/components/Guesser/index.module.css b/packages/website/src/components/Guesser/index.module.css
new file mode 100644
index 000000000..760f653f2
--- /dev/null
+++ b/packages/website/src/components/Guesser/index.module.css
@@ -0,0 +1,89 @@
+.guesser {
+ position: absolute;
+ bottom: 0;
+ left: 50%;
+ transform: translateX(-50%);
+
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: var(--space-4);
+
+ box-sizing: border-box;
+ width: 100%;
+ max-width: 25rem;
+ padding: var(--space-4);
+
+ .results {
+ position: relative;
+
+ .empty {
+ display: flex;
+ justify-content: center;
+ }
+
+ .list {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+
+ .item {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+
+ width: 100%;
+ padding: var(--space-2);
+
+ background: none;
+ border: none;
+ border-radius: var(--space-3);
+ cursor: pointer;
+ font: inherit;
+ color: var(--gray-12);
+ text-align: left;
+
+ &:hover,
+ &:focus-visible {
+ background-color: var(--gray-4);
+ outline: none;
+ }
+
+ .flag {
+ width: 1em;
+ height: 1em;
+ }
+
+ .name {
+ flex: 1;
+ }
+
+ .discriminator {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--space-2);
+
+ min-width: 38px;
+ padding: 0 var(--space-3);
+
+ border-radius: var(--max-size);
+ background-color: var(--gray-4);
+ font-size: var(--font-size-minor);
+ }
+ }
+ }
+ }
+
+ .controls {
+ display: flex;
+ gap: var(--space-3);
+
+ align-items: center;
+
+ .field {
+ flex: 1;
+ max-width: 14rem;
+ }
+ }
+}
diff --git a/packages/website/src/components/Guesser/index.tsx b/packages/website/src/components/Guesser/index.tsx
new file mode 100644
index 000000000..4daea5f81
--- /dev/null
+++ b/packages/website/src/components/Guesser/index.tsx
@@ -0,0 +1,362 @@
+import { alias, romanize, SEARCH_KEYS } from "@blitzkit/core";
+import { literals } from "@blitzkit/i18n";
+import { TankDefinition } from "@blitzkit/protos";
+import {
+ ArrowRightIcon,
+ CheckIcon,
+ EyeOpenIcon,
+ MagnifyingGlassIcon,
+ MixerVerticalIcon,
+ PaperPlaneIcon,
+ StarFilledIcon,
+ TrashIcon,
+} from "@radix-ui/react-icons";
+import fuzzysort from "fuzzysort";
+import type { QuicklimeEvent } from "quicklime";
+import { useCallback, useEffect, useRef, useState } from "react";
+import { api } from "../../api/dynamic";
+import { awaitableTankNames } from "../../core/awaitables/tankNames";
+import { awaitableTiers } from "../../core/awaitables/tiers";
+import { useStrings } from "../../hooks/useStrings";
+import { useUnwrapper } from "../../hooks/useUnwrapper";
+import { Guess, GuessState } from "../../stores/guess";
+import { AlertDialog } from "../AlertDialog";
+import { Button } from "../Button";
+import { Card } from "../Card";
+import { ClassIcon } from "../ClassIcon";
+import { DropdownMenu } from "../DropdownMenu";
+import { IconButton } from "../IconButton";
+import { TRANSITION_ANIMATION_TIME, transitionEvent } from "../Lighting";
+import { Text } from "../Text";
+import { TextField } from "../TextField";
+import styles from "./index.module.css";
+
+const { go } = fuzzysort;
+
+const [tankNames, tankDefinitions, TIERS] = await Promise.all([
+ awaitableTankNames,
+ api.tanks(),
+ awaitableTiers,
+]);
+
+// resolves once the lights have fully receded
+function waitForDarkness() {
+ return new Promise((resolve) => {
+ function done() {
+ clearTimeout(fallback);
+ transitionEvent.off(handleTransition);
+ resolve();
+ }
+
+ function handleTransition(event: QuicklimeEvent) {
+ if (event.data === 0) done();
+ }
+
+ // in case the renderer isn't animating (e.g. hidden tab)
+ const fallback = setTimeout(done, (TRANSITION_ANIMATION_TIME + 0.5) * 1000);
+
+ transitionEvent.on(handleTransition);
+ });
+}
+
+const ids = Object.keys(tankDefinitions.tanks);
+
+export function Guesser() {
+ const strings = useStrings();
+ const unwrap = useUnwrapper();
+
+ const tank = Guess.use((state) => state.tank);
+ const guessState = Guess.use((state) => state.guessState);
+ const correctGuesses = Guess.use((state) => state.correctGuesses);
+ const totalGuesses = Guess.use((state) => state.totalGuesses);
+ const helpingReveal = Guess.use((state) => state.helpingReveal);
+ const advancing = Guess.use((state) => state.advancing);
+ const streak = Guess.use((state) => state.streak);
+ const input = useRef(null);
+ const [results, setResults] = useState(null);
+ const [selected, setSelected] = useState(null);
+ const tiers = Guess.use((state) => state.tiers);
+ const status = useRef(null);
+ const allTiersSelected = tiers.length === TIERS.length;
+
+ const requestSearch = useCallback(() => {
+ if (!input.current) return;
+
+ setSelected(null);
+
+ const trimmed = input.current.value.trim();
+
+ if (trimmed.length === 0) {
+ setResults(null);
+ return;
+ }
+
+ const searchResults = go(trimmed, tankNames, {
+ keys: SEARCH_KEYS,
+ limit: 6,
+ });
+
+ setResults(
+ searchResults.map((result) => tankDefinitions.tanks[result.obj.id]),
+ );
+ }, []);
+
+ useEffect(() => {
+ if (guessState !== GuessState.NotGuessed) setSelected(null);
+ }, [guessState]);
+
+ useEffect(() => {
+ const interval = setInterval(() => {
+ if (!status.current) return;
+
+ status.current.innerText = allTiersSelected
+ ? literals(strings.guesser.stats, {
+ correct: correctGuesses,
+ total: totalGuesses,
+ streak: streak,
+ })
+ : strings.guesser.stats_untracked;
+ }, 1000 / 200);
+
+ return () => clearInterval(interval);
+ }, [correctGuesses, totalGuesses, streak, allTiersSelected]);
+
+ return (
+
+
+ {results !== null && (
+
+ {results.length === 0 && (
+
+ {strings.common.search.no_results}
+
+ )}
+
+
+ {results.map((result) => {
+ return (
+
+ );
+ })}
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {TIERS.map((tier) => {
+ const isSelected = tiers.includes(tier);
+
+ return (
+ {
+ event.preventDefault();
+
+ Guess.mutate((draft) => {
+ if (isSelected) {
+ if (draft.tiers.length === 1) return;
+ draft.tiers = draft.tiers.filter((t) => t !== tier);
+ } else {
+ draft.tiers = [...draft.tiers, tier];
+ }
+ });
+ }}
+ >
+
+
+
+
+ {literals(strings.guesser.tier, {
+ tier: romanize(tier),
+ })}
+
+ );
+ })}
+
+
+
+ {
+ event.preventDefault();
+
+ Guess.mutate((draft) => {
+ draft.tiers = Guess.initial.tiers;
+ });
+ }}
+ >
+ {strings.guesser.select_all}
+
+
+
+ {
+ event.preventDefault();
+
+ Guess.mutate((draft) => {
+ // at least one tier must stay selected
+ draft.tiers = [Math.max(...TIERS)];
+ });
+ }}
+ >
+ {strings.guesser.clear}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {strings.guesser.help.title}
+
+
+ {strings.guesser.help.description}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/packages/website/src/components/IconButton/index.module.css b/packages/website/src/components/IconButton/index.module.css
index 1e31935de..aa502299d 100644
--- a/packages/website/src/components/IconButton/index.module.css
+++ b/packages/website/src/components/IconButton/index.module.css
@@ -1,6 +1,6 @@
.icon-button {
&[data-size="regular"] {
- padding: var(--space-2);
+ padding: var(--space-3);
}
&[data-size="major"] {
diff --git a/packages/website/src/components/Lighting.tsx b/packages/website/src/components/Lighting.tsx
index d44bb12af..0f89a0da4 100644
--- a/packages/website/src/components/Lighting.tsx
+++ b/packages/website/src/components/Lighting.tsx
@@ -39,16 +39,17 @@ export const transitionEvent = new Quicklime(0);
interface Props {
hasPbr: boolean;
+ // keeps the lights off; toggling fades them out/in
+ hidden?: boolean;
}
-export function Lighting({ hasPbr }: Props) {
+export function Lighting({ hasPbr, hidden = false }: Props) {
const wrapper = useRef(null);
const highGraphics = TankopediaPersistent.use((state) => state.highGraphics);
const requestedDisplay = Tankopedia.use(
(state) => state.loaders.display.requested,
);
- const revealed = Tankopedia.use((state) => state.revealed);
const skinPending = Tankopedia.use(
(state) =>
state.loaders.skin.requested !== state.loaders.skin.loaded ||
@@ -57,7 +58,8 @@ export function Lighting({ hasPbr }: Props) {
const animationTime = useRef(REVEAL_ANIMATION_TIME);
const t0 = useRef(performance.now() / 1e3 - animationTime.current);
- const holding = useRef(false);
+ // starts held at the fully-faded point when hidden so there's no fade-in
+ const holding = useRef(hidden);
const isRevealing = useRef(true);
const isFirstReveal = useRef(true);
@@ -89,7 +91,7 @@ export function Lighting({ hasPbr }: Props) {
return;
}
- if (!revealed) {
+ if (hidden) {
animationTime.current = TRANSITION_ANIMATION_TIME;
t0.current = performance.now() / 1e3;
holding.current = true;
@@ -103,7 +105,7 @@ export function Lighting({ hasPbr }: Props) {
setAnimate(true);
invalidate();
- }, [revealed]);
+ }, [hidden]);
useEffect(() => {
if (!skinPending) return;
diff --git a/packages/website/src/components/SceneProps/index.tsx b/packages/website/src/components/SceneProps/index.tsx
index 49acf7566..e4be4006c 100644
--- a/packages/website/src/components/SceneProps/index.tsx
+++ b/packages/website/src/components/SceneProps/index.tsx
@@ -8,7 +8,11 @@ const SIZE = 2 ** 4;
export const screenshotReadyEvent = new Quicklime(false);
-export function SceneProps() {
+interface SceneProps {
+ color?: string;
+}
+
+export function SceneProps({ color = mauveDark.mauve6 }: SceneProps) {
const mesh = useRef(null!);
useEffect(() => {
@@ -79,7 +83,7 @@ export function SceneProps() {
>
diff --git a/packages/website/src/components/SmartCanvas.tsx b/packages/website/src/components/SmartCanvas.tsx
deleted file mode 100644
index 6dd23e46b..000000000
--- a/packages/website/src/components/SmartCanvas.tsx
+++ /dev/null
@@ -1,20 +0,0 @@
-import { Canvas, type CanvasProps } from "@react-three/fiber";
-import { forwardRef, useImperativeHandle, useRef } from "react";
-import { useOnScreen } from "../hooks/useOnScreen";
-
-export const SmartCanvas = forwardRef(
- (props, ref) => {
- const canvas = useRef(null!);
- const onScreen = useOnScreen(canvas);
-
- useImperativeHandle(ref, () => canvas.current!, []);
-
- return (
-
- );
- }
-);
diff --git a/packages/website/src/components/SmartCanvas/index.module.css b/packages/website/src/components/SmartCanvas/index.module.css
new file mode 100644
index 000000000..92c88e259
--- /dev/null
+++ b/packages/website/src/components/SmartCanvas/index.module.css
@@ -0,0 +1,4 @@
+.canvas {
+ position: absolute;
+ inset: 0;
+}
diff --git a/packages/website/src/components/SmartCanvas/index.tsx b/packages/website/src/components/SmartCanvas/index.tsx
new file mode 100644
index 000000000..1d1afe769
--- /dev/null
+++ b/packages/website/src/components/SmartCanvas/index.tsx
@@ -0,0 +1,21 @@
+import { Canvas, type CanvasProps } from "@react-three/fiber";
+import { useRef } from "react";
+import { useOnScreen } from "../../hooks/useOnScreen";
+import styles from "./index.module.css";
+
+export function SmartCanvas({ className, ...props }: CanvasProps) {
+ const canvas = useRef(null!);
+ const onScreen = useOnScreen(canvas);
+
+ return (
+
+ );
+}
diff --git a/packages/website/src/components/Spinner/index.module.css b/packages/website/src/components/Spinner/index.module.css
index e69de29bb..22d235eab 100644
--- a/packages/website/src/components/Spinner/index.module.css
+++ b/packages/website/src/components/Spinner/index.module.css
@@ -0,0 +1,18 @@
+.spinner {
+ display: inline-block;
+ box-sizing: border-box;
+ width: var(--space-5);
+ height: var(--space-5);
+
+ border: var(--space-2) solid var(--gray-5);
+ border-top-color: var(--gray-12);
+ border-radius: 50%;
+
+ animation: spin var(--duration-spinner) linear infinite;
+}
+
+@keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
diff --git a/packages/website/src/components/Spinner/index.tsx b/packages/website/src/components/Spinner/index.tsx
index 67ffe51d5..16ade5829 100644
--- a/packages/website/src/components/Spinner/index.tsx
+++ b/packages/website/src/components/Spinner/index.tsx
@@ -1,3 +1,5 @@
+import styles from "./index.module.css";
+
export function Spinner() {
- return null;
+ return ;
}
diff --git a/packages/website/src/components/TankModel.tsx b/packages/website/src/components/TankModel.tsx
index e1b3a8102..17c2dc3fc 100644
--- a/packages/website/src/components/TankModel.tsx
+++ b/packages/website/src/components/TankModel.tsx
@@ -1,6 +1,5 @@
import { invalidate, useThree, type ThreeEvent } from "@react-three/fiber";
-import type { QuicklimeEvent } from "quicklime";
-import { useEffect, useRef } from "react";
+import { useMemo, useRef } from "react";
import { Group, Mesh, MeshStandardMaterial, Vector2 } from "three";
import { applyPitchYawLimits } from "../core/blitz/applyPitchYawLimits";
import { modelTransformEvent } from "../core/blitzkit/modelTransform";
@@ -10,14 +9,19 @@ import { DuelModule, getDuel, useDuel } from "../hooks/useDuel";
import { useDuelModel } from "../hooks/useDuelModel";
import { hasEquipment } from "../hooks/useEquipment";
import { useModel } from "../hooks/useModel";
+import { useSilhouette } from "../hooks/useSilhouette";
import { useSkin } from "../hooks/useSkin";
import { useSkinAnimation } from "../hooks/useSkinAnimation";
import { useTankTransform } from "../hooks/useTankTransform";
import { Tankopedia } from "../stores/tankopedia";
import { TankopediaDisplay } from "../stores/tankopediaPersistent/constants";
-import { transitionEvent } from "./Lighting";
-export function TankModel() {
+interface TankModelProps {
+ // while the lights are down, draws the tank as a flat light shape
+ silhouette?: boolean;
+}
+
+export function TankModel({ silhouette = false }: TankModelProps) {
const protagonist = Tankopedia.use((state) => state.protagonist);
const track = useDuel("protagonist", DuelModule.Track);
const turret = useDuel("protagonist", DuelModule.Turret);
@@ -32,13 +36,16 @@ export function TankModel() {
const nodes = Object.values(gltf.nodes);
const skinGltf = useSkin(protagonist.tank);
const attachmentGltf = useSkin(protagonist.tank, "attachment");
- const skins = [skinGltf, attachmentGltf].filter((skin) => skin !== undefined);
+ const skins = useMemo(
+ () => [skinGltf, attachmentGltf].filter((skin) => skin !== undefined),
+ [skinGltf, attachmentGltf],
+ );
useTankTransform(track, turret, turretContainer, gunContainer);
useSkinAnimation(skins);
- useEffect(() => {
- const emissiveMaterials = new Set();
+ const materials = useMemo(() => {
+ const collected = new Set();
const roots = [
gltf.scene,
...skins.flatMap((skin) => [...skin.parts.values()].flat()),
@@ -46,34 +53,21 @@ export function TankModel() {
for (const root of roots) {
root.traverse((object) => {
- if (
- object instanceof Mesh &&
- object.material instanceof MeshStandardMaterial &&
- object.material.emissiveMap
- ) {
- emissiveMaterials.add(object.material);
+ if (!(object instanceof Mesh)) return;
+
+ for (const material of [object.material].flat()) {
+ if (material instanceof MeshStandardMaterial) {
+ collected.add(material);
+ }
}
});
}
- // emissive parts fade with the lights instead of glowing in the dark
- function applyIntensity(intensity: number) {
- for (const material of emissiveMaterials) {
- material.emissiveIntensity = intensity;
- }
- }
+ return [...collected];
+ }, [gltf, skins]);
- function handleTransitionEvent(event: QuicklimeEvent) {
- applyIntensity(event.data);
- }
-
- applyIntensity(transitionEvent.last!);
- transitionEvent.on(handleTransitionEvent);
-
- return () => {
- transitionEvent.off(handleTransitionEvent);
- };
- }, [gltf, skinGltf, attachmentGltf]);
+ // also fades emissive parts with the lights
+ useSilhouette(silhouette, materials);
return (
<>
diff --git a/packages/website/src/components/TankopediaSandbox/index.tsx b/packages/website/src/components/TankopediaSandbox/index.tsx
index 8913439af..23277633a 100644
--- a/packages/website/src/components/TankopediaSandbox/index.tsx
+++ b/packages/website/src/components/TankopediaSandbox/index.tsx
@@ -41,7 +41,6 @@ export function TankopediaSandbox({
antagonistCharacteristics,
}: TankSandboxProps) {
const wrapper = useRef(null);
- const canvas = useRef(null);
const hasImprovedVerticalStabilizer = useEquipment("protagonist", 122);
const hasDownImprovedVerticalStabilizer = useEquipment("protagonist", 124);
@@ -77,7 +76,7 @@ export function TankopediaSandbox({
gunModelDefinition.pitch!,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
- hasDownImprovedVerticalStabilizer
+ hasDownImprovedVerticalStabilizer,
);
modelTransformEvent.dispatch({ pitch, yaw });
@@ -91,7 +90,7 @@ export function TankopediaSandbox({
0,
gunModelDefinition.pitch!,
turretModelDefinition.yaw,
- hasImprovedVerticalStabilizer
+ hasImprovedVerticalStabilizer,
);
modelTransformEvent.dispatch({ pitch, yaw });
@@ -105,7 +104,7 @@ export function TankopediaSandbox({
0,
gunModelDefinition.pitch!,
turretModelDefinition.yaw,
- hasImprovedVerticalStabilizer
+ hasImprovedVerticalStabilizer,
);
modelTransformEvent.dispatch({ pitch, yaw });
@@ -133,7 +132,7 @@ export function TankopediaSandbox({
modelTransformEvent.last!.yaw,
gunModelDefinition.pitch!,
turretModelDefinition.yaw,
- hasImprovedVerticalStabilizer
+ hasImprovedVerticalStabilizer,
);
modelTransformEvent.dispatch({ pitch, yaw });
@@ -158,7 +157,6 @@ export function TankopediaSandbox({
state.revealed);
const hideTankModelUnderArmor = TankopediaPersistent.use(
- (state) => state.hideTankModelUnderArmor
+ (state) => state.hideTankModelUnderArmor,
);
return (
<>
-
+
diff --git a/packages/website/src/components/TextField/index.module.css b/packages/website/src/components/TextField/index.module.css
index 4185b6b70..e8abd8212 100644
--- a/packages/website/src/components/TextField/index.module.css
+++ b/packages/website/src/components/TextField/index.module.css
@@ -10,6 +10,8 @@
font: inherit;
color: var(--gray-11);
+ font-size: var(--font-size-regular);
+
&[data-variant="bubble"] {
border-radius: var(--max-size);
diff --git a/packages/website/src/hooks/useSilhouette.ts b/packages/website/src/hooks/useSilhouette.ts
new file mode 100644
index 000000000..8327bb42f
--- /dev/null
+++ b/packages/website/src/hooks/useSilhouette.ts
@@ -0,0 +1,117 @@
+import { mauveDark } from "@radix-ui/colors";
+import { invalidate, useFrame } from "@react-three/fiber";
+import type { QuicklimeEvent } from "quicklime";
+import { useEffect, useRef } from "react";
+import { Color, MeshStandardMaterial } from "three";
+import {
+ TRANSITION_ANIMATION_TIME,
+ transitionEvent,
+} from "../components/Lighting";
+
+const SILHOUETTE_COLOR = new Color(mauveDark.mauve9);
+// demand frameloop deltas span the idle time before a frame; cap them
+const MAX_DELTA = 1 / 30;
+
+// owns the materials' emissive: emissive parts fade with the lights, and
+// while the lights are down the tank is drawn as a flat light shape
+export function useSilhouette(
+ silhouette: boolean,
+ materials: MeshStandardMaterial[],
+) {
+ // how much of the silhouette is shown; fades in when the tank loads
+ const shown = useRef(0);
+ const render = useRef<() => void>(undefined);
+
+ useEffect(() => {
+ const entries = materials.map((material) => ({
+ material,
+ emissive: material.emissive.clone(),
+ emissiveMap: material.emissiveMap,
+ emissiveIntensity: material.emissiveIntensity,
+ }));
+ const glowing = entries.filter((entry) => entry.emissiveMap);
+ // how far the lights are up; the silhouette is what the lights hide
+ let light = transitionEvent.last!;
+ let active = false;
+
+ function swap(silhouetted: boolean) {
+ active = silhouetted;
+
+ for (const entry of entries) {
+ entry.material.emissive.copy(
+ silhouetted ? SILHOUETTE_COLOR : entry.emissive,
+ );
+
+ // only toggling the map changes the shader; color is a uniform
+ if (entry.emissiveMap) {
+ entry.material.emissiveMap = silhouetted ? null : entry.emissiveMap;
+ entry.material.needsUpdate = true;
+ }
+
+ if (!silhouetted) {
+ entry.material.emissiveIntensity = entry.emissiveIntensity;
+ }
+ }
+ }
+
+ function apply() {
+ const intensity = shown.current * (1 - light);
+
+ if (intensity > 0 !== active) swap(intensity > 0);
+
+ if (active) {
+ for (const entry of entries) {
+ entry.material.emissiveIntensity = intensity;
+ }
+ } else {
+ // the rest keep their original intensity, restored by swap
+ for (const entry of glowing) {
+ entry.material.emissiveIntensity = light;
+ }
+ }
+ }
+
+ function handleTransitionEvent(event: QuicklimeEvent) {
+ light = event.data;
+ apply();
+ }
+
+ shown.current = 0;
+ render.current = apply;
+ apply();
+ invalidate();
+ transitionEvent.on(handleTransitionEvent);
+
+ return () => {
+ render.current = undefined;
+ transitionEvent.off(handleTransitionEvent);
+
+ if (active) swap(false);
+
+ for (const entry of glowing) {
+ entry.material.emissiveIntensity = entry.emissiveIntensity;
+ }
+ };
+ }, [materials]);
+
+ useEffect(() => {
+ invalidate();
+ }, [silhouette]);
+
+ useFrame((state, delta) => {
+ const target = silhouette ? 1 : 0;
+
+ if (shown.current === target) return;
+
+ const step = Math.min(delta, MAX_DELTA) / TRANSITION_ANIMATION_TIME;
+
+ shown.current =
+ target > shown.current
+ ? Math.min(target, shown.current + step)
+ : Math.max(target, shown.current - step);
+
+ render.current?.();
+
+ if (shown.current !== target) state.invalidate();
+ });
+}
diff --git a/packages/website/src/pages/[...chunk_default]/[...locale]/guesser/_index.module.css b/packages/website/src/pages/[...chunk_default]/[...locale]/guesser/_index.module.css
new file mode 100644
index 000000000..657bd6875
--- /dev/null
+++ b/packages/website/src/pages/[...chunk_default]/[...locale]/guesser/_index.module.css
@@ -0,0 +1,4 @@
+.content {
+ flex: 1;
+ background-color: black;
+}
diff --git a/packages/website/src/pages/[...chunk_default]/[...locale]/guesser/_index.tsx b/packages/website/src/pages/[...chunk_default]/[...locale]/guesser/_index.tsx
index 5d8ebc4ab..35462e295 100644
--- a/packages/website/src/pages/[...chunk_default]/[...locale]/guesser/_index.tsx
+++ b/packages/website/src/pages/[...chunk_default]/[...locale]/guesser/_index.tsx
@@ -1,22 +1,18 @@
-import { Box, Flex, Heading } from "@radix-ui/themes";
import { Suspense, useEffect } from "react";
import { api } from "../../../../api/dynamic";
-import { GuessBackground } from "../../../../components/GuessBackground";
import { Guesser } from "../../../../components/Guesser";
import { GuessRenderer } from "../../../../components/GuessRenderer";
import { GuessRendererLoader } from "../../../../components/GuessRendererLoader";
import { withErrorWrapper } from "../../../../hocs/withErrorWrapper";
import { withLocale } from "../../../../hocs/withLocale";
import { DuelModule, useDuel } from "../../../../hooks/useDuel";
-import { useUnwrapper } from "../../../../hooks/useUnwrapper";
import { Guess, GuessState } from "../../../../stores/guess";
import { Tankopedia } from "../../../../stores/tankopedia";
+import { createTankState } from "../../../../tankopedia/createTankState";
import type { MaybeSkeletonComponentProps } from "../../../../types/maybeSkeletonComponentProps";
+import styles from "./_index.module.css";
-const [tankDefinitions, modelDefinitions] = await Promise.all([
- api.tanks(),
- api.models(),
-]);
+const [tankDefinitions] = await Promise.all([api.tanks()]);
const ids = Object.keys(tankDefinitions.tanks);
@@ -36,20 +32,18 @@ function Container({ skeleton }: MaybeSkeletonComponentProps) {
Tankopedia.useInitialization(tank);
+ useEffect(() => {
+ Tankopedia.mutate((draft) => {
+ draft.protagonist = createTankState(tank);
+ });
+ }, [id]);
+
return ;
}
function Content({ skeleton }: MaybeSkeletonComponentProps) {
const tank = useDuel("protagonist", DuelModule.Tank);
- const unwrap = useUnwrapper();
-
- const name = unwrap(tank.name!);
-
- const guessState = Guess.use((state) => state.guessState);
- const isRevealed = guessState !== GuessState.NotGuessed;
- const fontSize = `min(48vh, ${55 / name.length}vw)`;
- const transitionDuration = isRevealed ? "2s" : undefined;
const tiers = Guess.use((state) => state.tiers);
useEffect(() => {
@@ -76,59 +70,12 @@ function Content({ skeleton }: MaybeSkeletonComponentProps) {
}, [tiers]);
return (
-
-
-
-
- {name}
-
-
-
- }>
- {!skeleton && }
-
-
-
-
- {name}
-
+
+ }>
+ {!skeleton && }
+
-
+
);
}
diff --git a/packages/website/src/pages/[...chunk_default]/[...locale]/guesser/index.astro b/packages/website/src/pages/[...chunk_default]/[...locale]/guesser/index.astro
index fdfe8a50a..a9c514a49 100644
--- a/packages/website/src/pages/[...chunk_default]/[...locale]/guesser/index.astro
+++ b/packages/website/src/pages/[...chunk_default]/[...locale]/guesser/index.astro
@@ -1,4 +1,5 @@
---
+import ColorScale from "../../../../components/ColorScale.astro";
import BaseLayout from "../../../../layouts/BaseLayout.astro";
import { Page } from "./_index";
@@ -8,6 +9,8 @@ const { locale } = Astro.props;
---
+
+
diff --git a/packages/website/src/stores/guess.ts b/packages/website/src/stores/guess.ts
index 45a5c9d8e..8a8668113 100644
--- a/packages/website/src/stores/guess.ts
+++ b/packages/website/src/stores/guess.ts
@@ -16,6 +16,8 @@ export interface Guess {
correctGuesses: number;
streak: number;
helpingReveal: boolean;
+ // lights are receding before the next tank loads
+ advancing: boolean;
tiers: number[];
}
@@ -26,5 +28,6 @@ export const Guess = new Soapstone((tank) => ({
correctGuesses: 0,
streak: 0,
helpingReveal: false,
+ advancing: false,
tiers,
}));