EaselJS 原始碼分析系列--第三篇

2023-07-18 15:00:26

這一篇分析另外四個稍顯高階的顯示類 -- Sprite、Movieclip、DOMElement、BitmapText

SpriteSheet

SpriteSheet 比較簡單

它繼承自 EventDispatcher 所以 SpriteSheet 並不是顯示類

它是顯示類 Sprite 的資料來源為 Sprite 傳遞組裝 SpriteSheet 實現動畫效果如:

var data = {
 			images: ["sprites.jpg"],
 			frames: {width:50, height:50},
 			animations: {
 				stand:0,
 				run:[1,5],
 				jump:[6,8,"run"]
 			}
  };
var spriteSheet = new createjs.SpriteSheet(data);
var animation = new createjs.Sprite(spriteSheet, "run");

images 第一幀的影象資料

frames 屬性規定的是每一幀的 x, y, width, height, imageIndex, regX, regY*

frames 傳遞陣列,則可以為每一幀指定不同的引數

frames 傳遞物件,則可以為同尺寸的不同幀統一指定引數

animations 表示的是動畫播放邏輯,幀之間的跳轉,animations 下的各個 key 表示的是一組組的動畫

需要播放或暫停動畫時可直接使用這些 key 作為名字傳遞 比如 gotoStop('stand') 、 gotoPlay('jump')

最主要的方法就是 _parseData 和 _calculateFrames

// SpriteSheet 類 原始碼 485 - 564 行
p._parseData = function(data) {		
  var i,l,o,a;
  if (data == null) { return; }

  this.framerate = data.framerate||0;

  // 解析 images 屬性
  if (data.images && (l=data.images.length) > 0) {
    a = this._images = [];
    for (i=0; i<l; i++) {
      var img = data.images[i];
      if (typeof img == "string") {
        var src = img;
        img = document.createElement("img");
        img.src = src;
      }
      a.push(img);
      // 如果需要載入圖片,則非同步載入
      if (!img.getContext && !img.naturalWidth) {
        this._loadCount++;
        this.complete = false;
        (function(o, src) { img.onload = function() { o._handleImageLoad(src); } })(this, src);
        (function(o, src) { img.onerror = function() { o._handleImageError(src); } })(this, src);
      }
    }
  }

  // 解析 frames 屬性
  if (data.frames == null) { // nothing
  } else if (Array.isArray(data.frames)) {
    // 如果傳遞的是陣列
    this._frames = [];
    a = data.frames;
    for (i=0,l=a.length;i<l;i++) {
      var arr = a[i];
      // 此處幀的 image 需要判斷幀資料內有沒有特別指定 images 的 index,如果沒有指定則預設取 index 0
      this._frames.push({image:this._images[arr[4]?arr[4]:0], rect:new createjs.Rectangle(arr[0],arr[1],arr[2],arr[3]), regX:arr[5]||0, regY:arr[6]||0 });
    }
  } else {
    // 如果傳遞的是物件,意味著傳的是一整張圖(類似css中合併的雪碧圖),需要計算分解出每一幀影象
    o = data.frames;
    this._frameWidth = o.width;
    this._frameHeight = o.height;
    this._regX = o.regX||0;
    this._regY = o.regY||0;
    this._spacing = o.spacing||0;
    this._margin = o.margin||0;
    // 注意這裡傳遞的總幀數,需要這個計算幀
    this._numFrames = o.count;
    if (this._loadCount == 0) { this._calculateFrames(); }
  }

  // 解析動畫屬性
  this._animations = [];
  if ((o=data.animations) != null) {
    this._data = {};
    var name;
    for (name in o) {
      var anim = {name:name};
      var obj = o[name];
      if (typeof obj == "number") { // 單幀
        a = anim.frames = [obj];
      } else if (Array.isArray(obj)) { // 單幀
        if (obj.length == 1) { anim.frames = [obj[0]]; }
        else {
          anim.speed = obj[3];
          anim.next = obj[2];
          a = anim.frames = [];
          for (i=obj[0];i<=obj[1];i++) {
            a.push(i);
          }
        }
      } else { // complex
        anim.speed = obj.speed;
        anim.next = obj.next;
        var frames = obj.frames;
        a = anim.frames = (typeof frames == "number") ? [frames] : frames.slice(0);
      }
      if (anim.next === true || anim.next === undefined) { anim.next = name; } // loop
      if (anim.next === false || (a.length < 2 && anim.next == name)) { anim.next = null; } // stop
      if (!anim.speed) { anim.speed = 1; }
      this._animations.push(name);
      this._data[name] = anim;
    }
  }
};

_calculateFrames 用於從一張 Sprite 圖中自動生成多個幀

// SpriteSheet 類 原始碼 597 - 628 行
p._calculateFrames = function() {
		if (this._frames || this._frameWidth == 0) { return; }

		this._frames = [];

		var maxFrames = this._numFrames || 100000; // if we go over this, something is wrong.
		var frameCount = 0, frameWidth = this._frameWidth, frameHeight = this._frameHeight;
		var spacing = this._spacing, margin = this._margin;
		
		imgLoop:
		for (var i=0, imgs=this._images; i<imgs.length; i++) {
			var img = imgs[i], imgW = (img.width||img.naturalWidth), imgH = (img.height||img.naturalHeight);

			var y = margin;
			while (y <= imgH-margin-frameHeight) {
				var x = margin;
				while (x <= imgW-margin-frameWidth) {
					if (frameCount >= maxFrames) { break imgLoop; }
					frameCount++;
					this._frames.push({
							image: img,
							rect: new createjs.Rectangle(x, y, frameWidth, frameHeight),
							regX: this._regX,
							regY: this._regY
						});
					x += frameWidth+spacing;
				}
				y += frameHeight+spacing;
			}
		}
		this._numFrames = frameCount;
	};

其實就是讀取圖片,根據指定的幀尺寸與 margin、spacing 從左向右,從上到下的掃描圖片

生成每一幀的資訊儲存到 _frames 中,與手動傳的一至

通常我們不會用手指定每一幀,更多的情況是使用圖形工具生成「雪碧」圖就像在 css 中為了解決降低圖片的請求數量,把很多圖合成在一起

Sprite

繼承自顯示物件, 使用範例程式碼如下:

var spriteSheet = new createjs.SpriteSheet({
  framerate: 30,
  "images": ["../_assets/art/spritesheet_grant.png"],
  "frames": {"regX": 82, "height": 292, "count": 64, "regY": 0, "width": 165},
  // define two animations, run (loops, 1.5x speed) and jump (returns to run):
  "animations": {
    "run": [0, 25, "run", 1.5],
    "jump": [26, 63, "run"]
  }
});
var grant = new createjs.Sprite(spriteSheet, "run");
stage.addChild(grant);
createjs.Ticker.addEventListener("tick", stage);

它傳遞的是 SpriteSheet 的範例作為動畫資料

Sprite 的特點是它擁有別於 Tick 的 framerate 控制能力

你可以在 SpriteSheet 或 Sprite 範例中單獨指定 framerate

我們還是從 draw 方法入手:

// Sprite 類 原始碼 224 - 232 行
p.draw = function(ctx, ignoreCache) {
		if (this.DisplayObject_draw(ctx, ignoreCache)) { return true; }
		this._normalizeFrame();
		var o = this.spriteSheet.getFrame(this._currentFrame|0);
		if (!o) { return false; }
		var rect = o.rect;
		if (rect.width && rect.height) { ctx.drawImage(o.image, rect.x, rect.y, rect.width, rect.height, -o.regX, -o.regY, rect.width, rect.height); }
		return true;
	};

可以看到 draw 本身程式碼作用非常簡單清楚

  1. 有過 spriteSheet.getFrame 獲取當前幀
  2. 通過當前幀 的 rect 屬性用 drawImage 繪製到 canvas 上下文當中

draw 方法負責繪製 「當前幀」

_normalizeFrame 方法負責指定「當前幀」具體為動畫組 frames 中的哪一幀

// Sprite 類 原始碼 386-480
p._normalizeFrame = function(frameDelta) {
  frameDelta = frameDelta || 0;
  var animation = this._animation;
  var paused = this.paused;
  var frame = this._currentFrame;
  var l;
  debugger
  if (animation) {
    var speed = animation.speed || 1;
    var animFrame = this.currentAnimationFrame;
    l = animation.frames.length;
    if (animFrame + frameDelta * speed >= l) {
      var next = animation.next;
      // 如果幀播放完畢,則觸發 animationEnd 事件
      if (this._dispatchAnimationEnd(animation, frame, paused, next, l - 1)) {
        // something changed in the event stack, so we shouldn't make any more changes here.
        return;
      } else if (next) {
        // sequence. Automatically calls _normalizeFrame again with the remaining frames.
        return this._goto(next, frameDelta - (l - animFrame) / speed);
      } else {
        // end.
        this.paused = true;
        animFrame = animation.frames.length - 1;
      }
    } else {
      animFrame += frameDelta * speed;
    }
    this.currentAnimationFrame = animFrame;
    this._currentFrame = animation.frames[animFrame | 0]
  } else {
    frame = (this._currentFrame += frameDelta);
    l = this.spriteSheet.getNumFrames();
    if (frame >= l && l > 0) {
      if (!this._dispatchAnimationEnd(animation, frame, paused, l - 1)) {
        // looped.
        if ((this._currentFrame -= l) >= l) { return this._normalizeFrame(); }
      }
    }
  }
  frame = this._currentFrame | 0;
  if (this.currentFrame != frame) {
    this.currentFrame = frame;
    this.dispatchEvent("change");
  }
};

用 examples/SpriteSheet_simple.html 這個例子來調式

注意想要顯示這個例子,必須在伺服器環境下,本地跑個伺服器後用瀏覽器開啟 examples/SpriteSheet_simple.html

_normalizeFrame 內的 this._dispatchAnimationEnd 是一組幀播放完畢後觸發的事件

可以在 SpriteSheet_simple.html 新增以下程式碼測試功能, 比如暫停動畫

grant.on('animationend', function(event){
  event.currentTarget.paused = true;
})

_normalizeFrame 內新增 debugger 斷點偵錯後發現,draw 方法僅繪製 currentFrame

而 Sprite 的 _tick 方法通過與 Tick 類同步 控制 advance 無限迴圈

還記得 evtObj.delta 嗎? 比如設定 createjs.Ticker.framerate = 60; 那麼 evtObj.delta 大約就是 1000/60 = 17

// Sprite 類 原始碼 372-378
p._tick = function(evtObj) {
  if (!this.paused) {
    if (!this._skipAdvance) { this.advance(evtObj&&evtObj.delta); }
    this._skipAdvance = false;
  }
  this.DisplayObject__tick(evtObj);
};

advance 內的 _normalizeFrame 傳遞了 t 值才是控制 currentFrame 變化的關鍵

time/(1000/fps) 即 evtObj.delta / (1000 / fps)

如果沒有 time 那麼 t = 1 則頻率與舞臺的 Tick 頻率保持一至

// Sprite 類 原始碼 304-308
p.advance = function(time) {
  var fps = this.framerate || this.spriteSheet.framerate;
  var t = (fps && time != null) ? time/(1000/fps) : 1;
  this._normalizeFrame(t);
};

還有值得一提的是 _goto 方法,可通過此方法在不同的動畫組之間跳停、跳播等操作

動畫組或幀的獲取通過 spriteSheet 的 getAnimation 實體方法獲取的

等獲取到動畫組或幀後,還是呼叫 _normalizeFrame 進行播放而不是 draw

// Sprite 類 原始碼 461-475
p._goto = function(frameOrAnimation, frame) {
  this.currentAnimationFrame = 0;
  if (isNaN(frameOrAnimation)) {
    var data = this.spriteSheet.getAnimation(frameOrAnimation);
    if (data) {
      this._animation = data;
      this.currentAnimation = frameOrAnimation;
      this._normalizeFrame(frame);
    }
  } else {
    this.currentAnimation = this._animation = null;
    this._currentFrame = frameOrAnimation;
    this._normalizeFrame();
  }
};

Movieclip

Movieclip 繼承自 Container 類,它也是個容器

影片剪輯:Sprite 解決的是幀動畫問題,而 Movieclip 解決的是管理線性動畫的問題

Movieclip 就是將 Timeline 與 TweenJS 結合在一起,這兩個類不在 EaselJS 部分

大致看了一眼,em... 定然是沒有 greensock 的優秀,就不分析了

由於 Movieclip 工作方式設計的與 Sprite 很像,也是呼叫 _tick -> advance() ,在檢視原始碼時發現一個註釋比較有趣

在原始碼 512 行發現了一條註釋:

// Movieclip 類 原始碼 512-513 行
// adjusted by Dan Zen 3/27/21 for https://github.com/CreateJS/EaselJS/issues/1048
if (this.totalFrames <= 1) { return; }

名為 Dan Zen 的人提交了這個修改,作者把它註釋在此處,而我知道 Dan Zen 這個人,它是 zim 的作者

zim 又是以 createjs 基礎封裝的庫,功能很強大,教學也很多

zim 的宣傳語 ZIM - JavaScript Canvas Framework - Code Creativity! 它非常值得探索

DOMElement

可以將普通 DOM 元素當作 EaselJS 的元素融合在 canvas 中使用

DOMElement 原始碼非常簡單

EaselJS 會將 DOMElement 強制為 position:absolute 絕對定位,以便 DOM 位置與 canvas 內的座標同步

使用例子 examples/DOMElement.html 為切入點分析

使用的主要 JS 如下:

// 新建 DOMElement 
var content = new createjs.DOMElement("foo");
content.regX = 140;
content.regY = 140;
// 新增進 container 
container.addChild(content);

createjs.Ticker.timingMode = createjs.Ticker.RAF;
createjs.Ticker.addEventListener("tick", tick);

function tick(event) {
  // 對 container 進行屬性變幻
  container.rotation += event.delta * 0.01;
  container.alpha = 0.5 * (1 + Math.cos(container.rotation * 0.01));
  stage.update(event);
}

使用 new createjs.DOMElement("foo") 新建了一個 DOMElement類範例

DOMElement 原始檔在 src/easeljs/display/DOMElement.js

建構函式 DOMElement(htmlElement) 傳入 'foo'

// DOMElement 原始碼 81-83 行
var style = htmlElement.style;
		style.position = "absolute";
		style.transformOrigin = style.WebkitTransformOrigin = style.msTransformOrigin = style.MozTransformOrigin = style.OTransformOrigin = "0% 0%";

將 DOM 強制變為絕對定位,且 transformOrigin 歸位到元素左上角

DOMElement 的 draw 方法僅是一個直接返回 true 的方法沒做任何事,因為 DOM 不需要 canvas 去繪製

cache 相關功能也無法使用,用空的 function 覆寫了

DOMElement 互動事件也與普通的 EaselJS 顯示物件有別,原始碼的註釋中也可以看到,作者提示了

Interaction events should be added to `htmlElement`, and not the DOMElement instance, since DOMElement instances
are not full EaselJS display objects and do not participate in EaselJS mouse events

大意是點選之類的互動事件需要直接在 DOM 上新增,而不是 EaselJS 內的一套

DOMElement 實現了 _tick 方法

// DOMElement 原始碼 249-257 行
p._tick = function(evtObj) {
  var stage = this.stage;
  if(stage && stage !== this._oldStage) {
    this._drawAction && stage.off("drawend", this._drawAction);
    this._drawAction = stage.on("drawend", this._handleDrawEnd, this);
    this._oldStage = stage;
  }
  this.DisplayObject__tick(evtObj);
};

當 stage 為新的時,監聽了 stage 的發出的 drawend 事件

drawend 是在 stage update = function(props){} 內最後一行發出的,意味著是繪製更新完成後才輪到 DOMElement 處理更新

真正處理更新事件的是 _handleDrawEnd

// DOMElement 原始碼 264-290 行
p._handleDrawEnd = function(evt) {
  var o = this.htmlElement;
  if (!o) { return; }
  var style = o.style;
  
  var props = this.getConcatenatedDisplayProps(this._props), mtx = props.matrix;
  
  var visibility = props.visible ? "visible" : "hidden";
  if (visibility != style.visibility) { style.visibility = visibility; }
  if (!props.visible) { return; }
  
  var oldProps = this._oldProps, oldMtx = oldProps&&oldProps.matrix;
  var n = 10000; // precision
  
  if (!oldMtx || !oldMtx.equals(mtx)) {
    var str = "matrix(" + (mtx.a*n|0)/n +","+ (mtx.b*n|0)/n +","+ (mtx.c*n|0)/n +","+ (mtx.d*n|0)/n +","+ (mtx.tx+0.5|0);
    style.transform = style.WebkitTransform = style.OTransform = style.msTransform = str +","+ (mtx.ty+0.5|0) +")";
    style.MozTransform = str +"px,"+ (mtx.ty+0.5|0) +"px)";
    if (!oldProps) { oldProps = this._oldProps = new createjs.DisplayProps(true, null); }
    oldProps.matrix.copy(mtx);
  }
  
  if (oldProps.alpha != props.alpha) {
    style.opacity = ""+(props.alpha*n|0)/n;
    oldProps.alpha = props.alpha;
  }
};

總共分四步處理:

  1. 利用繼承顯示物件上的 getConcatenatedDisplayProps 方法得到 「顯示屬性物件」 顯示相關的屬性都在內,還有 Matrix

  2. visible 屬性轉變成 css 能處理的 visibility

  3. 如果沒有舊的 matrix "oldMtx" 或 舊的 matrix 與 當前 matrix 不相等, 就代表需要更新 DOM 樣式了,用新的 matrix "mtx", 更新後複製一份到 oldMtrix 用於下一次比較提高效能

  4. alpha 變成 css 的 opacity 屬性,也有新舊對比

旋轉,縮放,位置變更都還是由 css transform 的 matrix 實現的

這倒與 EaselJS 在 canvas 內的用 matrix 統一到了一起

BitmapText

有了它你可以隨意建立自己的字形庫,即使中文也可以

點陣圖文字,即文字是點陣圖形式表示的,用來展示圖形文字。

演示範例在 src/examples/BitmapText.html

var data = {
    "animations": {
      "V": {"frames": [21]},
      "A": {"frames": [0]},
      ",": {"frames": [26]},
      "W": {"frames": [22]},
     ...
    },
    "images": ["../_assets/art/spritesheet_font.png"],
    "frames": [
      [155, 2, 25, 41, 0, -10, -3],
      [72, 2, 28, 43, 0, -8, -1],
      [599, 2, 28, 38, 0, -8, -4],
      ...
    ]
  };
}

var ss = new createjs.SpriteSheet(data);

var text = new createjs.BitmapText("Hello World,\nWhat is Happening?", ss);

建構函式接受 一個文字資訊和 一個 SpriteSheet

SpriteSheet 傳遞的 data 包含圖片資訊以及對應的幀資訊

注意 data 的 animations 與 frames 是後面獲取具體影象幀的關鍵

結果是這樣的

這是一個有趣的類,它結合了 Bitmap 與 Text 而它又繼承自 Container 類

從原始碼中可以看到它雖然繼承自 Container 類,但不支援 addChild 等一類的操作

而 BitmapText 最主要的是 _updateText 方法

// BitmapText 類 原始碼 292-346 行
p._updateText = function() {
  var x=0, y=0, o=this._oldProps, change=false, spaceW=this.spaceWidth, lineH=this.lineHeight, ss=this.spriteSheet;
  var pool=BitmapText._spritePool, kids=this.children, childIndex=0, numKids=kids.length, sprite;
  // 判斷是否有變
  for (var n in o) {
    if (o[n] != this[n]) {
      o[n] = this[n];
      change = true;
    }
  }
  // 沒有任何變化直接返回不進行任何操作
  if (!change) { return; }
  
  // 獲取空格寬度,行高
  var hasSpace = !!this._getFrame(" ", ss);
  if (!hasSpace && !spaceW) { spaceW = this._getSpaceWidth(ss); }
  if (!lineH) { lineH = this._getLineHeight(ss); }
  // 迴圈文字
  for(var i=0, l=this.text.length; i<l; i++) {
    var character = this.text.charAt(i);
    // 計算 x , y 文字相對座標值
    if (character == " " && !hasSpace) {
      x += spaceW;
      continue;
    } else if (character=="\n" || character=="\r") {
      if (character=="\r" && this.text.charAt(i+1) == "\n") { i++; } // crlf
      x = 0;
      y += lineH;
      continue;
    }

    // 獲取單個文字對應幀的 index 值 _getFrameIndex 呼叫的是 SpriteSheet 的 getAnimation 
    var index = this._getFrameIndex(character, ss);
    if (index == null) { continue; }
    
    if (childIndex < numKids) {
      sprite = kids[childIndex];
    } else {
      // 將 sprite 放入 kids 即 children 陣列
      kids.push(sprite = pool.length ? pool.pop() : new createjs.Sprite());
      sprite.parent = this;
      numKids++;
    }
    // 停在 sprite 的第 0 幀
    sprite.spriteSheet = ss;
    sprite.gotoAndStop(index);
    sprite.x = x;
    sprite.y = y;
    childIndex++;
    // 加上一個文字實際的 x 偏移
    x += sprite.getBounds().width + this.letterSpacing;
  }
  // 儲存進 pool 陣列複用提高效能
  while (numKids > childIndex) {
      // faster than removeChild.
    pool.push(sprite = kids.pop());
    sprite.parent = null;
    numKids--;
  }
  if (pool.length > BitmapText.maxPoolSize) { pool.length = BitmapText.maxPoolSize; }
};

大致步驟:

  1. 用一個 for 迴圈判斷 o 屬性(o 即 _oldProps {text:0,spriteSheet:0,lineHeight:0,letterSpacing:0,spaceWidth:0}),是否有變, 有變說明需要在 canvas 更新

  2. for 迴圈整個 text 文字

  3. 提取單個文字,用 _getFrameIndex 獲取文字在 spritesheet 中的 index

  4. 如果 kids 陣列中已經有對應的 sprite 就直接複用比如 text 傳的是 'aaaaaa',從第 2 個 a 開始就可以複用了

  5. 如果還沒有生成過 sprite, 則從 pool 中找,還是沒有則直接 new createjs.Sprite() 生成一個全新的 sprite

  6. 指定 sprite.spriteSheet = ss; 得到 data

  7. 讓 sprite 停在第 index 幀,即畫面顯示在了 index 幀

  8. 將 sprite 存進 pool 快取起來複用

注意 _getFrameIndex 方法

// BitmapText 類 原始碼 245-252 行
p._getFrameIndex = function(character, spriteSheet) {
  var c, o = spriteSheet.getAnimation(character);
  if (!o) {
    (character != (c = character.toUpperCase())) || (character != (c = character.toLowerCase())) || (c=null);
    if (c) { o = spriteSheet.getAnimation(c); }
  }
  return o && o.frames[0];
};

它忽略大小寫

getAnimation 獲取 比如 'A' 對應的就是 data.animations 物件 key 為 'A' 的值 {"A": {"frames": [0]}}

從而得到 data.frames 對應就的 index 就是 0 , 即 sprite.gotoAndStop(0); 顯示的就是 A 對應的 bitmap A

小結

比較重要的是 SpriteSheet 和 Sprite ,以這兩個為基礎又擴充套件出了 BitmapText 類

而 Movieclip 是基於緩動函數與 Timeline 時間軸的

Sprite 與 Movieclip 功能合在一起就覆蓋了幀動畫與線性動畫功能

下一篇正式進入滑鼠互動事件原始碼分析


部落格園: http://cnblogs.com/willian/
github: https://github.com/willian12345/