顯示具有 amethyst 標籤的文章。 顯示所有文章
顯示具有 amethyst 標籤的文章。 顯示所有文章

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。

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