skin === null){ $skinTag = $nbt->getCompoundTag("Skin"); if($skinTag === null or !self::isValidSkin($skinTag->getString("Data", "", true))){ throw new \InvalidStateException((new \ReflectionClass($this))->getShortName() . " must have a valid skin set"); } } parent::__construct($level, $nbt); } /** * Checks the length of a supplied skin bitmap and returns whether the length is valid. * * @param string $skin * * @return bool */ public static function isValidSkin(string $skin) : bool{ return strlen($skin) === 64 * 64 * 4 or strlen($skin) === 64 * 32 * 4; } /** * @return UUID|null */ public function getUniqueId(){ return $this->uuid; } /** * @return string */ public function getRawUniqueId() : string{ return $this->rawUUID; } /** * Returns a Skin object containing information about this human's skin. * @return Skin */ public function getSkin() : Skin{ return $this->skin; } /** * Sets the human's skin. This will not send any update to viewers, you need to do that manually using * {@link sendSkin}. * * @param Skin $skin */ public function setSkin(Skin $skin) : void{ if(!$skin->isValid()){ throw new \InvalidStateException("Specified skin is not valid, must be 8KiB or 16KiB"); } $this->skin = $skin; $this->skin->debloatGeometryData(); } /** * Sends the human's skin to the specified list of players. If null is given for targets, the skin will be sent to * all viewers. * * @param Player[]|null $targets */ public function sendSkin(array $targets = null) : void{ $pk = new PlayerSkinPacket(); $pk->uuid = $this->getUniqueId(); $pk->skin = $this->skin; $this->server->broadcastPacket($targets ?? $this->hasSpawned, $pk); } public function jump(){ parent::jump(); if($this->isSprinting()){ $this->exhaust(0.8, PlayerExhaustEvent::CAUSE_SPRINT_JUMPING); }else{ $this->exhaust(0.2, PlayerExhaustEvent::CAUSE_JUMPING); } } public function getFood() : float{ return $this->attributeMap->getAttribute(Attribute::HUNGER)->getValue(); } /** * WARNING: This method does not check if full and may throw an exception if out of bounds. * Use {@link Human::addFood()} for this purpose * * @param float $new * * @throws \InvalidArgumentException */ public function setFood(float $new){ $attr = $this->attributeMap->getAttribute(Attribute::HUNGER); $old = $attr->getValue(); $attr->setValue($new); $reset = false; // ranges: 18-20 (regen), 7-17 (none), 1-6 (no sprint), 0 (health depletion) foreach([17, 6, 0] as $bound){ if(($old > $bound) !== ($new > $bound)){ $reset = true; break; } } if($reset){ $this->foodTickTimer = 0; } } public function getMaxFood() : float{ return $this->attributeMap->getAttribute(Attribute::HUNGER)->getMaxValue(); } public function addFood(float $amount){ $attr = $this->attributeMap->getAttribute(Attribute::HUNGER); $amount += $attr->getValue(); $amount = max(min($amount, $attr->getMaxValue()), $attr->getMinValue()); $this->setFood($amount); } public function getSaturation() : float{ return $this->attributeMap->getAttribute(Attribute::SATURATION)->getValue(); } /** * WARNING: This method does not check if saturated and may throw an exception if out of bounds. * Use {@link Human::addSaturation()} for this purpose * * @param float $saturation * * @throws \InvalidArgumentException */ public function setSaturation(float $saturation){ $this->attributeMap->getAttribute(Attribute::SATURATION)->setValue($saturation); } public function addSaturation(float $amount){ $attr = $this->attributeMap->getAttribute(Attribute::SATURATION); $attr->setValue($attr->getValue() + $amount, true); } public function getExhaustion() : float{ return $this->attributeMap->getAttribute(Attribute::EXHAUSTION)->getValue(); } /** * WARNING: This method does not check if exhausted and does not consume saturation/food. * Use {@link Human::exhaust()} for this purpose. * * @param float $exhaustion */ public function setExhaustion(float $exhaustion){ $this->attributeMap->getAttribute(Attribute::EXHAUSTION)->setValue($exhaustion); } /** * Increases a human's exhaustion level. * * @param float $amount * @param int $cause * * @return float the amount of exhaustion level increased */ public function exhaust(float $amount, int $cause = PlayerExhaustEvent::CAUSE_CUSTOM) : float{ $this->server->getPluginManager()->callEvent($ev = new PlayerExhaustEvent($this, $amount, $cause)); if($ev->isCancelled()){ return 0.0; } $exhaustion = $this->getExhaustion(); $exhaustion += $ev->getAmount(); while($exhaustion >= 4.0){ $exhaustion -= 4.0; $saturation = $this->getSaturation(); if($saturation > 0){ $saturation = max(0, $saturation - 1.0); $this->setSaturation($saturation); }else{ $food = $this->getFood(); if($food > 0){ $food--; $this->setFood($food); } } } $this->setExhaustion($exhaustion); return $ev->getAmount(); } public function consumeObject(Consumable $consumable) : bool{ if($consumable instanceof FoodSource){ if($consumable->requiresHunger() and $this->getFood() >= $this->getMaxFood()){ return false; } $this->addFood($consumable->getFoodRestore()); $this->addSaturation($consumable->getSaturationRestore()); } 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(); } /** * 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(); } /** * 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); } /** * 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; } /** * 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"); } $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(){ return $this->inventory; } public function getEnderChestInventory(){ return $this->enderChestInventory; } /** * For Human entities which are not players, sets their properties such as nametag, skin and UUID from NBT. */ protected function initHumanData(){ if($this->namedtag->hasTag("NameTag", StringTag::class)){ $this->setNameTag($this->namedtag->getString("NameTag")); } $skin = $this->namedtag->getCompoundTag("Skin"); if($skin !== null){ $this->setSkin(new Skin( $skin->getString("Name"), $skin->getString("Data") )); } $this->uuid = UUID::fromData((string) $this->getId(), $this->skin->getSkinData(), $this->getNameTag()); } protected function initEntity(){ $this->setPlayerFlag(self::DATA_PLAYER_FLAG_SLEEP, false); $this->setDataProperty(self::DATA_PLAYER_BED_POSITION, self::DATA_TYPE_POS, [0, 0, 0], false); $this->inventory = new PlayerInventory($this); $this->enderChestInventory = new EnderChestInventory($this); $this->initHumanData(); $inventoryTag = $this->namedtag->getListTag("Inventory"); if($inventoryTag !== null){ /** @var CompoundTag $item */ foreach($inventoryTag as $i => $item){ $slot = $item->getByte("Slot"); if($slot >= 0 and $slot < 9){ //Hotbar //Old hotbar saving stuff, remove it (useless now) unset($inventoryTag[$i]); }elseif($slot >= 100 and $slot < 104){ //Armor $this->inventory->setItem($this->inventory->getSize() + $slot - 100, ItemItem::nbtDeserialize($item)); }else{ $this->inventory->setItem($slot - 9, ItemItem::nbtDeserialize($item)); } } } $enderChestInventoryTag = $this->namedtag->getListTag("EnderChestInventory"); if($enderChestInventoryTag !== null){ /** @var CompoundTag $item */ foreach($enderChestInventoryTag as $i => $item){ $this->enderChestInventory->setItem($item->getByte("Slot"), ItemItem::nbtDeserialize($item)); } } $this->inventory->setHeldItemIndex($this->namedtag->getInt("SelectedInventorySlot", 0), false); parent::initEntity(); $this->setFood((float) $this->namedtag->getInt("foodLevel", (int) $this->getFood(), true)); $this->setExhaustion($this->namedtag->getFloat("foodExhaustionLevel", $this->getExhaustion(), true)); $this->setSaturation($this->namedtag->getFloat("foodSaturationLevel", $this->getSaturation(), true)); $this->foodTickTimer = $this->namedtag->getInt("foodTickTimer", $this->foodTickTimer, true); $this->setXpLevel($this->namedtag->getInt("XpLevel", $this->getXpLevel(), true)); $this->setXpProgress($this->namedtag->getFloat("XpP", $this->getXpProgress(), true)); $this->totalXp = $this->namedtag->getInt("XpTotal", $this->totalXp, true); if($this->namedtag->hasTag("XpSeed", IntTag::class)){ $this->xpSeed = $this->namedtag->getInt("XpSeed"); }else{ $this->xpSeed = random_int(INT32_MIN, INT32_MAX); } } protected function addAttributes(){ parent::addAttributes(); $this->attributeMap->addAttribute(Attribute::getAttribute(Attribute::SATURATION)); $this->attributeMap->addAttribute(Attribute::getAttribute(Attribute::EXHAUSTION)); $this->attributeMap->addAttribute(Attribute::getAttribute(Attribute::HUNGER)); $this->attributeMap->addAttribute(Attribute::getAttribute(Attribute::EXPERIENCE_LEVEL)); $this->attributeMap->addAttribute(Attribute::getAttribute(Attribute::EXPERIENCE)); } public function entityBaseTick(int $tickDiff = 1) : bool{ $hasUpdate = parent::entityBaseTick($tickDiff); $this->doFoodTick($tickDiff); if($this->xpCooldown > 0){ $this->xpCooldown--; } return $hasUpdate; } public function doFoodTick(int $tickDiff = 1){ if($this->isAlive()){ $food = $this->getFood(); $health = $this->getHealth(); $difficulty = $this->level->getDifficulty(); $this->foodTickTimer += $tickDiff; if($this->foodTickTimer >= 80){ $this->foodTickTimer = 0; } if($difficulty === Level::DIFFICULTY_PEACEFUL and $this->foodTickTimer % 10 === 0){ if($food < 20){ $this->addFood(1.0); } if($this->foodTickTimer % 20 === 0 and $health < $this->getMaxHealth()){ $this->heal(new EntityRegainHealthEvent($this, 1, EntityRegainHealthEvent::CAUSE_SATURATION)); } } if($this->foodTickTimer === 0){ if($food >= 18){ if($health < $this->getMaxHealth()){ $this->heal(new EntityRegainHealthEvent($this, 1, EntityRegainHealthEvent::CAUSE_SATURATION)); $this->exhaust(3.0, PlayerExhaustEvent::CAUSE_HEALTH_REGEN); } }elseif($food <= 0){ if(($difficulty === 1 and $health > 10) or ($difficulty === 2 and $health > 1) or $difficulty === 3){ $this->attack(new EntityDamageEvent($this, EntityDamageEvent::CAUSE_STARVATION, 1)); } } } if($food <= 6){ if($this->isSprinting()){ $this->setSprinting(false); } } } } protected function doAirSupplyTick(int $tickDiff){ //TODO: allow this to apply to other mobs if(($ench = $this->inventory->getHelmet()->getEnchantment(Enchantment::RESPIRATION)) === null or lcg_value() <= (1 / ($ench->getLevel() + 1))){ parent::doAirSupplyTick($tickDiff); } } public function getName() : string{ return $this->getNameTag(); } public function getDrops() : array{ return $this->inventory !== null ? array_values($this->inventory->getContents()) : []; } public function saveNBT(){ parent::saveNBT(); $this->namedtag->setInt("foodLevel", (int) $this->getFood(), true); $this->namedtag->setFloat("foodExhaustionLevel", $this->getExhaustion(), true); $this->namedtag->setFloat("foodSaturationLevel", $this->getSaturation(), true); $this->namedtag->setInt("foodTickTimer", $this->foodTickTimer); $this->namedtag->setInt("XpLevel", $this->getXpLevel()); $this->namedtag->setFloat("XpP", $this->getXpProgress()); $this->namedtag->setInt("XpTotal", $this->totalXp); $this->namedtag->setInt("XpSeed", $this->xpSeed); $inventoryTag = new ListTag("Inventory", [], NBT::TAG_Compound); $this->namedtag->setTag($inventoryTag); if($this->inventory !== null){ //Normal inventory $slotCount = $this->inventory->getSize() + $this->inventory->getHotbarSize(); for($slot = $this->inventory->getHotbarSize(); $slot < $slotCount; ++$slot){ $item = $this->inventory->getItem($slot - 9); if(!$item->isNull()){ $inventoryTag[$slot] = $item->nbtSerialize($slot); } } //Armor for($slot = 100; $slot < 104; ++$slot){ $item = $this->inventory->getItem($this->inventory->getSize() + $slot - 100); if(!$item->isNull()){ $inventoryTag[$slot] = $item->nbtSerialize($slot); } } $this->namedtag->setInt("SelectedInventorySlot", $this->inventory->getHeldItemIndex()); } if($this->enderChestInventory !== null){ /** @var CompoundTag[] $items */ $items = []; $slotCount = $this->enderChestInventory->getSize(); for($slot = 0; $slot < $slotCount; ++$slot){ $item = $this->enderChestInventory->getItem($slot); if(!$item->isNull()){ $items[] = $item->nbtSerialize($slot); } } $this->namedtag->setTag(new ListTag("EnderChestInventory", $items, NBT::TAG_Compound)); } if($this->skin !== null){ $this->namedtag->setTag(new CompoundTag("Skin", [ //TODO: save cape & geometry new StringTag("Data", $this->skin->getSkinData()), new StringTag("Name", $this->skin->getSkinId()) ])); } } public function spawnTo(Player $player){ if($player !== $this){ parent::spawnTo($player); } } protected function sendSpawnPacket(Player $player) : void{ if(!$this->skin->isValid()){ throw new \InvalidStateException((new \ReflectionClass($this))->getShortName() . " must have a valid skin set"); } $pk = new AddPlayerPacket(); $pk->uuid = $this->getUniqueId(); $pk->username = $this->getName(); $pk->entityRuntimeId = $this->getId(); $pk->position = $this->asVector3(); $pk->motion = $this->getMotion(); $pk->yaw = $this->yaw; $pk->pitch = $this->pitch; $pk->item = $this->getInventory()->getItemInHand(); $pk->metadata = $this->dataProperties; $player->dataPacket($pk); $this->inventory->sendArmorContents($player); if(!($this instanceof Player)){ $this->sendSkin([$player]); } } public function close(){ if(!$this->closed){ if($this->inventory !== null){ $this->inventory->removeAllViewers(true); $this->inventory = null; } if($this->enderChestInventory !== null){ $this->enderChestInventory->removeAllViewers(true); $this->enderChestInventory = null; } parent::close(); } } /** * Wrapper around {@link Entity#getDataFlag} for player-specific data flag reading. * * @param int $flagId * @return bool */ public function getPlayerFlag(int $flagId) : bool{ return $this->getDataFlag(self::DATA_PLAYER_FLAGS, $flagId); } /** * Wrapper around {@link Entity#setDataFlag} for player-specific data flag setting. * * @param int $flagId * @param bool $value */ public function setPlayerFlag(int $flagId, bool $value = true){ $this->setDataFlag(self::DATA_PLAYER_FLAGS, $flagId, $value, self::DATA_TYPE_BYTE); } }