feat: some guessing mechanics

This commit is contained in:
tresabhi committed 2025-05-04 21:02:50 -05:00
1 parent 46441d74d8
commit 232f24181f
10 files changed
+265 -76

No files matched your search

BIN
View File
Binary file not shown.
+3 -2
View File
@@ -40,9 +40,10 @@
}, },
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"sharp": "^0.33.5",
"googleapis": "^144.0.0", "googleapis": "^144.0.0",
"lodash-es": "^4.17.21" "lodash-es": "^4.17.21",
"quicklime": "^1.2.0",
"sharp": "^0.33.5"
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "latest", "@types/bun": "latest",
+6 -1
View File
@@ -857,7 +857,12 @@
"name": "Guess the tank", "name": "Guess the tank",
"button": "Guess away", "button": "Guess away",
"description": "Silhouette mini-game", "description": "Silhouette mini-game",
"seo_description": "Play a tank guessing game with a silhouette in World of Tanks Blitz" "seo_description": "Play a tank guessing game with a silhouette in World of Tanks Blitz",
"search": {
"placeholder": "Type to guess...",
"button": "Guess",
"no_results": "No results"
}
}, },
"tankopedia": { "tankopedia": {
@@ -6,7 +6,6 @@ import {
import { literals } from '@blitzkit/i18n/src/literals'; import { literals } from '@blitzkit/i18n/src/literals';
import { CaretLeftIcon, Link2Icon, PersonIcon } from '@radix-ui/react-icons'; import { CaretLeftIcon, Link2Icon, PersonIcon } from '@radix-ui/react-icons';
import { import {
Badge,
Button, Button,
Dialog, Dialog,
Flex, Flex,
@@ -22,6 +21,7 @@ import type { embedConfigurations } from '../../constants/embeds';
import { useLocale } from '../../hooks/useLocale'; import { useLocale } from '../../hooks/useLocale';
import { EmbedState, type EmbedStateStore } from '../../stores/embedState'; import { EmbedState, type EmbedStateStore } from '../../stores/embedState';
import { CopyButton } from '../CopyButton'; import { CopyButton } from '../CopyButton';
import { SearchResults } from '../SearchResults';
interface GenerateURLProps { interface GenerateURLProps {
embed: keyof typeof embedConfigurations; embed: keyof typeof embedConfigurations;
@@ -118,25 +118,19 @@ export function GenerateURL({ embed }: GenerateURLProps) {
)} )}
{!searching && results.length > 0 && ( {!searching && results.length > 0 && (
<Flex direction="column" gap="2" width="100%"> <SearchResults.Root>
{results.map((result) => ( {results.map((result) => (
<Button <SearchResults.Item
variant="ghost" text={result.nickname}
discriminator={strings.common.regions.short[result.region]}
onClick={() => { onClick={() => {
setCopyUser(result); setCopyUser(result);
setCopyDialogOpen(true); setCopyDialogOpen(true);
setSearchDialogOpen(false); setSearchDialogOpen(false);
}} }}
> />
<Flex justify="between" width="100%" gap="2">
{result.nickname}
<Badge>
{strings.common.regions.short[result.region]}
</Badge>
</Flex>
</Button>
))} ))}
</Flex> </SearchResults.Root>
)} )}
</Flex> </Flex>
@@ -0,0 +1,34 @@
import {
Badge,
Button,
Flex,
type ButtonProps,
type FlexProps,
} from '@radix-ui/themes';
import type { ReactNode } from 'react';
function Root(props: FlexProps) {
return <Flex direction="column" gap="2" width="100%" {...props} />;
}
interface ItemProps extends ButtonProps {
text: string;
discriminator?: ReactNode;
}
function Item({ text, discriminator, ...props }: ItemProps) {
return (
<Button variant="ghost" {...props} highContrast>
<Flex
justify={discriminator === undefined ? 'center' : 'between'}
width="100%"
gap="2"
>
{text}
{discriminator !== undefined && <Badge>{discriminator}</Badge>}
</Flex>
</Button>
);
}
export const SearchResults = { Root, Item };
@@ -36,6 +36,7 @@ interface ControlsProps {
autoRotate?: boolean; autoRotate?: boolean;
zoomable?: boolean; zoomable?: boolean;
enableRotate?: boolean; enableRotate?: boolean;
distanceScale?: number;
} }
export function Controls({ export function Controls({
@@ -43,6 +44,7 @@ export function Controls({
autoRotate = true, autoRotate = true,
zoomable = true, zoomable = true,
enableRotate = true, enableRotate = true,
distanceScale = 1,
}: ControlsProps) { }: ControlsProps) {
const mutateTankopediaEphemeral = TankopediaEphemeral.useMutation(); const mutateTankopediaEphemeral = TankopediaEphemeral.useMutation();
const display = TankopediaEphemeral.use((state) => state.display); const display = TankopediaEphemeral.use((state) => state.display);
@@ -94,11 +96,11 @@ export function Controls({
protagonistHullOrigin.y + protagonistHullOrigin.y +
protagonistTurretOrigin.y + protagonistTurretOrigin.y +
protagonistGunOrigin.y; protagonistGunOrigin.y;
const inspectModeInitialPosition = [ const inspectModeInitialPosition = new Vector3(
-8, -8,
gunHeight + (naked ? 10 : 2), gunHeight + (naked ? 10 : 2),
-13, -13,
] as const; ).multiplyScalar(distanceScale);
const protagonistGunOriginOnlyY = new Vector3( const protagonistGunOriginOnlyY = new Vector3(
0, 0,
protagonistTurretModelDefinition.gun_origin.y, protagonistTurretModelDefinition.gun_origin.y,
@@ -203,7 +205,7 @@ export function Controls({
} }
case Pose.Default: { case Pose.Default: {
camera.position.set(...inspectModeInitialPosition); camera.position.copy(inspectModeInitialPosition);
orbitControls.current?.target.set(0, 1.25, 0); orbitControls.current?.target.set(0, 1.25, 0);
break; break;
} }
@@ -312,7 +314,7 @@ export function Controls({
.add(shellOrigin); .add(shellOrigin);
} }
} else { } else {
camera.position.set(...inspectModeInitialPosition); camera.position.copy(inspectModeInitialPosition);
orbitControls.current.target.set(0, gunHeight / (naked ? 4 : 2), 0); orbitControls.current.target.set(0, gunHeight / (naked ? 4 : 2), 0);
orbitControls.current.enablePan = true; orbitControls.current.enablePan = true;
orbitControls.current.enableZoom = true; orbitControls.current.enableZoom = true;
@@ -3,9 +3,14 @@ import { awaitableModelDefinitions } from '../../../../core/awaitables/modelDefi
import { awaitableProvisionDefinitions } from '../../../../core/awaitables/provisionDefinitions'; import { awaitableProvisionDefinitions } from '../../../../core/awaitables/provisionDefinitions';
import { awaitableTankDefinitions } from '../../../../core/awaitables/tankDefinitions'; import { awaitableTankDefinitions } from '../../../../core/awaitables/tankDefinitions';
import { Var } from '../../../../core/radix/var'; import { Var } from '../../../../core/radix/var';
import {
LocaleProvider,
type LocaleAcceptorProps,
} from '../../../../hooks/useLocale';
import { Duel } from '../../../../stores/duel'; import { Duel } from '../../../../stores/duel';
import { TankopediaEphemeral } from '../../../../stores/tankopediaEphemeral'; import { TankopediaEphemeral } from '../../../../stores/tankopediaEphemeral';
import { GuessRenderer } from './components/GuessRenderer'; import { GuessRenderer } from './components/GuessRenderer';
import { Guesser } from './components/Guesser';
const [tankDefinitions, modelDefinitions, provisionDefinitions] = const [tankDefinitions, modelDefinitions, provisionDefinitions] =
await Promise.all([ await Promise.all([
@@ -16,17 +21,19 @@ const [tankDefinitions, modelDefinitions, provisionDefinitions] =
const ids = Object.keys(tankDefinitions.tanks); const ids = Object.keys(tankDefinitions.tanks);
export function Page() { export function Page({ locale }: LocaleAcceptorProps) {
const id = Number(ids[Math.floor(Math.random() * ids.length)]); const id = Number(ids[Math.floor(Math.random() * ids.length)]);
const tank = tankDefinitions.tanks[id]; const tank = tankDefinitions.tanks[id];
const model = modelDefinitions.models[id]; const model = modelDefinitions.models[id];
return ( return (
<TankopediaEphemeral.Provider data={model}> <LocaleProvider locale={locale}>
<Duel.Provider data={{ tank, model, provisionDefinitions }}> <TankopediaEphemeral.Provider data={model}>
<Content /> <Duel.Provider data={{ tank, model, provisionDefinitions }}>
</Duel.Provider> <Content />
</TankopediaEphemeral.Provider> </Duel.Provider>
</TankopediaEphemeral.Provider>
</LocaleProvider>
); );
} }
@@ -39,54 +46,31 @@ function Content() {
backgroundColor: Var('cyan-3'), backgroundColor: Var('cyan-3'),
}} }}
> >
<Flex flexGrow="2" position="relative"> <Box
<Box position="absolute"
position="absolute" width="100%"
width="100%" height="100%"
height="100%" top="0"
top="0" left="0"
left="0" style={{
style={ backgroundImage: `
{
// backgroundImage:
// 'url(https://images.pexels.com/photos/3052361/pexels-photo-3052361.jpeg)',
// backgroundSize: 'cover',
// backgroundPosition: 'center',
}
}
>
<Box
position="absolute"
width="100%"
height="100%"
top="0"
left="0"
style={{
backgroundImage: `
radial-gradient(circle, ${Var('white-a1')} 20%, transparent 20%), 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 0 0, ${Var('white-a1')} 10%, transparent 10%),
radial-gradient(circle at 100% 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 0 100%, ${Var('white-a1')} 10%, transparent 10%),
radial-gradient(circle at 100% 100%, ${Var('white-a1')} 10%, transparent 10%) radial-gradient(circle at 100% 100%, ${Var('white-a1')} 10%, transparent 10%)
`, `,
backgroundSize: '4rem 4rem', backgroundSize: '4rem 4rem',
// background: `radial-gradient(circle, ${Var('cyan-a1')} 15%, ${Var('cyan-a3')}, ${Var('cyan-a1')}, ${Var('gray-1')} 50%)`, // background: `radial-gradient(circle, ${Var('cyan-a1')} 15%, ${Var('cyan-a3')}, ${Var('cyan-a1')}, ${Var('gray-1')} 50%)`,
// background: `linear-gradient(90deg, ${Var('black-a3')}, ${Var('black-a9')} 66%)`, // background: `linear-gradient(90deg, ${Var('black-a3')}, ${Var('black-a9')} 66%)`,
}}
/>
<GuessRenderer />
</Box>
</Flex>
<Flex
flexGrow="1"
style={{
background: Var('color-panel-solid'),
}} }}
> />
b
</Flex> <Box position="absolute" width="100%" height="100%" top="0" left="0">
<GuessRenderer />
</Box>
<Guesser />
</Flex> </Flex>
); );
} }
@@ -1,4 +1,5 @@
import { Canvas, invalidate } from '@react-three/fiber'; import { Canvas, invalidate } from '@react-three/fiber';
import { type QuicklimeCallback, Quicklime } from 'quicklime';
import { useCallback, useEffect, useRef } from 'react'; import { useCallback, useEffect, useRef } from 'react';
import { Fog } from 'three'; import { Fog } from 'three';
import { Controls } from '../../../../../components/Tankopedia/HeroSection/components/TankSandbox/components/Control'; import { Controls } from '../../../../../components/Tankopedia/HeroSection/components/TankSandbox/components/Control';
@@ -7,15 +8,17 @@ import { TankModel } from '../../../../../components/Tankopedia/HeroSection/comp
import { Var } from '../../../../../core/radix/var'; import { Var } from '../../../../../core/radix/var';
import { TankopediaDisplay } from '../../../../../stores/tankopediaPersistent/constants'; import { TankopediaDisplay } from '../../../../../stores/tankopediaPersistent/constants';
const nearConcealed = -40; const nearConcealed = -45;
const farConcealed = 22; const farConcealed = 22;
const nearRevealed = 20; const nearRevealed = 25;
const farRevealed = 22; const farRevealed = 25;
const animationDuration = 1; const animationDuration = 1;
export const revealEvent = new Quicklime<boolean>();
export function GuessRenderer() { export function GuessRenderer() {
const fog = useRef(new Fog('black', nearConcealed, farConcealed)); const fog = useRef(new Fog('black', nearConcealed, farConcealed));
const reveal = useCallback(() => { const reveal = useCallback((show: boolean) => {
const t0 = Date.now() / 1000; const t0 = Date.now() / 1000;
function frame() { function frame() {
@@ -30,14 +33,27 @@ export function GuessRenderer() {
invalidate(); invalidate();
if (y < 1) requestAnimationFrame(frame); if (y < 1) {
requestAnimationFrame(frame);
} else {
fog.current.near = nearRevealed;
fog.current.far = farRevealed;
}
} }
requestAnimationFrame(frame); requestAnimationFrame(frame);
}, []); }, []);
useEffect(() => { useEffect(() => {
setTimeout(reveal, 2000); const handleRevealEvent: QuicklimeCallback<boolean> = ({ data }) => {
reveal(data);
};
revealEvent.on(handleRevealEvent);
return () => {
revealEvent.off(handleRevealEvent);
};
}, []); }, []);
return ( return (
@@ -48,7 +64,7 @@ export function GuessRenderer() {
camera={{ position: [-5, 5, -5] }} camera={{ position: [-5, 5, -5] }}
scene={{ fog: fog.current }} scene={{ fog: fog.current }}
> >
<Controls zoomable={false} /> <Controls distanceScale={2} zoomable={false} />
<Lighting display={TankopediaDisplay.Model} /> <Lighting display={TankopediaDisplay.Model} />
<TankModel /> <TankModel />
</Canvas> </Canvas>
@@ -0,0 +1,151 @@
import {
SEARCH_KEYS,
TankDefinition,
TIER_ROMAN_NUMERALS,
} from '@blitzkit/core';
import { 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, 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 { revealEvent } from './GuessRenderer';
const { go } = fuzzysort;
const [tankNames, tankDefinitions] = await Promise.all([
awaitableTankNames,
awaitableTankDefinitions,
]);
export function Guesser() {
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 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),
[],
);
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 gap="3">
<TextField.Root
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"
disabled={selected === null}
onClick={() => {
revealEvent.dispatch(true);
}}
>
{strings.website.tools.guess.search.button} <PaperPlaneIcon />
</Button>
</Flex>
</Flex>
);
}
@@ -4,10 +4,12 @@ import BaseLayout from '../../../../layouts/BaseLayout.astro';
import { Page } from './_index'; import { Page } from './_index';
export { getStaticPaths } from '../../index.astro'; export { getStaticPaths } from '../../index.astro';
const { locale } = Astro.params;
--- ---
<BaseLayout noFooter> <BaseLayout noFooter>
<PageWrapper p="0" maxWidth="unset"> <PageWrapper p="0" maxWidth="unset" color="cyan">
<Page client:load /> <Page {locale} client:load />
</PageWrapper> </PageWrapper>
</BaseLayout> </BaseLayout>