/** * 连连看绘制服务 */ 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;