diff --git a/.github/workflows/cd-assets.yml b/.github/workflows/cd-assets.yml index c48ce22c2..346153db2 100644 --- a/.github/workflows/cd-assets.yml +++ b/.github/workflows/cd-assets.yml @@ -62,4 +62,6 @@ jobs: $env:GH_TOKEN = "${{ secrets.GH_TOKEN }}" $env:DISCORD_TOKEN = "${{ secrets.DISCORD_TOKEN }}" $env:DATABASE_URL = "${{ secrets.DATABASE_URL }}" + $env:VIDEOS_SHEET = "${{ secrets.VIDEOS_SHEET }}" + $env:GOOGLE_APPLICATION_CREDENTIALS = "${{ secrets.GOOGLE_APPLICATION_CREDENTIALS }}" yarn build:assets --all-targets --depot ${{ github.ref == 'refs/heads/main' && '--production' || '' }} diff --git a/.gitignore b/.gitignore index 2658571a8..6173793fa 100644 --- a/.gitignore +++ b/.gitignore @@ -18,4 +18,5 @@ temp/ /.VSCodeCounter /.yarn depots -public/sitemap* \ No newline at end of file +public/sitemap* +/firebase.json \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index a5624fe48..5a039bae7 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -165,6 +165,7 @@ "xkdkw", "XMLDVPL", "YAMLDVPL", + "youtubers", "zustand" ], "deno.enable": false, diff --git a/docs/changelogs/1.19.5.md b/docs/changelogs/1.19.5.md new file mode 100644 index 000000000..cf6de67ac --- /dev/null +++ b/docs/changelogs/1.19.5.md @@ -0,0 +1,19 @@ +# Blitzkrieg 1.19.5 + +The tankopedia, now more helpful than ever! This update only affects the website. + +## Features + +- The Tankopedia pages for tanks now include videos from YouTubers + - The selection of tanks that have videos is very small currently but you can help expand it by dropping suggestions on [the official Discord server](https://discord.gg/nDt7AjGJQH) +- Added shell normalization and ricochet angles to the Tankopedia and Compare + +## Fixes + +- Removed undefined clipping planes +- Added proper keying to all JSX tree nodes, navbar menu items, antagonist bar shells, skill rows and fragments of tank stats in the Tankopedia +- The second half of barrels for tanks like the T-100 LT are now properly included + +## Technical Features + +- The list of videos is fetched transpiled from a Google Sheets spreadsheet into a CSV file in the asset repository diff --git a/docs/public/assets/images/hero.png b/docs/public/assets/images/hero.png index d0106ee5d..c2af7e9a7 100644 Binary files a/docs/public/assets/images/hero.png and b/docs/public/assets/images/hero.png differ diff --git a/package.json b/package.json index 882deb16a..8fda4b431 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { - "name": "blitzrinth", - "version": "1.19.4", + "name": "blitzkrieg", + "version": "1.19.5", "description": "🎉 Tools for everything World of Tanks Blitz", "main": "src/server.ts", "type": "module", @@ -52,6 +52,7 @@ "express": "^4.18.2", "false": "^0.0.4", "fuzzysort": "^2.0.4", + "googleapis": "^134.0.0", "immer": "^10.0.2", "js-yaml": "^4.1.0", "lodash": "^4.17.21", diff --git a/public/ads.txt b/public/ads.txt index 101d6d9b9..1f756c478 100644 --- a/public/ads.txt +++ b/public/ads.txt @@ -1 +1,3 @@ google.com, pub-1289406790225052, DIRECT, f08c47fec0942fa0 +appnexus.com, 14163, RESELLER +pubcenter.microsoft.com, 253617120, DIRECT diff --git a/public/assets/banners/compare.png b/public/assets/banners/compare.png index d65ba041d..7c7f25e6c 100644 Binary files a/public/assets/banners/compare.png and b/public/assets/banners/compare.png differ diff --git a/public/assets/banners/discord.png b/public/assets/banners/discord.png index f35024841..33ad626ab 100644 Binary files a/public/assets/banners/discord.png and b/public/assets/banners/discord.png differ diff --git a/public/assets/banners/statistics.png b/public/assets/banners/statistics.png index 9f35a179c..ca1135d32 100644 Binary files a/public/assets/banners/statistics.png and b/public/assets/banners/statistics.png differ diff --git a/scripts/buildAssets/definitions.ts b/scripts/buildAssets/definitions.ts index ebfefc8f7..2219c3cb5 100644 --- a/scripts/buildAssets/definitions.ts +++ b/scripts/buildAssets/definitions.ts @@ -38,7 +38,7 @@ import { DATA, POI } from './constants'; import { Avatar } from './skillIcons'; import { TankParameters } from './tankIcons'; -interface Strings { +export interface BlitzStrings { [key: string]: string; } type ShellKind = @@ -334,7 +334,7 @@ const blitzShellKindToBlitzrinth: Record = { const missingStrings: Record = { '#artefacts:tungstentip/name': 'Tungsten Shells', }; -const botPattern = /^.+((tutorial_bot(\d+)?)|(TU(R?)))$/; +export const botPattern = /^.+((tutorial_bot(\d+)?)|(TU(R?)))$/; export async function definitions(production: boolean) { console.log('Building definitions...'); @@ -361,12 +361,12 @@ export async function definitions(production: boolean) { (nations) => nations.filter((nation) => nation !== 'common'), ); const tankStringIdMap: Record = {}; - const stringsPreInstalled = await readYAMLDVPL( + const stringsPreInstalled = await readYAMLDVPL( `${DATA}/${POI.strings}/en.yaml.dvpl`, ); const stringsCache = await fetch(POI.cachedStrings) .then((response) => response.text()) - .then((string) => parseYaml(string) as Strings); + .then((string) => parseYaml(string) as BlitzStrings); const optionalDevices = await readXMLDVPL<{ root: OptionalDevices }>( `${DATA}/${POI.optionalDevices}.dvpl`, ); diff --git a/scripts/buildAssets/index.ts b/scripts/buildAssets/index.ts index 04d4a71ab..387a93085 100644 --- a/scripts/buildAssets/index.ts +++ b/scripts/buildAssets/index.ts @@ -24,15 +24,17 @@ import { skillIcons } from './skillIcons'; import { tankArmors } from './tankArmors'; import { tankIcons } from './tankIcons'; import { tankModels } from './tankModels'; +import { videos } from './videos'; if (!targets && !allTargets) throw new Error('No target(s) specified'); const methods = [ - // definitions, - // scratchedFlags, - // currencies, - // skillIcons, - // circleFlags, + definitions, + videos, + scratchedFlags, + currencies, + skillIcons, + circleFlags, shellIcons, moduleIcons, equipmentIcons, diff --git a/scripts/buildAssets/tankArmors.ts b/scripts/buildAssets/tankArmors.ts index 0db2ca58d..83ed88abb 100644 --- a/scripts/buildAssets/tankArmors.ts +++ b/scripts/buildAssets/tankArmors.ts @@ -28,9 +28,6 @@ export async function tankArmors(production: boolean) { if (tankKey.includes('tutorial_bot')) return; const id = toUniqueId(nation, tank.id); - - console.log(`Building armor ${id} @ ${nation}/${tankKey}`); - const model = await extractArmor(DATA, `${nation}-${tankKey}`); changes.push({ diff --git a/scripts/buildAssets/tankModels.ts b/scripts/buildAssets/tankModels.ts index c7131b7d0..b83177839 100644 --- a/scripts/buildAssets/tankModels.ts +++ b/scripts/buildAssets/tankModels.ts @@ -32,9 +32,6 @@ export async function tankModels(production: boolean) { if (tankKey.includes('tutorial_bot')) return; const id = toUniqueId(nation, tank.id); - - console.log(`Building model ${id} @ ${nation}/${tankKey}`); - const parameters = await readYAMLDVPL( `${DATA}/${POI.tankParameters}/${nation}/${tankKey}.yaml.dvpl`, ); diff --git a/scripts/buildAssets/videos.ts b/scripts/buildAssets/videos.ts new file mode 100644 index 000000000..a96b9b69b --- /dev/null +++ b/scripts/buildAssets/videos.ts @@ -0,0 +1,124 @@ +import { readdir } from 'fs/promises'; +import { google } from 'googleapis'; +import { cloneDeep, uniqBy } from 'lodash'; +import { parse as parseYaml } from 'yaml'; +import { readXMLDVPL } from '../../src/core/blitz/readXMLDVPL'; +import { readYAMLDVPL } from '../../src/core/blitz/readYAMLDVPL'; +import { toUniqueId } from '../../src/core/blitz/toUniqueId'; +import { commitAssets } from '../../src/core/blitzkrieg/commitAssets'; +import { + VideoDefinitions, + videoDefinitions, +} from '../../src/core/blitzkrieg/videos'; +import { DATA, POI } from './constants'; +import { BlitzStrings, botPattern, VehicleDefinitionList } from './definitions'; + +const youtubers = [ + 'UCKBYXp4Xn2I2tL1UL4fpbhw', // droodles + 'UCrQ-Dy8lVsm11u8pCJ8W7Tw', // fatness +]; + +export async function videos(production: boolean) { + console.log('Building videos...'); + + const currentVideos = await videoDefinitions; + const auth = await google.auth.getClient({ + scopes: ['https://www.googleapis.com/auth/youtube.readonly'], + }); + const youtube = google.youtube({ version: 'v3', auth }); + + const nations = await readdir(`${DATA}/${POI.vehicleDefinitions}`).then( + (nations) => nations.filter((nation) => nation !== 'common'), + ); + const stringsPreInstalled = await readYAMLDVPL( + `${DATA}/${POI.strings}/en.yaml.dvpl`, + ); + const stringsCache = await fetch(POI.cachedStrings) + .then((response) => response.text()) + .then((string) => parseYaml(string) as BlitzStrings); + const strings = { + ...stringsPreInstalled, + ...stringsCache, + }; + const tanks: { id: number; name: string; tier: number }[] = []; + await Promise.all( + nations.map(async (nation) => { + const tankList = await readXMLDVPL<{ root: VehicleDefinitionList }>( + `${DATA}/${POI.vehicleDefinitions}/${nation}/list.xml.dvpl`, + ); + + await Promise.all( + Object.entries(tankList.root).map(async ([tankKey, tank]) => { + if (botPattern.test(tankKey)) return null; + + const id = toUniqueId(nation, tank.id); + const name = + (tank.shortUserString + ? strings[tank.shortUserString] + : undefined) ?? strings[tank.userString]; + const tier = tank.level; + + tanks.push({ id, name, tier }); + }), + ); + }), + ); + + const tanksSanitized = tanks + .sort((a, b) => b.tier - a.tier) + .sort((a, b) => { + const lastUpdatedA = currentVideos[a.id]?.lastUpdated ?? 0; + const lastUpdatedB = currentVideos[b.id]?.lastUpdated ?? 0; + return lastUpdatedA - lastUpdatedB; + }); + const videos: VideoDefinitions = cloneDeep(currentVideos); + + let done = 0; + for (const tank of tanksSanitized) { + try { + const results = await youtube.search.list({ + part: ['snippet'], + q: `World of Tanks Blitz ${tank.name}`, + }); + + if (!results.data.items || results.data.items.length === 0) continue; + + const items = uniqBy( + results.data.items.filter( + (item) => + item.snippet?.channelId && + item.id?.videoId && + youtubers.includes(item.snippet.channelId), + ), + (item) => item.snippet!.channelId, + ); + + videos[tank.id] = { + lastUpdated: Math.round(Date.now() / 1000), + videos: items.map((item) => item.id!.videoId!), + }; + + console.log( + `${++done} / ${tanks.length} (${Math.round(100 * (done / tanks.length))}%) done; found ${items.length} for ${tank.name}`, + ); + + await new Promise((resolve) => setTimeout(resolve, 1000 / 5)); + } catch (error) { + console.warn('An error occurred, safely exiting prematurely...', error); + break; + } + } + + const content = Object.entries(videos) + .map( + ([id, { videos, lastUpdated }]) => + `${id},${lastUpdated},${videos.join(',')}`, + ) + .join('\n'); + + await commitAssets( + 'videos', + [{ content, encoding: 'utf-8', path: 'definitions/videos.csv' }], + production, + ); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 1f2548a2a..cc8fa2e23 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -7,9 +7,9 @@ import { Roboto_Flex } from 'next/font/google'; import { usePathname } from 'next/navigation'; import { ReactNode, Suspense, useEffect, useState } from 'react'; import { Footer } from '../components/Footer'; -import { Google } from '../components/Google'; import { Loader } from '../components/Loader'; import Navbar from '../components/Navbar'; +import { Party3 } from '../components/Party3'; import isDev from '../core/blitzrinth/isDev'; import { isLocalhost } from '../core/blitzrinth/isLocalhost'; import { useFullScreen } from '../hooks/useFullScreen'; @@ -60,7 +60,7 @@ export default function RootLayout({ children }: RootLayoutProps) { - + )} - + + + {antagonist.turret.guns.map((gun, index) => ( - {GUN_TYPE_NAMES[gun.type]} {stats.dpm} @@ -189,7 +188,6 @@ export function Characteristics() { {gun.type !== 'regular' && ( {stats.shells} )} - {gun.type === 'autoReloader' ? ( <> @@ -229,9 +227,6 @@ export function Characteristics() { {stats.intraClip!} )} - - {stats.caliber} - {stats.penetration} @@ -295,6 +290,22 @@ export function Characteristics() { {stats.explosionRadius!} )} + + {stats.caliber} + + + {stats.shellNormalization} + + {shell.type !== 'he' && ( + + {stats.shellRicochet!} + + )} {stats.shellVelocity} @@ -516,7 +527,7 @@ export function Characteristics() { const count = member.count ?? 1; return ( - <> + 1 ? ` x ${count}` : ''}`} @@ -545,7 +556,7 @@ export function Characteristics() { } /> )} - + ); })} diff --git a/src/app/tools/tankopedia/[id]/components/Model/components/TankModel.tsx b/src/app/tools/tankopedia/[id]/components/Model/components/TankModel.tsx index 87ae17881..26433bfd7 100644 --- a/src/app/tools/tankopedia/[id]/components/Model/components/TankModel.tsx +++ b/src/app/tools/tankopedia/[id]/components/Model/components/TankModel.tsx @@ -124,11 +124,15 @@ export const TankModel = memo(() => { event.stopPropagation(); } - return jsxTree(node, { - castShadow: true, - receiveShadow: true, - onPointerDown, - }); + return jsxTree( + node, + { + castShadow: true, + receiveShadow: true, + onPointerDown, + }, + node.uuid, + ); })} @@ -189,11 +193,15 @@ export const TankModel = memo(() => { window.removeEventListener('pointerup', handlePointerUp); } - return jsxTree(node, { - castShadow: true, - receiveShadow: true, - onPointerDown, - }); + return jsxTree( + node, + { + castShadow: true, + receiveShadow: true, + onPointerDown, + }, + node.uuid, + ); })} @@ -255,11 +263,15 @@ export const TankModel = memo(() => { window.removeEventListener('pointerup', handlePointerUp); } - return jsxTree(node, { - castShadow: true, - receiveShadow: true, - onPointerDown, - }); + return jsxTree( + node, + { + castShadow: true, + receiveShadow: true, + onPointerDown, + }, + node.uuid, + ); })} diff --git a/src/app/tools/tankopedia/[id]/components/Videos.tsx b/src/app/tools/tankopedia/[id]/components/Videos.tsx new file mode 100644 index 000000000..2d867d08d --- /dev/null +++ b/src/app/tools/tankopedia/[id]/components/Videos.tsx @@ -0,0 +1,74 @@ +import { InfoCircledIcon, PlusIcon } from '@radix-ui/react-icons'; +import { Callout, Card, Flex, Heading, Link, Text } from '@radix-ui/themes'; +import { use } from 'react'; +import { videoDefinitions } from '../../../../../core/blitzkrieg/videos'; +import { useFullScreen } from '../../../../../hooks/useFullScreen'; +import { useDuel } from '../../../../../stores/duel'; + +export function Videos() { + const isFullScreen = useFullScreen(); + const tank = useDuel((state) => state.protagonist!.tank); + const awaitedVideoDefinitions = use(videoDefinitions); + const videos = awaitedVideoDefinitions[tank.id]?.videos ?? []; + + if (isFullScreen) return null; + + return ( + + Review videos + + + + + + + Some YouTubers may not have videos on the {tank.name} leading to + off-topic videos. + + + + + {videos.map((video) => ( + + + + ))} + + + + + + {videos.length === 0 ? 'Suggest' : 'Suggest more'} + + + + + + + ); +} diff --git a/src/app/tools/tankopedia/[id]/page.tsx b/src/app/tools/tankopedia/[id]/page.tsx index ea3aca539..eec956cc4 100644 --- a/src/app/tools/tankopedia/[id]/page.tsx +++ b/src/app/tools/tankopedia/[id]/page.tsx @@ -17,6 +17,7 @@ import { Provisions } from './components/Characteristics/components/Provisions'; import { Skills } from './components/Characteristics/components/Skills'; import { TankSandbox } from './components/Model/TankSandbox'; import { Title } from './components/Title'; +import { Videos } from './components/Videos'; export default function Page({ params }: { params: { id: string } }) { const initialId = parseInt(params.id); @@ -83,7 +84,7 @@ export default function Page({ params }: { params: { id: string } }) { style={{ flex: 1, width: '100%', - top: 64, + top: 64 + 16, position: wideFormat ? 'sticky' : undefined, }} > @@ -91,7 +92,6 @@ export default function Page({ params }: { params: { id: string } }) { - @@ -110,6 +110,8 @@ export default function Page({ params }: { params: { id: string } }) { + + )} diff --git a/src/app/tools/tankopedia/map/page.tsx b/src/app/tools/tankopedia/map/page.tsx index 7c52606bf..e25b1bd62 100644 --- a/src/app/tools/tankopedia/map/page.tsx +++ b/src/app/tools/tankopedia/map/page.tsx @@ -1,5 +1,8 @@ +'use client'; + import { Flex, Link } from '@radix-ui/themes'; import { use } from 'react'; +import { AdHorizontal } from '../../../../components/Ads/Horizontal'; import PageWrapper from '../../../../components/PageWrapper'; import { tankDefinitions } from '../../../../core/blitzrinth/tankDefinitions'; @@ -8,11 +11,17 @@ export default function Page() { return ( + Super duper secret map for SEO + + + {Object.values(awaitedTankDefinitions) .sort((a, b) => b.tier - a.tier) .map((tank) => ( - {tank.name} + + {tank.name} + ))} diff --git a/src/components/Ads/Horizontal.tsx b/src/components/Ads/Horizontal.tsx new file mode 100644 index 000000000..4a66a4dcb --- /dev/null +++ b/src/components/Ads/Horizontal.tsx @@ -0,0 +1,24 @@ +export function AdHorizontal() { + return ( + <> +