他プロダクトでの応用を目的とした設計・実装ドキュメント
本ドキュメントは、Flopsプロジェクトで実装した以下の2つの主要機能について、設計思想、利用ライブラリ、失敗談、ベストプラクティスをまとめたものです:
- 独自記法のParser&再生機能 - テキストベースの記法をパースしてインタラクティブに描画・再生
- OGP画像生成機能 - 動的にOpen Graph画像を生成してSNS共有を最適化
テキスト入力が正規データ源という設計原則を採用しています。
``` ユーザー入力テキスト → Parser → 内部状態 → UI表示 ↑ ↓ └─────── テキスト生成 ←─────┘ ```
利点:
- 状態の整合性保証(UIからの直接変更を禁止)
- デバッグが容易(テキストを見れば状態がわかる)
- 共有・保存が簡単(テキストをそのまま保存)
- バージョン管理と差分表示が可能
すべての状態遷移は新しいオブジェクトを生成します。既存状態を変更しません。
```typescript // ❌ 悪い例:直接変更 state.players.push(newPlayer); state.pot += betAmount;
// ✅ 良い例:新しいオブジェクト生成 const newState = { ...state, players: [...state.players, newPlayer], pot: state.pot + betAmount, }; ```
利点:
- Undo/Redoの実装が容易(状態のスナップショット保持)
- バグの原因特定が容易(状態変更の追跡可能)
- Reactivity(Svelte/React等)との相性が良い
``` packages/ ├── domain/ # 型定義・スキーマ(依存なし) ├── engine/ # ビジネスロジック(domain依存) ├── parser/ # テキスト解析(domain, engine依存) └── utils/ # 共有ユーティリティ
apps/ ├── frontend/ # SvelteKit UI └── backend/ # Hono API ```
依存関係の方向: `domain ← engine ← parser ← frontend`
循環依存を防ぐため、下位パッケージは上位パッケージを参照しません。
``` 入力テキスト ↓ ┌─────────────────┐ │ Lexer │ トークン分割 └─────────────────┘ ↓ ┌─────────────────┐ │ Pattern Matchers│ 優先度付きマッチング └─────────────────┘ ↓ ┌─────────────────┐ │ GameStateBuilder│ 状態オブジェクト構築 └─────────────────┘ ↓ ┌─────────────────┐ │ValidationPipeline│ セマンティック検証 └─────────────────┘ ↓ GameState ```
```typescript // 各マッチャーは優先度順に試行される const matchers = [ { name: 'game-format', match: matchGameFormat, priority: 1 }, { name: 'board', match: matchBoard, priority: 2 }, { name: 'position-action', match: matchPositionAction, priority: 3 }, { name: 'stack', match: matchStack, priority: 4 }, { name: 'comment', match: matchComment, priority: 5 }, ];
// マッチ結果の型 interface MatchResult { matched: boolean; type: 'game-format' | 'board' | 'action' | ...; data: ParsedData; remaining: string; // 未消費の入力 } ```
ポイント:
- 各マッチャーは純粋関数として実装
- 優先度を明確に定義し、曖昧さを排除
- 部分マッチ時は`remaining`で残りを返す
```typescript // リアルタイム編集対応のため、行単位で状態更新可能 export function updateGameStateFromParsedLine( currentState: GameState, parsedLine: ParsedLineResult, lineNumber: number ): GameState { // 1. 既存行の検証 // 2. 差分計算 // 3. 新しいGameState生成 return { ...currentState, /* 更新内容 */ }; } ```
| ライブラリ | 用途 | 選定理由 |
|---|---|---|
| Zod | スキーマ定義・バリデーション | TypeScript統合、エラーメッセージのカスタマイズ性 |
| Svelte Store | 状態管理(フロントエンド) | Svelteとの相性、シンプルなAPI |
問題: Parser、Engine、Frontend で別々のGameState定義が存在し、型変換が必要だった
```typescript // ❌ 問題のあった構造 packages/parser/src/types/game-state.ts // ParserGameState packages/engine/src/types/game-state.ts // EngineGameState apps/frontend/src/lib/types.ts // UIGameState ```
教訓:
- `@flops/domain`パッケージに型を一元化
- 変換アダプター層を排除し、単一の型を全レイヤーで共有
問題: 一部のServiceがクラスで実装され、関数型コードと混在
教訓:
- 全ロジックを純粋関数で実装
- 依存性はカリー化またはパラメータ渡しで解決
```typescript // ✅ 関数型アプローチ const createAuthService = (db: Database) => ({ login: async (email: string) => { ... }, }); ```
問題: 最終アクションと完了フラグを同じステップに設定したため、最終アクションが表示されずにショーダウンに遷移
教訓:
- アクションステップと完了ステップは分離する
- 状態遷移の単位を明確に定義
```typescript // ✅ 修正後 // アクションステップ(全アクション) const actionSteps = actions.map(a => ({ type: 'action', action: a })); // 完了ステップ(最後に追加) if (isComplete) { actionSteps.push({ type: 'completion', outcome }); } ```
問題: UI入力とパース結果の双方向バインディングで状態の整合性が崩れた
教訓:
- 単方向データフローを徹底
- UIはテキスト入力のみを受け付け、状態はParserからのみ更新
- ドメイン型を独立パッケージに分離
- Parser → State → UI の単方向フロー設計
- Immutable更新パターンの採用
- Pattern Matcherの優先度定義
- インクリメンタル更新対応(リアルタイム編集向け)
- 検証エラーの蓄積と詳細メッセージ
WASM前提の技術選定を行いました。Node.js依存のライブラリ(Sharp等)はWorkers環境で動作しないため、WASMベースの代替を選択。
Vite SSRビルドでWASMモジュールが問題を起こすため、dynamic importで遅延読み込みします。
```typescript // ✅ Vite SSRビルド対応 // WASMモジュールはdynamic importで読み込む const { Resvg } = await import('@cf-wasm/resvg/workerd');
const resvg = await Resvg.create(svg, { fitTo: { mode: 'width', value: 1200 }, }); const pngData = resvg.render(); return pngData.asPng(); ```
ポイント:
- `@cf-wasm/resvg`はCloudflare Workers専用パッケージ
- `/workerd`サブパスでWorkers最適化版を読み込み
- static importを避けることでViteビルドエラーを回避
``` データ抽出 → HTMLテンプレート → Satori(SVG) → @cf-wasm/resvg(PNG) ```
| ステップ | ライブラリ | 役割 |
|---|---|---|
| HTML→SVG | satori | ReactスタイルのHTMLをSVGに変換 |
| HTML解析 | satori-html | HTML文字列をSatori互換形式に変換 |
| SVG→PNG | @cf-wasm/resvg | Cloudflare Workers専用SVGレンダラー |
| フォント | subset-font | フォントサブセット化(ビルド時) |
Satoriは純粋なFlexbox実装であり、以下の制約があります:
| 制約 | 対応策 |
|---|---|
| Gridレイアウト非対応 | Flexboxでネストして対応 |
| インラインスタイルのみ | テンプレート関数でスタイル生成 |
| CSSクラス非対応 | 全てstyle属性で記述 |
| position: absolute制限 | Flexboxのgap/paddingで調整 |
| transform制限 | サイズ計算で代替 |
| 外部画像制限 | Base64エンコードまたはfetch |
```typescript // ✅ 2行レイアウト(Hero vs Villain + Board)
```typescript // カードサイズ定数 const CARD_SIZES = { large: { width: 64, height: 88, fontSize: 28, suitSize: 24 }, medium: { width: 56, height: 76, fontSize: 24, suitSize: 20 }, small: { width: 48, height: 66, fontSize: 20, suitSize: 18 }, };
// ポジションバッジサイズ const BADGE_SIZES = { large: { fontSize: '20px', padding: '6px 8px', width: '72px' }, // 固定幅 normal: { fontSize: '16px', padding: '5px 10px', minWidth: '40px' }, small: { fontSize: '15px', padding: '5px 9px', minWidth: '40px' }, }; ```
ポイント:
- ポジションバッジは固定幅にすることで、アクションテキストの開始位置を揃える
- Hero/Villainで同じサイズ定数を使用し、視覚的統一感を確保
```typescript // 視覚的階層(大→小) const FONT_HIERARCHY = { action: '32px', // メインコンテンツ(アクションテキスト) streetHeader: '28px', // セクション見出し(PREFLOP, FLOP等) vs: '24px', // 接続テキスト(vs, Board) pot: '22px', // 補足情報(ポットサイズ) badge: '20px', // ラベル(ポジションバッジ) footer: '18px', // フッター(Made by...) }; ```
教訓: 見出しとコンテンツのサイズ差が大きすぎると統一感がなくなる(28px:32px = 1.14倍が適切)
```typescript // アクションリストコンテナ
教訓: SNSプレビュー(特にLINE)では表示領域が限られるため、gapとpaddingの最適化が重要
```typescript // apps/frontend/src/routes/api/og/[code]/+server.ts
export const GET: RequestHandler = async ({ params, fetch, url }) => { const code = params.code?.replace(/\.png$/, '');
// 1. バックエンドAPIからデータ取得 const handData = await fetchHandData(code);
// 2. テキストをパースしてGameState取得 const gameState = await parseTextToGameState(handData.content);
// 3. OGP用データ抽出 const ogpOptions = extractOGPOptions(gameState);
// 4. フォント読み込み(キャッシュ済み) const fontData = await loadFont(fetch, url.origin);
// 5. 画像生成(dynamic importでWASM読み込み) const pngBytes = await generateOGPImage(ogpOptions, fontData, { fetch, origin: url.origin });
// 6. キャッシュヘッダー付きで返却 return new Response(pngBytes, { headers: { 'Content-Type': 'image/png', 'Cache-Control': 'public, max-age=31536000, immutable', }, }); }; ```
ポイント:
- `[code].png`形式でURLをSNSフレンドリーに
- 生成済み画像は1年間キャッシュ(`immutable`)
```typescript headers: { 'Content-Type': 'image/png', 'Cache-Control': 'public, max-age=31536000, immutable', 'CDN-Cache-Control': 'max-age=31536000', } ```
```bash
https://flops.dev/api/og/example-mtt-8max.png?v=2.79 ```
教訓: OGP開発ではブラウザ/CDNキャッシュが邪魔になる。バージョンパラメータを付けてバイパス
問題: Noto Sans JP全体は4.44MBで、Workers環境では読み込みが遅い
解決策: `subset-font`でポーカー表示に必要な文字のみ抽出
```javascript // ビルドスクリプト const subset = await subsetFont(fullFont, { targetChars: [ // 英数字 ...'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789', // カードスート ...'♠♥♦♣', // 記号 ...'.,!?-—:;()[]{}', ], });
// 結果: 4.44MB → 50.71KB (98.9%削減) ```
問題: 最初にSharpを選択したが、Cloudflare Workersで動作しない
教訓:
- Edge/Workers環境を意識したライブラリ選定
- WASM版があるかを事前確認
- `@cf-wasm/resvg`はCloudflare Workers専用で最適化済み
問題: WASMモジュールをstatic importすると、Vite SSRビルドで失敗
```typescript // ❌ ビルドエラーになる import { Resvg } from '@cf-wasm/resvg/workerd'; ```
教訓:
- WASMモジュールはdynamic importで読み込む
- サーバーサイドでのみ使用するモジュールは遅延読み込み
問題: Heroポジションバッジを'large'、Villainを'normal'で描画し、視覚的不統一に
教訓:
- 同じ役割の要素は同じサイズ定数を使用
- コードレビューでサイズ定数の一貫性を確認
問題: "vs"、"Board"、"Made by..."のテキストが小さすぎて読みにくい
教訓:
- フォントサイズ階層を事前設計
- メインコンテンツ → 見出し → 接続テキスト → 補足情報の順で視認性を確保
問題: 3つ目のアクションがストリートタブの下端で見切れる
教訓:
- SNSプレビューサイズ(1200x630)の制約を意識
- gap/paddingを調整して表示領域を最大化
- 実際のSNS共有でプレビューを確認
問題: GIF/PNG export用にCanvas専用レンダラーを実装したが、レイアウトの動的複雑性により二重管理が非現実的に
教訓:
- 既存UIコンポーネントを画像化するアプローチを優先
- Satori + resvg-wasmならHTMLテンプレートをそのまま画像化可能
- Canvas専用レンダラーは「最終手段」として検討
| 最適化項目 | 効果 |
|---|---|
| フォントサブセット化 | 4.44MB → 50.71KB (98.9%削減) |
| Dynamic import | Viteビルド対応 + 遅延読み込み |
| キャッシュヘッダー | CDN活用(1年キャッシュ) |
| @cf-wasm/resvg | Workers最適化済みWASM |
| gap/padding最適化 | 表示領域最大化 |
- Edge/Workers環境対応ライブラリの選定(WASM版確認)
- WASMモジュールはdynamic importで読み込み
- フォントサブセット化(必要文字のみ抽出)
- HTMLテンプレートはFlexbox + インラインスタイルのみ
- サイズ定数を一元管理(カード、バッジ、フォント)
- フォントサイズ階層を設計
- キャッシュ戦略の設計(immutableヘッダー + 開発時バイパス)
- 実際のSNSプレビューで表示確認
```yaml
packages:
- 'packages/*'
- 'apps/*'
catalog: typescript: ^5.0.0 svelte: ^5.0.0 zod: 4.1.5 ```
利点:
- 依存バージョンの一元管理
- パッケージ間の型共有が容易
- ビルド順序の自動解決(pnpm + tsup)
- クラス禁止 - 全ロジックを純粋関数で実装
- Immutable更新 - スプレッド演算子による新規オブジェクト生成
- Result型 - 例外の代わりに`{ success, data, error }`パターン
- Pipe関数 - 処理の合成と可読性向上
```typescript // AAA (Arrange-Act-Assert) パターン describe('parseFlopsNotation', () => { it('should parse basic preflop action', () => { // Arrange const input = 'BTN r2.5 AhKs';
// Act
const result = parseFlopsNotation(input);
// Assert
expect(result.success).toBe(true);
expect(result.data.action.type).toBe('raise');
}); }); ```
``` Frontend: Cloudflare Pages ├── SvelteKit + adapter-cloudflare └── OGP画像生成(サーバーサイド)
Backend: Cloudflare Workers ├── Hono (軽量HTTPフレームワーク) ├── D1 (SQLiteベースDB) └── Drizzle ORM (型安全) ```
| ライブラリ | バージョン | 用途 |
|---|---|---|
| zod | 4.1.5 | スキーマ定義・バリデーション |
| typescript | ^5.0.0 | 型システム |
| ライブラリ | バージョン | 用途 |
|---|---|---|
| satori | ^0.12.0 | HTML→SVG変換 |
| satori-html | ^0.3.0 | HTML解析 |
| @cf-wasm/resvg | ^1.0.0 | Cloudflare Workers専用SVG→PNG |
| subset-font | ^2.4.0 | フォントサブセット化 |
Note: `@resvg/resvg-wasm`ではなく`@cf-wasm/resvg`を使用。Cloudflare Workers環境に最適化されている。
| ライブラリ | バージョン | 用途 |
|---|---|---|
| svelte | ^5.0.0 | UIフレームワーク |
| @sveltejs/kit | ^2.22.0 | フルスタックフレームワーク |
| tailwindcss | ^4.0.0 | スタイリング |
| ライブラリ | バージョン | 用途 |
|---|---|---|
| hono | ~4.9.9 | HTTPフレームワーク |
| drizzle-orm | ~0.44.5 | ORM |
- 標準OGPサイズ: 1200 x 630 px
``` ┌──────────────────────────────────────────────────────────────────────┐ │ [BTN] A♠K♣ vs [UTG] K♣K♦ [BB] ?? │ ← Hero vs Opponents (Row 1) │ Board Q♥7♦2♣K♠3♥ │ ← Board (Row 2, centered) ├──────────────────────────────────────────────────────────────────────┤ │ PREFLOP │ FLOP │ TURN │ RIVER │ ← Street Headers (28px) │ 2.5bb │ 51.5bb │ 51.5bb │ 201.5bb │ ← Pot Size (22px) ├────────────────┼──────────────┼──────────────┼──────────────────────┤ │ [UTG+1] Raise 3│ [UTG+1] Bet │ [UTG+1] AI │ — │ │ [SB ] Raise 9│ [SB ] Call │ [SB ] Call │ │ ← Actions (32px) │ [UTG+1] R 25 │ │ │ │ │ [SB ] Call │ │ │ │ └────────────────┴──────────────┴──────────────┴──────────────────────┘ │ Made by flopsapp.com │ ← Footer (18px) └──────────────────────────────────────────────────────────────────────┘ ```
| 要素 | サイズ | 備考 |
|---|---|---|
| カード (large) | 64 x 88 px | Hero, Villain, Board共通 |
| ポジションバッジ (large) | width: 72px, font: 20px | 固定幅で位置揃え |
| アクションテキスト | 32px | メインコンテンツ |
| Street見出し | 28px | セクションヘッダー |
| "vs" / "Board" | 24px | 接続テキスト |
| Potサイズ | 22px | 補足情報 |
| Footer | 18px | ブランディング |
| アクション間隔 | gap: 4px | 表示領域最大化 |
更新日: 2025-01-18 Flops Project - Technical Reuse Guide v2