<?phpnamespace App\Stage;use Evenement\EventEmitter;use App\Multiplayer\BotBrain;use App\Multiplayer\Versus;use App\Gfx\Gfx;use App\Gfx\TextRenderer;use App\Save\GhostRecorder;use App\Save\BestTimes;use App\Save\Options;use App\Audio\SoundManager;use App\Util\Lang;/** * Title screen, map screen and act-complete tally, rendered from the images extracted from the ROM * (assets/screens, see screens.data.php for the original positions / timings). * Timings are counted in 60Hz frames like the original. SMS screens are 256x192, drawn x2 centred. * * End of the game (_LABEL_258B_133): the island screens, the emerald burst with 6 emeralds, the final tally * (_172f) and the credits (_LABEL_1CED_131), from assets/screens/ending.data.php (tools/ending.py). * * Game over (_1401): "GAME OVER" for 120 frames then 180 more (or the button); with continues left ($D284), * "CONTINUE", one icon per continue and a countdown from 9 (one a second, sfx $1A): the button continues. * * Events: titleDone, titleDemo, mapDone, actCompleteDone, creditsDone, gameOverDone [bool continued], versusStart, * nameDone [name]. */class Screens extends EventEmitter{ /** Whole scale of the 256x192 screens in the current view (set by render()). */ private int $scale = 2; private const FRAME = 1 / 60; private array $data; private ?string $screen = null; private float $frameClock = 0; private int $frames = 0; private int $offsetX = 0; private int $offsetY = 0; // map private int $mapLevel = 1; private int $mapStep = 0; private int $mapStepFrames = 0; private int $lives = 3; // act complete private int $actLevel = 1; private int $score = 0; private int $rings = 0; private int $timeBonus = 0; private int $emeralds = 0; private int $tallyEnd = -1; // end of the game private array $ending; /** @var array[] remaining steps of the ending: [name, frames, ...] */ private array $steps = []; private int $stepFrames = 0; private bool $allEmeralds = false; private string $tallyPage = 'emerald'; private int $specialBonus = 0; private ?array $burst = null; private ?array $fade = null; private string $islandImage = 'island'; // credits private array $creditNames = []; private int $scriptPos = 0; private int $scriptWait = 0; private int $printCol = 0; private int $printRow = 0; private array $printQueue = []; private int $scrollsLeft = 0; private array $anims = []; private bool $creditsOver = false; // game over private int $continues = 0; private int $countdown = 9; private ?SoundManager $sound = null; // title menu (instead of "PRESS BUTTON"): START / OPTIONS, and the options page (ghost on / off) private ?TextRenderer $text = null; private ?Options $options = null; private ?GhostRecorder $ghosts = null; /** Options > erase the ghosts: '' / 'confirm' (asked once more) / 'done' (shown until the cursor moves). */ private string $eraseState = ''; private string $titlePage = 'menu'; private int $menuIndex = 0; private int $titleIdle = 0; private bool $pendingButton = false; private const MENU_Y = 129; // two-tone text like the game's screens: yellow over orange, the selected line white over yellow private const MENU_TOP = [255, 255, 85]; private const MENU_BOTTOM = [255, 170, 0]; private const SELECTED_TOP = [255, 255, 255]; private const SELECTED_BOTTOM = [255, 255, 85]; private ?BestTimes $bestTimes = null; /** Best times page: the act shown (left / right). */ private int $timesAct = 1; // jukebox: the songs of the game and the sound effects $00-$22 private const SONGS = [ 'greenhill' => 'GREEN HILL', 'bridge' => 'BRIDGE', 'jungle' => 'JUNGLE', 'labyrinth' => 'LABYRINTH', 'scrapbrain' => 'SCRAP BRAIN', 'skybase' => 'SKY BASE', 'title' => 'TITRE', 'map' => 'CARTE', 'invincibility' => 'INVINCIBLE', 'boss' => 'BOSS', 'actcomplete' => 'ACTE TERMINE', 'death' => 'MORT', 'specialstage' => 'SPECIAL STAGE', 'emerald' => 'EMERAUDE', 'allemeralds' => 'TOUTES EMERAUDES', 'ending' => 'FIN', ]; private const SFX_COUNT = 0x23; private int $song = 0; private int $sfx = 0; // fadeOut ($0A6E): 4 steps of 10 frames darken the palette, then [black frames]; _b50: 10 black frames and // 4 steps of 10 frames back to the palette. [event, args, frames, black frames] private ?array $closing = null; private int $fadeInFrames = 0; public const FADE_STEP = 10; public function setSoundManager(SoundManager $sound): void { $this->sound = $sound; } public function setMenu(TextRenderer $text, Options $options, GhostRecorder $ghosts, BestTimes $bestTimes): void { $this->text = $text; $this->options = $options; $this->ghosts = $ghosts; $this->bestTimes = $bestTimes; } /** The best times page opens on this act (the one just completed). */ public function showTimesOf(int $act): void { $this->timesAct = $act; } /** * Entries of the current menu page: [label, value shown on the right or null, button, left / right (or null)]. * Pages: 'menu' (on the title picture), 'multi', 'options', 'jukebox', 'cheats', 'scores'. */ private function menuItems(): array { $options = $this->options; switch ($this->titlePage) { case 'options': $ghost = fn() => $this->changed(fn() => $options->setGhostEnabled(!$options->isGhostEnabled())); $crt = fn() => $this->changed(fn() => $options->setCrtEnabled(!$options->isCrtEnabled())); $speed = fn() => $this->changed(fn() => $options->setSpeed($options->getSpeed() === 'pal' ? 'ntsc' : 'pal')); // language: AUTO (the site / the browser / the system), FRANCAIS, ANGLAIS $langs = ['', 'fr', 'en']; $lang = fn(int $step = 1) => $this->changed(function () use ($options, $langs, $step) { $options->setLang($langs[((int)array_search($options->getLang(), $langs, true) + $step + 3) % 3]); Lang::set($options->getLang() ?: Lang::detect()); }); return [ ['GHOST', $options->isGhostEnabled() ? 'ON' : 'OFF', $ghost, $ghost], [match ($this->eraseState) { 'confirm' => 'CONFIRMER ?', 'done' => 'GHOSTS EFFACES', default => 'EFFACER GHOSTS' }, null, fn() => $this->eraseGhosts(), null], ['FILTRE CRT', $options->isCrtEnabled() ? 'ON' : 'OFF', $crt, $crt], ['VITESSE', $options->getSpeed() === 'pal' ? 'PAL 50HZ' : 'NTSC 60HZ', $speed, $speed], ['LANGUE', match ($options->getLang()) { 'fr' => 'FRANCAIS', 'en' => 'ANGLAIS', default => 'AUTO' }, $lang, $lang], ['JUKEBOX', null, fn() => $this->openPage('jukebox', 0), null], ['CHEATS', null, fn() => $this->openPage('cheats', 0), null], ['RETOUR', null, fn() => $this->openPage('menu', 2), null], ]; case 'multi': // multiplayer: mode, number of Sonics, act; LANCER starts the match (event versusStart) $modes = array_keys(Versus::MODES); $mode = $options->getVersusMode(); $players = $options->getVersusPlayers(); $level = $options->getVersusLevel(); $levels = Versus::LEVELS; $set = fn(string $m, int $p, int $l) => $this->changed(fn() => $options->setVersus($m, $p, $l)); $cycle = fn(array $list, $current, int $step) => $list[((int)array_search($current, $list, true) + $step + count($list)) % count($list)]; $nextMode = fn(int $step = 1) => $set($cycle($modes, $mode, $step), $players, $level); $team = $options->getVersusTeamSize(); $nextPlayers = fn(int $step = 1) => $mode === 'flag' ? $this->changed(fn() => $options->setVersusTeamSize(($team - 1 + $step + 3) % 3 + 1)) : $set($mode, ($players - 2 + $step + 4) % 4 + 2, $level); $nextLevel = fn(int $step = 1) => $set($mode, $players, $cycle($levels, $level, $step)); $ai = $options->getVersusDifficulty(); $nextAi = fn(int $step = 1) => $this->changed(fn() => $options->setVersusDifficulty($ai + $step)); return [ ['MODE', Versus::MODES[$mode], $nextMode, $nextMode], ['SONICS', $mode === 'flag' ? Lang::t('{a} CONTRE {b}', ['a' => $team, 'b' => $team]) : (string)$players, $nextPlayers, $nextPlayers], ['ZONE', $this->zoneName($level), $nextLevel, $nextLevel], ['IA', BotBrain::DIFFICULTIES[$ai], $nextAi, $nextAi], ['LANCER', null, fn() => $this->finish('versusStart'), null], ['RETOUR', null, fn() => $this->openPage('menu', 1), null], ]; case 'jukebox': $songs = array_keys(self::SONGS); $play = function () use ($songs) { $this->sound?->playMusic($songs[$this->song], true, true); }; $sfx = fn() => $this->sound?->playSfx($this->sfx); return [ ['MUSIQUE', self::SONGS[$songs[$this->song]], $play, function (int $direction) use ($songs, $play) { $this->song = ($this->song + $direction + count($songs)) % count($songs); $play(); }], ['BRUITAGE', sprintf('%02X', $this->sfx), $sfx, function (int $direction) use ($sfx) { $this->sfx = ($this->sfx + $direction + self::SFX_COUNT) % self::SFX_COUNT; $sfx(); }], ['STOP', null, fn() => $this->sound?->stopMusic(), null], ['RETOUR', null, function () { $this->sound?->playMusic('title', false, true); $this->openPage('options', 5); }, null], ]; case 'cheats': $items = []; foreach (Options::CHEATS as $cheat => $label) { $toggle = fn() => $this->changed(fn() => $options->toggleCheat($cheat)); $items[] = [$label, $options->isCheatOn($cheat) ? 'ON' : 'OFF', $toggle, $toggle]; } // the act a new game starts with: left / right, or the button for the next one $start = $options->getStartLevel(); $items[] = ['DEPART', BestTimes::place($start), fn() => $this->changed(fn() => $options->setStartLevel($start % 18 + 1)), fn(int $direction) => $this->changed(fn() => $options->setStartLevel(($start - 1 + $direction + 18) % 18 + 1))]; $items[] = ['RETOUR', null, fn() => $this->openPage('options', 6), null]; return $items; case 'scores': // the act: left / right, or the button for the next one $act = fn(int $direction) => [$this->timesAct = ($this->timesAct - 1 + $direction + 18) % 18 + 1, $this->sound?->playSfx(SoundManager::SFX_SWITCH)]; return [ [BestTimes::place($this->timesAct), null, fn() => $act(1), $act], ['RETOUR', null, fn() => $this->openPage('menu', 3), null], ]; default: return [ ['DEMARRER', null, fn() => $this->finish('titleDone'), null], ['MULTIJOUEUR', null, fn() => $this->openPage('multi', 0), null], ['OPTIONS', null, fn() => $this->openPage('options', 0), null], ['MEILLEURS TEMPS', null, fn() => $this->openPage('scores', 0), null], ]; } } private function openPage(string $page, int $index): void { $this->titlePage = $page; $this->menuIndex = $index; $this->eraseState = ''; $this->sound?->playSfx(SoundManager::SFX_SWITCH); } /** ZONE: the act, or the level of the site chosen for the matches. */ private function zoneName(int $level): string { return Versus::levelName($level); } /** A name typed on the site in the game's font: capitals, digits and spaces, $length characters at most. */ private const ACCENTS = [ 'à' => 'a', 'â' => 'a', 'ä' => 'a', 'á' => 'a', 'ã' => 'a', 'å' => 'a', 'ç' => 'c', 'é' => 'e', 'è' => 'e', 'ê' => 'e', 'ë' => 'e', 'î' => 'i', 'ï' => 'i', 'í' => 'i', 'ì' => 'i', 'ñ' => 'n', 'ô' => 'o', 'ö' => 'o', 'ó' => 'o', 'ò' => 'o', 'õ' => 'o', 'ù' => 'u', 'û' => 'u', 'ü' => 'u', 'ú' => 'u', 'ÿ' => 'y', 'ý' => 'y', 'œ' => 'oe', 'æ' => 'ae', 'ß' => 'ss', 'À' => 'A', 'Â' => 'A', 'Ä' => 'A', 'Á' => 'A', 'Ã' => 'A', 'Å' => 'A', 'Ç' => 'C', 'É' => 'E', 'È' => 'E', 'Ê' => 'E', 'Ë' => 'E', 'Î' => 'I', 'Ï' => 'I', 'Í' => 'I', 'Ì' => 'I', 'Ñ' => 'N', 'Ô' => 'O', 'Ö' => 'O', 'Ó' => 'O', 'Ò' => 'O', 'Õ' => 'O', 'Ù' => 'U', 'Û' => 'U', 'Ü' => 'U', 'Ú' => 'U', 'Ý' => 'Y', 'Œ' => 'OE', 'Æ' => 'AE', ]; public static function plain(mixed $text, int $length): string { // the accents by hand: the browser's PHP has no iconv $text = strtoupper(strtr((string)$text, self::ACCENTS)); $text = trim((string)preg_replace('/\s+/', ' ', (string)preg_replace('/[^A-Z0-9 ]/', ' ', $text))); return substr($text !== '' ? $text : '?', 0, $length); } private function changed(\Closure $change): void { $change(); $this->sound?->playSfx(SoundManager::SFX_RING); } /** Options > erase the ghosts, asked twice: the saved runs are deleted for good. */ private function eraseGhosts(): void { if ($this->eraseState === 'confirm') { $this->ghosts?->clearAll(); $this->eraseState = 'done'; $this->sound?->playSfx(SoundManager::SFX_EXPLOSION); } elseif ($this->eraseState === '') { $this->eraseState = 'confirm'; $this->sound?->playSfx(SoundManager::SFX_SWITCH); } } /** * Escape: 'quit' on the main menu; a page of the menu goes back to the one before it (its RETOUR) and the * name entry keeps the name typed ('handled'); any other screen: 'title' (Game goes back to the menu). */ public function back(): string { if ($this->screen === 'nameEntry') { if ($this->entryPurpose !== 'name') $this->entryName = ''; // Escape: no room / no new name $this->typeName('ok'); return 'handled'; } if ($this->screen !== 'title') return 'title'; if ($this->titlePage === 'menu' || $this->options === null) return 'quit'; $items = $this->menuItems(); (end($items)[2])(); return 'handled'; } /** Arrow keys on the title menu: 'up', 'down', 'left', 'right'. */ public function menuInput(string $direction): void { if ($this->screen !== 'title' || $this->closing !== null || $this->options === null) return; $this->titleIdle = 0; $items = $this->menuItems(); if ($direction === 'up' || $direction === 'down') { $count = count($items); $this->menuIndex = ($this->menuIndex + ($direction === 'up' ? $count - 1 : 1)) % $count; $this->eraseState = ''; $this->sound?->playSfx(SoundManager::SFX_SWITCH); } elseif ($side = $items[$this->menuIndex][3] ?? null) { $side($direction === 'left' ? -1 : 1); } } public function __construct(private Gfx $sdl) { $this->data = require dirname(__DIR__, 2) . '/assets/screens/screens.data.php'; $this->ending = require dirname(__DIR__, 2) . '/assets/screens/ending.data.php'; } public function loadTextures(): void { $files = ['title', 'title-press', 'title-hero', 'map1-hero', 'map2-hero', 'map1-digits', 'map2-digits', 'actcomplete-act1', 'actcomplete-act2', 'actcomplete-act3', 'actcomplete-bonus', 'actcomplete-digits', 'actcomplete-emerald', 'ending-island', 'ending-island-clear', 'ending-emerald', 'ending-digits', 'credits-tiles', 'credits-sprites', 'fade', 'gameover', 'continue', 'continue-digits', 'continue-icon']; foreach (['emerald', 'lives', 'special'] as $page) { $files[] = 'ending-tally-' . $page . '-island'; $files[] = 'ending-tally-' . $page . '-clear'; } for ($zone = 1; $zone <= 6; $zone++) { $files[] = 'map-zone' . $zone; $files[] = 'map-zone' . $zone . '-fg'; } foreach ($files as $file) { $this->sdl->loadTexture('screen-' . $file, 'screens/' . $file . '.png'); } } public function isActive(): bool { return $this->screen !== null; } public function getScore(): int { return $this->score; } public function hasMap(int $level): bool { return isset($this->data['maps'][$level]); } public function close(): void { $this->screen = null; $this->closing = null; $this->fadeInFrames = 0; $this->fade = null; } /** The title screen, on the main menu (or another page of it: 'multi' after a multiplayer match). */ /** The title menu, on its page $page, the cursor on entry $index. */ public function showTitle(string $page = 'menu', int $index = 0): void { $this->sound?->playMusic('title', false, true); $this->titlePage = $page; $this->menuIndex = $index; $this->titleIdle = 0; $this->eraseState = ''; $this->start('title'); } // new best time: the player's name on an on-screen keyboard (event nameDone [name]) private string $entryAct = ''; private int $entryRank = 1; private string $entryTime = ''; private string $entryName = ''; private int $entryRow = 0; private int $entryCol = 0; private const NAME_LENGTH = 8; // 10 columns; the last row: SPACE (columns 0-2), DELETE (3-6), OK (7-9) private const KEYBOARD = ['ABCDEFGHIJ', 'KLMNOPQRST', 'UVWXYZ.-!?', '0123456789']; private const KEYBOARD_KEYS = [['ESPACE', ' ', 0, 3], ['EFFACER', 'back', 3, 4], ['OK', 'ok', 7, 3]]; private const KEY_W = 20; private const KEY_H = 15; private const KEYBOARD_X = 28; private const KEYBOARD_Y = 92; /** 'name': a best time (event nameDone). */ private string $entryPurpose = 'name'; private function entryLength(): int { return self::NAME_LENGTH; } /** A time of the act entered the top 5: the name is typed on the on-screen keyboard. */ public function showNameEntry(string $act, int $rank, string $time): void { $this->entryPurpose = 'name'; $this->entryAct = $act; $this->entryRank = $rank; $this->entryTime = $time; $this->entryName = ''; $this->entryRow = 0; $this->entryCol = 0; $this->start('nameEntry'); } public function isNameEntry(): bool { return $this->screen === 'nameEntry'; } /** Arrows 'up' / 'down' / 'left' / 'right' move on the keyboard; a typed letter / digit, or 'back'. */ public function nameKey(string $key): void { if ($this->screen !== 'nameEntry' || $this->closing !== null || $this->fadeInFrames > 0) return; $rows = count(self::KEYBOARD) + 1; switch ($key) { case 'up': case 'down': $this->entryRow = ($this->entryRow + ($key === 'up' ? $rows - 1 : 1)) % $rows; $this->sound?->playSfx(SoundManager::SFX_SWITCH); return; case 'left': case 'right': if ($this->entryRow === $rows - 1) { // the wide keys of the last row: to the previous / next one $next = ($this->bottomKey() + ($key === 'left' ? 2 : 1)) % 3; $this->entryCol = self::KEYBOARD_KEYS[$next][2]; } else { $this->entryCol = ($this->entryCol + ($key === 'left' ? 9 : 1)) % 10; } $this->sound?->playSfx(SoundManager::SFX_SWITCH); return; case 'back': case 'ok': $this->typeName($key); return; default: // the real keyboard: the cursor goes to ESPACE, so that the space bar (also the button) types a space $this->typeName(strtoupper($key)); [$this->entryRow, $this->entryCol] = [count(self::KEYBOARD), self::KEYBOARD_KEYS[0][2]]; } } /** Index (0-2) of the wide key of the last row under the cursor. */ private function bottomKey(): int { foreach (self::KEYBOARD_KEYS as $i => [, , $from, $span]) { if ($this->entryCol >= $from && $this->entryCol < $from + $span) return $i; } return 0; } /** The button on the key under the cursor. */ private function pressNameKey(): void { if ($this->entryRow < count(self::KEYBOARD)) { $this->typeName(self::KEYBOARD[$this->entryRow][$this->entryCol]); } else { $this->typeName(self::KEYBOARD_KEYS[$this->bottomKey()][1]); } } /** A character, 'back' (delete the last one) or 'ok' (the name is done, "SONIC" when empty). */ private function typeName(string $value): void { if ($value === 'ok') { $name = trim($this->entryName); $this->screen = null; $this->sound?->playSfx(SoundManager::SFX_EXTRA_LIFE); if ($this->entryPurpose !== 'name') { $this->emit($this->entryPurpose . 'Done', [$name]); return; } $this->emit('nameDone', [$name === '' ? 'SONIC' : $name]); return; } $name = $this->entryName; if ($value === 'back') { $name = substr($name, 0, -1); } elseif (strlen($value) === 1 && strlen($name) < $this->entryLength() && !($value === ' ' && $name === '') && ($value === ' ' || str_contains(implode('', self::KEYBOARD), $value))) { $name .= $value; } $this->sound?->playSfx($name !== $this->entryName ? SoundManager::SFX_RING : SoundManager::SFX_SWITCH); $this->entryName = $name; } public function showGameOver(int $continues): void { $this->continues = $continues; $this->countdown = 9; $this->sound?->stopMusic(); $this->start('gameOver'); } public function showMap(int $level, int $lives): void { $this->mapLevel = $level; $this->lives = $lives; $this->mapStep = 0; $this->mapStepFrames = 0; $this->sound?->playMusic('map', false, true); $this->start('map'); $this->fadeInFrames = 5 * self::FADE_STEP; } /** Act complete: rings x100 and the time bonus are counted down into the score, 100 points per frame each. */ public function showActComplete(int $level, int $score, int $rings, float $time, int $lives, int $emeralds): void { $this->actLevel = $level; $this->score = $score; $this->rings = $rings; $this->lives = $lives; $this->emeralds = $emeralds; $this->timeBonus = 0; if (!$this->isBonusStage()) { foreach ($this->data['time_bonus'] as [$threshold, $bonus]) { if ((int)$time >= $threshold) { $this->timeBonus = $bonus; break; } } } $this->tallyEnd = -1; // already started when the sign stopped / the capsule opened $this->sound?->playMusic('actcomplete', false); $this->start('actComplete'); } /** * End of the game, after the final animation of level 19. $stats: 1-ups taken from monitors outside the * special stages ($D280), special stages entered ($D281) and finished before their time ran out ($D282), * fell into a pit at least once ($D283). */ public function showEnding(int $score, int $lives, int $emeralds, array $stats): void { $this->score = $score; $this->lives = $lives; $this->emeralds = $emeralds; $this->allEmeralds = $emeralds >= 6; // special bonus ($D2FF, BCD tens of thousands) $bonus = $this->allEmeralds ? 5 : 0; if ($stats['oneUps'] >= 0x12) $bonus += 5; if ($stats['specialEntered'] >= 8) $bonus += 5; if ($stats['specialCleared'] >= 8) $bonus += 5; if (!$stats['fellInPit']) $bonus += 10; if ($bonus === 30) $bonus += 20; $this->specialBonus = $bonus; $this->islandImage = 'island'; $this->tallyPage = ''; $this->burst = null; $this->fade = null; $steps = []; if ($this->allEmeralds) { // the emerald shown 60 frames, then it bursts into 6 (61 steps of 2 frames), the screen fades to // white and comes back without the dark cloud $steps[] = ['emerald', 60]; $steps[] = ['burst', 0x3D * 2]; $steps[] = ['fadeOut', 32, [255, 255, 255]]; $steps[] = ['clearIsland', 0]; $steps[] = ['fadeIn', 32, [255, 255, 255]]; } $steps[] = ['wait', 240]; // _172f: the chaos emeralds (20000 each, every 60 frames), the lives left (5000, every 30 frames), // the special bonus (10000 a unit, every 30 frames) // shortened: the original counts a unit every 60 / 30 / 30 frames with 180-480 frame pauses (up to // ~40 s with a big special bonus), the same points are counted here in a few seconds $steps[] = ['page', 0, 'emerald']; $steps[] = ['wait', 90]; $steps[] = ['count', 12, 'emerald']; $steps[] = ['wait', 90]; $steps[] = ['page', 0, 'lives']; $steps[] = ['wait', 90]; $steps[] = ['count', 8, 'lives']; $steps[] = ['wait', 90]; $steps[] = ['page', 0, 'special']; $steps[] = ['wait', 90]; $steps[] = ['count', 4, 'special']; $steps[] = ['wait', 180]; $steps[] = ['fadeOut', 32, [0, 0, 0]]; $steps[] = ['black', 120]; $steps[] = ['credits', 0]; $this->steps = $steps; $this->stepFrames = 0; $this->start('ending'); } private function start(string $screen): void { $this->closing = null; $this->fadeInFrames = 0; if ($screen !== 'credits') $this->fade = null; $this->screen = $screen; $this->pendingButton = false; $this->frames = 0; $this->frameClock = 0; } private function isBonusStage(): bool { return $this->actLevel >= 28 && $this->actLevel <= 35; } public function update(float $deltaTime, bool $buttonPressed): void { if ($this->screen === null) return; // kept until the next 60Hz frame: the loop may call this between two frames $this->pendingButton = $this->pendingButton || $buttonPressed; $buttonPressed = false; $this->frameClock += $deltaTime; while ($this->frameClock >= self::FRAME && $this->screen !== null) { $this->frameClock -= self::FRAME; if ($this->closing !== null) { $this->pendingButton = false; $this->tickClosing(); continue; } if ($this->fadeInFrames > 0) { // _b50: the screen is frozen while it fades in $this->pendingButton = false; $done = 5 * self::FADE_STEP - $this->fadeInFrames--; $this->fade = [[0, 0, 0], max(0, 3 - intdiv(max(0, $done - self::FADE_STEP), self::FADE_STEP) - 1) / 3]; if ($done < self::FADE_STEP) $this->fade = [[0, 0, 0], 1]; if ($this->fadeInFrames === 0) $this->fade = null; continue; } $this->frames++; $this->tick($this->pendingButton); $this->pendingButton = false; } } private function tick(bool $buttonPressed): void { switch ($this->screen) { case 'title': $this->titleIdle++; if ($buttonPressed) { $this->titleIdle = 0; if ($this->options === null) { $this->finish('titleDone'); } else { $items = $this->menuItems(); ($items[$this->menuIndex][2])(); } } elseif ($this->titlePage === 'menu' && $this->titleIdle >= $this->data['title']['wags'] * $this->data['title']['frame_ticks'] + 2 * 0xFF) { // _1372: the finger animation ends (22 x 8 frames, then 2 x 255 frames): the demo starts $this->finish('titleDemo'); } break; case 'map': $frames = $this->data['maps'][$this->mapLevel]['frames']; if (++$this->mapStepFrames >= $frames[$this->mapStep][3]) { $this->mapStepFrames = 0; $this->mapStep = ($this->mapStep + 1) % count($frames); } // 300 frames, or the button if ($buttonPressed || $this->frames >= 300) $this->fadeOutThen('mapDone', [], 0x3C); break; case 'ending': $this->tickEnding(); break; case 'gameOver': // the screen is shown for $78 frames first if ($this->frames <= 0x78) break; if ($this->continues === 0) { if ($buttonPressed || $this->frames >= 0x78 + 0xB4) $this->finishGameOver(false); break; } if ($buttonPressed) { $this->finishGameOver(true); } elseif (($this->frames - 0x78) % 0x3C === 0) { $this->sound?->playSfx(SoundManager::SFX_SWITCH); if ($this->countdown === 0) { $this->finishGameOver(false); } else { $this->countdown--; } } break; case 'credits': $this->tickCredits($buttonPressed); break; case 'nameEntry': if ($buttonPressed) $this->pressNameKey(); break; case 'actComplete': if ($this->frames <= 120) break; if ($this->tallyEnd < 0) { if ($this->rings > 0) { $this->rings--; $this->score += 100; } // faster than the original (100 a frame): the same total, counted 5 times quicker if ($this->timeBonus > 0) { $step = min($this->timeBonus, 500); $this->timeBonus -= $step; $this->score += $step; } // `_155e`: the ring sound every 4 frames while counting if ($this->frames % 4 === 0) $this->sound?->playSfx(SoundManager::SFX_RING); if ($this->rings === 0 && $this->timeBonus === 0) $this->tallyEnd = $this->frames; } elseif ($buttonPressed || $this->frames - $this->tallyEnd >= 180) { $this->fadeOutThen('actCompleteDone', []); } break; } } private function tickEnding(): void { while ($this->steps) { $step = $this->steps[0]; [$name, $frames] = $step; if ($this->stepFrames === 0) { // first frame of a step switch ($name) { case 'burst': $this->sound?->playMusic('allemeralds', false, true); break; case 'clearIsland': $this->islandImage = 'clear'; break; case 'page': $this->tallyPage = $step[2]; break; case 'credits': $this->startCredits(); return; } } if ($name === 'count') { // one unit every $frames frames, until nothing is left if (++$this->stepFrames % $frames !== 0) return; $left = match ($step[2]) { 'emerald' => $this->emeralds, 'lives' => $this->lives, default => $this->specialBonus, }; if ($left === 0) { array_shift($this->steps); $this->stepFrames = 0; continue; } match ($step[2]) { 'emerald' => [$this->emeralds--, $this->score += 20000], 'lives' => [$this->lives--, $this->score += 5000], default => [$this->specialBonus--, $this->score += 10000], }; $this->sound?->playSfx(SoundManager::SFX_RING); return; } if ($this->stepFrames < $frames) { $this->stepFrames++; $this->burst = $name === 'burst' ? $this->ending['burst'][min(0x3C, intdiv($this->stepFrames - 1, 2))] : null; $this->fade = match ($name) { 'fadeOut' => [$step[2], $this->stepFrames / $frames], 'fadeIn' => [$step[2], 1 - $this->stepFrames / $frames], 'black' => [[0, 0, 0], 1], default => null, }; return; } array_shift($this->steps); $this->stepFrames = 0; } } private function startCredits(): void { $this->creditNames = $this->ending['names']; $this->scriptPos = 0; $this->scriptWait = 0; $this->printQueue = []; $this->scrollsLeft = 0; $this->creditsOver = false; // the 4 sprite animations of $D322: [entry, frame counter] $this->anims = array_fill(0, count($this->ending['anims']), [0, 0]); $this->sound?->playMusic('ending', false, true); $this->fade = [[0, 0, 0], 1]; $this->start('credits'); } /** S1_Credits_Text interpreter (_2795): a character every 8 frames, waits, scrolls of the lower box. */ private function tickCredits(bool $buttonPressed): void { // the palette fades in (_b50) $this->fade = $this->frames < 32 ? [[0, 0, 0], 1 - $this->frames / 32] : null; // _275a: each entry is shown for its duration + 1 frames foreach ($this->ending['anims'] as $k => $steps) { [$entry, $counter] = $this->anims[$k]; if (++$counter > $steps[$entry][0]) { $counter = 0; $entry = ($entry + 1) % count($steps); } $this->anims[$k] = [$entry, $counter]; } if ($this->creditsOver) { // the original stops here for good; the button goes back to the title screen if ($buttonPressed) $this->finish('creditsDone'); return; } if ($this->scriptWait > 0) { $this->scriptWait--; if ($this->scriptWait > 0) return; if ($this->printQueue) { $this->creditNames[$this->printRow * 32 + $this->printCol++] = array_shift($this->printQueue); } elseif ($this->scrollsLeft > 0) { $this->scrollCredits(); $this->scrollsLeft--; } } if ($this->printQueue) { $this->scriptWait = 8; return; } if ($this->scrollsLeft > 0) { $this->scriptWait = 12; return; } $script = $this->ending['script']; if ($this->scriptPos >= count($script)) { $this->creditsOver = true; return; } $command = $script[$this->scriptPos++]; switch ($command[0]) { case 'p': [, $this->printCol, $this->printRow, $this->printQueue] = $command; $this->scriptWait = 8; break; case 'w': $this->scriptWait = $command[1]; break; case 's': $this->scrollsLeft = $command[1]; $this->scriptWait = 12; break; } } /** The lower text box moves up a line: rows 11-19 of columns 18-27 copied one row up. */ private function scrollCredits(): void { for ($row = 10; $row < 19; $row++) { for ($col = 18; $col < 28; $col++) { $this->creditNames[$row * 32 + $col] = $this->creditNames[($row + 1) * 32 + $col]; } } } private function finishGameOver(bool $continued): void { $this->fadeOutThen('gameOverDone', [$continued]); } /** fadeOut, then $black frames of a black screen, then the event (the screen closes). */ private function fadeOutThen(string $event, array $args, int $black = 0): void { $this->closing = [$event, $args, 0, $black]; } private function tickClosing(): void { [$event, $args, $frames, $black] = $this->closing; $this->closing[2] = ++$frames; $this->fade = [[0, 0, 0], min(3, intdiv($frames - 1, self::FADE_STEP) + 1) / 3]; if ($frames < 4 * self::FADE_STEP + $black) return; $this->closing = null; $this->fade = null; $this->screen = null; $this->emit($event, $args); } private function renderGameOver(): void { $this->clear(0, 0, 0); if ($this->continues === 0) { $this->blit('gameover', 0, 0, 256, 192, 0, 0); return; } $layout = $this->data['continue']; $this->blit('continue', 0, 0, 256, 192, 0, 0); // _16d9: the icons 2 columns apart, from column $0B - count / 2 $col = $layout['icon_col'] - intdiv($this->continues, 2); for ($i = 0; $i < $this->continues; $i++) { $this->blit('continue-icon', 0, 0, 16, 16, ($col + 2 * $i) * 8, $layout['icon_row'] * 8); } if ($this->frames > 0x78) { // sprite: one line lower $this->drawNumber('continue-digits', (string)$this->countdown, $layout['digit'][0], $layout['digit'][1] + 1); } } private function finish(string $event): void { $this->screen = null; $this->emit($event, []); } /** Draws the screen on a $winW x $winH target (Game: the window itself, so that the texts are sharp). */ public function render(int $winW, int $winH): void { if ($this->screen === null) return; $renderer = $this->sdl->getRenderer()->getRenderer(); // biggest whole scale that fits the view (2 in the default 840x426 view, 1 in the 256x192 one) $this->scale = max(1, (int)floor(min($winW / 256, $winH / 192))); $this->offsetX = (int)(($winW - 256 * $this->scale) / 2); $this->offsetY = (int)(($winH - 192 * $this->scale) / 2); match ($this->screen) { 'title' => $this->renderTitle(), 'map' => $this->renderMap(), 'actComplete' => $this->renderActComplete(), 'ending' => $this->renderEnding(), 'credits' => $this->renderCredits(), 'gameOver' => $this->renderGameOver(), 'nameEntry' => $this->renderNameEntry(), }; if ($this->fade !== null) { // fade.png: white / black at 1/3, 2/3 and 3/3 (the palette fades in steps) [[$r], $alpha] = $this->fade; $step = (int)ceil(max(0, min(1, $alpha)) * 3); if ($step > 0) { $src = new \SDL_Rect; [$src->x, $src->y, $src->w, $src->h] = [($step - 1) * 8, $r > 0 ? 0 : 8, 8, 8]; $dst = new \SDL_Rect; [$dst->x, $dst->y, $dst->w, $dst->h] = [0, 0, $winW, $winH]; \SDL_RenderCopy($renderer, $this->sdl->getTextures('screen-fade')[0]['texture'], $src, $dst); } } } private function renderTitle(): void { $title = $this->data['title']; $this->clear(0, 0, 0); if ($this->titlePage !== 'menu' && $this->text !== null) { $this->renderMenuPage(); return; } // the menu takes the place of "PRESS BUTTON" (without the menu: shown for 64 frames out of 100) $press = $this->text === null && ($this->frames % $title['press_period']) < $title['press_visible']; $this->blit($press ? 'title-press' : 'title', 0, 0, 256, 192, 0, 0); if ($this->text !== null) { // the lines between the logo and the copyright line of the picture (5: a little tighter) $items = $this->menuItems(); $step = count($items) > 4 ? 9 : 10; if (count($items) > 4) { // 5 lines reach the copyright line of the picture (rows 168-174): moved down to the bottom, // its place covered with the black rows under it $this->blit('title', 0, 168, 256, 7, 0, 181); $this->blit('title', 0, 176, 256, 7, 0, 168); } foreach ($items as $i => [$label]) { $this->menuLine($label, $i === $this->menuIndex, self::MENU_Y - (count($items) > 4 ? 5 : 0) + $step * $i); } } // wagging finger: alternates every 8 frames 22 times, then rests on the first frame $tick = intdiv($this->frames, $title['frame_ticks']); $frame = $tick < $title['wags'] ? $tick % 2 : 0; $this->blit('title-hero', $frame * 48, 0, 48, 48, $title['x'], $title['y']); } /** One centred line of the title menu at $y (SMS pixels); the selected one has the arrow and blinks. */ private function menuLine(string $line, bool $selected, int $y): void { // translated before the arrow ("> DEMARRER" is not in the dictionary) $label = ($selected ? '> ' : '') . Lang::t($line); $size = 8 * $this->scale; $width = $this->text->styledWidth($label, $size, $this->scale); $this->styled($label, (int)((256 * $this->scale - $width) / 2 / $this->scale), $y, $size, $this->lineColours($selected)); } /** * Text at SMS position ($x, $y) with the outline of the game's screens. $overBlack: kept in a texture * (TextRenderer::drawStyled), not over a coloured box. */ private function styled(string $text, float $x, int $y, int $size, array $colours, bool $overBlack = true): void { $this->text->drawStyled($text, $this->offsetX + (int)round($x * $this->scale), $this->offsetY + $y * $this->scale, $size, $colours[0], $colours[1], $this->scale, $overBlack); } /** fit(): one step of the scrolling text every this many frames (6 characters a second), its pause at each end in steps. */ private const SCROLL_FRAMES = 10; private const SCROLL_PAUSE = 8; /** @var array<string, string> what fit() found, by text, size, width and start */ private array $fits = []; /** * $text (translated) in at most $width SMS pixels: as it is if it fits; too long, its start and "...", or with * $scroll a window that goes along it and back to the start (a pause at each end), a character at a time. */ private function fit(string $text, int $size, int $width, bool $scroll): string { $text = Lang::t($text); $wide = fn(string $part) => $this->text->styledWidth($part, $size, $this->scale) / $this->scale > $width; if (!$wide($text)) return $text; $from = 0; if ($scroll) { // the last start: the end of the text in view $key = "last|$size|$width|$text"; if (!isset($this->fits[$key])) { $last = strlen($text) - 1; while ($last > 0 && !$wide(substr($text, $last - 1))) $last--; $this->fits[$key] = (string)$last; } $last = (int)$this->fits[$key]; $tick = intdiv($this->frames, self::SCROLL_FRAMES) % ($last + 2 * self::SCROLL_PAUSE); $from = max(0, min($last, $tick - self::SCROLL_PAUSE)); } $key = ($scroll ? "s$from" : "cut") . "|$size|$width|$text"; if (!isset($this->fits[$key])) { if (count($this->fits) > 512) $this->fits = []; $end = $scroll ? '' : '...'; $part = ''; for ($i = $from, $n = strlen($text); $i < $n && !$wide($part . $text[$i] . $end); $i++) $part .= $text[$i]; $this->fits[$key] = $scroll ? $part : rtrim($part) . $end; } return $this->fits[$key]; } private function lineColours(bool $selected): array { return $selected && ($this->frames % 40) < 24 ? [self::SELECTED_TOP, self::SELECTED_BOTTOM] : [self::MENU_TOP, self::MENU_BOTTOM]; } /** Options, cheats and high scores: a page of its own, its title on top, the entries below. */ private function renderMenuPage(): void { $scale = $this->scale; $title = match ($this->titlePage) { 'cheats' => 'CHEATS', 'scores' => 'MEILLEURS TEMPS', 'jukebox' => 'JUKEBOX', 'multi' => 'MULTIJOUEUR', default => 'OPTIONS', }; $big = 12 * $scale; $width = $this->text->styledWidth($title, $big, $scale); $this->styled($title, (256 * $scale - $width) / 2 / $scale, 12, $big, [[255, 255, 255], [85, 170, 255]]); $items = $this->menuItems(); if ($this->titlePage === 'scores') { $this->renderBestTimes(); return; } $size = 8 * $scale; $step = count($items) > 6 ? 14 : (count($items) === 6 ? 18 : 20); foreach ($items as $i => [$label, $value]) { $y = 40 + $step * $i; $selected = $i === $this->menuIndex; if ($selected) $this->styled('>', 18, $y, $size, $this->lineColours(true)); // the label and the value share the line (x 32 to 236): a long one (a level of the site) is cut, and // scrolls on the selected line; the value keeps its room first unless it is the long one $labelWidth = $this->text->styledWidth($label, $size, $scale) / $scale; if ($value !== null) { $valueRoom = $labelWidth + 8 + $this->text->styledWidth($value, $size, $scale) / $scale > 204 ? max(48, 204 - 8 - min($labelWidth, 120)) : 204; $value = $this->fit($value, $size, (int)$valueRoom, $selected); $labelRoom = 204 - 8 - (int)ceil($this->text->styledWidth($value, $size, $scale) / $scale); } $this->styled($this->fit($label, $size, $value !== null ? $labelRoom : 204, $selected), 32, $y, $size, $this->lineColours($selected)); if ($value !== null) { // ON in green, OFF in grey, other values in the menu colours; right-aligned $colours = match ($value) { 'ON' => [[170, 255, 170], [0, 200, 0]], 'OFF' => [[170, 170, 170], [85, 85, 85]], default => $this->lineColours($selected), }; $width = $this->text->styledWidth($value, $size, $scale) / $scale; $this->styled($value, 236 - $width, $y, $size, $colours); } } if ($this->titlePage === 'multi') { // the rules of the mode chosen, under the entries $rules = [ 'race' => ['LE PREMIER A L ARRIVEE GAGNE', 'SAUTE OU ROULE SUR LES AUTRES'], 'flag' => ['RAMENE LE DRAPEAU A TA BASE', 'TOUCHE TU LE LACHES 3 POINTS'], 'rings' => ['TOUCHE TU PRENDS SES ANNEAUX', '3 MIN LE PLUS D ANNEAUX GAGNE'], 'deathmatch' => ['SAUTE OU ROULE SUR LES AUTRES', '3 MIN LE PLUS DE KO GAGNE'], ][$this->options->getVersusMode()]; foreach ($rules as $i => $rule) { $width = $this->text->styledWidth($rule, $size, $scale); $this->styled($rule, (256 * $scale - $width) / 2 / $scale, 154 + 12 * $i, $size, [[255, 255, 255], [170, 170, 170]]); } } } /** New record: "NOUVEAU RECORD !", the act, place and time, the name and the on-screen keyboard. */ private function renderNameEntry(): void { $this->clear(0, 0, 0); if ($this->text === null) return; $scale = $this->scale; $centre = function (string $text, int $y, int $size, array $colours) use ($scale): void { $width = $this->text->styledWidth($text, $size * $scale, $scale); $this->styled($text, (256 * $scale - $width) / 2 / $scale, $y, $size * $scale, $colours); }; $blue = [[255, 255, 255], [85, 170, 255]]; $length = $this->entryLength(); $centre('NOUVEAU RECORD !', 8, 12, ($this->frames % 30) < 20 ? $blue : [[255, 255, 85], [255, 170, 0]]); $centre($this->entryAct . ' ' . Lang::rank($this->entryRank) . ' ' . $this->entryTime, 32, 8, [self::MENU_TOP, self::MENU_BOTTOM]); // the name, with a blinking cursor, in a fixed-width line $cursor = strlen($this->entryName) < $length && ($this->frames % 30) < 15 ? '_' : ' '; $shown = str_pad($this->entryName . $cursor, $length, ' '); $width = $this->text->styledWidth(str_repeat('W', $length), 12 * $scale, $scale); $this->box(64, 52, 128, 24, [0, 0, 85]); $this->styled($shown, (256 * $scale - $width) / 2 / $scale, 57, 12 * $scale, $blue, false); // keyboard: the key under the cursor on a blue background $size = 8 * $scale; $rows = self::KEYBOARD; foreach ($rows as $r => $chars) { foreach (str_split($chars) as $c => $char) { $this->keyCap($char, self::KEYBOARD_X + $c * self::KEY_W, self::KEYBOARD_Y + $r * self::KEY_H, self::KEY_W, $r === $this->entryRow && $c === $this->entryCol, $size); } } $bottom = count($rows) === $this->entryRow ? $this->bottomKey() : -1; foreach (self::KEYBOARD_KEYS as $i => [$label, , $from, $span]) { $this->keyCap($label, self::KEYBOARD_X + $from * self::KEY_W, self::KEYBOARD_Y + count($rows) * self::KEY_H, $span * self::KEY_W, $i === $bottom, $size); } } /** One key of the on-screen keyboard, centred in its cell (SMS pixels). */ private function keyCap(string $label, int $x, int $y, int $w, bool $selected, int $size): void { if ($selected) $this->box($x + 1, $y - 2, $w - 2, self::KEY_H - 1, [0, 85, 170]); $width = $this->text->styledWidth($label, $size, $this->scale) / $this->scale; $this->styled($label, $x + ($w - $width) / 2, $y + 1, $size, $selected ? [self::SELECTED_TOP, self::SELECTED_BOTTOM] : [self::MENU_TOP, self::MENU_BOTTOM], !$selected); } /** Opaque rectangle (SMS pixels). */ private function box(int $x, int $y, int $w, int $h, array $rgb): void { $renderer = $this->sdl->getRenderer()->getRenderer(); \SDL_SetRenderDrawColor($renderer, $rgb[0], $rgb[1], $rgb[2], 255); $rect = new \SDL_Rect; [$rect->x, $rect->y, $rect->w, $rect->h] = [$this->offsetX + $x * $this->scale, $this->offsetY + $y * $this->scale, $w * $this->scale, $h * $this->scale]; \SDL_RenderFillRect($renderer, $rect); } /** Best times page: "< ZONE 1 ACTE 1 >", its 5 best times with their date (the last one blinking), RETOUR. */ private function renderBestTimes(): void { $selected = $this->menuIndex === 0; $label = '< ' . BestTimes::place($this->timesAct) . ' >'; $size = 8 * $this->scale; $width = $this->text->styledWidth($label, $size, $this->scale); $this->styled($label, (256 * $this->scale - $width) / 2 / $this->scale, 38, $size, $this->lineColours($selected)); $entries = $this->bestTimes?->top($this->timesAct) ?? []; $last = $this->bestTimes?->lastIndex($this->timesAct); if (!$entries) { $this->menuLine('AUCUN TEMPS', false, 92); } foreach ($entries as $i => $entry) { // the time just made blinks if ($i === $last && ($this->frames % 40) >= 28) continue; $colours = $i === 0 ? [[255, 255, 255], [255, 255, 85]] : [self::MENU_TOP, self::MENU_BOTTOM]; $y = 62 + 18 * $i; $this->styled(($i + 1) . '.', 14, $y, $size, $colours); $this->styled($entry['name'] !== '' ? $entry['name'] : '-', 32, $y, $size, $colours); $this->styled(BestTimes::format($entry['time']), 112, $y, $size, $colours); $this->styled($entry['date'], 182, $y, $size, $colours); } $this->menuLine('RETOUR', $this->menuIndex === 1, 168); } private function renderMap(): void { $map = $this->data['maps'][$this->mapLevel]; $zone = $map['zone']; $this->clearFromTexturePixel(); $this->blit('map-zone' . $zone, 0, 0, 256, 192, 0, 0); [$cell, $x, $y] = $map['frames'][$this->mapStep]; $this->blit('map' . $map['screen'] . '-hero', $cell * 48, 0, 48, 48, $x, $y); $this->drawNumber('map' . $map['screen'] . '-digits', sprintf('%02d', min(99, $this->lives)), $this->data['map_lives']['x'], $this->data['map_lives']['y']); $this->blit('map-zone' . $zone . '-fg', 0, 0, 256, 192, 0, 0); } private function renderActComplete(): void { $layout = $this->data['act_layout']; $this->clear(0x55, 0x55, 0xFF); if ($this->isBonusStage()) { $this->blit('actcomplete-bonus', 0, 0, 256, 192, 0, 0); } else { $act = intdiv($this->data['act_act'][$this->actLevel - 1] ?? 0, 8) + 1; $this->blit('actcomplete-act' . $act, 0, 0, 256, 192, 0, 0); } $digits = 'actcomplete-digits'; $this->drawNumber($digits, $this->blankZeros(sprintf('%08d', $this->score)), ...$layout['score']); if ($this->isBonusStage()) { $this->drawNumber($digits, $this->blankZeros(sprintf('%02d', $this->rings)), ...$layout['bonus_rings']); $this->drawNumber($digits, ' 100', $layout['ring_value'][0], $layout['bonus_rings'][1]); } else { $this->drawNumber($digits, $this->blankZeros(sprintf('%08d', $this->timeBonus)), ...$layout['time_bonus']); $this->drawNumber($digits, $this->blankZeros(sprintf('%02d', $this->rings)), ...$layout['rings']); $this->drawNumber($digits, ' 100', ...$layout['ring_value']); } $this->drawNumber($digits, sprintf('%02d', min(99, $this->lives)), ...$layout['lives']); // chaos emeralds collected so far, 2 columns apart and centred on column 16 $col = $layout['emerald_col'] - intdiv($this->emeralds, 2); for ($i = 0; $i < $this->emeralds; $i++) { $this->blit('actcomplete-emerald', 0, 0, 16, 16, ($col + 2 * $i) * 8, $layout['emerald_row'] * 8); } } private function renderEnding(): void { $this->clear(0x55, 0x55, 0xFF); if ($this->tallyPage !== '') { $this->blit('ending-tally-' . $this->tallyPage . '-' . $this->islandImage, 0, 0, 256, 192, 0, 0); $digits = 'ending-digits'; // _1860: the score at ($90, $80), the count at ($90, $60), what it is worth at ($A0, $60) $this->drawNumber($digits, $this->blankZeros(sprintf('%08d', min(99999999, $this->score))), 0x90, 0x81); match ($this->tallyPage) { 'emerald' => [ $this->drawNumber($digits, $this->blankZeros(sprintf('%02d', $this->emeralds)), 0x90, 0x61), $this->drawNumber($digits, ' 20000', 0xA0, 0x61), ], 'lives' => [ $this->drawNumber($digits, sprintf('%02d', min(99, $this->lives)), 0x90, 0x61), $this->drawNumber($digits, ' 5000', 0xA0, 0x61), ], default => $this->drawNumber($digits, $this->blankZeros(sprintf('%02d0000', $this->specialBonus)), 0xA0, 0x61), }; return; } $this->blit($this->islandImage === 'clear' ? 'ending-island-clear' : 'ending-island', 0, 0, 256, 192, 0, 0); $step = $this->steps[0][0] ?? ''; if ($step === 'emerald') { $this->blit('ending-emerald', 0, 0, 16, 16, 0x70, 0x61); } elseif ($this->burst !== null) { foreach ($this->burst as [$dx, $dy]) { $this->blit('ending-emerald', 0, 0, 16, 16, 0x70 + $dx, 0x61 + $dy); $this->blit('ending-emerald', 0, 0, 16, 16, 0x70 - $dx, 0x61 - $dy); } } } private function renderCredits(): void { $this->clear(0, 0, 0); foreach ($this->creditNames as $i => $tile) { if ($i >= 32 * 24) break; $tile &= 0xFF; $this->blit('credits-tiles', ($tile % 16) * 8, intdiv($tile, 16) * 8, 8, 8, ($i % 32) * 8, intdiv($i, 32) * 8); } foreach ($this->ending['anims'] as $k => $steps) { $frame = $steps[$this->anims[$k][0]][1]; [$x, $y] = $this->ending['sprite_frames'][$frame]; $this->blit('credits-sprites', $frame * 48, 0, 48, 48, $x, $y); } } /** Leading zeros become blanks, like routine $1B13 (a value of zero still shows its last digit). */ private function blankZeros(string $digits): string { $trimmed = ltrim($digits, '0'); if ($trimmed === '') $trimmed = '0'; return str_pad($trimmed, strlen($digits), ' ', STR_PAD_LEFT); } private function drawNumber(string $texture, string $text, int $x, int $y): void { foreach (str_split($text) as $i => $char) { if ($char === ' ') continue; $this->blit($texture, (int)$char * 8, 0, 8, 16, $x + $i * 8, $y); } } private function clear(int $r, int $g, int $b): void { $renderer = $this->sdl->getRenderer()->getRenderer(); \SDL_SetRenderDrawColor($renderer, $r, $g, $b, 255); \SDL_RenderClear($renderer); } /** The map backdrop colour fills the borders. */ private function clearFromTexturePixel(): void { $this->clear(0xFF, 0xAA, 0xAA); } private function blit(string $texture, int $sx, int $sy, int $w, int $h, int $x, int $y): void { $src = new \SDL_Rect; $src->x = $sx; $src->y = $sy; $src->w = $w; $src->h = $h; $dst = new \SDL_Rect; $dst->x = $this->offsetX + $x * $this->scale; $dst->y = $this->offsetY + $y * $this->scale; $dst->w = $w * $this->scale; $dst->h = $h * $this->scale; \SDL_RenderCopy($this->sdl->getRenderer()->getRenderer(), $this->sdl->getTextures('screen-' . $texture)[0]['texture'], $src, $dst); }}