mirror of
https://github.com/blitzkit/blitzkit.git
synced 2026-10-21 10:47:04 +02:00
feat: some guessing mechanics
This commit is contained in:
1 parent
46441d74d8
commit
232f24181f
10 files changed
+265
-76
No files matched your search
+3
-2
@@ -40,9 +40,10 @@
|
||||
},
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"sharp": "^0.33.5",
|
||||
"googleapis": "^144.0.0",
|
||||
"lodash-es": "^4.17.21"
|
||||
"lodash-es": "^4.17.21",
|
||||
"quicklime": "^1.2.0",
|
||||
"sharp": "^0.33.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
|
||||
@@ -857,7 +857,12 @@
|
||||
"name": "Guess the tank",
|
||||
"button": "Guess away",
|
||||
"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": {
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
import { literals } from '@blitzkit/i18n/src/literals';
|
||||
import { CaretLeftIcon, Link2Icon, PersonIcon } from '@radix-ui/react-icons';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Dialog,
|
||||
Flex,
|
||||
@@ -22,6 +21,7 @@ import type { embedConfigurations } from '../../constants/embeds';
|
||||
import { useLocale } from '../../hooks/useLocale';
|
||||
import { EmbedState, type EmbedStateStore } from '../../stores/embedState';
|
||||
import { CopyButton } from '../CopyButton';
|
||||
import { SearchResults } from '../SearchResults';
|
||||
|
||||
interface GenerateURLProps {
|
||||
embed: keyof typeof embedConfigurations;
|
||||
@@ -118,25 +118,19 @@ export function GenerateURL({ embed }: GenerateURLProps) {
|
||||
)}
|
||||
|
||||
{!searching && results.length > 0 && (
|
||||
<Flex direction="column" gap="2" width="100%">
|
||||
<SearchResults.Root>
|
||||
{results.map((result) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
<SearchResults.Item
|
||||
text={result.nickname}
|
||||
discriminator={strings.common.regions.short[result.region]}
|
||||
onClick={() => {
|
||||
setCopyUser(result);
|
||||
setCopyDialogOpen(true);
|
||||
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>
|
||||
|
||||
|
||||
@@ -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 };
|
||||
+6
-4
@@ -36,6 +36,7 @@ interface ControlsProps {
|
||||
autoRotate?: boolean;
|
||||
zoomable?: boolean;
|
||||
enableRotate?: boolean;
|
||||
distanceScale?: number;
|
||||
}
|
||||
|
||||
export function Controls({
|
||||
@@ -43,6 +44,7 @@ export function Controls({
|
||||
autoRotate = true,
|
||||
zoomable = true,
|
||||
enableRotate = true,
|
||||
distanceScale = 1,
|
||||
}: ControlsProps) {
|
||||
const mutateTankopediaEphemeral = TankopediaEphemeral.useMutation();
|
||||
const display = TankopediaEphemeral.use((state) => state.display);
|
||||
@@ -94,11 +96,11 @@ export function Controls({
|
||||
protagonistHullOrigin.y +
|
||||
protagonistTurretOrigin.y +
|
||||
protagonistGunOrigin.y;
|
||||
const inspectModeInitialPosition = [
|
||||
const inspectModeInitialPosition = new Vector3(
|
||||
-8,
|
||||
gunHeight + (naked ? 10 : 2),
|
||||
-13,
|
||||
] as const;
|
||||
).multiplyScalar(distanceScale);
|
||||
const protagonistGunOriginOnlyY = new Vector3(
|
||||
0,
|
||||
protagonistTurretModelDefinition.gun_origin.y,
|
||||
@@ -203,7 +205,7 @@ export function Controls({
|
||||
}
|
||||
|
||||
case Pose.Default: {
|
||||
camera.position.set(...inspectModeInitialPosition);
|
||||
camera.position.copy(inspectModeInitialPosition);
|
||||
orbitControls.current?.target.set(0, 1.25, 0);
|
||||
break;
|
||||
}
|
||||
@@ -312,7 +314,7 @@ export function Controls({
|
||||
.add(shellOrigin);
|
||||
}
|
||||
} else {
|
||||
camera.position.set(...inspectModeInitialPosition);
|
||||
camera.position.copy(inspectModeInitialPosition);
|
||||
orbitControls.current.target.set(0, gunHeight / (naked ? 4 : 2), 0);
|
||||
orbitControls.current.enablePan = true;
|
||||
orbitControls.current.enableZoom = true;
|
||||
|
||||
@@ -3,9 +3,14 @@ import { awaitableModelDefinitions } from '../../../../core/awaitables/modelDefi
|
||||
import { awaitableProvisionDefinitions } from '../../../../core/awaitables/provisionDefinitions';
|
||||
import { awaitableTankDefinitions } from '../../../../core/awaitables/tankDefinitions';
|
||||
import { Var } from '../../../../core/radix/var';
|
||||
import {
|
||||
LocaleProvider,
|
||||
type LocaleAcceptorProps,
|
||||
} from '../../../../hooks/useLocale';
|
||||
import { Duel } from '../../../../stores/duel';
|
||||
import { TankopediaEphemeral } from '../../../../stores/tankopediaEphemeral';
|
||||
import { GuessRenderer } from './components/GuessRenderer';
|
||||
import { Guesser } from './components/Guesser';
|
||||
|
||||
const [tankDefinitions, modelDefinitions, provisionDefinitions] =
|
||||
await Promise.all([
|
||||
@@ -16,17 +21,19 @@ const [tankDefinitions, modelDefinitions, provisionDefinitions] =
|
||||
|
||||
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 tank = tankDefinitions.tanks[id];
|
||||
const model = modelDefinitions.models[id];
|
||||
|
||||
return (
|
||||
<TankopediaEphemeral.Provider data={model}>
|
||||
<Duel.Provider data={{ tank, model, provisionDefinitions }}>
|
||||
<Content />
|
||||
</Duel.Provider>
|
||||
</TankopediaEphemeral.Provider>
|
||||
<LocaleProvider locale={locale}>
|
||||
<TankopediaEphemeral.Provider data={model}>
|
||||
<Duel.Provider data={{ tank, model, provisionDefinitions }}>
|
||||
<Content />
|
||||
</Duel.Provider>
|
||||
</TankopediaEphemeral.Provider>
|
||||
</LocaleProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -39,54 +46,31 @@ function Content() {
|
||||
backgroundColor: Var('cyan-3'),
|
||||
}}
|
||||
>
|
||||
<Flex flexGrow="2" position="relative">
|
||||
<Box
|
||||
position="absolute"
|
||||
width="100%"
|
||||
height="100%"
|
||||
top="0"
|
||||
left="0"
|
||||
style={
|
||||
{
|
||||
// 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: `
|
||||
<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 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',
|
||||
// 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%)`,
|
||||
}}
|
||||
/>
|
||||
|
||||
<GuessRenderer />
|
||||
</Box>
|
||||
</Flex>
|
||||
|
||||
<Flex
|
||||
flexGrow="1"
|
||||
style={{
|
||||
background: Var('color-panel-solid'),
|
||||
backgroundSize: '4rem 4rem',
|
||||
// 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%)`,
|
||||
}}
|
||||
>
|
||||
b
|
||||
</Flex>
|
||||
/>
|
||||
|
||||
<Box position="absolute" width="100%" height="100%" top="0" left="0">
|
||||
<GuessRenderer />
|
||||
</Box>
|
||||
|
||||
<Guesser />
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Canvas, invalidate } from '@react-three/fiber';
|
||||
import { type QuicklimeCallback, Quicklime } from 'quicklime';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { Fog } from 'three';
|
||||
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 { TankopediaDisplay } from '../../../../../stores/tankopediaPersistent/constants';
|
||||
|
||||
const nearConcealed = -40;
|
||||
const nearConcealed = -45;
|
||||
const farConcealed = 22;
|
||||
const nearRevealed = 20;
|
||||
const farRevealed = 22;
|
||||
const nearRevealed = 25;
|
||||
const farRevealed = 25;
|
||||
const animationDuration = 1;
|
||||
|
||||
export const revealEvent = new Quicklime<boolean>();
|
||||
|
||||
export function GuessRenderer() {
|
||||
const fog = useRef(new Fog('black', nearConcealed, farConcealed));
|
||||
const reveal = useCallback(() => {
|
||||
const reveal = useCallback((show: boolean) => {
|
||||
const t0 = Date.now() / 1000;
|
||||
|
||||
function frame() {
|
||||
@@ -30,14 +33,27 @@ export function GuessRenderer() {
|
||||
|
||||
invalidate();
|
||||
|
||||
if (y < 1) requestAnimationFrame(frame);
|
||||
if (y < 1) {
|
||||
requestAnimationFrame(frame);
|
||||
} else {
|
||||
fog.current.near = nearRevealed;
|
||||
fog.current.far = farRevealed;
|
||||
}
|
||||
}
|
||||
|
||||
requestAnimationFrame(frame);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(reveal, 2000);
|
||||
const handleRevealEvent: QuicklimeCallback<boolean> = ({ data }) => {
|
||||
reveal(data);
|
||||
};
|
||||
|
||||
revealEvent.on(handleRevealEvent);
|
||||
|
||||
return () => {
|
||||
revealEvent.off(handleRevealEvent);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
@@ -48,7 +64,7 @@ export function GuessRenderer() {
|
||||
camera={{ position: [-5, 5, -5] }}
|
||||
scene={{ fog: fog.current }}
|
||||
>
|
||||
<Controls zoomable={false} />
|
||||
<Controls distanceScale={2} zoomable={false} />
|
||||
<Lighting display={TankopediaDisplay.Model} />
|
||||
<TankModel />
|
||||
</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';
|
||||
|
||||
export { getStaticPaths } from '../../index.astro';
|
||||
|
||||
const { locale } = Astro.params;
|
||||
---
|
||||
|
||||
<BaseLayout noFooter>
|
||||
<PageWrapper p="0" maxWidth="unset">
|
||||
<Page client:load />
|
||||
<PageWrapper p="0" maxWidth="unset" color="cyan">
|
||||
<Page {locale} client:load />
|
||||
</PageWrapper>
|
||||
</BaseLayout>
|
||||
Reference in new issue
Block a user