feat:数字学习卡

This commit is contained in:
R524809
2025-11-26 18:03:29 +08:00
parent 61e7781bf8
commit 698109e6f5
37 changed files with 872 additions and 158 deletions
@@ -0,0 +1,142 @@
import { PAPER_SIZE } from '../../constants/colors';
import { drawMathHeader, drawMathMiniHeader } from './mathHeaderDraw';
/**
* 基础数学绘制服务
* 包含Paper设置和Header绘制功能,可被其他绘制服务复用
*/
export class BaseMathDrawService {
headerType: PrintHeader = 'wechat';
canvas: WechatMiniprogram.Canvas;
ctx: RenderingContext;
options: Record<string, any>;
paperSize: PaperSize;
currentX: number;
currentY: number;
canvasWidth: number; // 逻辑像素宽度
canvasHeight: number; // 逻辑像素高度
constructor(
canvas: Canvas,
ctx: RenderingContext,
options?: Record<string, any>,
) {
options = options || {};
this.canvas = canvas;
this.ctx = ctx;
this.paperSize = 'A4';
this.options = {
appName: '涂鸦丫小程序',
appHint: '在玩耍中学习数学',
title: '看数字,涂一涂',
subTitle: '找一找下面相同的数字,涂上颜色',
...options,
};
this.currentX = 0;
this.currentY = 0;
this.canvasWidth = 0;
this.canvasHeight = 0;
this.setPrintConfig();
}
setPrintConfig() {
const printConfig = getApp().getPrintConfig();
this.headerType = printConfig.header;
this.options.appName = printConfig.appName;
}
/**
* 设置Paper(逻辑像素,尺寸除以3)
*/
setPaper() {
const { ctx, canvas } = this;
const { pixelRatio: dpr } = wx.getWindowInfo();
let { width, height } = PAPER_SIZE[this.paperSize];
console.log('width', width);
console.log('height', height);
console.log('dpr', dpr);
this.canvasWidth = width;
this.canvasHeight = height;
// 设置 canvas 为物理像素尺寸(用于高分辨率显示)
const physicalWidth = width * dpr;
const physicalHeight = height * dpr;
canvas.width = physicalWidth;
canvas.height = physicalHeight;
// 重置 transform 并 scale 到逻辑像素
ctx.setTransform(1, 0, 0, 1, 0, 0); // 重置 transform
ctx.scale(dpr, dpr); // scale 到逻辑像素,后续绘制都使用逻辑像素
this.clear();
ctx.fillStyle = '#fff';
// 使用逻辑像素尺寸填充
ctx.fillRect(0, 0, this.canvasWidth, this.canvasHeight);
}
/**
* 清除画布
*/
clear() {
const canvas = this.canvas;
this.ctx.clearRect(0, 0, canvas.width, canvas.height);
}
/**
* 绘制Header(逻辑像素,尺寸除以3)
*/
async drawHeader() {
this.currentX = 24;
this.currentY = 24;
await drawMathHeader({
canvas: this.canvas,
ctx: this.ctx,
headerType: this.headerType,
options: {
appName: this.options.appName || '涂鸦丫小程序',
appHint: this.options.appHint || '识字|识图|练字|打印',
title: this.options.title || '看数字,涂一涂',
subTitle:
this.options.subTitle || '找一找下面相同的数字,涂上颜色',
},
onHeaderDrawn: (currentY) => {
this.currentY = currentY;
},
});
}
/**
* 绘制迷你Header(逻辑像素,尺寸除以3)
*/
drawMiniHeader() {
drawMathMiniHeader({
ctx: this.ctx,
canvasWidth: this.canvasWidth,
options: {
appName: this.options.appName || '涂鸦丫小程序',
title: this.options.title || '看数字,涂一涂',
},
onHeaderDrawn: (currentY) => {
this.currentY = currentY;
},
});
}
/**
* 绘制分割线(逻辑像素,尺寸除以3)
*/
drawDivider() {
const { ctx, canvasWidth } = this;
const dividerY = this.currentY + 10; // 10px间距
ctx.strokeStyle = '#000';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(24, dividerY);
ctx.lineTo(canvasWidth - 24, dividerY);
ctx.stroke();
this.currentY = dividerY + 10; // 分割线下方10px间距
}
}
@@ -0,0 +1,129 @@
import { getMiniCodeImage, getImage } from '../../utils/index';
/**
* 绘制数学模块页眉的参数接口(尺寸除以3)
*/
interface DrawMathHeaderParams {
canvas: WechatMiniprogram.Canvas;
ctx: RenderingContext;
headerType: PrintHeader;
options: {
appName: string;
appHint: string;
title: string;
subTitle: string;
};
onHeaderDrawn?: (currentY: number) => void;
}
/**
* 绘制数学模块完整页眉(尺寸除以3)
*/
export async function drawMathHeader({
canvas,
ctx,
headerType,
options,
onHeaderDrawn,
}: DrawMathHeaderParams): Promise<void> {
const { appName, appHint, title, subTitle } = options;
let titleX = 108; // 约109.33
const titleY = 26; // 约26.67
const logoX = 24; // 约26.67
const logoY = 20; // 20
const logoWidth = 65; // 约66.67
const logoHeight = 65; // 约66.67
// 根据 headerType 绘制 Logo 或调整标题位置
switch (headerType) {
case 'LogoImage': {
const image = await getImage(
canvas,
'/assets/imgs/doodle-logo.png',
);
ctx.drawImage(image, logoX, logoY, logoWidth, logoHeight);
break;
}
case 'noLogoImage': {
titleX = 40;
break;
}
case 'minimal': {
titleX = 40;
break;
}
default: {
const image = await getMiniCodeImage(canvas);
ctx.drawImage(image, logoX, logoY, logoWidth, logoHeight);
break;
}
}
// 绘制应用名称(字体大小除以3
ctx.font = 'bold 22px "Microsoft Yahei"';
ctx.fillStyle = '#000';
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
ctx.fillText(appName, titleX, titleY);
// 绘制应用提示(字体大小除以3
ctx.font = '16px "Microsoft Yahei"';
ctx.fillStyle = '#666';
ctx.fillText(appHint, titleX, 66);
// 绘制标题(字体大小除以3
ctx.font = 'bold 22px "Microsoft Yahei"';
ctx.fillStyle = '#000';
ctx.fillText(title, 280, titleY);
// 绘制副标题(字体大小除以3
ctx.font = '16px "Microsoft Yahei"';
ctx.fillStyle = '#666';
ctx.fillText(subTitle, 280, 66);
// 调用回调函数
if (onHeaderDrawn) {
onHeaderDrawn(100);
}
}
/**
* 绘制数学模块迷你页眉的参数接口(尺寸除以3)
*/
interface DrawMathMiniHeaderParams {
// canvas: WechatMiniprogram.Canvas;
ctx: RenderingContext;
options: {
appName: string;
title: string;
};
canvasWidth: number; // 逻辑像素宽度(已除以3
onHeaderDrawn?: (currentY: number) => void;
}
/**
* 绘制数学模块迷你页眉(尺寸除以3)
*/
export function drawMathMiniHeader({
ctx,
options,
canvasWidth,
onHeaderDrawn,
}: DrawMathMiniHeaderParams): void {
const { appName, title } = options;
const titleY = 46;
const centerX = canvasWidth / 2;
// 字体大小除以3
ctx.font = 'bold 24px "Microsoft Yahei"'; // 64/3
ctx.fillStyle = '#000';
ctx.textAlign = 'center';
ctx.fillText(appName + ' ' + title, centerX, titleY);
// 调用回调函数,传递除以3后的currentY
if (onHeaderDrawn) {
onHeaderDrawn(64); // 约66.67
}
}
@@ -0,0 +1,76 @@
import { BaseMathDrawService } from './baseMathDraw';
import { drawNumberPreview } from './numberPreviewDraw';
import { drawNumberContent } from './numberContentDraw';
import { drawNumberWriteContent } from './numberWriteDraw';
/**
* 数字涂色绘制服务
* 组合使用基础绘制服务、预览区域绘制服务和内容区域绘制服务
*/
class NumberColorDraw extends BaseMathDrawService {
selectedNumber: number;
functionId: string; // 功能ID,用于判断绘制类型
constructor(
canvas: Canvas,
ctx: RenderingContext,
options?: Record<string, any>,
) {
super(canvas, ctx, options);
this.selectedNumber = 0;
this.functionId = options?.functionId || 'number-find';
}
async draw(selectedNumber: number) {
if (selectedNumber <= 0 || selectedNumber > 10) {
return;
}
this.setPrintConfig();
this.selectedNumber = selectedNumber;
this.clear();
this.setPaper();
// 绘制Header
if (this.headerType !== 'minimal') {
await this.drawHeader();
} else {
this.drawMiniHeader();
}
// 绘制预览区域
this.drawDivider();
await drawNumberPreview({
canvas: this.canvas,
ctx: this.ctx,
selectedNumber: this.selectedNumber,
canvasWidth: this.canvasWidth,
startY: this.currentY,
onDrawn: (currentY) => {
this.currentY = currentY;
},
});
// 绘制内容区域(根据 functionId 选择不同的绘制方法)
this.drawDivider();
if (this.functionId === 'number-write') {
// 书写类型:绘制书写行
drawNumberWriteContent({
ctx: this.ctx,
selectedNumber: this.selectedNumber,
canvasWidth: this.canvasWidth,
startY: this.currentY,
});
} else {
// 默认类型(number-find):绘制数字涂色内容
drawNumberContent({
ctx: this.ctx,
selectedNumber: this.selectedNumber,
canvasWidth: this.canvasWidth,
startY: this.currentY,
});
}
}
}
export default NumberColorDraw;
@@ -0,0 +1,78 @@
/**
* 绘制内容区域的参数接口
*/
interface DrawNumberContentParams {
ctx: RenderingContext;
selectedNumber: number;
canvasWidth: number; // 逻辑像素宽度(已除以3
startY: number; // 起始Y坐标
}
/**
* 绘制数字涂色内容区域服务
* 随机绘制1-10的数字,尺寸除以3
*/
export function drawNumberContent({
ctx,
selectedNumber,
canvasWidth,
startY,
}: DrawNumberContentParams): void {
const rows = 6;
const radius = 26; // 约26.67
const fontSize = 24; // 24
const startYPos = startY + 10 + radius; // 约47.33
const startX1 = 100 + radius; // 约112.22
const startX2 = 60 + radius; // 约75.56
const spaceWidthFirst = (canvasWidth - startX1 * 2) / (5 - 1);
const spaceWidthSecond = (canvasWidth - startX2 * 2) / (6 - 1);
const spaceHeight = 18; // 18
ctx.moveTo(startX1, startYPos);
for (let i = 0; i < rows; i++) {
const cols = i % 2 === 0 ? 5 : 6;
for (let j = 0; j < cols; j++) {
const y = startYPos + i * (spaceHeight + radius * 2);
const x =
i % 2 === 0
? startX1 + j * spaceWidthFirst
: startX2 + j * spaceWidthSecond;
// 随机生成1-10的数字,但确保包含选中的数字
let number: number;
if (i === 0 && j === 0) {
// 第一个位置固定为选中的数字
number = selectedNumber;
} else {
// 其他位置随机,但增加选中数字出现的概率
const random = Math.random();
if (random < 0.3) {
// 30%概率是选中的数字
number = selectedNumber;
} else {
// 70%概率是其他随机数字
number = Math.floor(Math.random() * 10) + 1;
}
}
// 绘制圆形背景(线宽除以3
ctx.fillStyle = '#fff';
ctx.strokeStyle = '#000';
ctx.lineWidth = 1; // 约1.33
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
ctx.closePath();
// 绘制数字(字体大小除以3
ctx.fillStyle = '#333';
ctx.font = `bold ${fontSize}px "Microsoft Yahei"`;
ctx.textBaseline = 'middle';
ctx.textAlign = 'center';
ctx.fillText(String(number), x, y, radius * 2);
}
}
}
@@ -0,0 +1,138 @@
import { getImage } from '../../utils/index';
/**
* 数字到英文单词的映射
*/
const NUMBER_WORDS: Record<number, string> = {
1: 'one',
2: 'two',
3: 'three',
4: 'four',
5: 'five',
6: 'six',
7: 'seven',
8: 'eight',
9: 'nine',
10: 'ten',
};
const NUMBER_COLORS: Record<number, string> = {
1: '#9FC558',
2: '#CDB984',
3: '#B393B8',
4: '#ED7E97',
5: '#53B5B9',
6: '#B3B3B3',
7: '#EE8B7A',
8: '#F6C644',
9: '#99C1F4',
10: '#F5D25C',
};
/**
* 绘制预览区域的参数接口
*/
interface DrawNumberPreviewParams {
canvas: WechatMiniprogram.Canvas;
ctx: RenderingContext;
selectedNumber: number;
canvasWidth: number; // 逻辑像素宽度(已除以3
startY: number; // 起始Y坐标
onDrawn?: (currentY: number) => void; // 绘制完成后的回调
}
/**
* 绘制数字预览区域服务
* 左侧:animal-number 图片
* 右侧:两个框,第一个框是finger图片,第二个框是英文单词
* 所有尺寸除以3
*/
export async function drawNumberPreview({
canvas,
ctx,
selectedNumber,
canvasWidth,
startY,
onDrawn,
}: DrawNumberPreviewParams): Promise<void> {
// 左侧 animal-number 图片位置
const animalImageWidth = 360;
const animalImageHeight = 236;
const leftMargin = 35;
const animalImageX = leftMargin;
const animalImageY = startY + 10;
// 加载并绘制 animal-number 图片
try {
const animalImagePath = `/mathPages/assets/animal-number/animal-number-${selectedNumber}.png`;
const animalImage = await getImage(canvas, animalImagePath);
ctx.drawImage(
animalImage,
animalImageX,
animalImageY,
animalImageWidth,
animalImageHeight,
);
} catch (error) {
console.error('加载animal-number图片失败:', error);
}
// 右侧两个框的位置(逻辑像素,除以3)
const boxWidth = 130;
const boxHeight = 100;
const rightMargin = 35;
const boxSpacing = 30;
const boxX = canvasWidth - rightMargin - boxWidth;
// 第一个框:finger 图片
const fingerBoxY = animalImageY;
ctx.strokeStyle = '#000';
ctx.lineWidth = 1;
ctx.strokeRect(boxX, fingerBoxY, boxWidth, boxHeight);
try {
const fingerImagePath = `/mathPages/assets/finger/finger-${selectedNumber}.png`;
const fingerImage = await getImage(canvas, fingerImagePath);
// 计算图片在框内的位置(居中显示)
const imagePadding = 10;
const imageWidth = boxWidth - imagePadding * 2;
const imageHeight = boxHeight - imagePadding * 2;
ctx.drawImage(
fingerImage,
boxX + imagePadding,
fingerBoxY + imagePadding,
imageWidth,
imageHeight,
);
} catch (error) {
console.error('加载finger图片失败:', error);
}
// 第二个框:英文单词
const wordBoxY = fingerBoxY + boxHeight + boxSpacing;
ctx.strokeRect(boxX, wordBoxY, boxWidth, boxHeight);
// 绘制英文单词(字体大小除以3
const word = NUMBER_WORDS[selectedNumber] || '';
const color = NUMBER_COLORS[selectedNumber] || '#000';
ctx.fillStyle = color;
// 推荐使用系统内常见的圆体或幼圆体字体,更适合儿童视觉风格
ctx.font =
'bold 32px "HarmonyOS Sans", "PingFang SC", "YouYuan", "Microsoft Yahei", sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(
word,
boxX + boxWidth / 2,
wordBoxY + boxHeight / 2,
boxWidth - 20,
);
// 更新 currentY 为预览区域底部
const finalY =
Math.max(animalImageY + animalImageHeight, wordBoxY + boxHeight) + 10;
if (onDrawn) {
onDrawn(finalY);
}
}
@@ -0,0 +1,99 @@
/**
* 绘制书写行内容区域的参数接口
*/
interface DrawNumberWriteContentParams {
ctx: RenderingContext;
selectedNumber: number;
canvasWidth: number; // 逻辑像素宽度
startY: number; // 起始Y坐标
}
/**
* 绘制数字书写内容区域服务
* 绘制三条书写行,每条行有三条线(上下实线,中间虚线)
* 第一行和第二行有数字(第一个黑色实线,后续灰色虚线),第三行空白
*/
export function drawNumberWriteContent({
ctx,
selectedNumber,
canvasWidth,
startY,
}: DrawNumberWriteContentParams): void {
const lineSpacing = 30; // 行间距(三条线之间的间距)
const rowHeight = lineSpacing * 2; // 每行的高度(两条实线之间的距离)
const rowMargin = 24; // 行与行之间的间距
const leftMargin = 24; // 左边距
const rightMargin = 24; // 右边距
const numberIndent = selectedNumber === 10 ? 12 : 24; // 数字缩进(避免贴边)
const numberSpacing = 90; // 数字之间的间距
const numberCount = 6; // 每行数字的数量(第一个实线 + 6个虚线)
const fontSize = 76; // 数字字体大小(调整为行高的83%,避免超出上下边线)
// 计算每行的起始Y坐标
const rowStartY = startY + 20; // 顶部间距
// 绘制三条书写行
for (let rowIndex = 0; rowIndex < 5; rowIndex++) {
const rowY = rowStartY + rowIndex * (rowHeight + rowMargin);
const topLineY = rowY;
const middleLineY = rowY + lineSpacing;
const bottomLineY = rowY + rowHeight;
// 绘制上实线(蓝色)
ctx.strokeStyle = '#4A90E2'; // 蓝色
ctx.lineWidth = 1;
ctx.setLineDash([]); // 实线
ctx.beginPath();
ctx.moveTo(leftMargin, topLineY);
ctx.lineTo(canvasWidth - rightMargin, topLineY);
ctx.stroke();
// 绘制中间虚线(橙色)
ctx.strokeStyle = '#FF8C42'; // 橙色
ctx.lineWidth = 1;
ctx.setLineDash([12, 12]); // 虚线模式
ctx.beginPath();
ctx.moveTo(leftMargin, middleLineY);
ctx.lineTo(canvasWidth - rightMargin, middleLineY);
ctx.stroke();
ctx.setLineDash([]); // 恢复实线模式
// 绘制下实线(蓝色)
ctx.strokeStyle = '#4A90E2'; // 蓝色
ctx.lineWidth = 1;
ctx.setLineDash([]); // 实线
ctx.beginPath();
ctx.moveTo(leftMargin, bottomLineY);
ctx.lineTo(canvasWidth - rightMargin, bottomLineY);
ctx.stroke();
// 第一行和第二行绘制数字
if (rowIndex < 3) {
const centerY = rowY + lineSpacing; // 数字中心Y坐标(中间虚线位置)
for (let i = 0; i < numberCount; i++) {
const numberX = leftMargin + numberIndent + i * numberSpacing;
if (i === 0) {
// 第一个数字:黑色实线
ctx.fillStyle = '#000000';
ctx.font = `bold ${fontSize}px "Microsoft Yahei"`;
ctx.textBaseline = 'middle';
ctx.textAlign = 'left';
ctx.fillText(String(selectedNumber), numberX, centerY);
} else {
// 后续数字:灰色虚线(使用灰色填充配合透明度模拟虚线效果)
const savedAlpha = ctx.globalAlpha;
ctx.globalAlpha = 0.6; // 设置透明度模拟虚线效果
ctx.fillStyle = '#999999'; // 灰色
ctx.font = `${fontSize}px "Microsoft Yahei"`;
ctx.textBaseline = 'middle';
ctx.textAlign = 'left';
ctx.fillText(String(selectedNumber), numberX, centerY);
ctx.globalAlpha = savedAlpha; // 恢复透明度
}
}
}
// 第三行不绘制数字(空白行)
}
}