mirror of
https://github.com/blitzkit/blitzkit.git
synced 2026-11-13 04:36:12 +01:00
migrate guesser
This commit is contained in:
33 files changed
+802
-635
No files matched your search
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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"] {
|
||||||
|
|||||||
@@ -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} />;
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
}));
|
}));
|
||||||
Reference in new issue
Block a user