2020年8月20日 星期四

從 blogger 轉換到靜態網頁生成

看到這個標題大概就知道發生什麼事了……。
是的,經過十年 blogger 寫作,我終於決定要搬家了。

故事是這樣子的,自從 2011 年起我就持續的有在寫 blog,那時候選的是 google 的 blogger 服務,剛看一下到目前為止總計 246 篇文章,算上這篇搬家文的話是 247(剛好是 13 * 19 ,可以當作 RSA 公鑰),大部分都是技術相關,也有部分是生活雜記跟書評等等。
總括來講 blogger 並不是不好用,相反的 blogger 提供可以用瀏覽器編輯內容、圖片上傳後自動歸檔到近乎無限的 google 雲端(這另一方面也是一個問題,刪除誤上傳的照片很麻煩)、google 在背後保證了網站的可及性和穩定度、手機與電腦都支援的瀏覽介面,如果你對網誌內容沒有什麼太嚴謹的要求,只是想要記錄一下生活貼貼遊記照片,blogger 已經大概有 80 分吧。
特別是不要以今非古,以那個年代的網路環境,找免費空間架站還不是那麼容易的事,託管在 google/blogger 顯然是簡單很多的解法。

blogger 的缺點,最後逼得個人跳槽的最大原因,我認為是:程式碼與凌亂的文章格式。
畢竟個人的 blog 從開始的定位就不是簡單記錄生活就算了,我非程式相關的文章大概 30 篇左右,程式相關內容佔了 80 % 以上,而程式碼是 blogger 最弱的一環,目前我寫作 blogger 的工作流程通常是這樣子:
  1. 在 google doc 上面完成,我一個文件叫 write anything ,想寫什麼就全部往裡面倒。
  2. 整理成文章,把文章內容複製到 vim。
  3. 透過之前寫好的 blogger.vim 將文章內容要劃重點、程式碼的部分加上 tag。
  4. 用 blogger html 編輯器,直接將全篇文章貼入。
  5. 在 blogger 一般編輯器,幫文章加上連結、圖片等。
  6. 來回檢視預覽和編輯器,修正所有顯示不如預期的地方。
是的,我的文章從來不用 blogger 的編輯器,都是全部生好 html 再整個貼進去,程式碼的部分利用 pretty-print 套件(剛剛看到它 archive 了…)為 <pre> 上色,或是 pretty-print 失效時加上 <div> 標籤加框,幾乎每次編輯 blog 都一定要去改動 html,搞得寫程式相關文章變得很痛苦。

第二點也是我為何都用 html 編輯器的原因,透過 blogger 編輯器產生的內容格式非常不穩且難以維護,生出來的 html 簡直嘔吐物,很難再去手動修改 html ,但如上所述在寫文的時候去改動 html 幾乎是必要的,只能用編輯器的功能把所有格式都清除掉,那還不如一開始就寫 html 就好。

特別是這幾年開始寫 rust 相關文章之後,上述問題整個變本加厲,因為 rust 特有的生命周期語法,會讓 pretty-print 的上色功能大噴射(它會以為 lifetime 'a 是字串開頭,其實根本不是…),每寫一篇 rust 的文章想跳槽的意念就深一層,最近的 amethyst 系列真的是壓垮 yodalee 的最後一根稻草,系列文完成前就決定跳槽,想要看完 amethyst 系列文的朋友只好說聲對不起了。

這次跳槽的目標是轉換為 static site generator,其實這幾年來陸續都有朋友推薦這種寫作方式,但礙於懶得搬移舊文章一直沒有動手,推薦的工具也從早年的 Ruby/Jekyll 到最近改推 Go/Hugo 了;本來我是想說身為 rust 使用者,應該選擇 Rust/Zola(hail hydra…欸),只是後來發現 zola 的 theme 實在太少了,還是先用 hugo 撐撐場面。

廢話不多說,讓我們來開始著手搬家吧。

2020年8月9日 星期日

使用 Amethyst Engine 實作小行星遊戲 - 9 UI

現在讓我們來建 UI,我覺得建 UI 是目前掌握度比較低的部分,我也在想怎麼做才是對的。
目標是加上一個計分用的文字:
首先是 resource 的部分,建立 FontRes 來保存載入的字型檔,要建立文字的時候只要取用這個資源即可:
pub struct FontRes {
  pub font : FontHandle
}

impl FontRes {
  pub fn initialize(world: &mut World) {
    let font = world.read_resource::<Loader>().load(
      "font/square.ttf",
      TtfFormat,
      (),
      &world.read_resource(),
    );
    world.insert(
      FontRes { font : font }
    );
  }
  pub fn font(&self) -> FontHandle {
    self.font.clone()
  }
}

這部分跟載入 sprite 沒有差很多,就不贅述;另外我們再實作一個 ScoreRes,用來儲存目前的分數跟儲存 UI 文字的 Entity。
pub struct ScoreRes {
  pub score: i32,
  pub text: Entity,
}

impl ScoreRes {
  pub fn initialize(world: &mut World) {
    let font = world.read_resource::<FontRes>().font();
    let score_transform = UiTransform::new(
      "score".to_string(), Anchor::TopRight, Anchor::MiddleLeft,
      -220., -60., 1., 200., 50.);
    let text = world
      .create_entity()
      .with(score_transform)
      .with(UiText::new(font, "0".to_string(), [0.,0.,0.,1.], 50.))
      .build();

    world.insert(ScoreRes {
      score: 0,
      text: text
    });
  }
}

簡單來說 UI 的文字,自然也是一個 entity,裡面有兩個 components 分別是位移 UiTransform 跟 UiText;UiTransform 會需要幾個參數:
  • id :幫助辨識是哪個 Ui 元件。
  • anchor、pivot:Ui 元素位在 parent 的哪個方位、位在自己的哪個方位,可以用九宮格的方式來指定。
  • 後面五個數字則是指定 x, y, z, width, height。
這裡我們把顯示擊落數的文字定在畫面右上角,x y 的位移值剛好補償它的寬度跟高度。
UiText 需要帶入剛剛讀進來的字型,後面指定文字內容、顏色跟尺寸。

要修改文字的話,我們稍微修改一下先前實作的 Collision System,要新增存取 ScoreRes 這個 resource,另外記得上面所說 UI 文字也是 entity,文字的資訊是保存在 UiText 這個 Component 裡面,所以要改文字,我們一併要存取 UiText 這個 Component:
type SystemData = (
  WriteExpect<'s, ScoreRes>
  WriteStorage<'s, UiText>
)
fn run(&mut self,
          (mut scoretexts,
           mut uitext): Self::SystemData) {
  // change score
  scoretexts.score = scoretexts.score + 1;
  if let Some(text) = uitext.get_mut(scoretexts.text) {
    text.text = scoretexts.score.to_string()
  }
}
先直接修改 resource 裡面儲存的 score 的值,再來是用 Component uitext 去取出 resource 裡記錄的 text entity,這樣拿出來的就是這個 entity 所含的 UiText Component,這時候才能去修改它的 text 屬性。
上面這段 code 我放在處理碰撞的地方,只要有雷射砲跟小行星碰撞就會執行一次,真的要分得非常詳細,可以把這段移到獨立的系統中,比較不會亂掉。

你可能會問,這樣不就…讓 ScoreRes 這個 resource 的實作內容給暴露出來了,不能把 UiText 跟 score 等等的好好好封裝到一個 struct 裡面,並公開介面如 setText 讓外部使用嗎?沒錯當初我也是想要這樣設計,只不過到目前為止都沒有成功過 (._.),目前只能將就一下用這樣難看的寫法,畢竟連官方的範例都是這樣教……如果有大大知道的話也請不吝賜教。

2020年7月10日 星期五

使用 Amethyst Engine 實作小行星遊戲 - 8 使用 ncollide2d 實作碰撞

碰撞偵測應該也是許多遊戲內必要的元素之一,比如說我們的打小行星遊戲,就需要偵測雷射砲跟小行星的碰撞,以及小行星和太空船的碰撞。
簡單一點的土砲法,是用 for loop 把小行星跟電射砲的座標收集起來,太近的兩者把 entity 刪掉就行了;但我們畢竟身為專業的遊戲設計(才怪),用土砲法就太遜了,這裡我們用同樣是 rust 寫的 ncollide2d 套件來實作碰撞偵測。

因為 amethyst 內部包了一層 nalgebra 的關係,我們的 ncollide2d 用的版本必須是 0.21 版,我覺得這個問題挺…麻煩的,要自己去搜 amethyst 相依的 nalgebra 到底是哪個版本,然後對應回 ncollide2d 對應的版本。
為了識別每一個物件的屬性,我們在各個 entity 上面都加上一個新的 component:Collider,內含一個 enum 屬性;在每個 entity 上面都要附上這個 component 作為識別。
pub struct Collider {
  pub typ: ColliderType
}

pub enum ColliderType {
  Ship,
  Bullet,
  Asteroid,
}
從我們之前的教學文學到的 rule of thumb:要改變行為,就是加一個系統。
use ncollide2d::{
  bounding_volume,
  broad_phase::{DBVTBroadPhase, BroadPhase, BroadPhaseInterferenceHandler}
};
引入 ncollide2d 相關的模組。

#[derive(SystemDesc)]
pub struct CollisionSystem;

impl<'s> System<'s> for CollisionSystem {
  type SystemData = (
    Entities<'s>,
    ReadStorage<'s, Collider>,
    ReadStorage<'s, Ship>,
    ReadStorage<'s, Transform>,
  );
這段就只是宣告一下 system,因為有生命周期上色很麻煩所以獨立出一段來。
fn run(&mut self,
           (entities, colliders, ships, transforms): Self::SystemData) {

  // collect collider
  let mut broad_phase = DBVTBroadPhase::new(0f32);
  let mut handler = BulletAsteroidHandler::new();
  for (e, collider, _, transform) in (&entities, &colliders, !&ships, &transforms).join()  {
    let pos = transform.translation();
    let pos = Isometry2::new(Vector2::new(pos.x, pos.y), zero());
    let vol = bounding_volume::bounding_sphere( &Ball::new(5.0), &pos );
    broad_phase.create_proxy(vol, (collider.typ, e));
  }
  broad_phase.update(&mut handler);
  
新增一個 CollisionSystem,要動到有 Collider component 的 entity,讀位置需要 Transform。
上面顯示了 ncollide2d 提供的 DBVTBroadPhase 的碰撞偵測,它會把輸入的資料依照空間位置分到樹狀的結構上,更有效率的去偵測碰撞。
用 for loop 把所有非 ship 的 collider 取出來,從 transform 建立物體位置,再建立 ncollide2d 提供圓形 bounding_volume,這是 DBVTBroadPhase 偵測用的 key。
我們用 (ColliderType, Entity) 作為 value,ColliderType 是用來識別碰撞的物體型別,我們只希望偵測子彈跟小行星的碰撞,忽略其他像小行星自己的碰撞;Entity 則是在碰撞發生的時候,能夠追溯到是哪個 entity 發生碰撞。
最後只要呼叫 DBVTBroadPhase 的 update 並代入實作 BroadPhaseInterferenceHandler 的 struct 即可。下面就來實作 handler:
struct BulletAsteroidHandler {
  collide_entity: Vec<Entity>,
}

impl BulletAsteroidHandler {
  pub fn new() -> Self {
    Self {
      collide_entity: vec![],
    }
  }
}

type ColliderEntity = (ColliderType, Entity);
定義一下 type alias 寫起來比較方便:
impl BroadPhaseInterferenceHandler<ColliderEntity> for BulletAsteroidHandler {
  fn is_interference_allowed(&mut self, a: &ColliderEntity, b: &ColliderEntity) -> bool {
    a.0 != b.0
  }
  fn interference_started(&mut self, a: &ColliderEntity, b: &ColliderEntity) {
    self.collide_entity.push(a.1);
    self.collide_entity.push(b.1);
  }
  fn interference_stopped(&mut self, _a: &ColliderEntity, _b: &ColliderEntity) {}
}
is_interference_allowed 用來判斷這兩個物體能不能發生碰撞,這裡要求它們的 ColliderType 要不一樣;interference_started 則是碰撞發生時的處理,把兩個 entity 存起來;interference_stopped 處理碰撞結束的行為,留空就好

上面呼叫完 broad_phase.update(&mut handler) 之後,從 handler.collide_entity 就能拿到碰撞的 bullet 跟 asteroid,直接刪掉 entity 即可。
for e in handler.collide_entity {
  if let Err(e) = entities.delete(e) {
    error!("Failed to destroy collide entity: {}", e)
  }
}
這篇其實非常偷懶了,目前至少有下面兩點可以改進:
  • 不用在 system 裡面產生新的 DBVTBroadPhase 並重新插入 proxy,應該把碰撞偵測當成一個 resource,每次只要更新 DBVTBroadPhase 內記錄的位置,速度應該會比我們這樣從頭打造一個快。
  • 將 bounding_volume 存在 Collider 裡面,而不是每個東西都是單一尺寸的圓形,這樣也不用每次都重新生成新的 bounding_volume,從 Collider 裡面拿就可以了。
不過在我們這個小遊戲上還不需要在意這個,做完這一大步,現在遊戲已經有個可以玩的樣子了……雖然我立刻發現它難度有點太高了,我通常撐不到十秒,放不出 C8763,不過這只需要我們動一些參數,最後再來調整就好了。

2020年7月5日 星期日

使用 Amethyst Engine 實作小行星遊戲 - 7 亂數

亂數在遊戲中也是個舉足輕重的腳角,少了亂數的遊戲就像沒加珍珠的奶茶(?,讓玩家食之無味;這章我們會加上亂數,以及產生小行星的 system。

亂數直接用 rust 官方的 rand 模組,為了把它嵌入 ECS 裡面,可以觀察一下亂數模組會有什麼特性:大家使用一個亂數模組而不是大家都有一份,符合這個特性的就是 resource 了。
在 resources.rs 加上一個新的 struct:
pub struct RandomGen;

impl RandomGen {
  pub fn next_f32(&self) -> f32 {
    use rand::Rng;
    rand::thread_rng().gen::<f32>()
  }
}
它把 rand 模組給包起來,在內部呼叫 thread_rng().gen 產生 f32 的亂數,在載入資源的時候我們一併插入這個 resource,AsteroidRes 請仿造 ShipRes 跟 BulletRes 寫一份,這裡應該可以再把三個資源共用的部分抽出來,不過因為是範例 project 我就沒這麼做:
ShipRes::initialize(world);
BulletRes::initialize(world);
AsteroidRes::initialize(world);
world.insert(RandomGen);
ECS 的道理,要加上新的行為就是寫一個新的系統,我們把和生成小行星有關的設定都放在這個系統裡面(雷射槍的冷卻時間和太空船是綁在一起的,因此放在 Ship component 裡面):
#[derive(SystemDesc)]
pub struct SpawnAsteroidSystem {
  pub time_to_spawn: f32,
  pub max_velocity: f32,
  pub max_rotation: f32,
  pub distance_to_ship: f32,
  pub average_spawn_time: f32,
}
實作上,entities、LazyUpdate 是產生新物件必備;Ship、Transform 用來取得船的位置,免得小行星直接出現在太空船的旁邊;AsteroidRes、RandomGen 則是需要的資源:
impl<'s> System<'s> for SpawnAsteroidSystem {
  type SystemData = (
    Entities<'s>,
    ReadStorage<'s, Ship>,
    ReadStorage<'s, Transform>,
    ReadExpect<'s, AsteroidRes>,
    ReadExpect<'s, RandomGen>,
    Read<'s, LazyUpdate>,
    Read<'s, Time>,
  );
一開始就跟 ShipControlSystem 一樣,從 time.delta_seconds 取得秒數,去扣掉 system 的 time_to_spawn,一但低於零就會生成一顆小行星,並將 time_to_spawn 設定回 average_spawn_time。
let mut create_point: Vector3<f32> = zero();
// generate creation point
loop {
  create_point.x = rand.next_f32() * ARENA_WIDTH;
  create_point.y = rand.next_f32() * ARENA_HEIGHT;
  if (ship_translation-create_point).norm() > self.distance_to_ship {
    break;
  }
}
transform.set_translation_x(create_point.x);
transform.set_translation_y(create_point.y);
生成點的位置我用了偷懶的方式,一般在 system 內最好不要有這種執行時間不確定的東西,如果一不小心 distance_to_ship 設太大,可能會讓 system 進到無窮迴圈把整個遊戲卡住,比較好的做法應該是從 distance_to_ship,亂數產生距離跟方位角就可以了。
let e = entities.create();
lazy.insert(e, Asteroid {} );
lazy.insert(e, transform);
lazy.insert(e, physical);
lazy.insert(e, asteroidres.sprite_render());
所有的 component 都準備好之後,一樣透過 LazyUpdate 把 component 塞進 entity 裡面即可,現在我們的遊戲應該有個樣子,平均每兩秒生成一顆小行星,按空白可以射出雷射。
我們還沒實作碰撞,所以自然還沒有任何打爆小行星的行為,雷射打到小行星也只是無視的飛過去,下一章我們就進到遊戲除了亂數之外另一個必備成員:碰撞。

2020年7月3日 星期五

使用 Amethyst Engine 實作小行星遊戲 - 6 刪除物體

一般來說這種射小行星的遊戲,都會有一個莫名的設定,那就是太空船和小行星來到邊界的時候,會從螢幕的對面出現,就好像畫面是一個攤平的球體一樣。
估且不論這個設定合不合理,我們就來實作一下:

託 amethyst 之福,所謂實作就是:加一個新的 system,這裡叫它 BoundarySystem:
#[derive(SystemDesc)]
pub struct BoundarySystem;

impl<'s> System<'s> for BoundarySystem {
  type SystemData = (
    WriteStorage<'s, Transform>,
    ReadStorage<'s, Physical>,
    ReadStorage<'s, Bullet>,
    Entities<'s>,
  );
  
這個 system 要改動的是所有含 Physical Component 的 entity 的 Transform 屬性,設定 Transform 為 Write;要刪除 entity 因此需要 Entities。
for (_, _, transform) in (&physicals, !&bullets, &mut transforms).join() {
  let obj_x = transform.translation().x;
  let obj_y = transform.translation().y;
  if obj_x < 0.0 {
    transform.set_translation_x(ARENA_WIDTH-0.5);
  } else if obj_x > ARENA_WIDTH {
    transform.set_translation_x(0.5);
  }
 
  if obj_y < 0.0 {
    transform.set_translation_y(ARENA_HEIGHT-0.5);
  } else if obj_y > ARENA_HEIGHT {
    transform.set_translation_y(0.5);
  }
}
之所以要引入 bullets,是因為我們希望把含有 bullet component 一出畫面就直接消失,需要分出來特別對待。在 for loop join 的地方,可以列出一排 component,取出「包含所有列出 component 的 entity」,也可以用 Logical negation operator !,取出「不包含這個 component 的 entity」,就可以把 bullet 給排除在外。
for (e, _, transform) in (&*entities, &bullets, &mut transforms).join() {
  let x = transform.translation().x;
  let y = transform.translation().y;
  if x < 0.0 || y < 0.0 || x > ARENA_WIDTH || y > ARENA_HEIGHT {
    if let Err(e) = entities.delete(e) {
      error!("Failed to destroy entity: {}", e)
    }
    continue;
  }
}
另外一個迴圈處理 bullet,這次我們用 &*entities 拿到對應的 entity,在超出螢幕範圍的時候,呼叫 entities.delete(e) 把 entity 給刪除。

其實刪除 entity 就是如此簡單,單獨成一章實在有點不平衡XD。