diff --git a/miniprogram/app.json b/miniprogram/app.json index cd7f162..dc7f88f 100644 --- a/miniprogram/app.json +++ b/miniprogram/app.json @@ -29,7 +29,8 @@ "shape/shape", "positionColoring/positionColoring", "shapeSymbol/shapeSymbol", - "colorPattern/colorPattern" + "colorPattern/colorPattern", + "matchConnect/matchConnect" ], "independent": false } diff --git a/miniprogram/app.ts b/miniprogram/app.ts index 818bc87..aab5995 100644 --- a/miniprogram/app.ts +++ b/miniprogram/app.ts @@ -11,6 +11,12 @@ App({ onLaunch() { const accountInfo = wx.getAccountInfoSync(); const env = accountInfo.miniProgram.envVersion || 'release'; + /** + * 设置环境变量 + * 开发环境:develop + * 体验环境:trial + * 正式环境:release + */ this.globalData.env = env; // 从 localStorage 读取 UUID,如果没有则生成并存储 diff --git a/miniprogram/constants/focusFunctions.ts b/miniprogram/constants/focusFunctions.ts index 16a5b6d..5b11e3b 100644 --- a/miniprogram/constants/focusFunctions.ts +++ b/miniprogram/constants/focusFunctions.ts @@ -40,6 +40,14 @@ export const FOCUS_FUNCTION_TYPES: FocusFunctionType[] = [ desc: '观察颜色规律,在空白图形中涂上颜色', icon: '🎨', }, + // 连连看 + { + id: 'match-connect', + page: 'matchConnect', + title: '连连看', + desc: '快来根据物品连一连吧!', + icon: '🔗', + }, // 格子仿画 { id: 'grid-drawing-3x3', diff --git a/miniprogram/focusPages/matchConnect/matchConnect.json b/miniprogram/focusPages/matchConnect/matchConnect.json new file mode 100644 index 0000000..3196d2d --- /dev/null +++ b/miniprogram/focusPages/matchConnect/matchConnect.json @@ -0,0 +1,12 @@ +{ + "navigationBarTitleText": "连连看", + "navigationBarBackgroundColor": "#FFD719", + "homeButton": true, + "backgroundColor": "#F6F6F6", + "enablePullDownRefresh": false, + "usingComponents": { + "toy-button": "../../ui/button/button", + "share-guide-popup": "../../components/share-guide-popup/share-guide-popup", + "math-bottom-buttons": "../../components/math-bottom-buttons/math-bottom-buttons" + } +} diff --git a/miniprogram/focusPages/matchConnect/matchConnect.ts b/miniprogram/focusPages/matchConnect/matchConnect.ts new file mode 100644 index 0000000..f565fb0 --- /dev/null +++ b/miniprogram/focusPages/matchConnect/matchConnect.ts @@ -0,0 +1,97 @@ +import MatchConnectDraw from '../shared/service/matchConnectDraw'; +import { createFocusPage } from '../shared/common/focusPageMixin'; +import { MatchConnectData } from '../shared/service/matchConnectDraw'; + +createFocusPage({ + canvas: null as Canvas | null, + ctx: null as RenderingContext | null, + boxHeight: 0, + boxWidth: 0, + drawService: null as MatchConnectDraw | null, + matchData: null as MatchConnectData | null, + + data: { + pageTitle: '连连看', + functionId: '', + hasContent: false, + showShareDialog: false, + }, + + onLoad(options: { id?: string }) { + const functionId = options.id || 'match-connect'; + this.setData({ + functionId, + }); + this.initPageInfo(functionId, '连连看'); + }, + + onReady() { + this.initCanvas({ + createDrawService: ( + canvas: Canvas, + ctx: RenderingContext, + options?: Record, + ) => { + return new MatchConnectDraw(canvas, ctx, options); + }, + drawServiceOptions: { + title: this.data.pageTitle, + subTitle: '快来根据物品连一连吧!', + functionId: this.data.functionId, + }, + onCanvasReady: () => { + // 初始随机生成 + this.onRandom(); + }, + }); + }, + + /** + * 绘制Canvas内容 + */ + async drawCanvas() { + if (!this.ctx || !this.drawService || !this.matchData) { + return; + } + + try { + await this.drawService.draw(this.matchData); + this.setData({ hasContent: true }); + } catch (error) { + console.error('绘制失败:', error); + this.setData({ hasContent: false }); + } + }, + + /** + * 随机生成 + */ + onRandom() { + // 从33张图片中随机选择5张不重复的图片 + const allImageIndices = Array.from({ length: 33 }, (_, i) => i + 1); + const shuffled = [...allImageIndices].sort(() => Math.random() - 0.5); + const selectedImages = shuffled.slice(0, 5); + + // 创建参考序列(顺序) + const referenceSequence = [...selectedImages]; + + // 创建6个框,每个框包含5张图片的索引(位置由绘制服务随机生成) + const boxes: Array<{ + imageIndices: number[]; + }> = []; + + for (let boxIndex = 0; boxIndex < 6; boxIndex++) { + // 每个框使用相同的5张图片 + boxes.push({ + imageIndices: [...selectedImages], + }); + } + + this.matchData = { + referenceSequence, + boxes, + }; + + this.drawCanvas(); + }, +}); diff --git a/miniprogram/focusPages/matchConnect/matchConnect.wxml b/miniprogram/focusPages/matchConnect/matchConnect.wxml new file mode 100644 index 0000000..5d3cbdb --- /dev/null +++ b/miniprogram/focusPages/matchConnect/matchConnect.wxml @@ -0,0 +1,37 @@ + + + 预览打印效果 + + + + + + + + + + 随机生成 + + + + + + + diff --git a/miniprogram/focusPages/matchConnect/matchConnect.wxss b/miniprogram/focusPages/matchConnect/matchConnect.wxss new file mode 100644 index 0000000..ddc41cc --- /dev/null +++ b/miniprogram/focusPages/matchConnect/matchConnect.wxss @@ -0,0 +1 @@ +@import '../../base/baseDrawPage.wxss'; diff --git a/miniprogram/focusPages/shared/service/matchConnectDraw.ts b/miniprogram/focusPages/shared/service/matchConnectDraw.ts new file mode 100644 index 0000000..6746759 --- /dev/null +++ b/miniprogram/focusPages/shared/service/matchConnectDraw.ts @@ -0,0 +1,461 @@ +/** + * 连连看绘制服务 + */ + +import { BaseDrawService } from '../../../service/baseDraw'; +import { getImage } from '../../../utils/index'; + +/** + * 连连看数据 + */ +export interface MatchConnectData { + /** 参考序列:5张图片的索引数组 */ + referenceSequence: number[]; + /** 6个框,每个框包含5张图片的索引(位置由绘制服务随机生成) */ + boxes: Array<{ + imageIndices: number[]; + }>; +} + +/** + * 连连看绘制服务 + */ +class MatchConnectDraw extends BaseDrawService { + matchData: MatchConnectData | null = null; + + constructor( + canvas: Canvas, + ctx: RenderingContext, + options?: Record, + ) { + super(canvas, ctx, options); + } + + /** + * 绘制连连看内容 + */ + async draw(matchData: MatchConnectData) { + if (!matchData || !matchData.referenceSequence || !matchData.boxes) { + return; + } + + this.setPrintConfig(); + this.matchData = matchData; + this.clear(); + this.setPaper(); + + // 绘制Header + if (this.headerType !== 'minimal') { + await this.drawHeader(); + } else { + this.drawMiniHeader(); + } + + // 绘制内容区域 + this.drawDivider(); + await this.drawContent({ + canvas: this.canvas, + ctx: this.ctx, + matchData: this.matchData, + canvasWidth: this.canvasWidth, + startY: this.currentY, + }); + } + + /** + * 生成不重叠的图片位置 + * @param count 图片数量 + * @param contentWidth 内容区域宽度 + * @param contentHeight 内容区域高度 + * @param imageSize 图片大小 + * @param imagePadding 图片padding + * @returns 图片位置数组,每个位置包含x和y(相对于内容区域的坐标) + */ + private generateNonOverlappingPositions( + count: number, + contentWidth: number, + contentHeight: number, + imageSize: number, + imagePadding: number, + ): Array<{ x: number; y: number }> { + // 计算最大可放置位置(图片左上角的位置) + const maxX = contentWidth - imageSize; + const maxY = contentHeight - imageSize; + const positions: Array<{ x: number; y: number }> = []; + const placedRects: Array<{ + left: number; + top: number; + right: number; + bottom: number; + }> = []; + + /** + * 检查两个矩形是否重叠 + */ + const isRectOverlap = ( + rect1: { + left: number; + top: number; + right: number; + bottom: number; + }, + rect2: { + left: number; + top: number; + right: number; + bottom: number; + }, + ): boolean => { + return !( + rect1.right < rect2.left || + rect1.left > rect2.right || + rect1.bottom < rect2.top || + rect1.top > rect2.bottom + ); + }; + + // 计算图片的实际占用大小(包含padding) + const occupiedSize = imageSize + imagePadding * 2; + + // 生成候选位置网格,然后随机打乱 + // 这样可以确保位置分布更均匀,同时保持随机性 + const gridCols = Math.max(3, Math.floor(contentWidth / occupiedSize)); + const gridRows = Math.max(2, Math.floor(contentHeight / occupiedSize)); + const cellWidth = maxX / Math.max(1, gridCols - 1); + const cellHeight = maxY / Math.max(1, gridRows - 1); + + // 生成所有可能的网格位置 + const candidatePositions: Array<{ x: number; y: number }> = []; + for (let row = 0; row < gridRows; row++) { + for (let col = 0; col < gridCols; col++) { + const x = col * cellWidth; + const y = row * cellHeight; + candidatePositions.push({ x, y }); + } + } + + // 随机打乱候选位置 + const shuffledCandidates = [...candidatePositions].sort( + () => Math.random() - 0.5, + ); + + // 从打乱的候选位置中选择不重叠的位置 + for (let i = 0; i < count; i++) { + let found = false; + + // 先尝试从网格候选位置中选择 + for (const candidate of shuffledCandidates) { + const x = candidate.x; + const y = candidate.y; + + // 计算当前图片的实际占用区域(包含padding) + const currentRect = { + left: x - imagePadding, + top: y - imagePadding, + right: x + imageSize + imagePadding, + bottom: y + imageSize + imagePadding, + }; + + // 确保不超出内容区域的范围 + if ( + currentRect.left < 0 || + currentRect.top < 0 || + currentRect.right > contentWidth || + currentRect.bottom > contentHeight + ) { + continue; + } + + // 检查是否与已放置的图片重叠 + let overlaps = false; + for (const placedRect of placedRects) { + if (isRectOverlap(currentRect, placedRect)) { + overlaps = true; + break; + } + } + + if (!overlaps) { + // 存储实际占用区域 + placedRects.push({ + left: x - imagePadding, + top: y - imagePadding, + right: x + imageSize + imagePadding, + bottom: y + imageSize + imagePadding, + }); + + positions.push({ x, y }); + found = true; + break; + } + } + + // 如果网格位置都不行,尝试完全随机的位置 + if (!found) { + let attempts = 0; + const maxAttempts = 1000; + + while (attempts < maxAttempts) { + // 生成随机位置,添加一些随机偏移 + const randomOffsetX = + (Math.random() - 0.5) * cellWidth * 0.5; + const randomOffsetY = + (Math.random() - 0.5) * cellHeight * 0.5; + const baseX = Math.random() * maxX; + const baseY = Math.random() * maxY; + const x = Math.max( + 0, + Math.min(maxX, baseX + randomOffsetX), + ); + const y = Math.max( + 0, + Math.min(maxY, baseY + randomOffsetY), + ); + + // 计算当前图片的实际占用区域(包含padding) + const currentRect = { + left: x - imagePadding, + top: y - imagePadding, + right: x + imageSize + imagePadding, + bottom: y + imageSize + imagePadding, + }; + + // 确保不超出内容区域的范围 + if ( + currentRect.left < 0 || + currentRect.top < 0 || + currentRect.right > contentWidth || + currentRect.bottom > contentHeight + ) { + attempts++; + continue; + } + + // 检查是否与已放置的图片重叠 + let overlaps = false; + for (const placedRect of placedRects) { + if (isRectOverlap(currentRect, placedRect)) { + overlaps = true; + break; + } + } + + if (!overlaps) { + // 存储实际占用区域 + placedRects.push({ + left: x - imagePadding, + top: y - imagePadding, + right: x + imageSize + imagePadding, + bottom: y + imageSize + imagePadding, + }); + + positions.push({ x, y }); + found = true; + break; + } + + attempts++; + } + } + + // 最后的后备方案:使用网格布局,但添加随机偏移 + if (!found) { + const gridCols = 3; + const gridRows = 2; + const gridCellWidth = maxX / Math.max(1, gridCols - 1); + const gridCellHeight = maxY / Math.max(1, gridRows - 1); + const baseX = (i % gridCols) * gridCellWidth; + const baseY = Math.floor(i / gridCols) * gridCellHeight; + // 添加随机偏移,但确保不重叠 + const offsetX = (Math.random() - 0.5) * gridCellWidth * 0.3; + const offsetY = (Math.random() - 0.5) * gridCellHeight * 0.3; + const x = Math.max(0, Math.min(maxX, baseX + offsetX)); + const y = Math.max(0, Math.min(maxY, baseY + offsetY)); + positions.push({ x, y }); + } + } + + // 最后再次随机打乱位置顺序,增加随机性 + return positions.sort(() => Math.random() - 0.5); + } + + /** + * 绘制箭头 + */ + private drawArrow( + ctx: RenderingContext, + x1: number, + y1: number, + x2: number, + y2: number, + ) { + ctx.strokeStyle = '#FF69B4'; // 粉色 + ctx.lineWidth = 2; + ctx.setLineDash([]); // 实线 + + // 绘制箭头线 + ctx.beginPath(); + ctx.moveTo(x1, y1); + ctx.lineTo(x2, y2); + ctx.stroke(); + + // 绘制箭头头部 + const angle = Math.atan2(y2 - y1, x2 - x1); + const arrowLength = 8; + const arrowAngle = Math.PI / 6; // 30度 + + ctx.beginPath(); + ctx.moveTo(x2, y2); + ctx.lineTo( + x2 - arrowLength * Math.cos(angle - arrowAngle), + y2 - arrowLength * Math.sin(angle - arrowAngle), + ); + ctx.moveTo(x2, y2); + ctx.lineTo( + x2 - arrowLength * Math.cos(angle + arrowAngle), + y2 - arrowLength * Math.sin(angle + arrowAngle), + ); + ctx.stroke(); + } + + /** + * 绘制内容区域 + */ + private async drawContent(params: { + canvas: WechatMiniprogram.Canvas; + ctx: RenderingContext; + matchData: MatchConnectData; + canvasWidth: number; + startY: number; + }): Promise { + const { ctx, matchData, canvasWidth } = params; + let { startY } = params; + + startY = startY + 25; + + const margin = 40; // 左右边距 + const imageSize = 50; // 图片大小 + const arrowLength = 30; // 箭头长度 + const arrowSpacing = 8; // 箭头左右间距 + + // 计算参考序列的总宽度 + // 总宽度 = 所有图片宽度 + 所有箭头长度 + 所有箭头间距(左右各8px) + const arrowCount = matchData.referenceSequence.length - 1; + const referenceWidth = + matchData.referenceSequence.length * imageSize + + arrowCount * arrowLength + + arrowCount * arrowSpacing * 2; // 每个箭头左右各8px + + // 绘制参考序列(上面部分)- 居中显示 + const referenceStartX = + margin + (canvasWidth - margin * 2 - referenceWidth) / 2; + const referenceY = startY; + + // 绘制参考序列的图片和箭头 + let currentX = referenceStartX; + for (let i = 0; i < matchData.referenceSequence.length; i++) { + const imageIndex = matchData.referenceSequence[i]; + + // 绘制图片 + try { + const imagePath = `/focusPages/shared/aeests/material/${imageIndex}.png`; + const image = await getImage(this.canvas, imagePath); + ctx.drawImage( + image, + currentX, + referenceY, + imageSize, + imageSize, + ); + } catch (error) { + console.error(`加载参考图片失败: ${imageIndex}`, error); + } + + // 如果不是最后一张,绘制箭头 + if (i < matchData.referenceSequence.length - 1) { + // 箭头左边间距8px + const arrowStartX = currentX + imageSize + arrowSpacing; + const arrowEndX = arrowStartX + arrowLength; + const arrowY = referenceY + imageSize / 2; + this.drawArrow(ctx, arrowStartX, arrowY, arrowEndX, arrowY); + // 箭头右边间距8px,然后开始下一张图片 + currentX = arrowEndX + arrowSpacing; + } else { + currentX += imageSize; + } + } + + // 绘制虚线分割线 + const dividerY = referenceY + imageSize + 30; + this.drawDashedDivider(dividerY, margin); + + // 绘制下面的6个框(3行2列) + const boxStartY = dividerY + 30; + const boxWidth = (canvasWidth - margin * 2 - 20) / 2; // 减去列间距 + const boxHeight = 165; // 框的高度 + const boxSpacingX = 20; // 列间距 + const boxSpacingY = 30; // 行间距 + const boxPadding = 10; // 框内边距 + const boxImageSize = 35; // 框内图片大小 + const imagePadding = 20; // 每张图片的内边距(padding) + + for (let row = 0; row < 3; row++) { + for (let col = 0; col < 2; col++) { + const boxIndex = row * 2 + col; + const box = matchData.boxes[boxIndex]; + if (!box || !box.imageIndices) continue; + + const boxX = margin + col * (boxWidth + boxSpacingX); + const boxY = boxStartY + row * (boxHeight + boxSpacingY); + + // 绘制框(圆角实线) + this.drawRoundedRect(boxX, boxY, boxWidth, boxHeight, { + isDashed: false, + radius: 16, + color: '#000', + lineWidth: 1, + }); + + // 计算框内可用的内容区域 + const contentWidth = boxWidth - boxPadding * 2; + const contentHeight = boxHeight - boxPadding * 2; + + // 为这个框的图片随机分配位置,确保图片+padding不重叠 + const imagePositions = this.generateNonOverlappingPositions( + box.imageIndices.length, + contentWidth, + contentHeight, + boxImageSize, + imagePadding, + ); + + // 绘制框内的图片 + for (let i = 0; i < box.imageIndices.length; i++) { + const imageIndex = box.imageIndices[i]; + const position = imagePositions[i]; + + try { + const imagePath = `/focusPages/shared/aeests/material/${imageIndex}.png`; + const image = await getImage(this.canvas, imagePath); + + // 计算图片位置 + const imageX = boxX + boxPadding + position.x; + const imageY = boxY + boxPadding + position.y; + + ctx.drawImage( + image, + imageX, + imageY, + boxImageSize, + boxImageSize, + ); + } catch (error) { + console.error(`加载框内图片失败: ${imageIndex}`, error); + } + } + } + } + } +} + +export default MatchConnectDraw; diff --git a/miniprogram/service/baseDraw.ts b/miniprogram/service/baseDraw.ts index df20a00..77f8574 100644 --- a/miniprogram/service/baseDraw.ts +++ b/miniprogram/service/baseDraw.ts @@ -217,4 +217,69 @@ export class BaseDrawService { ctx.stroke(); } } + + /** + * 绘制圆角矩形框(逻辑像素,尺寸除以3) + * @param x 框的X坐标 + * @param y 框的Y坐标 + * @param width 框的宽度 + * @param height 框的高度 + * @param options 可选参数 + * @param options.isDashed 是否为虚线,默认为false(实线) + * @param options.radius 圆角半径,默认为10 + * @param options.color 线条颜色,默认为'#000' + * @param options.lineWidth 线条宽度,默认为1 + */ + drawRoundedRect( + x: number, + y: number, + width: number, + height: number, + options?: { + isDashed?: boolean; + radius?: number; + color?: string; + lineWidth?: number; + }, + ): void { + const { ctx } = this; + const { + isDashed = false, + radius = 10, + color = '#000', + lineWidth = 1, + } = options || {}; + + ctx.strokeStyle = color; + ctx.lineWidth = lineWidth; + + // 设置虚线或实线 + if (isDashed) { + ctx.setLineDash([4, 4]); // 虚线 + } else { + ctx.setLineDash([]); // 实线 + } + + // 绘制圆角矩形 + ctx.beginPath(); + ctx.moveTo(x + radius, y); + ctx.lineTo(x + width - radius, y); + ctx.quadraticCurveTo(x + width, y, x + width, y + radius); + ctx.lineTo(x + width, y + height - radius); + ctx.quadraticCurveTo( + x + width, + y + height, + x + width - radius, + y + height, + ); + ctx.lineTo(x + radius, y + height); + ctx.quadraticCurveTo(x, y + height, x, y + height - radius); + ctx.lineTo(x, y + radius); + ctx.quadraticCurveTo(x, y, x + radius, y); + ctx.closePath(); + ctx.stroke(); + + // 重置为实线(避免影响后续绘制) + ctx.setLineDash([]); + } } diff --git a/miniprogram/utils/shareGuide.ts b/miniprogram/utils/shareGuide.ts index 0cbe419..c5073fc 100644 --- a/miniprogram/utils/shareGuide.ts +++ b/miniprogram/utils/shareGuide.ts @@ -61,10 +61,9 @@ export function recordShareSuccess(): void { * @returns true表示需要显示分享引导,false表示不需要 */ export function shouldShowShareGuide(): boolean { - // TODO: 开启分享引导 - return false; // 如果今天已经分享过,不需要显示引导 - if (hasSharedToday()) { + const env = getApp().globalData.env; + if (env === 'develop' || hasSharedToday()) { return false; } // 如果今天已经下载过,需要显示引导 diff --git a/project.private.config.json b/project.private.config.json index 748ecf1..3158d20 100644 --- a/project.private.config.json +++ b/project.private.config.json @@ -23,12 +23,19 @@ "condition": { "miniprogram": { "list": [ + { + "name": "focusPages/matchConnect/matchConnect", + "pathName": "focusPages/matchConnect/matchConnect", + "query": "id=match-connect", + "scene": null, + "launchMode": "default" + }, { "name": "focusPages/colorPattern/colorPattern", "pathName": "focusPages/colorPattern/colorPattern", "query": "id=color-pattern", - "scene": null, - "launchMode": "default" + "launchMode": "default", + "scene": null }, { "name": "focusPages/shapeSymbol/shapeSymbol",