dotzuki-cli 0.8.1

dotzuki CLI: scaffold (dotzuki new), compile-check (dotzuki check) and play (dotzuki run) zero-Rust JRPG projects
package com.dotzuki.player

import android.app.Activity
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.KeyEvent
import android.view.View
import android.view.WindowInsets
import android.view.WindowInsetsController
import android.widget.LinearLayout
import android.widget.Toast
import java.nio.charset.StandardCharsets

class MainActivity : Activity() {
    private lateinit var gameView: GameSurfaceView
    private val audio = DotzukiAudio()
    private val handler = Handler(Looper.getMainLooper())
    private var runtimeReady = false
    private var lastSave = ""
    private var touchBits = 0
    private var hardwareBits = 0
    private val savePoll = object : Runnable {
        override fun run() {
            persistSave()
            handler.postDelayed(this, SAVE_POLL_MS)
        }
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val preferences = getSharedPreferences(SAVE_STORE, MODE_PRIVATE)
        lastSave = preferences.getString(SAVE_KEY, "") ?: ""
        val pack = resources.openRawResource(R.raw.game).use { it.readBytes() }
        val save = lastSave.takeIf { it.isNotEmpty() }?.toByteArray(StandardCharsets.UTF_8)
        if (!NativeBridge.create(pack, save)) {
            Toast.makeText(this, "Game failed to start: ${NativeBridge.lastError()}", Toast.LENGTH_LONG).show()
            finish()
            return
        }
        runtimeReady = true

        gameView = GameSurfaceView(this)
        val gamepad = GamepadView(this).apply {
            onInputChanged = {
                touchBits = it
                updateInput()
            }
        }
        setContentView(LinearLayout(this).apply {
            orientation = LinearLayout.VERTICAL
            setBackgroundColor(android.graphics.Color.BLACK)
            addView(gameView, LinearLayout.LayoutParams(
                LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f,
            ))
            addView(gamepad, LinearLayout.LayoutParams(
                LinearLayout.LayoutParams.MATCH_PARENT,
                (260 * resources.displayMetrics.density).toInt(),
            ))
        })
        enterImmersiveMode()
        handler.postDelayed(savePoll, SAVE_POLL_MS)
    }

    override fun onWindowFocusChanged(hasFocus: Boolean) {
        super.onWindowFocusChanged(hasFocus)
        if (hasFocus) enterImmersiveMode()
    }

    override fun onResume() {
        super.onResume()
        if (!runtimeReady) return
        gameView.resumeFrames()
        audio.start()
    }

    override fun onPause() {
        if (runtimeReady) {
            gameView.pauseFrames()
            touchBits = 0
            hardwareBits = 0
            audio.stop()
            persistSave()
        }
        super.onPause()
    }

    override fun onDestroy() {
        handler.removeCallbacks(savePoll)
        if (runtimeReady) {
            audio.stop()
            persistSave()
            NativeBridge.destroy()
            runtimeReady = false
        }
        super.onDestroy()
    }

    override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
        val bit = inputBit(keyCode) ?: return super.onKeyDown(keyCode, event)
        hardwareBits = hardwareBits or (1 shl bit)
        updateInput()
        return true
    }

    override fun onKeyUp(keyCode: Int, event: KeyEvent): Boolean {
        val bit = inputBit(keyCode) ?: return super.onKeyUp(keyCode, event)
        hardwareBits = hardwareBits and (1 shl bit).inv()
        updateInput()
        return true
    }

    private fun updateInput() {
        if (runtimeReady) gameView.inputBits = touchBits or hardwareBits
    }

    private fun enterImmersiveMode() {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
            @Suppress("DEPRECATION")
            window.setDecorFitsSystemWindows(false)
            window.insetsController?.let {
                it.hide(WindowInsets.Type.systemBars())
                it.systemBarsBehavior = WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
            }
        } else {
            @Suppress("DEPRECATION")
            window.decorView.systemUiVisibility = (
                View.SYSTEM_UI_FLAG_FULLSCREEN or
                    View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or
                    View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
                )
        }
    }

    private fun persistSave() {
        if (!runtimeReady) return
        val bytes = NativeBridge.exportSave() ?: return
        val save = String(bytes, StandardCharsets.UTF_8)
        if (save.isEmpty() || save == lastSave) return
        getSharedPreferences(SAVE_STORE, MODE_PRIVATE)
            .edit()
            .putString(SAVE_KEY, save)
            .apply()
        lastSave = save
    }

    private companion object {
        const val SAVE_STORE = "dotzuki-game"
        const val SAVE_KEY = "save-v1"
        const val SAVE_POLL_MS = 500L

        fun inputBit(keyCode: Int): Int? = when (keyCode) {
            KeyEvent.KEYCODE_BUTTON_A, KeyEvent.KEYCODE_BUTTON_X, KeyEvent.KEYCODE_ENTER -> 0
            KeyEvent.KEYCODE_BUTTON_B, KeyEvent.KEYCODE_BUTTON_Y, KeyEvent.KEYCODE_ESCAPE -> 1
            KeyEvent.KEYCODE_BUTTON_SELECT -> 2
            KeyEvent.KEYCODE_BUTTON_START -> 3
            KeyEvent.KEYCODE_DPAD_RIGHT -> 4
            KeyEvent.KEYCODE_DPAD_LEFT -> 5
            KeyEvent.KEYCODE_DPAD_UP -> 6
            KeyEvent.KEYCODE_DPAD_DOWN -> 7
            else -> null
        }
    }
}