= 1 && $len <= 16 && preg_match("/[^A-Za-z0-9_ ]/", $name) === 0; } protected ?NetworkSession $networkSession; public bool $spawned = false; protected string $username; protected string $displayName; protected string $xuid = ""; protected bool $authenticated; protected PlayerInfo $playerInfo; protected ?Inventory $currentWindow = null; /** @var Inventory[] */ protected array $permanentWindows = []; protected PlayerCursorInventory $cursorInventory; protected PlayerCraftingInventory $craftingGrid; protected CreativeInventory $creativeInventory; protected int $messageCounter = 2; protected DateTimeImmutable $firstPlayed; protected DateTimeImmutable $lastPlayed; protected GameMode $gamemode; /** * @var UsedChunkStatus[] chunkHash => status * @phpstan-var array */ protected array $usedChunks = []; /** * @var true[] * @phpstan-var array */ private array $activeChunkGenerationRequests = []; /** * @var true[] chunkHash => dummy * @phpstan-var array */ protected array $loadQueue = []; protected int $nextChunkOrderRun = 5; /** @var true[] */ private array $tickingChunks = []; protected int $viewDistance = -1; protected int $spawnThreshold; protected int $spawnChunkLoadCount = 0; protected int $chunksPerTick; protected ChunkSelector $chunkSelector; protected ChunkLoader $chunkLoader; protected ChunkTicker $chunkTicker; /** @var bool[] map: raw UUID (string) => bool */ protected array $hiddenPlayers = []; protected float $moveRateLimit = 10 * self::MOVES_PER_TICK; protected ?float $lastMovementProcess = null; protected int $inAirTicks = 0; protected float $stepHeight = 0.6; protected ?Vector3 $sleeping = null; private ?Position $spawnPosition = null; private bool $respawnLocked = false; private ?Position $deathPosition = null; //TODO: Abilities protected bool $autoJump = true; protected bool $allowFlight = false; protected bool $blockCollision = true; protected bool $flying = false; protected float $flightSpeedMultiplier = self::DEFAULT_FLIGHT_SPEED_MULTIPLIER; /** @phpstan-var positive-int|null */ protected ?int $lineHeight = null; protected string $locale = "en_US"; protected int $startAction = -1; /** * @phpstan-var array * @var int[] stateId|cooldownTag => ticks map */ protected array $usedItemsCooldown = []; private int $lastEmoteTick = 0; protected int $formIdCounter = 0; /** @var Form[] */ protected array $forms = []; protected \Logger $logger; protected ?SurvivalBlockBreakHandler $blockBreakHandler = null; public function __construct(Server $server, NetworkSession $session, PlayerInfo $playerInfo, bool $authenticated, Location $spawnLocation, ?CompoundTag $namedtag){ $username = TextFormat::clean($playerInfo->getUsername()); $this->logger = new \PrefixedLogger($server->getLogger(), "Player: $username"); $this->server = $server; $this->networkSession = $session; $this->playerInfo = $playerInfo; $this->authenticated = $authenticated; $this->username = $username; $this->displayName = $this->username; $this->locale = $this->playerInfo->getLocale(); $this->uuid = $this->playerInfo->getUuid(); $this->xuid = $this->playerInfo instanceof XboxLivePlayerInfo ? $this->playerInfo->getXuid() : ""; $this->creativeInventory = CreativeInventory::getInstance(); $rootPermissions = [DefaultPermissions::ROOT_USER => true]; if($this->server->isOp($this->username)){ $rootPermissions[DefaultPermissions::ROOT_OPERATOR] = true; } $this->perm = new PermissibleBase($rootPermissions); $this->chunksPerTick = $this->server->getConfigGroup()->getPropertyInt(YmlServerProperties::CHUNK_SENDING_PER_TICK, 4); $this->spawnThreshold = (int) (($this->server->getConfigGroup()->getPropertyInt(YmlServerProperties::CHUNK_SENDING_SPAWN_RADIUS, 4) ** 2) * M_PI); $this->chunkSelector = new ChunkSelector(); $this->chunkLoader = new class implements ChunkLoader{}; $this->chunkTicker = new ChunkTicker(); $world = $spawnLocation->getWorld(); //load the spawn chunk so we can see the terrain $xSpawnChunk = $spawnLocation->getFloorX() >> Chunk::COORD_BIT_SIZE; $zSpawnChunk = $spawnLocation->getFloorZ() >> Chunk::COORD_BIT_SIZE; $world->registerChunkLoader($this->chunkLoader, $xSpawnChunk, $zSpawnChunk, true); $world->registerChunkListener($this, $xSpawnChunk, $zSpawnChunk); $this->usedChunks[World::chunkHash($xSpawnChunk, $zSpawnChunk)] = UsedChunkStatus::NEEDED; parent::__construct($spawnLocation, $this->playerInfo->getSkin(), $namedtag); } protected function initHumanData(CompoundTag $nbt) : void{ $this->setNameTag($this->username); } private function callDummyItemHeldEvent() : void{ $slot = $this->inventory->getHeldItemIndex(); $event = new PlayerItemHeldEvent($this, $this->inventory->getItem($slot), $slot); $event->call(); //TODO: this event is actually cancellable, but cancelling it here has no meaningful result, so we //just ignore it. We fire this only because the content of the held slot changed, not because the //held slot index changed. We can't prevent that from here, and nor would it be sensible to. } protected function initEntity(CompoundTag $nbt) : void{ parent::initEntity($nbt); $this->addDefaultWindows(); $this->inventory->getListeners()->add(new CallbackInventoryListener( function(Inventory $unused, int $slot) : void{ if($slot === $this->inventory->getHeldItemIndex()){ $this->setUsingItem(false); $this->callDummyItemHeldEvent(); } }, function() : void{ $this->setUsingItem(false); $this->callDummyItemHeldEvent(); } )); $now = (int) (microtime(true) * 1000); $createDateTimeImmutable = static function(string $tag) use ($nbt, $now) : DateTimeImmutable{ return new DateTimeImmutable('@' . $nbt->getLong($tag, $now) / 1000); }; $this->firstPlayed = $createDateTimeImmutable(self::TAG_FIRST_PLAYED); $this->lastPlayed = $createDateTimeImmutable(self::TAG_LAST_PLAYED); if(!$this->server->getForceGamemode() && ($gameModeTag = $nbt->getTag(self::TAG_GAME_MODE)) instanceof IntTag){ $this->internalSetGameMode(GameModeIdMap::getInstance()->fromId($gameModeTag->getValue()) ?? GameMode::SURVIVAL); //TODO: bad hack here to avoid crashes on corrupted data }else{ $this->internalSetGameMode($this->server->getGamemode()); } $this->keepMovement = true; $this->setNameTagVisible(); $this->setNameTagAlwaysVisible(); $this->setCanClimb(); if(($world = $this->server->getWorldManager()->getWorldByName($nbt->getString(self::TAG_SPAWN_WORLD, ""))) instanceof World){ $this->spawnPosition = new Position($nbt->getInt(self::TAG_SPAWN_X), $nbt->getInt(self::TAG_SPAWN_Y), $nbt->getInt(self::TAG_SPAWN_Z), $world); } if(($world = $this->server->getWorldManager()->getWorldByName($nbt->getString(self::TAG_DEATH_WORLD, ""))) instanceof World){ $this->deathPosition = new Position($nbt->getInt(self::TAG_DEATH_X), $nbt->getInt(self::TAG_DEATH_Y), $nbt->getInt(self::TAG_DEATH_Z), $world); } } public function getLeaveMessage() : Translatable|string{ if($this->spawned){ return KnownTranslationFactory::multiplayer_player_left($this->getDisplayName())->prefix(TextFormat::YELLOW); } return ""; } public function isAuthenticated() : bool{ return $this->authenticated; } /** * Returns an object containing information about the player, such as their username, skin, and misc extra * client-specific data. */ public function getPlayerInfo() : PlayerInfo{ return $this->playerInfo; } /** * If the player is logged into Xbox Live, returns their Xbox user ID (XUID) as a string. Returns an empty string if * the player is not logged into Xbox Live. */ public function getXuid() : string{ return $this->xuid; } /** * Returns the player's UUID. This should be the preferred method to identify a player. * It does not change if the player changes their username. * * All players will have a UUID, regardless of whether they are logged into Xbox Live or not. However, note that * non-XBL players can fake their UUIDs. */ public function getUniqueId() : UuidInterface{ return parent::getUniqueId(); } /** * TODO: not sure this should be nullable */ public function getFirstPlayed() : ?DateTimeImmutable{ return $this->firstPlayed; } /** * TODO: not sure this should be nullable */ public function getLastPlayed() : ?DateTimeImmutable{ return $this->lastPlayed; } public function hasPlayedBefore() : bool{ return ((int) $this->firstPlayed->diff($this->lastPlayed)->format('%s')) > 1; } /** * Sets whether the player is allowed to toggle flight mode. * * If set to false, the player will be locked in its current flight mode (flying/not flying), and attempts by the * player to enter or exit flight mode will be prevented. * * Note: Setting this to false DOES NOT change whether the player is currently flying. Use * {@link Player::setFlying()} for that purpose. */ public function setAllowFlight(bool $value) : void{ if($this->allowFlight !== $value){ $this->allowFlight = $value; $this->getNetworkSession()->syncAbilities($this); } } /** * Returns whether the player is allowed to toggle its flight state. * * If false, the player is locked in its current flight mode (flying/not flying), and attempts by the player to * enter or exit flight mode will be prevented. */ public function getAllowFlight() : bool{ return $this->allowFlight; } /** * Sets whether the player's movement may be obstructed by blocks with collision boxes. * If set to false, the player can move through any block unobstructed. * * Note: Enabling flight mode in conjunction with this is recommended. A non-flying player will simply fall through * the ground into the void. * @see Player::setFlying() */ public function setHasBlockCollision(bool $value) : void{ if($this->blockCollision !== $value){ $this->blockCollision = $value; $this->getNetworkSession()->syncAbilities($this); } } /** * Returns whether blocks may obstruct the player's movement. * If false, the player can move through any block unobstructed. */ public function hasBlockCollision() : bool{ return $this->blockCollision; } public function setFlying(bool $value) : void{ if($this->flying !== $value){ $this->flying = $value; $this->resetFallDistance(); $this->getNetworkSession()->syncAbilities($this); } } public function isFlying() : bool{ return $this->flying; } /** * Sets the player's flight speed multiplier. * * Normal flying speed in blocks-per-tick is (multiplier * 10) blocks per tick. * When sprint-flying, this is doubled to 20. * * If set to zero, the player will not be able to move in the xz plane when flying. * Negative values will invert the controls. * * Note: Movement speed attribute does not influence flight speed. * * @see Player::DEFAULT_FLIGHT_SPEED_MULTIPLIER */ public function setFlightSpeedMultiplier(float $flightSpeedMultiplier) : void{ if($this->flightSpeedMultiplier !== $flightSpeedMultiplier){ $this->flightSpeedMultiplier = $flightSpeedMultiplier; $this->getNetworkSession()->syncAbilities($this); } } /** * Returns the player's flight speed multiplier. * * Normal flying speed in blocks-per-tick is (multiplier * 10) blocks per tick. * When sprint-flying, this is doubled to 20. * * If set to zero, the player will not be able to move in the xz plane when flying. * Negative values will invert the controls. * * @see Player::DEFAULT_FLIGHT_SPEED_MULTIPLIER */ public function getFlightSpeedMultiplier() : float{ return $this->flightSpeedMultiplier; } public function setAutoJump(bool $value) : void{ if($this->autoJump !== $value){ $this->autoJump = $value; $this->getNetworkSession()->syncAdventureSettings(); } } public function hasAutoJump() : bool{ return $this->autoJump; } public function spawnTo(Player $player) : void{ if($this->isAlive() && $player->isAlive() && $player->canSee($this) && !$this->isSpectator()){ parent::spawnTo($player); } } public function getServer() : Server{ return $this->server; } public function getScreenLineHeight() : int{ return $this->lineHeight ?? 7; } public function setScreenLineHeight(?int $height) : void{ if($height !== null && $height < 1){ throw new \InvalidArgumentException("Line height must be at least 1"); } $this->lineHeight = $height; } public function canSee(Player $player) : bool{ return !isset($this->hiddenPlayers[$player->getUniqueId()->getBytes()]); } public function hidePlayer(Player $player) : void{ if($player === $this){ return; } $this->hiddenPlayers[$player->getUniqueId()->getBytes()] = true; $player->despawnFrom($this); } public function showPlayer(Player $player) : void{ if($player === $this){ return; } unset($this->hiddenPlayers[$player->getUniqueId()->getBytes()]); if($player->isOnline()){ $player->spawnTo($this); } } public function canCollideWith(Entity $entity) : bool{ return false; } public function canBeCollidedWith() : bool{ return !$this->isSpectator() && parent::canBeCollidedWith(); } public function resetFallDistance() : void{ parent::resetFallDistance(); $this->inAirTicks = 0; } public function getViewDistance() : int{ return $this->viewDistance; } public function setViewDistance(int $distance) : void{ $newViewDistance = $this->server->getAllowedViewDistance($distance); if($newViewDistance !== $this->viewDistance){ $ev = new PlayerViewDistanceChangeEvent($this, $this->viewDistance, $newViewDistance); $ev->call(); } $this->viewDistance = $newViewDistance; $this->spawnThreshold = (int) (min($this->viewDistance, $this->server->getConfigGroup()->getPropertyInt(YmlServerProperties::CHUNK_SENDING_SPAWN_RADIUS, 4)) ** 2 * M_PI); $this->nextChunkOrderRun = 0; $this->getNetworkSession()->syncViewAreaRadius($this->viewDistance); $this->logger->debug("Setting view distance to " . $this->viewDistance . " (requested " . $distance . ")"); } public function isOnline() : bool{ return $this->isConnected(); } public function isConnected() : bool{ return $this->networkSession !== null && $this->networkSession->isConnected(); } public function getNetworkSession() : NetworkSession{ if($this->networkSession === null){ throw new \LogicException("Player is not connected"); } return $this->networkSession; } /** * Gets the username */ public function getName() : string{ return $this->username; } /** * Returns the "friendly" display name of this player to use in the chat. */ public function getDisplayName() : string{ return $this->displayName; } public function setDisplayName(string $name) : void{ $ev = new PlayerDisplayNameChangeEvent($this, $this->displayName, $name); $ev->call(); $this->displayName = $ev->getNewName(); } public function canBeRenamed() : bool{ return false; } /** * Returns the player's locale, e.g. en_US. */ public function getLocale() : string{ return $this->locale; } public function getLanguage() : Language{ return $this->server->getLanguage(); } /** * Called when a player changes their skin. * Plugin developers should not use this, use setSkin() and sendSkin() instead. */ public function changeSkin(Skin $skin, string $newSkinName, string $oldSkinName) : bool{ $ev = new PlayerChangeSkinEvent($this, $this->getSkin(), $skin); $ev->call(); if($ev->isCancelled()){ $this->sendSkin([$this]); return true; } $this->setSkin($ev->getNewSkin()); $this->sendSkin($this->server->getOnlinePlayers()); return true; } /** * {@inheritdoc} * * If null is given, will additionally send the skin to the player itself as well as its viewers. */ public function sendSkin(?array $targets = null) : void{ parent::sendSkin($targets ?? $this->server->getOnlinePlayers()); } /** * Returns whether the player is currently using an item (right-click and hold). */ public function isUsingItem() : bool{ return $this->startAction > -1; } public function setUsingItem(bool $value) : void{ $this->startAction = $value ? $this->server->getTick() : -1; $this->networkPropertiesDirty = true; } /** * Returns how long the player has been using their currently-held item for. Used for determining arrow shoot force * for bows. */ public function getItemUseDuration() : int{ return $this->startAction === -1 ? -1 : ($this->server->getTick() - $this->startAction); } /** * Returns the server tick on which the player's cooldown period expires for the given item. */ public function getItemCooldownExpiry(Item $item) : int{ $this->checkItemCooldowns(); return $this->usedItemsCooldown[$item->getCooldownTag() ?? $item->getStateId()] ?? 0; } /** * Returns whether the player has a cooldown period left before it can use the given item again. */ public function hasItemCooldown(Item $item) : bool{ $this->checkItemCooldowns(); return isset($this->usedItemsCooldown[$item->getCooldownTag() ?? $item->getStateId()]); } /** * Resets the player's cooldown time for the given item back to the maximum. */ public function resetItemCooldown(Item $item, ?int $ticks = null) : void{ $ticks = $ticks ?? $item->getCooldownTicks(); if($ticks > 0){ $this->usedItemsCooldown[$item->getCooldownTag() ?? $item->getStateId()] = $this->server->getTick() + $ticks; $this->getNetworkSession()->onItemCooldownChanged($item, $ticks); } } protected function checkItemCooldowns() : void{ $serverTick = $this->server->getTick(); foreach($this->usedItemsCooldown as $itemId => $cooldownUntil){ if($cooldownUntil <= $serverTick){ unset($this->usedItemsCooldown[$itemId]); } } } protected function setPosition(Vector3 $pos) : bool{ $oldWorld = $this->location->isValid() ? $this->location->getWorld() : null; if(parent::setPosition($pos)){ $newWorld = $this->getWorld(); if($oldWorld !== $newWorld){ if($oldWorld !== null){ foreach($this->usedChunks as $index => $status){ World::getXZ($index, $X, $Z); $this->unloadChunk($X, $Z, $oldWorld); } } $this->usedChunks = []; $this->loadQueue = []; $this->getNetworkSession()->onEnterWorld(); } return true; } return false; } protected function unloadChunk(int $x, int $z, ?World $world = null) : void{ $world = $world ?? $this->getWorld(); $index = World::chunkHash($x, $z); if(isset($this->usedChunks[$index])){ foreach($world->getChunkEntities($x, $z) as $entity){ if($entity !== $this){ $entity->despawnFrom($this); } } $this->getNetworkSession()->stopUsingChunk($x, $z); unset($this->usedChunks[$index]); unset($this->activeChunkGenerationRequests[$index]); } $world->unregisterChunkLoader($this->chunkLoader, $x, $z); $world->unregisterChunkListener($this, $x, $z); unset($this->loadQueue[$index]); $world->unregisterTickingChunk($this->chunkTicker, $x, $z); unset($this->tickingChunks[$index]); } protected function spawnEntitiesOnAllChunks() : void{ foreach($this->usedChunks as $chunkHash => $status){ if($status === UsedChunkStatus::SENT){ World::getXZ($chunkHash, $chunkX, $chunkZ); $this->spawnEntitiesOnChunk($chunkX, $chunkZ); } } } protected function spawnEntitiesOnChunk(int $chunkX, int $chunkZ) : void{ foreach($this->getWorld()->getChunkEntities($chunkX, $chunkZ) as $entity){ if($entity !== $this && !$entity->isFlaggedForDespawn()){ $entity->spawnTo($this); } } } /** * Requests chunks from the world to be sent, up to a set limit every tick. This operates on the results of the most recent chunk * order. */ protected function requestChunks() : void{ if(!$this->isConnected()){ return; } Timings::$playerChunkSend->startTiming(); $count = 0; $world = $this->getWorld(); $limit = $this->chunksPerTick - count($this->activeChunkGenerationRequests); foreach($this->loadQueue as $index => $distance){ if($count >= $limit){ break; } $X = null; $Z = null; World::getXZ($index, $X, $Z); ++$count; $this->usedChunks[$index] = UsedChunkStatus::REQUESTED_GENERATION; $this->activeChunkGenerationRequests[$index] = true; unset($this->loadQueue[$index]); $world->registerChunkLoader($this->chunkLoader, $X, $Z, true); $world->registerChunkListener($this, $X, $Z); if(isset($this->tickingChunks[$index])){ $world->registerTickingChunk($this->chunkTicker, $X, $Z); } $world->requestChunkPopulation($X, $Z, $this->chunkLoader)->onCompletion( function() use ($X, $Z, $index, $world) : void{ if(!$this->isConnected() || !isset($this->usedChunks[$index]) || $world !== $this->getWorld()){ return; } if($this->usedChunks[$index] !== UsedChunkStatus::REQUESTED_GENERATION){ //We may have previously requested this, decided we didn't want it, and then decided we did want //it again, all before the generation request got executed. In that case, the promise would have //multiple callbacks for this player. In that case, only the first one matters. return; } unset($this->activeChunkGenerationRequests[$index]); $this->usedChunks[$index] = UsedChunkStatus::REQUESTED_SENDING; $this->getNetworkSession()->startUsingChunk($X, $Z, function() use ($X, $Z, $index) : void{ $this->usedChunks[$index] = UsedChunkStatus::SENT; if($this->spawnChunkLoadCount === -1){ $this->spawnEntitiesOnChunk($X, $Z); }elseif($this->spawnChunkLoadCount++ === $this->spawnThreshold){ $this->spawnChunkLoadCount = -1; $this->spawnEntitiesOnAllChunks(); $this->getNetworkSession()->notifyTerrainReady(); } (new PlayerPostChunkSendEvent($this, $X, $Z))->call(); }); }, static function() : void{ //NOOP: we'll re-request this if it fails anyway } ); } Timings::$playerChunkSend->stopTiming(); } private function recheckBroadcastPermissions() : void{ foreach([ DefaultPermissionNames::BROADCAST_ADMIN => Server::BROADCAST_CHANNEL_ADMINISTRATIVE, DefaultPermissionNames::BROADCAST_USER => Server::BROADCAST_CHANNEL_USERS ] as $permission => $channel){ if($this->hasPermission($permission)){ $this->server->subscribeToBroadcastChannel($channel, $this); }else{ $this->server->unsubscribeFromBroadcastChannel($channel, $this); } } } /** * Called by the network system when the pre-spawn sequence is completed (e.g. after sending spawn chunks). * This fires join events and broadcasts join messages to other online players. */ public function doFirstSpawn() : void{ if($this->spawned){ return; } $this->spawned = true; $this->recheckBroadcastPermissions(); $this->getPermissionRecalculationCallbacks()->add(function(array $changedPermissionsOldValues) : void{ if(isset($changedPermissionsOldValues[Server::BROADCAST_CHANNEL_ADMINISTRATIVE]) || isset($changedPermissionsOldValues[Server::BROADCAST_CHANNEL_USERS])){ $this->recheckBroadcastPermissions(); } }); $ev = new PlayerJoinEvent($this, KnownTranslationFactory::multiplayer_player_joined($this->getDisplayName())->prefix(TextFormat::YELLOW) ); $ev->call(); if($ev->getJoinMessage() !== ""){ $this->server->broadcastMessage($ev->getJoinMessage()); } $this->noDamageTicks = 60; $this->spawnToAll(); if($this->getHealth() <= 0){ $this->logger->debug("Quit while dead, forcing respawn"); $this->actuallyRespawn(); } } /** * @param true[] $oldTickingChunks * @param true[] $newTickingChunks * * @phpstan-param array $oldTickingChunks * @phpstan-param array $newTickingChunks */ private function updateTickingChunkRegistrations(array $oldTickingChunks, array $newTickingChunks) : void{ $world = $this->getWorld(); foreach($oldTickingChunks as $hash => $_){ if(!isset($newTickingChunks[$hash]) && !isset($this->loadQueue[$hash])){ //we are (probably) still using this chunk, but it's no longer within ticking range World::getXZ($hash, $tickingChunkX, $tickingChunkZ); $world->unregisterTickingChunk($this->chunkTicker, $tickingChunkX, $tickingChunkZ); } } foreach($newTickingChunks as $hash => $_){ if(!isset($oldTickingChunks[$hash]) && !isset($this->loadQueue[$hash])){ //we were already using this chunk, but it is now within ticking range World::getXZ($hash, $tickingChunkX, $tickingChunkZ); $world->registerTickingChunk($this->chunkTicker, $tickingChunkX, $tickingChunkZ); } } } /** * Calculates which new chunks this player needs to use, and which currently-used chunks it needs to stop using. * This is based on factors including the player's current render radius and current position. */ protected function orderChunks() : void{ if(!$this->isConnected() || $this->viewDistance === -1){ return; } Timings::$playerChunkOrder->startTiming(); $newOrder = []; $tickingChunks = []; $unloadChunks = $this->usedChunks; $world = $this->getWorld(); $tickingChunkRadius = $world->getChunkTickRadius(); foreach($this->chunkSelector->selectChunks( $this->server->getAllowedViewDistance($this->viewDistance), $this->location->getFloorX() >> Chunk::COORD_BIT_SIZE, $this->location->getFloorZ() >> Chunk::COORD_BIT_SIZE ) as $radius => $hash){ if(!isset($this->usedChunks[$hash]) || $this->usedChunks[$hash] === UsedChunkStatus::NEEDED){ $newOrder[$hash] = true; } if($radius < $tickingChunkRadius){ $tickingChunks[$hash] = true; } unset($unloadChunks[$hash]); } foreach($unloadChunks as $index => $status){ World::getXZ($index, $X, $Z); $this->unloadChunk($X, $Z); } $this->loadQueue = $newOrder; $this->updateTickingChunkRegistrations($this->tickingChunks, $tickingChunks); $this->tickingChunks = $tickingChunks; if(count($this->loadQueue) > 0 || count($unloadChunks) > 0){ $this->getNetworkSession()->syncViewAreaCenterPoint($this->location, $this->viewDistance); } Timings::$playerChunkOrder->stopTiming(); } /** * Returns whether the player is using the chunk with the given coordinates, irrespective of whether the chunk has * been sent yet. */ public function isUsingChunk(int $chunkX, int $chunkZ) : bool{ return isset($this->usedChunks[World::chunkHash($chunkX, $chunkZ)]); } /** * @return UsedChunkStatus[] chunkHash => status * @phpstan-return array */ public function getUsedChunks() : array{ return $this->usedChunks; } /** * Returns a usage status of the given chunk, or null if the player is not using the given chunk. */ public function getUsedChunkStatus(int $chunkX, int $chunkZ) : ?UsedChunkStatus{ return $this->usedChunks[World::chunkHash($chunkX, $chunkZ)] ?? null; } /** * Returns whether the target chunk has been sent to this player. */ public function hasReceivedChunk(int $chunkX, int $chunkZ) : bool{ $status = $this->usedChunks[World::chunkHash($chunkX, $chunkZ)] ?? null; return $status === UsedChunkStatus::SENT; } /** * Ticks the chunk-requesting mechanism. */ public function doChunkRequests() : void{ if($this->nextChunkOrderRun !== PHP_INT_MAX && $this->nextChunkOrderRun-- <= 0){ $this->nextChunkOrderRun = PHP_INT_MAX; $this->orderChunks(); } if(count($this->loadQueue) > 0){ $this->requestChunks(); } } public function getDeathPosition() : ?Position{ if($this->deathPosition !== null && !$this->deathPosition->isValid()){ $this->deathPosition = null; } return $this->deathPosition; } /** * @param Vector3|Position|null $pos */ public function setDeathPosition(?Vector3 $pos) : void{ if($pos !== null){ if($pos instanceof Position && $pos->world !== null){ $world = $pos->world; }else{ $world = $this->getWorld(); } $this->deathPosition = new Position($pos->x, $pos->y, $pos->z, $world); }else{ $this->deathPosition = null; } $this->networkPropertiesDirty = true; } /** * @return Position */ public function getSpawn(){ if($this->hasValidCustomSpawn()){ return $this->spawnPosition; }else{ $world = $this->server->getWorldManager()->getDefaultWorld(); return $world->getSpawnLocation(); } } public function hasValidCustomSpawn() : bool{ return $this->spawnPosition !== null && $this->spawnPosition->isValid(); } /** * Sets the spawnpoint of the player (and the compass direction) to a Vector3, or set it on another world with a * Position object * * @param Vector3|Position|null $pos */ public function setSpawn(?Vector3 $pos) : void{ if($pos !== null){ if(!($pos instanceof Position)){ $world = $this->getWorld(); }else{ $world = $pos->getWorld(); } $this->spawnPosition = new Position($pos->x, $pos->y, $pos->z, $world); }else{ $this->spawnPosition = null; } $this->getNetworkSession()->syncPlayerSpawnPoint($this->getSpawn()); } public function isSleeping() : bool{ return $this->sleeping !== null; } public function sleepOn(Vector3 $pos) : bool{ $pos = $pos->floor(); $b = $this->getWorld()->getBlock($pos); $ev = new PlayerBedEnterEvent($this, $b); $ev->call(); if($ev->isCancelled()){ return false; } if($b instanceof Bed){ $b->setOccupied(); $this->getWorld()->setBlock($pos, $b); } $this->sleeping = $pos; $this->networkPropertiesDirty = true; $this->setSpawn($pos); $this->getWorld()->setSleepTicks(60); return true; } public function stopSleep() : void{ if($this->sleeping instanceof Vector3){ $b = $this->getWorld()->getBlock($this->sleeping); if($b instanceof Bed){ $b->setOccupied(false); $this->getWorld()->setBlock($this->sleeping, $b); } (new PlayerBedLeaveEvent($this, $b))->call(); $this->sleeping = null; $this->networkPropertiesDirty = true; $this->getWorld()->setSleepTicks(0); $this->getNetworkSession()->sendDataPacket(AnimatePacket::create($this->getId(), AnimatePacket::ACTION_STOP_SLEEP)); } } public function getGamemode() : GameMode{ return $this->gamemode; } protected function internalSetGameMode(GameMode $gameMode) : void{ $this->gamemode = $gameMode; $this->allowFlight = $this->gamemode === GameMode::CREATIVE; $this->hungerManager->setEnabled($this->isSurvival()); if($this->isSpectator()){ $this->setFlying(true); $this->setHasBlockCollision(false); $this->setSilent(); $this->onGround = false; //TODO: HACK! this syncs the onground flag with the client so that flying works properly //this is a yucky hack but we don't have any other options :( $this->sendPosition($this->location, null, null, MovePlayerPacket::MODE_TELEPORT); }else{ if($this->isSurvival()){ $this->setFlying(false); } $this->setHasBlockCollision(true); $this->setSilent(false); $this->checkGroundState(0, 0, 0, 0, 0, 0); } } /** * Sets the provided gamemode. */ public function setGamemode(GameMode $gm) : bool{ if($this->gamemode === $gm){ return false; } $ev = new PlayerGameModeChangeEvent($this, $gm); $ev->call(); if($ev->isCancelled()){ return false; } $this->internalSetGameMode($gm); if($this->isSpectator()){ $this->despawnFromAll(); }else{ $this->spawnToAll(); } $this->getNetworkSession()->syncGameMode($this->gamemode); return true; } /** * NOTE: Because Survival and Adventure Mode share some similar behaviour, this method will also return true if the player is * in Adventure Mode. Supply the $literal parameter as true to force a literal Survival Mode check. * * @param bool $literal whether a literal check should be performed */ public function isSurvival(bool $literal = false) : bool{ return $this->gamemode === GameMode::SURVIVAL || (!$literal && $this->gamemode === GameMode::ADVENTURE); } /** * NOTE: Because Creative and Spectator Mode share some similar behaviour, this method will also return true if the player is * in Spectator Mode. Supply the $literal parameter as true to force a literal Creative Mode check. * * @param bool $literal whether a literal check should be performed */ public function isCreative(bool $literal = false) : bool{ return $this->gamemode === GameMode::CREATIVE || (!$literal && $this->gamemode === GameMode::SPECTATOR); } /** * NOTE: Because Adventure and Spectator Mode share some similar behaviour, this method will also return true if the player is * in Spectator Mode. Supply the $literal parameter as true to force a literal Adventure Mode check. * * @param bool $literal whether a literal check should be performed */ public function isAdventure(bool $literal = false) : bool{ return $this->gamemode === GameMode::ADVENTURE || (!$literal && $this->gamemode === GameMode::SPECTATOR); } public function isSpectator() : bool{ return $this->gamemode === GameMode::SPECTATOR; } /** * TODO: make this a dynamic ability instead of being hardcoded */ public function hasFiniteResources() : bool{ return $this->gamemode !== GameMode::CREATIVE; } public function getDrops() : array{ if($this->hasFiniteResources()){ return parent::getDrops(); } return []; } public function getXpDropAmount() : int{ if($this->hasFiniteResources()){ return parent::getXpDropAmount(); } return 0; } protected function checkGroundState(float $wantedX, float $wantedY, float $wantedZ, float $dx, float $dy, float $dz) : void{ if($this->gamemode === GameMode::SPECTATOR){ $this->onGround = false; }else{ $bb = clone $this->boundingBox; $bb->minY = $this->location->y - 0.2; $bb->maxY = $this->location->y + 0.2; //we're already at the new position at this point; check if there are blocks we might have landed on between //the old and new positions (running down stairs necessitates this) $bb = $bb->addCoord(-$dx, -$dy, -$dz); $this->onGround = $this->isCollided = count($this->getWorld()->getCollisionBlocks($bb, true)) > 0; } } public function canBeMovedByCurrents() : bool{ return false; //currently has no server-side movement } protected function checkNearEntities() : void{ foreach($this->getWorld()->getNearbyEntities($this->boundingBox->expandedCopy(1, 0.5, 1), $this) as $entity){ $entity->scheduleUpdate(); if(!$entity->isAlive() || $entity->isFlaggedForDespawn()){ continue; } $entity->onCollideWithPlayer($this); } } public function getInAirTicks() : int{ return $this->inAirTicks; } /** * Attempts to move the player to the given coordinates. Unless you have some particularly specialized logic, you * probably want to use teleport() instead of this. * * This is used for processing movements sent by the player over network. * * @param Vector3 $newPos Coordinates of the player's feet, centered horizontally at the base of their bounding box. */ public function handleMovement(Vector3 $newPos) : void{ Timings::$playerMove->startTiming(); try{ $this->actuallyHandleMovement($newPos); }finally{ Timings::$playerMove->stopTiming(); } } private function actuallyHandleMovement(Vector3 $newPos) : void{ $this->moveRateLimit--; if($this->moveRateLimit < 0){ return; } $oldPos = $this->location; $distanceSquared = $newPos->distanceSquared($oldPos); $revert = false; if($distanceSquared > 225){ //15 blocks //TODO: this is probably too big if we process every movement /* !!! BEWARE YE WHO ENTER HERE !!! * * This is NOT an anti-cheat check. It is a safety check. * Without it hackers can teleport with freedom on their own and cause lots of undesirable behaviour, like * freezes, lag spikes and memory exhaustion due to sync chunk loading and collision checks across large distances. * Not only that, but high-latency players can trigger such behaviour innocently. * * If you must tamper with this code, be aware that this can cause very nasty results. Do not waste our time * asking for help if you suffer the consequences of messing with this. */ $this->logger->debug("Moved too fast (" . sqrt($distanceSquared) . " blocks in 1 movement), reverting movement"); $this->logger->debug("Old position: " . $oldPos->asVector3() . ", new position: " . $newPos); $revert = true; }elseif(!$this->getWorld()->isInLoadedTerrain($newPos)){ $revert = true; $this->nextChunkOrderRun = 0; } if(!$revert && $distanceSquared !== 0.0){ $dx = $newPos->x - $oldPos->x; $dy = $newPos->y - $oldPos->y; $dz = $newPos->z - $oldPos->z; $this->move($dx, $dy, $dz); } if($revert){ $this->revertMovement($oldPos); } } /** * Fires movement events and synchronizes player movement, every tick. */ protected function processMostRecentMovements() : void{ $now = microtime(true); $multiplier = $this->lastMovementProcess !== null ? ($now - $this->lastMovementProcess) * 20 : 1; $exceededRateLimit = $this->moveRateLimit < 0; $this->moveRateLimit = min(self::MOVE_BACKLOG_SIZE, max(0, $this->moveRateLimit) + self::MOVES_PER_TICK * $multiplier); $this->lastMovementProcess = $now; $from = clone $this->lastLocation; $to = clone $this->location; $delta = $to->distanceSquared($from); $deltaAngle = abs($this->lastLocation->yaw - $to->yaw) + abs($this->lastLocation->pitch - $to->pitch); if($delta > 0.0001 || $deltaAngle > 1.0){ if(PlayerMoveEvent::hasHandlers()){ $ev = new PlayerMoveEvent($this, $from, $to); $ev->call(); if($ev->isCancelled()){ $this->revertMovement($from); return; } if($to->distanceSquared($ev->getTo()) > 0.01){ //If plugins modify the destination $this->teleport($ev->getTo()); return; } } $this->lastLocation = $to; $this->broadcastMovement(); $horizontalDistanceTravelled = sqrt((($from->x - $to->x) ** 2) + (($from->z - $to->z) ** 2)); if($horizontalDistanceTravelled > 0){ //TODO: check for swimming if($this->isSprinting()){ $this->hungerManager->exhaust(0.01 * $horizontalDistanceTravelled, PlayerExhaustEvent::CAUSE_SPRINTING); }else{ $this->hungerManager->exhaust(0.0, PlayerExhaustEvent::CAUSE_WALKING); } if($this->nextChunkOrderRun > 20){ $this->nextChunkOrderRun = 20; } } } if($exceededRateLimit){ //client and server positions will be out of sync if this happens $this->logger->debug("Exceeded movement rate limit, forcing to last accepted position"); $this->sendPosition($this->location, $this->location->getYaw(), $this->location->getPitch(), MovePlayerPacket::MODE_RESET); } } protected function revertMovement(Location $from) : void{ $this->setPosition($from); $this->sendPosition($from, $from->yaw, $from->pitch, MovePlayerPacket::MODE_RESET); } protected function calculateFallDamage(float $fallDistance) : float{ return $this->flying ? 0 : parent::calculateFallDamage($fallDistance); } public function jump() : void{ (new PlayerJumpEvent($this))->call(); parent::jump(); } public function setMotion(Vector3 $motion) : bool{ if(parent::setMotion($motion)){ $this->broadcastMotion(); $this->getNetworkSession()->sendDataPacket(SetActorMotionPacket::create($this->id, $motion, tick: 0)); return true; } return false; } protected function updateMovement(bool $teleport = false) : void{ } protected function tryChangeMovement() : void{ } public function onUpdate(int $currentTick) : bool{ $tickDiff = $currentTick - $this->lastUpdate; if($tickDiff <= 0){ return true; } $this->messageCounter = 2; $this->lastUpdate = $currentTick; if($this->justCreated){ $this->onFirstUpdate($currentTick); } if(!$this->isAlive() && $this->spawned){ $this->onDeathUpdate($tickDiff); return true; } $this->timings->startTiming(); if($this->spawned){ Timings::$playerMove->startTiming(); $this->processMostRecentMovements(); $this->motion = Vector3::zero(); //TODO: HACK! (Fixes player knockback being messed up) if($this->onGround){ $this->inAirTicks = 0; }else{ $this->inAirTicks += $tickDiff; } Timings::$playerMove->stopTiming(); Timings::$entityBaseTick->startTiming(); $this->entityBaseTick($tickDiff); Timings::$entityBaseTick->stopTiming(); if($this->isCreative() && $this->fireTicks > 1){ $this->fireTicks = 1; } if(!$this->isSpectator() && $this->isAlive()){ Timings::$playerCheckNearEntities->startTiming(); $this->checkNearEntities(); Timings::$playerCheckNearEntities->stopTiming(); } if($this->blockBreakHandler !== null && !$this->blockBreakHandler->update()){ $this->blockBreakHandler = null; } } $this->timings->stopTiming(); return true; } public function canEat() : bool{ return $this->isCreative() || parent::canEat(); } public function canBreathe() : bool{ return $this->isCreative() || parent::canBreathe(); } /** * Returns whether the player can interact with the specified position. This checks distance and direction. * * @param float $maxDiff defaults to half of the 3D diagonal width of a block */ public function canInteract(Vector3 $pos, float $maxDistance, float $maxDiff = M_SQRT3 / 2) : bool{ $eyePos = $this->getEyePos(); if($eyePos->distanceSquared($pos) > $maxDistance ** 2){ return false; } $dV = $this->getDirectionVector(); $eyeDot = $dV->dot($eyePos); $targetDot = $dV->dot($pos); return ($targetDot - $eyeDot) >= -$maxDiff; } /** * Sends a chat message as this player. If the message begins with a / (forward-slash) it will be treated * as a command. */ public function chat(string $message) : bool{ $this->removeCurrentWindow(); if($this->messageCounter <= 0){ //the check below would take care of this (0 * (maxlen + 1) = 0), but it's better be explicit return false; } //Fast length check, to make sure we don't get hung trying to explode MBs of string ... $maxTotalLength = $this->messageCounter * (self::MAX_CHAT_BYTE_LENGTH + 1); if(strlen($message) > $maxTotalLength){ return false; } $message = TextFormat::clean($message, false); foreach(explode("\n", $message, $this->messageCounter + 1) as $messagePart){ if(trim($messagePart) !== "" && strlen($messagePart) <= self::MAX_CHAT_BYTE_LENGTH && mb_strlen($messagePart, 'UTF-8') <= self::MAX_CHAT_CHAR_LENGTH && $this->messageCounter-- > 0){ if(str_starts_with($messagePart, './')){ $messagePart = substr($messagePart, 1); } if(str_starts_with($messagePart, "/")){ Timings::$playerCommand->startTiming(); $this->server->dispatchCommand($this, substr($messagePart, 1)); Timings::$playerCommand->stopTiming(); }else{ $ev = new PlayerChatEvent($this, $messagePart, $this->server->getBroadcastChannelSubscribers(Server::BROADCAST_CHANNEL_USERS), new StandardChatFormatter()); $ev->call(); if(!$ev->isCancelled()){ $this->server->broadcastMessage($ev->getFormatter()->format($ev->getPlayer()->getDisplayName(), $ev->getMessage()), $ev->getRecipients()); } } } } return true; } public function selectHotbarSlot(int $hotbarSlot) : bool{ if(!$this->inventory->isHotbarSlot($hotbarSlot)){ //TODO: exception here? return false; } if($hotbarSlot === $this->inventory->getHeldItemIndex()){ return true; } $ev = new PlayerItemHeldEvent($this, $this->inventory->getItem($hotbarSlot), $hotbarSlot); $ev->call(); if($ev->isCancelled()){ return false; } $this->inventory->setHeldItemIndex($hotbarSlot); $this->setUsingItem(false); return true; } /** * @param Item[] $extraReturnedItems */ private function returnItemsFromAction(Item $oldHeldItem, Item $newHeldItem, array $extraReturnedItems) : void{ $heldItemChanged = false; if(!$newHeldItem->equalsExact($oldHeldItem) && $oldHeldItem->equalsExact($this->inventory->getItemInHand())){ //determine if the item was changed in some meaningful way, or just damaged/changed count //if it was really changed we always need to set it, whether we have finite resources or not $newReplica = clone $oldHeldItem; $newReplica->setCount($newHeldItem->getCount()); if($newReplica instanceof Durable && $newHeldItem instanceof Durable){ $newReplica->setDamage($newHeldItem->getDamage()); } $damagedOrDeducted = $newReplica->equalsExact($newHeldItem); if(!$damagedOrDeducted || $this->hasFiniteResources()){ if($newHeldItem instanceof Durable && $newHeldItem->isBroken()){ $this->broadcastSound(new ItemBreakSound()); } $this->inventory->setItemInHand($newHeldItem); $heldItemChanged = true; } } if(!$heldItemChanged){ $newHeldItem = $oldHeldItem; } if($heldItemChanged && count($extraReturnedItems) > 0 && $newHeldItem->isNull()){ $this->inventory->setItemInHand(array_shift($extraReturnedItems)); } foreach($this->inventory->addItem(...$extraReturnedItems) as $drop){ //TODO: we can't generate a transaction for this since the items aren't coming from an inventory :( $ev = new PlayerDropItemEvent($this, $drop); if($this->isSpectator()){ $ev->cancel(); } $ev->call(); if(!$ev->isCancelled()){ $this->dropItem($drop); } } } /** * Activates the item in hand, for example throwing a projectile. * * @return bool if it did something */ public function useHeldItem() : bool{ $directionVector = $this->getDirectionVector(); $item = $this->inventory->getItemInHand(); $oldItem = clone $item; $ev = new PlayerItemUseEvent($this, $item, $directionVector); if($this->hasItemCooldown($item) || $this->isSpectator()){ $ev->cancel(); } $ev->call(); if($ev->isCancelled()){ return false; } $returnedItems = []; $result = $item->onClickAir($this, $directionVector, $returnedItems); if($result === ItemUseResult::FAIL){ return false; } $this->resetItemCooldown($oldItem); $this->returnItemsFromAction($oldItem, $item, $returnedItems); $this->setUsingItem($item instanceof Releasable && $item->canStartUsingItem($this)); return true; } /** * Consumes the currently-held item. * * @return bool if the consumption succeeded. */ public function consumeHeldItem() : bool{ $slot = $this->inventory->getItemInHand(); if($slot instanceof ConsumableItem){ $oldItem = clone $slot; $ev = new PlayerItemConsumeEvent($this, $slot); if($this->hasItemCooldown($slot)){ $ev->cancel(); } $ev->call(); if($ev->isCancelled() || !$this->consumeObject($slot)){ return false; } $this->setUsingItem(false); $this->resetItemCooldown($oldItem); $slot->pop(); $this->returnItemsFromAction($oldItem, $slot, [$slot->getResidue()]); return true; } return false; } /** * Releases the held item, for example to fire a bow. This should be preceded by a call to useHeldItem(). * * @return bool if it did something. */ public function releaseHeldItem() : bool{ try{ $item = $this->inventory->getItemInHand(); if(!$this->isUsingItem() || $this->hasItemCooldown($item)){ return false; } $oldItem = clone $item; $returnedItems = []; $result = $item->onReleaseUsing($this, $returnedItems); if($result === ItemUseResult::SUCCESS){ $this->resetItemCooldown($oldItem); $this->returnItemsFromAction($oldItem, $item, $returnedItems); return true; } return false; }finally{ $this->setUsingItem(false); } } public function pickBlock(Vector3 $pos, bool $addTileNBT) : bool{ $block = $this->getWorld()->getBlock($pos); if($block instanceof UnknownBlock){ return true; } $item = $block->getPickedItem($addTileNBT); $ev = new PlayerBlockPickEvent($this, $block, $item); $existingSlot = $this->inventory->first($item); if($existingSlot === -1 && $this->hasFiniteResources()){ $ev->cancel(); } $ev->call(); if(!$ev->isCancelled()){ $this->equipOrAddPickedItem($existingSlot, $item); } return true; } public function pickEntity(int $entityId) : bool{ $entity = $this->getWorld()->getEntity($entityId); if($entity === null){ return true; } $item = $entity->getPickedItem(); if($item === null){ return true; } $ev = new PlayerEntityPickEvent($this, $entity, $item); $existingSlot = $this->inventory->first($item); if($existingSlot === -1 && ($this->hasFiniteResources() || $this->isSpectator())){ $ev->cancel(); } $ev->call(); if(!$ev->isCancelled()){ $this->equipOrAddPickedItem($existingSlot, $item); } return true; } private function equipOrAddPickedItem(int $existingSlot, Item $item) : void{ if($existingSlot !== -1){ if($existingSlot < $this->inventory->getHotbarSize()){ $this->inventory->setHeldItemIndex($existingSlot); }else{ $this->inventory->swap($this->inventory->getHeldItemIndex(), $existingSlot); } }else{ $firstEmpty = $this->inventory->firstEmpty(); if($firstEmpty === -1){ //full inventory $this->inventory->setItemInHand($item); }elseif($firstEmpty < $this->inventory->getHotbarSize()){ $this->inventory->setItem($firstEmpty, $item); $this->inventory->setHeldItemIndex($firstEmpty); }else{ $this->inventory->swap($this->inventory->getHeldItemIndex(), $firstEmpty); $this->inventory->setItemInHand($item); } } } /** * Performs a left-click (attack) action on the block. * * @return bool if an action took place successfully */ public function attackBlock(Vector3 $pos, int $face) : bool{ if($pos->distanceSquared($this->location) > 10000){ return false; //TODO: maybe this should throw an exception instead? } $target = $this->getWorld()->getBlock($pos); $ev = new PlayerInteractEvent($this, $this->inventory->getItemInHand(), $target, null, $face, PlayerInteractEvent::LEFT_CLICK_BLOCK); if($this->isSpectator()){ $ev->cancel(); } $ev->call(); if($ev->isCancelled()){ return false; } $this->broadcastAnimation(new ArmSwingAnimation($this), $this->getViewers()); if($target->onAttack($this->inventory->getItemInHand(), $face, $this)){ return true; } $block = $target->getSide($face); if($block->hasTypeTag(BlockTypeTags::FIRE)){ $this->getWorld()->setBlock($block->getPosition(), VanillaBlocks::AIR()); $this->getWorld()->addSound($block->getPosition()->add(0.5, 0.5, 0.5), new FireExtinguishSound()); return true; } if(!$this->isCreative() && !$target->getBreakInfo()->breaksInstantly()){ $this->blockBreakHandler = new SurvivalBlockBreakHandler($this, $pos, $target, $face, 16); } return true; } public function continueBreakBlock(Vector3 $pos, int $face) : void{ if($this->blockBreakHandler !== null && $this->blockBreakHandler->getBlockPos()->distanceSquared($pos) < 0.0001){ $this->blockBreakHandler->setTargetedFace($face); } } public function stopBreakBlock(Vector3 $pos) : void{ if($this->blockBreakHandler !== null && $this->blockBreakHandler->getBlockPos()->distanceSquared($pos) < 0.0001){ $this->blockBreakHandler = null; } } /** * Breaks the block at the given position using the currently-held item. * * @return bool if the block was successfully broken, false if a rollback needs to take place. */ public function breakBlock(Vector3 $pos) : bool{ $this->removeCurrentWindow(); if($this->canInteract($pos->add(0.5, 0.5, 0.5), $this->isCreative() ? self::MAX_REACH_DISTANCE_CREATIVE : self::MAX_REACH_DISTANCE_SURVIVAL)){ $this->broadcastAnimation(new ArmSwingAnimation($this), $this->getViewers()); $this->stopBreakBlock($pos); $item = $this->inventory->getItemInHand(); $oldItem = clone $item; $returnedItems = []; if($this->getWorld()->useBreakOn($pos, $item, $this, true, $returnedItems)){ $this->returnItemsFromAction($oldItem, $item, $returnedItems); $this->hungerManager->exhaust(0.005, PlayerExhaustEvent::CAUSE_MINING); return true; } }else{ $this->logger->debug("Cancelled block break at $pos due to not currently being interactable"); } return false; } /** * Touches the block at the given position with the currently-held item. * * @return bool if it did something */ public function interactBlock(Vector3 $pos, int $face, Vector3 $clickOffset) : bool{ $this->setUsingItem(false); if($this->canInteract($pos->add(0.5, 0.5, 0.5), $this->isCreative() ? self::MAX_REACH_DISTANCE_CREATIVE : self::MAX_REACH_DISTANCE_SURVIVAL)){ $this->broadcastAnimation(new ArmSwingAnimation($this), $this->getViewers()); $item = $this->inventory->getItemInHand(); //this is a copy of the real item $oldItem = clone $item; $returnedItems = []; if($this->getWorld()->useItemOn($pos, $item, $face, $clickOffset, $this, true, $returnedItems)){ $this->returnItemsFromAction($oldItem, $item, $returnedItems); return true; } }else{ $this->logger->debug("Cancelled interaction of block at $pos due to not currently being interactable"); } return false; } /** * Attacks the given entity with the currently-held item. * TODO: move this up the class hierarchy * * @return bool if the entity was dealt damage */ public function attackEntity(Entity $entity) : bool{ if(!$entity->isAlive()){ return false; } if($entity instanceof ItemEntity || $entity instanceof Arrow){ $this->logger->debug("Attempted to attack non-attackable entity " . get_class($entity)); return false; } $heldItem = $this->inventory->getItemInHand(); $oldItem = clone $heldItem; $ev = new EntityDamageByEntityEvent($this, $entity, EntityDamageEvent::CAUSE_ENTITY_ATTACK, $heldItem->getAttackPoints()); if(!$this->canInteract($entity->getLocation(), self::MAX_REACH_DISTANCE_ENTITY_INTERACTION)){ $this->logger->debug("Cancelled attack of entity " . $entity->getId() . " due to not currently being interactable"); $ev->cancel(); }elseif($this->isSpectator() || ($entity instanceof Player && !$this->server->getConfigGroup()->getConfigBool(ServerProperties::PVP))){ $ev->cancel(); } $meleeEnchantmentDamage = 0; /** @var EnchantmentInstance[] $meleeEnchantments */ $meleeEnchantments = []; foreach($heldItem->getEnchantments() as $enchantment){ $type = $enchantment->getType(); if($type instanceof MeleeWeaponEnchantment && $type->isApplicableTo($entity)){ $meleeEnchantmentDamage += $type->getDamageBonus($enchantment->getLevel()); $meleeEnchantments[] = $enchantment; } } $ev->setModifier($meleeEnchantmentDamage, EntityDamageEvent::MODIFIER_WEAPON_ENCHANTMENTS); if(!$this->isSprinting() && !$this->isFlying() && $this->fallDistance > 0 && !$this->effectManager->has(VanillaEffects::BLINDNESS()) && !$this->isUnderwater()){ $ev->setModifier($ev->getFinalDamage() / 2, EntityDamageEvent::MODIFIER_CRITICAL); } $entity->attack($ev); $this->broadcastAnimation(new ArmSwingAnimation($this), $this->getViewers()); $soundPos = $entity->getPosition()->add(0, $entity->size->getHeight() / 2, 0); if($ev->isCancelled()){ $this->getWorld()->addSound($soundPos, new EntityAttackNoDamageSound()); return false; } $this->getWorld()->addSound($soundPos, new EntityAttackSound()); if($ev->getModifier(EntityDamageEvent::MODIFIER_CRITICAL) > 0 && $entity instanceof Living){ $entity->broadcastAnimation(new CriticalHitAnimation($entity)); } foreach($meleeEnchantments as $enchantment){ $type = $enchantment->getType(); assert($type instanceof MeleeWeaponEnchantment); $type->onPostAttack($this, $entity, $enchantment->getLevel()); } if($this->isAlive()){ //reactive damage like thorns might cause us to be killed by attacking another mob, which //would mean we'd already have dropped the inventory by the time we reached here $returnedItems = []; $heldItem->onAttackEntity($entity, $returnedItems); $this->returnItemsFromAction($oldItem, $heldItem, $returnedItems); $this->hungerManager->exhaust(0.1, PlayerExhaustEvent::CAUSE_ATTACK); } return true; } /** * Performs actions associated with the attack action (left-click) without a target entity. * Under normal circumstances, this will just play the no-damage attack sound and the arm-swing animation. */ public function missSwing() : void{ $ev = new PlayerMissSwingEvent($this); $ev->call(); if(!$ev->isCancelled()){ $this->broadcastSound(new EntityAttackNoDamageSound()); $this->broadcastAnimation(new ArmSwingAnimation($this), $this->getViewers()); } } /** * Interacts with the given entity using the currently-held item. */ public function interactEntity(Entity $entity, Vector3 $clickPos) : bool{ $ev = new PlayerEntityInteractEvent($this, $entity, $clickPos); if(!$this->canInteract($entity->getLocation(), self::MAX_REACH_DISTANCE_ENTITY_INTERACTION)){ $this->logger->debug("Cancelled interaction with entity " . $entity->getId() . " due to not currently being interactable"); $ev->cancel(); } $ev->call(); $item = $this->inventory->getItemInHand(); $oldItem = clone $item; if(!$ev->isCancelled()){ if($item->onInteractEntity($this, $entity, $clickPos)){ if($this->hasFiniteResources() && !$item->equalsExact($oldItem) && $oldItem->equalsExact($this->inventory->getItemInHand())){ if($item instanceof Durable && $item->isBroken()){ $this->broadcastSound(new ItemBreakSound()); } $this->inventory->setItemInHand($item); } } return $entity->onInteract($this, $clickPos); } return false; } public function toggleSprint(bool $sprint) : bool{ if($sprint === $this->sprinting){ return true; } $ev = new PlayerToggleSprintEvent($this, $sprint); $ev->call(); if($ev->isCancelled()){ return false; } $this->setSprinting($sprint); return true; } public function toggleSneak(bool $sneak) : bool{ if($sneak === $this->sneaking){ return true; } $ev = new PlayerToggleSneakEvent($this, $sneak); $ev->call(); if($ev->isCancelled()){ return false; } $this->setSneaking($sneak); return true; } public function toggleFlight(bool $fly) : bool{ if($fly === $this->flying){ return true; } $ev = new PlayerToggleFlightEvent($this, $fly); if(!$this->allowFlight){ $ev->cancel(); } $ev->call(); if($ev->isCancelled()){ return false; } $this->setFlying($fly); return true; } public function toggleGlide(bool $glide) : bool{ if($glide === $this->gliding){ return true; } $ev = new PlayerToggleGlideEvent($this, $glide); $ev->call(); if($ev->isCancelled()){ return false; } $this->setGliding($glide); return true; } public function toggleSwim(bool $swim) : bool{ if($swim === $this->swimming){ return true; } $ev = new PlayerToggleSwimEvent($this, $swim); $ev->call(); if($ev->isCancelled()){ return false; } $this->setSwimming($swim); return true; } public function emote(string $emoteId) : void{ $currentTick = $this->server->getTick(); if($currentTick - $this->lastEmoteTick > 5){ $this->lastEmoteTick = $currentTick; $event = new PlayerEmoteEvent($this, $emoteId); $event->call(); if(!$event->isCancelled()){ $emoteId = $event->getEmoteId(); parent::emote($emoteId); } } } /** * Drops an item on the ground in front of the player. */ public function dropItem(Item $item) : void{ $this->broadcastAnimation(new ArmSwingAnimation($this), $this->getViewers()); $this->getWorld()->dropItem($this->location->add(0, 1.3, 0), $item, $this->getDirectionVector()->multiply(0.4), 40); } /** * Adds a title text to the user's screen, with an optional subtitle. * * @param int $fadeIn Duration in ticks for fade-in. If -1 is given, client-sided defaults will be used. * @param int $stay Duration in ticks to stay on screen for * @param int $fadeOut Duration in ticks for fade-out. */ public function sendTitle(string $title, string $subtitle = "", int $fadeIn = -1, int $stay = -1, int $fadeOut = -1) : void{ $this->setTitleDuration($fadeIn, $stay, $fadeOut); if($subtitle !== ""){ $this->sendSubTitle($subtitle); } $this->getNetworkSession()->onTitle($title); } /** * Sets the subtitle message, without sending a title. */ public function sendSubTitle(string $subtitle) : void{ $this->getNetworkSession()->onSubTitle($subtitle); } /** * Adds small text to the user's screen. */ public function sendActionBarMessage(string $message) : void{ $this->getNetworkSession()->onActionBar($message); } /** * Removes the title from the client's screen. */ public function removeTitles() : void{ $this->getNetworkSession()->onClearTitle(); } /** * Resets the title duration settings to defaults and removes any existing titles. */ public function resetTitles() : void{ $this->getNetworkSession()->onResetTitleOptions(); } /** * Sets the title duration. * * @param int $fadeIn Title fade-in time in ticks. * @param int $stay Title stay time in ticks. * @param int $fadeOut Title fade-out time in ticks. */ public function setTitleDuration(int $fadeIn, int $stay, int $fadeOut) : void{ if($fadeIn >= 0 && $stay >= 0 && $fadeOut >= 0){ $this->getNetworkSession()->onTitleDuration($fadeIn, $stay, $fadeOut); } } /** * Sends a direct chat message to a player */ public function sendMessage(Translatable|string $message) : void{ $this->getNetworkSession()->onChatMessage($message); } public function sendJukeboxPopup(Translatable|string $message) : void{ $this->getNetworkSession()->onJukeboxPopup($message); } /** * Sends a popup message to the player * * TODO: add translation type popups */ public function sendPopup(string $message) : void{ $this->getNetworkSession()->onPopup($message); } public function sendTip(string $message) : void{ $this->getNetworkSession()->onTip($message); } /** * Sends a toast message to the player, or queue to send it if a toast message is already shown. */ public function sendToastNotification(string $title, string $body) : void{ $this->getNetworkSession()->onToastNotification($title, $body); } /** * Sends a Form to the player, or queue to send it if a form is already open. * * @throws \InvalidArgumentException */ public function sendForm(Form $form) : void{ $id = $this->formIdCounter++; if($this->getNetworkSession()->onFormSent($id, $form)){ $this->forms[$id] = $form; } } public function onFormSubmit(int $formId, mixed $responseData) : bool{ if(!isset($this->forms[$formId])){ $this->logger->debug("Got unexpected response for form $formId"); return false; } try{ $this->forms[$formId]->handleResponse($this, $responseData); }catch(FormValidationException $e){ $this->logger->critical("Failed to validate form " . get_class($this->forms[$formId]) . ": " . $e->getMessage()); $this->logger->logException($e); }finally{ unset($this->forms[$formId]); } return true; } /** * Closes the current viewing form and forms in queue. */ public function closeAllForms() : void{ $this->getNetworkSession()->onCloseAllForms(); } /** * Transfers a player to another server. * * @param string $address The IP address or hostname of the destination server * @param int $port The destination port, defaults to 19132 * @param Translatable|string|null $message Message to show in the console when closing the player, null will use the default message * * @return bool if transfer was successful. */ public function transfer(string $address, int $port = 19132, Translatable|string|null $message = null) : bool{ $ev = new PlayerTransferEvent($this, $address, $port, $message ?? KnownTranslationFactory::pocketmine_disconnect_transfer()); $ev->call(); if(!$ev->isCancelled()){ $this->getNetworkSession()->transfer($ev->getAddress(), $ev->getPort(), $ev->getMessage()); return true; } return false; } /** * Kicks a player from the server * * @param Translatable|string $reason Shown in the server log - this should be a short one-line message * @param Translatable|string|null $quitMessage Message to broadcast to online players (null will use default) * @param Translatable|string|null $disconnectScreenMessage Shown on the player's disconnection screen (null will use the reason) */ public function kick(Translatable|string $reason = "", Translatable|string|null $quitMessage = null, Translatable|string|null $disconnectScreenMessage = null) : bool{ $ev = new PlayerKickEvent($this, $reason, $quitMessage ?? $this->getLeaveMessage(), $disconnectScreenMessage); $ev->call(); if(!$ev->isCancelled()){ $reason = $ev->getDisconnectReason(); if($reason === ""){ $reason = KnownTranslationFactory::disconnectionScreen_noReason(); } $disconnectScreenMessage = $ev->getDisconnectScreenMessage() ?? $reason; if($disconnectScreenMessage === ""){ $disconnectScreenMessage = KnownTranslationFactory::disconnectionScreen_noReason(); } $this->disconnect($reason, $ev->getQuitMessage(), $disconnectScreenMessage); return true; } return false; } /** * Removes the player from the server. This cannot be cancelled. * This is used for remote disconnects and for uninterruptible disconnects (for example, when the server shuts down). * * Note for plugin developers: Prefer kick() instead of this method. * That way other plugins can have a say in whether the player is removed or not. * * Note for internals developers: Do not call this from network sessions. It will cause a feedback loop. * * @param Translatable|string $reason Shown in the server log - this should be a short one-line message * @param Translatable|string|null $quitMessage Message to broadcast to online players (null will use default) * @param Translatable|string|null $disconnectScreenMessage Shown on the player's disconnection screen (null will use the reason) */ public function disconnect(Translatable|string $reason, Translatable|string|null $quitMessage = null, Translatable|string|null $disconnectScreenMessage = null) : void{ if(!$this->isConnected()){ return; } $this->getNetworkSession()->onPlayerDestroyed($reason, $disconnectScreenMessage ?? $reason); $this->onPostDisconnect($reason, $quitMessage); } /** * @internal * This method executes post-disconnect actions and cleanups. * * @param Translatable|string $reason Shown in the server log - this should be a short one-line message * @param Translatable|string|null $quitMessage Message to broadcast to online players (null will use default) */ public function onPostDisconnect(Translatable|string $reason, Translatable|string|null $quitMessage) : void{ if($this->isConnected()){ throw new \LogicException("Player is still connected"); } //prevent the player receiving their own disconnect message $this->server->unsubscribeFromAllBroadcastChannels($this); $this->removeCurrentWindow(); $ev = new PlayerQuitEvent($this, $quitMessage ?? $this->getLeaveMessage(), $reason); $ev->call(); if(($quitMessage = $ev->getQuitMessage()) !== ""){ $this->server->broadcastMessage($quitMessage); } $this->save(); $this->spawned = false; $this->stopSleep(); $this->blockBreakHandler = null; $this->despawnFromAll(); $this->server->removeOnlinePlayer($this); foreach($this->server->getOnlinePlayers() as $player){ if(!$player->canSee($this)){ $player->showPlayer($this); } } $this->hiddenPlayers = []; if($this->location->isValid()){ foreach($this->usedChunks as $index => $status){ World::getXZ($index, $chunkX, $chunkZ); $this->unloadChunk($chunkX, $chunkZ); } } if(count($this->usedChunks) !== 0){ throw new AssumptionFailedError("Previous loop should have cleared this array"); } $this->loadQueue = []; $this->removeCurrentWindow(); $this->removePermanentInventories(); $this->perm->getPermissionRecalculationCallbacks()->clear(); $this->flagForDespawn(); } protected function onDispose() : void{ $this->disconnect("Player destroyed"); $this->cursorInventory->removeAllViewers(); $this->craftingGrid->removeAllViewers(); parent::onDispose(); } protected function destroyCycles() : void{ $this->networkSession = null; unset($this->cursorInventory); unset($this->craftingGrid); $this->spawnPosition = null; $this->deathPosition = null; $this->blockBreakHandler = null; parent::destroyCycles(); } /** * @return mixed[] */ public function __debugInfo() : array{ return []; } public function __destruct(){ parent::__destruct(); $this->logger->debug("Destroyed by garbage collector"); } public function canSaveWithChunk() : bool{ return false; } public function setCanSaveWithChunk(bool $value) : void{ throw new \BadMethodCallException("Players can't be saved with chunks"); } public function getSaveData() : CompoundTag{ $nbt = $this->saveNBT(); $nbt->setString(self::TAG_LAST_KNOWN_XUID, $this->xuid); if($this->location->isValid()){ $nbt->setString(self::TAG_LEVEL, $this->getWorld()->getFolderName()); } if($this->hasValidCustomSpawn()){ $spawn = $this->getSpawn(); $nbt->setString(self::TAG_SPAWN_WORLD, $spawn->getWorld()->getFolderName()); $nbt->setInt(self::TAG_SPAWN_X, $spawn->getFloorX()); $nbt->setInt(self::TAG_SPAWN_Y, $spawn->getFloorY()); $nbt->setInt(self::TAG_SPAWN_Z, $spawn->getFloorZ()); } if($this->deathPosition !== null && $this->deathPosition->isValid()){ $nbt->setString(self::TAG_DEATH_WORLD, $this->deathPosition->getWorld()->getFolderName()); $nbt->setInt(self::TAG_DEATH_X, $this->deathPosition->getFloorX()); $nbt->setInt(self::TAG_DEATH_Y, $this->deathPosition->getFloorY()); $nbt->setInt(self::TAG_DEATH_Z, $this->deathPosition->getFloorZ()); } $nbt->setInt(self::TAG_GAME_MODE, GameModeIdMap::getInstance()->toId($this->gamemode)); $nbt->setLong(self::TAG_FIRST_PLAYED, (int) $this->firstPlayed->format('Uv')); $nbt->setLong(self::TAG_LAST_PLAYED, (int) floor(microtime(true) * 1000)); return $nbt; } /** * Handles player data saving */ public function save() : void{ $this->server->saveOfflinePlayerData($this->username, $this->getSaveData()); } protected function onDeath() : void{ //Crafting grid must always be evacuated even if keep-inventory is true. This dumps the contents into the //main inventory and drops the rest on the ground. $this->removeCurrentWindow(); $this->setDeathPosition($this->getPosition()); $ev = new PlayerDeathEvent($this, $this->getDrops(), $this->getXpDropAmount(), null); $ev->call(); if(!$ev->getKeepInventory()){ foreach($ev->getDrops() as $item){ $this->getWorld()->dropItem($this->location, $item); } $clearInventory = fn(Inventory $inventory) => $inventory->setContents(array_filter($inventory->getContents(), fn(Item $item) => $item->keepOnDeath())); $this->inventory->setHeldItemIndex(0); $clearInventory($this->inventory); $clearInventory($this->armorInventory); $clearInventory($this->offHandInventory); } if(!$ev->getKeepXp()){ $this->getWorld()->dropExperience($this->location, $ev->getXpDropAmount()); $this->xpManager->setXpAndProgress(0, 0.0); } if($ev->getDeathMessage() !== ""){ $this->server->broadcastMessage($ev->getDeathMessage()); } $this->startDeathAnimation(); $this->getNetworkSession()->onServerDeath($ev->getDeathScreenMessage()); } protected function onDeathUpdate(int $tickDiff) : bool{ parent::onDeathUpdate($tickDiff); return false; //never flag players for despawn } public function respawn() : void{ if($this->server->isHardcore()){ if($this->kick(KnownTranslationFactory::pocketmine_disconnect_ban(KnownTranslationFactory::pocketmine_disconnect_ban_hardcore()))){ //this allows plugins to prevent the ban by cancelling PlayerKickEvent $this->server->getNameBans()->addBan($this->getName(), "Died in hardcore mode"); } return; } $this->actuallyRespawn(); } protected function actuallyRespawn() : void{ if($this->respawnLocked){ return; } $this->respawnLocked = true; $this->logger->debug("Waiting for safe respawn position to be located"); $spawn = $this->getSpawn(); $spawn->getWorld()->requestSafeSpawn($spawn)->onCompletion( function(Position $safeSpawn) : void{ if(!$this->isConnected()){ return; } $this->logger->debug("Respawn position located, completing respawn"); $ev = new PlayerRespawnEvent($this, $safeSpawn); $ev->call(); $realSpawn = Position::fromObject($ev->getRespawnPosition()->add(0.5, 0, 0.5), $ev->getRespawnPosition()->getWorld()); $this->teleport($realSpawn); $this->setSprinting(false); $this->setSneaking(false); $this->setFlying(false); $this->extinguish(); $this->setAirSupplyTicks($this->getMaxAirSupplyTicks()); $this->deadTicks = 0; $this->noDamageTicks = 60; $this->effectManager->clear(); $this->setHealth($this->getMaxHealth()); foreach($this->attributeMap->getAll() as $attr){ if($attr->getId() === Attribute::EXPERIENCE || $attr->getId() === Attribute::EXPERIENCE_LEVEL){ //we have already reset both of those if needed when the player died continue; } $attr->resetToDefault(); } $this->spawnToAll(); $this->scheduleUpdate(); $this->getNetworkSession()->onServerRespawn(); $this->respawnLocked = false; }, function() : void{ if($this->isConnected()){ $this->getNetworkSession()->disconnectWithError(KnownTranslationFactory::pocketmine_disconnect_error_respawn()); } } ); } protected function applyPostDamageEffects(EntityDamageEvent $source) : void{ parent::applyPostDamageEffects($source); $this->hungerManager->exhaust(0.1, PlayerExhaustEvent::CAUSE_DAMAGE); } public function attack(EntityDamageEvent $source) : void{ if(!$this->isAlive()){ return; } if($this->isCreative() && $source->getCause() !== EntityDamageEvent::CAUSE_SUICIDE ){ $source->cancel(); }elseif($this->allowFlight && $source->getCause() === EntityDamageEvent::CAUSE_FALL){ $source->cancel(); } parent::attack($source); } protected function syncNetworkData(EntityMetadataCollection $properties) : void{ parent::syncNetworkData($properties); $properties->setGenericFlag(EntityMetadataFlags::ACTION, $this->startAction > -1); $properties->setGenericFlag(EntityMetadataFlags::HAS_COLLISION, $this->hasBlockCollision()); $properties->setPlayerFlag(PlayerMetadataFlags::SLEEP, $this->sleeping !== null); $properties->setBlockPos(EntityMetadataProperties::PLAYER_BED_POSITION, $this->sleeping !== null ? BlockPosition::fromVector3($this->sleeping) : new BlockPosition(0, 0, 0)); if($this->deathPosition !== null && $this->deathPosition->world === $this->location->world){ $properties->setBlockPos(EntityMetadataProperties::PLAYER_DEATH_POSITION, BlockPosition::fromVector3($this->deathPosition)); //TODO: this should be updated when dimensions are implemented $properties->setInt(EntityMetadataProperties::PLAYER_DEATH_DIMENSION, DimensionIds::OVERWORLD); $properties->setByte(EntityMetadataProperties::PLAYER_HAS_DIED, 1); }else{ $properties->setBlockPos(EntityMetadataProperties::PLAYER_DEATH_POSITION, new BlockPosition(0, 0, 0)); $properties->setInt(EntityMetadataProperties::PLAYER_DEATH_DIMENSION, DimensionIds::OVERWORLD); $properties->setByte(EntityMetadataProperties::PLAYER_HAS_DIED, 0); } } public function sendData(?array $targets, ?array $data = null) : void{ if($targets === null){ $targets = $this->getViewers(); $targets[] = $this; } parent::sendData($targets, $data); } public function broadcastAnimation(Animation $animation, ?array $targets = null) : void{ if($this->spawned && $targets === null){ $targets = $this->getViewers(); $targets[] = $this; } parent::broadcastAnimation($animation, $targets); } public function broadcastSound(Sound $sound, ?array $targets = null) : void{ if($this->spawned && $targets === null){ $targets = $this->getViewers(); $targets[] = $this; } parent::broadcastSound($sound, $targets); } /** * TODO: remove this */ protected function sendPosition(Vector3 $pos, ?float $yaw = null, ?float $pitch = null, int $mode = MovePlayerPacket::MODE_NORMAL) : void{ $this->getNetworkSession()->syncMovement($pos, $yaw, $pitch, $mode); $this->ySize = 0; } public function teleport(Vector3 $pos, ?float $yaw = null, ?float $pitch = null) : bool{ if(parent::teleport($pos, $yaw, $pitch)){ $this->removeCurrentWindow(); $this->stopSleep(); $this->sendPosition($this->location, $this->location->yaw, $this->location->pitch, MovePlayerPacket::MODE_TELEPORT); $this->broadcastMovement(true); $this->spawnToAll(); $this->resetFallDistance(); $this->nextChunkOrderRun = 0; if($this->spawnChunkLoadCount !== -1){ $this->spawnChunkLoadCount = 0; } $this->blockBreakHandler = null; //TODO: workaround for player last pos not getting updated //Entity::updateMovement() normally handles this, but it's overridden with an empty function in Player $this->resetLastMovements(); return true; } return false; } protected function addDefaultWindows() : void{ $this->cursorInventory = new PlayerCursorInventory($this); $this->craftingGrid = new PlayerCraftingInventory($this); $this->addPermanentInventories($this->inventory, $this->armorInventory, $this->cursorInventory, $this->offHandInventory, $this->craftingGrid); //TODO: more windows } public function getCursorInventory() : PlayerCursorInventory{ return $this->cursorInventory; } public function getCraftingGrid() : CraftingGrid{ return $this->craftingGrid; } /** * Returns the creative inventory shown to the player. * Unless changed by a plugin, this is usually the same for all players. */ public function getCreativeInventory() : CreativeInventory{ return $this->creativeInventory; } /** * To set a custom creative inventory, you need to make a clone of a CreativeInventory instance. */ public function setCreativeInventory(CreativeInventory $inventory) : void{ $this->creativeInventory = $inventory; if($this->spawned && $this->isConnected()){ $this->getNetworkSession()->getInvManager()?->syncCreative(); } } /** * @internal Called to clean up crafting grid and cursor inventory when it is detected that the player closed their * inventory. */ private function doCloseInventory() : void{ $inventories = [$this->craftingGrid, $this->cursorInventory]; if($this->currentWindow instanceof TemporaryInventory){ $inventories[] = $this->currentWindow; } $builder = new TransactionBuilder(); foreach($inventories as $inventory){ $contents = $inventory->getContents(); if(count($contents) > 0){ $drops = $builder->getInventory($this->inventory)->addItem(...$contents); foreach($drops as $drop){ $builder->addAction(new DropItemAction($drop)); } $builder->getInventory($inventory)->clearAll(); } } $actions = $builder->generateActions(); if(count($actions) !== 0){ $transaction = new InventoryTransaction($this, $actions); try{ $transaction->execute(); $this->logger->debug("Successfully evacuated items from temporary inventories"); }catch(TransactionCancelledException){ $this->logger->debug("Plugin cancelled transaction evacuating items from temporary inventories; items will be destroyed"); foreach($inventories as $inventory){ $inventory->clearAll(); } }catch(TransactionValidationException $e){ throw new AssumptionFailedError("This server-generated transaction should never be invalid", 0, $e); } } } /** * Returns the inventory the player is currently viewing. This might be a chest, furnace, or any other container. */ public function getCurrentWindow() : ?Inventory{ return $this->currentWindow; } /** * Opens an inventory window to the player. Returns if it was successful. */ public function setCurrentWindow(Inventory $inventory) : bool{ if($inventory === $this->currentWindow){ return true; } $ev = new InventoryOpenEvent($inventory, $this); $ev->call(); if($ev->isCancelled()){ return false; } $this->removeCurrentWindow(); if(($inventoryManager = $this->getNetworkSession()->getInvManager()) === null){ throw new \InvalidArgumentException("Player cannot open inventories in this state"); } $this->logger->debug("Opening inventory " . get_class($inventory) . "#" . spl_object_id($inventory)); $inventoryManager->onCurrentWindowChange($inventory); $inventory->onOpen($this); $this->currentWindow = $inventory; return true; } public function removeCurrentWindow() : void{ $this->doCloseInventory(); if($this->currentWindow !== null){ $currentWindow = $this->currentWindow; $this->logger->debug("Closing inventory " . get_class($this->currentWindow) . "#" . spl_object_id($this->currentWindow)); $this->currentWindow->onClose($this); if(($inventoryManager = $this->getNetworkSession()->getInvManager()) !== null){ $inventoryManager->onCurrentWindowRemove(); } $this->currentWindow = null; (new InventoryCloseEvent($currentWindow, $this))->call(); } } protected function addPermanentInventories(Inventory ...$inventories) : void{ foreach($inventories as $inventory){ $inventory->onOpen($this); $this->permanentWindows[spl_object_id($inventory)] = $inventory; } } protected function removePermanentInventories() : void{ foreach($this->permanentWindows as $inventory){ $inventory->onClose($this); } $this->permanentWindows = []; } /** * Opens the player's sign editor GUI for the sign at the given position. * TODO: add support for editing the rear side of the sign (not currently supported due to technical limitations) */ public function openSignEditor(Vector3 $position) : void{ $block = $this->getWorld()->getBlock($position); if($block instanceof BaseSign){ $this->getWorld()->setBlock($position, $block->setEditorEntityRuntimeId($this->getId())); $this->getNetworkSession()->onOpenSignEditor($position, true); }else{ throw new \InvalidArgumentException("Block at this position is not a sign"); } } use ChunkListenerNoOpTrait { onChunkChanged as private; onChunkUnloaded as private; } public function onChunkChanged(int $chunkX, int $chunkZ, Chunk $chunk) : void{ $status = $this->usedChunks[$hash = World::chunkHash($chunkX, $chunkZ)] ?? null; if($status === UsedChunkStatus::SENT){ $this->usedChunks[$hash] = UsedChunkStatus::NEEDED; $this->nextChunkOrderRun = 0; } } public function onChunkUnloaded(int $chunkX, int $chunkZ, Chunk $chunk) : void{ if($this->isUsingChunk($chunkX, $chunkZ)){ $this->logger->debug("Detected forced unload of chunk " . $chunkX . " " . $chunkZ); $this->unloadChunk($chunkX, $chunkZ); } } }