<?phpnamespace App\Data;use App\Game\Values;use JumPHP\Kernel\GameConfig;/** * Everything the game reads from assets/ (a port of modern/src/assets.cpp, minus the graphics pixels: * the engine draws with textures, the palettes and colours are kept as [r, g, b] arrays). * The room text files are parsed at boot (a few dozen files, done once). */final class Assets{ public const FRAME_LANTERN = 0x64; public const FRAME_TELEPORTER = 0x60; public const KIND_TILES = 0; public const KIND_SPRITES = 1; public const KIND_EXTRA = 2; public const KIND_FONT = 3; public const KIND_PICTURE = 4; public const SFX_FILES = [ 'shoot', 'rise', 'click', 'enemy_explode', 'player_death', 'pickup', 'blip', 'burst', 'hit', 'zap', 'thud', 'counter', 'game_over', 'level_done', 'whoosh', ]; public const SFX_NAMES = [ 'shoot' => 'shoot', 'rise' => 'rise', 'click' => 'click', 'enemy_explode' => 'enemy_explode', 'player_death' => 'player_death', 'pickup' => 'pickup', 'blip' => 'blip', 'burst' => 'burst', 'hit' => 'hit', 'zap' => 'zap', 'thud' => 'thud', 'counter' => 'counter', 'game_over' => 'game_over', 'level_done' => 'level_done', 'whoosh' => 'whoosh', ]; /** @var int[] firmware ink numbers of the 16 pens of the playfield */ public array $scenePalette = []; /** @var int[] hardware colours of the 4 pens of the text area */ public array $textPalette = []; public int $borderColour = 0; /** @var array<int, int[]> */ public array $itemRules = []; /** @var array<int, int[]> five {room, y, x} */ public array $startPlaces = []; /** @var array<int, int[]> rooms whose cauldrons are exchanged for each start place */ public array $startSwapRooms = []; /** @var array<int, int[]> {y, x} of the four rewards */ public array $rewardSpots = []; /** @var array<int, int[]> {dy, dx} of the 8 spell directions */ public array $boltVelocity = []; /** @var array<int, int[]> */ public array $doorBlock = []; /** @var int[] */ public array $stampPlain = []; /** @var int[] item ids that have a name */ public array $keyItems = []; /** @var array<int, int[]> the 8 magic items shown in room 0x1C */ public array $bossRoomItems = []; /** @var array<int, array{0: string, 1: int}> the default hall of fame (today) */ public array $hallToday = []; /** @var array<int, array{0: string, 1: int}> the default hall of fame (all-time) */ public array $hallAllTime = []; /** @var string[] */ public array $forbiddenNames = []; /** @var string[] */ public array $replacementNames = []; /** @var string[] */ public array $scrollMessages = []; /** @var array<int, int[]> the two demo scripts */ public array $demoScripts = []; /** @var int[] y deltas of the two figures of the final scene */ public array $victoryPath = []; /** @var array<int, int[]> the 10x4 pixel twinkle on the cauldrons (pens) */ public array $sparkle = []; /** @var string[] 30 phrases of the texts */ public array $phrases = []; /** @var string[] */ public array $itemNamesA = []; /** @var string[] */ public array $itemNamesB = []; /** @var string[] the room titles */ public array $titles = []; /** @var RoomDef[] the 79 rooms of the original quest */ public array $rooms = []; /** @var Level[] [0] = the original quest, then the packs */ public array $levels = []; private static ?Assets $instance = null; /** @var array<string, array<int, array<int, int>>> picture colours per pixel for the dissolve */ private array $picturePens = []; public function __construct(private GameConfig $config) { } /** Loads once (the service is shared, but be safe). */ public static function get(GameConfig $config): self { if (self::$instance === null) { self::$instance = new self($config); self::$instance->load(); } return self::$instance; } public function load(): void { $this->rooms = []; for ($n = 0; $n < Values::ROOM_COUNT; $n++) { $text = $this->readFile('assets/rooms/room' . sprintf('%02d', $n) . '.txt'); $err = null; $room = RoomText::parse($text, $err); if ($room === null) { throw new \RuntimeException("assets/rooms/room$n.txt: " . ($err[0] ?? 'bad room')); } $this->rooms[$n] = $room; } $code = $this->readBin('assets/data/CODE.bin'); $at = fn (int $a): int => $a >= 0x7000 && $a - 0x7000 < strlen($code) ? ord($code[$a - 0x7000]) : 0; for ($i = 0; $i < 16; $i++) { $this->scenePalette[$i] = $at(0x92F8 + $i); } for ($i = 0; $i < 4; $i++) { $this->textPalette[$i] = $at(0x9370 + $i) & 0x1F; } $this->borderColour = $at(0x936F) & 0x1F; for ($a = 0x9442; $at($a) !== Values::NONE; $a += 6) { $this->itemRules[] = [$at($a), $at($a + 1), $at($a + 2), $at($a + 3), $at($a + 4), $at($a + 5)]; } for ($i = 0; $i < 5; $i++) { $this->startPlaces[$i] = [$at(0x9668 + 3 * $i), $at(0x9669 + 3 * $i), $at(0x966A + 3 * $i)]; $this->startSwapRooms[$i] = [$at(0x9677 + 2 * $i), $at(0x9678 + 2 * $i)]; } for ($i = 0; $i < 4; $i++) { $this->rewardSpots[$i] = [$at(0x945C + 2 * $i), $at(0x945D + 2 * $i)]; } for ($i = 0; $i < 8; $i++) { $this->boltVelocity[$i] = [$this->s8($at(0x9466 + 4 * $i)), $this->s8($at(0x9467 + 4 * $i))]; } for ($i = 0; $i < 9; $i++) { $this->stampPlain[$i] = $at(0x9312 + $i); $this->doorBlock[0][$i] = $at(0x9315 + $i); $this->doorBlock[1][$i] = $at(0x9318 + $i); $this->doorBlock[2][$i] = $at(0x9321 + $i); } for ($i = 0; $i < 18; $i++) { $this->keyItems[$i] = $at(0x9CA4 + $i); } for ($i = 0; $i < 8; $i++) { for ($j = 0; $j < 8; $j++) { $this->bossRoomItems[$i][$j] = $at(0x9627 + 8 * $i + $j); } } // hall of fame: 7 entries of {21 characters padded with '.', 3 packed BCD bytes} $readHall = function (string $buf): array { $t = []; for ($i = 0; $i < 7; $i++) { $nm = rtrim(substr($buf, 24 * $i, 21), '.'); $score = 0; for ($k = 0; $k < 3; $k++) { $b = ord($buf[24 * $i + 21 + $k]); $score = $score * 100 + ($b >> 4) * 10 + ($b & 15); } $t[] = [$nm, $score]; } return $t; }; $buf = ''; for ($i = 0; $i < 168; $i++) { $buf .= chr($at(0x9522 + $i)); } $this->hallToday = $readHall($buf); $hf = $this->readBin('assets/data/halloffame.bin'); $this->hallAllTime = strlen($hf) === 168 ? $readHall($hf) : $this->hallToday; $a = 0x9EE0; $this->forbiddenNames = []; while ($at($a) !== 0) { $n = $at($a++); $s = ''; for ($i = 0; $i < $n; $i++) { $s .= chr($at($a++)); } $this->forbiddenNames[] = $s; } $this->replacementNames = $this->strings($at, 0x9FEC, count($this->forbiddenNames) + 1); // title scroller $this->scrollMessages = []; $m = $this->readBin('assets/data/SMESSC.bin'); for ($i = 0; $i + 1 < strlen($m); $i += 2) { $off = ord($m[$i]) | ord($m[$i + 1]) << 8; if ($off === 0xFFFF || $off >= strlen($m)) { break; } $s = ''; for ($p = $off; $p < strlen($m) && $m[$p] !== "\0"; $p++) { $s .= $m[$p]; } $this->scrollMessages[] = $s; } $this->demoScripts = []; foreach ([[0x99B4, 0x9A0F], [0x9A0F, 0x9A44]] as $range) { $sc = []; for ($a = $range[0]; $a < $range[1]; $a++) { $sc[] = $at($a); } $this->demoScripts[] = $sc; } $this->victoryPath = []; for ($a = 0x9E7C; $at($a) !== 0x80 && $a < 0x9F00; $a++) { $this->victoryPath[] = $at($a); } $this->victoryPath[] = 0x80; $this->sparkle = array_fill(0, 4, array_fill(0, 10, 0)); for ($r = 0; $r < 4; $r++) { for ($b = 0; $b < 5; $b++) { $v = $at(0x9333 + $r * 5 + $b); $this->sparkle[$r][$b * 2] = ($v >> 7 & 1) | ($v >> 3 & 1) << 1 | ($v >> 5 & 1) << 2 | ($v >> 1 & 1) << 3; $this->sparkle[$r][$b * 2 + 1] = ($v >> 6 & 1) | ($v >> 2 & 1) << 1 | ($v >> 4 & 1) << 2 | ($v & 1) << 3; } } for ($i = 0; $i < 4; $i++) { $this->picturePalette[$i] = $at(0x930C + $i); } $this->phrases = $this->strings($at, 0x9A44, 30); $this->itemNamesA = $this->strings($at, 0x9AC8, 19); $this->itemNamesB = $this->strings($at, 0x9BB9, 19); $this->titles = $this->strings($at, 0x9CB7, 80); $this->loadLevels(); } /** @var int[] */ public array $picturePalette = []; private function s8(int $v): int { return $v >= 0x80 ? $v - 256 : $v; } /** `count` strings in a row, the last character of each one has bit 7 set. */ private function strings(callable $at, int $a, int $count): array { $out = []; for ($i = 0; $i < $count; $i++) { $s = ''; for (;;) { $b = $at($a++); $s .= chr($b & 0x7F); if ($b & 0x80) { break; } } $out[] = $s; } return $out; } // ---- texts ---- /** Texts contain codes 0-0x1D that stand for the phrase number code+1. */ public function expand(string $s): string { $out = ''; foreach (str_split($s) as $ch) { $c = ord($ch); if ($c >= 0x20) { $out .= $ch; } elseif ($c + 1 < count($this->phrases)) { $out .= $this->expand($this->phrases[$c + 1]); } } return $out; } public function roomTitle(int $textNumber): string { $i = $textNumber + 1; return $i < count($this->titles) ? $this->expand($this->titles[$i]) : ''; } public function itemName(int $item, bool $secondQuest): string { for ($i = 0; $i < 18; $i++) { if ($this->keyItems[$i] === $item) { $names = $secondQuest ? $this->itemNamesB : $this->itemNamesA; return $this->expand($names[$i + 1]); } } return ''; } // ---- level packs ---- private function loadLevels(): void { $this->levels = []; $o = new Level(); $o->key = 'original'; $o->name = 'SORCERY+'; $o->description = ['LA QUETE ORIGINALE: 79 SALLES']; $o->original = true; $o->rooms = $this->rooms; $o->extras = $this->parseExtras($this->readFile('assets/levels/original_extras.txt')); $this->levels[] = $o; $idx = $this->readFile('assets/levels/index.txt'); foreach (preg_split('/\r?\n/', $idx) as $id) { $id = trim($id); if ($id === '' || $id[0] === '#') { continue; } $base = 'assets/levels/' . $id; $meta = $this->readFile($base . '/level.txt'); if ($meta === '') { continue; } $L = new Level(); $L->key = $id; $L->name = $id; foreach (preg_split('/\r?\n/', $meta) as $line) { $line = rtrim($line, "\r"); $sp = strpos($line, ' '); if ($line === '' || $line[0] === '#' || $sp === false) { continue; } $key = substr($line, 0, $sp); $val = substr($line, $sp + 1); if ($key === 'name') { $L->name = $val; } elseif ($key === 'description') { $L->description[] = $val; } elseif ($key === 'start') { $parts = preg_split('/\s+/', trim($val), -1, PREG_SPLIT_NO_EMPTY) ?: []; $L->startRoom = $this->num($parts[0] ?? '0'); $L->startY = $this->num($parts[1] ?? '0'); $L->startX = $this->num($parts[2] ?? '0'); } elseif ($key === 'goal') { $L->goal = $this->num($val); } elseif ($key === 'dark') { $L->dark = $this->num($val) !== 0; } elseif ($key === 'sky') { $parts = preg_split('/\s+/', trim($val), -1, PREG_SPLIT_NO_EMPTY) ?: []; $L->sky = array_map(fn (string $s): int => $this->num($s), $parts); $L->hasSky = count($L->sky) === 6; } elseif ($key === 'stars') { $L->stars = $this->num($val) !== 0; } } for ($n = 0; $n < Values::ROOM_COUNT; $n++) { $t = $this->readFile($base . '/rooms/room' . sprintf('%02d', $n) . '.txt'); if ($t === '') { break; } $err = null; $room = RoomText::parse($t, $err); if ($room === null) { throw new \RuntimeException("$base/rooms/room$n.txt: " . ($err[0] ?? 'bad room')); } $L->rooms[$n] = $room; } $L->extras = $this->parseExtras($this->readFile($base . '/extras.txt')); ksort($L->rooms); if ($L->rooms !== []) { $this->levels[] = $L; } } } /** decimal, or hex with 0x (the original's sscanf %i) */ private function num(string $s): int { $s = trim($s); if (str_starts_with(strtolower($s), '0x')) { return (int)hexdec(substr($s, 2)); } return (int)$s; } /** extras.txt: lantern ROOM Y X / teleport ROOM Y X TOROOM TOY TOX */ private function parseExtras(string $text): array { $out = []; foreach (preg_split('/\r?\n/', $text) as $line) { $h = strpos($line, '#'); if ($h !== false) { $line = substr($line, 0, $h); } $t = preg_split('/\s+/', trim($line), -1, PREG_SPLIT_NO_EMPTY); if ($t === false || $t === []) { continue; } $v = array_map(fn (string $s): int => $this->num($s), array_slice($t, 1)); if (count($t) >= 4 && $t[0] === 'lantern') { $out[] = new Extra(Extra::LANTERN, $v[0], $v[1], $v[2], 0, 0, 0); } elseif (count($t) >= 7 && $t[0] === 'teleport') { $out[] = new Extra(Extra::TELEPORTER, $v[0], $v[1], $v[2], $v[3], $v[4], $v[5]); } } return $out; } // ---- files / colours / picture ---- /** The absolute path of a file of the game folder. */ public function path(string $rel): string { return $this->config->path($rel); } private function readFile(string $rel): string { $p = $this->config->path($rel); return is_file($p) ? (string)file_get_contents($p) : ''; } private function readBin(string $rel): string { $p = $this->config->path($rel); if (!is_file($p)) { throw new \RuntimeException("missing $rel"); } return (string)file_get_contents($p); } /** firmware ink 0..26: 3 levels per channel */ public static function inkRgb(int $ink): array { $lv = [0, 128, 255]; $ink %= 27; return [$lv[$ink % 3], $lv[(int)($ink / 9)], $lv[(int)($ink / 3) % 3]]; } public static function hardwareRgb(int $hw): array { $ink2hw = [0x14, 0x04, 0x15, 0x1C, 0x18, 0x1D, 0x0C, 0x05, 0x0D, 0x16, 0x06, 0x17, 0x1E, 0x00, 0x1F, 0x0E, 0x07, 0x0F, 0x12, 0x02, 0x13, 0x1A, 0x19, 0x1B, 0x0A, 0x03, 0x0B]; $hw &= 0x1F; if ($hw === 0x01) { $hw = 0x00; } elseif ($hw === 0x08) { $hw = 0x05; } elseif ($hw === 0x09) { $hw = 0x03; } elseif ($hw === 0x10) { $hw = 0x04; } elseif ($hw === 0x11) { $hw = 0x02; } for ($i = 0; $i < 27; $i++) { if ($ink2hw[$i] === $hw) { return self::inkRgb($i); } } return [0, 0, 0]; } /** The colours of the Strangeloop picture per 4-pixel byte (for the dissolve of the attract mode). */ public function picturePens(): array { if ($this->picturePens !== []) { return $this->picturePens; } $path = $this->config->path('assets/pictures/title.png'); $im = imagecreatefrompng($path); if ($im === false) { throw new \RuntimeException('bad assets/pictures/title.png'); } $pal = []; foreach ($this->picturePalette as $i) { $pal[$i] = self::inkRgb($i); } $pens = array_fill(0, 200 * 320, 0); for ($y = 0; $y < 200; $y++) { for ($x = 0; $x < 320; $x++) { $c = imagecolorat($im, $x, $y); $pens[$y * 320 + $x] = $c & 0xFF; } } imagedestroy($im); // the PNG colours are the picture palette inks in order; recover the pen index by colour $byColor = []; for ($i = 0; $i < 4; $i++) { $c = self::inkRgb($this->picturePalette[$i]); $byColor[$c[0] << 16 | $c[1] << 8 | $c[2]] = $i; } $out = array_fill(0, 200 * 320, 0); for ($i = 0; $i < 200 * 320; $i++) { $c = $pens[$i]; $out[$i] = $byColor[$c] ?? 0; } return $this->picturePens = $out; }}