diff --git a/packages/i18n/strings/en.json b/packages/i18n/strings/en.json index 21eb34910..8d26e139c 100644 --- a/packages/i18n/strings/en.json +++ b/packages/i18n/strings/en.json @@ -2,7 +2,7 @@ "guesser": { "help": { "title": "Partially reveal tank?", - "description": "Reveals the tank without showing the name. This round will be considered incorrect and break your streak!", + "description": "Reveals the tank without showing the name. This round will be considered incorrect!", "cancel": "Cancel", "reveal": "Reveal" }, @@ -14,6 +14,7 @@ }, "stats": "{correct} / {total} correct, {streak} streak", + "stats_untracked": "The score is not tracked when not all tiers are selected", "tier": "Tier {tier}", "select_all": "Select all", "clear": "Clear" diff --git a/packages/website/src/components/AlertDialog/index.module.css b/packages/website/src/components/AlertDialog/index.module.css index 9af9f452c..00309474a 100644 --- a/packages/website/src/components/AlertDialog/index.module.css +++ b/packages/website/src/components/AlertDialog/index.module.css @@ -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); } diff --git a/packages/website/src/components/AlertDialog/index.tsx b/packages/website/src/components/AlertDialog/index.tsx index c06176a16..0845f9395 100644 --- a/packages/website/src/components/AlertDialog/index.tsx +++ b/packages/website/src/components/AlertDialog/index.tsx @@ -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
; +} + +export const AlertDialog = { ...composeRadixComponents(_, styles), Actions }; diff --git a/packages/website/src/components/BaseStyles.astro b/packages/website/src/components/BaseStyles.astro index 2cfaf75e1..00b37a559 100644 --- a/packages/website/src/components/BaseStyles.astro +++ b/packages/website/src/components/BaseStyles.astro @@ -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; } diff --git a/packages/website/src/components/Button/index.module.css b/packages/website/src/components/Button/index.module.css index 3dbfe4819..e9969a087 100644 --- a/packages/website/src/components/Button/index.module.css +++ b/packages/website/src/components/Button/index.module.css @@ -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), diff --git a/packages/website/src/components/DropdownMenu/index.module.css b/packages/website/src/components/DropdownMenu/index.module.css index 562fd585e..9e7821b7e 100644 --- a/packages/website/src/components/DropdownMenu/index.module.css +++ b/packages/website/src/components/DropdownMenu/index.module.css @@ -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) ); diff --git a/packages/website/src/components/GuessBackground/index.module.css b/packages/website/src/components/GuessBackground/index.module.css deleted file mode 100644 index deb5d3d75..000000000 --- a/packages/website/src/components/GuessBackground/index.module.css +++ /dev/null @@ -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%; - } -} diff --git a/packages/website/src/components/GuessBackground/index.tsx b/packages/website/src/components/GuessBackground/index.tsx deleted file mode 100644 index 039dfb406..000000000 --- a/packages/website/src/components/GuessBackground/index.tsx +++ /dev/null @@ -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 ( - <> -
-
-
- -
- - ); -} diff --git a/packages/website/src/components/GuessRenderer.tsx b/packages/website/src/components/GuessRenderer.tsx deleted file mode 100644 index 6bbbe3310..000000000 --- a/packages/website/src/components/GuessRenderer.tsx +++ /dev/null @@ -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 ( - - - - - - - ); -} diff --git a/packages/website/src/components/GuessRenderer/index.module.css b/packages/website/src/components/GuessRenderer/index.module.css new file mode 100644 index 000000000..e69de29bb diff --git a/packages/website/src/components/GuessRenderer/index.tsx b/packages/website/src/components/GuessRenderer/index.tsx new file mode 100644 index 000000000..c1f72ac7d --- /dev/null +++ b/packages/website/src/components/GuessRenderer/index.tsx @@ -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 ( + + + + + ); +} diff --git a/packages/website/src/components/GuessRendererLoader.tsx b/packages/website/src/components/GuessRendererLoader.tsx deleted file mode 100644 index da9479e91..000000000 --- a/packages/website/src/components/GuessRendererLoader.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { Box, Spinner } from '@radix-ui/themes'; - -export function GuessRendererLoader() { - return ( - - - - ); -} diff --git a/packages/website/src/components/GuessRendererLoader/index.module.css b/packages/website/src/components/GuessRendererLoader/index.module.css new file mode 100644 index 000000000..2fe3d1cd2 --- /dev/null +++ b/packages/website/src/components/GuessRendererLoader/index.module.css @@ -0,0 +1,7 @@ +.loader { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + font-size: 2rem; +} diff --git a/packages/website/src/components/GuessRendererLoader/index.tsx b/packages/website/src/components/GuessRendererLoader/index.tsx new file mode 100644 index 000000000..cc9ffd80c --- /dev/null +++ b/packages/website/src/components/GuessRendererLoader/index.tsx @@ -0,0 +1,10 @@ +import { Spinner } from "../Spinner"; +import styles from "./index.module.css"; + +export function GuessRendererLoader() { + return ( +
+ +
+ ); +} diff --git a/packages/website/src/components/Guesser.tsx b/packages/website/src/components/Guesser.tsx deleted file mode 100644 index f2c2237a9..000000000 --- a/packages/website/src/components/Guesser.tsx +++ /dev/null @@ -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(null); - const [searching, setSearching] = useState(false); - const [results, setResults] = useState(null); - const [selected, setSelected] = useState(null); - const tiers = Guess.use((state) => state.tiers); - const status = useRef(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 ( -
-
- {results !== null && ( - -
- {results.length === 0 && ( -
- {strings.common.search.no_results} -
- )} - - - {results.map((result) => { - return ( - { - if (!input.current) return; - - input.current.value = unwrap(result.name!); - setSelected(result); - setResults(null); - }} - text={unwrap(result.name!)} - prefix={ - - } - discriminator={ -
- - {romanize(result.tier)} -
- } - /> - ); - })} -
-
-
- )} -
- -
- -
- -
- - - - - - - - - {TIERS.map((tier) => { - const isSelected = tiers.includes(tier); - - return ( - { - 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), - })} - - ); - })} - - - - { - event.preventDefault(); - - Guess.mutate((draft) => { - draft.tiers = Guess.initial.tiers; - }); - }} - > - {strings.guesser.select_all} - - - { - event.preventDefault(); - - Guess.mutate((draft) => { - draft.tiers = []; - }); - }} - > - {strings.guesser.clear} - - - - - - {searching ? : } - - - - - - - - - - - {strings.guesser.help.title} - - {strings.guesser.help.description} - - -
- - - - - - - -
-
-
- - -
-
- ); -} diff --git a/packages/website/src/components/Guesser/index.module.css b/packages/website/src/components/Guesser/index.module.css new file mode 100644 index 000000000..760f653f2 --- /dev/null +++ b/packages/website/src/components/Guesser/index.module.css @@ -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; + } + } +} diff --git a/packages/website/src/components/Guesser/index.tsx b/packages/website/src/components/Guesser/index.tsx new file mode 100644 index 000000000..4daea5f81 --- /dev/null +++ b/packages/website/src/components/Guesser/index.tsx @@ -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((resolve) => { + function done() { + clearTimeout(fallback); + transitionEvent.off(handleTransition); + resolve(); + } + + function handleTransition(event: QuicklimeEvent) { + 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(null); + const [results, setResults] = useState(null); + const [selected, setSelected] = useState(null); + const tiers = Guess.use((state) => state.tiers); + const status = useRef(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 ( +
+
+ {results !== null && ( + + {results.length === 0 && ( +
+ {strings.common.search.no_results} +
+ )} + +
+ {results.map((result) => { + return ( + + ); + })} +
+
+ )} +
+ + + +
+ + + + + + + + + {TIERS.map((tier) => { + const isSelected = tiers.includes(tier); + + return ( + { + 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]; + } + }); + }} + > + + + + + {literals(strings.guesser.tier, { + tier: romanize(tier), + })} + + ); + })} + + + + { + event.preventDefault(); + + Guess.mutate((draft) => { + draft.tiers = Guess.initial.tiers; + }); + }} + > + {strings.guesser.select_all} + + + + { + event.preventDefault(); + + Guess.mutate((draft) => { + // at least one tier must stay selected + draft.tiers = [Math.max(...TIERS)]; + }); + }} + > + {strings.guesser.clear} + + + + + + + + + + + + + + + + + {strings.guesser.help.title} + + + {strings.guesser.help.description} + + + + + + + + + + + + + + + + + + + + +
+
+ ); +} diff --git a/packages/website/src/components/IconButton/index.module.css b/packages/website/src/components/IconButton/index.module.css index 1e31935de..aa502299d 100644 --- a/packages/website/src/components/IconButton/index.module.css +++ b/packages/website/src/components/IconButton/index.module.css @@ -1,6 +1,6 @@ .icon-button { &[data-size="regular"] { - padding: var(--space-2); + padding: var(--space-3); } &[data-size="major"] { diff --git a/packages/website/src/components/Lighting.tsx b/packages/website/src/components/Lighting.tsx index d44bb12af..0f89a0da4 100644 --- a/packages/website/src/components/Lighting.tsx +++ b/packages/website/src/components/Lighting.tsx @@ -39,16 +39,17 @@ export const transitionEvent = new Quicklime(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(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; diff --git a/packages/website/src/components/SceneProps/index.tsx b/packages/website/src/components/SceneProps/index.tsx index 49acf7566..e4be4006c 100644 --- a/packages/website/src/components/SceneProps/index.tsx +++ b/packages/website/src/components/SceneProps/index.tsx @@ -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(null!); useEffect(() => { @@ -79,7 +83,7 @@ export function SceneProps() { > diff --git a/packages/website/src/components/SmartCanvas.tsx b/packages/website/src/components/SmartCanvas.tsx deleted file mode 100644 index 6dd23e46b..000000000 --- a/packages/website/src/components/SmartCanvas.tsx +++ /dev/null @@ -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( - (props, ref) => { - const canvas = useRef(null!); - const onScreen = useOnScreen(canvas); - - useImperativeHandle(ref, () => canvas.current!, []); - - return ( - - ); - } -); diff --git a/packages/website/src/components/SmartCanvas/index.module.css b/packages/website/src/components/SmartCanvas/index.module.css new file mode 100644 index 000000000..92c88e259 --- /dev/null +++ b/packages/website/src/components/SmartCanvas/index.module.css @@ -0,0 +1,4 @@ +.canvas { + position: absolute; + inset: 0; +} diff --git a/packages/website/src/components/SmartCanvas/index.tsx b/packages/website/src/components/SmartCanvas/index.tsx new file mode 100644 index 000000000..1d1afe769 --- /dev/null +++ b/packages/website/src/components/SmartCanvas/index.tsx @@ -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(null!); + const onScreen = useOnScreen(canvas); + + return ( +
+
+ +
+
+ ); +} diff --git a/packages/website/src/components/Spinner/index.module.css b/packages/website/src/components/Spinner/index.module.css index e69de29bb..22d235eab 100644 --- a/packages/website/src/components/Spinner/index.module.css +++ b/packages/website/src/components/Spinner/index.module.css @@ -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); + } +} diff --git a/packages/website/src/components/Spinner/index.tsx b/packages/website/src/components/Spinner/index.tsx index 67ffe51d5..16ade5829 100644 --- a/packages/website/src/components/Spinner/index.tsx +++ b/packages/website/src/components/Spinner/index.tsx @@ -1,3 +1,5 @@ +import styles from "./index.module.css"; + export function Spinner() { - return null; + return ; } diff --git a/packages/website/src/components/TankModel.tsx b/packages/website/src/components/TankModel.tsx index e1b3a8102..17c2dc3fc 100644 --- a/packages/website/src/components/TankModel.tsx +++ b/packages/website/src/components/TankModel.tsx @@ -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(); + const materials = useMemo(() => { + const collected = new Set(); 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) { - 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 ( <> diff --git a/packages/website/src/components/TankopediaSandbox/index.tsx b/packages/website/src/components/TankopediaSandbox/index.tsx index 8913439af..23277633a 100644 --- a/packages/website/src/components/TankopediaSandbox/index.tsx +++ b/packages/website/src/components/TankopediaSandbox/index.tsx @@ -41,7 +41,6 @@ export function TankopediaSandbox({ antagonistCharacteristics, }: TankSandboxProps) { const wrapper = useRef(null); - const canvas = useRef(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({ state.revealed); const hideTankModelUnderArmor = TankopediaPersistent.use( - (state) => state.hideTankModelUnderArmor + (state) => state.hideTankModelUnderArmor, ); return ( <> - +