🕌

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

2022/09/22に公開

Next.jsでゲーム作る記事の続きです.

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

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

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

https://github.com/kage1020/TrafficLtd

データの取得

今回使うのは電車,バス,飛行機,船それぞれの停車,停留地点のみです.ありがたいことに国土交通省がこれらの座標や名前を無料で公開してくれています.ここからデータを使いやすいように加工していきます.ちなみに,現状半分ほどは非商用なので広告はつけられません.

バス停留所データ(非商用)
https://nlftp.mlit.go.jp/ksj/gml/datalist/KsjTmplt-P11.html

各都道府県ごとにシェイプファイルで取得できるのですべてダウンロードした後,解凍します.47個やるのはめんどくさいので自動化します.また,フォルダを1つに統合しシェイプファイルをgeojsonを経て都道府県別jsonに変換します.

2022/10/12 pythonコードを一部修正

unpack.py
import os
import sys
import shutil

def main():
    try:
        dir = input('input dir name: ')
        files = os.listdir(dir)
        for file in files:
            shutil.unpack_archive(f'{dir}/{file}', f'{dir}/out')
            sys.stdout.write(f'\033[2K\033[G {file} is unpacked')
            sys.stdout.flush()
    except Exception as e:
        print(e)
        exit(1)
merge_dirs.py
import os
import sys
import shutil

def main():
    try:
        src = input('input source dir name: ')
        des = input('input destination dir name: ')
        rmv = input('remove dirs automatically? (y): ')
        os.makedirs(des, exist_ok=True)
        dirs = os.listdir(src)
        for dir in dirs:
            files = os.listdir(f'{src}/{dir}')
            for file in files:
                shutil.move(f'{src}/{dir}/{file}', des)
            sys.stdout.write(f'\033[2K\033[G {dir} is merged')
            sys.stdout.flush()
            if rmv == 'y':
                os.rmdir(f'{src}/{dir}')
        if rmv == 'y':
            os.rmdir(src)
    except Exception as e:
        print(e)
        exit(1)
shp2geo.py
import sys
import glob
import shapefile as shp
import json

def main():
    try:
        src = input('input source dir name: ')
        des = input('input destination name: ')
        files = glob.glob(f'{src}/*.shp')
        for file in files:
            sf = shp.Reader(file, encoding='cp932')
            fields = sf.fields[1:]
            names = [field[0] for field in fields]
            buffer = []
            for record in sf.shapeRecords():
                atr = dict(zip(names, record.record))
                geom = record.shape.__geo_interface__
                buffer.append(dict(type='Feature', geometry=geom, properties=atr))
            name = file.replace('.shp', '.geojson').replace(f'{src}\\', '')
            with open(f'{des}/{name}', mode='w', encoding='UTF-8') as f:
                json.dump({'type': 'FeatureCollection', 'features': buffer}, f, indent=2, ensure_ascii=False)
            sys.stdout.write(f'\033[2K\033[G converted to {name}')
            sys.stdout.flush()
    except Exception as e:
        print(e)
        exit(1)
abstruct_data.py
import sys
import glob
import json

def main():
    try:
        src = input('input source dir name: ')
        des = input('input destination dir name: ')
        attr = input('input attribute name for bus stop name: ')
        files = glob.glob(f'{src}/*.geojson')
        for file in files:
            with open(file, mode='r', encoding='UTF-8') as f:
                ctx = json.load(f)
            points = { s['properties'][attr]: s['geometry']['coordinates'] for s in ctx['features'] }
            name = file.replace(src, des).replace('.geojson', '.json')
            with open(name, mode='w', encoding='UTF-8') as f:
                json.dump({'points': points}, f, ensure_ascii=False, indent=2)
            sys.stdout.write(f'\033[2K\033[G converted {file}')
            sys.stdout.flush()
    except Exception as e:
        print(e)
        exit(1)

空港データ(商用)
https://nlftp.mlit.go.jp/ksj/gml/datalist/KsjTmplt-C28-v3_0.html
空港データはgeojsonがそのまま手に入りますが,座標と空港名が別ファイルなので個別に統合します.

merge_airport_geo.py
import json

def main():
    point = input('input Airport file name: ')
    ref = input('input AirportReference name: ')
    with open(point, mode='r', encoding='UTF-8') as f:
        points = json.load(f)
    with open(ref, mode='r', encoding='UTF-8') as f:
        refs = json.load(f)
    ref_map = { p['properties']['C28_000']:p['geometry']['coordinates'] for p in refs['features'] }
    points = { 
        p['properties']['C28_005']: ref_map[p['properties']['C28_101'].replace('#','')]
        for p in points['features']
    }
    name = point.replace('.geojson', '.json')
    with open(name, mode='w', encoding='UTF-8') as f:
        json.dump({'points': points}, f, ensure_ascii=False, indent=2)

鉄道データ(おそらく非商用)
https://nlftp.mlit.go.jp/ksj/gml/datalist/KsjTmplt-N02-v3_0.html
こちらもgeojsonを整形していきます.

abstruct_train_data.py
import json

def main():
    try:
        src = input('input file name: ')
        with open(src, mode='r', encoding='UTF-8') as f:
            ctx = json.load(f)
        points = { 
            s['properties']['N02_005']: s['geometry']['coordinates'][len(s['geometry']['coordinates'])//2]
            for s in ctx['features']
        }
        name = src.replace('.geojson', '.json')
        with open(name, mode='w', encoding='UTF-8') as f:
            json.dump({'points': points}, f, ensure_ascii=False, indent=2)
    except Exception as e:
        print(e)
        exit(1)

港湾データ(非商用)
https://nlftp.mlit.go.jp/ksj/gml/datalist/KsjTmplt-C02-v3_2.html
geojsonに変換した後,必要な情報だけ抜き出します.

abstruct_port_data.py
import json


def main():
    try:
        src = input('input file name: ')
        with open(src, mode='r', encoding='UTF-8') as f:
            ctx = json.load(f)
        points = { s['properties']['C02_005']: s['geometry']['coordinates'] for s in ctx['features'] }
        name = src.replace('.geojson', '.json')
        with open(name, mode='w', encoding='UTF-8') as f:
            json.dump({'points': points}, f, ensure_ascii=False, indent=2)
    except Exception as e:
        print(e)
        exit(1)

日本地図の出力

日本地図を出力する方法はいくつかありますが,今回は無料でかつ簡単にできるleafletを使います.

leafletのインストール

leafletの追加
npm i leaflet react-leaflet
npm i -D @types/leaflet

Next.jsではSSRの関係でそのままでは使えないのでコンポーネント化したうえでダイナミックインポートをします.また,React leafletのバグ?でマーカーが正しく表示されないので設定を上書きします.

/components/map.tsx
/components/map.tsx
import { LayersControl, MapContainer, TileLayer, Popup, Marker } from 'react-leaflet'
import L from 'leaflet'

L.Icon.Default.imagePath = '';
L.Icon.Default.mergeOptions({
  iconUrl: '/images/marker-icon.png',
  iconRetinaUrl: '/images/marker-icon-2x.png',
  shadowUrl: '/images/marker-shadow.png',
});

const Map = () => {
  return (
    <div>
      <select
        defaultValue={type}
        className='absolute top-3 left-16 z-[500] text-black border border-black rounded'
        onChange={(e) => setType(e.target.value as MapType)}
      >
        {types.map(item => <option key={item}>{item}</option>)}
      </select>
      <MapContainer center={[35.68142790469971, 139.76706578914462]} zoom={13}>
        <LayersControl position='topleft'>
          <LayersControl.BaseLayer checked name='blank'>
            <TileLayer
              attribution='<a href="http://maps.gsi.go.jp/development/ichiran.html" target="_blank">国土地理地理院</a>'
              url='https://cyberjapandata.gsi.go.jp/xyz/blank/{z}/{x}/{y}.png'
            />
          </LayersControl.BaseLayer>
          <LayersControl.BaseLayer name='pale'>
            <TileLayer
              attribution='<a href="http://maps.gsi.go.jp/development/ichiran.html" target="_blank">国土地理地理院</a>'
              url='https://cyberjapandata.gsi.go.jp/xyz/pale/{z}/{x}/{y}.png'
            />
          </LayersControl.BaseLayer>
          <LayersControl.BaseLayer name='MTB'>
            <TileLayer
              attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors &amp; USGS'
              url="http://tile.mtbmap.cz/mtbmap_tiles/{z}/{x}/{y}.png"
            />
          </LayersControl.BaseLayer>
          <LayersControl.BaseLayer name='dark'>
            <TileLayer
              attribution='<a href="http://jawg.io" title="Tiles Courtesy of Jawg Maps" target="_blank">&copy; <b>Jawg</b>Maps</a> &copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
              url={`https://{s}.tile.jawg.io/jawg-dark/{z}/{x}/{y}{r}.png?access-token=${process.env.NEXT_PUBLIC_JAWG_ACCESS_TOKEN}`}
            />
          </LayersControl.BaseLayer>
          <LayersControl.BaseLayer name='light'>
            <TileLayer
              attribution='<a href="http://jawg.io" title="Tiles Courtesy of Jawg Maps" target="_blank">&copy; <b>Jawg</b>Maps</a> &copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
              url={`https://{s}.tile.jawg.io/jawg-light/{z}/{x}/{y}{r}.png?access-token=${process.env.NEXT_PUBLIC_JAWG_ACCESS_TOKEN}`}
            />
          </LayersControl.BaseLayer>
        </LayersControl>
        <Marker position={[35.68142790469971, 139.76706578914462]}>
          <Popup>
            A pretty CSS3 popup. <br /> Easily customizable.
          </Popup>
        </Marker>
      </MapContainer>
    </div>
  )
}

export default Map;
/pages/play.tsx
/pages/play.tsx
+ import dynamic from 'next/dynamic';
  import useScene from '../libs/redux/hooks/useScene'
+ const Map = dynamic(() => import('../components/map'), {ssr: false})

  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>
+       <button
+         className='z-[500] absolute right-4 top-4 border border-black text-black bg-white rounded'
+         onClick={() => setScene('start')}
+       >
+         back to start
+       </button>
+       <button
+         className='z-[500] absolute right-4 top-12 border border-black text-black bg-white rounded'
+         onClick={() => setScene('result')}
+       >
+         go to result
+       </button>
+       <Map />
      </>
    )
  }

  export default PlayScene

leafletはあくまでラスタ画像を表示する支援ツールなので,地図情報自体は持ちません.そこで外部のデータを読み込むことで地図を表示します.このサイトで良さそうな地図を探しました.
今回は国土地理院の2種とその他3種を選べる形式にすることにしました.
jawgについてはaccessTokenが必要なので,.env.localにtokenを書いてNEXT_PUBLIC_のプレフィックスをつけて呼び出します.また,無料枠が月50000viewまでなのでユーザーが多いと消えるかもしれません.

CanvasにMarkerを描画する

今後ゲーム内で表示するMarkerが何千個単位で増えてくると明らかに重くなるので,DOMによるレンダリングではなくhtmlのCanvasによるレンダリングに切り替えます.

https://github.com/francoisromain/leaflet-markers-canvas

これを使えば10万個のマーカーを1秒以内にレンダリングできます.

leaflet-markers-canvasの追加
npm install leaflet-markers-canvas
/components/map.tsx
  ...
+ import airport from '../assets/airport/C28-21_Airport.json'
+ import port from '../assets/port/C02-14-g_PortAndHarbor.json'
+ import train from '../assets/train/N02-21_Station.json'
+ import busHokkaido from '../assets/bus/P11-10_01-jgd-g_BusStop.json'
+ import 'leaflet-markers-canvas'

+ const AirportIcon = new L.Icon({
+   iconUrl: '/images/airport.svg',
+   iconSize: [24, 24],
+   iconAnchor: [12, 24],
+   popupAnchor: [0, -24],
+   tooltipAnchor: [0, -24],
+   shadowSize: [24, 24],
+ });

+ const StationIcon = new L.Icon({
+   iconUrl: '/images/train.svg',
+   iconSize: [24, 24],
+   iconAnchor: [12, 24],
+   popupAnchor: [0, -24],
+   tooltipAnchor: [6, -12],
+   shadowSize: [24, 24],
+ });

+ const PortIcon = new L.Icon({
+   iconUrl: '/images/port.svg',
+   iconSize: [24, 24],
+   iconAnchor: [12, 24],
+   popupAnchor: [0, -24],
+   tooltipAnchor: [0, -24],
+   shadowSize: [24, 24],
+ });

+ const BusIcon = new L.Icon({
+   iconUrl: '/images/bus.svg',
+   iconSize: [24, 24],
+   iconAnchor: [12, 24],
+   popupAnchor: [0, -24],
+   tooltipAnchor: [0, -24],
+   shadowSize: [24, 24],
+ });
  
  const Map = () => {
    ...
    return (
      <div>
        <MapContainer ...>
	  ...
+ 	  <MarkerLayer />
	</MapContainer>
      </div>
    )
  }
  
+ const MarkerLayer = () => {
+   const map = useMap()
+   useEffect(() => {
+     const layer = new L.MarkersCanvas().addTo(map)
+     const airports = airport.points.map((p) =>
+       L.marker(p.coordinate.reverse() as LatLngTuple, { icon: AirportIcon })
+         .bindTooltip(p.name)
+         .on('click', (e) => { console.log(p.name) })
+     );
+     const stations = train.points.map((p) =>
+       L.marker(p.coordinate.reverse() as LatLngTuple, { icon: StationIcon })
+         .bindTooltip(p.name)
+         .on('click', (e) => { console.log(p.name) })
+     );
+     const ports = port.points.map((p) =>
+       L.marker(p.coordinate.reverse() as LatLngTuple, { icon: PortIcon })
+         .bindTooltip(p.name)
+         .on('click', (e) => { console.log(p.name) })
+     );
+     const stops = busHokkaido.points.map((p) =>
+       L.marker(p.coordinate.reverse() as LatLngTuple, { icon: BusIcon })
+         .bindTooltip(p.name)
+         .on('click', (e) => { console.log(p.name) })
+     );
+     layer.addMarkers([...airports, ...stations, ...ports, ...stops])
+   }, [map])
+   return null
+ }

地図とマーカーの位置が微妙にずれてますが,どうしようもないので今回は目をつむってください.
また,マーカーは以下のサイトを利用させていただきました.

https://tabler-icons.io/

TypeScriptの型定義はgithubのissueにあったものをそのまま使います.

/types/leaflet.d.ts
import 'leaflet'

declare module 'leaflet' {
  export class MarkersCanvas extends Layer {
    addTo(map: Map | LayerGroup): this
    addMarker(marker: Marker): void
    addMarkers(markers: Array<Marker>): void
    getBounds(): LatLngBounds
    redraw(): void
    clear(): void
    removeMarker(marker: Marker):void
  }

  export function markersCanvas(): MarkersCanvas;

}

次回はゲームシステムを作っていきます.

続きはこちら

https://zenn.dev/kage1020/articles/1dcce32ac956c0

参考文献

https://qiita.com/halboujp/items/67e70f55906b7266e1fc

Discussion