個案研究 - 打造 Stanisław Lem Google Doodle

Marcin Wichary
Marcin Wichary

向 (奇怪的) 世界問好

Google 首頁是編寫程式碼的絕佳環境。這項工作有許多嚴格的限制:特別是速度和延遲,必須滿足各種瀏覽器的需求,並在各種情況下運作,而且...是的,要讓使用者驚喜連連。

我指的是 Google Doodle,也就是偶爾會取代 Google 標誌的特殊插圖。雖然我與筆和畫筆的關係一直有著類似限制令的特殊味道,但我經常會製作互動式內容。

我編寫的每個互動式塗鴉 (Pac-ManJules VerneWorld’s Fair) 和許多我協助編寫的塗鴉,都同時具備未來感和過時感:前者是應用程式運用尖端網頁功能的絕佳機會,後者則是跨瀏覽器相容性的務實實用性。

我們從每個互動式塗鴉中學到很多,最近的 Stanisław Lem 迷你遊戲也不例外,因為這款遊戲的 17,000 行 JavaScript 程式碼,是塗鴉歷史上首次嘗試許多新事物的成果。今天,我想與您分享這段程式碼,也許您會發現一些有趣的內容,或指出我的錯誤,並稍微討論一下。

查看 Stanisław Lem 塗鴉程式碼 »

請注意,Google 首頁並非技術示範的適合場所。我們希望透過塗鴉慶祝特定人物和活動,並運用最優質的藝術和技術來完成這項工作,但絕不會為了技術而慶祝技術。也就是說,我們會仔細查看 HTML5 的各個部分,並評估這些部分是否有助於改善塗鴉,同時不會分散或掩蓋塗鴉。

因此,讓我們一起來看看 Stanisław Lem 塗鴉中,哪些現代網頁技術已找到自己的位置,哪些則未成功。

透過 DOM 和畫布繪製圖形

Canvas 功能強大,而且正是我們在這個塗鴉中想要執行的操作。不過,我們重視的部分舊版瀏覽器不支援這個功能,而且雖然我和另一位同事共用辦公室,而他也製作了相當優異的 excanvas,但我還是決定選擇其他方法。

我整合了一個圖形引擎,用於抽象化稱為「矩形」的圖形原素,然後使用畫布或 DOM (如果無法使用畫布) 來算繪這些圖形。

這種方法會帶來一些有趣的挑戰,例如在 DOM 中移動或變更物件會立即產生後果,而畫布則會在特定時間同時繪製所有內容。(我決定只使用一個畫布,並在每個影格中清除畫布並從頭繪製。一方面是因為變動因素太多,另一方面是因為複雜度不足以分割成多個重疊的畫布,並選擇性地更新這些畫布)。

很遺憾,切換至畫布並非像使用 drawImage() 鏡射 CSS 背景那麼簡單:透過 DOM 將元素組合在一起時,您會失去許多免費提供的元素,其中最重要的是使用 z-index 和滑鼠事件進行分層。

我已經使用「平面」這個概念抽象化 z 索引。塗鴉定義了許多平面,從遠處的天空到所有物體前方的滑鼠游標,而塗鴉中的每個元素都必須決定自己屬於哪一個平面 (使用 planeCorrection 可在平面內進行小幅加/減修正)。

透過 DOM 轉譯時,平面會簡單轉譯為 z-index。不過,如果我們透過畫布算繪,則需要根據矩形的平面排序,再繪製矩形。由於每次執行這項操作的成本都很高,因此只有在新增或移動至其他平面時,才會重新計算順序。

至於滑鼠事件,我也有抽象化處理,針對 DOM 和畫布,我使用了額外的完全透明浮動 DOM 元素,並為其設定較高的 z-index,其功能僅是回應滑鼠移入/移出、點擊和輕觸。

我們希望透過這次塗鴉突破第四面牆,上述引擎可讓我們將以畫布為基礎的執行者與以 DOM 為基礎的執行者結合。舉例來說,結局中的爆炸效果會同時出現在虛擬世界物件的畫布中,以及 Google 首頁的其他 DOM 中。鳥類通常會在拍攝期間四處飛行,並像其他角色一樣被鋸齒狀遮罩遮住,但它決定在拍攝期間保持安全,並停在「我很幸運」按鈕上。這項操作的方式是讓鳥兒離開畫布,並成為 DOM 元素 (反之亦然),我希望這對訪客來說是完全透明的。

影格速率

瞭解目前的幀率,並在幀率過慢 (或過快) 時做出反應,是我們引擎的重要部分。由於瀏覽器不會回報影格速率,因此我們必須自行計算。

我開始使用 requestAnimationFrame,如果前者無法使用,則改用舊式 setTimeoutrequestAnimationFrame 會在某些情況下巧妙地節省 CPU,雖然我們會自行執行部分操作 (如後文所述),但這也讓我們可以獲得比 setTimeout 更高的幀率。

計算目前的幀率很簡單,但可能會發生劇烈變化,例如當其他應用程式佔用電腦一段時間時,幀率可能會迅速下降。因此,我們只會在每 100 個實體時標計算「滾動」(平均) 影格速率,並根據這些資料做出決策。

什麼樣的決策?

  • 如果影格速率超過 60 fps,我們會降低影格速率。目前,某些版本的 Firefox 中的 requestAnimationFrame 沒有影格速率上限,因此沒有必要浪費 CPU。請注意,我們實際上會將上限設為 65fps,因為在其他瀏覽器上,四捨五入誤差會讓影格速率比 60fps 高一點,我們不希望誤將這項功能納入限制。

  • 如果影格速率低於 10 fps,我們會簡單地減緩引擎,而不是捨棄影格。這兩種做法都會造成損失,但我認為,相較於讓遊戲速度變慢 (但仍保持一致性),讓遊戲過度略過影格會讓玩家更困惑。這還帶來另一個不錯的附帶效果:如果系統暫時變慢,使用者就不會因為引擎急於趕上進度而遇到奇怪的跳躍情形。(我對「吃豆人」採用了略有不同的做法,但最小影格速率是較佳的做法)。

  • 最後,當影格速率降至危險低點時,我們可以考慮簡化圖形。除了滑鼠游標 (請參閱下文),我們不會為 Lem 塗鴉執行這項操作,但假設我們可以移除一些不必要的動畫,讓塗鴉在較慢的電腦上也能流暢顯示。

我們也定義了實體勾選和邏輯勾選的概念。前者來自 requestAnimationFrame/setTimeout。一般遊戲過程中的比例為 1:1,但如果要快轉,我們只需在每個物理時鐘點新增更多邏輯時鐘點 (最多 1:5)。這樣一來,我們就能為每個邏輯時刻執行所有必要的計算,但只指定最後一個時刻,用於更新畫面上的內容。

基準化

我們可以假設 (實際上在早期就是如此),只要可用,畫布就會比 DOM 更快。但這並非一成不變的事實。在測試過程中,我們發現在 Mac 上執行的 Opera 10.0 至 10.1 版,以及在 Linux 上執行的 Firefox,在移動 DOM 元素時速度更快。

在理想情況下,塗鴉會在背景中基準測試不同的圖形技術,包括使用 style.leftstyle.top 移動 DOM 元素、在畫布上繪圖,甚至使用 CSS3 轉換移動 DOM 元素。

然後切換到幀率最高的選項。我開始為此編寫程式碼,但發現至少我的基準測試方式相當不可靠,而且需要花費大量時間。我們不希望首頁上出現任何延遲時間,因為我們非常重視速度,希望只要點按或輕觸,就會立即顯示塗鴉動畫,並開始遊戲。

最後,網頁開發有時會歸結為必須執行的作業。我回頭確認沒有人看著,然後就將 Opera 10 和 Firefox 硬式編碼為畫布。在下一個生命中,我會以 <marquee> 標記的形式回來。

節省 CPU

你知道那位朋友來你家時,會看《絕命毒師》第 2 季大結局,並向你透露劇情,然後從你的 DVR 中刪除嗎?你不想成為那種人,對吧?

所以,沒錯,這是有史以來最糟糕的比喻。但我們也不希望自己的塗鴉會成為那種人。我們能進入使用者的瀏覽器分頁,這本身就是一種特權,如果佔用 CPU 週期或干擾使用者,就會讓使用者覺得不受尊重。因此,如果沒有人玩塗鴉遊戲 (沒有輕觸、滑鼠點擊、滑鼠移動或按鍵),我們希望它最終會進入休眠狀態。

何時?

  • 在首頁上顯示 18 秒後 (街機遊戲稱之為「吸引模式」)
  • 在分頁有焦點的情況下,180 秒後
  • 30 秒後,如果分頁沒有焦點 (例如使用者切換至其他視窗,但仍在非活動分頁中觀看塗鴉)
  • 如果分頁不可見,則立即關閉 (例如,使用者在同一個視窗中切換至其他分頁,如果我們無法顯示,就沒有浪費週期)

如何得知分頁目前有焦點?我們會連結至 window.focuswindow.blur。我們如何知道分頁是否可見?我們會使用新的 Page Visibility API,並回應適當的事件。

上述超時時間比平常寬鬆。我將這些元素改編成這幅特別的塗鴉,其中包含許多環境動畫 (主要是天空和鳥類)。理想情況下,超時會受到遊戲內互動的限制,例如在著陸後,鳥兒可以回報給塗鴉,表示現在可以進入休眠狀態,但我最後並未實作這項功能。

由於天空會持續移動,因此當系統進入休眠狀態或喚醒時,塗鴉並不會停止或開始,而是會先減速再暫停,反之亦然,並視需要增加或減少每個實體時脈的邏輯時脈數。

轉場、轉換、事件

HTML 的其中一個優點,就是您可以自行改善它:如果 HTML 和 CSS 的常規組合不夠好,您可以使用 JavaScript 擴充功能。很遺憾,這通常意味著必須從頭開始。CSS3 轉場效果很棒,但您無法新增新的轉場類型,也無法使用轉場效果執行其他任何操作,除了設定元素樣式。另一個例子:CSS3 轉換效果非常適合 DOM,但當您切換至畫布時,就必須自行處理。

因此,Lem 塗鴉才會擁有自己的轉場和轉換引擎。是的,我知道 2000 年代的呼叫等功能,我內建的功能遠不如 CSS3 強大,但無論引擎執行什麼,都能提供一致的結果,並提供更多控制選項。

我從簡單的動作 (事件) 系統開始,也就是在未來觸發事件的時間軸,不使用 setTimeout,因為在任何時間點,塗鴉時間都可能與實際時間脫節,因為它會變得更快 (快轉)、更慢 (低影格率或進入休眠狀態以節省 CPU),或完全停止 (等待圖片完成載入)。

轉場只是另一種動作。除了基本動作和旋轉之外,我們也支援相對動作 (例如將某物向右移動 10 個像素)、顫動等自訂動作,以及主要影格圖片動畫。

我提到旋轉功能,這也是手動完成的:我們有各種角度的圖像,用於需要旋轉的物件。主要原因是 CSS3 和畫布旋轉都會產生視覺瑕疵,我們認為這是不可接受的情形。此外,這些瑕疵會因平台而異。

由於某些旋轉的物件會連結至其他旋轉物件,例如機器人的手連結至下臂,而下臂本身則連結至旋轉的上臂,因此我還需要以樞紐的形式建立簡易的 transform-origin。

這一切都是大量的工作,最終涵蓋 HTML5 已處理的基礎,但有時原生支援功能不夠好,這時就需要重新發明輪子。

處理圖片和精靈

引擎不僅用於執行塗鴉,也用於處理塗鴉。我已在上述內容中分享部分偵錯參數:您可以在 engine.readDebugParams 中找到其餘參數。

分層繪製是我們在繪製塗鴉時也常用的知名技巧。這可讓我們節省位元組並縮短載入時間,同時也能簡化預先載入作業。不過,這也讓開發作業變得更加困難,因為每次變更圖像都需要重新進行重繪 (雖然大部分是自動化,但仍相當繁瑣)。因此,引擎支援在原始圖片上執行開發作業,以及透過 engine.useSprites 執行實際工作環境的符號圖,兩者皆包含在原始碼中。

小精靈遊戲 Doodle
小精靈塗鴉使用的圖像方塊。

我們也支援預先載入圖片,並在圖片未及時載入時暫停塗鴉,並附上假進度列!(不幸的是,即使是 HTML5,也無法告訴我們圖片檔案已載入了多少內容)。

載入圖片的螢幕截圖,其中包含固定進度列。
載入圖片的螢幕截圖,其中包含固定進度列。

對於某些場景,我們使用多個圖像片段,並非為了加快使用平行連線的載入速度,而是因為 iOS 上的圖片限制為 3/5 百萬像素

HTML5 在其中扮演什麼角色?以上內容不多,但我為製作/裁剪圖片所編寫的工具是全新的網路技術:畫布、Bloba[download]。HTML 的其中一個優點,就是它會逐漸納入先前必須在瀏覽器外執行的作業,我們只需要在 HTML 中執行的部分,就是最佳化 PNG 檔案。

在遊戲之間儲存狀態

Lem 的遊戲世界總是讓人感覺廣闊、生動且寫實。他的小說通常一開始就沒有太多解釋,第一頁就直接進入情節,讀者必須自行找出方向。

而 Cyberiad 也不例外,我們希望這次塗鴉也能重現那種感覺。我們會先試著避免過度解釋故事。另一個主要部分是隨機化,我們認為這類似於書中宇宙的機械性質;我們有許多輔助函式可處理隨機性,並在許多地方使用這些函式。

我們也希望透過其他方式提高遊戲的可重玩性。為此,我們需要知道這幅塗鴉先前完成了多少次。以往的正確技術解決方案是使用 Cookie,但這不適用於 Google 首頁,因為每個 Cookie 都會增加每個網頁的酬載,而我們非常重視速度和延遲時間。

幸好,HTML5 提供 Web Storage,使用起來很簡單,可讓我們儲存及回想使用者觀看的一般播放次數和最後一個場景,比 Cookie 更有彈性。

我們會如何處理這些資訊?

  • 我們會顯示快轉按鈕,讓使用者快速略過先前看過的過場動畫
  • 我們會在最終集顯示不同的 N 項目
  • 我們稍微提高了射擊關卡的難度
  • 我們會在第三次及後續的遊玩次數中,顯示來自不同故事的彩蛋龍

有許多偵錯參數可控制此問題:

  • ?doodle-debug&doodle-first-run – 假設這是首次執行
  • ?doodle-debug&doodle-second-run – 假設這是第二次執行
  • ?doodle-debug&doodle-old-run – 假設是舊的跑步

觸控式裝置

我們希望這款塗鴉遊戲在觸控裝置上也能流暢運作,因為現在的觸控裝置效能都相當強大,因此這款塗鴉遊戲在這些裝置上運作得非常順暢,而且透過輕觸操作體驗遊戲,比起透過點選操作更有趣。

我們需要對使用者體驗進行一些前置變更。原本,只有滑鼠游標會顯示正在播放的過場動畫/非互動內容。後來我們在右下角新增了一個小指標,因此不必單獨依賴滑鼠游標 (因為觸控裝置上沒有滑鼠游標)。

一般 忙碌 可點按 曾點閱過
進行中
處理中正常指標
處理中繁忙指標
工作進行中可點選的指標
工作進度點選指標
決賽
最終的一般指標v
最終忙碌指標
最終可點選指標
最終點擊的指標
開發期間的滑鼠游標,以及最終的等效項目。

大多數內容都能立即使用。不過,我們針對觸控體驗進行快速即興的可用性測試,發現了兩個問題:部分目標按鈕很難按下,而且系統會忽略快速輕觸動作,因為我們剛剛覆寫了滑鼠點擊事件。

這裡使用獨立的可點選透明 DOM 元素,可說是相當實用,因為我可以不受視覺效果影響來調整這些元素的大小。我為觸控裝置引入額外的 15 像素邊框,並在建立可點選的元素時使用。(我也為滑鼠環境新增了 5 像素的邊框間距,只為了讓 Fitts 先生開心)。

至於其他問題,我只是確保附加並測試適當的觸控開始和結束處理常式,而不是依賴滑鼠點擊。

我們也使用更現代的樣式屬性,移除 WebKit 瀏覽器預設加入的部分觸控功能 (輕觸醒目顯示、輕觸提示)。

我們如何偵測特定裝置是否支援觸控功能?懶散地。我們並未事先判斷,而是在收到第一個觸控開始事件後,使用結合的 IQ 推斷裝置支援觸控。

自訂滑鼠游標

但並非所有操作都需要觸控。我們在設計時,有一個指導原則,就是盡可能在塗鴉中加入更多元素。小小的側欄 UI (快轉、問號)、工具提示,甚至是滑鼠游標。

如何自訂滑鼠游標?部分瀏覽器允許連結至自訂圖片檔案,藉此變更滑鼠游標。不過,這項做法不太受支援,且有一定限制。

如果不是這個,那麼是什麼?那麼,為什麼不讓滑鼠游標成為塗鴉中的另一個角色呢?這個方法雖然可行,但有許多限制,主要包括:

  • 您必須能夠移除原生滑鼠游標
  • 你必須非常擅長讓滑鼠游標與「實際」滑鼠游標保持同步

前者比較棘手。CSS3 允許使用 cursor: none,但部分瀏覽器也不支援。我們需要使用一些特殊技巧:使用空白 .cur 檔案做為備用方案、為部分瀏覽器指定具體行為,甚至將其他瀏覽器硬式編碼為任何體驗。

另一個問題表面上看起來比較簡單,但由於滑鼠游標只是塗鴉的另一個部分,因此也會繼承所有問題。最大的挑戰是什麼呢?如果塗鴉的影格速率偏低,滑鼠游標的影格速率也會偏低,這會造成嚴重後果,因為滑鼠游標是手的自然延伸,無論如何都需要有回應。(過去曾使用 Commodore Amiga 的人現在正熱烈點頭)。

解決這個問題的一個較複雜的做法,是將滑鼠游標與一般更新迴圈解耦。我們就是這麼做的,在另一個我不需要睡覺的平行世界中。有沒有更簡單的解決方法?只要在滾動影格速率低於 20fps 時,改回原生滑鼠游標即可。(這時滾動影格速率就派上用場。如果我們對目前的幀率做出反應,而幀率恰好在 20 fps 左右擺動,使用者就會看到自訂滑鼠游標一直顯示和隱藏。這會導致:

影格速率範圍 行為
>10fps 放慢遊戲速度,避免更多影格遺失。
10 到 20 fps 使用原生滑鼠游標,而非自訂滑鼠游標。
20 至 60 fps 正常運作。
>60fps 節流,以免影格速率超過這個值。
影格速率依賴行為摘要。

另外,Mac 上的滑鼠游標是深色,但在 PC 上是白色。這是因為因為即使在虛構世界中,平台之間的戰爭也需要燃料。

結論

這不是完美的引擎,但也不會嘗試成為完美引擎。這項功能是與 Lem 塗鴉一併開發,專門用於該塗鴉。這沒關係。正如 Don Knuth 的名言所言:「過早的最佳化是所有惡行的根源。」我認為先撰寫引擎,然後再套用,這麼做並不合理,因為實務經驗與理論知識一樣,都能為理論提供資訊,就我而言,程式碼已遭丟棄,許多部分不斷重寫,許多常見的部分也已注意到後續動作,而不是事前預測。但最終,我們還是決定採用這項功能,以便達成我們的目標:以最佳方式慶祝 Stanisław Lem 的職業生涯,以及 Daniel Mróz 的繪圖。

希望上述內容能讓您瞭解我們需要做出的部分設計選擇和取捨,以及我們如何在特定實際情境中使用 HTML5。請試用原始碼,並與我們分享您的想法。

我自己也做了這件事,以下是過去幾天在俄羅斯的實況畫面,倒數至 2011 年 11 月 23 日凌晨,這是第一個看到 Lem 塗鴉的時區。這可能有點愚蠢,但就像塗鴉一樣,看似微不足道的事物有時也有更深層的意義。這個計數器對引擎來說,其實是相當不錯的「壓力測試」。

螢幕截圖:Lem 塗鴉中的宇宙倒數時鐘。
Len 塗鴉內建倒數時鐘的螢幕截圖。

這就是 Google Doodle 的生命週期:花費數月時間進行設計、數週時間進行測試,再花 48 小時進行開發,只為了讓使用者玩五分鐘。每行 JavaScript 程式碼都希望這 5 分鐘能讓您獲得實質效益。祝你使用愉快!