前回の続き
何をしたいのか伝えられていない気がするのだけど、まだもう少し続ける
前回のJSONを拡張
"canvasWidth": 4096, //テクスチャ幅サイズ
"canvasHeight": 4096, //テクスチャ高さ
"layerCount": 2,
"baseCanvasWidth": 1920, //レンダリングキャンバスサイズ
"baseCanvasHeight": 1080, //レンダリング高さ
"TextureNum": [ //何枚のテクスチャを使用するか
{
"imageCount": 1,
"texImg": ["texture_room_4096.png"]
}
],
"layers": [
{
"basefileName": "01.png",
"imgType": "Texture", // Texture or Sprite
"assignID": "layer_1", // 参照名
"animID":"",
"x": 0,
"y": 0,
"width": 1920,
"height": 1080,
"basePosition_x": 0,
"basePosition_y": 0,
"textureZIndex": 0
}
],
"sprites":[ //スプライトアニメの定義
{
"anim01":[ //アニメーション
{
"fps":8,
"loop": 1, //0がループなし 1がループあり
"useTex":[
"s_01","s_02","s_03","s_04","s_05","s_06","s_07","s_08","s_09","s_10"
]
}
]
}
]
}
追加した仕様
- キャンバスサイズとレンダリング領域について定義
- テクスチャを複数持てるように
- Spriteアニメを定義できるように
- fps
- ループ
- 使用するテクスチャを配列で指定
JSONのサンプル
{
"canvasWidth": 4096,
"canvasHeight": 4096,
"layerCount": 2,
"baseCanvasWidth": 1920,
"baseCanvasHeight": 1080,
"TextureNum": [
{
"imageCount": 1,
"texImg": ["texture_room_4096.png"]
}
],
"layers": [
{
"fileName": "",
"imgType": "Texture",
"assignID": "layer_1",
"animID":"",
"x": 0,
"y": 0,
"width": 1920,
"height": 1080,
"basePosition_x": 0,
"basePosition_y": 0,
"textureZIndex": 0
},
{
"fileName": "",
"imgType": "Sprite",
"assignID": "s_01",
"animID":"anim01",
"x": 0,
"y": 1080,
"width": 408,
"height": 336,
"basePosition_x": 415,
"basePosition_y": 563,
"textureZIndex": 1
},
{
"fileName": "",
"imgType": "Sprite",
"assignID": "s_02",
"animID":"anim01",
"x": 408,
"y": 1080,
"width": 408,
"height": 336,
"basePosition_x": 415,
"basePosition_y": 563,
"textureZIndex": 1
},
{
"fileName": "",
"imgType": "Sprite",
"assignID": "s_03",
"animID":"anim01",
"x": 816,
"y": 1080,
"width": 408,
"height": 336,
"basePosition_x": 415,
"basePosition_y": 563,
"textureZIndex": 1
},
{
"fileName": "",
"imgType": "Sprite",
"assignID": "s_04",
"animID":"anim01",
"x": 1224,
"y": 1080,
"width": 408,
"height": 336,
"basePosition_x": 415,
"basePosition_y": 563,
"textureZIndex": 1
},
{
"fileName": "",
"imgType": "Sprite",
"assignID": "s_05",
"animID":"anim01",
"x": 1632,
"y": 1080,
"width": 408,
"height": 336,
"basePosition_x": 415,
"basePosition_y": 563,
"textureZIndex": 1
},
{
"fileName": "",
"imgType": "Sprite",
"assignID": "s_06",
"animID":"anim01",
"x": 0,
"y": 1416,
"width": 408,
"height": 336,
"basePosition_x": 415,
"basePosition_y": 563,
"textureZIndex": 1
},
{
"fileName": "",
"imgType": "Sprite",
"assignID": "s_07",
"animID":"anim01",
"x": 408,
"y": 1416,
"width": 408,
"height": 336,
"basePosition_x": 415,
"basePosition_y": 563,
"textureZIndex": 1
},
{
"fileName": "",
"imgType": "Sprite",
"assignID": "s_08",
"animID":"anim01",
"x": 816,
"y": 1416,
"width": 408,
"height": 336,
"basePosition_x": 415,
"basePosition_y": 563,
"textureZIndex": 1
},
{
"fileName": "",
"imgType": "Sprite",
"assignID": "s_09",
"animID":"anim01",
"x": 1224,
"y": 1416,
"width": 408,
"height": 336,
"basePosition_x": 415,
"basePosition_y": 563,
"textureZIndex": 1
},
{
"fileName": "",
"imgType": "Sprite",
"assignID": "s_10",
"animID":"anim01",
"x": 1632,
"y": 1416,
"width": 408,
"height": 336,
"basePosition_x": 415,
"basePosition_y": 563,
"textureZIndex": 1
}
],
"sprites":[
{
"anim01":[
{
"fps":8,
"loop": 1,
"useTex":[
"s_01","s_02","s_03","s_04","s_05","s_06","s_07","s_08","s_09","s_10"
]
}
]
}
]
}
pygameでレンダリングがうまくいかなかったので、今回はcanvasでレンダリングをしています
1枚のテクスチャで部分的にアニメを再生させています
かなりでかい画像を使っているのと(分かりにくいサンプルで)すいません
元のテクスチャに対して余裕があるので、何カ所かアニメが可能です
大きな目的が3つあり、
- きれいにするにはでかくなる
- 作るのが大変だし覚えるのも大変
- 仕様が不明
上記を解決したいと思って今回のプロトタイプを作成しています
(実装のjsも以下に置きますが、主体はJSONとテクスチャファイルです)
きれいにするにはでかくなる
何がでかくなるのかというと、ファイルサイズとレンダリングコストです
lo-fi girlを実装しよう考えたところ、ループアニメ専用の形式がないように思いました
APNGやGIFということも考えられますが、今回のデモでも同じ大きさの10枚のPNGを用意して、圧縮する必要があります
また次の指摘にも関わりますが、一つの動画ファイルにするとインタラクティブな要素が埋め込めないという大きな課題があります
スプライトアニメ自体は古くからあるアプローチですが課題の解決にはいいのかなと考えています
圧縮時に画質が落ちてしまうのは動画では避けられない課題です
作るのが大変だし覚えるのも大変
動画作成やアバター制作においてツールの学習コストが高い
ミドルウェアとしてファットにならないことはコンテンツ制作者に有利なのではないでしょうか
仕様が不明
仕様は明らかであるべきです
FlashでもMMDもブラックボックスなところが不利に働いた歴史を歩いてきました
少なくともインディーズでは誰もがさわれる形のほうが良いのではないでしょうか
以下ChatGPTが書いてくれたドキュメント
JSONデータのドキュメント
このドキュメントでは、room_texture.model.json
のデータ構造とその使用方法について説明します。
1. JSONデータの概要
このJSONファイルは、2Dグラフィックのレイヤー管理とスプライトアニメーションの設定を行うためのものです。
-
テクスチャ(
Texture
)- 静的な画像をキャンバス上に描画するためのデータ。
-
スプライト(
Sprite
)- アニメーションを持つレイヤーで、フレームごとに異なる画像を切り替えて再生。
-
テクスチャ情報
- 使用するテクスチャファイルの情報。
-
スプライトアニメーション設定
- フレームレートやループ設定を含む。
2. JSONの構造
{
"canvasWidth": 4096,
"canvasHeight": 4096,
"layerCount": 2,
"baseCanvasWidth": 1920,
"baseCanvasHeight": 1080,
"TextureNum": [
{
"imageCount": 1,
"texImg": ["texture_room_4096.png"]
}
],
"layers": [...],
"sprites": [...]
}
3. 各フィールドの詳細
(1) canvasWidth
/ canvasHeight
"canvasWidth": 4096,
"canvasHeight": 4096,
- 全体のキャンバスサイズを指定
- この値は通常の描画領域とは異なり、オリジナルのテクスチャサイズ
- 実際の描画には
baseCanvasWidth
/baseCanvasHeight
を使用する
(2) baseCanvasWidth
/ baseCanvasHeight
"baseCanvasWidth": 1920,
"baseCanvasHeight": 1080,
- 1枚目のJSONのキャンバスサイズとして適用
- 2枚目以降のJSONは、
baseCanvasWidth
に合わせて等比縮小される
(3) TextureNum
(テクスチャ情報)
"TextureNum": [
{
"imageCount": 1,
"texImg": ["texture_room_4096.png"]
}
]
texImg
にテクスチャの画像ファイル名を指定- 複数のテクスチャがある場合は
imageCount
で管理可能
(4) layers
(レイヤー情報)
各レイヤーごとに、画像の配置や種類を指定。
"layers": [
{
"imgType": "Texture",
"assignID": "layer_1",
"animID": "",
"x": 0, "y": 0, "width": 1920, "height": 1080,
"basePosition_x": 0, "basePosition_y": 0,
"textureZIndex": 0
},
{
"imgType": "Sprite",
"assignID": "s_01",
"animID": "anim01",
"x": 0, "y": 1080, "width": 408, "height": 336,
"basePosition_x": 415, "basePosition_y": 563,
"textureZIndex": 1
}
]
各フィールドの詳細
フィールド名 | 説明 |
---|---|
imgType |
"Texture" (静的画像)または "Sprite" (アニメーション) |
assignID |
一意のID(スプライトアニメーションでも使用) |
animID |
スプライトがアニメーションする場合のアニメーションID |
x , y
|
テクスチャ内の座標(この範囲から画像を切り抜く) |
width , height
|
切り抜く範囲のサイズ |
basePosition_x , basePosition_y
|
キャンバス上での描画位置 |
textureZIndex |
描画順序(大きいほど手前に表示) |
(5) sprites
(スプライトアニメーションの設定)
"sprites": [
{
"anim01": [
{
"fps": 8,
"loop": 1,
"useTex": [
"s_01", "s_02", "s_03", "s_04",
"s_05", "s_06", "s_07", "s_08",
"s_09", "s_10"
]
}
]
}
]
各フィールドの詳細
フィールド名 | 説明 |
---|---|
animID |
スプライトのアニメーションID(anim01 ) |
fps |
1秒間に何回フレームを更新するか |
loop |
1 → ループ、0 → 1回のみ再生 |
useTex |
assignID を指定し、アニメーションする画像の順番を決定 |
4. 使用方法
(1) JSONを読み込む
fetch('room_texture.model.json')
.then(response => response.json())
.then(jsonData => addJsonToCanvas(jsonData));
(2) キャンバスに描画
function renderCanvas() {
const canvas = document.getElementById('textureCanvas');
const ctx = canvas.getContext('2d');
jsonData.layers.forEach(layer => {
const textureImage = new Image();
textureImage.src = "texture_room_4096.png";
textureImage.onload = () => {
ctx.drawImage(
textureImage,
layer.x, layer.y, layer.width, layer.height,
layer.basePosition_x, layer.basePosition_y, layer.width, layer.height
);
};
});
}
(3) スプライトアニメーション
function startSpriteAnimation(ctx, jsonData, layer, textureImage) {
const spriteConfig = jsonData.sprites[0][layer.animID][0];
function animateSprite() {
const frameIndex = animationFrames[layer.assignID] % spriteConfig.useTex.length;
const frameAssignID = spriteConfig.useTex[frameIndex];
const frameLayer = jsonData.layers.find(l => l.assignID === frameAssignID);
if (frameLayer) {
ctx.drawImage(
textureImage,
frameLayer.x, frameLayer.y, frameLayer.width, frameLayer.height,
frameLayer.basePosition_x, frameLayer.basePosition_y, frameLayer.width, frameLayer.height
);
}
animationFrames[layer.assignID]++;
if (spriteConfig.loop === 1) {
setTimeout(animateSprite, 1000 / spriteConfig.fps);
}
}
animateSprite();
}
5. まとめ
- テクスチャ情報 (
TextureNum
) を指定 -
layers
にレイヤー情報を設定-
"Texture"
は静的画像 -
"Sprite"
はアニメーション用
-
-
sprites
でスプライトアニメーションを管理-
fps
やloop
で動作を制御 -
useTex
でフレームを指定
-
このドキュメントを参照し、JSONデータの適切な利用を行ってください! 🚀
次回
もう少しJSONの構造を考えるのと、プレイヤー部分を何とかしたいところ
今回の部屋の素材はニコニコモンズからおかりました
https://commons.nicovideo.jp/works/agreement/nc225712
南部休みさんありがとうございます
おまけ
jsのサンプル
document.getElementById('loadJsonButton').addEventListener('click', () => {
const fileInput = document.getElementById('jsonFileInput');
const file = fileInput.files[0];
if (!file) {
alert('Please select a JSON file.');
return;
}
const reader = new FileReader();
reader.onload = function (event) {
const jsonData = JSON.parse(event.target.result);
addJsonToCanvas(jsonData);
};
reader.readAsText(file);
});
document.getElementById('resetCanvasButton').addEventListener('click', resetCanvas);
let jsonDataList = [];
let baseCanvasSizeSet = false;
let secondLayerOffset = { x: 0, y: 0 };
let secondLayerCanvas = null;
let secondLayerScale = 1;
let animationFrames = {};
let activeAnimations = {};
// スライダーイベント設定
document.getElementById('sliderX').addEventListener('input', (event) => {
secondLayerOffset.x = parseInt(event.target.value);
requestRender();
});
document.getElementById('sliderY').addEventListener('input', (event) => {
secondLayerOffset.y = parseInt(event.target.value);
requestRender();
});
let renderRequested = false;
function requestRender() {
if (!renderRequested) {
renderRequested = true;
setTimeout(() => {
renderCanvas();
renderRequested = false;
}, 50);
}
}
function addJsonToCanvas(jsonData) {
jsonDataList.push(jsonData);
if (!baseCanvasSizeSet) {
// 1 枚目の JSON は即時描画
document.getElementById('textureCanvas').width = jsonData.baseCanvasWidth;
document.getElementById('textureCanvas').height = jsonData.baseCanvasHeight;
baseCanvasSizeSet = true;
renderCanvas();
} else {
// 2 枚目の JSON はオフスクリーンキャンバスで描画して縮小
secondLayerCanvas = document.createElement('canvas');
secondLayerCanvas.width = jsonData.baseCanvasWidth;
secondLayerCanvas.height = jsonData.baseCanvasHeight;
const secondLayerCtx = secondLayerCanvas.getContext('2d');
const textureInfo = jsonData.TextureNum[0];
const textureImage = new Image();
textureImage.src = textureInfo.texImg[0];
textureImage.onload = () => {
jsonData.layers.forEach(layer => {
secondLayerCtx.drawImage(
textureImage,
layer.x, layer.y, layer.width, layer.height,
layer.basePosition_x, layer.basePosition_y, layer.width, layer.height
);
});
let scaleW = document.getElementById('textureCanvas').width / secondLayerCanvas.width;
let scaleH = document.getElementById('textureCanvas').height / secondLayerCanvas.height;
secondLayerScale = Math.min(scaleW, scaleH);
requestRender();
};
}
}
function renderCanvas() {
const canvas = document.getElementById('textureCanvas');
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
jsonDataList.forEach((jsonData, index) => {
const textureInfo = jsonData.TextureNum[0];
const textureImage = new Image();
textureImage.src = textureInfo.texImg[0];
textureImage.onload = () => {
const sortedLayers = jsonData.layers.sort((a, b) => a.textureZIndex - b.textureZIndex);
sortedLayers.forEach(layer => {
if (index === 0) {
// 1 枚目の JSON のレイヤーを即時描画
ctx.drawImage(
textureImage,
layer.x, layer.y, layer.width, layer.height,
layer.basePosition_x, layer.basePosition_y, layer.width, layer.height
);
} else if (index === 1 && secondLayerCanvas) {
// 2 枚目の JSON は縮小して描画
ctx.drawImage(
secondLayerCanvas,
0, 0, secondLayerCanvas.width, secondLayerCanvas.height,
secondLayerOffset.x, secondLayerOffset.y,
secondLayerCanvas.width * secondLayerScale, secondLayerCanvas.height * secondLayerScale
);
}
});
// スプライトアニメーションを開始
jsonData.layers.forEach(layer => {
if (layer.imgType === "Sprite") {
startSpriteAnimation(ctx, jsonData, layer, textureImage);
}
});
};
textureImage.onerror = () => {
console.error("Failed to load texture image:", textureImage.src);
};
});
}
function startSpriteAnimation(ctx, jsonData, layer, textureImage) {
const spriteConfig = jsonData.sprites[0][layer.animID][0];
if (!spriteConfig) return;
animationFrames[layer.assignID] = 0;
activeAnimations[layer.assignID] = true;
function animateSprite() {
if (!activeAnimations[layer.assignID]) return;
const frameIndex = animationFrames[layer.assignID] % spriteConfig.useTex.length;
const frameAssignID = spriteConfig.useTex[frameIndex];
const frameLayer = jsonData.layers.find(l => l.assignID === frameAssignID);
if (frameLayer) {
ctx.clearRect(frameLayer.basePosition_x, frameLayer.basePosition_y, frameLayer.width, frameLayer.height);
ctx.drawImage(
textureImage,
frameLayer.x, frameLayer.y, frameLayer.width, frameLayer.height,
frameLayer.basePosition_x, frameLayer.basePosition_y, frameLayer.width, frameLayer.height
);
}
document.getElementById('updateLog').textContent = `Animating ${layer.assignID}: Frame ${frameIndex + 1}`;
animationFrames[layer.assignID]++;
if (spriteConfig.loop === 1 || animationFrames[layer.assignID] < spriteConfig.useTex.length) {
setTimeout(animateSprite, 1000 / spriteConfig.fps);
} else {
activeAnimations[layer.assignID] = false;
}
}
animateSprite();
}
function resetCanvas() {
jsonDataList = [];
baseCanvasSizeSet = false;
secondLayerOffset = { x: 0, y: 0 };
secondLayerCanvas = null;
secondLayerScale = 1;
animationFrames = {};
activeAnimations = {};
document.getElementById('textureCanvas').width = 0;
document.getElementById('textureCanvas').height = 0;
document.getElementById('sliderX').value = 0;
document.getElementById('sliderY').value = 0;
renderCanvas();
}
jsonは二つ目のロードもできますが、canvasだとぶっ壊れてしまうので考えものです