Add playable corridor scroll desktop prototype

This commit is contained in:
2026-06-30 11:36:23 -06:00
parent 2b208aff7e
commit 3ed24cf8be
4 changed files with 289 additions and 104 deletions

View File

@@ -3,16 +3,16 @@ package com.artlegend.modules
import com.artlegend.input.StylusInputBridge
import com.badlogic.gdx.Gdx
import com.badlogic.gdx.Screen
import com.badlogic.gdx.Input
import com.badlogic.gdx.graphics.Color
import com.badlogic.gdx.graphics.GL20
import com.badlogic.gdx.graphics.OrthographicCamera
import com.badlogic.gdx.graphics.g2d.BitmapFont
import com.badlogic.gdx.graphics.g2d.SpriteBatch
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
import kotlin.math.cos
class LineCorridorScreen(
private val bridge: StylusInputBridge,
@@ -21,90 +21,158 @@ class LineCorridorScreen(
private lateinit var camera: OrthographicCamera
private lateinit var shapeRenderer: ShapeRenderer
private lateinit var spriteBatch: SpriteBatch
private lateinit var font: BitmapFont
// 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
// Level settings
private val sessionDuration = 15f // 15 seconds session
private val speed = 100f // Slightly faster (100 pixels per second)
private val baseHalfWidth = 65f // base half-width of corridor
// Stroke
private data class Pt(val x: Float, val y: Float, val pressure: Float)
private val stroke = mutableListOf<Pt>()
private var isDrawing = false
// State
private var time = 0f
private var sessionDone = false
// Previous frame's isDown to detect edge transitions
private var isDrawing = false
private var prevDown = false
private data class Pt(val x: Float, val y: Float, val t: Float, val pressure: Float)
private val strokes = mutableListOf<MutableList<Pt>>()
private var currentStroke = mutableListOf<Pt>()
override fun show() {
camera = OrthographicCamera()
shapeRenderer = ShapeRenderer()
regenerate()
spriteBatch = SpriteBatch()
font = BitmapFont()
font.data.setScale(1.5f) // Make text highly readable
resetSession()
}
private fun regenerate() {
private fun resetSession() {
time = 0f
sessionDone = false
isDrawing = false
prevDown = false
strokes.clear()
currentStroke = mutableListOf()
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()
private fun getCorridorCenter(y: Float, t: Float): Float {
val w = Gdx.graphics.width.toFloat()
val centerX = w / 2f
val u = y - speed * t
// Composite waves: slow drift + faster wiggles
val kDrift = (2 * Math.PI / 2400f).toFloat() // wavelength 2400
val kWiggle = (2 * Math.PI / 600f).toFloat() // wavelength 600
// Drifts up to 220px from center, wiggles up to 90px around the drift path
return centerX + 220f * sin(kDrift * u) + 90f * cos(kWiggle * u)
}
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
private fun getHalfWidth(y: Float, t: Float): Float {
val u = y - speed * t
val widthWavelength = 1200f // width changes slower than the wiggles
val kWidth = (2 * Math.PI / widthWavelength).toFloat()
// factor oscillates between 0.5 (half as wide) and 2.0 (2x as wide)
val factor = 1.25f + 0.75f * sin(kWidth * u)
return baseHalfWidth * factor
}
override fun render(delta: Float) {
Gdx.gl.glClearColor(0.05f, 0.05f, 0.08f, 1f)
Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT)
processInput()
if (!sessionDone) {
time += delta
processInput()
if (time >= sessionDuration) {
sessionDone = true
isDrawing = false
if (currentStroke.size > 1) {
strokes.add(currentStroke)
}
val finalScore = computeScore()
onSessionComplete(finalScore)
}
} else {
// Handle restart on spacebar press or click
if (Gdx.input.isKeyJustPressed(Input.Keys.SPACE) || (Gdx.input.justTouched() && !isDrawing)) {
resetSession()
}
}
camera.update()
shapeRenderer.projectionMatrix = camera.combined
// Draw corridor and path
shapeRenderer.begin(ShapeRenderer.ShapeType.Line)
drawCorridor()
drawStroke()
shapeRenderer.end()
// Draw UI Text Overlay
spriteBatch.projectionMatrix = camera.combined
spriteBatch.begin()
if (!sessionDone) {
val remaining = (sessionDuration - time).coerceAtLeast(0f)
font.draw(spriteBatch, "Time Remaining: %.1fs".format(remaining), 20f, Gdx.graphics.height - 20f)
font.draw(spriteBatch, "Wiggle back-and-forth to fill the space!", 20f, Gdx.graphics.height - 50f)
} else {
val score = computeScore()
val cx = Gdx.graphics.width / 2f
val cy = Gdx.graphics.height / 2f
font.draw(spriteBatch, "SESSION COMPLETE!", cx - 120f, cy + 120f)
font.draw(spriteBatch, "Containment Score: %.0f%%".format(score.straightness * 100f), cx - 150f, cy + 60f)
font.draw(spriteBatch, "Fill Score: %.0f%%".format(score.anglePrecision * 100f), cx - 150f, cy + 20f)
font.draw(spriteBatch, "Overall Score: %.0f%%".format(score.overall * 100f), cx - 150f, cy - 20f)
font.draw(spriteBatch, "Press SPACE or TAP to play again", cx - 180f, cy - 100f)
}
spriteBatch.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
val frame = bridge.latestFrame
val down: Boolean
val screenX: Float
val screenY: Float
val pressure: Float
// DOWN edge: start capturing
if (frame != null) {
down = frame.isDown
screenX = frame.x
screenY = Gdx.graphics.height - frame.y
pressure = frame.pressure
} else {
// Desktop fallback: route mouse coordinates to drawing
down = Gdx.input.isTouched
screenX = Gdx.input.x.toFloat()
screenY = (Gdx.graphics.height - Gdx.input.y).toFloat()
pressure = 1f
}
// DOWN edge
if (down && !prevDown) {
isDrawing = true
stroke.clear()
currentStroke = mutableListOf()
}
if (isDrawing && down) {
stroke.add(Pt(frame.x, gdxY, frame.pressure))
currentStroke.add(Pt(screenX, screenY, time, pressure))
}
// UP edge: score and finish
// UP edge
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)
if (currentStroke.size > 1) {
strokes.add(currentStroke)
}
}
@@ -112,85 +180,153 @@ class LineCorridorScreen(
}
private fun drawCorridor() {
val perpX = -sin(corridorAngle)
val perpY = cos(corridorAngle)
val ox = perpX * halfWidth
val oy = perpY * halfWidth
// Corridor walls
val h = Gdx.graphics.height.toFloat()
// Draw scrolling 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)
val step = 10f
var prevLeftX = getCorridorCenter(0f, time) - getHalfWidth(0f, time)
var prevRightX = getCorridorCenter(0f, time) + getHalfWidth(0f, time)
var prevY = 0f
// End caps
shapeRenderer.line(startX + ox, startY + oy, startX - ox, startY - oy)
shapeRenderer.line(endX + ox, endY + oy, endX - ox, endY - oy)
var y = step
while (y <= h) {
val centerX = getCorridorCenter(y, time)
val hw = getHalfWidth(y, time)
val leftX = centerX - hw
val rightX = centerX + hw
// Dashed centerline
shapeRenderer.line(prevLeftX, prevY, leftX, y)
shapeRenderer.line(prevRightX, prevY, rightX, y)
prevLeftX = leftX
prevRightX = rightX
prevY = y
y += step
}
// Draw dotted 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
)
prevLeftX = getCorridorCenter(0f, time)
prevY = 0f
y = step
var dashIndex = 0
while (y <= h) {
val centerX = getCorridorCenter(y, time)
if (dashIndex % 2 == 0) {
shapeRenderer.line(prevLeftX, prevY, centerX, y)
}
prevLeftX = centerX
prevY = y
y += step
dashIndex++
}
}
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)
// Render past completed strokes
for (stroke in strokes) {
if (stroke.size < 2) continue
for (i in 0 until stroke.size - 1) {
val a = stroke[i]
val b = stroke[i + 1]
val ay = a.y + speed * (time - a.t)
val by = b.y + speed * (time - b.t)
if (ay < Gdx.graphics.height || by < Gdx.graphics.height) {
val alpha = a.pressure.coerceIn(0.15f, 1f)
shapeRenderer.color = Color(1f, 0.85f, 0.25f, alpha)
shapeRenderer.line(a.x, ay, b.x, by)
}
}
}
// Render current active stroke
if (isDrawing && currentStroke.size >= 2) {
for (i in 0 until currentStroke.size - 1) {
val a = currentStroke[i]
val b = currentStroke[i + 1]
val ay = a.y + speed * (time - a.t)
val by = b.y + speed * (time - b.t)
if (ay < Gdx.graphics.height || by < Gdx.graphics.height) {
val alpha = a.pressure.coerceIn(0.15f, 1f)
shapeRenderer.color = Color(1f, 0.85f, 0.25f, alpha)
shapeRenderer.line(a.x, ay, b.x, by)
}
}
}
}
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 allPoints = mutableListOf<Pt>()
for (s in strokes) {
allPoints.addAll(s)
}
if (isDrawing) {
allPoints.addAll(currentStroke)
}
val rms = sqrt(sumSqDev / stroke.size).toFloat()
val straightness = (1f - (rms / halfWidth)).coerceIn(0f, 1f)
if (allPoints.isEmpty()) {
return LineScore(0f, 0f, 0f)
}
// 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)
// 1. Containment Score (percentage of points drawn inside the corridor)
var insideCount = 0
for (pt in allPoints) {
val xc = getCorridorCenter(pt.y, pt.t)
val hw = getHalfWidth(pt.y, pt.t)
val dist = abs(pt.x - xc)
if (dist <= hw) {
insideCount++
}
}
val containment = insideCount.toFloat() / allPoints.size
val overall = straightness * 0.6f + anglePrecision * 0.4f
// 2. Fill Score (evaluate local horizontal coverage inside bins)
val binSize = 30f
val uValues = allPoints.map { it.y - speed * it.t }
val minU = uValues.minOrNull() ?: 0f
val maxU = uValues.maxOrNull() ?: 0f
val spanU = maxU - minU
return LineScore(straightness, anglePrecision, overall)
val binMap = mutableMapOf<Int, MutableList<Float>>()
for (pt in allPoints) {
val u = pt.y - speed * pt.t
val binIndex = (u / binSize).toInt()
val xc = getCorridorCenter(pt.y, pt.t)
val localX = pt.x - xc
binMap.getOrPut(binIndex) { mutableListOf() }.add(localX)
}
var totalBinScore = 0f
var scoredBins = 0
for ((binIndex, wList) in binMap) {
val u = binIndex * binSize
val hw = getHalfWidth(u, 0f)
val maxW = wList.maxOrNull() ?: 0f
val minW = wList.minOrNull() ?: 0f
val spread = (maxW - minW)
val binFill = (spread / (2 * hw)).coerceAtMost(1f)
totalBinScore += binFill
scoredBins++
}
var fill = if (scoredBins > 0) totalBinScore / scoredBins else 0f
// Scale down if player drew over a very short section of the corridor
val lengthScale = (spanU / 300f).coerceIn(0f, 1f)
fill *= lengthScale
val overall = containment * 0.5f + fill * 0.5f
return LineScore(containment, fill, overall)
}
override fun resize(width: Int, height: Int) {
if (!sessionDone) regenerate()
camera.setToOrtho(false, width.toFloat(), height.toFloat())
}
override fun pause() {}
@@ -199,5 +335,7 @@ class LineCorridorScreen(
override fun dispose() {
shapeRenderer.dispose()
spriteBatch.dispose()
font.dispose()
}
}