diff --git a/api/config.md b/api/config.md index 5c1c9ea..17b2ff8 100644 --- a/api/config.md +++ b/api/config.md @@ -41,10 +41,17 @@ This document covers global configuration options, build flags, and compile-time | `PIXELROOT32_ENABLE_DIRTY_REGION_PROFILING` | Enable dirty region profiling metrics. | `0` | | `PIXELROOT32_TFT_ESPI_LINES_PER_BLOCK` | TFT_eSPI DMA line batch size. | `60` | | `PIXELROOT32_TFT_ESPI_LINES_PER_BLOCK_FALLBACK` | Fallback DMA batch size if memory fails. | `30` | +| `PIXELROOT32_TFT_12BIT_COLOR` | Send frames as 12-bit RGB444 (2 pixels per 3 bytes) instead of RGB565. Experimental. | `0` | | `PIXELROOT32_DEBUG_MODE` | Enable unified logging system. | Disabled | | `PIXELROOT32_VELOCITY_DAMPING` | Per-frame velocity damping factor (0.0-1.0). | `0.999` | | `PIXELROOT32_MAX_VELOCITY` | Maximum velocity cap in units/s. | `500` | +### TFT_eSPI Display Flags + +> **Note:** `PIXELROOT32_TFT_12BIT_COLOR=1` is **experimental and not yet verified on hardware**. It cuts 25% of the SPI bus time per frame and shrinks each DMA line buffer by 25%, and the driver silently keeps RGB565 when `PHYSICAL_DISPLAY_WIDTH` is not a multiple of 4. See [12-bit Color on the Wire (RGB444)](../guide/performance/esp32-performance.md#12-bit-color-on-the-wire-rgb444) for the bandwidth math, the width constraint and the memory trade-off. + +> **Note:** `PIXELROOT32_TFT_ESPI_LINES_PER_BLOCK=60` only became reachable in `d6dc9ae`. Earlier builds hit a buffer-selection bug in `buildScaleLUTs()` that always downgraded to `PIXELROOT32_TFT_ESPI_LINES_PER_BLOCK_FALLBACK`, so the documented `60` default never applied. The fallback still applies when DMA-capable internal RAM is tight. + ## Memory Savings by Subsystem | Subsystem Disabled | RAM Savings | Flash Savings | diff --git a/api/generated/drivers/TFT_eSPI_Drawer.md b/api/generated/drivers/TFT_eSPI_Drawer.md index 2b50736..d29f1d1 100644 --- a/api/generated/drivers/TFT_eSPI_Drawer.md +++ b/api/generated/drivers/TFT_eSPI_Drawer.md @@ -91,6 +91,12 @@ Get pointer to sprite buffer for direct manipulation. Processes system events. Always true for embedded. +### `void waitForPendingDMA()` + +**Description:** + +Blocks until the DMA transfer deferred by sendBufferScaled() completes. + ### `bool needsScaling() const` **Description:** @@ -122,3 +128,29 @@ Sends the buffer using hardware DMA and software scaling. **Description:** Scales a single line from 8bpp logical to 16bpp physical. + +### `void convertBlockRgb444(const uint8_t* spriteBase, int startY, int endY, bool is2x, uint8_t* dst)` + +**Description:** + +Converts one block of physical lines into the packed RGB444 stream. + +**Parameters:** + +- `spriteBase`: Base of the 8bpp sprite framebuffer. +- `startY`: First physical line of the block. +- `endY`: One past the last physical line of the block. +- `is2x`: True when the frame is an exact 2x integer upscale. +- `dst`: Destination line buffer, viewed as raw bytes. + +### `void scaleLine444(const uint8_t* spriteBase, int srcY, uint8_t* dst)` + +**Description:** + +Scales a single line from 8bpp logical to packed RGB444 physical. + +### `int bytesPerLine444() const` + +**Description:** + +Bytes one physical line occupies in the packed RGB444 stream. diff --git a/api/generated/gameplay/GridMotion.md b/api/generated/gameplay/GridMotion.md new file mode 100644 index 0000000..41c221b --- /dev/null +++ b/api/generated/gameplay/GridMotion.md @@ -0,0 +1,24 @@ +# GridMotion + + + +**Source:** `GridMotion.h` + +## Description + +Plain five-`int` aggregate: logical cell, target cell, progress. + +At rest, `toX == cellX`, `toY == cellY` and `progress == 0`. In flight, +`progress` runs 1..stepsPerCell-1 and the logical cell still names the cell +being LEFT — it flips only on arrival. Gameplay rules that read the logical +cell therefore never see an actor occupying two cells, or none. + +## Properties + +| Name | Type | Description | +|------|------|-------------| +| `cellX` | `int` | Logical cell X — the one every gameplay rule reads. | +| `cellY` | `int` | Logical cell Y — the one every gameplay rule reads. | +| `toX` | `int` | Target cell X; equals cellX when at rest. | +| `toY` | `int` | Target cell Y; equals cellY when at rest. | +| `progress` | `int` | 0..stepsPerCell-1; 0 means "at rest in (cellX, cellY)". | diff --git a/api/generated/graphics/TransitionEffect.md b/api/generated/graphics/TransitionEffect.md index 0b82d3f..e79265d 100644 --- a/api/generated/graphics/TransitionEffect.md +++ b/api/generated/graphics/TransitionEffect.md @@ -202,8 +202,10 @@ Fill a 256-byte LUT for the current fade direction and progress. - `scaledProgress`: Progress in Q8.8 format (0..256, where 256 = 1.0). -Out: lut[i] = i * (256-p) / 256 — dims to black. -In: lut[i] = i * p / 256 — brightens from black. +Out scales by (256-p), In scales by p — but the scale is applied to each +RGB332 channel of the index, never to the packed byte. The byte is a +colour, not an intensity: scaling it whole carries bits between channels +and rotates the hue instead of dimming it. ### `void applyFade(uint8_t* buffer, int width, int height)` diff --git a/api/generated/graphics/UISprite.md b/api/generated/graphics/UISprite.md new file mode 100644 index 0000000..649557f --- /dev/null +++ b/api/generated/graphics/UISprite.md @@ -0,0 +1,126 @@ +# UISprite + + + +**Source:** `UISprite.h` + +**Inherits from:** [UIElement](./UIElement.md) + +## Description + +A UI leaf that draws a single sprite. + +The UI system could previously draw text and rectangles only, so anything +with an icon — an item slot in a menu, a dialog portrait, a button glyph, a +HUD resource — had to be drawn by hand in a `Scene::draw()` override, +outside the entity tree. That costs three things this element gets for +free: `setVisible()`, placement by a UILayout, and `setFixedPosition()`, +which bypasses the camera offset so a HUD stays put while the world +scrolls. + +Accepts all three sprite formats (`Sprite`, `Sprite2bpp`, `Sprite4bpp`) via +UISpriteRef, and adopts the sprite's dimensions as its own so layouts can +size it. It holds no clock: animating means setting a different sprite, +which is the game's decision, not the element's. + + +```cpp +UISprite icon(Vector2(8, 8)); +icon.setSprite(kKeyIcon4bpp); +icon.setFixedPosition(true); // stays put while the camera scrolls +scene.addEntity(&icon); +``` + +## Inheritance + +[UIElement](./UIElement.md) → `UISprite` + +## Methods + +### `explicit UISprite(pixelroot32::math::Vector2 position)` + +**Description:** + +Constructs an empty sprite element at `position`. + +### `void setSprite(const Sprite& sprite, Color tint = Color::White)` + +**Description:** + +Sets a 1bpp sprite, drawn in `tint`. + +**Parameters:** + +- `sprite`: Sprite to reference. Must outlive this element. +- `tint`: Color the set pixels are drawn in. + +### `void setSprite(const Sprite2bpp& sprite, uint8_t paletteSlot = 0)` + +**Description:** + +Sets a 2bpp sprite, drawn through `paletteSlot`. + +**Parameters:** + +- `sprite`: Sprite to reference. Must outlive this element. +- `paletteSlot`: Sprite palette slot to resolve colors through. + +### `void setSprite(const Sprite4bpp& sprite, uint8_t paletteSlot = 0)` + +**Description:** + +Sets a 4bpp sprite, drawn through `paletteSlot`. + +**Parameters:** + +- `sprite`: Sprite to reference. Must outlive this element. +- `paletteSlot`: Sprite palette slot to resolve colors through. + +### `void clearSprite()` + +**Description:** + +Removes the sprite, returning the element to 0x0 and drawing + nothing. + +### `UISpriteFormat getFormat() const` + +### `bool hasSprite() const` + +### `Color getTint() const` + +### `uint8_t getPaletteSlot() const` + +### `void setFlipX(bool flip)` + +**Description:** + +Mirrors the sprite horizontally when drawn. + +### `bool getFlipX() const` + +### `void update(unsigned long deltaTime)` + +**Description:** + +No-op. A sprite leaf has no internal animation clock. + +**Parameters:** + +- `deltaTime`: Ignored. + +### `void draw(pixelroot32::graphics::Renderer& renderer)` + +**Description:** + +Draws the sprite, honoring isVisible and fixedPosition. + +**Parameters:** + +- `renderer`: Reference to the renderer. + +### `void recalcSize()` + +**Description:** + +Re-reads width/height from the current sprite. diff --git a/api/generated/graphics/UISpriteFormat.md b/api/generated/graphics/UISpriteFormat.md new file mode 100644 index 0000000..4065015 --- /dev/null +++ b/api/generated/graphics/UISpriteFormat.md @@ -0,0 +1,9 @@ +# UISpriteFormat + + + +**Source:** `UISpriteRef.h` + +## Description + +Which member of a UISpriteRef's storage is live. diff --git a/api/generated/graphics/UISpriteRef.md b/api/generated/graphics/UISpriteRef.md new file mode 100644 index 0000000..f2a8901 --- /dev/null +++ b/api/generated/graphics/UISpriteRef.md @@ -0,0 +1,19 @@ +# UISpriteRef + + + +**Source:** `UISpriteRef.h` + +## Description + +Non-owning, format-tagged pointer to one sprite plus its draw + parameters. + +## Properties + +| Name | Type | Description | +|------|------|-------------| +| `storage` | `Storage` | The referenced sprite. | +| `format` | `UISpriteFormat` | Live member of `storage`. | +| `tint` | `Color` | Mono only; ignored otherwise. | +| `paletteSlot` | `uint8_t` | 2bpp/4bpp only; ignored otherwise. | diff --git a/api/generated/graphics/UISpriteRow.md b/api/generated/graphics/UISpriteRow.md new file mode 100644 index 0000000..ee6874e --- /dev/null +++ b/api/generated/graphics/UISpriteRow.md @@ -0,0 +1,212 @@ +# UISpriteRow + + + +**Source:** `UISpriteRow.h` + +**Inherits from:** [UIElement](./UIElement.md) + +## Description + +A UI leaf that draws a row of repeated icons whose fill is driven by + one value — hearts, lives, keys, ammo. + +**One element draws every icon.** The obvious alternative — a UILayout +holding N UISprite children — costs one scene entity per icon, and a row of +16 hearts would take two thirds of the 24-entity budget recommended for the +ESP32-C3. Worse, every one of those entities re-enters `Scene::sortEntities()` +(an insertion sort that runs each frame once depth sorting is on) to produce +an order that never changes. A single element sidesteps both. + +The engine does not know what a heart is. It knows a `value`, a `capacity`, +and how many units fill one icon: + +- `unitsPerIcon == 1` gives binary icons: a lives row, a key count. +- `unitsPerIcon == 2` gives half-steps: value 5 over 3 icons reads + full, full, half. +- Higher values give finer partial icons (quarter hearts at 4). + +State sprites are indexed by fill level: index 0 is empty, index +`unitsPerIcon` is full, and the values between are the partial steps. They +need not share a sprite format. + +`setCapacity()` may grow at runtime — a heart container picked up mid-game +widens the element, and any layout holding it sees the new preferred size. +`setIconsPerRow()` wraps onto further rows, which is how a heart bar longer +than the screen stays on screen. + + +```cpp +UISpriteRow hearts(Vector2(8, 8)); +hearts.setStateSprite(0, kHeartEmpty); +hearts.setStateSprite(1, kHeartHalf); +hearts.setStateSprite(2, kHeartFull); +hearts.setUnitsPerIcon(2); // half-heart granularity +hearts.setIconsPerRow(8); // wrap like the NES original +hearts.setCapacity(3); // three containers to start +hearts.setValue(6); // all full +hearts.setFixedPosition(true); // immune to camera scroll +scene.addEntity(&hearts); +``` + +## Inheritance + +[UIElement](./UIElement.md) → `UISpriteRow` + +## Methods + +### `explicit UISpriteRow(pixelroot32::math::Vector2 position)` + +**Description:** + +Constructs an empty row at `position`. + +### `void setStateSprite(int stateIndex, const Sprite& sprite, Color tint = Color::White)` + +**Description:** + +Assigns the 1bpp sprite drawn at fill level `stateIndex`. + +**Parameters:** + +- `stateIndex`: 0 = empty .. unitsPerIcon = full. Out-of-range + indices are ignored rather than clamped, so a caller's off-by-one + cannot silently overwrite a neighbouring state. +- `sprite`: Sprite to reference. Must outlive this element. +- `tint`: Color the set pixels are drawn in. + +### `void setStateSprite(int stateIndex, const Sprite2bpp& sprite, uint8_t paletteSlot = 0)` + +**Description:** + +Assigns the 2bpp sprite drawn at fill level `stateIndex`. + +**Parameters:** + +- `stateIndex`: 0 = empty .. unitsPerIcon = full. Out-of-range ignored. +- `sprite`: Sprite to reference. Must outlive this element. +- `paletteSlot`: Sprite palette slot to resolve colors through. + +### `void setStateSprite(int stateIndex, const Sprite4bpp& sprite, uint8_t paletteSlot = 0)` + +**Description:** + +Assigns the 4bpp sprite drawn at fill level `stateIndex`. + +**Parameters:** + +- `stateIndex`: 0 = empty .. unitsPerIcon = full. Out-of-range ignored. +- `sprite`: Sprite to reference. Must outlive this element. +- `paletteSlot`: Sprite palette slot to resolve colors through. + +### `void setUnitsPerIcon(uint8_t units)` + +**Description:** + +Sets how many units fill a single icon. + +**Parameters:** + +- `units`: Clamped to [1, kMaxStates - 1]. + +### `uint8_t getUnitsPerIcon() const` + +### `void setCapacity(uint8_t iconCount)` + +**Description:** + +Sets how many icons are drawn. + +### `uint8_t getCapacity() const` + +### `void setValue(int filledUnits)` + +**Description:** + +Sets the filled amount, in units. + +### `int getValue() const` + +### `void setSpacing(int pixels)` + +### `int getSpacing() const` + +### `void setRowSpacing(int pixels)` + +### `int getRowSpacing() const` + +### `void setIconsPerRow(uint8_t iconsPerRow)` + +**Description:** + +Sets how many icons fit on a row before wrapping. + +**Parameters:** + +- `iconsPerRow`: 0 disables wrapping (a single unbounded row). + +### `uint8_t getIconsPerRow() const` + +### `int stateIndexAt(int iconIndex) const` + +**Description:** + +Fill state of the icon at `iconIndex`. + +**Parameters:** + +- `iconIndex`: Icon position, 0-based. + +**Returns:** 0 (empty) .. unitsPerIcon (full). Returns 0 for an index outside + [0, capacity). + +Exposed because a game often needs the same answer the row draws with — +to flash the icon that just changed, or park a cursor on it. + +### `void iconOffsetAt(int iconIndex, int& outOffsetX, int& outOffsetY) const` + +**Description:** + +Offset of the icon at `iconIndex` relative to this element's + position, accounting for spacing and wrapping. + +**Parameters:** + +- `iconIndex`: Icon position, 0-based. +- `outOffsetX`: Receives the horizontal offset in pixels. +- `outOffsetY`: Receives the vertical offset in pixels. + +Both outputs are set to 0 for an index outside [0, capacity). + +### `void update(unsigned long deltaTime)` + +**Description:** + +No-op. The row has no internal clock; it renders whatever + setValue() last said. + +**Parameters:** + +- `deltaTime`: Ignored. + +### `void draw(pixelroot32::graphics::Renderer& renderer)` + +**Description:** + +Draws every icon, honoring isVisible and fixedPosition. + +**Parameters:** + +- `renderer`: Reference to the renderer. + +### `int iconWidth() const` + +**Description:** + +Widest sprite across the configured states. + +### `void recalcSize()` + +**Description:** + +Recomputes width/height from icon size, capacity and wrapping. diff --git a/api/generated/index.md b/api/generated/index.md index 6184545..d339c8e 100644 --- a/api/generated/index.md +++ b/api/generated/index.md @@ -69,6 +69,7 @@ The `apu` module documents PixelRoot32-APU `2.0.0`. - [GameplayEvent](./gameplay/GameplayEvent.md) — Fixed-size POD carried by the GameplayEventBus. - [GameplayEventBus](./gameplay/GameplayEventBus.md) — Fixed-capacity FIFO ring buffer for GameplayEvent, single-instance and Engine-owned. - [GameplayEventType](./gameplay/GameplayEventType.md) — Tag identifying the meaning of a GameplayEvent. +- [GridMotion](./gameplay/GridMotion.md) — Plain five-`int` aggregate: logical cell, target cell, progress. - [GridSpec](./gameplay/GridSpec.md) — Plain six-`int` aggregate describing a grid's origin, per-axis cell size, and extent (columns/rows). No member functions, no per-instance runtime state beyond these fields — a `constexpr @@ -146,6 +147,12 @@ The `apu` module documents PixelRoot32-APU `2.0.0`. - [UIManager](./graphics/UIManager.md) — Registry of touch UI elements for event routing (non-owning pointers). - [UIPaddingContainer](./graphics/UIPaddingContainer.md) — Container that wraps a single UI element and applies padding. - [UIPanel](./graphics/UIPanel.md) — Visual container that draws a background and border around a child element. +- [UISprite](./graphics/UISprite.md) — A UI leaf that draws a single sprite. +- [UISpriteFormat](./graphics/UISpriteFormat.md) — Which member of a UISpriteRef's storage is live. +- [UISpriteRef](./graphics/UISpriteRef.md) — Non-owning, format-tagged pointer to one sprite plus its draw + parameters. +- [UISpriteRow](./graphics/UISpriteRow.md) — A UI leaf that draws a row of repeated icons whose fill is driven by + one value — hearts, lives, keys, ammo. - [UITouchButton](./graphics/UITouchButton.md) — Touch-optimized button widget. - [UITouchCheckbox](./graphics/UITouchCheckbox.md) — Touch-optimized checkbox widget. - [UITouchElement](./graphics/UITouchElement.md) — UIElement with embedded UITouchWidget data for touch interaction. diff --git a/architecture/architecture-index.md b/architecture/architecture-index.md index a0a5322..d2f90ea 100644 --- a/architecture/architecture-index.md +++ b/architecture/architecture-index.md @@ -95,8 +95,8 @@ graph TD On ESP32 with **TFT_eSPI** (`TFT_eSPI_Drawer`), the logical framebuffer is typically an **8-bit color-depth sprite** (`TFT_eSprite`). Each frame: 1. **`Renderer::beginFrame()`** obtains a pointer to that buffer via **`DrawSurface::getSpriteBuffer()`** (when the driver supports it), clears the buffer, then draws the scene. -2. **2bpp / 4bpp tilemaps and sprites** can write **directly into that buffer** (matching TFT_eSPI's 8bpp packing for RGB565), avoiding a virtual `drawPixel` per pixel where possible. -3. **`present()` / `sendBuffer()`** converts logical 8bpp rows to **RGB565** using a LUT and pushes pixels to the panel via **DMA**. +2. **1bpp / 2bpp / 4bpp tilemaps and sprites** write **directly into that buffer** (matching TFT_eSPI's 8bpp packing for RGB565), avoiding a virtual `drawPixel` per pixel. The 1bpp path — all text, `MultiSprite` layers and 1bpp tilemaps — joined the direct path in `d6dc9ae`; the virtual route remains as the fallback for surfaces without an 8bpp buffer (U8G2, SDL2). +3. **`present()` / `sendBuffer()`** converts logical 8bpp rows to **RGB565** — or to packed **RGB444** when `PIXELROOT32_TFT_12BIT_COLOR=1` — using a LUT and pushes pixels to the panel via **DMA**. The last block of the frame is left in flight and flushed at the start of the next `sendBuffer()`, so its SPI time overlaps the next frame's work; see the **shared SPI bus contract** in [Driver Layer](./layer-drivers.md#tft_espi-driver). ### Static Tilemap Layer Cache diff --git a/architecture/index.md b/architecture/index.md index d8ea3b6..af82525 100644 --- a/architecture/index.md +++ b/architecture/index.md @@ -96,8 +96,8 @@ graph TD On ESP32 with **TFT_eSPI** (`TFT_eSPI_Drawer`), the logical framebuffer is typically an **8-bit color-depth sprite** (`TFT_eSprite`). Each frame: 1. **`Renderer::beginFrame()`** obtains a pointer to that buffer via **`DrawSurface::getSpriteBuffer()`** (when the driver supports it), clears the buffer, then draws the scene. -2. **2bpp / 4bpp tilemaps and sprites** can write **directly into that buffer** (matching TFT_eSPI's 8bpp packing for RGB565), avoiding a virtual `drawPixel` per pixel where possible. -3. **`present()` / `sendBuffer()`** converts logical 8bpp rows to **RGB565** using a LUT and pushes pixels to the panel via **DMA**. +2. **1bpp / 2bpp / 4bpp tilemaps and sprites** write **directly into that buffer** (matching TFT_eSPI's 8bpp packing for RGB565), avoiding a virtual `drawPixel` per pixel. The 1bpp path — all text, `MultiSprite` layers and 1bpp tilemaps — joined the direct path in `d6dc9ae`; the virtual route remains as the fallback for surfaces without an 8bpp buffer (U8G2, SDL2). +3. **`present()` / `sendBuffer()`** converts logical 8bpp rows to **RGB565** — or to packed **RGB444** when `PIXELROOT32_TFT_12BIT_COLOR=1` — using a LUT and pushes pixels to the panel via **DMA**. The last block of the frame is left in flight and flushed at the start of the next `sendBuffer()`, so its SPI time overlaps the next frame's work; see the **shared SPI bus contract** in [Driver Layer](./layer-drivers.md#tft_espi-driver). ### Static Tilemap Layer Cache diff --git a/architecture/layer-drivers.md b/architecture/layer-drivers.md index 83e60ca..ac027b6 100644 --- a/architecture/layer-drivers.md +++ b/architecture/layer-drivers.md @@ -27,8 +27,12 @@ The primary color display driver using the popular TFT_eSPI library. - DMA support for fast transfers - Resolution scaling (nearest-neighbor) - Double-buffering for smooth rendering +- Deferred DMA wait: the last block of a frame stays in flight and is flushed at the start of the next `sendBuffer()`, so the tail of the SPI transfer overlaps the next frame's update and draw +- Optional 12-bit RGB444 wire format (`PIXELROOT32_TFT_12BIT_COLOR`, off by default, experimental) -**Future optimization (Opción B — diseño, no implementado):** `sendBufferScaled()` hoy envía el rectángulo completo (**`setAddrWindow`** + bloques DMA). Una variante sería **comparar** el sprite 8 bpp contra una **copia del frame anterior** (o un diff por bandas) y emitir **varias ventanas** SPI solo donde cambiaron píxeles. Mejora el techo SPI cuando el área sucia es pequeña; coste típico **~W×H bytes** RAM y más llamadas a **`setAddrWindow`**. La **Opción A** (omitir `draw`+`present` en el **`Engine`** cuando la escena lo indica) está descrita en [ESP32 rendering](../ARCHITECTURE.md#esp32-rendering-pipeline-and-tilemap-caching). +**Shared SPI bus contract**: because the last DMA block of a frame is still in flight when `sendBuffer()` returns, **any code that touches the SPI bus, the TFT, or frees/reallocates the DMA line buffers must call `TFT_eSPI_Drawer::waitForPendingDMA()` first**. Otherwise it either corrupts the still-open SPI transaction or reads a buffer DMA is streaming from. The engine already guards the touch bridge, `freeScalingBuffers()`, the destructor, `init()` and `setRotation()`; a **new** peripheral on the shared bus (SD card, second display, raw SPI sensor) has to add the same call. It is a no-op when nothing is pending. See [ESP32 Performance Guide](../guide/performance/esp32-performance.md#shared-spi-bus-contract). + +**Future optimization (Option B — designed, not implemented):** `sendBufferScaled()` currently pushes the whole rectangle (**`setAddrWindow`** + DMA blocks). A variant would **compare** the 8bpp sprite against a **copy of the previous frame** (or a banded diff) and emit **several** SPI windows only where pixels changed. It raises the SPI ceiling when the dirty area is small; typical cost is **~W×H bytes** of RAM and more `setAddrWindow` calls. **Option A** (skipping `draw` + `present` in the **`Engine`** when the scene says so) is described in [ESP32 rendering](./architecture-index.md#esp32-rendering-pipeline-and-tilemap-caching). See finding **C-4** in the [ESP32 Performance Audit](../performance-audit-esp32.md) for the break-even math. **Supported Displays**: - ST7789 (240x240, 320x240) diff --git a/architecture/memory-system.md b/architecture/memory-system.md index a396901..6d10bd6 100644 --- a/architecture/memory-system.md +++ b/architecture/memory-system.md @@ -63,7 +63,7 @@ When subsystems are disabled via `PIXELROOT32_ENABLE_*` flags, their memory allo |------|-------------|--------------|-------------------| | `PIXELROOT32_ENABLE_AUDIO=0` | ~8 KB | ~15 KB | AudioEngine, MusicPlayer, audio buffers | | `PIXELROOT32_ENABLE_PHYSICS=0` | ~12 KB | ~25 KB | CollisionSystem, spatial grid, physics actors | -| `PIXELROOT32_ENABLE_UI_SYSTEM=0` | ~4 KB | ~20 KB | UIElement, all layouts, UI containers | +| `PIXELROOT32_ENABLE_UI_SYSTEM=0` | ~4 KB | ~20 KB | UIElement, all layouts, UI containers, sprite elements | | `PIXELROOT32_ENABLE_PARTICLES=0` | ~6 KB | ~10 KB | ParticleEmitter, particle pools | | **All disabled** | **~30 KB** | **~70 KB** | Maximum savings | @@ -106,8 +106,26 @@ Confirmed against the shipped `include/gameplay/StateMachine.h` layout — field | Flag | Default | RAM Cost When Enabled | Subsystem Added | |------|---------|------------------------|------------------| | `PIXELROOT32_ENABLE_GAMEPLAY_GRID_SPACE=1` | `0` | 0 B SRAM | `gameplay::GridSpace.h` — grid-to-world/world-to-grid coordinate conversion (`GridSpec`, `cellToWorldX/Y`, `cellToWorld`, `worldToCellX/Y`, `containsCell`) | +| `PIXELROOT32_ENABLE_GAMEPLAY_GRID_SPACE=1` | `0` | 20 B SRAM **per moving actor** | `gameplay::GridMotion.h` — per-actor cell-to-cell step state (`GridMotion`, `isMoving`, `placeAt`, `beginStep`, `tickStep`, `interpolatedWorld`) | -**`GridSpec` byte budget:** every shipped consumer (`examples/snake`, `examples/tic_tac_toe`) declares its grid as `inline constexpr GridSpec`. `constexpr` implies `const`, so the six-`int` aggregate lands in `.rodata`/flash, never `.data`/`.bss` — **0 B SRAM**, at every optimization level, independent of whether the optimizer also folds the constant away entirely. `sizeof(GridSpec) == 24 B` (six `int`s — `int` is 4 B under both the ESP32-C3's ILP32 and native's LP64), identical on both targets. A non-`constexpr` (runtime) `GridSpec` would cost 24 B SRAM instead; no shipped consumer uses one. +**`GridSpec` byte budget:** every shipped consumer (`examples/snake`, `examples/2048`, `examples/bomberbot`) declares its grid as `inline constexpr GridSpec`. `constexpr` implies `const`, so the six-`int` aggregate lands in `.rodata`/flash, never `.data`/`.bss` — **0 B SRAM**, at every optimization level, independent of whether the optimizer also folds the constant away entirely. `sizeof(GridSpec) == 24 B` (six `int`s — `int` is 4 B under both the ESP32-C3's ILP32 and native's LP64), identical on both targets. A non-`constexpr` (runtime) `GridSpec` would cost 24 B SRAM instead; no shipped consumer uses one. + +**`GridMotion` byte budget:** unlike `GridSpec`, a `GridMotion` is inherently per-actor runtime state, so it does land in `.bss`. `sizeof(GridMotion) == 20 B` (five `int`s, identical on ILP32 and LP64). Worst case is one instance per grid-moving actor: `examples/bomberbot` embeds one in `PlayerActor` and one in each of its `kMaxEnemies` pool slots. Against the ESP32-C3 ceiling of 24 entities that is **480 B** if every entity moves on the grid — comfortably inside budget, and typically far lower since static actors (walls, bombs, pickups) need none. `GridMotion` shares `GridSpace`'s flag rather than taking its own: `interpolatedWorld()` takes a `GridSpec`, so "motion without space" is not a reachable configuration. + +**`GridMotion` scope, and what it deliberately excludes:** it owns the logical cell, the in-flight target, the arrival edge and the cell-to-pixel lerp — the mechanics. Cell-enterability tests, direction selection, arrival reactions and input buffering stay in game code, because every shipped consumer answers them differently: `bomberbot`'s player treats the bomb it just dropped as passable while its enemies treat every bomb as solid, and neither buffers direction input (both sample direction only at rest and ignore it in flight). Modelling those as engine callbacks would cost more configuration than the ~17 lines of mechanics it replaces. + +**Sprite UI elements (`UISprite` / `UISpriteRow`, under `PIXELROOT32_ENABLE_UI_SYSTEM`):** the UI system drew text and rectangles only, so any icon — an item slot, a dialog portrait, a button glyph, a resource HUD — had to be drawn by hand in a `Scene::draw()` override, outside the entity tree. `UISpriteRef` (`include/graphics/ui/UISpriteRef.h`) is the tagged union that lets one element handle all three sprite descriptors (`Sprite`, `Sprite2bpp`, `Sprite4bpp`), each of which has a different draw signature. The format switch exists in exactly one place, `drawUISpriteRef()` — type erasure over templating, the same trade the gameplay framework made, for the same reason: one copy in flash instead of one per instantiation. + +| Type | native/PC (64-bit) | Notes | +|---|---|---| +| `UIElement` (base) | 40 B | For reference | +| `UISpriteRef` | 16 B | Pointer + format tag + tint/palette slot | +| `UISprite` | 64 B | Base + one ref + flip flag. **Smaller than `UILabel` (80 B)**, which carries a `std::string` | +| `UISpriteRow` | 136 B | Base + `kMaxStates` (5) refs + value/capacity/spacing scalars | + +With `PIXELROOT32_ENABLE_UI_SYSTEM=0` all three translation units compile to an empty object (434 B of container headers, zero code) — verified, not assumed. + +**Why `UISpriteRow` is one element and not a layout of N `UISprite`:** the obvious composition — `UIHorizontalLayout` holding one `UISprite` per icon — costs one scene entity per icon. A 16-heart bar would take two thirds of the 24-entity budget recommended for the ESP32-C3 (see the variant table above), and every one of those entities re-enters `Scene::sortEntities()` — an insertion sort that runs each frame once depth sorting is on — to produce an order that never changes. `UISpriteRow` draws N icons from one entity and 136 B instead. Its `capacity` is a plain `uint8_t` counter, not a per-icon array, so growing the row at runtime (a heart container) costs no additional storage. **Gameplay Framework Phase 3 part 2 — Room/Screen (opt-in, default `0`):** `RoomGraph` is a header-only template class under `PIXELROOT32_ENABLE_GAMEPLAY_ROOM`. A `Scene` owns it via a type-erased `RoomGraphBase*` pointer (composition, no inheritance). Entering a room updates camera bounds and fires an optional `onEnter` callback. The flag defaults to `0` — when disabled the entire `#if` block is excluded and the engine contributes zero bytes. @@ -714,6 +732,9 @@ In v1.0.0, the `TFT_eSPI_Drawer` uses double-buffering for DMA. Increasing `LINE - **Optimized**: 60 lines = ~30KB - **Max**: 120 lines = ~60KB (Half frame) +> [!NOTE] +> Figures above are per buffer, and the driver allocates two. The `60`-line setting was unreachable before `d6dc9ae` (a buffer-selection bug always forced the 30-line fallback); builds from that commit onward get the documented size, falling back only when DMA-capable internal RAM is short. Enabling `PIXELROOT32_TFT_12BIT_COLOR=1` shrinks each buffer by 25% (60 lines at 240 width: ~28.8KB → ~21.6KB) at the cost of a 768-byte pair LUT — see [ESP32 Performance Guide](../guide/performance/esp32-performance.md#12-bit-color-on-the-wire-rgb444). + > [!IMPORTANT] > Non-FPU platforms like ESP32-C3 have more limited SRAM. Be cautious when increasing DMA block sizes or logical resolutions. diff --git a/examples/bomberbot.md b/examples/bomberbot.md index 7de1535..d79d71e 100644 --- a/examples/bomberbot.md +++ b/examples/bomberbot.md @@ -32,6 +32,14 @@ every environment inherits them: the whole `GridSpace.h` header lives behind this flag (default `0`), so the example does not compile without it. - **`PIXELROOT32_ENABLE_AUDIO=1`** — needed for the four sound events below. +- **`PIXELROOT32_ENABLE_DEPTH_SORT=1`** — Y-axis ordering between the player + and the enemies, so whoever stands lower on the board is drawn in front. + Two details make it work: the comparator is consulted **only between entities + on the same render layer**, so `BoardRenderer` (layer 0) stays behind every + actor (layer 1) regardless of its Y; and `depthSortEnabled` is set because + actors move every frame, whereas the default only re-sorts when an entity is + added or removed. The comparator keys on the sprite's **bottom** edge, not its + top, because the feet are what read as the contact point with the floor. - **`PIXELROOT32_ENABLE_PHYSICS=0`** — the physics system is never used; every blocking/collision check here is a board lookup, not a physics query. - **`PIXELROOT32_ENABLE_PARTICLES=0`** — not used. @@ -110,13 +118,13 @@ pickup use; they are not currently triggered by the scene. The player's start cell and its two adjacent cells are guaranteed free of soft walls. - **Interpolated grid movement.** The player moves at 12 logic steps per - cell, enemies at 20. Both use the same small `GridMove` struct - (`src/GridMove.h`) but each actor writes its own advance loop — the - player stays put when blocked and reads held input; an enemy re-picks a - direction and reads the seeded PRNG. The two loops are deliberately not - merged into a shared controller; they disagree on enough policy (blocking - behavior, direction source, and the pass-through rule below) that sharing - more than the struct would need extra flags for every difference. + cell, enemies at 20. Both drive the engine's `gameplay::GridMotion` + (`include/gameplay/GridMotion.h`), which owns the logical cell, the + in-flight target, the arrival edge and the cell-to-pixel lerp. Movement + *policy* stays per-actor and is deliberately not shared: the player stays + put when blocked and reads held input; an enemy re-picks a direction and + reads the seeded PRNG. Those decisions, plus the pass-through rule below, + disagree between the two actors, so `GridMotion` does not model them. - **Own-bomb pass-through.** A player can always leave the cell they just bombed; that bomb becomes solid again the instant they arrive in a new cell. Enemies have no such exemption — every bomb is solid to them. diff --git a/examples/camera.md b/examples/camera.md index fcf4304..e6cec33 100644 --- a/examples/camera.md +++ b/examples/camera.md @@ -4,10 +4,14 @@ Side-scrolling platformer-style demo that showcases **`Camera2D`** (smoothing and horizontal bounds), **parallax background layers**, **`KinematicActor`** movement with tile-based ground and one-way platforms (`StaticActor`, collision layers), and **directional iris scene transitions** between two scenes. The world is wider than the screen so the camera follows the player. +It is also the reference for the two opt-in camera capabilities, **`CameraEffects`** (shake, punch, offset) and **`CameraTween`** (scripted pans). Both are demonstrated here rather than in a standalone menu because the interesting part is how they coexist with a camera that is already following a player — see [Effects and tweens](#effects-and-tweens). + ## Requirements (build flags) - **`PIXELROOT32_ENABLE_SCENE_ARENA`** - **`PIXELROOT32_ENABLE_SCENE_TRANSITIONS`** (enabled by default in `PlatformDefaults.h`) +- **`PIXELROOT32_ENABLE_CAMERA_EFFECTS=1`** — shake / punch / offset +- **`PIXELROOT32_ENABLE_CAMERA_TWEEN=1`** — the scripted camera pan Additional engine features (physics actors, tilemaps) follow defaults from [`PlatformDefaults.h`](../../include/platforms/PlatformDefaults.h). See **`platformio.ini`** in this folder for `native` and **`esp32dev`** presets. @@ -26,6 +30,35 @@ The engine version or Git branch is set in **`lib_deps`** in `platformio.ini`. - **Left / Right** — move (buttons **2** and **3** in `InputManager` order). - **Jump** — button **4** (edge-triggered after release so hold does not spam jump). +- **B** — button **5**, fires the next camera effect in the cycle: shake, punch up/down/left/right, offset, then back to shake. The active one is named in the top-left corner. +- **Up** — button **0**, pans the camera to the third platform, holds, and pans back. +- **Down** — button **1**, cancels every active effect. + +Landing after a fall fires a short downward punch on its own, which is the more +realistic way to use the capability: an effect keyed to a game event rather than +to a button. + +## Effects and tweens + +The two capabilities look similar and behave very differently, which is the +reason they share one example. + +**An effect is an offset, not a position.** `cameraEffects` resolves to a single +`Vector2` per frame, read through `getCameraEffectOffset()` and added to the +display offset at draw time. The camera's own position is never touched, so +`followTarget()` keeps working underneath a shake with no coordination at all. +Note that the offset is added to every parallax layer here — shaking only the +foreground would visibly tear the background away from it. + +**A tween is a position.** `CameraTween::update()` calls `camera.setPosition()` +directly. That means it competes with `followTarget()` for the same value, and +calling both in the same frame makes the follow win every time and the tween +appear to do nothing. This example suspends following for the duration of the +pan (`TourStage != Idle`), which is the pattern to copy. + +The return leg targets wherever the player is when the hold ends, not where the +camera started — the player is free to keep walking during the pan, and +returning to a stale position would snap the camera on the next follow frame. ## Scenes diff --git a/examples/demos.md b/examples/demos.md index 9ae5087..61bd4ea 100644 --- a/examples/demos.md +++ b/examples/demos.md @@ -22,15 +22,14 @@ The engine revision for each example is defined in `**lib_deps**` inside that ex - [bomberbot](./bomberbot) — [source code](https://github.com/PixelRoot32-Game-Engine/PixelRoot32-Game-Engine/tree/main/examples/bomberbot) - [brick_breaker](./brick_breaker) — [source code](https://github.com/PixelRoot32-Game-Engine/PixelRoot32-Game-Engine/tree/main/examples/brick_breaker) - [camera](./camera) — [source code](https://github.com/PixelRoot32-Game-Engine/PixelRoot32-Game-Engine/tree/main/examples/camera) -- [camera-effect-demo](./camera-effect-demo) — [source code](https://github.com/PixelRoot32-Game-Engine/PixelRoot32-Game-Engine/tree/main/examples/camera-effect-demo) - [dual_palette](./dual_palette) — [source code](https://github.com/PixelRoot32-Game-Engine/PixelRoot32-Game-Engine/tree/main/examples/dual_palette) - [flappy_bird](./flappy_bird) — [source code](https://github.com/PixelRoot32-Game-Engine/PixelRoot32-Game-Engine/tree/main/examples/flappy_bird) - [hello_world](./hello_world) — [source code](https://github.com/PixelRoot32-Game-Engine/PixelRoot32-Game-Engine/tree/main/examples/hello_world) +- [legend_of_clone](./legend_of_clone) — [source code](https://github.com/PixelRoot32-Game-Engine/PixelRoot32-Game-Engine/tree/main/examples/legend_of_clone) - [metroidvania](./metroidvania) — [source code](https://github.com/PixelRoot32-Game-Engine/PixelRoot32-Game-Engine/tree/main/examples/metroidvania) +- [midway_clone](./midway_clone) — [source code](https://github.com/PixelRoot32-Game-Engine/PixelRoot32-Game-Engine/tree/main/examples/midway_clone) - [music-demo](./music-demo) — [source code](https://github.com/PixelRoot32-Game-Engine/PixelRoot32-Game-Engine/tree/main/examples/music-demo) - [physics](./physics) — [source code](https://github.com/PixelRoot32-Game-Engine/PixelRoot32-Game-Engine/tree/main/examples/physics) - [room_screen](./room_screen) — [source code](https://github.com/PixelRoot32-Game-Engine/PixelRoot32-Game-Engine/tree/main/examples/room_screen) - [snake](./snake) — [source code](https://github.com/PixelRoot32-Game-Engine/PixelRoot32-Game-Engine/tree/main/examples/snake) -- [space_invaders](./space_invaders) — [source code](https://github.com/PixelRoot32-Game-Engine/PixelRoot32-Game-Engine/tree/main/examples/space_invaders) -- [sprites](./sprites) — [source code](https://github.com/PixelRoot32-Game-Engine/PixelRoot32-Game-Engine/tree/main/examples/sprites) -- [tic_tac_toe](./tic_tac_toe) — [source code](https://github.com/PixelRoot32-Game-Engine/PixelRoot32-Game-Engine/tree/main/examples/tic_tac_toe) \ No newline at end of file +- [sprites](./sprites) — [source code](https://github.com/PixelRoot32-Game-Engine/PixelRoot32-Game-Engine/tree/main/examples/sprites) \ No newline at end of file diff --git a/examples/legend_of_clone.md b/examples/legend_of_clone.md new file mode 100644 index 0000000..d87520c --- /dev/null +++ b/examples/legend_of_clone.md @@ -0,0 +1,461 @@ +# The Legend of Clone — screen transitions + + +> **⚠️ Demonstration example** — This project is provided **as an example** to showcase the capabilities of the PixelRoot32 Game Engine and what you can build with it. It may **not be 100% functional or finished**; some features can be incomplete, experimental, or work in progress. + + +An 8-bit-style overworld and the dungeon under it. Two scenes, four rooms each, +and one player who walks between them. + +The name is the honest label. This is a **clone** of The Legend of Zelda, built to exercise engine +features — room graphs, scrolling screen transitions, scene fades, 4bpp +tilemaps — against a layout everyone already knows, so the machinery is the +thing under review and not the level design. It is not a reproduction: the maps +are hand-authored rather than ripped, the hero carries nothing and is nobody in +particular, and there are no enemies, items or combat. + +It started as an answer to one question — *does a player walking off the edge +of one screen land correctly on the next?* — and the dungeon is the same +question asked again somewhere the answer had better not be different. + +``` + OVERWORLD DUNGEON ++----------------+----------------+ +----------------+----------------+ +| 0 north-west | 1 north-east | | 0 north-west | 1 north-east | ++----------------+----------------+ +----------------+----------------+ +| 2 START [C] | 3 south-east | | 2 ENTRANCE [S]| 3 south-east | ++----------------+----------------+ +----------------+----------------+ + + [C] cave mouth <-----------------> [S] stairs +``` + +## Running it + +```bash +pio run -e native -t exec # SDL2 desktop +pio run -e esp32dev -t upload # ESP32 + ST7789 240x240 +``` + +Arrow keys walk. Walk into a gap in the border and the screen scrolls. Walk +into the black cave mouth on the start screen and the picture fades into the +dungeon; walk onto the staircase you arrive beside and it fades back out, +putting you below the cave rather than at the start of the game. + +## What it demonstrates + +| Engine capability | Where | +| --- | --- | +| `RoomGraph` + `buildRoomGraph()` | `TopDownScene::init()` | +| Exported room-layer data contract | `assets/OverworldRooms.h` | +| Camera pinned per room, not following | `TopDownScene::snapCameraToRoom()` | +| `onEnter` hook bridged to `Scene::onRoomEnter` | `TopDownScene::onRoomEnterCallback()` | +| `Engine::triggerTransition()` fade between scenes | `OverworldScene::onPlayerSettled()` | +| Exported 4bpp tilemaps and sprites, flash-resident | `assets/OverworldTileMap.cpp` | +| Dual palette mode — world and player, 16 slots each | `TopDownScene::init()` | +| `StaticTilemapLayerCache` on a pinned camera | `TopDownScene::draw()` | +| Sprite flipping as animation, NES-style | `PlayerActor::draw()` | +| Offset bypass for a non-scrolling HUD strip | `drawStatusBar()` | + +The asset shapes here follow **[metroidvania](../metroidvania/)**, which is the +reference for how this engine expects tilemaps and sprites to be fed to it: +tileset pools and map indices in flash behind `PIXELROOT32_SCENE_FLASH_ATTR`, +read through `PIXELROOT32_READ_BYTE_P`, drawn through `StaticTilemapLayerCache`, +with every header gated on `PIXELROOT32_ENABLE_4BPP_SPRITES`. + +## Screen layout + +The NES splits its 256x240 output into a 256x176 playfield and a 64 px status +bar. This example keeps that split on the engine's reference 240x240 panel: a +**240x176 playfield** (15x11 tiles at 16 px) with the status bar **below** it. + +The bar sits at the bottom rather than the top on purpose. With the playfield +anchored at screen y = 0, world y maps straight to screen y and the camera +offset is the only transform in play. A top bar would push a constant +64 into +every world coordinate, tilemap origin and hit test in the example. + +The camera viewport is the playfield (240x176), not the panel. Because the +renderer's logical surface is still 240 px tall, world rows below the current +room would bleed into the strip — so the bar is drawn last, and opaque. + +## Two scenes, one set of machinery + +The overworld and the dungeon are separate `Scene`s. What they have in common — +a room grid, a camera pinned per room, the scrolling change between rooms, +collision, the player — lives in `TopDownScene`, and each concrete scene is +about eighty lines: its map, its status readout, and the one tile that changes +scene. + +That split is not tidiness. The room transition below contains one step that is +easy to leave out and impossible to spot afterwards, and a second copy of it in +the dungeon would have been a second chance to get it wrong. + +`TopDownScene::Setup` is what a scene hands over: two palettes (world and +player), a room layer, a `TileWorld` already attached to its export, and where +to put the player. Two hooks are optional — `drawStatusBar()` and +`onPlayerSettled()`, the latter being where a scene reacts to the tile the +player is standing on. + +`onPlayerSettled()` is deliberately not called mid-slide. During a room change +the player is being interpolated across a seam and passes over tiles they never +stepped on; firing a cave entrance from one of those would be a teleport nobody +asked for. + +### init() runs again every time + +`SceneManager::setCurrentScene()` calls `init()` on the scene it swaps to. Every +time. A scene you have already visited is re-initialised from scratch when you +come back to it, and two things in this example exist only because of that: + +**The spawn is remembered.** `OverworldScene` records where the player +should reappear *before* it starts the fade into the dungeon. Without it, +climbing the stairs back out would drop you at the start of the game. + +**The room graph is reset.** `buildRoomGraph()` **appends** — that is how +several exported layers are stitched into one graph — so a second call on a +graph that is already full adds nothing and returns 0. The scene therefore does +`rooms_ = RoomGraph{}` before every build. + +**The tilemap snapshot is dropped.** A framebuffer cached under the previous +scene's palette and tiles is worthless to this one, so `init()` calls `clear()` +and `invalidate()` before taking the buffer back with `allocateForRenderer()`. +Allocating at init rather than in `draw()` is what keeps the game loop off the +heap. + +That second one shipped broken: leaving the dungeon rebuilt no rooms, +`worldReady_` went false, and the scene stopped drawing. It presented as a black +screen rather than as the "MAP DATA REJECTED" message that was supposed to catch +exactly this, because the message was being drawn in **world space** — with the +dungeon camera's leftover offset still in the renderer, it landed sixty pixels +above the top of the screen. The check was right and its output was invisible. +Failure paths draw in screen space now. + +## How a screen change works + +The camera never follows the player. It sits pinned at the current room's +origin, which is what makes the overworld read as a grid of fixed screens +rather than a scrolling field. + +When the player's box crosses a room edge and that edge has a connection: + +1. **Camera bounds widen to the union of both rooms.** `Camera2D::setPosition` + clamps to its bounds, and those bounds are still the room being left. Skip + this and the slide pins to the old room's edge — the transition runs, the + timer expires, and nothing appears to move. +2. **The player is disabled.** `Scene::update` skips disabled entities, so this + is the whole of the input lockout. +3. **Camera and player interpolate together** over `kTransitionDurationMs`. + The player slides rather than teleports because an 8-bit top-down walks you the last + few pixels across the seam while the screen scrolls. Teleporting looks like + a cut. +4. **`RoomGraph::enterRoom()` on arrival** resets the bounds to the new room + and fires the `onEnter` hook. + +The crossing test fires on the *leading edge* — the frame any pixel of the +player enters the neighbouring screen, not once they are halfway across. + +### Why not CameraTween + +`CameraTween` interpolates a camera and nothing else. This transition has to +move the player in lockstep with it, which needs a timer the scene owns +anyway — at which point the camera lerp is two lines and a second timer inside +`CameraTween` would only be state to keep in sync. If a later iteration adds a +transition that moves the camera alone, `CameraTween` is the right tool for it. + +## The asset pipeline + +`src/assets/` is **generated code**, in the shape the PixelRoot32 Tilemap Editor +and Sprite Compiler produce. The runtime is written against that shape rather +than against anything this example invented — which is the point of the whole +folder. + +``` +character art ──exporter──► src/assets/*.h|.cpp packed 4bpp in flash +(not versioned) (versioned, what builds) +``` + +**Only the right-hand side is in the repository.** The exporter and its +character-art source are development tooling and live outside the tree, under +the gitignored `docs/audits/_dev_tools/legend_of_clone/`, next to the other +asset scripts. That is the same arrangement metroidvania has: its files say +`// Generated by PixelRoot32 Sprite Compiler` and the compiler is not in the +repository either. Cloning this repo gets you an example that builds; it does +not get you the art pipeline, and it does not need to. + +So treat `src/assets/*` as build output. The header on every file says as much, +and a hand edit there is lost on the next export. + +### What the exporter is, for the record + +Art is authored as **characters, one per pixel** — `art_source.py` — rather than +as hex. A bush you can see in a diff is worth more during review than 128 bytes +of hex, and eight rooms of level design are only reviewable as a grid. But the +authoring format **stops at the exporter**: what ships is packed bytes in flash, +identical to an editor export, with no packer, no `.bss` and no startup cost. + +That distinction is the correction this example needed. An earlier version +shipped the character art *as the runtime format* and unpacked it into RAM at +`init()`. Readable, and wrong: it spent 4,864 bytes of RAM on a convenience that +belongs at build time. + +A second script, `check_maps.py`, validates both maps against their room tables +and the doorway constants — a border declaring a connection the tiles wall off, +an edge opening the room graph knows nothing about, a spawn cell sitting on the +very tile that triggers a scene change. Each of those produces a game that runs +and misbehaves, which no compiler will catch. + +## Map data + +Both maps are authored as **22 rows of 30 characters**, one per tile, and +exported to a `TERRAIN_INDICES` array. The legend: + +``` +OVERWORLD DUNGEON +'.' sand walkable '.' floor walkable +',' grass patch walkable '#' wall blocking +'B' bush blocking 'S' stairs walkable, leaves the dungeon +'T' forest blocking +'#' mountain blocking +'C' cave mouth walkable, enters the dungeon +``` + +Alongside the indices, each tileset exports a `TILE_SOLID` table. Collision is a +property of the **tile**, not of a second layer — a bush blocks wherever it is — +so the two come out of one source and cannot drift apart. `TileWorld` is what +pairs them at runtime: three pointers, no data of its own. + +> This is where this example diverges from metroidvania, which derives collision +> from a separate `platforms` layer. That is the right answer *there*, because a +> platform tile and a background tile can share art. Here tile type **is** +> collision, so a 7-byte table beats a 660-byte layer. + +The cave mouth and the stairs are **walkable**. They have to be — you enter a +cave in a game like this by walking into it, not by bumping into it — which is why both are +placed inside a room rather than in its border. A walkable cell in a border +would be an opening the room graph knows nothing about. + +The overworld screens are hand-authored to read like the start area of the NES +first quest — mountains along the north, forest walling in the south, the cave +in a rock face. **They are not tile-exact rips of the original ROM.** Dungeon +rooms are a 2-tile-thick wall around an 11x7 interior, which is the NES dungeon +room proportioned to a 15-wide screen. Doorways are two tiles wide so the +16-pixel player walks through without being tile-aligned; the original gets away +with one-tile doors only because it nudges you onto the grid as you pass. + +Room seams have to line up by hand: the open cells on one side of a border must +face open cells on the other, or the connection is decorative and the player +walks into a wall. The seams are marked in each map's comments. + +## Colors + +A `Color` in this engine is a **palette slot, not an RGB value** — the same +enumerator resolves differently under each palette. So the NES look is not a +set of tinted draws; it is a custom 16-entry RGB565 table installed at `init()`, +after which `Color::Yellow` *is* sand. + +The scene runs in **dual palette mode**, which is the metroidvania arrangement: + +```cpp +enableDualPaletteMode(true); +setBackgroundCustomPalette(TILEMAP_PALETTE_DATA); // the world +setSpriteCustomPalette(PLAYER_SPRITE_PALETTE_RGB565); // the hero +``` + +Two tables of sixteen instead of one. The previous version shared a single +table, which meant three of the world's slots were spent on the hero's skin, tunic +and brown. They are no longer competing. + +**The dungeon shares the world's table.** Not a compromise — it already carried +a navy, a blue and a grey, so blue walls and stone stairs needed no new entries. +A dungeon that *did* want its own would pass a different pointer; +`TopDownScene::Setup` takes one per scene precisely so that stays a one-line +change. + +### Two indices, and why they are not the same + +Each palette is exported as **two** arrays, and conflating them is the mistake +the split exists to prevent: + +| | Indexed by | What it holds | +| --- | --- | --- | +| `..._PALETTE_MAPPING` | 4bpp **pixel value** | a `Color` slot | +| `..._PALETTE_DATA` | `Color` **slot** | RGB565 | + +The blitter treats pixel value 0 as transparent and never reads entry 0, so +black — which every outline, the cave mouth and the whole dungeon floor need — +cannot live at pixel value 0. It lives at 1 and maps to `Color::Black`. + +The mapping is therefore **not** the identity, and it must not be forced to be. +Metroidvania's happens to be identity because nothing in its art collides with +that rule; compacting this one the same way put `Color::White` at black and +would have drawn the status bar text invisibly. Colors keep their conventional +slots, so anything that names a color instead of indexing art — status text, +the `MAP DATA REJECTED` message — still means what it says. + +## Art + +Every 16x16 image is authored as **16 strings of 16 characters** and exported to +packed 4bpp. The characters mean: + +``` +'k' black 'g' green 'o' rock, lit +'w' white 'l' tunic green 'r' rock, shaded +'n' shadow '.' sand 's' skin +'d' dark green 'h' hero brown +' ' transparent +``` + +Nibble order matters and is easy to get wrong: within a byte the **low** nibble +is the left pixel of the pair. Reversing it mirrors every pair of pixels, which +looks almost right — harder to spot than art that looks broken. The exporter +owns that detail now, so it is decided once rather than per asset. + +Adding a color costs nothing until art references it: the exporter builds each +palette from the characters that group actually uses. + +## The player + +The hero is **three colors and a hole** — tunic green, skin, and one brown doing +outline, hair, boots and belt at once. That is one NES sprite palette exactly, +which was the whole budget an 8-bit cartridge had for a protagonist. + +He has **no black in him**. Outlining a sprite in black is the fastest way to +make it stop reading as an 8-bit character; it flattens into a sticker. + +**He carries nothing in either hand,** and that is a design constraint rather +than an omission. It keeps clear daylight between this hero and the obvious +inspiration — and it pays for itself a second time in the walk cycle below. + +### The walk cycle + +**Two frames, always** — a toggle, not a sequence. `PlayerActor::walkFrame_` is +one bit for that reason. + +**A frame lasts 100 ms**, which is 6 frames at ~60 Hz — the cadence 8-bit +top-down walks are built on. `kWalkFrameMs`. + +**The cycle is keyed on input, not on movement.** Pressed against a wall the +hero keeps walking on the spot, and releasing the D-pad freezes him on whatever +frame he was on rather than snapping to a neutral pose. Gating on *movement* +instead would make him stutter to a halt against every bush. + +**Which frames mirror, measured rather than assumed:** + +| Direction | Frame 0 | Frame 1 | +| --- | --- | --- | +| South | `PLAYER_DOWN` | `PLAYER_DOWN` mirrored | +| North | `PLAYER_UP` | `PLAYER_UP` mirrored | +| East | `PLAYER_SIDE_A` | `PLAYER_SIDE_B` — a real bitmap | +| West | east, mirrored | | + +Four bitmaps, not five, and the empty hands are why. A held object cannot be +mirrored — flipping the sprite would teleport it to the other hand — so a +character carrying a shield needs a second front-facing bitmap. This one does +not: the head and torso are left-right symmetric while the arms and legs are +not, so the mirror swings the far arm forward and the near one back, which *is* +the animation. The second front-facing bitmap measured **0 differing pixels** +against `mirror(PLAYER_DOWN)`; dropping it saved 168 bytes of flash. + +The side pair cannot play the same trick, because there the flip bit is already +spending itself on the facing. Mirroring a side-on pose turns him around instead +of animating him — 146 differing pixels, against the front pair's 0. + +One trap worth knowing, because it has already cost an afternoon here: a sprite +that is **perfectly** symmetric makes its own mirrored walk frame invisible, and +that reads as a broken timer rather than as broken art. `check_sprites.py` +measures every bitmap against its own mirror and fails below a floor: + +| Bitmap | Differing pixels vs its own mirror | +| --- | --- | +| `PLAYER_DOWN` | 44 | +| `PLAYER_UP` | 26 | +| `PLAYER_SIDE_A` | 146 | +| `PLAYER_SIDE_B` | 142 | + +The two vertical numbers are the ones that matter, because those are the only +frames whose animation *is* the mirror. An earlier `PLAYER_UP` sat at four +pixels — enough to pass, not enough to see. + +Note that the flip bit ends up doing two different jobs: facing north it +carries the animation *frame*, facing east or west it carries the *facing*. +`PlayerActor::draw()` has that same split. + +Five bitmaps cover four directions and a two-frame cycle. + +## Drawing the terrain + +The terrain goes through `StaticTilemapLayerCache`, the same path metroidvania +uses. A pinned camera is the case that cache exists for: + +```cpp +const TileMap4bppDrawSpec staticLayers[] = { terrainLayer() }; +tilemapLayerCache_.draw(renderer, camX, camY, staticLayers, 1, nullptr, 0); +``` + +While the player walks around a room the camera does not move, so the terrain is +byte-identical frame after frame and the cache replays it with one memcpy +instead of blitting 165 tiles. During a slide the camera sample changes every +frame and it rebuilds — which is exactly when a rebuild is correct. Nothing had +to be told when to invalidate. + +One detail is not obvious and is worth copying. The cache keys its snapshot on a +camera sample, and its documentation suggests `-renderer.getXOffset()`. That +does not work here: the offset is only set inside `draw()`, so +`adviseFramebufferBeforeBeginFrame` — which the engine runs *before* +`beginFrame` — would read the previous frame's value and disagree with `draw()` +on the exact frame the camera moves. Both callers read `Camera2D` directly +instead, so they always agree. + +## The build flag + +```ini +-D PIXELROOT32_ENABLE_4BPP_SPRITES=1 +``` + +The engine gates its 4bpp paths with `if constexpr`, so building without this is +**not a compile error — it is a black screen with no diagnostic.** Every header +here is gated on it the way metroidvania's are, and `Scenes.h` turns the missing +flag into an `#error` that says so. Failing at build time is the whole point: +this example cannot function without it, so it should not pretend to build. + +## Memory + +Almost everything is `const` in the scene flash section +(`PIXELROOT32_SCENE_FLASH_ATTR`) and read through `PIXELROOT32_READ_BYTE_P`, so +it costs flash rather than RAM: + +| Item | Bytes | Where | +| --- | ---: | --- | +| Tileset pixel data | 1,408 | flash — 11 tiles x 128 B across both maps | +| Player pixel data | 640 | flash — 5 sprites x 128 B | +| Map indices | 1,320 | flash — 660 per map | +| Collision tables | 11 | flash — one `bool` per tile, not per cell | +| `TileWorld` x2 | ~40 | RAM — three pointers each, no data | +| `RoomGraph<4>` x2 | ~240 | RAM — fixed capacity, no allocation | +| Framebuffer snapshot | 57,600 | heap, `allocateForRenderer()` at init, ESP32 only | + +Measured on `esp32dev`, whole example: **24,800 B RAM (7.6%)** and 351,073 B +flash (26.8%). + +The previous version of this example expanded character maps into `.bss` at +startup and packed art into RAM buffers. Moving to the exported format took RAM +from 29,664 B to 24,800 B — **4,864 bytes returned** — while flash stayed flat, +because the art that arrived was offset by the packer and character maps that +left. + +`TileMapGeneric::indices` is a non-const `uint8_t*`, so the exporter casts the +const array on assignment, exactly as the editor's own output does. Nothing +writes through it. + +## Not in this iteration + +- The dungeon has no keys, no locked doors, no enemies and nothing to find. Four + rooms and a way out. +- The status bar is a placeholder. `UISpriteRow` is what the heart row is for, + once the player has something to lose. +- No enemies, items, sword or combat anywhere. +- No persistence — leaving the dungeon forgets everything about it. + + +--- + +**Source code:** https://github.com/PixelRoot32-Game-Engine/PixelRoot32-Game-Engine/tree/main/examples/legend_of_clone diff --git a/examples/metroidvania.md b/examples/metroidvania.md index 10df3f5..af9c25a 100644 --- a/examples/metroidvania.md +++ b/examples/metroidvania.md @@ -14,6 +14,52 @@ A compact **platformer** sample with **4bpp tilemap layers**, **`StaticTilemapLa - **`PIXELROOT32_ENABLE_DIRTY_REGIONS`** - **`PIXELROOT32_ENABLE_CAMERA_EFFECTS`** — camera shake when player falls into void - **`PIXELROOT32_ENABLE_GAMEPLAY_STATE_MACHINE`** — required, not optional. `PlayerActor` holds a `gameplay::StateMachine` member and the whole class lives behind this flag (default `0`), so the actor does not compile without it. +- **`PIXELROOT32_ENABLE_INTERACTION_TRIGGERS=1`** — the collectible orbs. +- **`PIXELROOT32_ENABLE_GAMEPLAY_EVENTS=1`** — optional companion to the above; with it off the orbs still work, they just stop publishing to the bus. + +## Collectible orbs (interaction triggers + event bus) + +Three pulsing orbs sit along the player's path; the HUD counts them. They are +the reference for two capabilities that are usually reached for together. + +The orbs are 4bpp sprites (`assets/PickupSprites.h`) drawn from the **player's** +palette rather than from flat `Color` constants, and that is not a cosmetic +choice. This scene runs in dual-palette mode, and `Scene::draw()` picks the +palette context from the entity's render layer — layer 0 gets the Background +palette, everything else the Sprite one. An orb is an ordinary actor on the +default layer 1, so its colours resolve through `PLAYER_SPRITE_PALETTE_RGB565`, +which defines indices 0-7 and leaves 8-15 at `0x0000`. `Color::Yellow` is PR32 +index **8**, so a `drawFilledRectangle(..., Color::Yellow)` here paints solid +black. Any actor you add to this scene must stay inside indices 1-7 of the +sprite palette, or pick its own via `setSpriteCustomPalette()`. + +`PickupActor` is a **sensor** — the physics step still produces a contact for +the pair but resolves no response, so the player walks through the orb instead +of standing on it. A solid pickup would be a wall. + +The part that is easy to get wrong is that **contact is not collection**. The +engine reports a contact on every frame the boxes overlap. `InteractionTracker` +diffs each frame's contact set against the previous one and calls `onEnter` +exactly once, on the frame the overlap begins. Without that edge detection the +callback would fire ten times as the player crossed a single orb. + +Two ordering rules the example depends on: + +- `registerActor()` keys on `entityId`, which the collision system assigns + during `addEntity()`. Register **after** adding, or the orb silently stores + id 0 and never dispatches. +- The orbs use `Layers::ENEMY`, the layer `GameLayers.h` reserves for enemies, + projectiles and pickups. The player already carries it in every branch of its + mask — including while climbing — so no change to `PlayerActor`'s mask + juggling was needed. + +Attaching the **event bus** is optional and additive: the component callbacks +fire either way, and a bus additionally receives a `TriggerEnter`/`TriggerExit` +event per edge. That is what lets an unrelated system (an achievement tracker, a +sound director) observe contact without knowing `PickupActor` exists. The bus is +a fixed-capacity FIFO whose overflow policy is **drop-newest**, so a consumer +that skips a frame loses the newest events, not the oldest — draining every +frame is the contract, not an optimisation. See **`platformio.ini`** for **`native`** and **`esp32dev`** presets (no `esp32cyd` environment in this project). diff --git a/examples/midway_clone.md b/examples/midway_clone.md new file mode 100644 index 0000000..2f4bf2f --- /dev/null +++ b/examples/midway_clone.md @@ -0,0 +1,393 @@ +# Clone of Midway — continuous scrolling, and what it costs + + +> **⚠️ Demonstration example** — This project is provided **as an example** to showcase the capabilities of the PixelRoot32 Game Engine and what you can build with it. It may **not be 100% functional or finished**; some features can be incomplete, experimental, or work in progress. + + +A vertically scrolling shooter over the Pacific. One stage, one aircraft, a sea +that never stops moving. + +The name is the honest label. This is a **clone** of Capcom's *1943: The Battle +of Midway*, built to exercise one engine behaviour the rest of the examples +never touch: a camera that moves **every single frame**. All the art is +original — no ripped sprites, no ripped maps, no Capcom assets anywhere in the +tree. + +It exists to answer a question the other tilemap examples cannot: + +> Every tilemap example in this repository keeps its camera still and lets +> `StaticTilemapLayerCache` replay the terrain with one `memcpy`. +> **What happens when the camera never stops?** + +The answer is not the one the setup implies, and it is the most useful thing +this example has to say. See [The frame budget](#the-frame-budget). + +``` + world y = 0 ← north, end of stage + +---------------+ + | [carrier] | rows 5-12 + | | + | islands | + | · | + | · | + | | + | ┌───────────┐ | ← the 208 px viewport, climbing + | │ ✈ player │ | + | └───────────┘ | + +---------------+ + world y = 1600 ← south, camera starts here +``` + +## Running it + +```bash +pio run -e native -t exec # SDL2 desktop +pio run -e esp32dev -t upload # ESP32 + ST7789 240x240 +``` + +| Action | Desktop | ESP32 | +| --- | --- | --- | +| Fly | Arrow keys | D-pad, GPIO 32/27/33/14 | +| Gun (hold) | `SPACE` | A, GPIO 13 | +| Restart, once the run has ended | `RETURN` | B, GPIO 12 | + +Movement is eight-way and **not** normalised on the diagonal — the machine this +imitates moves the same pixels per axis whether one direction is held or two, +and correcting it to a true `1/√2` makes the aircraft feel sluggish diagonally. + +## What it demonstrates + +| Engine capability | Where | +| --- | --- | +| A camera driven per frame, not pinned or following | `MidwayScene::updateScroll()` | +| `Camera2D` bounds — and what happens without them | `MidwayScene::init()` | +| `ObjectPool` for bullets, enemies, explosions | `MidwayScene` members | +| Pool iteration via `nextLive()` / `kEnd` | every `MidwayScene::update*()` | +| `StaticTilemapLayerCache` **missing every frame**, measured | `MidwayScene::draw()` | +| Exported 4bpp tilemap and sprites, flash-resident | `assets/OceanTileMap.cpp` | +| Dual palette mode — sea and aircraft, 16 slots each | `MidwayScene::init()` | +| Offset bypass for a non-scrolling HUD strip | `MidwayScene::drawHud()` | +| Sprite-vs-sprite AABB with no physics engine | `MidwayScene::resolveCollisions()` | +| Press-edge input (`isButtonPressed`) for restart | `MidwayScene::update()` | + +The asset shapes follow **[metroidvania](../metroidvania/)** and +**[legend_of_clone](../legend_of_clone/)**: tileset pools and map indices in +flash behind `PIXELROOT32_SCENE_FLASH_ATTR`, drawn through +`StaticTilemapLayerCache`, every header gated on +`PIXELROOT32_ENABLE_4BPP_SPRITES`. + +## Screen layout + +``` + 0 ┌─────────────────────────┐ + │ │ + │ playfield 240x208 │ ← the camera viewport + │ │ +208├─────────────────────────┤ + │ SCORE 000000 │ ← HUD, 240x32, screen space + │ PLANES 3 │ +240└─────────────────────────┘ +``` + +The playfield is anchored at screen `y = 0` with the HUD **below** it, so world +`y` maps straight to screen `y` and the camera offset is the only transform in +play. A HUD on top would push a constant offset into every world coordinate and +every hit test in the example. + +## The frame budget + +This is why the example exists. + +### The floor nobody can move + +The ESP32 driver pushes the **entire** framebuffer on every present — +`sendBufferScaled()`, called unconditionally from `TFT_eSPI_Drawer.cpp:120`. +There is no dirty-rectangle path at the driver level; `DirtyGrid` marks cells +for selective framebuffer *clears*, not for selective transmission. + +So every frame, regardless of what changed: + +``` +240 × 240 px × 2 bytes (RGB565) = 115,200 bytes +115,200 × 8 bits ÷ 40 MHz = 23.0 ms +``` + +**23.0 ms of wire time, every frame, before the game has done anything.** That +is a hard ~43 FPS ceiling. 60 FPS is not slow here, it is *arithmetically +impossible*: 16.6 ms is less than the push alone. + +That is why `SPI_FREQUENCY` is 40 MHz and this example targets 30 FPS. Raising +the clock to 80 MHz halves the floor — many ST7789 panels manage it, and the +ones this was developed against do not. + +### The trap: lowering the logical resolution does not help the floor + +`RES_160x160` and friends shrink the **logical** framebuffer, so the clear and +every blit get cheaper. They do **not** reduce the DMA bytes, because the push +happens at *physical* resolution. The ~30% quoted in +[resolution-scaling.md](../../docs/architecture/resolution-scaling.md) is +CPU-side work, not wire time. Worth doing; it will not buy you 60 FPS. + +### What the scroll actually costs + +Here is the counter-intuitive part. A moving camera means +`StaticTilemapLayerCache` misses **100% of frames** — it replays its snapshot +only while the sampled camera position is unchanged, and here it changes every +frame. + +That sounds expensive. It is not what dominates. The scroll costs **CPU redraw +time and zero wire time**, and the wire is already 55% of the frame. The cache +is left wired up in `MidwayScene::draw()` — the same three calls metroidvania +makes — specifically so this can be measured rather than argued about. + +### Measuring it yourself + +Uncomment the three profiling lines in the `esp32dev` environment of +[`platformio.ini`](platformio.ini), then: + +```bash +pio device monitor -e esp32dev +``` + +Once a second, from `TFT_eSPI_Drawer.cpp:520`: + +``` +[TFT sendBufferScaled avg/N fr] total ... | setup ... | scale ... | dmaWait ... | pushDMA ... | endWrite ... | N FPS +``` + +That `total` splits the frame in two — transmit versus everything else — and +tells you which half to attack. + +They ship **commented out** on the device and enabled on native, because the +instrumentation is not free: `PIXELROOT32_ENABLE_PROFILING` timestamps inside +the DMA loop and the debug overlay renders text every frame. On a target whose +budget is already 55% spent, measuring the frame changes the frame. + +### One measured data point + +| | | +| --- | --- | +| Board | esp32dev + ST7789 240×240, SPI 40 MHz | +| Tiles | 8×8, 30×200 map | +| Instrumentation | overlay + profiler **on** | +| **Result** | **~24 FPS average** (41.7 ms/frame) | + +Of those 41.7 ms, 23.0 ms is the computed wire floor; the remaining ~18.7 ms is +clear, terrain, sprites, logic — **and the instrumentation measuring it**. + +The tiles have since been doubled to 16×16 (below) and the flags turned off. +**That combination has not been re-measured.** When it is, this table gets a +second row rather than an edited first one. + +## Why the tiles are 16×16 + +The map was 8×8 first, to match the machine being imitated, and **8×8 looked +better** — a finer grid draws a coastline that does not read as a staircase. It +was given up for frame rate, and the reason is worth recording because it is +not about pixels at all: + +> The renderer pays a per-tile cost on every tile — a call into +> `drawSpriteInternal`, an index fetch, bounds arithmetic — and that cost does +> not shrink when the tile does. + +| | 8×8 | 16×16 | +| --- | --- | --- | +| Map | 30 × 200 | 15 × 100 | +| World | 240 × 1600 px | **240 × 1600 px** | +| Blits per frame | 900 | **225** | +| Pixels per frame | 57,600 | 57,600 | +| Tileset + map in flash | 6,256 B | **2,524 B** | + +The world is deliberately identical. `kCameraStartY`, the stage length and all +twelve wave triggers are in **world pixels**, so halving the grid while doubling +the tile left every one of them untouched. + +What it cost: an island is now a 5-tile blob rather than a 10-tile ellipse, and +the foam shoreline ring went from 8 to 16 px. + +Two things that turned out **not** to be the problem, checked before making the +change: the 4bpp tilemap path already caches its palette LUT across tiles +(`Renderer.cpp:1005-1062`, rebuilt only when the palette pointer changes), and +`packRgb565ToTftSprite8` is an inline bit-shuffle. + +## Scrolling, and the two coordinate spaces + +The camera counts **down** from `kCameraStartY` (1392) to 0 at 32 px/s — 43.5 +seconds of stage. Row 0 is north, so the player meets the highest row numbers +first and the carrier last. + +### Camera2D silently pins to the origin without bounds + +The single worst trap in this example, and it cost a debugging session: + +```cpp +camera_.setBounds(math::toScalar(0), math::toScalar(0)); +camera_.setVerticalBounds(math::toScalar(0), math::toScalar(kCameraStartY)); +camera_.setPosition(...); // ← clamped by the two lines above +``` + +`Camera2D` constructs with `minX/maxX/minY/maxY` all **zero** +(`Camera2D.cpp:15-22`) and `setPosition()` clamps against them with no +diagnostic (`Camera2D.cpp:34-41`). A camera never given bounds is pinned to the +origin no matter what it is told. + +It fails with no error: the world renders row 0 forever while the game logic +runs correctly somewhere off screen. The symptom reads as *"every sprite in my +game vanished"*, which points nowhere near the camera. + +`legend_of_clone` and `metroidvania` never hit this because +`RoomGraph::enterRoom()` sets the bounds for them, once per room. There is no +room graph in a scrolling shooter. + +### The player lives in screen space + +The world scrolls underneath a shmup's player; the player does not travel +through it. So `PlayerActor` stores a **screen** position and converts to world +only at draw and hit-test time. + +Storing a world position would mean adding the scroll delta back every frame +just to stand still — and any frame that missed the addition would drag the +aircraft off the bottom of the screen. Screen space makes standing still the +default and costs one addition at draw time. + +### Sub-pixel motion without floats + +Speeds are px/s, frames arrive in ms, so a frame is worth a fraction of a pixel. +Dropping that fraction every frame makes everything measurably slower than its +stated speed — at 30 FPS the scroll would lose about 3% of its travel. +`advancePixels()` carries the remainder in an integer, which stays exact on the +non-FPU targets the engine also builds for. + +It is correct for negative rates too: C++ integer division truncates toward +zero, so the remainder keeps its sign and the climbing camera loses nothing. + +## Waves are keyed to the camera, not to a clock + +```c +const Wave kWaves[] = { + { 1300, 3, 0, 6, 0, 26 }, // trigger camera y, count, weaves, col, xstep, gap + { 1200, 3, 1, 14, 0, 26 }, + ... +``` + +A wave fires when the camera has climbed past its trigger. That way a formation +always arrives over the same stretch of water, which is what makes a stage +learnable. A timer would decouple the two and the same wave would meet the +player somewhere different every run. + +An enemy's on-screen speed is its world speed **plus the scroll**: 60 + 32 = 92 +px/s. Tuning that number while forgetting the scroll is added to it is the +easiest way to end up with enemies that flash past unhittably. + +## Pools, not entities + +The player is an `Entity` and goes through `Scene::draw`. Bullets, enemies and +explosions do not — they live in `ObjectPool`s the scene owns and draws +directly. + +`Scene::draw` sorts its entity list and viewport-culls each member every frame. +That is worth paying for a handful of long-lived objects and not for thirty +projectiles already known to be on screen. + +Pools are reset from `init()` **after** `Scene::init()` has run, which is the +ordering the [`ObjectPool`](../../include/gameplay/ObjectPool.h) header +requires: `resetState()` must clear the entity list before any pooled object is +destructed. + +Sizes are derived, not guessed. A player bullet crosses the 208 px playfield in +208/300 s = 693 ms and one leaves every 170 ms, so at most 5 are ever in flight; +the pool holds 8. + +## Collision without a physics engine + +The example builds with `PIXELROOT32_ENABLE_PHYSICS=0`. Aircraft do not fall, +do not push each other and never touch terrain — every collision is one AABB +overlap between two sprites. + +That uses a local integer `Box`, not `core::Rect`. `Rect` carries `Scalar` +coordinates every test would have to convert through, and its `intersects()` +compares with `<` rather than `<=`, so two boxes that merely touch along an edge +are reported as overlapping. + +The player's hitbox is inset 4 px per side from its sprite. 16×16 of P-38 is +mostly wing, and a shmup that kills you for a wingtip graze feels broken rather +than hard. Every arcade game of this kind cheats here. + +## The asset pipeline + +Everything in `src/assets/` is **generated**. The art source and the exporter +live outside the repository, in `docs/audits/_dev_tools/midway_clone/`, which is +gitignored — the same split `legend_of_clone` and `metroidvania` use, where the +output is what the repo carries and what reviewers read. + +```bash +python generate_assets.py # after editing art_source.py +python generate_assets.py --check # validate + round-trip, write nothing +python generate_assets.py --preview # print the art back as ASCII +``` + +A tool exists rather than hand-written headers because the 4bpp format puts the +**left** pixel in the **low** nibble (`Renderer.cpp:605-606`) while sprite rows +are declared `uint16_t`. On a little-endian target the hex digits of a literal +run in the opposite order to the pixels they draw: `0x2220` is the pixel run +`0,2,2,2`. Hand-authoring that is not difficult, merely impossible to do +reliably. Every grid is packed, unpacked again and diffed against its source +before anything is written. + +### Two things the art had to work around + +**`.` means transparent, so sand is `a`.** `legend_of_clone` spells sand `.` in +its tilemap art; here `.` is the hole in every grid. A symbol meaning *nothing* +in one file and *beach* in another is a trap worth avoiding. + +**The carrier deck is wood, not steel.** It was gray `#BCBCBC` first — exactly +the player's bare-aluminium airframe — and the aircraft vanished the moment it +crossed one. Wartime flight decks really were planked, so the colour that reads +is also the one that is correct. + +## Build flags + +| Flag | Required | Why | +| --- | --- | --- | +| `PIXELROOT32_ENABLE_4BPP_SPRITES=1` | **Yes** | The engine gates 4bpp draw paths with `if constexpr`. Without it this builds clean and renders **nothing** — `Scenes.h` `#error`s instead. | +| `PIXELROOT32_ENABLE_GAMEPLAY_OBJECT_POOL=1` | **Yes** | `ObjectPool.h` is wrapped in this and defaults to **0** (`PlatformDefaults.h:101-103`). Without it the whole `pixelroot32::gameplay` namespace is absent, and the error points at the namespace rather than at the missing flag. | +| `PIXELROOT32_ENABLE_GAMEPLAY_ROOM=0` | — | No rooms to enter. | +| `PIXELROOT32_ENABLE_PHYSICS=0` | — | No bodies to integrate. | +| `PIXELROOT32_ENABLE_PROFILING` | No | See [Measuring it yourself](#measuring-it-yourself). | + +## Memory + +| | Flash | SRAM | +| --- | --- | --- | +| Tileset, 8 tiles × 16×16 4bpp | 1,024 B | 0 | +| Map indices, 15 × 100 | 1,500 B | 0 | +| Sprites, 11 frames | 1,216 B | 0 | +| **Assets total** | **3,740 B** | **0 B** | +| Object pools (bullets, enemies, explosions) | — | ~780 B | +| `StaticTilemapLayerCache` snapshot | — | **57,600 B** | + +That last row deserves a hard look. `allocateForRenderer()` reserves a full +logical framebuffer — 240 × 240 bytes — and in this example the cache **never +hits**, because the camera moves every frame. It is 57.6 KB of SRAM buying one +`memcpy` on the stage-complete freeze. + +It is kept because measuring the miss is the point of the example. **A shipping +game with a continuously scrolling camera should call +`setFramebufferCacheEnabled(false)` and skip the allocation entirely.** + +## Not in this iteration + +- No audio. `PIXELROOT32_ENABLE_AUDIO=1` and a backend is wired up; nothing + plays yet. +- One stage, one enemy type. No boss, no POW power-ups, no loop-the-loop, no + refuelling — all of which the original has. +- Enemies do not react to the player. They descend, they weave, they shoot on a + timer. +- Terrain is decoration. `TILE_IS_LAND` is exported for ground targets that do + not exist yet; nothing collides with the map. +- The stage ends by holding on the last frame. No score tally, no next stage. + + +--- + +**Source code:** https://github.com/PixelRoot32-Game-Engine/PixelRoot32-Game-Engine/tree/main/examples/midway_clone diff --git a/examples/physics.md b/examples/physics.md index e0607c1..b0db1ab 100644 --- a/examples/physics.md +++ b/examples/physics.md @@ -10,8 +10,29 @@ When **`PIXELROOT32_ENABLE_UI_SYSTEM`** is on (default in [`PlatformDefaults.h`] - **`PIXELROOT32_ENABLE_SCENE_ARENA`** — pre-allocated box/circle pools and arena-safe add/remove when the slider changes. - **`PIXELROOT32_ENABLE_TOUCH=1`** — set for **`native`** and **`esp32cyd`** in `platformio.ini` so touch APIs compile and mouse/touch can drive the demo. +- **`PIXELROOT32_ENABLE_SPATIAL_QUERY=1`** — the proximity scan described below. - **`esp32cyd`** additionally enables **`PIXELROOT32_ENABLE_DEBUG_OVERLAY`**, **`PIXELROOT32_DEBUG_MODE`**, **ILI9341** 240×320, and **XPT2046** touch (many tuning `-D`s in `platformio.ini`). +## Proximity scan (spatial queries) + +Press **B** (button **5**) to toggle a radius query centred on the player. The +circle is the query, and every actor it returns is boxed in magenta. + +Worth knowing before you use `queryRadius()` in a game: + +- **It has no anchor actor, so only your mask applies.** `checkCollision()` + tests both sides (`a.mask & b.layer || b.mask & a.layer`) because both sides + are actors. An area query has no second actor to ask, so the test is just + `(mask & other->layer)`. This is a real behavioural difference, not an + oversight. +- **The player is in its own results.** There is no actor to exclude, so filter + it out yourself if the query drives damage or targeting. +- **Radius is bounded.** Squared-distance terms have to fit Q16.16, so the + radius is clamped to `SPATIAL_QUERY_MAX_RADIUS` (default 128). Debug builds + assert; release builds clamp. Distances are compared squared — never `sqrt`. +- **Run it after `Scene::update()`**, as this demo does, or the grid still holds + last frame's positions. + See **`platformio.ini`** for **`native`**, **`esp32dev`**, **`esp32cyd`**. ## Platforms diff --git a/examples/room_screen.md b/examples/room_screen.md index e3cded5..be73351 100644 --- a/examples/room_screen.md +++ b/examples/room_screen.md @@ -2,20 +2,18 @@ > **Demonstration example** — showcases the `RoomGraph` API. May not be 100% finished; provided as an example of what you can build. -Minimal 2-room layout: two adjacent rooms (240×240 each), connected left-to-right. Arrow keys trigger `enterRoom`, which clamps the camera to the target room and fires an `onEnter` callback. +A 4-room layout exported by the Tilemap Editor: four 15x15 rooms in a 2x2 grid (each 240×240 at 16 px/tile). A 16×16 hero (idle + two-frame walk, up/down/left/right) walks the world freely and collides with the Items layer (the collision layer). Crossing a room boundary follows the room connection — the camera snaps to the target room and the player lands on its entry edge; an unconnected edge acts as a wall. ## Where the rooms come from -The graph is not hand-written in C++. `src/assets/RoomScreenRooms.h` holds the room layer in the format the Tilemap Editor exports — tile-space rects plus connection slots — and `Scene::init()` turns it into a `RoomGraph<2>` with one call: +The graph is not hand-written in C++. `src/assets/roomscreen_main_scene.h` / `.cpp` hold the exported scene — tilemaps, palettes, and the room layer (tile-space rects plus connection slots) — and `Scene::init()` turns the room layer into a `RoomGraph<4>` with one call: ```cpp -gameplay::buildRoomGraph(ROOM_SCREEN_ROOM_LAYER, rooms_); +gameplay::buildRoomGraph(ROOMSCREEN_MAIN_SCENE_ROOM_LAYER, rooms_); ``` That keeps rects and connections in one place instead of spread across `addRoom`/`connect` calls that can drift out of sync with the map. See [Tilemap Editor — Room Layer](../../docs/tools/tilemap-editor/technical-reference.md#room-layer) for the format. -> The asset header is hand-written for now: it stands in for the editor's output until the room metadata emitter ships. The format is the contract. - ## Requirements (build flags) | Flag | Required | Notes | @@ -25,8 +23,23 @@ That keeps rects and connections in one place instead of spread across `addRoom` ## Controls -- **Left/Right arrow keys**: transition between Room 0 ↔ Room 1. -- Room label and background color change on transition. +- **Up/Down/Left/Right arrow keys**: move the player. Walking into a wall stops you; walking across an open room boundary transitions to the connected room. + +## Collision + +The exported **Items** layer is the collision layer: its tiles carry `TILE_SOLID` where the player cannot walk. The player resolves that layer with the engine helper `pixelroot32::physics::isWorldPixelSolid`, which decodes the tile's 4bpp bitmap so a solid tile only blocks where its pixels are actually opaque — a tile whose bitmap has transparent "dead pixels" (palette index 0) lets the player walk through the gaps instead of treating the whole 16×16 cell as a wall. + +The strategy is a **compile-time toggle** in `src/GameConstants.h`: + +| `kCollisionMode` | Behaviour | +|------------------|-----------| +| `CollisionMode::WholeTile` | Original behaviour: a `TILE_SOLID` tile blocks its entire cell; the bitmap is ignored. | +| `CollisionMode::PerPixel` | Per-pixel test: transparent pixels are walkable, no erosion. | +| `CollisionMode::PerPixelEroded` *(default)* | Per-pixel test with morphological erosion, so thin branches, dangling 1px protrusions and stray opaque pixels on irregular tiles (plants, tree canopies) don't snag the player. | + +`PerPixelEroded` uses `kTileSolidErosionPx` (default `1`) as the erosion radius: a pixel counts as solid only if the whole `(2*r+1)²` square around it is opaque. + +`Player::canOccupy` branches on `kCollisionMode` with `if constexpr`, so the unselected branches are discarded at compile time (zero runtime cost, and on ESP32 the unused helper is stripped by `--gc-sections`). The helper is stateless — no per-tile masks, no allocations — so RAM cost is 0 in every mode. ## Build diff --git a/guide/audio.md b/guide/audio.md index b3ebd01..f55571d 100644 --- a/guide/audio.md +++ b/guide/audio.md @@ -115,7 +115,7 @@ Point optional **`MusicTrack`** pointers from the **main** track: **`secondVoice ### Example project -The engine’s **`music_demo`** sample showcases **multi-track** arrangements, **instrument presets**, and melodies: [`examples/music_demo`](https://github.com/PixelRoot32-Game-Engine/PixelRoot32-Game-Engine/tree/main/examples/music_demo) (PlatformIO). Also see **`tic_tac_toe`** / **`brick_breaker`** for lighter music use ([Audio samples](/examples/audio-playback)). +The engine’s **`music-demo`** sample showcases **multi-track** arrangements, **instrument presets**, and melodies: [`examples/music-demo`](https://github.com/PixelRoot32-Game-Engine/PixelRoot32-Game-Engine/tree/main/examples/music-demo) (PlatformIO). Also see **`2048`** / **`brick_breaker`** for lighter music use ([Audio samples](/examples/audio-playback)). ```cpp #include