はじめに
この記事はPythonでレトロゲームが作れる「Pyxel」のアドベントカレンダー、「Pyxel Advent Calendar 2025」の14日目の記事です。よろしくお願いします。
数年前から TIC-80 のようなレトロ風なミニゲームエンジンを使ったりコミットしたりしていましたが、今年はPyxelでミニゲームをいくつか作ったりしました。
今年Pyxelで作った ゲーム は、バイクのアクションゲーム(リンク先には2つありますが内容はほぼ同じ)と、放置ゲームです。
カラーパレットとスプライト画像
Pyxelのカラーパレット は標準では16色のみ用意されていますが、実際には256色まで利用可能です。
また、スプライト画像を扱う Image は、ドット(ピクセル)ごとの色としてカラーパレットを使っているように見受けられます。
(参照: 16色を超える色を使ってのゲーム作り)
実際には Image だけではなく、Pyxelで表示される全ての絵は、そのドット(ピクセル)の色の表現としてカラーパレットを採用しているようです。
この仕組みを使った様々なグラフィックス表現が考えられます。
この記事では以下の2つの表現方法の実現方法に触れます。
- モノクロスプライト
- フェードイン・フェードアウト
Pillowのインストール
準備としてPythonのライブラリ Pillow をインストールします。
Pillowは高度な機能を持つ画像処理ライブラリですが、今回使うのは「利用している色を取得する」機能のみです。
PillowはPyodide対応パッケージなので、Web版のPyxel でも問題なく利用できます。
コマンドラインから以下でインストール可能です。
$ pip3 install Pillow
PNGからのスプライト画像の生成
冒頭で触れた自作ゲームでは、各スプライトはPNG画像として作っています。
使ったツールは Pixquare と Aseprite ですが、話の本筋ではないので詳細は省きます。
PyxelでPNG画像を読み込むには Image.load 関数を使いますが、各ピクセルの色はその時点でのカラーパレットにあるものに変換されます。
ですので、あらかじめPNG画像で使われている色をカラーパレットに登録することで、意図した色のスプライトをPyxelで使うことができます。
PNG画像を生成した段階でカラーパレット情報も特定する手もありますが、自作ゲームでは起動時にカラーパレットを生成しています。
実行速度としても問題ないと判断しています。
これにはPillowの Image.getcolors 関数を利用できます。
この関数は (個数, 色) のリストを返すので、例えば以下のようなクラスを使うことで、与えられた引数のPNG画像に含まれる色をPyxelのカラーパレットに追加できます。
import pyxel
from PIL import Image
class ColorPalette:
MaxColors = 100
def __init__(self, path):
colors = pyxel.colors.to_list()
colors.extend(self._colors(path))
pyxel.colors.from_list(colors)
def _colors(self, path):
rgbs = set()
image = Image.open(path)
cols = image.getcolors(self.MaxColors)
for col in cols:
r, g, b, _ = col[1]
rgbs.add(r * 65536 + g * 256 + b)
return list(rgbs)
コード全体については game01.py を参照して下さい。
ゲームの起動時にこのクラスを用いてカラーパレットを拡張してから、 pyxel.Image.load 関数を呼ぶことで意図した色のスプライトを生成できます。
なお、このコードで読み込むPNGは PNG-24 などのフルカラーのものを想定しています。
PNG-8 などではファイルの中にカラーパレットを持っているので、Pillowでその情報を直接取得するようなコードに変更する必要があります。
モノクロスプライト画像の生成
レースゲームなどでベストスコア時の自機をモノクロで表示したいこともあるかも知れません。
自機スプライトのアセット(PNG画像)生成時に同時にモノクロ用のアセットを作る手もありますが、アセットファイルが増えると作業コストがかかりますし、何より更新忘れなどのミスがこわいです。
なので、モノクロ画像もゲーム起動時に生成するようにしてはどうでしょう。
ただし、カラー画像とモノクロ画像で使われる色の両方合わせて256色が上限なので、PNG画像を作成する際にその制限を越えないよう注意して下さい。
モノクロ画像用カラーパレット
先程の ColorPalette クラスに、モノクロ画像用の色と、カラー色番号→モノクロ色番号へ変換する関数も追加しましょう。
class ColorPalette:
MaxColors = 100
def __init__(self, path):
colors = pyxel.colors.to_list()
self._to_gray = {}
for rgb in self._colors(path):
c_index = len(colors)
colors.append(rgb)
g_index = len(colors)
colors.append(self._make_gray(rgb))
self._to_gray[c_index] = g_index
pyxel.colors.from_list(colors)
def color_to_gray(self, col_index):
if col_index in self._to_gray:
return self._to_gray[col_index]
else:
return col_index
def _make_gray(self, rgb):
r = rgb >> 16
g = (rgb & 0xff00) >> 8
b = rgb & 0xff
v = max(min(int((r + g + b) / 3), 255), 0)
return v * 0x010101
def _colors(self, pathes):
(前と同じなので省略)
追加した関数 ColorPalette.color_to_gray にてカラーの色番号から対応するモノクロの色番号を得られます。
ただし、Pyxelに元からカラーパレットに登録されている色に対してはモノクロ色を生成していないので、元の色番号を返すようにしています。
もちろん、Pyxelに元々ある色も全てモノクロ色を生成するようにもできるので、必要に応じて書き換えて下さい。
これを使ってモノクロスプライトを生成するクラス Sprite も以下のように作ることできます。
import pyxel
class Sprite:
def __init__(self, path, w, h, color_palette):
self.w = w
self.h = h
self.color_palette = color_palette
self.color_image = pyxel.Image(w, h)
self.color_image.load(0, 0, path)
self.gray_image = self._make_gray_image()
def _make_gray_image(self):
image = pyxel.Image(self.w, self.h)
for y in range(self.h):
for x in range(self.w):
c_index = self.color_image.pget(x, y)
g_index = self.color_palette.color_to_gray(c_index)
image.pset(x, y, g_index)
return image
コード全体については game02.py を参照して下さい。
生成した Sprite のインスタンスの color_image プロパティにはカラースプライト画像が、 gray_image プロパティにはモノクロスプライト画像が格納されます。
パレット操作によるフェードインアウト
続いてフェードイン・フェードアウト作例を示します。
冒頭で挙げたゲームでは、こんな感じで、日の切り替わり時に使っています。
と言ってもカラーパレットで実現するので、透明度の変更とかはできませんし、そもそもPyxelで半透明表現は大変そうです。
ここでは徐々に画面全体を暗くしてから徐々に元に戻す表現にします。
カラーパレットの元の色を覚えておいて、フレーム更新に応じてカラーパレットのRGBの数値に明るさを乗算してあげれば実現できます。
ColorPalette クラスの該当部分はこんな感じで実現できます。
self._init_colors に元の色を格納しておいて、set_brightness 関数の引数に 0.0〜1.0 の数値を与えると明るさを変更できます。
(0.0が真っ黒で1.0が元の色)
class ColorPalette:
MaxColors = 100
def __init__(self, path):
colors = pyxel.colors.to_list()
self._to_gray = {}
for rgb in self._colors(path):
c_index = len(colors)
colors.append(rgb)
g_index = len(colors)
colors.append(self._make_gray(rgb))
self._to_gray[c_index] = g_index
self._init_colors = colors # これを追加
pyxel.colors.from_list(colors)
def set_brightness(self, ratio):
cols = []
for ocol in self._init_colors:
r = int((ocol >> 16) * ratio)
g = int(((ocol & 0xff00) >> 8) * ratio)
b = int((ocol & 0xff) * ratio)
col = (r << 16) + (g << 8) + b
cols.append(col)
pyxel.colors.from_list(cols)
ここでは暗転表現としましたが、もちろん白飛び表現もほぼ同じ方法で実現できます。
ゲームループからはこんな感じで呼び出せば良いです。
update 関数内の self.color_palette.set_brightness(bright_ratio) で明るさの変更をしています。
class App:
def __init__(self):
pyxel.init(128, 128)
img_path = "girl.png"
self.color_palette = ColorPalette(img_path)
self.sprite = Sprite(img_path, 64, 128, self.color_palette)
self.x0 = 0
self.y0 = 0
self.x1 = 64
self.y1 = 0
self.tic = 0
pyxel.run(self.update, self.show)
def update(self):
self.tic += 1
self.y0 = 5 * math.sin(self.tic * math.pi / 30)
self.y1 = -5 * math.sin(self.tic * math.pi / 30)
bright_ratio = (math.sin(self.tic * math.pi / 60) + 1) / 2
self.color_palette.set_brightness(bright_ratio)
def show(self):
pyxel.blt(self.x0, self.y0, self.sprite.color_image, 0, 0, 64, 128)
pyxel.blt(self.x1, self.y1, self.sprite.gray_image, 0, 0, 64, 128)
コード全体については game03.py を参照して下さい。
これを動かすとこんな感じになります。
さいごに
私にとってもPyxelはまだまだ知らないことだらけで、ブログやXやDiscordなどで使い方を色々教えてもらいながら楽しんでいます。
もしこの記事を読んでいる方の中で情報発信をして下さった方がいらしたら改めてお礼を言わせて下さい。
まったり楽しんでいきましょ。