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.artlegend.input.StylusInputBridge
import com.badlogic.gdx.Gdx import com.badlogic.gdx.Gdx
import com.badlogic.gdx.Screen import com.badlogic.gdx.Screen
import com.badlogic.gdx.Input
import com.badlogic.gdx.graphics.Color import com.badlogic.gdx.graphics.Color
import com.badlogic.gdx.graphics.GL20 import com.badlogic.gdx.graphics.GL20
import com.badlogic.gdx.graphics.OrthographicCamera 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 com.badlogic.gdx.graphics.glutils.ShapeRenderer
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.sin import kotlin.math.sin
import kotlin.math.sqrt import kotlin.math.cos
import kotlin.random.Random
class LineCorridorScreen( class LineCorridorScreen(
private val bridge: StylusInputBridge, private val bridge: StylusInputBridge,
@@ -21,90 +21,158 @@ class LineCorridorScreen(
private lateinit var camera: OrthographicCamera private lateinit var camera: OrthographicCamera
private lateinit var shapeRenderer: ShapeRenderer private lateinit var shapeRenderer: ShapeRenderer
private lateinit var spriteBatch: SpriteBatch
private lateinit var font: BitmapFont
// Corridor geometry (LibGDX coords: origin bottom-left) // Level settings
private var corridorAngle = 0f // radians private val sessionDuration = 15f // 15 seconds session
private var startX = 0f; private var startY = 0f private val speed = 100f // Slightly faster (100 pixels per second)
private var endX = 0f; private var endY = 0f private val baseHalfWidth = 65f // base half-width of corridor
private val halfWidth = 55f // pixels — tight enough to be challenging
// Stroke // State
private data class Pt(val x: Float, val y: Float, val pressure: Float) private var time = 0f
private val stroke = mutableListOf<Pt>()
private var isDrawing = false
private var sessionDone = false private var sessionDone = false
private var isDrawing = false
// Previous frame's isDown to detect edge transitions
private var prevDown = 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() { override fun show() {
camera = OrthographicCamera() camera = OrthographicCamera()
shapeRenderer = ShapeRenderer() 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 w = Gdx.graphics.width.toFloat()
val h = Gdx.graphics.height.toFloat() val h = Gdx.graphics.height.toFloat()
camera.setToOrtho(false, w, h) camera.setToOrtho(false, w, h)
}
val deg = (Random.nextFloat() * 50f - 25f) // -25° to +25° private fun getCorridorCenter(y: Float, t: Float): Float {
corridorAngle = deg * (Math.PI / 180.0).toFloat() val w = Gdx.graphics.width.toFloat()
val centerX = w / 2f
val u = y - speed * t
val cx = w / 2f // Composite waves: slow drift + faster wiggles
val cy = h / 2f val kDrift = (2 * Math.PI / 2400f).toFloat() // wavelength 2400
val halfLen = w * 0.40f val kWiggle = (2 * Math.PI / 600f).toFloat() // wavelength 600
val cos = cos(corridorAngle)
val sin = sin(corridorAngle)
startX = cx - halfLen * cos // Drifts up to 220px from center, wiggles up to 90px around the drift path
startY = cy - halfLen * sin return centerX + 220f * sin(kDrift * u) + 90f * cos(kWiggle * u)
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) { override fun render(delta: Float) {
Gdx.gl.glClearColor(0.05f, 0.05f, 0.08f, 1f) Gdx.gl.glClearColor(0.05f, 0.05f, 0.08f, 1f)
Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT) Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT)
if (!sessionDone) {
time += delta
processInput() 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() camera.update()
shapeRenderer.projectionMatrix = camera.combined shapeRenderer.projectionMatrix = camera.combined
// Draw corridor and path
shapeRenderer.begin(ShapeRenderer.ShapeType.Line) shapeRenderer.begin(ShapeRenderer.ShapeType.Line)
drawCorridor() drawCorridor()
drawStroke() drawStroke()
shapeRenderer.end() 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() { private fun processInput() {
val frame = bridge.latestFrame ?: return
if (sessionDone) return if (sessionDone) return
// Flip Android Y (top-left origin) to LibGDX Y (bottom-left origin) val frame = bridge.latestFrame
val gdxY = Gdx.graphics.height - frame.y val down: Boolean
val down = frame.isDown 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) { if (down && !prevDown) {
isDrawing = true isDrawing = true
stroke.clear() currentStroke = mutableListOf()
} }
if (isDrawing && down) { 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) { if (!down && prevDown && isDrawing) {
isDrawing = false isDrawing = false
if (stroke.size > 1) { if (currentStroke.size > 1) {
sessionDone = true strokes.add(currentStroke)
val score = computeScore()
Gdx.app.log("LineCorridor", "straightness=%.2f angle=%.2f overall=%.2f"
.format(score.straightness, score.anglePrecision, score.overall))
onSessionComplete(score)
} }
} }
@@ -112,85 +180,153 @@ class LineCorridorScreen(
} }
private fun drawCorridor() { private fun drawCorridor() {
val perpX = -sin(corridorAngle) val h = Gdx.graphics.height.toFloat()
val perpY = cos(corridorAngle)
val ox = perpX * halfWidth // Draw scrolling walls
val oy = perpY * halfWidth
// Corridor walls
shapeRenderer.color = Color(0f, 0.85f, 1f, 0.75f) shapeRenderer.color = Color(0f, 0.85f, 1f, 0.75f)
shapeRenderer.line(startX + ox, startY + oy, endX + ox, endY + oy) val step = 10f
shapeRenderer.line(startX - ox, startY - oy, endX - ox, endY - oy) var prevLeftX = getCorridorCenter(0f, time) - getHalfWidth(0f, time)
var prevRightX = getCorridorCenter(0f, time) + getHalfWidth(0f, time)
var prevY = 0f
// End caps var y = step
shapeRenderer.line(startX + ox, startY + oy, startX - ox, startY - oy) while (y <= h) {
shapeRenderer.line(endX + ox, endY + oy, endX - ox, endY - oy) 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) shapeRenderer.color = Color(0f, 0.85f, 1f, 0.25f)
val dx = endX - startX prevLeftX = getCorridorCenter(0f, time)
val dy = endY - startY prevY = 0f
val len = sqrt(dx * dx + dy * dy) y = step
val dashCount = (len / 24f).toInt() var dashIndex = 0
for (i in 0 until dashCount) { while (y <= h) {
if (i % 2 == 1) continue // every other segment is a gap val centerX = getCorridorCenter(y, time)
val t0 = i / dashCount.toFloat() if (dashIndex % 2 == 0) {
val t1 = (i + 1) / dashCount.toFloat() shapeRenderer.line(prevLeftX, prevY, centerX, y)
shapeRenderer.line( }
startX + dx * t0, startY + dy * t0, prevLeftX = centerX
startX + dx * t1, startY + dy * t1 prevY = y
) y += step
dashIndex++
} }
} }
private fun drawStroke() { private fun drawStroke() {
if (stroke.size < 2) return // Render past completed strokes
for (stroke in strokes) {
if (stroke.size < 2) continue
for (i in 0 until stroke.size - 1) { for (i in 0 until stroke.size - 1) {
val a = stroke[i] val a = stroke[i]
val b = stroke[i + 1] 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) val alpha = a.pressure.coerceIn(0.15f, 1f)
shapeRenderer.color = Color(1f, 0.85f, 0.25f, alpha) shapeRenderer.color = Color(1f, 0.85f, 0.25f, alpha)
shapeRenderer.line(a.x, a.y, b.x, b.y) 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 { private fun computeScore(): LineScore {
val dx = endX - startX val allPoints = mutableListOf<Pt>()
val dy = endY - startY for (s in strokes) {
val lenSq = dx * dx + dy * dy allPoints.addAll(s)
}
var sumSqDev = 0.0 if (isDrawing) {
for (pt in stroke) { allPoints.addAll(currentStroke)
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() if (allPoints.isEmpty()) {
val straightness = (1f - (rms / halfWidth)).coerceIn(0f, 1f) return LineScore(0f, 0f, 0f)
}
// Angle of drawn stroke (first → last point), scored against corridorAngle. // 1. Containment Score (percentage of points drawn inside the corridor)
// ±15° tolerance: 0° diff → 1.0, 15° diff → 0.0. var insideCount = 0
val first = stroke.first() for (pt in allPoints) {
val last = stroke.last() val xc = getCorridorCenter(pt.y, pt.t)
val strokeAngle = atan2((last.y - first.y).toDouble(), (last.x - first.x).toDouble()).toFloat() val hw = getHalfWidth(pt.y, pt.t)
var angleDiff = abs(strokeAngle - corridorAngle) val dist = abs(pt.x - xc)
// Clamp to [0, π/2] to handle π-equivalent opposite directions if (dist <= hw) {
if (angleDiff > Math.PI.toFloat() / 2f) angleDiff = Math.PI.toFloat() - angleDiff insideCount++
val maxAngleDiff = (15f * (Math.PI / 180.0)).toFloat() }
val anglePrecision = (1f - (angleDiff / maxAngleDiff)).coerceIn(0f, 1f) }
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) { override fun resize(width: Int, height: Int) {
if (!sessionDone) regenerate() camera.setToOrtho(false, width.toFloat(), height.toFloat())
} }
override fun pause() {} override fun pause() {}
@@ -199,5 +335,7 @@ class LineCorridorScreen(
override fun dispose() { override fun dispose() {
shapeRenderer.dispose() shapeRenderer.dispose()
spriteBatch.dispose()
font.dispose()
} }
} }

View File

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

View File

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