diff --git a/packages/i18n/strings/en.json b/packages/i18n/strings/en.json
index 502139b03..cc76295ef 100644
--- a/packages/i18n/strings/en.json
+++ b/packages/i18n/strings/en.json
@@ -861,6 +861,12 @@
},
"tools": {
+ "mixer": {
+ "name": "Mixer",
+ "button": "Mix and match",
+ "description": "Mix tank modules"
+ },
+
"guess": {
"name": "Guess the tank",
"button": "Guess away",
diff --git a/packages/website/src/components/MixerScene/components/Model.tsx b/packages/website/src/components/MixerScene/components/Model.tsx
new file mode 100644
index 000000000..b4825b06e
--- /dev/null
+++ b/packages/website/src/components/MixerScene/components/Model.tsx
@@ -0,0 +1,137 @@
+import { Vector3 } from 'three';
+import { awaitableModelDefinitions } from '../../../core/awaitables/modelDefinitions';
+import { jsxTree } from '../../../core/blitzkit/jsxTree';
+import { useModel } from '../../../hooks/useModel';
+import { MixerEphemeral } from '../../../stores/mixerEphemeral';
+
+const modelDefinitions = await awaitableModelDefinitions;
+
+export function Model() {
+ const hull = MixerEphemeral.use((state) => state.hull);
+ const turret = MixerEphemeral.use((state) => state.turret);
+ const gun = MixerEphemeral.use((state) => state.gun);
+
+ const hullGltf = useModel(hull.id);
+ const turretGltf = useModel(turret.tank.id);
+ const gunGltf = useModel(gun.tank.id);
+
+ const hullNodes = Object.values(hullGltf.gltf.nodes);
+ const turretNodes = Object.values(turretGltf.gltf.nodes);
+ const gunNodes = Object.values(gunGltf.gltf.nodes);
+
+ const tankModel = modelDefinitions.models[hull.id];
+ const trackModel = tankModel.tracks[hull.tracks.at(-1)!.id];
+
+ const turretTankModel = modelDefinitions.models[turret.tank.id];
+ const turretTrackModel =
+ turretTankModel.tracks[turret.tank.tracks.at(-1)!.id];
+ const turretModel = turretTankModel.turrets[turret.turret.id];
+
+ const gunTankModel = modelDefinitions.models[gun.tank.id];
+ const gunTrackModel = gunTankModel.tracks[gun.tank.tracks.at(-1)!.id];
+ const gunTurretModel = gunTankModel.turrets[gun.turret.id];
+ const gunModel = gunTurretModel.guns[gun.gun.id];
+
+ const turretDelta = new Vector3(
+ tankModel.turret_origin.x - turretTankModel.turret_origin.x,
+ tankModel.turret_origin.y - turretTankModel.turret_origin.y,
+ tankModel.turret_origin.z - turretTankModel.turret_origin.z,
+ );
+ const gunDelta = new Vector3(
+ turretModel.gun_origin.x - gunTurretModel.gun_origin.x,
+ turretModel.gun_origin.z - gunTurretModel.gun_origin.z,
+ turretModel.gun_origin.y - gunTurretModel.gun_origin.y,
+ );
+
+ return (
+
+ {hullNodes.map((node) => {
+ const isHull = node.name === 'hull';
+ const isWheel = node.name.startsWith('chassis_wheel_');
+ const isTrack = node.name.startsWith('chassis_track_');
+ const isVisible = isHull || isWheel || isTrack;
+
+ if (!isVisible) return null;
+
+ return jsxTree(node, {
+ mesh(_, props, key) {
+ return ;
+ },
+ });
+ })}
+
+
+ {turretNodes.map((node) => {
+ const isCurrentTurret =
+ node.name ===
+ `turret_${turretModel.model_id.toString().padStart(2, '0')}`;
+ const isVisible = isCurrentTurret;
+
+ if (!isVisible) return null;
+
+ return jsxTree(node, {
+ mesh(_, props, key) {
+ return ;
+ },
+ });
+ })}
+
+
+ {gunNodes.map((node) => {
+ const isCurrentMantlet =
+ node.name ===
+ `gun_${gunModel.model_id.toString().padStart(2, '0')}_mask`;
+ const isCurrentGun =
+ node.name ===
+ `gun_${gunModel.model_id.toString().padStart(2, '0')}`;
+ const isVisible = isCurrentGun || isCurrentMantlet;
+
+ if (!isVisible) return null;
+
+ return jsxTree(node, {
+ mesh(_, props, key) {
+ return ;
+ },
+ });
+ })}
+
+
+
+ );
+}
diff --git a/packages/website/src/components/MixerScene/index.tsx b/packages/website/src/components/MixerScene/index.tsx
new file mode 100644
index 000000000..7c37e7076
--- /dev/null
+++ b/packages/website/src/components/MixerScene/index.tsx
@@ -0,0 +1,25 @@
+import { Environment, OrbitControls } from '@react-three/drei';
+import { Canvas } from '@react-three/fiber';
+import { Suspense } from 'react';
+import { Model } from './components/Model';
+
+export function MixerScene() {
+ return (
+
+ );
+}
diff --git a/packages/website/src/constants/tools.ts b/packages/website/src/constants/tools.ts
index 4f9efe129..24a4daff7 100644
--- a/packages/website/src/constants/tools.ts
+++ b/packages/website/src/constants/tools.ts
@@ -127,11 +127,18 @@ export const guessTool: Tool = {
branches: ['main', 'dev', 'opentest', 'preview'],
};
+export const mixerTool: Tool = {
+ id: 'mixer',
+ button: { color: 'red' },
+ branches: ['main', 'dev', 'opentest', 'preview'],
+};
+
export const TOOLS: Tool[] = [
tankopediaTool,
compareTool,
performanceTool,
guessTool,
+ mixerTool,
// chartsTool,
galleryTool,
sessionTool,
diff --git a/packages/website/src/pages/[...locale]/mixer/_index.tsx b/packages/website/src/pages/[...locale]/mixer/_index.tsx
new file mode 100644
index 000000000..f2c0dbf1d
--- /dev/null
+++ b/packages/website/src/pages/[...locale]/mixer/_index.tsx
@@ -0,0 +1,64 @@
+import { Box } from '@radix-ui/themes';
+import { useRef } from 'react';
+import { MixerScene } from '../../../components/MixerScene';
+import { PageWrapper } from '../../../components/PageWrapper';
+import { awaitableTankDefinitions } from '../../../core/awaitables/tankDefinitions';
+import {
+ LocaleProvider,
+ type LocaleAcceptorProps,
+} from '../../../hooks/useLocale';
+import { MixerEphemeral } from '../../../stores/mixerEphemeral';
+
+const tankDefinitions = await awaitableTankDefinitions;
+const tanks = Object.values(tankDefinitions.tanks);
+
+export function Page({ locale }: LocaleAcceptorProps) {
+ const hull = useRef(tanks[Math.floor(Math.random() * tanks.length)]);
+ const turretTank = useRef(tanks[Math.floor(Math.random() * tanks.length)]);
+ const turret = useRef(
+ turretTank.current.turrets[
+ Math.floor(Math.random() * turretTank.current.turrets.length)
+ ],
+ );
+ const gunTank = useRef(tanks[Math.floor(Math.random() * tanks.length)]);
+ const gunTurret = useRef(
+ gunTank.current.turrets[
+ Math.floor(Math.random() * gunTank.current.turrets.length)
+ ],
+ );
+ const gun = useRef(
+ gunTurret.current.guns[
+ Math.floor(Math.random() * gunTurret.current.guns.length)
+ ],
+ );
+
+ return (
+
+
+
+
+
+ );
+}
+
+function Content() {
+ return (
+
+
+
+
+
+
+
+ );
+}
diff --git a/packages/website/src/pages/[...locale]/mixer/index.astro b/packages/website/src/pages/[...locale]/mixer/index.astro
new file mode 100644
index 000000000..ec26fd5f3
--- /dev/null
+++ b/packages/website/src/pages/[...locale]/mixer/index.astro
@@ -0,0 +1,15 @@
+---
+import { PageWrapper } from '../../../components/PageWrapper';
+import BaseLayout from '../../../layouts/BaseLayout.astro';
+import { Page } from './_index';
+
+export { getStaticPaths } from '../index.astro';
+
+const { locale } = Astro.params;
+---
+
+
+
+
+
+
diff --git a/packages/website/src/stores/mixerEphemeral.ts b/packages/website/src/stores/mixerEphemeral.ts
new file mode 100644
index 000000000..2b9801650
--- /dev/null
+++ b/packages/website/src/stores/mixerEphemeral.ts
@@ -0,0 +1,24 @@
+import type {
+ GunDefinition,
+ TankDefinition,
+ TurretDefinition,
+} from '@blitzkit/core';
+import { create } from 'zustand';
+import { createContextualStore } from '../core/zustand/createContextualStore';
+
+export interface MixerEphemeral {
+ hull: TankDefinition;
+ turret: {
+ tank: TankDefinition;
+ turret: TurretDefinition;
+ };
+ gun: {
+ tank: TankDefinition;
+ turret: TurretDefinition;
+ gun: GunDefinition;
+ };
+}
+
+export const MixerEphemeral = createContextualStore((data: MixerEphemeral) =>
+ create()(() => data),
+);