発端
ゼビウスを作ってみたいとも思ったけど、スプライトデータを作成するのが大変なのでここはあえて背景スクロールをPyxelで実現する方法を記述してみようかと思います。
前置
本記述はPyxel開発環境が理解できてる前提で書いています。
Pyxel(ピクセル)は、 Python 向けのレトロゲームエンジン。使える色は 16 色のみ、同時に再生できる音は 4 音までなど、レトロゲーム機を意識したシンプルな仕様で、Python を使ってドット絵スタイルのゲームづくりが気軽に楽しめます。
詳しくはここを参照
なお、下記ソースコードはエラー処理してないので注意が必要です。
準備
「Xevious Mapdata」で検索して背景全体図のJPEGファイルを取得。
なぜJPEGファイルなのかというと、後述の減色ツールの対象ベースがJPEGだったからという単純な理由からです。
こんなやつでサイズは横1024ドット、縦2048ドット。
(下記画像はここに掲載するために縮小しています)

各エリアの座標を取得したいので、「Xevious area map」で検索して画像を取得。
こんなやつ

画像データとしてでなく、ここから各エリアの座標を取得するのに使います。
ステップ1:背景画像を減色
まず背景全体図をPyxelで表示できるデータに変換する。
背景全体図のサイズは、横1024ドット、縦2048ドット。
Pyxelのタイルマップは、横256キャラ、縦256キャラなので横2048ドット、縦2048ドットなのでそのまま入りそうだけど、一からキャラクタデータを作成して・・・なんて面倒臭いこと極まりないのでデータ別持ちで作成します。
デフォルト色を据え置きとしてそれ以降に背景用の色を追加して作成します。
本来スプライト作成をPyxelEditorで行い、背景画像は16色超えの追加色を使用するためデータ化した方が後々便利かなとか思ったわけです。
背景全体図はJPEGのままでは扱えないので、減色してからパレットモードのPNGに変換します。そうすることで色データの抽出、並びにマップ画像の抽出を行います。
今回、減色の色数を16色としています。単に扱いやすい数というだけなので応用すれば増やすことも可能だと思います。(ナスカの地上絵の線の色をリアルに近づけたいなら32色にした方が良い。)
背景全体図のファイル名を_allmap.jpgとします。
pythonのPILを使って減色・変換します。
以下pythonソースコード(deccol.py)
from PIL import Image
#jpegファイルを開く
im = Image.open('_allmap.jpg')
#quantizeメソッドで減色する、k平均法を100回かける
im_q = im.quantize(colors=16, method=1, kmeans=100, dither=0)
#パレットモードに変換
im_q.convert("P")
#pngファイルを保存
im_q.save('_allmap_16.png')
quantizeメソッドで減色する方法はいくつかありますが上記方法が一番それっぽかったからという理由で使用しています。
python3 deccol.py
で_allmap_16.pngができました。
ステップ2:背景画像の色を取得、反映
次にPNGデータから色データ、表示データを取得します。
まず、色データ取得をします。
以下pythonソースコード("defrate.py"は機能で分割)
#<defrate.py (1)>
#PNGファイルのデータを展開する
import struct
import os
import zlib
#PNGファイルオープン
f = open("_allmap_16.png", "rb")
#PNGファイル読み込み
data = f.read()
#PNGファイルクローズ
f.close()
#IDATまでのオフセットを加算
#PNGシグネチャは8バイト
offset = 8
#IHDRは25バイト
offset += 25
#色データ取得する
length = struct.unpack_from(">I", data, offset + 0)
#Length(4) + chunk(4) = 8
offset += 8
#chunk data
pdata = list(struct.unpack_from(">" + str(length[0]) + "B", data, offset))
#CRC(4) = 4
offset += length[0] + 4
#色データのファイル出力
with open('palet.txt', mode='w') as f:
for n in range(int(length[0]/3)):
for x in range(3): #RGB
print('{:02x}'.format(pdata[n*3+x]),end="", file=f)
print("", file=f) #改行
f.close()
色データのファイル"palet.txt"が出力できます。
色データはRGBが各1バイトずつ格納されているのでそれを連結、16色分のデータを作成します。Pyxelの色データもRGBが各1バイトずつのデータを合わせたもので、
例:
R=0x12,G=0x34,B=0x56 とすると
色データは、0x123456
Pyxelのパレットファイルでは頭の"0x"を外して、"123456"と記述します。
この色データを格納するファイルを作成します。
空のリソースデータを作成してパレットファイルを作成します。
リソースファイル名をxevi.pyxresとします。
pyxel edit xevi.pyxres
としてエディタを起動し、何も描画せずに保存だけするとリソースファイルが作成されます。
色データはスプライト用に最初の16色分を空けておきたいので背景用の色データはその後に追加します。先のエディタから保存したファイルはリソースファイル(xevi.pyxres)しか出力されないので色データファイルは別途作成が必要です。
先頭16色のデフォルト色の後ろに今回作成した背景色("palet.txt"の内容)を追加します。デフォルト色はPyxelのドキュメントから抽出。
0
2b335f
7e2072
19959c
8b4852
395c98
a9c1ff
eeeeee
d4186c
d38441
e9c35b
70c6a9
7696de
a3a3a3
ff9798
edc7b0
81C6FF
000001
948B40
2A70DC
3E791D
A0A2A1
4847EF
38794E
5E3321
6F6F6E
5888BD
2270ED
6F7A2A
174600
5247C9
39759C
ファイル名をリソースファイルに合わせて、xevi.pyxpalとします。
ここでPyxelEditorを起動すると追加した色が反映されていることがわかります
こんな感じで

ステップ3:背景PNG画像を展開
次にPNGファイルのデータを展開します。
PNGのファイルフォーマットは検索すれば出てくるのでここでは省略。
バイナリエディタでPNGファイルを見るとIDATはふたつあったので連結してひとつのデータにします。
また、IDATはdeflate圧縮がかかっているので展開します。
引用元によると、
PNGデータは複数の IDAT チャンクから構成される。PNGデータは複数のチャンクデータから成り、実際の画像データとしての部分はIDAT チャンクと呼ばれるチャンクに格納されています。またさらに、このチャンクは複数個ある場合があり、その場合はすべての IDATチャンクのデータ部を結合したひとつのバイト配列が画像データを表すデータとなります。
最初のIDATだけだとzlib.decompressがエラーになるのでなんでかなと思っていたらそういうことだそうで連結したらうまくいきました。
以下pythonソースコードの続き
#<defrate.py (2)>
#バイナリエディタで見るとIDATは複数ある
#IDAT-1
length = struct.unpack_from(">I", data, offset)
ctype = struct.unpack_from(">4s", data, offset + 4)
idata = list(struct.unpack_from(">" + str(length[0]) + "B", data, offset + 8))
offset += length[0] + 12
#IDAT-2以降の複数のIDATを連結する
while True:
# Image trailer(12 bytes)
length = struct.unpack_from(">I", data, offset)
ctype = struct.unpack_from(">4s", data, offset + 4)
#IENDを拾ったら終了
if( ctype[0].decode() == 'IEND' ):
#終了
break
tdata = list(struct.unpack_from(">" + str(length[0]) + "B", data, offset + 8))
idata = idata + tdata
offset += length[0] + 12
#IDATはdeflate圧縮がかかっているので展開する
#Deflate(デフレート)とはLZ77とハフマン符号化を組み合わせた可逆データ圧縮アルゴリズム。
# zlibによる解凍
idata = zlib.decompress(bytearray(idata))
展開したデータ(idata)はフル画像分のドットデータになります。
ステップ4:取得した背景画像を圧縮
このままだと容量が大きくなってしまうので8ドット x 8ドットのキャラクターサイズで圧縮を試みることにします。背景データ左上から順に重複箇所を抽出してキャラクターデータを作成しつつ、キャラクター単位のマップデータを作成していきます。
以下pythonソースコードの続き
#<defrate.py (3)>
MAP_WIDTH = 512 #1024/2(1color4bit)
MAP_HEIGHT = 2048
CHAR_WIDTH = 4 #8/2
CHAR_HEIGHT = 8
CHAR_X_MAX = 128 #MAP_WIDTH/CHAR_WIDTH
CHAR_Y_MAX = 256 #MAP_HEIGHT/CHAR_HEIGHT
CHAR_Y_TOP = 36 #SCREEN_HEIGHT/8
_dottbl = [0 for _dt in range(MAP_WIDTH*MAP_HEIGHT)]
#(行の最初は必ず0x00が入るのでそれを除去してデータ作成、なぜ入ってるのかはわからない)
c = 0
for y in range(MAP_HEIGHT):
c+=1 #行頭の0x00分を加算
for x in range(1,MAP_WIDTH+1):
_dottbl[y*MAP_WIDTH+(x-1)] = idata[c]
c+=1
#キャラクタテーブル
_char_tbl = [0 for _ch in range(CHAR_X_MAX*CHAR_Y_MAX)]
_map_tbl = [0 for _mp in range(CHAR_X_MAX*(CHAR_Y_MAX + CHAR_Y_TOP))]
#1キャラクタデータ保管用
_parts = [0 for _pp in range(CHAR_WIDTH*CHAR_HEIGHT)]
_char_number = 0 #キャラクタテーブル番号
_map_number = 0 #マップテーブル番号
for _pp_y in range(CHAR_Y_MAX):
for _pp_x in range(CHAR_X_MAX):
#展開データのポイント位置算出
_pnt = ( _pp_y * CHAR_HEIGHT * MAP_WIDTH ) + ( _pp_x * CHAR_WIDTH )
#ポイント位置からキャラクタデータ取得
_parts.clear()
for _cc_y in range(CHAR_HEIGHT):
for _cc_x in range(CHAR_WIDTH):
#要素を格納
_parts.append( _dottbl[_pnt + ( _cc_y * MAP_WIDTH + _cc_x )] )
#今まで抽出したキャラクタテーブルとの比較
_set = 0
for _cccomp in range(_char_number):
if( _char_tbl[_cccomp] == _parts ):
#合致したのでマップ更新
_map_tbl[_map_number] = _cccomp
_map_number += 1 #マップテーブル番号更新
_set = 1 #セットマーキング
break
if( _set != 1 ):
#合致するものが無かったので追加
_char_tbl[_char_number] = list(_parts)
_map_tbl[_map_number] = _char_number
_char_number += 1 #キャラクタテーブル番号更新
_map_number += 1 #マップテーブル番号更新
#ゲーム開始時点では画面1枚分森になってるので
#マップデータの末端にエリア開始からの1画面分の森データを追加する
#暫定的に追加するデータは各マップの下から6キャラ分をループで格納
#(本来のデータはどうなのか後で確認必要だけど、今はとりあえずこのままで)
_base_map_number = _map_number - (6*CHAR_X_MAX)
for _pp_y in range(CHAR_Y_TOP):
for _pp_x in range(CHAR_X_MAX):
_pp_y2 = _pp_y % 6
_map_tbl[_map_number] = _map_tbl[_base_map_number+((_pp_y2 * CHAR_X_MAX)+_pp_x)]
_map_number += 1
ステップ5:背景データを取得
作成したデータからPyxelで使えるデータテーブルを作成します。
ファイル名"tbl.py"を出力。
以下pythonソースコードの続き
#<defrate.py (4)>
#ファイル tbl.py 出力
with open('tbl.py', mode='w') as f:
print("_map_number = ",_map_number, file=f)
print("_char_number = ",_char_number, file=f)
print("maptbl = [", file=f)
for n in range(_map_number):
if( ( n % 128 ) == 0 ):
print(" ",end="", file=f) #TAB
print('{:#04x}'.format(_map_tbl[n]),end=", ", file=f)
if( ( n % 128 ) == 127 ):
print("", file=f) #\n
print("]", file=f)
print("chartbl = [", file=f)
for n in range(_char_number):
print(" ",_char_tbl[n],",", file=f)
print("]", file=f)
f.close()
<defrate.py (1〜4)>を連結して"defrate.py"を作成
python3 defrate.py
ステップ6:Pyxelで表示
あとは出力されたデータ(tbl.py)を用いて表示するだけです。
各エリアの座標を準備の画像から取得。
#各エリアのマップX座標
_area_x = [512,800,128,688,296,592,0,768,464,64,688,768,352,128,592,800]
y座標は左上基準として、「マップ縦サイズ(2048)ー画面縦サイズ(288)=1760」が背景画像上の座標になりますが、開始地点では1画面森となっておりその分のマップデータを追加してるので、開始時点のy座標は「マップ縦サイズ(2048)-1」となります。
エリア間の繋ぎ目では、描画途中でデータを跨ぐのでその点は注意が必要。
また、以下のプログラムでは8ドットのキャラクタ単位描画なので設定スピードは8以下としています。
以下、Pyxelのソースコード(xeviscr.py)
import pyxel
import tbl as _tbl
#定数
SCROLL_SPEED = 1 #1〜8
SCREEN_WIDTH = 224
SCREEN_HEIGHT = 288
SCREEN_CHAR_WIDTH = 28+1 #224/8
SCREEN_CHAR_HEIGHT = 36+1 #288/8
MAP_WIDTH = 512 #1024/2
MAP_HEIGHT = 2048
CHAR_WIDTH = 4 #8/2
CHAR_HEIGHT = 8
CHAR_X_MAX = 128 #MAP_WIDTH/CHAR_WIDTH
#CHAR_Y_MAX = 256 #MAP_HEIGHT/CHAR_HEIGHT
#CHAR_Y_TOP = 36 #SCREEN_HEIGHT/8
MAPCOLOFS = 0x10 #マップの色は0x10以降
_dx = 0
_dy = 0
_area_number = 0
_startup = 1 #開始マーク
#各エリアのマップX座標
_area_x = [512,800,128,688,296,592,0,768,464,64,688,768,352,128,592,800]
#----------
#更新
def update():
global _dx
global _dy
global _area_number
#_dyは書き換え基準になる
if getInputUP():
_dy = _dy - SCROLL_SPEED
if( _dy < 0 ):
#次のエリアへ
_area_number += 1
if( _area_number >= 16 ):
_area_number = 16-1
_dy = 0
else:
_dy = MAP_HEIGHT-1
elif getInputDOWN():
_dy = _dy + SCROLL_SPEED
if( ( _area_number == 0 ) and ( _dy >= MAP_HEIGHT ) ):
if( _dy >= MAP_HEIGHT ):
_dy = MAP_HEIGHT - 1
elif( _dy >= MAP_HEIGHT ):
#前のエリアへ
_area_number -= 1
_dy = 0
#----------
#描画
def draw():
global _dx
global _dy
global _area_number
global _startup
#画面クリア
pyxel.cls(0)
if( _startup != 0 ):
_area_number = 0
_dy = MAP_HEIGHT - 1
_ddy = _dy & 0x07
for _yp in range(SCREEN_CHAR_HEIGHT): #画面縦キャラクタサイズ
for _xp in range(SCREEN_CHAR_WIDTH): #画面横キャラクタサイズ
#1キャラサイズ=8 * 8 なので、(座標+スクロール値)*8/8している
_xofs = _area_x[_area_number] + (_xp*8)
_yofs = _dy + (_yp*8)
_area = _area_number
_ddx = _area_x[_area] & 0x07
if( ( _area_number > 0 ) and ( _yofs >= MAP_HEIGHT ) ):
#上から描画していくので、はみでた部分を差し引いて前のエリアを記述していく
_yofs -= MAP_HEIGHT
_area -= 1
_xofs = _area_x[_area] + (_xp*8)
_ddx = _area_x[_area] & 0x07
_maptblTop = ( int( _yofs / 8) * CHAR_X_MAX ) + int( _xofs / 8 )
_coff = _tbl.maptbl[_maptblTop]
#8x8キャラクタセット(16色なので4bit使用、1バイトで2ドット分)
for _cy in range(CHAR_HEIGHT):
for _cx in range(CHAR_WIDTH):
pyxel.pset( _xp*8+_cx*2+0 - _ddx, _yp*8+_cy - _ddy, MAPCOLOFS + ( _tbl.chartbl[_coff][_cy*CHAR_WIDTH+_cx] >> 4 ) )
pyxel.pset( _xp*8+_cx*2+1 - _ddx, _yp*8+_cy - _ddy, MAPCOLOFS + ( _tbl.chartbl[_coff][_cy*CHAR_WIDTH+_cx] & 0x0f ) )
if( _startup != 0 ):
_startup = 0
#----------
#入力(キーボード&ジョイパッド)
#上
def getInputUP():
if pyxel.btn(pyxel.KEY_UP) or pyxel.btn(pyxel.GAMEPAD1_BUTTON_DPAD_UP):
return 1
else:
return 0
#下
def getInputDOWN():
if pyxel.btn(pyxel.KEY_DOWN) or pyxel.btn(pyxel.GAMEPAD1_BUTTON_DPAD_DOWN):
return 1
else:
return 0
#----------
#INIT&RUN
pyxel.init(SCREEN_WIDTH, SCREEN_HEIGHT)
#リソース読み込み
pyxel.load("xevi.pyxres")
#実行
pyxel.run(update, draw)
上下キーで上下スクロール可能、始端(エリア1)から終端(エリア16)までスクロールできます。
結果
スクロールスピード8での動作は以下

画像サイズは10MBまでなので途中まで・・・
あとがき
実現する方法は他にもあると思うのであくまでも参考程度に。
スプライトとか載せるには処理速度をなんとかする必要があるように思います。
動作確認
上記からさらに操作可能にしたものの動作確認は以下でできます。
HTMLなのでPyxelが無くても動きます。
Xevious Scroll
おまけ
PyxelEditorに関して、
横8ドット縦8ドットのキャラクタデータを作成するイメージデータは1枚で最大1024キャラクタまで登録可能。マップデータに相当するタイルマップはこのイメージデータ番号指定で1枚しか使用できない。
つまり、今回圧縮したキャラクタデータが1024以下でなければPyxelEditor上に置くことができないことになる。今回のキャラクタデータ数は1196だったので、今のままでは無理ということになる。
(※対象となる画像によってデータ数は変動します)
処理速度の軽減にもなると思うので、PyxelEditorのデータに変換して試してみるのも面白いかもしれない。