為什麼在光暈戰記做Bad Apple 其實不容易

想直接看成果的傳送門 => https://youtu.be/4LLYvSY010Y

目標: 在光暈戰記上面播一個能玩的Bad Apple,同時播放原影片

最初的開發方向: 先預備好每個畫格的資料,以二維布冧陣列表示每一格,用箱子變黑顯示,主要有三步

  1. 預備二維布冧陣列

    1. 搞到影片 (480px*360px)

    2. 到光暈戰記量度我想要的輸出大小 (18格*13格)

    3. 用python opencv2 將影片轉成二維布冧陣列,用gzip 方式壓縮,因為那麼多畫格直接json可能會慢。上載至github page 備用

  2. 研究如何在CG以程式方式放置地圖物件

    1. 看大神的Alt模組是怎製作放置地圖物件動作

      ^用F12下載他的碼

      ^ 找到新增地圖物件的代號

      ^ 在代碼中找到他的原理1

      ^ 在代碼中找到他的原理2,至於怎樣去理解這種超醜的程式,我也不知道怎解釋...

    2. 理解好到CG試試自製一個動作去放地圖物件

      import CgAction = CG.CgEventsEngine.CgAction;
      import TWEventsUtil = CG.TwilightWarsLib.libs.TWEventsUtil;
      export class PlayBadAppleAction extends CgAction{
          // ...
          
          public someFunction() {
              const game = TWEventsUtil.getEventsGame(this.event.manager);
              const box1 = game.mapResource.getObjectResourceByName('box1')
              const obj = game.mapRenderer.buildObjectRenderer(box1, 5, 5)
              game.addToGroundRoot(obj)
          }
      }
    3. 調整地圖物件至黑色,黑木箱是黑象素,地皮是白象素

      obj.children[0].tint = '#000000'
  3. 嘗試用地圖物件播放Bad Apple

    1. 用fetch API 去下載github page 上面的gzip file,用pakos 解壓

      export class PlayBadAppleAction extends CgAction{
          //...
          
          private frames
          
          public function prepare(){
              fetch('https://dipsy.me/bad-apple/compressed.gzip')
                  .then(r => r.arrayBuffer())
                  .then(r => pako.ungzip(r as Uint8Array, { to: 'string' }))
                  .then(JSON.parse)
                  .then(frames => {
                      this.frames = frames
                      this.play()
                  })
          }
      }
    2. 預先生成18*13的箱子陣列,用visibility 操控而不是移除再放置可以更快哦

      export class PlayBadAppleAction extends CgAction{
          //...
          
          private pixels = {}
          
           private preparePixels() {
              let game = TWEventsUtil.getEventsGame(this.event.manager);
              const box0 = game.mapResource.getObjectResourceByName('box1')
       
              for (let i = 0; i <= 18; i++) {
                  for (let j = 0; j <= 13; j++) {
                      const obj = game.mapRenderer.buildObjectRenderer(box0, i, j)
                      obj.visible = false
                      game.addToObjectRoot(obj)
                      // @ts-ignore
                      obj.children[0].tint = '#ffffff'
                      this.pixels[`${i},${j}`] = obj
                  }
              }
          }
      }
    3. 用setInterval 的方式以30fps 播放影片 (原影片是30fps)

      export class PlayBadAppleAction extends CgAction{
          //...
          
          private play() {
               var i = 0
               var k = setInterval(() => {
                   this.render(this.frames[i])
                   console.log(i)
                   i = i + 1
                   if (i >= this.frames.length) {
                       clearInterval(k)
                   }
               }, 1000 / 30)
          }
      }
  4. 將影片放到光暈裡面放,兩個問題:

    1. 光暈和原影片及音樂不同步
    2. 太少象素,根本看不出那個是bad apple
  5. 解決不同步問題

    1. 決定要改變開發方向,放棄gzip 二維布冧陣列方案,改為一直播放影片,即時讀取影片的象素來生成光暈的地圖物件
    2. stackoverflow 找到方法 https://stackoverflow.com/questions/12130475/get-raw-pixel-data-from-html5-video
    3. 建立多一個canvas,每次getAnimationFrame 將video tag內的影片渲染到canvas ,再用canvas 的getPixelData 取得它是不是黑色
    4. 成功做到在CG同人陣上播放Bad Apple並與光暈同步
  6. 解決太低清問題

    1. 以畫面縮放方式令畫面可以顯示更多地圖格子,從而令畫面更高清

    2. 計畫用180*130解象度,就是將畫面縮小10倍的簡單數學

    3. 看大神的Alt模組是怎製作畫面縮放

    4. 實測會看不到原本視野範圍以外的地板,但能看到視野以外的箱子

    5. 決定將原本的地板弄成黑色,木箱用作白色象素

    6. 卡爆,縮小3倍好了

    7. 效果就跟youtube上面的一樣了

  7. 做成能玩的Bad Apple - 大致方向就是想避開黑色

    1. 用光暈內建WASD來走太慢,決定讓鼠標避開黑色
    2. 找不到CG中得到鼠標在地圖上位置的方法
    3. 自己算鼠標位置,但需要校正
    4. 最後新增了兩個木箱,一個在(0,0),一個在(0,1),再分別取得它們在canvas 上面的座標,從而得出光暈戰記Bad Apple 在canvas左上角的象素座標和每個地圖格子的長和寛
      const box = game.mapResource.getObjectResourceByName('box1')
      const obj0 = game.mapRenderer.buildObjectRenderer(box, 0, 0) // 生成一個箱子的pixi sprite並放於地圖的0,0;即藍點
      const obj0 = game.mapRenderer.buildObjectRenderer(box, 1, 0) // 生成一個箱子的pixi sprite並放於地圖的1,0;即綠點
      const left = obj0.getGlobalPosition().x	// 地圖(0,0)距離canvas(0,0)(即紅點)left那麼多象素
      const delta = obj1.getGlobalPosition().x - left // 	箱子的長度和寛度
       
      game.on('mousemove', (event: PIXI.InteractionEvent) => {
          // 滑鼠移動時會觸發的函式
          // event.data.global 是鼠標在canvas 上面的座標(相對紅點多少象素)
          // 下面算出鼠標在哪個地圖格子上面並記下,待渲染時計算傷害
          this.prevX = Math.floor((event.data.global.x - left) / size)
          this.prevY = Math.floor(event.data.global.y / size)
      })
    5. 每次getAnimationFrame 可以順道檢查鼠標所在的地圖格子this.prevX, this.prevY是不是黑色的,從而判斷要不要扣血
    6. 玩兩三次調整傷害力和血量來令遊戲變得更具挑戰性
  8. 加入開始頁面和任務完成就完工了~

  9. 嗯寫這個write up花的時間比做這個遊戲還要久