Animate spritesheet
Uploading 20240410_144737.mp4…
Description
Hello @william-candillon,
I've noticed several discussions in the community where developers, including myself, were unsure about how to properly animate a sprite sheet using React Native Skia. After much experimentation and navigating through a bit of trial and error, I've developed a solution that seems to work effectively.
Given the apparent gap in resources and tutorials on this topic, I was wondering if you might consider creating a dedicated component to simplify this process for others. Alternatively, a tutorial video on your YouTube channel could greatly benefit the community by providing a clear, accessible guide on sprite sheet animation with React Native Skia.
Thank you for considering this suggestion. Your contributions have been incredibly valuable to the community, and I believe this could be another great addition.
import { MaterialCommunityIcons } from "@expo/vector-icons"; import { useCallback, useEffect, useMemo, useState } from "react"; import { Pressable, View, useWindowDimensions } from "react-native"; import { collisions } from "../data/collision"; import { PlatformConfig } from "@/Store/PlatformStore"; import { is2DColiding, isColiding } from "@/helpers/isColiding";
import {
Canvas,
Circle,
Group,
Image,
Mask,
Path,
Rect,
useClock,
useImage,
vec,
} from "@shopify/react-native-skia";
import {
SharedValue,
useDerivedValue,
useFrameCallback,
useSharedValue,
} from "react-native-reanimated";
export default function App() {
const [newTileMap, setNewTileMap] = useState<PlatformConfig[]>([]);
const vec2 = (x: SharedValue
const tileSize = 32; const tileRows = 211; const tileCols = 15; const SCREEN_WIDTH = useWindowDimensions().width; const SCREEN_HEIGHT = useWindowDimensions().height; const mario_spriteSheet = useImage(require("../assets/sprites/mario_spritesheet.png")); const spriteWidth = 186; const spriteHeight = 34; const frameSize = vec(spriteWidth / 6, spriteHeight);
enum PlayerState { IDLE, WALKING, JUMPING, FALLING, }
const playerState = useSharedValue(PlayerState.IDLE); const bg = useImage(require("../assets/sprites/tiles/bg.png")); const wall = useImage(require("../assets/sprites/tiles/land_14.png")); const map = useImage(require("../assets/sprites/tiles/map.png")); const direction = { left: useSharedValue(false), right: useSharedValue(false), up: useSharedValue(false), down: useSharedValue(false), };
const groundY = SCREEN_HEIGHT - 240;
const loadMap = useCallback(() => { const tileMap: PlatformConfig[] = []; collisions.forEach((row, rowIndex) => { row.forEach((col, colIndex) => { if (col !== 0) { tileMap.push({ x: colIndex * tileSize, y: rowIndex * tileSize, w: tileSize, h: tileSize, val: col, }); } }); }); setNewTileMap(tileMap); }, []);
useEffect(() => { loadMap(); }, []);
const player = { h: frameSize.y, w: frameSize.x, velocity: vec2(useSharedValue(0), useSharedValue(0)), pos: { x: useSharedValue(250), y: useSharedValue(groundY + 154), }, };
const world = { x: useSharedValue(0), y: useSharedValue(groundY), vel: vec2(useSharedValue(0), useSharedValue(0)), };
const transform = useDerivedValue(() => { return [ { translateX: world.x.value }, { translateY: world.y.value }, { scaleX: 2 }, { scaleY: 2 } ]; });
const frame = useSharedValue(1); const frameDerived = useDerivedValue(() => { return -frame.value * frameSize.x; }); const elapsed = useSharedValue(0);
const prevWorldPosX = useSharedValue(0); const prevWorldPosY = useSharedValue(0);
const getTileX = (x: number) => { 'worklet'
return x + world.x.value;
} const getTileY = (y: number) => { 'worklet'
return y + world.y.value + groundY;
}
const playerOBJ = { x: player.pos.x.value, y: player.pos.y.value, w: frameSize.x, h: frameSize.y, };
function applyGravity(){ 'worklet' world.vel.y.value -= 2; world.y.value += world.vel.y.value; };
function checkCollisionv(){ 'worklet' newTileMap.forEach((tile) => { const adjustedTile = { x: getTileX(tile.x), y: getTileY(tile.y - 45), w: tile.w, h: tile.h, };
if (is2DColiding(adjustedTile, playerOBJ)) {
if (world.vel.y.value < 0) {
let overlap = playerOBJ.y + playerOBJ.h - adjustedTile.y;
world.y.value += overlap + 0.01;
world.vel.y.value = 0;
} else if (world.vel.y.value > 0) {
world.vel.y.value = 0;
world.y.value -= adjustedTile.y + adjustedTile.h - playerOBJ.y - 0.01;
}
}
});
};
function checkCollisionH(){ 'worklet' newTileMap.forEach((tile) => { const adjustedTile = { x: getTileX(tile.x), y: getTileY(tile.y), w: tile.w, h: tile.h, };
if (is2DColiding(adjustedTile, playerOBJ)) {
if (world.vel.x.value < 0) {
const overlap = playerOBJ.x + playerOBJ.w - adjustedTile.x;
world.x.value += overlap + 0.01;
world.vel.x.value = 0;
} else if (world.vel.x.value > 0) {
world.x.value -= adjustedTile.x + adjustedTile.w - playerOBJ.x + 0.01;
world.vel.x.value = 0;
}
}
});
}; useFrameCallback(({ timeSincePreviousFrame: dt }) => { if (!dt) return;
prevWorldPosX.value = world.x.value;
prevWorldPosY.value = world.y.value;
world.x.value += world.vel.x.value;
elapsed.value += dt;
if (playerState.value === PlayerState.WALKING) {
frame.value = Math.floor(elapsed.value / 0.6) % 2;
} else if (playerState.value === PlayerState.IDLE) {
frame.value = 4;
} else if (playerState.value === PlayerState.JUMPING) {
frame.value = 5;
} else if (playerState.value === PlayerState.FALLING) {
frame.value = 3;
}
function checkPlayerStateAndVelocity(){ 'worklet' if (direction.right.value) { playerState.value = PlayerState.WALKING; world.vel.x.value = -3; } else if (direction.left.value) { playerState.value = PlayerState.WALKING; world.vel.x.value = 3; } else if (!direction.up.value && !direction.down.value && !direction.left.value && !direction.right.value) { playerState.value = PlayerState.IDLE; }
if (direction.up.value) {
playerState.value = PlayerState.JUMPING;
world.vel.y.value = 15;
} else if (direction.down.value) {
playerState.value = PlayerState.FALLING;
world.vel.y.value = 3;
} else {
world.vel.y.value = 0;
}
};
checkPlayerStateAndVelocity(); applyGravity(); checkCollisionv(); checkCollisionH();
});
return ( <View style={{ position: "relative", flex: 1, justifyContent: "center", alignItems: "center" }}> <Canvas style={{ width: SCREEN_WIDTH, height: SCREEN_HEIGHT, backgroundColor: "#5c94fc" }}> <Group transform={transform}> <Image image={map} x={0} y={50} width={211 * 16} height={15 * 16} /> </Group>
<Group transform={transform}>
{newTileMap.map((tile, index) => (
<Image
key={index}
opacity={0.5}
image={tile.val === 1 ? wall : bg}
x={tile.x}
y={tile.y - 160}
width={tile.w}
height={tile.h}
/>
))}
</Group>
<Group
origin={{ y: SCREEN_HEIGHT, x: 0 }}
transform={[
{ translateX: player.pos.x.value },
{ translateY: player.pos.y.value },
]}
>
<Mask mask={<Rect x={0} y={0} width={frameSize.x} height={frameSize.y} />}>
<Image
image={mario_spriteSheet}
x={frameDerived}
y={0}
width={spriteWidth}
height={spriteHeight}
/>
</Mask>
</Group>
</Canvas>
{createControlButton("arrow-left", 60, () => {
direction.left.value = true;
}, () => {
direction.left.value = false;
if (playerState.value === PlayerState.WALKING) {
world.vel.x.value = 0;
}
})}
{createControlButton("arrow-right", 20, () => {
direction.right.value = true;
}, () => {
direction.right.value = false;
if (playerState.value === PlayerState.WALKING) {
world.vel.x.value = 0;
}
})}
{createControlButton("arrow-up", 100, () => {
direction.up.value = true;
}, () => {
direction.up.value = false;
})}
</View>
); }
function createControlButton(iconName: string|any, positionLeft: number, onPressIn: () => void, onPressOut: () => void): JSX.Element { return ( <Pressable style={{ position: "absolute", bottom: 20, left: positionLeft, }} onPressIn={onPressIn} onPressOut={onPressOut} > <MaterialCommunityIcons name={iconName} size={24} color="red" /> </Pressable> ); }
Can you please formate your code and may be upload the video for more details? It will be very helpful.
Please let know if you have a reproducible example and what you think the bug might be.