Compare commits
4 Commits
24d78786db
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 3ed24cf8be | |||
| 2b208aff7e | |||
| d4c48ed393 | |||
| ac81ec51f4 |
14
.gitignore
vendored
Normal file
14
.gitignore
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
# Build outputs
|
||||
build/
|
||||
*/build/
|
||||
.gradle/
|
||||
|
||||
# Local config
|
||||
local.properties
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
*.iml
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
367
CURRICULUM.md
Normal file
367
CURRICULUM.md
Normal 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
125
HANDOFF_2026-05-05.md
Normal file
@@ -0,0 +1,125 @@
|
||||
# Agent Handoff — May 5, 2026
|
||||
|
||||
## Session Summary
|
||||
|
||||
Full rearchitecture from pure LibGDX to hybrid Compose + LibGDX. Sprint 0 (foundation) and Sprint 1 (Line Corridor MVP) both completed in this session. APK builds successfully.
|
||||
|
||||
---
|
||||
|
||||
## What Was Accomplished
|
||||
|
||||
### Architecture (Sprint 0 — all done)
|
||||
- Created `shared/` pure-Kotlin module containing `ModuleType` enum (LINE_CORRIDOR, PRESSURE_RAMP, CURVE_SCULPTOR, VECTOR_SNAP)
|
||||
- Created `core/GameHost.kt` — routes by ModuleType, exposes `onSessionComplete` callback
|
||||
- Created `core/input/StylusFrame.kt` + `StylusInputBridge.kt` — AtomicReference bridge, GL-thread safe
|
||||
- Created `core/modules/LineCorridorScreen.kt` + `LineScore.kt`
|
||||
- Created `core/PlaceholderModuleScreen.kt`
|
||||
- Created `android/MainActivity.kt` — ComponentActivity + Compose
|
||||
- Created `android/GameActivity.kt` — AndroidApplication, reads ModuleType from Intent, sets OnTouchListener on LibGDX view
|
||||
- Created full Compose navigation: `ArtLegendApp`, `HubScreen`, `ModuleDetailScreen`, `ProgressScreen`, `SettingsScreen`
|
||||
- Created dark Material3 theme (violet #7B2FBE / cyan #00E5FF)
|
||||
- Updated `AndroidManifest.xml`: MainActivity as LAUNCHER, GameActivity as landscape fullscreen non-exported
|
||||
- Deleted orphaned `AndroidLauncher.kt`, `ArtLegend.kt`, `styles.xml`
|
||||
|
||||
### Line Corridor (Sprint 1 — all done)
|
||||
- `LineCorridorScreen` renders two cyan corridor walls + dashed centerline at random ±25° angle via ShapeRenderer
|
||||
- Stroke captured live from StylusInputBridge, rendered in yellow with pressure-driven alpha
|
||||
- `isDown: Boolean` added to `StylusFrame`; DOWN/UP edge detection for clean session start/stop
|
||||
- Android Y flipped to LibGDX coordinate space in `processInput()`
|
||||
- `LineScore` computed: straightness (RMS perpendicular deviation) + pressureConsistency (RMS from 0.5)
|
||||
- Full result pipeline: GL thread → `runOnUiThread { setResult + finish() }` → `ActivityResultLauncher` → `lastScore` Compose state → `ScoreCard` on `ModuleDetailScreen`
|
||||
- "Start Training" button changes to "Train Again" after first session
|
||||
|
||||
### Build environment fixed
|
||||
- Added `gradlew` script (copied from whisper.cpp) + `gradle-wrapper.jar`
|
||||
- Added Foojay toolchain resolver plugin to `settings.gradle.kts` (auto-downloads JDK 17)
|
||||
- Installed Android SDK via `brew install --cask android-commandlinetools`
|
||||
- SDK root: `/opt/homebrew/share/android-commandlinetools`
|
||||
- Installed: `platforms;android-34`, `build-tools;34.0.0`, `platform-tools`
|
||||
- `local.properties` created: `sdk.dir=/opt/homebrew/share/android-commandlinetools`
|
||||
- Fixed `copyAndroidNatives` task — now extracts each `.so` into its ABI subdirectory (`libs/arm64-v8a/`, etc.)
|
||||
- Symlink at `/opt/homebrew/opt/jbr-home` → IntelliJ JBR (used for sdkmanager)
|
||||
|
||||
### Build command (verified working)
|
||||
```bash
|
||||
cd /Users/blindmouse/artlegend
|
||||
JAVA_HOME="/Users/blindmouse/Applications/IntelliJ IDEA.app/Contents/jbr/Contents/Home" ./gradlew :android:assembleDebug
|
||||
```
|
||||
APK output: `android/build/outputs/apk/debug/android-debug.apk`
|
||||
|
||||
---
|
||||
|
||||
## Current State
|
||||
|
||||
**SPRINT.md tickets:**
|
||||
- AL-001 through AL-008: DONE
|
||||
- AL-009 (on-device smoke test): not done — device needed
|
||||
|
||||
**Three deprecation warnings** (non-blocking, cosmetic):
|
||||
- `Icons.Default.ArrowBack` deprecated → use `Icons.AutoMirrored.Filled.ArrowBack`
|
||||
- Affects: `ModuleDetailScreen.kt`, `ProgressScreen.kt`, `SettingsScreen.kt`
|
||||
|
||||
---
|
||||
|
||||
## Next Steps (Priority Order)
|
||||
|
||||
### Immediate
|
||||
1. **AL-009 on-device test** — install APK, verify:
|
||||
- Hub shows 4 module cards
|
||||
- Tap → ModuleDetail → "Start Training" → LibGDX corridor renders
|
||||
- S-Pen stroke captured, score returned on finish
|
||||
- Check logcat: `adb logcat -s LineCorridor GameActivity`
|
||||
|
||||
2. **Fix ArrowBack deprecation** (3 files, 1-line change each):
|
||||
```kotlin
|
||||
// Old
|
||||
Icons.Default.ArrowBack
|
||||
// New
|
||||
Icons.AutoMirrored.Filled.ArrowBack
|
||||
// Also add import:
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
```
|
||||
|
||||
### Phase 1 (ROADMAP)
|
||||
3. Corridor difficulty progression (narrowing half-width per session)
|
||||
4. Session summary screen (score + streak counter)
|
||||
5. Local score persistence (DataStore or Room)
|
||||
6. Progress screen populated with real data (last 7 sessions chart)
|
||||
|
||||
### Phase 2
|
||||
7. Pressure Ramp module
|
||||
|
||||
---
|
||||
|
||||
## Key File Locations
|
||||
|
||||
```
|
||||
artlegend/
|
||||
shared/src/main/kotlin/com/artlegend/shared/ModuleType.kt
|
||||
core/src/main/kotlin/com/artlegend/
|
||||
GameHost.kt
|
||||
PlaceholderModuleScreen.kt
|
||||
input/StylusFrame.kt
|
||||
input/StylusInputBridge.kt
|
||||
modules/LineCorridorScreen.kt
|
||||
modules/LineScore.kt
|
||||
android/src/main/kotlin/com/artlegend/
|
||||
MainActivity.kt
|
||||
GameActivity.kt
|
||||
ui/ArtLegendApp.kt
|
||||
ui/theme/ArtLegendTheme.kt
|
||||
ui/screens/{Hub,ModuleDetail,Progress,Settings}Screen.kt
|
||||
android/src/main/res/values/themes.xml
|
||||
android/src/main/AndroidManifest.xml
|
||||
local.properties ← sdk.dir, gitignored
|
||||
settings.gradle.kts ← includes foojay toolchain resolver
|
||||
SPRINT.md, ROADMAP.md, README.md
|
||||
```
|
||||
|
||||
## Gotchas for Next Agent
|
||||
|
||||
- **sdkmanager requires** `JAVA_HOME=/opt/homebrew/opt/jbr-home SKIP_JDK_VERSION_CHECK=1` — the JBR version string breaks the integer test in the wrapper script
|
||||
- **StylusInputBridge** is last-write-wins (AtomicReference.set), not a queue — fine for 60fps render but means missed frames between renders are dropped
|
||||
- **Y-coordinate flip**: StylusFrame stores raw Android Y (top=0); LineCorridorScreen flips it to LibGDX space (bottom=0) in `processInput()`
|
||||
- **GameActivity.onSessionComplete** is called on the GL thread — always `runOnUiThread {}` before `setResult/finish`
|
||||
- The `natives-desktop` JAR is skipped by `copyAndroidNatives` (not in the `validAbis` set) — this is intentional
|
||||
77
HANDOFF_2026-05-07.md
Normal file
77
HANDOFF_2026-05-07.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# Agent Handoff — May 7, 2026
|
||||
|
||||
## Session Summary
|
||||
|
||||
This session was design-focused, not implementation-focused. Two things were accomplished:
|
||||
|
||||
1. The Corridor Scroll module mechanics were fully described and documented.
|
||||
2. The Vector Drawing module design conversation was started but not finished — bill had to leave mid-discussion.
|
||||
|
||||
---
|
||||
|
||||
## What Was Accomplished
|
||||
|
||||
### Documentation
|
||||
|
||||
- Created `MODULES.md` — the authoritative module design reference. This file exists specifically because design decisions were being re-explained from scratch each session. Keep it updated.
|
||||
- Updated `README.md` — module list now reflects Corridor Scroll (not "Line Corridor"), added Stroke-to-Drawing, updated the vision paragraph to describe the scroll mechanic properly, and linked to MODULES.md.
|
||||
|
||||
### Corridor Scroll — design is settled
|
||||
|
||||
Full mechanics documented in `MODULES.md`. Summary:
|
||||
- Guitar Hero-style scrolling corridor, player tracks it with the stylus
|
||||
- Scroll direction angle changes over time — full 360° range at high difficulty, no angle changes at low difficulty
|
||||
- Difficulty knobs: scroll speed, corridor width, angle change frequency/severity, lookahead distance
|
||||
- Scoring: running tally of fill coverage (rewarded) + wall crossings (penalized). No pressure component.
|
||||
- Early levels: straight corridor only. Later levels: any angle including full reversals.
|
||||
|
||||
### Code change from this session
|
||||
|
||||
`LineScore` was updated to replace `pressureConsistency` with `anglePrecision` (straightness 60%, angle 40%). This was done before the full corridor scroll mechanic was described — **this scoring model is now likely wrong for the module as designed.** The corridor scroll is real-time tracking, not a single-stroke grade. The scoring architecture will need to be rethought when implementation resumes. Don't build on top of the current `LineScore`/`computeScore()` without revisiting it first.
|
||||
|
||||
---
|
||||
|
||||
## In Progress: Vector Drawing Module Design
|
||||
|
||||
This conversation started but was not finished. Here is where it was left.
|
||||
|
||||
### What was proposed (by the agent — not yet agreed with bill)
|
||||
|
||||
Three sub-exercises, each isolating a different aspect of vector tool use:
|
||||
|
||||
1. **Anchor placement** — target curve shown, player repositions pre-suggested anchor points. Teaches where joints belong (corners, inflection points, extrema).
|
||||
2. **Handle pulling** — anchors pre-placed, player drags handles to shape the curve against a target. Teaches handle angle/length intuition.
|
||||
3. **Full trace** — player places anchors and pulls handles from scratch. Scored on fidelity to target AND anchor economy (fewer anchors = better, if fidelity holds).
|
||||
|
||||
Difficulty progression proposed:
|
||||
- Easy: target visible while working, simple open curves, loose tolerance, no economy pressure.
|
||||
- Hard: target shown briefly then hidden, complex closed shapes, tight tolerance, economy weighted heavily.
|
||||
|
||||
### Open questions — bill had not yet answered these
|
||||
|
||||
These need to be discussed before MODULES.md is updated with the vector module design:
|
||||
|
||||
1. **Simulate a specific tool or abstract it?** Should exercises feel like practicing for Illustrator's pen tool specifically (with its handle-breaking behavior etc.), or be a simplified universal bezier model that transfers to any app?
|
||||
|
||||
2. **Anchor economy in scoring?** Good habit to teach, but may frustrate beginners. Should economy only factor in at higher difficulty, or always be part of the score?
|
||||
|
||||
3. **Target content** — abstract curves and blobs, or recognizable things like letterforms and simple character shapes? Recognizable is more motivating but harder to generate procedurally.
|
||||
|
||||
4. **Target visibility** — should seeing the target while you trace always be available as a beginner/accessibility option, or does the module intentionally remove it as difficulty increases?
|
||||
|
||||
5. **Does pressure matter here?** Real vector tools ignore pressure. But this is an S-Pen trainer. Should the module ignore pressure entirely, or is there a creative angle (e.g. calligraphic vector mode where handle length is pressure-driven)?
|
||||
|
||||
### Where to pick up
|
||||
|
||||
Resume by getting bill's answers to the five questions above. Once agreed, write the Vector Drawing section in `MODULES.md` using the same format as the Corridor Scroll section. Do not write any implementation code until the design is settled and documented.
|
||||
|
||||
---
|
||||
|
||||
## Broader Reminders for Next Agent
|
||||
|
||||
- **Design before code.** Bill has explicitly said assumptions get made when mechanics aren't written out. When something is ambiguous, discuss and document first.
|
||||
- **Keep MODULES.md updated.** It exists to prevent re-explanation. Every settled design decision goes there.
|
||||
- **The current `LineCorridorScreen` and `LineScore` are placeholders**, not the real implementation of Corridor Scroll. The real mechanic (scrolling corridor, real-time tracking) is fundamentally different from what's coded (static corridor, single stroke scored on lift).
|
||||
- Build command: `JAVA_HOME="/Users/blindmouse/Applications/IntelliJ IDEA.app/Contents/jbr/Contents/Home" ./gradlew :android:assembleDebug`
|
||||
- APK output: `android/build/outputs/apk/debug/android-debug.apk`
|
||||
- AL-009 (on-device smoke test) is still the only open sprint ticket — needs a physical device.
|
||||
71
HANDOFF_2026-05-08.md
Normal file
71
HANDOFF_2026-05-08.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# Agent Handoff — May 8, 2026
|
||||
|
||||
## Session Summary
|
||||
|
||||
Short session. Two things happened:
|
||||
|
||||
1. Build infrastructure unblocked — JDK 17 installed, APK compiles cleanly.
|
||||
2. A `LineScore` field mismatch (stale `pressureConsistency` references) was caught by the compiler and fixed.
|
||||
|
||||
AL-009 (on-device smoke test) is unfinished — build passes but the physical device walkthrough hasn't happened yet.
|
||||
|
||||
---
|
||||
|
||||
## What Was Accomplished
|
||||
|
||||
### Build infrastructure
|
||||
|
||||
OpenJDK 17 installed via Homebrew (`/opt/homebrew/opt/openjdk@17`). The system Java wrappers symlink was **not** set up (requires sudo from a local terminal). Use the explicit JAVA_HOME form to build from the CLI:
|
||||
|
||||
```
|
||||
JAVA_HOME=/opt/homebrew/opt/openjdk@17 PATH="/opt/homebrew/opt/openjdk@17/bin:$PATH" ./gradlew :android:assembleDebug
|
||||
```
|
||||
|
||||
From IntelliJ (on the device machine) the JBR path from the previous handoff still applies:
|
||||
|
||||
```
|
||||
JAVA_HOME="/Users/blindmouse/Applications/IntelliJ IDEA.app/Contents/jbr/Contents/Home" ./gradlew :android:assembleDebug
|
||||
```
|
||||
|
||||
BUILD SUCCESSFUL — APK at `android/build/outputs/apk/debug/android-debug.apk`.
|
||||
|
||||
Three harmless deprecation warnings remain (`Icons.ArrowBack` → `Icons.AutoMirrored.Filled.ArrowBack`). Not worth fixing now.
|
||||
|
||||
### LineScore field mismatch fixed
|
||||
|
||||
`GameActivity.kt` and `MainActivity.kt` were still referencing `score.pressureConsistency` and passing `"pressureConsistency"` as an Intent extra — left over from before `LineScore` was redesigned. Fixed both to use `anglePrecision`.
|
||||
|
||||
---
|
||||
|
||||
## Still Open: AL-009 On-Device Smoke Test
|
||||
|
||||
Bill is SSH'd in and doesn't have a device attached to this machine. He'll pull from Gitea in a separate session with IntelliJ + S-Pen. Steps remaining:
|
||||
|
||||
- `./gradlew :android:installDebug` (device must be connected via ADB)
|
||||
- Launch app → confirm Compose hub shows 4 module cards
|
||||
- Tap card → ModuleDetail with "Start Training"
|
||||
- Tap "Start Training" → LibGDX dark background + cyan corridor renders
|
||||
- Stylus pressure values visible in logcat
|
||||
- Complete stroke → score returned to ScoreCard on ModuleDetail
|
||||
|
||||
Once all steps pass, mark AL-009 DONE and close Sprint 1.
|
||||
|
||||
---
|
||||
|
||||
## Still Open: Vector Drawing Module Design
|
||||
|
||||
Five questions from the May 7 handoff are still unanswered. Do not write MODULES.md entries or any implementation for the vector module until bill answers them. See HANDOFF_2026-05-07.md for the full question list.
|
||||
|
||||
---
|
||||
|
||||
## Design Philosophy Note
|
||||
|
||||
Bill pushed back on framing ArtLegend as a platform for people who "fail" at art. His view: art is expression, there is no failure state. ArtLegend teaches **rendering** specifically — it builds the physical vocabulary that lets artists execute what they imagine. Confident rendering lowers the activation energy for creative risk. Don't frame the app around deficits.
|
||||
|
||||
---
|
||||
|
||||
## Broader Reminders
|
||||
|
||||
- **Design before code.** When mechanics are ambiguous, discuss and document in MODULES.md first.
|
||||
- **The current `LineCorridorScreen` and `LineScore` are placeholders.** The real Corridor Scroll is a real-time scrolling tracker, not a single-stroke scorer. Don't build on top of the current scoring without revisiting it.
|
||||
- **Keep MODULES.md updated.** It exists to prevent re-explanation each session.
|
||||
43
HANDOFF_2026-06-19.md
Normal file
43
HANDOFF_2026-06-19.md
Normal 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
116
MODULES.md
Normal file
@@ -0,0 +1,116 @@
|
||||
# ArtLegend — Module Design Reference
|
||||
|
||||
This document is the authoritative description of each training module: what it teaches, how it plays, and what variables control difficulty. Update this whenever a module's design changes. Its purpose is to prevent design decisions from being re-explained from scratch each session.
|
||||
|
||||
---
|
||||
|
||||
## Module: Corridor Scroll
|
||||
|
||||
**Skill trained:** Sustained stroke control — staying within a moving corridor, filling available space confidently, and tracking direction changes.
|
||||
|
||||
### Concept
|
||||
|
||||
A corridor (two parallel lines with a gap between them) scrolls across the screen continuously. The player holds the S-Pen inside the corridor as it moves. Think Guitar Hero, but instead of hitting notes at the right moment, the player is maintaining precise stylus position inside a moving channel.
|
||||
|
||||
The corridor's **scroll direction can change angle** over time — the player must follow smooth or abrupt directional shifts while keeping their stroke inside the walls. Eventually the corridor can scroll from any direction: top, bottom, left, right, or any diagonal. Early levels keep the scroll direction fixed; later levels introduce angle changes of increasing frequency and severity.
|
||||
|
||||
### What the player is scored on
|
||||
|
||||
Scoring is a running tally across the session:
|
||||
|
||||
| Component | Good outcome | Bad outcome |
|
||||
|-----------|-------------|-------------|
|
||||
| **Fill** | Stroke covers a large proportion of the corridor width | Stroke is too thin/timid — space left unused |
|
||||
| **Containment** | Stroke stays inside both walls | Stroke crosses a wall — penalty applied |
|
||||
|
||||
There is no pressure consistency requirement in this module. The goal is spatial accuracy and confident use of the available space, not stylus pressure.
|
||||
|
||||
### Difficulty variables
|
||||
|
||||
All of these are independent knobs that can be tuned per difficulty tier:
|
||||
|
||||
| Variable | Easy | Hard |
|
||||
|----------|------|------|
|
||||
| **Scroll speed** | Slow | Fast |
|
||||
| **Corridor width** | Wide | Narrow |
|
||||
| **Angle change frequency** | None (straight) | Frequent |
|
||||
| **Angle change severity** | — | Up to full 360° range |
|
||||
| **Lookahead distance** | Generous (player can see far ahead) | Short (more reactive) |
|
||||
|
||||
### Angle design notes
|
||||
|
||||
- Early difficulty levels should have **no angle changes** — straight corridor only.
|
||||
- As difficulty increases, angle changes are introduced: first gentle curves, then sharper bends.
|
||||
- There is no artificial cap on angle range. The corridor can scroll in any direction including full direction reversals. Players need to handle all angles because later modules involve drawing at all angles.
|
||||
- The player's stroke within the corridor can itself be at an angle relative to the corridor walls — this is valid and intentional. Filling the corridor width at an angle is still good fill coverage.
|
||||
|
||||
### Session structure
|
||||
|
||||
- Session runs for a fixed duration or until a fixed length of corridor has passed.
|
||||
- Score is displayed at end of session as: Overall %, Fill %, Wall Penalty count (or %).
|
||||
- Difficulty progresses between sessions (corridor narrows, speed increases, angle changes introduced).
|
||||
|
||||
---
|
||||
|
||||
## Module: Stroke-to-Drawing
|
||||
|
||||
**Skill trained:** Guided stroke execution — strokes the player makes during a session are recorded and compose a recognizable drawing at the end.
|
||||
|
||||
### Concept
|
||||
|
||||
The player follows guided stroke prompts and their actual stylus input is retained. At the end of the session the accumulated strokes form a picture. This module teaches the player to execute intentional marks with confidence, knowing each stroke is permanent and contributes to an image.
|
||||
|
||||
### Design notes
|
||||
|
||||
- The Guitar Hero scroll mechanic is **not used** here — the player is making deliberate, placed strokes, not tracking a moving corridor.
|
||||
- Scoring is TBD — likely based on stroke accuracy relative to a guide path, with pressure and angle components relevant (unlike Corridor Scroll).
|
||||
- This module is planned for a later phase after Corridor Scroll is solid.
|
||||
|
||||
---
|
||||
|
||||
## Module: Pressure Ramp
|
||||
|
||||
**Skill trained:** Dynamic stroke weight control — matching a target pressure curve over the length of a stroke.
|
||||
|
||||
### Design notes
|
||||
|
||||
- Player draws a stroke while matching a displayed target pressure profile.
|
||||
- Scored via DTW (Dynamic Time Warping) between actual and target pressure curve.
|
||||
- Difficulty tiers: linear ramp → S-curve → arbitrary profile.
|
||||
- Planned for Phase 2.
|
||||
|
||||
---
|
||||
|
||||
## Module: Curve Sculptor
|
||||
|
||||
**Skill trained:** Smooth bezier curve shaping.
|
||||
|
||||
### Design notes
|
||||
|
||||
- Player strokes follow a target bezier curve.
|
||||
- Scored on how closely the stroke follows the curve path.
|
||||
- Optional control point hints at lower difficulty.
|
||||
- Planned for Phase 3.
|
||||
|
||||
---
|
||||
|
||||
## Module: Vector Snap
|
||||
|
||||
**Skill trained:** Precision endpoint targeting.
|
||||
|
||||
### Design notes
|
||||
|
||||
- Random endpoint targets displayed; player taps each as accurately as possible.
|
||||
- Scored on distance from target; speed bonus for fast accurate taps.
|
||||
- Planned for Phase 3.
|
||||
|
||||
---
|
||||
|
||||
## Notes on future modules
|
||||
|
||||
Additional module types discussed but not yet designed:
|
||||
|
||||
- **Gradient fill** — fill a shape with a smooth pressure gradient; teaches pressure ramp within a spatial boundary.
|
||||
- **Vector drawing tool practice** — precision exercises that map directly to using vector drawing tools in professional apps.
|
||||
|
||||
These are later-phase ideas. No implementation decisions have been made for them.
|
||||
134
README.md
Normal file
134
README.md
Normal file
@@ -0,0 +1,134 @@
|
||||
# ArtLegend
|
||||
|
||||
**S-Pen mastery training for digital artists — gamified, modular, precision-first.**
|
||||
|
||||
ArtLegend is an Android training app that turns S-Pen skill-building into a structured game. Each module isolates one drawing skill (pressure control, line confidence, curve accuracy, vector precision) and drills it with real-time feedback, scoring, and progressive difficulty.
|
||||
|
||||
---
|
||||
|
||||
## Vision
|
||||
|
||||
Most artists know *what* they want to draw but lack the muscle memory to execute it. ArtLegend bridges that gap by making practice deliberate, measurable, and satisfying.
|
||||
|
||||
Each module isolates a specific physical skill. The first is **Corridor Scroll** — a Guitar Hero-style scrolling corridor the player must track with their stylus, training spatial accuracy and confident stroke coverage across all angles and speeds. Later modules build toward exercises where the player's accumulated strokes form actual drawings, so the skills trained are directly transferable to real artwork.
|
||||
|
||||
The long-term goal: a modular curriculum where artists buy skill packs, track their growth over weeks, and build real hand-eye coordination for professional digital work.
|
||||
|
||||
---
|
||||
|
||||
## Module List
|
||||
|
||||
See [MODULES.md](MODULES.md) for full gameplay design, scoring rules, and difficulty variables for each module.
|
||||
|
||||
| Module | Skill Trained | Status |
|
||||
|--------|--------------|--------|
|
||||
| Corridor Scroll | Sustained stroke control inside a scrolling, direction-changing corridor | In Progress |
|
||||
| Stroke-to-Drawing | Guided strokes that compose a picture; teaches deliberate mark-making | Planned |
|
||||
| Pressure Ramp | Dynamic stroke weight control against a target pressure curve | Planned |
|
||||
| Curve Sculptor | Smooth bezier curve shaping | Planned |
|
||||
| Vector Snap | Precision endpoint targeting | Planned |
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
MainActivity (ComponentActivity + Compose)
|
||||
└── Navigation: Hub → ModuleDetail → Progress → Settings
|
||||
|
|
||||
| Intent (puts ModuleType enum name)
|
||||
v
|
||||
GameActivity (AndroidApplication / LibGDX)
|
||||
└── GameHost : Game() → routes to correct Screen by ModuleType
|
||||
└── LineCorridorScreen (first real module)
|
||||
└── StylusInputBridge (AtomicReference — bridges UI thread → GL thread)
|
||||
```
|
||||
|
||||
### Key Design Decisions
|
||||
|
||||
- **Compose for navigation/UI** — Hub, progress, settings, and module-detail screens benefit from standard Android UI patterns (adaptive layout, accessibility, back stack).
|
||||
- **LibGDX for game rendering** — Frame-accurate rendering, direct OpenGL access, and fine-grained input timing for each training module.
|
||||
- **Separate Activities** — Clean boundary: `MainActivity` owns Compose state; `GameActivity` owns the LibGDX lifecycle. Intent extras carry the `ModuleType`.
|
||||
- **`shared/` module** — Zero-dependency pure-Kotlin module that holds the `ModuleType` enum. Both Compose and LibGDX layers depend on it; neither depends on the other.
|
||||
- **StylusInputBridge** — `AtomicReference<StylusFrame?>` lets the UI thread post stylus data (pressure, tilt, orientation) without locking the GL thread.
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|-------|-----------|
|
||||
| UI / Navigation | Compose Multiplatform, Material3, Navigation Compose |
|
||||
| Game rendering | LibGDX 1.12.1 (OpenGL ES) |
|
||||
| Language | Kotlin 1.9.23 |
|
||||
| Build | Gradle 8.x (Kotlin DSL) |
|
||||
| Min SDK | 26 (Android 8.0) |
|
||||
| Target SDK | 34 (Android 14) |
|
||||
| Primary device | Samsung Galaxy Tab S-series with S-Pen |
|
||||
|
||||
---
|
||||
|
||||
## Build Instructions
|
||||
|
||||
### Prerequisites
|
||||
- Android Studio Hedgehog or later
|
||||
- Android SDK 34
|
||||
- JDK 17
|
||||
|
||||
### Build debug APK
|
||||
```bash
|
||||
./gradlew :android:assembleDebug
|
||||
```
|
||||
|
||||
### Install on device
|
||||
```bash
|
||||
./gradlew :android:installDebug
|
||||
```
|
||||
|
||||
### Verify compilation
|
||||
```bash
|
||||
./gradlew :shared:compileKotlin
|
||||
./gradlew :core:compileKotlin
|
||||
./gradlew :android:compileDebugKotlin
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Stylus Setup
|
||||
|
||||
ArtLegend reads S-Pen input via Android's `MotionEvent` API in `GameActivity`:
|
||||
|
||||
```kotlin
|
||||
// Captured per frame in GameActivity.handleStylusEvent()
|
||||
val pressure = event.pressure // 0.0 – 1.0
|
||||
val tiltRad = event.getAxisValue(MotionEvent.AXIS_TILT)
|
||||
val orientRad = event.getAxisValue(MotionEvent.AXIS_ORIENTATION)
|
||||
val isStylus = event.getToolType(0) == TOOL_TYPE_STYLUS
|
||||
```
|
||||
|
||||
The `StylusInputBridge` (an `AtomicReference<StylusFrame?>`) carries this data thread-safely into the LibGDX GL thread where each module's `render()` can consume it without locking.
|
||||
|
||||
The bridge posts every touch event but does **not** queue them — only the latest frame is visible to the GL thread. This matches the rendering cadence (60 fps) without unbounded buffering.
|
||||
|
||||
---
|
||||
|
||||
## How to Add a New Module
|
||||
|
||||
1. Add an entry to `ModuleType` in `shared/src/main/kotlin/com/artlegend/shared/ModuleType.kt`
|
||||
2. Create `core/src/main/kotlin/com/artlegend/modules/YourModuleScreen.kt` implementing `Screen`
|
||||
3. Add a `when` branch in `GameHost.create()` routing to your new screen
|
||||
4. Add a `ModuleCard` entry in `HubScreen.kt` (title + description)
|
||||
5. Add a description string in `ModuleDetailScreen.kt`'s `when` block
|
||||
|
||||
---
|
||||
|
||||
## Business / Licensing Model
|
||||
|
||||
ArtLegend is designed around a modular content model:
|
||||
|
||||
- **Free core**: Line Corridor + basic progress tracking
|
||||
- **Skill packs** (one-time purchase): Additional modules sold individually or as bundles (e.g., "Ink Fundamentals Pack" = Pressure Ramp + Curve Sculptor)
|
||||
- **Pro subscription**: Unlocks all current + future modules, cloud sync, detailed analytics
|
||||
- **Institutional licensing**: Studio / school licenses for group training dashboards
|
||||
|
||||
The `ModuleType` enum is the extension point — new modules ship as app updates and are unlocked via in-app purchase entitlements checked before launching `GameActivity`.
|
||||
123
ROADMAP.md
Normal file
123
ROADMAP.md
Normal file
@@ -0,0 +1,123 @@
|
||||
# ArtLegend Roadmap
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 — Foundation (current)
|
||||
|
||||
**Goal:** Hybrid architecture running end-to-end. Compose hub launches LibGDX game screen with stylus data flowing.
|
||||
|
||||
### Milestones
|
||||
- [x] LibGDX scaffold (core + android modules)
|
||||
- [x] `shared/` module with `ModuleType` enum
|
||||
- [x] `GameHost` routing by `ModuleType`
|
||||
- [x] `StylusInputBridge` (AtomicReference, thread-safe)
|
||||
- [x] `StylusFrame` data class (x, y, pressure, tiltRad, orientRad, isStylus)
|
||||
- [x] `MainActivity` + Compose navigation (Hub → Detail → Game)
|
||||
- [x] `GameActivity` reads `ModuleType` from Intent, posts stylus frames
|
||||
- [x] `LineCorridorScreen` renders guided corridor + real-time stroke
|
||||
- [x] Score calculation for Line Corridor (straightness + pressure consistency)
|
||||
- [x] Score displayed on return to ModuleDetail (ScoreCard)
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Line Corridor MVP
|
||||
|
||||
**Goal:** Shippable first module. Players can train, score, and see improvement over sessions.
|
||||
|
||||
### Milestones
|
||||
- [ ] Corridor generation (random angle, configurable width)
|
||||
- [ ] Stroke capture and rendering (LibGDX ShapeRenderer)
|
||||
- [ ] Straightness score algorithm (RMS deviation from ideal line)
|
||||
- [ ] Pressure consistency score (variance from target pressure profile)
|
||||
- [ ] Difficulty progression (narrowing corridor, longer strokes)
|
||||
- [ ] Session summary screen (score + streak)
|
||||
- [ ] Local persistence (Room or DataStore) for scores per session
|
||||
- [ ] Progress screen populated with real data (last 7 sessions graph)
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Pressure Ramp Module
|
||||
|
||||
**Goal:** Second module ships. Shared infrastructure for module scoring is solid.
|
||||
|
||||
### Milestones
|
||||
- [ ] Pressure Ramp screen: draw a stroke matching a target pressure curve
|
||||
- [ ] Pressure curve rendering (target vs actual overlay)
|
||||
- [ ] DTW (Dynamic Time Warping) score for pressure curve matching
|
||||
- [ ] Unified `ModuleScore` data model shared across modules
|
||||
- [ ] Difficulty tiers (linear ramp → S-curve → arbitrary profile)
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Curve Sculptor + Vector Snap
|
||||
|
||||
**Goal:** Full 4-module training suite. App is content-complete for v1.0.
|
||||
|
||||
### Milestones
|
||||
- [ ] Curve Sculptor: hit bezier control points with stylus
|
||||
- [ ] Vector Snap: tap precise endpoints within tolerance radius
|
||||
- [ ] Per-module difficulty levels stored in settings
|
||||
- [ ] Global XP system across all modules
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Monetization Ready
|
||||
|
||||
**Goal:** In-app purchase infrastructure. Free tier vs paid modules enforced.
|
||||
|
||||
### Milestones
|
||||
- [ ] Google Play Billing integration
|
||||
- [ ] Entitlement check before launching `GameActivity`
|
||||
- [ ] "Line Corridor" free; others gated behind Skill Pack purchase
|
||||
- [ ] Pro subscription tier (all modules + cloud sync)
|
||||
- [ ] Settings screen: manage subscription, restore purchases
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — Cloud + Social
|
||||
|
||||
**Goal:** Cross-device sync and community features.
|
||||
|
||||
### Milestones
|
||||
- [ ] Firebase Auth (Google sign-in)
|
||||
- [ ] Firestore sync for scores and progress
|
||||
- [ ] Global leaderboard per module
|
||||
- [ ] Weekly challenge mode (fixed seed corridor, global rank)
|
||||
- [ ] Share score card (image export)
|
||||
|
||||
---
|
||||
|
||||
## Per-Module Milestones Detail
|
||||
|
||||
### Line Corridor
|
||||
| Milestone | Description |
|
||||
|-----------|-------------|
|
||||
| Skeleton | Screen renders, bridge connected |
|
||||
| Corridor | Guided corridor drawn, stroke captured |
|
||||
| Score | Straightness + pressure score calculated |
|
||||
| Persist | Scores saved locally |
|
||||
| Polish | Animations, streak counter, SFX |
|
||||
|
||||
### Pressure Ramp
|
||||
| Milestone | Description |
|
||||
|-----------|-------------|
|
||||
| Skeleton | Screen renders |
|
||||
| Curve target | Target pressure profile displayed |
|
||||
| Score | DTW score vs target |
|
||||
| Difficulty | 3 difficulty tiers |
|
||||
|
||||
### Curve Sculptor
|
||||
| Milestone | Description |
|
||||
|-----------|-------------|
|
||||
| Skeleton | Screen renders |
|
||||
| Bezier display | Target bezier curve shown |
|
||||
| Stroke eval | How close did the stroke follow the curve? |
|
||||
| Control points | Optional control point hints |
|
||||
|
||||
### Vector Snap
|
||||
| Milestone | Description |
|
||||
|-----------|-------------|
|
||||
| Skeleton | Screen renders |
|
||||
| Target points | Random endpoint targets displayed |
|
||||
| Hit detection | Distance from target scored |
|
||||
| Speed bonus | Faster accurate taps = higher score |
|
||||
161
SPRINT.md
Normal file
161
SPRINT.md
Normal 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
|
||||
@@ -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
BIN
android/libs/arm64-v8a/libgdx.so
Executable file
Binary file not shown.
BIN
android/libs/armeabi-v7a/libgdx.so
Executable file
BIN
android/libs/armeabi-v7a/libgdx.so
Executable file
Binary file not shown.
BIN
android/libs/x86/libgdx.so
Executable file
BIN
android/libs/x86/libgdx.so
Executable file
Binary file not shown.
BIN
android/libs/x86_64/libgdx.so
Executable file
BIN
android/libs/x86_64/libgdx.so
Executable file
Binary file not shown.
@@ -10,18 +10,28 @@
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:label="@string/app_name"
|
||||
android:theme="@style/GdxTheme">
|
||||
android:theme="@style/Theme.ArtLegend">
|
||||
|
||||
<!-- Compose hub — LAUNCHER entry point -->
|
||||
<activity
|
||||
android:name=".AndroidLauncher"
|
||||
android:name=".MainActivity"
|
||||
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout"
|
||||
android:exported="true"
|
||||
android:screenOrientation="fullSensor">
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- LibGDX game activity — launched by Intent from MainActivity -->
|
||||
<activity
|
||||
android:name=".GameActivity"
|
||||
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout"
|
||||
android:exported="false"
|
||||
android:screenOrientation="landscape"
|
||||
android:theme="@style/Theme.ArtLegend.Fullscreen" />
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
package com.artlegend
|
||||
|
||||
import android.os.Bundle
|
||||
import com.badlogic.gdx.backends.android.AndroidApplication
|
||||
import com.badlogic.gdx.backends.android.AndroidApplicationConfiguration
|
||||
|
||||
class AndroidLauncher : AndroidApplication() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val config = AndroidApplicationConfiguration().apply {
|
||||
useImmersiveMode = true
|
||||
useGyroscope = false
|
||||
useAccelerometer = false
|
||||
}
|
||||
initialize(ArtLegend(), config)
|
||||
}
|
||||
}
|
||||
65
android/src/main/kotlin/com/artlegend/GameActivity.kt
Normal file
65
android/src/main/kotlin/com/artlegend/GameActivity.kt
Normal file
@@ -0,0 +1,65 @@
|
||||
package com.artlegend
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.MotionEvent
|
||||
import com.artlegend.input.StylusFrame
|
||||
import com.artlegend.shared.ModuleType
|
||||
import com.badlogic.gdx.backends.android.AndroidApplication
|
||||
import com.badlogic.gdx.backends.android.AndroidApplicationConfiguration
|
||||
|
||||
class GameActivity : AndroidApplication() {
|
||||
private lateinit var gameHost: GameHost
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val moduleName = intent.getStringExtra("MODULE_TYPE") ?: ModuleType.LINE_CORRIDOR.name
|
||||
val moduleType = ModuleType.valueOf(moduleName)
|
||||
|
||||
gameHost = GameHost(moduleType)
|
||||
val config = AndroidApplicationConfiguration().apply {
|
||||
useImmersiveMode = true
|
||||
useGyroscope = false
|
||||
useAccelerometer = false
|
||||
}
|
||||
initialize(gameHost, config)
|
||||
|
||||
// Wire result callback — invoked on GL thread, must switch to UI thread.
|
||||
gameHost.onSessionComplete = { score ->
|
||||
runOnUiThread {
|
||||
val result = Intent().apply {
|
||||
putExtra("straightness", score.straightness)
|
||||
putExtra("anglePrecision", score.anglePrecision)
|
||||
putExtra("overall", score.overall)
|
||||
}
|
||||
setResult(Activity.RESULT_OK, result)
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
// Capture stylus data on LibGDX's GL view; return false so LibGDX also sees the event.
|
||||
graphics.view.setOnTouchListener { _, event ->
|
||||
handleStylusEvent(event)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleStylusEvent(event: MotionEvent) {
|
||||
val isDown = event.action != MotionEvent.ACTION_UP &&
|
||||
event.action != MotionEvent.ACTION_CANCEL
|
||||
val frame = StylusFrame(
|
||||
x = event.x,
|
||||
y = event.y,
|
||||
pressure = event.pressure,
|
||||
tiltRad = event.getAxisValue(MotionEvent.AXIS_TILT),
|
||||
orientRad = event.getAxisValue(MotionEvent.AXIS_ORIENTATION),
|
||||
isStylus = event.getToolType(0) == MotionEvent.TOOL_TYPE_STYLUS,
|
||||
isDown = isDown
|
||||
)
|
||||
android.util.Log.d("GameActivity", "pressure=%.3f isStylus=${frame.isStylus} isDown=$isDown"
|
||||
.format(frame.pressure))
|
||||
gameHost.bridge.post(frame)
|
||||
}
|
||||
}
|
||||
59
android/src/main/kotlin/com/artlegend/MainActivity.kt
Normal file
59
android/src/main/kotlin/com/artlegend/MainActivity.kt
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
15
android/src/main/kotlin/com/artlegend/data/SessionDao.kt
Normal file
15
android/src/main/kotlin/com/artlegend/data/SessionDao.kt
Normal 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>>
|
||||
}
|
||||
14
android/src/main/kotlin/com/artlegend/data/SessionEntity.kt
Normal file
14
android/src/main/kotlin/com/artlegend/data/SessionEntity.kt
Normal 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
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
46
android/src/main/kotlin/com/artlegend/ui/ArtLegendApp.kt
Normal file
46
android/src/main/kotlin/com/artlegend/ui/ArtLegendApp.kt
Normal file
@@ -0,0 +1,46 @@
|
||||
package com.artlegend.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.artlegend.modules.LineScore
|
||||
import com.artlegend.shared.ModuleType
|
||||
import com.artlegend.ui.screens.HubScreen
|
||||
import com.artlegend.ui.screens.ModuleDetailScreen
|
||||
import com.artlegend.ui.screens.ProgressScreen
|
||||
import com.artlegend.ui.screens.SettingsScreen
|
||||
|
||||
@Composable
|
||||
fun ArtLegendApp(
|
||||
onLaunchModule: (ModuleType) -> Unit,
|
||||
lastScore: LineScore?
|
||||
) {
|
||||
val navController = rememberNavController()
|
||||
NavHost(navController = navController, startDestination = "hub") {
|
||||
composable("hub") {
|
||||
HubScreen(
|
||||
onModuleClick = { moduleType -> navController.navigate("detail/${moduleType.name}") },
|
||||
onProgressClick = { navController.navigate("progress") },
|
||||
onSettingsClick = { navController.navigate("settings") }
|
||||
)
|
||||
}
|
||||
composable("detail/{moduleType}") { backStackEntry ->
|
||||
val moduleType = ModuleType.valueOf(
|
||||
backStackEntry.arguments?.getString("moduleType") ?: ModuleType.LINE_CORRIDOR.name
|
||||
)
|
||||
ModuleDetailScreen(
|
||||
moduleType = moduleType,
|
||||
lastScore = lastScore,
|
||||
onStartTraining = { onLaunchModule(moduleType) },
|
||||
onBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
composable("progress") {
|
||||
ProgressScreen(onBack = { navController.popBackStack() })
|
||||
}
|
||||
composable("settings") {
|
||||
SettingsScreen(onBack = { navController.popBackStack() })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
102
android/src/main/kotlin/com/artlegend/ui/screens/HubScreen.kt
Normal file
102
android/src/main/kotlin/com/artlegend/ui/screens/HubScreen.kt
Normal file
@@ -0,0 +1,102 @@
|
||||
package com.artlegend.ui.screens
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.artlegend.shared.ModuleType
|
||||
|
||||
private data class ModuleCard(
|
||||
val type: ModuleType,
|
||||
val title: String,
|
||||
val description: String
|
||||
)
|
||||
|
||||
private val modules = listOf(
|
||||
ModuleCard(ModuleType.LINE_CORRIDOR, "Line Corridor", "Master straight lines under pressure"),
|
||||
ModuleCard(ModuleType.PRESSURE_RAMP, "Pressure Ramp", "Control stroke weight dynamically"),
|
||||
ModuleCard(ModuleType.CURVE_SCULPTOR, "Curve Sculptor", "Shape smooth bezier curves"),
|
||||
ModuleCard(ModuleType.VECTOR_SNAP, "Vector Snap", "Hit precise vector endpoints")
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun HubScreen(
|
||||
onModuleClick: (ModuleType) -> Unit,
|
||||
onProgressClick: () -> Unit,
|
||||
onSettingsClick: () -> Unit
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("ArtLegend") },
|
||||
actions = {
|
||||
IconButton(onClick = onSettingsClick) {
|
||||
Icon(Icons.Default.Settings, contentDescription = "Settings")
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
bottomBar = {
|
||||
NavigationBar {
|
||||
NavigationBarItem(
|
||||
selected = true,
|
||||
onClick = {},
|
||||
icon = { Text("Train") },
|
||||
label = { Text("Train") }
|
||||
)
|
||||
NavigationBarItem(
|
||||
selected = false,
|
||||
onClick = onProgressClick,
|
||||
icon = { Text("Progress") },
|
||||
label = { Text("Progress") }
|
||||
)
|
||||
}
|
||||
}
|
||||
) { padding ->
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(2),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
) {
|
||||
items(modules) { module ->
|
||||
Card(
|
||||
onClick = { onModuleClick(module.type) },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text(text = module.title, style = MaterialTheme.typography.titleMedium)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(text = module.description, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package com.artlegend.ui.screens
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.artlegend.modules.LineScore
|
||||
import com.artlegend.shared.ModuleType
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ModuleDetailScreen(
|
||||
moduleType: ModuleType,
|
||||
lastScore: LineScore?,
|
||||
onStartTraining: () -> Unit,
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
val title = moduleType.name.split('_').joinToString(" ") { word ->
|
||||
word.lowercase().replaceFirstChar { it.uppercase() }
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(title) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(24.dp),
|
||||
verticalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Column {
|
||||
Text(text = title, style = MaterialTheme.typography.headlineMedium)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(
|
||||
text = when (moduleType) {
|
||||
ModuleType.LINE_CORRIDOR ->
|
||||
"Train your hand to draw perfectly straight lines using S-Pen pressure " +
|
||||
"feedback. Keep your stroke within the corridor to score points."
|
||||
ModuleType.PRESSURE_RAMP ->
|
||||
"Learn to control stroke weight through gradual pressure ramping exercises. " +
|
||||
"Match the target pressure curve for a perfect score."
|
||||
ModuleType.CURVE_SCULPTOR ->
|
||||
"Master the art of smooth curve drawing with guided bezier targets. " +
|
||||
"Follow the path precisely to develop muscle memory."
|
||||
ModuleType.VECTOR_SNAP ->
|
||||
"Develop precision for hitting exact vector endpoints and anchor points. " +
|
||||
"Speed and accuracy both contribute to your score."
|
||||
},
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
|
||||
if (lastScore != null) {
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
ScoreCard(lastScore)
|
||||
}
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = onStartTraining,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(56.dp)
|
||||
) {
|
||||
Text(if (lastScore != null) "Train Again" else "Start Training")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ScoreCard(score: LineScore) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer
|
||||
)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text("Last Session", style = MaterialTheme.typography.labelMedium)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
ScoreStat("Overall", score.overall)
|
||||
ScoreStat("Line", score.straightness)
|
||||
ScoreStat("Angle", score.anglePrecision)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ScoreStat(label: String, value: Float) {
|
||||
Column(horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
text = "${(value * 100).toInt()}%",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.artlegend.ui.screens
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SettingsScreen(onBack: () -> Unit) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Settings") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text("Settings coming soon", style = MaterialTheme.typography.bodyLarge)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.artlegend.ui.theme
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
private val Violet = Color(0xFF7B2FBE)
|
||||
private val VioletContainer = Color(0xFF4A0E8F)
|
||||
private val Cyan = Color(0xFF00E5FF)
|
||||
private val CyanContainer = Color(0xFF004D57)
|
||||
private val DarkBackground = Color(0xFF0D0D12)
|
||||
private val DarkSurface = Color(0xFF16161F)
|
||||
|
||||
private val DarkColors = darkColorScheme(
|
||||
primary = Violet,
|
||||
onPrimary = Color.White,
|
||||
primaryContainer = VioletContainer,
|
||||
onPrimaryContainer = Cyan,
|
||||
secondary = Cyan,
|
||||
onSecondary = Color.Black,
|
||||
secondaryContainer = CyanContainer,
|
||||
background = DarkBackground,
|
||||
surface = DarkSurface,
|
||||
onBackground = Color.White,
|
||||
onSurface = Color.White
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun ArtLegendTheme(content: @Composable () -> Unit) {
|
||||
MaterialTheme(
|
||||
colorScheme = DarkColors,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="GdxTheme" parent="android:Theme.NoTitleBar.Fullscreen" />
|
||||
</resources>
|
||||
11
android/src/main/res/values/themes.xml
Normal file
11
android/src/main/res/values/themes.xml
Normal file
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Main Compose activity: Material3 dark, no action bar -->
|
||||
<style name="Theme.ArtLegend" parent="Theme.Material3.Dark.NoActionBar" />
|
||||
|
||||
<!-- LibGDX game activity: fullscreen, no action bar -->
|
||||
<style name="Theme.ArtLegend.Fullscreen" parent="Theme.Material3.Dark.NoActionBar">
|
||||
<item name="android:windowFullscreen">true</item>
|
||||
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ val gdxVersion: String by project
|
||||
|
||||
dependencies {
|
||||
api("com.badlogicgames.gdx:gdx:$gdxVersion")
|
||||
implementation(project(":shared"))
|
||||
}
|
||||
|
||||
kotlin {
|
||||
|
||||
27
core/src/main/kotlin/com/artlegend/GameHost.kt
Normal file
27
core/src/main/kotlin/com/artlegend/GameHost.kt
Normal file
@@ -0,0 +1,27 @@
|
||||
package com.artlegend
|
||||
|
||||
import com.artlegend.input.StylusInputBridge
|
||||
import com.artlegend.modules.LineCorridorScreen
|
||||
import com.artlegend.modules.LineScore
|
||||
import com.artlegend.shared.ModuleType
|
||||
import com.badlogic.gdx.Game
|
||||
|
||||
class GameHost(
|
||||
private val moduleType: ModuleType,
|
||||
val bridge: StylusInputBridge = StylusInputBridge()
|
||||
) : Game() {
|
||||
|
||||
/** Set by GameActivity before the first frame. Called on the GL thread. */
|
||||
var onSessionComplete: ((LineScore) -> Unit)? = null
|
||||
|
||||
override fun create() {
|
||||
setScreen(
|
||||
when (moduleType) {
|
||||
ModuleType.LINE_CORRIDOR -> LineCorridorScreen(bridge) { score ->
|
||||
onSessionComplete?.invoke(score)
|
||||
}
|
||||
else -> PlaceholderModuleScreen(moduleType)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,11 @@
|
||||
package com.artlegend
|
||||
|
||||
import com.badlogic.gdx.Game
|
||||
import com.artlegend.shared.ModuleType
|
||||
import com.badlogic.gdx.Gdx
|
||||
import com.badlogic.gdx.Screen
|
||||
import com.badlogic.gdx.graphics.GL20
|
||||
|
||||
class ArtLegend : Game() {
|
||||
override fun create() {
|
||||
setScreen(PlaceholderScreen())
|
||||
}
|
||||
}
|
||||
|
||||
class PlaceholderScreen : Screen {
|
||||
class PlaceholderModuleScreen(private val moduleType: ModuleType) : Screen {
|
||||
override fun show() {}
|
||||
|
||||
override fun render(delta: Float) {
|
||||
11
core/src/main/kotlin/com/artlegend/input/StylusFrame.kt
Normal file
11
core/src/main/kotlin/com/artlegend/input/StylusFrame.kt
Normal file
@@ -0,0 +1,11 @@
|
||||
package com.artlegend.input
|
||||
|
||||
data class StylusFrame(
|
||||
val x: Float,
|
||||
val y: Float,
|
||||
val pressure: Float,
|
||||
val tiltRad: Float,
|
||||
val orientRad: Float,
|
||||
val isStylus: Boolean,
|
||||
val isDown: Boolean
|
||||
)
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.artlegend.input
|
||||
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
class StylusInputBridge {
|
||||
private val ref = AtomicReference<StylusFrame?>(null)
|
||||
|
||||
fun post(frame: StylusFrame) {
|
||||
ref.set(frame)
|
||||
}
|
||||
|
||||
val latestFrame: StylusFrame?
|
||||
get() = ref.get()
|
||||
}
|
||||
341
core/src/main/kotlin/com/artlegend/modules/LineCorridorScreen.kt
Normal file
341
core/src/main/kotlin/com/artlegend/modules/LineCorridorScreen.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
7
core/src/main/kotlin/com/artlegend/modules/LineScore.kt
Normal file
7
core/src/main/kotlin/com/artlegend/modules/LineScore.kt
Normal file
@@ -0,0 +1,7 @@
|
||||
package com.artlegend.modules
|
||||
|
||||
data class LineScore(
|
||||
val straightness: Float, // 0..1, 1 = perfectly on centerline
|
||||
val anglePrecision: Float, // 0..1, 1 = stroke angle matches corridor exactly
|
||||
val overall: Float // weighted combination
|
||||
)
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
185
gradlew
vendored
Executable file
185
gradlew
vendored
Executable file
@@ -0,0 +1,185 @@
|
||||
#!/usr/bin/env sh
|
||||
|
||||
#
|
||||
# Copyright 2015 the original author or authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
##
|
||||
## Gradle start up script for UN*X
|
||||
##
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
# Resolve links: $0 may be a link
|
||||
PRG="$0"
|
||||
# Need this for relative symlinks.
|
||||
while [ -h "$PRG" ] ; do
|
||||
ls=`ls -ld "$PRG"`
|
||||
link=`expr "$ls" : '.*-> \(.*\)$'`
|
||||
if expr "$link" : '/.*' > /dev/null; then
|
||||
PRG="$link"
|
||||
else
|
||||
PRG=`dirname "$PRG"`"/$link"
|
||||
fi
|
||||
done
|
||||
SAVED="`pwd`"
|
||||
cd "`dirname \"$PRG\"`/" >/dev/null
|
||||
APP_HOME="`pwd -P`"
|
||||
cd "$SAVED" >/dev/null
|
||||
|
||||
APP_NAME="Gradle"
|
||||
APP_BASE_NAME=`basename "$0"`
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD="maximum"
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
}
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
}
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "`uname`" in
|
||||
CYGWIN* )
|
||||
cygwin=true
|
||||
;;
|
||||
Darwin* )
|
||||
darwin=true
|
||||
;;
|
||||
MINGW* )
|
||||
msys=true
|
||||
;;
|
||||
NONSTOP* )
|
||||
nonstop=true
|
||||
;;
|
||||
esac
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD="$JAVA_HOME/jre/sh/java"
|
||||
else
|
||||
JAVACMD="$JAVA_HOME/bin/java"
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD="java"
|
||||
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
|
||||
MAX_FD_LIMIT=`ulimit -H -n`
|
||||
if [ $? -eq 0 ] ; then
|
||||
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
|
||||
MAX_FD="$MAX_FD_LIMIT"
|
||||
fi
|
||||
ulimit -n $MAX_FD
|
||||
if [ $? -ne 0 ] ; then
|
||||
warn "Could not set maximum file descriptor limit: $MAX_FD"
|
||||
fi
|
||||
else
|
||||
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
|
||||
fi
|
||||
fi
|
||||
|
||||
# For Darwin, add options to specify how the application appears in the dock
|
||||
if $darwin; then
|
||||
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
|
||||
fi
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
|
||||
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
|
||||
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
|
||||
|
||||
JAVACMD=`cygpath --unix "$JAVACMD"`
|
||||
|
||||
# We build the pattern for arguments to be converted via cygpath
|
||||
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
|
||||
SEP=""
|
||||
for dir in $ROOTDIRSRAW ; do
|
||||
ROOTDIRS="$ROOTDIRS$SEP$dir"
|
||||
SEP="|"
|
||||
done
|
||||
OURCYGPATTERN="(^($ROOTDIRS))"
|
||||
# Add a user-defined pattern to the cygpath arguments
|
||||
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
|
||||
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
|
||||
fi
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
i=0
|
||||
for arg in "$@" ; do
|
||||
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
|
||||
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
|
||||
|
||||
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
|
||||
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
|
||||
else
|
||||
eval `echo args$i`="\"$arg\""
|
||||
fi
|
||||
i=`expr $i + 1`
|
||||
done
|
||||
case $i in
|
||||
0) set -- ;;
|
||||
1) set -- "$args0" ;;
|
||||
2) set -- "$args0" "$args1" ;;
|
||||
3) set -- "$args0" "$args1" "$args2" ;;
|
||||
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
|
||||
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
|
||||
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
|
||||
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
|
||||
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
|
||||
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# Escape application args
|
||||
save () {
|
||||
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
|
||||
echo " "
|
||||
}
|
||||
APP_ARGS=`save "$@"`
|
||||
|
||||
# Collect all arguments for the java command, following the shell quoting and substitution rules
|
||||
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
1
local.properties
Normal file
1
local.properties
Normal file
@@ -0,0 +1 @@
|
||||
sdk.dir=/opt/homebrew/share/android-commandlinetools
|
||||
@@ -1,2 +1,6 @@
|
||||
plugins {
|
||||
id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0"
|
||||
}
|
||||
|
||||
rootProject.name = "artlegend"
|
||||
include("core", "android", "desktop")
|
||||
include("core", "android", "desktop", "shared")
|
||||
|
||||
7
shared/build.gradle.kts
Normal file
7
shared/build.gradle.kts
Normal file
@@ -0,0 +1,7 @@
|
||||
plugins {
|
||||
kotlin("jvm")
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(17)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.artlegend.shared
|
||||
|
||||
enum class ModuleType {
|
||||
LINE_CORRIDOR,
|
||||
PRESSURE_RAMP,
|
||||
CURVE_SCULPTOR,
|
||||
VECTOR_SNAP
|
||||
}
|
||||
Reference in New Issue
Block a user