Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Units Page #279

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

32 changes: 30 additions & 2 deletions src/main/content/game/game-content.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,20 @@
import * as fs from "fs";
import * as glob from "glob-promise";
import { removeFromArray } from "$/jaz-ts-utils/object";
import * as path from "path";
import util, { promisify } from "util";
import zlib from "zlib";
import assert from "assert";
import { removeFromArray } from "$/jaz-ts-utils/object";
import { GameAI, GameVersion } from "@main/content/game/game-version";
import { parseLuaTable } from "@main/utils/parse-lua-table";
import { parseLuaOptions } from "@main/utils/parse-lua-options";
import { BufferStream } from "@main/utils/buffer-stream";
import { logger } from "@main/utils/logger";
import assert from "assert";
import { contentSources } from "@main/config/content-sources";
import { DownloadInfo } from "@main/content/downloads";
import { LuaOptionSection } from "@main/content/game/lua-options";
import { Scenario } from "@main/content/game/scenario";
import { extendUnitData, Unit, UnitLanguage, UnitMetadata } from "@main/content/game/unit";
import { SdpFileMeta, SdpFile } from "@main/content/game/sdp";
import { PrDownloaderAPI } from "@main/content/pr-downloader";
import { CONTENT_PATH, GAME_VERSIONS_GZ_PATH } from "@main/config/app";
Expand Down Expand Up @@ -162,6 +163,33 @@ export class GameContentAPI extends PrDownloaderAPI<GameVersion> {
return scenarios;
}

public async getUnits(): Promise<Unit[]> {
const currentGameVersion = this.installedVersions.at(-1);
assert(currentGameVersion, "No current game version found");
const unitDefinitions = await this.getGameFiles(currentGameVersion.packageMd5, "units/**/*.lua", true);

const units: Unit[] = [];
for (const unitDefinition of unitDefinitions) {
try {
const unitMetadata = parseLuaTable(unitDefinition.data) as { [unitId: string]: UnitMetadata };
const unit = extendUnitData(unitMetadata);
unit.fileName = unitDefinition.fileName;
units.push(unit);
} catch (err) {
console.error(`error parsing unit lua file: ${unitDefinition.fileName}`, err);
}
}
return units;
}

public async getUnitLanguage(locale: string): Promise<UnitLanguage> {
const currentGameVersion = this.installedVersions.at(-1);
assert(currentGameVersion, "No current game version found");
const unitLanguage = await this.getGameFiles(currentGameVersion.packageMd5, `language/${locale}/units.json`, true);

return JSON.parse(unitLanguage[0].data.toString()) as UnitLanguage;
}

public async uninstallVersion(version: GameVersion) {
// TODO: Uninstall game version through prd when prd supports it
removeFromArray(this.installedVersions, version);
Expand Down
126 changes: 126 additions & 0 deletions src/main/content/game/unit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
export type UnitType = "air" | "building" | "sea";

export type Unit = UnitMetadata & {
fileName: string;
images?: {
preview?: string;
preview3d?: string;
};
imageBlobs?: {
preview?: Blob;
preview3d?: Blob;
};
unitId: string;
unitName: string;
unitDescription: string;
unitCategory: UnitGroup;
techLevel: TechLevel;
factionKey: FactionKey;
factionName: string;
};

// Values parsed from LUA files
export type UnitMetadata = {
acceleration: number;
airsightdistance: number;
buildcostenergy: number;
buildcostmetal: number;
buildtime: number;
canmove: boolean;
category: string;
customparams: {
techlevel: number;
unitgroup: string;
};
icontype: UnitType;
sightdistance: number;
maxdamage: number;
maxvelocity: number;
};

export type UnitLanguage = {
units: {
factions: {
[factionId: string]: string;
};
names: {
[unitId: string]: string;
};
descriptions: {
[unitId: string]: string;
};
};
};

export type FactionKey = "arm" | "cor" | "leg" | "raptor" | "random";
export type TechLevel = "T1" | "T2" | "T3";
export type UnitGroup = "bots" | "vehicles" | "air" | "sea" | "hover" | "factories" | "defense" | "buildings";

function getFactionKey(unitId: string): FactionKey {
if (unitId.startsWith("arm")) return "arm";
if (unitId.startsWith("cor")) return "cor";
if (unitId.startsWith("leg")) return "leg";
if (unitId.startsWith("chicken")) return "raptor";
return "random";
}
const levelsMap: Record<number, TechLevel> = { 1: "T1", 2: "T2", 3: "T3" };

// TODO: there is probably a better way to achieve this
function getUnitCategory(unit: Unit): UnitGroup {
const categories = unit.category.split(" ");
if (categories.includes(categoryNames.MINE)) return "defense";
if (categories.includes(categoryNames.HOVER)) return "hover";
if (categories.includes(categoryNames.SHIP)) return "sea";
if (categories.includes(categoryNames.VTOL)) return "air";
if (categories.includes(categoryNames.MOBILE) && categories.includes(categoryNames.TANK)) return "vehicles";
if (categories.includes(categoryNames.MOBILE) && categories.includes(categoryNames.BOT)) return "bots";
if (categories.includes(categoryNames.SURFACE) && categories.includes(categoryNames.WEAPON)) return "defense";
if (categories.includes(categoryNames.SURFACE) && categories.includes(categoryNames.NOWEAPON) && unit.customparams.unitgroup === "builder") return "factories";
return "buildings";
}

export function extendUnitData(unitMetadata: { [unitId: string]: UnitMetadata }): Unit {
const unitId = Object.keys(unitMetadata)[0];
const unit = unitMetadata[unitId] as Unit;
unit.unitId = unitId;

unit.images = {
preview: `https://bar-rts.com/unitpics/${unitId}.png`,
preview3d: `https://bar-rts.com/unitpics3d/${unitId}.png`,
};

unit.techLevel = levelsMap[unit.customparams?.techlevel] ?? "T1";

unit.factionKey = getFactionKey(unit.unitId);

unit.unitCategory = getUnitCategory(unit);

return unit;
}

// from alldefs_post.lua
// Deprecated categories: BOT TANK PHIB NOTLAND SPACE
const categoryNames = {
ALL: "ALL",
MOBILE: "MOBILE",
NOTMOBILE: "NOTMOBILE",
WEAPON: "WEAPON",
NOWEAPON: "NOWEAPON",
VTOL: "VTOL", // air
NOTAIR: "NOTAIR",
HOVER: "HOVER",
NOTHOVER: "NOTHOVER",
SHIP: "SHIP",
NOTSHIP: "NOTSHIP",
NOTSUB: "NOTSUB",
CANBEUW: "CANBEUW", // underwater
UNDERWATER: "UNDERWATER",
SURFACE: "SURFACE",
MINE: "MINE",
COMMANDER: "COMMANDER",
EMPABLE: "EMPABLE",

// deprecated?
TANK: "TANK",
BOT: "BOT",
};
2 changes: 2 additions & 0 deletions src/main/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import replaysService from "@main/services/replays.service";
import { miscService } from "@main/services/news.service";
import { replayContentAPI } from "@main/content/replays/replay-content";
import path from "path";
import unitsService from "@main/services/units.service";

const log = logger("main/index.ts");
log.info("Starting Electron main process");
Expand Down Expand Up @@ -148,6 +149,7 @@ app.whenReady().then(() => {
shellService.registerIpcHandlers();
downloadsService.registerIpcHandlers(mainWindow);
miscService.registerIpcHandlers();
unitsService.registerIpcHandlers();

const file = replayFileOpenedWithTheApp();
if (file) {
Expand Down
2 changes: 2 additions & 0 deletions src/main/services/game.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ function registerIpcHandlers(mainWindow: Electron.BrowserWindow) {
ipcMain.handle("game:downloadGame", (_, version: string) => gameContentAPI.downloadGame(version));
ipcMain.handle("game:getGameOptions", (_, version: string) => gameContentAPI.getGameOptions(version));
ipcMain.handle("game:getScenarios", () => gameContentAPI.getScenarios());
ipcMain.handle("game:getUnits", () => gameContentAPI.getUnits());
ipcMain.handle("game:getUnitLanguage", (_, locale: string) => gameContentAPI.getUnitLanguage(locale));
ipcMain.handle("game:getInstalledVersions", () => gameContentAPI.installedVersions);
ipcMain.handle("game:isVersionInstalled", (_, id: string) => gameContentAPI.isVersionInstalled(id));
ipcMain.handle("game:uninstallVersion", (_, version: GameVersion) => gameContentAPI.uninstallVersion(version));
Expand Down
12 changes: 12 additions & 0 deletions src/main/services/units.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { ipcMain } from "electron";
import { fetchImage } from "@main/utils/background/image";

function registerIpcHandlers() {
ipcMain.handle("units:fetchUnitImage", async (_, imageUrl: string) => await fetchImage(imageUrl));
}

const unitsService = {
registerIpcHandlers,
};

export default unitsService;
16 changes: 16 additions & 0 deletions src/main/utils/background/image-worker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { isMainThread, parentPort } from "worker_threads";

if (isMainThread) {
throw new Error("This script should be run in worker thread.");
} else {
// listen to messages from the main thread
parentPort.on("message", async (imageSource: string) => {
try {
const response = await fetch(imageSource);
const arrayBuffer = await response.arrayBuffer();
parentPort.postMessage({ imageSource, arrayBuffer });
} catch (error) {
console.error(error);
}
});
}
34 changes: 34 additions & 0 deletions src/main/utils/background/image.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import path from "path";
import { Worker } from "worker_threads";

const worker = new Worker(path.join(__dirname, "image-worker.cjs"));

const jobs = new Map<
string,
{
resolve: (value: ArrayBuffer) => void;
reject: (reason?: string) => void;
}
>();
const promises = new Map<string, Promise<ArrayBuffer>>();

worker.on("message", ({ imageSource, arrayBuffer }) => {
const promiseHandles = jobs.get(imageSource);
promiseHandles.resolve(arrayBuffer);
jobs.delete(imageSource);
promises.delete(imageSource);
});
// worker.on("error", reject);
// worker.on("exit", (code) => {
// if (code !== 0) reject(new Error(`image-worker stopped with exit code ${code}`));
// });

export function fetchImage(imageSource: string): Promise<ArrayBuffer> {
if (promises.has(imageSource)) return promises.get(imageSource);
const promise = new Promise<ArrayBuffer>((resolve, reject) => {
jobs.set(imageSource, { resolve, reject });
worker.postMessage(imageSource);
});
promises.set(imageSource, promise);
return promise;
}
9 changes: 5 additions & 4 deletions src/main/utils/parse-lua-table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ export function parseLuaTable(luaFile: Buffer, options?: ParseLuaTableOptions):
const localStatement = parsedLua.body.find((body) => body.type === "LocalStatement") as LocalStatement | undefined;
if (localStatement) {
tableConstructorExpression = localStatement?.init.find((obj) => obj.type === "TableConstructorExpression") as TableConstructorExpression | undefined;
} else {
}
if (!tableConstructorExpression) {
const returnStatement = parsedLua.body.find((body) => body.type === "ReturnStatement") as ReturnStatement | undefined;
tableConstructorExpression = returnStatement?.arguments.find((obj) => obj.type === "TableConstructorExpression") as TableConstructorExpression | undefined;
}
Expand All @@ -48,10 +49,10 @@ function luaTableToObj(table: TableConstructorExpression): any {
obj[key] = (value.value as string | null) ?? value.raw.slice(1, -1);
} else if (value.type === "NumericLiteral" || value.type === "BooleanLiteral") {
obj[key] = value.value;
} else if (field.value.type === "TableConstructorExpression") {
obj[key] = luaTableToObj(field.value);
} else if (value.type === "TableConstructorExpression") {
obj[key] = luaTableToObj(value);
}
} else if (field.type === "TableValue") {
} else if (field.type === "TableValue" || field.type === "TableKey") {
if (field.value.type === "TableConstructorExpression") {
blocks.push(luaTableToObj(field.value));
} else if (field.value.type === "StringLiteral") {
Expand Down
9 changes: 9 additions & 0 deletions src/preload/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { GameVersion } from "@main/content/game/game-version";
import { MapData } from "@main/content/maps/map-data";
import { LuaOptionSection } from "@main/content/game/lua-options";
import { Scenario } from "@main/content/game/scenario";
import { Unit, UnitLanguage } from "@main/content/game/unit";
import { DownloadInfo } from "@main/content/downloads";
import { Info } from "@main/services/info.service";
import { NewsFeedData } from "@main/services/news.service";
Expand Down Expand Up @@ -84,6 +85,8 @@ const gameApi = {
downloadGame: (version: string): Promise<void> => ipcRenderer.invoke("game:downloadGame", version),
getGameOptions: (version: string): Promise<LuaOptionSection[]> => ipcRenderer.invoke("game:getOptions", version),
getScenarios: (): Promise<Scenario[]> => ipcRenderer.invoke("game:getScenarios"),
getUnits: (): Promise<Unit[]> => ipcRenderer.invoke("game:getUnits"),
getUnitLanguage: (locale: string): Promise<UnitLanguage> => ipcRenderer.invoke("game:getUnitLanguage", locale),
getInstalledVersions: (): Promise<GameVersion[]> => ipcRenderer.invoke("game:getInstalledVersions"),
isVersionInstalled: (version: string): Promise<boolean> => ipcRenderer.invoke("game:isVersionInstalled", version),
uninstallVersion: (version: GameVersion): Promise<void> => ipcRenderer.invoke("game:uninstallVersion", version),
Expand Down Expand Up @@ -116,6 +119,12 @@ const mapsApi = {
export type MapsApi = typeof mapsApi;
contextBridge.exposeInMainWorld("maps", mapsApi);

const unitsApi = {
fetchUnitImage: (imageSource: string): Promise<ArrayBuffer> => ipcRenderer.invoke("units:fetchUnitImage", imageSource),
};
export type UnitsApi = typeof unitsApi;
contextBridge.exposeInMainWorld("units", unitsApi);

const downloadsApi = {
// Events
// Engine
Expand Down
1 change: 1 addition & 0 deletions src/renderer/assets/assetFiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export const introVideos = import.meta.glob<string>("./videos/intros/*", { eager

// Images
export const backgroundImages = import.meta.glob<string>("./images/backgrounds/*", { eager: true, import: "default", query: "?url" });
export const factionIcons = import.meta.glob<string>("./images/icons/factions/*", { eager: true, import: "default", query: "?url" });

// Fonts
export const fontFiles = import.meta.glob<string>("./fonts/*", { eager: true, import: "default", query: "?url" });
Expand Down
Binary file added src/renderer/assets/images/icons/factions/arm.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/renderer/assets/images/icons/factions/cor.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 3 additions & 1 deletion src/renderer/components/controls/Options.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
<template>
<Control class="options">
<SelectButton v-bind="$attrs" />
<SelectButton v-bind="$attrs">
<template v-slot:option="scope"><slot name="option" v-bind="scope" /></template>
</SelectButton>
</Control>
</template>

Expand Down
4 changes: 2 additions & 2 deletions src/renderer/components/maps/MapOverviewCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,13 @@ const props = defineProps<{
const cache = useImageBlobUrlCache();

const mapSize = ref(props.map ? props.map.width + "x" + props.map.height : "Unknown");
const imageUrl = ref(props.map ? cache.get(props.map.fileName, props.map.images.texture) : defaultMiniMap);
const imageUrl = ref(props.map ? cache.base64(props.map.fileName, props.map.images.texture) : defaultMiniMap);

watch(
() => props.map,
() => {
mapSize.value = props.map ? props.map.width + "x" + props.map.height : "Unknown";
imageUrl.value = props.map ? cache.get(props.map.fileName, props.map.images.texture) : defaultMiniMap;
imageUrl.value = props.map ? cache.base64(props.map.fileName, props.map.images.texture) : defaultMiniMap;
}
);
</script>
Expand Down
4 changes: 2 additions & 2 deletions src/renderer/components/misc/NewsTile.vue
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ const props = defineProps<{
featured?: boolean;
}>();

const { get } = useImageBlobUrlCache();
const backgroundImageCss = ref(`url('${get(props.news.thumbnailUrl, props.news.thumbnail)}')`);
const cache = useImageBlobUrlCache();
const backgroundImageCss = ref(`url('${cache.base64(props.news.thumbnailUrl, props.news.thumbnail)}')`);

const openNews = () => {
window.shell.openInBrowser(props.news.link);
Expand Down
2 changes: 2 additions & 0 deletions src/renderer/components/misc/Preloader.vue
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { computed, onMounted, ref } from "vue";
import Progress from "@renderer/components/common/Progress.vue";
import { audioApi } from "@renderer/audio/audio";
import { backgroundImages, fontFiles } from "@renderer/assets/assetFiles";
import { fetchMissingUnitImages } from "@renderer/store/units.store";

const emit = defineEmits(["complete"]);

Expand All @@ -30,6 +31,7 @@ onMounted(async () => {
await loadFont(fontFile);
loadedFiles.value++;
}
await fetchMissingUnitImages();
} catch (error) {
console.error(`Failed to load fonts: `, error);
}
Expand Down
Loading