Files
doodle-mini/miniprogram/englishPages/shared/generators/letter-tracing-generator.ts
T

300 lines
9.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { ALPHABET_BY_LETTER, LETTERS_UPPER } from '../data/alphabet';
/** 字母描红的布局模式(与产品文档四类练习一致) */
export type LetterTracingMode =
/** 4 看图描红:教学区 + 6 行描红(前三行大写、后三行小写) */
| 'picture-tracing'
/** 1 大小写配对:左栏 A–M、右栏 N–Z,每行最多 4 组 Aa */
| 'case-pairing'
/** 2 分栏对照:半组字母,左栏大写、右栏小写,每行 4 个 */
| 'column-compare'
/** 3 字母总览:Uppercase / Lowercase 分区,每区 9+9+8 */
| 'overview';
/** 字母大小写模式(部分模式不使用) */
export type LetterCaseMode = 'upper' | 'lower' | 'both';
/** A–M / N–Z 半表(分栏对照) */
export type AlphabetHalf = 'A-M' | 'N-Z';
/** 字母描红生成器的配置项 */
export interface LetterTracingGeneratorConfig {
mode: LetterTracingMode;
letterCase: LetterCaseMode;
selectedLetter?: string;
repetitions: number;
fadePattern: 'gradient' | 'first-only';
/** 分栏对照:练习 A–M 或 N–Z */
alphabetHalf?: AlphabetHalf;
}
/** 单个描红格子的数据 */
export interface TracingCell {
char: string;
opacity: number;
isGuide: boolean;
/** 与 char 组成大小写一对(如 A + a),用于大小写配对格内展示 */
pairChar?: string;
}
/** 一行描红数据,包含展示字符和若干格子 */
export interface TracingRow {
displayChar: string;
cells: TracingCell[];
}
/** Highlight It 格子数据(3×6 网格,混入目标字母) */
export interface HighlightGrid {
rows: string[][];
targetLetter: string;
}
/** 看图描红教学区 */
export interface TeachingBlock {
upper: string;
lower: string;
word: string;
sentence: string;
emoji: string;
/** 字母笔顺图路径(/englishPages/shared/assets/letter/X.png */
letterImgPath: string;
/** 配图路径(/englishPages/shared/assets/letterImgs/Word.png */
wordImgPath: string;
}
export type LetterTracingData =
| {
mode: 'picture-tracing';
teaching: TeachingBlock;
highlightGrid: HighlightGrid;
/** Trace It 区域使用的字母笔顺图路径(同 teaching.letterImgPath */
traceImgPath: string;
/** 下部 4 行四线三格(无字符填充,纯空行) */
tracingLineCount: number;
}
| {
mode: 'case-pairing';
leftVisualRows: TracingRow[][];
rightVisualRows: TracingRow[][];
}
| {
mode: 'column-compare';
leftRows: TracingRow[];
rightRows: TracingRow[];
}
| {
mode: 'overview';
upperRows: string[][];
lowerRows: string[][];
};
/** 将用户输入的字母标准化为大写单字符,默认 'A' */
export function normalizeLetter(c: string): string {
const u = c.trim().toUpperCase();
return u.length ? u[0] : 'A';
}
/** 构建 Highlight It 的 3×6 随机字母网格,保证混入 2~5 个目标字母 */
function buildHighlightGrid(
target: string,
gridRows: number,
gridCols: number,
): HighlightGrid {
const total = gridRows * gridCols;
const targetCount = 2 + Math.floor(Math.random() * 4); // 2~5
const others = LETTERS_UPPER.filter((l) => l !== target);
const chars: string[] = [];
for (let i = 0; i < targetCount; i++) chars.push(target);
for (let i = targetCount; i < total; i++) {
chars.push(others[Math.floor(Math.random() * others.length)]);
}
for (let i = chars.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[chars[i], chars[j]] = [chars[j], chars[i]];
}
const rows: string[][] = [];
for (let r = 0; r < gridRows; r++) {
rows.push(chars.slice(r * gridCols, (r + 1) * gridCols));
}
return { rows, targetLetter: target };
}
function buildCells(
char: string,
count: number,
fadePattern: 'gradient' | 'first-only',
pairChar?: string,
): TracingCell[] {
const n = Math.max(1, Math.min(12, count));
const cells: TracingCell[] = [];
for (let i = 0; i < n; i++) {
let opacity: number;
if (fadePattern === 'first-only') {
opacity = i === 0 ? 1 : 0;
} else if (n <= 1) {
opacity = 1;
} else {
opacity = Math.max(0.12, 1 - i / (n - 1));
}
cells.push({
char,
opacity,
isGuide: i === 0,
...(pairChar !== undefined ? { pairChar } : {}),
});
}
return cells;
}
function rowForChar(
ch: string,
repetitions: number,
fadePattern: 'gradient' | 'first-only',
pairChar?: string,
): TracingRow {
return {
displayChar: ch,
cells: buildCells(ch, repetitions, fadePattern, pairChar),
};
}
/** 一行内多个字母,各自占一段描红格(用于分栏对照等) */
function rowForManyChars(
chars: string[],
repetitions: number,
fadePattern: 'gradient' | 'first-only',
): TracingRow {
const cells: TracingCell[] = [];
for (const ch of chars) {
cells.push(...buildCells(ch, repetitions, fadePattern));
}
return {
displayChar: chars.join(''),
cells,
};
}
function chunk<T>(arr: T[], size: number): T[][] {
const out: T[][] = [];
for (let i = 0; i < arr.length; i += size) {
out.push(arr.slice(i, i + size));
}
return out;
}
/** 总览区 26 字母拆成 9 + 9 + 8 三行 */
function overviewRowsFrom(letters: string[]): string[][] {
return [letters.slice(0, 9), letters.slice(9, 18), letters.slice(18, 26)];
}
/**
* 根据配置生成字母描红数据
* - picture-tracing:教学区 + 6 行描红
* - case-pairing:左 AM / 右 N–Z,每行最多 4 组大小写配对
* - column-compare:半表,左大写右小写,每行 4 个
* - overviewUppercase / Lowercase Letters9+9+8
*/
export function generateLetterTracing(
config: LetterTracingGeneratorConfig,
): LetterTracingData {
const { mode, repetitions, fadePattern } = config;
if (mode === 'picture-tracing') {
const key = normalizeLetter(config.selectedLetter ?? 'A');
const meta = ALPHABET_BY_LETTER[key] ?? ALPHABET_BY_LETTER.A;
const upper = key;
const lower = key.toLowerCase();
const hasLetterAsset = key === 'A' || key === 'B';
const letterFile = hasLetterAsset ? key : 'A';
const letterImgPath = `/englishPages/shared/assets/letter/${letterFile}.png`;
const wordImgPath = `/englishPages/shared/assets/letterImgs/Apple.png`;
const teaching: TeachingBlock = {
upper,
lower,
word: meta.word,
sentence: `${upper} is for ${meta.word}`,
emoji: meta.emoji,
letterImgPath,
wordImgPath,
};
const highlightGrid = buildHighlightGrid(upper, 3, 6);
return {
mode: 'picture-tracing',
teaching,
highlightGrid,
traceImgPath: letterImgPath,
tracingLineCount: 8,
};
}
if (mode === 'case-pairing') {
const leftLetters = LETTERS_UPPER.slice(0, 13);
const rightLetters = LETTERS_UPPER.slice(13);
const pack = (letters: string[]) =>
chunk(letters, 4).map((group) =>
group.map((L) =>
rowForChar(L, repetitions, fadePattern, L.toLowerCase()),
),
);
return {
mode: 'case-pairing',
leftVisualRows: pack(leftLetters),
rightVisualRows: pack(rightLetters),
};
}
if (mode === 'column-compare') {
const half =
config.alphabetHalf === 'N-Z'
? LETTERS_UPPER.slice(13)
: LETTERS_UPPER.slice(0, 13);
const leftRows = half.map((L) =>
rowForChar(L, repetitions, fadePattern),
);
const rightRows = half.map((L) =>
rowForChar(L.toLowerCase(), repetitions, fadePattern),
);
return { mode: 'column-compare', leftRows, rightRows };
}
// overview
return {
mode: 'overview',
upperRows: overviewRowsFrom([...LETTERS_UPPER]),
lowerRows: overviewRowsFrom(LETTERS_UPPER.map((L) => L.toLowerCase())),
};
}
/** 与 WorksheetDefinition.generatorConfig 合并后的运行时配置 */
export function mergeLetterTracingConfig(
base: Partial<LetterTracingGeneratorConfig>,
overrides: Partial<LetterTracingGeneratorConfig>,
): LetterTracingGeneratorConfig {
return {
mode: (overrides.mode ??
base.mode ??
'picture-tracing') as LetterTracingMode,
letterCase: (overrides.letterCase ??
base.letterCase ??
'upper') as LetterCaseMode,
selectedLetter: overrides.selectedLetter ?? base.selectedLetter,
repetitions: Math.max(
1,
(overrides.repetitions ?? base.repetitions ?? 5) as number,
),
fadePattern: (overrides.fadePattern ??
base.fadePattern ??
'gradient') as 'gradient' | 'first-only',
alphabetHalf: (overrides.alphabetHalf ??
base.alphabetHalf ??
'A-M') as AlphabetHalf,
};
}