diff --git a/HANDOFF_2026-06-19.md b/HANDOFF_2026-06-19.md new file mode 100644 index 0000000..dfd96b7 --- /dev/null +++ b/HANDOFF_2026-06-19.md @@ -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). diff --git a/core/src/main/kotlin/com/artlegend/modules/LineCorridorScreen.kt b/core/src/main/kotlin/com/artlegend/modules/LineCorridorScreen.kt index 49cdeab..6509521 100644 --- a/core/src/main/kotlin/com/artlegend/modules/LineCorridorScreen.kt +++ b/core/src/main/kotlin/com/artlegend/modules/LineCorridorScreen.kt @@ -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() - 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>() + private var currentStroke = mutableListOf() + 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() + 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>() + 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() } } diff --git a/desktop/build.gradle.kts b/desktop/build.gradle.kts index c45071e..28b24aa 100644 --- a/desktop/build.gradle.kts +++ b/desktop/build.gradle.kts @@ -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 { diff --git a/desktop/src/main/kotlin/com/artlegend/DesktopLauncher.kt b/desktop/src/main/kotlin/com/artlegend/DesktopLauncher.kt index 329e946..909f3c2 100644 --- a/desktop/src/main/kotlin/com/artlegend/DesktopLauncher.kt +++ b/desktop/src/main/kotlin/com/artlegend/DesktopLauncher.kt @@ -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) }