一些和AI相关的讨论
回复
头像
ejsoon
一枝独秀
一枝独秀
帖子: 5847
注册时间: 2022年 11月 18日 17:36
联系:

用國內(能直連的)AI寫紙飛機遊戲

帖子 ejsoon »

改進:
一,排行榜應有序號1.2.3……。

二,當網頁打開時,將自動生成一個用戶名(排行榜最大一個guest加1)。這個名稱將不能更改。

三,遊戲結束時將顯示當前的排名情況,並自動保存至排行榜,同一個guest將只收錄最高分。

四,如果超越了相同用戶名的最高分,則顯示「創造歷史最高分」,如果沒有,則顯示「歷史最高分為…」。

代码: 全选

改進:
一,排行榜應有序號1.2.3……。

二,當網頁打開時,將自動生成一個用戶名(排行榜最大一個guest加1)。這個名稱將不能更改。

三,遊戲結束時將顯示當前的排名情況,並自動保存至排行榜,同一個guest將只收錄最高分。

四,如果超越了相同用戶名的最高分,則顯示「創造歷史最高分」,如果沒有,則顯示「歷史最高分為…」。
附件
紙飛機2.0.html.7z
(5.19 KiB) 已下载 4 次
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀
一枝独秀
帖子: 5847
注册时间: 2022年 11月 18日 17:36
联系:

Re: 用國內(能直連的)AI寫紙飛機遊戲

帖子 ejsoon »

gemini 3.1 flash:

這是一個非常好的優化建議。為了實現這些需求,我們需要調整排行榜的資料結構(從「名稱」改為「主鍵名稱」)、調整 UI 下方的顯示邏輯,並在 JS 中修改保存邏輯。

以下是修改後的程式碼核心部分(請替換掉原本的 JavaScript 內容):
修改後的 JavaScript 部分

代码: 全选

<script>
    // ... 前面的設定 (CONFIG, DOM 元素等保持不變)

    // 排行榜機制
    let leaderboards = JSON.parse(localStorage.getItem('paperPlaneLB')) || [];
    let guestCounter = parseInt(localStorage.getItem('paperPlaneGuestCnt')) || 1;
    
    // 二、自動生成一個不可更改的用戶名
    const myUsername = `Guest${guestCounter}`;
    localStorage.setItem('paperPlaneGuestCnt', guestCounter + 1); // 預佔下一個號碼

    // 更新排行榜 UI
    function updateLeaderboardUI() {
        let html = '';
        // 一、加入序號
        leaderboards.forEach((entry, index) => {
            html += `<tr><td>${index + 1}</td><td>${entry.name}</td><td>${entry.score}</td><td>${entry.time}s</td></tr>`;
        });
        if(html === '') html = '<tr><td colspan="4">暫無記錄</td></tr>';
        document.getElementById('menuLBBody').innerHTML = html;
        document.getElementById('gameOverLBBody').innerHTML = html;
    }

    /** 遊戲結束 */
    function gameOver() {
        state.isPlaying = false;
        plane.isDead = true;
        planeLeft.setAttribute('fill', '#FF5252');
        planeRight.setAttribute('fill', '#D32F2F');

        document.getElementById('finalScore').innerText = state.score;
        
        // 三、檢查該用戶名是否已存在,並進行比較
        const existingEntry = leaderboards.find(l => l.name === myUsername);
        const recordMsg = document.getElementById('recordMsg');
        
        // 四、超越或維持紀錄的邏輯提示
        if (!existingEntry || state.score > existingEntry.score) {
            recordMsg.innerHTML = "🎉 創造歷史最高分!";
            recordMsg.style.color = "#4CAF50";
        } else {
            recordMsg.innerHTML = `歷史最高分為: ${existingEntry.score}`;
            recordMsg.style.color = "#333";
        }

        setTimeout(() => {
            uiGameOver.classList.remove('hidden');
        }, 800);
    }

    // 更新保存按鈕事件
    document.getElementById('saveRestartBtn').addEventListener('click', () => {
        // 三、處理保存邏輯:只收錄同一個用戶的最高分
        const existingIndex = leaderboards.findIndex(l => l.name === myUsername);
        const newData = {
            name: myUsername,
            score: state.score,
            time: state.time,
            date: getFormattedDate()
        };

        if (existingIndex !== -1) {
            if (state.score > leaderboards[existingIndex].score) {
                leaderboards[existingIndex] = newData;
            }
        } else {
            leaderboards.push(newData);
        }

        leaderboards.sort((a, b) => b.score - a.score);
        leaderboards = leaderboards.slice(0, 10);
        localStorage.setItem('paperPlaneLB', JSON.stringify(leaderboards));

        startGame();
    });

    // ... (遊戲主循環保持不變)
</script>
UI 部分需要微調

請在 HTML 的 gameOverUI 區塊中,將原本的 <input> 移除,並加入一個顯示訊息的區塊:

代码: 全选

<!-- 遊戲結束 UI 內的調整 -->
<div class="ui-layer hidden" id="gameOverUI">
    <h2 style="color: #FF1744;">遊戲結束!</h2>
    <p style="font-size: 20px;">玩家: <b id="playerNameDisplay"></b></p> <!-- 新增顯示名字 -->
    <p style="font-size: 20px; font-weight: bold;">最終分數: <span id="finalScore" style="color:#FF1744; font-size:28px;">0</span></p>
    <p id="recordMsg" style="font-weight:bold; margin-bottom:10px;"></p> <!-- 四、新增提示訊息 -->
    
    <button id="saveRestartBtn">保存並重新開始</button>
    <!-- ... 排行榜表格 -->
</div>
給你的建議:

名字顯示:在 startGame 函式中,加入 document.getElementById('playerNameDisplay').innerText = myUsername; 確保遊戲結束時能看到你的名字。
CSS 調整:因為移除了 <input>,現在的遊戲結算畫面對比更清晰,你可以直接讓用戶在開始前知道自己的編號(例如在 HUD 或 Menu 顯示 您是:GuestX 的字樣)。
資料持久化:使用 localStorage 是最簡單的實作,這對單機瀏覽器遊戲非常合適。透過 JSON.stringify 可以方便儲存複雜的陣列結構。
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀
一枝独秀
帖子: 5847
注册时间: 2022年 11月 18日 17:36
联系:

Re: 用國內(能直連的)AI寫紙飛機遊戲

帖子 ejsoon »

改進:
去掉排行榜,當前會話的最高分數及其用時將記錄在一個var變量中,當頁面刷新則重新開始計。

如果超越了最高分,則顯示「創造歷史最高分:…」,如果沒有,則顯示「歷史最高分為…」。

增加函式,使外部(例如通過iframe)能夠探知遊戲是否結束以及結束後的分數和用時。


代码: 全选

<!DOCTYPE html>
<html lang="zh-TW">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
    <title>紙飛機極速下落</title>
    <style>
        /* 基礎與卡通風格設定 */
        :root {
            --bg-top: #E0F7FA; /* 淺藍色 */
            --bg-bottom: #C8E6C9; /* 淺綠色 */
            --primary: #4CAF50;
            --text-color: #333;
        }

        body {
            margin: 0;
            padding: 0;
            display: flex;
            justify-content: center;
            align-items: center;
            height: 100vh;
            background-color: #333;
            font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
            overflow: hidden;
            touch-action: none; /* 防止手機端預設滑動行為 */
        }

        #game-wrapper {
            position: relative;
            width: 100%;
            max-width: 450px;
            height: 100vh;
            max-height: 800px;
            background: linear-gradient(to bottom, var(--bg-top), var(--bg-bottom));
            border: 4px solid #333;
            box-shadow: 0px 0px 20px rgba(0,0,0,0.5);
            overflow: hidden;
        }

        /* 畫布與 SVG 飛機 */
        canvas {
            display: block;
            width: 100%;
            height: 100%;
        }

        #plane-svg {
            position: absolute;
            width: 50px;
            height: 50px;
            top: 0; left: 0;
            /* 設置中心定位,後續由 JS 控制 transform 來實現偽 3D 轉向 */
            transform-origin: center center;
            pointer-events: none;
            z-index: 10;
            transition: fill 0.2s;
        }

        /* UI 層 */
        .ui-layer {
            position: absolute;
            top: 0; left: 0;
            width: 100%; height: 100%;
            display: flex;
            flex-direction: column;
            justify-content: center;
            align-items: center;
            background: rgba(255, 255, 255, 0.85);
            z-index: 20;
            backdrop-filter: blur(3px);
        }

        .hidden { display: none !important; }

        .hud {
            position: absolute;
            top: 15px; left: 15px; right: 15px;
            display: flex;
            justify-content: space-between;
            font-size: 20px;
            font-weight: 900;
            color: #333;
            text-shadow: 2px 2px 0px #fff;
            z-index: 15;
            pointer-events: none;
        }

        /* 按鈕與輸入框 */
        button {
            padding: 12px 30px;
            font-size: 20px;
            font-weight: bold;
            color: #fff;
            background-color: var(--primary);
            border: 3px solid #333;
            border-radius: 12px;
            cursor: pointer;
            box-shadow: 4px 4px 0px #333;
            margin-bottom: 20px;
            transition: transform 0.1s, box-shadow 0.1s;
        }

        button:active {
            transform: translate(4px, 4px);
            box-shadow: 0px 0px 0px #333;
        }

        input[type="text"] {
            padding: 10px;
            font-size: 16px;
            font-weight: bold;
            border: 3px solid #333;
            border-radius: 8px;
            margin-bottom: 15px;
            text-align: center;
            width: 220px;
            outline: none;
        }

        /* 排行榜 */
        .leaderboard {
            width: 85%;
            max-height: 250px;
            overflow-y: auto;
            background: #fff;
            border: 3px solid #333;
            border-radius: 8px;
            padding: 10px;
            box-shadow: inset 0px 0px 10px rgba(0,0,0,0.1);
        }

        table { width: 100%; border-collapse: collapse; font-size: 14px; text-align: center; }
        th { background-color: #eee; padding: 8px 5px; border-bottom: 2px solid #333; }
        td { padding: 8px 5px; border-bottom: 1px dashed #ccc; font-weight: bold;}
        h2 { margin-top: 0; font-size: 32px; color: #333; text-shadow: 2px 2px 0px #fff; }
        
        /* 觸控提示區塊(僅測試視覺用,預設透明) */
        .touch-zone {
            position: absolute;
            top: 0; bottom: 0; width: 50%;
            z-index: 5;
        }
    </style>
</head>
<body>

<div id="game-wrapper">
    <!-- HUD 資訊 -->
    <div class="hud" id="hud">
        <div id="scoreDisplay">分數: 0</div>
        <div id="timeDisplay">時間: 0.0s</div>
    </div>

    <!-- 障礙物渲染畫布 -->
    <canvas id="gameCanvas"></canvas>

    <!-- 偽三維 SVG 紙飛機 (利用右半邊陰影加上 CSS Transform 展現 3D 感) -->
    <svg id="plane-svg" viewBox="-25 -25 50 50">
        <!-- 飛機主體 (左翼與機身) -->
        <polygon id="planeLeft" points="0,20 -20,-15 0,-5" fill="#FFFFFF" stroke="#333" stroke-width="2" stroke-linejoin="round"/>
        <!-- 飛機右翼 (加深顏色以呈現陰影立體感) -->
        <polygon id="planeRight" points="0,20 0,-5 20,-15" fill="#E0E0E0" stroke="#333" stroke-width="2" stroke-linejoin="round"/>
    </svg>

    <!-- 主選單 UI -->
    <div class="ui-layer" id="menuUI">
        <h2>紙飛機極速下落</h2>
        <button id="startBtn">開始遊戲</button>
        <div class="leaderboard">
            <table>
                <thead><tr><th>玩家</th><th>分數</th><th>用時</th><th>日期</th></tr></thead>
                <tbody id="menuLBBody"></tbody>
            </table>
        </div>
    </div>

    <!-- 遊戲結束 UI -->
    <div class="ui-layer hidden" id="gameOverUI">
        <h2 style="color: #FF1744;">遊戲結束!</h2>
        <p style="font-size: 20px; font-weight: bold;">最終分數: <span id="finalScore" style="color:#FF1744; font-size:28px;">0</span></p>
        <input type="text" id="usernameInput" placeholder="輸入玩家名稱" maxlength="12">
        <button id="saveRestartBtn">保存並重新開始</button>
        <div class="leaderboard">
            <table>
                <thead><tr><th>玩家</th><th>分數</th><th>用時</th><th>日期</th></tr></thead>
                <tbody id="gameOverLBBody"></tbody>
            </table>
        </div>
    </div>
</div>

<script>
    /** 
     * 遊戲參數配置 (可在這裡調節難度)
     */
    const CONFIG = {
        blockDensity: 160,       // 矩形出現的縱向間距 (越小越密)
        blockMinW: 100,          // 方塊最小寬度
        blockMaxW: 240,          // 方塊最大寬度
        blockHeight: 20,         // 方塊高度 (較短)
        baseFallSpeed: 9,        // 垂直向下時的最快速度
        turnSpeed: 0.08,         // 轉向靈敏度
        maxAngle: Math.PI / 2.2  // 最大傾斜角度
    };

    // DOM 元素獲取
    const wrapper = document.getElementById('game-wrapper');
    const canvas = document.getElementById('gameCanvas');
    const ctx = canvas.getContext('2d');
    const planeSvg = document.getElementById('plane-svg');
    const planeLeft = document.getElementById('planeLeft');
    const planeRight = document.getElementById('planeRight');
    
    const uiMenu = document.getElementById('menuUI');
    const uiGameOver = document.getElementById('gameOverUI');
    const scoreDisp = document.getElementById('scoreDisplay');
    const timeDisp = document.getElementById('timeDisplay');
    
    let canvasW, canvasH;
    let animationFrameId;

    // 遊戲狀態
    let state = {
        isPlaying: false,
        score: 0,
        time: 0,
        startTime: 0,
        cameraY: 0,
        lastSpawnY: 0
    };

    // 飛機物件
    let plane = {
        x: 0,
        worldY: 0,
        screenY: 0,
        angle: 0,
        isDead: false
    };

    let blocks = [];
    let keys = { left: false, right: false };

    // 排行榜機制
    let leaderboards = JSON.parse(localStorage.getItem('paperPlaneLB')) || [];
    let guestCounter = parseInt(localStorage.getItem('paperPlaneGuestCnt')) || 1;

    // 自適應畫布大小
    function resizeCanvas() {
        canvasW = wrapper.clientWidth;
        canvasH = wrapper.clientHeight;
        canvas.width = canvasW;
        canvas.height = canvasH;
    }
    window.addEventListener('resize', resizeCanvas);
    resizeCanvas();

    // 鍵盤控制
    window.addEventListener('keydown', (e) => {
        if(e.code === 'ArrowLeft') keys.left = true;
        if(e.code === 'ArrowRight') keys.right = true;
    });
    window.addEventListener('keyup', (e) => {
        if(e.code === 'ArrowLeft') keys.left = false;
        if(e.code === 'ArrowRight') keys.right = false;
    });

    // 手機端左右螢幕觸控控制
    wrapper.addEventListener('touchstart', (e) => {
        if (!state.isPlaying) return;
        for (let i = 0; i < e.changedTouches.length; i++) {
            let touch = e.changedTouches[i];
            // 判斷點擊位置是否在屏幕左半邊
            if (touch.clientX < window.innerWidth / 2) keys.left = true;
            else keys.right = true;
        }
    });
    wrapper.addEventListener('touchend', (e) => {
        for (let i = 0; i < e.changedTouches.length; i++) {
            let touch = e.changedTouches[i];
            if (touch.clientX < window.innerWidth / 2) keys.left = false;
            else keys.right = false;
        }
    });
    wrapper.addEventListener('touchcancel', () => { keys.left = false; keys.right = false; });

    // 時間格式化
    function getFormattedDate() {
        const d = new Date();
        const pad = n => n.toString().padStart(2, '0');
        return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
    }

    // 更新排行榜 UI
    function updateLeaderboardUI() {
        let html = '';
        leaderboards.forEach(entry => {
            html += `<tr><td>${entry.name}</td><td>${entry.score}</td><td>${entry.time}s</td><td>${entry.date}</td></tr>`;
        });
        if(html === '') html = '<tr><td colspan="4">暫無記錄</td></tr>';
        document.getElementById('menuLBBody').innerHTML = html;
        document.getElementById('gameOverLBBody').innerHTML = html;
    }

    /** 啟動遊戲 */
    function startGame() {
        uiMenu.classList.add('hidden');
        uiGameOver.classList.add('hidden');
        
        // 初始化飛機:最左最上方,以最傾斜向右的角度滑翔
        plane.x = 40;
        plane.worldY = 40;
        plane.screenY = 40;
        plane.angle = CONFIG.maxAngle; // 極限向右傾斜
        plane.isDead = false;

        // 恢復正常顏色
        planeLeft.setAttribute('fill', '#FFFFFF');
        planeRight.setAttribute('fill', '#E0E0E0');

        state.isPlaying = true;
        state.score = 0;
        state.time = 0;
        state.startTime = performance.now();
        state.cameraY = 0;
        state.lastSpawnY = canvasH;
        
        blocks = [];
        keys.left = false;
        keys.right = false;

        updateLeaderboardUI();
        requestAnimationFrame(gameLoop);
    }

    /** 遊戲結束 */
    function gameOver() {
        state.isPlaying = false;
        plane.isDead = true;

        // 飛機變紅色
        planeLeft.setAttribute('fill', '#FF5252');
        planeRight.setAttribute('fill', '#D32F2F');

        // 更新最終分數與預設名字
        document.getElementById('finalScore').innerText = state.score;
        document.getElementById('usernameInput').value = `Guest${guestCounter}`;
        
        setTimeout(() => {
            uiGameOver.classList.remove('hidden');
        }, 800); // 稍微延遲顯示 UI 讓玩家看到撞擊畫面
    }

    /** 遊戲主循環 */
    function gameLoop(timestamp) {
        if (!state.isPlaying) return;

        // 1. 更新時間與分數
        state.time = ((timestamp - state.startTime) / 1000).toFixed(1);
        state.score = Math.floor(plane.worldY / 100);
        timeDisp.innerText = `時間: ${state.time}s`;
        scoreDisp.innerText = `分數: ${state.score}`;

        // 2. 處理轉向 (角度控制)
        if (keys.left) plane.angle -= CONFIG.turnSpeed;
        if (keys.right) plane.angle += CONFIG.turnSpeed;
        
        // 限制最大傾斜角
        plane.angle = Math.max(-CONFIG.maxAngle, Math.min(CONFIG.maxAngle, plane.angle));

        // 3. 計算下落速度 (垂直向下角度為0時最快,角度越大越慢)
        let angleRatio = Math.abs(plane.angle) / CONFIG.maxAngle;
        let currentSpeed = CONFIG.baseFallSpeed * (1 - (angleRatio * 0.6)); 

        // 4. 更新飛機世界座標 (0度是筆直朝下)
        plane.worldY += currentSpeed * Math.cos(plane.angle);
        plane.x += currentSpeed * Math.sin(plane.angle);

        // 邊界限制 (保持在螢幕左右內)
        if (plane.x < 20) { plane.x = 20; plane.angle = 0; }
        if (plane.x > canvasW - 20) { plane.x = canvasW - 20; plane.angle = 0; }

        // 5. 攝像機跟隨 (到達畫面中線後)
        if (plane.worldY > canvasH / 2) {
            state.cameraY = plane.worldY - canvasH / 2;
            plane.screenY = canvasH / 2;
        } else {
            plane.screenY = plane.worldY;
        }

        // 6. 生成障礙物方塊
        while (state.lastSpawnY < plane.worldY + canvasH) {
            state.lastSpawnY += CONFIG.blockDensity;
            let w = Math.random() * (CONFIG.blockMaxW - CONFIG.blockMinW) + CONFIG.blockMinW;
            let x = Math.random() * (canvasW - w);
            blocks.push({
                x: x, 
                worldY: state.lastSpawnY, 
                w: w, 
                h: CONFIG.blockHeight, 
                isHit: false 
            });
        }

        // 清理越界的舊方塊以釋放記憶體
        blocks = blocks.filter(b => b.worldY > state.cameraY - 50);

        // 7. 更新 SVG 偽三維渲染 (利用 CSS transform 旋轉及 3D 翻轉)
        // translate 置中, rotate 控制平面轉向, rotateY 控制側翻展現 3D 效果
        let rotateDeg = -plane.angle * (180 / Math.PI); // SVG預設向上,但我們初始設計朝下,需要反轉
        let rollDeg = plane.angle * 45; // 側翻角度
        planeSvg.style.transform = `translate(calc(${plane.x}px - 50%), calc(${plane.screenY}px - 50%)) rotate(${rotateDeg}deg) rotateY(${rollDeg}deg)`;

        // 8. 繪製畫面與碰撞檢測
        ctx.clearRect(0, 0, canvasW, canvasH);
        
        let planeHitbox = { x: plane.x - 10, y: plane.screenY - 15, w: 20, h: 30 };

        blocks.forEach(b => {
            let screenY = b.worldY - state.cameraY;
            
            // 碰撞檢測 (AABB)
            if (
                planeHitbox.x < b.x + b.w &&
                planeHitbox.x + planeHitbox.w > b.x &&
                planeHitbox.y < screenY + b.h &&
                planeHitbox.y + planeHitbox.h > screenY
            ) {
                b.isHit = true;
                gameOver();
            }

            // 繪製粉紅矩形方塊
            ctx.fillStyle = b.isHit ? '#FF1744' : '#FF80AB';
            ctx.strokeStyle = '#333';
            ctx.lineWidth = 3;
            ctx.beginPath();
            ctx.roundRect(b.x, screenY, b.w, b.h, 6);
            ctx.fill();
            ctx.stroke();
        });

        if (state.isPlaying) {
            animationFrameId = requestAnimationFrame(gameLoop);
        }
    }

    // 按鈕事件綁定
    document.getElementById('startBtn').addEventListener('click', startGame);
    
    document.getElementById('saveRestartBtn').addEventListener('click', () => {
        let name = document.getElementById('usernameInput').value.trim();
        if(!name) name = `Guest${guestCounter}`;
        
        // 如果使用了預設 GuestN,則計數器加 1
        if(name === `Guest${guestCounter}`) {
            guestCounter++;
            localStorage.setItem('paperPlaneGuestCnt', guestCounter);
        }

        // 保存紀錄
        leaderboards.push({
            name: name,
            score: state.score,
            time: state.time,
            date: getFormattedDate()
        });

        // 排序並只保留前 10 名
        leaderboards.sort((a, b) => b.score - a.score);
        leaderboards = leaderboards.slice(0, 10);
        localStorage.setItem('paperPlaneLB', JSON.stringify(leaderboards));

        startGame();
    });

    // 初始渲染排行榜
    updateLeaderboardUI();
</script>

</body>
</html>


改進:
去掉排行榜,當前會話的最高分數及其用時將記錄在一個var變量中,當頁面刷新則重新開始計。

如果超越了最高分,則顯示「創造歷史最高分:…」,如果沒有,則顯示「歷史最高分為…」。

增加函式,使外部(例如通過iframe)能夠探知遊戲是否結束以及結束後的分數和用時。
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀
一枝独秀
帖子: 5847
注册时间: 2022年 11月 18日 17:36
联系:

Re: 用國內(能直連的)AI寫紙飛機遊戲

帖子 ejsoon »

deepseek v4 pro:

代码: 全选

> OK let me now produce the final HTML. I'll be comprehensive and make sure everything works.

> Let me also fix a small issue: the original code uses `ctx.roundRect` but that's a method on CanvasRenderingContext2D that might need a polyfill in some browsers. Actually, `roundRect` is supported in Chrome 99+, Firefox 112+, Safari 15.4+. It should be fine for modern browsers. The original code used it so I'll keep it.

> Now I'll write the full HTML.

This is a complete HTML document for a "纸飞机极速下落" (Paper Plane Speed Drop) game. It's a side-scrolling avoidance game where you control a paper plane falling through obstacles, built with Canvas and SVG for a pseudo-3D effect.
```html
<!DOCTYPE html>
<html lang="zh-TW">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
    <title>紙飛機極速下落</title>
    <style>
        :root {
            --bg-top: #E0F7FA;
            --bg-bottom: #C8E6C9;
            --primary: #4CAF50;
            --text-color: #333;
        }

        body {
            margin: 0;
            padding: 0;
            display: flex;
            justify-content: center;
            align-items: center;
            height: 100vh;
            background-color: #333;
            font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
            overflow: hidden;
            touch-action: none;
        }

        #game-wrapper {
            position: relative;
            width: 100%;
            max-width: 450px;
            height: 100vh;
            max-height: 800px;
            background: linear-gradient(to bottom, var(--bg-top), var(--bg-bottom));
            border: 4px solid #333;
            box-shadow: 0px 0px 20px rgba(0, 0, 0, 0.5);
            overflow: hidden;
        }

        canvas {
            display: block;
            width: 100%;
            height: 100%;
        }

        #plane-svg {
            position: absolute;
            width: 50px;
            height: 50px;
            top: 0;
            left: 0;
            transform-origin: center center;
            pointer-events: none;
            z-index: 10;
            transition: fill 0.2s;
        }

        .ui-layer {
            position: absolute;
            top: 0;
            left: 0;
            width: 100%;
            height: 100%;
            display: flex;
            flex-direction: column;
            justify-content: center;
            align-items: center;
            background: rgba(255, 255, 255, 0.85);
            z-index: 20;
            backdrop-filter: blur(3px);
            padding: 20px;
            box-sizing: border-box;
        }

        .hidden {
            display: none !important;
        }

        .hud {
            position: absolute;
            top: 15px;
            left: 15px;
            right: 15px;
            display: flex;
            justify-content: space-between;
            font-size: 20px;
            font-weight: 900;
            color: #333;
            text-shadow: 2px 2px 0px #fff;
            z-index: 15;
            pointer-events: none;
        }

        button {
            padding: 12px 30px;
            font-size: 20px;
            font-weight: bold;
            color: #fff;
            background-color: var(--primary);
            border: 3px solid #333;
            border-radius: 12px;
            cursor: pointer;
            box-shadow: 4px 4px 0px #333;
            margin-bottom: 20px;
            transition: transform 0.1s, box-shadow 0.1s;
            -webkit-tap-highlight-color: transparent;
        }

        button:active {
            transform: translate(4px, 4px);
            box-shadow: 0px 0px 0px #333;
        }

        h2 {
            margin-top: 0;
            font-size: 32px;
            color: #333;
            text-shadow: 2px 2px 0px #fff;
            text-align: center;
        }

        .stat-text {
            font-size: 20px;
            font-weight: bold;
            color: #333;
            margin: 6px 0;
        }

        .highlight-score {
            color: #FF1744;
            font-size: 28px;
            font-weight: 900;
        }

        .highscore-msg {
            font-size: 20px;
            font-weight: bold;
            margin: 10px 0 20px 0;
            padding: 10px 20px;
            border-radius: 10px;
            background: #FFF8E1;
            border: 2px dashed #FF9800;
            color: #E65100;
            text-align: center;
            animation: popIn 0.4s ease-out;
        }

        .highscore-msg.new-record {
            background: #FFF3E0;
            border-color: #FF5722;
            color: #BF360C;
            animation: popIn 0.4s ease-out, pulse 0.8s ease-in-out infinite;
        }

        @keyframes popIn {
            0% {
                transform: scale(0.7);
                opacity: 0;
            }
            70% {
                transform: scale(1.05);
            }
            100% {
                transform: scale(1);
                opacity: 1;
            }
        }

        @keyframes pulse {
            0%,
            100% {
                box-shadow: 0 0 0 0 rgba(255, 87, 34, 0.4);
            }
            50% {
                box-shadow: 0 0 0 12px rgba(255, 87, 34, 0);
            }
        }

        .menu-highscore {
            font-size: 16px;
            font-weight: bold;
            color: #FF6D00;
            margin-top: 8px;
            text-align: center;
            min-height: 22px;
        }
    </style>
</head>
<body>

    <div id="game-wrapper">
        <!-- HUD -->
        <div class="hud" id="hud">
            <div id="scoreDisplay">分數: 0</div>
            <div id="timeDisplay">時間: 0.0s</div>
        </div>

        <!-- 遊戲畫布 -->
        <canvas id="gameCanvas"></canvas>

        <!-- SVG 紙飛機 -->
        <svg id="plane-svg" viewBox="-25 -25 50 50">
            <polygon id="planeLeft" points="0,20 -20,-15 0,-5" fill="#FFFFFF" stroke="#333" stroke-width="2" stroke-linejoin="round" />
            <polygon id="planeRight" points="0,20 0,-5 20,-15" fill="#E0E0E0" stroke="#333" stroke-width="2" stroke-linejoin="round" />
        </svg>

        <!-- 主選單 -->
        <div class="ui-layer" id="menuUI">
            <h2>紙飛機極速下落</h2>
            <button id="startBtn">開始遊戲</button>
            <div id="menuHighScore" class="menu-highscore"></div>
        </div>

        <!-- 遊戲結束 -->
        <div class="ui-layer hidden" id="gameOverUI">
            <h2 style="color: #FF1744;">遊戲結束!</h2>
            <p class="stat-text">最終分數: <span id="finalScore" class="highlight-score">0</span></p>
            <p class="stat-text">用時: <span id="finalTime" style="font-size:22px; font-weight:900;">0.0s</span></p>
            <div id="highScoreMsg"></div>
            <button id="restartBtn">重新開始</button>
        </div>
    </div>

    <script>
        /**
         * 遊戲參數配置
         */
        const CONFIG = {
            blockDensity: 160,
            blockMinW: 100,
            blockMaxW: 240,
            blockHeight: 20,
            baseFallSpeed: 9,
            turnSpeed: 0.08,
            maxAngle: Math.PI / 2.2
        };

        // ──────────────── DOM 元素 ────────────────
        const wrapper = document.getElementById('game-wrapper');
        const canvas = document.getElementById('gameCanvas');
        const ctx = canvas.getContext('2d');
        const planeSvg = document.getElementById('plane-svg');
        const planeLeft = document.getElementById('planeLeft');
        const planeRight = document.getElementById('planeRight');

        const uiMenu = document.getElementById('menuUI');
        const uiGameOver = document.getElementById('gameOverUI');
        const scoreDisp = document.getElementById('scoreDisplay');
        const timeDisp = document.getElementById('timeDisplay');
        const menuHighScoreEl = document.getElementById('menuHighScore');

        let canvasW, canvasH;
        let animationFrameId;

        // ──────────────── 遊戲狀態 ────────────────
        let state = {
            isPlaying: false,
            score: 0,
            time: 0,
            startTime: 0,
            cameraY: 0,
            lastSpawnY: 0
        };

        let plane = {
            x: 0,
            worldY: 0,
            screenY: 0,
            angle: 0,
            isDead: false
        };

        let blocks = [];
        let keys = { left: false, right: false };

        // ──────────────── 本輪會話最高分記錄(頁面刷新即重置) ────────────────
        var sessionHighScore = -1; // -1 表示尚未有任何遊戲記錄
        var sessionHighScoreTime = 0;
        var lastGameScore = 0;
        var lastGameTime = 0;
        var isGameOverState = false;
        var lastGameIsNewHighScore = false;

        // ──────────────── 外部 API(供 iframe 父頁面調用) ────────────────
        window.PaperPlaneGame = {
            /**
             * 取得完整遊戲狀態
             * @returns {Object} 包含 isPlaying, isGameOver, lastScore, lastTime,
             *                   sessionHighScore, sessionHighScoreTime, isNewHighScore
             */
            getState: function() {
                return {
                    isPlaying: state.isPlaying,
                    isGameOver: isGameOverState,
                    lastScore: lastGameScore,
                    lastTime: lastGameTime,
                    sessionHighScore: sessionHighScore,
                    sessionHighScoreTime: sessionHighScoreTime,
                    isNewHighScore: lastGameIsNewHighScore
                };
            },

            /** 遊戲是否正在進行中 */
            isPlaying: function() { return state.isPlaying; },

            /** 遊戲是否已結束(遊戲結束畫面顯示中) */
            isGameOver: function() { return isGameOverState; },

            /** 取得上一次遊戲的分數 */
            getLastScore: function() { return lastGameScore; },

            /** 取得上一次遊戲的用時(秒) */
            getLastTime: function() { return lastGameTime; },

            /** 取得本輪會話最高分 */
            getSessionHighScore: function() { return sessionHighScore; },

            /** 取得本輪會話最高分的用時 */
            getSessionHighScoreTime: function() { return sessionHighScoreTime; },

            /** 上一次遊戲是否刷新了最高分 */
            isNewHighScore: function() { return lastGameIsNewHighScore; },

            /**
             * 遊戲結束時的回調(同一來源的父頁面可設置此屬性)
             * @type {Function|null} callback(score, time, isNewHighScore)
             * @example
             * window.PaperPlaneGame.onGameOver = function(score, time, isNew) {
             *     console.log('Game over! Score:', score);
             * };
             */
            onGameOver: null
        };

        /**
         * 向父窗口發送 postMessage(適用於跨來源 iframe 嵌入)
         */
        function notifyParentGameOver(score, time, isNewHighScore) {
            if (window.parent && window.parent !== window) {
                try {
                    window.parent.postMessage({
                        source: 'paperPlaneGame',
                        event: 'gameOver',
                        data: {
                            score: score,
                            time: time,
                            isNewHighScore: isNewHighScore,
                            sessionHighScore: sessionHighScore,
                            sessionHighScoreTime: sessionHighScoreTime
                        }
                    }, '*');
                } catch (e) {
                    // 忽略跨來源通訊錯誤
                }
            }
        }

        // ──────────────── 輔助函式 ────────────────
        function resizeCanvas() {
            canvasW = wrapper.clientWidth;
            canvasH = wrapper.clientHeight;
            canvas.width = canvasW;
            canvas.height = canvasH;
        }
        window.addEventListener('resize', resizeCanvas);
        resizeCanvas();

        /** 更新選單上的本輪最高分顯示 */
        function updateMenuHighScore() {
            if (sessionHighScore >= 0) {
                menuHighScoreEl.textContent =
                    '本次最高分:' + sessionHighScore + ' 分 (用時 ' + sessionHighScoreTime + 's)';
            } else {
                menuHighScoreEl.textContent = '';
            }
        }

        // ──────────────── 鍵盤控制 ────────────────
        window.addEventListener('keydown', (e) => {
            if (e.code === 'ArrowLeft') keys.left = true;
            if (e.code === 'ArrowRight') keys.right = true;
        });
        window.addEventListener('keyup', (e) => {
            if (e.code === 'ArrowLeft') keys.left = false;
            if (e.code === 'ArrowRight') keys.right = false;
        });

        // ──────────────── 觸控控制(手機端左右半屏) ────────────────
        wrapper.addEventListener('touchstart', (e) => {
            if (!state.isPlaying) return;
            for (let i = 0; i < e.changedTouches.length; i++) {
                let touch = e.changedTouches[i];
                if (touch.clientX < window.innerWidth / 2) keys.left = true;
                else keys.right = true;
            }
        });
        wrapper.addEventListener('touchend', (e) => {
            for (let i = 0; i < e.changedTouches.length; i++) {
                let touch = e.changedTouches[i];
                if (touch.clientX < window.innerWidth / 2) keys.left = false;
                else keys.right = false;
            }
        });
        wrapper.addEventListener('touchcancel', () => {
            keys.left = false;
            keys.right = false;
        });

        // ──────────────── 遊戲流程 ────────────────
        function startGame() {
            uiMenu.classList.add('hidden');
            uiGameOver.classList.add('hidden');
            isGameOverState = false;

            // 初始化飛機
            plane.x = 40;
            plane.worldY = 40;
            plane.screenY = 40;
            plane.angle = CONFIG.maxAngle;
            plane.isDead = false;

            // 恢復飛機顏色
            planeLeft.setAttribute('fill', '#FFFFFF');
            planeRight.setAttribute('fill', '#E0E0E0');

            state.isPlaying = true;
            state.score = 0;
            state.time = 0;
            state.startTime = performance.now();
            state.cameraY = 0;
            state.lastSpawnY = canvasH;

            blocks = [];
            keys.left = false;
            keys.right = false;

            updateMenuHighScore();
            requestAnimationFrame(gameLoop);
        }

        function gameOver() {
            state.isPlaying = false;
            plane.isDead = true;
            isGameOverState = true;

            // 飛機變紅色
            planeLeft.setAttribute('fill', '#FF5252');
            planeRight.setAttribute('fill', '#D32F2F');

            // 記錄本次成績
            lastGameScore = state.score;
            lastGameTime = state.time;

            // 判斷是否刷新本輪會話最高分
            let isNewHighScore = (lastGameScore > sessionHighScore);
            lastGameIsNewHighScore = isNewHighScore;

            if (isNewHighScore) {
                sessionHighScore = lastGameScore;
                sessionHighScoreTime = lastGameTime;
            }

            // 更新遊戲結束 UI
            document.getElementById('finalScore').innerText = lastGameScore;
            document.getElementById('finalTime').innerText = lastGameTime + 's';

            const highScoreMsgDiv = document.getElementById('highScoreMsg');
            if (isNewHighScore) {
                highScoreMsgDiv.className = 'highscore-msg new-record';
                highScoreMsgDiv.innerHTML = '🎉 創造歷史最高分:<strong>' + sessionHighScore +
                    '</strong> 分!<br><small>用時 ' + sessionHighScoreTime + 's</small>';
            } else {
                highScoreMsgDiv.className = 'highscore-msg';
                highScoreMsgDiv.innerHTML = '歷史最高分為 <strong>' + sessionHighScore +
                    '</strong> 分<br><small>(用時 ' + sessionHighScoreTime + 's)</small>';
            }

            // 呼叫外部回調(同來源 iframe)
            if (typeof window.PaperPlaneGame.onGameOver === 'function') {
                try {
                    window.PaperPlaneGame.onGameOver(lastGameScore, lastGameTime, isNewHighScore);
                } catch (e) {
                    // 忽略回調中的錯誤
                }
            }

            // 向父窗口發送 postMessage(跨來源 iframe)
            notifyParentGameOver(lastGameScore, lastGameTime, isNewHighScore);

            // 更新選單最高分
            updateMenuHighScore();

            // 延遲顯示結束畫面
            setTimeout(() => {
                uiGameOver.classList.remove('hidden');
            }, 800);
        }

        function gameLoop(timestamp) {
            if (!state.isPlaying) return;

            // 更新時間與分數
            state.time = ((timestamp - state.startTime) / 1000).toFixed(1);
            state.score = Math.floor(plane.worldY / 100);
            timeDisp.innerText = '時間: ' + state.time + 's';
            scoreDisp.innerText = '分數: ' + state.score;

            // 處理轉向
            if (keys.left) plane.angle -= CONFIG.turnSpeed;
            if (keys.right) plane.angle += CONFIG.turnSpeed;
            plane.angle = Math.max(-CONFIG.maxAngle, Math.min(CONFIG.maxAngle, plane.angle));

            // 計算下落速度
            let angleRatio = Math.abs(plane.angle) / CONFIG.maxAngle;
            let currentSpeed = CONFIG.baseFallSpeed * (1 - (angleRatio * 0.6));

            // 更新飛機世界座標
            plane.worldY += currentSpeed * Math.cos(plane.angle);
            plane.x += currentSpeed * Math.sin(plane.angle);

            // 邊界限制
            if (plane.x < 20) { plane.x = 20;
                plane.angle = 0; }
            if (plane.x > canvasW - 20) { plane.x = canvasW - 20;
                plane.angle = 0; }

            // 攝像機跟隨
            if (plane.worldY > canvasH / 2) {
                state.cameraY = plane.worldY - canvasH / 2;
                plane.screenY = canvasH / 2;
            } else {
                plane.screenY = plane.worldY;
            }

            // 生成障礙物
            while (state.lastSpawnY < plane.worldY + canvasH) {
                state.lastSpawnY += CONFIG.blockDensity;
                let w = Math.random() * (CONFIG.blockMaxW - CONFIG.blockMinW) + CONFIG.blockMinW;
                let x = Math.random() * (canvasW - w);
                blocks.push({
                    x: x,
                    worldY: state.lastSpawnY,
                    w: w,
                    h: CONFIG.blockHeight,
                    isHit: false
                });
            }

            // 清理舊方塊
            blocks = blocks.filter(b => b.worldY > state.cameraY - 50);

            // 更新 SVG 飛機偽三維渲染
            let rotateDeg = -plane.angle * (180 / Math.PI);
            let rollDeg = plane.angle * 45;
            planeSvg.style.transform =
                'translate(calc(' + plane.x + 'px - 50%), calc(' + plane.screenY + 'px - 50%)) rotate(' +
                rotateDeg + 'deg) rotateY(' + rollDeg + 'deg)';

            // 繪製畫面與碰撞檢測
            ctx.clearRect(0, 0, canvasW, canvasH);

            let planeHitbox = { x: plane.x - 10, y: plane.screenY - 15, w: 20, h: 30 };

            blocks.forEach(b => {
                let screenY = b.worldY - state.cameraY;

                // AABB 碰撞檢測
                if (
                    planeHitbox.x < b.x + b.w &&
                    planeHitbox.x + planeHitbox.w > b.x &&
                    planeHitbox.y < screenY + b.h &&
                    planeHitbox.y + planeHitbox.h > screenY
                ) {
                    b.isHit = true;
                    gameOver();
                }

                // 繪製方塊
                ctx.fillStyle = b.isHit ? '#FF1744' : '#FF80AB';
                ctx.strokeStyle = '#333';
                ctx.lineWidth = 3;
                ctx.beginPath();
                if (ctx.roundRect) {
                    ctx.roundRect(b.x, screenY, b.w, b.h, 6);
                } else {
                    // fallback for older browsers
                    ctx.rect(b.x, screenY, b.w, b.h);
                }
                ctx.fill();
                ctx.stroke();
            });

            if (state.isPlaying) {
                animationFrameId = requestAnimationFrame(gameLoop);
            }
        }

        // ──────────────── 按鈕事件 ────────────────
        document.getElementById('startBtn').addEventListener('click', startGame);

        document.getElementById('restartBtn').addEventListener('click', () => {
            startGame();
        });

        // ──────────────── 初始渲染 ────────────────
        updateMenuHighScore();
    </script>

</body>
</html>
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀
一枝独秀
帖子: 5847
注册时间: 2022年 11月 18日 17:36
联系:

Re: 用國內(能直連的)AI寫紙飛機遊戲

帖子 ejsoon »

以上的html文件將上傳至wordpress的媒體中。已知它的id。現在寫一個wordpress的php頁面:

獲取這個id對應的文件地址,把地址賦值給一個iframe的src。

php判斷用戶有沒有登入,如果沒有則提示「登入可將參與排名,點擊登入。」如果已登入則提示「歡迎xxx,你的歷史最高分為xxx(用時xxx)」。

之後是排行榜,排行榜的項目為:序號(排名),分數,用時,日期和時間。一個用戶名最多只出現一次,只保留他的最高分。排行榜一次顯示十個,可翻頁。

排行榜的數據將是本文章的meta,用json格式保存。

iframe將顯示在下方。在其右上角增加兩個svg按鈕,功能是「網頁全屏,顯示器全屏」。

當iframe內的遊戲結束,將把回傳的分數和用時傳給php。如果當前用戶已登入,應用wordpress中的ajax,獲取本用戶名在排行榜上的分數,如果超過歷史分數,則提示「創造新紀錄,排名至xxx」,如果沒有則提示「歷史最高分為xxx」。當前的時間日期(yyyy-mm-dd hh:ii:ss)將由php在保存時生成。

如果沒有登入,則將只計算這個分數能在當前排行榜排第幾,但不會真的加進排行榜。

這個php代碼將保存至snippets擴展。

代码: 全选

<!DOCTYPE html>
<html lang="zh-TW">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
    <title>紙飛機極速下落</title>
    <style>
        :root {
            --bg-top: #E0F7FA;
            --bg-bottom: #C8E6C9;
            --primary: #4CAF50;
            --text-color: #333;
        }

        body {
            margin: 0;
            padding: 0;
            display: flex;
            justify-content: center;
            align-items: center;
            height: 100vh;
            background-color: #333;
            font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
            overflow: hidden;
            touch-action: none;
        }

        #game-wrapper {
            position: relative;
            width: 100%;
            max-width: 450px;
            height: 100vh;
            max-height: 800px;
            background: linear-gradient(to bottom, var(--bg-top), var(--bg-bottom));
            border: 4px solid #333;
            box-shadow: 0px 0px 20px rgba(0, 0, 0, 0.5);
            overflow: hidden;
        }

        canvas {
            display: block;
            width: 100%;
            height: 100%;
        }

        #plane-svg {
            position: absolute;
            width: 50px;
            height: 50px;
            top: 0;
            left: 0;
            transform-origin: center center;
            pointer-events: none;
            z-index: 10;
            transition: fill 0.2s;
        }

        .ui-layer {
            position: absolute;
            top: 0;
            left: 0;
            width: 100%;
            height: 100%;
            display: flex;
            flex-direction: column;
            justify-content: center;
            align-items: center;
            background: rgba(255, 255, 255, 0.85);
            z-index: 20;
            backdrop-filter: blur(3px);
            padding: 20px;
            box-sizing: border-box;
        }

        .hidden {
            display: none !important;
        }

        .hud {
            position: absolute;
            top: 15px;
            left: 15px;
            right: 15px;
            display: flex;
            justify-content: space-between;
            font-size: 20px;
            font-weight: 900;
            color: #333;
            text-shadow: 2px 2px 0px #fff;
            z-index: 15;
            pointer-events: none;
        }

        button {
            padding: 12px 30px;
            font-size: 20px;
            font-weight: bold;
            color: #fff;
            background-color: var(--primary);
            border: 3px solid #333;
            border-radius: 12px;
            cursor: pointer;
            box-shadow: 4px 4px 0px #333;
            margin-bottom: 20px;
            transition: transform 0.1s, box-shadow 0.1s;
            -webkit-tap-highlight-color: transparent;
        }

        button:active {
            transform: translate(4px, 4px);
            box-shadow: 0px 0px 0px #333;
        }

        h2 {
            margin-top: 0;
            font-size: 32px;
            color: #333;
            text-shadow: 2px 2px 0px #fff;
            text-align: center;
        }

        .stat-text {
            font-size: 20px;
            font-weight: bold;
            color: #333;
            margin: 6px 0;
        }

        .highlight-score {
            color: #FF1744;
            font-size: 28px;
            font-weight: 900;
        }

        .highscore-msg {
            font-size: 20px;
            font-weight: bold;
            margin: 10px 0 20px 0;
            padding: 10px 20px;
            border-radius: 10px;
            background: #FFF8E1;
            border: 2px dashed #FF9800;
            color: #E65100;
            text-align: center;
            animation: popIn 0.4s ease-out;
        }

        .highscore-msg.new-record {
            background: #FFF3E0;
            border-color: #FF5722;
            color: #BF360C;
            animation: popIn 0.4s ease-out, pulse 0.8s ease-in-out infinite;
        }

        @keyframes popIn {
            0% {
                transform: scale(0.7);
                opacity: 0;
            }
            70% {
                transform: scale(1.05);
            }
            100% {
                transform: scale(1);
                opacity: 1;
            }
        }

        @keyframes pulse {
            0%,
            100% {
                box-shadow: 0 0 0 0 rgba(255, 87, 34, 0.4);
            }
            50% {
                box-shadow: 0 0 0 12px rgba(255, 87, 34, 0);
            }
        }

        .menu-highscore {
            font-size: 16px;
            font-weight: bold;
            color: #FF6D00;
            margin-top: 8px;
            text-align: center;
            min-height: 22px;
        }
    </style>
</head>
<body>

    <div id="game-wrapper">
        <!-- HUD -->
        <div class="hud" id="hud">
            <div id="scoreDisplay">分數: 0</div>
            <div id="timeDisplay">時間: 0.0s</div>
        </div>

        <!-- 遊戲畫布 -->
        <canvas id="gameCanvas"></canvas>

        <!-- SVG 紙飛機 -->
        <svg id="plane-svg" viewBox="-25 -25 50 50">
            <polygon id="planeLeft" points="0,20 -20,-15 0,-5" fill="#FFFFFF" stroke="#333" stroke-width="2" stroke-linejoin="round" />
            <polygon id="planeRight" points="0,20 0,-5 20,-15" fill="#E0E0E0" stroke="#333" stroke-width="2" stroke-linejoin="round" />
        </svg>

        <!-- 主選單 -->
        <div class="ui-layer" id="menuUI">
            <h2>紙飛機極速下落</h2>
            <button id="startBtn">開始遊戲</button>
            <div id="menuHighScore" class="menu-highscore"></div>
        </div>

        <!-- 遊戲結束 -->
        <div class="ui-layer hidden" id="gameOverUI">
            <h2 style="color: #FF1744;">遊戲結束!</h2>
            <p class="stat-text">最終分數: <span id="finalScore" class="highlight-score">0</span></p>
            <p class="stat-text">用時: <span id="finalTime" style="font-size:22px; font-weight:900;">0.0s</span></p>
            <div id="highScoreMsg"></div>
            <button id="restartBtn">重新開始</button>
        </div>
    </div>

    <script>
        /**
         * 遊戲參數配置
         */
        const CONFIG = {
            blockDensity: 180,
            blockMinW: 120,
            blockMaxW: 240,
            blockHeight: 24,
            baseFallSpeed: 9,
            turnSpeed: 0.08,
            maxAngle: Math.PI / 2.2
        };

        // ──────────────── DOM 元素 ────────────────
        const wrapper = document.getElementById('game-wrapper');
        const canvas = document.getElementById('gameCanvas');
        const ctx = canvas.getContext('2d');
        const planeSvg = document.getElementById('plane-svg');
        const planeLeft = document.getElementById('planeLeft');
        const planeRight = document.getElementById('planeRight');

        const uiMenu = document.getElementById('menuUI');
        const uiGameOver = document.getElementById('gameOverUI');
        const scoreDisp = document.getElementById('scoreDisplay');
        const timeDisp = document.getElementById('timeDisplay');
        const menuHighScoreEl = document.getElementById('menuHighScore');

        let canvasW, canvasH;
        let animationFrameId;

        // ──────────────── 遊戲狀態 ────────────────
        let state = {
            isPlaying: false,
            score: 0,
            time: 0,
            startTime: 0,
            cameraY: 0,
            lastSpawnY: 0
        };

        let plane = {
            x: 0,
            worldY: 0,
            screenY: 0,
            angle: 0,
            isDead: false
        };

        let blocks = [];
        let keys = { left: false, right: false };

        // ──────────────── 本輪會話最高分記錄(頁面刷新即重置) ────────────────
        var sessionHighScore = -1; // -1 表示尚未有任何遊戲記錄
        var sessionHighScoreTime = 0;
        var lastGameScore = 0;
        var lastGameTime = 0;
        var isGameOverState = false;
        var lastGameIsNewHighScore = false;

        // ──────────────── 外部 API(供 iframe 父頁面調用) ────────────────
        window.PaperPlaneGame = {
            /**
             * 取得完整遊戲狀態
             * @returns {Object} 包含 isPlaying, isGameOver, lastScore, lastTime,
             *                   sessionHighScore, sessionHighScoreTime, isNewHighScore
             */
            getState: function() {
                return {
                    isPlaying: state.isPlaying,
                    isGameOver: isGameOverState,
                    lastScore: lastGameScore,
                    lastTime: lastGameTime,
                    sessionHighScore: sessionHighScore,
                    sessionHighScoreTime: sessionHighScoreTime,
                    isNewHighScore: lastGameIsNewHighScore
                };
            },

            /** 遊戲是否正在進行中 */
            isPlaying: function() { return state.isPlaying; },

            /** 遊戲是否已結束(遊戲結束畫面顯示中) */
            isGameOver: function() { return isGameOverState; },

            /** 取得上一次遊戲的分數 */
            getLastScore: function() { return lastGameScore; },

            /** 取得上一次遊戲的用時(秒) */
            getLastTime: function() { return lastGameTime; },

            /** 取得本輪會話最高分 */
            getSessionHighScore: function() { return sessionHighScore; },

            /** 取得本輪會話最高分的用時 */
            getSessionHighScoreTime: function() { return sessionHighScoreTime; },

            /** 上一次遊戲是否刷新了最高分 */
            isNewHighScore: function() { return lastGameIsNewHighScore; },

            /**
             * 遊戲結束時的回調(同一來源的父頁面可設置此屬性)
             * @type {Function|null} callback(score, time, isNewHighScore)
             * @example
             * window.PaperPlaneGame.onGameOver = function(score, time, isNew) {
             *     console.log('Game over! Score:', score);
             * };
             */
            onGameOver: null
        };

        /**
         * 向父窗口發送 postMessage(適用於跨來源 iframe 嵌入)
         */
        function notifyParentGameOver(score, time, isNewHighScore) {
            if (window.parent && window.parent !== window) {
                try {
                    window.parent.postMessage({
                        source: 'paperPlaneGame',
                        event: 'gameOver',
                        data: {
                            score: score,
                            time: time,
                            isNewHighScore: isNewHighScore,
                            sessionHighScore: sessionHighScore,
                            sessionHighScoreTime: sessionHighScoreTime
                        }
                    }, '*');
                } catch (e) {
                    // 忽略跨來源通訊錯誤
                }
            }
        }

        // ──────────────── 輔助函式 ────────────────
        function resizeCanvas() {
            canvasW = wrapper.clientWidth;
            canvasH = wrapper.clientHeight;
            canvas.width = canvasW;
            canvas.height = canvasH;
        }
        window.addEventListener('resize', resizeCanvas);
        resizeCanvas();

        /** 更新選單上的本輪最高分顯示 */
        function updateMenuHighScore() {
            if (sessionHighScore >= 0) {
                menuHighScoreEl.textContent =
                    '本次最高分:' + sessionHighScore + ' 分 (用時 ' + sessionHighScoreTime + 's)';
            } else {
                menuHighScoreEl.textContent = '';
            }
        }

        // ──────────────── 鍵盤控制 ────────────────
        window.addEventListener('keydown', (e) => {
            if (e.code === 'ArrowLeft') keys.left = true;
            if (e.code === 'ArrowRight') keys.right = true;
        });
        window.addEventListener('keyup', (e) => {
            if (e.code === 'ArrowLeft') keys.left = false;
            if (e.code === 'ArrowRight') keys.right = false;
        });

        // ──────────────── 觸控控制(手機端左右半屏) ────────────────
        wrapper.addEventListener('touchstart', (e) => {
            if (!state.isPlaying) return;
            for (let i = 0; i < e.changedTouches.length; i++) {
                let touch = e.changedTouches[i];
                if (touch.clientX < window.innerWidth / 2) keys.left = true;
                else keys.right = true;
            }
        });
        wrapper.addEventListener('touchend', (e) => {
            for (let i = 0; i < e.changedTouches.length; i++) {
                let touch = e.changedTouches[i];
                if (touch.clientX < window.innerWidth / 2) keys.left = false;
                else keys.right = false;
            }
        });
        wrapper.addEventListener('touchcancel', () => {
            keys.left = false;
            keys.right = false;
        });

        // ──────────────── 遊戲流程 ────────────────
        function startGame() {
            uiMenu.classList.add('hidden');
            uiGameOver.classList.add('hidden');
            isGameOverState = false;

            // 初始化飛機
            plane.x = 40;
            plane.worldY = 40;
            plane.screenY = 40;
            plane.angle = CONFIG.maxAngle;
            plane.isDead = false;

            // 恢復飛機顏色
            planeLeft.setAttribute('fill', '#FFFFFF');
            planeRight.setAttribute('fill', '#E0E0E0');

            state.isPlaying = true;
            state.score = 0;
            state.time = 0;
            state.startTime = performance.now();
            state.cameraY = 0;
            state.lastSpawnY = canvasH;

            blocks = [];
            keys.left = false;
            keys.right = false;

            updateMenuHighScore();
            requestAnimationFrame(gameLoop);
        }

        function gameOver() {
            state.isPlaying = false;
            plane.isDead = true;
            isGameOverState = true;

            // 飛機變紅色
            planeLeft.setAttribute('fill', '#FF5252');
            planeRight.setAttribute('fill', '#D32F2F');

            // 記錄本次成績
            lastGameScore = state.score;
            lastGameTime = state.time;

            // 判斷是否刷新本輪會話最高分
            let isNewHighScore = (lastGameScore > sessionHighScore);
            lastGameIsNewHighScore = isNewHighScore;

            if (isNewHighScore) {
                sessionHighScore = lastGameScore;
                sessionHighScoreTime = lastGameTime;
            }

            // 更新遊戲結束 UI
            document.getElementById('finalScore').innerText = lastGameScore;
            document.getElementById('finalTime').innerText = lastGameTime + 's';

            const highScoreMsgDiv = document.getElementById('highScoreMsg');
            if (isNewHighScore) {
                highScoreMsgDiv.className = 'highscore-msg new-record';
                highScoreMsgDiv.innerHTML = '🎉 創造歷史最高分:<strong>' + sessionHighScore +
                    '</strong> 分!<br><small>用時 ' + sessionHighScoreTime + 's</small>';
            } else {
                highScoreMsgDiv.className = 'highscore-msg';
                highScoreMsgDiv.innerHTML = '歷史最高分為 <strong>' + sessionHighScore +
                    '</strong> 分<br><small>(用時 ' + sessionHighScoreTime + 's)</small>';
            }

            // 呼叫外部回調(同來源 iframe)
            if (typeof window.PaperPlaneGame.onGameOver === 'function') {
                try {
                    window.PaperPlaneGame.onGameOver(lastGameScore, lastGameTime, isNewHighScore);
                } catch (e) {
                    // 忽略回調中的錯誤
                }
            }

            // 向父窗口發送 postMessage(跨來源 iframe)
            notifyParentGameOver(lastGameScore, lastGameTime, isNewHighScore);

            // 更新選單最高分
            updateMenuHighScore();

            // 延遲顯示結束畫面
            setTimeout(() => {
                uiGameOver.classList.remove('hidden');
            }, 800);
        }

        function gameLoop(timestamp) {
            if (!state.isPlaying) return;

            // 更新時間與分數
            state.time = ((timestamp - state.startTime) / 1000).toFixed(1);
            state.score = Math.floor(plane.worldY / 100);
            timeDisp.innerText = '時間: ' + state.time + 's';
            scoreDisp.innerText = '分數: ' + state.score;

            // 處理轉向
            if (keys.left) plane.angle -= CONFIG.turnSpeed;
            if (keys.right) plane.angle += CONFIG.turnSpeed;
            plane.angle = Math.max(-CONFIG.maxAngle, Math.min(CONFIG.maxAngle, plane.angle));

            // 計算下落速度
            let angleRatio = Math.abs(plane.angle) / CONFIG.maxAngle;
            let currentSpeed = CONFIG.baseFallSpeed * (1 - (angleRatio * 0.6));

            // 更新飛機世界座標
            plane.worldY += currentSpeed * Math.cos(plane.angle);
            plane.x += currentSpeed * Math.sin(plane.angle);

            // 邊界限制
            if (plane.x < 20) { plane.x = 20;
                plane.angle = 0; }
            if (plane.x > canvasW - 20) { plane.x = canvasW - 20;
                plane.angle = 0; }

            // 攝像機跟隨
            if (plane.worldY > canvasH / 2) {
                state.cameraY = plane.worldY - canvasH / 2;
                plane.screenY = canvasH / 2;
            } else {
                plane.screenY = plane.worldY;
            }

            // 生成障礙物
            while (state.lastSpawnY < plane.worldY + canvasH) {
                state.lastSpawnY += CONFIG.blockDensity;
                let w = Math.random() * (CONFIG.blockMaxW - CONFIG.blockMinW) + CONFIG.blockMinW;
                let x = Math.random() * (canvasW - w);
                blocks.push({
                    x: x,
                    worldY: state.lastSpawnY,
                    w: w,
                    h: CONFIG.blockHeight,
                    isHit: false
                });
            }

            // 清理舊方塊
            blocks = blocks.filter(b => b.worldY > state.cameraY - 50);

            // 更新 SVG 飛機偽三維渲染
            let rotateDeg = -plane.angle * (180 / Math.PI);
            let rollDeg = plane.angle * 45;
            planeSvg.style.transform =
                'translate(calc(' + plane.x + 'px - 50%), calc(' + plane.screenY + 'px - 50%)) rotate(' +
                rotateDeg + 'deg) rotateY(' + rollDeg + 'deg)';

            // 繪製畫面與碰撞檢測
            ctx.clearRect(0, 0, canvasW, canvasH);

            let planeHitbox = { x: plane.x - 10, y: plane.screenY - 15, w: 20, h: 30 };

            blocks.forEach(b => {
                let screenY = b.worldY - state.cameraY;

                // AABB 碰撞檢測
                if (
                    planeHitbox.x < b.x + b.w &&
                    planeHitbox.x + planeHitbox.w > b.x &&
                    planeHitbox.y < screenY + b.h &&
                    planeHitbox.y + planeHitbox.h > screenY
                ) {
                    b.isHit = true;
                    gameOver();
                }

                // 繪製方塊
                ctx.fillStyle = b.isHit ? '#FF1744' : '#FF80AB';
                ctx.strokeStyle = '#333';
                ctx.lineWidth = 3;
                ctx.beginPath();
                if (ctx.roundRect) {
                    ctx.roundRect(b.x, screenY, b.w, b.h, 6);
                } else {
                    // fallback for older browsers
                    ctx.rect(b.x, screenY, b.w, b.h);
                }
                ctx.fill();
                ctx.stroke();
            });

            if (state.isPlaying) {
                animationFrameId = requestAnimationFrame(gameLoop);
            }
        }

        // ──────────────── 按鈕事件 ────────────────
        document.getElementById('startBtn').addEventListener('click', startGame);

        document.getElementById('restartBtn').addEventListener('click', () => {
            startGame();
        });

        // ──────────────── 初始渲染 ────────────────
        updateMenuHighScore();
    </script>

</body>
</html>


以上的html文件將上傳至wordpress的媒體中。已知它的id。現在寫一個wordpress的php頁面:

獲取這個id對應的文件地址,把地址賦值給一個iframe的src。

php判斷用戶有沒有登入,如果沒有則提示「登入可將參與排名,點擊登入。」如果已登入則提示「歡迎xxx,你的歷史最高分為xxx(用時xxx)」。

之後是排行榜,排行榜的項目為:序號(排名),分數,用時,日期和時間。一個用戶名最多只出現一次,只保留他的最高分。排行榜一次顯示十個,可翻頁。

排行榜的數據將是本文章的meta,用json格式保存。

iframe將顯示在下方。在其右上角增加兩個svg按鈕,功能是「網頁全屏,顯示器全屏」。

當iframe內的遊戲結束,將把回傳的分數和用時傳給php。如果當前用戶已登入,應用wordpress中的ajax,獲取本用戶名在排行榜上的分數,如果超過歷史分數,則提示「創造新紀錄,排名至xxx」,如果沒有則提示「歷史最高分為xxx」。當前的時間日期(yyyy-mm-dd hh:ii:ss)將由php在保存時生成。

如果沒有登入,則將只計算這個分數能在當前排行榜排第幾,但不會真的加進排行榜。

這個php代碼將保存至snippets擴展。
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀
一枝独秀
帖子: 5847
注册时间: 2022年 11月 18日 17:36
联系:

Re: 用國內(能直連的)AI寫紙飛機遊戲

帖子 ejsoon »

gemini 3.1 pro:

代码: 全选

這是一個完整的 WordPress PHP 代碼片段,專為「Code Snippets」等擴充套件設計。它包含了一個短代碼(Shortcode),用於在文章或頁面中渲染遊戲 iframe、排行榜、登入提示以及全屏控制按鈕。同時也包含了處理遊戲結束時與後端通訊的 AJAX 邏輯。

### 使用方法:
1. 將以下代碼新增至您的 WordPress Snippets 擴充套件中,並選擇 **「在前端運行」或「隨處運行」**。
2. 在任何文章或頁面中輸入短代碼:`[paper_plane_game id="您的媒體庫文件ID"]` (例如:`[paper_plane_game id="125"]`)。

### PHP 代碼片段:

```php
<?php
// 1. 註冊短代碼 [paper_plane_game id="123"]
add_shortcode('paper_plane_game', 'render_paper_plane_game_shortcode');

function render_paper_plane_game_shortcode($atts) {
    global $post;
    
    // 解析短代碼參數
    $atts = shortcode_atts(array(
        'id' => 0
    ), $atts);
    
    $attachment_id = intval($atts['id']);
    if (!$attachment_id) return '<p>請提供遊戲文件的媒體庫 ID。</p>';
    
    $iframe_url = wp_get_attachment_url($attachment_id);
    if (!$iframe_url) return '<p>無法找到對應的遊戲文件。</p>';

    $post_id = $post->ID;
    $current_user = wp_get_current_user();
    $is_logged_in = is_user_logged_in();
    $login_url = wp_login_url(get_permalink());
    
    // 獲取當前排行榜數據(保存在文章 meta 中)
    $leaderboard = get_post_meta($post_id, '_paper_plane_leaderboard', true);
    if (!is_array($leaderboard)) $leaderboard = array();

    // 獲取當前用戶歷史最高分
    $user_best_score = 0;
    $user_best_time = 0;
    if ($is_logged_in) {
        foreach ($leaderboard as $entry) {
            if ($entry['user_id'] == $current_user->ID) {
                $user_best_score = $entry['score'];
                $user_best_time = $entry['time'];
                break;
            }
        }
    }

    // 將 PHP 變量傳遞給前端 JS
    $js_data = array(
        'ajaxurl' => admin_url('admin-ajax.php'),
        'nonce'   => wp_create_nonce('paper_plane_nonce'),
        'post_id' => $post_id,
        'is_logged_in' => $is_logged_in,
        'leaderboard' => $leaderboard
    );

    ob_start();
    ?>

    <style>
        .pp-game-container { font-family: sans-serif; max-width: 800px; margin: 0 auto; }
        .pp-status-bar { padding: 10px; background: #f0f4f8; border-radius: 8px; margin-bottom: 15px; text-align: center; font-size: 16px; }
        .pp-status-bar a { color: #0073aa; font-weight: bold; text-decoration: none; }
        
        /* iframe 及全屏樣式 */
        .pp-iframe-wrapper { position: relative; width: 100%; height: 600px; background: #000; border-radius: 8px; overflow: hidden; transition: all 0.3s ease; }
        .pp-iframe-wrapper iframe { width: 100%; height: 100%; border: none; display: block; }
        .pp-iframe-wrapper.web-fullscreen { position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; z-index: 99999; border-radius: 0; }
        
        /* 控制按鈕 */
        .pp-controls { position: absolute; top: 10px; right: 10px; display: flex; gap: 8px; z-index: 10; }
        .pp-btn { background: rgba(255,255,255,0.7); border: none; border-radius: 4px; width: 36px; height: 36px; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: 0.2s; }
        .pp-btn:hover { background: rgba(255,255,255,1); }
        .pp-btn svg { width: 20px; height: 20px; fill: #333; }

        /* 排行榜樣式 */
        .pp-leaderboard { margin-top: 20px; border: 1px solid #ddd; border-radius: 8px; overflow: hidden; }
        .pp-leaderboard-header { background: #333; color: #fff; padding: 10px; text-align: center; font-weight: bold; }
        .pp-table { width: 100%; border-collapse: collapse; text-align: center; font-size: 14px; }
        .pp-table th, .pp-table td { padding: 8px; border-bottom: 1px solid #eee; }
        .pp-table th { background: #f9f9f9; }
        .pp-pagination { display: flex; justify-content: center; align-items: center; padding: 10px; gap: 15px; background: #f9f9f9; }
        .pp-page-btn { padding: 4px 10px; cursor: pointer; background: #ddd; border: none; border-radius: 4px; }
        .pp-page-btn:disabled { opacity: 0.5; cursor: not-allowed; }
    </style>

    <div class="pp-game-container">
        <!-- 用戶狀態提示 -->
        <div class="pp-status-bar" id="pp-status-msg">
            <?php if (!$is_logged_in): ?>
                登入可參與排名,<a href="<?php echo esc_url($login_url); ?>">點擊登入</a>。
            <?php else: ?>
                歡迎 <strong><?php echo esc_html($current_user->display_name); ?></strong>,您的歷史最高分為 <strong><?php echo $user_best_score; ?></strong> 分(用時 <?php echo $user_best_time; ?>s)。
            <?php endif; ?>
        </div>

        <!-- 遊戲主體與控制按鈕 -->
        <div class="pp-iframe-wrapper" id="pp-wrapper">
            <div class="pp-controls">
                <!-- 網頁全屏按鈕 -->
                <button class="pp-btn" id="btn-web-fs" title="網頁全屏">
                    <svg viewBox="0 0 24 24"><path d="M15 3h6v6h-2V6h-4v-3zM9 3H3v6h2V6h4v-3zm6 18h6v-6h-2v4h-4v2zm-6 0H3v-6h2v4h4v2z"/></svg>
                </button>
                <!-- 顯示器全屏按鈕 -->
                <button class="pp-btn" id="btn-monitor-fs" title="顯示器全屏">
                    <svg viewBox="0 0 24 24"><path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z"/></svg>
                </button>
            </div>
            <iframe src="<?php echo esc_url($iframe_url); ?>" id="pp-iframe" allowfullscreen></iframe>
        </div>

        <!-- 排行榜 -->
        <div class="pp-leaderboard">
            <div class="pp-leaderboard-header">🏆 遊戲排行榜</div>
            <table class="pp-table">
                <thead>
                    <tr>
                        <th>排名</th><th>玩家</th><th>分數</th><th>用時</th><th>時間</th>
                    </tr>
                </thead>
                <tbody id="pp-tbody">
                    <!-- 數據將由 JS 渲染 -->
                </tbody>
            </table>
            <div class="pp-pagination">
                <button class="pp-page-btn" id="pp-prev">上一頁</button>
                <span id="pp-page-info">1 / 1</span>
                <button class="pp-page-btn" id="pp-next">下一頁</button>
            </div>
        </div>
    </div>

    <script>
    document.addEventListener("DOMContentLoaded", function() {
        const gameData = <?php echo json_encode($js_data); ?>;
        let leaderboard = gameData.leaderboard;
        let currentPage = 1;
        const itemsPerPage = 10;

        // --- 排行榜渲染邏輯 ---
        function renderLeaderboard() {
            const tbody = document.getElementById('pp-tbody');
            const totalPages = Math.ceil(leaderboard.length / itemsPerPage) || 1;
            
            if (currentPage > totalPages) currentPage = totalPages;
            if (currentPage < 1) currentPage = 1;

            document.getElementById('pp-page-info').innerText = currentPage + ' / ' + totalPages;
            document.getElementById('pp-prev').disabled = currentPage === 1;
            document.getElementById('pp-next').disabled = currentPage === totalPages;

            tbody.innerHTML = '';
            
            if(leaderboard.length === 0) {
                tbody.innerHTML = '<tr><td colspan="5">暫無排名數據,快來搶首殺吧!</td></tr>';
                return;
            }

            const startIdx = (currentPage - 1) * itemsPerPage;
            const pageData = leaderboard.slice(startIdx, startIdx + itemsPerPage);

            pageData.forEach((item, index) => {
                let rank = startIdx + index + 1;
                let tr = document.createElement('tr');
                tr.innerHTML = `
                    <td>${rank}</td>
                    <td>${item.username}</td>
                    <td>${item.score}</td>
                    <td>${item.time}s</td>
                    <td>${item.date}</td>
                `;
                tbody.appendChild(tr);
            });
        }

        document.getElementById('pp-prev').addEventListener('click', () => { currentPage--; renderLeaderboard(); });
        document.getElementById('pp-next').addEventListener('click', () => { currentPage++; renderLeaderboard(); });
        renderLeaderboard();

        // --- 全屏邏輯 ---
        const wrapper = document.getElementById('pp-wrapper');
        
        document.getElementById('btn-web-fs').addEventListener('click', function() {
            wrapper.classList.toggle('web-fullscreen');
        });

        document.getElementById('btn-monitor-fs').addEventListener('click', function() {
            if (!document.fullscreenElement) {
                if (wrapper.requestFullscreen) { wrapper.requestFullscreen(); }
                else if (wrapper.webkitRequestFullscreen) { wrapper.webkitRequestFullscreen(); }
                else if (wrapper.msRequestFullscreen) { wrapper.msRequestFullscreen(); }
            } else {
                if (document.exitFullscreen) { document.exitFullscreen(); }
                else if (document.webkitExitFullscreen) { document.webkitExitFullscreen(); }
            }
        });

        // 離開顯示器全屏時,確保網頁全屏狀態不出錯
        document.addEventListener('fullscreenchange', () => {
            if (!document.fullscreenElement) {
                wrapper.classList.remove('web-fullscreen');
            }
        });

        // --- 接收 iframe 的 postMessage 通訊 ---
        window.addEventListener('message', function(e) {
            // 驗證消息來源
            if (e.data && e.data.source === 'paperPlaneGame' && e.data.event === 'gameOver') {
                const finalScore = parseInt(e.data.data.score);
                const finalTime = parseFloat(e.data.data.time);

                // 發送 AJAX 請求至 WordPress 後台
                const formData = new URLSearchParams();
                formData.append('action', 'pp_save_game_score');
                formData.append('nonce', gameData.nonce);
                formData.append('post_id', gameData.post_id);
                formData.append('score', finalScore);
                formData.append('time', finalTime);

                fetch(gameData.ajaxurl, {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
                    body: formData.toString()
                })
                .then(res => res.json())
                .then(response => {
                    if (response.success) {
                        alert(response.data.message);
                        if (response.data.leaderboard) {
                            // 更新前端排行榜數據並重新渲染
                            leaderboard = response.data.leaderboard;
                            renderLeaderboard();
                        }
                        if (response.data.status_msg) {
                            document.getElementById('pp-status-msg').innerHTML = response.data.status_msg;
                        }
                    }
                }).catch(err => console.error('Error submitting score:', err));
            }
        });
    });
    </script>
    <?php
    return ob_get_clean();
}


// 2. 註冊 AJAX 處理邏輯
add_action('wp_ajax_pp_save_game_score', 'pp_handle_save_game_score');
add_action('wp_ajax_nopriv_pp_save_game_score', 'pp_handle_save_game_score');

function pp_handle_save_game_score() {
    check_ajax_referer('paper_plane_nonce', 'nonce');

    $post_id = intval($_POST['post_id']);
    $score = intval($_POST['score']);
    $time = floatval($_POST['time']);

    // 獲取目前排行榜數據
    $leaderboard = get_post_meta($post_id, '_paper_plane_leaderboard', true);
    if (!is_array($leaderboard)) $leaderboard = array();

    if (is_user_logged_in()) {
        $user = wp_get_current_user();
        $user_id = $user->ID;
        $username = $user->display_name;
        $date = current_time('Y-m-d H:i:s'); // WordPress 本地時區

        $found = false;
        $new_record = false;
        $user_best = $score;
        $user_best_time = $time;

        // 查找用戶是否已在排行榜中
        foreach ($leaderboard as $key => $entry) {
            if ($entry['user_id'] == $user_id) {
                $found = true;
                if ($score > $entry['score']) {
                    $leaderboard[$key]['score'] = $score;
                    $leaderboard[$key]['time'] = $time;
                    $leaderboard[$key]['date'] = $date;
                    $new_record = true;
                } else {
                    $user_best = $entry['score'];
                    $user_best_time = $entry['time'];
                }
                break;
            }
        }

        // 首次遊玩加入記錄
        if (!$found) {
            $leaderboard[] = array(
                'user_id' => $user_id,
                'username' => $username,
                'score' => $score,
                'time' => $time,
                'date' => $date
            );
            $new_record = true;
        }

        // 排序算法:分數高的在前,若分數相同,用時較短的在前
        usort($leaderboard, function($a, $b) {
            if ($a['score'] == $b['score']) {
                return ($a['time'] < $b['time']) ? -1 : 1;
            }
            return ($a['score'] > $b['score']) ? -1 : 1;
        });

        // 儲存更新後的 JSON
        update_post_meta($post_id, '_paper_plane_leaderboard', $leaderboard);

        // 找出目前的排名
        $rank = 0;
        foreach ($leaderboard as $index => $entry) {
            if ($entry['user_id'] == $user_id) {
                $rank = $index + 1;
                break;
            }
        }

        $message = $new_record 
            ? "🎉 創造新紀錄!排名至第 {$rank} 名!" 
            : "歷史最高分為 {$user_best} 分";

        $status_msg = "歡迎 <strong>{$username}</strong>,您的歷史最高分為 <strong>{$user_best}</strong> 分(用時 {$user_best_time}s)。";

        wp_send_json_success(array(
            'message' => $message,
            'leaderboard' => $leaderboard,
            'status_msg' => $status_msg
        ));

    } else {
        // 未登入用戶:僅計算模擬排名,不寫入數據庫
        $temp_entry = array(
            'user_id' => 'guest',
            'score' => $score,
            'time' => $time
        );
        $temp_board = $leaderboard;
        $temp_board[] = $temp_entry;

        usort($temp_board, function($a, $b) {
            if ($a['score'] == $b['score']) {
                return ($a['time'] < $b['time']) ? -1 : 1;
            }
            return ($a['score'] > $b['score']) ? -1 : 1;
        });

        $rank = 0;
        foreach ($temp_board as $index => $entry) {
            if (isset($entry['user_id']) && $entry['user_id'] === 'guest') {
                $rank = $index + 1;
                break;
            }
        }

        wp_send_json_success(array(
            'message' => "本次成績可在排行榜名列第 {$rank} 名!\n(目前為訪客狀態,未記錄分數)",
            'leaderboard' => null // 不觸發前端排行榜刷新
        ));
    }
}
?>
```

### 功能詳解:
1. **短代碼調用**:在後台新增好 Snippet 後,將 `[paper_plane_game id="X"]` 放進任何區塊,系統會根據 ID 自動抓取媒體庫的對應文件連結。
2. **前後端通訊 (postMessage & AJAX)**:利用您的 HTML 遊戲中自帶的 `window.parent.postMessage`,一旦觸發了 `gameOver`,頁面的 JavaScript 就會擷取這個信號,並透過 AJAX(攜帶 nonce 保證安全)把分數傳給 WordPress。
3. **未登入處理**:訪客遊玩結束時,系統會在陣列中「虛擬」排一次序,彈出警告窗告訴用戶他能排第幾名,但不會將該筆資料寫進數據庫。
4. **最高分單次留存**:每位登入會員僅用 `user_id` 作為唯一標識,排行榜陣列會判斷若 `score` 高於歷史記錄才覆寫;若分數相同,會採用時間較短的排在前面(常見規則)。
5. **分頁渲染**:將陣列匯入 JavaScript 當中作前端快速翻頁,每頁 10 筆,不需重新整理網頁即可即時查看最新排名榜單。
6. **全屏顯示器**:右上角提供兩個按鈕。左邊是「網頁佈局全屏」(透過 CSS 讓 iframe 佔滿螢幕),右邊呼叫了作業系統底層 API,達成徹底的「螢幕沉浸式全屏」。
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀
一枝独秀
帖子: 5847
注册时间: 2022年 11月 18日 17:36
联系:

Re: 用國內(能直連的)AI寫紙飛機遊戲

帖子 ejsoon »

deepseek 4 pro

代码: 全选

Hello! How can I assist you today?

<!DOCTYPE html>
<html lang="zh-TW">
    <script>
        /**
         * 遊戲參數配置
         */
        const CONFIG = {
            blockDensity: 180,
            blockMinW: 120,
            blockMaxW: 240,
            blockHeight: 24,
            baseFallSpeed: 9,
            turnSpeed: 0.08,
            maxAngle: Math.PI / 2.2
        };

        // ──────────────── DOM 元素 ────────────────
        const wrapper = document.getElementById('game-wrapper');
        const canvas = document.getElementById('gameCanvas');
        const ctx = canvas.getContext('2d');
        const planeSvg = document.getElementById('plane-svg');
        const planeLeft = document.getElementById('planeLeft');
        const planeRight = document.getElementById('planeRight');

        const uiMenu = document.getElementById('menuUI');
        const uiGameOver = document.getElementById('gameOverUI');
        const scoreDisp = document.getElementById('scoreDisplay');
        const timeDisp = document.getElementById('timeDisplay');
        const menuHighScoreEl = document.getElementById('menuHighScore');

        let canvasW, canvasH;
        let animationFrameId;

        // ──────────────── 遊戲狀態 ────────────────
        let state = {
            isPlaying: false,
            score: 0,
            time: 0,
            startTime: 0,
            cameraY: 0,
            lastSpawnY: 0
        };

        let plane = {
            x: 0,
            worldY: 0,
            screenY: 0,
            angle: 0,
            isDead: false
        };

        let blocks = [];
        let keys = { left: false, right: false };

        // ──────────────── 本輪會話最高分記錄(頁面刷新即重置) ────────────────
        var sessionHighScore = -1; // -1 表示尚未有任何遊戲記錄
        var sessionHighScoreTime = 0;
        var lastGameScore = 0;
        var lastGameTime = 0;
        var isGameOverState = false;
        var lastGameIsNewHighScore = false;

        // ──────────────── 外部 API(供 iframe 父頁面調用) ────────────────
        window.PaperPlaneGame = {
            /**
             * 取得完整遊戲狀態
             * @returns {Object} 包含 isPlaying, isGameOver, lastScore, lastTime,
             *                   sessionHighScore, sessionHighScoreTime, isNewHighScore
             */
            getState: function() {
                return {
                    isPlaying: state.isPlaying,
                    isGameOver: isGameOverState,
                    lastScore: lastGameScore,
                    lastTime: lastGameTime,
                    sessionHighScore: sessionHighScore,
                    sessionHighScoreTime: sessionHighScoreTime,
                    isNewHighScore: lastGameIsNewHighScore
                };
            },

            /** 遊戲是否正在進行中 */
            isPlaying: function() { return state.isPlaying; },

            /** 遊戲是否已結束(遊戲結束畫面顯示中) */
            isGameOver: function() { return isGameOverState; },

            /** 取得上一次遊戲的分數 */
            getLastScore: function() { return lastGameScore; },

            /** 取得上一次遊戲的用時(秒) */
            getLastTime: function() { return lastGameTime; },

            /** 取得本輪會話最高分 */
            getSessionHighScore: function() { return sessionHighScore; },

            /** 取得本輪會話最高分的用時 */
            getSessionHighScoreTime: function() { return sessionHighScoreTime; },

            /** 上一次遊戲是否刷新了最高分 */
            isNewHighScore: function() { return lastGameIsNewHighScore; },

            /**
             * 遊戲結束時的回調(同一來源的父頁面可設置此屬性)
             * @type {Function|null} callback(score, time, isNewHighScore)
             * @example
             * window.PaperPlaneGame.onGameOver = function(score, time, isNew) {
             *     console.log('Game over! Score:', score);
             * };
             */
            onGameOver: null
        };

        /**
         * 向父窗口發送 postMessage(適用於跨來源 iframe 嵌入)
         */
        function notifyParentGameOver(score, time, isNewHighScore) {
            if (window.parent && window.parent !== window) {
                try {
                    window.parent.postMessage({
                        source: 'paperPlaneGame',
                        event: 'gameOver',
                        data: {
                            score: score,
                            time: time,
                            isNewHighScore: isNewHighScore,
                            sessionHighScore: sessionHighScore,
                            sessionHighScoreTime: sessionHighScoreTime
                        }
                    }, '*');
                } catch (e) {
                    // 忽略跨來源通訊錯誤
                }
            }
        }

        // ──────────────── 輔助函式 ────────────────
        function resizeCanvas() {
            canvasW = wrapper.clientWidth;
            canvasH = wrapper.clientHeight;
            canvas.width = canvasW;
            canvas.height = canvasH;
        }
        window.addEventListener('resize', resizeCanvas);
        resizeCanvas();

        /** 更新選單上的本輪最高分顯示 */
        function updateMenuHighScore() {
            if (sessionHighScore >= 0) {
                menuHighScoreEl.textContent =
                    '本次最高分:' + sessionHighScore + ' 分 (用時 ' + sessionHighScoreTime + 's)';
            } else {
                menuHighScoreEl.textContent = '';
            }
        }

        // ──────────────── 鍵盤控制 ────────────────
        window.addEventListener('keydown', (e) => {
            if (e.code === 'ArrowLeft') keys.left = true;
            if (e.code === 'ArrowRight') keys.right = true;
        });
        window.addEventListener('keyup', (e) => {
            if (e.code === 'ArrowLeft') keys.left = false;
            if (e.code === 'ArrowRight') keys.right = false;
        });

        // ──────────────── 觸控控制(手機端左右半屏) ────────────────
        wrapper.addEventListener('touchstart', (e) => {
            if (!state.isPlaying) return;
            for (let i = 0; i < e.changedTouches.length; i++) {
                let touch = e.changedTouches[i];
                if (touch.clientX < window.innerWidth / 2) keys.left = true;
                else keys.right = true;
            }
        });
        wrapper.addEventListener('touchend', (e) => {
            for (let i = 0; i < e.changedTouches.length; i++) {
                let touch = e.changedTouches[i];
                if (touch.clientX < window.innerWidth / 2) keys.left = false;
                else keys.right = false;
            }
        });
        wrapper.addEventListener('touchcancel', () => {
            keys.left = false;
            keys.right = false;
        });

        // ──────────────── 遊戲流程 ────────────────
        function startGame() {
            uiMenu.classList.add('hidden');
            uiGameOver.classList.add('hidden');
            isGameOverState = false;

            // 初始化飛機
            plane.x = 40;
            plane.worldY = 40;
            plane.screenY = 40;
            plane.angle = CONFIG.maxAngle;
            plane.isDead = false;

            // 恢復飛機顏色
            planeLeft.setAttribute('fill', '#FFFFFF');
            planeRight.setAttribute('fill', '#E0E0E0');

            state.isPlaying = true;
            state.score = 0;
            state.time = 0;
            state.startTime = performance.now();
            state.cameraY = 0;
            state.lastSpawnY = canvasH;

            blocks = [];
            keys.left = false;
            keys.right = false;

            updateMenuHighScore();
            requestAnimationFrame(gameLoop);
        }

        function gameOver() {
            state.isPlaying = false;
            plane.isDead = true;
            isGameOverState = true;

            // 飛機變紅色
            planeLeft.setAttribute('fill', '#FF5252');
            planeRight.setAttribute('fill', '#D32F2F');

            // 記錄本次成績
            lastGameScore = state.score;
            lastGameTime = state.time;

            // 判斷是否刷新本輪會話最高分
            let isNewHighScore = (lastGameScore > sessionHighScore);
            lastGameIsNewHighScore = isNewHighScore;

            if (isNewHighScore) {
                sessionHighScore = lastGameScore;
                sessionHighScoreTime = lastGameTime;
            }

            // 更新遊戲結束 UI
            document.getElementById('finalScore').innerText = lastGameScore;
            document.getElementById('finalTime').innerText = lastGameTime + 's';

            const highScoreMsgDiv = document.getElementById('highScoreMsg');
            if (isNewHighScore) {
                highScoreMsgDiv.className = 'highscore-msg new-record';
                highScoreMsgDiv.innerHTML = '🎉 創造歷史最高分:<strong>' + sessionHighScore +
                    '</strong> 分!<br><small>用時 ' + sessionHighScoreTime + 's</small>';
            } else {
                highScoreMsgDiv.className = 'highscore-msg';
                highScoreMsgDiv.innerHTML = '歷史最高分為 <strong>' + sessionHighScore +
                    '</strong> 分<br><small>(用時 ' + sessionHighScoreTime + 's)</small>';
            }

            // 呼叫外部回調(同來源 iframe)
            if (typeof window.PaperPlaneGame.onGameOver === 'function') {
                try {
                    window.PaperPlaneGame.onGameOver(lastGameScore, lastGameTime, isNewHighScore);
                } catch (e) {
                    // 忽略回調中的錯誤
                }
            }

            // 向父窗口發送 postMessage(跨來源 iframe)
            notifyParentGameOver(lastGameScore, lastGameTime, isNewHighScore);

            // 更新選單最高分
            updateMenuHighScore();

            // 延遲顯示結束畫面
            setTimeout(() => {
                uiGameOver.classList.remove('hidden');
            }, 800);
        }

        function gameLoop(timestamp) {
            if (!state.isPlaying) return;

            // 更新時間與分數
            state.time = ((timestamp - state.startTime) / 1000).toFixed(1);
            state.score = Math.floor(plane.worldY / 100);
            timeDisp.innerText = '時間: ' + state.time + 's';
            scoreDisp.innerText = '分數: ' + state.score;

            // 處理轉向
            if (keys.left) plane.angle -= CONFIG.turnSpeed;
            if (keys.right) plane.angle += CONFIG.turnSpeed;
            plane.angle = Math.max(-CONFIG.maxAngle, Math.min(CONFIG.maxAngle, plane.angle));

            // 計算下落速度
            let angleRatio = Math.abs(plane.angle) / CONFIG.maxAngle;
            let currentSpeed = CONFIG.baseFallSpeed * (1 - (angleRatio * 0.6));

            // 更新飛機世界座標
            plane.worldY += currentSpeed * Math.cos(plane.angle);
            plane.x += currentSpeed * Math.sin(plane.angle);

            // 邊界限制
            if (plane.x < 20) { plane.x = 20;
                plane.angle = 0; }
            if (plane.x > canvasW - 20) { plane.x = canvasW - 20;
                plane.angle = 0; }

            // 攝像機跟隨
            if (plane.worldY > canvasH / 2) {
                state.cameraY = plane.worldY - canvasH / 2;
                plane.screenY = canvasH / 2;
            } else {
                plane.screenY = plane.worldY;
            }

            // 生成障礙物
            while (state.lastSpawnY < plane.worldY + canvasH) {
                state.lastSpawnY += CONFIG.blockDensity;
                let w = Math.random() * (CONFIG.blockMaxW - CONFIG.blockMinW) + CONFIG.blockMinW;
                let x = Math.random() * (canvasW - w);
                blocks.push({
                    x: x,
                    worldY: state.lastSpawnY,
                    w: w,
                    h: CONFIG.blockHeight,
                    isHit: false
                });
            }

            // 清理舊方塊
            blocks = blocks.filter(b => b.worldY > state.cameraY - 50);

            // 更新 SVG 飛機偽三維渲染
            let rotateDeg = -plane.angle * (180 / Math.PI);
            let rollDeg = plane.angle * 45;
            planeSvg.style.transform =
                'translate(calc(' + plane.x + 'px - 50%), calc(' + plane.screenY + 'px - 50%)) rotate(' +
                rotateDeg + 'deg) rotateY(' + rollDeg + 'deg)';

            // 繪製畫面與碰撞檢測
            ctx.clearRect(0, 0, canvasW, canvasH);

            let planeHitbox = { x: plane.x - 10, y: plane.screenY - 15, w: 20, h: 30 };

            blocks.forEach(b => {
                let screenY = b.worldY - state.cameraY;

                // AABB 碰撞檢測
                if (
                    planeHitbox.x < b.x + b.w &&
                    planeHitbox.x + planeHitbox.w > b.x &&
                    planeHitbox.y < screenY + b.h &&
                    planeHitbox.y + planeHitbox.h > screenY
                ) {
                    b.isHit = true;
                    gameOver();
                }

                // 繪製方塊
                ctx.fillStyle = b.isHit ? '#FF1744' : '#FF80AB';
                ctx.strokeStyle = '#333';
                ctx.lineWidth = 3;
                ctx.beginPath();
                if (ctx.roundRect) {
                    ctx.roundRect(b.x, screenY, b.w, b.h, 6);
                } else {
                    // fallback for older browsers
                    ctx.rect(b.x, screenY, b.w, b.h);
                }
                ctx.fill();
                ctx.stroke();
            });

            if (state.isPlaying) {
                animationFrameId = requestAnimationFrame(gameLoop);
            }
        }

        // ──────────────── 按鈕事件 ────────────────
        document.getElementById('startBtn').addEventListener('click', startGame);

        document.getElementById('restartBtn').addEventListener('click', () => {
            startGame();
        });

        // ──────────────── 初始渲染 ────────────────
        updateMenuHighScore();
    </script>

</body>
</html>

以上的html文件將上傳至wordpress的媒體中。已知它的id。現在寫一個wordpress的php頁面:

獲取這個id對應的文件地址,把地址賦值給一個iframe的src。

php判斷用戶有沒有登入,如果沒有則提示「登入可將參與排名,點擊登入。」如果已登入則提示「歡迎xxx,你的歷史最高分為xxx(用時xxx)」。

之後是排行榜,排行榜的項目為:序號(排名),分數,用時,日期和時間。一個用戶名最多只出現一次,只保留他的最高分。排行榜一次顯示十個,可翻頁。

排行榜的數據將是本文章的meta,用json格式保存。

iframe將顯示在下方。在其右上角增加兩個svg按鈕,功能是「網頁全屏,顯示器全屏」。

當iframe內的遊戲結束,將把回傳的分數和用時傳給php。如果當前用戶已登入,應用wordpress中的ajax,獲取本用戶名在排行榜上的分數,如果超過歷史分數,則提示「創造新紀錄,排名至xxx」,如果沒有則提示「歷史最高分為xxx」。當前的時間日期(yyyy-mm-dd hh:ii:ss)將由php在保存時生成。

如果沒有登入,則將只計算這個分數能在當前排行榜排第幾,但不會真的加進排行榜。

這個php代碼將保存至snippets擴展。

    We need to design a WordPress PHP page that does the following:

        Retrieve a specific media file (HTML) by ID and use its URL as the src of an iframe.

    Check if the user is logged in:
        If not logged in: show "登入可將參與排名,點擊登入。" with a login link.
        If logged in: show "歡迎xxx,你的歷史最高分為xxx(用時xxx)".
    Display a leaderboard (排行榜) with columns: rank (序號), score (分數), time (用時), date/time (日期和時間). Each user appears only once with their highest score. Pagination: 10 per page, with page navigation.
    Leaderboard data is stored as post meta (JSON format) of the current article (the WordPress page that contains this PHP code? Or the article that holds the media? The instruction: "排行榜的數據將是本文章的meta,用json格式保存。" So the leaderboard data will be stored as post meta of the WordPress post/page that displays this? Possibly the page that hosts this PHP snippet. But the snippet is saved to "snippets扩展" (maybe Code Snippets plugin). The PHP code will be a snippet that outputs the page. We need to know the context: The user uploaded the HTML file to WordPress media, and now wants a PHP page that embeds that HTML via iframe and shows leaderboard. The leaderboard data is stored as post meta of "本文章" - likely the WordPress page/post where this snippet is placed? Or perhaps the snippet itself is a page template? The instruction says "這個php代碼將保存至snippets擴展。" So it's a Code Snippet (plugin) snippet. The snippet can be a shortcode or a page. Typically, Code Snippets allows creating snippets that can be used as shortcodes or as standalone pages. The user may create a page and insert the shortcode. So "本文章" could refer to the WordPress page that contains the shortcode. But the snippet could also be a page template. Let's assume we'll create a snippet that outputs the entire page content, and it can be used either as a shortcode or as a standalone page. But the leaderboard data needs to be stored somewhere. The easiest is to store as post meta of the current post (the WordPress page where the shortcode is placed). However, the snippet itself may not know the post ID. If used as a shortcode, we can get the global $post->ID. If used as a standalone page (e.g., a page template), we can set a specific post ID. The instruction says "這個php代碼將保存至snippets擴展。" So it's a snippet; we can create a shortcode [paper_plane_game] that renders the game and leaderboard. The leaderboard data will be stored as post meta of the post containing the shortcode. But that would be weird because each post would have its own leaderboard. Possibly the user wants a single global leaderboard. But they said "排行榜的數據將是本文章的meta". "本文章" could refer to the article that they are creating (the WordPress page that will contain the game). So they will create a WordPress page and put the shortcode there. That page's post meta will store the leaderboard JSON. That makes sense: the game page has its own leaderboard data stored in its own post meta.

    Alternatively, they might mean the media attachment's post meta? But they said "本文章", which typically means the current post.

    Let's design a shortcode [paper_plane_game] that renders the entire UI.

    Additionally, we need to handle AJAX for score submission and leaderboard retrieval.

    Requirements:

    Get media file URL by ID (the HTML file). The ID is known. We'll define it in the snippet configuration or as a shortcode parameter? The user said "已知它的id". So we can either hardcode the ID in the snippet, or pass as shortcode attribute. Let's allow shortcode attribute: [paper_plane_game media_id="123"].
    Check login:
        If not logged in: "登入可將參與排名,點擊登入。" The login link could be wp_login_url().
        If logged in: "歡迎xxx,你的歷史最高分為xxx(用時xxx)". Get current user's display name and their highest score from leaderboard data (post meta).
    Leaderboard: stored as post meta of current post. JSON array of objects: [{userId, score, time, date}, ...]. Each user only once, keep highest score. Show 10 per page, pagination.
    The iframe shows the game. On game over, the iframe will send a postMessage to parent. The parent page (WordPress) will listen for that message and handle score submission.
    In the HTML file, there is already notifyParentGameOver that sends postMessage. So we need to listen for message event in the parent page, filter by source and event type.
    When received, if user logged in, send AJAX to server to update leaderboard; then show result message: either "創造新紀錄,排名至xxx" if new high score, else "歷史最高分為xxx". If not logged in, just compute where they would rank and show that, but don't save.
    The date/time (yyyy-mm-dd hh:ii:ss) is generated by PHP at save time.
    The iframe should have two SVG buttons at its top-right corner for "網頁全屏,顯示器全屏". "網頁全屏" likely means fullscreen of the iframe element (requestFullscreen on the iframe), "顯示器全屏" means fullscreen of the entire page? Actually "網頁全屏" could mean making the iframe fill the browser window (like fullscreen API on the iframe), "顯示器全屏" maybe fullscreen of the whole screen? But both are fullscreen API. Possibly one is for the iframe element, the other for the whole document. We'll implement two buttons: one requests fullscreen on the iframe container, the other on document.documentElement.

    We need to write the PHP code as a snippet for Code Snippets plugin. The snippet will be a shortcode.

    Let's outline the PHP code:

add_shortcode('paper_plane_game', 'paper_plane_game_shortcode');
function paper_plane_game_shortcode(atts) { atts = shortcode_atts(['media_id' => 0], atts);atts);media_id = intval(atts[′mediaid′]);if(!atts[′mediai​d′]);if(!media_id) return '<p>請設定 media_id。</p>';
gameurl=wpgetattachmenturl(gameu​rl=wpg​eta​ttachmentu​rl(media_id);
if (!$game_url) return '<p>找不到遊戲檔案。</p>';

    // Enqueue scripts and styles

wp_enqueue_script('paper-plane-game', plugin_dir_url(__FILE__) . 'paper-plane.js', ['jquery'], '1.0', true);

wp_localize_script('paper-plane-game', 'PaperPlaneAjax', [
    'ajax_url' => admin_url('admin-ajax.php'),
    'nonce' => wp_create_nonce('paper_plane_nonce'),
    'post_id' => get_the_ID(),
    'is_logged_in' => is_user_logged_in(),
    'user_display_name' => is_user_logged_in() ? wp_get_current_user()->display_name : '',
    'user_id' => get_current_user_id(),
    'login_url' => wp_login_url(get_permalink()),
    'leaderboard' => get_leaderboard_data(get_the_ID()),
    'user_high_score' => get_user_high_score(get_current_user_id(), get_the_ID()),
]);

    ob_start();

?>

<div id="paper-plane-game-container">
    <!-- UI elements -->
</div>
<?php
return ob_get_clean();

}


> But since this is a snippet, we don't have a plugin directory. We could either inline the JS/CSS in the shortcode output, or enqueue from a CDN or inline. The Code Snippets plugin allows adding code to functions.php essentially. We can add actions to wp_enqueue_scripts and output inline scripts. However, the snippet is PHP that gets executed. We can include inline JS and CSS within the shortcode output.

> But we also need AJAX handlers. We can add those in the same snippet using `add_action('wp_ajax_...', ...)`.

> Let's design the whole thing.

> We'll need:
- A shortcode that outputs the HTML structure for the game page.
- The HTML includes a container, login message, leaderboard table, pagination, iframe wrapper with fullscreen buttons.
- Inline CSS for styling (maybe minimal).
- Inline JavaScript to handle:
  - Fullscreen buttons.
  - Listening to postMessage from iframe.
  - AJAX submission.
  - Leaderboard pagination (client-side or server-side). We could do server-side pagination via AJAX, or load all data and paginate client-side. Since leaderboard may not be huge, client-side pagination is okay. But we need to load initial leaderboard data. We can pass the full leaderboard array (or top N) via wp_localize_script or inline JSON.
- AJAX endpoint to submit score:
  - Check nonce, user logged in.
  - Get current post meta leaderboard.
  - Compare score with user's existing highest.
  - If better, update/add entry with current timestamp.
  - Save back post meta.
  - Return result: new high score or not, current rank, etc.
- AJAX endpoint to get leaderboard (for pagination maybe).

> But since we are writing a snippet, we must be careful about performance and security.

> Let's think about the leaderboard data structure stored as post meta:

> ```json
[
    {"userId": 1, "score": 1000, "time": "12.3", "date": "2025-03-15 14:30:00"},
    ...
]

    We'll ensure each user only once, keep highest score. When a new score is submitted, if it's higher than existing for that user, update; else do nothing. Also if user not exist, add.

    We need to sort by score descending.

    Pagination: show 10 per page. We can compute total pages client-side if we load all data, or do server-side. Let's load all data and paginate client-side for simplicity, but if leaderboard grows large, server-side better. For a game, likely not huge. We'll load all leaderboard entries (maybe limited to top 100? But requirement says "排行榜一次顯示十個,可翻頁。" So we need all entries for pagination. We'll load all and paginate client-side.

    Now, the HTML file (the game) already has postMessage. The parent page must listen. The JS in the parent will do:

window.addEventListener('message', function(event) {
if (event.data && event.data.source === 'paperPlaneGame' && event.data.event === 'gameOver') {
var data = event.data.data;
// handle score
}
});


> Now, we need to implement the UI for the page.

> Layout:
- Top: login message / welcome message.
- Below: leaderboard table with pagination.
- Below: iframe with game, with fullscreen buttons overlay.

> Let's design HTML structure:

> ```html
<div id="ppg-container">
  <div id="ppg-header">
    <div id="ppg-login-msg"></div>
  </div>
  <div id="ppg-leaderboard">
    <h3>排行榜</h3>
    <table>...</table>
    <div id="ppg-pagination"></div>
  </div>
  <div id="ppg-game-wrapper">
    <div id="ppg-fullscreen-btns">
      <button id="ppg-fs-page" title="網頁全屏">...</button>
      <button id="ppg-fs-monitor" title="顯示器全屏">...</button>
    </div>
    <iframe id="ppg-iframe" src="..." allowfullscreen></iframe>
  </div>
</div>

    Fullscreen: "網頁全屏" could mean fullscreen the whole browser window (the document), and "顯示器全屏" could mean fullscreen the iframe element itself? Actually, in Chinese, "網頁全屏" might mean make the webpage fullscreen (i.e., the browser window), and "顯示器全屏" might mean fullscreen the entire display (which is the same API, but maybe they want to fullscreen the iframe container vs the whole page). The iframe already has allowfullscreen attribute. The HTML game can also request fullscreen. But the requirement: "在其右上角增加兩個svg按鈕,功能是「網頁全屏,顯示器全屏」。" The buttons are on the iframe's top-right corner, presumably overlaying the iframe. "網頁全屏" = fullscreen the iframe element (so the game fills the browser window). "顯示器全屏" = fullscreen the entire monitor (maybe fullscreen the document.documentElement). We'll implement both: one calls iframe.requestFullscreen(), the other calls document.documentElement.requestFullscreen().

    We'll use SVG icons for the buttons.

    Now, AJAX handling: We'll register two actions: wp_ajax_paper_plane_submit_score and wp_ajax_nopriv_paper_plane_submit_score? Actually, if not logged in, we don't save, but we might still need to compute ranking. The client already knows the leaderboard data, so it can compute ranking locally without AJAX. So we only need AJAX for logged-in users to save score. So we only need wp_ajax_paper_plane_submit_score. For non-logged-in, we can compute rank client-side.

    But we need to get leaderboard data for the page initially. We can embed it in the page (JSON) via wp_localize_script or inline script.

    Let's write the PHP snippet.

    We'll need to handle the post meta key. Let's define _paper_plane_leaderboard as meta key.

    Functions:

    get_leaderboard($post_id) - returns array sorted by score desc.
    update_leaderboard($post_id, $user_id, $score, $time) - updates entry, returns new entry and rank.
    get_user_high_score($post_id, $user_id) - returns highest score entry or null.

    Leaderboard entry: {"userId": int, "score": int, "time": string, "date": string, "userName": string}. Store user name as well to avoid fetching later.

    Now, the snippet will be added to Code Snippets. We'll write the code accordingly.

    Let's also consider that the shortcode may be used multiple times on different pages, each with its own leaderboard. That's fine.

    Now, let's produce the full PHP code.

    We'll also need to handle the case where the user is not logged in: show login link. When clicked, redirect to login page then back to this page.

    Now, write the code.

    But the user also wants "當iframe內的遊戲結束,將把回傳的分數和用時傳給php。如果當前用戶已登入,應用wordpress中的ajax,獲取本用戶名在排行榜上的分數,如果超過歷史分數,則提示「創造新紀錄,排名至xxx」,如果沒有則提示「歷史最高分為xxx」。當前的時間日期(yyyy-mm-dd hh:ii:ss)將由php在保存時生成。"

    So the flow:

    Game over -> postMessage to parent.
    Parent JS checks if user logged in.
    If logged in: send AJAX with score and time.
    PHP: get current leaderboard, check existing user high score. If new > old, update, return "new record, rank X". Else return "old high score is X".
    The date/time generated by PHP.
    If not logged in: compute rank client-side from embedded leaderboard data, show "you would rank X", but don't save.

    Now, let's write the code.

    We'll use a class to encapsulate everything to avoid function name conflicts.

    But Code Snippets runs in global scope. We'll use unique function names.

    Let's draft:

// Shortcode
add_shortcode('paper_plane_game', 'ppg_shortcode');

    function ppg_shortcode($atts) {
    // ...
    }
    // AJAX
    add_action('wp_ajax_ppg_submit_score', 'ppg_ajax_submit_score');
    // Scripts
    add_action('wp_enqueue_scripts', 'ppg_enqueue_scripts');


> But we need to ensure scripts are only enqueued when shortcode is present. We can enqueue in the shortcode itself using `wp_enqueue_script` (it will be output in footer if we do it correctly). However, if we call `wp_enqueue_script` inside shortcode, it might not work if the shortcode is processed after head. Better to register and enqueue in `wp_enqueue_scripts` action, and use a flag set in shortcode. Or we can just output inline script in the shortcode.

> Given this is a snippet, we can output everything inline to avoid dependency on external files.

> We'll output CSS inline in `<style>`, and JS inline in `<script>`.

> Now, let's construct the full code.

> We need to be careful about the leaderboard data being embedded as JSON; we'll output a script tag with `var ppg_leaderboard = ...;`.

> Now, let's write the PHP code.

> We'll also need to handle the fullscreen SVG buttons. We can use simple SVG icons.

> Let's design the UI.

> CSS: basic styling to make it look decent.

> Now, let's write the whole snippet.

> But the user also mentioned: "以上的html文件將上傳至wordpress的媒體中。已知它的id。現在寫一個wordpress的php頁面:獲取這個id對應的文件地址,把地址賦值給一個iframe的src。" So they want a PHP page that does that. But they also say "這個php代碼將保存至snippets擴展。" So it's a snippet that creates a page? Or a shortcode that they can insert into a page? The snippet extension allows creating shortcodes, which they can put in any page. That's likely the intention.

> Thus, we'll create a shortcode `[paper_plane_game media_id="XX"]`.

> Now, let's produce the final answer: the PHP code for the snippet.

> We'll structure the answer with explanation and the code.

> But the user is asking: "寫一個wordpress的php頁面" and then details. So we just need to provide the code.

> Let's write it in a single PHP snippet.

> I'll produce the code now.

> But we must ensure the code is secure and uses WordPress functions properly.

> Now, let's think about the leaderboard data: stored as post meta. We'll use `update_post_meta` and `get_post_meta`. We'll sanitize data.

> Now, let's write.

> We'll include functions:

> - `ppg_get_leaderboard($post_id)` - returns array of entries sorted by score desc.
- `ppg_get_user_entry($post_id, $user_id)` - returns entry for user or null.
- `ppg_update_leaderboard($post_id, $user_id, $user_name, $score, $time)` - updates leaderboard, returns new entry and rank.
- `ppg_get_rank($leaderboard, $user_id)` - returns rank (1-based).

> We'll also have a function to render the leaderboard table with pagination.

> For pagination, we'll output the table and then JavaScript handles pagination client-side. We can output all entries as JSON and let JS build the table dynamically. That might be easier. So we'll embed the leaderboard data in a JS variable, and the JS builds the table and pagination. This also makes it easier to update after AJAX submission.

> So the PHP shortcode will output:
- Container divs.
- Inline style.
- Embedded JSON data.
- Inline JavaScript that initializes everything.

> Now, let's code.

> But careful: the shortcode may be used multiple times on same page. We should support that. Use unique IDs based on instance count.

> We'll use a static variable to increment instance ID.

> Now, let's write the final code.

> We also need to handle the case where user is logged in but not in leaderboard (no entry). The welcome message should show "你的歷史最高分為0" or something.

> Now, I'll produce the code in the final answer.

> But the user also wants the PHP page to be saved to snippets. The answer should include the complete snippet code.

> Let's write it.

> I'll also include minor CSS for table and buttons.

> Now, producing final answer.

```php
<?php
/**
 * Shortcode: [paper_plane_game media_id="XXX"]
 *
 * 將 WordPress 媒體庫中的 HTML 遊戲以 iframe 嵌入,
 * 提供排行榜(記錄在文章 post meta)、全屏按鈕、AJAX 成績提交。
 * 僅已登入用戶的成績會寫入排行榜。
 */

// ───────────── 防止直接訪問 ─────────────
if ( ! defined( 'ABSPATH' ) ) {
    exit;
}

// ───────────── 常數與翻譯 ─────────────
define( 'PPG_META_KEY', '_paper_plane_leaderboard' );
define( 'PPG_PER_PAGE', 10 );

// ───────────── 輔助函式 ─────────────

/**
 * 取得排行榜陣列(已依分數由高至低排序)
 */
function ppg_get_leaderboard( $post_id ) {
    $data = get_post_meta( $post_id, PPG_META_KEY, true );
    if ( ! is_array( $data ) ) {
        $data = array();
    }
    // 排序:分數高優先,若同分則時間短優先
    usort( $data, function ( $a, $b ) {
        if ( $b['score'] != $a['score'] ) {
            return $b['score'] - $a['score'];
        }
        return floatval( $a['time'] ) - floatval( $b['time'] );
    } );
    return $data;
}

/**
 * 取得特定使用者在排行榜中的資料,若無則回傳 null
 */
function ppg_get_user_entry( $post_id, $user_id ) {
    $board = ppg_get_leaderboard( $post_id );
    foreach ( $board as $entry ) {
        if ( intval( $entry['userId'] ) === intval( $user_id ) ) {
            return $entry;
        }
    }
    return null;
}

/**
 * 更新排行榜(若新成績優於舊紀錄)
 * 回傳包含排名、是否新紀錄等資訊的陣列
 */
function ppg_update_leaderboard( $post_id, $user_id, $user_name, $score, $time ) {
    $board = ppg_get_leaderboard( $post_id );
    $now   = current_time( 'Y-m-d H:i:s' );

    $existing_index = -1;
    foreach ( $board as $i => $entry ) {
        if ( intval( $entry['userId'] ) === intval( $user_id ) ) {
            $existing_index = $i;
            break;
        }
    }

    $is_new_record = false;

    if ( $existing_index >= 0 ) {
        // 已有紀錄,只保留最高分
        if ( $score > intval( $board[ $existing_index ]['score'] ) ) {
            $board[ $existing_index ]['score']    = $score;
            $board[ $existing_index ]['time']     = $time;
            $board[ $existing_index ]['date']     = $now;
            $board[ $existing_index ]['userName'] = $user_name;
            $is_new_record = true;
        }
    } else {
        // 新參賽者
        $board[] = array(
            'userId'   => intval( $user_id ),
            'userName' => $user_name,
            'score'    => $score,
            'time'     => $time,
            'date'     => $now,
        );
        $is_new_record = true;
    }

    // 重新排序
    usort( $board, function ( $a, $b ) {
        if ( $b['score'] != $a['score'] ) {
            return $b['score'] - $a['score'];
        }
        return floatval( $a['time'] ) - floatval( $b['time'] );
    } );

    update_post_meta( $post_id, PPG_META_KEY, $board );

    // 計算排名 (1-based)
    $rank = 0;
    foreach ( $board as $i => $entry ) {
        if ( intval( $entry['userId'] ) === intval( $user_id ) ) {
            $rank = $i + 1;
            break;
        }
    }

    return array(
        'is_new_record' => $is_new_record,
        'rank'          => $rank,
        'entry'         => ppg_get_user_entry( $post_id, $user_id ),
        'leaderboard'   => $board,
    );
}

// ───────────── AJAX 處理 ─────────────
add_action( 'wp_ajax_ppg_submit_score', 'ppg_ajax_submit_score' );

function ppg_ajax_submit_score() {
    // 權限檢查
    if ( ! is_user_logged_in() ) {
        wp_send_json_error( array( 'message' => '請先登入。' ) );
    }
    if ( ! wp_verify_nonce( $_POST['nonce'] ?? '', 'ppg_nonce' ) ) {
        wp_send_json_error( array( 'message' => '安全檢查失敗。' ) );
    }

    $post_id  = intval( $_POST['post_id'] ?? 0 );
    $score    = intval( $_POST['score'] ?? 0 );
    $time     = sanitize_text_field( $_POST['time'] ?? '0' );

    if ( ! $post_id || $score < 0 ) {
        wp_send_json_error( array( 'message' => '參數錯誤。' ) );
    }

    $user      = wp_get_current_user();
    $user_id   = $user->ID;
    $user_name = $user->display_name;

    $old_entry = ppg_get_user_entry( $post_id, $user_id );
    $result    = ppg_update_leaderboard( $post_id, $user_id, $user_name, $score, $time );

    if ( $result['is_new_record'] ) {
        $message = '🎉 創造新紀錄,排名至 ' . $result['rank'];
    } else {
        $message = '歷史最高分為 ' . $old_entry['score'] . '(用時 ' . $old_entry['time'] . 's)';
    }

    wp_send_json_success( array(
        'message'     => $message,
        'is_new'      => $result['is_new_record'],
        'rank'        => $result['rank'],
        'leaderboard' => $result['leaderboard'],
        'entry'       => $result['entry'],
    ) );
}

// ───────────── Shortcode ─────────────
add_shortcode( 'paper_plane_game', 'ppg_shortcode' );

function ppg_shortcode( $atts ) {
    $atts = shortcode_atts( array(
        'media_id' => 0,
    ), $atts, 'paper_plane_game' );

    $media_id = intval( $atts['media_id'] );
    if ( ! $media_id ) {
        return '<p style="color:red;">[paper_plane_game] 請設定 media_id 參數。</p>';
    }

    $game_url = wp_get_attachment_url( $media_id );
    if ( ! $game_url ) {
        return '<p style="color:red;">找不到媒體 ID:' . esc_html( $media_id ) . '</p>';
    }

    $post_id      = get_the_ID();
    $leaderboard  = ppg_get_leaderboard( $post_id );
    $is_logged_in = is_user_logged_in();
    $current_user = $is_logged_in ? wp_get_current_user() : null;

    // 目前已登入使用者的最高分
    $user_entry = $is_logged_in ? ppg_get_user_entry( $post_id, $current_user->ID ) : null;

    // 靜態變數避免同一頁多個 shortcode 時 ID 衝突
    static $instance = 0;
    $instance ++;
    $uid = 'ppg-' . $instance;

    // ─── 輸出 HTML ───
    ob_start();
    ?>
    <div id="<?php echo esc_attr( $uid ); ?>" class="ppg-container">
        <style>
            .ppg-container {
                max-width: 900px;
                margin: 0 auto;
                font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
                color: #333;
            }
            .ppg-login-msg {
                background: #f5f5f5;
                padding: 12px 20px;
                border-radius: 8px;
                margin-bottom: 20px;
                font-size: 16px;
                line-height: 1.6;
            }
            .ppg-login-msg a {
                color: #1976d2;
                font-weight: 600;
                text-decoration: none;
            }
            .ppg-login-msg a:hover {
                text-decoration: underline;
            }
            .ppg-board-section {
                margin-bottom: 24px;
            }
            .ppg-board-section h3 {
                margin: 0 0 12px 0;
                font-size: 20px;
            }
            .ppg-table-wrap {
                width: 100%;
                overflow-x: auto;
            }
            .ppg-table {
                width: 100%;
                border-collapse: collapse;
                font-size: 15px;
                min-width: 500px;
            }
            .ppg-table th,
            .ppg-table td {
                padding: 10px 12px;
                border-bottom: 1px solid #e0e0e0;
                text-align: center;
            }
            .ppg-table th {
                background: #fafafa;
                font-weight: 700;
                color: #555;
            }
            .ppg-table tr:hover td {
                background: #f9f9ff;
            }
            .ppg-table .highlight {
                background: #fff9c4 !important;
                font-weight: 600;
            }
            .ppg-pagination {
                margin-top: 12px;
                display: flex;
                justify-content: center;
                gap: 8px;
                flex-wrap: wrap;
            }
            .ppg-pagination button {
                background: #fff;
                border: 1px solid #ccc;
                padding: 6px 14px;
                border-radius: 4px;
                cursor: pointer;
                font-size: 14px;
                transition: 0.2s;
            }
            .ppg-pagination button.active {
                background: #1976d2;
                color: #fff;
                border-color: #1976d2;
            }
            .ppg-pagination button:hover:not(.active) {
                background: #f0f0f0;
            }
            .ppg-game-wrapper {
                position: relative;
                border: 2px solid #ccc;
                border-radius: 12px;
                overflow: hidden;
                background: #000;
                aspect-ratio: 3 / 4;
                max-height: 80vh;
            }
            .ppg-game-wrapper iframe {
                width: 100%;
                height: 100%;
                border: none;
                display: block;
            }
            .ppg-fs-btns {
                position: absolute;
                top: 10px;
                right: 10px;
                display: flex;
                gap: 8px;
                z-index: 10;
            }
            .ppg-fs-btns button {
                background: rgba(255,255,255,0.85);
                border: none;
                border-radius: 6px;
                width: 40px;
                height: 40px;
                cursor: pointer;
                display: flex;
                align-items: center;
                justify-content: center;
                transition: background 0.2s;
                padding: 0;
            }
            .ppg-fs-btns button:hover {
                background: #fff;
            }
            .ppg-fs-btns svg {
                width: 22px;
                height: 22px;
                fill: #333;
            }
            .ppg-toast {
                position: fixed;
                bottom: 30px;
                left: 50%;
                transform: translateX(-50%);
                background: #323232;
                color: #fff;
                padding: 12px 28px;
                border-radius: 30px;
                font-size: 15px;
                z-index: 9999;
                opacity: 0;
                transition: opacity 0.3s;
                pointer-events: none;
                white-space: nowrap;
            }
            .ppg-toast.show {
                opacity: 1;
            }
        </style>

        <!-- 登入訊息 -->
        <div class="ppg-login-msg" id="<?php echo esc_attr( $uid ); ?>-loginMsg">
            <?php if ( $is_logged_in ) : ?>
                <?php
                    $uname = esc_html( $current_user->display_name );
                    if ( $user_entry ) {
                        echo "歡迎 {$uname},你的歷史最高分為 {$user_entry['score']}(用時 {$user_entry['time']}s)";
                    } else {
                        echo "歡迎 {$uname},你尚未有任何遊戲紀錄。";
                    }
                ?>
            <?php else : ?>
                登入可將參與排名,<a href="<?php echo esc_url( wp_login_url( get_permalink() ) ); ?>">點擊登入</a>。
            <?php endif; ?>
        </div>

        <!-- 排行榜 -->
        <div class="ppg-board-section">
            <h3>🏆 排行榜</h3>
            <div class="ppg-table-wrap">
                <table class="ppg-table" id="<?php echo esc_attr( $uid ); ?>-table">
                    <thead>
                        <tr>
                            <th>排名</th>
                            <th>玩家</th>
                            <th>分數</th>
                            <th>用時 (s)</th>
                            <th>日期</th>
                        </tr>
                    </thead>
                    <tbody></tbody>
                </table>
            </div>
            <div class="ppg-pagination" id="<?php echo esc_attr( $uid ); ?>-pagination"></div>
        </div>

        <!-- 遊戲區域 -->
        <div class="ppg-game-wrapper" id="<?php echo esc_attr( $uid ); ?>-gameWrapper">
            <div class="ppg-fs-btns">
                <!-- 網頁全屏(iframe 本身全屏) -->
                <button id="<?php echo esc_attr( $uid ); ?>-fsPage" title="網頁全屏">
                    <svg viewBox="0 0 24 24"><path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z"/></svg>
                </button>
                <!-- 顯示器全屏(整個頁面全屏) -->
                <button id="<?php echo esc_attr( $uid ); ?>-fsMonitor" title="顯示器全屏">
                    <svg viewBox="0 0 24 24"><path d="M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z"/></svg>
                </button>
            </div>
            <iframe id="<?php echo esc_attr( $uid ); ?>-iframe"
                    src="<?php echo esc_url( $game_url ); ?>"
                    allowfullscreen
                    allow="autoplay; fullscreen">
            </iframe>
        </div>
    </div>

    <!-- Toast -->
    <div class="ppg-toast" id="<?php echo esc_attr( $uid ); ?>-toast"></div>

    <script>
    (function() {
        var container = document.getElementById('<?php echo esc_js( $uid ); ?>');
        if (!container) return;

        // ── 資料 ──
        var postId        = <?php echo json_encode( $post_id ); ?>;
        var isLoggedIn    = <?php echo json_encode( $is_logged_in ); ?>;
        var currentUserId = <?php echo json_encode( $is_logged_in ? $current_user->ID : 0 ); ?>;
        var ajaxUrl       = <?php echo json_encode( admin_url( 'admin-ajax.php' ) ); ?>;
        var nonce         = <?php echo json_encode( wp_create_nonce( 'ppg_nonce' ) ); ?>;
        var leaderboard   = <?php echo json_encode( $leaderboard ); ?>;

        var perPage       = <?php echo PPG_PER_PAGE; ?>;
        var currentPage   = 1;

        var tableBody     = container.querySelector('#' + '<?php echo esc_js( $uid ); ?>' + '-table tbody');
        var paginationDiv = container.querySelector('#' + '<?php echo esc_js( $uid ); ?>' + '-pagination');
        var loginMsgDiv   = container.querySelector('#' + '<?php echo esc_js( $uid ); ?>' + '-loginMsg');
        var gameWrapper   = container.querySelector('#' + '<?php echo esc_js( $uid ); ?>' + '-gameWrapper');
        var toastEl       = container.querySelector('#' + '<?php echo esc_js( $uid ); ?>' + '-toast');
        var iframeEl      = container.querySelector('#' + '<?php echo esc_js( $uid ); ?>' + '-iframe');

        // ── Toast ──
        function showToast(msg, duration) {
            duration = duration || 3000;
            toastEl.textContent = msg;
            toastEl.classList.add('show');
            clearTimeout(toastEl._timeout);
            toastEl._timeout = setTimeout(function() {
                toastEl.classList.remove('show');
            }, duration);
        }

        // ── 排行榜渲染 ──
        function renderTable(page) {
            var start = (page - 1) * perPage;
            var end   = start + perPage;
            var pageData = leaderboard.slice(start, end);

            var html = '';
            for (var i = 0; i < pageData.length; i++) {
                var entry = pageData[i];
                var rank  = start + i + 1;
                var highlight = (currentUserId && entry.userId == currentUserId) ? ' class="highlight"' : '';
                html += '<tr' + highlight + '>';
                html += '<td>' + rank + '</td>';
                html += '<td>' + escHtml(entry.userName) + '</td>';
                html += '<td>' + entry.score + '</td>';
                html += '<td>' + entry.time + '</td>';
                html += '<td>' + escHtml(entry.date) + '</td>';
                html += '</tr>';
            }
            if (leaderboard.length === 0) {
                html = '<tr><td colspan="5">尚無紀錄</td></tr>';
            }
            tableBody.innerHTML = html;

            // 分頁按鈕
            var totalPages = Math.ceil(leaderboard.length / perPage) || 1;
            var btnHtml = '';
            for (var p = 1; p <= totalPages; p++) {
                btnHtml += '<button class="' + (p === page ? 'active' : '') + '" data-page="' + p + '">' + p + '</button>';
            }
            paginationDiv.innerHTML = btnHtml;

            // 綁定分頁事件
            var buttons = paginationDiv.querySelectorAll('button');
            for (var b = 0; b < buttons.length; b++) {
                buttons[b].addEventListener('click', function() {
                    currentPage = parseInt(this.getAttribute('data-page'));
                    renderTable(currentPage);
                });
            }
        }

        function escHtml(str) {
            var div = document.createElement('div');
            div.appendChild(document.createTextNode(str));
            return div.innerHTML;
        }

        // 初始渲染
        renderTable(currentPage);

        // ── 全屏按鈕 ──
        var btnFsPage    = container.querySelector('#' + '<?php echo esc_js( $uid ); ?>' + '-fsPage');
        var btnFsMonitor = container.querySelector('#' + '<?php echo esc_js( $uid ); ?>' + '-fsMonitor');

        btnFsPage.addEventListener('click', function() {
            if (iframeEl.requestFullscreen) {
                iframeEl.requestFullscreen().catch(function(e) {});
            } else if (iframeEl.webkitRequestFullscreen) {
                iframeEl.webkitRequestFullscreen();
            } else if (iframeEl.msRequestFullscreen) {
                iframeEl.msRequestFullscreen();
            }
        });

        btnFsMonitor.addEventListener('click', function() {
            if (document.documentElement.requestFullscreen) {
                document.documentElement.requestFullscreen().catch(function(e) {});
            } else if (document.documentElement.webkitRequestFullscreen) {
                document.documentElement.webkitRequestFullscreen();
            } else if (document.documentElement.msRequestFullscreen) {
                document.documentElement.msRequestFullscreen();
            }
        });

        // ── 接收 iframe 遊戲結束訊息 ──
        window.addEventListener('message', function(event) {
            if (!event.data || event.data.source !== 'paperPlaneGame' || event.data.event !== 'gameOver') {
                return;
            }
            var data = event.data.data; // { score, time, isNewHighScore, sessionHighScore, sessionHighScoreTime }
            var score = parseInt(data.score) || 0;
            var time  = data.time || '0';

            if (isLoggedIn) {
                // 已登入:透過 AJAX 提交成績
                var formData = new FormData();
                formData.append('action', 'ppg_submit_score');
                formData.append('nonce', nonce);
                formData.append('post_id', postId);
                formData.append('score', score);
                formData.append('time', time);

                fetch(ajaxUrl, {
                    method: 'POST',
                    credentials: 'same-origin',
                    body: formData
                })
                .then(function(resp) { return resp.json(); })
                .then(function(res) {
                    if (res.success) {
                        showToast(res.data.message, 4000);
                        // 更新排行榜
                        if (res.data.leaderboard) {
                            leaderboard = res.data.leaderboard;
                            renderTable(currentPage);
                        }
                        // 更新登入訊息
                        if (res.data.entry) {
                            var e = res.data.entry;
                            loginMsgDiv.textContent = '歡迎 ' + escHtml(e.userName) + ',你的歷史最高分為 ' + e.score + '(用時 ' + e.time + 's)';
                        }
                    } else {
                        showToast(res.data.message || '發生錯誤', 3000);
                    }
                })
                .catch(function() {
                    showToast('網絡錯誤,請稍後再試', 3000);
                });
            } else {
                // 未登入:僅計算排名
                var rank = 1;
                for (var i = 0; i < leaderboard.length; i++) {
                    if (score > leaderboard[i].score ||
                        (score === leaderboard[i].score && parseFloat(time) < parseFloat(leaderboard[i].time))) {
                        // 繼續
                    } else {
                        rank = i + 2;
                    }
                }
                showToast('你目前排名第 ' + rank + ' 名(登入後可保存成績)', 4000);
            }
        });

        // ── 響應式高度調整(可選) ──
        function adjustWrapperHeight() {
            var maxH = window.innerHeight * 0.8;
            gameWrapper.style.maxHeight = maxH + 'px';
        }
        window.addEventListener('resize', adjustWrapperHeight);
        adjustWrapperHeight();
    })();
    </script>
    <?php
    return ob_get_clean();
}

使用方式

    在 WordPress 後台「程式碼片段」(Code Snippets) 中新增一個 PHP 片段,貼上上述全部程式碼,啟用。
    編輯要放置遊戲的頁面,插入短代碼:

    [paper_plane_game media_id="123"]

    (將 123 換成你上傳的 HTML 檔案的媒體 ID)
    發佈頁面即可看到完整遊戲與排行榜。

功能說明
功能	說明
登入狀態	未登入顯示「登入可將參與排名,點擊登入。」;已登入顯示「歡迎xxx,你的歷史最高分為xxx(用時xxx)」。
排行榜	儲存在該頁面的 post meta (_paper_plane_leaderboard),JSON 格式。每位使用者只保留最高分。每頁 10 筆,可翻頁。
全屏按鈕	兩個 SVG 按鈕:左邊「網頁全屏」讓 iframe 全屏;右邊「顯示器全屏」讓整個網頁全屏。
成績提交	遊戲結束後透過 postMessage 回傳成績,已登入則經 AJAX 寫入排行榜;未登入僅計算並提示理論排名。
日期時間	由伺服器在儲存時自動產生 (Y-m-d H:i:s)。
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀
一枝独秀
帖子: 5847
注册时间: 2022年 11月 18日 17:36
联系:

Re: 用國內(能直連的)AI寫紙飛機遊戲

帖子 ejsoon »

紙飛機2.1deepseek.html.7z
(5.1 KiB) 已下载 5 次
如果有gemini用,我就不必去用deepseek,但是,gemini pro是有使用限量的,deepseek ro好像能無限用。
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀
一枝独秀
帖子: 5847
注册时间: 2022年 11月 18日 17:36
联系:

Re: 用國內(能直連的)AI寫紙飛機遊戲

帖子 ejsoon »

對於以上代碼,如果我希望在遊戲開始或遊戲結束時,按下enter或空白鍵,將等同於按「開始遊戲」或「重新開始」,給出所要增加的js代碼。

代码: 全选

<!DOCTYPE html>
<html lang="zh-TW">
    <script>
        /**
         * 遊戲參數配置
         */
        const CONFIG = {
            blockDensity: 180,
            blockMinW: 120,
            blockMaxW: 240,
            blockHeight: 24,
            baseFallSpeed: 9,
            turnSpeed: 0.08,
            maxAngle: Math.PI / 2.2
        };

        // ──────────────── DOM 元素 ────────────────
        const wrapper = document.getElementById('game-wrapper');
        const canvas = document.getElementById('gameCanvas');
        const ctx = canvas.getContext('2d');
        const planeSvg = document.getElementById('plane-svg');
        const planeLeft = document.getElementById('planeLeft');
        const planeRight = document.getElementById('planeRight');

        const uiMenu = document.getElementById('menuUI');
        const uiGameOver = document.getElementById('gameOverUI');
        const scoreDisp = document.getElementById('scoreDisplay');
        const timeDisp = document.getElementById('timeDisplay');
        const menuHighScoreEl = document.getElementById('menuHighScore');

        let canvasW, canvasH;
        let animationFrameId;

        // ──────────────── 遊戲狀態 ────────────────
        let state = {
            isPlaying: false,
            score: 0,
            time: 0,
            startTime: 0,
            cameraY: 0,
            lastSpawnY: 0
        };

        let plane = {
            x: 0,
            worldY: 0,
            screenY: 0,
            angle: 0,
            isDead: false
        };

        let blocks = [];
        let keys = { left: false, right: false };

        // ──────────────── 本輪會話最高分記錄(頁面刷新即重置) ────────────────
        var sessionHighScore = -1; // -1 表示尚未有任何遊戲記錄
        var sessionHighScoreTime = 0;
        var lastGameScore = 0;
        var lastGameTime = 0;
        var isGameOverState = false;
        var lastGameIsNewHighScore = false;

        // ──────────────── 外部 API(供 iframe 父頁面調用) ────────────────
        window.PaperPlaneGame = {
            /**
             * 取得完整遊戲狀態
             * @returns {Object} 包含 isPlaying, isGameOver, lastScore, lastTime,
             *                   sessionHighScore, sessionHighScoreTime, isNewHighScore
             */
            getState: function() {
                return {
                    isPlaying: state.isPlaying,
                    isGameOver: isGameOverState,
                    lastScore: lastGameScore,
                    lastTime: lastGameTime,
                    sessionHighScore: sessionHighScore,
                    sessionHighScoreTime: sessionHighScoreTime,
                    isNewHighScore: lastGameIsNewHighScore
                };
            },

            /** 遊戲是否正在進行中 */
            isPlaying: function() { return state.isPlaying; },

            /** 遊戲是否已結束(遊戲結束畫面顯示中) */
            isGameOver: function() { return isGameOverState; },

            /** 取得上一次遊戲的分數 */
            getLastScore: function() { return lastGameScore; },

            /** 取得上一次遊戲的用時(秒) */
            getLastTime: function() { return lastGameTime; },

            /** 取得本輪會話最高分 */
            getSessionHighScore: function() { return sessionHighScore; },

            /** 取得本輪會話最高分的用時 */
            getSessionHighScoreTime: function() { return sessionHighScoreTime; },

            /** 上一次遊戲是否刷新了最高分 */
            isNewHighScore: function() { return lastGameIsNewHighScore; },

            /**
             * 遊戲結束時的回調(同一來源的父頁面可設置此屬性)
             * @type {Function|null} callback(score, time, isNewHighScore)
             * @example
             * window.PaperPlaneGame.onGameOver = function(score, time, isNew) {
             *     console.log('Game over! Score:', score);
             * };
             */
            onGameOver: null
        };

        /**
         * 向父窗口發送 postMessage(適用於跨來源 iframe 嵌入)
         */
        function notifyParentGameOver(score, time, isNewHighScore) {
            if (window.parent && window.parent !== window) {
                try {
                    window.parent.postMessage({
                        source: 'paperPlaneGame',
                        event: 'gameOver',
                        data: {
                            score: score,
                            time: time,
                            isNewHighScore: isNewHighScore,
                            sessionHighScore: sessionHighScore,
                            sessionHighScoreTime: sessionHighScoreTime
                        }
                    }, '*');
                } catch (e) {
                    // 忽略跨來源通訊錯誤
                }
            }
        }

        // ──────────────── 輔助函式 ────────────────
        function resizeCanvas() {
            canvasW = wrapper.clientWidth;
            canvasH = wrapper.clientHeight;
            canvas.width = canvasW;
            canvas.height = canvasH;
        }
        window.addEventListener('resize', resizeCanvas);
        resizeCanvas();

        /** 更新選單上的本輪最高分顯示 */
        function updateMenuHighScore() {
            if (sessionHighScore >= 0) {
                menuHighScoreEl.textContent =
                    '本次最高分:' + sessionHighScore + ' 分 (用時 ' + sessionHighScoreTime + 's)';
            } else {
                menuHighScoreEl.textContent = '';
            }
        }

        // ──────────────── 鍵盤控制 ────────────────
        window.addEventListener('keydown', (e) => {
            if (e.code === 'ArrowLeft') keys.left = true;
            if (e.code === 'ArrowRight') keys.right = true;
        });
        window.addEventListener('keyup', (e) => {
            if (e.code === 'ArrowLeft') keys.left = false;
            if (e.code === 'ArrowRight') keys.right = false;
        });

        // ──────────────── 觸控控制(手機端左右半屏) ────────────────
        wrapper.addEventListener('touchstart', (e) => {
            if (!state.isPlaying) return;
            for (let i = 0; i < e.changedTouches.length; i++) {
                let touch = e.changedTouches[i];
                if (touch.clientX < window.innerWidth / 2) keys.left = true;
                else keys.right = true;
            }
        });
        wrapper.addEventListener('touchend', (e) => {
            for (let i = 0; i < e.changedTouches.length; i++) {
                let touch = e.changedTouches[i];
                if (touch.clientX < window.innerWidth / 2) keys.left = false;
                else keys.right = false;
            }
        });
        wrapper.addEventListener('touchcancel', () => {
            keys.left = false;
            keys.right = false;
        });

        // ──────────────── 遊戲流程 ────────────────
        function startGame() {
            uiMenu.classList.add('hidden');
            uiGameOver.classList.add('hidden');
            isGameOverState = false;

            // 初始化飛機
            plane.x = 40;
            plane.worldY = 40;
            plane.screenY = 40;
            plane.angle = CONFIG.maxAngle;
            plane.isDead = false;

            // 恢復飛機顏色
            planeLeft.setAttribute('fill', '#FFFFFF');
            planeRight.setAttribute('fill', '#E0E0E0');

            state.isPlaying = true;
            state.score = 0;
            state.time = 0;
            state.startTime = performance.now();
            state.cameraY = 0;
            state.lastSpawnY = canvasH;

            blocks = [];
            keys.left = false;
            keys.right = false;

            updateMenuHighScore();
            requestAnimationFrame(gameLoop);
        }

        function gameOver() {
            state.isPlaying = false;
            plane.isDead = true;
            isGameOverState = true;

            // 飛機變紅色
            planeLeft.setAttribute('fill', '#FF5252');
            planeRight.setAttribute('fill', '#D32F2F');

            // 記錄本次成績
            lastGameScore = state.score;
            lastGameTime = state.time;

            // 判斷是否刷新本輪會話最高分
            let isNewHighScore = (lastGameScore > sessionHighScore);
            lastGameIsNewHighScore = isNewHighScore;

            if (isNewHighScore) {
                sessionHighScore = lastGameScore;
                sessionHighScoreTime = lastGameTime;
            }

            // 更新遊戲結束 UI
            document.getElementById('finalScore').innerText = lastGameScore;
            document.getElementById('finalTime').innerText = lastGameTime + 's';

            const highScoreMsgDiv = document.getElementById('highScoreMsg');
            if (isNewHighScore) {
                highScoreMsgDiv.className = 'highscore-msg new-record';
                highScoreMsgDiv.innerHTML = '🎉 創造歷史最高分:<strong>' + sessionHighScore +
                    '</strong> 分!<br><small>用時 ' + sessionHighScoreTime + 's</small>';
            } else {
                highScoreMsgDiv.className = 'highscore-msg';
                highScoreMsgDiv.innerHTML = '歷史最高分為 <strong>' + sessionHighScore +
                    '</strong> 分<br><small>(用時 ' + sessionHighScoreTime + 's)</small>';
            }

            // 呼叫外部回調(同來源 iframe)
            if (typeof window.PaperPlaneGame.onGameOver === 'function') {
                try {
                    window.PaperPlaneGame.onGameOver(lastGameScore, lastGameTime, isNewHighScore);
                } catch (e) {
                    // 忽略回調中的錯誤
                }
            }

            // 向父窗口發送 postMessage(跨來源 iframe)
            notifyParentGameOver(lastGameScore, lastGameTime, isNewHighScore);

            // 更新選單最高分
            updateMenuHighScore();

            // 延遲顯示結束畫面
            setTimeout(() => {
                uiGameOver.classList.remove('hidden');
            }, 800);
        }

        function gameLoop(timestamp) {
            if (!state.isPlaying) return;

            // 更新時間與分數
            state.time = ((timestamp - state.startTime) / 1000).toFixed(1);
            state.score = Math.floor(plane.worldY / 100);
            timeDisp.innerText = '時間: ' + state.time + 's';
            scoreDisp.innerText = '分數: ' + state.score;

            // 處理轉向
            if (keys.left) plane.angle -= CONFIG.turnSpeed;
            if (keys.right) plane.angle += CONFIG.turnSpeed;
            plane.angle = Math.max(-CONFIG.maxAngle, Math.min(CONFIG.maxAngle, plane.angle));

            // 計算下落速度
            let angleRatio = Math.abs(plane.angle) / CONFIG.maxAngle;
            let currentSpeed = CONFIG.baseFallSpeed * (1 - (angleRatio * 0.6));

            // 更新飛機世界座標
            plane.worldY += currentSpeed * Math.cos(plane.angle);
            plane.x += currentSpeed * Math.sin(plane.angle);

            // 邊界限制
            if (plane.x < 20) { plane.x = 20;
                plane.angle = 0; }
            if (plane.x > canvasW - 20) { plane.x = canvasW - 20;
                plane.angle = 0; }

            // 攝像機跟隨
            if (plane.worldY > canvasH / 2) {
                state.cameraY = plane.worldY - canvasH / 2;
                plane.screenY = canvasH / 2;
            } else {
                plane.screenY = plane.worldY;
            }

            // 生成障礙物
            while (state.lastSpawnY < plane.worldY + canvasH) {
                state.lastSpawnY += CONFIG.blockDensity;
                let w = Math.random() * (CONFIG.blockMaxW - CONFIG.blockMinW) + CONFIG.blockMinW;
                let x = Math.random() * (canvasW - w);
                blocks.push({
                    x: x,
                    worldY: state.lastSpawnY,
                    w: w,
                    h: CONFIG.blockHeight,
                    isHit: false
                });
            }

            // 清理舊方塊
            blocks = blocks.filter(b => b.worldY > state.cameraY - 50);

            // 更新 SVG 飛機偽三維渲染
            let rotateDeg = -plane.angle * (180 / Math.PI);
            let rollDeg = plane.angle * 45;
            planeSvg.style.transform =
                'translate(calc(' + plane.x + 'px - 50%), calc(' + plane.screenY + 'px - 50%)) rotate(' +
                rotateDeg + 'deg) rotateY(' + rollDeg + 'deg)';

            // 繪製畫面與碰撞檢測
            ctx.clearRect(0, 0, canvasW, canvasH);

            let planeHitbox = { x: plane.x - 10, y: plane.screenY - 15, w: 20, h: 30 };

            blocks.forEach(b => {
                let screenY = b.worldY - state.cameraY;

                // AABB 碰撞檢測
                if (
                    planeHitbox.x < b.x + b.w &&
                    planeHitbox.x + planeHitbox.w > b.x &&
                    planeHitbox.y < screenY + b.h &&
                    planeHitbox.y + planeHitbox.h > screenY
                ) {
                    b.isHit = true;
                    gameOver();
                }

                // 繪製方塊
                ctx.fillStyle = b.isHit ? '#FF1744' : '#FF80AB';
                ctx.strokeStyle = '#333';
                ctx.lineWidth = 3;
                ctx.beginPath();
                if (ctx.roundRect) {
                    ctx.roundRect(b.x, screenY, b.w, b.h, 6);
                } else {
                    // fallback for older browsers
                    ctx.rect(b.x, screenY, b.w, b.h);
                }
                ctx.fill();
                ctx.stroke();
            });

            if (state.isPlaying) {
                animationFrameId = requestAnimationFrame(gameLoop);
            }
        }

        // ──────────────── 按鈕事件 ────────────────
        document.getElementById('startBtn').addEventListener('click', startGame);

        document.getElementById('restartBtn').addEventListener('click', () => {
            startGame();
        });

        // ──────────────── 初始渲染 ────────────────
        updateMenuHighScore();
    </script>

</body>
</html>

對於以上代碼,如果我希望在遊戲開始或遊戲結束時,按下enter或空白鍵,將等同於按「開始遊戲」或「重新開始」,給出所要增加的js代碼。
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀
一枝独秀
帖子: 5847
注册时间: 2022年 11月 18日 17:36
联系:

Re: 用國內(能直連的)AI寫紙飛機遊戲

帖子 ejsoon »

gemini 3.1 flash:

代码: 全选

        // ──────────────── 鍵盤控制 ────────────────
        window.addEventListener('keydown', (e) => {
            if (e.code === 'ArrowLeft') keys.left = true;
            if (e.code === 'ArrowRight') keys.right = true;

            // 新增:按下 Enter 或 空白鍵
            if (e.code === 'Enter' || e.code === 'Space') {
                const isMenuVisible = !uiMenu.classList.contains('hidden');
                const isGameOverVisible = !uiGameOver.classList.contains('hidden');

                if (isMenuVisible || isGameOverVisible) {
                    e.preventDefault(); // 阻止空白鍵預設的網頁捲動行為
                    startGame();
                }
            }
        });
        window.addEventListener('keyup', (e) => {
            if (e.code === 'ArrowLeft') keys.left = false;
            if (e.code === 'ArrowRight') keys.right = false;
        });
https://ejsoon.vip/
弈趣極光:享受思維樂趣
回复
  • 相似主题
    回复总数
    阅读次数
    最新帖子

在线用户

正浏览此版面之用户: 没有注册用户 和 1 访客