65.9K
CodeProject 正在变化。 阅读更多。
Home

Real Tree 2

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.95/5 (50投票s)

2009 年 2 月 9 日

CPOL

4分钟阅读

viewsIcon

69515

downloadIcon

2147

此应用程序展示了一个绘制随机花朵和树木的简单算法。其逻辑基于分形集。

Sample Image

目录

引言

当您观看此软件生成的形状时,您可能会认为背后有巨大而复杂的数学公式,但事实并非如此。该程序仅使用一些简单的公式(如 SIN 和 COS)和递归方法,仅此而已。程序的所有其他部分都是为了使形状看起来更好、更自然。

在上一版本(DotNet Real Tree)中,我解释了一些数学逻辑,现在我想添加一些使其更有趣的新功能(例如真实的叶子和花朵),但我删除了程序中一些我认为可能会引起问题(例如纳粹万字符!)的部分。我还删除了其他一些控件和比例尺,以使算法更简单、更有用。

什么是分形?

分形是重复使用数学公式形成的形状。每一次,它都会执行类似的操作,并且无论您从何处开始,它都会生成相同的形状。有一些著名的公式可以完成这项工作,例如曼德布罗集或朱利亚集。

它是如何工作的?

此应用程序使用的技术与您计算机上的文件系统非常相似。当您在硬盘驱动器上工作时(例如,如图所示的 D:\ 驱动器),您会看到一些文件和目录。当您进入一个目录时,您会看到其他文件和目录,当您继续进入新目录时,您会找到更多;最终会有一个最终点,那里没有更多目录了,所以您必须返回并搜索其他地方。

以及目录结构

在此应用程序中,目录类似于树枝,文件等于树上的叶子、花朵或果实。正如您的目录可能包含不同的文件一样,您的树的树枝也可能包含不同的对象。

关于递归?

此应用程序中的我们的主函数获取当前分支的信息,并计算和绘制新分支。有一个控件可供您选择理想的分区

结果显示为 3 种不同的选择

每步分区 = 2 每步分区 = 4 每步分区 = 10

但该过程只生成树的一个级别(步),其他级别呢?这正是我们要讨论的。

简而言之,这是我们的主过程

private void nextBranch(float startX, float startY, float startAngle)
{
        if (myTreeInfo.myStep >=  myTreeInfo.totalSteps) return;
        myTreeInfo.myStep++;

        endX = startX + (float)Math.Cos(startAngle * Math.PI / 180) * branchSize;
        endY = startY - (float)Math.Sin(startAngle * Math.PI / 180) * branchSize;

        DrawImage();

        for (int j = 1; j <= myTreeInfo.divisionPerStep; j++)
        {
            newAngle = startAngle - maxAngle/2+ maxAngle*(j-1);
            nextBranch(endX, endY, newAngle);
        }

        myTreeInfo.myStep--;
 }

我用 4 步生成分支来模拟它,您可以在此处下载 Flash 版本 此处

Using the Code

该算法很简单,类似于以下流程图

部分代码如下

变量

最初我声明了一些列表和类。objectCollection 类用于收集有关花、叶和果实的所有信息(在计算时)。BranchCollection 类用于收集分支的信息(绘制分支的项与花或果实的项不同)。还有一个 treeInfo 类用于保存树的一般信息,例如最大树大小、分支之间的角度等。

private List<string> imageTypes = new List<string>(new string[] 
			{ "*.gif", "*.png", "*.jpg", "*.bmp" });
private List<Image> picsBackground=new List<Image> ();
private List<Image> picsBase=new List<Image> ();
private List<Image> picsLeaves=new List<Image> ();
private List<Image> picsFlowers=new List<Image> ();
private List<Image> picsFruits=new List<Image>();

private class objectCollection    	// for collecting information of flowers, 
				// leaves and fruits.
{
    internal Image myImage = null;
    internal float myX = 0;
    internal float myY = 0;
    internal float myWidth = 0;
    internal float myHeight = 0;
    internal int myStep = 0;
}
private objectCollection myObjectInfo;
private List<objectCollection> myAllObjectsCollection;

private class BranchCollection
{
    internal float startX = 0;
    internal float startY = 0;
    internal float endX = 0;
    internal float endY = 0;
    internal int myStep = 0;
    internal float myWidth = 0;
    internal Color myColor;
}
private BranchCollection myBranchInfo;
private List<BranchCollection> myAllBranchCollection;

private class treeInfo
{
    internal int myStep, totalSteps;
    internal bool fixedSize, fixedAngle, brokenBranches;
    internal float divisionPerStep, startingBranch, maxSize, maxAngle, maxBrokenBranches;
    internal float leafLevel, trunkHeight, widthSize;
    internal float myProgress, flowerPercent, fruitPercent, leafPercent;
}
private treeInfo myTreeInfo=new treeInfo() ;

private Bitmap myBitmapTree;
private Pen myPen = new Pen(Color.Black);
private Random myRandom = new Random();
private static bool plzStopCalculation; // for manually stopping the calculation
private static bool plzStopDrawing; 	// for manually stopping the drawing

加载图像

然后我从硬盘和内部资源加载了一些图像,并为应用程序的不同部分(背景、水果...)选择随机图像。

private void firstStart()
{
    loadPictures();
    picGround.BackgroundImage = picsBase[myRandom.Next(picsBase.Count)];
    picBack.BackgroundImage = picsBackground[myRandom.Next(picsBackground.Count)];
    picFlower.BackgroundImage = picsFlowers[myRandom.Next(picsFlowers.Count)];
    picFruit.BackgroundImage = picsFruits[myRandom.Next(picsFruits.Count)];
    picLeaf.BackgroundImage = picsLeaves[myRandom.Next(picsLeaves.Count)];

    picTree.Image = Properties.Resources.about;

    // making "SAVE" directory for saving output images
    try
    {
        DirectoryInfo myDir = new DirectoryInfo(Application.StartupPath + "\\Save\\");
        if (!myDir.Exists) myDir.Create();
    }
    catch (Exception e)
    {
        MessageBox.Show(e.Message);
    }
}

// at the start of the program, load some pictures and shapes
private void loadPictures()
{
    // add some pictures from internal resources
    picsBase.Add(Properties.Resources.ground1);
    picsBackground.Add(Properties.Resources.Edinburgh_Castle__Edinburgh__Scotland );
    picsFlowers.Add(Properties.Resources.Flower__30_);
    picsFlowers.Add(Properties.Resources.Flower__32_);
    picsFruits.Add(Properties.Resources.icons6362);
    picsFruits.Add(Properties.Resources.icons6367);
    picsLeaves.Add(Properties.Resources.leaf_31);

    //load some pictures from Hard disk (different sub directories)
    loadFromHard("Ground", picsBase);
    loadFromHard("Back", picsBackground);
    loadFromHard("Fruits", picsFruits);
    loadFromHard("Flowers", picsFlowers);
    loadFromHard("Leaves", picsLeaves);
}

// load pictures from the hard disk
private void loadFromHard(string myDir, List<Image> myPicList)
{
    DirectoryInfo myFileDir = new DirectoryInfo(Application.StartupPath + "\\" + myDir);
    if (myFileDir.Exists)
    {
        // For each image extension (.jpg, .png, etc.)
        foreach (string imageType in imageTypes)
        {
            // all graphic files in the directory
            foreach (FileInfo myFile in myFileDir.GetFiles(imageType))
            {
                // add image
                try
                {
                    Image image = Image.FromFile(myFile.FullName);
                    myPicList.Add(image);
                }
                catch (OutOfMemoryException)
                {
                    continue;
                }
            }
        }
    }
}

运行按钮

此按钮有 3 种不同的操作

  1. 开始一个新过程。
  2. 停止计算。
  3. 停止绘制。

以下过程控制此按钮

private void btnOK_Click(object sender, System.EventArgs e)
{
    if (btnOK.Text == "Run")
    {
        goRun();
    }
    else if (btnOK.Text == "Stop Calculation") buttonsStatus(1);
    else if (btnOK.Text == "Stop Drawing") buttonsStatus(2);
}

private void goRun()
{
    setPictures();
    Application.DoEvents();
    getTreeInfo();
    // go for calculations
    buttonsStatus(0);
    calculateTree();
    // go for drawing
    buttonsStatus(1);
    if (!plzStopDrawing) startPaint();
    if (mnuAutoSave.Checked) ImageSave(); //Auto save image on hard disk

    // get ready for another user order
    buttonsStatus(2);
}

private void buttonsStatus(byte myStatus)
{
    if (myStatus == 0)
    {
        // start of calculation
        plzStopCalculation = false;
        plzStopDrawing = false;
        progressBar.Visible = true;
        btnOK.Text = "Stop Calculation";
    }
    else if (myStatus == 1)
    {
        // end of calculation and start of drawing
        plzStopCalculation = true;
        btnOK.Text = "Stop Drawing";
    }
    else
    {
        // at the end of drawing
        plzStopCalculation = true;
        plzStopDrawing = true;
        progressBar.Visible = false;
        btnOK.Text = "Run";
    }
} 

在新形状开始时,以下过程会更改随机图像并将控件值设置为变量。

// choose random picture for different parts
private void setPictures()
{
    if (rdoBackgroundTexture.Checked)
    {
        if (chkRandomGround.Checked) picGround.BackgroundImage =
        					picsBase[myRandom.Next(picsBase.Count)];
        if (chkRandomBack.Checked) picBack.BackgroundImage =
        			picsBackground[myRandom.Next(picsBackground.Count)];
    }
    else
    {
        if (chkRandomColor.Checked)lblBackgroundColor.BackColor =
        	Color.FromArgb(myRandom.Next(255), myRandom.Next(255), myRandom.Next(255));
    }
    if (chkRandomFlower.Checked && chkFlowerObjects.Checked)
    	picFlower.BackgroundImage = picsFlowers[myRandom.Next(picsFlowers.Count)];
    if (chkRandomFruit.Checked && chkFruitObjects.Checked)
    	picFruit.BackgroundImage = picsFruits[myRandom.Next(picsFruits.Count)];
    if (chkRandomLeaf.Checked && chkLeafObjects.Checked)
    	picLeaf.BackgroundImage = picsLeaves[myRandom.Next(picsLeaves.Count)];
}

//getting data from Controls
private void getTreeInfo()
{
    myTreeInfo.totalSteps = (int)updTotalSteps.Value;

    myTreeInfo.divisionPerStep = (int)updDivisionPerStep.Value;

    myTreeInfo.startingBranch = (int)updStartingBranch.Value;
    myTreeInfo.maxSize = (float)updMaxSize.Value;
    myTreeInfo.maxAngle = (float)updMaxAngle.Value;
    myTreeInfo.maxBrokenBranches = (float)updBrokenBranches.Value;

    myTreeInfo.fixedSize = chkFixedSize.Checked;
    myTreeInfo.fixedAngle = chkFixedAngle.Checked;
    myTreeInfo.brokenBranches = chkBrokenBranches.Checked;

    myTreeInfo.leafLevel = (float)trbBranchLevel.Value;
    myTreeInfo.trunkHeight = (float)trbTrunkHeight.Value;
    myTreeInfo.widthSize = (float)trbWidthSize.Value;

    myTreeInfo.flowerPercent = (float)updFlowerObjects.Value;
    myTreeInfo.fruitPercent = (float)updFruitObjects.Value;
    myTreeInfo.leafPercent = (float)updLeafObjects.Value;

    //some corrections in data
    if (chkFixedAngle.Checked) myTreeInfo.maxAngle *= 2 / myTreeInfo.divisionPerStep;
    myTreeInfo.maxSize -= (myTreeInfo.trunkHeight / 5 - 8) * 1.6F;
    if (myTreeInfo.maxSize < 1) myTreeInfo.maxSize = 1;
}

计算

这是主要部分;以下过程启动了递归方法。

private void calculateTree()
{
    myTreeInfo.myStep = 0; // starting Step.
    myTreeInfo.myProgress = 0;
    myAllBranchCollection = new List<BranchCollection>();
    myAllObjectsCollection = new List<objectCollection>();
    nextBranch(picTree.Width / 2, picTree.Height *4 / 5, 90);
}

这是递归部分

private void nextBranch(float startX, float startY, float startAngle)
{
    float endX, endY, newAngle, angleGrow, branchSize;
    if (!plzStopCalculation && myTreeInfo.myStep < myTreeInfo.totalSteps)
    {
        //following 6 lines are only for showing progress bar.
        if (myTreeInfo.myStep == 3)
        {
            myTreeInfo.myProgress +=	(float)(100 / 
		Math.Pow(myTreeInfo.divisionPerStep, myTreeInfo.myStep));
            if (myTreeInfo.myProgress > 100) myTreeInfo.myProgress = 100;
            progressBar.Value = (int)myTreeInfo.myProgress;
        }

        // for making broken branches, also when you reach the maximum step.
        if (myTreeInfo.brokenBranches && myTreeInfo.myStep > 2 &&
        	myRandom.NextDouble ()*100 < myTreeInfo.maxBrokenBranches +
        	(myTreeInfo.maxBrokenBranches * ((myTreeInfo.myStep * 2 -
        	myTreeInfo.totalSteps) / myTreeInfo.totalSteps)) * 0.7) return;

        myTreeInfo.myStep++;

        //different colors from root to leaves
        myPen.Color = Color.FromArgb(100, (int)(255 * 
			myTreeInfo.myStep / myTreeInfo.totalSteps), 35);

        //different width for branches from root to leaves.
        //you can replace following 2 lines with "myPen.Width=3;".
        myPen.Width = 10 * myTreeInfo.widthSize * 
		(float)Math.Pow((myTreeInfo.totalSteps - myTreeInfo.myStep), 3) / 
		(float)Math.Pow(myTreeInfo.totalSteps, 4);
        if (myPen.Width < 1) myPen.Width = 1;

        // size of current branch. you can replace following 9 lines 
        // with only "branchSize=15;".
        branchSize = (myTreeInfo.totalSteps - myTreeInfo.myStep * 
					myTreeInfo.leafLevel / 50);
        if (myTreeInfo.leafLevel >= 50) branchSize *= myTreeInfo.leafLevel / 50;
        else branchSize *= (myTreeInfo.leafLevel + 50) / 100;
        if (branchSize <= 0) branchSize = 1;
        branchSize *= (float)(picTree.Height / 
		Math.Pow(myTreeInfo.totalSteps, 1.9)) * myTreeInfo.maxSize / 80;
        if (!myTreeInfo.fixedSize) branchSize *= 
	(float)(myRandom.NextDouble() * 2 + 0.1); // only when Size is not fixed.

        // more control for height of trunk.
        if (myTreeInfo.myStep < 3) branchSize +=
        	picTree.Height / (30 * myTreeInfo.myStep) + branchSize *
        		(myTreeInfo.trunkHeight / 10 - 4) / (myTreeInfo.myStep + 1.5F) *
        		myTreeInfo.totalSteps / 15;

        // calculating end points. [* Math.PI / 180] is for changing degrees to radians.
        endX = startX + (float)Math.Cos(startAngle * Math.PI / 180) * branchSize;
        endY = startY - (float)Math.Sin(startAngle * Math.PI / 180) * branchSize;

        try
        {
            //adding branch info to collection.
            if (myTreeInfo.myStep >= myTreeInfo.startingBranch) // this "if" condition
            				//is for "Starting Branch" control.
            {
                myBranchInfo = new BranchCollection();
                myBranchInfo.startX = startX;
                myBranchInfo.startY = startY;
                myBranchInfo.endX = endX;
                myBranchInfo.endY = endY;
                myBranchInfo.myStep = myTreeInfo.myStep;
                myBranchInfo.myColor = myPen.Color;
                myBranchInfo.myWidth = myPen.Width;
                myAllBranchCollection.Add(myBranchInfo);
            }

            //adding leaves to collection
            if (chkLeafObjects.Checked)
            {
                if (myTreeInfo.myStep > myTreeInfo.totalSteps / 4) //leaves must be 
							//only on higher branches
                {
                    if (myRandom.Next(100000) * myTreeInfo.myStep < 
			Math.Pow(myTreeInfo.leafPercent, 4))// how many leaves ?
                    {
                        myObjectInfo = new objectCollection();
                        myObjectInfo.myImage = picLeaf.BackgroundImage;
                        myObjectInfo.myStep = myBranchInfo.myStep;
                        float myScale = (float)myRandom.Next(13) / 
					myObjectInfo.myImage.Width;
                        myObjectInfo.myWidth = myObjectInfo.myImage.Width * myScale;
                        myObjectInfo.myHeight = myObjectInfo.myImage.Height * myScale;
                        myObjectInfo.myX = startX - myObjectInfo.myWidth / 2;
                        myObjectInfo.myY = startY - myObjectInfo.myHeight / 2;

                        myAllObjectsCollection.Add(myObjectInfo);
                    }
                }
            }

            //adding flowers to collection
            if (chkFlowerObjects.Checked)
            {
                if (myTreeInfo.myStep > myTreeInfo.totalSteps / 2) //flowers must be 
							//only on higher branches
                {

                    if (myRandom.Next(100000) * myTreeInfo.myStep < 
			Math.Pow(myTreeInfo.flowerPercent, 3.5))// how many flowers ?
                    {
                        myObjectInfo = new objectCollection();
                        myObjectInfo.myImage = picFlower.BackgroundImage;
                        myObjectInfo.myStep = myBranchInfo.myStep;
                        float myScale = (float)myRandom.Next(12) / 
						myObjectInfo.myImage.Width;
                        myObjectInfo.myWidth = myObjectInfo.myImage.Width * myScale;
                        myObjectInfo.myHeight = myObjectInfo.myImage.Height * myScale;
                        myObjectInfo.myX = startX - myObjectInfo.myWidth / 2;
                        myObjectInfo.myY = startY - myObjectInfo.myHeight / 2;

                        myAllObjectsCollection.Add(myObjectInfo);
                    }
                }
            }

            //adding fruits to collection
            if (chkFruitObjects.Checked)
            {
                if (myTreeInfo.myStep > myTreeInfo.totalSteps * 4 / 5) //fruits must 
						// be only on higher branches
                {
                    if (myRandom.Next(100000) * myTreeInfo.myStep < 
			Math.Pow(myTreeInfo.fruitPercent, 3))// how many fruits ?
                    {
                        myObjectInfo = new objectCollection();
                        myObjectInfo.myImage = picFruit.BackgroundImage;
                        myObjectInfo.myStep = myBranchInfo.myStep;
                        float myScale = (float)myRandom.Next(15) / 
					myObjectInfo.myImage.Width;
                        myObjectInfo.myWidth = myObjectInfo.myImage.Width * myScale;
                        myObjectInfo.myHeight = myObjectInfo.myImage.Height * myScale;
                        myObjectInfo.myX = startX - myObjectInfo.myWidth / 2;
                        myObjectInfo.myY = startY - myObjectInfo.myHeight / 2;

                        myAllObjectsCollection.Add(myObjectInfo);
                    }
                }
            }
        }
        catch (Exception)
        {
            //MessageBox.Show("Error");
        }

        //recursion part.
        for (int j = 1; j <= myTreeInfo.divisionPerStep; j++)
        {
            // calculating angle for next branches.
            angleGrow = myTreeInfo.maxAngle; // range of differences
            if (myTreeInfo.fixedAngle) angleGrow = 
		angleGrow / 2 - angleGrow * (j - myTreeInfo.divisionPerStep / 2);
            else angleGrow *= (float)(myRandom.NextDouble() * 2 - 1);
            newAngle = (startAngle + angleGrow) % 360;

            nextBranch(endX, endY, newAngle);	// runs itself again.
        }

        myTreeInfo.myStep--; // go back
        Application.DoEvents();
    }
}

绘图

在上一版本中,绘图和计算部分在同一个过程中,但在这里,我将它们分成了 2 部分。对象的绘图顺序按其级别(步)进行。

 private void startPaint()
{
    try
    {
        // setting graphics
        myBitmapTree = new Bitmap(picTree.Width , picTree.Height );

        Graphics gTree=Graphics.FromImage(myBitmapTree );
        gTree.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias;

        // drawing landscape
        if (rdoBackgroundTexture.Checked)
        {
            gTree.DrawImage(picBack.BackgroundImage,
            	new Rectangle(0, 0, picTree.Width, picTree.Height),
            	new Rectangle(0, 0, picBack.BackgroundImage.Width,
            	picBack.BackgroundImage.Height), GraphicsUnit.Pixel);
            gTree.DrawImage(picGround.BackgroundImage,
            	new Rectangle(0, picTree.Height * 2 / 3, picTree.Width,
            	picTree.Height / 3), new Rectangle(0, 0,
            	picGround.BackgroundImage.Width,
            	picGround.BackgroundImage.Height), GraphicsUnit.Pixel);
        }
        else
        {
            gTree.FillRectangle(new SolidBrush(lblBackgroundColor.BackColor),
            	0, 0, picTree.Width, picTree.Height);

        }
        // drawing all branches and objects of the collections in order by their steps
        progressBar.Value = 0;
        for (int i = 0; i <= myTreeInfo.totalSteps ; i++)
        {
            foreach (BranchCollection myB in myAllBranchCollection)
            {
                if (i == myB.myStep-1 ) DrawTree(gTree,  myB);
            }

            picTree.Image = myBitmapTree;
            foreach (objectCollection myP in myAllObjectsCollection)
            {
                if (i == myP.myStep ) DrawPicture(gTree,  myP);
            }

            picTree.Image = myBitmapTree;
            progressBar.Value = i * 100 / myTreeInfo.totalSteps;
        }
    }
    catch (Exception ex)
    {
        MessageBox.Show(ex.Message);
    }
}

// Draw Tree Branches on the scene
private void DrawTree(Graphics gTree,  BranchCollection myB)
{
    Application.DoEvents();
    if(!plzStopDrawing) gTree.DrawLine(new Pen(myB.myColor, myB.myWidth),
    	myB.startX, myB.startY, myB.endX, myB.endY);
}

// Draw flowers, leaves and fruits on the scene
private void DrawPicture(Graphics gTree,  objectCollection myP)
{
    if (!plzStopDrawing) gTree.DrawImage
    	(myP.myImage, myP.myX, myP.myY, myP.myWidth, myP.myHeight);
}

就是这样。

GUI 

GUI 非常简单。

关注点

有一些预定义的示例,您可以从主菜单访问

您可以在菜单中添加自己的示例;在项的单击事件中添加值,如下所示

private void mnuFlower6_Click(object sender, EventArgs e)
{
    putInfo(150, 49, 8, 0, 55, 0, 38, 1, 9800, 40, 40, 20, 50, 1,
		0, 30, 1, 1, 0, 50, 1, 0, 0, 0, 0);
} 

值按照控件在窗体上的放置顺序排列。以下过程将值设置到控件上。

private void putInfo(params int[] infoArray)
{
updTotalSteps.Value = infoArray[0];
updDivisionPerStep.Value = infoArray[1];
updStartingBranch.Value = infoArray[2];
chkFixedSize.Checked = Convert.ToBoolean(infoArray[3]);
updMaxSize.Value = infoArray[4];
chkFixedAngle.Checked = Convert.ToBoolean(infoArray[5]);
updMaxAngle.Value = infoArray[6];
chkBrokenBranches.Checked = Convert.ToBoolean(infoArray[7]);
updBrokenBranches.Value = (decimal)infoArray[8] / 100;
trbBranchLevel.Value = infoArray[9];
trbTrunkHeight.Value = infoArray[10];
trbWidthSize.Value = infoArray[11];

chkLeafObjects.Checked = Convert.ToBoolean(infoArray[13]);
chkFlowerObjects.Checked = Convert.ToBoolean(infoArray[17]);
chkFruitObjects.Checked = Convert.ToBoolean(infoArray[21]);
if (chkLeafObjects.Checked)
{
    if (infoArray[14] > 0) picLeaf.BackgroundImage = picsLeaves[infoArray[14] - 1];
    if (infoArray[15] > 0) updLeafObjects.Value = infoArray[15];
    chkRandomLeaf.Checked = Convert.ToBoolean(infoArray[16]);
}
if (chkFlowerObjects.Checked)
{
    if (infoArray[18] > 0) picFlower.BackgroundImage = picsFlowers[infoArray[18] - 1];
    if (infoArray[19] > 0) updFlowerObjects.Value = infoArray[19];
    chkRandomFlower.Checked = Convert.ToBoolean(infoArray[20]);
}
if (chkFruitObjects.Checked)
{
    if (infoArray[22] > 0) picFruit.BackgroundImage = picsFruits[infoArray[22] - 1];
    if (infoArray[23] > 0) updFruitObjects.Value = infoArray[23];
    chkRandomFruit.Checked = Convert.ToBoolean(infoArray[24]);
}

您还可以自行更改对象的纹理和图片。在可执行文件所在的位置有几个目录,其中包含这些图像,您可以更改或添加更多。

历史

  • 首次发布(2009 年 2 月 9 日)
  • 更新 1(2009 年 2 月 16 日):添加了计时器
  • 更新 2(2009 年 2 月 25 日):添加了保存选项
  • 更新 3(2009 年 3 月 23 日):一些小改动
© . All rights reserved.