五月祭で感染シミュレーションゲームを作って公開した話

こんにちは！Life is Tech! でunityメンターをしております、たおと申します！
この記事は[Life is Tech ! Advent Calendar 2024]("https://qiita.com/advent-calendar/2024/mentors") 5日目の記事です。


## はじめに
まずは自己紹介になりますが、私は現在大学4年生で、東京大学医学部に通っています。6年制なのでまだまだですが、今年はなんと試験数が40超えととんでもないことになっています。進級できるかまだ心配です。

東大といえば五月祭が有名ですが、医学部も企画を出しており、私は感染シミュレーションゲームを作る企画チームに所属していました。
本記事ではこのゲームの制作過程や仕様などを紹介していけたらと思います！

[五月祭医学部企画　"越境する異邦人"　公式サイト]("https://www.utmed-mayfes.jp/2024/")
[感染シミュレーションゲーム　"simufection"]("https://simufection.vercel.app")

<img width="50%" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3632160/3b7f51e7-02ef-1e2a-725f-27ef23b7c246.png">

# 共同制作者
S.Yさん, Y.Yさん, T.Sさん

# 目次
**[はじめに](#はじめに)**
**[どんなゲーム？](#どんなゲーム？)**
**[使用技術](#使用技術)**
**[ゲームについて](#ゲームについて)**
**[プロジェクトについて](#プロジェクトについて)**
**[ゲームの仕様](#ゲームの仕様)**
**[攻略のコツ](#攻略のコツ)**
**[大変だったこと](#大変だったこと)**
**[最後に](#最後に)**
**[ソースコード](#ソースコード)**



## どんなゲーム？
<div style={{width:"100%" justify-content:"space-between"}}>
<img width="45%" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3632160/ecc385e0-36cb-cb5c-33c1-247004a452d4.png">

<img width="45%" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3632160/8ccb18f1-3060-59ef-9075-cc10d5896ac2.png">
</div>

画像のように人間を一つの点として接触感染をシミュレーションし、プレイヤーは政治家として政策をアイテムとして使い、一刻も早い感染の収束を目指すゲームになります。
アイテムである政策カードはドラッグ&ドロップで使用すると効果を発揮します
点と点は接触すると一定の確率で感染をうつし、一定ターンすると治癒の抽選を受けます。
全ての点が感染していない状態になったらクリアです。クリアの内容に応じてスコアが決定されます。

| ランク | 点数 | 画像 |
| ---- | ---- |----|
| 一流政治家 | 90000~ |<img width=100px src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3632160/c3d5fcb3-efb3-a7a0-6358-cc5b981b0b7d.png"/>|
| 二流政治家 | 80000~ |<img width=100px src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3632160/15e186fd-12cf-e464-f385-27e40323a6b3.png"/>|
| 三流政治家 | 70000~ |<img width=100px src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3632160/0d07fe64-b8fe-4e5c-2c6b-8134d0ced531.png"/>|
| できない政治家 | 60000~ |<img width=100px src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3632160/21ce786f-599e-1e45-de3d-66874a378b0e.png"/>|
| ひどい政治家 | 50000~ |<img width=100px src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3632160/214bd0bd-1dd0-6409-25f3-7df56368495c.png"/>|
| 最悪な政治家 | ~50000 |<img width=100px src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3632160/cfa4dffa-5209-79aa-c2ee-1fe96ddf90e0.png"/>|



## 使用技術
**Webフロントエンド**
- Next.js (プレイ画面はcanvasを使用)

**ゲームの仕組み**
- typescript

**スタイルシート**
- scss
- FLOCSS


**データベース**
- vercel postgres

**タスク管理**
- Notion (ポータル)
- JIRA attlasian (チーム開発のタスク管理)

**コード管理**
- GutHub

**サーバー**
- vercel (簡単のため)

**マップデータ作成**
- python(japanmapを用いて県境や海などを配列化)

## ゲームについて

**政策一覧**

政策カードをドラッグ&ドロップで使用します

|ID|政策|効果|
|-|-|-|
|v|ワクチン接種|感染力低下|
|e|治療薬|回復確率を上昇|
|d|マスク配布|max20人の感染能力を0にする|
|l|ロックダウン|県境の移動を制限、県内の移動速度の低下|
|p|PCR検査|一定人数を検査し、陽性者は自宅謹慎(感染×、移動×)|

**ウイルスイベント一覧**

VIRUS_EVENT_INTERVALごとに以下の一つをランダムに実行します

|ID|効果|
|-|-|
|e|感染確率の強化|
|d|死亡確率を強化|
|c|治癒の抽選の頻度を抑制|

## プロジェクトについて

ディレクトリ構造
```
src
├─ app
│  ├─ api/
│  └─ game
│     ├─ _components/
│     ├─ _data/
│     ├─ _functions/
│     │    ├─ _draw/
│     │    ├─ _game/
│     │    └─ _policies/
│     ├─ _maps/
│     ├─ _params/
│     ├─ _states/
│     ├─ _stats/
│     ├─ contextProvider.tsx
│     ├─ gameView.tsx
│     ├─ layout.tsx
│     └─ page.tsx
├─ components
├─ assets
│  └─ imgs/
├─ hooks/
├─ services/
├─ styles/
└─ types/
```
詳細は省略しますが、以上のような構造で管理していました。


## ゲームの仕様
フレームごとの人の座標、感染ステータスなどを全て裏側で管理し、それをdraw関数でフロントのcanvasに描画する、という作業をしています。
もちろんcanvasなので物理演算はできず、非常にアナログな方法での実装にはなっています。

### 人々について

生成される人の数は各マップの都道府県の人口比に準拠しています。


```ball.ts
export type Ball = {
  forecolor: string;
  radius: number;
  prefId: number;
  x: number;
  y: number;
  dx: number;
  dy: number;
  stop: boolean;
  first: boolean;
  infectedState: InfectedState;
  masked: boolean;
  disposable_mask_num: number;
  remainLevy: number;
  count: number;
  reinfect: boolean;
  turnInfection: number;
  turnHealed: number;
  turnReMove: number;
  turnsRequiredForHeal: number;
  turnsRequiredForDead: number;
  turnsRequiredForReinfect: number;
  turnRemoveMask: number;
};


export const createBall = (
  flag_stop: boolean,
  params: ParamsModel,
  map: number[][],
  prefId?: number,
  position?: Position,
  contacted: boolean = false
): Ball => {
  // 人を作成
};

export const createBalls = (params: ParamsModel, map: Map, isTutorial = false): Ball[] => {
  // マップごとに人を配置
};

const updatePosition = (
  currentBalls: Ball[],
  map: Map,
  params: ParamsModel,
  prefs: { [name: number]: Pref }
) => {
  // 速度や向きに応じて座標を更新
};

const updateBallState = (
  currentBalls: Ball[],
  params: ParamsModel,
  turn: number,
  virus: Virus,
  prefs: { [name: number]: Pref }
) => {
  // 感染者との接触、治癒の判定などによる状態の更新処理
};

export const updateBalls = (
  currentBalls: Ball[],
  params: ParamsModel,
  turns: number,
  virus: Virus,
  map: Map,
  prefs: { [name: number]: Pref }
) => {
  const ballsEvents: [number, string, any][] = [];
  const tmpBalls = updatePosition(currentBalls, map, params, prefs);
  const balls = updateBallState(tmpBalls, params, turns, virus, prefs);
  return { balls: balls, ballsEvents: ballsEvents };
};
```

### 移動について

人々の動きはレヴィウォーク(ランダムウォークの一種、長い直線距離を定期的に含むので実際の生物の動きに近いとされる)に則っています。
完全なランダムは実装不可能なため、疑似乱数を利用して実装しました。
```levy.ts
export const levyDist = (mu: number, c: number) => (x: number) => {
  if (x <= mu) return 0;
  return (
    (Math.sqrt(c / (2 * Math.PI)) * Math.exp(-c / (2 * (x - mu)))) /
    Math.pow(x - mu, 3 / 2)
  );
};

export const randLevy = (mu: number, c: number, max: number = 10) => {
  let x = 0;
  let density = 0;
  while (true) {
    x = mu + Math.random() * max;
    density = levyDist(mu, c)(x);
    if (Math.random() < density) {
      return x;
    }
  }
};

```

### 感染について

感染後はturnsRequiredForHealターン経過後にvirusのhealProbの確率でturnsJudgeHealターンごとに治癒が抽選されます。

```virus.ts
export type Virus = {
  prob: number;
  turnEvent: { [turn: number]: number };
  turnsRequiredForHeal: number;
  turnsRequiredForDead: number;
  turnsRequiredForReinfect: number;
  turnsJudgeHeal: number;
  turnsJudgeDead: number;
  healProb: number;
  deadProb: number;
};
```


### その他のステートについて
これをベースとして、シーンの状態(ポイントやターン数など)やウイルスの強さ、政策の使用状況なども同様にステートとして管理します。

``` state.ts
export enum PlayingState {
  title,
  selecting,
  playing,
  pausing,
  finishing,
  tutorial,
}

export enum Objects {
  none = 0,
  bar = 1,
  fence = 2,
}

export type GameState = {
  map: Map;
  playingState: PlayingState;
  sceneState: SceneState;
  player: Player;
  balls: Ball[];
  prefs: { [name: number]: Pref };
  virus: Virus;
  editing: Objects;
  events: [number, string, any][];
  policyData: PolicyData;
  tutorialMessage: string;
};

export const initializeGameState = (
  params: ParamsModel,
  mapName: string,
  isTutorial: boolean = false
): GameState => {
  // マップの選択
  }

  return {
    map: map,
    playingState: PlayingState.title,
    player: {
      points: isTutorial ? 0 : params.INITIAL_POINT,
      pt: params.INITIAL_DELTA_POINT,
      zero: isTutorial,
    },
    sceneState: {
      turns: 0,
      results: [],
      preResult: [0, 1, 1, 0, 0],
      contactedCount: 0,
      infectedCount: 0,
      healedCount: 0,
      deadCount: 0,
      sum_infected: 0,
      sum_dead: 0,
      sum_healed: 0,
    },
    balls: createBalls(params, map, isTutorial),
    prefs: initializePrefs(params, map.prefIds),
    virus: initializeVirus(mapName, params),
    editing: Objects.none,
    events: isTutorial ? [] : [[0, "game_start", {}]],
    policyData: initializePolicydata(params),
    tutorialMessage: "",
  };
};

export const updateGameState = (
  currentState: GameState,
  params: ParamsModel,
  isTutorial: boolean = false
): GameState => {
  if (currentState.playingState == PlayingState.playing) {
    const state = { ...currentState };
    const events = state.events;
    const { sceneState, playingState, sceneEvents } = updateSceneState(
      state.sceneState,
      params,
      state.balls,
      state.playingState,
      isTutorial
    );
    sceneEvents.forEach((e) => {
      events.push(e);
    });
    const { player, playerEvents } = updatePlayer(
      state.sceneState,
      state.player,
      params
    );
    playerEvents.forEach((e) => {
      events.push(e);
    });
    const { prefs, prefsEvents } = updatePrefs(
      params,
      state.prefs,
      state.balls,
      sceneState.turns
    );
    prefsEvents.forEach((e) => {
      events.push(e);
    });
    const { balls, ballsEvents } = updateBalls(
      state.balls,
      params,
      sceneState.turns,
      state.virus,
      state.map,
      state.prefs
    );
    ballsEvents.forEach((e) => {
      events.push(e);
    });
    const { virus, virusEvents } = updateVirus(
      state.virus,
      sceneState.turns,
      params
    );
    virusEvents.forEach((e) => {
      events.push(e);
    });

    return {
      ...state,
      ...{
        playingState: playingState,
        player: player,
        sceneState: sceneState,
        prefs: prefs,
        balls: balls,
        virus: virus,
        events: events,
      },
    };
  } else {
    return currentState;
  }
};

```




## 攻略のコツ

人口の多い県が端に固まるようなマップがおすすめ。初手でロックダウンの政策を使用すると安定するかもしれません。
最後にはマスクを配布して安全に収束させましょう。

以下に高得点者のイベント状況を添付します。
政策やウィルスイベントのIDは ゲームについて を参照してください。

```typescript
// しげ さん 99754点	1170ターン kanto

[
 [0,"game_start",{}],
 [23,"policy_d",{"num":3}],
 [127,"policy_e",{"healProb":"0.10"}],
 [490,"policy_v",{"prob":"0.07"}],
 [500,"virus_d",{"prob":"0.04"}],
 [765,"policy_d",{"num":2}],
 [1000,"virus_e",{"prob":"0.14"}],
 [1047,"policy_v",{"prob":"0.10"}]
]

// そうま さん 99462点 1536ターン kanto
[
 [0,"game_start",{}],
 [27,"policy_d",{"num":6}],
 [103,"policy_e",{"healProb":"0.10"}],
 [457,"policy_v",{"prob":"0.07"}],
 [500,"virus_c"{"turnsRequiredForHeal":190}],
 [650,"policy_d",{"num":1}],
 [1000,"virus_e",{"prob":"0.14"}],
 [1007,"policy_e",{"healProb":"0.12"}],
 [1195,"policy_d",{"num":1}],
 [1500,"virus_c",{"turnsRequiredForHeal":260}]
]
```


## 大変だったこと
元々pythonで書かれていたコードをベースにしてtypescriptに移したので、若干、どころか相当無理矢理な実装になってしまっています。特にクラスやステートの管理などが絡み合い、最初にシミュレーションが動き出すまでにかなり時間がかかりました。すごい数のエラーを乗り越えてゲームが動いた時は感動しました。

また、チームでの実装となりましたが残りのメンバーがtypescriptに触るのが初めてで,
その導入は若干ハードルが高かったです。（メンバーのキャッチアップ力が高く非常に助かりました）


パラメータ調整は最も難易度の高いタスクの一つで、ウイルスの強さや政策の強さ、初期値など変数がものすごい量あった中で、ハイスコアを取るのが一定以上の難易度になる、かつプレイングの内容がスコアに反映されやすいように調整するのは本当に大変でした。色々なパラメータで試遊してくれたメンバーには大感謝です。ゲームの難易度調整は本当に大変かつ重要で、ポケモンなどのゲームの調整の素晴らしさを身にしみて体験することができました。

↓使用したパラメータ一覧

```params.
MAX_BALLS	300
RADIUS	3
MAX_WIDTH	640
MAX_HEIGHT	640
RATIO_OF_BALLS_STOPPED	0
SLEEP_SEC	45321
TURNS_REQUIRED_FOR_POINT	50
LEVY_SCALE	1
LEVY_MAX	50
MAX_POINTS	10
BORDER_RATE	0.5
OPTION_REFLECTION	1
INITIAL_DELTA_POINT	0.01
INTERVAL	0.03
INITIAL_POINT	5
MAX_DELTA_POINT	0.2
COEFFICIENT_OF_sum_infected	1
COEFFICIENT_OF_sum_dead	0.9
POINTS_FOR_VACCINE	4
POINTS_FOR_CURE_FASTER	3
VACCINE_EFFECT	0.7
CURE_FASTER_EFFECT	0.6
POINTS_FOR_PCR	7
POINTS_FOR_MASK	5
FALSE_POSITIVE_RATE	10
CHECK_INFECTED	0.9
FALSE_POSITIVE_RATE	0.05
POSITIVE_RATE	0.9
TURNS_REQUIRED_FOR_RE_MOVE	150
POINTS_FOR_MEDICINE	4
POINTS_FOR_LOCKDOWN	5
MASK_PROB	0.8
MASK_DURATION	150
MEDICINE_EFFECT	1.2
POINTS_FOR_DISPOSABLE_MASK	2
INFECTION_RATE_FOR_LOCKDOWN	0.3
INFECTION_RATE_LOCKDOWN_ENDS	0.2
INFECTION_PROB_RATE_LOCKDOWN	0.6
SPEED_UNDER_LOCKDOWN	0.6
LOCKDOWN_COMPLIANCE	1
LOCKDOWN_COMPLIANCE_RATE	1
INFECTION_RATE_LOCKDOWN_ENDS	0.25
TURNS_REQUIRED_FOR_HEAL	200
INITIAL_PROB	0.1
PROB_POWER	2
CURE_SLOWER_EFFECT	70
TURNS_DEAD	70
BORDER_RATE	0.5
TURNS_REQUIRED_FOR_DEAD	250
TURNS_JUDGE_DEAD	70
TURNS_JUDGE_HEAL	70
HEAL_PROB	0.08
DEAD_PROB	0.02
VIRUS_EVENT_INTERVAL	500
```


## 最後に
最後までお読みいただきありがとうございました。
ぜひ遊んで、コメント送信していただけると嬉しいです！

## ソースコード

https://github.com/simufection/simufection