diff --git a/.agents/skills/proto-workflow/SKILL.md b/.agents/skills/proto-workflow/SKILL.md deleted file mode 100644 index d996edcfd..000000000 --- a/.agents/skills/proto-workflow/SKILL.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -name: proto-workflow -description: Regenerate this repository's TypeScript protobuf outputs after editing .proto schemas. ---- - -# Proto Workflow - -After editing any `.proto` schema in this repository, run `bun run build-protos` from the repository root. Review the generated changes and address any schema or generator errors before finishing the task. diff --git a/.agents/skills/repo-boundaries/SKILL.md b/.agents/skills/repo-boundaries/SKILL.md deleted file mode 100644 index ac28a38a0..000000000 --- a/.agents/skills/repo-boundaries/SKILL.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -name: repo-boundaries -description: Apply the user's persistent repository boundaries whenever working in their projects. ---- - -# Repo Boundaries - -Apply these boundaries to all work in the user's repositories: - -- Read-only Git commands are allowed when useful, including status, diff, log, and show. Do not run Git commands that modify repository state. -- Never edit `bun.lock`, including regenerating or formatting it. When dependency manifests change, leave the lockfile alone; the user will update it by running `bun install`. - -These are firm constraints, not preferences to revisit for a task. Continue useful work within them and explain any limitation if a requested result would require crossing either boundary. diff --git a/.agents/skills/repo-boundaries/agents/openai.yaml b/.agents/skills/repo-boundaries/agents/openai.yaml deleted file mode 100644 index 37b4d88ef..000000000 --- a/.agents/skills/repo-boundaries/agents/openai.yaml +++ /dev/null @@ -1,3 +0,0 @@ -interface: - display_name: "Repo Boundaries" - short_description: "Respect repository editing boundaries" diff --git a/.claude/skills/vfs/SKILL.md b/.claude/skills/vfs/SKILL.md new file mode 100644 index 000000000..4bec9dc48 --- /dev/null +++ b/.claude/skills/vfs/SKILL.md @@ -0,0 +1,44 @@ +--- +name: vfs +description: Read files from the World of Tanks Blitz game client (including DVPL-compressed ones) via scripts/vfs.sh. Use when you need to list, read, or parse game data like Data/*.yaml, version.txt, or any asset in the client install, or when you need to know where the client install and wotblitz.exe are. +--- + +# Game client VFS + +`scripts/vfs.sh` reads files out of the installed game client and transparently handles DVPL decompression. Use it instead of reading the Steam directory directly, because many client files are DVPL-compressed and raw reads give you garbage. + +Run it from the repo root (the wrapper `cd`s into `packages/scripts` itself): + +```bash +scripts/vfs.sh ls # list a directory, one entry per line +scripts/vfs.sh cat # raw file contents to stdout (decompressed) +scripts/vfs.sh yaml # parse a YAML file, print as JSON +``` + +The path argument is required. Paths are relative to the client root, e.g. `Data/version.txt`. Without a path it exits 1. + +Source of truth for commands: `packages/scripts/src/vfs/index.ts`. Check it if a command here seems missing. + +## Examples (verified) + +```bash +scripts/vfs.sh cat Data/version.txt # 11.21.0.1235_7376187 release/11.21.0 WOTB_Win7 (no trailing newline) +scripts/vfs.sh ls Data # *.yaml configs, 3d, Configs, Fonts, ... +scripts/vfs.sh yaml Data/countries.yaml # JSON: { "ru": ["AM", "AZ", ...], ... } +``` + +## Where the client is + +- The install dir comes from `CLIENT_DIR` in `packages/website/.env` (read via `packages/scripts/src/vfs/vfs.ts`). The location is machine-specific, so read that `.env` to find it rather than assuming a path. The `.env` may also contain commented-out alternative client locations. +- Executable: `wotblitz.exe` in that directory (~74 MB). `wotblitz-browser.exe` is the small embedded-browser helper, not the game. +- The `.env` holds an application ID and paths; don't paste its secrets elsewhere. + +## Reverse engineering the executable + +For binary analysis of `wotblitz.exe`, use the REA MCP tools (`mcp__rea__*`, load via ToolSearch). Nothing is open by default: call `open_binary` with the exe path first, and `close_binary` when done. The `rea` CLI is not on PATH, so `rea doctor` is unavailable; the MCP server is the interface. + +## Notes + +- Read-only: the VFS never modifies the client. +- Prefer `yaml` over `cat` for `.yaml` files when you want to query structure. +- Large outputs: pipe through `head` or `jq`. diff --git a/packages/core/src/blitzkit/api/server/1_tanks.ts b/packages/core/src/blitzkit/api/server/1_tanks.ts index f2c5975c6..0f5cd3de5 100644 --- a/packages/core/src/blitzkit/api/server/1_tanks.ts +++ b/packages/core/src/blitzkit/api/server/1_tanks.ts @@ -9,6 +9,7 @@ import { ShellDefinitionsList, ShellKind, sluggify, + TechTreeLayoutYaml, toUniqueId, TurretDefinitionsList, UnlocksListing, @@ -20,6 +21,7 @@ import { Crew, Equalizer, GunDefinition, + ModulePosition, ModuleType, ResearchCost, TankDefinitions, @@ -362,11 +364,77 @@ export abstract class ServerBlitzKitAPI1 extends ServerBlitzKitAPI0 { const name_full = name === canonicalFullName ? undefined : canonicalFullName; + // The client lays modules out automatically unless the tank has its own + // file, in which case every cell is placed by hand + const module_positions: ModulePosition[] = []; + const layoutPath = `Data/Configs/TechTree/${nation}/${tankKey}.yaml`; + const resolvedLayoutPath = await this.vfs.resolve(layoutPath); + + if (resolvedLayoutPath !== null) { + const layout = await this.vfs.yaml(layoutPath); + + if (layout.modules) { + for (const [moduleKey, { position }] of Object.entries( + layout.modules, + )) { + // keys look like `#vehicleGun:_85mm_S-53` + const [kind, name] = moduleKey.replace(/^#/, "").split(":"); + let rawId: number | undefined; + let type: ModuleType; + + switch (kind) { + case "vehicle": + rawId = tankList.root[name]?.id; + type = ModuleType.MODULE_TYPE_VEHICLE; + break; + + case "vehicleChassis": + rawId = chassisList.root.ids[name]; + type = ModuleType.MODULE_TYPE_TRACKS; + break; + + case "vehicleEngine": + rawId = enginesList.root.ids[name]; + type = ModuleType.MODULE_TYPE_ENGINE; + break; + + case "vehicleGun": + rawId = gunList.root.ids[name]; + type = ModuleType.MODULE_TYPE_GUN; + break; + + case "vehicleTurret": + rawId = turretList.root.ids[name]; + type = ModuleType.MODULE_TYPE_TURRET; + break; + + default: + throw new Error( + `Unknown module kind in ${layoutPath}: ${kind}`, + ); + } + + if (rawId === undefined) { + throw new Error( + `Unknown module in ${layoutPath}: ${moduleKey}`, + ); + } + + module_positions.push({ + id: toUniqueId(nation, rawId), + type, + position: { x: position[0], y: position[1] }, + }); + } + } + } + tankDefinitions.tanks[tankId] = { ancestors: [], successors: [], id: tankId, dev_name: tankKey, + module_positions, roles: {}, camouflages: camouflages, fixed_camouflage: fixedCamouflage, @@ -386,8 +454,8 @@ export abstract class ServerBlitzKitAPI1 extends ServerBlitzKitAPI0 { type: tankTags.includes("collectible") ? TankType.TANK_TYPE_COLLECTOR : (typeof tank.price === "number" ? false : "gold" in tank.price) - ? TankType.TANK_TYPE_PREMIUM - : TankType.TANK_TYPE_RESEARCHABLE, + ? TankType.TANK_TYPE_PREMIUM + : TankType.TANK_TYPE_RESEARCHABLE, tier: tank.level, class: tankClass, testing: tankTags.includes("testTank") || tank.level === 11, @@ -575,24 +643,24 @@ export abstract class ServerBlitzKitAPI1 extends ServerBlitzKitAPI0 { }, } : gunType === "autoReloader" - ? { - $case: "auto_reloader", - value: { - intra_clip: 60 / gun.clip!.rate, - shell_count: gunClipCount, - shell_reloads: gun - .pumpGunReloadTimes!.split(" ") - .map(Number), - }, - } - : { - $case: "auto_loader", - value: { - intra_clip: 60 / gun.clip!.rate, - clip_reload: gun.reloadTime, - shell_count: gunClipCount, - }, + ? { + $case: "auto_reloader", + value: { + intra_clip: 60 / gun.clip!.rate, + shell_count: gunClipCount, + shell_reloads: gun + .pumpGunReloadTimes!.split(" ") + .map(Number), }, + } + : { + $case: "auto_loader", + value: { + intra_clip: 60 / gun.clip!.rate, + clip_reload: gun.reloadTime, + shell_count: gunClipCount, + }, + }, } satisfies GunDefinition); for (const shellKey in gunListEntry.shots) { @@ -635,7 +703,7 @@ export abstract class ServerBlitzKitAPI1 extends ServerBlitzKitAPI0 { type: blitzShellKindToBlitzkit[shell.kind], explosion_radius: shell.kind === "HIGH_EXPLOSIVE" - ? (shell.explosionRadius ?? 0) + ? shell.explosionRadius ?? 0 : undefined, icon: shell.icon, penetration: { diff --git a/packages/core/src/blitzkit/api/server/3_models.ts b/packages/core/src/blitzkit/api/server/3_models.ts index 3693770a8..db1ab1a84 100644 --- a/packages/core/src/blitzkit/api/server/3_models.ts +++ b/packages/core/src/blitzkit/api/server/3_models.ts @@ -16,14 +16,14 @@ import { VehicleDefinitions, VertexAttribute, } from "@blitzkit/core"; -import { Armor, Crew, ModelDefinitions, Vector3 } from "@blitzkit/protos"; +import { Armor, Crew, ModelDefinitions, ProtoVector3 } from "@blitzkit/protos"; import { MathUtils, Vector3 as THREEVector3, Vector3Tuple } from "three"; import { Cache } from "./0_base"; import { ServerBlitzKitAPI2 } from "./2_camouflages"; export abstract class ServerBlitzKitAPI3 extends ServerBlitzKitAPI2 { private vector3TupleToBlitzkit(tuple: Vector3Tuple) { - return { x: tuple[0], y: tuple[1], z: -tuple[2] } satisfies Vector3; + return { x: tuple[0], y: tuple[1], z: -tuple[2] } satisfies ProtoVector3; } private async collisionBoundingBoxes( diff --git a/packages/core/src/blitzkit/normalizeBoundingBox.ts b/packages/core/src/blitzkit/normalizeBoundingBox.ts index b1b0f99b9..26ae27ae7 100644 --- a/packages/core/src/blitzkit/normalizeBoundingBox.ts +++ b/packages/core/src/blitzkit/normalizeBoundingBox.ts @@ -1,9 +1,9 @@ -import { BoundingBox, Vector3 } from "../../../protos/src/blitzkit"; +import { BoundingBox, ProtoVector3 } from "../../../protos/src/blitzkit"; export function normalizeBoundingBox(boundingBox: BoundingBox) { return { x: boundingBox.max!.x - boundingBox.min!.x, y: boundingBox.max!.y - boundingBox.min!.y, z: boundingBox.max!.z - boundingBox.min!.z, - } satisfies Vector3; + } satisfies ProtoVector3; } diff --git a/packages/core/src/blitzkit/vfs/abstract.ts b/packages/core/src/blitzkit/vfs/abstract.ts index e342e82a8..d0b06fe5f 100644 --- a/packages/core/src/blitzkit/vfs/abstract.ts +++ b/packages/core/src/blitzkit/vfs/abstract.ts @@ -14,7 +14,7 @@ export abstract class AbstractVFS { const path = await import("node:path/posix"); this.normalizePath = path.normalize; - await this._init(); + return await this._init(); } protected abstract _init(): Promise; diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index 2d3726d47..5c2f1845f 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -21,6 +21,7 @@ export * from "./squadBattleTypeStylesYaml"; export * from "./tankmenAvatar"; export * from "./tankParameters"; export * from "./tanksStats"; +export * from "./techTreeLayoutYaml"; export * from "./turretDefinitionsList"; export * from "./vehicleDefinitionArmor"; export * from "./vehicleDefinitionList"; diff --git a/packages/core/src/types/shellDefinitionsList.ts b/packages/core/src/types/shellDefinitionsList.ts index e8ec812c1..b37302e70 100644 --- a/packages/core/src/types/shellDefinitionsList.ts +++ b/packages/core/src/types/shellDefinitionsList.ts @@ -11,7 +11,7 @@ export type ShellDefinitionsList = Record< ricochetAngle?: number; explosionRadius?: number; speed: number; - maxDistance?: number; + maxDistance: number; piercingPower: string; } > & { diff --git a/packages/core/src/types/techTreeLayoutYaml.ts b/packages/core/src/types/techTreeLayoutYaml.ts new file mode 100644 index 000000000..8a257f660 --- /dev/null +++ b/packages/core/src/types/techTreeLayoutYaml.ts @@ -0,0 +1,7 @@ +export interface TechTreeLayoutYamlModule { + position: [number, number]; +} + +export interface TechTreeLayoutYaml { + modules?: Record; +} diff --git a/packages/i18n/strings/en.json b/packages/i18n/strings/en.json index 75b4b8ac1..27f475170 100644 --- a/packages/i18n/strings/en.json +++ b/packages/i18n/strings/en.json @@ -35,6 +35,7 @@ "bhp_t": "{value}bhp/t", "kN": "{value}kN", "hp_s": "{value}hp/s", + "m3": "{value}m³", "plus": "+{value}", "fraction": "{numerator} / {denominator}" }, @@ -112,6 +113,19 @@ "reload": { "skip": "Skip" + }, + + "flexibility": { + "pitch": "Pitch", + "yaw": "Yaw", + "max": "Max", + "min": "Min" + }, + + "view_range": { + "camo": "Camo", + "ghost_factory": "Ghost Factory", + "dead_rail": "Dead Rail" } }, @@ -137,6 +151,8 @@ "items": { "gun_type": "Gun type", "dpm": "Damage per minute", + "dpm_effective": "Effective by 60s", + "dpm_peekaboo": "Peek-a-boo", "damage": "Damage", "module_damage": "Module damage", "reload": "Reload", @@ -156,9 +172,16 @@ "caliber": "Caliber", "normalization": "Normalization", "ricochet": "Ricochet", + "explosion_radius": "Explosion radius", "aim_time": "Aim time", "dispersion": "Dispersion", "dispersion_angle": "Dispersion angle", + "dispersion_penalties": "Dispersion penalties", + "dispersion_moving": "Moving", + "dispersion_hull_traversing": "Hull traversing", + "dispersion_turret_traversing": "Turret traversing", + "dispersion_shooting": "After shooting", + "dispersion_gun_damaged": "Gun damaged", "gun_depression": "Gun depression", "gun_elevation": "Gun elevation", "gun_frontal_depression": "Frontal depression", @@ -174,6 +197,10 @@ "brake_force": "Brake force", "weight": "Weight", "power_to_weight": "Power to weight", + "power_to_weight_on_terrain": "Power to weight on terrain", + "power_to_weight_hard": "Hard", + "power_to_weight_medium": "Medium", + "power_to_weight_soft": "Soft", "terrain_coefficient": "Terrain coefficients", "raw_terrain_coefficient": "Raw terrain coefficients", "hard_terrain_coefficient": "Hard", @@ -193,8 +220,14 @@ "fire_chance": "Fire chance", "fire_rate": "Fire rate", "ramming_resistance": "Ramming resistance", + "crew_count": "Crew count", "view_range": "View range", - "camouflage": "Camouflage", + "camouflages": "Camouflage", + "camouflage": "Still", + "camouflage_moving": "Moving", + "camouflage_shooting_still": "Shooting still", + "camouflage_shooting_moving": "Shooting moving", + "camouflage_on_fire": "On fire", "width": "Width", "height": "Height", "length": "Length" @@ -205,6 +238,10 @@ "title": "Game mode abilities" }, + "videos": { + "title": "Videos" + }, + "navigation": { "tank": "Tank", "characteristics": "Characteristics" diff --git a/packages/protos/src/blitzkit/math.proto b/packages/protos/src/blitzkit/math.proto new file mode 100644 index 000000000..b0e1a621f --- /dev/null +++ b/packages/protos/src/blitzkit/math.proto @@ -0,0 +1,14 @@ +syntax = "proto3"; + +package blitzkit; + +message ProtoVector2 { + float x = 1; + float y = 2; +} + +message ProtoVector3 { + float x = 1; + float y = 2; + float z = 3; +} diff --git a/packages/protos/src/blitzkit/model_definitions.proto b/packages/protos/src/blitzkit/model_definitions.proto index a36660672..e745e2034 100644 --- a/packages/protos/src/blitzkit/model_definitions.proto +++ b/packages/protos/src/blitzkit/model_definitions.proto @@ -2,13 +2,15 @@ syntax = "proto3"; package blitzkit; +import "math.proto"; + message ModelDefinitions { map models = 1; } message ModelDefinition { Armor armor = 1; - Vector3 turret_origin = 2; + ProtoVector3 turret_origin = 2; optional InitialTurretRotation initial_turret_rotation = 3; map turrets = 4; map tracks = 5; @@ -20,12 +22,6 @@ message Armor { repeated uint32 spaced = 2; } -message Vector3 { - float x = 1; - float y = 2; - float z = 3; -} - message InitialTurretRotation { float yaw = 1; float pitch = 2; @@ -36,7 +32,7 @@ message TurretModelDefinition { BoundingBox bounding_box = 1; Armor armor = 2; uint32 model_id = 3; - Vector3 gun_origin = 4; + ProtoVector3 gun_origin = 4; map guns = 5; optional YawLimits yaw = 6; } @@ -47,13 +43,13 @@ message YawLimits { } message BoundingBox { - Vector3 min = 1; - Vector3 max = 2; + ProtoVector3 min = 1; + ProtoVector3 max = 2; } message TrackModelDefinition { - float thickness = 1; - Vector3 origin = 2; + float thickness = 1; + ProtoVector3 origin = 2; } message GunModelDefinition { diff --git a/packages/protos/src/blitzkit/tank_definitions.proto b/packages/protos/src/blitzkit/tank_definitions.proto index ef563ee4d..aec8124a5 100644 --- a/packages/protos/src/blitzkit/tank_definitions.proto +++ b/packages/protos/src/blitzkit/tank_definitions.proto @@ -3,6 +3,7 @@ syntax = "proto3"; package blitzkit; import "i18n.proto"; +import "math.proto"; message TankDefinitions { map tanks = 1; @@ -42,6 +43,15 @@ message TankDefinition { string dev_name = 32; optional Equalizer equalizer = 33; optional I18nString name_full = 34; + repeated ModulePosition module_positions = 35; +} + +// Hand-authored cell in the modules screen grid. Tanks without any use the +// automatic layout. +message ModulePosition { + int32 id = 1; + ModuleType type = 2; + ProtoVector2 position = 3; } message Equalizer { @@ -87,6 +97,7 @@ message TrackDefinition { float resistance_medium = 10; float resistance_soft = 11; repeated Unlock unlocks = 12; + float repair_time = 13; } message EngineDefinition { diff --git a/packages/scripts/src/vfs/vfs.ts b/packages/scripts/src/vfs/vfs.ts index a94b39bae..2461b5dc5 100644 --- a/packages/scripts/src/vfs/vfs.ts +++ b/packages/scripts/src/vfs/vfs.ts @@ -1,4 +1,6 @@ import { assertSecret } from "@blitzkit/core"; import { LocalVFS } from "@blitzkit/core/src/blitzkit/vfs/local"; -export const vfs = new LocalVFS(assertSecret(import.meta.env.CLIENT_DIR)); +export const vfs = await new LocalVFS( + assertSecret(import.meta.env.CLIENT_DIR), +).init(); diff --git a/packages/website/src/components/Discriminator/index.module.css b/packages/website/src/components/Discriminator/index.module.css index 7d76b905f..7578ab6ed 100644 --- a/packages/website/src/components/Discriminator/index.module.css +++ b/packages/website/src/components/Discriminator/index.module.css @@ -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); + } } diff --git a/packages/website/src/components/Discriminator/index.tsx b/packages/website/src/components/Discriminator/index.tsx index 53485bd67..8238e8570 100644 --- a/packages/website/src/components/Discriminator/index.tsx +++ b/packages/website/src/components/Discriminator/index.tsx @@ -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 ( - + ); } diff --git a/packages/website/src/components/TankopediaAntagonistControl/index.tsx b/packages/website/src/components/TankopediaAntagonistControl/index.tsx index 8323a8273..6e3c3e1cf 100644 --- a/packages/website/src/components/TankopediaAntagonistControl/index.tsx +++ b/packages/website/src/components/TankopediaAntagonistControl/index.tsx @@ -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; } }); }} diff --git a/packages/website/src/components/TankopediaCharacteristics/index.module.css b/packages/website/src/components/TankopediaCharacteristics/index.module.css index d4156b92c..8640a4c14 100644 --- a/packages/website/src/components/TankopediaCharacteristics/index.module.css +++ b/packages/website/src/components/TankopediaCharacteristics/index.module.css @@ -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); diff --git a/packages/website/src/components/TankopediaFlexibilityCanvas.tsx b/packages/website/src/components/TankopediaFlexibilityCanvas.tsx new file mode 100644 index 000000000..717198138 --- /dev/null +++ b/packages/website/src/components/TankopediaFlexibilityCanvas.tsx @@ -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 = { + 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 = { + 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 ( + + + + + + + ); +} + +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(null); + const gunWrapper = useRef(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) { + 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 ( + <> + + + + + + + + + + + + + + + + + ); +} + +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 ( + + {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 ( + <> + + + + + + ); + }, + }); + })} + + ); +} diff --git a/packages/website/src/components/TankopediaLoadout/index.module.css b/packages/website/src/components/TankopediaLoadout/index.module.css index a4ff906db..9d23a0ce2 100644 --- a/packages/website/src/components/TankopediaLoadout/index.module.css +++ b/packages/website/src/components/TankopediaLoadout/index.module.css @@ -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; diff --git a/packages/website/src/components/TankopediaLoadout/index.tsx b/packages/website/src/components/TankopediaLoadout/index.tsx index f62e69f80..eaa1a63f0 100644 --- a/packages/website/src/components/TankopediaLoadout/index.tsx +++ b/packages/website/src/components/TankopediaLoadout/index.tsx @@ -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 && } */} + - ); } +const moduleIconNames: Record = { + [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 ( +
+
+ {strings.tanks.loadout.modules} + + {hasUpgrades && ( +
+ { + 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; + }); + }} + > + + + + { + 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; + }); + }} + > + + +
+ )} +
+ + +
+ ); +} + +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.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 ( +
+ {cells.map((cell) => { + const module = cell.unlock && resolve(cell.unlock); + + return ( +
+ {cell.trunkUp && } + {cell.trunkDown && } + {cell.snakeUp && } + {cell.snakeDown && } + {cell.right && } + {cell.left && } + + {cell.unlock && module && ( + + )} +
+ ); + })} +
+ ); +} + +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 = ( + { + 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; + } + }); + }} + > + {romanize(module.tier)} + + {/* {unlock.cost!.value > 0 && ( + + {formatCompact(locale, unlock.cost!.value)} + {unlock.cost!.type} + + )} */} + + {tooltip} + + ); + + return ( + + {isTank ? ( + + {button} + + ) : ( + button + )} + + ); +} + 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 ( -
-
- {strings.tanks.loadout.dynamics} - - { - Tankopedia.mutate((draft) => { - draft.protagonist.consumables = []; - }); - }} - > - - -
- -
- shell: {shell} - speed: {speed} -
-
- ); -} - function Status() { const strings = useStrings(); const status = Tankopedia.use((state) => state.protagonist.status); @@ -623,293 +926,3 @@ function EquipmentOption({ ); } - -// 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 ( -//
-//
-// {strings.tanks.loadout.modules} - -//
-// { -// 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" -// > -// -// - -// { -// 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" -// > -// -// -//
-//
- -//
-// {/* TODO: hide lines with 1 module except for alts */} -// {tank.tank!.upgrade_lines.map((line) => ( -// -// ))} -//
-//
-// ); -// } - -// 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 ( -//
-// {!isAlternativeLine(name) && -// combinedLines.map((name) => ( -//
-// -//
-// ))} -//
-// ); -// } - -// function LineInner({ name, lines }: LineProps) { -// const line = lines.find((line) => line.name === name)!; - -// return chunk(line.stages, maxModulesPerRow).map((chunk, wrapIndex) => ( -//
-// {wrapIndex > 0 && ( -// -// -// -// )} - -// {chunk.map((stage, elementIndex) => ( -// -// ))} -//
-// )); -// } - -// 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 ( -//
-// -// -// - -// {} -//
-// ); -// } diff --git a/packages/website/src/components/TankopediaToys/index.module.css b/packages/website/src/components/TankopediaToys/index.module.css index 3dee2edf1..afe5500dd 100644 --- a/packages/website/src/components/TankopediaToys/index.module.css +++ b/packages/website/src/components/TankopediaToys/index.module.css @@ -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%); + } + } +} diff --git a/packages/website/src/components/TankopediaToys/index.tsx b/packages/website/src/components/TankopediaToys/index.tsx index efdc22e92..47e24faec 100644 --- a/packages/website/src/components/TankopediaToys/index.tsx +++ b/packages/website/src/components/TankopediaToys/index.tsx @@ -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)`, }} >
@@ -566,3 +578,333 @@ export function ReloadToy({ characteristics }: ToyProps) {
); } + +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(viewRangeMaps[0].id); + const [camouflage, setCamouflage] = useState(0); + const container = useRef(null); + const range = useRef(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) { + 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 ( +
+
+ +
+ + {literals(strings.common.units.m, { + value: resolvedViewRange.toFixed(0), + })} +
+ +
+ {strings.tanks.toys.view_range.camo} + + setCamouflage(value)} + > + + + + + + + + {literals(strings.common.units.percentage, { + value: Math.round(camouflage * 100), + })} + +
+ +
+ ({ + value: id, + label: strings.tanks.toys.view_range[string], + }))} + value={mapId} + onValueChange={setMapId} + variant="underline" + /> +
+
+ ); +} + +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(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) { + 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 ( +
+
+ {modelRequested && } + +
+
+ +
+
+ +
+ + + + + +
+
+ +
+ + + + + +
+
+ ); +} diff --git a/packages/website/src/components/TankopediaVideos/index.module.css b/packages/website/src/components/TankopediaVideos/index.module.css new file mode 100644 index 000000000..9c24e66c1 --- /dev/null +++ b/packages/website/src/components/TankopediaVideos/index.module.css @@ -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; + } + } + } +} diff --git a/packages/website/src/components/TankopediaVideos/index.tsx b/packages/website/src/components/TankopediaVideos/index.tsx new file mode 100644 index 000000000..5289bd44d --- /dev/null +++ b/packages/website/src/components/TankopediaVideos/index.tsx @@ -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 ( +
+ {strings.tanks.videos.title} + +
+ {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 ( + + +
+ ); +} diff --git a/packages/website/src/config/characteristics.ts b/packages/website/src/config/characteristics.ts index 27e08c4db..f7d18ec65 100644 --- a/packages/website/src/config/characteristics.ts +++ b/packages/website/src/config/characteristics.ts @@ -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((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 }) { diff --git a/packages/website/src/config/characteristicsGroups.ts b/packages/website/src/config/characteristicsGroups.ts index 1d4602efb..884a0cc70 100644 --- a/packages/website/src/config/characteristicsGroups.ts +++ b/packages/website/src/config/characteristicsGroups.ts @@ -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" }, diff --git a/packages/website/src/config/toys.ts b/packages/website/src/config/toys.ts index 4eff69120..bf54d4cdf 100644 --- a/packages/website/src/config/toys.ts +++ b/packages/website/src/config/toys.ts @@ -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 ReactNode> = { ricochet: RicochetToy, traverse: TraverseToy, reload: ReloadToy, + view_range: ViewRangeToy, + flexibility: FlexibilityToy, }; diff --git a/packages/website/src/pages/[...chunk_default]/[...locale]/tanks/[slug]/_index.tsx b/packages/website/src/pages/[...chunk_default]/[...locale]/tanks/[slug]/_index.tsx index 2c002add0..a10320ac0 100644 --- a/packages/website/src/pages/[...chunk_default]/[...locale]/tanks/[slug]/_index.tsx +++ b/packages/website/src/pages/[...chunk_default]/[...locale]/tanks/[slug]/_index.tsx @@ -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( /> +
); diff --git a/packages/website/src/tankopedia/calculateRankings.ts b/packages/website/src/tankopedia/calculateRankings.ts index bf26b823b..57587d001 100644 --- a/packages/website/src/tankopedia/calculateRankings.ts +++ b/packages/website/src/tankopedia/calculateRankings.ts @@ -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; diff --git a/packages/website/src/tankopedia/hullTraverseSpeed.ts b/packages/website/src/tankopedia/hullTraverseSpeed.ts index c3445fd93..e83e59756 100644 --- a/packages/website/src/tankopedia/hullTraverseSpeed.ts +++ b/packages/website/src/tankopedia/hullTraverseSpeed.ts @@ -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"), diff --git a/packages/website/src/tankopedia/moduleTreeLayout.ts b/packages/website/src/tankopedia/moduleTreeLayout.ts new file mode 100644 index 000000000..7ab1ab8c0 --- /dev/null +++ b/packages/website/src/tankopedia/moduleTreeLayout.ts @@ -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>; + +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( + 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(); + + // 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(); + // Keyed by both ends so shared subtrees don't repeat their connectors + const edges = new Map(); + 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 }; +} diff --git a/packages/website/src/three/toThreeVector.ts b/packages/website/src/three/toThreeVector.ts index 1fba6df1a..e0fb241af 100644 --- a/packages/website/src/three/toThreeVector.ts +++ b/packages/website/src/three/toThreeVector.ts @@ -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); } diff --git a/scripts/vfs.sh b/scripts/vfs.sh index 3ac48cf99..e075d2df6 100755 --- a/scripts/vfs.sh +++ b/scripts/vfs.sh @@ -1,2 +1,2 @@ cd packages/scripts -bun src/vfs/index.ts \ No newline at end of file +bun src/vfs/index.ts "$@" \ No newline at end of file