アバターやループアニメに使用できる軽量なフォーマットについて考える -03

前回の続き

https://qiita.com/platoronical/items/2edfa6b20b747f855a32

https://qiita.com/platoronical/items/650a5a34c0e4999e8791

### JSONの拡張

- temlpatePrams
- group
- layersにgroup要素
- アンカーポイント

サンプル

```json
"templateParams": [
        {
            "ParamEyeLOpen": "layer_11",
            "ParamEyeLClose": "layer_8",
            "ParamEyeLSmile": "layer_1",
            "ParamEyeROpen": "layer_11",
            "ParamEyeRClose": "layer_9",
            "ParamEyeRSmile": "layer_8",
            "ParamBrowL": "layer_13",
            "ParamBrowR": "layer_13",
            "ParamMouthOpen": "layer_6",
            "ParamMouthClose": "layer_7"
        }
    ],
    "templateGroups": [
        {
            "base": "base",
            "ParamGroupEyes": "eyes",
            "ParamGroupBrows": "brows",
            "ParamGroupMouth": "mouth"
        }
    ],

```

```JSON
"layers": [
        {
            "fileName": "01.png",
            "x": 0,
            "y": 0,
            "width": 209,
            "height": 478,
            "basePosition_x": 272,
            "basePosition_y": 592,
            "textureZIndex": 0,
            "type": "Texture",
            "textureID": "layer_1",
            "group": "base",
            "animID": "",
            "anchorPoint_x": 0,
            "anchorPoint_y": 0
        },
```

#### 追加した要素を使用したデモ

[![Image from Gyazo](https://i.gyazo.com/387c3177be453bcd6f94a9ba08b2fe81.gif)](https://gyazo.com/387c3177be453bcd6f94a9ba08b2fe81)
今回はCanvasではなく、div要素を使っています
最後のほうにおまけをつけておきます

### 今回の更新について

前回は1枚絵の中に部分的にスプライトアニメを仕込むことで動画的な静止画を実現しようとした試みでした
今回はアバタープロジェクトやインタラクティブなプロジェクトに使用できるように拡張を考えました
デモでは、新しいgroupを使用してランダムに変化する様子が見れます

#### temlpatePramsについて

現状は機能していないが、キーにレイヤー名をセットすることで、外部からの参照を楽にする目的がある
また、このテンプレート機能を仕様とすることで共通規格として成立するようにしたい

#### groupについて

groupはlayerに付与することで、一意に操作をできるようにする

#### アンカーポイント

アンカーポイントを設定することで中心をずらすときに自由度を高める目的がある
回転を想定している
拡大縮小は重いので今回は考えていない

#### その他

##### ファイル参照

パッキングを前提としていたが、パッキングがむつかしい場合にファイルでの参照も視野かと思った
ただ、欠けていたりする際に面倒なことがありそうなのでいまいちかもしれない

##### テクスチャサイズの記述位置の変更

TextureNum内に入れたが微妙な気がする

###### photoshopのjsxの更新

構造をかなり変更したため、書き出しの時のスクリプトを更新した

<details><summary>export.jsx</summary>

```
// Photoshopスクリプト: レイヤーごとにPNGを出力し、位置をJSONで保存（map未使用）

#target photoshop

try {
    // ユーザーに出力先フォルダを選択させる
    var outputFolder = Folder.selectDialog("出力先フォルダを選択してください");
    if (!outputFolder) {
        alert("出力先フォルダが選択されませんでした。");
        throw new Error("出力フォルダが選択されていません");
    }

    // JSONデータを格納する配列
    var layerData = [];

    // 現在のドキュメントを取得
    var doc = app.activeDocument;

    // ドキュメントのキャンバスサイズを取得
    var canvasWidth = doc.width.as("px");
    var canvasHeight = doc.height.as("px");

    // PNGを保存するためのオプションを設定
    function saveAsPNG(file) {
        var pngOptions = new PNGSaveOptions();
        pngOptions.interlaced = false;
        doc.saveAs(file, pngOptions, true, Extension.LOWERCASE);
    }

    // 配列チェックの代替関数
    function isArray(obj) {
        return Object.prototype.toString.call(obj) === "[object Array]";
    }

    // JSON文字列を手動で構築する関数
    function buildJSON(data) {
        var json = "{";
        for (var key in data) {
            if (data.hasOwnProperty(key)) {
                var value = data[key];
                if (typeof value === "string") {
                    value = '"' + value + '"'; // 文字列はダブルクォートで囲む
                } else if (typeof value === "object") {
                    if (isArray(value)) {
                        var arrayItems = [];
                        for (var i = 0; i < value.length; i++) {
                            arrayItems.push(buildJSON(value[i])); // 配列要素を再帰的に処理
                        }
                        value = "[" + arrayItems.join(",") + "]";
                    } else {
                        value = buildJSON(value); // オブジェクトの場合は再帰処理
                    }
                }
                json += '"' + key + '":' + value + ",";
            }
        }
        json = json.replace(/,$/, ""); // 最後のカンマを削除
        json += "}";
        return json;
    }

    // レイヤーを個別にエクスポートする関数（逆順で処理）
    function processLayers(layerSet, parentX, parentY, layerIndex) {
        for (var i = layerSet.layers.length - 1; i >= 0; i--) { // 下層から処理
            var layer = layerSet.layers[i];

            // レイヤーがレイヤーセットの場合は再帰的に処理
            if (layer.typename === "LayerSet") {
                layerIndex = processLayers(layer, parentX, parentY, layerIndex);
            } else {
                if (!layer.visible) continue; // 非表示レイヤーはスキップ

                // レイヤーの位置を取得
                var bounds = layer.bounds;
                var x = bounds[0].as("px") + parentX;
                var y = bounds[1].as("px") + parentY;

                // レイヤーを複製して新しいドキュメントを作成
                app.activeDocument.activeLayer = layer;
                layer.copy();
                var tempDoc = app.documents.add(layer.bounds[2] - layer.bounds[0], layer.bounds[3] - layer.bounds[1], doc.resolution, "TempDoc", NewDocumentMode.RGB, DocumentFill.TRANSPARENT);
                tempDoc.paste();

                // ファイル名を連番で作成
                var fileName = ("00" + layerIndex).slice(-2) + ".png";
                var filePath = new File(outputFolder + "/" + fileName);

                // PNGとして保存
                saveAsPNG(filePath);

                // JSONデータに追加
                layerData.push({
                    fileName: fileName,
                    x: x,
                    y: y,
                    width: tempDoc.width.as("px"),
                    height: tempDoc.height.as("px")
                });

                // 一時ドキュメントを閉じる
                tempDoc.close(SaveOptions.DONOTSAVECHANGES);

                // 次の連番用インデックスを増加
                layerIndex++;
            }
        }
        return layerIndex;
    }

    // レイヤーを処理（連番のインデックスは1から開始）
    processLayers(doc, 0, 0, 1);

    // JSONファイルとして保存
    if (layerData.length > 0) {
        var jsonFile = new File(outputFolder + "/layer_data.json");
        jsonFile.open("w");

        // JSONデータを手動構築
        var outputData = {
            canvasWidth: canvasWidth,
            canvasHeight: canvasHeight,
            layers: layerData
        };
        jsonFile.write(buildJSON(outputData)); // 手動JSON構築関数を使用
        jsonFile.close();

        alert("レイヤーのエクスポートが完了しました！");
    } else {
        alert("処理されたレイヤーがありませんでした。");
    }
} catch (e) {
    alert("エラーが発生しました: " + e.message);
}

```
</details>

#### データの更新

jsxで出力したjsonファイルだとほしいフォーマットになっていないので一度更新をかけている
フォーマットの更新、この後のパッキングは意識したくないのでツール読み込み時に成形されるようする

<details><summary>json更新python</summary>

```update.py
import json

# 入力ファイルと出力ファイルのパス
input_file = "layer_data.json"
output_file = "converted_texture.json"

# layer_data.json を読み込む
with open(input_file, "r", encoding="utf-8") as f:
    layer_data = json.load(f)

# zundamon.texture.json のフォーマットに変換
converted_data = {
    "layerCount": len(layer_data["layers"]),
    "baseCanvasWidth": layer_data["canvasWidth"],  # 修正: canvasWidth → baseCanvasWidth
    "baseCanvasHeight": layer_data["canvasHeight"],  # 修正: canvasHeight → baseCanvasHeight
    "TextureNum": [
        {
            "imageCount": 1,
            "texImg": ["texture.png"],
            "canvasWidth": 4096,
            "canvasHeight": 4096
        }
    ],
    "layers": []
}

# 各レイヤーを変換
for index, layer in enumerate(layer_data["layers"], start=1):
    converted_data["layers"].append({
        "fileName": layer["fileName"],
        "x": layer["x"],
        "y": layer["y"],
        "width": layer["width"],
        "height": layer["height"],
        "basePosition_x": layer["x"],  # `basePosition_x` を x に設定
        "basePosition_y": layer["y"],  # `basePosition_y` を y に設定
        "textureZIndex": index - 1,
        "type": "Texture",
        "textureID": f"layer_{index}",
        "group": "",
        "animID": "",
        "anchorPoint_x": 0,
        "anchorPoint_y": 0
    })

# 変換されたJSONを保存
with open(output_file, "w", encoding="utf-8") as f:
    json.dump(converted_data, f, indent=4)

print(f"変換完了: {output_file}")


```

</details>

#### パッキング

パッキングはテクスチャサイズが2の階乗になるように調整するようにした
この辺は色々アルゴリズムがありそうなので参考程度に置いておく

<details><summary>packingのサンプルpythonコード</summary>

```packing.py
import json
import os
import math
from PIL import Image

# 入力 JSON ファイルのパス
json_path = "converted_texture.json"
output_texture_path = "packed_texture.png"
output_json_path = "updated_texture_data.json"

# JSON を読み込む
with open(json_path, "r", encoding="utf-8") as file:
    data = json.load(file)

layers = data["layers"]
base_canvas_width = data["baseCanvasWidth"]  # 元の幅を使用
base_canvas_height = data["baseCanvasHeight"]  # 元の高さを使用

# 画像を読み込み
images = []
max_width, max_height = 0, 0

for layer in layers:
    img = Image.open(layer["fileName"]).convert("RGBA")  # 画像を開く
    images.append((img, layer["width"], layer["height"], layer["fileName"], layer["textureID"], layer["basePosition_x"], layer["basePosition_y"]))
    max_width = max(max_width, layer["width"])
    max_height = max(max_height, layer["height"])

# 必要なテクスチャのサイズを計算（2の累乗）
total_area = sum(w * h for _, w, h, _, _, _, _ in images)
side_length = 2 ** math.ceil(math.log2(math.sqrt(total_area)))

while True:
    texture_width = texture_height = side_length
    occupied_area = 0
    x, y, row_height = 0, 0, 0

    for _, w, h, _, _, _, _ in images:
        if x + w > texture_width:
            x = 0
            y += row_height
            row_height = 0
        if y + h > texture_height:
            break
        x += w
        row_height = max(row_height, h)
        occupied_area += w * h

    if occupied_area <= texture_width * texture_height:
        break
    side_length *= 2  # サイズを2倍に

# テクスチャ画像の作成
texture = Image.new("RGBA", (texture_width, texture_height), (0, 0, 0, 0))
x, y, row_height = 0, 0, 0
placement_data = []

for img, w, h, file_name, textureID, base_x, base_y in images:
    if x + w > texture_width:
        x = 0
        y += row_height
        row_height = 0
    texture.paste(img, (x, y))
    placement_data.append({
        "fileName": file_name,
        "x": x,
        "y": y,
        "width": w,
        "height": h,
        "basePosition_x": base_x,  # 元のJSONの値を使用
        "basePosition_y": base_y,  # 元のJSONの値を使用
        "textureZIndex": len(placement_data),
        "type": "Texture",
        "textureID": textureID,
        "group": "",
        "animID": "",
        "anchorPoint_x": 0,
        "anchorPoint_y": 0
    })
    x += w
    row_height = max(row_height, h)

# 保存
texture.save(output_texture_path, "PNG")

# 更新された JSON データを保存
data["TextureNum"][0]["texImg"] = [output_texture_path]
data["TextureNum"][0]["canvasWidth"] = texture_width
data["TextureNum"][0]["canvasHeight"] = texture_height
data["layers"] = placement_data
data["baseCanvasWidth"] = base_canvas_width  # 元の値を維持
data["baseCanvasHeight"] = base_canvas_height

with open(output_json_path, "w", encoding="utf-8") as file:
    json.dump(data, file, indent=4)

print(f"パッキング完了: {output_texture_path}, {output_json_path}")


```

</details>


#### おまけ

<details><summary>今回のサンプルのまとめ</summary>

[![Image from Gyazo](https://i.gyazo.com/c144a0ff9be00daf4474dd6bb2afbbc9.png)](https://gyazo.com/c144a0ff9be00daf4474dd6bb2afbbc9)

```JSON
{
    "layerCount": 15,
    "baseCanvasWidth": 1082,
    "baseCanvasHeight": 1650,
    "TextureNum": [
        {
            "imageCount": 1,
            "texImg": [
                "packed_texture.png"
            ],
            "canvasWidth": 2048,
            "canvasHeight": 2048
        }
    ],
    "templateParams": [
        {
            "ParamEyeLOpen": "layer_11",
            "ParamEyeLClose": "layer_8",
            "ParamEyeLSmile": "layer_1",
            "ParamEyeROpen": "layer_11",
            "ParamEyeRClose": "layer_9",
            "ParamEyeRSmile": "layer_8",
            "ParamBrowL": "layer_13",
            "ParamBrowR": "layer_13",
            "ParamMouthOpen": "layer_6",
            "ParamMouthClose": "layer_7"
        }
    ],
    "templateGroups": [
        {
            "base": "base",
            "ParamGroupEyes": "eyes",
            "ParamGroupBrows": "brows",
            "ParamGroupMouth": "mouth"
        }
    ],
    "layers": [
        {
            "fileName": "01.png",
            "x": 0,
            "y": 0,
            "width": 209,
            "height": 478,
            "basePosition_x": 272,
            "basePosition_y": 592,
            "textureZIndex": 0,
            "type": "Texture",
            "textureID": "layer_1",
            "group": "base",
            "animID": "",
            "anchorPoint_x": 0,
            "anchorPoint_y": 0
        },
        {
            "fileName": "02.png",
            "x": 209,
            "y": 0,
            "width": 182,
            "height": 477,
            "basePosition_x": 580,
            "basePosition_y": 595,
            "textureZIndex": 1,
            "type": "Texture",
            "textureID": "layer_2",
            "group": "base",
            "animID": "",
            "anchorPoint_x": 0,
            "anchorPoint_y": 0
        },
        {
            "fileName": "03.png",
            "x": 391,
            "y": 0,
            "width": 658,
            "height": 1415,
            "basePosition_x": 306,
            "basePosition_y": 167,
            "textureZIndex": 2,
            "type": "Texture",
            "textureID": "layer_3",
            "group": "base",
            "animID": "",
            "anchorPoint_x": 0,
            "anchorPoint_y": 0
        },
        {
            "fileName": "04.png",
            "x": 1049,
            "y": 0,
            "width": 171,
            "height": 475,
            "basePosition_x": 591,
            "basePosition_y": 600,
            "textureZIndex": 3,
            "type": "Texture",
            "textureID": "layer_4",
            "group": "base",
            "animID": "",
            "anchorPoint_x": 0,
            "anchorPoint_y": 0
        },
        {
            "fileName": "05.png",
            "x": 1220,
            "y": 0,
            "width": 202,
            "height": 474,
            "basePosition_x": 271,
            "basePosition_y": 599,
            "textureZIndex": 4,
            "type": "Texture",
            "textureID": "layer_5",
            "group": "base",
            "animID": "",
            "anchorPoint_x": 0,
            "anchorPoint_y": 0
        },
        {
            "fileName": "06.png",
            "x": 1422,
            "y": 0,
            "width": 66,
            "height": 61,
            "basePosition_x": 460,
            "basePosition_y": 474,
            "textureZIndex": 5,
            "type": "Texture",
            "textureID": "layer_6",
            "group": "mouth",
            "animID": "",
            "anchorPoint_x": 0,
            "anchorPoint_y": 0
        },
        {
            "fileName": "07.png",
            "x": 1488,
            "y": 0,
            "width": 58,
            "height": 16,
            "basePosition_x": 466,
            "basePosition_y": 496,
            "textureZIndex": 6,
            "type": "Texture",
            "textureID": "layer_7",
            "group": "mouth",
            "animID": "",
            "anchorPoint_x": 0,
            "anchorPoint_y": 0
        },
        {
            "fileName": "08.png",
            "x": 1546,
            "y": 0,
            "width": 222,
            "height": 48,
            "basePosition_x": 379,
            "basePosition_y": 396,
            "textureZIndex": 7,
            "type": "Texture",
            "textureID": "layer_8",
            "group": "eyes",
            "animID": "",
            "anchorPoint_x": 0,
            "anchorPoint_y": 0
        },
        {
            "fileName": "09.png",
            "x": 1768,
            "y": 0,
            "width": 231,
            "height": 33,
            "basePosition_x": 376,
            "basePosition_y": 406,
            "textureZIndex": 8,
            "type": "Texture",
            "textureID": "layer_9",
            "group": "eyes",
            "animID": "",
            "anchorPoint_x": 0,
            "anchorPoint_y": 0
        },
        {
            "fileName": "10.png",
            "x": 0,
            "y": 1415,
            "width": 250,
            "height": 72,
            "basePosition_x": 368,
            "basePosition_y": 372,
            "textureZIndex": 9,
            "type": "Texture",
            "textureID": "layer_10",
            "group": "eyes",
            "animID": "",
            "anchorPoint_x": 0,
            "anchorPoint_y": 0
        },
        {
            "fileName": "11.png",
            "x": 250,
            "y": 1415,
            "width": 256,
            "height": 106,
            "basePosition_x": 363,
            "basePosition_y": 352,
            "textureZIndex": 10,
            "type": "Texture",
            "textureID": "layer_11",
            "group": "eyes",
            "animID": "",
            "anchorPoint_x": 0,
            "anchorPoint_y": 0
        },
        {
            "fileName": "12.png",
            "x": 506,
            "y": 1415,
            "width": 250,
            "height": 107,
            "basePosition_x": 366,
            "basePosition_y": 351,
            "textureZIndex": 11,
            "type": "Texture",
            "textureID": "layer_12",
            "group": "eyes",
            "animID": "",
            "anchorPoint_x": 0,
            "anchorPoint_y": 0
        },
        {
            "fileName": "13.png",
            "x": 756,
            "y": 1415,
            "width": 216,
            "height": 18,
            "basePosition_x": 383,
            "basePosition_y": 305,
            "textureZIndex": 12,
            "type": "Texture",
            "textureID": "layer_13",
            "group": "brows",
            "animID": "",
            "anchorPoint_x": 0,
            "anchorPoint_y": 0
        },
        {
            "fileName": "14.png",
            "x": 972,
            "y": 1415,
            "width": 549,
            "height": 423,
            "basePosition_x": 258,
            "basePosition_y": 109,
            "textureZIndex": 13,
            "type": "Texture",
            "textureID": "layer_14",
            "group": "base",
            "animID": "",
            "anchorPoint_x": 0,
            "anchorPoint_y": 0
        },
        {
            "fileName": "15.png",
            "x": 1521,
            "y": 1415,
            "width": 158,
            "height": 116,
            "basePosition_x": 435,
            "basePosition_y": 87,
            "textureZIndex": 14,
            "type": "Texture",
            "textureID": "layer_15",
            "group": "base",
            "animID": "",
            "anchorPoint_x": 78,
            "anchorPoint_y": 90
        }
    ],
    "directory": ""
}
```

```index.html
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>JSONロード＆アニメーション</title>
    <link rel="stylesheet" href="styles.css">
</head>
<body>

    <!-- JSONアップロード用ボタン -->
    <div class="upload-section">
        <input type="file" id="jsonInput" accept=".json">
        <button onclick="loadJSON()">JSONをロード</button>
    </div>

    <!-- レイヤーを表示するコンテナ（JSONの値でサイズが変わる） -->
    <div class="container" id="layerContainer"></div>

    <script src="script.js"></script>

</body>
</html>

```

```styles.css
body {
    background-color: #222;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    height: 100vh;
    position: relative;
}
.container {
    position: relative;
    background-color: rgba(255, 255, 255, 0.2);
    overflow: hidden;
}
.layer {
    position: absolute;
    background-repeat: no-repeat;
    z-index: 10;
}
.upload-section {
    margin-bottom: 20px;
}

```

```scripts.js
let layersData = [];
let eyeLayers = [];
let mouthLayers = [];
let texturePath = "";
let containerWidth = 0;
let containerHeight = 0;

function loadJSON() {
    const fileInput = document.getElementById('jsonInput');
    const file = fileInput.files[0];

    if (!file) {
        alert("JSONファイルを選択してください！");
        return;
    }

    const reader = new FileReader();
    reader.onload = function(event) {
        const jsonData = JSON.parse(event.target.result);
        layersData = jsonData.layers; // レイヤー情報を保存

        // テクスチャパスを取得
        if (jsonData.TextureNum && jsonData.TextureNum.length > 0) {
            texturePath = jsonData.TextureNum[0].texImg[0]; // テクスチャのパス
        }

        // コンテナサイズを取得
        containerWidth = jsonData.baseCanvasWidth;
        containerHeight = jsonData.baseCanvasHeight;

        classifyLayers(jsonData);
        renderLayers();
        startAnimation();
    };
    reader.readAsText(file);
}

function classifyLayers(data) {
    eyeLayers = [];
    mouthLayers = [];

    data.layers.forEach(layer => {
        if (layer.group === "eyes") {
            eyeLayers.push(layer);
        } else if (layer.group === "mouth") {
            mouthLayers.push(layer);
        }
    });
}

function renderLayers() {
    const container = document.getElementById('layerContainer');
    container.innerHTML = ''; // 既存のレイヤーをクリア

    // JSONデータからコンテナサイズを適用
    container.style.width = `${containerWidth}px`;
    container.style.height = `${containerHeight}px`;

    layersData.forEach(layer => {
        const div = document.createElement('div');
        div.classList.add('layer');
        div.id = layer.textureID;
        div.style.width = `${layer.width}px`;
        div.style.height = `${layer.height}px`;
        div.style.left = `${layer.basePosition_x}px`;
        div.style.top = `${layer.basePosition_y}px`;
        div.style.backgroundImage = `url('${texturePath}')`; // JSONから取得したテクスチャパスを適用
        div.style.backgroundPosition = `-${layer.x}px -${layer.y}px`;
        div.style.zIndex = layer.textureZIndex;
        div.dataset.group = layer.group; // グループを設定
        container.appendChild(div);
    });
}

function startAnimation() {
    setInterval(() => toggleLayer(eyeLayers), 500); // 3秒ごとに目を切り替え
    setInterval(() => toggleLayer(mouthLayers), 300); // 2秒ごとに口を切り替え
}

function toggleLayer(layerGroup) {
    if (layerGroup.length < 2) return; // 切り替え可能なレイヤーがあるか確認

    // 現在表示されているレイヤーをすべて非表示
    layerGroup.forEach(layer => {
        document.getElementById(layer.textureID).style.opacity = "0";
    });

    // ランダムに1つのレイヤーを表示
    const randomLayer = layerGroup[Math.floor(Math.random() * layerGroup.length)];
    document.getElementById(randomLayer.textureID).style.opacity = "1";
}

```

</details>

次はアニメーションについての拡張か、他プラットフォームで使用できるような何か、もしくはFigmaなどからの書き出しを考えるか、もしくは、コンテナ化を考えます

今回も坂本アヒルさまのずんだもんを使用させていただきました
https://www.pixiv.net/artworks/92641351
ありがとうございます