My Personal Blog

Published on

Canvas随机生成树

Authors
  • avatar
    Name
    Tian Haipeng

第一步:设置坐标系

const canvas = document.getElementById('treeCanvas');
const ctx = canvas.getContext('2d');

建立坐标系统:

  • Canvas坐标系:左上角(0,0),向右为X轴正方向,向下为Y轴正方向
  • 画布大小:800px × 600px
  • 树的起始位置:画布底部中央 (400, 550)

为什么要先考虑坐标系: 所有的绘制都基于坐标计算,搞清楚坐标系是绘图的基础。

第二步:确定drawBranch方法要接收哪些参数

思考:要画一根树枝,我们需要知道什么信息?

function drawBranch(x, y, length, angle, thickness) {
    // x, y: 起点坐标 - 这根树枝从哪里开始
    // length: 分支的长度 - 这根树枝有多长
    // angle: 分支的角度 - 这根树枝朝哪个方向长
    // thickness: 分支的粗细 - 这根树枝有多粗
}

参数设计思考:

  • 起点坐标(x,y) - 必须知道从哪里开始画
  • 长度(length) - 决定树枝的大小
  • 角度(angle) - 决定树枝的方向
  • 粗细(thickness) - 决定树枝的视觉效果

第三步:首先画主干

function drawMainTrunk() {
    const startX = canvas.width / 2;  // 画布中央
    const startY = canvas.height - 50; // 距离底部50px

    // 设置主干样式
    ctx.strokeStyle = '#8B4513'; // 棕色
    ctx.lineWidth = 8;           // 粗线
    ctx.lineCap = 'round';       // 圆形端点

    // 绘制主干
    ctx.beginPath();
    ctx.moveTo(startX, startY);
    ctx.lineTo(startX, startY - 60); // 向上画60px
    ctx.stroke();

    return {
        endX: startX,
        endY: startY - 60  // 主干的终点,也是第一根分支的起点
    };
}

为什么先画主干:

  • 主干是固定的,不需要复杂计算
  • 主干的终点是分支的起点
  • 建立整棵树的基础结构

第四步:已知起点、长度、角度,如何算出终点

这是关键的数学计算!

// 已知条件:
const startX = 400;    // 起点X坐标
const startY = 490;    // 起点Y坐标  
const length = 80;     // 分支长度
const angle = -Math.PI / 4; // 分支角度(-45度,向左上)

// 计算终点:
const endX = startX + Math.cos(angle) * length;
const endY = startY + Math.sin(angle) * length;

数学原理图解:

     终点(endX, endY)
     /  
    /   length
   /    
  / angle
起点(startX, startY)

水平位移 = length × cos(angle)
垂直位移 = length × sin(angle)
终点X = 起点X + 水平位移
终点Y = 起点Y + 垂直位移

角度说明:

  • 0度:向右
  • -Math.PI/2 (-90度):向上
  • Math.PI/2 (90度):向下
  • Math.PI (180度):向左

第五步:画线段连接到终点

function drawBranchLine(startX, startY, endX, endY, thickness, color) {
    // 设置线条样式
    ctx.strokeStyle = color;
    ctx.lineWidth = thickness;
    ctx.lineCap = 'round';

    // 绘制线段
    ctx.beginPath();
    ctx.moveTo(startX, startY);
    ctx.lineTo(endX, endY);
    ctx.stroke();
}

绘制过程:

  1. 设置线条颜色和粗细
  2. 开始新路径
  3. 移动到起点
  4. 画线到终点
  5. 执行绘制

第六步:画出左分支和右分支

function drawBranch(x, y, length, angle, thickness, depth) {
    // 计算当前分支的终点
    const endX = x + Math.cos(angle) * length;
    const endY = y + Math.sin(angle) * length;

    // 绘制当前分支
    drawBranchLine(x, y, endX, endY, thickness, getColor(depth));

    // 生成左分支
    const leftAngle = angle - Math.PI / 6;  // 向左偏转30度
    const leftLength = length * 0.7;      // 长度缩短到70%
    const leftThickness = thickness * 0.8; // 粗细减少到80%

    // 生成右分支  
    const rightAngle = angle + Math.PI / 6;  // 向右偏转30度
    const rightLength = length * 0.7;
    const rightThickness = thickness * 0.8;

    // 递归绘制子分支(下一步会加递归控制)
    drawBranch(endX, endY, leftLength, leftAngle, leftThickness, depth - 1);
    drawBranch(endX, endY, rightLength, rightAngle, rightThickness, depth - 1);
}

分支生成策略:

  • 左分支向左偏转30度
  • 右分支向右偏转30度
  • 子分支长度是父分支的70%
  • 子分支粗细是父分支的80%

第七步:防止无限递归

function drawBranch(x, y, length, angle, thickness, depth) {
    // 递归终止条件
    if (depth <= 0 || length < 5) {
        return; // 停止递归
    }

    // 计算终点
    const endX = x + Math.cos(angle) * length;
    const endY = y + Math.sin(angle) * length;

    // 绘制当前分支
    drawBranchLine(x, y, endX, endY, thickness, getColor(depth));

    // 添加随机性,让树更自然
    const numBranches = Math.random() > 0.3 ? 2 : 3; // 70%概率2分支,30%概率3分支

    for (let i = 0; i < numBranches; i++) {
        // 随机角度偏移
        const angleOffset = (Math.random() - 0.5) * Math.PI / 3; // ±60度范围
        const newAngle = angle + angleOffset;

        // 随机长度缩放
        const lengthScale = 0.6 + Math.random() * 0.3; // 60%-90%
        const newLength = length * lengthScale;

        const newThickness = thickness * 0.8;

        // 异步绘制,创造生长动画
        setTimeout(() => {
            drawBranch(endX, endY, newLength, newAngle, newThickness, depth - 1);
        }, Math.random() * 100);
    }
}

防止无限递归的方法:

  1. 深度控制depth <= 0 时停止
  2. 长度控制length < 5 时停止(太小没必要画)
  3. 深度递减:每次递归 depth - 1

第八步:画一些花(叶子)

function maybeDrawLeaf(x, y, depth) {
    // 只在细分支上画叶子
    if (depth <= 2 && Math.random() > 0.6) {
        drawLeaf(x, y);
    }
}

function drawLeaf(x, y) {
    // 随机叶子颜色(绿色系)
    const hue = 100 + Math.random() * 40; // 100°-140°绿色
    ctx.fillStyle = `hsl(${hue}, 70%, 45%)`;

    // 随机叶子大小
    const size = 2 + Math.random() * 3;

    // 画圆形叶子
    ctx.beginPath();
    ctx.arc(x, y, size, 0, Math.PI * 2);
    ctx.fill();
}

// 在drawBranch函数末尾添加
function drawBranch(x, y, length, angle, thickness, depth) {
    // ... 前面的代码 ...

    // 在分支末端可能添加叶子
    maybeDrawLeaf(endX, endY, depth);
}

叶子绘制策略:

  • 只在细分支(depth ≤ 2)上画叶子
  • 40%的概率在分支末端添加叶子
  • 叶子颜色:绿色系随机变化
  • 叶子大小:2-5像素的圆形

第九步:启动

function generateTree() {
    // 清空画布
    ctx.clearRect(0, 0, canvas.width, canvas.height);

    // 画主干
    const trunk = drawMainTrunk();

    // 开始递归绘制分支
    const initialAngle = -Math.PI / 2; // 向上
    const initialLength = 100;
    const initialThickness = 6;
    const maxDepth = 8;

    drawBranch(trunk.endX, trunk.endY, initialLength, initialAngle, initialThickness, maxDepth);
}

// 页面加载后生成第一棵树
generateTree();

最后让Claude美化一下样式 就完成啦

在线演示

下面是一个使用 Canvas 生成随机树的在线演示,点击"生成新树"按钮可以创建不同的树形图案。