5
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

kintoneカスタマイズを1回だけのバイブコーディングでどこまで作れるかチャレンジしてみた

Posted at

どうも。BBです。メリクリ!🎅
みなさんのところにはサンタはきましたか?

自分のところにはあいにく赤と白のサンタは来ませんでしたが、代わりに緑と黄の配達員さんからゼンハイザーのヘッドホン(Amazonで46%OFFの3万円)を受け取りました。
いいね。これ。

ということで、お久しぶりのQiitaです。

最近はなかなか忙しくて昔ほどおもしろカスタマイズや小ネタが作れていないのですが、せめて年1回くらいはやろうかってことで久しぶりに書いてみます。
(今年の火山を知っている人は 🤔🤔🤔 かもしれませんが・・・あれは別腹ですな)

ということで、年1回のお祭り、何を書こうかなと思いふと今年のカスタマイズを振り返ってみたら、すべてAI任せで1から実装したやつはほぼないんじゃないかってくらいバイブコーディングな年でした。

なので、今年最後くらいはAIに頼らず自力で実装してみよう! と思って10分くらいコードを書いてみたものの、だんだんコード書くのがめんどくさくなって方向転換。

【kintoneってノーコードツールって言うけど、今ならカスタマイズもノーコードでそこそこできるのでは・・】

やはりそっちの方向に。
ただ、今のところAI開発といってもプロンプト一発で完成形ができることはあまりなく、何度もトライ&エラーするのが基本です。

でも、それじゃ面白くないので、今回は 「一発勝負!1回のプロンプト命令でどこまでkintoneカスタマイズができるのか!?」 を実験したいと思います。

動かなかった場合の「どこが間違っているのか」このくらいは自力で解説したいと思います笑

基本ルール

エディタはCursorを使います。
Cursorへのプロンプト文はGeminiに作ってもらいます。
アプリの作成、ならびにJSファイルのアップロードは(AIに作らせようと思ったけど、複数ファイル対応とかURL参照とかでめんどうなので)手動でやります。

その1. 抽選カスタマイズ

Geminiに依頼したテキスト文章

Geminiにはこんな感じで適当に渡しています。

kintoneで動く以下のカスタマイズプログラムの作成をAIエディタのCursorにお願いするときのプロンプトを考えてください。

【概要】
レコード保存時にランダムでユーザーを抽出する。

【詳細】
ユーザー選択フィールド(対象)にユーザーを複数名セットしておく。
数値フィールド(人数)にランダムで抽出する人数をセットしておく。
レコード保存時に人数分を抽出してユーザー選択フィールド(当選者)に設定する。

Geminiが作ったCursor用プロンプト文

役割の設定とかさすがAIなだけはある笑

# 役割
あなたはkintoneカスタマイズ開発の専門家です。
以下の要件に従って、kintoneアプリで動作するJavaScriptコードを作成してください。

# 概要
レコード保存時に、候補者リストの中から指定された人数分をランダムに抽出し、当選者フィールドにセットする。

# 要件詳細
1. **トリガーイベント**:
   - レコード追加画面の保存実行前 (app.record.create.submit)
   - レコード編集画面の保存実行前 (app.record.edit.submit)

2. **使用するフィールド**(フィールドコードは定数として定義し、変更しやすくすること):
   - `user_candidates`: ユーザー選択フィールド(抽選対象のユーザー一覧)
   - `num_select`: 数値フィールド(当選させる人数)
   - `user_winners`: ユーザー選択フィールド(当選者がセットされる場所)

3. **処理ロジック**:
   - `user_candidates` からユーザー配列を取得する。
   - `num_select` の値を取得する。
   - バリデーション:
     - 「人数」が入力されていない、または0以下の場合は処理を中断しエラーを表示せず保存する(あるいは全クリアする)。
     - 「候補者」が空の場合は何もしない。
     - 「人数」が「候補者の総数」より多い場合、エラー(event.error)を表示して保存をキャンセルする。
   - ランダム抽出ロジック:
     - 候補者リストをランダムにシャッフルし、先頭から指定人数分を取得する(Fisher-Yatesシャッフルアルゴリズム等の偏りのない方法を使用推奨)。
   - 結果を `user_winners` フィールドにセットして `return event;` する。

# コードの品質
- モダンなJavaScript (ES6+) で記述してください。
- コードの可読性を高めるため、適切なコメントを入れてください。
- 即時関数でラップし、グローバル汚染を防いでください。

Cursorが作成したJSプログラム

ぱっと見は悪くない。けどkintoneにアップロードしてみないとわからない。

/**
 * kintoneカスタマイズ: ランダム抽選機能
 * レコード保存時に候補者リストから指定人数をランダムに抽出し、当選者フィールドにセットする
 */
(function () {
  "use strict";

  // ============================================
  // 定数定義(フィールドコード)
  // ============================================
  const FIELD_CANDIDATES = "対象"; // ユーザー選択フィールド(抽選対象のユーザー一覧)
  const FIELD_NUM_SELECT = "人数"; // 数値フィールド(当選させる人数)
  const FIELD_WINNERS = "当選者"; // ユーザー選択フィールド(当選者がセットされる場所)

  // ============================================
  // ユーティリティ関数
  // ============================================

  /**
   * Fisher-Yatesシャッフルアルゴリズムで配列をランダムに並び替える
   * @param {Array} array - シャッフルする配列
   * @returns {Array} - シャッフルされた新しい配列
   */
  function shuffleArray(array) {
    const shuffled = [...array]; // 元の配列を変更しないようにコピー
    for (let i = shuffled.length - 1; i > 0; i--) {
      const j = Math.floor(Math.random() * (i + 1));
      [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; // ES6の分割代入でスワップ
    }
    return shuffled;
  }

  /**
   * ユーザー配列から指定人数をランダムに抽出する
   * @param {Array} candidates - 候補者のユーザー配列
   * @param {number} numSelect - 抽出する人数
   * @returns {Array} - 抽出されたユーザー配列
   */
  function selectRandomUsers(candidates, numSelect) {
    if (candidates.length === 0 || numSelect <= 0) {
      return [];
    }

    // Fisher-Yatesシャッフルでランダムに並び替え
    const shuffled = shuffleArray(candidates);

    // 先頭から指定人数分を取得
    return shuffled.slice(0, numSelect);
  }

  // ============================================
  // メイン処理関数
  // ============================================

  /**
   * レコード保存時の抽選処理
   * @param {Object} event - kintoneイベントオブジェクト
   * @returns {Object} - イベントオブジェクト
   */
  function handleLottery(event) {
    const record = event.record;

    // フィールドから値を取得
    const candidatesField = record[FIELD_CANDIDATES];
    const numSelectField = record[FIELD_NUM_SELECT];
    const winnersField = record[FIELD_WINNERS];

    // 候補者リストを取得(ユーザー選択フィールドの値は配列形式)
    const candidates = candidatesField.value || [];

    // 人数を取得
    const numSelect = numSelectField.value ? Number(numSelectField.value) : 0;

    // バリデーション1: 人数が入力されていない、または0以下の場合
    if (!numSelectField.value || numSelect <= 0) {
      // エラーを表示せず、当選者フィールドを全クリアして保存を続行
      winnersField.value = [];
      return event;
    }

    // バリデーション2: 候補者が空の場合は何もしない
    if (candidates.length === 0) {
      return event;
    }

    // バリデーション3: 人数が候補者の総数より多い場合
    if (numSelect > candidates.length) {
      event.error = `当選人数(${numSelect}人)が候補者数(${candidates.length}人)を超えています。`;
      return event;
    }

    // ランダム抽出処理
    const winners = selectRandomUsers(candidates, numSelect);

    // 当選者フィールドにセット
    winnersField.value = winners;

    return event;
  }

  // ============================================
  // イベントハンドラ登録
  // ============================================

  // レコード追加画面の保存実行前
  kintone.events.on("app.record.create.submit", handleLottery);

  // レコード編集画面の保存実行前
  kintone.events.on("app.record.edit.submit", handleLottery);
})();

結果:⭕️

ちゃんと動いた!
(レコード保存するたびに当選者が変わるけど、ここは人間の指示漏れです笑)

スクリーンショット 2025-12-25 0.22.10.png

その2. 地図埋め込みカスタマイズ

Geminiが作ったCursor用プロンプト文

# 役割
あなたはkintoneカスタマイズ開発の専門家です。
以下の要件に従って、kintoneアプリで動作するJavaScriptコードを作成してください。

# 概要
レコード詳細画面を開いた際、住所フィールドの値をもとにOpenStreetMapをスペースフィールドに表示する。

# 前提条件・環境
1. **使用ライブラリ**: Leaflet.js (CDNを利用)
2. **地図データ**: OpenStreetMap
3. **ジオコーディング**: Nominatim API (住所から緯度経度への変換に使用)

# アプリ構成(フィールドコード)
以下のフィールドコードを使用してください。定数として定義し、変更可能にしてください。
- `address`: 文字列(1行)フィールド(住所が入力されている)
- `map_space`: スペースフィールド(地図を表示する領域)

# 実装ロジック詳細
1. **トリガーイベント**:
   - レコード詳細画面の表示後 (app.record.detail.show)

2. **処理の流れ**:
   - イベント発火時、`map_space` の要素を取得する。
   - 地図表示用の `div` 要素(高さ: 400px, 幅: 100%程度)を作成し、スペース要素に追加する。
   - `address` フィールドの値を取得する。
     - 住所が空の場合は処理を終了する。
   - **Nominatim API** (`https://nominatim.openstreetmap.org/search`) を使用して住所を検索する。
     - パラメータ: `format=json`, `q={住所}`
     - **注意**: Nominatimの利用規約に従い、適切なUser-AgentやRefererヘッダーを意識するか、またはリクエスト頻度に配慮したコードにする(今回はテスト実装)。
   - APIから緯度経度が取得できたら、Leafletを使って地図を描画する。
     - 取得した座標にマーカーを立てる。
     - ポップアップで住所を表示する。

# コードの品質
- LeafletのCSSとJSは、kintone設定画面で読み込む想定ですが、コード内に「設定画面に追加すべきCDNのURL」をコメントで記述してください。
- APIリクエストは非同期(async/await)で処理してください。
- APIエラー時や該当する住所が見つからない場合は、コンソールにエラーを出力し、ユーザーにはアラート等で優しく通知してください。

Cursorが作成したJSプログラム

/**
 * kintoneカスタマイズ: 住所から地図表示機能
 * レコード詳細画面で住所フィールドの値をもとにOpenStreetMapをスペースフィールドに表示する
 *
 * 【設定画面に追加すべきCDNのURL】
 * Leaflet CSS: https://unpkg.com/leaflet@1.9.4/dist/leaflet.css
 * Leaflet JS:  https://unpkg.com/leaflet@1.9.4/dist/leaflet.js
 */
(function () {
  "use strict";

  // ============================================
  // 定数定義(フィールドコード)
  // ============================================
  const FIELD_ADDRESS = "address"; // 文字列(1行)フィールド(住所が入力されている)
  const FIELD_MAP_SPACE = "map_space"; // スペースフィールド(地図を表示する領域)

  // ============================================
  // 設定
  // ============================================
  const NOMINATIM_API_URL = "https://nominatim.openstreetmap.org/search";
  const MAP_HEIGHT = "400px"; // 地図の高さ

  // ============================================
  // ユーティリティ関数
  // ============================================

  /**
   * Nominatim APIを使用して住所から緯度経度を取得する
   * @param {string} address - 検索する住所
   * @returns {Promise<Object|null>} - {lat, lon} または null
   */
  async function geocodeAddress(address) {
    if (!address || address.trim() === "") {
      return null;
    }

    try {
      // Nominatim APIにリクエスト
      // 注意: 本番環境では適切なUser-AgentやRefererヘッダーを設定し、
      // リクエスト頻度に配慮する必要があります(今回はテスト実装)
      const params = new URLSearchParams({
        format: "json",
        q: address.trim(),
        limit: 1,
      });

      const response = await fetch(`${NOMINATIM_API_URL}?${params.toString()}`, {
        method: "GET",
        headers: {
          "Accept": "application/json",
        },
      });

      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`);
      }

      const data = await response.json();

      if (data && data.length > 0 && data[0].lat && data[0].lon) {
        return {
          lat: parseFloat(data[0].lat),
          lon: parseFloat(data[0].lon),
          displayName: data[0].display_name || address,
        };
      }

      return null;
    } catch (error) {
      console.error("ジオコーディングエラー:", error);
      throw error;
    }
  }

  /**
   * Leafletを使用して地図を描画する
   * @param {HTMLElement} container - 地図を表示するコンテナ要素
   * @param {number} lat - 緯度
   * @param {number} lon - 経度
   * @param {string} address - 表示する住所
   */
  function renderMap(container, lat, lon, address) {
    // 既存の地図がある場合は削除
    if (container._leafletMap) {
      container._leafletMap.remove();
    }

    // 地図を初期化
    const map = L.map(container).setView([lat, lon], 13);

    // OpenStreetMapのタイルレイヤーを追加
    L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
      attribution:
        '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
      maxZoom: 19,
    }).addTo(map);

    // マーカーを追加
    const marker = L.marker([lat, lon]).addTo(map);
    marker.bindPopup(`<b>住所</b><br>${address}`).openPopup();

    // コンテナに参照を保存(後で削除するため)
    container._leafletMap = map;
  }

  /**
   * 地図表示用のdiv要素を作成する
   * @returns {HTMLElement} - 作成されたdiv要素
   */
  function createMapContainer() {
    const mapDiv = document.createElement("div");
    mapDiv.id = "kintone-map-container";
    mapDiv.style.width = "100%";
    mapDiv.style.height = MAP_HEIGHT;
    mapDiv.style.border = "1px solid #ccc";
    mapDiv.style.borderRadius = "4px";
    return mapDiv;
  }

  // ============================================
  // メイン処理関数
  // ============================================

  /**
   * レコード詳細画面表示時の地図表示処理
   * @param {Object} event - kintoneイベントオブジェクト
   * @returns {Object} - イベントオブジェクト
   */
  async function handleMapDisplay(event) {
    const record = event.record;

    // フィールドから値を取得
    const addressField = record[FIELD_ADDRESS];
    const mapSpaceField = record[FIELD_MAP_SPACE];

    // 住所を取得
    const address = addressField.value || "";

    // 住所が空の場合は処理を終了
    if (!address || address.trim() === "") {
      console.log("住所が入力されていないため、地図を表示しません。");
      return event;
    }

    // スペースフィールドの要素を取得
    const spaceElement = kintone.app.record.getSpaceElement(FIELD_MAP_SPACE);
    if (!spaceElement) {
      console.error(`スペースフィールド "${FIELD_MAP_SPACE}" が見つかりません。`);
      return event;
    }

    // 既存の地図コンテナを削除
    const existingMap = spaceElement.querySelector("#kintone-map-container");
    if (existingMap) {
      if (existingMap._leafletMap) {
        existingMap._leafletMap.remove();
      }
      existingMap.remove();
    }

    // 地図表示用のdiv要素を作成
    const mapContainer = createMapContainer();
    spaceElement.appendChild(mapContainer);

    try {
      // ジオコーディング(住所から緯度経度を取得)
      const location = await geocodeAddress(address);

      if (!location) {
        throw new Error("該当する住所が見つかりませんでした。");
      }

      // 地図を描画
      renderMap(mapContainer, location.lat, location.lon, location.displayName);
    } catch (error) {
      console.error("地図表示エラー:", error);

      // エラーメッセージを表示
      const errorMessage = document.createElement("div");
      errorMessage.style.padding = "10px";
      errorMessage.style.color = "#d32f2f";
      errorMessage.style.backgroundColor = "#ffebee";
      errorMessage.style.border = "1px solid #ef5350";
      errorMessage.style.borderRadius = "4px";
      errorMessage.textContent = `地図の表示に失敗しました: ${error.message}`;
      mapContainer.appendChild(errorMessage);

      // ユーザーに通知
      alert(`地図の表示に失敗しました。\n\nエラー: ${error.message}\n\n住所を確認してください。`);
    }

    return event;
  }

  // ============================================
  // イベントハンドラ登録
  // ============================================

  // レコード詳細画面の表示後
  kintone.events.on("app.record.detail.show", handleMapDisplay);
})();

結果:⭕️

ええやん。外部ライブラリもお安い御用。

スクリーンショット 2025-12-25 0.38.47.png

その3. パワポのスライドショー

Geminiが作ったCursor用プロンプト文

# 役割
あなたはkintoneカスタマイズ開発の高度な専門家です。

# 目的
kintoneのレコード詳細画面にて、添付ファイルフィールドに添付されたPowerPointファイル(.pptx)のスライドプレビューを、スペースフィールドに表示するJavaScriptカスタマイズを作成してください。

# 技術的な前提(最重要)
kintoneの添付ファイル(.pptx)をJavaScript単体でブラウザ上に描画するのは極めて困難です。そのため、今回は**Microsoft Office Online Viewer** (`https://view.officeapps.live.com/op/view.aspx?src=...`) を `iframe` で埋め込む手法を選択します。

ただし、kintoneのファイルURLは認証が必要なため、Office Online Viewerが直接アクセスできず、このままではプレビューが表示されない(エラーになる)可能性が高いことを認識してください。今回は「コードの骨子を作成する」ことを目的とし、動かない場合の代替案をコメントで充実させることに注力してください。

# 要件詳細
1. **トリガーイベント**:
   - レコード詳細画面の表示後 (app.record.detail.show)

2. **使用するフィールド**(定数として定義すること):
   - `file_attachment`: 添付ファイルフィールド(.pptxが添付されている)
   - `preview_space`: スペースフィールド(プレビューを表示する領域)

3. **実装ロジック**:
   - イベント発火時、`file_attachment` フィールドからファイル情報を取得する。
   - ファイルが添付されていない、または複数ある場合は最初の1つを対象とする。
   - ファイルの拡張子が `.pptx` (大文字小文字区別しない) であることを確認する。
   - ファイルのダウンロードURLを取得する。
     - kintone公式のAPIを利用し、認証情報を含まない形(`https://{subdomain}.cybozu.com/k/v1/file.json?fileKey=...` の形式)のURLを構成しようと試みてください(※実際にはこれでは認証が通りませんが、Cursorの判断に任せます)。
   - **Office Online Viewer用のURLを作成**:
     - `https://view.officeapps.live.com/op/view.aspx?src=` の後ろに、取得したファイルURLをエンコードして結合する。
   - `iframe` 要素を作成し、`src` に上記のViewer用URLを設定する。
     - iframeのサイズは、スペースフィールドに合わせて適切に(例: 幅100%, 高さ600px)設定する。
   - `preview_space` の要素を取得し、作成した `iframe` を追加する。

# コードの品質とコメント
- モダンなJavaScript (ES6+) で記述してください。
- **最重要**: コード内のコメントにて、なぜこの方法ではプレビューが表示されない可能性が高いのか(認証問題)を技術的に解説してください。
- **代替案の提示**: 実用的なプレビュー機能を実現するための代替案(例:Google Apps Scriptを使ったPDF変換、AWS S3への一時アップロード、Box APIの利用など)をコメント欄に具体的に記載してください。
- 即時関数でラップし、グローバル汚染を防いでください。

Cursorが作成したJSプログラム

(すごいコメントがきた・・)

/**
 * kintoneカスタマイズ: PowerPointファイルプレビュー機能
 * レコード詳細画面で添付ファイルフィールドに添付されたPowerPointファイル(.pptx)の
 * スライドプレビューをスペースフィールドに表示する
 *
 * 【重要】認証問題について
 * ============================================
 * 本実装では、Microsoft Office Online Viewer (https://view.officeapps.live.com/)
 * をiframeで埋め込む手法を採用しています。
 *
 * しかし、kintoneのファイルダウンロードURLは認証が必要なため、
 * Office Online Viewerが直接アクセスできず、プレビューが表示されない可能性が高いです。
 *
 * 【なぜ動かないのか】
 * 1. kintoneのファイルURLは以下の形式です:
 *    https://{subdomain}.cybozu.com/k/v1/file.json?fileKey=...
 *
 * 2. このURLにアクセスするには、kintoneのセッション認証(Cookie)または
 *    APIトークン認証が必要です。
 *
 * 3. Office Online Viewerは外部サービスであり、kintoneの認証情報を持っていません。
 *    そのため、Office Online ViewerがkintoneのファイルURLにアクセスしようとしても、
 *    認証エラー(401 Unauthorized)が発生します。
 *
 * 4. 結果として、iframe内にエラーメッセージが表示されるか、空白になります。
 *
 * 【代替案】
 * 以下に、実用的なプレビュー機能を実現するための代替案を提示します:
 *
 * 【代替案1】Google Apps Script (GAS) を使用したPDF変換
 * --------------------------------------------------------
 * 1. kintoneの添付ファイルをGAS経由でダウンロード
 * 2. GASでPowerPointをPDFに変換(Google Drive APIを使用)
 * 3. 変換したPDFを一時的にGoogle Driveに保存
 * 4. PDFの公開URLを取得してiframeで表示
 *
 * メリット:
 * - Google Drive APIはPowerPoint→PDF変換をサポート
 * - PDFは認証なしで公開URLからアクセス可能
 *
 * デメリット:
 * - GASのセットアップが必要
 * - 変換処理に時間がかかる可能性
 *
 * 【代替案2】AWS S3への一時アップロード
 * --------------------------------------------------------
 * 1. kintoneの添付ファイルをダウンロード(JavaScriptからkintone REST APIを使用)
 * 2. ファイルをAWS S3の一時バケットにアップロード(署名付きURLを使用)
 * 3. S3の公開URLまたは署名付きURLをOffice Online Viewerに渡す
 *
 * メリット:
 * - S3の公開URLは認証不要でアクセス可能
 * - 一時ファイルの自動削除が可能(ライフサイクルポリシー)
 *
 * デメリット:
 * - AWSアカウントとS3のセットアップが必要
 * - ファイルサイズが大きい場合、アップロードに時間がかかる
 *
 * 【代替案3】Box APIの利用
 * --------------------------------------------------------
 * 1. kintoneの添付ファイルをダウンロード
 * 2. Box APIを使用してファイルをアップロード
 * 3. Boxのプレビュー機能を使用(Box View API)
 *
 * メリット:
 * - Box View APIはPowerPointのプレビューを直接サポート
 * - 認証済みのBoxアカウント経由でアクセス可能
 *
 * デメリット:
 * - BoxアカウントとAPIキーのセットアップが必要
 * - 有料プランが必要な場合がある
 *
 * 【代替案4】サーバーサイドプロキシの実装
 * --------------------------------------------------------
 * 1. 自社サーバーにプロキシエンドポイントを実装
 * 2. プロキシがkintoneのファイルをダウンロード(認証情報付き)
 * 3. プロキシがファイルを一時的に公開URLで提供
 * 4. Office Online Viewerがプロキシ経由でファイルにアクセス
 *
 * メリット:
 * - 認証問題を解決できる
 * - 自社インフラで完全に制御可能
 *
 * デメリット:
 * - サーバーサイドの実装が必要
 * - セキュリティ対策(認証、レート制限など)が必要
 *
 * 【代替案5】mammoth.js + スライド画像抽出(クライアントサイド)
 * --------------------------------------------------------
 * 1. .pptxファイルをJavaScriptでダウンロード(Blob形式)
 * 2. JSZip等を使用して.pptxファイルを展開
 * 3. スライド画像(PNG/JPEG)を抽出
 * 4. 画像をスペースフィールドに表示
 *
 * メリット:
 * - サーバーサイド不要
 * - 完全にクライアントサイドで完結
 *
 * デメリット:
 * - .pptxの内部構造を解析する必要がある(複雑)
 * - 大きなファイルの場合、ブラウザのメモリを消費
 * - アニメーションや特殊効果は失われる
 */
(function () {
  "use strict";

  // ============================================
  // 定数定義(フィールドコード)
  // ============================================
  const FIELD_FILE_ATTACHMENT = "file_attachment"; // 添付ファイルフィールド(.pptxが添付されている)
  const FIELD_PREVIEW_SPACE = "preview_space"; // スペースフィールド(プレビューを表示する領域)

  // ============================================
  // 設定
  // ============================================
  const OFFICE_VIEWER_BASE_URL = "https://view.officeapps.live.com/op/view.aspx?src=";
  const IFRAME_HEIGHT = "600px"; // iframeの高さ

  // ============================================
  // ユーティリティ関数
  // ============================================

  /**
   * ファイル名の拡張子を取得する
   * @param {string} filename - ファイル名
   * @returns {string} - 拡張子(小文字、ドットなし)
   */
  function getFileExtension(filename) {
    if (!filename || typeof filename !== "string") {
      return "";
    }
    const parts = filename.split(".");
    return parts.length > 1 ? parts[parts.length - 1].toLowerCase() : "";
  }

  /**
   * kintoneのファイルダウンロードURLを構築する
   * @param {string} fileKey - ファイルキー
   * @returns {string} - ファイルダウンロードURL
   *
   * 【注意】
   * このURLは認証が必要なため、Office Online Viewerから直接アクセスできません。
   * 実際の実装では、サーバーサイドプロキシや代替案を使用する必要があります。
   */
  function buildFileUrl(fileKey) {
    // 現在のホスト名からkintoneのベースURLを取得
    // 例: example.cybozu.com または example.kintone.com
    const hostname = window.location.hostname;

    // ファイルダウンロードURLを構築
    // 注意: このURLは認証が必要で、Office Online Viewerからはアクセスできません
    return `https://${hostname}/k/v1/file.json?fileKey=${encodeURIComponent(fileKey)}`;
  }

  /**
   * Office Online Viewer用のURLを作成する
   * @param {string} fileUrl - ファイルのURL
   * @returns {string} - Office Online Viewer用のURL
   */
  function buildOfficeViewerUrl(fileUrl) {
    // URLをエンコードしてOffice Online ViewerのURLに結合
    return OFFICE_VIEWER_BASE_URL + encodeURIComponent(fileUrl);
  }

  /**
   * プレビュー用のiframe要素を作成する
   * @param {string} viewerUrl - Office Online ViewerのURL
   * @returns {HTMLIFrameElement} - 作成されたiframe要素
   */
  function createPreviewIframe(viewerUrl) {
    const iframe = document.createElement("iframe");
    iframe.src = viewerUrl;
    iframe.style.width = "100%";
    iframe.style.height = IFRAME_HEIGHT;
    iframe.style.border = "1px solid #ccc";
    iframe.style.borderRadius = "4px";
    iframe.setAttribute("frameborder", "0");
    iframe.setAttribute("allowfullscreen", "true");
    iframe.setAttribute("sandbox", "allow-same-origin allow-scripts allow-popups allow-forms");

    // エラーハンドリング(iframeの読み込みエラーを検知)
    iframe.onerror = function () {
      console.error("iframeの読み込みに失敗しました。");
    };

    return iframe;
  }

  /**
   * エラーメッセージを表示する要素を作成する
   * @param {string} message - エラーメッセージ
   * @returns {HTMLElement} - エラーメッセージ要素
   */
  function createErrorMessage(message) {
    const errorDiv = document.createElement("div");
    errorDiv.style.padding = "15px";
    errorDiv.style.color = "#d32f2f";
    errorDiv.style.backgroundColor = "#ffebee";
    errorDiv.style.border = "1px solid #ef5350";
    errorDiv.style.borderRadius = "4px";
    errorDiv.style.marginTop = "10px";
    errorDiv.innerHTML = `
      <strong>プレビュー表示エラー</strong><br>
      ${message}<br>
      <small style="margin-top: 10px; display: block;">
        【原因】<br>
        kintoneのファイルURLは認証が必要なため、Office Online Viewerが直接アクセスできません。<br>
        実用的なプレビュー機能を実現するには、コード上部のコメントに記載されている代替案を検討してください。
      </small>
    `;
    return errorDiv;
  }

  // ============================================
  // メイン処理関数
  // ============================================

  /**
   * レコード詳細画面表示時のプレビュー表示処理
   * @param {Object} event - kintoneイベントオブジェクト
   * @returns {Object} - イベントオブジェクト
   */
  function handlePreviewDisplay(event) {
    const record = event.record;

    // フィールドから値を取得
    const fileAttachmentField = record[FIELD_FILE_ATTACHMENT];
    const previewSpaceField = record[FIELD_PREVIEW_SPACE];

    // 添付ファイル情報を取得
    const files = fileAttachmentField.value || [];

    // ファイルが添付されていない場合は処理を終了
    if (files.length === 0) {
      console.log("添付ファイルがありません。");
      return event;
    }

    // 最初のファイルを取得
    const firstFile = files[0];

    // ファイルの拡張子をチェック(.pptxであることを確認)
    const fileExtension = getFileExtension(firstFile.name);
    if (fileExtension !== "pptx") {
      console.log(`ファイル "${firstFile.name}" はPowerPointファイル(.pptx)ではありません。`);
      return event;
    }

    // スペースフィールドの要素を取得
    const spaceElement = kintone.app.record.getSpaceElement(FIELD_PREVIEW_SPACE);
    if (!spaceElement) {
      console.error(`スペースフィールド "${FIELD_PREVIEW_SPACE}" が見つかりません。`);
      return event;
    }

    // 既存のプレビュー要素を削除
    const existingPreview = spaceElement.querySelector("#kintone-pptx-preview-container");
    if (existingPreview) {
      existingPreview.remove();
    }

    // プレビューコンテナを作成
    const previewContainer = document.createElement("div");
    previewContainer.id = "kintone-pptx-preview-container";
    previewContainer.style.width = "100%";
    previewContainer.style.marginTop = "10px";

    // ファイルURLを構築
    // 【重要】このURLは認証が必要なため、Office Online Viewerからはアクセスできません
    const fileUrl = buildFileUrl(firstFile.fileKey);
    console.log("構築されたファイルURL:", fileUrl);
    console.warn("【警告】このURLは認証が必要なため、Office Online Viewerからはアクセスできません。");

    // Office Online Viewer用のURLを作成
    const viewerUrl = buildOfficeViewerUrl(fileUrl);
    console.log("Office Online Viewer URL:", viewerUrl);

    // iframeを作成
    const previewIframe = createPreviewIframe(viewerUrl);

    // プレビューコンテナにiframeを追加
    previewContainer.appendChild(previewIframe);

    // エラーメッセージも追加(認証問題について説明)
    const errorMessage = createErrorMessage(
      `ファイル "${firstFile.name}" のプレビューを表示しようとしましたが、<br>
      認証の問題により表示できない可能性が高いです。`,
    );
    previewContainer.appendChild(errorMessage);

    // スペースフィールドに追加
    spaceElement.appendChild(previewContainer);

    // 注意: 実際にはプレビューが表示されない可能性が高いため、
    // 代替案の実装を検討してください(コード上部のコメントを参照)

    return event;
  }

  // ============================================
  // イベントハンドラ登録
  // ============================================

  // レコード詳細画面の表示後
  kintone.events.on("app.record.detail.show", handlePreviewDisplay);
})();

結果:❌️

ざんねん・・・。コメントの通り.pptxはだめだったか。

スクリーンショット 2025-12-25 0.55.36.png

その3の2. PDFファイルでのスライドプレビュー

Cursorが作成したJSプログラム

/**
 * kintoneカスタマイズ: PDFファイルプレビュー機能
 * レコード詳細画面で添付ファイルフィールドに添付されたPDFファイル(PowerPointから変換されたものを想定)の
 * プレビューをスペースフィールドに表示する
 */
(function () {
  "use strict";

  // ============================================
  // 定数定義(フィールドコード)
  // ============================================
  const FIELD_FILE_ATTACHMENT = "file_attachment"; // 添付ファイルフィールド(PDFが添付されている)
  const FIELD_PREVIEW_SPACE = "preview_space"; // スペースフィールド(プレビューを表示する領域、要素IDも同名を想定)

  // ============================================
  // 設定
  // ============================================
  const IFRAME_HEIGHT = "600px"; // iframeの高さ

  // ============================================
  // ユーティリティ関数
  // ============================================

  /**
   * ファイル名の拡張子を取得する
   * @param {string} filename - ファイル名
   * @returns {string} - 拡張子(小文字、ドット含む)
   */
  function getFileExtension(filename) {
    if (!filename || typeof filename !== "string") {
      return "";
    }
    const parts = filename.split(".");
    return parts.length > 1 ? "." + parts[parts.length - 1].toLowerCase() : "";
  }

  /**
   * kintoneのファイルプレビュー用URLを構築する
   * @param {string} fileKey - ファイルキー
   * @returns {string} - ファイルプレビュー用URL
   *
   * 【重要】
   * このURL形式(/k/file.do?fileKey=...)を使用することで、
   * ログインセッションを利用してiframe内でPDFを表示可能です。
   */
  function buildPreviewUrl(fileKey) {
    // 現在のドメイン名(location.origin)を利用してURLを組み立てる
    // 例: https://subdomain.cybozu.com/k/file.do?fileKey=xxxxxx
    return `${location.origin}/k/file.do?fileKey=${encodeURIComponent(fileKey)}`;
  }

  /**
   * プレビュー用のiframe要素を作成する
   * @param {string} previewUrl - プレビュー用URL
   * @returns {HTMLIFrameElement} - 作成されたiframe要素
   */
  function createPreviewIframe(previewUrl) {
    const iframe = document.createElement("iframe");
    iframe.src = previewUrl;
    iframe.style.width = "100%";
    iframe.style.height = IFRAME_HEIGHT;
    iframe.style.border = "none"; // 境界線なし
    iframe.setAttribute("frameborder", "0");
    iframe.setAttribute("allowfullscreen", "true");

    return iframe;
  }

  /**
   * エラーメッセージを表示する要素を作成する
   * @param {string} message - エラーメッセージ
   * @returns {HTMLElement} - エラーメッセージ要素
   */
  function createErrorMessage(message) {
    const errorDiv = document.createElement("div");
    errorDiv.style.padding = "15px";
    errorDiv.style.color = "#d32f2f";
    errorDiv.style.backgroundColor = "#ffebee";
    errorDiv.style.border = "1px solid #ef5350";
    errorDiv.style.borderRadius = "4px";
    errorDiv.style.marginTop = "10px";
    errorDiv.textContent = message;
    return errorDiv;
  }

  // ============================================
  // メイン処理関数
  // ============================================

  /**
   * レコード詳細画面表示時のプレビュー表示処理
   * @param {Object} event - kintoneイベントオブジェクト
   * @returns {Object} - イベントオブジェクト
   */
  function handlePreviewDisplay(event) {
    const record = event.record;

    // フィールドから値を取得
    const fileAttachmentField = record[FIELD_FILE_ATTACHMENT];
    const previewSpaceField = record[FIELD_PREVIEW_SPACE];

    // スペースフィールドの要素を取得し、中身をクリア
    const spaceElement = kintone.app.record.getSpaceElement(FIELD_PREVIEW_SPACE);
    if (!spaceElement) {
      console.error(`スペースフィールド "${FIELD_PREVIEW_SPACE}" が見つかりません。`);
      return event;
    }

    // スペースフィールドの中身をクリア
    spaceElement.innerHTML = "";

    // 添付ファイル情報を取得
    const files = fileAttachmentField.value || [];

    // ファイルが添付されていない場合は処理を終了
    if (files.length === 0) {
      console.log("添付ファイルがありません。");
      return event;
    }

    // 最初の1つのファイルを取得
    const firstFile = files[0];

    // ファイルの拡張子が.pdfであることを確認(大文字小文字区別しない)
    const fileExtension = getFileExtension(firstFile.name);
    if (fileExtension !== ".pdf") {
      // PDFでない場合は、スペースフィールドにメッセージを表示して終了
      const errorMessage = createErrorMessage("PDFファイルではありません");
      spaceElement.appendChild(errorMessage);
      return event;
    }

    // プレビュー用URLを構築
    const previewUrl = buildPreviewUrl(firstFile.fileKey);

    // iframe要素を作成
    const previewIframe = createPreviewIframe(previewUrl);

    // スペースフィールドにiframeを追加
    spaceElement.appendChild(previewIframe);

    return event;
  }

  // ============================================
  // イベントハンドラ登録
  // ============================================

  // レコード詳細画面の表示後
  kintone.events.on("app.record.detail.show", handlePreviewDisplay);
})();

結果:❌️

だめやないかーい。添付ファイルのダウンロードの処理が「subdomain.cybozu.com/k/file.do?fileKey=xxxxxx」ってよくわからないURL構成になっていてファイルのDLがうまくできていない様子。

スクリーンショット 2025-12-25 1.13.52.png

余談

ファイルダウンロード部分がおかしいよって伝えたら修正版を作ってきて、それはちゃんと動きました。
けど、スライドショーにはならず縦スクロールに。

スクリーンショット 2025-12-25 1.19.03.png

その4. 一覧画面でインベーダーゲーム

Geminiが作ったCursor用プロンプト文

# 役割
あなたはkintoneカスタマイズ開発およびゲーム開発(HTML5 Canvas)の専門家です。

# 概要
kintoneの「カスタマイズビュー」機能を利用して、インベーダーゲーム風のシューティングゲームを作成してください。
敵キャラクターは、現在アプリに登録されているレコードの「顧客名」データを利用して生成します。

# 前提条件
1. **実行画面**: カスタマイズビュー(一覧画面)
2. **HTML設定**: カスタマイズビューのHTMLには `<div id="game-root"></div>` が記述されているものとします。
3. **使用フィールド**: `customer_name` (文字列1行フィールド)

# 要件詳細

## 1. データ取得と初期化
- イベント: `app.record.index.show`
- `id="game-root"` 要素が存在しない場合は処理を中断する(通常のリストビューでの誤動作防止)。
- kintone REST APIを使用してレコードを取得する(最大20〜30件程度に制限し、ランダムまたは新着順で取得)。
- 取得したレコードの `customer_name` を敵キャラクターのラベルとして使用する。

## 2. ゲームエンジン (HTML5 Canvas)
- `game-root` 内に `<canvas>` 要素を動的に生成する(幅: 800px, 高さ: 600px 程度)。
- 背景は宇宙っぽい黒色にする。
- **ゲームループ**: `requestAnimationFrame` を使用して滑らかな描画を行う。

## 3. キャラクターと操作
- **プレイヤー (自機)**:
  - 画面下部に配置。単純な図形(三角形や戦闘機っぽい形)で描画。
  - **操作**:
    - `W`: 上, `A`: 左, `S`: 下, `D`: 右 に移動(画面外には出ない)。
    - `Space`: 弾を発射(連射制限を設ける)。
- **敵 (顧客データ)**:
  - 画面上部に整列して配置する。
  - 敵のグラフィックの上に、取得した `customer_name` をテキストで描画する。
  - 左右に移動しながら徐々に降下してくる。
  - ランダムに弾を発射して攻撃してくる。

## 4. 当たり判定とゲーム進行
- **矩形判定**(AABB)を用いて、弾と敵、弾とプレイヤー、敵とプレイヤーの衝突を判定する。
- プレイヤーの弾が敵に当たったら、その敵(顧客)を消去しスコアを加算する。
- 敵の弾がプレイヤーに当たる、または敵が最下部まで到達したら「Game Over」を表示。
- 全ての敵を倒したら「Clear!」を表示。
- ゲーム終了後は「リトライ」ボタンを表示し、クリックで再ゲーム可能にする。

# コードの品質
- 1つのJavaScriptファイルで完結するように記述してください(CSSが必要な場合はJavaScript内で `<style>` タグを生成して埋め込む)。
- ゲームのクラス設計(Game, Player, Enemy, Bullet)を行い、可読性を高くしてください。
- 顧客名が長い場合でも画面が破綻しないよう、フォントサイズ調整や文字数制限('...'で省略など)の工夫を入れてください。

Cursorが作成したJSプログラム

/**
 * kintoneカスタマイズ: インベーダーゲーム風シューティングゲーム
 * カスタマイズビューで動作し、レコードの顧客名を敵として表示する
 */
(function () {
  "use strict";

  // ============================================
  // 定数定義
  // ============================================
  const GAME_ROOT_ID = "game-root";
  const FIELD_CUSTOMER_NAME = "customer_name";
  const CANVAS_WIDTH = 800;
  const CANVAS_HEIGHT = 600;
  const MAX_RECORDS = 25; // 取得するレコード数の上限

  // ============================================
  // スタイルの注入
  // ============================================
  function injectStyles() {
    if (document.getElementById("invader-game-styles")) {
      return;
    }

    const style = document.createElement("style");
    style.id = "invader-game-styles";
    style.textContent = `
      #${GAME_ROOT_ID} {
        display: flex;
        flex-direction: column;
        align-items: center;
        padding: 20px;
        background-color: #1a1a2e;
      }
      #${GAME_ROOT_ID} canvas {
        border: 2px solid #0f3460;
        border-radius: 8px;
        box-shadow: 0 0 20px rgba(0, 150, 255, 0.3);
      }
      #${GAME_ROOT_ID} .game-ui {
        color: #fff;
        font-family: 'Arial', sans-serif;
        margin-top: 10px;
        font-size: 18px;
      }
      #${GAME_ROOT_ID} .retry-button {
        margin-top: 15px;
        padding: 10px 30px;
        font-size: 16px;
        background-color: #0f3460;
        color: #fff;
        border: 2px solid #0ea5e9;
        border-radius: 5px;
        cursor: pointer;
        transition: all 0.3s;
      }
      #${GAME_ROOT_ID} .retry-button:hover {
        background-color: #0ea5e9;
        transform: scale(1.05);
      }
      #${GAME_ROOT_ID} .game-overlay {
        position: absolute;
        top: 50%;
        left: 50%;
        transform: translate(-50%, -50%);
        background-color: rgba(0, 0, 0, 0.9);
        color: #fff;
        padding: 30px 50px;
        border-radius: 10px;
        text-align: center;
        font-size: 32px;
        font-weight: bold;
        z-index: 1000;
        border: 3px solid #0ea5e9;
      }
    `;
    document.head.appendChild(style);
  }

  // ============================================
  // Bullet クラス
  // ============================================
  class Bullet {
    constructor(x, y, speed, isPlayerBullet = true) {
      this.x = x;
      this.y = y;
      this.speed = speed;
      this.isPlayerBullet = isPlayerBullet;
      this.width = 4;
      this.height = 10;
      this.active = true;
    }

    update() {
      if (this.isPlayerBullet) {
        this.y -= this.speed;
      } else {
        this.y += this.speed;
      }

      // 画面外に出たら非アクティブ
      if (this.y < 0 || this.y > CANVAS_HEIGHT) {
        this.active = false;
      }
    }

    draw(ctx) {
      ctx.fillStyle = this.isPlayerBullet ? "#0ea5e9" : "#ff4444";
      ctx.fillRect(this.x - this.width / 2, this.y - this.height / 2, this.width, this.height);
    }

    getBounds() {
      return {
        x: this.x - this.width / 2,
        y: this.y - this.height / 2,
        width: this.width,
        height: this.height,
      };
    }
  }

  // ============================================
  // Player クラス
  // ============================================
  class Player {
    constructor(x, y) {
      this.x = x;
      this.y = y;
      this.width = 40;
      this.height = 30;
      this.speed = 5;
      this.bullets = [];
      this.lastShotTime = 0;
      this.shootCooldown = 200; // ミリ秒
      this.keys = {};
    }

    update() {
      // 移動処理
      if (this.keys["w"] || this.keys["W"]) {
        this.y = Math.max(this.height / 2, this.y - this.speed);
      }
      if (this.keys["s"] || this.keys["S"]) {
        this.y = Math.min(CANVAS_HEIGHT - this.height / 2, this.y + this.speed);
      }
      if (this.keys["a"] || this.keys["A"]) {
        this.x = Math.max(this.width / 2, this.x - this.speed);
      }
      if (this.keys["d"] || this.keys["D"]) {
        this.x = Math.min(CANVAS_WIDTH - this.width / 2, this.x + this.speed);
      }

      // 弾の発射
      if ((this.keys[" "] || this.keys["Space"]) && Date.now() - this.lastShotTime > this.shootCooldown) {
        this.shoot();
        this.lastShotTime = Date.now();
      }

      // 弾の更新
      this.bullets.forEach((bullet) => bullet.update());
      this.bullets = this.bullets.filter((bullet) => bullet.active);
    }

    shoot() {
      this.bullets.push(new Bullet(this.x, this.y - this.height / 2, 8, true));
    }

    draw(ctx) {
      // プレイヤーを三角形で描画
      ctx.fillStyle = "#0ea5e9";
      ctx.beginPath();
      ctx.moveTo(this.x, this.y - this.height / 2);
      ctx.lineTo(this.x - this.width / 2, this.y + this.height / 2);
      ctx.lineTo(this.x + this.width / 2, this.y + this.height / 2);
      ctx.closePath();
      ctx.fill();

      // 弾の描画
      this.bullets.forEach((bullet) => bullet.draw(ctx));
    }

    getBounds() {
      return {
        x: this.x - this.width / 2,
        y: this.y - this.height / 2,
        width: this.width,
        height: this.height,
      };
    }

    checkCollision(bullet) {
      const playerBounds = this.getBounds();
      const bulletBounds = bullet.getBounds();
      return (
        playerBounds.x < bulletBounds.x + bulletBounds.width &&
        playerBounds.x + playerBounds.width > bulletBounds.x &&
        playerBounds.y < bulletBounds.y + bulletBounds.height &&
        playerBounds.y + playerBounds.height > bulletBounds.y
      );
    }
  }

  // ============================================
  // Enemy クラス
  // ============================================
  class Enemy {
    constructor(x, y, customerName) {
      this.x = x;
      this.y = y;
      this.width = 60;
      this.height = 40;
      this.speedX = 1;
      this.speedY = 0;
      this.customerName = customerName || "";
      this.bullets = [];
      this.lastShotTime = 0;
      this.shootInterval = 2000 + Math.random() * 3000; // 2-5秒間隔
      this.active = true;
      this.moveDirection = Math.random() > 0.5 ? 1 : -1;
    }

    update(game) {
      if (!this.active) return;

      // 左右移動
      this.x += this.speedX * this.moveDirection;

      // 画面端で反転
      if (this.x <= this.width / 2 || this.x >= CANVAS_WIDTH - this.width / 2) {
        this.moveDirection *= -1;
        this.y += 20; // 降下
      }

      // ランダムに弾を発射
      if (Date.now() - this.lastShotTime > this.shootInterval) {
        if (Math.random() < 0.01) {
          // 1%の確率で発射
          this.shoot();
          this.lastShotTime = Date.now();
          this.shootInterval = 2000 + Math.random() * 3000;
        }
      }

      // 弾の更新
      this.bullets.forEach((bullet) => bullet.update());
      this.bullets = this.bullets.filter((bullet) => bullet.active);
    }

    shoot() {
      this.bullets.push(new Bullet(this.x, this.y + this.height / 2, 4, false));
    }

    draw(ctx) {
      if (!this.active) return;

      // 敵のグラフィック(四角形)
      ctx.fillStyle = "#ff4444";
      ctx.fillRect(this.x - this.width / 2, this.y - this.height / 2, this.width, this.height);

      // 顧客名の表示(長い場合は省略)
      ctx.fillStyle = "#fff";
      ctx.font = "12px Arial";
      ctx.textAlign = "center";
      ctx.textBaseline = "middle";

      let displayName = this.customerName;
      const maxWidth = this.width - 10;
      const metrics = ctx.measureText(displayName);

      // テキストが長すぎる場合は省略
      if (metrics.width > maxWidth) {
        while (ctx.measureText(displayName + "...").width > maxWidth && displayName.length > 0) {
          displayName = displayName.slice(0, -1);
        }
        displayName += "...";
      }

      ctx.fillText(displayName, this.x, this.y);

      // 弾の描画
      this.bullets.forEach((bullet) => bullet.draw(ctx));
    }

    getBounds() {
      return {
        x: this.x - this.width / 2,
        y: this.y - this.height / 2,
        width: this.width,
        height: this.height,
      };
    }

    checkCollision(bullet) {
      if (!this.active) return false;
      const enemyBounds = this.getBounds();
      const bulletBounds = bullet.getBounds();
      return (
        enemyBounds.x < bulletBounds.x + bulletBounds.width &&
        enemyBounds.x + enemyBounds.width > bulletBounds.x &&
        enemyBounds.y < bulletBounds.y + bulletBounds.height &&
        enemyBounds.y + enemyBounds.height > bulletBounds.y
      );
    }
  }

  // ============================================
  // Game クラス
  // ============================================
  class Game {
    constructor(canvas, customerNames) {
      this.canvas = canvas;
      this.ctx = canvas.getContext("2d");
      this.player = new Player(CANVAS_WIDTH / 2, CANVAS_HEIGHT - 50);
      this.enemies = [];
      this.score = 0;
      this.gameState = "playing"; // playing, gameOver, cleared
      this.animationId = null;

      // 敵の初期配置
      this.initializeEnemies(customerNames);

      // イベントリスナーの設定
      this.setupEventListeners();
    }

    initializeEnemies(customerNames) {
      const rows = Math.ceil(Math.sqrt(customerNames.length));
      const cols = Math.ceil(customerNames.length / rows);
      const spacingX = CANVAS_WIDTH / (cols + 1);
      const spacingY = 80;
      const startY = 60;

      customerNames.forEach((name, index) => {
        const row = Math.floor(index / cols);
        const col = index % cols;
        const x = spacingX * (col + 1);
        const y = startY + row * spacingY;
        this.enemies.push(new Enemy(x, y, name));
      });
    }

    setupEventListeners() {
      window.addEventListener("keydown", (e) => {
        this.player.keys[e.key] = true;
        if (e.key === " ") {
          e.preventDefault();
        }
      });

      window.addEventListener("keyup", (e) => {
        this.player.keys[e.key] = false;
      });
    }

    update() {
      if (this.gameState !== "playing") return;

      // プレイヤーの更新
      this.player.update();

      // 敵の更新
      this.enemies.forEach((enemy) => {
        enemy.update(this);
      });

      // プレイヤーの弾と敵の衝突判定
      this.player.bullets.forEach((bullet, bulletIndex) => {
        this.enemies.forEach((enemy, enemyIndex) => {
          if (enemy.checkCollision(bullet)) {
            bullet.active = false;
            enemy.active = false;
            this.score += 10;
          }
        });
      });

      // 敵の弾とプレイヤーの衝突判定
      this.enemies.forEach((enemy) => {
        enemy.bullets.forEach((bullet) => {
          if (this.player.checkCollision(bullet)) {
            this.gameState = "gameOver";
            this.showGameOver();
          }
        });
      });

      // 敵が最下部に到達したかチェック
      this.enemies.forEach((enemy) => {
        if (enemy.active && enemy.y + enemy.height / 2 >= CANVAS_HEIGHT - 50) {
          this.gameState = "gameOver";
          this.showGameOver();
        }
      });

      // 全ての敵を倒したかチェック
      const activeEnemies = this.enemies.filter((enemy) => enemy.active);
      if (activeEnemies.length === 0 && this.gameState === "playing") {
        this.gameState = "cleared";
        this.showCleared();
      }
    }

    draw() {
      // 背景をクリア(黒)
      this.ctx.fillStyle = "#0a0a0a";
      this.ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);

      // 星の背景効果
      this.drawStars();

      // プレイヤーの描画
      this.player.draw(this.ctx);

      // 敵の描画
      this.enemies.forEach((enemy) => {
        enemy.draw(this.ctx);
      });

      // スコアの表示
      this.ctx.fillStyle = "#fff";
      this.ctx.font = "20px Arial";
      this.ctx.textAlign = "left";
      this.ctx.fillText(`Score: ${this.score}`, 10, 30);
    }

    drawStars() {
      // 簡単な星の効果(固定点)
      this.ctx.fillStyle = "#fff";
      const stars = [
        { x: 50, y: 50 },
        { x: 150, y: 80 },
        { x: 250, y: 120 },
        { x: 350, y: 60 },
        { x: 450, y: 100 },
        { x: 550, y: 70 },
        { x: 650, y: 90 },
        { x: 750, y: 110 },
      ];
      stars.forEach((star) => {
        this.ctx.fillRect(star.x, star.y, 2, 2);
      });
    }

    gameLoop() {
      this.update();
      this.draw();

      if (this.gameState === "playing") {
        this.animationId = requestAnimationFrame(() => this.gameLoop());
      }
    }

    start() {
      this.gameLoop();
    }

    stop() {
      if (this.animationId) {
        cancelAnimationFrame(this.animationId);
        this.animationId = null;
      }
    }

    showGameOver() {
      this.stop();
      const overlay = document.createElement("div");
      overlay.className = "game-overlay";
      overlay.textContent = "Game Over";
      overlay.style.position = "absolute";
      overlay.style.top = "50%";
      overlay.style.left = "50%";
      overlay.style.transform = "translate(-50%, -50%)";
      this.canvas.parentElement.style.position = "relative";
      this.canvas.parentElement.appendChild(overlay);

      const retryButton = document.createElement("button");
      retryButton.className = "retry-button";
      retryButton.textContent = "リトライ";
      retryButton.onclick = () => {
        location.reload();
      };
      overlay.appendChild(retryButton);
    }

    showCleared() {
      this.stop();
      const overlay = document.createElement("div");
      overlay.className = "game-overlay";
      overlay.textContent = "Clear!";
      overlay.style.position = "absolute";
      overlay.style.top = "50%";
      overlay.style.left = "50%";
      overlay.style.transform = "translate(-50%, -50%)";
      this.canvas.parentElement.style.position = "relative";
      this.canvas.parentElement.appendChild(overlay);

      const retryButton = document.createElement("button");
      retryButton.className = "retry-button";
      retryButton.textContent = "リトライ";
      retryButton.onclick = () => {
        location.reload();
      };
      overlay.appendChild(retryButton);
    }
  }

  // ============================================
  // kintone REST API でレコードを取得
  // ============================================
  async function fetchRecords() {
    try {
      const appId = kintone.app.getId();
      const query = `order by $id desc limit ${MAX_RECORDS}`;

      const response = await kintone.api("/k/v1/records", "GET", {
        app: appId,
        query: query,
      });

      const customerNames = response.records
        .map((record) => {
          const customerNameField = record[FIELD_CUSTOMER_NAME];
          return customerNameField ? customerNameField.value : null;
        })
        .filter((name) => name && name.trim() !== "");

      // レコードが少ない場合は、ダミー敵を追加
      if (customerNames.length === 0) {
        customerNames.push("サンプル顧客1", "サンプル顧客2", "サンプル顧客3");
      }

      return customerNames;
    } catch (error) {
      console.error("レコードの取得に失敗しました:", error);
      // エラー時はダミー敵を返す
      return ["エラー顧客1", "エラー顧客2", "エラー顧客3", "エラー顧客4", "エラー顧客5"];
    }
  }

  // ============================================
  // ゲームの初期化
  // ============================================
  async function initializeGame() {
    // game-root要素の存在確認
    const gameRoot = document.getElementById(GAME_ROOT_ID);
    if (!gameRoot) {
      console.log("game-root要素が見つかりません。通常のリストビューです。");
      return;
    }

    // スタイルの注入
    injectStyles();

    // 既存のコンテンツをクリア
    gameRoot.innerHTML = "";

    // Canvas要素の作成
    const canvas = document.createElement("canvas");
    canvas.width = CANVAS_WIDTH;
    canvas.height = CANVAS_HEIGHT;
    gameRoot.appendChild(canvas);

    // UI要素の作成
    const uiDiv = document.createElement("div");
    uiDiv.className = "game-ui";
    uiDiv.innerHTML = `
      <div>操作: W(上) A(左) S(下) D(右) / Space(攻撃)</div>
    `;
    gameRoot.appendChild(uiDiv);

    // レコードを取得
    const customerNames = await fetchRecords();

    // ゲームの開始
    const game = new Game(canvas, customerNames);
    game.start();
  }

  // ============================================
  // イベントハンドラ登録
  // ============================================
  kintone.events.on("app.record.index.show", initializeGame);
})();

結果:⭕️

なんかできたんだけど!!すごい!
一般的なHTML×JS×CSSで作れるレベルだから逆に作りやすいのかも。

スクリーンショット 2025-12-25 1.26.58.png
スクリーンショット 2025-12-25 1.26.38.png

まとめ

なんか記事を書くまでは「どうせ最初のお題くらいしか動かないだろう」って思っていたけど、なんかほとんど動いてしまった。パワポ/PDFのやつ以外は本当に一発勝負ですからね。

いつもの開発時よりCursorの精度が高いのはちゃんとプロンプト文をつくっているからかな。。改めてプロンプト文の大事さを実感できた時間でした。(今後はGeminiにプロンプト作ってもらお)

ということで、今日はここまで!メリクリ🎄

5
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?