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
@@ -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 type { ComponentProps } from "react";
|
||||
import { classNames } from "../../ui/classNames";
|
||||
import { composeRadixComponents } from "../../ui/compose";
|
||||
import styles from "./index.module.css";
|
||||
|
||||
export const AlertDialog = composeRadixComponents(_, styles);
|
||||
function Actions({ className, ...props }: ComponentProps<"div">) {
|
||||
return <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-subtle: 2s;
|
||||
--duration-spinner: 1s;
|
||||
|
||||
--tank-card-min-width: 8rem;
|
||||
--drop-down-min-width: 16rem;
|
||||
--alert-max-width: 28rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
border: none;
|
||||
|
||||
font-family: var(--font-family-regular);
|
||||
font-size: 0.8em;
|
||||
font-size: var(--font-size-regular);
|
||||
color: var(--gray-1);
|
||||
font-weight: var(--weight-medium);
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
}
|
||||
|
||||
&[data-size="minor"] {
|
||||
font-size: 0.7em;
|
||||
font-size: var(--font-size-minor);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
|
||||
&[data-even-padding] {
|
||||
@@ -30,7 +30,7 @@
|
||||
}
|
||||
|
||||
&[data-size="major"] {
|
||||
font-size: 1.25em;
|
||||
font-size: var(--font-size-major);
|
||||
}
|
||||
|
||||
&[data-list-child="horizontal"]:not(:first-child),
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
.SubContent {
|
||||
--gutter: var(--space-5);
|
||||
|
||||
min-width: min(calc(100dvw - 2 * var(--space-4)), 16rem);
|
||||
min-width: min(calc(100dvw - 2 * var(--space-4)), var(--drop-down-width));
|
||||
max-height: calc(
|
||||
var(--radix-dropdown-menu-content-available-height) - var(--space-6)
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
&[data-size="regular"] {
|
||||
padding: var(--space-2);
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
&[data-size="major"] {
|
||||
|
||||
@@ -39,16 +39,17 @@ export const transitionEvent = new Quicklime<number>(0);
|
||||
|
||||
interface Props {
|
||||
hasPbr: boolean;
|
||||
// keeps the lights off; toggling fades them out/in
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
export function Lighting({ hasPbr }: Props) {
|
||||
export function Lighting({ hasPbr, hidden = false }: Props) {
|
||||
const wrapper = useRef<Group>(null);
|
||||
|
||||
const highGraphics = TankopediaPersistent.use((state) => state.highGraphics);
|
||||
const requestedDisplay = Tankopedia.use(
|
||||
(state) => state.loaders.display.requested,
|
||||
);
|
||||
const revealed = Tankopedia.use((state) => state.revealed);
|
||||
const skinPending = Tankopedia.use(
|
||||
(state) =>
|
||||
state.loaders.skin.requested !== state.loaders.skin.loaded ||
|
||||
@@ -57,7 +58,8 @@ export function Lighting({ hasPbr }: Props) {
|
||||
|
||||
const animationTime = useRef(REVEAL_ANIMATION_TIME);
|
||||
const t0 = useRef(performance.now() / 1e3 - animationTime.current);
|
||||
const holding = useRef(false);
|
||||
// starts held at the fully-faded point when hidden so there's no fade-in
|
||||
const holding = useRef(hidden);
|
||||
|
||||
const isRevealing = useRef(true);
|
||||
const isFirstReveal = useRef(true);
|
||||
@@ -89,7 +91,7 @@ export function Lighting({ hasPbr }: Props) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!revealed) {
|
||||
if (hidden) {
|
||||
animationTime.current = TRANSITION_ANIMATION_TIME;
|
||||
t0.current = performance.now() / 1e3;
|
||||
holding.current = true;
|
||||
@@ -103,7 +105,7 @@ export function Lighting({ hasPbr }: Props) {
|
||||
|
||||
setAnimate(true);
|
||||
invalidate();
|
||||
}, [revealed]);
|
||||
}, [hidden]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!skinPending) return;
|
||||
|
||||
@@ -8,7 +8,11 @@ const SIZE = 2 ** 4;
|
||||
|
||||
export const screenshotReadyEvent = new Quicklime(false);
|
||||
|
||||
export function SceneProps() {
|
||||
interface SceneProps {
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export function SceneProps({ color = mauveDark.mauve6 }: SceneProps) {
|
||||
const mesh = useRef<Mesh>(null!);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -79,7 +83,7 @@ export function SceneProps() {
|
||||
>
|
||||
<planeGeometry args={[SIZE, SIZE]} />
|
||||
<meshStandardMaterial
|
||||
color={mauveDark.mauve6}
|
||||
color={color}
|
||||
roughness={1}
|
||||
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() {
|
||||
return null;
|
||||
return <span className={styles.spinner} />;
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { invalidate, useThree, type ThreeEvent } from "@react-three/fiber";
|
||||
import type { QuicklimeEvent } from "quicklime";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useMemo, useRef } from "react";
|
||||
import { Group, Mesh, MeshStandardMaterial, Vector2 } from "three";
|
||||
import { applyPitchYawLimits } from "../core/blitz/applyPitchYawLimits";
|
||||
import { modelTransformEvent } from "../core/blitzkit/modelTransform";
|
||||
@@ -10,14 +9,19 @@ import { DuelModule, getDuel, useDuel } from "../hooks/useDuel";
|
||||
import { useDuelModel } from "../hooks/useDuelModel";
|
||||
import { hasEquipment } from "../hooks/useEquipment";
|
||||
import { useModel } from "../hooks/useModel";
|
||||
import { useSilhouette } from "../hooks/useSilhouette";
|
||||
import { useSkin } from "../hooks/useSkin";
|
||||
import { useSkinAnimation } from "../hooks/useSkinAnimation";
|
||||
import { useTankTransform } from "../hooks/useTankTransform";
|
||||
import { Tankopedia } from "../stores/tankopedia";
|
||||
import { TankopediaDisplay } from "../stores/tankopediaPersistent/constants";
|
||||
import { transitionEvent } from "./Lighting";
|
||||
|
||||
export function TankModel() {
|
||||
interface TankModelProps {
|
||||
// while the lights are down, draws the tank as a flat light shape
|
||||
silhouette?: boolean;
|
||||
}
|
||||
|
||||
export function TankModel({ silhouette = false }: TankModelProps) {
|
||||
const protagonist = Tankopedia.use((state) => state.protagonist);
|
||||
const track = useDuel("protagonist", DuelModule.Track);
|
||||
const turret = useDuel("protagonist", DuelModule.Turret);
|
||||
@@ -32,13 +36,16 @@ export function TankModel() {
|
||||
const nodes = Object.values(gltf.nodes);
|
||||
const skinGltf = useSkin(protagonist.tank);
|
||||
const attachmentGltf = useSkin(protagonist.tank, "attachment");
|
||||
const skins = [skinGltf, attachmentGltf].filter((skin) => skin !== undefined);
|
||||
const skins = useMemo(
|
||||
() => [skinGltf, attachmentGltf].filter((skin) => skin !== undefined),
|
||||
[skinGltf, attachmentGltf],
|
||||
);
|
||||
|
||||
useTankTransform(track, turret, turretContainer, gunContainer);
|
||||
useSkinAnimation(skins);
|
||||
|
||||
useEffect(() => {
|
||||
const emissiveMaterials = new Set<MeshStandardMaterial>();
|
||||
const materials = useMemo(() => {
|
||||
const collected = new Set<MeshStandardMaterial>();
|
||||
const roots = [
|
||||
gltf.scene,
|
||||
...skins.flatMap((skin) => [...skin.parts.values()].flat()),
|
||||
@@ -46,34 +53,21 @@ export function TankModel() {
|
||||
|
||||
for (const root of roots) {
|
||||
root.traverse((object) => {
|
||||
if (
|
||||
object instanceof Mesh &&
|
||||
object.material instanceof MeshStandardMaterial &&
|
||||
object.material.emissiveMap
|
||||
) {
|
||||
emissiveMaterials.add(object.material);
|
||||
if (!(object instanceof Mesh)) return;
|
||||
|
||||
for (const material of [object.material].flat()) {
|
||||
if (material instanceof MeshStandardMaterial) {
|
||||
collected.add(material);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// emissive parts fade with the lights instead of glowing in the dark
|
||||
function applyIntensity(intensity: number) {
|
||||
for (const material of emissiveMaterials) {
|
||||
material.emissiveIntensity = intensity;
|
||||
}
|
||||
}
|
||||
return [...collected];
|
||||
}, [gltf, skins]);
|
||||
|
||||
function handleTransitionEvent(event: QuicklimeEvent<number>) {
|
||||
applyIntensity(event.data);
|
||||
}
|
||||
|
||||
applyIntensity(transitionEvent.last!);
|
||||
transitionEvent.on(handleTransitionEvent);
|
||||
|
||||
return () => {
|
||||
transitionEvent.off(handleTransitionEvent);
|
||||
};
|
||||
}, [gltf, skinGltf, attachmentGltf]);
|
||||
// also fades emissive parts with the lights
|
||||
useSilhouette(silhouette, materials);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -41,7 +41,6 @@ export function TankopediaSandbox({
|
||||
antagonistCharacteristics,
|
||||
}: TankSandboxProps) {
|
||||
const wrapper = useRef<HTMLDivElement>(null);
|
||||
const canvas = useRef<HTMLCanvasElement>(null);
|
||||
const hasImprovedVerticalStabilizer = useEquipment("protagonist", 122);
|
||||
const hasDownImprovedVerticalStabilizer = useEquipment("protagonist", 124);
|
||||
|
||||
@@ -77,7 +76,7 @@ export function TankopediaSandbox({
|
||||
gunModelDefinition.pitch!,
|
||||
turretModelDefinition.yaw,
|
||||
hasImprovedVerticalStabilizer,
|
||||
hasDownImprovedVerticalStabilizer
|
||||
hasDownImprovedVerticalStabilizer,
|
||||
);
|
||||
|
||||
modelTransformEvent.dispatch({ pitch, yaw });
|
||||
@@ -91,7 +90,7 @@ export function TankopediaSandbox({
|
||||
0,
|
||||
gunModelDefinition.pitch!,
|
||||
turretModelDefinition.yaw,
|
||||
hasImprovedVerticalStabilizer
|
||||
hasImprovedVerticalStabilizer,
|
||||
);
|
||||
|
||||
modelTransformEvent.dispatch({ pitch, yaw });
|
||||
@@ -105,7 +104,7 @@ export function TankopediaSandbox({
|
||||
0,
|
||||
gunModelDefinition.pitch!,
|
||||
turretModelDefinition.yaw,
|
||||
hasImprovedVerticalStabilizer
|
||||
hasImprovedVerticalStabilizer,
|
||||
);
|
||||
|
||||
modelTransformEvent.dispatch({ pitch, yaw });
|
||||
@@ -133,7 +132,7 @@ export function TankopediaSandbox({
|
||||
modelTransformEvent.last!.yaw,
|
||||
gunModelDefinition.pitch!,
|
||||
turretModelDefinition.yaw,
|
||||
hasImprovedVerticalStabilizer
|
||||
hasImprovedVerticalStabilizer,
|
||||
);
|
||||
|
||||
modelTransformEvent.dispatch({ pitch, yaw });
|
||||
@@ -158,7 +157,6 @@ export function TankopediaSandbox({
|
||||
<SmartCanvas
|
||||
className={styles.canvas}
|
||||
resize={{ debounce: 0 }}
|
||||
ref={canvas}
|
||||
gl={{
|
||||
clippingPlanes: [],
|
||||
localClippingEnabled: true,
|
||||
@@ -194,13 +192,15 @@ function Content({ thicknessRange }: { thicknessRange: ThicknessRange }) {
|
||||
|
||||
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(
|
||||
(state) => state.hideTankModelUnderArmor
|
||||
(state) => state.hideTankModelUnderArmor,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Lighting hasPbr={hasPbr} />
|
||||
<Lighting hasPbr={hasPbr} hidden={!revealed} />
|
||||
|
||||
<SceneProps />
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
font: inherit;
|
||||
color: var(--gray-11);
|
||||
|
||||
font-size: var(--font-size-regular);
|
||||
|
||||
&[data-variant="bubble"] {
|
||||
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 { api } from "../../../../api/dynamic";
|
||||
import { GuessBackground } from "../../../../components/GuessBackground";
|
||||
import { Guesser } from "../../../../components/Guesser";
|
||||
import { GuessRenderer } from "../../../../components/GuessRenderer";
|
||||
import { GuessRendererLoader } from "../../../../components/GuessRendererLoader";
|
||||
import { withErrorWrapper } from "../../../../hocs/withErrorWrapper";
|
||||
import { withLocale } from "../../../../hocs/withLocale";
|
||||
import { DuelModule, useDuel } from "../../../../hooks/useDuel";
|
||||
import { useUnwrapper } from "../../../../hooks/useUnwrapper";
|
||||
import { Guess, GuessState } from "../../../../stores/guess";
|
||||
import { Tankopedia } from "../../../../stores/tankopedia";
|
||||
import { createTankState } from "../../../../tankopedia/createTankState";
|
||||
import type { MaybeSkeletonComponentProps } from "../../../../types/maybeSkeletonComponentProps";
|
||||
import styles from "./_index.module.css";
|
||||
|
||||
const [tankDefinitions, modelDefinitions] = await Promise.all([
|
||||
api.tanks(),
|
||||
api.models(),
|
||||
]);
|
||||
const [tankDefinitions] = await Promise.all([api.tanks()]);
|
||||
|
||||
const ids = Object.keys(tankDefinitions.tanks);
|
||||
|
||||
@@ -36,20 +32,18 @@ function Container({ skeleton }: MaybeSkeletonComponentProps) {
|
||||
|
||||
Tankopedia.useInitialization(tank);
|
||||
|
||||
useEffect(() => {
|
||||
Tankopedia.mutate((draft) => {
|
||||
draft.protagonist = createTankState(tank);
|
||||
});
|
||||
}, [id]);
|
||||
|
||||
return <Content skeleton={skeleton} />;
|
||||
}
|
||||
|
||||
function Content({ skeleton }: MaybeSkeletonComponentProps) {
|
||||
const tank = useDuel("protagonist", DuelModule.Tank);
|
||||
|
||||
const unwrap = useUnwrapper();
|
||||
|
||||
const name = unwrap(tank.name!);
|
||||
|
||||
const guessState = Guess.use((state) => state.guessState);
|
||||
const isRevealed = guessState !== GuessState.NotGuessed;
|
||||
const fontSize = `min(48vh, ${55 / name.length}vw)`;
|
||||
const transitionDuration = isRevealed ? "2s" : undefined;
|
||||
const tiers = Guess.use((state) => state.tiers);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -76,59 +70,12 @@ function Content({ skeleton }: MaybeSkeletonComponentProps) {
|
||||
}, [tiers]);
|
||||
|
||||
return (
|
||||
<Flex flexGrow="1" position="relative" overflow="hidden">
|
||||
<GuessBackground />
|
||||
|
||||
<Heading
|
||||
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>
|
||||
<div className={styles.content}>
|
||||
<Suspense fallback={<GuessRendererLoader />}>
|
||||
{!skeleton && <GuessRenderer />}
|
||||
</Suspense>
|
||||
|
||||
<Guesser />
|
||||
</Flex>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
---
|
||||
import ColorScale from "../../../../components/ColorScale.astro";
|
||||
import BaseLayout from "../../../../layouts/BaseLayout.astro";
|
||||
import { Page } from "./_index";
|
||||
|
||||
@@ -8,6 +9,8 @@ const { locale } = Astro.props;
|
||||
---
|
||||
|
||||
<BaseLayout accent="blue" {locale}>
|
||||
<ColorScale color="red" />
|
||||
|
||||
<Page {locale} client:only="react">
|
||||
<Page {locale} skeleton slot="fallback" />
|
||||
</Page>
|
||||
|
||||
@@ -16,6 +16,8 @@ export interface Guess {
|
||||
correctGuesses: number;
|
||||
streak: number;
|
||||
helpingReveal: boolean;
|
||||
// lights are receding before the next tank loads
|
||||
advancing: boolean;
|
||||
tiers: number[];
|
||||
}
|
||||
|
||||
@@ -26,5 +28,6 @@ export const Guess = new Soapstone<Guess, [number]>((tank) => ({
|
||||
correctGuesses: 0,
|
||||
streak: 0,
|
||||
helpingReveal: false,
|
||||
advancing: false,
|
||||
tiers,
|
||||
}));
|
||||
Reference in new issue
Block a user