Files
blitzkit/packages/website/src/config/characteristics.ts
T
2026-11-11 23:23:53 -06:00

1243 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { Vector2Tuple } from "three";
import {
blitzkitToBlitzShellKind,
isExplosive,
normalizeBoundingBox,
unionBoundingBox,
} from "@blitzkit/core";
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;
// characteristics prefixed with an underscore are compute time only; they are never rendered
export const characteristics = {
gun_type: {
type: CharacteristicType.Enum,
compute({ gun }) {
return gun.gun_type!.$case;
},
},
damage: {
type: CharacteristicType.Number,
compute({ shell, environment, equalizer, script, gun }) {
let coefficient = 1;
if (environment.equalize) coefficient *= equalizer.damage;
script("protagonist", "TungstenTip", (effect) => {
coefficient *= (effect.minDamageFactor + effect.maxDamageFactor) / 2;
});
script("antagonist", "ShieldKit", (effect) => {
if (shell.type === ShellType.SHELL_TYPE_HE) return;
coefficient *= 1 - effect.shieldCoef;
});
script("antagonist", "ArmorMover", (effect) => {
coefficient *= 1 - effect.bonusValues!.damageReductionPercent / 100;
});
script("antagonist", "AntiHighExplosive", (effect) => {
if (shell.type !== ShellType.SHELL_TYPE_HE) return;
coefficient *= effect.bonusValues!.factor;
});
if (gun.assault_ranges?.types.includes(shell.type)) {
const range = gun.assault_ranges.ranges.find(
({ distance }) => distance >= environment.distance,
);
coefficient *= range?.factor ?? 0;
}
return coefficient * shell.armor_damage;
},
},
_reload_coefficient: {
type: CharacteristicType.Number,
compute({ script, degressive, gun }) {
let c0 = 1;
script("protagonist", "miscAttrs/gunReloadTimeFactor", (effect) => {
c0 += effect.bonusValues!.percent / 100;
});
const c1 = degressive("loader");
let c2 = 1;
// adrenaline only affects regular guns
if (gun.gun_type!.$case === "regular") {
script("protagonist", "Berserk", (effect) => {
c2 += effect.bonusValues!.gunReloadSpeedIncrease / 100;
});
}
return (c0 * c1) / c2;
},
},
reload: {
type: CharacteristicType.Number,
should_render({ gun }) {
return gun.gun_type!.$case !== "auto_reloader";
},
compute({ gun, characteristic }): number {
if (gun.gun_type!.$case === "auto_reloader") return -Infinity;
const c = characteristic("_reload_coefficient") as number;
if (gun.gun_type!.$case === "regular") {
return c * gun.gun_type!.value.reload;
}
return c * gun.gun_type!.value.clip_reload;
},
},
reloads: {
type: CharacteristicType.NumberList,
should_render({ gun }) {
return gun.gun_type!.$case === "auto_reloader";
},
compute({ gun, characteristic }) {
if (gun.gun_type!.$case !== "auto_reloader") return [];
const reloadCoefficient = characteristic("_reload_coefficient") as number;
return gun.gun_type!.value.shell_reloads.map(
(reload) => reload * reloadCoefficient,
);
},
},
intra_clip: {
type: CharacteristicType.Number,
should_render({ gun }) {
return gun.gun_type!.$case !== "regular";
},
compute({ gun, script }) {
if (gun.gun_type!.$case === "regular") return 0;
let c = 1;
script("protagonist", "NewAutoloader", (effect) => {
c = effect.clipReloadTimeFactor;
});
return c * gun.gun_type!.value.intra_clip;
},
},
// 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;
if (gun.gun_type!.$case === "regular") {
const reload = characteristic("reload") as number;
dps = alpha / reload;
} else if (gun.gun_type!.$case === "auto_loader") {
const damage = alpha * gun.gun_type!.value.shell_count;
const intraClip = characteristic("intra_clip") as number;
let time = characteristic("reload") as number;
if (gun.burst) {
time +=
(gun.gun_type!.value.shell_count / gun.burst.count - 1) * intraClip;
time +=
(gun.gun_type!.value.shell_count / gun.burst.count) *
(gun.burst.count - 1) *
gun.burst.interval;
} else {
time += (gun.gun_type!.value.shell_count - 1) * intraClip;
}
dps = damage / time;
} else {
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 rolls within +-25% unless tungsten tips raise the lower end
_damage_spread: {
type: CharacteristicType.Interval,
compute({ script }) {
let low = 0.75;
let high = 1.25;
script("protagonist", "TungstenTip", (effect) => {
low = effect.minDamageFactor;
high = effect.maxDamageFactor;
});
return [low, high] satisfies Vector2Tuple;
},
},
damage_range: {
type: CharacteristicType.Interval,
compute({ characteristic }): Vector2Tuple {
const [low, high] = characteristic("_damage_spread") as Vector2Tuple;
// damage already folds in the average of the spread
const base = (characteristic("damage") as number) / ((low + high) / 2);
return [base * low, base * high];
},
},
module_damage: {
type: CharacteristicType.Number,
compute({ shell, script }) {
let c = 1;
script("protagonist", "TungstenTip", (effect) => {
c *= (effect.minDamageFactor + effect.maxDamageFactor) / 2;
});
return c * shell.module_damage;
},
},
module_damage_range: {
type: CharacteristicType.Interval,
compute({ characteristic }): Vector2Tuple {
const [low, high] = characteristic("_damage_spread") as Vector2Tuple;
const base =
(characteristic("module_damage") as number) / ((low + high) / 2);
return [base * low, base * high];
},
},
clip_size: {
type: CharacteristicType.Number,
should_render({ gun }) {
return gun.gun_type!.$case !== "regular";
},
compute({ gun }) {
if (gun.gun_type!.$case === "regular") return 1;
return gun.gun_type!.value.shell_count;
},
},
clipping_potential: {
type: CharacteristicType.Number,
should_render({ gun }) {
return gun.gun_type!.$case !== "regular";
},
compute({ characteristic }): number {
const clipSize = characteristic("clip_size") as number;
const damage = characteristic("damage") as number;
return clipSize * damage;
},
},
/**
* 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";
},
compute({ characteristic }): number {
const reloads = characteristic("reloads") as number[];
const intraClip = characteristic("intra_clip") as number;
let best = 0;
let bestTime = Infinity;
reloads.forEach((reload, index) => {
const time = reload + (index > 0 ? intraClip : 0);
if (time < bestTime) {
best = index;
bestTime = time;
}
});
return best + 1;
},
},
burst_size: {
type: CharacteristicType.Number,
should_render({ gun }) {
return gun.burst !== undefined;
},
compute({ gun }) {
return gun.burst!.count;
},
},
intra_burst: {
type: CharacteristicType.Number,
should_render({ gun }) {
return gun.burst !== undefined;
},
compute({ gun }) {
return gun.burst!.interval;
},
},
penetration: {
type: CharacteristicType.Number,
compute({ shell, environment, equalizer, script }) {
let c = 1;
script("protagonist", "CalibratedShells", (effect) => {
c +=
effect.piercingFactors![blitzkitToBlitzShellKind[shell.type]] / 100;
});
if (environment.equalize) c *= equalizer.penetration;
return c * shell.penetration!.near;
},
},
penetration_loss_by_distance: {
type: CharacteristicType.Number,
compute({ shell, script }) {
let c = 1;
script("protagonist", "StaticMiscAttrsModifier", (effect) => {
c += effect.bonusValues!.piercingPenaltyFactor500m / 100;
});
return (
c *
100 *
((shell.penetration!.near - shell.penetration!.far) /
shell.penetration!.near)
);
},
},
penetration_loss_after_ricochet: {
type: CharacteristicType.Number,
should_render({ shell }) {
return !isExplosive(shell.type);
},
compute() {
return 25;
},
},
shell_velocity: {
type: CharacteristicType.Number,
compute({ shell, script }) {
let c = 1;
script("protagonist", "StaticMiscAttrsModifier", (effect) => {
c += effect.bonusValues!.projectileSpeedFactor / 100;
});
script("protagonist", "GunPowder", (effect) => {
c += effect.bonusValues!.projectileSpeedFactor / 100;
});
return c * shell.velocity;
},
},
shell_range: {
type: CharacteristicType.Number,
compute({ shell }) {
return shell.range;
},
},
shell_capacity: {
type: CharacteristicType.Number,
compute({ gun }) {
return gun.shell_capacity;
},
},
caliber: {
type: CharacteristicType.Number,
compute({ shell }) {
return shell.caliber;
},
},
ricochet: {
type: CharacteristicType.Number,
should_render({ shell }) {
return !isExplosive(shell.type);
},
compute({ shell }) {
return shell.ricochet ?? 0;
},
},
normalization: {
type: CharacteristicType.Number,
should_render({ shell }) {
return !isExplosive(shell.type);
},
compute({ shell }) {
return shell.normalization ?? 0;
},
},
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, 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, 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({ tank, gun, turret, track, degressive, script }): number {
/*
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(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 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 S =
(turret.traverse_speed * gun.dispersion_traverse) ** 2 +
(track.traverse_speed * track.dispersion_traverse) ** 2 +
(Math.min(tank.speed_forwards, 30) * track.dispersion_move) ** 2;
const A = gun.aim_time;
const C = degressive("gunner");
let p = 0;
script("protagonist", "miscAttrs/gunAimingTimeFactor", (effect) => {
p += effect.bonusValues!.percent / 100;
});
let k = 1;
script(
"protagonist",
"miscAttrs/additiveShotDispersionFactor",
(effect) => {
k += effect.bonusValues!.percent / 100;
},
);
const T = (A * C * Math.log(Math.sqrt(1 + k ** 2 * S))) / (1 - p);
return T;
},
},
dispersion: {
type: CharacteristicType.Number,
compute({ gun, degressive, script }) {
let coefficient = 1;
script("protagonist", "miscAttrs/shotDispersionAngleFactor", (effect) => {
coefficient += effect.bonusValues!.percent / 100;
});
script("protagonist", "FireControlSystem", (effect) => {
coefficient *= effect.shotDispersionAngleFactor;
});
return gun.dispersion_base * degressive("gunner") * coefficient;
},
},
_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, 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) +
bonus
);
},
},
gun_elevation: {
type: CharacteristicType.Number,
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) +
bonus
);
},
},
gun_frontal_depression: {
type: CharacteristicType.Number,
should_render({ state, turret, gun }) {
return (
state.model.turrets[turret.id].guns[gun.id].pitch!.front !== undefined
);
},
compute({ state, turret, gun }) {
return state.model.turrets[turret.id].guns[gun.id].pitch!.front!.max;
},
},
gun_frontal_elevation: {
type: CharacteristicType.Number,
should_render({ state, turret, gun }) {
return (
state.model.turrets[turret.id].guns[gun.id].pitch!.front !== undefined
);
},
compute({ state, turret, gun }) {
return -state.model.turrets[turret.id].guns[gun.id].pitch!.front!.min;
},
},
gun_rear_depression: {
type: CharacteristicType.Number,
should_render({ state, turret, gun }) {
return (
state.model.turrets[turret.id].guns[gun.id].pitch!.back !== undefined
);
},
compute({ state, turret, gun }) {
return state.model.turrets[turret.id].guns[gun.id].pitch!.back!.max;
},
},
gun_rear_elevation: {
type: CharacteristicType.Number,
should_render({ state, turret, gun }) {
return (
state.model.turrets[turret.id].guns[gun.id].pitch!.back !== undefined
);
},
compute({ state, turret, gun }) {
return -state.model.turrets[turret.id].guns[gun.id].pitch!.back!.min;
},
},
azimuth_left: {
type: CharacteristicType.Number,
should_render({ state, turret }) {
return state.model.turrets[turret.id].yaw !== undefined;
},
compute({ state, turret }) {
return -state.model.turrets[turret.id].yaw!.min;
},
},
azimuth_right: {
type: CharacteristicType.Number,
should_render({ state, turret }) {
return state.model.turrets[turret.id].yaw !== undefined;
},
compute({ state, turret }) {
return state.model.turrets[turret.id].yaw!.max;
},
},
weight: {
type: CharacteristicType.Number,
compute({ tank, engine, track, turret, gun }) {
return (
(tank.weight +
engine.weight +
track.weight +
turret.weight +
gun.weight) /
1000
);
},
},
_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,
// 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({ characteristic }): number {
const enginePower = characteristic("engine_power") as number;
const weight = characteristic("weight") as number;
return enginePower / weight;
},
},
// brake_force: unavailable,
hard_terrain_coefficient_raw: {
type: CharacteristicType.Number,
compute({ track, degressive, script }) {
return rawTerrainCoefficient(
track.resistance_hard,
"firmGroundPassabilityIncrease",
degressive("driver"),
script,
);
},
},
hard_terrain_coefficient: {
type: CharacteristicType.Number,
compute({ characteristic }): number {
const raw = characteristic("hard_terrain_coefficient_raw") as number;
return 100 / raw;
},
},
medium_terrain_coefficient_raw: {
type: CharacteristicType.Number,
compute({ track, degressive, script }) {
return rawTerrainCoefficient(
track.resistance_medium,
"mediumGroundPassabilityIncrease",
degressive("driver"),
script,
);
},
},
medium_terrain_coefficient: {
type: CharacteristicType.Number,
compute({ characteristic }): number {
const raw = characteristic("medium_terrain_coefficient_raw") as number;
return 100 / raw;
},
},
soft_terrain_coefficient_raw: {
type: CharacteristicType.Number,
compute({ track, degressive, script }) {
return rawTerrainCoefficient(
track.resistance_soft,
"softGroundPassabilityIncrease",
degressive("driver"),
script,
);
},
},
soft_terrain_coefficient: {
type: CharacteristicType.Number,
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 {
return hullTraverseSpeed(
context.characteristic("hard_terrain_coefficient_raw") as number,
context,
);
},
},
hull_traverse_speed_medium: {
type: CharacteristicType.Number,
compute(context): number {
return hullTraverseSpeed(
context.characteristic("medium_terrain_coefficient_raw") as number,
context,
);
},
},
hull_traverse_speed_soft: {
type: CharacteristicType.Number,
compute(context): number {
return hullTraverseSpeed(
context.characteristic("soft_terrain_coefficient_raw") as number,
context,
);
},
},
_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, characteristic }): number {
const c = characteristic("_turret_traverse_coefficient") as number;
return turret.traverse_speed * c;
},
},
gun_traverse_speed: {
type: CharacteristicType.Number,
compute({ gun, characteristic }): number {
const c = characteristic("_turret_traverse_coefficient") as number;
return gun.rotation_speed * c;
},
},
health: {
type: CharacteristicType.Number,
compute({ tank, turret, environment, equalizer, script }) {
let c0 = 1;
script("protagonist", "SmallHPStock", (effect) => {
c0 += effect.hpStockPercent / 100;
});
script("protagonist", "HighQualityAssembly", (effect) => {
c0 += effect.maxHpBonusPercent / 100;
});
let c1 = 1;
if (environment.equalize) {
c1 *= equalizer.health;
}
let health = tank.health + turret.health;
health *= c0 * c1;
if (environment.equalize) {
health = 50 * Math.round(health / 50);
}
return health;
},
},
fire_chance: {
type: CharacteristicType.Number,
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,
crew_count: {
type: CharacteristicType.Number,
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(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 (
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 }) {
const size = normalizeBoundingBox(
unionBoundingBox(
state.model.bounding_box!,
state.model.turrets[turret.id].bounding_box!,
),
);
return size.z;
},
},
height: {
type: CharacteristicType.Number,
compute({ state, turret }) {
const size = normalizeBoundingBox(
unionBoundingBox(
state.model.bounding_box!,
state.model.turrets[turret.id].bounding_box!,
),
);
return size.x;
},
},
length: {
type: CharacteristicType.Number,
compute({ state, turret }) {
const size = normalizeBoundingBox(
unionBoundingBox(
state.model.bounding_box!,
state.model.turrets[turret.id].bounding_box!,
),
);
return size.y;
},
},
} satisfies Record<string, Characteristic>;