こんにちは!Life is Tech! でunityメンターをしております、たおと申します!
この記事はLife is Tech ! Advent Calendar 2024 5日目の記事です。
はじめに
まずは自己紹介になりますが、私は現在大学4年生で、東京大学医学部に通っています。6年制なのでまだまだですが、今年はなんと試験数が40超えととんでもないことになっています。進級できるかまだ心配です。
東大といえば五月祭が有名ですが、医学部も企画を出しており、私は感染シミュレーションゲームを作る企画チームに所属していました。
本記事ではこのゲームの制作過程や仕様などを紹介していけたらと思います!
五月祭医学部企画 "越境する異邦人" 公式サイト
感染シミュレーションゲーム "simufection"
共同制作者
S.Yさん, Y.Yさん, T.Sさん
目次
はじめに
どんなゲーム?
使用技術
ゲームについて
プロジェクトについて
ゲームの仕様
攻略のコツ
大変だったこと
最後に
ソースコード
どんなゲーム?
画像のように人間を一つの点として接触感染をシミュレーションし、プレイヤーは政治家として政策をアイテムとして使い、一刻も早い感染の収束を目指すゲームになります。
アイテムである政策カードはドラッグ&ドロップで使用すると効果を発揮します
点と点は接触すると一定の確率で感染をうつし、一定ターンすると治癒の抽選を受けます。
全ての点が感染していない状態になったらクリアです。クリアの内容に応じてスコアが決定されます。
ランク | 点数 | 画像 |
---|---|---|
一流政治家 | 90000~ | |
二流政治家 | 80000~ | |
三流政治家 | 70000~ | |
できない政治家 | 60000~ | |
ひどい政治家 | 50000~ | |
最悪な政治家 | ~50000 |
使用技術
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なので物理演算はできず、非常にアナログな方法での実装にはなっています。
人々について
生成される人の数は各マップの都道府県の人口比に準拠しています。
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 };
};
移動について
人々の動きはレヴィウォーク(ランダムウォークの一種、長い直線距離を定期的に含むので実際の生物の動きに近いとされる)に則っています。
完全なランダムは実装不可能なため、疑似乱数を利用して実装しました。
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ターンごとに治癒が抽選されます。
export type Virus = {
prob: number;
turnEvent: { [turn: number]: number };
turnsRequiredForHeal: number;
turnsRequiredForDead: number;
turnsRequiredForReinfect: number;
turnsJudgeHeal: number;
turnsJudgeDead: number;
healProb: number;
deadProb: number;
};
その他のステートについて
これをベースとして、シーンの状態(ポイントやターン数など)やウイルスの強さ、政策の使用状況なども同様にステートとして管理します。
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は ゲームについて を参照してください。
// しげ さん 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に触るのが初めてで,
その導入は若干ハードルが高かったです。(メンバーのキャッチアップ力が高く非常に助かりました)
パラメータ調整は最も難易度の高いタスクの一つで、ウイルスの強さや政策の強さ、初期値など変数がものすごい量あった中で、ハイスコアを取るのが一定以上の難易度になる、かつプレイングの内容がスコアに反映されやすいように調整するのは本当に大変でした。色々なパラメータで試遊してくれたメンバーには大感謝です。ゲームの難易度調整は本当に大変かつ重要で、ポケモンなどのゲームの調整の素晴らしさを身にしみて体験することができました。
↓使用したパラメータ一覧
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
最後に
最後までお読みいただきありがとうございました。
ぜひ遊んで、コメント送信していただけると嬉しいです!
ソースコード