Anmelden
DE
games/sorcery / src / Data / Assets.php · commit 613f5fb
<?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;    }}