From 3d8cfc3aeb89adc8fbae330b76a35b3ea2d2e451 Mon Sep 17 00:00:00 2001 From: tresabhi <43380238+tresabhi@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:34:01 -0500 Subject: [PATCH] reload visualizer --- packages/i18n/strings/en.json | 4 + .../reload => media/toys}/background.jpg | Bin .../reload => media/toys}/shell.png | Bin .../src/components/Button/index.module.css | 4 + .../website/src/components/Button/index.tsx | 4 +- .../ReloadVisualizer/components/Shell.tsx | 2 +- .../ReloadVisualizer/components/Target.tsx | 2 +- .../TankopediaToys/index.module.css | 164 +++++++++++ .../src/components/TankopediaToys/index.tsx | 272 +++++++++++++++++- packages/website/src/config/toys.ts | 7 +- 10 files changed, 450 insertions(+), 9 deletions(-) rename packages/website/public/{assets/images/tankopedia/visualizers/reload => media/toys}/background.jpg (100%) rename packages/website/public/{assets/images/tankopedia/visualizers/reload => media/toys}/shell.png (100%) diff --git a/packages/i18n/strings/en.json b/packages/i18n/strings/en.json index 8e2f2fdfa..2c39a8494 100644 --- a/packages/i18n/strings/en.json +++ b/packages/i18n/strings/en.json @@ -33,6 +33,10 @@ "effective": "Effective", "hull": "Hull", "turret": "Turret" + }, + + "reload": { + "skip": "Skip" } }, diff --git a/packages/website/public/assets/images/tankopedia/visualizers/reload/background.jpg b/packages/website/public/media/toys/background.jpg similarity index 100% rename from packages/website/public/assets/images/tankopedia/visualizers/reload/background.jpg rename to packages/website/public/media/toys/background.jpg diff --git a/packages/website/public/assets/images/tankopedia/visualizers/reload/shell.png b/packages/website/public/media/toys/shell.png similarity index 100% rename from packages/website/public/assets/images/tankopedia/visualizers/reload/shell.png rename to packages/website/public/media/toys/shell.png diff --git a/packages/website/src/components/Button/index.module.css b/packages/website/src/components/Button/index.module.css index 5f0b5fc0c..0e135a82d 100644 --- a/packages/website/src/components/Button/index.module.css +++ b/packages/website/src/components/Button/index.module.css @@ -86,5 +86,9 @@ &[data-variant="surface"], &[data-variant="outline"] { outline: var(--space-1) solid var(--accent-6); + + &[data-high-contrast="true"] { + outline: var(--space-1) solid var(--accent-11); + } } } diff --git a/packages/website/src/components/Button/index.tsx b/packages/website/src/components/Button/index.tsx index 7e7121d08..8a3c6a6a8 100644 --- a/packages/website/src/components/Button/index.tsx +++ b/packages/website/src/components/Button/index.tsx @@ -8,7 +8,7 @@ export interface ButtonProps extends ComponentProps<"button"> { radius?: `${1 | 2 | 3}` | "max"; variant?: "solid" | "surface" | "soft" | "inverted" | "ghost" | "outline"; size?: "regular" | "minor" | "major"; - highContrast?: boolean; // TODO: add high contrast + highContrast?: boolean; listChild?: boolean; parentListChild?: boolean; @@ -40,6 +40,7 @@ export function Button({ listChild, parentListChild, disabled, + highContrast, ...props }: ButtonProps) { return ( @@ -52,6 +53,7 @@ export function Button({ data-size={size} data-list-child={listChild} data-parent-list-child={parentListChild} + data-high-contrast={highContrast} data-disabled={disabled} disabled={disabled} {...props} diff --git a/packages/website/src/components/Tankopedia/CharacteristicsSection/components/Characteristics/components/ReloadVisualizer/components/Shell.tsx b/packages/website/src/components/Tankopedia/CharacteristicsSection/components/Characteristics/components/ReloadVisualizer/components/Shell.tsx index c06e38136..acf06f4ac 100644 --- a/packages/website/src/components/Tankopedia/CharacteristicsSection/components/Characteristics/components/ReloadVisualizer/components/Shell.tsx +++ b/packages/website/src/components/Tankopedia/CharacteristicsSection/components/Characteristics/components/ReloadVisualizer/components/Shell.tsx @@ -69,7 +69,7 @@ export function Shell({ index, stats }: ShellProps & StatsAcceptorProps) { borderRadius: '0.25rem', overflow: 'hidden', maskImage: - 'url(/assets/images/tankopedia/visualizers/reload/shell.png)', + 'url(/media/toys/shell.png)', maskSize: 'contain', maskRepeat: 'no-repeat', maskPosition: 'center', diff --git a/packages/website/src/components/Tankopedia/CharacteristicsSection/components/Characteristics/components/ReloadVisualizer/components/Target.tsx b/packages/website/src/components/Tankopedia/CharacteristicsSection/components/Characteristics/components/ReloadVisualizer/components/Target.tsx index 8e3b76920..adec818f9 100644 --- a/packages/website/src/components/Tankopedia/CharacteristicsSection/components/Characteristics/components/ReloadVisualizer/components/Target.tsx +++ b/packages/website/src/components/Tankopedia/CharacteristicsSection/components/Characteristics/components/ReloadVisualizer/components/Target.tsx @@ -5,7 +5,7 @@ export function Target() { -
+
); } + +const RELOAD_PRECISION = 1; + +interface ReloadData { + reloads: number[]; + shells: number; + intraClip: number; + burstShells: number; + burstInterShell?: number; +} + +export function ReloadToy({ characteristics }: ToyProps) { + const gun = useDuel("protagonist", DuelModule.Gun); + const strings = useStrings(); + + const reloadData = useMemo(() => { + const reloadCoefficient = characteristics.get( + "_reload_coefficient", + ) as number; + const intraClip = + gun.gun_type!.$case === "regular" + ? 0 + : (characteristics.get("intra_clip") as number); + + let reloads: number[]; + let shells: number; + + switch (gun.gun_type!.$case) { + case "regular": + reloads = [characteristics.get("reload") as number]; + shells = 1; + break; + + case "auto_loader": + reloads = [characteristics.get("reload") as number]; + shells = gun.gun_type!.value.shell_count; + break; + + case "auto_reloader": + reloads = gun.gun_type!.value.shell_reloads.map( + (reload) => reload * reloadCoefficient, + ); + shells = gun.gun_type!.value.shell_count; + break; + } + + return { + reloads, + shells, + intraClip, + burstShells: gun.burst?.count ?? 1, + burstInterShell: gun.burst?.interval, + }; + }, [characteristics, gun]); + + const glow = useRef(null); + const ring = useRef(null); + const core = useRef(null); + const remainingTime = useRef(null); + const totalTime = useRef(null); + const flash = useRef(null); + const shellBars = useRef>([]); + const state = useRef(0); + + const shoot = useCallback(() => { + for (const element of [flash.current, ring.current, glow.current]) { + if (!element) continue; + + element.dataset.shot = "true"; + + void element.offsetWidth; + delete element.dataset.shot; + } + }, []); + + useEffect(() => { + let cancel = false; + let lastTime = performance.now() / 1000; + let lastState = state.current; + + state.current = 0; + + function frame() { + const reloadThreshold = reloadData.reloads.length; + const now = performance.now() / 1000; + const deltaTime = now - lastTime; + + let reload = 0; + let shells: number[]; + let interval = 0; + + if (state.current < reloadThreshold) { + const currentReload = reloadData.reloads[Math.floor(state.current)]; + + if (currentReload === undefined) { + throw new Error("Missing reload duration for current reload stage"); + } + + interval = currentReload; + state.current = Math.min( + reloadThreshold, + state.current + deltaTime / interval, + ); + reload = state.current % 1; + shells = Array(reloadData.shells).fill(reload); + + if (reloadThreshold > 1) { + shells = shells.map((_, index) => + Math.max(0, state.current - reloadData.shells + index + 1), + ); + } + + if (state.current >= reloadThreshold) shoot(); + } else if (state.current < reloadData.shells + reloadThreshold - 1) { + if ( + reloadData.burstShells === 1 || + Math.floor(state.current) % reloadData.burstShells === 0 + ) { + interval = reloadData.intraClip; + } else { + if (reloadData.burstInterShell === undefined) { + throw new Error("Missing burst interval for firing sequence"); + } + + interval = reloadData.burstInterShell; + } + + state.current = Math.min( + reloadData.shells + reloadThreshold, + state.current + deltaTime / interval, + ); + reload = state.current % 1; + + shells = + reloadThreshold > 1 + ? times(reloadData.shells, (index) => + Math.max( + 0, + Math.floor(index + 1 + reloadThreshold - state.current), + ), + ) + : times(reloadData.shells, (index) => + Number(index > state.current - 1), + ); + + if (lastState % 1 > state.current % 1) shoot(); + } else { + state.current = 0; + shells = Array(reloadData.shells).fill(0); + } + + if (interval > 0 && totalTime.current) { + totalTime.current.textContent = interval.toFixed(RELOAD_PRECISION); + } + + if (interval > 0 && remainingTime.current) { + remainingTime.current.textContent = ((1 - reload) * interval).toFixed( + RELOAD_PRECISION, + ); + } + + for (const [index, bar] of shellBars.current.entries()) { + if (bar) bar.style.width = `${(shells[index] ?? 0) * 100}%`; + } + + if (ring.current && glow.current && core.current) { + const angle = reload * 2 * Math.PI; + + ring.current.style.background = `conic-gradient(var(--amber-9) 0 ${angle}rad, var(--orange-7) ${angle}rad)`; + core.current.style.background = `conic-gradient(var(--amber-6) 0 ${angle}rad, var(--orange-3) ${angle}rad)`; + glow.current.style.background = `conic-gradient(var(--amber-9) 0 ${angle}rad, transparent ${angle}rad)`; + } + + if (!cancel) requestAnimationFrame(frame); + + lastTime = now; + lastState = state.current; + } + + frame(); + + return () => { + cancel = true; + }; + }, [reloadData, shoot]); + + return ( +
+
+
+ +
+
+
+
+
+
+
+ + + + + {(0).toFixed(RELOAD_PRECISION)} + + + {(0).toFixed(RELOAD_PRECISION)} + + + {reloadData.shells > 1 && ( +