Now and Nawoo

NFTの制作記録、技術メモ → C#, Solidity, Blockchain, Bitcoin, Ethereum, NFT

OnChainSlimeの紹介とコード解説(2)

コード解説

OnChainSlimeのコード解説(1) の続きです。

コードはこちら → PolygonScan

概要

このコントラクトでは、デプロイした後に、コントラクトオーナーがaddPart関数で各パーツの画像(PNG)と名前をストレージに保存していきます。 その後、DNAを指定してmintすることでNFTが発行(ミント)できます。ミントできるのもコントラクトオーナーだけです。 (DNAについては前回の記事を参照してください。)

tokenURI関数でBase64エンコードされたメタデータを返すのは、一般的なフルオンチェーンNFTと同じです。 SVGgetSvgByDna関数で作成しています。SVGにはパーツごとのPNG画像が含まれています。

import

import "@openzeppelin/contracts/utils/Base64.sol";

いつの間にやらBase64.solがOpenZeppelinに追加されていたので使ってみました。

今まではBase64.solファイルを別途用意して、、ってやっていたので、importするだけで使えるのは楽ちんです。

定数・変数

uint256 public constant MAX_TOKEN_ID = 999;

mapping(uint256 => uint256) public dnas; // tokenId => DNA
mapping(uint256 => bool) public dnaExists; // DNA/100 => exists

MAX_TOKEN_IDではtokenIDの最大値を指定しています。最初のtokenIDは1なので、最大で999枚となります。

dnasには、tokenIDごとのDNAを記録しています。

dnaExistsには、DNAから背景を除いた部分が、すでに発行済かどうかを記録しています。 DNAの下2桁が背景データなので、DNA/100で、背景を除いたデータになります。

getParamsByDna

function getParamsByDna(uint256 dna) private pure returns (uint8[6] memory params) {
    require(dna < 100**6, "Invalid DNA");
    for (uint256 i = 0; i < 6; i++) {
        params[i] = uint8((dna / 100**i) % 100);
    }
}

DNAを分解して、各パーツ番号の配列を取得します。 DNAの計算式は

DNA = 背景 + 体*100 + 口*100**2 + 頬*100**3 + 目*100**4 + 頭*100**5

なので、これを逆算していきます。

DNAが 010203040506 であれば、[1,2,3,4,5,6] に変換します。 [頭,目,頬,口,ボディ,背景] の順番なので、背景が06番、ボディが05番、、、となります。

mint

function mint(uint256 dna) external onlyOwner {
    require(nextTokenId <= MAX_TOKEN_ID, "All tokens minted");
    require(!dnaExists[dna / 100], "DNA already exists");

    uint8[6] memory params = getParamsByDna(dna);
    require(params[0] < layers[0].parts.length, "Background index out of range");
    require(params[1] < layers[1].parts.length, "Body index out of range");
    require(params[2] < layers[2].parts.length, "Mouth index out of range");
    require(params[3] < layers[3].parts.length, "Cheek index out of range");
    require(params[4] < layers[4].parts.length, "Eyes index out of range");
    require(params[5] < layers[5].parts.length, "Head index out of range");

    uint256 _tokenId = nextTokenId;
    nextTokenId++;
    dnas[_tokenId] = dna;
    dnaExists[dna / 100] = true;
    _safeMint(_msgSender(), _tokenId);
    emit MintSlime(_tokenId, dna);
}

DNAを指定してミントします。これはonlyOwnerがついているのでコントラクトオーナーだけが使うことができます。 まず、発行数がMAX_TOKEN_ID(999)以下であることをチェックします。

OnChainSlimeでは、背景を除いた、体、口、頬、目、頭のパーツが全く同じスライムは1つしか存在しません。 そのため、背景を除いたDNA (=DNA/100) がミント済みかどうかをチェックします。

getParamsByDna関数で、DNAから各パーツ番号を取得して、範囲内かどうかをチェックしてから、dnasdnaExistsに設定します。

_safeMintでミントした後に、MintSlimeイベントも発行してみました。(特に使いみちは考えていませんが。。。)

addPart

function addPart(
    uint8 layerNo,
    string calldata name,
    bytes calldata png
) external onlyOwner {
    require(bytes(name).length > 0, "Name is empty");
    require(layers[layerNo].parts.length < 100, "Excess number of parts in a layer");

    Part storage part = layers[layerNo].parts.push();
    part.name = name;
    part.png = png;
}

パーツ画像を追加します。これもonlyOwnerなのでコントラクトオーナーだけが使うことができます。 PNG画像データ(bytes)とパーツ名をストレージに保存します。

各レイヤーのパーツは最大100個です。 これはDNAで各パーツを2桁(00番〜99番)で表現するための制限です。

48x48ピクセルなので大丈夫だと思いますが、あまりPNGデータのサイズが大きくなるとエラーになってしまうかもしれません。 サイズが大きい場合は分割して追加できるようにしても良かったかもしれません。

tokenURI

function tokenURI(uint256 tokenId) public view override returns (string memory) {
    require(_exists(tokenId), "ERC721Metadata: URI query for nonexistent token");

    return string(abi.encodePacked("data:application/json;base64,", Base64.encode(getMetadata(tokenId))));
}

tokenURIでは、getMetadataで作成したメタデータBase64エンコードして返します。 これはフルオンチェーンの定番ですね。

getMetadata

function getMetadata(uint256 tokenId) private view returns (bytes memory) {
    uint256 dna = dnas[tokenId];
    uint8[6] memory params = getParamsByDna(dna);

    // traits
    bytes memory attributes = "";
    for (uint256 i = 0; i < layers.length; i++) {
        uint8 partIndex = params[i];
        attributes = abi.encodePacked(
            attributes,
            '{"trait_type":"',
            layers[i].name,
            '","value":"',
            layers[i].parts[partIndex].name,
            '"},'
        );
    }
    attributes[attributes.length - 1] = " "; // delete last comma

    return
        abi.encodePacked(
            '{"name": "OnChainSlime #',
            tokenId.toString(),
            '", "description": "OnChainSlime is a collection of 999 pixel art slimes, completely generated on-chain.", "image": "data:image/svg+xml;base64,',
            Base64.encode(bytes(getSvgByDna(dna, true))),
            '", "attributes": [',
            attributes,
            "]}"
        );
}

メタデータを作成します。

  • attributesを作成する
  • DNAからSVGデータを作成する (getSvgByDna)
  • SVGデータをメタデータのimagaに設定する

という処理を行います。

attributesでは、パーツの名前を指定してします。 OpenSeaではPropertiesとして表示されます。

頬と頭パーツはオプションなので、無い場合もありますが、無い場合のパーツ名は「(none)」という名前を設定しています。 パーツが無い場合にはattributesを設定しなのが一般的かと思いますが、今回はあえて設定しました。 これは、OpenSeaでパーツ無しのスライムを検索しやすいように、という理由です。

変な名前を付けたパーツもあるので、いろいろ探してみてください。(アニメネタもあるよ)

attrubutes作成時に、ループごとに「,」を追加しますが、最後の「,」を削除する必要がありました。

attributes[attributes.length - 1] = " ";

ここで最後の「,」をスペースに置換していますが、このやり方はあまり美しくなかったかも。 素直にループの中で i == layers.length - 1 なら「,」を追加しない、と書いた方が良かったかもしれません。

getSvgByTokenId

function getSvgByTokenId(uint256 tokenId, bool withBackground) external view returns (string memory) {
    require(_exists(tokenId), "TokenId does not exist");

    return getSvgByDna(dnas[tokenId], withBackground);
}

tokenIDを指定してSVGデータを取得する関数です。 Webサイト(フロントエンド)から利用することを想定しています。

引数に withBackground というのがありますが、これで背景の有無を指定することができます。 将来的にWebサイトで、背景画像あり/なしの両バージョンでPNG画像をDLする機能を付けたいと考えています。

OnChainSlimeでは画像をCC0で提供しているので、誰でも自由に利用できます。 プロフィールに使ってもいいし、自分の好きな背景と合成したり、他のイラストにスライムを合成したり、 パーツを書き加えたり、落書きしたり、、、などなど、自由に楽しんでもらえればいいなーと考えています。

getSvgByDna

function getSvgByDna(uint256 dna, bool withBackground) public view returns (string memory) {
    bytes
        memory bs = '<svg xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMinYMin meet" viewBox="0 0 480 480" width="480" height="480"><style>image{width:100%;height:100%;image-rendering:pixelated;}</style>';
    uint256 startLayerIndex = withBackground ? 0 : 1;
    uint8[6] memory params = getParamsByDna(dna);
    for (uint256 i = startLayerIndex; i < layers.length; i++) {
        uint8 partIndex = params[i];
        if (layers[i].parts[partIndex].png.length != 0) {
            string memory imageData = Base64.encode(layers[i].parts[partIndex].png);
            bs = abi.encodePacked(bs, '<image href="data:image/png;base64,', imageData, '"/>');
        }
    }
    bs = abi.encodePacked(bs, "</svg>");
    return string(bs);
}

DNAを指定してSVGデータを取得する関数です。 getMetadataからも利用しますし、Webサイト(フロントエンド)から利用もできます。

ここでSVGの作成を行います。SVGは次のような構造です。withBackgroundがfalseの場合は、startLayerIndex=1となって、背景データのimageタグは出力されません。

<svg ...>
  <image href="背景データ"/>  レイヤー0
  <image href="体データ"/>   レイヤー1
  <image href="口データ"/>   レイヤー2
  <image href="頬データ"/>   レイヤー3
  <image href="目データ"/>   レイヤー4
  <image href="頭データ"/>   レイヤー5
</svg>

hrefで指定するデータは、PNG画像をBase64エンコードして、dataURLにしたものです。

data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwAgMAAAAqbBEUAAAABGdBTUEAALGPC/xhBQAAAAFzUkdCAK7OHOkAAAAJUExURUdwTAAAANIZKtjdiAMAAAABdFJOUwBA5thmAAAAHklEQVQoz2NgGAWDEjA6IHHYJiBxpJYgcURDhpa3AIIgAf89SeK8AAAAAElFTkSuQmCC

処理内容は、

  • DNAから各パーツ番号を取得する
  • ストレージからパーツのPNG画像を取得してBase64エンコードする
  • dataURLを作成して、imageタグのhrefに指定する

となります。

PNGのサイズは48x48ピクセルですが、SVGのサイズは480x480ピクセルに拡大しています。 SVGのstyleでimage-rendering: pixelated;を指定することで、くっきりしたドット絵が表示されます。

おわりに

簡単ですがコード解説でした。

フルオンチェーンでドット絵のNFTを作成するには、 1ドットごとに正方形(rectタグ)を並べていく方法、NounsのようにRLE圧縮を使う方法などいろいろあります。

今回はSVGのimageタグを使ってPNG画像を配置するという方法で作ってみました。 この方法は、WolfGameというフルオンチェーンのNFTゲームのソースコードを見て知りました。

 

今回は自分でドット絵を描きましたが、すごく楽しかったです。 いつもドット絵師さんの素敵なNFTを集めているのですが、見ているだけでは我慢できなくなって、自分でも描いてしまいました。

ドット絵を描くにはAsepriteというソフトを使っています。WinとMacの両方に対応で、操作もわかりやすいし、スクリプトを使ってパーツ画像を一括出力!なんてこともできちゃいます。 Asepriteについてもまたブログ記事にまとめてみたいですね。

 

OnChainSlimeはOpenSeaで発売中です!!

↓↓↓

OnChainSlime - Collection | OpenSea