Markdown AIでそれらしいWebサイトを機能マシで作る

[Markdown AIのサーバーAI機能を使ってWebサイトを作ってみよう by MarkdownAI Advent Calendar 2024](https://qiita.com/advent-calendar/2024/markdownai)の12月20日分の企画に滑り込みます。

https://qiita.com/advent-calendar/2024/markdownai

`Markdown AI`で架空の映画をテーマにしたWebサイトを作ってみました。

https://mdown.ai/content/3619d16a-e153-4f9e-bca7-c438b80aabcd

内容はAI[Gemini](https://gemini.google.com/?hl=ja)や[ImageFx](https://labs.google/fx/tools/image-fx)をもりもりに使っています。

https://gemini.google.com/?hl=ja

https://labs.google/fx/tools/image-fx

 - Google Apps Scriptでホストしたタイピングゲーム
 - Looker Studioによるダッシュボード

上記も使ってみました。

# Markdown AIとは
その名の通り`Markdown`を書いて、手軽にWebサイトを構築できるツールです。
`HTML`や`CSS`などの知識がなくても、`Markdown`で書いたテキストをWebサイトとして公開できます。
サンプルアプリが充実していますので、ざっとどんなものが作れるのかは調べられます。

https://mdown.com/ja/category/sample-app/

アレンジの詳細は下記を参考にしてみてください。

| 項目 | 説明 |
|:---|:---|
| 作成できるWebページ数 | 最大500ページ |
| 1ページあたりの入力可能文字数 | 半角英語で約100万文字 |
| 挿入可能な画像枚数 | 500枚 |
| 画像の最大サイズ | 7.5MB |
| 拡張Markdown | テーブル、チェックボックス ※GitHubで使える拡張機能やiframe にも対応しているようです


https://mdown.com/ja/qa-for-markdown/

**作成できるWebページを最大500ページまで、各ページには、半角英語で約100万文字まで入力可能です。**

> Q：HTML形式で記述すれば反映されますか？ A：反映されます。
>> [Q&A – Markdown編 –](https://mdown.com/ja/qa-for-markdown/)

上記がありだとなんでもできそうです。ということでLet's challenge!!
アレンジマシマシでWebサイトを作ってみましょう！

# Markdown AIで作ってみたWebサイト

Markdown AIを使って、映画 **「OTTER FORCE: The Last Water Guardian」** の架空の広告サイトを作成してみました。

https://mdown.ai/content/3619d16a-e153-4f9e-bca7-c438b80aabcd

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2734332/e84110c5-530f-5a3f-e308-4a005c858d5b.png)

:::note info
画像は[ImageFx](https://labs.google/fx/tools/image-fx)をもりもりに使っています。
下記の通り架空の情報です。
> 登場するキャラクター、出来事、団体は、すべてOTTER FORCEの世界観内での創作です。 実在の人物や出来事、団体との類似は完全に偶然であり、意図したものではありません。

:::

`Markdown`だけではなく`HTML`をふんだんに使ったサイトです。
今回のテーマは`Server AIの活用`のため、チャットボットを入れてみました。

数回のクリックで**Server AI(チャットボット)を挿入できます。**

下記を入れると
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2734332/0fe6bdc9-05a6-18f8-b372-2488dc81eb16.png)

ページの情報に基づいた回答が返ってきます。

> "もちろん！*OTTER FORCE: The Last Water Guardian*の特長は、冒険と友情、そして環境保護のメッセージが詰まった素晴らしいストーリーです。映画は2157年の未来を舞台に、主人公のリップルが古代のライトセーバーを発見し、邪悪なハイドラテックと戦いながら地球の水のバランスを取り戻す旅を描いています。また、映画のビジュアルは美しい水中シーンや未来的なテクノロジーで彩られ、観客を魅了します。リップルの成長や仲間たちとの絆も感動的で、観る人に勇気を与えてくれます。もっとリップルの冒険や仲間たちについて知りたいですか？"
>> 返答例

内容については、ぜひ[サイト](https://mdown.ai/content/3619d16a-e153-4f9e-bca7-c438b80aabcd)で試してみてください。

## Server AIの使い方
1. `Markdown AI`のロボット🤖のマークをクリックします
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2734332/3d275c91-01fc-c169-7ebd-b33121d982c1.png)
1. `Create Model`の画面が出るため、`モデルの名前`、`AIモデル`の設定、`Prompt`を指定、必要に応じてKnowledgeを指定
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2734332/699c89fe-4b93-c720-003b-67ce9aad8fa9.png)
    1. 選択可能なAIモデル
        1. gpt-4o-mini
        1. gpt-3.5-turbo
        1. gpt-4
        1. claude
        1. gemini
        1. dallL-e-3
    1. Knowledge
        1. 作成した`Markdown`のページ、もとい`file`、`link`を指定


私は`gpt-4o-mini`を指定しています。
Knowledgeは表示されるWebページのMarkdownです。

`Server AI`の詳細は下記のようにまとめられます。

| 項目 | 機能 |
|------|------|
| 最大応答 | 1回の返信で出力可能な最大文字数。2048トークン（英語で約4000〜5000文字）まで対応 |
| プロンプト入力制限 | 約100万文字 |
| 複数モデルの生成 | リスト未指定時は「モデル名」入力で作成可 |
| モデル作成上限 | 最大500個 |
| Webサイトへの埋め込み方法 | サイト作成画面の「挿入」→「スクリプト」から AI モデルを選択し、テキストフォームと回答ボタンを配置 |
| Knowledge機能 | file、link |
| Prompt機能 | AIへの指示設定機能、システムプロンプトの立ち位置 |
| Playground機能 | AIの動作確認・応答テスト用の検証 |

前置きが長くなりましたが作成したページの内容に移ります。

# Google Apps Script Typing Shooting Game
`Typing Shooting Game`を作成して参照しています。
当初`Markdown AI`でホストするつもりでしたが、`Google Apps Script`でいいのでは🧐となり、書き換えました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2734332/4369cd3a-7e2f-9e41-100b-051dcb75dc28.png)

`Google Apps Script`で作成しています。

::: note warn
一回でも外したら終了の残念な仕様です。
:::

`Google Apps Script`の機能を利用して、`Google スプレッドシート`にゲーム🎮の結果を記録しています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2734332/f9665f06-8488-1a3a-1ac7-757919f511f5.png)

その内容が`iframe`の`Looker Studio`に表示されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2734332/ad104cd2-e6a3-982d-c16a-60c73c982ad2.png)

コスパが非常にいいプランですね。
Google Apps Scriptは、スプレッドシートにバインドされたスクリプトです。
がばがば条件ですが、サクッと設定できます。

＃Google Apps Script
`doget`でHTMLを表示しています。Google Apps Scriptの利点は`saveScore`で発揮されます。

```javascript:コード.gs
function doGet() {
  return HtmlService.createHtmlOutputFromFile('index.html')
    .setTitle('Shooting Game')
    .setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL);
}

function saveScore(username, score) {
  try {
    const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('Scores');

    const timestamp = new Date();
    const rowData = [timestamp, username, score];

    sheet.appendRow(rowData);

    return { success: true, message: 'Score submitted successfully!' };
  } catch (error) {
    Logger.log('Error saving score: ' + error.toString());
    return { success: false, message: 'Failed to submit score. Please try again.' };
  }
}
```

`Claude`様様の内容です。

```html:index.html
<!DOCTYPE html>
<html>

<head>
  <style>
    body {
      background: #1a1a1a;
      color: #fff;
      font-family: Arial, sans-serif;
      margin: 0;
      display: flex;
      flex-direction: column;
      align-items: center;
      min-height: 100vh;
      padding: 20px;
    }

    #game-container {
      position: relative;
      width: 800px;
      margin: 20px auto;
    }

    canvas {
      border: 2px solid #33ccff;
      background: #000033;
    }

    #word-input {
      width: 300px;
      padding: 10px;
      font-size: 18px;
      margin: 10px 0;
      background: #000033;
      color: #33ccff;
      border: 2px solid #33ccff;
      border-radius: 5px;
      text-align: center;
    }

    #current-word {
      font-size: 24px;
      color: #33ccff;
      margin: 10px 0;
      min-height: 36px;
    }

    #score {
      font-size: 20px;
      color: #33ccff;
      margin: 10px 0;
    }

    #game-over {
      display: none;
      position: absolute;
      top: 50%;
      left: 50%;
      transform: translate(-50%, -50%);
      background: rgba(0, 0, 0, 0.8);
      padding: 20px;
      border-radius: 10px;
      text-align: center;
    }

    button {
      padding: 10px 20px;
      font-size: 18px;
      background: #33ccff;
      color: #000033;
      border: none;
      border-radius: 5px;
      cursor: pointer;
      margin: 10px;
    }

    button:hover {
      background: #66ddff;
    }

    /* 新規追加のスタイル */
    #username-input {
      width: 200px;
      padding: 10px;
      font-size: 16px;
      margin: 10px 0;
      background: #000033;
      color: #33ccff;
      border: 2px solid #33ccff;
      border-radius: 5px;
    }

    #submit-score {
      background: #33ff33;
    }

    #submit-score:hover {
      background: #66ff66;
    }

    .score-message {
      color: #33ff33;
      margin: 10px 0;
      font-size: 16px;
    }
  </style>
</head>

<body>
  <div id="game-container">
    <div id="score">Score: 0</div>
    <canvas id="gameCanvas" width="800" height="600"></canvas>
    <div id="current-word"></div>
    <input type="text" id="word-input" placeholder="Type the word..." autocomplete="off">
    <div id="game-over">
      <h2>Game Over!</h2>
      <p id="final-score"></p>
      <input type="text" id="username-input" placeholder="Enter your name" maxlength="20">
      <button id="submit-score">Submit Score</button>
      <p id="submit-message" class="score-message"></p>
      <button onclick="startGame()">Play Again</button>
    </div>
  </div>

  <script>
    const canvas = document.getElementById('gameCanvas');
        const ctx = canvas.getContext('2d');
        const wordInput = document.getElementById('word-input');
        const currentWordDisplay = document.getElementById('current-word');
        const scoreDisplay = document.getElementById('score');
        const gameOverScreen = document.getElementById('game-over');
        const usernameInput = document.getElementById('username-input');
        const submitScoreButton = document.getElementById('submit-score');
        const submitMessage = document.getElementById('submit-message');

        const words = [
            'splash', 'ripple', 'water', 'crystal', 'force', 'otter',
            'aqua', 'wave', 'flow', 'swim', 'dive', 'fish',
            'river', 'ocean', 'stream', 'pool', 'lake', 'sea'
        ];

        const OTTER_SIZE = 50;
        const ENEMY_SIZE = 40;
        const LASER_SPEED = 7;
        const BASE_ENEMY_SPEED = 0.5;
        const ENEMY_SPAWN_RATE = 0.01;
        const DIFFICULTY_INCREASE_RATE = 0.0001;
        const MAX_ENEMIES = 4;
        let currentEnemySpeed = BASE_ENEMY_SPEED;

        let gameLoop;
        let score = 0;
        let currentWord = '';
        let currentEnemy = null;
        let enemies = [];
        let lasers = [];
        let otterX = canvas.width / 2;
        let gameActive = false;

        class Enemy {
            constructor(word) {
                this.x = Math.random() * (canvas.width - ENEMY_SIZE);
                this.y = 0;
                this.word = word;
                this.targeted = false;
                this.active = false;
            }

            draw() {
                ctx.fillStyle = this.targeted ? '#ff3333' : 
                              this.active ? '#33ff33' : '#33ccff';
                ctx.fillRect(this.x, this.y, ENEMY_SIZE, ENEMY_SIZE);
                
                ctx.fillStyle = this.active ? '#ffff00' : '#ffffff';
                ctx.font = '16px Arial';
                ctx.textAlign = 'center';
                ctx.fillText(this.word, this.x + ENEMY_SIZE/2, this.y + ENEMY_SIZE + 20);
            }

            update() {
                this.y += currentEnemySpeed;
                return this.y > canvas.height;
            }
        }

        class Laser {
            constructor(startX, startY, targetX, targetY) {
                this.x = startX;
                this.y = startY;
                const angle = Math.atan2(targetY - startY, targetX - startX);
                this.dx = Math.cos(angle) * LASER_SPEED;
                this.dy = Math.sin(angle) * LASER_SPEED;
                this.target = null;
            }

            draw() {
                ctx.strokeStyle = '#33ff33';
                ctx.lineWidth = 2;
                ctx.beginPath();
                ctx.moveTo(this.x, this.y);
                ctx.lineTo(this.x - this.dx * 2, this.y - this.dy * 2);
                ctx.stroke();
            }

            update() {
                this.x += this.dx;
                this.y += this.dy;
                return this.x < 0 || this.x > canvas.width || 
                       this.y < 0 || this.y > canvas.height;
            }
        }

        function drawOtter(x) {
            ctx.fillStyle = '#996633';
            ctx.fillRect(x - OTTER_SIZE/2, canvas.height - OTTER_SIZE, OTTER_SIZE, OTTER_SIZE);
        }

        function spawnEnemy() {
            if (Math.random() < ENEMY_SPAWN_RATE && enemies.length < MAX_ENEMIES) {
                const word = words[Math.floor(Math.random() * words.length)];
                const newEnemy = new Enemy(word);
                while (enemies.some(enemy => 
                    Math.abs(enemy.x - newEnemy.x) < ENEMY_SIZE * 1.5)) {
                    newEnemy.x = Math.random() * (canvas.width - ENEMY_SIZE);
                }
                enemies.push(newEnemy);
            }
        }

        function checkCollisions() {
            for (let laser of lasers) {
                currentEnemySpeed += DIFFICULTY_INCREASE_RATE;
            
                if (laser.target) {
                    const enemy = enemies.find(e => e === laser.target);
                    if (enemy) {
                        const dx = laser.x - (enemy.x + ENEMY_SIZE/2);
                        const dy = laser.y - (enemy.y + ENEMY_SIZE/2);
                        const distance = Math.sqrt(dx * dx + dy * dy);
                        
                        if (distance < ENEMY_SIZE/2) {
                            enemies = enemies.filter(e => e !== enemy);
                            lasers = lasers.filter(l => l !== laser);
                            score += 100;
                            scoreDisplay.textContent = `Score: ${score}`;
                            currentWord = '';
                            currentWordDisplay.textContent = '';
                            wordInput.value = '';
                            return;
                        }
                    }
                }
            }
        }

        async function submitScore(username, score) {
            try {
                const result = await google.script.run
                    .withSuccessHandler(function(response) {
                        if (response.success) {
                            submitMessage.textContent = response.message;
                            submitMessage.style.color = '#33ff33';
                            submitScoreButton.disabled = true;
                        } else {
                            throw new Error(response.message);
                        }
                    })
                    .withFailureHandler(function(error) {
                        submitMessage.textContent = 'Failed to submit score. Please try again.';
                        submitMessage.style.color = '#ff3333';
                    })
                    .saveScore(username, score);
            } catch (error) {
                submitMessage.textContent = 'Failed to submit score. Please try again.';
                submitMessage.style.color = '#ff3333';
            }
        }

        function gameOver() {
            gameActive = false;
            clearInterval(gameLoop);
            document.getElementById('final-score').textContent = `Final Score: ${score}`;
            gameOverScreen.style.display = 'block';
            usernameInput.value = '';
            submitMessage.textContent = '';
            submitScoreButton.disabled = false;
        }

        function update() {
            ctx.clearRect(0, 0, canvas.width, canvas.height);
            
            spawnEnemy();
            drawOtter(otterX);

            enemies = enemies.filter(enemy => {
                enemy.draw();
                if (enemy.update()) {
                    gameOver();
                    return false;
                }
                return true;
            });

            lasers = lasers.filter(laser => {
                laser.draw();
                return !laser.update();
            });

            checkCollisions();
        }

        function startGame() {
            gameActive = true;
            score = 0;
            enemies = [];
            lasers = [];
            currentWord = '';
            currentWordDisplay.textContent = '';
            wordInput.value = '';
            scoreDisplay.textContent = `Score: ${score}`;
            gameOverScreen.style.display = 'none';
            wordInput.focus();

            if (gameLoop) clearInterval(gameLoop);
            gameLoop = setInterval(update, 1000/60);
        }

        wordInput.addEventListener('input', (e) => {
            if (!gameActive) return;

            const typed = e.target.value.toLowerCase();
            
            if (!currentWord) {
                const availableEnemies = enemies.filter(e => !e.targeted);
                if (availableEnemies.length > 0) {
                    for (let enemy of availableEnemies) {
                        if (enemy.word.toLowerCase().startsWith(typed)) {
                            currentWord = enemy.word.toLowerCase();
                            enemy.targeted = true;
                            currentWordDisplay.textContent = currentWord;
                            currentWordDisplay.style.color = '#33ff33';
                            break;
                        }
                    }
                }
            }

            if (currentWord && typed === currentWord) {
                const targetEnemy = enemies.find(e => 
                    e.word.toLowerCase() === currentWord && e.targeted);
                
                if (targetEnemy) {
                    const laser = new Laser(
                        otterX,
                        canvas.height - OTTER_SIZE,
                        targetEnemy.x + ENEMY_SIZE/2,
                        targetEnemy.y + ENEMY_SIZE/2
                    );
                    laser.target = targetEnemy;
                    lasers.push(laser);
                }

                currentWord = '';
                currentWordDisplay.textContent = '';
                e.target.value = '';
            } else if (currentWord && !currentWord.startsWith(typed)) {
                const targetEnemy = enemies.find(e => 
                    e.word.toLowerCase() === currentWord && e.targeted);
                if (targetEnemy) {
                    targetEnemy.targeted = false;
                }
                currentWord = '';
                currentWordDisplay.textContent = '';
                currentWordDisplay.style.color = '#33ccff';
            }
        });

        // スコア送信イベントリスナーを追加
        submitScoreButton.addEventListener('click', () => {
            const username = usernameInput.value.trim();
            if (username) {
                submitScore(username, score);
            } else {
                submitMessage.textContent = 'Please enter your name';
                submitMessage.style.color = '#ff3333';
            }
        });

        startGame();
  </script>
</body>

</html>
```

# おわりに
知識不足の自分でもシームレスな連携ができる時代に感激しています。
一方で2025年はどうなるんだろうと焦る今です。

上手く技術と付き合わないとですね！