reload visualizer

This commit is contained in:
tresabhi committed 2026-10-12 10:34:01 -05:00
1 parent 1c4ef7c129
commit 3d8cfc3aeb
10 files changed
+450 -9

No files matched your search

+4
View File
@@ -33,6 +33,10 @@
"effective": "Effective", "effective": "Effective",
"hull": "Hull", "hull": "Hull",
"turret": "Turret" "turret": "Turret"
},
"reload": {
"skip": "Skip"
} }
}, },
@@ -86,5 +86,9 @@
&[data-variant="surface"], &[data-variant="surface"],
&[data-variant="outline"] { &[data-variant="outline"] {
outline: var(--space-1) solid var(--accent-6); outline: var(--space-1) solid var(--accent-6);
&[data-high-contrast="true"] {
outline: var(--space-1) solid var(--accent-11);
}
} }
} }
@@ -8,7 +8,7 @@ export interface ButtonProps extends ComponentProps<"button"> {
radius?: `${1 | 2 | 3}` | "max"; radius?: `${1 | 2 | 3}` | "max";
variant?: "solid" | "surface" | "soft" | "inverted" | "ghost" | "outline"; variant?: "solid" | "surface" | "soft" | "inverted" | "ghost" | "outline";
size?: "regular" | "minor" | "major"; size?: "regular" | "minor" | "major";
highContrast?: boolean; // TODO: add high contrast highContrast?: boolean;
listChild?: boolean; listChild?: boolean;
parentListChild?: boolean; parentListChild?: boolean;
@@ -40,6 +40,7 @@ export function Button({
listChild, listChild,
parentListChild, parentListChild,
disabled, disabled,
highContrast,
...props ...props
}: ButtonProps) { }: ButtonProps) {
return ( return (
@@ -52,6 +53,7 @@ export function Button({
data-size={size} data-size={size}
data-list-child={listChild} data-list-child={listChild}
data-parent-list-child={parentListChild} data-parent-list-child={parentListChild}
data-high-contrast={highContrast}
data-disabled={disabled} data-disabled={disabled}
disabled={disabled} disabled={disabled}
{...props} {...props}
@@ -69,7 +69,7 @@ export function Shell({ index, stats }: ShellProps & StatsAcceptorProps) {
borderRadius: '0.25rem', borderRadius: '0.25rem',
overflow: 'hidden', overflow: 'hidden',
maskImage: maskImage:
'url(/assets/images/tankopedia/visualizers/reload/shell.png)', 'url(/media/toys/shell.png)',
maskSize: 'contain', maskSize: 'contain',
maskRepeat: 'no-repeat', maskRepeat: 'no-repeat',
maskPosition: 'center', maskPosition: 'center',
@@ -5,7 +5,7 @@ export function Target() {
<Box <Box
style={{ style={{
background: background:
'url(/assets/images/tankopedia/visualizers/reload/background.jpg)', 'url(/media/toys/background.jpg)',
backgroundSize: '25rem', backgroundSize: '25rem',
backgroundPosition: 'center', backgroundPosition: 'center',
position: 'absolute', position: 'absolute',
@@ -222,3 +222,167 @@
transform: translateX(-50%); transform: translateX(-50%);
} }
} }
.reload {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
border-radius: inherit;
.backdrop,
.target {
position: absolute;
inset: 0;
background-image: url("/media/toys/background.jpg");
background-size: 25rem;
background-position: center;
filter: blur(var(--space-2)) contrast(1.2) brightness(0.5) saturate(0.8);
}
.vignette {
position: absolute;
inset: 0;
background: radial-gradient(transparent 50%, var(--black-a8) 100%);
}
.glow,
.ring {
position: absolute;
top: 50%;
left: 50%;
width: 65%;
height: 65%;
border-radius: 50%;
transform: translate(-50%, -50%);
transition: var(--duration-brief);
&[data-shot="true"] {
width: 70%;
height: 70%;
transition: none;
}
}
.glow {
filter: blur(var(--space-4));
}
.ring {
padding: var(--space-2);
background: var(--orange-7);
}
.core {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
border-radius: 50%;
background: black;
}
.fill {
position: absolute;
inset: 0;
border-radius: 50%;
mask-image: radial-gradient(transparent 25%, black 100%);
}
.caret {
position: absolute;
top: 50%;
left: 50%;
width: 10%;
height: 10%;
transform: translateX(-50%);
}
.remaining-time,
.total-time {
position: absolute;
z-index: 1;
top: 50%;
transform: translateY(-50%);
}
.remaining-time {
right: calc(50% + var(--space-6));
transform: translate(50%, -50%);
}
.total-time {
left: calc(50% + var(--space-6));
transform: translate(-50%, -50%);
}
.shells {
position: absolute;
inset: 0;
pointer-events: none;
}
.shell-track {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.shell {
position: absolute;
top: 50%;
left: 90%;
width: 1.25rem;
height: 1.25rem;
transform: translate(-50%, -50%);
}
.border {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
color: var(--jade-8);
}
.mask {
position: absolute;
inset: 0;
display: flex;
justify-content: end;
overflow: hidden;
border-radius: var(--space-1);
mask-image: url("/media/toys/shell.png");
mask-size: contain;
mask-repeat: no-repeat;
mask-position: center;
}
.shell-fill {
width: 100%;
background: var(--jade-9);
}
.flash {
position: absolute;
inset: 0;
background: transparent;
transition: var(--duration-brief);
&[data-shot="true"] {
background: var(--white-a3);
transition: none;
}
}
.skip {
position: absolute;
top: 68%;
left: 50%;
display: flex;
align-items: center;
gap: var(--space-2);
transform: translate(-50%, -50%);
}
}
@@ -1,7 +1,11 @@
import { alias, isExplosive } from "@blitzkit/core"; import { alias, isExplosive } from "@blitzkit/core";
import { literals } from "@blitzkit/i18n"; import { literals } from "@blitzkit/i18n";
import { DoubleArrowRightIcon } from "@radix-ui/react-icons";
import { times } from "lodash-es";
import { import {
useCallback,
useEffect, useEffect,
useMemo,
useRef, useRef,
useState, useState,
type CSSProperties, type CSSProperties,
@@ -12,9 +16,11 @@ import type { ToyProps } from "../../config/toys";
import { DuelModule, useDuel } from "../../hooks/useDuel"; import { DuelModule, useDuel } from "../../hooks/useDuel";
import { useStrings } from "../../hooks/useStrings"; import { useStrings } from "../../hooks/useStrings";
import type { ComputedCharacteristics } from "../../types/characteristics"; import type { ComputedCharacteristics } from "../../types/characteristics";
import { Button } from "../Button";
import { Checkbox } from "../Checkbox"; import { Checkbox } from "../Checkbox";
import { Code } from "../Code"; import { Code } from "../Code";
import { SegmentedControl } from "../SegmentedControl"; import { SegmentedControl } from "../SegmentedControl";
import { ShellBorder } from "../Tankopedia/CharacteristicsSection/components/Characteristics/components/ReloadVisualizer/components/ShellBorder";
import styles from "./index.module.css"; import styles from "./index.module.css";
export function RicochetToy({ characteristics }: ToyProps) { export function RicochetToy({ characteristics }: ToyProps) {
@@ -247,11 +253,7 @@ export function TraverseToy({ characteristics }: TraverseToyProps) {
return ( return (
<div className={styles.traverse}> <div className={styles.traverse}>
<div <div ref={hull} className={styles.hull} data-turretless={isTurretless}>
ref={hull}
className={styles.hull}
data-turretless={isTurretless}
>
<img <img
draggable="false" draggable="false"
className={styles.image} className={styles.image}
@@ -304,3 +306,263 @@ export function TraverseToy({ characteristics }: TraverseToyProps) {
</div> </div>
); );
} }
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<ReloadData>(() => {
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<HTMLDivElement>(null);
const ring = useRef<HTMLDivElement>(null);
const core = useRef<HTMLDivElement>(null);
const remainingTime = useRef<HTMLSpanElement>(null);
const totalTime = useRef<HTMLSpanElement>(null);
const flash = useRef<HTMLDivElement>(null);
const shellBars = useRef<Array<HTMLDivElement | null>>([]);
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 (
<div className={styles.reload}>
<div className={styles.backdrop} />
<div className={styles.vignette} />
<div className={styles.glow} ref={glow} />
<div className={styles.ring} ref={ring}>
<div className={styles.core}>
<div className={styles.target} />
<div className={styles.fill} ref={core} />
</div>
</div>
<img
className={styles.caret}
src="/assets/images/tankopedia/visualizers/reload/caret.png"
/>
<Code
variant="ghost"
className={styles["remaining-time"]}
ref={remainingTime}
>
{(0).toFixed(RELOAD_PRECISION)}
</Code>
<Code variant="ghost" className={styles["total-time"]} ref={totalTime}>
{(0).toFixed(RELOAD_PRECISION)}
</Code>
{reloadData.shells > 1 && (
<div className={styles.shells} aria-hidden="true">
{Array.from({ length: reloadData.shells }, (_, index) => (
<div
className={styles["shell-track"]}
key={index}
style={{
transform: `rotate(${135 - 7 * (index - (reloadData.shells - 1) / 2)}deg)`,
}}
>
<div className={styles.shell}>
<ShellBorder className={styles.border} />
<div className={styles.mask}>
<div
className={styles["shell-fill"]}
ref={(element) => {
shellBars.current[index] = element;
}}
/>
</div>
</div>
</div>
))}
</div>
)}
<div className={styles.flash} ref={flash} />
<Button
className={styles.skip}
size="minor"
variant="outline"
color="gray"
highContrast
aria-label={strings.tanks.toys.reload.skip}
onClick={() => {
state.current = Math.floor(state.current + 1);
}}
>
{strings.tanks.toys.reload.skip}
<DoubleArrowRightIcon />
</Button>
</div>
);
}
+6 -1
View File
@@ -1,5 +1,9 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { RicochetToy, TraverseToy } from "../components/TankopediaToys"; import {
ReloadToy,
RicochetToy,
TraverseToy,
} from "../components/TankopediaToys";
import type { ComputedCharacteristics } from "../types/characteristics"; import type { ComputedCharacteristics } from "../types/characteristics";
export interface ToyProps { export interface ToyProps {
@@ -9,4 +13,5 @@ export interface ToyProps {
export const toys: Record<string, (props: ToyProps) => ReactNode> = { export const toys: Record<string, (props: ToyProps) => ReactNode> = {
ricochet: RicochetToy, ricochet: RicochetToy,
traverse: TraverseToy, traverse: TraverseToy,
reload: ReloadToy,
}; };