Sprint 1 complete: hybrid Compose + LibGDX architecture building

Full AL-001 through AL-008 implementation: shared/ module with ModuleType
enum, GameHost routing, StylusInputBridge, LineCorridorScreen with live
stroke rendering, LineScore (straightness + anglePrecision), score returned
to Compose hub via ActivityResult. Fixes LineScore field mismatch
(pressureConsistency → anglePrecision) caught during AL-009 build check.
Adds .gitignore and handoff notes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-08 19:21:48 -06:00
parent 24d78786db
commit ac81ec51f4
39 changed files with 1797 additions and 41 deletions

View File

@@ -6,6 +6,7 @@ val gdxVersion: String by project
dependencies {
api("com.badlogicgames.gdx:gdx:$gdxVersion")
implementation(project(":shared"))
}
kotlin {

View File

@@ -0,0 +1,27 @@
package com.artlegend
import com.artlegend.input.StylusInputBridge
import com.artlegend.modules.LineCorridorScreen
import com.artlegend.modules.LineScore
import com.artlegend.shared.ModuleType
import com.badlogic.gdx.Game
class GameHost(
private val moduleType: ModuleType,
val bridge: StylusInputBridge = StylusInputBridge()
) : Game() {
/** Set by GameActivity before the first frame. Called on the GL thread. */
var onSessionComplete: ((LineScore) -> Unit)? = null
override fun create() {
setScreen(
when (moduleType) {
ModuleType.LINE_CORRIDOR -> LineCorridorScreen(bridge) { score ->
onSessionComplete?.invoke(score)
}
else -> PlaceholderModuleScreen(moduleType)
}
)
}
}

View File

@@ -1,17 +1,11 @@
package com.artlegend
import com.badlogic.gdx.Game
import com.artlegend.shared.ModuleType
import com.badlogic.gdx.Gdx
import com.badlogic.gdx.Screen
import com.badlogic.gdx.graphics.GL20
class ArtLegend : Game() {
override fun create() {
setScreen(PlaceholderScreen())
}
}
class PlaceholderScreen : Screen {
class PlaceholderModuleScreen(private val moduleType: ModuleType) : Screen {
override fun show() {}
override fun render(delta: Float) {

View File

@@ -0,0 +1,11 @@
package com.artlegend.input
data class StylusFrame(
val x: Float,
val y: Float,
val pressure: Float,
val tiltRad: Float,
val orientRad: Float,
val isStylus: Boolean,
val isDown: Boolean
)

View File

@@ -0,0 +1,14 @@
package com.artlegend.input
import java.util.concurrent.atomic.AtomicReference
class StylusInputBridge {
private val ref = AtomicReference<StylusFrame?>(null)
fun post(frame: StylusFrame) {
ref.set(frame)
}
val latestFrame: StylusFrame?
get() = ref.get()
}

View File

@@ -0,0 +1,203 @@
package com.artlegend.modules
import com.artlegend.input.StylusInputBridge
import com.badlogic.gdx.Gdx
import com.badlogic.gdx.Screen
import com.badlogic.gdx.graphics.Color
import com.badlogic.gdx.graphics.GL20
import com.badlogic.gdx.graphics.OrthographicCamera
import com.badlogic.gdx.graphics.glutils.ShapeRenderer
import kotlin.math.abs
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.sin
import kotlin.math.sqrt
import kotlin.random.Random
class LineCorridorScreen(
private val bridge: StylusInputBridge,
private val onSessionComplete: (LineScore) -> Unit
) : Screen {
private lateinit var camera: OrthographicCamera
private lateinit var shapeRenderer: ShapeRenderer
// Corridor geometry (LibGDX coords: origin bottom-left)
private var corridorAngle = 0f // radians
private var startX = 0f; private var startY = 0f
private var endX = 0f; private var endY = 0f
private val halfWidth = 55f // pixels — tight enough to be challenging
// Stroke
private data class Pt(val x: Float, val y: Float, val pressure: Float)
private val stroke = mutableListOf<Pt>()
private var isDrawing = false
private var sessionDone = false
// Previous frame's isDown to detect edge transitions
private var prevDown = false
override fun show() {
camera = OrthographicCamera()
shapeRenderer = ShapeRenderer()
regenerate()
}
private fun regenerate() {
val w = Gdx.graphics.width.toFloat()
val h = Gdx.graphics.height.toFloat()
camera.setToOrtho(false, w, h)
val deg = (Random.nextFloat() * 50f - 25f) // -25° to +25°
corridorAngle = deg * (Math.PI / 180.0).toFloat()
val cx = w / 2f
val cy = h / 2f
val halfLen = w * 0.40f
val cos = cos(corridorAngle)
val sin = sin(corridorAngle)
startX = cx - halfLen * cos
startY = cy - halfLen * sin
endX = cx + halfLen * cos
endY = cy + halfLen * sin
}
override fun render(delta: Float) {
Gdx.gl.glClearColor(0.05f, 0.05f, 0.08f, 1f)
Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT)
processInput()
camera.update()
shapeRenderer.projectionMatrix = camera.combined
shapeRenderer.begin(ShapeRenderer.ShapeType.Line)
drawCorridor()
drawStroke()
shapeRenderer.end()
}
private fun processInput() {
val frame = bridge.latestFrame ?: return
if (sessionDone) return
// Flip Android Y (top-left origin) to LibGDX Y (bottom-left origin)
val gdxY = Gdx.graphics.height - frame.y
val down = frame.isDown
// DOWN edge: start capturing
if (down && !prevDown) {
isDrawing = true
stroke.clear()
}
if (isDrawing && down) {
stroke.add(Pt(frame.x, gdxY, frame.pressure))
}
// UP edge: score and finish
if (!down && prevDown && isDrawing) {
isDrawing = false
if (stroke.size > 1) {
sessionDone = true
val score = computeScore()
Gdx.app.log("LineCorridor", "straightness=%.2f angle=%.2f overall=%.2f"
.format(score.straightness, score.anglePrecision, score.overall))
onSessionComplete(score)
}
}
prevDown = down
}
private fun drawCorridor() {
val perpX = -sin(corridorAngle)
val perpY = cos(corridorAngle)
val ox = perpX * halfWidth
val oy = perpY * halfWidth
// Corridor walls
shapeRenderer.color = Color(0f, 0.85f, 1f, 0.75f)
shapeRenderer.line(startX + ox, startY + oy, endX + ox, endY + oy)
shapeRenderer.line(startX - ox, startY - oy, endX - ox, endY - oy)
// End caps
shapeRenderer.line(startX + ox, startY + oy, startX - ox, startY - oy)
shapeRenderer.line(endX + ox, endY + oy, endX - ox, endY - oy)
// Dashed centerline
shapeRenderer.color = Color(0f, 0.85f, 1f, 0.25f)
val dx = endX - startX
val dy = endY - startY
val len = sqrt(dx * dx + dy * dy)
val dashCount = (len / 24f).toInt()
for (i in 0 until dashCount) {
if (i % 2 == 1) continue // every other segment is a gap
val t0 = i / dashCount.toFloat()
val t1 = (i + 1) / dashCount.toFloat()
shapeRenderer.line(
startX + dx * t0, startY + dy * t0,
startX + dx * t1, startY + dy * t1
)
}
}
private fun drawStroke() {
if (stroke.size < 2) return
for (i in 0 until stroke.size - 1) {
val a = stroke[i]
val b = stroke[i + 1]
val alpha = a.pressure.coerceIn(0.15f, 1f)
shapeRenderer.color = Color(1f, 0.85f, 0.25f, alpha)
shapeRenderer.line(a.x, a.y, b.x, b.y)
}
}
private fun computeScore(): LineScore {
val dx = endX - startX
val dy = endY - startY
val lenSq = dx * dx + dy * dy
var sumSqDev = 0.0
for (pt in stroke) {
val t = ((pt.x - startX) * dx + (pt.y - startY) * dy) / lenSq
val cx = startX + t * dx
val cy = startY + t * dy
val distX = pt.x - cx
val distY = pt.y - cy
sumSqDev += distX * distX + distY * distY
}
val rms = sqrt(sumSqDev / stroke.size).toFloat()
val straightness = (1f - (rms / halfWidth)).coerceIn(0f, 1f)
// Angle of drawn stroke (first → last point), scored against corridorAngle.
// ±15° tolerance: 0° diff → 1.0, 15° diff → 0.0.
val first = stroke.first()
val last = stroke.last()
val strokeAngle = atan2((last.y - first.y).toDouble(), (last.x - first.x).toDouble()).toFloat()
var angleDiff = abs(strokeAngle - corridorAngle)
// Clamp to [0, π/2] to handle π-equivalent opposite directions
if (angleDiff > Math.PI.toFloat() / 2f) angleDiff = Math.PI.toFloat() - angleDiff
val maxAngleDiff = (15f * (Math.PI / 180.0)).toFloat()
val anglePrecision = (1f - (angleDiff / maxAngleDiff)).coerceIn(0f, 1f)
val overall = straightness * 0.6f + anglePrecision * 0.4f
return LineScore(straightness, anglePrecision, overall)
}
override fun resize(width: Int, height: Int) {
if (!sessionDone) regenerate()
}
override fun pause() {}
override fun resume() {}
override fun hide() {}
override fun dispose() {
shapeRenderer.dispose()
}
}

View File

@@ -0,0 +1,7 @@
package com.artlegend.modules
data class LineScore(
val straightness: Float, // 0..1, 1 = perfectly on centerline
val anglePrecision: Float, // 0..1, 1 = stroke angle matches corridor exactly
val overall: Float // weighted combination
)