Animate spritesheet
mckeny3 opened this issue · comments
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, y: SharedValue) => {
return { x, y };
};
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" }}>
<Image image={map} x={0} y={50} width={211 * 16} height={15 * 16} />
<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}
>
);
}
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.