If you’ve been following my articles for a while, you would have known I have written a little isometric grid demo in pure Javascript.
That approach works well for what I was trying to archive but I am planning on adding more functionality into the website. Hence in this article, let me rebuild the project using Pixi.js and it’s React binding, React Pixi.
If you wanna learn more about the math behind isometric graphics, I suggests you checking out this video for it’s in depth explanation
Awesome asset pack from @dani_maccari !
For starters, I found a great isometric asset pack from itch.io that suits my needs. It includes some fine detailed blocks which would come in handy in some of the future use cases!
Secondly, let’s set up a react project using create-react-app and install the dependencies we need:
npx create-react-app isometric-world --template typescript
cd isometric-world && npm install pixi.js @pixi/react
Now we have our project setup, let’s unzip the assets and place it into the public directory in your project. Doing so allows pixi.js to reference the assets as sprite sheets in later step.
Following React-Pixi Getting Started tutorial, we should have the stage setup like this:
import { BlurFilter } from 'pixi.js';
import { Stage, Container, Sprite, Text } from '@pixi/react';
import { useMemo } from 'react';
export const MyComponent = () =>
{
const blurFilter = useMemo(() => new BlurFilter(4), []);
return (
<Stage>
<Sprite
image="https://pixijs.io/pixi-react/img/bunny.png"
x={400}
y={270}
anchor={{ x: 0.5, y: 0.5 }}
/>
<Container x={400} y={330}>
<Text text="Hello World" anchor={{ x: 0.5, y: 0.5 }} filters={[blurFilter]} />
</Container>
</Stage>
);
};
Let’s prune some of the codes we don’t need and update the Stage to fill up the available space:
export function App(){
return (
<Stage
height={window.innerHeight} // set initial height to window.innerHeight
width={window.innerWidth} // set initial width to window.innerWidth
options={{ resizeTo: window }} // resize to window whenever size changes
>
</Stage>
);
};
With the stage ready, it’s time to bring the blocks in! As mentioned, I am gonna load the sprites with Sprite Sheets. The sprite sheets from the Tinyblock assets pack are in 18 pixels x 18 pixels, there are total of 10 rows & 10 columns of block which makes the size of the sprite sheets 180pixels x 180pixels!
const atlasData = {
frames: {
pale_green_grass: {
// the location of the block
// located at top left corner with (0, 0)
// with a size of 18 x 18 pixels
frame: { x: 0, y: 0, w: 18, h: 18 },
sourceSize: { w: 18, h: 18 },
spriteSourceSize: { x: 0, y: 0, w: 18, h: 18 }
},
},
meta: {
// renamed one of the sprite sheets into `structural_blocks.png`
// and placed it in `public/sprites`
image: `${process.env.PUBLIC_URL}/sprites/structural_blocks.png`,
format: 'RGBA8888',
size: { w: 180, h: 180 },
// scale is 1:1
scale: "1",
},
};
export function App(){
const [textures, setTextures] = useState<utils.Dict<Texture<Resource>>>();
useEffect(() => {
const spritesheet = new Spritesheet(
BaseTexture.from(atlasData.meta.image, { scaleMode: SCALE_MODES.NEAREST }),
atlasData
);
spritesheet.parse().then(setTextures)
}, []);
// ... render bits
};
Loading Sprite Sheets is an async operation, we have to useState and set the resulting textures into state.
To draw the resulting texture onto the screen, we need to wire the texture into the sprite.
function App() {
const [textures, setTextures] = useState<utils.Dict<Texture<Resource>>>();
useEffect(() => {
const spritesheet = new Spritesheet(
BaseTexture.from(atlasData.meta.image, { scaleMode: SCALE_MODES.NEAREST }),
atlasData
);
spritesheet.parse().then(setTextures)
}, []);
return (
<Stage
height={window.innerHeight}
width={window.innerWidth}
options={{ resizeTo: window }}>
{textures
? <Sprite
texture={textures['pale_green_grass']}
scale={4} // scale into 4x
anchor={{ x: 0.5, y: 0.5 }} // the anchor point would be center of the sprite
x={36} // 72 / 2 = 36 so sprite is aligned in the middle
y={36}
/>
: null
}
</Stage>
);
}
With everything setup properly, this should appear in your browser window!
Looking back at my original repository, I have them line up in an isometric fashion with 16 x 16 pattern. Let’s apply the same principal in here. To do so, I wrote some utils functions that would come in handy
import { matrix, multiply } from "mathjs";
type Block = {
x: number
y: number
}
export function screen_to_isometric(x: number, y: number) {
// the weights as explanined in the youtube video
const isometric_weights = matrix([
[0.5, 0.25],
[-0.5, 0.25]
]);
// coordinatex times the size of the block 18 * 4 = 72
// as it's scaled 4x
const coordinate = matrix([
[x * 72, y * 72]
]);
const [isometricCoordinate] = multiply(coordinate, isometricWeights).toArray();
return isometricCoordinate as number[];
}
export function generateGrid<T extends Block = Block>(
rows: number,
cols: number,
blockProvider: (x: number, y: number) => T) {
const grid: T[] = [];
for (let i = 0; i < rows; i++) {
for (let j = 0; j < cols; j++) {
grid.push(blockProvider(i, j));
}
}
return grid;
}
function App() {
// ... texture loading code
// generate the grid using utils method
const grid = useMemo(() => generateGrid(16, 16, (x, y) => ({ x, y })), [])
return (
<Stage
height={window.innerHeight}
width={window.innerWidth}
options={{ resizeTo: window }}>
{textures
? grid.map(({ x, y }) => {
// convert the screen coordinate to isometric coordinate
const [isometric_x, isometric_y] = screen_to_isometric(x , y);
return (
<Sprite
texture={textures["pale_green_grass"]}
x={isometric_x + window.innerWidth / 2} // center horizontally
y={isometric_y + window.innerHeight / 4} // align the y axis to one fourth of the screen
anchor={{ x: 0, y: 0 }}
/>
)
})
: null
}
</Stage>
);
}
A perfect green plane should now appear on your screen!
we are very close to replicating our existing website!
The next step would be to replicate the wave motion as seen on the top of the article. To do that, we would need to useTick hook from React-Pixi to update the y-axis of corresponding block on each animation frame. In order to do that, we have to extract the sprite into it’s own component.
type GrassBlockProps = {
x: number
y: number
texture: Texture<Resource>
}
function GrassBlock({ x, y, texture }: GrassBlockProps) {
const [isometric_x, isometric_y] = useMemo(() => screen_to_isometric(x, y), [x, y])
const [elevation, setElevation] = useState(0);
const [timeElapsed, setTimeElapsed] = useState(0);
useTick((_, ticker) => {
// save elapsed time
setTimeElapsed(timeElapsed + ticker.deltaMS / 500);
// apply cosine function to elapsed time to create a wave form
setElevation(16 * Math.cos(timeElapsed - x))
});
return (
<Sprite
texture={texture}
x={isometric_x + window.innerWidth / 2}
y={isometric_y + window.innerHeight / 4 + elevation}
anchor={{ x: 0, y: 0 }}
/>
)
}
And update the stage with the new component
function App() {
//...
return (
<Stage
height={window.innerHeight}
width={window.innerWidth}
options={{ resizeTo: window }}>
{textures
? grid.map(({ x, y }) => (
<GrassBlock
x={x}
y={y}
texture={textures["pale_green_grass"]}
/>
))
: null
}
</Stage>
);
}
Ta da! There you have it isometric grid replicated with pixi.js + React pixi.
Like I mentioned this is going to be the end of the conversion from vanilla JS to Pixi.js. I hope you enjoyed the content and find this interesting!
I am also open for hire on Fiverr! If you like what you see and think my skills is what you need, feel free to send me a message there or on my Twitter (X): https://twitter.com/sheunglaili!
Thanks again for watching!
Top comments (0)