真實(shí)案例 - 用AI寫(xiě)俄羅斯方塊(觸屏手機(jī)也能玩))
1. 為什么我決定用 Codex 寫(xiě)一個(gè)觸屏能玩的俄羅斯方塊俄羅斯方塊這個(gè)游戲幾乎每個(gè)寫(xiě)前端的人都動(dòng)過(guò)手。但真正把它寫(xiě)到「手機(jī)上能順暢玩」的程度坑比想象中多得多。我試過(guò)直接讓 AI 生成一版結(jié)果在電腦上跑得好好的一放到手機(jī)瀏覽器就出問(wèn)題手指滑動(dòng)沒(méi)反應(yīng)、按鈕點(diǎn)一下觸發(fā)兩次、方塊下落速度跟屏幕刷新率綁死導(dǎo)致越玩越快。這次我換了個(gè)思路用 Codex 從零開(kāi)始把「觸屏手機(jī)也能玩」當(dāng)成第一優(yōu)先級(jí)來(lái)寫(xiě)。核心要解決三件事HTML5 Canvas 的渲染要適配不同 DPR 的屏幕、觸摸手勢(shì)要區(qū)分「點(diǎn)擊旋轉(zhuǎn)」和「下滑硬降」、游戲循環(huán)要用時(shí)間戳驅(qū)動(dòng)而不是幀數(shù)驅(qū)動(dòng)。這三個(gè)點(diǎn)搞定移動(dòng)端可玩性基本就穩(wěn)了。這篇文章會(huì)給出完整的 Codex 提示詞、Canvas 初始化配置、觸屏事件綁定代碼以及我在手機(jī)瀏覽器實(shí)測(cè)旋轉(zhuǎn)、下落、消行的過(guò)程。適合已經(jīng)會(huì)一點(diǎn) JavaScript、想用 AI 快速做出可玩小游戲的人。你不需要懂游戲引擎只要跟著步驟把代碼貼進(jìn)去就能在手機(jī)上玩到自己寫(xiě)的俄羅斯方塊。先說(shuō)清楚 Codex 在這里扮演什么角色。它不是幫你「一鍵生成整個(gè)游戲」的魔法按鈕而是一個(gè)能理解具體技術(shù)術(shù)語(yǔ)的結(jié)對(duì)伙伴。你描述得越精確——比如「10列×20行、每格30px、用 SRS 旋轉(zhuǎn)系統(tǒng)」——它生成的代碼就越接近能直接用的狀態(tài)。模糊地說(shuō)「做個(gè)俄羅斯方塊」出來(lái)的東西大概率要返工。2. 前置準(zhǔn)備TaoToken 接入 Codex 的配置與 API Key 獲取在開(kāi)始寫(xiě)游戲之前得先把 Codex 跑起來(lái)。我用的方式是走 TaoToken 的 API 接入這樣在命令行里就能直接調(diào)用不用來(lái)回切網(wǎng)頁(yè)。整個(gè)流程分三步拿 Key、配環(huán)境變量、驗(yàn)證連通。2.1 獲取 API Key打開(kāi) TaoToken 控制臺(tái)進(jìn)入 API Keys 頁(yè)面創(chuàng)建一個(gè)新的 Key。創(chuàng)建時(shí)建議給它起個(gè)能認(rèn)出來(lái)的名字比如codex-tetris方便后面區(qū)分不同項(xiàng)目的用量。Key 只在創(chuàng)建時(shí)完整顯示一次復(fù)制后先存到安全的地方。拿到 Key 之后不要直接寫(xiě)死在代碼里。我用的是環(huán)境變量方式在~/.zshrc或~/.bashrc里加一行export TAOTOKEN_API_KEYsk-你的key然后source ~/.zshrc讓它生效。這樣做的原因是后面 Codex 的配置文件里只需要引用變量名不會(huì)把明文 Key 提交到 git 里。2.2 配置 Codex 的 auth.jsonCodex 的認(rèn)證信息放在~/.codex/auth.json。如果你之前沒(méi)配過(guò)這個(gè)文件可能不存在手動(dòng)創(chuàng)建即可。內(nèi)容結(jié)構(gòu)如下{ OPENAI_API_KEY: sk-你的key, OPENAI_BASE_URL: https://taotoken.net/api }注意 Base URL 這里填的是https://taotoken.net/api不要帶多余的路徑。Model ID 在調(diào)用時(shí)指定常用的編碼模型填gpt-5-codex或你賬號(hào)下可用的對(duì)應(yīng)模型名。這三個(gè)要素——Base URL、Key、Model ID——缺一不可后面排查 401 錯(cuò)誤時(shí)也是先查這三項(xiàng)。2.3 驗(yàn)證連通性配好之后用一條最簡(jiǎn)單的請(qǐng)求確認(rèn)能通。在終端里執(zhí)行curl https://taotoken.net/api/v1/models \ -H Authorization: Bearer $TAOTOKEN_API_KEY如果返回一個(gè)包含模型列表的 JSON說(shuō)明 Key 和網(wǎng)絡(luò)都沒(méi)問(wèn)題。如果返回 401先檢查 Key 有沒(méi)有復(fù)制完整、有沒(méi)有多余空格如果返回連接超時(shí)檢查 Base URL 是不是寫(xiě)成了帶/v1的完整路徑導(dǎo)致重復(fù)。這一步看起來(lái)簡(jiǎn)單但很多人卡在這里。我踩過(guò)的坑是把 Key 寫(xiě)進(jìn)了auth.json但忘了export環(huán)境變量結(jié)果 Codex 讀的是空值。兩個(gè)地方都要對(duì)缺一個(gè)都不行。3. 可復(fù)制配置Canvas 初始化與觸屏事件綁定這一節(jié)是整篇文章的核心。我會(huì)把 Codex 生成的配置拆成三塊Canvas 的 DPR 適配、游戲循環(huán)的時(shí)間戳驅(qū)動(dòng)、觸摸手勢(shì)的判定邏輯。每一塊都給出可直接復(fù)制的代碼。3.1 Canvas 的 DPR 適配配置移動(dòng)端 Canvas 最常見(jiàn)的問(wèn)題是「畫(huà)出來(lái)模糊」。原因是 CSS 像素和物理像素不是 1:1尤其在 Retina 屏上。解決辦法是根據(jù)devicePixelRatio放大 Canvas 的實(shí)際分辨率再用 CSS 把它縮回視覺(jué)尺寸。function setupCanvas(canvas, cssWidth, cssHeight) { const dpr window.devicePixelRatio || 1; // 實(shí)際分辨率 CSS 尺寸 × DPR canvas.width cssWidth * dpr; canvas.height cssHeight * dpr; // CSS 尺寸保持視覺(jué)大小 canvas.style.width cssWidth px; canvas.style.height cssHeight px; const ctx canvas.getContext(2d); // 縮放上下文讓后續(xù)繪制按 CSS 像素坐標(biāo)走 ctx.scale(dpr, dpr); return ctx; } // 主畫(huà)布10列 × 20行每格30px const gameCtx setupCanvas( document.getElementById(game-canvas), 300, 600 );這段配置的關(guān)鍵在ctx.scale(dpr, dpr)。調(diào)用之后你后面所有fillRect(x, y, 30, 30)里的坐標(biāo)都按 CSS 像素算不用自己乘 DPR。Codex 生成這段時(shí)我特意在提示詞里寫(xiě)了「用 devicePixelRatio 適配高分屏」它就把 scale 這步帶上了。3.2 游戲循環(huán)的時(shí)間戳驅(qū)動(dòng)很多教程用setInterval或者按幀計(jì)數(shù)來(lái)控制下落這在 60Hz 和 120Hz 屏幕上表現(xiàn)完全不同。正確做法是用requestAnimationFrame拿到時(shí)間戳自己算距離上次下落過(guò)了多久。let lastDropTime 0; let dropInterval 800; // 初始下落間隔毫秒 function gameLoop(timestamp) { if (gameOver || paused) return; // 距離上次自動(dòng)下落是否超過(guò)間隔 if (timestamp - lastDropTime dropInterval) { moveDown(); lastDropTime timestamp; } render(); requestAnimationFrame(gameLoop); } // 啟動(dòng) requestAnimationFrame((ts) { lastDropTime ts; gameLoop(ts); });dropInterval隨等級(jí)遞減公式用經(jīng)典的Math.pow(0.8 - (level - 1) * 0.007, level - 1) * 1000最低不低于 50ms。這樣無(wú)論屏幕刷新率多少下落速度都是一致的。3.3 觸摸手勢(shì)的判定邏輯觸屏操作要區(qū)分三種意圖短按旋轉(zhuǎn)、快速下滑硬降、水平滑動(dòng)左右移。判定依據(jù)是位移距離和持續(xù)時(shí)間。let touchStartX 0; let touchStartY 0; let touchStartTime 0; gameCanvas.addEventListener(touchstart, (e) { e.preventDefault(); const touch e.touches[0]; touchStartX touch.clientX; touchStartY touch.clientY; touchStartTime Date.now(); }, { passive: false }); gameCanvas.addEventListener(touchend, (e) { e.preventDefault(); const touch e.changedTouches[0]; const dx touch.clientX - touchStartX; const dy touch.clientY - touchStartY; const dt Date.now() - touchStartTime; const absDx Math.abs(dx); const absDy Math.abs(dy); if (absDx 10 absDy 10 dt 300) { // 短按旋轉(zhuǎn) rotatePiece(1); } else if (absDy absDx dy 50) { // 快速下滑硬降 hardDrop(); } else if (absDx absDy absDx 30) { // 水平滑動(dòng)左右移 moveHorizontal(dx 0 ? 1 : -1); } }, { passive: false });這里{ passive: false }很重要。移動(dòng)端瀏覽器默認(rèn)把 touch 事件當(dāng)被動(dòng)監(jiān)聽(tīng)preventDefault()會(huì)失效導(dǎo)致頁(yè)面跟著滾動(dòng)。加上這個(gè)選項(xiàng)才能阻止默認(rèn)行為。3.4 虛擬按鈕的綁定除了手勢(shì)底部還放了一排虛擬按鈕方便不習(xí)慣滑動(dòng)的用戶。綁定用touchstart而不是click響應(yīng)更快。const btnMap { btn-left: () moveHorizontal(-1), btn-right: () moveHorizontal(1), btn-rotate: () rotatePiece(1), btn-down: () softDrop(), btn-drop: () hardDrop(), btn-hold: () holdCurrentPiece(), }; Object.entries(btnMap).forEach(([id, handler]) { const btn document.getElementById(id); btn.addEventListener(touchstart, (e) { e.preventDefault(); handler(); }, { passive: false }); });按鈕的 CSS 里要加touch-action: manipulation和-webkit-tap-highlight-color: transparent去掉點(diǎn)擊高亮和 300ms 延遲。4. 驗(yàn)證請(qǐng)求手機(jī)瀏覽器實(shí)測(cè)旋轉(zhuǎn)、下落與消行代碼寫(xiě)完得在真機(jī)上驗(yàn)證。我用的是手機(jī) Chrome通過(guò)局域網(wǎng)訪問(wèn)電腦起的靜態(tài)服務(wù)。這一步的目的是確認(rèn)三件事方塊能正常旋轉(zhuǎn)、下落速度穩(wěn)定、消行邏輯正確。4.1 起一個(gè)本地服務(wù)在項(xiàng)目目錄下執(zhí)行python3 -m http.server 8080然后在手機(jī)瀏覽器輸入電腦的局域網(wǎng) IP 加端口比如http://192.168.1.100:8080。注意手機(jī)和電腦要在同一個(gè) Wi-Fi 下。4.2 驗(yàn)證旋轉(zhuǎn)進(jìn)入游戲后點(diǎn)一下屏幕中央方塊應(yīng)該順時(shí)針轉(zhuǎn) 90 度。連續(xù)點(diǎn)四次應(yīng)該回到初始朝向。如果轉(zhuǎn)不動(dòng)檢查rotatePiece里的isValidPosition判斷——可能是旋轉(zhuǎn)后的形狀越界了SRS 的踢墻數(shù)據(jù)沒(méi)生效。我在實(shí)測(cè)時(shí)遇到過(guò)一次「貼墻旋轉(zhuǎn)失敗」原因是踢墻偏移的 y 軸方向搞反了。SRS 數(shù)據(jù)里 dy 為正表示向上代碼里要用y - dy而不是y dy。改過(guò)來(lái)之后貼左墻和貼右墻都能正常旋轉(zhuǎn)。4.3 驗(yàn)證下落與消行讓方塊自然下落觀察速度是否均勻。然后手動(dòng)堆幾行故意留一行快滿等一個(gè)長(zhǎng)條方塊下來(lái)消掉。消行時(shí)應(yīng)該看到閃爍動(dòng)畫(huà)然后上面的行整體下移。驗(yàn)證消行的關(guān)鍵是看clearLines函數(shù)。它從底部往上掃遇到滿行就splice刪掉然后在頂部unshift補(bǔ)空行。如果消行后出現(xiàn)錯(cuò)位多半是splice的索引沒(méi)處理好——?jiǎng)h一行之后后面的行號(hào)會(huì)變所以要從下往上刪。4.4 驗(yàn)證觸屏手勢(shì)在畫(huà)布上快速下滑方塊應(yīng)該直接落到底部并鎖定。水平滑動(dòng)方塊應(yīng)該左右移動(dòng)一格。短按方塊旋轉(zhuǎn)。三種手勢(shì)互不干擾。如果下滑被識(shí)別成了旋轉(zhuǎn)檢查dy 50這個(gè)閾值。手指滑動(dòng)距離太短會(huì)被當(dāng)成點(diǎn)擊。反過(guò)來(lái)如果水平滑動(dòng)沒(méi)反應(yīng)可能是absDx 30設(shè)得太高調(diào)低到 20 試試。5. 本篇常見(jiàn)錯(cuò)誤排查401、local proxy failed 與 OAuth 報(bào)錯(cuò)配置和運(yùn)行過(guò)程中最容易卡住的是認(rèn)證和網(wǎng)絡(luò)問(wèn)題。這一節(jié)把幾個(gè)高頻報(bào)錯(cuò)和對(duì)應(yīng)解法列出來(lái)。5.1 401 Unauthorized這是最常見(jiàn)的。原因通常是三個(gè)Key 不對(duì)、Base URL 不對(duì)、Model ID 不對(duì)。先確認(rèn)auth.json里的OPENAI_API_KEY和OPENAI_BASE_URL都填了。Base URL 必須是https://taotoken.net/api不要帶/v1。然后確認(rèn)調(diào)用時(shí)指定的 Model ID 是你賬號(hào)下可用的。三者都對(duì)還報(bào) 401就去控制臺(tái)重新生成一個(gè) Key 試試。5.2 local proxy failed這個(gè)報(bào)錯(cuò)通常出現(xiàn)在 Codex 嘗試走本地代理但連不上時(shí)。檢查你的環(huán)境變量里有沒(méi)有殘留的HTTP_PROXY或HTTPS_PROXY設(shè)置。如果有先unset掉再重試。另外確認(rèn)auth.json里的 Base URL 是完整的https://taotoken.net/api不要寫(xiě)成相對(duì)路徑。5.3 reading choices 報(bào)錯(cuò)這個(gè)錯(cuò)誤一般出現(xiàn)在響應(yīng)格式不符合預(yù)期時(shí)。Codex 期望返回的 JSON 里有choices字段如果返回的是錯(cuò)誤信息或者空對(duì)象就會(huì)報(bào)這個(gè)。先確認(rèn)請(qǐng)求的 endpoint 是/v1/chat/completions或?qū)?yīng)的編碼接口再確認(rèn) Model ID 拼寫(xiě)正確。5.4 OAuth 相關(guān)報(bào)錯(cuò)如果你用的是需要 OAuth 的接入方式報(bào)錯(cuò)時(shí)先檢查 token 有沒(méi)有過(guò)期。重新走一遍授權(quán)流程拿到新的 token 后更新到配置文件里。注意 OAuth token 和 API Key 是兩套東西不要混用。5.5 觸屏相關(guān)的問(wèn)題如果手機(jī)上按鈕點(diǎn)不動(dòng)先檢查有沒(méi)有加{ passive: false }。如果頁(yè)面跟著手指滾動(dòng)在touchstart和touchmove里都調(diào)preventDefault()。如果按鈕有 300ms 延遲加touch-action: manipulation。6. 從單機(jī)到聯(lián)機(jī)用 Codex 繼續(xù)擴(kuò)展你的俄羅斯方塊單機(jī)版跑通之后如果想繼續(xù)加功能Codex 也能接著幫你寫(xiě)。我試過(guò)讓它加 Hold 暫存、下一方塊預(yù)覽、甚至 WebSocket 聯(lián)機(jī)對(duì)戰(zhàn)都能生成可用的代碼。6.1 加 Hold 暫存Hold 的邏輯是按 C 鍵把當(dāng)前方塊存起來(lái)如果之前存過(guò)就交換。關(guān)鍵是每次鎖定前只能用一次用一個(gè)canHold標(biāo)志控制。function holdCurrentPiece() { if (!currentPiece || !canHold) return; if (holdPiece) { const temp holdPiece; holdPiece { type: currentPiece.type }; currentPiece createTetromino(temp.type); } else { holdPiece { type: currentPiece.type }; currentPiece getNextPiece(); } canHold false; updatePreview(); }鎖定新方塊時(shí)把canHold重置為true。6.2 加下一方塊預(yù)覽用一個(gè)小的 Canvas 畫(huà)下一個(gè)方塊的形狀。Codex 生成的drawPreview函數(shù)會(huì)自動(dòng)居中繪制不用自己算偏移。6.3 加 WebSocket 聯(lián)機(jī)聯(lián)機(jī)部分需要一個(gè) Node.js 服務(wù)端。核心邏輯是兩個(gè)玩家匹配到同一個(gè)房間一方消行時(shí)給對(duì)手發(fā)「垃圾行」一方游戲結(jié)束另一方獲勝。// 服務(wù)端轉(zhuǎn)發(fā)垃圾行 case lines_cleared: const garbageLines msg.count 4 ? 4 : msg.count - 1; if (garbageLines 0) { forwardToOpponent(ws, { type: garbage, count: garbageLines }); } break;客戶端收到garbage消息后在棋盤(pán)底部加一行灰色方塊隨機(jī)留一個(gè)缺口。6.4 長(zhǎng)期編碼建議如果你打算持續(xù)用 Codex 做這類(lèi)項(xiàng)目可以考慮用 Coding Plan 的方式管理調(diào)用。它的好處是額度更穩(wěn)定適合需要反復(fù)迭代的場(chǎng)景。配置方式跟單次調(diào)用一樣只是計(jì)費(fèi)模式不同。6.5 驗(yàn)證模型輸出寫(xiě)完一段邏輯不確定對(duì)不對(duì)可以把代碼貼到模型對(duì)話里讓它 review。比如問(wèn)「這段消行邏輯有沒(méi)有邊界問(wèn)題」它通常能指出splice索引或者空行補(bǔ)充的疏漏。整個(gè)項(xiàng)目從零到能玩我用了一個(gè)下午。大部分時(shí)間花在調(diào)觸屏手勢(shì)的閾值和 SRS 踢墻數(shù)據(jù)的方向上。Codex 省掉的是查文檔和寫(xiě)樣板代碼的時(shí)間但邏輯對(duì)不對(duì)、手感好不好還是得自己在真機(jī)上試。建議你先把單機(jī)版跑通再逐步加功能每加一個(gè)就在手機(jī)上驗(yàn)證一次這樣出問(wèn)題容易定位。