📋 專案概述

你三十六歲,蘆洲—臺北的捷運通勤單程四十二分鐘。某個加班後的末班車上,你又用三十四歲的拇指在手機上滑俄羅斯方塊——然後被「明早九點 meeting」的 LINE 彈窗當場處決:方塊停在三級高度,消四行的那條早就在等你抬頭。這一幕你已經演了三個月:手機模擬器每次開局都要先開飛航(不然微信通知會打斷渲染),觸屏十字鍵零手感、拇指出汗就滑出屏幕外,兩天新鮮感、三天放回抽屜。你翻出床底的 1998 年彩色 Game Boy:屏幕凹到看不見像素,電池匣的三號電池已經腐爛出白霜——童年遺物正式陣亡。買臺復古掌機吧:淘寶 NT$1,200 的「三千款合一」跑 GB 卡帶音效都破音;NT$3,200 的某安 B 牌要排隊搶、固件閉源,你真正想塞進去的那款你自己寫的遊戲根本裝不進。而你兒子今年四歲,已經開始對 iPad 上「黏答答的消水果」產生興趣——你心裡有個很小的聲音:他這個年紀,應該玩的是方塊和磚塊。

市售商品:

本計畫的哲學:與其花錢買一臺「別人的遊戲機」,不如花一個週末造一臺「你的遊戲機」。不跑模擬器、不碰任何卡帶 ROM——寫一個自己的微型遊戲引擎,裡面住着你親手刻的四款遊戲:

💰 成本效益:全機約 NT$700,約爲某安 B 牌入門款的一半、正版改機的三分之一。真正的產出不是掌機——是你兒子長大後記得「這臺是爸爸自己寫的遊戲、自己列印的殼」,以及你重新摸熟了一個自己寫的引擎,將來塞第五款遊戲進去的,可以是他。

爲什麼選 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 N16R81~230 元雙核+PSRAM+原生 USB,進階 HID 靠它
螢幕ILI9341 2.8 吋 SPI TFT 320×2401~120 元排針焊死前先用轉接板點亮——壞點三天內可退
按鍵圓弧頭微動開關(立貼式)8共 ~60 元十字 4+A/B+START/SELECT;直插式需加角碼
功放MAX98357A 3W I2S 模組1~60 元SD 直接接 3V3 常駐開聲
喇叭4Ω 2W 28mm1~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 8=RST 9=CS 10=DC 11=MOSI 12=SCK 13/14/21 I2S 4/5/6/7 方向鍵 15=A 16=B 17=START 18=SEL 5V←升壓 ILI9341 2.8 吋 CS←9 DC←10 RST←8 SCK←12 MOSI←11 LED→3V3 VCC→3V3 arcade 微動 ×8 4/5/6/7=方向十字 15=A 16=B 17=START 18=SELECT 共同端 → GND MAX98357A BCLK/WS/DIN ←13/14/21 SD→3V3 VIN 5V 4Ω 2W 喇叭 18650 電力 TP4056(Type-C)→MT3608 調 5.1V → 板 5V/GND +330µF 於板電源端 先調升壓 5.1V 再接機!6.5V 直灌=磚 SPI 線 ≤12cm;喇叭線與排針區隔一側走
▲ 全機訊號線 16 根——最貴的不是接線是電源:MT3608 出廠電位停在 6.5V 的事,每臺燒壞掌機的鬼故事都從它開始。

詳細接線

ESP32-S3 接腳連接說明
GPIO8 / 9 / 10ILI9341 RST / CS / DC避開關機 strapping(0/45/46)與 USB(19/20)
GPIO11 / 12ILI9341 MOSI / SCLKSPI 40MHz;LED 腳直接並 3V3 常亮
GPIO4 / 5 / 6 / 7方向鍵 上/下/左/右(對 GND)INPUT_PULLUP;微動共同端全部並 GND
GPIO15 / 16A / B(對 GND)同上
GPIO17 / 18START / SELECT(對 GND)SELECT 長按=回主選單(安全出口)
GPIO13 / 14 / 21MAX98357A BCLK / WS / DINI2S 方波合成 44.1kHz
5V / GNDMT3608 升壓輸出板電源端並 330µF;喇叭電流勿走板子 3V3
ESP32-S3 DevKitC-1 ILI9341 MAX98357A 電源 GPIO8(RST) ─────────── RST GPIO9(CS) ─────────── CS GPIO10(DC) ─────────── DC GPIO11(MOSI)─────────── SDA GPIO12(SCK) ─────────── SCL BCLK ←13 3V3 ──┬── LED/VCC WS ←14 VIN ← 5V(升壓) GPIO15..18 ←── A/B/START/SELECT DIN ←21 SD→3V3 GPIO4..7 ←── 方向十字(微動→GND) OUT± ── 4Ω 喇叭 GND ─────────────────────────────────────────── 共地單點 18650 → TP4056 → 開關 → MT3608(調5.1V) → 板 5V +330µF∥
⚠️ MT3608 出廠輸出 6~6.5V:先用萬用表量過、電位器調到 5.0~5.1V 才能接主板。直接灌 6.5V 的後果是板子當場發燙——這坑的門票是一塊 B 板的屍體和一下午的心情。

🖨️ 3D 列印設計

零件材質/設定數量備註
shell-front 前殼PLA/0.2mm/3 壁/25%1按鍵壁柱加厚到 2.4mm——arcade 帽連打不疲勞裂;螢幕窗 4 邊各留 0.3mm 間隙
shell-back 後殼PLA/0.2mm/2.5 壁/20%118650 電池槽+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%/無壁螺旋630 分鐘印完的「遊戲卡帶」,每面寫一款遊戲名——儀式感的來源

列印總重 ~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」。
📦 NAS 端(Node-RED):每週日 07:15 收週報 → Telegram「🎮 本週遊玩:方塊 1.1h(最高 4,200)、2048 0.8h(最高 512)——比上週少 40 分鐘,iPad 爭奪戰暫告一段落」;另加規則:週報裡方塊爲 0 且機器在線 168 小時——大概率它已經被你收進抽屜吃灰了。

🏗️ 組裝步驟

📘 圖文版組裝說明書:每一步附示意圖、預估時間與難度標記,可直接列印 A4 帶到工作檯 → 開啓 HTML 組裝說明書
  1. 列印後處理:熱熔銅螺母 M2 用烙鐵 180°C 垂直壓入前殼柱位(溢膠清乾淨);按鍵帽內孔試套微動柱塞,太緊就砂紙磨 0.2mm
  2. 螢幕先測:轉接板+Arduino_GFX 範例 demo_graphictest 點亮——彩點、壞點、方向(rotation 3)全過再裝機
  3. 按鍵組裝:壁柱槽塞微動→微動下疊 tact 開關墊(限位+脆手感)→鎖固或熱熔膠定位;十字鍵四角剪平防磨殼
  4. 電源先調後接:MT3608 空載調 5.1V 萬用表複測,再焊輸出線;TP4056 充電電流電阻檢查(默認 1k=1.05A 對 2500mAh 剛好)
  5. 主板與功放裝機:排線避開喇叭磁扣;I2S 與 SPI 兩組線分兩側走,十字鍵機構正上方勿跨線
  6. 燒錄與音效驗收:菜單雙音、消行三連音、game over 三音都要有顆粒感;沒顆粒=被淡入淡出化的方波(檢查方波任務優先級)
  7. 手感驗收:2048 快速連打方向鍵 100 次不吃豆、不連發;方塊軟降按住順暢——不合格查 25ms 掃描與 tact 墊
  8. 續航驗收:滿充亮屏連打,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 抓不到 5GHzESP32-S3 只有 2.4GHz 射頻連 2.4G SSID;週報走內網 NAS 不吃帶寬,沒損失

🎯 進階延伸

📦 檔案清單

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           # 圖文組裝說明書
← 上一篇:ESP32-S3 白噪音睡眠聲機 📚 系列索引 下一篇:ESP32-C3 老冷氣紅外學習中控 →