Compare commits

...

4 Commits

Author SHA1 Message Date
3ed24cf8be Add playable corridor scroll desktop prototype 2026-06-30 11:36:23 -06:00
2b208aff7e Sprint 2 head-start: Room persistence, ProgressViewModel, ProgressScreen
- Room DB (ArtLegendDatabase, SessionDao, SessionEntity) wired up
- SessionRepository saves scores and queries last 7 sessions per module
- ProgressViewModel exposes sessions as StateFlow
- ProgressScreen replaced with real bar chart + session history list
- MainActivity now persists each game result to Room on return
- build.gradle.kts + gradle.properties: KSP and Room 2.6.1 added

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 22:53:42 -06:00
d4c48ed393 Add artistic curriculum and Sprint 2 plan
CURRICULUM.md: full 14-group skill tree drawn from the artistic learning
journey — line control through creative systems, with skill dependency
graph and notes on how exercises translate into module designs.

SPRINT.md: Sprint 2 tickets (AL-010 through AL-015) replacing the Sprint 1
static-stroke prototype with the real scrolling corridor from MODULES.md,
including score model migration, difficulty tiers, and session summary screen.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 22:51:48 -06:00
ac81ec51f4 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>
2026-05-08 19:21:48 -06:00
49 changed files with 2695 additions and 42 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

367
CURRICULUM.md Normal file
View File

@@ -0,0 +1,367 @@
# ArtLegend — Artistic Learning Curriculum
This document defines the full skill curriculum that ArtLegend draws from. It is not a module design document — that is MODULES.md. This is the **artistic map**: every skill group we intend to train, the exercises within each group, and how skills transfer to one another.
When designing a new module, look here first to understand what skill you're targeting and what else it connects to.
---
## Philosophy
Artistic ability is not a singular talent. It is a collection of trainable micro-skills.
ArtLegend decomposes artistic growth into atomic, measurable exercises — each isolating one motor skill, one perceptual skill, or one conceptual skill. Every exercise produces a visible result. Learning and making happen at the same time.
Three principles drive the curriculum:
**One skill at a time.** Each exercise isolates a single thing — pressure control, gradient smoothness, silhouette readability, line confidence. Isolation reduces cognitive overload and makes improvement measurable.
**Constraints accelerate learning.** Limiting tools, colors, values, or time forces the skill to work without crutches. Constraints are not punishment — they are the training mechanism.
**Skills transfer.** Line confidence improves gesture drawing. Value studies improve lighting. Shape language improves character design. The curriculum is designed so early skills compound into later ones.
---
## Skill Dependency Overview
Some skill groups are foundational — they feed directly into everything else. Others build on top of prior work. This is a rough dependency graph, not a strict prerequisite lock:
```
Observation Training ──────────────────────────────────────┐
Line Control ──────────────────────────────────────────────┤
Value & Lighting ───────────────────────────────► Form Construction
Shape Language ────────────────────────────────► Character Design
Gesture & Motion ──────────────────────────────────────────┤
Form Construction ─────────────────────────────► Animation Primitives
Texture Systems ──────────────────────────────────────────┐
Composition ──────────────────────────────────────► Style Development
Character Design ─────────────────────────────────────────┤
Animation Primitives ─────────────────────────────────────┤
Pixel Art ────────────────────────────────────► Creative Systems
Vector & UI Design ───────────────────────────────────────┘
```
---
## Skill Group 1 — Line Control
**What it trains:** Precise stylus coordination and movement confidence. The physical foundation of all drawing.
**Core skills:**
- Stroke control (start, end, path accuracy)
- Pressure control (light to heavy, smooth transitions)
- Directional consistency (maintaining angle across a stroke)
- Rhythm and flow (speed without tension)
- Acceleration and deceleration (tapered, confident strokes)
**Exercises:**
- Straight line drills — horizontal, vertical, diagonal
- Ghosted lines — hover and plan the movement before committing
- Parallel lines — maintain consistent spacing across multiple strokes
- Tapered lines — thin → thick → thin pressure transitions in one stroke
- Superimposed lines — repeat the same stroke path to build muscle memory
- Ellipse sheets — controlled ellipse repetition at different angles and sizes
- S-curve drills — flow and gesture training with continuous curves
- Speed accuracy challenges — hit targets under time pressure
**Transfers to:** Every other skill group. This is the most foundational group in the curriculum.
---
## Skill Group 2 — Value & Lighting
**What it trains:** Perception of light, shadow, and form. The ability to see and reproduce tonal relationships.
**Core skills:**
- Tonal recognition (identifying light/midtone/shadow)
- Form perception (how light wraps around 3D shapes)
- Gradient control (smooth tonal transitions)
- Edge hierarchy (hard, soft, and lost edges)
**Exercises:**
- 2-value notan studies — black and white only, no midtones
- 3-value studies — separate light, midtone, and shadow clearly
- Gradient bars — smooth continuous tonal transitions
- Sphere rendering — core shadow, cast shadow, and highlight placement
- Cube lighting — analyze planar lighting on flat surfaces
- Cylinder shading — value wrapping on curved surfaces
- Value mass copying — ignore objects entirely; copy only the light and dark regions
- Shadow shape painting — fill shadow masses only; treat them as abstract shapes
- Edge control studies — practice intentional hard, soft, and lost edge transitions
- Posterization challenges — reduce a reference into a limited number of values
**Transfers to:** Form Construction, Texture Systems, Style Development
---
## Skill Group 3 — Texture Systems
**What it trains:** Material representation and surface variation. How to suggest texture with marks rather than literal detail.
**Core skills:**
- Texture abstraction (suggesting material with minimal marks)
- Density control (how mark frequency implies value)
- Directional texture flow (marks follow the surface)
- Material readability (the viewer recognizes the material at a glance)
**Exercises:**
- Crosshatching — directional line shading with controlled density
- Stippling — dot-based rendering with density gradients
- Scribble rendering — controlled chaotic texture
- Fur flow mapping — directional fur and hair studies
- Material swatches — practice representing metal, wood, stone, skin, cloth, and fur
- Texture gradients — create value transitions through texture density alone
- Surface simplification — reduce complex textures (bark, stone) into repeatable motifs
- Pixel texture clusters — low-resolution material readability
**Transfers to:** Value & Lighting, Style Development
---
## Skill Group 4 — Shape Language
**What it trains:** Symbolic simplification and visual readability. How shapes communicate personality before content.
**Core skills:**
- Silhouette design (readable form in pure black)
- Emotional shape association (circles = soft, triangles = aggressive, squares = stable)
- Visual hierarchy (what reads first in a composition)
**Exercises:**
- Circle-based character designs — friendly, soft, approachable
- Square-based character designs — stable, mechanical, grounded
- Triangle-based character designs — aggressive, dynamic, threatening
- Silhouette recognition — design forms that read as a pure black shape
- Shape reduction — break objects down into their primitive geometry
- Icon simplification — reduce recognizable objects into symbolic representations
- Blob creature design — start with a random shape and commit to a character
**Transfers to:** Character Design, Composition, Creative Systems
---
## Skill Group 5 — Form Construction
**What it trains:** 3D reasoning and structural drawing. The ability to build objects mentally and draw them from any angle.
**Core skills:**
- Perspective (1-point, 2-point, 3-point)
- Spatial visualization (rotating objects mentally)
- Structural simplification (seeing the primitive inside the complex form)
**Exercises:**
- Box perspective — draw boxes in 1-point, 2-point, and 3-point perspective
- Rotating forms — draw an object from multiple angles without reference
- Cylinder perspective — ellipse foreshortening as angle changes
- Cross contours — draw lines that wrap around a surface to show its form
- Primitive combination — build complex objects from cubes, cylinders, and spheres
- Mechanical breakdown — reduce machinery into its structural primitives
- Slice studies — mentally cross-section a form and draw what you see
**Transfers to:** Character Design, Animation Primitives, Gesture & Motion
---
## Skill Group 6 — Gesture & Motion
**What it trains:** Movement, rhythm, and dynamic posing. Drawing what a figure is *doing* rather than what it looks like.
**Core skills:**
- Motion flow (the single line that captures action)
- Pose readability (the silhouette communicates the action instantly)
- Action clarity (no ambiguity about what is happening)
**Exercises:**
- 30-second gestures — fast capture of movement, ignore detail
- Line of action studies — find and exaggerate the single motion line in a pose
- Rhythm drawing — prioritize flow and connection over structural accuracy
- Animal gestures — dynamic anatomy abstraction from animal movement
- Weight and balance sketches — where is the center of gravity?
- Anticipation poses — the moment before action; how the body loads up
**Transfers to:** Character Design, Animation Primitives
---
## Skill Group 7 — Character Design
**What it trains:** Stylized character creation — readability, appeal, and expressive design.
**Core skills:**
- Expression (the face communicates emotion at a glance)
- Silhouette readability (the character is recognizable as a black shape)
- Appeal (the design invites the viewer in)
- Modular design (heads, bodies, costumes combine and vary consistently)
**Exercises:**
- Expression sheets — explore the full range of emotion for one character
- Head shape variations — how head geometry affects personality perception
- Eye style exploration — different eye systems communicate different aesthetics
- Hairstyle silhouettes — hair as readable shape language
- Costume shape language — clothing personality through geometry
- Chibi simplification — reduce a character to minimal expressive elements
- Character turnarounds — draw one character from multiple angles consistently
**Transfers to:** Animation Primitives, Creative Systems
---
## Skill Group 8 — Animation Primitives
**What it trains:** Motion timing and visual feedback systems. The fundamentals of making things feel alive.
**Core skills:**
- Timing (how many frames does an action take?)
- Spacing (where does the form sit on each frame?)
- Anticipation (loading up before action)
- Impact communication (how does the viewer feel the hit?)
**Exercises:**
- Bounce ball — weight, timing, squash and stretch
- Pendulum motion — weight at the end of a swing
- Blink loops — micro-animation with easing
- Idle breathing — subtle slow looping motion
- Hover motion — floating cycles with organic irregularity
- Hit flash effects — communicating impact through contrast and timing
- Smear frames — motion blur exaggeration for speed
- Spell and slash effects — stylized game-ready impact frames
**Transfers to:** Character Design, Creative Systems
---
## Skill Group 9 — Pixel Art
**What it trains:** Visual clarity under extreme constraints. Every pixel has to earn its place.
**Core skills:**
- Readability at small size (the form reads from a distance)
- Palette control (limited colors used with intention)
- Cluster management (avoiding isolated pixels, building readable shapes)
**Exercises:**
- 16×16 icons — single-concept design at maximum constraint
- 32×32 creatures — character design with limited pixel budget
- Palette limitation challenges — 2-color, 4-color, 8-color restrictions
- Tile set construction — repeatable building blocks that work together
- Cluster theory studies — how pixels group into readable shapes
- Distance readability tests — does it read at 1×, 2×, 4× scale?
**Transfers to:** Vector & UI Design, Shape Language
---
## Skill Group 10 — Vector & UI Design
**What it trains:** Symbolic communication and interface clarity. Drawing systems, not individual pieces.
**Core skills:**
- Vector abstraction (reduce to essential geometry, no noise)
- Visual hierarchy (the eye goes where it should)
- Layout systems (consistent spacing, proportion, and grid logic)
**Exercises:**
- Bézier curve practice — control point placement and handle control
- Icon set construction — design multiple icons with a shared visual language
- UI hierarchy exercises — arrange elements so information priority is clear
- Educational diagram design — make a concept readable without words
- Flowchart beautification — clarity and visual flow in procedural diagrams
- Infographic simplification — compress data into readable visual form
- Constraint layout challenges — design within strict size and color limits
**Transfers to:** Creative Systems, Pixel Art
---
## Skill Group 11 — Observation Training
**What it trains:** Breaking symbolic drawing habits. Seeing what is actually there instead of what you expect to see.
**Core skills:**
- Observation accuracy (drawing what you see, not your idea of it)
- Proportion measurement (relative sizes and distances)
- Visual measurement (angles, negative space, relationships)
**Exercises:**
- Negative space drawing — draw the space around the subject, not the subject
- Upside-down drawing — remove symbolic recognition by flipping the reference
- Blind contour drawing — draw without looking at the paper
- Continuous line drawing — never lift the pen; one connected path
- Angle matching — identify and replicate precise angles in a reference
- Proportion measurement — measure and transfer relative sizes accurately
**Transfers to:** Everything. Observation is a meta-skill that improves every other group.
---
## Skill Group 12 — Style Development
**What it trains:** Artistic identity and stylistic flexibility. Translating between visual languages intentionally.
**Core skills:**
- Stylistic abstraction (choosing what to simplify and what to keep)
- Visual synthesis (combining influences into a coherent voice)
- Adaptation (translating between styles deliberately)
**Exercises:**
- Artist emulation studies — reproduce a reference in a specific artist's style
- Medium translation — photo → pixel art, realistic → stylized, vector → painterly
- Style constraint challenges — draw a subject using rules from a defined aesthetic
- Shape grammar systems — define and apply rules for a consistent personal style
**Transfers to:** Creative Systems, Character Design
---
## Skill Group 13 — Composition
**What it trains:** Visual arrangement and focal hierarchy. Where does the eye go and why?
**Core skills:**
- Scene readability (the main subject is never in doubt)
- Eye guidance (leading lines, value contrast, and framing direct the viewer)
- Value composition (the light/dark map works before detail is added)
**Exercises:**
- Rule of thirds — place subjects and horizons on division lines
- Thumbnail sketches — plan compositions at small scale before committing
- Eye flow mapping — trace the natural path the viewer's eye takes
- Focal point design — create a single dominant area of interest
- Value composition planning — design the light/dark structure before any color
**Transfers to:** Style Development, Character Design, Gesture & Motion
---
## Skill Group 14 — Creative Systems
**What it trains:** Procedural creativity and modular synthesis. Generating ideas within rules rather than from a blank page.
**Core skills:**
- Procedural generation (applying rules to generate variations)
- Rule-based creativity (constraints as a creativity engine, not a limit)
- Modular design thinking (systems of interchangeable parts)
**Exercises:**
- Procedural creature design — combine random body parts using defined rules
- Constraint character challenges — design characters under unusual restrictions
- Random prompt synthesis — turn an arbitrary combination of inputs into a design
- Modular asset systems — design parts that combine into many valid wholes
- Shape grammar construction — define a visual rule set and apply it consistently
**Transfers to:** Character Design, Animation Primitives, Style Development
---
## Notes for Module Design
When building a new ArtLegend module, the starting question is: **which skill from this curriculum is the player building?** A module may target one skill or create a bridge between two adjacent skills. It should not try to teach three things simultaneously.
The exercises listed here are training activities, not game designs. A single exercise can be turned into multiple different game mechanics. Refer to MODULES.md for how specific exercises have been translated into actual game screens.
Skills marked as "foundational" (Line Control, Observation Training) should be prioritized in early module development — players who improve here improve everywhere else faster.

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.

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

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

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 |

161
SPRINT.md Normal file
View File

@@ -0,0 +1,161 @@
# ArtLegend — Current Sprint
**Sprint goal:** Line Corridor MVP. Implement the real scrolling-corridor game from MODULES.md, replace the Sprint 1 prototype, add difficulty tiers, and wire real session data into the Progress screen.
> Sprint 1 built a static "draw one stroke" prototype to validate the architecture. Sprint 2 replaces the game logic with the actual design: a continuously scrolling corridor the player tracks with their stylus, scored on Fill and Containment.
---
## Tickets
### AL-010 — CorridorScore data model [TODO]
Replace the prototype `LineScore` with a score model that matches the Corridor Scroll design from MODULES.md.
- Delete `core/modules/LineScore.kt`
- Create `core/modules/CorridorScore.kt`:
```kotlin
data class CorridorScore(
val fill: Float, // 0..1 — avg coverage of corridor width
val wallPenalties: Int, // raw count of wall crossings
val overall: Float // weighted: fill * 0.7 penalty * 0.05, clamped 0..1
)
```
- Update `GameHost.onSessionComplete` signature: `((CorridorScore) -> Unit)?`
- Update `GameActivity` to pass `fill`, `wallPenalties`, `overall` via Intent extras
- Update `MainActivity.gameResultLauncher` to reconstruct `CorridorScore` from Intent
- Update `SessionEntity`: replace `straightness`/`anglePrecision` fields with `fill: Float` and `wallPenalties: Int`; bump Room schema version to 2 with a destructive migration (dev only — data is test data)
- Update `SessionRepository.save()` to write new fields
- Update `ProgressScreen` `SessionCard` and `SessionBarChart` labels (Overall, Fill, Penalties)
- Update `ProgressViewModel` — no logic change needed
---
### AL-011 — Scrolling corridor engine [TODO]
Rewrite `LineCorridorScreen` to implement the scrolling corridor from MODULES.md.
**Geometry:**
- Corridor scrolls across the screen at a configurable pixel/sec speed
- Corridor is represented as a spine (centerline) moving left-to-right (or at an angle on higher difficulties)
- Player tracks the moving gap by holding their stylus inside it
**Rendering:**
- Two cyan wall lines scrolling across screen
- Visible lookahead: player can see a few screen-widths ahead
- Stylus trail rendered in yellow (pressure-modulated alpha, same as prototype)
- HUD: session timer (top-right), score preview (fill % running avg, penalty count)
**Session lifecycle:**
- Session runs for a fixed duration (`sessionDuration` param, default 30s)
- When time runs out: compute final score, call `onSessionComplete`
- Player must hold stylus the entire time — stylus-up counts as not filling
**Constructor params:**
```kotlin
class LineCorridorScreen(
bridge: StylusInputBridge,
onSessionComplete: (CorridorScore) -> Unit,
corridorWidth: Float = 80f,
scrollSpeed: Float = 180f,
sessionDuration: Float = 30f,
angleChangeEnabled: Boolean = false
)
```
---
### AL-012 — Real-time containment + fill scoring [TODO]
Implement per-frame scoring logic inside `LineCorridorScreen`.
**Per-frame:**
- Compute the corridor bounds at the stylus X position (or nearest corridor segment)
- If stylus is inside bounds: record perpendicular offset → contributes to running fill average
- If stylus crosses a wall: increment `wallPenaltyCount`, enforce a brief visual flash (wall turns red for 0.3s)
- If stylus is up: does not contribute to fill average (neutral, not a penalty)
**At session end:**
- `fill` = mean of per-frame fill samples (each sample = `1 - |offset| / halfWidth`, clamped 0..1)
- `wallPenalties` = total crossing count
- `overall` = `(fill * 0.7 - wallPenalties * 0.05).coerceIn(0f, 1f)`
---
### AL-013 — Difficulty tiers [TODO]
Add selectable difficulty levels that feed into `LineCorridorScreen` params.
**In `shared/`:**
```kotlin
enum class Difficulty { EASY, MEDIUM, HARD }
```
**Difficulty knobs:**
| | EASY | MEDIUM | HARD |
|---|---|---|---|
| Corridor width | 90f | 60f | 38f |
| Scroll speed (px/s) | 140f | 220f | 340f |
| Angle changes | None | Gentle (±15° max) | Frequent (±35° max) |
| Session duration | 30s | 30s | 30s |
**UI — `ModuleDetailScreen`:**
- Add a `Difficulty` selector (three segmented chips: Easy / Medium / Hard) between the module description and the "Start Training" button
- Default: EASY
- Pass selected `Difficulty` as an Intent extra to `GameActivity`
**In `GameActivity`:**
- Read `Difficulty` from Intent, build `LineCorridorScreen` with corresponding params
- Pass `Difficulty.name` through to the score result so it can be stored with the session
**Add `difficulty: String` column to `SessionEntity`** (schema version → 3, destructive migration)
---
### AL-014 — Session summary screen [TODO]
Add a dedicated post-game summary in Compose shown after the LibGDX screen closes, before the user returns to the hub.
**New route in `ArtLegendApp`:** `summary/{moduleType}`
**`SummaryScreen` composable:**
- Score breakdown: Overall %, Fill %, Wall Penalties
- Streak badge: "X-day streak" (see streak logic below)
- Buttons: "Train Again" (relaunch same module/difficulty), "View Progress", "Back to Hub"
**Streak logic (in `ProgressViewModel`):**
- Add `fun streakFor(moduleType: ModuleType): Int` — query sessions ordered by timestamp, count consecutive calendar days ending today that have at least one session
- Expose as `StateFlow<Int>`
**Wire-up:**
- After `gameResultLauncher` receives a result, navigate to `summary/{moduleType}` instead of staying on `ModuleDetailScreen`
- Pass `CorridorScore` to `SummaryScreen` via `ArtLegendApp` state (same pattern as `lastScore` in Sprint 1)
- `ModuleDetailScreen` no longer shows the inline `ScoreCard` — summary screen owns that
---
### AL-015 — Integration smoke test [TODO]
- `./gradlew :android:assembleDebug` passes with no errors
- Hub → Module Detail → difficulty selector renders (Easy selected by default)
- "Start Training" → LibGDX renders scrolling corridor with visible movement
- Stylus held inside corridor: fill HUD increments; crossing a wall: wall flashes red
- Session timer counts down, auto-returns at 0s
- Summary screen shows Fill %, Wall Penalties, Overall %, streak badge
- Progress screen bar chart reflects the session just completed
- All three difficulty tiers launch without crash
---
## Blocked
_Nothing blocked currently._
## Out of Scope This Sprint
- Angle change corridor segments (HARD difficulty — define the knob values but angle changes can ship as a fast-follow if time allows)
- Sound effects / haptic feedback
- Global XP system
- In-app purchases
- Cloud sync

View File

@@ -1,9 +1,13 @@
plugins {
kotlin("android")
id("com.android.application")
id("com.google.devtools.ksp")
}
val gdxVersion: String by project
val composeBom: String by project
val composeCompilerExtension: String by project
val roomVersion: String by project
val natives: Configuration by configurations.creating
android {
@@ -27,6 +31,14 @@ android {
jvmTarget = "17"
}
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = composeCompilerExtension
}
sourceSets {
getByName("main") {
assets.srcDirs(rootProject.file("assets"))
@@ -37,24 +49,54 @@ 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")
// Room
implementation("androidx.room:room-runtime:$roomVersion")
implementation("androidx.room:room-ktx:$roomVersion")
ksp("androidx.room:room-compiler:$roomVersion")
}
tasks.register("copyAndroidNatives") {
doFirst {
val validAbis = setOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64")
natives.files.forEach { jar ->
val outputDir = file("libs")
outputDir.mkdirs()
copy {
from(zipTree(jar))
into(outputDir)
include("*.so")
eachFile { path = name }
includeEmptyDirs = false
// 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))
into(outputDir)
include("*.so")
eachFile { path = name }
includeEmptyDirs = false
}
}
}
}

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,59 @@
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 androidx.lifecycle.lifecycleScope
import com.artlegend.data.ArtLegendDatabase
import com.artlegend.data.SessionRepository
import com.artlegend.modules.LineScore
import com.artlegend.shared.ModuleType
import com.artlegend.ui.ArtLegendApp
import com.artlegend.ui.theme.ArtLegendTheme
import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() {
private var lastScore: LineScore? by mutableStateOf(null)
private var currentModuleType: ModuleType = ModuleType.LINE_CORRIDOR
private val gameResultLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
val data = result.data ?: return@registerForActivityResult
val score = LineScore(
straightness = data.getFloatExtra("straightness", 0f),
anglePrecision = data.getFloatExtra("anglePrecision", 0f),
overall = data.getFloatExtra("overall", 0f)
)
lastScore = score
lifecycleScope.launch {
SessionRepository(ArtLegendDatabase.get(this@MainActivity).sessionDao())
.save(currentModuleType, score)
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
ArtLegendTheme {
ArtLegendApp(
onLaunchModule = { moduleType ->
lastScore = null
currentModuleType = moduleType
val intent = Intent(this, GameActivity::class.java).apply {
putExtra("MODULE_TYPE", moduleType.name)
}
gameResultLauncher.launch(intent)
},
lastScore = lastScore
)
}
}
}
}

View File

@@ -0,0 +1,23 @@
package com.artlegend.data
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
@Database(entities = [SessionEntity::class], version = 1)
abstract class ArtLegendDatabase : RoomDatabase() {
abstract fun sessionDao(): SessionDao
companion object {
@Volatile private var INSTANCE: ArtLegendDatabase? = null
fun get(context: Context): ArtLegendDatabase = INSTANCE ?: synchronized(this) {
INSTANCE ?: Room.databaseBuilder(
context.applicationContext,
ArtLegendDatabase::class.java,
"artlegend.db"
).build().also { INSTANCE = it }
}
}
}

View File

@@ -0,0 +1,15 @@
package com.artlegend.data
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
@Dao
interface SessionDao {
@Insert
suspend fun insert(session: SessionEntity)
@Query("SELECT * FROM sessions WHERE moduleType = :module ORDER BY timestamp DESC LIMIT :limit")
fun recentByModule(module: String, limit: Int): Flow<List<SessionEntity>>
}

View File

@@ -0,0 +1,14 @@
package com.artlegend.data
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "sessions")
data class SessionEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val moduleType: String,
val straightness: Float,
val anglePrecision: Float,
val overall: Float,
val timestamp: Long
)

View File

@@ -0,0 +1,22 @@
package com.artlegend.data
import com.artlegend.modules.LineScore
import com.artlegend.shared.ModuleType
import kotlinx.coroutines.flow.Flow
class SessionRepository(private val dao: SessionDao) {
suspend fun save(moduleType: ModuleType, score: LineScore) {
dao.insert(
SessionEntity(
moduleType = moduleType.name,
straightness = score.straightness,
anglePrecision = score.anglePrecision,
overall = score.overall,
timestamp = System.currentTimeMillis()
)
)
}
fun recentSessions(moduleType: ModuleType, limit: Int = 7): Flow<List<SessionEntity>> =
dao.recentByModule(moduleType.name, limit)
}

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,20 @@
package com.artlegend.ui
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.artlegend.data.ArtLegendDatabase
import com.artlegend.data.SessionEntity
import com.artlegend.data.SessionRepository
import com.artlegend.shared.ModuleType
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
class ProgressViewModel(app: Application) : AndroidViewModel(app) {
private val repo = SessionRepository(ArtLegendDatabase.get(app).sessionDao())
val sessions: StateFlow<List<SessionEntity>> = repo
.recentSessions(ModuleType.LINE_CORRIDOR, limit = 7)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
}

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,190 @@
package com.artlegend.ui.screens
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
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.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.artlegend.data.SessionEntity
import com.artlegend.ui.ProgressViewModel
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ProgressScreen(onBack: () -> Unit) {
val vm: ProgressViewModel = viewModel()
val sessions by vm.sessions.collectAsState()
Scaffold(
topBar = {
TopAppBar(
title = { Text("Progress") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
}
)
}
) { padding ->
if (sessions.isEmpty()) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentAlignment = Alignment.Center
) {
Text(
"Complete a session to see your progress",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
} else {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
item {
Spacer(modifier = Modifier.height(4.dp))
Text("Line Corridor — Last 7 Sessions", style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.height(12.dp))
SessionBarChart(
sessions = sessions,
modifier = Modifier
.fillMaxWidth()
.height(140.dp)
)
Spacer(modifier = Modifier.height(20.dp))
Text("Session History", style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.height(4.dp))
}
items(sessions) { session ->
SessionCard(session)
}
item { Spacer(modifier = Modifier.height(16.dp)) }
}
}
}
}
@Composable
private fun SessionBarChart(sessions: List<SessionEntity>, modifier: Modifier = Modifier) {
val cyan = Color(0xFF00E5FF)
val dimCyan = cyan.copy(alpha = 0.25f)
val ordered = sessions.reversed()
Canvas(modifier = modifier) {
val barCount = 7
val gap = size.width * 0.04f
val barWidth = (size.width - gap * (barCount + 1)) / barCount
val maxBarHeight = size.height - 20.dp.toPx()
val baselineY = size.height - 4.dp.toPx()
repeat(barCount) { i ->
val x = gap + i * (barWidth + gap)
val session = ordered.getOrNull(i)
val fillHeight = if (session != null) (session.overall * maxBarHeight).coerceAtLeast(4.dp.toPx()) else 0f
// ghost bar (slot)
drawRoundRect(
color = dimCyan,
topLeft = Offset(x, baselineY - maxBarHeight),
size = Size(barWidth, maxBarHeight),
cornerRadius = CornerRadius(4.dp.toPx())
)
if (session != null) {
drawRoundRect(
color = cyan,
topLeft = Offset(x, baselineY - fillHeight),
size = Size(barWidth, fillHeight),
cornerRadius = CornerRadius(4.dp.toPx())
)
}
}
// baseline
drawLine(
color = dimCyan.copy(alpha = 0.5f),
start = Offset(0f, baselineY),
end = Offset(size.width, baselineY),
strokeWidth = 1.dp.toPx()
)
}
}
@Composable
private fun SessionCard(session: SessionEntity) {
val dateStr = SimpleDateFormat("MMM d, h:mm a", Locale.getDefault())
.format(Date(session.timestamp))
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(dateStr, style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
SessionStat("Overall", session.overall)
SessionStat("Line", session.straightness)
SessionStat("Angle", session.anglePrecision)
}
}
}
}
@Composable
private fun SessionStat(label: String, value: Float) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = "${(value * 100).toInt()}%",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.secondary
)
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}

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

@@ -1,6 +1,7 @@
buildscript {
val kotlinVersion: String by project
val androidGradleVersion: String by project
val kspVersion: String by project
repositories {
mavenCentral()
@@ -10,6 +11,7 @@ buildscript {
dependencies {
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion")
classpath("com.android.tools.build:gradle:$androidGradleVersion")
classpath("com.google.devtools.ksp:com.google.devtools.ksp.gradle.plugin:$kspVersion")
}
}

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,341 @@
package com.artlegend.modules
import com.artlegend.input.StylusInputBridge
import com.badlogic.gdx.Gdx
import com.badlogic.gdx.Screen
import com.badlogic.gdx.Input
import com.badlogic.gdx.graphics.Color
import com.badlogic.gdx.graphics.GL20
import com.badlogic.gdx.graphics.OrthographicCamera
import com.badlogic.gdx.graphics.g2d.BitmapFont
import com.badlogic.gdx.graphics.g2d.SpriteBatch
import com.badlogic.gdx.graphics.glutils.ShapeRenderer
import kotlin.math.abs
import kotlin.math.sin
import kotlin.math.cos
class LineCorridorScreen(
private val bridge: StylusInputBridge,
private val onSessionComplete: (LineScore) -> Unit
) : Screen {
private lateinit var camera: OrthographicCamera
private lateinit var shapeRenderer: ShapeRenderer
private lateinit var spriteBatch: SpriteBatch
private lateinit var font: BitmapFont
// Level settings
private val sessionDuration = 15f // 15 seconds session
private val speed = 100f // Slightly faster (100 pixels per second)
private val baseHalfWidth = 65f // base half-width of corridor
// State
private var time = 0f
private var sessionDone = false
private var isDrawing = false
private var prevDown = false
private data class Pt(val x: Float, val y: Float, val t: Float, val pressure: Float)
private val strokes = mutableListOf<MutableList<Pt>>()
private var currentStroke = mutableListOf<Pt>()
override fun show() {
camera = OrthographicCamera()
shapeRenderer = ShapeRenderer()
spriteBatch = SpriteBatch()
font = BitmapFont()
font.data.setScale(1.5f) // Make text highly readable
resetSession()
}
private fun resetSession() {
time = 0f
sessionDone = false
isDrawing = false
prevDown = false
strokes.clear()
currentStroke = mutableListOf()
val w = Gdx.graphics.width.toFloat()
val h = Gdx.graphics.height.toFloat()
camera.setToOrtho(false, w, h)
}
private fun getCorridorCenter(y: Float, t: Float): Float {
val w = Gdx.graphics.width.toFloat()
val centerX = w / 2f
val u = y - speed * t
// Composite waves: slow drift + faster wiggles
val kDrift = (2 * Math.PI / 2400f).toFloat() // wavelength 2400
val kWiggle = (2 * Math.PI / 600f).toFloat() // wavelength 600
// Drifts up to 220px from center, wiggles up to 90px around the drift path
return centerX + 220f * sin(kDrift * u) + 90f * cos(kWiggle * u)
}
private fun getHalfWidth(y: Float, t: Float): Float {
val u = y - speed * t
val widthWavelength = 1200f // width changes slower than the wiggles
val kWidth = (2 * Math.PI / widthWavelength).toFloat()
// factor oscillates between 0.5 (half as wide) and 2.0 (2x as wide)
val factor = 1.25f + 0.75f * sin(kWidth * u)
return baseHalfWidth * factor
}
override fun render(delta: Float) {
Gdx.gl.glClearColor(0.05f, 0.05f, 0.08f, 1f)
Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT)
if (!sessionDone) {
time += delta
processInput()
if (time >= sessionDuration) {
sessionDone = true
isDrawing = false
if (currentStroke.size > 1) {
strokes.add(currentStroke)
}
val finalScore = computeScore()
onSessionComplete(finalScore)
}
} else {
// Handle restart on spacebar press or click
if (Gdx.input.isKeyJustPressed(Input.Keys.SPACE) || (Gdx.input.justTouched() && !isDrawing)) {
resetSession()
}
}
camera.update()
shapeRenderer.projectionMatrix = camera.combined
// Draw corridor and path
shapeRenderer.begin(ShapeRenderer.ShapeType.Line)
drawCorridor()
drawStroke()
shapeRenderer.end()
// Draw UI Text Overlay
spriteBatch.projectionMatrix = camera.combined
spriteBatch.begin()
if (!sessionDone) {
val remaining = (sessionDuration - time).coerceAtLeast(0f)
font.draw(spriteBatch, "Time Remaining: %.1fs".format(remaining), 20f, Gdx.graphics.height - 20f)
font.draw(spriteBatch, "Wiggle back-and-forth to fill the space!", 20f, Gdx.graphics.height - 50f)
} else {
val score = computeScore()
val cx = Gdx.graphics.width / 2f
val cy = Gdx.graphics.height / 2f
font.draw(spriteBatch, "SESSION COMPLETE!", cx - 120f, cy + 120f)
font.draw(spriteBatch, "Containment Score: %.0f%%".format(score.straightness * 100f), cx - 150f, cy + 60f)
font.draw(spriteBatch, "Fill Score: %.0f%%".format(score.anglePrecision * 100f), cx - 150f, cy + 20f)
font.draw(spriteBatch, "Overall Score: %.0f%%".format(score.overall * 100f), cx - 150f, cy - 20f)
font.draw(spriteBatch, "Press SPACE or TAP to play again", cx - 180f, cy - 100f)
}
spriteBatch.end()
}
private fun processInput() {
if (sessionDone) return
val frame = bridge.latestFrame
val down: Boolean
val screenX: Float
val screenY: Float
val pressure: Float
if (frame != null) {
down = frame.isDown
screenX = frame.x
screenY = Gdx.graphics.height - frame.y
pressure = frame.pressure
} else {
// Desktop fallback: route mouse coordinates to drawing
down = Gdx.input.isTouched
screenX = Gdx.input.x.toFloat()
screenY = (Gdx.graphics.height - Gdx.input.y).toFloat()
pressure = 1f
}
// DOWN edge
if (down && !prevDown) {
isDrawing = true
currentStroke = mutableListOf()
}
if (isDrawing && down) {
currentStroke.add(Pt(screenX, screenY, time, pressure))
}
// UP edge
if (!down && prevDown && isDrawing) {
isDrawing = false
if (currentStroke.size > 1) {
strokes.add(currentStroke)
}
}
prevDown = down
}
private fun drawCorridor() {
val h = Gdx.graphics.height.toFloat()
// Draw scrolling walls
shapeRenderer.color = Color(0f, 0.85f, 1f, 0.75f)
val step = 10f
var prevLeftX = getCorridorCenter(0f, time) - getHalfWidth(0f, time)
var prevRightX = getCorridorCenter(0f, time) + getHalfWidth(0f, time)
var prevY = 0f
var y = step
while (y <= h) {
val centerX = getCorridorCenter(y, time)
val hw = getHalfWidth(y, time)
val leftX = centerX - hw
val rightX = centerX + hw
shapeRenderer.line(prevLeftX, prevY, leftX, y)
shapeRenderer.line(prevRightX, prevY, rightX, y)
prevLeftX = leftX
prevRightX = rightX
prevY = y
y += step
}
// Draw dotted centerline
shapeRenderer.color = Color(0f, 0.85f, 1f, 0.25f)
prevLeftX = getCorridorCenter(0f, time)
prevY = 0f
y = step
var dashIndex = 0
while (y <= h) {
val centerX = getCorridorCenter(y, time)
if (dashIndex % 2 == 0) {
shapeRenderer.line(prevLeftX, prevY, centerX, y)
}
prevLeftX = centerX
prevY = y
y += step
dashIndex++
}
}
private fun drawStroke() {
// Render past completed strokes
for (stroke in strokes) {
if (stroke.size < 2) continue
for (i in 0 until stroke.size - 1) {
val a = stroke[i]
val b = stroke[i + 1]
val ay = a.y + speed * (time - a.t)
val by = b.y + speed * (time - b.t)
if (ay < Gdx.graphics.height || by < Gdx.graphics.height) {
val alpha = a.pressure.coerceIn(0.15f, 1f)
shapeRenderer.color = Color(1f, 0.85f, 0.25f, alpha)
shapeRenderer.line(a.x, ay, b.x, by)
}
}
}
// Render current active stroke
if (isDrawing && currentStroke.size >= 2) {
for (i in 0 until currentStroke.size - 1) {
val a = currentStroke[i]
val b = currentStroke[i + 1]
val ay = a.y + speed * (time - a.t)
val by = b.y + speed * (time - b.t)
if (ay < Gdx.graphics.height || by < Gdx.graphics.height) {
val alpha = a.pressure.coerceIn(0.15f, 1f)
shapeRenderer.color = Color(1f, 0.85f, 0.25f, alpha)
shapeRenderer.line(a.x, ay, b.x, by)
}
}
}
}
private fun computeScore(): LineScore {
val allPoints = mutableListOf<Pt>()
for (s in strokes) {
allPoints.addAll(s)
}
if (isDrawing) {
allPoints.addAll(currentStroke)
}
if (allPoints.isEmpty()) {
return LineScore(0f, 0f, 0f)
}
// 1. Containment Score (percentage of points drawn inside the corridor)
var insideCount = 0
for (pt in allPoints) {
val xc = getCorridorCenter(pt.y, pt.t)
val hw = getHalfWidth(pt.y, pt.t)
val dist = abs(pt.x - xc)
if (dist <= hw) {
insideCount++
}
}
val containment = insideCount.toFloat() / allPoints.size
// 2. Fill Score (evaluate local horizontal coverage inside bins)
val binSize = 30f
val uValues = allPoints.map { it.y - speed * it.t }
val minU = uValues.minOrNull() ?: 0f
val maxU = uValues.maxOrNull() ?: 0f
val spanU = maxU - minU
val binMap = mutableMapOf<Int, MutableList<Float>>()
for (pt in allPoints) {
val u = pt.y - speed * pt.t
val binIndex = (u / binSize).toInt()
val xc = getCorridorCenter(pt.y, pt.t)
val localX = pt.x - xc
binMap.getOrPut(binIndex) { mutableListOf() }.add(localX)
}
var totalBinScore = 0f
var scoredBins = 0
for ((binIndex, wList) in binMap) {
val u = binIndex * binSize
val hw = getHalfWidth(u, 0f)
val maxW = wList.maxOrNull() ?: 0f
val minW = wList.minOrNull() ?: 0f
val spread = (maxW - minW)
val binFill = (spread / (2 * hw)).coerceAtMost(1f)
totalBinScore += binFill
scoredBins++
}
var fill = if (scoredBins > 0) totalBinScore / scoredBins else 0f
// Scale down if player drew over a very short section of the corridor
val lengthScale = (spanU / 300f).coerceIn(0f, 1f)
fill *= lengthScale
val overall = containment * 0.5f + fill * 0.5f
return LineScore(containment, fill, overall)
}
override fun resize(width: Int, height: Int) {
camera.setToOrtho(false, width.toFloat(), height.toFloat())
}
override fun pause() {}
override fun resume() {}
override fun hide() {}
override fun dispose() {
shapeRenderer.dispose()
spriteBatch.dispose()
font.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

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

View File

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

View File

@@ -4,3 +4,7 @@ android.useAndroidX=true
gdxVersion=1.12.1
kotlinVersion=1.9.23
androidGradleVersion=8.3.0
composeBom=2024.09.00
composeCompilerExtension=1.5.12
kspVersion=1.9.23-1.0.20
roomVersion=2.6.1

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
}