HTML5 Canvas:整洁的 JavaScript 和代码组织可实现更快的开发、更轻松的扩展
不到 300 行 JavaScript 代码创建了一个有趣的“生命游戏”示例(随机移动的图形生物,有生命周期——请参阅动画 gif)。
引言
在撰写另一系列文章(从这里开始: 算法:计算凸包并绘制 HTML5 Canvas(第 1 部分,共 2 部分)[^])时,我开始思考一个简单的游戏/新奇程序,它可以动态显示“活生物”。 我还阅读了一本关于 OOP 的旧书,并考虑这些生物应该如何管理自己以及代码可能是什么样子。
最终的程序,我称之为 RobotDots,就是这项工作的结果。
实时示例
您现在可以在我的网站上查看最终示例: http://raddev.us/RobotDots[^]
背景
当然,当我创建我的小 RobotDots 时,最初的 康威生命游戏 - 维基百科,自由的百科全书[^] 在我脑海中,尽管我对其细节一无所知。 当然,我的要简单得多,也没有原创的那么有趣。 基本上,比较仅限于一组“活生物”随机生成并具有某种生命周期的想法。
在 RobotDots 中,每个机器人都有
- 出生(生成并出现在屏幕上)
- 年龄(随时间推移而增加)
- 寿命(随机生成的值)
- 颜色(从一组中随机选择的值)
- 不透明度值(随着年龄增长变得更透明)
- 大小和最大大小(每个对象将增长到其单独的最大大小)
- 位置(最初随机生成的 x,y 值,然后计算移动)
- 死亡(当对象移出屏幕不止一次或年龄增加到最大年龄时)
这段代码的意义何在?
自我管理对象
我并没有试图创造什么宏大的游戏,也不是为了长时间娱乐某人。 相反,我感兴趣的是创建自我管理的对象。 我希望尽可能多的代码封装在我称之为 Robot 的主要类型中。
JavaScript OOP?
接下来,我感兴趣的是看看我是否可以使用 JavaScript 来实现这一点,就像我使用经典的 OOP 语言(C++、C#、Java)一样,而不是 JavaScript(基于原型的 OOP)。
JavaScript 中的 OOP 设计?它可能吗?它有帮助吗?
我真正想回答的问题是:“即使在 JavaScript 中遵循 OOP 设计,我也会从中受益吗?”
剧透警告
好处巨大。您的代码变得更容易管理、扩展、增强、修复。考虑复杂的解决方案变得惊人的容易。
引用如果你能将复杂的细节分离出来,以便单独处理它们,你就能让你的问题更容易解决。这就是 OOP 的好处。
为什么选择 JavaScript 和 HTML5?
我的背景是 C++,后来转向 C#,所以我更喜欢某些语言。 然而,我尽量为工作选择合适的工具。 在这种情况下,JavaScript、HTML5 Canvas 让我可以很轻松地完成我想要的图形工作,而且当然,任何拥有现代浏览器的人都可以使用它,所以部署起来也很容易。
如何控制动作
- 如果您单击棋盘上的任意位置,游戏将暂停。
- 如果您按住 Ctrl 键并单击一个机器人,它将成为一个主机器人,并向所有颜色相似的机器人绘制线条。(您也可以单击此功能的复选框——在手机和平板电脑上)
- 单击机器人会在其周围创建一个高亮环,以便您可以跟踪机器人。
- 稍后我将添加分数和其他效果。
现在,让我们检查代码。
应用启动:OnLoad
注意:有关所用库(jQuery、Bootstrap)和背景网格绘制的更多详细信息,您可以阅读我的另一系列文章中的第一篇文章(算法:计算凸包并绘制 HTML5 Canvas(第 1 部分,共 2 部分)[^])。
当浏览器的 onLoad
事件触发时,它将调用我们的 initApp()
方法,该方法在应用程序启动时只运行一次。这使我能够初始化在应用程序整个生命周期中需要的一些东西。基本上,它设置了网格背景并初始化了我们需要进行所有绘图的 Canvas 对象。
function initApp(){
theCanvas = document.getElementById("gamescreen");
ctx = theCanvas.getContext("2d");
ctx.canvas.height = 650;
ctx.canvas.width = ctx.canvas.height;
theCanvas.addEventListener("mousedown", mouseDownHandler);
intervalID = window.setInterval(mainGameLoop, 125);
lineInterval = Math.floor(ctx.canvas.width / LINES);
drawGameBoard();
}
SetInterval:设置游戏循环
initApp()
还设置了一个每 125 毫秒触发一次的计时器。我们通过调用 JavaScript 方法 setInterval()
来设置它。它非常易于使用,因为您只需提供方法名(方法的引用——注意没有括号)和毫秒间隔。您可以看到我将每次调用的方法命名为 mainGameLoop()
。这将作为整个应用程序的主要控制循环。
主游戏循环
主游戏循环非常简单,因为它基本上只是遍历应用程序全局数组 allRobots[]
中的机器人对象列表。大多数执行的代码都隐藏在每个机器人内部,因此更容易管理。
function mainGameLoop(){
for (var idx = allRobots.length-1; idx >= 0;idx--){
if (!allRobots[idx].isAlive){
console.log("died at: " + allRobots[idx].age);
if (allRobots[idx].isSelected){
selectedCount--;
}
allRobots.splice(idx,1);
if (masterRobot != null){
if (!masterRobot.isAlive){
masterRobot = null;
}
}
}
allRobots[idx].advanceAge();
allRobots[idx].calculatePosition();
}
if (allRobots.length < 48){
allRobots.push(new robot({x:genRandomNumber(600),y:genRandomNumber(600),color:getRandomColor()}));
}
drawGameBoard();
drawRobots();
if (masterRobot != null){
drawConnectedRobots();
}
}
游戏所做的一切总结都在游戏循环中
每 125 毫秒,此代码都会执行以下操作:
- 遍历整个机器人数组,并移除所有已死亡的机器人(
isAlive == false
)。 - 确定是否存在 MasterRobot(稍后详述),如果存在但已死亡,则将其移除。
- 增加所有机器人的年龄(游戏循环的每个间隔都是时间,机器人会老化)
- 计算每个机器人的新位置(表示机器人在屏幕上的移动)
- 如果 allRobots[] 数组中机器人少于 48 个,则添加一个新机器人(一个机器人诞生)
- 绘制游戏板——为了表示动画,我们必须(下一步)在新的位置绘制机器人,这意味着我们每次也需要重新绘制游戏板(以擦除和清理它)。
- 检查主机器人是否存在,如果存在,则将其连接线绘制到其每个机器人。
这就是 OOP 的强大之处。复杂的工作隐藏在每个对象(在这种情况下是 Robot 对象)中,因此我们可以轻松地总结整个程序的功能,并且只在需要时查看细节。
机器人类:此应用程序的强大之处
机器人类是隐藏细节的地方,但是创建一个新机器人非常容易。
如果你想创建一个,你所要做的就是以下几点
var robot1 = new robot({});
奇怪的语法
如果你没有做过太多 JavaScript 编程,你可能会觉得这看起来很奇怪。 实际上,即使你做过,它也可能看起来很奇怪。
那行简单的代码通过传入一个空对象(用 {} 表示)构造了一个新的机器人对象。
如果正确设置对象,使用此语法可以非常轻松地初始化 JavaScript 中的对象。
让我们看一个不那么复杂的例子,这样我就可以更清楚地解释这个概念。
创建模板类
在 JavaScript 中,您通过创建函数来创建模板类。这是一个例子
function animal(initObj){
this.name = initObj.name;
}
现在,如果你想创建一个这样的新对象,你可以编写以下代码
var cat = new animal({name:"cat"});
如果你想在控制台中打印动物的名字,你现在可以这样做
console.log(cat.name);
但是,如果开发人员用户试图在不发送对象的情况下创建动物,他将收到一条错误消息,指出:无法读取未定义对象的“name”属性。
那是因为他发送了空对象。
我们可以通过发送一个空(非空对象)来轻松解决这个问题,如下所示
var cat = new animal({});
然而,我们可能需要一个默认值,因此当只有一个空传入对象时,我们可以使用一些更奇怪的 JavaScript 来设置默认值。让我们修改类,使其看起来像下面这样
function animal(initObj){ this.name = initObj.name || "mammal"; }
奇怪语法的解释
这种语法有点奇怪,但它利用了这样一个事实:当传入对象为空时,名称返回未定义的值(这是 JavaScript 中的一个对象)。当你将它与有效值 || 运算时,右侧的有效值将为 true,你的 name 属性将被设置为该值。
现在,name 属性的默认值将是“mammal”。
现在让我们看看我们的机器人对象,您将在其定义的顶部看到这种初始化代码。
function robot (r){
this.x = r.x || 200;
this.y = r.y || 200;
this.color = r.color || "black";
this.size = r.size || 10;
this.maxSize = r.maxSize || null;
this.maxAge = r.maxAge || null;
this.isSelected = r.isSelected || false;
this.age = r.age || 1;
this.isAlive = true;
this.globalAlpha = r.globalAlpha || 1;
this.offGridCount = 0;
this.calculatePosition = function(){
var flag = genRandomNumber(2);
var addFlag = genRandomNumber(2);
//console.log(flag);
if (flag > 1){
if (addFlag > 1){
this.x += genRandomNumber(4) + genRandomNumber(3);
}else{
this.x -= genRandomNumber(4) + genRandomNumber(3);
}
}
else{
if (addFlag > 1){
this.y += genRandomNumber(4) + genRandomNumber(3);
}
else{
this.y -= genRandomNumber(4) + genRandomNumber(3);
}
}
if (this.x >= 650 || this.x <= 0 || this.y >= 650 || this.y <=0)
{
this.offGridCount +=1;
}
if (this.offGridCount >=2){
this.isAlive = false;
}
//console.log ("x : " + this.x + " y : " + this.y);
}
this.advanceAge = function() {
// console.log("advanceAge...");
if (this.age >= this.maxAge){
this.isAlive = false;
return;
}
this.age +=1;
// console.log("this.age : " + this.age);
this.grow();
}
this.grow = function() {
if (this.age % 100 == 0){
if (this.maxSize == null || this.size < this.maxSize){
this.size +=1;
}
}
if (this.age % 200 == 0){
this.globalAlpha -= .1;
if (this.globalAlpha <= .2){
this.isAlive = false;
}
}
}
this.drawRobotHighlight = function(){
ctx.beginPath();
ctx.lineWidth = 2;
ctx.arc(this.x, this.y,this.size + 7,0,2*Math.PI);
ctx.strokeStyle = "black";
ctx.globalAlpha = 1;
ctx.stroke();
}
this.drawRobot = function (){
//console.log("robot size : " + allRobots[idx].size);
ctx.fillStyle = this.color;
ctx.strokeStyle= this.color;
if (this.isSelected) {
this.drawRobotHighlight();
}
ctx.globalAlpha = this.globalAlpha;
ctx.beginPath();
ctx.arc(this.x, this.y,this.size,0,2*Math.PI);
ctx.stroke();
ctx.fill();
// reset opacity
ctx.globalAlpha = 1;
}
this.initRobot = function(){
this.maxSize = this.calcMaxSize();
//console.log("maxSize : " + this.maxSize);
this.calcMaxAge();
}
this.calcMaxSize = function(){
return genRandomNumber(15) + 10;
}
this.calcMaxAge = function (){
this.maxAge = genRandomNumber (40000) + 10000;
//console.log ("maxAge : " + this.maxAge);
}
this.initRobot();
}
这感觉像是很多代码,但我们只看机器人实现的方法。
机器人能做什么:公共函数
这是机器人可以做的所有事情的列表
calculatePosition()
- 生成 X、Y 位置值以模拟移动advanceAge()
- 增加年龄值使机器人变老grow()
- 增加尺寸值使机器人变大drawRobotHighlight()
- 绘制突出显示机器人的外圈drawRobot()
- 在屏幕上绘制机器人以显示自身initRobot()
- 计算 maxSize 和 maxAge,这将在以后用于确定大小和何时死亡calcMaxSize()
- 由 initRobot 调用,将 maxSize 初始化为随机值calcMaxAge()
- 由 initRobot 调用,将 maxAge 初始化为随机值。
所有的代码就是这些。每次 `mainGameLoop()` 触发时,这些方法背后的所有细节都被很好地隐藏起来,因此您可以独立于其他所有事情来理解正在发生的事情。
单独检查一个示例方法
好的,尽管代码看起来很多,但我们现在可以查看一个方法并确定它的作用。让我们看看 drawRobotHighlight()
方法,我们会发现它有多简单。
drawRobotHighlight()
这段代码用于在每个机器人被选中时在其周围绘制一个黑色的外环。这可能非常困难,因为机器人可能大小各异。然而,由于这段代码被封装在每个单独的机器人上运行,我们使其变得非常简单。
this.drawRobotHighlight = function(){
ctx.beginPath();
ctx.lineWidth = 2;
ctx.arc(this.x, this.y,this.size + 7,0,2*Math.PI);
ctx.strokeStyle = "black";
ctx.globalAlpha = 1;
ctx.stroke();
}
实际绘图的行是调用 `ctx.arc()` 方法的行。
您可以在下图中看到带有圆形高亮显示的机器人
我们在机器人的 x, y 位置(arc() 方法的前两个参数)绘制当前的机器人对象。但我们还需要绘制它,使其始终比其自身半径大 7 像素。这非常容易,因为正如您所看到的,第三个参数是您提供半径大小的地方。在这种情况下,我们只需将 7 添加到机器人当前的半径大小(存储在其 size 属性中)。现在,每当机器人增长并被绘制时,高亮圆形始终与特定机器人保持正确的尺寸。
我将让您检查其余方法,因为它们是自解释的(以及我在此处引用的另一篇文章)。
不过,我想谈谈创建主机器人最有趣的功能。
创建主机器人
起初我并没有考虑让用户点击任何机器人并让它连接到所有其他颜色相似的机器人的想法。 后来写完所有其他代码后,我突然想到,“嘿,如果我有一个所有机器人的集合,并且我知道它们所有的中心点,我可以很容易地从一个机器人画线到另一个。”
专注的力量
我能够独立于在屏幕上每个位置绘制每个机器人来思考并专注于此功能。现在它仅仅变成了遍历点并向具有相同颜色值的点绘制线条的练习。这是代码。一切都会变得有意义,效果(我认为)非常酷。
function drawConnectedRobots(){
for (var i = 0; i < allRobots.length;i++){
masterRobot.isSelected = true;
if (masterRobot.color == allRobots[i].color){
drawLine(masterRobot, allRobots[i], masterRobot.color);
}
}
}
所有这一切都非常简单,与绘制机器人所在位置的所有代码是独立的。
我希望这篇文章能让您思考 OOP 的一种可能的额外用途,您可以使用它来简化复杂的代码。
历史
首次发布:2016 年 5 月 3 日