Next.jsでゲーム作る (1)
webの沼に生きるものとして一度はゲーム制作をやりたいと思った今日この頃.
Next.jsを愛してやまないので,「Next.jsはやればできる子なんだぞ」というところを見せる意味で作ってみようと思います.
とても長い記事になったので分割しました.
ゲームはこちらからどうぞ
何を作るか
経営ストラテジーシミュレーションゲーム(?)を作る.
↓こんなかんじのやつ
Plague Inc.
Fly Corp
Mini Metro
内容としては,日本の公共交通機関版Mini Metro.飛行機,電車,バス,船など
上にあげた代表作を足して3で割ってオリジナリティをまぶしたゲームができればいいな...
会社経営としての側面も持たせたいので決算などの仕組みも取り入れたい.
ゲーム名は上2つにあやかって「Traffic Ltd.」
技術選定
ゲームライブラリを使ってもいいが,Next.js(SPA)を使う以上canvasでやるのはもったいない.
state管理の方がflexibleな実装ができる気がする()
コードが長くなるのは否めないけど,上手くhookを作ったりコンポーネントとして分離すれば大丈夫なはず...
構成は以下の通り.
- UI Framework: Next.js@12.1.6 (React 18)
- css framework: tailwind css@3.0.24
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と関連ライブラリを使います.
- leaflet@1.8.0
- React leaflet@4.0.1
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の設定
{
"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の初期設定
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
@tailwind base;
@tailwind components;
@tailwind utilities;
参考
GithubにpushしてVercelでデプロイ
シーン構成
(loading) -> start -> play -> result -> start -> ...
loading画面はfecthしなければ必要ないので基本3画面の構成.
start画面では,ゲームモードが選択できるようにします.
play画面ではパラメータをいじれるようにするので,必要に応じてシーンが増えるかも.
- 経営モード
- 移動手段別モード
- フリープレイモード
それぞれsceneコンポーネントとして分離するのでreduxを使い始めます.
シーン切り替え
親コンポーネント(Home)でシーン切り替えを行いますが,そのイベントは各シーン内部で発火させたいので,reduxでstate管理します.
stateを一元管理するのに必要なstoreの作成
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
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
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;
これがパフォーマンスを下げているかどうかは知りません.一応dispatch
をuseCallback
でラップしてますが意味があるのかもわかりません.いつかドキュメント読み込みたいです.
2022/06/24 追記
react-reduxのドキュメントにカスタムフックについての記述がありました.
これによりstateが欲しいときはuseSelector
で,stateを更新したいときはuseScene
で回すというシンプルな構造にできました.
/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
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
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
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
+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
-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
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
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.tsx
のNeonBox
,NeonText
はtext-shadow
とbox-shadow
を組み合わせてなんとかネオン調にしてます.ただ,tailwind cssにはtext-shadow
のクラスが存在しないので,pluginsに追加してます.
/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
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);
+ },
],
次回は駅などのデータを取得していきます.
続きはこちら
Discussion