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:
2026-05-08 19:21:48 -06:00
parent 24d78786db
commit ac81ec51f4
39 changed files with 1797 additions and 41 deletions

14
.gitignore vendored Normal file
View File

@@ -0,0 +1,14 @@
# Build outputs
build/
*/build/
.gradle/
# Local config
local.properties
# IDE
.idea/
*.iml
# OS
.DS_Store

125
HANDOFF_2026-05-05.md Normal file
View File

@@ -0,0 +1,125 @@
# Agent Handoff — May 5, 2026
## Session Summary
Full rearchitecture from pure LibGDX to hybrid Compose + LibGDX. Sprint 0 (foundation) and Sprint 1 (Line Corridor MVP) both completed in this session. APK builds successfully.
---
## What Was Accomplished
### Architecture (Sprint 0 — all done)
- Created `shared/` pure-Kotlin module containing `ModuleType` enum (LINE_CORRIDOR, PRESSURE_RAMP, CURVE_SCULPTOR, VECTOR_SNAP)
- Created `core/GameHost.kt` — routes by ModuleType, exposes `onSessionComplete` callback
- Created `core/input/StylusFrame.kt` + `StylusInputBridge.kt` — AtomicReference bridge, GL-thread safe
- Created `core/modules/LineCorridorScreen.kt` + `LineScore.kt`
- Created `core/PlaceholderModuleScreen.kt`
- Created `android/MainActivity.kt` — ComponentActivity + Compose
- Created `android/GameActivity.kt` — AndroidApplication, reads ModuleType from Intent, sets OnTouchListener on LibGDX view
- Created full Compose navigation: `ArtLegendApp`, `HubScreen`, `ModuleDetailScreen`, `ProgressScreen`, `SettingsScreen`
- Created dark Material3 theme (violet #7B2FBE / cyan #00E5FF)
- Updated `AndroidManifest.xml`: MainActivity as LAUNCHER, GameActivity as landscape fullscreen non-exported
- Deleted orphaned `AndroidLauncher.kt`, `ArtLegend.kt`, `styles.xml`
### Line Corridor (Sprint 1 — all done)
- `LineCorridorScreen` renders two cyan corridor walls + dashed centerline at random ±25° angle via ShapeRenderer
- Stroke captured live from StylusInputBridge, rendered in yellow with pressure-driven alpha
- `isDown: Boolean` added to `StylusFrame`; DOWN/UP edge detection for clean session start/stop
- Android Y flipped to LibGDX coordinate space in `processInput()`
- `LineScore` computed: straightness (RMS perpendicular deviation) + pressureConsistency (RMS from 0.5)
- Full result pipeline: GL thread → `runOnUiThread { setResult + finish() }``ActivityResultLauncher``lastScore` Compose state → `ScoreCard` on `ModuleDetailScreen`
- "Start Training" button changes to "Train Again" after first session
### Build environment fixed
- Added `gradlew` script (copied from whisper.cpp) + `gradle-wrapper.jar`
- Added Foojay toolchain resolver plugin to `settings.gradle.kts` (auto-downloads JDK 17)
- Installed Android SDK via `brew install --cask android-commandlinetools`
- SDK root: `/opt/homebrew/share/android-commandlinetools`
- Installed: `platforms;android-34`, `build-tools;34.0.0`, `platform-tools`
- `local.properties` created: `sdk.dir=/opt/homebrew/share/android-commandlinetools`
- Fixed `copyAndroidNatives` task — now extracts each `.so` into its ABI subdirectory (`libs/arm64-v8a/`, etc.)
- Symlink at `/opt/homebrew/opt/jbr-home` → IntelliJ JBR (used for sdkmanager)
### Build command (verified working)
```bash
cd /Users/blindmouse/artlegend
JAVA_HOME="/Users/blindmouse/Applications/IntelliJ IDEA.app/Contents/jbr/Contents/Home" ./gradlew :android:assembleDebug
```
APK output: `android/build/outputs/apk/debug/android-debug.apk`
---
## Current State
**SPRINT.md tickets:**
- AL-001 through AL-008: DONE
- AL-009 (on-device smoke test): not done — device needed
**Three deprecation warnings** (non-blocking, cosmetic):
- `Icons.Default.ArrowBack` deprecated → use `Icons.AutoMirrored.Filled.ArrowBack`
- Affects: `ModuleDetailScreen.kt`, `ProgressScreen.kt`, `SettingsScreen.kt`
---
## Next Steps (Priority Order)
### Immediate
1. **AL-009 on-device test** — install APK, verify:
- Hub shows 4 module cards
- Tap → ModuleDetail → "Start Training" → LibGDX corridor renders
- S-Pen stroke captured, score returned on finish
- Check logcat: `adb logcat -s LineCorridor GameActivity`
2. **Fix ArrowBack deprecation** (3 files, 1-line change each):
```kotlin
// Old
Icons.Default.ArrowBack
// New
Icons.AutoMirrored.Filled.ArrowBack
// Also add import:
import androidx.compose.material.icons.automirrored.filled.ArrowBack
```
### Phase 1 (ROADMAP)
3. Corridor difficulty progression (narrowing half-width per session)
4. Session summary screen (score + streak counter)
5. Local score persistence (DataStore or Room)
6. Progress screen populated with real data (last 7 sessions chart)
### Phase 2
7. Pressure Ramp module
---
## Key File Locations
```
artlegend/
shared/src/main/kotlin/com/artlegend/shared/ModuleType.kt
core/src/main/kotlin/com/artlegend/
GameHost.kt
PlaceholderModuleScreen.kt
input/StylusFrame.kt
input/StylusInputBridge.kt
modules/LineCorridorScreen.kt
modules/LineScore.kt
android/src/main/kotlin/com/artlegend/
MainActivity.kt
GameActivity.kt
ui/ArtLegendApp.kt
ui/theme/ArtLegendTheme.kt
ui/screens/{Hub,ModuleDetail,Progress,Settings}Screen.kt
android/src/main/res/values/themes.xml
android/src/main/AndroidManifest.xml
local.properties ← sdk.dir, gitignored
settings.gradle.kts ← includes foojay toolchain resolver
SPRINT.md, ROADMAP.md, README.md
```
## Gotchas for Next Agent
- **sdkmanager requires** `JAVA_HOME=/opt/homebrew/opt/jbr-home SKIP_JDK_VERSION_CHECK=1` — the JBR version string breaks the integer test in the wrapper script
- **StylusInputBridge** is last-write-wins (AtomicReference.set), not a queue — fine for 60fps render but means missed frames between renders are dropped
- **Y-coordinate flip**: StylusFrame stores raw Android Y (top=0); LineCorridorScreen flips it to LibGDX space (bottom=0) in `processInput()`
- **GameActivity.onSessionComplete** is called on the GL thread — always `runOnUiThread {}` before `setResult/finish`
- The `natives-desktop` JAR is skipped by `copyAndroidNatives` (not in the `validAbis` set) — this is intentional

77
HANDOFF_2026-05-07.md Normal file
View File

@@ -0,0 +1,77 @@
# Agent Handoff — May 7, 2026
## Session Summary
This session was design-focused, not implementation-focused. Two things were accomplished:
1. The Corridor Scroll module mechanics were fully described and documented.
2. The Vector Drawing module design conversation was started but not finished — bill had to leave mid-discussion.
---
## What Was Accomplished
### Documentation
- Created `MODULES.md` — the authoritative module design reference. This file exists specifically because design decisions were being re-explained from scratch each session. Keep it updated.
- Updated `README.md` — module list now reflects Corridor Scroll (not "Line Corridor"), added Stroke-to-Drawing, updated the vision paragraph to describe the scroll mechanic properly, and linked to MODULES.md.
### Corridor Scroll — design is settled
Full mechanics documented in `MODULES.md`. Summary:
- Guitar Hero-style scrolling corridor, player tracks it with the stylus
- Scroll direction angle changes over time — full 360° range at high difficulty, no angle changes at low difficulty
- Difficulty knobs: scroll speed, corridor width, angle change frequency/severity, lookahead distance
- Scoring: running tally of fill coverage (rewarded) + wall crossings (penalized). No pressure component.
- Early levels: straight corridor only. Later levels: any angle including full reversals.
### Code change from this session
`LineScore` was updated to replace `pressureConsistency` with `anglePrecision` (straightness 60%, angle 40%). This was done before the full corridor scroll mechanic was described — **this scoring model is now likely wrong for the module as designed.** The corridor scroll is real-time tracking, not a single-stroke grade. The scoring architecture will need to be rethought when implementation resumes. Don't build on top of the current `LineScore`/`computeScore()` without revisiting it first.
---
## In Progress: Vector Drawing Module Design
This conversation started but was not finished. Here is where it was left.
### What was proposed (by the agent — not yet agreed with bill)
Three sub-exercises, each isolating a different aspect of vector tool use:
1. **Anchor placement** — target curve shown, player repositions pre-suggested anchor points. Teaches where joints belong (corners, inflection points, extrema).
2. **Handle pulling** — anchors pre-placed, player drags handles to shape the curve against a target. Teaches handle angle/length intuition.
3. **Full trace** — player places anchors and pulls handles from scratch. Scored on fidelity to target AND anchor economy (fewer anchors = better, if fidelity holds).
Difficulty progression proposed:
- Easy: target visible while working, simple open curves, loose tolerance, no economy pressure.
- Hard: target shown briefly then hidden, complex closed shapes, tight tolerance, economy weighted heavily.
### Open questions — bill had not yet answered these
These need to be discussed before MODULES.md is updated with the vector module design:
1. **Simulate a specific tool or abstract it?** Should exercises feel like practicing for Illustrator's pen tool specifically (with its handle-breaking behavior etc.), or be a simplified universal bezier model that transfers to any app?
2. **Anchor economy in scoring?** Good habit to teach, but may frustrate beginners. Should economy only factor in at higher difficulty, or always be part of the score?
3. **Target content** — abstract curves and blobs, or recognizable things like letterforms and simple character shapes? Recognizable is more motivating but harder to generate procedurally.
4. **Target visibility** — should seeing the target while you trace always be available as a beginner/accessibility option, or does the module intentionally remove it as difficulty increases?
5. **Does pressure matter here?** Real vector tools ignore pressure. But this is an S-Pen trainer. Should the module ignore pressure entirely, or is there a creative angle (e.g. calligraphic vector mode where handle length is pressure-driven)?
### Where to pick up
Resume by getting bill's answers to the five questions above. Once agreed, write the Vector Drawing section in `MODULES.md` using the same format as the Corridor Scroll section. Do not write any implementation code until the design is settled and documented.
---
## Broader Reminders for Next Agent
- **Design before code.** Bill has explicitly said assumptions get made when mechanics aren't written out. When something is ambiguous, discuss and document first.
- **Keep MODULES.md updated.** It exists to prevent re-explanation. Every settled design decision goes there.
- **The current `LineCorridorScreen` and `LineScore` are placeholders**, not the real implementation of Corridor Scroll. The real mechanic (scrolling corridor, real-time tracking) is fundamentally different from what's coded (static corridor, single stroke scored on lift).
- Build command: `JAVA_HOME="/Users/blindmouse/Applications/IntelliJ IDEA.app/Contents/jbr/Contents/Home" ./gradlew :android:assembleDebug`
- APK output: `android/build/outputs/apk/debug/android-debug.apk`
- AL-009 (on-device smoke test) is still the only open sprint ticket — needs a physical device.

71
HANDOFF_2026-05-08.md Normal file
View File

@@ -0,0 +1,71 @@
# Agent Handoff — May 8, 2026
## Session Summary
Short session. Two things happened:
1. Build infrastructure unblocked — JDK 17 installed, APK compiles cleanly.
2. A `LineScore` field mismatch (stale `pressureConsistency` references) was caught by the compiler and fixed.
AL-009 (on-device smoke test) is unfinished — build passes but the physical device walkthrough hasn't happened yet.
---
## What Was Accomplished
### Build infrastructure
OpenJDK 17 installed via Homebrew (`/opt/homebrew/opt/openjdk@17`). The system Java wrappers symlink was **not** set up (requires sudo from a local terminal). Use the explicit JAVA_HOME form to build from the CLI:
```
JAVA_HOME=/opt/homebrew/opt/openjdk@17 PATH="/opt/homebrew/opt/openjdk@17/bin:$PATH" ./gradlew :android:assembleDebug
```
From IntelliJ (on the device machine) the JBR path from the previous handoff still applies:
```
JAVA_HOME="/Users/blindmouse/Applications/IntelliJ IDEA.app/Contents/jbr/Contents/Home" ./gradlew :android:assembleDebug
```
BUILD SUCCESSFUL — APK at `android/build/outputs/apk/debug/android-debug.apk`.
Three harmless deprecation warnings remain (`Icons.ArrowBack``Icons.AutoMirrored.Filled.ArrowBack`). Not worth fixing now.
### LineScore field mismatch fixed
`GameActivity.kt` and `MainActivity.kt` were still referencing `score.pressureConsistency` and passing `"pressureConsistency"` as an Intent extra — left over from before `LineScore` was redesigned. Fixed both to use `anglePrecision`.
---
## Still Open: AL-009 On-Device Smoke Test
Bill is SSH'd in and doesn't have a device attached to this machine. He'll pull from Gitea in a separate session with IntelliJ + S-Pen. Steps remaining:
- `./gradlew :android:installDebug` (device must be connected via ADB)
- Launch app → confirm Compose hub shows 4 module cards
- Tap card → ModuleDetail with "Start Training"
- Tap "Start Training" → LibGDX dark background + cyan corridor renders
- Stylus pressure values visible in logcat
- Complete stroke → score returned to ScoreCard on ModuleDetail
Once all steps pass, mark AL-009 DONE and close Sprint 1.
---
## Still Open: Vector Drawing Module Design
Five questions from the May 7 handoff are still unanswered. Do not write MODULES.md entries or any implementation for the vector module until bill answers them. See HANDOFF_2026-05-07.md for the full question list.
---
## Design Philosophy Note
Bill pushed back on framing ArtLegend as a platform for people who "fail" at art. His view: art is expression, there is no failure state. ArtLegend teaches **rendering** specifically — it builds the physical vocabulary that lets artists execute what they imagine. Confident rendering lowers the activation energy for creative risk. Don't frame the app around deficits.
---
## Broader Reminders
- **Design before code.** When mechanics are ambiguous, discuss and document in MODULES.md first.
- **The current `LineCorridorScreen` and `LineScore` are placeholders.** The real Corridor Scroll is a real-time scrolling tracker, not a single-stroke scorer. Don't build on top of the current scoring without revisiting it.
- **Keep MODULES.md updated.** It exists to prevent re-explanation each session.

116
MODULES.md Normal file
View File

@@ -0,0 +1,116 @@
# ArtLegend — Module Design Reference
This document is the authoritative description of each training module: what it teaches, how it plays, and what variables control difficulty. Update this whenever a module's design changes. Its purpose is to prevent design decisions from being re-explained from scratch each session.
---
## Module: Corridor Scroll
**Skill trained:** Sustained stroke control — staying within a moving corridor, filling available space confidently, and tracking direction changes.
### Concept
A corridor (two parallel lines with a gap between them) scrolls across the screen continuously. The player holds the S-Pen inside the corridor as it moves. Think Guitar Hero, but instead of hitting notes at the right moment, the player is maintaining precise stylus position inside a moving channel.
The corridor's **scroll direction can change angle** over time — the player must follow smooth or abrupt directional shifts while keeping their stroke inside the walls. Eventually the corridor can scroll from any direction: top, bottom, left, right, or any diagonal. Early levels keep the scroll direction fixed; later levels introduce angle changes of increasing frequency and severity.
### What the player is scored on
Scoring is a running tally across the session:
| Component | Good outcome | Bad outcome |
|-----------|-------------|-------------|
| **Fill** | Stroke covers a large proportion of the corridor width | Stroke is too thin/timid — space left unused |
| **Containment** | Stroke stays inside both walls | Stroke crosses a wall — penalty applied |
There is no pressure consistency requirement in this module. The goal is spatial accuracy and confident use of the available space, not stylus pressure.
### Difficulty variables
All of these are independent knobs that can be tuned per difficulty tier:
| Variable | Easy | Hard |
|----------|------|------|
| **Scroll speed** | Slow | Fast |
| **Corridor width** | Wide | Narrow |
| **Angle change frequency** | None (straight) | Frequent |
| **Angle change severity** | — | Up to full 360° range |
| **Lookahead distance** | Generous (player can see far ahead) | Short (more reactive) |
### Angle design notes
- Early difficulty levels should have **no angle changes** — straight corridor only.
- As difficulty increases, angle changes are introduced: first gentle curves, then sharper bends.
- There is no artificial cap on angle range. The corridor can scroll in any direction including full direction reversals. Players need to handle all angles because later modules involve drawing at all angles.
- The player's stroke within the corridor can itself be at an angle relative to the corridor walls — this is valid and intentional. Filling the corridor width at an angle is still good fill coverage.
### Session structure
- Session runs for a fixed duration or until a fixed length of corridor has passed.
- Score is displayed at end of session as: Overall %, Fill %, Wall Penalty count (or %).
- Difficulty progresses between sessions (corridor narrows, speed increases, angle changes introduced).
---
## Module: Stroke-to-Drawing
**Skill trained:** Guided stroke execution — strokes the player makes during a session are recorded and compose a recognizable drawing at the end.
### Concept
The player follows guided stroke prompts and their actual stylus input is retained. At the end of the session the accumulated strokes form a picture. This module teaches the player to execute intentional marks with confidence, knowing each stroke is permanent and contributes to an image.
### Design notes
- The Guitar Hero scroll mechanic is **not used** here — the player is making deliberate, placed strokes, not tracking a moving corridor.
- Scoring is TBD — likely based on stroke accuracy relative to a guide path, with pressure and angle components relevant (unlike Corridor Scroll).
- This module is planned for a later phase after Corridor Scroll is solid.
---
## Module: Pressure Ramp
**Skill trained:** Dynamic stroke weight control — matching a target pressure curve over the length of a stroke.
### Design notes
- Player draws a stroke while matching a displayed target pressure profile.
- Scored via DTW (Dynamic Time Warping) between actual and target pressure curve.
- Difficulty tiers: linear ramp → S-curve → arbitrary profile.
- Planned for Phase 2.
---
## Module: Curve Sculptor
**Skill trained:** Smooth bezier curve shaping.
### Design notes
- Player strokes follow a target bezier curve.
- Scored on how closely the stroke follows the curve path.
- Optional control point hints at lower difficulty.
- Planned for Phase 3.
---
## Module: Vector Snap
**Skill trained:** Precision endpoint targeting.
### Design notes
- Random endpoint targets displayed; player taps each as accurately as possible.
- Scored on distance from target; speed bonus for fast accurate taps.
- Planned for Phase 3.
---
## Notes on future modules
Additional module types discussed but not yet designed:
- **Gradient fill** — fill a shape with a smooth pressure gradient; teaches pressure ramp within a spatial boundary.
- **Vector drawing tool practice** — precision exercises that map directly to using vector drawing tools in professional apps.
These are later-phase ideas. No implementation decisions have been made for them.

134
README.md Normal file
View File

@@ -0,0 +1,134 @@
# ArtLegend
**S-Pen mastery training for digital artists — gamified, modular, precision-first.**
ArtLegend is an Android training app that turns S-Pen skill-building into a structured game. Each module isolates one drawing skill (pressure control, line confidence, curve accuracy, vector precision) and drills it with real-time feedback, scoring, and progressive difficulty.
---
## Vision
Most artists know *what* they want to draw but lack the muscle memory to execute it. ArtLegend bridges that gap by making practice deliberate, measurable, and satisfying.
Each module isolates a specific physical skill. The first is **Corridor Scroll** — a Guitar Hero-style scrolling corridor the player must track with their stylus, training spatial accuracy and confident stroke coverage across all angles and speeds. Later modules build toward exercises where the player's accumulated strokes form actual drawings, so the skills trained are directly transferable to real artwork.
The long-term goal: a modular curriculum where artists buy skill packs, track their growth over weeks, and build real hand-eye coordination for professional digital work.
---
## Module List
See [MODULES.md](MODULES.md) for full gameplay design, scoring rules, and difficulty variables for each module.
| Module | Skill Trained | Status |
|--------|--------------|--------|
| Corridor Scroll | Sustained stroke control inside a scrolling, direction-changing corridor | In Progress |
| Stroke-to-Drawing | Guided strokes that compose a picture; teaches deliberate mark-making | Planned |
| Pressure Ramp | Dynamic stroke weight control against a target pressure curve | Planned |
| Curve Sculptor | Smooth bezier curve shaping | Planned |
| Vector Snap | Precision endpoint targeting | Planned |
---
## Architecture
```
MainActivity (ComponentActivity + Compose)
└── Navigation: Hub → ModuleDetail → Progress → Settings
|
| Intent (puts ModuleType enum name)
v
GameActivity (AndroidApplication / LibGDX)
└── GameHost : Game() → routes to correct Screen by ModuleType
└── LineCorridorScreen (first real module)
└── StylusInputBridge (AtomicReference — bridges UI thread → GL thread)
```
### Key Design Decisions
- **Compose for navigation/UI** — Hub, progress, settings, and module-detail screens benefit from standard Android UI patterns (adaptive layout, accessibility, back stack).
- **LibGDX for game rendering** — Frame-accurate rendering, direct OpenGL access, and fine-grained input timing for each training module.
- **Separate Activities** — Clean boundary: `MainActivity` owns Compose state; `GameActivity` owns the LibGDX lifecycle. Intent extras carry the `ModuleType`.
- **`shared/` module** — Zero-dependency pure-Kotlin module that holds the `ModuleType` enum. Both Compose and LibGDX layers depend on it; neither depends on the other.
- **StylusInputBridge** — `AtomicReference<StylusFrame?>` lets the UI thread post stylus data (pressure, tilt, orientation) without locking the GL thread.
---
## Tech Stack
| Layer | Technology |
|-------|-----------|
| UI / Navigation | Compose Multiplatform, Material3, Navigation Compose |
| Game rendering | LibGDX 1.12.1 (OpenGL ES) |
| Language | Kotlin 1.9.23 |
| Build | Gradle 8.x (Kotlin DSL) |
| Min SDK | 26 (Android 8.0) |
| Target SDK | 34 (Android 14) |
| Primary device | Samsung Galaxy Tab S-series with S-Pen |
---
## Build Instructions
### Prerequisites
- Android Studio Hedgehog or later
- Android SDK 34
- JDK 17
### Build debug APK
```bash
./gradlew :android:assembleDebug
```
### Install on device
```bash
./gradlew :android:installDebug
```
### Verify compilation
```bash
./gradlew :shared:compileKotlin
./gradlew :core:compileKotlin
./gradlew :android:compileDebugKotlin
```
---
## Stylus Setup
ArtLegend reads S-Pen input via Android's `MotionEvent` API in `GameActivity`:
```kotlin
// Captured per frame in GameActivity.handleStylusEvent()
val pressure = event.pressure // 0.0 1.0
val tiltRad = event.getAxisValue(MotionEvent.AXIS_TILT)
val orientRad = event.getAxisValue(MotionEvent.AXIS_ORIENTATION)
val isStylus = event.getToolType(0) == TOOL_TYPE_STYLUS
```
The `StylusInputBridge` (an `AtomicReference<StylusFrame?>`) carries this data thread-safely into the LibGDX GL thread where each module's `render()` can consume it without locking.
The bridge posts every touch event but does **not** queue them — only the latest frame is visible to the GL thread. This matches the rendering cadence (60 fps) without unbounded buffering.
---
## How to Add a New Module
1. Add an entry to `ModuleType` in `shared/src/main/kotlin/com/artlegend/shared/ModuleType.kt`
2. Create `core/src/main/kotlin/com/artlegend/modules/YourModuleScreen.kt` implementing `Screen`
3. Add a `when` branch in `GameHost.create()` routing to your new screen
4. Add a `ModuleCard` entry in `HubScreen.kt` (title + description)
5. Add a description string in `ModuleDetailScreen.kt`'s `when` block
---
## Business / Licensing Model
ArtLegend is designed around a modular content model:
- **Free core**: Line Corridor + basic progress tracking
- **Skill packs** (one-time purchase): Additional modules sold individually or as bundles (e.g., "Ink Fundamentals Pack" = Pressure Ramp + Curve Sculptor)
- **Pro subscription**: Unlocks all current + future modules, cloud sync, detailed analytics
- **Institutional licensing**: Studio / school licenses for group training dashboards
The `ModuleType` enum is the extension point — new modules ship as app updates and are unlocked via in-app purchase entitlements checked before launching `GameActivity`.

123
ROADMAP.md Normal file
View File

@@ -0,0 +1,123 @@
# ArtLegend Roadmap
---
## Phase 0 — Foundation (current)
**Goal:** Hybrid architecture running end-to-end. Compose hub launches LibGDX game screen with stylus data flowing.
### Milestones
- [x] LibGDX scaffold (core + android modules)
- [x] `shared/` module with `ModuleType` enum
- [x] `GameHost` routing by `ModuleType`
- [x] `StylusInputBridge` (AtomicReference, thread-safe)
- [x] `StylusFrame` data class (x, y, pressure, tiltRad, orientRad, isStylus)
- [x] `MainActivity` + Compose navigation (Hub → Detail → Game)
- [x] `GameActivity` reads `ModuleType` from Intent, posts stylus frames
- [x] `LineCorridorScreen` renders guided corridor + real-time stroke
- [x] Score calculation for Line Corridor (straightness + pressure consistency)
- [x] Score displayed on return to ModuleDetail (ScoreCard)
---
## Phase 1 — Line Corridor MVP
**Goal:** Shippable first module. Players can train, score, and see improvement over sessions.
### Milestones
- [ ] Corridor generation (random angle, configurable width)
- [ ] Stroke capture and rendering (LibGDX ShapeRenderer)
- [ ] Straightness score algorithm (RMS deviation from ideal line)
- [ ] Pressure consistency score (variance from target pressure profile)
- [ ] Difficulty progression (narrowing corridor, longer strokes)
- [ ] Session summary screen (score + streak)
- [ ] Local persistence (Room or DataStore) for scores per session
- [ ] Progress screen populated with real data (last 7 sessions graph)
---
## Phase 2 — Pressure Ramp Module
**Goal:** Second module ships. Shared infrastructure for module scoring is solid.
### Milestones
- [ ] Pressure Ramp screen: draw a stroke matching a target pressure curve
- [ ] Pressure curve rendering (target vs actual overlay)
- [ ] DTW (Dynamic Time Warping) score for pressure curve matching
- [ ] Unified `ModuleScore` data model shared across modules
- [ ] Difficulty tiers (linear ramp → S-curve → arbitrary profile)
---
## Phase 3 — Curve Sculptor + Vector Snap
**Goal:** Full 4-module training suite. App is content-complete for v1.0.
### Milestones
- [ ] Curve Sculptor: hit bezier control points with stylus
- [ ] Vector Snap: tap precise endpoints within tolerance radius
- [ ] Per-module difficulty levels stored in settings
- [ ] Global XP system across all modules
---
## Phase 4 — Monetization Ready
**Goal:** In-app purchase infrastructure. Free tier vs paid modules enforced.
### Milestones
- [ ] Google Play Billing integration
- [ ] Entitlement check before launching `GameActivity`
- [ ] "Line Corridor" free; others gated behind Skill Pack purchase
- [ ] Pro subscription tier (all modules + cloud sync)
- [ ] Settings screen: manage subscription, restore purchases
---
## Phase 5 — Cloud + Social
**Goal:** Cross-device sync and community features.
### Milestones
- [ ] Firebase Auth (Google sign-in)
- [ ] Firestore sync for scores and progress
- [ ] Global leaderboard per module
- [ ] Weekly challenge mode (fixed seed corridor, global rank)
- [ ] Share score card (image export)
---
## Per-Module Milestones Detail
### Line Corridor
| Milestone | Description |
|-----------|-------------|
| Skeleton | Screen renders, bridge connected |
| Corridor | Guided corridor drawn, stroke captured |
| Score | Straightness + pressure score calculated |
| Persist | Scores saved locally |
| Polish | Animations, streak counter, SFX |
### Pressure Ramp
| Milestone | Description |
|-----------|-------------|
| Skeleton | Screen renders |
| Curve target | Target pressure profile displayed |
| Score | DTW score vs target |
| Difficulty | 3 difficulty tiers |
### Curve Sculptor
| Milestone | Description |
|-----------|-------------|
| Skeleton | Screen renders |
| Bezier display | Target bezier curve shown |
| Stroke eval | How close did the stroke follow the curve? |
| Control points | Optional control point hints |
### Vector Snap
| Milestone | Description |
|-----------|-------------|
| Skeleton | Screen renders |
| Target points | Random endpoint targets displayed |
| Hit detection | Distance from target scored |
| Speed bonus | Faster accurate taps = higher score |

79
SPRINT.md Normal file
View File

@@ -0,0 +1,79 @@
# ArtLegend — Current Sprint
**Sprint goal:** Hybrid architecture end-to-end. Compose hub → LibGDX `LineCorridorScreen` with live stylus pressure data.
---
## Tickets
### AL-001 — shared/ module compiles [DONE]
- `shared/build.gradle.kts` created (pure Kotlin JVM)
- `ModuleType.kt` created with LINE_CORRIDOR, PRESSURE_RAMP, CURVE_SCULPTOR, VECTOR_SNAP
- `settings.gradle.kts` updated to include `"shared"`
### AL-002 — core/ restructured [DONE]
- `GameHost.kt` replaces `ArtLegend.kt` — accepts `ModuleType`, routes to correct Screen
- `PlaceholderModuleScreen.kt` — stub Screen for unimplemented modules
- `input/StylusFrame.kt` — data class (x, y, pressure, tiltRad, orientRad, isStylus)
- `input/StylusInputBridge.kt` — AtomicReference bridge (GL-thread safe)
- `modules/LineCorridorScreen.kt` — skeleton (dark background, reads bridge)
- `core/build.gradle.kts` updated with `:shared` dependency
### AL-003 — Compose added to android/ [DONE]
- `android/build.gradle.kts` updated: `buildFeatures { compose = true }`, Compose BOM 2024.09.00, navigation-compose, activity-compose, lifecycle
- `gradle.properties` updated with compose versions
### AL-004 — MainActivity + Compose hub [DONE]
- `MainActivity.kt` created — ComponentActivity, sets Compose content
- `ui/theme/ArtLegendTheme.kt` — dark Material3 theme, violet/cyan accents
- `ui/ArtLegendApp.kt` — NavHost with 4 routes (hub, detail/{moduleType}, progress, settings)
- `ui/screens/HubScreen.kt` — LazyVerticalGrid of 4 module cards
- `ui/screens/ModuleDetailScreen.kt` — module info + "Start Training" button
- `ui/screens/ProgressScreen.kt` — stub
- `ui/screens/SettingsScreen.kt` — stub
- `res/values/themes.xml` — Theme.ArtLegend (Material3) + Theme.ArtLegend.Fullscreen
- `AndroidManifest.xml` updated — MainActivity as LAUNCHER, GameActivity registered
### AL-005 — GameActivity + stylus bridge [DONE]
- `GameActivity.kt` created — AndroidApplication, reads ModuleType from Intent
- Stylus OnTouchListener set on LibGDX view post-initialization
- `handleStylusEvent()` extracts pressure/tilt/orient, posts StylusFrame to bridge
- Logs `pressure` + `isStylus` to logcat for verification
### AL-006 — LineCorridorScreen renders corridor [DONE]
- ShapeRenderer corridor: two cyan walls + dashed centerline at random ±25° angle
- Stroke captured per-frame from StylusInputBridge; yellow alpha stroke rendered live
- `isDown` field added to StylusFrame; DOWN/UP edges detected for clean start/stop
- Y-coordinate flipped (Android top-left → LibGDX bottom-left)
### AL-007 — Score calculation [DONE]
- `LineScore(straightness, pressureConsistency, overall)` data class in core/modules
- RMS perpendicular deviation from centerline → straightness (0..1)
- RMS pressure deviation from 0.5 target → pressureConsistency (0..1)
- overall = straightness×0.6 + pressureConsistency×0.4
- Score logged to logcat on stylus lift
### AL-008 — Score returned to hub [DONE]
- `GameHost.onSessionComplete: ((LineScore) -> Unit)?` wired by GameActivity
- GL thread score callback → `runOnUiThread { setResult + finish() }`
- `MainActivity.gameResultLauncher` (ActivityResultContracts.StartActivityForResult)
- `lastScore: LineScore?` state flows through ArtLegendApp → ModuleDetailScreen
- ScoreCard shows Overall / Line / Pressure % on return; button changes to "Train Again"
### AL-009 — End-to-end smoke test [TODO]
- `./gradlew :android:assembleDebug` passes
- App launches to Compose hub showing 4 module cards
- Tap card → ModuleDetail with "Start Training" button
- Tap "Start Training" → LibGDX renders dark background with corridor
- Stylus pressure values visible in logcat
---
## Blocked
_Nothing blocked currently._
## Out of Scope This Sprint
- Local score persistence (Phase 1)
- Difficulty progression
- In-app purchases
- Cloud sync

View File

@@ -4,6 +4,8 @@ plugins {
}
val gdxVersion: String by project
val composeBom: String by project
val composeCompilerExtension: String by project
val natives: Configuration by configurations.creating
android {
@@ -27,6 +29,14 @@ android {
jvmTarget = "17"
}
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = composeCompilerExtension
}
sourceSets {
getByName("main") {
assets.srcDirs(rootProject.file("assets"))
@@ -37,17 +47,41 @@ android {
dependencies {
implementation(project(":core"))
implementation(project(":shared"))
// LibGDX
implementation("com.badlogicgames.gdx:gdx-backend-android:$gdxVersion")
natives("com.badlogicgames.gdx:gdx-platform:$gdxVersion:natives-armeabi-v7a")
natives("com.badlogicgames.gdx:gdx-platform:$gdxVersion:natives-arm64-v8a")
natives("com.badlogicgames.gdx:gdx-platform:$gdxVersion:natives-x86")
natives("com.badlogicgames.gdx:gdx-platform:$gdxVersion:natives-x86_64")
// Compose BOM
val composeBomDep = platform("androidx.compose:compose-bom:$composeBom")
implementation(composeBomDep)
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-core")
// Activity + Navigation + Lifecycle
implementation("androidx.activity:activity-compose:1.9.2")
implementation("androidx.navigation:navigation-compose:2.8.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.4")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.4")
// Material XML themes (for Activity window styling)
implementation("com.google.android.material:material:1.12.0")
}
tasks.register("copyAndroidNatives") {
doFirst {
val validAbis = setOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64")
natives.files.forEach { jar ->
val outputDir = file("libs")
// Extract ABI from classifier, e.g. "gdx-platform-1.12.1-natives-arm64-v8a.jar" → "arm64-v8a"
val abi = jar.name.substringAfter("natives-").removeSuffix(".jar")
if (abi in validAbis) {
val outputDir = file("libs/$abi")
outputDir.mkdirs()
copy {
from(zipTree(jar))
@@ -59,6 +93,7 @@ tasks.register("copyAndroidNatives") {
}
}
}
}
tasks.whenTaskAdded {
if (name.contains("package") || name.contains("assemble") || name.contains("bundle")) {

BIN
android/libs/arm64-v8a/libgdx.so Executable file

Binary file not shown.

Binary file not shown.

BIN
android/libs/x86/libgdx.so Executable file

Binary file not shown.

BIN
android/libs/x86_64/libgdx.so Executable file

Binary file not shown.

View File

@@ -10,18 +10,28 @@
<application
android:allowBackup="true"
android:label="@string/app_name"
android:theme="@style/GdxTheme">
android:theme="@style/Theme.ArtLegend">
<!-- Compose hub — LAUNCHER entry point -->
<activity
android:name=".AndroidLauncher"
android:name=".MainActivity"
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout"
android:exported="true"
android:screenOrientation="fullSensor">
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- LibGDX game activity — launched by Intent from MainActivity -->
<activity
android:name=".GameActivity"
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout"
android:exported="false"
android:screenOrientation="landscape"
android:theme="@style/Theme.ArtLegend.Fullscreen" />
</application>
</manifest>

View File

@@ -1,17 +0,0 @@
package com.artlegend
import android.os.Bundle
import com.badlogic.gdx.backends.android.AndroidApplication
import com.badlogic.gdx.backends.android.AndroidApplicationConfiguration
class AndroidLauncher : AndroidApplication() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val config = AndroidApplicationConfiguration().apply {
useImmersiveMode = true
useGyroscope = false
useAccelerometer = false
}
initialize(ArtLegend(), config)
}
}

View File

@@ -0,0 +1,65 @@
package com.artlegend
import android.app.Activity
import android.content.Intent
import android.os.Bundle
import android.view.MotionEvent
import com.artlegend.input.StylusFrame
import com.artlegend.shared.ModuleType
import com.badlogic.gdx.backends.android.AndroidApplication
import com.badlogic.gdx.backends.android.AndroidApplicationConfiguration
class GameActivity : AndroidApplication() {
private lateinit var gameHost: GameHost
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val moduleName = intent.getStringExtra("MODULE_TYPE") ?: ModuleType.LINE_CORRIDOR.name
val moduleType = ModuleType.valueOf(moduleName)
gameHost = GameHost(moduleType)
val config = AndroidApplicationConfiguration().apply {
useImmersiveMode = true
useGyroscope = false
useAccelerometer = false
}
initialize(gameHost, config)
// Wire result callback — invoked on GL thread, must switch to UI thread.
gameHost.onSessionComplete = { score ->
runOnUiThread {
val result = Intent().apply {
putExtra("straightness", score.straightness)
putExtra("anglePrecision", score.anglePrecision)
putExtra("overall", score.overall)
}
setResult(Activity.RESULT_OK, result)
finish()
}
}
// Capture stylus data on LibGDX's GL view; return false so LibGDX also sees the event.
graphics.view.setOnTouchListener { _, event ->
handleStylusEvent(event)
false
}
}
private fun handleStylusEvent(event: MotionEvent) {
val isDown = event.action != MotionEvent.ACTION_UP &&
event.action != MotionEvent.ACTION_CANCEL
val frame = StylusFrame(
x = event.x,
y = event.y,
pressure = event.pressure,
tiltRad = event.getAxisValue(MotionEvent.AXIS_TILT),
orientRad = event.getAxisValue(MotionEvent.AXIS_ORIENTATION),
isStylus = event.getToolType(0) == MotionEvent.TOOL_TYPE_STYLUS,
isDown = isDown
)
android.util.Log.d("GameActivity", "pressure=%.3f isStylus=${frame.isStylus} isDown=$isDown"
.format(frame.pressure))
gameHost.bridge.post(frame)
}
}

View File

@@ -0,0 +1,48 @@
package com.artlegend
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.artlegend.modules.LineScore
import com.artlegend.shared.ModuleType
import com.artlegend.ui.ArtLegendApp
import com.artlegend.ui.theme.ArtLegendTheme
class MainActivity : ComponentActivity() {
private var lastScore: LineScore? by mutableStateOf(null)
private val gameResultLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
val data = result.data ?: return@registerForActivityResult
lastScore = LineScore(
straightness = data.getFloatExtra("straightness", 0f),
anglePrecision = data.getFloatExtra("anglePrecision", 0f),
overall = data.getFloatExtra("overall", 0f)
)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
ArtLegendTheme {
ArtLegendApp(
onLaunchModule = { moduleType ->
lastScore = null // clear previous score when launching
val intent = Intent(this, GameActivity::class.java).apply {
putExtra("MODULE_TYPE", moduleType.name)
}
gameResultLauncher.launch(intent)
},
lastScore = lastScore
)
}
}
}
}

View File

@@ -0,0 +1,46 @@
package com.artlegend.ui
import androidx.compose.runtime.Composable
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.artlegend.modules.LineScore
import com.artlegend.shared.ModuleType
import com.artlegend.ui.screens.HubScreen
import com.artlegend.ui.screens.ModuleDetailScreen
import com.artlegend.ui.screens.ProgressScreen
import com.artlegend.ui.screens.SettingsScreen
@Composable
fun ArtLegendApp(
onLaunchModule: (ModuleType) -> Unit,
lastScore: LineScore?
) {
val navController = rememberNavController()
NavHost(navController = navController, startDestination = "hub") {
composable("hub") {
HubScreen(
onModuleClick = { moduleType -> navController.navigate("detail/${moduleType.name}") },
onProgressClick = { navController.navigate("progress") },
onSettingsClick = { navController.navigate("settings") }
)
}
composable("detail/{moduleType}") { backStackEntry ->
val moduleType = ModuleType.valueOf(
backStackEntry.arguments?.getString("moduleType") ?: ModuleType.LINE_CORRIDOR.name
)
ModuleDetailScreen(
moduleType = moduleType,
lastScore = lastScore,
onStartTraining = { onLaunchModule(moduleType) },
onBack = { navController.popBackStack() }
)
}
composable("progress") {
ProgressScreen(onBack = { navController.popBackStack() })
}
composable("settings") {
SettingsScreen(onBack = { navController.popBackStack() })
}
}
}

View File

@@ -0,0 +1,102 @@
package com.artlegend.ui.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.artlegend.shared.ModuleType
private data class ModuleCard(
val type: ModuleType,
val title: String,
val description: String
)
private val modules = listOf(
ModuleCard(ModuleType.LINE_CORRIDOR, "Line Corridor", "Master straight lines under pressure"),
ModuleCard(ModuleType.PRESSURE_RAMP, "Pressure Ramp", "Control stroke weight dynamically"),
ModuleCard(ModuleType.CURVE_SCULPTOR, "Curve Sculptor", "Shape smooth bezier curves"),
ModuleCard(ModuleType.VECTOR_SNAP, "Vector Snap", "Hit precise vector endpoints")
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HubScreen(
onModuleClick: (ModuleType) -> Unit,
onProgressClick: () -> Unit,
onSettingsClick: () -> Unit
) {
Scaffold(
topBar = {
TopAppBar(
title = { Text("ArtLegend") },
actions = {
IconButton(onClick = onSettingsClick) {
Icon(Icons.Default.Settings, contentDescription = "Settings")
}
}
)
},
bottomBar = {
NavigationBar {
NavigationBarItem(
selected = true,
onClick = {},
icon = { Text("Train") },
label = { Text("Train") }
)
NavigationBarItem(
selected = false,
onClick = onProgressClick,
icon = { Text("Progress") },
label = { Text("Progress") }
)
}
}
) { padding ->
LazyVerticalGrid(
columns = GridCells.Fixed(2),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
items(modules) { module ->
Card(
onClick = { onModuleClick(module.type) },
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(text = module.title, style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.height(4.dp))
Text(text = module.description, style = MaterialTheme.typography.bodySmall)
}
}
}
}
}
}

View File

@@ -0,0 +1,136 @@
package com.artlegend.ui.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.artlegend.modules.LineScore
import com.artlegend.shared.ModuleType
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ModuleDetailScreen(
moduleType: ModuleType,
lastScore: LineScore?,
onStartTraining: () -> Unit,
onBack: () -> Unit
) {
val title = moduleType.name.split('_').joinToString(" ") { word ->
word.lowercase().replaceFirstChar { it.uppercase() }
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(title) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(24.dp),
verticalArrangement = Arrangement.SpaceBetween
) {
Column {
Text(text = title, style = MaterialTheme.typography.headlineMedium)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = when (moduleType) {
ModuleType.LINE_CORRIDOR ->
"Train your hand to draw perfectly straight lines using S-Pen pressure " +
"feedback. Keep your stroke within the corridor to score points."
ModuleType.PRESSURE_RAMP ->
"Learn to control stroke weight through gradual pressure ramping exercises. " +
"Match the target pressure curve for a perfect score."
ModuleType.CURVE_SCULPTOR ->
"Master the art of smooth curve drawing with guided bezier targets. " +
"Follow the path precisely to develop muscle memory."
ModuleType.VECTOR_SNAP ->
"Develop precision for hitting exact vector endpoints and anchor points. " +
"Speed and accuracy both contribute to your score."
},
style = MaterialTheme.typography.bodyLarge
)
if (lastScore != null) {
Spacer(modifier = Modifier.height(24.dp))
ScoreCard(lastScore)
}
}
Button(
onClick = onStartTraining,
modifier = Modifier
.fillMaxWidth()
.height(56.dp)
) {
Text(if (lastScore != null) "Train Again" else "Start Training")
}
}
}
}
@Composable
private fun ScoreCard(score: LineScore) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text("Last Session", style = MaterialTheme.typography.labelMedium)
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
ScoreStat("Overall", score.overall)
ScoreStat("Line", score.straightness)
ScoreStat("Angle", score.anglePrecision)
}
}
}
}
@Composable
private fun ScoreStat(label: String, value: Float) {
Column(horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally) {
Text(
text = "${(value * 100).toInt()}%",
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}

View File

@@ -0,0 +1,43 @@
package com.artlegend.ui.screens
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ProgressScreen(onBack: () -> Unit) {
Scaffold(
topBar = {
TopAppBar(
title = { Text("Progress") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
}
)
}
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentAlignment = Alignment.Center
) {
Text("Progress tracking coming soon", style = MaterialTheme.typography.bodyLarge)
}
}
}

View File

@@ -0,0 +1,43 @@
package com.artlegend.ui.screens
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(onBack: () -> Unit) {
Scaffold(
topBar = {
TopAppBar(
title = { Text("Settings") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
}
)
}
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentAlignment = Alignment.Center
) {
Text("Settings coming soon", style = MaterialTheme.typography.bodyLarge)
}
}
}

View File

@@ -0,0 +1,35 @@
package com.artlegend.ui.theme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
private val Violet = Color(0xFF7B2FBE)
private val VioletContainer = Color(0xFF4A0E8F)
private val Cyan = Color(0xFF00E5FF)
private val CyanContainer = Color(0xFF004D57)
private val DarkBackground = Color(0xFF0D0D12)
private val DarkSurface = Color(0xFF16161F)
private val DarkColors = darkColorScheme(
primary = Violet,
onPrimary = Color.White,
primaryContainer = VioletContainer,
onPrimaryContainer = Cyan,
secondary = Cyan,
onSecondary = Color.Black,
secondaryContainer = CyanContainer,
background = DarkBackground,
surface = DarkSurface,
onBackground = Color.White,
onSurface = Color.White
)
@Composable
fun ArtLegendTheme(content: @Composable () -> Unit) {
MaterialTheme(
colorScheme = DarkColors,
content = content
)
}

View File

@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="GdxTheme" parent="android:Theme.NoTitleBar.Fullscreen" />
</resources>

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Main Compose activity: Material3 dark, no action bar -->
<style name="Theme.ArtLegend" parent="Theme.Material3.Dark.NoActionBar" />
<!-- LibGDX game activity: fullscreen, no action bar -->
<style name="Theme.ArtLegend.Fullscreen" parent="Theme.Material3.Dark.NoActionBar">
<item name="android:windowFullscreen">true</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style>
</resources>

View File

@@ -6,6 +6,7 @@ val gdxVersion: String by project
dependencies {
api("com.badlogicgames.gdx:gdx:$gdxVersion")
implementation(project(":shared"))
}
kotlin {

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

View File

@@ -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) {

View 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
)

View File

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

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

View 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
)

View File

@@ -4,3 +4,5 @@ android.useAndroidX=true
gdxVersion=1.12.1
kotlinVersion=1.9.23
androidGradleVersion=8.3.0
composeBom=2024.09.00
composeCompilerExtension=1.5.12

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

185
gradlew vendored Executable file
View File

@@ -0,0 +1,185 @@
#!/usr/bin/env sh
#
# Copyright 2015 the original author or authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
##
## Gradle start up script for UN*X
##
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
warn () {
echo "$*"
}
die () {
echo
echo "$*"
echo
exit 1
}
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
NONSTOP* )
nonstop=true
;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin or MSYS, switch paths to Windows format before running java
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=`expr $i + 1`
done
case $i in
0) set -- ;;
1) set -- "$args0" ;;
2) set -- "$args0" "$args1" ;;
3) set -- "$args0" "$args1" "$args2" ;;
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
# Escape application args
save () {
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
echo " "
}
APP_ARGS=`save "$@"`
# Collect all arguments for the java command, following the shell quoting and substitution rules
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
exec "$JAVACMD" "$@"

1
local.properties Normal file
View File

@@ -0,0 +1 @@
sdk.dir=/opt/homebrew/share/android-commandlinetools

View File

@@ -1,2 +1,6 @@
plugins {
id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0"
}
rootProject.name = "artlegend"
include("core", "android", "desktop")
include("core", "android", "desktop", "shared")

7
shared/build.gradle.kts Normal file
View File

@@ -0,0 +1,7 @@
plugins {
kotlin("jvm")
}
kotlin {
jvmToolchain(17)
}

View File

@@ -0,0 +1,8 @@
package com.artlegend.shared
enum class ModuleType {
LINE_CORRIDOR,
PRESSURE_RAMP,
CURVE_SCULPTOR,
VECTOR_SNAP
}