本文介紹一種塊編程工具,基于塊的編程語言可以利用簡單的圖像來進(jìn)行編程,而不是輸入字符。
中文翻譯參考 https://blog.csdn.net/code_for_fun/article/details/51898028 。
作者
Dethe 是一個極客老爸,具有審美趣味的程序員,導(dǎo)師,以及可視化編程工具Waterbear的作者。他聯(lián)合創(chuàng)辦了溫哥華手工制作教育沙龍并且滿心希望機器紙折兔能火遍全球。
背景
在基于塊(block-based)的編程語言中,你通過拖動和連接代表程序不同部分的塊來進(jìn)行編程。而在一般的編程語言中,你是通過鍵入字符來編程的。
學(xué)習(xí)編程可能很困難,因為一般編程語言對于拼寫錯誤是零容忍的。大部分的編程語言都是大小寫敏感的,并且語法比較晦澀,哪怕是少寫一個分號都會拒絕運行程序。更有甚者,大部分的編程語言是基于英語的并且語法不能本地化。
相反,基于塊的語言可以完全消除語法錯誤,你的程序僅僅可能發(fā)生邏輯錯誤。塊語言也更加直觀,你可以在塊列表中看到所有的程序構(gòu)件和語言庫。此外,塊可以被本地化為任何人類語言而不改變編程語言的含義。

基于塊的語言歷史悠久,比較著名的有 Lego Mindstorms,Alice3D,StarLogo,還有 Scratch。還有一些在 Web 上就可以訪問的:Blockly,AppInventor,Tynker 等等。
本文的代碼基于開源項目 Waterbear,它不是一個語言,而是將其它現(xiàn)存語言包裝成塊語法的工具。該包裝器的優(yōu)點包括:消除語法錯誤,可用組件的可視化顯示,易于本地化。除此之外,可視化的代碼有時更加容易閱讀和調(diào)試,還不會打字的兒童也能使用塊。(可以更進(jìn)一步地在塊上放置圖標(biāo),也可以加上文字,提供給學(xué)前兒童使用,然而這個功能我們先不考慮)。
該語言選擇使用的龜圖(turtle graphics)可以追溯到 Logo 語言,這是一個專門教導(dǎo)兒童編程的語言。許多基于塊的語言都包括了龜圖,該主題很適合用于一個類似被嚴(yán)格限制的項目。
如果想事先體驗一下基于塊的語言是怎么樣的,可以到作者的 Github 進(jìn)行實驗。
目標(biāo)和結(jié)構(gòu)
本文主要實現(xiàn)幾點。首先也是最重要的一點,為龜圖實現(xiàn)一個塊語言,通過簡單的拖放塊就可以編寫程序創(chuàng)建圖案,這使用簡單的 HTML、CSS 和 Javascript 來實現(xiàn)。其次但同樣重要的一點,本文要展示如何將塊構(gòu)想成為一個框架,服務(wù)于其它語言而不僅僅是簡單的龜語言(turtle language)。
為了做到這點,本文將龜語言相關(guān)部分全部封裝到了一個文件(turtle.js),這樣就可以輕易替換成其它文件。除此之外的任何代碼都不是特定于龜語言;其它的代碼全部用來處理塊(blocks.js 和 menu.js)或者是通用的 Web 工具(util.js、drag.js 和 file.js)。這是最終目標(biāo),然后為了使得工程盡量小型化,一些工具不是足夠通用但與塊相關(guān)。
腳本的本質(zhì)
和任何其它語言的腳本一樣,一個 Blockcode 腳本就是一系列的操作。對于 Blockcode 腳本來說,其中包含了一些 HTML 元素,腳本迭代執(zhí)行每個 HTML 元素對應(yīng)的JavaScript函數(shù)。一些塊包含(負(fù)責(zé)執(zhí)行)其他的塊,還有一些塊包含一些傳遞給函數(shù)的數(shù)值。
在大部分(基于文本)的語言中,一個腳本的執(zhí)行會經(jīng)歷多個階段:一個詞法分析器將文本解析為可識別的標(biāo)記,語法分析器將標(biāo)記組織成抽象語法樹,然后根據(jù)語言的不同,可能會編譯為機器碼或者輸入到解析器中。這是一個簡化的描述,事實上可能會有更多步驟。對于 Blockcode,塊的布局本身就代表了抽象語法樹,因為我們可以免去詞法分析和語法分析階段。我們使用訪問者模式(Visitor pattern)來迭代每個塊并執(zhí)行每個塊預(yù)定義的函數(shù)來運行整個程序。
我們完全可以添加額外的步驟來將 Blockcode 變得更像一般的語言。除了簡單的調(diào)用 Javascript 函數(shù)外,我們還可以將 turtle.js 替換為一個能產(chǎn)生字節(jié)碼的塊語言,運行于其它虛擬機?;蛘弋a(chǎn)生 C++ 代碼用以編譯運行。存在能夠生成 Java 字節(jié)碼的塊語言(作為 Waterbear 項目的一部分),用于 Arduino 編程和為 Raspberry Pi 上運行的 Minecraft 編寫腳本。
Web 應(yīng)用
為了讓更多的人使用該工具,我們使用了 Web。該工具使用 HTML,CSS 和 JavaScript 編寫,因此可以運行在大部分的瀏覽器和平臺。
現(xiàn)代 Web 瀏覽器是一個強大的平臺,提供了構(gòu)建偉大軟件的豐富工具。Web 應(yīng)用和傳統(tǒng)桌面應(yīng)用或者服務(wù)器應(yīng)用的一個重大的區(qū)別就是它沒有 main() 函數(shù)或者其它的入口,也沒有顯式地循環(huán),因為這些已經(jīng)被瀏覽器內(nèi)置了。我們所有的代碼都在加載的時候被分析和執(zhí)行,在這個過程中我們可以對感興趣的事件注冊監(jiān)聽器用來和用戶互動。在初次執(zhí)行后,所有后續(xù)的互動都在相應(yīng)事件中注冊的回調(diào)中進(jìn)行,要么是類似鼠標(biāo)移動的事件,或者是設(shè)置的定時器。瀏覽器并沒有暴露主要的線程(僅僅是共享的工作線程)。
逐步完成代碼
在整個項目中,我嘗試遵循一些慣例和最佳實踐。每個 JavaScript 文件都被包含在一個函數(shù)中,從而避免變量泄露到全局環(huán)境中。如果需要暴露變量給其他文件,那么每個文件中根據(jù)文件名只定義單個 global,所有需要暴露的函數(shù)都在其中。這些都在接近文件尾部進(jìn)行放置,接著就是該文件定義的各種事件處理器,因而只需要看一眼文件的末尾就能知道該文件定義的事件處理器和導(dǎo)出的函數(shù)。
代碼是過程式的,沒有采用面向?qū)ο蠡蛘吆瘮?shù)式。我們可以使用任意一種范式來做同一件事,然而那需要更多的設(shè)置代碼和包裝代碼來進(jìn)行本已存在于 DOM 的東西。最近有個項目 Custom Elements 使得你可以 OO 的方式操作 DOM,還有很多關(guān)于 Functional JavaScript 的文章,然而這些都需要額外的工作,因此保持過程式感覺更簡單。
項目中有八個源文件,index.html 和 blocks.css 是應(yīng)用的基本結(jié)構(gòu)和樣式,index.html 中分為 3 個部分:分別是左側(cè)的菜單區(qū)、中間的腳本區(qū)和右側(cè)的用例區(qū)。還有兩個 JavaScript 文件也不過多討論:util.js 包含了一些工具函數(shù),file.js 用于加載和保存文件并且序列化腳本。
剩下主要包含如下 js 文件:
- blocks.js 是塊語言的抽象表示
- drag.js 實現(xiàn)了語言的關(guān)鍵交互:允許用戶從可選塊(菜單)中拖拽塊并組裝成程序(腳本)。
- menu.js 包含了一些工具代碼并且負(fù)責(zé)實際地執(zhí)行用戶程序。
- turtle.js 定義了塊語言的特定細(xì)節(jié)并且初始化特定的塊。如果需要定義不同的塊語言,那么就替換該文件。
block.js
每一個塊由一些 HTML 元素組成,由 CSS 設(shè)置樣式,由一些 JavaScript 時間處理器處理拖拽并且修改輸入?yún)?shù)。blocks.js 文件用于創(chuàng)建并管理這些元素,并且將它們組成單一的對象。當(dāng)塊被加入到菜單中時,綁定了一個 JavaScript 函數(shù)用來實現(xiàn)語言,因而腳本中的每個塊在腳本執(zhí)行的時候都要能找到其對應(yīng)的函數(shù)并調(diào)用。

塊有兩種結(jié)構(gòu)。一種擁有一個數(shù)值參數(shù)(具有默認(rèn)值),還有一種作為其它塊的容器。塊的 HTML 代碼類似下面:
<!-- block 的 HTML 結(jié)構(gòu) -->
<div class="block" draggable="true" data-name="Right">
Right
<input type="number" value="5">
degrees
</div>
需要注意的是,腳本區(qū)中的塊和菜單區(qū)中的塊沒有區(qū)別。只有拖拽時會判斷塊是從哪兒拖出來的,腳本只會運行腳本區(qū)的塊,然而它們本質(zhì)上是一樣的結(jié)構(gòu),這就意味著從菜單中向腳本區(qū)拖動塊的時候可以進(jìn)行克隆。
createBlock(name, value, contents) 函數(shù)返回一個代表塊的 DOM 元素,并且在 DOM 中填充了各種內(nèi)部元素,可以直接插入到 document 中。這可以用于向菜單區(qū)添加塊,也可以用于從文件或 localStorage 中恢復(fù)塊到腳本區(qū)。這個函數(shù)是專為 Blockcode 語言編寫的,如果傳入的 value 參數(shù)有值,那么就假定這是一個數(shù)值,并且創(chuàng)建一個 number 類型的 input 元素。該函數(shù)被限制用于 Blockcode,如果要擴(kuò)展塊以支持其它類型的參數(shù),則需要更改代碼。
// 創(chuàng)建塊
function createBlock(name, value, contents) {
var item = elem('div', {'class': 'block', draggable: true, 'data-name': name}, [name])
if(value !== undefined && value !== null) {
item.appendChild(elem('input', {type: 'number', value: value}));
}
if(Array.isArray(contents)) {
item.appendChild(elem('div', {'class':'container'}, contents.map(function(block) {
return createBlock.apply(null, block);
})));
} else if(typeof contents === 'string') {
item.appendChild(document.createTextNode(' ' + contents));
}
return item;
}
還有一些將塊作為 DOM 處理的工具函數(shù):
-
blockContents(block)返回容器塊的子塊。如果參數(shù)是容器塊則以列表的形式返回子塊,否則返回null。 -
blockValue(block)如果塊中包含一個number類型的input則返回input的值,否則返回null。 -
blockScript(block)返回塊的 JSON 形式,便于序列化。其后方便恢復(fù)。 -
runBlocks(blocks)執(zhí)行塊數(shù)組中的所有塊。
// 返回塊的子塊
function blockContents(block) {
var container = block.querySelector('.container');
return container ? [].slice.call(container.children) : null;
}
// 返回input中的數(shù)值
function blockValue(block) {
var input = block.querySelector('input');
return input ? Number(input.value) : null;
}
// 返回塊的json形式用來序列化
function blockScript(block) {
var script = [block.dataset.name];
var value = blockValue(block);
if(value != null) {
script.push(blockValue(block))
}
var contents = blockContents(block);
var units = blockUnits(block);
if (contents) {
script.push(contents.map(blockScript));
}
if(units) {
script.push(units);
}
return script.filter(function(notNull) {
return notNull !== null;
})
}
// 執(zhí)行塊數(shù)組中的所有塊
function runBlocks(blocks) {
blocks.forEach(function(block) {
trigger('run', block);
});
}
JS小知識:[].slice.call方法中slice和Array.prototype.slice方法一樣,slice用來返回數(shù)組,call用來在其他對象上上執(zhí)行slice方法。
drag.js
drag.js 實現(xiàn)了菜單區(qū)和腳本區(qū)的交互,用于將靜態(tài)的 HTML 塊轉(zhuǎn)變?yōu)閯討B(tài)的編程語言。用戶從菜單區(qū)拖動塊到腳本區(qū)來建構(gòu)程序,系統(tǒng)執(zhí)行腳本區(qū)的塊。
我們使用 HTML5 的拖拽功能;需要的 JavaScript 事件處理器在這兒定義。(關(guān)于 HTML5 的拖拽,詳情參考 Eric Bidleman 的文章) 內(nèi)建支持拖拽固然很棒,然而也有一些限制,例如移動端瀏覽器上基本不支持。
文件開頭定義了一些變量。當(dāng)我們拖動時,需要在拖動的不同階段的回調(diào)中引用它們。
var dragTarget = null; // 正在拖動的塊
var dragType = null; // 從菜單還是從腳本中拖動
var scriptBlocks = []; // 腳本區(qū)中的塊
根據(jù)拖動的起始點和結(jié)束位置,drop 會有不同的效果。
- 從腳本區(qū)拖放到菜單區(qū)則刪除
dragTarget(從腳本區(qū)中刪除塊). - 從腳本區(qū)拖放到腳本區(qū)則移動
dragTarget(在腳本區(qū)中移動現(xiàn)有塊). - 從菜單區(qū)拖放到腳本區(qū)則復(fù)制
dragTarget(向腳本區(qū)中插入新塊). - 從菜單拖放到菜單,不做任何事。
在 dragStart(evt) 處理器中我們開始跟蹤塊是從菜單拖放到腳本區(qū)還是相反,或者在腳本區(qū)內(nèi)移動。我們還記錄下了腳本區(qū)中所有沒有被拖動的塊,以便后來使用。evt.dataTransfer.setData 是用來處理瀏覽器和其它應(yīng)用程序之間的拖放,這兒沒有用上,僅僅是為了繞開一個 bug 才使用的。
// 開始拖動
function dragStart(evt) {
if(!matches(evt.target, '.block')) {
return;
}
if(matches(evt.target, '.menu .block')) {
dragType = 'menu';
} else {
dragType = 'script';
}
evt.target.classList.add('dragging');
dragTarget = evt.target;
scriptBlocks = [].slice.call(document.querySelectorAll('.script .block:not(.dragging)'));
evt.dataTransfer.setData('text/html', evt.target.outerHTML);
if(matches(evt.target, '.menu .block')) {
evt.dataTransfer.effectAllowed = 'copy';
} else {
evt.dataTransfer.effectAllowed = 'move';
}
}
當(dāng)我們正在拖動時, 可以在 dragenter, dragover, 和 dragout 事件中添加一些視覺線索,例如高亮放置區(qū)等等。其中我們只使用了 dragover。
// 拖動到當(dāng)前節(jié)點事件
function dragEnter(evt) {
if(matches(evt.target, '.menu, .script, .content')) {
evt.target.classList.add('over');
if(evt.preventDefault) {
evt.preventDefault();
}
} else {
if(!matches(evt.target, '.menu *, .script *')) {
_findAndRemoveClass('over');
evt.target.classList.remove('over');
}
}
return false;
}
// 拖動結(jié)束事件
function dragOver(evt) {
if(!matches(evt.target, '.menu, .menu *, .script, .script *, .content')) {
return;
}
if(evt.preventDefault) {
evt.preventDefault();
}
if(dragType === 'menu') {
evt.dataTransfer.dropEffect = 'copy';
} else {
evt.dataTransfer.dropEffect = 'move';
}
return false; // 阻止默認(rèn)行為
}
當(dāng)我們松開鼠標(biāo)時會有一個 drop 事件,這時候我們需要檢查拖放的起始點,然后要么復(fù)制塊,要么移動塊,或者刪除塊。我們使用 trigger() (定義在 util.js 中)啟動自定義事件用來刷新腳本區(qū)。
// 釋放拖動目標(biāo)事件
function drop(evt) {
if(!matches(evt.target, '.menu, .menu *, .script, .script *')) {
return;
}
var dropTarget = closest(evt.target, '.script .container, .script .block, .menu, .script');
var dropType = 'script';
if(matches(dropTarget, '.menu')) {
dropType = 'menu';
}
if(evt.stopPropagation) {
evt.stopPropagation();
}
if(dragType === 'script' && dropType === 'menu') {
trigger('blockRemoved', dragTarget.parentElement, dragTarget);
dragTarget.parentElement.removeChild(dragTarget);
} else if(dragType ==='script' && dropType === 'script') {
if(matches(dropTarget, '.block')) {
dropTarget.parentElement.insertBefore(dragTarget, dropTarget.nextSibiling);
} else {
dropTarget.insertBefore(dragTarget, dropTarget.firstChildElement);
}
trigger('blockMoved', dropTarget, dragTarget);
} else if(dragType === 'menu' && dropType === 'script') {
var newNode = dragTarget.cloneNode(true);
newNode.classList.remove('dragging');
if(matches(dropTarget, '.block')) {
dropTarget.parentElement.insertBefore(newNode, dropTarget.nextSibiling);
} else {
dropTarget.insertBefore(newNode, dropTarget.firstChildElement);
}
trigger('blockAdded', dropTarget, newNode);
}
}
dragEnd(evt) 在鼠標(biāo)松開時被調(diào)用,然而是在我們處理了 drop 事件之后。這兒我們可以進(jìn)行一些清理,刪除元素中的class,重置以便下次拖放。
function _findAndRemoveClass(klass) {
var elem = document.querySelector('.' + klass);
if(elem) {
elem.classList.remove(klass);
}
}
// 拖動結(jié)束事件
function dragEnd(evt) {
_findAndRemoveClass('dragging');
_findAndRemoveClass('over');
_findAndRemoveClass('next');
JS小知識 在HTML中將節(jié)點的draggable屬性設(shè)為true就可以拖動元素。當(dāng)元素節(jié)點或選中的文本被拖拉時,就會觸發(fā)上文介紹的拖拉事件,其中需要注意的是drop事件只有當(dāng)dragenter和dragovers事件中包含event.preventDefault()的時候才能夠觸發(fā)。
menu.js
在文件 menu.js 中,塊被綁定了執(zhí)行時需要調(diào)用的函數(shù),也包含了實際運行腳本區(qū)塊的代碼。每次腳本被修改后,會自動重新運行。
這里的菜單不是下拉式或者彈出式的,而是一個塊的列表,從中你可以選擇塊,然后拖到腳本區(qū)。該文件就負(fù)責(zé)對菜單區(qū)進(jìn)行設(shè)置,菜單區(qū)以一個提供循環(huán)功能的塊)開始。
我們會較多的使用 menu 和 script,因而保留它們的引用;沒有必要每次都查找它們的 DOM。我們也會用到 scriptRegistry,它保存了菜單中塊的腳本。我們簡單的給菜單區(qū)中的塊進(jìn)行了命名,并進(jìn)行了映射,不支持一個名字對應(yīng)多個塊也不支持重命名塊。這種策略如果用在復(fù)雜的腳本環(huán)境中可能不夠健壯。
我們使用 scriptDirty 來標(biāo)識腳本區(qū)是否已被修改過了,因而可以避免反復(fù)執(zhí)行腳本區(qū)。
var menu = document.querySelector('.menu');
var script = document.querySelector('.script');
var scriptRegistry = {};
var scriptDirty = false;
當(dāng)我們想通知系統(tǒng)在下一個幀處理器中運行腳本,調(diào)用 runSoon() 將 scriptDirty 設(shè)置為 true。系統(tǒng)在每一個幀中調(diào)用 run(),除非 scriptDirty 被設(shè)置,否則立即返回。當(dāng) scriptDirty 被設(shè)置為 true 時,運行腳本區(qū)中所有的塊,并且觸發(fā)事件使得特定的語言處理相關(guān)任務(wù)。這樣做將塊和龜語言進(jìn)行了解耦,使得塊可以被重用(或者也可以說語言可插拔)。
在執(zhí)行腳本的時候,我們遍歷每個塊,調(diào)用它的 runEach(evt),該方法會在塊上添加一個 class(用于 CSS),然后找到并調(diào)用與塊綁定的函數(shù)。如果我們減慢執(zhí)行速度,你將看到每個塊在執(zhí)行時會被高亮。
下面的 requestAnimationFrame 是瀏覽器提供的用作動畫的函數(shù)。它接受一個函數(shù)作為參數(shù),然后在渲染下一幀(每秒60幀)的時候使用。具體得到多少幀取決于我們能多塊的處理任務(wù)。
// 修改標(biāo)識,說明腳本被修改
function runSoon() {
scriptDirty = true;
}
// 每個frame都執(zhí)行
function run() {
if(scriptDirty) {
scriptDirty = false;
Block.trigger('beforeRun', script);
var blocks = [].slice.call(document.querySelectorAll('.script > .block'));
Block.run(blocks);
Block.trigger('afterRun', script);
} else {
Block.trigger('everyFrame', script);
}
requestAnimationFrame(run);
}
// 動畫執(zhí)行
requestAnimationFrame(run);
// 在塊上添加類用來高亮
function runEach(evt) {
var elem = evt.target;
if(!matches(elem, '.script .block')) {
return;
}
if(elem.dataset.name === 'Define block') {
return;
}
elem.classList.add('running');
scriptRegistry[elem.dataset.name](elem);
elem.classList.remove('running');
}
我們使用 menuItem(name, fn, value, contents) 向菜單中添加塊,該函數(shù)接受一個普通塊,然后給它綁定一個函數(shù),并加入到菜單欄。
// 向菜單中添加塊
function menuItem(name, fn, value, uints) {
var item = Block.create(name, value, uints);
scriptRegistry[name] = fn;
menu.appendChild(item);
return item;
}
我們在此處定義 repeat(block),而不是在龜語言中,因為我們希望這個函數(shù)可以在不同的語言中通用。如果我們有了 if 塊和讀寫變量,這些也應(yīng)該放到這里,或者是一個單獨的語言轉(zhuǎn)換模塊,然而此時我們只有這一個通用的塊。
function repeat(block) {
var count = Block.value(block);
var children = Block.contents(block);
for(var i = 0; i < count; i++) {
Block.run(children);
}
}
menuItem('Repeat', repeat, 10, []);
turtle.js
turtle.js 是龜塊語言的實現(xiàn)部分。它不向代碼的其余部分公開任何函數(shù),因此它不被其它任何代碼依賴。因而這一部分可以很輕易的替換而不必?fù)?dān)心項目核心被破壞。

龜編程是圖形編程的一種,由于 Logo 語言被大眾所熟悉,簡單的說就是一個攜帶著一只筆的烏龜在屏幕上移動。你可以命令烏龜收起筆(不再畫線,繼續(xù)移動),放下筆(移動到哪兒,筆畫到哪兒),移動指定數(shù)量步,或者轉(zhuǎn)向多少度。僅僅依靠這些命令和循環(huán),就可以畫出令人驚嘆的復(fù)雜圖案。
在這個龜圖版本中,我們有一些額外的塊。技術(shù)上講,我們并不需要 turn right 和 turn left 同時存在,因為擁有其中一個,那么另外一個可以使用負(fù)數(shù)完成。類似的 move back 也可以使用 move forward 加上負(fù)數(shù)解決。
上面的圖案是將兩個循環(huán)放入另一個循環(huán)之中,然后給每個循環(huán)添加一個 move forward 和 turn right,然后交互地調(diào)整參數(shù),直到得到滿意的圖案。
var PIXEL_RATIO = window.devicePixelRatio || 1;
var canvasPlaceholder = document.querySelector('.canvas-placeholder');
var canvas = document.querySelector('.canvas');
var script = document.querySelector('.script');
var ctx = canvas.getContext('2d');
var cos = Math.cos, sin = Math.sin, sqrt = Math.sqrt, PI = Math.PI;
var DEGREE = PI / 180;
var WIDTH, HEIGHT, position, direction, visible, pen, color;
reset() 函數(shù)將所有的狀態(tài)變量恢復(fù)到默認(rèn)狀態(tài)。如果我們需要支持多個小烏龜,可以將這些變量封裝到一個對象中。我們有一個使用工具 deg2rad(deg) 函數(shù)用于將度轉(zhuǎn)化為弧度,因為 UI 中使用的是角度,作圖使用的是弧度。drawTurtle() 用于畫小烏龜自身,默認(rèn)是一個簡單的三角形。當(dāng)然你可以自定義成更加華麗的小烏龜。
注意 drawTurtle 函數(shù)使用了我們在 turtle 繪圖中定義的一些基本操作。有時你不想在不同的抽象層之間重用代碼,然而如果定義明確,這對于代碼大小和性能是至關(guān)重要的。
function reset(){
recenter();
direction = deg2rad(90); // facing "up"
visible = true;
pen = true; // when pen is true we draw, otherwise we move without drawing
color = 'black';
}
function deg2rad(degrees){ return DEGREE * degrees; }
function drawTurtle(){
var userPen = pen; // save pen state
if (visible){
penUp(); _moveForward(5); penDown();
_turn(-150); _moveForward(12);
_turn(-120); _moveForward(12);
_turn(-120); _moveForward(12);
_turn(30);
penUp(); _moveForward(-5);
if (userPen){
penDown(); // restore pen state
}
}
}
我們有個專門的塊用于畫一個特定半徑的圓。我們專門設(shè)置 drawCircle,因為你雖然可以使用 MOVE 1 RIGHT 1 然后循環(huán) 360 次來畫圓,但這太麻煩了。
function drawCircle(radius){
// Math for this is from http://www.mathopenref.com/polygonradius.html
var userPen = pen; // save pen state
if (visible){
penUp(); _moveForward(-radius); penDown();
_turn(-90);
var steps = Math.min(Math.max(6, Math.floor(radius / 2)), 360);
var theta = 360 / steps;
var side = radius * 2 * Math.sin(Math.PI / steps);
_moveForward(side / 2);
for (var i = 1; i < steps; i++){
_turn(theta); _moveForward(side);
}
_turn(theta); _moveForward(side / 2);
_turn(90);
penUp(); _moveForward(radius); penDown();
if (userPen){
penDown(); // restore pen state
}
}
}
我們主要的主要原語是 moveForward,它處理了一些三角函數(shù)的運算工作,并且判斷筆是否收起或放下。
function _moveForward(distance){
var start = position;
position = {
x: cos(direction) * distance * PIXEL_RATIO + start.x,
y: -sin(direction) * distance * PIXEL_RATIO + start.y
};
if (pen){
ctx.lineStyle = color;
ctx.beginPath();
ctx.moveTo(start.x, start.y);
ctx.lineTo(position.x, position.y);
ctx.stroke();
}
}
剩下大部分的龜語言命名都可以使用上面定義的函數(shù)來輕松的實現(xiàn)。
function penUp(){ pen = false; }
function penDown(){ pen = true; }
function hideTurtle(){ visible = false; }
function showTurtle(){ visible = true; }
function forward(block){ _moveForward(Block.value(block)); }
function back(block){ _moveForward(-Block.value(block)); }
function circle(block){ drawCircle(Block.value(block)); }
function _turn(degrees){ direction += deg2rad(degrees); }
function left(block){ _turn(Block.value(block)); }
function right(block){ _turn(-Block.value(block)); }
當(dāng)我們需要刷新狀態(tài),clear 函數(shù)可以恢復(fù)初始狀態(tài)。
function clear(){
ctx.save();
ctx.fillStyle = 'white';
ctx.fillRect(0,0,WIDTH,HEIGHT);
ctx.restore();
reset();
ctx.moveTo(position.x, position.y);
}
當(dāng)這個腳本初次加載并運行時,我們使用 reset 和 clear 來初始化并畫出小烏龜。
onResize();
clear();
drawTurtle();
現(xiàn)在我們可以在 menu.js 文件中通過 Menu.item 函數(shù)使用上面定義的函數(shù),為用戶生成腳本創(chuàng)建塊。這些塊被拖到合適的地方構(gòu)成用戶的程序。
Menu.item('Left', left, 5, 'degrees');
Menu.item('Right', right, 5, 'degrees');
Menu.item('Forward', forward, 10, 'steps');
Menu.item('Back', back, 10, 'steps');
Menu.item('Circle', circle, 20, 'radius');
Menu.item('Pen up', penUp);
Menu.item('Pen down', penDown);
Menu.item('Back to center', recenter);
Menu.item('Hide turtle', hideTurtle);
Menu.item('Show turtle', showTurtle);
經(jīng)驗總結(jié)
為何不使用 MVC?
Model-View-Controller (MVC) 對于 80 年代的 Smalltalk 程序來說是一個好的設(shè)計選擇,現(xiàn)在也可以用于一些 Web 應(yīng)用,然而它不是解決所有問題的最好選擇。塊語言中所有的狀態(tài)(MVC中的“M”)都被塊對象所持有,因而將該模式復(fù)制到 Javascript 中實無必要,除非對于模型有其它的需求(例如編輯共享的、分布式的代碼)。
在 Waterbear 的早期版本中,我曾通過在 JavaScript 中維持模型然后將之同步到 DOM 中,后來我發(fā)現(xiàn)有一半的代碼和 90% 的 bug 都出于這里。消除了這種重復(fù)之后,代碼變得更加簡單和健壯,所有的狀態(tài)都被 DOM 元素所持有后,使用開發(fā)者工具可以輕松查出很多 bug。因此在 HTML/CSS/JavaScript 的基礎(chǔ)上增加 MVC 的額外分層沒有什么幫助。
細(xì)微更改引發(fā)大變更
構(gòu)建一個我所從事的大型系統(tǒng)的小型而簡潔的版本是一個有趣的練習(xí)。在構(gòu)建大型系統(tǒng)時,你對于改變總是很遲疑,因為這會影響到很多方面。然后在一個小型版本中,你可以盡情試驗然后將所學(xué)到的東西應(yīng)用到大型系統(tǒng)。對于我來說,大型系統(tǒng)就是 Waterbear,而這個功能對于 Waterbear 的構(gòu)建很有建設(shè)意義。
小實驗使得失敗不再可怕
在這個微型的塊語言中,我進(jìn)行了下面的試驗:
- 使用 HTML5 的拖拽
- 通過直接遍歷 DOM 并調(diào)用綁定函數(shù)的方式來運行塊
- 將實際運行的代碼從 HTML DOM 中分離
- 簡化了拖動時的碰撞檢測
- 構(gòu)建了我的微型向量(vector)和精靈(sprite)庫(用于游戲塊)
- 改變腳本時實時顯示結(jié)果
試驗不一定需要成功。我們總是傾向于將失敗看得很嚴(yán)重,而不是將它視為通向成功的路徑。雖然我搞定了 HTML5 的拖拽,然后由于在手機瀏覽器上不支持,所以在 Waterbear 中沒有采用。分離代碼并且通過迭代塊來執(zhí)行工作良好,因此我已經(jīng)著手將它引入 Waterbear 了。
我們真正需要構(gòu)建什么?
構(gòu)建一個大型系統(tǒng)的小型版本可以使得我們更加專注于最重要的部分。使得我們思考,有哪些功能是歷史遺留而不再需要維護(hù)?有沒有無人使用但必須付費才能維護(hù)的功能?用戶界面是否可以簡化?在制作一個小版本的時候,所有這些都是很好的問題。重大的改變,如重新組織布局,可以不用擔(dān)心通過更復(fù)雜的系統(tǒng)級聯(lián)的后果,甚至可以幫助大型系統(tǒng)進(jìn)行重構(gòu)。
構(gòu)建程序之路還很漫長
在該項目中,我還有一些東西沒有試驗,也許將來會進(jìn)行嘗試。比如添加一個能添加函數(shù)的塊來創(chuàng)建不存在的塊。實現(xiàn)撤銷和重做功能。使塊能接受多個參數(shù)而不增加復(fù)雜性。找到方法通過網(wǎng)絡(luò)分享腳本時該工具的網(wǎng)絡(luò)型得到充分發(fā)揮。