mirror of
https://github.com/blitzkit/blitzkit.git
synced 2026-10-21 02:37:05 +02:00
reload visualizer
This commit is contained in:
1 parent
1c4ef7c129
commit
3d8cfc3aeb
10 files changed
+450
-9
No files matched your search
@@ -33,6 +33,10 @@
|
||||
"effective": "Effective",
|
||||
"hull": "Hull",
|
||||
"turret": "Turret"
|
||||
},
|
||||
|
||||
"reload": {
|
||||
"skip": "Skip"
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
File renamed without changes.
File renamed without changes.
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
+1
-1
@@ -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',
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ export function Target() {
|
||||
<Box
|
||||
style={{
|
||||
background:
|
||||
'url(/assets/images/tankopedia/visualizers/reload/background.jpg)',
|
||||
'url(/media/toys/background.jpg)',
|
||||
backgroundSize: '25rem',
|
||||
backgroundPosition: 'center',
|
||||
position: 'absolute',
|
||||
|
||||
@@ -222,3 +222,167 @@
|
||||
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 { literals } from "@blitzkit/i18n";
|
||||
import { DoubleArrowRightIcon } from "@radix-ui/react-icons";
|
||||
import { times } from "lodash-es";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
@@ -12,9 +16,11 @@ import type { ToyProps } from "../../config/toys";
|
||||
import { DuelModule, useDuel } from "../../hooks/useDuel";
|
||||
import { useStrings } from "../../hooks/useStrings";
|
||||
import type { ComputedCharacteristics } from "../../types/characteristics";
|
||||
import { Button } from "../Button";
|
||||
import { Checkbox } from "../Checkbox";
|
||||
import { Code } from "../Code";
|
||||
import { SegmentedControl } from "../SegmentedControl";
|
||||
import { ShellBorder } from "../Tankopedia/CharacteristicsSection/components/Characteristics/components/ReloadVisualizer/components/ShellBorder";
|
||||
import styles from "./index.module.css";
|
||||
|
||||
export function RicochetToy({ characteristics }: ToyProps) {
|
||||
@@ -247,11 +253,7 @@ export function TraverseToy({ characteristics }: TraverseToyProps) {
|
||||
|
||||
return (
|
||||
<div className={styles.traverse}>
|
||||
<div
|
||||
ref={hull}
|
||||
className={styles.hull}
|
||||
data-turretless={isTurretless}
|
||||
>
|
||||
<div ref={hull} className={styles.hull} data-turretless={isTurretless}>
|
||||
<img
|
||||
draggable="false"
|
||||
className={styles.image}
|
||||
@@ -304,3 +306,263 @@ export function TraverseToy({ characteristics }: TraverseToyProps) {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { RicochetToy, TraverseToy } from "../components/TankopediaToys";
|
||||
import {
|
||||
ReloadToy,
|
||||
RicochetToy,
|
||||
TraverseToy,
|
||||
} from "../components/TankopediaToys";
|
||||
import type { ComputedCharacteristics } from "../types/characteristics";
|
||||
|
||||
export interface ToyProps {
|
||||
@@ -9,4 +13,5 @@ export interface ToyProps {
|
||||
export const toys: Record<string, (props: ToyProps) => ReactNode> = {
|
||||
ricochet: RicochetToy,
|
||||
traverse: TraverseToy,
|
||||
reload: ReloadToy,
|
||||
};
|
||||
Reference in new issue
Block a user