migrate guesser

This commit is contained in:
tresabhi committed 2026-11-12 15:14:01 -06:00
1 parent c9f922ef24
commit c72ee930bf
33 files changed
+802 -635

No files matched your search

+2 -1
View File
@@ -2,7 +2,7 @@
"guesser": { "guesser": {
"help": { "help": {
"title": "Partially reveal tank?", "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", "cancel": "Cancel",
"reveal": "Reveal" "reveal": "Reveal"
}, },
@@ -14,6 +14,7 @@
}, },
"stats": "{correct} / {total} correct, {streak} streak", "stats": "{correct} / {total} correct, {streak} streak",
"stats_untracked": "The score is not tracked when not all tiers are selected",
"tier": "Tier {tier}", "tier": "Tier {tier}",
"select_all": "Select all", "select_all": "Select all",
"clear": "Clear" "clear": "Clear"
@@ -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);
} }
@@ -1,5 +1,11 @@
import { AlertDialog as _ } from "radix-ui"; import { AlertDialog as _ } from "radix-ui";
import type { ComponentProps } from "react";
import { classNames } from "../../ui/classNames";
import { composeRadixComponents } from "../../ui/compose"; import { composeRadixComponents } from "../../ui/compose";
import styles from "./index.module.css"; import styles from "./index.module.css";
export const AlertDialog = composeRadixComponents(_, styles); function Actions({ className, ...props }: ComponentProps<"div">) {
return <div className={classNames(styles.actions, className)} {...props} />;
}
export const AlertDialog = { ...composeRadixComponents(_, styles), Actions };
@@ -91,8 +91,11 @@ import "@fontsource-variable/roboto-flex";
--duration-brief: 200ms; --duration-brief: 200ms;
--duration-subtle: 2s; --duration-subtle: 2s;
--duration-spinner: 1s;
--tank-card-min-width: 8rem; --tank-card-min-width: 8rem;
--drop-down-min-width: 16rem;
--alert-max-width: 28rem;
} }
</style> </style>
@@ -10,7 +10,7 @@
border: none; border: none;
font-family: var(--font-family-regular); font-family: var(--font-family-regular);
font-size: 0.8em; font-size: var(--font-size-regular);
color: var(--gray-1); color: var(--gray-1);
font-weight: var(--weight-medium); font-weight: var(--weight-medium);
@@ -21,7 +21,7 @@
} }
&[data-size="minor"] { &[data-size="minor"] {
font-size: 0.7em; font-size: var(--font-size-minor);
padding: var(--space-2) var(--space-3); padding: var(--space-2) var(--space-3);
&[data-even-padding] { &[data-even-padding] {
@@ -30,7 +30,7 @@
} }
&[data-size="major"] { &[data-size="major"] {
font-size: 1.25em; font-size: var(--font-size-major);
} }
&[data-list-child="horizontal"]:not(:first-child), &[data-list-child="horizontal"]:not(:first-child),
@@ -2,7 +2,7 @@
.SubContent { .SubContent {
--gutter: var(--space-5); --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( max-height: calc(
var(--radix-dropdown-menu-content-available-height) - var(--space-6) var(--radix-dropdown-menu-content-available-height) - var(--space-6)
); );
@@ -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%;
}
}
@@ -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 (
<>
<div className={styles.foreground} data-guess-state={guessState}>
<div className={styles.overlay} />
</div>
<div className={styles.background} />
</>
);
}
@@ -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 (
<SmartCanvas
style={{
filter: `grayscale(${isRevealed ? 0 : 1}) contrast(${
isRevealed ? 1 : CONTRAST
}) brightness(${isRevealed ? 1 : BRIGHTNESS})`,
transitionDuration: isRevealed ? "2s" : undefined,
transitionProperty: "filter",
}}
camera={{ position: [-5, 5, -5] }}
>
<SceneProps />
<CanvasControl zoomable={false} />
<Lighting hasPbr={hasPbr} />
<TankModel />
</SmartCanvas>
);
}
Whitespace-only changes.
@@ -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 (
<SmartCanvas camera={{ position: [-5, 5, -5] }}>
<SceneProps color={groundColor} />
<CanvasControl zoomable={false} />
<Lighting hasPbr={hasPbr} hidden={!isRevealed} />
<TankModel silhouette={!advancing} />
</SmartCanvas>
);
}
@@ -1,14 +0,0 @@
import { Box, Spinner } from '@radix-ui/themes';
export function GuessRendererLoader() {
return (
<Box
position="absolute"
top="50%"
left="50%"
style={{ transform: 'translate(-50%, -50%)' }}
>
<Spinner size="3" />
</Box>
);
}
@@ -0,0 +1,7 @@
.loader {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 2rem;
}
@@ -0,0 +1,10 @@
import { Spinner } from "../Spinner";
import styles from "./index.module.css";
export function GuessRendererLoader() {
return (
<div className={styles.loader}>
<Spinner />
</div>
);
}
-358
View File
@@ -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<HTMLInputElement>(null);
const [searching, setSearching] = useState(false);
const [results, setResults] = useState<TankDefinition[] | null>(null);
const [selected, setSelected] = useState<TankDefinition | null>(null);
const tiers = Guess.use((state) => state.tiers);
const status = useRef<HTMLSpanElement>(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 (
<div
direction="column"
position="absolute"
bottom="0"
left="50%"
style={{ transform: "translateX(-50%)" }}
width="100%"
p="4"
maxWidth="25rem"
gap="3"
>
<div position="relative">
{results !== null && (
<Card variant="classic">
<div py="2" px="3">
{results.length === 0 && (
<div justify="center">
<Text color="gray">{strings.common.search.no_results}</Text>
</div>
)}
<SearchResults.Root>
{results.map((result) => {
return (
<SearchResults.Item
key={result.id}
onClick={() => {
if (!input.current) return;
input.current.value = unwrap(result.name!);
setSelected(result);
setResults(null);
}}
text={unwrap(result.name!)}
prefix={
<img
style={{ width: "1em", height: "1em" }}
src={alias(
"api",
`/flags/circle/${result.nation}.webp`,
)}
/>
}
discriminator={
<div
align="center"
gap="1"
width="38px"
justify="center"
>
<ClassIcon
class={result.class}
width="1em"
height="1em"
/>
{romanize(result.tier)}
</div>
}
/>
);
})}
</SearchResults.Root>
</div>
</Card>
)}
</div>
<div justify="center" style={{ userSelect: "none" }}>
<Text ref={status} />
</div>
<div gap={{ initial: "2", sm: "3" }}>
<DropdownMenu.Root modal={false}>
<DropdownMenu.Trigger>
<IconButton
size={{ initial: "2", sm: "3" }}
variant="surface"
color="gray"
>
<MixerVerticalIcon />
</IconButton>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
{TIERS.map((tier) => {
const isSelected = tiers.includes(tier);
return (
<DropdownMenu.CheckboxItem
checked={isSelected}
key={tier}
onClick={(event) => {
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),
})}
</DropdownMenu.CheckboxItem>
);
})}
<DropdownMenu.Separator />
<DropdownMenu.Item
onClick={(event) => {
event.preventDefault();
Guess.mutate((draft) => {
draft.tiers = Guess.initial.tiers;
});
}}
>
<StarFilledIcon /> {strings.guesser.select_all}
</DropdownMenu.Item>
<DropdownMenu.Item
color="red"
onClick={(event) => {
event.preventDefault();
Guess.mutate((draft) => {
draft.tiers = [];
});
}}
>
<TrashIcon /> {strings.guesser.clear}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
<TextField
disabled={guessState !== GuessState.NotGuessed}
ref={input}
onChange={requestSearch}
style={{ flex: 1, maxWidth: "14rem" }}
placeholder={strings.common.search.hint_tanks}
size={{ initial: "2", sm: "3" }}
variant="classic"
>
{searching ? <Spinner /> : <MagnifyingGlassIcon />}
</TextField>
<AlertDialog.Root>
<AlertDialog.Trigger>
<IconButton
size={{ initial: "2", sm: "3" }}
disabled={guessState !== GuessState.NotGuessed}
>
<EyeOpenIcon />
</IconButton>
</AlertDialog.Trigger>
<AlertDialog.Content>
<AlertDialog.Title>{strings.guesser.help.title}</AlertDialog.Title>
<AlertDialog.Description>
{strings.guesser.help.description}
</AlertDialog.Description>
<div justify="end" gap="2">
<AlertDialog.Cancel>
<Button variant="outline">{strings.guesser.help.cancel}</Button>
</AlertDialog.Cancel>
<AlertDialog.Action>
<Button
color="tomato"
onClick={() => {
Guess.mutate((draft) => {
draft.helpingReveal = true;
draft.streak = 0;
});
}}
>
{strings.guesser.help.reveal}
</Button>
</AlertDialog.Action>
</div>
</AlertDialog.Content>
</AlertDialog.Root>
<Button
size={{ initial: "2", sm: "3" }}
color={
guessState === GuessState.NotGuessed && selected === null
? "red"
: undefined
}
onClick={() => {
if (guessState === GuessState.NotGuessed) {
const correct =
selected !== null && selected.id === tank && !helpingReveal;
Guess.mutate((draft) => {
draft.guessState = correct
? GuessState.Correct
: GuessState.Incorrect;
draft.totalGuesses++;
draft.correctGuesses +=
correct && draft.tiers.length === 10 ? 1 : 0;
draft.streak = correct ? draft.streak + 1 : 0;
});
} else {
const filteredIds = ids.filter((id) => {
const tank = tankDefinitions.tanks[Number(id)];
return tiers.includes(tank.tier);
});
const id = Number(
filteredIds[Math.floor(Math.random() * filteredIds.length)],
);
const tank = tankDefinitions.tanks[id];
Guess.mutate((draft) => {
if (!input.current) return;
draft.tank = tank.id;
draft.guessState = GuessState.NotGuessed;
draft.helpingReveal = false;
input.current.value = "";
});
}
}}
>
{guessState === GuessState.NotGuessed ? (
<>
{strings.guesser.search[selected === null ? "skip" : "guess"]}
{selected === null ? <ArrowRightIcon /> : <PaperPlaneIcon />}
</>
) : (
<>
{strings.guesser.search.next}
<ArrowRightIcon />
</>
)}
</Button>
</div>
</div>
);
}
@@ -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;
}
}
}
@@ -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<void>((resolve) => {
function done() {
clearTimeout(fallback);
transitionEvent.off(handleTransition);
resolve();
}
function handleTransition(event: QuicklimeEvent<number>) {
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<HTMLInputElement>(null);
const [results, setResults] = useState<TankDefinition[] | null>(null);
const [selected, setSelected] = useState<TankDefinition | null>(null);
const tiers = Guess.use((state) => state.tiers);
const status = useRef<HTMLSpanElement>(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 (
<div className={styles.guesser}>
<div className={styles.results}>
{results !== null && (
<Card>
{results.length === 0 && (
<div className={styles.empty}>
<Text color="gray">{strings.common.search.no_results}</Text>
</div>
)}
<div className={styles.list}>
{results.map((result) => {
return (
<button
key={result.id}
className={styles.item}
onClick={() => {
if (!input.current) return;
input.current.value = unwrap(result.name!);
setSelected(result);
setResults(null);
}}
>
<img
className={styles.flag}
src={alias("api", `/flags/circle/${result.nation}.webp`)}
/>
<Text className={styles.name}>{unwrap(result.name!)}</Text>
<span className={styles.discriminator}>
<ClassIcon
class={result.class}
width="1em"
height="1em"
/>
{romanize(result.tier)}
</span>
</button>
);
})}
</div>
</Card>
)}
</div>
<Text lowContrast ref={status} />
<div className={styles.controls}>
<DropdownMenu.Root modal={false}>
<DropdownMenu.Trigger asChild>
<IconButton variant="surface" color="gray">
<MixerVerticalIcon />
</IconButton>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="start">
{TIERS.map((tier) => {
const isSelected = tiers.includes(tier);
return (
<DropdownMenu.CheckboxItem
checked={isSelected}
key={tier}
onClick={(event) => {
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];
}
});
}}
>
<DropdownMenu.ItemIndicator>
<CheckIcon />
</DropdownMenu.ItemIndicator>
{literals(strings.guesser.tier, {
tier: romanize(tier),
})}
</DropdownMenu.CheckboxItem>
);
})}
<DropdownMenu.Separator />
<DropdownMenu.Item
onClick={(event) => {
event.preventDefault();
Guess.mutate((draft) => {
draft.tiers = Guess.initial.tiers;
});
}}
>
{strings.guesser.select_all}
<StarFilledIcon />
</DropdownMenu.Item>
<DropdownMenu.Item
onClick={(event) => {
event.preventDefault();
Guess.mutate((draft) => {
// at least one tier must stay selected
draft.tiers = [Math.max(...TIERS)];
});
}}
>
{strings.guesser.clear}
<TrashIcon />
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
<AlertDialog.Root>
<AlertDialog.Trigger asChild>
<IconButton
color="gray"
variant="surface"
disabled={guessState !== GuessState.NotGuessed}
>
<EyeOpenIcon />
</IconButton>
</AlertDialog.Trigger>
<AlertDialog.Portal>
<AlertDialog.Overlay />
<AlertDialog.Content>
<AlertDialog.Title>
{strings.guesser.help.title}
</AlertDialog.Title>
<AlertDialog.Description>
{strings.guesser.help.description}
</AlertDialog.Description>
<AlertDialog.Actions>
<AlertDialog.Cancel asChild>
<Button variant="outline" color="gray">
{strings.guesser.help.cancel}
</Button>
</AlertDialog.Cancel>
<AlertDialog.Action asChild>
<Button
color="red"
onClick={() => {
Guess.mutate((draft) => {
draft.helpingReveal = true;
});
}}
>
{strings.guesser.help.reveal}
</Button>
</AlertDialog.Action>
</AlertDialog.Actions>
</AlertDialog.Content>
</AlertDialog.Portal>
</AlertDialog.Root>
<TextField
disabled={guessState !== GuessState.NotGuessed}
ref={input}
onChange={requestSearch}
className={styles.field}
placeholder={strings.common.search.hint_tanks}
>
<MagnifyingGlassIcon />
</TextField>
<Button
color={
guessState === GuessState.NotGuessed && selected === null
? "red"
: undefined
}
onClick={() => {
if (guessState === GuessState.NotGuessed) {
const correct =
selected !== null && selected.id === tank && !helpingReveal;
Guess.mutate((draft) => {
draft.guessState = correct
? GuessState.Correct
: GuessState.Incorrect;
draft.totalGuesses++;
draft.correctGuesses +=
correct && draft.tiers.length === TIERS.length ? 1 : 0;
draft.streak = correct ? draft.streak + 1 : 0;
});
} else {
if (advancing) return;
const filteredIds = ids.filter((id) => {
const tank = tankDefinitions.tanks[Number(id)];
return tiers.includes(tank.tier);
});
const id = Number(
filteredIds[Math.floor(Math.random() * filteredIds.length)],
);
const tank = tankDefinitions.tanks[id];
Guess.mutate((draft) => {
draft.advancing = true;
});
waitForDarkness().then(() => {
Guess.mutate((draft) => {
draft.tank = tank.id;
draft.guessState = GuessState.NotGuessed;
draft.helpingReveal = false;
draft.advancing = false;
});
if (input.current) input.current.value = "";
});
}
}}
>
{guessState === GuessState.NotGuessed ? (
<>
{strings.guesser.search[selected === null ? "skip" : "guess"]}
{selected === null ? <ArrowRightIcon /> : <PaperPlaneIcon />}
</>
) : (
<>
{strings.guesser.search.next}
<ArrowRightIcon />
</>
)}
</Button>
</div>
</div>
);
}
@@ -1,6 +1,6 @@
.icon-button { .icon-button {
&[data-size="regular"] { &[data-size="regular"] {
padding: var(--space-2); padding: var(--space-3);
} }
&[data-size="major"] { &[data-size="major"] {
+7 -5
View File
@@ -39,16 +39,17 @@ export const transitionEvent = new Quicklime<number>(0);
interface Props { interface Props {
hasPbr: boolean; 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<Group>(null); const wrapper = useRef<Group>(null);
const highGraphics = TankopediaPersistent.use((state) => state.highGraphics); const highGraphics = TankopediaPersistent.use((state) => state.highGraphics);
const requestedDisplay = Tankopedia.use( const requestedDisplay = Tankopedia.use(
(state) => state.loaders.display.requested, (state) => state.loaders.display.requested,
); );
const revealed = Tankopedia.use((state) => state.revealed);
const skinPending = Tankopedia.use( const skinPending = Tankopedia.use(
(state) => (state) =>
state.loaders.skin.requested !== state.loaders.skin.loaded || state.loaders.skin.requested !== state.loaders.skin.loaded ||
@@ -57,7 +58,8 @@ export function Lighting({ hasPbr }: Props) {
const animationTime = useRef(REVEAL_ANIMATION_TIME); const animationTime = useRef(REVEAL_ANIMATION_TIME);
const t0 = useRef(performance.now() / 1e3 - animationTime.current); 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 isRevealing = useRef(true);
const isFirstReveal = useRef(true); const isFirstReveal = useRef(true);
@@ -89,7 +91,7 @@ export function Lighting({ hasPbr }: Props) {
return; return;
} }
if (!revealed) { if (hidden) {
animationTime.current = TRANSITION_ANIMATION_TIME; animationTime.current = TRANSITION_ANIMATION_TIME;
t0.current = performance.now() / 1e3; t0.current = performance.now() / 1e3;
holding.current = true; holding.current = true;
@@ -103,7 +105,7 @@ export function Lighting({ hasPbr }: Props) {
setAnimate(true); setAnimate(true);
invalidate(); invalidate();
}, [revealed]); }, [hidden]);
useEffect(() => { useEffect(() => {
if (!skinPending) return; if (!skinPending) return;
@@ -8,7 +8,11 @@ const SIZE = 2 ** 4;
export const screenshotReadyEvent = new Quicklime(false); export const screenshotReadyEvent = new Quicklime(false);
export function SceneProps() { interface SceneProps {
color?: string;
}
export function SceneProps({ color = mauveDark.mauve6 }: SceneProps) {
const mesh = useRef<Mesh>(null!); const mesh = useRef<Mesh>(null!);
useEffect(() => { useEffect(() => {
@@ -79,7 +83,7 @@ export function SceneProps() {
> >
<planeGeometry args={[SIZE, SIZE]} /> <planeGeometry args={[SIZE, SIZE]} />
<meshStandardMaterial <meshStandardMaterial
color={mauveDark.mauve6} color={color}
roughness={1} roughness={1}
onBeforeCompile={handleBeforeCompile} onBeforeCompile={handleBeforeCompile}
/> />
@@ -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<HTMLCanvasElement, CanvasProps>(
(props, ref) => {
const canvas = useRef<HTMLCanvasElement>(null!);
const onScreen = useOnScreen(canvas);
useImperativeHandle(ref, () => canvas.current!, []);
return (
<Canvas
frameloop={onScreen ? "demand" : "never"}
ref={canvas}
{...props}
/>
);
}
);
@@ -0,0 +1,4 @@
.canvas {
position: absolute;
inset: 0;
}
@@ -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<HTMLCanvasElement>(null!);
const onScreen = useOnScreen(canvas);
return (
<div className={className}>
<div className={styles.canvas}>
<Canvas
frameloop={onScreen ? "demand" : "never"}
ref={canvas}
{...props}
/>
</div>
</div>
);
}
@@ -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);
}
}
@@ -1,3 +1,5 @@
import styles from "./index.module.css";
export function Spinner() { export function Spinner() {
return null; return <span className={styles.spinner} />;
} }
+24 -30
View File
@@ -1,6 +1,5 @@
import { invalidate, useThree, type ThreeEvent } from "@react-three/fiber"; import { invalidate, useThree, type ThreeEvent } from "@react-three/fiber";
import type { QuicklimeEvent } from "quicklime"; import { useMemo, useRef } from "react";
import { useEffect, useRef } from "react";
import { Group, Mesh, MeshStandardMaterial, Vector2 } from "three"; import { Group, Mesh, MeshStandardMaterial, Vector2 } from "three";
import { applyPitchYawLimits } from "../core/blitz/applyPitchYawLimits"; import { applyPitchYawLimits } from "../core/blitz/applyPitchYawLimits";
import { modelTransformEvent } from "../core/blitzkit/modelTransform"; import { modelTransformEvent } from "../core/blitzkit/modelTransform";
@@ -10,14 +9,19 @@ import { DuelModule, getDuel, useDuel } from "../hooks/useDuel";
import { useDuelModel } from "../hooks/useDuelModel"; import { useDuelModel } from "../hooks/useDuelModel";
import { hasEquipment } from "../hooks/useEquipment"; import { hasEquipment } from "../hooks/useEquipment";
import { useModel } from "../hooks/useModel"; import { useModel } from "../hooks/useModel";
import { useSilhouette } from "../hooks/useSilhouette";
import { useSkin } from "../hooks/useSkin"; import { useSkin } from "../hooks/useSkin";
import { useSkinAnimation } from "../hooks/useSkinAnimation"; import { useSkinAnimation } from "../hooks/useSkinAnimation";
import { useTankTransform } from "../hooks/useTankTransform"; import { useTankTransform } from "../hooks/useTankTransform";
import { Tankopedia } from "../stores/tankopedia"; import { Tankopedia } from "../stores/tankopedia";
import { TankopediaDisplay } from "../stores/tankopediaPersistent/constants"; 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 protagonist = Tankopedia.use((state) => state.protagonist);
const track = useDuel("protagonist", DuelModule.Track); const track = useDuel("protagonist", DuelModule.Track);
const turret = useDuel("protagonist", DuelModule.Turret); const turret = useDuel("protagonist", DuelModule.Turret);
@@ -32,13 +36,16 @@ export function TankModel() {
const nodes = Object.values(gltf.nodes); const nodes = Object.values(gltf.nodes);
const skinGltf = useSkin(protagonist.tank); const skinGltf = useSkin(protagonist.tank);
const attachmentGltf = useSkin(protagonist.tank, "attachment"); 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); useTankTransform(track, turret, turretContainer, gunContainer);
useSkinAnimation(skins); useSkinAnimation(skins);
useEffect(() => { const materials = useMemo(() => {
const emissiveMaterials = new Set<MeshStandardMaterial>(); const collected = new Set<MeshStandardMaterial>();
const roots = [ const roots = [
gltf.scene, gltf.scene,
...skins.flatMap((skin) => [...skin.parts.values()].flat()), ...skins.flatMap((skin) => [...skin.parts.values()].flat()),
@@ -46,34 +53,21 @@ export function TankModel() {
for (const root of roots) { for (const root of roots) {
root.traverse((object) => { root.traverse((object) => {
if ( if (!(object instanceof Mesh)) return;
object instanceof Mesh &&
object.material instanceof MeshStandardMaterial && for (const material of [object.material].flat()) {
object.material.emissiveMap if (material instanceof MeshStandardMaterial) {
) { collected.add(material);
emissiveMaterials.add(object.material); }
} }
}); });
} }
// emissive parts fade with the lights instead of glowing in the dark return [...collected];
function applyIntensity(intensity: number) { }, [gltf, skins]);
for (const material of emissiveMaterials) {
material.emissiveIntensity = intensity;
}
}
function handleTransitionEvent(event: QuicklimeEvent<number>) { // also fades emissive parts with the lights
applyIntensity(event.data); useSilhouette(silhouette, materials);
}
applyIntensity(transitionEvent.last!);
transitionEvent.on(handleTransitionEvent);
return () => {
transitionEvent.off(handleTransitionEvent);
};
}, [gltf, skinGltf, attachmentGltf]);
return ( return (
<> <>
@@ -41,7 +41,6 @@ export function TankopediaSandbox({
antagonistCharacteristics, antagonistCharacteristics,
}: TankSandboxProps) { }: TankSandboxProps) {
const wrapper = useRef<HTMLDivElement>(null); const wrapper = useRef<HTMLDivElement>(null);
const canvas = useRef<HTMLCanvasElement>(null);
const hasImprovedVerticalStabilizer = useEquipment("protagonist", 122); const hasImprovedVerticalStabilizer = useEquipment("protagonist", 122);
const hasDownImprovedVerticalStabilizer = useEquipment("protagonist", 124); const hasDownImprovedVerticalStabilizer = useEquipment("protagonist", 124);
@@ -77,7 +76,7 @@ export function TankopediaSandbox({
gunModelDefinition.pitch!, gunModelDefinition.pitch!,
turretModelDefinition.yaw, turretModelDefinition.yaw,
hasImprovedVerticalStabilizer, hasImprovedVerticalStabilizer,
hasDownImprovedVerticalStabilizer hasDownImprovedVerticalStabilizer,
); );
modelTransformEvent.dispatch({ pitch, yaw }); modelTransformEvent.dispatch({ pitch, yaw });
@@ -91,7 +90,7 @@ export function TankopediaSandbox({
0, 0,
gunModelDefinition.pitch!, gunModelDefinition.pitch!,
turretModelDefinition.yaw, turretModelDefinition.yaw,
hasImprovedVerticalStabilizer hasImprovedVerticalStabilizer,
); );
modelTransformEvent.dispatch({ pitch, yaw }); modelTransformEvent.dispatch({ pitch, yaw });
@@ -105,7 +104,7 @@ export function TankopediaSandbox({
0, 0,
gunModelDefinition.pitch!, gunModelDefinition.pitch!,
turretModelDefinition.yaw, turretModelDefinition.yaw,
hasImprovedVerticalStabilizer hasImprovedVerticalStabilizer,
); );
modelTransformEvent.dispatch({ pitch, yaw }); modelTransformEvent.dispatch({ pitch, yaw });
@@ -133,7 +132,7 @@ export function TankopediaSandbox({
modelTransformEvent.last!.yaw, modelTransformEvent.last!.yaw,
gunModelDefinition.pitch!, gunModelDefinition.pitch!,
turretModelDefinition.yaw, turretModelDefinition.yaw,
hasImprovedVerticalStabilizer hasImprovedVerticalStabilizer,
); );
modelTransformEvent.dispatch({ pitch, yaw }); modelTransformEvent.dispatch({ pitch, yaw });
@@ -158,7 +157,6 @@ export function TankopediaSandbox({
<SmartCanvas <SmartCanvas
className={styles.canvas} className={styles.canvas}
resize={{ debounce: 0 }} resize={{ debounce: 0 }}
ref={canvas}
gl={{ gl={{
clippingPlanes: [], clippingPlanes: [],
localClippingEnabled: true, localClippingEnabled: true,
@@ -194,13 +192,15 @@ function Content({ thicknessRange }: { thicknessRange: ThicknessRange }) {
const { hasPbr } = useModel(tank.id); const { hasPbr } = useModel(tank.id);
// this causes a re-render of the ENTIRE thing, maybe move this elsewhere?
const revealed = Tankopedia.use((state) => state.revealed);
const hideTankModelUnderArmor = TankopediaPersistent.use( const hideTankModelUnderArmor = TankopediaPersistent.use(
(state) => state.hideTankModelUnderArmor (state) => state.hideTankModelUnderArmor,
); );
return ( return (
<> <>
<Lighting hasPbr={hasPbr} /> <Lighting hasPbr={hasPbr} hidden={!revealed} />
<SceneProps /> <SceneProps />
@@ -10,6 +10,8 @@
font: inherit; font: inherit;
color: var(--gray-11); color: var(--gray-11);
font-size: var(--font-size-regular);
&[data-variant="bubble"] { &[data-variant="bubble"] {
border-radius: var(--max-size); border-radius: var(--max-size);
+117
View File
@@ -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<number>) {
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();
});
}
@@ -0,0 +1,4 @@
.content {
flex: 1;
background-color: black;
}
@@ -1,22 +1,18 @@
import { Box, Flex, Heading } from "@radix-ui/themes";
import { Suspense, useEffect } from "react"; import { Suspense, useEffect } from "react";
import { api } from "../../../../api/dynamic"; import { api } from "../../../../api/dynamic";
import { GuessBackground } from "../../../../components/GuessBackground";
import { Guesser } from "../../../../components/Guesser"; import { Guesser } from "../../../../components/Guesser";
import { GuessRenderer } from "../../../../components/GuessRenderer"; import { GuessRenderer } from "../../../../components/GuessRenderer";
import { GuessRendererLoader } from "../../../../components/GuessRendererLoader"; import { GuessRendererLoader } from "../../../../components/GuessRendererLoader";
import { withErrorWrapper } from "../../../../hocs/withErrorWrapper"; import { withErrorWrapper } from "../../../../hocs/withErrorWrapper";
import { withLocale } from "../../../../hocs/withLocale"; import { withLocale } from "../../../../hocs/withLocale";
import { DuelModule, useDuel } from "../../../../hooks/useDuel"; import { DuelModule, useDuel } from "../../../../hooks/useDuel";
import { useUnwrapper } from "../../../../hooks/useUnwrapper";
import { Guess, GuessState } from "../../../../stores/guess"; import { Guess, GuessState } from "../../../../stores/guess";
import { Tankopedia } from "../../../../stores/tankopedia"; import { Tankopedia } from "../../../../stores/tankopedia";
import { createTankState } from "../../../../tankopedia/createTankState";
import type { MaybeSkeletonComponentProps } from "../../../../types/maybeSkeletonComponentProps"; import type { MaybeSkeletonComponentProps } from "../../../../types/maybeSkeletonComponentProps";
import styles from "./_index.module.css";
const [tankDefinitions, modelDefinitions] = await Promise.all([ const [tankDefinitions] = await Promise.all([api.tanks()]);
api.tanks(),
api.models(),
]);
const ids = Object.keys(tankDefinitions.tanks); const ids = Object.keys(tankDefinitions.tanks);
@@ -36,20 +32,18 @@ function Container({ skeleton }: MaybeSkeletonComponentProps) {
Tankopedia.useInitialization(tank); Tankopedia.useInitialization(tank);
useEffect(() => {
Tankopedia.mutate((draft) => {
draft.protagonist = createTankState(tank);
});
}, [id]);
return <Content skeleton={skeleton} />; return <Content skeleton={skeleton} />;
} }
function Content({ skeleton }: MaybeSkeletonComponentProps) { function Content({ skeleton }: MaybeSkeletonComponentProps) {
const tank = useDuel("protagonist", DuelModule.Tank); 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); const tiers = Guess.use((state) => state.tiers);
useEffect(() => { useEffect(() => {
@@ -76,59 +70,12 @@ function Content({ skeleton }: MaybeSkeletonComponentProps) {
}, [tiers]); }, [tiers]);
return ( return (
<Flex flexGrow="1" position="relative" overflow="hidden"> <div className={styles.content}>
<GuessBackground /> <Suspense fallback={<GuessRendererLoader />}>
{!skeleton && <GuessRenderer />}
<Heading </Suspense>
style={{
transitionDuration,
opacity: isRevealed ? 1 : 0,
fontWeight: 900,
position: "absolute",
left: "50%",
top: "50%",
transform: "translate(-50%, -50%)",
fontSize,
userSelect: "none",
whiteSpace: "nowrap",
pointerEvents: "none",
}}
>
{name}
</Heading>
<Box
position="absolute"
top="0"
left="0"
width="100%"
height="100%"
style={{ transitionDuration }}
>
<Suspense fallback={<GuessRendererLoader />}>
{!skeleton && <GuessRenderer />}
</Suspense>
</Box>
<Heading
style={{
transitionDuration,
opacity: isRevealed ? 0.5 : 0,
fontWeight: 900,
position: "absolute",
left: "50%",
top: "50%",
transform: "translate(-50%, -50%)",
fontSize,
userSelect: "none",
whiteSpace: "nowrap",
pointerEvents: "none",
}}
>
{name}
</Heading>
<Guesser /> <Guesser />
</Flex> </div>
); );
} }
@@ -1,4 +1,5 @@
--- ---
import ColorScale from "../../../../components/ColorScale.astro";
import BaseLayout from "../../../../layouts/BaseLayout.astro"; import BaseLayout from "../../../../layouts/BaseLayout.astro";
import { Page } from "./_index"; import { Page } from "./_index";
@@ -8,6 +9,8 @@ const { locale } = Astro.props;
--- ---
<BaseLayout accent="blue" {locale}> <BaseLayout accent="blue" {locale}>
<ColorScale color="red" />
<Page {locale} client:only="react"> <Page {locale} client:only="react">
<Page {locale} skeleton slot="fallback" /> <Page {locale} skeleton slot="fallback" />
</Page> </Page>
+3
View File
@@ -16,6 +16,8 @@ export interface Guess {
correctGuesses: number; correctGuesses: number;
streak: number; streak: number;
helpingReveal: boolean; helpingReveal: boolean;
// lights are receding before the next tank loads
advancing: boolean;
tiers: number[]; tiers: number[];
} }
@@ -26,5 +28,6 @@ export const Guess = new Soapstone<Guess, [number]>((tank) => ({
correctGuesses: 0, correctGuesses: 0,
streak: 0, streak: 0,
helpingReveal: false, helpingReveal: false,
advancing: false,
tiers, tiers,
})); }));