1 of 31

TypeScriptでドット絵エディタ実装録

~状態設計と実装判断~

2 of 31

自己紹介

3 of 31

自己紹介

つくだに / 佐川

  • 出身:福島県
  • 所属:株式会社 オプティム�     フロントエンド推進室 運営メンバー
  • 趣味:サウナ・ゲーム・酒・ドット絵

4 of 31

4

5 of 31

私たちは、AI・IoT技術を社会の隅々に行き渡らせ、

日本のあらゆる産業を根底から変革する

「AXの中核を担う企業」を目指しています。

5

6 of 31

6

7 of 31

アジェンダ

  • 作ったものについて
  • 機能と実装判断
  • まとめ

​

✔ 話す

✖ 話さない

  • フレームワークとか、その他のこと
    • Canvas API の詳細
    • Nuxt / Vue の詳細
  • ドット絵エディタアプリ実装のロジック判断
    • データ構造
    • 直線・塗りつぶし
    • レイヤー管理
    • モバイル操作の工夫

8 of 31

作ったもの

WEBアプリ「DotArt」

​

  • 機能
    • ドット絵エディタ
      • ペン / 消しゴム / 直線 / 塗りつぶし
      • Undo・Redo
      • レイヤー機能(最大3層)
    • 画像保存・共有(共有機能は停止中)
    • モバイル・タブレット対応
  • 技術スタック
    • Nuxt (Vue.js)

​

​

9 of 31

作ったもの

作った経緯

​

  • 当時、iPhoneにモノクロ4色で簡単にドット絵が描けるアプリがあり、�とても気に入っていた
    • iOS11 での 32Bitアプリのサポート終了により起動不可に🥺
  • このアプリからドット絵を描き始めた人が色付きで描きたくなった時に、�高機能なエディターに移行する前に触りやすい簡単なエディタを作りたかった
  • AIエージェントが使われだす前だったのもあり、ロジックを自前で実装していった

10 of 31

型システムとライフサイクルの時間軸の衝突

Nullアサーション問題

  • 基本的に Canvas 要素で絵を描いているため、DOM参照が必須
  • Strict モードだと初期化時は null 許容型にせざるを得ない�→ canvasRef.value! のような null アサーションが至る所に散らばった
  • nullチェックを1回行った後は、 non-nullable な変数に再アサインするのがよさそう

const canvas = document.querySelector<HTMLCanvasElement>('#drawcanvas');

const ctx = canvas?.getContext('2d');

if (!canvas || !ctx) throw new Error('canvas init failed’);

canvasState.canvas = canvas;

canvasState.canvasCtx = ctx;

11 of 31

Point型 ── 座標を守る型

Point型

  • ドット絵エディタは座標計算だらけ
    • ドット絵を描く処理だけでなく、マウス操作のカーソル位置監視でも座標を受け取る
  • 座標情報を一元的に型指定することで広範囲の安全性を担保できた
  • 小さい型定義だが、影響範囲が広いほど恩恵も大きい
    • 型定義のありがたみ、TSありがとう

type Point = { X: number; Y: number }

�// 座標を受け取る関数すべてに適用

const getMousePoint = (wholeCoor: Point): Point => { ... }

const figureToolsState = reactive<{ // 直線描画の状態管理� drawingFigure: Point[]; // ドラッグ中の一時表示� figureToolsStart: Point; // 直線の始点�}>({...})

​

12 of 31

基本データ構造

キャンバスデータ

  • ドット絵の内容と、パレットデータ等を管理する基本的な概念

​

13 of 31

基本データ構造

  • 塗られた色をパレット配列のインデックスで管理
  • ドット絵上の座標と1次元配列の対応は Y座標 × 横幅 + X座標

canvasIndexData[ Y × N + X ] = paletteIndex

0

9

9

9

9

9

9

9

0

0

8

8

8

8

0

0

0

0

0

0

9

0

0

0

0

8

0

0

0

0

8

0

…

…

[ Y × 0 + X ]

[ Y × 1 + X ]

…

0

1

2

3

4

5

6

7

8

9

…

仕様

14 of 31

描画ツール

直線ツール

  • 直線を引く機能

15 of 31

描画ツール

実装

  • ブレゼンハムのアルゴリズムを採用
    • 一般的な線分描画のアルゴリズム
    • 直線状で横に1マス進むごとに縦に何マス進むか?�を、誤差を積み上げるという手法で解決した

let e = -xdiff;

for (let i = 0; i <= xdiff; i++) {

    Line.push({ ...cell });

    cell.X += Xvek;

    e += ydiff2;

    if (e >= 0) { cell.Y += Yvek; e -= xdiff2; }

}

16 of 31

描画ツール

  • 例: (0,0) → (6,3) の直線  (※詳細は後ほど確認ください)

横方向に 7 , 縦方向に 3 マス進む�↓

閾値 = 7(横の距離)

加算量 = 3(縦の距離)�横移動のたびに誤差 += 3 を積み上げ、

誤差が 7 以上になったら縦に1マス進み、誤差 -= 7 する

​

X=0: 誤差= 3 → 7未満 → Y=0 ・・・( 0, 0 )

X=1: 誤差= 6 → 7未満 → Y=0 ・・・( 1, 0 )

X=2: 誤差= 9 → 7以上! -7 → Y=1 ・・・( 2, 1 )

X=3: 誤差= 5 → 7未満 → Y=1 ・・・ ( 3, 1 )

X=4: 誤差= 8 → 7以上! -7 → Y=2 ・・・ ( 4, 2 )

X=5: 誤差= 4 → 7未満 → Y=2 ・・・ ( 5, 2 )

X=6: 誤差= 7 → 7以上! -7 → Y=3 ・・・ ( 6, 3 )

17 of 31

描画ツール

バケツツール

  • 囲われた中を塗りつぶす機能

18 of 31

描画ツール

実装

  • 再帰処理(4方向)を採用
    • 特にひねりなく、別の色がぶつかるまで上下左右へ走査する

const fill = (cell, color, target) => {

if (範囲外) return;

if (target[cell.Y * N + cell.X] !== color) return;

drawDot(cell);

fill({ X: cell.X - 1, Y: cell.Y }, color, target);

fill({ X: cell.X + 1, Y: cell.Y }, color, target);

fill({ X: cell.X, Y: cell.Y - 1 }, color, target);

fill({ X: cell.X, Y: cell.Y + 1 }, color, target);

};

19 of 31

描画ツール

シンプルさを重視

  • どちらも一般的な方式で実装
  • ドット絵のスケールでは再帰処理の呼び出し回数もそこまで多くならないので、十分と判断
    • 96 × 96 程度であれば問題なく動作

実装判断

20 of 31

レイヤー機能

レイヤー機能

  • ドット絵をレイヤー管理する機能

21 of 31

レイヤー機能

実装

  • 最大3層、キャンバスデータを積み重ねる

layerdCanvasData: {

layerName: string    // レイヤー名

layerIndex: number   // レイヤーの重なり順

active: boolean     // 表示/非表示

canvasIndexData: number[] // キャンバスデータ

}

canvasesIndexData: layerdCanvasData[] // 全レイヤーの配列

22 of 31

レイヤー機能

  • ドット絵の表示処理で3層のレイヤーを下から重ねるようにすると、�何か書くたびにキャンバス × 3の比較処理が走ってしまう�
  • 「今描いたドットが、レイヤー全体で一番上かどうか」の判定があれば、�レイヤー1枚走査するだけで描画可能なのでは?

🤔

思い付き

23 of 31

レイヤー機能

思い付きの結果

🤔

レイヤーの合成関数→�(約150行)

24 of 31

レイヤー機能

  • やってることはこんな感じ
  • 元々分岐が多い上に、�「現在描画されている最上位レイヤー」の更新処理等で複雑化している
  • シンプルな実装から入り、ボトルネックになってから最適化するべきだった

​

What is this

描画したピクセルは最前面のレイヤーか?

├─ Yes(このレイヤーが最前面)

│ ├─ 背景色 かつ 下に色あり → 透過と見なす 下レイヤーの色で描画

│ ├─ 背景色 かつ 下に色なし → 背景と見なす 背景色で描画

│ └─ それ以外 → そのまま描画

└─ No(上位レイヤーが前面) → 描画スキップ、データ更新のみ

25 of 31

モバイル対応 ─ カーソル方式

スマホ操作モード

  • PC操作をそのままスマホ版に持ち込むと、�指で直接描く形になる�→描いているところが指で隠れてしまう
  • タッチペンが無いと手を放すまでミスに気づけないため、改善したい

指で隠れるので�ものすごい見づらい

26 of 31

モバイル対応 ─ カーソル方式

実装

  • 「スマホモード」として、�直接タップではなくカーソル操作形式で描画できるように�

27 of 31

モバイル対応 ─ カーソル方式

画面内を�スワイプすると

カーソルも動く

ボタン押下中は

描画を行う

28 of 31

モバイル対応 ─ カーソル方式

カーソル方式

  • 既存スマホアプリで実装されており、非常に快適な操作が実現されていた
  • 代案が全く思いつかず、WEBで挙動を再現した!� → より良い操作方式があれば知りたい

​

実装判断

29 of 31

まとめ

30 of 31

まとめ

・nullチェックは1箇所に集約する

・明確な課題が出ていないなら、実装はシンプルに始める

・スマホに対応するなら、操作感にこだわる

振り返って思ったこと:

「動くものを作る」と「正しく作る」は別�

→動いたことに満足せず、

設計・計測・振り返りをセットにして初めて完成

31 of 31

ご清聴ありがとうございました!

ご紹介したもの以外に興味のある機能があれば、�ぜひ懇親会等でお話ししましょう!