flix_ge_camera / flix_game_engine
星空に浮かぶアリーナの上で box を動かすと、カメラが少し遅れて追いかけ、床と二層の星が 違う速さで流れ、端まで行くとカメラだけが止まる。+/− ボタンで滑らかに寄り引きし、 引き切るとアリーナの全景になる。この全部を作っているのは World.flix の追従・補間計算 と Main.flix の宣言数行 だけです (エンジン 0.1.4 で座標変換 → 0.1.5 で可視範囲 → 0.1.6 でマウスとズーム → 0.1.7 で防波堤と逆変換 → 0.1.8 で境界と視差、と段階的にエンジン側へ移りました)。 実際のコードと図を1行ずつ対比しながら解説します。
以降の解説で繰り返し出てくる言葉を先にまとめます。特に dt はすべての計算に登場します。
{ x, y } です。
cam が右へ動けば、切り取り窓が右へずれるので、映っている物は左へ流れて見えます。
a + (b − a) × t。
t = 0 なら a のまま、t = 1 なら b に一致、t = 0.1 なら「a から b へ 10% だけ近づいた点」。
毎フレームこれを繰り返すと「残り距離の一定割合ずつ詰める」なめらかな追従になります。
このページのコードは Flix(JVM で動く関数型言語)です。一般的な言語と 見た目が違うところだけ先に押さえれば、あとは普通に読めます。8個だけです。
world#x フィールド参照は #.(ドット)ではなく # でレコードの中身を取る。
world#cam#x のように連鎖もできる。{ x = nx | world } レコード更新| の右が土台。
値は不変なので「書き換える」のではなく毎回新しい World を作る —
World.step が毎フレームやっているのはこれ。type alias World = { … } 構造的レコード型f(center, design = d) 名前渡し引数|> パイプ演算子x |> f |> g は g(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 モナド内包forM (r <- rows; c <- cols) yield tile(…) が
二重ループのリスト生成になる(その3の背景タイルで使用)。ゲームと同じロジック(速度 120px/秒・追従率 5.0・32px タイル・カメラ境界・二層の星空)を JavaScript で再現したものです。「カメラ追従」を切ると、同じ入力でも box がすぐ画面外へ出てしまうことが分かります。
キャンバスをクリックしてから ↑↓←→(または WASD)で移動。 右上の +/− で寄り引き。端まで歩くとリングの向こうに星の余白が見えてカメラが止まり (⛔限界 が readout に出る)、−を押し切るとアリーナが星空に浮かぶ全景になる。 縁に沿って歩くと星が床よりゆっくり流れる=視差。
Main.flix はこのパイプラインを宣言しているだけです。状態は World という
ただのレコードで、毎フレーム「入力で次の World を作る → World を絵に写す」を繰り返します。
World には box の位置 x, y と、カメラの中心 cam が入っています。
box とカメラを別々の速さで動かすのがポイントです。
// 追従仕様: 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 そのもの
速度 120px/秒 × dt だけ進む。CameraRig.Follow で宣言する。
k × dt ≒ 1フレームで詰める割合(60fps なら約 8%)。内部の min(1, k×dt) は
処理落ちで dt が大きくなっても行き過ぎない安全弁。followDelta は「残りの距離 × min(1, k×dt)」の移動量を返す —
deadzone = 0 なら cam + (box − cam) × t の lerp(線形補間)そのもの。
残り距離に比例して詰めるので、離れるほど速く・近づくほどゆっくり追いつく。
キーを離すと box が止まり、cam だけが寄っていくので box は画面中央へ戻る。
deadzone を広げると「帯の中では据え置き」の Godot 風の遊びも作れる。
エンジン 0.1.4 から App.withCamera ができました。ゲームは
「world のどこを画面中央に映したいか」を1行で宣言するだけで、
すべての描画物の置き場所から cam を引いて画面中央へずらす仕事はエンジンが引き受けます。
View は world 座標のまま絵を組むだけになりました。
// 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
withCamera に渡すのは「World → 画面中央に映したい world 座標」の純粋関数。
カメラという特別なオブジェクトは無く、World の cam を返すだけ。withHudView に繋いだ絵だけはずらされない)。screen = world − cam + 画面サイズ/2 の引き算1つ。
cam が右へ 10 動くと、すべての screen 座標が左へ 10 ずれる — これが「背景が逆方向に流れる」理由。
市松模様はどこかに保存されていません。カメラ位置から「いま画面に映るタイル番号」を 割り出して、その場で並べ直しているだけです。だからどこまで移動しても途切れません。
// 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(…) }
visible は「cam を中央に映したとき画面に見える world の矩形」
(左上 = cam − design/2、大きさ = design)。エンジンが計算して ViewCtx で渡してくる。
その左上をタイル辺 32 で割って床関数に通すと「左端が乗っているタイルの番号」になる。+2 枚の余白を持たせる。(col + row) が偶数かで2色を交互に。番号は world に固定なので、
カメラが動いても模様は地面に貼り付いたままになる。タイル番号 × 32 の world 座標。あとは その2 の一括変換が画面に映してくれる。
Int32.modulo なのか:
左や上へ進むとタイル番号は負になります(… −2, −1, 0, 1 …)。
多くの言語の %(剰余)は -3 % 2 == -1 を返すので「== 0 か否か」の交互パターンが
負の側で崩れますが、Flix の Int32.modulo は常に 0 か 1 を返すため、
原点をまたいでも市松が乱れません。
右上の +/− ボタンをクリックすると寄り引きできます(エンジン 0.1.6 で Frame にマウスが、カメラ合成に zoom が入りました)。仕掛けは3層: ボタンは目標値を 1段動かすだけ、現在値は毎フレーム目標へ lerp(カメラ追従と同じ手口)、 エンジンは引き算の式に × zoom を1つ足して大きさも一緒に伸縮します。
// 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
frame#cursor(screen 座標)をそのまま比べられる。mouseClicked はキーの justPressed と同じエッジ
(押した瞬間だけ true)。押しっぱなしで連打にならない。zoomTarget だけで、実際の
zoom は毎フレーム目標へ lerp — カメラ追従とまったく同じ手口なので、
カチッと切り替わらずぬるっと寄る。ctx#visible(見えている範囲)も design/zoom に狭まるので、
背景タイルの敷き詰めは何も変えずにズームに追従する。
CameraRig.safeZoom が入り、0 以下・NaN・Infinity のような
不正な倍率は安全な範囲(0.01〜1000)へ丸められる — 1.0 / 距離 のような
うっかりゼロ除算でも画面が壊れない(ゲーム側の事前検査は不要)。
② 逆変換 toWorldPosZoomed(screen → world = (screen − 中央) ÷ zoom + cam)が
追加され、次の定番「ズーム中に world の物をクリックする」が正しく書ける。
順変換で screen へ移した点を逆変換に通すと元の world 座標へ戻る(往復不変条件)ことは
エンジンのテストが固定している。
エンジン 0.1.8 で入った最後の2機能。境界(withCameraBounds)は 「映してよい world の矩形」の宣言で、視界の縁がそこを出ないようカメラが止まる。 視差(addParallaxLayer)はカメラの動きに factor を掛けた速さで流れる 背景レイヤ。ゲーム側の追加はどちらも Main の宣言だけです。
// 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) — 遠いほど動きが小さい
ctx#visible も
見かけのカメラで計算されるので、culling のコードは主レイヤと同じに書ける。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 の手口なのがミソです。