Compare commits

...
28 Commits
Author SHA1 Message Date
tresabhi 5d2c63fed9 clean up some renderer stuff 2026-06-26 14:05:26 -05:00
tresabhi cead140855 optimize intro naimaiton 2026-06-26 14:01:39 -05:00
tresabhi 63a0143d4f optimize scene lighting and rnederer 2026-06-26 13:58:30 -05:00
tresabhi 19f22993f1 optimize shot display animator 2026-06-26 13:31:40 -05:00
tresabhi b2eb74914f optimize lighting loop 2026-06-26 13:22:24 -05:00
TrèsAbhi 05572918b1 exit 2026-06-23 10:35:27 -05:00
TrèsAbhi 1cc8b26b16 dispose 2026-06-23 10:30:56 -05:00
TrèsAbhi a8e8d20cf1 break 2026-06-23 10:26:05 -05:00
TrèsAbhi 55deb30607 logs 2026-06-23 10:20:22 -05:00
TrèsAbhi 03b8b89a17 log 2026-06-23 10:14:37 -05:00
TrèsAbhi 3e421c7eb7 done 2026-06-23 10:11:18 -05:00
TrèsAbhi fc27caabdc dvpl 2026-06-23 10:07:54 -05:00
tresabhi 3d35fae5ce dont log 2026-06-22 20:52:51 -05:00
tresabhi f6dfc4f176 what 2026-06-22 20:32:14 -05:00
tresabhi fdbbe2f356 Update applyPatches.ts 2026-06-22 20:18:25 -05:00
tresabhi cdf2aee43f debug 2026-06-22 20:13:52 -05:00
tresabhi 56933ddfac debug 2026-06-22 20:07:55 -05:00
tresabhi 1df2e818be dirs 2026-06-22 19:57:41 -05:00
tresabhi 1e4d00ea34 source 2026-06-22 19:55:39 -05:00
tresabhi 1bde50e0a3 garbage code 2026-06-22 19:54:09 -05:00
tresabhi 88290ec1ab garbage code 2026-06-22 19:54:01 -05:00
tresabhi e8325df832 lz4js 2026-06-22 19:44:03 -05:00
tresabhi 394f28e6ed remove proxyclient 2026-06-22 19:33:52 -05:00
tresabhi f5f170bcca switch to clean dava 2026-06-22 19:32:26 -05:00
tresabhi 2ffe3fc006 set up patches 2026-06-22 19:28:02 -05:00
TrèsAbhi 406d70b5b7 New translations en.json (Ukrainian) (#250)
[ci skip]
2026-06-22 09:38:55 -05:00
TrèsAbhi f801e81c8b New Crowdin updates (#249)
* New translations en.json (Russian)

[ci skip]

* New translations en.json (Russian)

[ci skip]
2026-06-17 09:26:26 -05:00
TrèsAbhi a0e1d8cee0 New Crowdin updates (#247)
* New translations en.json (Russian)

[ci skip]

* New translations en.json (Russian)

[ci skip]

* New translations en.json (Russian)

[ci skip]

* New translations en.json (Russian)

[ci skip]
2026-06-14 09:49:53 -05:00
21 changed files with 345 additions and 217 deletions

No files matched your search

+4
View File
@@ -73,10 +73,14 @@ jobs:
with:
repo-token: ${{ secrets.GH_TOKEN }}
- uses: oven-sh/setup-bun@v2
- run: bun run setup
- name: AppArmor hack
run: echo 0 | sudo tee /proc/sys/kernel/apparmor_restrict_unprivileged_userns
- name: Install patches
run: bun run setup:patches --source=${{ github.event.inputs.asset_source || 'steam:444200/444202' }}
- name: Build assets
run: bun run build:assets --all-targets --source=${{ github.event.inputs.asset_source || 'steam:444200/444202' }}
+1 -2
View File
@@ -6,6 +6,7 @@
"name": "blitzkit",
"dependencies": {
"@prisma/client": "^6.18.0",
"lz4js": "^0.2.0",
"prisma": "^6.18.0",
"sharp": "^0.34.5",
"typescript": "^5.9.3",
@@ -78,7 +79,6 @@
"dotenv": "^17.2.2",
"fast-xml-parser": "^5.3.2",
"lodash-es": "^4.17.21",
"lz4js": "^0.2.0",
"pickleparser": "^0.2.1",
"protobufjs": "^7.5.4",
"three": "^0.180.0",
@@ -118,7 +118,6 @@
"fast-xml-parser": "^5.2.5",
"googleapis": "^162.0.0",
"lodash-es": "^4.17.21",
"lz4js": "^0.2.0",
"progress": "^2.0.3",
"puppeteer": "^24.22.3",
"sharp": "^0.34.4",
+5
View File
@@ -0,0 +1,5 @@
# BlitzKit 1.30.3
## Fixes
- BlitzKit now correctly implements minor WoTB updates
+4 -3
View File
@@ -1,6 +1,6 @@
{
"name": "blitzkit",
"version": "1.30.3",
"version": "1.30.4",
"license": "BlitzKit License",
"private": true,
"scripts": {
@@ -21,7 +21,7 @@
"dev": "bun run dev:website",
"dev:api": "bun --filter=@blitzkit/api run dev",
"setup:depotdownloader": "bun --filter=@blitzkit/scripts run setup:depotdownloader",
"setup:patches": "bun --filter=@blitzkit/scripts run setup:patches",
"setup:patches": "mkdir -p packages/closed/node_modules && ln -sf ../../../node_modules/lz4js packages/closed/node_modules/lz4js && cd packages/scripts && bun run setup:patches",
"start:website": "bun --filter=@blitzkit/website run start",
"host:docs": "vitepress preview docs",
"lint": "tsc",
@@ -35,7 +35,7 @@
"build:reviews": "cd packages/scripts && bun build:reviews",
"build:ids": "cd packages/scripts && bun build:ids",
"prunestrings": "cd packages/scripts && bun prunestrings",
"setup": "bun install && bun run build:prisma && bun run build:proxyclient",
"setup": "bun install && bun run build:prisma",
"patchnotes": "cd packages/scripts && bun run patchnotes"
},
"type": "module",
@@ -44,6 +44,7 @@
],
"dependencies": {
"@prisma/client": "^6.18.0",
"lz4js": "^0.2.0",
"prisma": "^6.18.0",
"sharp": "^0.34.5",
"typescript": "^5.9.3"
-1
View File
@@ -19,7 +19,6 @@
"dotenv": "^17.2.2",
"fast-xml-parser": "^5.3.2",
"lodash-es": "^4.17.21",
"lz4js": "^0.2.0",
"pickleparser": "^0.2.1",
"protobufjs": "^7.5.4",
"three": "^0.180.0",
+19 -19
View File
@@ -139,7 +139,7 @@
}
},
"leagues": {
"diamond": "Диамантовая",
"diamond": "Алмазная",
"platinum": "Платиновая",
"gold": "Золотая",
"silver": "Серебряная",
@@ -196,7 +196,7 @@
"get_help": "Получить помощь",
"uncaught_error": "# BlitzKit столкнулся с ошибкой!\nНам очень жаль. Не стесняйтесь и [присоединяйтесь к официальному серверу Discord](https://discord.gg/nDt7AjGJQH), чтобы получить помощь.",
"preview": "# Эта команда находится в тестируется\n\nВы можете получить доступ к этой команде только на [официальном сервере Discord] (https://discord.gg/nDt7AjGJQH). Команда скоро станет общедоступной.",
"query_too_small": "# Ваш поисковой запрос слишком короткий\nДлина вашего запроса должна составлять не менее 3 символов.",
"query_too_small": "# Ваш поисковой слишком короткий\nДлина вашего запроса не менее 3 символов.",
"query_too_long": "# Ваш поисковой запрос слишком длинный\nДлина вашего запроса должна составлять не более 100 символов."
},
"wn8": {
@@ -320,13 +320,13 @@
}
},
"commands": {
"dead": ".",
"dead": "# Команды статистики мертвы\n\nБот BlitzKit использует BlitzStars для получения периодической статистики. BlitzStars включил агрессивную защиту Cloudflare; следовательно, отключив BlitzKit от использования API BlitzStar. Это повлияло на следующие команды:\n\n- `/stats`\n- `/today`\n- `/breakdown`\n- `/evolution`\n- `/full-stats`\n\nПожалуйста, используйте [Aftermath](<https://amth.one/invite>) в качестве альтернативы, которая совершенно не зависит от BlitzStars. Команда статистики BlitzKit-hosted вернется в ближайшее время.",
"replay": {
"name": "повтор",
"description": "Показать результаты повтора",
"options": {
"file": {
"name": "Файл",
"name": "файл",
"description": "Запись повтора"
}
}
@@ -351,7 +351,7 @@
"upgrades": "Улучшения: {cost}",
"purchase": "Покупка: {cost}",
"equipment": "Снаряжение: {cost}",
"estimation": "# Это приблизительная оценка!\n\nВ этот список могут входить уже исследованные танки из-за неточной информации, предоставляемой Wargaming. Попробуйте указать «стартовый-танк» в строке при использовании команды, если сможете."
"estimation": "# Это приблизительная оценка!\n\nВ этот список могут входить уже исследованные танки из-за неточной информации, предоставляемой Wargaming. Попробуйте указать «стартовый танк» в строке при использовании команды, если сможете."
},
"errors": {
"non_tech_tree": "# {tank} отсутствует в дереве танков\n\nВы не можете исследовать премиумный / коллекционный танк.",
@@ -363,7 +363,7 @@
}
},
"about": {
"name": "о-боте",
"name": "about",
"description": "Вся необходимая информация о боте",
"subcommands": {
"commands": {
@@ -372,7 +372,7 @@
"body": "# Команды\n\nBlitzKit предлагает следующие команды:\n\n"
},
"embeds": {
"name": "встраиваемые-элементы",
"name": "embeds",
"description": "Как работают встраиваемые элементы"
},
"introduction": {
@@ -384,7 +384,7 @@
"description": "Как пригласить бота"
},
"timezones": {
"name": "часовой-пояс",
"name": "timezones",
"description": "Как BlitzKit определяет и использует ваш часовой пояс"
},
"credits": {
@@ -409,11 +409,11 @@
"name": "отладка",
"description": "Сведения для отладки",
"body": {
"title": "# Отладка",
"title": "# Debug\n\n",
"version": "Версия",
"shard": {
"label": "Фрагмент",
"default": "по умолчанию"
"default": "default"
},
"uptime": "Время работы"
}
@@ -443,11 +443,11 @@
},
"body": {
"subtitle": "Неактивен {days}+ дней",
"listing": "{days} дней"
"listing": "{days{days}"
}
},
"full_stats": {
"name": "полная-статистика",
"name": "full-stats",
"description": "Полная игровая статистика",
"body": {
"cards": {
@@ -496,7 +496,7 @@
}
},
"search_clans": {
"name": "поиск-кланов",
"name": "search-clans",
"description": "Поиск кланов",
"options": {
"limit": {
@@ -510,7 +510,7 @@
}
},
"search_players": {
"name": "поиск-игроков",
"name": "search-players",
"description": "Поиск игроков",
"options": {
"limit": {
@@ -524,7 +524,7 @@
}
},
"rating_leaderboard": {
"name": "таблица-лидеров",
"name": "rating-leaderboard",
"description": "Статистика рейтингов боёв",
"subcommands": {
"neighbors": {
@@ -563,7 +563,7 @@
}
},
"today": {
"name": "cегодня",
"name": "today",
"description": "Танки, на которых сыграли сегодня и статистика"
},
"stats": {
@@ -607,8 +607,8 @@
}
},
"player_info": {
"name": "информация-об-игроке",
"description": "Основная информация о игроке",
"name": "player-ifor",
"description": "Basic information about a player",
"body": {
"title": "Информация о {name}",
"nickname": "Никнейм",
@@ -824,7 +824,7 @@
"skip": "Пропустить",
"guess": "Угадать",
"next": "Следующий",
"no_results": "Ничего не найденo"
"no_results": "Ничего не найдено"
},
"stats": "{correct} / {total} правильно, серия: {streak}",
"help": {
+3 -3
View File
@@ -876,7 +876,7 @@
"yaw": "Yaw",
"pitch": "Pitch",
"min": "Min",
"max": "Max"
"max": "Макс"
}
},
"sandbox": {
@@ -908,7 +908,7 @@
"normalization": "Нормалізація",
"ricochet": "Рикошет",
"explosion_radius": "Радіус розльоту уламків",
"exit": "Exit",
"exit": "Вийти",
"reset": "Reset"
},
"advanced": "Advanced highlighting",
@@ -923,7 +923,7 @@
},
"status": {
"penetration": "{damage}hp penetration",
"blocked": "Blocked",
"blocked": "Заблоковано",
"ricochet": "Рикошет",
"splash": "{damage}hp splash"
},
-1
View File
@@ -24,7 +24,6 @@
"fast-xml-parser": "^5.2.5",
"googleapis": "^162.0.0",
"lodash-es": "^4.17.21",
"lz4js": "^0.2.0",
"progress": "^2.0.3",
"puppeteer": "^24.22.3",
"sharp": "^0.34.4",
+36 -21
View File
@@ -4,7 +4,7 @@ import { mkdir, writeFile } from "fs/promises";
import { parse as parsePath } from "path";
import ProgressBar from "progress";
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
import { vfs } from "./buildAssets/constants";
import { PATCHES_ROOT, vfs } from "./buildAssets/constants";
import { writeDVPL } from "./core/blitz/writeDVPL";
const versionTextFile = await vfs.text(`Data/version.txt`);
@@ -20,27 +20,30 @@ let patchIndex = 1;
while (true) {
const response = await fetch(
`${assertSecret(
import.meta.env.WOTB_DLC_CDN
)}/dlc/s${currentVersion}_${patchIndex}.yaml`
import.meta.env.WOTB_DLC_CDN,
)}/dlc/s${currentVersion}_${patchIndex}.yaml`,
);
if (response.status === 200) {
console.log(patchIndex, response.status)
if (!response.ok) break;
console.log(`Applying patch ${patchIndex}...`);
const data = parseYaml(await response.text());
const dvpm = await fetch(
`${assertSecret(import.meta.env.WOTB_DLC_CDN)}/dlc/${data.dx11}`
`${assertSecret(import.meta.env.WOTB_DLC_CDN)}/dlc/${data.dx11}`,
).then((response) => response.arrayBuffer());
const dvpd = await fetch(
`${assertSecret(import.meta.env.WOTB_DLC_CDN)}/dlc/${data.dx11.replace(
".dvpm",
".dvpd"
)}`
".dvpd",
)}`,
).then((response) => response.arrayBuffer());
const files = await dvp(dvpm, dvpd);
const bar = new ProgressBar(
`Patching ${files.length} files :bar`,
files.length
files.length,
);
for (const { path, data } of files) {
@@ -49,43 +52,55 @@ while (true) {
const { dir } = parsePath(path);
try {
await mkdir(`Data/${dir}`, { recursive: true });
await mkdir(`${PATCHES_ROOT}/Data/${dir}`, { recursive: true });
} catch (error) {
console.warn(`Failed to make directory "${dir}"`);
}
const isDvpl = path.endsWith(".dvpl");
const buffer = Buffer.from(data);
await writeFile(
`Data/${path}${isDvpl ? "" : ".dvpl"}`,
new Uint8Array(isDvpl ? buffer : writeDVPL(buffer))
`${PATCHES_ROOT}/Data/${path}`,
new Uint8Array(buffer),
);
bar.tick();
}
if ("dynamicContentLocalizationsDir" in data) {
console.log("Found dynamic content localizations; patching...");
console.log('patches done for patch')
if ("dynamicContentLocalizationsDir" in data) {
console.log(
`${assertSecret(import.meta.env.WOTB_DLC_CDN)}/dlc/${
data.dynamicContentLocalizationsDir
}/en.yaml`,
);
const localizationsResponse = await fetch(
`${assertSecret(import.meta.env.WOTB_DLC_CDN)}/dlc/${
data.dynamicContentLocalizationsDir
}/en.yaml`
}/en.yaml`,
);
console.log('fetched')
const newStrings = parseYaml(await localizationsResponse.text());
const oldStrings = await vfs.yaml<Record<string, string>>(
`Data/Strings/en.yaml`
);
console.log('got new strings')
const oldStrings =
await vfs.yaml<Record<string, string>>(`Data/Strings/en.yaml`);
console.log('got old strings')
const patchedStrings = { ...oldStrings, ...newStrings };
const patchedContent = stringifyYaml(patchedStrings);
console.log('stringified')
await mkdir(`${PATCHES_ROOT}/Data/Strings`, { recursive: true });
console.log('mkdir')
await writeFile(
`Data/Strings/en.yaml`,
new Uint8Array(writeDVPL(Buffer.from(patchedContent)))
`${PATCHES_ROOT}/Data/Strings/en.yaml`,
new Uint8Array((Buffer.from(patchedContent))),
);
console.log('file')
}
patchIndex++;
} else break;
}
vfs.dispose()
process.exit(0);
@@ -3,6 +3,9 @@ import { AbstractVFS } from "../core/vfs/abstract";
import { LocalVFS } from "../core/vfs/local";
import { SteamVFS } from "../core/vfs/steam";
import { ZipVFS } from "../core/vfs/zip";
import { MixedVFS } from "../core/vfs/mixed";
export const PATCHES_ROOT = "../../temp/patches";
const steamPattern = /steam:(\d+)\/(\d+)/;
const zipPattern = /zip:(.+)/;
@@ -35,4 +38,6 @@ if (!source) {
throw new Error("No valid source specified");
}
resolved = new MixedVFS([new LocalVFS(PATCHES_ROOT), resolved]);
export const vfs = resolved;
+49
View File
@@ -0,0 +1,49 @@
import { AbstractVFS } from "./abstract";
export class MixedVFS extends AbstractVFS {
constructor(private vfses: AbstractVFS[]) {
super();
}
async init() {
for (const vfs of this.vfses) {
await vfs.init();
}
return this;
}
dispose() {
for (const vfs of this.vfses) {
vfs.dispose();
}
}
async has(path: string) {
for (const vfs of this.vfses) {
if (await vfs.has(path)) return true;
}
return false;
}
async raw(path: string) {
for (const vfs of this.vfses) {
if (await vfs.has(path)) return await vfs.raw(path);
}
throw new Error(`File not found: ${path}`);
}
async dir(path: string) {
const files = new Set<string>();
for (const vfs of this.vfses) {
for (const file of await vfs.dir(path)) {
files.add(file);
}
}
return Array.from(files);
}
}
@@ -1,9 +1,9 @@
import { J_HAT, K_HAT } from "@blitzkit/core";
import { Box, Flex, Text } from "@radix-ui/themes";
import { Html } from "@react-three/drei";
import { invalidate, useFrame } from "@react-three/fiber";
import { useFrame } from "@react-three/fiber";
import { clamp } from "lodash-es";
import { useEffect, useRef, type ComponentProps } from "react";
import { useEffect, useRef, type ComponentProps, type RefObject } from "react";
import {
BufferGeometry,
DoubleSide,
@@ -42,11 +42,10 @@ const TRACER_THIN = TRACER_THICK / 2;
export function ShotDisplay() {
const shot = Tankopedia.use((state) => state.shot);
const inTracer = useRef<Mesh>(null);
const outTracer = useRef<Mesh>(null);
const splashRadiusWrapper = useRef<Group>(null);
const splashRadiusMaterial = useRef<MeshBasicMaterial>(null);
let animationStartTime: number | null = null;
const inTracer = useRef<Mesh>(null!);
const outTracer = useRef<Mesh>(null!);
const splashRadiusWrapper = useRef<Group>(null!);
const splashRadiusMaterial = useRef<MeshBasicMaterial>(null!);
const { locale } = useLocale();
useEffect(() => {
@@ -64,10 +63,10 @@ export function ShotDisplay() {
outlineMaterial,
).computeLineDistances();
splashRadiusWrapper.current?.add(outline);
splashRadiusWrapper.current.add(outline);
return () => {
splashRadiusWrapper.current?.remove(outline);
splashRadiusWrapper.current.remove(outline);
};
}
});
@@ -86,44 +85,6 @@ export function ShotDisplay() {
}
}, [shot]);
useFrame(({ clock }) => {
if (!shot) return;
const t = clock.elapsedTime;
if (inTracer.current) {
const tracerT1 = t % 1;
const tracerT2 = (t + 0.5) % 1;
inTracer.current.scale.set(1, 1 - 2 * Math.abs(tracerT1 - 0.5), 1);
inTracer.current.position.set(0, (1 - tracerT1) * inLength, 0);
if (outTracer.current) {
outTracer.current.scale.set(1, 1 - 2 * Math.abs(tracerT2 - 0.5), 1);
outTracer.current.position.set(0, tracerT2 * outLength, 0);
}
invalidate();
}
if (
shot.splashRadius !== undefined &&
splashRadiusWrapper.current &&
splashRadiusMaterial.current
) {
if (animationStartTime === null) animationStartTime = t;
const scale =
clamp(4 * (t - animationStartTime), 0, 1) * shot.splashRadius;
const opacity = 1 - clamp(2 * (t - animationStartTime), 0, 1);
splashRadiusWrapper.current.scale.set(scale, scale, scale);
splashRadiusMaterial.current.opacity = opacity;
invalidate();
}
});
if (!shot) return null;
const inLength =
@@ -149,6 +110,15 @@ export function ShotDisplay() {
return (
<group>
<Animator
inTracer={inTracer}
outTracer={outTracer}
inLength={inLength}
outLength={outLength}
splashRadiusWrapper={splashRadiusWrapper}
splashRadiusMaterial={splashRadiusMaterial}
/>
{shot.splashRadius && (
<group
position={inLast.point}
@@ -283,3 +253,59 @@ export function ShotDisplay() {
</group>
);
}
interface AnimatorProps {
inTracer: RefObject<Mesh>;
outTracer: RefObject<Mesh>;
splashRadiusWrapper: RefObject<Group>;
splashRadiusMaterial: RefObject<MeshBasicMaterial>;
inLength: number;
outLength: number;
}
function Animator({
inTracer,
outTracer,
splashRadiusWrapper,
splashRadiusMaterial,
inLength,
outLength,
}: AnimatorProps) {
const shot = Tankopedia.use((state) => state.shot!);
let animationStartTime: number | null = null;
useFrame(({ clock, invalidate }) => {
const t = clock.elapsedTime;
const tracerT1 = t % 1;
const tracerT2 = (t + 0.5) % 1;
inTracer.current.scale.set(1, 1 - 2 * Math.abs(tracerT1 - 0.5), 1);
inTracer.current.position.set(0, (1 - tracerT1) * inLength, 0);
if (outTracer.current) {
outTracer.current.scale.set(1, 1 - 2 * Math.abs(tracerT2 - 0.5), 1);
outTracer.current.position.set(0, tracerT2 * outLength, 0);
}
invalidate();
if (shot.splashRadius !== undefined) {
if (animationStartTime === null) animationStartTime = t;
const scale =
clamp(4 * (t - animationStartTime), 0, 1) * shot.splashRadius;
const opacity = 1 - clamp(2 * (t - animationStartTime), 0, 1);
splashRadiusWrapper.current.scale.set(scale, scale, scale);
splashRadiusMaterial.current.opacity = opacity;
invalidate();
}
});
return null;
}
@@ -1,9 +1,9 @@
import { createPortal, useFrame, useThree } from '@react-three/fiber';
import { memo, useState } from 'react';
import { DepthTexture, Scene, Vector2 } from 'three';
import { PrimaryArmorScene } from './components/PrimaryArmorScene';
import { spacedArmorRenderTarget } from './components/PrimaryArmorSceneComponent/target';
import { SpacedArmorScene } from './components/SpacedArmorScene';
import { createPortal, useFrame, useThree } from "@react-three/fiber";
import { memo, useState } from "react";
import { DepthTexture, Scene, Vector2 } from "three";
import { PrimaryArmorScene } from "./components/PrimaryArmorScene";
import { spacedArmorRenderTarget } from "./components/PrimaryArmorSceneComponent/target";
import { SpacedArmorScene } from "./components/SpacedArmorScene";
export const Armor = memo(() => {
const rootScene = useThree((state) => state.scene);
@@ -1,4 +1,4 @@
import { useFrame } from '@react-three/fiber';
import { useFrame } from "@react-three/fiber";
export const zoom = 60;
@@ -1,4 +1,4 @@
import { useFrame } from '@react-three/fiber';
import { useFrame } from "@react-three/fiber";
export function AutoClear() {
useFrame(({ gl }) => {
@@ -19,6 +19,8 @@ const poseDistances: Record<Pose, number> = {
[Pose.Default]: -1,
};
const inspectModeInitialPosition = new Vector3(-8, 2, -13);
const modelDefinitions = await awaitableModelDefinitions;
interface ControlsProps {
@@ -77,22 +79,6 @@ export function Controls({
protagonistHullOrigin.y +
protagonistTurretOrigin.y +
protagonistGunOrigin.y;
const inspectModeInitialPosition = new Vector3(-8, 2, -13);
const t0 = useRef(Date.now() / 1000);
useFrame(() => {
if (!doAutoRotate) return;
const now = Date.now() / 1000;
const t = now - t0.current;
camera.position
.copy(inspectModeInitialPosition)
.applyAxisAngle(I_HAT, (Math.PI / 32) * Math.sin(t / 9))
.applyAxisAngle(J_HAT, (-Math.PI / 16) * Math.sin(t / 7));
invalidate();
});
useEffect(() => {
function handleControlsEnabled(event: QuicklimeEvent<boolean>) {
@@ -249,15 +235,37 @@ export function Controls({
}, []);
return (
<OrbitControls
enableRotate={enableRotate}
maxDistance={40}
minDistance={5}
enableZoom={zoomable}
zoomSpeed={zoomable ? undefined : 0}
ref={orbitControls}
enabled={controlsEnabledEvent.last!}
enableDamping={false}
/>
<>
{doAutoRotate && <Animator />}
<OrbitControls
enableRotate={enableRotate}
maxDistance={40}
minDistance={5}
enableZoom={zoomable}
zoomSpeed={zoomable ? undefined : 0}
ref={orbitControls}
enabled={controlsEnabledEvent.last!}
enableDamping={false}
/>
</>
);
}
function Animator() {
const clock = useThree((state) => state.clock);
const t0 = useRef(clock.elapsedTime);
useFrame(({ camera }) => {
const t = clock.elapsedTime - t0.current;
camera.position
.copy(inspectModeInitialPosition)
.applyAxisAngle(I_HAT, (Math.PI / 32) * Math.sin(t / 9))
.applyAxisAngle(J_HAT, (-Math.PI / 16) * Math.sin(t / 7));
invalidate();
});
return null;
}
@@ -1,63 +1,127 @@
import { useFrame, useThree } from "@react-three/fiber";
import { times } from "lodash-es";
import { useFrame } from "@react-three/fiber";
import { clamp, times } from "lodash-es";
import { Quicklime } from "quicklime";
import { useEffect, useRef } from "react";
import { useEffect, useRef, useState, type RefObject } from "react";
import { HemisphereLight, SpotLight, type Group } from "three";
import { clamp, degToRad, lerp } from "three/src/math/MathUtils.js";
import { degToRad, lerp } from "three/src/math/MathUtils.js";
import { Tankopedia } from "../../../../../../stores/tankopedia";
import { TankopediaPersistent } from "../../../../../../stores/tankopediaPersistent";
import { HelpingSpotLight } from "../../../../../HelpingSpotLight";
const ANGLE = Math.PI * 2 ** -2;
const ANGLE = degToRad(10);
const REVEAL_ANIMATION_TIME = 3;
const TRANSITION_ANIMATION_TIME = 0.5;
const LIGHTS_COUNT = 5;
const THETA_OFFSET = degToRad(-152);
const LIGHT_DISTANCE = 13;
const LIGHTS_COUNT = 4;
const THETA_OFFSET = degToRad(180 - 45);
const LIGHT_DISTANCE = 20;
const LIGHT_HEIGHT_0 = 4;
const LIGHT_HEIGHT_1 = 2;
const INTENSITY_0 = 3.5;
const INTENSITY_1 = 1.5;
const LIGHT_HEIGHT_1 = 6;
const INTENSITY_0 = 6;
const INTENSITY_1 = 3;
const HEMISPHERE_INTENSITY = 2;
export const transitionEvent = new Quicklime<number>(0);
export function Lighting() {
const wrapper = useRef<Group>(null!);
const t0 = useRef(0);
const transition = useRef(false);
const clock = useThree((state) => state.clock);
const display = Tankopedia.use((state) => state.display);
const requestedDisplay = Tankopedia.use((state) => state.requestedDisplay);
const animationTime = useRef(REVEAL_ANIMATION_TIME);
const highGraphics = TankopediaPersistent.use((state) => state.highGraphics);
const requestedDisplay = Tankopedia.use((state) => state.requestedDisplay);
const animationTime = useRef(REVEAL_ANIMATION_TIME);
const t0 = useRef(performance.now() / 1e3 - animationTime.current);
const isRevealing = useRef(true);
const [animate, setAnimate] = useState(true);
console.log(animate);
useEffect(() => {
Tankopedia.mutate((draft) => {
draft.revealed = true;
});
t0.current = performance.now() / 1000 - REVEAL_ANIMATION_TIME;
}, []);
useEffect(() => {
if (isRevealing.current) {
isRevealing.current = false;
} else {
animationTime.current = TRANSITION_ANIMATION_TIME;
t0.current = performance.now() / 1e3;
}
setAnimate(true);
}, [requestedDisplay]);
return (
<>
{animate && (
<Animator
t0={t0}
animationTime={animationTime}
wrapper={wrapper}
stop={() => {
setAnimate(false);
}}
/>
)}
<group ref={wrapper}>
<hemisphereLight intensity={0} color="#ffffff" groundColor="#afafaf" />
{times(LIGHTS_COUNT, (index) => {
const x = index / (LIGHTS_COUNT - 1);
const theta = 2 * Math.PI * (index / LIGHTS_COUNT) + THETA_OFFSET;
const position = [
LIGHT_DISTANCE * Math.sin(theta),
lerp(LIGHT_HEIGHT_0, LIGHT_HEIGHT_1, x),
LIGHT_DISTANCE * Math.cos(theta),
] as const;
const intensity = lerp(INTENSITY_0, INTENSITY_1, x);
return (
<HelpingSpotLight
userData={{ index }}
key={index}
position={position}
intensity={intensity}
penumbra={1}
castShadow={highGraphics}
decay={0}
color="#ffffff"
angle={0}
/>
);
})}
</group>
</>
);
}
interface AnimatorProps {
stop: () => void;
t0: RefObject<number>;
animationTime: RefObject<number>;
wrapper: RefObject<Group>;
}
function Animator({ stop, t0, animationTime, wrapper }: AnimatorProps) {
const requestedDisplay = Tankopedia.use((state) => state.requestedDisplay);
useFrame(({ invalidate }) => {
if (transition.current) {
transition.current = false;
t0.current = performance.now() / 1000;
const dt = performance.now() / 1e3 - t0.current;
const x = clamp(dt / animationTime.current, 0, 2);
if (x === 2) {
stop();
return;
}
const x = clamp(
(performance.now() / 1000 - t0.current) / animationTime.current,
0,
2,
);
const t = (0.5 * Math.sin(Math.PI * (x + 0.5)) + 0.5) ** 2;
if (t !== transitionEvent.last) {
transitionEvent.dispatch(t);
}
for (const child of wrapper.current.children) {
if (child instanceof SpotLight) {
child.angle = ANGLE * t;
@@ -66,53 +130,15 @@ export function Lighting() {
}
}
if (x < 2) invalidate();
transitionEvent.dispatch(t);
invalidate();
if (
x > 1 &&
Tankopedia.state.display !== Tankopedia.state.requestedDisplay
) {
if (x >= 1 && Tankopedia.state.display !== requestedDisplay) {
Tankopedia.mutate((draft) => {
draft.display = requestedDisplay;
});
}
});
useEffect(() => {
if (requestedDisplay === display) return;
transition.current = true;
animationTime.current = TRANSITION_ANIMATION_TIME;
}, [requestedDisplay]);
return (
<group ref={wrapper}>
<hemisphereLight intensity={0} color="#ffffff" groundColor="#afafaf" />
{times(LIGHTS_COUNT, (index) => {
const x = index / (LIGHTS_COUNT - 1);
const theta = 2 * Math.PI * (index / LIGHTS_COUNT) + THETA_OFFSET;
const position = [
LIGHT_DISTANCE * Math.sin(theta),
lerp(LIGHT_HEIGHT_0, LIGHT_HEIGHT_1, x),
LIGHT_DISTANCE * Math.cos(theta),
] as const;
const intensity = lerp(INTENSITY_0, INTENSITY_1, x);
return (
<HelpingSpotLight
userData={{ index }}
key={index}
position={position}
intensity={intensity}
penumbra={1}
castShadow={highGraphics}
decay={0}
color="#ffffff"
angle={0}
/>
);
})}
</group>
);
return null;
}
@@ -1,10 +1,9 @@
import { invalidate, useFrame } from "@react-three/fiber";
import { clamp } from "lodash-es";
import { invalidate } from "@react-three/fiber";
import { Quicklime, type QuicklimeEvent } from "quicklime";
import { useEffect, useRef } from "react";
import { Mesh, MeshStandardMaterial } from "three";
const SIZE = 2 ** 6;
const SIZE = 14;
export const screenshotReadyEvent = new Quicklime(false);
@@ -12,11 +11,6 @@ export function SceneProps() {
const mesh = useRef<Mesh>(null!);
const material = useRef<MeshStandardMaterial>(null);
useFrame(({ camera }) => {
if (!material.current) return;
material.current.opacity = clamp(0.5 * camera.position.y, 0, 0.5);
});
useEffect(() => {
function handleScreenshotReady(event: QuicklimeEvent<boolean>) {
mesh.current.visible = !event.data;
@@ -36,11 +30,9 @@ export function SceneProps() {
receiveShadow
visible={!screenshotReadyEvent.last!}
ref={mesh}
renderOrder={-1}
>
<planeGeometry args={[SIZE, SIZE]} />
<meshStandardMaterial
transparent
ref={material}
color="black"
roughness={1}
@@ -156,7 +156,7 @@ export const TankSandbox = forwardRef<HTMLCanvasElement, TankSandboxProps>(
localClippingEnabled: true,
preserveDrawingBuffer: true,
}}
shadows="soft"
shadows="basic"
onPointerDown={handlePointerDown}
onPointerMissed={() => {
Tankopedia.mutate((draft) => {
@@ -40,5 +40,5 @@ export const TankopediaPersistent = new Varuna<TankopediaPersistent>(
direction: "ascending",
},
},
"tankopedia-2"
"tankopedia-3",
);