// ---------------------------------------------------------------------------
// Pacman_Reel -- ONE static, arcade-accurate Pac-Man frame on 4-color e-paper
//
// Panel : GDEM0154F51H (200x200, black/white/red/yellow, controller JD79660)
// Board : ESP32-C3
//
// The scene is built once; loop() pushes that same image to the panel again
// every PUSH_INTERVAL_MS (5 s). Nothing on screen changes between pushes --
// it is the identical frame, re-driven.
//
// WHAT THAT ACTUALLY LOOKS LIKE. A 4-color Spectra refresh is a long
// multi-pass waveform (~12-20 s) that shuffles the red and yellow pigment,
// so the panel spends most of its time mid-refresh, strobing through the
// colour passes, and the real cycle is (refresh + 5 s), not 5 s. The wait is
// measured and printed each pass. If what you want is a still frame to
// photograph, the panel is bistable and holds the image with no power at all:
// set PUSH_ONCE below and it draws one frame and hibernates instead.
//
// WHAT IS ON SCREEN
//
// The real arcade playfield, tile for tile: 28 x 31 tiles of the original
// Pac-Man maze (the exact map, all 240 pellets + 4 energizers), drawn at
// 6 px per tile = 168 x 186 px, bottom-aligned, with the score row in the
// 14 px strip above it. Arcade tiles are 8 px, so this is the same maze at
// 3/4 scale; sprites are 11 px (arcade sprite cells are 16 px with ~13 px
// of ink, so ~10 px here).
//
// COLOR SUBSTITUTION. The panel has black/white/red/yellow only, so every
// arcade color that does not exist maps to the nearest one that does:
//
// background black -> black (exact)
// maze walls blue -> white (no blue; white keeps the contrast)
// pellets peach -> yellow (reads as the classic dot)
// Pac-Man yellow -> yellow (exact)
// Blinky red -> red (exact)
// Pinky pink -> red (nearest)
// Inky cyan -> white (nearest; its eyes are inverted)
// Clyde orange -> yellow (nearest)
// house door pink -> red (nearest)
//
// THE MOMENT. Not the level-start pose: a real mid-game frame. Pac-Man has
// run the route right along the row-23 corridor, down the right side and
// back along the bottom row; every pellet on that route is gone and the
// score in the HUD is computed from exactly those pellets (10 each), so the
// number on screen matches the board. Blinky is four tiles behind him in the
// bottom corridor, Pinky is cutting him off one corridor up, Inky is coming
// down the left side, Clyde has just left the ghost house.
// ---------------------------------------------------------------------------
#include <GxEPD2_4C.h>
#include <epd4c/GxEPD2_154c_GDEM0154F51H.h>
#include <math.h>
// Pin definitions (e-paper) -- same wiring as Epaper_4Color.ino
#define EPD_MOSI 7
#define EPD_SCK 6
#define EPD_CS 10
#define EPD_DC 3
#define EPD_RST 20
#define EPD_BUSY 4
GxEPD2_4C<GxEPD2_154c_GDEM0154F51H, GxEPD2_154c_GDEM0154F51H::HEIGHT>
display(GxEPD2_154c_GDEM0154F51H(EPD_CS, EPD_DC, EPD_RST, EPD_BUSY));
// ---------------------------------------------------------------------------
// Geometry
// ---------------------------------------------------------------------------
static const int16_t SCR_W = 200;
static const int16_t SCR_H = 200;
static const int16_t COLS = 28;
static const int16_t ROWS = 31;
static const int16_t TILE = 6; // arcade tile is 8 px
static const int16_t OX = (SCR_W - COLS * TILE) / 2; // 16
static const int16_t OY = SCR_H - ROWS * TILE; // 14, maze sits low
// Wall line weight. The arcade draws walls as 2 px outlines on an 8 px tile,
// i.e. 1.5 px here. 1 is the faithful one: blocks stay hollow and the outer
// border comes out as the arcade's double line. Raise to 2 for a bolder maze
// if single pixels come out too faint on your panel (the double line then
// closes up into one solid band).
static const int16_t WALL_TH = 1;
static const uint32_t HIGH_SCORE = 31160; // the arcade shows a stored high score
// Gap between pushes, measured from the end of one refresh to the start of the
// next. The refresh itself is ~12-20 s on top of this.
static const uint32_t PUSH_INTERVAL_MS = 5000;
// Set true to go back to one frame and hibernate (the panel holds the image
// with no power, so it stays on screen and never flashes).
static const bool PUSH_ONCE = false;
// ---------------------------------------------------------------------------
// The maze
//
// The original Pac-Man map, 28 x 31 tiles.
// '#' wall '.' pellet 'o' energizer '-' ghost-house door ' ' floor
// Mutable: the route walk below eats the pellets Pac-Man has already taken.
// ---------------------------------------------------------------------------
static char g_maze[ROWS][COLS + 1] = {
"############################", // 0
"#............##............#", // 1
"#.####.#####.##.#####.####.#", // 2
"#o####.#####.##.#####.####o#", // 3 energizers
"#.####.#####.##.#####.####.#", // 4
"#..........................#", // 5
"#.####.##.########.##.####.#", // 6
"#.####.##.########.##.####.#", // 7
"#......##....##....##......#", // 8
"######.##### ## #####.######", // 9
" #.##### ## #####.# ", // 10
" #.## ##.# ", // 11 the perch above the house
" #.## ###--### ##.# ", // 12 ghost-house door
"######.## # # ##.######", // 13
" # # ", // 14 tunnel row, open at both edges
"######.## # # ##.######", // 15
" #.## ######## ##.# ", // 16
" #.## ##.# ", // 17
" #.## ######## ##.# ", // 18
"######.## ######## ##.######", // 19
"#............##............#", // 20
"#.####.#####.##.#####.####.#", // 21
"#.####.#####.##.#####.####.#", // 22
"#o..##................##..o#", // 23 energizers, Pac-Man starts here
"###.##.##.########.##.##.###", // 24
"###.##.##.########.##.##.###", // 25
"#......##....##....##......#", // 26
"#.##########.##.##########.#", // 27
"#.##########.##.##########.#", // 28
"#..........................#", // 29
"############################", // 30
};
// Off-grid counts as floor so the outer border also gets its outside line --
// the arcade border is a double line. The exception is the two rows bounding
// the tunnel: there the corridor runs off the screen edge and must stay open.
static bool isWall(int16_t r, int16_t c)
{
if (r < 0 || r >= ROWS) return false;
if (c < 0 || c >= COLS) return (r == 13 || r == 15);
return g_maze[r][c] == '#';
}
static inline int16_t tileX(int16_t c) { return OX + c * TILE; }
static inline int16_t tileY(int16_t r) { return OY + r * TILE; }
// Pixel centre of a point given in tile units, where a whole number is a tile
// EDGE: tile (c,r) centres on (c + 0.5, r + 0.5) and a sprite sitting between
// two columns centres on a whole number -- the convention the arcade uses.
static inline int16_t spriteX(float c) { return OX + (int16_t)lroundf(c * TILE); }
static inline int16_t spriteY(float r) { return OY + (int16_t)lroundf(r * TILE); }
// ---------------------------------------------------------------------------
// Where everything is
//
// The route Pac-Man has already run, in tile coordinates. Every pellet on it
// is eaten and scores 10 (an energizer would score 50); he ends on the last
// waypoint, still heading left.
// ---------------------------------------------------------------------------
struct Tile { int8_t c, r; };
static const Tile ROUTE[] = {
{ 13, 23 }, // start, between the two centre columns of the bottom half
{ 21, 23 }, // right along the long corridor
{ 21, 26 }, // down the right side
{ 26, 26 },
{ 26, 29 },
{ 10, 29 }, // back left along the bottom row -- he is here now
};
static const uint8_t ROUTE_N = sizeof(ROUTE) / sizeof(ROUTE[0]);
static const float PAC_C = 10.5f, PAC_R = 29.5f; // head of the route
struct GhostPos { float c, r; uint16_t color; int8_t lookX, lookY; };
static const GhostPos GHOSTS[4] = {
{ 14.5f, 29.5f, GxEPD_RED, -1, 0 }, // Blinky, right behind him
{ 9.5f, 26.5f, GxEPD_RED, -1, 0 }, // Pinky, cutting him off above
{ 6.5f, 20.5f, GxEPD_WHITE, 0, 1 }, // Inky, coming down the left
{ 14.0f, 11.5f, GxEPD_YELLOW, -1, 0 }, // Clyde, just out of the house
};
static uint32_t eatRoute()
{
uint32_t score = 0;
int8_t c = ROUTE[0].c, r = ROUTE[0].r;
for (uint8_t i = 0; i < ROUTE_N; i++) {
const int8_t tc = ROUTE[i].c, tr = ROUTE[i].r;
const int8_t dc = (tc > c) - (tc < c);
const int8_t dr = (tr > r) - (tr < r);
for (;;) {
char& t = g_maze[r][c];
if (t == '.') { t = ' '; score += 10; }
else if (t == 'o') { t = ' '; score += 50; }
if (c == tc && r == tr) break;
c += dc;
r += dr;
}
}
return score;
}
// ---------------------------------------------------------------------------
// Sprites
// ---------------------------------------------------------------------------
// Yellow disc with a wedge bitten out, facing dirDeg (180 = left; screen y
// grows downwards). No outline -- the arcade sprite has none.
static void drawPacman(int16_t cx, int16_t cy, int16_t r, float dirDeg, float halfDeg)
{
display.fillCircle(cx, cy, r, GxEPD_YELLOW);
if (halfDeg <= 0.0f) return;
const float a = dirDeg * (float)PI / 180.0f;
const float h = halfDeg * (float)PI / 180.0f;
const float R = r + 2.0f; // overshoot so the mouth cuts clean through
display.fillTriangle(cx, cy,
cx + (int16_t)(R * cosf(a - h)), cy + (int16_t)(R * sinf(a - h)),
cx + (int16_t)(R * cosf(a + h)), cy + (int16_t)(R * sinf(a + h)),
GxEPD_BLACK);
}
// 11 x 11: domed head, square body, the three-footed skirt, and eyes that look
// where the ghost is heading. Corridors are 6 px and sprites are 11, so a ghost
// overlaps the walls it passes -- the arcade does exactly the same (16 px
// sprites in 8 px corridors). The white-bodied ghost would dissolve into the
// white walls, so it gets a one-pixel black halo and inverted eyes.
static void drawGhost(int16_t cx, int16_t cy, uint16_t body, int8_t lookX, int8_t lookY)
{
const int16_t x = cx - 5, y = cy - 5;
if (body == GxEPD_WHITE) {
display.fillCircle(cx, y + 5, 6, GxEPD_BLACK);
display.fillRect(x - 1, y + 5, 13, 7, GxEPD_BLACK);
}
display.fillCircle(cx, y + 5, 5, body);
display.fillRect(x, y + 5, 11, 6, body);
// The dome's own bottom row would fill these, so punch the skirt back out.
display.fillRect(x + 3, y + 9, 1, 2, GxEPD_BLACK);
display.fillRect(x + 7, y + 9, 1, 2, GxEPD_BLACK);
const uint16_t sclera = (body == GxEPD_WHITE) ? GxEPD_BLACK : GxEPD_WHITE;
const uint16_t pupil = (body == GxEPD_WHITE) ? GxEPD_WHITE : GxEPD_BLACK;
const int16_t px = (lookX > 0) ? 1 : 0;
const int16_t py = (lookY > 0) ? 1 : 0;
display.fillRect(x + 2, y + 3, 3, 4, sclera);
display.fillRect(x + 6, y + 3, 3, 4, sclera);
display.fillRect(x + 2 + px, y + 4 + py, 2, 2, pupil);
display.fillRect(x + 6 + px, y + 4 + py, 2, 2, pupil);
}
// Level-1 fruit. Its stem is green in the arcade; yellow is the nearest here.
static void drawCherry(int16_t cx, int16_t cy)
{
display.fillCircle(cx - 3, cy + 2, 3, GxEPD_RED);
display.fillCircle(cx + 2, cy + 3, 3, GxEPD_RED);
display.drawPixel(cx - 4, cy + 1, GxEPD_WHITE);
display.drawPixel(cx + 1, cy + 2, GxEPD_WHITE);
display.drawLine(cx - 3, cy - 1, cx + 1, cy - 5, GxEPD_YELLOW);
display.drawLine(cx + 2, cy, cx + 1, cy - 5, GxEPD_YELLOW);
}
// ---------------------------------------------------------------------------
// Maze rendering
//
// Arcade walls are outlines, not slabs: a wall tile only gets a line on the
// sides that face open floor. A two-tile-thick block therefore comes out
// hollow, and the one-tile outer border comes out as the classic double line.
// ---------------------------------------------------------------------------
static void drawMaze()
{
for (int16_t r = 0; r < ROWS; r++) {
for (int16_t c = 0; c < COLS; c++) {
const char t = g_maze[r][c];
const int16_t x = tileX(c), y = tileY(r);
if (t == '-') { // ghost-house door
display.fillRect(x, y + TILE / 2 - 1, TILE, 2, GxEPD_RED);
continue;
}
if (t != '#') continue;
if (!isWall(r - 1, c))
display.fillRect(x, y + 1, TILE, WALL_TH, GxEPD_WHITE);
if (!isWall(r + 1, c))
display.fillRect(x, y + TILE - 1 - WALL_TH, TILE, WALL_TH, GxEPD_WHITE);
if (!isWall(r, c - 1))
display.fillRect(x + 1, y, WALL_TH, TILE, GxEPD_WHITE);
if (!isWall(r, c + 1))
display.fillRect(x + TILE - 1 - WALL_TH, y, WALL_TH, TILE, GxEPD_WHITE);
}
}
}
static void drawPellets()
{
for (int16_t r = 0; r < ROWS; r++) {
for (int16_t c = 0; c < COLS; c++) {
const char t = g_maze[r][c];
const int16_t x = tileX(c), y = tileY(r);
if (t == '.') {
display.fillRect(x + 2, y + 2, 2, 2, GxEPD_YELLOW);
} else if (t == 'o') {
display.fillCircle(x + TILE / 2, y + TILE / 2, 3, GxEPD_YELLOW);
}
}
}
}
// Score row above the maze, plus lives and fruit. The arcade puts those two on
// a row below the maze; a square panel has no room for one, so they go in the
// side margins that the portrait playfield leaves free.
static void drawHud(uint32_t score)
{
char buf[12];
display.setTextSize(1); // built-in 6x8 font, closest to the arcade one
display.setTextColor(GxEPD_WHITE);
display.setCursor(OX, 3);
display.print("1UP");
sprintf(buf, "%lu", (unsigned long)score);
display.setCursor(64 - (int16_t)strlen(buf) * 6, 3);
display.print(buf);
display.setCursor(70, 3);
display.print("HIGH SCORE");
sprintf(buf, "%lu", (unsigned long)HIGH_SCORE);
display.setCursor(SCR_W - OX - (int16_t)strlen(buf) * 6, 3);
display.print(buf);
drawPacman(7, 174, 5, 180.0f, 40.0f); // two lives still in hand
drawPacman(7, 188, 5, 180.0f, 40.0f);
drawCherry(192, 186);
}
static void drawScene(uint32_t score)
{
display.fillScreen(GxEPD_BLACK);
drawMaze();
drawPellets();
drawHud(score);
for (uint8_t i = 0; i < 4; i++) {
const GhostPos& g = GHOSTS[i];
drawGhost(spriteX(g.c), spriteY(g.r), g.color, g.lookX, g.lookY);
}
drawPacman(spriteX(PAC_C), spriteY(PAC_R), 5, 180.0f, 40.0f);
}
// ---------------------------------------------------------------------------
// Setup / loop
// ---------------------------------------------------------------------------
// The eaten pellets and the score they add up to are worked out once, in
// setup(): eatRoute() clears the pellets it counts, so a second call would
// find an already-eaten board and return 0. Every push after that re-draws
// this same board with this same score.
static uint32_t g_score = 0;
static uint32_t g_pushes = 0;
static void pushFrame()
{
const uint32_t started = millis();
display.setFullWindow();
display.firstPage();
do {
drawScene(g_score);
} while (display.nextPage());
g_pushes++;
Serial.printf("push %lu : refresh %lu ms\n",
(unsigned long)g_pushes, (unsigned long)(millis() - started));
}
void setup()
{
Serial.begin(115200);
delay(2000);
Serial.println();
Serial.println("Pac-Man : GDEM0154F51H 4-color");
// E-paper uses SCK + MOSI only (no MISO).
SPI.begin(EPD_SCK, -1, EPD_MOSI, -1);
display.init(115200, true, 10, false);
display.setRotation(0);
g_score = eatRoute();
Serial.printf("score on screen: %lu\n", (unsigned long)g_score);
if (PUSH_ONCE) {
pushFrame();
display.hibernate(); // bistable: the image stays put with no power
Serial.println("frame held - hibernating, nothing further will be drawn");
return;
}
Serial.printf("pushing the same frame every %lu ms + refresh time\n",
(unsigned long)PUSH_INTERVAL_MS);
}
void loop()
{
if (PUSH_ONCE) { delay(1000); return; }
pushFrame();
delay(PUSH_INTERVAL_MS);
}
Comments