diff --git a/src/Battle.php b/src/Battle.php index f409a73..4936b31 100644 --- a/src/Battle.php +++ b/src/Battle.php @@ -3,6 +3,8 @@ declare(strict_types=1); namespace LotGD\Core; +use Doctrine\Common\Collections\ArrayCollection; + use LotGD\Core\{ DiceBag, Exceptions\ArgumentException, @@ -10,6 +12,11 @@ use LotGD\Core\{ Exceptions\BattleNotOverException, Models\FighterInterface }; +use LotGD\Core\Models\BattleEvents\{ + CriticalHitEvent, + DamageEvent, + DeathEvent +}; /** * Class for managing and running battles between 2 participants. @@ -22,18 +29,23 @@ class Battle const DAMAGEROUND_MONSTER = 0b10; const DAMAGEROUND_BOTH = 0b11; + const RESULT_UNDECIDED = 0; + const RESULT_PLAYERDEATH = 1; + const RESULT_MONSTERDEATH = 2; + protected $player; protected $monster; - protected $diceBag; - protected $isOver = false; - protected $winner; - protected $looser; + protected $game; + protected $events; + protected $result = 0; + protected $round = 0; - public function __construct(FighterInterface $player, FighterInterface $monster) + public function __construct(Game $game, FighterInterface $player, FighterInterface $monster) { + $this->game = $game; $this->player = $player; $this->monster = $monster; - $this->diceBag = new DiceBag(); + $this->events = new ArrayCollection(); } public function getActions() @@ -52,7 +64,7 @@ class Battle */ public function isOver() { - return $this->isOver; + return $this->result !== self::RESULT_UNDECIDED; } /** @@ -61,22 +73,24 @@ class Battle */ public function getWinner(): FighterInterface { - if (is_null($this->winner)) { + if ($this->isOver() === false) { throw new BattleNotOverException('There is no winner yet.'); } - return $this->winner; + + return $this->result === self::RESULT_PLAYERDEATH ? $this->monster : $this->player; } /** * Returns the looser of this fight * @return FighterInterface */ - public function getLooser(): FighterInterface + public function getLoser(): FighterInterface { - if (is_null($this->looser)) { - throw new BattleNotOverException('There is no looser yet.'); + if ($this->isOver() === false) { + throw new BattleNotOverException('There is no winner yet.'); } - return $this->looser; + + return $this->result === self::RESULT_PLAYERDEATH ? $this->player : $this->monster; } /** @@ -92,20 +106,15 @@ class Battle throw new ArgumentException('$firstDamageRound must not be 0.'); } - if ($this->isOver === true) { + if ($this->isOver()) { throw new BattleIsOverException('This battle has already ended. You cannot fight anymore rounds.'); } for ($count = 0; $count < $n; $count++) { $this->fightOneRound($firstDamageRound); - $isSurprised = self::DAMAGEROUND_BOTH; + $firstDamageRound = self::DAMAGEROUND_BOTH; - // If one of the participants is dead, abort. - if ($this->player->isAlive() === false || $this->monster->isAlive() === false) { - $this->isOver = true; - - $this->winner = $this->player->isAlive() ? $this->player : $this->monster; - $this->looser = $this->player->isAlive() ? $this->monster : $this->player; + if ($this->isOver()) { break; } } @@ -119,97 +128,78 @@ class Battle */ protected function fightOneRound(int $firstDamageRound) { - // playerDamage is the damage done to the player, to the monster. - list($playerDamage, $monsterDamage, $playerAttack) = $this->calculateDamage(); + $damageHasBeenDone = false; - // Player does damage to the monster - if ($firstDamageRound & self::DAMAGEROUND_PLAYER - && $this->player->isAlive() - && $this->monster->isAlive() - ) { - if ($monsterDamage < 0) { - // The damage done to the monster is negative. - // This means that the monster counters the player's attack - $this->player->damage(0 - $monsterDamage); - } elseif ($monsterDamage > 0) { - // The damage done to the monster is positive. - // This means that this is a normal attack - $this->monster->damage($monsterDamage); - } else { - // The damage done to the monster is 0. - // We interpretate this as a miss. + do { + $offenseTurnEvents = $firstDamageRound & self::DAMAGEROUND_PLAYER ? $this->turn($this->player, $this->monster) : new ArrayCollection(); + $defenseTurnEvents = $firstDamageRound & self::DAMAGEROUND_MONSTER ? $this->turn($this->monster, $this->player) : new ArrayCollection(); + + $events = new ArrayCollection(array_merge($offenseTurnEvents->toArray(), $defenseTurnEvents->toArray())); + $eventsToAdd = new ArrayCollection(); + + foreach($events as $event) { + $event->apply(); + + if ($event instanceof DamageEvent && $event->getDamage() !== 0) { + $damageHasBeenDone = true; + } + + $eventsToAdd->add($event); + + if ($this->player->getHealth() <= 0) { + $this->events->add(new DeathEvent($this->player)); + $this->result = self::RESULT_PLAYERDEATH; + break; + } + + if ($this->monster->getHealth() <= 0) { + $this->events->add(new DeathEvent($this->monster)); + $this->result = self::RESULT_MONSTERDEATH; + break; + } } - } + } while($damageHasBeenDone === false); - // Monster does damage to the player - if ($firstDamageRound & self::DAMAGEROUND_MONSTER - && $this->player->isAlive() - && $this->monster->isAlive() - ) { - if ($playerDamage > 0) { - // The damage done to the player is negative - // THis means that the player counters the monster's attack - $this->monster->damage(0 - $playerDamage); - } elseif($playerDamage > 0) { - // The damage done to the player is positive. - // This means that this is a normal attack - $this->player->damage($playerDamage); - } - else { - // The damage done to the player is 0. - // We interpretate this as a miss. - } - } + $this->round++; + $this->events = new ArrayCollection(array_merge($this->events->toArray(), $eventsToAdd->toArray())); } /** - * Returns the damage done to the player and to the monster. - * @return array [playerDamage, monsterDamage, playerAttack] + * Runs one turn. + * @param FighterInterface $attacker + * @param FighterInterface $defender */ - protected function calculateDamage(): array + protected function turn(FighterInterface $attacker, FighterInterface $defender): ArrayCollection { - $monsterDefense = $this->monster->getDefense(); - $monsterAttack = $this->monster->getAttack(); - $playerDefense = $this->player->getDefense(); - $playerAttack = $this->player->getAttack(); + $events = new ArrayCollection(); - $monsterDamage = 0; - $playerDamage = 0; + $attackersAttack = $attacker->getAttack($this->game); + $defendersDefense = $defender->getDefense($this->game); - while ($monsterDamage === 0 && $playerDamage === 0) { - $atk = $playerAttack; - - // Critical hit probablity is derived from the old e_rand() function. - // e_rand(1, 3) == 3 has a probablity of ~25%. - if ($this->diceBag->chance(0.25)) { - $atk *= 3; - } - - // Calculate damage done to the monster - $playerAtkRoll = $this->diceBag->normal(0, $atk); - $monsterDefRoll = $this->diceBag->normal(0, $monsterDefense); - $monsterDamage = $playerAtkRoll - $monsterDefRoll; - - if ($monsterDamage < 0) { - // Counter attack is only half as hard - $monsterDamage /= 2; - } - - // Calculate damage done to the player - $playerDefRoll = $this->diceBag->normal(0, $playerDefense); - $monsterAtkRoll = $this->diceBag->normal(0, $monsterAttack); - $playerDamage = $monsterAtkRoll - $playerDefRoll; - - if ($playerDamage < 0) { - // Counter attack is only half as hard - $playerDamage /= 2; + if ($attacker === $this->game->getCharacter()) { + // Players can land critical hits + if ($this->game->getDiceBag()->chance(0.25)) { + $attackersAttack *= 3; } } - return [ - (int)round($playerDamage, 0), - (int)round($monsterDamage, 0), - $atk - ]; + $attackersAtkRoll = $this->game->getDiceBag()->normal(0, $attackersAttack); + $defendersDefRoll = $this->game->getDiceBag()->normal(0, $defendersDefense); + $damage = $attackersAtkRoll - $defendersDefRoll; + + if ($attackersAttack > $attacker->getAttack($this->game, true)) { + $events->add(new CriticalHitEvent($attacker, $attackersAttack)); + } + + if ($damage < 0) { + // RIPOSTE are only half as damaging than normal attacks + $damage /= 2; + } + + $damage = (int)round($damage, 0); + + $events->add(new DamageEvent($attacker, $defender, $damage)); + + return $events; } } diff --git a/src/BuffList.php b/src/BuffList.php new file mode 100644 index 0000000..713fe7c --- /dev/null +++ b/src/BuffList.php @@ -0,0 +1,21 @@ +buffs = $buffs; + } +} diff --git a/src/Exceptions/BuffSlotOccupiedException.php b/src/Exceptions/BuffSlotOccupiedException.php new file mode 100644 index 0000000..37a6e05 --- /dev/null +++ b/src/Exceptions/BuffSlotOccupiedException.php @@ -0,0 +1,12 @@ +eventManager; } + + /** + * Returns the game's dice bag. + * @return DiceBag + */ + public function getDiceBag(): DiceBag + { + return $this->diceBag; + } + + /** + * Returns the active character for this game run + * @return Character + */ + public function getCharacter(): Character + { + return $this->character; + } } diff --git a/src/Models/BattleEvents/BattleEvent.php b/src/Models/BattleEvents/BattleEvent.php new file mode 100644 index 0000000..6dbe215 --- /dev/null +++ b/src/Models/BattleEvents/BattleEvent.php @@ -0,0 +1,20 @@ +attacker = $attacker; + $this->criticalAttackValue = $criticalAttackValue; + } + + public function decorate(Game $game): string + { + $pureAttackersAttack = $this->attacker->getAttack($game, true); + + if ($this->criticalAttackValue > $pureAttackersAttack * 4) { + return "You execute a MEGA power move!!!"; + } elseif ($this->criticalAttackValue > $pureAttackersAttack * 3) { + return "You execute a DOUBLE power move!!!"; + } elseif ($this->criticalAttackValue > $pureAttackersAttack * 2) { + return "You execute a power move!!!"; + } elseif ($this->criticalAttackValue > $pureAttackersAttack * 1.25) { + return "You execute a minor power move!"; + } + } +} diff --git a/src/Models/BattleEvents/DamageEvent.php b/src/Models/BattleEvents/DamageEvent.php new file mode 100644 index 0000000..3c19731 --- /dev/null +++ b/src/Models/BattleEvents/DamageEvent.php @@ -0,0 +1,69 @@ +attacker = $attacker; + $this->defender = $defender; + $this->damage = $damage; + } + + public function getDamage(): int + { + return $this->damage; + } + + public function apply() + { + if ($this->damage !== 0) { + // Only damage the victim if there is an actual effect + $victim = $this->damage > 0 ? $this->defender : $this->attacker; + $victim->damage(abs($this->damage)); + } + } + + public function decorate(Game $game): string + { + $attackersName = $this->attacker->getDisplayName(); + $defendersName = $this->defender->getDisplayName(); + + if ($this->damage === 0) { + if ($this->attacker === $game->getCharacter()) { + return "You try to hit {$defendersName} but MISS!"; + } + else { + return "{$attackersName} tries to hit you but they MISS!"; + } + } elseif ($this->damage > 0) { + if ($this->attacker === $game->getCharacter()) { + return "You hit {$defendersName} for {$this->damage} points of damage!"; + } + else { + return "{$attackersName} hits you for {$this->damage} points of damage!"; + } + } else { + if ($this->attacker === $game->getCharacter()) { + return "You try to hit {$defendersName} but are RIPOSTED for {$this->damage} points of damage"; + } + else { + return "{$attackersName} tries to hit you but you RIPOSTE for {$this->damage} points of damage"; + } + } + } +} diff --git a/src/Models/BattleEvents/DeathEvent.php b/src/Models/BattleEvents/DeathEvent.php new file mode 100644 index 0000000..1290b0b --- /dev/null +++ b/src/Models/BattleEvents/DeathEvent.php @@ -0,0 +1,29 @@ +victim = $victim; + } + + public function apply() + { + + } + + public function decorate(Game $game): string + { + return ""; + } +} diff --git a/src/Models/Character.php b/src/Models/Character.php index 4620205..7396c55 100644 --- a/src/Models/Character.php +++ b/src/Models/Character.php @@ -10,6 +10,8 @@ use Doctrine\Common\Collections\{ use Doctrine\ORM\Mapping\Entity; use Doctrine\ORM\Mapping\Table; +use LotGD\Core\Game; +use LotGD\Core\Tools\Exceptions\BuffSlotOccupiedException; use LotGD\Core\Tools\Model\{ Creator, PropertyManager, @@ -215,7 +217,7 @@ class Character implements CharacterInterface, CreateableInterface /** * Returns the character's virtual attribute "attack" */ - public function getAttack(): int + public function getAttack(Game $game, bool $ignoreBuffs = false): int { return $this->level * 2; } @@ -223,7 +225,7 @@ class Character implements CharacterInterface, CreateableInterface /** * Returns the character's virtual attribute "defense" */ - public function getDefense(): int + public function getDefense(Game $game, bool $ignoreBuffs = false): int { return $this->level * 2; } @@ -251,6 +253,27 @@ class Character implements CharacterInterface, CreateableInterface return $this->characterViewpoint->first(); } + /** + * Returns a list of buffs + */ + public function getBuffs(): BuffList + { + $this->buffList ?? new BuffList($this->buffs); + return $this->buffList; + } + + /** + * Adds a buff to the buffList + */ + public function addBuff(Buff $buff, bool $override = false) + { + try { + $this->getBuffs()->add($buff); + } catch(BuffSlotOccupiedException $e) { + $this->getBuffs()->renew($buff); + } + } + /** * Returns a list of message threads this user has created. * @return Collection @@ -259,16 +282,4 @@ class Character implements CharacterInterface, CreateableInterface { return $this->messageThreads; } - - public function sendMessageTo(Character $recipient) - { - // ToDo: implement later - throw new \LotGD\Core\Exceptions\NotImplementedException; - } - - public function receiveMessageFrom(Character $author) - { - // ToDo: implement later - throw new \LotGD\Core\Exceptions\NotImplementedException; - } } diff --git a/src/Models/FighterInterface.php b/src/Models/FighterInterface.php index dd0b7ad..ad56002 100644 --- a/src/Models/FighterInterface.php +++ b/src/Models/FighterInterface.php @@ -3,6 +3,8 @@ declare(strict_types=1); namespace LotGD\Core\Models; +use LotGD\Core\Game; + /** * Interface for models that should be able to participate in fights. */ @@ -13,8 +15,8 @@ interface FighterInterface public function getMaxHealth(): int; public function getHealth(): int; public function isAlive(): bool; - public function getAttack(): int; - public function getDefense(): int; + public function getAttack(Game $game, bool $ignoreBuffs = false): int; + public function getDefense(Game $game, bool $ignoreBuffs = false): int; public function damage(int $damage); public function heal(int $heal); } diff --git a/src/Tools/Model/AutoScaleFighter.php b/src/Tools/Model/AutoScaleFighter.php index 1883097..cc8b362 100644 --- a/src/Tools/Model/AutoScaleFighter.php +++ b/src/Tools/Model/AutoScaleFighter.php @@ -3,6 +3,8 @@ declare(strict_types=1); namespace LotGD\Core\Tools\Model; +use LotGD\Core\Game; + /** * Automatically calculated values based on the fighter's level */ @@ -22,7 +24,7 @@ trait AutoScaleFighter * Returns the attack value based on the fighter's level * @return int */ - public function getAttack(): int + public function getAttack(Game $game, bool $ignoreBuffs = false): int { $level = $this->getLevel(); return (int)$level * 2 - 1; @@ -32,7 +34,7 @@ trait AutoScaleFighter * Returns the defense value based on the fighter's level * @return int */ - public function getDefense(): int + public function getDefense(Game $game, bool $ignoreBuffs = false): int { $level = $this->getlevel(); return (int)floor($level*1.45); diff --git a/src/Tools/Model/MockCharacter.php b/src/Tools/Model/MockCharacter.php index 68418cb..87d263a 100644 --- a/src/Tools/Model/MockCharacter.php +++ b/src/Tools/Model/MockCharacter.php @@ -4,6 +4,7 @@ declare(strict_types = 1); namespace LotGD\Core\Tools\Model; +use LotGD\Core\Game; use LotGD\Core\Exceptions\IsNullException; use LotGD\Core\Models\CharacterViewpoint; @@ -61,12 +62,12 @@ trait MockCharacter throw new IsNullException(); } - public function getAttack(): int + public function getAttack(Game $game, bool $ignoreBuffs = false): int { throw new IsNullException(); } - public function getDefense(): int + public function getDefense(Game $game, bool $ignoreBuffs = false): int { throw new IsNullException(); } diff --git a/tests/Models/BattleTest.php b/tests/Models/BattleTest.php index deeb712..ac96494 100644 --- a/tests/Models/BattleTest.php +++ b/tests/Models/BattleTest.php @@ -5,6 +5,8 @@ namespace LotGD\Core\Tests\Models; use LotGD\Core\{ Battle, + DiceBag, + Game, Models\Character, Models\Monster }; @@ -19,6 +21,19 @@ class BattleTest extends ModelTestCase /** @var string default data set */ protected $dataset = "battle"; + public function getMockGame(Character $character): Game + { + $game = $this->getMockBuilder(Game::class) + ->disableOriginalConstructor() + ->getMock(); + + $game->method('getEntityManager')->willReturn($this->getEntityManager()); + $game->method('getDiceBag')->willReturn(new DiceBag()); + $game->method('getCharacter')->willReturn($character); + + return $game; + } + /** * Tests basic monster functionality */ @@ -26,12 +41,13 @@ class BattleTest extends ModelTestCase { $em = $this->getEntityManager(); + $character = $em->getRepository(Character::class)->find(1); $monster = $em->getRepository(Monster::class)->find(1); $this->assertSame(5, $monster->getLevel()); $this->assertSame(52, $monster->getMaxHealth()); - $this->assertSame(9, $monster->getAttack()); - $this->assertSame(7, $monster->getDefense()); + $this->assertSame(9, $monster->getAttack($this->getMockGame($character))); + $this->assertSame(7, $monster->getDefense($this->getMockGame($character))); $this->assertSame($monster->getMaxHealth(), $monster->getHealth()); } @@ -45,7 +61,7 @@ class BattleTest extends ModelTestCase $character = $em->getRepository(Character::class)->find(1); $monster = $em->getRepository(Monster::class)->find(1); - $battle = new Battle($character, $monster); + $battle = new Battle($this->getMockGame($character), $character, $monster); for ($n = 0; $n < 99; $n++) { $oldPlayerHealth = $character->getHealth(); @@ -75,7 +91,7 @@ class BattleTest extends ModelTestCase $highLevelPlayer = $em->getRepository(Character::class)->find(2); $lowLevelMonster = $em->getRepository(Monster::class)->find(3); - $battle = new Battle($highLevelPlayer, $lowLevelMonster); + $battle = new Battle($this->getMockGame($highLevelPlayer), $highLevelPlayer, $lowLevelMonster); for ($n = 0; $n < 99; $n++) { $oldPlayerHealth = $highLevelPlayer->getHealth(); @@ -108,7 +124,7 @@ class BattleTest extends ModelTestCase $lowLevelPlayer = $em->getRepository(Character::class)->find(3); $highLevelMonster = $em->getRepository(Monster::class)->find(2); - $battle = new Battle($lowLevelPlayer, $highLevelMonster); + $battle = new Battle($this->getMockGame($lowLevelPlayer), $lowLevelPlayer, $highLevelMonster); for ($n = 0; $n < 99; $n++) { $oldPlayerHealth = $lowLevelPlayer->getHealth(); @@ -141,7 +157,7 @@ class BattleTest extends ModelTestCase $character = $em->getRepository(Character::class)->find(1); $monster = $em->getRepository(Monster::class)->find(1); - $battle = new Battle($character, $monster); + $battle = new Battle($this->getMockGame($character), $character, $monster); $battle->getWinner(); } @@ -156,9 +172,9 @@ class BattleTest extends ModelTestCase $character = $em->getRepository(Character::class)->find(1); $monster = $em->getRepository(Monster::class)->find(1); - $battle = new Battle($character, $monster); + $battle = new Battle($this->getMockGame($character), $character, $monster); - $battle->getLooser(); + $battle->getLoser(); } /** @@ -172,7 +188,7 @@ class BattleTest extends ModelTestCase $character = $em->getRepository(Character::class)->find(1); $monster = $em->getRepository(Monster::class)->find(1); - $battle = new Battle($character, $monster); + $battle = new Battle($this->getMockGame($character), $character, $monster); // Fighting for 99 rounds should be enough for determining a looser - and to // throw the exception.