一、先從Canva AI上手吧!!
Canva網址:https://www.canva.com/
利用Canva AI的好處是:
有內建的Canvas試算表的資料庫可用,完全不用設定,資料庫內容可管理!!
生成的網頁可直接發佈成永久網址
「打地鼠:九九乘法大挑戰」網頁遊戲 Prompt:
[角色與目標] 你是一位精通 HTML5 遊戲開發與 UI/UX 設計的前端工程師。請幫我寫一個單一 HTML 檔案的「打地鼠:九九乘法大挑戰」網頁遊戲。
[技術與 UI 規範] 請務必使用 Tailwind CSS 進行排版。背景請使用草地綠色系。遊戲區域使用 Grid 排版,建立 6 個帶有深咖啡色洞穴外觀的圓形區塊。按鈕必須夠大,帶有圓角 (rounded-full) 與 Hover 放大效果 (scale-105)。
[功能與邏輯細節]
1. 遊戲時間預設 30 秒,畫面上方需有醒目的倒數計時器與目前分數顯示。
2. 點擊「開始遊戲」後,每隔 1~2 秒,隨機從一個洞穴中浮現「地鼠區塊」。地鼠區塊上必須顯示一個隨機的九九乘法題目(例如:7 x 8 = ?)。
3. 畫面下方固定顯示三個大型答案按鈕,其中一個是正確答案,另外兩個是隨機生成的錯誤干擾選項。
4. UX 防呆與回饋:玩家必須在該地鼠消失前點擊正確答案按鈕。答對加 10 分,地鼠立刻消失,並閃爍綠色邊框;答錯扣 5 分,閃爍紅色邊框。時間到時跳出「遊戲結束」與最終分數的精美彈出視窗 (Modal)。
二、Gemini Canvas
Gemini網址:https://www.canva.com/
Canvas生成的程式有"單一 HTML 檔案"及"React框架"兩種
單一 HTML 檔案為靜態網頁可直接上傳到GitHub(GitHub只接受靜態網頁,如果有資料庫則無法運作)
GitHub Pages 原生支援靜態 HTML,但對於 React 這類需要「編譯(Build)」的框架,它並不能直接讀取你的原始碼。
靜態網頁(Static Website)是由 HTML、CSS 和 JavaScript 組成的固定檔案,不需要後端資料庫或伺服器端處理即可顯示
但這React框架的網頁上傳到GitHub是無法運作 (非傳統HTML格式)
Canvas的好處??
有內建資料庫可用,但內建資料庫,我們無法直接管理!而且要登入Google帳號下,資料庫才有作用!!
可分享連結,但這連結在原始程式刪除後就斷線了!!而且別人可利用【試用Gemini Canvas】,把您的成果給拿走!!
請將檔案改成react框架,並另存一個新檔
這是一個使用 React 框架重新編寫的「打地鼠:九九乘法大挑戰」。
我將所有的遊戲邏輯、Firebase 雲端排行榜以及 Tailwind CSS 視覺效果整合進了這個單一的 React 檔案中。與 HTML 版本相比,React 版本提供了更強的狀態管理能力,讓地鼠的冒出動畫與分數更新更加流暢。
這個版本包含:
React 狀態驅動:精確控制倒數計時、分數與地鼠的出現。
Firebase Firestore 整合:完整實現匿名登入與全球排行榜同步功能。
響應式 UI:完美適配手機與電腦螢幕。
視覺強化:加入更豐富的 Tailwind 動畫與反饋效果。
簡單來說:React 程式絕對可以發佈到 GitHub Pages,但 Vercel 通常是更聰明、更省力的選擇。
以下為你分析兩個平台的差異與發佈建議:
GitHub Pages 原生支援靜態 HTML,但對於 React 這類需要「編譯(Build)」的框架,它並不能直接讀取你的原始碼。
發佈方式:你需要使用 gh-pages 套件。邏輯是先在本地執行 npm run build 產生一個 dist 或 build 資料夾,然後將該資料夾的內容推送到 GitHub 的一個特定分支(通常是 gh-pages)。
優點:完全免費、與原始碼儲存庫整合在一起。
缺點:
設定繁瑣:每次都要手動或透過 GitHub Actions 設定編譯腳本。
路由問題:React Router 的 BrowserRouter 在 GitHub Pages 上常會遇到重新整理後 404 的問題(需要改用 HashRouter 或特殊處理)。
Vercel 是 Next.js 團隊開發的平台,對 React、Vite 或各類現代框架的支援是「保姆級」的。
發佈方式:將 GitHub 帳號連動到 Vercel,選擇你的專案 Repository,點擊 Deploy。
優點:
全自動化(CI/CD):你只要 git push 到 GitHub,Vercel 就會自動幫你下載套件、編譯、發佈,過程不到一分鐘。
原生支援路由:不會有 React Router 重新整理變 404 的困擾。
預覽功能:如果你開了一個新的 Branch,Vercel 會幫你產生一個專屬的測試網址,讓你在合併前先確認畫面。
性能優異:自帶全球 CDN,載入速度通常比 GitHub Pages 快。
缺點:雖然有免費額度且非常慷慨,但若流量極大或用於大型商業用途才需付費。
請再將檔案改成可以上傳到Vercel的React架構目錄
2. 檢查 package.json
Vercel 依賴 package.json 來決定如何部署。請確保你的專案根目錄有這個檔案,並且包含類似以下的 scripts:
"scripts": {
"dev": "vite", // 如果用 Vite
"build": "vite build", // 或 "react-scripts build"
"preview": "vite preview"
}
如果沒有 package.json,Vercel 會預設這是一個純靜態網站,不會執行任何編譯動作。
4. 檢查是否有 index.html
React 需要一個 HTML 殼子來掛載程式碼。請確認根目錄或 public 資料夾下有一個 index.html,裡面包含一個 <div id="root"></div>。
為了將這個遊戲部署到 Vercel,我們建議使用 Vite 來建置 React 專案。以下是完整的目錄架構與檔案內容。
在本地端建立專案後,您的目錄結構會長這樣:
my-mole-game/
├── public/
├── src/
│ ├── App.jsx # 遊戲主程式
│ ├── firebase.js # Firebase 初始化設定
│ ├── index.css # Tailwind 引入與全局樣式
│ └── main.jsx # React 進入點
├── .env # 環境變數 (放置 Firebase 金鑰)
├── index.html #入口 HTML
├── package.json
├── tailwind.config.js #Tailwind 配置
└── vite.config.js
請在終端機中執行以下指令建立專案:
# 使用 Vite 建立 React 專案 (my-mole-game是名稱,可自己改名)
npm create vite@latest my-mole-game -- --template react
cd my-mole-game
# 安裝依賴套件 (Firebase, 圖示套件與 Tailwind CSS)
npm install firebase lucide-react
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p
出現錯誤:npm error could not determine executable to run npm error A complete log of this run can be found in: C:\Users\Huang WenYu\AppData\Local\npm-cache\_logs\2026-04-05T09_54_10_400Z-debug-0.log
可能原因 2:Tailwind CSS 版本更新差異 (最有可能)
目前透過 npm 下載可能會預設安裝最新的 Tailwind CSS v4,而 v4 已經移除了 init 指令與 tailwind.config.js 的依賴(改為全 CSS 配置)。因為我們 Canvas 文件中的指南是基於 v3 的標準架構,版本衝突會導致找不到指令。
👉 解決方式:請退回並明確指定安裝 v3 版本。請在您的終端機依序執行:
# 1. 先移除可能安裝錯的最新版本
npm uninstall tailwindcss postcss autoprefixer
# 2. 強制指定安裝相容的 v3 版本
npm install -D tailwindcss@3 postcss autoprefixer
# 3. 再次執行您剛剛選取的初始化指令
npx tailwindcss init -p
執行完畢後,您應該就能成功看到 tailwind.config.js 與 postcss.config.js 檔案被建立出來了!後續就可以繼續照著 Canvas 上的指南進行配置。
本地測試: 輸入 npm run dev,確認在 http://localhost:5173 看到的畫面是正確的。
在專案根目錄建立 .env 檔案,填入您在 Firebase 後台取得的專案金鑰(請不要將此檔案上傳至公開的 GitHub,Vercel 部署時請在 Vercel 後台設定這些環境變數):
VITE_FIREBASE_API_KEY=your_api_key
VITE_FIREBASE_AUTH_DOMAIN=your_project_id.firebaseapp.com
VITE_FIREBASE_PROJECT_ID=your_project_id
VITE_FIREBASE_STORAGE_BUCKET=your_project_id.appspot.com
VITE_FIREBASE_MESSAGING_SENDER_ID=your_messaging_sender_id
VITE_FIREBASE_APP_ID=your_app_id
設定 Tailwind 掃描的路徑:
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}
引入 Tailwind 與滾動條樣式: 加在後面
@tailwind base;
@tailwind components;
@tailwind utilities;
.custom-scrollbar::-webkit-scrollbar { width: 4px; }
.custom-scrollbar::-webkit-scrollbar-track { background: transparent; }
.custom-scrollbar::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 10px; }
將 Firebase 設定獨立出來,並使用環境變數:
import { initializeApp } from 'firebase/app';
import { getAuth } from 'firebase/auth';
import { getFirestore } from 'firebase/firestore';
const firebaseConfig = {
apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN,
projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,
storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET,
messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID,
appId: import.meta.env.VITE_FIREBASE_APP_ID
};
const app = initializeApp(firebaseConfig);
export const auth = getAuth(app);
export const db = getFirestore(app);
移除了原本針對預覽環境的特定變數,改為標準的 React 寫法:
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { signInAnonymously, onAuthStateChanged } from 'firebase/auth';
import { collection, addDoc, onSnapshot } from 'firebase/firestore';
import { Trophy, Play, Send, RefreshCw, Clock, Star, Zap } from 'lucide-react';
import { auth, db } from './firebase'; // 引入獨立的 firebase 設定
export default function App() {
const [isPlaying, setIsPlaying] = useState(false);
const [score, setScore] = useState(0);
const [timeLeft, setTimeLeft] = useState(30);
const [activeMoleIndex, setActiveMoleIndex] = useState(null);
const [currentQuestion, setCurrentQuestion] = useState({ text: '?', answer: null, options: [] });
const [feedback, setFeedback] = useState(null);
const [user, setUser] = useState(null);
const [leaderboard, setLeaderboard] = useState([]);
const [playerName, setPlayerName] = useState('');
const [showModal, setShowModal] = useState(false);
const [isSubmitted, setIsSubmitted] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const timerRef = useRef(null);
const moleTimeoutRef = useRef(null);
// 初始化 Firebase 匿名登入
useEffect(() => {
signInAnonymously(auth).catch(error => console.error("Auth error:", error));
const unsubscribe = onAuthStateChanged(auth, setUser);
return () => unsubscribe();
}, []);
// 監聽排行榜
useEffect(() => {
if (!user) return;
const scoresRef = collection(db, 'leaderboard'); // 修改為標準的 collection 根路徑
const unsubscribe = onSnapshot(scoresRef, (snapshot) => {
const data = snapshot.docs.map(doc => doc.data());
const sorted = data.sort((a, b) => b.score - a.score).slice(0, 10);
setLeaderboard(sorted);
}, (err) => console.error("Firestore error:", err));
return () => unsubscribe();
}, [user]);
// 產生題目
const generateQuestion = useCallback(() => {
const n1 = Math.floor(Math.random() * 8) + 2;
const n2 = Math.floor(Math.random() * 8) + 2;
const answer = n1 * n2;
let options = [answer];
while (options.length < 3) {
const offset = Math.floor(Math.random() * 10) + 1;
const wrong = Math.random() > 0.5 ? answer + offset : answer - offset;
if (wrong > 0 && !options.includes(wrong)) options.push(wrong);
}
return { text: `${n1} x ${n2}`, answer, options: options.sort(() => Math.random() - 0.5) };
}, []);
// 顯示地鼠
const spawnMole = useCallback(() => {
if (!isPlaying) return;
const nextIndex = Math.floor(Math.random() * 6);
setCurrentQuestion(generateQuestion());
setActiveMoleIndex(nextIndex);
const stayTime = Math.max(1200, 1800 - score * 5);
moleTimeoutRef.current = setTimeout(() => {
setActiveMoleIndex(null);
if (isPlaying) setTimeout(spawnMole, 400);
}, stayTime);
}, [isPlaying, generateQuestion, score]);
const startGame = () => {
setScore(0);
setTimeLeft(30);
setIsPlaying(true);
setShowModal(false);
setIsSubmitted(false);
setFeedback(null);
setActiveMoleIndex(null);
};
// 倒數計時處理
useEffect(() => {
if (isPlaying && timeLeft > 0) {
timerRef.current = setInterval(() => setTimeLeft(prev => prev - 1), 1000);
} else if (timeLeft === 0 && isPlaying) {
setIsPlaying(false);
setActiveMoleIndex(null);
setShowModal(true);
clearInterval(timerRef.current);
clearTimeout(moleTimeoutRef.current);
}
return () => clearInterval(timerRef.current);
}, [isPlaying, timeLeft]);
useEffect(() => {
if (isPlaying) spawnMole();
}, [isPlaying]);
const handleAnswer = (choice) => {
if (!isPlaying || activeMoleIndex === null) return;
if (choice === currentQuestion.answer) {
setScore(s => s + 10);
setFeedback('correct');
} else {
setScore(s => Math.max(0, s - 5));
setFeedback('wrong');
}
setTimeout(() => setFeedback(null), 400);
clearTimeout(moleTimeoutRef.current);
setActiveMoleIndex(null);
setTimeout(spawnMole, 300);
};
const submitScore = async () => {
if (!user || isSubmitting) return;
setIsSubmitting(true);
try {
const scoresRef = collection(db, 'leaderboard');
await addDoc(scoresRef, {
name: playerName.trim() || '無名英雄',
score: score,
timestamp: Date.now(),
uid: user.uid
});
setIsSubmitted(true);
} catch (e) {
console.error(e);
} finally {
setIsSubmitting(false);
}
};
// --- 以下 UI 部分與原本完全相同 ---
return (
<div className={`min-h-screen bg-green-500 flex flex-col items-center p-4 transition-colors duration-300 ${feedback === 'correct' ? 'bg-green-400' : feedback === 'wrong' ? 'bg-red-400' : ''}`}>
{/* 頂部數據面板 */}
<div className="w-full max-w-2xl bg-green-700 rounded-3xl p-5 shadow-2xl flex justify-between items-center mb-6 border-b-4 border-green-900">
<div className="text-white text-center px-4">
<p className="text-xs uppercase tracking-widest opacity-80 mb-1">Score</p>
<div className="flex items-center gap-2">
<Star className="w-5 h-5 text-yellow-400 fill-current" />
<span className="text-4xl font-black">{score}</span>
</div>
</div>
<div className="hidden md:block">
<h1 className="text-2xl font-bold text-white tracking-tighter">九九乘法大挑戰</h1>
</div>
<div className="text-white text-center px-4">
<p className="text-xs uppercase tracking-widest opacity-80 mb-1">Time</p>
<div className="flex items-center gap-2">
<Clock className={`w-5 h-5 ${timeLeft < 10 ? 'text-red-400 animate-pulse' : 'text-yellow-300'}`} />
<span className={`text-4xl font-black ${timeLeft < 10 ? 'text-red-400' : 'text-yellow-300'}`}>{timeLeft}s</span>
</div>
</div>
</div>
{/* 遊戲區域 */}
<div className="relative w-full max-w-2xl bg-green-400 p-6 rounded-[3rem] shadow-inner border-4 border-green-300 overflow-hidden">
<div className="grid grid-cols-2 md:grid-cols-3 gap-6 relative z-10">
{[...Array(6)].map((_, i) => (
<div key={i} className="relative aspect-square rounded-full bg-[#4b3621] shadow-[inset_0_8px_16px_rgba(0,0,0,0.6)] overflow-hidden border-b-8 border-amber-950">
<div className={`absolute inset-0 flex flex-col items-center justify-center transition-transform duration-300 ${activeMoleIndex === i ? 'translate-y-0' : 'translate-y-full'}`}>
<div className="w-4/5 h-4/5 bg-orange-700 rounded-t-full border-2 border-orange-900 shadow-lg flex flex-col items-center justify-center p-2">
<div className="flex gap-2 mb-2">
<div className="w-2 h-2 bg-black rounded-full" />
<div className="w-2 h-2 bg-black rounded-full" />
</div>
<div className="bg-white/90 px-3 py-1 rounded-xl shadow-inner scale-90 md:scale-100">
<span className="text-xl md:text-2xl font-black text-gray-800">{activeMoleIndex === i ? currentQuestion.text : ''}</span>
</div>
</div>
</div>
</div>
))}
</div>
{/* 開始遮罩 */}
{!isPlaying && !showModal && (
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center bg-black/50 backdrop-blur-sm rounded-[3rem] p-6 text-center">
<button
onClick={startGame}
className="bg-yellow-400 hover:bg-yellow-300 text-brown-900 font-black py-6 px-14 rounded-full text-4xl shadow-2xl transform transition hover:scale-105 active:scale-95 border-b-8 border-yellow-600 mb-8 flex items-center gap-3"
>
<Play className="w-10 h-10 fill-current" /> 開始挑戰
</button>
<div className="bg-white/95 p-5 rounded-3xl shadow-xl w-full max-w-xs">
<h3 className="text-green-700 font-black text-xl mb-3 flex items-center justify-center gap-2">
<Trophy className="w-5 h-5" /> 排行榜 TOP 5
</h3>
<div className="space-y-2">
{leaderboard.length > 0 ? leaderboard.slice(0, 5).map((entry, i) => (
<div key={i} className="flex justify-between items-center text-sm border-b border-gray-100 pb-1">
<span className="font-bold text-gray-500 w-5">{i+1}.</span>
<span className="flex-grow text-left truncate px-2 font-medium">{entry.name}</span>
<span className="font-black text-orange-500">{entry.score}</span>
</div>
)) : <p className="text-gray-400 py-4">正在載入高手數據...</p>}
</div>
</div>
</div>
)}
</div>
{/* 答案按鈕 */}
<div className="w-full max-w-2xl mt-8 grid grid-cols-3 gap-4">
{currentQuestion.options.map((opt, i) => (
<button
key={i}
disabled={!isPlaying || activeMoleIndex === null}
onClick={() => handleAnswer(opt)}
className="group relative bg-white disabled:opacity-50 disabled:scale-100 hover:bg-blue-50 text-blue-600 text-4xl font-black py-8 rounded-[2rem] shadow-xl transform transition hover:scale-105 active:scale-95 border-b-8 border-blue-200"
>
{opt}
<div className="absolute -top-2 -right-2 bg-yellow-400 w-8 h-8 rounded-full flex items-center justify-center text-xs text-white opacity-0 group-hover:opacity-100 transition-opacity">
<Zap className="w-4 h-4 fill-current" />
</div>
</button>
))}
</div>
{/* 遊戲結束 Modal */}
{showModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-md p-4">
<div className={`bg-white rounded-[3rem] p-8 max-w-md w-full text-center shadow-2xl transition-all duration-500 ${showModal ? 'scale-100 opacity-100' : 'scale-90 opacity-0'}`}>
<Trophy className="w-20 h-20 text-yellow-500 mx-auto mb-4 animate-bounce" />
<h2 className="text-4xl font-black text-green-600 mb-2">挑戰結束!</h2>
<div className="bg-green-50 rounded-3xl py-6 my-6 border-2 border-green-100">
<p className="text-gray-500 text-sm font-bold tracking-widest mb-1">YOUR SCORE</p>
<p className="text-7xl font-black text-green-700 tracking-tighter">{score}</p>
</div>
{!isSubmitted ? (
<div className="space-y-4 mb-6">
<input
type="text"
value={playerName}
onChange={(e) => setPlayerName(e.target.value)}
placeholder="輸入大名挑戰高手"
maxLength={12}
className="w-full p-4 border-2 border-green-100 rounded-2xl text-center text-xl font-bold focus:outline-none focus:border-green-400 transition-colors"
/>
<button
onClick={submitScore}
disabled={isSubmitting}
className="w-full bg-blue-500 hover:bg-blue-600 text-white font-black py-4 rounded-2xl text-xl shadow-lg flex items-center justify-center gap-3 disabled:opacity-50"
>
{isSubmitting ? <RefreshCw className="animate-spin" /> : <Send />} {isSubmitting ? '正在傳送...' : '上傳成績至排行榜'}
</button>
</div>
) : (
<div className="mb-8 p-4 bg-blue-50 rounded-2xl text-blue-700 font-bold flex flex-col items-center gap-2">
<p className="text-lg">成績上傳成功!🏅</p>
<div className="w-full max-h-40 overflow-y-auto pr-2 custom-scrollbar text-sm space-y-1">
{leaderboard.map((e, idx) => (
<div key={idx} className="flex justify-between border-b border-blue-100 py-1">
<span>{idx+1}. {e.name}</span>
<span>{e.score}</span>
</div>
))}
</div>
</div>
)}
<button
onClick={startGame}
className="w-full bg-green-500 hover:bg-green-600 text-white font-black py-5 rounded-full text-2xl shadow-lg transform transition hover:scale-105"
>
再戰一回
</button>
</div>
</div>
)}
</div>
);
}
推送程式碼到 GitHub:
將設定好的專案推送到您的 GitHub Repository。
在 Vercel 匯入專案:
登入 Vercel 點選 "Add New Project",選擇剛才的 GitHub Repo。
設定環境變數 (Environment Variables):
在 Vercel 的部署設定區塊,找到 Environment Variables,將 .env 中的 VITE_FIREBASE_... 等金鑰依序新增上去。
點擊 Deploy:
等待建置完成後,即可獲得專屬的 Vercel 網址,並擁有完整的 Firestore 排行榜功能!
按照上面方式修改
執行 npm run dev
在 Vercel 匯入專案:
把壓縮檔上傳到GitHub
登入 Vercel 點選 "Add New Project",選擇剛才的 GitHub Repo。
但發現一片空白
建立Firebase資料庫
進入 Firebase 控制台,網址:https://console.firebase.google.com/
到Vercel設定環境變數 (Environment Variables):
在 Vercel 的部署設定區塊,找到 Environment Variables,將 .env 中的 VITE_FIREBASE_... 等金鑰依序新增上去。(變數前面要有:VITE_FIREBASE_.)
點擊 Deploy:
發現還是一片空
再把Firestone認證的地方改為【匿名】
還是不行,原來變數前面要有:VITE_FIREBASE_.,再重新修改環境變數!!
終於有畫面了,但資料庫仍然有問題,寫不進去
太棒了,看到畫面代表您的 Vercel 部署和環境變數都已經設定正確!
現在您遇到的 Missing or insufficient permissions (權限不足) 錯誤,是 Firebase 的安全規則 (Security Rules) 擋住了您的網頁讀寫資料庫的請求。
因為 Firebase 預設的資料庫規則通常是非常嚴格的(可能預設為拒絕所有讀寫),我們需要去後台開放 leaderboard 這個集合的讀寫權限給玩家。
更新安全規則 :
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
// 針對排行榜 (leaderboard) 的規則
match /leaderboard/{docId} {
// 允許所有人讀取排行榜數據
allow read: if true;
// 必須是有驗證的用戶 (包含我們的匿名登入) 才能上傳成績
allow write: if request.auth != null;
}
}
}
終於成功了!!!
如果您已經有 Gemini 生成的 React 程式碼,不需要在自己電腦上設定,直接使用 StackBlitz 或 Codesandbox:
開啟 StackBlitz 並選擇 React (Vite) 範本。
將 src/App.jsx 的內容刪除,貼上您的 9x9.txt 原始碼 。
在左側的終端機(Terminal)輸入 npm install firebase lucide-react 並按 Enter。
一鍵部署: 點擊畫面右上角的 "Deploy" 按鈕,它可以直接連結您的 Vercel 帳號,按一下就完成部署。
瀏覽 StackBlitz 官網。
點擊 "React" 或 "Vite / React" 圖示建立一個新專案。
您會看到一個左側是檔案列表、中間是程式碼編輯器、右側是網頁預覽的視窗。
在左側檔案列表找到 src/App.jsx 並點擊打開。
刪除裡面原本所有的程式碼。
將您 9x9.txt 檔案中的內容全部複製並貼上 。
特別注意: 您的程式碼開頭有 `` 這種標記,貼上後請手動刪除這些標記,確保它是純粹的 JavaScript 。
因為這份程式碼使用了 firebase 和 lucide-react ,您需要安裝它們:
在 StackBlitz 畫面下方的 Terminal (終端機) 輸入以下指令並按 Enter:
Bash
npm install firebase lucide-react
做法跟上面的一樣
StackBlitz 最方便的地方在於它可以直接將成品送往 Vercel:
點擊編輯器右上角的 "Deploy" 按鈕。
選擇 "Vercel"。
系統會要求您登入 Vercel 帳號並授權。
授權完成後,點擊 "Deploy to Vercel"。
幾分鐘後,它會直接給您一個全新的 .vercel.app 網址。
執行 npm run dev
點擊 http://localhost:5173/,右邊一片空白
抓到「兇手」了!阿玉老師,您提供的報錯訊息非常精確,問題核心就在這一段:
Uncaught FirebaseError: Firebase: Error (auth/invalid-api-key). at firebase.js:15:21
這代表當程式執行到 firebase.js 的第 15 行(通常是 getAuth(app) 或 initializeApp)時,因為 API Key 是假的或是格式不正確,Firebase 直接罷工並中斷了整個 JavaScript 的執行 。當 JavaScript 中斷,React 就無法把遊戲畫面畫出來,所以您才會看到一片空白。
請按照以下步驟修正,保證能看到畫面:
Firebase 的 API Key 檢查非常嚴格,即使是測試用的,也必須符合一定的格式,否則它會報錯。請將您的 firebaseConfig 修改為以下內容,這是一組格式正確但功能無效的代碼,能騙過系統讓它不要崩潰:
JavaScript
const firebaseConfig = {
// 這裡的字串必須具備一定的長度,Firebase 才不會報 invalid-api-key
apiKey: "AIzaSyA-ExampleKeyForTestingOnly12345",
authDomain: "your-app.firebaseapp.com",
projectId: "your-app",
storageBucket: "your-app.appspot.com",
messagingSenderId: "123456789",
appId: "1:123456789:web:abcdef123456"
};
為了避免以後因為網路或是金鑰問題導致畫面變白,建議在初始化時加上保護。請找到您的 firebase.js 並修改如下:
JavaScript
let app, auth, db;
try {
app = initializeApp(firebaseConfig);
auth = getAuth(app);
db = getFirestore(app);
} catch (error) {
console.error("Firebase 初始化失敗,但遊戲仍會繼續運行:", error);
// 提供虛擬對象防止 App.jsx 崩潰
auth = { onAuthStateChanged: () => () => {} };
db = {};
}
export { auth, db };
您之前的截圖顯示:import { auth, db } from './firebase'; 請確保左側檔案列表裡真的有一個檔案叫 firebase.js(副檔名也要對)。
最保險的做法: 如果您覺得處理兩個檔案太麻煩,請直接把 firebase.js 的內容整段複製,貼到 App.jsx 的最上方(取代掉那行 import),並把 export { auth, db } 刪掉。這樣所有東西都在同一個檔案裡,出錯機率最小。
如果您發現畫面出來了,但還是醜醜的(沒有綠色背景),請檢查 StackBlitz 左側的 index.html。 在 <head> 標籤裡面加上這行,直接從網路抓取 Tailwind CSS:
HTML
<script src="https://cdn.tailwindcss.com"></script>
這是給老師最方便的「懶人包」,不用設定任何 config 檔案,畫面立刻變漂亮!
WASI 警告:那是 StackBlitz 環境的正常現象,可以忽略。
TTY 錯誤:也是環境警告,不影響網頁。
Invalid-api-key:這是唯一會讓網頁變白的真兇 。
老師,請先試著改掉那組 apiKey 字串,並加上 CDN 腳本。如果看到綠色草地跳出來,記得跟我分享這個好消息!