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:
@@ -6,6 +6,7 @@ val gdxVersion: String by project
|
||||
|
||||
dependencies {
|
||||
api("com.badlogicgames.gdx:gdx:$gdxVersion")
|
||||
implementation(project(":shared"))
|
||||
}
|
||||
|
||||
kotlin {
|
||||
|
||||
27
core/src/main/kotlin/com/artlegend/GameHost.kt
Normal file
27
core/src/main/kotlin/com/artlegend/GameHost.kt
Normal 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)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
11
core/src/main/kotlin/com/artlegend/input/StylusFrame.kt
Normal file
11
core/src/main/kotlin/com/artlegend/input/StylusFrame.kt
Normal 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
|
||||
)
|
||||
@@ -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()
|
||||
}
|
||||
203
core/src/main/kotlin/com/artlegend/modules/LineCorridorScreen.kt
Normal file
203
core/src/main/kotlin/com/artlegend/modules/LineCorridorScreen.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
7
core/src/main/kotlin/com/artlegend/modules/LineScore.kt
Normal file
7
core/src/main/kotlin/com/artlegend/modules/LineScore.kt
Normal 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
|
||||
)
|
||||
Reference in New Issue
Block a user