Files
blitzkit/packages/website/src/components/Guesser.tsx
T

371 lines
11 KiB
TypeScript
Raw Normal View History

2026-10-13 20:37:53 -05:00
import { alias, romanize, SEARCH_KEYS } from "@blitzkit/core";
2025-09-01 19:22:38 -05:00
import { literals } from "@blitzkit/i18n";
2026-09-27 17:19:39 -05:00
import { TankDefinition } from "@blitzkit/protos";
2025-05-04 21:39:09 -05:00
import {
ArrowRightIcon,
2025-12-27 19:51:50 -06:00
EyeOpenIcon,
2025-05-04 21:39:09 -05:00
MagnifyingGlassIcon,
2026-01-04 15:11:20 -06:00
MixerVerticalIcon,
2025-05-04 21:39:09 -05:00
PaperPlaneIcon,
2026-01-04 15:11:20 -06:00
StarFilledIcon,
TrashIcon,
2025-09-01 19:22:38 -05:00
} from "@radix-ui/react-icons";
2025-05-04 21:02:50 -05:00
import {
2025-12-27 19:51:50 -06:00
AlertDialog,
2025-05-04 21:02:50 -05:00
Box,
Button,
Card,
2026-01-04 15:11:20 -06:00
DropdownMenu,
2025-05-04 21:02:50 -05:00
Flex,
2025-12-27 19:51:50 -06:00
IconButton,
2025-05-04 21:02:50 -05:00
Spinner,
Text,
TextField,
2025-09-01 19:22:38 -05:00
} from "@radix-ui/themes";
import fuzzysort from "fuzzysort";
2026-10-05 18:18:19 +13:00
import { debounce } from "lodash-es";
2025-09-01 19:22:38 -05:00
import { useCallback, useEffect, useRef, useState } from "react";
2026-09-23 10:25:47 -05:00
import { api } from "../api/dynamic";
2025-09-01 19:22:38 -05:00
import { awaitableTankNames } from "../core/awaitables/tankNames";
2026-10-05 18:18:19 +13:00
import { awaitableTiers } from "../core/awaitables/tiers";
2026-08-04 20:35:51 -05:00
import { api } from "../core/blitzkit/api";
2025-09-01 19:22:38 -05:00
import { useLocale } from "../hooks/useLocale";
2025-09-01 22:56:39 -05:00
import { Guess, GuessState } from "../stores/guess";
2025-09-01 19:22:38 -05:00
import { classIcons } from "./ClassIcon";
import { SearchResults } from "./SearchResults";
2025-05-04 21:02:50 -05:00
const { go } = fuzzysort;
2026-10-05 18:18:19 +13:00
const [tankNames, tankDefinitions, TIERS] = await Promise.all([
2025-05-04 21:02:50 -05:00
awaitableTankNames,
2026-08-04 20:35:51 -05:00
api.tankDefinitions(),
2026-10-05 18:18:19 +13:00
awaitableTiers,
2025-05-04 21:02:50 -05:00
]);
2025-05-04 21:39:09 -05:00
const ids = Object.keys(tankDefinitions.tanks);
2026-01-04 15:11:20 -06:00
export function Guesser() {
2025-09-01 19:22:38 -05:00
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);
2025-12-27 19:51:50 -06:00
const helpingReveal = Guess.use((state) => state.helpingReveal);
2025-12-27 19:39:10 -06:00
const streak = Guess.use((state) => state.streak);
2025-05-04 21:02:50 -05:00
const { strings, unwrap } = useLocale();
const input = useRef<HTMLInputElement>(null);
const [searching, setSearching] = useState(false);
const [results, setResults] = useState<TankDefinition[] | null>(null);
const [selected, setSelected] = useState<TankDefinition | null>(null);
2026-01-04 15:11:20 -06:00
const tiers = Guess.use((state) => state.tiers);
2026-05-10 21:55:33 -05:00
const status = useRef<HTMLSpanElement>(null);
2025-05-04 21:02:50 -05:00
const requestSearch = useCallback(() => {
setSelected(null);
setSearching(true);
search();
}, []);
2026-05-10 21:55:33 -05:00
2025-05-04 21:02:50 -05:00
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(
2026-03-31 14:11:45 -05:00
searchResults.map((result) => tankDefinitions.tanks[result.obj.id]),
2025-05-04 21:02:50 -05:00
);
}, 500),
2026-03-31 14:11:45 -05:00
[],
2025-05-04 21:02:50 -05:00
);
2025-05-05 21:22:05 -05:00
useEffect(() => {
2025-09-01 22:55:31 -05:00
if (guessState !== GuessState.NotGuessed) setSelected(null);
2025-05-05 21:22:05 -05:00
}, [guessState]);
2026-05-10 21:55:33 -05:00
useEffect(() => {
const interval = setInterval(() => {
if (!status.current) return;
status.current.innerText = literals(strings.website.tools.guess.stats, {
correct: correctGuesses,
total: totalGuesses,
streak: streak,
});
2026-05-11 17:52:23 -05:00
}, 1000 / 200);
2026-05-10 21:55:33 -05:00
return () => clearInterval(interval);
}, [correctGuesses, totalGuesses, streak]);
2025-05-04 21:02:50 -05:00
return (
<Flex
direction="column"
position="absolute"
bottom="0"
left="50%"
2025-09-01 19:22:38 -05:00
style={{ transform: "translateX(-50%)" }}
2025-05-04 21:02:50 -05:00
width="100%"
p="4"
maxWidth="25rem"
2026-01-04 15:11:20 -06:00
gap="3"
2025-05-04 21:02:50 -05:00
>
2026-01-04 10:18:48 +13:00
<Box position="relative">
2026-01-04 15:11:20 -06:00
{results !== null && (
<Card variant="classic">
<Box py="2" px="3">
{results.length === 0 && (
<Flex justify="center">
<Text color="gray">
{strings.website.tools.guess.search.no_results}
</Text>
</Flex>
)}
2026-01-04 10:18:48 +13:00
2026-01-04 15:11:20 -06:00
<SearchResults.Root>
{results.map((result) => {
const Icon = classIcons[result.class];
2025-05-04 21:02:50 -05:00
2026-01-04 15:11:20 -06:00
return (
<SearchResults.Item
key={result.id}
onClick={() => {
if (!input.current) return;
2025-05-04 21:02:50 -05:00
2026-03-31 14:11:45 -05:00
input.current.value = unwrap(result.name!);
2026-01-04 15:11:20 -06:00
setSelected(result);
setResults(null);
}}
2026-03-31 14:11:45 -05:00
text={unwrap(result.name!)}
2026-01-04 15:11:20 -06:00
prefix={
<img
style={{ width: "1em", height: "1em" }}
2026-08-06 20:01:50 -05:00
src={alias(
"api",
`/flags/circle/${result.nation}.webp`,
)}
2026-01-04 15:11:20 -06:00
/>
}
discriminator={
<Flex
align="center"
gap="1"
width="38px"
justify="center"
>
<Icon width="1em" height="1em" />
2026-10-13 20:37:53 -05:00
{romanize(result.tier)}
2026-01-04 15:11:20 -06:00
</Flex>
}
/>
);
})}
</SearchResults.Root>
</Box>
</Card>
)}
</Box>
2025-05-04 21:02:50 -05:00
2025-09-01 19:22:38 -05:00
<Flex justify="center" style={{ userSelect: "none" }}>
2026-05-10 21:55:33 -05:00
<Text ref={status} />
2025-05-04 21:45:38 -05:00
</Flex>
2026-01-16 19:31:09 -06:00
<Flex gap={{ initial: "2", sm: "3" }}>
2026-01-04 15:11:20 -06:00
<DropdownMenu.Root modal={false}>
<DropdownMenu.Trigger>
2026-01-16 19:31:09 -06:00
<IconButton
size={{ initial: "2", sm: "3" }}
variant="surface"
color="gray"
>
2026-01-04 15:11:20 -06:00
<MixerVerticalIcon />
</IconButton>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
2026-10-05 18:18:19 +13:00
{TIERS.map((tier) => {
2026-01-04 15:11:20 -06:00
const isSelected = tiers.includes(tier);
return (
<DropdownMenu.CheckboxItem
checked={isSelected}
key={tier}
onClick={(event) => {
event.preventDefault();
Guess.mutate((draft) => {
if (isSelected) {
2026-01-04 15:16:57 -06:00
if (draft.tiers.length === 1) return;
2026-01-04 15:11:20 -06:00
draft.tiers = draft.tiers.filter((t) => t !== tier);
} else {
draft.tiers = [...draft.tiers, tier];
}
2026-01-04 15:18:00 -06:00
draft.streak = 0;
2026-01-04 15:11:20 -06:00
});
}}
>
{literals(strings.website.tools.guess.tier, {
2026-10-13 20:37:53 -05:00
tier: romanize(tier),
2026-01-04 15:11:20 -06:00
})}
</DropdownMenu.CheckboxItem>
);
})}
<DropdownMenu.Separator />
<DropdownMenu.Item
onClick={(event) => {
event.preventDefault();
Guess.mutate((draft) => {
draft.tiers = Guess.initial.tiers;
});
}}
>
<StarFilledIcon /> {strings.website.tools.guess.select_all}
</DropdownMenu.Item>
<DropdownMenu.Item
color="red"
onClick={(event) => {
event.preventDefault();
Guess.mutate((draft) => {
draft.tiers = [];
});
}}
>
<TrashIcon /> {strings.website.tools.guess.clear}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
2025-05-04 21:02:50 -05:00
<TextField.Root
2025-09-01 22:55:31 -05:00
disabled={guessState !== GuessState.NotGuessed}
2025-05-04 21:02:50 -05:00
ref={input}
onChange={requestSearch}
2026-01-16 19:31:09 -06:00
style={{ flex: 1, maxWidth: "14rem" }}
2025-05-04 21:02:50 -05:00
placeholder={strings.website.tools.guess.search.placeholder}
2026-01-16 19:31:09 -06:00
size={{ initial: "2", sm: "3" }}
2025-05-04 21:02:50 -05:00
variant="classic"
>
<TextField.Slot>
{searching ? <Spinner /> : <MagnifyingGlassIcon />}
</TextField.Slot>
</TextField.Root>
2025-12-27 19:51:50 -06:00
<AlertDialog.Root>
<AlertDialog.Trigger>
<IconButton
2026-01-16 19:31:09 -06:00
size={{ initial: "2", sm: "3" }}
2025-12-27 19:51:50 -06:00
disabled={guessState !== GuessState.NotGuessed}
>
<EyeOpenIcon />
</IconButton>
</AlertDialog.Trigger>
<AlertDialog.Content>
<AlertDialog.Title>
{strings.website.tools.guess.help.title}
</AlertDialog.Title>
<AlertDialog.Description>
{strings.website.tools.guess.help.description}
</AlertDialog.Description>
<Flex justify="end" gap="2">
<AlertDialog.Cancel>
<Button variant="outline">
{strings.website.tools.guess.help.cancel}
</Button>
</AlertDialog.Cancel>
<AlertDialog.Action>
<Button
color="tomato"
onClick={() => {
Guess.mutate((draft) => {
draft.helpingReveal = true;
draft.streak = 0;
});
}}
>
{strings.website.tools.guess.help.reveal}
</Button>
</AlertDialog.Action>
</Flex>
</AlertDialog.Content>
</AlertDialog.Root>
2025-05-04 21:02:50 -05:00
<Button
2026-01-16 19:31:09 -06:00
size={{ initial: "2", sm: "3" }}
2025-09-01 22:55:31 -05:00
color={
guessState === GuessState.NotGuessed && selected === null
? "red"
: undefined
}
2025-05-04 21:02:50 -05:00
onClick={() => {
2025-09-01 22:55:31 -05:00
if (guessState === GuessState.NotGuessed) {
2025-12-27 19:51:50 -06:00
const correct =
selected !== null && selected.id === tank.id && !helpingReveal;
2025-05-04 21:39:09 -05:00
2025-09-01 19:22:38 -05:00
Guess.mutate((draft) => {
2025-09-01 22:55:31 -05:00
draft.guessState = correct
? GuessState.Correct
: GuessState.Incorrect;
2025-05-04 21:45:38 -05:00
draft.totalGuesses++;
2026-08-04 20:35:51 -05:00
draft.correctGuesses +=
correct && draft.tiers.length === 10 ? 1 : 0;
2025-12-27 19:39:10 -06:00
draft.streak = correct ? draft.streak + 1 : 0;
2025-05-04 21:39:09 -05:00
});
} else {
2026-01-04 10:18:48 +13:00
const filteredIds = ids.filter((id) => {
const tank = tankDefinitions.tanks[Number(id)];
2026-01-04 15:11:20 -06:00
return tiers.includes(tank.tier);
2026-01-04 10:18:48 +13:00
});
const id = Number(
2026-03-31 14:11:45 -05:00
filteredIds[Math.floor(Math.random() * filteredIds.length)],
2026-01-04 10:18:48 +13:00
);
2025-05-04 21:39:09 -05:00
const tank = tankDefinitions.tanks[id];
2025-09-01 19:22:38 -05:00
Guess.mutate((draft) => {
2025-05-04 21:39:09 -05:00
if (!input.current) return;
draft.tank = tank;
2025-09-01 22:55:31 -05:00
draft.guessState = GuessState.NotGuessed;
2025-12-27 19:51:50 -06:00
draft.helpingReveal = false;
2025-09-01 22:55:31 -05:00
2025-09-01 19:22:38 -05:00
input.current.value = "";
2025-05-04 21:39:09 -05:00
});
}
2025-05-04 21:02:50 -05:00
}}
>
2025-09-01 22:55:31 -05:00
{guessState === GuessState.NotGuessed ? (
2025-05-04 21:39:09 -05:00
<>
2025-05-05 21:22:05 -05:00
{
strings.website.tools.guess.search[
2025-09-01 19:22:38 -05:00
selected === null ? "skip" : "guess"
2025-05-05 21:22:05 -05:00
]
}
{selected === null ? <ArrowRightIcon /> : <PaperPlaneIcon />}
2025-05-04 21:39:09 -05:00
</>
) : (
<>
{strings.website.tools.guess.search.next}
<ArrowRightIcon />
</>
)}
2025-05-04 21:02:50 -05:00
</Button>
</Flex>
</Flex>
);
}