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:
14
.gitignore
vendored
Normal file
14
.gitignore
vendored
Normal 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
125
HANDOFF_2026-05-05.md
Normal 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
77
HANDOFF_2026-05-07.md
Normal 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
71
HANDOFF_2026-05-08.md
Normal 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
116
MODULES.md
Normal 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
134
README.md
Normal 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
123
ROADMAP.md
Normal 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
79
SPRINT.md
Normal 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
|
||||
@@ -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))
|
||||
@@ -58,6 +92,7 @@ tasks.register("copyAndroidNatives") {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tasks.whenTaskAdded {
|
||||
|
||||
BIN
android/libs/arm64-v8a/libgdx.so
Executable file
BIN
android/libs/arm64-v8a/libgdx.so
Executable file
Binary file not shown.
BIN
android/libs/armeabi-v7a/libgdx.so
Executable file
BIN
android/libs/armeabi-v7a/libgdx.so
Executable file
Binary file not shown.
BIN
android/libs/x86/libgdx.so
Executable file
BIN
android/libs/x86/libgdx.so
Executable file
Binary file not shown.
BIN
android/libs/x86_64/libgdx.so
Executable file
BIN
android/libs/x86_64/libgdx.so
Executable file
Binary file not shown.
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
65
android/src/main/kotlin/com/artlegend/GameActivity.kt
Normal file
65
android/src/main/kotlin/com/artlegend/GameActivity.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
48
android/src/main/kotlin/com/artlegend/MainActivity.kt
Normal file
48
android/src/main/kotlin/com/artlegend/MainActivity.kt
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
46
android/src/main/kotlin/com/artlegend/ui/ArtLegendApp.kt
Normal file
46
android/src/main/kotlin/com/artlegend/ui/ArtLegendApp.kt
Normal 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() })
|
||||
}
|
||||
}
|
||||
}
|
||||
102
android/src/main/kotlin/com/artlegend/ui/screens/HubScreen.kt
Normal file
102
android/src/main/kotlin/com/artlegend/ui/screens/HubScreen.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="GdxTheme" parent="android:Theme.NoTitleBar.Fullscreen" />
|
||||
</resources>
|
||||
11
android/src/main/res/values/themes.xml
Normal file
11
android/src/main/res/values/themes.xml
Normal 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>
|
||||
@@ -6,6 +6,7 @@ val gdxVersion: String by project
|
||||
|
||||
dependencies {
|
||||
api("com.badlogicgames.gdx:gdx:$gdxVersion")
|
||||
implementation(project(":shared"))
|
||||
}
|
||||
|
||||
kotlin {
|
||||
|
||||
27
core/src/main/kotlin/com/artlegend/GameHost.kt
Normal file
27
core/src/main/kotlin/com/artlegend/GameHost.kt
Normal 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)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
11
core/src/main/kotlin/com/artlegend/input/StylusFrame.kt
Normal file
11
core/src/main/kotlin/com/artlegend/input/StylusFrame.kt
Normal 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
|
||||
)
|
||||
@@ -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()
|
||||
}
|
||||
203
core/src/main/kotlin/com/artlegend/modules/LineCorridorScreen.kt
Normal file
203
core/src/main/kotlin/com/artlegend/modules/LineCorridorScreen.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
7
core/src/main/kotlin/com/artlegend/modules/LineScore.kt
Normal file
7
core/src/main/kotlin/com/artlegend/modules/LineScore.kt
Normal 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
|
||||
)
|
||||
@@ -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
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
185
gradlew
vendored
Executable file
185
gradlew
vendored
Executable 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
1
local.properties
Normal file
@@ -0,0 +1 @@
|
||||
sdk.dir=/opt/homebrew/share/android-commandlinetools
|
||||
@@ -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
7
shared/build.gradle.kts
Normal file
@@ -0,0 +1,7 @@
|
||||
plugins {
|
||||
kotlin("jvm")
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(17)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.artlegend.shared
|
||||
|
||||
enum class ModuleType {
|
||||
LINE_CORRIDOR,
|
||||
PRESSURE_RAMP,
|
||||
CURVE_SCULPTOR,
|
||||
VECTOR_SNAP
|
||||
}
|
||||
Reference in New Issue
Block a user