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。

2020年7月1日 星期三

使用 Amethyst Engine 實作小行星遊戲 - 5 生成物體

其實這章才是真的讓這堆教學跟官方 pong 不一樣的地方,在遊戲內生成 entity;官方的 pong 就是生出兩塊板子一顆球,球跑到場外就計分然後把球放回場中間,完全不會新增/刪除 entity。

在這之前我們先做一點 refactor,之前我們在 states.rs 裡寫了一個函式:load_sprite_sheet 用來載入 sprite 資源,但這個資源沒向 world 註冊,在 system 裡面會無法使用。
先來改善這點,把 Handle<SpriteSheet> 包進 struct,放到另一個 textures.rs 檔案裡:
pub struct SpriteStore {
  handle: Handle<SpriteSheet>
}
這個 struct 提供兩個函式:
  • from_path:利用 load_sprite_sheet 取得 Handle<SpriteSheet>,建構 SpriteStore
  • sprite_render:給定一個 frame id ,從 handle 建構出 SpriteRender。
另外一個新檔案則是 resources.rs:
pub struct ShipRes {
  pub sprite_store: SpriteStore,
}

impl ShipRes {
  pub fn initialize(world: &mut World) {
    let sprite_store = SpriteStore::from_path(world, "ship");
    world.insert(
      ShipRes { sprite_store : sprite_store }
    );
  }

  pub fn sprite_render(&self) -> SpriteRender {
    self.sprite_store.sprite_render(0)
  }
}
用一個 struct 再把 sprite_store 包起來,我在名字後綴 Res 表示 Resource,用來跟 component 的 ship 做區隔,不然到處都是 Ship 很麻煩。
這個 struct initialize 拿到 SpriteStore 後,會呼叫 world.insert() 把自己這個資源註冊到 world 裡面,使用同樣的結構,我們可以另外生成資源 BulletRes 用來生成子彈。
記得在產生 game data 的時候,呼叫資源 struct 的 initialize 函式。

現在我們可以擴充我們的 ShipControlSystem 了:
impl<'s> System<'s> for ShipControlSystem {
  type SystemData = (
    WriteStorage<'s, Physical>,
    WriteStorage<'s, Ship>,
    ReadStorage<'s, Transform>,
    ReadExpect<'s, BulletRes>,
    Entities<'s>,
    Read<'s, LazyUpdate>,
    Read<'s, InputHandler::<StringBindings>>,
    Read<'s, Time>,
  );
擴充後的 SystemData 大幅增加取用的類別:
  • BulletRes 是 Resource,因為沒有 default 我們使用 ReadExpect 來讀取。
  • Entities<'s> 要新增/刪除 entity 必要的
  • LazyUpdate 是 amethyst 提供的一個方式,如果一條更新會動到很多的資源,可以先用 LazyUpdate 的方式記下來之後一次處理。
  • Time 跟 Ship 的寫入權限是要處理冷卻時間用的。
下面是在 ShipControlSystem 和射出子彈有關的原始碼:
fn run(&mut self,
          (mut physicals,
           mut ships,
           transforms,
           bullet_resources,
           entities,
           lazy,
           input,
           time): Self::SystemData) {
  for (physical, ship, transform) in (&mut physicals, &mut ships, &transforms).join() {
    let shoot = input.action_is_down("shoot").unwrap_or(false);
    // handle shoot
    if ship.reload_timer <= 0.0f32 {
      if shoot {
        ship.reload_timer = ship.time_to_reload;

        let bullet_transform = transform.clone();
        let velocity = transform.rotation() * Vector3::y() * 150f32;
        let velocity = physical.velocity + Vector2::new(velocity.x, velocity.y);
        let bullet_physical = Physical {
          velocity: velocity,
          max_velocity: 200f32,
          rotation: 0f32,
        };

        let e = entities.create();
        lazy.insert(e, Bullet {} );
        lazy.insert(e, bullet_transform);
        lazy.insert(e, bullet_physical);
        lazy.insert(e, bullet_resources.sprite_render());
      }
    } else {
      ship.reload_timer = (ship.reload_timer - delta).max(0.0f32);
    }
  }
}
首先,在遊戲裡面會重複的只有 component,所以需要用 for loop 配 join 解開的也只有用 ReadStorage/WriteStorage 拿進來的 component,其他都不用。
接著我們會去檢查 ship component 儲存的 reload_timer,依照 Time delta 減少,變為 0 就可以射擊,一但射擊了就會把 reload_timer 加一個冷卻時間。
後面會用 ship 的 transform 算出子彈速度,產生子彈用的 physical component。
產生 entity 其實很簡單,呼叫 entities.create() ,再用 LazyUpdate insert,往這個 entity 裡面塞 component。
bullet_transform 跟 physical 都是我們剛產生的;resource 一定要先註冊過之後,才能像這樣用 ReadExpect 拿出來用,我們直接呼叫 sprite_render 函式,拿到可顯示的 SpriteRender component,一樣塞進去就行了。

用 system 產生 entity 就介紹到這邊,其實沒有很難;大家應該更能感受到 ECS 系統的精神,Entity - 我們在螢幕上面看到的船 - 其實就是個空殼。
  • 它為什麼可以顯示東西?我們幫他加上一個 SpriteRender component。
  • 它為什麼有速度可以旋轉?它有 physical component。
  • 要變化位置?加上 transform component ,讀取 physical 來更新它。
  • 要設定加速度的值?把加速度存在 ship component 裡。
沒錯,所有的東西都是 component 達成的,entity 一點用也沒有,隨之而來的就是彈性,還要加上碰撞?在需要碰撞的三個東西:船、小行星、雷射加上一個新的 Collider component ,再寫新的系統處理它就可以了,舊有的程式碼完全不需要改動,這大概是 ECS 系統帶來的最大好處了。

2020年6月29日 星期一

使用 Amethyst Engine 實作小行星遊戲 - 4 移動物體

上一章連接輸入的部分,我們完成了第一個 system 的設計,當然這個系統什麼事都沒做,這章我們就來把輸入接到真正的變化上。

一開始讓我們在 component.rs 裡面新增一個 component physical:
use amethyst::{
  core::math::Vector2,
};

pub struct Physical {
  // velocity, [vx, vy]
  pub velocity: Vector2<f32>,
  // maximum velocity (units / s)
  pub max_velocity: f32,
  // rotation (rad / s)
  pub rotation: f32,
}

impl Component for Physical {
  type Storage = DenseVecStorage<Self>;
}

直覺上來想,可能會覺得我們之前產生的 Ship component 裡面應該要有 velocity 屬性,system 會讀取 velocity 去更新太空船的位置 - 也就是 transform。
但是不對,因為畫面上會動的東西不止有 Ship,之後會新增的子彈跟小行星都是會動的,而實作<會動>都是共同的,寫在 Ship 內的性質無法共用,所以我們直接新增一個 physical component 記錄速度和旋轉量,想要動的東西只要加上 physical component 就行了,程式碼共用的部分可以很漂亮的抽出來。
這裡我們直接使用 nalgebra 的 vector2 來代表速度,amethyst 裡面有包一包 nalgebra 並且重命名為 core::math,版本是 0.19.0,這個在我們後面引入 ncollide2d 的時候會帶來麻煩,不過這裡就先用吧。

state.rs 裡面,在 initialize_ship 函式幫產生的 entity 加上 Physical component:
use crate::components::{Ship, Physical};

world
  .create_entity()
  .with(transform)
  .with(sprite_render.clone())
  .with(Ship::new())
  .with(Physical {
    velocity: zero(),
    max_velocity: 10.0,
    rotation: 0.0
  })
  .build();
現在來修改 system,先新增一個操作 Physical component 的 system:
#[derive(SystemDesc)]
pub struct PhysicalSystem;

impl<'s> System<'s> for PhysicalSystem {
  type SystemData = (
    ReadStorage<'s, Physical>,
    WriteStorage<'s, Transform>,
    Read<'s, Time>,
  );

  fn run(&mut self,
    (physicals,
     mut transforms,
     time): Self::SystemData) {
    let delta = time.delta_seconds();
    for (physical, transform) in (&physicals, &mut transforms).join() {
      let movement = physical.velocity * delta;
      let rotation = physical.rotation * delta;
      transform.prepend_translation(Vector3::new(movement.x, movement.y, 0.0));
      transform.rotate_2d(rotation);
    }
  }
}
每個系統都要定義 SystemData,也就是它需要存取哪些資源:無非就是讀寫 component、新增/刪除 entity 還有讀取 world 內存的 resource,PhysicalSystem 會需要去讀 Physical component、系統提供的 Time resource、寫入 transform component。
在讀取和寫入有不同的方式,可以參考文件 system 章節,大致整理起來是:
  • Read<'s, Resource>、Write<'s, Resource>:取得唯讀/可讀寫的 Resource,這個是保證不會失敗的取得資源,如果失敗的話會直接給你一個 Default::default() 的版本。
  • ReadExpect<'s, Resource>、WriteExpect<'s, Resource>:同上,但這個適用在沒有實作 Default::default() 的資源上。
  • ReadStorage<'s, Component>、WriteStorage<'s, Component>:取得唯讀/可讀寫的 Component 參考。
  • Entities<'s>:創造或刪除 entity 用。
實作 system 只要實作一個 run 函式,這裡有兩種寫法,一種如上面所示,在參數階段就把 SystemData 解開來;另一種則是在函數內解開:
fn run(&mut self, data: Self::SystemData) {
  let (physicals,
        mut transforms,
        time) = data;
  // ...
}
兩種對編譯器來說應該是一樣的,所以選一個喜歡的就可以了,但記得一個原則,一定要把每一行都分開寫,不要擠在一行:
let (physicals, mut transforms, time) = data;
這是因為我們的 system 是會長大的,哪天要加一個新的 component,直接加一行會比在一行裡面找到正確的位置還要簡單。

從 SystemData 拿到的,會是這個遊戲裡「所有有這個 component 的資料」,這裡會收集到所有有 Physical component 的 entity;所以我們用 for loop ,搭配 join() 把資料展開來。
只要是 component 展開這步幾乎是必要的,我個人是建議從 system data 裡解出來的資料,一律加 s 用複數,用 for 解出來再變單數,變數名詞選同一個,比較不會去考慮哪個是哪個。
再來就是位移跟旋轉的實作,從 Time 這個 resource 裡面,我們可以拿到和上一個 frame 之間的時間差,配合 physical 裡面記錄的速度和角速度算出位移量,並更新到 transform 就行了。

上一篇的 ShipControlSystem 也要修改,它會從 ship 定義的加速度算出速度的變化值,修改到 physical 速度內,這裡揭示了 for loop 配 join 的用法,從 ReadStorage<'s, Physical> 拿到的,是所有「有 physical component」,只想要改 ship,只要把 physicals、ships 一起放進 join 裡面,就會只拿出同時有 physical 和 ship component 的 entity 了:
let delta = time.delta_seconds();
for (physical, ship, transform) in (&mut physicals, &ships, &transforms).join() {
  let acceleration = input.axis_value("accelerate");
  let rotate = input.axis_value("rotate");

  // handle acceleration -> velocity
  let acc = acceleration.unwrap_or_default();
  let added = Vector3::y() * delta * acc * ship.acceleration;
  let added = transform.rotation() * added;
  physical.velocity += Vector2::new(added.x, added.y);

  let magnitude = physical.velocity.magnitude();
  if magnitude > physical.max_velocity {
    physical.velocity *= physical.max_velocity / magnitude;
  }
最後一步,和上一篇一樣把我們的 system 註冊到 game data 裡面
use crate::system::{ShipControlSystem, PhysicalSystem};

let game_data = GameDataBuilder::default()
  // Render, transform bundle here ...
  .with(ShipControlSystem, "ship_control_system", &["input_system"])
  .with(PhysicalSystem, "physical_system", &["ship_control_system"]);

註冊 system 的 with 是可以寫明相依關係的,三個參數分別是 system struct,system name 和 dependencies list,我們這裡的寫法 input_system(這個名字應是預設的)、ShipControlSystem、PhysicalSystem 會依序執行。

使用 Amethyst Engine 實作小行星遊戲 - 3 連接輸入

這章有點短,但因為接了鍵盤輸入又要介紹處理輸入的 system 的話,篇幅又太長了,以每章都介紹同樣內容的原則獨立出來;對應為 pong tutorial 的第三章前半部

我們上一章已經寫了幾個 entity 跟 component,這章要開始進到 system,用來操作 entity 跟 component 的內容,在每個 frame system 都會叫起來執行一次,不做事或者做點變動。
要接使用者的輸入,我們在 config 下面準備第二個設定檔:config/input.ron。
(
  axes: {
    "rotate": Emulated( neg: Key(Left), pos: Key(Right) ),
    "accelerate": Emulated( neg: Key(Down), pos: Key(Up) ),
  },
  actions: {
    "shoot": [[Key(Space)]]
  },
)

amethyst 中輸入有兩種形式:axes 和 actions
  • axes 模擬的是兩個不同方向的操作,如果是搖桿的話應該能讀到類比的輸入,鍵盤則是兩個按鍵的互斥的輸入,兩個按鍵只會判定有一個被按下
  • actions 表示數位的輸入,只有按下跟放開兩種狀態。
我們這裡創造兩組 axes 輸入命名為 rotate 跟 accelerate,綁定方向鍵;一個 action 輸入接空白鍵。
可以綁定的對象當然不限於鍵盤,比如說 axes 就能綁定鍵盤、控制器(也許是搖桿)、滑鼠、滑鼠滾輪等輸入;Key 能綁定什麼按鍵則請參考文件

要接輸入,我們要對遊戲的 main.rs 作些修改,加上新的 input_bundle:
use amethyst::input::{InputBundle, StringBindings},

let input_config_path = config_dir.join("input.ron");
let input_bundle = InputBundle::<StringBindings>::new()
       .with_bindings_from_file(input_config_path)?;

let game_data = GameDataBuilder::default()
    // Render, transform bundle here ...
    .with_bundle(input_bundle)?
我們產生一個 InputBundle,並用字串來作為讀取 ron 檔案時的 key:也就是上面我們寫的 "rotate", "accelerate" 等。

這裡我們準備好寫我們第一個 system 了,這裡我們先把所有的 system 都塞在一個 system.rs 裡面,如果分割得更清楚的話,也可以改用 system 的 module 把各系統分到不同的檔案裡面。
system.rs 的內容如下:
use amethyst::{
  core::{Transform},
  derive::{SystemDesc},
  ecs::{Join, ReadStorage, WriteStorage, System, SystemData, Read},
  input::{InputHandler, StringBindings},
};

use crate::component::{Ship};

#[derive(SystemDesc)]
pub struct ShipControlSystem;

impl<'s> System<'s> for ShipControlSystem {
  type SystemData = (
    WriteStorage<'s, Transform>,
    ReadStorage<'s, Ship>,
    Read<'s, InputHandler::<StringBindings>>,
  );

  fn run(&mut self,
    (mut transforms,
     ships,
     input): Self::SystemData) {
    for (_ship, _transform) in (&ships, &mut transforms).join() {
      let rotate = input.axis_value("rotate");
      if let Some(rotate) = rotate {
        println!("{}", rotate);
      }
    }
  }
}
這個 system 很簡單,去讀 input 的值然後印出來,同樣的 system 也只是一個 struct,我們要實作 System<'s> trait ,裡面帶了 run 這個函式,amethyst 引擎會在每個 frame 呼叫各 system 的 run;實作 system 時也要指定對應的 SystemData,SystemData 的內容對應 world 裡面儲存的 entity、component 或是 resource;以我們這個系統為例,它會去修改 transform component、讀取 ship component、讀取使用者輸入。
在 run 裡,for loop 在下一章會更深入的介紹,這裡我們就是去讀取 input 的 axis_value,指定的 key 是 "rotate",得到對應左右鍵的輸入值,rotate 的值會是 Some(-1), Some(0), Some(1) 其中一個。

最後一步我們要把系統註冊到 game data,在 main.rs 生成 game_data 的地方,加上新的 System
use crate::system::{ShipControlSystem};

let game_data = GameDataBuilder::default()
    // Render, transform bundle here ...
    .with_bundle(input_bundle)?
    .with(ShipControlSystem, "ship_control_system", &["input_system"]);
執行遊戲的時候試按左右鍵就能看到輸出值的變化了。

2020年6月26日 星期五

使用 Amethyst Engine 實作小行星遊戲 - 2 讀入資源

畫東西應該是遊戲最基本的功能,除非你是要做什麼矮人要塞之類的 ASCII 遊戲…這種遊戲大概不太有人想玩了。
Amethyst 使用了一套叫 specs 的 ECS Entity-Component-System 框架,當然,也是 Rust 寫的,ECS 概念是:所有的遊戲裡面的物件都是一個 entity(實體),上面可以附上很多的 component(部件,或零件),System(系統)則會去操作這些 component,entity 本身只是帶著 component 走的容器,我們後面實作系統就會更明白這點。

我們先來個簡單的重構,創一個新的檔案:states.rs 並把在 main.rs 裡面的 state 搬出來:
use amethyst::{
  assets::{AssetStorage, Loader, Handle},
  core::transform::{Transform},
  prelude::*,
  renderer::{Camera, ImageFormat, SpriteSheet, Texture, SpriteSheetFormat, SpriteRender},
};

pub const ARENA_HEIGHT: f32 = 300.0;
pub const ARENA_WIDTH: f32 = 300.0;

pub struct AsteroidGame;

impl SimpleState for AsteroidGame {
  fn on_start(&mut self, data: StateData<'_, GameData<'_, '_>>) {
    let world = data.world;
  }
}
這次我們引入更多的 module,載入資源的 assets、控制位置轉換的 transform、Camera、顯示 Sprite 用的元件;下面定義我們場地的大小,最後是我們已經熟悉的 State Struct。
從 StateData 裡面可以拿到遊戲的 world,world 裡面會存有所有遊戲的資料:resource、entity 和 component。

第一步先來產生我們第一個 entity:一台相機。
fn initialize_camera(world: &mut World) {
  let mut transform = Transform::default();
  transform.set_translation_xyz(ARENA_WIDTH * 0.5, ARENA_HEIGHT * 0.5, 1.0);

  world
  .create_entity()
  .with(transform)
  .with(Camera::standard_2d(ARENA_WIDTH, ARENA_HEIGHT))
  .build();
}
我們產生一個位移定在遊戲場景中間,Z 為 1.0 的相機,之後產生的物件 Z 軸會定在 0.0 上面,Amethyst 的座標是第一象限往右往上愈大,以這個相機的可視範圍來說,左下是 (0.0, 0.0) 右上是 (WIDTH, HEIGHT);產生 entity 只需要呼叫 world.create_entity ,並把需要的 component 用 with 塞進去就可以了。

第二步來產生寫第一個 component,開一個新的檔案 components.rs 並填入下面的內容:
use amethyst::{
  ecs::prelude::{Component, DenseVecStorage},
};

pub struct Ship {
  pub acceleration: f32,
  pub rotate: f32,
  pub reload_timer: f32,
  pub time_to_reload: f32,
}

impl Ship {
  pub fn new() -> Self {
    Self {
      acceleration: 80f32,
      rotate: 180f32,
      reload_timer: 0.0f32,
      time_to_reload: 0.5f32,
    }
  }
}

impl Component for Ship {
  type Storage = DenseVecStorage<Self>;
}
component 沒有什麼特別的,就是單純的一個 struct,在我們實作了 Component 之後,就可以把這個 struct 塞進 entity 裡面,Component 裡面可以什麼都沒有,單純做個標記;也可以像現在這樣,存有一個 Ship 所需要的性質。
實作 Component 的時候,都會需要指定不同的儲存方式,specs 裡面有五種不同的儲存方式可選,針對存取速度、記憶體用量有不同的最佳化,我們還是小遊戲的時候,基本上無腦的用 DenseVecStorage 就行了。

第三步我們要載入 Sprite。
回到我們的 states.rs,加上載入 sprite_sheet 用的函式庫
fn load_sprite_sheet(world: &World) -> Handle<SpriteSheet> {
  let texture_handle = {
    let loader = world.read_resource::<Loader>();
    let texture_storage = world.read_resource::<AssetStorage<Texture>>();
    loader.load(
      "texture/ship.png",
      ImageFormat::default(),
      (),
      &texture_storage,
    )
  };

  let loader = world.read_resource::<Loader>();
  let sprite_sheet_store = world.read_resource::<AssetStorage<SpriteSheet>>();
  loader.load(
    "texture/ship.ron", // Here we load the associated ron file
    SpriteSheetFormat(texture_handle),
    (),
    &sprite_sheet_store,
  )
}
我們把所有圖形資源都放在 assets/texture 裡面,一套資源是一個 png 檔配上一個 ron 檔,ron 檔描述一系列資源的起始座標跟大小,除了 png 檔 amethyst 也能讀入其他的檔案如 3D 模型等;以這邊的 ship.ron 為例,指定圖片大小為 16x16,內含一個 sprite 從 (0,0) 到 (16,16):
List((
  texture_width: 16,
  texture_height: 16,
  sprites: [
    (
    x: 0,
    y: 0,
    width: 16,
    height: 16,
    ),
  ],
))
先用 amethyst 內提供的 loader 把整個 png 檔讀進來,變成 world 內部的 texture resource(資源),resource 和 component 類似但不會綁定在某個 entity 上面,load 函式會回傳一個 Handle<Texture> 指向 AssetStorage<Texture> 裡 png 檔被讀進的位置,Handle 的實作類似 reference count pointer,讓所有人都可以共用一個 asset。
png 檔被讀入 AssetStorage 後,再次使用 loader 把 texture 讀入變成 SpriteSheet,這次回傳的內容會是 Handle<SpriteSheet> 指向 AssetStorage<SpriteSheet> 中的位置。

最後一步,我們把上面一切都整合起來:
fn initialize_ship(world: &mut World, sprite_handle: Handle<SpriteSheet>) {
  let mut transform = Transform::default();
  transform.set_translation_xyz(ARENA_WIDTH * 0.5, ARENA_HEIGHT * 0.5, 0.0);

  let sprite_render = SpriteRender {
    sprite_sheet: sprite_handle,
    sprite_number: 0
  };

  world
    .create_entity()
    .with(transform)
    .with(sprite_render.clone())
    .with(Ship::new())
    .build();
}
initialize_ship 跟 initialize_camera 沒有差太多,參數的 sprite_handle 來自剛剛的函式 load_sprite_sheet;位移設定太空船的位置在畫面正中間,從 sprite_sheet 產生 sprite_render,SpriteRender 就是真的顯示在畫面上的物件了,如果一組 sprite 裡面有數個 sprite,可以用 sprite_number 去指定要顯示哪個;最後生成 entity 並把 transform、sprite、Ship 三個 component 加上去即可。
有關 Sprite 的部分,因為有點複雜,我簡單整理起來是這個樣子:
  1. 呼叫 Loader 將圖片載入為 Texture -> Handle<Texture>
  2. 呼叫 Loader 讀 ron 檔,將圖片分割為 SpriteSheet -> Handle<SpriteSheet>
  3. 從 Handle<SpriteSheet> 生成 SpriteRender -> 作為 Component 放到 entity 中
impl SimpleState for Asteroid {
  fn on_start(&mut self, data: StateData<'_, GameData<'_, '_>>) {
    let world = data.world;

    let sprite_sheet = load_sprite_sheet(world);

    world.register::<Ship>();

    initialize_camera(world);
    initialize_ship(world, sprite_sheet);
  }
}
在 state on_start 時,呼叫 load_sprite_sheet 得到 Handle<SpriteSheet>,呼叫 initialize_camera 和 initialize_ship 初始化 camera 跟 ship entity。
有一行特別是這行 world.register::<Ship>()
如果不加這行的話,會遇到執行期錯誤:
thread 'main' panicked at 'Tried to fetch resource of type `MaskedStorage<Ship>`[^1] from the `World`, but the resource does not exist.

You may ensure the resource exists through one of the following methods:

* Inserting it when the world is created: `world.insert(..)`.
* If the resource implements `Default`, include it in a system's `SystemData`, and ensure the system is registered in the dispatcher.
* If the resource does not implement `Default`, insert it in the world during `System::setup`.

[^1]: Full type name: `specs::storage::MaskedStorage<rocket::entities::Ship>`', /home/yodalee/.rustup/toolchains/nightly-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/src/libstd/macros.rs:16:9

這個原因是 component 內的 storage 要經過初始化才能使用,我們在 entity 裡面使用了 Ship 這個 component 卻沒初始化 storage,程式就爆掉了,register 就是向 world 註冊並初始化 component;未來我們加上 System 之後,只要有被 System 使用的 Component 都會自動初始化,這行就不需要了。
經過這麼一團千辛萬苦,現在執行起來就能看到飛船在中間的畫面了。


使用 Amethyst Engine 實作小行星遊戲 - 1 設定專案

首先我們要先設定專案,內容會對應官方教學的 Getting Started 和 Pong 的第一章

設定專案上我個人建議是使用 amethyst 自己的工具,可以用 Cargo 安裝比較簡單;不然就要 clone 官方提供的啟動專案,也不是不行但就不能帶著走了;再其次當然就是用 Cargo new 生一個空專案,在 Cargo.toml 裡面加上 amethyst 的 dependency,這個真的很麻煩完全不建議:
cargo install amethyst_tools
amethyst new <game-name>

專案會自動新增 Cargo.toml,比較重要的是下面的 features,amethyst 可以選擇依賴的底層 API,如果是 Linux/Windows 選 Vulkan;蘋果用戶選 Metal;empty 我編譯完成圖形介面會出不來所以就不要用了。
[package]
name = "rocket"
version = "0.1.0"
authors = []
edition = "2018"
[dependencies]
amethyst = "0.15.0"
[features]
default = ["vulkan"]
empty = ["amethyst/empty"]
metal = ["amethyst/metal"]
vulkan = ["amethyst/vulkan"]
專案建好可以直接先開始編譯,amethyst 框架滿大的,第一次編譯會花上幾分鐘的時間把整個框架給架起來,不過放心,編好一次之後編譯都只要編譯你寫的 code,速度會快很多(雖然說我覺得還是很慢,大概 30 秒至一分鐘不等,Rust 真的很適合這張圖)。

預設版本執行起來應該會看到完全空白的畫面,生成的 main.rs 如下所示:
use amethyst::{
  core::transform::TransformBundle,
  prelude::*,
  renderer::{
    plugins::{RenderFlat2D, RenderToWindow},
    types::DefaultBackend,
    RenderingBundle,
  },
  utils::application_root_dir,
};
一開始當然是引入需要的模組。
struct MyState;
impl SimpleState for MyState {
  fn on_start(&mut self, _data: StateData<'_, GameData<'_, '_>>) {}
}
定義遊戲的 struct,amethyst 把遊戲分為不同的狀態,例如遊戲開始的時候會載入資源,這時候要顯示載入中;載入完要顯示選單,在 amethyst 裡面這些都是不同的狀態,遊戲本質上來說就是在這些狀態間切來切去。Amethyst 使用一個 stack 來管理 state,最上層就是目前執行的 state。
不過呢,因為現在要做的範例還沒有這麼複雜,state 的部分我們就先跳過,我自己也還沒學會(欸,這應該要等到最後我們開始把遊戲外面包上介面的時候再來學就好了。
這邊使用 amethyst 提供的 SimpleState trait,它已經幫我們實作了事件的介面,比自己實作完整 state 簡單。
fn main() -> amethyst::Result<()> {
  amethyst::start_logger(Default::default());
  let app_root = application_root_dir()?;
  let assets_dir = app_root.join("assets");
  let config_dir = app_root.join("config");
  let display_config_path = config_dir.join("display.ron");
進到主程式第一件事就是先打開 logger(然後我找不到怎麼記下 log 的文件…),用來記錄事件/警告/錯誤等等。
設定 assets_dir 跟 config_dir 並讀入 display.ron 設定檔,ron 是款 rust 專門的格式, config/display.ron 會記錄視窗的標題,還有它的畫面尺寸等資訊:
(
  title: "rocket",
  dimensions: Some((1000, 1000)),
)
所有可以設定的選項可以參考 DisplayConfig 的文件
  let game_data = GameDataBuilder::default()
    .with_bundle(
      RenderingBundle::<DefaultBackend>::new()
        .with_plugin(
          RenderToWindow::from_config_path(display_config_path)?
        .with_clear([0.34, 0.36, 0.52, 1.0]),
      )
    .with_plugin(RenderFlat2D::default()),
    )?
    .with_bundle(TransformBundle::new())?;

  let mut game = Application::new(assets_dir, MyState, game_data)?;
  game.run();
  Ok(())
}
剩下的 main 內容就是把 game_data 建起來,預設我們引入兩個 bundle,用來顯示的 RenderingBundle 跟做圖形轉換用的 TransformBundle。
首先我們生一個 RenderingBundle,裡面有兩個 plugin,RenderToWindow 讀入我們寫好的 display.ron 並顯示主視窗;RenderFlat2D 可以在畫面上顯示 Sprite。
最後拿著我們生成的遊戲狀態 MyState、 game_data 全部塞進 Application 裡面就可以了;看到這麼一大團程式碼,就可以理解為什麼一定要用 amethyst 工具幫我們生出預設的設定了,自己手爆這堆東西一定會累死。

到這邊我們就寫好一個空白視窗的小程式了,下一步我們要在畫面上畫上一些東西。


2020年6月24日 星期三

使用 Amethyst Engine 實作小行星遊戲 - 目錄

故事是這個樣子的,之前因為武肺的關係耍廢了一陣子,你看 blog 都沒更新幾個月了,只有中間在那邊玩 vscode 整個就是魯廢。
最近受到強者我同學在歐陸大殺四方的呂行大神感召,試玩了一下 Rust 的 amethyst (紫水晶,託名字的福一定要查 rust amethyst 才會查到要的東西)框架,決定來寫點文介紹一下。

當初看到 amethyst 是在 rust 的 are we game yet 頁面上看到的,如果你只是要寫簡單遊戲的話,rust 有另一套也算知名的引擎 Piston,用量目前比 amethyst 還要高一截,但我覺得 piston 的潛力不及 amethyst,雖然完整但 piston 在虛擬化上面沒有 amethyst 這麼高階,導致很多東西還是要設計師自己跳下去設計,相對來說就是學習曲線比較淺,有經驗的話看看文件就能上手。
不過,現下一般來說找不太到用 piston 或 amethyst 寫的大型遊戲,在範例頁面兩者做的都只是些老遊戲;不過話說回來做遊戲本來跟遊戲引擎就沒什麼關係,比較像是你整體企劃跟資源有沒有弄好,沒引擎還是可以寫個爆紅的 2048 或 flappy bird,有了好引擎還是會搞出歷史性的糞作,像是最後生(消音。

總而言之 amethyst 是(另)一套遊戲框架,背後的設計邏輯是所謂的 ECS:entity、component、system,是有人說 ECS 在 gaming 有 buzzword 的意味,但畢竟兩個比較大的遊戲引擎:unity 跟 unreal 都用上了 ECS 的概念,我想這部分應該是沒什麼疑慮。
在這個系列文,我預計會用 amethyst 寫一個打小行星的經典小遊戲,先聲明這的專案是從這個同樣的專案複製而來,它也是用 amethyst 寫的,只是年代久遠現在已經編不起來了,我直接拿了它的素材來用(應該是不至於被吉吧Orz),完成的畫面應該會如下所示:


如果去看 amethyst 的教學文,它有用 amethyst 寫一個 pong 遊戲,但我覺得 pong 不算一個好的例子,它不會生成跟刪除新的物體,偏偏這應該是很多遊戲必備的功能,用打小行星這種比較能示範怎麼做。
總之讓我們開始吧,這篇就作一個目錄的角色,用來連接所有教學文,希望能對大家成功傳教(欸。

2020年5月16日 星期六

第一次跳槽 vscode 就上手

故事是這樣子的,小弟第一次學寫 code 的時候,是在大一修計算機程式(嚴格來說是高三下學期上了幾個小時的 C,不過那實在稱不上是"學")的時候,第一個使用編輯器是破舊破舊的 Dev C++ ,我打這篇的時候差點都忘了它叫 Dev C++ 了。
當然那時候的功力跟現在實在是天差地遠,淨寫一些垃圾,啊雖然現在也是淨寫一堆垃圾…。
總之後來應該是大二,被同學們拉去演算法課上當砲灰,第一次接觸了工作站 + vim,從那時候把 Dev C++ 給丟了跳槽到 vim,就一直用到現在,之中當然也會用一下其他的編輯器,像是改 windows 的 .NET 程式用到 Visual Studio,但大體還是以 vim 為主力,算算也是超過 10 年的 vimer 了。

不過這兩三年在工作上、日常 project 上面,多多少少都見識到 vim 的不足之處,例如新語言(主要是 Rust)支援不足、跟編譯除錯工具整合不佳、跟 GUI 整合不佳、跟 Git 整合不佳要另外開終端機跟 gitg、自動格式化/排版操作麻煩而且通常排不好;正好此時 Microsoft 回心轉意擁抱開源,推出了 vscode,隔壁棚的 emacs 有大神跳槽鬧得風風雨雨,台灣 CUDA 第一把交椅強者我同學 JJL 也跳槽 vscode 惹還來傳教。

正好最近寫 code 沒什麼靈感,而且最近正好武漢肺炎的關係時機歹歹,就來試著跳槽一下吧(?,到目前為止用 vscode 對最近碰的一個 ncollide package 做了一些除錯的工作,筆記一下到目前為止的設定還有使用方式的筆記。

vscode 基本上的優勢就是是它編輯/建構/除錯三位一體的編輯介面;還有它的擴充功能,用過的都說讚。
擴充方面主要參考的文件有兩個:VSCode 如何提高我的寫扣效率小克的 Visual Studio Code 必裝擴充套件,另外台灣 CUDA 第一把交椅強者我同學 JJL 大大也有推薦一些:

擴充的安裝方式是按快捷鍵 Ctrl + P,打入 ext install 後面接套件名,下面擴充的連結裡面也有顯示安裝的指令:

vim 擴充,讓 vscode 的編輯介面套用 vim 的操作方式,想要手跟鍵盤黏踢踢就一定要裝
語言相關:
C/C++ 擴充:還沒試用只是覺得起家的 C++ 必須裝一下:
Python 擴充:一樣還沒試用只是覺得有一天會寫到先裝一下:
Rust 擴充:這個是這次語言唯一試用過的,雖然結果不怎麼樣
codelldb 除錯擴充,可以用 LLVM 的 lldb 對程式除錯,裝了這個是為了要對 Rust 除錯

工具類:
Git Graph:整合 gitg 類似的圖形化顯示工具到介面,git 管理上當然可以靠打字,但看歷史還是看圖方便
GitLens:還沒試過,強者我同學 JJL 推薦的
TODO tree:統一管理 project 內部的 TODO, FIXME, XXX
Trailing Spaces:自動刪掉程式碼行尾的空白
Markdown Github Style:編輯 markdown 文件時可以直接預覽輸出的格式,解決每次編輯 Github README.md 都要一直 push -f 直到格式完全改對為止,這點很強烈的突顯出 vim 等純文字編輯器的弱項,無法和圖形整合,以致在 markdown、LaTex 這類文字和顯示有相互關係的文件編輯會很吃虧(好啦好啦我知道有人能人腦 render latex 的)。

怎麼建構專案?
在 vscode 裡面的建構叫 task,在選單 terminal 下面的 run Task 跟 run Build Task (Ctrl + Shift + B),沒有 cargo 預設的話就要自行編輯 tasks.json,以下是我這次 debug 時使用的 tasks.json
{
  "version": "2.0.0",
  "tasks": [
    {
      "label": "cargo run",
      "type": "shell",
      "command": "cargo",
      "args": ["build"],
      "group": {
        "kind": "build",
        "isDefault": true
      }
    }
  ]
}
應該滿直覺的,就是呼叫 cargo build 幫我編譯整個專案;在寫完 code 之後使用快捷鍵 Ctrl + Shift + B 就能編譯專案了。

如何除錯:
除錯是 vscode 一項殺手級的功能,vscode 公開一個 API 讓安裝的語言擴充使用,需要什麼語言的除錯安裝擴充就好,像我上面就安裝了 C/C++, Python, Rust 的擴充。如果我記憶沒錯的話,跟 visual studio 一樣,vscode 快捷鍵是也執行 Ctrl + F5 跟除錯 F5:

用 Ctrl + Shift + D 展開 debug 介面。
理論上用滑鼠在原始碼旁邊點一下就能加上 breakpoint 不知道是 Rust 還是 lldb 的問題,我用滑鼠加上去的 breakpoint 都煞不住,至少一定要先在 debug console 裡下一個 b main 讓程式煞住之後,用滑鼠加的 breakpoint 才會有用,真的很奇怪。
另外就是 debug console 的指令跟習慣的 gdb 有點不同要重新習慣,最奇怪的大概是按了 enter 竟然不會重複上個指令,這樣要一直按 n + enter + n + enter 怪麻煩的,只能去習慣 vscode 的除錯指令:F10/next、F11/step、Shift+F11/finish 了。

這次最主要的目的是要對 Rust 程式除錯,我參考的是下面這篇文章…不過試用之後沒有成功
首先我們要加上一個 launch.json 告訴 vscode 要怎麼跑除錯的程式:
{
  "version": "0.2.0",
  "configurations": [
  {
    "name": "Debug example contact_query2d",
    "type": "lldb",
    "request": "launch",
    "program": "${workspaceRoot}/target/debug/examples/contact_query2d",
    "args": [],
    "cwd": "${workspaceRoot}",
  }]
}
再來用 F5 就能開始除錯了,但不知道為什麼我 step into 一個函式,瞬間都變成 assembly code,連 stack 資訊都爛掉了,根本無從 debug 起,感覺是 vscode 哪裡跟 lldb 沒弄好,不過我覺得這不是 vscode 的問題,畢竟我在終端機用 rust-gdb 一樣會有問題,正好反過來如果下 b main 停下來的話,rust-gdb 下一步會停不下來,一口氣跑到 main 的尾巴…。
這個問題一時之間好像無解,也許要等 rust 跟 codelldb/gdb 真的接好之後再來看看了。

下面就是個零碎操作:
Ctrl + KT 叫出 color theme 設定,我用的是 light 的 Solarized Light ,最近眼睛好像不太適合全黑的畫面了QQ。

回頭一看怎麼一堆快捷鍵,不過算啦,跟 vim 的快捷鍵比起來這還是算少的吧XD;話說大概是「把手留在核心區」這個哲學的關係,vim 大部分的按鍵都少有用 Ctrl/Alt 開頭的,剛好一般的圖形應用程式包括 vscode ,大部分的快捷鍵都是 Ctrl/Alt 開頭,也因此在操作上面,vim 很容易就能跟桌面應用程式整在一起,就像是瀏覽器的 vimium 跟 vscode vim 擴充。

2020年4月11日 星期六

用 docker container 來編譯程式

故事是這樣子的,最近受朋友之託研究一個套件,在編譯的時候…不知道為什麼我的 Archlinux 編不起來,有某個奇怪的 bug 蒙蔽了我的雙眼擋住了我而且一時之間解不掉,目前看起來像是 golang 那邊的鍋。
總之目前看起來像是 Archlinux 限定的問題,如果裝一台 ubuntu 18.04 的虛擬機,在裡面 build 就沒這個問題,可以完成編譯。 不過想想,現在都 2020 年了,怎麼連 docker 怎麼用都還沒學起來(查一下這玩意 2013 年就已經問世了耶…),就本例來說沒事還要開一個巨大的 virtualbox ,建個巨大的虛擬磁碟再安裝作業系統真的有點划不來,就花了點時間學了一下 docker ,然後順便記個筆記,不然這年頭發現自己學習能力低落,連 docker 這麼簡單的東西都學不好QQ。

其實網路上已經有 100 篇 docker 的教學文了,不過我還是來寫個 101 篇吧。
首先是關於 docker,大抵上就是一個輕量化的容器,在主機作業系統之上為每個應用程式建立一個最小的執行環境,每個 container 都是一個 user space process;相對的虛擬機則是把整個作業系統都包進去,每個虛擬機共用一個硬體,這部分就偷用一下他們官網的圖片。


當然我們這裡只是要用,背後的原理要是我哪天學會的話再來寫文章記錄QQ,對比虛擬機 docker 具有小、快的優點,畢竟不用開一台機器就要裝一次作業系統,很適合像我這樣只是要用另一個作業系統做個測試,或者寫網路服務的,可以讓程式跑在一個固定的環境裡面,不用一台一台虛擬機處理環境的問題。

這次我的目標就是開一個 ubuntu 18.04 的作業系統,然後在裡面進行編譯。 以我的 archlinux 來說,第零步是先安裝並啟動 docker:
pacman -S docker
systemctl start docker.service

為求方便的話可以把自己加入 docker group 裡面,不過這等同於給他 root 權限(這段警語只出現在英文 wiki 上面):
gpasswd -a user docker

第一步當然就是先把 ubuntu 18.04 的 image 給拉下來,不加版號的話會拉下最新的版本,這裡的 ubuntu image 是 ubuntu 官方準備好,並且放到 docker hub 上面供大家下載的版本,是一套非常純粹的 ubuntu,映像檔最小化連 python 都沒有;大家可以視自己的需求選擇其他的版本,像是 node 官方也有出自己含 node.js 的映像檔,python、django、mysql …都有對應的映像檔可以選擇。
如果自己註冊 docker hub 的帳號,也可以把自己建構的映像檔上傳到 docker hub 上讓大家下載,不過我這篇不會介紹,有興趣的請自己參考這兩篇:。 
docker pull ubuntu:18.04 docker pull ubuntu

載好之後就可以在 docker image ls 或 docker images 看到 ubuntu 了:
$ docker image ls
REPOSITORY TAG IMAGE ID CREATED SIZE
ubuntu 18.04 4e5021d210f6 3 weeks ago 64.2MB
ubuntu latest 4e5021d210f6 3 weeks ago 64.2MB

有了 image 就可以把 container 給跑起來,可以想像 image 就是把需要的檔案都拿到手裡,把 image 放到 container 裡面跑起來就會變得像一個真的作業系統一樣。
docker run 可能是 docker 最複雜的指令之一,選項多到不可理喻,我們先從簡單的開始:
docker run -it ubuntu:18.04 bash

執行一個 ubuntu 18.04 的容器,-it 讓 docker 打開虛擬終端機,並執行 bash,這時候我們就會進到 ubuntu 的 bash,可以從 lsb-release 裡面看到這真的是一台 ubuntu 的機器。
root@e98deb8ccdaf:/# ls
bin boot dev etc home lib lib64 media mnt opt proc root run sbin srv sys tmp usr var
root@e98deb8ccdaf:/# cat /etc/lsb-release
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=18.04
DISTRIB_CODENAME=bionic
DISTRIB_DESCRIPTION="Ubuntu 18.04.4 LTS"

開另一個 host 的終端機,用 docker container ls 或是 docker ps 也能看到它在運作:
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
e98deb8ccdaf ubuntu:18.04 "bash" 59 minutes ago Up 59 minutes inspiring_feistel

但這個 container 在我們下 exit 離開的時候,它也會跟著不見,要用 docker container ls -a 把執行中跟已經被關掉的 container 都列出來才會看到它。
這多少顯示了 docker 隨開隨用,不用隨關的特性,下個 run 就開了一個,不用了它就被關掉了。 於是我們可以在 run 的時候,改成這樣下:
docker run -itd --name blogger ubuntu:18.04

首先是 -d 這個參數,會讓 docker 在背景把這個機器給開起來;--name 則是給機器一個別名,這樣就不需要去動到前面 docker container ls 裡面的 CONTAINER ID,畢竟打名字還是比打 hash 的 hex value 簡單多了。
下完這行 docker 會給出新產生機器的 hash value,docker ps 也可以看到:
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
35f40d006a5f ubuntu:18.04 "/bin/bash" 1 second ago Up Less than a second blogger

這時候我們可以用 exec 進到這台 container,這樣跟 run -it 的效果是一樣的,只是這次離開 container 之後它還是會繼續執行,blogger 的位置換成它的 container ID 35f4 也可以,以下同:
docker exec -it blogger bash

把它停掉可以用 stop 正常關掉這個 container 或是 kill 直接砍了它:
docker stop blogger docker kill blogger

就算是關掉的 container 在 docker ps -a 還是看得到它,可以用 restart 把它開回來
docker restart blogger

為了要用 ubuntu 的機器編譯,我們還要將外部的檔案放到 container 內部,docker 對應的機制可以用 docker cp,有點像是 scp 的下法:
docker cp : docker cp ~/server.py blog:/server.py docker cp blog:/server.py ~/server.py

或者我們想要簡單一點,可以用 volume 的方式,這有點像是 virtualbox 裡面的共享資料夾,平時 container 跟 host 之間可以用這個資料夾互通有無,且就算 container 被刪掉了,這個資料夾還是會留著;詳細的 volume 介紹可以看這篇,我這裡是直接用它的第二種方式,直接在 run 的時候指定一個資料夾給 container:
docker run -v ~/docker:/docker -it ubuntu:18.04 bash

就能在內部的 /docker 裡面看到 host 那邊 ~/docker 的檔案了:
root@c518b1b9fd7b:/# ls docker/
Dockerfile

如果要用 docker 當個編譯工具的話,差不多是這樣就夠了,連續的指令打起來就是:
docker run -v ~/docker:/docker -itd --name compile ubuntu:18.04
docker exec -it compile bash
root@c518b1b9fd7b:/# apt update ...

全新的 18.04 ubuntu 真的是超級單純,該裝的東西都要自己裝好,連 apt 都要自己 update,但我編譯下去它還真的編譯過了 WTF……到底 archlinux 是出了什麼問題…。

本篇文章感謝強者我同學在新加坡大殺四方稱霸麻六甲海峽的志賢大大多加指導。

2020年3月14日 星期六

幫 Google Assistant 加上更多語言

上一篇我們成功做出一個會跟我們猜數字的 Assistant,不過因為方便我開發的時候都是用英文在開發,自己玩玩當然 OK,但對非英文使用者就不行了,因此我們來試著加上中文的回應。
目前 google assistant 支援 20 種語言(註),在 assistant 的開發頁面選擇 modify language setting 可以打開想要的語言,我這裡先開英文和繁體中文,兩個語言都要設定 assistant 的呼叫詞,中文的我們就叫它「猜數字程式」。

在連接的 dialogflow 的部分,點選 project 旁邊的小齒輪,language 分頁裡面也加上繁體中文的選項,在左側欄的 project 名稱下面就可以看到有兩個不同的語言。

加完語言之後,最麻煩的就是把整個設定複製一份了,所有的 intent 設定在新的語言都要再設定一遍,或者你在不同的語言想要採取不同的 flow 也可以,因為猜數字很簡單,我英文跟中文就用相同設定,training phrase 的部分,直接打上中文就可以了。


設定完 dialogflow 之後,下一步要設定我們的 webhook 讓它也能處理多國語言,我選用的套件是 python 的 python-i18n,可以用 yml 或 json 格式儲存想要翻譯的文字,這樣我們程式碼幾乎結構不用大修,只要在回應的地方呼叫 i18n 幫我們吐出翻譯過的文字就好。
修改後的目錄長成這樣:
main.py
locales
  | guess.en.json
  | guess.zh-tw.json
把文件檔都放在 locales 下面,檔案名稱為:{title}.{lang}.json,英文的 guess.en.json 內容如下:
{
  "en": {
    "test" : "test",
    "welcome" : "I have a number between %{low} and %{high}. Can you guess it?",
    "guess_out": "Are you sure? I said a number between %{low} and %{high}",
    "guess_unmatch" : "A number between %{low} and %{high}. Keep guess."
  }
}
對應的中文則是 guess.zh-tw.json:
{
  "zh-tw": {
    "test" : "測試字串",
    "welcome" : "我有一個介於 %{low} 到 %{high} 的數字,你能猜到嗎?",
    "guess_out": "你確定嗎?我說介於 %{low} 跟 %{high} 之間",
    "guess_unmatch" : "介於 %{low} 跟 %{high} 之間,再接再勵"
  }
}
開頭是 language code,這邊這個名字要跟檔案的 {lang} 是相同的,雖然說有點多此一舉的感覺,內容則是 key-value 的形式儲存文字內容。

主程式的部分也要對應的修改:
import i18n

i18n.load_path.append('locales')
i18n.set('file_format', 'json')
i18n.set('fallback', 'en')

language_code = data.get("queryResult").get("languageCode")
print(language_code)
i18n.set('locale', language_code)
text = i18n.t('guess.welcome', low = str(low), high = str(high))
因為我們把檔案都放在 locales 下面,在搜尋路徑上加上 locales;檔案格式為 json;預設的語言是英文。
在 assistant 的 request 裡面,語言設定會放在 queryResult -> languageCode 下,用 i18n.set 設定 locale;這時呼叫 i18n.t(title.key) 就會找出在 {title}.{lang}.json 檔案裡,{lang} 下面 key 對應的字串了,i18n.t 的參數,則可以代入預先設定好的 placeholder 代換到字串裡。

測試一下,在測試頁面可以選擇測試的語言,選擇繁體中文來試試:

現在我們的人工智障會講中文了。

附註:在設定頁面全部可選的語言(依英文首字排序)有:繁中、粵、丹、荷、法、德、印度、印尼、義、日、韓、挪、波蘭、葡、俄、西、瑞典、泰、土;其中西、法、英三大家有口音選項。
雖然在寫這篇的時候我有查到去年 12 月的新聞,說 google assistant 會支援 44 種語言,但截至我寫這個機器人的時候,還沒有這麼多的語言可以設定,可能是新聞跑得比開發工具快吧。另外 google assistant 的說明文件上,則是沒有這麼多種語言,可能開發工具又跑得比文件快一點。
結論:宣傳 > 開發工具 > 開發文件(欸

2020年3月1日 星期日

Rust std process 生成子行程

最近在玩 Rust 的時候,需要用 Rust 去呼叫一些 shell command 來幫我完成一些事,幸好 Rust std 裡面已經有 process 來幫我們完成這件事,使用起來很像 python 的 subprocess,不過實際在用遇到一些問題,所以寫個筆記記錄一下:

首先當然是從 std 引入這個模組的 Command,Stdio 很常用也順便 include 一下:
use std::process::{Command, Stdio};

一切的基礎就是一行 Command::new(command_name),在 command_name 的地方填入你想呼叫的指令。
Command 代表了一個準備好要跑的命令,就像是在 shell 裡面打下 command_name 直接按 enter 一樣,沒有參數、繼續現在行程的環境、位置和現在行程的位置相同。
如果要設定給命令的參數,就用 .arg 塞進去,如下面的例子:
let mut ls = Command::new(ls).arg("-al");
這個參數一次只能塞一個,有多個參數要連續呼叫 .arg 才行。

有個 Command 之後接下來有三種方式讓它跑起來:.spawn(), .output(), .status():

  • spawn fork 子行程執行,拿到一個子行程的 handler,回傳的型別是 Result<Child>。
  • output fork 子行程執行,等待(wait)它結束之後,收集它寫到 std output 的內容,回傳的型別是 Result<Output>。
  • status fork 子行程執行,等待它結束之後,收集它回傳的資訊,回傳的型別是 Result<ExitStatus>。

第一個可以注意到的是回傳的型別都是 Result,這是因為 command 可能會跑起來也可能會跑不起來,像是我打一個 Command::new("www") 但我的 shell 根本沒 www 這個指令,Result 提醒了這個可能性的存在,一般來說這邊最簡單的就是用 .expect 把 Result 解開。
第二個另人疑惑的,是後面的 Child, Output, ExitStatus 是什麼鬼,整理之下大概是這樣:

  • ExitStatus 是最簡單的,就是行程結束的狀態的封裝,Rust 提供兩個介面 success 跟 code 來判斷子行程有沒有正常結束以及對應的 exit code。
  • Output 是更上一層,裡面包了一層 status : ExitStatus,加上兩個 stdout, stderr 的 Vec<u8>,裡面存了子行程所有寫到 stdout 跟 stderr 的內容。
  • 最外層就是由 spawn 產生的 Child,比起 output 跟 status 一生成行程就自動幫你 wait,spawn 給了完全的操作能力,可以做更多事情。

三個啟動的函式影響最大的就是子行程的 stdin/stdout/stderr,在 spawn 跟 status 下,stdin/stdout/stderr 會繼承父行程的 stdin/stdout/stderr;在 output 時,stdin 會被設定成不可使用(接到 /dev/null), stdout 跟 stderr 則會設定成 piped 來讀取。
如果不想用預設的設定,可以在呼叫 status/output/spawn 前做設定,有三個選項可選 Stdio::inherit、Stdio::piped、Stdio::null,分別就是繼承父行程、接 piped 到父行程跟接上 /dev/null 。

現在就能來玩一些例子,例如在 rust 裡面呼叫 ls,用 status() 的話輸出會直接輸出到螢幕上面:
let p = Command::new("ls")
    .arg("-al")
    .status()
    .expect("ls command failed to start");
drwxr-xr-x 5 yodalee yodalee 4096 2月 29 09:45 .
drwxr-xr-x 16 yodalee yodalee 4096 2月 28 20:10 ..
-rw-r--r-- 1 yodalee yodalee 62279 2月 29 00:07 Cargo.lock
-rw-r--r-- 1 yodalee yodalee 312 2月 29 00:07 Cargo.toml

如果想要把 ls 的內容截下來的話,就要改用 output:
let p = Command::new("ls")
    .arg("-al")
    .output()
    .expect("ls command failed to start");
這時候可以從 p.stdout 裡面拿到 Vec<u8>,要轉成字串就要用 String 的 from_utf8/from_utf8_lossy/from_utf8_unchecked 函式轉。
let s = from_utf8_lossy(&p.stdout);
println!("{}", s);
drwxr-xr-x 5 yodalee yodalee 4096 2月 29 09:45 .
drwxr-xr-x 16 yodalee yodalee 4096 2月 28 20:10 ..
-rw-r--r-- 1 yodalee yodalee 62279 2月 29 00:07 Cargo.lock
-rw-r--r-- 1 yodalee yodalee 312 2月 29 00:07 Cargo.toml

如果要對子行程上下其手有完全的操控,就要使用 spawn 了,不過相對來說也要小心,因為 spawn 不會自動幫你 wait,不小心就會把子行程變殭屍行程。
產生出來的 Child 物件,本身就自帶一些函式,像

  • kill() 發 SIGKILL 把子行程砍了。
  • wait()、wait_output() 等待子行程結束,spawn + wait/wait_with_output 就相當於直接呼叫 status/output。

我們用 shell 的 rev 當作例子,它會輸入 stdin 反轉之後輸出,這裡不能用 output() 因為 output 的 stdin 不會打開;可以用 status ,這樣 stdin 會繼承本來的 shell 的 stdin 讓我們打字,但如果我們是要反轉程式裡面的一行字串呢?這時候我們就要用 s.chars().rev().collect::<String>() 然後這篇文就不用寫了 spawn 再操作 stdin 了。

具體來說大概像是這樣:
let mut p = Command::new("rev")
    .stdin(Stdio::piped())
    .spawn()
    .expect("rev command failed to start");
let stdin = p.stdin.as_mut().expect("Failed to open stdin");
stdin.write_all("Hello".as_bytes()).expect("Failed to write stdin");

本來用 spawn 的話子行程的 io 會繼承父行程的,相當於上面那行改成 .stdin(Stdio::inherit()),這裡我們改用 Stdio::piped() 把它接出來。
接著我們可以從 p (型別是 process::Child)裡去取得它的 stdin, stdout, stderr,這個拿到的都是 Option 型別,用 expect 把它給解開來,裡面就會拿到 Rust 的 io 物件,可以用呼叫對應write 系列函式對它寫入內容,這裡用 write_all 對 stdin 寫入 "Hello" 的 Vec<u8>。
在 stdout 螢幕上就會看到 "olleH" 的輸出了。

當然我們也可以在呼叫的時候把 stdout 也導向 piped 處理,讓我們讀出反轉的結果:

let mut p = Command::new("rev")
    .stdin(Stdio::piped())
    .stdout(Stdio::piped())
    .spawn()
    .expect("rev command failed to start");

let stdin = p.stdin.as_mut().expect("Failed to open stdin");
stdin.write_all("Hello".as_bytes()).expect("Failed to write stdin");
let output = p.wait_with_output().expect("Failed to read stdout");
let revs = String::from_utf8_lossy(&output.stdout);
assert_eq!(revs, "olleH");

以上大概就是 Rust std process 使用方法的整理了,我自己大概有三點感想:
  1. 用 Rust 寫其實沒有比 C 用 fork/exec 來寫來得簡單多少,畢竟我們就是要操作子行程,底層都是系統程式那套,Rust 頂多是封裝得比較完善一點,實際上用起來該設定的一個少不了。
  2. 要寫系統程式,系統程式的概念少不了,要寫 process 至少需要知道作業系統行程的概念(不然一不小心會變成 World War Z 殭屍產生器),操作輸入輸出需要大略知道 file descriptor 的概念,不然文件的繼承 stdin/stdout/stderr,piped 根本看不懂,不管你用哪套語言哪個作業系統,這些基本知識是逃不掉的。
  3.  雖然如此,我覺得 Rust 仍然提供了一套不錯的封裝,在函式的回傳值上套用 Result/Option 的方式,能有效提醒使用者可能發生的錯誤,並要求使用者必須處理他們,這點我認為是花了差不多的成本之後,Rust 唯一可以勝過 C 的地方。
不小心寫了落落長,如果你竟然看到這行了,希望這篇文章對你有幫助XD。

2020年1月22日 星期三

跨年不寂寞,讓 Google Assistant 陪你猜數字

有了我們上次的 webhook 之後,我們可以真的來挑戰一些更複雜的助理智障功能,這次就來做跟你猜數字的助理,這樣就算跨年沒有朋友,還是有助理跟你玩有趣的猜數字遊戲,從螢幕感受到滿滿的溫暖 (欸。

其實做猜數字要做的事,在上篇的 webhook 中都差不多了。
我們可以簡單畫一下流程圖,從開頭的 default welcome intent 開始:
畫個簡單的流程圖是很重要的,特別是當設計的助理功能複雜到一定程度的時候,直接徒手下去硬幹很容易迷失在 intent 海洋中,特別是 dialogflow 的介面設計,在個別 Intent 中只能看到這個 Intent 會處理什麼輸入,給出什麼輸出,列出全體 Intent 的介面又看不出各 Intent 之間的關係,一下子就會迷失做亂掉,有了流程圖就能在編輯各 Intent 的時候,照著流程圖一一設定好。
Webhook 也是,要在單一的 webhook 裡面處理所有的 Intent 該怎麼回應,當 Intent 數量一多的時候就會亂掉,所以都要事先做好規劃。

簡單說一下上面的流程圖,default welcome intent 進來會產生一個數字,之後使用者輸入數字(猜數字),如果數字不對,會顯示更新的區間;對了就會顯示一句稱讚的話然後離開對話,讓我們開始實作:
  • 設定 Default Welcome Intent
這裡我們要回應一句請使用者猜數字的話,這句話簡單可以讓 dialogflow 自己回應就好,不過我們還是要打開 webhook,讓後端的伺服器產生一個亂數出來。
在 webhook 的部分,如果偵測到 Intent 是 Default Welcome Intent,就用 random 產生一個亂數出來;另外要把這個 session id 跟亂數寫到資料庫裡,這個 session id 是固定的,同樣的 session id 就會對應到同一組對話。
  • 新增一個 Intent GuessNumber
這個 Intent 裡面可以設定一些例句,像是:
I guess it is 11.
Let me think. 25.
38.
Maybe 0.
在 parameter 的地方把這個數字抽取出來,設定型別是 sys.number-integer,變數名number,這個 Intent 也要打開 webhook 讓伺服器處理使用者猜數字的行為。
  • 新增一個 Intent GuessEnd
這個 Intent 不會由使用者的輸入進入,而是我們在 GuessNumber 的 webhook 設定 assistant 進入的狀態,在這裡要新增一個 Event,我叫它 User_number_match,在回應的部分設定一些恭禧使用者的話,然後設定這個 Intent 結束對話 End of Conversation。
之所以要新增這個 event,是要讓 webhook 有能力讓 dialogflow 判定要進到這個 Intent,一般 Intent 的判定都是透過使用者的輸入來決定,但在猜數字裡面使用者輸入數字判定的 Intent 一定是 GuessNumber 不會是 GuessEnd,那對話就無法結束了。因此我們自定義這個 User_number_match 事件,只要 webhook 發出這個事件 dialogflow 就會判定為 GuessEnd Intent 了。

再來就可以寫 code 了,如上篇文所述,可以從送來的 json 中,從 queryResult -> intent -> displayName 拿到 Intent 的名字,用這個名字就能分派到不同的函式來處理;另外一個就是 json 的 session 可以拿到 session id。
session = data.get("session")
action_name = data.get("queryResult").get("intent").get("displayName")
我的處理函式就是對三個出現的 Intent 去處理:
Default Welcome Intent 產生亂數並寫入資料庫,這裡我是偷懶用 python 的 pickledb,雖然這樣推到 gae 上面可能會沒辦法用,但光為了這種小應用就要動用 gae 的 datastore 實在是有點大砲打小鳥,用 pickledb 展示一下概念就好了:
target = random.randint(low, high)
db.set(session, (low, target, high))
text = "I have a number between {} and {}. Can you guess it?".format(low, high)
reply = { "fulfillmentText": text }
return jsonify(reply)

GuessNumber 的 webhook 會從資料庫裡面把存起來的數字拿出來,並從 queryResult/parameters/number 拿到使用者輸入的數字,雖然我的型別選擇 number-integer 了,dialogflow 還是塞了個 number float 給我,只能用 int 轉成 integer。
後面就可以拿 guessnum 去跟 target 做比較,如果一樣的話就不會回覆 fulfillment 而是發送之前設定好的事件 User_number_match ,讓 dialogflow 進到 GuessEnd 並結束對話;不一樣的話就縮小可以猜的區間,設定回覆訊息給使用者。
minnum, target, maxnum = db.get(session)
guessnum = int(data.get("queryResult").get("parameters").get("number"))
if guessnum == target:
  event = "User_number_match"
  reply = { "followup_event_input" : { "name" : "User_number_match" } }
  return jsonify(reply)
else:
  # update minnum, maxnum here
  db.set(session, (minnum, target, maxnum))
  text = "A number between {} and {}. Keep guess.".format(minnum, maxnum)
  reply = { "fulfillmentText": text }
  return jsonify(reply)
  
GuessEnd Intent 的 webhook 就很簡單,把 session id 對應的條目庫裡面刪掉就可以了。

讓我們來測試一下:


2020年1月11日 星期六

連接 Google Assistant Webhook

上一篇可以看到,我們的 action 可以從我們說的話裡面萃取出關鍵字詞,一般簡單的回應可以在 Intent 裡面剖析、回應,但 dialogflow 也僅止於判斷語意跟萃取關鍵字,如果使用者要使用外部服務,像是訂車票之類,一定要連接到外部訂票網站,這個時候就需要借助 webhook 的力量了。
dialogflow 可以讓一個 Intent 的 fulfillment,也就是完成回應,送到另一個 server 的 webhook 來處理,由伺服器回應使用者的需求,同時間伺服器也能去呼叫其他的 API 服務,完成 Google Assistant 幫助使用者完成某件事情;這個做法的優先權比較高,我試過用了 webhook ,它的回應會蓋過 Intent 裡面設定的回應,完整的介紹可以參考 Google 的文件

如果有看 codelab 的課程,裡面使用的回應是用 dialogflow 內建的 server 或是連接到 firebase 上面的 server,兩個都是用 nodejs 實作,這是我們第一個要解的問題:我不想寫 nodejs 看到 nodejs 就會傷風感冒頭痛發燒上吐下瀉四肢無力,所以我們不能用 nodejs。
幸好隨手拜 google 大神,就找到有人用 python 架 server,之前在 MOPCON 講 COSCUP chatbot 的講者大大也是用 golang 架 server,所以要擺脫 nodejs 一定是沒問題。

我們這篇的目標是做一個把 python server 給架起來,然後把歡迎訊息改成用 webhook來回應,都是 google 的服務,伺服器可以用同專案開 gae 放在上面,但測試時用 ngrok 本地測試會比較方便。
首先是先設定 webhook,在 dialogflow 裡面把 Default Welcome Intent 最下面 fulfillment 的 Enable webhook call for this intent 打開:

在 fulfillment 裡面打開 webhook,在 URL 裡面填上 webhook 的位址,這部分等等寫好服務之後再來填:

下面開始寫我們首先開一個新的 python 專案,建立 pip requirements.txt:
# requirements.txt
Flask==1.1.1

使用 pip 跟 virtualenv 建立環境:
$ python -m venv env
$ source env/bin/activate
$ pip install -r requirements.txt

接著建立 flask 實作的 webhook:
from flask import Flask, request, jsonify, make_response
import json

app = Flask(__name__)

@app.route('/webhook', methods=['POST'])
def webhook():
  data = request.get_json(silent=True, force=True)
  print("Request:{}".format(json.dumps(data, indent=2)))
  action_name = data.get("queryResult").get("intent").get("displayName")
  if action_name == "Default Welcome Intent":
    text = "Welcome to my google assistant"
    reply = { "fulfillmentText": text }
    return jsonify(reply)
下面是一個 Default Welcome Intent 的 webhook request,要看這個內容可以使用 dialogflow 右手邊的 Try it now 搭配 diagnostic info,可以確認 dialogflow 在判斷 Intent 有沒有錯誤,還有 fulfillment request ,也就是送到 webhook 的內容。

下面節錄我 welcome 訊息的 request:
  • session:一個對話的 id,每一輪的話都會是同一個 id,作為對話的識別:
  • queryResult queryText:對話的內容
  • queryResult intent displayName:目前 dialogflow 判定使用者的意圖
dialogflow 接受的回應內容是 json 格式,可以填充的內容請見參考文件,最簡單的一個回應就是設定 key 為 fulfillmentText 的內容,這個內容就會是 Google Assistant 要顯示給使用者的回應。

最後我們使用 ngrok 來進行測試,ngrok 是一個網路服務,幫你把連接到 ngrok 的連線重導向到 localhost。在使用 ngrok 之前,測試架在雲端的網路服務流程會像這樣:
寫程式;在 local 進行有限的測試;上傳到雲端(等等等);跑了之後發現 server 炸掉;去雲端上面撈 log 檔(等等等);改完之後所有步驟重複一次。
用了 ngrok 之後,程式在本地、log 檔也在本地,上述耗時又麻煩的上傳雲端、撈 log 檔都省下來,真的是瞬間人生變成彩色的。

使用 ngrok 也非常簡單,安裝好 ngrok 之後照著網頁的指示先註冊金鑰:
$ ngrok authtoken <token>
$ ngrok http 8080
Forwarding https://wwwwww.ngrok.io -> http://localhost:8080

也就是 wwwwww.ngrok.io 已經被映射到我們的 localhost:8080 由 flask 執行的伺服器,因此我們可以在 webhook 的地方填入 wwwwww.ngrok.io/webhook。

我們來測試一下:

看到我們的 Assistant 回應了我們在 webhook 設定的回應。

2020年1月4日 星期六

跨年好寂寞?使用 Google Assistant 跟你對話

故事是這樣子的,2019-2020 的跨年,小弟邊緣人沒地方去,後來就自己回家當紅白難民,然後還沒聽到 Lisa 唱紅蓮華QQQQ,不過幸好宅宅有宅宅的做法,沒聽到紅蓮華我們可以看 Youtube 別人上傳的影片,沒有朋友跨年我們可以自幹朋友,也就是我們今天的主角:Google Assistant。
如果平常有用 Google Pixel 手機,或是家裡有 Google Home 裝置的,應該就會知道它上面附的 Google Assistant Christina,雖然說我覺得還是滿沒用的啦,我自己只會拿它來查天氣,有 Google Home 的同學只用它來開關燈,但反正,都有這麼好的工具了為什麼不來好好玩一下?就趁著跨年的假日做點不一樣的事來玩。

下面是一個基礎的流程,跟 codelabs 的課程(搜尋 google assistant 有三級課程可以上)一樣,做一個會回應你的小程序,我們想做到的就是:它會問你最喜歡的顏色,然後會重複你的話:你最喜歡的顏色是XXX。

首先來到 google action 的頁面(雖然是 Google Assistant 卻叫 Google Action 呵呵)先建立一個新的 project,類型選擇對話 Conversational,語言我是建議先選英文,之後應該會試著做做看中文的助手,但英文比較萬無一失。
建立之後要設定一個發語詞,平常在手機上呼叫 Google Assistant 是用 OK google,另外也可以用 talk to my "發語詞" 來呼叫你寫的程式,或者是打開某個 App,這裡我們沒決定名字就叫它 TestApp 就好。


下一步要產生一個 Action,選擇 Custom Intent 再點 Build ,這會連結你的 action 到 dialogflow 建立一個新的 Agent,可以想像一個 Agent 就是回話的機器人,這裡一樣語言建議選擇英文,時后就選擇 +8 時區(不知道為什麼只有香港可選)。

這個背後流程是這樣子的,你跟 Google Assistant 講話之後,Google Assistant 會把這段話送到 Google Action,那 Google Action 又要怎麼理解這段話?就是靠 dialogflow 服務,算是一個簡化版本的自然語言理解框架,可以理解說話的意圖,解析出關鍵字送出回應,而中間這些關鍵字跟回應是可以由設計者設定的;其他家類似的服務像是 Amazon Lex、IBM Watson 等。
dialogflow 由許多的 Intent(意圖)所構成,dialogflow 會從 google assistant 來的輸入辨識出現在要選擇哪個意圖,然後照著意圖的設定去回應;可以把google assistant 想像成一個狀態機,意圖想成一個狀態,照使用者的輸入進到不同狀態,依狀態決定輸出內容,以及下一個可能的狀態。

預設一定有的意圖就是 Default Welcome Intent,也就是一啟動 google assistant 時的的狀態,我們在它的 Response 裡面加上回應:"What is your favorite color?",這樣程式一開就會把問題丟給使用者。
這句話非常的重要,設計 chatbot 最重要的就是把使用者限制在一個小框框裡面,讓使用者針對只有有限選項或是只能答 yes/no 的問題做回答,否則使用者天馬行空,<請你跟我結婚>、<誰是世界上最美麗的女人>這種問題滿天飛,結果你的 chatbot 完全聽不懂,畢竟人人都以為人工智慧可以做到穿熱褲黑絲襪又會拌嘴的傲嬌助手,實際上還不是血汗工程師在後面拉線設定的人工智障,隨便一問就被看破手腳。


另外我們要新增一個 Intent ,命名為 favorite color,注意這個命名在未來連接 webhook 時非常重要,命名跟大小寫都要注意。
在 training phrase 的地方,試著打入一些使用者可能會說的話,最好是上一句問句的回話:
blue
my favorite color is red
orange is my favorite
ruby is the best
邊打的時候 dialogflow 就會自動的把顏色部分給標起來,接著往下拉到 Action and parameters ,系統應該會自動加上一個 color 的 parameter,我們設定 entity 為 @sys.color,value 為 $color。


這裡就能看出 dialogflow 服務的功力了,在我們打上例句的時候 dialogflow 就透過事先分類好的 entity,分析出我們現在想要知道使用者回話的什麼內容(這裡是顏色),再幫我們把這個內容存到變數裡。
最後在 response 的地方,我們使用剛剛萃取出來的內容:
OK. Your favorite color is $color
這樣就完成我們的回話機器人了,當然我們要測試一下,在 Integration 選擇 integration setting,記得打開 Auto-preview changes 後再點 test,就可以在 google action 測試頁面中測試了:

短短小文祝大家新年快樂,希望大家都有個機器人陪你過年(欸
Related Posts Plugin for WordPress, Blogger...