RPG-JS Tutorial - How to create a Bitshares based NFT gallery in an RPG!

in BitShareslast year (edited)

image.png

Continuing the RPG-JS topic, this time coming back to Bitshares, NFTs and blockchain technology!

A couple years back I wanted to create a 3D or isometric art gallery for my NFTEA NFT gallery, however these never really came to fruition for one reason or another..

Now however with the RPG-JS typescript framework and the Tiled map editor the skill ceiling for creating your own NFT art gallery for showing off your NFT collections has never been easier!

So that's what this tutorial is going to cover - creating an NFT art gallery in RPG-JS and Tiled for Bitshares based blockchains!

So step one - open Tiled and create an art gallery map!

I used the 'BaseChip_pipo' tileset (included in the rpg-js starter project) to form the interior map for the NFTEA NFT gallery.

As you can see I tried to mimic a real life art gallery, though due to the top down perspective we cannot see anything on the other side of the wall in each row, so for now just assume they're blank, lol!

image.png

In order to display the NFTEA NFT collection I had to shrink down the art collection to 32x32, a reduction of 95%+ visual data! I also included several picture frames in the tileset for decorating the NFTs on display!

image.png

This was initially an entirely manual process, resizing the first image for each NFT and including them in a tileset PNG using an image editor, however I wanted to take this a step further and do so programmatically using the sharp npmjs package!

I downloaded the NFT media contents for all NFTEA NFTs, and created a script to resize them to both 256x256 and 32x32 for use in game:

const fs = require("fs");
const path = require("path");
const sharp = require("sharp");

const filesAndFolders = fs.readdirSync(__dirname);
const directories = filesAndFolders.filter((file) =>
  fs.statSync(path.join(__dirname, file)).isDirectory()
);

directories.forEach((directory) => {
  const files = fs.readdirSync(path.join(__dirname, directory));
  const imageFiles = files.filter(
    (file) => file.endsWith(".webp") && !file.includes("_256x256") && !file.includes("_32x32")
  );

  imageFiles.forEach((file) => {
    const filePath = path.join(__dirname, directory, file);
    const fileName = path.parse(file).name;

    sharp(filePath)
      .resize(256, 256)
      .toFile(path.join(__dirname, directory, `${fileName}_256x256.webp`));

    sharp(filePath)
      .resize(32, 32)
      .toFile(path.join(__dirname, directory, `${fileName}_32x32.webp`));
  });
});

Then I created a script which takes each of the 32x32 images per NFT, and creates an individual NFT tileset!

const fs = require("fs");
const path = require("path");
const sharp = require("sharp");

const filesAndFolders = fs.readdirSync(__dirname);

const directories = filesAndFolders.filter((file) =>
  fs.statSync(path.join(__dirname, file)).isDirectory()
);

const newImageWidth = 512;

directories.forEach(async (directory) => {
  const files = fs.readdirSync(path.join(__dirname, directory));
  const imageFiles = files.filter((file) => file.includes("_32x32"));

  // Sort the files by number
  imageFiles.sort((a, b) => {
    const numberA = parseInt(a.split("_")[0]);
    const numberB = parseInt(b.split("_")[0]);
    return numberA - numberB;
  });

  const numRows = Math.ceil(imageFiles.length / (newImageWidth / 32));
  let newImageHeight = numRows * 64;
  newImageHeight = Math.max(newImageHeight, 1);

  const images = await Promise.all(
    imageFiles.map((file) =>
      sharp(path.join(__dirname, directory, file))
        .extend({
          top: 16,
          bottom: 16,
          left: 0,
          right: 0,
          background: { r: 0, g: 0, b: 0, alpha: 0 },
        })
        .toBuffer()
    )
  );

  sharp({
    create: {
      width: newImageWidth,
      height: newImageHeight,
      channels: 4,
      background: { r: 0, g: 0, b: 0, alpha: 0 },
    },
  })
    .composite(
      images.map((image, i) => ({
        input: image,
        top: Math.floor(i / (newImageWidth / 32)) * 64,
        left: (i % (newImageWidth / 32)) * 32,
      }))
    )
    // Save the tileset in the respective folder
    .toFile(path.join(__dirname, directory, `${directory}.webp`), (err, info) => {
      if (err) throw err;
      console.log(info);
    });
});

.
This script saves an immense amount of time, taking each of the tile images and places them properly offset in the tileset to be placed on the wall of the NFTEA NFT gallery in game!

image.png

These can easily be imported into Tiled as a new 32x32 based tileset!

The tileset can also now be edited directly, if need be..

image.png

Step two - Implement interactivity via object shapes!

So, the user can walk around a gallery with 32x32 shrunk version of images, that's not a great viewing experience, as cool as it is.

So, we're going to use object shapes to improve the user's art viewing experience and overall interactivity within the gallery!

So, unhiding the objects on this gallery floor looks like the following!

image.png

A little overwhelming, but that's only because it's quite a large gallery, and because it's zoomed quite far out - when you zoom in the view is less obfuscated by object labels/borders.

So let's focus on a single NFT display for now..

image.png

Create a rectangular shape which covers both the NFT on display, and some of the floor in front of the display, enough for the user to walk onto to enable launching the our gallery GUI!

Add the following custom properties:

[
    {
        "name": "CID",
        "propertytype": "",
        "type": "string",
        "value": "ipfs_cid_string"
    },
    {
        "name": "acknowledgements",
        "propertytype": "",
        "type": "string",
        "value": "NFT acknowledgements"
    },
    {
        "name": "artist",
        "propertytype": "",
        "type": "string",
        "value": "nft.artist"
    },
    {
        "name": "baseAssetID",
        "propertytype": "",
        "type": "string",
        "value": "1.3.0"
    },
    {
        "name": "basePrecision",
        "propertytype": "",
        "type": "int",
        "value": 5
    },
    {
        "name": "blockchain",
        "propertytype": "",
        "type": "string",
        "value": "bitshares"
    },
    {
        "name": "component",
        "propertytype": "",
        "type": "string",
        "value": "gallery"
    },
    {
        "name": "filetype",
        "propertytype": "",
        "type": "string",
        "value": "webp"
    },
    {
        "name": "header",
        "propertytype": "",
        "type": "string",
        "value": "Orbs"
    },
    {
        "name": "media_qty",
        "propertytype": "",
        "type": "int",
        "value": 34
    },
    {
        "name": "narrative",
        "propertytype": "",
        "type": "string",
        "value": "NFT Narrative"
    },
    {
        "name": "quoteAssetID",
        "propertytype": "",
        "type": "string",
        "value": "1.3.6542"
    },
    {
        "name": "quotePrecision",
        "propertytype": "",
        "type": "int",
        "value": 0
    },
    {
        "name": "symbol",
        "propertytype": "",
        "type": "string",
        "value": "NFTEA.ORBS"
    },
    {
        "name": "tags",
        "propertytype": "",
        "type": "string",
        "value": "comma, separated, strings"
    }
]

These custom properties enable you to specify how many images each NFT has as well as static data stores for the NFT's properties.

Sure, we could make a few blockchain calls to get all this data, but considering we know that none of this data aught to change (they're permanent NFTs) we can just store all this data in our Tiled map and avoid those network calls entirely!

So, when the user walks into the shape let's trigger this player code to display the NFT title to the user and initialize the GUI for interaction!

  async onInShape(player: RpgPlayer, shape: any) {
    if (shape.name.includes("gallery")) {
      player.setVariable("AT_GALLERY", { name: shape.name, properties: shape.obj.properties });
      player.name = shape.obj.properties.header;
    }
  },

.
And when the user walks out of the shape let's trigger this player code so as to no longer display the NFT title and properly close the GUI!

  async onOutShape(player: RpgPlayer, shape: any) {
    if (shape.name.includes("gallery")) {
      player.name = " ";
      player.gui(shape.obj.properties.component).close();
      player.hideAttachedGui();
      player.setVariable("AT_GALLERY", null);
    }
  }

.
When following this "within shape" and "outwith shape" logic flow, make sure there is a sufficient gap between them, otherwise you'll experience issues - this is why there is a 32px gap between the art on display within the gallery.

I've named this shape gallery_0011, make sure that every shape you add to your map is unique, so for each NFT iterate the counter in the ID.

Now, let's talk about introducing user interaction with the art!

We're going to add the following code to the onInput function within the player file!

  async onInput(player: RpgPlayer, { input }) {
    if (input === Control.Action && player.getVariable("AT_GALLERY")) {
      await promptPlayer(player, "AT_GALLERY", "Want to view this item?");
    }
  },

.
So, we check that the user is within the gallery event, and we prompt the player with the following:

const _allowedVariables = ["AT_COMPUTER", "AT_MARKET", "AFTER_INTRO", "AT_GALLERY"];
const _allowedComponents = ["gallery"];

async function promptPlayer(player: RpgPlayer, variable: string, prompt: string) {
  if (!variable || !_allowedVariables.includes(variable)) {
    console.log("Rejected prompt");
    return;
  }

  const retrievedVariable = player.getVariable(variable);

  if (
    !retrievedVariable.properties ||
    !retrievedVariable.properties.component ||
    !_allowedComponents.includes(retrievedVariable.properties.component)
  ) {
    console.log("Rejected prompt");
    return;
  }

  if (
    retrievedVariable.properties.component === "gallery" &&
    !retrievedVariable.properties.symbol.length
  ) {
    await player.showText("No art is currently on display here yet.");
    return;
  }

  const answer = await player.showChoices(prompt, [
    { text: "yes", value: "yes" },
    { text: "no", value: "no" },
  ]);

  if (!answer || (answer && answer.value === "no")) {
    console.log("User rejected prompt");
    return;
  }

  if (player.gui(retrievedVariable.properties.component)) {
    console.log("Closing previous GUI");
    await player.gui(retrievedVariable.properties.component).close();
    await player.hideAttachedGui();
  }

  await player
    .gui(retrievedVariable.properties.component)
    .open({ properties: retrievedVariable.properties });
  await player.showAttachedGui();
}

.
This will ask the user if they want to view the art in front of them, and if they say yes then we launch the following gallery component

<script>
import { defineComponent, computed, watch, watchEffect, ref, inject, onMounted } from "vue";
import { useStore } from "@nanostores/vue";

import { createMarketOrdersStore } from "../nanoeffects/MarketOrders";
import { $currentUser, $userStorage } from "../nanostores/users.ts";

import "@shoelace-style/shoelace/dist/components/button/button";
import "@shoelace-style/shoelace/dist/components/dialog/dialog";
import "@shoelace-style/shoelace/dist/components/spinner/spinner";
import "@shoelace-style/shoelace/dist/components/input/input";

import { generateDeepLink } from "../bts/generateDeepLink";
import { humanReadableFloat, blockchainFloat } from "../bts/common";

export default defineComponent({
  name: "gallery",
  props: {
    properties: {
      type: Object,
      required: true,
    },
  },
  setup(props) {
    const open = ref(true);
    const current_nft = ref(0);

    const about = ref(false);
    const buy = ref(false);
    const explore = ref(false);

    const beeteos = ref(false);
    const broadcast = ref(false);
    const deeplink = ref(null);

    // existing accounts
    const currentUser = useStore($currentUser);
    const storedUsers = useStore($userStorage);

    const artist = computed(() => {
      return props.properties.artist;
    });

    const acknowledgements = computed(() => {
      return props.properties.acknowledgements;
    });

    const blockchain = computed(() => {
      return props.properties.blockchain;
    });

    const header = computed(() => {
      return props.properties.header;
    });

    const narrative = computed(() => {
      return props.properties.narrative;
    });

    const symbol = computed(() => {
      return props.properties.symbol;
    });

    const tags = computed(() => {
      return props.properties.tags;
    });

    const filetype = computed(() => {
      return props.properties.filetype ?? null;
    });

    const media_qty = computed(() => {
      return props.properties.media_qty ?? null;
    });

    const basePrecision = computed(() => {
      // avoids blockchain asset query
      return props.properties.basePrecision ?? 5; // BTS has a precision of 5
    });

    const quotePrecision = computed(() => {
      // avoids blockchain asset query
      return props.properties.quotePrecision ?? 0; // NFTs have a precision of 0 (if whole indivisible units)
    });

    const baseAssetID = computed(() => {
      // avoids blockchain asset query
      return props.properties.baseAssetID ?? "1.3.0"; // BTS asset ID
    });

    const quoteAssetID = computed(() => {
      // avoids blockchain asset query
      return props.properties.quoteAssetID ?? "";
    });

    const CID = computed(() => {
      return props.properties.CID;
    });

    const retry = ref(0);
    const chainResult = ref(null);
    const loading = ref(false);

    // Fetching the latest order book data:
    watchEffect(async () => {
      if (beeteos.value || retry.value) {
        loading.value = true;

        const marketStore = createMarketOrdersStore([
          blockchain.value,
          symbol.value,
          blockchain.value === "bitshares" ? "BTS" : "TEST",
        ]);

        const unsub = marketStore.subscribe((result) => {
          if (result.error) {
            loading.value = false;
            console.error(result.error);
          }

          if (!result.loading) {
            if (result.data) {
              const res = result.data;
              console.log({ res });
              chainResult.value = res;
              loading.value = false;
            }
          }
        });

        return () => {
          unsub();
        };
      }
    });

    // Generating a deeplink for buying the lowest ask
    watchEffect(async () => {
      if (broadcast.value && chainResult.value) {
        if (!chainResult.value.asks || !chainResult.value.asks.length) {
          console.log("No asks available for this NFT.");
          return;
        }
        var expiry = new Date();
        expiry.setMinutes(expiry.getMinutes() + 60);

        const operation = {
          seller: currentUser.value.id,
          amount_to_sell: {
            amount: parseInt(
              blockchainFloat(
                parseFloat(chainResult.value.asks[0].base),
                basePrecision.value
              ).toFixed(0)
            ),
            asset_id: baseAssetID.value,
          },
          min_to_receive: {
            amount: parseInt(
              blockchainFloat(
                parseFloat(chainResult.value.asks[0].quote),
                quotePrecision.value
              ).toFixed(0)
            ),
            asset_id: quoteAssetID.value,
          },
          expiration: expiry,
          fill_or_kill: false,
          extensions: [],
        };

        console.log({ operation });

        let res;
        try {
          res = await generateDeepLink(blockchain.value, "limit_order_create", [operation]);
        } catch (err) {
          console.error(err);
        }

        if (res) {
          console.log({ res });
          deeplink.value = res;
        }
      }
    });

    const mainImage = computed(() => {
      return `/main/spritesheets/gfx/NFT/${symbol}/0_256x256.${filetype}`;
    });

    const currentImage = computed(() => {
      return `/main/spritesheets/gfx/NFT/${symbol}/${current_nft}_256x256.${filetype}`;
    });

    return {
      // Dialog open states:
      open,
      beeteos,
      broadcast,
      about,
      buy,
      explore,
      current_nft,
      // Nanostore data:
      currentUser,
      storedUsers,
      // Blockchain data:
      chainResult,
      deeplink,
      // Static asset data:
      basePrecision,
      baseAssetID,
      quotePrecision,
      quoteAssetID,
      // generated urls
      currentImage,
      mainImage,
      // NFT Properties:
      artist,
      acknowledgements,
      blockchain,
      header,
      filetype,
      media_qty,
      narrative,
      symbol,
      tags,
      CID,
      // Functions for in-JSX use:
      humanReadableFloat,
    };
  },
});
</script>

<template>
  <div class="gallery">
    <sl-dialog
      :open="open"
      :label="`Viewing '${header}' created by ${artist}`"
      class="dialog-overview"
    >
      <div v-if="filetype && media_qty">
        <div
          class="microGrid"
          style="
            display: flex;
            flex-direction: column;
            justify-content: center;
            align-items: center;
            height: 100%;
          "
        >
          <a :href="`https://gateway.pinata.cloud/ipfs/${CID}`" target="_blank">
            // display currentimage (removed for hive)
          </a>
          <p>{{ current_nft + 1 }} of {{ media_qty }}</p>
        </div>
        <div class="smallGrid">
          <sl-button
            variant="neutral"
            @click="current_nft > 0 ? (current_nft -= 1) : (current_nft = media_qty - 1)"
            size="small"
            pill
          >
            Previous
          </sl-button>
          <sl-button
            variant="neutral"
            @click="current_nft < media_qty - 1 ? (current_nft += 1) : (current_nft = 0)"
            size="small"
            pill
            >Next</sl-button
          >
        </div>
      </div>
      <div v-else style="display: flex; justify-content: center; align-items: center; height: 100%">
        // display main image (removed for hive)
      </div>
      <div class="grid">
        <sl-button
          variant="neutral"
          @click="
            about = true;
            open = false;
          "
          >About</sl-button
        >

        <sl-button
          variant="neutral"
          @click="
            buy = true;
            open = false;
          "
          >Buy</sl-button
        >

        <sl-button
          variant="neutral"
          @click="
            explore = true;
            open = false;
          "
          >Explore</sl-button
        >
      </div>
    </sl-dialog>

    <sl-dialog :open="about" :label="`About ${header} by ${artist}`" class="dialog-overview">
      <p>Narrative: {{ narrative }}</p>
      <p>Acknowledgements: {{ acknowledgements }}</p>
      <p>Tags: {{ tags }}</p>
      <p>Blockchain: {{ blockchain }}</p>
      <sl-button
        slot="footer"
        variant="neutral"
        @click="
          open = true;
          about = false;
        "
        >Close</sl-button
      >
    </sl-dialog>

    <sl-dialog :open="buy" :label="`Where to buy '${header}'`" class="dialog-overview">
      <div class="grid">
        <a :href="`https://ex.xbts.io/market/${symbol}_BTS?r=nftprofessional1`" target="_blank">
          <sl-button slot="footer" variant="neutral" style="margin-right: 10px"
            >Buy on XBTS.io</sl-button
          >
        </a>

        <a :href="`https://bts.exchange/#/market/${symbol}_BTS?r=nftprofessional1`" target="_blank">
          <sl-button slot="footer" variant="neutral" style="margin-right: 10px"
            >Buy on bts.exchange</sl-button
          >
        </a>

        <sl-button
          v-if="currentUser.chain === blockchain"
          slot="footer"
          variant="neutral"
          @click="
            buy = false;
            beeteos = true;
          "
          >Buy via BeetEOS</sl-button
        >
      </div>

      <sl-button
        slot="footer"
        variant="neutral"
        @click="
          open = true;
          buy = false;
        "
        >Back</sl-button
      >
    </sl-dialog>

    <sl-dialog :open="explore" :label="`Explorer links for '${header}'`" class="dialog-overview">
      <div class="grid">
        <a :href="`https://nftea.gallery/nft/${symbol}`" target="_blank">
          <sl-button slot="footer" variant="neutral" style="margin-right: 10px"
            >NFTEA Gallery</sl-button
          >
        </a>
        <a :href="`https://blocksights.info/#/assets/${symbol}`" target="_blank">
          <sl-button slot="footer" variant="neutral" style="margin-right: 10px"
            >Blocksights</sl-button
          >
        </a>
      </div>

      <sl-button
        slot="footer"
        variant="neutral"
        @click="
          open = true;
          explore = false;
        "
        >Close</sl-button
      >
    </sl-dialog>

    <sl-dialog :open="beeteos" label="Buying NFT via BeetEOS" class="dialog-overview">
      <div v-if="loading">
        Checking NFT liquidity...<br />
        <sl-spinner></sl-spinner>
      </div>

      <div v-if="!loading && chainResult">
        <span v-if="!chainResult.asks || !chainResult.asks.length">
          This NFT is currently not for sale, try again later.
        </span>
        <span v-for="ask in chainResult.asks">
          <p :id="ask.id">
            {{ ask.owner_name }} is selling {{ ask.quote }} {{ chainResult.quote }} for
            {{ ask.base }} {{ chainResult.base }}
          </p>
        </span>
        <sl-button
          slot="footer"
          variant="neutral"
          @click="
            broadcast = true;
            beeteos = false;
          "
          >Buy lowest ask</sl-button
        >
      </div>

      <div
        v-if="!loading && !chainResult"
        label="Proceeding with BeetEOS broadcast"
        class="dialog-overview"
      >
        <p>Unable to locate blockchain data, try again later.</p>
        <sl-button slot="footer" variant="neutral" @click="retry += 1">Refresh</sl-button>
      </div>

      <sl-button
        slot="footer"
        variant="neutral"
        @click="
          open = true;
          beeteos = false;
        "
        >Close</sl-button
      >
    </sl-dialog>

    <sl-dialog :open="broadcast">
      <p v-if="chainResult">
        Buying {{ chainResult.asks[0].quote }} {{ chainResult.quote }} from
        {{ chainResult.asks[0].owner_name }} for {{ chainResult.asks[0].base }}
        {{ chainResult.base }}
      </p>

      <div v-if="!deeplink">
        <p>Generating your deeplink...</p>
        <sl-spinner></sl-spinner>
      </div>
      <div v-else>
        <p>
          Your BeetEOS raw deeplink is ready. Launch BeetEOS, navigate to the raw deeplink page,
          allow sufficient operations then click the following button to proceed.
        </p>
        <a
          :href="`rawbeeteos://api?chain=${
            currentUser.chain === 'bitshares' ? 'BTS' : 'BTS_TEST'
          }&request=${deeplink}`"
        >
          <sl-button slot="footer" variant="neutral">Broadcast to BeetEOS!</sl-button>
        </a>
      </div>

      <sl-button
        slot="footer"
        variant="neutral"
        @click="
          beeteos = true;
          broadcast = false;
        "
        >Close</sl-button
      >
    </sl-dialog>
  </div>
</template>

<style>
.grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  grid-gap: 5px;
  margin-top: 30px;
}
.smallGrid {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  grid-gap: 5px;
  margin-top: 30px;
}
.microGrid {
  display: grid;
  grid-template-columns: repeat(1, 1fr);
  grid-gap: 5px;
  margin-top: 30px;
}
</style>

.
OK, wow that was a lot of text.

This is a vue3 component, using the single file component file format to show off the NFT's multimedia and to direct the user to external content to explore or purchase the NFT, as well as an option to purchase the NFT directly via a BeetEOS deeplink!

Check it out in action! (click the image link to watch the gif)

image.png

The gallery must expand!

Ok, so now we've successfully implemented an operational NFT gallery for the NFTEA NFT collection on the Bitshares blockchain, what next?

Time to add more floors!

How about additional NFT gallery space?

image.png

And a top-floor where the action behind the scenes takes place!

image.png

You can see how easy it is to make additional floors, what will you add to your NFT gallery using RPG-JS and Tiled? Comment below!

So what's next for the RPG-JS NFTEA NFT gallery?

  • Introduce additional computer terminal logic for top floor staff room
  • Additional floors with smaller maps joined up at the edges unlike the current teleportation based movement between maps (floors).
  • Release further NFTs and display them in the gallery
  • Host the RPG-JS project online for visitors to try out
  • Open source for devs to try out
  • Utilize the per-NFT tileset to create animated tiles which iterates through the different NFT media stored within the NFT.
  • Add NPCs to the gallery and surrounding world, relating to the contents of the gallery

So, what are your thoughts? Have any issues with RPG-JS or Tiled? Have a future feature request?

Comment below!


These developments were brought to you by the NFTEA Gallery.

Consider collecting an NFTEA NFT to or donate to the following BTS or EOS blockchain accounts directly, to continued developments.

Don't have a Bitshares account? Make one today!

Sort:  


Congratulations @nftea.gallery!
You raised your level and are now a Minnow!

Check out our last posts:

Our Hive Power Delegations to the May PUM Winners


The rewards earned on this comment will go directly to the people sharing the post on Reddit as long as they are registered with @poshtoken. Sign up at https://hiveposh.com. Otherwise, rewards go to the author of the blog post.