Pyxelでキャラクター書き換え背景アニメーション

## ファイナルファンタジー3の海のアニメーションを作ってみる

ファミコン版ファイナルファンタジー3（以下FF3）の海面は、波打つようにアニメーションしています。
（たとえば次の動画の1:29:39あたりから見られます）。

https://youtu.be/EGzTkVTUph0?si=ukSUixWgRotPYXwP&t=5377

それを、Pyxelで真似して作ってみました。

![pyxel-20241223-142136.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/29093/ab3bb2a6-efc9-067e-1956-451b42f240ae.gif)

https://kitao.github.io/pyxel/wasm/launcher/?play=kadoyan.Python_Playground.bg_anime.bg_anime&gamepad=enabled

## しくみ

もとのFF3のアニメーションを見てみると、一ラインごとにタイミングをずらして1pxずつ右に動いているように見えます。

![bg_anime_figure1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/29093/505c118b-130a-8c2d-018a-8f20dd5a3632.png)

左がもとになるキャラクター（Pyxelのイメージバンク）、右が表示した状態（Pyxelのタイルマップ）です。
水面にあたる左の赤枠の部分を何度もマップに配置して海を表現しています。
そのため、元になるイメージバンクの絵を動かしてしまえば、マップにある絵もすべて書き換わる算段です。
FF3も、元になるキャラクターのパターンを書き換えてアニメーションさせているはず。

https://github.com/kadoyan/Python_Playground/tree/main/bg_anime

## コード
指定したイメージバンクの部分を一ラインずつずらして表示するためのクラスです。
```py
self.tileshift = TileShift(0, 1, 40, 16, 1, 16, 16, True)
"""
self.tileshift = TileShift(
    イメージバンク番号,
    ずらす量（正で右、負で左）,
    イメージバンクのX,
    イメージバンクのY,
    何フレームごとに動かすか,
    動かす部分の幅,
    動かす部分の高さ,
    同ずらし動作（Falseで全ライン同時シフト）
    )
"""
```

```py:tile_shift.py
import pyxel

class TileShift:
    def __init__(self, bank, speed_h, u, v, timing, width=8, height=8, delay=False):
        """
        Shift an image cell on an image bank

        Args:
            bank (int): The number of the image bank
            speed_h (int): Scrolling direction (+:left, -:right)
            u (int): X position of the target image on the image bank
            v (int): Y position of the target image on the image bank
            timing (int): Scroll timing (per frame)
            width (int): tile width(px)
            height (int): tile height(px)
            delay: delay shift per lines (Boolean)
        """
        self.screen_ptr = pyxel.images[bank].data_ptr()
        self.speed_h = speed_h
        self.u = u
        self.v = v
        self.timing = timing
        self.width = width
        self.height = height
        self.count = 0
        self.delay = delay
        
    def update(self):
        if self.timing == 0 :
            self.timing = 1
        self.count = (self.count + 1) % self.height
        
        if pyxel.frame_count % self.timing == 0:
            if self.delay:
                y = self.count
                self.shift_line(y)
            else:
                for y in range(0, self.height):
                    self.shift_line(y)

    def shift_line(self, y):
        cell = self.u + (self.v + y) * 256
        source = self.screen_ptr[cell:cell + self.width]
        shifted = self.rotate(source, self.speed_h)
        self.screen_ptr[cell:cell + self.width] = shifted
        
    def rotate(self, list, n):
        if not list: # empty list
            return list
        length = len(list)
        n = n % length
        # n<0: left / n>0: right
        return list[-n:] + list[:-n]
```

`pyxel.images[bank].data_ptr()`で、{bank}番目のイメージバンクの色データをリストで取得できます。
データは一本につながっているので、
```py
        cell = self.u + (self.v + y) * 256
        source = self.screen_ptr[cell:cell + self.width]
```
`横の指定座標u + (縦の指定座標V + y座標) * イメージバンクの幅256`で目的のデータのスタート部分を割り出した後、そこからwidth分データを取り出します。
それを、定義したrotate()で左右にデータをずらし、`self.screen_ptr[cell:cell + self.width] = shifted`で、抜き出した部分に上書きしています。

ずらしありで動かすと、
```py
self.tileshift = TileShift(0, 1, 40, 16, 1, 16, 16, True)
```
![pyxel-20241223-142136.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/29093/ab3bb2a6-efc9-067e-1956-451b42f240ae.gif)

ずらしなしで動かすと、
```py
self.tileshift = TileShift(0, 1, 40, 16, 10, 16, 16, False)
```
![pyxel-20241223-142227.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/29093/8e14fcbf-66c6-7e04-89dc-b4e009c68809.gif)

MSXなど昔のコンピュータ風にいうと、PCG書き換えという感じでしょうか。
うまく使うと、スクロールしていないのにスクロールしているように見える、というような演出が可能です。

## 余談
ツインファミコンで遊んでますが、いよいよクライマックスという段階のデータが消えました。
ファイナルファンタジーもうやめます。

https://x.com/kadoyan/status/1871031713595597114