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

43
HANDOFF_2026-06-19.md Normal file
View File

@@ -0,0 +1,43 @@
# Handoff — June 19, 2026
## 📌 Summary of Progress
In this session, we successfully built and prototyped a fully playable MVP for the **Corridor Scroll** module using the desktop launcher.
1. **Environment & Build Fixes:**
- Installed `openjdk@17` via Homebrew and set up Java environment variables.
- Fixed desktop configuration in `desktop/build.gradle.kts` by adding the `:shared` dependency and passing `-XstartOnFirstThread` to resolve macOS GLFW thread restrictions.
- Fixed `DesktopLauncher.kt` to instantiate the new `GameHost(ModuleType.LINE_CORRIDOR)` setup.
2. **Gameplay Improvements (Corridor Scroll):**
- Transformed the static horizontal prototype into a vertical wiggling corridor scrolling from bottom to top.
- Slower scroll speed (`100f` px/s) to make drawing manageable.
- Implemented an organic, unpredictable centerline using composite waves (a slow left-to-right drift combined with faster wiggles).
- Designed a dynamic corridor width that swells up to 2x and pinches down to 0.5x of its base width as it scrolls.
3. **Inputs & Feedback Loop:**
- Added standard desktop mouse/touch drag fallback inside `LineCorridorScreen.kt` so the game is testable directly on macOS without an emulator.
- Built a 15-second timer per session.
- Programmed real-time scoring metrics:
- **Containment:** % of drawn points staying within the boundaries.
- **Fill:** Shading coverage between boundaries (calculated vertically in bins, normalized by the dynamic width of each bin).
- **Overall:** Weighted combination.
- Added end-game screens displaying scores and a restart prompt (SPACE/TAP to replay).
---
## 🛠️ Current State
* The desktop application compiles and runs successfully using:
```bash
export JAVA_HOME="$(brew --prefix openjdk@17)/libexec/openjdk.jdk/Contents/Home"
./gradlew :desktop:run
```
* Gameplay is interactive, responsive, and wiggles/pinches behave as expected.
---
## 📋 Next Steps & Feature Backlog
1. **Pretentious Art Critique Commentary ("The Gallery Critique"):**
- We drafted a feature proposal in `proposal_commentary.md`.
- Next session can implement `ArtArchetype` in the `:shared` module and load corresponding art snob quotes (e.g., from *Dr. Alistair Vance* or *Madame Genevieve*) depending on whether the player was too safe (high containment, low fill) or too messy (low containment, high fill).
2. **Android & Stylus Integration:**
- Test the Android target and hook up actual S-Pen data (pressure/tilt) via `MotionEvent` APIs to make sure it handles S-Pen pressure.
3. **Additional Modules:**
- Begin development of Phase 2 modules like the **Pressure Ramp** (matching target pressure curves).

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()
}
}

View File

@@ -7,12 +7,14 @@ val gdxVersion: String by project
dependencies {
implementation(project(":core"))
implementation(project(":shared"))
implementation("com.badlogicgames.gdx:gdx-backend-lwjgl3:$gdxVersion")
implementation("com.badlogicgames.gdx:gdx-platform:$gdxVersion:natives-desktop")
}
application {
mainClass.set("com.artlegend.DesktopLauncherKt")
applicationDefaultJvmArgs = listOf("-XstartOnFirstThread")
}
kotlin {

View File

@@ -3,6 +3,8 @@ package com.artlegend
import com.badlogic.gdx.backends.lwjgl3.Lwjgl3Application
import com.badlogic.gdx.backends.lwjgl3.Lwjgl3ApplicationConfiguration
import com.artlegend.shared.ModuleType
fun main() {
val config = Lwjgl3ApplicationConfiguration().apply {
setTitle("ArtLegend")
@@ -10,5 +12,5 @@ fun main() {
setForegroundFPS(60)
useVsync(true)
}
Lwjgl3Application(ArtLegend(), config)
Lwjgl3Application(GameHost(ModuleType.LINE_CORRIDOR), config)
}