sound library
This commit is contained in:
+309
-13
@@ -25,6 +25,13 @@
|
|||||||
const propMeshes = new Map();
|
const propMeshes = new Map();
|
||||||
const propSources = new Map();
|
const propSources = new Map();
|
||||||
let propPreviewSignature = null, propPreviewGeneration = 0;
|
let propPreviewSignature = null, propPreviewGeneration = 0;
|
||||||
|
const SOUND_SOURCE_KEY = 'animationdirector_sound_source';
|
||||||
|
const SOUND_PREVIEW_PROPERTY = 'animationdirector_audio_preview';
|
||||||
|
const SOUND_EVENTS = ['select_animation', 'load_project', 'select_project', 'update_keyframe_selection'];
|
||||||
|
let soundLibraryAction, soundLibraryDialog, soundLibraryListener, soundPreviewProperty, soundAudition;
|
||||||
|
let soundLibrary, soundLibraryGeneration = 0, soundPreviewMode = 'dry';
|
||||||
|
const soundAudioUrls = new Map(), soundMappedPoints = new WeakMap();
|
||||||
|
let soundAudioBytes = 0;
|
||||||
const SYSTEM_TAG_OPTIONS = {
|
const SYSTEM_TAG_OPTIONS = {
|
||||||
'': 'None (normal animation)',
|
'': 'None (normal animation)',
|
||||||
manualpeak: 'Manual peak',
|
manualpeak: 'Manual peak',
|
||||||
@@ -136,7 +143,7 @@
|
|||||||
function setActorUvBasis(actorIndex, width, height) {
|
function setActorUvBasis(actorIndex, width, height) {
|
||||||
const all = getActorUvBasis();
|
const all = getActorUvBasis();
|
||||||
if (all[actorIndex]?.width === width && all[actorIndex]?.height === height) return;
|
if (all[actorIndex]?.width === width && all[actorIndex]?.height === height) return;
|
||||||
all[actorIndex] = { width, height };
|
all[actorIndex] = { ...all[actorIndex], width, height };
|
||||||
Project[ACTOR_UV_BASIS_PROPERTY] = JSON.stringify(all);
|
Project[ACTOR_UV_BASIS_PROPERTY] = JSON.stringify(all);
|
||||||
Project.saved = false;
|
Project.saved = false;
|
||||||
}
|
}
|
||||||
@@ -606,20 +613,34 @@
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeActorUvs(actorIndex, width, height, original = null) {
|
function normalizeCubeUvs(cube, actorIndex, width, height, original = null) {
|
||||||
width = validUvDimension(width); height = validUvDimension(height);
|
width = validUvDimension(width); height = validUvDimension(height);
|
||||||
const basis = original || getActorUvBasis()[actorIndex] || {
|
const all = getActorUvBasis(), actor = all[actorIndex];
|
||||||
|
const key = cube.uuid || cube.name;
|
||||||
|
const basis = original || actor?.cubes?.[key] || actor || {
|
||||||
width: validUvDimension(Project.texture_width), height: validUvDimension(Project.texture_height) };
|
width: validUvDimension(Project.texture_width), height: validUvDimension(Project.texture_height) };
|
||||||
const oldWidth = validUvDimension(basis.width), oldHeight = validUvDimension(basis.height);
|
const oldWidth = validUvDimension(basis.width), oldHeight = validUvDimension(basis.height);
|
||||||
expandProjectUvs(width, height);
|
expandProjectUvs(width, height);
|
||||||
const x = oldWidth / width, y = oldHeight / height;
|
const x = oldWidth / width, y = oldHeight / height;
|
||||||
if (x !== 1 || y !== 1) {
|
scaleCubeUvs(cube, x, y);
|
||||||
for (const cube of Cube.all) if (cubeBelongsToActor(cube, actorIndex)) scaleCubeUvs(cube, x, y);
|
if (actor?.cubes?.[key]?.width !== width || actor?.cubes?.[key]?.height !== height) {
|
||||||
|
all[actorIndex] = { ...(actor || basis), cubes: { ...actor?.cubes, [key]: { width, height } } };
|
||||||
|
Project[ACTOR_UV_BASIS_PROPERTY] = JSON.stringify(all);
|
||||||
|
Project.saved = false;
|
||||||
}
|
}
|
||||||
setActorUvBasis(actorIndex, width, height);
|
|
||||||
return x !== 1 || y !== 1;
|
return x !== 1 || y !== 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeActorUvs(actorIndex, width, height, original = null) {
|
||||||
|
let changed = false;
|
||||||
|
for (const cube of Cube.all) {
|
||||||
|
if (cubeBelongsToActor(cube, actorIndex)) changed = normalizeCubeUvs(cube, actorIndex, width, height, original) || changed;
|
||||||
|
}
|
||||||
|
expandProjectUvs(validUvDimension(width), validUvDimension(height));
|
||||||
|
setActorUvBasis(actorIndex, width, height);
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
// ----------------------------
|
// ----------------------------
|
||||||
// Block requirement authoring
|
// Block requirement authoring
|
||||||
// ----------------------------
|
// ----------------------------
|
||||||
@@ -1843,6 +1864,11 @@
|
|||||||
function applyTextureToCube(cube, texture) {
|
function applyTextureToCube(cube, texture) {
|
||||||
if (!cube || !texture) return;
|
if (!cube || !texture) return;
|
||||||
|
|
||||||
|
const actorIndex = readActorIndexFromName(cube.name) || readActorIndexFromName(cube.parent?.name);
|
||||||
|
const imageWidth = texture.img?.naturalWidth || texture.canvas?.width || texture.uv_width || 0;
|
||||||
|
const imageHeight = texture.display_height || texture.img?.naturalHeight || texture.canvas?.height || texture.uv_height || 0;
|
||||||
|
if (actorIndex && imageWidth > 0 && imageHeight > 0) normalizeCubeUvs(cube, actorIndex, imageWidth, imageHeight);
|
||||||
|
|
||||||
const faces = ['north', 'south', 'east', 'west', 'up', 'down'];
|
const faces = ['north', 'south', 'east', 'west', 'up', 'down'];
|
||||||
const textureId = texture.uuid || texture.id || texture.name;
|
const textureId = texture.uuid || texture.id || texture.name;
|
||||||
|
|
||||||
@@ -1869,14 +1895,13 @@
|
|||||||
const texture = findTextureByActorTextureName(textureName);
|
const texture = findTextureByActorTextureName(textureName);
|
||||||
if (!texture) return { applied: 0, missing: true };
|
if (!texture) return { applied: 0, missing: true };
|
||||||
|
|
||||||
const imageWidth = texture.img?.naturalWidth || texture.canvas?.width || 0;
|
|
||||||
const imageHeight = texture.display_height || texture.img?.naturalHeight || texture.canvas?.height || 0;
|
|
||||||
if (imageWidth > 0 && imageHeight > 0) normalizeActorUvs(actorIndex, imageWidth, imageHeight);
|
|
||||||
|
|
||||||
let applied = 0;
|
let applied = 0;
|
||||||
|
const overrides = getProjectBoneTextureOverrides();
|
||||||
const cubes = Array.isArray(Cube.all) ? Cube.all : [];
|
const cubes = Array.isArray(Cube.all) ? Cube.all : [];
|
||||||
for (const cube of cubes) {
|
for (const cube of cubes) {
|
||||||
if (!cubeBelongsToActor(cube, actorIndex)) continue;
|
if (!cubeBelongsToActor(cube, actorIndex)) continue;
|
||||||
|
const override = getNearestBoneTextureOverride(cube, overrides);
|
||||||
|
if (override?.texture && findTextureByActorTextureName(override.texture)) continue;
|
||||||
applyTextureToCube(cube, texture);
|
applyTextureToCube(cube, texture);
|
||||||
applied++;
|
applied++;
|
||||||
}
|
}
|
||||||
@@ -2439,6 +2464,16 @@
|
|||||||
return { groups, ignored };
|
return { groups, ignored };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cleanSoundPreviewFields(clip) {
|
||||||
|
for (const cues of Object.values(clip?.sound_effects || {})) {
|
||||||
|
for (const cue of Array.isArray(cues) ? cues : [cues]) {
|
||||||
|
if (!cue || typeof cue !== 'object') continue;
|
||||||
|
delete cue.file; delete cue[SOUND_PREVIEW_PROPERTY];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return clip;
|
||||||
|
}
|
||||||
|
|
||||||
function compileConjoinedStage(stage) {
|
function compileConjoinedStage(stage) {
|
||||||
const animation = stage?.animation;
|
const animation = stage?.animation;
|
||||||
if (typeof animation?.compileBedrockAnimation !== 'function') {
|
if (typeof animation?.compileBedrockAnimation !== 'function') {
|
||||||
@@ -2447,7 +2482,7 @@
|
|||||||
|
|
||||||
const compiled = animation.compileBedrockAnimation() || {};
|
const compiled = animation.compileBedrockAnimation() || {};
|
||||||
if (!compiled.animations) {
|
if (!compiled.animations) {
|
||||||
return { formatVersion: '1.8.0', clip: stripBlockRequirementGuideBones(clone(compiled)) };
|
return { formatVersion: '1.8.0', clip: cleanSoundPreviewFields(stripBlockRequirementGuideBones(clone(compiled))) };
|
||||||
}
|
}
|
||||||
|
|
||||||
let clip = compiled.animations[stage.name];
|
let clip = compiled.animations[stage.name];
|
||||||
@@ -2460,7 +2495,7 @@
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
formatVersion: compiled.format_version || '1.8.0',
|
formatVersion: compiled.format_version || '1.8.0',
|
||||||
clip: stripBlockRequirementGuideBones(clone(clip)),
|
clip: cleanSoundPreviewFields(stripBlockRequirementGuideBones(clone(clip))),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3936,6 +3971,7 @@
|
|||||||
content: JSON.stringify(imported.fileObj, null, 2),
|
content: JSON.stringify(imported.fileObj, null, 2),
|
||||||
};
|
};
|
||||||
Animator.loadFile(fakeFile, imported.stageNames);
|
Animator.loadFile(fakeFile, imported.stageNames);
|
||||||
|
mapAnimationSounds(Animation.all);
|
||||||
Blockbench.showQuickMessage(
|
Blockbench.showQuickMessage(
|
||||||
`Imported ${imported.stageNames.length} stage${imported.stageNames.length === 1 ? '' : 's'} for "${outputAnimationId}"`,
|
`Imported ${imported.stageNames.length} stage${imported.stageNames.length === 1 ? '' : 's'} for "${outputAnimationId}"`,
|
||||||
2500
|
2500
|
||||||
@@ -5132,7 +5168,6 @@
|
|||||||
const base = row.textures.get(row.defaultTexture);
|
const base = row.textures.get(row.defaultTexture);
|
||||||
if (base) {
|
if (base) {
|
||||||
textures[index] = loadedTextures.get(base.file || `${base.relative}:${row.defaultTexture}`);
|
textures[index] = loadedTextures.get(base.file || `${base.relative}:${row.defaultTexture}`);
|
||||||
if (base.dimensions) normalizeActorUvs(index, base.dimensions.width, base.dimensions.height);
|
|
||||||
}
|
}
|
||||||
for (const [bone, textureName] of Object.entries(row.json.afw_bone_textures || {})) {
|
for (const [bone, textureName] of Object.entries(row.json.afw_bone_textures || {})) {
|
||||||
const group = Group.all.find((candidate) => candidate.name === `actor${index}_${bone}`);
|
const group = Group.all.find((candidate) => candidate.name === `actor${index}_${bone}`);
|
||||||
@@ -5147,6 +5182,7 @@
|
|||||||
setProjectBoneTextureOverrides(overrides);
|
setProjectBoneTextureOverrides(overrides);
|
||||||
setProjectAnimdefAnimators(plan.definition.animators || []);
|
setProjectAnimdefAnimators(plan.definition.animators || []);
|
||||||
Animator.loadFile({ name: `${plan.parsed.animationId}.animation.json`, path: '', content: JSON.stringify(plan.imported.fileObj) }, plan.imported.stageNames);
|
Animator.loadFile({ name: `${plan.parsed.animationId}.animation.json`, path: '', content: JSON.stringify(plan.imported.fileObj) }, plan.imported.stageNames);
|
||||||
|
mapAnimationSounds(Animation.all);
|
||||||
rebuildBlockRequirementGuides(plan.parsed.animationId, plan.blockEntry, plan.definition, false);
|
rebuildBlockRequirementGuides(plan.parsed.animationId, plan.blockEntry, plan.definition, false);
|
||||||
if (plan.propEntries) Project[PROP_PREVIEWS_PROPERTY] = JSON.stringify(plan.propEntries);
|
if (plan.propEntries) Project[PROP_PREVIEWS_PROPERTY] = JSON.stringify(plan.propEntries);
|
||||||
Canvas.updateAll();
|
Canvas.updateAll();
|
||||||
@@ -5313,6 +5349,248 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sound IDs remain gameplay cues. Blob URLs are editor-only, lazily loaded audio previews.
|
||||||
|
function stopSoundAudition() {
|
||||||
|
if (soundAudition) { soundAudition.pause(); soundAudition = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSoundAudio() {
|
||||||
|
stopSoundAudition();
|
||||||
|
for (const url of soundAudioUrls.values()) URL.revokeObjectURL(url);
|
||||||
|
soundAudioUrls.clear(); soundAudioBytes = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function soundJsonPaths(source, io) {
|
||||||
|
if (source.files) return [...source.files.keys()].filter((name) => /^assets\/[^/]+\/sounds\.json$/.test(name));
|
||||||
|
const root = io.path.basename(source.path) === 'assets' ? io.path.dirname(source.path) : source.path;
|
||||||
|
const assets = io.path.join(root, 'assets');
|
||||||
|
if (!io.fs.existsSync(assets)) return [];
|
||||||
|
return io.fs.readdirSync(assets, { withFileTypes: true }).slice(0, 256)
|
||||||
|
.filter((entry) => entry.isDirectory() && !entry.isSymbolicLink() && /^[a-z0-9_.-]+$/.test(entry.name))
|
||||||
|
.map((entry) => `assets/${entry.name}/sounds.json`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadSoundLibrary(io = desktopFiles()) {
|
||||||
|
clearSoundAudio();
|
||||||
|
const events = new Map(), sources = [], warnings = [];
|
||||||
|
const selected = localStorage.getItem(SOUND_SOURCE_KEY);
|
||||||
|
const paths = uniqueTrimmedStrings([...savedPackFolders(), ...(selected ? [selected] : [])]);
|
||||||
|
for (const file of paths.slice(0, 64)) {
|
||||||
|
try {
|
||||||
|
const source = openPropSource(file, io);
|
||||||
|
sources.push(source);
|
||||||
|
for (const name of soundJsonPaths(source, io)) {
|
||||||
|
const raw = source.read(name);
|
||||||
|
if (!raw) continue;
|
||||||
|
const json = JSON.parse(raw.toString('utf8').replace(/^\uFEFF/, ''));
|
||||||
|
const namespace = name.split('/')[1];
|
||||||
|
for (const [key, value] of Object.entries(json)) {
|
||||||
|
const id = `${namespace}:${key}`;
|
||||||
|
if (!importResourceId(id) || !Array.isArray(value?.sounds) || events.size >= 10000) continue;
|
||||||
|
const entries = value.sounds.slice(0, 256);
|
||||||
|
if (!events.has(id)) events.set(id, { entries, replace: value.replace === true });
|
||||||
|
else if (!events.get(id).replace) {
|
||||||
|
events.get(id).entries.push(...entries);
|
||||||
|
events.get(id).replace = value.replace === true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) { warnings.push(`${io.path.basename(file)}: ${error.message}`); }
|
||||||
|
}
|
||||||
|
soundLibrary = { events, sources, warnings };
|
||||||
|
soundLibraryGeneration++;
|
||||||
|
return soundLibrary;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureSoundLibrary() {
|
||||||
|
if (!soundLibrary) {
|
||||||
|
try { loadSoundLibrary(); }
|
||||||
|
catch (_) { soundLibrary = { events: new Map(), sources: [], warnings: ['Sound assets require Blockbench Desktop.'] }; }
|
||||||
|
}
|
||||||
|
return soundLibrary;
|
||||||
|
}
|
||||||
|
|
||||||
|
function soundCueId(cue, mode = soundPreviewMode) {
|
||||||
|
const text = String(cue || '').trim();
|
||||||
|
const bare = text.replace(/^(?:needsofnature|minecraft):/, '');
|
||||||
|
if (bare.toLowerCase() === 'reactiveimpact_silent') return null;
|
||||||
|
const semantic = { reactiveimpact: { dry: 'impactdry01', wet: 'impactwet01', full: 'wet04', peak: 'shot_in01' }[mode] || 'impactdry01',
|
||||||
|
impactdry: 'impactdry01', impactwet: 'impactwet01', birth: 'pop' };
|
||||||
|
if (semantic[bare.toLowerCase()]) return `needsofnature:${semantic[bare.toLowerCase()]}`;
|
||||||
|
const id = text.includes(':') ? text : `needsofnature:${text}`;
|
||||||
|
return importResourceId(id) ? id : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveSoundAudio(cue, library = ensureSoundLibrary(), visiting = new Set()) {
|
||||||
|
if (/^random\(/i.test(String(cue).trim())) {
|
||||||
|
const match = String(cue).trim().match(/^random\((.*)\)$/i);
|
||||||
|
if (!match) return null;
|
||||||
|
for (const option of match[1].split(',').slice(0, 256)) {
|
||||||
|
const audio = resolveSoundAudio(option, library, visiting);
|
||||||
|
if (audio) return audio;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const id = soundCueId(cue);
|
||||||
|
if (!id || visiting.has(id) || visiting.size >= 16) return null;
|
||||||
|
const event = library.events.get(id);
|
||||||
|
if (!event) return null;
|
||||||
|
const next = new Set(visiting); next.add(id);
|
||||||
|
for (const entry of event.entries) {
|
||||||
|
const object = typeof entry === 'string' ? { name: entry } : entry;
|
||||||
|
if (!object || typeof object.name !== 'string') continue;
|
||||||
|
const name = object.name.includes(':') ? object.name : `minecraft:${object.name}`;
|
||||||
|
const resource = importResourceId(name);
|
||||||
|
if (!resource) continue;
|
||||||
|
if (object.type === 'event') {
|
||||||
|
const audio = resolveSoundAudio(name, library, next);
|
||||||
|
if (audio) return audio;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const asset = `assets/${resource.namespace}/sounds/${resource.resource}.ogg`;
|
||||||
|
if (soundAudioUrls.has(asset)) return soundAudioUrls.get(asset);
|
||||||
|
for (const source of library.sources) {
|
||||||
|
try {
|
||||||
|
const bytes = source.read(asset);
|
||||||
|
if (!bytes) continue;
|
||||||
|
if (soundAudioBytes + bytes.length > 64 * 1024 * 1024) return null;
|
||||||
|
const url = URL.createObjectURL(new Blob([bytes], { type: 'audio/ogg' }));
|
||||||
|
soundAudioUrls.set(asset, url); soundAudioBytes += bytes.length;
|
||||||
|
return url;
|
||||||
|
} catch (_) { /* An unavailable asset must never prevent animation import. */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapAnimationSounds(animations) {
|
||||||
|
for (const animation of animations || []) {
|
||||||
|
for (const keyframe of animation?.animators?.effects?.sound || []) {
|
||||||
|
for (const point of keyframe.data_points || []) {
|
||||||
|
if (point.file && point.file !== point[SOUND_PREVIEW_PROPERTY]) continue;
|
||||||
|
const signature = `${soundLibraryGeneration}:${soundPreviewMode}:${point.effect}:${point.sound}:${point.file}`;
|
||||||
|
if (soundMappedPoints.get(point) === signature) continue;
|
||||||
|
const audio = resolveSoundAudio(point.effect) || (point.sound ? resolveSoundAudio(point.sound) : null);
|
||||||
|
point.file = audio || '';
|
||||||
|
point[SOUND_PREVIEW_PROPERTY] = point.file;
|
||||||
|
soundMappedPoints.set(point, `${soundLibraryGeneration}:${soundPreviewMode}:${point.effect}:${point.sound}:${point.file}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSoundPreviewMode(mode) {
|
||||||
|
soundPreviewMode = ['dry', 'wet', 'full', 'peak'].includes(mode) ? mode : 'dry';
|
||||||
|
mapAnimationSounds(typeof Animation === 'undefined' ? [] : Animation.all);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyLibrarySound(id, insert = false) {
|
||||||
|
const animation = typeof Animation === 'undefined' ? null : Animation.selected;
|
||||||
|
if (!animation) throw new Error('Select an animation first.');
|
||||||
|
const selected = (Timeline.selected || []).filter((keyframe) => keyframe.channel === 'sound' && keyframe.animator?.animation === animation);
|
||||||
|
if (!insert && selected.length) {
|
||||||
|
Undo.initEdit({ keyframes: selected });
|
||||||
|
for (const keyframe of selected) {
|
||||||
|
const point = keyframe.data_points[0];
|
||||||
|
point.effect = id; point.file = ''; point[SOUND_PREVIEW_PROPERTY] = '';
|
||||||
|
}
|
||||||
|
mapAnimationSounds([animation]);
|
||||||
|
Undo.finishEdit('Apply sound from library');
|
||||||
|
} else {
|
||||||
|
const effects = animation.animators.effects || (animation.animators.effects = new EffectAnimator(animation));
|
||||||
|
const keyframes = effects.sound.filter((frame) => Math.abs(frame.time - Timeline.time) < 0.00001);
|
||||||
|
Undo.initEdit({ keyframes });
|
||||||
|
let keyframe = keyframes[0];
|
||||||
|
if (keyframe) {
|
||||||
|
keyframe.extend({ data_points: [...keyframe.data_points.map((point) => point.getUndoCopy()), { effect: id }] });
|
||||||
|
} else {
|
||||||
|
keyframe = effects.createKeyframe({ data_points: [{ effect: id }] }, Timeline.time, 'sound', false, false);
|
||||||
|
keyframes.push(keyframe);
|
||||||
|
}
|
||||||
|
mapAnimationSounds([animation]);
|
||||||
|
effects.select(); keyframe.select();
|
||||||
|
Undo.finishEdit('Insert sound from library', { keyframes });
|
||||||
|
}
|
||||||
|
if (typeof updateKeyframeSelection === 'function') updateKeyframeSelection();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showSoundLibrary() {
|
||||||
|
stopSoundAudition();
|
||||||
|
ensureSoundLibrary();
|
||||||
|
soundLibraryDialog?.delete();
|
||||||
|
soundLibraryDialog = new Dialog({ id: 'animationdirector_sound_library', title: 'Sound Library', width: 700,
|
||||||
|
buttons: ['Close'], onConfirm: stopSoundAudition, onCancel: stopSoundAudition,
|
||||||
|
component: {
|
||||||
|
data() { return { query: '', category: 'all', selected: '', mode: soundPreviewMode, page: 0,
|
||||||
|
ids: [...soundLibrary.events.keys()].sort(), warnings: soundLibrary.warnings.join('\n'), error: '' }; },
|
||||||
|
computed: {
|
||||||
|
rows() {
|
||||||
|
const cues = ['reactiveimpact', 'reactiveimpact_silent', 'impactdry', 'impactwet', 'birth'];
|
||||||
|
return [...cues.map((id) => ({ id, category: 'Gameplay cues' })), ...this.ids.map((id) => ({ id, category: 'Sound events' }))]
|
||||||
|
.filter((entry) => (this.category === 'all' || entry.category === this.category) && entry.id.toLowerCase().includes(this.query.toLowerCase()));
|
||||||
|
},
|
||||||
|
visibleRows() { return this.rows.slice(this.page * 100, (this.page + 1) * 100); },
|
||||||
|
},
|
||||||
|
watch: { query() { this.page = 0; }, category() { this.page = 0; } },
|
||||||
|
methods: {
|
||||||
|
reload() {
|
||||||
|
try { loadSoundLibrary(); this.ids = [...soundLibrary.events.keys()].sort(); this.warnings = soundLibrary.warnings.join('\n');
|
||||||
|
this.error = ''; mapAnimationSounds(typeof Animation === 'undefined' ? [] : Animation.all); }
|
||||||
|
catch (error) { this.error = error.message; }
|
||||||
|
},
|
||||||
|
folder() {
|
||||||
|
try { const folder = Blockbench.pickDirectory({ title: 'Select folder containing assets', resource_id: SOUND_SOURCE_KEY });
|
||||||
|
if (folder) { localStorage.setItem(SOUND_SOURCE_KEY, folder); this.reload(); } }
|
||||||
|
catch (error) { this.error = error.message; }
|
||||||
|
},
|
||||||
|
jar() {
|
||||||
|
Filesystem.importFile({ type: 'Sound assets', extensions: ['jar', 'zip'], readtype: 'none', multiple: false }, (files) => {
|
||||||
|
if (files?.[0]?.path) { localStorage.setItem(SOUND_SOURCE_KEY, files[0].path); this.reload(); }
|
||||||
|
});
|
||||||
|
},
|
||||||
|
preview() {
|
||||||
|
stopSoundAudition(); this.error = '';
|
||||||
|
const file = resolveSoundAudio(this.selected);
|
||||||
|
if (!file) { this.error = this.selected === 'reactiveimpact_silent' ? 'Silent cue: gameplay effects only.' : 'No audio found. Select the NoN JAR or an extracted assets folder.'; return; }
|
||||||
|
soundAudition = new Audio(file);
|
||||||
|
soundAudition.volume = Math.max(0, Math.min(1, typeof settings === 'undefined' ? 1 : (settings.volume?.value ?? 100) / 100));
|
||||||
|
soundAudition.play().catch((error) => { this.error = error.message; });
|
||||||
|
},
|
||||||
|
stop: stopSoundAudition,
|
||||||
|
apply(insert) { try { applyLibrarySound(this.selected, insert); this.error = ''; } catch (error) { this.error = error.message; } },
|
||||||
|
modeChanged() { stopSoundAudition(); setSoundPreviewMode(this.mode); },
|
||||||
|
},
|
||||||
|
template: `<div style="padding:12px">
|
||||||
|
<div class="bar flex" style="gap:6px"><button @click="jar">Select NoN JAR / ZIP</button><button @click="folder">Select Assets Folder</button><button @click="reload">Refresh</button></div>
|
||||||
|
<p class="subtle">Uses the selected sound source and remembered Pack Folders. Previews never change exported cue IDs.</p>
|
||||||
|
<div class="bar flex" style="gap:8px;margin:10px 0"><input v-model="query" placeholder="Search sound IDs" style="flex:1" aria-label="Search sound IDs"><select v-model="category"><option value="all">All sounds</option><option>Sound events</option><option>Gameplay cues</option></select></div>
|
||||||
|
<label>Reactive preview <select v-model="mode" @change="modeChanged"><option value="dry">Dry</option><option value="wet">Wet</option><option value="full">Full</option><option value="peak">Peak</option></select></label>
|
||||||
|
<p class="subtle">Gameplay cues and random events use representative audio, not a gameplay simulation.</p>
|
||||||
|
<div style="height:260px;overflow-y:auto;margin:10px 0;border:1px solid var(--color-border)">
|
||||||
|
<div v-for="row in visibleRows" :key="row.category+row.id" @click="selected=row.id;stop()" @dblclick="selected=row.id;preview()" :style="{background:selected===row.id?'var(--color-accent)':'',padding:'6px 10px',cursor:'pointer'}"><span>{{row.id}}</span><small style="float:right;opacity:.6">{{row.category}}</small></div>
|
||||||
|
</div>
|
||||||
|
<div class="bar flex" style="gap:6px"><button @click="page--" :disabled="page===0">Previous</button><span>{{rows.length}} results</span><button @click="page++" :disabled="(page+1)*100>=rows.length">Next</button></div>
|
||||||
|
<div class="bar flex" style="gap:6px;margin-top:12px"><button @click="preview" :disabled="!selected">Play</button><button @click="stop">Stop</button><button @click="apply(false)" :disabled="!selected">Apply to Selected / Insert</button><button @click="apply(true)" :disabled="!selected">Insert at Playhead</button></div>
|
||||||
|
<p style="color:var(--color-error);white-space:pre-wrap">{{error}}</p><p class="subtle" style="white-space:pre-wrap">{{warnings}}</p>
|
||||||
|
</div>`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
soundLibraryDialog.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshSoundLibraryPanel() {
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
const panel = document.getElementById('panel_keyframe');
|
||||||
|
if (panel && !document.getElementById('animationdirector_sound_library_button')) {
|
||||||
|
const button = document.createElement('button');
|
||||||
|
button.id = 'animationdirector_sound_library_button'; button.textContent = 'Sound Library...';
|
||||||
|
button.style.cssText = 'margin:8px;width:calc(100% - 16px)';
|
||||||
|
button.addEventListener('click', showSoundLibrary); panel.appendChild(button);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mapAnimationSounds(typeof Animation === 'undefined' ? [] : [Animation.selected]);
|
||||||
|
}
|
||||||
|
|
||||||
Plugin.register(PLUGIN_ID, {
|
Plugin.register(PLUGIN_ID, {
|
||||||
title: 'AnimationDirector',
|
title: 'AnimationDirector',
|
||||||
author: 'L1Z0',
|
author: 'L1Z0',
|
||||||
@@ -5326,6 +5604,19 @@ You can import or export complete AFW animation IDs as one file containing all s
|
|||||||
version: '1.1.0',
|
version: '1.1.0',
|
||||||
variant: 'both',
|
variant: 'both',
|
||||||
onload() {
|
onload() {
|
||||||
|
if (typeof Property === 'function' && typeof KeyframeDataPoint !== 'undefined') {
|
||||||
|
soundPreviewProperty = new Property(KeyframeDataPoint, 'string', SOUND_PREVIEW_PROPERTY, {
|
||||||
|
exposed: false, condition: (point) => point.keyframe.channel === 'sound',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
soundLibraryAction = new Action('animationdirector_sound_library', {
|
||||||
|
name: 'Sound Library...', icon: 'library_music', category: 'animation',
|
||||||
|
description: 'Browse NoN and pack sounds, audition audio and add sound cues to the timeline.',
|
||||||
|
click: showSoundLibrary,
|
||||||
|
});
|
||||||
|
soundLibraryListener = refreshSoundLibraryPanel;
|
||||||
|
Blockbench.on(SOUND_EVENTS.join(' '), soundLibraryListener);
|
||||||
|
refreshSoundLibraryPanel();
|
||||||
if (typeof Property === 'function' && typeof ModelProject !== 'undefined' && ModelProject?.properties) {
|
if (typeof Property === 'function' && typeof ModelProject !== 'undefined' && ModelProject?.properties) {
|
||||||
if (!ModelProject.properties[ACTOR_NAMES_PROPERTY]) {
|
if (!ModelProject.properties[ACTOR_NAMES_PROPERTY]) {
|
||||||
actorNamesProjectProperty = new Property(ModelProject, 'string', ACTOR_NAMES_PROPERTY, {
|
actorNamesProjectProperty = new Property(ModelProject, 'string', ACTOR_NAMES_PROPERTY, {
|
||||||
@@ -5514,6 +5805,7 @@ You can import or export complete AFW animation IDs as one file containing all s
|
|||||||
replaceActorAction,
|
replaceActorAction,
|
||||||
blockRequirementAction,
|
blockRequirementAction,
|
||||||
propAction,
|
propAction,
|
||||||
|
soundLibraryAction,
|
||||||
createAnimationDefinitionAction,
|
createAnimationDefinitionAction,
|
||||||
exportConjoinedAction,
|
exportConjoinedAction,
|
||||||
],
|
],
|
||||||
@@ -5528,6 +5820,10 @@ You can import or export complete AFW animation IDs as one file containing all s
|
|||||||
MenuBar.update();
|
MenuBar.update();
|
||||||
},
|
},
|
||||||
onunload() {
|
onunload() {
|
||||||
|
for (const event of SOUND_EVENTS) Blockbench.removeListener(event, soundLibraryListener);
|
||||||
|
if (typeof document !== 'undefined') document.getElementById('animationdirector_sound_library_button')?.remove();
|
||||||
|
soundLibraryAction?.delete(); soundLibraryDialog?.delete(); soundPreviewProperty?.delete();
|
||||||
|
clearSoundAudio(); soundLibrary = undefined;
|
||||||
clearPropMeshes(); propSources.clear();
|
clearPropMeshes(); propSources.clear();
|
||||||
propAction?.delete(); propDialog?.delete(); propProjectProperty?.delete(); actorUvBasisProjectProperty?.delete();
|
propAction?.delete(); propDialog?.delete(); propProjectProperty?.delete(); actorUvBasisProjectProperty?.delete();
|
||||||
if (propListener) for (const event of ['select_animation','select_project','load_project','unselect_project','undo','redo','render_frame']) Blockbench.removeListener(event, propListener);
|
if (propListener) for (const event of ['select_animation','select_project','load_project','unselect_project','undo','redo','render_frame']) Blockbench.removeListener(event, propListener);
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# AnimationDirector
|
||||||
|
|
||||||
|
## Sound Library
|
||||||
|
|
||||||
|
Open **Sound Library...** in the keyframe panel or **Tools > AnimationDirector Anims**.
|
||||||
|
Select a NoN mod JAR/ZIP or a folder containing `assets` once. The plugin remembers
|
||||||
|
this source and also reads sound registries from your configured **Pack Folders**.
|
||||||
|
Pack folders use their configured priority, followed by the selected sound source.
|
||||||
|
No sound assets are downloaded or bundled into the plugin.
|
||||||
|
|
||||||
|
Search sound IDs, select a result and press **Play** (or double-click it) to audition.
|
||||||
|
**Apply to Selected / Insert** changes the first cue of selected sound keyframes,
|
||||||
|
or inserts at the playhead when no sound keyframe is selected. **Insert at Playhead**
|
||||||
|
always inserts; existing cues at that time are retained.
|
||||||
|
|
||||||
|
Recognized sound cues are automatically linked to timeline audio when importing
|
||||||
|
animations, opening projects or selecting animations. Select the sound source first,
|
||||||
|
or select it afterwards to map already imported animations. **Refresh** reloads changed
|
||||||
|
files. Manually assigned audio previews and unknown gameplay cues are preserved.
|
||||||
|
|
||||||
|
**Reactive preview** selects representative Dry/Wet/Full/Peak audio for
|
||||||
|
`reactiveimpact`. `reactiveimpact_silent` has no audio. Random sound events/pools use
|
||||||
|
the first available sample. These previews do not simulate gameplay; exported
|
||||||
|
animation cue IDs remain unchanged. Blockbench's native timeline player previews
|
||||||
|
the first cue at a sound keyframe; additional simultaneous cues still export normally.
|
||||||
|
|
||||||
|
Sound browsing requires Blockbench Desktop. Preview audio is loaded on demand,
|
||||||
|
cached with a 64 MiB limit, and kept out of exported animation JSON.
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ function harness(capture = false) {
|
|||||||
add(undo) { assert.equal(undo, false); Texture.all.push(this); }
|
add(undo) { assert.equal(undo, false); Texture.all.push(this); }
|
||||||
}
|
}
|
||||||
const context = {
|
const context = {
|
||||||
Buffer, Timeline: { animation: null },
|
Buffer, Blob, URL: { createObjectURL() { return 'blob:import-preview'; }, revokeObjectURL() {} }, Timeline: { animation: null },
|
||||||
Project: { uuid: 'original' }, Group, Cube: { all: [] }, Texture, Outliner: { elements: [] },
|
Project: { uuid: 'original' }, Group, Cube: { all: [] }, Texture, Outliner: { elements: [] },
|
||||||
Plugin: { register() {} }, requireNativeModule: require,
|
Plugin: { register() {} }, requireNativeModule: require,
|
||||||
localStorage: { getItem: (key) => storage.get(key), setItem: (key, value) => storage.set(key, value) },
|
localStorage: { getItem: (key) => storage.get(key), setItem: (key, value) => storage.set(key, value) },
|
||||||
@@ -49,7 +49,10 @@ function harness(capture = false) {
|
|||||||
Animator: {
|
Animator: {
|
||||||
loadFile(file, names) {
|
loadFile(file, names) {
|
||||||
context.loadedAnimation = JSON.parse(file.content);
|
context.loadedAnimation = JSON.parse(file.content);
|
||||||
context.Animation.all = names.map((name) => ({ name, select() {} }));
|
context.Animation.all = names.map((name) => ({ name, select() {}, animators: { effects: { sound:
|
||||||
|
Object.entries(context.loadedAnimation.animations[name]?.sound_effects || {}).map(([time, cues]) => ({
|
||||||
|
time: Number(time), data_points: Array.isArray(cues) ? cues : [cues],
|
||||||
|
})) } } }));
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Animation: { all: [] }, Canvas: { updateAll() {}, updateView() {} },
|
Animation: { all: [] }, Canvas: { updateAll() {}, updateView() {} },
|
||||||
@@ -313,7 +316,8 @@ test('complete import finds model texture in cached assets and uses its PNG dime
|
|||||||
importing.commitAutomaticImport(h.context.importedPlan);
|
importing.commitAutomaticImport(h.context.importedPlan);
|
||||||
assert.equal(importing.context.Texture.all[0].uv_width, 32);
|
assert.equal(importing.context.Texture.all[0].uv_width, 32);
|
||||||
assert.equal(importing.context.Texture.all[0].uv_height, 64);
|
assert.equal(importing.context.Texture.all[0].uv_height, 64);
|
||||||
assert.equal(JSON.parse(importing.context.Project.animationdirector_actor_uv_basis)['1'].width, 32);
|
assert.equal(JSON.parse(importing.context.Project.animationdirector_actor_uv_basis)['1'].width, 64,
|
||||||
|
'The actor retains its geometry basis; each textured cube tracks its own normalization');
|
||||||
assert.equal(JSON.parse(importing.context.Project.multiactor_actor_textures)['1'], row.defaultTexture);
|
assert.equal(JSON.parse(importing.context.Project.multiactor_actor_textures)['1'], row.defaultTexture);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -494,10 +498,13 @@ test('missing models can be supplied manually, and their locally supplied textur
|
|||||||
|
|
||||||
test('batch import uses a new project, one undo entry, actor prefixes and texture override bindings', () => {
|
test('batch import uses a new project, one undo entry, actor prefixes and texture override bindings', () => {
|
||||||
const h = harness();
|
const h = harness();
|
||||||
|
h.storage.set('animationdirector_sound_source', path.resolve(__dirname, '../../NON/src/main/resources'));
|
||||||
const plan = { parsed: { animationId: 'example' }, blockEntry: null,
|
const plan = { parsed: { animationId: 'example' }, blockEntry: null,
|
||||||
propEntries: { '["example","wolf","right",0]': { animation: 'example', item: 'minecraft:carrot', mode: 'item' } },
|
propEntries: { '["example","wolf","right",0]': { animation: 'example', item: 'minecraft:carrot', mode: 'item' } },
|
||||||
definition: { actors: [{ label: 'wolf', entity_types: ['minecraft:wolf'] }], animators: ['Creator'], stages: [{ stage: 1 }] },
|
definition: { actors: [{ label: 'wolf', entity_types: ['minecraft:wolf'] }], animators: ['Creator'], stages: [{ stage: 1 }] },
|
||||||
imported: { stageNames: ['example.p1'], fileObj: { animations: { 'example.p1': { bones: { actor1_body: { rotation: [1, 2, 3] } } } } } },
|
imported: { stageNames: ['example.p1'], fileObj: { animations: { 'example.p1': {
|
||||||
|
bones: { actor1_body: { rotation: [1, 2, 3] } }, sound_effects: { '0.5': { effect: 'reactiveimpact' } },
|
||||||
|
} } } },
|
||||||
rows: [{ label: 'wolf', json: { afw_bone_textures: { body: 'test:textures/wolf.png' } },
|
rows: [{ label: 'wolf', json: { afw_bone_textures: { body: 'test:textures/wolf.png' } },
|
||||||
geo: { bones: [{ name: 'body' }] }, defaultTexture: 'test:textures/wolf.png',
|
geo: { bones: [{ name: 'body' }] }, defaultTexture: 'test:textures/wolf.png',
|
||||||
textures: new Map([['test:textures/wolf.png', { file: 'wolf.png' }]]) }] };
|
textures: new Map([['test:textures/wolf.png', { file: 'wolf.png' }]]) }] };
|
||||||
@@ -511,6 +518,9 @@ test('batch import uses a new project, one undo entry, actor prefixes and textur
|
|||||||
assert.equal(override[h.context.Group.all[0].uuid].texture, 'test:textures/wolf.png');
|
assert.equal(override[h.context.Group.all[0].uuid].texture, 'test:textures/wolf.png');
|
||||||
assert.ok(JSON.parse(h.context.Project.animationdirector_animdef_definitions).example);
|
assert.ok(JSON.parse(h.context.Project.animationdirector_animdef_definitions).example);
|
||||||
assert.equal(JSON.parse(h.context.Project.animationdirector_prop_previews)['["example","wolf","right",0]'].item,'minecraft:carrot');
|
assert.equal(JSON.parse(h.context.Project.animationdirector_prop_previews)['["example","wolf","right",0]'].item,'minecraft:carrot');
|
||||||
|
const cue = h.context.Animation.all[0].animators.effects.sound[0].data_points[0];
|
||||||
|
assert.equal(cue.effect, 'reactiveimpact');
|
||||||
|
assert.equal(cue.file, 'blob:import-preview');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('unexpected animation-load failures cancel the batch rather than keeping a partial import', () => {
|
test('unexpected animation-load failures cancel the batch rather than keeping a partial import', () => {
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ test('animation menu orders import and creation actions and has no deprecated sp
|
|||||||
const menu = h.menus.find((entry) => entry.id === 'animationdirector_anims');
|
const menu = h.menus.find((entry) => entry.id === 'animationdirector_anims');
|
||||||
assert.deepEqual(Array.from(menu.children, (action) => action.name), [
|
assert.deepEqual(Array.from(menu.children, (action) => action.name), [
|
||||||
'Automatically Import Animation', 'Manual Import', 'Actor Preferences',
|
'Automatically Import Animation', 'Manual Import', 'Actor Preferences',
|
||||||
'Replace Actor Model', 'Block Requirements', 'Item Props', 'Create Animation Definition', 'Export Animations',
|
'Replace Actor Model', 'Block Requirements', 'Item Props', 'Sound Library...', 'Create Animation Definition', 'Export Animations',
|
||||||
]);
|
]);
|
||||||
assert.deepEqual(Array.from(menu.children[1].children, (action) => action.name), [
|
assert.deepEqual(Array.from(menu.children[1].children, (action) => action.name), [
|
||||||
'Import Actor', 'Import Animation', 'Import Animation Definition', 'Pack Folders',
|
'Import Actor', 'Import Animation', 'Import Animation Definition', 'Pack Folders',
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const vm = require('node:vm');
|
|||||||
function harness() {
|
function harness() {
|
||||||
const source = fs.readFileSync(path.join(__dirname, '..', 'AnimationDirector.js'), 'utf8').replace(
|
const source = fs.readFileSync(path.join(__dirname, '..', 'AnimationDirector.js'), 'utf8').replace(
|
||||||
' Plugin.register(PLUGIN_ID, {',
|
' Plugin.register(PLUGIN_ID, {',
|
||||||
' globalThis.uvTest = { normalizeActorUvs, expandProjectUvs, scaleCubeUvs, getActorUvBasis, tryLoadImportedActorTexture, applyActorTexturePreview, importGeometryIntoCurrentProject };\n Plugin.register(PLUGIN_ID, {'
|
' globalThis.uvTest = { normalizeActorUvs, expandProjectUvs, scaleCubeUvs, getActorUvBasis, tryLoadImportedActorTexture, applyActorTexturePreview, importGeometryIntoCurrentProject, applyAllActorTexturePreviews, setProjectActorTextures, setProjectBoneTextureOverrides };\n Plugin.register(PLUGIN_ID, {'
|
||||||
);
|
);
|
||||||
class Group {
|
class Group {
|
||||||
static all = [];
|
static all = [];
|
||||||
@@ -46,7 +46,7 @@ function harness() {
|
|||||||
setTimeout(callback) { callback(); },
|
setTimeout(callback) { callback(); },
|
||||||
Undo: { initEdit() {}, finishEdit() {}, cancelEdit() {} },
|
Undo: { initEdit() {}, finishEdit() {}, cancelEdit() {} },
|
||||||
Project: { texture_width: 64, texture_height: 64, box_uv: true },
|
Project: { texture_width: 64, texture_height: 64, box_uv: true },
|
||||||
Canvas: { updateAllUVs() {} }, Plugin: { register() {} },
|
Canvas: { updateAllUVs() {}, updateAll() {} }, Plugin: { register() {} },
|
||||||
localStorage: { getItem() { return null; } },
|
localStorage: { getItem() { return null; } },
|
||||||
};
|
};
|
||||||
vm.runInNewContext(source, context);
|
vm.runInNewContext(source, context);
|
||||||
@@ -82,6 +82,30 @@ test('assigning a manually imported texture to an actor normalizes only that act
|
|||||||
assert.deepEqual(Array.from(selected.faces.north.uv), [0, 0, 32, 16]);
|
assert.deepEqual(Array.from(selected.faces.north.uv), [0, 0, 32, 16]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('bone texture dimensions override the base without repeated UV scaling', () => {
|
||||||
|
const h = harness();
|
||||||
|
const group = new h.context.Group({ name: 'actor1_wind', uuid: 'wind' });
|
||||||
|
group.init();
|
||||||
|
const baseCube = new h.Cube('actor1_base'), windCube = new h.Cube('actor1_wind_cube');
|
||||||
|
windCube.addTo(group);
|
||||||
|
h.context.Project.texture_width = h.context.Project.texture_height = 128;
|
||||||
|
h.normalizeActorUvs(1, 128, 128);
|
||||||
|
for (const [name, size] of [['vanilla', 32], ['wind', 128]]) {
|
||||||
|
new h.Texture({ name, uv_width: size, uv_height: size }).add();
|
||||||
|
}
|
||||||
|
h.setProjectActorTextures({ 1: 'vanilla' });
|
||||||
|
h.setProjectBoneTextureOverrides({ wind: { uuid: 'wind', name: group.name, texture: 'wind' } });
|
||||||
|
h.applyAllActorTexturePreviews();
|
||||||
|
assert.deepEqual(Array.from(baseCube.faces.north.uv), [0, 0, 64, 64]);
|
||||||
|
assert.deepEqual(Array.from(windCube.faces.north.uv), [0, 0, 16, 16]);
|
||||||
|
assert.equal(windCube.faces.north.texture, 'wind');
|
||||||
|
h.applyAllActorTexturePreviews();
|
||||||
|
assert.deepEqual(Array.from(windCube.faces.north.uv), [0, 0, 16, 16]);
|
||||||
|
h.setProjectBoneTextureOverrides({});
|
||||||
|
h.applyAllActorTexturePreviews();
|
||||||
|
assert.deepEqual(Array.from(windCube.faces.north.uv), [0, 0, 64, 64]);
|
||||||
|
});
|
||||||
|
|
||||||
test('importing a larger actor preserves UV mapping of the earlier model', () => {
|
test('importing a larger actor preserves UV mapping of the earlier model', () => {
|
||||||
const h = harness();
|
const h = harness();
|
||||||
const geometry = (width) => ({ description: { texture_width: width, texture_height: width }, bones: [{ name: 'body', cubes: [
|
const geometry = (width) => ({ description: { texture_width: width, texture_height: width }, bones: [{ name: 'body', cubes: [
|
||||||
|
|||||||
Reference in New Issue
Block a user