代码: 全选
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[′mediaid′]);if(!media_id) return '<p>請設定 media_id。</p>';
gameurl=wpgetattachmenturl(gameurl=wpgetattachmenturl(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)。