mirror of
https://github.com/blitzkit/blitzkit.git
synced 2026-11-14 13:16:11 +01:00
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:
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user