diff --git a/.plans/README.md b/.plans/README.md index 3a66d16..9329fd5 100644 --- a/.plans/README.md +++ b/.plans/README.md @@ -5,7 +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. +- [Elephant cheat](elephant-cheat.md): embedded elephant art, food projectiles and sleeping enemies. 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 index 7a934af..1a97b38 100644 --- a/.plans/elephant-cheat.md +++ b/.plans/elephant-cheat.md @@ -41,3 +41,93 @@ 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. + +## Feeding mode + +- [x] Replace all monster families with walking and chewing elephant art. +- [x] Replace the slot 2 elephant projectiles with leafy carrots and food crumbs. +- [x] Animate full elephants tipping over and sleeping with breathing and `zZzZ`. +- [x] Settle full flying elephants onto the floor and suppress pain/death sounds. +- [x] Preserve drops, statistics, boss exits, Nightmare waking and reversible toggles. +- [x] Keep the original actor and sprite indices in saves, appending new art last. +- [x] Run feeding regressions and the complete suite, then inspect real-WAD frames. + +Feeding reuses the combat health budget as remaining appetite and the existing +pain/death states as chewing/flopping/sleeping. Only displayed monster frames are +substituted, so toggling the cheat restores original art immediately and legacy +saves retain their definitions. The existing ELPH frames now contain carrots and +crumbs. New ELEN frames are appended after all existing built-in patches. Sleep +letters are part of the actor patch and share its wall clipping and lighting. +Other weapons and difficulty rules retain their mechanics. The contact attack +revision below replaces monster ranged attacks while the mode is active. + +Local validation with elephc 0.27.6 passed all 108 gameplay tests (910 assertions), +including the five new feeding regressions, all 16 release-tooling tests and the +native SDL build. Real shareware E1M1 standing, chewing and sleeping frames were +rendered, and the standing/sleeping views and art sheet were visually inspected. +This revision has not been run through the remote native CI matrix. + +## Human feeder + +- [x] Replace the rear head with a human hand gripping a carrot. +- [x] Animate an empty-hand throwing pose and recovery with the next carrot. +- [x] Replace the player portrait with an original human wearing an unmarked cap. +- [x] Preserve expression priorities, health bands, portrait bounds and saved indices. +- [x] Validate the revised silhouettes, portrait expressions and native real-WAD frames. + +ELTG and ELFACE patch names and ordering stay stable, while their pixels now show +the human player. FeederFaces supplies all 32 portrait slots. The first-person +hand uses the existing three animation frames and keeps the upper view clear. +The palette adds skin, hair and clothing shades after the existing herd/food ramp. + +The revised gameplay suite passes locally with 109 tests and 913 assertions using +elephc 0.27.6. The native SDL executable was rebuilt, and real shareware E1M1 +ready, throwing and recovery views plus the human portrait/hand sheet were +rendered. The ready view and complete portrait/hand sheet were visually checked. + +## Contact attacks + +- [x] Make all elephant families chase and attack only at body contact. +- [x] Check body radii, vertical overlap and line of sight before dealing damage. +- [x] Cancel pending ranged windups and flying charges when the mode is active. +- [x] Retire hostile projectiles before direct or splash damage, preserving food. +- [x] Shorten the final chase step so large, fast actors can reach contact. +- [x] Validate all families, escaped targets, walls, heights, toggles and native build. + +Contact attacks deal 4 to 12 damage after a short windup, followed by a cooldown. +Nightmare retains its faster timing. Target positions are checked again when +the hit lands, allowing the player to escape. The original monster attacks return +when IDELEPHC is disabled, and the existing save schema is unchanged. + +Local validation passed all 115 gameplay tests (1001 assertions), including six +new contact regressions, all 16 tooling tests and the native SDL build with +elephc 0.27.6. Normal monster attacks and the existing feeding/portrait/save tests +remain covered by the same complete suite. + +## Occasional charges + +- [x] Give each chasing elephant a staggered chance to charge occasionally. +- [x] Show a 14-tic stationary warning with raised trunk art and a sound cue. +- [x] Lock the dash direction, sweep movement in two-unit steps, and stop on obstacles. +- [x] Deal one 16-point contact hit, then recover for three seconds. +- [x] Interrupt charges when fed, when full, or when the mode is disabled. +- [x] Reuse saved actor state, countdown, damage and velocity fields without a schema change. +- [x] Validate warning timing, dodges, collision, damage, all families and save continuation. +- [x] Inspect native warning/dash art and rebuild the SDL executable. + +Each eligible actor gets a 25% chance every 70 tics, staggered by object index, +when its target is visible, outside contact range and within 640 map units. +The windup lasts 14 tics; the locked dash lasts at most 24 tics at 24 units per +tic. There is no steering during the windup or dash. Walls, actors and map +openings use normal collision, while crossed monster specials still apply. +Food cancels the charge through the existing pain/fullness transition. + +State 8 with hitDamage 16 distinguishes the feeding-mode dash from legacy lost +soul charges. Existing saved velocity/countdown fields preserve both phases. +New warning pictures follow the original nine ELEN frames, preserving saved +sprite definitions. Original-mode lost soul charges retain their behavior. + +Local validation with elephc 0.27.6 passed all 122 gameplay tests (1092 assertions), +including seven charge regressions, all 16 tooling tests and the native SDL build. +Native real shareware E1M1 warning and dash frames and the complete herd art sheet +were rendered and visually inspected. No remote native CI run was requested. diff --git a/README.md b/README.md index fd93927..53a012b 100644 --- a/README.md +++ b/README.md @@ -172,17 +172,32 @@ 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 +| `IDELEPHC` | Toggle feeding mode: elephant enemies, flying carrots in slot 2, and sleepy full bellies | + +`IDELEPHC` turns every monster family into a red/orange elephant and selects +slot 2 to toss carrots. Food travels through the level and stops at walls, using +the pistol's bullets and firing cadence. Elephants chase you and cause damage +only at body contact, with no bullets or fireballs. Escaping before a contact +attack lands avoids the hit; walls and vertical gaps also block it. +Activating the mode retires hostile shots already in flight. +Occasionally, an elephant raises its trunk and pauses before charging in a +straight line. Sidestep during the warning to dodge it, or feed it to interrupt +the charge. Charges stop at walls, solid objects or the first contact hit, then +the elephant needs a short recovery before attacking again. +Elephants chew when fed, then flop onto +their side with an animated `zZzZ` when full. Flying elephants settle onto the +floor for their nap. Sleeping elephants stop attacking and blocking movement; +they still count toward level completion and unlock the original boss exits. +Nightmare's respawn rules still apply, waking elephants for another meal. + +You play as a human feeder wearing a plain cap. A hand holds a leafy carrot low +in the view, opens to toss it, and returns with the next carrot. The status-bar +portrait has short brown hair and a beard, glances around, and reacts to health, +damage, throwing food, pickups, invulnerability and death. Type the code again +to restore the original monsters, pistol and marine portrait. +Other weapons keep their behavior. The mode survives map changes, and saves +preserve the mode, food in flight and sleeping actors. Starting a new game or +restarting after death resets the mode. 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. @@ -254,6 +269,7 @@ src/ CombatTrace.php Wall openings, sight and hitscan targeting Weapons.php Weapon selection, ammo and firing cadence MonsterAI.php Sight, chase, attacks, pain, death and respawning + ElephantCharge.php Occasional feeding-mode charges, warning, collision and recovery MonsterRules.php Monster stats, animation frames and sound profiles Projectiles.php Swept projectiles, splash damage and BFG spray Data/ packed classes: map records, Mobj, Mover, LightEffect, Button diff --git a/src/Data/Mobj.php b/src/Data/Mobj.php index 86b8ea6..52fdc1a 100644 --- a/src/Data/Mobj.php +++ b/src/Data/Mobj.php @@ -22,7 +22,7 @@ public int $height; public int $solid; // Combat state: 0 idle, 1 chase, 2 attack windup, 3 pain, 4 dying, 5 corpse, - // 6 flying projectile, 7 exploding. Noncombat decorations keep health at 0. + // 6 flying projectile, 7 exploding, 8 charging. Noncombat decorations keep health at 0. public int $health; public int $state; public int $stateTics; @@ -36,7 +36,7 @@ public int $fixedX; public int $fixedY; public int $fixedZ; - public int $hitDamage; + public int $hitDamage; // Projectile damage, or the active elephant charge marker/damage. public int $splashDamage; public int $spawnX; public int $spawnY; diff --git a/src/Graphics/ElephantArt.php b/src/Graphics/ElephantArt.php index d138b6e..bf7bab4 100644 --- a/src/Graphics/ElephantArt.php +++ b/src/Graphics/ElephantArt.php @@ -11,25 +11,31 @@ final class ElephantArt { public array $colors = []; public function install(Wad $wad, Palette $palette): void { - $ramp = [0x391522, 0x80182D, 0xE52C46, 0xFF6928, 0xFFA22A, 0xFFDA69, 0xFFF1CB, 0x20131D]; + $ramp = [0x391522, 0x80182D, 0xE52C46, 0xFF6928, 0xFFA22A, 0xFFDA69, 0xFFF1CB, 0x20131D, 0x80C840, 0x28752B, + 0x57362E, 0xA56B50, 0xD89B73, 0xF2BE91, 0xFFE0B5, 0x76513C, 0x392B29, 0x253549, 0x475D77, 0x8EADC0, 0x335961]; foreach ($ramp as $rgb) { $this->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); + $pictures[] = $this->patch($this->hand($frame), 88, 80, -132, -94); } for ($frame = 0; $frame < 5; $frame++) { $names[] = "ELPH" . chr(65 + $frame) . "0"; - $pictures[] = $this->patch($this->elephant($frame), 32, 24, 16, 12); + $pictures[] = $this->patch($this->food($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); + FeederFaces $faces = new FeederFaces(); + for ($slot = 0; $slot < FeederFaces::COUNT; $slot++) { + $names[] = FeederFaces::name($slot); $pictures[] = $this->patch($faces->picture($slot), 32, 32, 0, 0); } + // Append herd frames last, preserving all existing saved definitions. + ElephantHerd $herd = new ElephantHerd(); + for ($frame = 0; $frame < 11; $frame++) { + $names[] = "ELEN" . chr(65 + $frame) . "0"; + $pictures[] = $this->patch($herd->picture($frame), 64, 72, 32, 72); + } $wad->appendLumps($names, $pictures); } @@ -46,51 +52,68 @@ public function nearest(Palette $palette, int $rgb): int { return $best; } - public function rearHead(int $frame): array { + public function hand(int $frame): array { $rows = []; - for ($y = 0; $y < 64; $y++) { + for ($y = 0; $y < 80; $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; + $row .= $this->handPixel($x, $y, $frame); } $rows[] = $row; } return $rows; } - public function elephant(int $frame): array { + public function handPixel(int $x, int $y, int $frame): string { + int $px = $x; int $py = $y; + if ($frame === 1) { $px = $x + 10; $py = $y + 9; } + else if ($frame === 2) { $px = $x - 8; $py = $y - 6; } + string $ink = "."; + // The right forearm runs into the lower edge of the view. + int $axis = 56 + intdiv(($px - 47) * 3, 5); + if ($px >= 43 && $px < 103 && abs($py - $axis) < 11) { + $ink = $py < $axis - 5 ? "=" : ($py > $axis + 6 ? ";" : "<"); + if ($px > 69) { $ink = $py < $axis - 4 ? "B" : "A"; } + if ($px === 69 || $px === 70) { $ink = "D"; } + } + int $hx = $px - 43; int $hy = $py - 52; + int $palm = $hx * $hx * 100 + $hy * $hy * 169; + if ($palm < 16900) { $ink = $palm > 13500 ? ";" : ($py < 51 ? "=" : "<"); } + // A leafy carrot sits behind the curled fingers in ready/recovery poses. + if ($frame !== 1) { + int $cx = 31 + intdiv(max(0, $py - 16), 9); + int $half = max(1, 7 - intdiv(max(0, $py - 16), 6)); + if ($py >= 16 && $py <= 55 && abs($px - $cx) <= $half) { + $ink = $px < $cx ? "3" : "4"; + if ($px === $cx + 3 && $py < 37) { $ink = "5"; } + if ($py % 7 === 0 && $px < $cx + 2) { $ink = "2"; } + } + if ($py >= 3 && $py <= 17 && (abs($px - 31) <= 1 || abs($px - 31) === intdiv(17 - $py, 2))) { + $ink = $px < 31 ? "9" : "8"; + } + // Four curled fingers overlap the carrot, with shaded separations. + if ($px >= 24 && $px <= 42 && $py >= 44 && $py <= 60) { + int $finger = intdiv($py - 44, 4); + int $edge = 24 + $finger; + if ($px >= $edge) { $ink = ($py - 44) % 4 === 3 ? ";" : ($px < 28 ? "<" : "="); } + } + int $tx = $px - 43; int $ty = $py - 42; + if ($tx * $tx * 49 + $ty * $ty * 25 < 1225) { $ink = $py < 41 ? ">" : "="; } + } else { + // Open fingers follow through after the carrot leaves the hand. + for ($finger = 0; $finger < 4; $finger++) { + int $fy = 39 + $finger * 5; + int $tip = 19 + abs($finger - 1) * 3; + if ($px >= $tip && $px <= 42 && $py >= $fy && $py <= $fy + 3) { + $ink = $py === $fy + 3 ? ";" : "="; + } + } + if ($px >= 43 && $px <= 48 && $py >= 29 && $py <= 47) { $ink = $px < 45 ? "<" : "="; } + } + return $ink; + } + + public function food(int $frame): array { $rows = []; for ($y = 0; $y < 24; $y++) { string $row = ""; @@ -98,31 +121,21 @@ public function elephant(int $frame): array { 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"; } + int $spread = 3 + ($frame - 2) * 4; + if (($dx === $spread || $dx === $spread - 1) && $dy < 3) { $ink = "4"; } + if (($dy === $spread || $dy === $spread - 1) && $dx < 3) { $ink = "8"; } } 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"; } + // A carrot with a leafy crown, bobbing in flight. + int $cy = $y - $frame; + int $width = intdiv(22 - $cy, 3); + if ($cy >= 7 && $cy <= 21 && abs($x - 16) <= $width) { + $ink = $x < 16 ? "3" : "4"; + if ($cy % 4 === 0 && $x < 17) { $ink = "2"; } + if ($x === 18 && $cy < 14) { $ink = "5"; } + } + if ($cy >= 1 && $cy <= 7 && (abs($x - 16) <= 1 || abs($x - 16) === 7 - $cy)) { + $ink = $x < 16 ? "9" : "8"; + } } $row .= $ink; } diff --git a/src/Graphics/ElephantFaces.php b/src/Graphics/ElephantFaces.php deleted file mode 100644 index f343c46..0000000 --- a/src/Graphics/ElephantFaces.php +++ /dev/null @@ -1,109 +0,0 @@ -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/ElephantHerd.php b/src/Graphics/ElephantHerd.php new file mode 100644 index 0000000..0c34d06 --- /dev/null +++ b/src/Graphics/ElephantHerd.php @@ -0,0 +1,96 @@ += 9) { $py = $y - 3; } + if ($frame >= 7 && $frame < 9) { $row .= $this->sleeping($x, $y, $frame); } + else { $row .= $this->standing($px, $py, $frame); } + } + $rows[] = $row; + } + return $rows; + } + + public function standing(int $x, int $y, int $frame): string { + string $ink = "."; + int $step = $frame < 4 ? ($frame & 1) * 2 : 0; + if ($frame >= 9) { $step = ($frame - 9) * 2; } + int $bx = $x - 32; int $by = $y - 47; + int $body = $bx * $bx * 361 + $by * $by * 400; + if ($body < 144400) { $ink = $body > 119000 ? "0" : ($x < 26 ? "2" : "3"); } + if (($x >= 18 && $x < 28 && $y >= 55 && $y < 71 - $step) + || ($x >= 36 && $x < 46 && $y >= 55 && $y < 69 + $step)) { + $ink = $y > 65 ? "1" : "3"; + if ($y > 67 && $x % 4 === 0) { $ink = "5"; } + } + int $side = $x < 32 ? $x : 63 - $x; + int $earX = $frame >= 9 ? 11 : 13; + int $ex = $side - $earX; int $ey = $y - 34; + int $flap = $frame === 1 || $frame === 3 ? 2 : 0; + int $ear = $ex * $ex * 289 + $ey * $ey * (100 + $flap * 12); + if ($ear < 28900) { $ink = $ear > 23000 ? "0" : ($side < 10 ? "2" : "4"); } + int $hx = $x - 32; int $hy = $y - 31; + int $head = $hx * $hx * 256 + $hy * $hy * 225; + if ($head < 57600) { $ink = $head > 47500 ? "0" : ($y < 28 ? "4" : "3"); } + if ($y >= 42 && $y <= 47 && (($x >= 21 && $x <= 25) || ($x >= 39 && $x <= 43))) { $ink = "6"; } + int $curl = $frame === 4 ? 3 : 0; + if ($frame >= 9) { $curl = $frame === 9 ? 8 : 5; } + if ($x >= 28 && $x <= 36 && $y >= 37 && $y <= 59 - $curl) { $ink = $x < 30 ? "1" : "4"; } + if ($x >= 34 && $x <= 42 && $y >= 54 - $curl && $y <= 59 - $curl) { $ink = $y > 57 - $curl ? "1" : "4"; } + if (($x >= 23 && $x <= 26 || $x >= 38 && $x <= 41) && $y >= 30 && $y <= 34) { + $ink = "7"; + if ($y === 30 && ($x === 24 || $x === 39)) { $ink = "6"; } + } + // Chewing is a happy, closed-eye expression, never a pain pose. + if ($frame === 4 && $y >= 30 && $y <= 34 && (($x >= 23 && $x <= 26) || ($x >= 38 && $x <= 41))) { + $ink = $y === 32 ? "7" : "3"; + } + if ($frame >= 9 && $y === 29 && (($x >= 22 && $x <= 27) || ($x >= 37 && $x <= 42))) { $ink = "0"; } + return $ink; + } + + public function sleeping(int $x, int $y, int $frame): string { + string $ink = "."; + int $breath = $frame - 7; + int $bx = $x - 28; int $by = $y - 57 + $breath; + int $body = $bx * $bx * 144 + $by * $by * 484; + if ($body < 69696) { $ink = $body > 56000 ? "0" : ($y < 55 ? "4" : "3"); } + if ($x >= 13 && $x <= 23 && $y >= 65 && $y <= 70) { $ink = "2"; } + int $hx = $x - 46; int $hy = $y - 58; + int $head = $hx * $hx + $hy * $hy; + if ($head < 121) { $ink = $head > 90 ? "0" : "3"; } + int $ex = $x - 38; int $ey = $y - 56; + int $ear = $ex * $ex + $ey * $ey; + if ($ear < 64) { $ink = $ear > 44 ? "1" : "4"; } + if ($x >= 51 && $x <= 61 && $y >= 62 && $y <= 67) { $ink = $y > 65 ? "1" : "4"; } + if ($x >= 47 && $x <= 52 && $y === 57) { $ink = "7"; } + if ($x >= 49 && $x <= 55 && $y === 61) { $ink = "6"; } + // Alternating small and large letters read zZzZ above the full belly. + for ($letter = 0; $letter < 4; $letter++) { + int $size = ($letter & 1) === 0 ? 4 : 6; + int $lx = $x - (8 + $letter * 12); + int $ly = $y - (30 - $letter * 5 - $breath * 2); + if ($lx >= 0 && $lx < $size && $ly >= 0 && $ly < $size) { + if ($ly === 0 || $ly === $size - 1 || $lx === $size - 1 - $ly) { $ink = "6"; } + } + } + return $ink; + } +} diff --git a/src/Graphics/FeederFaces.php b/src/Graphics/FeederFaces.php new file mode 100644 index 0000000..8bf5f07 --- /dev/null +++ b/src/Graphics/FeederFaces.php @@ -0,0 +1,102 @@ +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 = "."; + // Neck, shoulders, ears and a tapered human jaw. + if ($y >= 26 && $x >= 12 && $x <= 21) { $ink = $x < 16 ? ";" : "<"; } + if ($y >= 30 && $x >= 5 && $x <= 28) { $ink = $x < 17 ? "A" : "D"; } + if ($y >= 15 && $y <= 21 && (($x >= 5 && $x <= 8) || ($x >= 25 && $x <= 28))) { + $ink = $y === 18 ? ";" : "<"; + } + int $hx = $x - 16; int $hy = $y - 17; + int $head = $hx * $hx * 169 + $hy * $hy * 100; + if ($head < 16900) { + $ink = $x < 11 ? ";" : ($x > 21 ? "<" : "="); + if ($y >= 23) { $ink = ($x + $y) % 3 === 0 ? "?" : "<"; } + if ($y >= 27) { $ink = $x < 16 ? ":" : ";"; } + } + // Brown sideburns and a little side-swept hair under the cap. + if (($x >= 7 && $x <= 9 && $y >= 10 && $y <= 19) + || ($x >= 23 && $x <= 25 && $y >= 10 && $y <= 17)) { $ink = "?"; } + if ($x >= 9 && $x <= 17 && $y >= 9 && $y <= 12 && $x + $y < 26) { $ink = "?"; } + // Short moustache, nose, and smile. No lettering or logo on the cap. + if ($y >= 18 && $y <= 21 && $x >= 15 && $x <= 18) { $ink = $x === 15 ? "<" : ">"; } + if ($y === 22 && $x >= 12 && $x <= 21) { $ink = ":"; } + if ($x >= 12 && $x <= 21 && $y >= 23 && $y <= 25) { + $ink = $y === 24 ? "6" : ";"; + if ($expression === 1) { $ink = $x >= 15 && $x <= 18 ? "@" : "<"; } + else if ($expression === 2 || $pain >= 3) { $ink = $y === 24 ? ":" : "<"; } + else if ($expression === 3) { $ink = $y === 25 ? ":" : "6"; } + else if ($expression === 5) { $ink = $y === 25 ? ":" : "<"; } + } + for ($eye = 0; $eye < 2; $eye++) { + int $cx = $eye === 0 ? 12 : 21; + int $dx = $x - $cx; int $ey = $y - 16; + if ($expression === 5) { + if (abs($dx) <= 2 && abs($dx) === abs($ey)) { $ink = "@"; } + } else if ($expression === 4) { + if (abs($dx) <= 3 && abs($ey) <= 1) { $ink = "@"; } + if ($dx === -1 && $ey === -1) { $ink = "C"; } + if ($x >= 15 && $x <= 18 && $y === 16) { $ink = "@"; } + } else if ($expression === 3) { + if (abs($dx) <= 2 && $ey === -1 + intdiv(abs($dx), 2)) { $ink = "@"; } + } else { + int $height = $expression === 1 ? 2 : 1; + if (abs($dx) <= 2 && abs($ey) <= $height) { $ink = "6"; } + if ($dx === $look && abs($ey) <= 1) { $ink = "@"; } + int $brow = -3; + if ($expression === 2) { $brow = -3 + ($eye === 0 ? intdiv($dx + 2, 2) : intdiv(2 - $dx, 2)); } + if (abs($dx) <= 2 && $ey === $brow) { $ink = "?"; } + } + } + // Increasing tiredness uses cheek marks, leaving the cap readable. + if ($pain > 0 && $x === 11 && $y >= 20 && $y < 20 + $pain) { $ink = ";"; } + if ($pain > 1 && $x === 23 && $y === 20 + $pain) { $ink = ":"; } + int $capX = $x - 16; int $capY = $y - 9; + if ($y <= 10 && $capX * $capX * 49 + $capY * $capY * 121 < 5929) { + $ink = $x < 12 ? "A" : "B"; + if ($x === 16 && $y >= 3 && $y <= 7) { $ink = "C"; } + } + if ($y >= 9 && $y <= 11 && $x >= 7 && $x <= 29) { + $ink = $y === 11 ? "@" : "A"; + if ($y === 9 && $x > 19) { $ink = "B"; } + } + return $ink; + } +} diff --git a/src/Play/Cheats.php b/src/Play/Cheats.php index c220f9e..eadf20a 100644 --- a/src/Play/Cheats.php +++ b/src/Play/Cheats.php @@ -30,7 +30,7 @@ public function apply(Session $s): void { if ($p->elephantMode) { $p->weapons |= 4; $p->pendingWeapon = 2; - $message = "ELEPHC MODE ON"; + $message = "ELEPHC FEEDING TIME!"; } } else if (str_ends_with($this->tail, "iddqd")) { $p->godMode = !$p->godMode; diff --git a/src/Play/Combat.php b/src/Play/Combat.php index 813ab6c..b0b1a0e 100644 --- a/src/Play/Combat.php +++ b/src/Play/Combat.php @@ -18,6 +18,7 @@ final class Combat { public Trig $trig; public Sounds $sounds; public MonsterAI $monsters; + public ElephantCharge $elephants; public Projectiles $projectiles; public MonsterRules $rules; public int $skill = 3; @@ -45,6 +46,7 @@ public function __construct(Things $things, Player $player, Collision $collision $this->trig = $trig; $this->sounds = $sounds; $this->monsters = new MonsterAI(); + $this->elephants = new ElephantCharge(); $this->projectiles = new Projectiles(); $this->rules = new MonsterRules(); $this->seed = 1; @@ -218,6 +220,9 @@ public function hurt(int $index, int $damage, int $source): void { if ($index < 0 || $index >= $this->things->count || $mobjs[$index]->health <= 0) { return; } + if ($mobjs[$index]->state === 8 && $mobjs[$index]->hitDamage === ElephantCharge::DAMAGE) { + $this->elephants->stop($this, $index); + } int $remaining = $mobjs[$index]->health - $damage; $mobjs[$index]->health = $remaining; if ($remaining <= 0) { @@ -238,7 +243,7 @@ public function hurt(int $index, int $damage, int $source): void { int $firstDeath = $this->rules->deathFirst($mobjs[$index]->type); $this->frame($index, $firstDeath); int $sound = $this->rules->deathSound($mobjs[$index]->type); - $this->soundAt($sound, $mobjs[$index]->x, $mobjs[$index]->y); + if (!$this->player->elephantMode) { $this->soundAt($sound, $mobjs[$index]->x, $mobjs[$index]->y); } if ($mobjs[$index]->type === 3004) { $this->drop($index, 2007); } else if ($mobjs[$index]->type === 9) { @@ -254,7 +259,7 @@ public function hurt(int $index, int $damage, int $source): void { int $pain = $this->rules->pain($mobjs[$index]->type); $this->frame($index, $pain); int $sound = ($mobjs[$index]->type === 3004 || $mobjs[$index]->type === 9) ? Sounds::PAIN : Sounds::MONSTER_PAIN; - $this->soundAt($sound, $mobjs[$index]->x, $mobjs[$index]->y); + if (!$this->player->elephantMode) { $this->soundAt($sound, $mobjs[$index]->x, $mobjs[$index]->y); } } } diff --git a/src/Play/ElephantCharge.php b/src/Play/ElephantCharge.php new file mode 100644 index 0000000..af0909f --- /dev/null +++ b/src/Play/ElephantCharge.php @@ -0,0 +1,96 @@ +things->mobjs; + if (!$c->player->elephantMode || !$visible || $m[$i]->state !== 1 + || $m[$i]->target >= 0 || $m[$i]->health <= 0 || $m[$i]->type === 2035 + || $c->player->health <= 0 || $m[$i]->cooldown > 0) { return false; } + int $distance = max(abs($dx), abs($dy)); + int $near = $m[$i]->radius + 16 + 64; + if ($distance < $near || $distance > 640 || abs($m[$i]->z - $c->player->unitZ()) > 24) { return false; } + // One 25% chance every two seconds, staggered between actors. + if (($m[$i]->stateAge + $i * 11) % 70 !== 0 || $c->random() >= 64) { return false; } + int $length = max(1, (int)sqrt($dx * $dx + $dy * $dy)); + $m[$i]->velX = intdiv($dx * 24, $length); + $m[$i]->velY = intdiv($dy * 24, $length); + $m[$i]->velZ = 0; + $m[$i]->hitDamage = self::DAMAGE; + $m[$i]->state = 8; + $m[$i]->stateAge = 0; + $m[$i]->stateTics = self::WINDUP + self::RUN_TICS; + $m[$i]->cooldown = self::RECOVERY; + $m[$i]->angle = $c->monsters->angle($dx, $dy); + $c->frame($i, $c->rules->attackFrame($m[$i]->type)); + $c->soundAt(Sounds::DEMON_SIGHT, $m[$i]->x, $m[$i]->y); + return true; + } + + public function tick(Combat $c, int $i): void { + $m = $c->things->mobjs; + if (!$c->player->elephantMode || $c->player->health <= 0 || $m[$i]->health <= 0) { + $this->stop($c, $i); + return; + } + $m[$i]->stateAge++; + if ($m[$i]->stateTics > self::RUN_TICS) { + $m[$i]->stateTics--; + return; + } + if ($m[$i]->stateTics <= 0) { $this->stop($c, $i); return; } + if ($c->monsters->touchingTarget($c, $i)) { $this->hit($c, $i); return; } + int $startX = $m[$i]->x; int $startY = $m[$i]->y; + int $vx = $m[$i]->velX; int $vy = $m[$i]->velY; + if (abs($vx) > 24 || abs($vy) > 24 || ($vx === 0 && $vy === 0)) { $this->stop($c, $i); return; } + // Two-unit steps catch bodies and thin walls along the entire dash. + int $steps = max(1, intdiv(max(abs($vx), abs($vy)) + 1, 2)); + int $end = $steps + 1; + for ($step = 1; $step < $end; $step++) { + int $x = $startX + intdiv($vx * $step, $steps); + int $y = $startY + intdiv($vy * $step, $steps); + if (!$c->collision->checkActorPosition($x, $y, $m[$i]->z, $i, $m[$i]->radius, $m[$i]->height)) { + $this->stop($c, $i); + return; + } + int $oldX = $m[$i]->x; int $oldY = $m[$i]->y; + $m[$i]->x = $x; $m[$i]->y = $y; + $m[$i]->sector = $c->collision->sectorAt($x, $y); + if (!$c->rules->flying($m[$i]->type)) { $m[$i]->z = $c->collision->floorZ; } + for ($t = 0; $t < $c->collision->touchedCount; $t++) { + int $line = $c->collision->touched[$t]; + int $before = $c->collision->pointSide($line, $oldX, $oldY); + int $after = $c->collision->pointSide($line, $x, $y); + if ($before !== $after && $before !== 0) { $c->specials->monsterCross($line, $before, $i); } + if ($m[$i]->x !== $x || $m[$i]->y !== $y) { $this->stop($c, $i); return; } + } + if ($c->monsters->touchingTarget($c, $i)) { $this->hit($c, $i); return; } + } + $m[$i]->stateTics--; + if ($m[$i]->stateTics <= 0) { $this->stop($c, $i); } + } + + public function hit(Combat $c, int $i): void { + $c->monsters->damageTarget($c, $i, self::DAMAGE); + $this->stop($c, $i); + } + + public function stop(Combat $c, int $i): void { + $m = $c->things->mobjs; + $m[$i]->state = 1; $m[$i]->stateAge = 0; $m[$i]->stateTics = 0; + $m[$i]->velX = 0; $m[$i]->velY = 0; $m[$i]->velZ = 0; + $m[$i]->hitDamage = 0; + $m[$i]->cooldown = self::RECOVERY; + $c->frame($i, 0); + } +} diff --git a/src/Play/MonsterAI.php b/src/Play/MonsterAI.php index 8a02a05..14641f2 100644 --- a/src/Play/MonsterAI.php +++ b/src/Play/MonsterAI.php @@ -11,6 +11,11 @@ public function tick(Combat $combat, int $i): void { $mobjs = $combat->things->mobjs; int $type = $mobjs[$i]->type; int $state = $mobjs[$i]->state; + // A full flying elephant settles onto the floor for its nap. + if ($combat->player->elephantMode && ($state === 4 || $state === 5) && $combat->rules->flying($type)) { + int $floor = $combat->collision->map->sectors[$mobjs[$i]->sector]->floor_height; + $mobjs[$i]->z = max($floor, $mobjs[$i]->z - 4); + } if ($state === 5) { $mobjs[$i]->stateAge++; if ($combat->skill === 5 && $type !== 3006 && $mobjs[$i]->stateAge > 420 && ($mobjs[$i]->stateAge & 31) === 0 && $combat->random() < 8) { @@ -27,7 +32,13 @@ public function tick(Combat $combat, int $i): void { } return; } - if ($state === 8) { $this->charge($combat, $i); return; } + if ($state === 8) { + if ($mobjs[$i]->hitDamage === ElephantCharge::DAMAGE) { $combat->elephants->tick($combat, $i); return; } + if (!$combat->player->elephantMode) { $this->charge($combat, $i); return; } + $mobjs[$i]->state = 1; $mobjs[$i]->stateTics = 0; + $mobjs[$i]->velX = 0; $mobjs[$i]->velY = 0; $mobjs[$i]->velZ = 0; + $state = 1; + } $mobjs[$i]->stateAge++; if ($state === 4) { int $first = $combat->rules->deathFirst($type); @@ -79,11 +90,20 @@ public function tick(Combat $combat, int $i): void { } int $angle = $this->angle($dx, $dy); $mobjs[$i]->angle = $angle; + bool $touching = false; + if ($combat->player->elephantMode) { + $touching = $this->touchingTarget($combat, $i); + // Abandon a ranged windup or a contact attack the target escaped. + if ($state === 2 && !$touching) { + $mobjs[$i]->state = 1; $mobjs[$i]->stateTics = 0; + $state = 1; + } + } if ($state === 2) { $mobjs[$i]->stateTics--; if ($mobjs[$i]->stateTics === 6) { $combat->frame($i, $combat->rules->attackFrame($type) + 1); - if ($type === 16 && $visible) { $this->attack($combat, $i, $tx, $ty, $tz, $distance); } + if ($type === 16 && $visible && !$combat->player->elephantMode) { $this->attack($combat, $i, $tx, $ty, $tz, $distance); } } if ($mobjs[$i]->stateTics <= 0) { if ($visible) { @@ -91,6 +111,7 @@ public function tick(Combat $combat, int $i): void { } if ($mobjs[$i]->state !== 8) { $mobjs[$i]->state = 1; } $mobjs[$i]->cooldown = $type === 7 ? 4 : 15 + $combat->random() % 25; + if ($combat->player->elephantMode) { $mobjs[$i]->cooldown = 20; } if ($combat->skill === 5) { $mobjs[$i]->cooldown = 4; } } return; @@ -98,13 +119,16 @@ public function tick(Combat $combat, int $i): void { if ($mobjs[$i]->cooldown > 0) { $mobjs[$i]->cooldown--; } - if ($visible && $distance < 2000 && $mobjs[$i]->cooldown <= 0 && (!$combat->rules->meleeOnly($type) || $distance < 64)) { + bool $inRange = $distance < 2000 && (!$combat->rules->meleeOnly($type) || $distance < 64); + if ($combat->player->elephantMode) { $inRange = $touching; } + if ($visible && $inRange && $mobjs[$i]->cooldown <= 0) { $mobjs[$i]->state = 2; $mobjs[$i]->stateTics = $combat->skill === 5 ? 6 : 12; $combat->frame($i, $combat->rules->attackFrame($type)); - if ($type === 16) { $this->attack($combat, $i, $tx, $ty, $tz, $distance); } + if ($type === 16 && !$combat->player->elephantMode) { $this->attack($combat, $i, $tx, $ty, $tz, $distance); } return; } + if ($combat->elephants->tryStart($combat, $i, $dx, $dy, $visible)) { return; } int $walk = intdiv($mobjs[$i]->stateAge, 4) % 4; if ($type === 3005 || $type === 3006) { $walk %= 2; } $combat->frame($i, $walk); @@ -130,6 +154,13 @@ public function angle(int $dx, int $dy): int { public function attack(Combat $combat, int $i, int $tx, int $ty, int $tz, int $distance): void { $mobjs = $combat->things->mobjs; int $type = $mobjs[$i]->type; + if ($combat->player->elephantMode) { + if ($this->touchingTarget($combat, $i)) { + $this->damageTarget($combat, $i, ($combat->random() % 3 + 1) * 4); + } + $combat->frame($i, $combat->rules->attackFrame($type)); + return; + } if ($combat->player->invisible > 0 && $mobjs[$i]->target < 0) { $tx += ($combat->random() - $combat->random()) * 2; $ty += ($combat->random() - $combat->random()) * 2; @@ -210,9 +241,32 @@ public function damageTarget(Combat $combat, int $i, int $damage): void { } } + public function touchingTarget(Combat $combat, int $i): bool { + $m = $combat->things->mobjs; + int $target = $m[$i]->target; + int $tx = $combat->player->unitX(); int $ty = $combat->player->unitY(); + int $tz = $combat->player->unitZ(); int $height = 56; int $radius = 16; + if ($target >= 0) { + if ($target >= $combat->things->count || $m[$target]->active === 0 || $m[$target]->health <= 0) { return false; } + $tx = $m[$target]->x; $ty = $m[$target]->y; $tz = $m[$target]->z; + $height = $m[$target]->height; $radius = $m[$target]->radius; + } else if ($combat->player->health <= 0) { return false; } + int $reach = $m[$i]->radius + $radius + 2; + if (abs($tx - $m[$i]->x) > $reach || abs($ty - $m[$i]->y) > $reach + || $m[$i]->z >= $tz + $height || $m[$i]->z + $m[$i]->height <= $tz) { return false; } + return $combat->trace->visible($m[$i]->x, $m[$i]->y, $m[$i]->z + ($m[$i]->height >> 1), $tx, $ty, $tz + ($height >> 1)); + } + public function move(Combat $combat, int $i, int $dx, int $dy): void { int $direction = ($this->angle($dx, $dy) + 512) >> 10; int $speed = $combat->rules->speed($combat->things->mobjs[$i]->type); + if ($combat->player->elephantMode) { + int $target = $combat->things->mobjs[$i]->target; + int $radius = $target < 0 ? 16 : $combat->things->mobjs[$target]->radius; + int $reach = $combat->things->mobjs[$i]->radius + $radius; + // Shorten the last step so fast, large actors can reach body contact. + $speed = min($speed, max(1, max(abs($dx), abs($dy)) - $reach)); + } for ($attempt = 0; $attempt < 8; $attempt++) { int $offset = intdiv($attempt + 1, 2); if ($attempt % 2 === 0) { diff --git a/src/Play/Projectiles.php b/src/Play/Projectiles.php index 023866b..051bd1e 100644 --- a/src/Play/Projectiles.php +++ b/src/Play/Projectiles.php @@ -63,6 +63,12 @@ public function tick(Combat $c, int $i): void { $m[$i]->stateAge++; int $age = $m[$i]->stateAge; int $type = $m[$i]->type; + // Retire hostile shots from before a toggle or a loaded save before they + // can hit or apply splash damage. Player food and barrel blasts remain. + if ($c->player->elephantMode && $m[$i]->target >= 0 && $type >= 6000 && $type <= 6008) { + $m[$i]->active = 0; + return; + } if ($m[$i]->state === 7) { int $frames = 3; int $first = 2; @@ -136,7 +142,8 @@ public function tick(Combat $c, int $i): void { if ($type === 6003) { $m[$i]->kind = $c->things->types->find(6007); } else if ($type === 6004) { $m[$i]->kind = $c->things->types->find(6008); } $c->frame($i, $type === 6002 ? 1 : ($type === 6003 || $type === 6004 ? 0 : 2), 1); - $c->soundAt($type === 6002 ? Sounds::BARREL : Sounds::EXPLODE, $x, $y); + int $sound = $type === 6002 ? Sounds::BARREL : ($type === 6009 ? Sounds::PICKUP : Sounds::EXPLODE); + $c->soundAt($sound, $x, $y); return; } $m[$i]->fixedX = $newX; $m[$i]->fixedY = $newY; $m[$i]->fixedZ = $newZ; diff --git a/src/Play/Weapons.php b/src/Play/Weapons.php index 2b50966..05d6c94 100644 --- a/src/Play/Weapons.php +++ b/src/Play/Weapons.php @@ -56,7 +56,7 @@ public function tick(Player $p, Combat $c, Input $input, ptr $keys): void { $p->bullets--; $p->weaponCooldown = 14; $this->launch($p, $c, 6009); - $c->sounds->play(Sounds::PISTOL); + $c->sounds->play(Sounds::PUNCH); } 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 3f7b383..b28b4fe 100644 --- a/src/Render/Hud.php +++ b/src/Render/Hud.php @@ -2,7 +2,7 @@ namespace Showcases\Doom\Render; -use Showcases\Doom\Graphics\ElephantFaces; +use Showcases\Doom\Graphics\FeederFaces; use Showcases\Doom\Play\Player; use Showcases\Doom\Wad\Wad; @@ -10,7 +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. +// IDELEPHC selects built-in human portraits with state-driven expressions. final class Hud { public Wad $wad; public Screen $screen; @@ -24,7 +24,7 @@ final class Hud { public buffer $grayDigit; public buffer $keyCard; public buffer $face; - public buffer $elephantFace; + public buffer $feederFace; public buffer $font; public int $bigDigitWidth; public int $faceLook; @@ -52,9 +52,9 @@ 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)); + $this->feederFace = buffer_new(FeederFaces::COUNT); + for ($f = 0; $f < FeederFaces::COUNT; $f++) { + $this->feederFace[$f] = $wad->find(FeederFaces::name($f)); } for ($pain = 0; $pain < 5; $pain++) { for ($look = 0; $look < 3; $look++) { @@ -165,7 +165,7 @@ public function drawStatusBar(Player $player): void { } public function faceLump(Player $player): int { - if ($player->elephantMode) { return $this->elephantFaceLump($player); } + if ($player->elephantMode) { return $this->feederFaceLump($player); } if ($player->health <= 0) { return $this->faceDead; } if ($player->godMode || $player->invulnerable > 0) { return $this->wad->find("STFGOD0"); } int $health = $player->health; @@ -180,15 +180,15 @@ 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]; } + public function feederFaceLump(Player $player): int { + if ($player->health <= 0) { return $this->feederFace[FeederFaces::DEAD]; } + if ($player->godMode || $player->invulnerable > 0) { return $this->feederFace[FeederFaces::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]; + if ($player->damageCount > 0) { $slot = FeederFaces::HURT + $pain; } + else if ($player->bonusCount > 0) { $slot = FeederFaces::GRIN + $pain; } + else if ($player->fireHeld && $player->weaponCooldown > 0) { $slot = FeederFaces::FIRE + $pain; } + return $this->feederFace[$slot]; } // Right-aligned at $right, like DOOM's number widgets. diff --git a/src/Render/Renderer.php b/src/Render/Renderer.php index d172b99..eb1d569 100644 --- a/src/Render/Renderer.php +++ b/src/Render/Renderer.php @@ -86,7 +86,7 @@ public function render(MapData $map, Things $things, Player $player, int $tic, s $this->world->renderSubSector($order[$o]); } $this->world->finishFrame(); - $this->spriteRenderer->render($things, $tic); + $this->spriteRenderer->render($things, $tic, $player->elephantMode); $this->world->drawMaskedSegs(); $this->weapons->draw($this->screen, $player, $this->trig, $tic); $this->hud->drawStatusBar($player); diff --git a/src/Render/SpriteRenderer.php b/src/Render/SpriteRenderer.php index 57df48e..ee4d6d8 100644 --- a/src/Render/SpriteRenderer.php +++ b/src/Render/SpriteRenderer.php @@ -5,6 +5,8 @@ use Showcases\Doom\Data\Mobj; use Showcases\Doom\Data\Seg; use Showcases\Doom\Graphics\Sprites; +use Showcases\Doom\Play\ElephantCharge; +use Showcases\Doom\Play\MonsterRules; use Showcases\Doom\Play\Things; use Showcases\Doom\Wad\Wad; @@ -25,6 +27,9 @@ final class SpriteRenderer { public buffer $visibleSide; public buffer $columnTop; public buffer $columnBottom; + public buffer $elephantFrames; + public MonsterRules $monsterRules; + public bool $elephantMode = false; public function __construct(BspRenderer $world, Sprites $sprites, Wad $wad) { $this->world = $world; @@ -37,9 +42,16 @@ public function __construct(BspRenderer $world, Sprites $sprites, Wad $wad) { $this->visibleSide = buffer_new(1); $this->columnTop = buffer_new($world->width); $this->columnBottom = buffer_new($world->width); + $this->monsterRules = new MonsterRules(); + $this->elephantFrames = buffer_new(11); + int $key = $wad->key("ELEN"); + for ($f = 0; $f < 11; $f++) { + $this->elephantFrames[$f] = $sprites->find($key, $f); + } } - public function render(Things $things, int $tic): void { + public function render(Things $things, int $tic, bool $elephantMode = false): void { + $this->elephantMode = $elephantMode; if ($things->count <= 0) { return; } @@ -103,11 +115,30 @@ public function render(Things $things, int $tic): void { for ($k = 0; $k < $visible; $k++) { int $index = $order[$k]; - int $definition = $things->definitionAt($index, $tic); + int $definition = $this->definitionFor($things, $index, $tic); $this->drawThing($mobjs, $index, $definition, $depths[$k], $sides[$k]); } } + // Substitute only the displayed frame. Actor types and saved WAD definitions + // retain their original values, including when the cheat is toggled mid-fight. + public function definitionFor(Things $things, int $index, int $tic): int { + int $type = $things->mobjs[$index]->type; + if (!$this->elephantMode || $type === 2035 || $this->monsterRules->health($type) <= 0) { + return $things->definitionAt($index, $tic); + } + int $state = $things->mobjs[$index]->state; + int $age = $things->mobjs[$index]->stateAge; + int $frame = intdiv($age, 5) % 4; + if ($state === 0) { $frame = intdiv($tic + $index * 7, 10) % 2; } + else if ($state === 2 || $state === 3 || $state === 8) { $frame = 4; } + else if ($state === 4) { $frame = min(7, 5 + intdiv($age, 5)); } + else if ($state === 5) { $frame = 7 + intdiv($tic, 16) % 2; } + if ($state === 8 && $things->mobjs[$index]->hitDamage === ElephantCharge::DAMAGE) { $frame = 9 + intdiv($age, 3) % 2; } + int $definition = $this->elephantFrames[$frame]; + return $definition >= 0 ? $definition : $things->definitionAt($index, $tic); + } + public function drawThing(buffer $mobjs, int $index, int $definition, int $depth, int $side): void { BspRenderer $world = $this->world; int $rotationSlot = $definition * 8; @@ -185,7 +216,7 @@ public function drawThing(buffer $mobjs, int $index, int $definition, int } if ($world->fixedColormap >= 0) { $colormap = $world->fixedColormap; } - bool $shadow = $mobjs[$index]->type === 58; + bool $shadow = $mobjs[$index]->type === 58 && !$this->elephantMode; int $spriteTop = $mobjs[$index]->z + $topOffset; int $topHeight = $spriteTop - $world->viewZ; int $textureMid = $topHeight << 16; diff --git a/src/Render/WeaponRenderer.php b/src/Render/WeaponRenderer.php index 5d5f2d3..4090aa1 100644 --- a/src/Render/WeaponRenderer.php +++ b/src/Render/WeaponRenderer.php @@ -12,13 +12,13 @@ final class WeaponRenderer { public Wad $wad; public buffer $patches; public int $available; - public buffer $elephantHead; + public buffer $feedingHand; public function __construct(Wad $wad) { $this->wad = $wad; - $this->elephantHead = buffer_new(3); + $this->feedingHand = buffer_new(3); for ($f = 0; $f < 3; $f++) { - $this->elephantHead[$f] = $wad->find("ELTG" . chr(65 + $f) . "0"); + $this->feedingHand[$f] = $wad->find("ELTG" . chr(65 + $f) . "0"); } $this->patches = buffer_new(90); $prefixes = ["", "PUNG", "PISG", "SHTG", "CHGG", "MISG", "PLSG", "BFGG", "SAWG"]; @@ -93,11 +93,11 @@ public function draw(Screen $screen, Player $player, Trig $trig, int $tic): void 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; + int $handFrame = 0; if ($player->weaponCooldown > 0 && $player->weaponAge < 9) { - $headFrame = $player->weaponAge < 5 ? 1 : 2; + $handFrame = $player->weaponAge < 5 ? 1 : 2; } - $screen->drawPatch($this->wad, $this->elephantHead[$headFrame], $x, $y); + $screen->drawPatch($this->wad, $this->feedingHand[$handFrame], $x, $y); return; } int $slot = $base + $frame; diff --git a/src/engine.php b/src/engine.php index e26c33c..f31383d 100644 --- a/src/engine.php +++ b/src/engine.php @@ -26,7 +26,8 @@ require_once 'Graphics/Animations.php'; require_once 'Graphics/Palette.php'; -require_once 'Graphics/ElephantFaces.php'; +require_once 'Graphics/FeederFaces.php'; +require_once 'Graphics/ElephantHerd.php'; require_once 'Graphics/ElephantArt.php'; require_once 'Graphics/Textures.php'; require_once 'Graphics/Flats.php'; @@ -52,6 +53,7 @@ require_once 'Play/Cheats.php'; require_once 'Play/CombatTrace.php'; require_once 'Play/MonsterRules.php'; +require_once 'Play/ElephantCharge.php'; require_once 'Play/MonsterAI.php'; require_once 'Play/Projectiles.php'; require_once 'Play/Combat.php'; diff --git a/tests/ElephantChargeTest.php b/tests/ElephantChargeTest.php new file mode 100644 index 0000000..3182331 --- /dev/null +++ b/tests/ElephantChargeTest.php @@ -0,0 +1,188 @@ +boot(); + $this->prepare($s, 3004, 384); + $s->combat->seed = 7; + for ($t = 0; $t < 36; $t++) { $s->combat->monsters->tick($s->combat, 3); } + $this->assertSameInt(1, $s->things->mobjs[3]->state); + $s->combat->monsters->tick($s->combat, 3); + $this->assertSameInt(8, $s->things->mobjs[3]->state); + int $x = $s->things->mobjs[3]->x; + $this->tick($s, ElephantCharge::WINDUP); + $this->assertSameInt($x, $s->things->mobjs[3]->x); + $this->assertSameInt(100, $s->player->health); + $this->assertSameInt(ElephantCharge::RUN_TICS, $s->things->mobjs[3]->stateTics); + $s->combat->monsters->tick($s->combat, 3); + $this->assertTrue($s->things->mobjs[3]->x < $x - 16); + $this->prepare($s, 3004, 384); + $s->things->mobjs[3]->stateAge = 36; + $s->combat->seed = 1; + $s->combat->monsters->tick($s->combat, 3); + $this->assertSameInt(1, $s->things->mobjs[3]->state); + } + + public function testDashDamagesOnlyAtContactOnceThenRecovers(): void { + $s = $this->boot(); + $this->start($s, 3004, 224); + $this->tick($s, ElephantCharge::WINDUP); + $this->assertSameInt(100, $s->player->health); + for ($t = 0; $t < ElephantCharge::RUN_TICS; $t++) { + if ($s->things->mobjs[3]->state !== 8) { break; } + $this->tick($s, 1); + } + $this->assertSameInt(100 - ElephantCharge::DAMAGE, $s->player->health); + $this->assertSameInt(1, $s->things->mobjs[3]->state); + $this->assertTrue($s->combat->monsters->touchingTarget($s->combat, 3)); + $this->assertSameInt(ElephantCharge::RECOVERY, $s->things->mobjs[3]->cooldown); + $this->assertSameInt(0, $s->things->mobjs[3]->velX); + $this->tick($s, 50); + $this->assertSameInt(100 - ElephantCharge::DAMAGE, $s->player->health); + $this->assertSameInt(0, $s->things->mobjs[0]->active); + } + + public function testSidesteppingDuringTheWarningDodgesTheLockedDirection(): void { + $s = $this->boot(); + $this->start($s, 3004, 224); + $s->player->y = 208 << 16; + $this->finish($s); + $this->assertSameInt(100, $s->player->health); + $this->assertSameInt(128, $s->things->mobjs[3]->y); + $this->assertSameInt(1, $s->things->mobjs[3]->state); + $this->assertSameInt(0, $s->things->mobjs[3]->velX); + $this->assertTrue($s->things->mobjs[3]->x >= $s->things->mobjs[3]->radius); + } + + public function testClosedDoorsAndSolidObjectsStopTheSweptDash(): void { + $s = $this->boot(); + $s->player->x = 384 << 16; + $this->start($s, 3004, 192); + $s->map->sectors[1]->ceiling_height = 0; + $this->finish($s); + $this->assertTrue($s->things->mobjs[3]->x < 256); + $this->assertSameInt(100, $s->player->health); + $this->assertSameInt(1, $s->things->mobjs[3]->state); + $s->map->sectors[1]->ceiling_height = 128; + $this->start($s, 3004, 192); + $s->things->mobjs[6]->active = 1; + $s->things->mobjs[6]->x = 240; + $s->things->mobjs[6]->y = 128; + $this->finish($s); + $this->assertTrue($s->things->mobjs[3]->x < 220); + $this->assertSameInt(20, $s->things->mobjs[6]->health); + $this->assertSameInt(100, $s->player->health); + } + + public function testFoodInterruptsWarningAndDashAndDisablingTheModeCancelsIt(): void { + $s = $this->boot(); + $this->start($s, 3004, 224); + $s->combat->hurt(3, 1, -1); + $this->assertSameInt(3, $s->things->mobjs[3]->state); + $this->assertSameInt(0, $s->things->mobjs[3]->hitDamage); + $this->assertSameInt(0, $s->things->mobjs[3]->velX); + $this->start($s, 3004, 224); + $this->tick($s, ElephantCharge::WINDUP + 1); + $s->combat->hurt(3, 1, -1); + $this->assertSameInt(3, $s->things->mobjs[3]->state); + $this->assertSameInt(0, $s->things->mobjs[3]->velX); + $this->start($s, 3004, 224); + $s->cheats->feed($s, "idelephc"); + $this->tick($s, 1); + $this->assertSameInt(1, $s->things->mobjs[3]->state); + $this->assertSameInt(0, $s->things->mobjs[3]->hitDamage); + $this->assertSameInt(0, $s->things->mobjs[3]->velX); + $s->cheats->feed($s, "idelephc"); + $this->start($s, 3004, 224); + $s->combat->hurt(3, 1000, -1); + $this->tick($s, 60); + $this->assertSameInt(5, $s->things->mobjs[3]->state); + $this->assertSameInt(100, $s->player->health); + } + + public function testAllFamiliesCanChargeButOriginalModeCannotStartOne(): void { + $s = $this->boot(); + $types = [3004, 9, 3001, 3002, 58, 3006, 3005, 3003, 16, 7]; + foreach ($types as $type) { + $this->start($s, (int)$type, 384); + $this->assertSameInt(ElephantCharge::DAMAGE, $s->things->mobjs[3]->hitDamage); + $this->assertSameInt(0, $s->things->mobjs[0]->active); + } + $s->player->elephantMode = false; + $this->assertFalse($s->combat->elephants->tryStart($s->combat, 3, -320, 0, true)); + } + + public function testSavesResumeBothTheWarningAndDashWithMatchingMovement(): void { + $s = $this->boot(); + $this->start($s, 3004, 224); + string $warning = $s->saves->encode($s); + $this->tick($s, 5); + int $time = $s->things->mobjs[3]->stateTics; + $this->assertTrue($s->saves->decode($s, $warning)); + $this->tick($s, 5); + $this->assertSameInt($time, $s->things->mobjs[3]->stateTics); + $this->assertSameInt(224, $s->things->mobjs[3]->x); + $this->tick($s, ElephantCharge::WINDUP - 5 + 1); + string $dash = $s->saves->encode($s); + $this->tick($s, 3); + int $x = $s->things->mobjs[3]->x; + $time = $s->things->mobjs[3]->stateTics; + int $health = $s->player->health; + $this->assertTrue($s->saves->decode($s, $dash)); + $this->tick($s, 3); + $this->assertSameInt($x, $s->things->mobjs[3]->x); + $this->assertSameInt($time, $s->things->mobjs[3]->stateTics); + $this->assertSameInt($health, $s->player->health); + $s->render(""); + int $definition = $s->renderer->spriteRenderer->definitionFor($s->things, 3, 0); + $this->assertTrue($s->sprites->frame[$definition] >= 9); + } + + protected function start(Session $s, int $type, int $x): void { + $this->prepare($s, $type, $x); + $s->things->mobjs[3]->stateAge = 36; + $s->combat->seed = 7; + $this->tick($s, 1); + $this->assertSameInt(8, $s->things->mobjs[3]->state); + } + + protected function prepare(Session $s, int $type, int $x): void { + $this->isolate($s, 3); + $s->player->elephantMode = true; + int $kind = $s->things->types->find($type); + $m = $s->things->mobjs; + $m[3]->type = $type; $m[3]->kind = $kind; + $m[3]->radius = $s->things->types->radius[$kind]; + $m[3]->height = $s->things->types->height[$kind]; + $m[3]->health = $s->combat->rules->health($type); $m[3]->solid = 1; + $m[3]->x = $x; $m[3]->y = 128; $m[3]->z = 0; + $m[3]->sector = $x >= 256 ? 1 : 0; + $m[3]->state = 1; $m[3]->stateAge = 0; $m[3]->cooldown = 0; + $m[3]->target = -1; $m[3]->angle = 4096; $m[3]->hitDamage = 0; + } + + protected function tick(Session $s, int $tics): void { + for ($t = 0; $t < $tics; $t++) { $s->combat->monsters->tick($s->combat, 3); } + } + + protected function finish(Session $s): void { + int $end = ElephantCharge::WINDUP + ElephantCharge::RUN_TICS; + for ($t = 0; $t < $end; $t++) { + if ($s->things->mobjs[3]->state !== 8) { break; } + $this->tick($s, 1); + } + } +} diff --git a/tests/ElephantCheatTest.php b/tests/ElephantCheatTest.php index 61a2bb7..fb04841 100644 --- a/tests/ElephantCheatTest.php +++ b/tests/ElephantCheatTest.php @@ -27,7 +27,7 @@ public function testTypedCheatTogglesAndSelectsThePistol(): void { $this->assertSameString("", $s->cheats->tail); } - public function testElephantTravelsBeforeDamagingItsTarget(): void { + public function testFoodTravelsBeforeFeedingItsTarget(): void { $s = $this->boot(); $this->isolate($s, 3); $s->things->mobjs[3]->x = 192; @@ -51,7 +51,7 @@ public function testElephantTravelsBeforeDamagingItsTarget(): void { $this->assertSameInt(0, $s->things->mobjs[0]->active); } - public function testClosedDoorsStopElephantsAndProtectMonstersBehindThem(): void { + public function testClosedDoorsStopFoodAndProtectMonstersBehindThem(): void { $s = $this->boot(); $this->isolate($s, 4); $s->things->mobjs[4]->x = 384; @@ -66,7 +66,7 @@ public function testClosedDoorsStopElephantsAndProtectMonstersBehindThem(): void $this->assertTrue($s->things->mobjs[0]->x <= 256); } - public function testDisablingTheCheatRestoresHitscanWithoutRemovingFlyingElephants(): void { + public function testDisablingTheCheatRestoresHitscanWithoutRemovingFlyingFood(): void { $s = $this->boot(); $this->isolate($s, 3); $s->things->mobjs[3]->x = 192; @@ -84,7 +84,7 @@ public function testDisablingTheCheatRestoresHitscanWithoutRemovingFlyingElephan $this->assertSameInt(6, $s->things->mobjs[0]->state); } - public function testElephantHeadAnimatesAndOtherWeaponsKeepTheirArt(): void { + public function testCarrotHandAnimatesAndOtherWeaponsKeepTheirArt(): void { $s = $this->boot(); string $pistol = $this->weaponFrame($s); $s->cheats->feed($s, "idelephc"); @@ -114,7 +114,7 @@ public function testHeldFireKeepsPistolCadenceAndEmptyAmmoFallsBack(): void { $this->assertSameInt(0, $s->player->bullets); } - public function testElephantSpritesAreVisibleAndClippedByWalls(): void { + public function testFoodSpritesAreVisibleAndClippedByWalls(): void { $s = $this->boot(); $this->isolate($s, -1); $s->renderer->render($s->map, $s->things, $s->player, 0, ""); diff --git a/tests/ElephantFeedingTest.php b/tests/ElephantFeedingTest.php new file mode 100644 index 0000000..df63080 --- /dev/null +++ b/tests/ElephantFeedingTest.php @@ -0,0 +1,133 @@ +boot(); + $renderer = $s->renderer->spriteRenderer; + $s->cheats->feed($s, "idelephc"); + $s->render(""); + $types = [3004, 9, 3001, 3002, 58, 3006, 3005, 3003, 16, 7]; + foreach ($types as $type) { + $s->things->mobjs[3]->type = (int)$type; + $s->things->mobjs[3]->state = 0; + int $definition = $renderer->definitionFor($s->things, 3, 0); + $this->assertSameInt($s->wad->key("ELEN"), $s->sprites->spriteKey[$definition]); + } + $this->assertSameInt($s->things->definitionAt(0, 0), $renderer->definitionFor($s->things, 0, 0)); + $this->assertSameInt($s->things->definitionAt(6, 0), $renderer->definitionFor($s->things, 6, 0)); + $s->cheats->feed($s, "idelephc"); + $s->render(""); + $this->assertSameInt($s->things->definitionAt(3, 0), $renderer->definitionFor($s->things, 3, 0)); + } + + public function testFoodMakesAnElephantChewThenFlopAndSleepWithoutBlocking(): void { + $s = $this->boot(); + $this->isolate($s, 3); + $s->cheats->feed($s, "idelephc"); + $s->render(""); + $renderer = $s->renderer->spriteRenderer; + $s->things->mobjs[3]->health = 25; + $s->combat->hurt(3, 1, -1); + $this->assertSameInt(3, $s->things->mobjs[3]->state); + int $chewing = $renderer->definitionFor($s->things, 3, 0); + $this->assertSameInt(4, $s->sprites->frame[$chewing]); + // A real food projectile delivers the last mouthful. + $s->things->mobjs[3]->health = 1; + $s->combat->projectiles->launch($s->combat, -1, 160, 128, 32, 192, 128, 32, 6009); + $s->combat->projectiles->tick($s->combat, 0); + $this->assertSameInt(4, $s->things->mobjs[3]->state); + $this->assertSameInt(0, $s->things->mobjs[3]->solid); + $this->assertSameInt(1, $s->combat->kills); + int $tipping = $renderer->definitionFor($s->things, 3, 0); + $this->assertSameInt(5, $s->sprites->frame[$tipping]); + for ($t = 0; $t < 5; $t++) { $s->combat->monsters->tick($s->combat, 3); } + int $flopped = $renderer->definitionFor($s->things, 3, 0); + $this->assertSameInt(6, $s->sprites->frame[$flopped]); + for ($t = 0; $t < 60; $t++) { $s->combat->monsters->tick($s->combat, 3); } + $this->assertSameInt(5, $s->things->mobjs[3]->state); + $this->assertSameInt(1, $s->things->mobjs[3]->active); + int $asleep = $renderer->definitionFor($s->things, 3, 0); + $this->assertSameInt(7, $s->sprites->frame[$asleep]); + int $breathing = $renderer->definitionFor($s->things, 3, 16); + $this->assertSameInt(8, $s->sprites->frame[$breathing]); + int $kills = $s->combat->kills; + $s->combat->hurt(3, 100, -1); + $this->assertSameInt($kills, $s->combat->kills); + } + + public function testSleepingFlyingElephantsSettleOntoTheFloorAndSurviveSaves(): void { + $s = $this->boot(); + $this->isolate($s, 3); + $s->cheats->feed($s, "idelephc"); + $s->things->mobjs[3]->type = 3005; + $s->things->mobjs[3]->kind = $s->things->types->find(3005); + $s->things->mobjs[3]->z = 48; + $s->combat->hurt(3, 1000, -1); + for ($t = 0; $t < 60; $t++) { $s->combat->monsters->tick($s->combat, 3); } + $this->assertSameInt(0, $s->things->mobjs[3]->z); + $this->assertSameInt(5, $s->things->mobjs[3]->state); + string $snapshot = $s->saves->encode($s); + $s->player->elephantMode = false; + $this->assertTrue($s->saves->decode($s, $snapshot)); + $this->assertTrue($s->player->elephantMode); + $this->assertSameInt(5, $s->things->mobjs[3]->state); + $this->assertSameInt(0, $s->things->mobjs[3]->solid); + $s->render(""); + int $definition = $s->renderer->spriteRenderer->definitionFor($s->things, 3, 0); + $this->assertSameInt(7, $s->sprites->frame[$definition]); + } + + public function testSleepingElephantAndLettersAreClippedTogetherBehindClosedDoors(): void { + $s = $this->boot(); + $this->isolate($s, 3); + $s->cheats->feed($s, "idelephc"); + $s->combat->hurt(3, 1000, -1); + for ($t = 0; $t < 60; $t++) { $s->combat->monsters->tick($s->combat, 3); } + $s->things->mobjs[3]->active = 0; + $s->render(""); + string $empty = $this->frame($s); + $s->things->mobjs[3]->active = 1; + $s->render(""); + $this->assertFalse($empty === $this->frame($s)); + $s->map->sectors[1]->ceiling_height = 0; + $s->things->mobjs[3]->active = 0; + $s->render(""); + string $closed = $this->frame($s); + $s->things->mobjs[3]->active = 1; + $s->things->mobjs[3]->x = 384; + $s->things->mobjs[3]->sector = 1; + $s->render(""); + $this->assertSameString($closed, $this->frame($s)); + } + + public function testFullBossStillUnlocksTheExit(): void { + $s = $this->boot(); + $this->isolate($s, 3); + $s->cheats->feed($s, "idelephc"); + $s->things->mobjs[3]->type = 3003; + $s->things->mobjs[3]->kind = $s->things->types->find(3003); + $s->map->mapName = "E1M8"; + $s->map->sectors[1]->tag = 666; + $s->map->sectors[1]->floor_height = 64; + $s->combat->hurt(3, 2000, -1); + $this->assertSameInt(4, $s->things->mobjs[3]->state); + $this->assertTrue($s->map->sectors[1]->mover > 0); + $this->assertTrue($s->combat->bossTriggered); + } + + 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 index 5acf55f..1ed415f 100644 --- a/tests/ElephantHudTest.php +++ b/tests/ElephantHudTest.php @@ -31,17 +31,17 @@ protected function boot(): Session { return $s; } - public function testElephantReplacesOnlyThePortraitAndTogglesBackToTheMarine(): void { + public function testHumanReplacesOnlyThePortraitAndTogglesBackToTheMarine(): 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); + string $human = $this->status($s); + $this->assertFalse($classic === $human); 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++; } + if (($x < 143 || $x >= 175 || $y < 168) && $classic[$i] !== $human[$i]) { $outside++; } } $this->assertSameInt(0, $outside); $s->cheats->feed($s, "idelephc"); @@ -97,7 +97,7 @@ public function testPortraitReactsToFireDamagePickupsGodModeAndDeath(): void { $this->assertSameInt($s->wad->find("STFDEAD0"), $s->renderer->hud->faceLump($s->player)); } - public function testSavesRestoreTheElephantExpressionWithoutExtraState(): void { + public function testSavesRestoreTheHumanExpressionWithoutExtraState(): void { $s = $this->boot(); $s->cheats->feed($s, "idelephc"); $s->player->health = 25; @@ -109,28 +109,52 @@ public function testSavesRestoreTheElephantExpressionWithoutExtraState(): void { $this->assertSameString($hurt, $this->portrait($s)); } - public function testRearHeadStaysLowAndHasTwoWideEars(): void { + public function testHandHoldsACarrotLowInTheViewAndReleasesItWhenThrowing(): 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; + int $leaves = 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); + if ($y < 110) { $leaves++; } } - $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); + $this->assertTrue($top >= 94 && $top < 110); + $this->assertTrue($left >= 140 && $right < 221); + $this->assertTrue($bottom >= 168); + $this->assertTrue($leaves > 0); + // The right wrist enters from below; the leafy carrot clears the grip. + $this->assertTrue($screen->pixels[160 * 320 + 207] !== 254); + $this->assertTrue($screen->pixels[125 * 320 + 164] !== 254); + $s->player->weaponCooldown = 14; + $s->player->weaponAge = 0; + $screen->fill(254); + $s->renderer->weapons->draw($screen, $s->player, $s->trig, 0); + int $aboveHand = 0; + for ($i = 0; $i < 320 * 110; $i++) { + if ($screen->pixels[$i] !== 254) { $aboveHand++; } + } + $this->assertSameInt(0, $aboveHand); + $s->player->weaponAge = 6; + $screen->fill(254); + $s->renderer->weapons->draw($screen, $s->player, $s->trig, 0); + $this->assertTrue($screen->pixels[110 * 320 + 171] !== 254); + } + + public function testHumanPortraitHasAnUnmarkedCapAndSkinTones(): void { + $s = $this->boot(); + $s->cheats->feed($s, "idelephc"); + $this->status($s); + $art = new \Showcases\Doom\Graphics\ElephantArt(); + int $cap = $art->nearest($s->palette, 0x8EADC0); + int $skin = $art->nearest($s->palette, 0xF2BE91); + $this->assertSameInt($cap, $s->renderer->screen->pixels[173 * 320 + 159]); + $this->assertSameInt($skin, $s->renderer->screen->pixels[182 * 320 + 159]); } protected function status(Session $s): string { diff --git a/tests/ElephantMeleeTest.php b/tests/ElephantMeleeTest.php new file mode 100644 index 0000000..557af02 --- /dev/null +++ b/tests/ElephantMeleeTest.php @@ -0,0 +1,143 @@ +boot(); + $this->isolate($s, 3); + $s->cheats->feed($s, "idelephc"); + $types = [3004, 9, 3001, 3002, 58, 3006, 3005, 3003, 16, 7]; + int $count = $s->things->count; + foreach ($types as $type) { + $this->monster($s, (int)$type, 384); + $s->combat->monsters->attack($s->combat, 3, 64, 128, 28, 320); + for ($t = 0; $t < 18; $t++) { $s->combat->monsters->tick($s->combat, 3); } + $this->assertSameInt(100, $s->player->health); + $this->assertSameInt($count, $s->things->count); + $this->assertSameInt(1, $s->things->mobjs[3]->state); + } + } + + public function testEveryFamilyCanDamageOnlyAtBodyContact(): void { + $s = $this->boot(); + $this->isolate($s, 3); + $s->cheats->feed($s, "idelephc"); + $types = [3004, 9, 3001, 3002, 58, 3006, 3005, 3003, 16, 7]; + foreach ($types as $type) { + $this->monster($s, (int)$type, 192); + $s->player->health = 100; + $s->things->mobjs[3]->x = 64 + 16 + $s->things->mobjs[3]->radius; + $this->assertTrue($s->combat->monsters->touchingTarget($s->combat, 3)); + $s->combat->monsters->attack($s->combat, 3, 64, 128, 28, 0); + $this->assertTrue($s->player->health < 100); + $this->assertSameInt(1, $s->things->mobjs[3]->state); + } + } + + public function testContactCannotReachAcrossHeightsWallsOrAnEscapedTarget(): void { + $s = $this->boot(); + $this->isolate($s, 3); + $s->cheats->feed($s, "idelephc"); + $this->monster($s, 3004, 100); + $s->things->mobjs[3]->z = 64; + $s->combat->monsters->attack($s->combat, 3, 64, 128, 28, 36); + $this->assertSameInt(100, $s->player->health); + $s->things->mobjs[3]->z = 0; + $s->things->mobjs[3]->state = 2; + $s->things->mobjs[3]->stateTics = 1; + $s->player->x = 16 << 16; + $s->combat->monsters->tick($s->combat, 3); + $this->assertSameInt(100, $s->player->health); + $this->assertSameInt(1, $s->things->mobjs[3]->state); + $s->player->x = 240 << 16; + $s->things->mobjs[3]->x = 276; + $s->things->mobjs[3]->sector = 1; + $s->map->sectors[1]->ceiling_height = 0; + $s->combat->monsters->attack($s->combat, 3, 240, 128, 28, 36); + $this->assertSameInt(100, $s->player->health); + } + + public function testPendingRangedAttacksAndChargesBecomeChasesOnToggle(): void { + $s = $this->boot(); + $this->isolate($s, 3); + $this->monster($s, 16, 384); + $s->things->mobjs[3]->state = 2; + $s->things->mobjs[3]->stateTics = 7; + $s->cheats->feed($s, "idelephc"); + $s->combat->monsters->tick($s->combat, 3); + $this->assertSameInt(1, $s->things->mobjs[3]->state); + $this->assertSameInt(0, $s->things->mobjs[0]->active); + $this->monster($s, 3006, 384); + $s->things->mobjs[3]->state = 8; + $s->things->mobjs[3]->stateTics = 70; + $s->things->mobjs[3]->velX = -20; + $s->combat->monsters->tick($s->combat, 3); + $this->assertSameInt(1, $s->things->mobjs[3]->state); + $this->assertSameInt(0, $s->things->mobjs[3]->velX); + $this->assertSameInt(100, $s->player->health); + } + + public function testFastLargeElephantReachesContactAndStopsDamagingWhenAsleep(): void { + $s = $this->boot(); + $this->isolate($s, 3); + $s->cheats->feed($s, "idelephc"); + $this->monster($s, 16, 129); + for ($t = 0; $t < 35; $t++) { $s->combat->monsters->tick($s->combat, 3); } + $this->assertTrue($s->player->health < 100); + $this->assertTrue($s->things->mobjs[3]->x >= 120); + $this->assertSameInt(0, $s->things->mobjs[0]->active); + $s->combat->hurt(3, 10000, -1); + int $health = $s->player->health; + for ($t = 0; $t < 100; $t++) { $s->combat->monsters->tick($s->combat, 3); } + $this->assertSameInt($health, $s->player->health); + $this->assertSameInt(5, $s->things->mobjs[3]->state); + } + + public function testModeRetiresHostileShotsButKeepsPlayerFoodAndRestoresNormalAttacks(): void { + $s = $this->boot(); + $this->isolate($s, 3); + $this->monster($s, 3001, 192); + $s->combat->projectiles->launch($s->combat, 3, 96, 128, 28, 64, 128, 28, 6000); + $s->cheats->feed($s, "idelephc"); + $s->combat->projectiles->tick($s->combat, 0); + $this->assertSameInt(0, $s->things->mobjs[0]->active); + $this->assertSameInt(100, $s->player->health); + $s->combat->projectiles->launch($s->combat, 3, 96, 128, 28, 64, 128, 28, 6002); + $s->things->mobjs[0]->state = 7; + $s->combat->projectiles->tick($s->combat, 0); + $this->assertSameInt(0, $s->things->mobjs[0]->active); + $this->assertSameInt(100, $s->player->health); + $s->combat->projectiles->launch($s->combat, -1, 96, 128, 28, 192, 128, 28, 6009); + $s->combat->projectiles->tick($s->combat, 0); + $this->assertSameInt(1, $s->things->mobjs[0]->active); + $this->assertSameInt(6, $s->things->mobjs[0]->state); + $s->cheats->feed($s, "idelephc"); + $s->combat->monsters->attack($s->combat, 3, 64, 128, 28, 128); + $this->assertSameInt(6000, $s->things->mobjs[1]->type); + $this->assertSameInt(6, $s->things->mobjs[1]->state); + } + + protected function monster(Session $s, int $type, int $x): void { + int $kind = $s->things->types->find($type); + $m = $s->things->mobjs; + $m[3]->type = $type; $m[3]->kind = $kind; + $m[3]->radius = $s->things->types->radius[$kind]; + $m[3]->height = $s->things->types->height[$kind]; + $m[3]->health = $s->combat->rules->health($type); + $m[3]->x = $x; $m[3]->y = 128; $m[3]->z = 0; + $m[3]->sector = $x >= 256 ? 1 : 0; + $m[3]->state = 1; $m[3]->stateAge = 0; $m[3]->cooldown = 0; + $m[3]->target = -1; $m[3]->angle = 4096; + } +}