Vehicles
This commit is contained in:
+141
-19
@@ -20,44 +20,163 @@ async function fetchBuffer(url) {
|
||||
return res.arrayBuffer();
|
||||
}
|
||||
|
||||
function render(name, dffBuffer, txdBuffer) {
|
||||
const dff = parseDFF(dffBuffer);
|
||||
const txd = txdBuffer ? parseTXD(txdBuffer) : null;
|
||||
const report = viewer.load(dff, txd);
|
||||
async function fetchJSON(url) {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`${res.status} fetching ${url}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// A vehicle clump is recognisable by its frame names: every one of them has a
|
||||
// chassis and a set of wheel dummies.
|
||||
function looksLikeVehicle(dff) {
|
||||
return dff.frames.some((frame) => /^(chassis|wheel_[lr][fmb]_dummy)$/i.test(frame.name));
|
||||
}
|
||||
|
||||
// Paint comes from the url (?c1=3&c2=1, carcols palette indices, the same
|
||||
// numbers ChangeVehicleColor takes) and falls back to the vehicle's first
|
||||
// factory combo. An index with no entry is left for the viewer to default.
|
||||
function resolvePaint(catalogue, id) {
|
||||
if (!catalogue) return null;
|
||||
const params = new URLSearchParams(location.search);
|
||||
const info = catalogue.vehicles && catalogue.vehicles[id];
|
||||
const factory = (info && info.colors && info.colors[0]) || [];
|
||||
|
||||
return [0, 1, 2, 3].map((slot) => {
|
||||
const given = params.get(`c${slot + 1}`);
|
||||
const index = given !== null && given !== '' ? Number(given) : factory[slot];
|
||||
return Number.isFinite(index) ? catalogue.palette[index] || null : null;
|
||||
});
|
||||
}
|
||||
|
||||
// Parts come from ?u=1010,1008 - component ids, the same numbers
|
||||
// AddVehicleComponent takes. Fitting a left hand body kit part fits its right
|
||||
// hand twin too, which is what carmods' link section is for.
|
||||
async function resolveUpgrades(meta, catalogue) {
|
||||
const raw = new URLSearchParams(location.search).get('u');
|
||||
if (!raw || !catalogue || !catalogue.upgrades) return [];
|
||||
|
||||
const wanted = [];
|
||||
for (const field of raw.split(',')) {
|
||||
const id = Number(field.trim());
|
||||
if (!Number.isFinite(id)) continue;
|
||||
wanted.push(id);
|
||||
if (catalogue.links[id] !== undefined) wanted.push(catalogue.links[id]);
|
||||
}
|
||||
|
||||
const parts = await Promise.all(
|
||||
[...new Set(wanted)]
|
||||
.filter((id) => catalogue.upgrades[id])
|
||||
.map(async (id) => ({
|
||||
id,
|
||||
name: catalogue.upgrades[id].name,
|
||||
dff: parseDFF(await fetchBuffer(`${meta.base}/${id}.dff`)),
|
||||
}))
|
||||
);
|
||||
return parts;
|
||||
}
|
||||
|
||||
// Paintjobs are numbered from zero, the way ChangeVehiclePaintjob counts them.
|
||||
// Anything outside the vehicle's range means no paintjob, which is what 3 does
|
||||
// in game.
|
||||
async function fetchPaintjob(meta, catalogue, id, index) {
|
||||
const info = catalogue && catalogue.vehicles && catalogue.vehicles[id];
|
||||
if (!info || !Number.isFinite(index) || index < 0 || index >= info.paintjobs) return null;
|
||||
return parseTXD(await fetchBuffer(`${meta.base}/${id}_pj${index}.txd`));
|
||||
}
|
||||
|
||||
function render(name, dffBuffer, txdBuffers, options = {}) {
|
||||
const dff = parseDFF(dffBuffer);
|
||||
const txds = txdBuffers.filter(Boolean).map((buffer) => parseTXD(buffer));
|
||||
const report = viewer.load(dff, txds, options);
|
||||
|
||||
const textureCount = txds.reduce((total, txd) => total + txd.textures.size, 0);
|
||||
console.log(
|
||||
`${name}: rw ${dff.versionText}, ${report.meshes} mesh(es), ` +
|
||||
`${report.vertices} verts, ${report.triangles} tris, ` +
|
||||
`${txd ? txd.textures.size : 0} textures`
|
||||
`${textureCount} textures${options.vehicle ? ', vehicle' : ''}` +
|
||||
(options.paintjobs ? `, ${options.paintjobs} paintjob(s)` : '')
|
||||
);
|
||||
const warnings = [...dff.errors, ...(txd ? txd.errors : [])];
|
||||
const warnings = [...dff.errors, ...txds.flatMap((txd) => txd.errors)];
|
||||
if (report.missingTextures.size) {
|
||||
warnings.push('not in txd: ' + [...report.missingTextures].join(', '));
|
||||
}
|
||||
warnings.forEach((warning) => console.warn(warning));
|
||||
|
||||
setError('');
|
||||
return dff;
|
||||
}
|
||||
|
||||
async function loadSkin(id) {
|
||||
async function loadModel(kind, id) {
|
||||
try {
|
||||
const res = await fetch(`/api/skin/${encodeURIComponent(id)}`);
|
||||
const res = await fetch(`/api/${kind}/${encodeURIComponent(id)}`);
|
||||
const meta = await res.json();
|
||||
if (!res.ok) {
|
||||
setError(meta.error || `${res.status} loading ${id}`);
|
||||
return;
|
||||
}
|
||||
const [dffBuffer, txdBuffer] = await Promise.all([
|
||||
|
||||
// The shared dictionary and the carcols catalogue are optional: a missing
|
||||
// one costs textures or factory colours, not the render.
|
||||
const [dffBuffer, txdBuffer, sharedBuffer, catalogue] = await Promise.all([
|
||||
fetchBuffer(meta.dff),
|
||||
meta.txd ? fetchBuffer(meta.txd) : Promise.resolve(null),
|
||||
meta.txd ? fetchBuffer(meta.txd) : null,
|
||||
meta.shared ? fetchBuffer(meta.shared).catch(() => null) : null,
|
||||
meta.meta ? fetchJSON(meta.meta).catch(() => null) : null,
|
||||
]);
|
||||
render(id, dffBuffer, txdBuffer);
|
||||
|
||||
const vehicle = kind === 'vehicle';
|
||||
const info = (vehicle && catalogue && catalogue.vehicles[id]) || null;
|
||||
const wanted = new URLSearchParams(location.search).get('pj');
|
||||
// The vehicle's own textures win over the shared ones.
|
||||
render(id, dffBuffer, [txdBuffer, sharedBuffer], {
|
||||
vehicle,
|
||||
paint: vehicle ? resolvePaint(catalogue, id) : null,
|
||||
upgrades: vehicle ? await resolveUpgrades(meta, catalogue) : null,
|
||||
wheelScale: info ? info.wheelScale : 1,
|
||||
paintjob:
|
||||
vehicle && wanted !== null && wanted !== ''
|
||||
? await fetchPaintjob(meta, catalogue, id, Number(wanted))
|
||||
: null,
|
||||
paintjobs: info ? info.paintjobs : 0,
|
||||
});
|
||||
|
||||
if (vehicle && catalogue) {
|
||||
// Repainting needs no reload, so leave the palette and the viewer within
|
||||
// reach of the console: setColors(3, 1) is ChangeVehicleColor(v, 3, 1).
|
||||
window.setColors = (...ids) =>
|
||||
viewer.setPaint(ids.map((index) => catalogue.palette[index] || null));
|
||||
window.carcols = catalogue;
|
||||
window.viewer = viewer;
|
||||
// setPaintjob(0) is ChangeVehiclePaintjob(v, 0); anything out of range
|
||||
// takes it back off, the way 3 does in game.
|
||||
window.setPaintjob = async (index) =>
|
||||
viewer.applyPaintjob(await fetchPaintjob(meta, catalogue, id, index));
|
||||
// What ?u= accepts here, as id -> part name. Wheels are listed for every
|
||||
// vehicle because any wheel fits anything.
|
||||
window.mods = Object.fromEntries(
|
||||
((info && info.mods) || [])
|
||||
.concat(catalogue.wheels || [])
|
||||
.filter((partId) => catalogue.upgrades && catalogue.upgrades[partId])
|
||||
.map((partId) => [partId, catalogue.upgrades[partId].name])
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Dropped vehicles get the shared dictionary fetched for them, otherwise most
|
||||
// of the body renders untextured.
|
||||
async function sharedVehicleTxd() {
|
||||
try {
|
||||
const meta = await fetchJSON('/api/vehicle/411');
|
||||
return meta.shared ? await fetchBuffer(meta.shared) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function setupDragAndDrop() {
|
||||
document.body.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
@@ -69,17 +188,20 @@ function setupDragAndDrop() {
|
||||
document.body.classList.remove('dragging');
|
||||
const files = [...e.dataTransfer.files];
|
||||
const dffFile = files.find((f) => f.name.toLowerCase().endsWith('.dff'));
|
||||
const txdFile = files.find((f) => f.name.toLowerCase().endsWith('.txd'));
|
||||
const txdFiles = files.filter((f) => f.name.toLowerCase().endsWith('.txd'));
|
||||
if (!dffFile) {
|
||||
setError('drop a .dff (and its .txd) together');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
render(
|
||||
dffFile.name.replace(/\.dff$/i, ''),
|
||||
await dffFile.arrayBuffer(),
|
||||
txdFile ? await txdFile.arrayBuffer() : null
|
||||
);
|
||||
const name = dffFile.name.replace(/\.dff$/i, '');
|
||||
const dffBuffer = await dffFile.arrayBuffer();
|
||||
const txdBuffers = await Promise.all(txdFiles.map((f) => f.arrayBuffer()));
|
||||
|
||||
const vehicle = looksLikeVehicle(parseDFF(dffBuffer));
|
||||
if (vehicle && txdBuffers.length < 2) txdBuffers.push(await sharedVehicleTxd());
|
||||
|
||||
render(name, dffBuffer, txdBuffers, { vehicle });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setError(err.message);
|
||||
@@ -89,5 +211,5 @@ function setupDragAndDrop() {
|
||||
|
||||
setupDragAndDrop();
|
||||
|
||||
const match = location.pathname.match(/^\/samp\/skin\/(.+)$/);
|
||||
if (match) loadSkin(decodeURIComponent(match[1]));
|
||||
const match = location.pathname.match(/^\/samp\/(skin|vehicle)\/(.+)$/);
|
||||
if (match) loadModel(match[1], decodeURIComponent(match[2]));
|
||||
|
||||
Reference in New Issue
Block a user