arx-level-generator
Version:
A tool for creating Arx Fatalis maps
478 lines (477 loc) • 18.8 kB
JavaScript
import fs from 'node:fs/promises';
import path from 'node:path';
import { AMB } from 'arx-convert';
import { getCellCoords, MAP_DEPTH_IN_CELLS, MAP_WIDTH_IN_CELLS } from 'arx-convert/utils';
import { Audio } from './Audio.js';
import { Entities } from './Entities.js';
import { Entity } from './Entity.js';
import { Fog } from './Fog.js';
import { Fogs } from './Fogs.js';
import { HUD } from './HUD.js';
import { LevelLoader } from './LevelLoader.js';
import { Light } from './Light.js';
import { Lights } from './Lights.js';
import { Manifest } from './Manifest.js';
import { generateMetadata } from './MetaData.js';
import { Path } from './Path.js';
import { Paths } from './Paths.js';
import { Player } from './Player.js';
import { Polygon } from './Polygon.js';
import { Polygons } from './Polygons.js';
import { Portal } from './Portal.js';
import { Rotation } from './Rotation.js';
import { Script } from './Script.js';
import { $ } from './Selection.js';
import { Translations } from './Translations.js';
import { UI } from './UI.js';
import { Vector3 } from './Vector3.js';
import { Zone } from './Zone.js';
import { Zones } from './Zones.js';
import { compile } from './compile.js';
import { MapFinalizedError, MapNotFinalizedError } from './errors.js';
import { times, uniq } from './faux-ramda.js';
import { getGeneratorPackageJSON, latin9ToLatin1 } from './helpers.js';
export class ArxMap {
polygons = new Polygons();
lights = new Lights();
fogs = new Fogs();
entities = new Entities();
zones = new Zones();
paths = new Paths();
player = new Player();
portals = []; // TODO: create Portals class
config = {
isFinalized: false,
offset: new Vector3(0, 0, 0),
};
hud = new HUD();
ui = new UI();
i18n = new Translations();
todo = {
uniqueHeaders: [],
cells: times(() => ({}), MAP_DEPTH_IN_CELLS * MAP_WIDTH_IN_CELLS),
anchors: [],
rooms: [
{ portals: [], polygons: [] },
{ portals: [], polygons: [] },
],
roomDistances: [
{
distance: -1,
startPosition: { x: 0, y: 0, z: 0 },
endPosition: { x: 1, y: 0, z: 0 },
},
{
distance: -1,
startPosition: { x: 0, y: 0, z: 0 },
endPosition: { x: 0, y: 1, z: 0 },
},
{
distance: -1,
startPosition: { x: 0.984375, y: 0.984375, z: 0 },
endPosition: { x: 0, y: 0, z: 0 },
},
{
distance: -1,
startPosition: { x: 0, y: 0, z: 0 },
endPosition: { x: 0, y: 0, z: 0 },
},
],
};
constructor(dlf, fts, llf, areNormalsCalculated = false) {
if (typeof dlf === 'undefined' || typeof fts === 'undefined' || typeof llf === 'undefined') {
return;
}
this.player.orientation = Rotation.fromArxRotation(dlf.header.angleEdit);
this.player.position = Vector3.fromArxVector3(dlf.header.posEdit);
dlf.interactiveObjects.forEach((entity) => {
this.entities.push(Entity.fromArxInteractiveObject(entity));
});
dlf.fogs.forEach((fog) => {
this.fogs.push(Fog.fromArxFog(fog));
});
dlf.zones.forEach((zone) => {
this.zones.push(Zone.fromArxZone(zone));
});
dlf.paths.forEach((path) => {
this.paths.push(Path.fromArxPath(path));
});
fts.polygons.forEach((polygon) => {
this.polygons.push(Polygon.fromArxPolygon(polygon, llf.colors, fts.textureContainers, areNormalsCalculated));
});
this.portals = fts.portals.map(Portal.fromArxPortal);
llf.lights.forEach((light) => {
this.lights.push(Light.fromArxLight(light));
});
// TODO: deal with these stuff later
this.todo.uniqueHeaders = fts.uniqueHeaders;
this.config.offset = Vector3.fromArxVector3(fts.sceneHeader.mScenePosition);
this.todo.cells = fts.cells;
this.todo.anchors = fts.anchors;
this.todo.rooms = fts.rooms;
this.todo.roomDistances = fts.roomDistances;
}
async toArxData(settings) {
const now = Math.floor(Date.now() / 1000);
const generatorId = await ArxMap.getGeneratorId();
const dlf = {
header: {
lastUser: generatorId,
time: now,
posEdit: this.player.position.toArxVector3(),
angleEdit: this.player.orientation.toArxRotation(),
numberOfBackgroundPolygons: this.polygons.length,
},
scene: {
levelIdx: settings.levelIdx,
},
...this.fogs.toArxData(),
...this.paths.toArxData(),
...this.zones.toArxData(),
...this.entities.toArxData(),
};
const fts = {
header: {
levelIdx: settings.levelIdx,
},
uniqueHeaders: this.todo.uniqueHeaders,
sceneHeader: {
mScenePosition: this.config.offset.toArxVector3(),
},
cells: this.todo.cells,
anchors: this.todo.anchors,
portals: this.portals.map((portal) => portal.toArxPortal()),
rooms: this.todo.rooms,
roomDistances: this.todo.roomDistances,
...(await this.polygons.toArxData()),
};
const llf = {
header: {
lastUser: generatorId,
time: now,
numberOfBackgroundPolygons: this.polygons.length,
},
colors: this.polygons.getVertexColors(),
...this.lights.toArxData(),
};
return {
dlf,
fts,
llf,
};
}
/**
* Loads one of the levels found in the original game
*
* Requires the pkware-test-files repo
* @see https://github.com/meszaros-lajos-gyorgy/pkware-test-files
*/
static async fromOriginalLevel(levelIdx, settings) {
const loader = new LevelLoader(levelIdx, settings);
const dlf = await loader.readDlf();
const fts = await loader.readFts();
const llf = await loader.readLlf();
return new ArxMap(dlf, fts, llf, true);
}
static fromThreeJsMesh(threeJsObj, meshImportProps) {
const map = new ArxMap();
map.polygons.addThreeJsMesh(threeJsObj, meshImportProps);
return map;
}
static async getGeneratorId() {
const generator = await getGeneratorPackageJSON();
return `${generator.name} - v.${generator.version}`;
}
finalize() {
if (this.config.isFinalized) {
throw new MapFinalizedError();
}
const removedPolygons = $(this.polygons).clearSelection().selectOutOfBounds().delete();
if (removedPolygons.length > 0) {
console.warn(`[warning] ArxMap: Removed ${removedPolygons.length} polygons what are outside the 0..16000 boundary on the X or Z axis`);
}
this.polygons.forEach((polygon) => {
polygon.calculateNormals();
polygon.calculateArea();
});
this.calculateRoomData();
this.config.isFinalized = true;
}
removePortals() {
if (this.config.isFinalized) {
throw new MapFinalizedError();
}
this.portals = [];
this.todo.rooms.forEach((room) => {
room.portals = [];
});
this.todo.roomDistances = [
{
distance: -1,
startPosition: { x: 0, y: 0, z: 0 },
endPosition: { x: 1, y: 0, z: 0 },
},
{
distance: -1,
startPosition: { x: 0, y: 0, z: 0 },
endPosition: { x: 0, y: 1, z: 0 },
},
{
distance: -1,
startPosition: { x: 0.984375, y: 0.984375, z: 0 },
endPosition: { x: 0, y: 0, z: 0 },
},
{
distance: -1,
startPosition: { x: 0, y: 0, z: 0 },
endPosition: { x: 0, y: 0, z: 0 },
},
];
this.movePolygonsToSameRoom();
}
movePolygonsToSameRoom() {
$(this.polygons).selectAll().moveToRoom1();
this.todo.rooms = this.todo.rooms.slice(0, 2);
this.todo.roomDistances = [
{
distance: -1,
startPosition: { x: 0, y: 0, z: 0 },
endPosition: { x: 1, y: 0, z: 0 },
},
{
distance: -1,
startPosition: { x: 0, y: 0, z: 0 },
endPosition: { x: 0, y: 1, z: 0 },
},
{
distance: -1,
startPosition: { x: 0.984375, y: 0.984375, z: 0 },
endPosition: { x: 0, y: 0, z: 0 },
},
{
distance: -1,
startPosition: { x: 0, y: 0, z: 0 },
endPosition: { x: 0, y: 0, z: 0 },
},
];
}
calculateRoomData = () => {
this.todo.rooms.forEach((room) => {
room.polygons = [];
});
const polygonsPerCellCounter = {};
this.polygons.forEach((polygon) => {
if (polygon.room < 1) {
return;
}
const vertices = polygon.vertices.map((vertex) => vertex.toArxVertex());
const [cellX, cellY] = getCellCoords(vertices);
const key = `${cellX}|${cellY}`;
if (key in polygonsPerCellCounter) {
polygonsPerCellCounter[key] += 1;
}
else {
polygonsPerCellCounter[key] = 0;
}
this.todo.rooms[polygon.room].polygons.push({ cellX, cellY, polygonIdx: polygonsPerCellCounter[key] });
});
};
async saveToDisk(settings, exportJsonFiles = false, prettify = false) {
if (!this.config.isFinalized) {
throw new MapNotFinalizedError();
}
console.log(`[info] ArxMap: seed = "${settings.seed}"`);
console.log(`[info] ArxMap: output directory = "${settings.outputDir}"`);
await Manifest.uninstall(settings);
const meta = await generateMetadata(settings);
// ------------------------
let textures = await this.polygons.exportTextures(settings);
const hudElements = this.hud.exportSourcesAndTargets(settings);
const uiElements = this.ui.exportSourcesAndTargets(settings);
const ambienceTracks = this.zones.reduce((acc, zone) => {
if (!zone.hasAmbience() || zone.ambience.isNative) {
return acc;
}
zone.ambience.exportSourcesAndTargets(settings).forEach(([source, target]) => {
acc[target] = source;
});
return acc;
}, {});
const customAmbiences = this.zones.reduce((acc, zone) => {
if (!zone.hasAmbience() || zone.ambience.isNative) {
return acc;
}
return {
...acc,
...zone.ambience.toArxData(settings),
};
}, {});
const scripts = {};
const models = {};
const otherDependencies = {};
for (const entity of this.entities) {
if (entity.hasScript()) {
scripts[entity.exportScriptTarget(settings)] = entity.script.toArxData();
const texturesToExport = await entity.script.exportTextures(settings);
for (const target in texturesToExport) {
textures[target] = texturesToExport[target];
}
}
if (entity.hasInventoryIcon()) {
const texturesToExport = await entity.exportInventoryIcon(settings);
for (const target in texturesToExport) {
textures[target] = texturesToExport[target];
}
}
if (entity.hasModel()) {
const modelToExport = await entity.model.exportSourceAndTarget(settings, entity.src, exportJsonFiles, prettify);
for (const target in modelToExport) {
models[target] = modelToExport[target];
}
}
const dependenciesToExport = await entity.exportOtherDependencies(settings);
for (const target in dependenciesToExport) {
otherDependencies[target] = dependenciesToExport[target];
}
}
const sounds = Audio.exportReplacements(settings);
// removing root entities while also making sure the entities land in an Entities object and not in an array
this.entities = this.entities
.filter((entity) => {
return !entity.hasScript() || !entity.script.isRoot;
})
.reduce((acc, entity) => {
acc.push(entity);
return acc;
}, new Entities());
if (this.player.hasScript()) {
scripts[this.player.exportTarget(settings)] = this.player.script.toArxData();
textures = {
...textures,
...(await this.player.script.exportTextures(settings)),
};
}
const translations = this.i18n.exportSourcesAndTargets(settings);
const files = {
dlf: path.resolve(settings.outputDir, `graph/levels/level${settings.levelIdx}/level${settings.levelIdx}.dlf.json`),
fts: path.resolve(settings.outputDir, `game/graph/levels/level${settings.levelIdx}/fast.fts.json`),
llf: path.resolve(settings.outputDir, `graph/levels/level${settings.levelIdx}/level${settings.levelIdx}.llf.json`),
};
const pathsOfTheFiles = [
...Object.keys(textures),
...Object.keys(models),
...Object.keys(otherDependencies),
...Object.keys(sounds),
...Object.keys(hudElements),
...Object.keys(uiElements),
...Object.keys(ambienceTracks),
...Object.keys(customAmbiences).map((filename) => filename.replace(/\.json$/, '')),
...Object.keys(scripts),
...Object.keys(translations),
...Object.values(files).map((filename) => filename.replace(/\.json$/, '')),
];
const dirnames = uniq(pathsOfTheFiles.map(path.dirname.bind(path)));
for (const dirname of dirnames) {
await fs.mkdir(dirname, { recursive: true });
}
// ------------------------
const filesToCopy = [
...Object.entries(textures),
...Object.entries(hudElements),
...Object.entries(uiElements),
...Object.entries(ambienceTracks),
...Object.entries(models),
...Object.entries(otherDependencies),
...Object.entries(sounds),
];
for (const [target, source] of filesToCopy) {
await fs.copyFile(source, target);
}
// ------------------------
for (const [target, script] of Object.entries(scripts)) {
const latin1Script = latin9ToLatin1(script.replace(/\n/g, Script.EOL));
await fs.writeFile(target, latin1Script, 'latin1');
}
const generatorId = await ArxMap.getGeneratorId();
for (const [filename, translation] of Object.entries(translations)) {
await fs.writeFile(filename, `// ${meta.name} v.${meta.version} - ${generatorId}
${translation}`, 'utf8');
}
// ------------------------
const { dlf, fts, llf } = await this.toArxData(settings);
if (exportJsonFiles) {
const stringifiedDlf = prettify ? JSON.stringify(dlf, null, 2) : JSON.stringify(dlf);
await fs.writeFile(files.dlf, stringifiedDlf);
const stringifiedFts = prettify ? JSON.stringify(fts, null, 2) : JSON.stringify(fts);
await fs.writeFile(files.fts, stringifiedFts);
const stringifiedLlf = prettify ? JSON.stringify(llf, null, 2) : JSON.stringify(llf);
await fs.writeFile(files.llf, stringifiedLlf);
for (const [target, amb] of Object.entries(customAmbiences)) {
const stringifiedAmb = prettify ? JSON.stringify(amb, null, 2) : JSON.stringify(amb);
await fs.writeFile(target, stringifiedAmb);
}
pathsOfTheFiles.push(...Object.keys(customAmbiences));
pathsOfTheFiles.push(...Object.values(files));
}
for (const [target, amb] of Object.entries(customAmbiences)) {
const compiledAmb = AMB.save(amb);
await fs.writeFile(target.replace(/\.json$/, ''), compiledAmb);
}
await compile(settings, { dlf, fts, llf });
// ------------------------
await Manifest.write(settings, pathsOfTheFiles);
}
adjustOffsetTo(map) {
const offsetDifference = map.config.offset.clone().sub(this.config.offset);
$(this.polygons).selectAll().move(offsetDifference);
}
move(offset) {
if (this.config.isFinalized) {
throw new MapFinalizedError();
}
$(this.polygons).selectAll().move(offset);
$(this.entities).selectAll().move(offset);
$(this.lights).selectAll().move(offset);
$(this.fogs).selectAll().move(offset);
$(this.paths).selectAll().move(offset);
$(this.zones).selectAll().move(offset);
// anchors
this.todo.anchors.forEach((anchor) => {
anchor.data.pos.x += offset.x;
anchor.data.pos.y += offset.y;
anchor.data.pos.z += offset.z;
});
}
add(map, alignPolygons = false) {
if (this.config.isFinalized) {
throw new MapFinalizedError();
}
if (alignPolygons) {
map.adjustOffsetTo(this);
}
map.polygons.forEach((polygon) => {
this.polygons.push(polygon);
});
map.lights.forEach((light) => {
this.lights.push(light);
});
map.fogs.forEach((fog) => {
this.fogs.push(fog);
});
map.entities.forEach((entity) => {
this.entities.push(entity);
});
map.paths.forEach((path) => {
this.paths.push(path);
});
map.zones.forEach((zone) => {
this.zones.push(zone);
});
// map.anchors.forEach((anchor) => {
// this.anchors.push(anchor)
// })
// TODO: adjust fts anchor linked anchor indices
// TODO: adjust fts polygon texture container ids
}
}
//# sourceMappingURL=ArxMap.js.map