📋 專案概述
你三十六歲,蘆洲—臺北的捷運通勤單程四十二分鐘。某個加班後的末班車上,你又用三十四歲的拇指在手機上滑俄羅斯方塊——然後被「明早九點 meeting」的 LINE 彈窗當場處決:方塊停在三級高度,消四行的那條早就在等你抬頭。這一幕你已經演了三個月:手機模擬器每次開局都要先開飛航(不然微信通知會打斷渲染),觸屏十字鍵零手感、拇指出汗就滑出屏幕外,兩天新鮮感、三天放回抽屜。你翻出床底的 1998 年彩色 Game Boy:屏幕凹到看不見像素,電池匣的三號電池已經腐爛出白霜——童年遺物正式陣亡。買臺復古掌機吧:淘寶 NT$1,200 的「三千款合一」跑 GB 卡帶音效都破音;NT$3,200 的某安 B 牌要排隊搶、固件閉源,你真正想塞進去的那款你自己寫的遊戲根本裝不進。而你兒子今年四歲,已經開始對 iPad 上「黏答答的消水果」產生興趣——你心裡有個很小的聲音:他這個年紀,應該玩的是方塊和磚塊。
市售商品:
- 手機模擬器:免費,但通知、觸屏無手感、電量焦慮三座大山,實測平均使用壽命 2.7 天。
- 成品復古掌機:NT$1,200–3,500,閉源固件+搶票營銷+壞了變磚;而且你永遠只能玩「它允許你玩的遊戲」。
- 正版復古機+改機:情懷是滿分的,但改機後光禿禿的原機殼不防水不進沙塵——四歲的手會告訴你什麼叫entropy。
本計畫的哲學:與其花錢買一臺「別人的遊戲機」,不如花一個週末造一臺「你的遊戲機」。不跑模擬器、不碰任何卡帶 ROM——寫一個自己的微型遊戲引擎,裡面住着你親手刻的四款遊戲:
- 四款內置遊戲:俄羅斯方塊(七棋片、軟降、越消越快)/貪吃蛇(撞牆判定、越吃越快)/打磚塊(球速逐階加速、擋板反彈角)/2048(滑動合併、無解判定)——全部本文章逐行手寫,每一行你都看得懂、改得動
- 街機真按感:圓弧微動開關+3D 列印蘑菇帽,咔噠聲是你兒子這個年代的「投幣聲」;25ms 狀態機掃描消抖,連打不吃豆也不連發
- 方波芯片音樂:MAX98357A 直推方波——消行五連音、game over descending 三音,零取樣檔,跟 1989 年的 Game Boy 是同一門學問
- 2.8 吋 320×240 彩屏橫打:32 倍於 0.96 吋小屏的儀式感,SPI 40MHz 畫面不撕裂
- 18650 供電約 4 小時:TP4056 充電(Type-C 口)+MT3608 升壓,續航比某寶 3,200 那臺誠實
- 高分永存+NAS 週報:斷電不丟紀錄;每週日把各遊戲最高分與遊玩時數推 NAS——「本週遊玩 4.2 小時」這條你會替你兒子把 iPad 搶回來的,對吧
爲什麼選 ESP32-S3?
| 需求 | S3 的優勢 |
|---|---|
| 畫面不卡頓 | 雙核 240MHz:SPI 刷屏與 I2S 音效擠得下同一個核,遊戲邏輯不搶票;單核的 C3 在 SPI 忙等瞬間按鍵會「吃豆」 |
| 留了後路 | N16R8 的 8MB PSRAM:想玩大的,日後整顆換 RetroGO/nes-esp 跑真 GB/NES 核心——殼、按鍵、喇叭全部沿用(本文章正文不跑模擬器,版權自求) |
| 進階玩法:PC 手把 | S3 原生 USB-OTG:加一顆切換開關+TinyUSB HID,同一臺機器插上 PC 變街機手把打 Steam——C3/C6 沒有 USB 設備控制器,這條路線它們走不了 |
| IO 要夠爽 | 一顆板子同時吃下 SPI 五線+I2S 三線+八鍵,GPIO 還剩一大把留給振動馬達、LED 燈條和第五款遊戲的外設 |
🧱 硬體清單
| 項目 | 型號 | 數量 | 預估單價 | 備註 |
|---|---|---|---|---|
| 主控板 | ESP32-S3 DevKitC-1 N16R8 | 1 | ~230 元 | 雙核+PSRAM+原生 USB,進階 HID 靠它 |
| 螢幕 | ILI9341 2.8 吋 SPI TFT 320×240 | 1 | ~120 元 | 排針焊死前先用轉接板點亮——壞點三天內可退 |
| 按鍵 | 圓弧頭微動開關(立貼式) | 8 | 共 ~60 元 | 十字 4+A/B+START/SELECT;直插式需加角碼 |
| 功放 | MAX98357A 3W I2S 模組 | 1 | ~60 元 | SD 直接接 3V3 常駐開聲 |
| 喇叭 | 4Ω 2W 28mm | 1 | ~35 元 | 方波音樂不需要好喇叭,需要會響的 |
| 電力 | 18650 2500mAh+帶保護 TP4056+MT3608+開關 | 1 組 | ~110 元 | MT3608 出廠 6.5V,接機前必調 5.1V |
| 列印材料 | PLA 約 85g | — | ~30 元 | 按鍵帽區壁厚的確 2.4mm 才耐 arcade |
| 小件 | 20×20 tact 開關×4、M2×8 螺絲×6、銅柱、330µF/10V、硅膠線、磁簧尾蓋 | — | ~55 元 | 微動下疊 tact 開關=脆手感+限位 |
| 合計 | ~700 元 | 約 NT$3,200 成品掌機的 22% |
🔌 接線圖
詳細接線
| ESP32-S3 接腳 | 連接 | 說明 |
|---|---|---|
| GPIO8 / 9 / 10 | ILI9341 RST / CS / DC | 避開關機 strapping(0/45/46)與 USB(19/20) |
| GPIO11 / 12 | ILI9341 MOSI / SCLK | SPI 40MHz;LED 腳直接並 3V3 常亮 |
| GPIO4 / 5 / 6 / 7 | 方向鍵 上/下/左/右(對 GND) | INPUT_PULLUP;微動共同端全部並 GND |
| GPIO15 / 16 | A / B(對 GND) | 同上 |
| GPIO17 / 18 | START / SELECT(對 GND) | SELECT 長按=回主選單(安全出口) |
| GPIO13 / 14 / 21 | MAX98357A BCLK / WS / DIN | I2S 方波合成 44.1kHz |
| 5V / GND | MT3608 升壓輸出 | 板電源端並 330µF;喇叭電流勿走板子 3V3 |
🖨️ 3D 列印設計
| 零件 | 材質/設定 | 數量 | 備註 |
|---|---|---|---|
| shell-front 前殼 | PLA/0.2mm/3 壁/25% | 1 | 按鍵壁柱加厚到 2.4mm——arcade 帽連打不疲勞裂;螢幕窗 4 邊各留 0.3mm 間隙 |
| shell-back 後殼 | PLA/0.2mm/2.5 壁/20% | 1 | 18650 電池槽+TP4056 檢修窗(Type-C 開口對準)+喇叭音孔陣 |
| d-pad 十字鍵 | PLA/0.24mm/3 壁/30% | 1 | 一體列印彈性十字;中心套微動柱塞,四臂各壓一顆 |
| button-cap 帽 | PLA/0.2mm/3 壁/40% | 4 | 圓弧蘑菇頭;內孔 7mm 套微動柱塞,點膠固定 |
| bezel 螢幕框 | PLA/0.2mm/2 壁/20% | 1 | 壓住排線面,兼防「拇指戳進屏縫」——四歲特攻 |
| cartridge-coin 卡帶幣 | PLA/0.28mm/1 壁/8%/無壁螺旋 | 6 | 30 分鐘印完的「遊戲卡帶」,每面寫一款遊戲名——儀式感的來源 |
列印總重 ~85g(不含卡帶幣)。前後殼以 4 顆 M2×8+2 顆銅柱鎖固,微動以熱熔銅螺母預埋壁柱——拆得開的手掌機纔是好手掌機。電池槽開口尺寸按實車 18650+殼縮水 0.2mm 修;殼內貼一層電工帶,防止電池銅箔磨到走線。
💻 Arduino 程式碼
Arduino IDE 2.x+espressif 3.x core(開發板 ESP32S3 Dev Module,PSRAM 選 OPI);圖庫僅 Arduino_GFX(moononournation),音效走 core 內建 i2s_std,存檔用 Preferences。遊戲引擎採「菜單+函數指針表」,加第五款遊戲=註冊一個新 Game 結構體。
/* ============================================================
* 復古街機掌機 arcade-handheld.ino
* ------------------------------------------------------------
* 功能:自研四遊戲引擎(方塊/蛇/磚/2048)、方波芯片音樂、
* 高分永存、NAS 週報、SELECT 長按安全出口
* 硬體:ESP32-S3 DevKitC-1|ILI9341: 8=RST 9=CS 10=DC 11=MOSI
* 12=SCK|按鍵: 4/5/6/7=十字 15=A 16=B 17=START 18=SEL
* MAX98357A: 13=BCLK 14=WS 21=DIN(SD 接 3V3)
* 供電:18650→TP4056→開關→MT3608 調 5.1V → 板 5V+330µF
* 相依:Arduino_GFX、core 3.x(i2s_std)、Preferences
* ============================================================ */
#include <Arduino_GFX_Library.h>
#include <WiFi.h>
#include <HTTPClient.h>
#include <Preferences.h>
#include "driver/i2s_std.h"
/* --- 0. 配置與接腳 --- */
const char* WIFI_SSID = "YOUR_SSID";
const char* WIFI_PASS = "YOUR_PASS";
const char* NAS_URL = "http://192.168.8.60:1880/arcade-hh";
const char* NAS_TOKEN = "change-me";
#define LCD_RST 8
#define LCD_CS 9
#define LCD_DC 10
#define LCD_MOSI 11
#define LCD_SCK 12
#define I2S_BCLK 13
#define I2S_WS 14
#define I2S_DOUT 21
const uint8_t BTN_PINS[8] = {4, 5, 6, 7, 15, 16, 17, 18};
enum { B_UP = 1, B_DOWN = 2, B_LEFT = 4, B_RIGHT = 8,
B_A = 16, B_B = 32, B_START = 64, B_SELECT = 128 };
#define SR 44100
#define W 320
#define H 240
/* --- 0.5 前向宣告:引擎↔遊戲互相引用(spawn 要回菜單、tick 要讀連打)--- */
uint8_t btnHeld = 0;
void gameMenu();
void menuDraw();
void beep(uint16_t f, uint16_t ms);
bool collide(int8_t x, int8_t y, uint16_t m);
void spawn();
void lockAndClear();
const char* GKEY[4] = {"tetris", "snake", "brick", "g2048"};
/* --- 1. 顯示層(直抽繪製,不養 framebuffer)---
* 爲什麼直抽:四款遊戲都是方塊級重繪,ILI9341@40MHz 局部刷新
* 已經 <3ms/幀,省下的 RAM 留給引擎與棋片表 */
Arduino_DataBus* bus = new Arduino_ESP32SPI(LCD_DC, LCD_CS, LCD_SCK, LCD_MOSI, GFX_NOT_DEFINED);
Arduino_GFX* gfx = new Arduino_ILI9341(bus, LCD_RST, 3 /*rotation=橫向*/);
uint16_t cBg = 0x0000, cFg = 0xFFFF, cAcc = 0x07E0, cBad = 0xF800;
/* drawCenter:開機字卡與分數都靠它,y 以行爲單位 */
void drawCenter(const char* s, int y, int sz, uint16_t c) {
gfx->setTextSize(sz); gfx->setTextColor(c);
int16_t w = strlen(s) * 6 * sz;
gfx->setCursor((W - w) / 2, y);
gfx->print(s);
}
/* --- 2. 方波芯片音樂:I2S 任務 50% duty 直出 ---
* 參數:f=頻率(0=靜音) dur=毫秒(由序列器喂)
* 爲什麼不用 DAC:方波纔是 1989 年的味道,而且省掉整張波形表 */
volatile uint16_t toneFreq = 0;
volatile int16_t toneLeft = 0; // 剩餘毫秒
i2s_chan_handle_t gI2s;
void audioTask(void*) {
static int16_t buf[512];
float ph = 0; size_t w; const uint32_t ms = 512 * 1000UL / SR;
for (;;) {
uint16_t f = toneFreq;
for (uint16_t i = 0; i < 512; i++) {
ph += f / (float)SR;
int16_t v = (f && (ph >= 0.5f)) ? 9000 : 0;
if (ph >= 1.0f) ph -= 1.0f;
buf[i * 2] = v; buf[i * 2 + 1] = v;
}
if (toneFreq && toneLeft > 0) {
toneLeft -= ms;
if (toneLeft <= 0) toneFreq = 0; // 音符到期:自然斷=方波的本味
}
i2s_channel_write(gI2s, buf, sizeof buf, &w, portMAX_DELAY);
}
}
void i2sInit() {
i2s_chan_config_t cc = I2S_CHANNEL_DEFAULT_CONFIG(I2S_NUM_0, I2S_ROLE_MASTER);
cc.dma_desc_num = 4; cc.dma_frame_num = 256;
i2s_new_channel(&cc, &gI2s, NULL);
i2s_std_config_t sc = {
.clk_cfg = I2S_STD_CLK_DEFAULT_CONFIG(SR),
.slot_cfg = I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG(I2S_DATA_BIT_WIDTH_16BIT, I2S_SLOT_MODE_STEREO),
.gpio_cfg = { .mclk = I2S_GPIO_UNUSED, .bclk = I2S_BCLK, .ws = I2S_WS,
.dout = I2S_DOUT, .din = I2S_GPIO_UNUSED },
};
i2s_channel_init_std_mode(gI2s, &sc);
i2s_channel_enable(gI2s);
}
void beep(uint16_t f, uint16_t ms) { toneFreq = f; toneLeft = ms; }
struct Note { uint16_t f, ms; };
const Note S_CLEAR[] = {{660,90},{880,90},{1170,140},{0,0}}; // 消行三連音
const Note S_OVER[] = {{440,160},{330,160},{220,300},{0,0}}; // game over
uint8_t seqIdx = 0xFF; uint32_t seqAt = 0;
const Note* curSeq = nullptr;
/* 序列器在 loop 裡喂——不 delay、不搶幀,消四行照樣順 */
void playSeq(const Note* n) { curSeq = n; seqIdx = 0; seqAt = 0; }
void seqTick(uint32_t m) {
if (!curSeq) return;
if (m < seqAt) return;
Note n = curSeq[seqIdx++];
if (!n.f && !n.ms) { curSeq = nullptr; return; }
beep(n.f, n.ms); seqAt = m + n.ms + 30; // 30ms 斷音=顆粒感
}
/* --- 3. 輸入:25ms 掃描+邊緣偵測+按住連發 ---
* 回傳:本掃描新增按下位元;arcade 微動抖 ~1.5ms,25ms 掃描天然吃掉 */
uint8_t bPrev = 0;
uint8_t btnPoll() {
uint8_t now = 0;
for (int i = 0; i < 8; i++) if (!digitalRead(BTN_PINS[i])) now |= (1 << i);
uint8_t edge = now & ~bPrev; bPrev = now;
return edge;
}
/* --- 4. 遊戲引擎:狀態機+函數指針表 ---
* 加新遊戲=註冊一個 Game;SELECT 長按回主選單=安全出口 */
struct Game { const char* name; void (*init)(); void (*tick)(uint32_t);
void (*btn)(uint8_t); };
int8_t menuSel = 0; uint8_t inGame = 0xFF;
Preferences prefs;
uint32_t hs[4] = {0}; uint32_t playMs[4] = {0};
/* --- 5. 俄羅斯方塊(10×20、格 8px)--- */
#define TW 10
#define TH 20
#define TS 8
uint8_t grid[TH][TW];
const uint16_t P[7][4] = {
{0x0660,0x0660,0x0660,0x0660}, // O
{0x0E40,0x4640,0x4E00,0x2620}, // T
{0x0F00,0x4444,0x0F00,0x4444}, // I
{0x2C00,0x4620,0x2C00,0x4620}, // S
{0xC600,0x2640,0xC600,0x2640}, // Z
{0x2E00,0x4460,0xE400,0x6440}, // L
{0x4E00,0x2260,0xE200,0x6220}, // J(注意與 T.rot2 區分:J 少一顆)
};
const uint16_t PCOL[7] = {0xFE00,0x881F,0x07FF,0x07E0,0xF81F,0xFD20,0xF81F};
int8_t pX, pY; uint8_t pId, pRot, nextId;
uint32_t fallAt; uint16_t score = 0, lines = 0;
bool collide(int8_t x, int8_t y, uint16_t m) {
for (int r = 0; r < 4; r++) for (int c = 0; c < 4; c++)
if (m & (0x8000 >> (r * 4 + c))) {
int gx = x + c, gy = y + r;
if (gx < 0 || gx >= TW || gy >= TH) return true;
if (gy >= 0 && grid[gy][gx]) return true;
}
return false;
}
void spawn() {
pId = nextId; nextId = random(7); pRot = 0; pX = 3; pY = -1;
if (collide(pX, pY + 1, P[pId][0])) { // 堆到頂=結束
playSeq(S_OVER);
if (score > hs[0]) { hs[0] = score; prefs.putUInt("tetris", hs[0]); }
gameMenu();
}
}
uint16_t scoreTab[5] = {0, 100, 300, 500, 800};
void lockAndClear() {
uint16_t m = P[pId][pRot];
for (int r = 0; r < 4; r++) for (int c = 0; c < 4; c++)
if ((m >> (15 - (r * 4 + c))) & 1 && pY + r >= 0) grid[pY + r][pX + c] = pId + 1;
int cleared = 0;
for (int r = TH - 1; r >= 0; r--) {
bool full = true;
for (int c = 0; c < TW; c++) if (!grid[r][c]) { full = false; break; }
if (full) { cleared++; for (int rr = r; rr > 0; rr--) memcpy(grid[rr], grid[rr-1], TW); r++; }
}
if (cleared) { score += scoreTab[cleared] * (lines/10 + 1); lines += cleared; beep(1200, 60); }
spawn();
}
void tetrisInit() {
memset(grid, 0, sizeof grid); score = 0; lines = 0; nextId = random(7); spawn();
fallAt = millis();
}
void tetrisTick(uint32_t m) {
int32_t iv = max(90, 500 - (int)(lines / 10) * 45);
if (btnHeld & B_DOWN) iv = 45; // 軟降:按住即加速
if (m >= fallAt) {
fallAt = m + iv;
if (!collide(pX, pY + 1, P[pId][pRot])) pY++;
else lockAndClear();
}
}
void tetrisBtn(uint8_t e) {
if (e & B_LEFT && !collide(pX-1, pY, P[pId][pRot])) pX--;
if (e & B_RIGHT && !collide(pX+1, pY, P[pId][pRot])) pX++;
if (e & B_DOWN) fallAt = 0;
uint8_t nr = (pRot + 1) & 3;
if ((e & B_A || e & B_B) && !collide(pX, pY, P[pId][nr])) { // 牆踢簡化:轉不了就跳過
pRot = nr;
if (collide(pX, pY, P[pId][pRot])) pRot = (pRot + 3) & 3;
}
}
void tetrisDraw() {
gfx->fillRect(0, 0, TW * TS + 2, H, 0x1082);
for (int r = 0; r < TH; r++) for (int c = 0; c < TW; c++) if (grid[r][c])
gfx->fillRect(c*TS+1, r*TS+1, TS-1, TS-1, PCOL[grid[r][c]-1]);
uint16_t m = P[pId][pRot];
for (int r = 0; r < 4; r++) for (int cc = 0; cc < 4; cc++)
if ((m >> (15 - (r*4+cc))) & 1 && pY+r >= 0)
gfx->fillRect((pX+cc)*TS+1, (pY+r)*TS+1, TS-1, TS-1, PCOL[pId]);
gfx->fillRect(TW*TS+4, 0, W-TW*TS-4, H, 0x0000);
gfx->setTextColor(cFg); gfx->setTextSize(1);
gfx->setCursor(TW*TS+12, 10); gfx->printf("SC %u\n", score);
gfx->setCursor(TW*TS+12, 26); gfx->printf("LN %u\n", lines);
gfx->setCursor(TW*TS+12, 42); gfx->printf("HS %u\n", hs[0]);
for (int r = 0; r < 2; r++) for (int c = 0; c < 4; c++)
if ((P[nextId][0] >> (11 - (r*4+c))) & 1)
gfx->fillRect(TW*TS+14 + c*6, 62 + r*6, 5, 5, PCOL[nextId]);
}
/* --- 6. 貪吃蛇(26×18 格、格 10px 內縮)--- */
#define SW 26
#define SH 18
int16_t snake[64], snLen = 4, sdx = 1, sdy = 0;
int8_t fx = 13, fy = 9; uint16_t sScore = 0; uint32_t sAt;
void snakeInit() {
snLen = 4; for (int i = 0; i < snLen; i++) snake[i] = 5 + i;
sdx = 1; sdy = 0; sScore = 0; sAt = millis();
}
void snakeTick(uint32_t m) {
if (m < sAt) return;
sAt = m + max(70, 150 - sScore * 2); // 越吃越快
int16_t hx = snake[0] % SW + sdx, hy = snake[0] / SW + sdy;
bool dead = hx < 0 || hy < 0 || hx >= SW || hy >= SH;
for (int i = 0; i < snLen && !dead; i++) if (snake[i] == hy * SW + hx) dead = true;
if (dead) { playSeq(S_OVER); if (sScore > hs[1]) { hs[1] = sScore; prefs.putUInt("snake", hs[1]); } gameMenu(); return; }
for (int i = snLen; i > 0; i--) snake[i] = snake[i-1]; // 整體後移(≤64 節,memcpy 級開銷)
snake[0] = hy * SW + hx;
if (hx == fx && hy == fy) {
if (snLen < 60) snLen++; // 64 節封頂=數組永不出界
sScore += 10; beep(1400, 40);
do { fx = random(SW); fy = random(SH); } // 食物不落蛇身
while ([&]{ for (int i = 0; i < snLen; i++) if (snake[i] == fy*SW+fx) return true; return false; }());
}
}
void snakeBtn(uint8_t e) { // 禁止 180° 自殺
if ((e & B_UP) && sdy != 1) { sdx = 0; sdy = -1; }
if ((e & B_DOWN) && sdy != -1){ sdx = 0; sdy = 1; }
if ((e & B_LEFT) && sdx != 1) { sdx = -1; sdy = 0; }
if ((e & B_RIGHT) && sdx != -1){ sdx = 1; sdy = 0; }
}
void snakeDraw() {
gfx->fillRect(0, 0, W, H, 0x0841);
for (int i = 0; i < snLen; i++)
gfx->fillRect(snake[i]%SW*12+2, snake[i]/SW*12+2, 11, 11, i ? 0x07E0 : 0xFFFF);
gfx->fillRect(fx*12+2, fy*12+2, 11, 11, cBad);
gfx->setTextColor(cFg); gfx->setTextSize(1); gfx->setCursor(4, 3); gfx->printf("SC %u HS %u", sScore, hs[1]);
}
/* --- 7. 打磚塊(球速逐階、擋板反彈角)--- */
int16_t bpx = 140, bpy = 226; // 擋板
float bxx = 160, byy = 180, vx = 2.2f, vy = -2.6f;
uint8_t brk[6][6]; // 磚陣用 [1..5][0..5],[0][*] 空給頂部餘量
uint16_t bScore = 0; uint32_t bLast;
void brickInit() {
memset(brk, 0, sizeof brk);
for (int r = 1; r < 6; r++) for (int c = 0; c < 6; c++) brk[r][c] = 1;
bxx = 160; byy = 180; vx = 2.2f; vy = -2.6f; bScore = 0; bLast = millis();
}
void brickTick(uint32_t m) {
uint32_t dt = m - bLast; bLast = m;
if (btnHeld & B_LEFT) bpx -= dt / 4; // 擋板速度=dt 無關幀率
if (btnHeld & B_RIGHT) bpx += dt / 4;
bpx = constrain(bpx, 20, W - 20);
bxx += vx * dt / 8; byy += vy * dt / 8;
if (bxx < 4 || bxx > W - 4) vx = -vx;
if (byy < 12) vy = -vy;
int bc = (int)((bxx - 10) / 50), br = (int)(byy / 22); // 撞磚
if (bc >= 0 && bc < 6 && br >= 1 && br < 6 && brk[br][bc]) {
brk[br][bc] = 0; vy = -vy; bScore += 7; beep(900, 30);
float sp = hypotf(vx, vy) * 1.02f; // 加速但封頂
float k = min(sp, 5.5f) / sp; vx *= k; vy *= k;
}
if (byy > bpy - 3 && byy < bpy + 5 && fabsf(bxx - bpx) < 22 && vy > 0) {
vy = -fabsf(vy); vx = (bxx - bpx) * 0.12f; beep(600, 25); // 打中邊緣=大角度
}
if (byy > H + 6) { // 掉球=結束
playSeq(S_OVER);
if (bScore > hs[2]) { hs[2] = bScore; prefs.putUInt("brick", hs[2]); }
gameMenu();
}
bool clear = true;
for (int r = 1; r < 6 && clear; r++) for (int c = 0; c < 6; c++) if (brk[r][c]) { clear = false; break; }
if (clear) brickInit(); // 全清=下一關重來(本作無下一關,致敬)
}
void brickBtn(uint8_t) {}
void brickDraw() {
gfx->fillScreen(0x0000);
const uint16_t col[5] = {0xF81F, 0xFD20, 0x07E0, 0x07FF, 0x881F};
for (int r = 1; r < 6; r++) for (int c = 0; c < 6; c++) if (brk[r][c])
gfx->fillRect(10 + c*50, r*22, 46, 18, col[r-1]);
gfx->fillRect(bpx - 20, bpy, 40, 6, cFg);
gfx->fillRect(bxx - 3, byy - 3, 6, 6, cBad);
gfx->setTextColor(cFg); gfx->setTextSize(1); gfx->setCursor(4, 3); gfx->printf("SC %u HS %u", bScore, hs[2]);
}
/* --- 8. 2048(4×4、滑動合併)--- */
uint16_t g4[4][4]; uint16_t gScore;
void g4Spawn() {
uint8_t n = 0; for (int r = 0; r < 4; r++) for (int c = 0; c < 4; c++) if (!g4[r][c]) n++;
if (!n) return;
uint8_t k = random(n) + 1;
for (int r = 0; r < 4; r++) for (int c = 0; c < 4; c++)
if (!g4[r][c] && --k == 0) { g4[r][c] = random(10) ? 2 : 4; return; }
}
void g4Init() { memset(g4, 0, sizeof g4); gScore = 0; g4Spawn(); g4Spawn(); }
/* 一行向左壓+合併;參數 row[4]|回傳:分數增量 */
uint16_t slideRow(uint16_t* row) {
uint16_t t[4] = {0}, s = 0, w = 0;
for (int i = 0; i < 4; i++) if (row[i]) t[w++] = row[i];
for (int i = 0; i + 1 < w; i++)
if (t[i] == t[i+1]) { t[i] *= 2; s += t[i]; for (int j = i+1; j+1 < w; j++) t[j] = t[j+1]; w--; }
memcpy(row, t, 8);
for (int i = w; i < 4; i++) row[i] = 0;
return s;
}
/* dir: 0左 1右 2上 3下——「取線→線向左壓→放回」一條式,轉置統一:
* 正向座標 F(r,c,dir)、反向對應 G(r,c,dir),四方向共用一份線處理邏輯 */
static inline uint8_t F(uint8_t r, uint8_t c, uint8_t d) { return d == 1 ? 3 - r : (d == 3 ? c : r); }
static inline uint8_t G(uint8_t r, uint8_t c, uint8_t d) { return d == 1 ? c : (d == 3 ? 3 - r : c); }
bool g4Move(int dir) {
uint16_t before[4][4]; memcpy(before, g4, sizeof g4);
for (int i = 0; i < 4; i++) {
uint16_t row[4];
for (int j = 0; j < 4; j++) row[j] = g4[F(i, j, dir)][G(i, j, dir)];
gScore += slideRow(row);
for (int j = 0; j < 4; j++) g4[F(i, j, dir)][G(i, j, dir)] = row[j];
}
if (memcmp(before, g4, sizeof g4)) { g4Spawn(); beep(700, 25); return true; }
return false;
}
bool g4Stuck() {
for (int r = 0; r < 4; r++) for (int c = 0; c < 4; c++) {
if (!g4[r][c]) return false;
if (c < 3 && g4[r][c] == g4[r][c+1]) return false;
if (r < 3 && g4[r][c] == g4[r+1][c]) return false;
}
return true;
}
void g4Tick(uint32_t) {}
void g4Btn(uint8_t e) {
bool moved = false;
if (e & B_LEFT) moved = g4Move(0);
if (e & B_RIGHT) moved = g4Move(1);
if (e & B_UP) moved = g4Move(2);
if (e & B_DOWN) moved = g4Move(3);
if (moved && g4Stuck()) {
playSeq(S_OVER);
if (gScore > hs[3]) { hs[3] = gScore; prefs.putUInt("g2048", hs[3]); }
gameMenu();
}
}
void g4Draw() {
gfx->fillScreen(0x2945);
for (int r = 0; r < 4; r++) for (int c = 0; c < 4; c++) {
gfx->fillRect(40 + c*62, 26 + r*52, 58, 48, 0x18E3);
if (g4[r][c]) {
uint8_t lv = 1; uint16_t v = g4[r][c]; while (v >>= 1) lv++;
gfx->fillRect(43 + c*62, 29 + r*52, 52, 42, PCOL[lv % 7]);
gfx->setTextColor(cFg); gfx->setTextSize(g4[r][c] > 512 ? 1 : 2);
gfx->setCursor(47 + c*62, 40 + r*52); gfx->printf("%u", g4[r][c]);
}
}
gfx->setTextColor(cFg); gfx->setTextSize(1);
gfx->setCursor(4, 5); gfx->printf("SC %u HS %u", gScore, hs[3]);
}
/* --- 9. 菜單與引擎調度 --- */
Game GAMES[4] = {
{"TETRIS", tetrisInit, tetrisTick, tetrisBtn},
{"SNAKE", snakeInit, snakeTick, snakeBtn},
{"BRICK", brickInit, brickTick, brickBtn},
{"2048", g4Init, g4Tick, g4Btn},
};
void gameMenu() { inGame = 0xFF; menuDraw(); }
void menuDraw() {
gfx->fillScreen(cBg);
drawCenter("ARCADE-HH", 30, 3, cAcc);
for (int i = 0; i < 4; i++) {
char ln[24]; snprintf(ln, sizeof ln, "%s%s", menuSel == i ? ">" : " ", GAMES[i].name);
gfx->setTextSize(2); gfx->setTextColor(menuSel == i ? cAcc : cFg);
gfx->setCursor(110, 84 + i * 26); gfx->print(ln);
}
drawCenter("START=GO SEL-hold=BACK", 205, 1, 0x4208);
}
uint32_t selHoldAt = 0;
void engine(uint32_t m) {
uint8_t e = btnPoll(); btnHeld = bPrev;
if (e & B_SELECT) selHoldAt = m; // 按下起算時刻
bool selHeld = (bPrev & B_SELECT) && selHoldAt && m - selHoldAt > 1200;
if (selHeld && inGame != 0xFF) { playSeq(S_OVER); gameMenu(); return; } // 長按 SELECT=逃生門
if (inGame == 0xFF) {
if (e & B_UP) { menuSel = (menuSel + 3) % 4; menuDraw(); }
if (e & B_DOWN) { menuSel = (menuSel + 1) % 4; menuDraw(); }
if (e & B_START) {
inGame = menuSel; GAMES[inGame].init();
gfx->fillScreen(cBg);
}
} else {
playMs[inGame] += 40;
GAMES[inGame].tick(m);
GAMES[inGame].btn(e);
if (inGame == 0) tetrisDraw();
if (inGame == 1) snakeDraw();
if (inGame == 2) brickDraw();
if (inGame == 3) g4Draw();
}
}
/* --- 10. NAS 週報:各遊戲最高分+時數(斷電不丟靠 Preferences)--- */
void pushWeekly() {
if (WiFi.status() != WL_CONNECTED) return;
HTTPClient h; h.begin(NAS_URL);
h.addHeader("Content-Type", "application/json");
h.addHeader("X-Device-Token", NAS_TOKEN);
String b = "{";
const char q = char(34);
const char* nm[4] = {"tetris", "snake", "brick", "g2048"};
for (int i = 0; i < 4; i++) {
b += q; b += nm[i]; b += q; b += ":{";
b += q; b += "hs"; b += q; b += ":" + String(hs[i]) + ",";
b += q; b += "h"; b += q; b += ":" + String(playMs[i] / 3600000.0f, 2);
b += "}"; b += (i < 3) ? "," : "";
}
b += "}";
h.POST(b); h.end();
}
/* --- 11. setup:開順序有講究——先屏後聲、先本地後聯網 --- */
void setup() {
Serial.begin(115200);
for (auto p : BTN_PINS) pinMode(p, INPUT_PULLUP);
prefs.begin("arcade");
for (int i = 0; i < 4; i++) hs[i] = prefs.getUInt(GKEY[i], 0);
randomSeed(esp_random());
gfx->begin(); gfx->fillScreen(cBg); drawCenter("BOOT", 110, 2, cFg);
i2sInit();
xTaskCreatePinnedToCore(audioTask, "audio", 3072, NULL, 5, NULL, 1);
WiFi.begin(WIFI_SSID, WIFI_PASS); // 開機連網不等待:斷網照玩
beep(880, 60); delay(70); beep(1320, 90); // 開機雙音=投幣聲
menuDraw();
}
/* --- 12. loop:40ms 邏輯拍(25fps),週報每週日 07:10 --- */
void loop() {
static uint32_t t40 = 0, tWk = 0;
uint32_t m = millis();
if (m - t40 >= 40) { t40 = m; engine(m); seqTick(m); }
if (m - tWk >= 7UL * 24 * 3600 * 1000) { tWk = m; pushWeekly(); }
}
gameMenu/collide/spawn/lockAndClear…)都已在段 0.5 集中前向宣告——如果你從文章裡複製片段重組,記得把這組宣告一起搬過去。② Arduino_GFX 構造參數以你安裝的版本頭檔爲準(2.x 起 Arduino_ESP32SPI(dc, cs, sck, mosi, miso) 順序固定,RST 在 Arduino_ILI9341 構造)。③ 棋片位圖表 P[7][4] 裡 J 的 rot2(0xE200)與 T 的 rot0(0xE400)只差一顆像素——改表時對照註釋逐一畫格子驗證,串臺了遊戲照跑,只是「長得像 T 的 J」。🏗️ 組裝步驟
- 列印後處理:熱熔銅螺母 M2 用烙鐵 180°C 垂直壓入前殼柱位(溢膠清乾淨);按鍵帽內孔試套微動柱塞,太緊就砂紙磨 0.2mm
- 螢幕先測:轉接板+Arduino_GFX 範例
demo_graphictest點亮——彩點、壞點、方向(rotation 3)全過再裝機 - 按鍵組裝:壁柱槽塞微動→微動下疊 tact 開關墊(限位+脆手感)→鎖固或熱熔膠定位;十字鍵四角剪平防磨殼
- 電源先調後接:MT3608 空載調 5.1V 萬用表複測,再焊輸出線;TP4056 充電電流電阻檢查(默認 1k=1.05A 對 2500mAh 剛好)
- 主板與功放裝機:排線避開喇叭磁扣;I2S 與 SPI 兩組線分兩側走,十字鍵機構正上方勿跨線
- 燒錄與音效驗收:菜單雙音、消行三連音、game over 三音都要有顆粒感;沒顆粒=被淡入淡出化的方波(檢查方波任務優先級)
- 手感驗收:2048 快速連打方向鍵 100 次不吃豆、不連發;方塊軟降按住順暢——不合格查 25ms 掃描與 tact 墊
- 續航驗收:滿充亮屏連打,4 小時 ±20% 內合理;掉電快查 18650 內阻與升壓靜態電流(換 6R8 電感側二極管)
🔧 問題排除
| 問題 | 原因 | 解決方法 |
|---|---|---|
| 白屏/黑屏無字 | DC/CS 接錯序,或 RST 懸空 | 先跑 graphictest 獨立驗證;RST 必接 GPIO8;LED 腳沒給 3V3 也會「有信號沒背光」 |
| 畫面上下左右顛倒 | rotation 參數與排線方向不配 | 構造第 3 參 rotation=3(橫向),再配合 gfx->setRotation 微調到與你握機方向一致 |
| 按鍵偶發吃豆 | 掃描週期內抖動被當第二次按下 | 確認走 btnPoll 25ms 邊緣偵測(不是 digitalRead 直讀);tact 墊沒裝=微動回彈抖翻倍 |
| 開機發燙 | MT3608 沒調壓直接 6.5V 灌入 | 拆下重調 5.1V;板子若已燙過,充電與 Wi-Fi 表現都會變差——換板比搶救划算 |
| 連打 2048 會漏方向 | 40ms 邏輯拍+邊緣偵測,快過 25ms 掃描的手速會被合併 | 把 loop 邏輯拍降到 25ms,或在 g4Btn 加 3 格環形緩衝暫存未消費事件 |
| 音效有齒音不純 | 音量 9000 削峰或喇叭功率不足 | 方波振幅降到 5000,或換 1W 以上喇叭;MAX98357A GAIN 腳懸空(6dB)即可,別接 GND |
| 高分隔週就丟 | 寫 Preferences 時機只在 game over | 本稿已如此;若你改過,記得掉電前(gameMenu 內)都要 putUInt,別隻在 setup 讀 |
| Wi-Fi 抓不到 5GHz | ESP32-S3 只有 2.4GHz 射頻 | 連 2.4G SSID;週報走內網 NAS 不吃帶寬,沒損失 |
🎯 進階延伸
- PC 街機手把模式:S3 原生 USB 加一顆 SPDT 切換(充電↔USB-OTG)+ArduinoJoystickLibrary(TinyUSB)——插上 PC 即 HID 手把,Steam 大亂鬥直接用它打,八鍵全映射
- 真·模擬器路線:N16R8 的 PSRAM 養得起 RetroGO/nes-esp——殼、鍵、喇叭全部沿用,本引擎只留菜單殼當 loader(卡帶版權自理)
- 第五款遊戲:引擎註冊一個
Game就上架——推箱子( Sokoban)或炸彈人迷宮生成,你兒子出題你寫碼,分工明確 - 振動反饋:1027 線性馬達+MOS 開關掛 GPIO 38,撞磚/消行震一下——「啪」的一下,情懷就完整了
- 雙人對戰:第二臺機器+ESP-NOW 對打俄羅斯方塊(互相推垃圾行),週末親子賽從此沒有 iPad
📦 檔案清單
arcade-handheld/
├── src/
│ └── arcade-handheld.ino # 掌機韌體(本頁完整程式)
├── nas/
│ └── node-red-flow.json # 週報接收/吃灰告警
├── stl/
│ ├── shell-front.stl # 前殼(按鍵壁柱 2.4mm)
│ ├── shell-back.stl # 後殼(電池槽+檢修窗)
│ ├── d-pad.stl # 彈性十字鍵
│ ├── button-cap.stl # 圓弧帽 ×4
│ ├── bezel.stl # 螢幕框
│ └── cartridge-coin.stl # 卡帶幣 ×6
├── f3d/
│ └── arcadehh-design.f3d # Fusion 360 原始檔
├── test/
│ └── graphictest.ino # 螢幕獨立點亮測試
└── manual/
└── assembly.html # 圖文組裝說明書