mirror of
https://github.com/blitzkit/blitzkit.git
synced 2026-11-12 20:26:10 +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>
|
||||
);
|
||||
}
|
||||
@@ -7,11 +7,18 @@ import {
|
||||
import { ShellType, TankClass } from "@blitzkit/protos";
|
||||
import { hullTraverseSpeed } from "../tankopedia/hullTraverseSpeed";
|
||||
import { rawTerrainCoefficient } from "../tankopedia/rawTerrainCoefficient";
|
||||
import { tankClassToBlitzTankClass } from "../tankopedia/tankClass";
|
||||
import {
|
||||
CharacteristicType,
|
||||
type Characteristic,
|
||||
} from "../types/characteristics";
|
||||
|
||||
/**
|
||||
* peek-a-boo assumes at least this many seconds between shots to account for
|
||||
* peeking out, aiming, and retreating
|
||||
*/
|
||||
const PEEKABOO_MIN_CYCLE = 10;
|
||||
|
||||
export const characteristics = {
|
||||
gun_type: {
|
||||
type: CharacteristicType.Enum,
|
||||
@@ -59,10 +66,11 @@ export const characteristics = {
|
||||
},
|
||||
},
|
||||
|
||||
// characteristics prefixed with an underscore are compute time only; they are never rendered
|
||||
_reload_coefficient: {
|
||||
type: CharacteristicType.Number,
|
||||
|
||||
compute({ script, degressive }) {
|
||||
compute({ script, degressive, gun }) {
|
||||
let c0 = 1;
|
||||
|
||||
script("protagonist", "miscAttrs/gunReloadTimeFactor", (effect) => {
|
||||
@@ -73,9 +81,12 @@ export const characteristics = {
|
||||
|
||||
let c2 = 1;
|
||||
|
||||
script("protagonist", "Berserk", (effect) => {
|
||||
c2 += effect.bonusValues!.gunReloadSpeedIncrease / 100;
|
||||
});
|
||||
// adrenaline only affects regular guns
|
||||
if (gun.gun_type!.$case === "regular") {
|
||||
script("protagonist", "Berserk", (effect) => {
|
||||
c2 += effect.bonusValues!.gunReloadSpeedIncrease / 100;
|
||||
});
|
||||
}
|
||||
|
||||
return (c0 * c1) / c2;
|
||||
},
|
||||
@@ -88,7 +99,7 @@ export const characteristics = {
|
||||
return gun.gun_type!.$case !== "auto_reloader";
|
||||
},
|
||||
|
||||
compute({ gun, characteristic }) {
|
||||
compute({ gun, characteristic }): number {
|
||||
if (gun.gun_type!.$case === "auto_reloader") return -Infinity;
|
||||
|
||||
const c = characteristic("_reload_coefficient") as number;
|
||||
@@ -139,10 +150,26 @@ export const characteristics = {
|
||||
},
|
||||
},
|
||||
|
||||
dpm: {
|
||||
// time per shot when continuously firing the fastest shell of an auto reloader
|
||||
_optimal_shell_cycle: {
|
||||
type: CharacteristicType.Number,
|
||||
|
||||
compute({ gun, characteristic }) {
|
||||
if (gun.gun_type!.$case !== "auto_reloader") return 0;
|
||||
|
||||
const intraClip = characteristic("intra_clip") as number;
|
||||
const reloads = characteristic("reloads") as number[];
|
||||
|
||||
return Math.min(
|
||||
...reloads.map((reload, index) => reload + (index > 0 ? intraClip : 0)),
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
dpm: {
|
||||
type: CharacteristicType.Number,
|
||||
|
||||
compute({ gun, characteristic }): number {
|
||||
const alpha = characteristic("damage") as number;
|
||||
|
||||
let dps: number;
|
||||
@@ -170,32 +197,49 @@ export const characteristics = {
|
||||
|
||||
dps = damage / time;
|
||||
} else {
|
||||
const intraClip = characteristic("intra_clip") as number;
|
||||
const reloadCoefficient = characteristic(
|
||||
"_reload_coefficient",
|
||||
) as number;
|
||||
|
||||
const mostOptimalShell =
|
||||
gun.gun_type!.value.shell_reloads.reduce<null | {
|
||||
index: number;
|
||||
reload: number;
|
||||
}>((current, reloadRaw, index) => {
|
||||
const reload =
|
||||
reloadRaw * reloadCoefficient + (index > 0 ? intraClip : 0);
|
||||
|
||||
if (current === null || reload < current.reload) {
|
||||
return { index, reload };
|
||||
}
|
||||
return current;
|
||||
}, null)!;
|
||||
|
||||
dps = alpha / mostOptimalShell?.reload;
|
||||
dps = alpha / (characteristic("_optimal_shell_cycle") as number);
|
||||
}
|
||||
|
||||
return dps * 60;
|
||||
},
|
||||
},
|
||||
|
||||
dpm_peekaboo: {
|
||||
type: CharacteristicType.Number,
|
||||
|
||||
should_render({ gun }) {
|
||||
return gun.gun_type!.$case === "regular";
|
||||
},
|
||||
|
||||
compute({ characteristic }): number {
|
||||
const damage = characteristic("damage") as number;
|
||||
const reload = characteristic("reload") as number;
|
||||
|
||||
return damage * (60 / Math.max(reload, PEEKABOO_MIN_CYCLE));
|
||||
},
|
||||
},
|
||||
|
||||
dpm_effective: {
|
||||
type: CharacteristicType.Number,
|
||||
|
||||
should_render({ gun }) {
|
||||
return gun.gun_type!.$case === "auto_reloader";
|
||||
},
|
||||
|
||||
compute({ characteristic }): number {
|
||||
const damage = characteristic("damage") as number;
|
||||
const reloads = characteristic("reloads") as number[];
|
||||
const intraClip = characteristic("intra_clip") as number;
|
||||
|
||||
// dump every other shell at the intra clip rate, spend the rest of the
|
||||
// minute cycling the optimal shell; ordering doesn't change the total
|
||||
const rest = reloads.length - 1;
|
||||
const cycle = characteristic("_optimal_shell_cycle") as number;
|
||||
|
||||
return damage * rest + (damage * (60 - rest * intraClip)) / cycle;
|
||||
},
|
||||
},
|
||||
|
||||
// damage_range: unavailable,
|
||||
|
||||
module_damage: {
|
||||
@@ -233,7 +277,7 @@ export const characteristics = {
|
||||
return gun.gun_type!.$case !== "regular";
|
||||
},
|
||||
|
||||
compute({ characteristic }) {
|
||||
compute({ characteristic }): number {
|
||||
const clipSize = characteristic("clip_size") as number;
|
||||
const damage = characteristic("damage") as number;
|
||||
|
||||
@@ -241,36 +285,35 @@ export const characteristics = {
|
||||
},
|
||||
},
|
||||
|
||||
// TODO: custom type of shell index
|
||||
// optimal_shell: {
|
||||
// type: CharacteristicType.Number,
|
||||
/**
|
||||
* 1-indexed so that it can be rendered as-is
|
||||
*/
|
||||
optimal_shell: {
|
||||
type: CharacteristicType.Number,
|
||||
|
||||
// should_render({ gun }) {
|
||||
// return gun.gun_type!.$case === "auto_reloader";
|
||||
// },
|
||||
should_render({ gun }) {
|
||||
return gun.gun_type!.$case === "auto_reloader";
|
||||
},
|
||||
|
||||
// compute({ gun, script, degressive }) {
|
||||
// if (gun.gun_type!.$case !== "auto_reloader") return -1;
|
||||
compute({ characteristic }): number {
|
||||
const reloads = characteristic("reloads") as number[];
|
||||
const intraClip = characteristic("intra_clip") as number;
|
||||
|
||||
// const autoReloader = gun.gun_type!.value;
|
||||
// const coefficient = reloadCoefficient({ script, degressive, gun });
|
||||
// const intraClip = intraClipCoefficient({ script });
|
||||
let best = 0;
|
||||
let bestTime = Infinity;
|
||||
|
||||
// return autoReloader.shell_reloads.reduce(
|
||||
// (bestIndex, reload, index, reloads) => {
|
||||
// const effectiveReload =
|
||||
// reload * coefficient +
|
||||
// (index > 0 ? autoReloader.intra_clip * intraClip : 0);
|
||||
// const bestReload =
|
||||
// reloads[bestIndex] * coefficient +
|
||||
// (bestIndex > 0 ? autoReloader.intra_clip * intraClip : 0);
|
||||
reloads.forEach((reload, index) => {
|
||||
const time = reload + (index > 0 ? intraClip : 0);
|
||||
|
||||
// return effectiveReload < bestReload ? index : bestIndex;
|
||||
// },
|
||||
// 0,
|
||||
// );
|
||||
// },
|
||||
// },
|
||||
if (time < bestTime) {
|
||||
best = index;
|
||||
bestTime = time;
|
||||
}
|
||||
});
|
||||
|
||||
return best + 1;
|
||||
},
|
||||
},
|
||||
|
||||
burst_size: {
|
||||
type: CharacteristicType.Number,
|
||||
@@ -410,41 +453,87 @@ export const characteristics = {
|
||||
},
|
||||
},
|
||||
|
||||
explosion_radius: {
|
||||
type: CharacteristicType.Number,
|
||||
|
||||
should_render({ shell }) {
|
||||
return shell.explosion_radius !== undefined;
|
||||
},
|
||||
|
||||
compute({ shell }) {
|
||||
return shell.explosion_radius!;
|
||||
},
|
||||
},
|
||||
|
||||
speed_forward: {
|
||||
type: CharacteristicType.Number,
|
||||
compute({ tank }) {
|
||||
return tank.speed_forwards;
|
||||
compute({ tank, script }) {
|
||||
let bias = 0;
|
||||
let factor = 1;
|
||||
|
||||
script("protagonist", "GearOil", (effect) => {
|
||||
bias += effect.bonusValues?.fwdSpeedLimitBias ?? 0;
|
||||
});
|
||||
|
||||
script("protagonist", "ImprovedAfterburning", (effect) => {
|
||||
bias += effect.fwdSpeedLimitBias ?? 0;
|
||||
});
|
||||
|
||||
script("protagonist", "AttributesModifier", (effect) => {
|
||||
factor *= effect.attributes?.speedLimitsFactor ?? 1;
|
||||
});
|
||||
|
||||
return (tank.speed_forwards + bias) * factor;
|
||||
},
|
||||
},
|
||||
|
||||
speed_backward: {
|
||||
type: CharacteristicType.Number,
|
||||
compute({ tank }) {
|
||||
return tank.speed_backwards;
|
||||
compute({ tank, script }) {
|
||||
let bias = 0;
|
||||
let factor = 1;
|
||||
|
||||
script("protagonist", "GearOil", (effect) => {
|
||||
bias += effect.bonusValues?.bkwdSpeedLimitBias ?? 0;
|
||||
});
|
||||
|
||||
script("protagonist", "ImprovedAfterburning", (effect) => {
|
||||
bias += effect.bkwdSpeedLimitBias ?? 0;
|
||||
});
|
||||
|
||||
script("protagonist", "AttributesModifier", (effect) => {
|
||||
factor *= effect.attributes?.speedLimitsFactor ?? 1;
|
||||
});
|
||||
|
||||
return (tank.speed_backwards + bias) * factor;
|
||||
},
|
||||
},
|
||||
|
||||
aim_time: {
|
||||
type: CharacteristicType.Number,
|
||||
compute({ gun, turret, track, degressive, script, characteristic }) {
|
||||
compute({ tank, gun, turret, track, degressive, script }): number {
|
||||
/*
|
||||
T = (A / C) × ln(√(1 + k² × S)) / (1 − p/100)
|
||||
Mirrors the game's hangar params builder (wotblitz.exe 11.21, aim time
|
||||
at 0x19eb300). Consumables such as FireControlSystem are battle-time
|
||||
only and are not part of it.
|
||||
|
||||
T = (A × C) × ln(√(1 + k² × S)) / (1 − p/100)
|
||||
|
||||
S = (turret rotation speed × turret dispersion factor)²
|
||||
+ (hull rotation speed × hull dispersion factor)²
|
||||
+ (min(top speed, 30) × movement dispersion factor)²
|
||||
+ (min(raw top speed, 30) × movement dispersion factor)²
|
||||
raw top speed is the vehicle's unmodified forward speed limit; equipment
|
||||
and engine modifiers do not affect it here.
|
||||
A: the gun’s raw aimingTime—the time for the reticle to shrink by a factor of e ≈ 2.718.
|
||||
C: gunner crew factor, accounting for training, provisions, and shared crew roles.
|
||||
C: gunner degressive crew factor (the reciprocal of the game's gunner value, which divides the time).
|
||||
k: stabilization multiplier; normally 1, reduced by the stabilizer.
|
||||
p: signed aiming-drive modifier. For −12%, the displayed time is divided by 1.12, reducing it by approximately 10.7%.
|
||||
*/
|
||||
|
||||
const topSpeed = characteristic("speed_forward") as number;
|
||||
|
||||
const S =
|
||||
(turret.traverse_speed * gun.dispersion_traverse) ** 2 +
|
||||
(track.traverse_speed * track.dispersion_traverse) ** 2 +
|
||||
(Math.min(topSpeed, 30) * track.dispersion_move) ** 2;
|
||||
(Math.min(tank.speed_forwards, 30) * track.dispersion_move) ** 2;
|
||||
|
||||
const A = gun.aim_time;
|
||||
const C = degressive("gunner");
|
||||
@@ -452,7 +541,7 @@ export const characteristics = {
|
||||
let p = 0;
|
||||
|
||||
script("protagonist", "miscAttrs/gunAimingTimeFactor", (effect) => {
|
||||
p = effect.bonusValues!.percent / 100;
|
||||
p += effect.bonusValues!.percent / 100;
|
||||
});
|
||||
|
||||
let k = 1;
|
||||
@@ -465,7 +554,7 @@ export const characteristics = {
|
||||
},
|
||||
);
|
||||
|
||||
const T = ((A / C) * Math.log(Math.sqrt(1 + k ** 2 * S))) / (1 - p / 100);
|
||||
const T = (A * C * Math.log(Math.sqrt(1 + k ** 2 * S))) / (1 - p);
|
||||
|
||||
return T;
|
||||
},
|
||||
@@ -488,28 +577,171 @@ export const characteristics = {
|
||||
},
|
||||
},
|
||||
|
||||
_stabilizer_coefficient: {
|
||||
type: CharacteristicType.Number,
|
||||
compute({ script }) {
|
||||
let coefficient = 0;
|
||||
|
||||
script(
|
||||
"protagonist",
|
||||
"miscAttrs/additiveShotDispersionFactor",
|
||||
(effect) => {
|
||||
coefficient += effect.bonusValues!.percent / 100;
|
||||
},
|
||||
);
|
||||
|
||||
return coefficient;
|
||||
},
|
||||
},
|
||||
|
||||
_camouflage_bonus: {
|
||||
type: CharacteristicType.Number,
|
||||
compute({ tank, state }) {
|
||||
if (!state.camouflage) return 0;
|
||||
|
||||
switch (tank.class) {
|
||||
case TankClass.TANK_CLASS_TANK_DESTROYER:
|
||||
return 0.04;
|
||||
case TankClass.TANK_CLASS_HEAVY:
|
||||
return 0.03;
|
||||
default:
|
||||
return 0.02;
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
_camouflage_net_bonus_still: {
|
||||
type: CharacteristicType.Number,
|
||||
compute({ tank, script }) {
|
||||
let bonus = 0;
|
||||
|
||||
script("protagonist", "CamouflageNet", (effect) => {
|
||||
const base = effect.bonus?.[tankClassToBlitzTankClass[tank.class]] ?? 0;
|
||||
|
||||
bonus += base * (effect.activateBonusFactor ?? 1);
|
||||
});
|
||||
|
||||
return bonus;
|
||||
},
|
||||
},
|
||||
|
||||
_camouflage_net_bonus_moving: {
|
||||
type: CharacteristicType.Number,
|
||||
compute({ tank, script }) {
|
||||
let bonus = 0;
|
||||
|
||||
script("protagonist", "CamouflageNet", (effect) => {
|
||||
const base = effect.bonus?.[tankClassToBlitzTankClass[tank.class]] ?? 0;
|
||||
|
||||
bonus += base * 1;
|
||||
});
|
||||
|
||||
return bonus;
|
||||
},
|
||||
},
|
||||
|
||||
dispersion_moving: {
|
||||
type: CharacteristicType.Number,
|
||||
compute(context): number {
|
||||
const skill = (context.environment.skills.smooth_driving ?? 0) / 100;
|
||||
|
||||
return (
|
||||
context.track.dispersion_move *
|
||||
(1 +
|
||||
(context.characteristic("_stabilizer_coefficient") as number) -
|
||||
skill)
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
dispersion_hull_traversing: {
|
||||
type: CharacteristicType.Number,
|
||||
compute(context): number {
|
||||
const skill = (context.environment.skills.smooth_turn ?? 0) / 100;
|
||||
|
||||
return (
|
||||
context.track.dispersion_traverse *
|
||||
(1 +
|
||||
(context.characteristic("_stabilizer_coefficient") as number) -
|
||||
skill)
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
dispersion_turret_traversing: {
|
||||
type: CharacteristicType.Number,
|
||||
compute(context): number {
|
||||
const skill = (2 * (context.environment.skills.smooth_turret ?? 0)) / 100;
|
||||
|
||||
return (
|
||||
context.gun.dispersion_traverse *
|
||||
(1 +
|
||||
(context.characteristic("_stabilizer_coefficient") as number) -
|
||||
skill)
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
dispersion_shooting: {
|
||||
type: CharacteristicType.Number,
|
||||
compute(context): number {
|
||||
const skill = (context.environment.skills.soft_recoil ?? 0) / 100;
|
||||
|
||||
return (
|
||||
context.gun.dispersion_shot *
|
||||
(1 +
|
||||
(context.characteristic("_stabilizer_coefficient") as number) -
|
||||
skill)
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
dispersion_gun_damaged: {
|
||||
type: CharacteristicType.Number,
|
||||
compute(context): number {
|
||||
return (
|
||||
context.gun.dispersion_damaged *
|
||||
(1 + (context.characteristic("_stabilizer_coefficient") as number))
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
// dispersion_angle: unavailable,
|
||||
|
||||
gun_depression: {
|
||||
type: CharacteristicType.Number,
|
||||
compute({ state, turret, gun }) {
|
||||
compute({ state, turret, gun, script }) {
|
||||
const turretModel = state.model.turrets[turret.id];
|
||||
const gunModel = turretModel.guns[gun.id];
|
||||
let bonus = 0;
|
||||
|
||||
script("protagonist", "ImprovedPitchLimitsSystem", (effect) => {
|
||||
bonus += effect.bonusValues?.lowerPitchLimitIncrease ?? 0;
|
||||
});
|
||||
|
||||
return (
|
||||
gunModel.pitch!.max + (state.model.initial_turret_rotation?.pitch ?? 0)
|
||||
gunModel.pitch!.max +
|
||||
(state.model.initial_turret_rotation?.pitch ?? 0) +
|
||||
bonus
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
gun_elevation: {
|
||||
type: CharacteristicType.Number,
|
||||
compute({ state, turret, gun }) {
|
||||
compute({ state, turret, gun, script }) {
|
||||
const turretModel = state.model.turrets[turret.id];
|
||||
const gunModel = turretModel.guns[gun.id];
|
||||
let bonus = 0;
|
||||
|
||||
script("protagonist", "ImprovedPitchLimitsSystem", (effect) => {
|
||||
bonus += effect.bonusValues?.upperPitchLimitIncrease ?? 0;
|
||||
});
|
||||
|
||||
return (
|
||||
-gunModel.pitch!.min - (state.model.initial_turret_rotation?.pitch ?? 0)
|
||||
-gunModel.pitch!.min -
|
||||
(state.model.initial_turret_rotation?.pitch ?? 0) +
|
||||
bonus
|
||||
);
|
||||
},
|
||||
},
|
||||
@@ -596,22 +828,59 @@ export const characteristics = {
|
||||
},
|
||||
},
|
||||
|
||||
_engine_power_coefficient: {
|
||||
type: CharacteristicType.Number,
|
||||
compute({ tank, script }) {
|
||||
let coefficient = 1;
|
||||
|
||||
const tankClass = tankClassToBlitzTankClass[tank.class];
|
||||
|
||||
script("protagonist", "RecycledEngine", (effect) => {
|
||||
coefficient +=
|
||||
(effect.enginePowerPercentageBonus?.[tankClass] ?? 0) / 100;
|
||||
});
|
||||
|
||||
script("protagonist", "Afterburning", (effect) => {
|
||||
coefficient += (effect.bonusValues?.enginePowerIncrease ?? 0) / 100;
|
||||
});
|
||||
|
||||
script("protagonist", "ImprovedAfterburning", (effect) => {
|
||||
coefficient += (effect.enginePowerIncrease ?? 1) - 1;
|
||||
});
|
||||
|
||||
script("protagonist", "Fuel", (effect) => {
|
||||
coefficient += (effect.bonusValues?.enginePowerIncrease ?? 0) / 100;
|
||||
});
|
||||
|
||||
script("protagonist", "GearOil", (effect) => {
|
||||
coefficient += (effect.bonusValues?.enginePowerIncrease ?? 0) / 100;
|
||||
});
|
||||
|
||||
script("protagonist", "AttributesModifier", (effect) => {
|
||||
coefficient *= effect.attributes?.enginePowerFactor ?? 1;
|
||||
});
|
||||
|
||||
return coefficient;
|
||||
},
|
||||
},
|
||||
|
||||
engine_power: {
|
||||
type: CharacteristicType.Number,
|
||||
compute({ engine }) {
|
||||
return engine.power;
|
||||
// explicit return type breaks the circular inference through characteristic()
|
||||
compute({ engine, characteristic }): number {
|
||||
return (
|
||||
engine.power * (characteristic("_engine_power_coefficient") as number)
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
power_to_weight: {
|
||||
type: CharacteristicType.Number,
|
||||
compute({ engine, characteristic }) {
|
||||
const weight = characteristic("weight");
|
||||
if (typeof weight !== "number") {
|
||||
throw new Error("Weight characteristic must be numeric");
|
||||
}
|
||||
compute({ characteristic }): number {
|
||||
const enginePower = characteristic("engine_power") as number;
|
||||
const weight = characteristic("weight") as number;
|
||||
|
||||
return engine.power / weight;
|
||||
return enginePower / weight;
|
||||
},
|
||||
},
|
||||
|
||||
@@ -631,7 +900,7 @@ export const characteristics = {
|
||||
|
||||
hard_terrain_coefficient: {
|
||||
type: CharacteristicType.Number,
|
||||
compute({ characteristic }) {
|
||||
compute({ characteristic }): number {
|
||||
const raw = characteristic("hard_terrain_coefficient_raw") as number;
|
||||
return 100 / raw;
|
||||
},
|
||||
@@ -651,7 +920,7 @@ export const characteristics = {
|
||||
|
||||
medium_terrain_coefficient: {
|
||||
type: CharacteristicType.Number,
|
||||
compute({ characteristic }) {
|
||||
compute({ characteristic }): number {
|
||||
const raw = characteristic("medium_terrain_coefficient_raw") as number;
|
||||
return 100 / raw;
|
||||
},
|
||||
@@ -671,12 +940,42 @@ export const characteristics = {
|
||||
|
||||
soft_terrain_coefficient: {
|
||||
type: CharacteristicType.Number,
|
||||
compute({ characteristic }) {
|
||||
compute({ characteristic }): number {
|
||||
const raw = characteristic("soft_terrain_coefficient_raw") as number;
|
||||
return 100 / raw;
|
||||
},
|
||||
},
|
||||
|
||||
power_to_weight_hard: {
|
||||
type: CharacteristicType.Number,
|
||||
compute({ characteristic }): number {
|
||||
const powerToWeight = characteristic("power_to_weight") as number;
|
||||
const raw = characteristic("hard_terrain_coefficient_raw") as number;
|
||||
|
||||
return powerToWeight / raw;
|
||||
},
|
||||
},
|
||||
|
||||
power_to_weight_medium: {
|
||||
type: CharacteristicType.Number,
|
||||
compute({ characteristic }): number {
|
||||
const powerToWeight = characteristic("power_to_weight") as number;
|
||||
const raw = characteristic("medium_terrain_coefficient_raw") as number;
|
||||
|
||||
return powerToWeight / raw;
|
||||
},
|
||||
},
|
||||
|
||||
power_to_weight_soft: {
|
||||
type: CharacteristicType.Number,
|
||||
compute({ characteristic }): number {
|
||||
const powerToWeight = characteristic("power_to_weight") as number;
|
||||
const raw = characteristic("soft_terrain_coefficient_raw") as number;
|
||||
|
||||
return powerToWeight / raw;
|
||||
},
|
||||
},
|
||||
|
||||
hull_traverse_speed_hard: {
|
||||
type: CharacteristicType.Number,
|
||||
compute(context): number {
|
||||
@@ -707,17 +1006,32 @@ export const characteristics = {
|
||||
},
|
||||
},
|
||||
|
||||
_turret_traverse_coefficient: {
|
||||
type: CharacteristicType.Number,
|
||||
compute({ progressive, script }) {
|
||||
let c = 1;
|
||||
|
||||
script("protagonist", "Fuel", (effect) => {
|
||||
c += (effect.bonusValues?.turretRotationSpeedIncrease ?? 0) / 100;
|
||||
});
|
||||
|
||||
return c * progressive("gunner");
|
||||
},
|
||||
},
|
||||
|
||||
turret_traverse_speed: {
|
||||
type: CharacteristicType.Number,
|
||||
compute({ turret, progressive }) {
|
||||
return turret.traverse_speed * progressive("gunner");
|
||||
compute({ turret, characteristic }): number {
|
||||
const c = characteristic("_turret_traverse_coefficient") as number;
|
||||
return turret.traverse_speed * c;
|
||||
},
|
||||
},
|
||||
|
||||
gun_traverse_speed: {
|
||||
type: CharacteristicType.Number,
|
||||
compute({ gun, progressive }) {
|
||||
return gun.rotation_speed * progressive("gunner");
|
||||
compute({ gun, characteristic }): number {
|
||||
const c = characteristic("_turret_traverse_coefficient") as number;
|
||||
return gun.rotation_speed * c;
|
||||
},
|
||||
},
|
||||
|
||||
@@ -754,37 +1068,100 @@ export const characteristics = {
|
||||
|
||||
fire_chance: {
|
||||
type: CharacteristicType.Number,
|
||||
compute({ engine }) {
|
||||
return engine.fire_chance;
|
||||
compute({ engine, script }) {
|
||||
let c = 1;
|
||||
|
||||
script("protagonist", "SafetySet", (effect) => {
|
||||
c -= (effect.bonusValues?.fireProtectionIncrease ?? 0) / 100;
|
||||
});
|
||||
|
||||
return 100 * c * engine.fire_chance;
|
||||
},
|
||||
},
|
||||
|
||||
// fire_rate: unavailable,
|
||||
// ramming_resistance: unavailable,
|
||||
|
||||
view_range: {
|
||||
crew_count: {
|
||||
type: CharacteristicType.Number,
|
||||
compute({ turret, progressive }) {
|
||||
return turret.view_range * progressive("commander");
|
||||
compute({ tank }) {
|
||||
return tank.crew.length;
|
||||
},
|
||||
},
|
||||
|
||||
view_range: {
|
||||
type: CharacteristicType.Number,
|
||||
compute({ tank, turret, progressive, script }) {
|
||||
let c = 1;
|
||||
|
||||
script("protagonist", "CoatedOptics", (effect) => {
|
||||
c +=
|
||||
(effect.visionRadiusPercentageBonus?.[
|
||||
tankClassToBlitzTankClass[tank.class]
|
||||
] ?? 0) / 100;
|
||||
});
|
||||
|
||||
return turret.view_range * c * progressive("commander");
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* camouflage characteristics are stored as percentages
|
||||
*/
|
||||
camouflage: {
|
||||
type: CharacteristicType.Number,
|
||||
compute({ tank, state }) {
|
||||
const camouflageBonus =
|
||||
tank.class === TankClass.TANK_CLASS_TANK_DESTROYER
|
||||
? 0.04
|
||||
: tank.class === TankClass.TANK_CLASS_HEAVY
|
||||
? 0.03
|
||||
: 0.02;
|
||||
compute(context): number {
|
||||
const skill = (3 * (context.environment.skills.camouflage ?? 0)) / 100;
|
||||
const c =
|
||||
1 +
|
||||
(context.characteristic("_camouflage_bonus") as number) +
|
||||
skill +
|
||||
(context.characteristic("_camouflage_net_bonus_still") as number);
|
||||
|
||||
return 100 * context.tank.camouflage_still * c;
|
||||
},
|
||||
},
|
||||
|
||||
camouflage_moving: {
|
||||
type: CharacteristicType.Number,
|
||||
compute(context): number {
|
||||
// light tanks retain their full camouflage while moving
|
||||
if (context.tank.class === TankClass.TANK_CLASS_LIGHT) {
|
||||
return context.characteristic("camouflage") as number;
|
||||
}
|
||||
|
||||
const c =
|
||||
1 +
|
||||
(context.characteristic("_camouflage_bonus") as number) +
|
||||
(context.characteristic("_camouflage_net_bonus_moving") as number);
|
||||
|
||||
return 100 * context.tank.camouflage_moving * c;
|
||||
},
|
||||
},
|
||||
|
||||
camouflage_shooting_still: {
|
||||
type: CharacteristicType.Number,
|
||||
compute({ gun, characteristic }): number {
|
||||
return gun.camouflage_loss * (characteristic("camouflage") as number);
|
||||
},
|
||||
},
|
||||
|
||||
camouflage_shooting_moving: {
|
||||
type: CharacteristicType.Number,
|
||||
compute({ gun, characteristic }): number {
|
||||
return (
|
||||
tank.camouflage_still * (1 + (state.camouflage ? camouflageBonus : 0))
|
||||
gun.camouflage_loss * (characteristic("camouflage_moving") as number)
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
camouflage_on_fire: {
|
||||
type: CharacteristicType.Number,
|
||||
compute({ tank, characteristic }): number {
|
||||
return tank.camouflage_on_fire * (characteristic("camouflage") as number);
|
||||
},
|
||||
},
|
||||
|
||||
width: {
|
||||
type: CharacteristicType.Number,
|
||||
compute({ state, turret }) {
|
||||
|
||||
@@ -81,6 +81,20 @@ export const characteristicsGroups: CharacteristicsGroup[] = [
|
||||
},
|
||||
},
|
||||
{ name: "dpm", decimals: 0, units: "hp", localize: true },
|
||||
{
|
||||
name: "dpm_effective",
|
||||
decimals: 0,
|
||||
units: "hp",
|
||||
localize: true,
|
||||
indent: true,
|
||||
},
|
||||
{
|
||||
name: "dpm_peekaboo",
|
||||
decimals: 0,
|
||||
units: "hp",
|
||||
localize: true,
|
||||
indent: true,
|
||||
},
|
||||
{ name: "damage", decimals: 0, units: "hp", localize: true },
|
||||
{ name: "damage_range", decimals: 0, units: "hp", localize: true },
|
||||
{ name: "module_damage", decimals: 0, units: "hp", localize: true },
|
||||
@@ -108,7 +122,7 @@ export const characteristicsGroups: CharacteristicsGroup[] = [
|
||||
order: [
|
||||
{ name: "clipping_potential", localize: true, units: "hp" },
|
||||
{ name: "clip_size" },
|
||||
{ name: "optimal_shell" },
|
||||
{ name: "optimal_shell", ranking: CharacteristicRanking.None },
|
||||
{
|
||||
name: "intra_clip",
|
||||
decimals: 2,
|
||||
@@ -156,6 +170,12 @@ export const characteristicsGroups: CharacteristicsGroup[] = [
|
||||
units: "deg",
|
||||
ranking: CharacteristicRanking.None,
|
||||
},
|
||||
{
|
||||
name: "explosion_radius",
|
||||
decimals: 2,
|
||||
units: "m",
|
||||
ranking: CharacteristicRanking.None,
|
||||
},
|
||||
|
||||
{ toy: "ricochet" },
|
||||
],
|
||||
@@ -178,6 +198,44 @@ export const characteristicsGroups: CharacteristicsGroup[] = [
|
||||
ranking: CharacteristicRanking.LowerIsBetter,
|
||||
},
|
||||
{ name: "dispersion_angle", decimals: 3, units: "deg" },
|
||||
|
||||
{ label: "dispersion_penalties" },
|
||||
{
|
||||
name: "dispersion_moving",
|
||||
decimals: 3,
|
||||
units: "plus",
|
||||
indent: true,
|
||||
ranking: CharacteristicRanking.LowerIsBetter,
|
||||
},
|
||||
{
|
||||
name: "dispersion_hull_traversing",
|
||||
decimals: 3,
|
||||
units: "plus",
|
||||
indent: true,
|
||||
ranking: CharacteristicRanking.LowerIsBetter,
|
||||
},
|
||||
{
|
||||
name: "dispersion_turret_traversing",
|
||||
decimals: 3,
|
||||
units: "plus",
|
||||
indent: true,
|
||||
ranking: CharacteristicRanking.LowerIsBetter,
|
||||
},
|
||||
{
|
||||
name: "dispersion_shooting",
|
||||
decimals: 3,
|
||||
units: "plus",
|
||||
indent: true,
|
||||
ranking: CharacteristicRanking.LowerIsBetter,
|
||||
},
|
||||
{
|
||||
name: "dispersion_gun_damaged",
|
||||
decimals: 1,
|
||||
units: "x",
|
||||
indent: true,
|
||||
ranking: CharacteristicRanking.LowerIsBetter,
|
||||
},
|
||||
|
||||
{ toy: "aim_time" },
|
||||
],
|
||||
},
|
||||
@@ -215,6 +273,26 @@ export const characteristicsGroups: CharacteristicsGroup[] = [
|
||||
},
|
||||
{ name: "engine_power", units: "bhp" },
|
||||
{ name: "power_to_weight", decimals: 1, units: "bhp_t" },
|
||||
|
||||
{ label: "power_to_weight_on_terrain" },
|
||||
{
|
||||
name: "power_to_weight_hard",
|
||||
decimals: 1,
|
||||
units: "bhp_t",
|
||||
indent: true,
|
||||
},
|
||||
{
|
||||
name: "power_to_weight_medium",
|
||||
decimals: 1,
|
||||
units: "bhp_t",
|
||||
indent: true,
|
||||
},
|
||||
{
|
||||
name: "power_to_weight_soft",
|
||||
decimals: 1,
|
||||
units: "bhp_t",
|
||||
indent: true,
|
||||
},
|
||||
// { name: "brake_force", decimals: 1, units: "kN" },
|
||||
|
||||
{ label: "hull_traverse_speeds" },
|
||||
@@ -264,9 +342,11 @@ export const characteristicsGroups: CharacteristicsGroup[] = [
|
||||
{ name: "health", units: "hp" },
|
||||
{
|
||||
name: "fire_chance",
|
||||
decimals: 0,
|
||||
units: "percentage",
|
||||
ranking: CharacteristicRanking.LowerIsBetter,
|
||||
},
|
||||
{ name: "crew_count" },
|
||||
// { name: "fire_rate", units: "hp_s" },
|
||||
// { name: "ramming_resistance", units: "percentage" },
|
||||
|
||||
@@ -281,7 +361,38 @@ export const characteristicsGroups: CharacteristicsGroup[] = [
|
||||
// render: CharacteristicsGroupRender.Statistical,
|
||||
order: [
|
||||
{ name: "view_range", units: "m" },
|
||||
{ name: "camouflage", decimals: 0, units: "percentage" },
|
||||
|
||||
{ label: "camouflages" },
|
||||
{
|
||||
name: "camouflage",
|
||||
decimals: 2,
|
||||
units: "percentage",
|
||||
indent: true,
|
||||
},
|
||||
{
|
||||
name: "camouflage_moving",
|
||||
decimals: 2,
|
||||
units: "percentage",
|
||||
indent: true,
|
||||
},
|
||||
{
|
||||
name: "camouflage_shooting_still",
|
||||
decimals: 2,
|
||||
units: "percentage",
|
||||
indent: true,
|
||||
},
|
||||
{
|
||||
name: "camouflage_shooting_moving",
|
||||
decimals: 2,
|
||||
units: "percentage",
|
||||
indent: true,
|
||||
},
|
||||
{
|
||||
name: "camouflage_on_fire",
|
||||
decimals: 2,
|
||||
units: "percentage",
|
||||
indent: true,
|
||||
},
|
||||
|
||||
{ toy: "view_range" },
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
FlexibilityToy,
|
||||
ReloadToy,
|
||||
RicochetToy,
|
||||
TraverseToy,
|
||||
ViewRangeToy,
|
||||
} from "../components/TankopediaToys";
|
||||
import type { ComputedCharacteristics } from "../types/characteristics";
|
||||
|
||||
@@ -14,4 +16,6 @@ export const toys: Record<string, (props: ToyProps) => ReactNode> = {
|
||||
ricochet: RicochetToy,
|
||||
traverse: TraverseToy,
|
||||
reload: ReloadToy,
|
||||
view_range: ViewRangeToy,
|
||||
flexibility: FlexibilityToy,
|
||||
};
|
||||
@@ -4,6 +4,7 @@ import { Scroller } from "../../../../../components/Scroller";
|
||||
import type { ThicknessRange } from "../../../../../components/TankStaticArmor";
|
||||
import { TankopediaCharacteristics } from "../../../../../components/TankopediaCharacteristics";
|
||||
import { TankopediaGameModeAbilities } from "../../../../../components/TankopediaGameModeAbilities";
|
||||
import { TankopediaVideos } from "../../../../../components/TankopediaVideos";
|
||||
import { TankopediaLoadout } from "../../../../../components/TankopediaLoadout";
|
||||
import { TankopediaSandbox } from "../../../../../components/TankopediaSandbox";
|
||||
import { defaultEqualizer } from "../../../../../config/equalizer";
|
||||
@@ -69,6 +70,7 @@ export const Page = withErrorWrapper(
|
||||
/>
|
||||
<TankopediaCharacteristics characteristics={characteristics} />
|
||||
<TankopediaGameModeAbilities />
|
||||
<TankopediaVideos />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -67,9 +67,15 @@ export function calculateRankings(
|
||||
|
||||
if (currentValue === undefined || name[0] === "_") continue;
|
||||
|
||||
const config = characteristicConfigs.get(name)!;
|
||||
const config = characteristicConfigs.get(name);
|
||||
|
||||
if (config.ranking === CharacteristicRankingDirection.None) continue;
|
||||
// characteristics without a render config are never displayed
|
||||
if (
|
||||
config === undefined ||
|
||||
config.ranking === CharacteristicRankingDirection.None
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const lowerIsBetter =
|
||||
config.ranking === CharacteristicRankingDirection.LowerIsBetter;
|
||||
|
||||
@@ -2,43 +2,6 @@ import type {
|
||||
CharacteristicContext,
|
||||
CharacteristicScript,
|
||||
} from "../types/characteristics";
|
||||
import { tankClassToBlitzTankClass } from "./tankClass";
|
||||
|
||||
function hullTraverseEnginePowerCoefficient(
|
||||
tank: TankDefinition,
|
||||
script: CharacteristicScript,
|
||||
) {
|
||||
let coefficient = 1;
|
||||
|
||||
const enginePowerPercentage = tankClassToBlitzTankClass[tank.class];
|
||||
|
||||
script("protagonist", "RecycledEngine", (effect) => {
|
||||
coefficient +=
|
||||
(effect.enginePowerPercentageBonus?.[enginePowerPercentage] ?? 0) / 100;
|
||||
});
|
||||
|
||||
script("protagonist", "Afterburning", (effect) => {
|
||||
coefficient += (effect.bonusValues?.enginePowerIncrease ?? 0) / 100;
|
||||
});
|
||||
|
||||
script("protagonist", "ImprovedAfterburning", (effect) => {
|
||||
coefficient += (effect.enginePowerIncrease ?? 1) - 1;
|
||||
});
|
||||
|
||||
script("protagonist", "Fuel", (effect) => {
|
||||
coefficient += (effect.bonusValues?.enginePowerIncrease ?? 0) / 100;
|
||||
});
|
||||
|
||||
script("protagonist", "GearOil", (effect) => {
|
||||
coefficient += (effect.bonusValues?.enginePowerIncrease ?? 0) / 100;
|
||||
});
|
||||
|
||||
script("protagonist", "AttributesModifier", (effect) => {
|
||||
coefficient *= effect.attributes?.enginePowerFactor ?? 1;
|
||||
});
|
||||
|
||||
return coefficient;
|
||||
}
|
||||
|
||||
function hullTraverseRotationCoefficient(
|
||||
driverCoefficient: number,
|
||||
@@ -76,6 +39,7 @@ export function hullTraverseSpeed(
|
||||
progressive,
|
||||
script,
|
||||
environment,
|
||||
characteristic,
|
||||
} = context;
|
||||
const terrainResistance = track.resistance_hard;
|
||||
const stockEngine = tank.engines[0];
|
||||
@@ -91,7 +55,7 @@ export function hullTraverseSpeed(
|
||||
const weight =
|
||||
tank.weight + engine.weight + track.weight + turret.weight + gun.weight;
|
||||
const enginePower =
|
||||
(engine.power * hullTraverseEnginePowerCoefficient(tank, script)) /
|
||||
(engine.power * (characteristic("_engine_power_coefficient") as number)) /
|
||||
stockEngine.power;
|
||||
const rotationSpeed = hullTraverseRotationCoefficient(
|
||||
progressive("driver"),
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
import { ModuleType, type ModulePosition, type Unlock } from "@blitzkit/protos";
|
||||
import type { Vector2Tuple } from "three";
|
||||
|
||||
/**
|
||||
* Port of the game client's modules screen layout (wotblitz.exe 11.21,
|
||||
* `ModulesTreeInfoCache` auto-layout + `TankModulesTreeController` arrows).
|
||||
*
|
||||
* Every node lives on one shared grid: depth is the column, and leaves take
|
||||
* consecutive rows with each parent centered over its children. Connectors are
|
||||
* per-cell flags rather than a nested tree.
|
||||
*/
|
||||
|
||||
// The client sorts siblings by this order: guns, turrets, engines, tracks, tanks
|
||||
const moduleTypeOrder = [
|
||||
ModuleType.MODULE_TYPE_GUN,
|
||||
ModuleType.MODULE_TYPE_TURRET,
|
||||
ModuleType.MODULE_TYPE_ENGINE,
|
||||
ModuleType.MODULE_TYPE_TRACKS,
|
||||
ModuleType.MODULE_TYPE_VEHICLE,
|
||||
];
|
||||
const moduleTypeRank = new Map(
|
||||
moduleTypeOrder.map((type, index) => [type, index])
|
||||
);
|
||||
|
||||
export interface ModuleTreeCell {
|
||||
// 1-based grid column (also the depth in the unlock graph) and row
|
||||
position: Vector2Tuple;
|
||||
// Missing for empty cells that only carry a connector line
|
||||
unlock?: Unlock;
|
||||
// Arrow entering from the left
|
||||
left: boolean;
|
||||
// Stub leaving to the right
|
||||
right: boolean;
|
||||
// Vertical line from the cell center up to the previous row
|
||||
trunkUp: boolean;
|
||||
// Vertical line from the cell center down to the next row
|
||||
trunkDown: boolean;
|
||||
// Snake mode: line leaving the top edge with an arrow pointing up
|
||||
snakeUp: boolean;
|
||||
// Snake mode: line leaving the bottom edge with an arrow pointing down
|
||||
snakeDown: boolean;
|
||||
}
|
||||
|
||||
interface Branch {
|
||||
unlock: Unlock;
|
||||
key: string;
|
||||
children: Branch[];
|
||||
// Rows this branch spans
|
||||
leaves: number;
|
||||
}
|
||||
|
||||
interface Placement {
|
||||
unlock: Unlock;
|
||||
position: Vector2Tuple;
|
||||
}
|
||||
|
||||
function key(unlock: Unlock) {
|
||||
return `${unlock.type}:${unlock.id}`;
|
||||
}
|
||||
|
||||
// Cells are indexed column first, then row
|
||||
type CellGrid = Map<number, Map<number, ModuleTreeCell>>;
|
||||
|
||||
function flattenGrid(grid: CellGrid) {
|
||||
return [...grid.values()].flatMap((column) => [...column.values()]);
|
||||
}
|
||||
|
||||
function emptyCell(x: number, y: number) {
|
||||
return {
|
||||
position: [x, y],
|
||||
left: false,
|
||||
right: false,
|
||||
trunkUp: false,
|
||||
trunkDown: false,
|
||||
snakeUp: false,
|
||||
snakeDown: false,
|
||||
} satisfies ModuleTreeCell;
|
||||
}
|
||||
|
||||
/**
|
||||
* The client switches to this when any module is bought with something other
|
||||
* than xp (season tanks). It ignores the unlock graph past the stock modules
|
||||
* and folds the sequence into columns the height of the stock column,
|
||||
* alternating direction, with arrows decided purely by position.
|
||||
*/
|
||||
function layoutSnake(placements: Placement[]) {
|
||||
const ordered = [...placements].sort(
|
||||
(a, b) => a.position[0] - b.position[0] || a.position[1] - b.position[1]
|
||||
);
|
||||
const height = ordered.filter(({ position: [x] }) => x === 1).length;
|
||||
const deep = ordered.filter(({ position: [x] }) => x >= 2);
|
||||
|
||||
// The client takes this from whichever deep module sorts last
|
||||
const lastOnFirstRow = deep.length > 0 && deep.at(-1)!.position[1] === 1;
|
||||
|
||||
const grid: CellGrid = new Map();
|
||||
|
||||
for (const {
|
||||
unlock,
|
||||
position: [x, y],
|
||||
} of ordered) {
|
||||
let column = x;
|
||||
let row = y;
|
||||
|
||||
if (x >= 2) {
|
||||
const index = x - 2;
|
||||
const offset = index % height;
|
||||
|
||||
column = Math.floor(index / height) + 2;
|
||||
row =
|
||||
((lastOnFirstRow ? ~column : column) & 1) === 0
|
||||
? height - offset
|
||||
: offset + 1;
|
||||
}
|
||||
|
||||
let columnCells = grid.get(column);
|
||||
|
||||
if (columnCells === undefined) {
|
||||
columnCells = new Map();
|
||||
grid.set(column, columnCells);
|
||||
}
|
||||
|
||||
columnCells.set(row, { ...emptyCell(column, row), unlock });
|
||||
}
|
||||
|
||||
const cells = flattenGrid(grid);
|
||||
const width = Math.ceil(ordered.length / height);
|
||||
|
||||
for (const cell of cells) {
|
||||
const [column, row] = cell.position;
|
||||
const i = column - 1;
|
||||
const j = row - 1;
|
||||
const down = lastOnFirstRow ? i % 2 === 1 : i % 2 === 0;
|
||||
const hasNext = i < width - 1;
|
||||
|
||||
// The first column connects to the tank panel on the page this was
|
||||
// modeled on, which doesn't exist here, so nothing enters it from the left
|
||||
if (j === 0) {
|
||||
cell.left = i > 0 && down;
|
||||
cell.right = !down && hasNext;
|
||||
cell.snakeDown = down && i > 0;
|
||||
} else if (j === height - 1) {
|
||||
cell.left = i > 0 && !down;
|
||||
cell.right = down && hasNext;
|
||||
cell.snakeUp = !down && i > 0;
|
||||
} else {
|
||||
cell.snakeUp = !down && i > 0;
|
||||
cell.snakeDown = down && i > 0;
|
||||
}
|
||||
}
|
||||
|
||||
let columns = width;
|
||||
let rows = height;
|
||||
|
||||
for (const cell of cells) {
|
||||
columns = Math.max(columns, cell.position[0]);
|
||||
rows = Math.max(rows, cell.position[1]);
|
||||
}
|
||||
|
||||
return { cells, columns, rows };
|
||||
}
|
||||
|
||||
function sortSiblings(unlocks: Unlock[]) {
|
||||
return [...unlocks].sort(
|
||||
(a, b) =>
|
||||
(moduleTypeRank.get(a.type) ?? -1) - (moduleTypeRank.get(b.type) ?? -1)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param positions hand-authored cells for this tank. When given, the client
|
||||
* places exactly those modules and hides the rest instead of running the
|
||||
* automatic layout.
|
||||
*/
|
||||
export function layoutModuleTree(
|
||||
roots: Unlock[],
|
||||
getChildren: (unlock: Unlock) => Unlock[],
|
||||
positions: ModulePosition[] = []
|
||||
) {
|
||||
const authored =
|
||||
positions.length === 0
|
||||
? undefined
|
||||
: new Map<string, Vector2Tuple>(
|
||||
positions.map(({ type, id, position }) => [
|
||||
`${type}:${id}`,
|
||||
[position!.x, position!.y],
|
||||
])
|
||||
);
|
||||
|
||||
// A module's subtree doesn't depend on which parent reached it, so each one
|
||||
// is grown once instead of once per path through the unlock graph
|
||||
const branches = new Map<string, Branch>();
|
||||
|
||||
// Recursive, so the return type can't be inferred
|
||||
const grow = (unlock: Unlock): Branch => {
|
||||
const unlockKey = key(unlock);
|
||||
const cached = branches.get(unlockKey);
|
||||
|
||||
if (cached !== undefined) return cached;
|
||||
|
||||
const children = sortSiblings(getChildren(unlock)).map(grow);
|
||||
const branch = {
|
||||
unlock,
|
||||
key: unlockKey,
|
||||
children,
|
||||
leaves:
|
||||
children.length === 0
|
||||
? 1
|
||||
: children.reduce((total, child) => total + child.leaves, 0),
|
||||
} satisfies Branch;
|
||||
|
||||
branches.set(unlockKey, branch);
|
||||
|
||||
return branch;
|
||||
};
|
||||
|
||||
// A module unlocked from several parents is laid out once per parent and the
|
||||
// last placement wins, same as the client
|
||||
const placements = new Map<string, Placement>();
|
||||
// Keyed by both ends so shared subtrees don't repeat their connectors
|
||||
const edges = new Map<string, [parent: string, child: string]>();
|
||||
let isSnake = false;
|
||||
|
||||
const place = (branch: Branch, depth: number, startRow: number) => {
|
||||
const position =
|
||||
authored === undefined
|
||||
? ([
|
||||
depth,
|
||||
Math.floor((branch.leaves - 1) / 2) + startRow,
|
||||
] as Vector2Tuple)
|
||||
: authored.get(branch.key);
|
||||
|
||||
if (position !== undefined) {
|
||||
const { unlock } = branch;
|
||||
|
||||
placements.set(branch.key, { unlock, position });
|
||||
|
||||
if (unlock.cost !== undefined && unlock.cost.type !== "xp") {
|
||||
isSnake = true;
|
||||
}
|
||||
}
|
||||
|
||||
let row = startRow;
|
||||
|
||||
for (const child of branch.children) {
|
||||
place(child, depth + 1, row);
|
||||
edges.set(`${branch.key}>${child.key}`, [branch.key, child.key]);
|
||||
row += child.leaves;
|
||||
}
|
||||
};
|
||||
|
||||
// The tank itself is the invisible root at depth 0
|
||||
let row = 1;
|
||||
|
||||
for (const branch of sortSiblings(roots).map(grow)) {
|
||||
place(branch, 1, row);
|
||||
row += branch.leaves;
|
||||
}
|
||||
|
||||
if (isSnake) return layoutSnake([...placements.values()]);
|
||||
|
||||
const grid: CellGrid = new Map();
|
||||
let columns = 0;
|
||||
let rows = 0;
|
||||
|
||||
const cellAt = (x: number, y: number) => {
|
||||
let columnCells = grid.get(x);
|
||||
|
||||
if (columnCells === undefined) {
|
||||
columnCells = new Map();
|
||||
grid.set(x, columnCells);
|
||||
}
|
||||
|
||||
let cell = columnCells.get(y);
|
||||
|
||||
if (cell === undefined) {
|
||||
cell = emptyCell(x, y);
|
||||
columnCells.set(y, cell);
|
||||
columns = Math.max(columns, x);
|
||||
rows = Math.max(rows, y);
|
||||
}
|
||||
|
||||
return cell;
|
||||
};
|
||||
|
||||
for (const {
|
||||
unlock,
|
||||
position: [x, y],
|
||||
} of placements.values()) {
|
||||
cellAt(x, y).unlock = unlock;
|
||||
}
|
||||
|
||||
for (const [parentKey, childKey] of edges.values()) {
|
||||
const parent = placements.get(parentKey);
|
||||
const child = placements.get(childKey);
|
||||
|
||||
// Unplaced modules are hidden, and so are the connectors touching them
|
||||
if (parent === undefined || child === undefined) continue;
|
||||
|
||||
const [, parentRow] = parent.position;
|
||||
const [childColumn, childRow] = child.position;
|
||||
|
||||
cellAt(...parent.position).right = true;
|
||||
cellAt(...child.position).left = true;
|
||||
|
||||
// The vertical bus runs in the gap left of the child's column, through
|
||||
// empty cells where it has to
|
||||
const top = Math.min(parentRow, childRow);
|
||||
const bottom = Math.max(parentRow, childRow);
|
||||
|
||||
for (let y = top; y <= bottom; y++) {
|
||||
const cell = cellAt(childColumn, y);
|
||||
|
||||
if (y > top) cell.trunkUp = true;
|
||||
if (y < bottom) cell.trunkDown = true;
|
||||
}
|
||||
}
|
||||
|
||||
return { cells: flattenGrid(grid), columns, rows };
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Vector3 as BlitzKitVector } from "@blitzkit/protos";
|
||||
import type { ProtoVector3 } from "@blitzkit/protos";
|
||||
import { Vector3 } from "three";
|
||||
|
||||
export function toThreeVector(vector?: BlitzKitVector) {
|
||||
export function toThreeVector(vector?: ProtoVector3) {
|
||||
return new Vector3(vector!.x, vector!.y, vector!.z);
|
||||
}
|
||||
Reference in new issue
Block a user