diff --git a/Makefile b/Makefile index ef7213b..6eb86fa 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help setup install test lint shell run clean rebuild build-wasm serve-wasm +.PHONY: help setup install test lint shell run clean rebuild build-wasm serve-wasm check-sdl install-sdl run-sdl run-sdl-host help: ## Show this help message @echo 'Usage: make [target]' @@ -121,3 +121,56 @@ serve-wasm: ## Serve WASM build locally (requires Python 3) fi @echo "Starting HTTP server on http://localhost:8080" @cd dist && python3 -m http.server 8080 + +# SDL2 Native Frontend Targets + +check-sdl: ## Check if SDL2 extension is installed + @echo "Checking SDL2 extension..." + @php -m | grep -q sdl && echo "✓ SDL2 extension is installed" || (echo "✗ SDL2 extension not found. See docs/sdl2-setup.md for installation." && exit 1) + @echo "SDL2 version: $$(php -r 'echo SDL_GetVersion();')" + +install-sdl: ## Install SDL2 PHP extension (requires sudo) + @echo "Installing SDL2 PHP extension..." + @echo "This requires SDL2 development libraries to be installed first." + @echo "" + @echo "Ubuntu/Debian:" + @echo " sudo apt-get install libsdl2-dev" + @echo "" + @echo "macOS:" + @echo " brew install sdl2" + @echo "" + @echo "After installing SDL2 libraries, run:" + @echo " sudo pecl install sdl-beta" + @echo "" + @echo "Then add 'extension=sdl.so' to your php.ini" + @echo "" + @echo "See docs/sdl2-setup.md for detailed instructions." + +run-sdl: ## Run emulator with SDL2 native frontend in Docker (usage: make run-sdl ROM=path/to/rom.gb) + @if [ -z "$(ROM)" ]; then \ + echo "Error: ROM parameter is required. Usage: make run-sdl ROM=path/to/rom.gb"; \ + exit 1; \ + fi + @echo "Note: SDL2 GUI applications typically work better on host. Try: make run-sdl-host ROM=$(ROM)" + docker compose run --rm -e DISPLAY=$$DISPLAY -v /tmp/.X11-unix:/tmp/.X11-unix phpboy php bin/phpboy.php $(ROM) --frontend=sdl + +run-sdl-host: ## Run emulator with SDL2 on host (not in Docker) (usage: make run-sdl-host ROM=path/to/rom.gb) + @if [ -z "$(ROM)" ]; then \ + echo "Error: ROM parameter is required. Usage: make run-sdl-host ROM=path/to/rom.gb"; \ + exit 1; \ + fi + @echo "Running SDL2 frontend on host..." + @php -m | grep -q sdl || (echo "Error: SDL2 extension not installed. Run 'make install-sdl' or see docs/sdl2-setup.md" && exit 1) + php bin/phpboy.php $(ROM) --frontend=sdl + +test-sdl: ## Test SDL2 installation with simple window + @echo "Testing SDL2 installation..." + @php -m | grep -q sdl || (echo "Error: SDL2 extension not installed." && exit 1) + @php -r '\ + if (!extension_loaded("sdl")) { die("SDL not loaded\n"); } \ + SDL_Init(SDL_INIT_VIDEO); \ + $$w = SDL_CreateWindow("SDL Test", SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED, 640, 480, SDL_WINDOW_SHOWN); \ + if ($$w) { echo "✓ SDL2 working! Window created.\n"; sleep(2); SDL_DestroyWindow($$w); } \ + else { echo "✗ Failed: " . SDL_GetError() . "\n"; } \ + SDL_Quit(); \ + ' diff --git a/README.md b/README.md index 629294f..0a96cf0 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,14 @@ # PHPBoy - Game Boy Color Emulator -A readable, well-architected Game Boy Color (GBC) emulator written in PHP 8.5 that runs in the CLI and, via WebAssembly, in the browser. +A readable, well-architected Game Boy Color (GBC) emulator written in PHP 8.5 with multiple frontend options: native SDL2 desktop, CLI terminal, and browser via WebAssembly. ## Features - **Modern PHP 8.5 RC**: Leverages the latest PHP 8.5 release candidate features including strict types, readonly properties, enums, typed class constants, and property hooks -- **Browser Support**: Runs in the browser via WebAssembly using php-wasm - no backend required! +- **Multiple Frontends**: + - **SDL2 Native Desktop**: Hardware-accelerated rendering with true native performance ⭐ **NEW!** + - **Browser (WebAssembly)**: Runs in the browser via php-wasm - no backend required! + - **CLI Terminal**: ANSI color rendering in your terminal - **Fully Dockerized Development**: All PHP/Composer/testing tools run exclusively in Docker containers for consistency - **Comprehensive Testing**: PHPUnit 10 for unit and integration tests - **Static Analysis**: PHPStan at maximum level (9) for type safety @@ -77,6 +80,61 @@ For debugging or manual operations: make shell ``` +### Running with SDL2 Native Frontend + +PHPBoy supports true native desktop rendering using SDL2 for hardware-accelerated, low-latency gameplay. + +#### Prerequisites + +1. Install SDL2 development libraries: + ```bash + # Ubuntu/Debian + sudo apt-get install libsdl2-dev + + # macOS + brew install sdl2 + ``` + +2. Install SDL2 PHP extension: + ```bash + sudo pecl install sdl-beta + echo "extension=sdl.so" | sudo tee -a $(php --ini | grep "Loaded Configuration" | sed -e "s|.*:\s*||") + ``` + +3. Verify installation: + ```bash + make check-sdl + ``` + +#### Running a ROM + +```bash +# Run with SDL2 frontend (on host machine) +make run-sdl-host ROM=path/to/rom.gb + +# Or directly with PHP +php bin/phpboy.php path/to/rom.gb --frontend=sdl +``` + +**Features**: +- ✅ Hardware-accelerated rendering (GPU-based) +- ✅ VSync support for smooth 60fps +- ✅ Native desktop window +- ✅ Low-latency keyboard input +- ✅ Pixel-perfect integer scaling +- ✅ Cross-platform (Linux, macOS, Windows) + +**Default Controls**: +- Arrow Keys: D-pad +- Z or A: A button +- X or S: B button +- Enter: Start +- Right Shift: Select + +**Documentation**: +- [SDL2 Setup Guide](docs/sdl2-setup.md) - Installation instructions +- [SDL2 Usage Guide](docs/sdl2-usage.md) - Usage and customization + ### Running in the Browser PHPBoy can run entirely in the browser via WebAssembly using [php-wasm](https://github.com/seanmorris/php-wasm). @@ -121,6 +179,8 @@ phpboy/ ├── bin/ # CLI entry point ├── docs/ # Documentation │ ├── research.md # Game Boy hardware research +│ ├── sdl2-setup.md # SDL2 native frontend setup +│ ├── sdl2-usage.md # SDL2 usage guide │ ├── wasm-build.md # WebAssembly build guide │ ├── browser-usage.md # Browser usage guide │ └── wasm-options.md # WASM implementation options @@ -129,9 +189,10 @@ phpboy/ │ ├── Bus/ # Memory bus │ ├── Cartridge/ # ROM/MBC handling │ ├── Cpu/ # CPU emulation -│ ├── Frontend/ # CLI and WASM frontends -│ │ ├── Cli/ # CLI implementation -│ │ └── Wasm/ # WASM adapters +│ ├── Frontend/ # Multiple frontend implementations +│ │ ├── Cli/ # CLI terminal frontend +│ │ ├── Sdl/ # SDL2 native desktop frontend +│ │ └── Wasm/ # WebAssembly browser frontend │ ├── Ppu/ # Pixel Processing Unit │ └── Support/ # Utilities and helpers ├── tests/ # Test suite diff --git a/docs/sdl2-setup.md b/docs/sdl2-setup.md new file mode 100644 index 0000000..029cc10 --- /dev/null +++ b/docs/sdl2-setup.md @@ -0,0 +1,222 @@ +# SDL2 Native Frontend Setup + +This guide covers setting up the SDL2 PHP extension for native desktop rendering in PHPBoy. + +## Overview + +The SDL2 frontend provides **true native rendering** using hardware acceleration through SDL2 (Simple DirectMedia Layer). This approach offers: + +- ✅ Native desktop window with hardware-accelerated graphics +- ✅ Direct GPU rendering (60+ fps easily achievable) +- ✅ Keyboard and joystick input support +- ✅ Cross-platform (Linux, macOS, Windows) +- ✅ No browser/Electron wrapper needed +- ✅ Perfect for emulator development + +## Prerequisites + +- PHP 8.1+ with development headers +- SDL2 library (>= 2.0) +- C compiler and build tools +- Unix-like system (Linux, macOS, BSD) + +## Installation + +### Step 1: Install SDL2 Development Libraries + +#### Ubuntu/Debian +```bash +sudo apt-get update +sudo apt-get install libsdl2-dev +``` + +#### macOS +```bash +brew install sdl2 +``` + +#### Fedora/RHEL +```bash +sudo dnf install SDL2-devel +``` + +### Step 2: Install PHP SDL Extension + +#### Option A: From PECL (Recommended) + +```bash +pecl install sdl-beta +``` + +Then add to your `php.ini`: +```ini +extension=sdl.so +``` + +Verify installation: +```bash +php -m | grep sdl +``` + +#### Option B: Build from Source + +```bash +# Clone the repository +git clone https://github.com/Ponup/php-sdl.git +cd php-sdl + +# Build the extension +phpize +./configure --with-sdl +make +make test + +# Install +sudo make install +``` + +Add to `php.ini`: +```ini +extension=sdl.so +``` + +### Step 3: Verify Installation + +Create a test file `test-sdl.php`: + +```php +printKeyMappings(); +``` + +### Custom Key Mappings + +You can customize key mappings in your code: + +```php +use Gb\Frontend\Sdl\SdlInput; +use Gb\Input\Button; + +$input = new SdlInput(); + +// Map different keys +$input->setKeyMapping(SDL_SCANCODE_W, Button::Up); +$input->setKeyMapping(SDL_SCANCODE_S, Button::Down); +$input->setKeyMapping(SDL_SCANCODE_A, Button::Left); +$input->setKeyMapping(SDL_SCANCODE_D, Button::Right); + +// Action buttons on number keys +$input->setKeyMapping(SDL_SCANCODE_1, Button::A); +$input->setKeyMapping(SDL_SCANCODE_2, Button::B); +``` + +## Performance + +### Frame Rate + +The SDL2 frontend targets 60fps with VSync enabled by default. This matches the Game Boy's native refresh rate (59.73 Hz). + +To check actual performance: + +```bash +# Run with performance stats +php bin/phpboy.php rom.gb --frontend=sdl --stats +``` + +### Benchmarking + +For performance testing without rendering overhead: + +```bash +# Benchmark with headless mode +make benchmark ROM=rom.gb FRAMES=3600 + +# Compare SDL2 vs CLI frontend +php bin/phpboy.php rom.gb --frontend=sdl --frames=1000 --benchmark +php bin/phpboy.php rom.gb --frontend=cli --frames=1000 --benchmark +``` + +### Optimization Tips + +1. **Enable JIT** - PHP 8.4 JIT can improve performance: + ```bash + php -d opcache.jit_buffer_size=100M -d opcache.jit=tracing bin/phpboy.php rom.gb --frontend=sdl + ``` + +2. **Use VSync** - Prevents wasted CPU cycles rendering faster than display refresh + +3. **Disable debugging** - Remove `--debug` and `--trace` flags in production + +## Advanced Usage + +### Integration with Existing Code + +The SDL2 frontend implements the standard `FramebufferInterface`, making it a drop-in replacement: + +```php +use Gb\Frontend\Sdl\SdlRenderer; +use Gb\Frontend\Sdl\SdlInput; +use Gb\Emulator; + +// Create SDL2 components +$renderer = new SdlRenderer( + scale: 4, // 640x576 window (160x4, 144x4) + vsync: true, // Smooth 60fps + windowTitle: 'PHPBoy - Tetris' +); + +$input = new SdlInput(); + +// Create emulator with SDL2 frontend +$emulator = new Emulator($romPath, $renderer, $input); + +// Main loop +while ($renderer->isRunning()) { + // Poll input and window events + if (!$renderer->pollEvents()) { + break; + } + + // Run emulator frame + $emulator->stepFrame(); +} +``` + +### Screenshots + +Save the current framebuffer to PNG: + +```php +$renderer->saveToPng('screenshot.png'); +``` + +Or via command line (if implemented): + +```bash +php bin/phpboy.php rom.gb --frontend=sdl --screenshot=output.png +``` + +### Event Handling + +Handle SDL events in your application: + +```php +while ($renderer->isRunning()) { + // Poll events + $event = new \SDL_Event(); + while (SDL_PollEvent($event)) { + if ($event->type === SDL_QUIT) { + break 2; + } + + if ($event->type === SDL_KEYDOWN) { + // Handle custom hotkeys + if ($event->key->keysym->scancode === SDL_SCANCODE_ESCAPE) { + break 2; + } + + if ($event->key->keysym->scancode === SDL_SCANCODE_F11) { + // Toggle fullscreen + } + + if ($event->key->keysym->scancode === SDL_SCANCODE_F12) { + // Take screenshot + $renderer->saveToPng("screenshot_" . time() . ".png"); + } + } + } + + $emulator->stepFrame(); +} +``` + +## Troubleshooting + +### Window doesn't appear + +1. Check SDL2 is installed: + ```bash + php -m | grep sdl + ``` + +2. Test SDL2 directly: + ```bash + make test-sdl + ``` + +3. Check display is available: + ```bash + echo $DISPLAY # Should show :0 or similar on Linux + ``` + +### Poor performance / Low FPS + +1. **Check VSync** - Disable to test raw performance: + ```bash + php bin/phpboy.php rom.gb --frontend=sdl --no-vsync + ``` + +2. **Enable JIT** - Significant performance boost: + ```bash + php -d opcache.jit=tracing bin/phpboy.php rom.gb --frontend=sdl + ``` + +3. **Check GPU acceleration**: + ```bash + # Should show "accelerated" renderer + php -r 'SDL_Init(SDL_INIT_VIDEO); $r = SDL_CreateRenderer(SDL_CreateWindow("t", 0, 0, 100, 100, 0), -1, SDL_RENDERER_ACCELERATED); var_dump($r);' + ``` + +### Input lag / Unresponsive controls + +1. **Disable VSync** temporarily to rule out timing issues +2. **Check polling rate** - Ensure `pollEvents()` is called every frame +3. **Try different key mappings** - Some keyboards have limitations + +### "SDL_Init failed" error + +1. Check SDL2 library is installed: + ```bash + sdl2-config --version + ``` + +2. On Linux, ensure video subsystem is available: + ```bash + SDL_VIDEODRIVER=x11 php bin/phpboy.php rom.gb --frontend=sdl + ``` + +3. On macOS, may need to run from Terminal (not via SSH) + +### Building for Distribution + +To create a distributable version with SDL2: + +1. **Static PHP build** with SDL2 extension compiled in +2. **Bundle SDL2 library** with your application +3. **Create launcher script** that sets library paths + +Example launcher (`phpboy.sh`): + +```bash +#!/bin/bash +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +export LD_LIBRARY_PATH="$SCRIPT_DIR/lib:$LD_LIBRARY_PATH" +"$SCRIPT_DIR/php" "$SCRIPT_DIR/phpboy.phar" "$@" +``` + +## Comparison with Other Frontends + +| Feature | SDL2 | CLI (ANSI) | WASM (Browser) | +|---------|------|------------|----------------| +| **Performance** | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐ | +| **Visual Quality** | ⭐⭐⭐⭐⭐ | ⭐⭐ | ⭐⭐⭐⭐⭐ | +| **Input Latency** | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | +| **Setup Complexity** | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐ | +| **Distribution** | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | +| **Native Feel** | ⭐⭐⭐⭐⭐ | ⭐ | ⭐⭐⭐ | + +## Next Steps + +- **Audio support**: Implement SDL2 audio for APU output +- **Joystick support**: Add gamepad/controller input +- **Fullscreen mode**: Toggle fullscreen with F11 +- **Save states**: Quick save/load with hotkeys +- **Fast forward**: Hold key to run at 2x-8x speed + +## See Also + +- [SDL2 Setup Guide](sdl2-setup.md) - Installation instructions +- [Frontend Architecture](frontend-architecture.md) - How frontends work +- [Official SDL2 Documentation](https://wiki.libsdl.org/SDL2/FrontPage) diff --git a/phpstan.neon b/phpstan.neon index b80f37d..18efc70 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -18,3 +18,10 @@ parameters: - '#Parameter .* expects array, mixed given#' - '#does not accept array<.*mixed.*>#' - '#expects array, array given#' + + # SDL extension types (optional runtime dependency) + - '#(Constant|Function|Class) SDL_.*not found#' + - '#Parameter \$event .* has invalid type (\\)?SDL_Event#' + - '#Access to property .* on an unknown class (\\)?SDL_Event#' + - '#Instantiated class (\\)?SDL_Event not found#' + - '#Property .*::\$(texture|renderer|window) .* in isset\(\) is not nullable#' diff --git a/src/Frontend/Cli/CliRenderer.php b/src/Frontend/Cli/CliRenderer.php index a00cfb7..1e4b370 100644 --- a/src/Frontend/Cli/CliRenderer.php +++ b/src/Frontend/Cli/CliRenderer.php @@ -382,15 +382,6 @@ private function clearScreen(): void echo "\e[2J\e[H"; } - /** - * Hide the terminal cursor. - */ - private function hideCursor(): void - { - echo "\e[?25l"; - $this->cursorHidden = true; - } - /** * Show the terminal cursor. */ diff --git a/src/Frontend/Sdl/SdlInput.php b/src/Frontend/Sdl/SdlInput.php new file mode 100644 index 0000000..a1a1528 --- /dev/null +++ b/src/Frontend/Sdl/SdlInput.php @@ -0,0 +1,236 @@ + Current pressed buttons (Button[] indexed by SDL scancode) */ + private array $pressedButtons = []; + + /** @var array Keyboard mapping (SDL scancode => Button) */ + private array $keyMapping = []; + + public function __construct() + { + if (!extension_loaded('sdl')) { + throw new \RuntimeException('SDL extension not loaded'); + } + + // Initialize default keyboard mapping + $this->initializeDefaultKeyMapping(); + } + + /** + * Initialize default keyboard to Game Boy button mapping. + */ + private function initializeDefaultKeyMapping(): void + { + // Arrow keys for D-pad + $this->keyMapping[SDL_SCANCODE_UP] = Button::Up; + $this->keyMapping[SDL_SCANCODE_DOWN] = Button::Down; + $this->keyMapping[SDL_SCANCODE_LEFT] = Button::Left; + $this->keyMapping[SDL_SCANCODE_RIGHT] = Button::Right; + + // Action buttons - multiple keys for convenience + $this->keyMapping[SDL_SCANCODE_Z] = Button::A; + $this->keyMapping[SDL_SCANCODE_A] = Button::A; + + $this->keyMapping[SDL_SCANCODE_X] = Button::B; + $this->keyMapping[SDL_SCANCODE_S] = Button::B; + + // System buttons + $this->keyMapping[SDL_SCANCODE_RETURN] = Button::Start; + $this->keyMapping[SDL_SCANCODE_RSHIFT] = Button::Select; + + // Alternative mappings for convenience + $this->keyMapping[SDL_SCANCODE_SPACE] = Button::Select; + } + + /** + * Set a custom key mapping for a button. + * + * @param int $scancode SDL scancode constant (e.g., SDL_SCANCODE_SPACE) + * @param Button $button Game Boy button to map to + */ + public function setKeyMapping(int $scancode, Button $button): void + { + $this->keyMapping[$scancode] = $button; + } + + /** + * Clear all key mappings. + */ + public function clearKeyMappings(): void + { + $this->keyMapping = []; + } + + /** + * Get current key mappings. + * + * @return array + */ + public function getKeyMappings(): array + { + return $this->keyMapping; + } + + /** + * Poll for currently pressed buttons. + * + * Returns array of currently pressed Game Boy buttons. + * This should be called after SDL_PollEvent() to get the current keyboard state. + * + * @return Button[] Array of currently pressed buttons + */ + public function poll(): array + { + // Get keyboard state from SDL + $numKeys = 0; + $keyState = SDL_GetKeyboardState($numKeys); + + $pressed = []; + + // Check each mapped key + foreach ($this->keyMapping as $scancode => $button) { + // SDL_GetKeyboardState returns 1 if key is pressed + if ($keyState[$scancode] ?? 0) { + // Avoid duplicates (multiple keys can map to same button) + if (!in_array($button, $pressed, true)) { + $pressed[] = $button; + } + } + } + + return $pressed; + } + + /** + * Handle SDL keyboard event. + * + * This method can be called from the event loop to track key presses/releases. + * It's an alternative to using SDL_GetKeyboardState(). + * + * @param \SDL_Event $event SDL event object + */ + public function handleKeyEvent(\SDL_Event $event): void + { + if (!isset($event->key)) { + return; + } + + $scancode = $event->key->keysym->scancode ?? null; + if ($scancode === null) { + return; + } + + // Check if this scancode is mapped + if (!isset($this->keyMapping[$scancode])) { + return; + } + + $button = $this->keyMapping[$scancode]; + + if ($event->type === SDL_KEYDOWN) { + // Key pressed + $this->pressedButtons[$scancode] = $button; + } elseif ($event->type === SDL_KEYUP) { + // Key released + unset($this->pressedButtons[$scancode]); + } + } + + /** + * Get currently pressed buttons (when using event-based handling). + * + * This is used with handleKeyEvent() for event-based input tracking. + * + * @return Button[] Array of currently pressed buttons + */ + public function getPressedButtons(): array + { + // Remove duplicates and return unique buttons + return array_values(array_unique($this->pressedButtons, SORT_REGULAR)); + } + + /** + * Check if a specific button is currently pressed. + * + * @param Button $button Button to check + * @return bool True if button is pressed + */ + public function isButtonPressed(Button $button): bool + { + return in_array($button, $this->poll(), true); + } + + /** + * Get a human-readable description of current key mappings. + * + * @return string Description of key mappings + */ + public function getKeyMappingDescription(): string + { + $scancodeNames = [ + SDL_SCANCODE_UP => 'Up Arrow', + SDL_SCANCODE_DOWN => 'Down Arrow', + SDL_SCANCODE_LEFT => 'Left Arrow', + SDL_SCANCODE_RIGHT => 'Right Arrow', + SDL_SCANCODE_Z => 'Z', + SDL_SCANCODE_X => 'X', + SDL_SCANCODE_A => 'A', + SDL_SCANCODE_S => 'S', + SDL_SCANCODE_RETURN => 'Enter', + SDL_SCANCODE_RSHIFT => 'Right Shift', + SDL_SCANCODE_SPACE => 'Space', + ]; + + $lines = ["Keyboard Controls:"]; + $lines[] = str_repeat('-', 30); + + $grouped = []; + foreach ($this->keyMapping as $scancode => $button) { + $keyName = $scancodeNames[$scancode] ?? "Scancode $scancode"; + $buttonName = $button->name; + + if (!isset($grouped[$buttonName])) { + $grouped[$buttonName] = []; + } + $grouped[$buttonName][] = $keyName; + } + + foreach ($grouped as $buttonName => $keys) { + $keyList = implode(' or ', $keys); + $lines[] = sprintf('%-15s: %s', $buttonName, $keyList); + } + + return implode("\n", $lines); + } + + /** + * Print key mapping description to stdout. + */ + public function printKeyMappings(): void + { + echo $this->getKeyMappingDescription() . "\n"; + } +} diff --git a/src/Frontend/Sdl/SdlRenderer.php b/src/Frontend/Sdl/SdlRenderer.php new file mode 100644 index 0000000..ea8e255 --- /dev/null +++ b/src/Frontend/Sdl/SdlRenderer.php @@ -0,0 +1,368 @@ +> Framebuffer data [y][x] */ + private array $pixels = []; + + /** @var string Raw pixel buffer for SDL (RGBA format) */ + private string $pixelBuffer = ''; + + private int $frameCount = 0; + private int $scale; + private bool $vsync; + private bool $running = true; + + /** + * @param int $scale Window scale factor (1-8, default 4) + * @param bool $vsync Enable VSync for smooth 60fps (default true) + * @param string $windowTitle Window title + */ + public function __construct(int $scale = 4, bool $vsync = true, string $windowTitle = 'PHPBoy - Game Boy Color Emulator') + { + if (!extension_loaded('sdl')) { + throw new \RuntimeException( + "SDL extension not loaded. Install with: pecl install sdl-beta\n" . + "See docs/sdl2-setup.md for installation instructions." + ); + } + + $this->scale = max(1, min(8, $scale)); + $this->vsync = $vsync; + + // Initialize SDL2 video subsystem + if (SDL_Init(SDL_INIT_VIDEO) < 0) { + throw new \RuntimeException('SDL_Init failed: ' . SDL_GetError()); + } + + // Create window + $this->window = SDL_CreateWindow( + $windowTitle, + SDL_WINDOWPOS_CENTERED, + SDL_WINDOWPOS_CENTERED, + self::WIDTH * $this->scale, + self::HEIGHT * $this->scale, + SDL_WINDOW_SHOWN + ); + + if (!$this->window) { + throw new \RuntimeException('Failed to create SDL window: ' . SDL_GetError()); + } + + // Create hardware-accelerated renderer + $rendererFlags = SDL_RENDERER_ACCELERATED; + if ($this->vsync) { + $rendererFlags |= SDL_RENDERER_PRESENTVSYNC; + } + + $this->renderer = SDL_CreateRenderer($this->window, -1, $rendererFlags); + + if (!$this->renderer) { + throw new \RuntimeException('Failed to create SDL renderer: ' . SDL_GetError()); + } + + // Set render scale quality to nearest-neighbor (pixel-perfect scaling) + SDL_SetHint(SDL_HINT_RENDER_SCALE_QUALITY, '0'); + + // Create texture for Game Boy screen (160x144) + $this->texture = SDL_CreateTexture( + $this->renderer, + SDL_PIXELFORMAT_RGBA32, + SDL_TEXTUREACCESS_STREAMING, + self::WIDTH, + self::HEIGHT + ); + + if (!$this->texture) { + throw new \RuntimeException('Failed to create SDL texture: ' . SDL_GetError()); + } + + // Initialize framebuffer with white pixels + for ($y = 0; $y < self::HEIGHT; $y++) { + $this->pixels[$y] = []; + for ($x = 0; $x < self::WIDTH; $x++) { + $this->pixels[$y][$x] = new Color(255, 255, 255); + } + } + + // Pre-allocate pixel buffer (RGBA format: 4 bytes per pixel) + $this->pixelBuffer = str_repeat("\xFF\xFF\xFF\xFF", self::WIDTH * self::HEIGHT); + } + + /** + * Set a pixel at the given coordinates. + */ + public function setPixel(int $x, int $y, Color $color): void + { + if ($x >= 0 && $x < self::WIDTH && $y >= 0 && $y < self::HEIGHT) { + $this->pixels[$y][$x] = $color; + } + } + + /** + * Get a pixel at the given coordinates. + */ + public function getPixel(int $x, int $y): Color + { + if ($x >= 0 && $x < self::WIDTH && $y >= 0 && $y < self::HEIGHT) { + return $this->pixels[$y][$x]; + } + return new Color(0, 0, 0); + } + + /** + * Clear the framebuffer (fill with white). + */ + public function clear(): void + { + for ($y = 0; $y < self::HEIGHT; $y++) { + for ($x = 0; $x < self::WIDTH; $x++) { + $this->pixels[$y][$x] = new Color(255, 255, 255); + } + } + } + + /** + * Present the framebuffer to screen. + * + * This is called after each frame is complete. + * Updates the SDL texture and renders to window. + */ + public function present(): void + { + $this->frameCount++; + + // Convert Color objects to raw RGBA bytes + $offset = 0; + for ($y = 0; $y < self::HEIGHT; $y++) { + for ($x = 0; $x < self::WIDTH; $x++) { + $color = $this->pixels[$y][$x]; + $this->pixelBuffer[$offset++] = chr($color->r); + $this->pixelBuffer[$offset++] = chr($color->g); + $this->pixelBuffer[$offset++] = chr($color->b); + $this->pixelBuffer[$offset++] = chr(255); // Alpha + } + } + + // Update texture with pixel data (streaming texture for performance) + SDL_UpdateTexture($this->texture, null, $this->pixelBuffer, self::WIDTH * 4); + + // Clear renderer + SDL_RenderClear($this->renderer); + + // Copy texture to renderer (auto-scales to window size) + SDL_RenderCopy($this->renderer, $this->texture, null, null); + + // Present to screen (swaps buffers) + SDL_RenderPresent($this->renderer); + } + + /** + * Poll SDL events and handle window/input events. + * + * @return bool True if should continue running, false if quit requested + */ + public function pollEvents(): bool + { + $event = new \SDL_Event(); + + while (SDL_PollEvent($event)) { + if ($event->type === SDL_QUIT) { + $this->running = false; + return false; + } + + // Window events + if ($event->type === SDL_WINDOWEVENT) { + $this->handleWindowEvent($event); + } + } + + return $this->running; + } + + /** + * Handle window-specific events. + */ + private function handleWindowEvent(\SDL_Event $event): void + { + // Handle window close, resize, etc. + if (isset($event->window->event)) { + switch ($event->window->event) { + case SDL_WINDOWEVENT_CLOSE: + $this->running = false; + break; + } + } + } + + /** + * Get the framebuffer width. + */ + public function getWidth(): int + { + return self::WIDTH; + } + + /** + * Get the framebuffer height. + */ + public function getHeight(): int + { + return self::HEIGHT; + } + + /** + * Get the framebuffer as a 2D array. + * + * @return array> 2D array [y][x] of colors + */ + public function getFramebuffer(): array + { + return $this->pixels; + } + + /** + * Get the raw pixel data as a flat array. + * + * @return Color[] Array of colors in row-major order + */ + public function getPixelData(): array + { + $data = []; + for ($y = 0; $y < self::HEIGHT; $y++) { + for ($x = 0; $x < self::WIDTH; $x++) { + $data[] = $this->pixels[$y][$x]; + } + } + return $data; + } + + /** + * Get current frame count. + */ + public function getFrameCount(): int + { + return $this->frameCount; + } + + /** + * Check if renderer is still running. + */ + public function isRunning(): bool + { + return $this->running; + } + + /** + * Request renderer to stop. + */ + public function stop(): void + { + $this->running = false; + } + + /** + * Save current framebuffer to a PNG file. + * + * Requires GD extension. + * + * @param string $filename Output filename + */ + public function saveToPng(string $filename): void + { + if (!extension_loaded('gd')) { + throw new \RuntimeException("GD extension is required to save PNG files"); + } + + $image = imagecreatetruecolor(self::WIDTH, self::HEIGHT); + if ($image === false) { + throw new \RuntimeException("Failed to create image"); + } + + for ($y = 0; $y < self::HEIGHT; $y++) { + for ($x = 0; $x < self::WIDTH; $x++) { + $color = $this->pixels[$y][$x]; + $r = max(0, min(255, $color->r)); + $g = max(0, min(255, $color->g)); + $b = max(0, min(255, $color->b)); + $colorIndex = imagecolorallocate($image, $r, $g, $b); + if ($colorIndex !== false) { + imagesetpixel($image, $x, $y, $colorIndex); + } + } + } + + imagepng($image, $filename); + imagedestroy($image); + } + + /** + * Get renderer info for debugging. + * + * @return array{scale: int, vsync: bool, frames: int} + */ + public function getInfo(): array + { + return [ + 'scale' => $this->scale, + 'vsync' => $this->vsync, + 'frames' => $this->frameCount, + ]; + } + + /** + * Clean up SDL resources. + */ + public function __destruct() + { + if (isset($this->texture)) { + SDL_DestroyTexture($this->texture); + } + + if (isset($this->renderer)) { + SDL_DestroyRenderer($this->renderer); + } + + if (isset($this->window)) { + SDL_DestroyWindow($this->window); + } + + SDL_Quit(); + } +}