🔥

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

2022/09/23に公開

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

前回の記事はこちらからどうぞ

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

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

https://github.com/kage1020/TrafficLtd

stateの保持

ReactやReduxのstateは再レンダリングした場合初期値にリセットされてしまうため,今回のようなゲームでは一時中断すると最初からになってしまいます.そこで,逐次的にstateをlocalstorageIndexedDBに保存することでstateの永続化を図ります.

2022/10/13 追記
localstorageでは最大5MB程度しか保存できないためIndexedDBに保存することにしました.

redux-persistの追加
npm install redux-persist @piotr-cz/redux-persist-idb-storage
/libs/redux/store.ts
+ import { useSelector as rawUseSelector, TypedUseSelectorHook } from 'react-redux';
+ import {
+   persistStore,
+   persistReducer,
+   FLUSH,
+   REHYDRATE,
+   PAUSE,
+   PERSIST,
+   PURGE,
+   REGISTER,
+ } from 'redux-persist'
+ import storage from './storage';

const persistRootConfig = {
  key: 'root',
  storage,
};

const rootReducers = combineReducers({
  scene: sceneReducer,
  mode: modeReducer,
})

export const store = configureStore({
  reducer: persistReducer<ReturnType<typeof rootReducers>>(persistRootConfig, rootReducers),
  devTools: process.env.NODE_ENV !== 'production',
  middleware: (getDefaultMiddleware) => getDefaultMiddleware({
    serializableCheck: {
      ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER]
    }
  })
});

export const persistor = persistStore(store)

export type RootState = ReturnType<typeof store.getState>;
export const useSelector: TypedUseSelectorHook<RootState> = rawUseSelector;
_app.tsx
+ import { persistor, store } from '../libs/redux/store';
+ import { PersistGate } from 'redux-persist/integration/react'

  function MyApp({ Component, pageProps }: AppProps) {
    return (
      <Provider store={store}>
+       <PersistGate loading={null} persistor={persistor}>
          <Component {...pageProps} />
+       </PersistGate>
      </Provider>
    );
  }

サーバーにはlocalstorageがないのでstorageは自作します.

https://github.com/vercel/next.js/discussions/15687#discussioncomment-45319

/libs/redux/storage.ts
import createIdbStorage from '@piotr-cz/redux-persist-idb-storage'

const createNoopStorage = () => {
  return {
    getItem(_key: string) {
      return Promise.resolve(null);
    },
    setItem(_key: string, value: string) {
      return Promise.resolve(value);
    },
    removeItem(_key: string) {
      return Promise.resolve();
    },
  };
};

const storage = typeof window !== "undefined"
  ? createIdbStorage({ name: 'TrafficLtd', storeName: 'TrafficLtd' })
  : createNoopStorage();

export default storage;

客を待たせる

今回のゲームではすべてreduxで管理するため,客についての情報も同様です.
まず,各地点のsliceを作成します.stateには地点名をkeyとして客数customers,座標point,表示/非表示showをvalueにもたせます.reducerとしては地点を表示するshowTrainPoint,客を追加するaddTrainCustomersを書きます.また,ゲームリセットのことを考えてclearTrainPointsも用意しました.

PointTypeの定義
export type PointType<K extends string> = {
  [key in K]: {
    customers: number
    point: number[]
    show: boolean
  }
}

電車についてのsliceはこちら.これに限らず地点数が多いとメモリオーバーフローが発生するかもしれないのでいくつかに分割する予定です.(特にバス停は死ぬほどあるので...)

/libs/redux/slices/trainSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { PointType } from './companySlice';
import train from '../../../assets/train/N02-21_Station.json'

export type TrainKey = keyof typeof train.points
export const trainKeys = Object.keys(train.points) as TrainKey[]

const initialState: PointType<TrainKey> = Object.entries(train.points).reduce((acc, cur) => {
  acc[cur[0] as TrainKey] = { customers: 0, point: cur[1], show: false }
  return acc
}, {} as PointType<TrainKey>)

export const trainSlice = createSlice({
  name: 'train',
  initialState: initialState,
  reducers: {
    showTrainPoint: (state, action: PayloadAction<TrainKey>) => {
      state[action.payload].show = true
    },
    addTrainCustomers: (state, action: PayloadAction<{ name: TrainKey, count: number }>) => {
      state[action.payload.name].customers += action.payload.count
    },
    clearTrainPoints: () => initialState
  }
});

export const { showTrainPoint, addTrainCustomers, clearTrainPoints } = trainSlice.actions;

export default trainSlice.reducer;

次に,全体の客の待ち人数を把握するため会社のsliceも作ります.

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

type CompanyInitialStateType = {
  customers: number
}

const initialState: CompanyInitialStateType = {
  customers: 0
}

export const companySlice = createSlice({
  name: 'company',
  initialState: initialState,
  reducers: {
    addCustomers: (state, action: PayloadAction<number>) => {
      state.customers += action.payload
    },
    clearCompany: () => initialState
  }
});

export const { addCustomers, clearCompany } = companySlice.actions;

export default companySlice.reducer;

ゲーム進行に必要なシステムも作成します.

/libs/redux/slices/systemSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { AirportKey } from './airportSlice';
import { PointType } from './companySlice';
import { HokkaidoBusKey } from './hokkaidoBusSlice';
import { PortKey } from './portSlice';
import { TrainKey } from './trainSlice';

export type PlaceKey = AirportKey | HokkaidoBusKey | PortKey | TrainKey

type SystemInitialStateType = {
  time: number,
  point: PointType<PlaceKey>
}

const initialState: SystemInitialStateType = {
  time: 0,
  point: {} as PointType<PlaceKey>,
}

export const systemSlice = createSlice({
  name: 'system',
  initialState: initialState,
  reducers: {
    selectPoint: (state, action: PayloadAction<PointType<PlaceKey>>) => {
      state.point = action.payload
    },
    setTime: (state, action: PayloadAction<number>) => {
      state.time = action.payload
    },
    clearSystem: () => initialState
  }
});

export const { selectPoint, setTime, clearSystem } = systemSlice.actions;

export default systemSlice.reducer;

次に,客の情報をもつsliceを作ります.

/libs/redux/slices/customerSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import cuid from 'cuid';
import { PlaceKey } from './systemSlice';

export type CustomerType = {
  [id: string]: {
    money: number
    place: PlaceKey | 'flying'
    paths: PlaceKey[]
  }
}

const initialState: CustomerType = {}

export const customerSlice = createSlice({
  name: 'customer',
  initialState: initialState,
  reducers: {
    addCustomer: (state, action: PayloadAction<{ place: PlaceKey | 'flying', paths: PlaceKey[] }>) => ({
      ...state,
      [cuid()]: { money: 0, place: action.payload.place, paths: action.payload.paths }
    }),
    updatePaths: (state, action: PayloadAction<string>) => {
      if (state[action.payload].place === state[action.payload].paths[0]) state[action.payload].paths.shift()
    },
    clearCustomers: () => initialState
  }
});

export const { addCustomer, updatePaths, clearCustomers } = customerSlice.actions;

export default customerSlice.reducer;

storeも追加します.

/libs/redux/store.ts
+ import airportReducer from './slices/airportSlice';
+ import hokkaidoBusReducer from './slices/busSlice';
+ import trainReducer from './slices/trainSlice';
+ import portReducer from './slices/portSlice';
+ import companyReducer from './slices/companySlice';
+ import systemReducer from './slices/systemSlice';

+ const persistAirportConfig = {
+   key: 'airport',
+   storage
+ }

+ const persistHokkaidoBusConfig = {
+   key: 'hokkaidoBus',
+   storage
+ }

+ const persistTrainConfig = {
+   key: 'train',
+   storage
+ }

+ const persistPortConfig = {
+   key: 'port',
+   storage
+ }

  const reducers = combineReducers({
    scene: sceneReducer,
    mode: modeReducer,
+   airport: persistReducer(persistAirportConfig, airportReducer),
+   train: persistReducer(persistTrainConfig, trainReducer),
+   hokkaidoBus: persistReducer(persistHokkaidoBusConfig, hokkaidoBusReducer),
+   port: persistReducer(persistPortConfig, portReducer),
+   company: companyReducer,
+   system: systemReducer
  })

次に,時間経過で客を追加していきたいのでtimerパッケージを入れます.自前で作ってもいいけどレンダリング地獄が怖いので...

npm install react-timer-hook
/scenes/play.tsx
+ import { useEffect } from 'react';
+ import useCompany from '../libs/redux/hooks/useCompany';
+ import useSystem from '../libs/redux/hooks/useSystem';
+ import { useStopwatch } from 'react-timer-hook';

  const PlayScene = () => {
+   const { company, addCustomers } = useCompany();
+   const { system, setTime } = useSystem()
+   const offset = new Date()
+   offset.setSeconds(system.time + offset.getSeconds())
+   const { days, hours, minutes, seconds, isRunning } = useStopwatch({ autoStart: true, offsetTimestamp: offset })

+   useEffect(() => {
+     if (isRunning && seconds % 10 === 0) addCustomers(1)
+   }, [isRunning, seconds, addCustomers])
  ...
  }

これで一定時間ごとにincrementするtimerができました.これを各地点で行えば客を待たせることができます.


次回は時間経過とともにマーカーと客を増やしていくとともに,システムのstateも詳しく作っていきます.

参考文献

https://redux-toolkit.js.org/usage/usage-guide#working-with-non-serializable-data

Discussion