【Android】ドット絵エディタアプリの作り方|Canvasでピクセルアートを描く

開発
Screenshot

Androidでドット絵エディタを作りたいと思ったことはありませんか?

私は「Dottie」というドット絵アプリを開発・公開しています。この記事では、その開発で得た知見をもとに、Androidでドット絵エディタを実装する方法を解説します。

完成イメージ

この記事で作るドット絵エディタの機能:

  • グリッド表示されたキャンバス
  • タップでピクセルを描画
  • ピンチ操作でズーム・パン
  • カラーピッカーで色選択
  • PNG形式で保存

1. 基本設計

データ構造

ドット絵は2次元配列で表現します。

class PixelCanvas(
    val width: Int,   // キャンバス幅(ピクセル数)
    val height: Int   // キャンバス高さ(ピクセル数)
) {
    // 各ピクセルの色を保持(ARGB形式)
    private val pixels = Array(height) { IntArray(width) { Color.TRANSPARENT } }
    
    fun setPixel(x: Int, y: Int, color: Int) {
        if (x in 0 until width && y in 0 until height) {
            pixels[y][x] = color
        }
    }
    
    fun getPixel(x: Int, y: Int): Int {
        return if (x in 0 until width && y in 0 until height) {
            pixels[y][x]
        } else {
            Color.TRANSPARENT
        }
    }
    
    fun clear() {
        pixels.forEach { row -> row.fill(Color.TRANSPARENT) }
    }
}

なぜBitmapではなく配列?

Bitmapを直接編集する方法もありますが、以下の理由で配列を使います:

  • アンドゥ・リドゥが実装しやすい(状態のコピーが軽量)
  • レイヤー機能の拡張が容易
  • 描画と表示を分離できる

2. キャンバスViewの実装

カスタムViewを作成し、ドット絵を描画します。

class PixelEditorView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null
) : View(context, attrs) {

    // キャンバスデータ
    var pixelCanvas = PixelCanvas(32, 32)
        set(value) {
            field = value
            invalidate()
        }
    
    // 描画用Paint
    private val pixelPaint = Paint().apply {
        style = Paint.Style.FILL
    }
    
    private val gridPaint = Paint().apply {
        style = Paint.Style.STROKE
        color = Color.LTGRAY
        strokeWidth = 1f
    }
    
    // 表示用の変換行列
    private val transformMatrix = Matrix()
    private var scaleFactor = 1f
    private var translateX = 0f
    private var translateY = 0f
    
    // 現在の描画色
    var currentColor = Color.BLACK
    
    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        
        canvas.save()
        canvas.concat(transformMatrix)
        
        val pixelSize = calculatePixelSize()
        
        // ピクセルを描画
        for (y in 0 until pixelCanvas.height) {
            for (x in 0 until pixelCanvas.width) {
                val color = pixelCanvas.getPixel(x, y)
                if (color != Color.TRANSPARENT) {
                    pixelPaint.color = color
                    canvas.drawRect(
                        x * pixelSize,
                        y * pixelSize,
                        (x + 1) * pixelSize,
                        (y + 1) * pixelSize,
                        pixelPaint
                    )
                }
            }
        }
        
        // グリッドを描画
        drawGrid(canvas, pixelSize)
        
        canvas.restore()
    }
    
    private fun calculatePixelSize(): Float {
        val viewSize = minOf(width, height).toFloat()
        val canvasSize = maxOf(pixelCanvas.width, pixelCanvas.height)
        return viewSize / canvasSize
    }
    
    private fun drawGrid(canvas: Canvas, pixelSize: Float) {
        // 縦線
        for (x in 0..pixelCanvas.width) {
            canvas.drawLine(
                x * pixelSize, 0f,
                x * pixelSize, pixelCanvas.height * pixelSize,
                gridPaint
            )
        }
        // 横線
        for (y in 0..pixelCanvas.height) {
            canvas.drawLine(
                0f, y * pixelSize,
                pixelCanvas.width * pixelSize, y * pixelSize,
                gridPaint
            )
        }
    }
}

3. タッチ操作でピクセルを描画

タップした位置のピクセルに色を塗る処理を実装します。

class PixelEditorView : View {
    
    // ... 前述のコード ...
    
    override fun onTouchEvent(event: MotionEvent): Boolean {
        when (event.action) {
            MotionEvent.ACTION_DOWN,
            MotionEvent.ACTION_MOVE -> {
                drawPixelAt(event.x, event.y)
                return true
            }
        }
        return super.onTouchEvent(event)
    }
    
    private fun drawPixelAt(touchX: Float, touchY: Float) {
        // タッチ座標をピクセル座標に変換
        val pixelSize = calculatePixelSize()
        
        // 変換行列の逆行列を使って、実際のキャンバス座標を取得
        val inverse = Matrix()
        transformMatrix.invert(inverse)
        
        val points = floatArrayOf(touchX, touchY)
        inverse.mapPoints(points)
        
        val pixelX = (points[0] / pixelSize).toInt()
        val pixelY = (points[1] / pixelSize).toInt()
        
        // ピクセルに色を設定
        pixelCanvas.setPixel(pixelX, pixelY, currentColor)
        invalidate()
    }
}

4. ピンチズーム・パンの実装

ドット絵エディタでは、細かい部分を編集するためにズーム機能が必須です。

class PixelEditorView : View {
    
    // ジェスチャー検出器
    private val scaleDetector = ScaleGestureDetector(
        context,
        object : ScaleGestureDetector.SimpleOnScaleGestureListener() {
            override fun onScale(detector: ScaleGestureDetector): Boolean {
                scaleFactor *= detector.scaleFactor
                scaleFactor = scaleFactor.coerceIn(0.5f, 10f)
                updateTransformMatrix()
                return true
            }
        }
    )
    
    private val gestureDetector = GestureDetector(
        context,
        object : GestureDetector.SimpleOnGestureListener() {
            override fun onScroll(
                e1: MotionEvent?,
                e2: MotionEvent,
                distanceX: Float,
                distanceY: Float
            ): Boolean {
                // 2本指でのスクロール(パン)
                if (e2.pointerCount == 2) {
                    translateX -= distanceX
                    translateY -= distanceY
                    updateTransformMatrix()
                    return true
                }
                return false
            }
        }
    )
    
    private fun updateTransformMatrix() {
        transformMatrix.reset()
        transformMatrix.postTranslate(translateX, translateY)
        transformMatrix.postScale(scaleFactor, scaleFactor, width / 2f, height / 2f)
        invalidate()
    }
    
    override fun onTouchEvent(event: MotionEvent): Boolean {
        scaleDetector.onTouchEvent(event)
        gestureDetector.onTouchEvent(event)
        
        // 1本指のタッチは描画
        if (event.pointerCount == 1 && !scaleDetector.isInProgress) {
            when (event.action) {
                MotionEvent.ACTION_DOWN,
                MotionEvent.ACTION_MOVE -> {
                    drawPixelAt(event.x, event.y)
                }
            }
        }
        
        return true
    }
}

ポイント:描画とジェスチャーの分離

1本指は描画、2本指はズーム・パンと分けることで、直感的な操作を実現しています。

5. 描画ツールの実装

ペンだけでなく、消しゴムや塗りつぶしも実装してみましょう。

enum class DrawTool {
    PEN,
    ERASER,
    FILL
}

class PixelEditorView : View {
    
    var currentTool = DrawTool.PEN
    
    private fun drawPixelAt(touchX: Float, touchY: Float) {
        val (pixelX, pixelY) = touchToPixelCoord(touchX, touchY)
        
        when (currentTool) {
            DrawTool.PEN -> {
                pixelCanvas.setPixel(pixelX, pixelY, currentColor)
            }
            DrawTool.ERASER -> {
                pixelCanvas.setPixel(pixelX, pixelY, Color.TRANSPARENT)
            }
            DrawTool.FILL -> {
                floodFill(pixelX, pixelY, currentColor)
            }
        }
        
        invalidate()
    }
    
    // 塗りつぶし(Flood Fill)アルゴリズム
    private fun floodFill(startX: Int, startY: Int, newColor: Int) {
        val targetColor = pixelCanvas.getPixel(startX, startY)
        if (targetColor == newColor) return
        
        val queue = ArrayDeque<Pair<Int, Int>>()
        queue.add(Pair(startX, startY))
        
        while (queue.isNotEmpty()) {
            val (x, y) = queue.removeFirst()
            
            if (x !in 0 until pixelCanvas.width ||
                y !in 0 until pixelCanvas.height) continue
            
            if (pixelCanvas.getPixel(x, y) != targetColor) continue
            
            pixelCanvas.setPixel(x, y, newColor)
            
            queue.add(Pair(x + 1, y))
            queue.add(Pair(x - 1, y))
            queue.add(Pair(x, y + 1))
            queue.add(Pair(x, y - 1))
        }
    }
}

塗りつぶしの注意点

再帰で実装するとスタックオーバーフローになりやすいので、キューを使った幅優先探索で実装しています。

6. PNG形式で保存

作成したドット絵を画像ファイルとして保存します。

fun PixelCanvas.toBitmap(scale: Int = 1): Bitmap {
    val bitmap = Bitmap.createBitmap(
        width * scale,
        height * scale,
        Bitmap.Config.ARGB_8888
    )
    
    val canvas = Canvas(bitmap)
    val paint = Paint()
    
    for (y in 0 until height) {
        for (x in 0 until width) {
            val color = getPixel(x, y)
            if (color != Color.TRANSPARENT) {
                paint.color = color
                canvas.drawRect(
                    (x * scale).toFloat(),
                    (y * scale).toFloat(),
                    ((x + 1) * scale).toFloat(),
                    ((y + 1) * scale).toFloat(),
                    paint
                )
            }
        }
    }
    
    return bitmap
}

// 保存処理
fun saveBitmap(context: Context, bitmap: Bitmap, filename: String) {
    val contentValues = ContentValues().apply {
        put(MediaStore.Images.Media.DISPLAY_NAME, "$filename.png")
        put(MediaStore.Images.Media.MIME_TYPE, "image/png")
        put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES)
    }
    
    val uri = context.contentResolver.insert(
        MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
        contentValues
    )
    
    uri?.let {
        context.contentResolver.openOutputStream(it)?.use { stream ->
            bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)
        }
    }
}

scaleパラメータについて

32×32ピクセルのドット絵をそのまま保存すると小さすぎます。scaleパラメータで拡大保存できるようにしています。例えばscale=10なら320×320ピクセルの画像になります。

7. さらに発展させるには

基本的なドット絵エディタができたら、以下の機能を追加するとより実用的になります:

機能難易度ポイント
アンドゥ・リドゥ★★☆操作履歴をスタックで管理
レイヤー機能★★★複数のPixelCanvasを重ねて描画
アニメーション★★★フレームごとにキャンバスを切り替え
カラーパレット★☆☆色の配列をRecyclerViewで表示
グリッドの色変更★☆☆ユーザー設定で切り替え

まとめ

この記事では、Androidでドット絵エディタを実装する基本的な方法を解説しました。

ポイントをまとめると:

  1. ピクセルデータは2次元配列で管理 → 拡張性が高い
  2. Matrixで座標変換 → ズーム・パンに対応
  3. 1本指と2本指でジェスチャーを分離 → 直感的なUX
  4. 塗りつぶしは幅優先探索で → スタックオーバーフロー回避

実際に私が開発した「Dottie」では、これらの基本機能に加えて、レイヤー、アニメーション、スプライトシート出力などの機能を実装しています。

ドット絵制作に興味がある方は、ぜひ試してみてください。

Dottie – Google Playでダウンロード


質問やフィードバックがあれば、お気軽にどうぞ!

タイトルとURLをコピーしました