Merge branch 'main' into dev

This commit is contained in:
tresabhi committed 2025-05-15 22:35:42 -05:00
commit 091364d7f3
38 files changed
+1151 -443

No files matched your search

@@ -6,8 +6,6 @@ import { literals } from '@blitzkit/i18n/src/literals';
import { Box, Flex, Heading, Link } from '@radix-ui/themes';
import type { GetStaticPaths } from 'astro';
import { AesonPlug } from '../../components/AesonPlug';
import { DiscordPlug } from '../../components/Discord';
import { HomeLocaleSwitcher } from '../../components/Home/HomeLocaleSwitcher';
import HomeHero from '../../components/HomeHero.astro';
import { HomePageHotTanks } from '../../components/HomePageHotTanks';
import { Plugs } from '../../components/Plugs';
@@ -57,7 +55,7 @@ export const getStaticPaths = (() => {
<BaseLayout>
<HomeHero />
<Flex justify="center" py="6" px="4" justify="center">
<!-- <Flex justify="center" py="6" px="4" justify="center">
<Flex
wrap="wrap"
justify="between"
@@ -71,7 +69,7 @@ export const getStaticPaths = (() => {
<DiscordPlug {locale} skeleton slot="fallback" />
</DiscordPlug>
</Flex>
</Flex>
</Flex> -->
{
locale !== DEFAULT_LOCALE && (
@@ -118,6 +116,7 @@ export const getStaticPaths = (() => {
<Flex gap="4" direction={{ initial: 'column', sm: 'row' }}>
<!-- <ToolCard tool={chartsTool} /> -->
<!-- <ToolCard tool={guessTool} /> -->
<ToolCard tool={galleryTool} />
<ToolCard tool={sessionTool} />
</Flex>
@@ -0,0 +1,123 @@
import { Box, Flex, Heading } from '@radix-ui/themes';
import { Suspense } from 'react';
import { awaitableModelDefinitions } from '../../../../core/awaitables/modelDefinitions';
import { awaitableProvisionDefinitions } from '../../../../core/awaitables/provisionDefinitions';
import { awaitableTankDefinitions } from '../../../../core/awaitables/tankDefinitions';
import { Var } from '../../../../core/radix/var';
import {
LocaleProvider,
useLocale,
type LocaleAcceptorProps,
} from '../../../../hooks/useLocale';
import { Duel } from '../../../../stores/duel';
import { GuessEphemeral } from '../../../../stores/guessEphemeral';
import { TankopediaEphemeral } from '../../../../stores/tankopediaEphemeral';
import { Background } from './components/Background';
import { Guesser } from './components/Guesser';
import { GuessRenderer } from './components/GuessRenderer';
import { GuessRendererLoader } from './components/GuessRendererLoader';
const [tankDefinitions, modelDefinitions, provisionDefinitions] =
await Promise.all([
awaitableTankDefinitions,
awaitableModelDefinitions,
awaitableProvisionDefinitions,
]);
const ids = Object.keys(tankDefinitions.tanks);
export function Page({ locale }: LocaleAcceptorProps) {
const initialId = Number(ids[Math.floor(Math.random() * ids.length)]);
const initialTank = tankDefinitions.tanks[initialId];
return (
<GuessEphemeral.Provider data={initialTank}>
<LocaleProvider locale={locale}>
<Page2 />
</LocaleProvider>
</GuessEphemeral.Provider>
);
}
function Page2() {
const tank = GuessEphemeral.use((state) => state.tank);
const model = modelDefinitions.models[tank.id];
return (
<TankopediaEphemeral.Provider data={model}>
<Duel.Provider data={{ tank, model, provisionDefinitions }}>
<Content />
</Duel.Provider>
</TankopediaEphemeral.Provider>
);
}
function Content() {
const { unwrap } = useLocale();
const tank = GuessEphemeral.use((state) => state.tank);
const guessState = GuessEphemeral.use((state) => state.guessState);
const isRevealed = guessState !== null;
return (
<Flex flexGrow="1" position="relative" overflow="hidden">
<Background />
<Flex
position="absolute"
left="50%"
top="50%"
ml="9"
style={{
opacity: isRevealed ? 1 : 0,
transitionDuration: isRevealed ? '2s' : undefined,
transform: 'translateY(-50%)',
}}
>
<Heading weight="medium" size="9">
{unwrap(tank.name)}
</Heading>
</Flex>
<Box
position="absolute"
width="100%"
height="100%"
top="0"
right={isRevealed ? '16rem' : '0'}
style={{
transitionDuration: isRevealed ? '2s' : undefined,
}}
>
<Suspense fallback={<GuessRendererLoader />}>
<GuessRenderer />
</Suspense>
</Box>
<Flex
position="absolute"
left="50%"
top="50%"
ml="9"
style={{
opacity: isRevealed ? 1 : 0,
transitionDuration: isRevealed ? '2s' : undefined,
transform: 'translateY(-50%)',
color: 'transparent',
WebkitTextStroke: `1px ${Var('gray-12')}`,
}}
>
<Heading
style={{
WebkitTextStroke: `1px inherit`,
}}
weight="medium"
size="9"
>
{unwrap(tank.name)}
</Heading>
</Flex>
<Guesser />
</Flex>
);
}
@@ -0,0 +1,14 @@
.guess-background {
animation: moveBackground 180s linear infinite;
}
@keyframes moveBackground {
from {
top: 0;
left: 0;
}
to {
top: -100%;
left: -100%;
}
}
@@ -0,0 +1,81 @@
import { Box } from '@radix-ui/themes';
import { useEffect } from 'react';
import { awaitableModelDefinitions } from '../../../../../../core/awaitables/modelDefinitions';
import { awaitableProvisionDefinitions } from '../../../../../../core/awaitables/provisionDefinitions';
import { tankToDuelMember } from '../../../../../../core/blitzkit/tankToDuelMember';
import { Var } from '../../../../../../core/radix/var';
import { Duel } from '../../../../../../stores/duel';
import { GuessEphemeral } from '../../../../../../stores/guessEphemeral';
import { TankopediaEphemeral } from '../../../../../../stores/tankopediaEphemeral';
import './index.css';
const [modelDefinitions, provisionDefinitions] = await Promise.all([
awaitableModelDefinitions,
awaitableProvisionDefinitions,
]);
export function Background() {
const guessState = GuessEphemeral.use((state) => state.guessState);
const tank = GuessEphemeral.use((state) => state.tank);
const mutateDuel = Duel.useMutation();
const mutateTankopediaEphemeral = TankopediaEphemeral.useMutation();
useEffect(() => {
mutateDuel((state) => {
const model = modelDefinitions.models[tank.id];
state.protagonist = tankToDuelMember(tank, model, provisionDefinitions);
});
mutateTankopediaEphemeral((state) => {
state.model = modelDefinitions.models[tank.id];
});
}, [tank]);
return (
<>
<Box
position="absolute"
width="100%"
height="100%"
top="0"
left="0"
style={{
transitionDuration: '2s',
backgroundColor: Var(
guessState === null
? 'accent-2'
: guessState
? 'jade-3'
: 'tomato-3',
),
}}
>
<Box
width="100%"
height="100%"
style={{
background: `radial-gradient(circle, ${Var('color-panel-translucent')}, ${Var('color-panel-solid')})`,
}}
/>
</Box>
<Box
className="guess-background"
position="absolute"
width="200%"
height="200%"
top="0"
left="0"
style={{
backgroundImage: `
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%)
`,
backgroundSize: '4rem 4rem',
}}
/>
</>
);
}
@@ -0,0 +1,30 @@
import { Canvas } from '@react-three/fiber';
import { Controls } from '../../../../../components/Tankopedia/HeroSection/components/TankSandbox/components/Control';
import { Lighting } from '../../../../../components/Tankopedia/HeroSection/components/TankSandbox/components/Lighting';
import { TankModel } from '../../../../../components/Tankopedia/HeroSection/components/TankSandbox/components/TankModel';
import { GuessEphemeral } from '../../../../../stores/guessEphemeral';
import { TankopediaDisplay } from '../../../../../stores/tankopediaPersistent/constants';
const BRIGHTNESS = 2 ** -5;
const CONTRAST = 2 ** 2;
export function GuessRenderer() {
const guessState = GuessEphemeral.use((state) => state.guessState);
const isRevealed = guessState !== null;
return (
<Canvas
style={{
filter: `grayscale(${isRevealed ? 0 : 1}) contrast(${isRevealed ? 1 : CONTRAST}) brightness(${isRevealed ? 1 : BRIGHTNESS})`,
// filter: `drop-shadow(0 0 1rem ${Var('black-a11')})`,
transitionDuration: isRevealed ? '2s' : undefined,
transitionProperty: 'filter',
}}
camera={{ position: [-5, 5, -5] }}
>
<Controls distanceScale={2} zoomable={false} />
<Lighting display={TankopediaDisplay.Model} />
<TankModel />
</Canvas>
);
}
@@ -0,0 +1,14 @@
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,210 @@
import {
SEARCH_KEYS,
TankDefinition,
TIER_ROMAN_NUMERALS,
} from '@blitzkit/core';
import { literals } from '@blitzkit/i18n';
import {
ArrowRightIcon,
MagnifyingGlassIcon,
PaperPlaneIcon,
} from '@radix-ui/react-icons';
import {
Box,
Button,
Card,
Flex,
Spinner,
Text,
TextField,
} from '@radix-ui/themes';
import fuzzysort from 'fuzzysort';
import { debounce } from 'lodash-es';
import { useCallback, useEffect, useRef, useState } from 'react';
import { classIcons } from '../../../../../components/ClassIcon';
import { SearchResults } from '../../../../../components/SearchResults';
import { awaitableTankDefinitions } from '../../../../../core/awaitables/tankDefinitions';
import { awaitableTankNames } from '../../../../../core/awaitables/tankNames';
import { useLocale } from '../../../../../hooks/useLocale';
import { GuessEphemeral } from '../../../../../stores/guessEphemeral';
const { go } = fuzzysort;
const [tankNames, tankDefinitions] = await Promise.all([
awaitableTankNames,
awaitableTankDefinitions,
]);
const ids = Object.keys(tankDefinitions.tanks);
export function Guesser() {
const tank = GuessEphemeral.use((state) => state.tank);
const guessState = GuessEphemeral.use((state) => state.guessState);
const correctGuesses = GuessEphemeral.use((state) => state.correctGuesses);
const totalGuesses = GuessEphemeral.use((state) => state.totalGuesses);
const { strings, unwrap } = useLocale();
const input = useRef<HTMLInputElement>(null);
const [searching, setSearching] = useState(false);
const [results, setResults] = useState<TankDefinition[] | null>(null);
const [selected, setSelected] = useState<TankDefinition | null>(null);
const mutateGuessEphemeral = GuessEphemeral.useMutation();
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 !== null) setSelected(null);
}, [guessState]);
return (
<Flex
direction="column"
position="absolute"
bottom="0"
left="50%"
style={{ transform: 'translateX(-50%)' }}
width="100%"
p="4"
maxWidth="25rem"
gap="4"
>
{results !== null && (
<Card variant="classic">
<Box py="2" px="3">
{results.length === 0 && (
<Flex justify="center">
<Text color="gray">
{strings.website.tools.guess.search.no_results}
</Text>
</Flex>
)}
<SearchResults.Root>
{results.map((result) => {
const Icon = classIcons[result.class];
return (
<SearchResults.Item
onClick={() => {
if (!input.current) return;
input.current.value = unwrap(result.name);
setSelected(result);
setResults(null);
}}
text={unwrap(result.name)}
discriminator={
<Flex
align="center"
gap="1"
width="38px"
justify="center"
>
<Icon width="1em" height="1em" />
{TIER_ROMAN_NUMERALS[result.tier]}
</Flex>
}
/>
);
})}
</SearchResults.Root>
</Box>
</Card>
)}
<Flex justify="center">
<Text>
{literals(strings.website.tools.guess.stats, [
`${correctGuesses}`,
`${totalGuesses}`,
])}
</Text>
</Flex>
<Flex gap="3">
<TextField.Root
disabled={guessState !== null}
ref={input}
onChange={requestSearch}
style={{ flex: 1 }}
placeholder={strings.website.tools.guess.search.placeholder}
size="3"
variant="classic"
>
<TextField.Slot>
{searching ? <Spinner /> : <MagnifyingGlassIcon />}
</TextField.Slot>
</TextField.Root>
<Button
size="3"
color={guessState === null && selected === null ? 'red' : undefined}
onClick={() => {
if (guessState === null) {
const correct = selected !== null && selected.id === tank.id;
mutateGuessEphemeral((draft) => {
draft.guessState = correct;
draft.totalGuesses++;
draft.correctGuesses += correct ? 1 : 0;
});
} else {
const id = Number(ids[Math.floor(Math.random() * ids.length)]);
const tank = tankDefinitions.tanks[id];
mutateGuessEphemeral((draft) => {
if (!input.current) return;
draft.tank = tank;
draft.guessState = null;
input.current.value = '';
});
}
}}
>
{guessState === null ? (
<>
{
strings.website.tools.guess.search[
selected === null ? 'skip' : 'guess'
]
}
{selected === null ? <ArrowRightIcon /> : <PaperPlaneIcon />}
</>
) : (
<>
{strings.website.tools.guess.search.next}
<ArrowRightIcon />
</>
)}
</Button>
</Flex>
</Flex>
);
}
@@ -0,0 +1,15 @@
---
import { PageWrapper } from '../../../../components/PageWrapper';
import BaseLayout from '../../../../layouts/BaseLayout.astro';
import { Page } from './_index';
export { getStaticPaths } from '../../index.astro';
const { locale } = Astro.params;
---
<BaseLayout noFooter>
<PageWrapper p="0" maxWidth="unset" color="cyan">
<Page {locale} client:load />
</PageWrapper>
</BaseLayout>
@@ -46,8 +46,8 @@ export function Page({ id, skeleton, locale }: PageProps) {
>
<PageWrapper p="0" maxWidth="unset" color="purple" gap="9" pb="9">
<HeroSection skeleton={skeleton} />
<CalloutsSection />
<MetaSection />
<CalloutsSection />
{tank.type === TankType.RESEARCHABLE && !tank.deprecated && (
<TechTreeSection skeleton={skeleton} />
)}
@@ -107,7 +107,7 @@ const description = literals(
<meta name="twitter:card" content="summary" />
<meta name="twitter:image:src" content={icon} />
<BaseLayout>
<BaseLayout integrated>
<Page {locale} id={id} client:only="react">
<Page {locale} id={id} skeleton slot="fallback" />
</Page>