@uzuhq/engine-2d
v1.1.1
Published
UZU 2D Canvas Game Engine
Readme
@uzuhq/engine-2d
UZU 上で動く 2D ゲームのための Canvas ベースゲームエンジン。レスポンシブ画面サイズ対応、DPI 自動対応。
npm install @uzuhq/engine-2dプロジェクトの雛形は @uzuhq/code-cli の uzu create-2d-game で作成できます。マルチプレイ通信・サウンドには @uzuhq/code-sdk を併用してください。
画面サイズ
重要: ゲームコードで画面サイズをハードコードしてはいけない。必ず engine.width / engine.height を使う。
エンジンはレスポンシブモードで動作し、画面サイズは端末によって変わる:
engine.width: 現在の論理幅(portrait モードでは固定値 390)engine.height: 現在の論理高さ(端末の画面比率により 640〜844 で変動)
// ✅ 正しい — engine.width / engine.height を使う
const centerX = engine.width / 2;
const groundY = engine.height - 60;
engine.add('btn', {
type: 'rect',
x: 30,
y: engine.height - 80,
width: engine.width - 60,
height: 50,
});
// ❌ 間違い — 数値をハードコードしない
const centerX = 195; // NG
const groundY = 784; // NGエンジン設定(開発者が指定)
// デフォルト: 縦画面(何も指定しなくてもこの設定になる)
createEngine({ canvas });
// 明示的に縦画面
createEngine({ canvas, orientation: 'portrait', baseSize: 390, maxLength: 844 });
// 横画面
createEngine({ canvas, orientation: 'landscape', baseSize: 390, maxLength: 844 });
// 固定サイズ(レスポンシブ無効、互換用)
createEngine({ canvas, width: 600, height: 600 });| 設定 | 型 | デフォルト | 説明 |
| ------------- | --------------------------- | ------------ | --------------------------------------------- |
| orientation | 'portrait' \| 'landscape' | 'portrait' | 画面の向き |
| baseSize | number | 390 | 固定軸のサイズ(portrait=幅、landscape=高さ) |
| maxLength | number | 844 | 可変軸の上限 |
レイアウトの指針
| 要素 | 配置方法 |
| -------------------- | --------------------------------------------- |
| 上端固定(スコア等) | y: 20 |
| 下端固定(ボタン等) | y: engine.height - 80 |
| 中央 | x: engine.width / 2, y: engine.height / 2 |
| 全幅ボタン | x: 30, width: engine.width - 60 |
| 画面外から生成 | y: engine.height + 50(下端外) |
Quick Start — 最小ゲーム
export function startMyGame(engine: Engine) {
const W = engine.width;
const H = engine.height;
let score = 0;
engine.addMany([
{
id: 'score',
type: 'text',
x: W / 2,
y: 30,
text: '0',
font: 'bold 24px sans-serif',
fill: '#fff',
align: 'center',
},
{
id: 'target',
type: 'circle',
x: W / 2,
y: H / 2,
radius: 30,
fill: '#ef4444',
onClick: () => {
score++;
engine.update('score', { text: String(score) });
engine.animate(
'target',
{
x: 50 + Math.random() * (W - 100),
y: 100 + Math.random() * (H - 200),
},
{ duration: 200, easing: 'easeOutBack' },
);
},
},
]);
}パターン: Engine を受け取る → engine.width/engine.height で座標計算 → scene() でノード配置 → イベントで状態更新 → update() / animate() でUI反映。
全ゲームがこの構造に従う。
初期化
createEngine(config: EngineConfig): Engine
import { createEngine } from '@uzuhq/engine-2d';
// 最小構成(assets 省略可)
const engine = createEngine({
canvas: document.getElementById('canvas') as HTMLCanvasElement,
});
await engine.ready; // 初期化完了を待つ(assets の有無に関わらず必須)// 画像アセット付き
const engine = createEngine({
canvas: document.getElementById('canvas') as HTMLCanvasElement,
width: 600,
height: 600,
background: '#1a1a2e',
assets: {
images: { player: '/sprites/player.png' },
},
});
await engine.ready; // アセットロード完了を待つEngineConfig:
| プロパティ | 型 | デフォルト | 説明 |
| --------------- | ------------------------ | ------------- | ------------------------- |
| canvas | HTMLCanvasElement | 必須 | 描画先 canvas |
| width | number | canvas.width | デザイン幅 |
| height | number | canvas.height | デザイン高さ |
| background | string | '#000' | 背景色 |
| assets.images | Record<string, string> | — | 初期ロード画像 {key: url} |
ノード管理
ゲームオブジェクトは「ノード」として管理。6種類: rect, circle, text, sprite, line, nineslice。
engine.add(id, config)
ノードを追加。
engine.add('player', { type: 'rect', x: 100, y: 200, width: 30, height: 30, fill: '#3b82f6' });
engine.add('enemy', { type: 'circle', x: 300, y: 100, radius: 20, fill: '#ef4444' });
engine.add('label', {
type: 'text',
x: 300,
y: 50,
text: 'Score: 0',
font: 'bold 16px sans-serif',
fill: '#fff',
align: 'center',
});
// リッチテキスト(インラインカラー)
engine.add('dialog', {
type: 'text',
x: 50,
y: 400,
text: '{c:#7c3aed}長老{/c}「ようこそ」',
font: '16px sans-serif',
fill: '#fff',
richText: true,
});
engine.add('ship', {
type: 'sprite',
x: 100,
y: 100,
image: 'player',
width: 32,
height: 32,
anchor: { x: 0.5, y: 0.5 },
});
// line: (x, y) から (x2, y2) へ線を引く。x2/y2 は設計座標上の絶対位置。
engine.add('wall', { type: 'line', x: 0, y: 300, x2: 600, y2: 300, stroke: '#fff', lineWidth: 2 });
// 斜め線: (100, 50) → (200, 150)
engine.add('diag', { type: 'line', x: 100, y: 50, x2: 200, y2: 150, stroke: '#ff0', lineWidth: 2 });engine.update(id, props)
ノードのプロパティを更新。
engine.update('player', { x: 150, y: 250 });
engine.update('label', { text: 'Score: 100' });engine.remove(id)
ノードを削除(子ノードも再帰的に削除)。
engine.get(id): NodeConfig & { id } | null
ノードの現在の状態を取得。
engine.clear()
全ノード・コールバック・タイマーをクリア。シーン遷移時に自動で呼ばれる。
engine.addMany(nodes)
ノード配列を一括追加。
engine.addMany([
{ id: 'bg', type: 'rect', x: 0, y: 0, width: 600, height: 600, fill: '#000' },
{ id: 'title', type: 'text', x: 300, y: 100, text: 'Hello', fill: '#fff', align: 'center' },
]);engine.addMany(nodes)
scene() と同じ。追加時にクリアしない。
共通ノードプロパティ(NodeBase):
| プロパティ | 型 | デフォルト | 説明 |
| ------------------ | -------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------ |
| x, y | number | 0 | 位置 |
| alpha | number | 1 | 透明度 (0-1) |
| scaleX, scaleY | number | 1 | 拡大率 |
| rotation | number | 0 | 回転 (ラジアン) |
| flipX | boolean | false | 水平反転 |
| flipY | boolean | false | 垂直反転 |
| tint | string | — | カラーティント(CSS color, e.g. '#ff0000') |
| tintAmount | number | 0.5 | ティント強度 (0-1)。animate で補間可能 |
| visible | boolean | true | 表示/非表示 |
| tag | string | — | タグ(グループ化用) |
| clipRect | {x,y,width,height} | — | クリッピング領域(ローカル座標)。子ノードにも適用 |
| parent | string | — | 親ノードID |
| onClick | () => void | — | クリック/タップ時 |
| onDown | () => void | — | ポインタダウン時 |
| onUp | () => void | — | ポインタアップ時 |
| blockInput | boolean | false | true にすると onClick 等が未設定でもヒットテスト対象になり、背面ノードへのイベント貫通を阻止する。モーダルオーバーレイ等に使用 |
TextConfig 追加プロパティ:
| プロパティ | 型 | デフォルト | 説明 |
| ---------- | -------------------- | ---------- | ------------------------------------------------------------------- |
| baseline | CanvasTextBaseline | 'top' | 垂直方向のテキスト基準線。'middle' でボタン等の垂直中央揃えが可能 |
| richText | boolean | false | インラインカラーマークアップを有効化 |
テキスト垂直中央揃えの例:
// ボタン中央にテキストを配置
const btnX = 50,
btnY = 100,
btnW = 200,
btnH = 48;
engine.add('btn-bg', {
type: 'rect',
x: btnX,
y: btnY,
width: btnW,
height: btnH,
fill: '#3b82f6',
radius: 8,
});
engine.add('btn-text', {
type: 'text',
x: btnX + btnW / 2,
y: btnY + btnH / 2,
text: '決定',
font: 'bold 16px sans-serif',
fill: '#fff',
align: 'center',
baseline: 'middle',
});リッチテキスト書式: {c:#ff0000}赤いテキスト{/c} — 指定部分のみ色を変更。ネスト不可。
engine.add('dialog', {
type: 'text',
x: 50,
y: 400,
text: '{c:#7c3aed}長老{/c}「{c:#3b82f6}クリスタル{/c}を集めよ」',
font: '16px sans-serif',
fill: '#fff',
richText: true,
align: 'center', // center/right も対応
});スプライト円形クリップの使用例:
// プロフィールアイコンを円形に切り抜く
engine.add('avatar', {
type: 'sprite',
x: 50,
y: 50,
image: 'avatar',
width: 80,
height: 80,
clipCircle: { radius: 40 }, // 中心から半径40pxで円形クリップ
});flipX / flipY の使用例:
// キャラの向き変更(左向き)
engine.update('hero', { flipX: true });
// 親ノードに flipX → 子ノードも反転される
engine.add('player', {
type: 'rect',
x: 100,
y: 200,
width: 0,
height: 0,
fill: 'transparent',
flipX: true,
});
engine.add('player-body', {
type: 'rect',
x: -16,
y: 0,
width: 32,
height: 40,
fill: '#3b82f6',
parent: 'player',
});tint の使用例:
// ダメージ赤点滅
engine.update('hero', { tint: '#ff0000', tintAmount: 0.5 });
// 敵の色バリエーション(同じスプライトで色違い)
engine.add('enemy-fire', {
type: 'sprite',
image: 'slime',
x: 100,
y: 100,
width: 32,
height: 32,
tint: '#ff4400',
tintAmount: 0.4,
});
engine.add('enemy-ice', {
type: 'sprite',
image: 'slime',
x: 200,
y: 100,
width: 32,
height: 32,
tint: '#0088ff',
tintAmount: 0.4,
});
// tintAmount を animate でフェードアウト
engine.update('hero', { tint: '#ff0000', tintAmount: 0.6 });
await engine.animate('hero', { tintAmount: 0 }, { duration: 400 });NineSlice(UIパネル):
RPGウィンドウ風の装飾パネル。Canvas描画モード(デフォルト)と画像ベースモードを持つ。
// Canvas描画モード(画像不要)
engine.add('panel', {
type: 'nineslice',
x: 20,
y: 50,
width: 300,
height: 200,
fill: '#0f1729',
borderColor: '#4a90d9',
borderWidth: 2,
borderStyle: 'double', // 'solid' | 'double' | 'groove'
innerColor: '#2a5aa0',
innerPadding: 4,
cornerRadius: 8,
});
// 画像ベースモード(スプライトシートの9分割描画)
engine.add('frame', {
type: 'nineslice',
x: 10,
y: 10,
width: 400,
height: 300,
image: 'ui-frame',
sliceSize: 16, // 均一スライスサイズ
});| プロパティ | 型 | デフォルト | 説明 |
| ----------------- | -------- | ----------- | ----------------------------------- |
| width, height | number | 必須 | パネルサイズ |
| fill | string | — | 背景色 |
| borderColor | string | — | 外枠色 |
| borderWidth | number | 2 | 枠線太さ |
| cornerRadius | number | 0 | 角丸 |
| borderStyle | string | 'solid' | 'solid' / 'double' / 'groove' |
| innerColor | string | borderColor | 内枠色(double/groove 用) |
| innerPadding | number | 4 | 外枠と内枠の間隔 |
| image | string | — | 画像キー(設定時は画像9分割モード) |
| sliceSize | number | img.width/3 | 均一スライスサイズ |
clipRect(クリッピング):
ノードとその子を矩形領域内に描画制限する。スクロールコンテナの内部で使用。
engine.add('viewport', {
type: 'rect',
x: 50,
y: 50,
width: 0,
height: 0,
clipRect: { x: 0, y: 0, width: 200, height: 300 }, // ローカル座標
});
// viewport の子ノードは 200x300 の領域内のみ描画される
engine.add('content', { type: 'text', x: 10, y: 10, text: 'Clipped!', parent: 'viewport' });シーン管理
engine.defineScene(name, setup)
シーンを登録。
engine.defineScene('game', () => {
engine.addMany([...]);
engine.onUpdate((dt) => { /* game loop */ });
});engine.goTo(name, transition?, data?): Promise<void>
シーン遷移。clear() → setup() を実行。第3引数でデータを渡せる。
engine.goTo('game'); // フェード(デフォルト 300ms)
engine.goTo('menu', { type: 'none' }); // 即時遷移
engine.goTo('battle', { type: 'fade', duration: 500 }); // カスタムフェード
// データを次のシーンに渡す
engine.goTo('boss', { type: 'fade' }, { level: 3, crystals: 5 });engine.currentScene: string | null
現在のシーン名(読み取り専用)。
engine.sceneData: any
goTo() の第3引数で渡されたデータ(読み取り専用)。遷移先シーンの setup 関数内で参照する。
engine.defineScene('boss', () => {
const data = engine.sceneData; // { level: 3, crystals: 5 }
const crystals = data?.crystals ?? 0;
});グローバル状態
シーンをまたいで値を保持する Key-Value ストア。clear() ではクリアされず、destroy() でクリア。
engine.setGlobal(key, value)
値を保存。
engine.setGlobal('talkedToElder', true);
engine.setGlobal('inventory', ['sword', 'shield']);engine.getGlobal<T>(key, defaultValue?): T
値を取得。存在しない場合は defaultValue を返す。
const talked = engine.getGlobal<boolean>('talkedToElder', false);
const items = engine.getGlobal<string[]>('inventory', []);アニメーション
engine.animate(id, props, config): Promise<void>
ノードプロパティをアニメーション。x, y, alpha, scaleX, scaleY, rotation, tintAmount が対象。
await engine.animate('player', { x: 300, y: 100 }, { duration: 500, easing: 'easeOutQuad' });
await engine.animate('enemy', { alpha: 0, scaleX: 2, scaleY: 2 }, { duration: 300 });
// ダメージ赤点滅 → フェードアウト
engine.update('player', { tint: '#ff0000', tintAmount: 0.6 });
await engine.animate('player', { tintAmount: 0 }, { duration: 400 });AnimConfig:
| プロパティ | 型 | デフォルト | 説明 |
| ---------- | ------------ | --------------- | ------------- |
| duration | number | 必須 | 持続時間 (ms) |
| easing | EasingName | 'easeOutQuad' | イージング |
| delay | number | 0 | 開始遅延 (ms) |
EasingName: 'linear' 'easeInQuad' 'easeOutQuad' 'easeInOutQuad' 'easeInCubic' 'easeOutCubic' 'easeInOutCubic' 'easeOutBack' 'easeOutElastic' 'easeOutBounce'
engine.timeline(steps): Promise<void>
宣言的なアニメーションシーケンス。
await engine.timeline([
{ type: 'animate', id: 'enemy', props: { x: 100 }, config: { duration: 200 } },
{ type: 'wait', ms: 300 },
{ type: 'call', fn: () => console.log('done') },
{
type: 'parallel',
steps: [
{ type: 'animate', id: 'a', props: { alpha: 0 }, config: { duration: 200 } },
{ type: 'animate', id: 'b', props: { alpha: 0 }, config: { duration: 200 } },
],
},
]);engine.sequence(...fns): Promise<void>
関数を順番に実行。
await engine.sequence(
() => engine.animate('a', { x: 100 }, { duration: 200 }),
() => engine.animate('b', { x: 200 }, { duration: 200 }),
);engine.parallel(...fns): Promise<void>
関数を同時に実行。
await engine.parallel(
() => engine.animate('a', { alpha: 0 }, { duration: 200 }),
() => engine.animate('b', { alpha: 0 }, { duration: 200 }),
);engine.wait(ms): Promise<void>
指定ミリ秒待つ(ゲームループ連動)。
入力
engine.on(event, handler): unsubscribe
イベント購読。戻り値は解除関数。
const unsub = engine.on('tap', (e) => {
console.log(`Tapped at (${e.x}, ${e.y}), node: ${e.id}`);
});
unsub(); // 解除イベント一覧:
| イベント | ペイロード | 説明 |
| ------------- | ------------------------------------------------------------ | --------------- |
| tap | { id, x, y } | タップ/クリック |
| keydown | { key, code, repeat } | キー押下 |
| keyup | { key, code } | キー離し |
| pointermove | PointerDragEvent | ポインタ移動 |
| dragstart | PointerDragEvent | ドラッグ開始 |
| drag | PointerDragEvent | ドラッグ中 |
| dragend | PointerDragEvent | ドラッグ終了 |
| swipe | { direction, distance, speed, startX, startY, endX, endY } | スワイプ |
engine.isKeyDown(code): boolean
キーが押されているかチェック。
if (engine.isKeyDown('ArrowLeft')) {
/* ... */
}ノードの onClick / onDown / onUp
ノード定義時に直接ハンドラを設定。
engine.add('btn', { type: 'rect', ..., onClick: () => console.log('clicked!') });ゲームループ
engine.onUpdate(callback): unsubscribe
毎フレーム呼ばれるコールバック。dt は秒単位。
const unsub = engine.onUpdate((dt) => {
// dt ≈ 0.016 (60fps)
playerX += speed * dt;
engine.update('player', { x: playerX });
});衝突判定
矩形ベースの AABB 判定。
engine.overlap(idA, idB): boolean
2ノード間の衝突判定。
engine.overlapAny(id, tag): boolean
ノードとタグ付きノード群のいずれかに衝突しているか。
engine.overlapAll(id, tag): string[]
衝突している全ノードIDを返す。
カメラ
engine.setCamera(config)
カメラを設定。
engine.setCamera({ x: 100, y: 50 });
engine.setCamera({ followId: 'player', followSmooth: 0.1 });
// ズーム + 境界制限
engine.setCamera({
followId: 'player',
followSmooth: 0.12,
zoom: 1.5,
bounds: { minX: 0, minY: 0, maxX: 1200, maxY: 1200 },
});CameraConfig:
| プロパティ | 型 | デフォルト | 説明 |
| -------------- | ----------------------- | ---------- | ----------------------------------------------- |
| x | number | 0 | カメラ X 位置 |
| y | number | 0 | カメラ Y 位置 |
| followId | string | - | 追従するノード ID |
| followSmooth | number | 0.1 | 追従の滑らかさ (0-1) |
| zoom | number | 1 | ズーム倍率 (>1 = ズームイン, <1 = ズームアウト) |
| bounds | {minX,minY,maxX,maxY} | - | カメラ移動の境界制限 |
engine.updateCamera(props)
カメラを部分更新。
engine.updateCamera({ zoom: 2.0 });engine.getCameraOffset(): Vec2
現在のカメラオフセット。
engine.getCameraZoom(): number
現在のズーム倍率。
engine.animateCamera(props, config): Promise<void>
カメラプロパティをアニメーション。ズーム演出に便利。
await engine.animateCamera({ zoom: 1.5 }, { duration: 300, easing: 'easeOutQuad' });
await engine.animateCamera({ zoom: 1.0 }, { duration: 200 });engine.isInView(id): boolean
ノードが画面内にあるか(ズーム対応)。
画面効果
engine.shake(intensity?, duration?)
画面シェイク。被弾・爆発の演出に。
engine.shake(); // デフォルト: 強度8, 300ms
engine.shake(12, 500); // 強め、500ms| パラメータ | 型 | デフォルト | 説明 |
| ----------- | -------- | ---------- | ----------------- |
| intensity | number | 8 | 揺れの大きさ (px) |
| duration | number | 300 | 持続時間 (ms) |
engine.flash(color?, duration?)
画面フラッシュ。撃破・ダメージの演出に。
engine.flash(); // デフォルト: 白, 200ms
engine.flash('#ef4444', 150); // 赤フラッシュ
engine.flash('#f97316', 300); // オレンジ(大爆発)| パラメータ | 型 | デフォルト | 説明 |
| ---------- | -------- | ---------- | ------------- |
| color | string | '#fff' | フラッシュ色 |
| duration | number | 200 | 持続時間 (ms) |
アセット管理
engine.loadImage(key, url): Promise<void>
画像を読み込んでキャッシュ。
await engine.loadImage('enemy', '/sprites/enemy.png');engine.image(key): HTMLImageElement | null
キャッシュされた画像を取得。
engine.preload(manifest, onProgress?): Promise<void>
複数画像をバッチロード。進捗コールバック付き。
await engine.preload(
{ player: '/sprites/player.png', enemy: '/sprites/enemy.png', bg: '/sprites/bg.png' },
(loaded, total) => console.log(`${loaded}/${total}`),
);engine.ready: Promise<void>
初期アセット(EngineConfig.assets)のロード完了を待つ。
パーティクル
engine.emitParticles(config)
パーティクルを発生させる。
engine.emitParticles({
x: 300,
y: 200,
count: 15,
colors: ['#f97316', '#ef4444', '#fbbf24'],
speed: 150,
lifetime: 500,
size: 4,
direction: -Math.PI / 2, // 上方向
spread: Math.PI * 0.5, // 90度の扇形
gravity: 200, // 下方向の重力
});ParticleConfig:
| プロパティ | 型 | デフォルト | 説明 |
| ----------- | ---------- | ------------- | -------------- |
| x, y | number | 必須 | 発生位置 |
| count | number | 12 | パーティクル数 |
| speed | number | 150 | 初速 (px/s) |
| lifetime | number | 600 | 寿命 (ms) |
| size | number | 4 | サイズ (px) |
| colors | string[] | ['#fff'] | ランダム色配列 |
| spread | number | Math.PI * 2 | 拡散角度 (rad) |
| direction | number | 0 | 中心角度 (rad) |
| gravity | number | 0 | 重力 (px/s²) |
タイマー
ゲームループ連動。シーン遷移時に自動クリア。
engine.setTimeout(fn, ms): number
const id = engine.setTimeout(() => console.log('boom'), 1000);engine.setInterval(fn, ms): number
const id = engine.setInterval(() => spawnEnemy(), 2000);engine.clearTimer(id)
タイマーを解除。
テキスト計測
engine.measureText(text, font?): { width, height }
const { width } = engine.measureText('Hello World', 'bold 16px sans-serif');親子ノード
子ノードは親の座標系を継承(位置・回転・拡縮・透明度)。
engine.setParent(childId, parentId | null)
engine.add('body', { type: 'circle', x: 100, y: 100, radius: 30, fill: '#22c55e' });
engine.add('eye', { type: 'circle', x: 10, y: -5, radius: 5, fill: '#fff', parent: 'body' });engine.getChildren(parentId): string[]
engine.getParent(childId): string | null
engine.getWorldPos(id): Vec2
ワールド座標を計算(親の座標を加算)。
タグ・ノード検索
engine.findByTag(tag): string[]
engine.removeByTag(tag)
engine.updateByTag(tag, props)
engine.add('e1', { type: 'circle', ..., tag: 'enemy' });
engine.add('e2', { type: 'circle', ..., tag: 'enemy' });
engine.updateByTag('enemy', { fill: '#ef4444' });
engine.removeByTag('enemy');engine.findAll(predicate): string[]
engine.removeAll(predicate)
Z 順序
engine.sendToFront(id)
engine.sendToBack(id)
ノードプール
大量の同種ノードを効率的に再利用。
engine.createPool(config): NodePool
const pool = engine.createPool({
tag: 'bullet',
create: () => ({ type: 'rect', x: 0, y: 0, width: 4, height: 10, fill: '#ff0' }),
initialSize: 20,
});
const id = pool.acquire(); // 取得(visible: true)
pool.release(id); // 返却(visible: false)
pool.releaseAll(); // 全返却描画フック
Canvas の ctx に直接描画。
engine.raw(fn): () => void
全ノード描画後(スクリーン空間)。HUD 等。複数登録可。戻り値で解除。
engine.rawBelow(fn): () => void
ノード描画前(ワールド空間、カメラ影響あり)。タイルマップ背景等。複数登録可。
const unsub = engine.rawBelow((ctx, w, h) => {
ctx.fillStyle = '#333';
ctx.fillRect(0, 0, w, h);
});
unsub(); // 解除engine.addRaw(id, fn) / engine.removeRaw(id)
ノードと同じ drawOrder に参加する raw 描画。sendToFront/sendToBack で描画順を制御可能。
raw() と異なり、ノードの間に挟むことができるため、モーダルの下に raw 描画を配置できる。
// 宇宙船アニメーション(ノードと同じ描画順で管理)
engine.addRaw('spaceship', (ctx, w, h) => {
// カスタム描画...
});
engine.sendToBack('spaceship'); // 最背面に移動
// モーダルを追加してもこの描画はモーダルの下になる
engine.removeRaw('spaceship'); // 削除engine.simulateKeyDown(code) / engine.simulateKeyUp(code)
合成キー入力。仮想ゲームパッド等から isKeyDown() を透過的に動かす。
engine.simulateKeyDown('ArrowLeft'); // isKeyDown('ArrowLeft') が true になる
engine.simulateKeyUp('ArrowLeft');engine.canvas / engine.designWidth / engine.designHeight
読み取り専用プロパティ。Canvas 要素とデザイン解像度。
UIヘルパー
import { createHpBar, createTextWindow, createMenu, createVirtualGamepad, createScrollContainer } from './engine/ui'
createHpBar(engine, config): HpBarController
HP/MPバー。3ノード構成(背景 + 塗り + ラベル)。
const hpBar = createHpBar(engine, {
id: 'hp',
x: 20,
y: 400,
width: 200,
height: 18,
label: 'HP',
labelFont: 'bold 11px sans-serif',
colors: { high: '#22c55e', mid: '#eab308', low: '#ef4444' },
});
hpBar.update(75, 100); // current, maxcreateTextWindow(engine, config): TextWindowController
タイプライター演出付きメッセージ表示。
const tw = createTextWindow(engine, {
id: 'msg',
x: 20,
y: 300,
width: 560,
height: 80,
font: 'bold 16px sans-serif',
charDelay: 25,
});
await tw.show('勇者のこうげき!');
tw.hide();createMenu(engine, config): MenuController
選択メニュー。キーボード(↑↓+Enter)とタップ対応。
const menu = createMenu(engine, {
id: 'cmd',
x: 20,
y: 440,
width: 270,
font: 'bold 18px sans-serif',
});
const index = await menu.open(['たたかう', 'まほう', 'にげる']);
menu.close();createVirtualGamepad(engine, config?): VirtualGamepadController
モバイル用仮想ゲームパッド。D-pad + A/B ボタン。engine.raw() でスクリーン空間に描画。
押下中は engine.simulateKeyDown() で合成キーを注入するため、既存の isKeyDown() ベースのゲームコードがそのまま動く。
const gamepad = createVirtualGamepad(engine, {
buttons: ['a', 'b'],
keyMap: { a: 'KeyZ', b: 'KeyX' },
});
// autoHide: true にすると非タッチデバイスで自動非表示
// gamepad.destroy() でリスナー解除VirtualGamepadConfig:
| プロパティ | 型 | デフォルト | 説明 |
| ---------- | -------------------------- | ----------- | ------------------------ |
| dpadMode | '4way'\|'8way' | '8way' | D-pad 方向モード |
| buttons | ('a'\|'b')[] | ['a','b'] | 表示するアクションボタン |
| alpha | number | 0.35 | 透明度 |
| autoHide | boolean | false | 非タッチデバイスで非表示 |
| keyMap | {up,down,left,right,a,b} | Arrow+Z/X | キーマッピング |
VirtualGamepadController:
isPressed(name)— 方向/ボタン状態取得direction()—{x, y}正規化ベクトルshow()/hide()/visibledestroy()— リスナー解除
createScrollContainer(engine, config): ScrollContainerController
スクロール可能なコンテナ。内部で clipRect を使用して描画領域を制限。
ドラッグ操作でスクロール。スクロールバー表示。
const scroll = createScrollContainer(engine, {
id: 'list',
x: 20,
y: 100,
width: 260,
height: 340,
contentHeight: 800, // コンテンツ全体の高さ
});
// アイテムを追加(自動的にコンテナの子になる)
scroll.addItem('item-0', { type: 'text', x: 10, y: 10, text: 'Item 1', fill: '#fff' });
scroll.addItem('item-1', { type: 'text', x: 10, y: 50, text: 'Item 2', fill: '#fff' });
scroll.setScrollY(100); // プログラム的スクロール
scroll.setContentHeight(400); // コンテンツ高さ変更
scroll.clear(); // 全アイテム削除
scroll.destroy(); // コンテナ破棄ScrollContainerConfig:
| プロパティ | 型 | デフォルト | 説明 |
| ----------------- | -------- | --------------------- | ------------------------ |
| id | string | 必須 | コンテナID |
| x, y | number | 必須 | 位置 |
| width, height | number | 必須 | 表示領域サイズ |
| contentHeight | number | 必須 | スクロール可能な全体高さ |
| bgColor | string | 'rgba(0,0,20,0.85)' | 背景色 |
| borderColor | string | '#4a5568' | 枠線色 |
| scrollbarWidth | number | 6 | スクロールバー幅 |
| scrollbarColor | string | '#64748b' | スクロールバー色 |
物理ユーティリティ
import { Physics } from '@uzuhq/engine-2d'
エンジン非依存の純粋関数モジュール。ベクトル演算・衝突判定・衝突応答を提供。
ベクトル演算(Vec2)
const v = Physics.vec2(3, 4);
const n = Physics.normalize(v); // { x: 0.6, y: 0.8 }
const d = Physics.distance(a, b); // 2点間の距離
const r = Physics.reflect(vel, normal); // 反射ベクトル| 関数 | 説明 |
| ------------------------------------- | -------------------------------- |
| vec2(x, y) | Vec2 生成 |
| add(a, b) | ベクトル加算 |
| sub(a, b) | ベクトル減算 |
| scale(v, s) | スカラー倍 |
| length(v) / lengthSq(v) | 長さ / 長さ² |
| normalize(v) | 正規化(零ベクトル時は {0,0}) |
| dot(a, b) / cross2D(a, b) | 内積 / 2D外積 |
| reflect(v, normal) | 法線による反射 |
| rotate(v, angle) | ラジアン回転 |
| distance(a, b) / distanceSq(a, b) | 距離 / 距離² |
| lerp(a, b, t) | 線形補間 |
衝突判定
すべて CollisionResult { hit, normal, penetration, point } を返す。
const res = Physics.circleVsCircle({ x: 10, y: 10, radius: 5 }, { x: 15, y: 10, radius: 5 });
if (res.hit) {
const pos = Physics.separateCircle(Physics.vec2(10, 10), res.normal, res.penetration);
const vel = Physics.elasticBounce(Physics.vec2(vx, vy), res.normal, 0.8);
}| 関数 | 説明 |
| --------------------------------- | -------------- |
| circleVsCircle(a, b) | 円 vs 円 |
| circleVsLine(circle, line) | 円 vs 線分 |
| circleVsRect(circle, rect) | 円 vs AABB |
| circleVsRotatedRect(circle, rr) | 円 vs 回転矩形 |
衝突応答
| 関数 | 説明 |
| ----------------------------------------------- | -------------------- |
| elasticBounce(velocity, normal, restitution) | 弾性反射ベクトル |
| separateCircle(position, normal, penetration) | めり込み解消後の位置 |
スプライトアニメーション
import { sheetFrame, sheetFrames, createAnimator } from '@uzuhq/engine-2d'
エンジン非依存の純粋ユーティリティ。スプライトシートのフレーム座標計算とアニメーション再生管理。
sheetFrame(config, col, row): FrameRect
スプライトシートの指定位置から1フレームの矩形を計算。
sheetFrames(config, coords): FrameRect[]
複数の [col, row] ペアから一括計算。
const sheet: SpriteSheetConfig = { frameWidth: 16, frameHeight: 16, columns: 8 };
const idle = sheetFrames(sheet, [
[0, 0],
[1, 0],
[2, 0],
[3, 0],
]);
const run = sheetFrames(sheet, [
[0, 1],
[1, 1],
[2, 1],
[3, 1],
]);createAnimator(clips): AnimatorController
名前付きアニメーションクリップを管理。エンジン不要 — onUpdate 内で手動更新。
const anim = createAnimator({
idle: { frames: idle, frameDuration: 0.2, loop: true },
run: { frames: run, frameDuration: 0.1, loop: true },
});
anim.play('run');
engine.onUpdate((dt) => {
anim.update(dt);
const rect = anim.frame(); // 現在のフレーム FrameRect
engine.update('player', { sourceRect: rect });
});AnimatorController:
| メソッド/プロパティ | 説明 |
| ------------------- | -------------------------------- |
| play(name) | クリップ切り替え(同名なら無視) |
| update(dt) | フレーム進行(秒単位の dt) |
| frame() | 現在の FrameRect |
| current | 現在のクリップ名(readonly) |
タイルマップ
import { createTilemap, createLayeredTilemap } from '@uzuhq/engine-2d'
createTilemap(config): Tilemap
単層タイルマップ。
const tilemap = createTilemap({
cols: 20, rows: 15, tileSize: 32,
tiles: [[1,1,0,...], ...], // [row][col] → tileId
defs: {
0: { color: '#333', solid: false },
1: { color: '#8b5e3c', solid: true },
},
});
// rawBelow 内で描画
engine.rawBelow((ctx) => {
const cam = engine.getCameraOffset();
tilemap.render(ctx, cam.x, cam.y, 600, 600);
});
tilemap.isSolid(col, row); // タイル衝突判定
tilemap.getTile(col, row); // タイルID取得
tilemap.setTile(col, row, id); // タイル変更
tilemap.worldToTile(wx, wy); // ワールド座標→タイル座標
tilemap.tileToWorld(col, row); // タイル座標→ワールド座標(タイル中心)createLayeredTilemap(config): LayeredTilemap
多層タイルマップ + パララックススクロール対応。
const tilemap = createLayeredTilemap({
cols: 75, rows: 19, tileSize: 32,
layers: [
{ tiles: bgTiles, scrollFactor: 0.3, alpha: 0.6 }, // 遠景(ゆっくりスクロール)
{ tiles: mainTiles, scrollFactor: 1.0 }, // メイン(通常速度)
{ tiles: fgTiles, scrollFactor: 1.2, alpha: 0.7 }, // 前景(速めにスクロール)
],
defs: { ... },
});
// 全レイヤー一括描画
engine.rawBelow((ctx) => {
const cam = engine.getCameraOffset();
tilemap.render(ctx, cam.x, cam.y, 600, 600);
});
// レイヤー個別描画(前景をノードの上に描く場合)
engine.rawBelow((ctx) => {
const cam = engine.getCameraOffset();
tilemap.renderLayer(ctx, 0, cam.x, cam.y, 600, 600); // BG層
tilemap.renderLayer(ctx, 1, cam.x, cam.y, 600, 600); // メイン層
});
engine.raw((ctx) => {
const cam = engine.getCameraOffset();
tilemap.renderLayer(ctx, 2, cam.x, cam.y, 600, 600); // 前景層(ノードの上)
});LayerConfig:
| プロパティ | 型 | デフォルト | 説明 |
| -------------- | ------------ | ---------- | --------------------------------------- |
| tiles | number[][] | 必須 | [row][col] → tileId |
| scrollFactor | number | 1.0 | パララックス係数(0.5=半速、2.0=2倍速) |
| alpha | number | 1.0 | レイヤー透明度 |
LayeredTilemap メソッド:
| メソッド | 説明 |
| --------------------------------------------------- | ------------------------------------- |
| getTile(layer, col, row) | 特定レイヤーのタイルID取得 |
| setTile(layer, col, row, id) | 特定レイヤーのタイル変更 |
| isSolid(col, row) | 全レイヤー横断でソリッド判定(OR) |
| render(ctx, camX, camY, viewW, viewH) | 全レイヤー描画 |
| renderLayer(ctx, layer, camX, camY, viewW, viewH) | 特定レイヤーのみ描画 |
| worldToTile(wx, wy) | ワールド座標→タイル座標 |
| tileToWorld(col, row) | タイル座標→ワールド座標(タイル中心) |
| layerCount | レイヤー数(readonly) |
Overlap(衝突判定 + レイキャスト)
import { Overlap } from '@uzuhq/engine-2d' — Overlap.pointInRect(...) のように使用。
エンジン非依存の純粋関数モジュール。形状判定・レイキャスト・視線判定を提供。
形状判定
import { Overlap } from '../../engine';
Overlap.pointInRect({ x: 100, y: 100 }, { x: 50, y: 50, width: 200, height: 200 }); // true
Overlap.circleVsRect({ x: 10, y: 10, radius: 5 }, { x: 0, y: 0, width: 20, height: 20 }); // true
Overlap.polygonVsPolygon(
{
vertices: [
{ x: 0, y: 0 },
{ x: 10, y: 0 },
{ x: 5, y: 10 },
],
},
{
vertices: [
{ x: 5, y: 5 },
{ x: 15, y: 5 },
{ x: 10, y: 15 },
],
},
); // true関数一覧:
| 関数 | 説明 |
| ------------------------------- | ------------------------------------- |
| pointInRect(p, rect) | 点 vs AABB |
| pointInCircle(p, circle) | 点 vs 円 |
| pointInPolygon(p, poly) | 点 vs 凸多角形 |
| rectVsRect(a, b) | AABB vs AABB |
| circleVsCircle(a, b) | 円 vs 円 |
| circleVsRect(circle, rect) | 円 vs AABB |
| rectVsCircle(rect, circle) | AABB vs 円(circleVsRect の引数逆順) |
| circleVsPolygon(circle, poly) | 円 vs 凸多角形 |
| polygonVsPolygon(a, b) | 凸多角形 vs 凸多角形(SAT) |
レイキャスト
import { Overlap } from '../../engine';
import type { GridConfig } from '../../engine';
// レイ vs 矩形
const hit = Overlap.rayVsRect(
{ origin: { x: 0, y: 5 }, direction: { x: 1, y: 0 } },
{ x: 10, y: 0, width: 10, height: 10 },
);
// hit: { hit: true, t: 10, point: { x: 10, y: 5 }, normal: { x: -1, y: 0 } }
// レイ vs タイルマップ(DDA)
const grid: GridConfig = {
cols: 30,
rows: 30,
tileSize: 20,
isSolid: (c, r) => tilemap.isSolid(c, r),
};
const tilemapHit = Overlap.rayVsGrid(
{ origin: { x: 50, y: 50 }, direction: { x: 1, y: 0 } },
grid,
200, // maxDist
);
// tilemapHit.tileCol, tilemapHit.tileRow で当たったタイル座標
// 2点間の視線判定
Overlap.hasLineOfSight({ x: 50, y: 50 }, { x: 200, y: 100 }, grid); // true/falseレイキャスト関数:
| 関数 | 説明 |
| ------------------------------------ | ---------------------------------------------------------------- |
| rayVsRect(ray, rect, maxDist?) | レイ vs AABB |
| rayVsCircle(ray, circle, maxDist?) | レイ vs 円 |
| rayVsLine(ray, line, maxDist?) | レイ vs 線分 |
| rayVsGrid(ray, grid, maxDist?) | レイ vs タイルマップ(DDA アルゴリズム)。TilemapRayHit を返す |
| hasLineOfSight(from, to, grid) | 2点間の視線が通るか(内部で rayVsGrid 使用) |
Pathfinding(パスファインディング)
import { createPathfinder } from './engine/pathfinding'
グリッドベースの A* パスファインディング。バイナリヒープで高速。
基本使用
import { createPathfinder } from '../../engine/pathfinding';
const pf = createPathfinder({
cols: 30,
rows: 30,
isWalkable: (c, r) => !tilemap.isSolid(c, r),
diagonal: false, // 4方向移動(デフォルト)
});
// パス検索
const result = pf.findPath(startCol, startRow, goalCol, goalRow);
if (result.found) {
for (const { col, row } of result.path) {
// col, row に沿って移動
}
}
// 移動範囲取得(タクティクス用)
const reachable = pf.getReachable(unitCol, unitRow, 5);
for (const { col, row, cost } of reachable) {
// cost 以内で到達可能なマス
}PathfinderConfig:
| プロパティ | 型 | デフォルト | 説明 |
| ------------ | -------------------------------------------- | ---------- | --------------------------------- |
| cols | number | 必須 | グリッド列数 |
| rows | number | 必須 | グリッド行数 |
| isWalkable | (col, row) => boolean | 必須 | 通行可能判定 |
| diagonal | boolean | false | 8方向移動を許可(角切り防止付き) |
| cost | (fromCol, fromRow, toCol, toRow) => number | 1 / √2 | カスタムコスト関数 |
Pathfinder メソッド:
| メソッド | 説明 |
| ------------------------------------------------ | ------------------------------------------------------------ |
| findPath(startCol, startRow, goalCol, goalRow) | A* でパス検索。PathResult { found, path, explored } を返す |
| getReachable(startCol, startRow, maxCost) | Dijkstra フラッドフィル。コスト内の到達可能タイル一覧 |
レイアウトヘルパー
import { layoutRow, layoutColumn } from '@uzuhq/engine-2d'
ノード配列の x/y を自動計算する純粋関数。エンジン非依存。
layoutRow(opts, children): NodeConfig[]
子ノードを横一列に配置。
const cards = layoutRow({ x: 50, y: 300, gap: 8 }, [
{ id: 'c1', type: 'sprite', image: 'card1', width: 48, height: 64 },
{ id: 'c2', type: 'sprite', image: 'card2', width: 48, height: 64 },
]);
// → c1.x = 50, c2.x = 106| オプション | 型 | 説明 |
| ---------- | --------- | -------------------------------- |
| x | number | 行の左端 x |
| y | number | 行の y |
| gap | number? | 子ノード間の間隔(デフォルト 0) |
| centerIn | number? | 指定幅の中央揃え |
layoutColumn(opts, children): NodeConfig[]
子ノードを縦一列に配置。オプションは layoutRow と同様(centerIn は高さ方向)。
const buttons = layoutColumn({ x: 100, y: 50, gap: 10 }, [
{ id: 'b1', type: 'rect', width: 200, height: 40, fill: '#333' },
{ id: 'b2', type: 'rect', width: 200, height: 40, fill: '#333' },
]);
// → b1.y = 50, b2.y = 100エンジンの責務外
以下はエンジンが扱わない領域。SDK やホスト環境(Flutter 等)の責務として、ゲームコードから直接利用する。
| 機能 | 担当 | 使い方 |
| ------------------------ | ---- | --------------------------------------------------------------- |
| サウンド(SE・BGM) | SDK | import { playSound, playBgm, stopBgm } from '@uzuhq/code-sdk' |
| マルチプレイ(通信) | SDK | import { onRoom, init } from '@uzuhq/code-sdk' |
| セーブ/ロード | 未定 | SDK またはホスト環境で提供予定 |
設計原則: SDK が既に提供している機能をエンジンで再ラップしない。ゲームコードが直接 SDK を呼ぶ。
