403 lines
14 KiB
JavaScript
403 lines
14 KiB
JavaScript
import * as THREE from 'three';
|
|
import { TextureLoader } from 'three';
|
|
import { useThree, useLoader } from '@react-three/fiber';
|
|
import * as React from 'react';
|
|
import { useState } from 'react';
|
|
|
|
/* eslint react-hooks/exhaustive-deps: 1 */
|
|
// utils
|
|
const getFirstFrame = (frames, frameName) => {
|
|
if (Array.isArray(frames)) {
|
|
return frames[0];
|
|
} else {
|
|
const k = frameName !== null && frameName !== void 0 ? frameName : Object.keys(frames)[0];
|
|
return frames[k][0];
|
|
}
|
|
};
|
|
const checkIfFrameIsEmpty = frameData => {
|
|
for (let i = 3; i < frameData.length; i += 4) {
|
|
if (frameData[i] !== 0) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
};
|
|
function useSpriteLoader(/** The URL of the sprite sheet. */
|
|
input, /** The JSON data of the sprite sheet. */
|
|
json, /** The names of the animations in the sprite sheet. */
|
|
animationNames, /** The number of frames in the sprite sheet. */
|
|
numberOfFrames, /** A callback that is called when the sprite sheet is loaded. */
|
|
onLoad, /** The settings to use when creating the 2D context. */
|
|
canvasRenderingContext2DSettings) {
|
|
const viewportRef = React.useRef(useThree(state => state.viewport));
|
|
const spriteDataRef = React.useRef(null);
|
|
const totalFrames = React.useRef(0);
|
|
const aspectFactor = 0.1;
|
|
const inputRef = React.useRef(input);
|
|
const jsonRef = React.useRef(json);
|
|
const animationFramesRef = React.useRef(animationNames);
|
|
const [spriteData, setSpriteData] = useState(null);
|
|
const [spriteTexture, setSpriteTexture] = React.useState(new THREE.Texture());
|
|
const textureLoader = React.useMemo(() => new THREE.TextureLoader(), []);
|
|
const [spriteObj, setSpriteObj] = useState(null);
|
|
const calculateAspectRatio = React.useCallback((width, height, factor) => {
|
|
const adaptedHeight = height * (viewportRef.current.aspect > width / height ? viewportRef.current.width / width : viewportRef.current.height / height);
|
|
const adaptedWidth = width * (viewportRef.current.aspect > width / height ? viewportRef.current.width / width : viewportRef.current.height / height);
|
|
const scaleX = adaptedWidth * factor;
|
|
const scaleY = adaptedHeight * factor;
|
|
const currentMaxScale = 1;
|
|
// Calculate the maximum scale based on the aspect ratio and max scale limit
|
|
let finalMaxScaleW = Math.min(currentMaxScale, scaleX);
|
|
let finalMaxScaleH = Math.min(currentMaxScale, scaleY);
|
|
|
|
// Ensure that scaleX and scaleY do not exceed the max scale while maintaining aspect ratio
|
|
if (scaleX > currentMaxScale) {
|
|
finalMaxScaleW = currentMaxScale;
|
|
finalMaxScaleH = scaleY / scaleX * currentMaxScale;
|
|
}
|
|
return new THREE.Vector3(finalMaxScaleW, finalMaxScaleH, 1);
|
|
}, []);
|
|
const getRowsAndColumns = React.useCallback((texture, totalFrames) => {
|
|
if (texture.image) {
|
|
const canvas = document.createElement('canvas');
|
|
const ctx = canvas.getContext('2d', canvasRenderingContext2DSettings);
|
|
if (!ctx) {
|
|
throw new Error('Failed to get 2d context');
|
|
}
|
|
canvas.width = texture.image.width;
|
|
canvas.height = texture.image.height;
|
|
ctx.drawImage(texture.image, 0, 0);
|
|
const width = texture.image.width;
|
|
const height = texture.image.height;
|
|
|
|
// Calculate rows and columns based on the number of frames and image dimensions
|
|
const cols = Math.round(Math.sqrt(totalFrames * (width / height)));
|
|
const rows = Math.round(totalFrames / cols);
|
|
const frameWidth = width / cols;
|
|
const frameHeight = height / rows;
|
|
const emptyFrames = [];
|
|
for (let row = 0; row < rows; row++) {
|
|
for (let col = 0; col < cols; col++) {
|
|
const frameIndex = row * cols + col;
|
|
if (frameIndex >= totalFrames) {
|
|
emptyFrames.push({
|
|
row,
|
|
col
|
|
});
|
|
continue;
|
|
}
|
|
const frameData = ctx.getImageData(col * frameWidth, row * frameHeight, frameWidth, frameHeight).data;
|
|
const isEmpty = checkIfFrameIsEmpty(frameData);
|
|
if (isEmpty) {
|
|
emptyFrames.push({
|
|
row,
|
|
col
|
|
});
|
|
}
|
|
}
|
|
}
|
|
return {
|
|
rows,
|
|
columns: cols,
|
|
frameWidth,
|
|
frameHeight,
|
|
emptyFrames
|
|
};
|
|
} else {
|
|
return {
|
|
rows: 0,
|
|
columns: 0,
|
|
frameWidth: 0,
|
|
frameHeight: 0,
|
|
emptyFrames: []
|
|
};
|
|
}
|
|
}, [canvasRenderingContext2DSettings]);
|
|
|
|
// calculate scale ratio for the frames
|
|
const calculateScaleRatio = React.useCallback(frames => {
|
|
// Helper function to calculate scale ratio for an array of frames
|
|
const processFrameArray = frameArray => {
|
|
// Find the largest frame
|
|
let largestFrame = null;
|
|
for (const frame of frameArray) {
|
|
const {
|
|
w,
|
|
h
|
|
} = frame.frame;
|
|
const area = w * h;
|
|
if (!largestFrame || area > largestFrame.area) {
|
|
largestFrame = {
|
|
w,
|
|
h,
|
|
area
|
|
};
|
|
}
|
|
}
|
|
|
|
// Set scaleRatio property on each frame
|
|
const frameArr = frameArray.map(frame => {
|
|
const {
|
|
w,
|
|
h
|
|
} = frame.frame;
|
|
const area = w * h;
|
|
const scaleRatio = largestFrame ? area === largestFrame.area ? 1 : Math.sqrt(area / largestFrame.area) : 1;
|
|
return {
|
|
...frame,
|
|
scaleRatio
|
|
};
|
|
});
|
|
return frameArr;
|
|
};
|
|
|
|
// Handle both array and record cases
|
|
if (Array.isArray(frames)) {
|
|
return processFrameArray(frames);
|
|
} else {
|
|
const result = {};
|
|
for (const key in frames) {
|
|
result[key] = processFrameArray(frames[key]);
|
|
}
|
|
return result;
|
|
}
|
|
}, []);
|
|
|
|
// for frame based JSON Hash sprite data
|
|
const parseFrames = React.useCallback(() => {
|
|
const sprites = {};
|
|
const data = spriteDataRef.current;
|
|
const delimiters = animationFramesRef.current;
|
|
if (data) {
|
|
if (delimiters && Array.isArray(data['frames'])) {
|
|
for (let i = 0; i < delimiters.length; i++) {
|
|
// we convert each named animation group into an array
|
|
sprites[delimiters[i]] = [];
|
|
for (const value of data['frames']) {
|
|
const frameData = value['frame'];
|
|
const sourceWidth = value['sourceSize']['w'];
|
|
const sourceHeight = value['sourceSize']['h'];
|
|
if (typeof value['filename'] === 'string' && value['filename'].toLowerCase().indexOf(delimiters[i].toLowerCase()) !== -1) {
|
|
sprites[delimiters[i]].push({
|
|
...value,
|
|
frame: frameData,
|
|
sourceSize: {
|
|
w: sourceWidth,
|
|
h: sourceHeight
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|
|
for (const frame in sprites) {
|
|
const scaleRatioData = calculateScaleRatio(sprites[frame]);
|
|
if (Array.isArray(scaleRatioData)) {
|
|
sprites[frame] = scaleRatioData;
|
|
}
|
|
}
|
|
return sprites;
|
|
} else if (delimiters && typeof data['frames'] === 'object') {
|
|
for (let i = 0; i < delimiters.length; i++) {
|
|
// we convert each named animation group into an array
|
|
sprites[delimiters[i]] = [];
|
|
for (const innerKey in data['frames']) {
|
|
const value = data['frames'][innerKey];
|
|
const frameData = value['frame'];
|
|
const sourceWidth = value['sourceSize']['w'];
|
|
const sourceHeight = value['sourceSize']['h'];
|
|
if (typeof innerKey === 'string' && innerKey.toLowerCase().indexOf(delimiters[i].toLowerCase()) !== -1) {
|
|
sprites[delimiters[i]].push({
|
|
...value,
|
|
frame: frameData,
|
|
sourceSize: {
|
|
w: sourceWidth,
|
|
h: sourceHeight
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|
|
for (const frame in sprites) {
|
|
const scaleRatioData = calculateScaleRatio(sprites[frame]);
|
|
if (Array.isArray(scaleRatioData)) {
|
|
sprites[frame] = scaleRatioData;
|
|
}
|
|
}
|
|
return sprites;
|
|
} else {
|
|
let spritesArr = [];
|
|
if (data != null && data.frames) {
|
|
if (Array.isArray(data.frames)) {
|
|
spritesArr = data.frames.map(frame => ({
|
|
...frame,
|
|
x: frame.frame.x,
|
|
y: frame.frame.y,
|
|
w: frame.frame.w,
|
|
h: frame.frame.h
|
|
}));
|
|
} else {
|
|
spritesArr = Object.values(data.frames).flat().map(frame => ({
|
|
...frame,
|
|
x: frame.frame.x,
|
|
y: frame.frame.y,
|
|
w: frame.frame.w,
|
|
h: frame.frame.h
|
|
}));
|
|
}
|
|
}
|
|
return calculateScaleRatio(spritesArr);
|
|
}
|
|
}
|
|
return [];
|
|
}, [calculateScaleRatio, spriteDataRef]);
|
|
const parseSpriteData = React.useCallback((json, _spriteTexture) => {
|
|
let aspect = new THREE.Vector3(1, 1, 1);
|
|
// sprite only case
|
|
if (json === null) {
|
|
if (_spriteTexture && numberOfFrames) {
|
|
//get size from texture
|
|
const width = _spriteTexture.image.width;
|
|
const height = _spriteTexture.image.height;
|
|
totalFrames.current = numberOfFrames;
|
|
const {
|
|
rows,
|
|
columns,
|
|
frameWidth,
|
|
frameHeight,
|
|
emptyFrames
|
|
} = getRowsAndColumns(_spriteTexture, numberOfFrames);
|
|
const nonJsonFrames = {
|
|
frames: [],
|
|
meta: {
|
|
version: '1.0',
|
|
size: {
|
|
w: width,
|
|
h: height
|
|
},
|
|
rows,
|
|
columns,
|
|
frameWidth,
|
|
frameHeight,
|
|
scale: '1'
|
|
}
|
|
};
|
|
for (let row = 0; row < rows; row++) {
|
|
for (let col = 0; col < columns; col++) {
|
|
const isExcluded = (emptyFrames !== null && emptyFrames !== void 0 ? emptyFrames : []).some(coord => coord.row === row && coord.col === col);
|
|
if (isExcluded) {
|
|
continue;
|
|
}
|
|
if (Array.isArray(nonJsonFrames.frames)) {
|
|
nonJsonFrames.frames.push({
|
|
frame: {
|
|
x: col * frameWidth,
|
|
y: row * frameHeight,
|
|
w: frameWidth,
|
|
h: frameHeight
|
|
},
|
|
scaleRatio: 1,
|
|
rotated: false,
|
|
trimmed: false,
|
|
spriteSourceSize: {
|
|
x: 0,
|
|
y: 0,
|
|
w: frameWidth,
|
|
h: frameHeight
|
|
},
|
|
sourceSize: {
|
|
w: frameWidth,
|
|
h: frameHeight
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|
|
aspect = calculateAspectRatio(frameWidth, frameHeight, aspectFactor);
|
|
spriteDataRef.current = nonJsonFrames;
|
|
}
|
|
|
|
//scale ratio for standalone sprite
|
|
if (spriteDataRef.current && spriteDataRef.current.frames) {
|
|
spriteDataRef.current.frames = calculateScaleRatio(spriteDataRef.current.frames);
|
|
}
|
|
} else if (_spriteTexture) {
|
|
spriteDataRef.current = json;
|
|
spriteDataRef.current.frames = parseFrames();
|
|
totalFrames.current = Array.isArray(json.frames) ? json.frames.length : Object.keys(json.frames).length;
|
|
const {
|
|
w,
|
|
h
|
|
} = getFirstFrame(json.frames).sourceSize;
|
|
aspect = calculateAspectRatio(w, h, aspectFactor);
|
|
}
|
|
setSpriteData(spriteDataRef.current);
|
|
if ('encoding' in _spriteTexture) {
|
|
_spriteTexture.encoding = 3001; // sRGBEncoding
|
|
} else if ('colorSpace' in _spriteTexture) {
|
|
//@ts-ignore
|
|
_spriteTexture.colorSpace = THREE.SRGBColorSpace;
|
|
}
|
|
setSpriteTexture(_spriteTexture);
|
|
setSpriteObj({
|
|
spriteTexture: _spriteTexture,
|
|
spriteData: spriteDataRef.current,
|
|
aspect: aspect
|
|
});
|
|
}, [getRowsAndColumns, numberOfFrames, parseFrames, calculateAspectRatio, calculateScaleRatio]);
|
|
|
|
/**
|
|
*
|
|
*/
|
|
const loadJsonAndTextureAndExecuteCallback = React.useCallback((jsonUrl, textureUrl, callback) => {
|
|
const jsonPromise = fetch(jsonUrl).then(response => response.json());
|
|
const texturePromise = new Promise(resolve => {
|
|
textureLoader.load(textureUrl, resolve);
|
|
});
|
|
Promise.all([jsonPromise, texturePromise]).then(response => {
|
|
callback(response[0], response[1]);
|
|
});
|
|
}, [textureLoader]);
|
|
const loadStandaloneSprite = React.useCallback(textureUrl => {
|
|
if (!textureUrl && !inputRef.current) {
|
|
throw new Error('Either textureUrl or input must be provided');
|
|
}
|
|
const validUrl = textureUrl !== null && textureUrl !== void 0 ? textureUrl : inputRef.current;
|
|
if (!validUrl) {
|
|
throw new Error('A valid texture URL must be provided');
|
|
}
|
|
textureLoader.load(validUrl, texture => parseSpriteData(null, texture));
|
|
}, [textureLoader, parseSpriteData]);
|
|
const loadJsonAndTexture = React.useCallback((textureUrl, jsonUrl) => {
|
|
if (jsonUrl && textureUrl) {
|
|
loadJsonAndTextureAndExecuteCallback(jsonUrl, textureUrl, parseSpriteData);
|
|
} else {
|
|
loadStandaloneSprite(textureUrl);
|
|
}
|
|
}, [loadJsonAndTextureAndExecuteCallback, loadStandaloneSprite, parseSpriteData]);
|
|
React.useLayoutEffect(() => {
|
|
if (jsonRef.current && inputRef.current) {
|
|
loadJsonAndTextureAndExecuteCallback(jsonRef.current, inputRef.current, parseSpriteData);
|
|
} else if (inputRef.current) {
|
|
// only load the texture, this is an image sprite only
|
|
loadStandaloneSprite();
|
|
}
|
|
const _inputRef = inputRef.current;
|
|
return () => {
|
|
if (_inputRef) {
|
|
useLoader.clear(TextureLoader, _inputRef);
|
|
}
|
|
};
|
|
}, [loadJsonAndTextureAndExecuteCallback, loadStandaloneSprite, parseSpriteData]);
|
|
React.useLayoutEffect(() => {
|
|
onLoad == null || onLoad(spriteTexture, spriteData !== null && spriteData !== void 0 ? spriteData : null);
|
|
}, [spriteTexture, spriteData, onLoad]);
|
|
return {
|
|
spriteObj,
|
|
loadJsonAndTexture
|
|
};
|
|
}
|
|
useSpriteLoader.preload = url => useLoader.preload(TextureLoader, url);
|
|
useSpriteLoader.clear = input => useLoader.clear(TextureLoader, input);
|
|
|
|
export { checkIfFrameIsEmpty, getFirstFrame, useSpriteLoader };
|