diff --git a/src/pocketmine/Player.php b/src/pocketmine/Player.php index 027a91417..af8233af1 100644 --- a/src/pocketmine/Player.php +++ b/src/pocketmine/Player.php @@ -1433,6 +1433,14 @@ class Player extends Human implements CommandSender, ChunkLoader, IPlayer{ return []; } + public function getXpDropAmount() : int{ + if(!$this->isCreative()){ + return parent::getXpDropAmount(); + } + + return 0; + } + protected function checkGroundState(float $movX, float $movY, float $movZ, float $dx, float $dy, float $dz){ if(!$this->onGround or $movY != 0){ $bb = clone $this->boundingBox; diff --git a/src/pocketmine/entity/Entity.php b/src/pocketmine/entity/Entity.php index b475ebaac..90581174b 100644 --- a/src/pocketmine/entity/Entity.php +++ b/src/pocketmine/entity/Entity.php @@ -29,6 +29,7 @@ namespace pocketmine\entity; use pocketmine\block\Block; use pocketmine\block\BlockFactory; use pocketmine\block\Water; +use pocketmine\entity\object\ExperienceOrb; use pocketmine\entity\projectile\Arrow; use pocketmine\entity\projectile\Egg; use pocketmine\entity\projectile\Snowball; @@ -227,6 +228,7 @@ abstract class Entity extends Location implements Metadatable, EntityIds{ Entity::registerEntity(Arrow::class, false, ['Arrow', 'minecraft:arrow']); Entity::registerEntity(Egg::class, false, ['Egg', 'minecraft:egg']); + Entity::registerEntity(ExperienceOrb::class, false, ['XPOrb', 'minecraft:xp_orb']); Entity::registerEntity(FallingSand::class, false, ['FallingSand', 'minecraft:falling_block']); Entity::registerEntity(Item::class, false, ['Item', 'minecraft:item']); Entity::registerEntity(PrimedTNT::class, false, ['PrimedTnt', 'PrimedTNT', 'minecraft:tnt']); diff --git a/src/pocketmine/entity/Human.php b/src/pocketmine/entity/Human.php index 47acefd6d..4e4bfd6e0 100644 --- a/src/pocketmine/entity/Human.php +++ b/src/pocketmine/entity/Human.php @@ -24,6 +24,7 @@ declare(strict_types=1); namespace pocketmine\entity; use pocketmine\entity\projectile\ProjectileSource; +use pocketmine\entity\utils\ExperienceUtils; use pocketmine\event\entity\EntityDamageEvent; use pocketmine\event\entity\EntityRegainHealthEvent; use pocketmine\event\player\PlayerExhaustEvent; @@ -41,6 +42,8 @@ use pocketmine\nbt\tag\IntTag; use pocketmine\nbt\tag\ListTag; use pocketmine\nbt\tag\StringTag; use pocketmine\network\mcpe\protocol\AddPlayerPacket; +use pocketmine\network\mcpe\protocol\LevelEventPacket; +use pocketmine\network\mcpe\protocol\LevelSoundEventPacket; use pocketmine\network\mcpe\protocol\PlayerSkinPacket; use pocketmine\Player; use pocketmine\utils\UUID; @@ -75,6 +78,7 @@ class Human extends Creature implements ProjectileSource, InventoryHolder{ protected $totalXp = 0; protected $xpSeed; + protected $xpCooldown = 0; protected $baseOffset = 1.62; @@ -286,42 +290,180 @@ class Human extends Creature implements ProjectileSource, InventoryHolder{ return parent::consumeObject($consumable); } + /** + * Returns the player's experience level. + * @return int + */ public function getXpLevel() : int{ return (int) $this->attributeMap->getAttribute(Attribute::EXPERIENCE_LEVEL)->getValue(); } - public function setXpLevel(int $level){ + /** + * Sets the player's experience level. This does not affect their total XP or their XP progress. + * + * @param int $level + */ + public function setXpLevel(int $level) : void{ $this->attributeMap->getAttribute(Attribute::EXPERIENCE_LEVEL)->setValue($level); } + /** + * Adds a number of XP levels to the player. + * + * @param int $amount + * @param bool $playSound + */ + public function addXpLevels(int $amount, bool $playSound = true) : void{ + $oldLevel = $this->getXpLevel(); + $this->setXpLevel($oldLevel + $amount); + + if($playSound){ + $newLevel = $this->getXpLevel(); + if((int) ($newLevel / 5) > (int) ($oldLevel / 5)){ + $this->playLevelUpSound($newLevel); + } + } + } + + /** + * Subtracts a number of XP levels from the player. + * @param int $amount + */ + public function subtractXpLevels(int $amount) : void{ + $this->setXpLevel($this->getXpLevel() - $amount); + } + + /** + * Returns a value between 0.0 and 1.0 to indicate how far through the current level the player is. + * @return float + */ public function getXpProgress() : float{ return $this->attributeMap->getAttribute(Attribute::EXPERIENCE)->getValue(); } - public function setXpProgress(float $progress){ + /** + * Sets the player's progress through the current level to a value between 0.0 and 1.0. + * + * @param float $progress + */ + public function setXpProgress(float $progress) : void{ $this->attributeMap->getAttribute(Attribute::EXPERIENCE)->setValue($progress); } - public function getTotalXp() : int{ + /** + * Returns the number of XP points the player has progressed into their current level. + * @return int + */ + public function getRemainderXp() : int{ + return (int) (ExperienceUtils::getXpToCompleteLevel($this->getXpLevel()) * $this->getXpProgress()); + } + + /** + * Returns the amount of XP points the player currently has, calculated from their current level and progress + * through their current level. This will be reduced by enchanting deducting levels and is used to calculate the + * amount of XP the player drops on death. + * + * @return int + */ + public function getCurrentTotalXp() : int{ + return ExperienceUtils::getXpToReachLevel($this->getXpLevel()) + $this->getRemainderXp(); + } + + /** + * Sets the current total of XP the player has, recalculating their XP level and progress. + * Note that this DOES NOT update the player's lifetime total XP. + * + * @param int $amount + */ + public function setCurrentTotalXp(int $amount) : void{ + $newLevel = ExperienceUtils::getLevelFromXp($amount); + + $this->setXpLevel((int) $newLevel); + $this->setXpProgress($newLevel - ((int) $newLevel)); + } + + /** + * Adds an amount of XP to the player, recalculating their XP level and progress. XP amount will be added to the + * player's lifetime XP. + * + * @param int $amount + * @param bool $playSound Whether to play level-up and XP gained sounds. + */ + public function addXp(int $amount, bool $playSound = true) : void{ + $this->totalXp += $amount; + + $oldLevel = $this->getXpLevel(); + $oldTotal = $this->getCurrentTotalXp(); + + $this->setCurrentTotalXp($oldTotal + $amount); + + if($playSound){ + $newLevel = $this->getXpLevel(); + + if((int) ($newLevel / 5) > (int) ($oldLevel / 5)){ + $this->playLevelUpSound($newLevel); + }elseif($this->getCurrentTotalXp() > $oldTotal){ + $this->level->broadcastLevelEvent($this, LevelEventPacket::EVENT_SOUND_ORB, mt_rand()); + } + } + } + + private function playLevelUpSound(int $newLevel) : void{ + $volume = 0x10000000 * (min(30, $newLevel) / 5); //No idea why such odd numbers, but this works... + $this->level->broadcastLevelSoundEvent($this, LevelSoundEventPacket::SOUND_LEVELUP, 1, (int) $volume); + } + + /** + * Takes an amount of XP from the player, recalculating their XP level and progress. + * @param int $amount + */ + public function subtractXp(int $amount) : void{ + $this->addXp(-$amount); + } + + /** + * Returns the total XP the player has collected in their lifetime. Resets when the player dies. + * XP levels being removed in enchanting do not reduce this number. + * + * @return int + */ + public function getLifetimeTotalXp() : int{ return $this->totalXp; } - public function getRemainderXp() : int{ - return $this->getTotalXp() - self::getTotalXpForLevel($this->getXpLevel()); - } - - public function recalculateXpProgress() : float{ - $this->setXpProgress($progress = $this->getRemainderXp() / self::getTotalXpForLevel($this->getXpLevel())); - return $progress; - } - - public static function getTotalXpForLevel(int $level) : int{ - if($level <= 16){ - return $level ** 2 + $level * 6; - }elseif($level < 32){ - return $level ** 2 * 2.5 - 40.5 * $level + 360; + /** + * Sets the lifetime total XP of the player. This does not recalculate their level or progress. Used for player + * score when they die. (TODO: add this when MCPE supports it) + * + * @param int $amount + */ + public function setLifetimeTotalXp(int $amount) : void{ + if($amount < 0){ + throw new \InvalidArgumentException("XP must be greater than 0"); } - return $level ** 2 * 4.5 - 162.5 * $level + 2220; + + $this->totalXp = $amount; + } + + /** + * Returns whether the human can pickup XP orbs (checks cooldown time) + * @return bool + */ + public function canPickupXp() : bool{ + return $this->xpCooldown === 0; + } + + /** + * Sets the duration in ticks until the human can pick up another XP orb. + * + * @param int $value + */ + public function resetXpCooldown(int $value = 2) : void{ + $this->xpCooldown = $value; + } + + public function getXpDropAmount() : int{ + return (int) min(100, $this->getCurrentTotalXp()); } public function getInventory(){ @@ -419,6 +561,10 @@ class Human extends Creature implements ProjectileSource, InventoryHolder{ $this->doFoodTick($tickDiff); + if($this->xpCooldown > 0){ + $this->xpCooldown--; + } + return $hasUpdate; } diff --git a/src/pocketmine/entity/Living.php b/src/pocketmine/entity/Living.php index 30aa735e3..5c968b2f0 100644 --- a/src/pocketmine/entity/Living.php +++ b/src/pocketmine/entity/Living.php @@ -510,7 +510,9 @@ abstract class Living extends Entity implements Damageable{ $this->deadTicks += $tickDiff; if($this->deadTicks >= $this->maxDeadTicks){ $this->endDeathAnimation(); - //TODO: spawn experience orbs here + + //TODO: check death conditions (must have been damaged by player < 5 seconds from death) + $this->level->dropExperience($this, $this->getXpDropAmount()); } } @@ -660,6 +662,14 @@ abstract class Living extends Entity implements Damageable{ return []; } + /** + * Returns the amount of XP this mob will drop on death. + * @return int + */ + public function getXpDropAmount() : int{ + return 0; + } + /** * @param int $maxDistance * @param int $maxLength diff --git a/src/pocketmine/entity/Zombie.php b/src/pocketmine/entity/Zombie.php index 94b0a9d18..715f912a0 100644 --- a/src/pocketmine/entity/Zombie.php +++ b/src/pocketmine/entity/Zombie.php @@ -57,4 +57,9 @@ class Zombie extends Monster{ return $drops; } + + public function getXpDropAmount() : int{ + //TODO: check for equipment and whether it's a baby + return 5; + } } diff --git a/src/pocketmine/entity/object/ExperienceOrb.php b/src/pocketmine/entity/object/ExperienceOrb.php new file mode 100644 index 000000000..236e32c87 --- /dev/null +++ b/src/pocketmine/entity/object/ExperienceOrb.php @@ -0,0 +1,210 @@ += $split){ + return $split; + } + } + + return 1; + } + + /** + * Splits the specified amount of XP into an array of acceptable XP orb sizes. + * + * @param int $amount + * + * @return int[] + */ + public static function splitIntoOrbSizes(int $amount) : array{ + $result = []; + + while($amount > 0){ + $size = self::getMaxOrbSize($amount); + $result[] = $size; + $amount -= $size; + } + + return $result; + } + + public $height = 0.25; + public $width = 0.25; + + public $gravity = 0.04; + public $drag = 0.02; + + /** + * @var int + * Ticker used for determining interval in which to look for new target players. + */ + protected $lookForTargetTime = 0; + + /** + * @var int|null + * Runtime entity ID of the player this XP orb is targeting. + */ + protected $targetPlayerRuntimeId = null; + + protected function initEntity(){ + parent::initEntity(); + + $this->age = $this->namedtag->getShort("Age", 0); + + $value = 0; + if($this->namedtag->hasTag(self::TAG_VALUE_PC, ShortTag::class)){ //PC + $value = $this->namedtag->getShort(self::TAG_VALUE_PC); + }elseif($this->namedtag->hasTag(self::TAG_VALUE_PE, IntTag::class)){ //PE save format + $value = $this->namedtag->getInt(self::TAG_VALUE_PE); + } + + $this->setXpValue($value); + } + + public function saveNBT(){ + parent::saveNBT(); + + $this->namedtag->setShort("Age", $this->age); + + $this->namedtag->setShort(self::TAG_VALUE_PC, $this->getXpValue()); + $this->namedtag->setInt(self::TAG_VALUE_PE, $this->getXpValue()); + } + + public function getXpValue() : int{ + return $this->getDataProperty(self::DATA_EXPERIENCE_VALUE) ?? 0; + } + + public function setXpValue(int $amount) : void{ + if($amount <= 0){ + throw new \InvalidArgumentException("XP amount must be greater than 0, got $amount"); + } + $this->setDataProperty(self::DATA_EXPERIENCE_VALUE, self::DATA_TYPE_INT, $amount); + } + + public function hasTargetPlayer() : bool{ + return $this->targetPlayerRuntimeId !== null; + } + + public function getTargetPlayer() : ?Human{ + if($this->targetPlayerRuntimeId === null){ + return null; + } + + $entity = $this->server->findEntity($this->targetPlayerRuntimeId, $this->level); + if($entity instanceof Human){ + return $entity; + } + + return null; + } + + public function setTargetPlayer(?Human $player) : void{ + $this->targetPlayerRuntimeId = $player ? $player->getId() : null; + } + + public function entityBaseTick(int $tickDiff = 1) : bool{ + $hasUpdate = parent::entityBaseTick($tickDiff); + + if($this->age > 6000){ + $this->flagForDespawn(); + return true; + } + + $currentTarget = $this->getTargetPlayer(); + + if($this->lookForTargetTime >= 20){ + if($currentTarget === null or $currentTarget->distanceSquared($this) > self::MAX_TARGET_DISTANCE ** 2){ + $this->setTargetPlayer(null); + + $newTarget = $this->level->getNearestEntity($this, self::MAX_TARGET_DISTANCE, Human::class); + + if($newTarget instanceof Human and !($newTarget instanceof Player and $newTarget->isSpectator())){ + $currentTarget = $newTarget; + $this->setTargetPlayer($currentTarget); + } + } + + $this->lookForTargetTime = 0; + }else{ + $this->lookForTargetTime += $tickDiff; + } + + if($currentTarget !== null){ + $vector = $currentTarget->subtract($this)->add(0, $currentTarget->getEyeHeight() / 2, 0)->divide(self::MAX_TARGET_DISTANCE); + + $distance = $vector->length(); + $oneMinusDistance = (1 - $distance) ** 2; + + if($oneMinusDistance > 0){ + $this->motionX += $vector->x / $distance * $oneMinusDistance * 0.2; + $this->motionY += $vector->y / $distance * $oneMinusDistance * 0.2; + $this->motionZ += $vector->z / $distance * $oneMinusDistance * 0.2; + } + + if($currentTarget->canPickupXp() and $this->boundingBox->intersectsWith($currentTarget->getBoundingBox())){ + $this->flagForDespawn(); + + $currentTarget->addXp($this->getXpValue()); + $currentTarget->resetXpCooldown(); + + //TODO: check Mending enchantment + } + } + + return $hasUpdate; + } +} diff --git a/src/pocketmine/entity/utils/ExperienceUtils.php b/src/pocketmine/entity/utils/ExperienceUtils.php new file mode 100644 index 000000000..f0f719fd8 --- /dev/null +++ b/src/pocketmine/entity/utils/ExperienceUtils.php @@ -0,0 +1,90 @@ +temporalVector->setComponents((lcg_value() * 0.2 - 0.1) * 2, lcg_value() * 0.4, (lcg_value() * 0.2 - 0.1) * 2), + lcg_value() * 360, + 0 + ); + $nbt->setShort(ExperienceOrb::TAG_VALUE_PC, $split); + + $orb = Entity::createEntity("XPOrb", $this, $nbt); + if($orb === null){ + continue; + } + + $orb->spawnToAll(); + if($orb instanceof ExperienceOrb){ + $orbs[] = $orb; + } + } + + return $orbs; + } + /** * Checks if the level spawn protection radius will prevent the player from using items or building at the specified * Vector3 position.