mirror of
https://github.com/blitzkit/blitzkit.git
synced 2026-11-10 11:16:10 +01:00
shot card
This commit is contained in:
17 files changed
+373
-1158
No files matched your search
@@ -50,6 +50,31 @@
|
||||
"reset": "Reset"
|
||||
},
|
||||
|
||||
"shot_card": {
|
||||
"element": {
|
||||
"primary": "Primary",
|
||||
"spaced": "Spaced",
|
||||
"external": "External",
|
||||
"gap": "Gap",
|
||||
"gun": "Gun",
|
||||
"track": "Tracks"
|
||||
},
|
||||
|
||||
"status": {
|
||||
"penetration": "{damage}hp penetration",
|
||||
"blocked": "Blocked",
|
||||
"ricochet": "Ricochet",
|
||||
"splash": "{damage}hp splash"
|
||||
},
|
||||
|
||||
"stats": {
|
||||
"thickness_and_angle": "{thickness}mm {angle}°",
|
||||
"nominal": "{thickness}mm nominal",
|
||||
"ricochet_loss": "{loss}% penetration",
|
||||
"ricochet_divider": "Ricochet (-25% penetration)"
|
||||
}
|
||||
},
|
||||
|
||||
"tabs": {
|
||||
"model": "Model",
|
||||
"dynamic_armor": "Dynamic armor",
|
||||
|
||||
@@ -15,7 +15,7 @@ import { LocaleProvider, useLocale } from "../hooks/useLocale";
|
||||
import { useStrings } from "../hooks/useStrings";
|
||||
import { App } from "../stores/app";
|
||||
import { Tankopedia } from "../stores/tankopedia";
|
||||
import { layerTypeNames } from "./ShotDisplayCard";
|
||||
import { layerTypeNames } from "./ShotCard";
|
||||
import { ArmorType } from "./SpacedArmorScene";
|
||||
|
||||
export function ArmorPlateDisplay() {
|
||||
@@ -102,11 +102,11 @@ export function ArmorPlateDisplay() {
|
||||
|
||||
Tankopedia.mutate((draft) => {
|
||||
draft.highlightArmor = undefined;
|
||||
const { protagonist } = Duel.state;
|
||||
const tank = draft.model;
|
||||
const turret = tank.turrets[protagonist.turret.id];
|
||||
const gun = turret.guns[protagonist.gun.id];
|
||||
const track = tank.tracks[protagonist.track.id];
|
||||
const { protagonist } = draft;
|
||||
const tank = protagonist.model;
|
||||
const turret = tank.turrets[protagonist.turret];
|
||||
const gun = turret.guns[protagonist.gun];
|
||||
const track = tank.tracks[protagonist.track];
|
||||
|
||||
if (highlightArmor.name.startsWith("hull_")) {
|
||||
tank.armor!.thickness[index] = thickness;
|
||||
@@ -155,18 +155,17 @@ export function ArmorPlateDisplay() {
|
||||
|
||||
draft.highlightArmor = undefined;
|
||||
const initialState = Tankopedia.initial;
|
||||
const { protagonist } = Duel.state;
|
||||
const tank = draft.model;
|
||||
const initialTank = initialState.model;
|
||||
const turret = tank.turrets[protagonist.turret.id];
|
||||
const { protagonist } = draft;
|
||||
const tank = protagonist.model;
|
||||
const initialTank = initialState.protagonist.model;
|
||||
const turret = tank.turrets[protagonist.turret];
|
||||
const initialTurret =
|
||||
initialTank.turrets[protagonist.turret.id];
|
||||
const gun = turret.guns[protagonist.gun.id];
|
||||
const initialGun =
|
||||
initialTurret.guns[protagonist.gun.id];
|
||||
const track = tank.tracks[protagonist.track.id];
|
||||
initialTank.turrets[protagonist.turret];
|
||||
const gun = turret.guns[protagonist.gun];
|
||||
const initialGun = initialTurret.guns[protagonist.gun];
|
||||
const track = tank.tracks[protagonist.track];
|
||||
const initialTrack =
|
||||
initialTank.tracks[protagonist.track.id];
|
||||
initialTank.tracks[protagonist.track];
|
||||
|
||||
if (highlightArmor.name.startsWith("hull_")) {
|
||||
tank.armor!.thickness[index] =
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
.cardWrapper {
|
||||
pointer-events: none;
|
||||
|
||||
/* keeps the card clear of the tracer, on the side it isn't heading toward */
|
||||
&[data-going-up="true"] {
|
||||
transform: translateY(50%);
|
||||
margin-top: 4rem;
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
&[data-going-up="false"] {
|
||||
transform: translateY(-50%);
|
||||
margin-bottom: 4rem;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.point {
|
||||
opacity: 0.75;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
|
||||
padding: var(--space-4);
|
||||
border-radius: var(--space-3);
|
||||
border: 1px solid var(--gray-a4);
|
||||
background-color: var(--gray-2);
|
||||
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.layers {
|
||||
display: table;
|
||||
|
||||
/* column-gap doesn't apply to tables; border-spacing also pads the outer
|
||||
edges, so pull them back in */
|
||||
border-spacing: var(--space-3) var(--space-2);
|
||||
margin: 0 calc(-1 * var(--space-3));
|
||||
}
|
||||
|
||||
.row {
|
||||
display: table-row;
|
||||
}
|
||||
|
||||
.cell {
|
||||
display: table-cell;
|
||||
|
||||
&[data-align="right"] {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
.divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
|
||||
/* bleed to the card edges */
|
||||
margin: 0 calc(-1 * var(--space-4));
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: "";
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background-color: var(--gray-a4);
|
||||
}
|
||||
}
|
||||
+220
-36
@@ -1,5 +1,5 @@
|
||||
import { J_HAT, K_HAT } from "@blitzkit/core";
|
||||
import { Box, Flex, Text } from "@radix-ui/themes";
|
||||
import { literals } from "@blitzkit/i18n/src/literals";
|
||||
import { Html } from "@react-three/drei";
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
import { clamp } from "lodash-es";
|
||||
@@ -16,37 +16,236 @@ import {
|
||||
Path,
|
||||
Quaternion,
|
||||
} from "three";
|
||||
import { isHalloween } from "../core/blitzkit/isHalloween";
|
||||
import { LocaleProvider, useLocale } from "../hooks/useLocale";
|
||||
import { radToDeg } from "three/src/math/MathUtils.js";
|
||||
import { isHalloween } from "../../core/blitzkit/isHalloween";
|
||||
import { LocaleProvider, useLocale } from "../../hooks/useLocale";
|
||||
import { useStrings } from "../../hooks/useStrings";
|
||||
import {
|
||||
Tankopedia,
|
||||
type Shot,
|
||||
type ShotLayer,
|
||||
type ShotLayerNonExternal,
|
||||
type ShotStatus,
|
||||
} from "../stores/tankopedia";
|
||||
import { ShotDisplayCard } from "./ShotDisplayCard";
|
||||
} from "../../stores/tankopedia";
|
||||
import { classNames } from "../../ui/classNames";
|
||||
import { ArmorType } from "../SpacedArmorScene";
|
||||
import { Text, type TextProps } from "../Text";
|
||||
import styles from "./index.module.css";
|
||||
|
||||
export const shotStatusColors: Record<
|
||||
ShotStatus,
|
||||
ComponentProps<typeof Text>["color"]
|
||||
> = {
|
||||
export const shotStatusColors: Record<ShotStatus, TextProps["color"]> = {
|
||||
blocked: "red",
|
||||
penetration: "green",
|
||||
ricochet: "yellow",
|
||||
splash: "orange",
|
||||
};
|
||||
|
||||
export const layerTypeNames = {
|
||||
[ArmorType.Primary]: "primary",
|
||||
[ArmorType.Spaced]: "spaced",
|
||||
[ArmorType.External]: "external",
|
||||
null: "gap",
|
||||
} as const;
|
||||
|
||||
interface LayerEntryProps {
|
||||
layerIndex?: string;
|
||||
color: TextProps["color"];
|
||||
layerName: string;
|
||||
layer: ShotLayer;
|
||||
angle?: number;
|
||||
}
|
||||
|
||||
function LayerEntry({
|
||||
layerIndex,
|
||||
color,
|
||||
layerName,
|
||||
layer,
|
||||
angle,
|
||||
}: LayerEntryProps) {
|
||||
const locale = useLocale();
|
||||
const strings = useStrings();
|
||||
const { stats } = strings.tanks.shot_card;
|
||||
const format = (value: number) => Math.round(value).toLocaleString(locale);
|
||||
const mm = (value: number) =>
|
||||
literals(strings.common.units.mm, { value: format(value) });
|
||||
|
||||
return (
|
||||
<div className={styles.row}>
|
||||
<div className={styles.cell}>
|
||||
<Text size="minor" lowContrast>
|
||||
{layerIndex}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div className={styles.cell}>
|
||||
<Text color={color}>{layerName}</Text>
|
||||
</div>
|
||||
|
||||
<div className={styles.cell}>
|
||||
<Text color={color}>
|
||||
{layer.type === null && mm(layer.distance * 1000)}
|
||||
{layer.type === ArmorType.External && mm(layer.thickness)}
|
||||
{(layer.type === ArmorType.Primary ||
|
||||
layer.type === ArmorType.Spaced) &&
|
||||
(angle === undefined
|
||||
? mm(layer.thicknessAngled)
|
||||
: literals(stats.thickness_and_angle, {
|
||||
thickness: format(layer.thicknessAngled),
|
||||
angle: radToDeg(angle).toFixed(0),
|
||||
}))}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div className={styles.cell} data-align="right">
|
||||
{layer.type === null && (
|
||||
<Text lowContrast>
|
||||
{literals(stats.ricochet_loss, {
|
||||
loss: Math.max(-100, -50 * layer.distance).toFixed(0),
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{(layer.type === ArmorType.Primary ||
|
||||
layer.type === ArmorType.Spaced) && (
|
||||
<Text lowContrast>
|
||||
{literals(stats.nominal, { thickness: format(layer.thickness) })}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface LayersProps {
|
||||
layers: ShotLayer[];
|
||||
status: Shot["in"]["status"];
|
||||
containsGaps: boolean;
|
||||
/** Offset applied to displayed layer numbers (for post-ricochet layers). */
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
function Layers({ layers, status, containsGaps, offset = 0 }: LayersProps) {
|
||||
const strings = useStrings();
|
||||
|
||||
return (
|
||||
<div className={styles.layers}>
|
||||
{layers.map((layer, index) => {
|
||||
const shiftedIndex = index + offset;
|
||||
|
||||
return (
|
||||
<LayerEntry
|
||||
key={index}
|
||||
layer={layer}
|
||||
layerName={
|
||||
strings.tanks.shot_card.element[
|
||||
layer.type === ArmorType.External
|
||||
? layer.variant
|
||||
: layerTypeNames[`${layer.type}`]
|
||||
]
|
||||
}
|
||||
layerIndex={
|
||||
layer.type === null
|
||||
? undefined
|
||||
: `${(containsGaps ? shiftedIndex / 2 : shiftedIndex) + 1}.`
|
||||
}
|
||||
color={
|
||||
status === "splash"
|
||||
? shotStatusColors.splash
|
||||
: shotStatusColors[layer.status]
|
||||
}
|
||||
angle={
|
||||
layer.type === ArmorType.Primary ||
|
||||
layer.type === ArmorType.Spaced
|
||||
? layer.angle
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ShotCardHtmlProps extends ComponentProps<"div"> {
|
||||
shot: Shot;
|
||||
}
|
||||
|
||||
function ShotCardHtml({ shot, className, ...props }: ShotCardHtmlProps) {
|
||||
const locale = useLocale();
|
||||
const strings = useStrings();
|
||||
|
||||
if (!shot) return null;
|
||||
|
||||
const { status, stats } = strings.tanks.shot_card;
|
||||
const damage = Math.round(shot.damage).toLocaleString(locale);
|
||||
const hasOutLayers = shot.out !== undefined && shot.out.layers.length > 0;
|
||||
const penetrationChance = shot.penetrationChance !== undefined && (
|
||||
<Text lowContrast weight="bold">
|
||||
{literals(strings.common.units.percentage, {
|
||||
value: (shot.penetrationChance * 100).toFixed(0),
|
||||
})}
|
||||
</Text>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={classNames(styles.card, className)} {...props}>
|
||||
<div className={styles.section}>
|
||||
<div className={styles.header}>
|
||||
<Text color={shotStatusColors[shot.in.status]} weight="bold">
|
||||
{literals(status[shot.in.status], { damage })}
|
||||
</Text>
|
||||
|
||||
{!hasOutLayers && penetrationChance}
|
||||
</div>
|
||||
|
||||
<Layers
|
||||
layers={shot.in.layers}
|
||||
status={shot.in.status}
|
||||
containsGaps={shot.containsGaps}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{shot.out && hasOutLayers && (
|
||||
<>
|
||||
<div className={styles.divider}>
|
||||
<Text size="minor" lowContrast>
|
||||
{stats.ricochet_divider}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div className={styles.section}>
|
||||
<div className={styles.header}>
|
||||
<Text color={shotStatusColors[shot.out.status]} weight="bold">
|
||||
{literals(status[shot.out.status], { damage })}
|
||||
</Text>
|
||||
|
||||
{penetrationChance}
|
||||
</div>
|
||||
|
||||
<Layers
|
||||
layers={shot.out.layers}
|
||||
status={shot.out.status}
|
||||
containsGaps={shot.containsGaps}
|
||||
offset={shot.in.layers.length}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const SURFACE_POINT_SIZE = 12;
|
||||
export const SHOT_DISPLAY_LENGTH_INFINITY = 4;
|
||||
const SHOT_DISPLAY_LENGTH_INFINITY = 4;
|
||||
const TRACER_THICK = 1 / 32;
|
||||
const TRACER_THIN = TRACER_THICK / 2;
|
||||
|
||||
export function ShotDisplay() {
|
||||
export function ShotCard() {
|
||||
const shot = Tankopedia.use((state) => state.shot);
|
||||
const inTracer = useRef<Mesh>(null);
|
||||
const outTracer = useRef<Mesh>(null);
|
||||
const splashRadiusWrapper = useRef<Group>(null);
|
||||
const splashRadiusMaterial = useRef<MeshBasicMaterial>(null);
|
||||
const { locale } = useLocale();
|
||||
const locale = useLocale();
|
||||
|
||||
useEffect(() => {
|
||||
if (shot?.splashRadius !== undefined) {
|
||||
@@ -140,20 +339,11 @@ export function ShotDisplay() {
|
||||
)}
|
||||
|
||||
<Html position={inLast.point} center>
|
||||
<Box
|
||||
style={{
|
||||
pointerEvents: "none",
|
||||
transform: `translateY(${tracerGoingUp ? 50 : -50}%)`,
|
||||
}}
|
||||
mb={tracerGoingUp ? "0" : "9"}
|
||||
pb={tracerGoingUp ? "0" : "4"}
|
||||
mt={tracerGoingUp ? "9" : "0"}
|
||||
pt={tracerGoingUp ? "4" : "0"}
|
||||
>
|
||||
<div className={styles.cardWrapper} data-going-up={tracerGoingUp}>
|
||||
<LocaleProvider locale={locale}>
|
||||
<ShotDisplayCard shot={shot} />
|
||||
<ShotCardHtml shot={shot} />
|
||||
</LocaleProvider>
|
||||
</Box>
|
||||
</div>
|
||||
</Html>
|
||||
|
||||
{[...shot.in.layers, ...(shot.out?.layers ?? [])].map((layer, index) => {
|
||||
@@ -173,21 +363,15 @@ export function ShotDisplay() {
|
||||
transform: "translateY(-50%)",
|
||||
}}
|
||||
>
|
||||
<Flex
|
||||
<div
|
||||
className={styles.point}
|
||||
style={{
|
||||
width: SURFACE_POINT_SIZE,
|
||||
height: SURFACE_POINT_SIZE,
|
||||
marginLeft: -SURFACE_POINT_SIZE / 2,
|
||||
backgroundColor: shotStatusColor,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: SURFACE_POINT_SIZE,
|
||||
height: SURFACE_POINT_SIZE,
|
||||
backgroundColor: shotStatusColor,
|
||||
opacity: 0.75,
|
||||
borderRadius: SURFACE_POINT_SIZE / 2,
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
/>
|
||||
</Html>
|
||||
);
|
||||
})}
|
||||
@@ -1,274 +0,0 @@
|
||||
import { literals } from "@blitzkit/i18n/src/literals";
|
||||
import {
|
||||
Card,
|
||||
Flex,
|
||||
Inset,
|
||||
Separator,
|
||||
Text,
|
||||
type CardProps,
|
||||
} from "@radix-ui/themes";
|
||||
import type { ComponentProps } from "react";
|
||||
import { radToDeg } from "three/src/math/MathUtils.js";
|
||||
import { useLocale } from "../hooks/useLocale";
|
||||
import type { Shot, ShotLayer } from "../stores/tankopedia";
|
||||
import { shotStatusColors } from "./ShotDisplay";
|
||||
import { ArmorType } from "./SpacedArmorScene";
|
||||
|
||||
export const layerTypeNames = {
|
||||
[ArmorType.Primary]: "primary",
|
||||
[ArmorType.Spaced]: "spaced",
|
||||
[ArmorType.External]: "external",
|
||||
null: "gap",
|
||||
} as const;
|
||||
|
||||
function LayerEntry({
|
||||
layerIndex,
|
||||
shotStatusColor,
|
||||
layerName,
|
||||
layer,
|
||||
angle,
|
||||
}: {
|
||||
layerIndex?: string;
|
||||
shotStatusColor: ComponentProps<typeof Text>["color"];
|
||||
layerName: string;
|
||||
layer: ShotLayer;
|
||||
angle?: number;
|
||||
}) {
|
||||
const { locale, strings } = useLocale();
|
||||
|
||||
return (
|
||||
<tr>
|
||||
<td>
|
||||
<Text size="1" color="gray">
|
||||
{layerIndex}
|
||||
</Text>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<Text size="2" color={shotStatusColor}>
|
||||
{layerName}
|
||||
</Text>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
{layer.type === null && (
|
||||
<Text size="2" color={shotStatusColor}>
|
||||
{literals(strings.common.units.mm, {
|
||||
value: Math.round(layer.distance * 1000).toLocaleString(locale),
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{layer.type === ArmorType.External && (
|
||||
<Text size="2" color={shotStatusColor}>
|
||||
{literals(strings.common.units.mm, {
|
||||
value: Math.round(layer.thickness).toLocaleString(locale),
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
{(layer.type === ArmorType.Primary ||
|
||||
layer.type === ArmorType.Spaced) && (
|
||||
<Text size="2" color={shotStatusColor}>
|
||||
{angle === undefined
|
||||
? literals(strings.common.units.mm, {
|
||||
value: Math.round(layer.thicknessAngled).toLocaleString(
|
||||
locale
|
||||
),
|
||||
})
|
||||
: literals(
|
||||
strings.website.tools.tankopedia.sandbox.dynamic.shot_card
|
||||
.stats.thickness_and_angle,
|
||||
{
|
||||
thickness: Math.round(layer.thicknessAngled).toLocaleString(
|
||||
locale
|
||||
),
|
||||
angle: radToDeg(angle ?? 0).toFixed(0),
|
||||
}
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
</td>
|
||||
|
||||
<td>
|
||||
{layer.type === null && (
|
||||
<Text size="2" color="gray">
|
||||
{literals(
|
||||
strings.website.tools.tankopedia.sandbox.dynamic.shot_card.stats
|
||||
.ricochet_loss,
|
||||
{ loss: Math.max(-100, -50 * layer.distance).toFixed(0) }
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{(layer.type === ArmorType.Primary ||
|
||||
layer.type === ArmorType.Spaced) && (
|
||||
<Text size="2" color="gray">
|
||||
{literals(
|
||||
strings.website.tools.tankopedia.sandbox.dynamic.shot_card.stats
|
||||
.nominal,
|
||||
{ thickness: Math.round(layer.thickness).toLocaleString(locale) }
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
interface ShotDisplayCardProps extends CardProps {
|
||||
shot: Shot;
|
||||
}
|
||||
|
||||
export function ShotDisplayCard({ shot, ...props }: ShotDisplayCardProps) {
|
||||
const { locale, strings } = useLocale();
|
||||
|
||||
if (!shot) return null;
|
||||
|
||||
const outTitleColor = shot.out
|
||||
? 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">
|
||||
<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={{
|
||||
whiteSpace: "nowrap",
|
||||
borderSpacing: "var(--space-1) 0",
|
||||
}}
|
||||
>
|
||||
{shot.in.layers.map((layer, index) => {
|
||||
const layerName =
|
||||
strings.website.tools.tankopedia.sandbox.dynamic.shot_card
|
||||
.element[
|
||||
layer.type === ArmorType.External
|
||||
? layer.variant
|
||||
: layerTypeNames[`${layer.type}`]
|
||||
];
|
||||
const layerIndex =
|
||||
layer.type === null
|
||||
? undefined
|
||||
: `${(shot.containsGaps ? index / 2 : index) + 1}.`;
|
||||
const shotStatusColor =
|
||||
shot.in.status === "splash"
|
||||
? shotStatusColors.splash
|
||||
: shotStatusColors[layer.status];
|
||||
|
||||
return (
|
||||
<LayerEntry
|
||||
key={index}
|
||||
layer={layer}
|
||||
layerIndex={layerIndex}
|
||||
layerName={layerName}
|
||||
shotStatusColor={shotStatusColor}
|
||||
angle={
|
||||
layer.type === ArmorType.Primary ||
|
||||
layer.type === ArmorType.Spaced
|
||||
? layer.angle
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</table>
|
||||
</Flex>
|
||||
|
||||
{shot.out && shot.out.layers.length > 0 && (
|
||||
<>
|
||||
<Inset side="x">
|
||||
<Flex align="center" gap="2">
|
||||
<Separator
|
||||
style={{
|
||||
flex: 1,
|
||||
}}
|
||||
/>
|
||||
<Text size="1" color="gray">
|
||||
ricochet (-25% penetration)
|
||||
</Text>
|
||||
<Separator
|
||||
style={{
|
||||
flex: 1,
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
</Inset>
|
||||
|
||||
<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) => {
|
||||
const layerName =
|
||||
strings.website.tools.tankopedia.sandbox.dynamic.shot_card
|
||||
.element[
|
||||
layer.type === ArmorType.External
|
||||
? layer.variant
|
||||
: layerTypeNames[`${layer.type}`]
|
||||
];
|
||||
const shiftedIndex = index + shot.out!.layers.length;
|
||||
const layerIndex =
|
||||
layer.type === null
|
||||
? undefined
|
||||
: `${
|
||||
(shot.containsGaps ? shiftedIndex / 2 : shiftedIndex) +
|
||||
1
|
||||
}.`;
|
||||
const shotStatusColor =
|
||||
shot.out!.status === "splash"
|
||||
? shotStatusColors.splash
|
||||
: shotStatusColors[layer.status];
|
||||
|
||||
return (
|
||||
<LayerEntry
|
||||
key={index}
|
||||
layer={layer}
|
||||
layerIndex={layerIndex}
|
||||
layerName={layerName}
|
||||
shotStatusColor={shotStatusColor}
|
||||
angle={
|
||||
layer.type === ArmorType.Primary ||
|
||||
layer.type === ArmorType.Spaced
|
||||
? layer.angle
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
</>
|
||||
)}
|
||||
</Flex>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,10 @@ import {
|
||||
import { degToRad } from "three/src/math/MathUtils.js";
|
||||
import { defaultEqualizer } from "../config/equalizer";
|
||||
import { jsxTree } from "../core/blitzkit/jsxTree";
|
||||
import {
|
||||
SPALL_LINER_HE_DAMAGE_DELTA,
|
||||
SPALL_LINER_PROVISION_ID,
|
||||
} from "../core/blitzkit/spallLiner";
|
||||
import { discardClippingPlane } from "../core/three/discardClippingPlane";
|
||||
import { DuelModule, getDuel } from "../hooks/useDuel";
|
||||
import { hasEquipment } from "../hooks/useEquipment";
|
||||
@@ -240,8 +244,8 @@ export function SpacedArmorSceneComponent({
|
||||
shell.type === ShellType.SHELL_TYPE_HE
|
||||
? "blocked"
|
||||
: blocked
|
||||
? "blocked"
|
||||
: "penetration",
|
||||
? "blocked"
|
||||
: "penetration",
|
||||
variant: layer.variant,
|
||||
});
|
||||
} else {
|
||||
@@ -292,8 +296,8 @@ export function SpacedArmorSceneComponent({
|
||||
layer.type !== ArmorType.Primary
|
||||
? "blocked"
|
||||
: blocked
|
||||
? "blocked"
|
||||
: "penetration",
|
||||
? "blocked"
|
||||
: "penetration",
|
||||
angle,
|
||||
});
|
||||
}
|
||||
@@ -415,7 +419,7 @@ export function SpacedArmorSceneComponent({
|
||||
|
||||
shot.damage *= antagonistEqualizer.damage;
|
||||
|
||||
const hasSpallLiner = Duel.state.protagonist.provisions.includes(
|
||||
const hasSpallLiner = Tankopedia.state.protagonist.provisions.includes(
|
||||
SPALL_LINER_PROVISION_ID,
|
||||
);
|
||||
if (hasSpallLiner && shell.type === ShellType.SHELL_TYPE_HE) {
|
||||
|
||||
-24
@@ -1,24 +0,0 @@
|
||||
import { type FlexProps, Flex } from "@radix-ui/themes";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
type ConfigurationChildWrapperProps = FlexProps & {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function ConfigurationChildWrapper({
|
||||
children,
|
||||
style,
|
||||
...props
|
||||
}: ConfigurationChildWrapperProps) {
|
||||
return (
|
||||
<Flex
|
||||
gap="2"
|
||||
direction="column"
|
||||
align={{ initial: "center", sm: "start" }}
|
||||
style={{ width: "100%", ...style }}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
-95
@@ -1,95 +0,0 @@
|
||||
import { checkConsumableProvisionInclusivity } from "@blitzkit/core/src/blitzkit/checkConsumableProvisionInclusivity";
|
||||
import { InfoCircledIcon } from "@radix-ui/react-icons";
|
||||
import {
|
||||
Button,
|
||||
Flex,
|
||||
Heading,
|
||||
IconButton,
|
||||
Popover,
|
||||
Text,
|
||||
} from "@radix-ui/themes";
|
||||
import { api } from "../../../../api/dynamic";
|
||||
import { useEquipment } from "../../../../hooks/useEquipment";
|
||||
import { useLocale } from "../../../../hooks/useLocale";
|
||||
import { Duel } from "../../../../stores/duel";
|
||||
import { ConsumablesManager } from "../../../ConsumablesManager";
|
||||
import { ConfigurationChildWrapper } from "./ConfigurationChildWrapper";
|
||||
|
||||
const consumableDefinitions = await api.consumables();
|
||||
|
||||
export function Consumables() {
|
||||
const protagonist = Duel.use((state) => state.protagonist);
|
||||
const consumables = Duel.use((state) => state.protagonist.consumables);
|
||||
const consumablesList = Object.values(
|
||||
consumableDefinitions.consumables,
|
||||
).filter((consumable) =>
|
||||
checkConsumableProvisionInclusivity(
|
||||
consumable,
|
||||
protagonist.tank,
|
||||
protagonist.gun,
|
||||
),
|
||||
);
|
||||
const cooldownBooster = Duel.use(
|
||||
(state) => state.protagonist.cooldownBooster,
|
||||
);
|
||||
const hasConsumableDeliverySystem = useEquipment("protagonist", 118);
|
||||
const hasHighEndConsumables = useEquipment("protagonist", 101);
|
||||
const { strings } = useLocale();
|
||||
|
||||
return (
|
||||
<ConfigurationChildWrapper>
|
||||
<Flex gap="4" align="center">
|
||||
<Flex gap="2" align="center">
|
||||
<Heading size="4">
|
||||
{strings.website.tools.tankopedia.configuration.consumables.title}
|
||||
</Heading>
|
||||
|
||||
<Popover.Root>
|
||||
<Popover.Trigger>
|
||||
<IconButton variant="ghost">
|
||||
<InfoCircledIcon />
|
||||
</IconButton>
|
||||
</Popover.Trigger>
|
||||
|
||||
<Popover.Content>
|
||||
<Text>
|
||||
{
|
||||
strings.website.tools.tankopedia.configuration.consumables
|
||||
.info
|
||||
}
|
||||
</Text>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
</Flex>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="red"
|
||||
onClick={() => {
|
||||
Duel.mutate((draft) => {
|
||||
draft.protagonist.consumables = [];
|
||||
});
|
||||
}}
|
||||
>
|
||||
{strings.website.tools.tankopedia.configuration.consumables.clear}
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
<ConsumablesManager
|
||||
consumables={consumablesList}
|
||||
selected={consumables}
|
||||
cooldownBooster={cooldownBooster}
|
||||
disabled={protagonist.tank.max_consumables === consumables.length}
|
||||
hasConsumableDeliverySystem={hasConsumableDeliverySystem}
|
||||
hasHighEndConsumables={hasHighEndConsumables}
|
||||
timers
|
||||
onConsumablesChange={(consumables) => {
|
||||
Duel.mutate((draft) => {
|
||||
draft.protagonist.consumables = consumables;
|
||||
});
|
||||
}}
|
||||
justify={{ initial: "center", sm: "start" }}
|
||||
/>
|
||||
</ConfigurationChildWrapper>
|
||||
);
|
||||
}
|
||||
-53
@@ -1,53 +0,0 @@
|
||||
import { Button, Flex, Heading } from "@radix-ui/themes";
|
||||
import { api } from "../../../../api/dynamic";
|
||||
import { useLocale } from "../../../../hooks/useLocale";
|
||||
import { Duel } from "../../../../stores/duel";
|
||||
import { EquipmentManager } from "../../../EquipmentManager";
|
||||
import { ConfigurationChildWrapper } from "./ConfigurationChildWrapper";
|
||||
|
||||
const equipmentDefinitions = await api.equipment();
|
||||
|
||||
export function Equipment() {
|
||||
const protagonist = Duel.use((state) => state.protagonist);
|
||||
const equipmentPreset =
|
||||
equipmentDefinitions.presets[protagonist.tank.equipment_preset];
|
||||
const equipmentMatrix = Duel.use(
|
||||
(state) => state.protagonist.equipmentMatrix,
|
||||
);
|
||||
const { strings } = useLocale();
|
||||
|
||||
return (
|
||||
<ConfigurationChildWrapper>
|
||||
<Flex gap="4" align="center">
|
||||
<Heading size="4">
|
||||
{strings.website.tools.tankopedia.configuration.equipment.title}
|
||||
</Heading>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="red"
|
||||
onClick={() => {
|
||||
Duel.mutate((draft) => {
|
||||
draft.protagonist.equipmentMatrix.forEach((row) => {
|
||||
row.forEach((_, index) => {
|
||||
row[index] = 0;
|
||||
});
|
||||
});
|
||||
});
|
||||
}}
|
||||
>
|
||||
{strings.website.tools.tankopedia.configuration.equipment.clear}
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
<EquipmentManager
|
||||
matrix={equipmentMatrix}
|
||||
preset={equipmentPreset}
|
||||
onChange={(matrix) => {
|
||||
Duel.mutate((draft) => {
|
||||
draft.protagonist.equipmentMatrix = matrix;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</ConfigurationChildWrapper>
|
||||
);
|
||||
}
|
||||
-65
@@ -1,65 +0,0 @@
|
||||
import { alias } from "@blitzkit/core";
|
||||
import { Flex, Heading } from "@radix-ui/themes";
|
||||
import { useLocale } from "../../../../hooks/useLocale";
|
||||
import { Duel } from "../../../../stores/duel";
|
||||
import { GenericTankComponentButton } from "../../../ModuleButtons/GenericTankComponentButton";
|
||||
import { ConfigurationChildWrapper } from "./ConfigurationChildWrapper";
|
||||
|
||||
export function Miscellaneous() {
|
||||
const camouflage = Duel.use((state) => state.protagonist.camouflage);
|
||||
const cooldownBooster = Duel.use(
|
||||
(state) => state.protagonist.cooldownBooster,
|
||||
);
|
||||
const { tank } = Duel.use((state) => state.protagonist);
|
||||
const { strings } = useLocale();
|
||||
|
||||
return (
|
||||
<ConfigurationChildWrapper>
|
||||
<Heading size="4">
|
||||
{strings.website.tools.tankopedia.configuration.miscellaneous.title}
|
||||
</Heading>
|
||||
|
||||
<Flex gap="2" align="center">
|
||||
{!tank.fixed_camouflage && (
|
||||
<GenericTankComponentButton
|
||||
icon={alias("api", "/icons/camo.webp")}
|
||||
selected={camouflage}
|
||||
iconStyles={{
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
transform: "translate(calc(-50% + 4px), calc(-50% + 4px))",
|
||||
}}
|
||||
onClick={() => {
|
||||
Duel.mutate((draft) => {
|
||||
draft.protagonist.camouflage = !camouflage;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<GenericTankComponentButton
|
||||
icon={alias("api", "/icons/boosters/equipment.webp")}
|
||||
selected={cooldownBooster > 0}
|
||||
banner={
|
||||
cooldownBooster === 0
|
||||
? undefined
|
||||
: cooldownBooster === 1
|
||||
? "#afb0abc0"
|
||||
: cooldownBooster === 2
|
||||
? "#5f72cbc0"
|
||||
: "#9b3cc0c0"
|
||||
}
|
||||
iconStyles={{
|
||||
transform: "translate(-50%, -50%) scale(0.8)",
|
||||
}}
|
||||
onClick={() => {
|
||||
Duel.mutate((draft) => {
|
||||
draft.protagonist.cooldownBooster++;
|
||||
if (draft.protagonist.cooldownBooster === 4)
|
||||
draft.protagonist.cooldownBooster = 0;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
</ConfigurationChildWrapper>
|
||||
);
|
||||
}
|
||||
-446
@@ -1,446 +0,0 @@
|
||||
import {
|
||||
alias,
|
||||
formatCompact,
|
||||
tankIcon,
|
||||
romanize,
|
||||
} from "@blitzkit/core";
|
||||
import {
|
||||
EngineDefinition,
|
||||
GunDefinition,
|
||||
ModuleType,
|
||||
TrackDefinition,
|
||||
TurretDefinition,
|
||||
type TankDefinition,
|
||||
type Unlock,
|
||||
} from "@blitzkit/protos";
|
||||
import {
|
||||
Button,
|
||||
ChevronDownIcon,
|
||||
Flex,
|
||||
Heading,
|
||||
IconButton,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from "@radix-ui/themes";
|
||||
import { api } from "../../../../api/dynamic";
|
||||
import { useLocale } from "../../../../hooks/useLocale";
|
||||
import { Duel } from "../../../../stores/duel";
|
||||
import { LinkI18n } from "../../../LinkI18n";
|
||||
import { ConfigurationChildWrapper } from "./ConfigurationChildWrapper";
|
||||
|
||||
type ModuleDefinition =
|
||||
| TurretDefinition
|
||||
| GunDefinition
|
||||
| TrackDefinition
|
||||
| EngineDefinition;
|
||||
|
||||
function extractModule(raw: ModuleDefinition | TankDefinition) {
|
||||
if ("gun_type" in raw) {
|
||||
return raw;
|
||||
} else
|
||||
return raw as Exclude<ModuleDefinition | TankDefinition, GunDefinition>;
|
||||
}
|
||||
|
||||
interface ModuleWithUnlocks {
|
||||
unlocks: Unlock[];
|
||||
}
|
||||
|
||||
const moduleTypeTypeIconName: Record<ModuleType, string> = {
|
||||
[ModuleType.MODULE_TYPE_VEHICLE]: "",
|
||||
[ModuleType.MODULE_TYPE_ENGINE]: "engine",
|
||||
[ModuleType.MODULE_TYPE_TRACKS]: "chassis",
|
||||
[ModuleType.MODULE_TYPE_TURRET]: "turret",
|
||||
[ModuleType.MODULE_TYPE_GUN]: "gun",
|
||||
};
|
||||
|
||||
function ModuleButton({
|
||||
tier,
|
||||
selected,
|
||||
unlock,
|
||||
top,
|
||||
tooltip,
|
||||
onClick,
|
||||
}: {
|
||||
unlock: Unlock;
|
||||
tier: number;
|
||||
selected: boolean;
|
||||
top: boolean;
|
||||
tooltip?: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const isTank = unlock.type === ModuleType.MODULE_TYPE_VEHICLE;
|
||||
const { locale } = useLocale();
|
||||
|
||||
const button = (
|
||||
<IconButton
|
||||
size="4"
|
||||
radius="small"
|
||||
variant={selected ? "surface" : "soft"}
|
||||
color={top ? "amber" : selected ? undefined : "gray"}
|
||||
onClick={onClick}
|
||||
style={{
|
||||
position: "relative",
|
||||
width: isTank ? 72 : undefined,
|
||||
height: isTank ? 72 : undefined,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
size="1"
|
||||
color="gray"
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: isTank ? 8 : 4,
|
||||
top: isTank ? 8 : 4,
|
||||
}}
|
||||
>
|
||||
{romanize(tier)}
|
||||
</Text>
|
||||
|
||||
{unlock.cost!.value > 0 && (
|
||||
<Flex
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: "50%",
|
||||
bottom: 4,
|
||||
transform: "translateX(-50%)",
|
||||
}}
|
||||
align="center"
|
||||
gap={isTank ? "1" : undefined}
|
||||
>
|
||||
<Text size="1" color="gray">
|
||||
{formatCompact(locale, unlock.cost!.value)}
|
||||
</Text>
|
||||
|
||||
<img
|
||||
alt={unlock.cost!.type}
|
||||
src={alias("api", `/icons/currencies/${unlock.cost!.type}.webp`)}
|
||||
style={{
|
||||
width: 12,
|
||||
height: 12,
|
||||
objectFit: "contain",
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
<img
|
||||
alt={ModuleType[unlock.type]}
|
||||
src={
|
||||
isTank
|
||||
? tankIcon(unlock.id)
|
||||
: alias(
|
||||
"api",
|
||||
`/icons/modules/${moduleTypeTypeIconName[unlock.type]}.webp`,
|
||||
)
|
||||
}
|
||||
style={{
|
||||
width: isTank ? 64 : 32,
|
||||
height: isTank ? 64 : 32,
|
||||
objectFit: "contain",
|
||||
}}
|
||||
/>
|
||||
</IconButton>
|
||||
);
|
||||
|
||||
const node = isTank ? (
|
||||
<LinkI18n
|
||||
locale={locale}
|
||||
href={`/tanks/${tankDefinitions.tanks[unlock.id].slug}`}
|
||||
>
|
||||
{button}
|
||||
</LinkI18n>
|
||||
) : (
|
||||
button
|
||||
);
|
||||
|
||||
if (!tooltip) return node;
|
||||
|
||||
return <Tooltip content={tooltip}>{node}</Tooltip>;
|
||||
}
|
||||
|
||||
const tankDefinitions = await api.tanks();
|
||||
|
||||
export function Modules() {
|
||||
const tank = Duel.use((state) => state.protagonist.tank);
|
||||
const hasUpgrades =
|
||||
tank.turrets.length > 1 ||
|
||||
tank.turrets[0].guns.length > 1 ||
|
||||
tank.engines.length > 1 ||
|
||||
tank.tracks.length > 1;
|
||||
const turret = Duel.use((state) => state.protagonist.turret);
|
||||
const gun = Duel.use((state) => state.protagonist.gun);
|
||||
const engine = Duel.use((state) => state.protagonist.engine);
|
||||
const track = Duel.use((state) => state.protagonist.track);
|
||||
const turret0 = tank.turrets[0];
|
||||
const gun0 = turret0.guns[0];
|
||||
const engine0 = tank.engines[0];
|
||||
const track0 = tank.tracks[0];
|
||||
const topTurret = tank.turrets.at(-1)!;
|
||||
const topGun = topTurret.guns.at(-1)!;
|
||||
const topEngine = tank.engines.at(-1)!;
|
||||
const topTrack = tank.tracks.at(-1)!;
|
||||
const { strings, unwrap } = useLocale();
|
||||
|
||||
function setByUnlock(unlock: Unlock) {
|
||||
Duel.mutate((draft) => {
|
||||
if (unlock.type === ModuleType.MODULE_TYPE_TURRET) {
|
||||
draft.protagonist.turret = draft.protagonist.tank.turrets.find(
|
||||
(turret) => turret.id === unlock.id,
|
||||
)!;
|
||||
|
||||
// Check if current gun exists in new turret
|
||||
const gunInNewTurret = draft.protagonist.turret.guns.find(
|
||||
(gun) => gun.id === draft.protagonist.gun.id,
|
||||
);
|
||||
|
||||
if (gunInNewTurret) {
|
||||
// Gun exists - get it FROM this turret
|
||||
draft.protagonist.gun = gunInNewTurret;
|
||||
draft.protagonist.shell = gunInNewTurret.shells[0];
|
||||
} else {
|
||||
// Gun doesn't exist - select top gun from new turret
|
||||
draft.protagonist.gun = draft.protagonist.turret.guns.at(-1)!;
|
||||
draft.protagonist.shell = draft.protagonist.gun.shells[0];
|
||||
}
|
||||
} else if (unlock.type === ModuleType.MODULE_TYPE_GUN) {
|
||||
const gunInTurret = draft.protagonist.turret.guns.find(
|
||||
(gun) => gun.id === unlock.id,
|
||||
);
|
||||
if (gunInTurret) {
|
||||
draft.protagonist.gun = gunInTurret;
|
||||
draft.protagonist.shell = gunInTurret.shells[0];
|
||||
} else {
|
||||
// TODO: warn somehow?
|
||||
const suitableTurret = draft.protagonist.tank.turrets.find((turret) =>
|
||||
turret.guns.some((gun) => gun.id === unlock.id),
|
||||
)!;
|
||||
const gunInSuitableTurret = suitableTurret.guns.find(
|
||||
(gun) => gun.id === unlock.id,
|
||||
)!;
|
||||
|
||||
draft.protagonist.turret = suitableTurret;
|
||||
draft.protagonist.gun = gunInSuitableTurret;
|
||||
draft.protagonist.shell = gunInSuitableTurret.shells[0];
|
||||
}
|
||||
} else if (unlock.type === ModuleType.MODULE_TYPE_ENGINE) {
|
||||
draft.protagonist.engine = draft.protagonist.tank.engines.find(
|
||||
(engine) => engine.id === unlock.id,
|
||||
)!;
|
||||
} else if (unlock.type === ModuleType.MODULE_TYPE_TRACKS) {
|
||||
draft.protagonist.track = draft.protagonist.tank.tracks.find(
|
||||
(track) => track.id === unlock.id,
|
||||
)!;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function tree(type: ModuleType, unlocks: Unlock[]) {
|
||||
return (
|
||||
<Flex gap="2">
|
||||
{unlocks.map((unlock, index) => {
|
||||
const first = index === 0;
|
||||
const last = index === unlocks.length - 1;
|
||||
const central = !first && !last;
|
||||
let module: ModuleDefinition | TankDefinition | undefined = undefined;
|
||||
|
||||
if (unlock.type === ModuleType.MODULE_TYPE_TRACKS) {
|
||||
module = tank.tracks.find((track) => track.id === unlock.id)!;
|
||||
} else if (unlock.type === ModuleType.MODULE_TYPE_ENGINE) {
|
||||
module = tank.engines.find((engine) => engine.id === unlock.id)!;
|
||||
} else if (unlock.type === ModuleType.MODULE_TYPE_TURRET) {
|
||||
module = tank.turrets.find((turret) => turret.id === unlock.id)!;
|
||||
} else if (unlock.type === ModuleType.MODULE_TYPE_GUN) {
|
||||
module = tank.turrets
|
||||
.find((turret) =>
|
||||
turret.guns.some((gun) => gun.id === unlock.id),
|
||||
)!
|
||||
.guns.find((gun) => gun.id === unlock.id)!;
|
||||
} else if (unlock.type === ModuleType.MODULE_TYPE_VEHICLE) {
|
||||
module = tankDefinitions.tanks[unlock.id];
|
||||
}
|
||||
|
||||
if (module === undefined) return null;
|
||||
|
||||
const tooltip = unwrap(module.name!);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
key={unlock.id}
|
||||
align="center"
|
||||
direction="column"
|
||||
gap="2"
|
||||
position="relative"
|
||||
>
|
||||
{unlock.cost!.value > 0 && (
|
||||
<>
|
||||
{unlocks.length > 1 && (
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: "currentcolor",
|
||||
width: `calc(${central ? 100 : 50}% + ${
|
||||
central ? 8 : 4
|
||||
}px)`,
|
||||
height: 1,
|
||||
position: "absolute",
|
||||
right: first ? 0 : undefined,
|
||||
left: last ? 0 : undefined,
|
||||
transform: `translateX(${first ? 4 : last ? -4 : 0}px)`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Flex
|
||||
direction="column"
|
||||
align="center"
|
||||
style={{
|
||||
marginBottom: -7,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: "currentcolor",
|
||||
width: 1,
|
||||
height: 8,
|
||||
}}
|
||||
/>
|
||||
<ChevronDownIcon
|
||||
style={{
|
||||
transform: "translateY(-7px)",
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
</>
|
||||
)}
|
||||
|
||||
<ModuleButton
|
||||
unlock={unlock}
|
||||
tier={extractModule(module).tier}
|
||||
tooltip={tooltip}
|
||||
selected={
|
||||
(unlock.type === ModuleType.MODULE_TYPE_TURRET
|
||||
? turret.id
|
||||
: unlock.type === ModuleType.MODULE_TYPE_GUN
|
||||
? gun.id
|
||||
: unlock.type === ModuleType.MODULE_TYPE_ENGINE
|
||||
? engine.id
|
||||
: unlock.type === ModuleType.MODULE_TYPE_TRACKS
|
||||
? track.id
|
||||
: -1) === unlock.id
|
||||
}
|
||||
top={
|
||||
unlock.type === ModuleType.MODULE_TYPE_TURRET
|
||||
? extractModule(module).id == topTurret.id
|
||||
: unlock.type === ModuleType.MODULE_TYPE_GUN
|
||||
? extractModule(module).id === topGun.id
|
||||
: unlock.type === ModuleType.MODULE_TYPE_ENGINE
|
||||
? extractModule(module).id === topEngine.id
|
||||
: unlock.type === ModuleType.MODULE_TYPE_TRACKS
|
||||
? extractModule(module).id === topTrack.id
|
||||
: false
|
||||
}
|
||||
onClick={() => setByUnlock(unlock)}
|
||||
/>
|
||||
|
||||
{(extractModule(module) as ModuleWithUnlocks).unlocks &&
|
||||
(extractModule(module) as ModuleWithUnlocks).unlocks!.length >
|
||||
1 && (
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: "currentcolor",
|
||||
width: 1,
|
||||
height: 8,
|
||||
marginBottom: -8,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(extractModule(module) as ModuleWithUnlocks).unlocks &&
|
||||
tree(
|
||||
type,
|
||||
(extractModule(module) as ModuleWithUnlocks).unlocks!,
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ConfigurationChildWrapper>
|
||||
<Flex gap="4" align="center">
|
||||
<Heading size="4">
|
||||
{strings.website.tools.tankopedia.configuration.modules.title}
|
||||
</Heading>
|
||||
|
||||
{hasUpgrades && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="red"
|
||||
onClick={() => {
|
||||
Duel.mutate((draft) => {
|
||||
draft.protagonist.turret = draft.protagonist.tank.turrets[0];
|
||||
draft.protagonist.gun = draft.protagonist.turret.guns[0];
|
||||
draft.protagonist.shell = draft.protagonist.gun.shells[0];
|
||||
draft.protagonist.engine = draft.protagonist.tank.engines[0];
|
||||
draft.protagonist.track = draft.protagonist.tank.tracks[0];
|
||||
});
|
||||
}}
|
||||
>
|
||||
{strings.website.tools.tankopedia.configuration.modules.stock}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
Duel.mutate((draft) => {
|
||||
draft.protagonist.turret =
|
||||
draft.protagonist.tank.turrets.at(-1)!;
|
||||
draft.protagonist.gun = draft.protagonist.turret.guns.at(-1)!;
|
||||
draft.protagonist.shell = draft.protagonist.gun.shells[0];
|
||||
draft.protagonist.engine =
|
||||
draft.protagonist.tank.engines.at(-1)!;
|
||||
draft.protagonist.track =
|
||||
draft.protagonist.tank.tracks.at(-1)!;
|
||||
});
|
||||
}}
|
||||
>
|
||||
{strings.website.tools.tankopedia.configuration.modules.upgrade}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
<Flex gap="2" wrap="wrap" gapY="6">
|
||||
{tree(ModuleType.MODULE_TYPE_ENGINE, [
|
||||
{
|
||||
cost: { type: "xp", value: 0 },
|
||||
id: engine0.id,
|
||||
type: ModuleType.MODULE_TYPE_ENGINE,
|
||||
},
|
||||
])}
|
||||
{tree(ModuleType.MODULE_TYPE_TRACKS, [
|
||||
{
|
||||
cost: { type: "xp", value: 0 },
|
||||
id: track0.id,
|
||||
type: ModuleType.MODULE_TYPE_TRACKS,
|
||||
},
|
||||
])}
|
||||
{tree(ModuleType.MODULE_TYPE_TURRET, [
|
||||
{
|
||||
cost: { type: "xp", value: 0 },
|
||||
id: turret0.id,
|
||||
type: ModuleType.MODULE_TYPE_TURRET,
|
||||
},
|
||||
])}
|
||||
{tree(ModuleType.MODULE_TYPE_GUN, [
|
||||
{
|
||||
cost: { type: "xp", value: 0 },
|
||||
id: gun0.id,
|
||||
type: ModuleType.MODULE_TYPE_GUN,
|
||||
},
|
||||
])}
|
||||
</Flex>
|
||||
</ConfigurationChildWrapper>
|
||||
);
|
||||
}
|
||||
-48
@@ -1,48 +0,0 @@
|
||||
import { availableProvisions } from "@blitzkit/core";
|
||||
import { Button, Flex, Heading } from "@radix-ui/themes";
|
||||
import { api } from "../../../../api/dynamic";
|
||||
import { useLocale } from "../../../../hooks/useLocale";
|
||||
import { Duel } from "../../../../stores/duel";
|
||||
import { ProvisionsManager } from "../../../ProvisionsManager";
|
||||
import { ConfigurationChildWrapper } from "./ConfigurationChildWrapper";
|
||||
|
||||
const provisionDefinitions = await api.provisions();
|
||||
|
||||
export function Provisions() {
|
||||
const { tank, gun } = Duel.use((state) => state.protagonist);
|
||||
const provisions = Duel.use((state) => state.protagonist.provisions);
|
||||
const provisionsList = availableProvisions(tank, gun, provisionDefinitions);
|
||||
const { strings } = useLocale();
|
||||
|
||||
return (
|
||||
<ConfigurationChildWrapper>
|
||||
<Flex gap="4" align="center">
|
||||
<Heading size="4">
|
||||
{strings.website.tools.tankopedia.configuration.provisions.title}
|
||||
</Heading>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="red"
|
||||
onClick={() => {
|
||||
Duel.mutate((draft) => {
|
||||
draft.protagonist.provisions = [];
|
||||
});
|
||||
}}
|
||||
>
|
||||
{strings.website.tools.tankopedia.configuration.provisions.clear}
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
<ProvisionsManager
|
||||
provisions={provisionsList.map((provision) => provision.id)}
|
||||
selected={provisions}
|
||||
disabled={tank.max_provisions === provisions.length}
|
||||
onChange={(provisions) => {
|
||||
Duel.mutate((draft) => {
|
||||
draft.protagonist.provisions = provisions;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</ConfigurationChildWrapper>
|
||||
);
|
||||
}
|
||||
-82
@@ -1,82 +0,0 @@
|
||||
import { MAX_SKILL_LEVEL } from "@blitzkit/core";
|
||||
import { InfoCircledIcon } from "@radix-ui/react-icons";
|
||||
import {
|
||||
Button,
|
||||
Flex,
|
||||
Heading,
|
||||
IconButton,
|
||||
Popover,
|
||||
Text,
|
||||
} from "@radix-ui/themes";
|
||||
import { useLocale } from "../../../../hooks/useLocale";
|
||||
import { Tankopedia } from "../../../../stores/tankopedia";
|
||||
import { CrewSkillManager } from "../../../CrewSkillManager";
|
||||
import { ConfigurationChildWrapper } from "./ConfigurationChildWrapper";
|
||||
|
||||
export function Skills() {
|
||||
const skillLevels = Tankopedia.use((state) => state.skills);
|
||||
const { strings } = useLocale();
|
||||
|
||||
return (
|
||||
<ConfigurationChildWrapper>
|
||||
<Flex gap="4" align="center">
|
||||
<Flex gap="2" align="center">
|
||||
<Heading size="4">
|
||||
{strings.website.tools.tankopedia.configuration.skills.title}
|
||||
</Heading>
|
||||
|
||||
<Popover.Root>
|
||||
<Popover.Trigger>
|
||||
<IconButton variant="ghost">
|
||||
<InfoCircledIcon />
|
||||
</IconButton>
|
||||
</Popover.Trigger>
|
||||
|
||||
<Popover.Content>
|
||||
<Flex direction="column" gap="2">
|
||||
<Text>
|
||||
{strings.website.tools.tankopedia.configuration.skills.info}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
</Flex>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="red"
|
||||
onClick={() => {
|
||||
Tankopedia.mutate((draft) => {
|
||||
Object.keys(draft.skills).forEach((skill) => {
|
||||
draft.skills[skill] = 0;
|
||||
});
|
||||
});
|
||||
}}
|
||||
>
|
||||
{strings.website.tools.tankopedia.configuration.skills.clear}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
Tankopedia.mutate((draft) => {
|
||||
Object.keys(draft.skills).forEach((skill) => {
|
||||
draft.skills[skill] = MAX_SKILL_LEVEL;
|
||||
});
|
||||
});
|
||||
}}
|
||||
>
|
||||
{strings.website.tools.tankopedia.configuration.skills.maximize}
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
<CrewSkillManager
|
||||
skillLevels={skillLevels}
|
||||
onChange={(skills) => {
|
||||
Tankopedia.mutate((draft) => {
|
||||
draft.skills = skills;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</ConfigurationChildWrapper>
|
||||
);
|
||||
}
|
||||
@@ -17,7 +17,7 @@ import { CanvasControl } from "../CanvasControl";
|
||||
import { InitialCameraAligner } from "../InitialCameraAligner";
|
||||
import { Lighting, transitionEvent } from "../Lighting";
|
||||
import { SceneProps } from "../SceneProps";
|
||||
import { ShotDisplay } from "../ShotDisplay";
|
||||
import { ShotCard } from "../ShotCard";
|
||||
import { SmartCanvas } from "../SmartCanvas";
|
||||
import { TankDynamicArmor } from "../TankDynamicArmor";
|
||||
import { TankModel } from "../TankModel";
|
||||
@@ -75,7 +75,7 @@ export function TankopediaSandbox({
|
||||
gunModelDefinition.pitch!,
|
||||
turretModelDefinition.yaw,
|
||||
hasImprovedVerticalStabilizer,
|
||||
hasDownImprovedVerticalStabilizer,
|
||||
hasDownImprovedVerticalStabilizer
|
||||
);
|
||||
|
||||
modelTransformEvent.dispatch({ pitch, yaw });
|
||||
@@ -89,7 +89,7 @@ export function TankopediaSandbox({
|
||||
0,
|
||||
gunModelDefinition.pitch!,
|
||||
turretModelDefinition.yaw,
|
||||
hasImprovedVerticalStabilizer,
|
||||
hasImprovedVerticalStabilizer
|
||||
);
|
||||
|
||||
modelTransformEvent.dispatch({ pitch, yaw });
|
||||
@@ -103,7 +103,7 @@ export function TankopediaSandbox({
|
||||
0,
|
||||
gunModelDefinition.pitch!,
|
||||
turretModelDefinition.yaw,
|
||||
hasImprovedVerticalStabilizer,
|
||||
hasImprovedVerticalStabilizer
|
||||
);
|
||||
|
||||
modelTransformEvent.dispatch({ pitch, yaw });
|
||||
@@ -131,7 +131,7 @@ export function TankopediaSandbox({
|
||||
modelTransformEvent.last!.yaw,
|
||||
gunModelDefinition.pitch!,
|
||||
turretModelDefinition.yaw,
|
||||
hasImprovedVerticalStabilizer,
|
||||
hasImprovedVerticalStabilizer
|
||||
);
|
||||
|
||||
modelTransformEvent.dispatch({ pitch, yaw });
|
||||
@@ -191,7 +191,7 @@ function Content({ thicknessRange }: { thicknessRange: ThicknessRange }) {
|
||||
const { hasPbr } = useModel(tank.id);
|
||||
|
||||
const hideTankModelUnderArmor = TankopediaPersistent.use(
|
||||
(state) => state.hideTankModelUnderArmor,
|
||||
(state) => state.hideTankModelUnderArmor
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -208,7 +208,7 @@ function Content({ thicknessRange }: { thicknessRange: ThicknessRange }) {
|
||||
<TankStaticArmor thicknessRange={thicknessRange} />
|
||||
)}
|
||||
|
||||
<ShotDisplay />
|
||||
<ShotCard />
|
||||
<ArmorPlateDisplay />
|
||||
<CanvasAutoClear />
|
||||
|
||||
|
||||
@@ -47,8 +47,6 @@ export function TankopediaSkinSwitcher() {
|
||||
hasSkinAnimations(tank.id, loadedSkin) ||
|
||||
hasSkinAnimations(tank.id, loadedAttachment);
|
||||
|
||||
console.log("hasAnimations", hasAnimations);
|
||||
|
||||
return (
|
||||
<div className={styles["skin-switcher"]} data-visible={isVisible}>
|
||||
{groups.map((group, index) => (
|
||||
|
||||
@@ -23,10 +23,18 @@ export function Text({
|
||||
}: TextProps) {
|
||||
const scale = color === "gray" && lowContrast ? "white" : color;
|
||||
const isBW = color === "black" || color === "white";
|
||||
let power: string | number = isBW ? "a12" : 12;
|
||||
let power = color === "gray" ? "12" : "11";
|
||||
|
||||
if (lowContrast) {
|
||||
power = color === "gray" ? "a9" : isBW ? "a11" : 11;
|
||||
power = "5";
|
||||
|
||||
if (color === "gray") {
|
||||
power = "a9";
|
||||
}
|
||||
|
||||
if (isBW) {
|
||||
power = "a11";
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -36,9 +44,7 @@ export function Text({
|
||||
data-size={size}
|
||||
data-weight={weight}
|
||||
data-align={align === "left" ? undefined : align}
|
||||
style={{
|
||||
color: `var(--${scale}-${power})`,
|
||||
}}
|
||||
style={{ color: `var(--${scale}-${power})` }}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -28,6 +28,7 @@ const { id, locale } = Astro.props;
|
||||
<ColorScale color="jade" />
|
||||
<ColorScale color="jade" alpha />
|
||||
<ColorScale color="cyan" alpha />
|
||||
<ColorScale color="green" />
|
||||
|
||||
<Page {id} {locale} client:only="react">
|
||||
<Page slot="fallback" {id} {locale} skeleton />
|
||||
|
||||
Reference in new issue
Block a user