DEV Community

Alex
Alex

Posted on • Originally published at Medium on

Rebuilding Isometric World

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.

Isometric grid demo

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

Assets pack
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
Enter fullscreen mode Exit fullscreen mode

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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
};
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

With everything setup properly, this should appear in your browser window!

tiny grass block
A tiny grass block yeh!!!

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

A perfect green plane should now appear on your screen!

more grass blocks
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 }}
    />
  )
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

Ta da! There you have it isometric grid replicated with pixi.js + React pixi.

final product

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)