From be470268ab875b9a7102a893da9979bc7cfbd010 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Sun, 21 Jun 2026 08:44:23 -0500 Subject: [PATCH] Add beginner levels focusing on shapes and 3d --- src/app/api/award/[badge]/route.ts | 12 +- src/app/api/progress/route.ts | 16 +- src/app/learn/[level]/[slug]/page.tsx | 93 ++++----- src/app/learn/page.tsx | 185 +++++++++++------- src/app/profile/page.tsx | 27 ++- src/components/ColoringRunner.tsx | 30 +-- src/lib/curriculum.beginner.ts | 268 ++++++++++++++++++++++++++ src/lib/curriculum.ts | 30 ++- src/lib/gating.ts | 29 +++ 9 files changed, 538 insertions(+), 152 deletions(-) create mode 100644 src/lib/curriculum.beginner.ts create mode 100644 src/lib/gating.ts diff --git a/src/app/api/award/[badge]/route.ts b/src/app/api/award/[badge]/route.ts index f9be6b0..12088bb 100644 --- a/src/app/api/award/[badge]/route.ts +++ b/src/app/api/award/[badge]/route.ts @@ -1,6 +1,6 @@ import { getCurrentUser } from "@/lib/session"; import { hasBadge } from "@/lib/progress"; -import { LESSONS } from "@/lib/curriculum"; +import { LESSONS, BEGINNER_PACKS } from "@/lib/curriculum"; import { PDFDocument, StandardFonts, rgb } from "pdf-lib"; export async function GET(_req: Request, ctx: { params: Promise<{ badge: string }> }) { @@ -8,14 +8,18 @@ export async function GET(_req: Request, ctx: { params: Promise<{ badge: string if (!user) return new Response("Log in to download your award.", { status: 401 }); const { badge } = await ctx.params; + // Award can belong to a lesson (Early Beginner) or a pack (Beginner). const lesson = LESSONS.find((l) => l.badgeKey === badge); - if (!lesson) return new Response("Unknown award.", { status: 404 }); + const pack = BEGINNER_PACKS.find((p) => p.badgeKey === badge); + if (!lesson && !pack) return new Response("Unknown award.", { status: 404 }); if (!hasBadge(user.id, badge)) return new Response("You haven't earned this award yet.", { status: 403 }); + const badgeName = lesson ? lesson.badgeName : pack!.badgeName; + const lessonTitle = lesson ? lesson.title : `the ${pack!.name} pack`; const pdfBytes = await buildCertificate({ name: user.username, - badgeName: lesson.badgeName, - lessonTitle: lesson.title, + badgeName, + lessonTitle, date: new Date().toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" }), }); diff --git a/src/app/api/progress/route.ts b/src/app/api/progress/route.ts index 74cbe4f..c0df575 100644 --- a/src/app/api/progress/route.ts +++ b/src/app/api/progress/route.ts @@ -1,7 +1,8 @@ import { NextResponse } from "next/server"; import { getCurrentUser } from "@/lib/session"; -import { markStepComplete, awardBadge, recordCompletion } from "@/lib/progress"; -import { getLesson } from "@/lib/curriculum"; +import { markStepComplete, awardBadge, recordCompletion, hasBadge } from "@/lib/progress"; +import { getLesson, getPackForLesson } from "@/lib/curriculum"; +import { isPackComplete } from "@/lib/gating"; export async function POST(req: Request) { const user = await getCurrentUser(); @@ -25,7 +26,7 @@ export async function POST(req: Request) { let awarded = false; if (body.badgeKey) { const lesson = getLesson(level, sublevel); - // Only award the badge that actually belongs to this lesson. + // Only award the badge that actually belongs to this lesson (Early Beginner per-subject badges). if (lesson && lesson.badgeKey === body.badgeKey) { awardBadge(user.id, body.badgeKey); recordCompletion(user.id, level, sublevel, body.badgeKey); @@ -33,5 +34,14 @@ export async function POST(req: Request) { } } + // Beginner: award the per-pack badge once every subject/phase in the pack is finished. + const lesson = getLesson(level, sublevel); + const pack = lesson ? getPackForLesson(lesson) : undefined; + if (pack && !hasBadge(user.id, pack.badgeKey) && isPackComplete(user.id, pack)) { + awardBadge(user.id, pack.badgeKey); + recordCompletion(user.id, pack.level, 0, pack.badgeKey); + awarded = true; + } + return NextResponse.json({ ok: true, awarded }); } diff --git a/src/app/learn/[level]/[slug]/page.tsx b/src/app/learn/[level]/[slug]/page.tsx index f3d09cd..fc0ef18 100644 --- a/src/app/learn/[level]/[slug]/page.tsx +++ b/src/app/learn/[level]/[slug]/page.tsx @@ -4,15 +4,21 @@ import LessonRunner from "@/components/LessonRunner"; import ShadingRunner from "@/components/ShadingRunner"; import TraceRunner from "@/components/TraceRunner"; import ColoringRunner from "@/components/ColoringRunner"; -import { getLessonBySlug, getLevel, getSubjects, cumulativeSvg, type Lesson } from "@/lib/curriculum"; +import { getLessonBySlug, getLevel, getSubjects, cumulativeSvg, lessonStepCount, LIGHT_HINT_SVG, type Lesson } from "@/lib/curriculum"; import { getCurrentUser } from "@/lib/session"; import { getCompletedSteps, hasBadge } from "@/lib/progress"; +import { isLevelUnlocked } from "@/lib/gating"; import { getLatestDrawing } from "@/lib/drawings"; export const dynamic = "force-dynamic"; const FREE_SLUG = "fish-outline"; // the only lesson playable without an account +// Friendly label for each phase (Early Beginner + Beginner). +const PHASE_LABEL: Record = { + construct: "Shapes", outline: "Outline", detail: "Details", color: "Color", light: "Light & shadow", extra: "Color it!", +}; + export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) { const { slug } = await params; const lesson = getLessonBySlug(slug); @@ -27,69 +33,70 @@ export default async function LessonPage({ params }: { params: Promise<{ level: const user = await getCurrentUser(); if (!user && lesson.slug !== FREE_SLUG) redirect("/signup"); - const levelName = getLevel(lesson.level)?.name ?? ""; - const stepCount = (l: Lesson) => l.steps.length || 1; - const isComplete = (l: Lesson | undefined) => - !!l && !!user && getCompletedSteps(user.id, l.level, l.sublevel).length >= stepCount(l); + // Level gate: e.g. Beginner stays locked until 5 Early Beginner lessons are done. + if (user && !isLevelUnlocked(user.id, lesson.level)) redirect("/learn"); - // Phase gating: Detail needs Outline done; Extra needs Detail done. - if (user && lesson.phase) { - const subj = getSubjects(level).find((s) => s.key === lesson.subjectKey); - const find = (ph: string) => subj?.lessons.find((l) => l.phase === ph); - if (lesson.phase === "detail") { - const o = find("outline"); - if (o && !isComplete(o)) redirect(`/learn/${level}/${o.slug}`); - } else if (lesson.phase === "extra") { - const d = find("detail"); - if (d && !isComplete(d) && !(d.badgeKey && hasBadge(user.id, d.badgeKey))) redirect(`/learn/${level}/${d.slug}`); - } + const levelName = getLevel(lesson.level)?.name ?? ""; + const isComplete = (l: Lesson | undefined) => + !!l && !!user && getCompletedSteps(user.id, l.level, l.sublevel).length >= lessonStepCount(l); + + // Subject phase order (works for both 3-phase EB and 4-phase Beginner). + const subj = lesson.subjectKey ? getSubjects(level).find((s) => s.key === lesson.subjectKey) : undefined; + const ordered = subj?.lessons ?? [lesson]; + const myIdx = ordered.findIndex((l) => l.slug === lesson.slug); + const prevLesson = myIdx > 0 ? ordered[myIdx - 1] : undefined; + const nextLesson = myIdx >= 0 && myIdx < ordered.length - 1 ? ordered[myIdx + 1] : undefined; + + // Gate each phase behind the previous one in the subject. + if (user && prevLesson && !isComplete(prevLesson) && !(prevLesson.badgeKey && hasBadge(user.id, prevLesson.badgeKey))) { + redirect(`/learn/${level}/${prevLesson.slug}`); } const completed = user ? getCompletedSteps(user.id, lesson.level, lesson.sublevel) : []; const alreadyEarned = !!user && !!lesson.badgeKey && hasBadge(user.id, lesson.badgeKey); + const phaseLabel = PHASE_LABEL[lesson.phase ?? ""] ?? ""; const common = { level: lesson.level, levelName, sublevel: lesson.sublevel, title: lesson.title, emoji: lesson.emoji, intro: lesson.intro }; - // Coloring-book trace lessons (outline / detail) - if (lesson.phase === "outline" || lesson.phase === "detail") { + // The user's drawing carried from the previous phase (their own line work / coloring). + const prevDrawing = user && prevLesson ? (getLatestDrawing(user.id, level, prevLesson.sublevel) ?? "") : ""; + + // Trace phases: lay-in shapes / refine outline / add details (line-by-line, traced). + if (lesson.phase === "construct" || lesson.phase === "outline" || lesson.phase === "detail") { const steps = lesson.steps.map((s) => ({ n: s.n, title: s.title, instruction: s.instruction, tip: s.tip ?? "", lines: s.lines ?? [] })); - const meta = { ...common, badgeKey: lesson.badgeKey, badgeName: lesson.badgeName, baseSvg: lesson.baseSvg ?? "", phaseLabel: lesson.phase === "outline" ? "Outline" : "Details" }; - const subj = getSubjects(level).find((s) => s.key === lesson.subjectKey); - // Carry the user's own outline drawing into the Details lesson as the starting canvas. - let carryImage = ""; - if (lesson.phase === "detail" && user) { - const o = subj?.lessons.find((l) => l.phase === "outline"); - if (o) carryImage = getLatestDrawing(user.id, level, o.sublevel) ?? ""; - } - // Link to the next lesson in the subject. - let nextHref = "", nextLabel = ""; - const nextPhase = lesson.phase === "outline" ? "detail" : "extra"; - const nextLesson = subj?.lessons.find((l) => l.phase === nextPhase); - if (nextLesson) { nextHref = `/learn/${level}/${nextLesson.slug}`; nextLabel = lesson.phase === "outline" ? "Add the details" : "Color it in!"; } + const meta = { ...common, badgeKey: lesson.badgeKey, badgeName: lesson.badgeName, baseSvg: lesson.baseSvg ?? "", phaseLabel }; + const nextHref = nextLesson ? `/learn/${level}/${nextLesson.slug}` : ""; + const nextLabel = nextLesson ? `Next: ${PHASE_LABEL[nextLesson.phase ?? ""] ?? "Continue"}` : ""; return ( <>
- +
); } - // Coloring studio (extra) - if (lesson.phase === "extra") { - const meta = { ...common, baseSvg: lesson.baseSvg ?? "" }; - // The user's own drawn sketch (from the Details lesson) โ€” offered as an alternative to the stock line art. - let userSketch = ""; - if (user) { - const subj = getSubjects(level).find((s) => s.key === lesson.subjectKey); - const d = subj?.lessons.find((l) => l.phase === "detail"); - if (d) userSketch = getLatestDrawing(user.id, level, d.sublevel) ?? ""; - } + // Coloring studio (Early Beginner "extra", Beginner "color"). + if (lesson.phase === "extra" || lesson.phase === "color") { + const meta = { ...common, baseSvg: lesson.baseSvg ?? "", phaseLabel }; return ( <>
- + +
+ + ); + } + + // Light & shadow (Beginner): start from the colored drawing, add a single shadow side. + if (lesson.phase === "light") { + const meta = { ...common, baseSvg: lesson.baseSvg ?? "", phaseLabel, hintSvg: LIGHT_HINT_SVG }; + return ( + <> + +
+
); diff --git a/src/app/learn/page.tsx b/src/app/learn/page.tsx index 9c68661..caabdbf 100644 --- a/src/app/learn/page.tsx +++ b/src/app/learn/page.tsx @@ -1,23 +1,73 @@ import Link from "next/link"; import SiteNav from "@/components/SiteNav"; -import { LEVELS, getSubjects, type Lesson } from "@/lib/curriculum"; +import { LEVELS, getSubjects, BEGINNER_PACKS, lessonStepCount, type Lesson } from "@/lib/curriculum"; import { getCurrentUser } from "@/lib/session"; import { getCompletedSteps, hasBadge } from "@/lib/progress"; +import { isLevelUnlocked, countCompletedLessons, isPackComplete, BEGINNER_UNLOCK_THRESHOLD } from "@/lib/gating"; export const metadata = { title: "Learn ยท DrawIt" }; export const dynamic = "force-dynamic"; -const PHASE_LABEL: Record = { outline: "Outline", detail: "Details", extra: "Color it!" }; +const PHASE_LABEL: Record = { + construct: "Shapes", outline: "Outline", detail: "Details", color: "Color", light: "Light & shadow", extra: "Color it!", +}; +const OPTIONAL_PHASES = new Set(["extra"]); + +type Subject = ReturnType[number]; export default async function LearnPage() { const user = await getCurrentUser(); - const stepCount = (l: Lesson) => l.steps.length || 1; const doneCount = (l: Lesson) => (user ? getCompletedSteps(user.id, l.level, l.sublevel).length : 0); const isDone = (l: Lesson) => { if (!user) return false; if (l.badgeKey && hasBadge(user.id, l.badgeKey)) return true; - return doneCount(l) >= stepCount(l); + return doneCount(l) >= lessonStepCount(l); + }; + const subjectComplete = (s: Subject) => s.lessons.every((l) => isDone(l)); + + // One card per phase, each gated behind the previous phase in the subject. + const phaseCard = (l: Lesson, locked: boolean) => { + const optional = OPTIONAL_PHASES.has(l.phase ?? ""); + const done = isDone(l); + const inProg = !done && !locked && doneCount(l) > 0; + const inner = ( +
+ {PHASE_LABEL[l.phase ?? ""] ?? l.title}{optional ? " (optional)" : ""} + + {locked ? "๐Ÿ”’ Locked" : done ? "โœ“ Done" : inProg ? `Step ${Math.min(doneCount(l) + 1, lessonStepCount(l))}/${lessonStepCount(l)}` : l.steps.length === 0 ? "Tap to start" : `${lessonStepCount(l)} steps`} + +
+ ); + return locked ? ( +
{inner}
+ ) : ( + {inner} + ); + }; + + const subjectAccordion = (subj: Subject, open: boolean) => { + const complete = subjectComplete(subj); + const anyStarted = subj.lessons.some((l) => doneCount(l) > 0); + // gate each phase behind the previous one being done + let prevDone = true; + const cards = subj.lessons.map((l) => { + const locked = !prevDone; + const card = phaseCard(l, locked); + prevDone = isDone(l); + return card; + }); + return ( +
+ + {subj.emoji} {subj.name} + {complete ? โœ“ Done : anyStarted ? In progress : null} + +
+ {cards} +
+
+ ); }; return ( @@ -26,93 +76,84 @@ export default async function LearnPage() {

Learn to draw ๐ŸŽจ

- Each picture has an Outline lesson and a Details lesson (earn the badge), plus an optional Color it! lesson. + Each picture is built up in steps. Finish the lessons to earn badges and fill your gallery. {!user && " Log in to track your progress and earn badges."}

{LEVELS.map((lvl, idx) => { const subjects = getSubjects(lvl.key); - const complete = new Map(subjects.map((s) => [s.key, s.lessons.every((l) => isDone(l))])); - // The first not-yet-finished phased subject is expanded; completed ones collapse. - const firstIncomplete = subjects.find((s) => (s.lessons.length > 1 || !!s.lessons[0].phase) && !complete.get(s.key))?.key; + const unlocked = isLevelUnlocked(user?.id ?? null, lvl.key); + const firstIncomplete = subjects.find((s) => !subjectComplete(s))?.key; return (

{lvl.emoji} {idx + 1}. {lvl.name}

{subjects.length === 0 && Coming soon} + {subjects.length > 0 && !unlocked && ๐Ÿ”’ Locked}

{lvl.blurb}

-
- {subjects.map((subj) => { - const phased = subj.lessons.length > 1 || !!subj.lessons[0].phase; - - if (!phased) { - const l = subj.lessons[0]; - const done = isDone(l); - const inProg = !done && doneCount(l) > 0; + {/* Locked level: show how to unlock */} + {subjects.length > 0 && !unlocked ? ( +
+ {!user ? ( +

+ Sign up and finish {BEGINNER_UNLOCK_THRESHOLD} Early Beginner drawings to unlock this level. +

+ ) : ( + <> +

๐Ÿ”’ Finish {BEGINNER_UNLOCK_THRESHOLD} Early Beginner lessons to unlock the {lvl.name} level.

+

+ You've done {Math.min(countCompletedLessons(user.id, "early-beginner"), BEGINNER_UNLOCK_THRESHOLD)} of {BEGINNER_UNLOCK_THRESHOLD}. Keep going! +

+ Practice Early Beginner + + )} +
+ ) : lvl.key === "beginner" ? ( + // Beginner: group subjects into packs, each with a per-pack badge. +
+ {BEGINNER_PACKS.map((pack) => { + const packSubjects = subjects.filter((s) => pack.subjectKeys.includes(s.key)); + const packDone = !!user && isPackComplete(user.id, pack); return ( - -
- {subj.emoji} {l.title} - {done ? โœ“ Done : inProg ? Step {Math.min(doneCount(l) + 1, stepCount(l))}/{stepCount(l)} : {stepCount(l)} steps} +
+
+ {pack.emoji} {pack.name} + {packDone ? ๐Ÿ… {pack.badgeName} : Finish all {pack.subjectKeys.length} to earn {pack.badgeName}} +
+

{pack.blurb}

+
+ {packSubjects.map((subj) => subjectAccordion(subj, subj.key === firstIncomplete))}
- - ); - } - - const byPhase = (p: string) => subj.lessons.find((l) => l.phase === p); - const outline = byPhase("outline"); - const detail = byPhase("detail"); - const extra = byPhase("extra"); - const outlineDone = outline ? isDone(outline) : false; - const detailDone = detail ? isDone(detail) : false; - const subjComplete = !!complete.get(subj.key); - - const phaseCard = (l: Lesson | undefined, locked: boolean, optional = false) => { - if (!l) return null; - const done = isDone(l); - const inProg = !done && !locked && doneCount(l) > 0; - const inner = ( -
- {PHASE_LABEL[l.phase ?? ""]}{optional ? " (optional)" : ""} - - {locked ? "๐Ÿ”’ Locked" : done ? "โœ“ Done" : inProg ? `Step ${Math.min(doneCount(l) + 1, stepCount(l))}/${stepCount(l)}` : l.phase === "extra" ? "Add color" : `${stepCount(l)} steps`} -
); - return locked ? ( -
{inner}
- ) : ( - {inner} - ); - }; - - return ( -
- - {subj.emoji} {subj.name} - {subjComplete ? ( - โœ“ Done - ) : outlineDone || detailDone ? ( - In progress - ) : null} - -
- {phaseCard(outline, false)} - {phaseCard(detail, !outlineDone)} - {phaseCard(extra, !detailDone, true)} -
-
- ); - })} -
+ })} +
+ ) : ( + // Early Beginner (and any future flat levels) +
+ {subjects.map((subj) => { + const phased = subj.lessons.length > 1 || !!subj.lessons[0].phase; + if (!phased) { + const l = subj.lessons[0]; + const done = isDone(l); + const inProg = !done && doneCount(l) > 0; + return ( + +
+ {subj.emoji} {l.title} + {done ? โœ“ Done : inProg ? Step {Math.min(doneCount(l) + 1, lessonStepCount(l))}/{lessonStepCount(l)} : {lessonStepCount(l)} steps} +
+ + ); + } + return subjectAccordion(subj, subj.key === firstIncomplete); + })} +
+ )}
); })} diff --git a/src/app/profile/page.tsx b/src/app/profile/page.tsx index 3c54bf5..e86f03c 100644 --- a/src/app/profile/page.tsx +++ b/src/app/profile/page.tsx @@ -6,7 +6,7 @@ import ProfileGallery from "./ProfileGallery"; import { getCurrentUser } from "@/lib/session"; import { listFinalDrawings } from "@/lib/drawings"; import { listBadges, listCompletions } from "@/lib/progress"; -import { LESSONS, getLesson } from "@/lib/curriculum"; +import { badgeDefs, getLesson } from "@/lib/curriculum"; export const dynamic = "force-dynamic"; export const metadata = { title: "My profile ยท DrawIt" }; @@ -41,27 +41,24 @@ export default async function ProfilePage() { datesByBadge.get(c.badge_key)!.push(c.completed_at); } - // Earned badges โ†’ rich cards + // Earned badges โ†’ rich cards (per-subject Early Beginner + per-pack Beginner) const earnedKeys = new Set(listBadges(user.id).map((b) => b.badge_key)); const awardedAt = new Map(listBadges(user.id).map((b) => [b.badge_key, b.awarded_at])); - const earned = LESSONS.filter((l) => l.badgeKey && earnedKeys.has(l.badgeKey)).map((l) => { - const groupKey = `${l.level}-${l.sublevel}`; - const dates = datesByBadge.get(l.badgeKey) ?? [awardedAt.get(l.badgeKey) ?? ""]; + const defs = badgeDefs(); + const earned = defs.filter((d) => earnedKeys.has(d.badgeKey)).map((d) => { + const dates = datesByBadge.get(d.badgeKey) ?? [awardedAt.get(d.badgeKey) ?? ""]; return { - badgeKey: l.badgeKey, - name: l.badgeName, - emoji: l.emoji, + badgeKey: d.badgeKey, + name: d.name, + emoji: d.emoji, dates: dates.filter(Boolean), - drawingCount: drawingCountByGroup.get(groupKey) ?? 0, - groupKey, + drawingCount: d.groupKey ? (drawingCountByGroup.get(d.groupKey) ?? 0) : 0, + groupKey: d.groupKey, }; }); - // Badges still to collect (only badge-bearing lessons; one per subject) - const locked = LESSONS.filter((l) => l.badgeKey && !earnedKeys.has(l.badgeKey)).map((l) => ({ - name: l.badgeName, - emoji: l.emoji, - })); + // Badges still to collect + const locked = defs.filter((d) => !earnedKeys.has(d.badgeKey)).map((d) => ({ name: d.name, emoji: d.emoji })); return ( <> diff --git a/src/components/ColoringRunner.tsx b/src/components/ColoringRunner.tsx index 2d10ae1..d5f9b78 100644 --- a/src/components/ColoringRunner.tsx +++ b/src/components/ColoringRunner.tsx @@ -4,7 +4,7 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; import { loadProgressDrawing, saveProgressDrawing, saveFinalDrawing, fileToDataURL, loadDrawingAsLayer } from "./drawingPersistence"; -interface Meta { level: string; levelName: string; sublevel: number; title: string; emoji: string; intro: string; baseSvg: string } +interface Meta { level: string; levelName: string; sublevel: number; title: string; emoji: string; intro: string; baseSvg: string; phaseLabel?: string; hintSvg?: string } const W = 880; const H = 660; @@ -32,10 +32,11 @@ function newLayer(id: number, name: string): Layer { return { id, name, visible: true, canvas: c }; } -export default function ColoringRunner({ meta, loggedIn, username, alreadyEarned, userSketch = "" }: { - meta: Meta; loggedIn: boolean; username: string; alreadyEarned: boolean; userSketch?: string; +export default function ColoringRunner({ meta, loggedIn, username, alreadyEarned, userSketch = "", carryImage = "" }: { + meta: Meta; loggedIn: boolean; username: string; alreadyEarned: boolean; userSketch?: string; carryImage?: string; }) { const router = useRouter(); + const phaseLabel = meta.phaseLabel || "Color it!"; // Which line art to color: the stock template or the child's own sketch. // If they have no sketch of their own, skip the choice and use the stock art. const [sketch, setSketch] = useState<"stock" | "yours" | null>(userSketch ? null : "stock"); @@ -115,18 +116,24 @@ export default function ColoringRunner({ meta, loggedIn, username, alreadyEarned return () => { alive = false; }; }, [sketch, meta.baseSvg, userSketch]); - // Restore saved coloring into the bottom layer + // Restore saved coloring into the bottom layer; if none, seed with the carry image + // (e.g. the Light phase starts from the colored drawing). useEffect(() => { - if (!loggedIn) { composite(); return; } let alive = true; - loadProgressDrawing(meta.level, meta.sublevel).then((data) => { - if (!data || !alive) { composite(); return; } + const seed = (data: string) => { const im = new Image(); - im.onload = () => { layersRef.current[0].canvas.getContext("2d")!.drawImage(im, 0, 0, W, H); composite(); }; + im.onload = () => { if (alive) { layersRef.current[0]?.canvas.getContext("2d")!.drawImage(im, 0, 0, W, H); composite(); } }; im.src = data; + }; + if (!loggedIn) { if (carryImage) seed(carryImage); else composite(); return; } + loadProgressDrawing(meta.level, meta.sublevel).then((data) => { + if (!alive) return; + if (data) seed(data); + else if (carryImage) seed(carryImage); + else composite(); }); return () => { alive = false; }; - }, [loggedIn, meta.level, meta.sublevel, composite]); + }, [loggedIn, meta.level, meta.sublevel, composite, carryImage]); function flatten(withTemplate: boolean): string { const c = document.createElement("canvas"); c.width = W; c.height = H; @@ -261,7 +268,7 @@ export default function ColoringRunner({ meta, loggedIn, username, alreadyEarned if (sketch === null) { return (
-
{meta.levelName} ยท Color it!
+
{meta.levelName} ยท {phaseLabel}

{meta.emoji} {meta.title}

Which drawing would you like to color in?

@@ -320,7 +327,7 @@ export default function ColoringRunner({ meta, loggedIn, username, alreadyEarned
-
{meta.levelName} ยท Color it!
+
{meta.levelName} ยท {phaseLabel}

{meta.emoji} {meta.title}

Report a problem @@ -333,6 +340,7 @@ export default function ColoringRunner({ meta, loggedIn, username, alreadyEarned {overlayUrl && } + {meta.hintSvg && }
{/* Tools */} diff --git a/src/lib/curriculum.beginner.ts b/src/lib/curriculum.beginner.ts new file mode 100644 index 0000000..233f903 --- /dev/null +++ b/src/lib/curriculum.beginner.ts @@ -0,0 +1,268 @@ +/* AUTO-GENERATED by gen_beginner.py โ€” DrawIt Beginner level (3 packs x 4 subjects, 4 phases each). */ +import type { Lesson, LessonStep } from "./curriculum"; + +// Faint sun hint shown over the Light & shadow phase (sun up-left โ†’ shade the lower-right side). +export const LIGHT_HINT_SVG = ``; + +const BEACH_BALL_CONSTRUCT_STEPS: LessonStep[] = [ + { n:1, title:`The ball`, instruction:`Draw one big round circle.`, tip:`Slow and round, like a bubble.`, lines:[``] }, +]; + +const BEACH_BALL_OUTLINE_STEPS: LessonStep[] = [ + { n:1, title:`Center line`, instruction:`Draw a line curving down the middle.`, lines:[``] }, + { n:2, title:`Side panels`, instruction:`Add a curve on each side to make panels.`, tip:`Now it looks like a real ball!`, lines:[``,``] }, +]; + +const BEACH_BALL_CONSTRUCT_SVG = BEACH_BALL_CONSTRUCT_STEPS.flatMap(s=>s.lines||[]).join("\n"); +const BEACH_BALL_FULL_SVG = BEACH_BALL_CONSTRUCT_SVG + "\n" + BEACH_BALL_OUTLINE_STEPS.flatMap(s=>s.lines||[]).join("\n"); +export const BEACH_BALL_CONSTRUCT_LESSON: Lesson = { level:"beginner", subjectKey:"beach-ball", subjectName:`Beach Ball`, subjectEmoji:`๐Ÿ`, order:1, packKey:"pack-3d", emoji:`๐Ÿ`, badgeKey:"", badgeName:`Shape Shifter`, sublevel:111, slug:"beach-ball-shapes", title:`Beach Ball \u00b7 Shapes`, subject:`Beach Ball shapes`, intro:`Block in the basic shapes โ€” draw them light, one at a time.`, phase:"construct", steps:BEACH_BALL_CONSTRUCT_STEPS }; +export const BEACH_BALL_OUTLINE_LESSON: Lesson = { level:"beginner", subjectKey:"beach-ball", subjectName:`Beach Ball`, subjectEmoji:`๐Ÿ`, order:1, packKey:"pack-3d", emoji:`๐Ÿ`, badgeKey:"", badgeName:`Shape Shifter`, sublevel:112, slug:"beach-ball-outline", title:`Beach Ball \u00b7 Outline`, subject:`Beach Ball outline`, intro:`Turn your shapes into clean line art.`, phase:"outline", baseSvg:BEACH_BALL_CONSTRUCT_SVG, steps:BEACH_BALL_OUTLINE_STEPS }; +export const BEACH_BALL_COLOR_LESSON: Lesson = { level:"beginner", subjectKey:"beach-ball", subjectName:`Beach Ball`, subjectEmoji:`๐Ÿ`, order:1, packKey:"pack-3d", emoji:`๐Ÿ`, badgeKey:"", badgeName:`Shape Shifter`, sublevel:113, slug:"beach-ball-color", title:`Beach Ball \u00b7 Color`, subject:`color the beach ball`, intro:`Fill it in with flat color. Tip: red + yellow = orange!`, phase:"color", baseSvg:BEACH_BALL_FULL_SVG, steps:[] }; +export const BEACH_BALL_LIGHT_LESSON: Lesson = { level:"beginner", subjectKey:"beach-ball", subjectName:`Beach Ball`, subjectEmoji:`๐Ÿ`, order:1, packKey:"pack-3d", emoji:`๐Ÿ`, badgeKey:"", badgeName:`Shape Shifter`, sublevel:114, slug:"beach-ball-light", title:`Beach Ball \u00b7 Light & shadow`, subject:`shade the beach ball`, intro:`The sun is up-left. Softly shade the side facing away from it.`, phase:"light", baseSvg:BEACH_BALL_FULL_SVG, steps:[] }; + +const GIFT_BOX_CONSTRUCT_STEPS: LessonStep[] = [ + { n:1, title:`The front`, instruction:`Draw a square for the front of the box.`, lines:[``] }, + { n:2, title:`Make it 3-D`, instruction:`Add the top and the side to give it depth.`, tip:`See the box pop out!`, lines:[``,``] }, +]; + +const GIFT_BOX_OUTLINE_STEPS: LessonStep[] = [ + { n:1, title:`The ribbon`, instruction:`Draw a ribbon down the front and over the top.`, lines:[``,``] }, + { n:2, title:`The bow`, instruction:`Add a bow where the ribbons meet.`, lines:[``,``] }, +]; + +const GIFT_BOX_CONSTRUCT_SVG = GIFT_BOX_CONSTRUCT_STEPS.flatMap(s=>s.lines||[]).join("\n"); +const GIFT_BOX_FULL_SVG = GIFT_BOX_CONSTRUCT_SVG + "\n" + GIFT_BOX_OUTLINE_STEPS.flatMap(s=>s.lines||[]).join("\n"); +export const GIFT_BOX_CONSTRUCT_LESSON: Lesson = { level:"beginner", subjectKey:"gift-box", subjectName:`Gift Box`, subjectEmoji:`๐ŸŽ`, order:2, packKey:"pack-3d", emoji:`๐ŸŽ`, badgeKey:"", badgeName:`Shape Shifter`, sublevel:121, slug:"gift-box-shapes", title:`Gift Box \u00b7 Shapes`, subject:`Gift Box shapes`, intro:`Block in the basic shapes โ€” draw them light, one at a time.`, phase:"construct", steps:GIFT_BOX_CONSTRUCT_STEPS }; +export const GIFT_BOX_OUTLINE_LESSON: Lesson = { level:"beginner", subjectKey:"gift-box", subjectName:`Gift Box`, subjectEmoji:`๐ŸŽ`, order:2, packKey:"pack-3d", emoji:`๐ŸŽ`, badgeKey:"", badgeName:`Shape Shifter`, sublevel:122, slug:"gift-box-outline", title:`Gift Box \u00b7 Outline`, subject:`Gift Box outline`, intro:`Turn your shapes into clean line art.`, phase:"outline", baseSvg:GIFT_BOX_CONSTRUCT_SVG, steps:GIFT_BOX_OUTLINE_STEPS }; +export const GIFT_BOX_COLOR_LESSON: Lesson = { level:"beginner", subjectKey:"gift-box", subjectName:`Gift Box`, subjectEmoji:`๐ŸŽ`, order:2, packKey:"pack-3d", emoji:`๐ŸŽ`, badgeKey:"", badgeName:`Shape Shifter`, sublevel:123, slug:"gift-box-color", title:`Gift Box \u00b7 Color`, subject:`color the gift box`, intro:`Fill it in with flat color. Tip: red + yellow = orange!`, phase:"color", baseSvg:GIFT_BOX_FULL_SVG, steps:[] }; +export const GIFT_BOX_LIGHT_LESSON: Lesson = { level:"beginner", subjectKey:"gift-box", subjectName:`Gift Box`, subjectEmoji:`๐ŸŽ`, order:2, packKey:"pack-3d", emoji:`๐ŸŽ`, badgeKey:"", badgeName:`Shape Shifter`, sublevel:124, slug:"gift-box-light", title:`Gift Box \u00b7 Light & shadow`, subject:`shade the gift box`, intro:`The sun is up-left. Softly shade the side facing away from it.`, phase:"light", baseSvg:GIFT_BOX_FULL_SVG, steps:[] }; + +const MUG_CONSTRUCT_STEPS: LessonStep[] = [ + { n:1, title:`The top`, instruction:`Draw a flat oval for the opening.`, tip:`An oval, not a circle.`, lines:[``] }, + { n:2, title:`The body`, instruction:`Drop two sides down and curve the bottom.`, tip:`That is a cylinder!`, lines:[``,``,``] }, +]; + +const MUG_OUTLINE_STEPS: LessonStep[] = [ + { n:1, title:`The rim`, instruction:`Draw a smaller oval inside the top.`, lines:[``] }, + { n:2, title:`The handle`, instruction:`Add a handle on the side.`, tip:`Ready for cocoa!`, lines:[``] }, +]; + +const MUG_CONSTRUCT_SVG = MUG_CONSTRUCT_STEPS.flatMap(s=>s.lines||[]).join("\n"); +const MUG_FULL_SVG = MUG_CONSTRUCT_SVG + "\n" + MUG_OUTLINE_STEPS.flatMap(s=>s.lines||[]).join("\n"); +export const MUG_CONSTRUCT_LESSON: Lesson = { level:"beginner", subjectKey:"mug", subjectName:`Mug`, subjectEmoji:`โ˜•`, order:3, packKey:"pack-3d", emoji:`โ˜•`, badgeKey:"", badgeName:`Shape Shifter`, sublevel:131, slug:"mug-shapes", title:`Mug \u00b7 Shapes`, subject:`Mug shapes`, intro:`Block in the basic shapes โ€” draw them light, one at a time.`, phase:"construct", steps:MUG_CONSTRUCT_STEPS }; +export const MUG_OUTLINE_LESSON: Lesson = { level:"beginner", subjectKey:"mug", subjectName:`Mug`, subjectEmoji:`โ˜•`, order:3, packKey:"pack-3d", emoji:`โ˜•`, badgeKey:"", badgeName:`Shape Shifter`, sublevel:132, slug:"mug-outline", title:`Mug \u00b7 Outline`, subject:`Mug outline`, intro:`Turn your shapes into clean line art.`, phase:"outline", baseSvg:MUG_CONSTRUCT_SVG, steps:MUG_OUTLINE_STEPS }; +export const MUG_COLOR_LESSON: Lesson = { level:"beginner", subjectKey:"mug", subjectName:`Mug`, subjectEmoji:`โ˜•`, order:3, packKey:"pack-3d", emoji:`โ˜•`, badgeKey:"", badgeName:`Shape Shifter`, sublevel:133, slug:"mug-color", title:`Mug \u00b7 Color`, subject:`color the mug`, intro:`Fill it in with flat color. Tip: red + yellow = orange!`, phase:"color", baseSvg:MUG_FULL_SVG, steps:[] }; +export const MUG_LIGHT_LESSON: Lesson = { level:"beginner", subjectKey:"mug", subjectName:`Mug`, subjectEmoji:`โ˜•`, order:3, packKey:"pack-3d", emoji:`โ˜•`, badgeKey:"", badgeName:`Shape Shifter`, sublevel:134, slug:"mug-light", title:`Mug \u00b7 Light & shadow`, subject:`shade the mug`, intro:`The sun is up-left. Softly shade the side facing away from it.`, phase:"light", baseSvg:MUG_FULL_SVG, steps:[] }; + +const PARTY_HAT_CONSTRUCT_STEPS: LessonStep[] = [ + { n:1, title:`The cone`, instruction:`Draw two lines down to a wide base.`, lines:[``,``] }, + { n:2, title:`The base`, instruction:`Curve an oval base at the bottom.`, tip:`A cone!`, lines:[``,``] }, +]; + +const PARTY_HAT_OUTLINE_STEPS: LessonStep[] = [ + { n:1, title:`Pom-pom`, instruction:`Add a fluffy ball on the very top.`, lines:[``] }, + { n:2, title:`Stripes`, instruction:`Add zig-zag stripes across the hat.`, tip:`Party time!`, lines:[``,``] }, +]; + +const PARTY_HAT_CONSTRUCT_SVG = PARTY_HAT_CONSTRUCT_STEPS.flatMap(s=>s.lines||[]).join("\n"); +const PARTY_HAT_FULL_SVG = PARTY_HAT_CONSTRUCT_SVG + "\n" + PARTY_HAT_OUTLINE_STEPS.flatMap(s=>s.lines||[]).join("\n"); +export const PARTY_HAT_CONSTRUCT_LESSON: Lesson = { level:"beginner", subjectKey:"party-hat", subjectName:`Party Hat`, subjectEmoji:`๐ŸŽ‰`, order:4, packKey:"pack-3d", emoji:`๐ŸŽ‰`, badgeKey:"", badgeName:`Shape Shifter`, sublevel:141, slug:"party-hat-shapes", title:`Party Hat \u00b7 Shapes`, subject:`Party Hat shapes`, intro:`Block in the basic shapes โ€” draw them light, one at a time.`, phase:"construct", steps:PARTY_HAT_CONSTRUCT_STEPS }; +export const PARTY_HAT_OUTLINE_LESSON: Lesson = { level:"beginner", subjectKey:"party-hat", subjectName:`Party Hat`, subjectEmoji:`๐ŸŽ‰`, order:4, packKey:"pack-3d", emoji:`๐ŸŽ‰`, badgeKey:"", badgeName:`Shape Shifter`, sublevel:142, slug:"party-hat-outline", title:`Party Hat \u00b7 Outline`, subject:`Party Hat outline`, intro:`Turn your shapes into clean line art.`, phase:"outline", baseSvg:PARTY_HAT_CONSTRUCT_SVG, steps:PARTY_HAT_OUTLINE_STEPS }; +export const PARTY_HAT_COLOR_LESSON: Lesson = { level:"beginner", subjectKey:"party-hat", subjectName:`Party Hat`, subjectEmoji:`๐ŸŽ‰`, order:4, packKey:"pack-3d", emoji:`๐ŸŽ‰`, badgeKey:"", badgeName:`Shape Shifter`, sublevel:143, slug:"party-hat-color", title:`Party Hat \u00b7 Color`, subject:`color the party hat`, intro:`Fill it in with flat color. Tip: red + yellow = orange!`, phase:"color", baseSvg:PARTY_HAT_FULL_SVG, steps:[] }; +export const PARTY_HAT_LIGHT_LESSON: Lesson = { level:"beginner", subjectKey:"party-hat", subjectName:`Party Hat`, subjectEmoji:`๐ŸŽ‰`, order:4, packKey:"pack-3d", emoji:`๐ŸŽ‰`, badgeKey:"", badgeName:`Shape Shifter`, sublevel:144, slug:"party-hat-light", title:`Party Hat \u00b7 Light & shadow`, subject:`shade the party hat`, intro:`The sun is up-left. Softly shade the side facing away from it.`, phase:"light", baseSvg:PARTY_HAT_FULL_SVG, steps:[] }; + +const ICE_CREAM_CONSTRUCT_STEPS: LessonStep[] = [ + { n:1, title:`The cone`, instruction:`Draw a triangle pointing down.`, lines:[``] }, + { n:2, title:`The scoop`, instruction:`Add a round scoop on top.`, tip:`Cone + sphere!`, lines:[``] }, +]; + +const ICE_CREAM_OUTLINE_STEPS: LessonStep[] = [ + { n:1, title:`Waffle lines`, instruction:`Cross some lines on the cone.`, lines:[``,``,``,``] }, + { n:2, title:`Scoop top`, instruction:`Add little bumps on the scoop.`, tip:`Yum!`, lines:[``] }, +]; + +const ICE_CREAM_CONSTRUCT_SVG = ICE_CREAM_CONSTRUCT_STEPS.flatMap(s=>s.lines||[]).join("\n"); +const ICE_CREAM_FULL_SVG = ICE_CREAM_CONSTRUCT_SVG + "\n" + ICE_CREAM_OUTLINE_STEPS.flatMap(s=>s.lines||[]).join("\n"); +export const ICE_CREAM_CONSTRUCT_LESSON: Lesson = { level:"beginner", subjectKey:"ice-cream", subjectName:`Ice-Cream Cone`, subjectEmoji:`๐Ÿฆ`, order:5, packKey:"pack-combine", emoji:`๐Ÿฆ`, badgeKey:"", badgeName:`Shape Builder`, sublevel:211, slug:"ice-cream-shapes", title:`Ice-Cream Cone \u00b7 Shapes`, subject:`Ice-Cream Cone shapes`, intro:`Block in the basic shapes โ€” draw them light, one at a time.`, phase:"construct", steps:ICE_CREAM_CONSTRUCT_STEPS }; +export const ICE_CREAM_OUTLINE_LESSON: Lesson = { level:"beginner", subjectKey:"ice-cream", subjectName:`Ice-Cream Cone`, subjectEmoji:`๐Ÿฆ`, order:5, packKey:"pack-combine", emoji:`๐Ÿฆ`, badgeKey:"", badgeName:`Shape Builder`, sublevel:212, slug:"ice-cream-outline", title:`Ice-Cream Cone \u00b7 Outline`, subject:`Ice-Cream Cone outline`, intro:`Turn your shapes into clean line art.`, phase:"outline", baseSvg:ICE_CREAM_CONSTRUCT_SVG, steps:ICE_CREAM_OUTLINE_STEPS }; +export const ICE_CREAM_COLOR_LESSON: Lesson = { level:"beginner", subjectKey:"ice-cream", subjectName:`Ice-Cream Cone`, subjectEmoji:`๐Ÿฆ`, order:5, packKey:"pack-combine", emoji:`๐Ÿฆ`, badgeKey:"", badgeName:`Shape Builder`, sublevel:213, slug:"ice-cream-color", title:`Ice-Cream Cone \u00b7 Color`, subject:`color the ice-cream cone`, intro:`Fill it in with flat color. Tip: red + yellow = orange!`, phase:"color", baseSvg:ICE_CREAM_FULL_SVG, steps:[] }; +export const ICE_CREAM_LIGHT_LESSON: Lesson = { level:"beginner", subjectKey:"ice-cream", subjectName:`Ice-Cream Cone`, subjectEmoji:`๐Ÿฆ`, order:5, packKey:"pack-combine", emoji:`๐Ÿฆ`, badgeKey:"", badgeName:`Shape Builder`, sublevel:214, slug:"ice-cream-light", title:`Ice-Cream Cone \u00b7 Light & shadow`, subject:`shade the ice-cream cone`, intro:`The sun is up-left. Softly shade the side facing away from it.`, phase:"light", baseSvg:ICE_CREAM_FULL_SVG, steps:[] }; + +const SNOWMAN_CONSTRUCT_STEPS: LessonStep[] = [ + { n:1, title:`Three balls`, instruction:`Stack a big, medium and small circle.`, tip:`Biggest on the bottom.`, lines:[``,``,``] }, +]; + +const SNOWMAN_OUTLINE_STEPS: LessonStep[] = [ + { n:1, title:`The face`, instruction:`Add two eyes and a carrot nose.`, lines:[``,``,``] }, + { n:2, title:`Arms & buttons`, instruction:`Add stick arms and a few buttons.`, lines:[``,``,``,``,``] }, + { n:3, title:`The hat`, instruction:`Pop a hat on his head.`, tip:`Brrr!`, lines:[``,``] }, +]; + +const SNOWMAN_CONSTRUCT_SVG = SNOWMAN_CONSTRUCT_STEPS.flatMap(s=>s.lines||[]).join("\n"); +const SNOWMAN_FULL_SVG = SNOWMAN_CONSTRUCT_SVG + "\n" + SNOWMAN_OUTLINE_STEPS.flatMap(s=>s.lines||[]).join("\n"); +export const SNOWMAN_CONSTRUCT_LESSON: Lesson = { level:"beginner", subjectKey:"snowman", subjectName:`Snowman`, subjectEmoji:`โ›„`, order:6, packKey:"pack-combine", emoji:`โ›„`, badgeKey:"", badgeName:`Shape Builder`, sublevel:221, slug:"snowman-shapes", title:`Snowman \u00b7 Shapes`, subject:`Snowman shapes`, intro:`Block in the basic shapes โ€” draw them light, one at a time.`, phase:"construct", steps:SNOWMAN_CONSTRUCT_STEPS }; +export const SNOWMAN_OUTLINE_LESSON: Lesson = { level:"beginner", subjectKey:"snowman", subjectName:`Snowman`, subjectEmoji:`โ›„`, order:6, packKey:"pack-combine", emoji:`โ›„`, badgeKey:"", badgeName:`Shape Builder`, sublevel:222, slug:"snowman-outline", title:`Snowman \u00b7 Outline`, subject:`Snowman outline`, intro:`Turn your shapes into clean line art.`, phase:"outline", baseSvg:SNOWMAN_CONSTRUCT_SVG, steps:SNOWMAN_OUTLINE_STEPS }; +export const SNOWMAN_COLOR_LESSON: Lesson = { level:"beginner", subjectKey:"snowman", subjectName:`Snowman`, subjectEmoji:`โ›„`, order:6, packKey:"pack-combine", emoji:`โ›„`, badgeKey:"", badgeName:`Shape Builder`, sublevel:223, slug:"snowman-color", title:`Snowman \u00b7 Color`, subject:`color the snowman`, intro:`Fill it in with flat color. Tip: red + yellow = orange!`, phase:"color", baseSvg:SNOWMAN_FULL_SVG, steps:[] }; +export const SNOWMAN_LIGHT_LESSON: Lesson = { level:"beginner", subjectKey:"snowman", subjectName:`Snowman`, subjectEmoji:`โ›„`, order:6, packKey:"pack-combine", emoji:`โ›„`, badgeKey:"", badgeName:`Shape Builder`, sublevel:224, slug:"snowman-light", title:`Snowman \u00b7 Light & shadow`, subject:`shade the snowman`, intro:`The sun is up-left. Softly shade the side facing away from it.`, phase:"light", baseSvg:SNOWMAN_FULL_SVG, steps:[] }; + +const HOUSE_CONSTRUCT_STEPS: LessonStep[] = [ + { n:1, title:`The walls`, instruction:`Draw a square for the walls.`, lines:[``] }, + { n:2, title:`The roof`, instruction:`Add a triangle roof on top.`, tip:`Box + pyramid!`, lines:[``] }, +]; + +const HOUSE_OUTLINE_STEPS: LessonStep[] = [ + { n:1, title:`The door`, instruction:`Draw a door in the middle.`, lines:[``] }, + { n:2, title:`Windows`, instruction:`Add a window on each side.`, lines:[``,``] }, + { n:3, title:`Chimney`, instruction:`Add a chimney on the roof.`, tip:`Home sweet home!`, lines:[``] }, +]; + +const HOUSE_CONSTRUCT_SVG = HOUSE_CONSTRUCT_STEPS.flatMap(s=>s.lines||[]).join("\n"); +const HOUSE_FULL_SVG = HOUSE_CONSTRUCT_SVG + "\n" + HOUSE_OUTLINE_STEPS.flatMap(s=>s.lines||[]).join("\n"); +export const HOUSE_CONSTRUCT_LESSON: Lesson = { level:"beginner", subjectKey:"house", subjectName:`House`, subjectEmoji:`๐Ÿ `, order:7, packKey:"pack-combine", emoji:`๐Ÿ `, badgeKey:"", badgeName:`Shape Builder`, sublevel:231, slug:"house-shapes", title:`House \u00b7 Shapes`, subject:`House shapes`, intro:`Block in the basic shapes โ€” draw them light, one at a time.`, phase:"construct", steps:HOUSE_CONSTRUCT_STEPS }; +export const HOUSE_OUTLINE_LESSON: Lesson = { level:"beginner", subjectKey:"house", subjectName:`House`, subjectEmoji:`๐Ÿ `, order:7, packKey:"pack-combine", emoji:`๐Ÿ `, badgeKey:"", badgeName:`Shape Builder`, sublevel:232, slug:"house-outline", title:`House \u00b7 Outline`, subject:`House outline`, intro:`Turn your shapes into clean line art.`, phase:"outline", baseSvg:HOUSE_CONSTRUCT_SVG, steps:HOUSE_OUTLINE_STEPS }; +export const HOUSE_COLOR_LESSON: Lesson = { level:"beginner", subjectKey:"house", subjectName:`House`, subjectEmoji:`๐Ÿ `, order:7, packKey:"pack-combine", emoji:`๐Ÿ `, badgeKey:"", badgeName:`Shape Builder`, sublevel:233, slug:"house-color", title:`House \u00b7 Color`, subject:`color the house`, intro:`Fill it in with flat color. Tip: red + yellow = orange!`, phase:"color", baseSvg:HOUSE_FULL_SVG, steps:[] }; +export const HOUSE_LIGHT_LESSON: Lesson = { level:"beginner", subjectKey:"house", subjectName:`House`, subjectEmoji:`๐Ÿ `, order:7, packKey:"pack-combine", emoji:`๐Ÿ `, badgeKey:"", badgeName:`Shape Builder`, sublevel:234, slug:"house-light", title:`House \u00b7 Light & shadow`, subject:`shade the house`, intro:`The sun is up-left. Softly shade the side facing away from it.`, phase:"light", baseSvg:HOUSE_FULL_SVG, steps:[] }; + +const ROCKET_CONSTRUCT_STEPS: LessonStep[] = [ + { n:1, title:`The body`, instruction:`Draw a tall tube for the body.`, lines:[``] }, + { n:2, title:`Nose & fins`, instruction:`Add a cone on top and a fin each side.`, lines:[``,``,``] }, +]; + +const ROCKET_OUTLINE_STEPS: LessonStep[] = [ + { n:1, title:`The window`, instruction:`Add a round window.`, lines:[``] }, + { n:2, title:`Blast off`, instruction:`Add a band and a flame.`, tip:`Whoosh!`, lines:[``,``] }, +]; + +const ROCKET_CONSTRUCT_SVG = ROCKET_CONSTRUCT_STEPS.flatMap(s=>s.lines||[]).join("\n"); +const ROCKET_FULL_SVG = ROCKET_CONSTRUCT_SVG + "\n" + ROCKET_OUTLINE_STEPS.flatMap(s=>s.lines||[]).join("\n"); +export const ROCKET_CONSTRUCT_LESSON: Lesson = { level:"beginner", subjectKey:"rocket", subjectName:`Rocket`, subjectEmoji:`๐Ÿš€`, order:8, packKey:"pack-combine", emoji:`๐Ÿš€`, badgeKey:"", badgeName:`Shape Builder`, sublevel:241, slug:"rocket-shapes", title:`Rocket \u00b7 Shapes`, subject:`Rocket shapes`, intro:`Block in the basic shapes โ€” draw them light, one at a time.`, phase:"construct", steps:ROCKET_CONSTRUCT_STEPS }; +export const ROCKET_OUTLINE_LESSON: Lesson = { level:"beginner", subjectKey:"rocket", subjectName:`Rocket`, subjectEmoji:`๐Ÿš€`, order:8, packKey:"pack-combine", emoji:`๐Ÿš€`, badgeKey:"", badgeName:`Shape Builder`, sublevel:242, slug:"rocket-outline", title:`Rocket \u00b7 Outline`, subject:`Rocket outline`, intro:`Turn your shapes into clean line art.`, phase:"outline", baseSvg:ROCKET_CONSTRUCT_SVG, steps:ROCKET_OUTLINE_STEPS }; +export const ROCKET_COLOR_LESSON: Lesson = { level:"beginner", subjectKey:"rocket", subjectName:`Rocket`, subjectEmoji:`๐Ÿš€`, order:8, packKey:"pack-combine", emoji:`๐Ÿš€`, badgeKey:"", badgeName:`Shape Builder`, sublevel:243, slug:"rocket-color", title:`Rocket \u00b7 Color`, subject:`color the rocket`, intro:`Fill it in with flat color. Tip: red + yellow = orange!`, phase:"color", baseSvg:ROCKET_FULL_SVG, steps:[] }; +export const ROCKET_LIGHT_LESSON: Lesson = { level:"beginner", subjectKey:"rocket", subjectName:`Rocket`, subjectEmoji:`๐Ÿš€`, order:8, packKey:"pack-combine", emoji:`๐Ÿš€`, badgeKey:"", badgeName:`Shape Builder`, sublevel:244, slug:"rocket-light", title:`Rocket \u00b7 Light & shadow`, subject:`shade the rocket`, intro:`The sun is up-left. Softly shade the side facing away from it.`, phase:"light", baseSvg:ROCKET_FULL_SVG, steps:[] }; + +const ROBOT_CONSTRUCT_STEPS: LessonStep[] = [ + { n:1, title:`Head & body`, instruction:`Draw a square head and a box body.`, lines:[``,``] }, + { n:2, title:`Arms & legs`, instruction:`Add cylinder arms and two legs.`, lines:[``,``,``,``] }, +]; + +const ROBOT_OUTLINE_STEPS: LessonStep[] = [ + { n:1, title:`The face`, instruction:`Add two eyes and a mouth grid.`, lines:[``,``,``,``,``] }, + { n:2, title:`Antenna & buttons`, instruction:`Add an antenna and control buttons.`, tip:`Beep boop!`, lines:[``,``,``,``,``] }, +]; + +const ROBOT_CONSTRUCT_SVG = ROBOT_CONSTRUCT_STEPS.flatMap(s=>s.lines||[]).join("\n"); +const ROBOT_FULL_SVG = ROBOT_CONSTRUCT_SVG + "\n" + ROBOT_OUTLINE_STEPS.flatMap(s=>s.lines||[]).join("\n"); +export const ROBOT_CONSTRUCT_LESSON: Lesson = { level:"beginner", subjectKey:"robot", subjectName:`Robot`, subjectEmoji:`๐Ÿค–`, order:9, packKey:"pack-build", emoji:`๐Ÿค–`, badgeKey:"", badgeName:`Master Maker`, sublevel:311, slug:"robot-shapes", title:`Robot \u00b7 Shapes`, subject:`Robot shapes`, intro:`Block in the basic shapes โ€” draw them light, one at a time.`, phase:"construct", steps:ROBOT_CONSTRUCT_STEPS }; +export const ROBOT_OUTLINE_LESSON: Lesson = { level:"beginner", subjectKey:"robot", subjectName:`Robot`, subjectEmoji:`๐Ÿค–`, order:9, packKey:"pack-build", emoji:`๐Ÿค–`, badgeKey:"", badgeName:`Master Maker`, sublevel:312, slug:"robot-outline", title:`Robot \u00b7 Outline`, subject:`Robot outline`, intro:`Turn your shapes into clean line art.`, phase:"outline", baseSvg:ROBOT_CONSTRUCT_SVG, steps:ROBOT_OUTLINE_STEPS }; +export const ROBOT_COLOR_LESSON: Lesson = { level:"beginner", subjectKey:"robot", subjectName:`Robot`, subjectEmoji:`๐Ÿค–`, order:9, packKey:"pack-build", emoji:`๐Ÿค–`, badgeKey:"", badgeName:`Master Maker`, sublevel:313, slug:"robot-color", title:`Robot \u00b7 Color`, subject:`color the robot`, intro:`Fill it in with flat color. Tip: red + yellow = orange!`, phase:"color", baseSvg:ROBOT_FULL_SVG, steps:[] }; +export const ROBOT_LIGHT_LESSON: Lesson = { level:"beginner", subjectKey:"robot", subjectName:`Robot`, subjectEmoji:`๐Ÿค–`, order:9, packKey:"pack-build", emoji:`๐Ÿค–`, badgeKey:"", badgeName:`Master Maker`, sublevel:314, slug:"robot-light", title:`Robot \u00b7 Light & shadow`, subject:`shade the robot`, intro:`The sun is up-left. Softly shade the side facing away from it.`, phase:"light", baseSvg:ROBOT_FULL_SVG, steps:[] }; + +const CAR_CONSTRUCT_STEPS: LessonStep[] = [ + { n:1, title:`The body`, instruction:`Draw a long box for the body.`, lines:[``] }, + { n:2, title:`Cabin & wheels`, instruction:`Add a roof box and two round wheels.`, lines:[``,``,``] }, +]; + +const CAR_OUTLINE_STEPS: LessonStep[] = [ + { n:1, title:`Windows`, instruction:`Split the cabin into windows.`, lines:[``] }, + { n:2, title:`Details`, instruction:`Add a door line and a headlight.`, tip:`Vroom!`, lines:[``,``,``,``] }, +]; + +const CAR_CONSTRUCT_SVG = CAR_CONSTRUCT_STEPS.flatMap(s=>s.lines||[]).join("\n"); +const CAR_FULL_SVG = CAR_CONSTRUCT_SVG + "\n" + CAR_OUTLINE_STEPS.flatMap(s=>s.lines||[]).join("\n"); +export const CAR_CONSTRUCT_LESSON: Lesson = { level:"beginner", subjectKey:"car", subjectName:`Car`, subjectEmoji:`๐Ÿš—`, order:10, packKey:"pack-build", emoji:`๐Ÿš—`, badgeKey:"", badgeName:`Master Maker`, sublevel:321, slug:"car-shapes", title:`Car \u00b7 Shapes`, subject:`Car shapes`, intro:`Block in the basic shapes โ€” draw them light, one at a time.`, phase:"construct", steps:CAR_CONSTRUCT_STEPS }; +export const CAR_OUTLINE_LESSON: Lesson = { level:"beginner", subjectKey:"car", subjectName:`Car`, subjectEmoji:`๐Ÿš—`, order:10, packKey:"pack-build", emoji:`๐Ÿš—`, badgeKey:"", badgeName:`Master Maker`, sublevel:322, slug:"car-outline", title:`Car \u00b7 Outline`, subject:`Car outline`, intro:`Turn your shapes into clean line art.`, phase:"outline", baseSvg:CAR_CONSTRUCT_SVG, steps:CAR_OUTLINE_STEPS }; +export const CAR_COLOR_LESSON: Lesson = { level:"beginner", subjectKey:"car", subjectName:`Car`, subjectEmoji:`๐Ÿš—`, order:10, packKey:"pack-build", emoji:`๐Ÿš—`, badgeKey:"", badgeName:`Master Maker`, sublevel:323, slug:"car-color", title:`Car \u00b7 Color`, subject:`color the car`, intro:`Fill it in with flat color. Tip: red + yellow = orange!`, phase:"color", baseSvg:CAR_FULL_SVG, steps:[] }; +export const CAR_LIGHT_LESSON: Lesson = { level:"beginner", subjectKey:"car", subjectName:`Car`, subjectEmoji:`๐Ÿš—`, order:10, packKey:"pack-build", emoji:`๐Ÿš—`, badgeKey:"", badgeName:`Master Maker`, sublevel:324, slug:"car-light", title:`Car \u00b7 Light & shadow`, subject:`shade the car`, intro:`The sun is up-left. Softly shade the side facing away from it.`, phase:"light", baseSvg:CAR_FULL_SVG, steps:[] }; + +const CASTLE_CONSTRUCT_STEPS: LessonStep[] = [ + { n:1, title:`Keep & towers`, instruction:`Draw a center wall and two towers.`, lines:[``,``,``] }, + { n:2, title:`Tower roofs`, instruction:`Add a cone roof on each tower.`, lines:[``,``] }, +]; + +const CASTLE_OUTLINE_STEPS: LessonStep[] = [ + { n:1, title:`Battlements`, instruction:`Add square teeth on the center wall.`, lines:[``,``,``] }, + { n:2, title:`Gate & flags`, instruction:`Add a gate and two flags.`, tip:`A mighty castle!`, lines:[``,``,``] }, +]; + +const CASTLE_CONSTRUCT_SVG = CASTLE_CONSTRUCT_STEPS.flatMap(s=>s.lines||[]).join("\n"); +const CASTLE_FULL_SVG = CASTLE_CONSTRUCT_SVG + "\n" + CASTLE_OUTLINE_STEPS.flatMap(s=>s.lines||[]).join("\n"); +export const CASTLE_CONSTRUCT_LESSON: Lesson = { level:"beginner", subjectKey:"castle", subjectName:`Castle`, subjectEmoji:`๐Ÿฐ`, order:11, packKey:"pack-build", emoji:`๐Ÿฐ`, badgeKey:"", badgeName:`Master Maker`, sublevel:331, slug:"castle-shapes", title:`Castle \u00b7 Shapes`, subject:`Castle shapes`, intro:`Block in the basic shapes โ€” draw them light, one at a time.`, phase:"construct", steps:CASTLE_CONSTRUCT_STEPS }; +export const CASTLE_OUTLINE_LESSON: Lesson = { level:"beginner", subjectKey:"castle", subjectName:`Castle`, subjectEmoji:`๐Ÿฐ`, order:11, packKey:"pack-build", emoji:`๐Ÿฐ`, badgeKey:"", badgeName:`Master Maker`, sublevel:332, slug:"castle-outline", title:`Castle \u00b7 Outline`, subject:`Castle outline`, intro:`Turn your shapes into clean line art.`, phase:"outline", baseSvg:CASTLE_CONSTRUCT_SVG, steps:CASTLE_OUTLINE_STEPS }; +export const CASTLE_COLOR_LESSON: Lesson = { level:"beginner", subjectKey:"castle", subjectName:`Castle`, subjectEmoji:`๐Ÿฐ`, order:11, packKey:"pack-build", emoji:`๐Ÿฐ`, badgeKey:"", badgeName:`Master Maker`, sublevel:333, slug:"castle-color", title:`Castle \u00b7 Color`, subject:`color the castle`, intro:`Fill it in with flat color. Tip: red + yellow = orange!`, phase:"color", baseSvg:CASTLE_FULL_SVG, steps:[] }; +export const CASTLE_LIGHT_LESSON: Lesson = { level:"beginner", subjectKey:"castle", subjectName:`Castle`, subjectEmoji:`๐Ÿฐ`, order:11, packKey:"pack-build", emoji:`๐Ÿฐ`, badgeKey:"", badgeName:`Master Maker`, sublevel:334, slug:"castle-light", title:`Castle \u00b7 Light & shadow`, subject:`shade the castle`, intro:`The sun is up-left. Softly shade the side facing away from it.`, phase:"light", baseSvg:CASTLE_FULL_SVG, steps:[] }; + +const SAILBOAT_CONSTRUCT_STEPS: LessonStep[] = [ + { n:1, title:`The hull`, instruction:`Draw a boat shape for the bottom.`, lines:[``] }, + { n:2, title:`Mast & sails`, instruction:`Add a mast and two triangle sails.`, lines:[``,``,``] }, +]; + +const SAILBOAT_OUTLINE_STEPS: LessonStep[] = [ + { n:1, title:`The flag`, instruction:`Add a flag at the top of the mast.`, lines:[``] }, + { n:2, title:`The waves`, instruction:`Add waves under the boat.`, tip:`Set sail!`, lines:[``] }, +]; + +const SAILBOAT_CONSTRUCT_SVG = SAILBOAT_CONSTRUCT_STEPS.flatMap(s=>s.lines||[]).join("\n"); +const SAILBOAT_FULL_SVG = SAILBOAT_CONSTRUCT_SVG + "\n" + SAILBOAT_OUTLINE_STEPS.flatMap(s=>s.lines||[]).join("\n"); +export const SAILBOAT_CONSTRUCT_LESSON: Lesson = { level:"beginner", subjectKey:"sailboat", subjectName:`Sailboat`, subjectEmoji:`โ›ต`, order:12, packKey:"pack-build", emoji:`โ›ต`, badgeKey:"", badgeName:`Master Maker`, sublevel:341, slug:"sailboat-shapes", title:`Sailboat \u00b7 Shapes`, subject:`Sailboat shapes`, intro:`Block in the basic shapes โ€” draw them light, one at a time.`, phase:"construct", steps:SAILBOAT_CONSTRUCT_STEPS }; +export const SAILBOAT_OUTLINE_LESSON: Lesson = { level:"beginner", subjectKey:"sailboat", subjectName:`Sailboat`, subjectEmoji:`โ›ต`, order:12, packKey:"pack-build", emoji:`โ›ต`, badgeKey:"", badgeName:`Master Maker`, sublevel:342, slug:"sailboat-outline", title:`Sailboat \u00b7 Outline`, subject:`Sailboat outline`, intro:`Turn your shapes into clean line art.`, phase:"outline", baseSvg:SAILBOAT_CONSTRUCT_SVG, steps:SAILBOAT_OUTLINE_STEPS }; +export const SAILBOAT_COLOR_LESSON: Lesson = { level:"beginner", subjectKey:"sailboat", subjectName:`Sailboat`, subjectEmoji:`โ›ต`, order:12, packKey:"pack-build", emoji:`โ›ต`, badgeKey:"", badgeName:`Master Maker`, sublevel:343, slug:"sailboat-color", title:`Sailboat \u00b7 Color`, subject:`color the sailboat`, intro:`Fill it in with flat color. Tip: red + yellow = orange!`, phase:"color", baseSvg:SAILBOAT_FULL_SVG, steps:[] }; +export const SAILBOAT_LIGHT_LESSON: Lesson = { level:"beginner", subjectKey:"sailboat", subjectName:`Sailboat`, subjectEmoji:`โ›ต`, order:12, packKey:"pack-build", emoji:`โ›ต`, badgeKey:"", badgeName:`Master Maker`, sublevel:344, slug:"sailboat-light", title:`Sailboat \u00b7 Light & shadow`, subject:`shade the sailboat`, intro:`The sun is up-left. Softly shade the side facing away from it.`, phase:"light", baseSvg:SAILBOAT_FULL_SVG, steps:[] }; + +export const BEGINNER_LESSONS: Lesson[] = [ + BEACH_BALL_CONSTRUCT_LESSON, + BEACH_BALL_OUTLINE_LESSON, + BEACH_BALL_COLOR_LESSON, + BEACH_BALL_LIGHT_LESSON, + GIFT_BOX_CONSTRUCT_LESSON, + GIFT_BOX_OUTLINE_LESSON, + GIFT_BOX_COLOR_LESSON, + GIFT_BOX_LIGHT_LESSON, + MUG_CONSTRUCT_LESSON, + MUG_OUTLINE_LESSON, + MUG_COLOR_LESSON, + MUG_LIGHT_LESSON, + PARTY_HAT_CONSTRUCT_LESSON, + PARTY_HAT_OUTLINE_LESSON, + PARTY_HAT_COLOR_LESSON, + PARTY_HAT_LIGHT_LESSON, + ICE_CREAM_CONSTRUCT_LESSON, + ICE_CREAM_OUTLINE_LESSON, + ICE_CREAM_COLOR_LESSON, + ICE_CREAM_LIGHT_LESSON, + SNOWMAN_CONSTRUCT_LESSON, + SNOWMAN_OUTLINE_LESSON, + SNOWMAN_COLOR_LESSON, + SNOWMAN_LIGHT_LESSON, + HOUSE_CONSTRUCT_LESSON, + HOUSE_OUTLINE_LESSON, + HOUSE_COLOR_LESSON, + HOUSE_LIGHT_LESSON, + ROCKET_CONSTRUCT_LESSON, + ROCKET_OUTLINE_LESSON, + ROCKET_COLOR_LESSON, + ROCKET_LIGHT_LESSON, + ROBOT_CONSTRUCT_LESSON, + ROBOT_OUTLINE_LESSON, + ROBOT_COLOR_LESSON, + ROBOT_LIGHT_LESSON, + CAR_CONSTRUCT_LESSON, + CAR_OUTLINE_LESSON, + CAR_COLOR_LESSON, + CAR_LIGHT_LESSON, + CASTLE_CONSTRUCT_LESSON, + CASTLE_OUTLINE_LESSON, + CASTLE_COLOR_LESSON, + CASTLE_LIGHT_LESSON, + SAILBOAT_CONSTRUCT_LESSON, + SAILBOAT_OUTLINE_LESSON, + SAILBOAT_COLOR_LESSON, + SAILBOAT_LIGHT_LESSON +]; + +export interface Pack { key: string; name: string; emoji: string; blurb: string; level: string; badgeKey: string; badgeName: string; subjectKeys: string[]; } +export const BEGINNER_PACKS: Pack[] = [ + { key:"pack-3d", name:`Make it 3-D`, emoji:`๐Ÿ“ฆ`, blurb:`Turn flat shapes into solid 3-D forms.`, level:"beginner", badgeKey:"beginner-pack-1-3d", badgeName:`Shape Shifter`, subjectKeys:["beach-ball","gift-box","mug","party-hat"] }, + { key:"pack-combine", name:`Put it together`, emoji:`๐Ÿงฉ`, blurb:`Combine two or three forms into one picture.`, level:"beginner", badgeKey:"beginner-pack-2-combine", badgeName:`Shape Builder`, subjectKeys:["ice-cream","snowman","house","rocket"] }, + { key:"pack-build", name:`Build a thing`, emoji:`๐Ÿ—๏ธ`, blurb:`Construct bigger objects from many parts.`, level:"beginner", badgeKey:"beginner-pack-3-build", badgeName:`Master Maker`, subjectKeys:["robot","car","castle","sailboat"] }, +]; + diff --git a/src/lib/curriculum.ts b/src/lib/curriculum.ts index 5b2fc5b..e46c773 100644 --- a/src/lib/curriculum.ts +++ b/src/lib/curriculum.ts @@ -13,12 +13,13 @@ export const LEVELS: LevelMeta[] = [ ]; export function getLevel(key: string): LevelMeta | undefined { return LEVELS.find((l) => l.key === key); } export type StepKind = "construct" | "outline" | "color" | "shade" | "detail"; -export type Phase = "outline" | "detail" | "extra"; +// Early Beginner uses outline/detail/extra; Beginner uses construct/outline/color/light. +export type Phase = "construct" | "outline" | "detail" | "color" | "light" | "extra"; export interface LessonStep { n: number; title: string; instruction: string; tip?: string; kind?: StepKind; svg?: string; lines?: string[]; } export interface Lesson { level: string; sublevel: number; slug: string; title: string; subject: string; emoji: string; intro: string; badgeKey: string; badgeName: string; mode?: "draw" | "shade"; phase?: Phase; baseSvg?: string; - subjectKey?: string; subjectName?: string; subjectEmoji?: string; order?: number; steps: LessonStep[]; + subjectKey?: string; subjectName?: string; subjectEmoji?: string; order?: number; packKey?: string; steps: LessonStep[]; } @@ -171,7 +172,12 @@ const SHADE_STEPS: LessonStep[] = [ export const SHADE_BALL_LESSON: Lesson = { level:"learner", sublevel:1, slug:"shade-ball", title:"Light & Shadow: Shade a Ball", subject:"a shaded ball", emoji:"\u{1F317}", intro:"Make a flat circle look like a round ball using light and shadow!", badgeKey:"learner-1-shading", badgeName:"Shading Star", mode:"shade", subjectKey:"shade-ball", subjectName:"Shade a Ball", subjectEmoji:"\u{1F317}", order:1, steps: SHADE_STEPS }; -export const LESSONS: Lesson[] = [FISH_OUTLINE_LESSON, FISH_DETAIL_LESSON, FISH_EXTRA_LESSON, PANDA_OUTLINE_LESSON, PANDA_DETAIL_LESSON, PANDA_EXTRA_LESSON, FLOWER_OUTLINE_LESSON, FLOWER_DETAIL_LESSON, FLOWER_EXTRA_LESSON, UNICORN_OUTLINE_LESSON, UNICORN_DETAIL_LESSON, UNICORN_EXTRA_LESSON, TREE_OUTLINE_LESSON, TREE_DETAIL_LESSON, TREE_EXTRA_LESSON, DINO_OUTLINE_LESSON, DINO_DETAIL_LESSON, DINO_EXTRA_LESSON, SHADE_BALL_LESSON]; +import { BEGINNER_LESSONS, BEGINNER_PACKS, type Pack } from "./curriculum.beginner"; +export { BEGINNER_PACKS, type Pack } from "./curriculum.beginner"; +export { LIGHT_HINT_SVG } from "./curriculum.beginner"; + +const EARLY_BEGINNER_LESSONS: Lesson[] = [FISH_OUTLINE_LESSON, FISH_DETAIL_LESSON, FISH_EXTRA_LESSON, PANDA_OUTLINE_LESSON, PANDA_DETAIL_LESSON, PANDA_EXTRA_LESSON, FLOWER_OUTLINE_LESSON, FLOWER_DETAIL_LESSON, FLOWER_EXTRA_LESSON, UNICORN_OUTLINE_LESSON, UNICORN_DETAIL_LESSON, UNICORN_EXTRA_LESSON, TREE_OUTLINE_LESSON, TREE_DETAIL_LESSON, TREE_EXTRA_LESSON, DINO_OUTLINE_LESSON, DINO_DETAIL_LESSON, DINO_EXTRA_LESSON]; +export const LESSONS: Lesson[] = [...EARLY_BEGINNER_LESSONS, ...BEGINNER_LESSONS, SHADE_BALL_LESSON]; export function getLesson(level: string, sublevel: number): Lesson | undefined { return LESSONS.find((l) => l.level === level && l.sublevel === sublevel); } export function getLessonBySlug(slug: string): Lesson | undefined { return LESSONS.find((l) => l.slug === slug); } @@ -185,7 +191,23 @@ export function getSubjects(level: string): { key: string; name: string; emoji: if (!map.has(key)) map.set(key, { key, name: l.subjectName || l.title, emoji: l.subjectEmoji || l.emoji, order: l.order ?? l.sublevel, lessons: [] }); map.get(key)!.lessons.push(l); } - const rank: Record = { outline: 0, detail: 1, extra: 2 }; + // Covers both Early Beginner (outline/detail/extra) and Beginner (construct/outline/color/light). + const rank: Record = { construct: 0, outline: 1, detail: 2, color: 3, extra: 3, light: 4 }; for (const s of map.values()) s.lessons.sort((a, b) => (rank[a.phase ?? ""] ?? 0) - (rank[b.phase ?? ""] ?? 0)); return [...map.values()].sort((a, b) => a.order - b.order); } + +/** All badge definitions: per-subject (Early Beginner) + per-pack (Beginner). */ +export interface BadgeDef { badgeKey: string; name: string; emoji: string; groupKey: string; } +export function badgeDefs(): BadgeDef[] { + const fromLessons = LESSONS.filter((l) => l.badgeKey).map((l) => ({ badgeKey: l.badgeKey, name: l.badgeName, emoji: l.emoji, groupKey: `${l.level}-${l.sublevel}` })); + const fromPacks = BEGINNER_PACKS.map((p) => ({ badgeKey: p.badgeKey, name: p.badgeName, emoji: p.emoji, groupKey: "" })); + return [...fromLessons, ...fromPacks]; +} +export function getPackByBadge(badgeKey: string): Pack | undefined { return BEGINNER_PACKS.find((p) => p.badgeKey === badgeKey); } +/** The pack a lesson belongs to (Beginner only), via its packKey. */ +export function getPackForLesson(lesson: Lesson): Pack | undefined { return lesson.packKey ? BEGINNER_PACKS.find((p) => p.key === lesson.packKey) : undefined; } +/** Every lesson that belongs to a pack (all phases of all its subjects). */ +export function lessonsInPack(pack: Pack): Lesson[] { return LESSONS.filter((l) => l.level === pack.level && l.subjectKey && pack.subjectKeys.includes(l.subjectKey)); } +/** Number of completed steps needed to count a lesson as done. */ +export function lessonStepCount(l: Lesson): number { return l.steps.length || 1; } diff --git a/src/lib/gating.ts b/src/lib/gating.ts new file mode 100644 index 0000000..911e580 --- /dev/null +++ b/src/lib/gating.ts @@ -0,0 +1,29 @@ +import { LESSONS, lessonStepCount, lessonsInPack, type Lesson, type Pack } from "./curriculum"; +import { getCompletedSteps } from "./progress"; + +// A child must finish this many Early Beginner lessons before the Beginner level opens, +// so they get a feel for how DrawIt works first. +export const BEGINNER_UNLOCK_THRESHOLD = 5; + +export function isLessonComplete(userId: number, l: Lesson): boolean { + return getCompletedSteps(userId, l.level, l.sublevel).length >= lessonStepCount(l); +} + +/** How many lessons in a level the user has completed. */ +export function countCompletedLessons(userId: number, level: string): number { + return LESSONS.filter((l) => l.level === level).filter((l) => isLessonComplete(userId, l)).length; +} + +/** Levels other than Early Beginner are gated; Beginner opens after 5 Early Beginner lessons. */ +export function isLevelUnlocked(userId: number | null, level: string): boolean { + if (level === "early-beginner") return true; + if (!userId) return false; + if (level === "beginner") return countCompletedLessons(userId, "early-beginner") >= BEGINNER_UNLOCK_THRESHOLD; + return true; // other levels have no lessons yet; leave them open +} + +/** All phases of all subjects in a pack are complete. */ +export function isPackComplete(userId: number, pack: Pack): boolean { + const ls = lessonsInPack(pack); + return ls.length > 0 && ls.every((l) => isLessonComplete(userId, l)); +}