Files
2026-04-09 17:43:22 +08:00

451 lines
16 KiB
TypeScript

/**
* 连连看绘制服务
*/
import { BaseDrawService } from '../../../core/draw/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<string, any>,
) {
super(canvas, ctx, options);
}
/**
* 绘制连连看内容
*/
async draw(matchData: MatchConnectData) {
if (!matchData || !matchData.referenceSequence || !matchData.boxes) {
return;
}
this.matchData = matchData;
this.prepareDraw();
await this.drawHeaderAndDivider();
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<void> {
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;