*/ private array $fonts = []; private float $colorR = 1.0; private float $colorG = 1.0; private float $colorB = 1.0; private float $colorA = 1.0; private float $pixelRatio = 1.0; /** * Constructor * * @param resource $renderer SDL Renderer resource */ public function __construct($renderer) { $this->renderer = $renderer; } /** * Initialize TTF with a font file * * @param string|null $fontPath Path to TTF font file * @param int $fontSize Default font size * @param int $atlasWidth Font atlas texture width (ignored, for compatibility) * @param int $atlasHeight Font atlas texture height (ignored, for compatibility) * @return bool Success */ public function init( null|string $fontPath = null, int $fontSize = 16, int $atlasWidth = 512, int $atlasHeight = 512, ): bool { // Try to find a suitable font if none provided if ($fontPath === null) { $fontPath = $this->findSystemFont(); } if (!file_exists($fontPath)) { error_log("Font file not found: {$fontPath}"); return false; } $this->fontPath = $fontPath; $this->defaultFontSize = $fontSize; $font = $this->loadFont($fontSize); if ($font === null) { error_log("Failed to open font: {$fontPath}"); $this->initialized = false; return false; } $this->initialized = true; // Set default color to white $this->setColor(1.0, 1.0, 1.0, 1.0); return true; } private function loadFont(int $size): mixed { $size = max(1, (int) round($size)) * $this->pixelRatio; if (isset($this->fonts[$size])) { return $this->fonts[$size]; } if ($this->fontPath === '') { return null; } $font = ttf_open_font($this->fontPath, $size); if (!$font) { return null; } $this->fonts[$size] = $font; return $font; } private function getFont(int $size): mixed { if (!$this->initialized) { return null; } return $this->loadFont($size); } /** * Ensure that rendered text does not exceed the maximum supported * texture size by truncating very long strings. */ private function truncateToMaxTextureSize(string $text, $font): string { if ($text === '') { return $text; } $dimensions = ttf_size_text($font, $text); $width = (int) ($dimensions['w'] ?? 0); $height = (int) ($dimensions['h'] ?? 0); if ($width <= self::MAX_TEXTURE_SIZE && $height <= self::MAX_TEXTURE_SIZE) { return $text; } $length = \function_exists('mb_strlen') ? mb_strlen($text) : strlen($text); if ($length <= 1) { return $text; } // Estimate how much we can keep based on width ratio $scale = self::MAX_TEXTURE_SIZE / max($width, 1); $targetLength = max(1, (int) floor($length * $scale)); $substr = \function_exists('mb_substr') ? 'mb_substr' : 'substr'; $text = $substr($text, 0, $targetLength); // Safety: if still too large, iteratively shrink for ($i = 0; $i < 3; $i++) { $dimensions = ttf_size_text($font, $text); $width = (int) ($dimensions['w'] ?? 0); $height = (int) ($dimensions['h'] ?? 0); if ($width <= self::MAX_TEXTURE_SIZE && $height <= self::MAX_TEXTURE_SIZE) { break; } $length = \function_exists('mb_strlen') ? mb_strlen($text) : strlen($text); if ($length <= 1) { break; } $targetLength = max(1, (int) floor($length * 0.7)); $text = $substr($text, 0, $targetLength); } return $text; } private function getScaledFontSize(int $size): int { if ($this->pixelRatio <= 1.0) { return $size; } return max(1, (int) round($size * $this->pixelRatio)); } public function setPixelRatio(float $ratio): void { $this->pixelRatio = max(1.0, $ratio); } /** * Find a system font * * @return string|null Font path or null if not found */ private function findSystemFont(): null|string { $fontPaths = [ // Linux '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', '/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf', '/usr/share/fonts/TTF/DejaVuSans.ttf', // macOS '/System/Library/Fonts/Helvetica.ttc', '/Library/Fonts/Arial.ttf', // Windows 'C:/Windows/Fonts/arial.ttf', 'C:/Windows/Fonts/calibri.ttf', ]; foreach ($fontPaths as $path) { if (file_exists($path)) { return $path; } } return null; } /** * Draw text at position * * @param string $text Text to draw * @param int $x X position * @param int $y Y position * @param int|null $size Font size */ public function drawText(string $text, int $x, int $y, null|int $size = null): void { if (!$this->initialized) { return; } $fontSize = $size ?? $this->defaultFontSize; $baseFont = $this->getFont($fontSize); if ($baseFont === null) { return; } $renderFontSize = $this->getScaledFontSize($fontSize); $renderFont = $this->getFont($renderFontSize) ?? $baseFont; // Convert float color (0.0-1.0) to int (0-255) $r = (int) ($this->colorR * 255); $g = (int) ($this->colorG * 255); $b = (int) ($this->colorB * 255); // Render text to surface with anti-aliasing (blended mode for smooth text) if (strlen($text) < 1) { return; } // Truncate extremely long text so that the resulting texture // stays within the GPU's max texture size. $safeText = $this->truncateToMaxTextureSize($text, $renderFont); if ($safeText === '') { return; } $surface = ttf_render_text_blended($renderFont, $safeText, $r, $g, $b); if (!$surface) { return; } // Create texture from surface $texture = sdl_create_texture_from_surface($this->renderer, $surface); if (!$texture) { return; } // Get text size $textSize = ttf_size_text($baseFont, $safeText); // Render texture sdl_render_texture($this->renderer, $texture, [ 'x' => $x, 'y' => $y, 'w' => $textSize['w'], 'h' => $textSize['h'], ]); // Note: Texture and surface are automatically cleaned up by PHP resource destructors } /** * Create an SDL texture for the given text using the current color settings. * * @param string $text Text to render into a texture * @return array|null Returns ['texture' => resource, 'width' => int, 'height' => int] or null on failure */ public function createTextTexture(string $text, null|int $size = null): null|array { if (!$this->initialized) { return null; } $fontSize = $size ?? $this->defaultFontSize; $baseFont = $this->getFont($fontSize); if ($baseFont === null) { return null; } $renderFontSize = $this->getScaledFontSize($fontSize); $renderFont = $this->getFont($renderFontSize) ?? $baseFont; $r = (int) ($this->colorR * 255); $g = (int) ($this->colorG * 255); $b = (int) ($this->colorB * 255); if (strlen($text) < 1) { return null; } // Truncate extremely long text so that the resulting texture // stays within the GPU's max texture size. $safeText = $this->truncateToMaxTextureSize($text, $renderFont); if ($safeText === '') { return null; } $surface = ttf_render_text_blended($renderFont, $safeText, $r, $g, $b); if (!$surface) { return null; } $texture = sdl_create_texture_from_surface($this->renderer, $surface); if (!$texture) { return null; } $dimensions = ttf_size_text($baseFont, $safeText); sdl_set_texture_blend_mode($texture, SDL_BLENDMODE_BLEND); if (\function_exists('sdl_set_texture_alpha_mod')) { sdl_set_texture_alpha_mod($texture, (int) ($this->colorA * 255)); } return [ 'texture' => $texture, 'width' => (int) $dimensions['w'], 'height' => (int) $dimensions['h'], ]; } /** * Set text color * * @param float $r Red (0.0 - 1.0) * @param float $g Green (0.0 - 1.0) * @param float $b Blue (0.0 - 1.0) * @param float $a Alpha (0.0 - 1.0) */ public function setColor(float $r, float $g, float $b, float $a = 1.0): void { $this->colorR = $r; $this->colorG = $g; $this->colorB = $b; $this->colorA = $a; } /** * Measure text dimensions * * @param string $text Text to measure * @param int|null $size Font size * @return array [width, height] */ public function measureText(string $text, null|int $size = null): array { if (!$this->initialized) { return [0, 0]; } $fontSize = $size ?? $this->defaultFontSize; $font = $this->getFont($fontSize); if ($font === null) { return [0, 0]; } $dimensions = ttf_size_text($font, $text); return [(int) $dimensions['w'], (int) $dimensions['h']]; } /** * Update framebuffer size (call on window resize) * * @param int $width Window width * @param int $height Window height */ public function updateFramebuffer(int $width, int $height): void { // SDL3 handles this automatically through the renderer // This method is kept for compatibility but does nothing } /** * Free TTF resources */ public function free(): void { if (!$this->initialized) { return; } foreach ($this->fonts as $font) { ttf_close_font($font); } $this->fonts = []; $this->initialized = false; } /** * Check if initialized * * @return bool */ public function isInitialized(): bool { return $this->initialized; } /** * Destructor */ public function __destruct() { $this->free(); } }