diff --git a/README.md b/README.md index 66b437249..e546740e6 100644 --- a/README.md +++ b/README.md @@ -2545,8 +2545,16 @@ type Props = { alphaTest?: number /** Displays the texture on a SpriteGeometry always facing the camera, if set to false, it renders on a PlaneGeometry */ asSprite?: boolean + /** Allows for manual update of the sprite animation e.g: via ScrollControls */ + offset?: number + /** Allows the sprite animation to start from the end towards the start */ + playBackwards: boolean /** Allows the animation to be paused after it ended so it can be restarted on demand via auto */ resetOnEnd?: boolean + /** An array of items to create instances from */ + instanceItems?: any[] + /** The max number of items to instance (optional) */ + maxItems?: number } ``` @@ -2570,6 +2578,37 @@ Notes: /> ``` +ScrollControls example + +```jsx +; + + + + + +function FireScroll() { + const sprite = useSpriteAnimator() + const scroll = useScroll() + const ref = React.useRef() + useFrame(() => { + if (sprite && scroll) { + sprite.current = scroll.offset + } + }) + + return null +} +``` + #### Stats [![](https://img.shields.io/badge/-storybook-%23ff69b4)](https://drei.vercel.app/?path=/story/misc-stats--default-story) diff --git a/src/core/SpriteAnimator.tsx b/src/core/SpriteAnimator.tsx index d56aaae46..7b31f075c 100644 --- a/src/core/SpriteAnimator.tsx +++ b/src/core/SpriteAnimator.tsx @@ -1,6 +1,7 @@ import * as React from 'react' import { useFrame, Vector3 } from '@react-three/fiber' import * as THREE from 'three' +import { Instances, Instance } from './Instances' export type SpriteAnimatorProps = { startFrame?: number @@ -23,9 +24,28 @@ export type SpriteAnimatorProps = { position?: Array alphaTest?: number asSprite?: boolean + offset?: number + playBackwards?: boolean resetOnEnd?: boolean + maxItems?: number + instanceItems?: any[] } & JSX.IntrinsicElements['group'] +type SpriteAnimatorState = { + /** The user-defined, mutable, current goal position along the curve, it may be >1 or <0 */ + current: number | undefined + /** The 0-1 normalised and damped current goal position along curve */ + offset: number | undefined + hasEnded: boolean | undefined + ref: React.MutableRefObject | undefined | null | ((instance: any) => void) +} + +const context = React.createContext(null!) + +export function useSpriteAnimator() { + return React.useContext(context) as SpriteAnimatorState +} + export const SpriteAnimator: React.FC = /* @__PURE__ */ React.forwardRef( ( { @@ -49,14 +69,18 @@ export const SpriteAnimator: React.FC = /* @__PURE__ */ Rea alphaTest, children, asSprite, + offset, + playBackwards, resetOnEnd, + maxItems, + instanceItems, ...props }, fref ) => { + const ref = React.useRef() const spriteData = React.useRef(null) - const [isJsonReady, setJsonReady] = React.useState(false) - const hasEnded = React.useRef(false) + //const hasEnded = React.useRef(false) const matRef = React.useRef() const spriteRef = React.useRef() const timerOffset = React.useRef(window.performance.now()) @@ -70,6 +94,30 @@ export const SpriteAnimator: React.FC = /* @__PURE__ */ Rea const flipOffset = flipX ? -1 : 1 const [displayAsSprite, setDisplayAsSprite] = React.useState(asSprite ?? true) const pauseRef = React.useRef(pause) + const pos = React.useRef(offset) + const softEnd = React.useRef(false) + const frameBuffer = React.useRef([]) + // + + function reset() {} + + const state = React.useMemo( + () => ({ + current: pos.current, + offset: pos.current, + imageUrl: textureImageURL, + reset: reset, + hasEnded: false, + ref: fref, + }), + [textureImageURL] + ) + + React.useImperativeHandle(fref, () => ref.current, []) + + React.useLayoutEffect(() => { + pos.current = offset + }, [offset]) function loadJsonAndTextureAndExecuteCallback( jsonUrl: string, @@ -114,6 +162,16 @@ export const SpriteAnimator: React.FC = /* @__PURE__ */ Rea setDisplayAsSprite(asSprite ?? true) }, [asSprite]) + // support backwards play + React.useEffect(() => { + state.hasEnded = false + if (spriteData.current && playBackwards === true) { + currentFrame.current = spriteData.current.frames.length - 1 + } else { + currentFrame.current = 0 + } + }, [playBackwards]) + React.useLayoutEffect(() => { modifySpritePosition() }, [spriteTexture, flipX]) @@ -128,7 +186,7 @@ export const SpriteAnimator: React.FC = /* @__PURE__ */ Rea if (currentFrameName.current !== frameName && frameName) { currentFrame.current = 0 currentFrameName.current = frameName - hasEnded.current = false + state.hasEnded = false modifySpritePosition() if (spriteData.current) { const { w, h } = getFirstItem(spriteData.current.frames).sourceSize @@ -138,6 +196,7 @@ export const SpriteAnimator: React.FC = /* @__PURE__ */ Rea } }, [frameName]) + // parse sprite-data from JSON file (jsonHash or jsonArray) const parseSpriteData = (json: any, _spriteTexture: THREE.Texture): void => { // sprite only case if (json === null) { @@ -149,6 +208,11 @@ export const SpriteAnimator: React.FC = /* @__PURE__ */ Rea const frameHeight = height textureData.current = _spriteTexture totalFrames.current = numberOfFrames + + if (playBackwards) { + currentFrame.current = numberOfFrames - 1 + } + spriteData.current = { frames: [], meta: { @@ -177,6 +241,10 @@ export const SpriteAnimator: React.FC = /* @__PURE__ */ Rea totalFrames.current = Array.isArray(json.frames) ? json.frames.length : Object.keys(json.frames).length textureData.current = _spriteTexture + if (playBackwards) { + currentFrame.current = totalFrames.current - 1 + } + const { w, h } = getFirstItem(json.frames).sourceSize const aspect = calculateAspectRatio(w, h) @@ -186,6 +254,21 @@ export const SpriteAnimator: React.FC = /* @__PURE__ */ Rea } } + // buffer for instanced + if (instanceItems) { + for (var i = 0; i < instanceItems.length; i++) { + const keys = Object.keys(spriteData.current.frames) + const randomKey = keys[Math.floor(Math.random() * keys.length)] + + frameBuffer.current.push({ + key: i, + frames: spriteData.current.frames, + selectedFrame: randomKey, + offset: { x: 0, y: 0 }, + }) + } + } + _spriteTexture.premultiplyAlpha = false setSpriteTexture(_spriteTexture) @@ -259,7 +342,6 @@ export const SpriteAnimator: React.FC = /* @__PURE__ */ Rea matRef.current.map.offset.x = 0.0 //-matRef.current.map.repeat.x matRef.current.map.offset.y = 1 - frameOffsetY - setJsonReady(true) if (onStart) onStart({ currentFrameName: frameName, currentFrame: currentFrame.current }) } @@ -274,11 +356,22 @@ export const SpriteAnimator: React.FC = /* @__PURE__ */ Rea } = spriteData.current const { w: frameW, h: frameH } = getFirstItem(frames).sourceSize const spriteFrames = Array.isArray(frames) ? frames : frameName ? frames[frameName] : [] - const _endFrame = endFrame || spriteFrames.length - 1 - if (currentFrame.current > _endFrame) { + var _offset = offset === undefined ? state.current : offset + + // conditionals to support backwards play + var endCondition = playBackwards ? currentFrame.current < 0 : currentFrame.current > _endFrame + var onStartCondition = playBackwards ? currentFrame.current === _endFrame : currentFrame.current === 0 + var manualProgressEndCondition = playBackwards ? currentFrame.current < 0 : currentFrame.current >= _endFrame + + if (endCondition) { currentFrame.current = loop ? startFrame ?? 0 : 0 + + if (playBackwards) { + currentFrame.current = _endFrame + } + if (loop) { onLoopEnd?.({ currentFrameName: frameName, @@ -289,7 +382,12 @@ export const SpriteAnimator: React.FC = /* @__PURE__ */ Rea currentFrameName: frameName, currentFrame: currentFrame.current, }) - hasEnded.current = resetOnEnd ? false : true + + if (!_offset) { + console.log('will end') + } + + state.hasEnded = resetOnEnd ? false : true if (resetOnEnd) { pauseRef.current = true //calculateFinalPosition(frameW, frameH, metaInfo, spriteFrames) @@ -297,14 +395,32 @@ export const SpriteAnimator: React.FC = /* @__PURE__ */ Rea } if (!loop) return + } else if (onStartCondition) { + onStart?.({ + currentFrameName: frameName, + currentFrame: currentFrame.current, + }) + } + + // for manual update + if (_offset !== undefined && manualProgressEndCondition) { + if (softEnd.current === false) { + onEnd?.({ + currentFrameName: frameName, + currentFrame: currentFrame.current, + }) + softEnd.current = true + } + } else { + // same for start? + softEnd.current = false } + // clock to limit fps if (diff <= fpsInterval) return timerOffset.current = now - (diff % fpsInterval) calculateFinalPosition(frameW, frameH, metaInfo, spriteFrames) - - currentFrame.current += 1 } const calculateFinalPosition = ( @@ -313,15 +429,23 @@ export const SpriteAnimator: React.FC = /* @__PURE__ */ Rea metaInfo: { w: number; h: number }, spriteFrames: { frame: { x: any; y: any }; sourceSize: { w: any; h: any } }[] ) => { + // get the manual update offset to find the next frame + var _offset = offset === undefined ? state.current : offset + const targetFrame = currentFrame.current let finalValX = 0 let finalValY = 0 calculateAspectRatio(frameW, frameH) const framesH = (metaInfo.w - 1) / frameW const framesV = (metaInfo.h - 1) / frameH + if (!spriteFrames[targetFrame]) { + return + } + const { frame: { x: frameX, y: frameY }, sourceSize: { w: originalSizeX, h: originalSizeY }, - } = spriteFrames[currentFrame.current] + } = spriteFrames[targetFrame] + const frameOffsetX = 1 / framesH const frameOffsetY = 1 / framesV finalValX = @@ -332,6 +456,28 @@ export const SpriteAnimator: React.FC = /* @__PURE__ */ Rea matRef.current.map.offset.x = finalValX matRef.current.map.offset.y = finalValY + + // if manual update is active + if (_offset !== undefined && _offset !== null) { + // Calculate the frame index, based on offset given from the provider + let frameIndex = Math.floor(_offset * spriteFrames.length) + + // Ensure the frame index is within the valid range + frameIndex = Math.max(0, Math.min(frameIndex, spriteFrames.length - 1)) + + if (isNaN(frameIndex)) { + console.log('nan frame detected') + frameIndex = 0 //fallback + } + currentFrame.current = frameIndex + } else { + // auto update + if (playBackwards) { + currentFrame.current -= 1 + } else { + currentFrame.current += 1 + } + } } // *** Warning! It runs on every frame! *** @@ -344,7 +490,7 @@ export const SpriteAnimator: React.FC = /* @__PURE__ */ Rea return } - if (!hasEnded.current && (autoPlay || play)) { + if (!state.hasEnded && (autoPlay || play)) { runAnimation() onFrame && onFrame({ currentFrameName: currentFrameName.current, currentFrame: currentFrame.current }) } @@ -363,34 +509,57 @@ export const SpriteAnimator: React.FC = /* @__PURE__ */ Rea } return ( - - - {displayAsSprite && ( - - - - )} - {!displayAsSprite && ( - - - - - )} - - {children} + + + + {displayAsSprite && ( + + + + )} + {!displayAsSprite && ( + + + + + {(instanceItems ?? [0]).map((item, index) => { + const texture = spriteTexture.clone() + if (matRef.current && frameBuffer.current[index]) { + texture.offset.set(frameBuffer.current[index].offset.x, frameBuffer.current[index].offset.y) // Set the offset for this item + } + + return ( + + + + ) + })} + + )} + + {children} + ) }