Skip to content

Commit

Permalink
Import several animations to a sprite object in one go (#6035)
Browse files Browse the repository at this point in the history
- Sprite animations are automatically created when several image files are selected and they ends with an animation name and an optional frame index (most naming conversions should work).
  • Loading branch information
D8H authored Jan 4, 2024
1 parent fdd702c commit 0d49d44
Show file tree
Hide file tree
Showing 7 changed files with 930 additions and 404 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
// @flow
import path from 'path-browserify';
import groupBy from 'lodash/groupBy';

const findCommonPrefix = (values: Array<string>): string => {
if (values.length === 0) {
return '';
}
if (values.length === 1) {
return values[0];
}
let hasFoundBiggerPrefix = true;
let prefixLength = 0;
for (let index = 0; hasFoundBiggerPrefix; index++) {
const character = values[0].charAt(index);
hasFoundBiggerPrefix = values.every(
value => value.length > index && value.charAt(index) === character
);
if (!hasFoundBiggerPrefix) {
prefixLength = index;
}
}
return values[0].substring(0, prefixLength);
};

const separators = [' ', '_', '-', '.'];

const trimFromSeparators = (value: string) => {
let lowerIndex = 0;
for (let index = 0; index < value.length; index++) {
if (!separators.includes(value.charAt(index))) {
lowerIndex = index - 1;
break;
}
}
let upperIndex = value.length - 1;
for (let index = value.length - 1; index >= lowerIndex; index--) {
if (!separators.includes(value.charAt(index))) {
upperIndex = index + 1;
break;
}
}
return value.substring(lowerIndex, upperIndex + 1);
};

export const groupResourcesByAnimations = (
resources: Array<gdResource>
): Map<string, Array<gdResource>> => {
const resourcesByAnimation = new Map<string, Array<gdResource>>();

if (resources.length === 0) {
return resourcesByAnimation;
}

// Extract the frame indexes from the file names.
const namedResources = resources.map(resource => {
// The resource name is used instead of the resource file path because
// cloud projects are prefixing files names with a UID.
const basename = path.basename(
resource.getName(),
path.extname(resource.getName())
);
const indexMatches = basename.match(/\(\d+\)$|\d+$/g);
const indexNumberMatches =
indexMatches && indexMatches[0].match(/\(\d+\)$|\d+$/g);
const index = indexNumberMatches ? parseInt(indexNumberMatches[0]) : null;
let name = trimFromSeparators(
indexMatches
? basename.substring(0, basename.length - indexMatches[0].length)
: basename
);
if (separators.some(separator => name.endsWith(separator))) {
name = name.substring(0, name.length - 1);
}
return {
resource,
name,
index: isNaN(index) ? null : index,
};
});

const commonPrefix = findCommonPrefix(
namedResources.map(resources => resources.name)
);
// Remove the common prefix as it's probably the object name.
for (const namedResource of namedResources) {
namedResource.name = namedResource.name.substring(commonPrefix.length);
}

// Index the resources by animation names and frame indexes.
const resourcesByName = groupBy(namedResources, ({ name }) => name);
for (const name in resourcesByName) {
const enumeratedResources = resourcesByName[name];
resourcesByAnimation.set(
name,
enumeratedResources
.sort((a, b) => (a.index || 0) - (b.index || 0))
.map(resource => resource.resource)
);
}
return resourcesByAnimation;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
// @flow
import { groupResourcesByAnimations } from './AnimationImportHelper';
const gd = global.gd;

describe('AnimationImportHelper', () => {
const createResources = (filePaths: Array<string>): Array<gdResource> =>
filePaths.map(filePath => {
const resource = new gd.ImageResource();
resource.setName(filePath);
resource.setFile(filePath);
return resource;
});

const deleteResources = (resources: Array<gdResource>): void => {
for (const resource of resources) {
resource.delete();
}
};

const getAnimationFramesCount = (
resourcesByAnimations: Map<string, Array<gdResource>>,
animationName: string
): number | null => {
const frames = resourcesByAnimations.get(animationName);
return frames ? frames.length : null;
};

const getAnimationFramesPaths = (
resourcesByAnimations: Map<string, Array<gdResource>>,
animationName: string
): Array<string> | null => {
const frames = resourcesByAnimations.get(animationName);
return frames ? frames.map(resource => resource.getFile()) : null;
};

it('can handle empty resource lists', () => {
const resources = createResources([]);

const resourcesByAnimations = groupResourcesByAnimations(resources);
expect(resourcesByAnimations.size).toBe(0);

deleteResources(resources);
});

it('can handle 1 frame alone', () => {
const resources = createResources(['Assets/Player Jump.png']);

const resourcesByAnimations = groupResourcesByAnimations(resources);
expect(resourcesByAnimations.size).toBe(1);
expect(getAnimationFramesCount(resourcesByAnimations, '')).toBe(1);

deleteResources(resources);
});

it('can handle 1 animation alone', () => {
const resources = createResources([
'Assets/Player Run 1.png',
'Assets/Player Run 2.png',
]);

const resourcesByAnimations = groupResourcesByAnimations(resources);
expect(resourcesByAnimations.size).toBe(1);
expect(getAnimationFramesCount(resourcesByAnimations, '')).toBe(2);

deleteResources(resources);
});

it('can find animation names without frame indexes', () => {
const resources = createResources([
'Assets/Player Jump.png',
'Assets/Player Run 1.png',
'Assets/Player Run 2.png',
]);

const resourcesByAnimations = groupResourcesByAnimations(resources);
expect(resourcesByAnimations.size).toBe(2);
expect(getAnimationFramesCount(resourcesByAnimations, 'Jump')).toBe(1);
expect(getAnimationFramesCount(resourcesByAnimations, 'Run')).toBe(2);

deleteResources(resources);
});

it('can find animation names without object name', () => {
const resources = createResources([
'Assets/Jump.png',
'Assets/Run 1.png',
'Assets/Run 2.png',
]);

const resourcesByAnimations = groupResourcesByAnimations(resources);
expect(resourcesByAnimations.size).toBe(2);
expect(getAnimationFramesCount(resourcesByAnimations, 'Jump')).toBe(1);
expect(getAnimationFramesCount(resourcesByAnimations, 'Run')).toBe(2);

deleteResources(resources);
});

it('can find animation names without any separator', () => {
const resources = createResources([
'Assets/PlayerJump.png',
'Assets/PlayerRun1.png',
'Assets/PlayerRun2.png',
]);

const resourcesByAnimations = groupResourcesByAnimations(resources);
expect(resourcesByAnimations.size).toBe(2);
expect(getAnimationFramesCount(resourcesByAnimations, 'Jump')).toBe(1);
expect(getAnimationFramesCount(resourcesByAnimations, 'Run')).toBe(2);

deleteResources(resources);
});

it('can find animation names when separator contains several characters', () => {
const resources = createResources([
'Assets/Player - Jump.png',
'Assets/Player - Run - 1.png',
'Assets/Player - Run - 2.png',
]);

const resourcesByAnimations = groupResourcesByAnimations(resources);
expect(resourcesByAnimations.size).toBe(2);
expect(getAnimationFramesCount(resourcesByAnimations, 'Jump')).toBe(1);
expect(getAnimationFramesCount(resourcesByAnimations, 'Run')).toBe(2);

deleteResources(resources);
});

it('can sort frames by numerical order', () => {
const resources = createResources([
'Assets/PlayerRun02.png',
'Assets/PlayerJump.png',
'Assets/PlayerRun1.png',
]);

const resourcesByAnimations = groupResourcesByAnimations(resources);
expect(resourcesByAnimations.size).toBe(2);
expect(getAnimationFramesCount(resourcesByAnimations, 'Jump')).toBe(1);
expect(getAnimationFramesPaths(resourcesByAnimations, 'Run')).toStrictEqual(
['Assets/PlayerRun1.png', 'Assets/PlayerRun02.png']
);

deleteResources(resources);
});

it('can sort frames by numerical order without taking "-" as a negative sign', () => {
const resources = createResources([
'Assets/Jump.png',
'Assets/Run-1.png',
'Assets/Run-2.png',
]);

const resourcesByAnimations = groupResourcesByAnimations(resources);
expect(resourcesByAnimations.size).toBe(2);
expect(getAnimationFramesCount(resourcesByAnimations, 'Jump')).toBe(1);
expect(getAnimationFramesPaths(resourcesByAnimations, 'Run')).toStrictEqual(
['Assets/Run-1.png', 'Assets/Run-2.png']
);

deleteResources(resources);
});

it('can find frame index inside parenthesis', () => {
const resources = createResources([
'Assets/Player Run (1).png',
'Assets/Player Run (2).png',
'Assets/Player Jump.png',
]);

const resourcesByAnimations = groupResourcesByAnimations(resources);
expect(resourcesByAnimations.size).toBe(2);
expect(getAnimationFramesCount(resourcesByAnimations, 'Jump')).toBe(1);
expect(getAnimationFramesCount(resourcesByAnimations, 'Run')).toBe(2);

deleteResources(resources);
});

it('can find animation frames when indexes have holes', () => {
const resources = createResources([
'Assets/Jump.png',
'Assets/Run 0.png',
'Assets/Run 567.png',
]);

const resourcesByAnimations = groupResourcesByAnimations(resources);
expect(resourcesByAnimations.size).toBe(2);
expect(getAnimationFramesCount(resourcesByAnimations, 'Jump')).toBe(1);
expect(getAnimationFramesCount(resourcesByAnimations, 'Run')).toBe(2);

deleteResources(resources);
});
});
Loading

0 comments on commit 0d49d44

Please sign in to comment.