😸

Next.jsでゲーム作る (1)

2022/05/18に公開

webの沼に生きるものとして一度はゲーム制作をやりたいと思った今日この頃.
Next.jsを愛してやまないので,「Next.jsはやればできる子なんだぞ」というところを見せる意味で作ってみようと思います.

とても長い記事になったので分割しました.

https://zenn.dev/kage1020/articles/cd7f2346c7c357

ゲームはこちらからどうぞ

https://traffic-ltd.vercel.app/

何を作るか

経営ストラテジーシミュレーションゲーム(?)を作る.

↓こんなかんじのやつ
Plague Inc.
https://www.ndemiccreations.com/en/22-plague-inc
Fly Corp
https://en.kishmish-games.com/fly-corp
Mini Metro
https://dinopoloclub.com/games/mini-metro/

内容としては,日本の公共交通機関版Mini Metro.飛行機,電車,バス,船など
上にあげた代表作を足して3で割ってオリジナリティをまぶしたゲームができればいいな...
会社経営としての側面も持たせたいので決算などの仕組みも取り入れたい.

ゲーム名は上2つにあやかって「Traffic Ltd.

技術選定

ゲームライブラリを使ってもいいが,Next.js(SPA)を使う以上canvasでやるのはもったいない.
state管理の方がflexibleな実装ができる気がする()
コードが長くなるのは否めないけど,上手くhookを作ったりコンポーネントとして分離すれば大丈夫なはず...

構成は以下の通り.

Next.js 12は2022/05/13現在,React 18をサポートしているが,ライブラリの状況によりReact 17に下げるかもしれません.
tailwind cssはNext.jsが公式にサポートしているので脳死で使います.

ユーティリティとしてとりあえずredux(react-redux@reduxjs/toolkit)とclsxを入れます.ちなみにreduxは初めて触るのでバカやってたら指摘してください.
その他は随時追加していく予定.Typescriptは慣れてないので入れません.

2022/07/01 追記
Typescriptなし でも生きられる身体ではありますが,入れときます. だと発作が起き始めました.
2022/08/08 追記
地図描画ライブラリにleafletと関連ライブラリを使います.

2022/09/22 追記
reduxの状態をリロードしても保持するためredux-persistを使います.

プロジェクト作成からVercelでの公開まで

プロジェクト作成&tailwind cssのインストール

npx create-next-app traffic-ltd
cd traffic-ltd
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p
npm install -D typescript @types/node @types/react @types/react-dom
tsconfig.jsonの設定
tsconfig.json
{
    "compilerOptions": {
        "target": "es5",
        "lib": [
            "dom",
            "dom.iterable",
            "esnext"
        ],
        "allowJs": true,
        "skipLibCheck": true,
        "strict": true,
        "forceConsistentCasingInFileNames": true,
        "noEmit": true,
        "esModuleInterop": true,
        "module": "esnext",
        "moduleResolution": "node",
        "resolveJsonModule": true,
        "isolatedModules": true,
        "jsx": "preserve",
        "incremental": true
    },
    "include": [
        "next-env.d.ts",
        "**/*.ts",
        "**/*.tsx"
    ],
    "exclude": [
        "node_modules"
    ]
}
tailwind cssの初期設定
tailwind.config.js
module.exports = {
  content: [
+   './pages/**/*.{js,ts,jsx,tsx}',
+   './components/**/*.{js,ts,jsx,tsx}',
+   './libs/**/*.{js,ts,jsx.tsx}',
+   './scenes/**/*.{js,ts,jsx,tsx}'
  ],
  ...
};
global.css
global.css
@tailwind base;
@tailwind components;
@tailwind utilities;

参考
https://qiita.com/masakinihirota/items/bd8c07aa54efad307588

GithubにpushしてVercelでデプロイ
https://github.com/kage1020/TrafficLtd
https://vercel.com/
https://traffic-ltd.vercel.app/

シーン構成

(loading) -> start -> play -> result -> start -> ...

loading画面はfecthしなければ必要ないので基本3画面の構成.
start画面では,ゲームモードが選択できるようにします.
play画面ではパラメータをいじれるようにするので,必要に応じてシーンが増えるかも.

  • 経営モード
  • 移動手段別モード
  • フリープレイモード

それぞれsceneコンポーネントとして分離するのでreduxを使い始めます.

シーン切り替え

親コンポーネント(Home)でシーン切り替えを行いますが,そのイベントは各シーン内部で発火させたいので,reduxでstate管理します.

stateを一元管理するのに必要なstoreの作成
/libs/redux/store.ts
import { useSelector as rawUseSelector, TypedUseSelectorHook } from 'react-redux';
import { configureStore } from '@reduxjs/toolkit';
import sceneReducer from './slices/sceneSlice';

export const store = configureStore({
  reducer: {
    switcher: sceneReducer
  }
});

export type RootState = ReturnType<typeof store.getState>;
export const useSelector: TypedUseSelectorHook<RootState> = rawUseSelector;

redux toolkitではcreateSliceがactionやらreducerやらをよしなにしてくれます.

/libs/redux/slices/sceneSlice.ts
/libs/redux/slices/sceneSlice.ts
import { createSlice } from '@reduxjs/toolkit';

export const sceneSlice = createSlice({
  name: 'scene',
  initialState: {
    scene: 'start'
  },
  reducers: {
    switchScene: (state, action) => {
      state.scene = action.payload
    }
  }
});

export const { switchScene } = sceneSlice.actions;

export default sceneSlice.reducer;

hooksの便利さを知っている,reduxを初めて触る人間はdispatchやらselectorやらは面倒くさくて書いてられません.なのでhook化します.

/libs/redux/hooks/useScene.ts
/libs/redux/hooks/useScene.ts
import { useCallback } from 'react';
import { useDispatch } from 'react-redux';
import { switchScene } from '../slices/sceneSlice';
import { useSelector } from '../store';

const useScene = () => {
  const scene = useSelector((state) => state.scene.scene);
  const dispatch = useDispatch();
  const handler = useCallback(
    (name: string) => dispatch(switchScene(name)),
    [dispatch]
  );

  return { scene, handler }
}

export default useScene;

これがパフォーマンスを下げているかどうかは知りません.一応dispatchuseCallbackでラップしてますが意味があるのかもわかりません.いつかドキュメント読み込みたいです.

2022/06/24 追記
react-reduxのドキュメントにカスタムフックについての記述がありました.
https://react-redux.js.org/using-react-redux/usage-with-typescript

これによりstateが欲しいときはuseSelectorで,stateを更新したいときはuseSceneで回すというシンプルな構造にできました.

/pages/index.tsx
/pages/index.tsx
import { NextPage } from 'next';
import Head from 'next/head'
import { useSelector } from '../libs/redux/store';
import PlayScene from '../scenes/play';
import ResultScene from '../scenes/result';
import StartScene from '../scenes/start';

const Home: NextPage = () => {
  const scene = useSelector(state => state.switcher.scene);
  return (
    <>
      <Head>
        <title>Traffic Ltd.</title>
        <meta
          name='description'
          content='日本の公共交通機関を舞台にした経営ストラテジーシミュレーションゲーム'
        />
        <link rel='icon' href='/favicon.ico' />
      </Head>

      <main className='w-screen h-screen bg-black text-white font-gothic'>
        {scene === 'start' && <StartScene />}
        {scene === 'play' && <PlayScene />}
        {scene === 'result' && <ResultScene />}
      </main>
    </>
  );
}

export default Home;
/scenes/start.tsx
/scenes/start.tsx
import useScene from '../libs/redux/hooks/useScene';

const StartScene = () => {
  const { scene, handler: setScene } = useScene();

  return (
    <>
      <div>
        <p>{scene} page</p>
        <button onClick={() => setScene('play')}>play</button>
      </div>
    </>
  )
}

export default StartScene
/scenes/play.tsx
/scenes/play.tsx
import useScene from '../libs/redux/hooks/useScene'

const PlayScene = () => {
  const { scene, handler: setScene } = useScene();

  return (
    <>
      <div>{scene} page</div>
      <button className='mr-4' onClick={() => setScene('start')}>back to start</button>
      <button onClick={() => setScene('result')}>go to result</button>
    </>
  )
}

export default PlayScene
/scenes/result.tsx
/scenes/result.tsx
import useScene from '../libs/redux/hooks/useScene'

const ResultScene = () => {
  const { scene, handler: setScene } = useScene();
  return (
    <>
      <div>{scene} page</div>
      <button onClick={() => setScene('start')}>go to start</button>
    </>
  )
}

export default ResultScene

モード選択

sceneと同じくmodeもstartシーンからplayシーンへの切り替え時に値を渡すのでstoreに登録します.

/scenes/start.tsx
/scenes/start.tsx
+import useMode from '../libs/redux/hooks/useMode';
+import { NeonBox, NeonText } from '../components/neon';

+const modes = [
+  { key: 'management', text: '経営モード' },
+  { key: 'free', text: 'フリープレイ' },
+  { key: 'plane', text: '飛行機モード' },
+  { key: 'train', text: '電車モード' },
+  { key: 'bus', text: 'バスモード' },
+  { key: 'ship', text: '船モード' },
+];

const StartScene = () => {
+  const { handler: setMode } = useMode();

+  const startGame = (mode) => {
+    setMode(mode);
+    setScene('play');
+  }

  return (
-    <>
-      <div>
-        <p>{scene} page</p>
-        <button onClick={() => setScene('play')}>play</button>
-      </div>
-    </>
+    <div className='flex flex-col justify-center items-center w-full h-full'>
+      <div className='flex items-center pb-16'>
+        <NeonText className='text-8xl text-blue-400'>Traffic Ltd.</NeonText>
+      </div>
+      <div className='grid grid-cols-2'>
+        {modes.map((mode) => (
+          <div
+            className='mx-6 my-4 cursor-pointer'
+            key={mode.key}
+            onClick={() => startGame(mode.key)}
+          >
+            <NeonBox
+              className='p-6 flex justify-center 
+            text-xl font-bold hover:text-green-500'
+            >
+              {mode.text}
+            </NeonBox>
+          </div>
+        ))}
+      </div>
+    </div>
  );
}
/libs/redux/store.ts
/libs/redux/store.ts
-import { configureStore } from '@reduxjs/toolkit';
+import { combineReducers, configureStore } from '@reduxjs/toolkit';
+import modeReducer from './slices/modeSlice';

export const store = configureStore({
-  reducer: {
-    switcher: sceneReducer
-  }
+  reducer: combineReducers({
+    scene: sceneReducer,
+    mode: modeReducer,
+  })
});
/libs/redux/slices/modeSlice.ts
/libs/redux/slices/modeSlice.ts
import { createSlice } from '@reduxjs/toolkit';

export const modeSlice = createSlice({
  name: 'mode',
  initialState: {
    mode: 'management',
  },
  reducers: {
    selectMode: (state, action) => {
      state.mode = action.payload;
    },
  },
});

export const { selectMode } = modeSlice.actions;

export default modeSlice.reducer;
/libs/redux/useMode.ts
/libs/redux/useMode.ts
import { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { selectMode } from '../slices/modeSlice';

const useMode = () => {
  const mode = useSelector((state) => state.mode.mode);
  const dispatch = useDispatch();
  const handler = useCallback(
    (name) => dispatch(selectMode(name)),
    [dispatch]
  );

  return { mode, handler };
};

export default useMode;

UIに関しては,ダサダサデザインしか作れないので勘弁してください.start.tsxNeonBoxNeonTexttext-shadowbox-shadowを組み合わせてなんとかネオン調にしてます.ただ,tailwind cssにはtext-shadowのクラスが存在しないので,pluginsに追加してます.

/components/neon.tsx
/components/neon.tsx
import clsx from 'clsx';

export const NeonBox = ({ className = '', children }) => {
  return (
    <div className={clsx(className, 'border-4 shadow rounded')}>{children}</div>
  );
};

export const NeonText = ({ className = '', children }) => {
  return <span className={clsx(className, 'text-shadow')}>{children}</span>;
};
tailwind.config.js
tailwind.config.js
  theme: {
    extend: {
+      boxShadow: {
+        DEFAULT:
+          '0 0 1px, 0 0 2px, 0 0 20px, inset 0 0 1px, inset 0 0 2px, inset 0 0 16px',
+      },
    },
  },
  plugins: [
+    ({ addUtilities }) => {
+      const newUtilities = {
+        '.text-shadow': {
+          textShadow: '0 0 1px, 0 0 8px, 0 0 32px',
+        },
+        '.text-shadow-none': {
+          textShadow: 'none',
+        },
+      };
+      addUtilities(newUtilities);
+    },
  ],

次回は駅などのデータを取得していきます.

続きはこちら

https://zenn.dev/kage1020/articles/cd7f2346c7c357

Discussion