recover a bunch of stats from legacy, fix enhanced armor in sandbox, add game's module layout from the upgrade screen, a bunch of toys from legacy

This commit is contained in:
tresabhi committed 2026-11-11 22:43:47 -06:00
1 parent cc0284e2ec
commit 6bef735aea
36 files changed
+2416 -582

No files matched your search

@@ -1,11 +1,30 @@
.discriminator {
position: absolute;
right: var(--space-3);
font-size: var(--font-size-minor);
text-align: right;
pointer-events: none;
top: 50%;
white-space: nowrap;
&[data-corner="top-right"],
&[data-corner="bottom-right"] {
right: var(--space-3);
}
&[data-corner="top-left"],
&[data-corner="bottom-left"] {
right: var(--space-3);
}
&[data-corner="top-left"],
&[data-corner="top-right"] {
top: var(--space-3);
}
&[data-corner="bottom-left"],
&[data-corner="bottom-right"] {
bottom: var(--space-3);
}
}
@@ -2,12 +2,21 @@ import type { ComponentProps } from "react";
import { classNames } from "../../ui/classNames";
import styles from "./index.module.css";
interface DiscriminatorProps extends ComponentProps<"span"> {
corner?: `${"bottom" | "top"}-${"left" | "right"}`;
}
/** Small label overlaid on the right edge of a `position: relative` parent */
export function Discriminator({
className,
corner = "bottom-right",
...props
}: ComponentProps<"span">) {
}: DiscriminatorProps) {
return (
<span className={classNames(styles.discriminator, className)} {...props} />
<span
data-corner={corner}
className={classNames(styles.discriminator, className)}
{...props}
/>
);
}
@@ -38,7 +38,7 @@ export function TankopediaAntagonistControl({
const equalize = Tankopedia.use((state) => state.environment.equalize);
const hasCalibratedShells = useEquipment("antagonist", 103);
const hasEnhancedArmor = useEquipment("antagonist", 110);
const hasEnhancedArmor = useEquipment("protagonist", 110);
const tank = useDuel("antagonist", DuelModule.Tank);
const gun = useDuel("antagonist", DuelModule.Gun);
@@ -177,9 +177,9 @@ export function TankopediaAntagonistControl({
draft.shot = undefined;
if (hasEnhancedArmor) {
delete draft.antagonist.equipment[4];
delete draft.protagonist.equipment[4];
} else {
draft.antagonist.equipment[4] = 0;
draft.protagonist.equipment[4] = 0;
}
});
}}
@@ -125,6 +125,9 @@
width: 100%;
height: 100%;
position: relative;
overflow: hidden;
background-color: var(--gray-2);
outline: var(--gray-5) solid var(--space-1);
border-radius: var(--space-3);
@@ -0,0 +1,175 @@
import { mauveDark } from "@radix-ui/colors";
import { useFrame } from "@react-three/fiber";
import type { QuicklimeEvent } from "quicklime";
import { Suspense, useEffect, useRef } from "react";
import { LineBasicMaterial, MeshBasicMaterial, type Group } from "three";
import { degToRad } from "three/src/math/MathUtils.js";
import { api } from "../api/dynamic";
import { jsxTree } from "../core/blitzkit/jsxTree";
import {
modelTransformEvent,
type ModelTransformEventData,
} from "../core/blitzkit/modelTransform";
import { DuelModule, useDuel } from "../hooks/useDuel";
import { useModel } from "../hooks/useModel";
import { SmartCanvas } from "./SmartCanvas";
const modelDefinitions = await api.models();
const ZOOM = 60;
type Chunk = "gun" | "turret" | "body";
const surfaceMaterial: Record<Chunk, MeshBasicMaterial> = {
gun: new MeshBasicMaterial({ color: mauveDark.mauve8, toneMapped: false }),
turret: new MeshBasicMaterial({ color: mauveDark.mauve6, toneMapped: false }),
body: new MeshBasicMaterial({
color: mauveDark.mauve6,
toneMapped: false,
transparent: true,
opacity: 0.5,
}),
};
const outlineMaterial: Record<Chunk, LineBasicMaterial> = {
gun: new LineBasicMaterial({ color: mauveDark.mauve11, toneMapped: false }),
turret: new LineBasicMaterial({ color: mauveDark.mauve8, toneMapped: false }),
body: new LineBasicMaterial({
color: mauveDark.mauve8,
toneMapped: false,
transparent: true,
opacity: 0.5,
}),
};
export function TankopediaFlexibilityCanvas() {
return (
<SmartCanvas
orthographic
camera={{ zoom: ZOOM, rotation: [0, Math.PI / 2, 0] }}
>
<Suspense fallback={null}>
<Content />
</Suspense>
<CameraPositioner />
</SmartCanvas>
);
}
function CameraPositioner() {
useFrame(({ camera, gl: { domElement } }) => {
const { width } = domElement.getBoundingClientRect();
camera.position.set(10, 0, -width / ZOOM / 6);
});
return null;
}
function Content() {
const hullWrapper = useRef<Group>(null);
const gunWrapper = useRef<Group>(null);
const tank = useDuel("protagonist", DuelModule.Tank);
const turret = useDuel("protagonist", DuelModule.Turret);
const tankModel = modelDefinitions.models[tank.id];
const turretModel = tankModel.turrets[turret.id];
// baked-in turret mount tilt, applied as a static base rotation so the live
// gun pitch composes on top of it
const initialTurretPitch = -degToRad(
tankModel.initial_turret_rotation?.pitch ?? 0,
);
useEffect(() => {
function handle(event: QuicklimeEvent<ModelTransformEventData>) {
if (!gunWrapper.current || !hullWrapper.current) return;
gunWrapper.current.rotation.x = event.data.pitch;
if (event.data.yaw) hullWrapper.current.rotation.y = event.data.yaw;
}
modelTransformEvent.on(handle);
return () => {
modelTransformEvent.off(handle);
};
}, []);
const origin = turretModel.gun_origin!;
return (
<>
<group ref={hullWrapper} position={[0, 0, -origin.z]}>
<group position={[0, 0, origin.z]}>
<ModelChunk only="body" />
</group>
</group>
<group
position={[0, -origin.y, -origin.z]}
rotation={[initialTurretPitch, 0, 0]}
>
<group position={[0, origin.y, origin.z]}>
<ModelChunk only="turret" />
<group ref={gunWrapper}>
<ModelChunk only="gun" />
</group>
</group>
</group>
</>
);
}
function ModelChunk({ only }: { only: Chunk }) {
const tank = useDuel("protagonist", DuelModule.Tank);
const turret = useDuel("protagonist", DuelModule.Turret);
const gun = useDuel("protagonist", DuelModule.Gun);
const tankModel = modelDefinitions.models[tank.id];
const turretModel = tankModel.turrets[turret.id];
const gunModel = turretModel.guns[gun.id];
const originZ = tankModel.turret_origin!.z + turretModel.gun_origin!.z;
const originY = tankModel.turret_origin!.y + turretModel.gun_origin!.y;
const { gltf } = useModel(tank.id);
const nodes = Object.values(gltf.nodes);
const gunId = gunModel.model_id.toString().padStart(2, "0");
const turretId = turretModel.model_id.toString().padStart(2, "0");
return (
<group position={[0, -originY, -originZ]}>
{nodes.map((node) => {
const isGun =
node.name === `gun_${gunId}` || node.name === `gun_${gunId}_mask`;
const isTurret = node.name === `turret_${turretId}`;
const isBody =
node.name === "hull" ||
node.name.startsWith("chassis_wheel_") ||
node.name.startsWith("chassis_track_");
if (
(only === "gun" && !isGun) ||
(only === "turret" && !isTurret) ||
(only === "body" && !isBody)
) {
return null;
}
return jsxTree(node, {
mesh(mesh, props, key) {
return (
<>
<mesh key={key} {...props} material={surfaceMaterial[only]} />
<lineSegments material={outlineMaterial[only]}>
<edgesGeometry args={[mesh.geometry, 29]} />
</lineSegments>
</>
);
},
});
})}
</group>
);
}
@@ -39,12 +39,6 @@
}
}
.state {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.shells {
display: flex;
}
@@ -144,6 +138,86 @@
}
}
.tree {
--column-gap: var(--space-5);
--row-gap: var(--space-4);
--line-thickness: var(--space-1);
--line-border: var(--space-1) solid var(--gray-6);
display: grid;
gap: var(--row-gap) var(--column-gap);
.cell {
position: relative;
.trunk-up,
.trunk-down {
position: absolute;
left: calc(var(--column-gap) / -2);
height: 100%;
}
.trunk-up,
.trunk-down,
.snake-up,
.snake-down {
border-left: var(--line-border);
border-right: var(--line-border);
}
.trunk-up {
bottom: 50%;
}
.trunk-down {
top: 50%;
}
.left,
.right {
position: absolute;
top: 50%;
width: calc(var(--column-gap) / 2);
border-top: var(--line-border);
border-bottom: var(--line-border);
}
.left {
right: 100%;
}
.right {
left: 100%;
}
.snake-up,
.snake-down {
position: absolute;
left: 50%;
transform: translateX(-50%);
height: var(--row-gap);
}
.snake-up {
bottom: 100%;
}
.snake-down {
top: 100%;
}
.module {
position: relative;
.icon {
width: 2em;
height: 2em;
}
}
}
}
.lines {
display: flex;
flex-direction: column;
@@ -3,36 +3,56 @@ import {
MAX_SKILL_LEVEL,
permanentSkills,
romanize,
tankIcon,
} from "@blitzkit/core";
import type { Consumable, EquipmentSlot, Provision } from "@blitzkit/protos";
import {
ModuleType,
type Consumable,
type EngineDefinition,
type EquipmentSlot,
type GunDefinition,
type Provision,
type TankDefinition,
type TrackDefinition,
type TurretDefinition,
type Unlock,
} from "@blitzkit/protos";
import {
ClockIcon,
DoubleArrowDownIcon,
DoubleArrowUpIcon,
ResetIcon,
TrashIcon,
} from "@radix-ui/react-icons";
import { chunk } from "lodash-es";
import { useCallback, useMemo } from "react";
import { api } from "../../api/dynamic";
import { equipmentColumns } from "../../config/equipment";
import { DuelModule, useDuel } from "../../hooks/useDuel";
import { useEquipmentPreset } from "../../hooks/useEquipmentPreset";
import { useLocale } from "../../hooks/useLocale";
import { useProvision } from "../../hooks/useProvision";
import { useStrings } from "../../hooks/useStrings";
import { useTankCompatibility } from "../../hooks/useTankCompatibility";
import { useUnwrapper } from "../../hooks/useUnwrapper";
import { Tankopedia } from "../../stores/tankopedia";
import { layoutModuleTree } from "../../tankopedia/moduleTreeLayout";
import { vehicleStatusKeys } from "../../tankopedia/tankState";
import type { ComputedCharacteristics } from "../../types/characteristics";
import { Button } from "../Button";
import { Discriminator } from "../Discriminator";
import { Heading } from "../Heading";
import { IconButton } from "../IconButton";
import { LinkI18n } from "../LinkI18n";
import { Text } from "../Text";
import { Tooltip } from "../Tooltip";
import styles from "./index.module.css";
// TODO: across the entire repo, wrap these in Promise.all's
const equipment = await api.equipment();
const provisions = await api.provisions();
const consumables = await api.consumables();
const tanks = await api.tanks();
const skills = await api.skills();
interface TankopediaLoadoutProps {
@@ -40,23 +60,338 @@ interface TankopediaLoadoutProps {
}
export function TankopediaLoadout({ characteristics }: TankopediaLoadoutProps) {
// const tank = useDuel("protagonist", DuelModule.Tank);
// const showModules = hasUpgrades(tank);
return (
<>
{/* {showModules && <Modules />} */}
<Modules />
<Shells />
<Equipment />
<Provisions />
<Consumables characteristics={characteristics} />
<CrewSkills />
<State />
<Status />
</>
);
}
const moduleIconNames: 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",
};
type AnyModule =
| TurretDefinition
| GunDefinition
| TrackDefinition
| EngineDefinition;
function Modules() {
const strings = useStrings();
const tank = useDuel("protagonist", DuelModule.Tank);
const hasUpgrades =
tank.turrets.length > 1 ||
tank.turrets[0].guns.length > 1 ||
tank.engines.length > 1 ||
tank.tracks.length > 1;
return (
<div className={styles.section}>
<div className={styles.header}>
<Heading size="4">{strings.tanks.loadout.modules}</Heading>
{hasUpgrades && (
<div className={styles["upgrade-buttons"]}>
<IconButton
color="gray"
variant="soft"
onClick={() => {
Tankopedia.mutate((draft) => {
const turret = tank.turrets[0];
const gun = turret.guns[0];
draft.protagonist.turret = turret.id;
draft.protagonist.gun = gun.id;
draft.protagonist.shell = gun.shells[0].id;
draft.protagonist.engine = tank.engines[0].id;
draft.protagonist.track = tank.tracks[0].id;
});
}}
>
<DoubleArrowDownIcon />
</IconButton>
<IconButton
color="gray"
variant="soft"
aria-label={`${strings.tanks.loadout.maximize} ${strings.tanks.loadout.modules}`}
onClick={() => {
Tankopedia.mutate((draft) => {
const turret = tank.turrets.at(-1)!;
const gun = turret.guns.at(-1)!;
draft.protagonist.turret = turret.id;
draft.protagonist.gun = gun.id;
draft.protagonist.shell = gun.shells[0].id;
draft.protagonist.engine = tank.engines.at(-1)!.id;
draft.protagonist.track = tank.tracks.at(-1)!.id;
});
}}
>
<DoubleArrowUpIcon />
</IconButton>
</div>
)}
</div>
<ModuleTree />
</div>
);
}
function stock(type: ModuleType, id: number) {
return { id, type, cost: { type: "xp", value: 0 } } satisfies Unlock;
}
function ModuleTree() {
const tank = useDuel("protagonist", DuelModule.Tank);
const unwrap = useUnwrapper();
// The same gun can sit on several turrets; reversing keeps the first one,
// which is what a linear find would return
const modules = useMemo(
() =>
new Map<ModuleType, Map<number, AnyModule>>([
[
ModuleType.MODULE_TYPE_TRACKS,
new Map(tank.tracks.map((track) => [track.id, track])),
],
[
ModuleType.MODULE_TYPE_ENGINE,
new Map(tank.engines.map((engine) => [engine.id, engine])),
],
[
ModuleType.MODULE_TYPE_TURRET,
new Map(tank.turrets.map((turret) => [turret.id, turret])),
],
[
ModuleType.MODULE_TYPE_GUN,
new Map(
tank.turrets
.flatMap((turret) => turret.guns)
.toReversed()
.map((gun) => [gun.id, gun]),
),
],
]),
[tank],
);
const resolve = useCallback(
(unlock: Unlock) =>
unlock.type === ModuleType.MODULE_TYPE_VEHICLE
? (tanks.tanks[unlock.id] as TankDefinition | undefined)
: modules.get(unlock.type)?.get(unlock.id),
[modules],
);
const { cells, columns, rows } = useMemo(
() =>
layoutModuleTree(
[
stock(ModuleType.MODULE_TYPE_GUN, tank.turrets[0].guns[0].id),
stock(ModuleType.MODULE_TYPE_TURRET, tank.turrets[0].id),
stock(ModuleType.MODULE_TYPE_ENGINE, tank.engines[0].id),
stock(ModuleType.MODULE_TYPE_TRACKS, tank.tracks[0].id),
].filter((unlock) => resolve(unlock) !== undefined),
(unlock) => {
const module = resolve(unlock);
const children =
module !== undefined && "unlocks" in module ? module.unlocks : [];
return children.filter((child) => resolve(child) !== undefined);
},
tank.module_positions,
),
[tank, resolve],
);
return (
<div
className={styles.tree}
style={{
gridTemplateColumns: `repeat(${columns}, var(--module-size))`,
gridTemplateRows: `repeat(${rows}, var(--module-size))`,
}}
>
{cells.map((cell) => {
const module = cell.unlock && resolve(cell.unlock);
return (
<div
key={cell.position.join(",")}
className={styles.cell}
style={{ gridColumn: cell.position[0], gridRow: cell.position[1] }}
>
{cell.trunkUp && <span className={styles["trunk-up"]} />}
{cell.trunkDown && <span className={styles["trunk-down"]} />}
{cell.snakeUp && <span className={styles["snake-up"]}></span>}
{cell.snakeDown && <span className={styles["snake-down"]}></span>}
{cell.right && <span className={styles.right} />}
{cell.left && <span className={styles.left}></span>}
{cell.unlock && module && (
<ModuleNode
unlock={cell.unlock}
module={module}
tooltip={unwrap(module.name!)}
/>
)}
</div>
);
})}
</div>
);
}
interface ModuleNodeProps {
unlock: Unlock;
module: AnyModule | TankDefinition;
tooltip: string;
}
function ModuleNode({ unlock, module, tooltip }: ModuleNodeProps) {
const locale = useLocale();
const tank = useDuel("protagonist", DuelModule.Tank);
const isTank = unlock.type === ModuleType.MODULE_TYPE_VEHICLE;
const topTurret = tank.turrets.at(-1)!;
const isSelected = Tankopedia.use((state) => {
switch (unlock.type) {
case ModuleType.MODULE_TYPE_TURRET:
return state.protagonist.turret === unlock.id;
case ModuleType.MODULE_TYPE_GUN:
return state.protagonist.gun === unlock.id;
case ModuleType.MODULE_TYPE_ENGINE:
return state.protagonist.engine === unlock.id;
case ModuleType.MODULE_TYPE_TRACKS:
return state.protagonist.track === unlock.id;
default:
return false;
}
});
const isTop =
(unlock.type === ModuleType.MODULE_TYPE_TURRET &&
unlock.id === topTurret.id) ||
(unlock.type === ModuleType.MODULE_TYPE_GUN &&
unlock.id === topTurret.guns.at(-1)!.id) ||
(unlock.type === ModuleType.MODULE_TYPE_ENGINE &&
unlock.id === tank.engines.at(-1)!.id) ||
(unlock.type === ModuleType.MODULE_TYPE_TRACKS &&
unlock.id === tank.tracks.at(-1)!.id);
const button = (
<IconButton
aria-label={tooltip}
size="major"
radius="1"
variant={isSelected ? "surface" : "soft"}
color={isTop ? "amber" : isSelected ? undefined : "gray"}
className={styles.module}
onClick={() => {
if (isTank) return;
Tankopedia.mutate((draft) => {
switch (unlock.type) {
case ModuleType.MODULE_TYPE_TURRET: {
const turret = tank.turrets.find((t) => t.id === unlock.id)!;
const gun =
turret.guns.find((g) => g.id === draft.protagonist.gun) ??
turret.guns.at(-1)!;
draft.protagonist.turret = turret.id;
draft.protagonist.gun = gun.id;
draft.protagonist.shell = gun.shells[0].id;
break;
}
case ModuleType.MODULE_TYPE_GUN: {
const turret = tank.turrets
.find((t) => t.id === draft.protagonist.turret)!
.guns.some((g) => g.id === unlock.id)
? tank.turrets.find((t) => t.id === draft.protagonist.turret)!
: tank.turrets.find((t) =>
t.guns.some((g) => g.id === unlock.id),
)!;
const gun = turret.guns.find((g) => g.id === unlock.id)!;
draft.protagonist.turret = turret.id;
draft.protagonist.gun = gun.id;
draft.protagonist.shell = gun.shells[0].id;
break;
}
case ModuleType.MODULE_TYPE_ENGINE:
draft.protagonist.engine = unlock.id;
break;
case ModuleType.MODULE_TYPE_TRACKS:
draft.protagonist.track = unlock.id;
break;
}
});
}}
>
<Discriminator corner="top-right">{romanize(module.tier)}</Discriminator>
{/* {unlock.cost!.value > 0 && (
<Discriminator>
<Text size="minor">{formatCompact(locale, unlock.cost!.value)}</Text>
<img
alt={unlock.cost!.type}
src={alias("api", `/icons/currencies/${unlock.cost!.type}.webp`)}
/>
</Discriminator>
)} */}
<img
alt={tooltip}
src={
isTank
? tankIcon(unlock.id)
: alias(
"api",
`/icons/modules/${moduleIconNames[unlock.type]}.webp`,
)
}
className={styles.icon}
/>
</IconButton>
);
return (
<Tooltip tooltip={tooltip}>
{isTank ? (
<LinkI18n
locale={locale}
href={`/tanks/${tanks.tanks[unlock.id].slug}`}
>
{button}
</LinkI18n>
) : (
button
)}
</Tooltip>
);
}
function CrewSkills() {
const strings = useStrings();
const selectedSkills = Tankopedia.use((state) => state.environment.skills);
@@ -147,38 +482,6 @@ function CrewSkillEntry({ id, level, permanent }: CrewSkillEntryProps) {
);
}
function State() {
const strings = useStrings();
const shell = Tankopedia.use((state) => state.protagonist.shell);
const speed = Tankopedia.use((state) => state.protagonist.speed);
return (
<div className={styles.section}>
<div className={styles.header}>
<Heading size="4">{strings.tanks.loadout.dynamics}</Heading>
<IconButton
color="red"
aria-label={`${strings.tanks.loadout.clear} ${strings.tanks.loadout.consumables}`}
onClick={() => {
Tankopedia.mutate((draft) => {
draft.protagonist.consumables = [];
});
}}
>
<TrashIcon />
</IconButton>
</div>
<div className={styles.state}>
<span>shell: {shell}</span>
<span>speed: {speed}</span>
</div>
</div>
);
}
function Status() {
const strings = useStrings();
const status = Tankopedia.use((state) => state.protagonist.status);
@@ -623,293 +926,3 @@ function EquipmentOption({
</Tooltip>
);
}
// function Modules() {
// const strings = useStrings();
// const tank = useDuel("protagonist", DuelModule.Tank);
// const upgrades = Tankopedia.use((state) => state.protagonist.upgrades);
// const alternates = Tankopedia.use((state) => state.protagonist.alternates);
// const isStock = useMemo(() => {
// return tank.tank!.upgrade_lines.every((line) => {
// if (isAlternativeLine(line.name)) return true;
// return upgrades[line.name] === 0;
// });
// }, [upgrades]);
// const isUpgraded = useMemo(() => {
// return tank.tank!.upgrade_lines.every((line) => {
// if (isAlternativeLine(line.name)) return true;
// return (
// upgrades[line.name] === line.stages.length - 1 ||
// (line.name in alternates && alternates[line.name])
// );
// });
// }, [upgrades]);
// return (
// <div className={styles.section}>
// <div className={styles.header}>
// <Heading size="4">{strings.tanks.loadout.modules}</Heading>
// <div className={styles["upgrade-buttons"]}>
// <IconButton
// variant={isStock ? "solid" : "soft"}
// size="minor"
// array
// onClick={() => {
// Tankopedia.mutate((draft) => {
// for (const line of tank.tank!.upgrade_lines) {
// if (isAlternativeLine(line.name)) continue;
// draft.protagonist.upgrades[line.name] = 0;
// draft.protagonist.alternates[line.name] = false;
// }
// });
// }}
// color="gray"
// >
// <DoubleArrowLeftIcon />
// </IconButton>
// <IconButton
// variant={isUpgraded ? "solid" : "soft"}
// size="minor"
// array
// onClick={() => {
// Tankopedia.mutate((draft) => {
// for (const line of tank.tank!.upgrade_lines) {
// draft.protagonist.upgrades[line.name] =
// line.stages.length - 1;
// if (isAlternativeLine(line.name)) {
// const original = originalLineName(line.name)!;
// draft.protagonist.alternates[original] = true;
// }
// }
// });
// }}
// color="gray"
// >
// <DoubleArrowRightIcon />
// </IconButton>
// </div>
// </div>
// <div className={styles.lines}>
// {/* TODO: hide lines with 1 module except for alts */}
// {tank.tank!.upgrade_lines.map((line) => (
// <Line
// key={line.name}
// name={line.name}
// lines={tank.tank!.upgrade_lines}
// />
// ))}
// </div>
// </div>
// );
// }
// interface LineProps {
// name: string;
// lines: UpgradeLine[];
// }
// function Line({ name, lines }: LineProps) {
// const combinedLines = useMemo(() => {
// const names: string[] = [name];
// for (const originalLine in alternativeLines) {
// const alternativeLine = alternativeLines[originalLine];
// if (
// originalLine === name &&
// lines.some((line) => line.name === alternativeLine)
// ) {
// names.push(alternativeLine);
// }
// }
// return names;
// }, [name]);
// return (
// <div className={styles.line}>
// {!isAlternativeLine(name) &&
// combinedLines.map((name) => (
// <div className={styles["chunks-wrapper"]}>
// <LineInner key={name} name={name} lines={lines} />
// </div>
// ))}
// </div>
// );
// }
// function LineInner({ name, lines }: LineProps) {
// const line = lines.find((line) => line.name === name)!;
// return chunk(line.stages, maxModulesPerRow).map((chunk, wrapIndex) => (
// <div className={styles["line-wrap"]}>
// {wrapIndex > 0 && (
// <Text size="minor" className={styles["wrap-marker"]} lowContrast>
// <CornerBottomLeftIcon />
// </Text>
// )}
// {chunk.map((stage, elementIndex) => (
// <LineElement
// key={elementIndex}
// index={wrapIndex * maxModulesPerRow + elementIndex}
// lineName={name}
// stage={stage}
// />
// ))}
// </div>
// ));
// }
// interface LineElementProps {
// index: number;
// lineName: string;
// stage: StageParameters;
// }
// function LineElement({ index, lineName, stage }: LineElementProps) {
// const upgrades = Tankopedia.use((state) => state.protagonist.upgrades);
// const alternates = Tankopedia.use((state) => state.protagonist.alternates);
// const tank = useDuel("protagonist", DuelModule.Tank);
// const strings = useStrings();
// const gameStrings = useGameStrings("TankEntity");
// const upgradePreset = useUpgradePreset(tank.tank!.tank_upgrade_preset);
// let price: StandardPrice | undefined;
// switch (stage.grade) {
// case Grade.GRADE_COMMON:
// price = upgradePreset.common_grade_price;
// break;
// case Grade.GRADE_RARE:
// price = upgradePreset.rare_grade_price;
// break;
// case Grade.GRADE_EPIC:
// price = upgradePreset.unique_grade_price;
// break;
// case Grade.GRADE_LEGENDARY:
// price = upgradePreset.legendary_grade_price;
// break;
// }
// for (const override of upgradePreset.prices_overrides) {
// if (override.stage_tech_name !== stage.tech_name) continue;
// price = override.price;
// }
// price ??= StandardPrice.create();
// let isSelected: boolean;
// if (isAlternativeLine(lineName)) {
// const originalLine = originalLineName(lineName);
// isSelected = alternates[originalLine!];
// } else {
// if (alternates[lineName]) {
// isSelected = false;
// } else {
// isSelected = upgrades[lineName] === index;
// }
// }
// return (
// <div className={styles.wrapper}>
// <Tooltip tooltip={gameStrings[stage.display_name]}>
// <Button
// className={styles.stage}
// color={isSelected ? undefined : "gray"}
// variant={isSelected ? "surface" : "soft"}
// radius="1"
// onClick={() => {
// // TODO: make minimum selectable upgrade the last free module for tanks like the destiny
// Tankopedia.mutate((draft) => {
// const tankData = tank.tank!;
// if (isAlternativeLine(lineName)) {
// const originalLine = originalLineName(lineName)!;
// draft.protagonist.alternates[originalLine] = true;
// draft.protagonist.upgrades[lineName] = index;
// } else {
// draft.protagonist.alternates[lineName] = false;
// draft.protagonist.upgrades[lineName] = index;
// }
// for (const required of stage.required_upgrades) {
// for (const line of tankData.upgrade_lines) {
// let i = 0;
// for (const candidateStage of line.stages) {
// if (candidateStage.tech_name === required) {
// draft.protagonist.upgrades[line.name] = Math.max(
// draft.protagonist.upgrades[line.name],
// i,
// );
// }
// i++;
// }
// }
// }
// for (const line of tankData.upgrade_lines) {
// if (draft.protagonist.alternates[line.name]) {
// continue;
// }
// let i = draft.protagonist.upgrades[line.name];
// while (i > 0) {
// const currentStage = line.stages[i];
// const valid = currentStage.required_upgrades.every(
// (required) => {
// for (const otherLine of tankData.upgrade_lines) {
// const idx = draft.protagonist.upgrades[otherLine.name];
// if (otherLine.stages[idx]?.tech_name === required) {
// return true;
// }
// }
// return false;
// },
// );
// if (valid) break;
// i--;
// }
// draft.protagonist.upgrades[line.name] = i;
// }
// });
// }}
// >
// <img
// className={styles.icon}
// src={`/media/modules/${stage.stage_type}.webp`}
// />
// <Text weight="light" lowContrast size="minor" className={styles.tier}>
// {isAlternativeLine(lineName)
// ? strings.tanks.loadout.alternative
// : romanize(stage.number)}
// </Text>
// </Button>
// </Tooltip>
// {<Price className={styles.price} price={price} />}
// </div>
// );
// }
@@ -1,3 +1,36 @@
.corner-stat {
position: absolute;
display: flex;
gap: var(--space-2);
color: var(--gray-11);
font-size: var(--font-size-minor);
.value {
color: var(--gray-12);
font-size: inherit;
}
&[data-position="top-left"],
&[data-position="top-right"] {
top: var(--space-3);
}
&[data-position="bottom-left"],
&[data-position="bottom-right"] {
bottom: var(--space-3);
}
&[data-position="top-left"],
&[data-position="bottom-left"] {
left: var(--space-3);
}
&[data-position="top-right"],
&[data-position="bottom-right"] {
right: var(--space-3);
}
}
.visualizer {
position: relative;
width: 100%;
@@ -104,39 +137,6 @@
}
}
.corner-stat {
position: absolute;
display: flex;
gap: var(--space-2);
color: var(--gray-11);
font-size: var(--font-size-minor);
.value {
color: var(--gray-12);
font-size: inherit;
}
&[data-position="top-left"] {
top: var(--space-3);
left: var(--space-3);
}
&[data-position="top-right"] {
top: var(--space-3);
right: var(--space-3);
}
&[data-position="bottom-left"] {
bottom: var(--space-3);
left: var(--space-3);
}
&[data-position="bottom-right"] {
right: var(--space-3);
bottom: var(--space-3);
}
}
.explosive-overlay {
position: absolute;
inset: 0;
@@ -386,3 +386,158 @@
transform: translate(-50%, -50%);
}
}
.view-range {
width: 100%;
height: 100%;
touch-action: none;
border-radius: inherit;
user-select: none;
.map {
position: absolute;
inset: 0;
background-size: cover;
background-position: center;
filter: grayscale(0.5) brightness(0.8) contrast(1.2);
}
.range {
position: absolute;
top: 50%;
left: 50%;
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-1);
aspect-ratio: 1 / 1;
border-radius: 50%;
transform: translate(-50%, -50%);
background: radial-gradient(var(--black-a6), var(--gray-a12));
pointer-events: none;
}
.camouflage {
position: absolute;
top: 0;
left: 0;
display: flex;
align-items: center;
gap: var(--space-3);
box-sizing: border-box;
width: 100%;
padding: var(--space-2) var(--space-3);
background-color: var(--black-a7);
backdrop-filter: blur(4rem);
-webkit-backdrop-filter: blur(4rem);
font-size: var(--font-size-minor);
.slider {
flex: 1;
}
}
.maps {
position: absolute;
bottom: 0;
width: 100%;
display: flex;
justify-content: center;
background: linear-gradient(
90deg,
transparent,
var(--black-a7),
transparent
);
padding-top: var(--space-2);
}
}
.flexibility {
position: relative;
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
overflow: hidden;
touch-action: none;
border-radius: inherit;
user-select: none;
.side {
position: relative;
flex: 0 0 35%;
background: linear-gradient(var(--gray-1), var(--gray-3));
.cone {
position: absolute;
top: 0;
left: 33%;
width: 67%;
height: 100%;
pointer-events: none;
}
}
.radial {
position: relative;
flex: 1;
min-height: 0;
container-type: size;
/* the interactive disc is always the largest centered square */
.square {
position: absolute;
top: 50%;
left: 50%;
width: min(100cqw, 100cqh);
height: min(100cqw, 100cqh);
transform: translate(-50%, -50%);
pointer-events: none;
}
.texture {
position: absolute;
inset: 0;
opacity: 0.125;
background-image: url("/media/toys/armor-hash.png");
box-shadow: inset 0 0 4rem 1rem black;
}
.limits {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.pointer {
position: absolute;
top: 50%;
left: 50%;
width: 1pt;
height: 50%;
transform-origin: top center;
border-radius: var(--space-1);
background: linear-gradient(var(--gray-a11), transparent);
pointer-events: none;
}
.dot {
position: absolute;
width: 0.25rem;
height: 0.25rem;
border-radius: 50%;
background-color: var(--gray-12);
transform: translate(-50%, 50%);
}
}
}
@@ -1,6 +1,6 @@
import { alias, isExplosive } from "@blitzkit/core";
import { literals } from "@blitzkit/i18n";
import { DoubleArrowRightIcon } from "@radix-ui/react-icons";
import { DoubleArrowRightIcon, EyeOpenIcon } from "@radix-ui/react-icons";
import { times } from "lodash-es";
import {
useCallback,
@@ -12,15 +12,25 @@ import {
type PointerEvent,
} from "react";
import { clamp, degToRad, radToDeg } from "three/src/math/MathUtils.js";
import { api } from "../../api/dynamic";
import type { ToyProps } from "../../config/toys";
import {
DEFAULT_PITCH_TRANSITION,
applyPitchYawLimits,
} from "../../core/blitz/applyPitchYawLimits";
import { modelTransformEvent } from "../../core/blitzkit/modelTransform";
import { DuelModule, useDuel } from "../../hooks/useDuel";
import { useEquipment } from "../../hooks/useEquipment";
import { useStrings } from "../../hooks/useStrings";
import { Tankopedia } from "../../stores/tankopedia";
import type { ComputedCharacteristics } from "../../types/characteristics";
import { Button } from "../Button";
import { Checkbox } from "../Checkbox";
import { Code } from "../Code";
import { SegmentedControl } from "../SegmentedControl";
import { Slider } from "../Slider";
import { ShellBorder } from "../Tankopedia/CharacteristicsSection/components/Characteristics/components/ReloadVisualizer/components/ShellBorder";
import { TankopediaFlexibilityCanvas } from "../TankopediaFlexibilityCanvas";
import styles from "./index.module.css";
export function RicochetToy({ characteristics }: ToyProps) {
@@ -528,7 +538,9 @@ export function ReloadToy({ characteristics }: ToyProps) {
className={styles["shell-track"]}
key={index}
style={{
transform: `rotate(${135 - 7 * (index - (reloadData.shells - 1) / 2)}deg)`,
transform: `rotate(${
135 - 7 * (index - (reloadData.shells - 1) / 2)
}deg)`,
}}
>
<div className={styles.shell}>
@@ -566,3 +578,333 @@ export function ReloadToy({ characteristics }: ToyProps) {
</div>
);
}
const viewRangeMaps = [
{ id: "ghost-factory", string: "ghost_factory", size: 512 },
{ id: "dead-rail", string: "dead_rail", size: 512 },
] as const;
export function ViewRangeToy({ characteristics }: ToyProps) {
const strings = useStrings();
const [mapId, setMapId] = useState<string>(viewRangeMaps[0].id);
const [camouflage, setCamouflage] = useState(0);
const container = useRef<HTMLDivElement>(null);
const range = useRef<HTMLDivElement>(null);
const map = viewRangeMaps.find(({ id }) => id === mapId) ?? viewRangeMaps[0];
const viewRange = characteristics.get("view_range") as number;
const resolvedViewRange = viewRange * (1 - camouflage);
function follow(event: PointerEvent<HTMLDivElement>) {
if (!container.current || !range.current) return;
const rect = container.current.getBoundingClientRect();
range.current.style.left = `${event.clientX - rect.left}px`;
range.current.style.top = `${event.clientY - rect.top - 16}px`;
}
return (
<div
ref={container}
className={styles["view-range"]}
onPointerMove={follow}
>
<div
className={styles.map}
style={{
backgroundImage: `url(/assets/images/tankopedia/visualizers/range/${map.id}.png)`,
}}
/>
<div
ref={range}
className={styles.range}
style={{ width: `${(resolvedViewRange / map.size) * 100}%` }}
>
<EyeOpenIcon width="1.25rem" height="1.25rem" />
{literals(strings.common.units.m, {
value: resolvedViewRange.toFixed(0),
})}
</div>
<div className={styles.camouflage}>
<span>{strings.tanks.toys.view_range.camo}</span>
<Slider.Root
className={styles.slider}
value={[camouflage]}
min={0}
max={0.5}
step={0.01}
onValueChange={([value]) => setCamouflage(value)}
>
<Slider.Track>
<Slider.Range />
</Slider.Track>
<Slider.Thumb />
</Slider.Root>
<Code size="minor">
{literals(strings.common.units.percentage, {
value: Math.round(camouflage * 100),
})}
</Code>
</div>
<div className={styles.maps}>
<SegmentedControl
options={viewRangeMaps.map(({ id, string }) => ({
value: id,
label: strings.tanks.toys.view_range[string],
}))}
value={mapId}
onValueChange={setMapId}
variant="underline"
/>
</div>
</div>
);
}
const modelDefinitions = await api.models();
const ANGLE_COEFFICIENT = 1 / 10;
function mag(x: number) {
return (1 / Math.PI) * Math.atan(ANGLE_COEFFICIENT * x) + 1 / 2;
}
function magInverse(y: number) {
return (1 / ANGLE_COEFFICIENT) * Math.tan(Math.PI * y - Math.PI / 2);
}
function polar(thetaDeg: number, m = 1) {
const theta = degToRad(thetaDeg);
return `${m * Math.cos(theta)} ${-m * Math.sin(theta)}`;
}
export function FlexibilityToy() {
const strings = useStrings();
const degrees = (value: number) =>
literals(strings.common.units.deg, { value: value.toFixed(0) });
const tank = useDuel("protagonist", DuelModule.Tank);
const turret = useDuel("protagonist", DuelModule.Turret);
const gun = useDuel("protagonist", DuelModule.Gun);
const modelRequested = Tankopedia.use(
(state) => state.loaders.model.requested,
);
const hasImprovedVerticalStabilizer = useEquipment("protagonist", 122);
const hasDownImprovedVerticalStabilizer = useEquipment("protagonist", 124);
const tankModel = modelDefinitions.models[tank.id];
const turretModel = tankModel.turrets[turret.id];
const gunModel = turretModel.guns[gun.id];
const p = gunModel.pitch!;
const y = turretModel.yaw;
const f = p.front;
const b = p.back;
const t = p.transition ?? DEFAULT_PITCH_TRANSITION;
// the turret's baked-in mount tilt is folded into displayed pitch values
const initialTurretPitch = degToRad(
tankModel.initial_turret_rotation?.pitch ?? 0,
);
const tiltDeg = radToDeg(initialTurretPitch);
const magTilted = (deg: number) => mag(deg + tiltDeg);
const defaultMin = -degToRad(f?.max ?? p.max) - initialTurretPitch;
const defaultMax = -degToRad(f?.min ?? p.min) - initialTurretPitch;
const [yaw, setYaw] = useState(0);
const [pitch, setPitch] = useState(0);
const [minPitch, setMinPitch] = useState(defaultMin);
const [maxPitch, setMaxPitch] = useState(defaultMax);
const container = useRef<HTMLDivElement>(null);
// reset when the gun or turret changes
useEffect(() => {
setYaw(0);
setPitch(0);
setMinPitch(defaultMin);
setMaxPitch(defaultMax);
modelTransformEvent.dispatch({ pitch: 0, yaw: 0 });
}, [tank.id, turret.id, gun.id]);
function outline(limit: "max" | "min") {
const m = magTilted(p[limit]);
let d = "";
// right
if (y) {
d += `M 0 0 L ${polar(90 - y.max, m)}`;
} else if (b) {
d += `M ${polar(-90 + b.range / 2 + t, m)}`;
} else {
d += `M ${polar(-90, m)}`;
}
d += f
? `A ${m} ${m} 0 0 0 ${polar(90 - f.range / 2 - t, m)}`
: `A ${m} ${m} 0 0 0 ${polar(90, m)}`;
// front
if (f) {
const mf = magTilted(f[limit]);
d += `L ${polar(90 - f.range / 2, mf)}`;
d += `A ${mf} ${mf} 0 0 0 ${polar(90 + f.range / 2, mf)}`;
}
// left
d += f ? `L ${polar(90 + f.range / 2 + t, m)}` : `L ${polar(90, m)}`;
if (y) {
d += `A ${m} ${m} 0 0 0 ${polar(90 - y.min, m)}`;
} else if (b) {
d += `A ${m} ${m} 0 0 0 ${polar(-90 - b.range / 2 - t, m)}`;
} else {
d += `A ${m} ${m} 0 0 0 ${polar(-90, m)}`;
}
// back
if (!y && b) {
const mb = magTilted(b[limit]);
d += `L ${polar(-90 - b.range / 2, mb)}`;
d += `A ${mb} ${mb} 0 0 0 ${polar(-90 + b.range / 2, mb)}`;
}
return `${d}Z`;
}
const path = outline("max") + outline("min");
const r = mag(radToDeg(-(pitch - initialTurretPitch)));
const dotX = r * Math.sin(yaw);
const dotY = r * Math.cos(yaw);
function update(event: PointerEvent<HTMLDivElement>) {
event.preventDefault();
if (!container.current) return;
const rect = container.current.getBoundingClientRect();
const size = Math.min(rect.width, rect.height);
const u = event.clientX - rect.left - rect.width / 2;
const v = event.clientY - rect.top - rect.height / 2;
let nextYaw = Math.atan2(u, -v);
const min = applyPitchYawLimits(
-Math.PI,
nextYaw,
p,
y,
hasImprovedVerticalStabilizer,
hasDownImprovedVerticalStabilizer,
);
const max = applyPitchYawLimits(
Math.PI,
nextYaw,
p,
y,
hasImprovedVerticalStabilizer,
hasDownImprovedVerticalStabilizer,
);
nextYaw = min[1];
const distance = clamp(
2 * Math.sqrt((u / size) ** 2 + (v / size) ** 2),
0,
1,
);
const nextPitch = clamp(
degToRad(magInverse(-distance)) + initialTurretPitch,
min[0],
max[0],
);
setPitch(nextPitch);
setYaw(nextYaw);
setMinPitch(min[0] - initialTurretPitch);
setMaxPitch(max[0] - initialTurretPitch);
modelTransformEvent.dispatch({ pitch: nextPitch, yaw: nextYaw });
}
return (
<div className={styles.flexibility}>
<div className={styles.side}>
{modelRequested && <TankopediaFlexibilityCanvas />}
<div
className={styles.cone}
style={{
background: `conic-gradient(
at 0 50%,
transparent,
transparent ${Math.PI / 2 - maxPitch}rad,
var(--cyan-a4) ${Math.PI / 2 - maxPitch}rad,
var(--jade-a4) ${Math.PI / 2 - minPitch}rad,
transparent ${Math.PI / 2 - minPitch}rad,
transparent 180deg
)`,
}}
/>
</div>
<div ref={container} className={styles.radial} onPointerMove={update}>
<div className={styles.texture} />
<div className={styles.square}>
<svg className={styles.limits} viewBox="-1 -1 2 2">
<path
fillRule="evenodd"
fill="var(--gray-5)"
stroke="var(--gray-8)"
strokeWidth="1px"
vectorEffect="non-scaling-stroke"
d={path}
/>
<path
stroke="var(--gray-10)"
fill="transparent"
strokeWidth="1px"
strokeDasharray="0.5rem 0.5rem"
vectorEffect="non-scaling-stroke"
d="M 0.5 0 A 0.5 0.5 0 1 1 -0.5 0 A 0.5 0.5 0 1 1 0.5 0 Z"
/>
</svg>
<div
className={styles.dot}
style={{
left: `${50 * (dotX + 1)}%`,
bottom: `${50 * (dotY + 1)}%`,
}}
/>
</div>
<div
className={styles.pointer}
style={{ transform: `translateX(-50%) rotate(${yaw + Math.PI}rad)` }}
/>
<CornerStat
label={strings.tanks.toys.flexibility.pitch}
value={degrees(radToDeg(pitch - initialTurretPitch))}
position={CornerStatPosition.TopLeft}
/>
<CornerStat
label={strings.tanks.toys.flexibility.yaw}
value={degrees(radToDeg(yaw))}
position={CornerStatPosition.TopRight}
/>
<CornerStat
label={strings.tanks.toys.flexibility.max}
value={degrees(radToDeg(maxPitch))}
position={CornerStatPosition.BottomLeft}
/>
<CornerStat
label={strings.tanks.toys.flexibility.min}
value={degrees(radToDeg(minPitch))}
position={CornerStatPosition.BottomRight}
/>
</div>
</div>
);
}
@@ -0,0 +1,48 @@
.section {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-5);
padding: var(--space-4);
.cards {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: var(--space-4);
width: 100%;
.card {
display: flex;
flex: 0 0 auto;
flex-direction: column;
overflow: hidden;
border-radius: var(--space-3);
background-size: cover;
background-position: 0 18px;
color: inherit;
text-decoration: none;
.thumbnail {
width: 16rem;
aspect-ratio: 16 / 9;
background-size: cover;
background-position: center;
}
.info {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-1);
padding: var(--space-3);
backdrop-filter: blur(4rem);
-webkit-backdrop-filter: blur(4rem);
}
&:hover .info {
text-decoration: underline;
}
}
}
}
@@ -0,0 +1,56 @@
import { youtubers } from "@blitzkit/core";
import { awaitableReviews } from "../../core/awaitables/reviews";
import { DuelModule, useDuel } from "../../hooks/useDuel";
import { useStrings } from "../../hooks/useStrings";
import { Heading } from "../Heading";
import { Text } from "../Text";
import { VerifiedIcon } from "../VerifiedIcon";
import styles from "./index.module.css";
const reviews = await awaitableReviews;
export function TankopediaVideos() {
const strings = useStrings();
const tank = useDuel("protagonist", DuelModule.Tank);
const videos = reviews.reviews[tank.id]?.videos;
if (videos === undefined || videos.length === 0) return null;
return (
<section className={styles.section}>
<Heading size="3">{strings.tanks.videos.title}</Heading>
<div className={styles.cards}>
{videos.map((video) => {
const image = `https://i.ytimg.com/vi/${video.id}/hqdefault.jpg`;
const youtuber = youtubers.find(
(youtuber) => youtuber.id === video.author,
);
if (!youtuber) return null;
return (
<a
key={video.id}
className={styles.card}
style={{ backgroundImage: `url(${image})` }}
href={`https://www.youtube.com/watch?v=${video.id}`}
target="_blank"
rel="noreferrer"
>
<div
className={styles.thumbnail}
style={{ backgroundImage: `url(${image})` }}
/>
<div className={styles.info}>
<Text>{youtuber.name}</Text>
<VerifiedIcon width="1em" height="1em" />
</div>
</a>
);
})}
</div>
</section>
);
}