diff --git a/.plans/README.md b/.plans/README.md index aad8732..3a66d16 100644 --- a/.plans/README.md +++ b/.plans/README.md @@ -5,6 +5,7 @@ menus, cheats, saves and music, including validation and compatibility limits. - [macOS runtime](macos-runtime.md): relocatable SDL libraries and clean-machine archive checks. - [Release automation](release-automation.md): Make targets, native CI and version-tag releases. +- [Elephant cheat](elephant-cheat.md): embedded trunk art and elephant projectiles. The completed gameplay specifications moved from Elephc when DOOM became an independent project. Historical compiler and Code Journal references belong to Elephc. diff --git a/.plans/elephant-cheat.md b/.plans/elephant-cheat.md new file mode 100644 index 0000000..7a934af --- /dev/null +++ b/.plans/elephant-cheat.md @@ -0,0 +1,43 @@ +# Elephant cheat + +- [x] Add embedded palette-matched rear-head, elephant and impact patches. +- [x] Implement IDELEPHC as a reversible pistol mode with real projectiles. +- [x] Preserve mode and in-flight projectiles in saves and across maps. +- [x] Test firing, collisions, rendering, resets and legacy saves. +- [x] Inspect real-WAD frames and run the native CI matrix. + +The pistol slot keeps its bullet ammo and cadence. Other weapons keep their +behavior. Custom art is generated into memory, using the WAD's palette and the +existing sprite clipping, lighting and projectile collision paths. The WAD file +on disk is never modified. Save fingerprints cover only the original WAD bytes; +new sprite/type records are appended so existing save indices stay stable. + +## Local validation + +The initial implementation passed 98 gameplay tests (832 assertions), including +10 elephant regressions, and the 16 release-tooling tests with elephc 0.27.6. + +[Native CI run 40700243717](https://github.com/getelephc/doom/actions/runs/40700243717) +passed on Linux x86_64, Linux ARM64 and macOS ARM64 at +`6d84db9f99b195c9c4e16af4c0f70b299b81573a`. The macOS archive also passed its +startup and SDL initialization check after removing the Homebrew SDL kegs. + +## Visual revision + +- [x] Show a small rear-view elephant head low in the view, with broad flapping ears. +- [x] Add an elephant HUD portrait while IDELEPHC is active. +- [x] Cover glances, five health bands, firing, damage, pickups, god mode and death. +- [x] Check portrait bounds, toggle restoration, saves and real E1M1 frames. + +The portrait patches are appended after the original custom weapon/projectile +patches and use names excluded from the sprite-frame catalog. This keeps existing +saved projectile definitions stable. Expressions use the player's existing state; +no new save fields or random stream are needed. Five new HUD regressions cover +the expression priorities, health bands, frame bounds, save restoration and the +weapon's low silhouette. Real E1M1 idle/firing frames and a portrait sheet were +inspected. The two elephant test classes pass locally (15 tests, 93 assertions), +and the native SDL executable was rebuilt with this revision. + +The weapon shows only the back of a rounded head and its ears. Its existing lump +names and frame order are preserved so saved sprite references remain stable. +The placement regression checks both ears and leaves the upper view clear. diff --git a/README.md b/README.md index 04a5667..fd93927 100644 --- a/README.md +++ b/README.md @@ -172,6 +172,18 @@ Type cheat codes during play, without a console, then release movement keys as n | `IDCLEVxy` | Warp to episode x, mission y, if present in the WAD | | `IDMUSxy` | Select an available episode/mission music track | | `IDMYPOS` | Toggle position display | +| `IDELEPHC` | Toggle elephant mode in pistol slot 2; fires red/orange elephant projectiles | + +`IDELEPHC` selects the pistol slot and shows an elephant's head from behind, +low in the view, with ears that flap when firing. The status-bar portrait becomes +an elephant that glances around and reacts to health, damage, firing, pickups, +invulnerability and death. The +elephants travel through the level, hit monsters and stop at walls, using the +pistol's bullets and firing cadence. Type the code again to restore the pistol +and the marine portrait. +Other weapons keep their behavior. The mode survives map changes and saves; +starting a new game or restarting after death resets it. Its pixel art is built +into the executable and uses the loaded WAD's palette, with no extra asset files. Saves live in `saves/slot0.json` through `slot5.json`, relative to the launch directory. They preserve player inventory and powers, enemies/projectiles, map changes, movers, diff --git a/src/App/SaveGame.php b/src/App/SaveGame.php index 919742f..11dcbce 100644 --- a/src/App/SaveGame.php +++ b/src/App/SaveGame.php @@ -11,7 +11,7 @@ public function __construct() { $this->records = new SaveRecords(); } public function fingerprint(Session $s): string { if ($this->wadHash === "") { - $this->wadHash = hash("sha256", ptr_read_string(buffer_ptr($s->wad->data), $s->wad->size)); + $this->wadHash = hash("sha256", ptr_read_string(buffer_ptr($s->wad->data), $s->wad->sourceSize)); } return $this->wadHash; } @@ -31,7 +31,8 @@ public function encode(Session $s): string { for ($i = 0; $i < $s->things->count * 4; $i++) { $frames[] = $s->things->frameDefinition[$i]; } $c = $s->combat; $data = ["version" => 1, "wad" => $this->fingerprint($s), "map" => $s->mapName, "skill" => $s->config->skill, - "time" => $s->levelTime, "player" => $this->records->readPlayer($s->player), "objects" => $objects, + "time" => $s->levelTime, "elephant" => $s->player->elephantMode, + "player" => $this->records->readPlayer($s->player), "objects" => $objects, "sectors" => $sectors, "sides" => $sides, "lines" => $lines, "movers" => $movers, "lights" => $lights, "buttons" => $buttons, "secrets" => $secrets, "frames" => $frames, "combat" => [$c->seed, $c->kills, $c->totalKills, $c->items, $c->totalItems, $c->secrets, $c->totalSecrets, (int)$c->bossTriggered, $s->specials->seed, $c->clock]]; @@ -81,6 +82,7 @@ public function decode(Session $s, string $json): bool { $decoded = json_decode($json, true); if (!is_array($decoded)) { return false; } array $d = (array)$decoded; + if (array_key_exists("elephant", $d) && !is_bool($d["elephant"])) { return false; } if (($d["version"] ?? 0) !== 1 || ($d["wad"] ?? "") !== $this->fingerprint($s) || !is_string($d["map"] ?? null)) { return false; } string $mapName = (string)$d["map"]; if (strlen($mapName) !== 4 || $mapName[0] !== "E" || $mapName[2] !== "M" || $mapName[1] < "1" || $mapName[1] > "4" || $mapName[3] < "1" || $mapName[3] > "9") { return false; } @@ -151,6 +153,7 @@ public function references(Session $s, array $d, int $sectors, int $sides): bool public function restore(Session $s, array $d): void { $this->records->writePlayer($s->player, (array)$d["player"]); + $s->player->elephantMode = (bool)($d["elephant"] ?? false); $s->player->availableWeapons = $s->renderer->weapons->available; $s->levelTime = (int)$d["time"]; array $rows = (array)$d["objects"]; diff --git a/src/App/Session.php b/src/App/Session.php index 5c6ca3b..9a249c6 100644 --- a/src/App/Session.php +++ b/src/App/Session.php @@ -5,6 +5,7 @@ use Showcases\Doom\Bsp\BspWalker; use Showcases\Doom\Graphics\Flats; use Showcases\Doom\Graphics\Palette; +use Showcases\Doom\Graphics\ElephantArt; use Showcases\Doom\Graphics\Sprites; use Showcases\Doom\Graphics\Textures; use Showcases\Doom\Map\MapData; @@ -99,7 +100,13 @@ public function boot(): bool { return false; } $this->say("Loaded WAD: " . $this->wad->kind . " | lumps: " . $this->wad->lumpCount); - if (!$this->palette->load($this->wad) || !$this->textures->load($this->wad) || !$this->flats->load($this->wad) || !$this->sprites->load($this->wad)) { + if (!$this->palette->load($this->wad)) { + $this->say("The WAD lacks PLAYPAL, textures, flats or sprites"); + return false; + } + ElephantArt $art = new ElephantArt(); + $art->install($this->wad, $this->palette); + if (!$this->textures->load($this->wad) || !$this->flats->load($this->wad) || !$this->sprites->load($this->wad)) { $this->say("The WAD lacks PLAYPAL, textures, flats or sprites"); return false; } diff --git a/src/Graphics/ElephantArt.php b/src/Graphics/ElephantArt.php new file mode 100644 index 0000000..d138b6e --- /dev/null +++ b/src/Graphics/ElephantArt.php @@ -0,0 +1,162 @@ +colors[] = $this->nearest($palette, (int)$rgb); } + $names = []; $pictures = []; + for ($frame = 0; $frame < 3; $frame++) { + // Preserve the existing lump names and frame order for saved indices. + $names[] = "ELTG" . chr(65 + $frame) . "0"; + int $recoil = $frame === 1 ? 3 : ($frame === 2 ? 1 : 0); + $pictures[] = $this->patch($this->rearHead($frame), 88, 64, -116, -110 - $recoil); + } + for ($frame = 0; $frame < 5; $frame++) { + $names[] = "ELPH" . chr(65 + $frame) . "0"; + $pictures[] = $this->patch($this->elephant($frame), 32, 24, 16, 12); + } + // Portraits follow the existing projectile frames so save indices stay stable. + ElephantFaces $faces = new ElephantFaces(); + for ($slot = 0; $slot < ElephantFaces::COUNT; $slot++) { + $names[] = ElephantFaces::name($slot); + $pictures[] = $this->patch($faces->picture($slot), 32, 32, 0, 0); + } + $wad->appendLumps($names, $pictures); + } + + public function nearest(Palette $palette, int $rgb): int { + int $best = 0; int $distance = 2147483647; + for ($i = 0; $i < 256; $i++) { + int $color = $palette->argb[$i]; + int $r = (($color >> 16) & 255) - (($rgb >> 16) & 255); + int $g = (($color >> 8) & 255) - (($rgb >> 8) & 255); + int $b = ($color & 255) - ($rgb & 255); + int $d = $r * $r + $g * $g + $b * $b; + if ($d < $distance) { $best = $i; $distance = $d; } + } + return $best; + } + + public function rearHead(int $frame): array { + $rows = []; + for ($y = 0; $y < 64; $y++) { + string $row = ""; + for ($x = 0; $x < 88; $x++) { + string $ink = "."; + // Broad, scalloped ears frame the back of the head. On firing, + // they spread slightly while the entire head recoils downward. + int $flap = $frame === 1 ? 2 : ($frame === 2 ? 1 : 0); + int $side = $x < 44 ? $x : 87 - $x; + int $ex = $side - 19 + $flap; + int $ey = $y - 31; + int $ear = $ex * $ex * 484 + $ey * $ey * 289; + bool $notch = $side < 10 && $y > 42; + if ($ear <= 139876 && !$notch) { + $ink = "0"; + if ($ear < 118000) { + $ink = $ey < -8 ? "3" : "2"; + if ($side > 25) { $ink = "1"; } + if ($ey < -15 && $side > 12 && $side < 24) { $ink = "4"; } + int $fold = 35 + intdiv(abs($ex), 2); + if ($y === $fold && $side < 26) { $ink = "1"; } + } + } + // A rounded crown and nape are all that the rear view exposes. + int $hx = $x - 44; int $hy = $y - 42; + int $head = $hx * $hx * 676 + $hy * $hy * 625; + if ($head <= 422500) { + $ink = "0"; + if ($head < 377000) { + $ink = $hx < -12 || $hy > 12 ? "2" : "3"; + int $shineX = $hx - 7; int $shineY = $hy + 10; + int $shine = $shineX * $shineX * 196 + $shineY * $shineY * 144; + if ($shine < 28224) { $ink = "4"; } + if ($shine < 3500 && $shineY < 0) { $ink = "5"; } + } + } + $row .= $ink; + } + $rows[] = $row; + } + return $rows; + } + + public function elephant(int $frame): array { + $rows = []; + for ($y = 0; $y < 24; $y++) { + string $row = ""; + for ($x = 0; $x < 32; $x++) { + string $ink = "."; + if ($frame >= 2) { + int $dx = abs($x - 16); int $dy = abs($y - 12); + int $radius = 4 + ($frame - 2) * 3; + int $distance = $dx + $dy; + if ($distance < $radius && $distance >= max(0, $radius - 5)) { + $ink = $distance < $radius - 2 ? "5" : "3"; + } + if ($dx === $dy && $dx >= $radius - 2 && $dx <= $radius) { $ink = "2"; } + } else { + // Tail and alternating feet, then body, head and large ear. + if ($x >= 2 && $x <= 4 && $y >= 9 && $y <= 15) { $ink = "1"; } + int $step = $frame * 2; + if (($x >= 7 && $x <= 11 && $y >= 15 && $y <= 22 - $step) + || ($x >= 16 && $x <= 20 && $y >= 15 && $y <= 20 + $step)) { $ink = "2"; } + int $bx = $x - 13; int $by = $y - 12; + int $body = $bx * $bx * 49 + $by * $by * 100; + if ($body <= 4900) { $ink = $body > 3650 ? "0" : ($y < 10 ? "4" : "3"); } + int $hx = $x - 24; int $hy = $y - 10; + int $head = $hx * $hx + $hy * $hy; + if ($head <= 36) { $ink = $head > 26 ? "0" : ($y < 8 ? "5" : "3"); } + int $ex = $x - 20; int $ey = $y - 11 - $frame; + int $ear = $ex * $ex * 36 + $ey * $ey * 16; + if ($ear <= 576) { $ink = $ear > 360 ? "1" : ($x < 20 ? "2" : "3"); } + if ($x >= 27 && $x <= 30 && $y >= 11 && $y <= 20) { $ink = $x === 27 || $x === 30 ? "1" : "4"; } + if ($x >= 25 && $x <= 29 && $y >= 19 && $y <= 21) { $ink = $y === 21 ? "1" : "4"; } + if ($x >= 25 && $x <= 28 && $y === 14) { $ink = "6"; } + if ($x === 25 && $y === 8) { $ink = "7"; } + } + $row .= $ink; + } + $rows[] = $row; + } + return $rows; + } + + // Encode column posts so built-in art uses the existing masked-patch paths. + public function patch(array $rows, int $width, int $height, int $left, int $top): string { + string $table = ""; string $posts = ""; + for ($x = 0; $x < $width; $x++) { + int $offset = 8 + $width * 4 + strlen($posts); + $table .= $this->dword($offset); + int $y = 0; + while ($y < $height) { + string $row = (string)$rows[$y]; + if ($row[$x] === ".") { $y++; continue; } + int $start = $y; + string $pixels = ""; + while ($y < $height) { + $row = (string)$rows[$y]; + if ($row[$x] === ".") { break; } + int $shade = ord($row[$x]) - 48; + $pixels .= chr((int)$this->colors[$shade]); + $y++; + } + $posts .= chr($start) . chr($y - $start) . chr(0) . $pixels . chr(0); + } + $posts .= chr(255); + } + return $this->word($width) . $this->word($height) . $this->word($left) . $this->word($top) . $table . $posts; + } + + public function word(int $value): string { return chr($value & 255) . chr(($value >> 8) & 255); } + public function dword(int $value): string { return $this->word($value) . $this->word($value >> 16); } +} diff --git a/src/Graphics/ElephantFaces.php b/src/Graphics/ElephantFaces.php new file mode 100644 index 0000000..f343c46 --- /dev/null +++ b/src/Graphics/ElephantFaces.php @@ -0,0 +1,109 @@ +pixel($x, $y, $pain, $expression, $look); + } + $rows[] = $row; + } + return $rows; + } + + public function pixel(int $x, int $y, int $pain, int $expression, int $look): string { + string $ink = "."; + int $droop = intdiv($pain, 2); + if ($expression === 1) { $droop = -1; } + if ($expression === 5) { $droop = 3; } + int $earY = $y - 15 - $droop; + int $earX = $x < 16 ? $x - 6 : $x - 25; + int $ear = $earX * $earX * 81 + $earY * $earY * 36; + if ($ear <= 2916) { + $ink = "0"; + if ($ear < 2300) { $ink = $earY < -3 ? "3" : "1"; } + if ($ear < 1300) { $ink = "2"; } + } + int $headX = $x - 16; + int $headY = $y - 13 - ($expression === 5 ? 3 : 0); + int $head = $headX * $headX * 144 + $headY * $headY * 81; + if ($head <= 11664) { + $ink = $head > 9800 ? "0" : ($x < 12 ? "2" : ($x > 21 ? "3" : "4")); + if ($headY < -5 && abs($headX) < 4) { $ink = "5"; } + if ($pain >= 1 && $x === 23 && $y >= 14 && $y <= 16) { $ink = "1"; } + if ($pain >= 2 && $x >= 10 && $x <= 13 && $y >= 15 && $y <= 17) { $ink = "1"; } + if ($pain >= 4 && $x >= 18 && $x <= 23 && $y === 7) { $ink = "6"; } + } + // A downturned, curling trunk and two small ivory tusks frame the mouth. + if ($y >= 15 && $y <= 29) { + int $curl = $y > 23 ? intdiv(($y - 23) * ($y - 23), 8) : 0; + int $center = 16 + $curl; + int $radius = $y < 20 ? 4 : 3; + int $distance = abs($x - $center); + if ($distance <= $radius) { + $ink = $distance === $radius ? "1" : ($x < $center ? "3" : "4"); + if ($y === 20 || $y === 24) { $ink = $x < $center ? "1" : "2"; } + } + if ($y >= 27 && $y <= 28 && ($x === $center - 1 || $x === $center + 1)) { $ink = "7"; } + } + if ($y >= 19 && $y <= 23) { + int $tip = intdiv($y - 19, 2); + if ($x === 11 - $tip || $x === 21 + $tip) { $ink = "6"; } + if ($y < 22 && ($x === 12 - $tip || $x === 20 + $tip)) { $ink = "5"; } + } + if ($expression === 3 && $y >= 19 && $y <= 21 && (($x >= 9 && $x <= 11) || ($x >= 21 && $x <= 23))) { + $ink = $y === 21 ? "0" : "6"; + } + // Eyes track left/center/right. Damage opens them, firing lowers the + // brows, pickups squint happily, god mode wears shades, death has X eyes. + for ($eye = 0; $eye < 2; $eye++) { + int $cx = $eye === 0 ? 11 : 21; + int $dx = $x - $cx; + int $ey = $y - 11; + if ($expression === 5) { + $ey = $y - 15; + if (abs($dx) <= 2 && abs($dx) === abs($ey)) { $ink = "7"; } + } else if ($expression === 4) { + if (abs($dx) <= 4 && $ey >= -2 && $ey <= 2) { $ink = $ey === -2 ? "5" : "7"; } + if (abs($dx) <= 2 && $ey === -1) { $ink = "3"; } + if ($x >= 15 && $x <= 17 && $y === 10) { $ink = "7"; } + } else if ($expression === 3) { + if (abs($dx) <= 2 && $ey === -1 + intdiv(abs($dx), 2)) { $ink = "7"; } + } else { + int $height = $expression === 1 ? 2 : 1; + if (abs($dx) <= 2 && abs($ey) <= $height) { $ink = "6"; } + if ($dx === $look && abs($ey) <= 1) { $ink = "7"; } + int $brow = -3; + if ($expression === 2) { $brow = -3 + ($eye === 0 ? intdiv($dx + 2, 2) : intdiv(2 - $dx, 2)); } + else if ($pain >= 3) { $brow = -2; } + if (abs($dx) <= 3 && $ey === $brow) { $ink = "0"; } + } + } + return $ink; + } +} diff --git a/src/Graphics/Sprites.php b/src/Graphics/Sprites.php index 78bb288..e2d0327 100644 --- a/src/Graphics/Sprites.php +++ b/src/Graphics/Sprites.php @@ -32,7 +32,7 @@ public function load(Wad $wad): bool { if ($start < 0 || $end <= $start) { return false; } - int $capacity = $end - $start; + int $capacity = $end - $start + $wad->lumpCount - $wad->sourceLumpCount; int $rotationSlots = $capacity * 8; buffer $oldSpriteKey = $this->spriteKey; buffer_free($oldSpriteKey); @@ -49,36 +49,47 @@ public function load(Wad $wad): bool { $this->rotated = buffer_new($capacity); $this->lump = buffer_new($rotationSlots); $this->flip = buffer_new($rotationSlots); + $this->count = 0; for ($slot = 0; $slot < $rotationSlots; $slot++) { $this->lump[$slot] = -1; } int $firstLump = $start + 1; for ($lump = $firstLump; $lump < $end; $lump++) { - int $key = $wad->lumpKey[$lump]; - int $sprite = $key & 4294967295; - int $frameShift = $key >> 32; - int $frameLetter = $frameShift & 255; - int $rotationShift = $key >> 40; - int $rotationDigit = $rotationShift & 255; - if ($frameLetter < 65 || $rotationDigit < 48 || $rotationDigit > 56) { - continue; - } - int $frameIndex = $frameLetter - 65; - int $rotation = $rotationDigit - 48; - $this->assign($sprite, $frameIndex, $rotation, $lump, 0); + $this->loadLump($wad, $lump); + } + bool $hasSprites = $this->count > 0; + // Built-in pictures live after the original directory, without changing + // the WAD file or the indices of its original sprite definitions. + for ($lump = $wad->sourceLumpCount; $lump < $wad->lumpCount; $lump++) { + $this->loadLump($wad, $lump); + } + return $hasSprites; + } - int $mirrorFrameShift = $key >> 48; - int $mirrorFrame = $mirrorFrameShift & 255; - int $mirrorRotationShift = $key >> 56; - int $mirrorRotationDigit = $mirrorRotationShift & 255; - if ($mirrorFrame >= 65 && $mirrorRotationDigit >= 48 && $mirrorRotationDigit <= 56) { - int $mirrorIndex = $mirrorFrame - 65; - int $mirrorRotation = $mirrorRotationDigit - 48; - $this->assign($sprite, $mirrorIndex, $mirrorRotation, $lump, 1); - } + public function loadLump(Wad $wad, int $lump): void { + int $key = $wad->lumpKey[$lump]; + int $sprite = $key & 4294967295; + int $frameShift = $key >> 32; + int $frameLetter = $frameShift & 255; + int $rotationShift = $key >> 40; + int $rotationDigit = $rotationShift & 255; + if ($frameLetter < 65 || $rotationDigit < 48 || $rotationDigit > 56) { + return; + } + int $frameIndex = $frameLetter - 65; + int $rotation = $rotationDigit - 48; + $this->assign($sprite, $frameIndex, $rotation, $lump, 0); + + int $mirrorFrameShift = $key >> 48; + int $mirrorFrame = $mirrorFrameShift & 255; + int $mirrorRotationShift = $key >> 56; + int $mirrorRotationDigit = $mirrorRotationShift & 255; + if ($mirrorFrame >= 65 && $mirrorRotationDigit >= 48 && $mirrorRotationDigit <= 56) { + int $mirrorIndex = $mirrorFrame - 65; + int $mirrorRotation = $mirrorRotationDigit - 48; + $this->assign($sprite, $mirrorIndex, $mirrorRotation, $lump, 1); } - return $this->count > 0; } public function assign(int $sprite, int $frameIndex, int $rotation, int $lump, int $flip): void { diff --git a/src/Play/Cheats.php b/src/Play/Cheats.php index 5c738db..c220f9e 100644 --- a/src/Play/Cheats.php +++ b/src/Play/Cheats.php @@ -24,7 +24,15 @@ public function feed(Session $s, string $text): void { public function apply(Session $s): void { Player $p = $s->player; string $message = ""; - if (str_ends_with($this->tail, "iddqd")) { + if (str_ends_with($this->tail, "idelephc")) { + $p->elephantMode = !$p->elephantMode; + $message = "ELEPHC MODE OFF"; + if ($p->elephantMode) { + $p->weapons |= 4; + $p->pendingWeapon = 2; + $message = "ELEPHC MODE ON"; + } + } else if (str_ends_with($this->tail, "iddqd")) { $p->godMode = !$p->godMode; $message = "GOD MODE OFF"; if ($p->godMode) { diff --git a/src/Play/Player.php b/src/Play/Player.php index 0b19953..efc120c 100644 --- a/src/Play/Player.php +++ b/src/Play/Player.php @@ -59,6 +59,7 @@ final class Player { public int $selectionHeld; public int $sawClock; public bool $fireHeld; + public bool $elephantMode = false; public function __construct() { $this->x = 0; @@ -98,6 +99,7 @@ public function reset(): void { $this->maxCells = 300; $this->backpack = false; $this->godMode = false; + $this->elephantMode = false; $this->noclip = false; $this->berserk = false; $this->fullMap = false; diff --git a/src/Play/Projectiles.php b/src/Play/Projectiles.php index ccbba7f..023866b 100644 --- a/src/Play/Projectiles.php +++ b/src/Play/Projectiles.php @@ -29,6 +29,7 @@ public function launch(Combat $c, int $source, int $x, int $y, int $z, int $tx, else if ($type === 6004) { $speed = 25; $damage = 100; } else if ($type === 6005) { $speed = 15; $damage = 8; } else if ($type === 6006) { $damage = 5; } + else if ($type === 6009) { $speed = 16; $damage = 3; } $m[$slot]->active = 1; $m[$slot]->type = $type; $m[$slot]->kind = $kind; diff --git a/src/Play/ThingTypes.php b/src/Play/ThingTypes.php index 23f66c9..89a4884 100644 --- a/src/Play/ThingTypes.php +++ b/src/Play/ThingTypes.php @@ -128,6 +128,8 @@ public function __construct() { [62, "GOR5", "A", 1, 0, 0, 20, 52, 0, 1], [63, "GOR1", "A", 3, 10, 0, 20, 68, 0, 1], ]; + // Append custom kinds so old savegame kind indices remain valid. + $rows[] = [6009, "ELPH", "A", 2, 4, 1, 6, 8, 0, 0]; int $count = count($rows); $this->count = $count; $this->type = buffer_new($count); diff --git a/src/Play/Weapons.php b/src/Play/Weapons.php index 14892d4..2b50966 100644 --- a/src/Play/Weapons.php +++ b/src/Play/Weapons.php @@ -52,6 +52,11 @@ public function tick(Player $p, Combat $c, Input $input, ptr $keys): void { $c->sounds->play($hit >= 0 ? Sounds::SAW_HIT : Sounds::SAW_FULL); } else if ($hit >= 0) { $c->sounds->play(Sounds::PUNCH); } if ($hit >= 0) { $c->noise(); } + } else if ($w === 2 && $p->elephantMode) { + $p->bullets--; + $p->weaponCooldown = 14; + $this->launch($p, $c, 6009); + $c->sounds->play(Sounds::PISTOL); } else if ($w === 2 || $w === 4) { $p->bullets--; $p->weaponCooldown = $w === 2 ? 14 : 4; diff --git a/src/Render/Hud.php b/src/Render/Hud.php index d1da252..3f7b383 100644 --- a/src/Render/Hud.php +++ b/src/Render/Hud.php @@ -2,6 +2,7 @@ namespace Showcases\Doom\Render; +use Showcases\Doom\Graphics\ElephantFaces; use Showcases\Doom\Play\Player; use Showcases\Doom\Wad\Wad; @@ -9,6 +10,7 @@ // the big red STTNUM digits for ammo, health and armor, the STARMS weapon panel, // the marine's face (STFST, by pain level and glance direction), key cards, ammo // tallies in small STYSNUM digits, and STCFN font text for messages and the FPS. +// IDELEPHC selects built-in elephant portraits with state-driven expressions. final class Hud { public Wad $wad; public Screen $screen; @@ -22,6 +24,7 @@ final class Hud { public buffer $grayDigit; public buffer $keyCard; public buffer $face; + public buffer $elephantFace; public buffer $font; public int $bigDigitWidth; public int $faceLook; @@ -49,6 +52,10 @@ public function __construct(Wad $wad, Screen $screen, int $top) { $this->keyCard[$k] = $wad->find("STKEYS" . $k); } $this->face = buffer_new(15); + $this->elephantFace = buffer_new(ElephantFaces::COUNT); + for ($f = 0; $f < ElephantFaces::COUNT; $f++) { + $this->elephantFace[$f] = $wad->find(ElephantFaces::name($f)); + } for ($pain = 0; $pain < 5; $pain++) { for ($look = 0; $look < 3; $look++) { int $row = $pain * 3; @@ -158,6 +165,7 @@ public function drawStatusBar(Player $player): void { } public function faceLump(Player $player): int { + if ($player->elephantMode) { return $this->elephantFaceLump($player); } if ($player->health <= 0) { return $this->faceDead; } if ($player->godMode || $player->invulnerable > 0) { return $this->wad->find("STFGOD0"); } int $health = $player->health; @@ -172,6 +180,17 @@ public function faceLump(Player $player): int { return $this->face[$slot]; } + public function elephantFaceLump(Player $player): int { + if ($player->health <= 0) { return $this->elephantFace[ElephantFaces::DEAD]; } + if ($player->godMode || $player->invulnerable > 0) { return $this->elephantFace[ElephantFaces::GOD]; } + int $pain = intdiv((100 - min(100, $player->health)) * 5, 101); + int $slot = $pain * 3 + $this->faceLook; + if ($player->damageCount > 0) { $slot = ElephantFaces::HURT + $pain; } + else if ($player->bonusCount > 0) { $slot = ElephantFaces::GRIN + $pain; } + else if ($player->fireHeld && $player->weaponCooldown > 0) { $slot = ElephantFaces::FIRE + $pain; } + return $this->elephantFace[$slot]; + } + // Right-aligned at $right, like DOOM's number widgets. public function bigNumber(int $value, int $right, int $y): void { int $remaining = $value; diff --git a/src/Render/WeaponRenderer.php b/src/Render/WeaponRenderer.php index ef8b042..5d5f2d3 100644 --- a/src/Render/WeaponRenderer.php +++ b/src/Render/WeaponRenderer.php @@ -12,9 +12,14 @@ final class WeaponRenderer { public Wad $wad; public buffer $patches; public int $available; + public buffer $elephantHead; public function __construct(Wad $wad) { $this->wad = $wad; + $this->elephantHead = buffer_new(3); + for ($f = 0; $f < 3; $f++) { + $this->elephantHead[$f] = $wad->find("ELTG" . chr(65 + $f) . "0"); + } $this->patches = buffer_new(90); $prefixes = ["", "PUNG", "PISG", "SHTG", "CHGG", "MISG", "PLSG", "BFGG", "SAWG"]; for ($weapon = 1; $weapon < 9; $weapon++) { @@ -87,6 +92,14 @@ public function draw(Screen $screen, Player $player, Trig $trig, int $tic): void int $bob = min(4, intdiv($speed, 3)); int $x = ($trig->cos($tic * 409) * $bob) >> 16; int $y = abs(($trig->sin($tic * 409) * $bob) >> 16); + if ($player->weapon === 2 && $player->elephantMode) { + int $headFrame = 0; + if ($player->weaponCooldown > 0 && $player->weaponAge < 9) { + $headFrame = $player->weaponAge < 5 ? 1 : 2; + } + $screen->drawPatch($this->wad, $this->elephantHead[$headFrame], $x, $y); + return; + } int $slot = $base + $frame; int $patch = $this->patches[$slot]; if ($patch < 0) { diff --git a/src/Wad/Wad.php b/src/Wad/Wad.php index 1cdf574..5e9bbae 100644 --- a/src/Wad/Wad.php +++ b/src/Wad/Wad.php @@ -8,6 +8,9 @@ final class Wad { public buffer $data; public int $size; + // Original file boundaries exclude generated art from save fingerprints. + public int $sourceSize = 0; + public int $sourceLumpCount = 0; public string $kind; public int $lumpCount; public buffer $lumpOffset; @@ -42,6 +45,7 @@ public function load(string $path): bool { $this->data = buffer_new($size); ptr_write_string(buffer_ptr($this->data), $bytes); $this->size = $size; + $this->sourceSize = $size; int $magic = $this->keyAt(0); int $kindKey = $magic & 4294967295; $this->kind = $this->nameFromKey($kindKey); @@ -57,6 +61,7 @@ public function load(string $path): bool { return false; } $this->lumpCount = $count; + $this->sourceLumpCount = $count; buffer $oldOffset = $this->lumpOffset; buffer_free($oldOffset); buffer $oldSize = $this->lumpSize; @@ -78,6 +83,44 @@ public function load(string $path): bool { return true; } + // Append built-in pictures before any renderer caches the WAD's buffers. + // Existing lump indices and the original file prefix remain unchanged. + public function appendLumps(array $names, array $pictures): void { + string $bytes = ptr_read_string(buffer_ptr($this->data), $this->size); + int $count = $this->lumpCount + count($names); + buffer $offsets = buffer_new($count); + buffer $sizes = buffer_new($count); + buffer $keys = buffer_new($count); + for ($i = 0; $i < $this->lumpCount; $i++) { + $offsets[$i] = $this->lumpOffset[$i]; + $sizes[$i] = $this->lumpSize[$i]; + $keys[$i] = $this->lumpKey[$i]; + } + for ($i = 0; $i < count($names); $i++) { + int $slot = $this->lumpCount + $i; + string $picture = (string)$pictures[$i]; + $offsets[$slot] = strlen($bytes); + $sizes[$slot] = strlen($picture); + $keys[$slot] = $this->key((string)$names[$i]); + $bytes .= $picture; + } + buffer $oldData = $this->data; + buffer $oldOffsets = $this->lumpOffset; + buffer $oldSizes = $this->lumpSize; + buffer $oldKeys = $this->lumpKey; + $this->size = strlen($bytes); + $this->data = buffer_new($this->size); + ptr_write_string(buffer_ptr($this->data), $bytes); + $this->lumpOffset = $offsets; + $this->lumpSize = $sizes; + $this->lumpKey = $keys; + $this->lumpCount = $count; + buffer_free($oldData); + buffer_free($oldOffsets); + buffer_free($oldSizes); + buffer_free($oldKeys); + } + public function u8(int $offset): int { return $this->data[$offset]; } diff --git a/src/engine.php b/src/engine.php index 0f86365..e26c33c 100644 --- a/src/engine.php +++ b/src/engine.php @@ -26,6 +26,8 @@ require_once 'Graphics/Animations.php'; require_once 'Graphics/Palette.php'; +require_once 'Graphics/ElephantFaces.php'; +require_once 'Graphics/ElephantArt.php'; require_once 'Graphics/Textures.php'; require_once 'Graphics/Flats.php'; require_once 'Graphics/Sprites.php'; diff --git a/tests/ElephantCheatTest.php b/tests/ElephantCheatTest.php new file mode 100644 index 0000000..61a2bb7 --- /dev/null +++ b/tests/ElephantCheatTest.php @@ -0,0 +1,191 @@ +boot(); + $s->player->weapons |= 8; + $s->player->weapon = 3; + $s->cheats->feed($s, "IdE"); + $this->assertFalse($s->player->elephantMode); + $s->cheats->feed($s, "lePhC"); + $this->assertTrue($s->player->elephantMode); + $this->advance($s, 1); + $this->assertSameInt(2, $s->player->weapon); + $s->cheats->feed($s, "IDELEPHC"); + $this->assertFalse($s->player->elephantMode); + $this->assertSameString("", $s->cheats->tail); + } + + public function testElephantTravelsBeforeDamagingItsTarget(): void { + $s = $this->boot(); + $this->isolate($s, 3); + $s->things->mobjs[3]->x = 192; + $s->things->mobjs[3]->y = 128; + $s->things->mobjs[3]->health = 1000; + $s->cheats->feed($s, "idelephc"); + $this->fire($s); + $this->assertSameInt(49, $s->player->bullets); + $this->assertSameInt(14, $s->player->weaponCooldown); + $this->assertSameInt(1000, $s->things->mobjs[3]->health); + $this->assertSameInt(6009, $s->things->mobjs[0]->type); + $this->assertSameInt(6, $s->things->mobjs[0]->state); + $this->assertSameInt(0, $s->things->mobjs[0]->splashDamage); + $s->combat->projectiles->tick($s->combat, 0); + $this->assertTrue($s->things->mobjs[0]->x > $s->player->unitX()); + for ($i = 0; $i < 10; $i++) { $s->combat->projectiles->tick($s->combat, 0); } + $this->assertTrue($s->things->mobjs[3]->health < 1000); + $this->assertSameInt(7, $s->things->mobjs[0]->state); + $this->assertSameInt(100, $s->player->health); + for ($i = 0; $i < 15; $i++) { $s->combat->projectiles->tick($s->combat, 0); } + $this->assertSameInt(0, $s->things->mobjs[0]->active); + } + + public function testClosedDoorsStopElephantsAndProtectMonstersBehindThem(): void { + $s = $this->boot(); + $this->isolate($s, 4); + $s->things->mobjs[4]->x = 384; + $s->things->mobjs[4]->y = 128; + $s->things->mobjs[4]->health = 1000; + $s->map->sectors[1]->ceiling_height = 0; + $s->cheats->feed($s, "idelephc"); + $this->fire($s); + for ($i = 0; $i < 14; $i++) { $s->combat->projectiles->tick($s->combat, 0); } + $this->assertSameInt(1000, $s->things->mobjs[4]->health); + $this->assertSameInt(7, $s->things->mobjs[0]->state); + $this->assertTrue($s->things->mobjs[0]->x <= 256); + } + + public function testDisablingTheCheatRestoresHitscanWithoutRemovingFlyingElephants(): void { + $s = $this->boot(); + $this->isolate($s, 3); + $s->things->mobjs[3]->x = 192; + $s->things->mobjs[3]->y = 128; + $s->things->mobjs[3]->health = 1000; + $s->cheats->feed($s, "idelephc"); + $this->fire($s); + $s->cheats->feed($s, "idelephc"); + $this->assertSameInt(1, $s->things->mobjs[0]->active); + $s->player->weaponCooldown = 0; + $s->player->fireHeld = false; + $this->fire($s); + $this->assertTrue($s->things->mobjs[3]->health < 1000); + $this->assertSameInt(48, $s->player->bullets); + $this->assertSameInt(6, $s->things->mobjs[0]->state); + } + + public function testElephantHeadAnimatesAndOtherWeaponsKeepTheirArt(): void { + $s = $this->boot(); + string $pistol = $this->weaponFrame($s); + $s->cheats->feed($s, "idelephc"); + string $idle = $this->weaponFrame($s); + $this->assertFalse($idle === $pistol); + $s->player->weaponCooldown = 14; + string $firing = $this->weaponFrame($s); + $this->assertFalse($idle === $firing); + $s->player->weaponAge = 6; + $this->assertFalse($firing === $this->weaponFrame($s)); + $s->player->weapon = 3; + string $shotgun = $this->weaponFrame($s); + $s->player->elephantMode = false; + $this->assertSameString($shotgun, $this->weaponFrame($s)); + } + + public function testHeldFireKeepsPistolCadenceAndEmptyAmmoFallsBack(): void { + $s = $this->boot(); + $this->isolate($s, -1); + $s->cheats->feed($s, "idelephc"); + $this->advance($s, 15, 224); + $this->assertSameInt(48, $s->player->bullets); + $s->player->bullets = 0; + $s->player->weaponCooldown = 0; + $this->fire($s); + $this->assertSameInt(1, $s->player->weapon); + $this->assertSameInt(0, $s->player->bullets); + } + + public function testElephantSpritesAreVisibleAndClippedByWalls(): void { + $s = $this->boot(); + $this->isolate($s, -1); + $s->renderer->render($s->map, $s->things, $s->player, 0, ""); + string $empty = $this->frame($s); + $s->combat->projectiles->launch($s->combat, -1, 192, 128, 48, 240, 128, 48, 6009); + $s->renderer->render($s->map, $s->things, $s->player, 0, ""); + $this->assertFalse($empty === $this->frame($s)); + $s->map->sectors[1]->ceiling_height = 0; + $s->things->mobjs[0]->active = 0; + $s->renderer->render($s->map, $s->things, $s->player, 0, ""); + string $closed = $this->frame($s); + $s->things->mobjs[0]->active = 1; + $s->things->mobjs[0]->x = 384; + $s->things->mobjs[0]->sector = 1; + $s->renderer->render($s->map, $s->things, $s->player, 0, ""); + $this->assertSameString($closed, $this->frame($s)); + } + + public function testSavesPreserveTheModeAndProjectilesWithoutChangingTheWad(): void { + $s = $this->boot(); + $this->isolate($s, -1); + string $original = (string)file_get_contents($this->wadPath); + $this->assertSameString(hash("sha256", $original), $s->saves->fingerprint($s)); + $s->cheats->feed($s, "idelephc"); + $this->fire($s); + string $snapshot = $s->saves->encode($s); + $s->player->elephantMode = false; + $this->assertTrue($s->saves->decode($s, $snapshot)); + $this->assertTrue($s->player->elephantMode); + $this->assertSameInt(6009, $s->things->mobjs[0]->type); + int $before = $s->things->mobjs[0]->x; + $s->combat->projectiles->tick($s->combat, 0); + $this->assertTrue($s->things->mobjs[0]->x > $before); + $this->assertSameString($original, (string)file_get_contents($this->wadPath)); + } + + public function testLegacySavesDisableTheModeAndInvalidFlagsAreRejected(): void { + $s = $this->boot(); + string $snapshot = $s->saves->encode($s); + $s->cheats->feed($s, "idelephc"); + $this->assertFalse($s->saves->decode($s, str_replace('"elephant":false', '"elephant":1', $snapshot))); + $this->assertFalse($s->saves->decode($s, str_replace('"elephant":false', '"elephant":null', $snapshot))); + $this->assertFalse($s->saves->decode($s, str_replace('"elephant":false', '"elephant":"true"', $snapshot))); + $this->assertTrue($s->player->elephantMode); + $this->assertTrue($s->saves->decode($s, str_replace('"elephant":false,', "", $snapshot))); + $this->assertFalse($s->player->elephantMode); + } + + public function testMapChangesKeepTheModeAndFreshPlayersResetIt(): void { + $s = $this->boot(); + $s->cheats->feed($s, "idelephc"); + $this->assertTrue($s->loadMap("E1M2")); + $this->assertTrue($s->player->elephantMode); + $s->player->reset(); + $this->assertFalse($s->player->elephantMode); + } + + protected function fire(Session $s): void { + buffer $keys = buffer_new(512); + $keys[224] = 1; + $s->weapons->tick($s->player, $s->combat, new Input(), buffer_ptr($keys)); + buffer_free($keys); + } + + protected function weaponFrame(Session $s): string { + $s->renderer->screen->fill(254); + $s->renderer->weapons->draw($s->renderer->screen, $s->player, $s->trig, 0); + return $this->frame($s); + } + + protected function frame(Session $s): string { + return hash("sha256", ptr_read_string(buffer_ptr($s->renderer->screen->pixels), 64000)); + } +} diff --git a/tests/ElephantHudTest.php b/tests/ElephantHudTest.php new file mode 100644 index 0000000..5acf55f --- /dev/null +++ b/tests/ElephantHudTest.php @@ -0,0 +1,148 @@ +add("STBAR", $wad->patch(320, 32, 0, 0, 40, 0)); + for ($pain = 0; $pain < 5; $pain++) { + for ($look = 0; $look < 3; $look++) { + $wad->add("STFST" . $pain . $look, $wad->patch(24, 30, 0, 0, 100 + $pain * 3 + $look, 0)); + } + } + $wad->add("STFGOD0", $wad->patch(24, 30, 0, 0, 206, 0)); + $wad->add("STFDEAD0", $wad->patch(24, 30, 0, 0, 207, 0)); + $this->wadPath = $wad->write(); + Config $config = new Config(); + $config->wadPath = $this->wadPath; + $config->verbose = false; + Session $s = new Session($config); + $this->assertTrue($s->boot()); + return $s; + } + + public function testElephantReplacesOnlyThePortraitAndTogglesBackToTheMarine(): void { + $s = $this->boot(); + string $classic = $this->status($s); + int $marine = $s->renderer->hud->faceLump($s->player); + $s->cheats->feed($s, "idelephc"); + string $elephant = $this->status($s); + $this->assertFalse($classic === $elephant); + int $outside = 0; + for ($i = 0; $i < 64000; $i++) { + int $x = $i % 320; int $y = intdiv($i, 320); + if (($x < 143 || $x >= 175 || $y < 168) && $classic[$i] !== $elephant[$i]) { $outside++; } + } + $this->assertSameInt(0, $outside); + $s->cheats->feed($s, "idelephc"); + $this->assertSameInt($marine, $s->renderer->hud->faceLump($s->player)); + $this->assertSameString($classic, $this->status($s)); + } + + public function testGlancesAndAllFiveHealthBandsHaveDistinctPortraits(): void { + $s = $this->boot(); + $s->cheats->feed($s, "idelephc"); + $s->renderer->hud->tick(17, 0); + string $left = $this->portrait($s); + $s->renderer->hud->tick(34, 2); + string $right = $this->portrait($s); + $this->assertFalse($left === $right); + $s->renderer->hud->tick(51, 1); + $this->assertFalse($left === $this->portrait($s)); + $this->assertFalse($right === $this->portrait($s)); + $health = [100, 75, 50, 25, 1]; + $seen = []; + foreach ($health as $value) { + $s->player->health = (int)$value; + string $face = $this->portrait($s); + $this->assertFalse(in_array($face, $seen, true)); + $seen[] = $face; + } + } + + public function testPortraitReactsToFireDamagePickupsGodModeAndDeath(): void { + $s = $this->boot(); + $s->cheats->feed($s, "idelephc"); + string $idle = $this->portrait($s); + $s->player->fireHeld = true; $s->player->weaponCooldown = 14; + string $firing = $this->portrait($s); + $this->assertFalse($idle === $firing); + $s->player->damageCount = 8; + string $hurt = $this->portrait($s); + $this->assertFalse($hurt === $firing); + $s->player->bonusCount = 8; + $this->assertSameString($hurt, $this->portrait($s)); + $s->player->damageCount = 0; + string $grin = $this->portrait($s); + $this->assertFalse($grin === $hurt); + $this->assertFalse($grin === $idle); + $s->player->godMode = true; + string $god = $this->portrait($s); + $this->assertFalse($god === $grin); + $s->player->godMode = false; $s->player->invulnerable = 10; + $this->assertSameString($god, $this->portrait($s)); + $s->player->health = 0; + $this->assertFalse($god === $this->portrait($s)); + $s->player->elephantMode = false; + $this->assertSameInt($s->wad->find("STFDEAD0"), $s->renderer->hud->faceLump($s->player)); + } + + public function testSavesRestoreTheElephantExpressionWithoutExtraState(): void { + $s = $this->boot(); + $s->cheats->feed($s, "idelephc"); + $s->player->health = 25; + $s->player->damageCount = 8; + string $hurt = $this->portrait($s); + string $snapshot = $s->saves->encode($s); + $s->player->reset(); + $this->assertTrue($s->saves->decode($s, $snapshot)); + $this->assertSameString($hurt, $this->portrait($s)); + } + + public function testRearHeadStaysLowAndHasTwoWideEars(): void { + $s = $this->boot(); + $s->cheats->feed($s, "idelephc"); + $screen = $s->renderer->screen; + $screen->fill(254); + $s->renderer->weapons->draw($screen, $s->player, $s->trig, 0); + int $left = 320; int $right = 0; int $top = 200; int $bottom = 0; + for ($i = 0; $i < 64000; $i++) { + if ($screen->pixels[$i] === 254) { continue; } + int $x = $i % 320; int $y = intdiv($i, 320); + $left = min($left, $x); $right = max($right, $x); + $top = min($top, $y); $bottom = max($bottom, $y); + } + $this->assertTrue($top >= 115); + $this->assertTrue($right - $left > $bottom - $top); + $this->assertTrue($right - $left >= 80); + // The ears flank a centered head, with an open gap above each shoulder. + $this->assertTrue($left < 130 && $right > 190); + $this->assertTrue($screen->pixels[140 * 320 + 132] !== 254); + $this->assertTrue($screen->pixels[140 * 320 + 188] !== 254); + $this->assertTrue($screen->pixels[118 * 320 + 132] === 254); + $this->assertTrue($screen->pixels[118 * 320 + 188] === 254); + } + + protected function status(Session $s): string { + $s->renderer->screen->fill(254); + $s->renderer->hud->drawStatusBar($s->player); + return ptr_read_string(buffer_ptr($s->renderer->screen->pixels), 64000); + } + + protected function portrait(Session $s): string { + string $screen = $this->status($s); + string $crop = ""; + for ($y = 168; $y < 200; $y++) { $crop .= substr($screen, $y * 320 + 143, 32); } + return hash("sha256", $crop); + } +}