flix_ge_camera / flix_game_engine

なぜ box が動き、カメラが追いかけ、背景が流れるのか

星空に浮かぶアリーナの上で box を動かすと、カメラが少し遅れて追いかけ、床と二層の星が 違う速さで流れ、端まで行くとカメラだけが止まる。+/− ボタンで滑らかに寄り引きし、 引き切るとアリーナの全景になる。この全部を作っているのは World.flix の追従・補間計算Main.flix の宣言数行 だけです (エンジン 0.1.4 で座標変換 → 0.1.5 で可視範囲 → 0.1.6 でマウスとズーム → 0.1.7 で防波堤と逆変換 → 0.1.8 で境界と視差、と段階的にエンジン側へ移りました)。 実際のコードと図を1行ずつ対比しながら解説します。

まず基礎用語 — この5つが分かれば読める

以降の解説で繰り返し出てくる言葉を先にまとめます。特に dt はすべての計算に登場します。

フレーム frame
ゲームはパラパラ漫画のように1秒間に約60回、画面を描き直しています。 その1回分が「1フレーム」。毎フレーム「キーを見る → 状態を進める → 描く」を繰り返すのが ゲームループです。
dt delta time(デルタタイム)
前のフレームから今のフレームまでに実際に経った時間(秒)。60fps なら約 0.0167 秒。 「1フレームで何px動かす」と書くと PC の速さで移動速度が変わってしまうので、 速度(px/秒) × dt の形で書きます。こうすると重い PC でも軽い PC でも 「1秒で 120px」という速さは同じになります。
world 座標 / screen 座標 world / screen
world 座標は「ゲームの世界の中の住所」。無限に広く、box は (520, 280) のような 世界の中の位置を持ちます。screen 座標は「画面のどこに描くか」。 画面は 320×240 しかないので、world の一部を切り取って screen に写します。 この切り取り窓の位置を決めるのがカメラです。
カメラ camera / cam
「world のどの点を画面の中央に映すか」を表す1つの座標。 このゲームではレンズも何もない、ただの { x, y } です。 cam が右へ動けば、切り取り窓が右へずれるので、映っている物は左へ流れて見えます。
lerp linear interpolation(線形補間)
2点の間を割合 t で混ぜる計算 a + (b − a) × t。 t = 0 なら a のまま、t = 1 なら b に一致、t = 0.1 なら「a から b へ 10% だけ近づいた点」。 毎フレームこれを繰り返すと「残り距離の一定割合ずつ詰める」なめらかな追従になります。
viewport / design サイズ viewport / design
ゲームの論理的な画面サイズ(このプロジェクトでは 320×240。project.json の designWidth/Height)。実際の窓が何ピクセルでも、ゲーム内の計算はこのサイズで行い、 エンジンが窓に合わせて拡大します。

Flix の読み方 — コード片に出てくる尖った文法

このページのコードは Flix(JVM で動く関数型言語)です。一般的な言語と 見た目が違うところだけ先に押さえれば、あとは普通に読めます。8個だけです。

world#x フィールド参照は #
.(ドット)ではなく # でレコードの中身を取る。 world#cam#x のように連鎖もできる。
{ x = nx | world } レコード更新
「x だけ差し替えて、残りは world のまま」。| の右が土台。 値は不変なので「書き換える」のではなく毎回新しい World を作る — World.step が毎フレームやっているのはこれ。
type alias World = { … } 構造的レコード型
型は名前でなくで決まる。World もただの別名(alias)で、同じ形のレコードなら 同じ型として通る。クラス定義は存在しない。
f(center, design = d) 名前渡し引数
同じ型(Vec2 同士など)の引数が並ぶと取り違えるので、ラベルを必須にできる。 実体は「1ラベルのレコードで包む」糖衣構文。エンジンの camera API が多用している。
|> パイプ演算子
x |> f |> gg(f(x))。Main の App.makeWith(…) |> App.addSystem(…) |> … はこれでビルダーを繋いでいる。
def speed(): Float64 = 120.0 定数も0引数関数
トップレベルに変数は置けないため、定数は引数なしの関数として書く。 呼ぶときも speed() と括弧が付く。
\ IO\ ef 効果システム(Flix の看板機能)
関数型の後ろの \ … は「外の世界に触れるか」の宣言で、 何も書かなければ純粋だとコンパイラが保証する。World.step や View.frame が 純粋なのは規約ではなく型。main だけが run { … } with … で ハンドラを張り、窓・時計・描画の副作用を引き受ける。
forM (…) yield モナド内包
Scala の for 内包表記と同じ。List はモナドなので forM (r <- rows; c <- cols) yield tile(…) が 二重ループのリスト生成になる(その3の背景タイルで使用)。

次に触ってみる — ブラウザ上の再現デモ

ゲームと同じロジック(速度 120px/秒・追従率 5.0・32px タイル・カメラ境界・二層の星空)を JavaScript で再現したものです。「カメラ追従」を切ると、同じ入力でも box がすぐ画面外へ出てしまうことが分かります。

いまの状態(World の中身)

box (160.0, 120.0)
cam (160.0, 120.0)
zoom 1.00 → 目標 1.00
画面上の box (160.0, 120.0)

キャンバスをクリックしてから (または WASD)で移動。 右上の +/− で寄り引き。端まで歩くとリングの向こうに星の余白が見えてカメラが止まり (⛔限界 が readout に出る)、−を押し切るとアリーナが星空に浮かぶ全景になる。 縁に沿って歩くと星が床よりゆっくり流れる=視差。

全体の流れ — 毎フレームこの順で関数が呼ばれる

Main.flix はこのパイプラインを宣言しているだけです。状態は World という ただのレコードで、毎フレーム「入力で次の World を作る → World を絵に写す」を繰り返します。

キー入力
App(エンジン側)
押されているキーの集合と経過時間 dt を Frame にまとめる
入力を軸値へ
Controls.inputOf
← → を x = −1/0/+1、↑ ↓ を y = −1/0/+1 に畳む(変更なし)
状態を1歩進める
World.step
box を動かし、cam を box へ寄せる(今回の本体その1)
絵に写す
View.frame
world 座標 → 画面座標へ変換して描く(今回の本体その2)

その1: box は入力どおり、カメラは lerp で遅れて追う src/World.flix

World には box の位置 x, y と、カメラの中心 cam が入っています。 box とカメラを別々の速さで動かすのがポイントです。

World.step — 毎フレーム呼ばれるflix
// 追従仕様: deadzone = 不感帯の幅, k = 1秒あたりの寄せ率
pub def follow(): CameraRig.Follow =
    { deadzone = 0.0, k = 5.0 }                2

pub def step(input, dt, world): World =
    let nx = world#x + input#x * speed() * dt;   1
    let ny = world#y + input#y * speed() * dt;
    { x = nx, y = ny,
      cam = { x = cam#x + CameraRig.followDelta(  3
                    follow(), dt, nx, cam#x),
              y = cam#y + CameraRig.followDelta(
                    follow(), dt, ny, cam#y) }
      | world }

// エンジン側(CameraRig.followDelta)。deadzone=0 なら実質:
//   (target − current) × min(1, k×dt)   ← lerp そのもの
cam(画面中央に映る点) (nx − cam) × t box (nx, ny) 1 3 毎フレーム、残り距離の t 割だけカメラが box に近づく
1 box は入力そのまま 速度 120px/秒 × dt だけ進む。
2 追従仕様はエンジン(0.1.4)の CameraRig.Follow で宣言する。 k × dt ≒ 1フレームで詰める割合(60fps なら約 8%)。内部の min(1, k×dt) は 処理落ちで dt が大きくなっても行き過ぎない安全弁。
3 followDelta は「残りの距離 × min(1, k×dt)」の移動量を返す — deadzone = 0 なら cam + (box − cam) × t の lerp(線形補間)そのもの。 残り距離に比例して詰めるので、離れるほど速く・近づくほどゆっくり追いつく。 キーを離すと box が止まり、cam だけが寄っていくので box は画面中央へ戻る。 deadzone を広げると「帯の中では据え置き」の Godot 風の遊びも作れる。

その2: 「カメラが動く」の正体は座標の引き算 src/Main.flix + エンジン

エンジン 0.1.4 から App.withCamera ができました。ゲームは 「world のどこを画面中央に映したいか」を1行で宣言するだけで、 すべての描画物の置き場所から cam を引いて画面中央へずらす仕事はエンジンが引き受けます。 View は world 座標のまま絵を組むだけになりました。

Main.flix / View.flix — カメラは宣言するだけflix
// Main.flix — App にカメラを1行で繋ぐ
App.makeWith(World.initial)
    |> App.addSystem(Controls.step)
    |> App.withView(View.frame)
    |> App.withCamera(View.cameraCenter)      1
    |> App.launch

// View.flix — 変換コードは無い。world 座標で組むだけ。
// ctx(0.1.5 の ViewCtx)から「見えている範囲」も貰える
pub def cameraCenter(world): Vec2.Vec2 = world#cam

pub def frame(ctx: App.ViewCtx, world): List[Render.PlacedItem] = 2
    background(ctx#visible) ::: player(world) :: Nil

// エンジン側(CameraRig.centerOn)。やっていることは:
//   screen = world − cam + 画面サイズ/2      3
world 座標(無限に広い) cam(500,300) box(520,280) 3 screen 座標(320×240) → (160,120) 中央 → (180,100) cam との相対位置だけが画面に残る → cam が動けば全部が逆方向へ流れる
1 withCamera に渡すのは「World → 画面中央に映したい world 座標」の純粋関数。 カメラという特別なオブジェクトは無く、World の cam を返すだけ。
2 View は背景もプレイヤーも world 座標のまま1本のリストで返すだけ。 エンジンが毎フレーム、全描画物に同じずらしを一括適用する(HUD 用の withHudView に繋いだ絵だけはずらされない)。
3 エンジンの中身は今も screen = world − cam + 画面サイズ/2 の引き算1つ。 cam が右へ 10 動くと、すべての screen 座標が左へ 10 ずれる — これが「背景が逆方向に流れる」理由。

その3: 背景は「見える範囲のタイル」だけを毎フレーム計算する src/View.flix

市松模様はどこかに保存されていません。カメラ位置から「いま画面に映るタイル番号」を 割り出して、その場で並べ直しているだけです。だからどこまで移動しても途切れません。

View.background — 可視範囲のタイルを列挙flix
// 0.1.5: 「見えている矩形」は frame が ctx#visible として受け取り、
// そのまま渡ってくる。cam も design もこの関数はもう知らない
def background(visible: Rect2.Rect2): List[Render.PlacedItem] =
    let ts = tileSize();  // 32px
    let col0 = tileIndex(visible#position#x / ts);          1
    let row0 = tileIndex(visible#position#y / ts);
    let cols = tileIndex(visible#size#x / ts) + 2;      2
    let rows = tileIndex(visible#size#y / ts) + 2;
    // List はモナドなので forM がリスト内包として使える(行×列の総当たり)
    forM (r <- List.range(0, rows); c <- List.range(0, cols))
        yield tile(col0 + c, row0 + r)

// 市松の1枚(タイル番号 → world 座標の PlacedItem)
def tile(col: Int32, row: Int32): Render.PlacedItem =
    let ts = tileSize();
    let color = if (Int32.modulo(col + row, 2) == 0)     3
                    tileColorA() else tileColorB();
    { at = { x = Int32.toFloat64(col) * ts, … },            4
      item = Render.box(…) }
画面(cam を中心に 320×240) 1 ↑ (col0, row0) = 画面左上の角を含むタイル 2 端のタイルは半分だけ映る → 左右それぞれ1枚余分に敷いて隙間を防ぐ(+2)
1 visible は「cam を中央に映したとき画面に見える world の矩形」 (左上 = cam − design/2、大きさ = design)。エンジンが計算して ViewCtx で渡してくる。 その左上をタイル辺 32 で割って床関数に通すと「左端が乗っているタイルの番号」になる。
2 320÷32 = 10 枚で幅は覆えるが、スクロール中は左右とも端が欠けるので +2 枚の余白を持たせる。
3 (col + row) が偶数かで2色を交互に。番号は world に固定なので、 カメラが動いても模様は地面に貼り付いたままになる。
4 置き場所は タイル番号 × 32 の world 座標。あとは その2 の一括変換が画面に映してくれる。
なぜ Int32.modulo なのか: 左や上へ進むとタイル番号は負になります(… −2, −1, 0, 1 …)。 多くの言語の %(剰余)は -3 % 2 == -1 を返すので「== 0 か否か」の交互パターンが 負の側で崩れますが、Flix の Int32.modulo は常に 0 か 1 を返すため、 原点をまたいでも市松が乱れません。

その4: ズームは「目標値・現在値の2段構え」+ 引き算に×zoomを1つ足すだけ src/World.flix ほか

右上の +/− ボタンをクリックすると寄り引きできます(エンジン 0.1.6 で Frame にマウスが、カメラ合成に zoom が入りました)。仕掛けは3層: ボタンは目標値を 1段動かすだけ、現在値は毎フレーム目標へ lerp(カメラ追従と同じ手口)、 エンジンは引き算の式に × zoom を1つ足して大きさも一緒に伸縮します。

クリック → 目標 → 補間 → 合成 の4段flix
// Controls.flix — クリック判定。0.1.6 の Frame には
// cursor(design 座標)と mouseClicked(エッジ)が焼かれている
pub def zoomButtons(frame, world) =
    let overPlus = Hud.inCircle(                    1
        Hud.plusCenter(frame#viewport), frame#cursor);
    ...
    if (frame#mouseClicked and overPlus)             2
        World.zoomIn(hovered) ...

// World.flix — ボタンは目標値を1段変えるだけ
pub def zoomIn(world) =
    { zoomTarget = clamp(world#zoomTarget * 1.25) | world }

// World.step — 現在値が目標へ lerp(追従カメラと同じ)
zoom = world#zoom + (world#zoomTarget - world#zoom)    3
            * min(1.0, 8.0 * dt)

// Main.flix — エンジンへは宣言1行ずつ
|> App.withZoom(w -> w#zoom)
|> App.withHudView(View.hud)   // +/− ボタンの絵(画面固定)

// エンジン側(composeSceneZoomed)。その2の式に ×zoom が入るだけ:
//   screen = (world − cam) × zoom + 画面サイズ/2   4
//   大きさも × zoom、見えている範囲は design / zoom
zoom = 1(見える範囲 320×240) ↑ zoom=2 で見える範囲 ×2 zoom = 2(見える範囲 160×120) + box もタイルも2倍・HUD ボタンはそのまま 寄ると「見える world の範囲」が狭くなり、映る物は大きくなる。HUD には掛けない
1 当たり判定は「カーソルと円の中心の距離 ≤ 半径」だけ。ボタンは画面に 貼り付いているので、frame#cursor(screen 座標)をそのまま比べられる。
2 mouseClicked はキーの justPressed と同じエッジ (押した瞬間だけ true)。押しっぱなしで連打にならない。
3 ボタンが変えるのは zoomTarget だけで、実際の zoom は毎フレーム目標へ lerp — カメラ追従とまったく同じ手口なので、 カチッと切り替わらずぬるっと寄る
4 エンジンの仕事は その2 の引き算に ×zoom を掛けるだけ。位置も大きさも 同じ倍率で伸縮し、ctx#visible(見えている範囲)も design/zoom に狭まるので、 背景タイルの敷き詰めは何も変えずにズームに追従する。
0.1.7 の仕上げ(レビューで塞いだ端): ① zoom には防波堤 CameraRig.safeZoom が入り、0 以下・NaN・Infinity のような 不正な倍率は安全な範囲(0.01〜1000)へ丸められる — 1.0 / 距離 のような うっかりゼロ除算でも画面が壊れない(ゲーム側の事前検査は不要)。 ② 逆変換 toWorldPosZoomed(screen → world = (screen − 中央) ÷ zoom + cam)が 追加され、次の定番「ズーム中に world の物をクリックする」が正しく書ける。 順変換で screen へ移した点を逆変換に通すと元の world 座標へ戻る(往復不変条件)ことは エンジンのテストが固定している。

その5: 端で止まるカメラと星空の視差 — ショーケースの完成 0.1.8

エンジン 0.1.8 で入った最後の2機能。境界(withCameraBounds)は 「映してよい world の矩形」の宣言で、視界の縁がそこを出ないようカメラが止まる。 視差(addParallaxLayer)はカメラの動きに factor を掛けた速さで流れる 背景レイヤ。ゲーム側の追加はどちらも Main の宣言だけです。

Main / World / View — 宣言と、余白のレベルデザインflix
// Main.flix — 3行足すだけ
|> App.withCameraBounds(_ -> World.cameraBounds()) 1
|> App.addParallaxLayer(0.25, View.farStars)
|> App.addParallaxLayer(0.55, View.nearStars)     2

// World.flix — 映してよい範囲 = アリーナ + 星空の余白
pub def cameraBounds(): Rect2.Rect2 =
    let a = arena();
    let m = 128.0;  // ← 余白ゼロだと星が一生映らない 3
    { position = { x = a#position#x - m, … },
      size = { x = a#size#x + 2.0 * m, … } }

// View.flix — 星はセル番号のハッシュで「決定的に」撒く
def starAt(col, row, …): Option[Render.PlacedItem] =
    let idx = col * 92821 + row * 31 + salt;
    if (Hash01.at(idx, 0) > 0.5) None            4
    else Some(セル内の位置・明るさもハッシュで決めた星)

// エンジン側の芯(どちらも純関数):
//   clampCenter: 視界(design/zoom)が bounds を出ない位置へ center を収める
//   parallaxCenter: lerp(画面中央, center, factor) — 遠いほど動きが小さい
bounds = アリーナ + 余白 128px アリーナ(床 + リング) 視界(bounds の縁で停止) 端に立つと「リング → 星の余白 → 視界の縁」の順に見えて、そこでカメラが止まる
1 bounds は「カメラ中心が動ける範囲」ではなく「映してよい範囲」。 視界の縁で判定するので、ズームで視界が広がると止まる位置は自動で内側に寄る。 矩形が視界より狭い軸は中央固定 — ズームアウトの全景が安定するのはこのルール。
2 factor はカメラに対する速度比。0.25 の遠景はカメラの 1/4 しか動かない =「遠くの物ほどゆっくり流れる」奥行きの錯覚。レイヤの ctx#visible も 見かけのカメラで計算されるので、culling のコードは主レイヤと同じに書ける。
3 実際に踏んだ教訓: 最初 bounds をアリーナぴったりにしたら、 「端の外を映さない」が完璧に働いて星空が一生映らなかった。 境界は機能であると同時にレベルデザイン — 「何を見せるための余白か」まで決めて初めて完成する。
4 星は乱数ではなくセル番号のハッシュ(Hash01)。 どこへ動いても・何度戻っても同じ空が再現されるので、F8 の巻き戻しやリプレイと矛盾しない。

まとめ — 仕掛けの分担

1 box は入力で world を進む(World.step) → 2 cam が followDelta(lerp)で遅れて追う → 3 ボタンが zoomTarget を1段動かし、zoom が lerp で目標へ寄る → 4 エンジンが「(world − cam) × zoom + 中央」で全描画物をずらして伸縮する(HUD は素通し)。 0.1.8 の境界と視差も同じ流儀で、World は何も知らず Main の宣言だけが増える。 カメラという特別なオブジェクトは存在せず、World の中の座標2つと倍率1つ、宣言数行だけで 「追いかけ・止まり・寄り引きし・奥行きが出る」カメラ一式が動いています。 追従もズームも同じ lerp の手口なのがミソです。