diff --git a/SchwertUndMagieOnPebbleCompanionApp/app/src/main/AndroidManifest.xml b/SchwertUndMagieOnPebbleCompanionApp/app/src/main/AndroidManifest.xml index d741679..9eff0cf 100644 --- a/SchwertUndMagieOnPebbleCompanionApp/app/src/main/AndroidManifest.xml +++ b/SchwertUndMagieOnPebbleCompanionApp/app/src/main/AndroidManifest.xml @@ -15,7 +15,8 @@ android:theme="@style/Theme.SchwertUndMagieOnPebbleCompanionApp"> + android:exported="true" + android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"> diff --git a/SchwertUndMagieOnPebbleCompanionApp/app/src/main/java/de/ladkau/schwertundmagieonpebblecompanionapp/C64DisplayView.kt b/SchwertUndMagieOnPebbleCompanionApp/app/src/main/java/de/ladkau/schwertundmagieonpebblecompanionapp/C64DisplayView.kt index f97403a..c939698 100644 --- a/SchwertUndMagieOnPebbleCompanionApp/app/src/main/java/de/ladkau/schwertundmagieonpebblecompanionapp/C64DisplayView.kt +++ b/SchwertUndMagieOnPebbleCompanionApp/app/src/main/java/de/ladkau/schwertundmagieonpebblecompanionapp/C64DisplayView.kt @@ -5,57 +5,63 @@ import android.graphics.Bitmap import android.graphics.Canvas import android.graphics.Rect import android.util.AttributeSet -import android.view.SurfaceHolder -import android.view.SurfaceView +import android.view.View import java.nio.ByteBuffer /** - * Renders the VICE 320×200 ARGB framebuffer into a SurfaceView, - * scaled to fill the view while keeping the 8:5 aspect ratio. + * Renders the VICE 320×200 ARGB framebuffer into a View. + * + * Frame data is written from the emulator thread via captureFrame(), then the + * view is invalidated so onDraw() runs on the main thread under Choreographer + * vsync. All canvas operations stay on the main thread, giving smooth rendering + * synchronized with the hardware display refresh rate. */ class C64DisplayView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null -) : SurfaceView(context, attrs), SurfaceHolder.Callback { +) : View(context, attrs) { private val bitmap = Bitmap.createBitmap(320, 200, Bitmap.Config.ARGB_8888) - private val pixelBuf: ByteBuffer = ByteBuffer.allocateDirect(320 * 200 * 4) + + /* Two ByteBuffers: the emulator thread writes to backBuf, then atomically + * swaps it to frontBuf. onDraw() reads frontBuf without holding the lock. */ + private val buf0: ByteBuffer = ByteBuffer.allocateDirect(320 * 200 * 4) + private val buf1: ByteBuffer = ByteBuffer.allocateDirect(320 * 200 * 4) + @Volatile private var frontBuf: ByteBuffer = buf0 + private var backBuf: ByteBuffer = buf1 + private val srcRect = Rect(0, 0, 320, 200) private var dstRect = Rect() init { - holder.addCallback(this) + setBackgroundColor(android.graphics.Color.BLACK) + setLayerType(LAYER_TYPE_HARDWARE, null) } - /** Called from the emulator thread after every runFrame(). */ - fun updateFrame(engine: C64Engine) { - if (!holder.surface.isValid) return - - pixelBuf.rewind() - engine.getVideoBuffer(pixelBuf) - pixelBuf.rewind() - bitmap.copyPixelsFromBuffer(pixelBuf) - - val canvas: Canvas = try { holder.lockCanvas() ?: return } catch (_: Exception) { return } - try { - canvas.drawBitmap(bitmap, srcRect, dstRect, null) - } finally { - holder.unlockCanvasAndPost(canvas) - } + /** Called from the emulator thread — copies one C64 frame into the back buffer. */ + fun captureFrame(engine: C64Engine) { + backBuf.rewind() + engine.getVideoBuffer(backBuf) + /* Swap: make the freshly-written buffer visible to onDraw() */ + val tmp = frontBuf + frontBuf = backBuf + backBuf = tmp } - override fun surfaceCreated(h: SurfaceHolder) = recalcDst() - override fun surfaceChanged(h: SurfaceHolder, f: Int, w: Int, h2: Int) = recalcDst() - override fun surfaceDestroyed(h: SurfaceHolder) {} + override fun onDraw(canvas: Canvas) { + val buf = frontBuf + buf.rewind() + bitmap.copyPixelsFromBuffer(buf) + canvas.drawBitmap(bitmap, srcRect, dstRect, null) + } - private fun recalcDst() { - val vw = width.toFloat() - val vh = height.toFloat() - val scale = minOf(vw / 320f, vh / 200f) + override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { + if (w <= 0 || h <= 0) return + val scale = minOf(w.toFloat() / 320f, h.toFloat() / 200f) val sw = (320 * scale).toInt() val sh = (200 * scale).toInt() - val ox = ((vw - sw) / 2).toInt() - val oy = ((vh - sh) / 2).toInt() + val ox = ((w - sw) / 2).toInt() + val oy = ((h - sh) / 2).toInt() dstRect = Rect(ox, oy, ox + sw, oy + sh) } } diff --git a/SchwertUndMagieOnPebbleCompanionApp/app/src/main/java/de/ladkau/schwertundmagieonpebblecompanionapp/C64Engine.kt b/SchwertUndMagieOnPebbleCompanionApp/app/src/main/java/de/ladkau/schwertundmagieonpebblecompanionapp/C64Engine.kt index 611a7dd..77f7d74 100644 --- a/SchwertUndMagieOnPebbleCompanionApp/app/src/main/java/de/ladkau/schwertundmagieonpebblecompanionapp/C64Engine.kt +++ b/SchwertUndMagieOnPebbleCompanionApp/app/src/main/java/de/ladkau/schwertundmagieonpebblecompanionapp/C64Engine.kt @@ -19,9 +19,12 @@ class C64Engine { /** Initialize VICE. romDir should contain kernal, basic, chargen. */ external fun initEmulator(romDir: String): Boolean - /** Mount a .d64/.t64 disk image to drive 8. */ + /** Reset the machine and autostart from this disk image (use for A-side episode disks). */ external fun loadDisk(path: String): Boolean + /** Hot-swap disk in drive 8 without resetting (use for B-side / hero disks). */ + external fun attachDisk(path: String): Boolean + /** Run the emulator for one video frame (~20 000 CPU cycles). */ external fun runFrame() @@ -40,6 +43,12 @@ class C64Engine { /** Enable or disable audio output. Disabled by default. */ external fun setSoundEnabled(enabled: Boolean) + /** Returns true if drive 8's LED is currently on (disk read/write in progress). */ + external fun getDriveLed(): Boolean + + /** Monotonically increasing frame counter — incremented once per rendered C64 frame. */ + external fun getFrameCount(): Int + companion object { init { System.loadLibrary("vice_jni") } @@ -63,14 +72,45 @@ class C64Engine { const val KEY_8 = (3 shl 8) or 3 const val KEY_9 = (4 shl 8) or 0 const val KEY_0 = (4 shl 8) or 3 + // Letters const val KEY_Q = (7 shl 8) or 6 const val KEY_W = (1 shl 8) or 1 const val KEY_E = (1 shl 8) or 6 const val KEY_R = (2 shl 8) or 1 + const val KEY_T = (2 shl 8) or 6 const val KEY_Y = (3 shl 8) or 1 + const val KEY_U = (3 shl 8) or 6 + const val KEY_I = (4 shl 8) or 1 + const val KEY_O = (4 shl 8) or 6 + const val KEY_P = (5 shl 8) or 1 + const val KEY_A = (1 shl 8) or 2 + const val KEY_S = (1 shl 8) or 5 + const val KEY_D = (2 shl 8) or 2 + const val KEY_F = (2 shl 8) or 5 + const val KEY_G = (3 shl 8) or 2 + const val KEY_H = (3 shl 8) or 5 + const val KEY_J = (4 shl 8) or 2 + const val KEY_K = (4 shl 8) or 5 + const val KEY_L = (5 shl 8) or 2 + const val KEY_Z = (1 shl 8) or 4 + const val KEY_X = (2 shl 8) or 7 + const val KEY_C = (2 shl 8) or 4 + const val KEY_V = (3 shl 8) or 7 + const val KEY_B = (3 shl 8) or 4 const val KEY_N = (4 shl 8) or 7 + const val KEY_M = (4 shl 8) or 4 + // Control / special const val KEY_RETURN = (0 shl 8) or 1 const val KEY_SPACE = (7 shl 8) or 4 + const val KEY_DEL = (0 shl 8) or 0 + const val KEY_HOME = (6 shl 8) or 3 + const val KEY_LSHIFT = (1 shl 8) or 7 + const val KEY_RSHIFT = (6 shl 8) or 4 + const val KEY_CTRL = (7 shl 8) or 2 + const val KEY_CBM = (7 shl 8) or 5 + const val KEY_CUR_UD = (0 shl 8) or 7 // alone = down; SHIFT+this = up + const val KEY_CUR_LR = (0 shl 8) or 2 // alone = right; SHIFT+this = left + // Function const val KEY_F1 = (0 shl 8) or 4 const val KEY_F3 = (0 shl 8) or 5 const val KEY_F5 = (0 shl 8) or 6 diff --git a/SchwertUndMagieOnPebbleCompanionApp/app/src/main/java/de/ladkau/schwertundmagieonpebblecompanionapp/C64KeyboardView.kt b/SchwertUndMagieOnPebbleCompanionApp/app/src/main/java/de/ladkau/schwertundmagieonpebblecompanionapp/C64KeyboardView.kt index a8bfd44..dee4be0 100644 --- a/SchwertUndMagieOnPebbleCompanionApp/app/src/main/java/de/ladkau/schwertundmagieonpebblecompanionapp/C64KeyboardView.kt +++ b/SchwertUndMagieOnPebbleCompanionApp/app/src/main/java/de/ladkau/schwertundmagieonpebblecompanionapp/C64KeyboardView.kt @@ -9,15 +9,21 @@ import android.util.AttributeSet import android.view.MotionEvent import android.view.View -data class C64Key(val label: String, val code: Int, val widthWeight: Float = 1f) +/** + * A key can send one or more C64 matrix codes simultaneously. + * The two-code variant is used for shifted cursor keys (e.g. ↑ = LSHIFT + CUR_UD). + */ +data class C64Key( + val label: String, + val codes: List, + val widthWeight: Float = 1f +) { + constructor(label: String, code: Int, widthWeight: Float = 1f) + : this(label, listOf(code), widthWeight) +} -/** Callback fired when a key is pressed or released. */ typealias KeyEventListener = (key: C64Key, pressed: Boolean) -> Unit -/** - * Virtual C64 keyboard. Draws four rows of keys and fires [onKeyEvent] - * on touch down / up. - */ class C64KeyboardView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null @@ -26,30 +32,63 @@ class C64KeyboardView @JvmOverloads constructor( var onKeyEvent: KeyEventListener? = null private val rows: List> = listOf( + // Row 1 — function keys + DEL / HOME / RUN·STOP listOf( - C64Key("F1", C64Engine.KEY_F1), C64Key("F3", C64Engine.KEY_F3), - C64Key("F5", C64Engine.KEY_F5), C64Key("F7", C64Engine.KEY_F7), - C64Key("RUN\nSTOP", C64Engine.KEY_RUNSTOP, 1.5f) + C64Key("F1", C64Engine.KEY_F1), + C64Key("F3", C64Engine.KEY_F3), + C64Key("F5", C64Engine.KEY_F5), + C64Key("F7", C64Engine.KEY_F7), + C64Key("DEL", C64Engine.KEY_DEL), + C64Key("HOME", C64Engine.KEY_HOME), + C64Key("STOP", C64Engine.KEY_RUNSTOP, 1.5f), ), + // Row 2 — numbers listOf( C64Key("1", C64Engine.KEY_1), C64Key("2", C64Engine.KEY_2), C64Key("3", C64Engine.KEY_3), C64Key("4", C64Engine.KEY_4), C64Key("5", C64Engine.KEY_5), C64Key("6", C64Engine.KEY_6), C64Key("7", C64Engine.KEY_7), C64Key("8", C64Engine.KEY_8), - C64Key("9", C64Engine.KEY_9), C64Key("0", C64Engine.KEY_0) + C64Key("9", C64Engine.KEY_9), C64Key("0", C64Engine.KEY_0), ), + // Row 3 — Q … P listOf( C64Key("Q", C64Engine.KEY_Q), C64Key("W", C64Engine.KEY_W), C64Key("E", C64Engine.KEY_E), C64Key("R", C64Engine.KEY_R), - C64Key("Y", C64Engine.KEY_Y), C64Key("N", C64Engine.KEY_N), - C64Key("RETURN", C64Engine.KEY_RETURN, 2f) + C64Key("T", C64Engine.KEY_T), C64Key("Y", C64Engine.KEY_Y), + C64Key("U", C64Engine.KEY_U), C64Key("I", C64Engine.KEY_I), + C64Key("O", C64Engine.KEY_O), C64Key("P", C64Engine.KEY_P), ), + // Row 4 — A … L + RETURN listOf( - C64Key("SPACE", C64Engine.KEY_SPACE, 6f) - ) + C64Key("A", C64Engine.KEY_A), C64Key("S", C64Engine.KEY_S), + C64Key("D", C64Engine.KEY_D), C64Key("F", C64Engine.KEY_F), + C64Key("G", C64Engine.KEY_G), C64Key("H", C64Engine.KEY_H), + C64Key("J", C64Engine.KEY_J), C64Key("K", C64Engine.KEY_K), + C64Key("L", C64Engine.KEY_L), + C64Key("RET", C64Engine.KEY_RETURN, 1.5f), + ), + // Row 5 — SHIFT + Z … M + SHIFT + listOf( + C64Key("SHF", C64Engine.KEY_LSHIFT, 1.5f), + C64Key("Z", C64Engine.KEY_Z), C64Key("X", C64Engine.KEY_X), + C64Key("C", C64Engine.KEY_C), C64Key("V", C64Engine.KEY_V), + C64Key("B", C64Engine.KEY_B), C64Key("N", C64Engine.KEY_N), + C64Key("M", C64Engine.KEY_M), + C64Key("SHF", C64Engine.KEY_RSHIFT, 1.5f), + ), + // Row 6 — modifiers + SPACE + cursor keys + // ↑ and ← are composite: LSHIFT + the cursor key + listOf( + C64Key("CTRL", C64Engine.KEY_CTRL), + C64Key("C=", C64Engine.KEY_CBM), + C64Key("SPACE", C64Engine.KEY_SPACE, 3f), + C64Key("←", listOf(C64Engine.KEY_LSHIFT, C64Engine.KEY_CUR_LR)), + C64Key("→", C64Engine.KEY_CUR_LR), + C64Key("↑", listOf(C64Engine.KEY_LSHIFT, C64Engine.KEY_CUR_UD)), + C64Key("↓", C64Engine.KEY_CUR_UD), + ), ) - // Map pointer-id → key currently held down by that finger private val heldKeys = mutableMapOf() private val keyPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { @@ -63,7 +102,6 @@ class C64KeyboardView @JvmOverloads constructor( textAlign = Paint.Align.CENTER } - // Pre-computed key rectangles for hit-testing: rowIndex → list of (rect, key) private var keyRects: List>> = emptyList() override fun onSizeChanged(w: Int, h: Int, oldW: Int, oldH: Int) { @@ -73,13 +111,13 @@ class C64KeyboardView @JvmOverloads constructor( private fun buildKeyRects(totalW: Float, totalH: Float) { val rowH = totalH / rows.size - val pad = 4f + val pad = 3f keyRects = rows.mapIndexed { rowIdx, row -> val totalUnits = row.sumOf { it.widthWeight.toDouble() }.toFloat() val unitW = totalW / totalUnits var x = 0f row.map { key -> - val kw = key.widthWeight * unitW + val kw = key.widthWeight * unitW val rect = RectF(x + pad, rowIdx * rowH + pad, x + kw - pad, (rowIdx + 1) * rowH - pad) x += kw rect to key @@ -89,18 +127,27 @@ class C64KeyboardView @JvmOverloads constructor( override fun onDraw(canvas: Canvas) { val held = heldKeys.values.toSet() - textPaint.textSize = height / (rows.size * 2.5f) + // Scale text so it fits roughly in half the row height + textPaint.textSize = (height / rows.size) * 0.38f for (row in keyRects) { for ((rect, key) in row) { - val paint = if (key in held) pressedPaint else keyPaint - canvas.drawRoundRect(rect, 6f, 6f, paint) - val ty = rect.centerY() - (textPaint.ascent() + textPaint.descent()) / 2 - canvas.drawText(key.label, rect.centerX(), ty, textPaint) + canvas.drawRoundRect(rect, 5f, 5f, if (key in held) pressedPaint else keyPaint) + drawKeyLabel(canvas, rect, key.label) } } } + private fun drawKeyLabel(canvas: Canvas, rect: RectF, label: String) { + val lines = label.split("\n") + val lineH = textPaint.textSize * 1.15f + val totalTextH = lineH * lines.size + val startY = rect.centerY() - totalTextH / 2f + textPaint.textSize + lines.forEachIndexed { i, line -> + canvas.drawText(line, rect.centerX(), startY + i * lineH, textPaint) + } + } + override fun onTouchEvent(event: MotionEvent): Boolean { val idx = event.actionIndex val pid = event.getPointerId(idx) diff --git a/SchwertUndMagieOnPebbleCompanionApp/app/src/main/java/de/ladkau/schwertundmagieonpebblecompanionapp/MainActivity.kt b/SchwertUndMagieOnPebbleCompanionApp/app/src/main/java/de/ladkau/schwertundmagieonpebblecompanionapp/MainActivity.kt index ba0a39b..92eb505 100644 --- a/SchwertUndMagieOnPebbleCompanionApp/app/src/main/java/de/ladkau/schwertundmagieonpebblecompanionapp/MainActivity.kt +++ b/SchwertUndMagieOnPebbleCompanionApp/app/src/main/java/de/ladkau/schwertundmagieonpebblecompanionapp/MainActivity.kt @@ -8,7 +8,12 @@ import android.os.Handler import android.os.Looper import android.provider.OpenableColumns import android.util.Log +import android.content.res.ColorStateList +import android.graphics.Color +import android.graphics.drawable.GradientDrawable +import android.view.Choreographer import android.widget.Button +import android.widget.ImageView import android.widget.ScrollView import android.widget.TextView import android.widget.Toast @@ -39,8 +44,6 @@ private data class DiskSlot( class MainActivity : AppCompatActivity() { - private lateinit var tvStatus: TextView - private lateinit var tvTime: TextView private lateinit var tvLog: TextView private lateinit var scrollLog: ScrollView private lateinit var display: C64DisplayView @@ -53,9 +56,11 @@ class MainActivity : AppCompatActivity() { private var server: CompanionServer? = null private val engine = C64Engine() - private var emuThread: Thread? = null - @Volatile private var emuRunning = false + private lateinit var choreographer: Choreographer + @Volatile private var renderActive = false private var soundEnabled = false + private lateinit var driveLedDrawable: GradientDrawable + @Volatile private var driveLedState = false // ---- disk slots --------------------------------------------------------- @@ -74,6 +79,7 @@ class MainActivity : AppCompatActivity() { ) private val episodeButtons = mutableMapOf() private val loadedADisks = mutableSetOf() + private var activeSlot: DiskSlot? = null // ---- Disk import (multi-select) ----------------------------------------- @@ -105,6 +111,12 @@ class MainActivity : AppCompatActivity() { drawerLayout = findViewById(R.id.drawer_layout) + driveLedDrawable = GradientDrawable().apply { + shape = GradientDrawable.OVAL + setColor(Color.parseColor("#2A2A2A")) + } + findViewById(R.id.drive_led).background = driveLedDrawable + // On Android 15+ the app draws behind the status bar (edge-to-edge enforced). // Shift the main content down by the actual status bar height so the // hamburger button isn't hidden underneath it. @@ -115,8 +127,13 @@ class MainActivity : AppCompatActivity() { view.setPadding(pad8, bars.top + pad8, pad8, pad8) insets } - tvStatus = findViewById(R.id.tv_status) - tvTime = findViewById(R.id.tv_time) + val navDrawer = findViewById(R.id.nav_drawer) + ViewCompat.setOnApplyWindowInsetsListener(navDrawer) { view, insets -> + val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + val pad12 = (12 * resources.displayMetrics.density).toInt() + view.setPadding(pad12, bars.top + pad12, pad12, pad12) + insets + } tvLog = findViewById(R.id.tv_log) scrollLog = findViewById(R.id.scroll_log) display = findViewById(R.id.c64_display) @@ -155,10 +172,12 @@ class MainActivity : AppCompatActivity() { findViewById