This commit is contained in:
Brian Beck 2025-11-12 19:22:29 -08:00
parent 7de0355f3a
commit ee6ff28af5
11 changed files with 157 additions and 95 deletions

1
.nvmrc Normal file
View file

@ -0,0 +1 @@
24.0

View file

@ -1,8 +1,8 @@
import "./style.css"; import "./style.css";
export const metadata = { export const metadata = {
title: "Tribes 2 Maps", title: "MapGenius  Explore maps for Tribes 2",
description: "Be the map genius.", description: "Tribes 2 forever.",
}; };
export default function RootLayout({ export default function RootLayout({

View file

@ -13,6 +13,7 @@
"deploy": "npm run build && git add -f docs && git commit -m \"Deploy\" && git push", "deploy": "npm run build && git add -f docs && git commit -m \"Deploy\" && git push",
"prebuild": "npm run clean && git checkout -- docs && rimraf public/base && mv docs/base public/", "prebuild": "npm run clean && git checkout -- docs && rimraf public/base && mv docs/base public/",
"postbuild": "git checkout -- public/base", "postbuild": "git checkout -- public/base",
"manifest": "tsx scripts/manifest.ts",
"start": "next dev" "start": "next dev"
}, },
"dependencies": { "dependencies": {

View file

@ -32,28 +32,8 @@ if not enabled:
mods = [m.__name__ for m in addon_utils.modules()] mods = [m.__name__ for m in addon_utils.modules()]
die(f"Could not enable '{addon_mod}'. Installed add-ons: {mods}") die(f"Could not enable '{addon_mod}'. Installed add-ons: {mods}")
# ---- resolve importer (import_scene.* or wm.*) ----
def list_ops(ns):
try: return [n for n in dir(ns) if not n.startswith("_")]
except: return []
def resolve_operator():
if forced_op:
mod, name = forced_op.split(".", 1)
return forced_op, getattr(getattr(bpy.ops, mod), name)
# auto-discover
for modname in ("import_scene", "wm"):
names = list_ops(getattr(bpy.ops, modname))
hits = [n for n in names if "dif" in n.lower()] or [n for n in names if "torque" in n.lower()]
if len(hits) == 1:
name = hits[0]
return f"{modname}.{name}", getattr(getattr(bpy.ops, modname), name)
if len(hits) > 1:
raise RuntimeError(f"Multiple candidates: {[f'{modname}.{h}' for h in hits]}. Use --op.")
raise RuntimeError("No DIF-like importer found.")
try: try:
op_id, op_call = resolve_operator() op_id, op_call = "import_scene.dif", bpy.ops.import_scene.dif
except Exception as e: except Exception as e:
die(str(e)) die(str(e))
@ -66,7 +46,7 @@ if "FINISHED" not in res: die(f"Import failed via {op_id}: {in_path}")
# ---- export ---- # ---- export ----
res = bpy.ops.export_scene.gltf( res = bpy.ops.export_scene.gltf(
filepath=out_path, filepath=out_path,
export_format=export_format, # GLB | GLTF_SEPARATE export_format=export_format, # GLB | GLTF_SEPARATE
use_selection=False, use_selection=False,
export_apply=True, export_apply=True,
) )

View file

@ -0,0 +1,53 @@
# dts2gltf.py
import bpy, sys, os, addon_utils
def die(msg, code=2):
print(f"[dts2gltf] ERROR: {msg}", file=sys.stderr); sys.exit(code)
# ---- args ----
argv = sys.argv
if "--" not in argv: die("Usage: blender -b -P dts2gltf.py -- <in.dts> <out.glb|.gltf> [--addon io_scene_dts] [--format GLB|GLTF_SEPARATE]")
argv = argv[argv.index("--")+1:]
if len(argv) < 2: die("Need <in.dts> and <out.glb|.gltf>")
in_path, out_path = map(os.path.abspath, argv[:2])
addon_mod = "io_scene_dts"
forced_op = None
export_format = "GLTF_SEPARATE"
i = 2
while i < len(argv):
if argv[i] == "--addon" and i+1 < len(argv): addon_mod = argv[i+1]; i += 2
elif argv[i] == "--format" and i+1 < len(argv): export_format = argv[i+1]; i += 2
else: die(f"Unknown arg: {argv[i]}")
if not os.path.isfile(in_path): die(f"Input not found: {in_path}")
# ---- reset FIRST (so we don't lose the add-on afterward) ----
bpy.ops.wm.read_factory_settings(use_empty=True)
# ---- enable add-on ----
addon_utils.enable(addon_mod, default_set=True, handle_error=None)
loaded, enabled = addon_utils.check(addon_mod)
if not enabled:
mods = [m.__name__ for m in addon_utils.modules()]
die(f"Could not enable '{addon_mod}'. Installed add-ons: {mods}")
try:
op_id, op_call = "import_scene.dts", bpy.ops.import_scene.dts
except Exception as e:
die(str(e))
print(f"[dts2gltf] Using importer: {op_id}")
# ---- import ----
res = op_call(filepath=in_path)
if "FINISHED" not in res: die(f"Import failed via {op_id}: {in_path}")
# ---- export ----
res = bpy.ops.export_scene.gltf(
filepath=out_path,
export_format=export_format, # GLB | GLTF_SEPARATE
use_selection=False,
export_apply=True,
)
if "FINISHED" not in res: die(f"Export failed: {out_path}")
print(f"[dts2gltf] OK: {in_path} -> {out_path}")

27
scripts/convert-dif.ts Normal file
View file

@ -0,0 +1,27 @@
import fs from "node:fs/promises";
import { execFileSync } from "node:child_process";
const blender = `/Applications/Blender.app/Contents/MacOS/Blender`;
/**
* Find all .dif files in `public/base` and convert them to glTF.
*/
async function run() {
for await (const inFile of fs.glob("public/base/**/*.dif")) {
const outFile = inFile.replace(/\.dif$/i, ".gltf");
execFileSync(
blender,
[
"--background",
"--python",
"scripts/blender/dif2gltf.py",
"--", // args after here go to the script
inFile,
outFile,
],
{ stdio: "inherit" }
);
}
}
run();

33
scripts/convert-dts.ts Normal file
View file

@ -0,0 +1,33 @@
import fs from "node:fs/promises";
import { execFileSync } from "node:child_process";
const blender = `/Applications/Blender.app/Contents/MacOS/Blender`;
/**
* Find all .dts files in `public/base` and convert them to glTF.
*/
async function run() {
for await (const inFile of fs.glob("public/base/**/*.dts")) {
const outFile = inFile.replace(/\.dts$/i, ".gltf");
execFileSync(
blender,
[
"--background",
"--python",
"scripts/blender/dts2gltf.py",
"--", // args after here go to the script
inFile,
outFile,
"--format",
"gltf",
// "--scale",
// "1.0",
// "--no-anims",
// "--only-visible",
],
{ stdio: "inherit" }
);
}
}
run();

View file

@ -1,17 +0,0 @@
import fs from "node:fs/promises";
import { execFileSync } from "node:child_process";
const blender = `/Applications/Blender.app/Contents/MacOS/Blender`;
async function run() {
for await (const inFile of fs.glob("public/**/*.dif")) {
const outFile = inFile.replace(/\.dif$/i, ".gltf");
execFileSync(
blender,
["-b", "-P", "scripts/dif2gltf.py", "--", inFile, outFile],
{ stdio: "inherit" }
);
}
}
run();

View file

@ -2,7 +2,7 @@ import fs from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { parseArgs } from "node:util"; import { parseArgs } from "node:util";
import unzipper from "unzipper"; import unzipper from "unzipper";
import { normalize } from "@/src/stringUtils"; import { normalizePath } from "@/src/stringUtils";
const archiveFilePattern = /\.vl2$/i; const archiveFilePattern = /\.vl2$/i;
@ -12,6 +12,33 @@ function isArchive(name: string) {
return archiveFilePattern.test(name); return archiveFilePattern.test(name);
} }
/**
* Log and return the manifest of files for the given game asset directory.
* The assets used to build the mapper are a filtered set of relevant files
* (map related assets) from the `Tribes2/GameData/base` folder. The manifest
* consists of the set of unique paths (case sensitive!) represented by the file
* tree AND the vl2 files as if they had been unzipped. Thus, each file in the
* manifest can have one or more "sources". If the file appears outside of a vl2,
* it will have a blank source (the empty string) first. Each vl2 containing the
* file will then be listed in order. To resolve an asset, the engine uses a
* layering approach where paths inside lexicographically-higher vl2 files win
* over the same path outside of a vl2 or in a lexicographically-lower vl2 file.
* So, to choose the same final asset as the engine, choose the last source in
* the list for any given path.
*
* Example:
*
* ```
* {
* "textures/terrainTiles/green.png": ["textures.vl2"],
* "textures/lava/ds_iwal01a.png": [
* "lava.vl2",
* "yHDTextures2.0.vl2",
* "zAddOnsVL2s/zDiscord-Map-Pack-4.7.1.vl2"
* ]
* }
* ```
*/
async function buildManifest() { async function buildManifest() {
const fileSources = new Map<string, string[]>(); const fileSources = new Map<string, string[]>();
@ -21,7 +48,7 @@ async function buildManifest() {
withFileTypes: true, withFileTypes: true,
})) { })) {
if (entry.isFile()) { if (entry.isFile()) {
const fullPath = normalize(`${entry.parentPath}/${entry.name}`); const fullPath = normalizePath(`${entry.parentPath}/${entry.name}`);
if (isArchive(entry.name)) { if (isArchive(entry.name)) {
archiveFiles.push(fullPath); archiveFiles.push(fullPath);
} else { } else {
@ -31,17 +58,17 @@ async function buildManifest() {
} }
for (const filePath of looseFiles) { for (const filePath of looseFiles) {
const relativePath = normalize(path.relative(baseDir, filePath)); const relativePath = normalizePath(path.relative(baseDir, filePath));
fileSources.set(relativePath, [""]); fileSources.set(relativePath, [""]);
} }
archiveFiles.sort(); archiveFiles.sort();
for (const archivePath of archiveFiles) { for (const archivePath of archiveFiles) {
const relativePath = normalize(path.relative(baseDir, archivePath)); const relativePath = normalizePath(path.relative(baseDir, archivePath));
const archive = await unzipper.Open.file(archivePath); const archive = await unzipper.Open.file(archivePath);
for (const archiveEntry of archive.files) { for (const archiveEntry of archive.files) {
if (archiveEntry.type === "File") { if (archiveEntry.type === "File") {
const filePath = normalize(archiveEntry.path); const filePath = normalizePath(archiveEntry.path);
const sources = fileSources.get(filePath) ?? []; const sources = fileSources.get(filePath) ?? [];
sources.push(relativePath); sources.push(relativePath);
fileSources.set(filePath, sources); fileSources.set(filePath, sources);

View file

@ -1,48 +0,0 @@
export function rotateHeightMap(
src: Uint16Array,
width: number,
height: number,
degrees: 90 | 180 | 270
) {
let outW: number;
let outH: number;
switch (degrees) {
case 90:
case 270:
outW = height;
outH = width;
break;
case 180:
outW = width;
outH = height;
break;
}
const out = new Uint16Array(outW * outH);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const val = src[y * width + x];
let nx, ny;
switch (degrees) {
case 90:
nx = height - 1 - y;
ny = x;
break;
case 180:
nx = width - 1 - x;
ny = height - 1 - y;
break;
case 270:
nx = y;
ny = width - 1 - x;
}
out[ny * outW + nx] = val;
}
}
return out;
}

View file

@ -1,3 +1,8 @@
export function normalize(pathString: string) { /**
* Normalizes a path string, but not as complicated as Node's `path.normalize`.
* This simply changes all backslashes to `/` (regardless of platform) and
* collapses any adjacent slashes to a single slash.
*/
export function normalizePath(pathString: string) {
return pathString.replace(/\\/g, "/").replace(/\/+/g, "/"); return pathString.replace(/\\/g, "/").replace(/\/+/g, "/");
} }