前編のおさらいと、後編でやること
前編では、Qlik Cloudの標準機能だけでパックマン風の迷路を作りました。ピボットテーブルを盤面に見立て、CSSとボタンと変数で、キャラが壁の中を動くところまでは行けた。でも、こう結論しました——「これはまだゲームじゃない」と。
後編では、ゲームらしくするために、 nebula.jsをつかいます。Qlik公式の、独自ビジュアライゼーション拡張を作るためのフレームワークです。ざっくり言うと「Qlikの標準部品でできないものを、自分でゼロから作る道具」。canvasを1枚持った部品を作り、その中で本物のゲームループを回す。しかも迷路のデータはQlikのデータモデルから読むので、「ただのブラウザゲーム」ではなく「Qlikでやる意味のあるゲーム」になる——ここが後編のキモです。
そして最終的には、Qlikのデータに複数ステージのコースを持たせ、切り替えて遊べるところまで行きます。データがステージを定義し、ゲームはそれを読むだけ。ゲームのコードを1行も変えず、Qlik側のデータを差し替えるだけで迷路が変わる——「ブラウザでゲーム作ればいいじゃん」への最終回答が、ここで立ちます。
先に言っておくと、後編は環境構築で盛大にハマります。ゲームのコードより、そこに行き着くまでが本番の沼でした。
nebula.js って何?
前編の読者向けに一応。Qlikの標準チャート(棒グラフ、ピボット…)は「出来合いの部品」です。便利だけど「パックマン」なんて部品はない。標準の部品でできないものを自作するための公式フレームワークが nebula.js です。
宇宙っぽい単語が3つ出てきます。nebula(星雲=フレームワーク全体)、stardust(星屑=実際に使うAPIライブラリ)、supernova(超新星=作る部品1個)。深い意味はないです。「nebulaフレームワークで、stardustというAPIを使って、supernovaという部品を作る」というノリ。
重要なのは、この部品がQlikのデータに繋がっていること。中身は普通のJS/canvasだけど、データの供給源はQlik。だから第一弾のMazeテーブルを、この部品が読み込める。
環境構築:ここが後編最大の沼だった
やることは「本番テナントは汚さず、ローカルで開発する」。前編で宣言したとおりです。ローカルに開発サーバーを立て、そこからQlik Cloudのデータだけ読みに行く。コードも実行環境も全部ローカル、でもデータは本物。
プロジェクト作成でいきなりコケる
まず環境確認。Node.jsは新しめ(v24以上推奨)が要ります。手元はv26で問題なし。
で、公式の案内をうろ覚えでこう叩いたら404:
npm create @nebula.js/supernova@latest maze-game # ← 存在しないパッケージを探して404
正しくは nebula create を使います:
npx @nebula.js/cli create maze-game
対話で「picassoテンプレート」を聞かれたら、canvasで自前描画するので none(まっさら)を選ぶ。あとは:
cd maze-game
npm install
npm run start
npm run start(=nebula serve)でローカル開発サーバーが立ち上がります。
認証で無限ループ地獄
nebula serveを起動すると、接続方式を3つ聞かれます。Local Engine / Web Integration Id / Client Id。本番テナントのデータを使うので、素直な Web Integration Id を選択。

ここで最初の大沼。テナント管理コンソール → Web で「Web統合」を新規作成し、許可オリジンに開発サーバーのアドレス(自分の環境では http://localhost:8000。ポートは環境で変わるのでアドレスバーを要確認)を登録。発行されたIDを控える。
接続画面には「Engine WebSocket URL」と「Web Integration Id」の2欄。説明文は1本のURL(wss://<tenant>.<region>.qlikcloud.com?qlik-web-integration-id=xxx)を書いてるのに、入力欄は2つに分かれていて混乱しました。正解はURLを?の前で割って2欄に分けて入れる:
- Engine WebSocket URL:
wss://<tenant>.<region>.qlikcloud.com - Web Integration Id: 発行したID本体
これで接続すると……「Select an app」画面が無限にリロードを繰り返す。ログイン済みでも止まらない。
原因はサードパーティCookieのブロックでした。テナントはHTTPSのクラウド、開発サーバーはhttp://localhost。この2つは別オリジンなので、テナントの認証Cookieはlocalhost側から見ると「サードパーティCookie」扱いになり、ブラウザがブロックする。すると「認証状態が読めない→未認証と判断→ログインへ→でもまたCookie読めない」で無限ループ。
そして犯人はSafariでした。 SafariはITP(トラッキング防止)でサードパーティCookieを強力にブロックし、しかも「このサイトだけ許可」の例外設定がしにくい。localhost×クラウドの開発では一番詰むブラウザです。
結論:開発ブラウザはChromeにする。 Chromeなら例外設定が効くので、設定でサードパーティCookieを許可すれば抜けられます。これを知らずにSafariで粘ると無限に溶かします。同じことをやる人は、最初からChromeで。
Chromeに変えて、ログイン済みの状態で再接続したら、今度は「Select an app」がちゃんと出た。第一弾のあの迷路アプリを選ぶと、ローカルの開発プレビューに、本番テナントのデータが繋がった状態が完成。環境構築、ゴールです。長かった。

データを取る:ハイパーキューブ
繋がった直後は「No data targets found」。これは「どのデータを取るか」をまだ定義してないから。3つのファイルを書き換えます。
object-properties.js でハイパーキューブに「X・Y・Cell」の3項目を持たせる(第一弾のピボットと同じく X=次元・Y=次元・Cell=メジャー)。data.js でデータターゲット(次元2・メジャー1)を宣言。index.js で取れたデータをcanvasに描く。
迷路は441行なので、qInitialDataFetch で一度に500行取れば全マス一発で読めます。
右のパネルで X・Y・=Sum(Cell) を割り当てると、canvasに21×21の迷路が静止画で描かれた。第一弾のピボット盤面が、canvas版で再現された瞬間です。ここからはピボットの色塗りじゃなく、自由に絵が描ける世界。
「時間」を手に入れる:ゲームループ
第一弾に無かった「時間」を、requestAnimationFrameのループで獲得します。
function loop(ts) {
// 経過時間ぶんキャラを動かす
draw();
requestAnimationFrame(loop);
}
これで毎フレームdraw()が呼ばれ続ける。第一弾は「ボタンを押した時だけ再描画」だったのが、勝手に回り続ける世界に変わった。プレイヤー(黄色い丸)を描いて、ループの中で動かす。大事なのはクリーンアップで、ループを止める処理を入れないとレイアウト変更のたびに多重起動して重くなる。
移動と壁判定は、まずキーボードで動作確認。ここで前編との落差に笑います。あれだけSet分析と変数フリーズで苦しんだ壁判定が、canvasだと:
function canMove(x, y) {
return walls[y][x] === 0; // 壁でなければ通れる。以上。
}
これだけ。状態も普通のJS変数で持てる。前編の地獄は何だったのか。
操作をどうするか:当初案の見直し
当初は「Qlikのネイティブボタンで変数を書き換え、拡張がそれを監視して動く」という案でした。「Qlikのボタンがcanvasゲームを操縦する」絵は確かに強い。
でも実際に作ってみて、これは実装難度が跳ね上がる上に、開発プレビューだけでは完結せず本番アプリにボタンを置く工程が要る(=本番を汚さない方針と衝突する)と分かった。そこで方針転換。操作はcanvas内に描いた十字ボタンで手堅く済ませ、「Qlikでやる意味」はデータ駆動(迷路をQlikから読む)の方で出すことにしました。操作方法は瑣末な論点で、本質は「データがQlik由来かどうか」だと気づいたからです。
canvasに十字ボタンを描いて、クリック座標との当たり判定で方向をセット。これも数行。
「状態管理」を手に入れる:ドット
前編で「変数だと状態保持が地獄」と切り捨てたドット。canvasだとSetで持つだけ:
const dots = new Set(); // 通路マスに撒く
if (dots.has(k)) { dots.delete(k); score += 10; } // 通ったら消す
if (dots.size === 0) cleared = true; // 全部食べたらクリア
食べる=Setから消す。残り=size。これだけ。前編でQlik変数に状態を持たせようとしてアプリごとフリーズさせた苦労が、嘘のように消えました。canvas(=普通のJS)の状態管理の威力です。
ちなみにこの過程で、開発サーバーが派手に落ちる沼も踏みました。コードを手で継ぎ足したら括弧の対応が崩れて構文エラー→nebula serveがそれをブラウザに通知しようとしてserver.sockWrite is not a functionで自分ごとクラッシュ。一瞬「環境壊れた!?」と焦るけど、正体はただのJS構文ミス。コードは手で継ぎ足すより丸ごと差し替える方が安全、という教訓。
「時間」の本領:敵
パックマンの心臓部、追ってくる敵。前編で「標準に時間が無いから絶対無理」と言った本丸です。
敵に必要なのは、自動で動く(時間)・プレイヤーを追う(AI)・接触でゲームオーバー(判定)。追尾は本格的な経路探索まで作ると重いので、「プレイヤーに近づく方向を選ぶ簡易追尾」にしました。進める4方向のうちプレイヤーとの距離が最短の方向を8割の確率で選び、2割はランダム。この「たまに間抜け」が逃げる隙になり、逃げゲーとして成立する。敵の移動テンポはプレイヤーより少し遅くして、逃げ切れるバランスに。
プレイヤーと敵が別々のテンポで、同時に、自動で動く——これは前編では原理的に不可能だったことです。「時間」を2系統扱えている。
コースをパックマン向きに:データ駆動の予兆
ここで問題発覚。迷路が第一弾用の「一本道・行き止まりだらけ」だと、敵に詰められて即ゲームオーバー。パックマンは回遊できる(ループのある)通路が本質です。
そこでコースを作り替え。完全迷路を生成してから、行き止まりを潰してループを作る(braid)アルゴリズムで、行き止まりゼロ・ループ多数のパックマン向きコースを生成しました。
そしてここが重要。コースはQlik側のMazeデータなので、差し替えるのはQlikアプリのロードスクリプトです。ゲームのコードは1行も触っていない。 Qlik側のデータを差し替えてリロードしただけで、迷路が変わった。ドットの配置も通路から自動生成しているので、コースに自動追従する。
これがまさに「迷路がQlikデータ駆動である」ことの実証です。「ブラウザでゲーム作ればいいじゃん」への回答が、ここで形になった。ゲームロジックとステージデータが分離していて、データはQlikが持つ。この「気づき」が、後半のマルチステージ化にそのまま繋がります。
仕上げ:カクカクを滑らかに
最後に操作感。マスからマスへ瞬間移動する作りだとコマ送りに見える。パックマンらしい滑らかさを出すため、位置をピクセル座標で補間する方式に変更。各キャラは「今のマス→次のマスへの進行度(0→1)」を持ち、毎フレームその間を連続的に動く。交差点に着いた瞬間だけ次の方向を決める(=本家の「先行入力で曲がる」挙動)。dtベースの移動にしてフレームレート非依存に。
これでヌルヌル動くパックマンになりました。1コース分の、ちゃんと遊べるパックマンの完成です。
ここまでで「ゲームとしては完成」。でも、さっきのMazeデータ差し替えで見えたデータ駆動を、もう一段推し進められます。1コースだけじゃなく、Qlikのデータに複数ステージを持たせて、切り替えて遊ぶ。ここからが本企画の到達点です。
設計:Stage列でコースを積む
これまでのMazeテーブルは「Y, X, Cell」の3列=1コース分。ステージ切替には、複数コースをStage列で区別して持たせます。
Stage, Y, X, Cell
1, 0, 0, 1
... ← ステージ1(441行)
2, ... ← ステージ2
3, ... ← ステージ3
3ステージ分、それぞれ別レイアウトのパックマン向きコース(行き止まりゼロ・ループ多数)を生成し、縦に積む。データ部は441行×3=1323行。
ポイントは、ゲーム側は「どのステージを描くか」を選ぶだけで、コースの中身はデータが持つこと。ステージを増やしたければデータに足すだけ。ゲームのコードは不変。これがデータ駆動の本質です。
ハイパーキューブにStageを足す:ここで沼
object-properties.jsのハイパーキューブにStage次元を追加(次元2→3・メジャー1)。data.jsのデータターゲットを次元3に。index.jsの読み取りを列順[Stage, X, Y, Cell]に合わせる。
理屈はこれだけ。でもここで、nebula開発の典型的な沼に2つハマりました。
沼1:真っ白/undefinedエラー
保存すると盤面が真っ白。コンソールにCannot read properties of undefined (reading 'qNum')。
デバッグでmatrix[0]を出すと、3要素しか来ていない。列は[Stage, X, Y, Cell]の4つのはずが、メジャーのCellが欠けている。だからrow[3]がundefined。
さらにqHyperCubeの構造を出してみると:
dims: 3 measures: 1 width: 4 ← 構造は4列と認識している
row0: 3要素 ← なのにデータは3列で来る
構造は4列なのに、取得データが3列で切れている。決定的だったのはqDataPages[0].qAreaを見たとき:
qArea: { qWidth: 3, qHeight: 500 }
qWidth:3。そしてqHeightが、コードで1500に変えたはずなのに500のまま。
沼2:Enable property cache
qHeightが古い値のまま、が答えでした。object-properties.jsのハイパーキューブ定義を変えても、反映されていなかった。
犯人は、nebula開発プレビューの画面にある 「Enable property cache」 というチェックボックス。プロパティ定義をキャッシュしていて、いくらコードを変えても最初に作られたときの定義(qWidth:3, qHeight:500)が使い回されていた。
このチェックを外してリロードしたら、新しい定義が読まれて4列来た。 盤面も復活。
教訓:nebula開発でプロパティを変えても反映されないときは、qAreaのqWidth/qHeightを見て古い定義が生きていないか疑い、Enable property cacheを外す。 デバッグはmatrix[0]だけでなくqAreaまで見ると原因が一発で分かります。この「構造は正しいのにデータが古い」系は、キャッシュを疑えないと延々ハマります。
ステージ切替のロジック
データは3ステージ全部(1323行)来ています。そこから「今のステージのコースだけ」を選んで盤面を作る。
const cells = allRows.filter((r) => r.stage === currentStage);
これだけ。まずcurrentStageを決め打ちで2にしてみると、ちゃんとステージ2の別レイアウトが描かれた。3にすれば3のコース。データもロジックも正しいことが確定。あとはこのcurrentStageをUIで変えられるようにするだけ。
UIをどうするか
当初は「Qlik標準のフィルターペインでステージを選ぶ」つもりでした。Qlikのセレクションでゲームが変わる、という一番象徴的な形。
ところが、nebula serveの開発プレビュー(Createモード)には、Qlikのフィルターペインもセレクション操作UIも出ませんでした。Editモードは「このアプリにmaze-game拡張は存在しない」と言う——当然で、本番テナントに拡張を載せていないから。
本物のフィルターペインで見せるには、拡張をビルドして実アプリに載せる=本番テナントを触ることになるので、それはやりたくないところ。
そこで方針変更。ステージ選択ボタンを拡張の中(canvas)に自前で描く。方向ボタンをcanvasに描いたのと同じ発想。canvas下部に「STAGE 1 / 2 / 3」ボタンを置き、押すとbuildStage()でそのコースに作り直す。現在ステージは緑でハイライト。
これなら開発プレビューだけで完結し、本番も汚さない。
それで、これはゲームなのか?
前編の問いに、今度は胸を張って答えられます。ゲームです。 しかも、3ステージを切り替えて遊べる。
- 時間 → ゲームループで敵が自動で追ってくる
- 状態管理 → ドットを
Setで食べて消す、スコア、ゲームオーバー - 自由な描画 → canvasでヌルヌル動く
- そしてQlikでやる意味 → 迷路はQlikのデータモデルから読んでいる。
Stage列で複数コースを持ち、切り替えられる
STAGEボタンを押すと、迷路が別レイアウトに切り替わる。ステージ1・2・3、それぞれ違うコースをパックマンで攻略できる。整理すると
- 迷路のコースはQlikのデータ(Stage列で複数保持)
- ゲームロジックはそれを読むだけ(コース追加はデータに足すだけ、コード不変)
- 切替はセレクションと等価(実アプリならフィルターペイン、開発では自前ボタン)
- 中身は普通のcanvasゲーム、でもステージの定義はQlik
前編で「BIツールの構造的限界」と呼んだ壁は、nebula.jsで拡張機能を書くことで全部越えられた。しかも「中身は普通のcanvasゲーム、でもデータ源はQlik」という形を保ったまま。このゲームは、Qlikアプリの中で、Qlikのデータをステージ定義として動いている。 データを差し替えればゲームが変わる。これはQlikでやるからこそ意味がある形です。
前編・後編を終えて
前編「標準機能だとここが限界」、後編「nebula.jsで限界を越え、Qlikデータでステージを駆動する」。BIツールを本来の用途から思い切り逸脱させて、最後はちゃんとBIに帰ってきました。
途中、CSSがpaddingを通さず、変数フリーズでアプリが固まり、SafariのCookieで認証地獄に落ち、property cacheで半日溶かした。全部、同じことをやる人が必ず踏む沼です。踏み抜いた記録が、誰かの近道になれば。
BIツールでパックマンを動かす——業務では一生使わない技術ですが、Qlikの拡張機能・ハイパーキューブ・セレクション・データ駆動という、実務でも効く概念を、遊びながら全部触れた企画でした。認証で半日溶かしたのも含めて、週末の工作としては上出来だったと思います。


