Merge branch 'main' into pr/Hitjack007/283

This commit is contained in:
tresabhi committed 2026-11-01 13:29:12 -06:00
commit 7fb73edb87
124 files changed
+3133 -1319

No files matched your search

@@ -128,19 +128,30 @@ export function ShotDisplayCard({ shot, ...props }: ShotDisplayCardProps) {
? shotStatusColors[shot.out.status]
: undefined;
const inTitleColor = shotStatusColors[shot.in.status];
const hasOutLayers = shot.out !== undefined && shot.out.layers.length > 0;
const penetrationChance = shot.penetrationChance !== undefined && (
<Text color="gray" weight="bold">
{literals(strings.common.units.percentage, {
value: (shot.penetrationChance * 100).toFixed(0),
})}
</Text>
);
return (
<Card variant="classic" {...props}>
<Flex direction="column" gap="2">
<Flex direction="column" gap="1">
<Text color={inTitleColor} weight="bold">
{literals(
strings.website.tools.tankopedia.sandbox.dynamic.shot_card.status[
shot.in.status
],
{ damage: Math.round(shot.damage).toLocaleString(locale) }
)}
</Text>
<Flex justify="between" align="center" gap="4">
<Text color={inTitleColor} weight="bold">
{literals(
strings.website.tools.tankopedia.sandbox.dynamic.shot_card
.status[shot.in.status],
{ damage: Math.round(shot.damage).toLocaleString(locale) }
)}
</Text>
{!hasOutLayers && penetrationChance}
</Flex>
<table
style={{
@@ -204,13 +215,17 @@ export function ShotDisplayCard({ shot, ...props }: ShotDisplayCardProps) {
</Flex>
</Inset>
<Text color={outTitleColor} weight="bold">
{literals(
strings.website.tools.tankopedia.sandbox.dynamic.shot_card
.status[shot.out.status],
{ damage: Math.round(shot.damage).toLocaleString(locale) }
)}
</Text>
<Flex justify="between" align="center" gap="4">
<Text color={outTitleColor} weight="bold">
{literals(
strings.website.tools.tankopedia.sandbox.dynamic.shot_card
.status[shot.out.status],
{ damage: Math.round(shot.damage).toLocaleString(locale) }
)}
</Text>
{penetrationChance}
</Flex>
<Flex direction="column">
{shot.out.layers.map((layer, index) => {
@@ -63,6 +63,36 @@ export type ArmorUserData = {
}
);
const PENETRATION_RANDOMIZATION = 0.05;
function resolvePenetrationChance(shoot: (scale: number) => Shot | null) {
function penetrates(scale: number) {
const shot = shoot(scale);
return (shot?.out ?? shot?.in)?.status === "penetration";
}
let low = 1 - PENETRATION_RANDOMIZATION;
let high = 1 + PENETRATION_RANDOMIZATION;
if (penetrates(low)) return 1;
if (!penetrates(high)) return 0;
for (let iteration = 0; iteration < 16; iteration++) {
const middle = (low + high) / 2;
if (penetrates(middle)) {
high = middle;
} else {
low = middle;
}
}
return (
(1 + PENETRATION_RANDOMIZATION - high) / (2 * PENETRATION_RANDOMIZATION)
);
}
const omitMaterial = new MeshBasicMaterial({
colorWrite: false,
depthWrite: true,
@@ -89,6 +119,7 @@ export function SpacedArmorSceneComponent({
intersections: Intersection[],
allowRicochet: boolean,
remainingPenetrationInput?: number,
penetrationScale = 1,
) => {
const { customShell } = Tankopedia.state;
const shell = customShell ?? Duel.state.antagonist.shell;
@@ -125,6 +156,7 @@ export function SpacedArmorSceneComponent({
Duel.state.protagonist.equipmentMatrix,
);
const penetration =
penetrationScale *
shell.penetration!.near *
resolvePenetrationCoefficient(
hasCalibratedShells,
@@ -367,6 +399,7 @@ export function SpacedArmorSceneComponent({
ricochetIntersections,
false,
remainingPenetration,
penetrationScale,
);
shot.in.status = "ricochet";
@@ -422,6 +455,16 @@ export function SpacedArmorSceneComponent({
const shot = shoot(event.point, event.intersections, true)!;
shot.penetrationChance = resolvePenetrationChance((scale) =>
shoot(
event.point,
event.intersections,
true,
undefined,
scale,
),
);
Tankopedia.mutate((draft) => {
draft.shot = shot;
});
@@ -1,38 +0,0 @@
---
import { tankIcon } from "@blitzkit/core";
import { Flex, Heading, Link, Strong, Text } from "@radix-ui/themes";
---
<Flex justify="center" px="4">
<Link
color="gray"
highContrast
style={{ display: "content" }}
href="/docs/guides/christmas-2025"
underline="none"
>
<Flex
mt="3"
px="5"
py="4"
style={{
borderRadius: "var(--radius-3)",
backgroundImage: "url(https://i.imgur.com/LRYmSkg.png)",
backgroundSize: "cover",
backgroundPosition: "center",
boxSizing: "border-box",
}}
align="center"
gap="5"
>
<img src={tankIcon(25169)} style={{ height: "min(15vw, 5rem)" }} />
<Flex direction="column">
<Heading size={{ initial: "4", sm: "6" }}>Christmas 2025</Heading>
<Text size={{ initial: "2", sm: "3" }}>
Win the <Strong>GSOR the TANK</Strong> and more!
</Text>
</Flex>
</Flex>
</Link>
</Flex>
@@ -0,0 +1,36 @@
.outer {
padding: 0 var(--space-4);
margin-top: var(--space-3);
img {
width: 1.25em;
height: 1.25em;
border-radius: 50%;
}
.center {
position: relative;
background: linear-gradient(var(--pink-3), var(--purple-2));
box-shadow: var(--shadow-3);
max-width: 40rem;
padding: var(--space-5);
border-radius: var(--radius-4);
.important {
position: absolute;
top: 0;
left: 50%;
transform: translate(-50%, -50%);
background-color: inherit;
padding: var(--space-3);
}
}
}
@@ -0,0 +1,48 @@
import { HeartFilledIcon } from "@radix-ui/react-icons";
import { Button, Em, Flex, Strong, Text } from "@radix-ui/themes";
import { donationTiers } from "../../config/donations";
import styles from "./index.module.css";
export function Beg() {
return (
<Flex align="center" gap="4" direction="column" className={styles.outer}>
<Text size="3" className={styles.important}>
<Flex gap="2" align="center">
<img src="https://github.com/tresabhi.png" />
<Em>We need your attention</Em>
</Flex>
</Text>
<Flex direction="column" className={styles.center} gap="4">
<Flex direction="column" gap="3">
<Text color="tomato">
We can no longer keep BlitzKit sustainable for <Strong>free</Strong>
.
</Text>
<Text>
We want to add tank modules, player statistics, rebalancing patch
notes, bring back the Discord bot, and more.
</Text>
<Text>
But paying out of pocket makes that impossible. We are an earnest
non-profit, so consider donating to make the above possible. 100% of
your money goes back into BlitzKit.
</Text>
</Flex>
<Flex gap="3">
{donationTiers.map(({ name, value, link }) => (
<a target="_blank" key={name} href={link}>
<Button size="3" color="crimson">
{value === null && <HeartFilledIcon />}
{value === null ? "Custom" : `$${value}`}
</Button>
</a>
))}
</Flex>
</Flex>
</Flex>
);
}
@@ -2,7 +2,7 @@ import { alias } from "@blitzkit/core";
import { Flex, Heading, IconButton, Table } from "@radix-ui/themes";
import type { TankCharacteristics } from "../../core/blitzkit/tankCharacteristics";
import { useLocale } from "../../hooks/useLocale";
import { CompareEphemeral } from "../../stores/compareEphemeral";
import { CompareEphemeral, syncShellSlot } from "../../stores/compareEphemeral";
import { CompareCellDirection } from "../CompareCell";
import { StickyColumnHeaderCell } from "../StickyColumnHeaderCell";
import { CompareRow } from "./CompareRow";
@@ -50,7 +50,11 @@ export function Body({ stats }: BodyProps) {
}}
onClick={() => {
CompareEphemeral.mutate((draft) => {
draft.members[index].shell = thisShell;
if (draft.syncShells) {
syncShellSlot(draft.members, shellIndex);
} else {
draft.members[index].shell = thisShell;
}
});
}}
>
@@ -1,10 +1,17 @@
import { PlusIcon, TrashIcon } from "@radix-ui/react-icons";
import { Button, Dialog, Flex, SegmentedControl } from "@radix-ui/themes";
import {
Button,
Checkbox,
Dialog,
Flex,
SegmentedControl,
Text,
} from "@radix-ui/themes";
import { DIALOG_MAX_HEIGHT } from "../../constants/dialog";
import { api } from "../../core/blitzkit/api";
import { tankToCompareMember } from "../../core/blitzkit/tankToCompareMember";
import { useLocale } from "../../hooks/useLocale";
import { CompareEphemeral } from "../../stores/compareEphemeral";
import { CompareEphemeral, syncShellSlot } from "../../stores/compareEphemeral";
import {
ComparePersistent,
type DeltaMode,
@@ -23,6 +30,7 @@ export function Controls({
onAddTankDialogOpenChange,
}: ControlsProps) {
const deltaMode = ComparePersistent.use((state) => state.deltaMode);
const syncShells = CompareEphemeral.use((state) => state.syncShells);
const { strings } = useLocale();
return (
@@ -112,6 +120,31 @@ export function Controls({
{strings.website.tools.compare.actions.deltas.absolute}
</SegmentedControl.Item>
</SegmentedControl.Root>
<Flex
align="center"
gap="2"
style={{ cursor: "pointer" }}
onClick={() => {
CompareEphemeral.mutate((draft) => {
draft.syncShells = !draft.syncShells;
if (!draft.syncShells || draft.members.length === 0) return;
const [first] = draft.members;
syncShellSlot(
draft.members,
first.gun.shells.findIndex(({ id }) => id === first.shell.id),
);
});
}}
>
<Checkbox variant="classic" checked={syncShells} />
<Text size="2">
{strings.website.tools.compare.actions.sync_shells}
</Text>
</Flex>
</Flex>
);
}
@@ -3,9 +3,9 @@ import { useEffect, useState, type ReactNode } from "react";
import type { MaybeSkeletonComponentProps } from "../types/maybeSkeletonComponentProps";
import { InlineSkeleton } from "./InlineSkeleton";
const TIME = 1764898200 * 1000;
const TIME = 1792598400 * 1000;
export function Giveaway({ skeleton }: MaybeSkeletonComponentProps) {
export function Countdown({ skeleton }: MaybeSkeletonComponentProps) {
const [timeLeft, setTimeLeft] = useState<number>(TIME - Date.now());
useEffect(() => {
@@ -31,14 +31,14 @@ export function Giveaway({ skeleton }: MaybeSkeletonComponentProps) {
string = (
<>
in{" "}
revealed in{" "}
<Code highContrast>
{hours}h {minutes}m {seconds}s
</Code>
</>
);
} else {
string = "has ended. Come again later!";
string = "have been revealed.";
}
return (
@@ -47,7 +47,7 @@ export function Giveaway({ skeleton }: MaybeSkeletonComponentProps) {
color="gray"
highContrast
style={{ display: "content" }}
href="https://discord.gg/njQdBhNS?event=1446208951136813117"
href="https://preview.blitzkit.app/tanks"
underline="none"
>
<Flex
@@ -56,17 +56,17 @@ export function Giveaway({ skeleton }: MaybeSkeletonComponentProps) {
py="4"
style={{
borderRadius: "var(--radius-3)",
backgroundImage: "url(https://i.imgur.com/LRYmSkg.png)",
backgroundImage: "url(https://i.imgur.com/xgngxoy.png)",
backgroundSize: "cover",
backgroundPosition: "center",
boxSizing: "border-box",
boxShadow: "0 0 var(--space-7) var(--blue-a5)",
boxShadow: "0 0 var(--space-7) var(--white-a2)",
}}
align="center"
gap="5"
>
<img
src="https://i.imgur.com/mvmJQgB.png"
src="https://i.imgur.com/kXV3r3P.png"
style={{
height: "min(15vw, 5rem)",
filter: "drop-shadow(0.5rem 0.5rem 1rem #00000080)",
@@ -75,11 +75,11 @@ export function Giveaway({ skeleton }: MaybeSkeletonComponentProps) {
<Flex direction="column">
<Heading size={{ initial: "4", sm: "6" }}>
Music Box giveaway {!skeleton && string}{" "}
Tier XIs {!skeleton && string}{" "}
{skeleton && <InlineSkeleton width="10rem" />}
</Heading>
<Text size={{ initial: "2", sm: "3" }}>
Join the BlitzKit Discord server event
Get ready to go to the Tankopedia!
</Text>
</Flex>
</Flex>
@@ -0,0 +1,135 @@
---
import { Grid, Text } from "@radix-ui/themes";
import { credits } from "../config/credits";
import { getStrings } from "../core/i18n/getStrings";
interface Props {
locale: string;
}
const { locale } = Astro.props;
const strings = getStrings(locale);
---
<style>
.credits {
display: flex;
justify-content: center;
padding: var(--space-4);
.inner {
display: flex;
flex-direction: column;
gap: var(--space-2);
width: 100%;
max-width: 80rem;
.list {
gap: var(--space-5);
.link {
display: contents;
.person {
--profile-size: 5rem;
position: relative;
display: flex;
flex-direction: column;
padding: var(--space-4);
padding-top: 0;
margin-top: calc(var(--profile-size) / 2);
gap: var(--space-1);
box-shadow: var(--shadow-3);
border-radius: var(--radius-3);
.contain {
border-radius: inherit;
position: absolute;
top: 0;
right: 0;
width: 100%;
height: 100%;
overflow: hidden;
.hint {
width: 100%;
height: 100%;
background-size: cover;
background-position: center;
filter: saturate(20) brightness(0.125) blur(4rem);
}
}
.text {
position: relative;
}
img {
border-radius: 50%;
position: relative;
margin-top: calc(var(--profile-size) / -2);
object-fit: cover;
width: var(--profile-size);
height: var(--profile-size);
margin-bottom: var(--space-2);
}
}
}
}
}
}
</style>
<div class="credits">
<div class="inner">
<Grid
className="list"
py="4"
columns="repeat(auto-fill, minmax(16rem, 1fr))"
flow="dense"
gap="4"
gapY="8"
>
{
credits.map((person) => (
<a target="_blank" href={person.url} class="link">
<div class="person">
<div class="contain">
<div
class="hint"
style={`background-image: url(${person.image})`}
/>
</div>
<img src={person.image} />
<Text weight="medium" className="text" color="gray" highContrast>
{person.name}
</Text>
<Text className="text" color="gray" wrap="pretty">
{person.description}
</Text>
</div>
</a>
))
}
</Grid>
</div>
</div>
+3 -6
View File
@@ -43,9 +43,9 @@ const strings = getStrings(locale);
}}
>
<Heading>BlitzKit</Heading>
<Text color="gray">{strings.website.footer.motto}</Text>
<Text size="1" color="gray">
{strings.website.footer.copyright}
<Text size="2" color="gray">{strings.website.footer.motto}</Text>
<Text size="2" color="gray">
{strings.website.footer.association}
</Text>
<Box mt="2">
@@ -84,9 +84,6 @@ const strings = getStrings(locale);
sm: "start",
}}
>
<LinkI18n {locale} href="/docs/about/credits" color="gray">
{strings.website.footer.about.credits}
</LinkI18n>
<Link
href="https://discord.gg/nDt7AjGJQH"
target="_blank"
+9 -2
View File
@@ -1,13 +1,20 @@
import { Link, Table } from '@radix-ui/themes';
import MarkdownToJSX from 'markdown-to-jsx';
import type { ComponentProps } from 'react';
import { docImageUrl, isExternalImage } from '../core/blitzkit/docImages';
interface MarkdownProps {
children: string;
}
function Image(props: ComponentProps<'img'>) {
return <img style={{ maxWidth: '100%' }} {...props} />;
function Image({ src, ...props }: ComponentProps<'img'>) {
return (
<img
style={{ maxWidth: '100%' }}
src={src && isExternalImage(src) ? docImageUrl(src) : src}
{...props}
/>
);
}
export function Markdown({ children }: MarkdownProps) {
+8 -44
View File
@@ -5,7 +5,7 @@ import {
GearIcon,
HamburgerMenuIcon,
} from "@radix-ui/react-icons";
import { Box, Code, Flex, IconButton, Link } from "@radix-ui/themes";
import { Box, Code, Flex, Grid, IconButton, Link } from "@radix-ui/themes";
import { resolveBranchName } from "../core/blitzkit/branches";
import { getStrings } from "../core/i18n/getStrings";
import { Var } from "../core/radix/var";
@@ -20,7 +20,7 @@ export interface Props {
const { integrated = false } = Astro.props;
const toolsFiltered = Object.values(tools).filter(
(tool) => !tool.href && !tool.disabled
(tool) => !tool.href && !tool.disabled,
);
const { locale } = Astro.props;
const branchName = resolveBranchName(locale);
@@ -105,7 +105,7 @@ const strings = getStrings(locale);
<Flex
className="content"
width="100%"
p={{ initial: "4", sm: "3" }}
p="3"
direction="column"
align="center"
>
@@ -133,41 +133,6 @@ const strings = getStrings(locale);
<Box flexGrow="1" />
<Flex
align="center"
gap={{ initial: "3", md: "6" }}
display={{ initial: "none", sm: "flex" }}
justify="center"
className="tools"
>
{
toolsFiltered.map((tool) => {
const selected = Astro.url.pathname.includes(`/${tool.id}`);
const toolStrings =
strings.website.tools[
(tool.strings ??
tool.id) as keyof typeof strings.website.tools
];
return (
<LinkI18n
{locale}
weight={selected ? "medium" : "regular"}
color="gray"
highContrast={selected}
size={{ initial: "1", md: "2" }}
href={`/${tool.id}`}
underline="hover"
>
{toolStrings.name}
</LinkI18n>
);
})
}
</Flex>
<Box flexGrow="1" />
<Flex align="center" gap="4">
<Link
style={{ display: "flex", alignContent: "center" }}
@@ -191,7 +156,7 @@ const strings = getStrings(locale);
<NavbarAccount {locale} client:idle />
<Flex align="center" display={{ initial: "flex", sm: "none" }}>
<Flex align="center">
<IconButton variant="ghost" color="gray" className="hamburger">
<Cross1Icon data-expanded />
<HamburgerMenuIcon data-closed />
@@ -200,11 +165,10 @@ const strings = getStrings(locale);
</Flex>
</Flex>
<Flex
direction="column"
<Grid
data-expanded
columns="repeat(auto-fill, minmax(30rem, 1fr))"
gap="3"
display={{ initial: "flex", sm: "none" }}
>
{
toolsFiltered.map((tool) => {
@@ -229,7 +193,7 @@ const strings = getStrings(locale);
}}
>
<Flex
py="4"
p="6"
justify="end"
flexGrow="1"
style={{
@@ -243,7 +207,7 @@ const strings = getStrings(locale);
);
})
}
</Flex>
</Grid>
</Flex>
</Flex>
@@ -1,3 +0,0 @@
export function StupidCat() {
return <img src="https://i.imgur.com/6FBBzb9.png" />;
}
@@ -1,7 +1,7 @@
import {
normalizeBoundingBox,
resolveDpm,
unionBoundingBox,
tankBoundingBox,
type TankDefinition,
} from "@blitzkit/core";
import { useMemo } from "react";
@@ -112,10 +112,7 @@ export function TankSearchCard({ tank, onSelect }: TankSearchCardProps) {
return (tank.camouflage_still * gun.camouflage_loss * 100).toFixed(0);
case "survivability.volume": {
const dimensions = normalizeBoundingBox(
unionBoundingBox(
tankModelDefinition.bounding_box!,
turretModelDefinition.bounding_box!,
),
tankBoundingBox(tankModelDefinition, turretModelDefinition),
);
return (dimensions.x * dimensions.y * dimensions.z).toFixed(0);
@@ -123,10 +120,7 @@ export function TankSearchCard({ tank, onSelect }: TankSearchCardProps) {
case "survivability.length": {
const bounds = normalizeBoundingBox(
unionBoundingBox(
tankModelDefinition.bounding_box!,
turretModelDefinition.bounding_box!,
),
tankBoundingBox(tankModelDefinition, turretModelDefinition),
);
return Math.max(bounds.x, bounds.y, bounds.z).toFixed(0);
@@ -3,7 +3,7 @@ import {
normalizeBoundingBox,
resolveDpm,
SEARCH_KEYS,
unionBoundingBox,
tankBoundingBox,
type TankDefinition,
} from "@blitzkit/core";
import { literals } from "@blitzkit/i18n/src/literals";
@@ -137,16 +137,10 @@ export const TankSearch = memo<TankSearchProps>(
const bTurretModelDefinition =
bTankModelDefinition.turrets[b.turrets.at(-1)!.id];
const aSize = normalizeBoundingBox(
unionBoundingBox(
aTankModelDefinition.bounding_box!,
aTurretModelDefinition.bounding_box!,
),
tankBoundingBox(aTankModelDefinition, aTurretModelDefinition),
);
const bSize = normalizeBoundingBox(
unionBoundingBox(
bTankModelDefinition.bounding_box!,
bTurretModelDefinition.bounding_box!,
),
tankBoundingBox(bTankModelDefinition, bTurretModelDefinition),
);
const aVolume = aSize.x * aSize.y * aSize.z;
const bVolume = bSize.x * bSize.y * bSize.z;
@@ -164,16 +158,10 @@ export const TankSearch = memo<TankSearchProps>(
const bTurretModelDefinition =
bTankModelDefinition.turrets[b.turrets.at(-1)!.id];
const aSize = normalizeBoundingBox(
unionBoundingBox(
aTankModelDefinition.bounding_box!,
aTurretModelDefinition.bounding_box!,
),
tankBoundingBox(aTankModelDefinition, aTurretModelDefinition),
);
const bSize = normalizeBoundingBox(
unionBoundingBox(
bTankModelDefinition.bounding_box!,
bTurretModelDefinition.bounding_box!,
),
tankBoundingBox(bTankModelDefinition, bTurretModelDefinition),
);
const aLength = Math.max(aSize.x, aSize.y, aSize.z);
const bLength = Math.max(bSize.x, bSize.y, bSize.z);
@@ -44,7 +44,9 @@ export function GunFlexibilityVisualizer({
const tank = Duel.use((state) => state.protagonist.tank);
const turret = Duel.use((state) => state.protagonist.turret);
const gun = Duel.use((state) => state.protagonist.gun);
const modelRequested = Tankopedia.use((state) => state.modelRequested);
const modelRequested = Tankopedia.use(
(state) => state.loaders.model.requested,
);
const tankModel = modelDefinition.models[tank.id];
const turretModel = tankModel.turrets[turret.id];
@@ -1,5 +1,5 @@
import { createDefaultSkills } from "@blitzkit/core";
import { Flex, Progress, Text } from "@radix-ui/themes";
import { Box, Flex, Progress, Text } from "@radix-ui/themes";
import { clamp } from "lodash-es";
import { memo, useMemo, type ComponentProps, type ReactNode } from "react";
import { api } from "../../../../../../core/blitzkit/api";
@@ -16,6 +16,7 @@ import {
TankopediaRelativeAgainst,
} from "../../../../../../stores/tankopedia";
import { Info, type InfoProps } from "./Info";
import { StatScale } from "./StatScale";
type InfoWithDeltaProps = Omit<InfoProps, "name"> & {
stats: TankCharacteristics;
@@ -122,6 +123,15 @@ export const InfoWithDelta = memo<InfoWithDeltaProps>(
return othersValue !== undefined;
}) as TankCharacteristics[];
}, [relativeAgainst, shellIndex, equipmentMatrix, equalize]);
const values = useMemo(
() =>
others.map((tank) =>
typeof props.value === "function"
? props.value(tank)!
: (tank[props.value] as number),
),
[others],
);
const betterTanks = others.filter((tank) => {
const othersValue =
typeof props.value === "function"
@@ -161,13 +171,25 @@ export const InfoWithDelta = memo<InfoWithDeltaProps>(
{!noRanking && (
<Flex pl={indent ? "2" : "0"} align="center" gap="2">
<Progress
variant="soft"
size="1"
value={goodness * 100}
color={color}
style={{ height: "0.125rem", opacity: 0.5 }}
/>
<Box position="relative" flexGrow="1">
<StatScale
values={values}
value={uhWhatDoICallThisVariable}
lowerIsBetter={deltaType === "lowerIsBetter"}
/>
<Progress
variant="soft"
size="1"
value={goodness * 100}
color={color}
style={{
position: "relative",
height: "0.125rem",
opacity: 0.75,
}}
/>
</Box>
<Text color="gray" size="1">
{clamp(betterTanks.length + 1, 1, others.length)} /{" "}
@@ -0,0 +1,91 @@
import { memo, useEffect, useMemo, useRef } from "react";
const RESOLUTION = 1000;
const HEIGHT = 8;
const DENSE_TICKS = 100;
interface StatScaleProps {
values: number[];
value: number;
lowerIsBetter?: boolean;
}
export const StatScale = memo<StatScaleProps>(
({ values, value, lowerIsBetter }) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const ticks = useMemo(() => {
const min = Math.min(value, ...values);
const max = Math.max(value, ...values);
const range = max - min;
const positions = new Set<number>();
for (const other of values) {
const raw = range === 0 ? 0.5 : (other - min) / range;
const x = Math.round((lowerIsBetter ? 1 - raw : raw) * RESOLUTION);
if (x === 0 || x === RESOLUTION) continue;
positions.add(x);
}
return Array.from(positions, (x) => ({ x }));
}, [values, value, lowerIsBetter]);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const draw = () => {
const { width } = canvas.getBoundingClientRect();
const pixelRatio = window.devicePixelRatio || 1;
const canvasWidth = Math.ceil(width * pixelRatio);
const canvasHeight = Math.ceil(HEIGHT * pixelRatio);
if (canvas.width !== canvasWidth || canvas.height !== canvasHeight) {
canvas.width = canvasWidth;
canvas.height = canvasHeight;
}
const context = canvas.getContext("2d");
if (!context) return;
context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
context.clearRect(0, 0, width, HEIGHT);
const styles = getComputedStyle(canvas);
for (const { x } of ticks) {
context.fillStyle = styles.getPropertyValue("--gray-a6").trim();
context.fillRect((x / RESOLUTION) * width, 0, 1, HEIGHT);
}
};
const observer = new ResizeObserver(draw);
observer.observe(canvas);
window.addEventListener("resize", draw);
draw();
return () => {
observer.disconnect();
window.removeEventListener("resize", draw);
};
}, [ticks]);
return (
<canvas
ref={canvasRef}
aria-hidden="true"
style={{
position: "absolute",
top: 0,
bottom: 0,
left: 0,
width: "100%",
height: HEIGHT,
margin: "auto 0",
opacity: Math.min(1, Math.sqrt(DENSE_TICKS / ticks.length)),
pointerEvents: "none",
}}
/>
);
},
);
@@ -24,6 +24,14 @@ export function Survivability({ stats }: StatsAcceptorProps) {
decimals={0}
value={(stats) => stats.fireChance * 100}
/>
{stats.trackRepairTime !== undefined && (
<InfoWithDelta
value="trackRepairTime"
stats={stats}
deltaType="lowerIsBetter"
decimals={2}
/>
)}
<InfoWithDelta value="viewRange" stats={stats} decimals={0} />
<ViewRangeVisualizer stats={stats} />
<Info
@@ -0,0 +1,44 @@
import { Flex, IconButton, type FlexProps } from "@radix-ui/themes";
import type { ComponentProps, CSSProperties } from "react";
type SidebarIconButtonProps = ComponentProps<typeof IconButton> & {
selected: boolean;
src: string;
alt: string;
imageStyle?: CSSProperties;
};
export function SidebarIconGroup({ style, ...props }: FlexProps) {
return (
<Flex
direction="column"
overflow="hidden"
style={{ borderRadius: "var(--radius-full)", ...style }}
{...props}
/>
);
}
export function SidebarIconButton({
selected,
src,
alt,
imageStyle,
...props
}: SidebarIconButtonProps) {
return (
<IconButton
color={selected ? undefined : "gray"}
variant="soft"
size={{ initial: "2", sm: "3" }}
radius="none"
{...props}
>
<img
alt={alt}
src={src}
style={{ width: "50%", height: "50%", ...imageStyle }}
/>
</IconButton>
);
}
@@ -0,0 +1,106 @@
import { alias } from "@blitzkit/core";
import { Flex, Tooltip } from "@radix-ui/themes";
import { uniqBy } from "lodash-es";
import { SKIN_NUMBER } from "../../../../../../constants/skinNumber";
import { api } from "../../../../../../core/blitzkit/api";
import { useLocale } from "../../../../../../hooks/useLocale";
import { Duel } from "../../../../../../stores/duel";
import { Tankopedia } from "../../../../../../stores/tankopedia";
import { TankopediaDisplay } from "../../../../../../stores/tankopediaPersistent/constants";
import { SidebarIconButton } from "./SidebarIconButton";
const camouflageDefinitions = await api.camouflageDefinitions();
export function SkinSwitcher() {
const tank = Duel.use((state) => state.protagonist.tank);
const skin = Tankopedia.use((state) => state.loaders.skin.requested);
const attachment = Tankopedia.use(
(state) => state.loaders.attachment.requested,
);
const requestedDisplay = Tankopedia.use((state) => state.requestedDisplay);
const disturbed = Tankopedia.use((state) => state.disturbed);
const { unwrap } = useLocale();
const skins = uniqBy(
Object.values(camouflageDefinitions.camouflages).filter(
(camouflage) => camouflage.tank_id === tank.id,
),
(camouflage) => unwrap(camouflage.name!),
);
// attachments aren't camouflages so they get their own group
const groups = [
skins.filter((camouflage) => !camouflage.attachment),
skins.filter((camouflage) => camouflage.attachment),
].filter((group) => group.length > 0);
if (skins.length === 0) return null;
return (
<Flex
direction="column"
gap="3"
top="50%"
right={
disturbed && requestedDisplay === TankopediaDisplay.Model
? "3"
: "-4rem"
}
style={{
position: "absolute",
transform: "translateY(-50%)",
transitionDuration: "200ms",
pointerEvents: "auto",
}}
>
{groups.map((group) => (
<Flex key={group[0].id} direction="column">
{group.map((camouflage, index) => {
const name = unwrap(camouflage.name!).replace(
"%(camo_num)",
SKIN_NUMBER,
);
const top = index === 0 ? "var(--radius-full)" : "0";
const bottom =
index === group.length - 1 ? "var(--radius-full)" : "0";
return (
<Tooltip key={camouflage.id} content={name} side="left">
<SidebarIconButton
selected={
(camouflage.attachment ? attachment : skin) ===
camouflage.id
}
alt={name}
src={alias("api", `/icons/camouflages/${camouflage.id}.webp`)}
size={{ initial: "3", sm: "4" }}
radius="full"
style={{
borderRadius: `${top} ${top} ${bottom} ${bottom}`,
}}
imageStyle={{
width: "70%",
height: "70%",
borderRadius: "50%",
}}
onClick={() => {
Tankopedia.mutate((draft) => {
const loader =
draft.loaders[
camouflage.attachment ? "attachment" : "skin"
];
loader.requested =
loader.requested === camouflage.id
? undefined
: camouflage.id;
});
}}
/>
</Tooltip>
);
})}
</Flex>
))}
</Flex>
);
}
@@ -47,6 +47,11 @@ import { TankSearch } from "../../../../TankSearch";
import { CustomShellButton } from "./components/CustomShellButton";
import { DynamicArmorSwitcher } from "./components/DynamicArmorSwitcher";
import { QuickInputs } from "./components/QuickInputs";
import {
SidebarIconButton,
SidebarIconGroup,
} from "./components/SidebarIconButton";
import { SkinSwitcher } from "./components/SkinSwitcher";
import { SpallLinerSwitcher } from "./components/SpallLinerSwitcher";
import { Thicknesses } from "./components/Thicknesses";
@@ -78,7 +83,9 @@ export function Options({ thicknessRange, canvas, skeleton }: OptionsProps) {
const revealed = Tankopedia.use((state) => state.revealed);
const disturbed = Tankopedia.use((state) => state.disturbed);
const highGraphics = TankopediaPersistent.use((state) => state.highGraphics);
const modelRequested = Tankopedia.use((state) => state.modelRequested);
const modelRequested = Tankopedia.use(
(state) => state.loaders.model.requested,
);
const equalize = Duel.use((state) => state.equalize);
const equalizer = Duel.use((state) => state.antagonist.tank.equalizer);
@@ -88,6 +95,8 @@ export function Options({ thicknessRange, canvas, skeleton }: OptionsProps) {
<Thicknesses skeleton={skeleton} thicknessRange={thicknessRange} />
<SkinSwitcher />
<Flex
gap="2"
direction="column"
@@ -182,24 +191,13 @@ export function Options({ thicknessRange, canvas, skeleton }: OptionsProps) {
</Popover.Root>
)}
<Flex
direction="column"
style={{
borderRadius: "var(--radius-full)",
}}
overflow="hidden"
>
<SidebarIconGroup>
{antagonistGun.shells.map((thisShell) => (
<IconButton
color={
thisShell.id === antagonistShell.id && !hasCustomShell
? undefined
: "gray"
}
variant="soft"
<SidebarIconButton
key={thisShell.id}
size={{ initial: "2", sm: "3" }}
radius="none"
selected={thisShell.id === antagonistShell.id && !hasCustomShell}
alt={unwrap(thisShell.name!)}
src={alias("api", `/icons/shells/${thisShell.icon}.webp`)}
onClick={() => {
Duel.mutate((draft) => {
draft.antagonist.shell = thisShell;
@@ -209,19 +207,10 @@ export function Options({ thicknessRange, canvas, skeleton }: OptionsProps) {
draft.customShell = undefined;
});
}}
>
<img
alt={unwrap(thisShell.name!)}
src={alias("api", `/icons/shells/${thisShell.icon}.webp`)}
style={{
width: "50%",
height: "50%",
}}
/>
</IconButton>
/>
))}
<CustomShellButton />
</Flex>
</SidebarIconGroup>
<Flex
direction="column"
@@ -1,30 +1,53 @@
import { UpdateIcon } from "@radix-ui/react-icons";
import { Button, Flex, Heading } from "@radix-ui/themes";
import { useEffect, useRef } from "react";
import { SKIN_NUMBER } from "../../../../../../constants/skinNumber";
import { api } from "../../../../../../core/blitzkit/api";
import { useLocale } from "../../../../../../hooks/useLocale";
import { Duel } from "../../../../../../stores/duel";
import { Tankopedia } from "../../../../../../stores/tankopedia";
import type { MaybeSkeletonComponentProps } from "../../../../../../types/maybeSkeletonComponentProps";
import { Tracker } from "./components/Tracker";
const camouflageDefinitions = await api.camouflageDefinitions();
export function Title({ skeleton }: MaybeSkeletonComponentProps) {
const { unwrap, strings } = useLocale();
const modelRequested = Tankopedia.use((state) => state.modelRequested);
const modelRequested = Tankopedia.use(
(state) => state.loaders.model.requested,
);
const protagonist = Duel.use((state) => state.protagonist.tank);
const revealed = Tankopedia.use((state) => state.revealed);
const disturbed = Tankopedia.use((state) => state.disturbed);
const name = unwrap(protagonist.name!);
const hasRevealed = useRef(false);
const skin = Tankopedia.use((state) => state.loaders.skin.requested);
const camouflage =
skin === undefined ? undefined : camouflageDefinitions.camouflages[skin];
const tankName = unwrap(protagonist.name!);
// legendary camos carry the tank's in-battle name
const name = (
camouflage
? camouflage.tank_name
? unwrap(camouflage.tank_name)
: `${tankName} ${unwrap(camouflage.name!)}`
: tankName
).replace("%(camo_num)", SKIN_NUMBER);
const fontSize = revealed
? disturbed
? "1.5rem"
: "2rem"
: `min(12vh, ${75 / name.length}vw)`;
useEffect(() => {
if (revealed) hasRevealed.current = true;
}, [revealed]);
return (
<Flex
onPointerDown={(event) => {
event.preventDefault();
if (!modelRequested) return;
if (!modelRequested || hasRevealed.current) return;
Tankopedia.mutate((draft) => {
draft.revealed = true;
@@ -86,7 +109,7 @@ export function Title({ skeleton }: MaybeSkeletonComponentProps) {
onPointerDown={(event) => event.stopPropagation()}
onClick={() => {
Tankopedia.mutate((draft) => {
draft.modelRequested = true;
draft.loaders.model.requested = true;
});
}}
>
@@ -18,7 +18,7 @@ import { TankopediaDisplay } from "../../../../../../stores/tankopediaPersistent
const ANGLE = degToRad(15);
const REVEAL_ANIMATION_TIME = 3;
const TRANSITION_ANIMATION_TIME = 0.5;
export const TRANSITION_ANIMATION_TIME = 0.5;
const LIGHTS_COUNT = 3;
const THETA_OFFSET = degToRad(180 - 45);
@@ -46,17 +46,26 @@ export function Lighting({ hasPbr }: Props) {
const highGraphics = TankopediaPersistent.use((state) => state.highGraphics);
const requestedDisplay = Tankopedia.use((state) => state.requestedDisplay);
const revealed = Tankopedia.use((state) => state.revealed);
const skinPending = Tankopedia.use(
(state) =>
state.loaders.skin.requested !== state.loaders.skin.loaded ||
state.loaders.attachment.requested !== state.loaders.attachment.loaded,
);
const animationTime = useRef(REVEAL_ANIMATION_TIME);
const t0 = useRef(performance.now() / 1e3 - animationTime.current);
const holding = useRef(false);
const isRevealing = useRef(true);
const isFirstReveal = useRef(true);
const [animate, setAnimate] = useState(true);
useEffect(() => {
Tankopedia.mutate((draft) => {
draft.revealed = true;
draft.loaders.model.loaded = true;
});
}, []);
@@ -71,6 +80,37 @@ export function Lighting({ hasPbr }: Props) {
setAnimate(true);
}, [requestedDisplay]);
useEffect(() => {
if (isFirstReveal.current) {
isFirstReveal.current = false;
return;
}
if (!revealed) {
animationTime.current = TRANSITION_ANIMATION_TIME;
t0.current = performance.now() / 1e3;
holding.current = true;
} else if (holding.current) {
animationTime.current = REVEAL_ANIMATION_TIME;
t0.current = performance.now() / 1e3 - animationTime.current;
holding.current = false;
} else {
return;
}
setAnimate(true);
invalidate();
}, [revealed]);
useEffect(() => {
if (!skinPending) return;
animationTime.current = TRANSITION_ANIMATION_TIME;
t0.current = performance.now() / 1e3;
setAnimate(true);
}, [skinPending]);
return (
<>
<ContactShadows
@@ -90,6 +130,7 @@ export function Lighting({ hasPbr }: Props) {
hasPbr={hasPbr}
t0={t0}
animationTime={animationTime}
holding={holding}
wrapper={wrapper}
stop={() => {
setAnimate(false);
@@ -124,6 +165,7 @@ export function Lighting({ hasPbr }: Props) {
key={index}
position={position}
intensity={intensity}
userData={{ intensity }}
penumbra={1}
castShadow={highGraphics}
decay={1}
@@ -145,11 +187,19 @@ interface AnimatorProps {
t0: RefObject<number>;
animationTime: RefObject<number>;
holding: RefObject<boolean>;
wrapper: RefObject<Group | null>;
hasPbr: boolean;
}
function Animator({ stop, t0, animationTime, wrapper, hasPbr }: AnimatorProps) {
function Animator({
stop,
t0,
animationTime,
holding,
wrapper,
hasPbr,
}: AnimatorProps) {
const requestedDisplay = Tankopedia.use((state) => state.requestedDisplay);
const display = Tankopedia.use((state) => state.display);
@@ -166,6 +216,11 @@ function Animator({ stop, t0, animationTime, wrapper, hasPbr }: AnimatorProps) {
for (const child of wrapper.current.children) {
if (child instanceof SpotLight) {
child.angle = ANGLE * t * factor;
child.intensity = child.userData.intensity;
if (child.angle === 0) {
child.intensity = 0;
}
} else if (child instanceof HemisphereLight) {
child.intensity = HEMISPHERE_INTENSITY * t * factor;
}
@@ -176,7 +231,7 @@ function Animator({ stop, t0, animationTime, wrapper, hasPbr }: AnimatorProps) {
useFrame(({ invalidate }) => {
const dt = performance.now() / 1e3 - t0.current;
const x = clamp(dt / animationTime.current, 0, 2);
const x = clamp(dt / animationTime.current, 0, holding.current ? 1 : 2);
if (x === 2) {
apply(1);
@@ -188,6 +243,9 @@ function Animator({ stop, t0, animationTime, wrapper, hasPbr }: AnimatorProps) {
const t = (0.5 * Math.sin(Math.PI * (x + 0.5)) + 0.5) ** 2;
apply(t);
if (holding.current && x === 1) return;
invalidate();
if (x >= 1 && Tankopedia.state.display !== requestedDisplay) {
@@ -1,18 +1,21 @@
import { invalidate, useThree, type ThreeEvent } from "@react-three/fiber";
import { useRef } from "react";
import type { QuicklimeEvent } from "quicklime";
import { useEffect, useRef } from "react";
import { Group, Mesh, MeshStandardMaterial, Vector2 } from "three";
import { applyPitchYawLimits } from "../../../../../../core/blitz/applyPitchYawLimits";
import { hasEquipment } from "../../../../../../core/blitzkit/hasEquipment";
import { jsxTree } from "../../../../../../core/blitzkit/jsxTree";
import { modelTransformEvent } from "../../../../../../core/blitzkit/modelTransform";
import { skinTree } from "../../../../../../core/blitzkit/skinTree";
import { controlsEnabledEvent } from "../../../../../../core/controlsEnabled";
import { useModel } from "../../../../../../hooks/useModel";
import { useSkin } from "../../../../../../hooks/useSkin";
import { useTankModelDefinition } from "../../../../../../hooks/useTankModelDefinition";
import { useTankTransform } from "../../../../../../hooks/useTankTransform";
import { Duel } from "../../../../../../stores/duel";
import { Tankopedia } from "../../../../../../stores/tankopedia";
import { TankopediaDisplay } from "../../../../../../stores/tankopediaPersistent/constants";
import { ModelTankWrapper } from "../../../../../Armor/components/ModelTankWrapper";
import { transitionEvent } from "./Lighting";
export function TankModel() {
const protagonist = Duel.use((draft) => draft.protagonist);
@@ -28,9 +31,46 @@ export function TankModel() {
const gunModelDefinition = turretModelDefinition.guns[protagonist.gun.id];
const { gltf } = useModel(protagonist.tank.id);
const nodes = Object.values(gltf.nodes);
const skinGltf = useSkin(protagonist.tank.id);
const attachmentGltf = useSkin(protagonist.tank.id, "attachment");
const skins = [skinGltf, attachmentGltf].filter((skin) => skin !== undefined);
useTankTransform(track, turret, turretContainer, gunContainer);
useEffect(() => {
const emissiveMaterials = new Set<MeshStandardMaterial>();
for (const scene of [gltf.scene, ...skins.map((skin) => skin.scene)]) {
scene.traverse((object) => {
if (
object instanceof Mesh &&
object.material instanceof MeshStandardMaterial &&
object.material.emissiveMap
) {
emissiveMaterials.add(object.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;
}
}
function handleTransitionEvent(event: QuicklimeEvent<number>) {
applyIntensity(event.data);
}
applyIntensity(transitionEvent.last!);
transitionEvent.on(handleTransitionEvent);
return () => {
transitionEvent.off(handleTransitionEvent);
};
}, [gltf, skinGltf, attachmentGltf]);
return (
<ModelTankWrapper ref={hullContainer}>
{nodes.map((node) => {
@@ -92,7 +132,7 @@ export function TankModel() {
window.removeEventListener("pointerup", handlePointerUp);
}
return jsxTree(node, {
return skinTree(node, skins, {
mesh(_, props, key) {
return (
<mesh
@@ -169,7 +209,7 @@ export function TankModel() {
window.removeEventListener("pointerup", handlePointerUp);
}
return jsxTree(node, {
return skinTree(node, skins, {
mesh(_, props, key) {
return (
<mesh
@@ -250,7 +290,7 @@ export function TankModel() {
window.removeEventListener("pointerup", handlePointerUp);
}
return jsxTree(node, {
return skinTree(node, skins, {
mesh(_, props, key) {
return (
<mesh
@@ -20,7 +20,9 @@ export function HeroSection({ skeleton }: MaybeSkeletonComponentProps) {
const canvas = useRef<HTMLCanvasElement>(null!);
const isFullScreen = useFullScreen();
const protagonist = Duel.use((state) => state.protagonist.tank);
const modelRequested = Tankopedia.use((state) => state.modelRequested);
const modelRequested = Tankopedia.use(
(state) => state.loaders.model.requested,
);
const thicknessRange = useMemo(() => {
const entries = Object.values(tankDefinitions.tanks);
const filtered = entries.filter(