Next.jsでゲーム作る (2)
Next.jsでゲーム作る記事の続きです.
前回の記事はこちらからどうぞ
データの取得
今回使うのは電車,バス,飛行機,船それぞれの停車,停留地点のみです.ありがたいことに国土交通省がこれらの座標や名前を無料で公開してくれています.ここからデータを使いやすいように加工していきます.ちなみに,現状半分ほどは非商用なので広告はつけられません.
バス停留所データ(非商用)
各都道府県ごとにシェイプファイルで取得できるのですべてダウンロードした後,解凍します.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)
空港データ(商用)
空港データは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)
鉄道データ(おそらく非商用)
こちらも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)
港湾データ(非商用)
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のインストール
npm i leaflet react-leaflet
npm i -D @types/leaflet
Next.jsではSSRの関係でそのままでは使えないのでコンポーネント化したうえでダイナミックインポートをします.また,React leafletのバグ?でマーカーが正しく表示されないので設定を上書きします.
/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='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors & 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">© <b>Jawg</b>Maps</a> © <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">© <b>Jawg</b>Maps</a> © <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
+ 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によるレンダリングに切り替えます.
これを使えば10万個のマーカーを1秒以内にレンダリングできます.
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
+ }
地図とマーカーの位置が微妙にずれてますが,どうしようもないので今回は目をつむってください.
また,マーカーは以下のサイトを利用させていただきました.
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;
}
次回はゲームシステムを作っていきます.
続きはこちら
参考文献
Discussion