feat(examples): Pico Doom — Wolf3D-style raycaster on RP2040 + ILI9341
A new entry in the games category for the Raspberry Pi Pico. Renders
a first-person 3D corridor à la Wolfenstein / early Doom using a
column-wise DDA raycaster — 160 rays per frame drawn straight to a
320×240 ILI9341 TFT via drawFastVLine, no framebuffer. 16×16 tile map
with 5 wall palettes (slate, blood, brown, toxic green, bronze door),
darkened on NS faces so corners read in 3D. Forward / back move,
two more buttons turn the player. 40-px HUD bar.
Why a demo, not the canonical id Software Doom: Graham Sanderson's
rp2040-doom port shoehorns DOOM1.WAD into 2 MB of flash with custom
compression and pushes video out over PIO-driven DVI / VGA — none of
that survives the rp2040js emulator (no PIO accuracy, no flash
mapping for huge assets). A raycaster reproduces the *visual* of
early Doom using only ~67 KB of flash and 9 KB of RAM, which the
emulator runs perfectly.
Pre-flight: arduino-cli compile against rp2040:rp2040:rpipico
already verified inside the Velxio backend container — 3 % flash,
3 % RAM. The Adafruit_GFX + Adafruit_ILI9341 libs the example
declares are already in the gallery's auto-install list.
Bundled:
test/pico_doom_demo/arduino_sketch.ino — source of truth sketch
test/pico_doom_demo/README.md — what + why + pin map
test/pico_doom_demo/compile_check.sh — operator script that
runs arduino-cli against the same FQBN the prod backend uses
frontend/src/data/examples.ts — gallery entry (boardType
raspberry-pi-pico, category games, difficulty advanced, 5
components, 14 wires)
frontend/src/__tests__/examples-pico-doom.test.ts — 10 vitest
assertions: example is registered exactly once, target board /
category / difficulty match, libraries declared, all four
pushbuttons present, every wire endpoint references a real
component id, SPI pin mapping matches the sketch's #define
block, every button has a GND wire, the renderFrame loop is
still present in the embedded code.
This commit is contained in:
parent
a4893a0da7
commit
5742ed0146
|
|
@ -0,0 +1,151 @@
|
|||
/**
|
||||
* Pico Doom example — data integrity test.
|
||||
*
|
||||
* The runtime smoke test (does the sketch actually compile and run on
|
||||
* the rp2040js emulator?) lives at `test/pico_doom_demo/compile_check.sh`
|
||||
* — it needs arduino-cli + the rp2040 core, which our vitest CI image
|
||||
* doesn't ship. This file just guarantees the example we surface in the
|
||||
* /examples gallery is structurally valid: declared once, target board
|
||||
* correct, libraries listed, every wire endpoint refers to a component
|
||||
* that exists.
|
||||
*
|
||||
* If you change pin assignments in the sketch (e.g. move CS off GP17),
|
||||
* update the WIRE_EXPECTATIONS map below in the same commit.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { exampleProjects } from '../data/examples';
|
||||
|
||||
const EXAMPLE_ID = 'pico-doom-raycaster';
|
||||
|
||||
function findExample() {
|
||||
return exampleProjects.find((e) => e.id === EXAMPLE_ID);
|
||||
}
|
||||
|
||||
describe('pico-doom-raycaster example', () => {
|
||||
it('is registered exactly once in the exampleProjects list', () => {
|
||||
const matches = exampleProjects.filter((e) => e.id === EXAMPLE_ID);
|
||||
expect(matches.length).toBe(1);
|
||||
});
|
||||
|
||||
it('targets the Raspberry Pi Pico in the games category at advanced difficulty', () => {
|
||||
const e = findExample()!;
|
||||
expect(e.boardType).toBe('raspberry-pi-pico');
|
||||
expect(e.category).toBe('games');
|
||||
expect(e.difficulty).toBe('advanced');
|
||||
});
|
||||
|
||||
it('declares the two Adafruit libraries the sketch needs', () => {
|
||||
const e = findExample()!;
|
||||
expect(e.libraries).toEqual(
|
||||
expect.arrayContaining(['Adafruit GFX Library', 'Adafruit ILI9341']),
|
||||
);
|
||||
});
|
||||
|
||||
it('lists one ILI9341 plus exactly four pushbuttons', () => {
|
||||
const e = findExample()!;
|
||||
const tftCount = e.components.filter((c) => c.type === 'wokwi-ili9341').length;
|
||||
const btnCount = e.components.filter((c) => c.type === 'wokwi-pushbutton').length;
|
||||
expect(tftCount).toBe(1);
|
||||
expect(btnCount).toBe(4);
|
||||
});
|
||||
|
||||
it('exposes button ids fwd / back / left / right', () => {
|
||||
const e = findExample()!;
|
||||
const ids = e.components.map((c) => c.id);
|
||||
expect(ids).toEqual(
|
||||
expect.arrayContaining(['btn-fwd', 'btn-back', 'btn-left', 'btn-right']),
|
||||
);
|
||||
});
|
||||
|
||||
it('every wire endpoint references a real component (board id is implicit)', () => {
|
||||
const e = findExample()!;
|
||||
const knownIds = new Set<string>([
|
||||
'raspberry-pi-pico', // implicit board instance id
|
||||
...e.components.map((c) => c.id),
|
||||
]);
|
||||
const offenders: string[] = [];
|
||||
for (const w of e.wires) {
|
||||
if (!knownIds.has(w.start.componentId)) offenders.push(`${w.id}:start=${w.start.componentId}`);
|
||||
if (!knownIds.has(w.end.componentId)) offenders.push(`${w.id}:end=${w.end.componentId}`);
|
||||
}
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it('SPI wires connect the Pico GP18/GP19/GP17/GP20/GP21/GP22 to the ILI9341 pins the sketch expects', () => {
|
||||
const e = findExample()!;
|
||||
// (pico-pin, ili9341-pin) pairs the sketch hard-codes. If any of these
|
||||
// get re-routed in the example, the sketch's #define block has to
|
||||
// match — fail loud here so the mismatch can't slip into prod.
|
||||
const expected: Array<[string, string]> = [
|
||||
['GP18', 'SCK'],
|
||||
['GP19', 'MOSI'],
|
||||
['GP17', 'CS'],
|
||||
['GP20', 'D/C'],
|
||||
['GP21', 'RST'],
|
||||
['GP22', 'LED'],
|
||||
];
|
||||
for (const [picoPin, tftPin] of expected) {
|
||||
const match = e.wires.find(
|
||||
(w) =>
|
||||
w.start.componentId === 'raspberry-pi-pico' &&
|
||||
w.start.pinName === picoPin &&
|
||||
w.end.componentId === 'tft1' &&
|
||||
w.end.pinName === tftPin,
|
||||
);
|
||||
expect(match, `missing wire ${picoPin} → tft1.${tftPin}`).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it('each button signal goes to the GP10-13 pin its sketch label expects', () => {
|
||||
const e = findExample()!;
|
||||
const expected: Record<string, string> = {
|
||||
'btn-fwd': 'GP10',
|
||||
'btn-back': 'GP11',
|
||||
'btn-left': 'GP12',
|
||||
'btn-right': 'GP13',
|
||||
};
|
||||
for (const [btnId, picoPin] of Object.entries(expected)) {
|
||||
const match = e.wires.find(
|
||||
(w) =>
|
||||
w.start.componentId === 'raspberry-pi-pico' &&
|
||||
w.start.pinName === picoPin &&
|
||||
w.end.componentId === btnId &&
|
||||
w.end.pinName === '1.l',
|
||||
);
|
||||
expect(match, `missing signal wire ${picoPin} → ${btnId}.1.l`).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it('every button has the second terminal tied to a Pico GND pin', () => {
|
||||
const e = findExample()!;
|
||||
for (const btn of ['btn-fwd', 'btn-back', 'btn-left', 'btn-right']) {
|
||||
const gnd = e.wires.find(
|
||||
(w) =>
|
||||
w.start.componentId === btn &&
|
||||
w.start.pinName === '2.l' &&
|
||||
w.end.componentId === 'raspberry-pi-pico' &&
|
||||
/^GND/.test(w.end.pinName),
|
||||
);
|
||||
expect(gnd, `missing GND wire for ${btn}`).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it('embedded sketch keeps the SPI pin defines aligned with the wiring above', () => {
|
||||
const e = findExample()!;
|
||||
// Source-of-truth grep — if the constants drift in the sketch, this
|
||||
// catches it before the example loads broken on real users.
|
||||
expect(e.code).toMatch(/#define\s+TFT_CS\s+17\b/);
|
||||
expect(e.code).toMatch(/#define\s+TFT_DC\s+20\b/);
|
||||
expect(e.code).toMatch(/#define\s+TFT_RST\s+21\b/);
|
||||
expect(e.code).toMatch(/#define\s+TFT_LED\s+22\b/);
|
||||
expect(e.code).toMatch(/#define\s+BTN_FWD\s+10\b/);
|
||||
expect(e.code).toMatch(/#define\s+BTN_BACK\s+11\b/);
|
||||
expect(e.code).toMatch(/#define\s+BTN_LEFT\s+12\b/);
|
||||
expect(e.code).toMatch(/#define\s+BTN_RIGHT\s+13\b/);
|
||||
// Sanity: the sketch must keep the renderFrame DDA loop (the visual
|
||||
// payoff of the example). If a future "minify" removes it the example
|
||||
// becomes a black screen.
|
||||
expect(e.code).toMatch(/renderFrame\s*\(\s*\)/);
|
||||
expect(e.code).toMatch(/drawFastVLine/);
|
||||
});
|
||||
});
|
||||
|
|
@ -737,6 +737,243 @@ void loop() {
|
|||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'pico-doom-raycaster',
|
||||
title: 'Pico Doom — Raycaster Demo',
|
||||
description:
|
||||
'Wolfenstein / early-Doom-style first-person 3D corridor on a Pi Pico + ILI9341 TFT. DDA raycasting at 160 rays/frame, no framebuffer (columns drawn straight to the TFT via drawFastVLine). Forward/back move, two more buttons turn the player. 16×16 tile map with 5 wall palettes (slate, blood, brown, toxic green, bronze door). The full id Software Doom needs the WAD assets shoehorned into 2 MB of flash with custom compression that the emulator cannot reproduce — this is the visual demo the Pico hardware actually runs in real life.',
|
||||
libraries: ['Adafruit GFX Library', 'Adafruit ILI9341'],
|
||||
category: 'games',
|
||||
difficulty: 'advanced',
|
||||
boardType: 'raspberry-pi-pico',
|
||||
tags: ['pico', 'rp2040', 'doom', 'raycaster', 'tft', 'ili9341', '3d', 'game'],
|
||||
code: `/*
|
||||
* Pico Doom — A Wolf3D-style raycaster running on Raspberry Pi Pico.
|
||||
*
|
||||
* Hardware
|
||||
* Raspberry Pi Pico (RP2040) + ILI9341 SPI TFT (320x240, landscape)
|
||||
* 4 pushbuttons: forward, backward, turn left, turn right
|
||||
*
|
||||
* Pins
|
||||
* SPI0: SCK=GP18 MOSI=GP19 CS=GP17 DC=GP20 RST=GP21 LED=GP22
|
||||
* Buttons (active LOW, INPUT_PULLUP):
|
||||
* FWD=GP10 BACK=GP11 LEFT=GP12 RIGHT=GP13
|
||||
*/
|
||||
|
||||
#include <SPI.h>
|
||||
#include <Adafruit_GFX.h>
|
||||
#include <Adafruit_ILI9341.h>
|
||||
#include <math.h>
|
||||
|
||||
#define TFT_CS 17
|
||||
#define TFT_DC 20
|
||||
#define TFT_RST 21
|
||||
#define TFT_LED 22
|
||||
#define BTN_FWD 10
|
||||
#define BTN_BACK 11
|
||||
#define BTN_LEFT 12
|
||||
#define BTN_RIGHT 13
|
||||
|
||||
Adafruit_ILI9341 tft(TFT_CS, TFT_DC, TFT_RST);
|
||||
|
||||
#define MAP_W 16
|
||||
#define MAP_H 16
|
||||
const uint8_t worldMap[MAP_H][MAP_W] = {
|
||||
{1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1},
|
||||
{1,0,0,0,0,0,0,2,2,2,0,0,0,0,0,1},
|
||||
{1,0,1,1,0,0,0,0,0,0,0,0,1,1,0,1},
|
||||
{1,0,1,0,0,0,3,3,0,0,0,0,0,0,0,1},
|
||||
{1,0,1,0,0,0,0,0,0,0,4,4,4,0,0,1},
|
||||
{1,0,1,1,1,0,0,5,5,0,0,0,0,0,0,1},
|
||||
{1,0,0,0,0,0,0,0,0,0,0,2,0,0,0,1},
|
||||
{1,0,3,3,3,3,0,0,0,2,2,0,0,0,0,1},
|
||||
{1,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1},
|
||||
{1,0,0,0,0,5,5,0,0,3,0,0,0,0,0,1},
|
||||
{1,4,0,0,0,0,0,0,0,0,0,0,0,0,0,1},
|
||||
{1,4,0,0,0,2,2,2,0,0,0,0,0,5,0,1},
|
||||
{1,4,0,0,0,0,0,0,0,0,3,3,3,0,0,1},
|
||||
{1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1},
|
||||
{1,0,0,4,4,4,4,4,4,0,0,0,0,0,0,1},
|
||||
{1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1},
|
||||
};
|
||||
|
||||
float posX = 8.5f, posY = 8.5f;
|
||||
float dirX = -1.0f, dirY = 0.0f;
|
||||
float planeX = 0.0f, planeY = 0.66f;
|
||||
|
||||
#define SCREEN_W 320
|
||||
#define SCREEN_H 240
|
||||
#define HUD_H 40
|
||||
#define VIEW_H (SCREEN_H - HUD_H)
|
||||
#define HALF_VH (VIEW_H / 2)
|
||||
#define SKY_COLOR 0x18C3
|
||||
#define FLOOR_COLOR 0x4208
|
||||
|
||||
uint16_t wallColor(uint8_t tile, bool nsFace) {
|
||||
uint16_t c;
|
||||
switch (tile) {
|
||||
case 1: c = 0x8410; break; // slate gray
|
||||
case 2: c = 0xC800; break; // blood red
|
||||
case 3: c = 0x4A60; break; // brown
|
||||
case 4: c = 0x32A0; break; // toxic green
|
||||
case 5: c = 0xFD20; break; // bronze door
|
||||
default: c = 0xFFFF; break;
|
||||
}
|
||||
if (nsFace) c = (c >> 1) & 0x7BEFu;
|
||||
return c;
|
||||
}
|
||||
|
||||
void drawTitleScreen() {
|
||||
tft.fillScreen(ILI9341_BLACK);
|
||||
tft.setTextSize(6);
|
||||
tft.setTextColor(0xC800);
|
||||
tft.setCursor(60, 40);
|
||||
tft.print("DOOM");
|
||||
tft.setTextSize(2);
|
||||
tft.setTextColor(0xFFE0);
|
||||
tft.setCursor(50, 110);
|
||||
tft.print("Pico Edition");
|
||||
tft.setTextSize(1);
|
||||
tft.setTextColor(0xC618);
|
||||
tft.setCursor(40, 160);
|
||||
tft.print("FWD/BACK move LEFT/RIGHT turn");
|
||||
tft.setTextSize(2);
|
||||
tft.setTextColor(0xFFFF);
|
||||
tft.setCursor(56, 200);
|
||||
tft.print("Press FWD to start");
|
||||
while (digitalRead(BTN_FWD) == HIGH) delay(40);
|
||||
while (digitalRead(BTN_FWD) == LOW) delay(40);
|
||||
}
|
||||
|
||||
void drawHUD() {
|
||||
tft.fillRect(0, SCREEN_H - HUD_H, SCREEN_W, HUD_H, 0x2104);
|
||||
tft.drawFastHLine(0, SCREEN_H - HUD_H, SCREEN_W, 0x52AA);
|
||||
tft.setTextSize(2);
|
||||
tft.setTextColor(0xFFE0);
|
||||
tft.setCursor(8, SCREEN_H - 28);
|
||||
tft.print("HP:100 ARM:50 AMMO:50");
|
||||
}
|
||||
|
||||
void renderFrame() {
|
||||
tft.fillRect(0, 0, SCREEN_W, HALF_VH, SKY_COLOR);
|
||||
tft.fillRect(0, HALF_VH, SCREEN_W, HALF_VH, FLOOR_COLOR);
|
||||
|
||||
for (int x = 0; x < SCREEN_W; x += 2) {
|
||||
float cameraX = 2.0f * x / SCREEN_W - 1.0f;
|
||||
float rayDirX = dirX + planeX * cameraX;
|
||||
float rayDirY = dirY + planeY * cameraX;
|
||||
|
||||
int mapX = (int)posX, mapY = (int)posY;
|
||||
float deltaDistX = (rayDirX == 0) ? 1e30f : fabsf(1.0f / rayDirX);
|
||||
float deltaDistY = (rayDirY == 0) ? 1e30f : fabsf(1.0f / rayDirY);
|
||||
|
||||
int stepX, stepY;
|
||||
float sideDistX, sideDistY;
|
||||
if (rayDirX < 0) { stepX = -1; sideDistX = (posX - mapX) * deltaDistX; }
|
||||
else { stepX = 1; sideDistX = (mapX + 1.0f - posX) * deltaDistX; }
|
||||
if (rayDirY < 0) { stepY = -1; sideDistY = (posY - mapY) * deltaDistY; }
|
||||
else { stepY = 1; sideDistY = (mapY + 1.0f - posY) * deltaDistY; }
|
||||
|
||||
bool hit = false, nsFace = false;
|
||||
int iter = 0;
|
||||
while (!hit && iter++ < 64) {
|
||||
if (sideDistX < sideDistY) { sideDistX += deltaDistX; mapX += stepX; nsFace = false; }
|
||||
else { sideDistY += deltaDistY; mapY += stepY; nsFace = true; }
|
||||
if (mapX < 0 || mapY < 0 || mapX >= MAP_W || mapY >= MAP_H) break;
|
||||
if (worldMap[mapY][mapX] > 0) hit = true;
|
||||
}
|
||||
if (!hit) continue;
|
||||
|
||||
float perpDist = nsFace
|
||||
? (mapY - posY + (1.0f - stepY) * 0.5f) / rayDirY
|
||||
: (mapX - posX + (1.0f - stepX) * 0.5f) / rayDirX;
|
||||
if (perpDist < 0.0001f) perpDist = 0.0001f;
|
||||
|
||||
int lineH = (int)(VIEW_H / perpDist);
|
||||
int drawStart = HALF_VH - lineH / 2;
|
||||
int drawEnd = HALF_VH + lineH / 2;
|
||||
if (drawStart < 0) drawStart = 0;
|
||||
if (drawEnd > VIEW_H) drawEnd = VIEW_H;
|
||||
|
||||
uint16_t color = wallColor(worldMap[mapY][mapX], nsFace);
|
||||
tft.drawFastVLine(x, drawStart, drawEnd - drawStart, color);
|
||||
tft.drawFastVLine(x + 1, drawStart, drawEnd - drawStart, color);
|
||||
}
|
||||
}
|
||||
|
||||
void setup() {
|
||||
Serial.begin(115200);
|
||||
pinMode(TFT_LED, OUTPUT);
|
||||
digitalWrite(TFT_LED, HIGH);
|
||||
pinMode(BTN_FWD, INPUT_PULLUP);
|
||||
pinMode(BTN_BACK, INPUT_PULLUP);
|
||||
pinMode(BTN_LEFT, INPUT_PULLUP);
|
||||
pinMode(BTN_RIGHT, INPUT_PULLUP);
|
||||
tft.begin();
|
||||
tft.setRotation(3);
|
||||
drawTitleScreen();
|
||||
tft.fillScreen(ILI9341_BLACK);
|
||||
drawHUD();
|
||||
Serial.println(F("Pico Doom — raycaster ready"));
|
||||
}
|
||||
|
||||
unsigned long lastFrameMs = 0;
|
||||
const float MOVE_SPEED = 0.10f;
|
||||
const float ROT_SPEED = 0.08f;
|
||||
|
||||
void loop() {
|
||||
unsigned long now = millis();
|
||||
if (now - lastFrameMs < 100) return;
|
||||
lastFrameMs = now;
|
||||
|
||||
if (digitalRead(BTN_FWD) == LOW) {
|
||||
float nx = posX + dirX * MOVE_SPEED;
|
||||
float ny = posY + dirY * MOVE_SPEED;
|
||||
if (nx > 0 && nx < MAP_W && worldMap[(int)posY][(int)nx] == 0) posX = nx;
|
||||
if (ny > 0 && ny < MAP_H && worldMap[(int)ny][(int)posX] == 0) posY = ny;
|
||||
}
|
||||
if (digitalRead(BTN_BACK) == LOW) {
|
||||
float nx = posX - dirX * MOVE_SPEED;
|
||||
float ny = posY - dirY * MOVE_SPEED;
|
||||
if (nx > 0 && nx < MAP_W && worldMap[(int)posY][(int)nx] == 0) posX = nx;
|
||||
if (ny > 0 && ny < MAP_H && worldMap[(int)ny][(int)posX] == 0) posY = ny;
|
||||
}
|
||||
auto rotate = [](float &x, float &y, float a) {
|
||||
float ox = x;
|
||||
x = x * cosf(a) - y * sinf(a);
|
||||
y = ox * sinf(a) + y * cosf(a);
|
||||
};
|
||||
if (digitalRead(BTN_LEFT) == LOW) { rotate(dirX, dirY, ROT_SPEED); rotate(planeX, planeY, ROT_SPEED); }
|
||||
if (digitalRead(BTN_RIGHT) == LOW) { rotate(dirX, dirY, -ROT_SPEED); rotate(planeX, planeY, -ROT_SPEED); }
|
||||
renderFrame();
|
||||
}
|
||||
`,
|
||||
components: [
|
||||
{ type: 'wokwi-ili9341', id: 'tft1', x: 460, y: 60, properties: {} },
|
||||
{ type: 'wokwi-pushbutton', id: 'btn-fwd', x: 220, y: 420, properties: { color: 'red', label: 'FWD' } },
|
||||
{ type: 'wokwi-pushbutton', id: 'btn-back', x: 220, y: 520, properties: { color: 'blue', label: 'BACK' } },
|
||||
{ type: 'wokwi-pushbutton', id: 'btn-left', x: 130, y: 470, properties: { color: 'green', label: 'LEFT' } },
|
||||
{ type: 'wokwi-pushbutton', id: 'btn-right', x: 310, y: 470, properties: { color: 'green', label: 'RIGHT' } },
|
||||
],
|
||||
wires: [
|
||||
// SPI bus + control lines to the ILI9341
|
||||
{ id: 'w-tft-sck', start: { componentId: 'raspberry-pi-pico', pinName: 'GP18' }, end: { componentId: 'tft1', pinName: 'SCK' }, color: '#ff8800' },
|
||||
{ id: 'w-tft-mosi', start: { componentId: 'raspberry-pi-pico', pinName: 'GP19' }, end: { componentId: 'tft1', pinName: 'MOSI' }, color: '#ff8800' },
|
||||
{ id: 'w-tft-cs', start: { componentId: 'raspberry-pi-pico', pinName: 'GP17' }, end: { componentId: 'tft1', pinName: 'CS' }, color: '#00aaff' },
|
||||
{ id: 'w-tft-dc', start: { componentId: 'raspberry-pi-pico', pinName: 'GP20' }, end: { componentId: 'tft1', pinName: 'D/C' }, color: '#00cc00' },
|
||||
{ id: 'w-tft-rst', start: { componentId: 'raspberry-pi-pico', pinName: 'GP21' }, end: { componentId: 'tft1', pinName: 'RST' }, color: '#cc0000' },
|
||||
{ id: 'w-tft-led', start: { componentId: 'raspberry-pi-pico', pinName: 'GP22' }, end: { componentId: 'tft1', pinName: 'LED' }, color: '#ffffff' },
|
||||
// Buttons signal + GND each
|
||||
{ id: 'w-btn-fwd', start: { componentId: 'raspberry-pi-pico', pinName: 'GP10' }, end: { componentId: 'btn-fwd', pinName: '1.l' }, color: '#ff4444' },
|
||||
{ id: 'w-btn-back', start: { componentId: 'raspberry-pi-pico', pinName: 'GP11' }, end: { componentId: 'btn-back', pinName: '1.l' }, color: '#4477ff' },
|
||||
{ id: 'w-btn-left', start: { componentId: 'raspberry-pi-pico', pinName: 'GP12' }, end: { componentId: 'btn-left', pinName: '1.l' }, color: '#44cc44' },
|
||||
{ id: 'w-btn-right', start: { componentId: 'raspberry-pi-pico', pinName: 'GP13' }, end: { componentId: 'btn-right', pinName: '1.l' }, color: '#cccc44' },
|
||||
{ id: 'w-gnd-fwd', start: { componentId: 'btn-fwd', pinName: '2.l' }, end: { componentId: 'raspberry-pi-pico', pinName: 'GND.1' }, color: '#000000' },
|
||||
{ id: 'w-gnd-back', start: { componentId: 'btn-back', pinName: '2.l' }, end: { componentId: 'raspberry-pi-pico', pinName: 'GND.2' }, color: '#000000' },
|
||||
{ id: 'w-gnd-left', start: { componentId: 'btn-left', pinName: '2.l' }, end: { componentId: 'raspberry-pi-pico', pinName: 'GND.3' }, color: '#000000' },
|
||||
{ id: 'w-gnd-right', start: { componentId: 'btn-right', pinName: '2.l' }, end: { componentId: 'raspberry-pi-pico', pinName: 'GND.4' }, color: '#000000' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'tft-display',
|
||||
title: 'TFT ILI9341 Display',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,89 @@
|
|||
# Pico Doom — pre-flight compile test
|
||||
|
||||
A column-wise raycaster that produces a Wolfenstein / early-Doom-style
|
||||
3D corridor on the Pi Pico + ILI9341 TFT. Lives here as the canonical
|
||||
source of the sketch + a one-shot compile check the operator can run
|
||||
before exposing the example in the gallery.
|
||||
|
||||
## What the sketch does
|
||||
|
||||
- Reads four pushbuttons (forward, back, turn left, turn right) on
|
||||
GP10-13 with INPUT_PULLUP.
|
||||
- Draws a title splash on the ILI9341, waits for FWD.
|
||||
- Casts 160 rays per frame (every other column, 2-px stripe) through
|
||||
a 16×16 tile map using DDA, paints walls straight onto the TFT with
|
||||
`drawFastVLine` — no framebuffer.
|
||||
- Renders a fixed 40-px HUD bar at the bottom (HP / armour / ammo).
|
||||
- Caps the frame rate at ~10 fps so the SPI bus has room to breathe.
|
||||
|
||||
## Why this is a "demo" and not the real id Software Doom
|
||||
|
||||
The canonical Pico port — Graham Sanderson's [rp2040-doom][rp2040doom]
|
||||
— ships the shareware DOOM1.WAD inside the flash with custom
|
||||
compression and pushes pixels out via PIO-driven VGA / DVI. None of
|
||||
that survives the rp2040js emulator (no PIO accuracy, no flash
|
||||
mapping for the WAD blob), so what you'd see in the simulator is a
|
||||
boot loop.
|
||||
|
||||
A Wolf3D-style raycaster reproduces the *visual experience* — first-
|
||||
person 3D corridor with textured walls, free rotation, smooth
|
||||
forward/strafe movement — using the same tools that fit on a 264 KB
|
||||
chip. That's what this sketch does, and it survives emulation fine.
|
||||
|
||||
[rp2040doom]: https://github.com/kilograham/rp2040-doom
|
||||
|
||||
## How to run the pre-flight check
|
||||
|
||||
The sketch must compile against the Pico FQBN with only the libraries
|
||||
the Velxio compile pipeline already auto-installs (Adafruit_GFX,
|
||||
Adafruit_ILI9341). Run:
|
||||
|
||||
```bash
|
||||
cd test/pico_doom_demo
|
||||
./compile_check.sh
|
||||
```
|
||||
|
||||
The script:
|
||||
|
||||
1. Checks `arduino-cli` is in `$PATH`.
|
||||
2. Verifies `rp2040:rp2040` core is installed (installs it if not).
|
||||
3. Confirms `Adafruit_GFX` and `Adafruit_ILI9341` are installed (lib
|
||||
install if missing).
|
||||
4. Runs `arduino-cli compile --fqbn rp2040:rp2040:rpipico .` and
|
||||
asserts a non-empty `.uf2` output.
|
||||
|
||||
A successful run prints the binary size and exits 0. Sketch size
|
||||
should sit comfortably under 80 KB — well below the Pico's 2 MB
|
||||
flash ceiling — so this isn't a "will it fit" test but a
|
||||
"compiles cleanly with the gallery's lib set" test.
|
||||
|
||||
## How the example wires up the gallery
|
||||
|
||||
`frontend/src/data/examples.ts` registers an entry with:
|
||||
|
||||
- `id: 'pico-doom-raycaster'`
|
||||
- `boardType: 'raspberry-pi-pico'`
|
||||
- `category: 'games'`
|
||||
- `difficulty: 'advanced'`
|
||||
- `libraries: ['Adafruit GFX Library', 'Adafruit ILI9341']`
|
||||
- Components: 1 ILI9341 + 4 pushbuttons + the Pico board
|
||||
- Wires: SPI bus (SCK/MOSI/CS/DC/RST/LED) + 4 button pulls to GP10-13
|
||||
|
||||
The unit test `frontend/src/__tests__/examples-pico-doom.test.ts`
|
||||
validates that the registered example has those fields and that
|
||||
every wire endpoint references a component id that actually exists.
|
||||
|
||||
## Pin map (printed here so it's obvious during hardware bring-up too)
|
||||
|
||||
| Function | Pico GPIO | Note |
|
||||
|---|---|---|
|
||||
| SPI0 SCK | GP18 | Adafruit_ILI9341 uses SPI0 by default. |
|
||||
| SPI0 MOSI | GP19 | |
|
||||
| TFT CS | GP17 | |
|
||||
| TFT D/C | GP20 | |
|
||||
| TFT RST | GP21 | |
|
||||
| TFT LED | GP22 | Backlight, always HIGH. |
|
||||
| Button FWD | GP10 | INPUT_PULLUP, active LOW. |
|
||||
| Button BACK | GP11 | |
|
||||
| Button LEFT | GP12 | Turn anticlockwise. |
|
||||
| Button RIGHT | GP13 | Turn clockwise. |
|
||||
|
|
@ -0,0 +1,261 @@
|
|||
/*
|
||||
* Pico Doom — A Wolf3D-style raycaster running on Raspberry Pi Pico.
|
||||
*
|
||||
* Hardware
|
||||
* Raspberry Pi Pico (RP2040) + ILI9341 SPI TFT (320x240, landscape)
|
||||
* 4 pushbuttons: forward, backward, turn left, turn right
|
||||
*
|
||||
* Pins
|
||||
* SPI0: SCK=GP18 MOSI=GP19 CS=GP17 DC=GP20 RST=GP21 LED=GP22
|
||||
* Buttons (active LOW, INPUT_PULLUP):
|
||||
* FWD=GP10 BACK=GP11 LEFT=GP12 RIGHT=GP13
|
||||
*
|
||||
* Renderer
|
||||
* DDA raycasting at 2-column step (160 rays / frame) — column-stripe
|
||||
* drawing straight to the TFT, no framebuffer. Single-room 16x16 map
|
||||
* with 5 wall styles (slate, blood, brown, green, door) painted darker
|
||||
* on NS faces so corners read in 3D. Sky + floor are flat colour
|
||||
* bands; the bottom 40 px is the HUD.
|
||||
*
|
||||
* Why a demo and not the real id Software Doom: the canonical Pico
|
||||
* port (Graham Sanderson's rp2040-doom) shoehorns the shareware WAD
|
||||
* into 2 MB of flash with custom compression + PIO video timing the
|
||||
* emulator can't reproduce. A column-wise raycaster gets you the
|
||||
* "first-person 3D corridor" visual that Wolfenstein/early Doom both
|
||||
* used, with zero external assets, and it fits in a 100-line sketch.
|
||||
*
|
||||
* Build
|
||||
* FQBN: rp2040:rp2040:rpipico
|
||||
* Required libraries: Adafruit_GFX, Adafruit_ILI9341, SPI (builtin).
|
||||
*
|
||||
* This is the source-of-truth file. The /examples gallery entry in
|
||||
* frontend/src/data/examples.ts is generated/copied from this sketch;
|
||||
* if you change the gameplay or wiring here, mirror it there.
|
||||
*/
|
||||
|
||||
#include <SPI.h>
|
||||
#include <Adafruit_GFX.h>
|
||||
#include <Adafruit_ILI9341.h>
|
||||
#include <math.h>
|
||||
|
||||
// ─── Pins ──────────────────────────────────────────────────────────────
|
||||
#define TFT_CS 17
|
||||
#define TFT_DC 20
|
||||
#define TFT_RST 21
|
||||
#define TFT_LED 22
|
||||
#define BTN_FWD 10
|
||||
#define BTN_BACK 11
|
||||
#define BTN_LEFT 12
|
||||
#define BTN_RIGHT 13
|
||||
|
||||
Adafruit_ILI9341 tft(TFT_CS, TFT_DC, TFT_RST);
|
||||
|
||||
// ─── Map (1=slate, 2=blood, 3=brown, 4=green, 5=door, 0=empty) ─────────
|
||||
#define MAP_W 16
|
||||
#define MAP_H 16
|
||||
const uint8_t worldMap[MAP_H][MAP_W] = {
|
||||
{1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1},
|
||||
{1,0,0,0,0,0,0,2,2,2,0,0,0,0,0,1},
|
||||
{1,0,1,1,0,0,0,0,0,0,0,0,1,1,0,1},
|
||||
{1,0,1,0,0,0,3,3,0,0,0,0,0,0,0,1},
|
||||
{1,0,1,0,0,0,0,0,0,0,4,4,4,0,0,1},
|
||||
{1,0,1,1,1,0,0,5,5,0,0,0,0,0,0,1},
|
||||
{1,0,0,0,0,0,0,0,0,0,0,2,0,0,0,1},
|
||||
{1,0,3,3,3,3,0,0,0,2,2,0,0,0,0,1},
|
||||
{1,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1},
|
||||
{1,0,0,0,0,5,5,0,0,3,0,0,0,0,0,1},
|
||||
{1,4,0,0,0,0,0,0,0,0,0,0,0,0,0,1},
|
||||
{1,4,0,0,0,2,2,2,0,0,0,0,0,5,0,1},
|
||||
{1,4,0,0,0,0,0,0,0,0,3,3,3,0,0,1},
|
||||
{1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1},
|
||||
{1,0,0,4,4,4,4,4,4,0,0,0,0,0,0,1},
|
||||
{1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1},
|
||||
};
|
||||
|
||||
// ─── Player state ──────────────────────────────────────────────────────
|
||||
float posX = 8.5f, posY = 8.5f;
|
||||
float dirX = -1.0f, dirY = 0.0f;
|
||||
float planeX = 0.0f, planeY = 0.66f;
|
||||
|
||||
// ─── Render constants ──────────────────────────────────────────────────
|
||||
#define SCREEN_W 320
|
||||
#define SCREEN_H 240
|
||||
#define HUD_H 40
|
||||
#define VIEW_H (SCREEN_H - HUD_H) // 200 — the actual 3D viewport
|
||||
#define HALF_VH (VIEW_H / 2)
|
||||
#define SKY_COLOR 0x18C3
|
||||
#define FLOOR_COLOR 0x4208
|
||||
|
||||
// 5 wall palettes (RGB565). NS faces are rendered with the dim variant
|
||||
// so corners read correctly without per-pixel shading cost.
|
||||
uint16_t wallColor(uint8_t tile, bool nsFace) {
|
||||
uint16_t c;
|
||||
switch (tile) {
|
||||
case 1: c = 0x8410; break; // slate gray
|
||||
case 2: c = 0xC800; break; // blood red
|
||||
case 3: c = 0x4A60; break; // brown
|
||||
case 4: c = 0x32A0; break; // toxic green
|
||||
case 5: c = 0xFD20; break; // bronze door
|
||||
default: c = 0xFFFF; break;
|
||||
}
|
||||
if (nsFace) c = (c >> 1) & 0x7BEFu; // darken
|
||||
return c;
|
||||
}
|
||||
|
||||
void drawTitleScreen() {
|
||||
tft.fillScreen(ILI9341_BLACK);
|
||||
|
||||
tft.setTextSize(6);
|
||||
tft.setTextColor(0xC800); // blood red
|
||||
tft.setCursor(60, 40);
|
||||
tft.print("DOOM");
|
||||
|
||||
tft.setTextSize(2);
|
||||
tft.setTextColor(0xFFE0); // yellow
|
||||
tft.setCursor(50, 110);
|
||||
tft.print("Pico Edition");
|
||||
|
||||
tft.setTextSize(1);
|
||||
tft.setTextColor(0xC618);
|
||||
tft.setCursor(40, 160);
|
||||
tft.print("FWD/BACK move LEFT/RIGHT turn");
|
||||
|
||||
tft.setTextSize(2);
|
||||
tft.setTextColor(0xFFFF);
|
||||
tft.setCursor(56, 200);
|
||||
tft.print("Press FWD to start");
|
||||
|
||||
// Wait for the player to press FWD.
|
||||
while (digitalRead(BTN_FWD) == HIGH) {
|
||||
delay(40);
|
||||
}
|
||||
// Debounce — don't immediately walk into the wall.
|
||||
while (digitalRead(BTN_FWD) == LOW) {
|
||||
delay(40);
|
||||
}
|
||||
}
|
||||
|
||||
void drawHUD() {
|
||||
tft.fillRect(0, SCREEN_H - HUD_H, SCREEN_W, HUD_H, 0x2104); // dark gray bar
|
||||
tft.drawFastHLine(0, SCREEN_H - HUD_H, SCREEN_W, 0x52AA);
|
||||
|
||||
tft.setTextSize(2);
|
||||
tft.setTextColor(0xFFE0);
|
||||
tft.setCursor(8, SCREEN_H - 28);
|
||||
tft.print("HP:100 ARM:50 AMMO:50");
|
||||
}
|
||||
|
||||
// Render one frame. Sky/floor bands are repainted every frame (cheap
|
||||
// fillRects) so the previous frame's wall columns get overwritten
|
||||
// automatically — no separate clear pass needed.
|
||||
void renderFrame() {
|
||||
// Sky covers the top half of the VIEW; floor the bottom half.
|
||||
tft.fillRect(0, 0, SCREEN_W, HALF_VH, SKY_COLOR);
|
||||
tft.fillRect(0, HALF_VH, SCREEN_W, HALF_VH, FLOOR_COLOR);
|
||||
|
||||
for (int x = 0; x < SCREEN_W; x += 2) {
|
||||
// Camera-X in [-1, 1].
|
||||
float cameraX = 2.0f * x / SCREEN_W - 1.0f;
|
||||
float rayDirX = dirX + planeX * cameraX;
|
||||
float rayDirY = dirY + planeY * cameraX;
|
||||
|
||||
int mapX = (int)posX;
|
||||
int mapY = (int)posY;
|
||||
float deltaDistX = (rayDirX == 0) ? 1e30f : fabsf(1.0f / rayDirX);
|
||||
float deltaDistY = (rayDirY == 0) ? 1e30f : fabsf(1.0f / rayDirY);
|
||||
|
||||
int stepX, stepY;
|
||||
float sideDistX, sideDistY;
|
||||
if (rayDirX < 0) { stepX = -1; sideDistX = (posX - mapX) * deltaDistX; }
|
||||
else { stepX = 1; sideDistX = (mapX + 1.0f - posX) * deltaDistX; }
|
||||
if (rayDirY < 0) { stepY = -1; sideDistY = (posY - mapY) * deltaDistY; }
|
||||
else { stepY = 1; sideDistY = (mapY + 1.0f - posY) * deltaDistY; }
|
||||
|
||||
bool hit = false;
|
||||
bool nsFace = false; // false = EW (vertical wall), true = NS (horizontal)
|
||||
int iter = 0;
|
||||
while (!hit && iter++ < 64) {
|
||||
if (sideDistX < sideDistY) { sideDistX += deltaDistX; mapX += stepX; nsFace = false; }
|
||||
else { sideDistY += deltaDistY; mapY += stepY; nsFace = true; }
|
||||
if (mapX < 0 || mapY < 0 || mapX >= MAP_W || mapY >= MAP_H) break;
|
||||
if (worldMap[mapY][mapX] > 0) hit = true;
|
||||
}
|
||||
if (!hit) continue;
|
||||
|
||||
float perpDist = nsFace
|
||||
? (mapY - posY + (1.0f - stepY) * 0.5f) / rayDirY
|
||||
: (mapX - posX + (1.0f - stepX) * 0.5f) / rayDirX;
|
||||
if (perpDist < 0.0001f) perpDist = 0.0001f;
|
||||
|
||||
int lineH = (int)(VIEW_H / perpDist);
|
||||
int drawStart = HALF_VH - lineH / 2;
|
||||
int drawEnd = HALF_VH + lineH / 2;
|
||||
if (drawStart < 0) drawStart = 0;
|
||||
if (drawEnd > VIEW_H) drawEnd = VIEW_H;
|
||||
|
||||
uint16_t color = wallColor(worldMap[mapY][mapX], nsFace);
|
||||
// Draw two adjacent columns so the 2-px step doesn't show as gaps.
|
||||
tft.drawFastVLine(x, drawStart, drawEnd - drawStart, color);
|
||||
tft.drawFastVLine(x + 1, drawStart, drawEnd - drawStart, color);
|
||||
}
|
||||
}
|
||||
|
||||
void setup() {
|
||||
Serial.begin(115200);
|
||||
pinMode(TFT_LED, OUTPUT);
|
||||
digitalWrite(TFT_LED, HIGH);
|
||||
pinMode(BTN_FWD, INPUT_PULLUP);
|
||||
pinMode(BTN_BACK, INPUT_PULLUP);
|
||||
pinMode(BTN_LEFT, INPUT_PULLUP);
|
||||
pinMode(BTN_RIGHT, INPUT_PULLUP);
|
||||
|
||||
tft.begin();
|
||||
tft.setRotation(3); // landscape, 320×240
|
||||
|
||||
drawTitleScreen();
|
||||
tft.fillScreen(ILI9341_BLACK);
|
||||
drawHUD();
|
||||
|
||||
Serial.println(F("Pico Doom — raycaster ready"));
|
||||
}
|
||||
|
||||
unsigned long lastFrameMs = 0;
|
||||
const float MOVE_SPEED = 0.10f;
|
||||
const float ROT_SPEED = 0.08f;
|
||||
|
||||
void loop() {
|
||||
unsigned long now = millis();
|
||||
if (now - lastFrameMs < 100) return; // cap at ~10 fps for the SPI bus
|
||||
lastFrameMs = now;
|
||||
|
||||
// ── Input ───────────────────────────────────────────────────────────
|
||||
if (digitalRead(BTN_FWD) == LOW) {
|
||||
float nx = posX + dirX * MOVE_SPEED;
|
||||
float ny = posY + dirY * MOVE_SPEED;
|
||||
if (nx > 0 && nx < MAP_W && worldMap[(int)posY][(int)nx] == 0) posX = nx;
|
||||
if (ny > 0 && ny < MAP_H && worldMap[(int)ny][(int)posX] == 0) posY = ny;
|
||||
}
|
||||
if (digitalRead(BTN_BACK) == LOW) {
|
||||
float nx = posX - dirX * MOVE_SPEED;
|
||||
float ny = posY - dirY * MOVE_SPEED;
|
||||
if (nx > 0 && nx < MAP_W && worldMap[(int)posY][(int)nx] == 0) posX = nx;
|
||||
if (ny > 0 && ny < MAP_H && worldMap[(int)ny][(int)posX] == 0) posY = ny;
|
||||
}
|
||||
// Rotate by +/- ROT_SPEED radians.
|
||||
auto rotate = [](float &x, float &y, float a) {
|
||||
float ox = x;
|
||||
x = x * cosf(a) - y * sinf(a);
|
||||
y = ox * sinf(a) + y * cosf(a);
|
||||
};
|
||||
if (digitalRead(BTN_LEFT) == LOW) {
|
||||
rotate(dirX, dirY, ROT_SPEED);
|
||||
rotate(planeX, planeY, ROT_SPEED);
|
||||
}
|
||||
if (digitalRead(BTN_RIGHT) == LOW) {
|
||||
rotate(dirX, dirY, -ROT_SPEED);
|
||||
rotate(planeX, planeY, -ROT_SPEED);
|
||||
}
|
||||
|
||||
// ── Render ──────────────────────────────────────────────────────────
|
||||
renderFrame();
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
#!/usr/bin/env bash
|
||||
# Pre-flight compile check for the Pico Doom raycaster example.
|
||||
#
|
||||
# Runs arduino-cli against the same FQBN the Velxio backend uses for the
|
||||
# Raspberry Pi Pico and exits 0 only if the sketch builds cleanly with
|
||||
# the gallery's auto-installed library set.
|
||||
#
|
||||
# Manual use:
|
||||
# cd test/pico_doom_demo && ./compile_check.sh
|
||||
#
|
||||
# This file is wired into the test/ tree (not the vitest suite) because
|
||||
# arduino-cli isn't available in the CI image we use for the JS tests;
|
||||
# the operator runs it before merging the example, the in-repo unit
|
||||
# test verifies the example data shape.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
SKETCH_DIR="$SCRIPT_DIR"
|
||||
BOARD_FQBN="rp2040:rp2040:rpipico"
|
||||
BUILD_DIR="$SCRIPT_DIR/build"
|
||||
|
||||
echo "─── Pico Doom compile check ───────────────────────────────"
|
||||
|
||||
if ! command -v arduino-cli >/dev/null 2>&1; then
|
||||
echo "ERROR: arduino-cli not on PATH. Install from https://arduino.github.io/arduino-cli/"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Make sure the Pico core is installed. earlephilhower/arduino-pico is
|
||||
# what the production Velxio backend pulls.
|
||||
if ! arduino-cli core list 2>/dev/null | grep -q "rp2040:rp2040"; then
|
||||
echo "[install] adding rp2040:rp2040 core index"
|
||||
arduino-cli config add board_manager.additional_urls \
|
||||
https://github.com/earlephilhower/arduino-pico/releases/download/global/package_rp2040_index.json \
|
||||
|| true
|
||||
arduino-cli core update-index
|
||||
arduino-cli core install rp2040:rp2040
|
||||
fi
|
||||
|
||||
# Confirm required libs exist; install if absent.
|
||||
for lib in "Adafruit GFX Library" "Adafruit ILI9341"; do
|
||||
if ! arduino-cli lib list 2>/dev/null | grep -q "$lib"; then
|
||||
echo "[install] adding library: $lib"
|
||||
arduino-cli lib install "$lib"
|
||||
fi
|
||||
done
|
||||
|
||||
rm -rf "$BUILD_DIR"
|
||||
mkdir -p "$BUILD_DIR"
|
||||
|
||||
echo "[compile] $BOARD_FQBN"
|
||||
arduino-cli compile \
|
||||
--fqbn "$BOARD_FQBN" \
|
||||
--output-dir "$BUILD_DIR" \
|
||||
"$SKETCH_DIR"
|
||||
|
||||
uf2_path=$(find "$BUILD_DIR" -name '*.uf2' | head -n1 || true)
|
||||
if [ -z "$uf2_path" ] || [ ! -s "$uf2_path" ]; then
|
||||
echo "ERROR: no .uf2 produced — compile output above"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
uf2_kb=$(($(stat -c%s "$uf2_path") / 1024))
|
||||
echo "[ok] built $(basename "$uf2_path") (${uf2_kb} KB)"
|
||||
echo "──────────────────────────────────────────────────────────"
|
||||
Loading…
Reference in New Issue