Feng-DDXY / blogs

个人博客 Personal blog(Mainly used for recording and backing up)
0 stars 0 forks source link

ROS编程基础(七):2-4只小乌龟跟随控制(C++)(键盘控制)+画固定形状 #1

Open Feng-DDXY opened 1 week ago

Feng-DDXY commented 1 week ago

使用版本:Ubuntu 16.04 ROS Kinetic(已安装)

以下步骤自行完成(仅附终端命令) 1.在已有工作空间下创建功能包ch10_tutorials1 2.导入std_msgs和roscpp作为该功能包的依赖项

catkin_create_pkg ch10_tutorials1 std_msgs roscpp

两只小乌龟跟随运动(单文件实现)

本文使用TF功能包实现乌龟坐标广播以及跟随,故需要加入TF相关依赖 在功能包内的CMakelists.txt 编写以下语句:(建议ctrl+f搜索)

find_package(catkin REQUIRED COMPONENTS 
roscpp 
std_msgs 
message_generation
tf2_ros
)
catkin_package( 
CATKIN_DEPENDS roscpp  rospy std_msgs message_runtime tf2_ros 
)
add_executable(turtle3 src/turtle3.cpp)
target_link_libraries(turtle3 ${catkin_LIBRARIES})

其中turtle3是你的cpp文件名,如果以其他的命名需要修改(我这里用的turtle4)

159c9c9de14c497e809934a8b4eac7d4 6fd18961bd2e41c1976b8ae495a4e31b

1a92c4ffb20d496386725ed9f2bcc365-1731713132743-33

3804863b18e241498a4486ded9bd66b8-1731713132743-37

在功能包内的package.xml 创建以下语句:

  <depend>tf2_ros</depend>

ab5fe7a26fdd4dfbb0d326e22892273d 在\ch10_tutorials1\src下新建turtle3.cpp文件,并编写以下代码:

#include "ros/ros.h"
#include "turtlesim/Pose.h"
#include "turtlesim/Spawn.h"
#include "tf2_ros/transform_broadcaster.h"
#include "tf2_ros/transform_listener.h"
#include "geometry_msgs/TransformStamped.h"
#include "geometry_msgs/Twist.h"
#include "tf2/LinearMath/Quaternion.h"
#include <cmath>

std::string turtle_name = "turtle1"; //代表要跟随乌龟的名字

void doPose(const turtlesim::Pose::ConstPtr& pose, const std::string& turtle_name) {
    static tf2_ros::TransformBroadcaster broadcaster;
    geometry_msgs::TransformStamped tfs;
    tfs.header.frame_id = "world";
    tfs.header.stamp = ros::Time::now();
    tfs.child_frame_id = turtle_name;

    //设定2只乌龟发布tf坐标的位置
    if (turtle_name == "turtle1") {
        tfs.transform.translation.x = (pose->x) + 1.0;
        tfs.transform.translation.y = (pose->y) + 1.0;
    } else if (turtle_name == "turtle2") {
        tfs.transform.translation.x = pose->x;
        tfs.transform.translation.y = pose->y;
    }
    tfs.transform.translation.z = 0.0;

    tf2::Quaternion qtn;
    qtn.setRPY(0, 0, pose->theta);
    tfs.transform.rotation.x = qtn.getX();
    tfs.transform.rotation.y = qtn.getY();
    tfs.transform.rotation.z = qtn.getZ();
    tfs.transform.rotation.w = qtn.getW();

    broadcaster.sendTransform(tfs);
}

int main(int argc, char *argv[]) {
    setlocale(LC_ALL, "");
    ros::init(argc, argv, "pub_sub_tf");
    ros::NodeHandle nh;
    ros::ServiceClient spawn_client = nh.serviceClient<turtlesim::Spawn>("/spawn");

    //新建2只乌龟
    turtlesim::Spawn spawn_msg;
    spawn_msg.request.x = 5.0;
    spawn_msg.request.y = 5.0;
    spawn_msg.request.theta = 0.0;
    spawn_msg.request.name = "turtle1";
    if (spawn_client.call(spawn_msg)) {
        ROS_INFO("Spawned turtle1 at (5, 5)");
    } else {
        ROS_ERROR("Failed to spawn turtle1");
        return -1;
    }

    spawn_msg.request.x = 8.0;
    spawn_msg.request.y = 5.0;
    spawn_msg.request.theta = 0.0;
    spawn_msg.request.name = "turtle2";
    if (spawn_client.call(spawn_msg)) {
        ROS_INFO("Spawned turtle2 at (8, 5)");
    } else {
        ROS_ERROR("Failed to spawn turtle2");
        return -1;
    }

    ros::Publisher pub = nh.advertise<geometry_msgs::Twist>("/turtle2/cmd_vel", 1000);

    tf2_ros::Buffer buffer;
    tf2_ros::TransformListener listener(buffer);

    //发布turtle1和turtle2的坐标(使用doPose函数)
    ros::Subscriber sub1 = nh.subscribe<turtlesim::Pose>("turtle1/pose", 1000, boost::bind(doPose, _1, "turtle1"));
    ros::Subscriber sub2 = nh.subscribe<turtlesim::Pose>("turtle2/pose", 1000, boost::bind(doPose, _1, "turtle2"));

    //ros::Duration(2.0).sleep();  //可以加个延迟,但是还是会有WARN(从3个变成1个)

    ros::Rate rate(10);
    while (ros::ok()) {
        try {
            // 获取 turtle1 相对 turtle2 的坐标信息
            geometry_msgs::TransformStamped tfs = buffer.lookupTransform("turtle2", "turtle1", ros::Time(0));

            // 根据坐标信息生成速度信息
            geometry_msgs::Twist twist;
            twist.linear.x = 0.5 * sqrt(pow(tfs.transform.translation.x, 2) + pow(tfs.transform.translation.y, 2));
            twist.angular.z = 4 * atan2(tfs.transform.translation.y, tfs.transform.translation.x);
            pub.publish(twist);
        } catch (const tf2::TransformException& e) {
            ROS_WARN("错误提示:%s", e.what());
        }

        rate.sleep();
        ros::spinOnce();
    }

    return 0;
}

在这里插入图片描述

切换到工作空间下编译工作空间:

cd ~/catkin_ws
catkin_make

使用方法:

分别在4个终端输入以下指令:

roscore
rosrun turtlesim turtlesim_node
rosservice call /kill "turtle1"
rosrun ch10_tutorials1 turtle4

rosservice call /kill "turtle1"是因为代码里没有写kill小乌龟代码,手动在终端里去掉,不然rosrun turtlesim turtlesim_node会自动生成一只turtle1乌龟,导致重名报错

此时窗口里会出现2个小海龟,打开另一个终端输入以下指令控制其中一只运动

rosrun turtlesim turtle_teleop_key

即可看到一个乌龟跟随另一个了 我的代码里写的是跟随的(pose->x) + 1.0;即一直跟随在相对位置右上角),如果只是跟随可以修改为(pose->x)

或者将乌龟2发布的改成(pose->x) - 1.0 同样跟随在右上角效果

4bd8e6e442f5403395694622410916e5-1731713167602-63

四只小乌龟的护航跟随(单文件实现)

这里提供2份写法,第一种是按照上面的相对位置,纯写死偏移的,只需要修改文件内容,将2只扩展成4只即可:

写死版四只乌龟护航

(写的很烂,属于拿来应付截图效果)

#include "ros/ros.h"
#include "turtlesim/Pose.h"
#include "turtlesim/Spawn.h"
#include "tf2_ros/transform_broadcaster.h"
#include "tf2_ros/transform_listener.h"
#include "geometry_msgs/TransformStamped.h"
#include "geometry_msgs/Twist.h"
#include "tf2/LinearMath/Quaternion.h"
#include <cmath>

std::string turtle_name = "turtle1";

void doPose(const turtlesim::Pose::ConstPtr& pose, const std::string& turtle_name) {
    static tf2_ros::TransformBroadcaster broadcaster;
    geometry_msgs::TransformStamped tfs;
    tfs.header.frame_id = "world";
    tfs.header.stamp = ros::Time::now();
    tfs.child_frame_id = turtle_name;
    //通过名字修改相对偏移量
    if (turtle_name == "turtle1") {
        tfs.transform.translation.x = pose->x;
        tfs.transform.translation.y = pose->y;
    } else if (turtle_name == "turtle2") {
        tfs.transform.translation.x = (pose->x)-1.0;
        tfs.transform.translation.y = (pose->y)-1.0;
    } else if (turtle_name == "turtle3") {
        tfs.transform.translation.x = (pose->x)-1.0;
        tfs.transform.translation.y = (pose->y)+1.0;
    } else if (turtle_name == "turtle4") {
        tfs.transform.translation.x = (pose->x)+1.0;
        tfs.transform.translation.y = (pose->y)-1.0;
    }
    tfs.transform.translation.z = 0.0;

    tf2::Quaternion qtn;
    qtn.setRPY(0, 0, pose->theta);
    tfs.transform.rotation.x = qtn.getX();
    tfs.transform.rotation.y = qtn.getY();
    tfs.transform.rotation.z = qtn.getZ();
    tfs.transform.rotation.w = qtn.getW();

    broadcaster.sendTransform(tfs);
}

int main(int argc, char *argv[]) {
    setlocale(LC_ALL, "");

    ros::init(argc, argv, "pub_sub_tf");

    ros::NodeHandle nh;

    ros::ServiceClient spawn_client = nh.serviceClient<turtlesim::Spawn>("/spawn");

    turtlesim::Spawn spawn_msg;

    spawn_msg.request.x = 5.0;
    spawn_msg.request.y = 5.0;
    spawn_msg.request.theta = 0.0;
    spawn_msg.request.name = "turtle1";
    if (spawn_client.call(spawn_msg)) {
        ROS_INFO("Spawned turtle1 at (5, 5)");
    } else {
        ROS_ERROR("Failed to spawn turtle1");
        return -1;
    }

    spawn_msg.request.x = 8.0;
    spawn_msg.request.y = 5.0;
    spawn_msg.request.theta = 0.0;
    spawn_msg.request.name = "turtle2";
    spawn_client.call(spawn_msg);

    spawn_msg.request.name = "turtle3";
    spawn_client.call(spawn_msg);
    spawn_msg.request.name = "turtle4";
    spawn_client.call(spawn_msg);

    ros::Duration(2.0).sleep();
    //将一个改成3个
    ros::Publisher pub = nh.advertise<geometry_msgs::Twist>("/turtle2/cmd_vel", 1000);
    ros::Publisher pub3 = nh.advertise<geometry_msgs::Twist>("/turtle3/cmd_vel", 1000);
    ros::Publisher pub4 = nh.advertise<geometry_msgs::Twist>("/turtle4/cmd_vel", 1000);

    tf2_ros::Buffer buffer;
    tf2_ros::TransformListener listener(buffer);
    //4个位置都要发布
    ros::Subscriber sub1 = nh.subscribe<turtlesim::Pose>("turtle1/pose", 1000, boost::bind(doPose, _1, "turtle1"));
    ros::Subscriber sub2 = nh.subscribe<turtlesim::Pose>("turtle2/pose", 1000, boost::bind(doPose, _1, "turtle2"));
    ros::Subscriber sub3 = nh.subscribe<turtlesim::Pose>("turtle3/pose", 1000, boost::bind(doPose, _1, "turtle3"));
    ros::Subscriber sub4 = nh.subscribe<turtlesim::Pose>("turtle4/pose", 1000, boost::bind(doPose, _1, "turtle4"));
    ros::Rate rate(10);

    while (ros::ok()) {
        try {
            geometry_msgs::TransformStamped tfs = buffer.lookupTransform("turtle2", "turtle1", ros::Time(0));
            geometry_msgs::Twist twist;
            twist.linear.x = 0.5 * sqrt(pow(tfs.transform.translation.x, 2) + pow(tfs.transform.translation.y, 2));
            twist.angular.z = 4 * atan2(tfs.transform.translation.y, tfs.transform.translation.x);
            pub.publish(twist);

            geometry_msgs::TransformStamped tfs2 = buffer.lookupTransform("turtle3", "turtle1", ros::Time(0));
            twist.linear.x = 0.5 * sqrt(pow(tfs2.transform.translation.x, 2) + pow(tfs2.transform.translation.y, 2));
            twist.angular.z = 4 * atan2(tfs2.transform.translation.y, tfs2.transform.translation.x);
            pub3.publish(twist);

            geometry_msgs::TransformStamped tfs3 = buffer.lookupTransform("turtle4", "turtle1", ros::Time(0));
            twist.linear.x = 0.5 * sqrt(pow(tfs3.transform.translation.x, 2) + pow(tfs3.transform.translation.y, 2));
            twist.angular.z = 4 * atan2(tfs3.transform.translation.y, tfs3.transform.translation.x);
            pub4.publish(twist);

        } catch (const tf2::TransformException& e) {
            ROS_WARN("错误提示:%s", e.what());
        }

        rate.sleep();
        ros::spinOnce();
    }

    return 0;
}

这个就不细讲了,属于是纯纯“护”在上下左右,运行方法也和2只的一样(记得kill乌龟1)

乌龟朝左截图效果最好,如果需要换队形自己修改一下参数

世界坐标系左下角是(0,0) 对乌龟2进行x和y都减1是相对乌龟1来说的右上角(即世界坐标系+1)

x减1是往右,y减1是往上

右下角是乌龟3 左上角是乌龟4

image-20241116113502591

例如正方形编队:

image-20241116123142581

image-20241116123129472

通过偏移量进行四只乌龟护航(稍微动态)

也是只需要修改代码:

通过修改tfs 中的坐标改变坐标变换本身的结果进行偏移,运动会更加丝滑(后续所有基于 tfs 的计算都会基于新的坐标偏移)

#include "ros/ros.h"
#include "turtlesim/Pose.h"
#include "turtlesim/Spawn.h"
#include "tf2_ros/transform_broadcaster.h"
#include "tf2_ros/transform_listener.h"
#include "geometry_msgs/TransformStamped.h"
#include "geometry_msgs/Twist.h"
#include "tf2/LinearMath/Quaternion.h"
#include <cmath>

std::string turtle_name = "turtle1";

void doPose(const turtlesim::Pose::ConstPtr& pose, const std::string& turtle_name) {
    static tf2_ros::TransformBroadcaster broadcaster;
    geometry_msgs::TransformStamped tfs;
    tfs.header.frame_id = "world";
    tfs.header.stamp = ros::Time::now();
    tfs.child_frame_id = turtle_name;
    //取消了之前的偏移量
    tfs.transform.translation.x = pose->x;
    tfs.transform.translation.y = pose->y;
    tfs.transform.translation.z = 0.0;
    tf2::Quaternion qtn;
    qtn.setRPY(0, 0, pose->theta);
    tfs.transform.rotation.x = qtn.getX();
    tfs.transform.rotation.y = qtn.getY();
    tfs.transform.rotation.z = qtn.getZ();
    tfs.transform.rotation.w = qtn.getW();
    broadcaster.sendTransform(tfs);
}

int main(int argc, char *argv[]) {
    setlocale(LC_ALL, "");
    ros::init(argc, argv, "pub_sub_tf");
    ros::NodeHandle nh;

    ros::ServiceClient spawn_client = nh.serviceClient<turtlesim::Spawn>("/spawn");
    turtlesim::Spawn spawn_msg;
    spawn_msg.request.x = 5.0;
    spawn_msg.request.y = 5.0;
    spawn_msg.request.theta = 0.0;
    spawn_msg.request.name = "turtle1";
    if (!spawn_client.call(spawn_msg)) {
        return -1;
    }

    spawn_msg.request.x = 8.0;
    spawn_msg.request.y = 5.0;
    spawn_msg.request.theta = 0.0;
    spawn_msg.request.name = "turtle2";
    spawn_client.call(spawn_msg);
    spawn_msg.request.name = "turtle3";
    spawn_client.call(spawn_msg);
    spawn_msg.request.name = "turtle4";
    spawn_client.call(spawn_msg);
    ros::Duration(2.0).sleep();

    ros::Publisher pub = nh.advertise<geometry_msgs::Twist>("/turtle2/cmd_vel", 1000);
    ros::Publisher pub3 = nh.advertise<geometry_msgs::Twist>("/turtle3/cmd_vel", 1000);
    ros::Publisher pub4 = nh.advertise<geometry_msgs::Twist>("/turtle4/cmd_vel", 1000);

    tf2_ros::Buffer buffer;
    tf2_ros::TransformListener listener(buffer);

    ros::Subscriber sub1 = nh.subscribe<turtlesim::Pose>("turtle1/pose", 1000, boost::bind(doPose, _1, "turtle1"));
    ros::Subscriber sub2 = nh.subscribe<turtlesim::Pose>("turtle2/pose", 1000, boost::bind(doPose, _1, "turtle2"));
    ros::Subscriber sub3 = nh.subscribe<turtlesim::Pose>("turtle3/pose", 1000, boost::bind(doPose, _1, "turtle3"));
    ros::Subscriber sub4 = nh.subscribe<turtlesim::Pose>("turtle4/pose", 1000, boost::bind(doPose, _1, "turtle4"));

    ros::Rate rate(10);
    while (ros::ok()) {
        try {
            geometry_msgs::TransformStamped tfs = buffer.lookupTransform("turtle2", "turtle1", ros::Time(0));
            //通过对tf坐标变化进行偏移
            tfs.transform.translation.x -= 1.5;
            geometry_msgs::Twist twist;
            twist.linear.x = 0.5 * sqrt(pow(tfs.transform.translation.x, 2) + pow(tfs.transform.translation.y, 2));
            twist.angular.z = 4 * atan2(tfs.transform.translation.y, tfs.transform.translation.x);
            //当距离较近时(通过速度判断)停止运动
            if (twist.linear.x < 0.3) {
                twist.linear.x = 0.0;
                twist.angular.z = 0.0;
            }
            pub.publish(twist);

            //第二只
            geometry_msgs::TransformStamped tfs2 = buffer.lookupTransform("turtle3", "turtle1", ros::Time(0));
            tfs2.transform.translation.y -= 1.5;
            twist.linear.x = 0.5 * sqrt(pow(tfs2.transform.translation.x, 2) + pow(tfs2.transform.translation.y, 2));
            twist.angular.z = 4 * atan2(tfs2.transform.translation.y, tfs2.transform.translation.x);
            if (twist.linear.x < 0.3) {
                twist.linear.x = 0.0;
                twist.angular.z = 0.0;
            }
            pub3.publish(twist);

            geometry_msgs::TransformStamped tfs3 = buffer.lookupTransform("turtle4", "turtle1", ros::Time(0));
            tfs3.transform.translation.x -= 1.5;
            tfs3.transform.translation.y -= 1.5;
            twist.linear.x = 0.5 * sqrt(pow(tfs3.transform.translation.x, 2) + pow(tfs3.transform.translation.y, 2));
            twist.angular.z = 4 * atan2(tfs3.transform.translation.y, tfs3.transform.translation.x);
            if (twist.linear.x < 0.3) {
                twist.linear.x = 0.0;
                twist.angular.z = 0.0;
            }
            pub4.publish(twist);

        } catch (const tf2::TransformException& e) {
        }

        rate.sleep();
        ros::spinOnce();
    }

    return 0;
}

总的逻辑差不多,也不详细解释了,但是这种我没有仔细研究停止速度阈值和相对位置关系,有兴趣的可以自己改改

效果如下:

image-20241116124509412

小乌龟的路径规划

我懒得改文件名了,继续使用turtle4.cpp文件

主要逻辑是写了2个函数:

moveInCircle:对pub发布运动指令,使其朝左进行圆周运动(画圈) 正数是朝左

moveInLine:对pub发布运动指令,使其朝左进行旋转后走指定长度(直线) 正数是朝左

一曲一直不就妙了吗,可以实现理想形状,这里实现的是乌龟1走数字2,乌龟2走数字5,如果需要其他形状自行修改

但是我不知道为什么计算的很抽象,实际上的具体数值需要调,和理想输入的不一样,主要是moveInLine函数,两个值都不准,甚至同样的代码重复运行效果都不同

为了配合代码还可以设置乌龟的初始生成位置和角度,代码里有注释,这里不做多描述

#include "ros/ros.h"
#include "turtlesim/Spawn.h"
#include <turtlesim/Kill.h>
#include "geometry_msgs/Twist.h"
#include "turtlesim/Pose.h"
#include <cmath>

double rate_hz =50;
double speed = 1.0;

int customRound(double value) {
    return (value >= 0) ? (int)(value + 0.5) : (int)(value - 0.5);
}

double toRadians(double degrees) {
    return degrees * M_PI / 180.0;
}

void moveInCircle(ros::Publisher &pub, double radius, double angle_degrees) {
    ros::Rate rate(rate_hz);
    double angle = toRadians(angle_degrees);  // 角度转弧度
    double angular_speed = speed / radius;
    int steps = customRound((angle / (2 * M_PI)) * radius * 2 * M_PI * rate_hz / speed);

    geometry_msgs::Twist twist;
    twist.linear.x = speed;
    twist.angular.z = angular_speed;

    for (int i = 0; i < steps; ++i) {
        pub.publish(twist);
        rate.sleep();
    }

    twist.linear.x = 0;
    twist.angular.z = 0;
    pub.publish(twist);
}

void moveInLine(ros::Publisher &pub, double distance, double angle_degrees) {
    ros::Rate rate(rate_hz);
    int steps = customRound(distance / (speed / rate_hz));

    double angle = toRadians(angle_degrees);  // 角度转弧度

    geometry_msgs::Twist twist;
    twist.linear.x = 0;            // 保持线速度为0,只有旋转
    twist.angular.z = angle; // 设置角速度(正值表示逆时针)

    // 旋转直到达到目标角度
    for (int i = 0; i < 51; ++i) {
        pub.publish(twist);
        rate.sleep();
    }

    twist.linear.x = speed;
    twist.angular.z = 0;

    for (int i = 0; i < steps; ++i) {
        pub.publish(twist);
        rate.sleep();
    }

    twist.linear.x = 0;
    pub.publish(twist);
}

int main(int argc, char *argv[]) {
    setlocale(LC_ALL, "");

    ros::init(argc, argv, "pub_sub_tf");
    ros::NodeHandle nh;
    ros::ServiceClient spawn_client = nh.serviceClient<turtlesim::Spawn>("/spawn");
    turtlesim::Spawn spawn_msg;

    //删除第一只乌龟
    ros::ServiceClient client_kill = nh.serviceClient <turtlesim::Kill>("kill");
    turtlesim::Kill kill_name;
    kill_name.request.name ="turtle1";
    client_kill.call(kill_name);

    // 启动 turtle1
    spawn_msg.request.x = 4.0;  // turtle1 起始位置 越小越靠左
    spawn_msg.request.y = 5.0;  // turtle1 起始位置 越小越靠下
    spawn_msg.request.theta = M_PI; //0朝右  M_PI/2朝上  -M_PI/2朝下  M_PI朝左
    spawn_msg.request.name = "turtle1";
    if (spawn_client.call(spawn_msg)) {
        //ROS_INFO("Spawned turtle1 at (4, 5)");
    } else {
        ROS_ERROR("Failed to spawn turtle1");
        return -1;
    }

    // 启动 turtle2
    spawn_msg.request.x = 5.0;  // turtle2 起始位置
    spawn_msg.request.y = 4.9;
    spawn_msg.request.theta = -M_PI / 18;
    spawn_msg.request.name = "turtle2";
    if (spawn_client.call(spawn_msg)) {
        //ROS_INFO("Spawned turtle2 at (8, 5)");
    } else {
        ROS_ERROR("Failed to spawn turtle2");
        return -1;
    }

    // 创建发布者对象,pub1是对乌龟1
    ros::Publisher pub1 = nh.advertise<geometry_msgs::Twist>("/turtle1/cmd_vel", 1000);
    ros::Publisher pub2 = nh.advertise<geometry_msgs::Twist>("/turtle2/cmd_vel", 1000);

    ros::Rate rate(10);  // 设置循环频率为10Hz
    if (ros::ok()) {
            //pub1为乌龟1
            //moveInLine(pub1, 2.0,240); // 直线长度为 2   往左转180度左右(实际输入240 有很大偏差)
            moveInLine(pub1, 2.0,0); // 直线长度为 2  往右转0度(配合前面theta朝向,不进行旋转)
            moveInLine(pub1, 1.0,-160); // 直线长度为 1  往右转135度
            moveInCircle(pub1, 1, 190); // 半径为 1.0  190度(比较精准)

            moveInCircle(pub2, 0.8, 200); // 半径为 0.8    往左转200度(实际略有偏差)
            moveInLine(pub2, 0.9,-100); // 直线长度为 1  往右转90度左右(实际输入为100)
            moveInLine(pub2, 1,-92); 

        rate.sleep();  
        ros::spinOnce(); 
    }

    return 0;
}

运行:

分别在3个终端输入以下指令:这里面加入了删除乌龟1的代码,所以省了一行

roscore
rosrun turtlesim turtlesim_node
rosrun ch10_tutorials1 turtle4

效果如下:(分开截的)

image-20241116231731086

image-20241116231819209

Kenzot007 commented 3 days ago

hello