NetSuite でテトリスを遊んでみた

# はじめに
今回は少し変わった切り口でNetSuiteのカスタマイズの可能性についてお話しをしたいと思います。
タイトルにある通り、NetSuite上で動くテトリスを作ってみました（厳密にはNetSuite上にコードをデプロイしてブラウザ上で動作するテトリスです）。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/289720/b8d3fa84-dd68-d696-8b82-4a1751bba459.png)


「ERPシステムでテトリス？」と違和感を覚える方も多いかもしれません。できちゃうんです。
NetSuiteの持つカスタマイズ性を示せるいい例かなと思います。

NetSuiteではJavaScript（SuiteScript）でカスタマイズが可能ですが、超ざっくり、サーバー上で動くサーバーサイドスクリプトとブラウザ上で動くクライアントサイドスクリプトがあります。
今回はこれらのスクリプトタイプを組み合わせて、あえてテトリスを作ってみました。ERPを触ったことがありカスタマイズに興味がある方も、はたまた開発畑出身でERPを触ったことがない方でも取っ付きやすい内容になっているかなと思います。

# 構成
NetSuiteには大まかに分けてサーバーサイドスクリプトとクライアントサイドスクリプトがありますが、サーバーサイドスクリプトには様々な種類があります。REST APIを構築できるRESTlet、イベント駆動型のユーザーイベントスクリプト、ワークフローに組み込めるワークフローアクションスクリプト等々。今回は、UIを構築できるSuiteletとブラウザで動作するClientScriptを使います。

```mermaid
sequenceDiagram
    actor User
    participant Browser
    participant ClientScript as ClientScript (クライアントサイド)
    participant Suitelet as Suitelet (サーバーサイド)

    User->>Browser: アクセス
    Browser->>Suitelet: GET
    Suitelet->>Browser: HTML生成<br/>(Canvas + Score表示)
    Suitelet->>Browser: ClientScript読み込み

    Note over ClientScript: ゲーム初期化<br/>Canvas設定<br/>イベントリスナー設定

    loop Game Loop
        ClientScript->>Browser: ゲーム状態更新<br/>画面描画
        User->>Browser: キー操作
        Browser->>ClientScript: keydownイベント
    end
```


# テトリスの作り方をサクッと紹介
テトリスの実装は、SuiteletとClientScriptの2つのスクリプトで構成されています。


## Suitelet
まずはSuiteletでゲーム画面の土台を作りましょう。

```javascript tetris_sl.js
/**
 * @NApiVersion 2.1
 * @NScriptType Suitelet
 */
define(['N/ui/serverWidget'], function(serverWidget) {
    function onRequest(context) {
        if (context.request.method === 'GET') {
            var form = serverWidget.createForm({
                title: 'Tetris Demo'
            });
            
            // ClientScriptの読み込み
            form.clientScriptFileId = 12076; // 実際のClientScriptのファイルIDに置き換える

            // キャンバスとスコア表示用のHTML
            var gameField = form.addField({
                id: 'custpage_canvas',
                type: serverWidget.FieldType.INLINEHTML,
                label: 'Game Canvas'
            });
            
            const html = `
                <div style="display: flex; flex-direction: column; align-items: center;">
                    <canvas id="tetrisCanvas" width="300" height="600" style="border:1px solid #000000;"></canvas>
                    <div style="margin-top: 10px;">
                        <span>Score: </span>
                        <span id="scoreDisplay">0</span>
                    </div>
                </div>
            `;
            
            gameField.defaultValue = html;

            context.response.writePage(form);
        }
    }

    return {
        onRequest: onRequest
    };
});
```

## ClientScript
続いてClientScriptでゲームロジックを作りましょう。

```javascript tetris_cs.js
/**
 * @NApiVersion 2.1
 * @NScriptType ClientScript
 */
define(['N/currentRecord'], function(currentRecord) {
    const SHAPES = [
        [[1,1],[1,1]],     // 四角
        [[1,1,1,1]],       // 棒
        [[1,1,1],[0,1,0]], // T
        [[0,1,1],[1,1,0]], // Z
        [[1,1,0],[0,1,1]], // 逆Z
        [[1,0,0],[1,1,1]], // L
        [[0,0,1],[1,1,1]]  // 逆L
    ];
    const COLORS = ['red', 'blue', 'green', 'yellow', 'purple', 'orange', 'cyan'];
    const GAME_SPEED = 500; // ミリ秒

    let canvas, ctx, gameState, gameLoop;

    function pageInit(context) {
        canvas = document.getElementById('tetrisCanvas');
        ctx = canvas.getContext('2d');
        
        gameState = {
            grid: Array(20).fill().map(() => Array(10).fill(0)),
            currentPiece: null,
            score: 0
        };

        // ゲームループ
        gameLoop = setInterval(update, GAME_SPEED);

        // インプットのイベントリスナー
        document.addEventListener('keydown', handleKeyPress);
    }

    function createPiece() {
        const shapeIndex = Math.floor(Math.random() * SHAPES.length);
        return {
            x: 3,
            y: 0,
            shape: SHAPES[shapeIndex],
            color: COLORS[shapeIndex]
        };
    }

    function drawGame() {
        ctx.clearRect(0, 0, canvas.width, canvas.height);
        
        // グリッド描画
        gameState.grid.forEach((row, y) => {
            row.forEach((value, x) => {
                if (value) {
                    ctx.fillStyle = value;
                    ctx.fillRect(x * 30, y * 30, 30, 30);
                }
            });
        });
        
        // 現在のピース描画
        if (gameState.currentPiece) {
            ctx.fillStyle = gameState.currentPiece.color;
            gameState.currentPiece.shape.forEach((row, y) => {
                row.forEach((value, x) => {
                    if (value) {
                        ctx.fillRect((gameState.currentPiece.x + x) * 30, (gameState.currentPiece.y + y) * 30, 30, 30);
                    }
                });
            });
        }

        // スコアの更新
        document.getElementById('scoreDisplay').textContent = gameState.score;
    }

    function update() {
        if (!gameState.currentPiece) {
            gameState.currentPiece = createPiece();
        } else {
            gameState.currentPiece.y++;
            if (checkCollision()) {
                gameState.currentPiece.y--;
                mergePiece();
                clearLines();
                gameState.currentPiece = null;
            }
        }
        drawGame();
    }

    function checkCollision() {
        return gameState.currentPiece.shape.some((row, y) => {
            return row.some((value, x) => {
                if (!value) return false;
                const newY = y + gameState.currentPiece.y;
                const newX = x + gameState.currentPiece.x;
                return newY >= 20 || newX < 0 || newX >= 10 || (gameState.grid[newY] && gameState.grid[newY][newX]);
            });
        });
    }

    function mergePiece() {
        gameState.currentPiece.shape.forEach((row, y) => {
            row.forEach((value, x) => {
                if (value) {
                    gameState.grid[y + gameState.currentPiece.y][x + gameState.currentPiece.x] = gameState.currentPiece.color;
                }
            });
        });
    }

    function clearLines() {
        let linesCleared = 0;
        gameState.grid = gameState.grid.filter(row => {
            if (row.every(cell => cell !== 0)) {
                linesCleared++;
                return false;
            }
            return true;
        });

        // スコアの更新を先に行う
        if (linesCleared > 0) {
            gameState.score += linesCleared * 100;
        }

        // 新しい行の追加
        while (linesCleared > 0) {
            gameState.grid.unshift(Array(10).fill(0));
            linesCleared--;
        }
    }

    function handleKeyPress(e) {
        if (!gameState.currentPiece) return;

        switch (e.key) {
            case 'ArrowLeft':
                gameState.currentPiece.x--;
                if (checkCollision()) gameState.currentPiece.x++;
                break;
            case 'ArrowRight':
                gameState.currentPiece.x++;
                if (checkCollision()) gameState.currentPiece.x--;
                break;
            case 'ArrowDown':
                gameState.currentPiece.y++;
                if (checkCollision()) {
                    gameState.currentPiece.y--;
                    mergePiece();
                    clearLines();
                    gameState.currentPiece = null;
                }
                break;
            case 'ArrowUp':
                rotatePiece();
                break;
        }
        drawGame();
    }

    function rotatePiece() {
        const rotated = gameState.currentPiece.shape[0].map((_, index) =>
            gameState.currentPiece.shape.map(row => row[index]).reverse()
        );
        const originalShape = gameState.currentPiece.shape;
        gameState.currentPiece.shape = rotated;
        if (checkCollision()) {
            gameState.currentPiece.shape = originalShape;
        }
    }

    return {
        pageInit: pageInit
    };
});

```

## スクリプトファイルのアップロード
まずはClientScriptのファイルIDを取得したいので、ClientScriptをファイルキャビネットのSuiteScriptsフォルダ配下に配置しましょう。VSCode等を使用していてSuiteCloud Extension for VSCodeを利用している場合は、通常通りファイルのアップロードを行っていただければ大丈夫です。
アップロードをしたらファイルの内部IDをコピーして、Suitelet内の以下の箇所に設定しましょう。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/289720/9919a4b4-12ef-1794-9e68-ca47308bfb09.png)


```javascript
form.clientScriptFieldId = 12076 // ここに設定
```

値を更新したら、Suiteletもアップロードしましょう。

## スクリプトレコードとデプロイメントの作成
ClientScriptはSuiteletからスクリプトファイルを直接読み込んで使用するので、今回はSuiteletのみのスクリプトレコードとデプロイメントの作成で十分です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/289720/5b4df5ed-85c5-6962-3a29-841578ed03ad.png)

スクリプトデプロイメントのURLをクリックするとテトリスを遊べるようになります！

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/289720/bb5fffee-5de6-9ed1-dcb9-09be60d87f77.png)


# こんなことができる！

今回のようなSuiteletとClientScriptの組み合わせで実現できることは多岐にわたります：
1. 画面設計の自由度
    - Suiteletによる基本レイアウトの作成
    - HTML5/CSS3によるモダンなUI
    - NetSuite標準フォームの部品も利用可能
2. 動的な処理の実装
    - ClientScriptによるリアルタイム処理
    - ユーザーの操作に応じた画面更新
    - 非同期処理の実装
3. NetSuiteとの連携
    - レコードの作成・更新
    - 既存機能との統合
    - バックエンド処理との連携

これらの要素は、以下のような業務アプリケーションの開発でも活用できます：
- インタラクティブなダッシュボード
- リアルタイムバリデーション付きの入力フォーム
- データ可視化ツール

# まとめ
今回は「テトリス」という遊び心のある例を通じて、NetSuiteのSuiteletとClientScriptの可能性をご紹介しました。この2つのスクリプトタイプを組み合わせることで、従来のERPシステムの概念を超えた、現代的なWebアプリケーションの開発が可能になります。