flix_ge_camera / flix_game_engine

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

星空に浮かぶアリーナの上で四角い箱(box。このページの主役)を動かすと、 カメラが少し遅れて追いかけ、床と二層の星が違う速さで流れ、端まで行くとカメラだけが止まる。 +/− ボタンで滑らかに寄り引きし、引き切るとアリーナの全景になる — ページ内のデモで実際に触れます。 本物のゲームの画面右下にはさらに小窓(PiP)があって、 プレイヤー周辺の絵が縮小されて映ります(デモの右下でも動きます。しくみはその6)。 この全部を作っているのは、ゲーム側に書いた「箱を追いかける」小さな計算設定の数行だけ。残りの共通の仕事はエンジンが裏で引き受けています。 それがどう動いているのか、実際のコードと図を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)。実際の窓が何ピクセルでも、ゲーム内の計算はこのサイズで行い、 エンジンが窓に合わせて拡大します。以降の「screen 座標」はこの design サイズ基準の 画面上の位置のことです(Unity の Pixel Perfect Camera の reference resolution に相当)。

そもそもカメラとは何か — 裏で起きていること エンジン内部

2D ゲームのカメラには、レンズも本体もありません。実体は 「world のどの点を画面中央に映すか」の座標1つ(cam)と、 「どこまで寄るか」の倍率1つ(zoom)だけ。 カメラと呼べる物は、この2つの値のほかに存在しません。

では cam が動くと、何が起きているのか。画面(枠)そのものは1ミリも動かせないので、 「カメラが動く」は実は錯覚です。本当に起きているのは 「描く物すべてを反対方向にずらす」こと — 停車中の電車で、隣の電車が動くと自分が動いた気がするのと同じで、 枠が固定でも世界側を逆にずらせば「視点が動いた」ように見えます。

頭の中のイメージ: カメラ(窓)が世界の上を動く box(world に固定) cam 窓が右へ…のつもり 「カメラが右へ動く」と感じている でも枠は 動かせない 実際に起きていること: 枠は固定、世界が逆へずれる world − cam 画面(固定) 全描画物の座標から cam を引く = 世界が左へずれる
左のように「カメラという窓が world の上を滑る」と考えてよいが、実機の画面(枠)は動かせない。 そこでエンジンは右のようにすべての描画物の座標から cam を引いて、 世界側を反対方向へずらす。どちらでも見える絵は完全に同じ — これが「カメラが動く」の実体で、式にすると screen = world − cam + 中央 (ズームも入れた完全形は (world − cam) × zoom + 中央。中央 = 画面サイズ/2)。

この「ずらし」を含めて、エンジンが毎フレーム絵を描く直前にやり直している仕事を 順に並べるとこうなります。

① 材料を読む
ゲームの状態から 中心 cam倍率 zoom を読み取る — カメラの材料はこの2つだけ。どちらもただの値
② 行きすぎを直す
「ここより外は映さない」という範囲が決めてあれば、 はみ出さない位置まで中心を内側へ丸める(仕組みはその5
③ 見える範囲を出す
中心と倍率から「world のどの矩形が画面に入るか」を計算して、 絵を組む側へ渡す — 見える分だけ描けばよくなる(使い方はその3
④ 全部ずらす
上の図の変換 (world − cam) × zoom + 中央 を、描く物すべてに一括適用。 画面に貼り付く UI(HUD。+/− ボタンのこと)には掛けない(式はその2その4
つまりカメラの正体は、毎フレームやり直される座標変換です。 保存・復元するような「カメラの状態」はエンジンのどこにもなく、①の材料から毎回導き直します。 ここではまだ名前も式も覚えなくて大丈夫 — この4つの仕事を実際のコードでどう書くかを、 この後の その1〜その5 で1つずつ見ていきます。

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

ゲームと同じロジック(速度 120px/秒・追従率 5.0・32px タイル・カメラ境界・二層の星空・ 右下の PiP ミニビューポート)を 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 に出る)、 −を押し切るとアリーナが星空に浮かぶ全景になる。 縁に沿って歩くと星が床よりゆっくり流れる=視差。 右下の小窓(PiP)は自分を中心にした縮小ビューで、溢れた絵は窓の縁でスパッと切れる (しくみはその6)。

Flix の読み方 — コード片に出てくる特徴的な文法

ここからコードを読んでいきます。このページのコードは Flix (JVM で動く関数型言語)です。見慣れない文法も、 「TypeScript ならこう書くやつ」と対にすればほとんどそのまま読めます。 上段が TypeScript・下段が Flix の対比で押さえましょう。

world#x フィールド参照は #
// TypeScript ならこう
world.x;  world.cam.x;

// Flix はこう
world#x;  world#cam#x
読み方は同じ「world の中の x」。. の代わりに # なだけで、連鎖もできる。
{ x = nx | world } レコード更新
// TypeScript ならこう(スプレッドで「残りはコピー」)
const next = { ...world, x: nx };

// Flix はこう(| の右が土台)
let next = { x = nx | world };
意味も同じ「x だけ差し替えて残りは world のまま」。値は不変なので、 World.step は毎フレームこれで新しい World を作る (丸ごとコピーではなく、変わらない部分は共有される。毎フレーム作っても軽い)。
type alias World = { … } 構造的レコード型
// TypeScript ならこう(TS も形で型が決まる)
type World = { x: number; cam: Vec2 };

// Flix はこう
type alias World = { x = Float64, cam = Vec2.Vec2 }
「名前でなくが同じなら同じ型」は TS と同じ感覚で読める。 違いは、クラス定義がそもそも存在しないこと。
f(center, design = d) 名前渡し引数
// TypeScript ならこう(named args が無いのでオブジェクトで代用)
centerOn(center, { zoom: z, design: d }, items);

// Flix はこう(ラベルが言語機能)
CameraRig.centerOn(center, zoom = z, design = d, items)
実体は「1ラベルのレコードで包む」糖衣 — TS の代用テクがそのまま言語公式になった形。 同じ型(Vec2 同士など)の引数が並んでも、位置でなくラベルで区別できる。
|> パイプ演算子
// TypeScript ならこう(メソッドチェーン。ただしクラスに
// メソッドとして生やした関数しか繋げない)
App.makeWith(init).addSystem(step).launch();

// Flix はこう(ただの関数を何でも繋げる)
App.makeWith(init)
    |> App.addSystem(step)
    |> App.launch
x |> ff(x)。読み味はメソッドチェーンと同じだが、 クラス定義に手を入れずに任意の関数を挟める(TS では TC39 提案止まりの機能)。 Main.flix のビルダー連結はこれ。
def speed(): Float64 = 120.0 定数も0引数関数
// TypeScript ならこう(モジュール直下に変数を置ける)
const SPEED = 120;
world.x + SPEED * dt;

// Flix はこう(トップレベル変数が無い → 引数なしの関数)
pub def speed(): Float64 = 120.0
world#x + speed() * dt
呼ぶ側にも () が付くのが目印。
\ IO 効果システム(Flix の看板機能)
// TypeScript ではこの保証が書けない — 型は「純粋」を約束しない
function step(w: World): World {
  fetch("https://…");   // 型には何の痕跡も残らない
  return { ...w, x: w.x + 1 };
}

// Flix — 何も書かなければ純粋だとコンパイラが保証
pub def step(input: Input, dt: Float64, world: World): World = …
def main(): Unit \ IO = …  // 外に触れるなら \ IO と書くしかない
一番 TS に無いもの。「async が signature に現れる」感覚の一般化で、World.step や View.frame が純粋なのは規約ではなく。main だけが run { … } with … で 窓・時計・描画の副作用を引き受ける(with は「IO を実際に誰がやるか」の 実装を差し込む構文。DI コンテナに近い)。
forM (…) yield for 内包(いわゆるモナド内包)
// TypeScript ならこう(flatMap + map の入れ子)
rows.flatMap(r => cols.map(c => tile(c, r)));

// Flix はこう(Scala の for 内包表記と同じ)
forM (r <- rows; c <- cols) yield tile(c, r)
二重ループのリスト生成。flatMap を持つ型なら何にでも使える糖衣 (この仕組みの名前が「モナド」)。その3の背景タイルで登場する。
x :: xsxs ::: ysNil リストの組み立て
// TypeScript ならこう(スプレッドで連結)
const items = [...background(v), player(w)];

// Flix はこう(:: は先頭に1つ足す・::: は連結・Nil は [])
background(v) ::: player(w) :: Nil
List は配列ではなく連結リストで、先頭に足すのが基本操作。 その2の View.frame がこの形で「絵の列」を組む。
OptionSomeNone 「無いかもしれない」値
// TypeScript ならこう(null で「無い」を表す)
function starAt(…): Star | null { … }

// Flix はこう(null は無い。Option 型で包む)
def starAt(…): Option[Star] = if (…) None else Some(star)
T | null 相当。「無い」も普通の値なので、チェック漏れがコンパイルで落ちる。 その5の星撒きで登場する。
w -> w#zoom ラムダ(無名関数)
// TypeScript ならこう
(w) => w.zoom;

// Flix はこう(=> でなく ->。_ は「引数を使わない」)
w -> w#zoom
_ -> World.cameraBounds()
矢印が1本違うだけ。Main.flix が withZoomwithCameraBounds に渡している小さな関数がこれ。

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

Main.flix は App.addSystem(Controls.step)App.withView(View.frame) と 「何を呼んでほしいか」を宣言しているだけで、渡した関数はエンジンが毎フレームこの順で呼びます (Controls.step の中で inputOfWorld.step と繋がる)。 状態は World というただのレコードで、毎フレーム 「入力で次の World を作る → World を絵に写す」の繰り返し。 「そもそもカメラとは」で見た4つの仕事は、 最後の「絵に写す」の内部で起きていることです。

キー入力
App(エンジン側)
押されているキーの集合と経過時間 dt を Frame にまとめる
入力を軸値へ
Controls.inputOf
WASD と矢印を意図の表(InputMap)で束ね、x / 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;
    let cam = world#cam;
    { 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 追従仕様はエンジンの CameraRig(カメラ計算の道具箱モジュール)に Follow として宣言する。k × dt ≒ 1フレームで詰める割合(60fps なら約 8%。 Godot の Camera2D position_smoothing_speed と同じ役どころ)。内部の min(1, k×dt) は 処理落ちで dt が大きくなっても行き過ぎない安全弁。
3 followDelta は「残りの距離 × min(1, k×dt)」の移動量を返す — deadzone = 0 なら cam + (box − cam) × t の lerp そのもの。 残り距離に比例して詰めるので、離れるほど速く・近づくほどゆっくり追いつく。 キーを離すと box が止まり、cam だけが寄っていくので box は画面中央へ戻る。 deadzone を広げると「帯の中ではカメラを動かさない」遊びも作れる (Godot の drag margin 相当)。

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

エンジンの 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(App.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 に繋いだ絵だけはずらされない。Godot の CanvasLayer、 Unity の Screen Space Canvas 相当)。
3 エンジンの中身は今も screen = world − cam + 画面サイズ/2 の引き算1つ。 cam が右へ 10 動くと、すべての screen 座標が左へ 10 ずれる — これが「背景が逆方向に流れる」理由。

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

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

View.background — 可視範囲のタイルを列挙flix
// 「見えている矩形」は frame が ctx#visible として受け取り、
// そのまま渡ってくる。cam も design もこの関数は知らない
def background(visible: Rect2.Rect2): List[Render.PlacedItem] =
    let ts = tileSize();  // 32px。tileIndex ≒ floor(切り下げて整数のタイル番号に)
    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 ほか

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

クリック → 目標 → 補間 → 合成flix
// Controls.flix — クリック判定。エンジンの Frame には
// cursor(screen 座標)と mouseClicked(押した瞬間だけ true)が焼かれている
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)   // +/− ボタンの絵(画面固定)

// エンジン側(composeScene)。その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押した瞬間だけ true。 押しっぱなしでは false のままなので、連打にならない。
3 ボタンが変えるのは zoomTarget だけで、実際の zoom は毎フレーム目標へ lerp — カメラ追従とまったく同じ手口なので、 カチッと切り替わらずぬるっと寄る
4 エンジンの仕事は その2 の引き算に ×zoom を掛けるだけ。位置も大きさも 同じ倍率で伸縮し、ctx#visible(見えている範囲)も design/zoom に狭まるので、 背景タイルの敷き詰めは何も変えずにズームに追従する。
エンジン側の安全装置: ① zoom には防波堤 CameraRig.safeZoom があり、0 以下・NaN・Infinity のような 不正な倍率は安全な範囲(0.01〜1000)へ丸められる — 1.0 / 距離 のような うっかりゼロ除算でも画面が壊れない(ゲーム側の事前検査は不要)。 ② 逆変換 toWorldPos(screen → world = (screen − 中央) ÷ zoom + cam)もあり、 定番の「ズーム中に world の物をクリックする」が正しく書ける。 順変換で screen へ移した点を逆変換に通すと元の world 座標へ戻ることは エンジンのテストが保証している。

その5: 端で止まるカメラと星空の視差 — ショーケースの完成 src/Main.flix ほか

仕上げの2機能です。境界(withCameraBounds)は 「映してよい world の矩形」の宣言で、視界の縁がそこを出ないようカメラが止まる (Godot の Camera2D limits、Unity の Cinemachine Confiner 相当)。 視差(addParallaxLayer)はカメラの動きに factor を掛けた速さで流れる 背景レイヤ(Godot の ParallaxLayer motion_scale 相当)。 ゲーム側の追加はどちらも 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のタイル敷き詰めと同じに書ける。
3 実際に踏んだ教訓: 最初 bounds をアリーナぴったりにしたら、 「端の外を映さない」が完璧に働いて星空が一生映らなかった。 境界は機能であると同時にレベルデザイン — 「何を見せるための余白か」まで決めて初めて完成する。
4 星は乱数ではなくセル番号のハッシュ(Hash01)。 どこへ動いても・何度戻っても同じ空が再現されるので、リプレイや エンジンの時間巻き戻しデバッグ機能(F8 キー)とも矛盾しない。

その6: PiP ミニビューポート — 同じ世界を2台目のカメラで描き直して、窓で切る src/View.flix

本物のゲームの画面右下には小窓(PiP = Picture in Picture)があり、 プレイヤー周辺の world がそのまま縮小されて映っています — 上の再現デモの右下でも同じものが動いているので、動かしながら読めます。 専用のミニマップ画像はどこにもありません。 「そもそもカメラとは」で見たとおりカメラの正体は座標変換なので、 メイン画面と同じ描画部品の列に、もう1つ別の変換(プレイヤー中心・0.2倍)を 掛け直せば、それだけで「2台目のカメラ」になります。 本物と同じ部品を映すので、地図と実物が食い違う事故も起きません。

ただし新しい問題が1つ。窓が見る world は 480×320、アリーナは 1024×768 なので、 縮小した中身は必ず窓から溢れますその3では溢れを「描く前に選別する」 (見えるタイルだけ組む = culling)ことで解決しましたが、タイルは 32px 単位でしか 増減できないので、窓の縁「ぴったり」の始末はこの手では作れません。 そこで出番なのがエンジン v0.4.0 の新機能 clipRender.clipped / Render.clippedAll)— 「描いてから、指定した矩形の縁でピクセルを切り落とす」道具です。 選別は「描く量を減らす節約」、clip は「縁の正確な始末」。役割の違う2つを重ねて使います。

View.pip — 2台目のカメラ + clipflix
// 窓: 画面右下 96×64。0.2倍 → world 480×320 が見える
def pipScale(): Float64 = 0.2

// PiP 一式 = 下地(額縁)+ 窓で切り抜いた中身
def pip(design, world): List[Render.PlacedItem] =
    let r = pipRect(design);  // 右下の窓の矩形
    let backdrop = 虚空色の箱 |> Render.outline(…);  4
    backdrop :: Render.clippedAll(r, pipContent(r, world)) 3

// 中身: 同じ部品の列に、もう一度別の変換を掛ける
def pipContent(r, world): List[Render.PlacedItem] =
    let s = pipScale();
    let winCenter = 窓の中心;
    let playerPos = { x = world#x, y = world#y };
    let items = background(worldView)                1
                    ::: rim() :: player(world) :: Nil;
    items |> List.map(p ->
        { at = Vec2.add(winCenter,                     2
                  Vec2.mul(Vec2.sub(p#at, playerPos), s)),
          item = p#item |> Render.scaled(s)
                        |> Render.raiseZ(pipLift()) })

// その2の式:  screen = (world − cam)    × zoom + 画面中央
// PiP の式:   screen = (world − player) × 0.2  + 窓の中心
//             — 中心とずらし先が違うだけの、同じ変換
実行中の画面。プレイヤーの箱がアリーナ左端の近くに立ち、画面の左側にはアリーナの外の星空が見えている。右下の PiP 小窓にはプレイヤーを中心にした縮小ビューが映り、縮小タイルが窓の途中でまっすぐ途切れて左側が虚空になっている
実際の画面(make bake が焼いたスクリーンショット)。アリーナの左端に立つと、 PiP の中の縮小タイルも窓の途中でスパッと途切れて虚空に変わる — これが clip の切り口。
1 中身の材料はメイン画面と同じ関数 — 床タイルの background(その3)・縁取りの rimplayerworldView は「窓が見る world 480×320」で、タイルの選別もその3のまま再利用。
2 2台目のカメラの変換 窓の中心 + (world − player位置) × 0.2。 その2の world − cam + 画面中央 と見比べると、 中心にする点とずらし先が変わっただけの同じ引き算。 大きさも Render.scaled で同じ倍率に縮める(その4の ×zoom と同じ役)。
3 変換しただけでは、溢れた縮小タイルが窓の外(HUD のボタンの上)まで 敷き詰められてしまう。Render.clippedAll(r, …) が窓の矩形 r の縁で ピクセルを切り落とす。
4 下地(額縁)だけは clip のに置く — 一緒に切ると枠線の外側半分が消えてしまうから。
PiP に星空が映らないのはなぜ? 手抜きではなく設計判断です。 視差の星(その5)は factor を掛けた「見かけのカメラ」で流れる カメラ相対の演出で、「world のどこ」という住所を持ちません — メインカメラと PiP のカメラでは、同じ星が別の場所に見えてしまいます。 PiP は「いま world のどこに何があるか」という事実だけを映す窓なので、 見るカメラによって場所が変わる演出は映さない、と決めています。

まとめ — 仕掛けの分担

毎フレームの分担はこの4つ:

境界と視差も同じ流儀で、World は何も知らず Main の宣言だけが増える。 PiP(その6)も World には触れず、View が同じ部品にもう1つの変換を掛けて clip で切るだけ。 カメラという特別なオブジェクトは存在せず、World の中の座標2つと倍率1つ、宣言数行だけで 「追いかけ・止まり・寄り引きし・奥行きが出る」カメラ一式が動いています。 追従もズームも同じ lerp の手口なのがミソです。